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:
windpacer
2026-06-05 19:46:57 +09:00
parent 1bc46b1eb0
commit 4306f76ddb
15 changed files with 1242 additions and 73 deletions

View File

@@ -1,21 +1,99 @@
# 작업지시서 — 학습형 제어 다음 단계 (6-1차 이후) # 작업지시서 — 학습형 제어 다음 단계 (6-1차 이후)
> 작성 2026-06-05. 전체 설계·진행로그는 `docs/학습형제어-오퍼레이터모방-플랜.md`(특히 §0 오리엔테이션, §15 데이터, §16 분석). > 작성 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. 현재까지 (필독 컨텍스트) ## 0. 현재까지 (필독 컨텍스트)
**완료(6-1차 컬럼 C-6111, 오프라인 분석 완주):** **6-1차 (C-6111, 오프라인 분석 완주):**
- ① 생산제어: 전향맵 `스팀유량=f(피드,제품,목표T_C)` GBM R²0.99 (본질 steam/feed≈0.73) + shadow 94% 모방 + 롤링/OOD 안전 + 캠페인내 피드백 트림은 미미(컬럼 자기제어). - ① 생산제어: 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℃(조건기반). - ② 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 디코드). - 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 사용). - 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). - 코드: `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:** **핵심 gotcha:**
- 컬럼 디코드: `ptname→ptlist.pid→mapping(tid,oit)→cont{tbl}.col{oit:02d}`, 시간축 `dtat`. - 컬럼 디코드: `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`). 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 자동검증(미해결 태그 경고). 끝자리 규칙이 다르면 컬럼별 매핑표 작성. 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 컷인 트리거. 4. 컬럼별 산출: steam/feed비, 맵 R², shadow 일치율, startup 컷인 트리거.
**산출물**: 형제별 결과표(steam/feed, R², 컷인트리거) + 통합 startup 트리거(샘플↑로 reb-A/ΔT 변동성 재추정). **산출물**: 형제별 결과표(steam/feed, R², 컷인트리거) + 통합 startup 트리거(샘플↑로 reb-A/ΔT 변동성 재추정).
@@ -47,6 +125,12 @@
**주의**: 8/9/10은 컬럼 크기·운전조건 다를 수 있음 → steam/feed비·트리거가 6-1과 다르면 그게 정상(컬럼별 학습). 죽은 플랜트 3·4차 제외. 5차는 유형 미확인(§15.9). **주의**: 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] ## 작업 2 — ③ SHUTDOWN 절차 [우선순위 2]
@@ -67,6 +151,12 @@
**주의**: shutdown은 안전 최우선 — 급격 진공해제/스팀차단 순서가 장비보호에 중요. 데이터의 순서·rate를 그대로 보존. **주의**: 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단계] ## 작업 3 — (2) operator-assist 패키징 [우선순위 3, 현장투입 1단계]
@@ -77,6 +167,7 @@
**단계**: **단계**:
1. **예측 서비스화**(Python 먼저): `predict(live_tags) → {rec_OP, confidence(in/OOD), rec_steam_flow}`. 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 체크. - 입력 평활(인과 trailing), 운전점맵 예측→밸브역특성→OP, envelope 체크.
- 롤링 재학습 스케줄(일/주 단위, expanding 또는 trailing window). - 롤링 재학습 스케줄(일/주 단위, expanding 또는 trailing window).
2. **모드 인지**: 현재 운전모드(PROD/LINEOUT/STARTUP…) 분류(c6111_extract.classify_phases) → PROD에서만 ① 맵 조언, STARTUP이면 ② 레시피(컷인 게이트) 조언. 2. **모드 인지**: 현재 운전모드(PROD/LINEOUT/STARTUP…) 분류(c6111_extract.classify_phases) → PROD에서만 ① 맵 조언, STARTUP이면 ② 레시피(컷인 게이트) 조언.
@@ -89,6 +180,11 @@
**주의**: **절대 write 금지**(advisory_only). OOD·비-PROD에선 조언 보류(폴백). hunting 공포 고려 — 조언도 gentle(작은 변화). **주의**: **절대 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, 실플랜트 연결] ## 작업 4 — (5) live C# shadow 포팅 [우선순위 4, 실플랜트 연결]
@@ -98,7 +194,7 @@
**선행**: 작업3 로직 확정. 플랜트6 HC900 통신 살아있음(live값 가공이라도 경로 테스트 가능, §0.2). `ff_column_config` C-6111 advisory_only=t 이미 설정. **선행**: 작업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 산출. 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). 3. **운전모드 분류 + OOD** C#에 포팅(임계 §16.3-2, envelope §16.7).
4. shadow 로깅: `FfTrackingStore`에 권장 vs 실제 OP 기록. 화면 노출(advisory). 4. shadow 로깅: `FfTrackingStore`에 권장 vs 실제 OP 기록. 화면 노출(advisory).
@@ -110,11 +206,30 @@
**주의**: live값이 현재 시뮬/가공 → 정확도 평가는 field_hist 백테스트로, live는 통합·안전 검증용. write 금지. 실제 실데이터 확보 시 재검증. **주의**: 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(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(롤링). - 디코드/데이터: 플랜 §15. C-6111 토폴로지: §16.1. 방법론 교훈: §16.6(운전점), §16.7(OOD), §16.8(롤링).

View 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 평문 | ⬜ 보류 | 환경변수 전환 |

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

View File

@@ -1,10 +1,17 @@
""" """
C-6111 (6-1차 측류 정제 컬럼) 데이터 추출 + 운전모드 1차 특성 분석. 컬럼 데이터 추출 + 운전모드 1차 특성 분석.
field_hist DB(shinam 실데이터, WIDE 포맷)에서 ptlist/mapping/tblist로 태그를 디코드해 field_hist DB(shinam 실데이터, WIDE 포맷)에서 ptlist/mapping/tblist로 태그를 디코드해
tidy DataFrame을 만든다. 재사용 가능한 tag_frame() 추출기 포함. tidy DataFrame을 만든다. 재사용 가능한 tag_frame() 추출기 포함.
근거: docs/학습형제어-오퍼레이터모방-플랜.md §15(디코드), §16(C-6111 토폴로지). 근거: 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 sys
import psycopg import psycopg
@@ -13,27 +20,78 @@ import pandas as pd
DSN = "host=localhost port=5432 dbname=field_hist user=postgres password=postgres" DSN = "host=localhost port=5432 dbname=field_hist user=postgres password=postgres"
ASSET = "/ASSETS/P6" ASSET = "/ASSETS/P6"
# C-6111 역할별 태그 (ff_column_config/ff_stream_config + 사용자 도메인, 플랜 §16.1) # --- 형제 컬럼 역할 생성기 ---
ROLES = { # DB 검증 결과(2026-06-05) 기반 예외 오버라이드:
"feed": "FICQ-6101.PV", # 피드(주 외란) # P8(81): TICA에 A/B/C/D 접미사 없음, PICA-8111A (with A suffix)
"steam_op": "TICA-6111A.OP", # 리보일러 스팀 밸브(조작/OP) # P9(91): PICA-9111A (with A suffix). 92xx 2차 컬럼 존재
"steam_flow": "FIQ-6115.PV", # 실제 스팀 유량 # P10(101): FICQ-10114A (not 10114), PICA-10111A, LIA-10111 (not LICA). 102xx 2차 컬럼 존재
"reb_temp": "TICA-6111A.PV", # 리보일러 온도(A, 최고온) COLUMN_EXCEPTIONS = {
"T_B": "TI-6111B.PV", # 피드존 "81": {
"T_C": "TI-6111C.PV", # 민감단(제품 추출 트레이 근처) "steam_op": "TICA-8111.OP",
"T_D": "TI-6111D.PV", # 탑상(최저온) "reb_temp": "TICA-8111.PV",
"feed_preheat": "TI-6103.PV", # 원료 예열 "vacuum": "PICA-8111A.PV",
"vacuum": "PICA-6111.PV", # 진공압력 },
"dp": "PI-6111B.PV", # 컬럼 차압 "91": {
"product": "FICQ-6118.PV", # 측류 제품 P "vacuum": "PICA-9111A.PV",
"reflux": "FICQ-6113.PV", # 리플럭스 R },
"light": "FICQ-6114.PV", # 경질분 제거 D "92": {
"heavy": "FICQ-6116.PV", # 중질분 제거 B "vacuum": "PICA-9211A.PV",
"reb_level": "LI-6111.PV", # 리보일러 레벨 },
"reflux_drum": "LICA-6113.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): def resolve(conn, shorttags, asset=ASSET):
"""shortptname 목록 -> {tag: (tblname, colnum)}""" """shortptname 목록 -> {tag: (tblname, colnum)}"""
with conn.cursor() as cur: with conn.cursor() as cur:

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

View File

@@ -1,14 +1,12 @@
""" """
C-6111 ① 생산 정상상태 맵 (플랜 §16.3-5). ① 생산 정상상태 맵.
PROD 구간에서: PROD 구간에서 밸브특성 + 스팀유량 회귀.
1) 밸브특성: OP(TICA-6111A.OP) ↔ 스팀유량(FIQ-6115) — stiction/비선형/게인
2) 정상상태 세그먼트 추출
3) 회귀: 스팀유량 = f(피드, 리플럭스, 제품, 진공, ΔT…) + 피처중요도 + 시간분할 검증
"오퍼레이터 스팀이 가용변수로 얼마나 설명되나" (FIT/MAE)
선행: c6111_extract.py 가 만든 c6111_data.pkl (mode 컬럼 포함). 선행: c6111_extract.py 가 만든 c6111_data.pkl (mode 컬럼 포함).
형제 컬럼 호환: --data, --prefix CLI 인자.
""" """
import argparse
import numpy as np import numpy as np
import pandas as pd import pandas as pd
import matplotlib import matplotlib
@@ -20,14 +18,15 @@ from sklearn.preprocessing import StandardScaler
from sklearn.metrics import r2_score, mean_absolute_error from sklearn.metrics import r2_score, mean_absolute_error
BASE = "/home/windpacer/projects/hc900_ax/scripts/analysis/" BASE = "/home/windpacer/projects/hc900_ax/scripts/analysis/"
TARGET = "steam_flow" # FIQ-6115 (에너지). 비교용으로 steam_op도 출력 TARGET = "steam_flow"
# 깨끗한 인과입력만 (reflux/dp/dT는 스팀의 결과·동시조작 → 순환참조라 제외)
FEATURES = ["feed", "product", "vacuum", "feed_preheat", "T_C", "T_D"] FEATURES = ["feed", "product", "vacuum", "feed_preheat", "T_C", "T_D"]
OP_RESAMPLE = "6h" # 운전점 집계 (정상상태 내부 변동 적음 → 캠페인/로드레벨 단위 학습) OP_RESAMPLE = "6h"
def load(): def load(data_path=None):
df = pd.read_pickle(BASE + "c6111_data.pkl") if data_path is None:
data_path = BASE + "c6111_data.pkl"
df = pd.read_pickle(data_path)
df = df[df["mode"] == "PROD"].copy() df = df[df["mode"] == "PROD"].copy()
# 엔지니어링 피처: 온도 구배(분리도) # 엔지니어링 피처: 온도 구배(분리도)
df["dT_AC"] = df["reb_temp"] - df["T_C"] 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 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)) fig, ax = plt.subplots(1, 4, figsize=(22, 5))
ax[0].scatter(hb["op"], hb["flow"], s=20, c="k", label="mean") 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_up"], "b.-", ms=4, label="OP rising")
ax[0].plot(hb["op"], hb["flow_dn"], "r.-", ms=4, label="OP falling") 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[0].set_title("Valve char (hysteresis=stiction)"); ax[0].legend()
ax[1].scatter(ops["feed"], ops[TARGET], s=10, alpha=.5) 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[1].set_title("steam vs feed (operating points)")
ax[2].scatter(yte, pred, s=12, alpha=.5) ax[2].scatter(yte, pred, s=12, alpha=.5)
lim = [min(yte.min(), pred.min()), max(yte.max(), pred.max())] 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].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)") 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") 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) fig.tight_layout(); fig.savefig(BASE + f"{prefix}_prodmap.png", dpi=95)
print(f"\n플롯 저장: {BASE}c6111_prodmap.png") print(f"\n플롯 저장: {BASE}{prefix}_prodmap.png")
def main(): 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)}") print(f"PROD 정합데이터 {len(df)}")
hb, a = valve_char(df) hb, a = valve_char(df)
ops, gbm, Xte, yte, pred, imp = regress(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__": if __name__ == "__main__":

View File

@@ -1,10 +1,9 @@
""" """
C-6111 롤링(walk-forward) 재학습 — OOD/외삽 바이어스 해소 데모 (플랜 §16.7-(1)). 롤링(walk-forward) 재학습 — OOD/외삽 바이어스 해소 데모.
held-out 5월을 하루씩 전진하며 '그 날 이전 전체 이력(expanding window)'으로 매일 재학습→그 날 예측. 형제 컬럼 호환: --data, --prefix CLI 인자.
정적 모델(2~4월 고정)의 +4% 외삽 바이어스가 모델이 5월 저부하 데이터를 흡수하며
사라지는지(적응 곡선) + OOD 비율이 떨어지는지 확인. 입력 평활은 인과(trailing).
""" """
import argparse
import numpy as np import numpy as np
import pandas as pd import pandas as pd
import matplotlib import matplotlib
@@ -18,7 +17,11 @@ RETRAIN_EVERY = "1D"
def main(): 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["mode"] == "PROD"].copy()
df = df[(df["feed"] > 50) & (df["steam_flow"] > 10) & (df["steam_op"] > 1) df = df[(df["feed"] > 50) & (df["steam_flow"] > 10) & (df["steam_op"] > 1)
& df[FEATURES + ["steam_op"]].notna().all(axis=1)].sort_values("dtat") & 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() df[c + "_s"] = df[c].rolling(SMOOTH, min_periods=1).median()
ho = pd.Timestamp(HELDOUT_START) 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) days = pd.date_range(ho, df["dtat"].max(), freq=RETRAIN_EVERY)
# 정적 모델: 5월 이전 전체로 1회 학습 # 정적 모델: 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[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].plot(r.day, r.ood_roll, "b.-"); ax[1].set_ylabel("rolling OOD %")
ax[1].set_title("OOD fraction (학습 envelope 밖) — 5월 데이터 흡수하며 감소") ax[1].set_title("OOD fraction (학습 envelope 밖) — 5월 데이터 흡수하며 감소")
fig.tight_layout(); fig.savefig(BASE + "c6111_rolling.png", dpi=95) fig.tight_layout(); fig.savefig(BASE + f"{args.prefix}_rolling.png", dpi=95)
print(f"\n플롯 저장: {BASE}c6111_rolling.png") print(f"\n플롯 저장: {BASE}{args.prefix}_rolling.png")
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -1,12 +1,9 @@
""" """
C-6111 Shadow 예측기 — 히스토리 리플레이 백테스트 (플랜 §7 shadow 진입). Shadow 예측기 — 히스토리 리플레이 백테스트.
학습기간 운전점으로 `스팀유량=f(피드,제품,목표T_C)` 학습 → held-out 미래기간을 선행: c6111_data.pkl. 형제 컬럼 호환: --data, --prefix CLI 인자.
매 시점 리플레이하여 예측 스팀→(밸브 역특성)→예측 OP 를 산출, **실제 오퍼레이터 OP와 비교**.
"이 예측기를 shadow로 돌렸다면 오퍼레이터 손과 얼마나 일치했나" 를 정직 검증.
선행: c6111_data.pkl. 포팅대상(추후 C# live shadow)은 동일 로직.
""" """
import argparse
import numpy as np import numpy as np
import pandas as pd import pandas as pd
import matplotlib import matplotlib
@@ -16,9 +13,9 @@ from sklearn.ensemble import GradientBoostingRegressor
from sklearn.metrics import r2_score, mean_absolute_error from sklearn.metrics import r2_score, mean_absolute_error
BASE = "/home/windpacer/projects/hc900_ax/scripts/analysis/" BASE = "/home/windpacer/projects/hc900_ax/scripts/analysis/"
FEATURES = ["feed", "product", "T_C"] # 깨끗한 인과/목표 입력 (§16.6) FEATURES = ["feed", "product", "T_C"]
SMOOTH = 40 # 입력 평활 20분(운전점 성격 유지) SMOOTH = 40
TRAIN_FRAC = 0.70 # 앞 70% 기간 학습, 뒤 30% held-out shadow TRAIN_FRAC = 0.70
class SteamPredictor: class SteamPredictor:
@@ -42,7 +39,11 @@ class SteamPredictor:
def main(): 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["mode"] == "PROD"].copy()
df = df[(df["feed"] > 50) & (df["steam_flow"] > 10) & (df["steam_op"] > 1) df = df[(df["feed"] > 50) & (df["steam_flow"] > 10) & (df["steam_op"] > 1)
& df[FEATURES + ["steam_op"]].notna().all(axis=1)].sort_values("dtat") & df[FEATURES + ["steam_op"]].notna().all(axis=1)].sort_values("dtat")
@@ -91,8 +92,8 @@ def main():
err = te["pred_op"] - te["steam_op"] err = te["pred_op"] - te["steam_op"]
ax[2].hist(err, bins=80); ax[2].axvline(0, c="k", lw=.5) 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}%") 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) fig.tight_layout(); fig.savefig(BASE + f"{args.prefix}_shadow.png", dpi=95)
print(f"\n플롯 저장: {BASE}c6111_shadow.png") print(f"\n플롯 저장: {BASE}{args.prefix}_shadow.png")
if __name__ == "__main__": if __name__ == "__main__":

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

View File

@@ -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 numpy as np
import pandas as pd import pandas as pd
import matplotlib import matplotlib
@@ -61,7 +59,11 @@ def milestones(df, ci):
def main(): 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) cutins = detect_cutins(df)
print(f"탐지된 ★제품 컷인★(진짜 startup) 이벤트: {len(cutins)}") 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") ax[3].set_ylabel("feed"); ax[3].set_xlabel("minutes from product cut-in")
for a in ax: for a in ax:
a.axvline(0, c="k", lw=.5) a.axvline(0, c="k", lw=.5)
fig.tight_layout(); fig.savefig(BASE + "c6111_startup.png", dpi=95) fig.tight_layout(); fig.savefig(BASE + f"{args.prefix}_startup.png", dpi=95)
print(f"\n플롯 저장: {BASE}c6111_startup.png") print(f"\n플롯 저장: {BASE}{args.prefix}_startup.png")
if __name__ == "__main__": if __name__ == "__main__":

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

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

View File

@@ -103,6 +103,16 @@ builder.Services.AddSingleton<Hc900Crawler.Core.Application.Feedforward.ISimOver
builder.Services.AddSingleton<Hc900Crawler.Core.Application.Feedforward.ICompositionStore, builder.Services.AddSingleton<Hc900Crawler.Core.Application.Feedforward.ICompositionStore,
Hc900Crawler.Infrastructure.Control.CompositionStore>(); Hc900Crawler.Infrastructure.Control.CompositionStore>();
builder.Services.AddScoped<Hc900Crawler.Infrastructure.Control.FeedRampAdvisorService>(); 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 ─────────────────────────────────────────────────────────────── // ── MCP Service ───────────────────────────────────────────────────────────────
builder.Services.AddHttpClient(McpClient.HttpClientName, c => builder.Services.AddHttpClient(McpClient.HttpClientName, c =>

View File

@@ -38,7 +38,9 @@
"Enabled": true "Enabled": true
}, },
"Feedforward": { "Feedforward": {
"SimOverrideEnabled": true "SimOverrideEnabled": true,
"FeedRampDryRun": false,
"FeedRampStepSec": 10
}, },
"McpServer": { "McpServer": {
"WorkingDirectory": "../../mcp-server" "WorkingDirectory": "../../mcp-server"
@@ -62,6 +64,9 @@
"LockoutMinutes": 15 "LockoutMinutes": 15
} }
}, },
"SteamAdvisor": {
"ModelPath": "/home/windpacer/projects/hc900_ax/scripts/analysis/c6111_model.json"
},
"Kestrel": { "Kestrel": {
"Endpoints": { "Endpoints": {
"Http": { "Http": {

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