feat(report): P2-D 클린범위 자동도출 — tag_metadata EU레인지(eulo/euhi)

- EuRangeProvider: tag_metadata의 eulo/euhi를 base_tag별 1회 캐시(thread-safe),
  미존재/로드실패 시 역할 기본값 폴백. ComputeAsync에서 선로딩(동기 조회 대비).
- ReportColumnMap.Tag(): 클린범위(스파이크/드롭아웃 BETWEEN 경계) = EU레인지 우선.
  energy_efficiency/yield/control_residual의 하드코딩 TEMP/STEAM/FLOW 제거.
- 검증: hc900.tag_metadata에 947태그 eulo/euhi 적재 확인(리포트 태그 전부 포함).
  운전모드 제외는 별개(cleaning 마스크); EU레인지는 계기 물리유효범위만 담당.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-06-17 21:48:45 +09:00
parent 400ba2c639
commit 4ac35c3ce0
4 changed files with 88 additions and 14 deletions

View File

@@ -174,6 +174,7 @@ builder.Services.AddCors(opt =>
builder.WebHost.UseUrls("http://0.0.0.0:5000");
// ── P0 셀프서비스 리포트 ──────────────────────────────────────────────────────
builder.Services.AddSingleton<Hc900Crawler.Infrastructure.Reporting.EuRangeProvider>();
builder.Services.AddSingleton<Hc900Crawler.Infrastructure.Reporting.ReportColumnMap>();
// P1a: 1초 링버퍼 히스토리안 (history_1s, 보존정책으로 디스크 상한 고정)
builder.Services.AddHostedService<Hc900Crawler.Infrastructure.Hc900.Hc900FastHistoryService>();

View File

@@ -0,0 +1,69 @@
using System.Data;
using System.Globalization;
using Hc900Crawler.Infrastructure.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Hc900Crawler.Infrastructure.Reporting;
/// <summary>
/// tag_metadata(EAV)의 eulo/euhi(계기 EU레인지)를 base_tag별로 1회 로드·캐시.
/// 메트릭 클린범위(스파이크/드롭아웃 = 계기 물리범위 밖) 자동도출용. 없으면 호출측이 역할 기본값으로 폴백.
/// 운전모드 제외는 별개(cleaning 마스크); 여기선 물리적 유효범위만.
/// </summary>
public sealed class EuRangeProvider
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<EuRangeProvider> _logger;
private readonly SemaphoreSlim _gate = new(1, 1);
private volatile Dictionary<string, (double Lo, double Hi)>? _cache;
public EuRangeProvider(IServiceScopeFactory scopeFactory, ILogger<EuRangeProvider> logger)
{ _scopeFactory = scopeFactory; _logger = logger; }
/// <summary>최초 1회 tag_metadata에서 eulo/euhi 로드(멱등, thread-safe). 실패 시 빈 캐시(전부 폴백).</summary>
public async Task EnsureLoadedAsync(CancellationToken ct = default)
{
if (_cache != null) return;
await _gate.WaitAsync(ct);
try
{
if (_cache != null) return;
var map = new Dictionary<string, (double, double)>(StringComparer.OrdinalIgnoreCase);
try
{
using var scope = _scopeFactory.CreateScope();
var conn = scope.ServiceProvider.GetRequiredService<Hc900DbContext>().Database.GetDbConnection();
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT base_tag, attribute, value FROM hc900.tag_metadata WHERE attribute IN ('eulo','euhi')";
var lo = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
var hi = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
await using var rd = await cmd.ExecuteReaderAsync(ct);
while (await rd.ReadAsync(ct))
{
if (rd.IsDBNull(2)) continue;
var tag = rd.GetString(0); var attr = rd.GetString(1);
if (!double.TryParse(rd.GetString(2), NumberStyles.Float, CultureInfo.InvariantCulture, out var v)) continue;
(attr == "eulo" ? lo : hi)[tag] = v;
}
foreach (var t in lo.Keys)
if (hi.TryGetValue(t, out var h) && h > lo[t]) map[t] = (lo[t], h); // 유효 범위만
_logger.LogInformation("[EuRange] {N}개 태그 EU레인지 로드", map.Count);
}
catch (Exception ex) { _logger.LogWarning(ex, "[EuRange] 로드 실패 — 역할 기본값 폴백"); }
_cache = map;
}
finally { _gate.Release(); }
}
/// <summary>base_tag(속성 무관) EU레인지. 미로드/미존재면 false → 호출측 폴백.</summary>
public bool TryGet(string baseTag, out double lo, out double hi)
{
lo = hi = 0;
var c = _cache;
if (c != null && c.TryGetValue(baseTag, out var r)) { lo = r.Lo; hi = r.Hi; return true; }
return false;
}
}

View File

@@ -16,18 +16,26 @@ public sealed record CleaningSpec(string? VacTag, double VacMax, string ProductT
/// <summary>
/// 컬럼→태그 매핑. 기존 appsettings `SteamAdvisor:Columns`(Feed/Product/TC/SteamOp/SteamFlow)를
/// 단일 진실원으로 재사용한다(멀티컬럼 무료). 클린범위는 역할별 기본값(향후 tag_metadata EU레인지로 대체 P2).
/// 단일 진실원으로 재사용한다(멀티컬럼 무료). 클린범위(스파이크/드롭아웃 경계)는 tag_metadata
/// EU레인지(eulo/euhi)를 우선 사용하고, 메타 없으면 역할별 기본값으로 폴백한다.
/// </summary>
public sealed class ReportColumnMap
{
private readonly IConfiguration _config;
public ReportColumnMap(IConfiguration config) => _config = config;
private readonly EuRangeProvider _eu;
public ReportColumnMap(IConfiguration config, EuRangeProvider eu) { _config = config; _eu = eu; }
// 역할별 기본 클린범위 (오늘 검증에서 0/드롭아웃/스파이크 제거에 쓴 값)
// 역할별 기본 클린범위 — tag_metadata EU레인지 없을 때만 폴백
private static readonly (double lo, double hi) TEMP = (60, 95);
private static readonly (double lo, double hi) STEAM = (50, 3000);
private static readonly (double lo, double hi) FLOW = (100, 1500);
/// <summary>태그의 클린범위 = EU레인지(있으면) 우선, 없으면 역할 기본값. 캐시는 ComputeAsync에서 선로딩.</summary>
private MetricTag Tag(string tag, (double lo, double hi) def)
=> _eu.TryGet(StripAttr(tag), out var lo, out var hi)
? new MetricTag(tag, lo, hi)
: new MetricTag(tag, def.lo, def.hi);
/// <summary>설정된 컬럼 키 목록(SteamAdvisor:Columns).</summary>
public IReadOnlyList<string> Columns()
=> _config.GetSection("SteamAdvisor:Columns").GetChildren().Select(c => c.Key).ToList();
@@ -111,24 +119,18 @@ public sealed class ReportColumnMap
{
case "energy_efficiency":
if (steam is null || product is null) return false;
spec = new MetricSpec("kg스팀/kg제품",
new MetricTag(steam, STEAM.lo, STEAM.hi),
new MetricTag(product, FLOW.lo, FLOW.hi));
spec = new MetricSpec("kg스팀/kg제품", Tag(steam, STEAM), Tag(product, FLOW));
return true;
case "yield":
if (product is null || feed is null) return false;
spec = new MetricSpec("제품/원료",
new MetricTag(product, FLOW.lo, FLOW.hi),
new MetricTag(feed, FLOW.lo, FLOW.hi));
spec = new MetricSpec("제품/원료", Tag(product, FLOW), Tag(feed, FLOW));
return true;
case "control_residual":
if (steamOp is null) return false;
var loopBase = StripAttr(steamOp); // TICA-6111A.OP → TICA-6111A
spec = new MetricSpec("degC",
new MetricTag(loopBase + ".PV", TEMP.lo, TEMP.hi),
new MetricTag(loopBase + ".SP", TEMP.lo, TEMP.hi));
spec = new MetricSpec("degC", Tag(loopBase + ".PV", TEMP), Tag(loopBase + ".SP", TEMP));
return true;
default:

View File

@@ -18,10 +18,11 @@ public sealed class ReportMetricService : IReportMetricService
private readonly Hc900DbContext _ctx;
private readonly ILogger<ReportMetricService> _logger;
private readonly ReportColumnMap _map;
private readonly EuRangeProvider _eu;
private const string NUMERIC = "^-?[0-9]+(\\.[0-9]+)?$";
public ReportMetricService(Hc900DbContext ctx, ILogger<ReportMetricService> logger, ReportColumnMap map)
{ _ctx = ctx; _logger = logger; _map = map; }
public ReportMetricService(Hc900DbContext ctx, ILogger<ReportMetricService> logger, ReportColumnMap map, EuRangeProvider eu)
{ _ctx = ctx; _logger = logger; _map = map; _eu = eu; }
private static readonly HashSet<string> QV_METRICS =
new() { "production_total", "yield_qv", "energy_intensity_qv", "mass_balance_closure" };
@@ -70,6 +71,7 @@ public sealed class ReportMetricService : IReportMetricService
{
var conn = _ctx.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
await _eu.EnsureLoadedAsync(ct); // 클린범위 EU레인지 캐시 선로딩(ReportColumnMap이 동기 조회)
if (req.Metric == "dynamics")
{