diff --git a/docs/templates/리포트템플릿-예시-C6111-daily.xlsx b/docs/templates/리포트템플릿-예시-C6111-daily.xlsx new file mode 100644 index 0000000..6c9858e Binary files /dev/null and b/docs/templates/리포트템플릿-예시-C6111-daily.xlsx differ diff --git a/docs/논의-AI운전원-제어-아이디어.md b/docs/논의-AI운전원-제어-아이디어.md index 9ac1def..3860fe3 100644 --- a/docs/논의-AI운전원-제어-아이디어.md +++ b/docs/논의-AI운전원-제어-아이디어.md @@ -69,8 +69,121 @@ ### 첫 단추 (추천) **①(마진)부터.** 필요한 것: 민감단 온도 태그 + 환류/스팀 관련 태그. → 4개월치로 "버려지는 마진"이 실제 존재하는지 데이터로 확인. +## 데이터 검증 1회차 (2026-06-12) — ① 마진 가설 부분 반증 + +대상 확정: **P6-1 / C-6111 진공증류탑 (C3)**. +⚠️ **데이터 창 (확정):** 현장 실데이터 = **2026-02-05 ~ 2026-06-05** (field_hist `dtat` KST 기준; UTC `recorded_at` 02-05 03:53 ~ **06-05 02:22**). field_hist 17개 cont 전수조회로 확인(주 테이블 336,519행). 그 이후 ~06-12는 **C3 시뮬레이션 소스**이므로 제외. **분석 컷오프 = `recorded_at < '2026-06-05 02:22 UTC'`** (사용자 (A) 선택). (메모리의 06-05는 정확; 이전 "5월말일"은 며칠 짧았음.) + +**태그 매핑 확정 (정정: 민감단 = TI-6111C, NOT D):** +- **민감단(품질·분리도 proxy) = `TI-6111C.PV` = T_C** (중상부). DB·문서·현 프로그램 모두 이 기준. 실창 p05~p95 = **83.9~86.0, sd 1.45** → **실제로 움직임**. (`작업플랜-민감단온도-전환복귀제어.md` L26) +- 참고: `TI-6111D.PV`(상부)는 sd 0.19로 못박혀 있으나 **민감단 아님** — 품질 프록시로 쓰지 말 것. +- 에너지 레버 = 스팀 `FIQ-6115.PV`(T_C 주조작변수), 리플럭스 `FICQ-6113.PV` +- 처리량 = feed `FICQ-6101.PV` (p05~p95 = 393~918, **2.3배 변동**) +- 하부온도(스팀 자동제어) = `TICA-6111A` (.PV/.SP/.OP) + +**핵심 발견 (분 단위 피벗, feed·민감단 T_C 동시 고정 counterfactual; n=13,137):** +| 검증 | 전체 변동(실창) | feed 600~700 & T_C(TI-6111C) 84.6~85.0 고정 후 | +|---|---|---| +| 스팀 FIQ-6115 | sd 146 (p05~p95 277~664) | **sd 12.5**, p10~p90 478~500 (±2.3%) | +| 리플럭스 FICQ-6113 | sd 442 (p05~p95 905~2042) | **sd 37**, p10~p90 1470~1497 (±1%) | +| feed FICQ-6101 | sd 197 (p05~p95 392~913) | (고정변수) | + +→ **스팀·리플럭스 2.3~2.4배 변동은 거의 전부 feed/T_C로 설명됨.** 진짜 민감단 T_C로 고정하니 스팀 변동이 더 작아짐(sd 12.5) — 스팀이 곧 T_C를 잡는 MV이므로 당연. 정상상태 "같은 품질·더 적은 스팀" 마진 = p50−p10 ≈ **11 kg/h (~2.3%)**. + +**결론:** ①(정상상태 에너지 마진)은 헤드라인 불가(~2.3%). 변동의 원천은 **feed 변화와 그 과도구간(transition)**, 그리고 **T_C 자체가 sd 1.45로 움직이는 구간**. 가치는 정상상태가 아니라 *전환·T_C 이탈*에 있음. + +**🔑 핵심 발견 — 이 thread는 이미 작업라인:** `docs/작업플랜-민감단온도-전환복귀제어.md` 가 정확히 이 방향을 설계해 둠 (T_C 대역유지 → 이탈 시 전환류 도피 → bumpless 복귀). C# 인프라 일부 가동 중: `FeedforwardEngine`/`ColumnMode{Normal,Recovering,Returning}` 상태기계, `SteamAdvisor`(steam=f(feed,product,T_C) 맵 = T_C 목표역산·디커플링), `FeedRampAdvisor`. → 브레인스토밍이 기존 작업라인으로 수렴. 다음 단계는 **T_C 이탈 구간의 정량화**(빈도·크기·원인=feed/grade/주야)로 그 작업라인의 가치를 사이징하는 것. + +**미해결 단서:** `TICA-6111A.SP` 이봉성 (p05=84.0, p50=88.4, p95=88.4) → 두 모드(저온~84/고온88.4) = **grade/feed 모드 전환** 추정. T_C 이탈과 묶이는지 확인 필요. + +## 데이터 검증 2회차 (2026-06-12) — ★전환 후 재정착에 마진이 있다 + +T_C 이탈 정량화 결과 (2,861시간, 실데이터): +- **시간 내(분 단위 제어): 98.9%가 폭 ≤0.5°C** (시간당 평균폭 0.26, sd 0.06). 이탈시간(폭>1°C) = 0.5%. +- **시간 간(의도 이동): 99.3%가 ≤0.25°C/h**, p99=0.166. 최대 3.63°C(드문 큰 전환). +- 주야 차이 거의 없음 (day/swing/night avg range 0.25/0.28/0.25, 이탈 0.42/0.74/0.42%) → ③ 교대조 분산 닫힘. +→ T_C 총 2.3°C 폭은 **싸운 이탈이 아니라 의도적 조건변경의 누적**. 운전원은 단기·전환 모두 매우 잘함. + +**핵심 컨텍스트(사용자): 지난 몇 달간 운전 조건을 많이 변경함.** → 데이터 = rich excitation 자연실험(moat #2 연료). 풀링 금지(레짐별로 봐야). + +**주별 캠페인 (median):** +| 주 | feed | T_C | 스팀 | 제품 | 스팀/제품 | +|---|---|---|---|---|---| +| 02-16 | 497 | 84.3 | 344 | 446 | 0.787 | +| 02-23 | 798 | 85.4 | 560 | 717 | 0.783 | +| 03-02 | 903 | 86.0 | 652 | 821 | 0.795 | +| 04-06 | 804 | 85.5 | 608 | 717 | 0.847 | +| **04-27** | 421 | 84.1 | 304 | 355 | **0.858** | +| 05-04 | 403 | 84.0 | 295 | 355 | 0.834 | +| **05-11** | 404 | 84.0 | 281 | 355 | **0.796** | +| 05-18 | 406 | 84.0 | 282 | 355 | 0.792 | + +- 운전점 자체가 크게 변동: feed 400~900(2.25배), T_C 목표 84.0(저율)~86.0(고율) = 캠페인별 의도 설정. +- **★재정착 마진:** 04-27 vs 05-11 = **거의 동일 운전점**(feed~410, T_C 84.0, 제품 355)인데 스팀/제품 **0.858→0.796 (~8%)**. 조건변경 직후 보수적→수주에 걸쳐 효율점 재탐색(0.858→0.834→0.796→0.792). = **전환 후 재정착 손실**. + +**확정된 어필 논리:** 운전원은 *정착하면* 프런티어 근처(정상상태 마진 ~2%, ②③ 닫힘). 그러나 *조건 변경 시* 효율점 재탐색에 수주가 걸려 그동안 ~8% 스팀 과다. 모델은 학습한 `steam=f(feed,T_C)` 바닥값을 **전환 즉시** 적용 → 재정착 손실 회수. **전환이 잦은 공장이라 반복 적용됨.** "제어를 이긴다"가 아니라 "운전원이 결국 찾는 최선을 전환 직후부터" = 방어 가능. + +**다음 probe:** 4개월 전 구간에서 조건변경 이벤트를 자동 검출 → 각 전환의 (재정착 소요일 × 효율 페널티) 합산 = 회수가능 스팀 총량(연간 환산). 기존 `SteamAdvisor`(steam=f(feed,product,T_C) 맵)가 바닥값 산출 엔진. + +## 데이터 검증 3회차 (2026-06-12) — 재정착 마진도 작음, 진짜 비용은 startup/off-spec + +전환 settling 체계적 정량화 (일단위, 5캠페인/4전환; `/tmp/settling_probe.py`): +| seg | 시작 | 일 | feed | T_C | eff | settle | penalty | +|---|---|---|---|---|---|---|---| +| 0 | 02-05 | 20 | 501 | 84.3 | 0.810 | 8d | 8,292kg (startup, 선례無→청구불가) | +| 1 | 02-25 | 20 | 901 | 86.0 | 0.796 | 0 | 0 | +| 2 | 03-17 | 35 | 801 | 85.4 | 0.830 | 0 | 0 | +| 3 | 04-21 | 11 | 650 | 84.8 | 0.842 | 0 | 0 | +| 4 | 05-02 | 35 | 404 | 84.0 | 0.811 | 3d | 2,044kg (선례有) | + +- **운전원은 큰 조건변경 후에도 며칠 내 자기 효율바닥 도달** (seg1·2·3 settling=0). +- 선례 있는 재정착 회수 = seg4의 2,044kg/3.9mo = **연 6,200kg ≈ 저율 연스팀의 0.25%** → 무시 수준. +- **결론: 에너지 마진 전부 닫힘** — 정상상태 ~2%, 재정착 ~0.25%. + +**남은 진짜 기회 (에너지 아님):** +1. **캠페인 간 eff 격차(0.796~0.852, ~7%)** — 운전점 교란됨(저율=고정손실↑로 steam/prod 자연↑). 청구하려면 `eff=f(feed,T_C)` 맵(SteamAdvisor) 적합 후 **모델바닥 위 잔차**로 측정. seg2(35일 0.830)가 바닥 위였나가 핵심. +2. **★startup/grade 전환 = 진짜 큰 과도비용** — seg0 1회 startup = 8,292kg(8일). 스팀보다 **off-spec 제품·time-to-spec**이 금액 큼(미측정). bumpless-복귀 플랜(`작업플랜-민감단온도-전환복귀제어`)이 노리는 지점. + +**가치 메트릭 전환 필요:** "운전원보다 적은 스팀"(닫힘) → "**전환·startup의 time-to-spec / off-spec 제품 절감**" + "**24/7 무인 운전원-품질 보장**(labor)" + "**throughput 최대화**(미검증)". 제어 outperform이 아니라 운전원이 잘 못하는 *드문 어려운 순간*과 *지속 보장*. + +## 재프레임된 후보 (임팩트 순) +1. 🔥 **전환 최적화** — 이봉 SP 전환 이벤트 추출 → 전환 소요시간·정착중 품질편차·추가스팀 정량화. 어필: "전환시간/off-spec 절반." +2. 🔥 **처리량 최대화** — 제약 한계(하부온도/진공/플러딩)까지 feed 추가 여력 있는 시간대 정량화. +3. **분산/최악시간대(③)** — 교대조·시간대·전환구간 tail 편차로 쪼개기. +4. ~~① 정상상태 에너지 마진~~ — 부수효과로만. + ## 다시 논의할 때 정할 것 -- [ ] 어느 축부터 갈지 (추천: ① 마진) -- [ ] 대상 컬럼/루프 및 민감단 온도 태그명 -- [ ] 환류/스팀 등 마진 관련 태그명 -- [ ] realtime 1초급 데이터가 실제로 쌓이는지 확인 (동특성 식별용) +- [x] 데이터 컷오프 = `< 2026-06-05 02:22 UTC` (A) · 민감단 = TI-6111C 확정 +- [ ] **다음 probe: T_C(TI-6111C) 이탈 구간 정량화** — 목표대역 밖 체류시간 %·이탈 크기·빈도, 그리고 feed 변화/SP 이봉전환/주야와의 상관. 이게 기존 `작업플랜-민감단온도-전환복귀제어` 작업라인의 가치(=전환·분산 절감)를 사이징함. +- [ ] TICA-6111A.SP 이봉성의 정체 확인 (grade? feed 소스? 주야?) — T_C 이탈과 묶이는지 +- [ ] (병렬) 처리량 최대화 여력: 제약 한계 대비 feed 깔린 시간대 +- [ ] realtime 1초급 데이터가 실제로 쌓이는지 확인 (동특성 식별용) — 미확인 + +--- + +# 🔀 방향 전환 (2026-06-12): 제어 outperform → 편의성·리포트 제품화 + +> 위 1~4 후보(제어 성능으로 운전원 이기기)는 **데이터가 거의 다 닫음**. 사용자 결정: **"제어를 더 잘한다"가 아니라 "운전원/관리자가 하기 힘든 결정론적 분석·리포트를 자동·셀프서비스로 해주는 편의성"** 에 포커스. ← 이쪽이 더 돈에 가깝고 방어 가능. + +## 통찰: 오늘 한 작업 자체가 제품의 프로토타입 +오늘 세션에서 내가 한 일 = 기간 선정 → 드롭아웃/양자화 필터 → 효율·잔차·편차·재정착을 **결정론 SQL로 계산**. 이걸 **운전원이 버튼으로** 하게 만드는 것이 제품. DCS/MES가 못 하고 전산팀을 안 거침. + +## 제품 = 3개 아이디어가 한 파이프라인으로 체인 +**캡처(fastRecord) → 결정론 계산(메트릭) → 시각화(온도프로파일 방식) → 운전원 엑셀폼 export** — 전 과정이 전산팀/MES 우회, 운전원 소유. + +1. **시각적 셀프서비스 분석** — 온도프로파일처럼 "기간 선택 → 효율/제어잔차/수율/편차를 자동 계산·시각화 → 특정시각 문제 즉시 확인". 작업외 시간↓. +2. **운전원-정의 엑셀폼 리포트 (= 돈)** — 현 파이프라인(Experion→DB→MES→폼)은 *컬럼 하나 바꾸는 데도* 전산팀 협의·의도설명·장기 리드타임·재작업. 대안: **운전원이 만든 엑셀 폼을 계약(contract)으로 삼아**, DB에서 Daily/Monthly/Yearly(컬럼별 에너지효율·제어잔차·수율…)를 계산해 **그 폼에 즉시 꽂아줌**. 포맷 즉시 변경 = 상품성. +3. **★fastRecord = 빠진 주춧돌** — UI fastRecord(태그·간격 s/min·기간 선택 레코딩)가 결정론 로직과 결합하면 **60초 history로 불가능한 동특성·루프진단**(FOPDT τ/deadtime, 오버슈트, 밸브 stiction/헌팅)이 열림. 외과적 step/bump test 셀프서비스 = 제어벤더가 비싸게 파는 카테고리. **보너스: 고해상 전환캡처 = SteamAdvisor/모델 학습 데이터** → 편의성으로 팔며 AI운전원 연료 축적(같은 기능 2번 일함). + +## 결정적 기술 사실 (MVP 성립 근거) +- **`fast_record`와 `history_table`가 동일 long 포맷**(`tagname, recorded_at, value`) → 메트릭 SQL이 **소스 테이블만 교체**해 양쪽에서 그대로 작동 = "해상도 가변"이 공짜. +- **SheetJS(`xlsx.full.min.js`) 이미 클라이언트 탑재** → 엑셀폼 읽기/쓰기 인프라 존재. +- `FastController`/`FastSession`/`fast_record` 가동 중(start/stop/sessions, 백그라운드 sampling 루프). +- 의미층(moat) 이미 존재: `tag_metadata` + KB 문서(단일 진실원) + loop→필드계기 매핑 + `pid_equipment`. ← "컬럼 하나 즉시 변경"을 가능케 하는 핵심(전산팀이 매번 수작업 재구성하는 부분). + +## 설계 철칙 +- **해상도-인지**: 모든 메트릭 출력에 `(source, sampling_ms, n, cleaned_fraction)` 메타 동봉 — 1초 리포트 vs 60초 리포트가 사과-오렌지 안 되게. +- **결정론 검증 게이트**(메모리): 셀 무음 공란/날조 금지, 모든 셀이 메트릭+SQL로 추적, 실패는 명시적 에러. +- **메트릭/매핑은 레지스트리·config** (하드코딩 아님) → "컬럼 변경 = 폼/레지스트리 편집, 전산팀 0". + +→ MVP 아키텍처 상세: [`작업플랜-셀프서비스-분석리포트-MVP.md`](작업플랜-셀프서비스-분석리포트-MVP.md) diff --git a/docs/작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md b/docs/작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md new file mode 100644 index 0000000..5daf32b --- /dev/null +++ b/docs/작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md @@ -0,0 +1,524 @@ +# P0 MVP 상세설계 — 셀프서비스 결정론 리포트 (코딩 레벨) + +> 2026-06-12. 상위: [`작업플랜-셀프서비스-분석리포트-MVP.md`](작업플랜-셀프서비스-분석리포트-MVP.md) → [`논의-AI운전원-제어-아이디어.md`](논의-AI운전원-제어-아이디어.md). +> **P0 목표(한 문장):** 운전원이 올린 엑셀 템플릿에 `{{ metric|... }}` 토큰을 박아두면, 날짜 하나 선택 시 **C-6111 컬럼의 일일 효율·수율·제어잔차**가 그 폼 그대로 채워져 다운로드된다. 소스=`history_table`(60s), 동일 코드로 `fast_record`도 가능함을 1개 메트릭으로 실증. + +## 0. P0 범위 (의도적 최소) +| 포함 | 제외(P1+) | +|---|---| +| 메트릭 3종: `energy_efficiency`, `yield`, `control_residual` | `dynamics`(fast 전용), `excursion`, `settling` | +| 대상 1컬럼: **C-6111** (KB 매핑 기존) | 멀티컬럼/멀티플랜트 | +| 소스: `history_table`(주) + `fast_record`(효율 1종만 실증) | 멀티소스 일반화 | +| Daily 1주기, **수동 트리거**(날짜 선택→생성) | Monthly/Yearly, 스케줄 자동생성 | +| 엑셀 토큰 채움(EPPlus, 서버) + 다운로드 | 템플릿 업로드 UI(P2), 미리보기 | +| 결과 표 + 토큰맵 JSON | named-range 고급문법 | + +## 0.1 기존 자산 재사용 (신규 의존성 0) +- **EPPlus 7.4.2** — `Hc900Crawler.csproj`에 이미 있음. `PidExtractorService.cs:620`에서 `new OfficeOpenXml.ExcelPackage()` 바로 사용 = **라이선스 컨텍스트 이미 설정됨**(동일 패턴 재사용). +- **raw SQL** — `FeedforwardAuditService` 패턴(`_ctx.Database.GetDbConnection()` + `@param`) 그대로. +- **패널 로딩** — `index.html` `data-src="/panes/X.html"` + nav `data-tab` 컨벤션. +- **연결** — `appsettings.json:DefaultConnection`(`iiot_platform`, `Search Path=hc900`). +- **KST 처리** — `KstClock`/`KoreanTimeRangeExtractor`(Program.cs:70-71) 존재. recorded_at=UTC. + +--- + +## 1. 데이터 모델 (DDL — 최소 2테이블) +`hc900` 스키마. 마이그레이션 SQL(`scripts/sql/p0_report.sql`): + +```sql +-- 운전원이 올린 엑셀 템플릿 + 토큰맵 +CREATE TABLE IF NOT EXISTS hc900.report_template ( + id serial PRIMARY KEY, + name text NOT NULL, + owner text, + xlsx_blob bytea NOT NULL, -- 원본 템플릿(.xlsx) + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- 생성 이력(감사). 어떤 정의로 어떤 기간을 뽑았는지 박제 +CREATE TABLE IF NOT EXISTS hc900.report_run ( + id bigserial PRIMARY KEY, + template_id int REFERENCES hc900.report_template(id), + period_kind text NOT NULL, -- 'DAILY' + period_date date NOT NULL, -- KST 기준 날짜 + source_table text NOT NULL, -- 'history_table' | 'fast_record' + generated_at timestamptz NOT NULL DEFAULT now(), + status text NOT NULL, -- 'ok' | 'partial' | 'error' + cells_json jsonb, -- 채운 셀/값/메타 박제(추적) + out_blob bytea -- 채워진 .xlsx (다운로드 캐시) +); +``` + +> 토큰맵은 별도 컬럼이 아니라 **템플릿 셀 주석/값에 직접** 박는다(§7). report_run.cells_json에 "어떤 셀=어떤 메트릭+파라미터+값+sampling메타"를 결정론적으로 박제 → 감사·재현. + +## 2. 컬럼→메트릭 태그 매핑 (config, 하드코딩 금지) +KB(`docs/kb/P6-1_플랜트_공정마스터.md`)에서 도출. 파일 `config/report-metric-map.json`(런타임 로드, 편집 시 재배포 불필요): + +```json +{ + "C-6111": { + "label": "6-1차 PGMEA 진공증류탑", + "metrics": { + "energy_efficiency": { "steam": "FIQ-6115.PV", "product": "FICQ-6118.PV", "unit": "kg스팀/kg제품" }, + "yield": { "product": "FICQ-6118.PV", "feed": "FICQ-6101.PV", "unit": "비율" }, + "control_residual": { "pv": "TICA-6111A.PV", "sp": "TICA-6111A.SP", "unit": "degC" } + }, + "clean": { + "TICA-6111A.PV": [60,95], "TICA-6111A.SP": [60,95], + "FIQ-6115.PV": [50,3000], "FICQ-6118.PV": [100,1500], "FICQ-6101.PV": [100,1500] + } + } +} +``` +> `clean` 범위 = 오늘 검증에서 0/드롭아웃/스파이크 제거에 쓴 값. 채널별 양자화/RTD레인지 주의(메모리). 향후 tag_metadata에서 EU레인지로 자동도출 가능(P2). + +--- + +## 3. 메트릭 엔진 (C#) + +### 3.1 DTO — `src/Core/Application/DTOs/ReportDtos.cs` +```csharp +namespace Hc900Crawler.Core.Application.DTOs; + +public sealed class MetricRequestDto +{ + public string Column { get; set; } = "C-6111"; + public string Metric { get; set; } = ""; // energy_efficiency | yield | control_residual + public DateTime PeriodDateKst { get; set; } // 운전원이 고른 KST 날짜(00:00 기준) + public string SourceTable { get; set; } = "history_table"; // | fast_record (+ session_id) + public int? SessionId { get; set; } // fast_record일 때 +} + +/// 결정론 메트릭 1건 결과 + 해상도 메타(필수). +public sealed class MetricResultDto +{ + public string Metric { get; set; } = ""; + public string Column { get; set; } = ""; + public double? Value { get; set; } // 대표값(없으면 null = 명시적 실패, 날조 금지) + public string? Unit { get; set; } + public string Status { get; set; } = "ok"; // ok | no_data | error + public string? Error { get; set; } + // ── 해상도-인지 메타 (사과-오렌지 방지) ── + public string Source { get; set; } = ""; + public int SamplingMs { get; set; } // 60000(history) | fast_record.sampling_ms + public int N { get; set; } // 클린 후 표본수 + public double CleanedFraction { get; set; } // 제거된 비율 + public Dictionary Extra { get; set; } = new(); // sd/p95/이탈% 등 +} +``` + +### 3.2 인터페이스 — `src/Core/Application/Interfaces/IReportMetricService.cs` +```csharp +using Hc900Crawler.Core.Application.DTOs; +namespace Hc900Crawler.Core.Application.Interfaces; + +public interface IReportMetricService +{ + Task ComputeAsync(MetricRequestDto req, CancellationToken ct = default); +} +``` + +### 3.3 구현 — `src/Infrastructure/Reporting/ReportMetricService.cs` +raw SQL은 `FeedforwardAuditService` 패턴(`_ctx.Database.GetDbConnection()`)을 따른다. KST 날짜 → UTC 경계는 C#에서 계산(recorded_at=UTC). + +```csharp +using System.Data; +using System.Text.Json; +using Hc900Crawler.Core.Application.DTOs; +using Hc900Crawler.Core.Application.Interfaces; +using Hc900Crawler.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Hc900Crawler.Infrastructure.Reporting; + +public sealed class ReportMetricService : IReportMetricService +{ + private readonly Hc900DbContext _ctx; + private readonly ILogger _logger; + private readonly MetricMap _map; // config/report-metric-map.json 로드 + + public ReportMetricService(Hc900DbContext ctx, ILogger logger, MetricMap map) + { _ctx = ctx; _logger = logger; _map = map; } + + public async Task ComputeAsync(MetricRequestDto req, CancellationToken ct = default) + { + var res = new MetricResultDto { Metric = req.Metric, Column = req.Column, + Source = req.SourceTable, SamplingMs = req.SourceTable == "fast_record" ? 0 : 60000 }; + if (!_map.TryGet(req.Column, req.Metric, out var roles, out var unit, out var clean)) + { res.Status = "error"; res.Error = $"미정의 매핑: {req.Column}/{req.Metric}"; return res; } + res.Unit = unit; + + // KST 날짜 [00:00, +1d) → UTC + var fromUtc = DateTime.SpecifyKind(req.PeriodDateKst.Date, DateTimeKind.Unspecified).AddHours(-9); + var toUtc = fromUtc.AddDays(1); + // fast_record는 세션 전체(기간무관) 또는 세션의 해당일. P0: 세션 전체. + bool isFast = req.SourceTable == "fast_record"; + string tbl = isFast ? "fast_record" : "history_table"; + + try + { + res = req.Metric switch + { + "energy_efficiency" => await RatioMetric(res, tbl, roles["steam"], roles["product"], clean, fromUtc, toUtc, isFast, req.SessionId, ct), + "yield" => await RatioMetric(res, tbl, roles["product"], roles["feed"], clean, fromUtc, toUtc, isFast, req.SessionId, ct), + "control_residual" => await ResidualMetric(res, tbl, roles["pv"], roles["sp"], clean, fromUtc, toUtc, isFast, req.SessionId, ct), + _ => Fail(res, $"미구현 메트릭: {req.Metric}") + }; + } + catch (Exception ex) { _logger.LogError(ex, "[Report] metric 실패 {M}", req.Metric); Fail(res, ex.Message); } + return res; + } + + private static MetricResultDto Fail(MetricResultDto r, string msg) { r.Status="error"; r.Error=msg; r.Value=null; return r; } + + // ── 비율 메트릭: median(numer)/median(denom). 분단위 피벗으로 정렬 후 robust 중앙값 ── + private async Task RatioMetric( + MetricResultDto res, string tbl, string numerTag, string denomTag, + IReadOnlyDictionary clean, + DateTime fromUtc, DateTime toUtc, bool isFast, int? sessionId, CancellationToken ct) + { + var (nlo,nhi) = clean[numerTag]; var (dlo,dhi) = clean[denomTag]; + var conn = _ctx.Database.GetDbConnection(); + if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct); + await using var cmd = conn.CreateCommand(); + // 분 버킷 피벗 → 둘 다 유효한 분만 → 비율의 중앙값/표본수 + 원천 표본수(클린율 산출) + cmd.CommandText = $@" +WITH raw AS ( + SELECT date_trunc('minute', recorded_at) ts, tagname, value::float v + FROM hc900.{tbl} + WHERE tagname IN (@numer,@denom) + AND ({(isFast ? "session_id = @sid" : "recorded_at >= @from AND recorded_at < @to")}) +), tot AS ( SELECT count(*) c FROM raw ), +piv AS ( + SELECT ts, + max(v) FILTER (WHERE tagname=@numer) n, + max(v) FILTER (WHERE tagname=@denom) d + FROM raw GROUP BY ts +), good AS ( + SELECT n, d FROM piv + WHERE n BETWEEN @nlo AND @nhi AND d BETWEEN @dlo AND @dhi AND d <> 0 +) +SELECT + (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY n/d) FROM good) AS ratio_med, + (SELECT count(*) FROM good) AS n_good, + (SELECT c FROM tot) AS n_raw;"; + AddP(cmd,"@numer",numerTag); AddP(cmd,"@denom",denomTag); + AddP(cmd,"@nlo",nlo); AddP(cmd,"@nhi",nhi); AddP(cmd,"@dlo",dlo); AddP(cmd,"@dhi",dhi); + if (isFast) AddP(cmd,"@sid", sessionId ?? -1); + else { AddP(cmd,"@from",fromUtc); AddP(cmd,"@to",toUtc); } + + await using var rd = await cmd.ExecuteReaderAsync(ct); + if (await rd.ReadAsync(ct) && !rd.IsDBNull(0)) + { + res.Value = rd.GetDouble(0); + res.N = rd.GetInt32(1); + var nRaw = rd.GetInt64(2); + var nGood = res.N; + res.CleanedFraction = nRaw > 0 ? 1.0 - (2.0*nGood)/nRaw : 0; // 2태그라 분모 보정 + if (isFast) res.SamplingMs = await FastSamplingMsAsync(conn, sessionId ?? -1, ct); + } + else { res.Status = "no_data"; res.Value = null; } + return res; + } + + // ── 제어잔차: (PV-SP) 통계. 분 피벗 후 잔차의 mean/sd/p95/이탈% ── + private async Task ResidualMetric( + MetricResultDto res, string tbl, string pvTag, string spTag, + IReadOnlyDictionary clean, + DateTime fromUtc, DateTime toUtc, bool isFast, int? sessionId, CancellationToken ct) + { + var (plo,phi) = clean[pvTag]; var (slo,shi) = clean[spTag]; + var conn = _ctx.Database.GetDbConnection(); + if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = $@" +WITH raw AS ( + SELECT date_trunc('minute', recorded_at) ts, tagname, value::float v + FROM hc900.{tbl} + WHERE tagname IN (@pv,@sp) + AND ({(isFast ? "session_id = @sid" : "recorded_at >= @from AND recorded_at < @to")}) +), piv AS ( + SELECT ts, max(v) FILTER (WHERE tagname=@pv) pv, max(v) FILTER (WHERE tagname=@sp) sp + FROM raw GROUP BY ts +), good AS ( + SELECT pv - sp AS e FROM piv + WHERE pv BETWEEN @plo AND @phi AND sp BETWEEN @slo AND @shi +) +SELECT avg(e), stddev(e), percentile_cont(0.95) WITHIN GROUP (ORDER BY abs(e)), + count(*), 100.0*count(*) FILTER (WHERE abs(e) > 0.5)/NULLIF(count(*),0) +FROM good;"; + AddP(cmd,"@pv",pvTag); AddP(cmd,"@sp",spTag); + AddP(cmd,"@plo",plo); AddP(cmd,"@phi",phi); AddP(cmd,"@slo",slo); AddP(cmd,"@shi",shi); + if (isFast) AddP(cmd,"@sid", sessionId ?? -1); + else { AddP(cmd,"@from",fromUtc); AddP(cmd,"@to",toUtc); } + + await using var rd = await cmd.ExecuteReaderAsync(ct); + if (await rd.ReadAsync(ct) && !rd.IsDBNull(3) && rd.GetInt64(3) > 0) + { + res.Value = rd.IsDBNull(0) ? null : rd.GetDouble(0); // 평균 잔차 + res.Extra["sd"] = rd.IsDBNull(1) ? null : rd.GetDouble(1); + res.Extra["abs_p95"] = rd.IsDBNull(2) ? null : rd.GetDouble(2); + res.Extra["out_pct_0_5"]= rd.IsDBNull(4) ? null : rd.GetDouble(4); + res.N = (int)rd.GetInt64(3); + if (isFast) res.SamplingMs = await FastSamplingMsAsync(conn, sessionId ?? -1, ct); + } + else { res.Status = "no_data"; res.Value = null; } + return res; + } + + private static async Task FastSamplingMsAsync(System.Data.Common.DbConnection conn, int sid, CancellationToken ct) + { + await using var c = conn.CreateCommand(); + c.CommandText = "SELECT sampling_ms FROM hc900.fast_session WHERE id=@id"; + AddP(c,"@id",sid); + var o = await c.ExecuteScalarAsync(ct); + return o is int i ? i : (o is null ? 0 : Convert.ToInt32(o)); + } + + private static void AddP(System.Data.Common.DbCommand cmd, string name, object val) + { var p = cmd.CreateParameter(); p.ParameterName = name; p.Value = val ?? DBNull.Value; cmd.Parameters.Add(p); } +} +``` + +> **결정론 검증 게이트 준수**: 표본 0 → `Status=no_data`, `Value=null`(빈칸을 0으로 날조하지 않음). 매핑 미정의 → 명시적 error. (메모리 [[deterministic-verification-gate]]) + +### 3.4 매핑 로더 — `src/Infrastructure/Reporting/MetricMap.cs` +```csharp +using System.Text.Json; +namespace Hc900Crawler.Infrastructure.Reporting; + +public sealed class MetricMap +{ + private readonly JsonElement _root; + public MetricMap(string jsonPath) => _root = JsonDocument.Parse(File.ReadAllText(jsonPath)).RootElement; + + public bool TryGet(string column, string metric, + out Dictionary roles, out string? unit, + out Dictionary clean) + { + roles = new(); unit = null; clean = new(); + if (!_root.TryGetProperty(column, out var col)) return false; + if (!col.GetProperty("metrics").TryGetProperty(metric, out var m)) return false; + foreach (var p in m.EnumerateObject()) + if (p.Name == "unit") unit = p.Value.GetString(); else roles[p.Name] = p.Value.GetString()!; + foreach (var p in col.GetProperty("clean").EnumerateObject()) + clean[p.Name] = (p.Value[0].GetDouble(), p.Value[1].GetDouble()); + return true; + } +} +``` + +--- + +## 4. 엑셀 토큰 채움 (EPPlus, 서버) — `src/Infrastructure/Reporting/ReportFillService.cs` +토큰 = **셀 값 텍스트** `{{ metric=energy_efficiency; column=C-6111 }}`. 엔진이 전 시트 스캔→토큰 셀을 결과값으로 치환, 메타는 셀 주석으로. + +```csharp +using OfficeOpenXml; +using System.Text.RegularExpressions; +using Hc900Crawler.Core.Application.DTOs; +using Hc900Crawler.Core.Application.Interfaces; + +namespace Hc900Crawler.Infrastructure.Reporting; + +public sealed class ReportFillService +{ + private static readonly Regex TOKEN = new(@"\{\{\s*(?.+?)\s*\}\}", RegexOptions.Compiled); + private readonly IReportMetricService _metrics; + public ReportFillService(IReportMetricService metrics) => _metrics = metrics; + + public async Task<(byte[] xlsx, List 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(); 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 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 Metric([FromBody] MetricRequestDto req, CancellationToken ct) + => Ok(await _metrics.ComputeAsync(req, ct)); + + // 템플릿 업로드(P0: 단순 등록) + [HttpPost("template")] + public async Task 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 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(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +``` + +--- + +## 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 + +... +
+``` +**`wwwroot/panes/reports.html`** (요지): +```html +
+ + + + + +
+
+ +``` +**`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]].* diff --git a/docs/작업플랜-셀프서비스-분석리포트-MVP.md b/docs/작업플랜-셀프서비스-분석리포트-MVP.md new file mode 100644 index 0000000..6a4a78f --- /dev/null +++ b/docs/작업플랜-셀프서비스-분석리포트-MVP.md @@ -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]].* diff --git a/scripts/sql/p0_report.sql b/scripts/sql/p0_report.sql new file mode 100644 index 0000000..fa39eed --- /dev/null +++ b/scripts/sql/p0_report.sql @@ -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); diff --git a/src/Core/Application/DTOs/ReportDtos.cs b/src/Core/Application/DTOs/ReportDtos.cs new file mode 100644 index 0000000..5ffed47 --- /dev/null +++ b/src/Core/Application/DTOs/ReportDtos.cs @@ -0,0 +1,30 @@ +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 + public int? SessionId { get; set; } // fast_record일 때 +} + +/// 결정론 메트릭 1건 결과 + 해상도 메타(필수, 사과-오렌지 방지). +/// 프로퍼티로 선언해야 System.Text.Json이 직렬화함(필드는 기본 미직렬화). +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 Extra { get; set; } = new(); // sd/abs_p95/out_pct_0_5 등 +} diff --git a/src/Core/Application/Interfaces/IReportMetricService.cs b/src/Core/Application/Interfaces/IReportMetricService.cs new file mode 100644 index 0000000..e6526fb --- /dev/null +++ b/src/Core/Application/Interfaces/IReportMetricService.cs @@ -0,0 +1,9 @@ +using Hc900Crawler.Core.Application.DTOs; + +namespace Hc900Crawler.Core.Application.Interfaces; + +/// 기간/소스/대상을 받아 결정론적으로 메트릭 1건을 계산한다. +public interface IReportMetricService +{ + Task ComputeAsync(MetricRequestDto req, CancellationToken ct = default); +} diff --git a/src/Core/Application/Interfaces/IReportTemplateStore.cs b/src/Core/Application/Interfaces/IReportTemplateStore.cs new file mode 100644 index 0000000..7620324 --- /dev/null +++ b/src/Core/Application/Interfaces/IReportTemplateStore.cs @@ -0,0 +1,11 @@ +namespace Hc900Crawler.Core.Application.Interfaces; + +/// report_template / report_run CRUD (raw SQL). +public interface IReportTemplateStore +{ + Task CreateAsync(string name, string? owner, byte[] xlsx, CancellationToken ct = default); + Task 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); +} diff --git a/src/Hc900Crawler/Controllers/ReportController.cs b/src/Hc900Crawler/Controllers/ReportController.cs new file mode 100644 index 0000000..eeb949b --- /dev/null +++ b/src/Hc900Crawler/Controllers/ReportController.cs @@ -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; } + + /// 단건 메트릭(미리보기/디버그). + [HttpPost("metric")] + public async Task Metric([FromBody] MetricRequestDto req, CancellationToken ct) + => Ok(await _metrics.ComputeAsync(req, ct)); + + /// 엑셀 템플릿 등록. + [HttpPost("template")] + public async Task 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 }); + } + + /// ★템플릿+날짜 → 채워진 xlsx 다운로드. + [HttpGet("generate")] + public async Task 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); + } +} diff --git a/src/Hc900Crawler/Program.cs b/src/Hc900Crawler/Program.cs index 05912a1..1c63fbc 100644 --- a/src/Hc900Crawler/Program.cs +++ b/src/Hc900Crawler/Program.cs @@ -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(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + var app = builder.Build(); // ── DB 초기화 ───────────────────────────────────────────────────────────────── diff --git a/src/Hc900Crawler/wwwroot/index.html b/src/Hc900Crawler/wwwroot/index.html index d3ee134..cac5265 100644 --- a/src/Hc900Crawler/wwwroot/index.html +++ b/src/Hc900Crawler/wwwroot/index.html @@ -97,6 +97,10 @@ 13 스팀 Advisory +
@@ -133,6 +137,7 @@
+
@@ -232,5 +237,6 @@ + diff --git a/src/Hc900Crawler/wwwroot/js/reports.js b/src/Hc900Crawler/wwwroot/js/reports.js new file mode 100644 index 0000000..57effb3 --- /dev/null +++ b/src/Hc900Crawler/wwwroot/js/reports.js @@ -0,0 +1,55 @@ +/* P0 셀프서비스 리포트 — 패널 JS. + pane HTML은 innerHTML로 주입되므로