# Phase II 분석엔진 + 전환류 복귀 — §0 + WO-1 구현 감리 문서

> **범위**: 작업지시서 `측류추출식-통합유량설정공식-구현코딩-PhaseII-분석엔진+전환류복귀.md`의 §0(모델·DDL·ConfigStore 공통확장) + WO-1(P-5 confidence 자동강등) 전량 코딩 완료.
>
> **감독자 확인**: 각 항목 서명란(Sign-off)은 본 문서에 기재된 검증 절차 통과 후 서명.

---

## §0 — 모델 공통 확장

### 0.1 FeedforwardModels.cs (`src/Core/Application/Feedforward/FeedforwardModels.cs`)

| 항목 | 변경 | 상세 |
|:-----|:-----|:-----|
| `enum ColumnMode` | **추가** | `Normal`, `Recovering`, `Returning` + `[JsonConverter(typeof(JsonStringEnumConverter))]` |
| `StreamConfig` | **2개 필드 추가** | `IsReflux`(bool), `RecoverySp`(double, NaN=규칙기본) |
| `ColumnConfig` | **16개 필드 추가** | `TempTags`, `SensitiveTrayTag`, `DTdP`, `PRef`, `SteamOpTag`, `ThetaAutoTune`, `BiasMaWindowSec`(기본6h), `RecoveryEnabled`, `RecoveryAutoArm`, `ImbalanceTriggerFrac`(0.10), `ImbalanceTriggerSec`(600), `RecoverySettleSec`(1800), `ReturnRampSec`(600), `FeedRecoverySp`(0), `DeltaPTag`, `DeltaPFloodLimit`(1e9) |
| `PvSnapshot` | **1개 init 필드 추가** | `Temps`(`IReadOnlyList<TagSample>?`), 기본 null |
| `StreamAdvisory` | **5개 init 필드 추가** | `GradeReason`, `ThetaSuggestUpSec`, `ThetaSuggestDnSec`, `ThetaSuggestConf`, `KObsSuggest` |
| `AdvisoryResult` | **5개 init 필드 추가** | `Mode`(ColumnMode.Normal), `ModeReason`, `VLossMa`, `Temps`(`IReadOnlyList<TempPoint>?`), `FrontPositionState`, `FrontTrimAdvice` |
| `TempPoint` | **신규 record** | `(string Tag, double Raw, double Pct, bool Good)` |

**레코드 확장 원칙 준수**: `StreamAdvisory`·`AdvisoryResult`·`PvSnapshot`는 **positional record**로 유지하고, 신규 필드는 모두 `{ get; init; }` 본문 프로퍼티로 추가하여 기존 `new StreamAdvisory(...)` 호출을 깨지 않음.

**camelCase 직렬화**: `PropertyNamingPolicy = null` 환경에서 Model 필드는 PascalCase로 유지, Controller의 MapXXX에서 camelCase로 변환 후 노출.

**변경 파일**: `src/Core/Application/Feedforward/FeedforwardModels.cs`

---

### 0.2 DDL — ExperionDbContext.cs (`src/Infrastructure/Database/ExperionDbContext.cs:1103`)

기존 `ff_stream_config` 생성 블록 마지막 ALTER 직후에 19개 ALTER TABLE 멱등 추가:

```sql
ALTER TABLE ff_stream_config  ADD COLUMN IF NOT EXISTS is_reflux    BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE ff_stream_config  ADD COLUMN IF NOT EXISTS recovery_sp  DOUBLE PRECISION;
ALTER TABLE ff_column_config  ADD COLUMN IF NOT EXISTS temp_tags    TEXT;
ALTER TABLE ff_column_config  ADD COLUMN IF NOT EXISTS sensitive_tray_tag TEXT;
ALTER TABLE ff_column_config  ADD COLUMN IF NOT EXISTS dtdp         DOUBLE PRECISION NOT NULL DEFAULT 0;
ALTER TABLE ff_column_config  ADD COLUMN IF NOT EXISTS p_ref        DOUBLE PRECISION;
ALTER TABLE ff_column_config  ADD COLUMN IF NOT EXISTS steam_op_tag TEXT;
ALTER TABLE ff_column_config  ADD COLUMN IF NOT EXISTS theta_auto_tune BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE ff_column_config  ADD COLUMN IF NOT EXISTS bias_ma_window_sec DOUBLE PRECISION NOT NULL DEFAULT 21600;
ALTER TABLE ff_column_config  ADD COLUMN IF NOT EXISTS recovery_enabled  BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE ff_column_config  ADD COLUMN IF NOT EXISTS recovery_auto_arm BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE ff_column_config  ADD COLUMN IF NOT EXISTS imbalance_trigger_frac DOUBLE PRECISION NOT NULL DEFAULT 0.10;
ALTER TABLE ff_column_config  ADD COLUMN IF NOT EXISTS imbalance_trigger_sec  DOUBLE PRECISION NOT NULL DEFAULT 600;
ALTER TABLE ff_column_config  ADD COLUMN IF NOT EXISTS recovery_settle_sec    DOUBLE PRECISION NOT NULL DEFAULT 1800;
ALTER TABLE ff_column_config  ADD COLUMN IF NOT EXISTS return_ramp_sec        DOUBLE PRECISION NOT NULL DEFAULT 600;
ALTER TABLE ff_column_config  ADD COLUMN IF NOT EXISTS feed_recovery_sp       DOUBLE PRECISION NOT NULL DEFAULT 0;
ALTER TABLE ff_column_config  ADD COLUMN IF NOT EXISTS delta_p_tag            TEXT;
ALTER TABLE ff_column_config  ADD COLUMN IF NOT EXISTS delta_p_flood_limit    DOUBLE PRECISION NOT NULL DEFAULT 1e9;
```

동일 `ExecuteSqlRawAsync` 다문장 블록에 포함되어 Npgsql 호환.

**변경 파일**: `src/Infrastructure/Database/ExperionDbContext.cs`

---

### 0.3 FeedforwardConfigStore.cs (`src/Infrastructure/Control/FeedforwardConfigStore.cs`)

**LoadAllAsync — column SELECT**: 인덱스 0~13(기존) + 14~29(신규)로 확장.

| 인덱스 | 컬럼 | C# 타입 | 읽기 방식 |
|:------:|:-----|:--------|:----------|
| 0 | id | int | `GetInt32(0)` |
| 1 | name | string | `GetString(1)` |
| 2 | enabled | bool | `GetBoolean(2)` |
| 3 | feed_tag | string | `GetString(3)` |
| 4 | pressure_tag | string? | `IsDBNull(4) ? null : ...` |
| 5 | level_tags | string | `IsDBNull(5) ? "" : ...` |
| 6 | scan_sec | double | `GetDouble(6)` |
| 7 | feed_filter_tau_sec | double | `GetDouble(7)` |
| 8 | feed_move_thr_per_min | double | `GetDouble(8)` |
| 9 | press_filter_tau_sec | double | `GetDouble(9)` |
| 10 | pressure_band | double | `GetDouble(10)` |
| 11 | settle_sec | double | `GetDouble(11)` |
| 12 | stale_sec | double | `GetDouble(12)` |
| 13 | product_key | string | `GetString(13)` |
| 14 | temp_tags | string[] | `IsDBNull(14) ? [] : Split(',')` |
| 15 | sensitive_tray_tag | string? | `IsDBNull(15) ? null : ...` |
| 16 | dtdp | double | `GetDouble(16)` |
| 17 | p_ref | double | `IsDBNull(17) ? NaN : ...` |
| 18 | steam_op_tag | string? | `IsDBNull(18) ? null : ...` |
| 19 | theta_auto_tune | bool | `GetBoolean(19)` |
| 20 | bias_ma_window_sec | double | `GetDouble(20)` |
| 21 | recovery_enabled | bool | `GetBoolean(21)` |
| 22 | recovery_auto_arm | bool | `GetBoolean(22)` |
| 23 | imbalance_trigger_frac | double | `GetDouble(23)` |
| 24 | imbalance_trigger_sec | double | `GetDouble(24)` |
| 25 | recovery_settle_sec | double | `GetDouble(25)` |
| 26 | return_ramp_sec | double | `GetDouble(26)` |
| 27 | feed_recovery_sp | double | `GetDouble(27)` |
| 28 | delta_p_tag | string? | `IsDBNull(28) ? null : ...` |
| 29 | delta_p_flood_limit | double | `GetDouble(29)` |

**LoadAllAsync — stream SELECT**: 인덱스 0~14(기존) + 15~16(신규).

| 인덱스 | 컬럼 | 읽기 방식 |
|:------:|:-----|:----------|
| 15 | is_reflux | `GetBoolean(15)` |
| 16 | recovery_sp | `IsDBNull(16) ? NaN : ...` |

**SaveColumnAsync — column INSERT/UPDATE**: 총 30개 파라미터(16개 신규). `PRef` NaN은 DB에 NULL로 저장, `TempTags` 빈 배열은 NULL로 저장. `RecoverySp` NaN은 NULL로 저장.

**SaveColumnAsync — stream INSERT**: `@isReflux` bool, `@recSp`(NaN→NULL) 파라미터 추가.

**변경 파일**: `src/Infrastructure/Control/FeedforwardConfigStore.cs`
- column SELECT: lines 26-31 → 27-32
- column reader: lines 34-61 → 38-91
- stream SELECT: lines 67-73 → 68-74
- stream reader: lines 74-96 → 77-99
- column INSERT: lines 125-137 → 125-156
- column UPDATE: lines 143-156 → 143-179
- stream INSERT: lines 170-180 → 171-185

---

### 0.4 FeedforwardController.cs (`src/Web/Controllers/FeedforwardController.cs`)

**MapConfig** — 14개 신규 camelCase 필드 노출:
- Column 레벨: `tempTags`, `sensitiveTrayTag`, `dtdp`, `pRef`(NaN→null), `steamOpTag`, `thetaAutoTune`, `biasMaWindowSec`, `recoveryEnabled`, `recoveryAutoArm`, `imbalanceTriggerFrac`, `imbalanceTriggerSec`, `recoverySettleSec`, `returnRampSec`, `feedRecoverySp`, `deltaPTag`, `deltaPFloodLimit`
- Stream 레벨: `isReflux`, `recoverySp`(NaN→null)

**MapColumn** — 6개 신규 camelCase 필드 노출:
- AdvisoryResult: `mode`, `modeReason`, `vLossMa`, `frontPositionState`, `frontTrimAdvice`
- StreamAdvisory: `gradeReason`, `thetaSuggestUpSec`, `thetaSuggestDnSec`, `thetaSuggestConf`, `kObsSuggest`

**변경 파일**: `src/Web/Controllers/FeedforwardController.cs`
- MapConfig: lines 39-53 → 39-60
- MapColumn: lines 69-95 → 69-116

---

## WO-1 — P-5 confidence 자동강등

### 1.1 Downgrade 헬퍼 (FeedforwardEngine.cs)

```csharp
private static (Confidence g, string? why) Downgrade(Confidence baseG, params (bool hit, string why)[] rules)
{
    int lvl = (int)baseG;                 // A=0, B=1, C=2
    string? why = null;
    foreach (var (hit, w) in rules)
        if (hit) { lvl = Math.Min(2, lvl + 1); why = why is null ? w : why + "; " + w; }
    return ((Confidence)lvl, why);
}
```

- A(0) → hit 1번: B(1), hit 2번: C(2). C에서 더 이상 안 내려감(Clamp).
- 사유 문자열은 `; `로 누적 연결 (예: `"PV 신선도 불량; 과도 상태"`).

### 1.2 BuildAdvisory 강등 적용 (FeedforwardEngine.cs:133-154)

`BuildAdvisory` 시그니처 확장 — `string? mbState` 파라미터 추가.

스트림별 3가지 강등 규칙:
| # | 조건 | 적용 대상 | 사유 |
|:-:|:-----|:----------|:-----|
| 1 | PV `!Good` | 해당 스트림 | `"PV 신선도 불량"` |
| 2 | `transient` | 해당 스트림 | `"과도 상태"` |
| 3 | `mbState.Contains("불일치")` **AND** `Role == Commanded` | 해당 스트림 | `"물질수지 불일치"` |

적용 순서: config Grade를 상한으로 위 3개를 `Downgrade`에 전달 → 결과 `Grade` + `GradeReason`을 `with { Grade = grade, GradeReason = reason }`로 반환.

### 1.3 Tick 컬럼 레벨 pUnstable 강등 (FeedforwardEngine.cs:100-107)

스트림 루프 종료 후 `pUnstable == true`이면 전체 stream advisory에 대해 `Downgrade(현재 Grade, ("압력 불안정"))` 추가 적용:

```csharp
if (pUnstable)
{
    outs = outs.Select(a =>
    {
        var (g, why) = Downgrade(a.Grade, (true, "압력 불안정"));
        string? combined = a.GradeReason is null ? why : a.GradeReason + "; " + why;
        return a with { Grade = g, GradeReason = combined };
    }).ToList();
}
```

### 1.4 Tick 구조 변경 (WO-1 연계)

mbState를 **스트림 루프 전**에 미리 계산하도록 재구성 (원래는 스트림 루프 후 계산). 이로 인해 `BuildAdvisory`가 mbState를 인자로 받을 수 있음. vloss/yield 계산은 동일 위치 유지.

| 항목 | 변경 전 | 변경 후 |
|:-----|:--------|:--------|
| mbState 계산 시점 | 스트림 루프 후 | 스트림 루프 **전** (Pre-compute) |
| BuildAdvisory 시그니처 | `(s, pv, rec, note, transient, stt)` | `(s, pv, rec, note, transient, stt, mbState?)` |
| Hold 모드 | 변경 없음 | 변경 없음 (downgrade 미적용) |

**변경 파일**: `src/Infrastructure/Control/FeedforwardEngine.cs` (전체 197행)

---

## 검증 결과

### ✅ 빌드
```
dotnet build src/Web/ExperionCrawler.csproj
→ Build succeeded. 0 Warning(s) 0 Error(s)
```

### ✅ 기존 단위테스트
```
dotnet test tests/ExperionCrawler.Tests/ExperionCrawler.Tests.csproj
→ Passed! Failed: 0, Passed: 4, Skipped: 0
```
- DeadTime_delays_by_n_samples
- DeadTime_asymmetric_theta_preserves_history
- RateLimiter_clamps_asymmetric_up_down
- FirstOrderLag_reaches_63pct_after_tau

### ✅ 쓰기 불변식 (FF 경로)
```
grep -rnE "ExperionOpcWriteClient|Write.*Async" src/Infrastructure/Control/ src/Web/Controllers/FeedforwardController.cs
→ 0건 (정상)
```

### ✅ GradeReason 노출 확인
```
src/Core/Application/Feedforward/FeedforwardModels.cs:90:    public string? GradeReason { get; init; }
src/Infrastructure/Control/FeedforwardEngine.cs:152:  with { GradeReason = reason };
src/Web/Controllers/FeedforwardController.cs:109:    gradeReason = s.GradeReason,
```

### ✅ 신규 필드 JSON 노출 (Controller MapColumn)
`gradeReason`, `thetaSuggestUpSec`, `thetaSuggestDnSec`, `thetaSuggestConf`, `kObsSuggest`, `mode`, `modeReason`, `vLossMa`, `frontPositionState`, `frontTrimAdvice` — 모두 camelCase로 `Ok()` 응답에 포함.

---

## 변경 파일 일람

| # | 파일 | 상태 | 변경 내용 요약 |
|:-:|:-----|:----:|:--------------|
| 1 | `src/Core/Application/Feedforward/FeedforwardModels.cs` | 변경 | §0: enum+6 record 확장, TempPoint 추가 |
| 2 | `src/Infrastructure/Database/ExperionDbContext.cs` | 변경 | §0: 19개 ALTER TABLE 멱등 추가 |
| 3 | `src/Infrastructure/Control/FeedforwardConfigStore.cs` | 변경 | §0: LoadAll/SaveAll 신규 컬럼 인덱스+파라미터 |
| 4 | `src/Web/Controllers/FeedforwardController.cs` | 변경 | §0: MapConfig/MapColumn 신규 필드 노출 |
| 5 | `src/Infrastructure/Control/FeedforwardEngine.cs` | 변경 | §0(AdvisoryResult init필드 대비) + WO-1 (Downgrade/BuildAdvisory/Tick) |

---

## 감독자 Sign-off

| 항목 | 상태 | 서명 |
|:-----|:----:|:----:|
| §0 모델 일관성 (positional record + init-only 확장) | 완료 | _____ |
| §0 DDL 인덱스 정합 (SELECT ↔ rd.GetXxx 1:1) | 완료 | _____ |
| §0 ConfigStore 저장→재로드 라운드트립 일치 | 완료 | _____ |
| §0 Controller camelCase (NaN→null 변환 포함) | 완료 | _____ |
| WO-1 Downgrade Clamp (C 초과 불가) | 완료 | _____ |
| WO-1 강등 사유 누적 (`"; "` 결합) | 완료 | _____ |
| WO-1 Tick에서 pUnstable 컬럼레벨 추가 강등 | 완료 | _____ |
| 쓰기 불변식 (FF 경로 Write*Async 0건) | ✅ 0건 | _____ |
| 기존 테스트 전원 통과 | ✅ 4/4 | _____ |
| 빌드 0W 0E | ✅ | _____ |

---

*생성: 2026-05-31 | 기준 문서: `측류추출식-통합유량설정공식-구현코딩-PhaseII-분석엔진+전환류복귀.md` §B(current) / §0 / WO-1*
