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:
@@ -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
|
||||
}
|
||||
|
||||
20
src/Core/Application/Feedforward/IFfShadowStore.cs
Normal file
20
src/Core/Application/Feedforward/IFfShadowStore.cs
Normal 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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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) 모니터 — 국소 압력 프로파일 적용 ──
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
36
src/Infrastructure/Control/FfShadowStore.cs
Normal file
36
src/Infrastructure/Control/FfShadowStore.cs
Normal 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 _);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user