FF 엔진/슈퍼바이저/FeedRamp 개선, shadow store(IFfShadowStore/FfShadowStore) 추가, FF 컨트롤러·UI(ff.js/ff.css). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
538 lines
26 KiB
C#
538 lines
26 KiB
C#
using Hc900Crawler.Core.Application.Feedforward;
|
|
using Hc900Crawler.Core.Application.Interfaces;
|
|
using Hc900Crawler.Core.Domain.Entities;
|
|
using Hc900Crawler.Infrastructure.Hc900;
|
|
using Hc900Crawler.Infrastructure.Kb;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Configuration;
|
|
|
|
namespace Hc900Crawler.Web.Controllers;
|
|
|
|
[ApiController]
|
|
[Route("api/ff")]
|
|
public sealed class FeedforwardController : ControllerBase
|
|
{
|
|
private readonly IFeedforwardAdvisoryStore _store;
|
|
private readonly IFeedforwardConfigStore _config;
|
|
private readonly IFeedforwardAuditService _audit;
|
|
private readonly IFeedforwardWriteGuard _writeGuard;
|
|
private readonly Hc900WriteService _writeClient;
|
|
private readonly IKbAuthService _auth;
|
|
private readonly Microsoft.Extensions.Configuration.IConfiguration _appConfig;
|
|
private readonly Hc900Crawler.Infrastructure.Control.FeedforwardSupervisor _supervisor;
|
|
public FeedforwardController(
|
|
IFeedforwardAdvisoryStore store,
|
|
IFeedforwardConfigStore config,
|
|
IFeedforwardAuditService audit,
|
|
IFeedforwardWriteGuard writeGuard,
|
|
Hc900WriteService writeClient,
|
|
IKbAuthService auth,
|
|
Microsoft.Extensions.Configuration.IConfiguration appConfig,
|
|
Hc900Crawler.Infrastructure.Control.FeedforwardSupervisor supervisor,
|
|
ISimOverrideStore sim,
|
|
Hc900Crawler.Infrastructure.Control.FeedRampAdvisorService ramp,
|
|
ICompositionStore composition,
|
|
IFeedRampJobStore rampJobs,
|
|
IExperionDbService db,
|
|
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; _shadow = shadow; }
|
|
|
|
private readonly IFfTrackingStore _tracking;
|
|
private readonly IFfShadowStore _shadow;
|
|
|
|
private readonly ISimOverrideStore _sim;
|
|
private readonly Hc900Crawler.Infrastructure.Control.FeedRampAdvisorService _ramp;
|
|
private readonly ICompositionStore _composition;
|
|
private readonly IFeedRampJobStore _rampJobs;
|
|
private readonly IExperionDbService _db;
|
|
|
|
// realtime_table에서 현재값(예: WSP) 1개 best-effort 조회 — 되돌리기/이전값 표시용
|
|
private async Task<double?> TryReadCurrentAsync(string tag, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
var rows = await _db.GetRealtimeRecordsByTagNamesAsync(new[] { tag });
|
|
var r = rows.FirstOrDefault();
|
|
if (r?.LiveValue is not null &&
|
|
double.TryParse(r.LiveValue, System.Globalization.NumberStyles.Float,
|
|
System.Globalization.CultureInfo.InvariantCulture, out var v))
|
|
return v;
|
|
}
|
|
catch { /* best-effort */ }
|
|
return null;
|
|
}
|
|
|
|
private async Task<bool> AuthAsync(CancellationToken ct)
|
|
=> await _auth.ValidateAsync(Request.Headers["X-Kb-Token"].ToString(), ct);
|
|
|
|
// ── 설정 CRUD ──
|
|
[HttpGet("config")]
|
|
public async Task<IActionResult> GetConfig(CancellationToken ct)
|
|
{
|
|
var cols = await _config.LoadAllAsync(ct);
|
|
return Ok(new { columns = cols.Select(MapConfig) });
|
|
}
|
|
|
|
[HttpPost("config")]
|
|
public async Task<IActionResult> SaveConfig([FromBody] ColumnConfig body, CancellationToken ct)
|
|
{
|
|
if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" });
|
|
var id = await _config.SaveColumnAsync(body, ct);
|
|
return Ok(new { success = true, id });
|
|
}
|
|
|
|
[HttpDelete("config/{id:int}")]
|
|
public async Task<IActionResult> DeleteConfig(int id, CancellationToken ct)
|
|
{
|
|
if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" });
|
|
await _config.DeleteColumnAsync(id, ct);
|
|
return Ok(new { success = true });
|
|
}
|
|
|
|
// ── WO-6 전환류 ARM/취소 ──
|
|
[HttpPost("recovery/{id:int}/arm")]
|
|
public IActionResult ArmRecovery(int id) => Ok(new { success = _supervisor.Arm(id) });
|
|
|
|
[HttpPost("recovery/{id:int}/cancel")]
|
|
public IActionResult CancelRecovery(int id) => Ok(new { success = _supervisor.Cancel(id) });
|
|
|
|
// ── Phase II: 수동 SP 쓰기 ──
|
|
[HttpPost("write/{columnId:int}/{streamKey}")]
|
|
public async Task<IActionResult> WriteSp(int columnId, string streamKey, [FromBody] WriteSpBody body, CancellationToken ct)
|
|
{
|
|
if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" });
|
|
var advisory = _store.Get(columnId);
|
|
if (advisory is null) return NotFound(new { error = "advisory 없음" });
|
|
var cfg = (await _config.LoadAllAsync(ct)).FirstOrDefault(c => c.Id == columnId);
|
|
if (cfg is null) return NotFound(new { error = "config 없음" });
|
|
var sc = cfg.Streams.FirstOrDefault(s => s.Key == streamKey);
|
|
if (sc is null) return NotFound(new { error = "stream 없음" });
|
|
// SP 쓰기 대상 = flow 태그에서 WSP(.SP, offset 0x04 RW) 자동 파생. SpNodeId는 선택적 override.
|
|
var spTag = FfSpTag.Resolve(sc.FlowTag, sc.SpNodeId);
|
|
if (string.IsNullOrWhiteSpace(spTag))
|
|
return BadRequest(new { error = "flow 태그가 없어 SP 대상 산출 불가" });
|
|
var adv = advisory.Streams.FirstOrDefault(a => a.Key == streamKey);
|
|
if (adv is null) return NotFound(new { error = "stream advisory 없음" });
|
|
|
|
double spVal = body.value ?? (adv.RecommendedSp ?? double.NaN);
|
|
if (double.IsNaN(spVal)) return BadRequest(new { error = "SP 값 없음" });
|
|
|
|
// 범위 클램프(§3.3) 후 WriteGuard 검증 — manualOverride=true(AdvisoryOnly만 우회, 나머지 가드 유지)
|
|
spVal = Math.Clamp(spVal, sc.SpMin, sc.SpMax);
|
|
var check = _writeGuard.Check(cfg, adv, sc, advisory, manualOverride: true);
|
|
if (!check.Allowed)
|
|
return BadRequest(new { error = $"WriteGuard 차단: {check.Reason}" });
|
|
|
|
// 되돌리기용: 쓰기 전 현재 WSP 값을 캡처(realtime_table)
|
|
double? prevSp = await TryReadCurrentAsync(spTag, ct);
|
|
|
|
// 컨트롤러는 태그→controller_id 매핑에서 해석(DB). 컬럼 ControllerId에 의존하지 않음.
|
|
var ctrlId = await _db.GetControllerIdForTagAsync(spTag);
|
|
if (string.IsNullOrWhiteSpace(ctrlId))
|
|
return BadRequest(new { error = $"태그 {spTag}의 컨트롤러를 DB에서 찾지 못함(realtime 폴링 확인)" });
|
|
|
|
// HC900 gRPC 쓰기 (WSP 태그, 해석된 컨트롤러로 라우팅)
|
|
var (success, error) = await _writeClient.WriteTagAsync(ctrlId, spTag, spVal);
|
|
|
|
// 감사 로그(이전값 포함 — 되돌리기 근거)
|
|
await _audit.LogAsync(new FfActionLogEntry(columnId, "sp_write",
|
|
StreamKey: streamKey, SpValue: spVal, NodeId: spTag,
|
|
Result: success ? $"success (prev={prevSp?.ToString("F2") ?? "n/a"})" : $"error: {error}",
|
|
OperatorName: "manual"), ct);
|
|
|
|
if (!success)
|
|
return StatusCode(502, new { error = $"HC900 쓰기 실패: {error}" });
|
|
|
|
return Ok(new { success = true, streamKey, nodeId = spTag, value = spVal, previousSp = prevSp });
|
|
}
|
|
|
|
// ── 측류 추종 ON/OFF/원복 ───────────────────────────────────────
|
|
// ON: baseline(현재 WSP) 캡처 후 추종 시작 → Supervisor가 매 주기 권장 WSP를 연속 씀
|
|
[HttpPost("track/{columnId:int}/{streamKey}/on")]
|
|
public async Task<IActionResult> TrackOn(int columnId, string streamKey, CancellationToken ct)
|
|
{
|
|
var cfg = (await _config.LoadAllAsync(ct)).FirstOrDefault(c => c.Id == columnId);
|
|
if (cfg is null) return NotFound(new { error = "config 없음" });
|
|
var sc = cfg.Streams.FirstOrDefault(s => s.Key == streamKey);
|
|
if (sc is null) return NotFound(new { error = "stream 없음" });
|
|
if (sc.Role == StreamRole.Monitor) return BadRequest(new { error = "Monitor 스트림은 추종 대상 아님" });
|
|
var spTag = FfSpTag.Resolve(sc.FlowTag, sc.SpNodeId);
|
|
if (string.IsNullOrWhiteSpace(spTag)) return BadRequest(new { error = "flow 태그 없음 — SP 대상 산출 불가" });
|
|
|
|
_tracking.Set(new FfTrackingState
|
|
{
|
|
ColumnId = columnId, StreamKey = streamKey, Enabled = true,
|
|
StartedAt = DateTime.UtcNow, Operator = "manual"
|
|
});
|
|
await _audit.LogAsync(new FfActionLogEntry(columnId, "track_on",
|
|
StreamKey: streamKey, NodeId: spTag, Result: "started", OperatorName: "manual"), ct);
|
|
return Ok(new { success = true, streamKey });
|
|
}
|
|
|
|
// OFF(=취소): 추종 중지. 컨트롤러는 마지막 값 유지.
|
|
[HttpPost("track/{columnId:int}/{streamKey}/off")]
|
|
public async Task<IActionResult> TrackOff(int columnId, string streamKey, CancellationToken ct)
|
|
{
|
|
var cur = _tracking.Get(columnId, streamKey);
|
|
_tracking.Set(new FfTrackingState
|
|
{
|
|
ColumnId = columnId, StreamKey = streamKey, Enabled = false,
|
|
StartedAt = cur?.StartedAt, Operator = "manual"
|
|
});
|
|
await _audit.LogAsync(new FfActionLogEntry(columnId, "track_off",
|
|
StreamKey: streamKey, Result: "stopped", OperatorName: "manual"), ct);
|
|
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)
|
|
{
|
|
if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" });
|
|
var rows = await _audit.QueryAsync(columnId, limit, ct);
|
|
return Ok(new { rows });
|
|
}
|
|
|
|
private object MapConfig(ColumnConfig c) => new
|
|
{
|
|
id = c.Id, name = c.Name, enabled = c.Enabled, advisoryOnly = c.AdvisoryOnly,
|
|
controllerId = c.ControllerId,
|
|
feedSpNodeId = c.FeedSpNodeId, feedSpMin = c.FeedSpMin,
|
|
feedSpMax = double.IsInfinity(c.FeedSpMax) || c.FeedSpMax >= double.MaxValue ? 1e9 : c.FeedSpMax,
|
|
feedTag = c.FeedTag, pressureTag = c.PressureTag, levelTags = c.LevelTags,
|
|
scanSec = c.ScanSec, feedFilterTauSec = c.FeedFilterTauSec,
|
|
feedMoveThresholdPerMin = c.FeedMoveThresholdPerMin, pressFilterTauSec = c.PressFilterTauSec,
|
|
pressureBand = c.PressureBand, settleSec = c.SettleSec, staleSec = c.StaleSec, productKey = c.ProductKey,
|
|
tempTags = c.TempTags, sensitiveTrayTag = c.SensitiveTrayTag,
|
|
dtdp = c.DTdP, pRef = double.IsNaN(c.PRef) ? (double?)null : c.PRef,
|
|
steamOpTag = c.SteamOpTag, thetaAutoTune = c.ThetaAutoTune,
|
|
biasMaWindowSec = c.BiasMaWindowSec,
|
|
recoveryEnabled = c.RecoveryEnabled, recoveryAutoArm = c.RecoveryAutoArm,
|
|
imbalanceTriggerFrac = c.ImbalanceTriggerFrac, imbalanceTriggerSec = c.ImbalanceTriggerSec,
|
|
recoverySettleSec = c.RecoverySettleSec, returnRampSec = c.ReturnRampSec,
|
|
feedRecoverySp = c.FeedRecoverySp,
|
|
deltaPTag = c.DeltaPTag, deltaPFloodLimit = c.DeltaPFloodLimit,
|
|
tempHighLimit = c.TempHighLimit,
|
|
tempLowLimit = c.TempLowLimit,
|
|
tcReturnRebTarget = double.IsNaN(c.TcReturnRebTarget) ? (double?)null : c.TcReturnRebTarget,
|
|
tcReturnRebBand = c.TcReturnRebBand,
|
|
tcReturnDeltaAdRef = double.IsNaN(c.TcReturnDeltaAdRef) ? (double?)null : c.TcReturnDeltaAdRef,
|
|
tcReturnDeltaAdBand = c.TcReturnDeltaAdBand,
|
|
tcReturnTcTarget = double.IsNaN(c.TcReturnTcTarget) ? (double?)null : c.TcReturnTcTarget,
|
|
tcReturnTcBand = c.TcReturnTcBand,
|
|
streams = c.Streams.Select(s => new
|
|
{
|
|
key = s.Key, flowTag = s.FlowTag, role = s.Role.ToString(), levelTag = s.LevelTag, targetCoeff = s.TargetCoeff,
|
|
thetaUpSec = s.ThetaUpSec, thetaDnSec = s.ThetaDnSec, tauSec = s.TauSec,
|
|
spMin = s.SpMin, spMax = s.SpMax, rateUpPerMin = s.RateUpPerMin, rateDnPerMin = s.RateDnPerMin,
|
|
refluxFromProduct = s.RefluxFromProduct, grade = s.Grade.ToString(),
|
|
isReflux = s.IsReflux, recoverySp = double.IsNaN(s.RecoverySp) ? (double?)null : s.RecoverySp,
|
|
spNodeId = s.SpNodeId
|
|
})
|
|
};
|
|
|
|
// ── 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));
|
|
}
|
|
|
|
// ── 작업 B: FEED 램프 실행 (시작/취소/상태) ───────────────────────
|
|
private bool RampDryRun() => _appConfig.GetValue<bool?>("Feedforward:FeedRampDryRun") ?? true;
|
|
|
|
[HttpPost("feed-ramp/{columnId:int}/start")]
|
|
public async Task<IActionResult> StartFeedRamp(int columnId, [FromBody] FeedRampStartBody body, CancellationToken ct)
|
|
{
|
|
if (body is null || double.IsNaN(body.targetFeed) || double.IsInfinity(body.targetFeed))
|
|
return BadRequest(new { error = "targetFeed 값 필요" });
|
|
|
|
var cfg = (await _config.LoadAllAsync(ct)).FirstOrDefault(c => c.Id == columnId);
|
|
if (cfg is null) return NotFound(new { error = "config 없음" });
|
|
if (string.IsNullOrWhiteSpace(FfSpTag.Resolve(cfg.FeedTag, cfg.FeedSpNodeId)))
|
|
return BadRequest(new { error = "Feed 태그 없음 — FEED SP 대상 산출 불가" });
|
|
|
|
// 현재 피드 확인 + 업램프만 허용
|
|
var adv = await _ramp.ComputeAsync(columnId, body.targetFeed, 50, double.NaN, double.NaN, double.NaN, 1.8, ct);
|
|
if (adv is null) return NotFound(new { error = "config 없음" });
|
|
if (adv.Hold) return BadRequest(new { error = $"피드 불량 — 시작 불가: {string.Join(", ", adv.Warnings)}" });
|
|
|
|
bool dryRun = RampDryRun() || _sim.Enabled;
|
|
var job = _rampJobs.Start(columnId, body.targetFeed, "manual", dryRun);
|
|
|
|
await _audit.LogAsync(new FfActionLogEntry(columnId, "feed_ramp_start",
|
|
SpValue: body.targetFeed, NodeId: cfg.FeedSpNodeId,
|
|
Result: dryRun ? "started(dry-run)" : "started", OperatorName: "manual"), ct);
|
|
|
|
return Ok(new { success = true, dryRun, job = MapRampJob(job) });
|
|
}
|
|
|
|
[HttpPost("feed-ramp/{columnId:int}/cancel")]
|
|
public async Task<IActionResult> CancelFeedRamp(int columnId, CancellationToken ct)
|
|
{
|
|
bool ok = _rampJobs.Cancel(columnId);
|
|
if (ok)
|
|
await _audit.LogAsync(new FfActionLogEntry(columnId, "feed_ramp_cancel",
|
|
Result: "canceled", OperatorName: "manual"), ct);
|
|
return Ok(new { success = ok });
|
|
}
|
|
|
|
[HttpGet("feed-ramp/{columnId:int}")]
|
|
public IActionResult GetFeedRamp(int columnId)
|
|
{
|
|
var job = _rampJobs.Get(columnId);
|
|
return Ok(new { dryRun = RampDryRun() || _sim.Enabled, job = job is null ? null : MapRampJob(job) });
|
|
}
|
|
|
|
[HttpGet("feed-ramp")]
|
|
public IActionResult GetAllFeedRamp()
|
|
=> Ok(new { dryRun = RampDryRun() || _sim.Enabled, jobs = _rampJobs.GetAll().Select(MapRampJob) });
|
|
|
|
private static object MapRampJob(FeedRampJob j) => new
|
|
{
|
|
columnId = j.ColumnId,
|
|
targetFeed = Fin(j.TargetFeed),
|
|
lastWrittenSp = double.IsNaN(j.LastWrittenSp) ? (double?)null : j.LastWrittenSp,
|
|
state = j.State.ToString(),
|
|
hold = j.Hold,
|
|
currentFeed = j.CurrentFeed,
|
|
ceiling = j.Ceiling,
|
|
rampRate = j.RampRate,
|
|
startedAt = j.StartedAt,
|
|
lastStepAt = j.LastStepAt,
|
|
dryRun = j.DryRun,
|
|
warnings = j.Warnings
|
|
};
|
|
|
|
// ── 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 });
|
|
}
|
|
|
|
// ── WP5 3단계: 조성 분율 수동입력(랩) ──
|
|
[HttpGet("composition")]
|
|
public IActionResult GetComposition() => Ok(new { fractions = _composition.Snapshot() });
|
|
|
|
[HttpPost("composition")]
|
|
public IActionResult SetComposition([FromBody] CompositionBody b)
|
|
{
|
|
if (b is null || string.IsNullOrWhiteSpace(b.streamKey)) return BadRequest(new { error = "streamKey 필요" });
|
|
_composition.Set(b.columnId, b.streamKey, b.fraction);
|
|
return Ok(new { fractions = _composition.Snapshot() });
|
|
}
|
|
|
|
[HttpDelete("composition/{columnId:int}/{streamKey}")]
|
|
public IActionResult ClearComposition(int columnId, string streamKey)
|
|
{ _composition.Clear(columnId, streamKey); return Ok(new { fractions = _composition.Snapshot() }); }
|
|
|
|
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 { columns = _store.GetAll().Select(MapColumn) });
|
|
|
|
[HttpGet("advisory/{columnId:int}")]
|
|
public IActionResult Get(int columnId)
|
|
{
|
|
var r = _store.Get(columnId);
|
|
return r is null ? NotFound() : Ok(MapColumn(r));
|
|
}
|
|
|
|
private object MapColumn(AdvisoryResult r)
|
|
{
|
|
var streams = r.Streams.Select(s =>
|
|
{
|
|
var (lastSp, lastErr, lastAt) = _supervisor.GetLastWrite(r.ColumnId, s.Key);
|
|
// SP는 flow 태그에서 .SP(WSP) 자동 파생 → Commanded/LevelDriven(피드결정형 D·B)·flow태그 있으면 추종 가능
|
|
bool writable = s.Role != StreamRole.Monitor && !string.IsNullOrWhiteSpace(s.FlowTag);
|
|
var trk = _tracking.Get(r.ColumnId, s.Key);
|
|
return new
|
|
{
|
|
tracking = trk?.Enabled ?? false,
|
|
key = s.Key,
|
|
flowTag = s.FlowTag,
|
|
role = s.Role.ToString(),
|
|
levelTag = s.LevelTag,
|
|
pv = double.IsNaN(s.Pv) ? (double?)null : s.Pv,
|
|
recommendedSp = s.RecommendedSp,
|
|
writable = writable, // SP 태그 설정됨 → 측류 SP 쓰기 버튼 노출 가능
|
|
gap = s.Gap,
|
|
trend = s.Trend,
|
|
valid = s.Valid,
|
|
grade = s.Grade.ToString(),
|
|
note = s.Note,
|
|
gradeReason = s.GradeReason,
|
|
thetaSuggestUpSec = s.ThetaSuggestUpSec,
|
|
thetaSuggestDnSec = s.ThetaSuggestDnSec,
|
|
thetaSuggestConf = s.ThetaSuggestConf,
|
|
kObsSuggest = s.KObsSuggest,
|
|
// WP5 2·3단계: 조성 base + trim
|
|
compositionBase = s.CompositionBase,
|
|
trim = s.Trim,
|
|
recommendedSpComposition = s.RecommendedSpComposition,
|
|
trimSource = s.TrimSource,
|
|
// Phase II: auto-write 결과
|
|
lastWriteSp = lastSp,
|
|
lastWriteError = lastErr,
|
|
lastWriteAt = lastAt
|
|
};
|
|
}).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,
|
|
columnName = r.ColumnName,
|
|
computedAt = r.ComputedAt,
|
|
enabled = r.Enabled,
|
|
transient = r.Transient,
|
|
transientReason = r.TransientReason,
|
|
feedFiltered = r.FeedFiltered,
|
|
vLoss = r.VLoss,
|
|
yield = r.Yield,
|
|
massBalanceState = r.MassBalanceState,
|
|
mode = r.Mode.ToString(),
|
|
modeReason = r.ModeReason,
|
|
feedRecommendedSp = r.FeedRecommendedSp,
|
|
vLossMa = r.VLossMa,
|
|
frontPositionState = r.FrontPositionState,
|
|
frontTrimAdvice = r.FrontTrimAdvice,
|
|
tempProfileState = r.TempProfileState,
|
|
inversionPair = r.InversionPair,
|
|
tempSpan = (r.TempSpan is double ts2 && !double.IsNaN(ts2)) ? ts2 : (double?)null,
|
|
tempSpanRef = r.TempSpanRef,
|
|
upperFrontState = r.UpperFrontState,
|
|
lowerFrontState = r.LowerFrontState,
|
|
upperFrontMetric = r.UpperFrontMetric,
|
|
lowerFrontMetric = r.LowerFrontMetric,
|
|
autoWriteActive = r.AutoWriteActive,
|
|
writeGuardBlockedSp = r.WriteGuardBlockedSp,
|
|
writeGuardReason = r.WriteGuardReason,
|
|
sensitiveTrayTag = r.SensitiveTrayTag,
|
|
tcReturnTcTarget = r.TcReturnTcTarget,
|
|
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
|
|
{
|
|
tag = t.Tag,
|
|
raw = double.IsNaN(t.Raw) ? (double?)null : t.Raw,
|
|
pct = double.IsNaN(t.Pct) ? (double?)null : t.Pct,
|
|
good = t.Good
|
|
}),
|
|
streams = streams
|
|
};
|
|
}
|
|
}
|
|
|
|
public sealed record WriteSpBody { public double? value { get; init; } }
|
|
|
|
public sealed record FeedRampStartBody { public double targetFeed { get; init; } = double.NaN; }
|
|
|
|
public sealed record SimOverrideBody
|
|
{
|
|
public bool enabled { get; init; }
|
|
public Dictionary<string, double>? values { get; init; }
|
|
}
|
|
|
|
public sealed record CompositionBody
|
|
{
|
|
public int columnId { get; init; }
|
|
public string streamKey { get; init; } = "";
|
|
public double fraction { get; init; }
|
|
}
|