feat(report): P2-A 토큰 파서 일반화 — period=DAILY|MONTHLY|YEARLY 윈도 분기
- MetricRequest/Result에 Period 추가, ReportMetricService.PeriodWindowUtc로 선택일이 속한 일/월/연 [from,to)를 KST→UTC 변환(DAILY는 기존 동작 동일). - 오타 period는 daily 묵인 대신 명시적 error(결정론 게이트). - ReportFillService 토큰 period= 파싱 + 셀주석/cells_json 박제. - summary 엔드포인트 period 파라미터, 웹 바로보기 기간 드롭다운 + 치트시트. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ 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 Period { get; set; } = "DAILY"; // DAILY | MONTHLY | YEARLY — 윈도 = PeriodDateKst가 속한 일/월/연
|
||||
public string SourceTable { get; set; } = "history_table"; // | fast_record
|
||||
public int? SessionId { get; set; } // fast_record일 때
|
||||
}
|
||||
@@ -23,6 +24,7 @@ public sealed class MetricResultDto
|
||||
|
||||
// ── 해상도-인지 메타 ──
|
||||
public string Source { get; set; } = ""; // history_table | fast_record
|
||||
public string Period { get; set; } = "DAILY"; // DAILY | MONTHLY | YEARLY (집계 윈도)
|
||||
public int SamplingMs { get; set; } // 60000(history) | fast_session.sampling_ms
|
||||
public int N { get; set; } // 클린 후 표본수
|
||||
public double CleanedFraction { get; set; } // 제거된 비율(0~1)
|
||||
|
||||
@@ -125,17 +125,17 @@ GROUP BY b ORDER BY b";
|
||||
/// <summary>웹에서 바로 보기 — 한 컬럼·날짜의 전 메트릭을 한 번에.</summary>
|
||||
[HttpGet("summary")]
|
||||
public async Task<IActionResult> Summary(string column = "C-6111", DateTime? date = null,
|
||||
string source = "history_table", int? sessionId = null, CancellationToken ct = default)
|
||||
string source = "history_table", string period = "DAILY", int? sessionId = null, CancellationToken ct = default)
|
||||
{
|
||||
var d = (date ?? DateTime.UtcNow.AddHours(9).AddDays(-1)).Date; // 기본 = 어제(KST)
|
||||
var results = new List<MetricResultDto>();
|
||||
foreach (var m in SUMMARY_METRICS)
|
||||
results.Add(await _metrics.ComputeAsync(new MetricRequestDto
|
||||
{
|
||||
Column = column, Metric = m, PeriodDateKst = d,
|
||||
Column = column, Metric = m, PeriodDateKst = d, Period = period,
|
||||
SourceTable = source, SessionId = sessionId
|
||||
}, ct));
|
||||
return Ok(new { Column = column, Date = d.ToString("yyyy-MM-dd"), Source = source, Metrics = results });
|
||||
return Ok(new { Column = column, Date = d.ToString("yyyy-MM-dd"), Source = source, Period = period, Metrics = results });
|
||||
}
|
||||
|
||||
/// <summary>엑셀 템플릿 등록.</summary>
|
||||
|
||||
@@ -25,7 +25,7 @@ paneInit['reports'] = function () {
|
||||
if (go) go.onclick = async () => {
|
||||
out.innerHTML = '<span class="mono">⏳ 조회 중...</span>';
|
||||
try {
|
||||
let url = `/api/report/summary?column=${encodeURIComponent(col.value)}&date=${date.value}&source=${$('rvSource').value}`;
|
||||
let url = `/api/report/summary?column=${encodeURIComponent(col.value)}&date=${date.value}&source=${$('rvSource').value}&period=${$('rvPeriod').value}`;
|
||||
if ($('rvSource').value === 'fast_record' && $('rvSess').value) url += `&sessionId=${$('rvSess').value}`;
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error('조회 실패 ' + r.status);
|
||||
@@ -73,7 +73,8 @@ function rpVal(m, kind) { return m.Status === 'ok' && m.Value != null ? rpFmt(m.
|
||||
function renderSummary(d) {
|
||||
const by = {}; (d.Metrics || []).forEach(m => by[m.Metric] = m);
|
||||
const cl = by['mass_balance_closure'];
|
||||
let html = `<div class="mono" style="margin-bottom:10px;color:var(--t2)">${d.Column} · ${d.Date} · ${d.Source}</div>`;
|
||||
const pTxt = { DAILY: '일', MONTHLY: '월', YEARLY: '연' }[d.Period] || d.Period || '일';
|
||||
let html = `<div class="mono" style="margin-bottom:10px;color:var(--t2)">${d.Column} · ${d.Date} · ${pTxt} · ${d.Source}</div>`;
|
||||
|
||||
// 물질수지 신뢰블록
|
||||
if (cl) {
|
||||
|
||||
@@ -20,6 +20,13 @@
|
||||
<div style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap">
|
||||
<label>컬럼<br><select id="rvCol" style="margin-top:4px"></select></label>
|
||||
<label>날짜(KST)<br><input type="date" id="rvDate" style="margin-top:4px"></label>
|
||||
<label>기간<br>
|
||||
<select id="rvPeriod" style="margin-top:4px">
|
||||
<option value="DAILY">일</option>
|
||||
<option value="MONTHLY">월</option>
|
||||
<option value="YEARLY">연</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>소스<br>
|
||||
<select id="rvSource" style="margin-top:4px">
|
||||
<option value="history_table">history (60초)</option>
|
||||
@@ -67,6 +74,10 @@
|
||||
■ PV 기반 (근사)
|
||||
{{ metric=control_residual; column=C-6111 }} → 평균 잔차(PV-SP)
|
||||
{{ metric=control_residual; column=C-6111; field=sd }} → 잔차 표준편차
|
||||
|
||||
■ 집계 기간 (period, 미지정=DAILY) — 선택 날짜가 속한 일/월/연 전체
|
||||
{{ metric=production_total; column=C-6111; period=MONTHLY }} → 그 달 생산량 적산
|
||||
{{ metric=production_total; column=C-6111; period=YEARLY }} → 그 해 생산량 적산
|
||||
</pre>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
@@ -40,6 +40,7 @@ public sealed class ReportFillService
|
||||
Metric = kv.GetValueOrDefault("metric", ""),
|
||||
Column = kv.GetValueOrDefault("column", "C-6111"),
|
||||
PeriodDateKst = periodKst,
|
||||
Period = kv.GetValueOrDefault("period", "DAILY"), // 셀별 일/월/연 윈도(미지정=일)
|
||||
SourceTable = sourceTable,
|
||||
SessionId = sessionId
|
||||
};
|
||||
@@ -50,10 +51,10 @@ public sealed class ReportFillService
|
||||
else { cell.Value = m.Status == "no_data" ? "N/A" : "ERR"; anyErr = true; }
|
||||
|
||||
if (cell.Comment == null)
|
||||
cell.AddComment($"{m.Metric} | src={m.Source} {m.SamplingMs}ms | n={m.N} | keep={1 - m.CleanedFraction:P0} | {m.Unit}"
|
||||
cell.AddComment($"{m.Metric} | {m.Period} | src={m.Source} {m.SamplingMs}ms | n={m.N} | keep={1 - m.CleanedFraction:P0} | {m.Unit}"
|
||||
+ (m.Error != null ? $" | {m.Error}" : ""), "report");
|
||||
|
||||
cells.Add(new { sheet = ws.Name, r, c, m.Metric, m.Column,
|
||||
cells.Add(new { sheet = ws.Name, r, c, m.Metric, m.Column, m.Period,
|
||||
field = kv.GetValueOrDefault("field", ""), value = v, m.Status,
|
||||
m.Source, m.SamplingMs, m.N, m.Unit });
|
||||
}
|
||||
|
||||
@@ -30,21 +30,41 @@ public sealed class ReportMetricService : IReportMetricService
|
||||
private static readonly HashSet<string> SERIES_SOURCES =
|
||||
new() { "history_table", "history_1s", "history_1min_src" };
|
||||
|
||||
private static readonly HashSet<string> PERIODS =
|
||||
new(StringComparer.OrdinalIgnoreCase) { "DAILY", "MONTHLY", "YEARLY" };
|
||||
|
||||
/// <summary>KST 날짜가 속한 일/월/연 경계 [from, to) 를 UTC로. recorded_at(UTC) 비교용.</summary>
|
||||
private static (DateTime fromUtc, DateTime toUtc) PeriodWindowUtc(DateTime dateKst, string period)
|
||||
{
|
||||
var d = dateKst.Date;
|
||||
var (fromKst, toKst) = period switch
|
||||
{
|
||||
"MONTHLY" => (new DateTime(d.Year, d.Month, 1), new DateTime(d.Year, d.Month, 1).AddMonths(1)),
|
||||
"YEARLY" => (new DateTime(d.Year, 1, 1), new DateTime(d.Year, 1, 1).AddYears(1)),
|
||||
_ => (d, d.AddDays(1)),
|
||||
};
|
||||
return (DateTime.SpecifyKind(fromKst, DateTimeKind.Unspecified).AddHours(-9),
|
||||
DateTime.SpecifyKind(toKst, DateTimeKind.Unspecified).AddHours(-9));
|
||||
}
|
||||
|
||||
public async Task<MetricResultDto> ComputeAsync(MetricRequestDto req, CancellationToken ct = default)
|
||||
{
|
||||
bool isFast = req.SourceTable == "fast_record";
|
||||
// history_table(60s) | history_1s(1s 버퍼) | history_1min_src(연속집계) | fast_record. 미지정/미허용→history_table.
|
||||
string tbl = isFast ? "fast_record"
|
||||
: SERIES_SOURCES.Contains(req.SourceTable) ? req.SourceTable : "history_table";
|
||||
string period = string.IsNullOrWhiteSpace(req.Period) ? "DAILY" : req.Period.Trim().ToUpperInvariant();
|
||||
var res = new MetricResultDto
|
||||
{
|
||||
Metric = req.Metric, Column = req.Column,
|
||||
Metric = req.Metric, Column = req.Column, Period = period,
|
||||
Source = tbl, SamplingMs = isFast ? 0 : (tbl == "history_1s" ? 1000 : 60000)
|
||||
};
|
||||
// 결정론 게이트: 오타 period를 daily로 묵인하면 틀린 값 → 명시적 에러.
|
||||
if (!PERIODS.Contains(period))
|
||||
{ res.Status = "error"; res.Error = $"미지원 period: {req.Period} (DAILY|MONTHLY|YEARLY)"; res.Value = null; return res; }
|
||||
|
||||
// KST 날짜 [00:00, +1d) → UTC (recorded_at은 UTC)
|
||||
var fromUtc = DateTime.SpecifyKind(req.PeriodDateKst.Date, DateTimeKind.Unspecified).AddHours(-9);
|
||||
var toUtc = fromUtc.AddDays(1);
|
||||
// PeriodDateKst가 속한 일/월/연 [from, to) KST → UTC (recorded_at은 UTC). fast_record는 session 기준이라 윈도 무관.
|
||||
var (fromUtc, toUtc) = PeriodWindowUtc(req.PeriodDateKst, period);
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user