docs: 측류추출 통합유량설정공식 설계문서 (Phase I/II/III + 분석엔진)
This commit is contained in:
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에 기록 (감사 추적)
|
||||||
|
```
|
||||||
1168
docs/측류추출식-통합유량설정공식.md
Normal file
1168
docs/측류추출식-통합유량설정공식.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user