Files
HC900-Crawler/src/Infrastructure/Reporting/ReportMetricService.cs
windpacer 30a3286d35 feat(report): 적산(.QV) 정확 메트릭 + 물질수지 폐합 + 웹 대시보드
PV-중앙값 근사 대신 유량 적산(.QV)으로 정확한 적분 메트릭 추가, 웹에서 바로 보기.

- QV 메트릭 4종: production_total·yield_qv·energy_intensity_qv·mass_balance_closure.
  적산 Δ 헬퍼(QvDeltaAsync)는 값 급감(reset/wrap)으로 구간분할 후 구간별 (마지막-처음)
  합산 → gap·양자화 강건, DCS 일일적산과 동일 의미(노이즈 과대계상 없음).
- 매핑은 appsettings SteamAdvisor:Columns 재사용(.QV 치환, 7컬럼). 폐합 스트림은
  Report:Closure:C-6111(Feed + Outputs[제품·경비물·중비물]).
- 웹 대시보드: GET /api/report/columns, /api/report/summary →
  리포트 탭에서 컬럼·날짜 선택 시 물질수지 신뢰블록(IN/OUT/폐합%, 98~101% 색상)
  + 메트릭 표를 즉시 렌더. 엑셀 export와 병존.
- 물리적 타당성 게이트: 수율>1.5·에너지원단위>5·≤0 → no_data+사유(garbage 차단).

검증(2026-05-15 C-6111): 생산 8455kg·수율 0.8739·에너지 0.7791·폐합 99.04%
(feed 9675/out 9582), 17주 폐합 99.1~99.8%. C-9111 garbage 수율 → no_data 처리 확인.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 07:54:34 +09:00

249 lines
12 KiB
C#

using System.Data;
using System.Data.Common;
using Hc900Crawler.Core.Application.DTOs;
using Hc900Crawler.Core.Application.Interfaces;
using Hc900Crawler.Infrastructure.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Hc900Crawler.Infrastructure.Reporting;
/// <summary>
/// 결정론 메트릭 엔진. history_table(60s)·fast_record(s/min) 동일 long 포맷이라 source만 교체.
/// raw SQL은 FeedforwardAuditService 패턴(_ctx.Database.GetDbConnection() + @param).
/// 표본 0 → no_data(0 날조 금지), 매핑 미정의 → error (결정론 검증 게이트).
/// </summary>
public sealed class ReportMetricService : IReportMetricService
{
private readonly Hc900DbContext _ctx;
private readonly ILogger<ReportMetricService> _logger;
private readonly ReportColumnMap _map;
private const string NUMERIC = "^-?[0-9]+(\\.[0-9]+)?$";
public ReportMetricService(Hc900DbContext ctx, ILogger<ReportMetricService> logger, ReportColumnMap map)
{ _ctx = ctx; _logger = logger; _map = map; }
private static readonly HashSet<string> QV_METRICS =
new() { "production_total", "yield_qv", "energy_intensity_qv", "mass_balance_closure" };
public async Task<MetricResultDto> ComputeAsync(MetricRequestDto req, CancellationToken ct = default)
{
bool isFast = req.SourceTable == "fast_record";
var res = new MetricResultDto
{
Metric = req.Metric, Column = req.Column,
Source = req.SourceTable, SamplingMs = isFast ? 0 : 60000
};
// KST 날짜 [00:00, +1d) → UTC (recorded_at은 UTC)
var fromUtc = DateTime.SpecifyKind(req.PeriodDateKst.Date, DateTimeKind.Unspecified).AddHours(-9);
var toUtc = fromUtc.AddDays(1);
string tbl = isFast ? "fast_record" : "history_table";
try
{
var conn = _ctx.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
if (QV_METRICS.Contains(req.Metric))
{
if (!_map.TryResolveQv(req.Column, req.Metric, out var qspec) || qspec is null)
{ res.Status = "error"; res.Error = $"미정의 QV 매핑: {req.Column}/{req.Metric}"; res.Value = null; return res; }
res.Unit = qspec.Unit;
await ComputeQvAsync(res, conn, tbl, qspec, fromUtc, toUtc, isFast, req.SessionId, ct);
}
else
{
if (!_map.TryResolve(req.Column, req.Metric, out var spec) || spec is null)
{ res.Status = "error"; res.Error = $"미정의 매핑: {req.Column}/{req.Metric}"; res.Value = null; return res; }
res.Unit = spec.Unit;
if (req.Metric == "control_residual")
await ResidualAsync(res, conn, tbl, spec, fromUtc, toUtc, isFast, req.SessionId, ct);
else
await RatioAsync(res, conn, tbl, spec, fromUtc, toUtc, isFast, req.SessionId, ct);
}
if (isFast && res.Status == "ok") res.SamplingMs = await FastSamplingMsAsync(conn, req.SessionId ?? -1, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "[Report] metric 실패 {Col}/{M}", req.Column, req.Metric);
res.Status = "error"; res.Error = ex.Message; res.Value = null;
}
return res;
}
// ── 적산(.QV) 메트릭: Single=ΔA, Ratio=ΔA/ΔB, Closure=100·ΣΔOut/Δfeed ──
private async Task ComputeQvAsync(MetricResultDto res, DbConnection conn, string tbl, QvSpec s,
DateTime fromUtc, DateTime toUtc, bool isFast, int? sid, CancellationToken ct)
{
if (s.Kind == QvKind.Single)
{
var (tot, n, resets) = await QvDeltaAsync(conn, tbl, s.A, fromUtc, toUtc, isFast, sid, ct);
if (tot is null) { res.Status = "no_data"; res.Value = null; return; }
res.Value = tot; res.N = n; res.Extra["resets"] = resets;
}
else if (s.Kind == QvKind.Ratio)
{
var (a, na, ra) = await QvDeltaAsync(conn, tbl, s.A, fromUtc, toUtc, isFast, sid, ct);
var (b, nb, rb) = await QvDeltaAsync(conn, tbl, s.B!, fromUtc, toUtc, isFast, sid, ct);
if (a is null || b is null || b == 0) { res.Status = "no_data"; res.Value = null; return; }
res.Value = a / b; res.N = Math.Min(na, nb);
res.Extra["numer_qv"] = a; res.Extra["denom_qv"] = b; res.Extra["resets"] = ra + rb;
// 물리적 타당성 게이트: 비율이 음수/0이하 또는 상한 초과 → 데이터 이상(분모 적산 부족 등)
if (res.Value <= 0 || (s.Max is double mx && res.Value > mx))
{
res.Status = "no_data";
res.Error = $"비물리적 비율 {res.Value:F1} (분모 적산 Δ={b:F0} 비정상 추정)";
res.Value = null;
}
}
else // Closure
{
var (feed, nf, rf) = await QvDeltaAsync(conn, tbl, s.A, fromUtc, toUtc, isFast, sid, ct);
if (feed is null || feed == 0) { res.Status = "no_data"; res.Value = null; return; }
double outSum = 0; int resets = rf;
for (int i = 0; i < s.Outputs.Count; i++)
{
var (d, _, ri) = await QvDeltaAsync(conn, tbl, s.Outputs[i], fromUtc, toUtc, isFast, sid, ct);
outSum += d ?? 0; resets += ri;
res.Extra[$"out{i}_qv"] = d; // out0=제품, out1=경비물, out2=중비물 (config 순서)
}
res.Value = 100.0 * outSum / feed; // 폐합 %
res.N = nf;
res.Extra["feed_qv"] = feed;
res.Extra["out_total"] = outSum;
res.Extra["product_qv"] = s.Outputs.Count > 0 ? res.Extra["out0_qv"] : null;
res.Extra["resets"] = resets;
}
}
/// <summary>
/// 적산 Δ. 리셋/wrap(값 급감)으로 구간 분할 후 구간별 (마지막−처음) 합. gap·양자화에 강건,
/// 노이즈 과대계상 없음(=DCS 일일적산과 동일 의미). wrap당 꼭대기 잔량(≈소량)만 손실.
/// </summary>
private static async Task<(double? Total, int NSteps, int NResets)> QvDeltaAsync(
DbConnection conn, string tbl, string tag, DateTime fromUtc, DateTime toUtc,
bool isFast, int? sid, CancellationToken ct)
{
await using var cmd = conn.CreateCommand();
cmd.CommandText = $@"
WITH s AS (
SELECT recorded_at, value::float v,
lag(value::float) OVER (ORDER BY recorded_at) prev
FROM hc900.{tbl}
WHERE tagname=@tag AND value ~ '{NUMERIC}'
AND ({(isFast ? "session_id = @sid" : "recorded_at >= @from AND recorded_at < @to")})
), seg AS (
SELECT recorded_at, v,
sum(CASE WHEN prev IS NOT NULL AND v < prev - 1 THEN 1 ELSE 0 END)
OVER (ORDER BY recorded_at) AS seg_id
FROM s
), perseg AS (
SELECT (array_agg(v ORDER BY recorded_at))[1] AS first_v,
(array_agg(v ORDER BY recorded_at DESC))[1] AS last_v
FROM seg GROUP BY seg_id
)
SELECT (SELECT sum(last_v - first_v) FROM perseg),
(SELECT count(*) FROM s),
(SELECT count(*) FROM s WHERE prev IS NOT NULL AND v < prev - 1);";
AddP(cmd, "@tag", tag);
if (isFast) AddP(cmd, "@sid", sid ?? -1); else { AddP(cmd, "@from", fromUtc); AddP(cmd, "@to", toUtc); }
await using var rd = await cmd.ExecuteReaderAsync(ct);
if (await rd.ReadAsync(ct) && !rd.IsDBNull(0))
return (rd.GetDouble(0), (int)rd.GetInt64(1), (int)rd.GetInt64(2));
return (null, 0, 0);
}
// ── 비율: median(A/B) (분 버킷 피벗, 둘 다 유효한 분만). 클린율 = 1 - 2*good/raw ──
private async Task RatioAsync(MetricResultDto res, DbConnection conn, string tbl, MetricSpec s,
DateTime fromUtc, DateTime toUtc, bool isFast, int? sid, CancellationToken ct)
{
await using var cmd = conn.CreateCommand();
cmd.CommandText = $@"
WITH raw AS (
SELECT date_trunc('minute', recorded_at) ts, tagname, value::float v
FROM hc900.{tbl}
WHERE tagname IN (@a,@b) AND value ~ '{NUMERIC}'
AND ({(isFast ? "session_id = @sid" : "recorded_at >= @from AND recorded_at < @to")})
), tot AS (SELECT count(*) c FROM raw),
piv AS (
SELECT ts, max(v) FILTER (WHERE tagname=@a) a, max(v) FILTER (WHERE tagname=@b) b
FROM raw GROUP BY ts
), good AS (
SELECT a, b FROM piv
WHERE a BETWEEN @alo AND @ahi AND b BETWEEN @blo AND @bhi AND b <> 0
)
SELECT (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY a/b) FROM good),
(SELECT count(*) FROM good),
(SELECT c FROM tot);";
AddP(cmd, "@a", s.A.Tag); AddP(cmd, "@b", s.B.Tag);
AddP(cmd, "@alo", s.A.Lo); AddP(cmd, "@ahi", s.A.Hi);
AddP(cmd, "@blo", s.B.Lo); AddP(cmd, "@bhi", s.B.Hi);
if (isFast) AddP(cmd, "@sid", sid ?? -1); else { AddP(cmd, "@from", fromUtc); AddP(cmd, "@to", toUtc); }
await using var rd = await cmd.ExecuteReaderAsync(ct);
if (await rd.ReadAsync(ct) && !rd.IsDBNull(0))
{
res.Value = rd.GetDouble(0);
res.N = (int)rd.GetInt64(1);
var nRaw = rd.GetInt64(2);
res.CleanedFraction = nRaw > 0 ? Math.Clamp(1.0 - (2.0 * res.N) / nRaw, 0, 1) : 0;
}
else { res.Status = "no_data"; res.Value = null; }
}
// ── 제어잔차: (PV-SP) mean/sd/abs_p95/이탈% ──
private async Task ResidualAsync(MetricResultDto res, DbConnection conn, string tbl, MetricSpec s,
DateTime fromUtc, DateTime toUtc, bool isFast, int? sid, CancellationToken ct)
{
await using var cmd = conn.CreateCommand();
cmd.CommandText = $@"
WITH raw AS (
SELECT date_trunc('minute', recorded_at) ts, tagname, value::float v
FROM hc900.{tbl}
WHERE tagname IN (@pv,@sp) AND value ~ '{NUMERIC}'
AND ({(isFast ? "session_id = @sid" : "recorded_at >= @from AND recorded_at < @to")})
), piv AS (
SELECT ts, max(v) FILTER (WHERE tagname=@pv) pv, max(v) FILTER (WHERE tagname=@sp) sp
FROM raw GROUP BY ts
), good AS (
SELECT pv - sp AS e FROM piv
WHERE pv BETWEEN @plo AND @phi AND sp BETWEEN @slo AND @shi
)
SELECT avg(e), stddev(e),
percentile_cont(0.95) WITHIN GROUP (ORDER BY abs(e)),
count(*),
100.0 * count(*) FILTER (WHERE abs(e) > 0.5) / NULLIF(count(*),0)
FROM good;";
AddP(cmd, "@pv", s.A.Tag); AddP(cmd, "@sp", s.B.Tag);
AddP(cmd, "@plo", s.A.Lo); AddP(cmd, "@phi", s.A.Hi);
AddP(cmd, "@slo", s.B.Lo); AddP(cmd, "@shi", s.B.Hi);
if (isFast) AddP(cmd, "@sid", sid ?? -1); else { AddP(cmd, "@from", fromUtc); AddP(cmd, "@to", toUtc); }
await using var rd = await cmd.ExecuteReaderAsync(ct);
if (await rd.ReadAsync(ct) && !rd.IsDBNull(3) && rd.GetInt64(3) > 0)
{
res.Value = rd.IsDBNull(0) ? null : rd.GetDouble(0);
res.Extra["sd"] = rd.IsDBNull(1) ? null : rd.GetDouble(1);
res.Extra["abs_p95"] = rd.IsDBNull(2) ? null : rd.GetDouble(2);
res.N = (int)rd.GetInt64(3);
res.Extra["out_pct_0_5"] = rd.IsDBNull(4) ? null : rd.GetDouble(4);
}
else { res.Status = "no_data"; res.Value = null; }
}
private static async Task<int> FastSamplingMsAsync(DbConnection conn, int sid, CancellationToken ct)
{
await using var c = conn.CreateCommand();
c.CommandText = "SELECT sampling_ms FROM hc900.fast_session WHERE id=@id";
AddP(c, "@id", sid);
var o = await c.ExecuteScalarAsync(ct);
return o is null or DBNull ? 0 : Convert.ToInt32(o);
}
private static void AddP(DbCommand cmd, string name, object val)
{ var p = cmd.CreateParameter(); p.ParameterName = name; p.Value = val ?? DBNull.Value; cmd.Parameters.Add(p); }
}