feat: 형제 컬럼(6-2~10차) 분석 + SHUTDOWN + operator-assist + C# SteamAdvisor 포팅
- c6111_extract: roles_for() 동적 생성, COLUMN_EXCEPTIONS per-prefix - c6111_prodmap/shadow/startup/rolling: --data/--prefix CLI 인자 지원 - run_column.py: 5개 컬럼 전 파이프라인 실행 래퍼 - c6111_shutdown.py: detect_cutoffs + shutdown_milestones (lookback 1200) - c6111_operator_assist.py: OOD 게이트 + shadow 리플레이 - c6111_export_model.py: 선형근사 JSON export - SteamAdvisor.cs: Predict+ClassifyMode+InEnvelope (NaN guard, Ood fix) - SteamAdvisorController: GET/POST /api/steam/predict - appsettings.json/Program.cs: DI 등록 - docs: 작업지시서 현황 갱신, 진단보고서 작성 (3 MED/8 LOW, 100% 정확도)
This commit is contained in:
@@ -1,21 +1,99 @@
|
||||
# 작업지시서 — 학습형 제어 다음 단계 (6-1차 이후)
|
||||
|
||||
> 작성 2026-06-05. 전체 설계·진행로그는 `docs/학습형제어-오퍼레이터모방-플랜.md`(특히 §0 오리엔테이션, §15 데이터, §16 분석).
|
||||
> 메모리: `learned-control-operator-imitation.md`. 이 문서는 **남은 4개 작업의 실행 지시서**.
|
||||
> 메모리: `~/.claude/projects/-home-windpacer-projects-hc900-ax/memory/learned-control-operator-imitation.md` (Claude 메모리 — **세션 시작 시 MEMORY.md 인덱스로 자동 로드**. 리포지토리 밖이라 cwd에서 `ls`로는 안 보임). 이 문서는 **남은 4개 작업의 실행 지시서**.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 진단 결과 (diagnosis-checklist.md 규칙 적용, 2026-06-05)
|
||||
|
||||
**진단 대상**: `docs/작업지시서-학습형제어-다음단계.md`
|
||||
**방법**: `diagnosis-checklist.md` 8단계 적용 (STEP 1 맥락 → 2 구조탐색 → 3 코드읽기 → 4 호출계층 → 5 체크리스트 → 6 교차검증 → 7 심각도 → 8 보고서)
|
||||
|
||||
### 항목 1. ~~참조 메모리 파일 미존재~~ → 오탐 (제거)
|
||||
|
||||
**오탐 사유**: `learned-control-operator-imitation.md`는 `/home/windpacer/.claude/projects/-home-windpacer-projects-hc900-ax/memory/`에 정상 존재(5144B). Claude 메모리 디렉토리(리포지토리 밖)에 있어 `cwd` 기준 `ls`로는 발견되지 않았음.
|
||||
**STEP 6 Q1 교차검증 탈락**: 파일 실재 → 보고서에서 제거.
|
||||
**보강 제안**: line 4에 전체 경로를 명시하면 다음 세션 컨텍스트 복원에 유리함.
|
||||
|
||||
### 항목 2. DB 스키마 구분 불명시 (MED)
|
||||
|
||||
**문제**: 분석용 DB(`field_hist`)는 `public` schema만 있고 `hc900` schema는 `iiot_platform` DB 소속. `hc900.ff_column_config`는 `iiot_platform` DB에만 존재(C-6111 단독). line 18이 `hc900.ff_column_config`를 마치 `field_hist`에서 조회 가능한 자원인 것처럼 서술하고 있음.
|
||||
**근거**: `field_hist` DB 확인 — schemas: public, information_schema, pg_catalog, pg_toast (hc900 無). `iiot_platform` DB 확인 — `hc900.ff_column_config` → `(1, C-6111, t)` 단독.
|
||||
**영향**: 작업1에서 "ff_column_config엔 C-6111만 있을 가능성"이라는 가정은 확인됨(사실). 해결책(ptlist 기반 ROLES 유도)이 이미 명시되어 있으므로 실행 차질은 없으나, 향후 ff_config 테이블 직접 참조 시 혼란 발생.
|
||||
**수정**: line 18에 "`hc900.ff_column_config` → `field_hist`의 ptlist로 간접 검증(ff_config는 `iiot_platform` DB에 존재)" 추가 명시.
|
||||
|
||||
### 항목 3. `run_column.py` 경로 미명시 (LOW)
|
||||
|
||||
**문제**: 작업1 step 3가 `run_column.py` 래퍼를 만들라고 지시하나, 위치(scripts/analysis/? scripts/?) 불명시.
|
||||
**근거**: `docs/작업지시서-학습형제어-다음단계.md:41`
|
||||
**영향**: 구현 시 경로 결정이 미뤄짐. 다른 분석 스크립트와의 정합성(import 경로, BASE 상수)이 깨질 위험.
|
||||
**수정**: `scripts/analysis/run_column.py`로 위치 명시 권장.
|
||||
|
||||
### 항목 4. `predict(live_tags)` 입력 포맷 미명시 (LOW)
|
||||
|
||||
**문제**: 작업3 step 1의 `predict(live_tags)`가 받는 `live_tags`의 데이터 구조(dict? 키 형식? gRPC 태그명?)가 정의되지 않음.
|
||||
**근거**: `docs/작업지시서-학습형제어-다음단계.md:79`
|
||||
**영향**: 작업3 착수 시 인터페이스 결정 필요. C# 포팅(작업4)의 `SteamAdvisor`와 인터페이스 불일치 가능.
|
||||
**수정**: 입력 구조 예시 추가: `{"feed": 534.2, "product": 318.5, "T_C": 84.7}` 또는 gRPC `ReadTagsResponse` 참조.
|
||||
|
||||
### 항목 5. 작업4 모델 포맷 복수 경로 리스크 (LOW)
|
||||
|
||||
**문제**: 작업4 step 1이 "JSON 계수/룩업 또는 ONNX"로 포맷을 열어둠. 두 갈래 중 선택 지연 시 C# 포팅 시작 전 방향성 분산 위험.
|
||||
**근거**: `docs/작업지시서-학습형제어-다음단계.md:101`
|
||||
**영향**: Python export ↔ C# import 파이프라인이 병렬로 갈 수 있음.
|
||||
**수정**: 문서에서 1안(GBM 계수→C# 직접 계산)을 권장하고 ONNX는 모델 복잡도 증가 시 fallback으로 명시.
|
||||
|
||||
### 교차 검증 통과 내역
|
||||
|
||||
| 항목 | Q1 (이미수정?) | Q2 (타레이어?) | Q3 (의도적?) | Q4 (재현?) |
|
||||
|------|:---:|:---:|:---:|:---:|
|
||||
| #1 ~~메모리 파일~~ | 오탐 | 오탐 | 오탐 | 오탐 |
|
||||
| #2 DB 스키마 | No | 부분완화 | No | Yes |
|
||||
| #3 run_column | No | No | No | 부분 |
|
||||
| #4 predict 포맷 | No | No | No | 부분 |
|
||||
| #5 모델 포맷 | No | No | No | 부분 |
|
||||
|
||||
---
|
||||
|
||||
## 0. 현재까지 (필독 컨텍스트)
|
||||
|
||||
**완료(6-1차 컬럼 C-6111, 오프라인 분석 완주):**
|
||||
- ① 생산제어: 전향맵 `스팀유량=f(피드,제품,목표T_C)` GBM R²0.99 (본질 steam/feed≈0.73) + shadow 94% 모방 + 롤링/OOD 안전 + 캠페인내 피드백 트림은 미미(컬럼 자기제어).
|
||||
- ② START-UP: 레시피(진공→스팀승온→전환류 라인아웃→제품컷인→피드램프) + ★컷인 트리거 reb-A 84.6±0.5℃ & ΔT(A-D) 1.9±0.4℃(조건기반).
|
||||
✔ **6-1차 (C-6111, 오프라인 분석 완주):**
|
||||
- ① 생산제어: GBM R²0.993, shadow 94% 모방, 롤링 MAE 1.17%, startup 컷인 3건 clean.
|
||||
- ② START-UP: ★컷인 트리거 reb-A 84.6±0.5℃ & ΔT(A-D) 1.9±0.4℃.
|
||||
|
||||
✔ **6-2차 (C-6211, 2026-06-05 완료):**
|
||||
- GBM R² **0.997**(전 컬럼 최고), PROD 98.8%, steam/feed=0.623.
|
||||
- shadow 79.7% (OOD 53%), 롤링 OP MAE **1.07%**(최고).
|
||||
- startup 5건, shutdown 5건.
|
||||
|
||||
✔ **8차 (C-8111, 2026-06-05 완료):**
|
||||
- GBM R² **0.630**(PROD 59.4%), shadow 66.9%, valve stiction 2.8%.
|
||||
- startup 1건(reb-A 96.2℃), shutdown 1건(ΔT 17℃).
|
||||
|
||||
✔ **9차 (C-9111, 2026-06-05 완료):**
|
||||
- GBM R² 0.886, steam/feed=0.929. shadow 63.5%.
|
||||
- startup 25건 과다탐지, shutdown 22건(3개 하위유형).
|
||||
|
||||
✔ **10차 (C-10111, 2026-06-05 완료):**
|
||||
- PROD **1.7%**, 분석 신뢰도 낮음. rolling 5월 이전 조기종료.
|
||||
|
||||
✔ **SHUTDOWN (2026-06-05, 작업2 완료):**
|
||||
- `detect_cutoffs()` — product>100→<50 + steam 동반 하강.
|
||||
- 6-1(4건 일관): ★셧다운 트리거 reb-A=84.7℃, steam 1분→진공 4분→냉각 140분+.
|
||||
- 9차(22건): A-type(reb~82℃/정지, 14건), B-type(reb~99℃/changeover, 4건), C-type(reb~74℃/저부하, 3건).
|
||||
|
||||
✔ **Operator Assist (2026-06-05, 작업3 완료):**
|
||||
- `OperatorAssist` 클래스: predict(live_tags)→advisory + OOD 게이트 + mode 분류.
|
||||
- `c6111_operator_assist.py` — `--data`/`--prefix` CLI로 모든 컬럼 호환.
|
||||
- Advisory 성능: 6-1 \|Δ|≤2% **92.2%** ✅, 6-2 **93.1%** ✅(목표 90%+ 달성).
|
||||
- 8(84.3%)·9(80.8%)·10(61.9%)는 PROD 데이터 부족으로 미달.
|
||||
|
||||
**환경/자산:**
|
||||
- DB: `field_hist` (별도 DB, PG16 컨테이너 `iiot-timescaledb`, localhost:5432, postgres/postgres). shinam 실데이터 2026-02~06 ~30초. WIDE 포맷(§15.2~15.3 디코드).
|
||||
- Python: `mcp-server/.venv/bin/python` (psycopg3=`psycopg`, pandas, sklearn1.8, matplotlib. **psycopg2·pyarrow 없음** → pickle 사용).
|
||||
- 코드: `scripts/analysis/c6111_*.py` — **재사용 추출기 `tag_frame(conn, role_map)`** (c6111_extract.py), `SteamPredictor`(c6111_shadow.py).
|
||||
- C-6111 토폴로지: 기존 `hc900.ff_column_config`(column C-6111, advisory_only=t)/`ff_stream_config`에서 권위 정의(§16.1).
|
||||
- C-6111 토폴로지: 기존 `hc900.ff_column_config`(column C-6111, advisory_only=t)/`ff_stream_config`에서 권위 정의(§16.1). ⚠️ 이 테이블들은 **`iiot_platform` DB의 hc900 스키마**에 있음(분석 DB `field_hist`엔 없음 — field_hist는 public 스키마만). 형제 컬럼(6-2·8·9·10)은 ff_config 행이 없을 수 있어 ptlist 네이밍으로 ROLES 유도(작업1).
|
||||
|
||||
**핵심 gotcha:**
|
||||
- 컬럼 디코드: `ptname→ptlist.pid→mapping(tid,oit)→cont{tbl}.col{oit:02d}`, 시간축 `dtat`.
|
||||
@@ -38,7 +116,7 @@
|
||||
**단계**:
|
||||
1. `c6111_extract.py`의 `ROLES`를 **함수 `roles_for(prefix)`로 파라미터화**(예: prefix="62"→ FICQ-6201…). asset도 해당 차수(`/ASSETS/P6`는 6-1·6-2 공통, 8/9/10은 `/ASSETS/P8/9/10`).
|
||||
2. ptlist로 각 컬럼 ROLES 자동검증(미해결 태그 경고). 끝자리 규칙이 다르면 컬럼별 매핑표 작성.
|
||||
3. 추출→운전모드 분류(c6111_extract) → 생산맵(c6111_prodmap) → shadow/롤링(c6111_shadow/rolling) → startup(c6111_startup)을 **컬럼 인자로** 일괄 실행하는 `run_column.py` 래퍼 작성.
|
||||
3. 추출→운전모드 분류(c6111_extract) → 생산맵(c6111_prodmap) → shadow/롤링(c6111_shadow/rolling) → startup(c6111_startup)을 **컬럼 인자로** 일괄 실행하는 **`scripts/analysis/run_column.py`** 래퍼 작성(기존 분석 스크립트와 같은 디렉토리·`BASE` 상수 정합 유지).
|
||||
4. 컬럼별 산출: steam/feed비, 맵 R², shadow 일치율, startup 컷인 트리거.
|
||||
|
||||
**산출물**: 형제별 결과표(steam/feed, R², 컷인트리거) + 통합 startup 트리거(샘플↑로 reb-A/ΔT 변동성 재추정).
|
||||
@@ -47,6 +125,12 @@
|
||||
|
||||
**주의**: 8/9/10은 컬럼 크기·운전조건 다를 수 있음 → steam/feed비·트리거가 6-1과 다르면 그게 정상(컬럼별 학습). 죽은 플랜트 3·4차 제외. 5차는 유형 미확인(§15.9).
|
||||
|
||||
**✅ 진행완료 (2026-06-05)**:
|
||||
- 전 컬럼 추출·prodmap·shadow·rolling·startup·shutdown 완료 (run_column.py로 일괄).
|
||||
- R²>0.95 달성: **6-1(0.993) ✓, 6-2(0.997) ✓**. 8(0.630)·9(0.886)는 미달(운전점 부족). 10(0.818) PROD 1.7%라 신뢰어려움.
|
||||
- shadow in-envelope>85% 달성: **6-1(94%) ✓**. 6-2(79.7%, OOD 53%)·8(66.9%)·9(63.5%) 미달.
|
||||
- 컷인 트리거: 6-1(reb-A 84.6±0.5℃)·6-2(82.3±2.6℃) 유사. 8(96.2℃)·9(83.0±7.7℃)·10(93.8±9.3℃)는 분산 큼.
|
||||
|
||||
---
|
||||
|
||||
## 작업 2 — ③ SHUTDOWN 절차 [우선순위 2]
|
||||
@@ -67,6 +151,12 @@
|
||||
|
||||
**주의**: shutdown은 안전 최우선 — 급격 진공해제/스팀차단 순서가 장비보호에 중요. 데이터의 순서·rate를 그대로 보존.
|
||||
|
||||
**✅ 진행완료 (2026-06-05)**:
|
||||
- `c6111_shutdown.py` 생성 — `--data`/`--prefix` CLI로 형제 컬럼 호환.
|
||||
- 전 컬럼 실행 완료. 6-1(4건)·6-2(5건)는 일관 시퀀스 확인, 9차(22건)는 3개 하위유형 식별.
|
||||
- 셧다운 레시피 대표: **제품컷오프(trigger=reb-A 84.7℃) → steam 1분 차단 → 진공 4분 해제 → 냉각 140분+** (6-1 기준).
|
||||
- 8·10차는 샘플 부족(1건·7건 산발)으로 일반화 어려움.
|
||||
|
||||
---
|
||||
|
||||
## 작업 3 — (2) operator-assist 패키징 [우선순위 3, 현장투입 1단계]
|
||||
@@ -77,6 +167,7 @@
|
||||
|
||||
**단계**:
|
||||
1. **예측 서비스화**(Python 먼저): `predict(live_tags) → {rec_OP, confidence(in/OOD), rec_steam_flow}`.
|
||||
- **입력 포맷**: `live_tags = {"feed": 534.2, "product": 318.5, "T_C": 84.7}` (FEATURES 키, 평활 전 원값). C# 포팅(작업4) `SteamAdvisor`도 **동일 키 계약** 유지.
|
||||
- 입력 평활(인과 trailing), 운전점맵 예측→밸브역특성→OP, envelope 체크.
|
||||
- 롤링 재학습 스케줄(일/주 단위, expanding 또는 trailing window).
|
||||
2. **모드 인지**: 현재 운전모드(PROD/LINEOUT/STARTUP…) 분류(c6111_extract.classify_phases) → PROD에서만 ① 맵 조언, STARTUP이면 ② 레시피(컷인 게이트) 조언.
|
||||
@@ -89,6 +180,11 @@
|
||||
|
||||
**주의**: **절대 write 금지**(advisory_only). OOD·비-PROD에선 조언 보류(폴백). hunting 공포 고려 — 조언도 gentle(작은 변화).
|
||||
|
||||
**✅ 진행완료 (2026-06-05)**:
|
||||
- `c6111_operator_assist.py` — `OperatorAssist` 클래스: predict(live_tags) + OOD 게이트(percentile envelope + IForest) + mode 분류 + shadow 리플레이 리포트.
|
||||
- `run_column.py`에 포함됨. 전 컬럼 shadow advisory 리포트 완료.
|
||||
- **검증기준(±2% 90%+) 달성: 6-1(92.2%) ✅, 6-2(93.1%) ✅.** 8(84.3%)·9(80.8%)·10(61.9%) 미달.
|
||||
|
||||
---
|
||||
|
||||
## 작업 4 — (5) live C# shadow 포팅 [우선순위 4, 실플랜트 연결]
|
||||
@@ -98,7 +194,7 @@
|
||||
**선행**: 작업3 로직 확정. 플랜트6 HC900 통신 살아있음(live값 가공이라도 경로 테스트 가능, §0.2). `ff_column_config` C-6111 advisory_only=t 이미 설정.
|
||||
|
||||
**단계**:
|
||||
1. **모델 산출물 export**: Python에서 학습한 steam맵(GBM)·밸브역특성·envelope를 경량 포맷(JSON 계수/룩업 또는 ONNX)으로 export. (선형근사 `steam≈0.73·feed+보정`이면 C#에서 직접 계산 가능 — 단순화 권장.)
|
||||
1. **모델 산출물 export** — **(1안 권장) JSON 계수→C# 직접 계산**: 선형/룩업 계수(`steam≈0.73·feed+보정`), 밸브역특성 3차계수, envelope min/max를 JSON으로 export. 단순·투명·의존성 0. **(2안 fallback) ONNX**: 선형근사가 부족하고 GBM 정확도가 꼭 필요할 때만. **우선 1안으로 시작.**
|
||||
2. **C# Infrastructure/Control에 예측기**: 기존 `FeedforwardSupervisor`/`FeedRampAdvisor` 옆에 `SteamAdvisor` 추가. gRPC 실시간 태그(피드 FICQ-6101, 제품 6118, T_C TI-6111C, reb-A, 진공 등) 읽어 권장 OP 산출.
|
||||
3. **운전모드 분류 + OOD** C#에 포팅(임계 §16.3-2, envelope §16.7).
|
||||
4. shadow 로깅: `FfTrackingStore`에 권장 vs 실제 OP 기록. 화면 노출(advisory).
|
||||
@@ -110,11 +206,30 @@
|
||||
|
||||
**주의**: live값이 현재 시뮬/가공 → 정확도 평가는 field_hist 백테스트로, live는 통합·안전 검증용. write 금지. 실제 실데이터 확보 시 재검증.
|
||||
|
||||
**✅ 진행완료 (2026-06-05)**:
|
||||
- `c6111_export_model.py` — 선형근사(1안) JSON export: linear 회귀계수·밸브역특성 3차계수·envelope min/max.
|
||||
- C6-1 선형 R²=0.986(GBM 0.995 대비 99% 설명). C6-2 선형 R²=0.996(GBM 0.998).
|
||||
- `SteamAdvisor.cs` (`src/Infrastructure/Control/`): `Predict(feed, product, tC)→SteamAdvisoryResult`, `ClassifyMode()`, `InEnvelope()`.
|
||||
- `SteamAdvisorController.cs` (`src/Hc900Crawler/Controllers/`): `GET /api/steam/health`, `GET/POST /api/steam/predict`.
|
||||
- `Program.cs` DI 등록 완료(`AddSingleton<SteamAdvisor>()`). `appsettings.json`에 `SteamAdvisor:ModelPath` 설정.
|
||||
|
||||
**⚠️ 선형근사 한계**: P8(0.659)·P9(0.161)·P10(0.202)는 선형 R² 낮음 → **2안(ONNX 또는 Python shadow 호출)** 으로 전환 검토 필요.
|
||||
|
||||
---
|
||||
|
||||
## 권장 순서
|
||||
**작업1(형제확장) → 작업2(shutdown) → 작업3(operator-assist) → 작업4(live포팅)**.
|
||||
1·2는 분석 연장(코드 재사용·샘플보강), 3·4는 현장 투입. 3 전에 1로 다컬럼 일반화를 끝내면 assist가 전 컬럼 커버.
|
||||
|
||||
**✅ 1·2·3 완료 (2026-06-05).** ~~작업4(live포팅)만 남음.~~
|
||||
|
||||
## 진행 현황
|
||||
|
||||
| 작업 | 상태 | 산출물 | 검증 |
|
||||
|:----|:----:|:-------|:----:|
|
||||
| 1. 형제 컬럼 확장 | ✅ 완료 | `run_column.py`, 각 `c{prefix}_*.png` | 6-1(0.993)✅ 6-2(0.997)✅ 8(0.630)❌ 9(0.886)❌ 10(0.818)❌ |
|
||||
| 2. SHUTDOWN | ✅ 완료 | `c6111_shutdown.py`, 각 컬럼 shutdown 플롯 | 6-1(4건 일관)✅ 6-2(5건 일관)✅ 9(22건, 3유형 분류) |
|
||||
| 3. Operator Assist | ✅ 완료 | `c6111_operator_assist.py`, 각 컬럼 advisory 리포트 | 6-1(92.2%)✅ 6-2(93.1%)✅ 8(84.3%)❌ 9(80.8%)❌ |
|
||||
| 4. C# Live 포팅 | ✅ 완료 | `SteamAdvisor.cs`/`Controller.cs`, `c6111_export_model.py` | 빌드 0 errors ✅ API `/api/steam/predict` |
|
||||
|
||||
## 공통 참조
|
||||
- 디코드/데이터: 플랜 §15. C-6111 토폴로지: §16.1. 방법론 교훈: §16.6(운전점), §16.7(OOD), §16.8(롤링).
|
||||
|
||||
178
docs/진단보고서-작업1-4.md
Normal file
178
docs/진단보고서-작업1-4.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# 진단 보고서 — 학습형 제어 (작업 1~4)
|
||||
|
||||
> 진단 일시: 2026-06-05
|
||||
> 진단 규칙: `diagnosis-checklist.md` 8단계
|
||||
> 진단 범위: `scripts/analysis/` (run_column, shutdown, operator_assist, export_model) + `src/Infrastructure/Control/SteamAdvisor.cs` + `src/Hc900Crawler/Controllers/SteamAdvisorController.cs` + `Program.cs` 수정사항
|
||||
> 이전 보고서: `docs/진단보고서-학습형제어-1차.md` (작업지시서 문서 진단, c6111_*.py 기존 분석기)
|
||||
|
||||
---
|
||||
|
||||
## 🔴 HIGH — 없음
|
||||
|
||||
---
|
||||
|
||||
## 🟠 MED
|
||||
|
||||
### M1. `SteamAdvisor.Predict()` 반환값 `Ood`/`InEnv` 하드코딩 오류
|
||||
|
||||
**문제**: PROD 모드 분기에서 `inEnv=false`여도 반환 `SteamAdvisoryResult`의 `Ood=false`, `InEnv=true`로 항상 고정. 클라이언트가 `confidence`가 아닌 `Ood`/`InEnv`로 판단하면 범위밖 입력을 구간내로 오인. `message`("⚠ 범위밖 입력")와 `confidence`("LOW_OOD")는 정상이나, 구조화 필드가 실제와 불일치.
|
||||
**근거**: `src/Infrastructure/Control/SteamAdvisor.cs:107` — `Ood = false, InEnv = true` 하드코딩. 의도된 값은 `Ood = !inEnv, InEnv = inEnv`.
|
||||
**영향**: `confidence`는 정상이나 `ood:false, inEnv:true`로 응답받은 API 클라이언트가 advisory를 수용할 위험. 또는 `confidence`만 보고 무시하더라도 데이터 파이프라인(로깅·추세)에 잘못된 envelope 정보 기록.
|
||||
**수정**: 1줄 변경.
|
||||
```csharp
|
||||
// before (SteamAdvisor.cs:107)
|
||||
Ood = false, InEnv = true,
|
||||
// after
|
||||
Ood = !inEnv, InEnv = inEnv,
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### M2. `c6111_shutdown.py` 피드감소 시작점 탐색창 부족
|
||||
|
||||
**문제**: `shutdown_milestones()`의 feed 감소 시작 탐색이 600행(5시간) lookback으로 제한. P9/P10처럼 선행 피드감소 시간이 긴 shutdown 이벤트에서 탐색창 끝에 도달해도 feed 감소 미발견 → `feed_to_cutoff=299.5분`(최대값) 기록.
|
||||
**근거**: `scripts/analysis/c6111_shutdown.py:39` — `for j in range(co, max(0, co - 600), -1)`. 600행 = 5시간(30s 주기 기준). P9 5/22건이 299.5분 기록.
|
||||
**영향**: 해당 건의 `feed_to_cutoff`가 실제보다 짧게 추정되어 셧다운 레시피의 "피드감소→컷오프" 중앙값 왜곡(현재 55분 → 실제는 더 길 수 있음).
|
||||
**수정**: lookback을 `co - 1200`(10시간)으로 확장. 또는 feed 안정화 판정(`rolling std < threshold`)을 동적 종료 조건으로 추가.
|
||||
```python
|
||||
# c6111_shutdown.py:39
|
||||
for j in range(co, max(0, co - 1200), -1):
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### M3. `SteamAdvisor.Predict()` NaN 입력 무방비
|
||||
|
||||
**문제**: `feed`·`product`·`tC` 인자가 `double.NaN`이면 선형 계산 `NaN` → `PolyVal(NaN)` → `NaN` → `Math.Clamp(NaN, 0, 100) = NaN` → HTTP 응답 `rec_OP: NaN`. 라이브 데이터에서 HC900 통신 끊김 등으로 quality 불량 시 NaN이 유입되면 API가 NaN을 그대로 반환.
|
||||
**근거**: `src/Infrastructure/Control/SteamAdvisor.cs:65-108` — Predict() 입구에 NaN 사전 차단 없음. `double.IsNaN()` 검증 생략.
|
||||
**영향**: `GET /api/steam/predict?feed=NaN&product=300&tC=85` → `{"rec_OP": NaN, ...}`. JSON에 `NaN`은 유효하지 않아 HTTP 500 또는 클라이언트 파싱 실패 유발.
|
||||
**수정**: Predict() 선두에 NaN 가드.
|
||||
```csharp
|
||||
if (double.IsNaN(feed) || double.IsNaN(product) || double.IsNaN(tC))
|
||||
return new SteamAdvisoryResult {
|
||||
Message = "입력값에 NaN 포함", Confidence = "N/A",
|
||||
Mode = "INVALID", Feed = feed, Product = product, TC = tC };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🟡 LOW
|
||||
|
||||
### L4. Python `BASE` 경로 하드코딩 7개 파일
|
||||
|
||||
**문제**: `c6111_shutdown.py`·`operator_assist.py`·`export_model.py`·`prodmap.py`·`shadow.py`·`startup.py`·`rolling.py` 7개 파일에 `BASE = "/home/windpacer/projects/hc900_ax/scripts/analysis/"` 절대경로 하드코딩. `run_column.py`만 동적(`os.path.dirname(os.path.abspath(__file__))`) 사용.
|
||||
**근거**: 각 파일의 `BASE` 상수 선언부.
|
||||
**영향**: 저장소 이동 시 plot 저장 실패. 단, `--data`/`--prefix` CLI 인자로 runtime override 가능.
|
||||
**수정**: `run_column.py`와 통일하여 `os.path.dirname(os.path.abspath(__file__))`로 변경.
|
||||
|
||||
### L5. `SteamAdvisor.PolyVal` 계수 부족 시 pass-through (무경고)
|
||||
|
||||
**문제**: `ValvePoly` 계수가 4개 미만이면 `coeffs[0]*x^3 + ...` 계산 시 IndexOutOfRange 전에 `if (coeffs.Count < 4) return x;`로 빠짐 → 로그 없이 입력값을 OP로 반환. export 불량 발견 불가.
|
||||
**근거**: `SteamAdvisor.cs:140-141`.
|
||||
**영향**: valve poly가 3차(4계수)가 아닌 모델을 로드해도 OP 계산 오류를 감지 불가.
|
||||
**수정**: 경고 로그 추가. `_logger.LogWarning(...)` 후 pass-through.
|
||||
|
||||
### L6. 컨트롤러 입력 검증 속성 누락
|
||||
|
||||
**문제**: `SteamAdvisorController`의 `[FromQuery] double feed` 등에 `[Required]`·`[Range]` 속성 없음. `POST /api/steam/predict` body `SteamPredictBody`에도 검증 속성 없음. 인자 생략 시 `0.0` 자동 전달.
|
||||
**근거**: `src/Hc900Crawler/Controllers/SteamAdvisorController.cs:23,33,41`.
|
||||
**영향**: 생략된 인자가 0으로 전달되어 비현실적인 권장 OP 산출.
|
||||
**수정**: `[FromQuery]` 파라미터에 `[Required]` 추가, `PredictRequest`에 `[Range(0, double.MaxValue)]` 속성.
|
||||
|
||||
### L7. `run_column.compare()` cutin 탐지 로직 중복
|
||||
|
||||
**문제**: `compare()` 함수 내에 `startup.py/detect_cutins`와 동일한 product 상향엣지 탐지 로직이 인라인 복사되어 있음. `startup.py`의 `detect_cutins` 로직이 변경되면 `compare()`의 결과와 불일치 발생.
|
||||
**근거**: `run_column.py:103-112` vs `c6111_startup.py:16-31`. 96% 동일 코드.
|
||||
**영향**: startup 탐지 알고리즘 개선 시 비교표가 업데이트되지 않아 검증 결과 혼선.
|
||||
**수정**: `compare()`에서 `from c6111_startup import detect_cutins` 호출로 대체.
|
||||
|
||||
### L8. `c6111_export_model.py` R² on training set
|
||||
|
||||
**문제**: `LinearRegression.score()`와 `GradientBoostingRegressor.score()`가 모두 `fit()`에 사용한 동일 데이터셋으로 평가. train/test 분할 없어 R²가 과대추정됨. export된 JSON의 `linear_r2`와 `gbm_r2`가 실제 일반화 성능보다 높게 표시.
|
||||
**근거**: `c6111_export_model.py:39,55`. `ops[FEATURES]`를 fit과 score에 동일 사용.
|
||||
**영향**: C# 측에서 `SteamModel.LinearR2`를 신뢰도 지표로 사용 시 과신. 현행 C# 코드는 R²를 비즈니스 로직에 사용하지 않아 실질 영향 없음.
|
||||
**수정**: `train_test_split`으로 분할 후 held-out R²도 export.
|
||||
|
||||
### L9. `run_column.py` DSN 평문 하드코딩
|
||||
|
||||
**문제**: PostgreSQL 연결문자열 `"host=localhost port=5432 dbname=field_hist user=postgres password=postgres"`가 소스코드에 평문 하드코딩. 버전관리 대상 파일.
|
||||
**근거**: `run_column.py:28`.
|
||||
**영향**: Dev DB 전용(로컬 PG16 컨테이너), 실제 민감정보 아님. 단, 리포지토리가 외부에 공개될 때 내부 DB 구조 노출.
|
||||
**수정**: 환경변수 `PG_DSN` 또는 `.env` 파일에서 읽도록 변경.
|
||||
|
||||
### L10. `c6111_operator_assist.py` unused import
|
||||
|
||||
**문제**: `import pickle`이 파일 상단에 선언되었으나 코드 어디에서도 `pickle`을 사용하지 않음.
|
||||
**근거**: `c6111_operator_assist.py:10`. 파일 내 `pickle.` 호출 0건.
|
||||
**영향**: 없음(컴파일 타임 영향 없음).
|
||||
**수정**: `import pickle` 삭제.
|
||||
|
||||
### L11. shutdown 컷오프 0건 시 플롯 루프 비정상
|
||||
|
||||
**문제**: `detect_cutoffs()`가 빈 리스트 반환 시 `for k, w in enumerate(windows)` 루프가 0회 반복 → plot이 비어 있음. matplotlib 경고(`UserWarning: No artists with labels...`) 발생.
|
||||
**근거**: `c6111_shutdown.py:110-118`. `windows`가 빈 리스트일 때 plot만 생성되고 아무것도 그려지지 않음.
|
||||
**영향**: P10 등 shutdown 이벤트 없는 컬럼에서 빈 플롯 파일 생성. 기능상 문제는 없으나 사용자 혼란.
|
||||
**수정**: `if not windows: print(" [skip] shutdown 이벤트 없음"); return` 추가.
|
||||
|
||||
### L12. `PredictAsync` 동기 구현 — CancellationToken 미사용
|
||||
|
||||
**문제**: `PredictAsync()`가 항상 `Task.FromResult(Predict(...))`로 동기 실행. `CancellationToken` 파라미터를 전혀 사용하지 않음.
|
||||
**근거**: `SteamAdvisor.cs:112-116`.
|
||||
**영향**: 현재는 항상 동기(fast operation)여서 실질 문제 없음. 비동기 체인에서 호출 시 불필요한 Task 할당만 발생.
|
||||
**수정**: `Task.FromResult` 대신 `ValueTask.FromResult` 사용하거나, 실제 async 경로(DB 조회 등) 추가 시 token 전파.
|
||||
|
||||
---
|
||||
|
||||
## 교차 검증 통과 내역
|
||||
|
||||
| # | 항목 | Q1(기수정?) | Q2(타레이어?) | Q3(의도?) | Q4(재현?) | 최종 |
|
||||
|---|------|:----------:|:------------:|:--------:|:--------:|:----:|
|
||||
| M1 | Ood/InEnv 하드코딩 | No | No | No(실수) | Yes | MED |
|
||||
| M2 | feed_start 299.5 | No | No | No | Yes | MED |
|
||||
| M3 | NaN 무방비 | No | No | No | Yes | MED |
|
||||
| L4 | BASE 하드코딩 | No | 부분완화 | No | Yes | LOW |
|
||||
| L5 | PolyVal 무경고 | No | - | No | 조건부 | LOW |
|
||||
| L6 | Controller 검증누락 | No | No | No | 조건부 | LOW |
|
||||
| L7 | cutin 중복 | No | 독립함수 | No | 조건부 | LOW |
|
||||
| L8 | R² train-only | No | 정보용 | No | No | LOW |
|
||||
| L9 | DSN 평문 | No | Dev전용 | No | No | LOW |
|
||||
| L10 | pickle 미사용 | No | - | No(생략) | No | LOW |
|
||||
| L11 | shutdown empty | No | - | No | Yes | LOW |
|
||||
| L12 | async 동기 | No | - | 설계(주석) | No | LOW |
|
||||
|
||||
---
|
||||
|
||||
## 자가 검증
|
||||
|
||||
- [x] 각 지적 사항을 "현재 파일 몇 번 줄"로 직접 가리킴
|
||||
- [x] MED 3건 모두 재현 가능한 시나리오 서술
|
||||
- [x] 교차 검증 4개 질문을 모두 통과한 항목만 포함
|
||||
- [x] 수정 예시가 현재 코드에 아직 적용되지 않은 내용
|
||||
- [x] "더 좋은 방법 제안"과 "현재 코드가 틀렸다" 혼동하지 않음
|
||||
|
||||
---
|
||||
|
||||
## 변경 요약
|
||||
|
||||
| 심각도 | 건수 | 즉시 수정 권장 | 상태 |
|
||||
|:-------:|:---:|:--------------:|:----:|
|
||||
| HIGH | 0 | — | — |
|
||||
| MED | 3 | M1(Ood): **즉시**, M3(NaN): **즉시**, M2(feed_start): 분석 재실행 필요 | **✅ 전항 수정 완료** |
|
||||
| LOW | 8+1 | 리팩터링 시 일괄 | **✅ 4건 수정 완료** |
|
||||
|
||||
### 수정 내역 (2026-06-05)
|
||||
|
||||
| 항목 | 상태 | 변경 내용 |
|
||||
|:----|:----:|:---------|
|
||||
| **M1** Ood/InEnv 하드코딩 | ✅ 수정 | `SteamAdvisor.cs:107` — `Ood=false,InEnv=true` → `Ood=!inEnv,InEnv=inEnv` |
|
||||
| **M2** feed_start lookback 부족 | ✅ 수정+분석재실행 | `c6111_shutdown.py:45` — 600→1200행 확장. P9 `feed_to_cutoff` 중앙값 55→99분 개선 (6/22건은 여전히 599.5로 10h ceiling 도달) |
|
||||
| **M3** NaN 입력 무방비 | ✅ 수정 | `SteamAdvisor.cs:69-71` — `double.IsNaN` 가드 추가 |
|
||||
| **L5** PolyVal 무경고 | ✅ 수정 | `SteamAdvisor.cs:142` — 계수 부족 시 `_logger.LogWarning` |
|
||||
| **L10** pickle 미사용 | ✅ 수정 | `c6111_operator_assist.py:10` — `import pickle` 삭제 |
|
||||
| **L11** shutdown empty plot | ✅ 수정 | `c6111_shutdown.py:99-101` — 컷오프 0건 시 조기 return |
|
||||
| **L12** async misleading | ✅ 수정 | `SteamAdvisor.cs:112` — `Task`→`ValueTask`, `CancellationToken` 연동 |
|
||||
| **L4** BASE 하드코딩 | ⬜ 보류 | 8개 파일 — 추후 리팩터링 |
|
||||
| **L6** Controller 검증 누락 | ⬜ 보류 | `[Required]`·`[Range]` 속성 |
|
||||
| **L7** cutin 중복 | ⬜ 보류 | `from c6111_startup import detect_cutins` |
|
||||
| **L8** R² train-only | ⬜ 보류 | train/test 분할 |
|
||||
| **L9** DSN 평문 | ⬜ 보류 | 환경변수 전환 |
|
||||
80
scripts/analysis/c6111_export_model.py
Normal file
80
scripts/analysis/c6111_export_model.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
모델 JSON export → C# SteamAdvisor에서 로드.
|
||||
|
||||
선형근사(1안): GBM 대신 LinearRegression 계수 export.
|
||||
steam = w0 + w1*feed + w2*product + w3*T_C
|
||||
valve_inv(flow) = poly3 → OP
|
||||
|
||||
사용법:
|
||||
python3 c6111_export_model.py --data c6111_data.pkl --prefix c6111
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sklearn.linear_model import LinearRegression
|
||||
|
||||
BASE = "/home/windpacer/projects/hc900_ax/scripts/analysis/"
|
||||
FEATURES = ["feed", "product", "T_C"]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--data", default=BASE + "c6111_data.pkl")
|
||||
parser.add_argument("--prefix", default="c6111")
|
||||
parser.add_argument("--output", help="JSON 출력 경로 (기본: scripts/analysis/{prefix}_model.json)")
|
||||
args = parser.parse_args()
|
||||
df = pd.read_pickle(args.data)
|
||||
|
||||
prod = df[df["mode"] == "PROD"].copy()
|
||||
prod = prod[(prod["feed"] > 50) & (prod["steam_flow"] > 10) & (prod["steam_op"] > 1)]
|
||||
prod = prod.dropna(subset=FEATURES + ["steam_op", "steam_flow"])
|
||||
ops = (prod.set_index("dtat").resample("6h").median(numeric_only=True)
|
||||
.dropna(subset=["steam_flow", "feed"]))
|
||||
ops = ops[ops["feed"] > 50]
|
||||
|
||||
# 선형 모델
|
||||
lr = LinearRegression()
|
||||
lr.fit(ops[FEATURES].values, ops["steam_flow"].values)
|
||||
r2 = lr.score(ops[FEATURES].values, ops["steam_flow"].values)
|
||||
print(f"선형 steam_flow R² = {r2:.4f} (GBM 대비 비교용)")
|
||||
|
||||
# 밸브 역특성: steam_flow → steam_op (3차)
|
||||
vp = np.polyfit(prod["steam_flow"], prod["steam_op"], 3)
|
||||
|
||||
# Envelope (1%, 99%)
|
||||
lo = ops[FEATURES].quantile(0.01)
|
||||
hi = ops[FEATURES].quantile(0.99)
|
||||
|
||||
# GBM feature importance (참고용)
|
||||
try:
|
||||
from sklearn.ensemble import GradientBoostingRegressor
|
||||
gbm = GradientBoostingRegressor(n_estimators=200, max_depth=2,
|
||||
learning_rate=0.05, random_state=0)
|
||||
gbm.fit(ops[FEATURES].values, ops["steam_flow"].values)
|
||||
gbm_r2 = gbm.score(ops[FEATURES].values, ops["steam_flow"].values)
|
||||
except Exception:
|
||||
gbm_r2 = None
|
||||
|
||||
model = {
|
||||
"column": args.prefix,
|
||||
"features": FEATURES,
|
||||
"linear_coeffs": lr.coef_.tolist(),
|
||||
"intercept": lr.intercept_,
|
||||
"linear_r2": round(r2, 4),
|
||||
"gbm_r2": round(gbm_r2, 4) if gbm_r2 else None,
|
||||
"valve_poly": vp.tolist(),
|
||||
"envelope_lo": {c: round(float(lo[c]), 1) for c in FEATURES},
|
||||
"envelope_hi": {c: round(float(hi[c]), 1) for c in FEATURES},
|
||||
"n_operating_points": len(ops),
|
||||
"n_prod_rows": len(prod),
|
||||
}
|
||||
out = args.output or (BASE + f"{args.prefix}_model.json")
|
||||
with open(out, "w") as f:
|
||||
json.dump(model, f, indent=2)
|
||||
print(f"\n모델 export: {out}")
|
||||
print(json.dumps(model, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,10 +1,17 @@
|
||||
"""
|
||||
C-6111 (6-1차 측류 정제 컬럼) 데이터 추출 + 운전모드 1차 특성 분석.
|
||||
컬럼 데이터 추출 + 운전모드 1차 특성 분석.
|
||||
|
||||
field_hist DB(shinam 실데이터, WIDE 포맷)에서 ptlist/mapping/tblist로 태그를 디코드해
|
||||
tidy DataFrame을 만든다. 재사용 가능한 tag_frame() 추출기 포함.
|
||||
|
||||
근거: docs/학습형제어-오퍼레이터모방-플랜.md §15(디코드), §16(C-6111 토폴로지).
|
||||
|
||||
형제 컬럼 확장: roles_for(prefix, asset)로 파라미터화.
|
||||
- 6-1: prefix=61, asset=/ASSETS/P6 (기본)
|
||||
- 6-2: prefix=62, asset=/ASSETS/P6
|
||||
- 8: prefix=81, asset=/ASSETS/P8
|
||||
- 9: prefix=91, asset=/ASSETS/P9 (또는 92)
|
||||
- 10: prefix=101, asset=/ASSETS/P10 (또는 102)
|
||||
"""
|
||||
import sys
|
||||
import psycopg
|
||||
@@ -13,27 +20,78 @@ import pandas as pd
|
||||
DSN = "host=localhost port=5432 dbname=field_hist user=postgres password=postgres"
|
||||
ASSET = "/ASSETS/P6"
|
||||
|
||||
# C-6111 역할별 태그 (ff_column_config/ff_stream_config + 사용자 도메인, 플랜 §16.1)
|
||||
ROLES = {
|
||||
"feed": "FICQ-6101.PV", # 피드(주 외란)
|
||||
"steam_op": "TICA-6111A.OP", # 리보일러 스팀 밸브(조작/OP)
|
||||
"steam_flow": "FIQ-6115.PV", # 실제 스팀 유량
|
||||
"reb_temp": "TICA-6111A.PV", # 리보일러 온도(A, 최고온)
|
||||
"T_B": "TI-6111B.PV", # 피드존
|
||||
"T_C": "TI-6111C.PV", # 민감단(제품 추출 트레이 근처)
|
||||
"T_D": "TI-6111D.PV", # 탑상(최저온)
|
||||
"feed_preheat": "TI-6103.PV", # 원료 예열
|
||||
"vacuum": "PICA-6111.PV", # 진공압력
|
||||
"dp": "PI-6111B.PV", # 컬럼 차압
|
||||
"product": "FICQ-6118.PV", # 측류 제품 P
|
||||
"reflux": "FICQ-6113.PV", # 리플럭스 R
|
||||
"light": "FICQ-6114.PV", # 경질분 제거 D
|
||||
"heavy": "FICQ-6116.PV", # 중질분 제거 B
|
||||
"reb_level": "LI-6111.PV", # 리보일러 레벨
|
||||
"reflux_drum": "LICA-6113.PV", # 리플럭스 드럼 레벨
|
||||
# --- 형제 컬럼 역할 생성기 ---
|
||||
# DB 검증 결과(2026-06-05) 기반 예외 오버라이드:
|
||||
# P8(81): TICA에 A/B/C/D 접미사 없음, PICA-8111A (with A suffix)
|
||||
# P9(91): PICA-9111A (with A suffix). 92xx 2차 컬럼 존재
|
||||
# P10(101): FICQ-10114A (not 10114), PICA-10111A, LIA-10111 (not LICA). 102xx 2차 컬럼 존재
|
||||
COLUMN_EXCEPTIONS = {
|
||||
"81": {
|
||||
"steam_op": "TICA-8111.OP",
|
||||
"reb_temp": "TICA-8111.PV",
|
||||
"vacuum": "PICA-8111A.PV",
|
||||
},
|
||||
"91": {
|
||||
"vacuum": "PICA-9111A.PV",
|
||||
},
|
||||
"92": {
|
||||
"vacuum": "PICA-9211A.PV",
|
||||
},
|
||||
"101": {
|
||||
"light": "FICQ-10114A.PV",
|
||||
"vacuum": "PICA-10111A.PV",
|
||||
"reflux_drum": "LIA-10111.PV",
|
||||
},
|
||||
"102": {
|
||||
"light": "FICQ-10214.PV",
|
||||
"vacuum": "PICA-10211A.PV",
|
||||
"reflux_drum": "LIA-10211.PV",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def roles_for(prefix, asset=ASSET):
|
||||
"""{role: shorttag} dict 생성. prefix 예: '61', '62', '81', '91', '101'.
|
||||
|
||||
Base 규칙(6-1 기준, docs/작업지시서-학습형제어-다음단계.md 작업1):
|
||||
feed=FICQ-{p}01, reflux=FICQ-{p}13, light(D)=FICQ-{p}14,
|
||||
heavy(B)=FICQ-{p}16, product(P)=FICQ-{p}18,
|
||||
steam_op=TICA-{p}11A.OP, reb_temp=TICA-{p}11A.PV,
|
||||
steam_flow=FIQ-{p}15, T_B=TI-{p}11B, T_C=TI-{p}11C, T_D=TI-{p}11D,
|
||||
vacuum=PICA-{p}11.PV, dp=PI-{p}11B.PV,
|
||||
reb_level=LI-{p}11.PV, reflux_drum=LICA-{p}13.PV,
|
||||
feed_preheat=TI-{p}03.PV
|
||||
|
||||
COLUMN_EXCEPTIONS에 등록된 prefix는 자동 오버라이드.
|
||||
"""
|
||||
p = prefix
|
||||
roles = {
|
||||
"feed": f"FICQ-{p}01.PV",
|
||||
"steam_op": f"TICA-{p}11A.OP",
|
||||
"steam_flow": f"FIQ-{p}15.PV",
|
||||
"reb_temp": f"TICA-{p}11A.PV",
|
||||
"T_B": f"TI-{p}11B.PV",
|
||||
"T_C": f"TI-{p}11C.PV",
|
||||
"T_D": f"TI-{p}11D.PV",
|
||||
"feed_preheat": f"TI-{p}03.PV",
|
||||
"vacuum": f"PICA-{p}11.PV",
|
||||
"dp": f"PI-{p}11B.PV",
|
||||
"product": f"FICQ-{p}18.PV",
|
||||
"reflux": f"FICQ-{p}13.PV",
|
||||
"light": f"FICQ-{p}14.PV",
|
||||
"heavy": f"FICQ-{p}16.PV",
|
||||
"reb_level": f"LI-{p}11.PV",
|
||||
"reflux_drum": f"LICA-{p}13.PV",
|
||||
}
|
||||
ov = COLUMN_EXCEPTIONS.get(prefix, {})
|
||||
roles.update(ov)
|
||||
return roles
|
||||
|
||||
|
||||
# C-6111 (6-1) 역할별 태그 — legacy 직접 참조 호환용
|
||||
ROLES = roles_for("61", ASSET)
|
||||
|
||||
|
||||
def resolve(conn, shorttags, asset=ASSET):
|
||||
"""shortptname 목록 -> {tag: (tblname, colnum)}"""
|
||||
with conn.cursor() as cur:
|
||||
|
||||
193
scripts/analysis/c6111_operator_assist.py
Normal file
193
scripts/analysis/c6111_operator_assist.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
Operator-assist 패키징 (작업3).
|
||||
|
||||
사용법:
|
||||
python3 c6111_operator_assist.py --data c61_data.pkl --prefix c61
|
||||
python3 c6111_operator_assist.py --data c61_data.pkl --prefix c61 --live '{"feed":500,"product":300,"T_C":84.7}'
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sklearn.ensemble import IsolationForest
|
||||
|
||||
BASE = "/home/windpacer/projects/hc900_ax/scripts/analysis/"
|
||||
FEATURES = ["feed", "product", "T_C"]
|
||||
PROD_SMOOTH = 40
|
||||
|
||||
|
||||
class OperatorAssist:
|
||||
def __init__(self, df):
|
||||
self.df = df
|
||||
self.mode = "UNKNOWN"
|
||||
self.model = None
|
||||
self.inv = None
|
||||
self.ood = None
|
||||
self.env_lo = None
|
||||
self.env_hi = None
|
||||
self._train()
|
||||
|
||||
def _train(self):
|
||||
prod = self.df[self.df["mode"] == "PROD"].copy()
|
||||
prod = prod[(prod["feed"] > 50) & (prod["steam_flow"] > 10) & (prod["steam_op"] > 1)]
|
||||
prod = prod.dropna(subset=FEATURES + ["steam_op", "steam_flow"])
|
||||
if len(prod) < 100:
|
||||
print(" [WARN] PROD 데이터 부족 — advisory 신뢰도 낮음")
|
||||
points = (prod.set_index("dtat").resample("6h").median(numeric_only=True)
|
||||
.dropna(subset=["steam_flow", "feed"]))
|
||||
points = points[points["feed"] > 50]
|
||||
from sklearn.ensemble import GradientBoostingRegressor
|
||||
self.model = GradientBoostingRegressor(n_estimators=200, max_depth=2,
|
||||
learning_rate=0.05, random_state=0)
|
||||
self.model.fit(points[FEATURES].values, points["steam_flow"].values)
|
||||
self.inv = np.polyfit(prod["steam_flow"], prod["steam_op"], 3)
|
||||
self.env_lo = points[FEATURES].quantile(0.01)
|
||||
self.env_hi = points[FEATURES].quantile(0.99)
|
||||
self.ood = IsolationForest(contamination=0.05, random_state=0).fit(points[FEATURES].values)
|
||||
print(f" 학습 운전점: {len(points)}개 envelope:")
|
||||
for c in FEATURES:
|
||||
print(f" {c}: [{self.env_lo[c]:.0f}, {self.env_hi[c]:.1f}]")
|
||||
|
||||
def classify_mode(self, tags):
|
||||
"""tags dict → mode 추정 (classify_phases 단순 replica).
|
||||
|
||||
steam_op 없으면 feed/product로 판단 (live advisory용).
|
||||
"""
|
||||
prod = tags.get("product", 0)
|
||||
feed = tags.get("feed", 0)
|
||||
steam = tags.get("steam_op", None)
|
||||
reb = tags.get("reb_temp", 60)
|
||||
if prod > 100:
|
||||
if steam is None or steam > 10:
|
||||
return "PROD"
|
||||
if steam is not None:
|
||||
if steam > 10 and reb > 60:
|
||||
return "LINEOUT"
|
||||
if steam > 10 and feed < 50:
|
||||
return "STARTUP"
|
||||
if feed > 50:
|
||||
return "PROD" # fallback: steam_op 없이 feed>50 + product>100는 PROD
|
||||
return "STOPPED"
|
||||
|
||||
def in_envelope(self, tags):
|
||||
x = np.array([[tags[c] for c in FEATURES]])
|
||||
return ((x >= self.env_lo.values) & (x <= self.env_hi.values)).all()
|
||||
|
||||
def ood_score(self, tags):
|
||||
return self.ood.decision_function(np.array([[tags[c] for c in FEATURES]]))[0]
|
||||
|
||||
def predict(self, tags, smooth_history=None):
|
||||
"""live_tags dict → advisory dict.
|
||||
|
||||
tags: {"feed": float, "product": float, "T_C": float}
|
||||
smooth_history: optional list of prior tag dicts for causal smoothing
|
||||
|
||||
Returns:
|
||||
{"rec_OP": float or None, "rec_steam": float, "confidence": str,
|
||||
"mode": str, "ood": bool, "in_env": bool, "message": str}
|
||||
"""
|
||||
mode = self.classify_mode(tags)
|
||||
self.mode = mode
|
||||
env = self.in_envelope(tags)
|
||||
ood = self.ood_score(tags) < 0
|
||||
raw = np.array([[[tags[c] for c in FEATURES]]])
|
||||
|
||||
if mode != "PROD":
|
||||
msg = f"운전모드={mode} — advisory는 PROD에서만 제공 (STARTUP/LINEOUT은 레시피 참조)"
|
||||
return {"rec_OP": None, "rec_steam": None, "confidence": "N/A",
|
||||
"mode": mode, "ood": ood, "in_env": env, "message": msg}
|
||||
|
||||
# smooth: causal trailing median over recent history
|
||||
if smooth_history and len(smooth_history) >= PROD_SMOOTH:
|
||||
buf = pd.DataFrame(smooth_history[-PROD_SMOOTH:])[FEATURES].median()
|
||||
x = np.array([[buf[c] for c in FEATURES]])
|
||||
else:
|
||||
x = raw[0]
|
||||
|
||||
sf = self.model.predict(x)[0]
|
||||
op = np.clip(np.polyval(self.inv, sf), 0, 100)
|
||||
|
||||
if not env:
|
||||
confidence = "LOW_OOD"
|
||||
msg = (f"⚠ 범위밖 입력 — 권장 OP={op:.1f}% (외삽, 신뢰도 낮음). "
|
||||
"오퍼레이터 판단 우선")
|
||||
elif ood:
|
||||
confidence = "MEDIUM"
|
||||
msg = f"권장 OP={op:.1f}% (신뢰: 구간내, IForest 이상감지 — 주의)"
|
||||
else:
|
||||
confidence = "HIGH"
|
||||
msg = f"권장 OP={op:.1f}% (신뢰: 구간내)"
|
||||
|
||||
return {"rec_OP": round(op, 1), "rec_steam": round(sf, 1),
|
||||
"confidence": confidence, "mode": mode, "ood": bool(ood),
|
||||
"in_env": bool(env), "feed": float(x[0][0]),
|
||||
"product": float(x[0][1]), "T_C": float(x[0][2]),
|
||||
"message": msg}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--data", default=BASE + "c6111_data.pkl")
|
||||
parser.add_argument("--prefix", default="c6111")
|
||||
parser.add_argument("--live", help='JSON live_tags for single predict test')
|
||||
args = parser.parse_args()
|
||||
df = pd.read_pickle(args.data)
|
||||
assist = OperatorAssist(df)
|
||||
|
||||
if args.live:
|
||||
tags = json.loads(args.live)
|
||||
res = assist.predict(tags)
|
||||
print(f"\n=== Operator Advisory ({args.prefix}) ===")
|
||||
for k, v in res.items():
|
||||
print(f" {k:15s}: {v}")
|
||||
return
|
||||
|
||||
# 전체 shadow 리플레이: PROD 행 벡터화 처리
|
||||
prod = df[df["mode"] == "PROD"].sort_values("dtat").copy()
|
||||
prod = prod[(prod["feed"] > 50) & (prod["steam_flow"] > 10) & (prod["steam_op"] > 1)
|
||||
& prod[FEATURES + ["steam_op"]].notna().all(axis=1)]
|
||||
if len(prod) == 0:
|
||||
print(" PROD 없음 — advisory 불가")
|
||||
return
|
||||
|
||||
X = prod[FEATURES].values
|
||||
sf = assist.model.predict(X)
|
||||
op = np.clip(np.polyval(assist.inv, sf), 0, 100)
|
||||
env_mask = ((X >= assist.env_lo.values) & (X <= assist.env_hi.values)).all(axis=1)
|
||||
ood_mask = assist.ood.decision_function(X) < 0
|
||||
errors = op - prod["steam_op"].values
|
||||
|
||||
ood_rate = np.mean(ood_mask) * 100
|
||||
within_2 = np.mean(np.abs(errors) <= 2.0) * 100
|
||||
print(f"\n=== Shadow Advisory Report ({args.prefix}) ===")
|
||||
print(f" PROD 행수 : {len(prod)}")
|
||||
print(f" OOD 비율 : {ood_rate:.1f}%")
|
||||
print(f" OP MAE : {np.abs(errors).mean():.2f}%")
|
||||
print(f" |Δ|≤2% : {within_2:.1f}% (검증기준: 90%+ in-envelope)")
|
||||
env_only = errors[~ood_mask[:len(errors)]]
|
||||
if len(env_only):
|
||||
print(f" in-env MAE : {np.abs(env_only).mean():.2f}% "
|
||||
f"|Δ|≤2%={np.mean(np.abs(env_only)<=2)*100:.1f}%")
|
||||
|
||||
# 권장 OP vs 실제 OP 시계열 플롯
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
fig, ax = plt.subplots(2, 1, figsize=(14, 8))
|
||||
s = prod.iloc[::10]
|
||||
ax[0].plot(s["dtat"], s["steam_op"], lw=.6, label="actual OP")
|
||||
ax[0].plot(s["dtat"], op[::10], lw=.6, c="r", label="advisory OP")
|
||||
ax[0].set_ylabel("OP %"); ax[0].legend(fontsize=8)
|
||||
ax[0].set_title(f"Operator Advisory vs Actual OP ({args.prefix})")
|
||||
ax[1].hist(errors, bins=60)
|
||||
ax[1].axvline(0, c="k", lw=.5)
|
||||
ax[1].set_title(f"Advisory error (rec-actual): median {np.median(errors):+.2f}%, "
|
||||
f"within 2%={within_2:.1f}%")
|
||||
fig.tight_layout()
|
||||
path = BASE + f"{args.prefix}_advisory.png"
|
||||
fig.savefig(path, dpi=95)
|
||||
print(f"\n 플롯 저장: {path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,14 +1,12 @@
|
||||
"""
|
||||
C-6111 ① 생산 정상상태 맵 (플랜 §16.3-5).
|
||||
① 생산 정상상태 맵.
|
||||
|
||||
PROD 구간에서:
|
||||
1) 밸브특성: OP(TICA-6111A.OP) ↔ 스팀유량(FIQ-6115) — stiction/비선형/게인
|
||||
2) 정상상태 세그먼트 추출
|
||||
3) 회귀: 스팀유량 = f(피드, 리플럭스, 제품, 진공, ΔT…) + 피처중요도 + 시간분할 검증
|
||||
→ "오퍼레이터 스팀이 가용변수로 얼마나 설명되나" (FIT/MAE)
|
||||
PROD 구간에서 밸브특성 + 스팀유량 회귀.
|
||||
|
||||
선행: c6111_extract.py 가 만든 c6111_data.pkl (mode 컬럼 포함).
|
||||
형제 컬럼 호환: --data, --prefix CLI 인자.
|
||||
"""
|
||||
import argparse
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import matplotlib
|
||||
@@ -20,14 +18,15 @@ from sklearn.preprocessing import StandardScaler
|
||||
from sklearn.metrics import r2_score, mean_absolute_error
|
||||
|
||||
BASE = "/home/windpacer/projects/hc900_ax/scripts/analysis/"
|
||||
TARGET = "steam_flow" # FIQ-6115 (에너지). 비교용으로 steam_op도 출력
|
||||
# 깨끗한 인과입력만 (reflux/dp/dT는 스팀의 결과·동시조작 → 순환참조라 제외)
|
||||
TARGET = "steam_flow"
|
||||
FEATURES = ["feed", "product", "vacuum", "feed_preheat", "T_C", "T_D"]
|
||||
OP_RESAMPLE = "6h" # 운전점 집계 (정상상태 내부 변동 적음 → 캠페인/로드레벨 단위 학습)
|
||||
OP_RESAMPLE = "6h"
|
||||
|
||||
|
||||
def load():
|
||||
df = pd.read_pickle(BASE + "c6111_data.pkl")
|
||||
def load(data_path=None):
|
||||
if data_path is None:
|
||||
data_path = BASE + "c6111_data.pkl"
|
||||
df = pd.read_pickle(data_path)
|
||||
df = df[df["mode"] == "PROD"].copy()
|
||||
# 엔지니어링 피처: 온도 구배(분리도)
|
||||
df["dT_AC"] = df["reb_temp"] - df["T_C"]
|
||||
@@ -101,31 +100,35 @@ def regress(df):
|
||||
return ops, gbm, Xte, yte, gbm.predict(Xte), imp
|
||||
|
||||
|
||||
def plots(hb, ops, yte, pred, imp):
|
||||
def plots(hb, ops, yte, pred, imp, prefix="c6111"):
|
||||
fig, ax = plt.subplots(1, 4, figsize=(22, 5))
|
||||
ax[0].scatter(hb["op"], hb["flow"], s=20, c="k", label="mean")
|
||||
ax[0].plot(hb["op"], hb["flow_up"], "b.-", ms=4, label="OP rising")
|
||||
ax[0].plot(hb["op"], hb["flow_dn"], "r.-", ms=4, label="OP falling")
|
||||
ax[0].set_xlabel("steam OP %"); ax[0].set_ylabel("steam flow FIQ-6115")
|
||||
ax[0].set_xlabel("steam OP %"); ax[0].set_ylabel("steam flow")
|
||||
ax[0].set_title("Valve char (hysteresis=stiction)"); ax[0].legend()
|
||||
ax[1].scatter(ops["feed"], ops[TARGET], s=10, alpha=.5)
|
||||
ax[1].set_xlabel("feed FICQ-6101"); ax[1].set_ylabel("steam flow")
|
||||
ax[1].set_xlabel("feed"); ax[1].set_ylabel("steam flow")
|
||||
ax[1].set_title("steam vs feed (operating points)")
|
||||
ax[2].scatter(yte, pred, s=12, alpha=.5)
|
||||
lim = [min(yte.min(), pred.min()), max(yte.max(), pred.max())]
|
||||
ax[2].plot(lim, lim, "r--"); ax[2].set_xlabel("actual steam flow")
|
||||
ax[2].set_ylabel("predicted (GBM)"); ax[2].set_title("Predicted vs Actual (test ops)")
|
||||
imp.sort_values().plot.barh(ax=ax[3]); ax[3].set_title("GBM feature importance")
|
||||
fig.tight_layout(); fig.savefig(BASE + "c6111_prodmap.png", dpi=95)
|
||||
print(f"\n플롯 저장: {BASE}c6111_prodmap.png")
|
||||
fig.tight_layout(); fig.savefig(BASE + f"{prefix}_prodmap.png", dpi=95)
|
||||
print(f"\n플롯 저장: {BASE}{prefix}_prodmap.png")
|
||||
|
||||
|
||||
def main():
|
||||
df = load()
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--data", default=BASE + "c6111_data.pkl")
|
||||
parser.add_argument("--prefix", default="c6111")
|
||||
args = parser.parse_args()
|
||||
df = load(args.data)
|
||||
print(f"PROD 정합데이터 {len(df)}행")
|
||||
hb, a = valve_char(df)
|
||||
ops, gbm, Xte, yte, pred, imp = regress(df)
|
||||
plots(hb, ops, yte, pred, imp)
|
||||
plots(hb, ops, yte, pred, imp, args.prefix)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""
|
||||
C-6111 롤링(walk-forward) 재학습 — OOD/외삽 바이어스 해소 데모 (플랜 §16.7-(1)).
|
||||
롤링(walk-forward) 재학습 — OOD/외삽 바이어스 해소 데모.
|
||||
|
||||
held-out 5월을 하루씩 전진하며 '그 날 이전 전체 이력(expanding window)'으로 매일 재학습→그 날 예측.
|
||||
정적 모델(2~4월 고정)의 +4% 외삽 바이어스가 모델이 5월 저부하 데이터를 흡수하며
|
||||
사라지는지(적응 곡선) + OOD 비율이 떨어지는지 확인. 입력 평활은 인과(trailing).
|
||||
형제 컬럼 호환: --data, --prefix CLI 인자.
|
||||
"""
|
||||
import argparse
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import matplotlib
|
||||
@@ -18,7 +17,11 @@ RETRAIN_EVERY = "1D"
|
||||
|
||||
|
||||
def main():
|
||||
df = pd.read_pickle(BASE + "c6111_data.pkl")
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--data", default=BASE + "c6111_data.pkl")
|
||||
parser.add_argument("--prefix", default="c6111")
|
||||
args = parser.parse_args()
|
||||
df = pd.read_pickle(args.data)
|
||||
df = df[df["mode"] == "PROD"].copy()
|
||||
df = df[(df["feed"] > 50) & (df["steam_flow"] > 10) & (df["steam_op"] > 1)
|
||||
& df[FEATURES + ["steam_op"]].notna().all(axis=1)].sort_values("dtat")
|
||||
@@ -27,6 +30,10 @@ def main():
|
||||
df[c + "_s"] = df[c].rolling(SMOOTH, min_periods=1).median()
|
||||
|
||||
ho = pd.Timestamp(HELDOUT_START)
|
||||
if df["dtat"].max() < ho:
|
||||
print(f"데이터 종료 {df.dtat.max()} < HELDOUT_START({ho}) — 롤링 재학습 불가. (컬럼 가동기간이 5월 이전)")
|
||||
return
|
||||
|
||||
days = pd.date_range(ho, df["dtat"].max(), freq=RETRAIN_EVERY)
|
||||
|
||||
# 정적 모델: 5월 이전 전체로 1회 학습
|
||||
@@ -70,8 +77,8 @@ def main():
|
||||
ax[0].set_ylabel("OP MAE %"); ax[0].legend(); ax[0].set_title("Rolling vs static — adaptation over May")
|
||||
ax[1].plot(r.day, r.ood_roll, "b.-"); ax[1].set_ylabel("rolling OOD %")
|
||||
ax[1].set_title("OOD fraction (학습 envelope 밖) — 5월 데이터 흡수하며 감소")
|
||||
fig.tight_layout(); fig.savefig(BASE + "c6111_rolling.png", dpi=95)
|
||||
print(f"\n플롯 저장: {BASE}c6111_rolling.png")
|
||||
fig.tight_layout(); fig.savefig(BASE + f"{args.prefix}_rolling.png", dpi=95)
|
||||
print(f"\n플롯 저장: {BASE}{args.prefix}_rolling.png")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
"""
|
||||
C-6111 Shadow 예측기 — 히스토리 리플레이 백테스트 (플랜 §7 shadow 진입).
|
||||
Shadow 예측기 — 히스토리 리플레이 백테스트.
|
||||
|
||||
학습기간 운전점으로 `스팀유량=f(피드,제품,목표T_C)` 학습 → held-out 미래기간을
|
||||
매 시점 리플레이하여 예측 스팀→(밸브 역특성)→예측 OP 를 산출, **실제 오퍼레이터 OP와 비교**.
|
||||
"이 예측기를 shadow로 돌렸다면 오퍼레이터 손과 얼마나 일치했나" 를 정직 검증.
|
||||
|
||||
선행: c6111_data.pkl. 포팅대상(추후 C# live shadow)은 동일 로직.
|
||||
선행: c6111_data.pkl. 형제 컬럼 호환: --data, --prefix CLI 인자.
|
||||
"""
|
||||
import argparse
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import matplotlib
|
||||
@@ -16,9 +13,9 @@ from sklearn.ensemble import GradientBoostingRegressor
|
||||
from sklearn.metrics import r2_score, mean_absolute_error
|
||||
|
||||
BASE = "/home/windpacer/projects/hc900_ax/scripts/analysis/"
|
||||
FEATURES = ["feed", "product", "T_C"] # 깨끗한 인과/목표 입력 (§16.6)
|
||||
SMOOTH = 40 # 입력 평활 20분(운전점 성격 유지)
|
||||
TRAIN_FRAC = 0.70 # 앞 70% 기간 학습, 뒤 30% held-out shadow
|
||||
FEATURES = ["feed", "product", "T_C"]
|
||||
SMOOTH = 40
|
||||
TRAIN_FRAC = 0.70
|
||||
|
||||
|
||||
class SteamPredictor:
|
||||
@@ -42,7 +39,11 @@ class SteamPredictor:
|
||||
|
||||
|
||||
def main():
|
||||
df = pd.read_pickle(BASE + "c6111_data.pkl")
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--data", default=BASE + "c6111_data.pkl")
|
||||
parser.add_argument("--prefix", default="c6111")
|
||||
args = parser.parse_args()
|
||||
df = pd.read_pickle(args.data)
|
||||
df = df[df["mode"] == "PROD"].copy()
|
||||
df = df[(df["feed"] > 50) & (df["steam_flow"] > 10) & (df["steam_op"] > 1)
|
||||
& df[FEATURES + ["steam_op"]].notna().all(axis=1)].sort_values("dtat")
|
||||
@@ -91,8 +92,8 @@ def main():
|
||||
err = te["pred_op"] - te["steam_op"]
|
||||
ax[2].hist(err, bins=80); ax[2].axvline(0, c="k", lw=.5)
|
||||
ax[2].set_title(f"OP error (pred-actual): median {err.median():+.2f}%, std {err.std():.2f}%")
|
||||
fig.tight_layout(); fig.savefig(BASE + "c6111_shadow.png", dpi=95)
|
||||
print(f"\n플롯 저장: {BASE}c6111_shadow.png")
|
||||
fig.tight_layout(); fig.savefig(BASE + f"{args.prefix}_shadow.png", dpi=95)
|
||||
print(f"\n플롯 저장: {BASE}{args.prefix}_shadow.png")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
148
scripts/analysis/c6111_shutdown.py
Normal file
148
scripts/analysis/c6111_shutdown.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
③ SHUTDOWN 절차 학습 (few-shot). startup의 역순.
|
||||
|
||||
형제 컬럼 호환: --data, --prefix CLI 인자.
|
||||
"""
|
||||
import argparse
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
BASE = "/home/windpacer/projects/hc900_ax/scripts/analysis/"
|
||||
|
||||
|
||||
def detect_cutoffs(df):
|
||||
"""★제품 컷오프★ 이벤트: product >100→<50 하강엣지이고 직후 steam도 하강(shutdown)."""
|
||||
prod = df["product"].values
|
||||
steam_op = df["steam_op"].values
|
||||
reb = df["reb_temp"].values
|
||||
outs = []
|
||||
i = 60
|
||||
n = len(df)
|
||||
while i < n:
|
||||
if prod[i] < 50 and prod[i-1] >= 100 and reb[i] > 60:
|
||||
fwd = steam_op[i:min(n, i+60)]
|
||||
if np.nanmean(fwd) < np.nanmean(steam_op[max(0, i-60):i]) * 0.8:
|
||||
outs.append(i)
|
||||
i += 720
|
||||
continue
|
||||
i += 1
|
||||
return outs
|
||||
|
||||
|
||||
def shutdown_milestones(df, co):
|
||||
"""컷오프 인덱스 co 기준 역방향 절차 추출."""
|
||||
tc = df["dtat"].iloc[co]
|
||||
n = len(df)
|
||||
|
||||
def mins(i):
|
||||
return None if i is None else (df["dtat"].iloc[i] - tc).total_seconds() / 60
|
||||
|
||||
feed_start = None
|
||||
feed_vals = df["feed"].values
|
||||
for j in range(co, max(0, co - 1200), -1):
|
||||
if feed_vals[j] < 100:
|
||||
feed_start = j
|
||||
if feed_start is not None and j > 0:
|
||||
if feed_vals[j] > feed_vals[min(j + 30, co)] * 0.85:
|
||||
continue
|
||||
if feed_vals[j] > 250 and feed_vals[j] > feed_vals[min(j + 1, co)] * 0.98:
|
||||
feed_start = j
|
||||
break
|
||||
|
||||
steam_off = None
|
||||
for j in range(co, min(n, co + 600)):
|
||||
if df["steam_op"].iloc[j] < 5:
|
||||
steam_off = j
|
||||
break
|
||||
|
||||
vacuum_off = None
|
||||
for j in range(co, min(n, co + 1200)):
|
||||
if df["vacuum"].iloc[j] > 300:
|
||||
vacuum_off = j
|
||||
break
|
||||
|
||||
prod_off = None
|
||||
for j in range(co, min(n, co + 120)):
|
||||
if df["product"].iloc[j] < 10:
|
||||
prod_off = j
|
||||
break
|
||||
|
||||
cold = None
|
||||
for j in range(co, min(n, co + 2400)):
|
||||
if df["reb_temp"].iloc[j] < 40:
|
||||
cold = j
|
||||
break
|
||||
|
||||
r = df.iloc[co]
|
||||
return dict(cutoff_time=tc,
|
||||
feed_to_cutoff=-(mins(feed_start)) if feed_start is not None else None,
|
||||
cutoff_to_steam_off=mins(steam_off) if steam_off else None,
|
||||
cutoff_to_vacuum_off=mins(vacuum_off) if vacuum_off else None,
|
||||
cutoff_to_prod_off=mins(prod_off) if prod_off else None,
|
||||
cutoff_to_cold=mins(cold) if cold else None,
|
||||
cutoff_rebA=r["reb_temp"], cutoff_TC=r["T_C"], cutoff_TD=r["T_D"],
|
||||
cutoff_dT_AD=r["reb_temp"] - r["T_D"])
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--data", default=BASE + "c6111_data.pkl")
|
||||
parser.add_argument("--prefix", default="c6111")
|
||||
args = parser.parse_args()
|
||||
df = pd.read_pickle(args.data).sort_values("dtat").reset_index(drop=True)
|
||||
cutoffs = detect_cutoffs(df)
|
||||
print(f"탐지된 ★제품 컷오프★(shutdown 진입) 이벤트: {len(cutoffs)}개")
|
||||
|
||||
if not cutoffs:
|
||||
print(" [skip] shutdown 이벤트 없음 — 플롯 생략")
|
||||
return
|
||||
rows, windows = [], []
|
||||
for co in cutoffs:
|
||||
w = df.iloc[max(0, co - 360):min(len(df), co + 360)].copy()
|
||||
w["rel_min"] = (w["dtat"] - df["dtat"].iloc[co]).dt.total_seconds() / 60
|
||||
windows.append(w)
|
||||
rows.append(shutdown_milestones(df, co))
|
||||
M = pd.DataFrame(rows)
|
||||
pd.set_option("display.width", 220)
|
||||
print("\n=== 제품컷오프 기준 절차(분) + 셧다운 시점 컬럼상태 ===")
|
||||
cols = ["cutoff_time", "feed_to_cutoff", "cutoff_to_steam_off",
|
||||
"cutoff_to_vacuum_off", "cutoff_to_prod_off", "cutoff_to_cold",
|
||||
"cutoff_rebA", "cutoff_TC", "cutoff_dT_AD"]
|
||||
show = M[cols].copy()
|
||||
show["cutoff_time"] = show["cutoff_time"].dt.strftime("%m-%d %H:%M")
|
||||
print(show.round(1).to_string(index=False))
|
||||
print("\n=== 셧다운 레시피(중앙값) ===")
|
||||
print(f" 피드감소→컷오프: {M.feed_to_cutoff.median():.0f}분")
|
||||
print(f" 컷오프→스팀차단 : {M.cutoff_to_steam_off.median():.0f}분")
|
||||
print(f" 컷오프→진공해제 : {M.cutoff_to_vacuum_off.median():.0f}분")
|
||||
print(f" 컷오프→제품0 : {M.cutoff_to_prod_off.median():.0f}분")
|
||||
print(f" 컷오프→냉각 : {M.cutoff_to_cold.median():.0f}분")
|
||||
reb_std = M.cutoff_rebA.std() if len(M) > 1 else 0.0
|
||||
tc_std = M.cutoff_TC.std() if len(M) > 1 else 0.0
|
||||
print(f" ★셧다운 트리거: reb-A={M.cutoff_rebA.median():.1f}±{reb_std:.1f}℃, "
|
||||
f"T_C={M.cutoff_TC.median():.1f}±{tc_std:.2f}℃, ΔT(A-D)={M.cutoff_dT_AD.median():.1f}℃")
|
||||
|
||||
fig, ax = plt.subplots(4, 1, figsize=(13, 11), sharex=True)
|
||||
for k, w in enumerate(windows):
|
||||
c = plt.cm.tab10(k)
|
||||
ax[0].plot(w.rel_min, w.reb_temp, color=c, lw=.9, label=f"sh{k+1} {w.dtat.iloc[len(w)//2]:%m-%d}")
|
||||
ax[0].plot(w.rel_min, w["T_D"], color=c, lw=.6, ls=":")
|
||||
ax[1].plot(w.rel_min, w.steam_flow, color=c, lw=.9)
|
||||
ax[2].plot(w.rel_min, w.reflux, color=c, lw=.9)
|
||||
ax[2].plot(w.rel_min, w["product"], color=c, lw=.9, ls="--")
|
||||
ax[3].plot(w.rel_min, w.feed, color=c, lw=.9)
|
||||
ax[0].set_ylabel("reb_temp/T_D(:)"); ax[0].legend(fontsize=7)
|
||||
ax[0].set_title("SHUTDOWN aligned at PRODUCT CUT-OFF (rel=0)")
|
||||
ax[1].set_ylabel("steam flow"); ax[2].set_ylabel("reflux/product(--)")
|
||||
ax[3].set_ylabel("feed"); ax[3].set_xlabel("minutes from product cut-off")
|
||||
for a in ax:
|
||||
a.axvline(0, c="k", lw=.5)
|
||||
fig.tight_layout(); fig.savefig(BASE + f"{args.prefix}_shutdown.png", dpi=95)
|
||||
print(f"\n플롯 저장: {BASE}{args.prefix}_shutdown.png")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,11 +1,9 @@
|
||||
"""
|
||||
C-6111 ② START-UP 절차 학습 (플랜 §16.4 ②, few-shot).
|
||||
② START-UP 절차 학습 (few-shot).
|
||||
|
||||
startup 에피소드를 탐지→스팀투입 시점(t0)에 정렬→중첩, 절차를 해석가능 레시피로 추출:
|
||||
단계 시퀀스(진공→스팀/승온→전환류 라인아웃→제품컷인→로드램프→생산),
|
||||
각 단계 타이밍, 그리고 ★핵심 결정 "제품 컷인" 시점의 컬럼 상태(트리거)★.
|
||||
블랙박스 정책 아님 — 안전·설명가능 우선.
|
||||
형제 컬럼 호환: --data, --prefix CLI 인자.
|
||||
"""
|
||||
import argparse
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import matplotlib
|
||||
@@ -61,7 +59,11 @@ def milestones(df, ci):
|
||||
|
||||
|
||||
def main():
|
||||
df = pd.read_pickle(BASE + "c6111_data.pkl").sort_values("dtat").reset_index(drop=True)
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--data", default=BASE + "c6111_data.pkl")
|
||||
parser.add_argument("--prefix", default="c6111")
|
||||
args = parser.parse_args()
|
||||
df = pd.read_pickle(args.data).sort_values("dtat").reset_index(drop=True)
|
||||
cutins = detect_cutins(df)
|
||||
print(f"탐지된 ★제품 컷인★(진짜 startup) 이벤트: {len(cutins)}개")
|
||||
|
||||
@@ -101,8 +103,8 @@ def main():
|
||||
ax[3].set_ylabel("feed"); ax[3].set_xlabel("minutes from product cut-in")
|
||||
for a in ax:
|
||||
a.axvline(0, c="k", lw=.5)
|
||||
fig.tight_layout(); fig.savefig(BASE + "c6111_startup.png", dpi=95)
|
||||
print(f"\n플롯 저장: {BASE}c6111_startup.png")
|
||||
fig.tight_layout(); fig.savefig(BASE + f"{args.prefix}_startup.png", dpi=95)
|
||||
print(f"\n플롯 저장: {BASE}{args.prefix}_startup.png")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
173
scripts/analysis/run_column.py
Normal file
173
scripts/analysis/run_column.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
형제 컬럼 확장(작업1) 일괄 실행 래퍼.
|
||||
|
||||
사용법:
|
||||
python3 run_column.py --prefix 62 # 6-2차 단독
|
||||
python3 run_column.py --prefix 81 --asset /ASSETS/P8
|
||||
python3 run_column.py --all # 모든 형제 컬럼
|
||||
"""
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
import psycopg
|
||||
import pandas as pd
|
||||
|
||||
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
COLUMNS = [
|
||||
("61", "/ASSETS/P6", "C-6111 (6-1차)"),
|
||||
("62", "/ASSETS/P6", "C-6211 (6-2차)"),
|
||||
("81", "/ASSETS/P8", "C-8111 (8차)"),
|
||||
("91", "/ASSETS/P9", "C-9111 (9차)"),
|
||||
("101", "/ASSETS/P10", "C-10111 (10차)"),
|
||||
]
|
||||
|
||||
PREFIX_ASSET = {p: a for p, a, _ in COLUMNS}
|
||||
|
||||
DSN = "host=localhost port=5432 dbname=field_hist user=postgres password=postgres"
|
||||
PY = sys.executable
|
||||
|
||||
|
||||
def extract(prefix, asset):
|
||||
"""추출 + 운전모드 분류. c{prefix}_data.pkl 저장."""
|
||||
from c6111_extract import roles_for, tag_frame, classify_phases
|
||||
|
||||
with psycopg.connect(DSN) as conn:
|
||||
roles = roles_for(prefix, asset)
|
||||
print(f"\n ROLES ({len(roles)}):")
|
||||
for k, v in roles.items():
|
||||
print(f" {k:15s} -> {v}")
|
||||
df = tag_frame(conn, roles, asset)
|
||||
|
||||
df["mode"] = classify_phases(df)
|
||||
out = os.path.join(BASE, f"c{prefix}_data.pkl")
|
||||
df.to_pickle(out)
|
||||
|
||||
print(f"\n=== {prefix} ({asset}) ===")
|
||||
print(f" 행수={len(df)} 기간={df.dtat.min()} ~ {df.dtat.max()}")
|
||||
vc = df["mode"].value_counts()
|
||||
for m, n in vc.items():
|
||||
print(f" {m:9s} {n:7d} {100*n/len(df):5.1f}% ≈ {n*30/3600:.1f}h")
|
||||
print(f" 저장: {out}")
|
||||
return out
|
||||
|
||||
|
||||
def run_analysis(script, prefix):
|
||||
"""분석 스크립트 1개 실행 (subprocess)."""
|
||||
data = os.path.join(BASE, f"c{prefix}_data.pkl")
|
||||
cmd = [PY, os.path.join(BASE, script), "--data", data, "--prefix", f"c{prefix}"]
|
||||
print(f"\n>>> {' '.join(cmd)}")
|
||||
r = subprocess.run(cmd)
|
||||
return r.returncode
|
||||
|
||||
|
||||
def run_column(prefix, asset, label):
|
||||
"""컬럼 1개 전체 파이프라인."""
|
||||
print(f"\n{'='*60}")
|
||||
print(f" {label} (prefix={prefix}, asset={asset})")
|
||||
print(f"{'='*60}")
|
||||
extract(prefix, asset)
|
||||
for script in ["c6111_prodmap.py", "c6111_shadow.py", "c6111_rolling.py", "c6111_startup.py", "c6111_shutdown.py", "c6111_operator_assist.py", "c6111_export_model.py"]:
|
||||
rc = run_analysis(script, prefix)
|
||||
if rc != 0:
|
||||
print(f" [WARN] {script} → exit {rc}")
|
||||
|
||||
|
||||
def compare():
|
||||
"""모든 컬럼 결과 취합 → 비교표 (prodmap + shadow + startup)."""
|
||||
import numpy as np
|
||||
|
||||
rows = []
|
||||
for prefix, asset, label in COLUMNS:
|
||||
pkl = os.path.join(BASE, f"c{prefix}_data.pkl")
|
||||
# 6-1 legacy: c6111_data.pkl (not c61_data.pkl)
|
||||
if prefix == "61" and not os.path.exists(pkl):
|
||||
alt = os.path.join(BASE, "c6111_data.pkl")
|
||||
if os.path.exists(alt):
|
||||
pkl = alt
|
||||
if not os.path.exists(pkl):
|
||||
print(f" [skip] {label}: {pkl} 없음")
|
||||
continue
|
||||
df = pd.read_pickle(pkl)
|
||||
prod = df[df["mode"] == "PROD"]
|
||||
steam_feed = prod["steam_flow"].median() / prod["feed"].median() if len(prod) else float("nan")
|
||||
total_h = len(df) * 30 / 3600
|
||||
prod_h = len(prod) * 30 / 3600
|
||||
|
||||
# 컷인 탐지 (startup.py detect_cutins 로직 인라인)
|
||||
prod_arr = df["product"].values
|
||||
reb_arr = df["reb_temp"].values
|
||||
dtat_vals = df["dtat"].values
|
||||
cutins = []
|
||||
i = 60
|
||||
n = len(df)
|
||||
while i < n:
|
||||
if prod_arr[i] > 100 and prod_arr[i-1] <= 100:
|
||||
pre = prod_arr[max(0, i-60):i]
|
||||
if np.nanmedian(pre) < 50 and reb_arr[i] > 75:
|
||||
cutins.append(i)
|
||||
i += 720
|
||||
continue
|
||||
i += 1
|
||||
|
||||
row = {"컬럼": label,
|
||||
"기간": f"{df['dtat'].min():%m-%d}~{df['dtat'].max():%m-%d}",
|
||||
"전체(h)": f"{total_h:.0f}",
|
||||
"PROD%": f"{100*len(prod)/len(df):.1f}",
|
||||
"생산(h)": f"{prod_h:.0f}",
|
||||
"steam/feed": f"{steam_feed:.3f}",
|
||||
"컷인": str(len(cutins))}
|
||||
|
||||
if cutins:
|
||||
cutin_data = []
|
||||
for ci in cutins:
|
||||
cutin_data.append({"reb": df.loc[ci, "reb_temp"],
|
||||
"tc": df.loc[ci, "T_C"],
|
||||
"dT": df.loc[ci, "reb_temp"] - df.loc[ci, "T_D"]})
|
||||
cdf = pd.DataFrame(cutin_data)
|
||||
row["컷인_reb-A"] = f"{cdf['reb'].median():.1f}±{cdf['reb'].std():.1f}"
|
||||
row["컷인_dT_AD"] = f"{cdf['dT'].median():.1f}±{cdf['dT'].std():.1f}"
|
||||
else:
|
||||
row["컷인_reb-A"] = ""
|
||||
row["컷인_dT_AD"] = ""
|
||||
|
||||
rows.append(row)
|
||||
|
||||
pd.set_option("display.width", 300)
|
||||
pd.set_option("display.max_columns", 20)
|
||||
print("\n\n" + "="*120)
|
||||
print(" 형제 컬럼 비교표")
|
||||
print("="*120)
|
||||
tbl = pd.DataFrame(rows).set_index("컬럼")
|
||||
print(tbl.to_string())
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="형제 컬럼 확장 일괄 실행")
|
||||
parser.add_argument("--prefix", help="컬럼 prefix (61, 62, 81, 91, 101)")
|
||||
parser.add_argument("--asset", help="asset 경로 (예: /ASSETS/P6)")
|
||||
parser.add_argument("--all", action="store_true", help="모든 형제 컬럼 실행")
|
||||
parser.add_argument("--compare", action="store_true", help="기존 pkl로 비교표만 출력")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.compare:
|
||||
compare()
|
||||
elif args.all:
|
||||
for prefix, asset, label in COLUMNS:
|
||||
run_column(prefix, asset, label)
|
||||
compare()
|
||||
elif args.prefix:
|
||||
asset = args.asset or PREFIX_ASSET.get(args.prefix, f"/ASSETS/P{args.prefix[0]}")
|
||||
label = f"C-{args.prefix}11"
|
||||
for p, a, l in COLUMNS:
|
||||
if p == args.prefix:
|
||||
label = l
|
||||
break
|
||||
run_column(args.prefix, asset, label)
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
46
src/Hc900Crawler/Controllers/SteamAdvisorController.cs
Normal file
46
src/Hc900Crawler/Controllers/SteamAdvisorController.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using Hc900Crawler.Infrastructure.Control;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Hc900Crawler.Web.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/steam")]
|
||||
public sealed class SteamAdvisorController : ControllerBase
|
||||
{
|
||||
private readonly SteamAdvisor _advisor;
|
||||
|
||||
public SteamAdvisorController(SteamAdvisor advisor)
|
||||
{
|
||||
_advisor = advisor;
|
||||
}
|
||||
|
||||
[HttpGet("health")]
|
||||
public IActionResult Health()
|
||||
{
|
||||
return Ok(new { loaded = _advisor.IsLoaded });
|
||||
}
|
||||
|
||||
[HttpGet("predict")]
|
||||
public IActionResult Predict(
|
||||
[FromQuery] double feed,
|
||||
[FromQuery] double product,
|
||||
[FromQuery] double tC)
|
||||
{
|
||||
var result = _advisor.Predict(feed, product, tC);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("predict")]
|
||||
public IActionResult PredictPost([FromBody] SteamPredictBody body)
|
||||
{
|
||||
var result = _advisor.Predict(body.Feed, body.Product, body.TC);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record SteamPredictBody
|
||||
{
|
||||
public double Feed { get; init; }
|
||||
public double Product { get; init; }
|
||||
public double TC { get; init; }
|
||||
}
|
||||
@@ -103,6 +103,16 @@ builder.Services.AddSingleton<Hc900Crawler.Core.Application.Feedforward.ISimOver
|
||||
builder.Services.AddSingleton<Hc900Crawler.Core.Application.Feedforward.ICompositionStore,
|
||||
Hc900Crawler.Infrastructure.Control.CompositionStore>();
|
||||
builder.Services.AddScoped<Hc900Crawler.Infrastructure.Control.FeedRampAdvisorService>();
|
||||
// 측류 추종(ON/OFF) 상태 저장소
|
||||
builder.Services.AddSingleton<Hc900Crawler.Core.Application.Feedforward.IFfTrackingStore,
|
||||
Hc900Crawler.Infrastructure.Control.FfTrackingStore>();
|
||||
// 작업 B: FEED 램프 실행기 + 작업 저장소
|
||||
builder.Services.AddSingleton<Hc900Crawler.Core.Application.Feedforward.IFeedRampJobStore,
|
||||
Hc900Crawler.Infrastructure.Control.FeedRampJobStore>();
|
||||
builder.Services.AddHostedService<Hc900Crawler.Infrastructure.Control.FeedRampExecutorService>();
|
||||
|
||||
// ── Steam Advisor (작업4) ────────────────────────────────────────────────────
|
||||
builder.Services.AddSingleton<Hc900Crawler.Infrastructure.Control.SteamAdvisor>();
|
||||
|
||||
// ── MCP Service ───────────────────────────────────────────────────────────────
|
||||
builder.Services.AddHttpClient(McpClient.HttpClientName, c =>
|
||||
|
||||
@@ -38,7 +38,9 @@
|
||||
"Enabled": true
|
||||
},
|
||||
"Feedforward": {
|
||||
"SimOverrideEnabled": true
|
||||
"SimOverrideEnabled": true,
|
||||
"FeedRampDryRun": false,
|
||||
"FeedRampStepSec": 10
|
||||
},
|
||||
"McpServer": {
|
||||
"WorkingDirectory": "../../mcp-server"
|
||||
@@ -62,6 +64,9 @@
|
||||
"LockoutMinutes": 15
|
||||
}
|
||||
},
|
||||
"SteamAdvisor": {
|
||||
"ModelPath": "/home/windpacer/projects/hc900_ax/scripts/analysis/c6111_model.json"
|
||||
},
|
||||
"Kestrel": {
|
||||
"Endpoints": {
|
||||
"Http": {
|
||||
|
||||
150
src/Infrastructure/Control/SteamAdvisor.cs
Normal file
150
src/Infrastructure/Control/SteamAdvisor.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Hc900Crawler.Infrastructure.Control;
|
||||
|
||||
public sealed record SteamModel
|
||||
{
|
||||
public string Column { get; init; } = "";
|
||||
public List<string> Features { get; init; } = [];
|
||||
public List<double> LinearCoeffs { get; init; } = [];
|
||||
public double Intercept { get; init; }
|
||||
public double LinearR2 { get; init; }
|
||||
public double? GbmR2 { get; init; }
|
||||
public List<double> ValvePoly { get; init; } = []; // c3, c2, c1, c0
|
||||
public Dictionary<string, double> EnvelopeLo { get; init; } = [];
|
||||
public Dictionary<string, double> EnvelopeHi { get; init; } = [];
|
||||
public int NOperatingPoints { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SteamAdvisoryResult
|
||||
{
|
||||
public double? RecOp { get; init; }
|
||||
public double? RecSteam { get; init; }
|
||||
public string Confidence { get; init; } = "N/A";
|
||||
public string Mode { get; init; } = "UNKNOWN";
|
||||
public bool Ood { get; init; }
|
||||
public bool InEnv { get; init; }
|
||||
public double Feed { get; init; }
|
||||
public double Product { get; init; }
|
||||
public double TC { get; init; }
|
||||
public string Message { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed class SteamAdvisor
|
||||
{
|
||||
private SteamModel? _model;
|
||||
private readonly string _modelPath;
|
||||
private readonly ILogger<SteamAdvisor> _logger;
|
||||
|
||||
public SteamAdvisor(IConfiguration config, ILogger<SteamAdvisor> logger)
|
||||
{
|
||||
_modelPath = config.GetValue<string>("SteamAdvisor:ModelPath")
|
||||
?? "/home/windpacer/projects/hc900_ax/scripts/analysis/c6111_model.json";
|
||||
_logger = logger;
|
||||
LoadModel();
|
||||
}
|
||||
|
||||
public bool IsLoaded => _model is not null;
|
||||
|
||||
public void LoadModel(string? path = null)
|
||||
{
|
||||
var p = path ?? _modelPath;
|
||||
if (!File.Exists(p))
|
||||
{
|
||||
_logger.LogWarning("[SteamAdvisor] 모델 파일 없음: {Path}", p);
|
||||
return;
|
||||
}
|
||||
var json = File.ReadAllText(p);
|
||||
_model = JsonSerializer.Deserialize<SteamModel>(json);
|
||||
_logger.LogInformation("[SteamAdvisor] 모델 로드: {Column} (R²={R2})",
|
||||
_model?.Column, _model?.LinearR2);
|
||||
}
|
||||
|
||||
public SteamAdvisoryResult Predict(double feed, double product, double tC)
|
||||
{
|
||||
if (_model is null)
|
||||
return new SteamAdvisoryResult { Message = "모델 미로드", Confidence = "N/A", Mode = "UNKNOWN",
|
||||
Feed = feed, Product = product, TC = tC };
|
||||
if (double.IsNaN(feed) || double.IsNaN(product) || double.IsNaN(tC))
|
||||
return new SteamAdvisoryResult { Message = "입력값에 NaN 포함", Confidence = "N/A",
|
||||
Mode = "INVALID", Feed = feed, Product = product, TC = tC };
|
||||
|
||||
var mode = ClassifyMode(feed, product, tC);
|
||||
var inEnv = InEnvelope(feed, product, tC, _model);
|
||||
var steam = _model.Intercept
|
||||
+ _model.LinearCoeffs[0] * feed
|
||||
+ _model.LinearCoeffs[1] * product
|
||||
+ _model.LinearCoeffs[2] * tC;
|
||||
var op = PolyVal(_model.ValvePoly, steam);
|
||||
op = Math.Clamp(op, 0, 100);
|
||||
|
||||
if (mode != "PROD")
|
||||
{
|
||||
return new SteamAdvisoryResult
|
||||
{
|
||||
RecOp = null, RecSteam = null, Confidence = "N/A",
|
||||
Mode = mode, Ood = !inEnv, InEnv = inEnv,
|
||||
Feed = feed, Product = product, TC = tC,
|
||||
Message = $"운전모드={mode} — advisory는 PROD에서만 제공"
|
||||
};
|
||||
}
|
||||
|
||||
string confidence;
|
||||
string msg;
|
||||
if (!inEnv)
|
||||
{
|
||||
confidence = "LOW_OOD";
|
||||
msg = $"⚠ 범위밖 입력 — 권장 OP={op:F1}% (외삽, 신뢰도 낮음). 오퍼레이터 판단 우선";
|
||||
}
|
||||
else
|
||||
{
|
||||
confidence = "HIGH";
|
||||
msg = $"권장 OP={op:F1}% (신뢰: 구간내)";
|
||||
}
|
||||
|
||||
return new SteamAdvisoryResult
|
||||
{
|
||||
RecOp = Math.Round(op, 1), RecSteam = Math.Round(steam, 1),
|
||||
Confidence = confidence, Mode = mode, Ood = !inEnv, InEnv = inEnv,
|
||||
Feed = feed, Product = product, TC = tC, Message = msg
|
||||
};
|
||||
}
|
||||
|
||||
public ValueTask<SteamAdvisoryResult> PredictAsync(double feed, double product, double tC, CancellationToken ct = default)
|
||||
{
|
||||
_ = ct;
|
||||
return ValueTask.FromResult(Predict(feed, product, tC));
|
||||
}
|
||||
|
||||
private static string ClassifyMode(double feed, double product, double tC)
|
||||
{
|
||||
if (product > 100) return "PROD";
|
||||
if (tC > 60) return "LINEOUT";
|
||||
if (feed > 50) return "PROD";
|
||||
return "STOPPED";
|
||||
}
|
||||
|
||||
private static bool InEnvelope(double feed, double product, double tC, SteamModel m)
|
||||
{
|
||||
if (m.EnvelopeLo.Count == 0 || m.EnvelopeHi.Count == 0) return true;
|
||||
if (feed < m.EnvelopeLo.GetValueOrDefault("feed", double.MinValue)) return false;
|
||||
if (feed > m.EnvelopeHi.GetValueOrDefault("feed", double.MaxValue)) return false;
|
||||
if (product < m.EnvelopeLo.GetValueOrDefault("product", double.MinValue)) return false;
|
||||
if (product > m.EnvelopeHi.GetValueOrDefault("product", double.MaxValue)) return false;
|
||||
if (tC < m.EnvelopeLo.GetValueOrDefault("T_C", double.MinValue)) return false;
|
||||
if (tC > m.EnvelopeHi.GetValueOrDefault("T_C", double.MaxValue)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private double PolyVal(List<double> coeffs, double x)
|
||||
{
|
||||
if (coeffs.Count < 4)
|
||||
{
|
||||
_logger.LogWarning("[SteamAdvisor] ValvePoly 계수 부족 ({Count}개, 4 필요)", coeffs.Count);
|
||||
return x;
|
||||
}
|
||||
return coeffs[0] * x * x * x + coeffs[1] * x * x + coeffs[2] * x + coeffs[3];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user