Files
ExperionCrawler/src/Infrastructure/Control/FeedRampAdvisorService.cs
windpacer 54ca4d0d62 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>
2026-06-01 16:22:11 +09:00

70 lines
3.2 KiB
C#

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