Files
HC900-Crawler/docs/작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md
windpacer 30a3286d35 feat(report): 적산(.QV) 정확 메트릭 + 물질수지 폐합 + 웹 대시보드
PV-중앙값 근사 대신 유량 적산(.QV)으로 정확한 적분 메트릭 추가, 웹에서 바로 보기.

- QV 메트릭 4종: production_total·yield_qv·energy_intensity_qv·mass_balance_closure.
  적산 Δ 헬퍼(QvDeltaAsync)는 값 급감(reset/wrap)으로 구간분할 후 구간별 (마지막-처음)
  합산 → gap·양자화 강건, DCS 일일적산과 동일 의미(노이즈 과대계상 없음).
- 매핑은 appsettings SteamAdvisor:Columns 재사용(.QV 치환, 7컬럼). 폐합 스트림은
  Report:Closure:C-6111(Feed + Outputs[제품·경비물·중비물]).
- 웹 대시보드: GET /api/report/columns, /api/report/summary →
  리포트 탭에서 컬럼·날짜 선택 시 물질수지 신뢰블록(IN/OUT/폐합%, 98~101% 색상)
  + 메트릭 표를 즉시 렌더. 엑셀 export와 병존.
- 물리적 타당성 게이트: 수율>1.5·에너지원단위>5·≤0 → no_data+사유(garbage 차단).

검증(2026-05-15 C-6111): 생산 8455kg·수율 0.8739·에너지 0.7791·폐합 99.04%
(feed 9675/out 9582), 17주 폐합 99.1~99.8%. C-9111 garbage 수율 → no_data 처리 확인.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 07:54:34 +09:00

28 KiB
Raw Blame History

P0 MVP 상세설계 — 셀프서비스 결정론 리포트 (코딩 레벨)

2026-06-12. 상위: 작업플랜-셀프서비스-분석리포트-MVP.md논의-AI운전원-제어-아이디어.md. P0 목표(한 문장): 운전원이 올린 엑셀 템플릿에 {{ metric|... }} 토큰을 박아두면, 날짜 하나 선택 시 C-6111 컬럼의 일일 효율·수율·제어잔차가 그 폼 그대로 채워져 다운로드된다. 소스=history_table(60s), 동일 코드로 fast_record도 가능함을 1개 메트릭으로 실증.

0. P0 범위 (의도적 최소)

포함 제외(P1+)
메트릭 3종: energy_efficiency, yield, control_residual dynamics(fast 전용), excursion, settling
대상 1컬럼: C-6111 (KB 매핑 기존) 멀티컬럼/멀티플랜트
소스: history_table(주) + fast_record(효율 1종만 실증) 멀티소스 일반화
Daily 1주기, 수동 트리거(날짜 선택→생성) Monthly/Yearly, 스케줄 자동생성
엑셀 토큰 채움(EPPlus, 서버) + 다운로드 템플릿 업로드 UI(P2), 미리보기
결과 표 + 토큰맵 JSON named-range 고급문법

0.1 기존 자산 재사용 (신규 의존성 0)

  • EPPlus 7.4.2Hc900Crawler.csproj에 이미 있음. PidExtractorService.cs:620에서 new OfficeOpenXml.ExcelPackage() 바로 사용 = 라이선스 컨텍스트 이미 설정됨(동일 패턴 재사용).
  • raw SQLFeedforwardAuditService 패턴(_ctx.Database.GetDbConnection() + @param) 그대로.
  • 패널 로딩index.html data-src="/panes/X.html" + nav data-tab 컨벤션.
  • 연결appsettings.json:DefaultConnection(iiot_platform, Search Path=hc900).
  • KST 처리KstClock/KoreanTimeRangeExtractor(Program.cs:70-71) 존재. recorded_at=UTC.

1. 데이터 모델 (DDL — 최소 2테이블)

hc900 스키마. 마이그레이션 SQL(scripts/sql/p0_report.sql):

-- 운전원이 올린 엑셀 템플릿 + 토큰맵
CREATE TABLE IF NOT EXISTS hc900.report_template (
  id          serial PRIMARY KEY,
  name        text NOT NULL,
  owner       text,
  xlsx_blob   bytea NOT NULL,           -- 원본 템플릿(.xlsx)
  created_at  timestamptz NOT NULL DEFAULT now(),
  updated_at  timestamptz NOT NULL DEFAULT now()
);

-- 생성 이력(감사). 어떤 정의로 어떤 기간을 뽑았는지 박제
CREATE TABLE IF NOT EXISTS hc900.report_run (
  id           bigserial PRIMARY KEY,
  template_id  int REFERENCES hc900.report_template(id),
  period_kind  text NOT NULL,           -- 'DAILY'
  period_date  date NOT NULL,           -- KST 기준 날짜
  source_table text NOT NULL,           -- 'history_table' | 'fast_record'
  generated_at timestamptz NOT NULL DEFAULT now(),
  status       text NOT NULL,           -- 'ok' | 'partial' | 'error'
  cells_json   jsonb,                   -- 채운 셀/값/메타 박제(추적)
  out_blob     bytea                    -- 채워진 .xlsx (다운로드 캐시)
);

토큰맵은 별도 컬럼이 아니라 템플릿 셀 주석/값에 직접 박는다(§7). report_run.cells_json에 "어떤 셀=어떤 메트릭+파라미터+값+sampling메타"를 결정론적으로 박제 → 감사·재현.

2. 컬럼→메트릭 태그 매핑 (config, 하드코딩 금지)

KB(docs/kb/P6-1_플랜트_공정마스터.md)에서 도출. 파일 config/report-metric-map.json(런타임 로드, 편집 시 재배포 불필요):

{
  "C-6111": {
    "label": "6-1차 PGMEA 진공증류탑",
    "metrics": {
      "energy_efficiency": { "steam": "FIQ-6115.PV", "product": "FICQ-6118.PV", "unit": "kg스팀/kg제품" },
      "yield":             { "product": "FICQ-6118.PV", "feed": "FICQ-6101.PV", "unit": "비율" },
      "control_residual":  { "pv": "TICA-6111A.PV", "sp": "TICA-6111A.SP", "unit": "degC" }
    },
    "clean": {
      "TICA-6111A.PV": [60,95], "TICA-6111A.SP": [60,95],
      "FIQ-6115.PV": [50,3000], "FICQ-6118.PV": [100,1500], "FICQ-6101.PV": [100,1500]
    }
  }
}

clean 범위 = 오늘 검증에서 0/드롭아웃/스파이크 제거에 쓴 값. 채널별 양자화/RTD레인지 주의(메모리). 향후 tag_metadata에서 EU레인지로 자동도출 가능(P2).


3. 메트릭 엔진 (C#)

3.1 DTO — src/Core/Application/DTOs/ReportDtos.cs

namespace Hc900Crawler.Core.Application.DTOs;

public sealed class MetricRequestDto
{
    public string Column      { get; set; } = "C-6111";
    public string Metric      { get; set; } = "";       // energy_efficiency | yield | control_residual
    public DateTime PeriodDateKst { get; set; }          // 운전원이 고른 KST 날짜(00:00 기준)
    public string SourceTable { get; set; } = "history_table"; // | fast_record (+ session_id)
    public int?   SessionId   { get; set; }              // fast_record일 때
}

/// <summary>결정론 메트릭 1건 결과 + 해상도 메타(필수).</summary>
public sealed class MetricResultDto
{
    public string  Metric   { get; set; } = "";
    public string  Column   { get; set; } = "";
    public double? Value    { get; set; }     // 대표값(없으면 null = 명시적 실패, 날조 금지)
    public string? Unit     { get; set; }
    public string  Status   { get; set; } = "ok"; // ok | no_data | error
    public string? Error    { get; set; }
    // ── 해상도-인지 메타 (사과-오렌지 방지) ──
    public string  Source     { get; set; } = "";
    public int     SamplingMs { get; set; }   // 60000(history) | fast_record.sampling_ms
    public int     N          { get; set; }   // 클린 후 표본수
    public double  CleanedFraction { get; set; } // 제거된 비율
    public Dictionary<string, double?> Extra { get; set; } = new(); // sd/p95/이탈% 등
}

3.2 인터페이스 — src/Core/Application/Interfaces/IReportMetricService.cs

using Hc900Crawler.Core.Application.DTOs;
namespace Hc900Crawler.Core.Application.Interfaces;

public interface IReportMetricService
{
    Task<MetricResultDto> ComputeAsync(MetricRequestDto req, CancellationToken ct = default);
}

3.3 구현 — src/Infrastructure/Reporting/ReportMetricService.cs

raw SQL은 FeedforwardAuditService 패턴(_ctx.Database.GetDbConnection())을 따른다. KST 날짜 → UTC 경계는 C#에서 계산(recorded_at=UTC).

using System.Data;
using System.Text.Json;
using Hc900Crawler.Core.Application.DTOs;
using Hc900Crawler.Core.Application.Interfaces;
using Hc900Crawler.Infrastructure.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace Hc900Crawler.Infrastructure.Reporting;

public sealed class ReportMetricService : IReportMetricService
{
    private readonly Hc900DbContext _ctx;
    private readonly ILogger<ReportMetricService> _logger;
    private readonly MetricMap _map;     // config/report-metric-map.json 로드

    public ReportMetricService(Hc900DbContext ctx, ILogger<ReportMetricService> logger, MetricMap map)
    { _ctx = ctx; _logger = logger; _map = map; }

    public async Task<MetricResultDto> ComputeAsync(MetricRequestDto req, CancellationToken ct = default)
    {
        var res = new MetricResultDto { Metric = req.Metric, Column = req.Column,
                                        Source = req.SourceTable, SamplingMs = req.SourceTable == "fast_record" ? 0 : 60000 };
        if (!_map.TryGet(req.Column, req.Metric, out var roles, out var unit, out var clean))
        { res.Status = "error"; res.Error = $"미정의 매핑: {req.Column}/{req.Metric}"; return res; }
        res.Unit = unit;

        // KST 날짜 [00:00, +1d) → UTC
        var fromUtc = DateTime.SpecifyKind(req.PeriodDateKst.Date, DateTimeKind.Unspecified).AddHours(-9);
        var toUtc   = fromUtc.AddDays(1);
        // fast_record는 세션 전체(기간무관) 또는 세션의 해당일. P0: 세션 전체.
        bool isFast = req.SourceTable == "fast_record";
        string tbl  = isFast ? "fast_record" : "history_table";

        try
        {
            res = req.Metric switch
            {
                "energy_efficiency" => await RatioMetric(res, tbl, roles["steam"],   roles["product"], clean, fromUtc, toUtc, isFast, req.SessionId, ct),
                "yield"             => await RatioMetric(res, tbl, roles["product"], roles["feed"],    clean, fromUtc, toUtc, isFast, req.SessionId, ct),
                "control_residual"  => await ResidualMetric(res, tbl, roles["pv"], roles["sp"],        clean, fromUtc, toUtc, isFast, req.SessionId, ct),
                _ => Fail(res, $"미구현 메트릭: {req.Metric}")
            };
        }
        catch (Exception ex) { _logger.LogError(ex, "[Report] metric 실패 {M}", req.Metric); Fail(res, ex.Message); }
        return res;
    }

    private static MetricResultDto Fail(MetricResultDto r, string msg) { r.Status="error"; r.Error=msg; r.Value=null; return r; }

    // ── 비율 메트릭: median(numer)/median(denom). 분단위 피벗으로 정렬 후 robust 중앙값 ──
    private async Task<MetricResultDto> RatioMetric(
        MetricResultDto res, string tbl, string numerTag, string denomTag,
        IReadOnlyDictionary<string,(double lo,double hi)> clean,
        DateTime fromUtc, DateTime toUtc, bool isFast, int? sessionId, CancellationToken ct)
    {
        var (nlo,nhi) = clean[numerTag]; var (dlo,dhi) = clean[denomTag];
        var conn = _ctx.Database.GetDbConnection();
        if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
        await using var cmd = conn.CreateCommand();
        // 분 버킷 피벗 → 둘 다 유효한 분만 → 비율의 중앙값/표본수 + 원천 표본수(클린율 산출)
        cmd.CommandText = $@"
WITH raw AS (
  SELECT date_trunc('minute', recorded_at) ts, tagname, value::float v
  FROM hc900.{tbl}
  WHERE tagname IN (@numer,@denom)
    AND ({(isFast ? "session_id = @sid" : "recorded_at >= @from AND recorded_at < @to")})
), tot AS ( SELECT count(*) c FROM raw ),
piv AS (
  SELECT ts,
    max(v) FILTER (WHERE tagname=@numer) n,
    max(v) FILTER (WHERE tagname=@denom) d
  FROM raw GROUP BY ts
), good AS (
  SELECT n, d FROM piv
  WHERE n BETWEEN @nlo AND @nhi AND d BETWEEN @dlo AND @dhi AND d <> 0
)
SELECT
  (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY n/d) FROM good) AS ratio_med,
  (SELECT count(*) FROM good)  AS n_good,
  (SELECT c FROM tot)          AS n_raw;";
        AddP(cmd,"@numer",numerTag); AddP(cmd,"@denom",denomTag);
        AddP(cmd,"@nlo",nlo); AddP(cmd,"@nhi",nhi); AddP(cmd,"@dlo",dlo); AddP(cmd,"@dhi",dhi);
        if (isFast) AddP(cmd,"@sid", sessionId ?? -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(0))
        {
            res.Value = rd.GetDouble(0);
            res.N = rd.GetInt32(1);
            var nRaw = rd.GetInt64(2);
            var nGood = res.N;
            res.CleanedFraction = nRaw > 0 ? 1.0 - (2.0*nGood)/nRaw : 0; // 2태그라 분모 보정
            if (isFast) res.SamplingMs = await FastSamplingMsAsync(conn, sessionId ?? -1, ct);
        }
        else { res.Status = "no_data"; res.Value = null; }
        return res;
    }

    // ── 제어잔차: (PV-SP) 통계. 분 피벗 후 잔차의 mean/sd/p95/이탈% ──
    private async Task<MetricResultDto> ResidualMetric(
        MetricResultDto res, string tbl, string pvTag, string spTag,
        IReadOnlyDictionary<string,(double lo,double hi)> clean,
        DateTime fromUtc, DateTime toUtc, bool isFast, int? sessionId, CancellationToken ct)
    {
        var (plo,phi) = clean[pvTag]; var (slo,shi) = clean[spTag];
        var conn = _ctx.Database.GetDbConnection();
        if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
        await using var cmd = conn.CreateCommand();
        cmd.CommandText = $@"
WITH raw AS (
  SELECT date_trunc('minute', recorded_at) ts, tagname, value::float v
  FROM hc900.{tbl}
  WHERE tagname IN (@pv,@sp)
    AND ({(isFast ? "session_id = @sid" : "recorded_at >= @from AND recorded_at < @to")})
), piv AS (
  SELECT ts, max(v) FILTER (WHERE tagname=@pv) pv, max(v) FILTER (WHERE tagname=@sp) sp
  FROM raw GROUP BY ts
), good AS (
  SELECT pv - sp AS e FROM piv
  WHERE pv BETWEEN @plo AND @phi AND sp BETWEEN @slo AND @shi
)
SELECT avg(e), stddev(e), percentile_cont(0.95) WITHIN GROUP (ORDER BY abs(e)),
       count(*), 100.0*count(*) FILTER (WHERE abs(e) > 0.5)/NULLIF(count(*),0)
FROM good;";
        AddP(cmd,"@pv",pvTag); AddP(cmd,"@sp",spTag);
        AddP(cmd,"@plo",plo); AddP(cmd,"@phi",phi); AddP(cmd,"@slo",slo); AddP(cmd,"@shi",shi);
        if (isFast) AddP(cmd,"@sid", sessionId ?? -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(3) && rd.GetInt64(3) > 0)
        {
            res.Value = rd.IsDBNull(0) ? null : rd.GetDouble(0);      // 평균 잔차
            res.Extra["sd"]         = rd.IsDBNull(1) ? null : rd.GetDouble(1);
            res.Extra["abs_p95"]    = rd.IsDBNull(2) ? null : rd.GetDouble(2);
            res.Extra["out_pct_0_5"]= rd.IsDBNull(4) ? null : rd.GetDouble(4);
            res.N = (int)rd.GetInt64(3);
            if (isFast) res.SamplingMs = await FastSamplingMsAsync(conn, sessionId ?? -1, ct);
        }
        else { res.Status = "no_data"; res.Value = null; }
        return res;
    }

    private static async Task<int> FastSamplingMsAsync(System.Data.Common.DbConnection conn, int sid, CancellationToken ct)
    {
        await using var c = conn.CreateCommand();
        c.CommandText = "SELECT sampling_ms FROM hc900.fast_session WHERE id=@id";
        AddP(c,"@id",sid);
        var o = await c.ExecuteScalarAsync(ct);
        return o is int i ? i : (o is null ? 0 : Convert.ToInt32(o));
    }

    private static void AddP(System.Data.Common.DbCommand cmd, string name, object val)
    { var p = cmd.CreateParameter(); p.ParameterName = name; p.Value = val ?? DBNull.Value; cmd.Parameters.Add(p); }
}

결정론 검증 게이트 준수: 표본 0 → Status=no_data, Value=null(빈칸을 0으로 날조하지 않음). 매핑 미정의 → 명시적 error. (메모리 deterministic-verification-gate)

3.4 매핑 로더 — src/Infrastructure/Reporting/MetricMap.cs

using System.Text.Json;
namespace Hc900Crawler.Infrastructure.Reporting;

public sealed class MetricMap
{
    private readonly JsonElement _root;
    public MetricMap(string jsonPath) => _root = JsonDocument.Parse(File.ReadAllText(jsonPath)).RootElement;

    public bool TryGet(string column, string metric,
        out Dictionary<string,string> roles, out string? unit,
        out Dictionary<string,(double lo,double hi)> clean)
    {
        roles = new(); unit = null; clean = new();
        if (!_root.TryGetProperty(column, out var col)) return false;
        if (!col.GetProperty("metrics").TryGetProperty(metric, out var m)) return false;
        foreach (var p in m.EnumerateObject())
            if (p.Name == "unit") unit = p.Value.GetString(); else roles[p.Name] = p.Value.GetString()!;
        foreach (var p in col.GetProperty("clean").EnumerateObject())
            clean[p.Name] = (p.Value[0].GetDouble(), p.Value[1].GetDouble());
        return true;
    }
}

4. 엑셀 토큰 채움 (EPPlus, 서버) — src/Infrastructure/Reporting/ReportFillService.cs

토큰 = 셀 값 텍스트 {{ metric=energy_efficiency; column=C-6111 }}. 엔진이 전 시트 스캔→토큰 셀을 결과값으로 치환, 메타는 셀 주석으로.

using OfficeOpenXml;
using System.Text.RegularExpressions;
using Hc900Crawler.Core.Application.DTOs;
using Hc900Crawler.Core.Application.Interfaces;

namespace Hc900Crawler.Infrastructure.Reporting;

public sealed class ReportFillService
{
    private static readonly Regex TOKEN = new(@"\{\{\s*(?<body>.+?)\s*\}\}", RegexOptions.Compiled);
    private readonly IReportMetricService _metrics;
    public ReportFillService(IReportMetricService metrics) => _metrics = metrics;

    public async Task<(byte[] xlsx, List<object> cells, string status)> FillAsync(
        byte[] template, DateTime periodKst, string sourceTable, int? sessionId, CancellationToken ct = default)
    {
        using var pkg = new ExcelPackage(new MemoryStream(template));  // 라이선스 컨텍스트 기설정(PidExtractorService 동일)
        var cells = new List<object>(); var anyErr = false; var anyOk = false;

        foreach (var ws in pkg.Workbook.Worksheets)
        {
            var dim = ws.Dimension; if (dim == null) continue;
            for (int r = dim.Start.Row; r <= dim.End.Row; r++)
            for (int c = dim.Start.Column; c <= dim.End.Column; c++)
            {
                var txt = ws.Cells[r, c].Text;
                var mt = TOKEN.Match(txt); if (!mt.Success) continue;
                var kv = ParseToken(mt.Groups["body"].Value);   // metric=..; column=..; field=..(opt)
                var req = new MetricRequestDto {
                    Metric = kv.GetValueOrDefault("metric",""), Column = kv.GetValueOrDefault("column","C-6111"),
                    PeriodDateKst = periodKst, SourceTable = sourceTable, SessionId = sessionId };
                var m = await _metrics.ComputeAsync(req, ct);

                double? v = kv.TryGetValue("field", out var f) && m.Extra.TryGetValue(f, out var ev) ? ev : m.Value;
                if (m.Status == "ok" && v.HasValue) { ws.Cells[r,c].Value = v.Value; anyOk = true; }
                else { ws.Cells[r,c].Value = m.Status == "no_data" ? "N/A" : "ERR"; anyErr = true; }

                ws.Cells[r,c].AddComment($"{m.Metric} | src={m.Source} {m.SamplingMs}ms | n={m.N} | clean={1-m.CleanedFraction:P0} | {m.Unit}", "report");
                cells.Add(new { sheet=ws.Name, r, c, m.Metric, m.Column, value=v, m.Status, m.Source, m.SamplingMs, m.N });
            }
        }
        var status = anyErr ? (anyOk ? "partial" : "error") : "ok";
        return (pkg.GetAsByteArray(), cells, status);
    }

    private static Dictionary<string,string> ParseToken(string body) =>
        body.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
            .Select(p => p.Split('=', 2)).Where(a => a.Length==2)
            .ToDictionary(a => a[0].Trim().ToLowerInvariant(), a => a[1].Trim());
}

5. 컨트롤러 — src/Hc900Crawler/Controllers/ReportController.cs

using Hc900Crawler.Core.Application.DTOs;
using Hc900Crawler.Core.Application.Interfaces;
using Hc900Crawler.Infrastructure.Reporting;
using Microsoft.AspNetCore.Mvc;

namespace Hc900Crawler.Web.Controllers;

[ApiController]
[Route("api/report")]
public class ReportController : ControllerBase
{
    private readonly IReportMetricService _metrics;
    private readonly ReportFillService _fill;
    private readonly IReportTemplateStore _store;   // report_template/report_run CRUD (raw SQL, 패턴 동일)
    public ReportController(IReportMetricService m, ReportFillService f, IReportTemplateStore s)
    { _metrics = m; _fill = f; _store = s; }

    // 단건 메트릭(미리보기/디버그)
    [HttpPost("metric")]
    public async Task<IActionResult> Metric([FromBody] MetricRequestDto req, CancellationToken ct)
        => Ok(await _metrics.ComputeAsync(req, ct));

    // 템플릿 업로드(P0: 단순 등록)
    [HttpPost("template")]
    public async Task<IActionResult> Upload([FromForm] IFormFile file, [FromForm] string name, [FromForm] string? owner)
    {
        using var ms = new MemoryStream(); await file.CopyToAsync(ms);
        var id = await _store.CreateAsync(name, owner, ms.ToArray());
        return Ok(new { Id = id });
    }

    // ★핵심: 템플릿+날짜 → 채워진 xlsx
    [HttpGet("generate")]
    public async Task<IActionResult> Generate(int templateId, DateTime date,
        string source = "history_table", int? sessionId = null, CancellationToken ct = default)
    {
        var tpl = await _store.GetBlobAsync(templateId);
        if (tpl == null) return NotFound();
        var (xlsx, cells, status) = await _fill.FillAsync(tpl, date, source, sessionId, ct);
        await _store.RecordRunAsync(templateId, "DAILY", date, source, status, cells, xlsx);
        var fname = $"report_{templateId}_{date:yyyyMMdd}.xlsx";
        Response.Headers["X-Report-Status"] = status;
        return File(xlsx, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fname);
    }
}

IReportTemplateStore(+ ReportTemplateStore)는 report_template/report_run raw SQL CRUD — FeedforwardAuditService 패턴 복제(생략, §10 체크리스트 포함).

6. DI 등록 — src/Hc900Crawler/Program.cs (추가 라인)

// ── P0 Report ──
builder.Services.AddSingleton(new Hc900Crawler.Infrastructure.Reporting.MetricMap(
    Path.Combine(builder.Environment.ContentRootPath, "config", "report-metric-map.json")));
builder.Services.AddScoped<Hc900Crawler.Core.Application.Interfaces.IReportMetricService,
                           Hc900Crawler.Infrastructure.Reporting.ReportMetricService>();
builder.Services.AddScoped<Hc900Crawler.Infrastructure.Reporting.ReportFillService>();
builder.Services.AddScoped<Hc900Crawler.Core.Application.Interfaces.IReportTemplateStore,
                           Hc900Crawler.Infrastructure.Reporting.ReportTemplateStore>();

7. 토큰 규약 (운전원이 엑셀에 입력)

토큰(셀에 그대로 입력) 채워지는 값
{{ metric=energy_efficiency; column=C-6111 }} 일일 스팀/제품 중앙값
{{ metric=yield; column=C-6111 }} 일일 제품/원료
{{ metric=control_residual; column=C-6111 }} 평균 잔차(PVSP)
{{ metric=control_residual; column=C-6111; field=sd }} 잔차 표준편차
{{ metric=control_residual; column=C-6111; field=out_pct_0_5 }} |잔차|>0.5℃ 시간비율
  • 채운 셀엔 주석으로 src/sampling/n/clean/unit 자동 부착(해상도-인지).
  • 포맷 변경 = 운전원이 셀 옮기고 토큰 복붙. 전산팀 0, 리드타임 0.

8. 프론트엔드 (최소 패널)

index.html — nav + pane 등록(기존 컨벤션):

<li class="nav-item" data-tab="reports"><span class="nl">리포트</span></li>
...
<section class="pane" id="pane-reports" data-src="/panes/reports.html?v=20260612"></section>

wwwroot/panes/reports.html (요지):

<div class="report-pane">
  <input type="file" id="rpTpl" accept=".xlsx">
  <input type="date" id="rpDate">
  <select id="rpSource"><option value="history_table">history(60s)</option>
    <option value="fast_record">fastRecord</option></select>
  <input type="number" id="rpSession" placeholder="fast session id" hidden>
  <button id="rpGen">리포트 생성·다운로드</button>
  <div id="rpStatus"></div>
</div>
<script src="/js/reports.js"></script>

wwwroot/js/reports.js (요지 — 업로드 후 generate 호출, blob 다운로드):

document.getElementById('rpGen').onclick = async () => {
  const f = document.getElementById('rpTpl').files[0];
  const fd = new FormData(); fd.append('file', f); fd.append('name', f.name);
  const up = await fetch('/api/report/template', {method:'POST', body:fd}).then(r=>r.json());
  const date = document.getElementById('rpDate').value;
  const src  = document.getElementById('rpSource').value;
  const sid  = document.getElementById('rpSession').value;
  const url  = `/api/report/generate?templateId=${up.Id}&date=${date}&source=${src}`+(sid?`&sessionId=${sid}`:'');
  const resp = await fetch(url);
  document.getElementById('rpStatus').textContent = '상태: ' + resp.headers.get('X-Report-Status');
  const blob = await resp.blob();
  const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
  a.download = `report_${date}.xlsx`; a.click();
};

9. 수용 기준 (Acceptance) · 데모 스크립트

  1. C-6111 효율 토큰 1개 엑셀 → 2026-05-15 선택 → 생성 → 셀에 ~0.79 값, 주석에 src=history_table 60000ms n=~1400.
  2. 같은 토큰을 control_residual; field=sd로 → TICA-6111A 잔차 sd 채워짐.
  3. 데이터 없는 날(예: 2026-01-01) → 셀 N/A(0 날조 아님), X-Report-Status: partial/error.
  4. fastRecord 세션 1개 띄워(예: 1초·5분) → source=fast_record&sessionId=N → 동일 효율 토큰이 sampling 60000→1000ms로 주석 바뀌어 채워짐(해상도 가변 실증).
  5. report_run에 cells_json 박제 확인(감사·재현).

검증용 결정론 쿼리(오늘 검증과 일치): 2026-05 저율 캠페인 효율 ≈ 0.79~0.83 (검증 3회차 seg4). 엔진 값이 이 범위면 OK.

10. 파일 체크리스트 / 작업량

파일 신규/수정 비고
scripts/sql/p0_report.sql 신규 2테이블 DDL
config/report-metric-map.json 신규 C-6111 매핑
Core/Application/DTOs/ReportDtos.cs 신규 3 DTO
Core/Application/Interfaces/IReportMetricService.cs 신규
Core/Application/Interfaces/IReportTemplateStore.cs 신규
Infrastructure/Reporting/ReportMetricService.cs 신규 메트릭 3종 SQL
Infrastructure/Reporting/MetricMap.cs 신규 config 로더
Infrastructure/Reporting/ReportFillService.cs 신규 EPPlus 토큰 채움
Infrastructure/Reporting/ReportTemplateStore.cs 신규 raw SQL CRUD(패턴복제)
Hc900Crawler/Controllers/ReportController.cs 신규 3 엔드포인트
Hc900Crawler/Program.cs 수정 DI 4줄
wwwroot/index.html 수정 nav+pane 2줄
wwwroot/panes/reports.html + js/reports.js 신규 최소 UI

추정: 백엔드 핵심(메트릭+채움+컨트롤러) ~1.5일, store/DDL ~0.5일, 프론트 ~0.5일, 검증/데모 ~0.5일 → 약 3일.

12. 적산(.QV) 메트릭 추가 (2026-06-13 구현)

각 유량태그의 .QV(적산/totalizer)를 활용. PV-중앙값 근사 대신 정확한 적분 → gap·양자화 무관, "비율의 중앙값" 모호성 해소. 폐합이 닫혀(99.1~99.8%) 적산기 신뢰성도 입증됨.

추가 메트릭(4종):

metric 정의 비고
production_total ΔQV(제품) 생산량 kg = MES 생산리포트 숫자
yield_qv Δproduct/Δfeed 정확 수율
energy_intensity_qv Δsteam/Δproduct 정확 에너지원단위
mass_balance_closure 100·ΣΔout/Δfeed 폐합 % + Extra(feed_qv/out_total/out0~2_qv)

적산 Δ 헬퍼(QvDeltaAsync) — 핵심. 값 급감(<prev1)으로 리셋/wrap 구간 분할 후 구간별 (마지막−처음) 합산:

  • gap 강건(끝점 차이가 정답), 노이즈 과대계상 없음(=DCS 일일적산과 동일 의미), wrap(1e6) 꼭대기 잔량(소량)만 손실.
  • 검증(2026-05-15 C-6111): production 8454.7kg·yield 0.8739·energy 0.7791·closure 99.04%(feed 9675/out 9582). 폐합 17주 99.1~99.8%.

매핑 재사용: production/yield/energy = SteamAdvisor:Columns의 Feed/Product/SteamFlow를 .QV로 치환(7컬럼 무료). 폐합은 신규 appsettings:Report:Closure:{col}(Feed + Outputs[제품,경비물,중비물]) — C-6111만 등록(타컬럼은 P&ID 그래프 유도 후 확장).

신뢰 블록(운전원 검산): 템플릿에 feed_qv(IN)·out_total(OUT)·mass_balance_closure(%)를 한 표에 배치 → "9675 vs 9582 = 99% 닫힘"을 눈으로 검산. 샘플 템플릿에 반영.

11. 다음(P1 훅)

  • dynamics 메트릭(fast_record 전용, FOPDT/stiction) — 같은 인터페이스에 metric 1개 추가.
  • 토큰에 period=MONTHLY|YEARLY 추가 → from/to 계산만 분기.
  • 스케줄 자동생성 BackgroundService(기존 Hc900HistoryService 패턴).
  • 컬럼 매핑을 tag_metadata EU레인지에서 자동도출.

근거: 본 세션 검증 1~3회차, FastController/fast_record(동일 long 포맷), EPPlus 7.4.2 기탑재(PidExtractorService), raw SQL 패턴(FeedforwardAuditService), 패널 data-src 컨벤션(index.html). 관련 메모리: product-pivot-selfservice-reporting, deterministic-verification-gate, plant-knowledge-document-first.