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>
249 lines
12 KiB
C#
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); }
|
|
}
|