Compare commits
2 Commits
main
...
4306f76ddb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4306f76ddb | ||
|
|
1bc46b1eb0 |
236
docs/작업지시서-학습형제어-다음단계.md
Normal file
236
docs/작업지시서-학습형제어-다음단계.md
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
# 작업지시서 — 학습형 제어 다음 단계 (6-1차 이후)
|
||||||
|
|
||||||
|
> 작성 2026-06-05. 전체 설계·진행로그는 `docs/학습형제어-오퍼레이터모방-플랜.md`(특히 §0 오리엔테이션, §15 데이터, §16 분석).
|
||||||
|
> 메모리: `~/.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, 오프라인 분석 완주):**
|
||||||
|
- ① 생산제어: 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). ⚠️ 이 테이블들은 **`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`.
|
||||||
|
- **운전점 집계가 필수**: 정상상태 98%라 점단위 회귀는 음의 R² → 6h 중앙값으로 집계해야 맞음(§16.6).
|
||||||
|
- **OOD 게이트 필수**: 새 로드레짐은 외삽 실패 → 롤링 재학습 + OOD→오퍼레이터 폴백(§16.7~8).
|
||||||
|
- pandas `df.product`는 메서드와 충돌 → `df["product"]`. col은 0패딩(`col03`). `pd.read_sql`는 psycopg3에 경고만(동작OK).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 작업 1 — (4) 6-2·8·9·10 형제 컬럼 확장 [우선순위 1]
|
||||||
|
|
||||||
|
**목적**: 동일 코드가 형제 측류 솔벤트 컬럼(6-2, 8, 9, 10차)에 그대로 도는지 검증 + startup 샘플 보강.
|
||||||
|
|
||||||
|
**선행 확인**:
|
||||||
|
1. 형제 컬럼 토폴로지 출처: `ff_column_config`엔 **C-6111(6-1)만** 있을 가능성 → 6-2/8/9/10은 **태그 네이밍 규칙으로 ROLES 유도**.
|
||||||
|
- 6-1 규칙(끝자리 역할): feed=FICQ-**6101**, reflux=FICQ-6113, light(D)=6114, heavy(B)=6116, product(P)=6118, steam밸브=TICA-**6111A**.OP, steam유량=FIQ-6115, 민감단=TI-6111**C**, 진공=PICA-6111, DP=PI-6111B, 온도프로파일 TI-6111A/B/C/D.
|
||||||
|
- 일반화: 차수·train 접두 `{P}1` (6-1=61, 6-2=62, 8=81, 9=91, 10=101). 즉 6-2=FICQ-6201/6213/6214/6216/6218, TICA-6211A 등.
|
||||||
|
2. **반드시 ptlist로 검증**: 각 형제의 실제 태그가 같은 끝자리 규칙인지 쿼리로 확인(8/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 자동검증(미해결 태그 경고). 끝자리 규칙이 다르면 컬럼별 매핑표 작성.
|
||||||
|
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 변동성 재추정).
|
||||||
|
|
||||||
|
**검증기준**: 각 형제 생산맵 R²>0.95(운전점), shadow in-envelope 일치율>85%. startup 컷인 트리거가 6-1과 정합(같은 솔벤트 유형이면 유사 예상).
|
||||||
|
|
||||||
|
**주의**: 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]
|
||||||
|
|
||||||
|
**목적**: 안전 정지 절차 학습(startup의 역순). few-shot.
|
||||||
|
|
||||||
|
**선행**: `c6111_startup.py`를 템플릿으로. SHUTDOWN = **제품 컷오프(product >100→0 하강엣지)** 기준.
|
||||||
|
|
||||||
|
**단계**:
|
||||||
|
1. `detect_cutins`를 미러링한 `detect_cutoffs` 작성: product 하강엣지(>100→<50)이고 직후 steam도 내려감(shutdown 진입) 탐지.
|
||||||
|
2. 컷오프 정렬 후 역시퀀스 추출: 생산 → **피드 감소** → **제품 컷오프** → 전환류 복귀? → **스팀 차단** → **진공 해제** → 냉각.
|
||||||
|
3. 단계 타이밍 + 트리거: "언제 피드 줄이기 시작? 제품 컷오프 시 컬럼상태? 스팀 차단 순서?" 추출.
|
||||||
|
4. startup 에피소드 목록(02-15, 04-30, 05-02, 05-13 등)에 대응하는 shutdown이 직전에 있음(같은 에피소드의 앞부분) → 재활용.
|
||||||
|
|
||||||
|
**산출물**: SHUTDOWN 레시피(단계 시퀀스+타이밍+트리거) + 플롯(컷오프 정렬 중첩).
|
||||||
|
|
||||||
|
**검증기준**: 깨끗한 shutdown ≥3건에서 시퀀스 일관. 안전 관점(급격 차단 없이 순차 감소) 확인.
|
||||||
|
|
||||||
|
**주의**: 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단계]
|
||||||
|
|
||||||
|
**목적**: ①의 예측기를 **"권장 OP + 신뢰도" 자문 출력**으로 패키징(write 안 함). 현장 신뢰구축 단계.
|
||||||
|
|
||||||
|
**선행**: `SteamPredictor`(c6111_shadow.py) + OOD 게이트 + 롤링 재학습 로직.
|
||||||
|
|
||||||
|
**단계**:
|
||||||
|
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이면 ② 레시피(컷인 게이트) 조언.
|
||||||
|
3. **출력 UI**: 기존 `...AdvisorService`/FF 자산(FeedforwardSupervisor, FfTrackingStore) 패턴으로 화면에 "권장 OP=X% (신뢰: 구간내/범위밖)" 표시 + 오퍼레이터 실조작 병행 로깅(비교).
|
||||||
|
4. shadow 로깅: 권장 vs 실제 OP 차이 누적 → 신뢰 리포트.
|
||||||
|
|
||||||
|
**산출물**: 자문 API/서비스 + 화면 + 권장-vs-실제 로그/리포트.
|
||||||
|
|
||||||
|
**검증기준**: 신뢰구간에서 권장 OP가 오퍼레이터와 ±2% 이내 90%+(§16.7 재현). OOD 시 "범위밖→수동" 정확 표시.
|
||||||
|
|
||||||
|
**주의**: **절대 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, 실플랜트 연결]
|
||||||
|
|
||||||
|
**목적**: 검증된 예측기를 C# 크롤러에 포팅, **플랜트6 live(gRPC)** 에 shadow 연결.
|
||||||
|
|
||||||
|
**선행**: 작업3 로직 확정. 플랜트6 HC900 통신 살아있음(live값 가공이라도 경로 테스트 가능, §0.2). `ff_column_config` C-6111 advisory_only=t 이미 설정.
|
||||||
|
|
||||||
|
**단계**:
|
||||||
|
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).
|
||||||
|
5. 롤링 재학습: 주기적 Python 재학습→계수 갱신 파이프(또는 C#서 온라인 회귀).
|
||||||
|
|
||||||
|
**산출물**: C# `SteamAdvisor` 서비스(advisory) + live shadow 로그 + UI.
|
||||||
|
|
||||||
|
**검증기준**: live 태그로 권장 OP 산출·표시 동작. (live 데이터가 가공이라 정확도보다 **경로·안전(OOD폴백)·로깅** 검증 우선.)
|
||||||
|
|
||||||
|
**주의**: 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 완료 (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(롤링).
|
||||||
|
- 코드 시작점: `scripts/analysis/c6111_extract.py`(tag_frame, ROLES, classify_phases), `c6111_shadow.py`(SteamPredictor).
|
||||||
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 평문 | ⬜ 보류 | 환경변수 전환 |
|
||||||
658
docs/학습형제어-오퍼레이터모방-플랜.md
Normal file
658
docs/학습형제어-오퍼레이터모방-플랜.md
Normal file
@@ -0,0 +1,658 @@
|
|||||||
|
# 학습형 제어 (오퍼레이터 수동조작 모방) — 설계 플랜
|
||||||
|
|
||||||
|
> 상태: 설계(design) 단계. 코드 착수 전.
|
||||||
|
> 작성 맥락: PID hunting 회피를 위해 현장 MANUAL 운전 이력을 학습해 OP를 자동 산출하는 제어기 설계 논의 보존.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 오리엔테이션 — 다음 세션의 나에게 (READ FIRST)
|
||||||
|
|
||||||
|
> 이 블록은 다음에 이 문서를 여는 에이전트(=미래의 나)가 **지금과 동일한 사고 상태로 즉시 몰입**하기 위한 것이다. 아래를 먼저 읽고 §1~§13으로 들어가라.
|
||||||
|
|
||||||
|
### 0.1 이 문서가 뭔가
|
||||||
|
HC900 프로젝트(`/home/windpacer/projects/hc900_ax`, CLAUDE.md 참조)의 **신규 작업 줄기**에 대한 설계 플랜이다. 본체 프로젝트는 "HC900 Modbus→C++ gateway→gRPC→C# 크롤러→PostgreSQL"로 Experion OPC UA 경로를 대체하는 일이고, **이 문서는 그 위에 얹는 "학습형 제어기"** 설계다. 아직 **코드는 한 줄도 안 짰다.** 전부 설계 합의 단계.
|
||||||
|
|
||||||
|
### 0.2 사용자가 진짜 풀려는 문제 (가장 중요 — 이걸 잊으면 길을 잃는다)
|
||||||
|
- 현장(6차 플랜트)은 **PID를 못 믿어서 거의 모든 루프를 MANUAL로 돌리고 오퍼레이터가 손으로 OP를 잡는다** (진공제어만 PID/AUTO 예외).
|
||||||
|
- 사용자가 **가장 두려워하는 단 하나 = hunting(지속 진동)**. PID 교란성 발진. 이 공포가 모든 설계 결정의 1순위 제약이다.
|
||||||
|
- 따라서 목표는 "PID를 잘 튜닝"이 **절대 아니다.** **오퍼레이터의 수동 조작을 학습해 자동화**하되, **구조적으로 안 떠는** 제어기를 만드는 것. 평가 기준은 "잘 튜닝된 PID"가 아니라 **"현재 신뢰받는 오퍼레이터"**.
|
||||||
|
|
||||||
|
### 0.3 이미 굳은 핵심 결정 (재론하지 말 것 — 사용자와 합의됨)
|
||||||
|
1. **접근법 = 정상상태 맵**: `OP_ss = f(정착 PV, 부하변수)`. 원시 behavior cloning(`OP=f(PV,SP)`) 아님. (§4)
|
||||||
|
2. **SP를 정답으로 안 쓴다**: 현장 SP 신뢰도 ~80%(MANUAL이라 방치 가능). **정착 PV = de facto target**으로 사용. (§4)
|
||||||
|
3. **안 떠는 이유 = 전향맵(피드백 없음) + 데드밴드 + dwell + rate-limit.** stiction은 어떤 제어로도 못 고치며 "덜 움직이기"만이 답. (§5)
|
||||||
|
4. **검증 = shadow 모드(실플랜트, write 안 함) > 시뮬레이터.** 시뮬은 선택. (§6)
|
||||||
|
5. **로드맵 = 학습 → shadow → operator-assist → guarded closed-loop**, 이상 시 fallback은 PID가 아니라 **오퍼레이터에게 반환**. (§7)
|
||||||
|
6. **RL/imitation은 실증 부족(sim2real 미해결)으로 1차 투입 부적합.** 실증된 건 **MPC(2단 구조)**·MFAC·퍼지/전문가 supervisory. 우리 "정상상태 맵+gentle"이 마침 **MPC 2단 구조(LP 정상상태 타깃 + 동적 move-suppress)와 동형** → de-risk. (§11·§12)
|
||||||
|
7. **착수 1순위 = 기둥 A(루프 헬스 모니터링, APROMON류).** dump만 있으면 즉시 가능하고 파일럿 루프를 데이터로 객관 선정해준다. (§13.1)
|
||||||
|
8. **개발은 6차 플랜트 기준으로 먼저, 이후 차수 확장.** 이유: 6차는 **HC900 통신이 살아있어**(현재 live 값은 가공이라도) 실 제어경로(shadow→assist→closed) 테스트 가능. 6·8·9·10차는 **측류 반도체용 솔벤트**로 동일 유형 → 6차용을 만들면 8·9·10에 그대로 확장. 1·2차는 **측류 아닌 2컬럼 경질/중질 제거 일반 증류**라 별도. (§15.9)
|
||||||
|
9. **★개발 범위 = 6-1차 단독★** (6-1/6-2 독립운전). 6-1차 활성 제어루프 6개: **TICA-6111A**(파일럿) + **FICQ-6101/6113/6114/6116/6118**. 제외: PICA-6111(진공), LICA(사장). 6-1차용 완성 → 6-2·8·9·10 복제. 피처는 **6-1차 내부 변수만** 사용. (§15.11) 컬럼 토폴로지·센서위치·스트림역할은 §16.1(기존 ff_config + 사용자 도메인)에 권위 정의.
|
||||||
|
10. **★학습 대상 3종★**: ①정상생산 제어(정상상태 맵, 현재) + ②START-UP 절차 + ③SHUTDOWN 절차(②③=시퀀스/절차 모방, 안전 자동화, 추후). 6모드 상태분류기가 ①필터+②③골격 이중역할. **과도구간 데이터 버리지 말 것.** (§16.4)
|
||||||
|
|
||||||
|
### 0.4 데이터 자산 (반입·복원 완료 2026-06-05 — 상세 §15)
|
||||||
|
- **dump 반입 완료**: 현장(신암정유) DB `shinam`(PG9.5) → 별도 DB **`field_hist`**(우리 PG16) 복원 완료. 라이브 `iiot_platform`/`hc900` 안 건드림.
|
||||||
|
- 기간 **2026-02-05~06-05 (~4개월), 간격 주로 30초**. WIDE 포맷: `cont001~017` + `ptlist/tblist/mapping` 디코드. 시계열 복원 규칙·검증은 §15.3.
|
||||||
|
- **OP(106)·SP(114)·PV(456) 모두 존재 → PV+SP+OP 완비 루프 104개.** 학습 대상 확보(make-or-break 통과).
|
||||||
|
- ⚠️ **MODE 태그는 없음** (100% MANUAL이라 미기록 추정). → **진공제어(PID) 루프 식별·제외 방법이 미해결** (§15.5).
|
||||||
|
- ★통찰★: MANUAL OP 계단조작 = 개루프 step 데이터 → 시스템 식별 유리. bump test 보조. (§13.0)
|
||||||
|
- 30초 해상도: 느린 루프(온도/레벨) OK, 빠른 루프(유량/압력) 동역학·stiction은 aliasing 한계.
|
||||||
|
|
||||||
|
### 0.5 지금 막혀있는 곳 / 다음 행동
|
||||||
|
- **데이터 확보됨**(`field_hist`). 다음: ① (선택) cont 테이블 하이퍼테이블+압축 → ② **§13.1 기둥 A KPI**(진동지수·IAE·OP travel·stiction) → ③ 파일럿 루프 선정 → ④ SP 신뢰도 실측 + 정상상태 세그먼테이션 → ⑤ `OP_ss=f(정착PV,부하)` 맵.
|
||||||
|
- **사용자 확인 대기**: 진공(PID) 루프를 태그 패턴/지식으로 어떻게 가려낼지 (§15.5).
|
||||||
|
- 미해결 질문 전체는 §10·§15.5.
|
||||||
|
- ★**남은 작업(형제확장·shutdown·operator-assist·live포팅)은 `docs/작업지시서-학습형제어-다음단계.md`에 실행 지시서로 정리됨.** ①생산제어·②startup은 6-1차 오프라인 완료(§16.6~16.10).
|
||||||
|
|
||||||
|
### 0.6 톤/태도 (사용자와의 작업 방식)
|
||||||
|
- 사용자는 한국어로 빠르게 핵심만 던진다. **장황한 옵션 나열 말고 추천을 줘라.** 단, 안전·hunting 관련해선 트레이드오프를 정직하게.
|
||||||
|
- 이 문서는 **사용자가 "기록/플랜에 남겨"라고 하면 즉시 갱신**해온 살아있는 문서다. 큰 결정이 바뀌면 §0.3을 먼저 고쳐라.
|
||||||
|
|
||||||
|
### 0.7 관련 포인터
|
||||||
|
- 본체 지침: `/home/windpacer/projects/hc900_ax/CLAUDE.md`
|
||||||
|
- 메모리: `learned-control-operator-imitation.md` (이 줄기), `mode-write-mechanism.md`(OP/MODE 쓰기 메커니즘), `project_overview.md`
|
||||||
|
- 기능 벤치마크 원문: `docs/PITOPS-브레인스토밍-웹페이지복사.md`
|
||||||
|
- 기존 FF 자산 재활용: `FeedforwardWriteGuard`(상하한/rate-limit), `FfTrackingStore`(모델 결정 로깅), `...AdvisorService` 네이밍
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 배경 / 문제 정의
|
||||||
|
|
||||||
|
- 현장에서 **PID가 제대로 안 쓰임** → 오퍼레이터가 루프를 **MANUAL로 돌려놓고 손으로 OP를 조정**.
|
||||||
|
- 가장 두려워하는 것: **PID 교란에 의한 hunting(지속 진동)**.
|
||||||
|
- 따라서 목표는 "PID를 더 잘 튜닝"이 **아니라**, **오퍼레이터의 수동 조작을 학습해 자동화**하는 것. 비교 기준은 "잘 튜닝된 PID"가 아니라 "현재 신뢰받는 오퍼레이터".
|
||||||
|
- 오퍼레이터는 본질적으로 hunting을 안 함 → **오퍼레이터를 모방하면 "안 떠는" 성질을 그대로 물려받음**.
|
||||||
|
|
||||||
|
## 2. 목표
|
||||||
|
|
||||||
|
- 현장 실 운전데이터로 `OP`(제어출력)를 학습 → 그 모델로 제어.
|
||||||
|
- **hunting 없이** 동작하는 것이 최우선 제약.
|
||||||
|
- 단계적·안전 우선 투입 (사람을 루프에 두고 시작).
|
||||||
|
|
||||||
|
## 3. 데이터 자산 (확정)
|
||||||
|
|
||||||
|
- 현장 DB 서버에 **PostgreSQL, 30초 주기, 몇 달치** 이력 존재. `pg_dump`으로 반입 예정.
|
||||||
|
- **OP 로깅됨** ✅ (제어출력 학습 가능 — make-or-break 통과)
|
||||||
|
- **100% MANUAL 운전 데이터** (진공제어만 PID/AUTO 예외) → 통째로 *오퍼레이터 시연 데이터*. MODE 필터링 거의 불필요.
|
||||||
|
- **모든 플랜트 변수 존재**: 온도, 압력, 유량, 레벨, 진공압력 → `f(목표, 부하)`의 입력 확보.
|
||||||
|
- **SP 신뢰도 ~80%**: MANUAL이라 오퍼레이터가 SP를 자기 목표값으로 정확히 설정했는지 불확실(20%는 stale 가능).
|
||||||
|
|
||||||
|
### 해상도 주의 (30초)
|
||||||
|
| 루프 종류 | 30초로 가능 |
|
||||||
|
|---|---|
|
||||||
|
| 온도/레벨/조성 (τ 분~시간) | 동역학·stiction 한계주기 ✅, 시스템ID ✅ |
|
||||||
|
| 유량/압력 (τ 초) | 빠른 동역학·stiction은 **aliasing으로 소실** ❌ (정상상태 맵은 학습 가능) |
|
||||||
|
|
||||||
|
→ 30초 해상도는 **gentle 제어기(정상상태 맵 + 데드밴드)** 방향을 지지함. 공격적 RL/고정밀 동역학 시뮬에는 부족.
|
||||||
|
|
||||||
|
## 4. 핵심 전략: 정상상태 운전 맵 (Steady-State Map)
|
||||||
|
|
||||||
|
원시 behavior cloning(`OP=f(PV,SP)`) 대신 **정상상태 맵**을 학습:
|
||||||
|
|
||||||
|
```
|
||||||
|
OP_ss = f(정착 PV, 부하변수) ← SP를 아예 안 거침
|
||||||
|
```
|
||||||
|
|
||||||
|
근거:
|
||||||
|
- **SP 80% 신뢰도 문제 우회** — MANUAL 정상상태에서 오퍼레이터가 OP를 잡아두고 PV가 안정돼 있으면 **그 PV가 곧 진짜 목표**(SP 입력값과 무관). 정착 PV = de facto target.
|
||||||
|
- **구조적으로 안 뜸** — 전향(feedforward) 맵은 피드백 루프가 없어 자기 혼자 진동 불가.
|
||||||
|
- **설명 가능** — "이 조건이면 밸브는 이 위치"라는 오퍼레이터 지식의 모델화. 블랙박스 NN보다 현장 수용성↑.
|
||||||
|
|
||||||
|
### SP의 역할 = 보조
|
||||||
|
- SP 신뢰도는 **루프별로 데이터로 실측**: 정상상태 구간마다 `|SP − 정착PV|` 분포 → 작으면 신뢰, 지속적으로 벌어지면 stale → 그 구간 폐기.
|
||||||
|
- "80%"를 전역으로 받지 말고 루프별 차등 적용.
|
||||||
|
- 전이(transition) 구간의 목적지 힌트로만 SP 보조 사용(PV와 일치할 때 한정).
|
||||||
|
- **배포 시 기준값은 SP 레지스터가 아니라 오퍼레이터/레시피 입력 목표**에서.
|
||||||
|
|
||||||
|
## 5. Hunting — 원인과 방지
|
||||||
|
|
||||||
|
### 원인 4가지
|
||||||
|
| 원인 | 증상 | 처방 |
|
||||||
|
|---|---|---|
|
||||||
|
| ① 과도한 게인 | 감쇠 안 되는 진동 | 게인↓ (느린 제어) |
|
||||||
|
| ② 데드타임/지연 | 주기 ≈ 2×데드타임 | **dwell time ≥ 데드타임** |
|
||||||
|
| ③ 밸브 stiction | OP 톱니/PV 삼각파 **한계주기** | **어떤 PID로도 못 잡음** — 데드밴드로 가두고 거의 안 움직이기 |
|
||||||
|
| ④ PV 노이즈 | OP 고주파 chattering | 데드밴드 + 필터 |
|
||||||
|
|
||||||
|
③ stiction이 오퍼레이터가 MANUAL로 도망가는 주 원인일 가능성 높음. 사람은 stiction 영역에서 OP를 가만히 둠.
|
||||||
|
|
||||||
|
### 학습 제어기가 안 떠는 이유 (정밀)
|
||||||
|
- **전향 맵 부분** = 피드백 없음 → 절대 자기발진 불가.
|
||||||
|
- **느린 트림(feedback) 부분** = 반드시 **데드밴드 + dwell + rate-limit**으로 이산·저속화 → ②③④ 동시 차단.
|
||||||
|
- "안 떠는 건 데드밴드/dwell이 만든다."
|
||||||
|
|
||||||
|
### Hunting 자동 감지기 (안전 폴백 트리거)
|
||||||
|
- 최근 N분간 오차 `(SP−PV)` 부호 반전 ≥ k회, 또는 OP 방향 반전 ≥ k회 + 진폭 초과
|
||||||
|
- 감지 시 → **OP 동결 → 오퍼레이터에 반환** + 경보
|
||||||
|
|
||||||
|
## 6. 검증 전략: Shadow 모드 > 시뮬레이터
|
||||||
|
|
||||||
|
실 MANUAL 데이터가 있으므로 **시뮬레이터는 더 이상 전제조건이 아님**:
|
||||||
|
- 학습: 실데이터로 바로 → 시뮬 불필요.
|
||||||
|
- 검증: **shadow 모드(실플랜트)가 더 나은 검증기** — 플랜트가 이미 MANUAL로 도니, 모델이 실시간 OP를 *예측만* 하고 오퍼레이터 실제 조작과 비교. write 안 하니 위험 0, 진짜 오퍼레이터 상대 정직한 검증, distribution shift도 즉시 노출.
|
||||||
|
- 시뮬은 **선택사항**(hunting 감지기·fallback을 드문 시나리오로 스트레스 테스트할 때만). 필요 시 같은 데이터로 system-ID해 보정(느린 루프 한정).
|
||||||
|
|
||||||
|
## 7. 로드맵 (단계적·안전 우선)
|
||||||
|
|
||||||
|
```
|
||||||
|
① 실 MANUAL 데이터로 정책 학습 OP_ss = f(정착PV, 부하…)
|
||||||
|
↓
|
||||||
|
② Shadow 모드 (실플랜트, write 안 함)
|
||||||
|
오퍼레이터 vs 모델 실시간 비교 — 위험 0
|
||||||
|
↓
|
||||||
|
③ Operator-assist "권장 OP=X" 화면 표시, 사람이 누름 (신뢰 구축)
|
||||||
|
↓
|
||||||
|
④ Guarded closed-loop 데드밴드+dwell+rate-limit+상하한 + hunting 감지
|
||||||
|
↓
|
||||||
|
이상 시 fallback = 오퍼레이터에게 반환 (PID 아님 — PID는 못 믿음)
|
||||||
|
```
|
||||||
|
|
||||||
|
기존 자산 활용: `FeedforwardWriteGuard`(상하한/rate-limit), `FfTrackingStore`(모델 결정 로깅), `...AdvisorService` 네이밍이 자문 모드에 부합.
|
||||||
|
|
||||||
|
## 8. 리스크 / 확인 항목
|
||||||
|
|
||||||
|
- **Distribution shift (imitation 고질병)**: 학습 정책이 오퍼레이터가 안 가본 상태에 들어가면 오차 누적. 완화책 = 몇 달치 넓은 커버리지 + gentle 정책으로 분포 근처 유지 + shadow/assist 선행.
|
||||||
|
- **OP 변경 빈도 측정**: 드물게 계단식이면(대부분 그럴 것) 정책이 사실상 정적 맵 = 공짜 gentle 제어기.
|
||||||
|
- **off-spec 구간 학습 위험**: 오퍼레이터가 불량값 잡고 있던 구간. 품질/생산 데이터로 필터, 없으면 shadow+assist 단계에서 사람이 거름.
|
||||||
|
- **Causality/confounding**: 오퍼레이터가 DB 밖 정보(육안, 랩 샘플, 전화)로 움직였을 수 있음. 잔차/피처 분석으로 "가용 변수가 OP를 얼마나 설명하나" 측정.
|
||||||
|
- **OP 레지스터 RW 확인**: register-map에서 대상 루프 OP의 access.
|
||||||
|
|
||||||
|
## 9. 다음 스텝 (dump 반입 후 즉시)
|
||||||
|
|
||||||
|
1. dump을 **별도 스키마**(예: `field_history`)로 restore — 라이브 `hc900` 안 건드림. 용량 ≈ 수억 행(2000태그×30초×3개월), Timescale 압축 권장.
|
||||||
|
2. 루프별 **SP 신뢰도 실측** (`|SP−정착PV|` 분포)
|
||||||
|
3. **정상상태 세그먼트 추출** (dPV/dt≈0, dOP/dt≈0)
|
||||||
|
4. **`OP_ss = f(정착PV, 부하)` 맵 학습** + 잔차/피처 분석
|
||||||
|
5. **stiction 1차 진단** (느린 루프 한정)
|
||||||
|
6. **파일럿 루프 1개 선정** (느리고 안정적인 온도/레벨 우선, 빠른 유량은 나중)
|
||||||
|
|
||||||
|
## 10. 미해결 질문
|
||||||
|
|
||||||
|
- [ ] dump 반입 시점?
|
||||||
|
- [ ] 현장 DB **스키마(테이블·컬럼명)** — 쿼리 설계용
|
||||||
|
- [ ] 파일럿 후보: "오퍼레이터가 제일 안정적으로 잘 잡는 루프"는? (현장 감 + 데이터 양쪽)
|
||||||
|
- [ ] 품질/생산 데이터 존재 여부 (off-spec 필터용)
|
||||||
|
- [ ] 대상 루프 OP가 register-map에서 RW인지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 참고 문헌 — 실증된 제어 기법 (실증·인용 1·2위)
|
||||||
|
|
||||||
|
> 선정 기준: **실플랜트 실증 이력 + 인용수**. RL/imitation 계열은 논문은 많으나
|
||||||
|
> 실플랜트 실증이 빈약(sim-to-real 미해결)해 1차 투입 부적합 → 아래 두 기법이 검증된 선택지.
|
||||||
|
|
||||||
|
### 1위 — 산업용 MPC (Model Predictive Control)
|
||||||
|
- **문헌**: S.J. Qin & T.A. Badgwell, *A Survey of Industrial Model Predictive Control Technology*, Control Engineering Practice **11** (2003) 733–764.
|
||||||
|
- **링크**: https://www.sciencedirect.com/science/article/abs/pii/S0967066102001867
|
||||||
|
- **요지**:
|
||||||
|
- 상용 MPC(선형·비선형)를 **벤더 실사 데이터** 기반으로 정리한 서베이. 제어 분야 **최다 인용급(수천 회)**.
|
||||||
|
- 1980년대 **DMC(Dynamic Matrix Control, Shell/Cutler)·IDCOM** 이래 정유·석화에 **수천 개 실제 설치** — 비-PID 고급제어의 사실상 산업 표준.
|
||||||
|
- 핵심 특성: **미래 예측 + 제약(상하한·rate) 명시적 준수 + 다변수 처리**. 잘못 튜닝된 PID보다 hunting 위험이 낮고, 운전 한계를 직접 지킴.
|
||||||
|
- **우리 적용**:
|
||||||
|
- 30초 운전데이터로 **느린 루프(온도/레벨)는 동역학 모델 ID 가능** → MPC 구성 가능. 빠른 유량은 ID 한계(해상도 부족).
|
||||||
|
- 단점: 좋은 동역학 모델이 전제. 우리의 "정상상태 맵 + 데드밴드 트림"은 이 모델 의존도를 낮춘 실용적 절충.
|
||||||
|
|
||||||
|
### 2위 — MFAC (Model-Free Adaptive Control)
|
||||||
|
- **문헌**: Zhongsheng Hou & Shangtai Jin, *Model Free Adaptive Control: Theory and Applications*, CRC Press (2013). 원개념 Hou, 1994.
|
||||||
|
- **링크**: https://www.taylorfrancis.com/books/mono/10.1201/b15752/model-free-adaptive-control-zhongsheng-hou-shangtai-jin
|
||||||
|
- **요지**:
|
||||||
|
- **모델 없이 입출력(I/O) 측정 데이터만으로** 제어기 설계·안정성 분석. 핵심은 **동적 선형화(dynamic linearization, pseudo-partial-derivative)** — 매 시점 등가 선형모델을 데이터로 추정.
|
||||||
|
- 미지의 이산시간 **비선형·시변** 시스템 대상. 1차 원리/system-ID로 모델링이 어려운 복잡 공정에 적합.
|
||||||
|
- 산업 적용 사례 다수(주로 중국). MFAC + 예측제어 + 반복학습제어(ILC)로 확장.
|
||||||
|
- **우리 적용**:
|
||||||
|
- "PID·모델 없이 데이터로 제어"라는 우리 취지에 직접 부합. MPC처럼 사전 모델이 필요 없음.
|
||||||
|
- 단 적응 게인이 들어가므로 **hunting 방지 가드(데드밴드·dwell·rate-limit)와 병행** 필요.
|
||||||
|
|
||||||
|
### 우리 방향과의 관계
|
||||||
|
우리가 설계한 **"정상상태 맵 + gentle 제어(데드밴드/dwell)"** 는 화려한 RL보다 **MPC/supervisory 계보(수십 년 실증)에 가깝다.** 즉 방향이 검증된 쪽에 붙어 있어 de-risk됨. 추가로 **퍼지/전문가 시스템 supervisory control**(시멘트 킬른·철강 등)은 "오퍼레이터 지식→자동화"를 수십 년 실증한 가장 직접적 계보 — 별도 사례조사 가치 있음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 1위 심화 — 산업용 MPC 정밀 분석
|
||||||
|
|
||||||
|
> ★핵심 발견★: 상용 MPC는 **2단 구조**이며, 우리가 독립적으로 설계한
|
||||||
|
> "정상상태 맵 + gentle 동적 제어"가 **산업 표준 아키텍처와 동형**.
|
||||||
|
|
||||||
|
### 12.1 동작 원리 — Receding Horizon
|
||||||
|
매 주기: ① 모델로 미래구간(P) PV 예측(미래 OP 시퀀스 M의 함수) → ② 비용
|
||||||
|
`J = Σ(SP−PV_예측)² + λ·Σ(ΔOP)²` 을 제약(`OP_min/max`, `|ΔOP|≤rate`, `PV 한계`)
|
||||||
|
하에 최소화 → ③ **첫 수만 적용**, 다음 주기 재측정 후 재최적화.
|
||||||
|
PID는 "현재 오차"만, **MPC는 "미래 궤적"을 본다**는 게 본질 차이.
|
||||||
|
|
||||||
|
### 12.2 ★상용 MPC = 2단 구조 (우리 설계와 동형)★
|
||||||
|
Aspen DMC3 / Honeywell Profit Suite 등 상용 패키지 구조:
|
||||||
|
```
|
||||||
|
[상층] LP/정상상태 최적화 → "어디로" (정상상태 OP/PV 타깃)
|
||||||
|
[하층] 동적 MPC → "어떻게 부드럽게" (move 최소화)
|
||||||
|
```
|
||||||
|
→ **우리 `OP_ss=f(정착PV,부하)` 맵 = 상층 LP의 데이터 기반판. 데드밴드/dwell 트림
|
||||||
|
= 하층 동적 MPC의 경량판.** 우리 설계가 수십 년 검증된 표준 골격과 같음 = de-risk.
|
||||||
|
|
||||||
|
### 12.3 왜 PID보다 hunting이 적나
|
||||||
|
| Hunting 원인 | MPC 처리 |
|
||||||
|
|---|---|
|
||||||
|
| ② 데드타임 | 모델이 지연응답을 명시 예측 → "효과 볼 때까지 대기". **MPC 최대 강점** |
|
||||||
|
| ① 과도 게인 | move suppression λ로 공격성 조절 (λ↑ = gentle = 오퍼레이터처럼) |
|
||||||
|
| 다변수 간섭 | 모델이 상호작용 포함 → decoupling 내장 |
|
||||||
|
| 제약/windup | OP·rate 한계를 최적화에 명시 → windup성 진동 없음 |
|
||||||
|
|
||||||
|
단 **③ stiction(한계주기)은 MPC도 못 고침** (공격적 MPC는 오히려 자극).
|
||||||
|
처방 동일: **λ↑ + 데드밴드** → 데드타임·stiction 동시 대응.
|
||||||
|
|
||||||
|
### 12.4 핵심 제약 — 모델 필요 + 식별 문제 (최대 관문)
|
||||||
|
- 상용 MPC = step-response/FIR(DMC) 또는 저차 전달함수(FOPDT) 모델 사용.
|
||||||
|
- 식별엔 **여기성(excitation)** 필요 — 통상 step/PRBS **bump test**.
|
||||||
|
- **우리 데이터의 한계**:
|
||||||
|
- operator MANUAL = **외란에 반응한 폐루프 데이터** → 입력·외란 상관 → 편향 모델 위험(폐루프 식별법 필요).
|
||||||
|
- **30초 해상도** → 느린 루프(온도/레벨) ID 가능, **빠른 유량 부족**.
|
||||||
|
- → **느린 파일럿 루프 + 가동 중 소수 bump test** 보강이 현실적.
|
||||||
|
|
||||||
|
### 12.5 벤더 실증 계보 (Qin & Badgwell)
|
||||||
|
- **DMC** (Cutler & Ramaker, Shell 1980) → AspenTech → **DMCplus / DMC3**
|
||||||
|
- **Honeywell RMPCT** (Robust Multivariable Predictive Control Technology) + Profimatics PCT → **Profit Controller / Profit Suite**
|
||||||
|
- **IDCOM/HIECON** (Adersa) — 최초 상용 MPC
|
||||||
|
- 정유·석화 중심 **수천 건 실제 설치**.
|
||||||
|
- **HC900이 Honeywell** → Profit 계보와 개념적 한 식구(단 Profit은 Experion/TPS급, HC900 소형 → 자체 구현 필요).
|
||||||
|
|
||||||
|
### 12.6 우리 시스템 적용 아키텍처
|
||||||
|
```
|
||||||
|
C# 크롤러 = MPC 호스트
|
||||||
|
PV/부하 읽기(gRPC 캐시 ~1s)
|
||||||
|
→ [상층] 데이터 정상상태 맵 → OP_ss 타깃
|
||||||
|
→ [하층] move-suppressed QP → ΔOP 1수
|
||||||
|
→ WriteTag로 OP (FeedforwardWriteGuard 상하한/rate)
|
||||||
|
1초 주기 = 느린 루프엔 충분
|
||||||
|
로드맵 동일: shadow → assist → guarded closed-loop
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.7 갭/리스크
|
||||||
|
1. **모델 ID**: 폐루프·외란상관·30초 → bump test는 *보조*. **MANUAL OP step ≈ 개루프 데이터**라 기존 데이터만으로 식별 우선 시도 (→ §13.0 참조).
|
||||||
|
2. **계산**: .NET QP 솔버(소형·1초면 충분), 무제약 DMC+클램프로 간이화 가능.
|
||||||
|
3. **설명가능성**: MPC는 룩업맵보다 덜 직관 → assist 모드 + 제약/타깃 화면표시로 보완.
|
||||||
|
4. **유지보수**: 모델 드리프트 → 주기적 re-ID.
|
||||||
|
5. **stiction**: MPC 미해결 → 데드밴드 레이어 필수.
|
||||||
|
|
||||||
|
### 12.8 권고
|
||||||
|
- **Full 상용형 MPC를 처음부터 가지 말 것** (모델ID·계산·신뢰 부담).
|
||||||
|
- 대신 **2단 구조 경량화**: `정상상태 맵(데이터)` + `강한 move-suppress 예측 트림`
|
||||||
|
= **"우리 데이터·제약에 맞춘 경량 MPC"** (산업 표준 골격과 일치).
|
||||||
|
- 파일럿 = **느린 루프(온도/레벨)** — 기존 MANUAL 데이터로 식별 우선, bump test는 필요 시 보조.
|
||||||
|
- 착수 순서 추천: **(a) 상층 정상상태 맵 먼저**(데이터만으로 가능) → 이후 **(b) 하층 모델 식별**(기존 데이터 우선, bump test 보조).
|
||||||
|
|
||||||
|
### 12.9 참고 링크
|
||||||
|
- Qin & Badgwell 서베이: https://www.sciencedirect.com/science/article/abs/pii/S0967066102001867
|
||||||
|
- 상용 MPC 2단(LP+동적) 구조 설명: https://www.picontrolsolutions.com/blog/picontrol-closed-loop-time-domain-technology-helps-on-aspen-dmc-honeywell-rmpct-profit-emerson-and-other-mpc-software/
|
||||||
|
- Honeywell Profit Controller / RMPCT 개요: https://www.scribd.com/document/90410821/Profit-Controller-Rmpct-Overview
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 기능 로드맵 — PiControl/PITOPS 벤치마크
|
||||||
|
|
||||||
|
> 출처: `docs/PITOPS-브레인스토밍-웹페이지복사.md` (PiControl 제품 페이지).
|
||||||
|
> PiControl 3제품 = 3개 기능 기둥. **제품을 사는 게 아니라 핵심 아이디어를 C# 스택에 내재화.**
|
||||||
|
> - **APROMON** = 루프 성능 모니터링(CLPM)
|
||||||
|
> - **PITOPS** = 다변수 폐루프 시스템 식별 + 다목적 PID 튜닝 + APC 설계
|
||||||
|
> - **COLUMBO** = MPC 모델 유지보수(오프라인 Excel)
|
||||||
|
|
||||||
|
### 13.0 ★핵심 시사점 — bump test 걱정 완화★
|
||||||
|
PITOPS의 셀링포인트가 우리 §12.4 난제와 동일: *"정상운전·폐루프 데이터에서 step test 없이 모델 식별 — 진동/불안정/stiction/고잡음/미측정외란 데이터에서도 전처리 없이."*
|
||||||
|
- 우리만의 추가 행운: 플랜트가 **100% MANUAL** → 오퍼레이터 OP 계단 조작 = **개루프 step 데이터가 몇 달치 흩어진 셈** → PITOPS가 어렵게 푸는 폐루프보다 **오히려 식별이 쉬움**.
|
||||||
|
- 남은 과제 = "오퍼레이터 move vs 외란" 분리 → PITOPS의 **미측정 외란 패턴 식별** 기능이 담당.
|
||||||
|
- **결론**: 기존 MANUAL 데이터만으로 모델 식별 우선 시도, bump test는 보조 (§12.4·12.7·12.8 갱신 반영).
|
||||||
|
|
||||||
|
### 13.1 기둥 A — 루프 헬스 모니터링 (APROMON 류) · 우선순위 **즉시**
|
||||||
|
dump만 있으면 바로 구현 가능. 부수효과로 **파일럿 루프를 데이터로 객관 선정**(§9 미해결 자동 해결).
|
||||||
|
- 루프별 KPI: **진동지수(oscillation index)**, **time-in-manual %**, SP추적오차, **IAE**, **밸브 이동률(OP travel)**, **SNR**
|
||||||
|
- 30+ 기준 → 단일 **Grade(0~100)**
|
||||||
|
- **valve stiction 감지**, **frozen/noisy 센서 감지**
|
||||||
|
- 임계 이하 루프 자동 플래그 → 우선순위 작업목록 (수작업 트렌드 감사 대체)
|
||||||
|
- ※ 우리 §5 "hunting 자동 감지기"의 상위 일반화
|
||||||
|
|
||||||
|
### 13.2 기둥 B — 시스템 식별 엔진 (PITOPS 류) · 우선순위 **중** (MPC 전제)
|
||||||
|
- MANUAL/operator 데이터 → **FOPDT·2차·적분형·deadtime·개루프불안정** transfer function 피팅
|
||||||
|
- **시간영역**(Z변환 불요) **제약 비선형 최적화**
|
||||||
|
- **미측정 외란 패턴 식별·분리**(트렌드로 표시)
|
||||||
|
- **stiction/hysteresis 동시 식별**
|
||||||
|
- SISO/MISO 다변수, 초~분 다중 스케일
|
||||||
|
- = §12의 MPC 모델 엔진 + 상층 정상상태 맵의 동역학 보강
|
||||||
|
|
||||||
|
### 13.3 기둥 C — 다목적 제어 설계 (PITOPS 류) · 우선순위 **중**
|
||||||
|
- 목표함수에 **외란(펄스/스텝/램프/사인)·노이즈·OP rate·valve stiction·비선형 게인** 반영
|
||||||
|
- = 우리 hunting-averse 제어의 데드밴드/dwell/rate-limit를 **정량 목표**로 일반화
|
||||||
|
- what-if 시뮬(추정 파라미터로 시나리오 비교)
|
||||||
|
|
||||||
|
### 13.4 부가 규약
|
||||||
|
- **검증 지표**: FIT(%)·IAE·NRMSE
|
||||||
|
- **TTSS(Time to Steady State)** 도구 → 우리 §9 "정상상태 세그먼테이션"과 직결
|
||||||
|
- **데이터 규약**: CV/MV/DV(=PV/OP/외란) 컬럼 구조 → 학습 파이프라인 입력 포맷 채택
|
||||||
|
- **보안 모델**: 식별/학습은 **DB dump 오프라인**으로 (레벨3 네트워크 미접속, COLUMBO 철학)
|
||||||
|
- 데이터 규모: 단일 케이스 ~10만 행급 처리
|
||||||
|
|
||||||
|
### 13.5 우리 플랜과의 연결
|
||||||
|
1. **기둥 A부터** (dump 직후 즉시) → 파일럿 루프 객관 선정 + hunting/stiction 진단
|
||||||
|
2. **기둥 B** = §12 MPC/정상상태 모델 엔진의 청사진
|
||||||
|
3. **기둥 C** = §5 hunting 방지 가드의 정량화
|
||||||
|
|
||||||
|
### 13.6 참고 링크
|
||||||
|
- PiControl 제품 페이지 원문: `docs/PITOPS-브레인스토밍-웹페이지복사.md`
|
||||||
|
- COLUMBO(MPC 유지보수): 정상운전 데이터로 MPC 모델 refit, 오프라인 Excel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 데이터 반입 절차 (SOP) — 현장 DB → 우리 환경
|
||||||
|
|
||||||
|
> **전제: 현장에서는 점검·선별이 불가.** 그래서 현장은 **DB 전체를 한 줄로 dump해 파일만 전달**하고,
|
||||||
|
> 테이블 구조 파악·TimescaleDB 처리·복원·최적화는 **전부 우리 쪽에서 파일로부터** 한다.
|
||||||
|
> 결론: **별도 DB `field_hist`** 로 적재(별도 스키마 아님 — `-Fc`가 원본 스키마명을 박아 충돌/rename
|
||||||
|
> 문제 → 별도 DB가 충돌0·복원1방). 라이브 `hc900`(DB `iiot_platform`) 안 건드림.
|
||||||
|
|
||||||
|
### 14.0 우리 환경 (확정)
|
||||||
|
- PG16 + TimescaleDB 컨테이너 `iiot-timescaledb` (`timescale/timescaledb-ha:pg16`), 5432.
|
||||||
|
- 운영 DB `iiot_platform`, 스키마 `hc900`, user `postgres`.
|
||||||
|
- **pg 클라이언트 도구는 컨테이너 안에만** → 모든 복원은 `docker exec`.
|
||||||
|
|
||||||
|
### 14.1 현장이 할 일 — 딱 한 줄 (전체 DB dump)
|
||||||
|
```bash
|
||||||
|
pg_dump -U <사용자> -d <DB이름> -Fc -Z6 -f field_full.dump
|
||||||
|
```
|
||||||
|
- **DB 전체** 통째. 테이블명·구조 몰라도 됨, 점검 불필요.
|
||||||
|
- `-Fc` 단일 압축파일(USB 운반 쉬움), `-Z6` 압축.
|
||||||
|
- 결과 **`field_full.dump` 파일 하나만 전달**.
|
||||||
|
|
||||||
|
**변형:**
|
||||||
|
- DB 이름 모를 때: `psql -U postgres -l` 로 목록 확인 후 위 명령에 사용.
|
||||||
|
- 명령줄 불가, pgAdmin(GUI)만: 대상 DB 우클릭 → **Backup...** → Format **Custom** → 저장 (= `-Fc`).
|
||||||
|
- 같이 알려주면 좋은 것(몰라도 진행 가능): 파일 **대략 용량**(수백MB/수GB/수십GB) → 복원 병렬도·디스크 준비용.
|
||||||
|
|
||||||
|
### 14.2 우리가 할 일 ① — 파일에서 점검 (현장 점검 대체)
|
||||||
|
```bash
|
||||||
|
pg_restore -l field_full.dump | head -50 # PG버전·스키마·테이블·인덱스 목록
|
||||||
|
```
|
||||||
|
→ 이력 테이블명·컬럼(PV/SP/OP/MODE/외란)·timestamp 컬럼·TimescaleDB 여부를 **파일에서 직접 파악**.
|
||||||
|
|
||||||
|
### 14.3 우리가 할 일 ② — 별도 DB로 복원
|
||||||
|
```bash
|
||||||
|
docker cp field_full.dump iiot-timescaledb:/tmp/
|
||||||
|
docker exec -i iiot-timescaledb psql -U postgres -c "CREATE DATABASE field_hist;"
|
||||||
|
docker exec -i iiot-timescaledb pg_restore -U postgres -d field_hist \
|
||||||
|
-j 4 --no-owner --no-privileges -v /tmp/field_full.dump
|
||||||
|
```
|
||||||
|
|
||||||
|
### 14.4 우리가 할 일 ③ — TimescaleDB 최적화 (이력 테이블 확인 후)
|
||||||
|
```sql
|
||||||
|
-- field_hist DB. <history_table>·<ts>·<tagname>은 14.2에서 파악한 실제값
|
||||||
|
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||||
|
SELECT create_hypertable('<schema>.<history_table>', '<ts>', migrate_data => true);
|
||||||
|
ALTER TABLE <schema>.<history_table>
|
||||||
|
SET (timescaledb.compress, timescaledb.compress_segmentby = '<tagname>');
|
||||||
|
SELECT add_compression_policy('<schema>.<history_table>', INTERVAL '7 days');
|
||||||
|
CREATE INDEX ON <schema>.<history_table> ('<tagname>', '<ts>' DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 14.5 주의
|
||||||
|
- **버전 호환**: PG16 `pg_restore`가 구버전 `-Fc` dump 복원 가능(forward 호환).
|
||||||
|
- **현장이 TimescaleDB인 경우**: 전체 `-Fc` dump에 하이퍼테이블 청크가 포함됨 → 복원 시 일부 오류 가능. 그때 우리 쪽에서 `SELECT timescaledb_pre_restore();` → 복원 → `timescaledb_post_restore();` 순서로 재처리. **현장은 신경 안 써도 됨**(우리가 14.2에서 감지해 처리).
|
||||||
|
- **복원 튜닝**: 대용량 시 `SET maintenance_work_mem='1GB'; SET synchronous_commit=off;` + `-j` 병렬.
|
||||||
|
- **분석 접속**: `field_hist` 전용 연결문자열만 추가 (`hc900`과 조인 불필요).
|
||||||
|
|
||||||
|
### 14.6 파일 도착 후 즉시 (요약)
|
||||||
|
1. `pg_restore -l` 로 구조 파악 → 2. `field_hist` 복원 → 3. 하이퍼테이블+압축 → 4. §13.1 기둥 A KPI 쿼리 착수.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 실데이터 구조 — shinam dump (2026-06-05 반입·복원 완료)
|
||||||
|
|
||||||
|
> 파일 `docs/PG-Dump-20260605/field_full_shinam.dump` (453MB, PG9.5.25 커스텀 dump) →
|
||||||
|
> **별도 DB `field_hist`** 로 복원 완료(iiot_platform 안 건드림, 무해 경고 1개: public 스키마 중복).
|
||||||
|
> 현장 = **신암정유(주)**, 소스 DB명 `shinam`.
|
||||||
|
|
||||||
|
### 15.1 기간·해상도 (확정)
|
||||||
|
- 기간: **2026-02-05 12:53 ~ 2026-06-05 11:22** (약 120일 ≈ 4개월).
|
||||||
|
- 간격: **주로 30초** (2월 일부 60초 혼재, 간헐적 공백=재기동/다운타임). `dtat` 유니크.
|
||||||
|
- cont001 행수 336,519 (테이블별 20만~33.6만).
|
||||||
|
|
||||||
|
### 15.2 데이터 모델 — WIDE 포맷 + 디코드 테이블
|
||||||
|
- **`cont001`~`cont017`** (17개): 시계열 본체. 컬럼 = `dtat`(timestamp) + `col01..colNN`(real). 설명 "신암정유(주) 아날로그 모니터링 포인트 (Read Only)".
|
||||||
|
- **`ptlist`** (799행): 포인트 정의. `pid, ptname(예 /ASSETS/P10/TI-10117.PV), shortptname, asset, cont`.
|
||||||
|
- **`tblist`** (17행): 테이블 정의. `tid, tblname(cont0NN)`.
|
||||||
|
- **`mapping`** (817행): `tid, pid, oit` — **어느 테이블(tid)의 어느 컬럼(oit)이 어느 포인트(pid)인지** 디코드.
|
||||||
|
- `cont`·`batch`·`batchlist`: 0행(비어있음).
|
||||||
|
|
||||||
|
### 15.3 ★태그 시계열 복원 규칙 (검증 완료)★
|
||||||
|
```
|
||||||
|
ptname → ptlist.pid
|
||||||
|
→ mapping(pid) → (tid, oit)
|
||||||
|
→ tblist(tid) → tblname
|
||||||
|
→ SELECT dtat, col{oit:2자리0패딩} FROM {tblname} ORDER BY dtat
|
||||||
|
```
|
||||||
|
- **oit는 colNN에 직결** (oit=3 → col03). 검증: `TI-10117.PV` → cont015.col03 → ~22°C(온도 합리값).
|
||||||
|
- 한 루프(PV/SP/OP)는 **서로 다른 cont 테이블·컬럼에 흩어져 있을 수 있음** → 시간축(dtat)으로 조인.
|
||||||
|
|
||||||
|
### 15.4 접미사 분포 — OP/SP/PV 확인 (make-or-break 통과)
|
||||||
|
| 접미사 | 개수 | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| **PV** | 456 | 공정값 |
|
||||||
|
| **SP** | 114 | 설정값 (신뢰도 ~80%, §4) |
|
||||||
|
| **OP** | 106 | ✅ **제어출력 존재** |
|
||||||
|
| FIELDVALUE | 55 | |
|
||||||
|
| QV | 32 | 적산/유량 추정 |
|
||||||
|
| VALUE | 29 | |
|
||||||
|
| LSET | 6 | local setpoint? |
|
||||||
|
| HZSET | 1 | |
|
||||||
|
|
||||||
|
- **PV+SP+OP 완비 루프 = 104개** (PV+OP만 = 106). → imitation/학습 대상 루프 ~104개 확보.
|
||||||
|
|
||||||
|
### 15.5 진공(PID) 루프 제외 규칙 (확정)
|
||||||
|
- **진공 = 압력제어기 `PICA-*`** (OP 보유 12개) → AUTO/PID 루프이므로 **학습 대상에서 제외**.
|
||||||
|
- 제외 12: PICA-111, -2111, -2121, -3203, -5111, -6111, -6211, -8111A, -9111A, -9211A, -10111A, -10211A
|
||||||
|
- `PI-*`(43개)는 전부 PV만 = 단순 압력지시계 → 어차피 제어루프 아님(무관).
|
||||||
|
- 제외 SQL: `WHERE upper(split_part(base,'-',1)) <> 'PICA'`.
|
||||||
|
- (MODE 태그는 없음 — 100% MANUAL이라 미기록. 위 PICA 제외로 AUTO/MANUAL 구분 대체.)
|
||||||
|
|
||||||
|
### 15.6 학습 대상 루프 인벤토리 (PV+SP+OP 완비, PICA 제외 = **91개**)
|
||||||
|
| 계기 | 루프수 | 종류 | 파일럿 적합성 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **TICA** | 12 | 온도제어 | ★ **느림 → 30초 적합, 첫 파일럿 이상적** |
|
||||||
|
| **LICA** | 20 | 레벨제어 | 비교적 느림 (차순위) |
|
||||||
|
| LIC/LISA | 3 | 레벨 | |
|
||||||
|
| **FICQ** | 54 | 유량(적산) — 다수. ※사용자 feed-ramp의 FICQ 계열 | 빠름 → 나중 |
|
||||||
|
| FICA | 1 | 유량제어 | 빠름 |
|
||||||
|
| AIC | 1 | 분석기 제어 | |
|
||||||
|
- 전체 완비루프 103 = 진공 12 + 학습대상 91.
|
||||||
|
- **플랜트 인코딩**: 태그 숫자 앞자리 = 차수(1~6,8,9,10 / **7차 없음**), `ptlist.asset`=`/ASSETS/Pn`이 차수 보유. 91개 분포: P6=15, P1=15, P10=14, P9=14, P2=14, P5=7, P8=6, P3=3, P4=3. TICA는 P6·P9·P10에 각 2개.
|
||||||
|
|
||||||
|
### 15.9 플랜트 분류 & 개발 전략 (사용자 도메인 지식)
|
||||||
|
**활성 플랜트 = 1, 2, 5, 6, 8, 9, 10** (3·4차는 **죽은 플랜트, 안 씀** → 제외. 7차 없음.)
|
||||||
|
|
||||||
|
| 플랜트 | 유형 | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| **6, 8, 9, 10** | **측류(side-draw) 반도체용 솔벤트** | 동일 유형(형제). 6차용 만들면 8·9·10 확장 |
|
||||||
|
| **1, 2** | 측류 아님 — **2컬럼 경질/중질 제거 일반 증류** | 별도 유형 |
|
||||||
|
| 5 | (유형 미확인, 활성, 7루프) | 확인 필요 |
|
||||||
|
| ~~3, 4~~ | **죽은 플랜트 — 제외** | |
|
||||||
|
|
||||||
|
**개발 순서**:
|
||||||
|
1. **6차 먼저** — HC900 **통신이 살아있음**(현재 live 값은 가공이라도 실 제어경로 shadow→assist→closed 테스트 가능). 6차 = 측류 솔벤트 대표.
|
||||||
|
2. → **8·9·10차** (동일 유형, 코드 재사용)
|
||||||
|
3. → **1·2차** (2컬럼 일반증류, 제어구조 다름)
|
||||||
|
4. 5차는 유형 확인 후.
|
||||||
|
|
||||||
|
**학습 대상 루프(활성, 3·4 제외)**: P6=15, P1=15, P10=14, P9=14, P2=14, P5=7, P8=6 → **계 85개**.
|
||||||
|
|
||||||
|
### 15.10 6차 학습 데이터 출처 — 판정: **실데이터** (2026-06-05 검증)
|
||||||
|
사용자: "6차 플랜트 데이터가 가공". → dump 속 P6를 통계 지문으로 판별한 결과 **실 오퍼레이터 데이터로 강하게 판정**:
|
||||||
|
- **인과성 corr(PV,OP)**: FICQ-6101 0.72, TICA-6111A 0.88 (실 공정 물리 — 가공 노이즈면 ≈0)
|
||||||
|
- **OP 평탄율 98%** + 수천 이산 스텝 + 유지구간 들쭉날쭉(중앙 8분~최대 53h) = 전형적 수동조작 서명
|
||||||
|
- 센서 노이즈(음수 영점 포함)·SP distinct 3~11·반복성 없음 모두 현실적
|
||||||
|
- (LICA-6113은 OP 전구간 0 = 미사용 루프일 뿐)
|
||||||
|
- **해석**: 사용자의 "가공"은 현재 **HC900 live 피드(실시간 테스트 시뮬값)**를 지칭한 것으로 추정. shinam dump의 P6 이력은 실데이터.
|
||||||
|
- **결론**: **6차를 P6 실이력으로 직접 학습** 가능(형제 우회 불필요). 학습·배포 모두 6차 일관.
|
||||||
|
- ✅ **사용자 확정(2026-06-05): "완벽한 현실 데이터, 의심하지 마." → dump P6 = 실데이터 확정.** "가공"은 live 피드 지칭.
|
||||||
|
|
||||||
|
### 15.11 6차 내부 구조 & OP 활동 스크린 (2026-06-05)
|
||||||
|
**6차 = 6-1차(태그 61XX) + 6-2차(62XX), 두 train 독립 운전(연관 없음, 컬럼별 독립).**
|
||||||
|
- 6-1차: TICA-6111A, FICQ-6101/6113/6114/6116/6118, (PICA-6111=진공 제외)
|
||||||
|
- 6-2차: TICA-6211A, FICQ-6201/6213/6214/6216/6218, (PICA-6211=진공 제외)
|
||||||
|
- ★모델링 함의: 한 train 루프의 **부하/외란 피처는 같은 train 안에서만** 사용. 6-1↔6-2 교차 피처 금지. 각 train = 독립 모델링 단위.
|
||||||
|
|
||||||
|
**OP 활동 스크린 (평탄율=OP 정지비율, chg=수동 스텝 수)**:
|
||||||
|
| 루프 | flat% | OP변경 | 판정 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| TICA-6111A | 98.0 | 6590 | ★수동, 풍부 (6-1 파일럿) |
|
||||||
|
| TICA-6211A | 97.7 | 7651 | 수동, 풍부 (6-2) |
|
||||||
|
| FICQ-6101/6113/6201/6118/6213/6218… | 98.4~99.7 | 850~5458 | 모두 수동 서명 |
|
||||||
|
| **LICA-6113/6128/6213** | 100.0 | **0** | **OP=0 사장루프(미사용) → 제외** |
|
||||||
|
|
||||||
|
- AUTO 의심(평탄율 급락) 루프는 전체기준 없음 → 간혹 있다는 AUTO 기간은 **소수 구간** → 학습 시 **세그먼트 단위 AUTO 탐지**(OP 연속변동 구간)로 제거 필요(§4·§13.1 time-in-manual). MODE 태그 없으니 OP 거동으로 판별.
|
||||||
|
- **6차 활성 제어루프**: 6-1차 6개(TICA-6111A + FICQ 5) + 6-2차 6개. LICA 3개는 사장.
|
||||||
|
|
||||||
|
### 15.7 ⚠️ 잔여 주의
|
||||||
|
- "Read Only 모니터링 포인트" 설명 → OP가 오퍼레이터 실제 조작값인지 1차 확인 권장(맞을 것으로 판단).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 6-1차 파일럿 (TICA-6111A) — 착수
|
||||||
|
|
||||||
|
### 16.1 6-1차 컬럼 C-6111 — 권위 토폴로지 (기존 `hc900.ff_column_config`/`ff_stream_config`에서)
|
||||||
|
> ★피처 역할을 추측할 필요 없음★ — 팀이 이미 정의해둠. column_id=1, name=**C-6111**, enabled=t, **advisory_only=t**(이미 자문모드 가동).
|
||||||
|
|
||||||
|
| 역할 | 태그 | 출처 컬럼 |
|
||||||
|
|---|---|---|
|
||||||
|
| **제어출력 OP(학습대상)** | **TICA-6111A.OP** = 리보일러 **스팀 밸브(조작)** | steam_op_tag |
|
||||||
|
| **스팀 유량(측정)** | **FIQ-6115** (cont008 PV37/QV38) = 실제 스팀유량/열입력 | 사용자 도메인 |
|
||||||
|
| **제어온도(target)** | 민감단 **TI-6111C** | sensitive_tray_tag |
|
||||||
|
| 온도 프로파일 | TICA-6111a, TI-6111b, TI-6111c, TI-6111d | temp_tags |
|
||||||
|
| **피드(주 외란)** | **FICQ-6101** | feed_tag |
|
||||||
|
| 진공압력 | **PICA-6111** | pressure_tag |
|
||||||
|
| 컬럼 차압(플러딩) | **PI-6111B** | delta_p_tag |
|
||||||
|
| 온도 상한 | 89.5 / p_ref 50 | temp_high_limit |
|
||||||
|
|
||||||
|
**스트림 역할 (ff_stream_config, column_id=1)**:
|
||||||
|
| key | 태그 | role | 비고 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **P(★측류 제품)** | FICQ-6118 | Commanded | **반도체 솔벤트 제품 = 진짜 측류(side-draw)**. coeff 0.95, θ60/60, τ900 |
|
||||||
|
| R(리플럭스) | FICQ-6113 | Commanded | is_reflux, reflux_from_product |
|
||||||
|
| D(경질분 제거) | FICQ-6114 | LevelDriven | **측류 아님** — 리플럭스 라인의 **경비물(경질분/light) 제거** 유량. level=lica-6113, grade B |
|
||||||
|
| B(중질분 제거) | FICQ-6116 | LevelDriven | **보텀 아님** — **중비물(중질분/heavy) 제거** 유량. level=li-6111, grade B |
|
||||||
|
|
||||||
|
> **컬럼 본질**: C-6111 = **측류 정제 컬럼**. 측류 제품 P(FICQ-6118, 반도체 솔벤트)를 가운데서 뽑고, 경질분(D)·중질분(B)을 양쪽으로 퍼지, 리플럭스(R) 환류. 오퍼레이터는 스팀(TICA-6111A.OP)으로 온도 프로파일(민감단 TI-6111C)을 잡아 이 분리·순도를 관리.
|
||||||
|
|
||||||
|
> ★운전 현실(사용자)★: **D(FICQ-6114)·B(FICQ-6116)은 유량이 작아 제어가 어려워 대부분 고정 운전** (스크린 평탄율 99.6~99.7%로 일치). → 모델에서 **준상수**로 취급(능동 조작변수 아님). **오퍼레이터 핵심 레버 = 스팀(TICA-6111A.OP/FIQ-6115) + 리플럭스(FICQ-6113) + 측류제품(FICQ-6118)**. 피드 FICQ-6101은 주 외란.
|
||||||
|
|
||||||
|
**센서 물리 위치 (사용자 도메인) — C-6111 수직 프로파일 하부→상부**:
|
||||||
|
| 태그 | 위치 | 모델 의미 |
|
||||||
|
|---|---|---|
|
||||||
|
| **TICA-6111A** | 리보일러 온도(최하부, 최고온) | 스팀 직접 제어점(OP 연결) |
|
||||||
|
| **TI-6111B** | 중간부, **원료투입 디스트리뷰터 위** | 피드존 온도 |
|
||||||
|
| **TI-6111C** | 중상부, 팩킹 넘어 **제품 추출 트레이 근처** | ★**민감단=측류제품(FICQ-6118) 순도 직결**(sensitive_tray) |
|
||||||
|
| **TI-6111D** | 상부 리플럭스 디스트리뷰터 밑(최저온) | 탑상 온도 |
|
||||||
|
| TI-6103 | **원료 예열 온도** | 주 외란(피드 엔탈피) |
|
||||||
|
| TI-6117 | 제품 탱크 이송 전 온도 | 다운스트림(제품 상태 — 제어입력 아님, 품질 프록시 가능) |
|
||||||
|
| LI-6111 | **리보일러 레벨** | 하부 인벤토리/하이드롤릭 |
|
||||||
|
| LICA-6113 | **리플럭스 드럼 레벨** | 환류 인벤토리 |
|
||||||
|
|
||||||
|
> **온도 순서 A > B > C > D** (리보일러 최고온 → 탑상 최저온, 단조 감소 구배). 정상 분리 = 이 구배 유지, 구배 붕괴=분리 이상 신호.
|
||||||
|
> ΔT(인접단 차, 예: A−B, B−C, C−D)가 **분리도 지표** → 피처로 사용. 모델: `스팀 = f(목표 TI-6111C, 피드 FICQ-6101, 원료예열 TI-6103, 리플럭스 FICQ-6113, 측류 FICQ-6118, 진공 PICA-6111)`.
|
||||||
|
|
||||||
|
- **제어 철학**: 오퍼레이터가 **스팀(TICA-6111A.OP)** 으로 민감단 온도(TI-6111C)를 잡고, **피드 FICQ-6101**·진공·스트림이 외란. → 모델 `OP_ss = f(목표온도, FICQ-6101, PICA-6111, PI-6111B, 스트림유량…)`.
|
||||||
|
- **저장탱크 온도 제외**(사용자): TI-6117, TI-6121/6122/6123/6125/6126.
|
||||||
|
- ★기존 ff 시스템(FeedforwardSupervisor 등)이 C-6111에서 이미 advisory로 가동 중 → 우리 학습형 맵은 **이와 연계/보완**(중복 금지). FROM-TO·역할은 `ff_*`·`pid_equipment.from_tag/to_tag`에서 읽음.
|
||||||
|
|
||||||
|
### 16.2 파일럿 적합성 (TICA-6111A 실측)
|
||||||
|
- OP 가동률 **99.4%** (거의 항상 활성) · 정상상태 **92.7%** (정상상태 맵 학습에 풍부)
|
||||||
|
- **SP 신뢰 67%**(±2 이내) → SP 불완전 확정 → **§4대로 정착 PV를 target으로** 사용이 옳음.
|
||||||
|
- 평균 |SP−PV| 1.51 (오퍼레이터가 PV를 목표 근처로 잘 유지)
|
||||||
|
|
||||||
|
### 16.3 다음 모델링 단계 (오프라인, Python)
|
||||||
|
1. field_hist에서 TICA-6111A + 6-1 피처 풀 pull.
|
||||||
|
2. **★운전상태 분류 (필수 전처리)★** — 각 시점을 다음 모드로 라벨:
|
||||||
|
- **STOPPED**(유량0·실온) / **STARTUP**(승온) / **LINEOUT-전환류**(hot+진공ON **but 측류제품 FICQ-6118≈0**, 리플럭스 높음, 저부하 — 프로파일 정렬·랩샘플 대기) / **LOAD-RAMP**(피드·제품 상승중) / **RUNNING-생산**(피드·제품 정상범위, 정상상태) / **SHUTDOWN**(하강).
|
||||||
|
- 판정 신호: 피드 FICQ-6101, 리보일러온도 TICA-6111A, 진공 PICA-6111, **측류제품 FICQ-6118(≈0=전환류 핵심 판별)**, 리플럭스/제품 비.
|
||||||
|
- **정상상태 맵 학습 = RUNNING-생산 구간만** (여러 로드율의 정상점은 **모두 유효** → 운전점 커버리지↑). **LINEOUT·LOAD-RAMP·STARTUP·SHUTDOWN·STOPPED 전부 제외**.
|
||||||
|
- ★STARTUP/SHUTDOWN/LOAD-RAMP/LINEOUT 과도는 **버리지 말고 1급 데이터로 보존·라벨**★ — (a)동특성 식별 + (b)**START-UP/SHUTDOWN 절차학습(②③)의 학습데이터**(§16.4).
|
||||||
|
- ※ 기존 `hc900.v_plant_running_state`(+_corroborated, vacuum_torr/pump 기반) 판정 로직을 field_hist에 재현.
|
||||||
|
- 💡 향후: 라인아웃→제품컷 시점 학습 시 **START-UP 어드바이저**(언제 로드 올릴지)로 확장 가능 — 현 스코프 밖, 메모.
|
||||||
|
4. AUTO 구간 제거(OP 연속변동 탐지), off-spec 제외, 정상상태 세그먼트(dPV≈0·dOP=0) 추출.
|
||||||
|
5. **`OP_ss = f(정착PV, 6-1 부하)`** 회귀(설명가능 모델: 선형/GBM/GMR) + 피처 중요도·잔차 분석.
|
||||||
|
- ★스팀유량 FIQ-6115 활용 두 갈래★:
|
||||||
|
- (밸브특성) OP(TICA-6111A.OP) ↔ FIQ-6115 관계 학습 → **밸브 stiction/비선형 진단**(§5).
|
||||||
|
- (에너지모델) target = **스팀유량 demand** `FIQ-6115_ss = f(목표온도, 피드 FICQ-6101, 진공, 스트림)` → 밸브문제와 분리, 더 강건. 이후 유량→밸브% 역변환.
|
||||||
|
6. 산출물: 맵 + FIT/IAE 검증 + "오퍼레이터 OP가 가용변수로 얼마나 설명되나".
|
||||||
|
→ 이후 shadow 모드용 예측기로 연결(§7).
|
||||||
|
|
||||||
|
### 16.4 학습 대상 3종 (스코프 확장 — 사용자)
|
||||||
|
정상상태만 학습하지 않는다. START-UP/SHUTDOWN 절차도 결국 학습(안전 자동화).
|
||||||
|
| # | 대상 | 문제 유형 | 시점 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ① | 정상 생산 제어 | 정상상태 맵 `OP_ss=f(...)` (회귀) | **현재** |
|
||||||
|
| ② | **START-UP 절차** | **시퀀스/절차 모방** — 시간순 동작 + **전이 트리거**(예: 프로파일 정렬+샘플OK→제품컷→로드램프) | 추후 |
|
||||||
|
| ③ | **SHUTDOWN 절차** | 시퀀스/절차 모방(역순, 안전) | 추후 |
|
||||||
|
- ②③은 ①과 문제 종류가 다름: "조건→OP"가 아니라 **"상태→다음단계 언제·어떻게"**. 궤적/절차 학습.
|
||||||
|
- ★**6모드 상태분류기(§16.3-2)가 ②③의 backbone**: STOPPED→STARTUP→LINEOUT→LOAD-RAMP→RUNNING→SHUTDOWN 전이를 학습. 상태분류 = ① 필터 + ②③ 골격 이중역할.
|
||||||
|
- 안전 중요(반도체 솔벤트 startup/shutdown = 고위험 과도). → 과도구간 데이터 1급 보존 필수.
|
||||||
|
|
||||||
|
### 16.5 진행 로그 (2026-06-05)
|
||||||
|
**착수 완료 — 추출기 + 운전모드 분류 + 타임라인 검증.** 코드: `scripts/analysis/c6111_extract.py`
|
||||||
|
(재사용 추출기 `tag_frame()` = ptlist/mapping/tblist 디코드). 산출: `c6111_data.pkl`, `c6111_timeline.png`, `c6111_episode.png`.
|
||||||
|
- **운전모드 분포**(4개월): PROD 99.2%(2781h), SHUTDOWN 0.4%, LINEOUT 0.2%, STARTUP/STOPPED 각 0.1%.
|
||||||
|
- **여러 로드 캠페인** 확인(3~4월 고부하 ~700-900, 5월초 이후 저부하 ~400) → 맵 운전점 커버리지 양호.
|
||||||
|
- **과도 에피소드 ~4회**(5/13 10h, 4/30 7h, 5/2, 2/15) — 각각 깨끗한 shutdown→냉각→startup 사이클.
|
||||||
|
- ★STARTUP 절차 실데이터 검증: 진공재확립→스팀투입→승온→**전환류(리플럭스 ON, 제품 0)**→정렬→**제품컷인**→생산. 사용자 설명과 일치.
|
||||||
|
- 임계(분포 기반): hot&vacuum = reb_temp>60 & vacuum<200 & steam_op>5; 전환류 = product<80.
|
||||||
|
- ⚠️ 절차학습(②③)은 **few-shot(~4 에피소드)** — 6-2·8·9·10 동일유형 에피소드 합치면 샘플 보강 가능.
|
||||||
|
- 다음: ① **생산 정상상태 맵** (PROD 구간, 밸브특성 OP↔FIQ-6115 + `스팀=f(목표온도,피드,…)`).
|
||||||
|
|
||||||
|
### 16.6 ① 생산 맵 결과 (2026-06-05) — 코드 `scripts/analysis/c6111_prodmap.py`
|
||||||
|
- **밸브특성**: 스팀유량 ≈ 18.5·OP − 220 (OP 22~48%→flow 98~698). **히스테리시스 0.4% = stiction 사실상 없음** → 이 컬럼 hunting은 스팀밸브 원인 아님.
|
||||||
|
- **★학습 단위 = 운전점(6h 중앙값)★**: 정상상태가 98%로 너무 안정 → 점단위 회귀는 캠페인내 변동 부족으로 실패(음의 R²). **6h 운전점(479개)으로 집계하면 해결**. steam vs feed에 **이산 로드 캠페인 클러스터**(feed≈400/500/700/800-900) 확인.
|
||||||
|
- **맵 `스팀유량 = f(피드, 제품, 목표 T_C)`**: GBM **test R²=0.993**, MAE 7.9(스팬 1.8%). Linear 0.987. **피드 단독 R²=0.985, steam/feed비=0.729**.
|
||||||
|
- 피처 중요도: **T_C 0.42 > feed 0.31 > product 0.26**, 나머지(예열·T_D·진공) ~0. feed_preheat 계수 음(−)=물리 정확.
|
||||||
|
- **함의**: 오퍼레이터 스팀 **99% 설명/학습 가능**. 본질은 **steam/feed 비율(~0.73) + 온도·제품 보정**. = 기존 `ff_column_config` steam/feed 계수를 **데이터로 검증·정밀화**(경로 나).
|
||||||
|
- ⚠️ 이건 **정상상태(에너지밸런스) 맵** = §4 OP_ss 베이스라인(전향). 아직 동특성·캠페인내 미세 온도제어(피드백 트림)는 별도.
|
||||||
|
- 다음 후보: (a) shadow-mode 예측기로 연결(live 피드/제품/목표→스팀 권장, 오퍼레이터 비교) / (b) 캠페인내 미세 온도 피드백(트림) 분석 / (c) 6-2·8·9·10 확장.
|
||||||
|
|
||||||
|
### 16.7 Shadow 예측기 백테스트 (2026-06-05) — 코드 `scripts/analysis/c6111_shadow.py`
|
||||||
|
시간 전진 분할(학습 2~4월 70% / shadow held-out 5월 30%), 스팀유량 예측 →밸브 역특성→ 예측 OP를 **실제 오퍼레이터 OP와 비교**. OOD(학습 운전envelope 1~99% 밖) 게이트 포함.
|
||||||
|
- **신뢰구간(in-envelope): OP MAE 0.80%, |예측−실제 OP|≤2% 가 94%** → **오퍼레이터 손을 충실히 모방**.
|
||||||
|
- **held-out 5월 = 100% OOD** (저부하 feed 378~423 < 학습하한 433, 목표 T_C도 85.3→84.0으로 낮춤). 예측 OP가 **체계적 +4.0%(std 1.1%) 과예측** = 외삽 바이어스.
|
||||||
|
- ★**OOD 게이트가 5월 전체를 정확히 플래그 → "오퍼레이터 폴백"** = §5 안전설계(범위밖→사람) 실증. shadow가 **나쁜 조언을 내기 전에 스스로 기권**.
|
||||||
|
- **교훈**: 모델은 신뢰구간에서 탁월(94% 일치)하나, **새 운전레짐엔 외삽 바이어스** → 반드시 (1)OOD 게이트 + 오퍼레이터 폴백, (2)연속/롤링 재학습으로 envelope 확장.
|
||||||
|
- 다음 후보: (1) **롤링 재학습** 데모(5월이 in-envelope가 됨을 확인) / (2) **operator-assist**(예측 OP 화면표시) / (3) 캠페인내 미세 온도 트림 / (4) 6-2·8·9·10 확장.
|
||||||
|
|
||||||
|
### 16.8 롤링 재학습 (2026-06-05) — 코드 `scripts/analysis/c6111_rolling.py`
|
||||||
|
walk-forward: 5월을 하루씩 전진, '그 날 이전 전체 이력(expanding)'으로 매일 재학습→그 날 예측. 입력 평활 인과(trailing).
|
||||||
|
| 모델 | 5월 OP MAE | ≤2% 일치 |
|
||||||
|
|---|---|---|
|
||||||
|
| 정적(2~4월 고정) | 3.88% | 3.7% |
|
||||||
|
| **롤링 재학습** | **1.17%** | **83.7%** |
|
||||||
|
- **적응 곡선**: 05-01 첫날 4%(OOD 100%) → **05-02 단 하루 흡수로 1.6%** → 이후 ~0.5%. **OOD 첫주 35%→0%**.
|
||||||
|
- 05-14~19 일시 상승(5/13 shutdown/startup 직후 sub-레짐) 후 자동 회복.
|
||||||
|
- ★**완성된 안전 설계**: 롤링 재학습(envelope가 플랜트 추종) + OOD 게이트(전이 첫 1~2일은 오퍼레이터 폴백) → 적응 후 94% 모방. 새 레짐 진입 시 사람이 잠깐 몰다 모델이 흡수.
|
||||||
|
- **결론**: ① 생산 제어의 offline 파이프라인(추출→모드분류→정상맵 R²0.99→shadow 94%→롤링 적응) **완주·검증**. 핵심 = `스팀≈0.73·피드 + 온도/제품 보정`, 롤링+OOD로 안전.
|
||||||
|
- 다음 후보: (2) operator-assist 패키징(예측 OP 화면) / (3) 캠페인내 미세 온도 트림(피드백) / (4) 6-2·8·9·10 확장 / (5) live C# shadow 포팅.
|
||||||
|
|
||||||
|
### 16.9 캠페인내 온도 트림 = gentle 피드백 (2026-06-05) — 코드 `scripts/analysis/c6111_trim.py`
|
||||||
|
부하 일정 구간(PROD의 70%)에서 오퍼레이터의 스팀 미세조정이 무엇에 반응하나 분석.
|
||||||
|
- **온도 유지 성능**: 목표대비 **reb-A·T_C 오차 std ≈ 0.07~0.09℃ (±0.13℃, 노이즈 수준)**. 극도로 안정.
|
||||||
|
- **OP는 1.3%만 이동**(98.7% 정지), dwell 중앙 21분.
|
||||||
|
- **트림 트리거 약함**: 작은 이동 dOP vs reb-A오차 corr **−0.245**(T_C −0.10보다 큼; TICA 직접변수). **데드밴드 차이 없음**(이동/비이동 |오차| 동일). 큰 이동(11회)은 **피드변화(−0.65)=전향**.
|
||||||
|
- ★**결론: 캠페인 내부에선 컬럼이 자기제어(self-regulating), 스팀 거의 개루프. 의미있는 피드백 트림이 거의 없음.** 제어는 **전향 맵(§16.6)이 지배**, 피드백은 미미(저게인 ~−0.6%OP/℃, 데드밴드 ~0.1℃=노이즈, dwell ~20분).
|
||||||
|
- **함의**:
|
||||||
|
- **전향 맵(steam=0.73·피드+보정)이 사실상 정상생산 제어 전체.** §5의 "전향+gentle 데드밴드 트림" 중 전향이 압도적.
|
||||||
|
- **hunting 저위험 설명**: 오퍼레이터가 공정과 싸우지 않음(스팀=부하, 온도 자기안정).
|
||||||
|
- **자동화 가치 = 부하변동 시 일관된 전향 스팀 + 전이/startup 처리**, 정상 유지(쉬움)가 아님.
|
||||||
|
- 데이터 도출 gentle 제어 스펙: FF `steam=f(피드,제품,목표T_C)` + (옵션)경량 FB(reb-A오차, gain~−0.6, 데드밴드±0.1℃, dwell≥20분).
|
||||||
|
|
||||||
|
### 16.10 ② START-UP 절차 (2026-06-05, few-shot) — 코드 `scripts/analysis/c6111_startup.py`
|
||||||
|
전체 모드 데이터에서 **제품 컷인** 이벤트 탐지→정렬→중첩, 절차를 해석가능 레시피로 추출.
|
||||||
|
- 탐지 5건 중 **깨끗한 절차적 컷인 3건**(02-15, 04-30 22:00, 05-13; 컬럼 정렬 T_C~83-84℃, ΔT~2℃), 2건은 미정렬 블립(T_C 63-66, ΔT 30-50 — 비정상/부분).
|
||||||
|
- **★START-UP 레시피★** (시퀀스): STOPPED → 진공재확립 → **스팀투입·승온** → **전환류 라인아웃**(리플럭스 ON, 제품 0) → **제품 컷인** → **피드 램프** → 생산.
|
||||||
|
- **단계 타이밍**: 스팀투입→컷인(라인아웃 길이) **60~120분 가변**, 리플럭스확립→컷인 25~82분, 컷인→풀로드 빠름(0.5~18분).
|
||||||
|
- ★**제품 컷인 트리거 = 조건기반(시간 아님)**, 3건 일관: **reb-A 84.6±0.5℃, ΔT(A-D) 1.9±0.4℃** (프로파일이 생산상태로 압축=정렬 완료). 사용자 설명("정렬 상태 보고 로드 올림")과 정확히 일치.
|
||||||
|
- **→ START-UP 어드바이저 플레이북**: 승온 후 **reb-A≈84.5℃ & ΔT(A-D)≈2℃ 도달 시 제품 컷인** 권장, 이후 피드 램프. = 안전·설명가능 절차 자동화의 핵심 게이트.
|
||||||
|
- ⚠️ **few-shot(3 클린/4개월)** → 형제(6-2·8·9·10) startup 합치면 트리거 신뢰도·변동성 보강.
|
||||||
|
- 다음: 6-2·8·9·10 확장(샘플 보강+코드 재사용) / SHUTDOWN 절차(③) / operator-assist·live 포팅.
|
||||||
|
|
||||||
|
### 15.8 다음 액션
|
||||||
|
1. (선택) 큰 cont 테이블 TimescaleDB 하이퍼테이블+압축 (§14.5) — 분석 스캔 가속.
|
||||||
|
2. **§13.1 기둥 A**: 91개 루프 KPI(진동지수·IAE·OP travel·stiction) → 파일럿 선정. **TICA 12개부터** 권장.
|
||||||
|
3. §4 정상상태 세그먼테이션 + SP 신뢰도 실측 → `OP_ss=f(정착PV,부하)` 맵.
|
||||||
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()
|
||||||
199
scripts/analysis/c6111_extract.py
Normal file
199
scripts/analysis/c6111_extract.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
"""
|
||||||
|
컬럼 데이터 추출 + 운전모드 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
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
DSN = "host=localhost port=5432 dbname=field_hist user=postgres password=postgres"
|
||||||
|
ASSET = "/ASSETS/P6"
|
||||||
|
|
||||||
|
# --- 형제 컬럼 역할 생성기 ---
|
||||||
|
# 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:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT p.shortptname, t.tblname, m.oit
|
||||||
|
FROM ptlist p JOIN mapping m ON m.pid=p.pid JOIN tblist t ON t.tid=m.tid
|
||||||
|
WHERE p.asset=%s AND p.shortptname = ANY(%s)
|
||||||
|
""", (asset, list(shorttags)))
|
||||||
|
out = {}
|
||||||
|
for short, tbl, oit in cur.fetchall():
|
||||||
|
out[short] = (tbl, int(oit))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def tag_frame(conn, role_map, asset=ASSET):
|
||||||
|
"""{role: shorttag} -> dtat 인덱스 DataFrame(컬럼=role). 테이블별 1쿼리 후 merge."""
|
||||||
|
loc = resolve(conn, role_map.values(), asset)
|
||||||
|
missing = [r for r, t in role_map.items() if t not in loc]
|
||||||
|
if missing:
|
||||||
|
print(f"[warn] 미해결 태그: {[(r, role_map[r]) for r in missing]}", file=sys.stderr)
|
||||||
|
# 테이블별 그룹
|
||||||
|
by_tbl = {}
|
||||||
|
for role, short in role_map.items():
|
||||||
|
if short not in loc:
|
||||||
|
continue
|
||||||
|
tbl, col = loc[short]
|
||||||
|
by_tbl.setdefault(tbl, []).append((role, col))
|
||||||
|
df = None
|
||||||
|
for tbl, cols in by_tbl.items():
|
||||||
|
sel = ", ".join([f'col{c:02d} AS "{role}"' for role, c in cols])
|
||||||
|
q = f"SELECT dtat, {sel} FROM {tbl}"
|
||||||
|
part = pd.read_sql(q, conn)
|
||||||
|
df = part if df is None else df.merge(part, on="dtat", how="outer")
|
||||||
|
return df.sort_values("dtat").reset_index(drop=True)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_phases(df):
|
||||||
|
"""1차 운전모드 분류 (임계 기반, §16.3-2). 추후 정교화."""
|
||||||
|
import numpy as np
|
||||||
|
reb, vac, steam, prod = df["reb_temp"], df["vacuum"], df["steam_op"], df["product"]
|
||||||
|
hot_vac = (reb > 60) & (vac < 200) & (steam > 5) # 컬럼 가동(hot+진공)
|
||||||
|
# 온도 추세(60분=120샘플 기울기)로 startup/shutdown 구분
|
||||||
|
slope = reb.diff().rolling(120, min_periods=10, center=True).mean()
|
||||||
|
mode = np.where(
|
||||||
|
hot_vac,
|
||||||
|
np.where(prod < 80, "LINEOUT", "PROD"), # 제품≈0 → 전환류/라인아웃
|
||||||
|
np.where(slope > 0.02, "STARTUP",
|
||||||
|
np.where(slope < -0.02, "SHUTDOWN", "STOPPED")))
|
||||||
|
return pd.Series(mode, index=df.index, name="mode")
|
||||||
|
|
||||||
|
|
||||||
|
def plot_timeline(df, png):
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
d = df.iloc[::30].copy() # 15분 다운샘플
|
||||||
|
colors = {"PROD": "#2ca02c", "LINEOUT": "#ff7f0e", "STARTUP": "#1f77b4",
|
||||||
|
"SHUTDOWN": "#d62728", "STOPPED": "#7f7f7f"}
|
||||||
|
fig, ax = plt.subplots(5, 1, figsize=(16, 12), sharex=True)
|
||||||
|
ax[0].plot(d.dtat, d.reb_temp, lw=.5, label="reb_temp(A)")
|
||||||
|
ax[0].plot(d.dtat, d.T_C, lw=.5, label="T_C(민감단)")
|
||||||
|
ax[0].plot(d.dtat, d.T_D, lw=.5, label="T_D(탑상)")
|
||||||
|
ax[0].set_ylabel("온도"); ax[0].legend(loc="upper right", fontsize=7)
|
||||||
|
ax[1].plot(d.dtat, d.feed, lw=.5, color="purple"); ax[1].set_ylabel("feed FICQ-6101")
|
||||||
|
ax[2].plot(d.dtat, d["product"], lw=.5, color="orange"); ax[2].set_ylabel("측류제품 6118")
|
||||||
|
ax[3].plot(d.dtat, d.steam_flow, lw=.5, color="red")
|
||||||
|
ax[3].plot(d.dtat, d.steam_op * 10, lw=.5, color="brown", alpha=.5, label="OP×10")
|
||||||
|
ax[3].set_ylabel("스팀유량/OP"); ax[3].legend(loc="upper right", fontsize=7)
|
||||||
|
ax[4].plot(d.dtat, d.vacuum, lw=.5, color="teal"); ax[4].set_ylabel("진공 PICA-6111")
|
||||||
|
ax[4].set_ylim(100, 130)
|
||||||
|
# 모드 배경 음영
|
||||||
|
for a in ax:
|
||||||
|
for m, c in colors.items():
|
||||||
|
seg = d[d["mode"] == m]
|
||||||
|
a.scatter(seg.dtat, [a.get_ylim()[0]] * len(seg), c=c, s=2, marker="|")
|
||||||
|
fig.suptitle("C-6111 (6-1차) 전체기간 — 운전모드별 (하단 컬러바)")
|
||||||
|
fig.tight_layout()
|
||||||
|
fig.savefig(png, dpi=90)
|
||||||
|
print(f"플롯 저장: {png}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with psycopg.connect(DSN) as conn:
|
||||||
|
df = tag_frame(conn, ROLES)
|
||||||
|
print(f"행수={len(df)} 기간={df.dtat.min()} ~ {df.dtat.max()}")
|
||||||
|
print("\n=== 핵심 신호 분포 (운전모드 임계 설정용) ===")
|
||||||
|
show = ["feed", "reb_temp", "vacuum", "product", "reflux", "steam_op",
|
||||||
|
"steam_flow", "T_C", "T_D", "dp"]
|
||||||
|
desc = df[show].describe(percentiles=[.01, .05, .25, .5, .75, .95, .99]).T
|
||||||
|
print(desc[["min", "1%", "5%", "50%", "95%", "99%", "max"]].round(2).to_string())
|
||||||
|
|
||||||
|
df["mode"] = classify_phases(df)
|
||||||
|
print("\n=== 운전모드 분포 (30초 샘플 기준) ===")
|
||||||
|
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:7.1f} h")
|
||||||
|
|
||||||
|
out = "/home/windpacer/projects/hc900_ax/scripts/analysis/c6111_data.pkl"
|
||||||
|
df.to_pickle(out)
|
||||||
|
plot_timeline(df, "/home/windpacer/projects/hc900_ax/scripts/analysis/c6111_timeline.png")
|
||||||
|
print(f"저장: {out}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
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()
|
||||||
135
scripts/analysis/c6111_prodmap.py
Normal file
135
scripts/analysis/c6111_prodmap.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"""
|
||||||
|
① 생산 정상상태 맵.
|
||||||
|
|
||||||
|
PROD 구간에서 밸브특성 + 스팀유량 회귀.
|
||||||
|
|
||||||
|
선행: c6111_extract.py 가 만든 c6111_data.pkl (mode 컬럼 포함).
|
||||||
|
형제 컬럼 호환: --data, --prefix CLI 인자.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
from sklearn.linear_model import LinearRegression
|
||||||
|
from sklearn.ensemble import GradientBoostingRegressor
|
||||||
|
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"
|
||||||
|
FEATURES = ["feed", "product", "vacuum", "feed_preheat", "T_C", "T_D"]
|
||||||
|
OP_RESAMPLE = "6h"
|
||||||
|
|
||||||
|
|
||||||
|
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"]
|
||||||
|
df["dT_CD"] = df["T_C"] - df["T_D"]
|
||||||
|
# 기본 정합성: 유량/유효범위 (센서 음수노이즈·결측 제거)
|
||||||
|
df = df[(df["feed"] > 50) & (df["steam_flow"] > 10) & (df["steam_op"] > 1)
|
||||||
|
& df[FEATURES + [TARGET, "steam_op"]].notna().all(axis=1)]
|
||||||
|
return df.sort_values("dtat").reset_index(drop=True)
|
||||||
|
|
||||||
|
|
||||||
|
def valve_char(df):
|
||||||
|
"""OP(밸브%) ↔ 스팀유량(FIQ-6115) 특성."""
|
||||||
|
op, fl = df["steam_op"].values, df["steam_flow"].values
|
||||||
|
# 선형게인
|
||||||
|
a = np.polyfit(op, fl, 1)
|
||||||
|
# 상승/하강 방향별(히스테리시스 ~ stiction 신호): OP 변화방향으로 분리
|
||||||
|
dop = np.diff(df["steam_op"].values, prepend=df["steam_op"].values[0])
|
||||||
|
up, dn = dop > 0.05, dop < -0.05
|
||||||
|
# OP 빈(bin)별 유량 평균 — 같은 OP에서 상승/하강 유량차 = 히스테리시스
|
||||||
|
bins = np.arange(np.floor(op.min()), np.ceil(op.max()) + 1, 1.0)
|
||||||
|
rows = []
|
||||||
|
for lo, hi in zip(bins[:-1], bins[1:]):
|
||||||
|
m = (op >= lo) & (op < hi)
|
||||||
|
if m.sum() < 20:
|
||||||
|
continue
|
||||||
|
fu = fl[m & up].mean() if (m & up).sum() > 5 else np.nan
|
||||||
|
fd = fl[m & dn].mean() if (m & dn).sum() > 5 else np.nan
|
||||||
|
rows.append((lo + .5, fl[m].mean(), fu, fd, m.sum()))
|
||||||
|
hb = pd.DataFrame(rows, columns=["op", "flow", "flow_up", "flow_dn", "n"])
|
||||||
|
hyst = (hb["flow_dn"] - hb["flow_up"]).abs().mean()
|
||||||
|
print(f"[밸브] 선형 flow ≈ {a[0]:.1f}·OP + {a[1]:.1f} "
|
||||||
|
f"(OP {op.min():.0f}~{op.max():.0f}%, flow {fl.min():.0f}~{fl.max():.0f})")
|
||||||
|
print(f"[밸브] 상승/하강 평균 유량차(히스테리시스≈stiction) = {hyst:.1f} "
|
||||||
|
f"(유량 스팬의 {100*hyst/(fl.max()-fl.min()):.1f}%)")
|
||||||
|
return hb, a
|
||||||
|
|
||||||
|
|
||||||
|
def regress(df):
|
||||||
|
from sklearn.model_selection import train_test_split
|
||||||
|
# 운전점 집계: 정상상태 내부 변동이 거의 없어(98% steady) 점단위 학습 불가.
|
||||||
|
# 6h 중앙값 = 캠페인/로드레벨 단위 운전점 → 진짜 f(부하) 신호.
|
||||||
|
ops = (df.set_index("dtat").resample(OP_RESAMPLE).median(numeric_only=True)
|
||||||
|
.dropna(subset=[TARGET, "feed"]))
|
||||||
|
ops = ops[ops["feed"] > 50]
|
||||||
|
print(f"\n[운전점] PROD {len(df)}행 → {OP_RESAMPLE} 운전점 {len(ops)}개")
|
||||||
|
X, y = ops[FEATURES].values, ops[TARGET].values
|
||||||
|
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=.3, random_state=0)
|
||||||
|
|
||||||
|
# 베이스라인: 피드만 (steam/feed 비율제어가 얼마나 설명?)
|
||||||
|
lb = LinearRegression().fit(Xtr[:, :1], ytr)
|
||||||
|
r2_feed = r2_score(yte, lb.predict(Xte[:, :1]))
|
||||||
|
|
||||||
|
sc = StandardScaler().fit(Xtr)
|
||||||
|
lin = LinearRegression().fit(sc.transform(Xtr), ytr)
|
||||||
|
gbm = GradientBoostingRegressor(n_estimators=200, max_depth=2,
|
||||||
|
learning_rate=0.05, random_state=0).fit(Xtr, ytr)
|
||||||
|
span = y.max() - y.min()
|
||||||
|
for name, pred in [("Linear", lin.predict(sc.transform(Xte))),
|
||||||
|
("GBM", gbm.predict(Xte))]:
|
||||||
|
print(f"[모델 {name:7s}] test R²(FIT)={r2_score(yte,pred):.3f} "
|
||||||
|
f"MAE={mean_absolute_error(yte,pred):.1f} (스팬의 {100*mean_absolute_error(yte,pred)/span:.1f}%)")
|
||||||
|
print(f"[베이스라인 피드단독] test R²={r2_feed:.3f} "
|
||||||
|
f"steam/feed비 중앙값={(ops[TARGET]/ops['feed']).median():.3f}")
|
||||||
|
|
||||||
|
print("\n[피처 중요도]")
|
||||||
|
coef = pd.Series(lin.coef_, index=FEATURES) # 표준화 → 상대중요도
|
||||||
|
imp = pd.Series(gbm.feature_importances_, index=FEATURES)
|
||||||
|
tbl = pd.DataFrame({"lin_std계수": coef.round(1),
|
||||||
|
"GBM중요도": imp.round(3)}).sort_values("GBM중요도", ascending=False)
|
||||||
|
print(tbl.to_string())
|
||||||
|
return ops, gbm, Xte, yte, gbm.predict(Xte), 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")
|
||||||
|
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"); 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 + f"{prefix}_prodmap.png", dpi=95)
|
||||||
|
print(f"\n플롯 저장: {BASE}{prefix}_prodmap.png")
|
||||||
|
|
||||||
|
|
||||||
|
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 = 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, args.prefix)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
85
scripts/analysis/c6111_rolling.py
Normal file
85
scripts/analysis/c6111_rolling.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
롤링(walk-forward) 재학습 — OOD/외삽 바이어스 해소 데모.
|
||||||
|
|
||||||
|
형제 컬럼 호환: --data, --prefix CLI 인자.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
from sklearn.metrics import mean_absolute_error
|
||||||
|
from c6111_shadow import SteamPredictor, FEATURES, BASE, SMOOTH
|
||||||
|
|
||||||
|
HELDOUT_START = "2026-05-01"
|
||||||
|
RETRAIN_EVERY = "1D"
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
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")
|
||||||
|
# 인과(trailing) 평활 — 미래누설 없음
|
||||||
|
for c in FEATURES:
|
||||||
|
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회 학습
|
||||||
|
static = SteamPredictor().fit(df[df["dtat"] < ho])
|
||||||
|
slo, shi = (df[df["dtat"] < ho][FEATURES].quantile(0.01),
|
||||||
|
df[df["dtat"] < ho][FEATURES].quantile(0.99))
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for d0, d1 in zip(days[:-1], days[1:]):
|
||||||
|
day = df[(df["dtat"] >= d0) & (df["dtat"] < d1)]
|
||||||
|
if len(day) < 30:
|
||||||
|
continue
|
||||||
|
train = df[df["dtat"] < d0] # expanding: 그 날 이전 전체
|
||||||
|
roll = SteamPredictor().fit(train)
|
||||||
|
lo, hi = train[FEATURES].quantile(0.01), train[FEATURES].quantile(0.99)
|
||||||
|
Xs = day[[c + "_s" for c in FEATURES]].values
|
||||||
|
ao = day["steam_op"].values
|
||||||
|
po_r = roll.flow_to_op(roll.predict_flow(Xs))
|
||||||
|
po_s = static.flow_to_op(static.predict_flow(Xs))
|
||||||
|
ood_r = (~((day[FEATURES] >= lo) & (day[FEATURES] <= hi)).all(axis=1)).mean()
|
||||||
|
rows.append(dict(day=d0,
|
||||||
|
mae_roll=mean_absolute_error(ao, po_r),
|
||||||
|
mae_static=mean_absolute_error(ao, po_s),
|
||||||
|
w2_roll=np.mean(np.abs(po_r - ao) <= 2) * 100,
|
||||||
|
w2_static=np.mean(np.abs(po_s - ao) <= 2) * 100,
|
||||||
|
ood_roll=ood_r * 100))
|
||||||
|
r = pd.DataFrame(rows)
|
||||||
|
|
||||||
|
print(f"=== 5월 held-out, 일별 walk-forward 재학습 ({len(r)}일) ===")
|
||||||
|
print(f"정적 모델 : OP MAE {r.mae_static.mean():.2f}% |Δ|≤2% {r.w2_static.mean():.1f}%")
|
||||||
|
print(f"롤링 모델 : OP MAE {r.mae_roll.mean():.2f}% |Δ|≤2% {r.w2_roll.mean():.1f}%")
|
||||||
|
print(f"롤링 OOD 비율: 첫주 {r.head(7).ood_roll.mean():.0f}% → 마지막주 {r.tail(7).ood_roll.mean():.0f}%")
|
||||||
|
print("\n일별(요약):")
|
||||||
|
print(r[["day", "mae_static", "mae_roll", "w2_roll", "ood_roll"]]
|
||||||
|
.assign(day=r.day.dt.strftime("%m-%d")).round(1).to_string(index=False))
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(2, 1, figsize=(14, 8), sharex=True)
|
||||||
|
ax[0].plot(r.day, r.mae_static, "r.-", label="static (Feb-Apr model)")
|
||||||
|
ax[0].plot(r.day, r.mae_roll, "g.-", label="rolling retrain")
|
||||||
|
ax[0].axhline(2, color="gray", ls=":", label="2% 허용")
|
||||||
|
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 + f"{args.prefix}_rolling.png", dpi=95)
|
||||||
|
print(f"\n플롯 저장: {BASE}{args.prefix}_rolling.png")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
100
scripts/analysis/c6111_shadow.py
Normal file
100
scripts/analysis/c6111_shadow.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""
|
||||||
|
Shadow 예측기 — 히스토리 리플레이 백테스트.
|
||||||
|
|
||||||
|
선행: c6111_data.pkl. 형제 컬럼 호환: --data, --prefix CLI 인자.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
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"]
|
||||||
|
SMOOTH = 40
|
||||||
|
TRAIN_FRAC = 0.70
|
||||||
|
|
||||||
|
|
||||||
|
class SteamPredictor:
|
||||||
|
"""운전점 학습 + 밸브 역특성(flow→OP)."""
|
||||||
|
def fit(self, df_train):
|
||||||
|
ops = (df_train.set_index("dtat").resample("6h").median(numeric_only=True)
|
||||||
|
.dropna(subset=["steam_flow", "feed"]))
|
||||||
|
ops = ops[ops["feed"] > 50]
|
||||||
|
self.model = GradientBoostingRegressor(n_estimators=200, max_depth=2,
|
||||||
|
learning_rate=0.05, random_state=0)
|
||||||
|
self.model.fit(ops[FEATURES].values, ops["steam_flow"].values)
|
||||||
|
# 밸브 역특성: OP = poly(flow) (단조, 3차)
|
||||||
|
self.inv = np.polyfit(df_train["steam_flow"], df_train["steam_op"], 3)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def predict_flow(self, X):
|
||||||
|
return self.model.predict(X)
|
||||||
|
|
||||||
|
def flow_to_op(self, flow):
|
||||||
|
return np.clip(np.polyval(self.inv, flow), 0, 100)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
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")
|
||||||
|
# 입력 평활 (실제 shadow도 노이즈 평활 사용)
|
||||||
|
for c in FEATURES:
|
||||||
|
df[c + "_s"] = df[c].rolling(SMOOTH, min_periods=1, center=True).median()
|
||||||
|
|
||||||
|
cut = df["dtat"].quantile(TRAIN_FRAC)
|
||||||
|
tr, te = df[df["dtat"] <= cut], df[df["dtat"] > cut]
|
||||||
|
print(f"학습 {tr.dtat.min()}~{tr.dtat.max()} ({len(tr)}) "
|
||||||
|
f"shadow(held-out) {te.dtat.min()}~{te.dtat.max()} ({len(te)})")
|
||||||
|
|
||||||
|
pred = SteamPredictor().fit(tr)
|
||||||
|
# OOD(학습 운전envelope 밖) 게이트: 입력이 학습 1~99% 범위 밖이면 '저신뢰→오퍼레이터 폴백'
|
||||||
|
lo, hi = tr[FEATURES].quantile(0.01), tr[FEATURES].quantile(0.99)
|
||||||
|
print(f"학습 envelope: " + ", ".join(f"{c}[{lo[c]:.0f},{hi[c]:.1f}]" for c in FEATURES))
|
||||||
|
|
||||||
|
def in_env(d):
|
||||||
|
return ((d[FEATURES] >= lo) & (d[FEATURES] <= hi)).all(axis=1)
|
||||||
|
|
||||||
|
for name, d in [("학습기간", tr), ("★held-out shadow", te)]:
|
||||||
|
Xs = d[[c + "_s" for c in FEATURES]].values
|
||||||
|
pf = pred.predict_flow(Xs)
|
||||||
|
po = pred.flow_to_op(pf)
|
||||||
|
ao = d["steam_op"].values
|
||||||
|
env = in_env(d).values
|
||||||
|
within = np.mean(np.abs(po - ao) <= 2.0) * 100
|
||||||
|
print(f"\n[{name}] OOD(범위밖)={100*(~env).mean():.1f}%")
|
||||||
|
print(f" 전체 OP MAE={mean_absolute_error(ao,po):.2f} |Δ|≤2%={within:.1f}%")
|
||||||
|
if env.sum() > 50:
|
||||||
|
print(f" in-envelope OP MAE={mean_absolute_error(ao[env],po[env]):.2f} "
|
||||||
|
f"|Δ|≤2%={np.mean(np.abs(po[env]-ao[env])<=2)*100:.1f}% ← shadow가 신뢰구간에서 조언")
|
||||||
|
d = d.assign(pred_flow=pf, pred_op=po, ood=~env)
|
||||||
|
if name.startswith("★"):
|
||||||
|
te = d
|
||||||
|
|
||||||
|
# 플롯: held-out 시계열 오버레이 + OP 비교 + 오차분포
|
||||||
|
fig, ax = plt.subplots(3, 1, figsize=(16, 11))
|
||||||
|
s = te.iloc[::20]
|
||||||
|
ax[0].plot(s.dtat, s["steam_flow"], lw=.6, label="actual steam flow")
|
||||||
|
ax[0].plot(s.dtat, s["pred_flow"], lw=.6, c="r", label="predicted")
|
||||||
|
ax[0].set_title("held-out shadow: steam flow actual vs predicted"); ax[0].legend(fontsize=8)
|
||||||
|
ax[1].plot(s.dtat, s["steam_op"], lw=.6, label="actual operator OP")
|
||||||
|
ax[1].plot(s.dtat, s["pred_op"], lw=.6, c="r", label="predicted OP")
|
||||||
|
ax[1].set_ylabel("OP %"); ax[1].set_title("operator OP vs shadow-predicted OP"); ax[1].legend(fontsize=8)
|
||||||
|
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 + f"{args.prefix}_shadow.png", dpi=95)
|
||||||
|
print(f"\n플롯 저장: {BASE}{args.prefix}_shadow.png")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
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()
|
||||||
111
scripts/analysis/c6111_startup.py
Normal file
111
scripts/analysis/c6111_startup.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"""
|
||||||
|
② START-UP 절차 학습 (few-shot).
|
||||||
|
|
||||||
|
형제 컬럼 호환: --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_cutins(df):
|
||||||
|
"""★제품 컷인★ 이벤트: product 0→>100 상향, 직전 30분 라인아웃(product<50)이고 hot(reb>75)."""
|
||||||
|
prod = df["product"].values
|
||||||
|
reb = df["reb_temp"].values
|
||||||
|
outs = []
|
||||||
|
i = 60
|
||||||
|
n = len(df)
|
||||||
|
while i < n:
|
||||||
|
if prod[i] > 100 and prod[i-1] <= 100:
|
||||||
|
pre = prod[max(0, i-60):i] # 직전 30분
|
||||||
|
if np.nanmedian(pre) < 50 and reb[i] > 75: # 라인아웃(제품off)+hot
|
||||||
|
outs.append(i)
|
||||||
|
i += 720
|
||||||
|
continue
|
||||||
|
i += 1
|
||||||
|
return outs
|
||||||
|
|
||||||
|
|
||||||
|
def milestones(df, ci):
|
||||||
|
"""제품 컷인 인덱스 ci 기준 절차 추출."""
|
||||||
|
tc = df["dtat"].iloc[ci]
|
||||||
|
# 역방향: 스팀투입(steam_op>10 연속 시작) — 컷인 직전 steam off→on
|
||||||
|
back = df.iloc[max(0, ci-1200):ci]
|
||||||
|
off = back[back["steam_op"] <= 10]
|
||||||
|
i_steam = off.index[-1] + 1 if len(off) else back.index[0]
|
||||||
|
# 리플럭스 확립(스팀투입 이후 reflux>100 첫)
|
||||||
|
aft = df.iloc[i_steam:ci]
|
||||||
|
r_on = aft[aft["reflux"] > 100]
|
||||||
|
i_refl = r_on.index[0] if len(r_on) else None
|
||||||
|
# 풀로드(컷인 이후 feed>250 첫)
|
||||||
|
fwd = df.iloc[ci:ci+1200]
|
||||||
|
f_on = fwd[fwd["feed"] > 250]
|
||||||
|
i_full = f_on.index[0] if len(f_on) else None
|
||||||
|
|
||||||
|
def mins(i):
|
||||||
|
return None if i is None else (df["dtat"].iloc[i]-tc).total_seconds()/60
|
||||||
|
r = df.iloc[ci]
|
||||||
|
return dict(cutin_time=tc,
|
||||||
|
steam_to_cutin=-mins(i_steam),
|
||||||
|
reflux_to_cutin=(-mins(i_refl) if i_refl is not None else None),
|
||||||
|
cutin_to_full=mins(i_full),
|
||||||
|
cutin_rebA=r["reb_temp"], cutin_TC=r["T_C"], cutin_TD=r["T_D"],
|
||||||
|
cutin_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)
|
||||||
|
cutins = detect_cutins(df)
|
||||||
|
print(f"탐지된 ★제품 컷인★(진짜 startup) 이벤트: {len(cutins)}개")
|
||||||
|
|
||||||
|
rows, windows = [], []
|
||||||
|
for ci in cutins:
|
||||||
|
w = df.iloc[max(0, ci-360):min(len(df), ci+360)].copy() # 컷인 ±3h
|
||||||
|
w["rel_min"] = (w["dtat"] - df["dtat"].iloc[ci]).dt.total_seconds()/60
|
||||||
|
windows.append(w)
|
||||||
|
rows.append(milestones(df, ci))
|
||||||
|
M = pd.DataFrame(rows)
|
||||||
|
pd.set_option("display.width", 220)
|
||||||
|
print("\n=== 제품컷인 기준 절차(분) + 컷인 시점 컬럼상태 ===")
|
||||||
|
cols = ["cutin_time", "steam_to_cutin", "reflux_to_cutin", "cutin_to_full",
|
||||||
|
"cutin_rebA", "cutin_TC", "cutin_dT_AD"]
|
||||||
|
show = M[cols].copy()
|
||||||
|
show["cutin_time"] = show["cutin_time"].dt.strftime("%m-%d %H:%M")
|
||||||
|
print(show.round(1).to_string(index=False))
|
||||||
|
print("\n=== 절차 레시피(중앙값) ===")
|
||||||
|
print(f" 스팀투입→제품컷인(전환류 라인아웃 길이): {M.steam_to_cutin.median():.0f}분")
|
||||||
|
print(f" 리플럭스확립→제품컷인 : {M.reflux_to_cutin.median():.0f}분")
|
||||||
|
print(f" 제품컷인→풀로드 : {M.cutin_to_full.median():.0f}분")
|
||||||
|
print(f" ★제품컷인 트리거(컬럼상태): reb-A={M.cutin_rebA.median():.1f}±{M.cutin_rebA.std():.1f}℃, "
|
||||||
|
f"T_C={M.cutin_TC.median():.1f}±{M.cutin_TC.std():.2f}℃, ΔT(A-D)={M.cutin_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"ep{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("STARTUP aligned at PRODUCT CUT-IN (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-in")
|
||||||
|
for a in ax:
|
||||||
|
a.axvline(0, c="k", lw=.5)
|
||||||
|
fig.tight_layout(); fig.savefig(BASE + f"{args.prefix}_startup.png", dpi=95)
|
||||||
|
print(f"\n플롯 저장: {BASE}{args.prefix}_startup.png")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
92
scripts/analysis/c6111_trim.py
Normal file
92
scripts/analysis/c6111_trim.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
C-6111 캠페인내 온도 트림 = gentle 피드백 추출 (플랜 §5, §16.8-(3)).
|
||||||
|
|
||||||
|
정상맵(전향)은 운전점 단위 steam=f(부하)만 잡는다. 캠페인 내부에서 오퍼레이터가
|
||||||
|
T_C 작은 편차에 스팀(OP)을 어떻게 미세조정하는지 = 피드백 정책을 데이터에서 추출:
|
||||||
|
- 부하 일정 구간만(전향/부하응답과 분리)
|
||||||
|
- 목표 T_C = 느린 인과 베이스라인, 오차 err = T_C - 목표
|
||||||
|
- OP 변경 이벤트의 트리거 오차(데드밴드), 변경크기 vs 오차(게인), 이벤트 간격(dwell)
|
||||||
|
→ §5 anti-hunting 제어(데드밴드+게인+dwell)의 현장 파라미터.
|
||||||
|
"""
|
||||||
|
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/"
|
||||||
|
TGT_WIN = 360 # 목표 T_C 베이스라인 3h(인과 trailing)
|
||||||
|
LOAD_WIN = 120 # 부하 일정 판정 1h
|
||||||
|
LOAD_STD_MAX = 15 # feed rolling std 임계(저변동=캠페인 내부)
|
||||||
|
MOVE = 0.1 # OP 변경 인식 임계(%)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
df = pd.read_pickle(BASE + "c6111_data.pkl")
|
||||||
|
df = df[df["mode"] == "PROD"].copy().sort_values("dtat").reset_index(drop=True)
|
||||||
|
df = df[(df["feed"] > 50) & (df["steam_op"] > 1)]
|
||||||
|
|
||||||
|
# 목표 T_C(느린 인과 베이스라인) 및 오차
|
||||||
|
df["tc_tgt"] = df["T_C"].rolling(TGT_WIN, min_periods=30).median()
|
||||||
|
df["err"] = df["T_C"] - df["tc_tgt"]
|
||||||
|
df["dop"] = df["steam_op"].diff()
|
||||||
|
# 부하 일정 구간(전향 부하응답 제외 → 순수 피드백 트림)
|
||||||
|
df["feed_std"] = df["feed"].rolling(LOAD_WIN, min_periods=30).std()
|
||||||
|
steady = df[(df["feed_std"] < LOAD_STD_MAX) & df["err"].notna()].copy()
|
||||||
|
print(f"PROD {len(df)} → 부하일정 정상구간 {len(steady)} ({100*len(steady)/len(df):.0f}%)")
|
||||||
|
|
||||||
|
# 제어 성능: 목표 대비 T_C 유지 밴드
|
||||||
|
e = steady["err"]
|
||||||
|
print(f"\n[T_C 유지] 오차 std={e.std():.3f}℃ p5/p95=[{e.quantile(.05):+.2f},{e.quantile(.95):+.2f}]℃")
|
||||||
|
|
||||||
|
# OP 변경 이벤트
|
||||||
|
mv = steady[steady["dop"].abs() > MOVE].copy()
|
||||||
|
# 직전 오차(트리거)
|
||||||
|
mv["err_trig"] = steady["err"].shift(1).loc[mv.index]
|
||||||
|
print(f"[OP 이동] 정상구간 {len(steady)}샘플 중 이동 {len(mv)}회 "
|
||||||
|
f"(평탄율 {100*(1-len(mv)/len(steady)):.1f}%)")
|
||||||
|
|
||||||
|
# 데드밴드: 이동시 |오차| vs 비이동시 |오차|
|
||||||
|
nomv = steady[steady["dop"].abs() <= MOVE]
|
||||||
|
print(f"[데드밴드] 이동시 |오차| median={mv['err_trig'].abs().median():.3f}℃ "
|
||||||
|
f"비이동시 |오차| median={nomv['err'].abs().median():.3f}℃")
|
||||||
|
print(f" 이동의 75%는 |오차|>{mv['err_trig'].abs().quantile(.25):.3f}℃ 에서 발생")
|
||||||
|
|
||||||
|
# 피드백 게인: dOP vs 트리거오차 (음의 기울기 기대)
|
||||||
|
g = mv.dropna(subset=["err_trig"])
|
||||||
|
g = g[g["err_trig"].abs() < 2] # 이상치 제외
|
||||||
|
if len(g) > 30:
|
||||||
|
a = np.polyfit(g["err_trig"], g["dop"], 1)
|
||||||
|
r = np.corrcoef(g["err_trig"], g["dop"])[0, 1]
|
||||||
|
print(f"[피드백 게인] dOP ≈ {a[0]:+.2f}·오차 {a[1]:+.2f} (corr={r:+.2f}, "
|
||||||
|
f"음수=음의피드백: 온도↑→스팀↓)")
|
||||||
|
|
||||||
|
# dwell: 이동 간격(샘플)
|
||||||
|
dwell = np.diff(mv.index.values)
|
||||||
|
dwell = dwell[dwell > 0]
|
||||||
|
if len(dwell):
|
||||||
|
print(f"[dwell] 이동 간격 중앙 {np.median(dwell)*30/60:.0f}분 "
|
||||||
|
f"p25/p75=[{np.percentile(dwell,25)*30/60:.0f},{np.percentile(dwell,75)*30/60:.0f}]분")
|
||||||
|
|
||||||
|
# 플롯
|
||||||
|
fig, ax = plt.subplots(2, 2, figsize=(14, 9))
|
||||||
|
ax[0, 0].hist(e.dropna(), bins=100); ax[0, 0].axvline(0, c="k", lw=.5)
|
||||||
|
ax[0, 0].set_title(f"T_C error band (std {e.std():.3f}C)"); ax[0, 0].set_xlim(-1, 1)
|
||||||
|
ax[0, 1].hist(mv["err_trig"].abs().dropna(), bins=60, alpha=.6, density=True, label="at move")
|
||||||
|
ax[0, 1].hist(nomv["err"].abs().dropna(), bins=60, alpha=.6, density=True, label="no move")
|
||||||
|
ax[0, 1].set_title("deadband: |error| at move vs no-move"); ax[0, 1].set_xlim(0, 1); ax[0, 1].legend()
|
||||||
|
if len(g) > 30:
|
||||||
|
ax[1, 0].scatter(g["err_trig"], g["dop"], s=5, alpha=.3)
|
||||||
|
xs = np.linspace(g["err_trig"].min(), g["err_trig"].max(), 50)
|
||||||
|
ax[1, 0].plot(xs, np.polyval(a, xs), "r-")
|
||||||
|
ax[1, 0].set_xlabel("T_C error trigger"); ax[1, 0].set_ylabel("dOP")
|
||||||
|
ax[1, 0].set_title(f"feedback gain dOP/err = {a[0]:+.2f}")
|
||||||
|
if len(dwell):
|
||||||
|
ax[1, 1].hist(dwell * 30 / 60, bins=60); ax[1, 1].set_xlabel("dwell min")
|
||||||
|
ax[1, 1].set_title("dwell between OP moves"); ax[1, 1].set_xlim(0, 300)
|
||||||
|
fig.tight_layout(); fig.savefig(BASE + "c6111_trim.png", dpi=95)
|
||||||
|
print(f"\n플롯 저장: {BASE}c6111_trim.png")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
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,
|
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 =>
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
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