feat(report): P0 셀프서비스 결정론 리포트 — 메트릭 3종·엑셀 토큰 채움·fastRecord 소스
운전원이 올린 엑셀 템플릿의 {{ metric=...; column=... }} 토큰을, 선택한 날짜의
결정론 계산값으로 채워 다운로드하는 셀프서비스 리포트 레이어(P0).
- 메트릭 3종: energy_efficiency·yield·control_residual (분 버킷 피벗, 클린필터,
KST→UTC 경계). history_table(60s)·fast_record(s/min) 동일 long 포맷이라 source만 교체.
- 컬럼→태그 매핑은 기존 appsettings SteamAdvisor:Columns 재사용(7컬럼 무료).
- EPPlus 토큰 치환 + 셀 주석에 해상도 메타(source/sampling/n/keep) 부착.
- report_template/report_run 테이블 + cells_json 감사 박제.
- 리포트 탭(웹 UI) + 샘플 템플릿.
검증: 빌드 0에러. E2E 라운드트립 실동작(2026-05-15 C-6111 효율 0.778·수율 0.872·
잔차 mean 0.746 — 오프라인 검증값과 일치). 결정론 검증 게이트 준수(표본0=N/A, 0 날조 금지).
⚠️ EPPlus는 NonCommercial 컨텍스트 — 상용 출시 전 라이선스 정리 필요.
설계: docs/작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
BIN
docs/templates/리포트템플릿-예시-C6111-daily.xlsx
vendored
Normal file
BIN
docs/templates/리포트템플릿-예시-C6111-daily.xlsx
vendored
Normal file
Binary file not shown.
@@ -69,8 +69,121 @@
|
|||||||
### 첫 단추 (추천)
|
### 첫 단추 (추천)
|
||||||
**①(마진)부터.** 필요한 것: 민감단 온도 태그 + 환류/스팀 관련 태그. → 4개월치로 "버려지는 마진"이 실제 존재하는지 데이터로 확인.
|
**①(마진)부터.** 필요한 것: 민감단 온도 태그 + 환류/스팀 관련 태그. → 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. ~~① 정상상태 에너지 마진~~ — 부수효과로만.
|
||||||
|
|
||||||
## 다시 논의할 때 정할 것
|
## 다시 논의할 때 정할 것
|
||||||
- [ ] 어느 축부터 갈지 (추천: ① 마진)
|
- [x] 데이터 컷오프 = `< 2026-06-05 02:22 UTC` (A) · 민감단 = TI-6111C 확정
|
||||||
- [ ] 대상 컬럼/루프 및 민감단 온도 태그명
|
- [ ] **다음 probe: T_C(TI-6111C) 이탈 구간 정량화** — 목표대역 밖 체류시간 %·이탈 크기·빈도, 그리고 feed 변화/SP 이봉전환/주야와의 상관. 이게 기존 `작업플랜-민감단온도-전환복귀제어` 작업라인의 가치(=전환·분산 절감)를 사이징함.
|
||||||
- [ ] 환류/스팀 등 마진 관련 태그명
|
- [ ] TICA-6111A.SP 이봉성의 정체 확인 (grade? feed 소스? 주야?) — T_C 이탈과 묶이는지
|
||||||
- [ ] realtime 1초급 데이터가 실제로 쌓이는지 확인 (동특성 식별용)
|
- [ ] (병렬) 처리량 최대화 여력: 제약 한계 대비 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)
|
||||||
|
|||||||
524
docs/작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md
Normal file
524
docs/작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md
Normal file
@@ -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일 때
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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`
|
||||||
|
```csharp
|
||||||
|
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).
|
||||||
|
|
||||||
|
```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<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`
|
||||||
|
```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<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 }}`. 엔진이 전 시트 스캔→토큰 셀을 결과값으로 치환, 메타는 셀 주석으로.
|
||||||
|
|
||||||
|
```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*(?<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`
|
||||||
|
```csharp
|
||||||
|
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` (추가 라인)
|
||||||
|
```csharp
|
||||||
|
// ── 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 등록(기존 컨벤션):
|
||||||
|
```html
|
||||||
|
<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`** (요지):
|
||||||
|
```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 다운로드):
|
||||||
|
```js
|
||||||
|
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일**.
|
||||||
|
|
||||||
|
## 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]].*
|
||||||
104
docs/작업플랜-셀프서비스-분석리포트-MVP.md
Normal file
104
docs/작업플랜-셀프서비스-분석리포트-MVP.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# 작업 플랜 — 셀프서비스 결정론 분석·리포트 레이어 (MVP 아키텍처 스케치)
|
||||||
|
|
||||||
|
> 2026-06-12. 출처: [`논의-AI운전원-제어-아이디어.md`](논의-AI운전원-제어-아이디어.md) §방향전환.
|
||||||
|
> 제품 한 줄: **"운전원 소유의, 결정론적·해상도 가변 캡처→분석→리포트 레이어 — DCS/MES가 못 하고 전산팀을 안 거치는."**
|
||||||
|
> 데이터 자체가 상품 → 거기에 *의미층(KB/태그매핑) + 결정론 계산 + 운전원 포맷*을 얹어 판다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 핵심 설계 전제 (이미 성립)
|
||||||
|
| 전제 | 근거 (현 코드) |
|
||||||
|
|---|---|
|
||||||
|
| 캡처 2소스가 **동일 long 포맷** (`tagname, recorded_at, value`) | `history_table`(60s 상시) · `fast_record`(on-demand s/min, `FastController`) |
|
||||||
|
| → 메트릭 SQL은 **소스 테이블만 파라미터**로 양쪽 동작 | "해상도 가변"이 추가비용 0 |
|
||||||
|
| 엑셀 폼 읽기/쓰기 인프라 존재 | `wwwroot/js/xlsx.full.min.js` (SheetJS) 클라이언트 탑재 |
|
||||||
|
| 캡처 UI/세션관리 가동 | `FastController`(start/stop/sessions), `FastSession`(sampling_ms·duration_sec·tag_list·retention) |
|
||||||
|
| 의미층(moat) 존재 | `tag_metadata` + KB 문서(단일진실원) + loop→필드계기 매핑 + `pid_equipment` |
|
||||||
|
| 시각화 패턴 존재 | 온도프로파일/`trend.js`, `fast.html` 패널 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 컴포넌트 (레이어)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ 캡처 레이어 (Source) ────────────────────────────────────┐
|
||||||
|
│ history_table (상시 60s) │ fast_record (on-demand s/min) │
|
||||||
|
│ └──────────── 동일 스키마 ─────────────┘ │
|
||||||
|
└──────────────────────────┬──────────────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌─ 의미층 (Semantic / moat) ──────────────────────────────────┐
|
||||||
|
│ tag_metadata · KB 문서 · loop→필드계기 매핑 · pid_equipment │
|
||||||
|
│ "C-6111 효율" → 구체 태그/공식/단위 resolve │
|
||||||
|
└──────────────────────────┬──────────────────────────────────┘
|
||||||
|
▼
|
||||||
|
┌─ 메트릭 엔진 (결정론 로직 = 오늘 내가 짠 SQL의 승격) ────────────┐
|
||||||
|
│ Metric(source, tags|loop|column, period) → value+meta │
|
||||||
|
│ · 자동 데이터 클리닝(드롭아웃/양자화 필터) │
|
||||||
|
│ · 출력 메타: source, sampling_ms, n, cleaned_fraction │
|
||||||
|
└──────────────────────────┬──────────────────────────────────┘
|
||||||
|
┌────────────┴────────────┐
|
||||||
|
▼ ▼
|
||||||
|
┌─ 시각화 ────────────┐ ┌─ 리포트/폼 바인딩 (= 돈) ──────────────┐
|
||||||
|
│ 기간선택 → 메트릭 │ │ 운전원 엑셀폼(named cells) = 계약 │
|
||||||
|
│ 곡선+이상플래그+숫자 │ │ → 엔진이 값 채움 → Daily/Monthly/Yearly │
|
||||||
|
│ (온도프로파일 일반화) │ │ → 다운로드 (SheetJS/ClosedXML) │
|
||||||
|
└─────────────────────┘ └─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 메트릭 레지스트리 (오늘 작업의 제품화)
|
||||||
|
각 메트릭 = **순수 함수**: `(source_table, 대상[column|loop|tags], period) → {values, meta}`.
|
||||||
|
선언적 정의(레지스트리/config, 하드코딩 금지):
|
||||||
|
|
||||||
|
| metric id | 정의 | 필요 태그(의미층이 resolve) | 최소 sampling | 출처(오늘 검증) |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `energy_efficiency` | steam/product (컬럼별) | 리보일러 스팀 / 제품유량 (C-6111: FIQ-6115/FICQ-6118) | 60s | 검증 2·3회차 |
|
||||||
|
| `yield` | product/feed | FICQ-6118/FICQ-6101 | 60s | 〃 |
|
||||||
|
| `control_residual` | (PV−SP) 통계 (mean/sd/p95/이탈%) | 루프 .PV/.SP | 60s | — |
|
||||||
|
| `excursion_time` | 민감단 목표대역 밖 체류% · 크기 · 빈도 | T_C(TI-6111C) + 대역 | 60s | 검증 2회차 |
|
||||||
|
| `settling` | 조건변경 후 재정착 일수 · 페널티 | feed/T_C/steam/prod | 일 | 검증 3회차 (`/tmp/settling_probe.py`) |
|
||||||
|
| `dynamics` ★fast | FOPDT τ/deadtime, 오버슈트, 정착, **밸브 stiction/헌팅** | 루프 PV/OP/SP | **≤1s (fast_record 필수)** | 미구현(주춧돌) |
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
- 모든 메트릭은 **자동 클리닝** 내장(오늘 손으로 한 `value BETWEEN ...`, 0/스파이크 제거, KST/UTC). 채널별 양자화/RTD레인지 주의(메모리 참조).
|
||||||
|
- 모든 출력에 **sampling 컨텍스트** 동봉 → 리포트 비교 안전성.
|
||||||
|
- **결정론 검증 게이트**: 빈 결과/날조 금지, 실패는 명시적 에러(메모리 [[deterministic-verification-gate]]).
|
||||||
|
|
||||||
|
## 3. 리포트/폼 바인딩 (핵심 상품)
|
||||||
|
**계약 = 운전원이 만든 .xlsx 템플릿.** 셀/네임드레인지에 메트릭 참조를 박는다:
|
||||||
|
|
||||||
|
```
|
||||||
|
예) 셀 B5 주석/네임: {{ energy_efficiency | column=C-6111 | period=DAILY }}
|
||||||
|
셀 C5: {{ control_residual.sd | loop=TICA-6111A | period=DAILY }}
|
||||||
|
범위 A10:G40: {{ table: excursion_events | column=C-6111 | period=DAILY }}
|
||||||
|
```
|
||||||
|
- 엔진이 플레이스홀더 파싱 → 메트릭 실행 → **그 위치에 값/표/미니차트 주입** → 다운로드.
|
||||||
|
- 포맷 변경 = 운전원이 엑셀에서 셀 옮기고 토큰 바꾸면 끝. **전산팀 0, 리드타임 0.**
|
||||||
|
- 구현 선택지: 서버 `ClosedXML`/`EPPlus`(스케줄 배치용) + 클라이언트 `SheetJS`(즉시 미리보기/수동 export).
|
||||||
|
|
||||||
|
## 4. MVP 슬라이스 (가장 작은 판매가능 단위)
|
||||||
|
1. 메트릭 3종(`energy_efficiency`, `yield`, `control_residual`)을 `history_table` 위 레지스트리 함수로 구현(오늘 SQL 승격).
|
||||||
|
2. 운전원 엑셀 템플릿 1개 + 토큰 파서 → 선택 날짜로 채워 다운로드(ClosedXML).
|
||||||
|
3. 같은 메트릭을 **`fast_record` 소스로도** 실행해 "해상도 가변" 실증(동일 코드, source만 교체).
|
||||||
|
4. 시각화는 온도프로파일/`trend.js` 재활용해 메트릭 곡선 + 이상 플래그.
|
||||||
|
→ 데모 시나리오: "운전원이 자기 엑셀폼 올림 → 어제 날짜 선택 → 컬럼별 효율·수율·제어잔차가 그 폼 그대로 채워져 내려옴."
|
||||||
|
|
||||||
|
## 5. 스키마/잡 추가(최소)
|
||||||
|
- `metric_definition`(id, formula_ref, required_tags_rule, min_sampling, unit, version) — 감사·버전.
|
||||||
|
- `report_template`(id, name, owner, xlsx_blob, token_map) · `report_run`(template_id, period, generated_at, source, status, file).
|
||||||
|
- Daily/Monthly/Yearly 자동생성 = 기존 `Hc900HistoryService`류 **BackgroundService** 패턴으로 스케줄.
|
||||||
|
|
||||||
|
## 6. 리스크 / 반드시 못박을 것
|
||||||
|
- **정의 거버넌스**: "효율/수율"의 컬럼별 공식은 합의·서명 필요 → 공식+버전을 리포트에 박아 **감사 가능**하게. 컬럼별 steam/product 매핑은 KB 문서(플랜트별)에서 확장.
|
||||||
|
- **해상도 혼동**: §2 sampling 메타 필수(안 하면 1s vs 60s 통계 오해).
|
||||||
|
- **데이터 품질**: 채널별 양자화/RTD레인지/드롭아웃 자동처리 — 메트릭이 "사람이 하기 힘든 부분"을 대신하는 핵심 가치.
|
||||||
|
- **타임존**: recorded_at=UTC, 운전원 리포트=KST(+9) — 엔진 단일 처리.
|
||||||
|
- **fast_record 보존/스토리지**: on-demand·retention_days로 폭증 방지(이미 필드 존재).
|
||||||
|
|
||||||
|
## 7. 단계 (제안)
|
||||||
|
- **P0 (MVP)**: §4 슬라이스 — 메트릭 3종 + 엑셀폼 1개 + history 소스 + 1컬럼(C-6111). **상세 코딩설계 → [`작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md`](작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md)**
|
||||||
|
- **P1**: fast_record 소스 + `dynamics` 메트릭(루프 stiction/헌팅, step-test 셀프서비스).
|
||||||
|
- **P2**: 토큰 파서 일반화 + 템플릿 업로드 UI + Daily 스케줄 자동생성.
|
||||||
|
- **P3**: 멀티컬럼/멀티플랜트(KB 매핑 확장) + Monthly/Yearly + 모델학습 데이터 적재 연계.
|
||||||
|
|
||||||
|
---
|
||||||
|
*근거: 본 세션 검증 1~3회차(에너지/품질 결정론 통계), `FastController`/`fast_record`, `xlsx.full.min.js`, `tag_metadata`+KB. 관련: [[plant-knowledge-document-first]], [[deterministic-verification-gate]], [[loop-field-instrument-value-mapping]].*
|
||||||
28
scripts/sql/p0_report.sql
Normal file
28
scripts/sql/p0_report.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-- P0 셀프서비스 리포트 — 테이블 2개 (hc900 스키마)
|
||||||
|
-- 적용: psql "host=localhost dbname=iiot_platform user=postgres" -f scripts/sql/p0_report.sql
|
||||||
|
SET search_path TO hc900;
|
||||||
|
|
||||||
|
-- 운전원이 올린 엑셀 템플릿 + 메타
|
||||||
|
CREATE TABLE IF NOT EXISTS hc900.report_template (
|
||||||
|
id serial PRIMARY KEY,
|
||||||
|
name text NOT NULL,
|
||||||
|
owner text,
|
||||||
|
xlsx_blob bytea NOT NULL,
|
||||||
|
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) ON DELETE CASCADE,
|
||||||
|
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, -- 채운 셀=메트릭+파라미터+값+sampling메타 박제
|
||||||
|
out_blob bytea -- 채워진 .xlsx (다운로드 캐시)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_report_run_template ON hc900.report_run(template_id, period_date);
|
||||||
30
src/Core/Application/DTOs/ReportDtos.cs
Normal file
30
src/Core/Application/DTOs/ReportDtos.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
namespace Hc900Crawler.Core.Application.DTOs;
|
||||||
|
|
||||||
|
/// <summary>단일 결정론 메트릭 요청.</summary>
|
||||||
|
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
|
||||||
|
public int? SessionId { get; set; } // fast_record일 때
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>결정론 메트릭 1건 결과 + 해상도 메타(필수, 사과-오렌지 방지).
|
||||||
|
/// 프로퍼티로 선언해야 System.Text.Json이 직렬화함(필드는 기본 미직렬화).</summary>
|
||||||
|
public sealed class MetricResultDto
|
||||||
|
{
|
||||||
|
public string Metric { get; set; } = "";
|
||||||
|
public string Column { get; set; } = "";
|
||||||
|
public double? Value { get; set; } // 대표값(없으면 null = 명시적 실패, 0 날조 금지)
|
||||||
|
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; } = ""; // history_table | fast_record
|
||||||
|
public int SamplingMs { get; set; } // 60000(history) | fast_session.sampling_ms
|
||||||
|
public int N { get; set; } // 클린 후 표본수
|
||||||
|
public double CleanedFraction { get; set; } // 제거된 비율(0~1)
|
||||||
|
public Dictionary<string, double?> Extra { get; set; } = new(); // sd/abs_p95/out_pct_0_5 등
|
||||||
|
}
|
||||||
9
src/Core/Application/Interfaces/IReportMetricService.cs
Normal file
9
src/Core/Application/Interfaces/IReportMetricService.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
using Hc900Crawler.Core.Application.DTOs;
|
||||||
|
|
||||||
|
namespace Hc900Crawler.Core.Application.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>기간/소스/대상을 받아 결정론적으로 메트릭 1건을 계산한다.</summary>
|
||||||
|
public interface IReportMetricService
|
||||||
|
{
|
||||||
|
Task<MetricResultDto> ComputeAsync(MetricRequestDto req, CancellationToken ct = default);
|
||||||
|
}
|
||||||
11
src/Core/Application/Interfaces/IReportTemplateStore.cs
Normal file
11
src/Core/Application/Interfaces/IReportTemplateStore.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Hc900Crawler.Core.Application.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>report_template / report_run CRUD (raw SQL).</summary>
|
||||||
|
public interface IReportTemplateStore
|
||||||
|
{
|
||||||
|
Task<int> CreateAsync(string name, string? owner, byte[] xlsx, CancellationToken ct = default);
|
||||||
|
Task<byte[]?> GetBlobAsync(int templateId, CancellationToken ct = default);
|
||||||
|
Task RecordRunAsync(int templateId, string periodKind, DateTime periodDate,
|
||||||
|
string sourceTable, string status, object cells, byte[] outBlob,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
51
src/Hc900Crawler/Controllers/ReportController.cs
Normal file
51
src/Hc900Crawler/Controllers/ReportController.cs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
public ReportController(IReportMetricService metrics, ReportFillService fill, IReportTemplateStore store)
|
||||||
|
{ _metrics = metrics; _fill = fill; _store = store; }
|
||||||
|
|
||||||
|
/// <summary>단건 메트릭(미리보기/디버그).</summary>
|
||||||
|
[HttpPost("metric")]
|
||||||
|
public async Task<IActionResult> Metric([FromBody] MetricRequestDto req, CancellationToken ct)
|
||||||
|
=> Ok(await _metrics.ComputeAsync(req, ct));
|
||||||
|
|
||||||
|
/// <summary>엑셀 템플릿 등록.</summary>
|
||||||
|
[HttpPost("template")]
|
||||||
|
public async Task<IActionResult> Upload([FromForm] IFormFile file, [FromForm] string name,
|
||||||
|
[FromForm] string? owner, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (file == null || file.Length == 0) return BadRequest(new { Error = "파일 없음" });
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
await file.CopyToAsync(ms, ct);
|
||||||
|
var id = await _store.CreateAsync(name, owner, ms.ToArray(), ct);
|
||||||
|
return Ok(new { Id = id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>★템플릿+날짜 → 채워진 xlsx 다운로드.</summary>
|
||||||
|
[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, ct);
|
||||||
|
if (tpl == null) return NotFound(new { Error = $"템플릿 {templateId} 없음" });
|
||||||
|
|
||||||
|
var (xlsx, cells, status) = await _fill.FillAsync(tpl, date, source, sessionId, ct);
|
||||||
|
await _store.RecordRunAsync(templateId, "DAILY", date, source, status, cells, xlsx, ct);
|
||||||
|
|
||||||
|
Response.Headers["X-Report-Status"] = status;
|
||||||
|
var fname = $"report_{templateId}_{date:yyyyMMdd}.xlsx";
|
||||||
|
return File(xlsx, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fname);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,11 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// EPPlus 라이선스 컨텍스트 (리포트 엑셀 생성용). ⚠️ 상용 출시 전 라이선스 정리 필요(NonCommercial 한정).
|
||||||
|
#pragma warning disable CS0618
|
||||||
|
OfficeOpenXml.ExcelPackage.LicenseContext = OfficeOpenXml.LicenseContext.NonCommercial;
|
||||||
|
#pragma warning restore CS0618
|
||||||
|
|
||||||
// ── MVC / Swagger ─────────────────────────────────────────────────────────────
|
// ── MVC / Swagger ─────────────────────────────────────────────────────────────
|
||||||
var mvcBuilder = builder.Services.AddControllers()
|
var mvcBuilder = builder.Services.AddControllers()
|
||||||
.AddJsonOptions(opt =>
|
.AddJsonOptions(opt =>
|
||||||
@@ -168,6 +173,14 @@ builder.Services.AddCors(opt =>
|
|||||||
|
|
||||||
builder.WebHost.UseUrls("http://0.0.0.0:5000");
|
builder.WebHost.UseUrls("http://0.0.0.0:5000");
|
||||||
|
|
||||||
|
// ── P0 셀프서비스 리포트 ──────────────────────────────────────────────────────
|
||||||
|
builder.Services.AddSingleton<Hc900Crawler.Infrastructure.Reporting.ReportColumnMap>();
|
||||||
|
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>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// ── DB 초기화 ─────────────────────────────────────────────────────────────────
|
// ── DB 초기화 ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -97,6 +97,10 @@
|
|||||||
<span class="ni">13</span>
|
<span class="ni">13</span>
|
||||||
<span class="nl">스팀 Advisory</span>
|
<span class="nl">스팀 Advisory</span>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item" data-tab="reports">
|
||||||
|
<span class="ni">14</span>
|
||||||
|
<span class="nl">리포트</span>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="sb-foot">
|
<div class="sb-foot">
|
||||||
@@ -133,6 +137,7 @@
|
|||||||
|
|
||||||
<section class="pane" id="pane-ff" data-src="/panes/ff.html?v=20260604"></section>
|
<section class="pane" id="pane-ff" data-src="/panes/ff.html?v=20260604"></section>
|
||||||
<section class="pane" id="pane-steam" data-src="/panes/steam.html?v=20260606"></section>
|
<section class="pane" id="pane-steam" data-src="/panes/steam.html?v=20260606"></section>
|
||||||
|
<section class="pane" id="pane-reports" data-src="/panes/reports.html?v=20260612"></section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -232,5 +237,6 @@
|
|||||||
<script src="/js/trend.js?v=20260611"></script>
|
<script src="/js/trend.js?v=20260611"></script>
|
||||||
<script src="/js/ff.js?v=20260604"></script>
|
<script src="/js/ff.js?v=20260604"></script>
|
||||||
<script src="/js/steam.js?v=20260606"></script>
|
<script src="/js/steam.js?v=20260606"></script>
|
||||||
|
<script src="/js/reports.js?v=20260612"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
55
src/Hc900Crawler/wwwroot/js/reports.js
Normal file
55
src/Hc900Crawler/wwwroot/js/reports.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/* P0 셀프서비스 리포트 — 패널 JS.
|
||||||
|
pane HTML은 innerHTML로 주입되므로 <script>는 실행 안 됨 → 전역 로드 + paneInit 등록.
|
||||||
|
paneInit['reports']는 탭 활성화 때마다 호출되므로 바인딩은 멱등하게(onclick 재할당). */
|
||||||
|
paneInit['reports'] = function () {
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const tpl = $('rpTpl'), date = $('rpDate'), src = $('rpSource');
|
||||||
|
const sessWrap = $('rpSessionWrap'), sess = $('rpSession');
|
||||||
|
const gen = $('rpGen'), status = $('rpStatus');
|
||||||
|
if (!gen) return;
|
||||||
|
|
||||||
|
// 기본 날짜 = 어제(KST)
|
||||||
|
if (date && !date.value) {
|
||||||
|
const d = new Date(Date.now() + 9 * 3600e3 - 86400e3);
|
||||||
|
date.value = d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
src.onchange = () => { sessWrap.style.display = src.value === 'fast_record' ? '' : 'none'; };
|
||||||
|
src.onchange();
|
||||||
|
|
||||||
|
gen.onclick = async () => {
|
||||||
|
if (!tpl.files[0]) { status.textContent = '⚠️ 엑셀 템플릿을 선택하세요.'; return; }
|
||||||
|
if (!date.value) { status.textContent = '⚠️ 날짜를 선택하세요.'; return; }
|
||||||
|
gen.disabled = true; status.textContent = '⏳ 생성 중...';
|
||||||
|
try {
|
||||||
|
// ① 템플릿 등록
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', tpl.files[0]);
|
||||||
|
fd.append('name', tpl.files[0].name);
|
||||||
|
const up = await fetch('/api/report/template', { method: 'POST', body: fd });
|
||||||
|
if (!up.ok) throw new Error('템플릿 업로드 실패 ' + up.status);
|
||||||
|
const { Id } = await up.json();
|
||||||
|
|
||||||
|
// ② 생성
|
||||||
|
let url = `/api/report/generate?templateId=${Id}&date=${date.value}&source=${src.value}`;
|
||||||
|
if (src.value === 'fast_record' && sess.value) url += `&sessionId=${sess.value}`;
|
||||||
|
const resp = await fetch(url);
|
||||||
|
if (!resp.ok) throw new Error('생성 실패 ' + resp.status);
|
||||||
|
|
||||||
|
const reportStatus = 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.value}.xlsx`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
status.textContent = `✅ 완료 (templateId=${Id}, 상태=${reportStatus})\n` +
|
||||||
|
(reportStatus === 'partial' ? '※ 일부 셀 N/A/ERR — 셀 주석 확인' :
|
||||||
|
reportStatus === 'error' ? '※ 토큰 없음 또는 전체 실패 — 셀 주석 확인' : '');
|
||||||
|
} catch (e) {
|
||||||
|
status.textContent = '❌ ' + e.message;
|
||||||
|
} finally {
|
||||||
|
gen.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
43
src/Hc900Crawler/wwwroot/panes/reports.html
Normal file
43
src/Hc900Crawler/wwwroot/panes/reports.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<div class="report-pane" style="padding:20px;max-width:680px">
|
||||||
|
<h2 style="margin-top:0">리포트 생성 (P0)</h2>
|
||||||
|
<p style="color:var(--t2);font-size:13px">
|
||||||
|
운전원 엑셀 템플릿의 셀에 <code>{{ metric=energy_efficiency; column=C-6111 }}</code> 형태 토큰을 박아두면,
|
||||||
|
선택한 날짜의 결정론 계산값으로 채워 다운로드합니다. 채운 셀엔 해상도 메타(소스/sampling/표본수)가 주석으로 붙습니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="display:flex;flex-direction:column;gap:12px;margin-top:16px">
|
||||||
|
<label>① 엑셀 템플릿(.xlsx)
|
||||||
|
<input type="file" id="rpTpl" accept=".xlsx,.xlsm" style="display:block;margin-top:4px">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>② 날짜 (KST)
|
||||||
|
<input type="date" id="rpDate" style="display:block;margin-top:4px">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>③ 데이터 소스
|
||||||
|
<select id="rpSource" style="display:block;margin-top:4px">
|
||||||
|
<option value="history_table">history (60초 상시)</option>
|
||||||
|
<option value="fast_record">fastRecord 세션</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label id="rpSessionWrap" style="display:none">fastRecord session id
|
||||||
|
<input type="number" id="rpSession" placeholder="예: 12" style="display:block;margin-top:4px">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button id="rpGen" class="btn" style="margin-top:8px;align-self:flex-start">리포트 생성·다운로드</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="rpStatus" class="mono" style="margin-top:14px;white-space:pre-wrap"></div>
|
||||||
|
|
||||||
|
<details style="margin-top:18px">
|
||||||
|
<summary style="cursor:pointer;color:var(--t2)">토큰 치트시트</summary>
|
||||||
|
<pre style="font-size:12px;background:var(--bg2,#1a1a1a);padding:10px;border-radius:6px">
|
||||||
|
{{ 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℃ 시간비율
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
70
src/Infrastructure/Reporting/ReportColumnMap.cs
Normal file
70
src/Infrastructure/Reporting/ReportColumnMap.cs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace Hc900Crawler.Infrastructure.Reporting;
|
||||||
|
|
||||||
|
/// <summary>메트릭 1건의 태그 역할 + 클린범위. 비율메트릭: A=분자,B=분모. 잔차: A=PV,B=SP.</summary>
|
||||||
|
public sealed record MetricTag(string Tag, double Lo, double Hi);
|
||||||
|
public sealed record MetricSpec(string Unit, MetricTag A, MetricTag B);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 컬럼→태그 매핑. 기존 appsettings `SteamAdvisor:Columns`(Feed/Product/TC/SteamOp/SteamFlow)를
|
||||||
|
/// 단일 진실원으로 재사용한다(멀티컬럼 무료). 클린범위는 역할별 기본값(향후 tag_metadata EU레인지로 대체 P2).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReportColumnMap
|
||||||
|
{
|
||||||
|
private readonly IConfiguration _config;
|
||||||
|
public ReportColumnMap(IConfiguration config) => _config = config;
|
||||||
|
|
||||||
|
// 역할별 기본 클린범위 (오늘 검증에서 0/드롭아웃/스파이크 제거에 쓴 값)
|
||||||
|
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);
|
||||||
|
|
||||||
|
public bool TryResolve(string column, string metric, out MetricSpec? spec)
|
||||||
|
{
|
||||||
|
spec = null;
|
||||||
|
var sec = _config.GetSection($"SteamAdvisor:Columns:{column}");
|
||||||
|
if (!sec.Exists()) return false;
|
||||||
|
|
||||||
|
string? feed = Norm(sec["Feed"]);
|
||||||
|
string? product= Norm(sec["Product"]);
|
||||||
|
string? steam = Norm(sec["SteamFlow"]); // 예: "FIQ-6115" → "FIQ-6115.PV"
|
||||||
|
string? steamOp= sec["SteamOp"]; // 예: "TICA-6111A.OP"
|
||||||
|
|
||||||
|
switch (metric)
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
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));
|
||||||
|
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));
|
||||||
|
return true;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>속성(.PV 등) 없으면 .PV 부여.</summary>
|
||||||
|
private static string? Norm(string? tag)
|
||||||
|
=> string.IsNullOrWhiteSpace(tag) ? null : (tag!.Contains('.') ? tag : tag + ".PV");
|
||||||
|
|
||||||
|
/// <summary>마지막 '.' 이후(속성) 제거 → 루프 베이스.</summary>
|
||||||
|
private static string StripAttr(string tag)
|
||||||
|
{ var i = tag.LastIndexOf('.'); return i < 0 ? tag : tag[..i]; }
|
||||||
|
}
|
||||||
71
src/Infrastructure/Reporting/ReportFillService.cs
Normal file
71
src/Infrastructure/Reporting/ReportFillService.cs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Hc900Crawler.Core.Application.DTOs;
|
||||||
|
using Hc900Crawler.Core.Application.Interfaces;
|
||||||
|
using OfficeOpenXml;
|
||||||
|
|
||||||
|
namespace Hc900Crawler.Infrastructure.Reporting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 운전원 엑셀 템플릿의 `{{ metric=...; column=...; field=... }}` 토큰을 메트릭 값으로 치환.
|
||||||
|
/// 채운 셀엔 해상도 메타를 주석으로 부착. EPPlus(서버) — 의존성 기탑재.
|
||||||
|
/// </summary>
|
||||||
|
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));
|
||||||
|
var cells = new List<object>();
|
||||||
|
bool anyErr = false, anyOk = false, anyToken = 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 cell = ws.Cells[r, c];
|
||||||
|
var mt = TOKEN.Match(cell.Text);
|
||||||
|
if (!mt.Success) continue;
|
||||||
|
anyToken = true;
|
||||||
|
|
||||||
|
var kv = ParseToken(mt.Groups["body"].Value);
|
||||||
|
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) { cell.Value = v.Value; anyOk = true; }
|
||||||
|
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}"
|
||||||
|
+ (m.Error != null ? $" | {m.Error}" : ""), "report");
|
||||||
|
|
||||||
|
cells.Add(new { sheet = ws.Name, r, c, m.Metric, m.Column,
|
||||||
|
field = kv.GetValueOrDefault("field", ""), value = v, m.Status,
|
||||||
|
m.Source, m.SamplingMs, m.N, m.Unit });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string status = !anyToken ? "error" : 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());
|
||||||
|
}
|
||||||
153
src/Infrastructure/Reporting/ReportMetricService.cs
Normal file
153
src/Infrastructure/Reporting/ReportMetricService.cs
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
using System.Data;
|
||||||
|
using System.Data.Common;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 결정론 메트릭 엔진. history_table(60s)·fast_record(s/min) 동일 long 포맷이라 source만 교체.
|
||||||
|
/// raw SQL은 FeedforwardAuditService 패턴(_ctx.Database.GetDbConnection() + @param).
|
||||||
|
/// 표본 0 → no_data(0 날조 금지), 매핑 미정의 → error (결정론 검증 게이트).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReportMetricService : IReportMetricService
|
||||||
|
{
|
||||||
|
private readonly Hc900DbContext _ctx;
|
||||||
|
private readonly ILogger<ReportMetricService> _logger;
|
||||||
|
private readonly ReportColumnMap _map;
|
||||||
|
private const string NUMERIC = "^-?[0-9]+(\\.[0-9]+)?$";
|
||||||
|
|
||||||
|
public ReportMetricService(Hc900DbContext ctx, ILogger<ReportMetricService> logger, ReportColumnMap map)
|
||||||
|
{ _ctx = ctx; _logger = logger; _map = map; }
|
||||||
|
|
||||||
|
public async Task<MetricResultDto> ComputeAsync(MetricRequestDto req, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
bool isFast = req.SourceTable == "fast_record";
|
||||||
|
var res = new MetricResultDto
|
||||||
|
{
|
||||||
|
Metric = req.Metric, Column = req.Column,
|
||||||
|
Source = req.SourceTable, SamplingMs = isFast ? 0 : 60000
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!_map.TryResolve(req.Column, req.Metric, out var spec) || spec is null)
|
||||||
|
{ res.Status = "error"; res.Error = $"미정의 매핑: {req.Column}/{req.Metric}"; res.Value = null; return res; }
|
||||||
|
res.Unit = spec.Unit;
|
||||||
|
|
||||||
|
// KST 날짜 [00:00, +1d) → UTC (recorded_at은 UTC)
|
||||||
|
var fromUtc = DateTime.SpecifyKind(req.PeriodDateKst.Date, DateTimeKind.Unspecified).AddHours(-9);
|
||||||
|
var toUtc = fromUtc.AddDays(1);
|
||||||
|
string tbl = isFast ? "fast_record" : "history_table";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var conn = _ctx.Database.GetDbConnection();
|
||||||
|
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
|
||||||
|
|
||||||
|
if (req.Metric == "control_residual")
|
||||||
|
await ResidualAsync(res, conn, tbl, spec, fromUtc, toUtc, isFast, req.SessionId, ct);
|
||||||
|
else
|
||||||
|
await RatioAsync(res, conn, tbl, spec, fromUtc, toUtc, isFast, req.SessionId, ct);
|
||||||
|
|
||||||
|
if (isFast && res.Status == "ok") res.SamplingMs = await FastSamplingMsAsync(conn, req.SessionId ?? -1, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[Report] metric 실패 {Col}/{M}", req.Column, req.Metric);
|
||||||
|
res.Status = "error"; res.Error = ex.Message; res.Value = null;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 비율: median(A/B) (분 버킷 피벗, 둘 다 유효한 분만). 클린율 = 1 - 2*good/raw ──
|
||||||
|
private async Task RatioAsync(MetricResultDto res, DbConnection conn, string tbl, MetricSpec s,
|
||||||
|
DateTime fromUtc, DateTime toUtc, bool isFast, int? sid, CancellationToken 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 (@a,@b) AND value ~ '{NUMERIC}'
|
||||||
|
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=@a) a, max(v) FILTER (WHERE tagname=@b) b
|
||||||
|
FROM raw GROUP BY ts
|
||||||
|
), good AS (
|
||||||
|
SELECT a, b FROM piv
|
||||||
|
WHERE a BETWEEN @alo AND @ahi AND b BETWEEN @blo AND @bhi AND b <> 0
|
||||||
|
)
|
||||||
|
SELECT (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY a/b) FROM good),
|
||||||
|
(SELECT count(*) FROM good),
|
||||||
|
(SELECT c FROM tot);";
|
||||||
|
AddP(cmd, "@a", s.A.Tag); AddP(cmd, "@b", s.B.Tag);
|
||||||
|
AddP(cmd, "@alo", s.A.Lo); AddP(cmd, "@ahi", s.A.Hi);
|
||||||
|
AddP(cmd, "@blo", s.B.Lo); AddP(cmd, "@bhi", s.B.Hi);
|
||||||
|
if (isFast) AddP(cmd, "@sid", sid ?? -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 = (int)rd.GetInt64(1);
|
||||||
|
var nRaw = rd.GetInt64(2);
|
||||||
|
res.CleanedFraction = nRaw > 0 ? Math.Clamp(1.0 - (2.0 * res.N) / nRaw, 0, 1) : 0;
|
||||||
|
}
|
||||||
|
else { res.Status = "no_data"; res.Value = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 제어잔차: (PV-SP) mean/sd/abs_p95/이탈% ──
|
||||||
|
private async Task ResidualAsync(MetricResultDto res, DbConnection conn, string tbl, MetricSpec s,
|
||||||
|
DateTime fromUtc, DateTime toUtc, bool isFast, int? sid, CancellationToken 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 value ~ '{NUMERIC}'
|
||||||
|
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", s.A.Tag); AddP(cmd, "@sp", s.B.Tag);
|
||||||
|
AddP(cmd, "@plo", s.A.Lo); AddP(cmd, "@phi", s.A.Hi);
|
||||||
|
AddP(cmd, "@slo", s.B.Lo); AddP(cmd, "@shi", s.B.Hi);
|
||||||
|
if (isFast) AddP(cmd, "@sid", sid ?? -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.N = (int)rd.GetInt64(3);
|
||||||
|
res.Extra["out_pct_0_5"] = rd.IsDBNull(4) ? null : rd.GetDouble(4);
|
||||||
|
}
|
||||||
|
else { res.Status = "no_data"; res.Value = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<int> FastSamplingMsAsync(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 null or DBNull ? 0 : Convert.ToInt32(o);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddP(DbCommand cmd, string name, object val)
|
||||||
|
{ var p = cmd.CreateParameter(); p.ParameterName = name; p.Value = val ?? DBNull.Value; cmd.Parameters.Add(p); }
|
||||||
|
}
|
||||||
65
src/Infrastructure/Reporting/ReportTemplateStore.cs
Normal file
65
src/Infrastructure/Reporting/ReportTemplateStore.cs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
using System.Data;
|
||||||
|
using System.Data.Common;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Hc900Crawler.Core.Application.Interfaces;
|
||||||
|
using Hc900Crawler.Infrastructure.Database;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Hc900Crawler.Infrastructure.Reporting;
|
||||||
|
|
||||||
|
/// <summary>report_template / report_run raw SQL CRUD (FeedforwardAuditService 패턴).</summary>
|
||||||
|
public sealed class ReportTemplateStore : IReportTemplateStore
|
||||||
|
{
|
||||||
|
private readonly Hc900DbContext _ctx;
|
||||||
|
public ReportTemplateStore(Hc900DbContext ctx) => _ctx = ctx;
|
||||||
|
|
||||||
|
public async Task<int> CreateAsync(string name, string? owner, byte[] xlsx, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var conn = await OpenAsync(ct);
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "INSERT INTO hc900.report_template(name, owner, xlsx_blob) VALUES (@n,@o,@b) RETURNING id";
|
||||||
|
AddP(cmd, "@n", name);
|
||||||
|
AddP(cmd, "@o", (object?)owner ?? DBNull.Value);
|
||||||
|
AddP(cmd, "@b", xlsx);
|
||||||
|
var o = await cmd.ExecuteScalarAsync(ct);
|
||||||
|
return Convert.ToInt32(o);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<byte[]?> GetBlobAsync(int templateId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var conn = await OpenAsync(ct);
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT xlsx_blob FROM hc900.report_template WHERE id=@id";
|
||||||
|
AddP(cmd, "@id", templateId);
|
||||||
|
var o = await cmd.ExecuteScalarAsync(ct);
|
||||||
|
return o is byte[] b ? b : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RecordRunAsync(int templateId, string periodKind, DateTime periodDate,
|
||||||
|
string sourceTable, string status, object cells, byte[] outBlob, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var conn = await OpenAsync(ct);
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = @"
|
||||||
|
INSERT INTO hc900.report_run(template_id, period_kind, period_date, source_table, status, cells_json, out_blob)
|
||||||
|
VALUES (@t, @pk, @pd, @src, @st, @cells::jsonb, @blob)";
|
||||||
|
AddP(cmd, "@t", templateId);
|
||||||
|
AddP(cmd, "@pk", periodKind);
|
||||||
|
AddP(cmd, "@pd", periodDate.Date);
|
||||||
|
AddP(cmd, "@src", sourceTable);
|
||||||
|
AddP(cmd, "@st", status);
|
||||||
|
AddP(cmd, "@cells", JsonSerializer.Serialize(cells));
|
||||||
|
AddP(cmd, "@blob", outBlob);
|
||||||
|
await cmd.ExecuteNonQueryAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<DbConnection> OpenAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var conn = _ctx.Database.GetDbConnection();
|
||||||
|
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddP(DbCommand cmd, string name, object val)
|
||||||
|
{ var p = cmd.CreateParameter(); p.ParameterName = name; p.Value = val ?? DBNull.Value; cmd.Parameters.Add(p); }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user