# 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?`), 기본 null | | `StreamAdvisory` | **5개 init 필드 추가** | `GradeReason`, `ThetaSuggestUpSec`, `ThetaSuggestDnSec`, `ThetaSuggestConf`, `KObsSuggest` | | `AdvisoryResult` | **5개 init 필드 추가** | `Mode`(ColumnMode.Normal), `ModeReason`, `VLossMa`, `Temps`(`IReadOnlyList?`), `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*