diff --git a/src/Hc900Crawler/Program.cs b/src/Hc900Crawler/Program.cs index d3c3d40..6bc5e28 100644 --- a/src/Hc900Crawler/Program.cs +++ b/src/Hc900Crawler/Program.cs @@ -174,6 +174,7 @@ builder.Services.AddCors(opt => builder.WebHost.UseUrls("http://0.0.0.0:5000"); // ── P0 셀프서비스 리포트 ────────────────────────────────────────────────────── +builder.Services.AddSingleton(); builder.Services.AddSingleton(); // P1a: 1초 링버퍼 히스토리안 (history_1s, 보존정책으로 디스크 상한 고정) builder.Services.AddHostedService(); diff --git a/src/Infrastructure/Reporting/EuRangeProvider.cs b/src/Infrastructure/Reporting/EuRangeProvider.cs new file mode 100644 index 0000000..3e9cdcd --- /dev/null +++ b/src/Infrastructure/Reporting/EuRangeProvider.cs @@ -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; + +/// +/// tag_metadata(EAV)의 eulo/euhi(계기 EU레인지)를 base_tag별로 1회 로드·캐시. +/// 메트릭 클린범위(스파이크/드롭아웃 = 계기 물리범위 밖) 자동도출용. 없으면 호출측이 역할 기본값으로 폴백. +/// 운전모드 제외는 별개(cleaning 마스크); 여기선 물리적 유효범위만. +/// +public sealed class EuRangeProvider +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly SemaphoreSlim _gate = new(1, 1); + private volatile Dictionary? _cache; + + public EuRangeProvider(IServiceScopeFactory scopeFactory, ILogger logger) + { _scopeFactory = scopeFactory; _logger = logger; } + + /// 최초 1회 tag_metadata에서 eulo/euhi 로드(멱등, thread-safe). 실패 시 빈 캐시(전부 폴백). + public async Task EnsureLoadedAsync(CancellationToken ct = default) + { + if (_cache != null) return; + await _gate.WaitAsync(ct); + try + { + if (_cache != null) return; + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + try + { + using var scope = _scopeFactory.CreateScope(); + var conn = scope.ServiceProvider.GetRequiredService().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(StringComparer.OrdinalIgnoreCase); + var hi = new Dictionary(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(); } + } + + /// base_tag(속성 무관) EU레인지. 미로드/미존재면 false → 호출측 폴백. + 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; + } +} diff --git a/src/Infrastructure/Reporting/ReportColumnMap.cs b/src/Infrastructure/Reporting/ReportColumnMap.cs index 5934c65..5598fd9 100644 --- a/src/Infrastructure/Reporting/ReportColumnMap.cs +++ b/src/Infrastructure/Reporting/ReportColumnMap.cs @@ -16,18 +16,26 @@ public sealed record CleaningSpec(string? VacTag, double VacMax, string ProductT /// /// 컬럼→태그 매핑. 기존 appsettings `SteamAdvisor:Columns`(Feed/Product/TC/SteamOp/SteamFlow)를 -/// 단일 진실원으로 재사용한다(멀티컬럼 무료). 클린범위는 역할별 기본값(향후 tag_metadata EU레인지로 대체 P2). +/// 단일 진실원으로 재사용한다(멀티컬럼 무료). 클린범위(스파이크/드롭아웃 경계)는 tag_metadata +/// EU레인지(eulo/euhi)를 우선 사용하고, 메타 없으면 역할별 기본값으로 폴백한다. /// 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); + /// 태그의 클린범위 = EU레인지(있으면) 우선, 없으면 역할 기본값. 캐시는 ComputeAsync에서 선로딩. + 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); + /// 설정된 컬럼 키 목록(SteamAdvisor:Columns). public IReadOnlyList 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: diff --git a/src/Infrastructure/Reporting/ReportMetricService.cs b/src/Infrastructure/Reporting/ReportMetricService.cs index d70de7e..877e018 100644 --- a/src/Infrastructure/Reporting/ReportMetricService.cs +++ b/src/Infrastructure/Reporting/ReportMetricService.cs @@ -18,10 +18,11 @@ public sealed class ReportMetricService : IReportMetricService private readonly Hc900DbContext _ctx; private readonly ILogger _logger; private readonly ReportColumnMap _map; + private readonly EuRangeProvider _eu; private const string NUMERIC = "^-?[0-9]+(\\.[0-9]+)?$"; - public ReportMetricService(Hc900DbContext ctx, ILogger logger, ReportColumnMap map) - { _ctx = ctx; _logger = logger; _map = map; } + public ReportMetricService(Hc900DbContext ctx, ILogger logger, ReportColumnMap map, EuRangeProvider eu) + { _ctx = ctx; _logger = logger; _map = map; _eu = eu; } private static readonly HashSet 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") {