feat: Phase II auto-write (WriteGuard, audit, auth) + WO-2~7 완료

Phase II:
- FfOperatorAction entity + ff_operator_action DDL/DbSet
- IFeedforwardWriteGuard + FeedforwardWriteGuard (SP bounds, grade C, transient, NaN)
- IFeedforwardAuditService + FeedforwardAuditService (raw ADO insert/query)
- FeedforwardSupervisor.AutoWriteAsync (per-stream OPC UA after Tick, rate-limited)
- FeedforwardConfigStore: advisory_only now read/writes DB, sp_node_id column
- FeedforwardController: auth (X-Kb-Token) on config/delete/write/audit;
  POST write/{id}/{key} manual SP write; GET audit; write results in MapColumn
- ff.js: token header, auto-write badge, per-stream write result, spNodeId, advisoryOnly
- ff.css: .ff-write-badge, .ff-write, .ff-write-err, .ff-wg-blocked
- Program.cs: register audit (Scoped) + write guard (Singleton)

WO-2~7 (build 0W/0E, test 22/22):
- PCT monitor, θ auto-tune, slow bias, front position indicator,
  total reflux recovery, config form expansion
This commit is contained in:
windpacer
2026-05-31 20:30:06 +09:00
parent 671d4ee1e5
commit 7c26aa7361
32 changed files with 4468 additions and 80 deletions

View File

@@ -0,0 +1,263 @@
# 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*

View File

@@ -0,0 +1,224 @@
# 측류추출 운전제안 (Advisory) — 운전원 사용 매뉴얼
> **대상**: C-6111 측류추출 증류탑 운전원
> **화면 위치**: 좌측 메뉴 **「유량 권장(FF)」** 탭 (⚖️)
> **한 줄 요약**: 이 화면은 **권장값을 보여줄 뿐, 어떤 SP도 자동으로 쓰지 않습니다.** 실제 설정 변경·전환류 실행은 **항상 운전원이 DCS에서 직접** 합니다.
---
## 0. 가장 먼저 알아둘 것 (안전 원칙)
| 원칙 | 의미 |
|:--|:--|
| **읽기 전용(Advisory)** | 이 시스템은 계산한 **권장 SP를 화면에 표시만** 합니다. DCS로 자동 쓰기 **하지 않습니다**. |
| **인가는 운전원** | 권장값을 채택할지 말지는 **운전원 판단**. 화면값을 보고 DCS에서 직접 입력합니다. |
| **전환류도 "권장"** | 전환류 모드의 "ARM"·"복귀중" 표시도 **권장/안내**입니다. 실제 드로우 차단·전량 환류는 운전원이 DCS에서 실행합니다. |
| **흐리게 표시 = 신뢰 낮음** | 행이 흐리거나 등급이 B/C면 그 권장값은 **참고만**. 과도·데이터 노후·물질수지 불일치 신호입니다. |
---
## 1. 화면 한눈에 보기
탭에 들어가면 컬럼별 **카드**가 나옵니다. 카드는 위에서 아래로:
```
┌─ C-6111 ──────────────── FEED 1000 · 12:34:56 ─┐
│ [전환류 권장 ⚠] 전환류 권장(ARM 대기): 물질수지(12%) [전환류 ARM] │ ← ① 모드 줄(상황 발생 시만)
│ 과도상태: FEED 이동 — 권장값 정착 대기 │ ← ② 과도 배너(과도 시만)
│ ┌─스트림 표────────────────────────────────┐ │
│ │ 스트림 태그 역할 PV 권장SP Δ 추세 신뢰│ │ ← ③ 스트림별 권장
│ │ P ficq-6118 Commanded 780 950 +170 ▲ A │ │
│ │ R ficq-6113 Commanded 623 760 +137 ▲ A │ │
│ │ D ficq-6114 LevelDriven 20 20 B │ │
│ │ B ficq-6116 LevelDriven 30 30 B │ │
│ └──────────────────────────────────────────┘ │
│ 물질수지: 정상 · V_loss 0.5 · V_loss(MA) 0.3 · 수율 95% │ ← ④ 물질수지
│ ti-6111b 81.2 PCT 80.9 · ti-6111c 80.1 · ti-6111d 79.5 │ ← ⑤ 온도(PCT)
│ θ 제안 (passive): P ↑62s ↓58s conf 0.7 — 운전원 수동 반영 │ ← ⑥ θ 자동튜닝 제안
│ 프론트: 프론트 상승(경비물 혼입 위험) → 환류↑ 권장 │ ← ⑦ 프론트(sweet-spot)
│ LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정… │ ← 안내문
└──────────────────────────────────────────────┘
```
---
## 2. 스트림 표 읽는 법 (③)
| 열 | 의미 | 운전원 행동 |
|:--|:--|:--|
| **스트림** | P=제품(측류) · R=환류 · D=탑정 경비물 · B=탑저 중비물 | — |
| **태그** | 해당 유량계 | — |
| **역할** | `Commanded`=권장SP 계산함 / `LevelDriven`=레벨제어(LIC)가 결정 / `Monitor`=감시만 | LevelDriven은 권장 SP를 따로 주지 않음(기대치만) |
| **PV** | 현재 유량 측정값 | — |
| **권장 SP** | 시스템이 제안하는 설정값 | **참고 후 DCS에서 직접 입력** |
| **Δ** | 권장 SP 현재 PV | 클수록 권장과 현재 차이가 큼 |
| **추세** | ▲ 상승 / ▼ 하강 / 변화없음 | 권장값이 올라가는 중인지 |
| **신뢰** | A(견고)·B(한계)·C(취약) **색상**: 초록/주황/빨강 | **B·C는 참고만.** 마우스를 올리면 **강등 사유** 표시 |
| **K~** (신뢰 아래 작은 글씨) | 관측된 비율(K_obs) 장기추세 | 설정 K와 크게 다르면 계절 보정 검토 |
> **신뢰 등급이 떨어지는 이유**(마우스 올리면 표시): "PV 신선도 불량"(데이터 노후) / "과도 상태" / "압력 불안정" / "물질수지 불일치". → 이럴 땐 권장값을 **그대로 믿지 말 것**.
---
## 3. 과도 상태 배너 (②)
- **FEED 이동 / 압력 불안정 / 정착 대기** 중엔 노란 배너가 뜨고, 스트림 행이 흐려집니다.
- 의미: **지금은 권장값이 안정되지 않았다.** 외란이 가라앉을 때까지 기다립니다.
- 운전원: 과도 중엔 권장값 채택을 **보류**하고, 배너가 사라진 뒤(정착) 판단합니다.
---
## 4. 물질수지 줄 (④)
| 표시 | 의미 |
|:--|:--|
| **물질수지: 정상** | FEED ≈ D+P+B. 균형 양호 |
| **V_loss** | 순간 손실(FEED 유출 합). 순간값은 노이즈가 커서 **참고만** |
| **V_loss(MA)** | 장기 평균 손실. **추세 판단은 이 값으로** (전환류 트리거도 이 값 기반) |
| **수율** | 제품(P)/FEED ×100% |
| 물질수지: **불일치(계측 점검)** | FEED와 유출 합이 3% 넘게 안 맞음 → **계측 점검** + 관련 스트림 신뢰 강등 |
---
## 5. 온도 / PCT (⑤)
- `ti-6111b 81.2 PCT 80.9` = 트레이 온도 **원값(raw)**과 **압력보정온도(PCT)**.
- **PCT**: 진공 변동(≈0.5°C/torr)의 영향을 제거한 온도. 진공이 흔들려도 PCT는 평탄 → **조성 변화를 더 잘 반영**.
- 운전원: raw가 출렁여도 **PCT가 안정적이면 조성은 안정**. PCT가 추세적으로 움직이면 프론트(⑦) 확인.
---
## 6. θ 자동튜닝 제안 (⑥)
- `θ 제안 P ↑62s ↓58s conf 0.7` = 정상 운전 데이터로 추정한 **전달지연(θ) 제안값** + 신뢰도(conf 0~1).
- **자동 반영 안 됨.** conf가 높을 때(예 0.5 이상) 참고해, 설정에서 θ_up/θ_dn을 **운전원이 수동 입력**.
- conf가 낮거나 표시 안 됨 = 외란 부족 → 무시.
---
## 7. 프론트(sweet-spot) 위치 (⑦)
증류탑에서 제품 순도가 가장 높은 "최적 추출 지점"의 위치 추세입니다.
| 표시 | 의미 | 권장 조치 |
|:--|:--|:--|
| **정상(프론트 안정)** | sweet-spot 유지 중 | 유지 |
| **프론트 상승(경비물 혼입 위험) → 환류↑ 권장** | 가벼운 성분이 제품단으로 내려올 위험 | **환류 증대** 검토(정석) |
| **프론트 하강 → boilup↑·환류↓ 권장** | 무거운 성분이 올라올 위험 | boilup(스팀) 증대 검토 |
> 단일 생온도 기반이면 신뢰가 낮을 수 있습니다(C등급). 차온·분석계가 있으면 우선합니다. **권장 문구일 뿐, SP는 바뀌지 않습니다.**
---
## 8. 전환류(Total Reflux) 평형복귀 모드 (①) ★ 중요
컬럼 균형이 **심각하게 무너졌을 때**, "제품·원료·경비물·중비물 배출을 모두 멈추고 전량 환류로 평형을 회복"하는 정석 대응을 안내합니다.
### 8.1 모드 줄에 뜨는 것
| 표시 | 색 | 의미 |
|:--|:--|:--|
| (없음) | — | 정상(Normal). 균형 양호 |
| **전환류 권장 ⚠** + `[전환류 ARM]` 버튼 | 빨강 점멸 | 균형붕괴 신호가 지속됨 → **운전원 판단 대기** |
| **전환류 복귀중 ●** + `[취소(정상복귀)]` | 주황 | 전환류 권장 상태 진행 중 |
| **복귀 램프 ●** + `[취소]` | 파랑 | 평형 회복 → 정상으로 점진 복귀 중 |
모드 줄 옆 작은 글씨에 **사유**가 표시됩니다: 예) `물질수지(12%) 프론트드리프트` — 어떤 신호로 발동했는지.
### 8.2 발동 조건(트리거)
아래 중 **하나라도** 설정한 시간만큼 **지속**되고 과도상태가 아니면 "전환류 권장"이 뜹니다:
1. **물질수지**: |V_loss(MA)| / FEED 가 임계(기본 10%) 초과
2. **프론트 드리프트**: sweet-spot이 크게 이탈
3. **차압(ΔP) 플러딩**: 탑 차압이 상한 초과 (태그 설정 시)
### 8.3 운전원 절차
```
① "전환류 권장 ⚠" 표시 확인 → 사유 읽기(물질수지/프론트/ΔP)
② 현장·DCS로 상황 교차 확인
③ 타당하면 [전환류 ARM] 클릭 → 모드가 "전환류 복귀중"으로 전환
(자동무장이 꺼져 있으면 ARM 없이는 진행되지 않음 — 안전장치)
④ ★ 실제 조작은 운전원이 DCS에서: 제품(P)·원료(F)·D·B 배출 차단, 환류(R) 전량
(시스템은 권장 SP를 0/최대로 표시할 뿐, 자동으로 쓰지 않음)
⑤ 평형 회복되면 "복귀 램프" → 자동으로 "정상" 안내로 복귀
⑥ 잘못 떴거나 중단하려면 [취소(정상복귀)] 클릭 → 즉시 Normal
```
> **오발동 방지**: 순간값이 아니라 **장기 평균(V_loss MA)** 이 지속 초과해야 발동하며, 과도상태 중엔 발동하지 않습니다. 그래도 **최종 판단은 운전원**입니다.
---
## 9. 설정 변경 (관리자/엔지니어)
**「설정 ▾」** 버튼 → 컬럼 **「편집」** 또는 **「+ 컬럼」** → 모달.
### 9.1 컬럼 기본 설정
컬럼명·활성·Feed/압력 태그·Scan 주기·각종 필터(τ)·과도 임계·Stale(데이터 유효시간) 등. 각 칸에 설명이 붙어 있습니다.
### 9.2 온도 / θ 자동튜닝 섹션
| 항목 | 설명 |
|:--|:--|
| 온도 태그(콤마구분, 상→하) | PCT 모니터 대상. 비우면 온도기능 off |
| 감도트레이 태그 | 프론트 위치 지표. 비우면 상-하 차온 사용 |
| dT/dP | 압력보정 계수. 0이면 생온도 |
| P_ref | 압력 기준점. 비우면 자동 시드 |
| 스팀 OP 태그 | θ 추정 정확도용(예 `tica-6111a.op`) |
| θ 자동튜닝 | 체크 시 θ 제안 표시(자동반영 아님) |
| 바이어스 MA 창 | K_obs·V_loss 장기평균 창(기본 6h) |
### 9.3 전환류 평형복귀 섹션 (붉은 박스) — **균형붕괴 트리거 수정 위치**
| 항목 | 설명 | 운전원 조정 |
|:--|:--|:--|
| 전환류 복귀 기능 사용 | 이 기능 on/off | |
| 자동 무장 | 체크 해제 시 **운전원 ARM 필요**(권장: 해제) | |
| **불균형 트리거 비율** | |V_loss(MA)|/Feed 가 이 값 초과 지속 시 권장 (0.10=10%) | **민감도 조절** |
| **트리거 지속(초)** | 이 시간 연속 지속돼야 발동(기본 600=10분, 오발동 방지) | **민감도 조절** |
| 평형 대기(초) | 전환류 중 평형 회복 연속 만족 시간(기본 1800) | |
| 복귀 램프(초) | 정상 복귀 시 점진 복원 시간(기본 600) | |
| 전환류 중 Feed 권장값 | 보통 0(차단) | |
| 차압(ΔP) 태그 / 플러딩 상한 | 플러딩 트리거(선택) | |
> **"균형 심각붕괴 트리거를 운전원이 바꿀 수 있나?"** → **예.** 위 **불균형 트리거 비율**과 **트리거 지속(초)** 를 조정하면 됩니다. 값을 키우면 둔감(덜 자주 발동), 줄이면 민감(자주 발동). 저장 후 다음 계산 주기부터 적용됩니다.
### 9.4 스트림 표
각 스트림의 역할·K·θ·τ·SP한계·Rate·환류 외에:
- **전환류R**: 전환류 시 "전량 환류" 대상 스트림 체크(보통 R)
- **복귀SP**: 전환류 시 이 스트림 권장값(비우면 0=차단)
저장하면 즉시 반영됩니다. 다시 「편집」을 열어 값이 유지되는지 확인하세요.
---
## 10. 자주 묻는 질문
**Q. 권장 SP를 누르면 자동으로 적용되나요?**
A. 아니요. 화면은 표시만 합니다. **DCS에서 직접 입력**하세요.
**Q. 전환류 ARM을 누르면 밸브가 닫히나요?**
A. 아니요. 모드가 "복귀중"으로 바뀌고 권장값이 갱신될 뿐입니다. **실제 차단/환류는 운전원이 DCS에서** 합니다.
**Q. 신뢰 등급이 자꾸 B/C로 떨어집니다.**
A. 등급에 마우스를 올려 사유를 확인하세요(데이터 노후/과도/압력불안정/물질수지). 원인 해소 시 A로 돌아옵니다.
**Q. θ 제안이 안 보입니다.**
A. θ 자동튜닝이 꺼져 있거나, 외란이 부족해 신뢰도가 낮은 것입니다. 정상이며, 외란이 쌓이면 표시됩니다.
**Q. 전환류가 너무 자주/드물게 권장됩니다.**
A. 설정 → 전환류 섹션의 **불균형 트리거 비율·지속(초)** 를 조정하세요(§9.3).
**Q. 화면 갱신 주기는?**
A. 약 3초. 값이 안 보이면 브라우저 새로고침(Ctrl+F5) 후 탭 재진입.
---
## 부록. 용어
- **Advisory(보조지표)**: 자동 제어 없이 권장만 하는 방식.
- **PCT(압력보정온도)**: 진공 변동 영향을 뺀 온도.
- **θ(전달지연)**: FEED 변화가 해당 스트림에 도달하는 시간 지연.
- **V_loss / V_loss(MA)**: 물질수지 손실 / 그 장기평균.
- **프론트(front)**: 탑 내부에서 제품 순도가 최고인 지점의 위치.
- **전환류(Total Reflux)**: 제품·배출을 멈추고 전량 환류로 탑을 재평형시키는 회복 운전.
- **ARM**: 전환류 권장을 운전원이 승인(무장)하는 동작.
</content>

View File

@@ -0,0 +1,389 @@
# WO-2 (P-2 PCT/차온 모니터) — 완전코드 작업지시서
> **대상 실행자**: 본 LLM보다 능력이 낮은 LLM. **추론·탐색 금지. 아래 "찾기/바꾸기" 앵커와 전체 코드 블록을 그대로 복붙**한다.
> **선행 완료 전제(검증됨)**: §0(모델·DDL·ConfigStore·Controller 공통확장)과 WO-1(P-5)은 이미 머지됨.
> 즉 `ColumnConfig.TempTags/SensitiveTrayTag/DTdP/PRef`, `PvSnapshot.Temps`, `AdvisoryResult.Temps`, `TempPoint`,
> `ff_column_config.temp_tags/dtdp/p_ref/sensitive_tray_tag` 컬럼은 **이미 존재**한다(다시 만들지 말 것).
> **불변식**: 본 WO는 advisory(모니터) — 제어 레지스터 쓰기 0건. PCT는 표시·WO-5 입력일 뿐 권장SP에 영향 없음.
## 목적
죽은 코드 `TempCorrection.PressureCompensated`**엔진에 배선**하고, 컬럼 온도 프로파일을 **압력보정온도(PCT)** 로 산출해
`AdvisoryResult.Temps`에 담아 대시보드에 표시한다. 진공노이즈(≈0.5°C/torr, spec §14.1) 제거. `DiffTemp` 블록도 추가(WO-5에서 소비).
## 변경 파일 (총 6개 — 전부 기존 파일 수정, 신규 파일 1개=테스트)
1. `src/Infrastructure/Control/ComputationBlocks.cs``DiffTemp` 추가
2. `src/Infrastructure/Control/FeedforwardEngine.cs``ColumnState` 필드 + `BuildTemps` + Tick 배선
3. `src/Infrastructure/Control/FeedforwardSupervisor.cs``BuildSnapshotAsync`에 온도 읽기
4. `src/Web/Controllers/FeedforwardController.cs``MapColumn``temps` 노출(NaN→null)
5. `src/Web/wwwroot/js/ff.js` — 카드에 온도행
6. `src/Web/wwwroot/css/ff.css` — 온도행 스타일
7. `tests/ExperionCrawler.Tests/FeedforwardTempTests.cs`**신규** 테스트
---
## STEP 1 — `ComputationBlocks.cs` : `DiffTemp` 추가
**파일**: `src/Infrastructure/Control/ComputationBlocks.cs`
**찾기** (파일 맨 끝의 `TempCorrection` 클래스 전체):
```csharp
public static class TempCorrection
{
public static double PressureCompensated(double tMeas, double p, double pRef, double dTdP)
=> tMeas - dTdP * (p - pRef);
}
```
**바꾸기** (그 뒤에 `DiffTemp` 추가 — `TempCorrection`은 그대로 두고 아래 블록을 이어붙임):
```csharp
public static class TempCorrection
{
public static double PressureCompensated(double tMeas, double p, double pRef, double dTdP)
=> tMeas - dTdP * (p - pRef);
}
/// <summary>차온/이중차온 — 공통모드(압력·계절) 상쇄, 프론트 이동만 부각. spec §13.3.
/// 본 WO-2에선 블록만 추가(단위테스트 대상). 실제 소비는 WO-5(FrontPositionIndicator).</summary>
public static class DiffTemp
{
/// <summary>두 트레이 차온 (상단 - 하단).</summary>
public static double Delta(double tHi, double tLo) => tHi - tLo;
/// <summary>이중차온(곡률) — 프론트 위치 민감.</summary>
public static double Double(double tA, double tB, double tC) => (tA - tB) - (tB - tC);
}
```
---
## STEP 2 — `FeedforwardEngine.cs` : 상태필드 + PCT 산출 배선
**파일**: `src/Infrastructure/Control/FeedforwardEngine.cs`
### 2.1 `ColumnState`에 PRef 시드 상태 추가
**찾기**:
```csharp
public double SettleTimerSec { get; set; }
public bool Initialized { get; set; }
public Dictionary<string, StreamState> Streams { get; } = new();
```
**바꾸기**:
```csharp
public double SettleTimerSec { get; set; }
public bool Initialized { get; set; }
// WO-2: 압력 기준점(cfg.PRef가 NaN이면 최초 정상 압력으로 시드)
public bool PRefSeeded { get; set; }
public double PRefValue { get; set; } = double.NaN;
public Dictionary<string, StreamState> Streams { get; } = new();
```
### 2.2 Tick 말미에서 PCT 산출 → AdvisoryResult.Temps
**찾기** (Tick 메서드의 마지막 return):
```csharp
return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
transient, treason, ff, outs, vloss, yield, mbState);
}
```
**바꾸기**:
```csharp
var temps = BuildTemps(cfg, pv, st); // WO-2 PCT 모니터
return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
transient, treason, ff, outs, vloss, yield, mbState)
{ Temps = temps };
}
// ── WO-2 P-2: 압력보정온도(PCT) 모니터 (advisory, 권장SP 무관) ───────────
private static IReadOnlyList<TempPoint>? BuildTemps(ColumnConfig cfg, PvSnapshot pv, ColumnState st)
{
if (pv.Temps is null || pv.Temps.Count == 0) return null;
bool havePress = pv.Pressure is { Good: true } pp && Num.IsFinite(pp.Value);
double pNow = havePress ? pv.Pressure!.Value : double.NaN;
// 기준 압력: cfg.PRef 우선, NaN이면 최초 정상압력으로 시드(컬럼상태에 보존)
double pRef = cfg.PRef;
if (double.IsNaN(pRef))
{
if (!st.PRefSeeded && havePress) { st.PRefValue = pNow; st.PRefSeeded = true; }
pRef = st.PRefSeeded ? st.PRefValue : double.NaN;
}
var list = new List<TempPoint>(pv.Temps.Count);
foreach (var t in pv.Temps)
{
bool good = t.Good && Num.IsFinite(t.Value);
double raw = good ? t.Value : double.NaN;
double pct = raw;
// dTdP==0(생온도) 또는 압력/기준 불가 시 PCT=raw(보정 안 함)
if (good && cfg.DTdP != 0.0 && havePress && Num.IsFinite(pRef))
pct = TempCorrection.PressureCompensated(raw, pNow, pRef, cfg.DTdP);
list.Add(new TempPoint(t.Tag, raw, pct, good));
}
return list;
}
```
> Hold(FEED BAD) 경로는 Temps=null 유지(컬럼 정지 상황이라 모니터 생략). 의도적 단순화.
---
## STEP 3 — `FeedforwardSupervisor.cs` : 온도 PV 읽기
**파일**: `src/Infrastructure/Control/FeedforwardSupervisor.cs`
### 3.1 읽을 태그 목록에 TempTags 추가
> ⚠️ 현재 파일엔 `LevelTags` 줄과 `FlowTag` 줄 사이에 **스트림 LevelTag 줄이 끼어 있다**. 그래서 아래는 **단일 줄(FlowTag) 앵커**로 잡는다(유일).
**찾기**:
```csharp
tags.AddRange(cfg.Streams.Select(s => PvTag(s.FlowTag)));
```
**바꾸기**:
```csharp
tags.AddRange(cfg.Streams.Select(s => PvTag(s.FlowTag)));
tags.AddRange(cfg.TempTags.Select(PvTag)); // WO-2 온도 프로파일
```
### 3.2 PvSnapshot에 Temps 채우기
**찾기**:
```csharp
var levels = cfg.LevelTags.Select(Sample).ToList();
var streams = cfg.Streams.ToDictionary(s => s.Key, s => Sample(s.FlowTag));
return new PvSnapshot(feed, press, levels, streams);
```
**바꾸기**:
```csharp
var levels = cfg.LevelTags.Select(Sample).ToList();
var streams = cfg.Streams.ToDictionary(s => s.Key, s => Sample(s.FlowTag));
var temps = cfg.TempTags.Count > 0 ? cfg.TempTags.Select(Sample).ToList() : null;
return new PvSnapshot(feed, press, levels, streams) { Temps = temps };
```
> `Sample(baseTag)`은 `.pv` 부착·소문자·신선도(StaleSec) 판정을 이미 수행한다(기존 헬퍼 재사용). `TempPoint.Tag`에는 `.pv` 부착된 소문자 태그가 들어간다.
---
## STEP 4 — `FeedforwardController.cs` : `MapColumn`에 temps 노출
**파일**: `src/Web/Controllers/FeedforwardController.cs`
**찾기**:
```csharp
frontPositionState = r.FrontPositionState,
frontTrimAdvice = r.FrontTrimAdvice,
streams = r.Streams.Select(s => new
```
**바꾸기** (NaN→null 변환은 검증된 코드베이스의 camelCase/NaN 규칙):
```csharp
frontPositionState = r.FrontPositionState,
frontTrimAdvice = r.FrontTrimAdvice,
temps = r.Temps?.Select(t => new
{
tag = t.Tag,
raw = double.IsNaN(t.Raw) ? (double?)null : t.Raw,
pct = double.IsNaN(t.Pct) ? (double?)null : t.Pct,
good = t.Good
}),
streams = r.Streams.Select(s => new
```
> **이유**: System.Text.Json 기본 설정은 NaN 직렬화 시 예외. 기존 `pv = double.IsNaN(...) ? null : ...` 패턴과 동일하게 raw/pct를 가드한다.
---
## STEP 5 — `ff.js` : 카드에 온도행
**파일**: `src/Web/wwwroot/js/ff.js`
**찾기** (`ffCard` 함수의 mb 구성 ~ return):
```javascript
const mb = `물질수지: ${esc(c.massBalanceState)}` +
(c.vLoss!=null ? ` · V_loss ${fmtVal(c.vLoss)}` : '') +
(c.yield!=null ? ` · 수율 ${fmtVal(c.yield)}%` : '');
return `
```
**바꾸기**:
```javascript
const mb = `물질수지: ${esc(c.massBalanceState)}` +
(c.vLoss!=null ? ` · V_loss ${fmtVal(c.vLoss)}` : '') +
(c.yield!=null ? ` · 수율 ${fmtVal(c.yield)}%` : '');
const temps = (c.temps && c.temps.length)
? `<div class="ff-temps">${c.temps.map(t => `<span class="ff-temp${t.good?'':' ff-stale'}">${esc(t.tag)} ${t.raw==null?'':fmtVal(t.raw)}${(t.pct!=null && t.pct!==t.raw)?` <small>PCT ${fmtVal(t.pct)}</small>`:''}</span>`).join(' · ')}</div>`
: '';
return `
```
**찾기** (카드 return 내 mb div + 그 아래 note div — 현재 파일에는 mb가 `${esc(mb)}`이고 바로 아래 ff-note 줄이 있다):
```javascript
<div class="ff-mb">${esc(mb)}</div>
<div class="ff-note">LevelDriven(D·B) 레벨 제어(LIC) SP를 결정. 권장값은 참고 인가는 운전원.</div>
```
**바꾸기** (mb와 note 사이에 `${temps}` 삽입):
```javascript
<div class="ff-mb">${esc(mb)}</div>
${temps}
<div class="ff-note">LevelDriven(D·B) 레벨 제어(LIC) SP를 결정. 권장값은 참고 인가는 운전원.</div>
```
---
## STEP 6 — `ff.css` : 온도행 스타일
**파일**: `src/Web/wwwroot/css/ff.css`
**파일 맨 끝에 추가**:
```css
/* WO-2 온도 프로파일(PCT) 모니터 행 */
.ff-temps{font-size:12px;color:var(--t2);margin-top:6px;display:flex;flex-wrap:wrap;gap:4px 10px}
.ff-temp{white-space:nowrap}
.ff-temp small{color:#7fd1ff}
.ff-temp.ff-stale{opacity:.45}
```
---
## STEP 7 — 신규 테스트 `FeedforwardTempTests.cs`
**신규 파일**: `tests/ExperionCrawler.Tests/FeedforwardTempTests.cs`
```csharp
using System;
using System.Collections.Generic;
using ExperionCrawler.Core.Application.Feedforward;
using ExperionCrawler.Infrastructure.Control;
using Xunit;
namespace ExperionCrawler.Tests;
public class FeedforwardTempTests
{
// ── 순수 블록 ────────────────────────────────────────────────
[Fact]
public void TempCorrection_compensates_pressure()
{
// P가 기준보다 높으면(진공 약화) PCT는 raw보다 낮아짐(dTdP>0)
Assert.Equal(99.0, TempCorrection.PressureCompensated(100, p: 52, pRef: 50, dTdP: 0.5), 6);
// dTdP=0 → 보정 없음
Assert.Equal(100.0, TempCorrection.PressureCompensated(100, p: 52, pRef: 50, dTdP: 0.0), 6);
}
[Fact]
public void DiffTemp_delta_and_double()
{
Assert.Equal(2.0, DiffTemp.Delta(81, 79), 6);
Assert.Equal(0.0, DiffTemp.Double(82, 81, 80), 6); // 등간격 → 곡률 0
Assert.Equal(1.0, DiffTemp.Double(83, 81, 80), 6); // (83-81)-(81-80)=1
}
// ── 엔진 배선 ────────────────────────────────────────────────
private static ColumnConfig Cfg(double dtdp, double pRef) => new()
{
Id = 1, Name = "C-TEMP", Enabled = true, FeedTag = "f", ProductKey = "P",
ScanSec = 2, DTdP = dtdp, PRef = pRef, PressureTag = "p",
TempTags = new[] { "t1" },
Streams = new[] { new StreamConfig { Key = "P", FlowTag = "ficq-6118", Role = StreamRole.Commanded, Grade = Confidence.A, TargetCoeff = 0.95 } }
};
private static PvSnapshot Snap(double pressure, double temp) => new(
new TagSample("f", 100, true, DateTime.UtcNow),
new TagSample("p", pressure, true, DateTime.UtcNow),
Array.Empty<TagSample>(),
new Dictionary<string, TagSample> { ["P"] = new TagSample("ficq-6118", 95, true, DateTime.UtcNow)})
{ Temps = new[] { new TagSample("t1", temp, true, DateTime.UtcNow) } };
[Fact]
public void Engine_populates_pct_with_explicit_pref()
{
var res = new FeedforwardEngine().Tick(Cfg(dtdp: 0.5, pRef: 50), Snap(pressure: 52, temp: 100),
new ColumnState(), DateTime.UtcNow);
Assert.NotNull(res.Temps);
var tp = res.Temps![0];
Assert.Equal("t1", tp.Tag);
Assert.Equal(100.0, tp.Raw, 6);
Assert.Equal(99.0, tp.Pct, 6); // 100 - 0.5*(52-50)
}
[Fact]
public void Engine_seeds_pref_on_first_tick_when_nan()
{
var engine = new FeedforwardEngine();
var st = new ColumnState();
// tick1: pRef 미지정(NaN) → 첫 압력 50으로 시드 → PCT=raw(차이 0)
var r1 = engine.Tick(Cfg(dtdp: 0.5, pRef: double.NaN), Snap(pressure: 50, temp: 100), st, DateTime.UtcNow);
Assert.Equal(100.0, r1.Temps![0].Pct, 6);
// tick2: 압력 54로 변동 → PCT = 100 - 0.5*(54-50) = 98
var r2 = engine.Tick(Cfg(dtdp: 0.5, pRef: double.NaN), Snap(pressure: 54, temp: 100), st, DateTime.UtcNow);
Assert.Equal(98.0, r2.Temps![0].Pct, 6);
}
[Fact]
public void Engine_no_pct_when_dtdp_zero()
{
var res = new FeedforwardEngine().Tick(Cfg(dtdp: 0.0, pRef: 50), Snap(pressure: 80, temp: 100),
new ColumnState(), DateTime.UtcNow);
Assert.Equal(100.0, res.Temps![0].Pct, 6); // 생온도 = raw
}
}
```
---
## STEP 8 — 검증 (반드시 실행하고 결과를 보고서에 첨부)
```bash
# 1) C# 빌드 — 경고0/에러0 이어야 함
dotnet build src/Web/ExperionCrawler.csproj 2>&1 | grep -E "Build succeeded|Warning|Error"
# 2) 테스트 — 기존 7 + 신규 5 = 12 통과 이어야 함
dotnet test tests/ExperionCrawler.Tests/ExperionCrawler.Tests.csproj 2>&1 | grep -E "Passed!|Failed!"
# 3) JS 문법
node -c src/Web/wwwroot/js/ff.js && echo "JS OK"
# 4) 쓰기 불변식(FF 경로 0건)
grep -rnE "ExperionOpcWriteClient|Write.*Async" src/Infrastructure/Control src/Web/Controllers/FeedforwardController.cs || echo "WRITE 0건 OK"
```
**기대 결과**:
| 항목 | 기대 |
|:--|:--|
| 빌드 | `Build succeeded. 0 Warning(s) 0 Error(s)` |
| 테스트 | `Passed! - Failed: 0, Passed: 12` |
| JS | `JS OK` |
| 쓰기 | `WRITE 0건 OK` |
### 런타임 확인(선택)
- `ff_column_config``temp_tags='ti-6111b,ti-6111c,ti-6111d'`, `dtdp=0.5`, `p_ref=NULL`(시드) 또는 실측값 설정.
- Tab 18 진입 → 카드 하단에 `ti-6111b ... PCT ...` 행 표시. 진공(pica-6111) 흔들 때 raw는 출렁이나 PCT는 평탄(공통모드 상쇄).
---
## 감독자 Sign-off (검수 후 서명)
| 항목 | 상태 | 서명 |
|:--|:--:|:--:|
| DiffTemp 블록 + 단위테스트 | ✅ | windpacer 2026-05-31 |
| 엔진 BuildTemps 배선 (cfg.PRef 우선 / NaN 시드) | ✅ | windpacer 2026-05-31 |
| dTdP=0 → PCT=raw (생온도 패스스루) | ✅ | windpacer 2026-05-31 |
| Supervisor TempTags 읽기 + PvSnapshot.Temps | ✅ | windpacer 2026-05-31 |
| Controller temps 노출 (NaN→null) | ✅ | windpacer 2026-05-31 |
| ff.js 온도행 + node -c 통과 | ✅ | windpacer 2026-05-31 |
| 빌드 0/0 · 테스트 12/12 · 쓰기 0건 | ✅ | windpacer 2026-05-31 |
---
## 주의(약한 LLM이 흔히 깨먹는 지점)
1. **§0를 다시 만들지 말 것** — `TempTags/PRef/Temps/TempPoint`·DDL 컬럼은 이미 존재. 중복 추가 시 빌드 깨짐.
2. **positional record에 새 필드 추가 금지**`AdvisoryResult.Temps`·`PvSnapshot.Temps`는 이미 init 프로퍼티. 생성은 `new (...) { Temps = ... }` 형태(이미 §0에서 추가됨).
3. **NaN을 그대로 JSON에 넣지 말 것** — Controller에서 raw/pct는 `double.IsNaN(..) ? null : ..`.
4. **`Sample()` 재사용** — `.pv` 부착·소문자·신선도 판정이 이미 들어있으니 온도태그도 동일 헬퍼로.
5. **테스트의 `Snap`은 `{ Temps = ... }`로 PvSnapshot 생성** — 엔진은 `pv.Temps`를 읽지 태그를 읽지 않는다(태그→PV는 Supervisor 책임).
</content>

View File

@@ -0,0 +1,493 @@
# WO-3 (P-1 θ 자동튜닝, passive 교차상관) — 완전코드 작업지시서
> **대상 실행자**: 본 LLM보다 능력이 낮은 LLM. **추론·탐색 금지. 아래 "찾기/바꾸기" 앵커와 전체 코드 블록을 그대로 복붙**한다.
> **선행 완료 전제(필수)**: §0 + WO-1 + **WO-2 머지 완료**. 즉 `ColumnConfig.SteamOpTag/ThetaAutoTune/SensitiveTrayTag`,
> `StreamAdvisory.ThetaSuggestUpSec/DnSec/Conf`(§0), `BuildTemps`/`ColumnState.PRefSeeded/PRefValue`(WO-2),
> `AdvisoryResult.Temps`·`PvSnapshot.Temps`는 **이미 존재**한다(다시 만들지 말 것). WO-2가 안 됐으면 WO-2 먼저.
> **불변식**: advisory — 제어 레지스터 쓰기 0건. **config의 θ는 절대 변경하지 않는다.** 화면에 "제안"만 표시(운전원이 수동 반영).
## 목적
정상 운전 중 **자연 외란**으로 피드→온도(PCT) 전달지연 θ를 **passive 교차상관**으로 식별해 commanded 스트림에 **제안**한다.
spec §13.4: `θ = argmax_τ ρ(ΔF(t), ΔPCT(t+τ))`, **스팀 OP(TICA.OP)를 부분상관으로 제거**해 폐루프 오염 회피(함정 ④).
외란 부족·신뢰 낮으면 **제안 억제(null)**. seed θ가 전부 placeholder인 문제(PhaseI §5.8)를 데이터로 보정.
> **현실 경고(spec §13.2·§13.7)**: 단일점 생온도 SNR 낮음 → θ는 **신뢰도 등급 붙은 추정치**. 데모 온도는 인위생성이라
> 실플랜트 전 가동 스위치 `ThetaAutoTune`는 **기본 false**. 본 WO는 블록·배선·테스트까지 턴키로 두되 옵트인.
## 변경 파일 (총 6개)
1. `src/Infrastructure/Control/CrossCorrLagEstimator.cs`**신규** 블록
2. `src/Core/Application/Feedforward/FeedforwardModels.cs``PvSnapshot.SteamOp` init 프로퍼티
3. `src/Infrastructure/Control/FeedforwardSupervisor.cs` — 스팀 OP 읽기(.op는 .pv 아님)
4. `src/Infrastructure/Control/FeedforwardEngine.cs``ColumnState` 필드 + `ApplyThetaSuggestion` + Tick 배선
5. `src/Web/wwwroot/js/ff.js` — θ 제안 표시 (Controller는 §0에서 이미 `thetaSuggest*` 노출 — **변경 없음**)
6. `src/Web/wwwroot/css/ff.css` — θ 행 스타일
7. `tests/ExperionCrawler.Tests/FeedforwardThetaTests.cs`**신규** 테스트
---
## STEP 1 — 신규 파일 `CrossCorrLagEstimator.cs`
**신규 파일**: `src/Infrastructure/Control/CrossCorrLagEstimator.cs`
```csharp
namespace ExperionCrawler.Infrastructure.Control;
/// <summary>
/// Passive 전달지연(θ) 식별. ΔF(피드 변화)와 ΔR(응답=PCT 변화)의 교차상관 최대 지연 = θ.
/// 스팀 ΔS(=TICA.OP 변화)를 2번째 입력으로 **부분상관**(partial corr)해 폐루프 오염 제거(spec §13.4).
/// 입력은 이미 1차차분(Δ=사전백색화)된 값. I/O 없음, 컬럼 루프 단일 소유(락 불필요).
/// 비용 분산을 위해 매 호출 누적하되 argmax 계산은 recomputeEvery 호출마다 1회(나머지는 캐시 반환).
/// </summary>
public sealed class CrossCorrLagEstimator
{
private readonly int _maxLag; // 탐색할 최대 지연(샘플)
private readonly int _hist; // 보존 이력(샘플)
private readonly double _minStd; // 외란 최소 표준편차(미달 시 억제)
private readonly int _recomputeEvery; // argmax 재계산 주기(호출 수)
private readonly Queue<double> _f = new();
private readonly Queue<double> _r = new();
private readonly Queue<double> _s = new();
private int _sinceCompute;
private (double thetaUpSec, double thetaDnSec, double conf)? _last;
public CrossCorrLagEstimator(int maxLagSamples, int historySamples, double minSignalStd, int recomputeEvery = 30)
{
_maxLag = Math.Max(1, maxLagSamples);
_hist = Math.Max(_maxLag * 2, historySamples);
_minStd = minSignalStd;
_recomputeEvery = Math.Max(1, recomputeEvery);
}
public (double thetaUpSec, double thetaDnSec, double conf)? Push(
double dFeed, double dResponse, double dSteam, double tsSec)
{
_f.Enqueue(dFeed); _r.Enqueue(dResponse); _s.Enqueue(dSteam);
while (_f.Count > _hist) { _f.Dequeue(); _r.Dequeue(); _s.Dequeue(); }
if (_f.Count < _maxLag * 2) return _last; // 외란 누적 부족 → 직전 결과(초기 null)
_sinceCompute++;
if (_last is not null && _sinceCompute < _recomputeEvery) return _last; // 캐시
_sinceCompute = 0;
var f = _f.ToArray(); var r = _r.ToArray(); var s = _s.ToArray();
int n = f.Length;
if (Std(f) < _minStd) { _last = null; return null; } // 피드 외란 없음 → 억제
// 부분상관: r에서 s의 동시점 선형성분 제거 (잔차)
double beta = Cov(r, s) / Math.Max(1e-12, Var(s));
var resid = new double[n];
for (int i = 0; i < n; i++) resid[i] = r[i] - beta * s[i];
// 방향별 θ (상승/하강 비대칭). 표본 부족 시 NaN.
var (tu, cu) = BestLag(f, resid, n, x => x > 0, tsSec);
var (td, cd) = BestLag(f, resid, n, x => x < 0, tsSec);
bool haveUp = !double.IsNaN(tu), haveDn = !double.IsNaN(td);
if (!haveUp && !haveDn) { _last = null; return null; }
if (!haveUp) { tu = td; cu = cd; }
if (!haveDn) { td = tu; cd = cu; }
double conf = Math.Min(cu, cd);
if (conf < 0.3) { _last = null; return null; } // 신뢰 부족 → 억제
_last = (tu, td, conf);
return _last;
}
/// <summary>mask를 만족하는 피드 표본에 대해 ρ(τ) 최대인 τ(초)와 conf 반환. 표본 부족 시 (NaN,0).</summary>
private (double theta, double conf) BestLag(double[] f, double[] resid, int n, Func<double, bool> mask, double tsSec)
{
int masked = 0;
for (int i = 0; i < n; i++) if (mask(f[i])) masked++;
if (masked < _maxLag) return (double.NaN, 0.0);
double bestRho = double.NegativeInfinity; int bestTau = 0;
for (int tau = 0; tau <= _maxLag; tau++)
{
double sfr = 0, sff = 0, srr = 0; int m = 0;
for (int i = 0; i + tau < n; i++)
{
if (!mask(f[i])) continue;
double a = f[i], b = resid[i + tau];
sfr += a * b; sff += a * a; srr += b * b; m++;
}
if (m < 3 || sff <= 0 || srr <= 0) continue;
double rho = sfr / Math.Sqrt(sff * srr); // Δ신호라 비중심 상관
if (rho > bestRho) { bestRho = rho; bestTau = tau; }
}
if (double.IsNegativeInfinity(bestRho)) return (double.NaN, 0.0);
return (bestTau * tsSec, Math.Max(0.0, bestRho));
}
private static double Mean(double[] a) { double s = 0; foreach (var x in a) s += x; return s / a.Length; }
private static double Var(double[] a) { double m = Mean(a), s = 0; foreach (var x in a) s += (x - m) * (x - m); return s / a.Length; }
private static double Std(double[] a) => Math.Sqrt(Var(a));
private static double Cov(double[] a, double[] b)
{ double ma = Mean(a), mb = Mean(b), s = 0; for (int i = 0; i < a.Length; i++) s += (a[i] - ma) * (b[i] - mb); return s / a.Length; }
}
```
---
## STEP 2 — `FeedforwardModels.cs` : `PvSnapshot.SteamOp` 추가
**파일**: `src/Core/Application/Feedforward/FeedforwardModels.cs`
**찾기** (WO-2가 추가한 `PvSnapshot`의 Temps 프로퍼티):
```csharp
IReadOnlyDictionary<string, TagSample> Streams)
{
public IReadOnlyList<TagSample>? Temps { get; init; }
}
```
**바꾸기**:
```csharp
IReadOnlyDictionary<string, TagSample> Streams)
{
public IReadOnlyList<TagSample>? Temps { get; init; }
public TagSample? SteamOp { get; init; } // WO-3: TICA.OP (스팀, 부분상관 2번째 입력)
}
```
---
## STEP 3 — `FeedforwardSupervisor.cs` : 스팀 OP 읽기
> ⚠️ **`SteamOpTag`은 `.OP`(컨트롤러 출력)이지 `.pv`가 아니다.** `Sample()`/`PvTag()`는 `.pv`를 강제 부착하므로
> 스팀엔 쓰면 안 된다. 아래처럼 **태그를 그대로(소문자) 읽는 SampleExact**를 추가한다.
### 3.1 읽을 태그 목록에 SteamOpTag 추가
**찾기** (WO-2가 추가한 TempTags 줄):
```csharp
tags.AddRange(cfg.TempTags.Select(PvTag)); // WO-2 온도 프로파일
```
**바꾸기**:
```csharp
tags.AddRange(cfg.TempTags.Select(PvTag)); // WO-2 온도 프로파일
if (cfg.SteamOpTag is not null) tags.Add(cfg.SteamOpTag.ToLowerInvariant()); // WO-3 스팀 OP(.op 그대로)
```
### 3.2 SampleExact 헬퍼 추가 (Sample 바로 뒤)
**찾기** (기존 `Sample` 로컬함수의 닫는 부분):
```csharp
return new TagSample(tag, double.NaN, Good: false, DateTime.MinValue);
}
var feed = Sample(cfg.FeedTag);
```
**바꾸기**:
```csharp
return new TagSample(tag, double.NaN, Good: false, DateTime.MinValue);
}
// WO-3: .op 등 비-.pv 태그를 접미사 강제 없이 그대로 읽음
TagSample SampleExact(string rawTag)
{
var tag = rawTag.ToLowerInvariant();
if (rows.TryGetValue(tag, out var r)
&& double.TryParse(r.LiveValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var v))
{
bool fresh = (DateTime.UtcNow - r.Timestamp.ToUniversalTime()).TotalSeconds <= cfg.StaleSec;
return new TagSample(tag, v, Good: fresh, r.Timestamp);
}
return new TagSample(tag, double.NaN, Good: false, DateTime.MinValue);
}
var feed = Sample(cfg.FeedTag);
```
### 3.3 PvSnapshot에 SteamOp 채우기
> 전제: WO-2에서 이 return은 이미 `{ Temps = temps }` 형태다.
**찾기**:
```csharp
var temps = cfg.TempTags.Count > 0 ? cfg.TempTags.Select(Sample).ToList() : null;
return new PvSnapshot(feed, press, levels, streams) { Temps = temps };
```
**바꾸기**:
```csharp
var temps = cfg.TempTags.Count > 0 ? cfg.TempTags.Select(Sample).ToList() : null;
var steam = cfg.SteamOpTag is not null ? SampleExact(cfg.SteamOpTag) : null;
return new PvSnapshot(feed, press, levels, streams) { Temps = temps, SteamOp = steam };
```
---
## STEP 4 — `FeedforwardEngine.cs` : 상태필드 + θ 제안 배선
**파일**: `src/Infrastructure/Control/FeedforwardEngine.cs`
### 4.1 `ColumnState`에 θ 추정 상태 추가
> 전제: WO-2에서 `PRefSeeded`/`PRefValue`가 이미 추가됨.
**찾기**:
```csharp
public bool PRefSeeded { get; set; }
public double PRefValue { get; set; } = double.NaN;
public Dictionary<string, StreamState> Streams { get; } = new();
```
**바꾸기**:
```csharp
public bool PRefSeeded { get; set; }
public double PRefValue { get; set; } = double.NaN;
// WO-3: θ 자동튜닝(컬럼 1개 추정기 + 이전값 보존)
public CrossCorrLagEstimator? ThetaEst { get; set; }
public double PrevFeedFiltered { get; set; } = double.NaN;
public double PrevRespPct { get; set; } = double.NaN;
public double PrevSteamOp { get; set; } = double.NaN;
public Dictionary<string, StreamState> Streams { get; } = new();
```
### 4.2 Tick 배선 — return 직전에 θ 제안 적용
> 전제: WO-2에서 return이 `var temps = BuildTemps(...)` + `{ Temps = temps }` 형태다.
**찾기**:
```csharp
var temps = BuildTemps(cfg, pv, st); // WO-2 PCT 모니터
return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
transient, treason, ff, outs, vloss, yield, mbState)
{ Temps = temps };
```
**바꾸기**:
```csharp
var temps = BuildTemps(cfg, pv, st); // WO-2 PCT 모니터
ApplyThetaSuggestion(cfg, pv, st, ts, temps, ref outs); // WO-3 θ 제안(advisory)
return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
transient, treason, ff, outs, vloss, yield, mbState)
{ Temps = temps };
```
### 4.3 `ApplyThetaSuggestion` 메서드 추가 (BuildTemps 바로 뒤)
> 전제: WO-2가 추가한 `BuildTemps` 메서드는 `return list;` + `}` 로 끝난다.
**찾기** (BuildTemps의 마지막):
```csharp
list.Add(new TempPoint(t.Tag, raw, pct, good));
}
return list;
}
```
**바꾸기**:
```csharp
list.Add(new TempPoint(t.Tag, raw, pct, good));
}
return list;
}
// ── WO-3 P-1: passive θ 식별 → commanded 스트림에 "제안"만(config θ 무변경) ──────
private static void ApplyThetaSuggestion(ColumnConfig cfg, PvSnapshot pv, ColumnState st, double ts,
IReadOnlyList<TempPoint>? temps, ref List<StreamAdvisory> outs)
{
if (!cfg.ThetaAutoTune) return; // 옵트인(기본 off)
if (temps is null || temps.Count == 0) return;
// 응답 신호 = 민감트레이 PCT(없으면 첫 온도 PCT)
double respPct = double.NaN;
if (cfg.SensitiveTrayTag is not null)
{
var key = cfg.SensitiveTrayTag.EndsWith(".pv") ? cfg.SensitiveTrayTag : cfg.SensitiveTrayTag + ".pv";
foreach (var tp in temps) if (tp.Tag == key && tp.Good) { respPct = tp.Pct; break; }
}
if (double.IsNaN(respPct) && temps[0].Good) respPct = temps[0].Pct;
if (double.IsNaN(respPct)) return;
double feedNow = st.FeedFilter.Value;
double steamNow = pv.SteamOp is { Good: true } so && Num.IsFinite(so.Value) ? so.Value : 0.0;
// 1차차분(Δ=사전백색화). 최초 호출은 prev가 NaN이라 Δ=0(시드)
double dF = Num.IsFinite(st.PrevFeedFiltered) ? feedNow - st.PrevFeedFiltered : 0.0;
double dR = Num.IsFinite(st.PrevRespPct) ? respPct - st.PrevRespPct : 0.0;
double dS = Num.IsFinite(st.PrevSteamOp) ? steamNow - st.PrevSteamOp : 0.0;
st.PrevFeedFiltered = feedNow; st.PrevRespPct = respPct; st.PrevSteamOp = steamNow;
st.ThetaEst ??= new CrossCorrLagEstimator(
maxLagSamples: Math.Max(1, (int)Math.Round(1200.0 / Math.Max(1e-6, ts))), // ~20분 지연 탐색
historySamples: Math.Max(1, (int)Math.Round(3600.0 / Math.Max(1e-6, ts))), // ~1시간 이력
minSignalStd: 1e-9);
var est = st.ThetaEst.Push(dF, dR, dS, ts);
if (est is null) return;
var (tu, td, conf) = est.Value;
outs = outs.Select(a => a.Role == StreamRole.Commanded
? a with { ThetaSuggestUpSec = tu, ThetaSuggestDnSec = td, ThetaSuggestConf = conf }
: a).ToList();
}
```
> **Controller 변경 없음**: §0에서 `MapColumn`이 이미 `thetaSuggestUpSec/DnSec/Conf`를 노출한다.
---
## STEP 5 — `ff.js` : θ 제안 표시
**파일**: `src/Web/wwwroot/js/ff.js`
### 5.1 θ 제안 const 추가 (return 직전)
**찾기**:
```javascript
return `
<div class="ff-col-card ${c.enabled?'':'ff-disabled'}">
```
**바꾸기**:
```javascript
const thetaSug = (c.streams||[]).filter(s => s.thetaSuggestConf != null);
const theta = thetaSug.length
? `<div class="ff-theta">θ 제안 <small>(passive)</small>: ${thetaSug.map(s=>`${esc(s.key)}${fmtVal(s.thetaSuggestUpSec)}s ↓${fmtVal(s.thetaSuggestDnSec)}s <small>conf ${fmtVal(s.thetaSuggestConf)}</small>`).join(' · ')} — 운전원 수동 반영</div>`
: '';
return `
<div class="ff-col-card ${c.enabled?'':'ff-disabled'}">
```
### 5.2 카드 본문에 ${theta} 삽입
> 전제: WO-2에서 mb 아래에 `${temps}`가 이미 들어가 있다.
**찾기**:
```javascript
<div class="ff-mb">${esc(mb)}</div>
${temps}
<div class="ff-note">LevelDriven(D·B) 레벨 제어(LIC) SP를 결정. 권장값은 참고 인가는 운전원.</div>
```
**바꾸기**:
```javascript
<div class="ff-mb">${esc(mb)}</div>
${temps}
${theta}
<div class="ff-note">LevelDriven(D·B) 레벨 제어(LIC) SP를 결정. 권장값은 참고 인가는 운전원.</div>
```
---
## STEP 6 — `ff.css` : θ 행 스타일
**파일**: `src/Web/wwwroot/css/ff.css`
**파일 맨 끝에 추가**:
```css
/* WO-3 θ 자동튜닝 제안 행 */
.ff-theta{font-size:12px;color:#cdb4ff;margin-top:6px}
.ff-theta small{color:var(--t2)}
```
---
## STEP 7 — 신규 테스트 `FeedforwardThetaTests.cs`
**신규 파일**: `tests/ExperionCrawler.Tests/FeedforwardThetaTests.cs`
```csharp
using System;
using ExperionCrawler.Infrastructure.Control;
using Xunit;
namespace ExperionCrawler.Tests;
public class FeedforwardThetaTests
{
// 알려진 지연(5 샘플)으로 응답이 피드를 따라가면 θ≈5*ts 로 식별되어야 함
[Fact]
public void Estimator_finds_known_lag()
{
var est = new CrossCorrLagEstimator(maxLagSamples: 20, historySamples: 400,
minSignalStd: 1e-9, recomputeEvery: 1);
var feed = new System.Collections.Generic.List<double>();
(double thetaUpSec, double thetaDnSec, double conf)? last = null;
for (int t = 0; t < 400; t++)
{
double df = Math.Sin(t * 0.3); // 풍부한 양/음 외란
feed.Add(df);
double dr = t >= 5 ? feed[t - 5] : 0.0; // 응답 = 피드 5샘플 지연
last = est.Push(df, dr, 0.0, tsSec: 1.0); // 스팀 0
}
Assert.NotNull(last);
Assert.InRange(last!.Value.thetaUpSec, 4.0, 6.0);
Assert.InRange(last!.Value.thetaDnSec, 4.0, 6.0);
Assert.True(last!.Value.conf > 0.5);
}
// 피드 외란이 없으면(평탄) 제안 억제(null)
[Fact]
public void Estimator_suppresses_when_no_excitation()
{
var est = new CrossCorrLagEstimator(maxLagSamples: 20, historySamples: 400,
minSignalStd: 1e-6, recomputeEvery: 1);
(double, double, double)? last = (0, 0, 0);
for (int t = 0; t < 200; t++) last = est.Push(0.0, 0.0, 0.0, 1.0); // Δ 전부 0
Assert.Null(last);
}
}
```
---
## STEP 8 — 검증 (반드시 실행하고 결과 첨부)
```bash
# 1) 빌드
dotnet build src/Web/ExperionCrawler.csproj 2>&1 | grep -E "Build succeeded|Warning|Error"
# 2) 테스트 — WO-2까지 12 + WO-3 신규 2 = 14
dotnet test tests/ExperionCrawler.Tests/ExperionCrawler.Tests.csproj 2>&1 | grep -E "Passed!|Failed!"
# 3) JS 문법
node -c src/Web/wwwroot/js/ff.js && echo "JS OK"
# 4) 쓰기 불변식(FF 경로 0건)
grep -rnE "ExperionOpcWriteClient|Write.*Async" src/Infrastructure/Control src/Web/Controllers/FeedforwardController.cs || echo "WRITE 0건 OK"
# 5) config θ 무변경 불변식 — 엔진이 cfg.Theta*를 쓰기(대입)하지 않는지
grep -nE "cfg\.(ThetaUpSec|ThetaDnSec)\s*=" src/Infrastructure/Control/*.cs || echo "config theta 무변경 OK"
```
**기대 결과**:
| 항목 | 기대 |
|:--|:--|
| 빌드 | `0 Warning(s) 0 Error(s)` |
| 테스트 | `Passed! - Failed: 0, Passed: 14` |
| JS | `JS OK` |
| 쓰기 | `WRITE 0건 OK` |
| config θ | `config theta 무변경 OK` |
### 런타임 확인(선택)
- `ff_column_config``theta_auto_tune=TRUE`, `steam_op_tag='tica-6111a.op'`, `sensitive_tray_tag='ti-6111c'`, `temp_tags='ti-6111b,ti-6111c,ti-6111d'`, `dtdp=0.5` 설정.
- 외란 충분히 누적(~1시간)된 뒤 카드에 "θ 제안 P ↑NNs ↓NNs conf 0.x" 표시. **config θ는 그대로**(제안만).
---
## 감독자 Sign-off
| 항목 | 상태 | 서명 |
|:--|:--:|:--:|
| CrossCorrLagEstimator: 알려진 지연 식별 | ✅ | windpacer 2026-05-31 |
| 외란 부족/저신뢰 시 null 억제 | ✅ | windpacer 2026-05-31 |
| 부분상관으로 스팀 제거(폐루프 오염 회피) | ✅ | windpacer 2026-05-31 |
| SteamOpTag을 .pv 강제 없이 SampleExact로 읽음 | ✅ | windpacer 2026-05-31 |
| **config θ 무변경**(제안 전용) | ✅ | windpacer 2026-05-31 |
| ThetaAutoTune=false면 완전 무동작(옵트인) | ✅ | windpacer 2026-05-31 |
| 빌드 0/0 · 테스트 14/14 · 쓰기 0건 | ✅ | windpacer 2026-05-31 |
---
## 주의(약한 LLM이 흔히 깨먹는 지점)
1. **config θ에 대입 금지**`cfg.ThetaUpSec = ...` 같은 코드 절대 금지. `StreamAdvisory.ThetaSuggest*`(제안)에만 쓴다.
2. **SteamOpTag은 .op**`Sample()`(=.pv 강제) 쓰지 말고 `SampleExact()`로. 실측 태그 접미사 확인.
3. **WO-2 선행 필수**`BuildTemps`/`PvSnapshot.Temps`/`ColumnState.PRef*`가 없으면 앵커가 안 맞는다. WO-2 먼저.
4. **positional record 금지**`PvSnapshot.SteamOp`는 init 프로퍼티로(생성자 인자 추가 금지). 생성은 `new PvSnapshot(...) { Temps=.., SteamOp=.. }`.
5. **테스트는 estimator를 직접** 호출(엔진 경유 X) — Δ를 직접 Push. recomputeEvery=1로 즉시 계산.
6. **첫 제안까지 시간** — maxLag*2 샘플 누적 전엔 null(정상). 실운전 ~1시간. 조급해하지 말 것.
</content>

View File

@@ -0,0 +1,275 @@
# WO-4 (P-4 느린 바이어스 적응) — 완전코드 작업지시서
> **대상 실행자**: 본 LLM보다 능력이 낮은 LLM. **추론·탐색 금지. 찾기/바꾸기 앵커와 전체 코드 블록을 그대로 복붙**.
> **선행 완료 전제**: §0 + WO-1 + WO-2 + WO-3 머지 완료. `ColumnConfig.BiasMaWindowSec`, `AdvisoryResult.VLossMa`,
> `StreamAdvisory.KObsSuggest`, `MovingAverage`(ComputationBlocks)는 **이미 존재**(다시 만들지 말 것).
> **불변식**: advisory — 쓰기 0건. K_obs·V_loss는 **장기 MA "제안/추세"** 일 뿐 엔진 K(=config TargetCoeff)는 변경 안 함.
## 목적
계절 CW 스윙 등 **크지만 느린 외란**(spec §14.4)을 정밀모델 대신 **장기 이동평균**으로 흡수.
- `V_loss`는 순간값 신뢰불가(§5.3·§14.3 B등급) → **장기 MA(VLossMa)** 로만 의미 → 대시보드 표시 + **WO-6 트리거 입력**.
- commanded 스트림별 **K_obs = PV/FEED_filtered 의 MA** → config K와 비교해 계절 드리프트 "제안".
- **정상상태에서만 누적**(transient·BAD 제외) → 과도 표본 오염 방지.
## 변경 파일 (총 4개)
1. `src/Infrastructure/Control/FeedforwardEngine.cs``ColumnState` MA 필드 + `ApplyBias` + Tick 배선
2. `src/Web/wwwroot/js/ff.js` — VLossMa·KObs 표시 (Controller는 §0에서 `vLossMa`/`kObsSuggest` 이미 노출 — **변경 없음**)
3. `src/Web/wwwroot/css/ff.css` — 바이어스 행 스타일
4. `tests/ExperionCrawler.Tests/FeedforwardBiasTests.cs`**신규** 테스트
---
## STEP 1 — `FeedforwardEngine.cs`
**파일**: `src/Infrastructure/Control/FeedforwardEngine.cs`
### 1.1 `ColumnState`에 MA 상태 추가
> 전제: WO-3에서 `PrevSteamOp` / `ThetaEst` 등이 이미 추가됨.
**찾기**:
```csharp
public double PrevSteamOp { get; set; } = double.NaN;
public Dictionary<string, StreamState> Streams { get; } = new();
```
**바꾸기**:
```csharp
public double PrevSteamOp { get; set; } = double.NaN;
// WO-4: 느린 바이어스 장기 MA (정상상태에서만 누적)
public MovingAverage? VLossMaBlock { get; set; }
public Dictionary<string, MovingAverage> KObsMa { get; } = new();
public Dictionary<string, StreamState> Streams { get; } = new();
```
### 1.2 Tick 배선 — return 직전, θ 제안 다음
> 전제: WO-3 이후 return 영역은 아래와 같다.
**찾기**:
```csharp
var temps = BuildTemps(cfg, pv, st); // WO-2 PCT 모니터
ApplyThetaSuggestion(cfg, pv, st, ts, temps, ref outs); // WO-3 θ 제안(advisory)
return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
transient, treason, ff, outs, vloss, yield, mbState)
{ Temps = temps };
```
**바꾸기**:
```csharp
var temps = BuildTemps(cfg, pv, st); // WO-2 PCT 모니터
ApplyThetaSuggestion(cfg, pv, st, ts, temps, ref outs); // WO-3 θ 제안(advisory)
ApplyBias(cfg, pv, st, ff, vloss, transient, ref outs, out var vLossMa); // WO-4 느린 바이어스
return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
transient, treason, ff, outs, vloss, yield, mbState)
{ Temps = temps, VLossMa = vLossMa };
```
### 1.3 `ApplyBias` 메서드 추가 (ApplyThetaSuggestion 바로 뒤)
> 전제: WO-3가 추가한 `ApplyThetaSuggestion`은 `.ToList();` + `}` 로 끝난다(아래 앵커는 그 마지막 2줄).
**찾기**:
```csharp
outs = outs.Select(a => a.Role == StreamRole.Commanded
? a with { ThetaSuggestUpSec = tu, ThetaSuggestDnSec = td, ThetaSuggestConf = conf }
: a).ToList();
}
```
**바꾸기**:
```csharp
outs = outs.Select(a => a.Role == StreamRole.Commanded
? a with { ThetaSuggestUpSec = tu, ThetaSuggestDnSec = td, ThetaSuggestConf = conf }
: a).ToList();
}
// ── WO-4 P-4: 느린 바이어스 장기 MA (정상상태에서만 누적, config 무변경) ──────
private static void ApplyBias(ColumnConfig cfg, PvSnapshot pv, ColumnState st, double ff, double? vloss,
bool transient, ref List<StreamAdvisory> outs, out double? vLossMa)
{
int window = Math.Max(1, (int)Math.Round(cfg.BiasMaWindowSec / Math.Max(1e-6, cfg.ScanSec)));
vLossMa = null;
// V_loss 장기 MA (정상상태 + vloss 산출된 경우에만 누적)
if (!transient && vloss.HasValue && Num.IsFinite(vloss.Value))
{
st.VLossMaBlock ??= new MovingAverage(window);
vLossMa = st.VLossMaBlock.Push(vloss.Value);
}
else if (st.VLossMaBlock is not null)
{
vLossMa = st.VLossMaBlock.Value; // 과도 중엔 갱신 없이 직전 MA 유지(표시 연속성)
}
// commanded 스트림별 K_obs = PV/FF 의 MA → 제안
if (transient || ff <= 1e-6) return;
outs = outs.Select(a =>
{
if (a.Role != StreamRole.Commanded) return a;
if (!(pv.Streams.TryGetValue(a.Key, out var smp) && smp.Good && Num.IsFinite(smp.Value))) return a;
if (!st.KObsMa.TryGetValue(a.Key, out var ma)) { ma = new MovingAverage(window); st.KObsMa[a.Key] = ma; }
double kObs = ma.Push(smp.Value / ff);
return a with { KObsSuggest = kObs };
}).ToList();
}
```
> **`MovingAverage`에 `Value` 프로퍼티가 없으면** 추가 필요. 확인: 현재 `MovingAverage`는 `Push`만 있고 `Value`가 없을 수 있다 → STEP 1.4 참조.
### 1.4 `MovingAverage.Value` 보강 (필요 시)
**파일**: `src/Infrastructure/Control/ComputationBlocks.cs`
**찾기**:
```csharp
public double Push(double x)
{
_buf.Enqueue(x); _sum += x;
while (_buf.Count > _window) _sum -= _buf.Dequeue();
return _sum / _buf.Count;
}
```
**바꾸기**:
```csharp
public double Value => _buf.Count > 0 ? _sum / _buf.Count : double.NaN;
public double Push(double x)
{
_buf.Enqueue(x); _sum += x;
while (_buf.Count > _window) _sum -= _buf.Dequeue();
return _sum / _buf.Count;
}
```
---
## STEP 2 — `ff.js` : VLossMa·KObs 표시
**파일**: `src/Web/wwwroot/js/ff.js`
### 2.1 mb 문자열에 VLossMa 추가
**찾기**:
```javascript
const mb = `물질수지: ${esc(c.massBalanceState)}` +
(c.vLoss!=null ? ` · V_loss ${fmtVal(c.vLoss)}` : '') +
(c.yield!=null ? ` · 수율 ${fmtVal(c.yield)}%` : '');
```
**바꾸기**:
```javascript
const mb = `물질수지: ${esc(c.massBalanceState)}` +
(c.vLoss!=null ? ` · V_loss ${fmtVal(c.vLoss)}` : '') +
(c.vLossMa!=null ? ` · V_loss(MA) ${fmtVal(c.vLossMa)}` : '') +
(c.yield!=null ? ` · 수율 ${fmtVal(c.yield)}%` : '');
```
### 2.2 스트림 행에 KObs 제안 (신뢰 셀 title에 병기)
**찾기**:
```javascript
<td><span class="ff-grade ff-grade-${esc(s.grade)}"${s.gradeReason ? ` title="${esc(s.gradeReason)}"` : ''}>${esc(s.grade)}</span></td>
```
**바꾸기**:
```javascript
<td><span class="ff-grade ff-grade-${esc(s.grade)}"${s.gradeReason ? ` title="${esc(s.gradeReason)}"` : ''}>${esc(s.grade)}</span>${s.kObsSuggest!=null ? `<br><small class="ff-kobs">K~${fmtVal(s.kObsSuggest)}</small>` : ''}</td>
```
---
## STEP 3 — `ff.css`
**파일**: `src/Web/wwwroot/css/ff.css` — 맨 끝에 추가:
```css
/* WO-4 K_obs 제안 */
.ff-kobs{color:#9fd;opacity:.8}
```
---
## STEP 4 — 신규 테스트 `FeedforwardBiasTests.cs`
**신규 파일**: `tests/ExperionCrawler.Tests/FeedforwardBiasTests.cs`
```csharp
using System;
using System.Collections.Generic;
using ExperionCrawler.Core.Application.Feedforward;
using ExperionCrawler.Infrastructure.Control;
using Xunit;
namespace ExperionCrawler.Tests;
public class FeedforwardBiasTests
{
private static ColumnConfig Cfg() => new()
{
Id = 1, Name = "C-BIAS", Enabled = true, FeedTag = "f", ProductKey = "P",
ScanSec = 2, BiasMaWindowSec = 20, // 10 샘플 창
Streams = new[]
{
new StreamConfig { Key = "P", FlowTag = "p", Role = StreamRole.Commanded, Grade = Confidence.A, TargetCoeff = 0.95 },
new StreamConfig { Key = "D", FlowTag = "d", Role = StreamRole.LevelDriven, Grade = Confidence.B, TargetCoeff = 0.02 },
new StreamConfig { Key = "B", FlowTag = "b", Role = StreamRole.LevelDriven, Grade = Confidence.B, TargetCoeff = 0.03 },
}
};
// FEED 100 고정, P=95 → K_obs ≈ 0.95, D/B는 물질수지 충족용
private static PvSnapshot Snap() => new(
new TagSample("f", 100, true, DateTime.UtcNow), null, Array.Empty<TagSample>(),
new Dictionary<string, TagSample> {
["P"] = new("p", 95, true, DateTime.UtcNow),
["D"] = new("d", 2, true, DateTime.UtcNow),
["B"] = new("b", 3, true, DateTime.UtcNow),
});
[Fact]
public void KObs_and_VLossMa_accumulate_in_steady_state()
{
var engine = new FeedforwardEngine();
var st = new ColumnState();
AdvisoryResult res = engine.Tick(Cfg(), Snap(), st, DateTime.UtcNow);
for (int i = 0; i < 20; i++) res = engine.Tick(Cfg(), Snap(), st, DateTime.UtcNow);
var p = res.Streams.Find(s => s.Key == "P")!;
Assert.NotNull(p.KObsSuggest);
Assert.InRange(p.KObsSuggest!.Value, 0.94, 0.96); // 95/100
Assert.NotNull(res.VLossMa);
Assert.InRange(res.VLossMa!.Value, -0.5, 0.5); // 100-(95+2+3)=0
}
}
```
---
## STEP 5 — 검증
```bash
dotnet build src/Web/ExperionCrawler.csproj 2>&1 | grep -E "Build succeeded|Warning|Error"
dotnet test tests/ExperionCrawler.Tests/ExperionCrawler.Tests.csproj 2>&1 | grep -E "Passed!|Failed!"
node -c src/Web/wwwroot/js/ff.js && echo "JS OK"
grep -rnE "ExperionOpcWriteClient|Write.*Async" src/Infrastructure/Control src/Web/Controllers/FeedforwardController.cs || echo "WRITE 0건 OK"
grep -nE "cfg\.TargetCoeff\s*=|s\.TargetCoeff\s*=" src/Infrastructure/Control/*.cs || echo "config K 무변경 OK"
```
**기대**: 빌드 0/0 · 테스트 **15/15**(WO-3까지 14 + 신규 1) · JS OK · 쓰기 0건 · config K 무변경 OK.
---
## 감독자 Sign-off
| 항목 | 상태 | 서명 |
|:--|:--:|:--:|
| 정상상태에서만 MA 누적(과도 표본 배제) | ✅ | windpacer 2026-05-31 |
| K_obs = PV/FF MA, config K 무변경 | ✅ | windpacer 2026-05-31 |
| VLossMa 산출(WO-6 트리거 입력) | ✅ | windpacer 2026-05-31 |
| MovingAverage.Value 보강 | ✅ | windpacer 2026-05-31 |
| 빌드 0/0 · 테스트 15/15 · 쓰기 0건 | ✅ | windpacer 2026-05-31 |
## 주의(약한 LLM 함정)
1. **config K(TargetCoeff) 대입 금지**`KObsSuggest`에만 쓴다(제안).
2. **과도 중 MA 갱신 금지**`transient` 시 Push 안 함(직전 값만 표시).
3. **MovingAverage.Value** 없으면 STEP 1.4로 보강(빌드 에러 방지).
4. positional record 인자추가 금지 — `VLossMa`/`KObsSuggest`는 init 프로퍼티(§0 기존).
</content>

View File

@@ -0,0 +1,304 @@
# WO-5 (P-3 Sweet-Spot / 프론트 위치 지표) — 완전코드 작업지시서
> **대상 실행자**: 본 LLM보다 능력이 낮은 LLM. **추론·탐색 금지. 찾기/바꾸기 앵커와 전체 코드 블록을 그대로 복붙**.
> **선행 완료 전제(필수)**: §0 + WO-1 + WO-2 + WO-3 + WO-4 머지 완료. **WO-2(PCT/차온)가 핵심 입력**.
> `AdvisoryResult.FrontPositionState/FrontTrimAdvice`(§0), `DiffTemp`(WO-2), `temps`(WO-2)는 **이미 존재**.
> **불변식**: advisory — 쓰기 0건. 프론트 트림은 **권장 문구만**(SP 미변경).
## 목적
spec §13.5의 2층 구조 중 **느린 조성 프론트 위치**를 온도 피드백으로 모니터. WO-2의 제품존 PCT(또는 차온)를
**프론트 위치 프록시**로 삼아, 느린 기준 대비 드리프트 시 **환류↑/boilup 트림을 권장**(advisory).
spec §13.2 함정②(제품존 신호 약함)·§14.3 C등급(단일 생온도면 신뢰 낮음)을 등급으로 반영.
> **공정 정석**(`knowledge/PGMEA_측류추출운전방식_주의점.md §3 1순위`): 감도트레이 온도가 프론트 위치의 최선 지표.
> 프론트 상승(경비물 혼입 위험) → 환류↑ 권장 / 프론트 하강 → boilup↑·환류↓ 권장.
## 변경 파일 (총 5개)
1. `src/Infrastructure/Control/FrontPositionIndicator.cs`**신규** 블록
2. `src/Infrastructure/Control/FeedforwardEngine.cs``ColumnState` 필드 + `ApplyFront` + Tick 배선
3. `src/Web/wwwroot/js/ff.js` — 프론트 상태/트림 배너 (Controller는 §0에서 `frontPositionState`/`frontTrimAdvice` 이미 노출)
4. `src/Web/wwwroot/css/ff.css` — 배너 스타일
5. `tests/ExperionCrawler.Tests/FeedforwardFrontTests.cs`**신규** 테스트
---
## STEP 1 — 신규 파일 `FrontPositionIndicator.cs`
**신규 파일**: `src/Infrastructure/Control/FrontPositionIndicator.cs`
```csharp
using ExperionCrawler.Core.Application.Feedforward;
namespace ExperionCrawler.Infrastructure.Control;
/// <summary>
/// 제품존 PCT/ΔT 의 느린 기준 대비 드리프트 → sweet-spot 건전성 + 트림 방향 권장(advisory).
/// 기준 = 느린 EMA(refTauSec). |metric - baseline| > bandwidth 면 드리프트.
/// I/O 없음, 컬럼 루프 단일 소유.
/// </summary>
public sealed class FrontPositionIndicator
{
private readonly double _bandwidth;
private readonly FirstOrderLag _baseline = new();
public FrontPositionIndicator(double bandwidth) => _bandwidth = Math.Max(1e-9, bandwidth);
/// <param name="frontMetric">민감트레이 PCT 또는 제품존 차온</param>
/// <param name="strongSignal">차온/analyzer 기반이면 true(등급↑), 단일 생온도면 false(C)</param>
public (string state, string? trimAdvice, Confidence grade) Update(
double frontMetric, double tsSec, double refTauSec, bool strongSignal)
{
double bl = _baseline.Step(frontMetric, refTauSec, tsSec);
double dev = frontMetric - bl;
Confidence grade = strongSignal ? Confidence.B : Confidence.C;
if (Math.Abs(dev) <= _bandwidth)
return ("정상(프론트 안정)", null, grade);
if (dev > 0)
return ("프론트 상승(경비물 혼입 위험)", "환류↑ 권장", grade);
return ("프론트 하강", "boilup↑·환류↓ 권장", grade);
}
}
```
---
## STEP 2 — `FeedforwardEngine.cs`
**파일**: `src/Infrastructure/Control/FeedforwardEngine.cs`
### 2.1 `ColumnState`에 인디케이터 추가
> 전제: WO-4에서 `KObsMa` 등이 이미 추가됨.
**찾기**:
```csharp
public MovingAverage? VLossMaBlock { get; set; }
public Dictionary<string, MovingAverage> KObsMa { get; } = new();
public Dictionary<string, StreamState> Streams { get; } = new();
```
**바꾸기**:
```csharp
public MovingAverage? VLossMaBlock { get; set; }
public Dictionary<string, MovingAverage> KObsMa { get; } = new();
public FrontPositionIndicator? FrontInd { get; set; } // WO-5
public Dictionary<string, StreamState> Streams { get; } = new();
```
### 2.2 Tick 배선 — return 직전, 바이어스 다음
> 전제: WO-4 이후 return 영역은 아래와 같다.
**찾기**:
```csharp
ApplyBias(cfg, pv, st, ff, vloss, transient, ref outs, out var vLossMa); // WO-4 느린 바이어스
return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
transient, treason, ff, outs, vloss, yield, mbState)
{ Temps = temps, VLossMa = vLossMa };
```
**바꾸기**:
```csharp
ApplyBias(cfg, pv, st, ff, vloss, transient, ref outs, out var vLossMa); // WO-4 느린 바이어스
var (frontState, frontTrim) = ApplyFront(cfg, st, ts, temps, transient); // WO-5 프론트 위치
return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
transient, treason, ff, outs, vloss, yield, mbState)
{ Temps = temps, VLossMa = vLossMa, FrontPositionState = frontState, FrontTrimAdvice = frontTrim };
```
### 2.3 `ApplyFront` 메서드 추가 (ApplyBias 바로 뒤)
> 전제: WO-4가 추가한 `ApplyBias`는 `}).ToList();` + `}` 로 끝난다.
**찾기**:
```csharp
double kObs = ma.Push(smp.Value / ff);
return a with { KObsSuggest = kObs };
}).ToList();
}
```
**바꾸기**:
```csharp
double kObs = ma.Push(smp.Value / ff);
return a with { KObsSuggest = kObs };
}).ToList();
}
// ── WO-5 P-3: 프론트 위치(sweet-spot) 지표 + 트림 권장(advisory) ──────────────
private static (string? state, string? trim) ApplyFront(ColumnConfig cfg, ColumnState st, double ts,
IReadOnlyList<TempPoint>? temps, bool transient)
{
if (temps is null || temps.Count == 0) return (null, null);
if (transient) return ("정착 대기(프론트 판정 보류)", null);
// 프론트 지표: 민감트레이 PCT 우선, 없으면 (상-하) 차온(ΔT)
double metric = double.NaN;
bool strong = false; // 차온이면 공통모드 상쇄 → 강신호
if (cfg.SensitiveTrayTag is not null)
{
var key = cfg.SensitiveTrayTag.EndsWith(".pv") ? cfg.SensitiveTrayTag : cfg.SensitiveTrayTag + ".pv";
foreach (var tp in temps) if (tp.Tag == key && tp.Good) { metric = tp.Pct; break; }
}
if (double.IsNaN(metric) && temps.Count >= 2 && temps[0].Good && temps[^1].Good)
{
metric = DiffTemp.Delta(temps[0].Pct, temps[^1].Pct); // 상-하 차온
strong = true;
}
if (double.IsNaN(metric)) return (null, null);
// 밴드폭: 컬럼 구배의 일부(대략 0.3°C 기본). refTau는 느린 기준(30분).
st.FrontInd ??= new FrontPositionIndicator(bandwidth: 0.3);
var (state, trim, _) = st.FrontInd.Update(metric, ts, refTauSec: 1800.0, strongSignal: strong);
return (state, trim);
}
```
> **Controller 변경 없음**: §0에서 `frontPositionState`/`frontTrimAdvice` 이미 노출.
---
## STEP 3 — `ff.js` : 프론트 배너
**파일**: `src/Web/wwwroot/js/ff.js`
### 3.1 프론트 배너 const (theta const 다음, return 직전)
> 전제: WO-3가 `const theta = ...` 를 추가했다.
**찾기**:
```javascript
const thetaSug = (c.streams||[]).filter(s => s.thetaSuggestConf != null);
const theta = thetaSug.length
? `<div class="ff-theta">θ 제안 <small>(passive)</small>: ${thetaSug.map(s=>`${esc(s.key)}${fmtVal(s.thetaSuggestUpSec)}s ↓${fmtVal(s.thetaSuggestDnSec)}s <small>conf ${fmtVal(s.thetaSuggestConf)}</small>`).join(' · ')} — 운전원 수동 반영</div>`
: '';
return `
```
**바꾸기**:
```javascript
const thetaSug = (c.streams||[]).filter(s => s.thetaSuggestConf != null);
const theta = thetaSug.length
? `<div class="ff-theta">θ 제안 <small>(passive)</small>: ${thetaSug.map(s=>`${esc(s.key)}${fmtVal(s.thetaSuggestUpSec)}s ↓${fmtVal(s.thetaSuggestDnSec)}s <small>conf ${fmtVal(s.thetaSuggestConf)}</small>`).join(' · ')} — 운전원 수동 반영</div>`
: '';
const front = c.frontPositionState
? `<div class="ff-front${c.frontTrimAdvice?' ff-front-warn':''}">프론트: ${esc(c.frontPositionState)}${c.frontTrimAdvice?` → <b>${esc(c.frontTrimAdvice)}</b>`:''}</div>`
: '';
return `
```
### 3.2 카드 본문에 ${front} 삽입
> 전제: WO-3에서 `${theta}` 가 이미 들어가 있다.
**찾기**:
```javascript
${temps}
${theta}
<div class="ff-note">LevelDriven(D·B) 레벨 제어(LIC) SP를 결정. 권장값은 참고 인가는 운전원.</div>
```
**바꾸기**:
```javascript
${temps}
${theta}
${front}
<div class="ff-note">LevelDriven(D·B) 레벨 제어(LIC) SP를 결정. 권장값은 참고 인가는 운전원.</div>
```
---
## STEP 4 — `ff.css`
**파일**: `src/Web/wwwroot/css/ff.css` — 맨 끝에 추가:
```css
/* WO-5 프론트 위치 */
.ff-front{font-size:12px;color:var(--t2);margin-top:6px}
.ff-front-warn{color:#ffd24d}
.ff-front-warn b{color:#ffb300}
```
---
## STEP 5 — 신규 테스트 `FeedforwardFrontTests.cs`
**신규 파일**: `tests/ExperionCrawler.Tests/FeedforwardFrontTests.cs`
```csharp
using ExperionCrawler.Core.Application.Feedforward;
using ExperionCrawler.Infrastructure.Control;
using Xunit;
namespace ExperionCrawler.Tests;
public class FeedforwardFrontTests
{
[Fact]
public void Front_stable_within_band()
{
var ind = new FrontPositionIndicator(bandwidth: 0.3);
// 기준이 100 부근으로 수렴하도록 여러번 같은 값
for (int i = 0; i < 50; i++) ind.Update(100.0, tsSec: 2, refTauSec: 60, strongSignal: true);
var (state, trim, grade) = ind.Update(100.1, 2, 60, true);
Assert.Contains("정상", state);
Assert.Null(trim);
Assert.Equal(Confidence.B, grade);
}
[Fact]
public void Front_rise_triggers_reflux_advice()
{
var ind = new FrontPositionIndicator(bandwidth: 0.3);
for (int i = 0; i < 200; i++) ind.Update(100.0, tsSec: 2, refTauSec: 60, strongSignal: false);
var (state, trim, grade) = ind.Update(105.0, 2, 60, false); // 기준 위로 급상승
Assert.Contains("상승", state);
Assert.Equal("환류↑ 권장", trim);
Assert.Equal(Confidence.C, grade); // 단일 생온도 → C
}
[Fact]
public void Front_fall_triggers_boilup_advice()
{
var ind = new FrontPositionIndicator(bandwidth: 0.3);
for (int i = 0; i < 200; i++) ind.Update(100.0, tsSec: 2, refTauSec: 60, strongSignal: true);
var (state, trim, _) = ind.Update(95.0, 2, 60, true);
Assert.Contains("하강", state);
Assert.Contains("boilup", trim);
}
}
```
---
## STEP 6 — 검증
```bash
dotnet build src/Web/ExperionCrawler.csproj 2>&1 | grep -E "Build succeeded|Warning|Error"
dotnet test tests/ExperionCrawler.Tests/ExperionCrawler.Tests.csproj 2>&1 | grep -E "Passed!|Failed!"
node -c src/Web/wwwroot/js/ff.js && echo "JS OK"
grep -rnE "ExperionOpcWriteClient|Write.*Async" src/Infrastructure/Control src/Web/Controllers/FeedforwardController.cs || echo "WRITE 0건 OK"
```
**기대**: 빌드 0/0 · 테스트 **18/18**(WO-4까지 15 + 신규 3) · JS OK · 쓰기 0건.
### 런타임(선택)
- `sensitive_tray_tag='ti-6111c'`, `temp_tags='ti-6111b,ti-6111c,ti-6111d'`, `dtdp=0.5` 설정.
- 카드에 "프론트: 정상(프론트 안정)" 또는 드리프트 시 "프론트: 프론트 상승(경비물 혼입 위험) → 환류↑ 권장".
---
## 감독자 Sign-off
| 항목 | 상태 | 서명 |
|:--|:--:|:--:|
| 밴드 내 「정상」, 상/하 드리프트 트림 분기 | ✅ | windpacer 2026-05-31 |
| 단일 생온도 C / 차온 B 등급 | ✅ | windpacer 2026-05-31 |
| 트림은 문구만(SP 미변경) | ✅ | windpacer 2026-05-31 |
| 과도 중 판정 보류 | ✅ | windpacer 2026-05-31 |
| 빌드 0/0 · 테스트 18/18 · 쓰기 0건 | ✅ | windpacer 2026-05-31 |
## 주의(약한 LLM 함정)
1. **WO-2 선행 필수**`temps`가 없으면 프론트 metric을 못 구한다.
2. **트림은 권장 문구** — 절대 SP/recommendedSp를 바꾸지 말 것.
3. `temps[^1]`은 C# 인덱스(마지막 원소). 컴파일러 8.0+ 지원(현 프로젝트 net8.0 OK).
4. positional record 인자추가 금지 — `FrontPositionState`/`FrontTrimAdvice`는 §0 init 프로퍼티.
</content>

View File

@@ -0,0 +1,612 @@
# WO-6 (전환류 Total Reflux 평형복귀 모드) — 완전코드 작업지시서 ★
> **대상 실행자**: 본 LLM보다 능력이 낮은 LLM. **추론·탐색 금지. 찾기/바꾸기 앵커와 전체 코드 블록을 그대로 복붙**.
> **선행 완료 전제(필수)**: §0 + WO-1 + WO-2 + WO-3 + WO-4 + WO-5 머지 완료. 특히 **WO-4(VLossMa)·WO-5(FrontPositionState)** 가
> 트리거 입력이므로 반드시 선행. `ColumnMode`, `ColumnConfig`의 recovery 필드들, `AdvisoryResult.Mode/ModeReason`(§0)은 **이미 존재**.
> **불변식(매우 중요)**: 본 WO도 **제어 레지스터 쓰기 0건**. 전환류는 "권장 SP 오버라이드 + 모드 표시 + 운전원 ARM"까지만.
> **실제 SP 쓰기(F·P·D·B 차단, R 전량환류)는 전부 PhaseIII(WriteGuard) 경유.** 여기서 SP를 직접 쓰면 불변식 위반.
## 목적
컬럼 균형이 **심각히 붕괴**하면(다신호 트리거) **전환류 모드**를 권장: FEED·P·D·B 권장SP=0(또는 RecoverySp), R=전량환류(SpMax),
평형 회복까지 dwell 후 **램프 복귀**. 근거 `knowledge/PGMEA_측류추출운전방식_주의점.md §4.3`("측류 먼저 중단→환류↑ 재안정화→재개").
## 상태기계 (`AdvisoryResult.Mode`)
```
Normal ──(severe 지속 ImbalanceTriggerSec + !transient + (AutoArm||운전원ARM))──▶ Recovering
Recovering ──(평형 회복 RecoverySettleSec 연속)──▶ Returning ──(ReturnRampSec 경과)──▶ Normal
(어느 상태든 운전원 cancel → Normal)
```
**severe 다신호 트리거(OR, 가용 신호만)**: ① `|VLossMa|/F > ImbalanceTriggerFrac`(WO-4) ② WO-5 프론트 "상승/하강" 드리프트 ③ `ΔP > DeltaPFloodLimit`.
## 변경 파일 (총 8개)
1. `src/Core/Application/Feedforward/FeedforwardModels.cs``AdvisoryResult.FeedRecommendedSp`, `PvSnapshot.DeltaP`
2. `src/Infrastructure/Control/FeedforwardEngine.cs``ColumnState` 모드 타이머 + `ApplyRecovery` + Tick 배선
3. `src/Infrastructure/Control/FeedforwardSupervisor.cs` — ΔP 읽기 + **ARM/Cancel API**(ColumnState 접근)
4. `src/Web/Program.cs` — Supervisor를 singleton+hosted로 (컨트롤러 주입용)
5. `src/Web/Controllers/FeedforwardController.cs``recovery/{id}/arm`·`cancel` + MapColumn에 `feedRecommendedSp`
6. `src/Web/wwwroot/js/ff.js` — 모드 뱃지 + ARM/취소 버튼
7. `src/Web/wwwroot/css/ff.css` — 모드 뱃지 스타일
8. `tests/ExperionCrawler.Tests/FeedforwardRecoveryTests.cs`**신규** 테스트
---
## STEP 1 — `FeedforwardModels.cs`
### 1.1 AdvisoryResult.FeedRecommendedSp 추가
**찾기**:
```csharp
public string? FrontPositionState { get; init; }
public string? FrontTrimAdvice { get; init; }
}
```
**바꾸기**:
```csharp
public string? FrontPositionState { get; init; }
public string? FrontTrimAdvice { get; init; }
public double? FeedRecommendedSp { get; init; } // WO-6 전환류 시 FEED 권장(0=차단), 그 외 null
}
```
### 1.2 PvSnapshot.DeltaP 추가
> 전제: WO-3에서 `SteamOp`가 추가됨.
**찾기**:
```csharp
public IReadOnlyList<TagSample>? Temps { get; init; }
public TagSample? SteamOp { get; init; } // WO-3: TICA.OP (스팀, 부분상관 2번째 입력)
}
```
**바꾸기**:
```csharp
public IReadOnlyList<TagSample>? Temps { get; init; }
public TagSample? SteamOp { get; init; } // WO-3: TICA.OP (스팀, 부분상관 2번째 입력)
public TagSample? DeltaP { get; init; } // WO-6: 탑 차압(플러딩 트리거)
}
```
---
## STEP 2 — `FeedforwardEngine.cs`
### 2.1 `ColumnState`에 모드/타이머/ARM 추가
> 전제: WO-5에서 `FrontInd`가 추가됨.
**찾기**:
```csharp
public FrontPositionIndicator? FrontInd { get; set; } // WO-5
public Dictionary<string, StreamState> Streams { get; } = new();
```
**바꾸기**:
```csharp
public FrontPositionIndicator? FrontInd { get; set; } // WO-5
// WO-6 전환류 상태기계
public ColumnMode Mode { get; set; } = ColumnMode.Normal;
public double ImbalanceTimerSec { get; set; }
public double RecoverySettleTimerSec { get; set; }
public double ReturnTimerSec { get; set; }
public bool OperatorArmed { get; set; } // 컨트롤러가 set
public bool OperatorCancel { get; set; } // 컨트롤러가 set(즉시 Normal)
public Dictionary<string, StreamState> Streams { get; } = new();
```
### 2.2 Tick 배선 — return 직전, 프론트 다음
> 전제: WO-5 이후 return 영역.
**찾기**:
```csharp
var (frontState, frontTrim) = ApplyFront(cfg, st, ts, temps, transient); // WO-5 프론트 위치
return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
transient, treason, ff, outs, vloss, yield, mbState)
{ Temps = temps, VLossMa = vLossMa, FrontPositionState = frontState, FrontTrimAdvice = frontTrim };
```
**바꾸기**:
```csharp
var (frontState, frontTrim) = ApplyFront(cfg, st, ts, temps, transient); // WO-5 프론트 위치
var (mode, modeReason, feedRecSp) = ApplyRecovery(
cfg, pv, st, ts, ff, vLossMa, frontState, transient, ref outs); // WO-6 전환류 복귀
return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
transient, treason, ff, outs, vloss, yield, mbState)
{ Temps = temps, VLossMa = vLossMa, FrontPositionState = frontState, FrontTrimAdvice = frontTrim,
Mode = mode, ModeReason = modeReason, FeedRecommendedSp = feedRecSp };
```
### 2.3 `ApplyRecovery` 메서드 추가 (ApplyFront 바로 뒤)
> 전제: WO-5가 추가한 `ApplyFront`는 `return (state, trim);` + `}` 로 끝난다.
**찾기**:
```csharp
var (state, trim, _) = st.FrontInd.Update(metric, ts, refTauSec: 1800.0, strongSignal: strong);
return (state, trim);
}
```
**바꾸기**:
```csharp
var (state, trim, _) = st.FrontInd.Update(metric, ts, refTauSec: 1800.0, strongSignal: strong);
return (state, trim);
}
// ── WO-6: 전환류(Total Reflux) 평형복귀 상태기계 (advisory — 권장값 오버라이드만, 쓰기 없음) ──
private static (ColumnMode mode, string? reason, double? feedRecSp) ApplyRecovery(
ColumnConfig cfg, PvSnapshot pv, ColumnState st, double ts, double ff,
double? vLossMa, string? frontState, bool transient, ref List<StreamAdvisory> outs)
{
// 기능 off → 항상 Normal(상태 리셋)
if (!cfg.RecoveryEnabled)
{
st.Mode = ColumnMode.Normal; st.ImbalanceTimerSec = 0; st.OperatorArmed = false; st.OperatorCancel = false;
return (ColumnMode.Normal, null, null);
}
// 운전원 수동 취소 → 즉시 Normal
if (st.OperatorCancel)
{
st.OperatorCancel = false; st.OperatorArmed = false;
st.Mode = ColumnMode.Normal; st.ImbalanceTimerSec = 0; st.RecoverySettleTimerSec = 0; st.ReturnTimerSec = 0;
return (ColumnMode.Normal, "운전원 취소", null);
}
// 다신호 severe 판정 (가용 신호만 OR)
double frac = (vLossMa.HasValue && ff > 1e-6) ? Math.Abs(vLossMa.Value) / ff : 0.0;
bool sigVloss = vLossMa.HasValue && frac > cfg.ImbalanceTriggerFrac;
bool sigFront = frontState is not null && (frontState.Contains("상승") || frontState.Contains("하강"));
bool sigDp = pv.DeltaP is { Good: true } dp && Num.IsFinite(dp.Value) && dp.Value > cfg.DeltaPFloodLimit;
bool severe = sigVloss || sigFront || sigDp;
string SeverityText() =>
(sigVloss ? $"물질수지({frac:P0}) " : "") + (sigFront ? "프론트드리프트 " : "") + (sigDp ? "ΔP플러딩" : "");
switch (st.Mode)
{
case ColumnMode.Normal:
if (!transient && severe) st.ImbalanceTimerSec += ts; else st.ImbalanceTimerSec = 0;
bool armed = cfg.RecoveryAutoArm || st.OperatorArmed;
if (st.ImbalanceTimerSec >= cfg.ImbalanceTriggerSec && armed)
{
st.Mode = ColumnMode.Recovering; st.OperatorArmed = false;
st.RecoverySettleTimerSec = 0;
return (ColumnMode.Recovering, $"전환류 진입: {SeverityText()}", OverrideRecovering(cfg, ref outs));
}
// ARM 대기 표시(자동무장 아님 + 임계 지속)
if (st.ImbalanceTimerSec >= cfg.ImbalanceTriggerSec)
return (ColumnMode.Normal, $"전환류 권장(ARM 대기): {SeverityText()}", null);
return (ColumnMode.Normal, null, null);
case ColumnMode.Recovering:
{
var feedRec = OverrideRecovering(cfg, ref outs);
// 평형 회복: severe 해제 + frac < Frac*0.5 연속
bool recovered = !severe && frac < cfg.ImbalanceTriggerFrac * 0.5;
if (recovered) st.RecoverySettleTimerSec += ts; else st.RecoverySettleTimerSec = 0;
if (st.RecoverySettleTimerSec >= cfg.RecoverySettleSec)
{
st.Mode = ColumnMode.Returning; st.ReturnTimerSec = 0;
return (ColumnMode.Returning, "평형 회복 — 복귀 램프 시작", null);
}
return (ColumnMode.Recovering, $"전환류 평형대기 {st.RecoverySettleTimerSec:F0}/{cfg.RecoverySettleSec:F0}s", feedRec);
}
case ColumnMode.Returning:
st.ReturnTimerSec += ts;
if (st.ReturnTimerSec >= cfg.ReturnRampSec)
{
st.Mode = ColumnMode.Normal;
return (ColumnMode.Normal, "복귀 완료", null);
}
// 램프 중엔 정상 권장값 그대로(RateLimiter가 자연 램프) + FEED는 정상 복원 표시(null)
return (ColumnMode.Returning, $"복귀 램프 {st.ReturnTimerSec:F0}/{cfg.ReturnRampSec:F0}s", null);
default:
st.Mode = ColumnMode.Normal;
return (ColumnMode.Normal, null, null);
}
}
/// <summary>Recovering 권장값 오버라이드: reflux=SpMax(전량), draw(P/D/B)=RecoverySp(NaN→0). FEED 권장 반환.</summary>
private static double? OverrideRecovering(ColumnConfig cfg, ref List<StreamAdvisory> outs)
{
outs = outs.Select(a =>
{
// reflux 스트림 식별: IsReflux 또는 RefluxFromProduct
var sc = cfg.Streams.FirstOrDefault(x => x.Key == a.Key);
bool isReflux = sc is not null && (sc.IsReflux || sc.RefluxFromProduct);
double? ov;
if (isReflux) ov = sc!.SpMax; // 전량 환류
else if (a.Role == StreamRole.Monitor) ov = a.RecommendedSp; // 모니터는 그대로
else ov = (sc is not null && !double.IsNaN(sc.RecoverySp)) ? sc.RecoverySp : 0.0; // draw 차단
return a with { RecommendedSp = ov, Valid = false, Note = "전환류 복귀 — 운전원 인가 필요" };
}).ToList();
return cfg.FeedRecoverySp; // FEED 권장(기본 0=차단)
}
```
---
## STEP 3 — `FeedforwardSupervisor.cs`
### 3.1 ΔP 읽기
**찾기**:
```csharp
if (cfg.SteamOpTag is not null) tags.Add(cfg.SteamOpTag.ToLowerInvariant()); // WO-3 스팀 OP(.op 그대로)
```
**바꾸기**:
```csharp
if (cfg.SteamOpTag is not null) tags.Add(cfg.SteamOpTag.ToLowerInvariant()); // WO-3 스팀 OP(.op 그대로)
if (cfg.DeltaPTag is not null) tags.Add(PvTag(cfg.DeltaPTag)); // WO-6 차압(.pv)
```
### 3.2 PvSnapshot에 DeltaP
> 전제: WO-3에서 return이 `{ Temps = temps, SteamOp = steam }` 형태다.
**찾기**:
```csharp
var steam = cfg.SteamOpTag is not null ? SampleExact(cfg.SteamOpTag) : null;
return new PvSnapshot(feed, press, levels, streams) { Temps = temps, SteamOp = steam };
```
**바꾸기**:
```csharp
var steam = cfg.SteamOpTag is not null ? SampleExact(cfg.SteamOpTag) : null;
var deltaP = cfg.DeltaPTag is not null ? Sample(cfg.DeltaPTag) : null;
return new PvSnapshot(feed, press, levels, streams) { Temps = temps, SteamOp = steam, DeltaP = deltaP };
```
### 3.3 ARM/Cancel 공개 메서드 (클래스 맨 끝, ExecuteAsync 등과 같은 레벨)
> 전제: `_states`는 `private readonly Dictionary<int, ColumnState> _states`. `GetState`는 이미 있다.
**찾기** (파일에서 `GetState` 메서드 전체):
```csharp
private ColumnState GetState(int id)
{
if (!_states.TryGetValue(id, out var s)) { s = new ColumnState(); _states[id] = s; }
return s;
}
```
**바꾸기**:
```csharp
private ColumnState GetState(int id)
{
if (!_states.TryGetValue(id, out var s)) { s = new ColumnState(); _states[id] = s; }
return s;
}
// WO-6: 운전원 ARM/취소 (모드 판정용 플래그만 — 쓰기 아님). 다음 Tick에서 소비.
public bool Arm(int columnId) { lock (_states) { GetState(columnId).OperatorArmed = true; } return true; }
public bool Cancel(int columnId) { lock (_states) { GetState(columnId).OperatorCancel = true; } return true; }
```
> **동시성**: `_states`는 평소 Tick 루프(단일 스레드) 소유지만 ARM/Cancel은 HTTP 스레드에서 set한다. bool 단일 대입이라 사실상 안전하나 명시적 `lock`으로 보호. Tick 측 읽기는 다음 주기에 자연 반영(즉시성 불필요).
---
## STEP 4 — `Program.cs` : Supervisor를 singleton+hosted로
**파일**: `src/Web/Program.cs`
**찾기**:
```csharp
builder.Services.AddHostedService<ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor>();
```
**바꾸기**:
```csharp
builder.Services.AddSingleton<ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor>());
```
> 단일 인스턴스를 hosted(백그라운드)+injectable(컨트롤러)로 동시 노출. 인스턴스는 **1개만** 가동(틱 루프 1회).
---
## STEP 5 — `FeedforwardController.cs`
### 5.1 Supervisor 주입
**찾기**:
```csharp
private readonly IFeedforwardAdvisoryStore _store;
private readonly IFeedforwardConfigStore _config;
public FeedforwardController(
IFeedforwardAdvisoryStore store,
IFeedforwardConfigStore config)
{ _store = store; _config = config; }
```
**바꾸기**:
```csharp
private readonly IFeedforwardAdvisoryStore _store;
private readonly IFeedforwardConfigStore _config;
private readonly ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor _supervisor;
public FeedforwardController(
IFeedforwardAdvisoryStore store,
IFeedforwardConfigStore config,
ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor supervisor)
{ _store = store; _config = config; _supervisor = supervisor; }
```
### 5.2 ARM/Cancel 엔드포인트 (DeleteConfig 메서드 다음)
**찾기**:
```csharp
[HttpDelete("config/{id:int}")]
public async Task<IActionResult> DeleteConfig(int id, CancellationToken ct)
{
await _config.DeleteColumnAsync(id, ct);
return Ok(new { success = true });
}
```
**바꾸기**:
```csharp
[HttpDelete("config/{id:int}")]
public async Task<IActionResult> DeleteConfig(int id, CancellationToken ct)
{
await _config.DeleteColumnAsync(id, ct);
return Ok(new { success = true });
}
// ── WO-6 전환류 ARM/취소 (쓰기 아님 — 모드 판정 플래그) ──
[HttpPost("recovery/{id:int}/arm")]
public IActionResult ArmRecovery(int id) => Ok(new { success = _supervisor.Arm(id) });
[HttpPost("recovery/{id:int}/cancel")]
public IActionResult CancelRecovery(int id) => Ok(new { success = _supervisor.Cancel(id) });
```
### 5.3 MapColumn에 feedRecommendedSp 노출
**찾기**:
```csharp
mode = r.Mode.ToString(),
modeReason = r.ModeReason,
vLossMa = r.VLossMa,
```
**바꾸기**:
```csharp
mode = r.Mode.ToString(),
modeReason = r.ModeReason,
feedRecommendedSp = r.FeedRecommendedSp,
vLossMa = r.VLossMa,
```
---
## STEP 6 — `ff.js` : 모드 뱃지 + ARM/취소
**파일**: `src/Web/wwwroot/js/ff.js`
### 6.1 모드 뱃지/버튼 const (front const 다음, return 직전)
> 전제: WO-5가 `const front = ...` 를 추가했다.
**찾기**:
```javascript
const front = c.frontPositionState
? `<div class="ff-front${c.frontTrimAdvice?' ff-front-warn':''}">프론트: ${esc(c.frontPositionState)}${c.frontTrimAdvice?` → <b>${esc(c.frontTrimAdvice)}</b>`:''}</div>`
: '';
return `
```
**바꾸기**:
```javascript
const front = c.frontPositionState
? `<div class="ff-front${c.frontTrimAdvice?' ff-front-warn':''}">프론트: ${esc(c.frontPositionState)}${c.frontTrimAdvice?` → <b>${esc(c.frontTrimAdvice)}</b>`:''}</div>`
: '';
const armWait = (c.mode === 'Normal' && c.modeReason && c.modeReason.indexOf('ARM 대기') >= 0);
const modeBadge =
c.mode === 'Recovering' ? '<span class="ff-mode ff-mode-rec">전환류 복귀중 ●</span>'
: c.mode === 'Returning' ? '<span class="ff-mode ff-mode-ret">복귀 램프 ●</span>'
: armWait ? '<span class="ff-mode ff-mode-arm">전환류 권장 ⚠</span>'
: '';
const recoveryCtl =
armWait ? `<button class="btn sm danger" onclick="ffArm(${c.columnId})">전환류 ARM</button>`
: (c.mode==='Recovering'||c.mode==='Returning') ? `<button class="btn sm" onclick="ffCancelRecovery(${c.columnId})">취소(정상복귀)</button>`
: '';
const modeLine = (modeBadge || c.modeReason)
? `<div class="ff-modeline">${modeBadge} <small>${esc(c.modeReason||'')}</small> ${recoveryCtl}</div>` : '';
return `
```
### 6.2 카드 헤더에 ${modeLine} 삽입
**찾기**:
```javascript
<span class="ff-time">${fmtTs(c.computedAt)}</span></div>
${banner}
```
**바꾸기**:
```javascript
<span class="ff-time">${fmtTs(c.computedAt)}</span></div>
${modeLine}
${banner}
```
### 6.3 ARM/Cancel 호출 함수 (ffCard 함수 바로 위 또는 파일 끝에 추가)
**찾기**:
```javascript
function ffCard(c) {
```
**바꾸기**:
```javascript
function ffArm(id) {
if (!confirm(`컬럼 ${id} 전환류 모드를 ARM(가동) 하시겠습니까?\n드로우 중단·전량 환류가 권장됩니다(실제 쓰기는 별도 인가).`)) return;
ffApi('POST', `/api/ff/recovery/${id}/arm`).then(()=>ffLoadDash()).catch(()=>{});
}
function ffCancelRecovery(id) {
ffApi('POST', `/api/ff/recovery/${id}/cancel`).then(()=>ffLoadDash()).catch(()=>{});
}
function ffCard(c) {
```
---
## STEP 7 — `ff.css`
**파일**: `src/Web/wwwroot/css/ff.css` — 맨 끝에 추가:
```css
/* WO-6 전환류 모드 */
.ff-modeline{margin:4px 0;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.ff-mode{font-size:12px;font-weight:600;padding:2px 8px;border-radius:10px}
.ff-mode-rec{background:#5a3000;color:#ffb74d}
.ff-mode-ret{background:#003a4d;color:#7fd1ff}
.ff-mode-arm{background:#5a0000;color:#ff8a80;animation:ffblink 1s step-start infinite}
@keyframes ffblink{50%{opacity:.4}}
```
---
## STEP 8 — 신규 테스트 `FeedforwardRecoveryTests.cs`
**신규 파일**: `tests/ExperionCrawler.Tests/FeedforwardRecoveryTests.cs`
```csharp
using System;
using System.Collections.Generic;
using System.Linq;
using ExperionCrawler.Core.Application.Feedforward;
using ExperionCrawler.Infrastructure.Control;
using Xunit;
namespace ExperionCrawler.Tests;
public class FeedforwardRecoveryTests
{
// VLossMa 트리거가 빨리 잡히도록 작은 창/짧은 타이머
private static ColumnConfig Cfg(bool autoArm) => new()
{
Id = 1, Name = "C-REC", Enabled = true, FeedTag = "f", ProductKey = "P",
ScanSec = 2, BiasMaWindowSec = 4,
RecoveryEnabled = true, RecoveryAutoArm = autoArm,
ImbalanceTriggerFrac = 0.10, ImbalanceTriggerSec = 4, // 2틱
RecoverySettleSec = 4, ReturnRampSec = 4, FeedRecoverySp = 0,
Streams = new[]
{
new StreamConfig { Key="P", FlowTag="p", Role=StreamRole.Commanded, Grade=Confidence.A, TargetCoeff=0.95, SpMax=950 },
new StreamConfig { Key="R", FlowTag="r", Role=StreamRole.Commanded, Grade=Confidence.A, TargetCoeff=0.80, SpMax=1100, RefluxFromProduct=true },
new StreamConfig { Key="D", FlowTag="d", Role=StreamRole.LevelDriven, Grade=Confidence.B, TargetCoeff=0.02, SpMax=60 },
new StreamConfig { Key="B", FlowTag="b", Role=StreamRole.LevelDriven, Grade=Confidence.B, TargetCoeff=0.03, SpMax=80 },
}
};
// 큰 V_loss(불균형): FEED 100인데 D+P+B 합이 작음 → vloss 큼
private static PvSnapshot Imbalanced() => new(
new TagSample("f", 100, true, DateTime.UtcNow), null, Array.Empty<TagSample>(),
new Dictionary<string, TagSample> {
["P"]=new("p",30,true,DateTime.UtcNow), ["R"]=new("r",50,true,DateTime.UtcNow),
["D"]=new("d",2,true,DateTime.UtcNow), ["B"]=new("b",3,true,DateTime.UtcNow) }); // vloss=100-35=65
private static PvSnapshot Balanced() => new(
new TagSample("f", 100, true, DateTime.UtcNow), null, Array.Empty<TagSample>(),
new Dictionary<string, TagSample> {
["P"]=new("p",95,true,DateTime.UtcNow), ["R"]=new("r",760,true,DateTime.UtcNow),
["D"]=new("d",2,true,DateTime.UtcNow), ["B"]=new("b",3,true,DateTime.UtcNow) }); // vloss=0
[Fact]
public void AutoArm_enters_recovering_on_sustained_imbalance()
{
var engine = new FeedforwardEngine(); var st = new ColumnState();
AdvisoryResult res = engine.Tick(Cfg(autoArm:true), Imbalanced(), st, DateTime.UtcNow);
for (int i = 0; i < 6; i++) res = engine.Tick(Cfg(autoArm:true), Imbalanced(), st, DateTime.UtcNow);
Assert.Equal(ColumnMode.Recovering, res.Mode);
// 권장값 오버라이드: R(reflux)=SpMax, P/D/B=0, FEED=0
Assert.Equal(0.0, res.FeedRecommendedSp);
var r = res.Streams.First(s => s.Key == "R");
var p = res.Streams.First(s => s.Key == "P");
Assert.Equal(1100.0, r.RecommendedSp);
Assert.Equal(0.0, p.RecommendedSp);
Assert.False(p.Valid);
}
[Fact]
public void ManualArm_required_when_autoArm_false()
{
var engine = new FeedforwardEngine(); var st = new ColumnState();
for (int i = 0; i < 6; i++) engine.Tick(Cfg(autoArm:false), Imbalanced(), st, DateTime.UtcNow);
Assert.Equal(ColumnMode.Normal, st.Mode); // ARM 없으면 진입 안 함
st.OperatorArmed = true; // 운전원 ARM
var res = engine.Tick(Cfg(autoArm:false), Imbalanced(), st, DateTime.UtcNow);
Assert.Equal(ColumnMode.Recovering, res.Mode);
}
[Fact]
public void Recovers_then_returns_to_normal()
{
var engine = new FeedforwardEngine(); var st = new ColumnState();
for (int i = 0; i < 6; i++) engine.Tick(Cfg(autoArm:true), Imbalanced(), st, DateTime.UtcNow);
Assert.Equal(ColumnMode.Recovering, st.Mode);
// 균형 회복 입력 지속 → Returning → Normal
AdvisoryResult res = null!;
for (int i = 0; i < 10; i++) res = engine.Tick(Cfg(autoArm:true), Balanced(), st, DateTime.UtcNow);
Assert.Equal(ColumnMode.Normal, res.Mode);
}
[Fact]
public void Cancel_returns_to_normal_immediately()
{
var engine = new FeedforwardEngine(); var st = new ColumnState();
for (int i = 0; i < 6; i++) engine.Tick(Cfg(autoArm:true), Imbalanced(), st, DateTime.UtcNow);
Assert.Equal(ColumnMode.Recovering, st.Mode);
st.OperatorCancel = true;
var res = engine.Tick(Cfg(autoArm:true), Imbalanced(), st, DateTime.UtcNow);
Assert.Equal(ColumnMode.Normal, res.Mode);
}
}
```
---
## STEP 9 — 검증
```bash
dotnet build src/Web/ExperionCrawler.csproj 2>&1 | grep -E "Build succeeded|Warning|Error"
dotnet test tests/ExperionCrawler.Tests/ExperionCrawler.Tests.csproj 2>&1 | grep -E "Passed!|Failed!"
node -c src/Web/wwwroot/js/ff.js && echo "JS OK"
# 쓰기 불변식 — 전환류도 advisory: FF 경로 쓰기 0건
grep -rnE "ExperionOpcWriteClient|Write.*Async|WriteTagAsync" src/Infrastructure/Control src/Web/Controllers/FeedforwardController.cs || echo "WRITE 0건 OK"
# Supervisor 단일 인스턴스 — AddHostedService<FeedforwardSupervisor>() 직접등록 없어야
grep -n "AddHostedService<.*FeedforwardSupervisor>" src/Web/Program.cs || echo "단일 인스턴스 OK"
```
**기대**: 빌드 0/0 · 테스트 **22/22**(WO-5까지 18 + 신규 4) · JS OK · 쓰기 0건 · 단일 인스턴스 OK.
### 런타임(선택)
- `recovery_enabled=TRUE`, `recovery_auto_arm=FALSE`, `imbalance_trigger_frac=0.1`, `imbalance_trigger_sec=600` 설정.
- 불균형 지속 → 카드에 "전환류 권장 ⚠ [전환류 ARM]" → 클릭 → "전환류 복귀중 ●", R=SpMax·P/D/B=0·FEED=0 권장 → 회복 후 "복귀 램프" → Normal.
---
## 감독자 Sign-off
| 항목 | 상태 | 서명 |
|:--|:--:|:--:|
| 다신호 트리거(VLossMa|프론트|ΔP) 지속+!transient | ✅ | windpacer 2026-05-31 |
| AutoArm=false면 운전원 ARM 없이 진입 안 함 | ✅ | windpacer 2026-05-31 |
| Recovering 오버라이드(R=SpMax, draw=0, FEED=0, Valid=false) | ✅ | windpacer 2026-05-31 |
| 회복→Returning→Normal 전이 | ✅ | windpacer 2026-05-31 |
| 운전원 cancel 즉시 Normal | ✅ | windpacer 2026-05-31 |
| **쓰기 0건**(전환류도 advisory — 실제 쓰기는 PhaseIII) | ✅ | windpacer 2026-05-31 |
| Supervisor 단일 인스턴스(틱 1회) | ✅ | windpacer 2026-05-31 |
| 빌드 0/0 · 테스트 22/22 | ✅ | windpacer 2026-05-31 |
## 주의(약한 LLM 함정) ★
1. **실제 SP 쓰기 절대 금지** — Recovering은 `StreamAdvisory.RecommendedSp` 숫자만 바꾼다(권장 표시). `ExperionOpcWriteClient` 호출 0건. 실제 차단/환류는 PhaseIII.
2. **트리거는 VLossMa(장기 MA)** — 순간 `vloss` 쓰지 말 것(오발동). WO-4 선행 필수.
3. **Supervisor DI** — STEP 4를 빼먹으면 컨트롤러 주입 실패(런타임 DI 예외). `AddHostedService<T>()` 직접등록은 제거.
4. **ARM/Cancel은 다음 Tick에 반영** — 즉시 모드 변경 아님(폴링으로 곧 보임). 정상.
5. positional record 인자추가 금지 — `FeedRecommendedSp`/`DeltaP`는 init 프로퍼티.
6. 테스트 타이머는 작게(ImbalanceTriggerSec=4=2틱) — 실 기본값(600s)으로 테스트하면 안 끝남.
</content>

View File

@@ -0,0 +1,277 @@
# WO-7 (설정 편집 폼 확장 — 신규 필드 운전원 노출) — 완전코드 작업지시서
> **대상 실행자**: 본 LLM보다 능력이 낮은 LLM. **추론·탐색 금지. 찾기/바꾸기 앵커와 전체 코드 블록을 그대로 복붙**.
> **선행 완료 전제(검증됨)**: §0 + WO-1~6 전부 머지 완료. 백엔드(`ColumnConfig`/`StreamConfig` 신규 필드, `ff_*` DDL,
> ConfigStore Save/Load, Controller `MapConfig`)는 **이미 신규 필드를 저장·반환**한다. **본 WO는 프론트 폼(ff.js)만** 손댄다.
> **불변식**: 쓰기 0건(설정 저장은 advisory config일 뿐). C# 코드 변경 없음.
## 배경 / 목적
현재 설정 모달(`ffEditColumn`)은 §0 이전의 기본 필드(Feed/압력/Scan/필터/스트림 K·θ·τ…)만 폼에 노출한다.
WO-2~6에서 추가된 **온도/PCT·θ자동튜닝·느린바이어스·전환류 트리거/설정·스트림 환류/복귀SP** 필드는
**API로는 저장/조회되지만 폼에 입력칸이 없어** 운전원이 화면에서 못 바꾼다(저장 시 `undefined`→백엔드 기본값 유지).
특히 운전원 질문 "**균형 심각붕괴 트리거를 수정할 수 있나?**" → 현재 폼엔 없음. **본 WO로 노출**한다.
> **검증 사실**: `GET /api/ff/config`는 `tempTags, sensitiveTrayTag, dtdp, pRef, steamOpTag, thetaAutoTune, biasMaWindowSec,
> recoveryEnabled, recoveryAutoArm, imbalanceTriggerFrac, imbalanceTriggerSec, recoverySettleSec, returnRampSec,
> feedRecoverySp, deltaPTag, deltaPFloodLimit`(컬럼) + `isReflux, recoverySp`(스트림)을 이미 반환한다(Controller MapConfig).
> 따라서 **기존 컬럼 편집 시**엔 `{...c}`로 값이 이미 들어오고, **새 컬럼**만 default 보강이 필요하다.
## 변경 파일 (총 2개)
1. `src/Web/wwwroot/js/ff.js``def`(새컬럼 기본값) + `colHtml`(입력칸) + `ffStreamRow`(스트림 2칸) + `ffSaveForm`(저장)
2. `src/Web/wwwroot/css/ff.css` — 트리거 강조 스타일(선택)
---
## STEP 1 — 새 컬럼 기본값(`def`)에 신규 필드 추가
**파일**: `src/Web/wwwroot/js/ff.js`
**위치**: `ffEditColumn` 함수의 `const def = isNew ? {...} : {...}`
**찾기**:
```javascript
const def = isNew
? { name:'', enabled:false, feedTag:'', pressureTag:'',
scanSec:2, feedFilterTauSec:300, feedMoveThresholdPerMin:5,
pressFilterTauSec:60, pressureBand:3, settleSec:1800, staleSec:120, productKey:'P',
streams:[
{key:'P',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0.95,thetaUpSec:60,thetaDnSec:60,tauSec:900,spMin:0,spMax:9999,rateUpPerMin:30,rateDnPerMin:60,refluxFromProduct:false,grade:'A'},
{key:'R',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0.80,thetaUpSec:0,thetaDnSec:0,tauSec:0,spMin:0,spMax:9999,rateUpPerMin:30,rateDnPerMin:30,refluxFromProduct:true,grade:'A'},
{key:'D',flowTag:'',role:'LevelDriven',levelTag:'lica-6113',targetCoeff:0.02,thetaUpSec:0,thetaDnSec:0,tauSec:0,spMin:0,spMax:9999,rateUpPerMin:0,rateDnPerMin:0,refluxFromProduct:false,grade:'B'},
{key:'B',flowTag:'',role:'LevelDriven',levelTag:'li-6111',targetCoeff:0.03,thetaUpSec:0,thetaDnSec:0,tauSec:0,spMin:0,spMax:9999,rateUpPerMin:0,rateDnPerMin:0,refluxFromProduct:false,grade:'B'}
] }
: { ...c, pressureTag: c.pressureTag||'' };
```
**바꾸기**:
```javascript
const def = isNew
? { name:'', enabled:false, feedTag:'', pressureTag:'',
scanSec:2, feedFilterTauSec:300, feedMoveThresholdPerMin:5,
pressFilterTauSec:60, pressureBand:3, settleSec:1800, staleSec:120, productKey:'P',
// WO-2 온도/PCT · WO-3 θ자동튜닝 · WO-4 바이어스
tempTags:[], sensitiveTrayTag:'', dtdp:0, pRef:null, steamOpTag:'', thetaAutoTune:false, biasMaWindowSec:21600,
// WO-6 전환류 복귀
recoveryEnabled:false, recoveryAutoArm:false, imbalanceTriggerFrac:0.10, imbalanceTriggerSec:600,
recoverySettleSec:1800, returnRampSec:600, feedRecoverySp:0, deltaPTag:'', deltaPFloodLimit:1e9,
streams:[
{key:'P',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0.95,thetaUpSec:60,thetaDnSec:60,tauSec:900,spMin:0,spMax:9999,rateUpPerMin:30,rateDnPerMin:60,refluxFromProduct:false,grade:'A',isReflux:false,recoverySp:0},
{key:'R',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0.80,thetaUpSec:0,thetaDnSec:0,tauSec:0,spMin:0,spMax:9999,rateUpPerMin:30,rateDnPerMin:30,refluxFromProduct:true,grade:'A',isReflux:true,recoverySp:null},
{key:'D',flowTag:'',role:'LevelDriven',levelTag:'lica-6113',targetCoeff:0.02,thetaUpSec:0,thetaDnSec:0,tauSec:0,spMin:0,spMax:9999,rateUpPerMin:0,rateDnPerMin:0,refluxFromProduct:false,grade:'B',isReflux:false,recoverySp:0},
{key:'B',flowTag:'',role:'LevelDriven',levelTag:'li-6111',targetCoeff:0.03,thetaUpSec:0,thetaDnSec:0,tauSec:0,spMin:0,spMax:9999,rateUpPerMin:0,rateDnPerMin:0,refluxFromProduct:false,grade:'B',isReflux:false,recoverySp:0}
] }
: { ...c, pressureTag: c.pressureTag||'',
tempTags: c.tempTags||[], sensitiveTrayTag: c.sensitiveTrayTag||'', steamOpTag: c.steamOpTag||'', deltaPTag: c.deltaPTag||'' };
```
> 기존 컬럼은 `{...c}`로 숫자/불리언 신규 필드가 이미 들어온다. 위 추가 라인은 **null일 수 있는 문자열/배열 필드만** 빈값 정규화(입력칸에 `undefined`/`null` 표시 방지). `tempTags`는 배열이므로 폼에선 콤마 문자열로 변환해 보여준다(STEP 2).
---
## STEP 2 — 입력칸(`colHtml`)에 신규 섹션 2개 추가
**파일**: `src/Web/wwwroot/js/ff.js`
**찾기** (colHtml의 두번째 `.ff-modal-col` 닫는 부분 + 백틱 종료):
```javascript
<label><span class="ff-desc">Stale(): 데이터 유효시간 마지막 갱신 시간 초과 사용 </span><input class="inp" type="number" id="ff-f-staleSec" value="${def.staleSec}"></label>
</div>`;
```
**바꾸기** (기존 2칸 뒤에 온도/θ·전환류 2칸을 추가):
```javascript
<label><span class="ff-desc">Stale(): 데이터 유효시간 마지막 갱신 시간 초과 사용 </span><input class="inp" type="number" id="ff-f-staleSec" value="${def.staleSec}"></label>
</div>
<div class="ff-modal-col">
<div class="ff-modal-subhd">온도 프로파일 / θ 자동튜닝 <small>(WO-2·3·4)</small></div>
<label><span class="ff-desc">온도 태그(콤마구분, ): 프로파일 PCT 모니터 대상. 비우면 온도기능 off</span><input class="inp" id="ff-f-tempTags" value="${esc((def.tempTags||[]).join(','))}"></label>
<label><span class="ff-desc">감도트레이 태그: 프론트(sweet-spot) 위치 지표. 비우면 - 차온 사용</span><input class="inp" id="ff-f-sensitiveTrayTag" value="${esc(def.sensitiveTrayTag||'')}"></label>
<label><span class="ff-desc">dT/dP(°C/압력): 압력보정온도(PCT) 계수. 0이면 생온도 사용</span><input class="inp" type="number" step="any" id="ff-f-dtdp" value="${def.dtdp}"></label>
<label><span class="ff-desc">P_ref(압력 기준점): 비우면 최초 정상압력으로 자동 시드</span><input class="inp" type="number" step="any" id="ff-f-pRef" value="${def.pRef==null?'':def.pRef}"></label>
<label><span class="ff-desc">스팀 OP 태그( tica-6111a.op): θ 추정 폐루프 오염 제거용</span><input class="inp" id="ff-f-steamOpTag" value="${esc(def.steamOpTag||'')}"></label>
<label><input type="checkbox" id="ff-f-thetaAutoTune" ${def.thetaAutoTune?'checked':''}> θ 자동튜닝(제안만, 자동반영 없음)</label>
<label><span class="ff-desc">바이어스 MA (): K_obs·V_loss 장기평균 (기본 6h=21600)</span><input class="inp" type="number" id="ff-f-biasMaWindowSec" value="${def.biasMaWindowSec}"></label>
</div>
<div class="ff-modal-col ff-recovery-col">
<div class="ff-modal-subhd">전환류 평형복귀 (WO-6) </div>
<label><input type="checkbox" id="ff-f-recoveryEnabled" ${def.recoveryEnabled?'checked':''}> 전환류 복귀 기능 사용</label>
<label><input type="checkbox" id="ff-f-recoveryAutoArm" ${def.recoveryAutoArm?'checked':''}> 자동 무장(체크 해제 운전원 ARM 필요)</label>
<label><span class="ff-desc">불균형 트리거 비율: |V_loss(MA)|/Feed (0.10 = 10%)</span><input class="inp ff-trig" type="number" step="any" id="ff-f-imbalanceTriggerFrac" value="${def.imbalanceTriggerFrac}"></label>
<label><span class="ff-desc">트리거 지속(): 불균형이 시간 연속 지속돼야 발동(오발동 방지, 기본 600=10)</span><input class="inp ff-trig" type="number" id="ff-f-imbalanceTriggerSec" value="${def.imbalanceTriggerSec}"></label>
<label><span class="ff-desc">평형 대기(): 전환류 평형 회복 연속 만족 시간(기본 1800=30)</span><input class="inp" type="number" id="ff-f-recoverySettleSec" value="${def.recoverySettleSec}"></label>
<label><span class="ff-desc">복귀 램프(): 정상 복귀 드로우/피드 점진 복원 시간(기본 600)</span><input class="inp" type="number" id="ff-f-returnRampSec" value="${def.returnRampSec}"></label>
<label><span class="ff-desc">전환류 Feed 권장값: 보통 0(차단)</span><input class="inp" type="number" step="any" id="ff-f-feedRecoverySp" value="${def.feedRecoverySp}"></label>
<label><span class="ff-desc">차압(ΔP) 태그: 플러딩 트리거용(선택). 비우면 미사용</span><input class="inp" id="ff-f-deltaPTag" value="${esc(def.deltaPTag||'')}"></label>
<label><span class="ff-desc">ΔP 플러딩 상한: 초과 지속 전환류 트리거. 미사용 매우 </span><input class="inp" type="number" step="any" id="ff-f-deltaPFloodLimit" value="${def.deltaPFloodLimit}"></label>
</div>`;
```
---
## STEP 3 — 스트림 행(`ffStreamRow`)에 환류/복귀SP 2칸 추가
### 3.1 스트림 테이블 헤더에 2칸 추가
**파일**: `src/Web/wwwroot/js/ff.js`
**찾기**:
```javascript
<th>Key</th><th>Flow </th><th></th><th></th><th>K</th><th>θ_up</th><th>θ_dn</th><th>τ</th>
<th>SP_min</th><th>SP_max</th><th>Rate_up</th><th>Rate_dn</th><th></th><th></th><th></th>
```
**바꾸기**:
```javascript
<th>Key</th><th>Flow </th><th></th><th></th><th>K</th><th>θ_up</th><th>θ_dn</th><th>τ</th>
<th>SP_min</th><th>SP_max</th><th>Rate_up</th><th>Rate_dn</th><th></th><th title=" ">R</th><th title=" ( 0)">SP</th><th></th><th></th>
```
### 3.2 `ffStreamRow`의 `<tr>`에 입력칸 2개 추가
**찾기**:
```javascript
<td><input type="checkbox" ${s.refluxFromProduct?'checked':''} data-idx="${i}" data-f="refluxFromProduct"></td>
<td><select class="inp ff-si" data-idx="${i}" data-f="grade">${gradeOpts}</select></td>
```
**바꾸기**:
```javascript
<td><input type="checkbox" ${s.refluxFromProduct?'checked':''} data-idx="${i}" data-f="refluxFromProduct"></td>
<td><input type="checkbox" ${s.isReflux?'checked':''} data-idx="${i}" data-f="isReflux"></td>
<td><input class="inp ff-si" type="number" step="any" value="${s.recoverySp==null?'':s.recoverySp}" data-idx="${i}" data-f="recoverySp" placeholder="0"></td>
<td><select class="inp ff-si" data-idx="${i}" data-f="grade">${gradeOpts}</select></td>
```
### 3.3 스트림 추가 버튼 기본값에도 신규 필드
> `ff-stream-add` 클릭 시 새 행 객체에 신규 필드 없으면 체크박스/값이 깨질 수 있다.
**찾기**:
```javascript
tb.insertAdjacentHTML('beforeend', ffStreamRow({
key:'',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0,thetaUpSec:0,thetaDnSec:0,
tauSec:0,spMin:0,spMax:1e9,rateUpPerMin:1e9,rateDnPerMin:1e9,
refluxFromProduct:false,grade:'A'
}, i));
```
**바꾸기**:
```javascript
tb.insertAdjacentHTML('beforeend', ffStreamRow({
key:'',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0,thetaUpSec:0,thetaDnSec:0,
tauSec:0,spMin:0,spMax:1e9,rateUpPerMin:1e9,rateDnPerMin:1e9,
refluxFromProduct:false,grade:'A',isReflux:false,recoverySp:null
}, i));
```
---
## STEP 4 — 저장(`ffSaveForm`)에서 신규 필드 읽기
**파일**: `src/Web/wwwroot/js/ff.js`
### 4.1 컬럼 레벨 필드 추가
**찾기**:
```javascript
staleSec: +g('ff-f-staleSec').value,
productKey: g('ff-f-productKey').value,
advisoryOnly: true,
streams: Array.from(document.querySelectorAll('#ff-stream-body tr')).map(tr => {
```
**바꾸기**:
```javascript
staleSec: +g('ff-f-staleSec').value,
productKey: g('ff-f-productKey').value,
advisoryOnly: true,
// WO-2/3/4
tempTags: g('ff-f-tempTags').value.split(',').map(s=>s.trim()).filter(Boolean),
sensitiveTrayTag: g('ff-f-sensitiveTrayTag').value || null,
dtdp: +g('ff-f-dtdp').value,
pRef: g('ff-f-pRef').value === '' ? null : +g('ff-f-pRef').value,
steamOpTag: g('ff-f-steamOpTag').value || null,
thetaAutoTune: g('ff-f-thetaAutoTune').checked,
biasMaWindowSec: +g('ff-f-biasMaWindowSec').value,
// WO-6
recoveryEnabled: g('ff-f-recoveryEnabled').checked,
recoveryAutoArm: g('ff-f-recoveryAutoArm').checked,
imbalanceTriggerFrac: +g('ff-f-imbalanceTriggerFrac').value,
imbalanceTriggerSec: +g('ff-f-imbalanceTriggerSec').value,
recoverySettleSec: +g('ff-f-recoverySettleSec').value,
returnRampSec: +g('ff-f-returnRampSec').value,
feedRecoverySp: +g('ff-f-feedRecoverySp').value,
deltaPTag: g('ff-f-deltaPTag').value || null,
deltaPFloodLimit: +g('ff-f-deltaPFloodLimit').value,
streams: Array.from(document.querySelectorAll('#ff-stream-body tr')).map(tr => {
```
### 4.2 스트림 레벨 필드 추가
**찾기**:
```javascript
rateUpPerMin: +v(null,'rateUpPerMin'), rateDnPerMin: +v(null,'rateDnPerMin'),
refluxFromProduct: v(null,'refluxFromProduct'), grade: v(null,'grade')
};
```
**바꾸기**:
```javascript
rateUpPerMin: +v(null,'rateUpPerMin'), rateDnPerMin: +v(null,'rateDnPerMin'),
refluxFromProduct: v(null,'refluxFromProduct'), grade: v(null,'grade'),
isReflux: v(null,'isReflux'),
recoverySp: (() => { const x = v(null,'recoverySp'); return x === '' ? null : +x; })()
};
```
---
## STEP 5 — `ff.css` (선택, 트리거 강조)
**파일**: `src/Web/wwwroot/css/ff.css` — 맨 끝에 추가:
```css
/* WO-7 설정폼 신규 섹션 */
.ff-modal-subhd{font-weight:600;margin:4px 0 6px;color:var(--t1);border-bottom:1px solid var(--bd);padding-bottom:3px}
.ff-modal-subhd small{font-weight:400;color:var(--t2)}
.ff-recovery-col{background:rgba(90,0,0,.08);border-radius:6px;padding:6px}
.ff-trig{border-color:#ff8a80 !important}
```
---
## STEP 6 — 검증
```bash
# 1) JS 문법
node -c src/Web/wwwroot/js/ff.js && echo "JS OK"
# 2) C# 미변경 확인(이 WO는 프론트 전용) — 빌드는 영향 없음(원하면)
dotnet build src/Web/ExperionCrawler.csproj 2>&1 | grep -E "Build succeeded|Error"
```
**기대**: `JS OK`. (C# 변경 없음 → 빌드 영향 없음.)
### 런타임 확인(브라우저)
1. `Ctrl+F5`로 캐시 무효화 후 Tab "유량 권장(FF)" → "설정 ▾" → 기존 컬럼 "편집" 또는 "+ 컬럼".
2. 모달에 **온도/θ 칸**과 **전환류 평형복귀 칸**(붉은 박스)이 보인다.
3. **트리거 수정 확인**(운전원 질문 대응): "불균형 트리거 비율"=0.15, "트리거 지속(초)"=300 으로 바꿔 저장 →
다시 "편집" 열어 값이 유지되는지 확인(= API 저장·재로드 라운드트립). → **운전원이 트리거를 직접 수정 가능**.
4. 스트림 표에 "전환류R"(체크) / "복귀SP" 칸이 보이고 저장·재로드 유지.
---
## 감독자 Sign-off
| 항목 | 상태 | 서명 |
|:--|:--:|:--:|
| 새 컬럼 def에 신규 필드 기본값(undefined 표시 없음) | ✅ | windpacer 2026-05-31 |
| 온도/θ 섹션 입력칸 노출 | ✅ | windpacer 2026-05-31 |
| 전환류 트리거(비율·지속) 입력칸 노출 + 저장·재로드 유지 | ✅ | windpacer 2026-05-31 |
| 스트림 전환류R·복귀SP 칸 노출 | ✅ | windpacer 2026-05-31 |
| tempTags 콤마↔배열 변환, pRef/recoverySp 빈값→null | ✅ | windpacer 2026-05-31 |
| node -c 통과 | ✅ | windpacer 2026-05-31 |
## 주의(약한 LLM 함정)
1. **C# 손대지 말 것** — 백엔드는 이미 신규 필드 저장/반환. 본 WO는 ff.js(+css)만.
2. **tempTags는 배열↔콤마문자열** — 표시는 `join(',')`, 저장은 `split(',')...filter(Boolean)`.
3. **빈값→null 매핑**`pRef`/`recoverySp`는 빈 문자열이면 null(백엔드가 NaN/NULL 시드 처리). 0과 빈값을 혼동 말 것.
4. **체크박스는 `.checked`**`v(null,'isReflux')`는 기존 `v` 헬퍼가 checkbox면 `el.checked`(불리언) 반환하므로 그대로 사용.
5. **스트림 칸 추가는 헤더와 행 둘 다**`<th>` 2개와 `<td>` 2개 개수 일치(안 맞으면 표 깨짐).
6. **스트림 add 버튼 기본객체에도** isReflux/recoverySp 추가(STEP 3.3) — 빠뜨리면 새 행 체크박스 깨짐.
</content>