적산(.QV) 메트릭이 비정상 운전 분을 포함해 풀기간/월간 집계가 틀어지던 문제 해결. - 3신호 마스크: (진공 PICA-*.PV>300) OR (제품 FICQ-*18.PV<10) OR (원료 FICQ-*01.PV<10). = cleaning(진공깨짐/제품~0) + drawdown(feed~0, 인벤토리 인출/컬럼간 이송) 제외. - QvDeltaAsync 재작성: 비정상분 제외 후 정상구간 양(+)증분만 합산(cap 5e4). 리셋 3종 자동처리(999999 wrap·cleaning 리셋·운전조건변경 리셋). excluded_min 메타. - appsettings Report:Cleaning(컬럼별 진공태그+임계) + Closure 7컬럼 전체 등록. 검증(풀기간 폐합): 마스크 후 6/7 컬럼 99~100% (C-6211 216.8→100.0, C-9111 133.2→99.9, C-9211 97.8→99.9, C-10211 121.9→99.1). C-10111 91.4%는 C-9111↔C-10111 연결라인 시운전 중 홀드업 축적(정상 비정상상태, KPI가 표시). 라이브: C-9111 일일(04-10) 99.46%, C-6111 일일(05-15) 99.04% 확인. 현장사실: C-9111↔C-10111 잇는 라인 추가·시운전 중 — C-9111 drawdown 시 인출물이 C-10111 feed로 유입(FICQ-10101 0→~1090, 진공 750→45). 개별 수지 불성립 주원인. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
30 KiB
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.2 —
Hc900Crawler.csproj에 이미 있음.PidExtractorService.cs:620에서new OfficeOpenXml.ExcelPackage()바로 사용 = 라이선스 컨텍스트 이미 설정됨(동일 패턴 재사용). - raw SQL —
FeedforwardAuditService패턴(_ctx.Database.GetDbConnection()+@param) 그대로. - 패널 로딩 —
index.htmldata-src="/panes/X.html"+ navdata-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_runraw 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 }} |
평균 잔차(PV−SP) |
{{ 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) · 데모 스크립트
- C-6111 효율 토큰 1개 엑셀 → 2026-05-15 선택 → 생성 → 셀에 ~0.79 값, 주석에
src=history_table 60000ms n=~1400. - 같은 토큰을
control_residual; field=sd로 → TICA-6111A 잔차 sd 채워짐. - 데이터 없는 날(예: 2026-01-01) → 셀
N/A(0 날조 아님),X-Report-Status: partial/error. - fastRecord 세션 1개 띄워(예: 1초·5분) →
source=fast_record&sessionId=N→ 동일 효율 토큰이 sampling 60000→1000ms로 주석 바뀌어 채워짐(해상도 가변 실증). - 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) — 핵심. 값 급감(<prev−1)으로 리셋/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% 닫힘"을 눈으로 검산. 샘플 템플릿에 반영.
13. Cleaning/Drawdown 데이터품질 게이트 (2026-06-14 검증)
적산(.QV) 메트릭의 결정적 정합성 조건: 비정상 운전 분(分)을 제외해야 생산량·수율·폐합이 맞는다. 데이터로 검증한 3신호 마스크.
비정상(제외) 분 = 아래 중 하나:
| 신호 | 조건 | 의미 |
|---|---|---|
| 진공 | PICA-*.PV > 300 |
vacuum 깨짐 = cleaning/비운전 (정상 50~113, 비정상 750+) |
| 제품~0 | 제품 FICQ-*18.PV < 10 |
제품 안 만듦 = cleaning |
| drawdown | 원료 FICQ-*01.PV < 10 |
feed≈0인데 운전 = 인벤토리 인출/링크 이송 |
컬럼별 진공태그(P6는 무접미사, 그 외 A 접미사):
C-6111→PICA-6111, C-6211→PICA-6211, C-8111→PICA-8111A, C-9111→PICA-9111A, C-9211→PICA-9211A, C-10111→PICA-10111A, C-10211→PICA-10211A.
QV Δ 알고리즘 (리셋 3종 + 마스크 통합):
prev=None
for 각 분(시간순):
if 비정상(위 3신호): prev=None; continue # 체인 끊기(cleaning/drawdown 리셋 흡수)
if prev≠None and 0 ≤ v−prev < 50000: total += v−prev # 양증분만
prev = v
이 한 규칙이 처리: ①999999 자동 wrap(하강 스킵, 손실≈0) ②cleaning 리셋(제외분) ③운전조건변경 리셋(리셋전 누적 보존, 하강 스킵).
검증 (풀기간 2026-02-09~06-05 폐합):
| 컬럼 | 마스크前 | 3신호 마스크後 |
|---|---|---|
| C-6111 | 99.3 | 99.6 |
| C-6211 | 216.8 | 100.0 |
| C-8111 | 99.0 | 99.8 |
| C-9111 | 133.2 | 99.9 |
| C-9211 | 97.8 | 99.9 |
| C-10211 | 121.9 | 99.1 |
| C-10111 | 53~105 | 91.4 (※) |
→ 6/7 컬럼 99~100% 폐합. ※C-10111 91.4%는 버그 아님 — C-9111→C-10111 연결라인 테스트 중 C-10111이 받은 물질을 홀드업 축적(제품 미생산)한 정상 비정상상태. KPI가 "축적 중"을 정확히 표시.
중요 현장 사실 — C-9111↔C-10111 연결라인: 품질이슈 대응으로 두 컬럼을 잇는 라인 추가·시운전 중. C-9111 drawdown(자체 feed≈0, 제품↑)일 때 그 인출물이 C-10111 feed로 유입(C-10111 FICQ-10101.PV 0→1090, 진공 750→45 운전전환). 개별 컬럼 풀기간 수지가 안 닫히는 주된 원인이며, drawdown 마스크로 정상일만 남겨 해결. C-9111 계량 자체는 정상(정상일 99.299.8%).
구현 메모: Report:Cleaning config(컬럼별 진공태그 + VacMax=300, ProductMin=10, FeedMin=10). QvDeltaAsync가 분 단위 마스크 적용. PV 메트릭(efficiency/yield/residual)도 동일 마스크. 결과 메타에 제외분 수 표기.
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.