From 9d4269a02c592b6911cc8ca3aa2f45e739a77188 Mon Sep 17 00:00:00 2001 From: windpacer Date: Mon, 15 Jun 2026 08:20:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(report):=20dynamics=20=EB=A9=94=ED=8A=B8?= =?UTF-8?q?=EB=A6=AD=20(=EA=B3=A0=ED=95=B4=EC=83=81=20=EC=A0=84=EC=9A=A9)?= =?UTF-8?q?=20=E2=80=94=20=EB=B0=B8=EB=B8=8C=20stiction/hunting=20?= =?UTF-8?q?=EC=A7=84=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit history_1s/fast_record 전용 루프 동특성 — 60초로는 불가능한 진단. - 대상: 하부온도 루프 TICA-*A의 PV vs OP(스팀밸브). - 산출: OP travel/h(밸브 활동=stiction/마모/hunting proxy), PV sd(고해상 변동), 헌팅 주기(s, PV mean-crossing 기반), crossings. - 고해상 게이트: 60초 history → no_data + 사유. 표본<30s → no_data. - FOPDT 모델 식별은 step-test 필요라 미표방(정직 범위 = 진단지표). 검증: C-6111 하부루프 history_1s 30분 → PV sd 2.77, OP travel 35/h, 헌팅주기 ~97s. Co-Authored-By: Claude Opus 4.8 --- .../Reporting/ReportColumnMap.cs | 11 ++++ .../Reporting/ReportMetricService.cs | 62 ++++++++++++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/Infrastructure/Reporting/ReportColumnMap.cs b/src/Infrastructure/Reporting/ReportColumnMap.cs index 7d04b00..61a20b9 100644 --- a/src/Infrastructure/Reporting/ReportColumnMap.cs +++ b/src/Infrastructure/Reporting/ReportColumnMap.cs @@ -67,6 +67,17 @@ public sealed class ReportColumnMap public bool HasClosure(string column) => _config.GetSection($"Report:Closure:{column}").Exists(); + /// 동특성 대상 루프(하부온도 TICA-*A): PV vs OP(스팀밸브). 밸브 stiction/hunting 진단용. + public bool TryResolveDynamics(string column, out string? pvTag, out string? opTag) + { + pvTag = opTag = null; + var op = _config[$"SteamAdvisor:Columns:{column}:SteamOp"]; // 예: TICA-6111A.OP + if (string.IsNullOrWhiteSpace(op)) return false; + var b = StripAttr(op); + pvTag = b + ".PV"; opTag = b + ".OP"; + return true; + } + public bool TryResolve(string column, string metric, out MetricSpec? spec) { spec = null; diff --git a/src/Infrastructure/Reporting/ReportMetricService.cs b/src/Infrastructure/Reporting/ReportMetricService.cs index 523e845..c895d5d 100644 --- a/src/Infrastructure/Reporting/ReportMetricService.cs +++ b/src/Infrastructure/Reporting/ReportMetricService.cs @@ -51,7 +51,11 @@ public sealed class ReportMetricService : IReportMetricService var conn = _ctx.Database.GetDbConnection(); if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct); - if (QV_METRICS.Contains(req.Metric)) + if (req.Metric == "dynamics") + { + await DynamicsAsync(res, conn, tbl, req.Column, fromUtc, toUtc, isFast, req.SessionId, ct); + } + else 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; } @@ -80,6 +84,62 @@ public sealed class ReportMetricService : IReportMetricService return res; } + // ── 동특성(고해상 전용): 하부온도 루프 PV vs OP. 밸브 travel/hunting 진단. FOPDT 모델링 아님(step-test 필요). ── + private async Task DynamicsAsync(MetricResultDto res, DbConnection conn, string tbl, string column, + DateTime fromUtc, DateTime toUtc, bool isFast, int? sid, CancellationToken ct) + { + res.Unit = "OP/h"; + if (tbl != "history_1s" && tbl != "fast_record") + { res.Status = "no_data"; res.Value = null; res.Error = "동특성은 고해상 소스 필요(history_1s 또는 fast_record). 60초 history 불가."; return; } + if (!_map.TryResolveDynamics(column, out var pv, out var op) || pv is null || op is null) + { res.Status = "error"; res.Value = null; res.Error = $"동특성 루프 미정의: {column}"; return; } + + await using var cmd = conn.CreateCommand(); + cmd.CommandText = $@" +WITH s AS ( + SELECT date_trunc('second', recorded_at) ts, + max(CASE WHEN tagname=@pv THEN value::float END) pv, + max(CASE WHEN tagname=@op THEN value::float END) op + FROM hc900.{tbl} + WHERE tagname IN (@pv,@op) AND value ~ '{NUMERIC}' + AND ({(isFast ? "session_id = @sid" : "recorded_at >= @from AND recorded_at < @to")}) + GROUP BY 1 +), m AS ( + SELECT avg(pv) pvm, stddev(pv) pvsd, count(*) n, + extract(epoch FROM (max(ts)-min(ts))) dur FROM s WHERE pv IS NOT NULL +), seq AS ( + SELECT ts, sign(pv - (SELECT pvm FROM m)) side, + abs(op - lag(op) OVER (ORDER BY ts)) adop + FROM s WHERE pv IS NOT NULL +), seq2 AS ( + SELECT side, lag(side) OVER (ORDER BY ts) ps, adop FROM seq +) +SELECT (SELECT pvsd FROM m), (SELECT n FROM m), (SELECT dur FROM m), + count(*) FILTER (WHERE side <> ps AND side <> 0 AND ps IS NOT NULL), + coalesce(sum(adop), 0) +FROM seq2"; + AddP(cmd, "@pv", pv); AddP(cmd, "@op", op); + 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(1) && rd.GetInt64(1) >= 30) + { + double pvsd = rd.IsDBNull(0) ? 0 : rd.GetDouble(0); + res.N = (int)rd.GetInt64(1); + double dur = rd.IsDBNull(2) ? 0 : rd.GetDouble(2); + long crossings = rd.IsDBNull(3) ? 0 : rd.GetInt64(3); + double opTravel = rd.IsDBNull(4) ? 0 : rd.GetDouble(4); + + res.Value = dur > 0 ? opTravel * 3600.0 / dur : null; // OP travel/h = 밸브 활동(stiction/hunting/마모 proxy) + res.Extra["pv_sd"] = pvsd; // PV 변동(고해상) + res.Extra["osc_period_s"] = crossings > 0 && dur > 0 ? 2.0 * dur / crossings : null; // 헌팅 주기(s) + res.Extra["crossings"] = crossings; + res.Extra["dur_s"] = dur; + if (res.Value is null) res.Status = "no_data"; + } + else { res.Status = "no_data"; res.Value = null; res.Error = "고해상 표본 부족(≥30s 필요)"; } + } + // ── 적산(.QV) 메트릭: Single=ΔA, Ratio=ΔA/ΔB, Closure=100·ΣΔOut/Δfeed. cleaning/drawdown 제외. ── private async Task ComputeQvAsync(MetricResultDto res, DbConnection conn, string tbl, QvSpec s, CleaningSpec? cl, DateTime fromUtc, DateTime toUtc, bool isFast, int? sid, CancellationToken ct)