P0 셀프서비스 결정론 리포트 — 적산·물질수지 폐합·cleaning 마스크 (+ P1 온라인 스펙) #1

Open
windpacer wants to merge 43 commits from feat/p0-selfservice-report into main
17 changed files with 1350 additions and 4 deletions
Showing only changes of commit 37a464ab96 - Show all commits

Binary file not shown.

View File

@@ -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이므로 당연. 정상상태 "같은 품질·더 적은 스팀" 마진 = p50p10 ≈ **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)

View 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 }}` | 평균 잔차(PVSP) |
| `{{ metric=control_residual; column=C-6111; field=sd }}` | 잔차 표준편차 |
| `{{ metric=control_residual; column=C-6111; field=out_pct_0_5 }}` | \|잔차\|>0.5℃ 시간비율 |
- 채운 셀엔 **주석**으로 `src/sampling/n/clean/unit` 자동 부착(해상도-인지).
- 포맷 변경 = 운전원이 셀 옮기고 토큰 복붙. **전산팀 0, 리드타임 0.**
## 8. 프론트엔드 (최소 패널)
**`index.html`** — nav + pane 등록(기존 컨벤션):
```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]].*

View 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` | (PVSP) 통계 (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
View 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);

View 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 등
}

View 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);
}

View 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);
}

View 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);
}
}

View File

@@ -11,6 +11,11 @@ using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// EPPlus 라이선스 컨텍스트 (리포트 엑셀 생성용). ⚠️ 상용 출시 전 라이선스 정리 필요(NonCommercial 한정).
#pragma warning disable CS0618
OfficeOpenXml.ExcelPackage.LicenseContext = OfficeOpenXml.LicenseContext.NonCommercial;
#pragma warning restore CS0618
// ── MVC / Swagger ─────────────────────────────────────────────────────────────
var mvcBuilder = builder.Services.AddControllers()
.AddJsonOptions(opt =>
@@ -168,6 +173,14 @@ builder.Services.AddCors(opt =>
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();
// ── DB 초기화 ─────────────────────────────────────────────────────────────────

View File

@@ -97,6 +97,10 @@
<span class="ni">13</span>
<span class="nl">스팀 Advisory</span>
</li>
<li class="nav-item" data-tab="reports">
<span class="ni">14</span>
<span class="nl">리포트</span>
</li>
</ul>
<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-steam" data-src="/panes/steam.html?v=20260606"></section>
<section class="pane" id="pane-reports" data-src="/panes/reports.html?v=20260612"></section>
</main>
</div>
@@ -232,5 +237,6 @@
<script src="/js/trend.js?v=20260611"></script>
<script src="/js/ff.js?v=20260604"></script>
<script src="/js/steam.js?v=20260606"></script>
<script src="/js/reports.js?v=20260612"></script>
</body>
</html>

View 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;
}
};
};

View 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>

View 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]; }
}

View 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());
}

View 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); }
}

View 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); }
}