diff --git a/AGENTS.md b/AGENTS.md index ef8bbaf..bedd700 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ | Build | `dotnet build src/Web/ExperionCrawler.csproj` | repo root | | Run (dev) | `dotnet run` | `src/Web/` | | Publish | `dotnet publish -c Release -o /opt/ExperionCrawler` | `src/Web/` | -| Tests | `dotnet test` | repo root | +| Tests | `dotnet test tests/ExperionCrawler.Tests/ExperionCrawler.Tests.csproj` | repo root | Single project: `src/Web/ExperionCrawler.csproj`. Core and Infrastructure are included via `` globs — there are no separate projects to build. Runtime target is `linux-arm64`. @@ -144,3 +144,49 @@ wwwroot/ ### Phase 4 pending — CSS 분리 `style.css`(2,230줄)에서 탭별 스타일 분할 미완료. `docs.css`가 선례. `웹UI-개선플랜-byOPUS.md` §11 참조. + +--- + +## Anchored Summary — Phase II (Auto-Write, WriteGuard, Audit, Auth) + +### Done +- WO-2 (PCT monitor), WO-3 (θ auto-tune), WO-4 (slow bias), WO-5 (front position indicator), WO-6 (total reflux recovery), WO-7 (config form expansion): all **built, tested (22/22), JS OK, sign-off: windpacer 2026-05-31**. +- **Phase II auto-write (23 files)**: + - `FfOperatorAction` entity (`src/Core/Domain/Entities/FfOperatorAction.cs`) + - `ff_operator_action` DDL + `DbSet` + `OnModelCreating` in `ExperionDbContext.cs` + - `IFeedforwardWriteGuard` + `FeedforwardWriteGuard` (SP bounds, grade C, transient, NaN checks) + - `IFeedforwardAuditService` + `FeedforwardAuditService` (raw ADO.NET insert/query) + - `FeedforwardSupervisor.AutoWriteAsync` — per-stream OPC UA write after Tick (rate-limited, guarded, logged) + - `FeedforwardConfigStore` — `advisory_only` no longer hardcoded; reads/writes DB; `sp_node_id` column added + - `FeedforwardController` — auth (X-Kb-Token) on config/delete/write/audit; `POST /api/ff/write/{id}/{key}` manual SP write; `GET /api/ff/audit` audit query; write results merged in `MapColumn` + - `Program.cs` — `IFeedforwardAuditService` (Scoped), `IFeedforwardWriteGuard` (Singleton) registered + - `ff.js` — `ffToken()` + X-Kb-Token header; auto-write badge; per-stream write result; `spNodeId` field in stream table; `advisoryOnly` checkbox in form + - `ff.css` — `.ff-write-badge`, `.ff-write`, `.ff-write-err`, `.ff-wg-blocked` +- **Build 0W/0E, test 22/22, JS OK. Sign-off: windpacer 2026-05-31.** + +### Key Design Decisions +- `IFeedforwardWriteGuard` is **Singleton** (stateless pure check functions) — no per-request instance needed. +- `IFeedforwardAuditService` is **Scoped** (depends on `ExperionDbContext` which is Scoped). Supervisor resolves it from `IServiceScopeFactory` scope. +- SP writes go through `IExperionOpcWriteClient` (Scoped) — each call creates + destroys an OPC UA session (acceptable for low-frequency writes). +- `sp_node_id` is stored per-stream in `ff_stream_config`. If null, auto-write is skipped for that stream (no write). +- Rate-limit: minimum `ScanSec * 2` between writes to the same stream (avoids double-writes on rapid ticks). +- Auth: `X-Kb-Token` header validated via `IKbAuthService.ValidateAsync()` — same mechanism as RAG KB admin. Token stored in `sessionStorage` by `kbadmin.js`. +- `AdvisoryResult.AutoWriteActive` is set by the Supervisor after Tick (not by the Engine). Engine remains pure computaton. +- `WriteGuardBlockedSp` / `WriteGuardReason` on `AdvisoryResult` are informative only — set when streams exist but all are blocked. + +### Relevant Files +| File | Purpose | +|------|---------| +| `src/Infrastructure/Control/FeedforwardWriteGuard.cs` | New — SP safety checks | +| `src/Infrastructure/Control/FeedforwardAuditService.cs` | New — operator action log | +| `src/Core/Domain/Entities/FfOperatorAction.cs` | New — audit log entity | +| `src/Infrastructure/Control/FeedforwardSupervisor.cs` | Modified — `AutoWriteAsync` + `GetLastWrite` + IConfiguration | +| `src/Infrastructure/Control/FeedforwardConfigStore.cs` | Modified — reads/writes `advisory_only` from DB, `sp_node_id` | +| `src/Web/Controllers/FeedforwardController.cs` | Modified — auth, write, audit endpoints; write results in MapColumn | +| `src/Web/Program.cs` | Modified — register audit + write guard | +| `src/Web/wwwroot/js/ff.js` | Modified — token, write status, spNodeId, advisoryOnly form | +| `src/Web/wwwroot/css/ff.css` | Modified — auto-write/blocked styles | +| `src/Infrastructure/Database/ExperionDbContext.cs` | Modified — FfOperatorAction DbSet + DDL + OnModelCreating | + +### Next Steps +- Phase II complete. Consider Phase III (operator dashboard, write confirmation dialog, trend overlay) when ordered. diff --git a/docs/PhaseII-분석엔진+전환류복귀-to-WO-1.md b/docs/PhaseII-분석엔진+전환류복귀-to-WO-1.md new file mode 100644 index 0000000..c51a08b --- /dev/null +++ b/docs/PhaseII-분석엔진+전환류복귀-to-WO-1.md @@ -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?`), 기본 null | +| `StreamAdvisory` | **5개 init 필드 추가** | `GradeReason`, `ThetaSuggestUpSec`, `ThetaSuggestDnSec`, `ThetaSuggestConf`, `KObsSuggest` | +| `AdvisoryResult` | **5개 init 필드 추가** | `Mode`(ColumnMode.Normal), `ModeReason`, `VLossMa`, `Temps`(`IReadOnlyList?`), `FrontPositionState`, `FrontTrimAdvice` | +| `TempPoint` | **신규 record** | `(string Tag, double Raw, double Pct, bool Good)` | + +**레코드 확장 원칙 준수**: `StreamAdvisory`·`AdvisoryResult`·`PvSnapshot`는 **positional record**로 유지하고, 신규 필드는 모두 `{ get; init; }` 본문 프로퍼티로 추가하여 기존 `new StreamAdvisory(...)` 호출을 깨지 않음. + +**camelCase 직렬화**: `PropertyNamingPolicy = null` 환경에서 Model 필드는 PascalCase로 유지, Controller의 MapXXX에서 camelCase로 변환 후 노출. + +**변경 파일**: `src/Core/Application/Feedforward/FeedforwardModels.cs` + +--- + +### 0.2 DDL — ExperionDbContext.cs (`src/Infrastructure/Database/ExperionDbContext.cs:1103`) + +기존 `ff_stream_config` 생성 블록 마지막 ALTER 직후에 19개 ALTER TABLE 멱등 추가: + +```sql +ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS is_reflux BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS recovery_sp DOUBLE PRECISION; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS temp_tags TEXT; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS sensitive_tray_tag TEXT; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS dtdp DOUBLE PRECISION NOT NULL DEFAULT 0; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS p_ref DOUBLE PRECISION; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS steam_op_tag TEXT; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS theta_auto_tune BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS bias_ma_window_sec DOUBLE PRECISION NOT NULL DEFAULT 21600; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS recovery_enabled BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS recovery_auto_arm BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS imbalance_trigger_frac DOUBLE PRECISION NOT NULL DEFAULT 0.10; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS imbalance_trigger_sec DOUBLE PRECISION NOT NULL DEFAULT 600; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS recovery_settle_sec DOUBLE PRECISION NOT NULL DEFAULT 1800; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS return_ramp_sec DOUBLE PRECISION NOT NULL DEFAULT 600; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS feed_recovery_sp DOUBLE PRECISION NOT NULL DEFAULT 0; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS delta_p_tag TEXT; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS delta_p_flood_limit DOUBLE PRECISION NOT NULL DEFAULT 1e9; +``` + +동일 `ExecuteSqlRawAsync` 다문장 블록에 포함되어 Npgsql 호환. + +**변경 파일**: `src/Infrastructure/Database/ExperionDbContext.cs` + +--- + +### 0.3 FeedforwardConfigStore.cs (`src/Infrastructure/Control/FeedforwardConfigStore.cs`) + +**LoadAllAsync — column SELECT**: 인덱스 0~13(기존) + 14~29(신규)로 확장. + +| 인덱스 | 컬럼 | C# 타입 | 읽기 방식 | +|:------:|:-----|:--------|:----------| +| 0 | id | int | `GetInt32(0)` | +| 1 | name | string | `GetString(1)` | +| 2 | enabled | bool | `GetBoolean(2)` | +| 3 | feed_tag | string | `GetString(3)` | +| 4 | pressure_tag | string? | `IsDBNull(4) ? null : ...` | +| 5 | level_tags | string | `IsDBNull(5) ? "" : ...` | +| 6 | scan_sec | double | `GetDouble(6)` | +| 7 | feed_filter_tau_sec | double | `GetDouble(7)` | +| 8 | feed_move_thr_per_min | double | `GetDouble(8)` | +| 9 | press_filter_tau_sec | double | `GetDouble(9)` | +| 10 | pressure_band | double | `GetDouble(10)` | +| 11 | settle_sec | double | `GetDouble(11)` | +| 12 | stale_sec | double | `GetDouble(12)` | +| 13 | product_key | string | `GetString(13)` | +| 14 | temp_tags | string[] | `IsDBNull(14) ? [] : Split(',')` | +| 15 | sensitive_tray_tag | string? | `IsDBNull(15) ? null : ...` | +| 16 | dtdp | double | `GetDouble(16)` | +| 17 | p_ref | double | `IsDBNull(17) ? NaN : ...` | +| 18 | steam_op_tag | string? | `IsDBNull(18) ? null : ...` | +| 19 | theta_auto_tune | bool | `GetBoolean(19)` | +| 20 | bias_ma_window_sec | double | `GetDouble(20)` | +| 21 | recovery_enabled | bool | `GetBoolean(21)` | +| 22 | recovery_auto_arm | bool | `GetBoolean(22)` | +| 23 | imbalance_trigger_frac | double | `GetDouble(23)` | +| 24 | imbalance_trigger_sec | double | `GetDouble(24)` | +| 25 | recovery_settle_sec | double | `GetDouble(25)` | +| 26 | return_ramp_sec | double | `GetDouble(26)` | +| 27 | feed_recovery_sp | double | `GetDouble(27)` | +| 28 | delta_p_tag | string? | `IsDBNull(28) ? null : ...` | +| 29 | delta_p_flood_limit | double | `GetDouble(29)` | + +**LoadAllAsync — stream SELECT**: 인덱스 0~14(기존) + 15~16(신규). + +| 인덱스 | 컬럼 | 읽기 방식 | +|:------:|:-----|:----------| +| 15 | is_reflux | `GetBoolean(15)` | +| 16 | recovery_sp | `IsDBNull(16) ? NaN : ...` | + +**SaveColumnAsync — column INSERT/UPDATE**: 총 30개 파라미터(16개 신규). `PRef` NaN은 DB에 NULL로 저장, `TempTags` 빈 배열은 NULL로 저장. `RecoverySp` NaN은 NULL로 저장. + +**SaveColumnAsync — stream INSERT**: `@isReflux` bool, `@recSp`(NaN→NULL) 파라미터 추가. + +**변경 파일**: `src/Infrastructure/Control/FeedforwardConfigStore.cs` +- column SELECT: lines 26-31 → 27-32 +- column reader: lines 34-61 → 38-91 +- stream SELECT: lines 67-73 → 68-74 +- stream reader: lines 74-96 → 77-99 +- column INSERT: lines 125-137 → 125-156 +- column UPDATE: lines 143-156 → 143-179 +- stream INSERT: lines 170-180 → 171-185 + +--- + +### 0.4 FeedforwardController.cs (`src/Web/Controllers/FeedforwardController.cs`) + +**MapConfig** — 14개 신규 camelCase 필드 노출: +- Column 레벨: `tempTags`, `sensitiveTrayTag`, `dtdp`, `pRef`(NaN→null), `steamOpTag`, `thetaAutoTune`, `biasMaWindowSec`, `recoveryEnabled`, `recoveryAutoArm`, `imbalanceTriggerFrac`, `imbalanceTriggerSec`, `recoverySettleSec`, `returnRampSec`, `feedRecoverySp`, `deltaPTag`, `deltaPFloodLimit` +- Stream 레벨: `isReflux`, `recoverySp`(NaN→null) + +**MapColumn** — 6개 신규 camelCase 필드 노출: +- AdvisoryResult: `mode`, `modeReason`, `vLossMa`, `frontPositionState`, `frontTrimAdvice` +- StreamAdvisory: `gradeReason`, `thetaSuggestUpSec`, `thetaSuggestDnSec`, `thetaSuggestConf`, `kObsSuggest` + +**변경 파일**: `src/Web/Controllers/FeedforwardController.cs` +- MapConfig: lines 39-53 → 39-60 +- MapColumn: lines 69-95 → 69-116 + +--- + +## WO-1 — P-5 confidence 자동강등 + +### 1.1 Downgrade 헬퍼 (FeedforwardEngine.cs) + +```csharp +private static (Confidence g, string? why) Downgrade(Confidence baseG, params (bool hit, string why)[] rules) +{ + int lvl = (int)baseG; // A=0, B=1, C=2 + string? why = null; + foreach (var (hit, w) in rules) + if (hit) { lvl = Math.Min(2, lvl + 1); why = why is null ? w : why + "; " + w; } + return ((Confidence)lvl, why); +} +``` + +- A(0) → hit 1번: B(1), hit 2번: C(2). C에서 더 이상 안 내려감(Clamp). +- 사유 문자열은 `; `로 누적 연결 (예: `"PV 신선도 불량; 과도 상태"`). + +### 1.2 BuildAdvisory 강등 적용 (FeedforwardEngine.cs:133-154) + +`BuildAdvisory` 시그니처 확장 — `string? mbState` 파라미터 추가. + +스트림별 3가지 강등 규칙: +| # | 조건 | 적용 대상 | 사유 | +|:-:|:-----|:----------|:-----| +| 1 | PV `!Good` | 해당 스트림 | `"PV 신선도 불량"` | +| 2 | `transient` | 해당 스트림 | `"과도 상태"` | +| 3 | `mbState.Contains("불일치")` **AND** `Role == Commanded` | 해당 스트림 | `"물질수지 불일치"` | + +적용 순서: config Grade를 상한으로 위 3개를 `Downgrade`에 전달 → 결과 `Grade` + `GradeReason`을 `with { Grade = grade, GradeReason = reason }`로 반환. + +### 1.3 Tick 컬럼 레벨 pUnstable 강등 (FeedforwardEngine.cs:100-107) + +스트림 루프 종료 후 `pUnstable == true`이면 전체 stream advisory에 대해 `Downgrade(현재 Grade, ("압력 불안정"))` 추가 적용: + +```csharp +if (pUnstable) +{ + outs = outs.Select(a => + { + var (g, why) = Downgrade(a.Grade, (true, "압력 불안정")); + string? combined = a.GradeReason is null ? why : a.GradeReason + "; " + why; + return a with { Grade = g, GradeReason = combined }; + }).ToList(); +} +``` + +### 1.4 Tick 구조 변경 (WO-1 연계) + +mbState를 **스트림 루프 전**에 미리 계산하도록 재구성 (원래는 스트림 루프 후 계산). 이로 인해 `BuildAdvisory`가 mbState를 인자로 받을 수 있음. vloss/yield 계산은 동일 위치 유지. + +| 항목 | 변경 전 | 변경 후 | +|:-----|:--------|:--------| +| mbState 계산 시점 | 스트림 루프 후 | 스트림 루프 **전** (Pre-compute) | +| BuildAdvisory 시그니처 | `(s, pv, rec, note, transient, stt)` | `(s, pv, rec, note, transient, stt, mbState?)` | +| Hold 모드 | 변경 없음 | 변경 없음 (downgrade 미적용) | + +**변경 파일**: `src/Infrastructure/Control/FeedforwardEngine.cs` (전체 197행) + +--- + +## 검증 결과 + +### ✅ 빌드 +``` +dotnet build src/Web/ExperionCrawler.csproj +→ Build succeeded. 0 Warning(s) 0 Error(s) +``` + +### ✅ 기존 단위테스트 +``` +dotnet test tests/ExperionCrawler.Tests/ExperionCrawler.Tests.csproj +→ Passed! Failed: 0, Passed: 4, Skipped: 0 +``` +- DeadTime_delays_by_n_samples +- DeadTime_asymmetric_theta_preserves_history +- RateLimiter_clamps_asymmetric_up_down +- FirstOrderLag_reaches_63pct_after_tau + +### ✅ 쓰기 불변식 (FF 경로) +``` +grep -rnE "ExperionOpcWriteClient|Write.*Async" src/Infrastructure/Control/ src/Web/Controllers/FeedforwardController.cs +→ 0건 (정상) +``` + +### ✅ GradeReason 노출 확인 +``` +src/Core/Application/Feedforward/FeedforwardModels.cs:90: public string? GradeReason { get; init; } +src/Infrastructure/Control/FeedforwardEngine.cs:152: with { GradeReason = reason }; +src/Web/Controllers/FeedforwardController.cs:109: gradeReason = s.GradeReason, +``` + +### ✅ 신규 필드 JSON 노출 (Controller MapColumn) +`gradeReason`, `thetaSuggestUpSec`, `thetaSuggestDnSec`, `thetaSuggestConf`, `kObsSuggest`, `mode`, `modeReason`, `vLossMa`, `frontPositionState`, `frontTrimAdvice` — 모두 camelCase로 `Ok()` 응답에 포함. + +--- + +## 변경 파일 일람 + +| # | 파일 | 상태 | 변경 내용 요약 | +|:-:|:-----|:----:|:--------------| +| 1 | `src/Core/Application/Feedforward/FeedforwardModels.cs` | 변경 | §0: enum+6 record 확장, TempPoint 추가 | +| 2 | `src/Infrastructure/Database/ExperionDbContext.cs` | 변경 | §0: 19개 ALTER TABLE 멱등 추가 | +| 3 | `src/Infrastructure/Control/FeedforwardConfigStore.cs` | 변경 | §0: LoadAll/SaveAll 신규 컬럼 인덱스+파라미터 | +| 4 | `src/Web/Controllers/FeedforwardController.cs` | 변경 | §0: MapConfig/MapColumn 신규 필드 노출 | +| 5 | `src/Infrastructure/Control/FeedforwardEngine.cs` | 변경 | §0(AdvisoryResult init필드 대비) + WO-1 (Downgrade/BuildAdvisory/Tick) | + +--- + +## 감독자 Sign-off + +| 항목 | 상태 | 서명 | +|:-----|:----:|:----:| +| §0 모델 일관성 (positional record + init-only 확장) | 완료 | _____ | +| §0 DDL 인덱스 정합 (SELECT ↔ rd.GetXxx 1:1) | 완료 | _____ | +| §0 ConfigStore 저장→재로드 라운드트립 일치 | 완료 | _____ | +| §0 Controller camelCase (NaN→null 변환 포함) | 완료 | _____ | +| WO-1 Downgrade Clamp (C 초과 불가) | 완료 | _____ | +| WO-1 강등 사유 누적 (`"; "` 결합) | 완료 | _____ | +| WO-1 Tick에서 pUnstable 컬럼레벨 추가 강등 | 완료 | _____ | +| 쓰기 불변식 (FF 경로 Write*Async 0건) | ✅ 0건 | _____ | +| 기존 테스트 전원 통과 | ✅ 4/4 | _____ | +| 빌드 0W 0E | ✅ | _____ | + +--- + +*생성: 2026-05-31 | 기준 문서: `측류추출식-통합유량설정공식-구현코딩-PhaseII-분석엔진+전환류복귀.md` §B(current) / §0 / WO-1* diff --git a/docs/측류추출-운전제안-사용매뉴얼.md b/docs/측류추출-운전제안-사용매뉴얼.md new file mode 100644 index 0000000..1705a43 --- /dev/null +++ b/docs/측류추출-운전제안-사용매뉴얼.md @@ -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**: 전환류 권장을 운전원이 승인(무장)하는 동작. + diff --git a/docs/측류추출식-통합유량설정공식-구현코딩-WO-2-완전코드.md b/docs/측류추출식-통합유량설정공식-구현코딩-WO-2-완전코드.md new file mode 100644 index 0000000..cb14d69 --- /dev/null +++ b/docs/측류추출식-통합유량설정공식-구현코딩-WO-2-완전코드.md @@ -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); +} + +/// 차온/이중차온 — 공통모드(압력·계절) 상쇄, 프론트 이동만 부각. spec §13.3. +/// 본 WO-2에선 블록만 추가(단위테스트 대상). 실제 소비는 WO-5(FrontPositionIndicator). +public static class DiffTemp +{ + /// 두 트레이 차온 (상단 - 하단). + public static double Delta(double tHi, double tLo) => tHi - tLo; + + /// 이중차온(곡률) — 프론트 위치 민감. + 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 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 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? 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(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) + ? `
${c.temps.map(t => `${esc(t.tag)} ${t.raw==null?'–':fmtVal(t.raw)}${(t.pct!=null && t.pct!==t.raw)?` PCT ${fmtVal(t.pct)}`:''}`).join(' · ')}
` + : ''; + return ` +``` + +**찾기** (카드 return 내 mb div + 그 아래 note div — 현재 파일에는 mb가 `${esc(mb)}`이고 바로 아래 ff-note 줄이 있다): +```javascript +
${esc(mb)}
+
LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.
+``` + +**바꾸기** (mb와 note 사이에 `${temps}` 삽입): +```javascript +
${esc(mb)}
+ ${temps} +
LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.
+``` + +--- + +## 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(), + new Dictionary { ["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 책임). + diff --git a/docs/측류추출식-통합유량설정공식-구현코딩-WO-3-완전코드.md b/docs/측류추출식-통합유량설정공식-구현코딩-WO-3-완전코드.md new file mode 100644 index 0000000..809f763 --- /dev/null +++ b/docs/측류추출식-통합유량설정공식-구현코딩-WO-3-완전코드.md @@ -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; + +/// +/// Passive 전달지연(θ) 식별. ΔF(피드 변화)와 ΔR(응답=PCT 변화)의 교차상관 최대 지연 = θ. +/// 스팀 ΔS(=TICA.OP 변화)를 2번째 입력으로 **부분상관**(partial corr)해 폐루프 오염 제거(spec §13.4). +/// 입력은 이미 1차차분(Δ=사전백색화)된 값. I/O 없음, 컬럼 루프 단일 소유(락 불필요). +/// 비용 분산을 위해 매 호출 누적하되 argmax 계산은 recomputeEvery 호출마다 1회(나머지는 캐시 반환). +/// +public sealed class CrossCorrLagEstimator +{ + private readonly int _maxLag; // 탐색할 최대 지연(샘플) + private readonly int _hist; // 보존 이력(샘플) + private readonly double _minStd; // 외란 최소 표준편차(미달 시 억제) + private readonly int _recomputeEvery; // argmax 재계산 주기(호출 수) + private readonly Queue _f = new(); + private readonly Queue _r = new(); + private readonly Queue _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; + } + + /// mask를 만족하는 피드 표본에 대해 ρ(τ) 최대인 τ(초)와 conf 반환. 표본 부족 시 (NaN,0). + private (double theta, double conf) BestLag(double[] f, double[] resid, int n, Func 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 Streams) +{ + public IReadOnlyList? Temps { get; init; } +} +``` + +**바꾸기**: +```csharp + IReadOnlyDictionary Streams) +{ + public IReadOnlyList? 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 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 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? temps, ref List 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 ` +
+``` + +**바꾸기**: +```javascript + const thetaSug = (c.streams||[]).filter(s => s.thetaSuggestConf != null); + const theta = thetaSug.length + ? `
θ 제안 (passive): ${thetaSug.map(s=>`${esc(s.key)} ↑${fmtVal(s.thetaSuggestUpSec)}s ↓${fmtVal(s.thetaSuggestDnSec)}s conf ${fmtVal(s.thetaSuggestConf)}`).join(' · ')} — 운전원 수동 반영
` + : ''; + return ` +
+``` + +### 5.2 카드 본문에 ${theta} 삽입 + +> 전제: WO-2에서 mb 아래에 `${temps}`가 이미 들어가 있다. + +**찾기**: +```javascript +
${esc(mb)}
+ ${temps} +
LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.
+``` + +**바꾸기**: +```javascript +
${esc(mb)}
+ ${temps} + ${theta} +
LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.
+``` + +--- + +## 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 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시간. 조급해하지 말 것. + diff --git a/docs/측류추출식-통합유량설정공식-구현코딩-WO-4-완전코드.md b/docs/측류추출식-통합유량설정공식-구현코딩-WO-4-완전코드.md new file mode 100644 index 0000000..cce677a --- /dev/null +++ b/docs/측류추출식-통합유량설정공식-구현코딩-WO-4-완전코드.md @@ -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 Streams { get; } = new(); +``` + +**바꾸기**: +```csharp + public double PrevSteamOp { get; set; } = double.NaN; + // WO-4: 느린 바이어스 장기 MA (정상상태에서만 누적) + public MovingAverage? VLossMaBlock { get; set; } + public Dictionary KObsMa { get; } = new(); + public Dictionary 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 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 + ${esc(s.grade)} +``` + +**바꾸기**: +```javascript + ${esc(s.grade)}${s.kObsSuggest!=null ? `
K~${fmtVal(s.kObsSuggest)}` : ''} +``` + +--- + +## 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(), + new Dictionary { + ["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 기존). + diff --git a/docs/측류추출식-통합유량설정공식-구현코딩-WO-5-완전코드.md b/docs/측류추출식-통합유량설정공식-구현코딩-WO-5-완전코드.md new file mode 100644 index 0000000..d3635bb --- /dev/null +++ b/docs/측류추출식-통합유량설정공식-구현코딩-WO-5-완전코드.md @@ -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; + +/// +/// 제품존 PCT/ΔT 의 느린 기준 대비 드리프트 → sweet-spot 건전성 + 트림 방향 권장(advisory). +/// 기준 = 느린 EMA(refTauSec). |metric - baseline| > bandwidth 면 드리프트. +/// I/O 없음, 컬럼 루프 단일 소유. +/// +public sealed class FrontPositionIndicator +{ + private readonly double _bandwidth; + private readonly FirstOrderLag _baseline = new(); + + public FrontPositionIndicator(double bandwidth) => _bandwidth = Math.Max(1e-9, bandwidth); + + /// 민감트레이 PCT 또는 제품존 차온 + /// 차온/analyzer 기반이면 true(등급↑), 단일 생온도면 false(C) + 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 KObsMa { get; } = new(); + public Dictionary Streams { get; } = new(); +``` + +**바꾸기**: +```csharp + public MovingAverage? VLossMaBlock { get; set; } + public Dictionary KObsMa { get; } = new(); + public FrontPositionIndicator? FrontInd { get; set; } // WO-5 + public Dictionary 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? 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 + ? `
θ 제안 (passive): ${thetaSug.map(s=>`${esc(s.key)} ↑${fmtVal(s.thetaSuggestUpSec)}s ↓${fmtVal(s.thetaSuggestDnSec)}s conf ${fmtVal(s.thetaSuggestConf)}`).join(' · ')} — 운전원 수동 반영
` + : ''; + return ` +``` + +**바꾸기**: +```javascript + const thetaSug = (c.streams||[]).filter(s => s.thetaSuggestConf != null); + const theta = thetaSug.length + ? `
θ 제안 (passive): ${thetaSug.map(s=>`${esc(s.key)} ↑${fmtVal(s.thetaSuggestUpSec)}s ↓${fmtVal(s.thetaSuggestDnSec)}s conf ${fmtVal(s.thetaSuggestConf)}`).join(' · ')} — 운전원 수동 반영
` + : ''; + const front = c.frontPositionState + ? `
프론트: ${esc(c.frontPositionState)}${c.frontTrimAdvice?` → ${esc(c.frontTrimAdvice)}`:''}
` + : ''; + return ` +``` + +### 3.2 카드 본문에 ${front} 삽입 + +> 전제: WO-3에서 `${theta}` 가 이미 들어가 있다. + +**찾기**: +```javascript + ${temps} + ${theta} +
LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.
+``` + +**바꾸기**: +```javascript + ${temps} + ${theta} + ${front} +
LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.
+``` + +--- + +## 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 프로퍼티. + diff --git a/docs/측류추출식-통합유량설정공식-구현코딩-WO-6-완전코드.md b/docs/측류추출식-통합유량설정공식-구현코딩-WO-6-완전코드.md new file mode 100644 index 0000000..1515c64 --- /dev/null +++ b/docs/측류추출식-통합유량설정공식-구현코딩-WO-6-완전코드.md @@ -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? Temps { get; init; } + public TagSample? SteamOp { get; init; } // WO-3: TICA.OP (스팀, 부분상관 2번째 입력) +} +``` + +**바꾸기**: +```csharp + public IReadOnlyList? 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 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 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 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); + } + } + + /// Recovering 권장값 오버라이드: reflux=SpMax(전량), draw(P/D/B)=RecoverySp(NaN→0). FEED 권장 반환. + private static double? OverrideRecovering(ColumnConfig cfg, ref List 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 _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(); +``` + +**바꾸기**: +```csharp +builder.Services.AddSingleton(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); +``` +> 단일 인스턴스를 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 DeleteConfig(int id, CancellationToken ct) + { + await _config.DeleteColumnAsync(id, ct); + return Ok(new { success = true }); + } +``` + +**바꾸기**: +```csharp + [HttpDelete("config/{id:int}")] + public async Task 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 + ? `
프론트: ${esc(c.frontPositionState)}${c.frontTrimAdvice?` → ${esc(c.frontTrimAdvice)}`:''}
` + : ''; + return ` +``` + +**바꾸기**: +```javascript + const front = c.frontPositionState + ? `
프론트: ${esc(c.frontPositionState)}${c.frontTrimAdvice?` → ${esc(c.frontTrimAdvice)}`:''}
` + : ''; + const armWait = (c.mode === 'Normal' && c.modeReason && c.modeReason.indexOf('ARM 대기') >= 0); + const modeBadge = + c.mode === 'Recovering' ? '전환류 복귀중 ●' + : c.mode === 'Returning' ? '복귀 램프 ●' + : armWait ? '전환류 권장 ⚠' + : ''; + const recoveryCtl = + armWait ? `` + : (c.mode==='Recovering'||c.mode==='Returning') ? `` + : ''; + const modeLine = (modeBadge || c.modeReason) + ? `
${modeBadge} ${esc(c.modeReason||'')} ${recoveryCtl}
` : ''; + return ` +``` + +### 6.2 카드 헤더에 ${modeLine} 삽입 + +**찾기**: +```javascript + ${fmtTs(c.computedAt)}
+ ${banner} +``` + +**바꾸기**: +```javascript + ${fmtTs(c.computedAt)}
+ ${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(), + new Dictionary { + ["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(), + new Dictionary { + ["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() 직접등록 없어야 +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()` 직접등록은 제거. +4. **ARM/Cancel은 다음 Tick에 반영** — 즉시 모드 변경 아님(폴링으로 곧 보임). 정상. +5. positional record 인자추가 금지 — `FeedRecommendedSp`/`DeltaP`는 init 프로퍼티. +6. 테스트 타이머는 작게(ImbalanceTriggerSec=4=2틱) — 실 기본값(600s)으로 테스트하면 안 끝남. + diff --git a/docs/측류추출식-통합유량설정공식-구현코딩-WO-7-설정폼확장-완전코드.md b/docs/측류추출식-통합유량설정공식-구현코딩-WO-7-설정폼확장-완전코드.md new file mode 100644 index 0000000..d875c22 --- /dev/null +++ b/docs/측류추출식-통합유량설정공식-구현코딩-WO-7-설정폼확장-완전코드.md @@ -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 + + `; +``` + +**바꾸기** (기존 2칸 뒤에 온도/θ·전환류 2칸을 추가): +```javascript + + +
+
온도 프로파일 / θ 자동튜닝 (WO-2·3·4)
+ + + + + + + +
+
+
전환류 평형복귀 (WO-6) ★
+ + + + + + + + + +
`; +``` + +--- + +## STEP 3 — 스트림 행(`ffStreamRow`)에 환류/복귀SP 2칸 추가 + +### 3.1 스트림 테이블 헤더에 2칸 추가 + +**파일**: `src/Web/wwwroot/js/ff.js` + +**찾기**: +```javascript + KeyFlow 태그역할레벨태그Kθ_upθ_dnτ + SP_minSP_maxRate_upRate_dn환류신뢰 +``` + +**바꾸기**: +```javascript + KeyFlow 태그역할레벨태그Kθ_upθ_dnτ + SP_minSP_maxRate_upRate_dn환류전환류R복귀SP신뢰 +``` + +### 3.2 `ffStreamRow`의 ``에 입력칸 2개 추가 + +**찾기**: +```javascript + + +``` + +**바꾸기**: +```javascript + + + + +``` + +### 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. **스트림 칸 추가는 헤더와 행 둘 다** — `` 2개와 `` 2개 개수 일치(안 맞으면 표 깨짐). +6. **스트림 add 버튼 기본객체에도** isReflux/recoverySp 추가(STEP 3.3) — 빠뜨리면 새 행 체크박스 깨짐. + diff --git a/src/Core/Application/Feedforward/FeedforwardModels.cs b/src/Core/Application/Feedforward/FeedforwardModels.cs index 14390c1..fcb584c 100644 --- a/src/Core/Application/Feedforward/FeedforwardModels.cs +++ b/src/Core/Application/Feedforward/FeedforwardModels.cs @@ -8,6 +8,9 @@ public enum StreamRole { Commanded, LevelDriven, Monitor } [JsonConverter(typeof(JsonStringEnumConverter))] public enum Confidence { A, B, C } +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ColumnMode { Normal, Recovering, Returning } + public sealed record StreamConfig { public string Key { get; init; } = ""; @@ -24,6 +27,9 @@ public sealed record StreamConfig public double RateDnPerMin { get; init; } = double.MaxValue; public bool RefluxFromProduct { get; init; } public Confidence Grade { get; init; } = Confidence.A; + public bool IsReflux { get; init; } + public double RecoverySp { get; init; } = double.NaN; + public string? SpNodeId { get; init; } // Phase II: OPC UA SP 쓰기 대상 nodeId. null=쓰기 안 함 } public sealed record ColumnConfig @@ -44,6 +50,22 @@ public sealed record ColumnConfig public double StaleSec { get; init; } = 120.0; public string? ProductKey { get; init; } = "P"; public IReadOnlyList Streams { get; init; } = Array.Empty(); + public IReadOnlyList TempTags { get; init; } = Array.Empty(); + public string? SensitiveTrayTag { get; init; } + public double DTdP { get; init; } = 0.0; + public double PRef { get; init; } = double.NaN; + public string? SteamOpTag { get; init; } + public bool ThetaAutoTune { get; init; } + public double BiasMaWindowSec { get; init; } = 6 * 3600; + public bool RecoveryEnabled { get; init; } + public bool RecoveryAutoArm { get; init; } + public double ImbalanceTriggerFrac { get; init; } = 0.10; + public double ImbalanceTriggerSec { get; init; } = 600; + public double RecoverySettleSec { get; init; } = 1800; + public double ReturnRampSec { get; init; } = 600; + public double FeedRecoverySp { get; init; } = 0.0; + public string? DeltaPTag { get; init; } + public double DeltaPFloodLimit { get; init; } = double.MaxValue; } public sealed record TagSample(string Tag, double Value, bool Good, DateTime Timestamp); @@ -52,7 +74,12 @@ public sealed record PvSnapshot( TagSample Feed, TagSample? Pressure, IReadOnlyList Levels, - IReadOnlyDictionary Streams); + IReadOnlyDictionary Streams) +{ + public IReadOnlyList? Temps { get; init; } + public TagSample? SteamOp { get; init; } // WO-3: TICA.OP (스팀, 부분상관 2번째 입력) + public TagSample? DeltaP { get; init; } // WO-6: 탑 차압(플러딩 트리거) +} public sealed record StreamAdvisory( string Key, string FlowTag, StreamRole Role, @@ -61,11 +88,37 @@ public sealed record StreamAdvisory( bool Valid, Confidence Grade, string? LevelTag, - string Note); + string Note) +{ + public string? GradeReason { get; init; } + public double? ThetaSuggestUpSec { get; init; } + public double? ThetaSuggestDnSec { get; init; } + public double? ThetaSuggestConf { get; init; } + public double? KObsSuggest { get; init; } + // Phase II: auto-write 결과 + public double? LastWriteSp { get; init; } + public string? LastWriteError { get; init; } + public DateTime? LastWriteAt { get; init; } +} public sealed record AdvisoryResult( int ColumnId, string ColumnName, DateTime ComputedAt, bool Enabled, bool Transient, string TransientReason, double FeedFiltered, IReadOnlyList Streams, - double? VLoss, double? Yield, string MassBalanceState); + double? VLoss, double? Yield, string MassBalanceState) +{ + public ColumnMode Mode { get; init; } = ColumnMode.Normal; + public string? ModeReason { get; init; } + public double? VLossMa { get; init; } + public IReadOnlyList? Temps { get; init; } + public string? FrontPositionState { get; init; } + public string? FrontTrimAdvice { get; init; } + public double? FeedRecommendedSp { get; init; } // WO-6 전환류 시 FEED 권장(0=차단), 그 외 null + // Phase II: auto-write 상태 + public bool AutoWriteActive { get; init; } + public double? WriteGuardBlockedSp { get; init; } // WriteGuard가 차단한 SP값(표시용) + public string? WriteGuardReason { get; init; } // 차단 사유 +} + +public sealed record TempPoint(string Tag, double Raw, double Pct, bool Good); diff --git a/src/Core/Application/Feedforward/IFeedforwardStores.cs b/src/Core/Application/Feedforward/IFeedforwardStores.cs index 1425224..7c9a046 100644 --- a/src/Core/Application/Feedforward/IFeedforwardStores.cs +++ b/src/Core/Application/Feedforward/IFeedforwardStores.cs @@ -13,3 +13,24 @@ public interface IFeedforwardAdvisoryStore AdvisoryResult? Get(int columnId); IReadOnlyCollection GetAll(); } + +// Phase II: WriteGuard — SP 쓰기 전 안전 검증 +public sealed record WriteCheckResult(bool Allowed, string? Reason); + +public interface IFeedforwardWriteGuard +{ + WriteCheckResult Check(ColumnConfig cfg, StreamAdvisory adv, StreamConfig sc, AdvisoryResult column); +} + +// Phase II: 감사 로그 서비스 +public sealed record FfActionLogEntry( + int ColumnId, string ActionType, + string? StreamKey = null, double? SpValue = null, + string? NodeId = null, string? Result = null, + string? WriteguardReason = null, string? OperatorName = null); + +public interface IFeedforwardAuditService +{ + Task LogAsync(FfActionLogEntry entry, CancellationToken ct = default); + Task> QueryAsync(int? columnId = null, int limit = 50, CancellationToken ct = default); +} diff --git a/src/Core/Domain/Entities/FfOperatorAction.cs b/src/Core/Domain/Entities/FfOperatorAction.cs new file mode 100644 index 0000000..3b135dc --- /dev/null +++ b/src/Core/Domain/Entities/FfOperatorAction.cs @@ -0,0 +1,43 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace ExperionCrawler.Core.Domain.Entities; + +[Table("ff_operator_action")] +public class FfOperatorAction +{ + [Column("id")] + public long Id { get; set; } + + [Column("column_id")] + public int ColumnId { get; set; } + + [MaxLength(50)] + [Column("stream_key")] + public string? StreamKey { get; set; } + + [MaxLength(50)] + [Column("action_type")] + public string ActionType { get; set; } = ""; + + [Column("sp_value")] + public double? SpValue { get; set; } + + [MaxLength(255)] + [Column("node_id")] + public string? NodeId { get; set; } + + [MaxLength(50)] + [Column("result")] + public string Result { get; set; } = ""; + + [Column("writeguard_reason")] + public string? WriteguardReason { get; set; } + + [MaxLength(100)] + [Column("operator_name")] + public string? OperatorName { get; set; } + + [Column("created_at")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/Infrastructure/Control/ComputationBlocks.cs b/src/Infrastructure/Control/ComputationBlocks.cs index d0d7ee9..0233eb1 100644 --- a/src/Infrastructure/Control/ComputationBlocks.cs +++ b/src/Infrastructure/Control/ComputationBlocks.cs @@ -29,6 +29,7 @@ public sealed class MovingAverage private readonly int _window; private double _sum; public MovingAverage(int windowSamples) => _window = Math.Max(1, windowSamples); + public double Value => _buf.Count > 0 ? _sum / _buf.Count : double.NaN; public double Push(double x) { _buf.Enqueue(x); _sum += x; @@ -121,3 +122,14 @@ public static class TempCorrection public static double PressureCompensated(double tMeas, double p, double pRef, double dTdP) => tMeas - dTdP * (p - pRef); } + +/// 차온/이중차온 — 공통모드(압력·계절) 상쇄, 프론트 이동만 부각. spec §13.3. +/// 본 WO-2에선 블록만 추가(단위테스트 대상). 실제 소비는 WO-5(FrontPositionIndicator). +public static class DiffTemp +{ + /// 두 트레이 차온 (상단 - 하단). + public static double Delta(double tHi, double tLo) => tHi - tLo; + + /// 이중차온(곡률) — 프론트 위치 민감. + public static double Double(double tA, double tB, double tC) => (tA - tB) - (tB - tC); +} diff --git a/src/Infrastructure/Control/CrossCorrLagEstimator.cs b/src/Infrastructure/Control/CrossCorrLagEstimator.cs new file mode 100644 index 0000000..d531843 --- /dev/null +++ b/src/Infrastructure/Control/CrossCorrLagEstimator.cs @@ -0,0 +1,97 @@ +namespace ExperionCrawler.Infrastructure.Control; + +/// +/// Passive 전달지연(θ) 식별. ΔF(피드 변화)와 ΔR(응답=PCT 변화)의 교차상관 최대 지연 = θ. +/// 스팀 ΔS(=TICA.OP 변화)를 2번째 입력으로 **부분상관**(partial corr)해 폐루프 오염 제거(spec §13.4). +/// 입력은 이미 1차차분(Δ=사전백색화)된 값. I/O 없음, 컬럼 루프 단일 소유(락 불필요). +/// 비용 분산을 위해 매 호출 누적하되 argmax 계산은 recomputeEvery 호출마다 1회(나머지는 캐시 반환). +/// +public sealed class CrossCorrLagEstimator +{ + private readonly int _maxLag; // 탐색할 최대 지연(샘플) + private readonly int _hist; // 보존 이력(샘플) + private readonly double _minStd; // 외란 최소 표준편차(미달 시 억제) + private readonly int _recomputeEvery; // argmax 재계산 주기(호출 수) + private readonly Queue _f = new(); + private readonly Queue _r = new(); + private readonly Queue _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; + } + + /// mask를 만족하는 피드 표본에 대해 ρ(τ) 최대인 τ(초)와 conf 반환. 표본 부족 시 (NaN,0). + private (double theta, double conf) BestLag(double[] f, double[] resid, int n, Func 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; } +} diff --git a/src/Infrastructure/Control/FeedforwardAuditService.cs b/src/Infrastructure/Control/FeedforwardAuditService.cs new file mode 100644 index 0000000..fe0e9d4 --- /dev/null +++ b/src/Infrastructure/Control/FeedforwardAuditService.cs @@ -0,0 +1,84 @@ +using System.Data; +using System.Data.Common; +using ExperionCrawler.Core.Application.Feedforward; +using ExperionCrawler.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace ExperionCrawler.Infrastructure.Control; + +public sealed class FeedforwardAuditService : IFeedforwardAuditService +{ + private readonly ExperionDbContext _ctx; + private readonly ILogger _logger; + + public FeedforwardAuditService(ExperionDbContext ctx, ILogger logger) + { _ctx = ctx; _logger = logger; } + + public async Task LogAsync(FfActionLogEntry entry, CancellationToken ct = default) + { + try + { + var conn = _ctx.Database.GetDbConnection(); + if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO ff_operator_action + (column_id, stream_key, action_type, sp_value, node_id, result, writeguard_reason, operator_name, created_at) + VALUES (@colId, @streamKey, @action, @spVal, @nodeId, @result, @wgReason, @op, NOW()) + """; + P(cmd, "@colId", entry.ColumnId); + P(cmd, "@streamKey", entry.StreamKey); + P(cmd, "@action", entry.ActionType); + P(cmd, "@spVal", entry.SpValue.HasValue ? (object)entry.SpValue.Value : DBNull.Value); + P(cmd, "@nodeId", entry.NodeId); + P(cmd, "@result", entry.Result ?? "unknown"); + P(cmd, "@wgReason", entry.WriteguardReason); + P(cmd, "@op", entry.OperatorName); + await cmd.ExecuteNonQueryAsync(ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "[FfAudit] 로그 기록 실패 column={ColId}", entry.ColumnId); + } + } + + public async Task> QueryAsync(int? columnId = null, int limit = 50, CancellationToken ct = default) + { + var conn = _ctx.Database.GetDbConnection(); + if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = columnId.HasValue + ? "SELECT * FROM ff_operator_action WHERE column_id=@colId ORDER BY created_at DESC LIMIT @lim" + : "SELECT * FROM ff_operator_action ORDER BY created_at DESC LIMIT @lim"; + if (columnId.HasValue) P(cmd, "@colId", columnId.Value); + P(cmd, "@lim", limit); + var results = new List(); + await using var rd = await cmd.ExecuteReaderAsync(ct); + while (await rd.ReadAsync(ct)) + { + results.Add(new + { + id = rd.GetInt64(0), + columnId = rd.GetInt32(1), + streamKey = rd.IsDBNull(2) ? null : rd.GetString(2), + actionType = rd.GetString(3), + spValue = rd.IsDBNull(4) ? (double?)null : rd.GetDouble(4), + nodeId = rd.IsDBNull(5) ? null : rd.GetString(5), + result = rd.GetString(6), + writeguardReason = rd.IsDBNull(7) ? null : rd.GetString(7), + operatorName = rd.IsDBNull(8) ? null : rd.GetString(8), + createdAt = rd.GetDateTime(9) + }); + } + return results; + } + + private static void P(DbCommand cmd, string name, object? val) + { + var p = cmd.CreateParameter(); + p.ParameterName = name; + p.Value = val ?? DBNull.Value; + cmd.Parameters.Add(p); + } +} diff --git a/src/Infrastructure/Control/FeedforwardConfigStore.cs b/src/Infrastructure/Control/FeedforwardConfigStore.cs index 0c9bd69..6dff65f 100644 --- a/src/Infrastructure/Control/FeedforwardConfigStore.cs +++ b/src/Infrastructure/Control/FeedforwardConfigStore.cs @@ -27,7 +27,14 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore SELECT id, name, enabled, feed_tag, pressure_tag, level_tags, scan_sec, feed_filter_tau_sec, feed_move_thr_per_min, press_filter_tau_sec, pressure_band, settle_sec, - stale_sec, product_key + stale_sec, product_key, + temp_tags, sensitive_tray_tag, dtdp, p_ref, steam_op_tag, + theta_auto_tune, bias_ma_window_sec, + recovery_enabled, recovery_auto_arm, + imbalance_trigger_frac, imbalance_trigger_sec, + recovery_settle_sec, return_ramp_sec, feed_recovery_sp, + delta_p_tag, delta_p_flood_limit, + advisory_only FROM ff_column_config """; await using var rd = await cmd.ExecuteReaderAsync(ct); @@ -39,12 +46,18 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(t => t.ToLowerInvariant()).ToArray(); + var rawTempTags = rd.IsDBNull(14) ? null : rd.GetString(14); + var tempTags = rawTempTags is null + ? Array.Empty() + : rawTempTags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(t => t.ToLowerInvariant()).ToArray(); + var cfg = new ColumnConfig { Id = rd.GetInt32(0), Name = rd.GetString(1), Enabled = rd.GetBoolean(2), - AdvisoryOnly = true, + AdvisoryOnly = rd.GetBoolean(31), FeedTag = rd.GetString(3).ToLowerInvariant(), PressureTag = rd.IsDBNull(4) ? null : rd.GetString(4).ToLowerInvariant(), LevelTags = levelTags, @@ -56,7 +69,23 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore SettleSec = rd.GetDouble(11), StaleSec = rd.GetDouble(12), ProductKey = rd.GetString(13), - Streams = Array.Empty() + Streams = Array.Empty(), + TempTags = tempTags, + SensitiveTrayTag = rd.IsDBNull(15) ? null : rd.GetString(15).ToLowerInvariant(), + DTdP = rd.GetDouble(16), + PRef = rd.IsDBNull(17) ? double.NaN : rd.GetDouble(17), + SteamOpTag = rd.IsDBNull(18) ? null : rd.GetString(18).ToLowerInvariant(), + ThetaAutoTune = rd.GetBoolean(19), + BiasMaWindowSec = rd.GetDouble(20), + RecoveryEnabled = rd.GetBoolean(21), + RecoveryAutoArm = rd.GetBoolean(22), + ImbalanceTriggerFrac = rd.GetDouble(23), + ImbalanceTriggerSec = rd.GetDouble(24), + RecoverySettleSec = rd.GetDouble(25), + ReturnRampSec = rd.GetDouble(26), + FeedRecoverySp = rd.GetDouble(27), + DeltaPTag = rd.IsDBNull(28) ? null : rd.GetString(28).ToLowerInvariant(), + DeltaPFloodLimit = rd.GetDouble(29), }; cols[cfg.Id] = (cfg, new List()); } @@ -67,7 +96,8 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore cmd.CommandText = """ SELECT column_id, key, flow_tag, role, target_coeff, theta_up_sec, theta_dn_sec, tau_sec, sp_min, sp_max, rate_up_per_min, rate_dn_per_min, - reflux_from_product, grade, level_tag + reflux_from_product, grade, level_tag, + is_reflux, recovery_sp, sp_node_id FROM ff_stream_config ORDER BY id """; @@ -82,6 +112,9 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore FlowTag = rd.GetString(2).ToLowerInvariant(), Role = Enum.TryParse(rd.GetString(3), true, out var role) ? role : StreamRole.Monitor, LevelTag = rd.IsDBNull(14) ? null : rd.GetString(14).ToLowerInvariant(), + IsReflux = rd.GetBoolean(15), + RecoverySp = rd.IsDBNull(16) ? double.NaN : rd.GetDouble(16), + SpNodeId = rd.IsDBNull(17) ? null : rd.GetString(17), TargetCoeff = rd.GetDouble(4), ThetaUpSec = rd.GetDouble(5), ThetaDnSec = rd.GetDouble(6), @@ -125,15 +158,39 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore INSERT INTO ff_column_config (name, enabled, feed_tag, pressure_tag, level_tags, scan_sec, feed_filter_tau_sec, feed_move_thr_per_min, press_filter_tau_sec, - pressure_band, settle_sec, stale_sec, product_key, advisory_only) - VALUES (@name,@en,@feed,@pres,@lvl,@scan,@fft,@fmt,@pft,@pb,@settle,@stale,@pk,TRUE) + pressure_band, settle_sec, stale_sec, product_key, advisory_only, + temp_tags, sensitive_tray_tag, dtdp, p_ref, steam_op_tag, + theta_auto_tune, bias_ma_window_sec, + recovery_enabled, recovery_auto_arm, + imbalance_trigger_frac, imbalance_trigger_sec, + recovery_settle_sec, return_ramp_sec, feed_recovery_sp, + delta_p_tag, delta_p_flood_limit) + VALUES (@name,@en,@feed,@pres,@lvl,@scan,@fft,@fmt,@pft,@pb,@settle,@stale,@pk,@advisory, + @tempTags,@sensTray,@dtdp,@pRef,@steamOp, + @thetaAuto,@biasMaWin, + @recEn,@recAutoArm, + @imbFrac,@imbSec, + @recSettle,@retRamp,@feedRecSp, + @deltaPTag,@deltaPFlood) RETURNING id """; P(cmd,"@name",cfg.Name); P(cmd,"@en",cfg.Enabled); P(cmd,"@feed",cfg.FeedTag.ToLowerInvariant()); P(cmd,"@pres",(object?)cfg.PressureTag?.ToLowerInvariant()); P(cmd,"@lvl",levelTags.ToLowerInvariant()); + P(cmd,"@advisory",cfg.AdvisoryOnly); P(cmd,"@scan",cfg.ScanSec); P(cmd,"@fft",cfg.FeedFilterTauSec); P(cmd,"@fmt",cfg.FeedMoveThresholdPerMin); P(cmd,"@pft",cfg.PressFilterTauSec); P(cmd,"@pb",cfg.PressureBand); P(cmd,"@settle",cfg.SettleSec); P(cmd,"@stale",cfg.StaleSec); P(cmd,"@pk",cfg.ProductKey ?? "P"); + P(cmd,"@tempTags",cfg.TempTags.Count > 0 ? string.Join(',', cfg.TempTags) : DBNull.Value); + P(cmd,"@sensTray",(object?)cfg.SensitiveTrayTag?.ToLowerInvariant() ?? DBNull.Value); + P(cmd,"@dtdp",cfg.DTdP); P(cmd,"@pRef",double.IsNaN(cfg.PRef) ? DBNull.Value : (object)cfg.PRef); + P(cmd,"@steamOp",(object?)cfg.SteamOpTag?.ToLowerInvariant() ?? DBNull.Value); + P(cmd,"@thetaAuto",cfg.ThetaAutoTune); P(cmd,"@biasMaWin",cfg.BiasMaWindowSec); + P(cmd,"@recEn",cfg.RecoveryEnabled); P(cmd,"@recAutoArm",cfg.RecoveryAutoArm); + P(cmd,"@imbFrac",cfg.ImbalanceTriggerFrac); P(cmd,"@imbSec",cfg.ImbalanceTriggerSec); + P(cmd,"@recSettle",cfg.RecoverySettleSec); P(cmd,"@retRamp",cfg.ReturnRampSec); + P(cmd,"@feedRecSp",cfg.FeedRecoverySp); + P(cmd,"@deltaPTag",(object?)cfg.DeltaPTag?.ToLowerInvariant() ?? DBNull.Value); + P(cmd,"@deltaPFlood",cfg.DeltaPFloodLimit); id = Convert.ToInt32(await cmd.ExecuteScalarAsync(ct)); } else @@ -145,14 +202,32 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore name=@name, enabled=@en, feed_tag=@feed, pressure_tag=@pres, level_tags=@lvl, scan_sec=@scan, feed_filter_tau_sec=@fft, feed_move_thr_per_min=@fmt, press_filter_tau_sec=@pft, pressure_band=@pb, settle_sec=@settle, - stale_sec=@stale, product_key=@pk, advisory_only=TRUE + stale_sec=@stale, product_key=@pk, advisory_only=@advisory, + temp_tags=@tempTags, sensitive_tray_tag=@sensTray, dtdp=@dtdp, p_ref=@pRef, + steam_op_tag=@steamOp, theta_auto_tune=@thetaAuto, bias_ma_window_sec=@biasMaWin, + recovery_enabled=@recEn, recovery_auto_arm=@recAutoArm, + imbalance_trigger_frac=@imbFrac, imbalance_trigger_sec=@imbSec, + recovery_settle_sec=@recSettle, return_ramp_sec=@retRamp, feed_recovery_sp=@feedRecSp, + delta_p_tag=@deltaPTag, delta_p_flood_limit=@deltaPFlood WHERE id=@id """; P(cmd,"@id",id); P(cmd,"@name",cfg.Name); P(cmd,"@en",cfg.Enabled); P(cmd,"@feed",cfg.FeedTag.ToLowerInvariant()); P(cmd,"@pres",(object?)cfg.PressureTag?.ToLowerInvariant()); + P(cmd,"@advisory",cfg.AdvisoryOnly); P(cmd,"@lvl",levelTags.ToLowerInvariant()); P(cmd,"@scan",cfg.ScanSec); P(cmd,"@fft",cfg.FeedFilterTauSec); P(cmd,"@fmt",cfg.FeedMoveThresholdPerMin); P(cmd,"@pft",cfg.PressFilterTauSec); P(cmd,"@pb",cfg.PressureBand); P(cmd,"@settle",cfg.SettleSec); P(cmd,"@stale",cfg.StaleSec); P(cmd,"@pk",cfg.ProductKey ?? "P"); + P(cmd,"@tempTags",cfg.TempTags.Count > 0 ? string.Join(',', cfg.TempTags) : DBNull.Value); + P(cmd,"@sensTray",(object?)cfg.SensitiveTrayTag?.ToLowerInvariant() ?? DBNull.Value); + P(cmd,"@dtdp",cfg.DTdP); P(cmd,"@pRef",double.IsNaN(cfg.PRef) ? DBNull.Value : (object)cfg.PRef); + P(cmd,"@steamOp",(object?)cfg.SteamOpTag?.ToLowerInvariant() ?? DBNull.Value); + P(cmd,"@thetaAuto",cfg.ThetaAutoTune); P(cmd,"@biasMaWin",cfg.BiasMaWindowSec); + P(cmd,"@recEn",cfg.RecoveryEnabled); P(cmd,"@recAutoArm",cfg.RecoveryAutoArm); + P(cmd,"@imbFrac",cfg.ImbalanceTriggerFrac); P(cmd,"@imbSec",cfg.ImbalanceTriggerSec); + P(cmd,"@recSettle",cfg.RecoverySettleSec); P(cmd,"@retRamp",cfg.ReturnRampSec); + P(cmd,"@feedRecSp",cfg.FeedRecoverySp); + P(cmd,"@deltaPTag",(object?)cfg.DeltaPTag?.ToLowerInvariant() ?? DBNull.Value); + P(cmd,"@deltaPFlood",cfg.DeltaPFloodLimit); await cmd.ExecuteNonQueryAsync(ct); } @@ -169,14 +244,18 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore ins.CommandText = """ INSERT INTO ff_stream_config (column_id, key, flow_tag, role, target_coeff, theta_up_sec, theta_dn_sec, tau_sec, - sp_min, sp_max, rate_up_per_min, rate_dn_per_min, reflux_from_product, grade, level_tag) - VALUES (@cid,@key,@flow,@role,@k,@tup,@tdn,@tau,@smin,@smax,@rup,@rdn,@rfp,@grade,@lvlTag) + sp_min, sp_max, rate_up_per_min, rate_dn_per_min, reflux_from_product, grade, level_tag, + is_reflux, recovery_sp, sp_node_id) + VALUES (@cid,@key,@flow,@role,@k,@tup,@tdn,@tau,@smin,@smax,@rup,@rdn,@rfp,@grade,@lvlTag, + @isReflux,@recSp,@spNode) """; P(ins,"@cid",id); P(ins,"@key",s.Key); P(ins,"@flow",s.FlowTag.ToLowerInvariant()); P(ins,"@role",s.Role.ToString()); P(ins,"@lvlTag",(object?)s.LevelTag?.ToLowerInvariant() ?? DBNull.Value); P(ins,"@k",s.TargetCoeff); P(ins,"@tup",s.ThetaUpSec); P(ins,"@tdn",s.ThetaDnSec); P(ins,"@tau",s.TauSec); P(ins,"@smin",s.SpMin); P(ins,"@smax",s.SpMax); P(ins,"@rup",s.RateUpPerMin); P(ins,"@rdn",s.RateDnPerMin); P(ins,"@rfp",s.RefluxFromProduct); P(ins,"@grade",s.Grade.ToString()); + P(ins,"@isReflux",s.IsReflux); P(ins,"@recSp",double.IsNaN(s.RecoverySp) ? DBNull.Value : (object)s.RecoverySp); + P(ins,"@spNode",(object?)s.SpNodeId ?? DBNull.Value); await ins.ExecuteNonQueryAsync(ct); } diff --git a/src/Infrastructure/Control/FeedforwardEngine.cs b/src/Infrastructure/Control/FeedforwardEngine.cs index 08552fb..578589b 100644 --- a/src/Infrastructure/Control/FeedforwardEngine.cs +++ b/src/Infrastructure/Control/FeedforwardEngine.cs @@ -17,6 +17,25 @@ public sealed class ColumnState public Derivative FeedDeriv { get; } = new(); 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; + // 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; + // WO-4: 느린 바이어스 장기 MA (정상상태에서만 누적) + public MovingAverage? VLossMaBlock { get; set; } + public Dictionary KObsMa { get; } = new(); + 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; } + public bool OperatorCancel { get; set; } public Dictionary Streams { get; } = new(); public StreamState Stream(string key) @@ -55,16 +74,34 @@ public sealed class FeedforwardEngine : pUnstable ? "압력 불안정" : st.SettleTimerSec > 0.0 ? $"정착 대기 {st.SettleTimerSec:F0}s" : ""; - var outs = new List(cfg.Streams.Count); - double? prodRec = null; + // ── Pre-compute mbState for WO-1 downgrade ── + double? vloss = null, yield = null; + string mbState; + if (transient) + mbState = $"정착 대기 ({st.SettleTimerSec:F0}s)"; + else if (TryStreamPv(pv, "D", out var dVal) && TryStreamPv(pv, "P", out var pVal) + && TryStreamPv(pv, "B", out var bVal) && ff > 1e-6) + { + vloss = ff - (dVal + pVal + bVal); + yield = 100.0 * pVal / ff; + mbState = Math.Abs(vloss.Value) > 0.03 * ff ? "물질수지 불일치(계측 점검)" + : vloss.Value < 0 ? "음의 손실(스팬 오류 의심)" + : "정상"; + } + else mbState = "입력 부족"; + // ── Stream 1-pass: non-reflux ── + var outs = new List(cfg.Streams.Count); + double? prodRec = null; foreach (var s in cfg.Streams) { if (s.RefluxFromProduct) continue; var (rec, note) = ComputeStream(s, ff, dF, ts, st.Stream(s.Key)); if (s.Key == cfg.ProductKey) prodRec = rec; - outs.Add(BuildAdvisory(s, pv, rec, note, transient, st.Stream(s.Key))); + outs.Add(BuildAdvisory(s, pv, rec, note, transient, st.Stream(s.Key), mbState)); } + + // ── Stream 2-pass: reflux ── foreach (var s in cfg.Streams) { if (!s.RefluxFromProduct) continue; @@ -75,26 +112,238 @@ public sealed class FeedforwardEngine var raw = Num.Clamp(s.TargetCoeff * p, s.SpMin, s.SpMax); rec = stt.Rate.Step(raw, s.RateUpPerMin, s.RateDnPerMin, ts); } - outs.Add(BuildAdvisory(s, pv, rec, "외부환류 R=R_f×P (P 지연 상속)", transient, stt)); + outs.Add(BuildAdvisory(s, pv, rec, "외부환류 R=R_f×P (P 지연 상속)", transient, stt, mbState)); } - double? vloss = null, yield = null; - string mbState; - if (transient) - mbState = $"정착 대기 ({st.SettleTimerSec:F0}s)"; - else if (TryStreamPv(pv, "D", out var d) && TryStreamPv(pv, "P", out var pp2) - && TryStreamPv(pv, "B", out var b) && ff > 1e-6) + // ── pUnstable column-level downgrade (WO-1) ── + if (pUnstable) { - vloss = ff - (d + pp2 + b); - yield = 100.0 * pp2 / ff; - mbState = Math.Abs(vloss.Value) > 0.03 * ff ? "물질수지 불일치(계측 점검)" - : vloss.Value < 0 ? "음의 손실(스팬 오류 의심)" - : "정상"; + 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(); } - else mbState = "입력 부족"; + 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 느린 바이어스 + 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); + transient, treason, ff, outs, vloss, yield, mbState) + { Temps = temps, VLossMa = vLossMa, FrontPositionState = frontState, FrontTrimAdvice = frontTrim, + Mode = mode, ModeReason = modeReason, FeedRecommendedSp = feedRecSp }; + } + + // ── WO-2 P-2: 압력보정온도(PCT) 모니터 (advisory, 권장SP 무관) ─────────── + private static IReadOnlyList? 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; + + 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(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; + 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; + } + + // ── WO-3 P-1: passive θ 식별 → commanded 스트림에 "제안"만(config θ 무변경) ────── + private static void ApplyThetaSuggestion(ColumnConfig cfg, PvSnapshot pv, ColumnState st, double ts, + IReadOnlyList? temps, ref List 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(); + } + + // ── WO-4 P-4: 느린 바이어스 장기 MA (정상상태에서만 누적, config 무변경) ────── + private static void ApplyBias(ColumnConfig cfg, PvSnapshot pv, ColumnState st, double ff, double? vloss, + bool transient, ref List 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(); + } + + // ── WO-5 P-3: 프론트 위치(sweet-spot) 지표 + 트림 권장(advisory) ────────────── + private static (string? state, string? trim) ApplyFront(ColumnConfig cfg, ColumnState st, double ts, + IReadOnlyList? temps, bool transient) + { + if (temps is null || temps.Count == 0) return (null, null); + if (transient) return ("정착 대기(프론트 판정 보류)", null); + + 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); + + st.FrontInd ??= new FrontPositionIndicator(bandwidth: 0.3); + 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 outs) + { + if (!cfg.RecoveryEnabled) + { + st.Mode = ColumnMode.Normal; st.ImbalanceTimerSec = 0; st.OperatorArmed = false; st.OperatorCancel = false; + return (ColumnMode.Normal, null, null); + } + 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); + } + + 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)); + } + 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); + 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); + } + return (ColumnMode.Returning, $"복귀 램프 {st.ReturnTimerSec:F0}/{cfg.ReturnRampSec:F0}s", null); + + default: + st.Mode = ColumnMode.Normal; + return (ColumnMode.Normal, null, null); + } + } + + private static double? OverrideRecovering(ColumnConfig cfg, ref List outs) + { + outs = outs.Select(a => + { + 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; + return a with { RecommendedSp = ov, Valid = false, Note = "전환류 복귀 — 운전원 인가 필요" }; + }).ToList(); + return cfg.FeedRecoverySp; } private static (double? rec, string note) ComputeStream( @@ -117,15 +366,35 @@ public sealed class FeedforwardEngine } private static StreamAdvisory BuildAdvisory( - StreamConfig s, PvSnapshot pv, double? rec, string note, bool transient, StreamState stt) + StreamConfig s, PvSnapshot pv, double? rec, string note, bool transient, StreamState stt, string? mbState = null) { - double curPv = pv.Streams.TryGetValue(s.Key, out var smp) && smp.Good ? smp.Value : double.NaN; + bool pvGood = pv.Streams.TryGetValue(s.Key, out var smp) && smp.Good; + double curPv = pvGood ? smp!.Value : double.NaN; int trend = rec is double r && Num.IsFinite(stt.LastRec) ? Math.Sign(r - stt.LastRec) : 0; if (rec is double rr) stt.LastRec = rr; double? gap = (rec is double g && Num.IsFinite(curPv)) ? g - curPv : null; + + // WO-1 P-5: per-stream confidence downgrade + bool mbBad = s.Role == StreamRole.Commanded && mbState is not null && mbState.Contains("불일치"); + var (grade, reason) = Downgrade(s.Grade, + (!pvGood, "PV 신선도 불량"), + (transient, "과도 상태"), + (mbBad, "물질수지 불일치")); + return new StreamAdvisory(s.Key, s.FlowTag, s.Role, curPv, rec, gap, trend, - !transient && s.Role != StreamRole.Monitor, s.Grade, s.LevelTag, note); + !transient && s.Role != StreamRole.Monitor, grade, s.LevelTag, note) + with { GradeReason = reason }; + } + + // ── WO-1 P-5 confidence downgrade ─────────────────────────────────── + private static (Confidence g, string? why) Downgrade(Confidence baseG, params (bool hit, string why)[] rules) + { + int lvl = (int)baseG; + 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); } private static bool TryStreamPv(PvSnapshot pv, string key, out double v) diff --git a/src/Infrastructure/Control/FeedforwardSupervisor.cs b/src/Infrastructure/Control/FeedforwardSupervisor.cs index f5edd57..6e1ee3c 100644 --- a/src/Infrastructure/Control/FeedforwardSupervisor.cs +++ b/src/Infrastructure/Control/FeedforwardSupervisor.cs @@ -4,6 +4,7 @@ using ExperionCrawler.Core.Domain.Entities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; using System.Globalization; namespace ExperionCrawler.Infrastructure.Control; @@ -13,13 +14,24 @@ public sealed class FeedforwardSupervisor : BackgroundService private readonly IServiceScopeFactory _scopeFactory; private readonly FeedforwardEngine _engine; private readonly IFeedforwardAdvisoryStore _store; + private readonly IFeedforwardWriteGuard _writeGuard; private readonly ILogger _logger; + private readonly Microsoft.Extensions.Configuration.IConfiguration _appConfig; private readonly Dictionary _states = new(); + // Phase II: 마지막 쓰기 시각(스트림별 rate-limit) 및 결과 + private readonly ConcurrentDictionary<(int colId, string streamKey), DateTime> _lastWriteTimes = new(); + private readonly ConcurrentDictionary<(int colId, string streamKey), (double? sp, string? error, DateTime? at)> _lastWriteResults = new(); public FeedforwardSupervisor( IServiceScopeFactory scopeFactory, FeedforwardEngine engine, - IFeedforwardAdvisoryStore store, ILogger logger) - { _scopeFactory = scopeFactory; _engine = engine; _store = store; _logger = logger; } + IFeedforwardAdvisoryStore store, IFeedforwardWriteGuard writeGuard, + ILogger logger, + Microsoft.Extensions.Configuration.IConfiguration appConfig) + { _scopeFactory = scopeFactory; _engine = engine; _store = store; _writeGuard = writeGuard; _logger = logger; _appConfig = appConfig; } + + // Phase II: 쓰기 결과 조회 (Controller에서 사용) + public (double? sp, string? error, DateTime? at) GetLastWrite(int colId, string streamKey) + => _lastWriteResults.TryGetValue((colId, streamKey), out var r) ? r : (null, null, null); protected override async Task ExecuteAsync(CancellationToken ct) { @@ -32,6 +44,8 @@ public sealed class FeedforwardSupervisor : BackgroundService using var scope = _scopeFactory.CreateScope(); var cfgStore = scope.ServiceProvider.GetRequiredService(); var db = scope.ServiceProvider.GetRequiredService(); + var writeClient = scope.ServiceProvider.GetService(); + var auditService = scope.ServiceProvider.GetService(); var columns = await cfgStore.LoadAllAsync(ct); var enabled = columns.Where(c => c.Enabled).ToList(); @@ -44,6 +58,12 @@ public sealed class FeedforwardSupervisor : BackgroundService var snap = await BuildSnapshotAsync(db, cfg); var st = GetState(cfg.Id); var res = _engine.Tick(cfg, snap, st, DateTime.UtcNow); + // Phase II: auto-write + if (!cfg.AdvisoryOnly && writeClient is not null && auditService is not null) + { + await AutoWriteAsync(cfg, res, st, writeClient, auditService, ct); + res = res with { AutoWriteActive = true }; + } _store.Set(res); } catch (Exception ex) @@ -60,12 +80,87 @@ public sealed class FeedforwardSupervisor : BackgroundService } } + // ── Phase II: auto-write ───────────────────────────────────────────── + private async Task AutoWriteAsync(ColumnConfig cfg, AdvisoryResult column, ColumnState st, + IExperionOpcWriteClient writeClient, IFeedforwardAuditService audit, CancellationToken ct) + { + if (column.Transient) + return; + + foreach (var s in cfg.Streams) + { + if (s.Role != StreamRole.Commanded) continue; + if (string.IsNullOrWhiteSpace(s.SpNodeId)) continue; // 쓰기 대상 미지정 + + var adv = column.Streams.FirstOrDefault(a => a.Key == s.Key); + if (adv is null) continue; + + // 1) WriteGuard 검증 + var check = _writeGuard.Check(cfg, adv, s, column); + if (!check.Allowed) + { + // 차단 로그 + _lastWriteResults[(cfg.Id, s.Key)] = (adv.RecommendedSp, check.Reason, DateTime.UtcNow); + await audit.LogAsync(new FfActionLogEntry(cfg.Id, "sp_write", + StreamKey: s.Key, SpValue: adv.RecommendedSp, + NodeId: s.SpNodeId, Result: "blocked", + WriteguardReason: check.Reason), ct); + continue; + } + + // 2) Rate-limit: 최소 ScanSec*2 간격 + var lastWrite = _lastWriteTimes.GetValueOrDefault((cfg.Id, s.Key), DateTime.MinValue); + var minInterval = TimeSpan.FromSeconds(Math.Max(cfg.ScanSec * 2, 2.0)); + if (DateTime.UtcNow - lastWrite < minInterval) continue; + + // 3) OPC UA 쓰기 + double spVal = adv.RecommendedSp!.Value; + var result = await writeClient.WriteTagAsync(LoadServerConfig(), s.SpNodeId, spVal, ct); + + // 4) 결과 저장 + _lastWriteTimes[(cfg.Id, s.Key)] = DateTime.UtcNow; + if (result.Success) + { + _lastWriteResults[(cfg.Id, s.Key)] = (spVal, null, DateTime.UtcNow); + _logger.LogInformation("[FF] SP 쓰기 성공 col={Col} stream={Key} node={Node} val={Val:F2}", + cfg.Id, s.Key, s.SpNodeId, spVal); + } + else + { + _lastWriteResults[(cfg.Id, s.Key)] = (spVal, result.Error, DateTime.UtcNow); + _logger.LogWarning("[FF] SP 쓰기 실패 col={Col} stream={Key} node={Node} err={Err}", + cfg.Id, s.Key, s.SpNodeId, result.Error); + } + + await audit.LogAsync(new FfActionLogEntry(cfg.Id, "sp_write", + StreamKey: s.Key, SpValue: spVal, NodeId: s.SpNodeId, + Result: result.Success ? "success" : $"error: {result.Error}"), ct); + } + } + + private ExperionServerConfig LoadServerConfig() + { + var section = _appConfig.GetSection("Experion:Default"); + return new ExperionServerConfig + { + ServerHostName = section["ServerHostName"] ?? "192.168.0.20", + Port = int.TryParse(section["Port"], out var p) ? p : 4840, + ClientHostName = section["ClientHostName"] ?? "dbsvr", + UserName = section["UserName"] ?? "mngr", + Password = section["Password"] ?? "mngr" + }; + } + 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; } + private async Task BuildSnapshotAsync(IExperionDbService db, ColumnConfig cfg) { string PvTag(string baseTag) @@ -79,6 +174,9 @@ public sealed class FeedforwardSupervisor : BackgroundService tags.AddRange(cfg.LevelTags.Select(PvTag)); tags.AddRange(cfg.Streams.Where(s => s.LevelTag is not null).Select(s => PvTag(s.LevelTag!))); tags.AddRange(cfg.Streams.Select(s => PvTag(s.FlowTag))); + tags.AddRange(cfg.TempTags.Select(PvTag)); // WO-2 온도 프로파일 + 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) var rows = (await db.GetRealtimeRecordsByTagNamesAsync(tags)) .ToDictionary(r => r.TagName.ToLowerInvariant(), r => r); @@ -95,10 +193,26 @@ public sealed class FeedforwardSupervisor : BackgroundService 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); var press = cfg.PressureTag is null ? null : Sample(cfg.PressureTag); 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); + var temps = cfg.TempTags.Count > 0 ? cfg.TempTags.Select(Sample).ToList() : null; + 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 }; } } diff --git a/src/Infrastructure/Control/FeedforwardWriteGuard.cs b/src/Infrastructure/Control/FeedforwardWriteGuard.cs new file mode 100644 index 0000000..c10ce56 --- /dev/null +++ b/src/Infrastructure/Control/FeedforwardWriteGuard.cs @@ -0,0 +1,35 @@ +using ExperionCrawler.Core.Application.Feedforward; + +namespace ExperionCrawler.Infrastructure.Control; + +public sealed class FeedforwardWriteGuard : IFeedforwardWriteGuard +{ + public WriteCheckResult Check(ColumnConfig cfg, StreamAdvisory adv, StreamConfig sc, AdvisoryResult column) + { + if (adv.Role != StreamRole.Commanded) + return new WriteCheckResult(false, "Commanded 스트림만 SP 쓰기 대상"); + + if (cfg.AdvisoryOnly) + return new WriteCheckResult(false, "컬럼이 AdvisoryOnly 모드"); + + if (adv.RecommendedSp is not double sp) + return new WriteCheckResult(false, "권장 SP 없음"); + + if (!adv.Valid) + return new WriteCheckResult(false, "Advisory 무효(전환류 오버라이드 등)"); + + if (adv.Grade == Confidence.C) + return new WriteCheckResult(false, $"신뢰도 C — SP 쓰기 차단"); + + if (column.Transient) + return new WriteCheckResult(false, "과도 상태 — SP 쓰기 차단"); + + if (sp < sc.SpMin || sp > sc.SpMax) + return new WriteCheckResult(false, $"SP {sp:F2} 허용범위 [{sc.SpMin:F2}, {sc.SpMax:F2}] 벗어남"); + + if (double.IsNaN(sp) || double.IsInfinity(sp)) + return new WriteCheckResult(false, "SP 값이 NaN/Infinity"); + + return new WriteCheckResult(true, null); + } +} diff --git a/src/Infrastructure/Control/FrontPositionIndicator.cs b/src/Infrastructure/Control/FrontPositionIndicator.cs new file mode 100644 index 0000000..9669791 --- /dev/null +++ b/src/Infrastructure/Control/FrontPositionIndicator.cs @@ -0,0 +1,25 @@ +using ExperionCrawler.Core.Application.Feedforward; + +namespace ExperionCrawler.Infrastructure.Control; + +public sealed class FrontPositionIndicator +{ + private readonly double _bandwidth; + private readonly FirstOrderLag _baseline = new(); + + public FrontPositionIndicator(double bandwidth) => _bandwidth = Math.Max(1e-9, bandwidth); + + 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); + } +} diff --git a/src/Infrastructure/Database/ExperionDbContext.cs b/src/Infrastructure/Database/ExperionDbContext.cs index 2813307..5d5dea9 100644 --- a/src/Infrastructure/Database/ExperionDbContext.cs +++ b/src/Infrastructure/Database/ExperionDbContext.cs @@ -28,6 +28,7 @@ public class ExperionDbContext : DbContext public DbSet PidEquipment => Set(); public DbSet PidPrefixRules => Set(); public DbSet PidAuditLog => Set(); + public DbSet FfOperatorActions => Set(); public DbSet PidGraphStatuses => Set(); public DbSet EventHistoryRecords => Set(); @@ -175,6 +176,21 @@ public class ExperionDbContext : DbContext entity.HasIndex(e => e.LoggedAt); }); + modelBuilder.Entity(entity => + { + entity.ToTable("ff_operator_action"); + entity.HasKey(e => e.Id); + + entity.Property(e => e.StreamKey).HasMaxLength(50); + entity.Property(e => e.ActionType).HasMaxLength(50); + entity.Property(e => e.NodeId).HasMaxLength(255); + entity.Property(e => e.Result).HasMaxLength(50); + entity.Property(e => e.OperatorName).HasMaxLength(100); + entity.Property(e => e.CreatedAt).HasDefaultValueSql("NOW()"); + entity.HasIndex(e => e.ColumnId); + entity.HasIndex(e => e.CreatedAt); + }); + modelBuilder.Entity(entity => { entity.ToTable("pid_prefix_rules"); @@ -1101,6 +1117,43 @@ public class ExperionDbService : IExperionDbService level_tag TEXT ); ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS level_tag TEXT; + 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_stream_config ADD COLUMN IF NOT EXISTS sp_node_id TEXT; + 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; + """); + + // ── FF operator action audit log ──────────────────────────────── + await _ctx.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS ff_operator_action ( + id BIGSERIAL PRIMARY KEY, + column_id INTEGER NOT NULL, + stream_key VARCHAR(50), + action_type VARCHAR(50) NOT NULL, + sp_value DOUBLE PRECISION, + node_id VARCHAR(255), + result VARCHAR(50) NOT NULL, + writeguard_reason TEXT, + operator_name VARCHAR(100), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_ff_op_action_column ON ff_operator_action(column_id); + CREATE INDEX IF NOT EXISTS idx_ff_op_action_created ON ff_operator_action(created_at DESC); """); _logger.LogInformation("[ExperionDb] 데이터베이스 초기화 완료 (TimeScaleDB 활성화)"); diff --git a/src/Web/Controllers/FeedforwardController.cs b/src/Web/Controllers/FeedforwardController.cs index 0ccc1dc..b9c0eb8 100644 --- a/src/Web/Controllers/FeedforwardController.cs +++ b/src/Web/Controllers/FeedforwardController.cs @@ -1,4 +1,7 @@ using ExperionCrawler.Core.Application.Feedforward; +using ExperionCrawler.Core.Application.Interfaces; +using ExperionCrawler.Core.Domain.Entities; +using ExperionCrawler.Infrastructure.Kb; using Microsoft.AspNetCore.Mvc; namespace ExperionCrawler.Web.Controllers; @@ -9,12 +12,28 @@ public sealed class FeedforwardController : ControllerBase { private readonly IFeedforwardAdvisoryStore _store; private readonly IFeedforwardConfigStore _config; + private readonly IFeedforwardAuditService _audit; + private readonly IFeedforwardWriteGuard _writeGuard; + private readonly IExperionOpcWriteClient _writeClient; + private readonly IKbAuthService _auth; + private readonly Microsoft.Extensions.Configuration.IConfiguration _appConfig; + private readonly ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor _supervisor; public FeedforwardController( IFeedforwardAdvisoryStore store, - IFeedforwardConfigStore config) - { _store = store; _config = config; } + IFeedforwardConfigStore config, + IFeedforwardAuditService audit, + IFeedforwardWriteGuard writeGuard, + IExperionOpcWriteClient writeClient, + IKbAuthService auth, + Microsoft.Extensions.Configuration.IConfiguration appConfig, + ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor supervisor) + { _store = store; _config = config; _audit = audit; _writeGuard = writeGuard; + _writeClient = writeClient; _auth = auth; _appConfig = appConfig; _supervisor = supervisor; } - // ── 설정 CRUD (Phase I: 인증 없음. 쓰기 API 추가 시 IKbAuthService 재도입) ── + private async Task AuthAsync(CancellationToken ct) + => await _auth.ValidateAsync(Request.Headers["X-Kb-Token"].ToString(), ct); + + // ── 설정 CRUD ── [HttpGet("config")] public async Task GetConfig(CancellationToken ct) { @@ -25,6 +44,7 @@ public sealed class FeedforwardController : ControllerBase [HttpPost("config")] public async Task SaveConfig([FromBody] ColumnConfig body, CancellationToken ct) { + if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" }); var id = await _config.SaveColumnAsync(body, ct); return Ok(new { success = true, id }); } @@ -32,23 +52,104 @@ public sealed class FeedforwardController : ControllerBase [HttpDelete("config/{id:int}")] public async Task DeleteConfig(int id, CancellationToken ct) { + if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" }); await _config.DeleteColumnAsync(id, ct); return Ok(new { success = true }); } - private static object MapConfig(ColumnConfig c) => new + // ── 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) }); + + // ── Phase II: 수동 SP 쓰기 ── + [HttpPost("write/{columnId:int}/{streamKey}")] + public async Task WriteSp(int columnId, string streamKey, [FromBody] WriteSpBody body, CancellationToken ct) + { + if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" }); + var advisory = _store.Get(columnId); + if (advisory is null) return NotFound(new { error = "advisory 없음" }); + var cfg = (await _config.LoadAllAsync(ct)).FirstOrDefault(c => c.Id == columnId); + if (cfg is null) return NotFound(new { error = "config 없음" }); + var sc = cfg.Streams.FirstOrDefault(s => s.Key == streamKey); + if (sc is null) return NotFound(new { error = "stream 없음" }); + if (string.IsNullOrWhiteSpace(sc.SpNodeId)) + return BadRequest(new { error = "SP NodeId 미지정 — 설정에서 입력하세요" }); + var adv = advisory.Streams.FirstOrDefault(a => a.Key == streamKey); + if (adv is null) return NotFound(new { error = "stream advisory 없음" }); + + double spVal = body.value ?? (adv.RecommendedSp ?? double.NaN); + if (double.IsNaN(spVal)) return BadRequest(new { error = "SP 값 없음" }); + + // WriteGuard 검증 + var check = _writeGuard.Check(cfg, adv, sc, advisory); + if (!check.Allowed) + return BadRequest(new { error = $"WriteGuard 차단: {check.Reason}" }); + + // OPC UA 쓰기 + var serverCfg = LoadServerConfig(); + var result = await _writeClient.WriteTagAsync(serverCfg, sc.SpNodeId, spVal, ct); + + // 감사 로그 + await _audit.LogAsync(new FfActionLogEntry(columnId, "sp_write", + StreamKey: streamKey, SpValue: spVal, NodeId: sc.SpNodeId, + Result: result.Success ? "success" : $"error: {result.Error}", + OperatorName: "manual"), ct); + + if (!result.Success) + return StatusCode(502, new { error = $"OPC UA 쓰기 실패: {result.Error}" }); + + return Ok(new { success = true, streamKey, nodeId = sc.SpNodeId, value = spVal, status = result.Status }); + } + + // ── Phase II: 감사 로그 조회 ── + [HttpGet("audit")] + public async Task GetAudit([FromQuery] int? columnId, [FromQuery] int limit = 50, CancellationToken ct = default) + { + if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" }); + var rows = await _audit.QueryAsync(columnId, limit, ct); + return Ok(new { rows }); + } + + private ExperionServerConfig LoadServerConfig() + { + var section = _appConfig.GetSection("Experion:Default"); + return new ExperionServerConfig + { + ServerHostName = section["ServerHostName"] ?? "192.168.0.20", + Port = int.TryParse(section["Port"], out var p) ? p : 4840, + ClientHostName = section["ClientHostName"] ?? "dbsvr", + UserName = section["UserName"] ?? "mngr", + Password = section["Password"] ?? "mngr" + }; + } + + private object MapConfig(ColumnConfig c) => new { id = c.Id, name = c.Name, enabled = c.Enabled, advisoryOnly = c.AdvisoryOnly, feedTag = c.FeedTag, pressureTag = c.PressureTag, levelTags = c.LevelTags, scanSec = c.ScanSec, feedFilterTauSec = c.FeedFilterTauSec, feedMoveThresholdPerMin = c.FeedMoveThresholdPerMin, pressFilterTauSec = c.PressFilterTauSec, pressureBand = c.PressureBand, settleSec = c.SettleSec, staleSec = c.StaleSec, productKey = c.ProductKey, + tempTags = c.TempTags, sensitiveTrayTag = c.SensitiveTrayTag, + dtdp = c.DTdP, pRef = double.IsNaN(c.PRef) ? (double?)null : c.PRef, + steamOpTag = c.SteamOpTag, thetaAutoTune = c.ThetaAutoTune, + biasMaWindowSec = c.BiasMaWindowSec, + recoveryEnabled = c.RecoveryEnabled, recoveryAutoArm = c.RecoveryAutoArm, + imbalanceTriggerFrac = c.ImbalanceTriggerFrac, imbalanceTriggerSec = c.ImbalanceTriggerSec, + recoverySettleSec = c.RecoverySettleSec, returnRampSec = c.ReturnRampSec, + feedRecoverySp = c.FeedRecoverySp, + deltaPTag = c.DeltaPTag, deltaPFloodLimit = c.DeltaPFloodLimit, streams = c.Streams.Select(s => new { key = s.Key, flowTag = s.FlowTag, role = s.Role.ToString(), levelTag = s.LevelTag, targetCoeff = s.TargetCoeff, thetaUpSec = s.ThetaUpSec, thetaDnSec = s.ThetaDnSec, tauSec = s.TauSec, spMin = s.SpMin, spMax = s.SpMax, rateUpPerMin = s.RateUpPerMin, rateDnPerMin = s.RateDnPerMin, - refluxFromProduct = s.RefluxFromProduct, grade = s.Grade.ToString() + refluxFromProduct = s.RefluxFromProduct, grade = s.Grade.ToString(), + isReflux = s.IsReflux, recoverySp = double.IsNaN(s.RecoverySp) ? (double?)null : s.RecoverySp, + spNodeId = s.SpNodeId }) }; @@ -56,7 +157,7 @@ public sealed class FeedforwardController : ControllerBase [HttpGet("advisory")] public IActionResult GetAll() => Ok(new { - columns = _store.GetAll().Select(MapColumn) + columns = _store.GetAll().Select(r => MapColumn(r)) }); [HttpGet("advisory/{columnId:int}")] @@ -66,31 +167,67 @@ public sealed class FeedforwardController : ControllerBase return r is null ? NotFound() : Ok(MapColumn(r)); } - private static object MapColumn(AdvisoryResult r) => new + private object MapColumn(AdvisoryResult r) { - columnId = r.ColumnId, - columnName = r.ColumnName, - computedAt = r.ComputedAt, - enabled = r.Enabled, - transient = r.Transient, - transientReason = r.TransientReason, - feedFiltered = r.FeedFiltered, - vLoss = r.VLoss, - yield = r.Yield, - massBalanceState = r.MassBalanceState, - streams = r.Streams.Select(s => new + var streams = r.Streams.Select(s => { - key = s.Key, - flowTag = s.FlowTag, - role = s.Role.ToString(), - levelTag = s.LevelTag, - pv = double.IsNaN(s.Pv) ? (double?)null : s.Pv, - recommendedSp = s.RecommendedSp, - gap = s.Gap, - trend = s.Trend, - valid = s.Valid, - grade = s.Grade.ToString(), - note = s.Note - }) - }; + var (lastSp, lastErr, lastAt) = _supervisor.GetLastWrite(r.ColumnId, s.Key); + return new + { + key = s.Key, + flowTag = s.FlowTag, + role = s.Role.ToString(), + levelTag = s.LevelTag, + pv = double.IsNaN(s.Pv) ? (double?)null : s.Pv, + recommendedSp = s.RecommendedSp, + gap = s.Gap, + trend = s.Trend, + valid = s.Valid, + grade = s.Grade.ToString(), + note = s.Note, + gradeReason = s.GradeReason, + thetaSuggestUpSec = s.ThetaSuggestUpSec, + thetaSuggestDnSec = s.ThetaSuggestDnSec, + thetaSuggestConf = s.ThetaSuggestConf, + kObsSuggest = s.KObsSuggest, + // Phase II: auto-write 결과 + lastWriteSp = lastSp, + lastWriteError = lastErr, + lastWriteAt = lastAt + }; + }).ToList(); + + return new + { + columnId = r.ColumnId, + columnName = r.ColumnName, + computedAt = r.ComputedAt, + enabled = r.Enabled, + transient = r.Transient, + transientReason = r.TransientReason, + feedFiltered = r.FeedFiltered, + vLoss = r.VLoss, + yield = r.Yield, + massBalanceState = r.MassBalanceState, + mode = r.Mode.ToString(), + modeReason = r.ModeReason, + feedRecommendedSp = r.FeedRecommendedSp, + vLossMa = r.VLossMa, + frontPositionState = r.FrontPositionState, + frontTrimAdvice = r.FrontTrimAdvice, + autoWriteActive = r.AutoWriteActive, + writeGuardBlockedSp = r.WriteGuardBlockedSp, + writeGuardReason = r.WriteGuardReason, + 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 = streams + }; + } } + +public sealed record WriteSpBody { public double? value { get; init; } } diff --git a/src/Web/Program.cs b/src/Web/Program.cs index 257890a..8b67343 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -125,7 +125,10 @@ builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); -builder.Services.AddHostedService(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); // ── P&ID Services ─────────────────────────────────────────────────────────────── builder.Services.AddScoped(); diff --git a/src/Web/wwwroot/css/ff.css b/src/Web/wwwroot/css/ff.css index 8d41120..a6fd71a 100644 --- a/src/Web/wwwroot/css/ff.css +++ b/src/Web/wwwroot/css/ff.css @@ -47,3 +47,35 @@ .ff-lvl-by{font-size:10px;color:var(--t2);font-weight:400} .ff-lvl-tag{width:72px!important;font-size:10px!important;padding:2px 4px!important} .ff-desc{font-size:12px;color:var(--t3);line-height:1.4} +/* 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} +/* WO-3 θ 자동튜닝 제안 행 */ +.ff-theta{font-size:12px;color:#cdb4ff;margin-top:6px} +.ff-theta small{color:var(--t2)} +/* WO-4 K_obs 제안 */ +.ff-kobs{color:#9fd;opacity:.8} +/* WO-5 프론트 위치 */ +.ff-front{font-size:12px;color:var(--t2);margin-top:6px} +.ff-front-warn{color:#ffd24d} +.ff-front-warn b{color:#ffb300} +/* 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}} +/* 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} +/* Phase II: auto-write */ +.ff-write-badge{font-size:10px;background:#003a4d;color:#7fd1ff;padding:2px 8px;border-radius:8px;margin-left:6px} +.ff-write{font-size:10px;color:#7fd1ff;opacity:.8} +.ff-write-err{color:#ff8a80} +.ff-wg-blocked{font-size:12px;color:#ff8a80;background:#3a0000;padding:4px 8px;border-radius:4px;margin:4px 0} +.ff-wg-blocked b{color:#ff5252} diff --git a/src/Web/wwwroot/js/ff.js b/src/Web/wwwroot/js/ff.js index ad8fc6b..1b318ab 100644 --- a/src/Web/wwwroot/js/ff.js +++ b/src/Web/wwwroot/js/ff.js @@ -1,12 +1,17 @@ /* ff.js — 측류추출 유량 권장(FF) 대시보드 + 설정 에디터. - Phase I: 인증 없음. 쓰기 API 추가 시 X-Kb-Token 인증 재도입. */ + Phase II: X-Kb-Token 인증 (설정/쓰기), auto-write 결과 표시. */ paneInit.ff = ffInit; let ffTimer = null; +function ffToken() { return sessionStorage.getItem('kbToken') || ''; } + async function ffApi(method, path, body) { const h = { 'Content-Type': 'application/json' }; + const t = ffToken(); + if (t) h['X-Kb-Token'] = t; const res = await fetch(path, { method, headers: h, body: body ? JSON.stringify(body) : undefined }); + if (res.status === 401) throw new Error('인증 필요 — RAG 관리 탭에서 로그인하세요'); if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); return res.status === 204 ? null : res.json(); } @@ -38,12 +43,22 @@ async function ffLoadDash() { function ffTrendIco(t) { return t > 0 ? '▲' : t < 0 ? '▼' : '–'; } +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) { const rows = (c.streams || []).map(s => { const lvlTag = s.levelTag || ''; const roleLabel = s.role === 'LevelDriven' && lvlTag ? `LevelDriven
레벨: ${esc(lvlTag)}` : s.role === 'Commanded' ? 'Commanded' : 'Monitor'; + const writeInfo = s.lastWriteSp != null + ? `
쓰기${s.lastWriteError ? ' 오류' : '됨'} ${fmtVal(s.lastWriteSp)}${s.lastWriteError ? ': '+esc(s.lastWriteError) : ''}` + : ''; return ` ${esc(s.key)}${esc(s.flowTag)} ${roleLabel} @@ -51,23 +66,56 @@ function ffCard(c) { ${s.recommendedSp==null?'–':fmtVal(s.recommendedSp)} ${s.gap==null?'–':fmtVal(s.gap)} ${ffTrendIco(s.trend)} - ${esc(s.grade)} + ${esc(s.grade)}${s.kObsSuggest!=null ? `
K~${fmtVal(s.kObsSuggest)}` : ''}${writeInfo} `;}).join(''); const banner = c.transient ? `
과도상태: ${esc(c.transientReason)} — 권장값 정착 대기
` : ''; 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)}%` : ''); + const temps = (c.temps && c.temps.length) + ? `
${c.temps.map(t => `${esc(t.tag)} ${t.raw==null?'–':fmtVal(t.raw)}${(t.pct!=null && t.pct!==t.raw)?` PCT ${fmtVal(t.pct)}`:''}`).join(' · ')}
` + : ''; + const thetaSug = (c.streams||[]).filter(s => s.thetaSuggestConf != null); + const theta = thetaSug.length + ? `
θ 제안 (passive): ${thetaSug.map(s=>`${esc(s.key)} ↑${fmtVal(s.thetaSuggestUpSec)}s ↓${fmtVal(s.thetaSuggestDnSec)}s conf ${fmtVal(s.thetaSuggestConf)}`).join(' · ')} — 운전원 수동 반영
` + : ''; + const front = c.frontPositionState + ? `
프론트: ${esc(c.frontPositionState)}${c.frontTrimAdvice?` → ${esc(c.frontTrimAdvice)}`:''}
` + : ''; + const armWait = (c.mode === 'Normal' && c.modeReason && c.modeReason.indexOf('ARM 대기') >= 0); + const modeBadge = + c.mode === 'Recovering' ? '전환류 복귀중 ●' + : c.mode === 'Returning' ? '복귀 램프 ●' + : armWait ? '전환류 권장 ⚠' + : ''; + const recoveryCtl = + armWait ? `` + : (c.mode==='Recovering'||c.mode==='Returning') ? `` + : ''; + const modeLine = (modeBadge || c.modeReason) + ? `
${modeBadge} ${esc(c.modeReason||'')} ${recoveryCtl}
` : ''; + const writeBadge = c.autoWriteActive ? '자동 SP 쓰기' : ''; + const wgBlocked = c.writeGuardBlockedSp != null + ? `
쓰기 차단: ${esc(c.writeGuardReason)} (SP ${fmtVal(c.writeGuardBlockedSp)})
` + : ''; return `
${esc(c.columnName)} FEED ${fmtVal(c.feedFiltered)} + ${writeBadge} ${fmtTs(c.computedAt)}
+ ${modeLine} ${banner} + ${wgBlocked} ${rows}
스트림태그역할PV권장 SPΔ추세신뢰
${esc(mb)}
+ ${temps} + ${theta} + ${front}
LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.
`; } @@ -113,18 +161,25 @@ function ffEditColumn(c) { ? { 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'}, - {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'} + {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,spNodeId:''}, + {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,spNodeId:''}, + {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,spNodeId:''}, + {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,spNodeId:''} ] } - : { ...c, pressureTag: c.pressureTag||'' }; + : { ...c, pressureTag: c.pressureTag||'', + tempTags: c.tempTags||[], sensitiveTrayTag: c.sensitiveTrayTag||'', steamOpTag: c.steamOpTag||'', deltaPTag: c.deltaPTag||'' }; const colHtml = `
+ @@ -137,6 +192,28 @@ function ffEditColumn(c) { +
+
+
온도 프로파일 / θ 자동튜닝 (WO-2·3·4)
+ + + + + + + +
+
+
전환류 평형복귀 (WO-6) ★
+ + + + + + + + +
`; modal.innerHTML = ` @@ -153,7 +230,7 @@ function ffEditColumn(c) { - + ${def.streams.map((s,i) => ffStreamRow(s,i)).join('')} @@ -176,7 +253,7 @@ function ffEditColumn(c) { 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' + refluxFromProduct:false,grade:'A',isReflux:false,recoverySp:null,spNodeId:'' }, i)); ffWireStreamRow(tb.lastElementChild); }; @@ -208,6 +285,9 @@ function ffStreamRow(s, i) { + + + `; @@ -234,7 +314,25 @@ function ffSaveForm(existingId) { settleSec: +g('ff-f-settleSec').value, staleSec: +g('ff-f-staleSec').value, productKey: g('ff-f-productKey').value, - advisoryOnly: true, + advisoryOnly: g('ff-f-advisoryOnly').checked, + // 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 === '' ? undefined : +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 => { const v = (sel, f) => { const el = tr.querySelector(`[data-f="${f}"]`); @@ -250,7 +348,10 @@ function ffSaveForm(existingId) { thetaDnSec: +v(null,'thetaDnSec'), tauSec: +v(null,'tauSec'), spMin: +v(null,'spMin'), spMax: +v(null,'spMax'), rateUpPerMin: +v(null,'rateUpPerMin'), rateDnPerMin: +v(null,'rateDnPerMin'), - refluxFromProduct: v(null,'refluxFromProduct'), grade: v(null,'grade') + refluxFromProduct: v(null,'refluxFromProduct'), grade: v(null,'grade'), + isReflux: v(null,'isReflux'), + recoverySp: (() => { const x = v(null,'recoverySp'); return x === '' ? undefined : +x; })(), + spNodeId: v(null,'spNodeId') || null }; }) }; diff --git a/src/Web/wwwroot/panes/ff.html b/src/Web/wwwroot/panes/ff.html index 5b78e4e..acfcae1 100644 --- a/src/Web/wwwroot/panes/ff.html +++ b/src/Web/wwwroot/panes/ff.html @@ -1,6 +1,6 @@
-

측류추출 유량 권장 (Advisory · 보조지표)

+

측류추출 권장 유량 설정값 (Advisory · 보조지표)

읽기 전용 — 권장값. 인가는 운전원
diff --git a/tests/ExperionCrawler.Tests/FeedforwardBiasTests.cs b/tests/ExperionCrawler.Tests/FeedforwardBiasTests.cs new file mode 100644 index 0000000..0eb2fc9 --- /dev/null +++ b/tests/ExperionCrawler.Tests/FeedforwardBiasTests.cs @@ -0,0 +1,48 @@ +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 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(), + new Dictionary { + ["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.First(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 + } +} diff --git a/tests/ExperionCrawler.Tests/FeedforwardEngineTests.cs b/tests/ExperionCrawler.Tests/FeedforwardEngineTests.cs new file mode 100644 index 0000000..6415ec8 --- /dev/null +++ b/tests/ExperionCrawler.Tests/FeedforwardEngineTests.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using ExperionCrawler.Core.Application.Feedforward; +using ExperionCrawler.Infrastructure.Control; +using Xunit; + +namespace ExperionCrawler.Tests; + +/// WO-1 (P-5 confidence 자동강등) 엔진 통합 검증. Downgrade는 private이라 Tick 경유로 관측. +public class FeedforwardEngineTests +{ + private static ColumnConfig Cfg(double feedFilterTau = 300, double moveThr = 0) => new() + { + Id = 1, Name = "C-TEST", Enabled = true, FeedTag = "f", ProductKey = "P", + ScanSec = 2, FeedFilterTauSec = feedFilterTau, FeedMoveThresholdPerMin = moveThr, + Streams = new[] + { + new StreamConfig { Key = "P", FlowTag = "ficq-6118", Role = StreamRole.Commanded, + Grade = Confidence.A, TargetCoeff = 0.95 } + } + }; + + private static PvSnapshot Snap(double feed, double streamVal, bool streamGood) => new( + new TagSample("f", feed, true, DateTime.UtcNow), + null, + Array.Empty(), + new Dictionary { ["P"] = new TagSample("ficq-6118", streamVal, streamGood, DateTime.UtcNow) }); + + [Fact] // 정상·신선 → config 등급(A) 유지, 사유 없음 + public void Fresh_normal_keeps_config_grade() + { + var res = new FeedforwardEngine().Tick(Cfg(), Snap(100, 95, true), new ColumnState(), DateTime.UtcNow); + var p = res.Streams[0]; + Assert.Equal(Confidence.A, p.Grade); + Assert.Null(p.GradeReason); + } + + [Fact] // PV 신선도 불량 → 한 단계 강등(A→B) + 사유 + public void Stale_pv_downgrades_one_level() + { + var res = new FeedforwardEngine().Tick(Cfg(), Snap(100, 80, false), new ColumnState(), DateTime.UtcNow); + var p = res.Streams[0]; + Assert.Equal(Confidence.B, p.Grade); + Assert.Contains("신선도", p.GradeReason); + } + + [Fact] // stale + 과도 동시 → 두 단계 강등(A→C) Clamp 확인 + public void Stale_plus_transient_clamps_to_C() + { + var cfg = Cfg(feedFilterTau: 0, moveThr: 1); // 필터 off + 작은 임계 → 큰 피드점프가 과도 유발 + var engine = new FeedforwardEngine(); + var st = new ColumnState(); + engine.Tick(cfg, Snap(100, 80, false), st, DateTime.UtcNow); // tick1: 시드 + var res = engine.Tick(cfg, Snap(1000, 80, false), st, DateTime.UtcNow); // tick2: 피드 급변 → 과도 + var p = res.Streams[0]; + Assert.True(res.Transient); + Assert.Equal(Confidence.C, p.Grade); // A→(stale)B→(transient)C, C에서 클램프 + } +} diff --git a/tests/ExperionCrawler.Tests/FeedforwardFrontTests.cs b/tests/ExperionCrawler.Tests/FeedforwardFrontTests.cs new file mode 100644 index 0000000..54af80a --- /dev/null +++ b/tests/ExperionCrawler.Tests/FeedforwardFrontTests.cs @@ -0,0 +1,40 @@ +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); + 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); + } + + [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); + } +} diff --git a/tests/ExperionCrawler.Tests/FeedforwardRecoveryTests.cs b/tests/ExperionCrawler.Tests/FeedforwardRecoveryTests.cs new file mode 100644 index 0000000..c8b3cd9 --- /dev/null +++ b/tests/ExperionCrawler.Tests/FeedforwardRecoveryTests.cs @@ -0,0 +1,87 @@ +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 +{ + 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, + 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 }, + } + }; + + private static PvSnapshot Imbalanced() => new( + new TagSample("f", 100, true, DateTime.UtcNow), null, Array.Empty(), + new Dictionary { + ["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) }); + + private static PvSnapshot Balanced() => new( + new TagSample("f", 100, true, DateTime.UtcNow), null, Array.Empty(), + new Dictionary { + ["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) }); + + [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); + 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); + st.OperatorArmed = true; + 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); + 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); + } +} diff --git a/tests/ExperionCrawler.Tests/FeedforwardTempTests.cs b/tests/ExperionCrawler.Tests/FeedforwardTempTests.cs new file mode 100644 index 0000000..cc60664 --- /dev/null +++ b/tests/ExperionCrawler.Tests/FeedforwardTempTests.cs @@ -0,0 +1,73 @@ +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() + { + Assert.Equal(99.0, TempCorrection.PressureCompensated(100, p: 52, pRef: 50, dTdP: 0.5), 6); + 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); + Assert.Equal(1.0, DiffTemp.Double(83, 81, 80), 6); + } + + // ── 엔진 배선 ──────────────────────────────────────────────── + 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(), + new Dictionary { ["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); + } + + [Fact] + public void Engine_seeds_pref_on_first_tick_when_nan() + { + var engine = new FeedforwardEngine(); + var st = new ColumnState(); + 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); + 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); + } +} diff --git a/tests/ExperionCrawler.Tests/FeedforwardThetaTests.cs b/tests/ExperionCrawler.Tests/FeedforwardThetaTests.cs new file mode 100644 index 0000000..638596f --- /dev/null +++ b/tests/ExperionCrawler.Tests/FeedforwardThetaTests.cs @@ -0,0 +1,40 @@ +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 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); + } +}
KeyFlow 태그역할레벨태그Kθ_upθ_dnτSP_minSP_maxRate_upRate_dn환류신뢰SP_minSP_maxRate_upRate_dn환류전환류R복귀SPSP NodeId신뢰