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:
48
AGENTS.md
48
AGENTS.md
@@ -7,7 +7,7 @@
|
||||
| Build | `dotnet build src/Web/ExperionCrawler.csproj` | repo root |
|
||||
| Run (dev) | `dotnet run` | `src/Web/` |
|
||||
| Publish | `dotnet publish -c Release -o /opt/ExperionCrawler` | `src/Web/` |
|
||||
| Tests | `dotnet test` | repo root |
|
||||
| Tests | `dotnet test tests/ExperionCrawler.Tests/ExperionCrawler.Tests.csproj` | repo root |
|
||||
|
||||
Single project: `src/Web/ExperionCrawler.csproj`. Core and Infrastructure are included via `<Compile Include>` globs — there are no separate projects to build. Runtime target is `linux-arm64`.
|
||||
|
||||
@@ -144,3 +144,49 @@ wwwroot/
|
||||
### Phase 4 pending — CSS 분리
|
||||
|
||||
`style.css`(2,230줄)에서 탭별 스타일 분할 미완료. `docs.css`가 선례. `웹UI-개선플랜-byOPUS.md` §11 참조.
|
||||
|
||||
---
|
||||
|
||||
## Anchored Summary — Phase II (Auto-Write, WriteGuard, Audit, Auth)
|
||||
|
||||
### Done
|
||||
- WO-2 (PCT monitor), WO-3 (θ auto-tune), WO-4 (slow bias), WO-5 (front position indicator), WO-6 (total reflux recovery), WO-7 (config form expansion): all **built, tested (22/22), JS OK, sign-off: windpacer 2026-05-31**.
|
||||
- **Phase II auto-write (23 files)**:
|
||||
- `FfOperatorAction` entity (`src/Core/Domain/Entities/FfOperatorAction.cs`)
|
||||
- `ff_operator_action` DDL + `DbSet` + `OnModelCreating` in `ExperionDbContext.cs`
|
||||
- `IFeedforwardWriteGuard` + `FeedforwardWriteGuard` (SP bounds, grade C, transient, NaN checks)
|
||||
- `IFeedforwardAuditService` + `FeedforwardAuditService` (raw ADO.NET insert/query)
|
||||
- `FeedforwardSupervisor.AutoWriteAsync` — per-stream OPC UA write after Tick (rate-limited, guarded, logged)
|
||||
- `FeedforwardConfigStore` — `advisory_only` no longer hardcoded; reads/writes DB; `sp_node_id` column added
|
||||
- `FeedforwardController` — auth (X-Kb-Token) on config/delete/write/audit; `POST /api/ff/write/{id}/{key}` manual SP write; `GET /api/ff/audit` audit query; write results merged in `MapColumn`
|
||||
- `Program.cs` — `IFeedforwardAuditService` (Scoped), `IFeedforwardWriteGuard` (Singleton) registered
|
||||
- `ff.js` — `ffToken()` + X-Kb-Token header; auto-write badge; per-stream write result; `spNodeId` field in stream table; `advisoryOnly` checkbox in form
|
||||
- `ff.css` — `.ff-write-badge`, `.ff-write`, `.ff-write-err`, `.ff-wg-blocked`
|
||||
- **Build 0W/0E, test 22/22, JS OK. Sign-off: windpacer 2026-05-31.**
|
||||
|
||||
### Key Design Decisions
|
||||
- `IFeedforwardWriteGuard` is **Singleton** (stateless pure check functions) — no per-request instance needed.
|
||||
- `IFeedforwardAuditService` is **Scoped** (depends on `ExperionDbContext` which is Scoped). Supervisor resolves it from `IServiceScopeFactory` scope.
|
||||
- SP writes go through `IExperionOpcWriteClient` (Scoped) — each call creates + destroys an OPC UA session (acceptable for low-frequency writes).
|
||||
- `sp_node_id` is stored per-stream in `ff_stream_config`. If null, auto-write is skipped for that stream (no write).
|
||||
- Rate-limit: minimum `ScanSec * 2` between writes to the same stream (avoids double-writes on rapid ticks).
|
||||
- Auth: `X-Kb-Token` header validated via `IKbAuthService.ValidateAsync()` — same mechanism as RAG KB admin. Token stored in `sessionStorage` by `kbadmin.js`.
|
||||
- `AdvisoryResult.AutoWriteActive` is set by the Supervisor after Tick (not by the Engine). Engine remains pure computaton.
|
||||
- `WriteGuardBlockedSp` / `WriteGuardReason` on `AdvisoryResult` are informative only — set when streams exist but all are blocked.
|
||||
|
||||
### Relevant Files
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/Infrastructure/Control/FeedforwardWriteGuard.cs` | New — SP safety checks |
|
||||
| `src/Infrastructure/Control/FeedforwardAuditService.cs` | New — operator action log |
|
||||
| `src/Core/Domain/Entities/FfOperatorAction.cs` | New — audit log entity |
|
||||
| `src/Infrastructure/Control/FeedforwardSupervisor.cs` | Modified — `AutoWriteAsync` + `GetLastWrite` + IConfiguration |
|
||||
| `src/Infrastructure/Control/FeedforwardConfigStore.cs` | Modified — reads/writes `advisory_only` from DB, `sp_node_id` |
|
||||
| `src/Web/Controllers/FeedforwardController.cs` | Modified — auth, write, audit endpoints; write results in MapColumn |
|
||||
| `src/Web/Program.cs` | Modified — register audit + write guard |
|
||||
| `src/Web/wwwroot/js/ff.js` | Modified — token, write status, spNodeId, advisoryOnly form |
|
||||
| `src/Web/wwwroot/css/ff.css` | Modified — auto-write/blocked styles |
|
||||
| `src/Infrastructure/Database/ExperionDbContext.cs` | Modified — FfOperatorAction DbSet + DDL + OnModelCreating |
|
||||
|
||||
### Next Steps
|
||||
- Phase II complete. Consider Phase III (operator dashboard, write confirmation dialog, trend overlay) when ordered.
|
||||
|
||||
263
docs/PhaseII-분석엔진+전환류복귀-to-WO-1.md
Normal file
263
docs/PhaseII-분석엔진+전환류복귀-to-WO-1.md
Normal 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*
|
||||
224
docs/측류추출-운전제안-사용매뉴얼.md
Normal file
224
docs/측류추출-운전제안-사용매뉴얼.md
Normal 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>
|
||||
389
docs/측류추출식-통합유량설정공식-구현코딩-WO-2-완전코드.md
Normal file
389
docs/측류추출식-통합유량설정공식-구현코딩-WO-2-완전코드.md
Normal 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>
|
||||
493
docs/측류추출식-통합유량설정공식-구현코딩-WO-3-완전코드.md
Normal file
493
docs/측류추출식-통합유량설정공식-구현코딩-WO-3-완전코드.md
Normal 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>
|
||||
275
docs/측류추출식-통합유량설정공식-구현코딩-WO-4-완전코드.md
Normal file
275
docs/측류추출식-통합유량설정공식-구현코딩-WO-4-완전코드.md
Normal 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>
|
||||
304
docs/측류추출식-통합유량설정공식-구현코딩-WO-5-완전코드.md
Normal file
304
docs/측류추출식-통합유량설정공식-구현코딩-WO-5-완전코드.md
Normal 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>
|
||||
612
docs/측류추출식-통합유량설정공식-구현코딩-WO-6-완전코드.md
Normal file
612
docs/측류추출식-통합유량설정공식-구현코딩-WO-6-완전코드.md
Normal 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>
|
||||
277
docs/측류추출식-통합유량설정공식-구현코딩-WO-7-설정폼확장-완전코드.md
Normal file
277
docs/측류추출식-통합유량설정공식-구현코딩-WO-7-설정폼확장-완전코드.md
Normal 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>
|
||||
@@ -8,6 +8,9 @@ public enum StreamRole { Commanded, LevelDriven, Monitor }
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum Confidence { A, B, C }
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ColumnMode { Normal, Recovering, Returning }
|
||||
|
||||
public sealed record StreamConfig
|
||||
{
|
||||
public string Key { get; init; } = "";
|
||||
@@ -24,6 +27,9 @@ public sealed record StreamConfig
|
||||
public double RateDnPerMin { get; init; } = double.MaxValue;
|
||||
public bool RefluxFromProduct { get; init; }
|
||||
public Confidence Grade { get; init; } = Confidence.A;
|
||||
public bool IsReflux { get; init; }
|
||||
public double RecoverySp { get; init; } = double.NaN;
|
||||
public string? SpNodeId { get; init; } // Phase II: OPC UA SP 쓰기 대상 nodeId. null=쓰기 안 함
|
||||
}
|
||||
|
||||
public sealed record ColumnConfig
|
||||
@@ -44,6 +50,22 @@ public sealed record ColumnConfig
|
||||
public double StaleSec { get; init; } = 120.0;
|
||||
public string? ProductKey { get; init; } = "P";
|
||||
public IReadOnlyList<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);
|
||||
@@ -52,7 +74,12 @@ public sealed record PvSnapshot(
|
||||
TagSample Feed,
|
||||
TagSample? Pressure,
|
||||
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(
|
||||
string Key, string FlowTag, StreamRole Role,
|
||||
@@ -61,11 +88,37 @@ public sealed record StreamAdvisory(
|
||||
bool Valid,
|
||||
Confidence Grade,
|
||||
string? LevelTag,
|
||||
string Note);
|
||||
string Note)
|
||||
{
|
||||
public string? GradeReason { get; init; }
|
||||
public double? ThetaSuggestUpSec { get; init; }
|
||||
public double? ThetaSuggestDnSec { get; init; }
|
||||
public double? ThetaSuggestConf { get; init; }
|
||||
public double? KObsSuggest { get; init; }
|
||||
// Phase II: auto-write 결과
|
||||
public double? LastWriteSp { get; init; }
|
||||
public string? LastWriteError { get; init; }
|
||||
public DateTime? LastWriteAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AdvisoryResult(
|
||||
int ColumnId, string ColumnName, DateTime ComputedAt,
|
||||
bool Enabled, bool Transient, string TransientReason,
|
||||
double FeedFiltered,
|
||||
IReadOnlyList<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);
|
||||
|
||||
@@ -13,3 +13,24 @@ public interface IFeedforwardAdvisoryStore
|
||||
AdvisoryResult? Get(int columnId);
|
||||
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);
|
||||
}
|
||||
|
||||
43
src/Core/Domain/Entities/FfOperatorAction.cs
Normal file
43
src/Core/Domain/Entities/FfOperatorAction.cs
Normal 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;
|
||||
}
|
||||
@@ -29,6 +29,7 @@ public sealed class MovingAverage
|
||||
private readonly int _window;
|
||||
private double _sum;
|
||||
public MovingAverage(int windowSamples) => _window = Math.Max(1, windowSamples);
|
||||
public double Value => _buf.Count > 0 ? _sum / _buf.Count : double.NaN;
|
||||
public double Push(double x)
|
||||
{
|
||||
_buf.Enqueue(x); _sum += x;
|
||||
@@ -121,3 +122,14 @@ public static class TempCorrection
|
||||
public static double PressureCompensated(double tMeas, double p, double pRef, double dTdP)
|
||||
=> tMeas - dTdP * (p - pRef);
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
97
src/Infrastructure/Control/CrossCorrLagEstimator.cs
Normal file
97
src/Infrastructure/Control/CrossCorrLagEstimator.cs
Normal 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; }
|
||||
}
|
||||
84
src/Infrastructure/Control/FeedforwardAuditService.cs
Normal file
84
src/Infrastructure/Control/FeedforwardAuditService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,14 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
|
||||
SELECT id, name, enabled, feed_tag, pressure_tag, level_tags, scan_sec,
|
||||
feed_filter_tau_sec, feed_move_thr_per_min,
|
||||
press_filter_tau_sec, pressure_band, settle_sec,
|
||||
stale_sec, product_key
|
||||
stale_sec, product_key,
|
||||
temp_tags, sensitive_tray_tag, dtdp, p_ref, steam_op_tag,
|
||||
theta_auto_tune, bias_ma_window_sec,
|
||||
recovery_enabled, recovery_auto_arm,
|
||||
imbalance_trigger_frac, imbalance_trigger_sec,
|
||||
recovery_settle_sec, return_ramp_sec, feed_recovery_sp,
|
||||
delta_p_tag, delta_p_flood_limit,
|
||||
advisory_only
|
||||
FROM ff_column_config
|
||||
""";
|
||||
await using var rd = await cmd.ExecuteReaderAsync(ct);
|
||||
@@ -39,12 +46,18 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(t => t.ToLowerInvariant()).ToArray();
|
||||
|
||||
var rawTempTags = rd.IsDBNull(14) ? null : rd.GetString(14);
|
||||
var tempTags = rawTempTags is null
|
||||
? Array.Empty<string>()
|
||||
: rawTempTags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(t => t.ToLowerInvariant()).ToArray();
|
||||
|
||||
var cfg = new ColumnConfig
|
||||
{
|
||||
Id = rd.GetInt32(0),
|
||||
Name = rd.GetString(1),
|
||||
Enabled = rd.GetBoolean(2),
|
||||
AdvisoryOnly = true,
|
||||
AdvisoryOnly = rd.GetBoolean(31),
|
||||
FeedTag = rd.GetString(3).ToLowerInvariant(),
|
||||
PressureTag = rd.IsDBNull(4) ? null : rd.GetString(4).ToLowerInvariant(),
|
||||
LevelTags = levelTags,
|
||||
@@ -56,7 +69,23 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
|
||||
SettleSec = rd.GetDouble(11),
|
||||
StaleSec = rd.GetDouble(12),
|
||||
ProductKey = rd.GetString(13),
|
||||
Streams = Array.Empty<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>());
|
||||
}
|
||||
@@ -67,7 +96,8 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
|
||||
cmd.CommandText = """
|
||||
SELECT column_id, key, flow_tag, role, target_coeff, theta_up_sec, theta_dn_sec,
|
||||
tau_sec, sp_min, sp_max, rate_up_per_min, rate_dn_per_min,
|
||||
reflux_from_product, grade, level_tag
|
||||
reflux_from_product, grade, level_tag,
|
||||
is_reflux, recovery_sp, sp_node_id
|
||||
FROM ff_stream_config
|
||||
ORDER BY id
|
||||
""";
|
||||
@@ -82,6 +112,9 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
|
||||
FlowTag = rd.GetString(2).ToLowerInvariant(),
|
||||
Role = Enum.TryParse<StreamRole>(rd.GetString(3), true, out var role) ? role : StreamRole.Monitor,
|
||||
LevelTag = rd.IsDBNull(14) ? null : rd.GetString(14).ToLowerInvariant(),
|
||||
IsReflux = rd.GetBoolean(15),
|
||||
RecoverySp = rd.IsDBNull(16) ? double.NaN : rd.GetDouble(16),
|
||||
SpNodeId = rd.IsDBNull(17) ? null : rd.GetString(17),
|
||||
TargetCoeff = rd.GetDouble(4),
|
||||
ThetaUpSec = rd.GetDouble(5),
|
||||
ThetaDnSec = rd.GetDouble(6),
|
||||
@@ -125,15 +158,39 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
|
||||
INSERT INTO ff_column_config
|
||||
(name, enabled, feed_tag, pressure_tag, level_tags, scan_sec,
|
||||
feed_filter_tau_sec, feed_move_thr_per_min, press_filter_tau_sec,
|
||||
pressure_band, settle_sec, stale_sec, product_key, advisory_only)
|
||||
VALUES (@name,@en,@feed,@pres,@lvl,@scan,@fft,@fmt,@pft,@pb,@settle,@stale,@pk,TRUE)
|
||||
pressure_band, settle_sec, stale_sec, product_key, advisory_only,
|
||||
temp_tags, sensitive_tray_tag, dtdp, p_ref, steam_op_tag,
|
||||
theta_auto_tune, bias_ma_window_sec,
|
||||
recovery_enabled, recovery_auto_arm,
|
||||
imbalance_trigger_frac, imbalance_trigger_sec,
|
||||
recovery_settle_sec, return_ramp_sec, feed_recovery_sp,
|
||||
delta_p_tag, delta_p_flood_limit)
|
||||
VALUES (@name,@en,@feed,@pres,@lvl,@scan,@fft,@fmt,@pft,@pb,@settle,@stale,@pk,@advisory,
|
||||
@tempTags,@sensTray,@dtdp,@pRef,@steamOp,
|
||||
@thetaAuto,@biasMaWin,
|
||||
@recEn,@recAutoArm,
|
||||
@imbFrac,@imbSec,
|
||||
@recSettle,@retRamp,@feedRecSp,
|
||||
@deltaPTag,@deltaPFlood)
|
||||
RETURNING id
|
||||
""";
|
||||
P(cmd,"@name",cfg.Name); P(cmd,"@en",cfg.Enabled); P(cmd,"@feed",cfg.FeedTag.ToLowerInvariant());
|
||||
P(cmd,"@pres",(object?)cfg.PressureTag?.ToLowerInvariant()); P(cmd,"@lvl",levelTags.ToLowerInvariant());
|
||||
P(cmd,"@advisory",cfg.AdvisoryOnly);
|
||||
P(cmd,"@scan",cfg.ScanSec); P(cmd,"@fft",cfg.FeedFilterTauSec); P(cmd,"@fmt",cfg.FeedMoveThresholdPerMin);
|
||||
P(cmd,"@pft",cfg.PressFilterTauSec); P(cmd,"@pb",cfg.PressureBand); P(cmd,"@settle",cfg.SettleSec);
|
||||
P(cmd,"@stale",cfg.StaleSec); P(cmd,"@pk",cfg.ProductKey ?? "P");
|
||||
P(cmd,"@tempTags",cfg.TempTags.Count > 0 ? string.Join(',', cfg.TempTags) : DBNull.Value);
|
||||
P(cmd,"@sensTray",(object?)cfg.SensitiveTrayTag?.ToLowerInvariant() ?? DBNull.Value);
|
||||
P(cmd,"@dtdp",cfg.DTdP); P(cmd,"@pRef",double.IsNaN(cfg.PRef) ? DBNull.Value : (object)cfg.PRef);
|
||||
P(cmd,"@steamOp",(object?)cfg.SteamOpTag?.ToLowerInvariant() ?? DBNull.Value);
|
||||
P(cmd,"@thetaAuto",cfg.ThetaAutoTune); P(cmd,"@biasMaWin",cfg.BiasMaWindowSec);
|
||||
P(cmd,"@recEn",cfg.RecoveryEnabled); P(cmd,"@recAutoArm",cfg.RecoveryAutoArm);
|
||||
P(cmd,"@imbFrac",cfg.ImbalanceTriggerFrac); P(cmd,"@imbSec",cfg.ImbalanceTriggerSec);
|
||||
P(cmd,"@recSettle",cfg.RecoverySettleSec); P(cmd,"@retRamp",cfg.ReturnRampSec);
|
||||
P(cmd,"@feedRecSp",cfg.FeedRecoverySp);
|
||||
P(cmd,"@deltaPTag",(object?)cfg.DeltaPTag?.ToLowerInvariant() ?? DBNull.Value);
|
||||
P(cmd,"@deltaPFlood",cfg.DeltaPFloodLimit);
|
||||
id = Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
|
||||
}
|
||||
else
|
||||
@@ -145,14 +202,32 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
|
||||
name=@name, enabled=@en, feed_tag=@feed, pressure_tag=@pres, level_tags=@lvl,
|
||||
scan_sec=@scan, feed_filter_tau_sec=@fft, feed_move_thr_per_min=@fmt,
|
||||
press_filter_tau_sec=@pft, pressure_band=@pb, settle_sec=@settle,
|
||||
stale_sec=@stale, product_key=@pk, advisory_only=TRUE
|
||||
stale_sec=@stale, product_key=@pk, advisory_only=@advisory,
|
||||
temp_tags=@tempTags, sensitive_tray_tag=@sensTray, dtdp=@dtdp, p_ref=@pRef,
|
||||
steam_op_tag=@steamOp, theta_auto_tune=@thetaAuto, bias_ma_window_sec=@biasMaWin,
|
||||
recovery_enabled=@recEn, recovery_auto_arm=@recAutoArm,
|
||||
imbalance_trigger_frac=@imbFrac, imbalance_trigger_sec=@imbSec,
|
||||
recovery_settle_sec=@recSettle, return_ramp_sec=@retRamp, feed_recovery_sp=@feedRecSp,
|
||||
delta_p_tag=@deltaPTag, delta_p_flood_limit=@deltaPFlood
|
||||
WHERE id=@id
|
||||
""";
|
||||
P(cmd,"@id",id); P(cmd,"@name",cfg.Name); P(cmd,"@en",cfg.Enabled);
|
||||
P(cmd,"@feed",cfg.FeedTag.ToLowerInvariant()); P(cmd,"@pres",(object?)cfg.PressureTag?.ToLowerInvariant());
|
||||
P(cmd,"@advisory",cfg.AdvisoryOnly);
|
||||
P(cmd,"@lvl",levelTags.ToLowerInvariant()); P(cmd,"@scan",cfg.ScanSec); P(cmd,"@fft",cfg.FeedFilterTauSec);
|
||||
P(cmd,"@fmt",cfg.FeedMoveThresholdPerMin); P(cmd,"@pft",cfg.PressFilterTauSec); P(cmd,"@pb",cfg.PressureBand);
|
||||
P(cmd,"@settle",cfg.SettleSec); P(cmd,"@stale",cfg.StaleSec); P(cmd,"@pk",cfg.ProductKey ?? "P");
|
||||
P(cmd,"@tempTags",cfg.TempTags.Count > 0 ? string.Join(',', cfg.TempTags) : DBNull.Value);
|
||||
P(cmd,"@sensTray",(object?)cfg.SensitiveTrayTag?.ToLowerInvariant() ?? DBNull.Value);
|
||||
P(cmd,"@dtdp",cfg.DTdP); P(cmd,"@pRef",double.IsNaN(cfg.PRef) ? DBNull.Value : (object)cfg.PRef);
|
||||
P(cmd,"@steamOp",(object?)cfg.SteamOpTag?.ToLowerInvariant() ?? DBNull.Value);
|
||||
P(cmd,"@thetaAuto",cfg.ThetaAutoTune); P(cmd,"@biasMaWin",cfg.BiasMaWindowSec);
|
||||
P(cmd,"@recEn",cfg.RecoveryEnabled); P(cmd,"@recAutoArm",cfg.RecoveryAutoArm);
|
||||
P(cmd,"@imbFrac",cfg.ImbalanceTriggerFrac); P(cmd,"@imbSec",cfg.ImbalanceTriggerSec);
|
||||
P(cmd,"@recSettle",cfg.RecoverySettleSec); P(cmd,"@retRamp",cfg.ReturnRampSec);
|
||||
P(cmd,"@feedRecSp",cfg.FeedRecoverySp);
|
||||
P(cmd,"@deltaPTag",(object?)cfg.DeltaPTag?.ToLowerInvariant() ?? DBNull.Value);
|
||||
P(cmd,"@deltaPFlood",cfg.DeltaPFloodLimit);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
@@ -169,14 +244,18 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
|
||||
ins.CommandText = """
|
||||
INSERT INTO ff_stream_config
|
||||
(column_id, key, flow_tag, role, target_coeff, theta_up_sec, theta_dn_sec, tau_sec,
|
||||
sp_min, sp_max, rate_up_per_min, rate_dn_per_min, reflux_from_product, grade, level_tag)
|
||||
VALUES (@cid,@key,@flow,@role,@k,@tup,@tdn,@tau,@smin,@smax,@rup,@rdn,@rfp,@grade,@lvlTag)
|
||||
sp_min, sp_max, rate_up_per_min, rate_dn_per_min, reflux_from_product, grade, level_tag,
|
||||
is_reflux, recovery_sp, sp_node_id)
|
||||
VALUES (@cid,@key,@flow,@role,@k,@tup,@tdn,@tau,@smin,@smax,@rup,@rdn,@rfp,@grade,@lvlTag,
|
||||
@isReflux,@recSp,@spNode)
|
||||
""";
|
||||
P(ins,"@cid",id); P(ins,"@key",s.Key); P(ins,"@flow",s.FlowTag.ToLowerInvariant());
|
||||
P(ins,"@role",s.Role.ToString()); P(ins,"@lvlTag",(object?)s.LevelTag?.ToLowerInvariant() ?? DBNull.Value); P(ins,"@k",s.TargetCoeff); P(ins,"@tup",s.ThetaUpSec);
|
||||
P(ins,"@tdn",s.ThetaDnSec); P(ins,"@tau",s.TauSec); P(ins,"@smin",s.SpMin); P(ins,"@smax",s.SpMax);
|
||||
P(ins,"@rup",s.RateUpPerMin); P(ins,"@rdn",s.RateDnPerMin); P(ins,"@rfp",s.RefluxFromProduct);
|
||||
P(ins,"@grade",s.Grade.ToString());
|
||||
P(ins,"@isReflux",s.IsReflux); P(ins,"@recSp",double.IsNaN(s.RecoverySp) ? DBNull.Value : (object)s.RecoverySp);
|
||||
P(ins,"@spNode",(object?)s.SpNodeId ?? DBNull.Value);
|
||||
await ins.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,25 @@ public sealed class ColumnState
|
||||
public Derivative FeedDeriv { get; } = new();
|
||||
public double SettleTimerSec { get; set; }
|
||||
public bool Initialized { get; set; }
|
||||
// WO-2: 압력 기준점(cfg.PRef가 NaN이면 최초 정상 압력으로 시드)
|
||||
public bool PRefSeeded { get; set; }
|
||||
public double PRefValue { get; set; } = double.NaN;
|
||||
// WO-3: θ 자동튜닝(컬럼 1개 추정기 + 이전값 보존)
|
||||
public CrossCorrLagEstimator? ThetaEst { get; set; }
|
||||
public double PrevFeedFiltered { get; set; } = double.NaN;
|
||||
public double PrevRespPct { get; set; } = double.NaN;
|
||||
public double PrevSteamOp { get; set; } = double.NaN;
|
||||
// WO-4: 느린 바이어스 장기 MA (정상상태에서만 누적)
|
||||
public MovingAverage? VLossMaBlock { get; set; }
|
||||
public Dictionary<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 StreamState Stream(string key)
|
||||
@@ -55,16 +74,34 @@ public sealed class FeedforwardEngine
|
||||
: pUnstable ? "압력 불안정"
|
||||
: st.SettleTimerSec > 0.0 ? $"정착 대기 {st.SettleTimerSec:F0}s" : "";
|
||||
|
||||
var outs = new List<StreamAdvisory>(cfg.Streams.Count);
|
||||
double? prodRec = null;
|
||||
// ── Pre-compute mbState for WO-1 downgrade ──
|
||||
double? vloss = null, yield = null;
|
||||
string mbState;
|
||||
if (transient)
|
||||
mbState = $"정착 대기 ({st.SettleTimerSec:F0}s)";
|
||||
else if (TryStreamPv(pv, "D", out var dVal) && TryStreamPv(pv, "P", out var pVal)
|
||||
&& TryStreamPv(pv, "B", out var bVal) && ff > 1e-6)
|
||||
{
|
||||
vloss = ff - (dVal + pVal + bVal);
|
||||
yield = 100.0 * pVal / ff;
|
||||
mbState = Math.Abs(vloss.Value) > 0.03 * ff ? "물질수지 불일치(계측 점검)"
|
||||
: vloss.Value < 0 ? "음의 손실(스팬 오류 의심)"
|
||||
: "정상";
|
||||
}
|
||||
else mbState = "입력 부족";
|
||||
|
||||
// ── Stream 1-pass: non-reflux ──
|
||||
var outs = new List<StreamAdvisory>(cfg.Streams.Count);
|
||||
double? prodRec = null;
|
||||
foreach (var s in cfg.Streams)
|
||||
{
|
||||
if (s.RefluxFromProduct) continue;
|
||||
var (rec, note) = ComputeStream(s, ff, dF, ts, st.Stream(s.Key));
|
||||
if (s.Key == cfg.ProductKey) prodRec = rec;
|
||||
outs.Add(BuildAdvisory(s, pv, rec, note, transient, st.Stream(s.Key)));
|
||||
outs.Add(BuildAdvisory(s, pv, rec, note, transient, st.Stream(s.Key), mbState));
|
||||
}
|
||||
|
||||
// ── Stream 2-pass: reflux ──
|
||||
foreach (var s in cfg.Streams)
|
||||
{
|
||||
if (!s.RefluxFromProduct) continue;
|
||||
@@ -75,26 +112,238 @@ public sealed class FeedforwardEngine
|
||||
var raw = Num.Clamp(s.TargetCoeff * p, s.SpMin, s.SpMax);
|
||||
rec = stt.Rate.Step(raw, s.RateUpPerMin, s.RateDnPerMin, ts);
|
||||
}
|
||||
outs.Add(BuildAdvisory(s, pv, rec, "외부환류 R=R_f×P (P 지연 상속)", transient, stt));
|
||||
outs.Add(BuildAdvisory(s, pv, rec, "외부환류 R=R_f×P (P 지연 상속)", transient, stt, mbState));
|
||||
}
|
||||
|
||||
double? vloss = null, yield = null;
|
||||
string mbState;
|
||||
if (transient)
|
||||
mbState = $"정착 대기 ({st.SettleTimerSec:F0}s)";
|
||||
else if (TryStreamPv(pv, "D", out var d) && TryStreamPv(pv, "P", out var pp2)
|
||||
&& TryStreamPv(pv, "B", out var b) && ff > 1e-6)
|
||||
// ── pUnstable column-level downgrade (WO-1) ──
|
||||
if (pUnstable)
|
||||
{
|
||||
vloss = ff - (d + pp2 + b);
|
||||
yield = 100.0 * pp2 / ff;
|
||||
mbState = Math.Abs(vloss.Value) > 0.03 * ff ? "물질수지 불일치(계측 점검)"
|
||||
: vloss.Value < 0 ? "음의 손실(스팬 오류 의심)"
|
||||
: "정상";
|
||||
outs = outs.Select(a =>
|
||||
{
|
||||
var (g, why) = Downgrade(a.Grade, (true, "압력 불안정"));
|
||||
string? combined = a.GradeReason is null ? why : a.GradeReason + "; " + why;
|
||||
return a with { Grade = g, GradeReason = combined };
|
||||
}).ToList();
|
||||
}
|
||||
else mbState = "입력 부족";
|
||||
|
||||
var temps = BuildTemps(cfg, pv, st); // WO-2 PCT 모니터
|
||||
ApplyThetaSuggestion(cfg, pv, st, ts, temps, ref outs); // WO-3 θ 제안(advisory)
|
||||
ApplyBias(cfg, pv, st, ff, vloss, transient, ref outs, out var vLossMa); // WO-4 느린 바이어스
|
||||
var (frontState, frontTrim) = ApplyFront(cfg, st, ts, temps, transient); // WO-5 프론트 위치
|
||||
var (mode, modeReason, feedRecSp) = ApplyRecovery(
|
||||
cfg, pv, st, ts, ff, vLossMa, frontState, transient, ref outs); // WO-6 전환류 복귀
|
||||
return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
|
||||
transient, treason, ff, outs, vloss, yield, mbState);
|
||||
transient, treason, ff, outs, vloss, yield, mbState)
|
||||
{ Temps = temps, VLossMa = vLossMa, FrontPositionState = frontState, FrontTrimAdvice = frontTrim,
|
||||
Mode = mode, ModeReason = modeReason, FeedRecommendedSp = feedRecSp };
|
||||
}
|
||||
|
||||
// ── WO-2 P-2: 압력보정온도(PCT) 모니터 (advisory, 권장SP 무관) ───────────
|
||||
private static IReadOnlyList<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(
|
||||
@@ -117,15 +366,35 @@ public sealed class FeedforwardEngine
|
||||
}
|
||||
|
||||
private static StreamAdvisory BuildAdvisory(
|
||||
StreamConfig s, PvSnapshot pv, double? rec, string note, bool transient, StreamState stt)
|
||||
StreamConfig s, PvSnapshot pv, double? rec, string note, bool transient, StreamState stt, string? mbState = null)
|
||||
{
|
||||
double curPv = pv.Streams.TryGetValue(s.Key, out var smp) && smp.Good ? smp.Value : double.NaN;
|
||||
bool pvGood = pv.Streams.TryGetValue(s.Key, out var smp) && smp.Good;
|
||||
double curPv = pvGood ? smp!.Value : double.NaN;
|
||||
int trend = rec is double r && Num.IsFinite(stt.LastRec)
|
||||
? Math.Sign(r - stt.LastRec) : 0;
|
||||
if (rec is double rr) stt.LastRec = rr;
|
||||
double? gap = (rec is double g && Num.IsFinite(curPv)) ? g - curPv : null;
|
||||
|
||||
// WO-1 P-5: per-stream confidence downgrade
|
||||
bool mbBad = s.Role == StreamRole.Commanded && mbState is not null && mbState.Contains("불일치");
|
||||
var (grade, reason) = Downgrade(s.Grade,
|
||||
(!pvGood, "PV 신선도 불량"),
|
||||
(transient, "과도 상태"),
|
||||
(mbBad, "물질수지 불일치"));
|
||||
|
||||
return new StreamAdvisory(s.Key, s.FlowTag, s.Role, curPv, rec, gap, trend,
|
||||
!transient && s.Role != StreamRole.Monitor, s.Grade, s.LevelTag, note);
|
||||
!transient && s.Role != StreamRole.Monitor, grade, s.LevelTag, note)
|
||||
with { GradeReason = reason };
|
||||
}
|
||||
|
||||
// ── WO-1 P-5 confidence downgrade ───────────────────────────────────
|
||||
private static (Confidence g, string? why) Downgrade(Confidence baseG, params (bool hit, string why)[] rules)
|
||||
{
|
||||
int lvl = (int)baseG;
|
||||
string? why = null;
|
||||
foreach (var (hit, w) in rules)
|
||||
if (hit) { lvl = Math.Min(2, lvl + 1); why = why is null ? w : why + "; " + w; }
|
||||
return ((Confidence)lvl, why);
|
||||
}
|
||||
|
||||
private static bool TryStreamPv(PvSnapshot pv, string key, out double v)
|
||||
|
||||
@@ -4,6 +4,7 @@ using ExperionCrawler.Core.Domain.Entities;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
|
||||
namespace ExperionCrawler.Infrastructure.Control;
|
||||
@@ -13,13 +14,24 @@ public sealed class FeedforwardSupervisor : BackgroundService
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly FeedforwardEngine _engine;
|
||||
private readonly IFeedforwardAdvisoryStore _store;
|
||||
private readonly IFeedforwardWriteGuard _writeGuard;
|
||||
private readonly ILogger<FeedforwardSupervisor> _logger;
|
||||
private readonly Microsoft.Extensions.Configuration.IConfiguration _appConfig;
|
||||
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(
|
||||
IServiceScopeFactory scopeFactory, FeedforwardEngine engine,
|
||||
IFeedforwardAdvisoryStore store, ILogger<FeedforwardSupervisor> logger)
|
||||
{ _scopeFactory = scopeFactory; _engine = engine; _store = store; _logger = logger; }
|
||||
IFeedforwardAdvisoryStore store, IFeedforwardWriteGuard writeGuard,
|
||||
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)
|
||||
{
|
||||
@@ -32,6 +44,8 @@ public sealed class FeedforwardSupervisor : BackgroundService
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var cfgStore = scope.ServiceProvider.GetRequiredService<IFeedforwardConfigStore>();
|
||||
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 enabled = columns.Where(c => c.Enabled).ToList();
|
||||
@@ -44,6 +58,12 @@ public sealed class FeedforwardSupervisor : BackgroundService
|
||||
var snap = await BuildSnapshotAsync(db, cfg);
|
||||
var st = GetState(cfg.Id);
|
||||
var res = _engine.Tick(cfg, snap, st, DateTime.UtcNow);
|
||||
// Phase II: auto-write
|
||||
if (!cfg.AdvisoryOnly && writeClient is not null && auditService is not null)
|
||||
{
|
||||
await AutoWriteAsync(cfg, res, st, writeClient, auditService, ct);
|
||||
res = res with { AutoWriteActive = true };
|
||||
}
|
||||
_store.Set(res);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -60,12 +80,87 @@ public sealed class FeedforwardSupervisor : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase II: auto-write ─────────────────────────────────────────────
|
||||
private async Task AutoWriteAsync(ColumnConfig cfg, AdvisoryResult column, ColumnState st,
|
||||
IExperionOpcWriteClient writeClient, IFeedforwardAuditService audit, CancellationToken ct)
|
||||
{
|
||||
if (column.Transient)
|
||||
return;
|
||||
|
||||
foreach (var s in cfg.Streams)
|
||||
{
|
||||
if (s.Role != StreamRole.Commanded) continue;
|
||||
if (string.IsNullOrWhiteSpace(s.SpNodeId)) continue; // 쓰기 대상 미지정
|
||||
|
||||
var adv = column.Streams.FirstOrDefault(a => a.Key == s.Key);
|
||||
if (adv is null) continue;
|
||||
|
||||
// 1) WriteGuard 검증
|
||||
var check = _writeGuard.Check(cfg, adv, s, column);
|
||||
if (!check.Allowed)
|
||||
{
|
||||
// 차단 로그
|
||||
_lastWriteResults[(cfg.Id, s.Key)] = (adv.RecommendedSp, check.Reason, DateTime.UtcNow);
|
||||
await audit.LogAsync(new FfActionLogEntry(cfg.Id, "sp_write",
|
||||
StreamKey: s.Key, SpValue: adv.RecommendedSp,
|
||||
NodeId: s.SpNodeId, Result: "blocked",
|
||||
WriteguardReason: check.Reason), ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2) Rate-limit: 최소 ScanSec*2 간격
|
||||
var lastWrite = _lastWriteTimes.GetValueOrDefault((cfg.Id, s.Key), DateTime.MinValue);
|
||||
var minInterval = TimeSpan.FromSeconds(Math.Max(cfg.ScanSec * 2, 2.0));
|
||||
if (DateTime.UtcNow - lastWrite < minInterval) continue;
|
||||
|
||||
// 3) OPC UA 쓰기
|
||||
double spVal = adv.RecommendedSp!.Value;
|
||||
var result = await writeClient.WriteTagAsync(LoadServerConfig(), s.SpNodeId, spVal, ct);
|
||||
|
||||
// 4) 결과 저장
|
||||
_lastWriteTimes[(cfg.Id, s.Key)] = DateTime.UtcNow;
|
||||
if (result.Success)
|
||||
{
|
||||
_lastWriteResults[(cfg.Id, s.Key)] = (spVal, null, DateTime.UtcNow);
|
||||
_logger.LogInformation("[FF] SP 쓰기 성공 col={Col} stream={Key} node={Node} val={Val:F2}",
|
||||
cfg.Id, s.Key, s.SpNodeId, spVal);
|
||||
}
|
||||
else
|
||||
{
|
||||
_lastWriteResults[(cfg.Id, s.Key)] = (spVal, result.Error, DateTime.UtcNow);
|
||||
_logger.LogWarning("[FF] SP 쓰기 실패 col={Col} stream={Key} node={Node} err={Err}",
|
||||
cfg.Id, s.Key, s.SpNodeId, result.Error);
|
||||
}
|
||||
|
||||
await audit.LogAsync(new FfActionLogEntry(cfg.Id, "sp_write",
|
||||
StreamKey: s.Key, SpValue: spVal, NodeId: s.SpNodeId,
|
||||
Result: result.Success ? "success" : $"error: {result.Error}"), ct);
|
||||
}
|
||||
}
|
||||
|
||||
private ExperionServerConfig LoadServerConfig()
|
||||
{
|
||||
var section = _appConfig.GetSection("Experion:Default");
|
||||
return new ExperionServerConfig
|
||||
{
|
||||
ServerHostName = section["ServerHostName"] ?? "192.168.0.20",
|
||||
Port = int.TryParse(section["Port"], out var p) ? p : 4840,
|
||||
ClientHostName = section["ClientHostName"] ?? "dbsvr",
|
||||
UserName = section["UserName"] ?? "mngr",
|
||||
Password = section["Password"] ?? "mngr"
|
||||
};
|
||||
}
|
||||
|
||||
private ColumnState GetState(int id)
|
||||
{
|
||||
if (!_states.TryGetValue(id, out var s)) { s = new ColumnState(); _states[id] = s; }
|
||||
return s;
|
||||
}
|
||||
|
||||
// WO-6: 운전원 ARM/취소 (모드 판정용 플래그만 — 쓰기 아님). 다음 Tick에서 소비.
|
||||
public bool Arm(int columnId) { lock (_states) { GetState(columnId).OperatorArmed = true; } return true; }
|
||||
public bool Cancel(int columnId) { lock (_states) { GetState(columnId).OperatorCancel = true; } return true; }
|
||||
|
||||
private async Task<PvSnapshot> BuildSnapshotAsync(IExperionDbService db, ColumnConfig cfg)
|
||||
{
|
||||
string PvTag(string baseTag)
|
||||
@@ -79,6 +174,9 @@ public sealed class FeedforwardSupervisor : BackgroundService
|
||||
tags.AddRange(cfg.LevelTags.Select(PvTag));
|
||||
tags.AddRange(cfg.Streams.Where(s => s.LevelTag is not null).Select(s => PvTag(s.LevelTag!)));
|
||||
tags.AddRange(cfg.Streams.Select(s => PvTag(s.FlowTag)));
|
||||
tags.AddRange(cfg.TempTags.Select(PvTag)); // WO-2 온도 프로파일
|
||||
if (cfg.SteamOpTag is not null) tags.Add(cfg.SteamOpTag.ToLowerInvariant()); // WO-3 스팀 OP(.op 그대로)
|
||||
if (cfg.DeltaPTag is not null) tags.Add(PvTag(cfg.DeltaPTag)); // WO-6 차압(.pv)
|
||||
|
||||
var rows = (await db.GetRealtimeRecordsByTagNamesAsync(tags))
|
||||
.ToDictionary(r => r.TagName.ToLowerInvariant(), r => r);
|
||||
@@ -95,10 +193,26 @@ public sealed class FeedforwardSupervisor : BackgroundService
|
||||
return new TagSample(tag, double.NaN, Good: false, DateTime.MinValue);
|
||||
}
|
||||
|
||||
// WO-3: .op 등 비-.pv 태그를 접미사 강제 없이 그대로 읽음
|
||||
TagSample SampleExact(string rawTag)
|
||||
{
|
||||
var tag = rawTag.ToLowerInvariant();
|
||||
if (rows.TryGetValue(tag, out var r)
|
||||
&& double.TryParse(r.LiveValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var v))
|
||||
{
|
||||
bool fresh = (DateTime.UtcNow - r.Timestamp.ToUniversalTime()).TotalSeconds <= cfg.StaleSec;
|
||||
return new TagSample(tag, v, Good: fresh, r.Timestamp);
|
||||
}
|
||||
return new TagSample(tag, double.NaN, Good: false, DateTime.MinValue);
|
||||
}
|
||||
|
||||
var feed = Sample(cfg.FeedTag);
|
||||
var press = cfg.PressureTag is null ? null : Sample(cfg.PressureTag);
|
||||
var levels = cfg.LevelTags.Select(Sample).ToList();
|
||||
var streams = cfg.Streams.ToDictionary(s => s.Key, s => Sample(s.FlowTag));
|
||||
return new PvSnapshot(feed, press, levels, streams);
|
||||
var temps = cfg.TempTags.Count > 0 ? cfg.TempTags.Select(Sample).ToList() : null;
|
||||
var steam = cfg.SteamOpTag is not null ? SampleExact(cfg.SteamOpTag) : null;
|
||||
var deltaP = cfg.DeltaPTag is not null ? Sample(cfg.DeltaPTag) : null;
|
||||
return new PvSnapshot(feed, press, levels, streams) { Temps = temps, SteamOp = steam, DeltaP = deltaP };
|
||||
}
|
||||
}
|
||||
|
||||
35
src/Infrastructure/Control/FeedforwardWriteGuard.cs
Normal file
35
src/Infrastructure/Control/FeedforwardWriteGuard.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
25
src/Infrastructure/Control/FrontPositionIndicator.cs
Normal file
25
src/Infrastructure/Control/FrontPositionIndicator.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ public class ExperionDbContext : DbContext
|
||||
public DbSet<PidEquipment> PidEquipment => Set<PidEquipment>();
|
||||
public DbSet<PidPrefixRule> PidPrefixRules => Set<PidPrefixRule>();
|
||||
public DbSet<PidAuditLog> PidAuditLog => Set<PidAuditLog>();
|
||||
public DbSet<FfOperatorAction> FfOperatorActions => Set<FfOperatorAction>();
|
||||
public DbSet<PidGraphStatus> PidGraphStatuses => Set<PidGraphStatus>();
|
||||
public DbSet<EventHistoryRecord> EventHistoryRecords => Set<EventHistoryRecord>();
|
||||
|
||||
@@ -175,6 +176,21 @@ public class ExperionDbContext : DbContext
|
||||
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 =>
|
||||
{
|
||||
entity.ToTable("pid_prefix_rules");
|
||||
@@ -1101,6 +1117,43 @@ public class ExperionDbService : IExperionDbService
|
||||
level_tag TEXT
|
||||
);
|
||||
ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS level_tag TEXT;
|
||||
ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS is_reflux BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS recovery_sp DOUBLE PRECISION;
|
||||
ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS sp_node_id TEXT;
|
||||
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS temp_tags TEXT;
|
||||
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS sensitive_tray_tag TEXT;
|
||||
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS dtdp DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS p_ref DOUBLE PRECISION;
|
||||
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS steam_op_tag TEXT;
|
||||
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS theta_auto_tune BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS bias_ma_window_sec DOUBLE PRECISION NOT NULL DEFAULT 21600;
|
||||
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS recovery_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS recovery_auto_arm BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS imbalance_trigger_frac DOUBLE PRECISION NOT NULL DEFAULT 0.10;
|
||||
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS imbalance_trigger_sec DOUBLE PRECISION NOT NULL DEFAULT 600;
|
||||
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS recovery_settle_sec DOUBLE PRECISION NOT NULL DEFAULT 1800;
|
||||
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS return_ramp_sec DOUBLE PRECISION NOT NULL DEFAULT 600;
|
||||
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS feed_recovery_sp DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS delta_p_tag TEXT;
|
||||
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS delta_p_flood_limit DOUBLE PRECISION NOT NULL DEFAULT 1e9;
|
||||
""");
|
||||
|
||||
// ── FF operator action audit log ────────────────────────────────
|
||||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS ff_operator_action (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
column_id INTEGER NOT NULL,
|
||||
stream_key VARCHAR(50),
|
||||
action_type VARCHAR(50) NOT NULL,
|
||||
sp_value DOUBLE PRECISION,
|
||||
node_id VARCHAR(255),
|
||||
result VARCHAR(50) NOT NULL,
|
||||
writeguard_reason TEXT,
|
||||
operator_name VARCHAR(100),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ff_op_action_column ON ff_operator_action(column_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ff_op_action_created ON ff_operator_action(created_at DESC);
|
||||
""");
|
||||
|
||||
_logger.LogInformation("[ExperionDb] 데이터베이스 초기화 완료 (TimeScaleDB 활성화)");
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using ExperionCrawler.Core.Application.Feedforward;
|
||||
using ExperionCrawler.Core.Application.Interfaces;
|
||||
using ExperionCrawler.Core.Domain.Entities;
|
||||
using ExperionCrawler.Infrastructure.Kb;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ExperionCrawler.Web.Controllers;
|
||||
@@ -9,12 +12,28 @@ public sealed class FeedforwardController : ControllerBase
|
||||
{
|
||||
private readonly IFeedforwardAdvisoryStore _store;
|
||||
private readonly IFeedforwardConfigStore _config;
|
||||
private readonly IFeedforwardAuditService _audit;
|
||||
private readonly IFeedforwardWriteGuard _writeGuard;
|
||||
private readonly IExperionOpcWriteClient _writeClient;
|
||||
private readonly IKbAuthService _auth;
|
||||
private readonly Microsoft.Extensions.Configuration.IConfiguration _appConfig;
|
||||
private readonly ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor _supervisor;
|
||||
public FeedforwardController(
|
||||
IFeedforwardAdvisoryStore store,
|
||||
IFeedforwardConfigStore config)
|
||||
{ _store = store; _config = config; }
|
||||
IFeedforwardConfigStore config,
|
||||
IFeedforwardAuditService audit,
|
||||
IFeedforwardWriteGuard writeGuard,
|
||||
IExperionOpcWriteClient writeClient,
|
||||
IKbAuthService auth,
|
||||
Microsoft.Extensions.Configuration.IConfiguration appConfig,
|
||||
ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor supervisor)
|
||||
{ _store = store; _config = config; _audit = audit; _writeGuard = writeGuard;
|
||||
_writeClient = writeClient; _auth = auth; _appConfig = appConfig; _supervisor = supervisor; }
|
||||
|
||||
// ── 설정 CRUD (Phase I: 인증 없음. 쓰기 API 추가 시 IKbAuthService 재도입) ──
|
||||
private async Task<bool> AuthAsync(CancellationToken ct)
|
||||
=> await _auth.ValidateAsync(Request.Headers["X-Kb-Token"].ToString(), ct);
|
||||
|
||||
// ── 설정 CRUD ──
|
||||
[HttpGet("config")]
|
||||
public async Task<IActionResult> GetConfig(CancellationToken ct)
|
||||
{
|
||||
@@ -25,6 +44,7 @@ public sealed class FeedforwardController : ControllerBase
|
||||
[HttpPost("config")]
|
||||
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);
|
||||
return Ok(new { success = true, id });
|
||||
}
|
||||
@@ -32,23 +52,104 @@ public sealed class FeedforwardController : ControllerBase
|
||||
[HttpDelete("config/{id:int}")]
|
||||
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);
|
||||
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,
|
||||
feedTag = c.FeedTag, pressureTag = c.PressureTag, levelTags = c.LevelTags,
|
||||
scanSec = c.ScanSec, feedFilterTauSec = c.FeedFilterTauSec,
|
||||
feedMoveThresholdPerMin = c.FeedMoveThresholdPerMin, pressFilterTauSec = c.PressFilterTauSec,
|
||||
pressureBand = c.PressureBand, settleSec = c.SettleSec, staleSec = c.StaleSec, productKey = c.ProductKey,
|
||||
tempTags = c.TempTags, sensitiveTrayTag = c.SensitiveTrayTag,
|
||||
dtdp = c.DTdP, pRef = double.IsNaN(c.PRef) ? (double?)null : c.PRef,
|
||||
steamOpTag = c.SteamOpTag, thetaAutoTune = c.ThetaAutoTune,
|
||||
biasMaWindowSec = c.BiasMaWindowSec,
|
||||
recoveryEnabled = c.RecoveryEnabled, recoveryAutoArm = c.RecoveryAutoArm,
|
||||
imbalanceTriggerFrac = c.ImbalanceTriggerFrac, imbalanceTriggerSec = c.ImbalanceTriggerSec,
|
||||
recoverySettleSec = c.RecoverySettleSec, returnRampSec = c.ReturnRampSec,
|
||||
feedRecoverySp = c.FeedRecoverySp,
|
||||
deltaPTag = c.DeltaPTag, deltaPFloodLimit = c.DeltaPFloodLimit,
|
||||
streams = c.Streams.Select(s => new
|
||||
{
|
||||
key = s.Key, flowTag = s.FlowTag, role = s.Role.ToString(), levelTag = s.LevelTag, targetCoeff = s.TargetCoeff,
|
||||
thetaUpSec = s.ThetaUpSec, thetaDnSec = s.ThetaDnSec, tauSec = s.TauSec,
|
||||
spMin = s.SpMin, spMax = s.SpMax, rateUpPerMin = s.RateUpPerMin, rateDnPerMin = s.RateDnPerMin,
|
||||
refluxFromProduct = s.RefluxFromProduct, grade = s.Grade.ToString()
|
||||
refluxFromProduct = s.RefluxFromProduct, grade = s.Grade.ToString(),
|
||||
isReflux = s.IsReflux, recoverySp = double.IsNaN(s.RecoverySp) ? (double?)null : s.RecoverySp,
|
||||
spNodeId = s.SpNodeId
|
||||
})
|
||||
};
|
||||
|
||||
@@ -56,7 +157,7 @@ public sealed class FeedforwardController : ControllerBase
|
||||
[HttpGet("advisory")]
|
||||
public IActionResult GetAll() => Ok(new
|
||||
{
|
||||
columns = _store.GetAll().Select(MapColumn)
|
||||
columns = _store.GetAll().Select(r => MapColumn(r))
|
||||
});
|
||||
|
||||
[HttpGet("advisory/{columnId:int}")]
|
||||
@@ -66,31 +167,67 @@ public sealed class FeedforwardController : ControllerBase
|
||||
return r is null ? NotFound() : Ok(MapColumn(r));
|
||||
}
|
||||
|
||||
private static object MapColumn(AdvisoryResult r) => new
|
||||
private object MapColumn(AdvisoryResult r)
|
||||
{
|
||||
columnId = r.ColumnId,
|
||||
columnName = r.ColumnName,
|
||||
computedAt = r.ComputedAt,
|
||||
enabled = r.Enabled,
|
||||
transient = r.Transient,
|
||||
transientReason = r.TransientReason,
|
||||
feedFiltered = r.FeedFiltered,
|
||||
vLoss = r.VLoss,
|
||||
yield = r.Yield,
|
||||
massBalanceState = r.MassBalanceState,
|
||||
streams = r.Streams.Select(s => new
|
||||
var streams = r.Streams.Select(s =>
|
||||
{
|
||||
key = s.Key,
|
||||
flowTag = s.FlowTag,
|
||||
role = s.Role.ToString(),
|
||||
levelTag = s.LevelTag,
|
||||
pv = double.IsNaN(s.Pv) ? (double?)null : s.Pv,
|
||||
recommendedSp = s.RecommendedSp,
|
||||
gap = s.Gap,
|
||||
trend = s.Trend,
|
||||
valid = s.Valid,
|
||||
grade = s.Grade.ToString(),
|
||||
note = s.Note
|
||||
})
|
||||
};
|
||||
var (lastSp, lastErr, lastAt) = _supervisor.GetLastWrite(r.ColumnId, s.Key);
|
||||
return new
|
||||
{
|
||||
key = s.Key,
|
||||
flowTag = s.FlowTag,
|
||||
role = s.Role.ToString(),
|
||||
levelTag = s.LevelTag,
|
||||
pv = double.IsNaN(s.Pv) ? (double?)null : s.Pv,
|
||||
recommendedSp = s.RecommendedSp,
|
||||
gap = s.Gap,
|
||||
trend = s.Trend,
|
||||
valid = s.Valid,
|
||||
grade = s.Grade.ToString(),
|
||||
note = s.Note,
|
||||
gradeReason = s.GradeReason,
|
||||
thetaSuggestUpSec = s.ThetaSuggestUpSec,
|
||||
thetaSuggestDnSec = s.ThetaSuggestDnSec,
|
||||
thetaSuggestConf = s.ThetaSuggestConf,
|
||||
kObsSuggest = s.KObsSuggest,
|
||||
// Phase II: auto-write 결과
|
||||
lastWriteSp = lastSp,
|
||||
lastWriteError = lastErr,
|
||||
lastWriteAt = lastAt
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return new
|
||||
{
|
||||
columnId = r.ColumnId,
|
||||
columnName = r.ColumnName,
|
||||
computedAt = r.ComputedAt,
|
||||
enabled = r.Enabled,
|
||||
transient = r.Transient,
|
||||
transientReason = r.TransientReason,
|
||||
feedFiltered = r.FeedFiltered,
|
||||
vLoss = r.VLoss,
|
||||
yield = r.Yield,
|
||||
massBalanceState = r.MassBalanceState,
|
||||
mode = r.Mode.ToString(),
|
||||
modeReason = r.ModeReason,
|
||||
feedRecommendedSp = r.FeedRecommendedSp,
|
||||
vLossMa = r.VLossMa,
|
||||
frontPositionState = r.FrontPositionState,
|
||||
frontTrimAdvice = r.FrontTrimAdvice,
|
||||
autoWriteActive = r.AutoWriteActive,
|
||||
writeGuardBlockedSp = r.WriteGuardBlockedSp,
|
||||
writeGuardReason = r.WriteGuardReason,
|
||||
temps = r.Temps?.Select(t => new
|
||||
{
|
||||
tag = t.Tag,
|
||||
raw = double.IsNaN(t.Raw) ? (double?)null : t.Raw,
|
||||
pct = double.IsNaN(t.Pct) ? (double?)null : t.Pct,
|
||||
good = t.Good
|
||||
}),
|
||||
streams = streams
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record WriteSpBody { public double? value { get; init; } }
|
||||
|
||||
@@ -125,7 +125,10 @@ builder.Services.AddScoped<IMetadataLoaderService, MetadataLoaderService>();
|
||||
builder.Services.AddSingleton<ExperionCrawler.Infrastructure.Control.FeedforwardEngine>();
|
||||
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.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 ───────────────────────────────────────────────────────────────
|
||||
builder.Services.AddScoped<IPidExtractorService, PidExtractorService>();
|
||||
|
||||
@@ -47,3 +47,35 @@
|
||||
.ff-lvl-by{font-size:10px;color:var(--t2);font-weight:400}
|
||||
.ff-lvl-tag{width:72px!important;font-size:10px!important;padding:2px 4px!important}
|
||||
.ff-desc{font-size:12px;color:var(--t3);line-height:1.4}
|
||||
/* WO-2 온도 프로파일(PCT) 모니터 행 */
|
||||
.ff-temps{font-size:12px;color:var(--t2);margin-top:6px;display:flex;flex-wrap:wrap;gap:4px 10px}
|
||||
.ff-temp{white-space:nowrap}
|
||||
.ff-temp small{color:#7fd1ff}
|
||||
.ff-temp.ff-stale{opacity:.45}
|
||||
/* WO-3 θ 자동튜닝 제안 행 */
|
||||
.ff-theta{font-size:12px;color:#cdb4ff;margin-top:6px}
|
||||
.ff-theta small{color:var(--t2)}
|
||||
/* WO-4 K_obs 제안 */
|
||||
.ff-kobs{color:#9fd;opacity:.8}
|
||||
/* WO-5 프론트 위치 */
|
||||
.ff-front{font-size:12px;color:var(--t2);margin-top:6px}
|
||||
.ff-front-warn{color:#ffd24d}
|
||||
.ff-front-warn b{color:#ffb300}
|
||||
/* WO-6 전환류 모드 */
|
||||
.ff-modeline{margin:4px 0;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.ff-mode{font-size:12px;font-weight:600;padding:2px 8px;border-radius:10px}
|
||||
.ff-mode-rec{background:#5a3000;color:#ffb74d}
|
||||
.ff-mode-ret{background:#003a4d;color:#7fd1ff}
|
||||
.ff-mode-arm{background:#5a0000;color:#ff8a80;animation:ffblink 1s step-start infinite}
|
||||
@keyframes ffblink{50%{opacity:.4}}
|
||||
/* WO-7 설정폼 신규 섹션 */
|
||||
.ff-modal-subhd{font-weight:600;margin:4px 0 6px;color:var(--t1);border-bottom:1px solid var(--bd);padding-bottom:3px}
|
||||
.ff-modal-subhd small{font-weight:400;color:var(--t2)}
|
||||
.ff-recovery-col{background:rgba(90,0,0,.08);border-radius:6px;padding:6px}
|
||||
.ff-trig{border-color:#ff8a80 !important}
|
||||
/* Phase II: auto-write */
|
||||
.ff-write-badge{font-size:10px;background:#003a4d;color:#7fd1ff;padding:2px 8px;border-radius:8px;margin-left:6px}
|
||||
.ff-write{font-size:10px;color:#7fd1ff;opacity:.8}
|
||||
.ff-write-err{color:#ff8a80}
|
||||
.ff-wg-blocked{font-size:12px;color:#ff8a80;background:#3a0000;padding:4px 8px;border-radius:4px;margin:4px 0}
|
||||
.ff-wg-blocked b{color:#ff5252}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
/* ff.js — 측류추출 유량 권장(FF) 대시보드 + 설정 에디터.
|
||||
Phase I: 인증 없음. 쓰기 API 추가 시 X-Kb-Token 인증 재도입. */
|
||||
Phase II: X-Kb-Token 인증 (설정/쓰기), auto-write 결과 표시. */
|
||||
paneInit.ff = ffInit;
|
||||
|
||||
let ffTimer = null;
|
||||
|
||||
function ffToken() { return sessionStorage.getItem('kbToken') || ''; }
|
||||
|
||||
async function ffApi(method, path, body) {
|
||||
const h = { 'Content-Type': 'application/json' };
|
||||
const t = ffToken();
|
||||
if (t) h['X-Kb-Token'] = t;
|
||||
const res = await fetch(path, { method, headers: h, body: body ? JSON.stringify(body) : undefined });
|
||||
if (res.status === 401) throw new Error('인증 필요 — RAG 관리 탭에서 로그인하세요');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
||||
return res.status === 204 ? null : res.json();
|
||||
}
|
||||
@@ -38,12 +43,22 @@ async function ffLoadDash() {
|
||||
|
||||
function ffTrendIco(t) { return t > 0 ? '▲' : t < 0 ? '▼' : '–'; }
|
||||
|
||||
function ffArm(id) {
|
||||
if (!confirm(`컬럼 ${id} 전환류 모드를 ARM(가동) 하시겠습니까?\n드로우 중단·전량 환류가 권장됩니다(실제 쓰기는 별도 인가).`)) return;
|
||||
ffApi('POST', `/api/ff/recovery/${id}/arm`).then(()=>ffLoadDash()).catch(()=>{});
|
||||
}
|
||||
function ffCancelRecovery(id) {
|
||||
ffApi('POST', `/api/ff/recovery/${id}/cancel`).then(()=>ffLoadDash()).catch(()=>{});
|
||||
}
|
||||
function ffCard(c) {
|
||||
const rows = (c.streams || []).map(s => {
|
||||
const lvlTag = s.levelTag || '';
|
||||
const roleLabel = s.role === 'LevelDriven' && lvlTag
|
||||
? `LevelDriven<br><span class="ff-lvl-by">레벨: ${esc(lvlTag)}</span>`
|
||||
: 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'}">
|
||||
<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>
|
||||
@@ -51,23 +66,56 @@ function ffCard(c) {
|
||||
<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>${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('');
|
||||
const banner = c.transient
|
||||
? `<div class="ff-transient">과도상태: ${esc(c.transientReason)} — 권장값 정착 대기</div>` : '';
|
||||
const mb = `물질수지: ${esc(c.massBalanceState)}` +
|
||||
(c.vLoss!=null ? ` · V_loss ${fmtVal(c.vLoss)}` : '') +
|
||||
(c.vLossMa!=null ? ` · V_loss(MA) ${fmtVal(c.vLossMa)}` : '') +
|
||||
(c.yield!=null ? ` · 수율 ${fmtVal(c.yield)}%` : '');
|
||||
const temps = (c.temps && c.temps.length)
|
||||
? `<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 `
|
||||
<div class="ff-col-card ${c.enabled?'':'ff-disabled'}">
|
||||
<div class="ff-col-head"><b>${esc(c.columnName)}</b>
|
||||
<span class="ff-feed">FEED ${fmtVal(c.feedFiltered)}</span>
|
||||
${writeBadge}
|
||||
<span class="ff-time">${fmtTs(c.computedAt)}</span></div>
|
||||
${modeLine}
|
||||
${banner}
|
||||
${wgBlocked}
|
||||
<table class="ff-tbl"><thead><tr>
|
||||
<th>스트림</th><th>태그</th><th>역할</th><th>PV</th><th>권장 SP</th><th>Δ</th><th>추세</th><th>신뢰</th>
|
||||
</tr></thead><tbody>${rows}</tbody></table>
|
||||
<div class="ff-mb">${esc(mb)}</div>
|
||||
${temps}
|
||||
${theta}
|
||||
${front}
|
||||
<div class="ff-note">LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -113,18 +161,25 @@ function ffEditColumn(c) {
|
||||
? { name:'', enabled:false, feedTag:'', pressureTag:'',
|
||||
scanSec:2, feedFilterTauSec:300, feedMoveThresholdPerMin:5,
|
||||
pressFilterTauSec:60, pressureBand:3, settleSec:1800, staleSec:120, productKey:'P',
|
||||
// WO-2 온도/PCT · WO-3 θ자동튜닝 · WO-4 바이어스
|
||||
tempTags:[], sensitiveTrayTag:'', dtdp:0, pRef:null, steamOpTag:'', thetaAutoTune:false, biasMaWindowSec:21600,
|
||||
// WO-6 전환류 복귀
|
||||
recoveryEnabled:false, recoveryAutoArm:false, imbalanceTriggerFrac:0.10, imbalanceTriggerSec:600,
|
||||
recoverySettleSec:1800, returnRampSec:600, feedRecoverySp:0, deltaPTag:'', deltaPFloodLimit:1e9,
|
||||
streams:[
|
||||
{key:'P',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0.95,thetaUpSec:60,thetaDnSec:60,tauSec:900,spMin:0,spMax:9999,rateUpPerMin:30,rateDnPerMin:60,refluxFromProduct:false,grade:'A'},
|
||||
{key:'R',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0.80,thetaUpSec:0,thetaDnSec:0,tauSec:0,spMin:0,spMax:9999,rateUpPerMin:30,rateDnPerMin:30,refluxFromProduct:true,grade:'A'},
|
||||
{key:'D',flowTag:'',role:'LevelDriven',levelTag:'lica-6113',targetCoeff:0.02,thetaUpSec:0,thetaDnSec:0,tauSec:0,spMin:0,spMax:9999,rateUpPerMin:0,rateDnPerMin:0,refluxFromProduct:false,grade:'B'},
|
||||
{key:'B',flowTag:'',role:'LevelDriven',levelTag:'li-6111',targetCoeff:0.03,thetaUpSec:0,thetaDnSec:0,tauSec:0,spMin:0,spMax:9999,rateUpPerMin:0,rateDnPerMin:0,refluxFromProduct:false,grade:'B'}
|
||||
{key:'P',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0.95,thetaUpSec:60,thetaDnSec:60,tauSec:900,spMin:0,spMax:9999,rateUpPerMin:30,rateDnPerMin:60,refluxFromProduct:false,grade:'A',isReflux:false,recoverySp:0,spNodeId:''},
|
||||
{key:'R',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0.80,thetaUpSec:0,thetaDnSec:0,tauSec:0,spMin:0,spMax:9999,rateUpPerMin:30,rateDnPerMin:30,refluxFromProduct:true,grade:'A',isReflux:true,recoverySp:null,spNodeId:''},
|
||||
{key:'D',flowTag:'',role:'LevelDriven',levelTag:'lica-6113',targetCoeff:0.02,thetaUpSec:0,thetaDnSec:0,tauSec:0,spMin:0,spMax:9999,rateUpPerMin:0,rateDnPerMin:0,refluxFromProduct:false,grade:'B',isReflux:false,recoverySp:0,spNodeId:''},
|
||||
{key:'B',flowTag:'',role:'LevelDriven',levelTag:'li-6111',targetCoeff:0.03,thetaUpSec:0,thetaDnSec:0,tauSec:0,spMin:0,spMax:9999,rateUpPerMin:0,rateDnPerMin:0,refluxFromProduct:false,grade:'B',isReflux:false,recoverySp:0,spNodeId:''}
|
||||
] }
|
||||
: { ...c, pressureTag: c.pressureTag||'' };
|
||||
: { ...c, pressureTag: c.pressureTag||'',
|
||||
tempTags: c.tempTags||[], sensitiveTrayTag: c.sensitiveTrayTag||'', steamOpTag: c.steamOpTag||'', deltaPTag: c.deltaPTag||'' };
|
||||
|
||||
const colHtml = `
|
||||
<div class="ff-modal-col">
|
||||
<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-advisoryOnly" ${def.advisoryOnly!==false?'checked':''}> AdvisoryOnly(체크=권장만, 쓰기 안 함)</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>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">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>
|
||||
</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>`;
|
||||
|
||||
modal.innerHTML = `
|
||||
@@ -153,7 +230,7 @@ function ffEditColumn(c) {
|
||||
<table class="ff-tbl ff-stream-tbl">
|
||||
<thead><tr>
|
||||
<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>
|
||||
<tbody id="ff-stream-body">
|
||||
${def.streams.map((s,i) => ffStreamRow(s,i)).join('')}
|
||||
@@ -176,7 +253,7 @@ function ffEditColumn(c) {
|
||||
tb.insertAdjacentHTML('beforeend', ffStreamRow({
|
||||
key:'',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0,thetaUpSec:0,thetaDnSec:0,
|
||||
tauSec:0,spMin:0,spMax:1e9,rateUpPerMin:1e9,rateDnPerMin:1e9,
|
||||
refluxFromProduct:false,grade:'A'
|
||||
refluxFromProduct:false,grade:'A',isReflux:false,recoverySp:null,spNodeId:''
|
||||
}, i));
|
||||
ffWireStreamRow(tb.lastElementChild);
|
||||
};
|
||||
@@ -208,6 +285,9 @@ function ffStreamRow(s, i) {
|
||||
<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 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><button class="btn sm danger ff-stream-del" data-idx="${i}">✕</button></td>
|
||||
</tr>`;
|
||||
@@ -234,7 +314,25 @@ function ffSaveForm(existingId) {
|
||||
settleSec: +g('ff-f-settleSec').value,
|
||||
staleSec: +g('ff-f-staleSec').value,
|
||||
productKey: g('ff-f-productKey').value,
|
||||
advisoryOnly: true,
|
||||
advisoryOnly: g('ff-f-advisoryOnly').checked,
|
||||
// WO-2/3/4
|
||||
tempTags: g('ff-f-tempTags').value.split(',').map(s=>s.trim()).filter(Boolean),
|
||||
sensitiveTrayTag: g('ff-f-sensitiveTrayTag').value || null,
|
||||
dtdp: +g('ff-f-dtdp').value,
|
||||
pRef: g('ff-f-pRef').value === '' ? undefined : +g('ff-f-pRef').value,
|
||||
steamOpTag: g('ff-f-steamOpTag').value || null,
|
||||
thetaAutoTune: g('ff-f-thetaAutoTune').checked,
|
||||
biasMaWindowSec: +g('ff-f-biasMaWindowSec').value,
|
||||
// WO-6
|
||||
recoveryEnabled: g('ff-f-recoveryEnabled').checked,
|
||||
recoveryAutoArm: g('ff-f-recoveryAutoArm').checked,
|
||||
imbalanceTriggerFrac: +g('ff-f-imbalanceTriggerFrac').value,
|
||||
imbalanceTriggerSec: +g('ff-f-imbalanceTriggerSec').value,
|
||||
recoverySettleSec: +g('ff-f-recoverySettleSec').value,
|
||||
returnRampSec: +g('ff-f-returnRampSec').value,
|
||||
feedRecoverySp: +g('ff-f-feedRecoverySp').value,
|
||||
deltaPTag: g('ff-f-deltaPTag').value || null,
|
||||
deltaPFloodLimit: +g('ff-f-deltaPFloodLimit').value,
|
||||
streams: Array.from(document.querySelectorAll('#ff-stream-body tr')).map(tr => {
|
||||
const v = (sel, f) => {
|
||||
const el = tr.querySelector(`[data-f="${f}"]`);
|
||||
@@ -250,7 +348,10 @@ function ffSaveForm(existingId) {
|
||||
thetaDnSec: +v(null,'thetaDnSec'), tauSec: +v(null,'tauSec'),
|
||||
spMin: +v(null,'spMin'), spMax: +v(null,'spMax'),
|
||||
rateUpPerMin: +v(null,'rateUpPerMin'), rateDnPerMin: +v(null,'rateDnPerMin'),
|
||||
refluxFromProduct: v(null,'refluxFromProduct'), grade: v(null,'grade')
|
||||
refluxFromProduct: v(null,'refluxFromProduct'), grade: v(null,'grade'),
|
||||
isReflux: v(null,'isReflux'),
|
||||
recoverySp: (() => { const x = v(null,'recoverySp'); return x === '' ? undefined : +x; })(),
|
||||
spNodeId: v(null,'spNodeId') || null
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="ff-wrap">
|
||||
<div class="ff-head">
|
||||
<h2>측류추출 유량 권장 (Advisory · 보조지표)</h2>
|
||||
<h2>측류추출 권장 유량 설정값 (Advisory · 보조지표)</h2>
|
||||
<span class="ff-badge">읽기 전용 — 권장값. 인가는 운전원</span>
|
||||
<button id="ff-cfg-toggle" class="btn">설정 ▾</button>
|
||||
</div>
|
||||
|
||||
48
tests/ExperionCrawler.Tests/FeedforwardBiasTests.cs
Normal file
48
tests/ExperionCrawler.Tests/FeedforwardBiasTests.cs
Normal 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
|
||||
}
|
||||
}
|
||||
59
tests/ExperionCrawler.Tests/FeedforwardEngineTests.cs
Normal file
59
tests/ExperionCrawler.Tests/FeedforwardEngineTests.cs
Normal 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에서 클램프
|
||||
}
|
||||
}
|
||||
40
tests/ExperionCrawler.Tests/FeedforwardFrontTests.cs
Normal file
40
tests/ExperionCrawler.Tests/FeedforwardFrontTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
87
tests/ExperionCrawler.Tests/FeedforwardRecoveryTests.cs
Normal file
87
tests/ExperionCrawler.Tests/FeedforwardRecoveryTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
73
tests/ExperionCrawler.Tests/FeedforwardTempTests.cs
Normal file
73
tests/ExperionCrawler.Tests/FeedforwardTempTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
40
tests/ExperionCrawler.Tests/FeedforwardThetaTests.cs
Normal file
40
tests/ExperionCrawler.Tests/FeedforwardThetaTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user