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:
@@ -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>();
|
||||
|
||||
69
src/Infrastructure/Reporting/EuRangeProvider.cs
Normal file
69
src/Infrastructure/Reporting/EuRangeProvider.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user