feat(report): dynamics 메트릭 (고해상 전용) — 밸브 stiction/hunting 진단

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 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-06-15 08:20:57 +09:00
parent c3a5258bf2
commit 9d4269a02c
2 changed files with 72 additions and 1 deletions

View File

@@ -67,6 +67,17 @@ public sealed class ReportColumnMap
public bool HasClosure(string column)
=> _config.GetSection($"Report:Closure:{column}").Exists();
/// <summary>동특성 대상 루프(하부온도 TICA-*A): PV vs OP(스팀밸브). 밸브 stiction/hunting 진단용.</summary>
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;

View File

@@ -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)