# 진단 보고서 — 학습형 제어 (작업 1~4)

> 진단 일시: 2026-06-05  
> 진단 규칙: `diagnosis-checklist.md` 8단계  
> 진단 범위: `scripts/analysis/` (run_column, shutdown, operator_assist, export_model) + `src/Infrastructure/Control/SteamAdvisor.cs` + `src/Hc900Crawler/Controllers/SteamAdvisorController.cs` + `Program.cs` 수정사항  
> 이전 보고서: `docs/진단보고서-학습형제어-1차.md` (작업지시서 문서 진단, c6111_*.py 기존 분석기)

---

## 🔴 HIGH — 없음

---

## 🟠 MED

### M1. `SteamAdvisor.Predict()` 반환값 `Ood`/`InEnv` 하드코딩 오류

**문제**: PROD 모드 분기에서 `inEnv=false`여도 반환 `SteamAdvisoryResult`의 `Ood=false`, `InEnv=true`로 항상 고정. 클라이언트가 `confidence`가 아닌 `Ood`/`InEnv`로 판단하면 범위밖 입력을 구간내로 오인. `message`("⚠ 범위밖 입력")와 `confidence`("LOW_OOD")는 정상이나, 구조화 필드가 실제와 불일치.  
**근거**: `src/Infrastructure/Control/SteamAdvisor.cs:107` — `Ood = false, InEnv = true` 하드코딩. 의도된 값은 `Ood = !inEnv, InEnv = inEnv`.  
**영향**: `confidence`는 정상이나 `ood:false, inEnv:true`로 응답받은 API 클라이언트가 advisory를 수용할 위험. 또는 `confidence`만 보고 무시하더라도 데이터 파이프라인(로깅·추세)에 잘못된 envelope 정보 기록.  
**수정**: 1줄 변경.
```csharp
// before (SteamAdvisor.cs:107)
Ood = false, InEnv = true,
// after
Ood = !inEnv, InEnv = inEnv,
```

---

### M2. `c6111_shutdown.py` 피드감소 시작점 탐색창 부족

**문제**: `shutdown_milestones()`의 feed 감소 시작 탐색이 600행(5시간) lookback으로 제한. P9/P10처럼 선행 피드감소 시간이 긴 shutdown 이벤트에서 탐색창 끝에 도달해도 feed 감소 미발견 → `feed_to_cutoff=299.5분`(최대값) 기록.  
**근거**: `scripts/analysis/c6111_shutdown.py:39` — `for j in range(co, max(0, co - 600), -1)`. 600행 = 5시간(30s 주기 기준). P9 5/22건이 299.5분 기록.  
**영향**: 해당 건의 `feed_to_cutoff`가 실제보다 짧게 추정되어 셧다운 레시피의 "피드감소→컷오프" 중앙값 왜곡(현재 55분 → 실제는 더 길 수 있음).  
**수정**: lookback을 `co - 1200`(10시간)으로 확장. 또는 feed 안정화 판정(`rolling std < threshold`)을 동적 종료 조건으로 추가.
```python
# c6111_shutdown.py:39
for j in range(co, max(0, co - 1200), -1):
```

---

### M3. `SteamAdvisor.Predict()` NaN 입력 무방비

**문제**: `feed`·`product`·`tC` 인자가 `double.NaN`이면 선형 계산 `NaN` → `PolyVal(NaN)` → `NaN` → `Math.Clamp(NaN, 0, 100) = NaN` → HTTP 응답 `rec_OP: NaN`. 라이브 데이터에서 HC900 통신 끊김 등으로 quality 불량 시 NaN이 유입되면 API가 NaN을 그대로 반환.  
**근거**: `src/Infrastructure/Control/SteamAdvisor.cs:65-108` — Predict() 입구에 NaN 사전 차단 없음. `double.IsNaN()` 검증 생략.  
**영향**: `GET /api/steam/predict?feed=NaN&product=300&tC=85` → `{"rec_OP": NaN, ...}`. JSON에 `NaN`은 유효하지 않아 HTTP 500 또는 클라이언트 파싱 실패 유발.  
**수정**: Predict() 선두에 NaN 가드.
```csharp
if (double.IsNaN(feed) || double.IsNaN(product) || double.IsNaN(tC))
    return new SteamAdvisoryResult {
        Message = "입력값에 NaN 포함", Confidence = "N/A",
        Mode = "INVALID", Feed = feed, Product = product, TC = tC };
```

---

## 🟡 LOW

### L4. Python `BASE` 경로 하드코딩 7개 파일

**문제**: `c6111_shutdown.py`·`operator_assist.py`·`export_model.py`·`prodmap.py`·`shadow.py`·`startup.py`·`rolling.py` 7개 파일에 `BASE = "/home/windpacer/projects/hc900_ax/scripts/analysis/"` 절대경로 하드코딩. `run_column.py`만 동적(`os.path.dirname(os.path.abspath(__file__))`) 사용.  
**근거**: 각 파일의 `BASE` 상수 선언부.  
**영향**: 저장소 이동 시 plot 저장 실패. 단, `--data`/`--prefix` CLI 인자로 runtime override 가능.  
**수정**: `run_column.py`와 통일하여 `os.path.dirname(os.path.abspath(__file__))`로 변경.

### L5. `SteamAdvisor.PolyVal` 계수 부족 시 pass-through (무경고)

**문제**: `ValvePoly` 계수가 4개 미만이면 `coeffs[0]*x^3 + ...` 계산 시 IndexOutOfRange 전에 `if (coeffs.Count < 4) return x;`로 빠짐 → 로그 없이 입력값을 OP로 반환. export 불량 발견 불가.  
**근거**: `SteamAdvisor.cs:140-141`.  
**영향**: valve poly가 3차(4계수)가 아닌 모델을 로드해도 OP 계산 오류를 감지 불가.  
**수정**: 경고 로그 추가. `_logger.LogWarning(...)` 후 pass-through.

### L6. 컨트롤러 입력 검증 속성 누락

**문제**: `SteamAdvisorController`의 `[FromQuery] double feed` 등에 `[Required]`·`[Range]` 속성 없음. `POST /api/steam/predict` body `SteamPredictBody`에도 검증 속성 없음. 인자 생략 시 `0.0` 자동 전달.  
**근거**: `src/Hc900Crawler/Controllers/SteamAdvisorController.cs:23,33,41`.  
**영향**: 생략된 인자가 0으로 전달되어 비현실적인 권장 OP 산출.  
**수정**: `[FromQuery]` 파라미터에 `[Required]` 추가, `PredictRequest`에 `[Range(0, double.MaxValue)]` 속성.

### L7. `run_column.compare()` cutin 탐지 로직 중복

**문제**: `compare()` 함수 내에 `startup.py/detect_cutins`와 동일한 product 상향엣지 탐지 로직이 인라인 복사되어 있음. `startup.py`의 `detect_cutins` 로직이 변경되면 `compare()`의 결과와 불일치 발생.  
**근거**: `run_column.py:103-112` vs `c6111_startup.py:16-31`. 96% 동일 코드.  
**영향**: startup 탐지 알고리즘 개선 시 비교표가 업데이트되지 않아 검증 결과 혼선.  
**수정**: `compare()`에서 `from c6111_startup import detect_cutins` 호출로 대체.

### L8. `c6111_export_model.py` R² on training set

**문제**: `LinearRegression.score()`와 `GradientBoostingRegressor.score()`가 모두 `fit()`에 사용한 동일 데이터셋으로 평가. train/test 분할 없어 R²가 과대추정됨. export된 JSON의 `linear_r2`와 `gbm_r2`가 실제 일반화 성능보다 높게 표시.  
**근거**: `c6111_export_model.py:39,55`. `ops[FEATURES]`를 fit과 score에 동일 사용.  
**영향**: C# 측에서 `SteamModel.LinearR2`를 신뢰도 지표로 사용 시 과신. 현행 C# 코드는 R²를 비즈니스 로직에 사용하지 않아 실질 영향 없음.  
**수정**: `train_test_split`으로 분할 후 held-out R²도 export.

### L9. `run_column.py` DSN 평문 하드코딩

**문제**: PostgreSQL 연결문자열 `"host=localhost port=5432 dbname=field_hist user=postgres password=postgres"`가 소스코드에 평문 하드코딩. 버전관리 대상 파일.  
**근거**: `run_column.py:28`.  
**영향**: Dev DB 전용(로컬 PG16 컨테이너), 실제 민감정보 아님. 단, 리포지토리가 외부에 공개될 때 내부 DB 구조 노출.  
**수정**: 환경변수 `PG_DSN` 또는 `.env` 파일에서 읽도록 변경.

### L10. `c6111_operator_assist.py` unused import

**문제**: `import pickle`이 파일 상단에 선언되었으나 코드 어디에서도 `pickle`을 사용하지 않음.  
**근거**: `c6111_operator_assist.py:10`. 파일 내 `pickle.` 호출 0건.  
**영향**: 없음(컴파일 타임 영향 없음).  
**수정**: `import pickle` 삭제.

### L11. shutdown 컷오프 0건 시 플롯 루프 비정상

**문제**: `detect_cutoffs()`가 빈 리스트 반환 시 `for k, w in enumerate(windows)` 루프가 0회 반복 → plot이 비어 있음. matplotlib 경고(`UserWarning: No artists with labels...`) 발생.  
**근거**: `c6111_shutdown.py:110-118`. `windows`가 빈 리스트일 때 plot만 생성되고 아무것도 그려지지 않음.  
**영향**: P10 등 shutdown 이벤트 없는 컬럼에서 빈 플롯 파일 생성. 기능상 문제는 없으나 사용자 혼란.  
**수정**: `if not windows: print("  [skip] shutdown 이벤트 없음"); return` 추가.

### L12. `PredictAsync` 동기 구현 — CancellationToken 미사용

**문제**: `PredictAsync()`가 항상 `Task.FromResult(Predict(...))`로 동기 실행. `CancellationToken` 파라미터를 전혀 사용하지 않음.  
**근거**: `SteamAdvisor.cs:112-116`.  
**영향**: 현재는 항상 동기(fast operation)여서 실질 문제 없음. 비동기 체인에서 호출 시 불필요한 Task 할당만 발생.  
**수정**: `Task.FromResult` 대신 `ValueTask.FromResult` 사용하거나, 실제 async 경로(DB 조회 등) 추가 시 token 전파.

---

## 교차 검증 통과 내역

| # | 항목 | Q1(기수정?) | Q2(타레이어?) | Q3(의도?) | Q4(재현?) | 최종 |
|---|------|:----------:|:------------:|:--------:|:--------:|:----:|
| M1 | Ood/InEnv 하드코딩 | No | No | No(실수) | Yes | MED |
| M2 | feed_start 299.5 | No | No | No | Yes | MED |
| M3 | NaN 무방비 | No | No | No | Yes | MED |
| L4 | BASE 하드코딩 | No | 부분완화 | No | Yes | LOW |
| L5 | PolyVal 무경고 | No | - | No | 조건부 | LOW |
| L6 | Controller 검증누락 | No | No | No | 조건부 | LOW |
| L7 | cutin 중복 | No | 독립함수 | No | 조건부 | LOW |
| L8 | R² train-only | No | 정보용 | No | No | LOW |
| L9 | DSN 평문 | No | Dev전용 | No | No | LOW |
| L10 | pickle 미사용 | No | - | No(생략) | No | LOW |
| L11 | shutdown empty | No | - | No | Yes | LOW |
| L12 | async 동기 | No | - | 설계(주석) | No | LOW |

---

## 자가 검증

- [x] 각 지적 사항을 "현재 파일 몇 번 줄"로 직접 가리킴
- [x] MED 3건 모두 재현 가능한 시나리오 서술
- [x] 교차 검증 4개 질문을 모두 통과한 항목만 포함
- [x] 수정 예시가 현재 코드에 아직 적용되지 않은 내용
- [x] "더 좋은 방법 제안"과 "현재 코드가 틀렸다" 혼동하지 않음

---

## 변경 요약

| 심각도 | 건수 | 즉시 수정 권장 | 상태 |
|:-------:|:---:|:--------------:|:----:|
| HIGH | 0 | — | — |
| MED | 3 | M1(Ood): **즉시**, M3(NaN): **즉시**, M2(feed_start): 분석 재실행 필요 | **✅ 전항 수정 완료** |
| LOW | 8+1 | 리팩터링 시 일괄 | **✅ 4건 수정 완료** |

### 수정 내역 (2026-06-05)

| 항목 | 상태 | 변경 내용 |
|:----|:----:|:---------|
| **M1** Ood/InEnv 하드코딩 | ✅ 수정 | `SteamAdvisor.cs:107` — `Ood=false,InEnv=true` → `Ood=!inEnv,InEnv=inEnv` |
| **M2** feed_start lookback 부족 | ✅ 수정+분석재실행 | `c6111_shutdown.py:45` — 600→1200행 확장. P9 `feed_to_cutoff` 중앙값 55→99분 개선 (6/22건은 여전히 599.5로 10h ceiling 도달) |
| **M3** NaN 입력 무방비 | ✅ 수정 | `SteamAdvisor.cs:69-71` — `double.IsNaN` 가드 추가 |
| **L5** PolyVal 무경고 | ✅ 수정 | `SteamAdvisor.cs:142` — 계수 부족 시 `_logger.LogWarning` |
| **L10** pickle 미사용 | ✅ 수정 | `c6111_operator_assist.py:10` — `import pickle` 삭제 |
| **L11** shutdown empty plot | ✅ 수정 | `c6111_shutdown.py:99-101` — 컷오프 0건 시 조기 return |
| **L12** async misleading | ✅ 수정 | `SteamAdvisor.cs:112` — `Task`→`ValueTask`, `CancellationToken` 연동 |
| **L4** BASE 하드코딩 | ⬜ 보류 | 8개 파일 — 추후 리팩터링 |
| **L6** Controller 검증 누락 | ⬜ 보류 | `[Required]`·`[Range]` 속성 |
| **L7** cutin 중복 | ⬜ 보류 | `from c6111_startup import detect_cutins` |
| **L8** R² train-only | ⬜ 보류 | train/test 분할 |
| **L9** DSN 평문 | ⬜ 보류 | 환경변수 전환 |
