diff --git a/docs/templates/리포트템플릿-예시-C6111-daily.xlsx b/docs/templates/리포트템플릿-예시-C6111-daily.xlsx
new file mode 100644
index 0000000..6c9858e
Binary files /dev/null and b/docs/templates/리포트템플릿-예시-C6111-daily.xlsx differ
diff --git a/docs/논의-AI운전원-제어-아이디어.md b/docs/논의-AI운전원-제어-아이디어.md
index 9ac1def..3860fe3 100644
--- a/docs/논의-AI운전원-제어-아이디어.md
+++ b/docs/논의-AI운전원-제어-아이디어.md
@@ -69,8 +69,121 @@
### 첫 단추 (추천)
**①(마진)부터.** 필요한 것: 민감단 온도 태그 + 환류/스팀 관련 태그. → 4개월치로 "버려지는 마진"이 실제 존재하는지 데이터로 확인.
+## 데이터 검증 1회차 (2026-06-12) — ① 마진 가설 부분 반증
+
+대상 확정: **P6-1 / C-6111 진공증류탑 (C3)**.
+⚠️ **데이터 창 (확정):** 현장 실데이터 = **2026-02-05 ~ 2026-06-05** (field_hist `dtat` KST 기준; UTC `recorded_at` 02-05 03:53 ~ **06-05 02:22**). field_hist 17개 cont 전수조회로 확인(주 테이블 336,519행). 그 이후 ~06-12는 **C3 시뮬레이션 소스**이므로 제외. **분석 컷오프 = `recorded_at < '2026-06-05 02:22 UTC'`** (사용자 (A) 선택). (메모리의 06-05는 정확; 이전 "5월말일"은 며칠 짧았음.)
+
+**태그 매핑 확정 (정정: 민감단 = TI-6111C, NOT D):**
+- **민감단(품질·분리도 proxy) = `TI-6111C.PV` = T_C** (중상부). DB·문서·현 프로그램 모두 이 기준. 실창 p05~p95 = **83.9~86.0, sd 1.45** → **실제로 움직임**. (`작업플랜-민감단온도-전환복귀제어.md` L26)
+- 참고: `TI-6111D.PV`(상부)는 sd 0.19로 못박혀 있으나 **민감단 아님** — 품질 프록시로 쓰지 말 것.
+- 에너지 레버 = 스팀 `FIQ-6115.PV`(T_C 주조작변수), 리플럭스 `FICQ-6113.PV`
+- 처리량 = feed `FICQ-6101.PV` (p05~p95 = 393~918, **2.3배 변동**)
+- 하부온도(스팀 자동제어) = `TICA-6111A` (.PV/.SP/.OP)
+
+**핵심 발견 (분 단위 피벗, feed·민감단 T_C 동시 고정 counterfactual; n=13,137):**
+| 검증 | 전체 변동(실창) | feed 600~700 & T_C(TI-6111C) 84.6~85.0 고정 후 |
+|---|---|---|
+| 스팀 FIQ-6115 | sd 146 (p05~p95 277~664) | **sd 12.5**, p10~p90 478~500 (±2.3%) |
+| 리플럭스 FICQ-6113 | sd 442 (p05~p95 905~2042) | **sd 37**, p10~p90 1470~1497 (±1%) |
+| feed FICQ-6101 | sd 197 (p05~p95 392~913) | (고정변수) |
+
+→ **스팀·리플럭스 2.3~2.4배 변동은 거의 전부 feed/T_C로 설명됨.** 진짜 민감단 T_C로 고정하니 스팀 변동이 더 작아짐(sd 12.5) — 스팀이 곧 T_C를 잡는 MV이므로 당연. 정상상태 "같은 품질·더 적은 스팀" 마진 = p50−p10 ≈ **11 kg/h (~2.3%)**.
+
+**결론:** ①(정상상태 에너지 마진)은 헤드라인 불가(~2.3%). 변동의 원천은 **feed 변화와 그 과도구간(transition)**, 그리고 **T_C 자체가 sd 1.45로 움직이는 구간**. 가치는 정상상태가 아니라 *전환·T_C 이탈*에 있음.
+
+**🔑 핵심 발견 — 이 thread는 이미 작업라인:** `docs/작업플랜-민감단온도-전환복귀제어.md` 가 정확히 이 방향을 설계해 둠 (T_C 대역유지 → 이탈 시 전환류 도피 → bumpless 복귀). C# 인프라 일부 가동 중: `FeedforwardEngine`/`ColumnMode{Normal,Recovering,Returning}` 상태기계, `SteamAdvisor`(steam=f(feed,product,T_C) 맵 = T_C 목표역산·디커플링), `FeedRampAdvisor`. → 브레인스토밍이 기존 작업라인으로 수렴. 다음 단계는 **T_C 이탈 구간의 정량화**(빈도·크기·원인=feed/grade/주야)로 그 작업라인의 가치를 사이징하는 것.
+
+**미해결 단서:** `TICA-6111A.SP` 이봉성 (p05=84.0, p50=88.4, p95=88.4) → 두 모드(저온~84/고온88.4) = **grade/feed 모드 전환** 추정. T_C 이탈과 묶이는지 확인 필요.
+
+## 데이터 검증 2회차 (2026-06-12) — ★전환 후 재정착에 마진이 있다
+
+T_C 이탈 정량화 결과 (2,861시간, 실데이터):
+- **시간 내(분 단위 제어): 98.9%가 폭 ≤0.5°C** (시간당 평균폭 0.26, sd 0.06). 이탈시간(폭>1°C) = 0.5%.
+- **시간 간(의도 이동): 99.3%가 ≤0.25°C/h**, p99=0.166. 최대 3.63°C(드문 큰 전환).
+- 주야 차이 거의 없음 (day/swing/night avg range 0.25/0.28/0.25, 이탈 0.42/0.74/0.42%) → ③ 교대조 분산 닫힘.
+→ T_C 총 2.3°C 폭은 **싸운 이탈이 아니라 의도적 조건변경의 누적**. 운전원은 단기·전환 모두 매우 잘함.
+
+**핵심 컨텍스트(사용자): 지난 몇 달간 운전 조건을 많이 변경함.** → 데이터 = rich excitation 자연실험(moat #2 연료). 풀링 금지(레짐별로 봐야).
+
+**주별 캠페인 (median):**
+| 주 | feed | T_C | 스팀 | 제품 | 스팀/제품 |
+|---|---|---|---|---|---|
+| 02-16 | 497 | 84.3 | 344 | 446 | 0.787 |
+| 02-23 | 798 | 85.4 | 560 | 717 | 0.783 |
+| 03-02 | 903 | 86.0 | 652 | 821 | 0.795 |
+| 04-06 | 804 | 85.5 | 608 | 717 | 0.847 |
+| **04-27** | 421 | 84.1 | 304 | 355 | **0.858** |
+| 05-04 | 403 | 84.0 | 295 | 355 | 0.834 |
+| **05-11** | 404 | 84.0 | 281 | 355 | **0.796** |
+| 05-18 | 406 | 84.0 | 282 | 355 | 0.792 |
+
+- 운전점 자체가 크게 변동: feed 400~900(2.25배), T_C 목표 84.0(저율)~86.0(고율) = 캠페인별 의도 설정.
+- **★재정착 마진:** 04-27 vs 05-11 = **거의 동일 운전점**(feed~410, T_C 84.0, 제품 355)인데 스팀/제품 **0.858→0.796 (~8%)**. 조건변경 직후 보수적→수주에 걸쳐 효율점 재탐색(0.858→0.834→0.796→0.792). = **전환 후 재정착 손실**.
+
+**확정된 어필 논리:** 운전원은 *정착하면* 프런티어 근처(정상상태 마진 ~2%, ②③ 닫힘). 그러나 *조건 변경 시* 효율점 재탐색에 수주가 걸려 그동안 ~8% 스팀 과다. 모델은 학습한 `steam=f(feed,T_C)` 바닥값을 **전환 즉시** 적용 → 재정착 손실 회수. **전환이 잦은 공장이라 반복 적용됨.** "제어를 이긴다"가 아니라 "운전원이 결국 찾는 최선을 전환 직후부터" = 방어 가능.
+
+**다음 probe:** 4개월 전 구간에서 조건변경 이벤트를 자동 검출 → 각 전환의 (재정착 소요일 × 효율 페널티) 합산 = 회수가능 스팀 총량(연간 환산). 기존 `SteamAdvisor`(steam=f(feed,product,T_C) 맵)가 바닥값 산출 엔진.
+
+## 데이터 검증 3회차 (2026-06-12) — 재정착 마진도 작음, 진짜 비용은 startup/off-spec
+
+전환 settling 체계적 정량화 (일단위, 5캠페인/4전환; `/tmp/settling_probe.py`):
+| seg | 시작 | 일 | feed | T_C | eff | settle | penalty |
+|---|---|---|---|---|---|---|---|
+| 0 | 02-05 | 20 | 501 | 84.3 | 0.810 | 8d | 8,292kg (startup, 선례無→청구불가) |
+| 1 | 02-25 | 20 | 901 | 86.0 | 0.796 | 0 | 0 |
+| 2 | 03-17 | 35 | 801 | 85.4 | 0.830 | 0 | 0 |
+| 3 | 04-21 | 11 | 650 | 84.8 | 0.842 | 0 | 0 |
+| 4 | 05-02 | 35 | 404 | 84.0 | 0.811 | 3d | 2,044kg (선례有) |
+
+- **운전원은 큰 조건변경 후에도 며칠 내 자기 효율바닥 도달** (seg1·2·3 settling=0).
+- 선례 있는 재정착 회수 = seg4의 2,044kg/3.9mo = **연 6,200kg ≈ 저율 연스팀의 0.25%** → 무시 수준.
+- **결론: 에너지 마진 전부 닫힘** — 정상상태 ~2%, 재정착 ~0.25%.
+
+**남은 진짜 기회 (에너지 아님):**
+1. **캠페인 간 eff 격차(0.796~0.852, ~7%)** — 운전점 교란됨(저율=고정손실↑로 steam/prod 자연↑). 청구하려면 `eff=f(feed,T_C)` 맵(SteamAdvisor) 적합 후 **모델바닥 위 잔차**로 측정. seg2(35일 0.830)가 바닥 위였나가 핵심.
+2. **★startup/grade 전환 = 진짜 큰 과도비용** — seg0 1회 startup = 8,292kg(8일). 스팀보다 **off-spec 제품·time-to-spec**이 금액 큼(미측정). bumpless-복귀 플랜(`작업플랜-민감단온도-전환복귀제어`)이 노리는 지점.
+
+**가치 메트릭 전환 필요:** "운전원보다 적은 스팀"(닫힘) → "**전환·startup의 time-to-spec / off-spec 제품 절감**" + "**24/7 무인 운전원-품질 보장**(labor)" + "**throughput 최대화**(미검증)". 제어 outperform이 아니라 운전원이 잘 못하는 *드문 어려운 순간*과 *지속 보장*.
+
+## 재프레임된 후보 (임팩트 순)
+1. 🔥 **전환 최적화** — 이봉 SP 전환 이벤트 추출 → 전환 소요시간·정착중 품질편차·추가스팀 정량화. 어필: "전환시간/off-spec 절반."
+2. 🔥 **처리량 최대화** — 제약 한계(하부온도/진공/플러딩)까지 feed 추가 여력 있는 시간대 정량화.
+3. **분산/최악시간대(③)** — 교대조·시간대·전환구간 tail 편차로 쪼개기.
+4. ~~① 정상상태 에너지 마진~~ — 부수효과로만.
+
## 다시 논의할 때 정할 것
-- [ ] 어느 축부터 갈지 (추천: ① 마진)
-- [ ] 대상 컬럼/루프 및 민감단 온도 태그명
-- [ ] 환류/스팀 등 마진 관련 태그명
-- [ ] realtime 1초급 데이터가 실제로 쌓이는지 확인 (동특성 식별용)
+- [x] 데이터 컷오프 = `< 2026-06-05 02:22 UTC` (A) · 민감단 = TI-6111C 확정
+- [ ] **다음 probe: T_C(TI-6111C) 이탈 구간 정량화** — 목표대역 밖 체류시간 %·이탈 크기·빈도, 그리고 feed 변화/SP 이봉전환/주야와의 상관. 이게 기존 `작업플랜-민감단온도-전환복귀제어` 작업라인의 가치(=전환·분산 절감)를 사이징함.
+- [ ] TICA-6111A.SP 이봉성의 정체 확인 (grade? feed 소스? 주야?) — T_C 이탈과 묶이는지
+- [ ] (병렬) 처리량 최대화 여력: 제약 한계 대비 feed 깔린 시간대
+- [ ] realtime 1초급 데이터가 실제로 쌓이는지 확인 (동특성 식별용) — 미확인
+
+---
+
+# 🔀 방향 전환 (2026-06-12): 제어 outperform → 편의성·리포트 제품화
+
+> 위 1~4 후보(제어 성능으로 운전원 이기기)는 **데이터가 거의 다 닫음**. 사용자 결정: **"제어를 더 잘한다"가 아니라 "운전원/관리자가 하기 힘든 결정론적 분석·리포트를 자동·셀프서비스로 해주는 편의성"** 에 포커스. ← 이쪽이 더 돈에 가깝고 방어 가능.
+
+## 통찰: 오늘 한 작업 자체가 제품의 프로토타입
+오늘 세션에서 내가 한 일 = 기간 선정 → 드롭아웃/양자화 필터 → 효율·잔차·편차·재정착을 **결정론 SQL로 계산**. 이걸 **운전원이 버튼으로** 하게 만드는 것이 제품. DCS/MES가 못 하고 전산팀을 안 거침.
+
+## 제품 = 3개 아이디어가 한 파이프라인으로 체인
+**캡처(fastRecord) → 결정론 계산(메트릭) → 시각화(온도프로파일 방식) → 운전원 엑셀폼 export** — 전 과정이 전산팀/MES 우회, 운전원 소유.
+
+1. **시각적 셀프서비스 분석** — 온도프로파일처럼 "기간 선택 → 효율/제어잔차/수율/편차를 자동 계산·시각화 → 특정시각 문제 즉시 확인". 작업외 시간↓.
+2. **운전원-정의 엑셀폼 리포트 (= 돈)** — 현 파이프라인(Experion→DB→MES→폼)은 *컬럼 하나 바꾸는 데도* 전산팀 협의·의도설명·장기 리드타임·재작업. 대안: **운전원이 만든 엑셀 폼을 계약(contract)으로 삼아**, DB에서 Daily/Monthly/Yearly(컬럼별 에너지효율·제어잔차·수율…)를 계산해 **그 폼에 즉시 꽂아줌**. 포맷 즉시 변경 = 상품성.
+3. **★fastRecord = 빠진 주춧돌** — UI fastRecord(태그·간격 s/min·기간 선택 레코딩)가 결정론 로직과 결합하면 **60초 history로 불가능한 동특성·루프진단**(FOPDT τ/deadtime, 오버슈트, 밸브 stiction/헌팅)이 열림. 외과적 step/bump test 셀프서비스 = 제어벤더가 비싸게 파는 카테고리. **보너스: 고해상 전환캡처 = SteamAdvisor/모델 학습 데이터** → 편의성으로 팔며 AI운전원 연료 축적(같은 기능 2번 일함).
+
+## 결정적 기술 사실 (MVP 성립 근거)
+- **`fast_record`와 `history_table`가 동일 long 포맷**(`tagname, recorded_at, value`) → 메트릭 SQL이 **소스 테이블만 교체**해 양쪽에서 그대로 작동 = "해상도 가변"이 공짜.
+- **SheetJS(`xlsx.full.min.js`) 이미 클라이언트 탑재** → 엑셀폼 읽기/쓰기 인프라 존재.
+- `FastController`/`FastSession`/`fast_record` 가동 중(start/stop/sessions, 백그라운드 sampling 루프).
+- 의미층(moat) 이미 존재: `tag_metadata` + KB 문서(단일 진실원) + loop→필드계기 매핑 + `pid_equipment`. ← "컬럼 하나 즉시 변경"을 가능케 하는 핵심(전산팀이 매번 수작업 재구성하는 부분).
+
+## 설계 철칙
+- **해상도-인지**: 모든 메트릭 출력에 `(source, sampling_ms, n, cleaned_fraction)` 메타 동봉 — 1초 리포트 vs 60초 리포트가 사과-오렌지 안 되게.
+- **결정론 검증 게이트**(메모리): 셀 무음 공란/날조 금지, 모든 셀이 메트릭+SQL로 추적, 실패는 명시적 에러.
+- **메트릭/매핑은 레지스트리·config** (하드코딩 아님) → "컬럼 변경 = 폼/레지스트리 편집, 전산팀 0".
+
+→ MVP 아키텍처 상세: [`작업플랜-셀프서비스-분석리포트-MVP.md`](작업플랜-셀프서비스-분석리포트-MVP.md)
diff --git a/docs/작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md b/docs/작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md
new file mode 100644
index 0000000..5daf32b
--- /dev/null
+++ b/docs/작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md
@@ -0,0 +1,524 @@
+# P0 MVP 상세설계 — 셀프서비스 결정론 리포트 (코딩 레벨)
+
+> 2026-06-12. 상위: [`작업플랜-셀프서비스-분석리포트-MVP.md`](작업플랜-셀프서비스-분석리포트-MVP.md) → [`논의-AI운전원-제어-아이디어.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.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`):
+
+```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`(런타임 로드, 편집 시 재배포 불필요):
+
+```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`
+```csharp
+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일 때
+}
+
+/// 결정론 메트릭 1건 결과 + 해상도 메타(필수).
+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 Extra { get; set; } = new(); // sd/p95/이탈% 등
+}
+```
+
+### 3.2 인터페이스 — `src/Core/Application/Interfaces/IReportMetricService.cs`
+```csharp
+using Hc900Crawler.Core.Application.DTOs;
+namespace Hc900Crawler.Core.Application.Interfaces;
+
+public interface IReportMetricService
+{
+ Task 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).
+
+```csharp
+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 _logger;
+ private readonly MetricMap _map; // config/report-metric-map.json 로드
+
+ public ReportMetricService(Hc900DbContext ctx, ILogger logger, MetricMap map)
+ { _ctx = ctx; _logger = logger; _map = map; }
+
+ public async Task 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 RatioMetric(
+ MetricResultDto res, string tbl, string numerTag, string denomTag,
+ IReadOnlyDictionary 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 ResidualMetric(
+ MetricResultDto res, string tbl, string pvTag, string spTag,
+ IReadOnlyDictionary 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 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`
+```csharp
+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 roles, out string? unit,
+ out Dictionary 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 }}`. 엔진이 전 시트 스캔→토큰 셀을 결과값으로 치환, 메타는 셀 주석으로.
+
+```csharp
+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*(?.+?)\s*\}\}", RegexOptions.Compiled);
+ private readonly IReportMetricService _metrics;
+ public ReportFillService(IReportMetricService metrics) => _metrics = metrics;
+
+ public async Task<(byte[] xlsx, List