feat: 안전 피드램프 Advisor (WP0 Sim Override + WP1 매트릭스 + WP3 계산기)
WP3 — read-only Feed Ramp Advisor (쓰기 없음): - FeedRampCalculator(순수함수): ceiling(밸브포화/flooding)·램프율(valveSlew/dynamic)·예상시간·binding·스팀(FIQ-6115 목표, a안 현열보정) 산출 - FeedRampModels(DTO+ISimOverrideStore), FeedRampAdvisorService(라이브+override), GET /api/ff/ramp-advisor WP0 — Sim Override Layer: - SimOverrideStore(ConcurrentDictionary+volatile), sim/override GET·POST·DELETE, Feedforward:SimOverrideEnabled 게이트 - 한계: ramp-advisor만 통합·엔진 미반영 → S6/S7 라이브는 override 불가(문서화) WP1 — docs/안전피드램프-검증시나리오매트릭스.md (S0~S7) 검증: 단위 31/31, 라이브 스모크 S1~S5 기대치 일치(3.29 dynamic/60.8min/366.7, 31.58 valveSlew, 2105 clamp, 1018.8 flooding, 309 현열) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
126
docs/안전피드램프-검증시나리오매트릭스.md
Normal file
126
docs/안전피드램프-검증시나리오매트릭스.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# WP1 — 안전 피드램프 Advisory 검증 시나리오 매트릭스
|
||||
|
||||
> 작성 2026-06-01. 대상: `plans/안전피드램프-advisory-작업지시서.md` WP3(`FeedRampCalculator`/`/api/ff/ramp-advisor`).
|
||||
> 역할 3종: ① WP3 구현 spec ② WP3 단위테스트 정의 ③ 라이브 sim(WP0 override) 검증 스크립트.
|
||||
> 컬럼: C-6111. 모든 기대 수치는 §0 config로 직접 계산(재현 가능).
|
||||
|
||||
---
|
||||
|
||||
## 0. 기준 상수 (C-6111, ff_stream_config) + 공통식
|
||||
|
||||
| key | role | K | θ_up(s) | τ(s) | sp_max | rate_up(kg/hr·min) |
|
||||
|---|---|---|---|---|---|---|
|
||||
| P | Commanded | 0.95 | 60 | 900 | 2000 | 30 |
|
||||
| R | Commanded | 0.80 | 0 | 0 | 2000 | 30 |
|
||||
| D | LevelDriven | 0.02 | – | – | 1000 | 0 |
|
||||
| B | LevelDriven | 0.03 | – | – | 500 | 0 |
|
||||
|
||||
ProductKey=P. 피드=ficq-6101.
|
||||
|
||||
```
|
||||
valveSlew = min_i(rate_up_i/K_i) = min(30/0.95, 30/0.8) = 31.58 kg/hr·min (P)
|
||||
dynamic = ΔI × 60 / (K_P×(τ_P+θ_P)) = ΔI × 60/912 = ΔI × 0.06579
|
||||
ΔI=50 → 3.29 ΔI=600 → 39.47
|
||||
R_feed = min(valveSlew, dynamic, [energyLoop])
|
||||
valveCeiling = min_i(sp_max_i/K_i) = min(2105.3, 2500, 50000, 16666.7) = 2105.3 (P)
|
||||
floodCeiling = Fcur × (floodLimit/ΔPnow)^(1/n) ΔPnow = pi-6111b − pica-6111
|
||||
ceiling = min(valveCeiling, floodCeiling[, steamCeiling])
|
||||
rampTimeMin= (clampedTarget − currentFeed) / R_feed
|
||||
steamTo = fiq6115Cur × (clampedTarget/currentFeed) + sensibleGain × clampedTarget × (feedTempRef − ti6103Now)
|
||||
```
|
||||
|
||||
## 0.1 공통 baseline 주입값 (anchor, 평형)
|
||||
| 태그 | 값 | 비고 |
|
||||
|---|---|---|
|
||||
| ficq-6101.pv (feed) | 900 | currentFeed |
|
||||
| ficq-6118.pv (P) | 855 | ≈0.95×900 |
|
||||
| ficq-6113.pv (R) | 720 | ≈0.8×900 |
|
||||
| ficq-6114.pv (D) | 18 | ≈0.02×900 |
|
||||
| ficq-6116.pv (B) | 27 | ≈0.03×900 |
|
||||
| ficq-6115.pv (스팀) | 300 | fiq6115Cur |
|
||||
| ti-6103.pv | 150 | = feedTempRef(앵커) |
|
||||
| pica-6111.pv | 40 | 탑정 진공압(mmHg) |
|
||||
| pi-6111b.pv | 80 | 탑저 진공압 → ΔPnow=40 |
|
||||
|
||||
허용오차: 비율·flow ±2%, 시간 ±2%, binding 라벨은 정확일치.
|
||||
|
||||
---
|
||||
|
||||
## 1. 시나리오 매트릭스 (S0~S7)
|
||||
|
||||
| ID | 목적 | 주입/파라미터(anchor에서 변경분) | 기대 출력 | 합격기준 | 검증경로 |
|
||||
|---|---|---|---|---|---|
|
||||
| **S0** | baseline 평형 | targetFeed=**900**, ΔI=50 | clampedTarget=900, rampTimeMin≈0, ceiling=2105.3(**P**), steamTo=300(무변화), warnings≠∅ | rampTime≈0, ceiling 2105±2% binding=P, steam 300±2% | 단위+sim |
|
||||
| **S1** | 피드 증량(동특성 binding) | targetFeed=**1100**, ΔI=50, feedTempRef=150, sensibleGain=0.001 | R_feed=**3.29**(binding=**dynamic**), clampedTarget=1100, rampTimeMin=**60.8**, steamTo=**366.7**, ceiling=2105.3(P) | rampRate 3.29±2% binding=dynamic, time 60.8±2%, steam 366.7±2% | 단위+sim |
|
||||
| **S2** | 밸브 슬루 binding | targetFeed=1100, **ΔI=600** | dynamic=39.5 > valveSlew=31.58 → R_feed=**31.58**(binding=**valveSlew@P**), rampTimeMin=**6.33** | rampRate 31.58±2% binding=valveSlew@P, time 6.33±2% | 단위+sim |
|
||||
| **S3** | ceiling 초과(밸브포화) | targetFeed=**2200**, ΔI=50 | clampedTarget=**2105.3**(binding=**P sp_max**), R_feed=3.29, rampTimeMin=**366.4** | clampedTarget 2105±2%, ceiling.binding="P sp_max", targetFeed 원값 2200 표기 | 단위+sim |
|
||||
| **S4** | flooding ceiling | pi-6111b=**120**(ΔPnow=80), **floodLimit=100, n=1.8**, targetFeed=1100, ΔI=50 | floodCeiling=900×(100/80)^(1/1.8)=**1018.5**, ceiling=min(2105,1018.5)=**1018.5**(binding=**flooding**), clampedTarget=1018.5, rampTimeMin=**36.1** | ceiling 1018.5±2% binding=flooding, clampedTarget 클램프 | 단위+sim |
|
||||
| **S5** | TI-6103 하강(현열 FF) | feed=900 고정, **ti-6103=140**, targetFeed=900, sensibleGain=0.001, feedTempRef=150 | steamTo=300×1 + 0.001×900×(150−140)=**309.0**(+9), rampTimeMin≈0 | steamTo 309±2%(피드 무변에도 steam↑), 현열항 부호 양수 | 단위+sim |
|
||||
| **S6** | stale/HOLD | feed 태그 Good=false(단위) / 운전원 frozen(>stale_sec=120s) | **HOLD** 반환 + warning="feed-bad/stale", 권장 미산정 | HOLD 상태·warning stale, 수치 미반환 | 단위(Good=false), 운전원 frozen |
|
||||
| **S7** | 과추출 감지(현행엔진) | B=**150**(vs 27), P=855, D=18 → D+P+B=1023 > feed → vloss=**−123** | (FeedRampCalculator 아님) `/api/ff/advisory` **massBalanceState="음의 손실(스팬 오류 의심)"** | mbState≠"정상"(음의손실/불일치) | **WP2 엔진**(/api/ff/advisory) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Worked 계산 (재현)
|
||||
|
||||
```
|
||||
S1: dynamic=50×0.06579=3.29 valveSlew=31.58 → R_feed=3.29(dynamic)
|
||||
clampedTarget=min(1100,2105.3)=1100 rampTime=(1100−900)/3.29=60.8 min
|
||||
steam=300×(1100/900)+0.001×1100×(150−150)=366.7+0=366.7
|
||||
|
||||
S2: dynamic=600×0.06579=39.47 valveSlew=31.58 → R_feed=31.58(valveSlew@P)
|
||||
rampTime=(1100−900)/31.58=6.33 min
|
||||
|
||||
S3: clampedTarget=min(2200,2105.3)=2105.3(P sp_max)
|
||||
rampTime=(2105.3−900)/3.29=366.4 min
|
||||
|
||||
S4: ΔPnow=120−40=80 floodCeiling=900×(100/80)^(1/1.8)
|
||||
(100/80)=1.25 1.25^0.5556=1.1317 → 1018.5
|
||||
ceiling=min(2105.3,1018.5)=1018.5(flooding) clampedTarget=1018.5
|
||||
rampTime=(1018.5−900)/3.29=36.0 min
|
||||
|
||||
S5: steam=300×(900/900)+0.001×900×(150−140)=300+9.0=309.0 (피드 무변, 현열만)
|
||||
|
||||
S7: vloss=feed−(D+P+B)=900−(18+855+150)=−123 (|−123|>3%·900=27) → "음의 손실(스팬 오류 의심)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 라이브 sim 검증 (WP0 override 경로)
|
||||
|
||||
공통 anchor 주입(S1 예):
|
||||
```
|
||||
curl -s -XPOST :5000/api/ff/sim/override -H 'Content-Type: application/json' -d '{
|
||||
"enabled":true,
|
||||
"values":{"ficq-6101.pv":900,"ficq-6118.pv":855,"ficq-6113.pv":720,
|
||||
"ficq-6114.pv":18,"ficq-6116.pv":27,"ficq-6115.pv":300,
|
||||
"ti-6103.pv":150,"pica-6111.pv":40,"pi-6111b.pv":80}}'
|
||||
|
||||
curl -s ':5000/api/ff/ramp-advisor?columnId=1&targetFeed=1100&deltaIAllow=50&feedTempRef=150&sensibleGain=0.001'
|
||||
# 기대: rampRate.value≈3.29 binding=dynamic, rampTimeMin≈60.8, steam.fiq6115To≈366.7, ceiling.value≈2105 binding=P
|
||||
|
||||
curl -s -XDELETE :5000/api/ff/sim/override
|
||||
```
|
||||
- S2: 같은 anchor + `deltaIAllow=600`
|
||||
- S3: `targetFeed=2200`
|
||||
- S4: override에 `pi-6111b.pv=120` + query `floodLimit=100&n=1.8`
|
||||
- S5: override에 `ti-6103.pv=140` + query `targetFeed=900&sensibleGain=0.001&feedTempRef=150`
|
||||
- S7: override에 `ficq-6116.pv=150` 후 `GET /api/ff/advisory`의 massBalanceState 확인(ramp-advisor 아님)
|
||||
|
||||
## 4. 단위테스트 매핑 (WP3-F)
|
||||
|
||||
| 시나리오 | 테스트 | 비고 |
|
||||
|---|---|---|
|
||||
| S0~S5 | `FeedRampCalculatorTests` (합성 ColumnConfig+PvSnapshot 직접 구성) | S5는 sensibleGain·feedTempRef 명시 전달 |
|
||||
| S6 | 동상 단위테스트, `pv.Feed.Good=false` → HOLD 검증 | |
|
||||
| S7 | **제외** — mbState는 `FeedforwardEngine` 산출. WP2 또는 Engine 통합테스트 | 작업지시서 §256 |
|
||||
|
||||
## 5. 합격 종합
|
||||
- S0~S5 단위테스트 전건 통과(허용오차 내), binding 라벨 정확.
|
||||
- 라이브 sim S1~S5가 단위테스트 기대치와 일치(override↔계산 동일 입력).
|
||||
- S6 HOLD, S7 mbState≠정상.
|
||||
- 모든 미산정(energyLoop·flooding 근거 부재 등) warnings 노출.
|
||||
|
||||
## 6. 참조
|
||||
- `plans/안전피드램프-advisory-작업지시서.md` (WP0~WP3)
|
||||
- `docs/안전피드램프-한계치-브레인스토밍.md` §3(공식)·§7(스팀)·§9(압력)
|
||||
@@ -111,6 +111,13 @@ pica-6111 탑정 / REFLUX DIST(R) / TI-6111D / 상부PACKING(정류,D) /
|
||||
- `SimOverrideEnabled=false`(기본)에서 sim 엔드포인트 403, advisor는 live만 사용.
|
||||
- 코드 grep: sim 경로에서 `WriteTagAsync`/SQL write **0건**.
|
||||
|
||||
### ⚠ 한계 (구현 후 확인, 2026-06-01)
|
||||
**Sim Override는 `FeedRampAdvisorService`(ramp-advisor)만 통합. 엔진(`FeedforwardSupervisor.BuildSnapshotAsync`)은 DB를 직접 읽어 override 미반영.**
|
||||
- 결과: **S7(mbState 과추출)·S6(stale) 라이브 검증은 override로 불가** — `/api/ff/advisory`(엔진 산출)는 override 무시.
|
||||
- S7 라이브 = 운전원이 실제 DCS/realtime_table에 합성값 주입 / 또는 Engine 통합테스트. S6 라이브 = stale 태그(운전원) / 또는 단위테스트(`pv.Feed.Good=false`, 구현됨).
|
||||
- 라이브 override로 검증 가능한 건 **S1~S5(ramp-advisor 경로)뿐** — 전부 통과 확인(2026-06-01).
|
||||
- 후속 옵션: 엔진 스냅샷 빌드도 `ISimOverrideStore` 경유하도록 확장하면 S6·S7도 override로 자율 검증 가능(현재 미적용).
|
||||
|
||||
### 실행자 사용 예 (자율 루프)
|
||||
```
|
||||
curl -s -XPOST :5000/api/ff/sim/override -H 'Content-Type: application/json' \
|
||||
|
||||
39
src/Core/Application/Feedforward/FeedRampModels.cs
Normal file
39
src/Core/Application/Feedforward/FeedRampModels.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
namespace ExperionCrawler.Core.Application.Feedforward;
|
||||
|
||||
// WP3: Feed Ramp Advisor DTO (read-only 계산 결과). 컨트롤러에서 camelCase 익명객체로 매핑.
|
||||
public sealed record Bound(double Value, string Binding);
|
||||
|
||||
public sealed record SteamTarget(double? Fiq6115From, double? Fiq6115To, double? StartOpPct);
|
||||
|
||||
/// <summary>계산기 부가 입력(부가 태그값 + flooding 파라미터). null=미가용.</summary>
|
||||
public sealed record RampExtraInputs(
|
||||
double? Pica = null,
|
||||
double? Pi6111b = null,
|
||||
double? Ti6103 = null,
|
||||
double? Fiq6115 = null,
|
||||
double? Op = null,
|
||||
double FloodLimit = double.NaN,
|
||||
double FloodExponentN = 1.8);
|
||||
|
||||
public sealed record FeedRampAdvisory(
|
||||
int ColumnId,
|
||||
double CurrentFeed,
|
||||
double TargetFeed,
|
||||
double ClampedTarget,
|
||||
Bound Ceiling,
|
||||
Bound RampRate,
|
||||
double RampTimeMin,
|
||||
SteamTarget Steam,
|
||||
bool SimOverrideActive,
|
||||
bool Hold,
|
||||
string[] Warnings);
|
||||
|
||||
// WP0: Sim Override Layer — advisor가 읽는 입력값을 in-memory로 치환(제어 쓰기 아님).
|
||||
public interface ISimOverrideStore
|
||||
{
|
||||
bool Enabled { get; }
|
||||
void SetMany(bool enabled, IReadOnlyDictionary<string, double> values);
|
||||
void Clear();
|
||||
bool TryGet(string tag, out double value);
|
||||
IReadOnlyDictionary<string, double> Snapshot();
|
||||
}
|
||||
69
src/Infrastructure/Control/FeedRampAdvisorService.cs
Normal file
69
src/Infrastructure/Control/FeedRampAdvisorService.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using System.Globalization;
|
||||
using ExperionCrawler.Core.Application.Feedforward;
|
||||
using ExperionCrawler.Core.Application.Interfaces;
|
||||
|
||||
namespace ExperionCrawler.Infrastructure.Control;
|
||||
|
||||
/// <summary>
|
||||
/// WP3-C: 라이브 데이터 수집 + FeedRampCalculator 호출. 읽기 전용(쓰기 없음).
|
||||
/// Sim Override(WP0) 활성 시 해당 태그는 override값 우선. extras 태그는 C-6111 스코프 하드코딩(작업지시서 §5).
|
||||
/// </summary>
|
||||
public sealed class FeedRampAdvisorService
|
||||
{
|
||||
private readonly IFeedforwardConfigStore _cfgStore;
|
||||
private readonly IExperionDbService _db;
|
||||
private readonly ISimOverrideStore _sim;
|
||||
|
||||
public FeedRampAdvisorService(IFeedforwardConfigStore cfgStore, IExperionDbService db, ISimOverrideStore sim)
|
||||
{ _cfgStore = cfgStore; _db = db; _sim = sim; }
|
||||
|
||||
public async Task<FeedRampAdvisory?> ComputeAsync(
|
||||
int columnId, double targetFeed, double deltaIAllow,
|
||||
double sensibleGain, double feedTempRef, double floodLimit, double n, CancellationToken ct = default)
|
||||
{
|
||||
var cfg = (await _cfgStore.LoadAllAsync(ct)).FirstOrDefault(c => c.Id == columnId);
|
||||
if (cfg is null) return null;
|
||||
|
||||
static string PvTag(string baseTag)
|
||||
{ var t = baseTag.ToLowerInvariant(); return t.EndsWith(".pv") ? t : t + ".pv"; }
|
||||
|
||||
string picaTag = PvTag(cfg.PressureTag ?? "pica-6111");
|
||||
const string piTag = "pi-6111b.pv", tiTag = "ti-6103.pv", steamTag = "ficq-6115.pv", opTag = "tica-6111a.op";
|
||||
|
||||
var tags = new List<string> { PvTag(cfg.FeedTag), picaTag, piTag, tiTag, steamTag, opTag };
|
||||
tags.AddRange(cfg.Streams.Select(s => PvTag(s.FlowTag)));
|
||||
|
||||
var rows = (await _db.GetRealtimeRecordsByTagNamesAsync(tags))
|
||||
.ToDictionary(r => r.TagName.ToLowerInvariant(), r => r);
|
||||
|
||||
bool TryRead(string tag, out double v)
|
||||
{
|
||||
tag = tag.ToLowerInvariant();
|
||||
if (_sim.Enabled && _sim.TryGet(tag, out v)) return true; // override(신선)
|
||||
if (rows.TryGetValue(tag, out var r) && r.LiveValue is not null
|
||||
&& double.TryParse(r.LiveValue, NumberStyles.Float, CultureInfo.InvariantCulture, out v)
|
||||
&& (DateTime.UtcNow - r.Timestamp.ToUniversalTime()).TotalSeconds <= cfg.StaleSec)
|
||||
return true;
|
||||
v = double.NaN; return false;
|
||||
}
|
||||
|
||||
TagSample Sample(string baseTag)
|
||||
{
|
||||
var tag = PvTag(baseTag);
|
||||
bool ok = TryRead(tag, out var v);
|
||||
return new TagSample(tag, ok ? v : double.NaN, ok, DateTime.UtcNow);
|
||||
}
|
||||
double? Opt(string tag) => TryRead(tag, out var v) ? v : (double?)null;
|
||||
|
||||
var feed = Sample(cfg.FeedTag);
|
||||
var streams = cfg.Streams.ToDictionary(s => s.Key, s => Sample(s.FlowTag));
|
||||
var pv = new PvSnapshot(feed, null, Array.Empty<TagSample>(), streams);
|
||||
|
||||
var extra = new RampExtraInputs(
|
||||
Pica: Opt(picaTag), Pi6111b: Opt(piTag), Ti6103: Opt(tiTag),
|
||||
Fiq6115: Opt(steamTag), Op: Opt(opTag),
|
||||
FloodLimit: floodLimit, FloodExponentN: n);
|
||||
|
||||
return FeedRampCalculator.Compute(cfg, pv, targetFeed, deltaIAllow, sensibleGain, feedTempRef, extra, _sim.Enabled);
|
||||
}
|
||||
}
|
||||
109
src/Infrastructure/Control/FeedRampCalculator.cs
Normal file
109
src/Infrastructure/Control/FeedRampCalculator.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using ExperionCrawler.Core.Application.Feedforward;
|
||||
|
||||
namespace ExperionCrawler.Infrastructure.Control;
|
||||
|
||||
/// <summary>
|
||||
/// WP3 안전 피드램프 계산기 — 순수 함수(부수효과 0, DB 무관). 단위테스트 대상.
|
||||
/// 한계치(ceiling)·램프율(rate)·예상시간·binding 제약·스팀(FIQ-6115) 목표를 산출. 쓰기 없음.
|
||||
/// 공식: docs/안전피드램프-검증시나리오매트릭스.md §0, docs/안전피드램프-한계치-브레인스토밍.md §3·§7.
|
||||
/// </summary>
|
||||
public static class FeedRampCalculator
|
||||
{
|
||||
public static FeedRampAdvisory Compute(
|
||||
ColumnConfig cfg, PvSnapshot pv, double targetFeed, double deltaIAllow,
|
||||
double sensibleGain, double feedTempRef, RampExtraInputs extra, bool simOverrideActive)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
|
||||
// 1) 피드 — bad/정지 시 HOLD
|
||||
if (!pv.Feed.Good || !Num.IsFinite(pv.Feed.Value) || pv.Feed.Value <= 0)
|
||||
{
|
||||
warnings.Add("feed-bad: 피드 PV 불량/정지/stale → HOLD");
|
||||
return new FeedRampAdvisory(cfg.Id, double.NaN, targetFeed, double.NaN,
|
||||
new Bound(double.NaN, "hold"), new Bound(double.NaN, "hold"), 0,
|
||||
new SteamTarget(null, null, null), simOverrideActive, true, warnings.ToArray());
|
||||
}
|
||||
double currentFeed = pv.Feed.Value;
|
||||
|
||||
// 2) Ceiling
|
||||
double valveCeiling = double.PositiveInfinity; string valveBind = "none";
|
||||
foreach (var s in cfg.Streams)
|
||||
{
|
||||
if (s.TargetCoeff <= 0 || !Num.IsFinite(s.SpMax) || s.SpMax >= double.MaxValue) continue;
|
||||
double c = s.SpMax / s.TargetCoeff;
|
||||
if (c < valveCeiling) { valveCeiling = c; valveBind = $"{s.Key} sp_max"; }
|
||||
}
|
||||
|
||||
double floodCeiling = double.PositiveInfinity;
|
||||
if (extra.Pica.HasValue && extra.Pi6111b.HasValue && Num.IsFinite(extra.FloodLimit) && extra.FloodLimit > 0)
|
||||
{
|
||||
double dPnow = extra.Pi6111b.Value - extra.Pica.Value; // 전탑 ΔP (진공: 탑저>탑정)
|
||||
if (dPnow > 0)
|
||||
{
|
||||
double n = extra.FloodExponentN > 0 ? extra.FloodExponentN : 1.8;
|
||||
floodCeiling = currentFeed * Math.Pow(extra.FloodLimit / dPnow, 1.0 / n);
|
||||
}
|
||||
else warnings.Add("flooding: ΔP(pi-6111b−pica-6111)≤0 → ceiling 미산정");
|
||||
}
|
||||
else warnings.Add("flooding ceiling 미산정(ΔP 또는 floodLimit 부재)");
|
||||
warnings.Add("steam ceiling 미산정(최대 OP/스팀 근거 부재)");
|
||||
|
||||
double ceiling; string ceilBind;
|
||||
if (floodCeiling < valveCeiling) { ceiling = floodCeiling; ceilBind = "flooding"; }
|
||||
else { ceiling = valveCeiling; ceilBind = valveBind; }
|
||||
if (double.IsPositiveInfinity(ceiling)) warnings.Add("ceiling 무제한(밸브 sp_max 부재)");
|
||||
|
||||
// 3) Ramp rate (kg/hr per min)
|
||||
double valveSlew = double.PositiveInfinity; string slewBind = "valveSlew";
|
||||
foreach (var s in cfg.Streams)
|
||||
{
|
||||
if (s.Role != StreamRole.Commanded || s.RateUpPerMin <= 0 || s.TargetCoeff <= 0) continue;
|
||||
double r = s.RateUpPerMin / s.TargetCoeff;
|
||||
if (r < valveSlew) { valveSlew = r; slewBind = $"valveSlew@{s.Key}"; }
|
||||
}
|
||||
|
||||
// dynamic: lagging Commanded 합 ΣK_i(τ_i+θ_up_i) (C-6111은 P만 τ≠0)
|
||||
double denom = 0;
|
||||
foreach (var s in cfg.Streams)
|
||||
if (s.Role == StreamRole.Commanded) denom += s.TargetCoeff * (s.TauSec + s.ThetaUpSec);
|
||||
double dynamic = double.PositiveInfinity;
|
||||
if (denom > 0 && deltaIAllow > 0) dynamic = deltaIAllow * 60.0 / denom;
|
||||
else warnings.Add("dynamic 제약 미산정(lag 0 또는 ΔI≤0)");
|
||||
|
||||
warnings.Add("energyLoop 제약 미산정(에너지루프 시상수 부재)");
|
||||
|
||||
double rFeed; string rateBind;
|
||||
if (valveSlew <= dynamic) { rFeed = valveSlew; rateBind = slewBind; }
|
||||
else { rFeed = dynamic; rateBind = "dynamic"; }
|
||||
if (double.IsPositiveInfinity(rFeed)) { rateBind = "none"; warnings.Add("램프율 무제한(제약 없음)"); }
|
||||
|
||||
// 4) clamp
|
||||
double clampedTarget = Math.Min(targetFeed, ceiling);
|
||||
|
||||
// 5) time
|
||||
double rampTimeMin = 0;
|
||||
if (clampedTarget > currentFeed && Num.IsFinite(rFeed) && rFeed > 0)
|
||||
rampTimeMin = (clampedTarget - currentFeed) / rFeed;
|
||||
else if (clampedTarget < currentFeed)
|
||||
warnings.Add("down-ramp: rate_dn 분기 미구현(시간 0 표기)");
|
||||
|
||||
// 6) Steam target = 처리량비례 + 현열보정(앵커 대비 편차) (a안)
|
||||
double? steamFrom = extra.Fiq6115;
|
||||
double? steamTo = null, startOp = null;
|
||||
if (extra.Fiq6115.HasValue && currentFeed > 0)
|
||||
{
|
||||
double ratio = extra.Fiq6115.Value * (clampedTarget / currentFeed);
|
||||
double sensible = 0;
|
||||
if (Num.IsFinite(sensibleGain) && sensibleGain != 0 && Num.IsFinite(feedTempRef) && extra.Ti6103.HasValue)
|
||||
sensible = sensibleGain * clampedTarget * (feedTempRef - extra.Ti6103.Value);
|
||||
else warnings.Add("현열보정 off(sensibleGain/feedTempRef/ti-6103 부재)");
|
||||
steamTo = ratio + sensible;
|
||||
warnings.Add("startOP 미산정(local gain 부재)");
|
||||
}
|
||||
else warnings.Add("steam 미산정(ficq-6115 부재)");
|
||||
|
||||
return new FeedRampAdvisory(cfg.Id, currentFeed, targetFeed, clampedTarget,
|
||||
new Bound(ceiling, ceilBind), new Bound(rFeed, rateBind), rampTimeMin,
|
||||
new SteamTarget(steamFrom, steamTo, startOp), simOverrideActive, false, warnings.ToArray());
|
||||
}
|
||||
}
|
||||
33
src/Infrastructure/Control/SimOverrideStore.cs
Normal file
33
src/Infrastructure/Control/SimOverrideStore.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ExperionCrawler.Core.Application.Feedforward;
|
||||
|
||||
namespace ExperionCrawler.Infrastructure.Control;
|
||||
|
||||
/// <summary>
|
||||
/// WP0 Sim Override — 싱글톤 in-memory 입력 치환. advisor가 읽는 태그값만 대체(제어/DB 쓰기 아님).
|
||||
/// thread-safety: 싱글톤·동시 HTTP → ConcurrentDictionary + volatile bool (ARM64 약한 메모리 모델 대비).
|
||||
/// 주의: per-key 안전만 보장(SetMany/Snapshot 그룹 원자성 X) — 순차 테스트 사용 전제.
|
||||
/// </summary>
|
||||
public sealed class SimOverrideStore : ISimOverrideStore
|
||||
{
|
||||
private volatile bool _enabled;
|
||||
private readonly ConcurrentDictionary<string, double> _values = new();
|
||||
|
||||
public bool Enabled => _enabled;
|
||||
|
||||
public void SetMany(bool enabled, IReadOnlyDictionary<string, double> values)
|
||||
{
|
||||
foreach (var kv in values) _values[kv.Key.ToLowerInvariant()] = kv.Value;
|
||||
_enabled = enabled;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_values.Clear();
|
||||
_enabled = false;
|
||||
}
|
||||
|
||||
public bool TryGet(string tag, out double value) => _values.TryGetValue(tag.ToLowerInvariant(), out value);
|
||||
|
||||
public IReadOnlyDictionary<string, double> Snapshot() => new Dictionary<string, double>(_values);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using ExperionCrawler.Core.Application.Interfaces;
|
||||
using ExperionCrawler.Core.Domain.Entities;
|
||||
using ExperionCrawler.Infrastructure.Kb;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ExperionCrawler.Web.Controllers;
|
||||
|
||||
@@ -26,9 +27,15 @@ public sealed class FeedforwardController : ControllerBase
|
||||
IExperionOpcWriteClient writeClient,
|
||||
IKbAuthService auth,
|
||||
Microsoft.Extensions.Configuration.IConfiguration appConfig,
|
||||
ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor supervisor)
|
||||
ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor supervisor,
|
||||
ISimOverrideStore sim,
|
||||
ExperionCrawler.Infrastructure.Control.FeedRampAdvisorService ramp)
|
||||
{ _store = store; _config = config; _audit = audit; _writeGuard = writeGuard;
|
||||
_writeClient = writeClient; _auth = auth; _appConfig = appConfig; _supervisor = supervisor; }
|
||||
_writeClient = writeClient; _auth = auth; _appConfig = appConfig; _supervisor = supervisor;
|
||||
_sim = sim; _ramp = ramp; }
|
||||
|
||||
private readonly ISimOverrideStore _sim;
|
||||
private readonly ExperionCrawler.Infrastructure.Control.FeedRampAdvisorService _ramp;
|
||||
|
||||
private async Task<bool> AuthAsync(CancellationToken ct)
|
||||
=> await _auth.ValidateAsync(Request.Headers["X-Kb-Token"].ToString(), ct);
|
||||
@@ -153,6 +160,57 @@ public sealed class FeedforwardController : ControllerBase
|
||||
})
|
||||
};
|
||||
|
||||
// ── WP3: Feed Ramp Advisor (read-only) ────────────────────────
|
||||
[HttpGet("ramp-advisor")]
|
||||
public async Task<IActionResult> RampAdvisor(
|
||||
[FromQuery] int columnId, [FromQuery] double targetFeed,
|
||||
[FromQuery] double deltaIAllow = 50, [FromQuery] double sensibleGain = double.NaN,
|
||||
[FromQuery] double feedTempRef = double.NaN, [FromQuery] double floodLimit = double.NaN,
|
||||
[FromQuery] double n = 1.8, CancellationToken ct = default)
|
||||
{
|
||||
var a = await _ramp.ComputeAsync(columnId, targetFeed, deltaIAllow, sensibleGain, feedTempRef, floodLimit, n, ct);
|
||||
if (a is null) return NotFound(new { error = "config 없음" });
|
||||
return Ok(MapRamp(a));
|
||||
}
|
||||
|
||||
// ── WP0: Sim Override (DEMO 게이트, 입력 치환 — 제어 쓰기 아님) ──
|
||||
private bool SimEnabled() => _appConfig.GetValue<bool>("Feedforward:SimOverrideEnabled");
|
||||
|
||||
[HttpGet("sim/override")]
|
||||
public IActionResult GetSimOverride()
|
||||
{
|
||||
if (!SimEnabled()) return StatusCode(403, new { error = "SimOverride 비활성(Feedforward:SimOverrideEnabled=false)" });
|
||||
return Ok(new { enabled = _sim.Enabled, values = _sim.Snapshot() });
|
||||
}
|
||||
|
||||
[HttpPost("sim/override")]
|
||||
public IActionResult SetSimOverride([FromBody] SimOverrideBody body)
|
||||
{
|
||||
if (!SimEnabled()) return StatusCode(403, new { error = "SimOverride 비활성" });
|
||||
_sim.SetMany(body.enabled, body.values ?? new Dictionary<string, double>());
|
||||
return Ok(new { enabled = _sim.Enabled, values = _sim.Snapshot() });
|
||||
}
|
||||
|
||||
[HttpDelete("sim/override")]
|
||||
public IActionResult ClearSimOverride()
|
||||
{
|
||||
if (!SimEnabled()) return StatusCode(403, new { error = "SimOverride 비활성" });
|
||||
_sim.Clear();
|
||||
return Ok(new { enabled = _sim.Enabled });
|
||||
}
|
||||
|
||||
private static double? Fin(double v) => (double.IsNaN(v) || double.IsInfinity(v)) ? (double?)null : v;
|
||||
private static object MapRamp(FeedRampAdvisory a) => new
|
||||
{
|
||||
columnId = a.ColumnId,
|
||||
currentFeed = Fin(a.CurrentFeed), targetFeed = Fin(a.TargetFeed), clampedTarget = Fin(a.ClampedTarget),
|
||||
ceiling = new { value = Fin(a.Ceiling.Value), binding = a.Ceiling.Binding },
|
||||
rampRate = new { value = Fin(a.RampRate.Value), binding = a.RampRate.Binding },
|
||||
rampTimeMin = Fin(a.RampTimeMin),
|
||||
steam = new { fiq6115From = a.Steam.Fiq6115From, fiq6115To = a.Steam.Fiq6115To, startOpPct = a.Steam.StartOpPct },
|
||||
simOverrideActive = a.SimOverrideActive, hold = a.Hold, warnings = a.Warnings
|
||||
};
|
||||
|
||||
// ── Advisory (공개 읽기) ───────────────────────────────────────
|
||||
[HttpGet("advisory")]
|
||||
public IActionResult GetAll() => Ok(new
|
||||
@@ -231,3 +289,9 @@ public sealed class FeedforwardController : ControllerBase
|
||||
}
|
||||
|
||||
public sealed record WriteSpBody { public double? value { get; init; } }
|
||||
|
||||
public sealed record SimOverrideBody
|
||||
{
|
||||
public bool enabled { get; init; }
|
||||
public Dictionary<string, double>? values { get; init; }
|
||||
}
|
||||
|
||||
@@ -129,6 +129,9 @@ builder.Services.AddScoped<ExperionCrawler.Core.Application.Feedforward.IFeedfor
|
||||
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>());
|
||||
// WP0/WP3: Sim Override(입력 치환) + Feed Ramp Advisor(read-only)
|
||||
builder.Services.AddSingleton<ExperionCrawler.Core.Application.Feedforward.ISimOverrideStore, ExperionCrawler.Infrastructure.Control.SimOverrideStore>();
|
||||
builder.Services.AddScoped<ExperionCrawler.Infrastructure.Control.FeedRampAdvisorService>();
|
||||
|
||||
// ── P&ID Services ───────────────────────────────────────────────────────────────
|
||||
builder.Services.AddScoped<IPidExtractorService, PidExtractorService>();
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
"Password": "mngr"
|
||||
}
|
||||
},
|
||||
"Feedforward": {
|
||||
"SimOverrideEnabled": true
|
||||
},
|
||||
"Fast": {
|
||||
"MaxConcurrentSessions": 3,
|
||||
"MaxRowsPerSession": 5000000,
|
||||
|
||||
109
tests/ExperionCrawler.Tests/FeedRampCalculatorTests.cs
Normal file
109
tests/ExperionCrawler.Tests/FeedRampCalculatorTests.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ExperionCrawler.Core.Application.Feedforward;
|
||||
using ExperionCrawler.Infrastructure.Control;
|
||||
using Xunit;
|
||||
|
||||
namespace ExperionCrawler.Tests;
|
||||
|
||||
// WP3-F: docs/안전피드램프-검증시나리오매트릭스.md S0~S6 (S7은 엔진 mbState라 제외).
|
||||
public class FeedRampCalculatorTests
|
||||
{
|
||||
private static ColumnConfig C6111() => new()
|
||||
{
|
||||
Id = 1, Name = "C-6111", Enabled = true, FeedTag = "ficq-6101", ProductKey = "P",
|
||||
ScanSec = 2, StaleSec = 120, PressureTag = "pica-6111", TempTags = Array.Empty<string>(),
|
||||
Streams = new[]
|
||||
{
|
||||
new StreamConfig { Key = "P", FlowTag = "ficq-6118", Role = StreamRole.Commanded, TargetCoeff = 0.95, ThetaUpSec = 60, ThetaDnSec = 60, TauSec = 900, SpMin = 0, SpMax = 2000, RateUpPerMin = 30, RateDnPerMin = 60, Grade = Confidence.A },
|
||||
new StreamConfig { Key = "R", FlowTag = "ficq-6113", Role = StreamRole.Commanded, TargetCoeff = 0.80, ThetaUpSec = 0, ThetaDnSec = 0, TauSec = 0, SpMin = 0, SpMax = 2000, RateUpPerMin = 30, RateDnPerMin = 30, Grade = Confidence.A },
|
||||
new StreamConfig { Key = "D", FlowTag = "ficq-6114", Role = StreamRole.LevelDriven, TargetCoeff = 0.02, SpMin = 0, SpMax = 1000, Grade = Confidence.B },
|
||||
new StreamConfig { Key = "B", FlowTag = "ficq-6116", Role = StreamRole.LevelDriven, TargetCoeff = 0.03, SpMin = 0, SpMax = 500, Grade = Confidence.B },
|
||||
}
|
||||
};
|
||||
|
||||
private static PvSnapshot Snap(double feed, bool good = true) => new(
|
||||
new TagSample("ficq-6101.pv", feed, good, DateTime.UtcNow), null,
|
||||
Array.Empty<TagSample>(),
|
||||
new Dictionary<string, TagSample>
|
||||
{
|
||||
["P"] = new("ficq-6118.pv", 0.95 * feed, true, DateTime.UtcNow),
|
||||
["R"] = new("ficq-6113.pv", 0.80 * feed, true, DateTime.UtcNow),
|
||||
["D"] = new("ficq-6114.pv", 0.02 * feed, true, DateTime.UtcNow),
|
||||
["B"] = new("ficq-6116.pv", 0.03 * feed, true, DateTime.UtcNow),
|
||||
});
|
||||
|
||||
// anchor extras: pica40, pi80(ΔP40), ti6103=150, fiq6115=300
|
||||
private static RampExtraInputs Extra(double? pi6111b = 80, double ti6103 = 150, double floodLimit = double.NaN, double n = 1.8)
|
||||
=> new(Pica: 40, Pi6111b: pi6111b, Ti6103: ti6103, Fiq6115: 300, Op: null, FloodLimit: floodLimit, FloodExponentN: n);
|
||||
|
||||
private static void Near(double expected, double actual, double tolFrac = 0.02)
|
||||
=> Assert.True(Math.Abs(actual - expected) <= Math.Abs(expected) * tolFrac + 1e-6,
|
||||
$"expected≈{expected}, actual={actual}");
|
||||
|
||||
[Fact] // S0 평형
|
||||
public void S0_baseline_no_change()
|
||||
{
|
||||
var a = FeedRampCalculator.Compute(C6111(), Snap(900), 900, 50, 0.001, 150, Extra(), false);
|
||||
Assert.False(a.Hold);
|
||||
Near(0, a.RampTimeMin, 0); Assert.Equal(0, a.RampTimeMin);
|
||||
Near(2105.26, a.Ceiling.Value); Assert.Equal("P sp_max", a.Ceiling.Binding);
|
||||
Near(300, a.Steam.Fiq6115To!.Value);
|
||||
}
|
||||
|
||||
[Fact] // S1 동특성 binding
|
||||
public void S1_dynamic_binding()
|
||||
{
|
||||
var a = FeedRampCalculator.Compute(C6111(), Snap(900), 1100, 50, 0.001, 150, Extra(), false);
|
||||
Assert.Equal("dynamic", a.RampRate.Binding);
|
||||
Near(3.289, a.RampRate.Value);
|
||||
Near(60.8, a.RampTimeMin);
|
||||
Near(366.7, a.Steam.Fiq6115To!.Value);
|
||||
Near(2105.26, a.Ceiling.Value);
|
||||
}
|
||||
|
||||
[Fact] // S2 밸브 슬루 binding (ΔI 크게)
|
||||
public void S2_valveslew_binding()
|
||||
{
|
||||
var a = FeedRampCalculator.Compute(C6111(), Snap(900), 1100, 600, 0.001, 150, Extra(), false);
|
||||
Assert.Equal("valveSlew@P", a.RampRate.Binding);
|
||||
Near(31.58, a.RampRate.Value);
|
||||
Near(6.33, a.RampTimeMin);
|
||||
}
|
||||
|
||||
[Fact] // S3 ceiling 초과 → 클램프
|
||||
public void S3_ceiling_clamp()
|
||||
{
|
||||
var a = FeedRampCalculator.Compute(C6111(), Snap(900), 2200, 50, 0.001, 150, Extra(), false);
|
||||
Near(2105.26, a.ClampedTarget);
|
||||
Assert.Equal("P sp_max", a.Ceiling.Binding);
|
||||
Assert.Equal(2200, a.TargetFeed);
|
||||
}
|
||||
|
||||
[Fact] // S4 flooding ceiling
|
||||
public void S4_flooding_ceiling()
|
||||
{
|
||||
var a = FeedRampCalculator.Compute(C6111(), Snap(900), 1100, 50, 0.001, 150,
|
||||
Extra(pi6111b: 120, floodLimit: 100, n: 1.8), false); // ΔPnow=80
|
||||
Assert.Equal("flooding", a.Ceiling.Binding);
|
||||
Near(1018.8, a.Ceiling.Value);
|
||||
Near(1018.8, a.ClampedTarget);
|
||||
}
|
||||
|
||||
[Fact] // S5 TI-6103 하강 현열 FF (피드 무변에도 steam↑)
|
||||
public void S5_sensible_correction()
|
||||
{
|
||||
var a = FeedRampCalculator.Compute(C6111(), Snap(900), 900, 50, 0.001, 150,
|
||||
Extra(ti6103: 140), false); // 150→140, 피드 고정
|
||||
Near(309.0, a.Steam.Fiq6115To!.Value); // 300 + 0.001*900*10
|
||||
Assert.Equal(0, a.RampTimeMin);
|
||||
}
|
||||
|
||||
[Fact] // S6 stale/HOLD
|
||||
public void S6_feed_bad_holds()
|
||||
{
|
||||
var a = FeedRampCalculator.Compute(C6111(), Snap(900, good: false), 1100, 50, 0.001, 150, Extra(), false);
|
||||
Assert.True(a.Hold);
|
||||
Assert.Contains(a.Warnings, w => w.Contains("feed-bad"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user