diff --git a/src/Core/Application/Feedforward/FeedforwardModels.cs b/src/Core/Application/Feedforward/FeedforwardModels.cs index 0064e78..6de9b68 100644 --- a/src/Core/Application/Feedforward/FeedforwardModels.cs +++ b/src/Core/Application/Feedforward/FeedforwardModels.cs @@ -92,6 +92,8 @@ public sealed record PvSnapshot( { public IReadOnlyList? 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 } diff --git a/src/Core/Application/Feedforward/IFfShadowStore.cs b/src/Core/Application/Feedforward/IFfShadowStore.cs new file mode 100644 index 0000000..bf4fc47 --- /dev/null +++ b/src/Core/Application/Feedforward/IFfShadowStore.cs @@ -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 GetRecent(int columnId, int count = 100); + void Clear(int columnId); +} diff --git a/src/Hc900Crawler/Controllers/FeedforwardController.cs b/src/Hc900Crawler/Controllers/FeedforwardController.cs index 588256a..16bfa00 100644 --- a/src/Hc900Crawler/Controllers/FeedforwardController.cs +++ b/src/Hc900Crawler/Controllers/FeedforwardController.cs @@ -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 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 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 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 diff --git a/src/Hc900Crawler/wwwroot/css/ff.css b/src/Hc900Crawler/wwwroot/css/ff.css index c312ef1..ccfa614 100644 --- a/src/Hc900Crawler/wwwroot/css/ff.css +++ b/src/Hc900Crawler/wwwroot/css/ff.css @@ -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} diff --git a/src/Hc900Crawler/wwwroot/js/ff.js b/src/Hc900Crawler/wwwroot/js/ff.js index 3097ffc..ba13bc7 100644 --- a/src/Hc900Crawler/wwwroot/js/ff.js +++ b/src/Hc900Crawler/wwwroot/js/ff.js @@ -286,14 +286,22 @@ function ffThermometer(c) { const limitHtml = tll != null ? `
` : ''; - 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) - ? `
conf ${esc(c.steamConfidence||'')} · SP→${fmtVal(c.steamRecOp)}${c.tcDeviation!=null?` ε${fmtVal(c.tcDeviation)}`:''}
` + ? (() => { + const dev = c.tcDeviation; + const devCls = dev != null ? (Math.abs(dev) > 1 ? 'low' : Math.abs(dev) > 0.3 ? 'warn' : 'ok') : ''; + const devHtml = dev != null ? `ε${dev >= 0 ? '+' : ''}${fmtVal(dev)}℃` : ''; + const actualHtml = (c.actualSteamOp != null && c.actualSteamOp !== c.steamRecOp) + ? ` 실${fmtVal(c.actualSteamOp)}` : ''; + return `
conf ${esc(c.steamConfidence||'')} · SP→${fmtVal(c.steamRecOp)}${actualHtml}
${devHtml}
`; + })() : ''; return `
T_C (℃)
-
${fmtVal(hi)}
-
${fmtVal(hi-(hi-lo)*0.25)}
+
${fmtVal(lo)}
+
${fmtVal(lo+(hi-lo)*0.25)}
${fmtVal(lo+(hi-lo)*0.5)}
-
${fmtVal(lo+(hi-lo)*0.25)}
-
${fmtVal(lo)}
+
${fmtVal(lo+(hi-lo)*0.75)}
+
${fmtVal(hi)}
${bandHtml} ${limitHtml} ${markers} diff --git a/src/Infrastructure/Control/FeedRampExecutorService.cs b/src/Infrastructure/Control/FeedRampExecutorService.cs index 935232c..4a9b581 100644 --- a/src/Infrastructure/Control/FeedRampExecutorService.cs +++ b/src/Infrastructure/Control/FeedRampExecutorService.cs @@ -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 { diff --git a/src/Infrastructure/Control/FeedforwardEngine.cs b/src/Infrastructure/Control/FeedforwardEngine.cs index a3316c9..0743818 100644 --- a/src/Infrastructure/Control/FeedforwardEngine.cs +++ b/src/Infrastructure/Control/FeedforwardEngine.cs @@ -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) 모니터 — 국소 압력 프로파일 적용 ── diff --git a/src/Infrastructure/Control/FeedforwardSupervisor.cs b/src/Infrastructure/Control/FeedforwardSupervisor.cs index 5a1ac0e..e2f193f 100644 --- a/src/Infrastructure/Control/FeedforwardSupervisor.cs +++ b/src/Infrastructure/Control/FeedforwardSupervisor.cs @@ -30,11 +30,12 @@ public sealed class FeedforwardSupervisor : BackgroundService ILogger 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? 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; } } diff --git a/src/Infrastructure/Control/FfShadowStore.cs b/src/Infrastructure/Control/FfShadowStore.cs new file mode 100644 index 0000000..da64dd1 --- /dev/null +++ b/src/Infrastructure/Control/FfShadowStore.cs @@ -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> _buffers = new(); + private const int MaxEntriesPerColumn = 1000; + + public void Add(FfShadowEntry entry) + { + var list = _buffers.GetOrAdd(entry.ColumnId, _ => new List(MaxEntriesPerColumn)); + lock (list) + { + list.Add(entry); + if (list.Count > MaxEntriesPerColumn) + list.RemoveRange(0, list.Count - MaxEntriesPerColumn); + } + } + + public IReadOnlyCollection GetRecent(int columnId, int count = 100) + { + if (!_buffers.TryGetValue(columnId, out var list)) return Array.Empty(); + 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 _); + } +}