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

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

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

View File

@@ -7,7 +7,7 @@
| Build | `dotnet build src/Web/ExperionCrawler.csproj` | repo root | | Build | `dotnet build src/Web/ExperionCrawler.csproj` | repo root |
| Run (dev) | `dotnet run` | `src/Web/` | | Run (dev) | `dotnet run` | `src/Web/` |
| Publish | `dotnet publish -c Release -o /opt/ExperionCrawler` | `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 `<Compile Include>` globs — there are no separate projects to build. Runtime target is `linux-arm64`. Single project: `src/Web/ExperionCrawler.csproj`. Core and Infrastructure are included via `<Compile Include>` globs — there are no separate projects to build. Runtime target is `linux-arm64`.
@@ -144,3 +144,49 @@ wwwroot/
### Phase 4 pending — CSS 분리 ### Phase 4 pending — CSS 분리
`style.css`(2,230줄)에서 탭별 스타일 분할 미완료. `docs.css`가 선례. `웹UI-개선플랜-byOPUS.md` §11 참조. `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.

View File

@@ -0,0 +1,263 @@
# Phase II 분석엔진 + 전환류 복귀 — §0 + WO-1 구현 감리 문서
> **범위**: 작업지시서 `측류추출식-통합유량설정공식-구현코딩-PhaseII-분석엔진+전환류복귀.md`의 §0(모델·DDL·ConfigStore 공통확장) + WO-1(P-5 confidence 자동강등) 전량 코딩 완료.
>
> **감독자 확인**: 각 항목 서명란(Sign-off)은 본 문서에 기재된 검증 절차 통과 후 서명.
---
## §0 — 모델 공통 확장
### 0.1 FeedforwardModels.cs (`src/Core/Application/Feedforward/FeedforwardModels.cs`)
| 항목 | 변경 | 상세 |
|:-----|:-----|:-----|
| `enum ColumnMode` | **추가** | `Normal`, `Recovering`, `Returning` + `[JsonConverter(typeof(JsonStringEnumConverter))]` |
| `StreamConfig` | **2개 필드 추가** | `IsReflux`(bool), `RecoverySp`(double, NaN=규칙기본) |
| `ColumnConfig` | **16개 필드 추가** | `TempTags`, `SensitiveTrayTag`, `DTdP`, `PRef`, `SteamOpTag`, `ThetaAutoTune`, `BiasMaWindowSec`(기본6h), `RecoveryEnabled`, `RecoveryAutoArm`, `ImbalanceTriggerFrac`(0.10), `ImbalanceTriggerSec`(600), `RecoverySettleSec`(1800), `ReturnRampSec`(600), `FeedRecoverySp`(0), `DeltaPTag`, `DeltaPFloodLimit`(1e9) |
| `PvSnapshot` | **1개 init 필드 추가** | `Temps`(`IReadOnlyList<TagSample>?`), 기본 null |
| `StreamAdvisory` | **5개 init 필드 추가** | `GradeReason`, `ThetaSuggestUpSec`, `ThetaSuggestDnSec`, `ThetaSuggestConf`, `KObsSuggest` |
| `AdvisoryResult` | **5개 init 필드 추가** | `Mode`(ColumnMode.Normal), `ModeReason`, `VLossMa`, `Temps`(`IReadOnlyList<TempPoint>?`), `FrontPositionState`, `FrontTrimAdvice` |
| `TempPoint` | **신규 record** | `(string Tag, double Raw, double Pct, bool Good)` |
**레코드 확장 원칙 준수**: `StreamAdvisory`·`AdvisoryResult`·`PvSnapshot`는 **positional record**로 유지하고, 신규 필드는 모두 `{ get; init; }` 본문 프로퍼티로 추가하여 기존 `new StreamAdvisory(...)` 호출을 깨지 않음.
**camelCase 직렬화**: `PropertyNamingPolicy = null` 환경에서 Model 필드는 PascalCase로 유지, Controller의 MapXXX에서 camelCase로 변환 후 노출.
**변경 파일**: `src/Core/Application/Feedforward/FeedforwardModels.cs`
---
### 0.2 DDL — ExperionDbContext.cs (`src/Infrastructure/Database/ExperionDbContext.cs:1103`)
기존 `ff_stream_config` 생성 블록 마지막 ALTER 직후에 19개 ALTER TABLE 멱등 추가:
```sql
ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS is_reflux BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS recovery_sp DOUBLE PRECISION;
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS temp_tags TEXT;
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS sensitive_tray_tag TEXT;
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS dtdp DOUBLE PRECISION NOT NULL DEFAULT 0;
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS p_ref DOUBLE PRECISION;
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS steam_op_tag TEXT;
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS theta_auto_tune BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS bias_ma_window_sec DOUBLE PRECISION NOT NULL DEFAULT 21600;
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS recovery_enabled BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS recovery_auto_arm BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS imbalance_trigger_frac DOUBLE PRECISION NOT NULL DEFAULT 0.10;
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS imbalance_trigger_sec DOUBLE PRECISION NOT NULL DEFAULT 600;
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS recovery_settle_sec DOUBLE PRECISION NOT NULL DEFAULT 1800;
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS return_ramp_sec DOUBLE PRECISION NOT NULL DEFAULT 600;
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS feed_recovery_sp DOUBLE PRECISION NOT NULL DEFAULT 0;
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS delta_p_tag TEXT;
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS delta_p_flood_limit DOUBLE PRECISION NOT NULL DEFAULT 1e9;
```
동일 `ExecuteSqlRawAsync` 다문장 블록에 포함되어 Npgsql 호환.
**변경 파일**: `src/Infrastructure/Database/ExperionDbContext.cs`
---
### 0.3 FeedforwardConfigStore.cs (`src/Infrastructure/Control/FeedforwardConfigStore.cs`)
**LoadAllAsync — column SELECT**: 인덱스 0~13(기존) + 14~29(신규)로 확장.
| 인덱스 | 컬럼 | C# 타입 | 읽기 방식 |
|:------:|:-----|:--------|:----------|
| 0 | id | int | `GetInt32(0)` |
| 1 | name | string | `GetString(1)` |
| 2 | enabled | bool | `GetBoolean(2)` |
| 3 | feed_tag | string | `GetString(3)` |
| 4 | pressure_tag | string? | `IsDBNull(4) ? null : ...` |
| 5 | level_tags | string | `IsDBNull(5) ? "" : ...` |
| 6 | scan_sec | double | `GetDouble(6)` |
| 7 | feed_filter_tau_sec | double | `GetDouble(7)` |
| 8 | feed_move_thr_per_min | double | `GetDouble(8)` |
| 9 | press_filter_tau_sec | double | `GetDouble(9)` |
| 10 | pressure_band | double | `GetDouble(10)` |
| 11 | settle_sec | double | `GetDouble(11)` |
| 12 | stale_sec | double | `GetDouble(12)` |
| 13 | product_key | string | `GetString(13)` |
| 14 | temp_tags | string[] | `IsDBNull(14) ? [] : Split(',')` |
| 15 | sensitive_tray_tag | string? | `IsDBNull(15) ? null : ...` |
| 16 | dtdp | double | `GetDouble(16)` |
| 17 | p_ref | double | `IsDBNull(17) ? NaN : ...` |
| 18 | steam_op_tag | string? | `IsDBNull(18) ? null : ...` |
| 19 | theta_auto_tune | bool | `GetBoolean(19)` |
| 20 | bias_ma_window_sec | double | `GetDouble(20)` |
| 21 | recovery_enabled | bool | `GetBoolean(21)` |
| 22 | recovery_auto_arm | bool | `GetBoolean(22)` |
| 23 | imbalance_trigger_frac | double | `GetDouble(23)` |
| 24 | imbalance_trigger_sec | double | `GetDouble(24)` |
| 25 | recovery_settle_sec | double | `GetDouble(25)` |
| 26 | return_ramp_sec | double | `GetDouble(26)` |
| 27 | feed_recovery_sp | double | `GetDouble(27)` |
| 28 | delta_p_tag | string? | `IsDBNull(28) ? null : ...` |
| 29 | delta_p_flood_limit | double | `GetDouble(29)` |
**LoadAllAsync — stream SELECT**: 인덱스 0~14(기존) + 15~16(신규).
| 인덱스 | 컬럼 | 읽기 방식 |
|:------:|:-----|:----------|
| 15 | is_reflux | `GetBoolean(15)` |
| 16 | recovery_sp | `IsDBNull(16) ? NaN : ...` |
**SaveColumnAsync — column INSERT/UPDATE**: 총 30개 파라미터(16개 신규). `PRef` NaN은 DB에 NULL로 저장, `TempTags` 빈 배열은 NULL로 저장. `RecoverySp` NaN은 NULL로 저장.
**SaveColumnAsync — stream INSERT**: `@isReflux` bool, `@recSp`(NaN→NULL) 파라미터 추가.
**변경 파일**: `src/Infrastructure/Control/FeedforwardConfigStore.cs`
- column SELECT: lines 26-31 → 27-32
- column reader: lines 34-61 → 38-91
- stream SELECT: lines 67-73 → 68-74
- stream reader: lines 74-96 → 77-99
- column INSERT: lines 125-137 → 125-156
- column UPDATE: lines 143-156 → 143-179
- stream INSERT: lines 170-180 → 171-185
---
### 0.4 FeedforwardController.cs (`src/Web/Controllers/FeedforwardController.cs`)
**MapConfig** — 14개 신규 camelCase 필드 노출:
- Column 레벨: `tempTags`, `sensitiveTrayTag`, `dtdp`, `pRef`(NaN→null), `steamOpTag`, `thetaAutoTune`, `biasMaWindowSec`, `recoveryEnabled`, `recoveryAutoArm`, `imbalanceTriggerFrac`, `imbalanceTriggerSec`, `recoverySettleSec`, `returnRampSec`, `feedRecoverySp`, `deltaPTag`, `deltaPFloodLimit`
- Stream 레벨: `isReflux`, `recoverySp`(NaN→null)
**MapColumn** — 6개 신규 camelCase 필드 노출:
- AdvisoryResult: `mode`, `modeReason`, `vLossMa`, `frontPositionState`, `frontTrimAdvice`
- StreamAdvisory: `gradeReason`, `thetaSuggestUpSec`, `thetaSuggestDnSec`, `thetaSuggestConf`, `kObsSuggest`
**변경 파일**: `src/Web/Controllers/FeedforwardController.cs`
- MapConfig: lines 39-53 → 39-60
- MapColumn: lines 69-95 → 69-116
---
## WO-1 — P-5 confidence 자동강등
### 1.1 Downgrade 헬퍼 (FeedforwardEngine.cs)
```csharp
private static (Confidence g, string? why) Downgrade(Confidence baseG, params (bool hit, string why)[] rules)
{
int lvl = (int)baseG; // A=0, B=1, C=2
string? why = null;
foreach (var (hit, w) in rules)
if (hit) { lvl = Math.Min(2, lvl + 1); why = why is null ? w : why + "; " + w; }
return ((Confidence)lvl, why);
}
```
- A(0) → hit 1번: B(1), hit 2번: C(2). C에서 더 이상 안 내려감(Clamp).
- 사유 문자열은 `; `로 누적 연결 (예: `"PV 신선도 불량; 과도 상태"`).
### 1.2 BuildAdvisory 강등 적용 (FeedforwardEngine.cs:133-154)
`BuildAdvisory` 시그니처 확장 — `string? mbState` 파라미터 추가.
스트림별 3가지 강등 규칙:
| # | 조건 | 적용 대상 | 사유 |
|:-:|:-----|:----------|:-----|
| 1 | PV `!Good` | 해당 스트림 | `"PV 신선도 불량"` |
| 2 | `transient` | 해당 스트림 | `"과도 상태"` |
| 3 | `mbState.Contains("불일치")` **AND** `Role == Commanded` | 해당 스트림 | `"물질수지 불일치"` |
적용 순서: config Grade를 상한으로 위 3개를 `Downgrade`에 전달 → 결과 `Grade` + `GradeReason``with { Grade = grade, GradeReason = reason }`로 반환.
### 1.3 Tick 컬럼 레벨 pUnstable 강등 (FeedforwardEngine.cs:100-107)
스트림 루프 종료 후 `pUnstable == true`이면 전체 stream advisory에 대해 `Downgrade(현재 Grade, ("압력 불안정"))` 추가 적용:
```csharp
if (pUnstable)
{
outs = outs.Select(a =>
{
var (g, why) = Downgrade(a.Grade, (true, "압력 불안정"));
string? combined = a.GradeReason is null ? why : a.GradeReason + "; " + why;
return a with { Grade = g, GradeReason = combined };
}).ToList();
}
```
### 1.4 Tick 구조 변경 (WO-1 연계)
mbState를 **스트림 루프 전**에 미리 계산하도록 재구성 (원래는 스트림 루프 후 계산). 이로 인해 `BuildAdvisory`가 mbState를 인자로 받을 수 있음. vloss/yield 계산은 동일 위치 유지.
| 항목 | 변경 전 | 변경 후 |
|:-----|:--------|:--------|
| mbState 계산 시점 | 스트림 루프 후 | 스트림 루프 **전** (Pre-compute) |
| BuildAdvisory 시그니처 | `(s, pv, rec, note, transient, stt)` | `(s, pv, rec, note, transient, stt, mbState?)` |
| Hold 모드 | 변경 없음 | 변경 없음 (downgrade 미적용) |
**변경 파일**: `src/Infrastructure/Control/FeedforwardEngine.cs` (전체 197행)
---
## 검증 결과
### ✅ 빌드
```
dotnet build src/Web/ExperionCrawler.csproj
→ Build succeeded. 0 Warning(s) 0 Error(s)
```
### ✅ 기존 단위테스트
```
dotnet test tests/ExperionCrawler.Tests/ExperionCrawler.Tests.csproj
→ Passed! Failed: 0, Passed: 4, Skipped: 0
```
- DeadTime_delays_by_n_samples
- DeadTime_asymmetric_theta_preserves_history
- RateLimiter_clamps_asymmetric_up_down
- FirstOrderLag_reaches_63pct_after_tau
### ✅ 쓰기 불변식 (FF 경로)
```
grep -rnE "ExperionOpcWriteClient|Write.*Async" src/Infrastructure/Control/ src/Web/Controllers/FeedforwardController.cs
→ 0건 (정상)
```
### ✅ GradeReason 노출 확인
```
src/Core/Application/Feedforward/FeedforwardModels.cs:90: public string? GradeReason { get; init; }
src/Infrastructure/Control/FeedforwardEngine.cs:152: with { GradeReason = reason };
src/Web/Controllers/FeedforwardController.cs:109: gradeReason = s.GradeReason,
```
### ✅ 신규 필드 JSON 노출 (Controller MapColumn)
`gradeReason`, `thetaSuggestUpSec`, `thetaSuggestDnSec`, `thetaSuggestConf`, `kObsSuggest`, `mode`, `modeReason`, `vLossMa`, `frontPositionState`, `frontTrimAdvice` — 모두 camelCase로 `Ok()` 응답에 포함.
---
## 변경 파일 일람
| # | 파일 | 상태 | 변경 내용 요약 |
|:-:|:-----|:----:|:--------------|
| 1 | `src/Core/Application/Feedforward/FeedforwardModels.cs` | 변경 | §0: enum+6 record 확장, TempPoint 추가 |
| 2 | `src/Infrastructure/Database/ExperionDbContext.cs` | 변경 | §0: 19개 ALTER TABLE 멱등 추가 |
| 3 | `src/Infrastructure/Control/FeedforwardConfigStore.cs` | 변경 | §0: LoadAll/SaveAll 신규 컬럼 인덱스+파라미터 |
| 4 | `src/Web/Controllers/FeedforwardController.cs` | 변경 | §0: MapConfig/MapColumn 신규 필드 노출 |
| 5 | `src/Infrastructure/Control/FeedforwardEngine.cs` | 변경 | §0(AdvisoryResult init필드 대비) + WO-1 (Downgrade/BuildAdvisory/Tick) |
---
## 감독자 Sign-off
| 항목 | 상태 | 서명 |
|:-----|:----:|:----:|
| §0 모델 일관성 (positional record + init-only 확장) | 완료 | _____ |
| §0 DDL 인덱스 정합 (SELECT ↔ rd.GetXxx 1:1) | 완료 | _____ |
| §0 ConfigStore 저장→재로드 라운드트립 일치 | 완료 | _____ |
| §0 Controller camelCase (NaN→null 변환 포함) | 완료 | _____ |
| WO-1 Downgrade Clamp (C 초과 불가) | 완료 | _____ |
| WO-1 강등 사유 누적 (`"; "` 결합) | 완료 | _____ |
| WO-1 Tick에서 pUnstable 컬럼레벨 추가 강등 | 완료 | _____ |
| 쓰기 불변식 (FF 경로 Write*Async 0건) | ✅ 0건 | _____ |
| 기존 테스트 전원 통과 | ✅ 4/4 | _____ |
| 빌드 0W 0E | ✅ | _____ |
---
*생성: 2026-05-31 | 기준 문서: `측류추출식-통합유량설정공식-구현코딩-PhaseII-분석엔진+전환류복귀.md` §B(current) / §0 / WO-1*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,9 @@ public enum StreamRole { Commanded, LevelDriven, Monitor }
[JsonConverter(typeof(JsonStringEnumConverter))] [JsonConverter(typeof(JsonStringEnumConverter))]
public enum Confidence { A, B, C } public enum Confidence { A, B, C }
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ColumnMode { Normal, Recovering, Returning }
public sealed record StreamConfig public sealed record StreamConfig
{ {
public string Key { get; init; } = ""; public string Key { get; init; } = "";
@@ -24,6 +27,9 @@ public sealed record StreamConfig
public double RateDnPerMin { get; init; } = double.MaxValue; public double RateDnPerMin { get; init; } = double.MaxValue;
public bool RefluxFromProduct { get; init; } public bool RefluxFromProduct { get; init; }
public Confidence Grade { get; init; } = Confidence.A; 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 public sealed record ColumnConfig
@@ -44,6 +50,22 @@ public sealed record ColumnConfig
public double StaleSec { get; init; } = 120.0; public double StaleSec { get; init; } = 120.0;
public string? ProductKey { get; init; } = "P"; public string? ProductKey { get; init; } = "P";
public IReadOnlyList<StreamConfig> Streams { get; init; } = Array.Empty<StreamConfig>(); public IReadOnlyList<StreamConfig> Streams { get; init; } = Array.Empty<StreamConfig>();
public IReadOnlyList<string> TempTags { get; init; } = Array.Empty<string>();
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); public sealed record TagSample(string Tag, double Value, bool Good, DateTime Timestamp);
@@ -52,7 +74,12 @@ public sealed record PvSnapshot(
TagSample Feed, TagSample Feed,
TagSample? Pressure, TagSample? Pressure,
IReadOnlyList<TagSample> Levels, IReadOnlyList<TagSample> Levels,
IReadOnlyDictionary<string, TagSample> Streams); IReadOnlyDictionary<string, TagSample> Streams)
{
public IReadOnlyList<TagSample>? Temps { get; init; }
public TagSample? SteamOp { get; init; } // WO-3: TICA.OP (스팀, 부분상관 2번째 입력)
public TagSample? DeltaP { get; init; } // WO-6: 탑 차압(플러딩 트리거)
}
public sealed record StreamAdvisory( public sealed record StreamAdvisory(
string Key, string FlowTag, StreamRole Role, string Key, string FlowTag, StreamRole Role,
@@ -61,11 +88,37 @@ public sealed record StreamAdvisory(
bool Valid, bool Valid,
Confidence Grade, Confidence Grade,
string? LevelTag, 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( public sealed record AdvisoryResult(
int ColumnId, string ColumnName, DateTime ComputedAt, int ColumnId, string ColumnName, DateTime ComputedAt,
bool Enabled, bool Transient, string TransientReason, bool Enabled, bool Transient, string TransientReason,
double FeedFiltered, double FeedFiltered,
IReadOnlyList<StreamAdvisory> Streams, IReadOnlyList<StreamAdvisory> 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<TempPoint>? 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);

View File

@@ -13,3 +13,24 @@ public interface IFeedforwardAdvisoryStore
AdvisoryResult? Get(int columnId); AdvisoryResult? Get(int columnId);
IReadOnlyCollection<AdvisoryResult> GetAll(); IReadOnlyCollection<AdvisoryResult> 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<IReadOnlyList<dynamic>> QueryAsync(int? columnId = null, int limit = 50, CancellationToken ct = default);
}

View File

@@ -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;
}

View File

@@ -29,6 +29,7 @@ public sealed class MovingAverage
private readonly int _window; private readonly int _window;
private double _sum; private double _sum;
public MovingAverage(int windowSamples) => _window = Math.Max(1, windowSamples); 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) public double Push(double x)
{ {
_buf.Enqueue(x); _sum += 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) public static double PressureCompensated(double tMeas, double p, double pRef, double dTdP)
=> tMeas - dTdP * (p - pRef); => tMeas - dTdP * (p - pRef);
} }
/// <summary>차온/이중차온 — 공통모드(압력·계절) 상쇄, 프론트 이동만 부각. spec §13.3.
/// 본 WO-2에선 블록만 추가(단위테스트 대상). 실제 소비는 WO-5(FrontPositionIndicator).</summary>
public static class DiffTemp
{
/// <summary>두 트레이 차온 (상단 - 하단).</summary>
public static double Delta(double tHi, double tLo) => tHi - tLo;
/// <summary>이중차온(곡률) — 프론트 위치 민감.</summary>
public static double Double(double tA, double tB, double tC) => (tA - tB) - (tB - tC);
}

View File

@@ -0,0 +1,97 @@
namespace ExperionCrawler.Infrastructure.Control;
/// <summary>
/// Passive 전달지연(θ) 식별. ΔF(피드 변화)와 ΔR(응답=PCT 변화)의 교차상관 최대 지연 = θ.
/// 스팀 ΔS(=TICA.OP 변화)를 2번째 입력으로 **부분상관**(partial corr)해 폐루프 오염 제거(spec §13.4).
/// 입력은 이미 1차차분(Δ=사전백색화)된 값. I/O 없음, 컬럼 루프 단일 소유(락 불필요).
/// 비용 분산을 위해 매 호출 누적하되 argmax 계산은 recomputeEvery 호출마다 1회(나머지는 캐시 반환).
/// </summary>
public sealed class CrossCorrLagEstimator
{
private readonly int _maxLag; // 탐색할 최대 지연(샘플)
private readonly int _hist; // 보존 이력(샘플)
private readonly double _minStd; // 외란 최소 표준편차(미달 시 억제)
private readonly int _recomputeEvery; // argmax 재계산 주기(호출 수)
private readonly Queue<double> _f = new();
private readonly Queue<double> _r = new();
private readonly Queue<double> _s = new();
private int _sinceCompute;
private (double thetaUpSec, double thetaDnSec, double conf)? _last;
public CrossCorrLagEstimator(int maxLagSamples, int historySamples, double minSignalStd, int recomputeEvery = 30)
{
_maxLag = Math.Max(1, maxLagSamples);
_hist = Math.Max(_maxLag * 2, historySamples);
_minStd = minSignalStd;
_recomputeEvery = Math.Max(1, recomputeEvery);
}
public (double thetaUpSec, double thetaDnSec, double conf)? Push(
double dFeed, double dResponse, double dSteam, double tsSec)
{
_f.Enqueue(dFeed); _r.Enqueue(dResponse); _s.Enqueue(dSteam);
while (_f.Count > _hist) { _f.Dequeue(); _r.Dequeue(); _s.Dequeue(); }
if (_f.Count < _maxLag * 2) return _last; // 외란 누적 부족 → 직전 결과(초기 null)
_sinceCompute++;
if (_last is not null && _sinceCompute < _recomputeEvery) return _last; // 캐시
_sinceCompute = 0;
var f = _f.ToArray(); var r = _r.ToArray(); var s = _s.ToArray();
int n = f.Length;
if (Std(f) < _minStd) { _last = null; return null; } // 피드 외란 없음 → 억제
// 부분상관: r에서 s의 동시점 선형성분 제거 (잔차)
double beta = Cov(r, s) / Math.Max(1e-12, Var(s));
var resid = new double[n];
for (int i = 0; i < n; i++) resid[i] = r[i] - beta * s[i];
// 방향별 θ (상승/하강 비대칭). 표본 부족 시 NaN.
var (tu, cu) = BestLag(f, resid, n, x => x > 0, tsSec);
var (td, cd) = BestLag(f, resid, n, x => x < 0, tsSec);
bool haveUp = !double.IsNaN(tu), haveDn = !double.IsNaN(td);
if (!haveUp && !haveDn) { _last = null; return null; }
if (!haveUp) { tu = td; cu = cd; }
if (!haveDn) { td = tu; cd = cu; }
double conf = Math.Min(cu, cd);
if (conf < 0.3) { _last = null; return null; } // 신뢰 부족 → 억제
_last = (tu, td, conf);
return _last;
}
/// <summary>mask를 만족하는 피드 표본에 대해 ρ(τ) 최대인 τ(초)와 conf 반환. 표본 부족 시 (NaN,0).</summary>
private (double theta, double conf) BestLag(double[] f, double[] resid, int n, Func<double, bool> mask, double tsSec)
{
int masked = 0;
for (int i = 0; i < n; i++) if (mask(f[i])) masked++;
if (masked < _maxLag) return (double.NaN, 0.0);
double bestRho = double.NegativeInfinity; int bestTau = 0;
for (int tau = 0; tau <= _maxLag; tau++)
{
double sfr = 0, sff = 0, srr = 0; int m = 0;
for (int i = 0; i + tau < n; i++)
{
if (!mask(f[i])) continue;
double a = f[i], b = resid[i + tau];
sfr += a * b; sff += a * a; srr += b * b; m++;
}
if (m < 3 || sff <= 0 || srr <= 0) continue;
double rho = sfr / Math.Sqrt(sff * srr); // Δ신호라 비중심 상관
if (rho > bestRho) { bestRho = rho; bestTau = tau; }
}
if (double.IsNegativeInfinity(bestRho)) return (double.NaN, 0.0);
return (bestTau * tsSec, Math.Max(0.0, bestRho));
}
private static double Mean(double[] a) { double s = 0; foreach (var x in a) s += x; return s / a.Length; }
private static double Var(double[] a) { double m = Mean(a), s = 0; foreach (var x in a) s += (x - m) * (x - m); return s / a.Length; }
private static double Std(double[] a) => Math.Sqrt(Var(a));
private static double Cov(double[] a, double[] b)
{ double ma = Mean(a), mb = Mean(b), s = 0; for (int i = 0; i < a.Length; i++) s += (a[i] - ma) * (b[i] - mb); return s / a.Length; }
}

View File

@@ -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<FeedforwardAuditService> _logger;
public FeedforwardAuditService(ExperionDbContext ctx, ILogger<FeedforwardAuditService> 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<IReadOnlyList<dynamic>> 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<dynamic>();
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);
}
}

View File

@@ -27,7 +27,14 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
SELECT id, name, enabled, feed_tag, pressure_tag, level_tags, scan_sec, SELECT id, name, enabled, feed_tag, pressure_tag, level_tags, scan_sec,
feed_filter_tau_sec, feed_move_thr_per_min, feed_filter_tau_sec, feed_move_thr_per_min,
press_filter_tau_sec, pressure_band, settle_sec, 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 FROM ff_column_config
"""; """;
await using var rd = await cmd.ExecuteReaderAsync(ct); await using var rd = await cmd.ExecuteReaderAsync(ct);
@@ -39,12 +46,18 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => t.ToLowerInvariant()).ToArray(); .Select(t => t.ToLowerInvariant()).ToArray();
var rawTempTags = rd.IsDBNull(14) ? null : rd.GetString(14);
var tempTags = rawTempTags is null
? Array.Empty<string>()
: rawTempTags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => t.ToLowerInvariant()).ToArray();
var cfg = new ColumnConfig var cfg = new ColumnConfig
{ {
Id = rd.GetInt32(0), Id = rd.GetInt32(0),
Name = rd.GetString(1), Name = rd.GetString(1),
Enabled = rd.GetBoolean(2), Enabled = rd.GetBoolean(2),
AdvisoryOnly = true, AdvisoryOnly = rd.GetBoolean(31),
FeedTag = rd.GetString(3).ToLowerInvariant(), FeedTag = rd.GetString(3).ToLowerInvariant(),
PressureTag = rd.IsDBNull(4) ? null : rd.GetString(4).ToLowerInvariant(), PressureTag = rd.IsDBNull(4) ? null : rd.GetString(4).ToLowerInvariant(),
LevelTags = levelTags, LevelTags = levelTags,
@@ -56,7 +69,23 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
SettleSec = rd.GetDouble(11), SettleSec = rd.GetDouble(11),
StaleSec = rd.GetDouble(12), StaleSec = rd.GetDouble(12),
ProductKey = rd.GetString(13), ProductKey = rd.GetString(13),
Streams = Array.Empty<StreamConfig>() Streams = Array.Empty<StreamConfig>(),
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<StreamConfig>()); cols[cfg.Id] = (cfg, new List<StreamConfig>());
} }
@@ -67,7 +96,8 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
cmd.CommandText = """ cmd.CommandText = """
SELECT column_id, key, flow_tag, role, target_coeff, theta_up_sec, theta_dn_sec, 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, 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 FROM ff_stream_config
ORDER BY id ORDER BY id
"""; """;
@@ -82,6 +112,9 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
FlowTag = rd.GetString(2).ToLowerInvariant(), FlowTag = rd.GetString(2).ToLowerInvariant(),
Role = Enum.TryParse<StreamRole>(rd.GetString(3), true, out var role) ? role : StreamRole.Monitor, Role = Enum.TryParse<StreamRole>(rd.GetString(3), true, out var role) ? role : StreamRole.Monitor,
LevelTag = rd.IsDBNull(14) ? null : rd.GetString(14).ToLowerInvariant(), 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), TargetCoeff = rd.GetDouble(4),
ThetaUpSec = rd.GetDouble(5), ThetaUpSec = rd.GetDouble(5),
ThetaDnSec = rd.GetDouble(6), ThetaDnSec = rd.GetDouble(6),
@@ -125,15 +158,39 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
INSERT INTO ff_column_config INSERT INTO ff_column_config
(name, enabled, feed_tag, pressure_tag, level_tags, scan_sec, (name, enabled, feed_tag, pressure_tag, level_tags, scan_sec,
feed_filter_tau_sec, feed_move_thr_per_min, press_filter_tau_sec, feed_filter_tau_sec, feed_move_thr_per_min, press_filter_tau_sec,
pressure_band, settle_sec, stale_sec, product_key, advisory_only) 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) 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 RETURNING id
"""; """;
P(cmd,"@name",cfg.Name); P(cmd,"@en",cfg.Enabled); P(cmd,"@feed",cfg.FeedTag.ToLowerInvariant()); 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,"@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,"@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,"@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,"@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)); id = Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
} }
else else
@@ -145,14 +202,32 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
name=@name, enabled=@en, feed_tag=@feed, pressure_tag=@pres, level_tags=@lvl, 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, 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, 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 WHERE id=@id
"""; """;
P(cmd,"@id",id); P(cmd,"@name",cfg.Name); P(cmd,"@en",cfg.Enabled); 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,"@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,"@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,"@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,"@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); await cmd.ExecuteNonQueryAsync(ct);
} }
@@ -169,14 +244,18 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
ins.CommandText = """ ins.CommandText = """
INSERT INTO ff_stream_config INSERT INTO ff_stream_config
(column_id, key, flow_tag, role, target_coeff, theta_up_sec, theta_dn_sec, tau_sec, (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) 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) 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,"@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,"@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,"@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,"@rup",s.RateUpPerMin); P(ins,"@rdn",s.RateDnPerMin); P(ins,"@rfp",s.RefluxFromProduct);
P(ins,"@grade",s.Grade.ToString()); 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); await ins.ExecuteNonQueryAsync(ct);
} }

View File

@@ -17,6 +17,25 @@ public sealed class ColumnState
public Derivative FeedDeriv { get; } = new(); public Derivative FeedDeriv { get; } = new();
public double SettleTimerSec { get; set; } public double SettleTimerSec { get; set; }
public bool Initialized { 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<string, MovingAverage> 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<string, StreamState> Streams { get; } = new(); public Dictionary<string, StreamState> Streams { get; } = new();
public StreamState Stream(string key) public StreamState Stream(string key)
@@ -55,16 +74,34 @@ public sealed class FeedforwardEngine
: pUnstable ? "압력 불안정" : pUnstable ? "압력 불안정"
: st.SettleTimerSec > 0.0 ? $"정착 대기 {st.SettleTimerSec:F0}s" : ""; : st.SettleTimerSec > 0.0 ? $"정착 대기 {st.SettleTimerSec:F0}s" : "";
// ── 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<StreamAdvisory>(cfg.Streams.Count); var outs = new List<StreamAdvisory>(cfg.Streams.Count);
double? prodRec = null; double? prodRec = null;
foreach (var s in cfg.Streams) foreach (var s in cfg.Streams)
{ {
if (s.RefluxFromProduct) continue; if (s.RefluxFromProduct) continue;
var (rec, note) = ComputeStream(s, ff, dF, ts, st.Stream(s.Key)); var (rec, note) = ComputeStream(s, ff, dF, ts, st.Stream(s.Key));
if (s.Key == cfg.ProductKey) prodRec = rec; 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) foreach (var s in cfg.Streams)
{ {
if (!s.RefluxFromProduct) continue; if (!s.RefluxFromProduct) continue;
@@ -75,26 +112,238 @@ public sealed class FeedforwardEngine
var raw = Num.Clamp(s.TargetCoeff * p, s.SpMin, s.SpMax); var raw = Num.Clamp(s.TargetCoeff * p, s.SpMin, s.SpMax);
rec = stt.Rate.Step(raw, s.RateUpPerMin, s.RateDnPerMin, ts); 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; // ── pUnstable column-level downgrade (WO-1) ──
string mbState; if (pUnstable)
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)
{ {
vloss = ff - (d + pp2 + b); outs = outs.Select(a =>
yield = 100.0 * pp2 / ff; {
mbState = Math.Abs(vloss.Value) > 0.03 * ff ? "물질수지 불일치(계측 점검)" var (g, why) = Downgrade(a.Grade, (true, "압력 불안정"));
: vloss.Value < 0 ? "음의 손실(스팬 오류 의심)" 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, 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<TempPoint>? BuildTemps(ColumnConfig cfg, PvSnapshot pv, ColumnState st)
{
if (pv.Temps is null || pv.Temps.Count == 0) return null;
bool havePress = pv.Pressure is { Good: true } pp && Num.IsFinite(pp.Value);
double pNow = havePress ? pv.Pressure!.Value : double.NaN;
double pRef = cfg.PRef;
if (double.IsNaN(pRef))
{
if (!st.PRefSeeded && havePress) { st.PRefValue = pNow; st.PRefSeeded = true; }
pRef = st.PRefSeeded ? st.PRefValue : double.NaN;
}
var list = new List<TempPoint>(pv.Temps.Count);
foreach (var t in pv.Temps)
{
bool good = t.Good && Num.IsFinite(t.Value);
double raw = good ? t.Value : double.NaN;
double pct = raw;
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<TempPoint>? temps, ref List<StreamAdvisory> outs)
{
if (!cfg.ThetaAutoTune) return; // 옵트인(기본 off)
if (temps is null || temps.Count == 0) return;
// 응답 신호 = 민감트레이 PCT(없으면 첫 온도 PCT)
double respPct = double.NaN;
if (cfg.SensitiveTrayTag is not null)
{
var key = cfg.SensitiveTrayTag.EndsWith(".pv") ? cfg.SensitiveTrayTag : cfg.SensitiveTrayTag + ".pv";
foreach (var tp in temps) if (tp.Tag == key && tp.Good) { respPct = tp.Pct; break; }
}
if (double.IsNaN(respPct) && temps[0].Good) respPct = temps[0].Pct;
if (double.IsNaN(respPct)) return;
double feedNow = st.FeedFilter.Value;
double steamNow = pv.SteamOp is { Good: true } so && Num.IsFinite(so.Value) ? so.Value : 0.0;
// 1차차분(Δ=사전백색화). 최초 호출은 prev가 NaN이라 Δ=0(시드)
double dF = Num.IsFinite(st.PrevFeedFiltered) ? feedNow - st.PrevFeedFiltered : 0.0;
double dR = Num.IsFinite(st.PrevRespPct) ? respPct - st.PrevRespPct : 0.0;
double dS = Num.IsFinite(st.PrevSteamOp) ? steamNow - st.PrevSteamOp : 0.0;
st.PrevFeedFiltered = feedNow; st.PrevRespPct = respPct; st.PrevSteamOp = steamNow;
st.ThetaEst ??= new CrossCorrLagEstimator(
maxLagSamples: Math.Max(1, (int)Math.Round(1200.0 / Math.Max(1e-6, ts))), // ~20분 지연 탐색
historySamples: Math.Max(1, (int)Math.Round(3600.0 / Math.Max(1e-6, ts))), // ~1시간 이력
minSignalStd: 1e-9);
var est = st.ThetaEst.Push(dF, dR, dS, ts);
if (est is null) return;
var (tu, td, conf) = est.Value;
outs = outs.Select(a => a.Role == StreamRole.Commanded
? a with { ThetaSuggestUpSec = tu, ThetaSuggestDnSec = td, ThetaSuggestConf = conf }
: a).ToList();
}
// ── WO-4 P-4: 느린 바이어스 장기 MA (정상상태에서만 누적, config 무변경) ──────
private static void ApplyBias(ColumnConfig cfg, PvSnapshot pv, ColumnState st, double ff, double? vloss,
bool transient, ref List<StreamAdvisory> outs, out double? vLossMa)
{
int window = Math.Max(1, (int)Math.Round(cfg.BiasMaWindowSec / Math.Max(1e-6, cfg.ScanSec)));
vLossMa = null;
// V_loss 장기 MA (정상상태 + vloss 산출된 경우에만 누적)
if (!transient && vloss.HasValue && Num.IsFinite(vloss.Value))
{
st.VLossMaBlock ??= new MovingAverage(window);
vLossMa = st.VLossMaBlock.Push(vloss.Value);
}
else if (st.VLossMaBlock is not null)
{
vLossMa = st.VLossMaBlock.Value; // 과도 중엔 갱신 없이 직전 MA 유지(표시 연속성)
}
// commanded 스트림별 K_obs = PV/FF 의 MA → 제안
if (transient || ff <= 1e-6) return;
outs = outs.Select(a =>
{
if (a.Role != StreamRole.Commanded) return a;
if (!(pv.Streams.TryGetValue(a.Key, out var smp) && smp.Good && Num.IsFinite(smp.Value))) return a;
if (!st.KObsMa.TryGetValue(a.Key, out var ma)) { ma = new MovingAverage(window); st.KObsMa[a.Key] = ma; }
double kObs = ma.Push(smp.Value / ff);
return a with { KObsSuggest = kObs };
}).ToList();
}
// ── WO-5 P-3: 프론트 위치(sweet-spot) 지표 + 트림 권장(advisory) ──────────────
private static (string? state, string? trim) ApplyFront(ColumnConfig cfg, ColumnState st, double ts,
IReadOnlyList<TempPoint>? temps, bool transient)
{
if (temps is null || temps.Count == 0) return (null, null);
if (transient) return ("정착 대기(프론트 판정 보류)", null);
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<StreamAdvisory> 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<StreamAdvisory> 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( private static (double? rec, string note) ComputeStream(
@@ -117,15 +366,35 @@ public sealed class FeedforwardEngine
} }
private static StreamAdvisory BuildAdvisory( 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) int trend = rec is double r && Num.IsFinite(stt.LastRec)
? Math.Sign(r - stt.LastRec) : 0; ? Math.Sign(r - stt.LastRec) : 0;
if (rec is double rr) stt.LastRec = rr; if (rec is double rr) stt.LastRec = rr;
double? gap = (rec is double g && Num.IsFinite(curPv)) ? g - curPv : null; 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, 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) private static bool TryStreamPv(PvSnapshot pv, string key, out double v)

View File

@@ -4,6 +4,7 @@ using ExperionCrawler.Core.Domain.Entities;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Globalization; using System.Globalization;
namespace ExperionCrawler.Infrastructure.Control; namespace ExperionCrawler.Infrastructure.Control;
@@ -13,13 +14,24 @@ public sealed class FeedforwardSupervisor : BackgroundService
private readonly IServiceScopeFactory _scopeFactory; private readonly IServiceScopeFactory _scopeFactory;
private readonly FeedforwardEngine _engine; private readonly FeedforwardEngine _engine;
private readonly IFeedforwardAdvisoryStore _store; private readonly IFeedforwardAdvisoryStore _store;
private readonly IFeedforwardWriteGuard _writeGuard;
private readonly ILogger<FeedforwardSupervisor> _logger; private readonly ILogger<FeedforwardSupervisor> _logger;
private readonly Microsoft.Extensions.Configuration.IConfiguration _appConfig;
private readonly Dictionary<int, ColumnState> _states = new(); private readonly Dictionary<int, ColumnState> _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( public FeedforwardSupervisor(
IServiceScopeFactory scopeFactory, FeedforwardEngine engine, IServiceScopeFactory scopeFactory, FeedforwardEngine engine,
IFeedforwardAdvisoryStore store, ILogger<FeedforwardSupervisor> logger) IFeedforwardAdvisoryStore store, IFeedforwardWriteGuard writeGuard,
{ _scopeFactory = scopeFactory; _engine = engine; _store = store; _logger = logger; } ILogger<FeedforwardSupervisor> 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) protected override async Task ExecuteAsync(CancellationToken ct)
{ {
@@ -32,6 +44,8 @@ public sealed class FeedforwardSupervisor : BackgroundService
using var scope = _scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var cfgStore = scope.ServiceProvider.GetRequiredService<IFeedforwardConfigStore>(); var cfgStore = scope.ServiceProvider.GetRequiredService<IFeedforwardConfigStore>();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>(); var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var writeClient = scope.ServiceProvider.GetService<IExperionOpcWriteClient>();
var auditService = scope.ServiceProvider.GetService<IFeedforwardAuditService>();
var columns = await cfgStore.LoadAllAsync(ct); var columns = await cfgStore.LoadAllAsync(ct);
var enabled = columns.Where(c => c.Enabled).ToList(); var enabled = columns.Where(c => c.Enabled).ToList();
@@ -44,6 +58,12 @@ public sealed class FeedforwardSupervisor : BackgroundService
var snap = await BuildSnapshotAsync(db, cfg); var snap = await BuildSnapshotAsync(db, cfg);
var st = GetState(cfg.Id); var st = GetState(cfg.Id);
var res = _engine.Tick(cfg, snap, st, DateTime.UtcNow); 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); _store.Set(res);
} }
catch (Exception ex) 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) private ColumnState GetState(int id)
{ {
if (!_states.TryGetValue(id, out var s)) { s = new ColumnState(); _states[id] = s; } if (!_states.TryGetValue(id, out var s)) { s = new ColumnState(); _states[id] = s; }
return 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<PvSnapshot> BuildSnapshotAsync(IExperionDbService db, ColumnConfig cfg) private async Task<PvSnapshot> BuildSnapshotAsync(IExperionDbService db, ColumnConfig cfg)
{ {
string PvTag(string baseTag) string PvTag(string baseTag)
@@ -79,6 +174,9 @@ public sealed class FeedforwardSupervisor : BackgroundService
tags.AddRange(cfg.LevelTags.Select(PvTag)); 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.Where(s => s.LevelTag is not null).Select(s => PvTag(s.LevelTag!)));
tags.AddRange(cfg.Streams.Select(s => PvTag(s.FlowTag))); 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)) var rows = (await db.GetRealtimeRecordsByTagNamesAsync(tags))
.ToDictionary(r => r.TagName.ToLowerInvariant(), r => r); .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); 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 feed = Sample(cfg.FeedTag);
var press = cfg.PressureTag is null ? null : Sample(cfg.PressureTag); var press = cfg.PressureTag is null ? null : Sample(cfg.PressureTag);
var levels = cfg.LevelTags.Select(Sample).ToList(); var levels = cfg.LevelTags.Select(Sample).ToList();
var streams = cfg.Streams.ToDictionary(s => s.Key, s => Sample(s.FlowTag)); 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 };
} }
} }

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -28,6 +28,7 @@ public class ExperionDbContext : DbContext
public DbSet<PidEquipment> PidEquipment => Set<PidEquipment>(); public DbSet<PidEquipment> PidEquipment => Set<PidEquipment>();
public DbSet<PidPrefixRule> PidPrefixRules => Set<PidPrefixRule>(); public DbSet<PidPrefixRule> PidPrefixRules => Set<PidPrefixRule>();
public DbSet<PidAuditLog> PidAuditLog => Set<PidAuditLog>(); public DbSet<PidAuditLog> PidAuditLog => Set<PidAuditLog>();
public DbSet<FfOperatorAction> FfOperatorActions => Set<FfOperatorAction>();
public DbSet<PidGraphStatus> PidGraphStatuses => Set<PidGraphStatus>(); public DbSet<PidGraphStatus> PidGraphStatuses => Set<PidGraphStatus>();
public DbSet<EventHistoryRecord> EventHistoryRecords => Set<EventHistoryRecord>(); public DbSet<EventHistoryRecord> EventHistoryRecords => Set<EventHistoryRecord>();
@@ -175,6 +176,21 @@ public class ExperionDbContext : DbContext
entity.HasIndex(e => e.LoggedAt); entity.HasIndex(e => e.LoggedAt);
}); });
modelBuilder.Entity<FfOperatorAction>(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<PidPrefixRule>(entity => modelBuilder.Entity<PidPrefixRule>(entity =>
{ {
entity.ToTable("pid_prefix_rules"); entity.ToTable("pid_prefix_rules");
@@ -1101,6 +1117,43 @@ public class ExperionDbService : IExperionDbService
level_tag TEXT 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 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 활성화)"); _logger.LogInformation("[ExperionDb] 데이터베이스 초기화 완료 (TimeScaleDB 활성화)");

View File

@@ -1,4 +1,7 @@
using ExperionCrawler.Core.Application.Feedforward; using ExperionCrawler.Core.Application.Feedforward;
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Domain.Entities;
using ExperionCrawler.Infrastructure.Kb;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace ExperionCrawler.Web.Controllers; namespace ExperionCrawler.Web.Controllers;
@@ -9,12 +12,28 @@ public sealed class FeedforwardController : ControllerBase
{ {
private readonly IFeedforwardAdvisoryStore _store; private readonly IFeedforwardAdvisoryStore _store;
private readonly IFeedforwardConfigStore _config; 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( public FeedforwardController(
IFeedforwardAdvisoryStore store, IFeedforwardAdvisoryStore store,
IFeedforwardConfigStore config) IFeedforwardConfigStore config,
{ _store = store; _config = 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<bool> AuthAsync(CancellationToken ct)
=> await _auth.ValidateAsync(Request.Headers["X-Kb-Token"].ToString(), ct);
// ── 설정 CRUD ──
[HttpGet("config")] [HttpGet("config")]
public async Task<IActionResult> GetConfig(CancellationToken ct) public async Task<IActionResult> GetConfig(CancellationToken ct)
{ {
@@ -25,6 +44,7 @@ public sealed class FeedforwardController : ControllerBase
[HttpPost("config")] [HttpPost("config")]
public async Task<IActionResult> SaveConfig([FromBody] ColumnConfig body, CancellationToken ct) public async Task<IActionResult> SaveConfig([FromBody] ColumnConfig body, CancellationToken ct)
{ {
if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" });
var id = await _config.SaveColumnAsync(body, ct); var id = await _config.SaveColumnAsync(body, ct);
return Ok(new { success = true, id }); return Ok(new { success = true, id });
} }
@@ -32,23 +52,104 @@ public sealed class FeedforwardController : ControllerBase
[HttpDelete("config/{id:int}")] [HttpDelete("config/{id:int}")]
public async Task<IActionResult> DeleteConfig(int id, CancellationToken ct) public async Task<IActionResult> DeleteConfig(int id, CancellationToken ct)
{ {
if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" });
await _config.DeleteColumnAsync(id, ct); await _config.DeleteColumnAsync(id, ct);
return Ok(new { success = true }); 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<IActionResult> 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<IActionResult> 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, id = c.Id, name = c.Name, enabled = c.Enabled, advisoryOnly = c.AdvisoryOnly,
feedTag = c.FeedTag, pressureTag = c.PressureTag, levelTags = c.LevelTags, feedTag = c.FeedTag, pressureTag = c.PressureTag, levelTags = c.LevelTags,
scanSec = c.ScanSec, feedFilterTauSec = c.FeedFilterTauSec, scanSec = c.ScanSec, feedFilterTauSec = c.FeedFilterTauSec,
feedMoveThresholdPerMin = c.FeedMoveThresholdPerMin, pressFilterTauSec = c.PressFilterTauSec, feedMoveThresholdPerMin = c.FeedMoveThresholdPerMin, pressFilterTauSec = c.PressFilterTauSec,
pressureBand = c.PressureBand, settleSec = c.SettleSec, staleSec = c.StaleSec, productKey = c.ProductKey, 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 streams = c.Streams.Select(s => new
{ {
key = s.Key, flowTag = s.FlowTag, role = s.Role.ToString(), levelTag = s.LevelTag, targetCoeff = s.TargetCoeff, 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, thetaUpSec = s.ThetaUpSec, thetaDnSec = s.ThetaDnSec, tauSec = s.TauSec,
spMin = s.SpMin, spMax = s.SpMax, rateUpPerMin = s.RateUpPerMin, rateDnPerMin = s.RateDnPerMin, 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")] [HttpGet("advisory")]
public IActionResult GetAll() => Ok(new public IActionResult GetAll() => Ok(new
{ {
columns = _store.GetAll().Select(MapColumn) columns = _store.GetAll().Select(r => MapColumn(r))
}); });
[HttpGet("advisory/{columnId:int}")] [HttpGet("advisory/{columnId:int}")]
@@ -66,19 +167,12 @@ public sealed class FeedforwardController : ControllerBase
return r is null ? NotFound() : Ok(MapColumn(r)); return r is null ? NotFound() : Ok(MapColumn(r));
} }
private static object MapColumn(AdvisoryResult r) => new private object MapColumn(AdvisoryResult r)
{ {
columnId = r.ColumnId, var streams = r.Streams.Select(s =>
columnName = r.ColumnName, {
computedAt = r.ComputedAt, var (lastSp, lastErr, lastAt) = _supervisor.GetLastWrite(r.ColumnId, s.Key);
enabled = r.Enabled, return new
transient = r.Transient,
transientReason = r.TransientReason,
feedFiltered = r.FeedFiltered,
vLoss = r.VLoss,
yield = r.Yield,
massBalanceState = r.MassBalanceState,
streams = r.Streams.Select(s => new
{ {
key = s.Key, key = s.Key,
flowTag = s.FlowTag, flowTag = s.FlowTag,
@@ -90,7 +184,50 @@ public sealed class FeedforwardController : ControllerBase
trend = s.Trend, trend = s.Trend,
valid = s.Valid, valid = s.Valid,
grade = s.Grade.ToString(), grade = s.Grade.ToString(),
note = s.Note 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; } }

View File

@@ -125,7 +125,10 @@ builder.Services.AddScoped<IMetadataLoaderService, MetadataLoaderService>();
builder.Services.AddSingleton<ExperionCrawler.Infrastructure.Control.FeedforwardEngine>(); builder.Services.AddSingleton<ExperionCrawler.Infrastructure.Control.FeedforwardEngine>();
builder.Services.AddSingleton<ExperionCrawler.Core.Application.Feedforward.IFeedforwardAdvisoryStore, ExperionCrawler.Infrastructure.Control.FeedforwardAdvisoryStore>(); builder.Services.AddSingleton<ExperionCrawler.Core.Application.Feedforward.IFeedforwardAdvisoryStore, ExperionCrawler.Infrastructure.Control.FeedforwardAdvisoryStore>();
builder.Services.AddScoped<ExperionCrawler.Core.Application.Feedforward.IFeedforwardConfigStore, ExperionCrawler.Infrastructure.Control.FeedforwardConfigStore>(); builder.Services.AddScoped<ExperionCrawler.Core.Application.Feedforward.IFeedforwardConfigStore, ExperionCrawler.Infrastructure.Control.FeedforwardConfigStore>();
builder.Services.AddHostedService<ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor>(); builder.Services.AddScoped<ExperionCrawler.Core.Application.Feedforward.IFeedforwardAuditService, ExperionCrawler.Infrastructure.Control.FeedforwardAuditService>();
builder.Services.AddSingleton<ExperionCrawler.Core.Application.Feedforward.IFeedforwardWriteGuard, ExperionCrawler.Infrastructure.Control.FeedforwardWriteGuard>();
builder.Services.AddSingleton<ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor>());
// ── P&ID Services ─────────────────────────────────────────────────────────────── // ── P&ID Services ───────────────────────────────────────────────────────────────
builder.Services.AddScoped<IPidExtractorService, PidExtractorService>(); builder.Services.AddScoped<IPidExtractorService, PidExtractorService>();

View File

@@ -47,3 +47,35 @@
.ff-lvl-by{font-size:10px;color:var(--t2);font-weight:400} .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-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} .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}

View File

@@ -1,12 +1,17 @@
/* ff.js — 측류추출 유량 권장(FF) 대시보드 + 설정 에디터. /* ff.js — 측류추출 유량 권장(FF) 대시보드 + 설정 에디터.
Phase I: 인증 없음. 쓰기 API 추가 시 X-Kb-Token 인증 재도입. */ Phase II: X-Kb-Token 인증 (설정/쓰기), auto-write 결과 표시. */
paneInit.ff = ffInit; paneInit.ff = ffInit;
let ffTimer = null; let ffTimer = null;
function ffToken() { return sessionStorage.getItem('kbToken') || ''; }
async function ffApi(method, path, body) { async function ffApi(method, path, body) {
const h = { 'Content-Type': 'application/json' }; 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 }); 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()}`); if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
return res.status === 204 ? null : res.json(); return res.status === 204 ? null : res.json();
} }
@@ -38,12 +43,22 @@ async function ffLoadDash() {
function ffTrendIco(t) { return t > 0 ? '▲' : t < 0 ? '▼' : ''; } 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) { function ffCard(c) {
const rows = (c.streams || []).map(s => { const rows = (c.streams || []).map(s => {
const lvlTag = s.levelTag || ''; const lvlTag = s.levelTag || '';
const roleLabel = s.role === 'LevelDriven' && lvlTag const roleLabel = s.role === 'LevelDriven' && lvlTag
? `LevelDriven<br><span class="ff-lvl-by">레벨: ${esc(lvlTag)}</span>` ? `LevelDriven<br><span class="ff-lvl-by">레벨: ${esc(lvlTag)}</span>`
: s.role === 'Commanded' ? 'Commanded' : 'Monitor'; : s.role === 'Commanded' ? 'Commanded' : 'Monitor';
const writeInfo = s.lastWriteSp != null
? `<br><small class="ff-write${s.lastWriteError ? ' ff-write-err' : ''}">쓰기${s.lastWriteError ? ' 오류' : '됨'} ${fmtVal(s.lastWriteSp)}${s.lastWriteError ? ': '+esc(s.lastWriteError) : ''}</small>`
: '';
return `<tr class="${s.valid ? '' : 'ff-stale'}"> return `<tr class="${s.valid ? '' : 'ff-stale'}">
<td>${esc(s.key)}</td><td class="ff-tag">${esc(s.flowTag)}</td> <td>${esc(s.key)}</td><td class="ff-tag">${esc(s.flowTag)}</td>
<td><span class="ff-role ff-role-${esc(s.role)}">${roleLabel}</span></td> <td><span class="ff-role ff-role-${esc(s.role)}">${roleLabel}</span></td>
@@ -51,23 +66,56 @@ function ffCard(c) {
<td class="ff-num ff-rec">${s.recommendedSp==null?'':fmtVal(s.recommendedSp)}</td> <td class="ff-num ff-rec">${s.recommendedSp==null?'':fmtVal(s.recommendedSp)}</td>
<td class="ff-num">${s.gap==null?'':fmtVal(s.gap)}</td> <td class="ff-num">${s.gap==null?'':fmtVal(s.gap)}</td>
<td>${ffTrendIco(s.trend)}</td> <td>${ffTrendIco(s.trend)}</td>
<td><span class="ff-grade ff-grade-${esc(s.grade)}">${esc(s.grade)}</span></td> <td><span class="ff-grade ff-grade-${esc(s.grade)}"${s.gradeReason ? ` title="${esc(s.gradeReason)}"` : ''}>${esc(s.grade)}</span>${s.kObsSuggest!=null ? `<br><small class="ff-kobs">K~${fmtVal(s.kObsSuggest)}</small>` : ''}${writeInfo}</td>
</tr>`;}).join(''); </tr>`;}).join('');
const banner = c.transient const banner = c.transient
? `<div class="ff-transient">과도상태: ${esc(c.transientReason)} — 권장값 정착 대기</div>` : ''; ? `<div class="ff-transient">과도상태: ${esc(c.transientReason)} — 권장값 정착 대기</div>` : '';
const mb = `물질수지: ${esc(c.massBalanceState)}` + const mb = `물질수지: ${esc(c.massBalanceState)}` +
(c.vLoss!=null ? ` · V_loss ${fmtVal(c.vLoss)}` : '') + (c.vLoss!=null ? ` · V_loss ${fmtVal(c.vLoss)}` : '') +
(c.vLossMa!=null ? ` · V_loss(MA) ${fmtVal(c.vLossMa)}` : '') +
(c.yield!=null ? ` · 수율 ${fmtVal(c.yield)}%` : ''); (c.yield!=null ? ` · 수율 ${fmtVal(c.yield)}%` : '');
const temps = (c.temps && c.temps.length)
? `<div class="ff-temps">${c.temps.map(t => `<span class="ff-temp${t.good?'':' ff-stale'}">${esc(t.tag)} ${t.raw==null?'':fmtVal(t.raw)}${(t.pct!=null && t.pct!==t.raw)?` <small>PCT ${fmtVal(t.pct)}</small>`:''}</span>`).join(' · ')}</div>`
: '';
const thetaSug = (c.streams||[]).filter(s => s.thetaSuggestConf != null);
const theta = thetaSug.length
? `<div class="ff-theta">θ 제안 <small>(passive)</small>: ${thetaSug.map(s=>`${esc(s.key)}${fmtVal(s.thetaSuggestUpSec)}s ↓${fmtVal(s.thetaSuggestDnSec)}s <small>conf ${fmtVal(s.thetaSuggestConf)}</small>`).join(' · ')} — 운전원 수동 반영</div>`
: '';
const front = c.frontPositionState
? `<div class="ff-front${c.frontTrimAdvice?' ff-front-warn':''}">프론트: ${esc(c.frontPositionState)}${c.frontTrimAdvice?` → <b>${esc(c.frontTrimAdvice)}</b>`:''}</div>`
: '';
const armWait = (c.mode === 'Normal' && c.modeReason && c.modeReason.indexOf('ARM 대기') >= 0);
const modeBadge =
c.mode === 'Recovering' ? '<span class="ff-mode ff-mode-rec">전환류 복귀중 ●</span>'
: c.mode === 'Returning' ? '<span class="ff-mode ff-mode-ret">복귀 램프 ●</span>'
: armWait ? '<span class="ff-mode ff-mode-arm">전환류 권장 ⚠</span>'
: '';
const recoveryCtl =
armWait ? `<button class="btn sm danger" onclick="ffArm(${c.columnId})">전환류 ARM</button>`
: (c.mode==='Recovering'||c.mode==='Returning') ? `<button class="btn sm" onclick="ffCancelRecovery(${c.columnId})">취소(정상복귀)</button>`
: '';
const modeLine = (modeBadge || c.modeReason)
? `<div class="ff-modeline">${modeBadge} <small>${esc(c.modeReason||'')}</small> ${recoveryCtl}</div>` : '';
const writeBadge = c.autoWriteActive ? '<span class="ff-write-badge">자동 SP 쓰기</span>' : '';
const wgBlocked = c.writeGuardBlockedSp != null
? `<div class="ff-wg-blocked">쓰기 차단: ${esc(c.writeGuardReason)} (SP <b>${fmtVal(c.writeGuardBlockedSp)}</b>)</div>`
: '';
return ` return `
<div class="ff-col-card ${c.enabled?'':'ff-disabled'}"> <div class="ff-col-card ${c.enabled?'':'ff-disabled'}">
<div class="ff-col-head"><b>${esc(c.columnName)}</b> <div class="ff-col-head"><b>${esc(c.columnName)}</b>
<span class="ff-feed">FEED ${fmtVal(c.feedFiltered)}</span> <span class="ff-feed">FEED ${fmtVal(c.feedFiltered)}</span>
${writeBadge}
<span class="ff-time">${fmtTs(c.computedAt)}</span></div> <span class="ff-time">${fmtTs(c.computedAt)}</span></div>
${modeLine}
${banner} ${banner}
${wgBlocked}
<table class="ff-tbl"><thead><tr> <table class="ff-tbl"><thead><tr>
<th>스트림</th><th>태그</th><th>역할</th><th>PV</th><th>권장 SP</th><th>Δ</th><th>추세</th><th>신뢰</th> <th>스트림</th><th>태그</th><th>역할</th><th>PV</th><th>권장 SP</th><th>Δ</th><th>추세</th><th>신뢰</th>
</tr></thead><tbody>${rows}</tbody></table> </tr></thead><tbody>${rows}</tbody></table>
<div class="ff-mb">${esc(mb)}</div> <div class="ff-mb">${esc(mb)}</div>
${temps}
${theta}
${front}
<div class="ff-note">LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.</div> <div class="ff-note">LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.</div>
</div>`; </div>`;
} }
@@ -113,18 +161,25 @@ function ffEditColumn(c) {
? { name:'', enabled:false, feedTag:'', pressureTag:'', ? { name:'', enabled:false, feedTag:'', pressureTag:'',
scanSec:2, feedFilterTauSec:300, feedMoveThresholdPerMin:5, scanSec:2, feedFilterTauSec:300, feedMoveThresholdPerMin:5,
pressFilterTauSec:60, pressureBand:3, settleSec:1800, staleSec:120, productKey:'P', 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:[ 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:'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'}, {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'}, {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'} {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 = ` const colHtml = `
<div class="ff-modal-col"> <div class="ff-modal-col">
<label>컬럼명 <input class="inp" id="ff-f-name" value="${esc(def.name)}"></label> <label>컬럼명 <input class="inp" id="ff-f-name" value="${esc(def.name)}"></label>
<label><input type="checkbox" id="ff-f-enabled" ${def.enabled?'checked':''}> 활성</label> <label><input type="checkbox" id="ff-f-enabled" ${def.enabled?'checked':''}> 활성</label>
<label><input type="checkbox" id="ff-f-advisoryOnly" ${def.advisoryOnly!==false?'checked':''}> AdvisoryOnly(체크=권장만, 쓰기 안 함)</label>
<label>Feed 태그 <input class="inp" id="ff-f-feedTag" value="${esc(def.feedTag)}"></label> <label>Feed 태그 <input class="inp" id="ff-f-feedTag" value="${esc(def.feedTag)}"></label>
<label>압력 태그 <input class="inp" id="ff-f-pressureTag" value="${esc(def.pressureTag)}"></label> <label>압력 태그 <input class="inp" id="ff-f-pressureTag" value="${esc(def.pressureTag)}"></label>
<label>Product Key <input class="inp" id="ff-f-productKey" value="${esc(def.productKey)}"></label> <label>Product Key <input class="inp" id="ff-f-productKey" value="${esc(def.productKey)}"></label>
@@ -137,6 +192,28 @@ function ffEditColumn(c) {
<label><span class="ff-desc">Pressure Band: 진공 설정값과 현재값 상하 변동폭 판정 기준</span><input class="inp" type="number" id="ff-f-pressureBand" value="${def.pressureBand}"></label> <label><span class="ff-desc">Pressure Band: 진공 설정값과 현재값 상하 변동폭 판정 기준</span><input class="inp" type="number" id="ff-f-pressureBand" value="${def.pressureBand}"></label>
<label><span class="ff-desc">Settle(초): 안정화 판단 기준 시간 — 이 시간 동안 안정 시 과도상태 해제</span><input class="inp" type="number" id="ff-f-settleSec" value="${def.settleSec}"></label> <label><span class="ff-desc">Settle(초): 안정화 판단 기준 시간 — 이 시간 동안 안정 시 과도상태 해제</span><input class="inp" type="number" id="ff-f-settleSec" value="${def.settleSec}"></label>
<label><span class="ff-desc">Stale(초): 데이터 유효시간 — 마지막 갱신 후 이 시간 초과 시 사용 안 함</span><input class="inp" type="number" id="ff-f-staleSec" value="${def.staleSec}"></label> <label><span class="ff-desc">Stale(초): 데이터 유효시간 — 마지막 갱신 후 이 시간 초과 시 사용 안 함</span><input class="inp" type="number" id="ff-f-staleSec" value="${def.staleSec}"></label>
</div>
<div class="ff-modal-col">
<div class="ff-modal-subhd">온도 프로파일 / θ 자동튜닝 <small>(WO-2·3·4)</small></div>
<label><span class="ff-desc">온도 태그(콤마구분, 상→하): 프로파일 PCT 모니터 대상. 비우면 온도기능 off</span><input class="inp" id="ff-f-tempTags" value="${esc((def.tempTags||[]).join(','))}"></label>
<label><span class="ff-desc">감도트레이 태그: 프론트(sweet-spot) 위치 지표. 비우면 상-하 차온 사용</span><input class="inp" id="ff-f-sensitiveTrayTag" value="${esc(def.sensitiveTrayTag||'')}"></label>
<label><span class="ff-desc">dT/dP(°C/압력): 압력보정온도(PCT) 계수. 0이면 생온도 사용</span><input class="inp" type="number" step="any" id="ff-f-dtdp" value="${def.dtdp}"></label>
<label><span class="ff-desc">P_ref(압력 기준점): 비우면 최초 정상압력으로 자동 시드</span><input class="inp" type="number" step="any" id="ff-f-pRef" value="${def.pRef==null?'':def.pRef}"></label>
<label><span class="ff-desc">스팀 OP 태그(예 tica-6111a.op): θ 추정 폐루프 오염 제거용</span><input class="inp" id="ff-f-steamOpTag" value="${esc(def.steamOpTag||'')}"></label>
<label><input type="checkbox" id="ff-f-thetaAutoTune" ${def.thetaAutoTune?'checked':''}> θ 자동튜닝(제안만, 자동반영 없음)</label>
<label><span class="ff-desc">바이어스 MA 창(초): K_obs·V_loss 장기평균 창(기본 6h=21600)</span><input class="inp" type="number" id="ff-f-biasMaWindowSec" value="${def.biasMaWindowSec}"></label>
</div>
<div class="ff-modal-col ff-recovery-col">
<div class="ff-modal-subhd">전환류 평형복귀 (WO-6) ★</div>
<label><input type="checkbox" id="ff-f-recoveryEnabled" ${def.recoveryEnabled?'checked':''}> 전환류 복귀 기능 사용</label>
<label><input type="checkbox" id="ff-f-recoveryAutoArm" ${def.recoveryAutoArm?'checked':''}> 자동 무장(체크 해제 시 운전원 ARM 필요)</label>
<label><span class="ff-desc">불균형 트리거 비율: |V_loss(MA)|/Feed 가 이 값 초과 지속 시 전환류 권장 (0.10 = 10%)</span><input class="inp ff-trig" type="number" step="any" id="ff-f-imbalanceTriggerFrac" value="${def.imbalanceTriggerFrac}"></label>
<label><span class="ff-desc">트리거 지속(초): 불균형이 이 시간 연속 지속돼야 발동(오발동 방지, 기본 600=10분)</span><input class="inp ff-trig" type="number" id="ff-f-imbalanceTriggerSec" value="${def.imbalanceTriggerSec}"></label>
<label><span class="ff-desc">평형 대기(초): 전환류 중 평형 회복 연속 만족 시간(기본 1800=30분)</span><input class="inp" type="number" id="ff-f-recoverySettleSec" value="${def.recoverySettleSec}"></label>
<label><span class="ff-desc">복귀 램프(초): 정상 복귀 시 드로우/피드 점진 복원 시간(기본 600)</span><input class="inp" type="number" id="ff-f-returnRampSec" value="${def.returnRampSec}"></label>
<label><span class="ff-desc">전환류 중 Feed 권장값: 보통 0(차단)</span><input class="inp" type="number" step="any" id="ff-f-feedRecoverySp" value="${def.feedRecoverySp}"></label>
<label><span class="ff-desc">차압(ΔP) 태그: 플러딩 트리거용(선택). 비우면 미사용</span><input class="inp" id="ff-f-deltaPTag" value="${esc(def.deltaPTag||'')}"></label>
<label><span class="ff-desc">ΔP 플러딩 상한: 초과 지속 시 전환류 트리거. 미사용 시 매우 큰 값</span><input class="inp" type="number" step="any" id="ff-f-deltaPFloodLimit" value="${def.deltaPFloodLimit}"></label>
</div>`; </div>`;
modal.innerHTML = ` modal.innerHTML = `
@@ -153,7 +230,7 @@ function ffEditColumn(c) {
<table class="ff-tbl ff-stream-tbl"> <table class="ff-tbl ff-stream-tbl">
<thead><tr> <thead><tr>
<th>Key</th><th>Flow 태그</th><th>역할</th><th>레벨태그</th><th>K</th><th>θ_up</th><th>θ_dn</th><th>τ</th> <th>Key</th><th>Flow 태그</th><th>역할</th><th>레벨태그</th><th>K</th><th>θ_up</th><th>θ_dn</th><th>τ</th>
<th>SP_min</th><th>SP_max</th><th>Rate_up</th><th>Rate_dn</th><th>환류</th><th>신뢰</th><th></th> <th>SP_min</th><th>SP_max</th><th>Rate_up</th><th>Rate_dn</th><th>환류</th><th title="전환류 시 전량환류 대상">전환류R</th><th title="전환류 시 이 스트림 권장값(비우면 0)">복귀SP</th><th title="OPC UA SP NodeId (예: ns=3;s=ficq-6113.sp)">SP NodeId</th><th>신뢰</th><th></th>
</tr></thead> </tr></thead>
<tbody id="ff-stream-body"> <tbody id="ff-stream-body">
${def.streams.map((s,i) => ffStreamRow(s,i)).join('')} ${def.streams.map((s,i) => ffStreamRow(s,i)).join('')}
@@ -176,7 +253,7 @@ function ffEditColumn(c) {
tb.insertAdjacentHTML('beforeend', ffStreamRow({ tb.insertAdjacentHTML('beforeend', ffStreamRow({
key:'',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0,thetaUpSec:0,thetaDnSec:0, key:'',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0,thetaUpSec:0,thetaDnSec:0,
tauSec:0,spMin:0,spMax:1e9,rateUpPerMin:1e9,rateDnPerMin:1e9, tauSec:0,spMin:0,spMax:1e9,rateUpPerMin:1e9,rateDnPerMin:1e9,
refluxFromProduct:false,grade:'A' refluxFromProduct:false,grade:'A',isReflux:false,recoverySp:null,spNodeId:''
}, i)); }, i));
ffWireStreamRow(tb.lastElementChild); ffWireStreamRow(tb.lastElementChild);
}; };
@@ -208,6 +285,9 @@ function ffStreamRow(s, i) {
<td><input class="inp ff-si" type="number" step="any" value="${s.rateUpPerMin}" data-idx="${i}" data-f="rateUpPerMin"></td> <td><input class="inp ff-si" type="number" step="any" value="${s.rateUpPerMin}" data-idx="${i}" data-f="rateUpPerMin"></td>
<td><input class="inp ff-si" type="number" step="any" value="${s.rateDnPerMin}" data-idx="${i}" data-f="rateDnPerMin"></td> <td><input class="inp ff-si" type="number" step="any" value="${s.rateDnPerMin}" data-idx="${i}" data-f="rateDnPerMin"></td>
<td><input type="checkbox" ${s.refluxFromProduct?'checked':''} data-idx="${i}" data-f="refluxFromProduct"></td> <td><input type="checkbox" ${s.refluxFromProduct?'checked':''} data-idx="${i}" data-f="refluxFromProduct"></td>
<td><input type="checkbox" ${s.isReflux?'checked':''} data-idx="${i}" data-f="isReflux"></td>
<td><input class="inp ff-si" type="number" step="any" value="${s.recoverySp==null?'':s.recoverySp}" data-idx="${i}" data-f="recoverySp" placeholder="0"></td>
<td><input class="inp ff-si" value="${esc(s.spNodeId||'')}" data-idx="${i}" data-f="spNodeId" placeholder="예: ns=3;s=ficq-6113.sp"></td>
<td><select class="inp ff-si" data-idx="${i}" data-f="grade">${gradeOpts}</select></td> <td><select class="inp ff-si" data-idx="${i}" data-f="grade">${gradeOpts}</select></td>
<td><button class="btn sm danger ff-stream-del" data-idx="${i}">✕</button></td> <td><button class="btn sm danger ff-stream-del" data-idx="${i}">✕</button></td>
</tr>`; </tr>`;
@@ -234,7 +314,25 @@ function ffSaveForm(existingId) {
settleSec: +g('ff-f-settleSec').value, settleSec: +g('ff-f-settleSec').value,
staleSec: +g('ff-f-staleSec').value, staleSec: +g('ff-f-staleSec').value,
productKey: g('ff-f-productKey').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 => { streams: Array.from(document.querySelectorAll('#ff-stream-body tr')).map(tr => {
const v = (sel, f) => { const v = (sel, f) => {
const el = tr.querySelector(`[data-f="${f}"]`); const el = tr.querySelector(`[data-f="${f}"]`);
@@ -250,7 +348,10 @@ function ffSaveForm(existingId) {
thetaDnSec: +v(null,'thetaDnSec'), tauSec: +v(null,'tauSec'), thetaDnSec: +v(null,'thetaDnSec'), tauSec: +v(null,'tauSec'),
spMin: +v(null,'spMin'), spMax: +v(null,'spMax'), spMin: +v(null,'spMin'), spMax: +v(null,'spMax'),
rateUpPerMin: +v(null,'rateUpPerMin'), rateDnPerMin: +v(null,'rateDnPerMin'), 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
}; };
}) })
}; };

View File

@@ -1,6 +1,6 @@
<div class="ff-wrap"> <div class="ff-wrap">
<div class="ff-head"> <div class="ff-head">
<h2>측류추출 유량 권장 (Advisory · 보조지표)</h2> <h2>측류추출 권장 유량 설정값 (Advisory · 보조지표)</h2>
<span class="ff-badge">읽기 전용 — 권장값. 인가는 운전원</span> <span class="ff-badge">읽기 전용 — 권장값. 인가는 운전원</span>
<button id="ff-cfg-toggle" class="btn">설정 ▾</button> <button id="ff-cfg-toggle" class="btn">설정 ▾</button>
</div> </div>

View File

@@ -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<TagSample>(),
new Dictionary<string, TagSample> {
["P"] = new("p", 95, true, DateTime.UtcNow),
["D"] = new("d", 2, true, DateTime.UtcNow),
["B"] = new("b", 3, true, DateTime.UtcNow),
});
[Fact]
public void KObs_and_VLossMa_accumulate_in_steady_state()
{
var engine = new FeedforwardEngine();
var st = new ColumnState();
AdvisoryResult res = engine.Tick(Cfg(), Snap(), st, DateTime.UtcNow);
for (int i = 0; i < 20; i++) res = engine.Tick(Cfg(), Snap(), st, DateTime.UtcNow);
var p = res.Streams.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
}
}

View File

@@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using ExperionCrawler.Core.Application.Feedforward;
using ExperionCrawler.Infrastructure.Control;
using Xunit;
namespace ExperionCrawler.Tests;
/// <summary>WO-1 (P-5 confidence 자동강등) 엔진 통합 검증. Downgrade는 private이라 Tick 경유로 관측.</summary>
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<TagSample>(),
new Dictionary<string, TagSample> { ["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에서 클램프
}
}

View File

@@ -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);
}
}

View File

@@ -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<TagSample>(),
new Dictionary<string, TagSample> {
["P"]=new("p",30,true,DateTime.UtcNow), ["R"]=new("r",50,true,DateTime.UtcNow),
["D"]=new("d",2,true,DateTime.UtcNow), ["B"]=new("b",3,true,DateTime.UtcNow) });
private static PvSnapshot Balanced() => new(
new TagSample("f", 100, true, DateTime.UtcNow), null, Array.Empty<TagSample>(),
new Dictionary<string, TagSample> {
["P"]=new("p",95,true,DateTime.UtcNow), ["R"]=new("r",760,true,DateTime.UtcNow),
["D"]=new("d",2,true,DateTime.UtcNow), ["B"]=new("b",3,true,DateTime.UtcNow) });
[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);
}
}

View File

@@ -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<TagSample>(),
new Dictionary<string, TagSample> { ["P"] = new TagSample("ficq-6118", 95, true, DateTime.UtcNow)})
{ Temps = new[] { new TagSample("t1", temp, true, DateTime.UtcNow) } };
[Fact]
public void Engine_populates_pct_with_explicit_pref()
{
var res = new FeedforwardEngine().Tick(Cfg(dtdp: 0.5, pRef: 50), Snap(pressure: 52, temp: 100),
new ColumnState(), DateTime.UtcNow);
Assert.NotNull(res.Temps);
var tp = res.Temps![0];
Assert.Equal("t1", tp.Tag);
Assert.Equal(100.0, tp.Raw, 6);
Assert.Equal(99.0, tp.Pct, 6);
}
[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);
}
}

View File

@@ -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>();
(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);
}
}