Compare commits
6 Commits
b53a34c9db
...
7c26aa7361
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c26aa7361 | ||
|
|
671d4ee1e5 | ||
|
|
48a6b6be57 | ||
|
|
7f1965a678 | ||
|
|
e3167807b4 | ||
|
|
7688757b21 |
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*
|
||||
344
docs/보조운전-브레인스토밍.md
Normal file
344
docs/보조운전-브레인스토밍.md
Normal file
@@ -0,0 +1,344 @@
|
||||
# 보조운전 (Operation Assistant) — 브레인스토밍
|
||||
|
||||
> 2026-05-30, C-6111 PGMEA 측류 추출식 증류탑 기준
|
||||
|
||||
## 1. 목표
|
||||
|
||||
LLM(MCP) + 실시간 DCS 데이터 + KB(SOP)를 결합하여 운전원의 startup / 생산 순도 확인 / 생산 모드 / shutdown 과정을 **단계별로 보조**하는 UI.
|
||||
|
||||
## 2. 핵심 설계 원칙
|
||||
|
||||
| 구분 | 처리 방식 | 예시 |
|
||||
|:----|:---------|:-----|
|
||||
| **DCS 계측 항목** | 시스템 자동 검증 (`find_tags`/`run_sql`/`query_events`) | Pump 상태, FICQ SP, XV open/close, 온도/압력 |
|
||||
| **수동 항목** (hand valve, local gauge, visual) | LLM 지시 → **운전원이 "확인완료" 클릭** | "HV-6115 개방하세요" → 버튼 클릭 |
|
||||
| **계산/분석 항목** | MCP tool + LLM 추론 | 물질수지 검증, 순도 확인, R/P 계산 |
|
||||
|
||||
## 3. 시스템 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 보조운전 UI (신규 pane ops.html + ops.js) │
|
||||
│ ┌─ sub-tabs: [startup] [verify_purity] [production] [shutdown] │
|
||||
│ └─ Step progress bar + 단계별 카드 + 확인 버튼 │
|
||||
└──────────────────┬──────────────────────────────────────────┘
|
||||
│ HTTP (JSON-RPC)
|
||||
┌──────────────────▼──────────────────────────────────────────┐
|
||||
│ C# Controller (/api/ops/*) │
|
||||
│ - GET /api/ops/guide?unit=C-6111&mode=startup&step=1 │
|
||||
│ - POST /api/ops/confirm { unit, mode, step, feedback } │
|
||||
│ - GET /api/ops/state?unit=C-6111 │
|
||||
└──────────────────┬──────────────────────────────────────────┘
|
||||
│ IMcpService
|
||||
┌──────────────────▼──────────────────────────────────────────┐
|
||||
│ Python MCP Server (server.py) │
|
||||
│ - execute_operation_guide(unit, mode, step, feedback) │
|
||||
│ - KB 검색 (rag_query) + 실시간 데이터 검증 (find_tags) │
|
||||
│ - 내부 state machine으로 현재 단계 추적 │
|
||||
└──────────────────┬──────────────────────────────────────────┘
|
||||
│
|
||||
┌──────────────────▼──────────────────────────────────────────┐
|
||||
│ Data Sources │
|
||||
│ ├─ Qdrant (KB — SOP 문서) │
|
||||
│ ├─ PostgreSQL (DCS 실시간/이력 데이터) │
|
||||
│ └─ vLLM (Qwen3.6-35B-A3B-FP8) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 4. SOP 문서 구조 (KB ingest 용)
|
||||
|
||||
각 SOP는 JSON 구조의 step 배열로 청킹:
|
||||
|
||||
```json
|
||||
{
|
||||
"doc_id": "c-6111-startup-sop",
|
||||
"title": "C-6111 Start-up 절차",
|
||||
"unit": "C-6111",
|
||||
"mode": "startup",
|
||||
"steps": [
|
||||
{
|
||||
"step": 1,
|
||||
"title": "전단계 안전 확인",
|
||||
"actions": [
|
||||
"비상정지 버튼(XV-6123, XV-6124) CLOSE 확인",
|
||||
"컬럼 진공압 PI-6111 확인 (~50 torr)"
|
||||
],
|
||||
"verification_type": "operator",
|
||||
"expected": null
|
||||
},
|
||||
{
|
||||
"step": 2,
|
||||
"title": "원료 라인 구성",
|
||||
"actions": [
|
||||
"HV-6101 개방 (feed line, 수동밸브)",
|
||||
"HV-6115 개방 (reflux return, 수동밸브)",
|
||||
"HV-6116 개방 (bottom line, 수동밸브)"
|
||||
],
|
||||
"verification_type": "operator",
|
||||
"expected": null
|
||||
},
|
||||
{
|
||||
"step": 3,
|
||||
"title": "원료 공급 펌프 기동 및 유량 설정",
|
||||
"actions": [
|
||||
"P-6102 R-RUN 전환",
|
||||
"FICQ-6101 SP=820 kg/hr 설정"
|
||||
],
|
||||
"verification_type": "system",
|
||||
"expected": {
|
||||
"p-6102.pv": "R-RUN",
|
||||
"ficq-6101.pv": { "min": 800, "max": 840 }
|
||||
}
|
||||
},
|
||||
{
|
||||
"step": 4,
|
||||
"title": "Reboiler 가온",
|
||||
"actions": [
|
||||
"TICA-6111A SP=81.5°C 설정",
|
||||
"Reboiler 응축수 라인 확인"
|
||||
],
|
||||
"verification_type": "mixed",
|
||||
"system_checks": [
|
||||
{ "tag": "tica-6111a.pv", "min": 78, "max": 85 }
|
||||
],
|
||||
"operator_action": "Reboiler 응축수 드레인 밸브 개방 확인"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 청킹 전략
|
||||
|
||||
- 문서 1개 = 1개 unit × 1개 mode (startup/shutdown/production)
|
||||
- 각 step의 `verification_type`: `operator` | `system` | `mixed`
|
||||
- `expected` 필드: 자동 검증할 태그명(key)과 기대값(value)
|
||||
- KB collection: `plant_operation`
|
||||
|
||||
## 5. UI 구조 (보조운전 화면)
|
||||
|
||||
### 5.1 레이아웃
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ [pane-hdr] 보조운전 / C-6111 운전 보조 │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ [mode-tabs] │
|
||||
│ ● START-UP ○ 순도 확인 ○ 생산모드 ○ SHUTDOWN │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ [step-progress] │
|
||||
│ ■■■■□□□□□□□ Step 4/12 │
|
||||
│ ●●●●○○○○○○○ 33% │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ [step-card] │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ Step 4: Reboiler 가온 │ │
|
||||
│ │ │ │
|
||||
│ │ □ TICA-6111A SP=81.5°C 설정 │ │
|
||||
│ │ ✓ PV=79.6°C (설정 중...) │ │
|
||||
│ │ □ Reboiler 응축수 드레인 밸브 개방 확인 │ │
|
||||
│ │ [✔ 확인완료] ← 운전원 클릭 │ │
|
||||
│ │ │ │
|
||||
│ │ [← 이전 단계] [다음 단계 →] │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ [참고 정보 패널 - collapsible] │
|
||||
│ - 현재 컬럼 온도 프로파일 │
|
||||
│ - 현재 물질수지 │
|
||||
│ - 활성 알람 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.2 sub-tab 구성
|
||||
|
||||
| 탭 | mode | 설명 |
|
||||
|:---|:-----|:------|
|
||||
| START-UP | `startup` | 초기 기동 → 정상 상태 도달 |
|
||||
| 순도 확인 | `verify_purity` | 생산 전 물질수지 + 온도 프로파일 검증 |
|
||||
| 생산모드 | `production` | 정상 운전 중 모니터링 및 미세 조정 |
|
||||
| SHUTDOWN | `shutdown` | 정지 절차 |
|
||||
|
||||
### 5.3 step-card 구성 요소
|
||||
|
||||
- Step 번호 + 제목
|
||||
- Action 항목 리스트 (체크박스 형태)
|
||||
- 시스템 자동 확인 항목: ✓ / ✗ 아이콘
|
||||
- 운전원 확인 항목: [확인완료] 버튼
|
||||
- 진행 버튼: [← 이전] [다음 →]
|
||||
- 참고정보 패널: 현재 공정 데이터 요약
|
||||
|
||||
## 6. MCP Tool: `execute_operation_guide`
|
||||
|
||||
### Signature
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
async def execute_operation_guide(
|
||||
unit: str,
|
||||
mode: str, # "startup" | "verify_purity" | "production" | "shutdown"
|
||||
operator_feedback: str | None = None,
|
||||
current_step: int = 1
|
||||
) -> str:
|
||||
"""
|
||||
KB에서 SOP 조회 → 실시간 데이터 검증 → 다음 action 반환
|
||||
"""
|
||||
```
|
||||
|
||||
### 동작 흐름
|
||||
|
||||
1. KB에서 `unit` + `mode`에 해당하는 SOP 검색
|
||||
2. `current_step`의 expected 조건을 실시간 데이터로 검증
|
||||
3. 검증 실패 항목 있으면 → 경고 메시지
|
||||
4. `operator_feedback`이 있으면 → 현재 step 메모에 기록
|
||||
5. 다음 step 정보 + 검증 결과 + action 목록을 JSON으로 반환
|
||||
|
||||
### 응답 구조
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"unit": "C-6111",
|
||||
"mode": "startup",
|
||||
"current_step": 4,
|
||||
"total_steps": 12,
|
||||
"title": "Reboiler 가온",
|
||||
"actions": [
|
||||
{
|
||||
"description": "TICA-6111A SP=81.5°C 설정",
|
||||
"type": "system",
|
||||
"status": "in_progress",
|
||||
"detail": "PV=79.6°C, SP=81.5°C (설정 중...)"
|
||||
},
|
||||
{
|
||||
"description": "Reboiler 응축수 드레인 밸브 개방 확인",
|
||||
"type": "operator",
|
||||
"status": "pending",
|
||||
"detail": null
|
||||
}
|
||||
],
|
||||
"all_verified": false,
|
||||
"can_proceed": false
|
||||
}
|
||||
```
|
||||
|
||||
## 7. C# Controller: `/api/ops/*`
|
||||
|
||||
| Route | Method | Description |
|
||||
|:------|:-------|:------------|
|
||||
| `/api/ops/guide` | GET | unit + mode + step → SOP 조회 + 검증 |
|
||||
| `/api/ops/confirm` | POST | 운전원 확인 feedback 전송 |
|
||||
| `/api/ops/state` | GET | 현재 진행 상태 조회 (재접속 등) |
|
||||
| `/api/ops/reset` | POST | 진행 상태 초기화 |
|
||||
|
||||
## 8. 프론트엔드 구현 순서
|
||||
|
||||
| 단계 | 파일 | 내용 |
|
||||
|:----:|:-----|:------|
|
||||
| 1 | `index.html` | nav item #18 + pane + script 추가 |
|
||||
| 2 | `panes/ops.html` | UI 레이아웃 (sub-tabs, progress, step-card) |
|
||||
| 3 | `css/ops.css` | 전용 스타일 |
|
||||
| 4 | `js/ops.js` | `paneInit.ops` — 탭 전환, step 렌더링, confirm 버튼 |
|
||||
| 5 | `server.py` | `execute_operation_guide` MCP tool |
|
||||
| 6 | C# Controller | `/api/ops/*` 라우트 |
|
||||
| 7 | SOP 문서 | KB ingest 용 청킹 |
|
||||
|
||||
## 9. 향후 확장
|
||||
|
||||
- **다중 unit 지원**: C-6111 외 C-6211 등으로 확장
|
||||
- **에이전트 모드**: LLM이 조건 만족 시 자동으로 다음 단계 진행
|
||||
- **히스토리 트래킹**: 각 startup/shutdown 이력 DB 저장 → 패턴 분석
|
||||
- **Alarm 연동**: 비정상 상황 감지 시 자동 shutdown 권고
|
||||
|
||||
## 10. 구현 전 사전 검증 항목
|
||||
|
||||
> ⚠️ 현재 이 문서는 **아이디어 스케치(브레인스토밍) 수준**이다. 아래 항목에 대한 검증과 사전 작업 없이 구현에 들어갈 경우, 실제 플랜트와의 정합성 문제로 재작업이 발생한다.
|
||||
|
||||
### 10.1 SOP JSON 구조 검증
|
||||
|
||||
| 검증 항목 | 상세 | 확인 방법 |
|
||||
|:----------|:-----|:---------|
|
||||
| step 필드 적절성 | title/actions/verification_type/expected만으로 모든 절차 표현 가능? | 실제 절차서 1개를 JSON으로 변환해보기 |
|
||||
| 중첩 조건 표현 | "A 또는 B 상태" 같은 OR 조건 필요? `expected` 값의 표현력 | 태그 상태가 binary가 아닌 경우 (범위, enum) |
|
||||
| 분기/조건부 step | 특정 조건에서만 수행하는 step 처리 | 예: "진공압이 50 torr 이하이면..." |
|
||||
| 병렬 step | 동시에 수행 가능한 여러 action | 정렬 순서 강제 vs 자유로운 병렬 |
|
||||
| 비정상 경로 | 정상 절차 외의 예외 처리 (재시도, 우회) | step 실패 시 fallback 절차 |
|
||||
|
||||
### 10.2 DCS 태그 계측 범위 검증
|
||||
|
||||
| 검증 항목 | 상세 | 확인 방법 |
|
||||
|:----------|:-----|:---------|
|
||||
| 검증 가능한 태그 목록 | SOP의 `expected` 조건에 사용할 태그들이 실제 realtime_table에 존재? | `find_tags` + `v_tag_summary` |
|
||||
| 태그 데이터 타입 | digital(R-RUN/L-STOP) / analog(PV float) / enum 구분 | 각 태그별 `.pv` 값 형식 확인 |
|
||||
| 계측 불가 항목 | 수동밸브, local gauge, visual check — 태그 없음 | SOP에서 `operator` 타입으로만 처리 |
|
||||
| 신뢰도 | 태그 값이 0 또는 null인 경우 (고장/fault) | TI-6111A(PV=0), LI-6128(PV=0) 같은 사례 |
|
||||
|
||||
**C-6111 예비 조사 결과 (2026-05-30):**
|
||||
|
||||
| 태그 | 계측됨 | 데이터형 | 비고 |
|
||||
|:----|:------:|:--------|:------|
|
||||
| ficq-6101.pv | ✅ | analog | Feed SP=820 ✓ |
|
||||
| ficq-6113.pv | ✅ | analog | Reflux SP=350 ✓ |
|
||||
| ficq-6118.pv | ✅ | analog | Product SP=777.2 ✓ |
|
||||
| pi-6111.pv | ✅ | analog | Vacuum ~48.5 torr ✓ |
|
||||
| tica-6111a.pv | ✅ | analog | Reboiler SP=81.5 ✓ |
|
||||
| ti-6111a.pv | ❌ | analog | PV=0 → 고장 의심 |
|
||||
| ti-6111b/c/d.pv | ✅ | analog | Column profile ✓ |
|
||||
| li-6128.pv | ❌ | analog | Scrubber level PV=0 → 미계측 |
|
||||
| p-6102.pv | ✅ | digital? | R-RUN / L-STOP (상태 확인 필요) |
|
||||
| xv-6123.pv | ? | digital | OPEN/CLOSE (태그 형식 확인 필요) |
|
||||
|
||||
### 10.3 Mode 분류 검증
|
||||
|
||||
| Mode | C-6111 적용 여부 | 비고 |
|
||||
|:-----|:--------------:|:------|
|
||||
| **startup** | ⬜ | 최초 기동 절차 존재? SIP(Startup Inspection Plan) 보유? |
|
||||
| **verify_purity** | ⬜ | 생산 전 순도 분석 절차? off-spec 시 재순환 루틴? |
|
||||
| **production** | ⬜ | 정상 운전 중 모니터링 항목? 조정 주기? |
|
||||
| **shutdown** | ⬜ | 정지 절차? emergency shutdown과 normal shutdown 분리? |
|
||||
| **grade_change** | ⬜ | 제품 grade 전환 절차 필요한가? (C-6111 연속식 단일 제품?) |
|
||||
| **emergency** | ⬜ | Alarm/interlock 조건 발동 시 대응 절차? |
|
||||
|
||||
### 10.4 운전원 피드백 종류
|
||||
|
||||
| 피드백 | 의미 | UI 액션 |
|
||||
|:-------|:-----|:---------|
|
||||
| ✅ **확인완료** | 지시받은 수동 조작 완료 | 다음 step 진행 가능 |
|
||||
| ⏸️ **보류** | 조건 미충족으로 일단 대기 | step 유지, 진행 불가 |
|
||||
| ⚠️ **이상있음** | 예상과 다른 상태 발견 | LLM이 재확인/대안 제시 |
|
||||
| 💬 **메모** | 자유 텍스트 기록 (운전일지) | step 로그에 기록 |
|
||||
|
||||
### 10.5 SOP 문서 소스 현황
|
||||
|
||||
| 항목 | 상태 | 액션 |
|
||||
|:-----|:----|:------|
|
||||
| C-6111 startup 절차서 | ❓ | 보유 여부 확인 필요 |
|
||||
| C-6111 shutdown 절차서 | ❓ | 보유 여부 확인 필요 |
|
||||
| 운전 체크리스트 양식 | ❓ | 기존 양식이 있다면 JSON 구조 맞출 기준 |
|
||||
| P&ID (PFD) | ✅ | 이미 추출 완료 (node_map_master) |
|
||||
| 물질수지/에너지수지 | ✅ | 오늘 작성한 공식 문서 |
|
||||
|
||||
### 10.6 사전 작업 목록
|
||||
|
||||
| 우선순위 | 작업 | 설명 | 담당 |
|
||||
|:--------:|:-----|:------|:-----|
|
||||
| **P0** | SOP 문서 확보 | C-6111 startup/shutdown 절차서 원본 수집 | 운전원/공정 |
|
||||
| **P0** | 태그 매핑 | SOP의 모든 valve/기기명을 실제 DCS 태그명에 매핑 | 공정+개발 |
|
||||
| **P0** | C-6111 startup 시나리오 시뮬 | JSON SOP 1건을 직접 작성해보며 필드 적절성 검증 | 개발 |
|
||||
| **P1** | DCS 태그 형식 확인 | digital 태그의 .pv 값 형식 (R-RUN/L-STOP 등) | 개발 |
|
||||
| **P1** | 예외 처리 정의 | 각 step별 실패 시나리오 및 fallback 정의 | 공정 |
|
||||
| **P1** | UI 프로토타입 | panes/ops.html mockup 제작 (하드코딩 step) | 개발 |
|
||||
| **P2** | MCP tool 구현 | `execute_operation_guide` server.py에 추가 | 개발 |
|
||||
| **P2** | 피드백 타입 시험 | 운전원 확인 → 다음 step 전환까지 end-to-end | 통합 |
|
||||
|
||||
### 10.7 구현 원칙
|
||||
|
||||
1. **SOP 구조는 실제 절차서를 JSON으로 변환해보기 전에 확정 금지** — 필드 부족/과잉이 발생할 수 있음
|
||||
2. **하드코딩 mock으로 UI 먼저 검증** — MCP tool 구현 전에 pane HTML+JS를 mock data로 동작시켜 UX 확인
|
||||
3. **P0 검증 완료 후 P1, P1 완료 후 P2 진행** — 병렬 가능한 항목은 병렬
|
||||
4. **처음부터 모든 mode를 만들지 않음** — startup만 먼저 구현, 다른 mode는 startup 구조 검증 후 확장
|
||||
|
||||
---
|
||||
|
||||
## 11. 참고 문서
|
||||
|
||||
- `docs/측류추출식-통합유량설정공식.md` — C-6111 물질수지/환류/온도보정/V_loss 공식
|
||||
- `docs/보조운전-브레인스토밍.md` (본 문서)
|
||||
239
docs/운전원교육-FF-스트림파라미터-해설.md
Normal file
239
docs/운전원교육-FF-스트림파라미터-해설.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# Feedforward Advisory — 스트림 파라미터 해설
|
||||
## 운전원 교육 자료
|
||||
|
||||
> 대상: C-6111 PGMEA 측류추출 증류탑 — 각 스트림(P/R/D/B)별 파라미터 의미와 튜닝 기준
|
||||
|
||||
---
|
||||
|
||||
## 1. 스트림 역할 (Role)
|
||||
|
||||
| Role | 의미 | 적용 스트림 | 동작 방식 |
|
||||
|:-----|:------|:-----------|:---------|
|
||||
| **Commanded** | FF가 SP를 직접 계산 | **P**(주생성물), **R**(환류) | Deadtime → Lag → RateLimit → 권장SP |
|
||||
| **LevelDriven** | 레벨 제어기가 SP 결정, FF는 기대치만 제시 | **D**(유출액), **B**(탑저) | K × Feed, deadtime/lag 없음, 참고용 |
|
||||
| **Monitor** | 모니터링만, 권장 SP 없음 | (향후 확장) | SP 계산 안 함, PV만 표시 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 공통 파라미터
|
||||
|
||||
### 2.1 K (TargetCoeff) — 목표 계수
|
||||
|
||||
**정의**: Feed 대비 해당 스트림의 목표流量 비율.
|
||||
|
||||
```
|
||||
P_stream 권장SP = K × FeedFilter출력 (Commanded, lag/rate 적용 후)
|
||||
D/B_stream 기대치 = K × FeedFilter출력 (LevelDriven, 즉시 반영)
|
||||
R_stream 권장SP = K × P_stream 권장SP (환류, P 경유)
|
||||
```
|
||||
|
||||
**C-6111 기본값**:
|
||||
|
||||
| 스트림 | K | 근거 |
|
||||
|:------|:---|:------|
|
||||
| P=0.95 | 측류 95% 추출 | 주생성물이 Feed의 95% |
|
||||
| R=0.80 | 환류비 R/P=0.8 | P 대비 80% 환류 |
|
||||
| D=0.02 | 유출액 2% | 드럼 퍼지량 |
|
||||
| B=0.03 | 탑저 3% | 바텀 드로우량 |
|
||||
|
||||
**튜닝**: K의 합이 1.0을 넘으면 물질수지 불일치. K_P + K_D + K_B = 1.0이 이상적 (R은 환류라 수지에 포함 안 됨).
|
||||
|
||||
### 2.2 Grade (Confidence) — 신뢰도 등급
|
||||
|
||||
| Grade | 의미 | Auto-write | 운전원 대응 |
|
||||
|:------|:-----|:-----------|:-----------|
|
||||
| **A** | K/θ/τ가 잘 튜닝됨, 권장값 신뢰 가능 | ✅ 허용 | 권장값을 SP로 채택 가능 |
|
||||
| **B** | 튜닝 불확실성 있음, 참고용 | ❌ 금지 | 운전원 판단 필요, SP 채택 시 주의 |
|
||||
| **C** | 모델 신뢰 불가, 진단 우선 | ❌ 금지 | 사용하지 말 것, 엔지니어 진단 요청 |
|
||||
|
||||
---
|
||||
|
||||
## 3. Commanded 스트림 전용 파라미터 (P, R)
|
||||
|
||||
Commanded 스트림은 Feed 변화가 권장SP에 반영될 때까지 **3단계 지연**을 거칩니다:
|
||||
|
||||
```
|
||||
FeedFilter출력 → [Deadtime θ] → [Lag τ] → [RateLimit] → 권장SP
|
||||
```
|
||||
|
||||
### 3.1 θ_up / θ_dn (ThetaUpSec / ThetaDnSec) — Deadtime (불감시간)
|
||||
|
||||
**정의**: Feed 변화가 해당 스트림에 실제로 영향을 미치기까지의 **순수 지연 시간(초)**.
|
||||
|
||||
| 방향 | 파라미터 | 의미 |
|
||||
|:-----|:---------|:------|
|
||||
| Feed 증가 | θ_up | 상승 응답까지 걸리는 시간 |
|
||||
| Feed 감소 | θ_dn | 하강 응답까지 걸리는 시간 |
|
||||
|
||||
**비대칭 deadtime**: θ_up과 θ_dn을 다르게 설정 가능 (상승/하강 응답 특성이 다른 공정용).
|
||||
|
||||
**C-6111 기본값**: P=60초 (상승/하강 동일). Feed 변화가 C-6111 탑을 거쳐 측류 추출점에 도달하는 시간.
|
||||
|
||||
**물리적 의미**: 칼럼 내 액체 홀드업, 트레이/패킹을 통과하는 시간, 응답 지연.
|
||||
|
||||
### 3.2 τ (TauSec) — Lag 시정수 (1차 지연)
|
||||
|
||||
**정의**: Deadtime 이후 Feed 변화가 지수적으로 정착하는 **시정수(초)**. 1차 저역통과필터의 τ.
|
||||
|
||||
```
|
||||
Lag출력 = Lag출력 + (입력 - Lag출력) × ts / τ
|
||||
|
||||
ts=Scan, τ=900초:
|
||||
1τ(900초) 후 63% 도달
|
||||
2τ(1800초) 후 86% 도달
|
||||
3τ(2700초) 후 95% 도달 (≈ 정상상태)
|
||||
```
|
||||
|
||||
**C-6111 기본값**: P=900초(15분). 측류 추출 응답이 느린 이유:
|
||||
- 칼럼 내 액체 홀드업 완료 시간
|
||||
- 물질 이동(mass transfer) 속도
|
||||
- 온도/조성 평형 도달 시간
|
||||
|
||||
### 3.3 Rate_up / Rate_dn (RateUpPerMin / RateDnPerMin) — 변화율 제한
|
||||
|
||||
**정의**: 권장SP가 **분당 최대 몇 단위까지 변할 수 있는지** 제한.
|
||||
|
||||
```
|
||||
|권장SP(t) - 권장SP(t-1)| ≤ Rate × ts / 60
|
||||
```
|
||||
|
||||
**C-6111 기본값**:
|
||||
- P: up=30/min, dn=60/min (상승보다 하강 2배 빠름)
|
||||
- R: up=30/min, dn=30/min (대칭)
|
||||
|
||||
**용도**: SP가 급격히 변하는 것을 방지 — DCS의 PID 제어기가 추종 가능한 범위로 제한.
|
||||
|
||||
### 3.4 θ vs τ vs Rate — 차이 이해
|
||||
|
||||
| 구분 | 성격 | 영향 | 단위 |
|
||||
|:-----|:-----|:-----|:------|
|
||||
| **θ (Deadtime)** | 순수 지연 | Feed 변화 후 아무 반응 없음 | 초 |
|
||||
| **τ (Lag)** | 지수적 정착 | 변화가 서서히 나타남 | 초 |
|
||||
| **Rate** | 변화율 제한 | SP의 기울기 제한 | /분 |
|
||||
|
||||
**직관적 비유 (온수 샤워)**:
|
||||
- **θ**: 핸들 돌린 후 물이 뜨거워질 때까지 걸리는 시간
|
||||
- **τ**: 뜨거워지는 속도 (급격/완만)
|
||||
- **Rate**: 핸들을 1초에 얼마나 돌릴 수 있는지 제한
|
||||
|
||||
---
|
||||
|
||||
## 4. LevelDriven 스트림 전용 파라미터 (D, B)
|
||||
|
||||
LevelDriven은 단순 비례이므로 deadtime/lag/rate 파라미터가 **의미 없음** (0으로 설정):
|
||||
|
||||
```
|
||||
기대치 = K × FeedFilter출력
|
||||
→ deadtime 없음, lag 없음, rate 제한 없음
|
||||
```
|
||||
|
||||
### 4.1 LevelTag — 레벨 태그
|
||||
|
||||
**정의**: 해당 LevelDriven 스트림을 구동하는 **레벨 제어기의 태그명**.
|
||||
|
||||
| 스트림 | LevelTag | 레벨 제어기 | 의미 |
|
||||
|:------|:---------|:-----------|:------|
|
||||
| D | lica-6113 | D-6113 환류버퍼드럼 레벨 | 유출액 = 드럼 레벨로 제어 |
|
||||
| B | li-6111 | C-6111 탑저 레벨 | 탑저 = 칼럼 바텀 레벨로 제어 |
|
||||
|
||||
**LevelDriven 기대치와 실제 SP의 관계**:
|
||||
```
|
||||
FF 기대치 = K × Feed (참고용)
|
||||
실제 DCS SP = LIC 출력 (레벨 제어기가 결정)
|
||||
```
|
||||
|
||||
FF는 "Feed가 이만큼이면 D/B에 이 정도 flow가 필요할 것이다"는 **기대치**만 제시. 실제 SP는 레벨 제어기가 결정하므로, FF 기대치는 **운전원이 레벨 제어가 정상인지 확인하는 보조지표**.
|
||||
|
||||
---
|
||||
|
||||
## 5. RefluxFromProduct — P 경유 환류 (R 스트림)
|
||||
|
||||
**정의**: R(환류)이 P(주생성물) 권장SP를 기준으로 비례 계산되는지 여부.
|
||||
|
||||
```
|
||||
RefluxFromProduct = true:
|
||||
R_rec = K_R × P_rec (P의 deadtime/lag/rate을 상속받음)
|
||||
|
||||
RefluxFromProduct = false:
|
||||
R_rec = (K_R × FeedFilter출력 → 독자적인 deadtime/lag/rate)
|
||||
```
|
||||
|
||||
**C-6111 기본값**: R=true (P 경유). 이유:
|
||||
- 환류량은 주생성물량에 비례
|
||||
- P의 지연 특성(θ=60, τ=900)을 자연스럽게 상속
|
||||
- 독립 계산 시 P와 R이 서로 다른 시점에 변동 → 불안정
|
||||
|
||||
---
|
||||
|
||||
## 6. SP 제한 (SpMin / SpMax)
|
||||
|
||||
**정의**: 권장SP가 가질 수 있는 **최소값/최대값**.
|
||||
|
||||
```
|
||||
권장SP = Clamp(계산값, SpMin, SpMax)
|
||||
```
|
||||
|
||||
**C-6111 기본값**:
|
||||
- P: 0 ~ 9999 (사실상 무제한, Range만 0~9999)
|
||||
- R: 0 ~ 9999
|
||||
- D: 0 ~ 9999
|
||||
- B: 0 ~ 9999
|
||||
|
||||
**용도**:
|
||||
- 펌프 최소 유량 보호 (SpMin > 0)
|
||||
- 배관/계기 최대 유량 초과 방지 (SpMax)
|
||||
- 운전 범위를 물리적 한계 내로 제한
|
||||
|
||||
---
|
||||
|
||||
## 7. C-6111 기본값 종합표
|
||||
|
||||
| 파라미터 | P | R | D | B | 단위 | 비고 |
|
||||
|:---------|:--|:--|:--|:--|:----|:-----|
|
||||
| **Role** | Commanded | Commanded | LevelDriven | LevelDriven | — | |
|
||||
| **K** | 0.95 | 0.80 | 0.02 | 0.03 | — | D+B = Feed의 5%, P = 95% |
|
||||
| **θ_up** | 60 | — | — | — | 초 | P만 deadtime 적용 |
|
||||
| **θ_dn** | 60 | — | — | — | 초 | |
|
||||
| **τ** | 900 | — | — | — | 초 | P만 lag 적용 |
|
||||
| **Rate_up** | 30 | — | — | — | /분 | 상승 변화율 제한 |
|
||||
| **Rate_dn** | 60 | — | — | — | /분 | 하강 (2배 빠름) |
|
||||
| **SpMin** | 0 | 0 | 0 | 0 | — | |
|
||||
| **SpMax** | 9999 | 9999 | 9999 | 9999 | — | |
|
||||
| **Grade** | A | A | B | B | — | D/B는 레벨 제어 의존, B 등급 |
|
||||
| **RefluxFromProduct** | — | true | — | — | — | R은 P 경유 |
|
||||
| **LevelTag** | — | — | lica-6113 | li-6111 | — | 구동 레벨 태그 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 튜닝 가이드
|
||||
|
||||
### K 조정
|
||||
|
||||
```
|
||||
K 합 체크: K_P + K_D + K_B = 1.0 (이상적)
|
||||
현재: 0.95 + 0.02 + 0.03 = 1.00 ✅
|
||||
|
||||
K_P 증가 → P 권장 SP 상승 → R(환류)도 상승 (RefluxFromProduct=true)
|
||||
K_B 증가 → B 기대치 상승 → 탑저 드로우 증가
|
||||
```
|
||||
|
||||
### θ/τ 조정
|
||||
|
||||
```
|
||||
θ가 너무 작음 → Feed 변화에 SP가 너무 빨리 반응 (불안정)
|
||||
θ가 너무 큼 → 응답 지연, Feed 변화 후 오래 기다려야 함
|
||||
|
||||
τ가 너무 작음 → SP가 급변, DCS PID가 추종 못 할 수 있음
|
||||
τ가 너무 큼 → SP 정착까지 너무 오래 걸림 (30분+)
|
||||
|
||||
튜닝 방법: Feed step 테스트 → 실제 PV 응답 곡선에서 θ와 τ 측정
|
||||
```
|
||||
|
||||
### Rate 조정
|
||||
|
||||
```
|
||||
Rate_up/dn이 너무 작음 → SP 변화가 너무 느려서 Feed 변화를 따라가지 못함
|
||||
Rate_up/dn이 너무 큼 → SP가 급변, DCS 밸브가 추종 못 할 수 있음
|
||||
|
||||
적정값: DCS PID의 최대 추종 변화율 × 0.8 (여유)
|
||||
```
|
||||
261
docs/운전원교육-FF과도상태-메커니즘.md
Normal file
261
docs/운전원교육-FF과도상태-메커니즘.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# Feedforward Advisory — 과도상태(Transient) 메커니즘
|
||||
## 운전원 교육 자료
|
||||
|
||||
> 대상: C-6111 PGMEA 측류추출 증류탑 FF(Feedforward Advisory) 시스템
|
||||
> 버전: 2026-05 Phase I
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요
|
||||
|
||||
FF 시스템은 Feed(공급유량) 변동 시 각 스트림(P/R/D/B)의 **권장 SP(Setpoint)를 실시간 계산**하여 운전원에게 제시합니다. 그러나 Feed나 압력이 불안정할 때는 권장값을 곧바로 신뢰할 수 없습니다. 이때 **과도상태(Transient)** 메커니즘이 작동하여 권장값을 "참고용"으로 전환하고, 안정화될 때까지 기다립니다.
|
||||
|
||||
### ⚠️ 중요: FF는 Advisory(보조지표)입니다
|
||||
|
||||
**FF 시스템은 DCS의 실제 Setpoint(SP)를 절대 변경하지 않습니다.** Transient 여부와 관계없이:
|
||||
- DCS SP는 운전원이 설정한 값 그대로 유지됨
|
||||
- FF는 권장값(Recommended SP)을 화면에 표시만 함
|
||||
- 운전원이 권장값을 검토한 후 **직접 DCS에 입력**해야 적용됨
|
||||
- Transient 중에는 권장값에 `valid=false`(신뢰도 낮음) 플래그만 추가됨
|
||||
|
||||
즉, FF가 transient 상태라고 해서 **공정에 실제 변화가 일어나지는 않습니다.** 단지 운전원이 참고할 권장 숫자가 "아직 믿을 만한 상태가 아니다"라는 신호일 뿐입니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. Transient 진입 조건
|
||||
|
||||
Transient는 다음 **3가지 조건** 중 하나라도 만족하면 발동합니다.
|
||||
|
||||
| 조건 | 감지 방식 | 파라미터 | 기본값 |
|
||||
|:----|:---------|:---------|:------|
|
||||
| **① FEED 이동** | Feed 유량의 순간변화율(미분)이 임계 초과 | `FeedMoveThreshold` | **5%/분** |
|
||||
| **② 압력 불안정** | 압력PV가 필터 추종 범위 이탈 | `PressureBand` | **3.0** |
|
||||
| **③ 정착 대기 중** | 위 조건이 해제된 후 SettleTimer가 0이 아님 | `SettleSec` | **1800초(30분)** |
|
||||
|
||||
### 2.1 FEED 이동 감지 — 상세
|
||||
|
||||
```
|
||||
FeedFilter: 1차 저역통과필터 (τ=300초, 시정수 5분)
|
||||
목적: 공급유량의 순간 노이즈 제거, 실질적인 변화만 포착
|
||||
|
||||
FeedDeriv: 필터 출력의 미분값 (틱당 변화량 → 분당 변화율)
|
||||
moving = |dF| × 60 > FeedMoveThreshold (기본 5%/분)
|
||||
```
|
||||
|
||||
예:
|
||||
- Feed가 820→1000으로 180만큼 변화
|
||||
- FeedFilter가 300초 시정수로 부드럽게 추종
|
||||
- 1차 틱(2초)에서 dF ≈ (821.2-820)/2 = 0.6/sec = 36/min
|
||||
- **36 > 5 → moving = true**
|
||||
|
||||
### 2.2 압력 불안정 감지 — 상세
|
||||
|
||||
```
|
||||
PressFilter: 1차 저역통과필터 (τ=60초)
|
||||
목적: 압력 신호의 고주파 진동 제거
|
||||
|
||||
pUnstable = |압력PV - PressFilter출력| > PressureBand (기본 3.0)
|
||||
```
|
||||
|
||||
의미: 필터가 추종 가능한 범위를 실제 압력이 벗어나면 "불안정"으로 판단.
|
||||
|
||||
### 2.3 정착 대기(SettleTimer) — 상세
|
||||
|
||||
```
|
||||
if (moving || pUnstable)
|
||||
SettleTimer = SettleSec (1800초) // ← 리셋
|
||||
else
|
||||
SettleTimer = max(0, SettleTimer - ScanSec)
|
||||
```
|
||||
|
||||
Transient 조건이 해제되어도 **SettleTimer가 0이 될 때까지(최대 30분) transient 유지.**
|
||||
|
||||
---
|
||||
|
||||
## 3. Transient 상태에서의 동작
|
||||
|
||||
### 3.1 UI 표시
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 과도상태: 압력 불안정 — 권장값 정착 대기 │
|
||||
│ │
|
||||
│ 스트림 │ PV │ 권장 SP │ Δ │ 신뢰 │
|
||||
│ P │ 780.2 │ 779.0 │ -1.2 │ A (흐림) │
|
||||
│ R │ 620.0 │ 623.2 │ +3.2 │ A (흐림) │
|
||||
│ D │ 16.5 │ 16.4 │ -0.1 │ B (흐림) │
|
||||
│ B │ 24.8 │ 24.6 │ -0.2 │ B (흐림) │
|
||||
│ │
|
||||
│ 물질수지: 정착 대기 (1795s) │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- 모든 스트림의 권장값이 **valid = false** (회색/흐리게 표시, `.ff-stale`)
|
||||
- 권장값 계산은 **내부적으로 계속 진행**되지만, 운전원 화면에서는 "참고용" 표시
|
||||
- 물질수지(V_loss, 수율) 계산 **중단**
|
||||
- 우측 하단에 정착 대기 시간 카운트다운
|
||||
|
||||
### 3.2 Transient 중 Recommended SP는 어떻게 되나?
|
||||
|
||||
**Recommended SP는 계속 업데이트됩니다. 이전값에 고정되지 않습니다.**
|
||||
|
||||
```
|
||||
예: Feed 820→1000 step, P stream 기준
|
||||
|
||||
t=0min Feed step, transient 시작
|
||||
P_rec = 779 (deadtime 중, 이전 Feed≈820 기준)
|
||||
t=1min P_rec = 781 (deadtime 끝나며 새 Feed 반영 시작)
|
||||
t=5min P_rec = 799 (Lag τ=900s로 서서히 상승)
|
||||
t=15min P_rec = 861
|
||||
t=30min P_rec = 916 (transient 해제, valid=true 전환)
|
||||
t=45min P_rec = 950 (정상상태 도달)
|
||||
```
|
||||
|
||||
계산 자체는 멈추지 않습니다:
|
||||
- FeedFilter, PressFilter, DeadTimeBuffer, Lag, RateLimiter 모두 **정상 작동**
|
||||
- Recommended SP도 매 틱(2초)마다 **계속 재계산되어 갱신됨**
|
||||
- **단, `valid=false` 플래그만 붙어서 화면에 흐리게 출력**
|
||||
|
||||
즉, transient가 풀리는 순간 **이미 최신 Feed를 반영한 권장값이 valid=true로 전환**됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. Transient 종료 후 — 정상 상태 전환
|
||||
|
||||
### 조건
|
||||
```
|
||||
moving = false AND pUnstable = false AND SettleTimer = 0
|
||||
```
|
||||
|
||||
### 전환 효과
|
||||
1. 모든 스트림 `valid = true` — 권장값 본격 표시
|
||||
2. 물질수지 계산 재개:
|
||||
```
|
||||
V_loss = FeedFilter출력 - (D_PV + P_PV + B_PV)
|
||||
수율 = 100 × P_PV / FeedFilter출력
|
||||
```
|
||||
3. MassBalanceState 평가:
|
||||
- `|V_loss| > 0.03 × Feed` → **"물질수지 불일치(계측 점검)"**
|
||||
- `V_loss < 0` → **"음의 손실(스팬 오류 의심)"**
|
||||
- else → **"정상"**
|
||||
|
||||
### 주의: Transient 해제 ≠ 정상상태 도달
|
||||
|
||||
Transient가 풀려도 P stream의 Lag(τ=900초=15분)가 아직 정착 중일 수 있음:
|
||||
|
||||
```
|
||||
예) Feed 820→1000 변경 시:
|
||||
t=0분 Feed step, transient 시작
|
||||
t=30분 SettleTimer=0, transient 해제
|
||||
→ P 권장은 아직 950(최종값)에 도달 전 (약 910~920)
|
||||
t=45분 P 권장 95% 도달 (3τ)
|
||||
```
|
||||
|
||||
운전원은 transient 해제 후에도 **권장값이 서서히 움직이는 것**을 정상으로 이해해야 함.
|
||||
|
||||
---
|
||||
|
||||
## 5. 스트림별 동작 차이
|
||||
|
||||
| 역할 | Deadtime | Lag | Rate Limit | Transient 영향 |
|
||||
|:----|:---------|:----|:-----------|:---------------|
|
||||
| **P** (Commanded) | θ=60초 | τ=900초 | 30/min | valid=false만 적용, 계산은 계속 |
|
||||
| **R** (Reflux) | P 경유(상속) | P 경유 | 30/min | P와 동일 |
|
||||
| **D** (LevelDriven) | 없음 | 없음 | 없음 | `K×Feed` 즉시 반영, valid=false |
|
||||
| **B** (LevelDriven) | 없음 | 없음 | 없음 | `K×Feed` 즉시 반영, valid=false |
|
||||
|
||||
**LevelDriven**은 단순 비례식:
|
||||
```
|
||||
D_rec = 0.02 × FeedFilter출력
|
||||
B_rec = 0.03 × FeedFilter출력
|
||||
```
|
||||
deadtime/lag이 없으므로 Feed 변동에 즉시 반응하지만, 어디까지나 **기대치(Feedforward bias)**입니다. 실제 SP는 레벨 제어기(LIC)가 결정합니다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 설정 파라미터 튜닝 가이드
|
||||
|
||||
| 파라미터 | 현재값 | 느슨하게 | 엄격하게 | 영향 |
|
||||
|:---------|:-------|:---------|:---------|:-----|
|
||||
| `FeedMoveThreshold` | 5/분 | ↑ 증가 | ↓ 감소 | 크면 Feed 변동 둔감, 작으면 민감 |
|
||||
| `PressureBand` | 3.0 | ↑ 증가 | ↓ 감소 | 크면 압력 둔감, 작으면 민감 |
|
||||
| `SettleSec` | 1800초 | ↓ 감소 | ↑ 증가 | 작으면 빨리 해제, 크면 오래 대기 |
|
||||
| `FeedFilterTauSec` | 300초 | ↓ 감소 | ↑ 증가 | 작으면 빠른 반응, 크면 늦은 반응 |
|
||||
| `PressFilterTauSec` | 60초 | ↓ 감소 | ↑ 증가 | 작으면 압력 빠른 추종, 크면 늦음 |
|
||||
|
||||
### 튜닝 예시
|
||||
|
||||
**"transient가 너무 자주 걸려요" →**
|
||||
- `FeedMoveThreshold` 증가 (예: 5→8) — 미세 Feed 변동 무시
|
||||
- `PressureBand` 증가 (예: 3→5) — 압력 진동 허용폭 확대
|
||||
- 단, 너무 느슨하면 실제 불안정 상황을 놓칠 수 있음
|
||||
|
||||
**"transient가 너무 오래 가요" →**
|
||||
- `SettleSec` 감소 (예: 1800→900) — 정착 대기시간 단축
|
||||
- 단, 30분은 의도된 값 — 압력/Feed가 안정된 후에도 일정 시간 관찰하도록 설계
|
||||
|
||||
---
|
||||
|
||||
## 7. 실제 운전 시나리오 예시
|
||||
|
||||
### 시나리오 1: Feed 정상 증가 (의도적)
|
||||
|
||||
```
|
||||
운전 조작: FICQ-6101 SP를 820→1000으로 변경
|
||||
FF 반응:
|
||||
1. FeedDeriv 36/min → transient 시작
|
||||
2. 30분간 valid=false (흐림 표시)
|
||||
3. 30분 후 valid=true 전환
|
||||
- P 권장 ≈ 910 (아직 950 도달 전, Lag 진행 중)
|
||||
- D 권장 ≈ 20 (0.02 × 1000)
|
||||
- B 권장 ≈ 30 (0.03 × 1000)
|
||||
4. 약 45분 후 P 권장 950에 정착
|
||||
```
|
||||
|
||||
운전원 행동:
|
||||
- transient 중에는 권장값을 **참고**만 하고, 자신의 판단으로 SP 조정
|
||||
- transient 해제 후 권장값이 합리적이면 추종
|
||||
- P 권장이 최종값(950)에 도달하는 건 **15분 더 걸림** 인지
|
||||
|
||||
### 시나리오 2: 압력 진동 (진공펌프 기동)
|
||||
|
||||
```
|
||||
현상: PICA-6111 압력이 3torr 이상 진동
|
||||
FF 반응:
|
||||
1. pUnstable = true → transient 시작
|
||||
2. SettleTimer 1800초 리셋
|
||||
3. 압력 안정 후에도 30분간 transient 유지
|
||||
```
|
||||
|
||||
운전원 행동:
|
||||
- 진공계 안정화 확인
|
||||
- transient가 불편하면 `SettleSec` 또는 `PressureBand` 조정 검토
|
||||
|
||||
### 시나리오 3: Feed 미세 변동 (transient 미진입)
|
||||
|
||||
```
|
||||
Feed 820→830 (+10, 약 1.2% 변화)
|
||||
FF 반응:
|
||||
1. FeedDeriv ≈ 2/min → 5 이하 → moving=false
|
||||
2. Transient 미진입 → 권장값 계속 유효
|
||||
3. P 권장: 0.95×830 ≈ 788.5 (lag/rate 적용)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. FAQ
|
||||
|
||||
**Q: transient 중인데 권장값이 보이긴 하는데 흐려요. 왜죠?**
|
||||
A: 계산은 계속되지만 운전원이 **아직 신뢰할 단계가 아니다**는 의미입니다. 압력이나 Feed가 안정된 후 30분이 지나면 선명하게 전환됩니다.
|
||||
|
||||
**Q: transient를 강제로 해제할 수 있나요?**
|
||||
A: 현재 UI에는 강제 해제 기능이 없습니다. 설정에서 `SettleSec`을 0으로 줄이면 transient 시간을 없앨 수 있습니다. 단, 권장값의 신뢰성이 떨어질 수 있습니다.
|
||||
|
||||
**Q: transient 중에도 권장값을 SP에 반영해야 하나요?**
|
||||
A: **아니요.** transient 중 권장값은 "참고용"입니다. 운전원의 경험과 판단으로 SP를 결정하세요. transient 해제 후 권장값을 검토하고 반영하세요.
|
||||
|
||||
**Q: P stream 권장값이 왜 이렇게 느리게 변하나요?**
|
||||
A: Lag 시정수 τ=900초(15분) 때문입니다. Feed 변화가 Deadtime(60초)을 거쳐 Lag에 들어가면 15분에 걸쳐 서서히 정착합니다. 이는 공정의 실제 응답 특성을 모델링한 것입니다.
|
||||
|
||||
**Q: LevelDriven(D/B)은 왜 deadtime/lag이 없나요?**
|
||||
A: D/B는 레벨 제어기(LIC)가 실제 SP를 결정합니다. FF가 계산하는 값은 **기대치(bias)**일 뿐입니다. 실제 레벨 제어 루프가 deadtime/lag을 처리하므로, FF 단에서는 즉시 반영해도 무방합니다.
|
||||
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>
|
||||
1067
docs/측류추출식-통합유량설정공식-구현코딩-PhaseI.md
Normal file
1067
docs/측류추출식-통합유량설정공식-구현코딩-PhaseI.md
Normal file
File diff suppressed because it is too large
Load Diff
439
docs/측류추출식-통합유량설정공식-구현코딩-PhaseII-분석엔진+전환류복귀.md
Normal file
439
docs/측류추출식-통합유량설정공식-구현코딩-PhaseII-분석엔진+전환류복귀.md
Normal file
@@ -0,0 +1,439 @@
|
||||
# 측류추출 통합유량 — Phase II 분석엔진 + 전환류 복귀 모드 구현 작업지시서
|
||||
|
||||
> **성격**: PhaseI(advisory 엔진)·PhaseII-UI(Tab 18)·PhaseIII(auto-write) 문서의 **§6 잔여 보정항목(P-1~P-5)** 과
|
||||
> **신규 안전기능(전환류 평형복귀 모드)** 의 턴키 구현 작업지시서. 다른 LLM이 코드베이스 추가 탐색 없이 구현하도록
|
||||
> **검증된 현재 코드 기준선** 위에서 작성한다.
|
||||
>
|
||||
> **불변식(PhaseI 계승)**: P-1~P-5 및 전환류 모드의 **권장(advisory) 계산까지는 제어 레지스터 쓰기 0건**.
|
||||
> 실제 SP 쓰기(전환류 자동 실행 포함)는 **전부 PhaseIII(WriteGuard)** 경유. 본 문서는 "권장값/모드 산출 + 표시"까지가 범위.
|
||||
>
|
||||
> **작업 순서(영향도·의존성 반영)**: §A 문서감리 선반영 → **WO-1(P-5)** → **WO-2(P-2)** → **WO-3(P-1)** →
|
||||
> **WO-4(P-4)** → **WO-5(P-3)** → **WO-6(전환류 복귀)** → §C 통합검증. P-7은 기존 PhaseIII 문서(§D 정정 메모 참조).
|
||||
|
||||
---
|
||||
|
||||
## §A. 선행 — 기존 문서·코드 감리 결과 (먼저 반영할 것) ★
|
||||
|
||||
본 작업 착수 전 아래 문서 드리프트를 인지하고 **§B 현재 코드 기준선을 정본**으로 삼는다. (기존 PhaseI/II/III 문서를 글자대로 따르면 깨지는 지점들.)
|
||||
|
||||
| # | 등급 | 위치 | 문제 | 조치 |
|
||||
|:-:|:----:|:-----|:-----|:-----|
|
||||
| A1 | **HIGH** | PhaseI §5.4 DDL·§5.8 seed·§2 모델, PhaseII §2.2 | `ff_stream_config.level_tag`(TEXT) 컬럼이 **실제 스키마·`StreamConfig.LevelTag`·`StreamAdvisory.LevelTag`·ConfigStore SELECT(인덱스 14)·SaveColumn INSERT·Controller `levelTag`** 에 전부 존재하나 **그 문서들엔 누락** | 본 문서는 **현재 코드 기준선(§B)** 을 정본으로 삼는다. 기존 DDL 문서를 그대로 따르지 말 것 |
|
||||
| A2 | **WO-6 선결** | `src/Web/Program.cs:124~128` | (중복 버그 아님 — 현재 `AddHostedService<FeedforwardSupervisor>()` **단일 등록**으로 정상.) 단, WO-6 복구 컨트롤러가 `FeedforwardSupervisor`에 주입 접근(ColumnState ARM)하려면 **singleton 노출 필요** | `AddHostedService<FeedforwardSupervisor>()`(128) → **`AddSingleton<FeedforwardSupervisor>()` + `AddHostedService(sp=>sp.GetRequiredService<FeedforwardSupervisor>())`** 2줄로 교체(단일 인스턴스를 hosted+injectable로). **WO-6 착수 시에만** 변경, 그전엔 현 상태 유지 |
|
||||
| A3 | **MED** | PhaseII §2.3 본문 vs §8 | §2.3 본문은 `IKbAuthService`/`IsAdminAsync`로 config CRUD를 막는 코드를 보여주나, §8과 **실제 컨트롤러는 인증 제거**(config CRUD 공개) | 본 문서는 **인증 없는 현재 컨트롤러(§B)** 기준. auth 재도입은 PhaseIII(쓰기 시)에서만 |
|
||||
| A4 | LOW | PhaseI §2 | `StreamAdvisory` 레코드에 `LevelTag` 없음, enum에 `[JsonStringEnumConverter]` 표기 없음 — 실제는 둘 다 있음 | §B 기준선 사용 |
|
||||
| A5 | LOW | PhaseI §6 P-2 | "TempCorrection(已구현)" — `TempCorrection.PressureCompensated`는 존재하나 **엔진에서 호출되지 않는 죽은 코드** | WO-2에서 배선 |
|
||||
| A6 | LOW | PhaseIII §6 | 변경대상 `src/Infrastructure/OpcUa/OpcUaClientService.cs`는 **그 이름의 파일이 없음**. 단, **`src/Infrastructure/OpcUa/ExperionOpcWriteClient.cs`(namespace `...Infrastructure.Control`)가 이미 존재**(쓰기 클라이언트 — 가드 없음). NodeId `ns=3;s="{tag}.sp"` 규칙 미검증 | §D 참조 — PhaseIII는 **기존 `ExperionOpcWriteClient` 재사용/확장**(신규 파일 X). NodeId는 서버 브라우즈로 확정 |
|
||||
|
||||
> **advisory 쓰기 불변식의 정확한 범위**: 코드베이스 전체엔 범용 `ExperionOpcWriteClient`(다른 기능용)가 **존재**한다.
|
||||
> 불변식은 **"FF advisory 경로가 그 쓰기 클라이언트를 호출하지 않는다"** 는 의미.
|
||||
> 검증 grep은 **FF 파일에 한정**: `grep -rE "ExperionOpcWriteClient|Write.*Async" src/Infrastructure/Control src/Web/Controllers/FeedforwardController.cs` → 0건(현재 유지됨).
|
||||
|
||||
---
|
||||
|
||||
## §B. 검증된 현재 코드 기준선 (정본 — 2026-05-31 실독)
|
||||
|
||||
다른 LLM은 아래를 **사실로 간주**하고, 기존 PhaseI/II/III 문서와 충돌 시 **본 §B를 우선**한다.
|
||||
|
||||
### B.1 모델 (`src/Core/Application/Feedforward/FeedforwardModels.cs`)
|
||||
- `enum StreamRole { Commanded, LevelDriven, Monitor }` · `enum Confidence { A, B, C }` — 둘 다 `[JsonConverter(typeof(JsonStringEnumConverter))]`.
|
||||
- `StreamConfig`: Key, FlowTag, Role, **LevelTag(string?)**, TargetCoeff, ThetaUpSec, ThetaDnSec, TauSec, SpMin, SpMax, RateUpPerMin, RateDnPerMin, RefluxFromProduct, Grade.
|
||||
- `ColumnConfig`: Id, Name, Enabled, AdvisoryOnly(=true 강제), FeedTag, PressureTag, LevelTags, ScanSec, FeedFilterTauSec, FeedMoveThresholdPerMin, PressFilterTauSec, PressureBand, SettleSec, StaleSec, ProductKey, Streams.
|
||||
- `TagSample(Tag, Value, Good, Timestamp)`.
|
||||
- `PvSnapshot(Feed, Pressure?, Levels[], Streams{key→TagSample})`.
|
||||
- `StreamAdvisory(Key, FlowTag, Role, Pv, RecommendedSp?, Gap?, Trend, Valid, Grade, **LevelTag?**, Note)`.
|
||||
- `AdvisoryResult(ColumnId, ColumnName, ComputedAt, Enabled, Transient, TransientReason, FeedFiltered, Streams[], VLoss?, Yield?, MassBalanceState)`.
|
||||
|
||||
### B.2 연산블록 (`src/Infrastructure/Control/ComputationBlocks.cs`)
|
||||
`Num`(Clamp/IsFinite), `FirstOrderLag`(Seed/Step), `MovingAverage(windowSamples)`(Push), `DeadTimeBuffer`(Through — 증가전용 링·비대칭 θ 보존), `RateLimiter`(Seed/Step 비대칭), `Derivative`(Update), `TempCorrection.PressureCompensated`(**미배선 죽은 코드**).
|
||||
|
||||
### B.3 엔진 (`src/Infrastructure/Control/FeedforwardEngine.cs`)
|
||||
- `StreamState{ Dead, Lag, Rate, LastRec }`, `ColumnState{ FeedFilter, PressFilter, FeedDeriv, SettleTimerSec, Initialized, Streams{key→StreamState} }`.
|
||||
- `FeedforwardEngine.Tick(ColumnConfig cfg, PvSnapshot pv, ColumnState st, DateTime now) → AdvisoryResult` — **순수함수(I/O 없음)**.
|
||||
- 흐름: FEED 품질게이트→Hold / FEED 필터(EMA) / 시드 / 과도·압력 게이트(transient) / 스트림 2-pass(commanded→reflux) / 물질수지(transient 아닐 때 `vloss=ff-(D+P+B)`, `yield=100*P/ff`).
|
||||
- 역할별: Commanded=비대칭θ DeadTime→Lag→K→RateLimit / LevelDriven=`K*ff`(즉시) / Monitor=null.
|
||||
|
||||
### B.4 수급/저장/컨트롤러/DI
|
||||
- `FeedforwardSupervisor`(BackgroundService): 스코프마다 `IFeedforwardConfigStore.LoadAllAsync` + `IExperionDbService.GetRealtimeRecordsByTagNamesAsync`로 `PvSnapshot` 구성 → `Tick` → `IFeedforwardAdvisoryStore.Set`. 쓰기 없음. 컬럼별 `ColumnState` 단일 루프 소유(락 없음).
|
||||
- `IFeedforwardConfigStore{ LoadAllAsync, SaveColumnAsync, DeleteColumnAsync }`, `IFeedforwardAdvisoryStore{ Set, Get, GetAll }`.
|
||||
- `FeedforwardConfigStore`: ADO(EF 커넥션). LoadAll은 컬럼+스트림 2쿼리(스트림 SELECT에 `level_tag` 인덱스 14 포함). Save/Delete는 **파라미터화 `P()` 헬퍼 + 트랜잭션**.
|
||||
- `FeedforwardController` (`api/ff`): `GET/POST/DELETE config`(**인증 없음**), `GET advisory`·`advisory/{id}`(공개). `MapColumn`/`MapConfig`로 camelCase 명시.
|
||||
- DI(Program.cs:124~128): Engine=Singleton, AdvisoryStore=Singleton, ConfigStore=Scoped, Supervisor=**단일 AddHostedService**(중복 없음). WO-6에서 singleton+hosted로 교체(A2).
|
||||
- DDL(ExperionDbContext.InitializeAsync, line ~1066~1103): `ff_column_config`, `ff_stream_config`(grade, **level_tag** 포함)을 **하나의 `ExecuteSqlRawAsync`에 여러 문장(CREATE;CREATE;ALTER)** 으로 멱등 생성(Npgsql는 다문장 허용 — PhaseI의 "한 호출 한 문장" 주의는 현재 코드에선 무효). try/catch로 "[ExperionDb] 초기화 실패" 경고 후 `return false`.
|
||||
|
||||
### B.5 C-6111 태그 매핑 (브레인스토밍 확정)
|
||||
| 키 | 태그 | 의미 | 역할(seed) | 전환류 시 |
|
||||
|:--:|:-----|:-----|:----------|:----------|
|
||||
| FEED | ficq-6101 | 원료 투입(물질수지 기준) | (입력·외란) | **차단(→0)** |
|
||||
| R | ficq-6113 | 환류(reflux) | Commanded(RefluxFromProduct) | **최대(전량 환류)** |
|
||||
| P | ficq-6118 | 측류 제품(PGMEA) | Commanded | **차단(→0)** |
|
||||
| D | ficq-6114 | 탑정 경비물(저비점) 배출 | LevelDriven | **차단(→0)** |
|
||||
| B | ficq-6116 | 탑저 중비물(고비점) 배출 | LevelDriven | **차단(→0)** |
|
||||
| 보조 | tica-6111a(탑저/리보일러), pica-6111·pi-6111(진공 ~48.5torr), lica-6113/li-6111(레벨), ti-6111b/c/d(프로파일) | | |
|
||||
|
||||
> 출처: D/P/B 매핑 = `knowledge/PGMEA_측류추출운전방식_주의점.md §1` + PhaseI §5.8 seed. 태그 계측현황 = `docs/보조운전-브레인스토밍.md §10.2`(ti-6111a.pv=0 고장의심 주의).
|
||||
> **운전 정석(`PGMEA_측류추출운전방식_주의점.md §4.2·§4.3`)**: 측류 조성이 목표 이탈/외란 시 → ① **측류를 먼저 줄이거나 일시 중단**(오염 제품 방지) → ② **환류비를 높여 탑 내부 재안정화** → ③ 회복되면 측류 재개. "탑을 안정시킨 후 뽑는다"가 원칙. **전환류 모드(WO-6)는 이 §4.3 정석의 극단(드로우 전면 중단·전량 환류)을 상태기계로 형식화한 것.**
|
||||
|
||||
---
|
||||
|
||||
## §0. 모델 공통 확장 (모든 WO 선행) — `FeedforwardModels.cs`
|
||||
|
||||
WO들이 공유하는 필드를 **한 번에** 추가한다(레코드라 `with` 호환). **camelCase 직렬화는 컨트롤러 Map에서 명시** (PhaseI 규칙).
|
||||
|
||||
```csharp
|
||||
// enum 추가
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum ColumnMode { Normal, Recovering, Returning } // WO-6 전환류 상태기계
|
||||
|
||||
// StreamConfig 추가 필드 (DDL ff_stream_config 동반)
|
||||
// P-1(θ제안)·전환류 reflux 식별용
|
||||
public bool IsReflux { get; init; } // 전환류 시 "전량 환류" 대상. (없으면 RefluxFromProduct로 대체식별)
|
||||
public double RecoverySp { get; init; } = double.NaN; // 전환류 중 이 스트림 권장SP. NaN이면 규칙기본(draw=0, reflux=SpMax)
|
||||
|
||||
// ColumnConfig 추가 필드 (DDL ff_column_config 동반)
|
||||
// P-2(PCT/차온)
|
||||
public IReadOnlyList<string> TempTags { get; init; } = Array.Empty<string>(); // 프로파일 온도 base tag (상→하 순서)
|
||||
public string? SensitiveTrayTag { get; init; } // 민감트레이(프론트 지표). null이면 ΔT(상-하)로 대체
|
||||
public double DTdP { get; init; } = 0.0; // dT/dP [°C/압력단위]. 0이면 PCT 미적용(생온도)
|
||||
public double PRef { get; init; } = double.NaN; // 압력 기준점. NaN이면 첫 정상압력 시드
|
||||
// P-1(θ 자동튜닝)
|
||||
public string? SteamOpTag { get; init; } // TICA-6111A.OP(스팀) — 부분상관 2번째 입력(폐루프 오염 회피)
|
||||
public bool ThetaAutoTune { get; init; } // θ 식별 가동 여부(제안만, 자동반영 X)
|
||||
// P-4(느린 바이어스)
|
||||
public double BiasMaWindowSec { get; init; } = 6*3600; // K_obs/k_V 장기 MA 창(기본 6h)
|
||||
// WO-6(전환류 복귀)
|
||||
public bool RecoveryEnabled { get; init; } // 전환류 권장 기능 on/off
|
||||
public bool RecoveryAutoArm { get; init; } // true=자동권장, false=운전원 1클릭 무장 후에만
|
||||
public double ImbalanceTriggerFrac { get; init; } = 0.10; // |V_loss_MA|/F 지속 초과 시 트리거(기본 10%)
|
||||
public double ImbalanceTriggerSec { get; init; } = 600; // 지속 시간(기본 10분)
|
||||
public double RecoverySettleSec { get; init; } = 1800; // 전환류 평형 dwell(기본 30분)
|
||||
public double ReturnRampSec { get; init; } = 600; // 복귀 시 draw/feed 램프(기본 10분)
|
||||
public double FeedRecoverySp { get; init; } = 0.0; // 전환류 중 FEED 권장값(기본 0=차단)
|
||||
public string? DeltaPTag { get; init; } // 탑 차압(ΔP) — 플러딩/비산 트리거(주의점§3 4순위). null=미사용
|
||||
public double DeltaPFloodLimit { get; init; } = double.MaxValue; // ΔP 상한(초과 지속 시 트리거)
|
||||
|
||||
// StreamAdvisory 추가 필드
|
||||
public string? GradeReason { get; init; } // P-5 강등 사유
|
||||
public double? ThetaSuggestUpSec { get; init; } // P-1 제안 θ↑ (null=신뢰부족)
|
||||
public double? ThetaSuggestDnSec { get; init; } // P-1 제안 θ↓
|
||||
public double? ThetaSuggestConf { get; init; } // P-1 상관 신뢰 0~1
|
||||
public double? KObsSuggest { get; init; } // P-4 관측 K 장기추세 제안
|
||||
|
||||
// AdvisoryResult 추가 필드
|
||||
public ColumnMode Mode { get; init; } = ColumnMode.Normal; // WO-6
|
||||
public string? ModeReason { get; init; }
|
||||
public double? VLossMa { get; init; } // P-4/WO-6 장기 MA V_loss
|
||||
public IReadOnlyList<TempPoint>? Temps { get; init; } // P-2 PCT/차온 모니터
|
||||
public string? FrontPositionState { get; init; } // P-3
|
||||
public string? FrontTrimAdvice { get; init; } // P-3
|
||||
|
||||
public sealed record TempPoint(string Tag, double Raw, double Pct, bool Good);
|
||||
```
|
||||
|
||||
> **레코드 확장 주의**: `StreamAdvisory`·`AdvisoryResult`는 **위치 인자(positional) record** 다. 새 필드는 **positional 파라미터로 추가하면 기존 `new StreamAdvisory(...)` 호출이 전부 깨진다.** → **새 필드는 위와 같이 `{ get; init; }` 본문 프로퍼티로 추가**(positional 생성자 불변)하고, 생성부에서 `with { ... }` 또는 object initializer로 채운다. 엔진의 기존 `new StreamAdvisory(...)`/`new AdvisoryResult(...)` 호출은 그대로 두고 뒤에 `with { GradeReason = ..., Mode = ... }`를 붙인다.
|
||||
|
||||
**DDL 델타** (ExperionDbContext.InitializeAsync, line ~1102 기존 ff 블록 끝 — 같은 `ExecuteSqlRawAsync` 다문장 블록에 멱등 ALTER 추가; Npgsql 다문장 허용. 위치는 `ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS level_tag TEXT;` 바로 뒤):
|
||||
```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; -- NULL=규칙기본
|
||||
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; -- NULL=시드
|
||||
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;
|
||||
```
|
||||
> **ConfigStore 동반 수정**: `LoadAllAsync`의 두 SELECT에 신규 컬럼 추가(읽기 인덱스 시프트 주의 — 새 컬럼은 **항상 SELECT 끝에 append**하고 새 인덱스로 읽을 것), `SaveColumnAsync`의 INSERT/UPDATE에 신규 파라미터 추가, `MapConfig`에 camelCase 필드 추가. **인덱스 시프트가 PhaseI 진단의 단골 버그**이므로 SELECT 컬럼 순서와 `rd.GetXxx(n)` 번호를 1:1 대조 검증.
|
||||
|
||||
---
|
||||
|
||||
## WO-1 — P-5 confidence 자동강등 (1순위, 노력 小)
|
||||
|
||||
**목적**: config의 정적 `Grade`(A/B/C)를 **실시간 입력 건전성으로 강등**해, 운전원이 "지금 이 권장값을 믿을지"를 색으로 안다. PhaseIII auto-write의 **안전 게이트 전제**(Grade A만 쓰기).
|
||||
**근거**: spec §14.3(보정 3등급), §14.5(신뢰도 플래그). PhaseII §6 훅("`StreamAdvisory.Grade` ← P-5 연결점").
|
||||
|
||||
**설계**: config `Grade`는 **상한(best-case)**. 엔진이 아래 사유로 한 단계씩 강등하고 `GradeReason`에 기록.
|
||||
|
||||
| 강등 사유 | 적용 |
|
||||
|:----------|:-----|
|
||||
| PV stale/BAD(신선도 초과) | 해당 스트림 → 최소 B, 연속 시 C |
|
||||
| 과도(transient) 중 | → 한 단계 강등(정착 전 신뢰 낮음) |
|
||||
| 압력 불안정(pUnstable) | 컬럼 전체 → 한 단계 강등 |
|
||||
| analyzer/온도지표 부재인데 등급이 C 항목 의존(P-3 연계) | C 유지 |
|
||||
| 물질수지 불일치(`mbState`≠"정상") | commanded 스트림 → 한 단계 강등 |
|
||||
|
||||
**구현 (`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);
|
||||
}
|
||||
```
|
||||
`BuildAdvisory`에서 스트림별 적용(`pv.Streams[key].Good` 신선도, `transient`, mbState 불일치 플래그를 인자로 받게 시그니처 확장), 결과를 `with { Grade = g, GradeReason = why }`. 컬럼 공통 사유(pUnstable)는 Tick에서 일괄 한 단계 추가 강등.
|
||||
|
||||
**UI(ff.js, 기존 P-6 자산)**: 이미 `.ff-grade-A/B/C` 색 클래스 존재 → `s.gradeReason`을 셀 `title`(툴팁)로 노출만 추가.
|
||||
|
||||
**테스트(xUnit, PhaseI §5.7 프로젝트)**:
|
||||
- 신선 PV + 정상 → config Grade 그대로.
|
||||
- stale PV → ≥B. transient → 한 단계. stale+transient+mb불일치 → C(바닥).
|
||||
- `Downgrade`는 A를 넘어 C에서 더 내려가지 않음(클램프).
|
||||
|
||||
**검증**: 빌드 0/0, `grep`로 쓰기 0건 유지, advisory 응답에 `gradeReason` 등장, 한 스트림 강제 stale 시 해당 카드만 강등.
|
||||
|
||||
---
|
||||
|
||||
## WO-2 — P-2 PCT/차온 모니터 (2순위, 반쯤 완성)
|
||||
|
||||
**목적**: 죽은 코드 `TempCorrection.PressureCompensated`를 **엔진에 배선** + `DiffTemp` 추가 → 진공노이즈 제거된 PCT·차온을 모니터로 산출. **P-3(프론트 위치)·P-5(C등급 근거)의 입력**.
|
||||
**근거**: spec §13.3(PCT/ΔT), §13.6(블록), §14.1(dT/dP≈0.5°C/torr — 진공 ±2torr가 구배 절반 → PCT 필수).
|
||||
|
||||
**신규 블록 (`ComputationBlocks.cs`)**:
|
||||
```csharp
|
||||
/// <summary>차온/이중차온 — 공통모드(압력·계절) 상쇄, 프론트 이동만 부각. §13.3</summary>
|
||||
public static class DiffTemp
|
||||
{
|
||||
public static double Delta(double tHi, double tLo) => tHi - tLo; // 두 트레이 차온
|
||||
public static double Double(double tA, double tB, double tC) => (tA - tB) - (tB - tC); // 이중차온(곡률)
|
||||
}
|
||||
```
|
||||
> `TempCorrection.PressureCompensated`는 그대로 사용(추가 코드 없음). PCT = `T_meas − dTdP·(P − P_ref)`.
|
||||
|
||||
**엔진 배선**:
|
||||
- `ColumnState`에 `FirstOrderLag PRefSeed`(또는 단순 `double _pRef`) 추가 — `cfg.PRef`가 NaN이면 첫 정상 압력으로 시드.
|
||||
- `BuildSnapshotAsync`(Supervisor)에 `cfg.TempTags` PV 읽기를 추가(이미 feed/pressure/levels/streams 읽는 패턴 재사용). `PvSnapshot`에 `IReadOnlyList<TagSample> Temps`를 추가(positional이므로 **새 record 필드는 init 프로퍼티로** 추가하거나 `PvSnapshot`를 확장 — 본 문서는 `PvSnapshot`에 `Temps` init 프로퍼티 추가 권장).
|
||||
- Tick에서 각 온도에 PCT 계산 → `AdvisoryResult.Temps`(`TempPoint`)로 저장. `dTdP==0`이면 PCT=raw(생온도, 모니터만).
|
||||
- **advisory-only**: Temps는 표시·P-3 입력일 뿐 권장SP에 영향 없음(이번 WO 한정).
|
||||
|
||||
**ConfigStore/Controller/DDL**: §0의 `temp_tags`·`dtdp`·`p_ref`·`sensitive_tray_tag` 반영(load/save/map).
|
||||
|
||||
**UI(ff.js)**: 카드 하단에 온도 미니행(태그·raw·PCT) 1줄. 없으면 생략.
|
||||
|
||||
**테스트**: `TempCorrection`(P 상승 시 PCT가 raw보다 낮아짐, dTdP=0이면 PCT=raw), `DiffTemp.Delta/Double` 산술, `PRef` NaN 시드 경로.
|
||||
|
||||
**검증**: dtdp>0 컬럼에서 진공 흔들 때 raw는 출렁이나 PCT는 평탄(공통모드 상쇄) 확인.
|
||||
|
||||
---
|
||||
|
||||
## WO-3 — P-1 θ 자동튜닝 (3순위, 노력 大·통계)
|
||||
|
||||
**목적**: seed θ/τ가 전부 placeholder(PhaseI §5.8 경고)인 문제를 해소. **정상 운전 중 자연외란**으로 θ를 **passive 식별**해 **제안만**(자동반영 금지·운전원 승인 시 config 반영).
|
||||
**근거**: spec §13.4(교차상관 θ, 스팀 부분상관으로 폐루프 오염 회피), §13.7(θ는 신뢰도 등급 붙은 추정치).
|
||||
|
||||
> **현실 경고(spec §13.2)**: 단일점 생온도 SNR 낮음 → **WO-2의 PCT/ΔT를 입력으로** 쓰고, **스팀 OP(`SteamOpTag`)를 2번째 입력으로 부분상관**해 TICA 폐루프 동특성을 θ로 오귀속하지 않게 한다. 외란 부족 시 **신뢰도 낮음 → 제안 억제(null)**.
|
||||
|
||||
**신규 블록 (`ComputationBlocks.cs` 또는 신규 `CrossCorrLagEstimator.cs`)** — 계약(시그니처) 고정:
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Passive 전달지연 식별. ΔF(피드 변화)와 ΔS_i(=ΔPCT/Δflow) 의 교차상관 최대 지연 = θ.
|
||||
/// 스팀 ΔS_steam 을 2번째 입력으로 부분상관(partial corr)해 폐루프 오염 제거(§13.4).
|
||||
/// 사전백색화(pre-whitening=1차차분) 적용. I/O 없음, 컬럼 루프 단일 소유(락 불필요).
|
||||
/// </summary>
|
||||
public sealed class CrossCorrLagEstimator
|
||||
{
|
||||
public CrossCorrLagEstimator(int maxLagSamples, int historySamples, double minSignalStd);
|
||||
/// 매 틱 호출. 반환: 충분한 외란 누적 후에만 (θup,θdn,conf), 아니면 null.
|
||||
public (double thetaUpSec, double thetaDnSec, double conf)? Push(
|
||||
double dFeed, double dResponse, double dSteam, double tsSec);
|
||||
}
|
||||
```
|
||||
**알고리즘(구현 지침)**:
|
||||
1. 입력은 **1차차분(Δ)** 받음(미분=사전백색화). 링버퍼(historySamples)에 `dFeed`,`dResponse`,`dSteam` 누적.
|
||||
2. 외란 검정: `std(dFeed) < minSignalStd` → 신뢰 0, null 반환(억제).
|
||||
3. **부분상관**: `dResponse`에서 `dSteam` 선형회귀 성분 제거(잔차 `r = dResponse − β·dSteam`, β=cov/var). 이후 `ρ(τ)=corr(dFeed[t], r[t+τ])` for τ∈[0,maxLag].
|
||||
4. `θ = argmax_τ ρ(τ)·ts`. `conf = max ρ`(0~1, 음수면 0).
|
||||
5. 상승/하강 비대칭: `dFeed>0` 표본만으로 θup, `dFeed<0` 표본만으로 θdn 별도 추정(표본 부족 시 공통값).
|
||||
6. `conf < 0.3`면 제안 억제(null).
|
||||
|
||||
**배선**: `StreamState`에 컬럼당 1개 estimator(또는 commanded 스트림별). Tick에서 `cfg.ThetaAutoTune && WO-2 PCT 가용` 일 때만 `Push` → `StreamAdvisory.ThetaSuggestUpSec/DnSec/Conf` 채움. **config θ는 변경하지 않음**(제안 전용).
|
||||
|
||||
**UI**: 카드에 "θ 제안 ↑NNs ↓NNs (conf 0.x)" 보조행. 운전원이 설정 에디터에서 수동 반영.
|
||||
|
||||
**테스트**: 합성 신호(알려진 θ로 지연된 응답 + 노이즈)에 대해 추정 θ가 ±1 샘플 내. 외란 std 미달 시 null. 스팀 상관 주입 시 부분상관이 제거하는지(스팀만 상관된 가짜 지연은 conf 낮게).
|
||||
|
||||
**검증**: 라이브에서 ThetaAutoTune=true 컬럼이 외란 충분 시에만 제안 노출, config 무변경(쓰기 0건) 확인.
|
||||
|
||||
> **현실성**: 데모 시스템 온도는 인위 생성(spec §13.7) → 실플랜트 전 **검증 보류 가능**. 본 WO는 **인터페이스·블록·테스트까지** 턴키로 두되, 가동 스위치(`ThetaAutoTune`)는 기본 false.
|
||||
|
||||
---
|
||||
|
||||
## WO-4 — P-4 느린 바이어스 적응 (4순위)
|
||||
|
||||
**목적**: 계절 CW 스윙 등 **크지만 느린 외란**(spec §14.4)을 정밀모델 대신 **장기 MA로 K_obs·k_V 추세**를 내어 운전원에게 "계절 보정 제안". 자동 변경 아님.
|
||||
**근거**: spec §14.4(느린 바이어스/운전원 트림), §14.3 B등급(V_loss는 장기 MA로만 의미).
|
||||
|
||||
**설계**:
|
||||
- `ColumnState`에 `MovingAverage`(창=`BiasMaWindowSec/ScanSec` 샘플) 2개: `VLossMa`, 그리고 commanded 스트림별 `KObsMa`(=PV/FeedFiltered 의 MA).
|
||||
- **정상상태에서만 갱신**(transient·BAD 제외) — 과도 표본 오염 방지.
|
||||
- 산출: `AdvisoryResult.VLossMa`, `StreamAdvisory.KObsSuggest`(= K_obs MA, config TargetCoeff와 비교해 드리프트 표시).
|
||||
- **advisory-only**: 제안값일 뿐 엔진 K는 config 그대로.
|
||||
|
||||
**테스트**: 일정 비율 입력 스텝 후 MA가 천천히 수렴(창 길이만큼), 과도 표본은 MA에 안 들어감.
|
||||
|
||||
**검증**: 장기 가동 후 KObsSuggest가 config K 부근, 인위적 bias 주입 시 추세 이동.
|
||||
|
||||
> WO-4의 `VLossMa`는 **WO-6 전환류 트리거 입력**으로 재사용(순간 V_loss는 §5.3대로 신뢰불가 → MA로 판정).
|
||||
|
||||
---
|
||||
|
||||
## WO-5 — P-3 Sweet-Spot / 프론트 위치 지표 (5순위, P-2 의존)
|
||||
|
||||
**목적**: WO-2의 제품존 PCT/ΔT(민감트레이)를 **프론트 위치 프록시**로 삼아 드리프트 시 **환류/boilup 트림 권장**(advisory). analyzer 있으면 우선.
|
||||
**근거**: spec §13.5(2층 구조: 빠른 에너지=피드포워드, 느린 조성=온도 피드백), §13.2 함정②(제품존 신호 약함 → 차온 필수), §14.3 C등급.
|
||||
|
||||
**신규 블록 (`FrontPositionIndicator.cs`)**:
|
||||
```csharp
|
||||
/// <summary>제품존 PCT/ΔT 의 기준대비 드리프트 → sweet-spot 건전성 + 트림 방향 권장. advisory.</summary>
|
||||
public sealed class FrontPositionIndicator
|
||||
{
|
||||
public FrontPositionIndicator(double bandwidth, double refTau);
|
||||
public (string state, string? trimAdvice, Confidence grade) Update(
|
||||
double frontMetric, double tsSec); // frontMetric = 민감트레이 PCT 또는 제품존 ΔT
|
||||
}
|
||||
```
|
||||
- 느린 기준(EMA refTau, 예 30~60min)에서 `frontMetric` 이탈량 산출.
|
||||
- 밴드 내=「정상」 / 위로 드리프트=「프론트 상승 — 경비물 혼입 위험: 환류↑ 권장」(브레인스토밍 Q2/Q3 정석) / 아래=「프론트 하강 — boilup↑/환류↓ 권장」.
|
||||
- 등급: 단일 생온도면 C(신호 약함), 차온/analyzer면 B 이상.
|
||||
- **트림은 권장 문구만**(`AdvisoryResult.FrontPositionState`/`FrontTrimAdvice`) — SP 미변경.
|
||||
|
||||
**배선**: WO-2 Temps에서 `SensitiveTrayTag`(없으면 상-하 ΔT) 추출 → Indicator.Update → AdvisoryResult 필드.
|
||||
|
||||
**UI**: 카드 배너에 프론트 상태/트림 권장 표시(P-6 `ff-note` 자리 활용).
|
||||
|
||||
**테스트**: 기준 대비 상/하 드리프트에 대한 state·trim 분기, 밴드 내 「정상」, 등급 강등(생온도→C).
|
||||
|
||||
---
|
||||
|
||||
## WO-6 — 전환류(Total Reflux) 평형복귀 모드 ★ 신규
|
||||
|
||||
**요구(운전원)**: 컬럼 균형이 **심각하게 깨졌다고 판단되면**, **전환류 모드**로 전환 — **제품(P)·원료투입(F)·경비물(D)·중비물(B) 제거를 모두 차단**하고 **환류(R)를 전량 환류**로 두어 **평형 복귀**할 때까지 유지, 회복 후 정상 복귀.
|
||||
|
||||
**공정 근거**: 증류 정석의 *total reflux* 회복기동 = `knowledge/PGMEA_측류추출운전방식_주의점.md §4.3`("외란 시: 측류 먼저 중단 → 환류비↑ 재안정화 → 회복 후 재개")의 극단형. 외란으로 조성 프론트가 무너지면, 드로우·피드를 끊고 내부 환류만 순환시키면 단별 조성 프로파일이 **재평형**된다. 측류추출탑은 측류 조성이 상부 경비물·하부 중비물 **균형에 전적 의존**(동 §2)하므로 균형 붕괴 시 **측류 혼입(off-spec) 직전 회복 카드**.
|
||||
|
||||
### 6.1 아키텍처 결정 (불변식 준수)
|
||||
- **Phase II 범위 = 권장·상태표시까지**. 전환류는 본질적으로 **쓰기 동작**(드로우/피드 SP=0, 환류=max)이므로, **실제 실행은 PhaseIII(WriteGuard) 경유**. 본 WO는 **모드 판정 상태기계 + 권장 SP 산출 + UI 경보/표시**까지(쓰기 0건).
|
||||
- **트리거 권위 2모드**: `RecoveryAutoArm=false`(기본) → 엔진은 "전환류 권장(ARMED)"만 띄우고 **운전원 1클릭 확인** 후 Recovering 진입(PhaseIII에서 실제 쓰기). `=true` → 자동 권장 상태기계가 직접 Recovering 진입(여전히 표시/권장, 쓰기는 PhaseIII gating).
|
||||
- **오발동 비용 큼**(피드 차단=생산중단) → 트리거는 **순간 V_loss 금지**(§5.3). 과도(transient) 중엔 트리거 금지.
|
||||
- **다신호 트리거(canonical 근거: `PGMEA_측류추출운전방식_주의점.md §3`)** — "균형 심각 붕괴"는 단일 V_loss보다 아래 **지속 조건의 OR**로 판정(어느 하나라도 `ImbalanceTriggerSec` 지속 시 ARM):
|
||||
- **① 물질수지**: `|VLossMa|/F > ImbalanceTriggerFrac` (WO-4 장기 MA).
|
||||
- **② 프론트(감도트레이)**: WO-5 `FrontPositionState`=심각 드리프트(주의점 §3 1순위 = 감도트레이 온도. **가장 신뢰도 높은 조기신호**).
|
||||
- **③ 플러딩/비산**: `DeltaPTag` 가용 시 `ΔP > DeltaPFloodLimit` (주의점 §3 4순위 — 비산이 제품 오염 직결).
|
||||
- 신호별 가용성은 config로 결정(태그 없으면 해당 조건 비활성).
|
||||
|
||||
### 6.2 상태기계 (`ColumnMode`)
|
||||
```
|
||||
┌────────────────────────── Normal ──────────────────────────┐
|
||||
│ |VLossMa|/F > ImbalanceTriggerFrac 지속> ImbalanceTriggerSec │
|
||||
│ AND !transient │
|
||||
│ AND (RecoveryAutoArm OR 운전원 ARM 확인) │
|
||||
▼ │
|
||||
Recovering(전환류) │
|
||||
권장: F→FeedRecoverySp(0), P/D/B→0(또는 RecoverySp), R→SpMax(전량) │
|
||||
dwell: 평형지표 안정(|VLossMa|/F < Frac·0.5 AND 프론트(WO-5) 정상) │
|
||||
을 RecoverySettleSec 동안 연속 만족 │
|
||||
▼ │
|
||||
Returning(복귀 램프) │
|
||||
ReturnRampSec 동안 F·draw를 정상 권장값으로 RateLimiter 램프 복원 │
|
||||
완료 → Normal ──────────────────────────────────────────────────┘
|
||||
(어느 상태든 운전원 수동 Normal 복귀 가능 / FEED BAD 지속 시 Recovering 유지)
|
||||
```
|
||||
|
||||
### 6.3 엔진 구현 (`FeedforwardEngine.cs` + `ColumnState`)
|
||||
- `ColumnState`에 추가: `ColumnMode Mode`, `double ImbalanceTimerSec`, `double RecoverySettleTimerSec`, `double ReturnTimerSec`, `bool OperatorArmed`(컨트롤러가 set).
|
||||
- **순수성 유지**: 상태 전이는 Tick 내에서 `st`(가변 ColumnState)로 처리 — 기존 SettleTimer와 동일 패턴(I/O 없음).
|
||||
- 절차(Tick 말미, advisory 산출 후):
|
||||
1. `severe = ① || ② || ③` (위 다신호; 가용 신호만 평가). `① frac=|VLossMa|/ff > ImbalanceTriggerFrac`, `② WO-5 FrontPositionState 심각`, `③ ΔP>DeltaPFloodLimit`.
|
||||
2. **Normal**: `!transient && severe` → `ImbalanceTimerSec += ts` else `=0`. `ImbalanceTimerSec ≥ ImbalanceTriggerSec` + (AutoArm || OperatorArmed) → `Mode=Recovering`, 타이머 리셋, OperatorArmed=false. (AutoArm=false면 ARM 없이는 "전환류 권장(ARMED 대기)" 표시만.)
|
||||
3. **Recovering**: 스트림 권장값을 **오버라이드** — reflux(IsReflux||RefluxFromProduct)=`SpMax`, 그 외 commanded draw=`RecoverySp(NaN→0)`, FEED 권장=`FeedRecoverySp`. 평형조건(`frac < Frac*0.5 && 프론트 정상`) 연속 만족 시 `RecoverySettleTimerSec += ts`, 도달 시 `Mode=Returning`.
|
||||
4. **Returning**: `ReturnTimerSec += ts`; 진행률 `α=min(1, ReturnTimerSec/ReturnRampSec)`로 draw/feed 권장을 0→정상값 보간(또는 RateLimiter가 자연 램프). `α>=1` → `Mode=Normal`.
|
||||
5. `AdvisoryResult`에 `with { Mode = st.Mode, ModeReason = ..., VLossMa = ... }`. Recovering/Returning에선 각 `StreamAdvisory.RecommendedSp`가 오버라이드값, `Note`에 "전환류 복귀" 표기. **Grade는 강등하지 않되 Valid=false(운전원 인가 필요)**.
|
||||
- **FEED 권장 노출**: FEED는 스트림이 아니므로 `AdvisoryResult`에 `FeedRecommendedSp`(double?) init 프로퍼티 추가 — Recovering 시 `FeedRecoverySp`, 그 외 null.
|
||||
|
||||
### 6.4 컨트롤러 (`FeedforwardController.cs`)
|
||||
- `POST api/ff/recovery/{columnId}/arm` — 운전원 ARM(=Supervisor 통해 해당 ColumnState.OperatorArmed=true). **Supervisor를 singleton 주입**(A2 정정으로 가능)해 컬럼 상태 접근. 쓰기 아님(모드 판정용 플래그).
|
||||
- `POST api/ff/recovery/{columnId}/cancel` — 수동 Normal 복귀.
|
||||
- (PhaseIII에서) 실제 SP 쓰기는 별도 `apply` 엔드포인트가 WriteGuard 경유.
|
||||
|
||||
### 6.5 UI (ff.js / ff.css)
|
||||
- 카드 헤더에 모드 뱃지: `Normal`(무표시) / `전환류 복귀중 ●`(주황) / `복귀 램프 ●`(파랑) / `전환류 권장(ARMED)`(점멸 경보 + [확인] 버튼 → arm 호출).
|
||||
- Recovering/Returning 시 표에 "권장 SP" 열이 0/max 오버라이드로 표시, Valid=false라 흐리게(`ff-stale`)·"운전원 인가" 주석.
|
||||
|
||||
### 6.6 DDL/ConfigStore
|
||||
§0의 recovery 컬럼들(`recovery_enabled`, `recovery_auto_arm`, `imbalance_trigger_frac`, `imbalance_trigger_sec`, `recovery_settle_sec`, `return_ramp_sec`, `feed_recovery_sp`) + stream `is_reflux`/`recovery_sp` 반영.
|
||||
|
||||
### 6.7 테스트 (xUnit)
|
||||
- 합성 시퀀스: 정상 → VLossMa 임계 지속 초과(+!transient) → `Recovering` 진입. AutoArm=false면 OperatorArmed 없이는 **진입 안 함**.
|
||||
- Recovering 권장값: reflux=SpMax, P/D/B=0, FeedRecommendedSp=FeedRecoverySp.
|
||||
- 평형 회복(frac↓ 지속 RecoverySettleSec) → `Returning` → ReturnRampSec 경과 → `Normal`.
|
||||
- transient 중엔 트리거 타이머 누적 안 됨.
|
||||
- 수동 cancel 시 즉시 Normal.
|
||||
- **쓰기 0건**(엔진/컨트롤러 grep) — 모드 판정·권장만.
|
||||
|
||||
### 6.8 안전 결정 (문서화)
|
||||
| 항목 | 결정 |
|
||||
|:-----|:-----|
|
||||
| 실행 권한 | 권장·표시는 PhaseII, **실제 SP 쓰기는 PhaseIII WriteGuard** 경유(전환류는 대규모 조작이라 운전원 확인 강제 권장) |
|
||||
| 오발동 방지 | 순간 V_loss 금지, `VLossMa`(장기 MA) 지속 초과 + `!transient` + (AutoArm||운전원 ARM) |
|
||||
| 복귀 부드<EBB680>러움 | Returning에서 RateLimiter 램프 — bumpless |
|
||||
| 트리거 보수성 | 기본 `RecoveryAutoArm=false`(운전원 1클릭). 자동무장은 신뢰 확보 후 |
|
||||
| point of no return | 프론트(WO-5) "경비물 혼입 위험" 단계에서 **선제 ARM 권장**(정석) |
|
||||
|
||||
---
|
||||
|
||||
## §C. 통합 검증 (감독자 — diagnosis-checklist.md 8단계)
|
||||
|
||||
1. **빌드**: `dotnet build src/Web/ExperionCrawler.csproj` 경고0/에러0.
|
||||
2. **쓰기 불변식**(FF 경로 한정): `grep -rE "ExperionOpcWriteClient|Write.*Async" src/Infrastructure/Control src/Web/Controllers/FeedforwardController.cs` → **0건**(범용 `ExperionOpcWriteClient.cs`는 OpcUa 폴더라 미포함 — 정상). WO 전체 advisory.
|
||||
3. **DI(A2)**: WO-6 적용 시 `FeedforwardSupervisor`가 singleton+hosted로 노출되어 **인스턴스 1개만 가동**(로그 틱 루프 1회) + 컨트롤러 주입 가능. WO-6 전이면 현 단일 AddHostedService 유지.
|
||||
4. **인덱스 정합(§0)**: ConfigStore SELECT 컬럼 ↔ `rd.GetXxx(n)` 1:1, 저장→재로드 라운드트립 일치(신규 필드 포함).
|
||||
5. **단위테스트**: WO-1~WO-6 케이스 + PhaseI 기존 4 모두 green.
|
||||
6. **라이브**: Tab 18에서 등급 강등(WO-1)·PCT(WO-2)·θ제안(WO-3)·KObs추세(WO-4)·프론트 트림(WO-5)·전환류 모드 뱃지/ARM(WO-6) 표시. 폴링 누수 없음.
|
||||
7. **전환류 시나리오**: 인위적 V_loss bias 주입 → VLossMa 임계 지속 → (ARM 후) Recovering 권장(R=max, F/P/D/B=0) → 회복 → Returning → Normal. 쓰기 0건.
|
||||
|
||||
---
|
||||
|
||||
## §D. P-7 (PhaseIII auto-write) — 기존 문서 정정 메모
|
||||
|
||||
PhaseIII 문서는 대체로 유효하나 착수 전 아래 정정:
|
||||
- **D1**: PhaseIII §6이 지목한 `OpcUaClientService.cs`는 **그 이름으론 부재**. 그러나 **`src/Infrastructure/OpcUa/ExperionOpcWriteClient.cs`(namespace `ExperionCrawler.Infrastructure.Control`, `Opc.Ua.Client` 사용)가 이미 존재** — 쓰기 래퍼를 **신규 작성하지 말고 이 기존 클라이언트를 재사용/확장**(가드는 WriteGuard로 상위에서). 기존 읽기 서비스(Realtime/History/Metadata 등)는 건드리지 말 것.
|
||||
- **D2**: NodeId `ns=3;s="{tag}.sp"` 는 **미검증 가정** — `ExperionOpcWriteClient`의 실제 노드 지정 방식 + 서버 브라우즈로 **확인 후 확정**.
|
||||
- **D3**: WriteGuard 게이트는 **WO-1의 동적 Grade**(정적 config Grade 아님) + `!Transient` + SafetyMaxDelta. WO-6 전환류는 별도 `apply` 경로로 **운전원 확인 강제**.
|
||||
- **D4**: PhaseIII §1.2("B/C 금지")와 §4.3("스트림 단위 A만 대상")은 **per-stream 동적 Grade 기준**으로 통일.
|
||||
- **D5**: auth 재도입(`IKbAuthService`)은 **쓰기 엔드포인트에만**(advisory/config 읽기는 공개 유지).
|
||||
|
||||
---
|
||||
|
||||
## §E. 턴키 요약
|
||||
|
||||
| WO | 항목 | 노력 | 신규 파일 | 변경 파일 | 가동 스위치(기본) |
|
||||
|:--:|:-----|:----:|:----------|:----------|:------------------|
|
||||
| A | 문서 드리프트 정정(§B 기준선화) | 小 | — | (WO-6 시 Program.cs DI 교체) | — |
|
||||
| 0 | 모델·DDL 공통확장 | 中 | — | Models/ConfigStore/Context/Controller | — |
|
||||
| 1 | P-5 등급 자동강등 | 小 | — | Engine, ff.js | 항상 |
|
||||
| 2 | P-2 PCT/차온 | 小 | (DiffTemp 블록) | Blocks/Engine/Supervisor/Store/Ctrl | dtdp>0 시 |
|
||||
| 3 | P-1 θ 자동튜닝 | 大 | CrossCorrLagEstimator | Engine/State/Supervisor | ThetaAutoTune=false |
|
||||
| 4 | P-4 느린 바이어스 | 中 | — | Engine/State | 항상(제안) |
|
||||
| 5 | P-3 프론트 위치 | 中 | FrontPositionIndicator | Engine/State | 온도 가용 시 |
|
||||
| 6 | 전환류 복귀 | 大 | — | Engine/State/Ctrl/ff.js/css | RecoveryEnabled=false, AutoArm=false |
|
||||
| D | P-7 정정 | — | (PhaseIII) | — | — |
|
||||
|
||||
**구현 순서**: A(정정) → 0(공통확장·DDL·ConfigStore 인덱스검증) → 1 → 2 → 3 → 4 → 5 → 6 → C(검증). 각 WO는 **빌드+해당 테스트 green + 쓰기 0건 grep** 후 다음 진행.
|
||||
|
||||
**불변식 재확인**: 본 문서 전체 범위에서 **제어 레지스터 쓰기 0건**. 전환류 포함 모든 실제 SP 쓰기는 PhaseIII.
|
||||
</content>
|
||||
</invoke>
|
||||
|
||||
<result>
|
||||
File created successfully: /home/windpacer/projects/ExperionCrawler/docs/측류추출식-통합유량설정공식-구현코딩-PhaseII-분석엔진+전환류복귀.md
|
||||
514
docs/측류추출식-통합유량설정공식-구현코딩-PhaseII.md
Normal file
514
docs/측류추출식-통합유량설정공식-구현코딩-PhaseII.md
Normal file
@@ -0,0 +1,514 @@
|
||||
# 측류추출 통합유량 — Phase II UI 구현 코딩 (Tab 18: 설정 + 권장 SP 대시보드)
|
||||
|
||||
> **성격**: Phase I advisory 엔진(`...-PhaseI.md`)의 **Web UI 코딩 명세 + 검증 절차**.
|
||||
> 감독자가 `diagnosis-checklist.md` 8단계로 진단한 뒤 반영. **advisory 불변식 유지** — 제어 레지스터 쓰기 0건.
|
||||
> Phase II는 **운전원이 경험상수를 공급**하고 **권장 SP를 화면에서 본다**(수동 인가). 자동 쓰기는 Phase III.
|
||||
|
||||
**Phase II 범위 분리**:
|
||||
- **본 문서 = UI 코딩**: ① 설정 CRUD API(admin) ② Tab 18 = 설정 에디터 + 권장 SP 대시보드.
|
||||
- **Phase II-분석(별도)**: θ 자동튜닝·PCT/차온·front-position·confidence 자동강등·느린 바이어스(= PhaseI §6 P-1~P-5). 본 문서 §6에 인터페이스 훅만.
|
||||
|
||||
---
|
||||
|
||||
## 0. 기존 UI 아키텍처 전제 (확인됨)
|
||||
|
||||
| 요소 | 사실 |
|
||||
|:-----|:-----|
|
||||
| 탭 라우터 | `core.js`의 `paneInit` 맵 + `activateTab(tab)` → `data-src`(`/panes/<tab>.html`) HTML을 fetch해 주입 후 `paneInit[tab]?.()` 호출 |
|
||||
| 탭 등록 | ① `index.html` `<li class="nav-item" data-tab="ff">` ② `<section class="pane" id="pane-ff" data-src="/panes/ff.html">` ③ `<script src="/js/ff.js">` ④ `ff.js`에서 `paneInit.ff = ffInit` |
|
||||
| API 헬퍼 | `core.js` `api(method, path, body)` (camelCase JSON), `esc()` XSS 이스케이프, `fmtVal/fmtTs` |
|
||||
| admin 인증 | KB 토큰 — 헤더 `X-Kb-Token`, 백엔드 `IKbAuthService.ValidateAsync`, 프론트 `sessionStorage.kbToken` (docs.js 패턴) |
|
||||
| JSON 바인딩 | `PropertyNamingPolicy=null` + **`PropertyNameCaseInsensitive=true`** → 프론트 camelCase 바디가 PascalCase DTO에 바인딩됨. 응답은 **camelCase 명시 익명객체**(CODING_CONVENTIONS) |
|
||||
| 라이브 폴링 | trend.js의 `setInterval` 패턴 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 파일 배치 (신규/변경)
|
||||
|
||||
```
|
||||
변경:
|
||||
src/Core/Application/Feedforward/IFeedforwardStores.cs # IFeedforwardConfigStore에 CRUD 추가
|
||||
src/Infrastructure/Control/FeedforwardConfigStore.cs # Save/Delete (파라미터화)
|
||||
src/Web/Controllers/FeedforwardController.cs # config CRUD(admin) 추가
|
||||
src/Web/wwwroot/index.html # nav-item + pane + script 3줄
|
||||
신규:
|
||||
src/Web/wwwroot/panes/ff.html # 대시보드 + 설정 에디터 마크업
|
||||
src/Web/wwwroot/js/ff.js # paneInit.ff — 폴링·렌더·에디터
|
||||
src/Web/wwwroot/css/ff.css # (또는 style.css에 추가)
|
||||
```
|
||||
|
||||
> Phase I과 동일하게 **단일 csproj**(`src/Web/ExperionCrawler.csproj`). C# 빌드: `dotnet build src/Web/ExperionCrawler.csproj`.
|
||||
|
||||
---
|
||||
|
||||
## 2. 백엔드 — 설정 CRUD (admin)
|
||||
|
||||
### 2.1 Store 인터페이스 확장 (`IFeedforwardStores.cs`)
|
||||
|
||||
```csharp
|
||||
public interface IFeedforwardConfigStore
|
||||
{
|
||||
Task<IReadOnlyList<ColumnConfig>> LoadAllAsync(CancellationToken ct = default);
|
||||
Task<int> SaveColumnAsync(ColumnConfig cfg, CancellationToken ct = default); // Id==0 INSERT, else UPDATE. 반환=id
|
||||
Task DeleteColumnAsync(int columnId, CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Store 구현 추가 (`FeedforwardConfigStore.cs`)
|
||||
|
||||
> **★ 사용자 입력이 들어오므로 전 컬럼 파라미터 바인딩**(인젝션 차단). 컬럼+스트림은 트랜잭션으로 원자적 교체.
|
||||
> `AdvisoryOnly`는 항상 TRUE 강제(불변식).
|
||||
|
||||
```csharp
|
||||
// using System.Data.Common; 추가
|
||||
|
||||
private static DbParameter P(DbCommand cmd, string name, object? val)
|
||||
{
|
||||
var p = cmd.CreateParameter();
|
||||
p.ParameterName = name;
|
||||
p.Value = val ?? DBNull.Value;
|
||||
cmd.Parameters.Add(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
public async Task<int> SaveColumnAsync(ColumnConfig cfg, CancellationToken ct = default)
|
||||
{
|
||||
var conn = _ctx.Database.GetDbConnection();
|
||||
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
|
||||
await using var tx = await conn.BeginTransactionAsync(ct);
|
||||
|
||||
int id = cfg.Id;
|
||||
var levelTags = string.Join(',', cfg.LevelTags);
|
||||
|
||||
if (id == 0)
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.Transaction = tx;
|
||||
cmd.CommandText = """
|
||||
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)
|
||||
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,"@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");
|
||||
id = Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
|
||||
}
|
||||
else
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.Transaction = tx;
|
||||
cmd.CommandText = """
|
||||
UPDATE ff_column_config SET
|
||||
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
|
||||
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,"@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");
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
// 스트림 원자적 교체
|
||||
await using (var del = conn.CreateCommand())
|
||||
{
|
||||
del.Transaction = tx; del.CommandText = "DELETE FROM ff_stream_config WHERE column_id=@id";
|
||||
P(del,"@id",id); await del.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
foreach (var s in cfg.Streams)
|
||||
{
|
||||
await using var ins = conn.CreateCommand();
|
||||
ins.Transaction = tx;
|
||||
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)
|
||||
VALUES (@cid,@key,@flow,@role,@k,@tup,@tdn,@tau,@smin,@smax,@rup,@rdn,@rfp,@grade)
|
||||
""";
|
||||
P(ins,"@cid",id); P(ins,"@key",s.Key); P(ins,"@flow",s.FlowTag.ToLowerInvariant());
|
||||
P(ins,"@role",s.Role.ToString()); 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());
|
||||
await ins.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
await tx.CommitAsync(ct);
|
||||
return id;
|
||||
}
|
||||
|
||||
public async Task DeleteColumnAsync(int columnId, 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 = "DELETE FROM ff_column_config WHERE id=@id"; // ON DELETE CASCADE → 스트림 동반 삭제
|
||||
P(cmd,"@id",columnId);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
```
|
||||
|
||||
> **누적 상태 정리(PhaseI 진단 잔여 #2)**: 컬럼 삭제 시 `AdvisoryStore`/`Supervisor._states`도 정리하려면
|
||||
> `IFeedforwardAdvisoryStore.Remove(int id)`를 추가하고 컨트롤러 DELETE에서 호출(아래 §2.3 주석). Supervisor는
|
||||
> 다음 Tick에서 enabled 목록에 없으면 자연 미갱신 — `_states` 잔존만 남으나 미미.
|
||||
|
||||
### 2.3 컨트롤러 확장 (`FeedforwardController.cs`)
|
||||
|
||||
```csharp
|
||||
// 생성자에 IKbAuthService 주입 (DocsController 패턴)
|
||||
private readonly IFeedforwardAdvisoryStore _store;
|
||||
private readonly IFeedforwardConfigStore _config;
|
||||
private readonly IKbAuthService _auth;
|
||||
public FeedforwardController(IFeedforwardAdvisoryStore store, IFeedforwardConfigStore config, IKbAuthService auth)
|
||||
{ _store = store; _config = config; _auth = auth; }
|
||||
|
||||
private Task<bool> IsAdminAsync(CancellationToken ct)
|
||||
=> _auth.ValidateAsync(Request.Headers["X-Kb-Token"].ToString(), ct);
|
||||
|
||||
// ── 설정 조회 (admin) ──────────────────────────────────────────
|
||||
[HttpGet("config")]
|
||||
public async Task<IActionResult> GetConfig(CancellationToken ct)
|
||||
{
|
||||
if (!await IsAdminAsync(ct)) return Unauthorized();
|
||||
var cols = await _config.LoadAllAsync(ct);
|
||||
return Ok(new { columns = cols.Select(MapConfig) });
|
||||
}
|
||||
|
||||
[HttpPost("config")]
|
||||
public async Task<IActionResult> SaveConfig([FromBody] ColumnConfig body, CancellationToken ct)
|
||||
{
|
||||
if (!await IsAdminAsync(ct)) return Unauthorized();
|
||||
var id = await _config.SaveColumnAsync(body, ct);
|
||||
return Ok(new { success = true, id });
|
||||
}
|
||||
|
||||
[HttpDelete("config/{id:int}")]
|
||||
public async Task<IActionResult> DeleteConfig(int id, CancellationToken ct)
|
||||
{
|
||||
if (!await IsAdminAsync(ct)) return Unauthorized();
|
||||
await _config.DeleteColumnAsync(id, ct);
|
||||
// _store.Remove(id); // IFeedforwardAdvisoryStore.Remove 구현 시 활성화
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
// camelCase 매핑 (설정 — 응답)
|
||||
private static 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,
|
||||
streams = c.Streams.Select(s => new
|
||||
{
|
||||
key = s.Key, flowTag = s.FlowTag, role = s.Role.ToString(), 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()
|
||||
})
|
||||
};
|
||||
```
|
||||
|
||||
> `[FromBody] ColumnConfig`는 record라도 `PropertyNameCaseInsensitive=true`로 camelCase 바디가 바인딩됨.
|
||||
> `double.MaxValue`(1e9 DDL 기본) 같은 큰 수는 그대로 직렬화/역직렬화. `Enum.Parse`는 Role/Grade 문자열로 처리(StreamConfig가 enum이라 JSON 문자열 "Commanded"/"A" 그대로 바인딩).
|
||||
|
||||
---
|
||||
|
||||
## 3. 프론트엔드 — Tab 18
|
||||
|
||||
### 3.1 index.html 와이어링 (3곳)
|
||||
|
||||
```html
|
||||
<!-- (1) nav: data-tab="trend" 다음 -->
|
||||
<li class="nav-item" data-tab="ff">
|
||||
<span class="nav-ico">⚖️</span><span class="nav-txt">유량 권장(FF)</span>
|
||||
</li>
|
||||
|
||||
<!-- (2) pane: pane-trend 다음 -->
|
||||
<section class="pane" id="pane-ff" data-src="/panes/ff.html"></section>
|
||||
|
||||
<!-- (3) script: /js/trend.js 다음 -->
|
||||
<script src="/js/ff.js"></script>
|
||||
<link rel="stylesheet" href="/css/ff.css"> <!-- 또는 style.css에 병합 -->
|
||||
```
|
||||
|
||||
### 3.2 panes/ff.html
|
||||
|
||||
```html
|
||||
<div class="ff-wrap">
|
||||
<div class="ff-head">
|
||||
<h2>측류추출 유량 권장 (Advisory · 보조지표)</h2>
|
||||
<span class="ff-badge">읽기 전용 — 권장값. 인가는 운전원</span>
|
||||
<button id="ff-cfg-toggle" class="btn">설정 ▾</button>
|
||||
</div>
|
||||
|
||||
<!-- 권장 SP 대시보드 (공개 읽기) -->
|
||||
<div id="ff-dash" class="ff-dash"><div class="ff-empty">불러오는 중…</div></div>
|
||||
|
||||
<!-- 설정 에디터 (admin) -->
|
||||
<div id="ff-cfg" class="ff-cfg" style="display:none">
|
||||
<div class="ff-cfg-bar">
|
||||
<input id="ff-token" type="password" placeholder="admin 토큰" class="inp">
|
||||
<button id="ff-unlock" class="btn">잠금해제</button>
|
||||
<button id="ff-new" class="btn" disabled>+ 컬럼</button>
|
||||
<span id="ff-cfg-msg" class="ff-msg"></span>
|
||||
</div>
|
||||
<div id="ff-cfg-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 3.3 js/ff.js (paneInit.ff)
|
||||
|
||||
```javascript
|
||||
/* ff.js — 측류추출 유량 권장(FF) 대시보드 + 설정 에디터.
|
||||
대시보드는 공개 읽기(/api/ff/advisory), 설정은 admin(X-Kb-Token). */
|
||||
paneInit.ff = ffInit;
|
||||
|
||||
let ffTimer = null;
|
||||
|
||||
function ffToken() { return sessionStorage.getItem('kbToken') || ''; }
|
||||
async function ffApiAdmin(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('UNAUTH');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
||||
return res.status === 204 ? null : res.json();
|
||||
}
|
||||
|
||||
async function ffInit() {
|
||||
// 재진입 시 폴링 중복 방지
|
||||
if (ffTimer) { clearInterval(ffTimer); ffTimer = null; }
|
||||
await ffLoadDash();
|
||||
ffTimer = setInterval(ffLoadDash, 3000);
|
||||
|
||||
document.getElementById('ff-cfg-toggle').onclick = () => {
|
||||
const c = document.getElementById('ff-cfg');
|
||||
c.style.display = c.style.display === 'none' ? 'block' : 'none';
|
||||
};
|
||||
document.getElementById('ff-unlock').onclick = ffUnlock;
|
||||
document.getElementById('ff-new').onclick = () => ffEditColumn(null);
|
||||
if (ffToken()) ffEnableAdmin();
|
||||
}
|
||||
|
||||
// ── 대시보드 (공개) ──────────────────────────────────────────────
|
||||
async function ffLoadDash() {
|
||||
let data;
|
||||
try { data = await api('GET', '/api/ff/advisory'); }
|
||||
catch (e) { return; } // 일시 오류 무시(다음 폴링)
|
||||
const host = document.getElementById('ff-dash');
|
||||
if (!host) { clearInterval(ffTimer); ffTimer = null; return; } // 탭 떠남
|
||||
const cols = data.columns || [];
|
||||
if (!cols.length) { host.innerHTML = '<div class="ff-empty">활성 컬럼 없음</div>'; return; }
|
||||
host.innerHTML = cols.map(ffCard).join('');
|
||||
}
|
||||
|
||||
function ffTrendIco(t) { return t > 0 ? '▲' : t < 0 ? '▼' : '–'; }
|
||||
|
||||
function ffCard(c) {
|
||||
const rows = (c.streams || []).map(s => `
|
||||
<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)}">${esc(s.role)}</span></td>
|
||||
<td class="ff-num">${fmtVal(s.pv)}</td>
|
||||
<td class="ff-num ff-rec">${s.recommendedSp==null?'–':fmtVal(s.recommendedSp)}</td>
|
||||
<td class="ff-num">${s.gap==null?'–':fmtVal(s.gap)}</td>
|
||||
<td>${ffTrendIco(s.trend)}</td>
|
||||
<td><span class="ff-grade ff-grade-${esc(s.grade)}">${esc(s.grade)}</span></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.yield!=null ? ` · 수율 ${fmtVal(c.yield)}%` : '');
|
||||
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>
|
||||
<span class="ff-time">${fmtTs(c.computedAt)}</span></div>
|
||||
${banner}
|
||||
<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>
|
||||
<div class="ff-note">D·B는 레벨 제어가 구동(기대치). 권장값은 참고 — 인가는 운전원.</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── 설정 에디터 (admin) ──────────────────────────────────────────
|
||||
async function ffUnlock() {
|
||||
const tok = document.getElementById('ff-token').value.trim();
|
||||
if (!tok) return;
|
||||
// KB 로그인 재사용: 토큰 유효성은 첫 admin 호출에서 검증
|
||||
sessionStorage.setItem('kbToken', tok);
|
||||
try { await ffLoadConfig(); ffEnableAdmin(); ffMsg('잠금 해제됨'); }
|
||||
catch (e) { sessionStorage.removeItem('kbToken'); ffMsg('토큰 무효', true); }
|
||||
}
|
||||
function ffEnableAdmin() { document.getElementById('ff-new').disabled = false; ffLoadConfig().catch(()=>{}); }
|
||||
function ffMsg(m, err) { const e=document.getElementById('ff-cfg-msg'); e.textContent=m; e.className='ff-msg'+(err?' err':''); }
|
||||
|
||||
async function ffLoadConfig() {
|
||||
const data = await ffApiAdmin('GET', '/api/ff/config');
|
||||
const host = document.getElementById('ff-cfg-list');
|
||||
host.innerHTML = (data.columns||[]).map(ffCfgRow).join('') || '<div class="ff-empty">설정 없음</div>';
|
||||
host.querySelectorAll('[data-edit]').forEach(b => b.onclick = () =>
|
||||
ffEditColumn(data.columns.find(c => c.id == b.dataset.edit)));
|
||||
host.querySelectorAll('[data-del]').forEach(b => b.onclick = () => ffDelete(b.dataset.del));
|
||||
}
|
||||
function ffCfgRow(c) {
|
||||
return `<div class="ff-cfg-item"><b>${esc(c.name)}</b> (id ${c.id}) — feed ${esc(c.feedTag)},
|
||||
스트림 ${c.streams.length}개, ${c.enabled?'활성':'비활성'}
|
||||
<button class="btn sm" data-edit="${c.id}">편집</button>
|
||||
<button class="btn sm danger" data-del="${c.id}">삭제</button></div>`;
|
||||
}
|
||||
async function ffDelete(id) {
|
||||
if (!confirm(`컬럼 ${id} 삭제?`)) return;
|
||||
try { await ffApiAdmin('DELETE', `/api/ff/config/${id}`); await ffLoadConfig(); ffMsg('삭제됨'); }
|
||||
catch (e) { ffMsg(e.message==='UNAUTH'?'권한 없음':'삭제 실패', true); }
|
||||
}
|
||||
|
||||
// 간단 JSON 편집(턴키 최소형). Phase II-b에서 폼 위젯화 권장.
|
||||
function ffEditColumn(c) {
|
||||
const tmpl = c || { name:'', enabled:false, feedTag:'', pressureTag:null, levelTags:[],
|
||||
scanSec:2, feedFilterTauSec:300, feedMoveThresholdPerMin:5, pressFilterTauSec:60,
|
||||
pressureBand:3, settleSec:1800, staleSec:120, productKey:'P',
|
||||
streams:[{key:'P',flowTag:'',role:'Commanded',targetCoeff:0.95,thetaUpSec:60,thetaDnSec:60,
|
||||
tauSec:900,spMin:0,spMax:1e9,rateUpPerMin:30,rateDnPerMin:60,refluxFromProduct:false,grade:'A'}] };
|
||||
const json = prompt('컬럼 설정(JSON) 편집:', JSON.stringify(tmpl));
|
||||
if (!json) return;
|
||||
let body; try { body = JSON.parse(json); } catch { return ffMsg('JSON 파싱 오류', true); }
|
||||
if (c) body.id = c.id;
|
||||
ffApiAdmin('POST', '/api/ff/config', body)
|
||||
.then(() => { ffLoadConfig(); ffMsg('저장됨'); })
|
||||
.catch(e => ffMsg(e.message==='UNAUTH'?'권한 없음':'저장 실패: '+e.message, true));
|
||||
}
|
||||
```
|
||||
|
||||
> 에디터는 **턴키 최소형(JSON prompt)** 으로 제공. 운전원용 폼 위젯(스트림 행 추가/삭제, 검증)은 **Phase II-b**에서
|
||||
> 고도화 권장(본 문서 범위 밖). 대시보드는 완성형.
|
||||
|
||||
### 3.4 css/ff.css (요지)
|
||||
|
||||
```css
|
||||
.ff-wrap{padding:16px;color:var(--t1)}
|
||||
.ff-head{display:flex;align-items:center;gap:12px;margin-bottom:12px}
|
||||
.ff-badge{font-size:12px;color:var(--t2);border:1px solid var(--bd);border-radius:10px;padding:2px 8px}
|
||||
.ff-dash{display:grid;grid-template-columns:repeat(auto-fill,minmax(420px,1fr));gap:12px}
|
||||
.ff-col-card{background:var(--bg2);border:1px solid var(--bd);border-radius:8px;padding:12px}
|
||||
.ff-col-card.ff-disabled{opacity:.5}
|
||||
.ff-col-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
|
||||
.ff-transient{background:#3a2e00;color:#ffd24d;padding:4px 8px;border-radius:4px;font-size:13px;margin:4px 0}
|
||||
.ff-tbl{width:100%;border-collapse:collapse;font-size:13px}
|
||||
.ff-tbl th,.ff-tbl td{padding:3px 6px;border-bottom:1px solid var(--bd);text-align:left}
|
||||
.ff-num{text-align:right;font-variant-numeric:tabular-nums}
|
||||
.ff-rec{font-weight:600;color:#7fd1ff}
|
||||
.ff-stale{opacity:.45}
|
||||
.ff-role-LevelDriven{color:#9aa}.ff-role-Monitor{color:#777}.ff-role-Commanded{color:#7fd1ff}
|
||||
.ff-grade-A{color:#4caf50}.ff-grade-B{color:#ffb300}.ff-grade-C{color:#ff5252}
|
||||
.ff-mb,.ff-note{font-size:12px;color:var(--t2);margin-top:6px}
|
||||
.ff-msg.err{color:#ff5252}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 검증 절차 (diagnosis-checklist.md 8단계)
|
||||
|
||||
### STEP 1~2 맥락·구조
|
||||
- 레이어: Web 컨트롤러(읽기 공개 + 설정 admin), Infra 스토어(파라미터화 CRUD), 프론트 탭.
|
||||
- 변경 파일 4 / 신규 3. 제어 레지스터 무관.
|
||||
|
||||
### STEP 3 코드 읽기
|
||||
순서: IFeedforwardStores(인터페이스) → ConfigStore(Save/Delete) → Controller → ff.js → ff.html/css.
|
||||
|
||||
### STEP 4 호출계층 지도
|
||||
```
|
||||
[대시보드] 브라우저 setInterval(3s) → GET /api/ff/advisory (공개) → AdvisoryStore (read)
|
||||
[설정] ff.js (X-Kb-Token) → GET/POST/DELETE /api/ff/config → IsAdminAsync 게이트
|
||||
→ ConfigStore.Save/Delete (트랜잭션·파라미터화) → ff_* 테이블
|
||||
─ 제어 SP/OP 쓰기 경로 없음 (WriteTagAsync/SetModeAsync 미참조) ─
|
||||
```
|
||||
|
||||
### STEP 5 패턴 매칭 (자가 사전점검)
|
||||
| 체크 | 상태 |
|
||||
|:-----|:-----|
|
||||
| **SQL 인젝션** | CRUD 전 컬럼 **파라미터 바인딩**(`P()` 헬퍼) — f-string/concat 없음 ✅ |
|
||||
| **인증** | 모든 변경/설정조회 엔드포인트 `IsAdminAsync` 선검사, 401 반환 ✅ |
|
||||
| **XSS** | 렌더는 `esc()` 경유, 숫자는 `fmtVal` ✅ |
|
||||
| **폴링 누수** | `ffInit` 재진입 시 `clearInterval` 선행, host 없으면(탭 이탈) 타이머 정지 ✅ |
|
||||
| **camelCase 응답** | `MapConfig`/`MapColumn` 명시 익명객체 ✅ |
|
||||
| **제어 쓰기 0건** | Control/Controllers grep `WriteTagAsync|SetModeAsync` = 0 (불변식 유지) ★ |
|
||||
| 트랜잭션 | Save는 컬럼+스트림 원자적(`BeginTransactionAsync`/`Commit`) ✅ |
|
||||
| 커넥션 | EF 소유 — 열기만 보장, 닫지 않음(스코프 종료 시 정리) |
|
||||
|
||||
### STEP 6 교차검증 (Q1~Q4)
|
||||
- 설정 조회를 admin으로? Q3: 엔지니어링 값이라 의도. (대시보드 읽기는 공개)
|
||||
- JSON prompt 에디터: Q4 장애? 잘못된 JSON은 파싱 catch로 무해. 운영 편의는 Phase II-b 폼으로.
|
||||
- 토큰 미검증 unlock: 토큰을 sessionStorage에 넣고 **첫 admin 호출에서 검증**(401 시 제거) — docs.js와 동일.
|
||||
|
||||
### STEP 7 심각도 가이드
|
||||
- HIGH: 인젝션/인증우회/빌드실패(없어야 함).
|
||||
- MED: 트랜잭션 누락(부분 저장), 폴링 중복.
|
||||
- LOW: 에디터 UX, 미사용 CSS.
|
||||
|
||||
### STEP 8 보고서 양식 — 감독자 채움(파일:줄 인용).
|
||||
|
||||
---
|
||||
|
||||
## 5. 빌드/런타임 검증 (감독자 승인 후)
|
||||
- `dotnet build src/Web/ExperionCrawler.csproj` 경고0/에러0.
|
||||
- **제어 쓰기 불변식**: `grep -rn "WriteTagAsync\|SetModeAsync" src/Infrastructure/Control src/Web/Controllers/FeedforwardController.cs` → 0건.
|
||||
- admin 미인증 `POST /api/ff/config` → 401, 인증 후 저장→`GET /api/ff/config` 반영.
|
||||
- 인젝션: `name`에 `'); DROP TABLE ff_stream_config;--` 넣어도 **리터럴 저장**(파라미터화) 확인.
|
||||
- 브라우저: Tab 18 진입 → 대시보드 3초 폴링, 과도 배너/신뢰등급 색/레벨주석 표시, 다른 탭 이동 시 폴링 정지(타이머 누수 없음).
|
||||
- camelCase: 응답 필드가 `recommendedSp`·`massBalanceState` 등으로 옴(undefined 없음).
|
||||
|
||||
---
|
||||
|
||||
## 6. Phase II-분석 훅 (별도 진행 — 본 UI 문서 범위 밖)
|
||||
|
||||
PhaseI §6 P-1~P-5(θ 자동튜닝·PCT/차온·front-position·confidence 자동강등·느린 바이어스)는
|
||||
**분석 엔진 확장**이라 UI와 분리. 본 UI는 그 산출을 표시할 **자리만** 둔다:
|
||||
- `StreamAdvisory.Grade`(이미 표시) ← confidence 자동강등(P-5) 연결점.
|
||||
- 컬럼 카드 `ff-note`/배너 ← sweet-spot 드리프트 경고(P-3) 표시 위치.
|
||||
- 설정에 `tempTags`·`analyzerTag`·`dTdP`·`pRef` 필드 추가 시 ColumnConfig 확장(P-2) — DDL ALTER + 로더/CRUD 컬럼 추가로 후속.
|
||||
|
||||
---
|
||||
|
||||
## 7. 턴키 상태 & 잔여
|
||||
**턴키**: 백엔드 CRUD(파라미터화)·컨트롤러·프론트(대시보드 완성, 에디터 최소형)·index 와이어링·검증절차 모두 포함.
|
||||
**구현 순서**: ① 인터페이스+ConfigStore CRUD → ② 컨트롤러(IKbAuthService 주입) → ③ index.html 3줄 + ff.html/ff.js/ff.css → ④ build → ⑤ admin 토큰으로 CRUD·인젝션·폴링 검증.
|
||||
**잔여(판단/후속)**: 에디터 폼 위젯화(II-b), `AdvisoryStore.Remove`로 삭제 컬럼 정리, 분석 훅 P-1~P-5.
|
||||
|
||||
---
|
||||
|
||||
## 8. Phase I 회고 — 인증 제거 (2026-05-31 적용)
|
||||
|
||||
### 배경
|
||||
Phase I 엔진은 **어디에도 Experion SP/OP 쓰기 코드가 없다**(`WriteTagAsync`/`SetModeAsync` 0건).
|
||||
그런데 FF 설정 CRUD API에 `IKbAuthService`(KB admin 토큰) 인증이 붙어 있어,
|
||||
운전원이 대시보드를 보기 위해 RAG 관리 탭 로그인이 필요하거나 별도 토큰을 입력해야 했다.
|
||||
|
||||
advisory(보조지표)는 늘상 운전원이 봐야 하는 페이지인데, 보지 못하게 막는 진입장벽이 불합리했다.
|
||||
|
||||
### 적용 변경
|
||||
|
||||
| 레이어 | 변경 내용 |
|
||||
|:-------|:----------|
|
||||
| `FeedforwardController.cs` | `IKbAuthService` 의존성 및 `IsAdminAsync()` 가드 제거. config CRUD 엔드포인트 인증 없이 동작 |
|
||||
| `ff.js` | `ffApiAdmin`(토큰 헤더) → `ffApi`(인증 없음). `ffUnlock`/`ffEnableAdmin` 제거. `ffInit`에서 바로 설정 로드 |
|
||||
| `ff.html` | `#ff-token` input + `#ff-unlock` 버튼 제거. `#ff-new` disabled 해제 |
|
||||
|
||||
### 향후 재도입 시점 (Phase III)
|
||||
RSP 쓰기(Experion SP/OP write)가 실제로 구현되는 **Phase III**에서 `IKbAuthService`를 다시 주입하고
|
||||
프론트에 토큰 입력 UI를 복원한다. 그때는 쓰기 동작이 있으므로 인증이 정당화된다.
|
||||
337
docs/측류추출식-통합유량설정공식-구현코딩-PhaseIII.md
Normal file
337
docs/측류추출식-통합유량설정공식-구현코딩-PhaseIII.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# 측류추출 통합유량 — Phase III Auto-Write 구현 코딩 (RSP → DCS SP 쓰기)
|
||||
|
||||
> **성격**: Phase I(advisory engine) + Phase II(UI 대시보드)에서 계산한 권장 SP(RSP)를 **DCS Setpoint에 직접 쓰는(auto-write) 기능**.
|
||||
> Phase I 불변식("제어 레지스터 쓰기 0건")을 해제 — **단, 안전장치(WriteGuard)를 동반**.
|
||||
|
||||
---
|
||||
|
||||
## 0. Phase 분할 현황
|
||||
|
||||
| 범위 | Phase I (완료) | Phase II (완료) | Phase III (본 문서) |
|
||||
|:-----|:----|:----|:----|
|
||||
| 엔진 | FeedforwardEngine.Tick | θ 자동튜닝·바이어스 적응 (분석) | — |
|
||||
| 출력 | 권장 SP 저장 + 읽기 API | 대시보드 시각화 (흐림/선명) | **DCS SP 쓰기** |
|
||||
| 제어 | Advisory(쓰기 0건) | Advisory(쓰기 0건) | Auto-write (조건부 쓰기) |
|
||||
| 설정 | DB 테이블 + 로더 | Web UI 설정 에디터 | Auto-write On/Off 스위치 |
|
||||
| 안전 | — | — | **WriteGuard + 워치독** |
|
||||
|
||||
---
|
||||
|
||||
## 1. 핵심 설계 원칙
|
||||
|
||||
### 1.1 Auto-write 조건
|
||||
|
||||
**RSP를 DCS SP에 쓸 수 있는 조건은 다음 두 가지를 모두 만족해야 함:**
|
||||
|
||||
```
|
||||
WriteCondition = (Grade == A) AND (!Transient)
|
||||
```
|
||||
|
||||
| 조건 | 의미 | 위반 시 조치 |
|
||||
|:-----|:-----|:------------|
|
||||
| **Grade == A** | 모델 파라미터(K, θ, τ)가 잘 튜닝됨 | Grade B/C면 쓰지 않음 |
|
||||
| **!Transient** | Feed/압력 안정 + 정착 대기 완료 | Transient 중이면 이전 SP 홀드 |
|
||||
|
||||
### 1.2 Grade별 Auto-write 정책
|
||||
|
||||
| Grade | Auto-write 허용 | 근거 |
|
||||
|:------|:---------------|:------|
|
||||
| **A** | ✅ 허용 | K/θ/τ 튜닝 양호, 권장값 신뢰 가능 |
|
||||
| **B** | ❌ 금지 | 튜닝 불확실성 존재, 운전원 수동 인가 필요 |
|
||||
| **C** | ❌ 금지 | 모델 자체를 신뢰할 수 없음, 진단 우선 |
|
||||
|
||||
### 1.3 Transient 상태별 Auto-write 정책
|
||||
|
||||
| 상태 | Auto-write | RSP 처리 | DCS SP 처리 |
|
||||
|:-----|:----------|:---------|:------------|
|
||||
| 정상(안정) | ✅ 허용 | 최신 RSP로 갱신 | RSP = DCS SP |
|
||||
| FEED 이동 중 | ❌ 홀드 | RSP는 계산 계속 (변함) | **이전 SP 유지** |
|
||||
| 압력 불안정 | ❌ 홀드 | RSP는 계산 계속 (변함) | **이전 SP 유지** |
|
||||
| 정착 대기 중 | ❌ 홀드 | RSP는 정상 계산 | **이전 SP 유지** |
|
||||
| FEAD BAD (Hold) | ❌ 홀드 | RSP = 이전 LastRec | **이전 SP 유지** |
|
||||
|
||||
### 1.4 홀드 동작 상세
|
||||
|
||||
Transient 진입 시:
|
||||
|
||||
```
|
||||
1. Transient 발생 (moving / pUnstable)
|
||||
2. 현재 DCS SP 값을 snapshot으로 저장 (LastWrittenSP)
|
||||
3. Transient가 지속되는 동안 DCS SP = LastWrittenSP 유지
|
||||
4. Transient 해제 후:
|
||||
a. Grade == A → RSP로 즉시 갱신 (RSP는 transient 중에도 계속 계산됨)
|
||||
b. Grade != A → 쓰지 않음, 운전원 수동 인가 대기
|
||||
```
|
||||
|
||||
**RSP는 transient 중에도 매 틱 갱신되므로**, transient 해제 시점에는 이미 최신 Feed를 반영한 값으로 부드럽게 전환됨 — ramp-up이 필요 없음.
|
||||
|
||||
---
|
||||
|
||||
## 2. WriteGuard 아키텍처
|
||||
|
||||
### 2.1 이중 조건 검증
|
||||
|
||||
```
|
||||
[FF Engine] → RSP
|
||||
│
|
||||
▼
|
||||
[WriteGuard]
|
||||
├─ Grade == A ?
|
||||
├─ !Transient ?
|
||||
└─ SP 변동폭 < SafetyLimit ?
|
||||
│
|
||||
통과 ?──┤
|
||||
│
|
||||
YES NO
|
||||
│ │
|
||||
[Write] [Hold]
|
||||
```
|
||||
|
||||
### 2.2 SP 변동폭 제한 (SafetyLimit)
|
||||
|
||||
추가 안전장치로, 한 번에 변경 가능한 SP 폭에 상한을 둠:
|
||||
|
||||
```
|
||||
|RSP - CurrentDCS_SP| > SafetyMaxDelta
|
||||
→ 쓰지 않고 경고 로그, 운전원 확인 대기
|
||||
```
|
||||
|
||||
제안값: `SafetyMaxDelta = RateUpPerMin × 5min` (5분 분량의 최대 변화율)
|
||||
|
||||
### 2.3 워치독 (Watchdog Timer)
|
||||
|
||||
```
|
||||
- Auto-write 활성화 후 일정 시간(예: 30분) 동안
|
||||
Feed/압력 변화가 1회도 없으면 → 워치독 알람
|
||||
- 의미: "RSP가 너무 오래 같음 — DCS가 FF를 추종 중인지 확인"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. DCS 쓰기 인터페이스
|
||||
|
||||
### 3.1 쓰기 방식
|
||||
|
||||
DCS SP 쓰기는 OPC UA **Write 서비스를 통해** 수행:
|
||||
|
||||
```
|
||||
OPC UA NodeId: ns=3;s="{tag_name}.sp" (예: "ficq-6118.sp")
|
||||
데이터 타입: Double
|
||||
쓰기 권한: WriteGuard 통과 시에만 호출
|
||||
```
|
||||
|
||||
### 3.2 쓰기 주기
|
||||
|
||||
```
|
||||
정상 상태: 매 Scan(2초)마다 RSP를 쓰지 않음
|
||||
→ RSP 변화 감지 시에만 Write 호출
|
||||
→ 변화 없으면 Skip (OPC UA 부하 감소)
|
||||
|
||||
Transient: 쓰지 않음 (Hold)
|
||||
최초 쓰기: Auto-write On → 즉시 1회 Write
|
||||
```
|
||||
|
||||
### 3.3 쓰기 실패 처리
|
||||
|
||||
```
|
||||
- OPC UA Write 실패 → 3회 재시도 (1초 간격)
|
||||
- 3회 실패 → Auto-write 자중단, 경고 로그
|
||||
- 운전원 UI에 "DCS 쓰기 실패" 표시
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. UI: Auto-write On/Off 제어
|
||||
|
||||
### 4.1 설정 에디터 추가 항목 (Phase II ff.js 확장)
|
||||
|
||||
```
|
||||
컬럼 편집 모달:
|
||||
|
||||
[일반 설정] [Auto-write 설정]
|
||||
컬럼명: C-6111 ☐ Auto-write 활성화
|
||||
Feed: ficq-6101 SafetyMaxDelta: ____
|
||||
... 워치독(분): ____
|
||||
|
||||
[스트림]
|
||||
Key │ Flow 태그 │ 역할 │ 레벨태그 │ K │ θ_up │ ...
|
||||
P │ ficq-6118 │ Commanded │ │ 0.95 │ 60 │ ...
|
||||
R │ ficq-6113 │ Commanded │ │ 0.80 │ 0 │ ...
|
||||
D │ ficq-6114 │ LevelDriven │ lica-6113 │ 0.02 │ ...
|
||||
B │ ficq-6116 │ LevelDriven │ li-6111 │ 0.03 │ ...
|
||||
```
|
||||
|
||||
### 4.2 대시보드 표시
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ C-6111 FEED 1000 12:34:56 │
|
||||
│ [Auto-Write ● 활성] │
|
||||
│ │
|
||||
│ 스트림 │ PV │ 권장 SP │ DCS SP │ Δ │ 신뢰 │
|
||||
│ P │ 780.2 │ 950.0 │ 950.0 │ — │ A │ ← auto-written
|
||||
│ R │ 623.0 │ 760.0 │ 623.0 │ -137 │ A │ ← 수동 (환류)
|
||||
│ D │ 20.0 │ 20.0 │ 20.0 │ — │ B │ ← auto-written
|
||||
│ B │ 30.0 │ 30.0 │ 30.0 │ — │ B │ ← auto-written
|
||||
│ │
|
||||
│ 물질수지: 정상 V_loss: +0.5 수율: 95.0% │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- DCS SP 열 추가 (현재 DCS에 쓰여진 SP 값)
|
||||
- Δ는 권장 SP - DCS SP (auto-write 중엔 보통 0)
|
||||
- Auto-write 활성 상태를 헤더에 뱃지 표시
|
||||
|
||||
### 4.3 Auto-write 활성화 조건
|
||||
|
||||
```
|
||||
※ 전체 Column 단위 On/Off (스트림 개별 아님)
|
||||
|
||||
On 전환 조건:
|
||||
- 해당 Column의 모든 스트림이 Grade A는 아니어도 됨
|
||||
- 단, Grade A인 스트림만 Auto-write 대상
|
||||
- Grade B/C 스트림은 운전원 수동 유지
|
||||
|
||||
Off 조건:
|
||||
- 운전원 수동 Off
|
||||
- WriteGuard 연속 실패 (3회)
|
||||
- 워치독 타임아웃
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. DB 변경
|
||||
|
||||
### 5.1 ff_column_config (ALTER TABLE)
|
||||
|
||||
```sql
|
||||
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS auto_write_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS safety_max_delta DOUBLE PRECISION; -- NULL=무제한
|
||||
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS watchdog_min INTEGER NOT NULL DEFAULT 30;
|
||||
```
|
||||
|
||||
### 5.2 신규 테이블: ff_write_log (감사 추적)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ff_write_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
column_id INTEGER NOT NULL REFERENCES ff_column_config(id) ON DELETE CASCADE,
|
||||
stream_key TEXT NOT NULL,
|
||||
sp_before DOUBLE PRECISION,
|
||||
sp_after DOUBLE PRECISION,
|
||||
grade TEXT NOT NULL,
|
||||
transient BOOLEAN NOT NULL,
|
||||
success BOOLEAN NOT NULL,
|
||||
error_msg TEXT,
|
||||
written_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 구현 파일 배치
|
||||
|
||||
```
|
||||
변경:
|
||||
src/Core/Application/Feedforward/
|
||||
FeedforwardModels.cs # ColumnConfig: AutoWriteEnabled, SafetyMaxDelta, WatchdogMin
|
||||
IFeedforwardStores.cs # (변경 없음)
|
||||
src/Infrastructure/Control/
|
||||
FeedforwardEngine.cs # (변경 없음 — RSP 계산 로직은 그대로)
|
||||
FeedforwardSupervisor.cs # Auto-write 로직 추가 (WriteGuard 호출)
|
||||
FeedforwardConfigStore.cs # auto_write_enabled, safety_max_delta, watchdog_min 저장/로드
|
||||
src/Infrastructure/OpcUa/
|
||||
OpcUaClientService.cs # WriteNodeAsync(tag, value) — OPC UA Write 래퍼
|
||||
src/Web/Controllers/
|
||||
FeedforwardController.cs # GET/POST config에 auto-write 필드 추가
|
||||
src/Web/wwwroot/js/ff.js # 설정 에디터 + 자동쓰기 On/Off + DCS SP 표시
|
||||
src/Web/wwwroot/css/ff.css # DCS SP 열, auto-write 뱃지 스타일
|
||||
|
||||
신규:
|
||||
src/Infrastructure/Control/
|
||||
WriteGuard.cs # 조건 검증 + SafetyLimit + 워치독
|
||||
AutoWriter.cs # 조건 충족 시 OPC UA Write 호출
|
||||
docs/측류추출식-통합유량설정공식-구현코딩-PhaseIII.md # 본 문서
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 안전 시나리오
|
||||
|
||||
### 7.1 정상 동작
|
||||
|
||||
```
|
||||
1. 운전원이 FF 설정에서 "Auto-write 활성화" ON
|
||||
2. Feed/압력 안정, Grade A
|
||||
3. RSP가 DCS SP에 자동 반영 (Scan 주기로 변화 감지 시)
|
||||
4. 운전원은 모니터링만
|
||||
5. 비정상 상황 시 수동 OFF → 즉시 쓰기 중단
|
||||
```
|
||||
|
||||
### 7.2 Transient 발생
|
||||
|
||||
```
|
||||
1. Feed 급변 → moving = true
|
||||
2. WriteGuard가 transient 감지 → Write 차단
|
||||
3. DCS SP는 마지막 값 유지 (변경 안 됨)
|
||||
4. RSP는 내부적으로 계속 계산
|
||||
5. 30분 후 transient 해제
|
||||
6. WriteGuard 조건 재확인 → Grade A → 새 RSP Write
|
||||
7. DCS SP가 최신 RSP로 점프 (ramp 불필요 — RSP는 transient 중에도 계산됐으므로)
|
||||
```
|
||||
|
||||
### 7.3 OPC UA 단절
|
||||
|
||||
```
|
||||
1. OPC UA 서버와 연결 끊김
|
||||
2. Write 실패 → 3회 재시도 → 실패
|
||||
3. Auto-write 자동 중단
|
||||
4. 운전원 UI에 "DCS 쓰기 불가" 경고
|
||||
5. 연결 복구 시 자동 재개 (선택 사항 — 운전원 확인 후 재활성화)
|
||||
```
|
||||
|
||||
### 7.4 운전원 수동 개입
|
||||
|
||||
```
|
||||
Auto-write 활성화 상태에서 운전원이 DCS SP를 수동 변경:
|
||||
→ WriteGuard 감지 (DCS SP != LastWrittenSP)
|
||||
→ 자동 쓰기 일시 중단 (예: 60초)
|
||||
→ 60초 후 WriteGuard가 조건 재확인
|
||||
→ 조건 만족: RSP로 다시 쓰기 (운전원 변경 덮어씀)
|
||||
→ 조건 불만족: 중단 유지
|
||||
|
||||
※ 운전원 변경을 존중하려면:
|
||||
"수동 변경 감지 시 auto-write 영구 중단" 정책도 고려
|
||||
→ UI에 "운전원 수동 변경 감지 — Auto-write 중단됨" 표시
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Phase III 마일스톤
|
||||
|
||||
| 단계 | 내용 | 비고 |
|
||||
|:-----|:-----|:------|
|
||||
| **M1** | WriteGuard 구현 (조건 검증 + SafetyLimit) | 단위테스트 |
|
||||
| **M2** | OPC UA Write 래퍼 (WriteNodeAsync) | 기존 OpcUaClientService 확장 |
|
||||
| **M3** | AutoWriter 구현 (Guard → Write) + ff_write_log | 통합테스트 |
|
||||
| **M4** | Supervisor에 Auto-wire 루프 통합 + Transient 홀드 | Supervisor 확장 |
|
||||
| **M5** | DB: 컬럼 추가 + Config Store 확장 | 마이그레이션 |
|
||||
| **M6** | UI: 설정 에디터(Auto-write On/Off, SafetyLimit) + DCS SP 열 | ff.js 확장 |
|
||||
| **M7** | UI: Auto-write 뱃지 + 쓰기 실패 표시 | 대시보드 확장 |
|
||||
| **M8** | 종합 테스트 (시나리오 7.1~7.4) | 감독자 진단 |
|
||||
|
||||
---
|
||||
|
||||
## 9. Phase I 불변식 해제 선언
|
||||
|
||||
```
|
||||
Phase I 불변식: "제어 레지스터(SP/OP)에 쓰기 호출 0건"
|
||||
Phase III에서 해제. 단, 아래 조건을 모두 만족해야 함:
|
||||
|
||||
✅ WriteGuard가 쓰기 허가를 내린 경우에만 Write
|
||||
✅ Grade A && !Transient
|
||||
✅ SafetyMaxDelta 초과 시 Write 금지
|
||||
✅ 쓰기 실패 3회 → 자동 중단
|
||||
✅ 운전원 수동 OFF 가능
|
||||
✅ 모든 쓰기 내역은 ff_write_log에 기록 (감사 추적)
|
||||
```
|
||||
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>
|
||||
1168
docs/측류추출식-통합유량설정공식.md
Normal file
1168
docs/측류추출식-통합유량설정공식.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -98,3 +98,133 @@ S = K_S × F
|
||||
따라서 DCS 로직을 설계할 때는 완전 비례식만 적용하기보다, 운전원이 공정 상태를 보며 미세 조정할 수 있도록 **보정값(Bias)** 튜닝 탭을 반드시 확보해야 합니다.
|
||||
|
||||
특히 스팀 제어의 경우, 앞서 언급한 **탑 내부 차압[f(ΔP)]에 따른 감쇄(Override) 제어 알고리즘**을 상기 스팀 설정식에 더하여(Cascade/Override 구조) 안전성을 이중으로 확보해야 합니다.
|
||||
|
||||
|
||||
## 리플럭스량 계산 재검토
|
||||
|
||||
의문이 맞습니다. **리플럭스량 350 → 33으로 줄어드는 것은 공정적으로 문제가 있습니다.**
|
||||
|
||||
### 🔍 문제점 분석
|
||||
|
||||
제 이전 계산에서 사용한 공식:
|
||||
R = R_f × D = 2.0 × 16.36 = 32.72
|
||||
|
||||
|
||||
이 공식은 **관계식 문서의 일반 증류탑 공식**입니다. 하지만 **측류 추출식(C-6111)** 에는 적합하지 않습니다.
|
||||
|
||||
### 📐 측류 추출식에서의 리플럭스량
|
||||
|
||||
측류 추출식에서는 리플럭스량이 **상부 제거량(D)** 이 아니라 **원료량(F)** 또는 **제품량(P)** 과 연동됩니다.
|
||||
|
||||
| 구분 | 현재 운전 | 설계 기준 (R/F) | 설계 기준 (R/P) |
|
||||
|------|----------|----------------|----------------|
|
||||
| **리플럭스량 (R)** | 350 | — | — |
|
||||
| **원료량 (F)** | 818.13 | — | — |
|
||||
| **제품량 (P)** | 481.05 | — | — |
|
||||
| **R/F 비율** | 0.428 | 0.5~1.0 | — |
|
||||
| **R/P 비율** | 0.727 | — | 0.8~1.2 |
|
||||
|
||||
### 📊 올바른 리플럭스량 산출
|
||||
|
||||
**현재 운전 상태의 R/F 비율(0.428)을 유지하거나, 설계 기준(R/F = 0.5~1.0) 적용:**
|
||||
|
||||
| 시나리오 | 계산식 | **새 SP값** | 현재 SP |
|
||||
|----------|--------|------------|---------|
|
||||
| **현재 R/F 유지** | 0.428 × 818.13 | **350** | 350 |
|
||||
| **설계 R/F = 0.5** | 0.5 × 818.13 | **409** | 350 |
|
||||
| **설계 R/F = 0.8** | 0.8 × 818.13 | **655** | 350 |
|
||||
| **설계 R/F = 1.0** | 1.0 × 818.13 | **818** | 350 |
|
||||
|
||||
### 💡 권고
|
||||
|
||||
1. **리플럭스량 33은 너무 작음** — 컬럼 내 기-액 평형 유지 불가
|
||||
2. **현재 운전 상태(R=350, R/F=0.428)는 합리적** — 반도체 등급 PGMEA 증류에 적합한 범위
|
||||
3. **리플럭스량 조정 시** — R/F = 0.5~0.8 범위에서 점진적 조정 권장
|
||||
|
||||
### 📋 수정된 SP값 산출 (리플럭스량 제외)
|
||||
|
||||
| 태그명 | 역할 | 계산식 | **새 SP값** | 현재 SP |
|
||||
|--------|------|--------|------------|---------|
|
||||
| ficq-6114 | 경비물 제거량 (D) | 0.02 × 818.13 | **16.36** | 85.5 |
|
||||
| ficq-6118 | 제품 추출량 (P) | 0.95 × 818.13 | **777.22** | 485 |
|
||||
| ficq-6116 | 하부 제거량 (B) | 0.03 × 818.13 | **24.54** | 53.2 |
|
||||
| ficq-6113 | 리플럭스량 (R) | **현재 R/F 유지** | **350** | 350 |
|
||||
|
||||
**리플럭스량은 현재 상태를 유지하고, D/P/B만 조정하시기 바랍니다.**
|
||||
|
||||
1. 측류 추출식 C-6111 — 통합 유량 설정 공식
|
||||
📐 기본 물질수지
|
||||
F = D + P + B
|
||||
|
||||
F: 원료투입량 (kg/hr)
|
||||
P: 제품 추출량 (kg/hr) ← 주요 제어 변수
|
||||
D: 경비물 제거량 (kg/hr)
|
||||
B: 하부 제거량 (kg/hr)
|
||||
📐 분할 비율 (설계 기준)
|
||||
항목 계수 비율 근거
|
||||
P k_P 0.95 주 제품 fraction
|
||||
D k_D 0.02 경비물 fraction
|
||||
B k_B 0.03 중비물 fraction
|
||||
k_D + k_P + k_B = 1.00
|
||||
📐 P 기준 환산식
|
||||
설정값 공식 예 (P=777.2)
|
||||
F P ÷ k_P 777.2 ÷ 0.95 = 818.1
|
||||
D (k_D/k_P) × P (0.02/0.95) × 777.2 = 16.36
|
||||
B (k_B/k_P) × P (0.03/0.95) × 777.2 = 24.54
|
||||
📐 환류량 (온도보정 포함)
|
||||
외부 환류 (FICQ 계측값):
|
||||
R_ext = R_f × P
|
||||
|
||||
R_f: 제품기준 환류비 (설계 0.5~1.2)
|
||||
현재 R_f = 350/777.2 = 0.45
|
||||
내부 환류 (과냉각 보정):
|
||||
R_int = R_ext × (1 + c_p × ΔT / λ)
|
||||
|
||||
c_p: PGMEA 비열 (kJ/kg·K)
|
||||
λ: PGMEA 증발잠열 (kJ/kg)
|
||||
ΔT: T_top - T_reflux_drum (°C)
|
||||
= 컬럼 상부 온도 - 환류 drum 온도
|
||||
보정계수 TCF = 1 + c_p × ΔT / λ
|
||||
📐 컬럼 내부 실제 L/V 비
|
||||
L/V = R_int / (R_int + P)
|
||||
2. 실시간 데이터 적용
|
||||
현재 컬럼 온도 Profile
|
||||
태그 위치 PV (°C)
|
||||
TICA-6111A 최하부 (Reboiler) 79.6
|
||||
TI-6111B 원료투입구 위 중부 79.1
|
||||
TI-6111C 제품추출 노즐 근처 중상부 78.8
|
||||
TI-6111D 상부 제품추출 노즐 근처 77.8
|
||||
→ 컬럼 상부 온도(T_top) = 77.8°C (TI-6111D)
|
||||
현재 유량 현황
|
||||
태그 역할 PV
|
||||
FICQ-6101 F (원료) 820.7
|
||||
FICQ-6114 D (경비물) 17.4
|
||||
FICQ-6118 P (제품) 784.0
|
||||
FICQ-6116 B (하부) 25.2
|
||||
FICQ-6113 R_ext (환류) 350.7
|
||||
→ P/F = 784/820.7 = 0.955 (설계 0.95와 일치 ✓)
|
||||
온도보정 환류량 계산
|
||||
PGMEA 물성치 (일반값, 실제 PGMEA 순도/압력에 따라 조정 필요):
|
||||
물성 기호 값
|
||||
비열 (액체) c_p 2.0
|
||||
증발잠열 λ 330
|
||||
c_p/λ 0.00606
|
||||
ΔT를 추정할 환류 drum 온도 센서가 확인되지 않습니다. TI-6111D=77.8°C이므로, ΔT 시나리오별 결과:
|
||||
ΔT (°C) TCF R_int (R_f=0.8, P=777.2) R_int (R_f=0.45, P=777.2)
|
||||
0 (포화) 1.000 622 350
|
||||
5 1.030 641 361
|
||||
10 1.061 660 371
|
||||
15 1.091 679 382
|
||||
20 1.121 697 392
|
||||
권장 설정값 (R_f=0.8, ΔT=5°C 가정)
|
||||
설정값 공식 계산값 현재 SP
|
||||
F (FICQ-6101) P ÷ 0.95 818.1 820
|
||||
D (FICQ-6114) 0.02/0.95 × P 16.4 16.7
|
||||
P (FICQ-6118) (기준) 777.2 777.2
|
||||
B (FICQ-6116) 0.03/0.95 × P 24.5 24.54
|
||||
R_ext (FICQ-6113) R_f × P 622 350
|
||||
R_int (보정 후) 622 × 1.030 641 -
|
||||
⚠️ 확인 필요 사항
|
||||
1. 환류 drum 온도 센서 (reflux drum TI) 태그를 찾지 못했습니다 — ΔT 확인 후 TCF 재계산 필요
|
||||
2. PGMEA 물성치(c_p, λ) 는 일반값입니다 — 실제 분석 데이터로 대체 권장
|
||||
3. R_f = 0.8은 설계 범위 내 첫 권장값 — 순도 결과 보며 0.5~1.2 범위에서 조정
|
||||
@@ -1 +1 @@
|
||||
{"vllm_model":"Qwen3.6-35B-A3B-FP8"}
|
||||
{"vllm_model":"Qwen3.6-35B-A3B-FP8","temperature":0.1}
|
||||
|
||||
1
mcp-server/verifier/logs/2026-05-30.jsonl
Normal file
1
mcp-server/verifier/logs/2026-05-30.jsonl
Normal file
@@ -0,0 +1 @@
|
||||
{"ts": 1780099383.3920546, "tool": "query_events", "params": {"tag_name": "ficq-611%", "area": null}, "verifier_error": {"verifier_error": "R1.invalid_tag_format", "hint": "태그 형식 비정상: 'ficq-611%'. 예시: ficq-6113.pv, p-6102"}}
|
||||
124
src/Core/Application/Feedforward/FeedforwardModels.cs
Normal file
124
src/Core/Application/Feedforward/FeedforwardModels.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ExperionCrawler.Core.Application.Feedforward;
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
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; } = "";
|
||||
public string FlowTag { get; init; } = "";
|
||||
public StreamRole Role { get; init; } = StreamRole.Monitor;
|
||||
public string? LevelTag { get; init; }
|
||||
public double TargetCoeff { get; init; }
|
||||
public double ThetaUpSec { get; init; }
|
||||
public double ThetaDnSec { get; init; }
|
||||
public double TauSec { get; init; }
|
||||
public double SpMin { get; init; }
|
||||
public double SpMax { get; init; } = double.MaxValue;
|
||||
public double RateUpPerMin { get; init; } = double.MaxValue;
|
||||
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
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public string Name { get; init; } = "";
|
||||
public bool Enabled { get; init; }
|
||||
public bool AdvisoryOnly { get; init; } = true;
|
||||
public string FeedTag { get; init; } = "";
|
||||
public string? PressureTag { get; init; }
|
||||
public IReadOnlyList<string> LevelTags { get; init; } = Array.Empty<string>();
|
||||
public double ScanSec { get; init; } = 2.0;
|
||||
public double FeedFilterTauSec { get; init; } = 300.0;
|
||||
public double FeedMoveThresholdPerMin { get; init; } = 0.0;
|
||||
public double PressFilterTauSec { get; init; } = 60.0;
|
||||
public double PressureBand { get; init; } = double.MaxValue;
|
||||
public double SettleSec { get; init; } = 0.0;
|
||||
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);
|
||||
|
||||
public sealed record PvSnapshot(
|
||||
TagSample Feed,
|
||||
TagSample? Pressure,
|
||||
IReadOnlyList<TagSample> Levels,
|
||||
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,
|
||||
double Pv, double? RecommendedSp, double? Gap,
|
||||
int Trend,
|
||||
bool Valid,
|
||||
Confidence Grade,
|
||||
string? LevelTag,
|
||||
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)
|
||||
{
|
||||
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);
|
||||
36
src/Core/Application/Feedforward/IFeedforwardStores.cs
Normal file
36
src/Core/Application/Feedforward/IFeedforwardStores.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
namespace ExperionCrawler.Core.Application.Feedforward;
|
||||
|
||||
public interface IFeedforwardConfigStore
|
||||
{
|
||||
Task<IReadOnlyList<ColumnConfig>> LoadAllAsync(CancellationToken ct = default);
|
||||
Task<int> SaveColumnAsync(ColumnConfig cfg, CancellationToken ct = default);
|
||||
Task DeleteColumnAsync(int columnId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public interface IFeedforwardAdvisoryStore
|
||||
{
|
||||
void Set(AdvisoryResult result);
|
||||
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;
|
||||
}
|
||||
135
src/Infrastructure/Control/ComputationBlocks.cs
Normal file
135
src/Infrastructure/Control/ComputationBlocks.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
namespace ExperionCrawler.Infrastructure.Control;
|
||||
|
||||
public static class Num
|
||||
{
|
||||
public static double Clamp(double x, double lo, double hi) => Math.Max(lo, Math.Min(hi, x));
|
||||
public static bool IsFinite(double x) => !double.IsNaN(x) && !double.IsInfinity(x);
|
||||
}
|
||||
|
||||
public sealed class FirstOrderLag
|
||||
{
|
||||
private double _y;
|
||||
private bool _seeded;
|
||||
public double Value => _y;
|
||||
public bool Seeded => _seeded;
|
||||
public void Seed(double v) { _y = v; _seeded = true; }
|
||||
public double Step(double x, double tauSec, double tsSec)
|
||||
{
|
||||
if (!_seeded) { Seed(x); return _y; }
|
||||
if (tauSec <= 0.0) { _y = x; return _y; }
|
||||
var a = tsSec / (tauSec + tsSec);
|
||||
_y += (x - _y) * a;
|
||||
return _y;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MovingAverage
|
||||
{
|
||||
private readonly Queue<double> _buf = new();
|
||||
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;
|
||||
while (_buf.Count > _window) _sum -= _buf.Dequeue();
|
||||
return _sum / _buf.Count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 가변 전달지연(데드타임) 링버퍼. 용량은 요청된 최대 n 으로 **증가만**(축소 금지)하며
|
||||
/// θ(=n)가 스캔마다 바뀌어도(비대칭 θup/θdn, D7) **읽기 오프셋만 가변** — 히스토리를 보존한다.
|
||||
/// (이전 구현은 n 변경 시 Resize 로 버퍼를 재시드해 비대칭 θ에서 지연선이 매 부호반전마다 소실됨)
|
||||
/// </summary>
|
||||
public sealed class DeadTimeBuffer
|
||||
{
|
||||
private double[] _buf = Array.Empty<double>(); // 항상 가득 찬 링(시드로 사전충전)
|
||||
private int _cap; // 용량(보존 샘플 수). 증가만.
|
||||
private int _head; // 다음 덮어쓸 위치(=가장 오래된 값)
|
||||
private bool _seeded;
|
||||
|
||||
public double Through(double x, double thetaSec, double tsSec)
|
||||
{
|
||||
int n = (int)Math.Round(thetaSec / Math.Max(1e-6, tsSec));
|
||||
if (n <= 0) return x; // θ<ts → 지연 없음
|
||||
|
||||
EnsureCapacity(n + 1, x); // n 샘플 지연엔 n+1 슬롯 필요
|
||||
|
||||
_buf[_head] = x; // 가장 오래된 자리에 현재값 기록
|
||||
int newest = _head;
|
||||
_head = (_head + 1) % _cap;
|
||||
|
||||
int idx = ((newest - n) % _cap + _cap) % _cap; // n 스캔 전 값
|
||||
return _buf[idx];
|
||||
}
|
||||
|
||||
/// <summary>용량을 need 이상으로 확보(증가 전용). 기존 이력은 논리순서로 보존.</summary>
|
||||
private void EnsureCapacity(int need, double seed)
|
||||
{
|
||||
if (_seeded && need <= _cap) return;
|
||||
int newCap = Math.Max(need, Math.Max(_cap, 1));
|
||||
var nb = new double[newCap];
|
||||
if (!_seeded)
|
||||
{
|
||||
Array.Fill(nb, seed); // 최초: 현재값으로 사전충전(bumpless)
|
||||
}
|
||||
else
|
||||
{
|
||||
int extra = newCap - _cap;
|
||||
double oldest = _buf[_head];
|
||||
for (int i = 0; i < extra; i++) nb[i] = oldest; // 앞쪽 패딩=가장 오래된 값
|
||||
for (int k = 0; k < _cap; k++) nb[extra + k] = _buf[(_head + k) % _cap]; // 오래된→최신
|
||||
}
|
||||
_buf = nb; _cap = newCap; _head = 0; // head=0: 인덱스0이 가장 오래된, cap-1이 최신
|
||||
_seeded = true;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RateLimiter
|
||||
{
|
||||
private double _last;
|
||||
private bool _seeded;
|
||||
public double Last => _last;
|
||||
public void Seed(double v) { _last = v; _seeded = true; }
|
||||
public double Step(double target, double rateUpPerMin, double rateDnPerMin, double tsSec)
|
||||
{
|
||||
if (!_seeded) { Seed(target); return _last; }
|
||||
var up = Math.Abs(rateUpPerMin) * tsSec / 60.0;
|
||||
var dn = Math.Abs(rateDnPerMin) * tsSec / 60.0;
|
||||
var d = Num.Clamp(target - _last, -dn, up);
|
||||
_last += d;
|
||||
return _last;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class Derivative
|
||||
{
|
||||
private double _prev;
|
||||
private bool _seeded;
|
||||
public double Update(double x, double tsSec)
|
||||
{
|
||||
if (!_seeded) { _prev = x; _seeded = true; return 0.0; }
|
||||
var d = (x - _prev) / Math.Max(1e-6, tsSec);
|
||||
_prev = x;
|
||||
return d;
|
||||
}
|
||||
}
|
||||
|
||||
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; }
|
||||
}
|
||||
12
src/Infrastructure/Control/FeedforwardAdvisoryStore.cs
Normal file
12
src/Infrastructure/Control/FeedforwardAdvisoryStore.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ExperionCrawler.Core.Application.Feedforward;
|
||||
|
||||
namespace ExperionCrawler.Infrastructure.Control;
|
||||
|
||||
public sealed class FeedforwardAdvisoryStore : IFeedforwardAdvisoryStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<int, AdvisoryResult> _latest = new();
|
||||
public void Set(AdvisoryResult r) => _latest[r.ColumnId] = r;
|
||||
public AdvisoryResult? Get(int id) => _latest.TryGetValue(id, out var r) ? r : null;
|
||||
public IReadOnlyCollection<AdvisoryResult> GetAll() => _latest.Values.ToArray();
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
275
src/Infrastructure/Control/FeedforwardConfigStore.cs
Normal file
275
src/Infrastructure/Control/FeedforwardConfigStore.cs
Normal file
@@ -0,0 +1,275 @@
|
||||
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 FeedforwardConfigStore : IFeedforwardConfigStore
|
||||
{
|
||||
private readonly ExperionDbContext _ctx;
|
||||
private readonly ILogger<FeedforwardConfigStore> _logger;
|
||||
|
||||
public FeedforwardConfigStore(ExperionDbContext ctx, ILogger<FeedforwardConfigStore> logger)
|
||||
{ _ctx = ctx; _logger = logger; }
|
||||
|
||||
public async Task<IReadOnlyList<ColumnConfig>> LoadAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
var conn = _ctx.Database.GetDbConnection();
|
||||
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
|
||||
|
||||
var cols = new Dictionary<int, (ColumnConfig cfg, List<StreamConfig> streams)>();
|
||||
await using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
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,
|
||||
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);
|
||||
while (await rd.ReadAsync(ct))
|
||||
{
|
||||
var levelTags = rd.IsDBNull(5)
|
||||
? Array.Empty<string>()
|
||||
: rd.GetString(5)
|
||||
.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 = rd.GetBoolean(31),
|
||||
FeedTag = rd.GetString(3).ToLowerInvariant(),
|
||||
PressureTag = rd.IsDBNull(4) ? null : rd.GetString(4).ToLowerInvariant(),
|
||||
LevelTags = levelTags,
|
||||
ScanSec = rd.GetDouble(6),
|
||||
FeedFilterTauSec = rd.GetDouble(7),
|
||||
FeedMoveThresholdPerMin = rd.GetDouble(8),
|
||||
PressFilterTauSec = rd.GetDouble(9),
|
||||
PressureBand = rd.GetDouble(10),
|
||||
SettleSec = rd.GetDouble(11),
|
||||
StaleSec = rd.GetDouble(12),
|
||||
ProductKey = rd.GetString(13),
|
||||
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>());
|
||||
}
|
||||
}
|
||||
|
||||
await using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
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,
|
||||
is_reflux, recovery_sp, sp_node_id
|
||||
FROM ff_stream_config
|
||||
ORDER BY id
|
||||
""";
|
||||
await using var rd = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await rd.ReadAsync(ct))
|
||||
{
|
||||
int colId = rd.GetInt32(0);
|
||||
if (!cols.TryGetValue(colId, out var entry)) continue;
|
||||
entry.streams.Add(new StreamConfig
|
||||
{
|
||||
Key = rd.GetString(1),
|
||||
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),
|
||||
TauSec = rd.GetDouble(7),
|
||||
SpMin = rd.GetDouble(8),
|
||||
SpMax = rd.GetDouble(9),
|
||||
RateUpPerMin = rd.GetDouble(10),
|
||||
RateDnPerMin = rd.GetDouble(11),
|
||||
RefluxFromProduct = rd.GetBoolean(12),
|
||||
Grade = Enum.TryParse<Confidence>(rd.GetString(13), true, out var g) ? g : Confidence.A
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return cols.Values.Select(e => e.cfg with { Streams = e.streams }).ToList();
|
||||
}
|
||||
|
||||
private static DbParameter P(DbCommand cmd, string name, object? val)
|
||||
{
|
||||
var p = cmd.CreateParameter();
|
||||
p.ParameterName = name;
|
||||
p.Value = val ?? DBNull.Value;
|
||||
cmd.Parameters.Add(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
public async Task<int> SaveColumnAsync(ColumnConfig cfg, CancellationToken ct = default)
|
||||
{
|
||||
var conn = _ctx.Database.GetDbConnection();
|
||||
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
|
||||
await using var tx = await conn.BeginTransactionAsync(ct);
|
||||
|
||||
int id = cfg.Id;
|
||||
var levelTags = string.Join(',', cfg.LevelTags);
|
||||
|
||||
if (id == 0)
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.Transaction = tx;
|
||||
cmd.CommandText = """
|
||||
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,
|
||||
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
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.Transaction = tx;
|
||||
cmd.CommandText = """
|
||||
UPDATE ff_column_config SET
|
||||
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=@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);
|
||||
}
|
||||
|
||||
// 스트림 원자적 교체
|
||||
await using (var del = conn.CreateCommand())
|
||||
{
|
||||
del.Transaction = tx; del.CommandText = "DELETE FROM ff_stream_config WHERE column_id=@id";
|
||||
P(del,"@id",id); await del.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
foreach (var s in cfg.Streams)
|
||||
{
|
||||
await using var ins = conn.CreateCommand();
|
||||
ins.Transaction = tx;
|
||||
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,
|
||||
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);
|
||||
}
|
||||
|
||||
await tx.CommitAsync(ct);
|
||||
return id;
|
||||
}
|
||||
|
||||
public async Task DeleteColumnAsync(int columnId, 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 = "DELETE FROM ff_column_config WHERE id=@id";
|
||||
P(cmd,"@id",columnId);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
}
|
||||
432
src/Infrastructure/Control/FeedforwardEngine.cs
Normal file
432
src/Infrastructure/Control/FeedforwardEngine.cs
Normal file
@@ -0,0 +1,432 @@
|
||||
using ExperionCrawler.Core.Application.Feedforward;
|
||||
|
||||
namespace ExperionCrawler.Infrastructure.Control;
|
||||
|
||||
public sealed class StreamState
|
||||
{
|
||||
public DeadTimeBuffer Dead { get; } = new();
|
||||
public FirstOrderLag Lag { get; } = new();
|
||||
public RateLimiter Rate { get; } = new();
|
||||
public double LastRec { get; set; } = double.NaN;
|
||||
}
|
||||
|
||||
public sealed class ColumnState
|
||||
{
|
||||
public FirstOrderLag FeedFilter { get; } = new();
|
||||
public FirstOrderLag PressFilter { get; } = new();
|
||||
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)
|
||||
{
|
||||
if (!Streams.TryGetValue(key, out var s)) { s = new StreamState(); Streams[key] = s; }
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FeedforwardEngine
|
||||
{
|
||||
public AdvisoryResult Tick(ColumnConfig cfg, PvSnapshot pv, ColumnState st, DateTime now)
|
||||
{
|
||||
var ts = cfg.ScanSec;
|
||||
|
||||
if (!pv.Feed.Good || !Num.IsFinite(pv.Feed.Value))
|
||||
return Hold(cfg, st, now, "FEED BAD");
|
||||
|
||||
var ff = st.FeedFilter.Step(pv.Feed.Value, cfg.FeedFilterTauSec, ts);
|
||||
|
||||
if (!st.Initialized) { SeedAll(cfg, pv, st, ff); st.Initialized = true; }
|
||||
|
||||
var dF = st.FeedDeriv.Update(ff, ts);
|
||||
bool moving = cfg.FeedMoveThresholdPerMin > 0
|
||||
&& Math.Abs(dF) * 60.0 > cfg.FeedMoveThresholdPerMin;
|
||||
bool pUnstable = false;
|
||||
if (pv.Pressure is { Good: true } pp && Num.IsFinite(pp.Value))
|
||||
{
|
||||
var pf = st.PressFilter.Step(pp.Value, cfg.PressFilterTauSec, ts);
|
||||
pUnstable = Math.Abs(pp.Value - pf) > cfg.PressureBand;
|
||||
}
|
||||
if (moving || pUnstable) st.SettleTimerSec = cfg.SettleSec;
|
||||
else st.SettleTimerSec = Math.Max(0.0, st.SettleTimerSec - ts);
|
||||
bool transient = moving || pUnstable || st.SettleTimerSec > 0.0;
|
||||
string treason = moving ? "FEED 이동"
|
||||
: pUnstable ? "압력 불안정"
|
||||
: st.SettleTimerSec > 0.0 ? $"정착 대기 {st.SettleTimerSec:F0}s" : "";
|
||||
|
||||
// ── Pre-compute mbState for WO-1 downgrade ──
|
||||
double? vloss = null, yield = null;
|
||||
string mbState;
|
||||
if (transient)
|
||||
mbState = $"정착 대기 ({st.SettleTimerSec:F0}s)";
|
||||
else if (TryStreamPv(pv, "D", out var dVal) && TryStreamPv(pv, "P", out var pVal)
|
||||
&& TryStreamPv(pv, "B", out var bVal) && ff > 1e-6)
|
||||
{
|
||||
vloss = ff - (dVal + pVal + bVal);
|
||||
yield = 100.0 * pVal / ff;
|
||||
mbState = Math.Abs(vloss.Value) > 0.03 * ff ? "물질수지 불일치(계측 점검)"
|
||||
: vloss.Value < 0 ? "음의 손실(스팬 오류 의심)"
|
||||
: "정상";
|
||||
}
|
||||
else mbState = "입력 부족";
|
||||
|
||||
// ── Stream 1-pass: non-reflux ──
|
||||
var outs = new List<StreamAdvisory>(cfg.Streams.Count);
|
||||
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), mbState));
|
||||
}
|
||||
|
||||
// ── Stream 2-pass: reflux ──
|
||||
foreach (var s in cfg.Streams)
|
||||
{
|
||||
if (!s.RefluxFromProduct) continue;
|
||||
var stt = st.Stream(s.Key);
|
||||
double? rec = null;
|
||||
if (prodRec is double p)
|
||||
{
|
||||
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, mbState));
|
||||
}
|
||||
|
||||
// ── pUnstable column-level downgrade (WO-1) ──
|
||||
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();
|
||||
}
|
||||
|
||||
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)
|
||||
{ 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(
|
||||
StreamConfig s, double ff, double dF, double ts, StreamState stt)
|
||||
{
|
||||
switch (s.Role)
|
||||
{
|
||||
case StreamRole.Commanded:
|
||||
double theta = dF >= 0 ? s.ThetaUpSec : s.ThetaDnSec;
|
||||
double fd = stt.Dead.Through(ff, theta, ts);
|
||||
fd = stt.Lag.Step(fd, s.TauSec, ts);
|
||||
double raw = Num.Clamp(s.TargetCoeff * fd, s.SpMin, s.SpMax);
|
||||
double rec = stt.Rate.Step(raw, s.RateUpPerMin, s.RateDnPerMin, ts);
|
||||
return (rec, "");
|
||||
case StreamRole.LevelDriven:
|
||||
return (s.TargetCoeff * ff, "레벨 제어 구동 — 기대치(deadtime 미적용)");
|
||||
default:
|
||||
return (null, "모니터(SP 없음)");
|
||||
}
|
||||
}
|
||||
|
||||
private static StreamAdvisory BuildAdvisory(
|
||||
StreamConfig s, PvSnapshot pv, double? rec, string note, bool transient, StreamState stt, string? mbState = null)
|
||||
{
|
||||
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, 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)
|
||||
{
|
||||
v = double.NaN;
|
||||
if (pv.Streams.TryGetValue(key, out var s) && s.Good && Num.IsFinite(s.Value)) { v = s.Value; return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void SeedAll(ColumnConfig cfg, PvSnapshot pv, ColumnState st, double ff)
|
||||
{
|
||||
st.FeedDeriv.Update(ff, cfg.ScanSec);
|
||||
foreach (var s in cfg.Streams)
|
||||
{
|
||||
var stt = st.Stream(s.Key);
|
||||
double seed = pv.Streams.TryGetValue(s.Key, out var smp) && smp.Good ? smp.Value : ff * s.TargetCoeff;
|
||||
stt.Lag.Seed(seed);
|
||||
stt.Rate.Seed(seed);
|
||||
stt.LastRec = seed;
|
||||
}
|
||||
}
|
||||
|
||||
private AdvisoryResult Hold(ColumnConfig cfg, ColumnState st, DateTime now, string reason)
|
||||
{
|
||||
var outs = cfg.Streams.Select(s =>
|
||||
{
|
||||
var stt = st.Stream(s.Key);
|
||||
double? rec = Num.IsFinite(stt.LastRec) ? stt.LastRec : (double?)null;
|
||||
return new StreamAdvisory(s.Key, s.FlowTag, s.Role, double.NaN, rec, null, 0,
|
||||
false, s.Grade, s.LevelTag, $"홀드: {reason}");
|
||||
}).ToList();
|
||||
return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled, true, reason,
|
||||
st.FeedFilter.Value, outs, null, null, $"홀드: {reason}");
|
||||
}
|
||||
}
|
||||
218
src/Infrastructure/Control/FeedforwardSupervisor.cs
Normal file
218
src/Infrastructure/Control/FeedforwardSupervisor.cs
Normal file
@@ -0,0 +1,218 @@
|
||||
using ExperionCrawler.Core.Application.Feedforward;
|
||||
using ExperionCrawler.Core.Application.Interfaces;
|
||||
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;
|
||||
|
||||
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, 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)
|
||||
{
|
||||
await Task.Yield();
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
double minScan = 2.0;
|
||||
try
|
||||
{
|
||||
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();
|
||||
if (enabled.Count > 0) minScan = enabled.Min(c => c.ScanSec);
|
||||
|
||||
foreach (var cfg in enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
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)
|
||||
{
|
||||
_logger.LogWarning(ex, "FF tick 실패: column {Id}", cfg.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "FF supervisor 루프 오류");
|
||||
}
|
||||
await Task.Delay(TimeSpan.FromSeconds(Math.Clamp(minScan, 1.0, 10.0)), ct);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
{
|
||||
var t = baseTag.ToLowerInvariant();
|
||||
return t.EndsWith(".pv") ? t : t + ".pv";
|
||||
}
|
||||
var feedTag = PvTag(cfg.FeedTag);
|
||||
var tags = new List<string> { feedTag };
|
||||
if (cfg.PressureTag is not null) tags.Add(PvTag(cfg.PressureTag));
|
||||
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);
|
||||
|
||||
TagSample Sample(string baseTag)
|
||||
{
|
||||
var tag = PvTag(baseTag);
|
||||
if (rows.TryGetValue(tag.ToLowerInvariant(), 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);
|
||||
}
|
||||
|
||||
// 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));
|
||||
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");
|
||||
@@ -1061,6 +1077,85 @@ public class ExperionDbService : IExperionDbService
|
||||
GROUP BY area_code ORDER BY area_code
|
||||
""");
|
||||
|
||||
// ── Feedforward advisory engine config tables ─────────────────────
|
||||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS ff_column_config (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
feed_tag TEXT NOT NULL,
|
||||
pressure_tag TEXT,
|
||||
level_tags TEXT,
|
||||
scan_sec DOUBLE PRECISION NOT NULL DEFAULT 2,
|
||||
feed_filter_tau_sec DOUBLE PRECISION NOT NULL DEFAULT 300,
|
||||
feed_move_thr_per_min DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
press_filter_tau_sec DOUBLE PRECISION NOT NULL DEFAULT 60,
|
||||
pressure_band DOUBLE PRECISION NOT NULL DEFAULT 1e9,
|
||||
settle_sec DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
stale_sec DOUBLE PRECISION NOT NULL DEFAULT 120,
|
||||
product_key TEXT NOT NULL DEFAULT 'P',
|
||||
advisory_only BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
""");
|
||||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS ff_stream_config (
|
||||
id SERIAL PRIMARY KEY,
|
||||
column_id INTEGER NOT NULL REFERENCES ff_column_config(id) ON DELETE CASCADE,
|
||||
key TEXT NOT NULL,
|
||||
flow_tag TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
target_coeff DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
theta_up_sec DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
theta_dn_sec DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
tau_sec DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
sp_min DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
sp_max DOUBLE PRECISION NOT NULL DEFAULT 1e9,
|
||||
rate_up_per_min DOUBLE PRECISION NOT NULL DEFAULT 1e9,
|
||||
rate_dn_per_min DOUBLE PRECISION NOT NULL DEFAULT 1e9,
|
||||
reflux_from_product BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
grade TEXT NOT NULL DEFAULT 'A',
|
||||
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 활성화)");
|
||||
return true;
|
||||
}
|
||||
|
||||
233
src/Web/Controllers/FeedforwardController.cs
Normal file
233
src/Web/Controllers/FeedforwardController.cs
Normal file
@@ -0,0 +1,233 @@
|
||||
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;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/ff")]
|
||||
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,
|
||||
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; }
|
||||
|
||||
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)
|
||||
{
|
||||
var cols = await _config.LoadAllAsync(ct);
|
||||
return Ok(new { columns = cols.Select(MapConfig) });
|
||||
}
|
||||
|
||||
[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 });
|
||||
}
|
||||
|
||||
[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 });
|
||||
}
|
||||
|
||||
// ── 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(),
|
||||
isReflux = s.IsReflux, recoverySp = double.IsNaN(s.RecoverySp) ? (double?)null : s.RecoverySp,
|
||||
spNodeId = s.SpNodeId
|
||||
})
|
||||
};
|
||||
|
||||
// ── Advisory (공개 읽기) ───────────────────────────────────────
|
||||
[HttpGet("advisory")]
|
||||
public IActionResult GetAll() => Ok(new
|
||||
{
|
||||
columns = _store.GetAll().Select(r => MapColumn(r))
|
||||
});
|
||||
|
||||
[HttpGet("advisory/{columnId:int}")]
|
||||
public IActionResult Get(int columnId)
|
||||
{
|
||||
var r = _store.Get(columnId);
|
||||
return r is null ? NotFound() : Ok(MapColumn(r));
|
||||
}
|
||||
|
||||
private object MapColumn(AdvisoryResult r)
|
||||
{
|
||||
var streams = r.Streams.Select(s =>
|
||||
{
|
||||
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; } }
|
||||
@@ -121,6 +121,15 @@ builder.Services.AddHostedService(sp => sp.GetRequiredService<ExperionFastServic
|
||||
// ── Metadata Loader Service ───────────────────────────────────────────────────
|
||||
builder.Services.AddScoped<IMetadataLoaderService, MetadataLoaderService>();
|
||||
|
||||
// ── Feedforward Advisory Engine ───────────────────────────────────────────────
|
||||
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.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>();
|
||||
builder.Services.AddScoped<ITagMappingService, TagMappingService>();
|
||||
|
||||
81
src/Web/wwwroot/css/ff.css
Normal file
81
src/Web/wwwroot/css/ff.css
Normal file
@@ -0,0 +1,81 @@
|
||||
.ff-wrap{padding:16px;color:var(--t1)}
|
||||
.ff-head{display:flex;align-items:center;gap:12px;margin-bottom:12px}
|
||||
.ff-badge{font-size:12px;color:var(--t2);border:1px solid var(--bd);border-radius:10px;padding:2px 8px}
|
||||
.ff-dash{display:grid;grid-template-columns:repeat(auto-fill,minmax(420px,1fr));gap:12px}
|
||||
.ff-col-card{background:var(--bg2);border:1px solid var(--bd);border-radius:8px;padding:12px}
|
||||
.ff-col-card.ff-disabled{opacity:.5}
|
||||
.ff-col-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
|
||||
.ff-transient{background:#3a2e00;color:#ffd24d;padding:4px 8px;border-radius:4px;font-size:13px;margin:4px 0}
|
||||
.ff-tbl{width:100%;border-collapse:collapse;font-size:13px}
|
||||
.ff-tbl th,.ff-tbl td{padding:3px 6px;border-bottom:1px solid var(--bd);text-align:left}
|
||||
.ff-num{text-align:right;font-variant-numeric:tabular-nums}
|
||||
.ff-rec{font-weight:600;color:#7fd1ff}
|
||||
.ff-stale{opacity:.45}
|
||||
.ff-role-LevelDriven{color:#9aa}.ff-role-Monitor{color:#777}.ff-role-Commanded{color:#7fd1ff}
|
||||
.ff-grade-A{color:#4caf50}.ff-grade-B{color:#ffb300}.ff-grade-C{color:#ff5252}
|
||||
.ff-mb,.ff-note{font-size:12px;color:var(--t2);margin-top:6px}
|
||||
.ff-msg.err{color:#ff5252}
|
||||
.ff-cfg{margin-top:16px;border-top:1px solid var(--bd);padding-top:12px}
|
||||
.ff-cfg-bar{display:flex;gap:8px;align-items:center;margin-bottom:12px}
|
||||
.ff-cfg-item{padding:6px 0;border-bottom:1px solid var(--bd);font-size:13px}
|
||||
.ff-msg{font-size:12px;margin-left:8px}
|
||||
|
||||
/* ── 폼 에디터 모달 ───────────────────────────────── */
|
||||
.ff-modal{position:fixed;inset:0;z-index:900;display:flex;align-items:center;justify-content:center}
|
||||
.ff-modal-overlay{position:absolute;inset:0;background:rgba(0,0,0,.55)}
|
||||
.ff-modal-box{position:relative;background:var(--s2);border:1px solid var(--bd2);border-radius:var(--rl);width:min(1100px,95vw);max-height:92vh;display:flex;flex-direction:column}
|
||||
.ff-modal-hd{padding:14px 16px;border-bottom:1px solid var(--bd);font-weight:700;font-size:15px;color:var(--t0)}
|
||||
.ff-modal-id{font-weight:400;font-size:12px;color:var(--t2);margin-left:8px}
|
||||
.ff-modal-body{overflow-y:auto;flex:1;padding:16px}
|
||||
.ff-modal-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px}
|
||||
.ff-modal-col{display:flex;flex-direction:column;gap:8px}
|
||||
.ff-modal-col label{font-size:12px;color:var(--t2);display:flex;flex-direction:column;gap:2px}
|
||||
.ff-modal-col label input[type="checkbox"]{margin:6px 0}
|
||||
.ff-modal-sec-hd{display:flex;align-items:center;gap:8px;font-weight:700;font-size:13px;color:var(--t0);margin-bottom:8px}
|
||||
.ff-key-legend{font-weight:400;font-size:11px;color:var(--t2)}
|
||||
.ff-stream-tbl{font-size:11px}
|
||||
.ff-stream-tbl th,.ff-stream-tbl td{padding:2px 4px;white-space:nowrap}
|
||||
.ff-stream-tbl .inp{width:64px;min-width:0;padding:4px 6px;font-size:11px}
|
||||
.ff-stream-tbl select.inp{width:auto}
|
||||
.ff-stream-tbl td:first-child .inp{width:48px}
|
||||
.ff-stream-tbl td:nth-child(2) .inp{width:80px}
|
||||
.ff-stream-tbl td input[type="checkbox"]{margin:0}
|
||||
.ff-modal-ft{display:flex;gap:8px;align-items:center;justify-content:flex-end;padding:12px 16px;border-top:1px solid var(--bd)}
|
||||
.ff-modal-ft .ff-msg{margin-right:auto}
|
||||
.danger{color:var(--red);border:1px solid var(--red)}.danger:hover{background:var(--red);color:#fff}
|
||||
.ff-lvl-hint{font-size:10px;color:var(--t2);margin-left:4px;white-space:nowrap}
|
||||
.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,4 +1,7 @@
|
||||
/* ── P&ID 추출 스타일 ───────────────────────────────────────── */
|
||||
#pane-pid {
|
||||
margin: 0 -16px;
|
||||
}
|
||||
#pane-pid .card-cap {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<link rel="stylesheet" href="/lib/uPlot.min.css"/>
|
||||
<link rel="stylesheet" href="/css/docs.css"/>
|
||||
<link rel="stylesheet" href="/css/trend.css"/>
|
||||
<link rel="stylesheet" href="/css/ff.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
@@ -110,6 +111,10 @@
|
||||
<span class="ni">17</span>
|
||||
<span class="nl">트렌드</span>
|
||||
</li>
|
||||
<li class="nav-item" data-tab="ff">
|
||||
<span class="ni">18</span>
|
||||
<span class="nl">유량 권장(FF)</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="sb-foot">
|
||||
@@ -137,6 +142,7 @@
|
||||
<section class="pane" id="pane-write" data-src="/panes/write.html"></section>
|
||||
<section class="pane" id="pane-docs" data-src="/panes/docs.html"></section>
|
||||
<section class="pane" id="pane-trend" data-src="/panes/trend.html"></section>
|
||||
<section class="pane" id="pane-ff" data-src="/panes/ff.html"></section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -239,5 +245,6 @@
|
||||
<script src="/js/write.js"></script>
|
||||
<script src="/js/docs.js"></script>
|
||||
<script src="/js/trend.js"></script>
|
||||
<script src="/js/ff.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
364
src/Web/wwwroot/js/ff.js
Normal file
364
src/Web/wwwroot/js/ff.js
Normal file
@@ -0,0 +1,364 @@
|
||||
/* ff.js — 측류추출 유량 권장(FF) 대시보드 + 설정 에디터.
|
||||
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();
|
||||
}
|
||||
|
||||
async function ffInit() {
|
||||
if (ffTimer) { clearInterval(ffTimer); ffTimer = null; }
|
||||
await ffLoadDash();
|
||||
ffTimer = setInterval(ffLoadDash, 3000);
|
||||
|
||||
document.getElementById('ff-cfg-toggle').onclick = () => {
|
||||
const c = document.getElementById('ff-cfg');
|
||||
c.style.display = c.style.display === 'none' ? 'block' : 'none';
|
||||
};
|
||||
document.getElementById('ff-new').onclick = () => ffEditColumn(null);
|
||||
ffLoadConfig().catch(()=>{});
|
||||
}
|
||||
|
||||
// ── 대시보드 (공개) ──────────────────────────────────────────────
|
||||
async function ffLoadDash() {
|
||||
let data;
|
||||
try { data = await api('GET', '/api/ff/advisory'); }
|
||||
catch (e) { return; }
|
||||
const host = document.getElementById('ff-dash');
|
||||
if (!host) { clearInterval(ffTimer); ffTimer = null; return; }
|
||||
const cols = data.columns || [];
|
||||
if (!cols.length) { host.innerHTML = '<div class="ff-empty">활성 컬럼 없음</div>'; return; }
|
||||
host.innerHTML = cols.map(ffCard).join('');
|
||||
}
|
||||
|
||||
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>
|
||||
<td class="ff-num">${fmtVal(s.pv)}</td>
|
||||
<td class="ff-num ff-rec">${s.recommendedSp==null?'–':fmtVal(s.recommendedSp)}</td>
|
||||
<td class="ff-num">${s.gap==null?'–':fmtVal(s.gap)}</td>
|
||||
<td>${ffTrendIco(s.trend)}</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>`;
|
||||
}
|
||||
|
||||
// ── 설정 에디터 ──────────────────────────────────────────────────
|
||||
function ffMsg(m, err) { const e=document.getElementById('ff-cfg-msg'); e.textContent=m; e.className='ff-msg'+(err?' err':''); }
|
||||
|
||||
async function ffLoadConfig() {
|
||||
const data = await ffApi('GET', '/api/ff/config');
|
||||
const host = document.getElementById('ff-cfg-list');
|
||||
host.innerHTML = (data.columns||[]).map(ffCfgRow).join('') || '<div class="ff-empty">설정 없음</div>';
|
||||
host.querySelectorAll('[data-edit]').forEach(b => b.onclick = () =>
|
||||
ffEditColumn(data.columns.find(c => c.id == b.dataset.edit)));
|
||||
host.querySelectorAll('[data-del]').forEach(b => b.onclick = () => ffDelete(b.dataset.del));
|
||||
}
|
||||
function ffCfgRow(c) {
|
||||
return `<div class="ff-cfg-item"><b>${esc(c.name)}</b> (id ${c.id}) — feed ${esc(c.feedTag)},
|
||||
스트림 ${c.streams.length}개, ${c.enabled?'활성':'비활성'}
|
||||
<button class="btn sm" data-edit="${c.id}">편집</button>
|
||||
<button class="btn sm danger" data-del="${c.id}">삭제</button></div>`;
|
||||
}
|
||||
async function ffDelete(id) {
|
||||
if (!confirm(`컬럼 ${id} 삭제?`)) return;
|
||||
try { await ffApi('DELETE', `/api/ff/config/${id}`); await ffLoadConfig(); ffMsg('삭제됨'); }
|
||||
catch (e) { ffMsg('삭제 실패', true); }
|
||||
}
|
||||
|
||||
// ── 폼 에디터 모달 ──────────────────────────────────────────────
|
||||
const FF_ROLES = ['Commanded','LevelDriven','Monitor'];
|
||||
const FF_GRADES = ['A','B','C'];
|
||||
const FF_KEY_DESC = { P:'주생성물(Feedforward 계산)', R:'환류(R_f × P_sp)', D:'유출액(LevelDriven 기대치)', B:'탑저(LevelDriven 기대치)' };
|
||||
|
||||
function ffEditColumn(c) {
|
||||
const existing = document.getElementById('ff-modal');
|
||||
if (existing) existing.remove();
|
||||
const isNew = !c;
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'ff-modal';
|
||||
modal.className = 'ff-modal';
|
||||
|
||||
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,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||'',
|
||||
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>
|
||||
</div>
|
||||
<div class="ff-modal-col">
|
||||
<label><span class="ff-desc">Scan(초): 계산 주기 — 빠를수록 민감하나 부하 증가</span><input class="inp" type="number" id="ff-f-scanSec" value="${def.scanSec}"></label>
|
||||
<label><span class="ff-desc">Feed 필터 τ(초): 원료투입량 필터 — 잦은 변화 안정화로 헌팅 방지</span><input class="inp" type="number" id="ff-f-feedFilterTauSec" value="${def.feedFilterTauSec}"></label>
|
||||
<label><span class="ff-desc">Feed 변동 임계(/분): 원료 투입량 분당 변화율 — 초과 시 과도상태 진입</span><input class="inp" type="number" id="ff-f-feedMoveThresholdPerMin" value="${def.feedMoveThresholdPerMin}"></label>
|
||||
<label><span class="ff-desc">압력 필터 τ(초): 진공압 필터 — 잦은 변화 안정화로 헌팅 방지</span><input class="inp" type="number" id="ff-f-pressFilterTauSec" value="${def.pressFilterTauSec}"></label>
|
||||
<label><span class="ff-desc">Pressure Band: 진공 설정값과 현재값 상하 변동폭 판정 기준</span><input class="inp" type="number" id="ff-f-pressureBand" value="${def.pressureBand}"></label>
|
||||
<label><span class="ff-desc">Settle(초): 안정화 판단 기준 시간 — 이 시간 동안 안정 시 과도상태 해제</span><input class="inp" type="number" id="ff-f-settleSec" value="${def.settleSec}"></label>
|
||||
<label><span class="ff-desc">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 = `
|
||||
<div class="ff-modal-overlay"></div>
|
||||
<div class="ff-modal-box">
|
||||
<div class="ff-modal-hd">${isNew?'새 컬럼':'컬럼 편집'} <span class="ff-modal-id">${isNew?'':'(id '+c.id+')'}</span></div>
|
||||
<div class="ff-modal-body">
|
||||
<div class="ff-modal-grid">${colHtml}</div>
|
||||
<div class="ff-modal-sec">
|
||||
<div class="ff-modal-sec-hd">스트림
|
||||
<span class="ff-key-legend">P=주생성물 R=환류(P rec) D=유출액 B=탑저</span>
|
||||
<button class="btn sm" id="ff-stream-add">+ 추가</button>
|
||||
</div>
|
||||
<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 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('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ff-modal-ft">
|
||||
<span id="ff-modal-msg" class="ff-msg"></span>
|
||||
<button class="btn-b" id="ff-modal-cancel">취소</button>
|
||||
<button class="btn-a" id="ff-modal-save">${isNew?'생성':'저장'}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
document.getElementById('ff-stream-add').onclick = () => {
|
||||
const tb = document.getElementById('ff-stream-body');
|
||||
const i = tb.children.length;
|
||||
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,spNodeId:''
|
||||
}, i));
|
||||
ffWireStreamRow(tb.lastElementChild);
|
||||
};
|
||||
|
||||
document.querySelectorAll('#ff-stream-body tr').forEach(ffWireStreamRow);
|
||||
document.getElementById('ff-modal-cancel').onclick = () => modal.remove();
|
||||
document.getElementById('ff-modal-save').onclick = () => ffSaveForm(c?.id);
|
||||
document.querySelector('.ff-modal-overlay').onclick = () => modal.remove();
|
||||
}
|
||||
|
||||
function ffStreamRow(s, i) {
|
||||
const roleOpts = FF_ROLES.map(r => `<option ${r===s.role?'selected':''}>${r}</option>`).join('');
|
||||
const gradeOpts = FF_GRADES.map(g => `<option ${g===s.grade?'selected':''}>${g}</option>`).join('');
|
||||
const desc = FF_KEY_DESC[s.key] || '';
|
||||
const lvlTagHtml = s.role==='LevelDriven'
|
||||
? `<input class="inp ff-si ff-lvl-tag" value="${esc(s.levelTag||'')}" data-idx="${i}" data-f="levelTag" placeholder="레벨태그">`
|
||||
: `<input type="hidden" data-idx="${i}" data-f="levelTag" value="">`;
|
||||
return `<tr>
|
||||
<td><input class="inp ff-si" value="${esc(s.key)}" title="${esc(desc)}" data-idx="${i}" data-f="key"></td>
|
||||
<td><input class="inp ff-si" value="${esc(s.flowTag)}" data-idx="${i}" data-f="flowTag"></td>
|
||||
<td><select class="inp ff-si" data-idx="${i}" data-f="role">${roleOpts}</select></td>
|
||||
<td>${lvlTagHtml}</td>
|
||||
<td><input class="inp ff-si" type="number" step="any" value="${s.targetCoeff}" data-idx="${i}" data-f="targetCoeff"></td>
|
||||
<td><input class="inp ff-si" type="number" value="${s.thetaUpSec}" data-idx="${i}" data-f="thetaUpSec"></td>
|
||||
<td><input class="inp ff-si" type="number" value="${s.thetaDnSec}" data-idx="${i}" data-f="thetaDnSec"></td>
|
||||
<td><input class="inp ff-si" type="number" value="${s.tauSec}" data-idx="${i}" data-f="tauSec"></td>
|
||||
<td><input class="inp ff-si" type="number" step="any" value="${s.spMin}" data-idx="${i}" data-f="spMin"></td>
|
||||
<td><input class="inp ff-si" type="number" step="any" value="${s.spMax}" data-idx="${i}" data-f="spMax"></td>
|
||||
<td><input class="inp ff-si" type="number" step="any" value="${s.rateUpPerMin}" data-idx="${i}" data-f="rateUpPerMin"></td>
|
||||
<td><input class="inp ff-si" type="number" step="any" value="${s.rateDnPerMin}" data-idx="${i}" data-f="rateDnPerMin"></td>
|
||||
<td><input 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>`;
|
||||
}
|
||||
|
||||
function ffWireStreamRow(tr) {
|
||||
const del = tr.querySelector('.ff-stream-del');
|
||||
if (del) del.onclick = () => { tr.remove(); };
|
||||
}
|
||||
|
||||
function ffSaveForm(existingId) {
|
||||
const g = id => document.getElementById(id);
|
||||
const body = {
|
||||
id: existingId || 0,
|
||||
name: g('ff-f-name').value,
|
||||
enabled: g('ff-f-enabled').checked,
|
||||
feedTag: g('ff-f-feedTag').value,
|
||||
pressureTag: g('ff-f-pressureTag').value || null,
|
||||
scanSec: +g('ff-f-scanSec').value,
|
||||
feedFilterTauSec: +g('ff-f-feedFilterTauSec').value,
|
||||
feedMoveThresholdPerMin: +g('ff-f-feedMoveThresholdPerMin').value,
|
||||
pressFilterTauSec: +g('ff-f-pressFilterTauSec').value,
|
||||
pressureBand: +g('ff-f-pressureBand').value,
|
||||
settleSec: +g('ff-f-settleSec').value,
|
||||
staleSec: +g('ff-f-staleSec').value,
|
||||
productKey: g('ff-f-productKey').value,
|
||||
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}"]`);
|
||||
if (!el) return '';
|
||||
if (el.type === 'checkbox') return el.checked;
|
||||
if (el.tagName === 'SELECT') return el.value;
|
||||
return el.value;
|
||||
};
|
||||
return {
|
||||
key: v(null,'key'), flowTag: v(null,'flowTag'), role: v(null,'role'),
|
||||
levelTag: v(null,'levelTag') || null,
|
||||
targetCoeff: +v(null,'targetCoeff'), thetaUpSec: +v(null,'thetaUpSec'),
|
||||
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'),
|
||||
isReflux: v(null,'isReflux'),
|
||||
recoverySp: (() => { const x = v(null,'recoverySp'); return x === '' ? undefined : +x; })(),
|
||||
spNodeId: v(null,'spNodeId') || null
|
||||
};
|
||||
})
|
||||
};
|
||||
if (existingId) body.id = existingId;
|
||||
|
||||
const msg = g('ff-modal-msg');
|
||||
ffApi('POST', '/api/ff/config', body)
|
||||
.then(() => { document.getElementById('ff-modal').remove(); ffLoadConfig(); ffMsg('저장됨'); })
|
||||
.catch(e => { msg.textContent = '저장 실패: '+e.message; msg.className = 'ff-msg err'; });
|
||||
}
|
||||
19
src/Web/wwwroot/panes/ff.html
Normal file
19
src/Web/wwwroot/panes/ff.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<div class="ff-wrap">
|
||||
<div class="ff-head">
|
||||
<h2>측류추출 권장 유량 설정값 (Advisory · 보조지표)</h2>
|
||||
<span class="ff-badge">읽기 전용 — 권장값. 인가는 운전원</span>
|
||||
<button id="ff-cfg-toggle" class="btn">설정 ▾</button>
|
||||
</div>
|
||||
|
||||
<!-- 권장 SP 대시보드 (공개 읽기) -->
|
||||
<div id="ff-dash" class="ff-dash"><div class="ff-empty">불러오는 중…</div></div>
|
||||
|
||||
<!-- 설정 에디터 (Phase I: 인증 없음) -->
|
||||
<div id="ff-cfg" class="ff-cfg" style="display:none">
|
||||
<div class="ff-cfg-bar">
|
||||
<button id="ff-new" class="btn">+ 컬럼</button>
|
||||
<span id="ff-cfg-msg" class="ff-msg"></span>
|
||||
</div>
|
||||
<div id="ff-cfg-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
29
tests/ExperionCrawler.Tests/ExperionCrawler.Tests.csproj
Normal file
29
tests/ExperionCrawler.Tests/ExperionCrawler.Tests.csproj
Normal file
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
|
||||
<PackageReference Include="xunit" Version="2.4.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Web\ExperionCrawler.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
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
|
||||
}
|
||||
}
|
||||
48
tests/ExperionCrawler.Tests/FeedforwardBlocksTests.cs
Normal file
48
tests/ExperionCrawler.Tests/FeedforwardBlocksTests.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using ExperionCrawler.Infrastructure.Control;
|
||||
using Xunit;
|
||||
|
||||
public class FeedforwardBlocksTests
|
||||
{
|
||||
[Fact]
|
||||
public void DeadTime_delays_by_n_samples()
|
||||
{
|
||||
var d = new DeadTimeBuffer();
|
||||
Assert.Equal(10.0, d.Through(10, 10, 2), 3);
|
||||
for (int i = 0; i < 4; i++)
|
||||
Assert.Equal(10.0, d.Through(20, 10, 2), 3);
|
||||
Assert.Equal(10.0, d.Through(20, 10, 2), 3);
|
||||
Assert.Equal(20.0, d.Through(20, 10, 2), 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeadTime_asymmetric_theta_preserves_history()
|
||||
{
|
||||
// 비대칭 θ(20↔10) 토글 시에도 지연선이 리셋되지 않아야 한다.
|
||||
var d = new DeadTimeBuffer();
|
||||
double ts = 2;
|
||||
for (int i = 0; i < 12; i++) d.Through(0, 20, ts); // θ=20 → n=10, 0으로 채움
|
||||
// 입력을 100으로 바꾸고 θ를 작은 값으로 토글 — 재시드 버그면 즉시 100 누출
|
||||
Assert.Equal(0.0, d.Through(100, 10, ts), 3); // n=5 지연 → 아직 0
|
||||
Assert.Equal(0.0, d.Through(100, 20, ts), 3); // θ 되돌려도 0 (히스토리 보존)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RateLimiter_clamps_asymmetric_up_down()
|
||||
{
|
||||
var r = new RateLimiter();
|
||||
r.Seed(0);
|
||||
Assert.Equal(1, r.Step(100, 60, 600, 1), 3);
|
||||
r.Seed(100);
|
||||
Assert.Equal(90, r.Step(0, 60, 600, 1), 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FirstOrderLag_reaches_63pct_after_tau()
|
||||
{
|
||||
var l = new FirstOrderLag();
|
||||
l.Seed(0);
|
||||
double y = 0; double ts = 1, tau = 10;
|
||||
for (int i = 0; i < 10; i++) y = l.Step(1.0, tau, ts);
|
||||
Assert.InRange(y, 0.60, 0.66);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
1
tests/ExperionCrawler.Tests/GlobalUsings.cs
Normal file
1
tests/ExperionCrawler.Tests/GlobalUsings.cs
Normal file
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
Reference in New Issue
Block a user