feat: Feedforward 제어 + shadow store

FF 엔진/슈퍼바이저/FeedRamp 개선, shadow store(IFfShadowStore/FfShadowStore) 추가, FF 컨트롤러·UI(ff.js/ff.css).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-06-10 08:11:44 +09:00
parent 961e819d3c
commit d040388557
9 changed files with 247 additions and 36 deletions

View File

@@ -92,6 +92,8 @@ public sealed record PvSnapshot(
{
public IReadOnlyList<TagSample>? Temps { get; init; }
public TagSample? SteamOp { get; init; } // WO-3: TICA.OP (스팀, 부분상관 2번째 입력)
public TagSample? SteamPv { get; init; } // TICA.PV (BOTTOM 온도)
public TagSample? SteamSp { get; init; } // TICA.SP (BOTTOM SP)
public TagSample? DeltaP { get; init; } // WO-6: 탑 차압(플러딩 트리거)
public TagSample? BottomPressure { get; init; } // WP6: 탑저 압력(ΔP 합성·국소 PCT용)
}
@@ -156,6 +158,10 @@ public sealed record AdvisoryResult(
public double? TempLowLimit { get; init; } // T_C 하한 임계(℃)
// 작업플랜-민감단온도 모듈1: T_C 유지 Steam OP 제안
public double? SteamRecOp { get; init; } // 권장 Steam OP(%)
public double? ActualSteamOp { get; init; } // 실제 Steam OP(%) — shadow 검증용
public string? SteamTagName { get; init; } // BOTTOM 태그명 (예: TICA-6111A)
public double? SteamPv { get; init; } // SteamTagName.PV (BOTTOM 온도, ℃)
public double? SteamSp { get; init; } // SteamTagName.SP (BOTTOM SP, ℃)
public string? SteamConfidence { get; init; } // HIGH/LOW_OOD/N/A
public double? TcDeviation { get; init; } // T_C 편차(℃) = actual - target
}

View File

@@ -0,0 +1,20 @@
namespace Hc900Crawler.Core.Application.Feedforward;
public sealed record FfShadowEntry
{
public int ColumnId { get; init; }
public DateTime Timestamp { get; init; }
public double? SteamRecOp { get; init; }
public string? SteamConfidence { get; init; }
public double? TcDeviation { get; init; }
public double? ActualSteamOp { get; init; }
public double? TcTemp { get; init; }
public double? TcTarget { get; init; }
}
public interface IFfShadowStore
{
void Add(FfShadowEntry entry);
IReadOnlyCollection<FfShadowEntry> GetRecent(int columnId, int count = 100);
void Clear(int columnId);
}

View File

@@ -34,12 +34,13 @@ public sealed class FeedforwardController : ControllerBase
ICompositionStore composition,
IFeedRampJobStore rampJobs,
IExperionDbService db,
IFfTrackingStore tracking)
IFfTrackingStore tracking, IFfShadowStore shadow)
{ _store = store; _config = config; _audit = audit; _writeGuard = writeGuard;
_writeClient = writeClient; _auth = auth; _appConfig = appConfig; _supervisor = supervisor;
_sim = sim; _ramp = ramp; _composition = composition; _rampJobs = rampJobs; _db = db; _tracking = tracking; }
_sim = sim; _ramp = ramp; _composition = composition; _rampJobs = rampJobs; _db = db; _tracking = tracking; _shadow = shadow; }
private readonly IFfTrackingStore _tracking;
private readonly IFfShadowStore _shadow;
private readonly ISimOverrideStore _sim;
private readonly Hc900Crawler.Infrastructure.Control.FeedRampAdvisorService _ramp;
@@ -185,6 +186,21 @@ public sealed class FeedforwardController : ControllerBase
return Ok(new { success = true });
}
// ── 모듈1 shadow 검증: 최근 shadow 데이터 조회 ──
[HttpGet("shadow/{columnId:int}")]
public IActionResult GetShadow(int columnId, [FromQuery] int count = 100)
{
var entries = _shadow.GetRecent(columnId, count);
return Ok(new { columnId, count = entries.Count, entries });
}
[HttpPost("shadow/{columnId:int}/clear")]
public IActionResult ClearShadow(int columnId)
{
_shadow.Clear(columnId);
return Ok(new { success = true });
}
// ── Phase II: 감사 로그 조회 ──
[HttpGet("audit")]
public async Task<IActionResult> GetAudit([FromQuery] int? columnId, [FromQuery] int limit = 50, CancellationToken ct = default)
@@ -251,7 +267,6 @@ public sealed class FeedforwardController : ControllerBase
[HttpPost("feed-ramp/{columnId:int}/start")]
public async Task<IActionResult> StartFeedRamp(int columnId, [FromBody] FeedRampStartBody body, CancellationToken ct)
{
if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" });
if (body is null || double.IsNaN(body.targetFeed) || double.IsInfinity(body.targetFeed))
return BadRequest(new { error = "targetFeed 값 필요" });
@@ -278,7 +293,6 @@ public sealed class FeedforwardController : ControllerBase
[HttpPost("feed-ramp/{columnId:int}/cancel")]
public async Task<IActionResult> CancelFeedRamp(int columnId, CancellationToken ct)
{
if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" });
bool ok = _rampJobs.Cancel(columnId);
if (ok)
await _audit.LogAsync(new FfActionLogEntry(columnId, "feed_ramp_cancel",
@@ -418,6 +432,41 @@ public sealed class FeedforwardController : ControllerBase
};
}).ToList();
// BOTTOM stream: SteamTagName에서 key/flowTag 자동 파생
if ((r.SteamPv.HasValue || r.SteamSp.HasValue) && r.SteamTagName is not null)
{
var tag = r.SteamTagName;
var key = tag.Length > 0 ? tag[..1].ToUpperInvariant() : "B";
streams.Add(new
{
tracking = false,
key = key,
flowTag = tag,
role = "Monitor",
levelTag = (string?)null,
pv = r.SteamPv,
recommendedSp = r.SteamSp,
writable = false,
gap = (double?)null,
trend = 0,
valid = true,
grade = "N/A",
note = "",
gradeReason = (string?)null,
thetaSuggestUpSec = (double?)null,
thetaSuggestDnSec = (double?)null,
thetaSuggestConf = (double?)null,
kObsSuggest = (double?)null,
compositionBase = (double?)null,
trim = (double?)null,
recommendedSpComposition = (double?)null,
trimSource = (string?)null,
lastWriteSp = (double?)null,
lastWriteError = (string?)null,
lastWriteAt = (DateTime?)null
});
}
return new
{
columnId = r.ColumnId,
@@ -452,6 +501,10 @@ public sealed class FeedforwardController : ControllerBase
tcReturnTcBand = r.TcReturnTcBand,
tempLowLimit = r.TempLowLimit,
steamRecOp = r.SteamRecOp,
actualSteamOp = r.ActualSteamOp,
steamTagName = r.SteamTagName,
steamPv = r.SteamPv,
steamSp = r.SteamSp,
steamConfidence = r.SteamConfidence,
tcDeviation = r.TcDeviation,
temps = r.Temps?.Select(t => new

View File

@@ -1,7 +1,7 @@
.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-dash{display:grid;grid-template-columns:repeat(auto-fill,minmax(520px,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}
@@ -141,14 +141,14 @@
/* ── FF T_C 온도계 위젯 (sweet spot thermometer) ──────────────── */
.ff-has-thermo{display:flex;flex-direction:row;gap:0}
.ff-card-main{flex:1;min-width:0}
.ff-thermo{display:flex;flex-direction:column;width:110px;flex-shrink:0;border-left:1px solid var(--bd);padding:4px 0 4px 8px}
.ff-thermo{display:flex;flex-direction:column;width:130px;flex-shrink:0;border-left:1px solid var(--bd);padding:4px 0 4px 8px}
.ff-thermo-hd{font-size:10px;font-weight:700;color:var(--t2);text-align:center;margin-bottom:2px;letter-spacing:-.3px}
.ff-thermo-body{flex:1;position:relative;min-height:180px}
.ff-thermo-track{position:absolute;inset:4px 0 4px 28px}
.ff-thermo-tick{position:absolute;left:-28px;right:0;height:0;border-top:1px solid rgba(255,255,255,.08);pointer-events:none}
.ff-thermo-tick span{position:absolute;right:calc(100% + 3px);top:-6px;font-size:9px;color:var(--t3);white-space:nowrap;font-variant-numeric:tabular-nums}
.ff-thermo-band{position:absolute;left:0;right:4px;background:rgba(100,200,100,.13);border:1px solid rgba(100,200,100,.25);border-radius:2px;pointer-events:none}
.ff-thermo-limit{position:absolute;left:-4px;right:0;height:0;border-top:2px dashed #ff5252;pointer-events:none}
.ff-thermo-track{position:absolute;inset:4px 28px 4px 0}
.ff-thermo-tick{position:absolute;left:0;right:28px;height:0;border-top:1px solid rgba(255,255,255,.08);pointer-events:none}
.ff-thermo-tick span{position:absolute;left:calc(100% + 4px);top:-6px;font-size:9px;color:var(--t3);white-space:nowrap;font-variant-numeric:tabular-nums}
.ff-thermo-band{position:absolute;left:0;right:28px;background:rgba(100,200,100,.13);border:1px solid rgba(100,200,100,.25);border-radius:2px;pointer-events:none}
.ff-thermo-limit{position:absolute;left:0;right:28px;height:0;border-top:2px dashed #ff5252;pointer-events:none}
.ff-thermo-mark{position:absolute;left:0;right:0;height:0;display:flex;align-items:center;gap:3px;pointer-events:none;margin-top:-6px}
.ff-thermo-dot{display:flex;align-items:center;justify-content:center;width:10px;height:10px;font-size:6px;color:var(--t2);background:var(--bg2);border:1px solid var(--t3);border-radius:50%;line-height:1;margin-left:2px;z-index:1}
.ff-thermo-c{width:16px;height:16px;font-size:12px;font-weight:700;background:var(--bg1);border-width:2px;z-index:2}
@@ -159,4 +159,6 @@
.ff-thermo-lbl small{font-size:8px;color:var(--t3);line-height:1}
.ff-thermo-arrow{text-align:center;font-size:16px;line-height:1;padding:1px 0}
.ff-thermo-ft{font-size:9px;color:var(--t2);text-align:center;line-height:1.3;padding:2px 0;border-top:1px solid var(--bd);margin-top:2px}
.ff-thermo-ft .ff-tc-ok{color:#69f0ae}.ff-thermo-ft .ff-tc-low{color:#ff5252}
.ff-thermo-ft .ff-tc-ok{color:#69f0ae}.ff-thermo-ft .ff-tc-warn{color:#ffd24d}.ff-thermo-ft .ff-tc-low{color:#ff5252}
.ff-thermo-ft .ff-tc-rec{color:#7fd1ff;font-weight:600}
.ff-thermo-ft .ff-tc-actual{color:var(--t3);font-size:8px;opacity:.7}

View File

@@ -286,14 +286,22 @@ function ffThermometer(c) {
const limitHtml = tll != null
? `<div class="ff-thermo-limit" style="top:${toY(tll)}%"></div>` : '';
const trayLabels = ['reb-A', 'B', 'C', 'D'];
const trayPcts = ['0%', '50%', '70%', '90%'];
const markerLabels = temps.map((t, i) => {
const tag = (t.tag || '').replace(/\.pv$/i, '').toUpperCase();
const m = tag.match(/[A-Z]+$/);
const suffix = m ? m[0] : '';
if (suffix && !suffix.match(/^\d+$/)) return suffix;
const def = ['A', 'B', 'C', 'D', 'E', 'F', 'G'];
return def[i] || 'T' + i;
});
const markerPcts = temps.map((_, i) => `${Math.round((i / Math.max(temps.length - 1, 1)) * 100)}%`);
const n = temps.length - 1;
const markers = temps.map((t, i) => {
if (!t.good || t.raw == null) return '';
const y = toY(t.raw);
const y = n > 0 ? (1 - i / n) * 100 : 50; // 인덱스 기준 위치 (0=A=밑→100%, n=D=위→0%)
const isC = i === cIdx;
const label = trayLabels[i] || 'T' + i;
const pct = trayPcts[i] || '';
const label = markerLabels[i];
const pct = markerPcts[i];
const cState = isC && tcTarget != null
? (t.raw < tcTarget - tcBand ? 'low' : t.raw > tcTarget + tcBand ? 'high' : 'ok') : '';
const dotCls = isC
@@ -308,19 +316,26 @@ function ffThermometer(c) {
const arrow = cState === 'low' ? '▼' : cState === 'high' ? '▲' : '';
const arrowCls = 'ff-thermo-arrow' + (cState ? ' ff-tc-' + cState : '');
// 옵션 푸터: Module 1 T_C 유지 SP 제안
// 옵션 푸터: Module 1 T_C 유지 SP 제안 + 실제 OP 비교
const footer = (c.steamRecOp != null)
? `<div class="ff-thermo-ft">conf ${esc(c.steamConfidence||'')} · SP→${fmtVal(c.steamRecOp)}${c.tcDeviation!=null?` <span class="ff-tc-${Math.abs(c.tcDeviation)>1?'low':Math.abs(c.tcDeviation)>0.1?'':'ok'}">ε${fmtVal(c.tcDeviation)}</span>`:''}</div>`
? (() => {
const dev = c.tcDeviation;
const devCls = dev != null ? (Math.abs(dev) > 1 ? 'low' : Math.abs(dev) > 0.3 ? 'warn' : 'ok') : '';
const devHtml = dev != null ? `<span class="ff-tc-${devCls}">ε${dev >= 0 ? '+' : ''}${fmtVal(dev)}℃</span>` : '';
const actualHtml = (c.actualSteamOp != null && c.actualSteamOp !== c.steamRecOp)
? ` <span class="ff-tc-actual">실${fmtVal(c.actualSteamOp)}</span>` : '';
return `<div class="ff-thermo-ft">conf ${esc(c.steamConfidence||'')} · <span class="ff-tc-rec">SP→${fmtVal(c.steamRecOp)}</span>${actualHtml}<br>${devHtml}</div>`;
})()
: '';
return `<div class="ff-thermo">
<div class="ff-thermo-hd">T_C (℃)</div>
<div class="ff-thermo-body"><div class="ff-thermo-track">
<div class="ff-thermo-tick" style="top:0%"><span>${fmtVal(hi)}</span></div>
<div class="ff-thermo-tick" style="top:25%"><span>${fmtVal(hi-(hi-lo)*0.25)}</span></div>
<div class="ff-thermo-tick" style="top:0%"><span>${fmtVal(lo)}</span></div>
<div class="ff-thermo-tick" style="top:25%"><span>${fmtVal(lo+(hi-lo)*0.25)}</span></div>
<div class="ff-thermo-tick" style="top:50%"><span>${fmtVal(lo+(hi-lo)*0.5)}</span></div>
<div class="ff-thermo-tick" style="top:75%"><span>${fmtVal(lo+(hi-lo)*0.25)}</span></div>
<div class="ff-thermo-tick" style="top:100%"><span>${fmtVal(lo)}</span></div>
<div class="ff-thermo-tick" style="top:75%"><span>${fmtVal(lo+(hi-lo)*0.75)}</span></div>
<div class="ff-thermo-tick" style="top:100%"><span>${fmtVal(hi)}</span></div>
${bandHtml}
${limitHtml}
${markers}

View File

@@ -120,6 +120,7 @@ public sealed class FeedRampExecutorService : BackgroundService
double rampRate = adv.RampRate.Value;
double ceiling = adv.Ceiling.Value;
double targetCap = Math.Min(job.TargetFeed, double.IsFinite(ceiling) ? ceiling : job.TargetFeed);
bool goingDown = double.IsFinite(job.LastWrittenSp) && targetCap < job.LastWrittenSp - 1e-6;
// 앵커: 첫 단계 — LastWrittenSp를 현재 FEED WSP(설정값)로 시드(범프리스 시작).
// ※ 측정 PV(currentFeed)는 데드타임·필터 지연값이므로 앵커로 쓰지 않음. SP는 WSP에서 출발해
@@ -140,8 +141,13 @@ public sealed class FeedRampExecutorService : BackgroundService
return;
}
// 도달 판정
if (job.LastWrittenSp >= targetCap - Eps)
// 도달 판정 (4-A: 방향 인지)
bool reached;
if (goingDown)
reached = job.LastWrittenSp <= targetCap + Eps; // 하강: LastWrittenSp가 targetCap 이하로 내려옴
else
reached = job.LastWrittenSp >= targetCap - Eps; // 상승: LastWrittenSp가 targetCap 이상으로 올라감
if (reached)
{
_jobs.Update(job with
{
@@ -171,8 +177,6 @@ public sealed class FeedRampExecutorService : BackgroundService
double minIntervalSec = Math.Max(cfg.ScanSec * 2, 2.0);
if ((now - lastStep).TotalSeconds < minIntervalSec) return;
// 작업플랜-민감단온도: 하강 램프 지원 (adv.RampRate 부호에 따라 방향 결정)
bool goingDown = targetCap < job.LastWrittenSp - 1e-6;
double step = rampRate * elapsedMin;
double nextSp = goingDown
? Math.Max(job.LastWrittenSp - step, targetCap) // 하강: rate는 양수, step만큼 감소
@@ -187,8 +191,13 @@ public sealed class FeedRampExecutorService : BackgroundService
return;
}
// 진전 없음(ceiling 하락 등) → 쓰지 않고 대기
if (nextSp <= job.LastWrittenSp + Eps)
// 진전 없음(램프 방향으로 움직이지 않음) → 쓰지 않고 대기 (4-A: 방향 인지)
bool stuck;
if (goingDown)
stuck = nextSp >= job.LastWrittenSp - Eps; // 하강: LastWrittenSp보다 덜 내려갔으면 stuck
else
stuck = nextSp <= job.LastWrittenSp + Eps; // 상승: LastWrittenSp보다 더 올라가지 않았으면 stuck
if (stuck)
{
_jobs.Update(job with
{

View File

@@ -197,8 +197,15 @@ public sealed class FeedforwardEngine
SensitiveTrayTag = cfg.SensitiveTrayTag,
TcReturnTcTarget = double.IsNaN(cfg.TcReturnTcTarget) ? null : cfg.TcReturnTcTarget,
TcReturnTcBand = cfg.TcReturnTcBand,
TempLowLimit = double.IsNaN(cfg.TempLowLimit) || cfg.TempLowLimit <= -1e8 ? null : cfg.TempLowLimit,
SteamRecOp = steamRecOp, SteamConfidence = steamConf, TcDeviation = tcDev };
TempLowLimit = double.IsNaN(cfg.TempLowLimit) || cfg.TempLowLimit <= -1e8 ? null : cfg.TempLowLimit,
SteamRecOp = steamRecOp, SteamConfidence = steamConf, TcDeviation = tcDev,
ActualSteamOp = pv.SteamOp is { Good: true } sop ? sop.Value : null,
SteamTagName = cfg.SteamOpTag is not null
? cfg.SteamOpTag.Replace(".op", "", StringComparison.OrdinalIgnoreCase)
.Replace(".OP", "").ToUpperInvariant()
: null,
SteamPv = pv.SteamPv is { Good: true } spv ? spv.Value : null,
SteamSp = pv.SteamSp is { Good: true } ssp ? ssp.Value : null };
}
// ── WO-2 P-2 + WP6: 압력보정온도(PCT) 모니터 — 국소 압력 프로파일 적용 ──

View File

@@ -30,11 +30,12 @@ public sealed class FeedforwardSupervisor : BackgroundService
ILogger<FeedforwardSupervisor> logger,
Microsoft.Extensions.Configuration.IConfiguration appConfig,
ISimOverrideStore sim, ICompositionStore composition,
IFfTrackingStore tracking)
{ _scopeFactory = scopeFactory; _engine = engine; _store = store; _writeGuard = writeGuard; _logger = logger; _appConfig = appConfig; _sim = sim; _composition = composition; _tracking = tracking; }
IFfTrackingStore tracking, IFfShadowStore shadow)
{ _scopeFactory = scopeFactory; _engine = engine; _store = store; _writeGuard = writeGuard; _logger = logger; _appConfig = appConfig; _sim = sim; _composition = composition; _tracking = tracking; _shadow = shadow; }
private readonly ICompositionStore _composition;
private readonly IFfTrackingStore _tracking;
private readonly IFfShadowStore _shadow;
// WP5 3단계: LevelDriven 스트림의 분율을 수동입력(랩)값으로 치환(있으면). 없으면 config K.
private ColumnConfig ApplyManualFractions(ColumnConfig cfg)
@@ -91,6 +92,21 @@ public sealed class FeedforwardSupervisor : BackgroundService
else if (anyTracked && _sim.Enabled)
_logger.LogWarning("[FF] Sim Override 활성 — col {Id} 추종쓰기 억제(가짜 입력)", cfg.Id);
_store.Set(res);
// 모듈1 shadow: Normal에서 SteamRecOp 산출 시 shadow 로깅 (FfTrackingStore 패턴 재활용)
if (res.SteamRecOp.HasValue && res.Mode == ColumnMode.Normal)
{
double? actualOp = snap.SteamOp is { Good: true } sop ? sop.Value : null;
double? tcTemp = ResolveTcTemp(cfg, snap, res.Temps);
_shadow.Add(new FfShadowEntry
{
ColumnId = cfg.Id, Timestamp = DateTime.UtcNow,
SteamRecOp = res.SteamRecOp, SteamConfidence = res.SteamConfidence,
TcDeviation = res.TcDeviation,
ActualSteamOp = actualOp, TcTemp = tcTemp,
TcTarget = double.IsNaN(cfg.TcReturnTcTarget) ? null : cfg.TcReturnTcTarget,
});
}
}
catch (Exception ex)
{
@@ -201,8 +217,24 @@ public sealed class FeedforwardSupervisor : BackgroundService
tags.AddRange(cfg.LevelTags.Select(PvTag));
tags.AddRange(cfg.Streams.Where(s => s.LevelTag is not null).Select(s => PvTag(s.LevelTag!)));
tags.AddRange(cfg.Streams.Select(s => PvTag(s.FlowTag)));
tags.AddRange(cfg.TempTags.Select(PvTag)); // WO-2 온도 프로파일
if (cfg.SteamOpTag is not null) tags.Add(cfg.SteamOpTag.ToUpperInvariant()); // WO-3 스팀 OP(.op)
// WO-2 온도 프로파일 — .pv + raw 병행 조회 (TI-6111B 등은 .pv 미사용)
foreach (var tt in cfg.TempTags)
{
var pv = PvTag(tt);
tags.Add(pv);
var raw = tt.ToUpperInvariant();
if (raw != pv) tags.Add(raw);
}
if (cfg.SteamOpTag is not null)
{
var baseOp = cfg.SteamOpTag.ToUpperInvariant();
tags.Add(baseOp); // WO-3 스팀 OP(.op)
if (baseOp.EndsWith(".OP"))
{
tags.Add(baseOp[..^3] + ".PV"); // TICA.PV (BOTTOM 온도)
tags.Add(baseOp[..^3] + ".SP"); // TICA.SP (BOTTOM SP)
}
}
if (cfg.DeltaPTag is not null) tags.Add(PvTag(cfg.DeltaPTag)); // WO-6 차압(.pv)
// WP6: DeltaPTag 미지정 시 PressureTag로부터 탑저압 파생
@@ -233,6 +265,14 @@ public sealed class FeedforwardSupervisor : BackgroundService
bool fresh = (DateTime.UtcNow - r.Timestamp.ToUniversalTime()).TotalSeconds <= cfg.StaleSec;
return new TagSample(tag, v, Good: fresh, r.Timestamp);
}
// Fallback: .pv 미존재 시 raw 태그명 재시도 (TI-6111B 등 AI 태그)
var raw = baseTag.ToUpperInvariant();
if (raw != tag && rows.TryGetValue(raw, out r)
&& double.TryParse(r.LiveValue, NumberStyles.Float, CultureInfo.InvariantCulture, out v))
{
bool fresh = (DateTime.UtcNow - r.Timestamp.ToUniversalTime()).TotalSeconds <= cfg.StaleSec;
return new TagSample(tag, v, Good: fresh, r.Timestamp); // tag=.pv 버전 유지(ResolveTcTemp 매칭용)
}
return new TagSample(tag, double.NaN, Good: false, DateTime.MinValue);
}
@@ -257,6 +297,16 @@ public sealed class FeedforwardSupervisor : BackgroundService
var streams = cfg.Streams.ToDictionary(s => s.Key, s => Sample(s.FlowTag));
var temps = cfg.TempTags.Count > 0 ? cfg.TempTags.Select(Sample).ToList() : null;
var steam = cfg.SteamOpTag is not null ? SampleExact(cfg.SteamOpTag) : null;
// TICA-6111A.PV/.SP (SteamOpTag에서 .OP → .PV/.SP 파생)
TagSample? steamPv = null, steamSp = null;
if (cfg.SteamOpTag is not null)
{
var baseOp = cfg.SteamOpTag.ToUpperInvariant();
var pvTag = baseOp.EndsWith(".OP") ? baseOp[..^3] + ".PV" : null;
var spTag = baseOp.EndsWith(".OP") ? baseOp[..^3] + ".SP" : null;
if (pvTag is not null) steamPv = SampleExact(pvTag);
if (spTag is not null) steamSp = SampleExact(spTag);
}
// WP6: ΔP 합성 — delta_p_tag 직접값 또는 {bottom - top} 파생
TagSample? deltaP;
@@ -278,6 +328,19 @@ public sealed class FeedforwardSupervisor : BackgroundService
else deltaP = null;
return new PvSnapshot(feed, press, levels, streams)
{ Temps = temps, SteamOp = steam, DeltaP = deltaP, BottomPressure = bottomPress };
{ Temps = temps, SteamOp = steam, SteamPv = steamPv, SteamSp = steamSp,
DeltaP = deltaP, BottomPressure = bottomPress };
}
// 모듈1 shadow: T_C 온도 추출 (Engine.ResolveTcTemp 로직 복제, 정적+public 불필요 회피)
private static double? ResolveTcTemp(ColumnConfig cfg, PvSnapshot pv, IReadOnlyList<TempPoint>? temps)
{
if (temps is null || cfg.SensitiveTrayTag is null) return null;
var key = cfg.SensitiveTrayTag.EndsWith(".pv", StringComparison.OrdinalIgnoreCase)
? cfg.SensitiveTrayTag : cfg.SensitiveTrayTag + ".pv";
foreach (var t in temps)
if (t.Tag.Equals(key, StringComparison.OrdinalIgnoreCase) && t.Good && Num.IsFinite(t.Raw))
return t.Raw;
return null;
}
}

View File

@@ -0,0 +1,36 @@
using System.Collections.Concurrent;
using Hc900Crawler.Core.Application.Feedforward;
namespace Hc900Crawler.Infrastructure.Control;
public sealed class FfShadowStore : IFfShadowStore
{
private readonly ConcurrentDictionary<int, List<FfShadowEntry>> _buffers = new();
private const int MaxEntriesPerColumn = 1000;
public void Add(FfShadowEntry entry)
{
var list = _buffers.GetOrAdd(entry.ColumnId, _ => new List<FfShadowEntry>(MaxEntriesPerColumn));
lock (list)
{
list.Add(entry);
if (list.Count > MaxEntriesPerColumn)
list.RemoveRange(0, list.Count - MaxEntriesPerColumn);
}
}
public IReadOnlyCollection<FfShadowEntry> GetRecent(int columnId, int count = 100)
{
if (!_buffers.TryGetValue(columnId, out var list)) return Array.Empty<FfShadowEntry>();
lock (list)
{
int take = Math.Min(count, list.Count);
return list.GetRange(list.Count - take, take).ToArray();
}
}
public void Clear(int columnId)
{
_buffers.TryRemove(columnId, out _);
}
}