Files
HC900-Crawler/src/Hc900Crawler/Controllers/FeedforwardController.cs
windpacer d040388557 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>
2026-06-10 08:11:44 +09:00

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