docs: 작업지시·진단·아키텍처 설계 문서 추가
온도프로파일/PV일관성/PointBuilder/history 작업지시, 신호태그·스팀유량 진단, 베이직아키텍처 재설계, MSDS, LLM채팅 구조 등. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
35
HANOVER.md
Normal file
35
HANOVER.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# 인수인계 — 다음 LLM 작업자를 위한 진입점
|
||||||
|
|
||||||
|
> 기준 커밋: `1f989bd` (`git log -1`으로 확인 후 진행)
|
||||||
|
|
||||||
|
## 착수 순서 (2순위)
|
||||||
|
|
||||||
|
| 순위 | 작업 | 진입점 |
|
||||||
|
|------|------|--------|
|
||||||
|
| **1순위** | 모듈1 shadow 검증 | `FeedforwardEngine.cs:163-201` Normal 분기(SteamRecOp 산출). `FfTrackingStore`에 shadow 로깅 재활용. |
|
||||||
|
| **2순위** | 모듈4-A 하강 램프 | `FeedRampCalculator.Compute()` 하강 분기(현재 `FeedRampCalculator.cs:87-88` — warning만 + 시간 0). `StreamConfig.RateDnPerMin`(이미 존재) 사용. |
|
||||||
|
|
||||||
|
## 스펙 문서 (이것만 읽으면 됨)
|
||||||
|
|
||||||
|
- `docs/작업플랜-민감단온도-전환복귀제어.md` — 구현 상태 표 + 모듈별 file:line
|
||||||
|
- `docs/작업플랜-FF온도계위젯-C민감단-sweetspot.md` — 구현 상태 표 + 일반화 원칙
|
||||||
|
|
||||||
|
## ★ 외부 LLM이 놓치기 쉬운 5가지 제약 (반드시 전달)
|
||||||
|
|
||||||
|
1. **★ closed-loop 자동제어 금지** — 전 모드 advisory-only 우선, 운전원 개별 follow 인가 시에만 write (메모리: 현장 조건 실변동 중).
|
||||||
|
2. **`TcReturnTcTarget`/`Band` 이중용도** — 위젯 시각화 밴드 = 엔진 복귀 게이트(`FeedforwardEngine.cs:497-498`). 위젯에서 이 값 편집 UI 만들지 말 것.
|
||||||
|
3. **현재 C3/6차만 online (C4 미연결)** — first-cut·검증은 6-1차. 위젯 단 라벨 config화는 C4 붙기 직전까지 보류.
|
||||||
|
4. **하드코딩 금지** — 단 개수·민감단 위치는 `temps[]` 순회 + `sensitiveTrayTag` 매칭으로. 잔여 하드코딩은 라벨 텍스트(`trayLabels`/`trayPcts`, `ff.js:288~`)뿐.
|
||||||
|
5. **range·기준값은 realtime/config live** — xlsx·상수 금지.
|
||||||
|
|
||||||
|
## 상태 요약
|
||||||
|
|
||||||
|
| 모듈 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| 모듈1 — T_C 유지 SP 제안(Normal) | ✅ 완료 |
|
||||||
|
| 모듈2 — 전환류(Recovering) 트리거 | ⏳ 미착수 |
|
||||||
|
| 모듈3 — 복귀(Returning) 게이트 | ⏳ 미착수 |
|
||||||
|
| 모듈4-A — 온도 하강 램프 | ⏳ 미착수 (2순위) |
|
||||||
|
| 모듈4-B — feed-steam 동반 램프 | ⏳ 후속 작업 |
|
||||||
|
| 모듈4-C — Bumpless 보장 | ⏳ 미착수 |
|
||||||
|
| 위젯 온도계 (ffThermometer) | ✅ 완료 (라벨 config화 잔여) |
|
||||||
64
docs/LLM-채팅-태그-구조-문제점.md
Normal file
64
docs/LLM-채팅-태그-구조-문제점.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# LLM 채팅 태그 구조 문제점
|
||||||
|
|
||||||
|
> 발견일: 2026-06-09
|
||||||
|
|
||||||
|
## 문제 요약
|
||||||
|
|
||||||
|
LLM 채팅에서 "6-1차 플랜트(P6-1)의 현재 운전 상황" 조회 시 데이터 없음.
|
||||||
|
다음 4가지 원인이 중첩됨.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 문제 1 (해결): MCP 서버 실행 경로
|
||||||
|
|
||||||
|
**원인:** `~/.config/opencode/opencode.json`에서 `ExperionCrawler/mcp-server`(구버전)로
|
||||||
|
설정되어 있었음. 구버전은 `search_path=hc900`이 없어 `v_tag_summary`를 찾지 못함.
|
||||||
|
|
||||||
|
**조치:** `hc900_ax/mcp-server`로 경로 변경 (`opencode.json` 수정)
|
||||||
|
|
||||||
|
## 문제 2 (해결): `v_tag_summary` 뷰 접미사 대소문자
|
||||||
|
|
||||||
|
**원인:** 뷰가 `.pv`(소문자)로 JOIN하지만 `realtime_table`은 `.PV`(대문자) → 모든 PV=NULL
|
||||||
|
|
||||||
|
**조치:** 뷰 재정의 — `.PV`, `.SP`, `.OP` (대문자 접미사)
|
||||||
|
|
||||||
|
## 문제 3 (해결): `v_plant_running_state*` 뷰 영역/패턴 대소문자
|
||||||
|
|
||||||
|
**원인:**
|
||||||
|
- `split_part(area, '|', 2)`가 단축 영역코드(`P6`)에서 빈 문자열 반환
|
||||||
|
- `base_tag ~~ 'p-%'`가 대문자 태그명(`P-6101`)에 불일치
|
||||||
|
|
||||||
|
**조치:**
|
||||||
|
- `COALESCE` + 영역코드 정규화 (두 형식 모두 처리)
|
||||||
|
- `~~*` (ILIKE)로 대소문자 무시 매칭
|
||||||
|
|
||||||
|
## 문제 4 (해결): `tag_metadata`에 `sub_area` 미등록
|
||||||
|
|
||||||
|
**원인:** `build_register_map.py`가 `sub_area` attribute를 생성하지 않음
|
||||||
|
|
||||||
|
**조치:** 태그번호 prefix 기반 sub_area 자동 매핑 스크립트 실행 → 802건 등록
|
||||||
|
|
||||||
|
## 문제 5 (진행중): 실시간 데이터 부족
|
||||||
|
|
||||||
|
**현상:** `event_history_table`에 P6-1 이벤트 없음
|
||||||
|
|
||||||
|
**원인:** HC900 게이트웨이 가동 2일차, 디지털 이벤트 수집 미설정
|
||||||
|
|
||||||
|
**조치:** 시간이 해결할 문제
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 현재 상태
|
||||||
|
|
||||||
|
| 항목 | 수정 전 | 수정 후 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| `v_tag_summary` PV not null | 0건 (전체 NULL) | P6 118건 전부 정상 |
|
||||||
|
| `v_tag_summary` sub_area | 0건 | 345건 (전 area) |
|
||||||
|
| `v_plant_running_state` | 빈 결과 | P6=16펌프/5RUNNING |
|
||||||
|
| `find_tags(sub_area='P6-1')` | 작동 불가 | 재시작 후 정상 예상 |
|
||||||
|
| 라이브 데이터 | 2일치 | 충분치 않음 |
|
||||||
|
|
||||||
|
## 필요조치
|
||||||
|
|
||||||
|
- opencode 재시작 → `~/.config/opencode/opencode.json` 경로변경 반영
|
||||||
|
- (선택) `build_register_map.py`에 sub_area 생성 로직 추가
|
||||||
220
docs/MSDS_PMA_PGMEA_EL.md
Normal file
220
docs/MSDS_PMA_PGMEA_EL.md
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
# MSDS 요약: PMA / PGMEA / EL
|
||||||
|
|
||||||
|
> 본 문서는 반도체 공정용 주요 용제 3종의 물질안전보건자료(MSDS)를 요약한 것입니다.
|
||||||
|
> 참고용으로만 사용하며, 최신 공식 MSDS를 반드시 병행 확인하십시오.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. PMA (Propylene Glycol Monomethyl Ether Acetate)
|
||||||
|
|
||||||
|
### 1.1 기본 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 화학명 | 1-Methoxy-2-propyl acetate |
|
||||||
|
| CAS No. | 108-65-6 |
|
||||||
|
| 분자식 | C₆H₁₂O₃ |
|
||||||
|
| 분자량 | 132.16 g/mol |
|
||||||
|
| 외관 | 무색 투명 액체 |
|
||||||
|
| 냄새 | 약한 에테르/에스테르 향 |
|
||||||
|
|
||||||
|
> **주의:** PMA와 PGMEA는 동일 물질입니다. 업계에서는 제조사·용도에 따라 혼용되며, 본 문서에서는 구분 표기합니다.
|
||||||
|
|
||||||
|
### 1.2 물리·화학적 특성
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| 비점 | 146 °C |
|
||||||
|
| 융점 | −87 °C |
|
||||||
|
| 인화점 | 43 °C (밀폐컵) |
|
||||||
|
| 발화점 | 270 °C |
|
||||||
|
| 증기압 | 3.7 mmHg @ 20 °C |
|
||||||
|
| 증기밀도 | 4.6 (공기 = 1) |
|
||||||
|
| 비중 | 0.966 g/mL @ 20 °C |
|
||||||
|
| 수용해도 | 부분 용해 |
|
||||||
|
| 폭발한계 | 1.0 – 8.0 vol% |
|
||||||
|
|
||||||
|
### 1.3 유해·위험성
|
||||||
|
|
||||||
|
| 구분 | GHS 분류 |
|
||||||
|
|------|----------|
|
||||||
|
| 인화성 액체 | GHS02 — 구분 3 |
|
||||||
|
| 눈 자극 | GHS07 — 구분 2 |
|
||||||
|
| 특정 표적 장기 독성 (단회) | GHS08 — 구분 3 (마취 효과) |
|
||||||
|
|
||||||
|
- 증기 흡입 시 두통, 어지러움, 마취 증상
|
||||||
|
- 눈·피부 접촉 시 자극
|
||||||
|
- 생식독성 우려 (동물실험 참고)
|
||||||
|
|
||||||
|
### 1.4 응급조치
|
||||||
|
|
||||||
|
| 노출 경로 | 조치 |
|
||||||
|
|-----------|------|
|
||||||
|
| 흡입 | 신선한 공기로 이동, 증상 지속 시 의사 진찰 |
|
||||||
|
| 피부 | 오염 의복 제거 후 다량의 물로 세척 |
|
||||||
|
| 눈 | 흐르는 물로 15분 이상 세안, 즉시 의사 진찰 |
|
||||||
|
| 섭취 | 구토 유도 금지, 즉시 의사 진찰 |
|
||||||
|
|
||||||
|
### 1.5 취급·저장
|
||||||
|
|
||||||
|
- 점화원·열원으로부터 격리, 정전기 방지
|
||||||
|
- 밀폐 용기에 서늘하고 환기 좋은 장소 저장
|
||||||
|
- 산화제, 강산, 강염기와 격리
|
||||||
|
- 사용 구역 금연·금화기
|
||||||
|
|
||||||
|
### 1.6 누출 대응
|
||||||
|
|
||||||
|
- 점화원 제거 후 환기
|
||||||
|
- 모래, 흡수제로 회수 — 하수구·수계 유입 방지
|
||||||
|
- 폐기물 관련 법령에 따라 처리
|
||||||
|
|
||||||
|
### 1.7 노출 기준 및 보호구
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| ACGIH TLV-TWA | 50 ppm |
|
||||||
|
| OSHA PEL | 100 ppm |
|
||||||
|
| 호흡 보호구 | 유기증기 카트리지 방독마스크 |
|
||||||
|
| 눈 보호구 | 화학용 보안경 또는 페이스 실드 |
|
||||||
|
| 피부 보호구 | 내화학성 장갑 (니트릴 또는 부틸) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. PGMEA (Propylene Glycol Monomethyl Ether Acetate)
|
||||||
|
|
||||||
|
> PMA와 동일 물질 (CAS 108-65-6).
|
||||||
|
> 반도체 포토레지스트 공정에서 주로 사용되는 명칭으로, 순도 및 금속 불순물 규격이 엄격하게 관리됩니다.
|
||||||
|
|
||||||
|
### 2.1 기본 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 화학명 | Propylene Glycol Methyl Ether Acetate |
|
||||||
|
| 별칭 | PMA, PGMEA, 1-MPA |
|
||||||
|
| CAS No. | 108-65-6 |
|
||||||
|
| 주요 용도 | 포토레지스트 용제, 잉크, 코팅 |
|
||||||
|
| 등급 | Electronic Grade (반도체 공정용) |
|
||||||
|
|
||||||
|
### 2.2 물리·화학적 특성
|
||||||
|
|
||||||
|
PMA 항목과 동일 (1.2 참조)
|
||||||
|
|
||||||
|
### 2.3 반도체 공정 관련 주의사항
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 금속 불순물 | 각 원소 < 1 ppb (Electronic Grade) |
|
||||||
|
| 수분 함량 | < 50 ppm |
|
||||||
|
| 여과 | 사용 전 0.2 μm 필터 여과 권장 |
|
||||||
|
| 포장 | N₂ 퍼징 밀봉 용기 사용 |
|
||||||
|
|
||||||
|
### 2.4 유해·위험성 (PMA와 동일)
|
||||||
|
|
||||||
|
GHS 분류, 응급조치, 취급·저장, 노출 기준 모두 PMA 항목(1.3 ~ 1.7) 동일 적용.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. EL (Ethyl Lactate)
|
||||||
|
|
||||||
|
### 3.1 기본 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 화학명 | Ethyl (S)-(+)-Lactate (또는 Ethyl 2-hydroxypropanoate) |
|
||||||
|
| CAS No. | 97-64-3 |
|
||||||
|
| 분자식 | C₅H₁₀O₃ |
|
||||||
|
| 분자량 | 118.13 g/mol |
|
||||||
|
| 외관 | 무색 투명 액체 |
|
||||||
|
| 냄새 | 약한 과일향 |
|
||||||
|
| 주요 용도 | 포토레지스트 용제, 세정제, 식품 향료 |
|
||||||
|
|
||||||
|
### 3.2 물리·화학적 특성
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| 비점 | 154 °C |
|
||||||
|
| 융점 | −25 °C |
|
||||||
|
| 인화점 | 53 °C (밀폐컵) |
|
||||||
|
| 발화점 | 400 °C |
|
||||||
|
| 증기압 | 1.5 mmHg @ 20 °C |
|
||||||
|
| 증기밀도 | 4.1 (공기 = 1) |
|
||||||
|
| 비중 | 1.031 g/mL @ 20 °C |
|
||||||
|
| 수용해도 | 혼화성 (임의 비율) |
|
||||||
|
| 폭발한계 | 1.5 – 8.5 vol% |
|
||||||
|
|
||||||
|
### 3.3 유해·위험성
|
||||||
|
|
||||||
|
| 구분 | GHS 분류 |
|
||||||
|
|------|----------|
|
||||||
|
| 인화성 액체 | GHS02 — 구분 3 |
|
||||||
|
| 눈 자극 | GHS07 — 구분 2 |
|
||||||
|
|
||||||
|
- 비교적 낮은 독성 (생분해성 우수, 친환경 용제로 분류)
|
||||||
|
- 고농도 증기 장시간 흡입 시 두통, 어지러움
|
||||||
|
- 눈·피부 경도 자극
|
||||||
|
|
||||||
|
> ✅ EL은 FDA 승인 식품 첨가물(GRAS)이며, PGMEA 대비 독성·환경 부담이 낮아 그린 용제로 주목받습니다.
|
||||||
|
|
||||||
|
### 3.4 응급조치
|
||||||
|
|
||||||
|
| 노출 경로 | 조치 |
|
||||||
|
|-----------|------|
|
||||||
|
| 흡입 | 신선한 공기로 이동, 증상 지속 시 의사 진찰 |
|
||||||
|
| 피부 | 다량의 물과 비누로 세척 |
|
||||||
|
| 눈 | 흐르는 물로 15분 이상 세안 |
|
||||||
|
| 섭취 | 입 헹굼, 다량의 물 음용, 의사 진찰 |
|
||||||
|
|
||||||
|
### 3.5 취급·저장
|
||||||
|
|
||||||
|
- 점화원으로부터 격리, 통풍이 양호한 장소 저장
|
||||||
|
- 서늘하고 건조한 환경 (권장 보관 온도: 15 ~ 25 °C)
|
||||||
|
- 강산화제, 강알칼리와 격리
|
||||||
|
- 흡습성 있으므로 밀봉 보관
|
||||||
|
|
||||||
|
### 3.6 누출 대응
|
||||||
|
|
||||||
|
- 점화원 제거 후 환기
|
||||||
|
- 흡수제 또는 모래로 회수
|
||||||
|
- 물에 희석 가능하나 대량 유출 시 수계 오염 방지
|
||||||
|
|
||||||
|
### 3.7 노출 기준 및 보호구
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| ACGIH TLV | 설정 없음 (독성 낮음) |
|
||||||
|
| 호흡 보호구 | 유기증기 방독마스크 (고농도 작업 시) |
|
||||||
|
| 눈 보호구 | 화학용 보안경 |
|
||||||
|
| 피부 보호구 | 내화학성 장갑 (니트릴) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 3종 비교 요약
|
||||||
|
|
||||||
|
| 항목 | PMA / PGMEA | EL |
|
||||||
|
|------|-------------|-----|
|
||||||
|
| CAS No. | 108-65-6 | 97-64-3 |
|
||||||
|
| 비점 | 146 °C | 154 °C |
|
||||||
|
| 인화점 | 43 °C | 53 °C |
|
||||||
|
| 비중 | 0.966 | 1.031 |
|
||||||
|
| 수용해도 | 부분 용해 | 혼화성 |
|
||||||
|
| 증기압 (20 °C) | 3.7 mmHg | 1.5 mmHg |
|
||||||
|
| GHS 인화성 | 구분 3 | 구분 3 |
|
||||||
|
| 생식독성 우려 | 있음 (동물) | 없음 |
|
||||||
|
| 친환경성 | 보통 | 우수 (생분해성) |
|
||||||
|
| 반도체 용도 | PR 용제, 현상 | PR 용제, 세정 |
|
||||||
|
| TLV-TWA | 50 ppm | 미설정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 공통 비상 연락처
|
||||||
|
|
||||||
|
| 기관 | 연락처 |
|
||||||
|
|------|--------|
|
||||||
|
| 화학물질안전원 (NICS) | 043-830-4000 |
|
||||||
|
| 119 (화재·응급) | 119 |
|
||||||
|
| 중독정보센터 | 1899-2252 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*작성 기준: GHS Rev.9, ACGIH 2023, 각 제조사 공개 SDS 기반 요약*
|
||||||
|
*최종 검토일: 2026-06-08*
|
||||||
414
docs/diagnosis-signal-tag-pv-missing.md
Normal file
414
docs/diagnosis-signal-tag-pv-missing.md
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
# 진단 보고서 — 신호태그 .PV 누락으로 인한 history_table 분절
|
||||||
|
|
||||||
|
**진단일**: 2026-06-08
|
||||||
|
**발생일**: 2026-06-03 (commit 7409fab 컬럼명칭 통일) 이후 신규 데이터부터
|
||||||
|
**영향**: `history_table` 1억 7,900만 행 중 5,180만 행 (19GB) 이력 분절
|
||||||
|
**등급**: 🔴 HIGH — 데이터 무결성 결함
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 1 — 맥락 파악
|
||||||
|
|
||||||
|
**질문: 이 문제는 무엇을 하는 시스템의 문제인가?**
|
||||||
|
|
||||||
|
HC900 프로세스 컨트롤러 → Modbus TCP → C++ 게이트웨이 → gRPC → C# 크롤러 → PostgreSQL
|
||||||
|
|
||||||
|
```
|
||||||
|
HC900 ──Modbus TCP──▶ C++ Gateway ──gRPC──▶ C# Hc900Crawler ──▶ PostgreSQL (hc900.history_table)
|
||||||
|
```
|
||||||
|
|
||||||
|
세 가지 Python 스크립트가 태그명 생성의 근원:
|
||||||
|
|
||||||
|
| 스크립트 | 역할 | 파일 |
|
||||||
|
|---|---|---|
|
||||||
|
| `build_register_map.py` | HC Designer CSV → register-map.json | `scripts/build_register_map.py` |
|
||||||
|
| `load_map_master.py` | register-map.json → `hc900_map_master` 테이블 | `scripts/load_map_master.py` |
|
||||||
|
| `Hc900RealtimeService.cs` | 게이트웨이 폴링 → `realtime_table` → `history_table` | `src/Infrastructure/Hc900/Hc900RealtimeService.cs` |
|
||||||
|
|
||||||
|
> 이 단계를 건너뛰면 "의도적 설계"를 "버그"로 오인한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 2 — 구조 탐색
|
||||||
|
|
||||||
|
**의존 파일 및 데이터 흐름:**
|
||||||
|
|
||||||
|
```
|
||||||
|
docs/SignalTags.csv ← HC Designer export (원본)
|
||||||
|
docs/SummaryFucntionBlockReport.csv ← 루프 정의 (원본)
|
||||||
|
docs/Variables.csv ← 변수 정의 (원본)
|
||||||
|
│
|
||||||
|
▼ build_register_map.py
|
||||||
|
docs/register-map.json ← 게이트웨이 레지스터 맵
|
||||||
|
│
|
||||||
|
▼ load_map_master.py
|
||||||
|
PostgreSQL hc900_map_master ← DB 매핑 테이블
|
||||||
|
│
|
||||||
|
▼ Hc900RealtimeService (C#)
|
||||||
|
PostgreSQL realtime_table → history_table
|
||||||
|
```
|
||||||
|
|
||||||
|
**핵심 발견:** `build_register_map.py`는 루프와 신호태그를 **서로 다른 로직**으로 처리한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 3 — 코드 읽기
|
||||||
|
|
||||||
|
### `build_register_map.py` — 루프 처리부 (line 153-168)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# line 159: 루프는 Experion point name을 재구성
|
||||||
|
tag_name = f"{loop['tag']}.{name}"
|
||||||
|
# 결과: "FICQ-6101.PV", "FICQ-6101.SP", "FICQ-6101.OP" ← Experion ItemName과 일치 ✓
|
||||||
|
```
|
||||||
|
|
||||||
|
`LOOP_PARAM_OFFSETS`에서 `.PV`, `.SP`, `.OP` 등을 붙여 Experion 시스템의 point name 형식을 재구성한다.
|
||||||
|
|
||||||
|
### `build_register_map.py` — 신호태그 처리부 (line 171-180)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# line 171-180: 신호태그는 CSV raw tag를 그대로 사용
|
||||||
|
for sig in signals:
|
||||||
|
registers.append({
|
||||||
|
"tag": sig["tag"], ← 문제: CSV row[2] 그대로, .PV suffix 없음
|
||||||
|
"addr": sig["addr"],
|
||||||
|
...
|
||||||
|
})
|
||||||
|
# 결과: "TI-6111B" ← Experion의 "TI-6111B.PV"와 불일치 ✗
|
||||||
|
```
|
||||||
|
|
||||||
|
**근거:** `parse_signal_or_variable_csv()` (line 102-140)에서 `tag = row[2].strip()` — HC Designer CSV의 3번째 컬럼을 그대로 가져온다. HC Designer export에는 `.PV` suffix가 붙어있지 않다.
|
||||||
|
|
||||||
|
### `load_map_master.py` — DB 로딩 (line 59-65)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# line 64: tagname == hc900_tag == e["tag"]
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO hc900_map_master (tagname, hc900_tag, ...) VALUES (%s, %s, ...)",
|
||||||
|
(e["tag"], e["tag"], ...))
|
||||||
|
```
|
||||||
|
|
||||||
|
register-map의 `tag` 필드를 `tagname`과 `hc900_tag`에 **동일하게** 저장. register-map tag가 `.PV` 붙으면 자동으로 따라옴. 수정 불필요.
|
||||||
|
|
||||||
|
### `Hc900RealtimeService.cs` — 데이터 기록 (line 203, 227-231)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// line 203: 게이트웨이 응답을 tagLookup으로 매칭
|
||||||
|
if (tagLookup.TryGetValue(tv.TagName, out var tagnames))
|
||||||
|
{
|
||||||
|
foreach (var tagname in tagnames)
|
||||||
|
{
|
||||||
|
rows.Add((tagname, livevalue, ts)); // ← tagname이 DB에 기록됨
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// line 227-231: realtime_table INSERT
|
||||||
|
INSERT INTO realtime_table (controller_id, tagname, ..., livevalue, timestamp)
|
||||||
|
VALUES ($1, $2, ..., $3, $4)
|
||||||
|
ON CONFLICT (controller_id, tagname) DO UPDATE ...
|
||||||
|
```
|
||||||
|
|
||||||
|
전구간 tag name 변형 없음. register-map tag가 그대로 `realtime_table` → `history_table`에 기록된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 4 — 호출 계층 지도
|
||||||
|
|
||||||
|
```
|
||||||
|
HC Designer CSV (SignalTags.csv)
|
||||||
|
│ row[2] = "TI-6111B" ← .PV 없음
|
||||||
|
▼
|
||||||
|
build_register_map.py:131 tag = row[2].strip()
|
||||||
|
│ line 159 (루프): f"{loop['tag']}.{name}" → "FICQ-6101.PV" ✓
|
||||||
|
│ line 171 (신호): sig["tag"] → "TI-6111B" ✗
|
||||||
|
▼
|
||||||
|
register-map.json: "tag": "TI-6111B"
|
||||||
|
▼
|
||||||
|
load_map_master.py:64 tagname == hc900_tag == "TI-6111B"
|
||||||
|
▼
|
||||||
|
C++ gateway (gateway.cpp:80, 169, 189) ← tag field 그대로 전달, 변형 없음
|
||||||
|
▼
|
||||||
|
C# Hc900RealtimeService (line 203, 227) ← tagname 그대로 realtime_table INSERT
|
||||||
|
▼
|
||||||
|
history_table (SnapshotToHistoryAsync) ← realtime_table에서 복사
|
||||||
|
```
|
||||||
|
|
||||||
|
**결론:** tag name 결정은 `build_register_map.py` line 171에서 한 번에 발생. 이후 전구간 무변형 전달.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 5 — 패턴 매칭 (체크리스트 순회)
|
||||||
|
|
||||||
|
### 🔴 런타임 즉시 실패
|
||||||
|
|
||||||
|
| 체크 | 항목 | 판단 |
|
||||||
|
|------|------|------|
|
||||||
|
| [x] | **데이터 불일치** | Experion dump 원본(`TI-6111B.PV`) vs 우리 추출(`TI-6111B`) |
|
||||||
|
| [ ] | 미정의 변수·함수 참조 | 해당 없음 |
|
||||||
|
| [ ] | 잘못된 타입 | 해당 없음 |
|
||||||
|
|
||||||
|
### 🟠 데이터 무결성
|
||||||
|
|
||||||
|
| 체크 | 항목 | 판단 |
|
||||||
|
|------|------|------|
|
||||||
|
| [x] | **이력 분절** | 같은 물리 신호가 두 이름으로 `history_table`에 분산 저장 |
|
||||||
|
| [x] | **과거 데이터 권위** | Experion dump(`.PV` 포함)가 현장 권위 원본 |
|
||||||
|
| [ ] | Race Condition | 해당 없음 |
|
||||||
|
|
||||||
|
### 🟡 코드 구조
|
||||||
|
|
||||||
|
| 체크 | 항목 | 판단 |
|
||||||
|
|------|------|------|
|
||||||
|
| [x] | **처리 로직 불일치** | 루프는 Experion point name 재구성, 신호태그는 raw 사용 |
|
||||||
|
| [ ] | 설정 하드코딩 | 해당 없음 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 6 — 교차 검증
|
||||||
|
|
||||||
|
STEP 5에서 발견한 각 항목에 대해 4개 질문 모두 확인:
|
||||||
|
|
||||||
|
### 항목 1: 신호태그 .PV 누락 (HIGH)
|
||||||
|
|
||||||
|
| 질문 | 확인 | 결과 |
|
||||||
|
|------|------|------|
|
||||||
|
| Q1. 이미 수정된 문제인가? | `build_register_map.py` 현재 상태 grep | **아니오** — line 171 여전히 `sig["tag"]` |
|
||||||
|
| Q2. 다른 레이어에서 처리되고 있는가? | C++ gateway, C# RealtimeService grep | **아니오** — 전구간 무변형 전달 |
|
||||||
|
| Q3. 의도적 설계인가? | 문서·주석 확인 | **아니오** — 루프는 `.PV` 붙이는데 신호는 안 붙이는 이유 없음 |
|
||||||
|
| Q4. 실제 장애 시나리오가 있는가? | `history_table`에서 `TI-6111B.PV` vs `TI-6111B` 분산 | **예** — 5,180만 행 분절, 시계열 조회 시 끊김 |
|
||||||
|
|
||||||
|
**→ Q1~Q4 통과. 보고서 포함.**
|
||||||
|
|
||||||
|
### 항목 2: 이력 분절 (HIGH)
|
||||||
|
|
||||||
|
| 질문 | 확인 | 결과 |
|
||||||
|
|------|------|------|
|
||||||
|
| Q1. 이미 수정된 문제인가? | `history_table` 행 수 확인 | **아니오** — 5,180만 행 여전히 옛 이름으로 존재 |
|
||||||
|
| Q2. 다른 레이어에서 처리되고 있는가? | rename 훅, 데이터 이관 로직 grep | **아니오** — tagname 변경 시 이관 코드 없음 |
|
||||||
|
| Q3. 의도적 설계인가? | 아키텍처 문서 확인 | **아니오** — 단일 신호가 두 이름으로 저장되는 것은 의도 아님 |
|
||||||
|
| Q4. 실제 장애 시나리오가 있는가? | 시계열 조회 시 두 이름 모두 검색 필요 | **예** — 운영상 실수 유발 |
|
||||||
|
|
||||||
|
**→ Q1~Q4 통과. 보고서 포함.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 7 — 심각도 분류
|
||||||
|
|
||||||
|
| 등급 | 항목 | 기준 |
|
||||||
|
|------|------|------|
|
||||||
|
| 🔴 HIGH | 신호태그 .PV 누락 | 데이터 무결성 결함. Experion dump 원본과 불일치. 재현 가능 |
|
||||||
|
| 🔴 HIGH | history_table 이력 분절 | 5,180만 행 분산. 시계열 조회 시 끊김. 데이터 손실과 동등 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 8 — 진단 결과 및 수정 계획
|
||||||
|
|
||||||
|
### [1]. 신호태그 .PV 누락 (HIGH)
|
||||||
|
|
||||||
|
**문제**: `build_register_map.py`가 신호태그(CSV row[2])를 Experion point name 규칙(`TAGNAME.PV`) 없이 그대로 register-map에 기록. 루프는 `.PV` 등을 붙이는데 신호태그만 누락.
|
||||||
|
|
||||||
|
**근거**: `scripts/build_register_map.py:171` — `"tag": sig["tag"]` (CSV raw tag, `.PV` suffix 없음)
|
||||||
|
|
||||||
|
**영향**: Experion dump 원본(`TI-6111B.PV`)과 불일치 → `history_table`에 같은 물리 신호가 두 이름으로 분산 저장. 5,180만 행 분절.
|
||||||
|
|
||||||
|
**수정**: `build_register_map.py` line 171 부근에서 신호태그에 `.PV` suffix 추가.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 수정 전 (line 171-180):
|
||||||
|
for sig in signals:
|
||||||
|
registers.append({
|
||||||
|
"tag": sig["tag"], # "TI-6111B"
|
||||||
|
...
|
||||||
|
})
|
||||||
|
|
||||||
|
# 수정 후:
|
||||||
|
for sig in signals:
|
||||||
|
# Experion point naming convention: TI-6111B → TI-6111B.PV
|
||||||
|
experion_tag = sig["tag"] + ".PV"
|
||||||
|
registers.append({
|
||||||
|
"tag": experion_tag, # "TI-6111B.PV"
|
||||||
|
...
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**변수태그 확인 필요**: `Variables.csv`의 tag name이 Experion에서 어떻게 매핑되는지 확인. 변수는 R/W 태그로 HC900 내부 변수이므로 `.PV` 규칙이 적용되는지 별도 검증 필요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [2]. history_table 이력 분절 (HIGH)
|
||||||
|
|
||||||
|
**문제**: tagname 변경 시 과거 데이터 이관 훅이 없음. 6/3 이후 신규 데이터가 옛 이름(`.PV` 없음)으로 쌓이면서 같은 물리 신호가 두 이름으로 분절.
|
||||||
|
|
||||||
|
**근거**: `Hc900RealtimeService.cs` — `realtime_table` → `history_table` 복사 시 tagname 변환 로직 없음. `Hc900DbContext.cs` SnapshotToHistoryAsync도 tagname 그대로 복사.
|
||||||
|
|
||||||
|
**영향**: 시계열 조회 시 두 이름 모두 검색 필요. 운영상 실수 유발.
|
||||||
|
|
||||||
|
**수정**: 단계별 진행 필요 (아래 STEP 9~11 참조).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 9 — 수정 실행 계획 (완료 서명)
|
||||||
|
|
||||||
|
### 단계 1: `build_register_map.py` 수정
|
||||||
|
|
||||||
|
- [ ] 신호태그 처리부(line 171)에 `.PV` suffix 추가
|
||||||
|
- [ ] 변수태그 처리부(line 183)도 Experion point name 규칙 적용 여부 확인
|
||||||
|
- [ ] 수정 후 register-map 재생성 테스트
|
||||||
|
|
||||||
|
**수정자**: ____________________
|
||||||
|
**작성일**: ____/____/____
|
||||||
|
**검토자**: ____________________
|
||||||
|
**검토일**: ____/____/____
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 단계 2: register-map 재생성 및 DB 반영
|
||||||
|
|
||||||
|
- [ ] 수정된 `build_register_map.py`로 register-map 재생성
|
||||||
|
- [ ] `load_map_master.py`로 `hc900_map_master` 갱신
|
||||||
|
- [ ] `hc900_map_master`에서 신호태그 tagname에 `.PV`가 붙었는지 확인
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 확인 쿼리
|
||||||
|
SELECT tagname, hc900_tag FROM hc900_map_master
|
||||||
|
WHERE tagname ~ '^[TFDLI]P?[A-Z]?-\d{4,5}$'
|
||||||
|
AND tagname NOT LIKE '%.%'
|
||||||
|
LIMIT 20;
|
||||||
|
-- 결과가 없어야 정상 (모든 신호태그에 .PV 붙음)
|
||||||
|
```
|
||||||
|
|
||||||
|
**실행자**: ____________________
|
||||||
|
**실행일**: ____/____/____
|
||||||
|
**검토자**: ____________________
|
||||||
|
**검토일**: ____/____/____
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 단계 3: history_table 과거 데이터 정합화
|
||||||
|
|
||||||
|
- [ ] `history_table`에서 옛 이름(`.PV` 없음) 행 수 확인
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 옛 이름 잔존 행 수 확인
|
||||||
|
SELECT COUNT(*) FROM hc900.history_table
|
||||||
|
WHERE tagname ~ '^[TFDLI]P?[A-Z]?-\d{4,5}$'
|
||||||
|
AND tagname NOT LIKE '%.%'
|
||||||
|
AND tagname NOT IN (
|
||||||
|
-- 변수태그 등 .PV 없이 정상인 태그 제외
|
||||||
|
SELECT tagname FROM hc900.map_master WHERE ...
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] 옛 이름 → 새 이름(.PV 붙임) UPDATE 실행
|
||||||
|
- [ ] UPDATE 후 행 수 확인 (분절 없이 단일 이름으로 통합)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- UPDATE 예시 (본격 실행 전 반드시 트랜잭션으로 테스트)
|
||||||
|
BEGIN;
|
||||||
|
UPDATE hc900.history_table
|
||||||
|
SET tagname = tagname || '.PV'
|
||||||
|
WHERE tagname = 'TI-6111B';
|
||||||
|
-- 확인: SELECT COUNT(*) WHERE tagname = 'TI-6111B.PV'
|
||||||
|
COMMIT;
|
||||||
|
```
|
||||||
|
|
||||||
|
**실행자**: ____________________
|
||||||
|
**실행일**: ____/____/____
|
||||||
|
**검토자**: ____________________
|
||||||
|
**검토일**: ____/____/____
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 단계 4: 재발 방지 설계
|
||||||
|
|
||||||
|
- [ ] `hc900_map_master.tagname` 변경 시 `history_table` 이관을 강제하는 절차 문서화
|
||||||
|
- [ ] 또는: 장기 저장 식별자를 tagname이 아닌 안정 키(Modbus addr / hc900_tag)로 변경 검토
|
||||||
|
- [ ] display name은 read 시점에 `map_master`로 해석하는 아키텍처로 전환 검토
|
||||||
|
|
||||||
|
**설계자**: ____________________
|
||||||
|
**작성일**: ____/____/____
|
||||||
|
**검토자**: ____________________
|
||||||
|
**검토일**: ____/____/____
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록: 데이터 흐름 전체 도식
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ HC Designer CSV Export (현장 dump) │
|
||||||
|
│ SignalTags.csv: row[2] = "TI-6111B" ← .PV 없음 │
|
||||||
|
│ SummaryFucntionBlockReport.csv: row[2] = "FICQ-6101" │
|
||||||
|
└──────────────────────────┬──────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ build_register_map.py │
|
||||||
|
│ │
|
||||||
|
│ 루프 (line 159): │
|
||||||
|
│ tag_name = f"{loop['tag']}.{name}" │
|
||||||
|
│ → "FICQ-6101.PV" ✓ Experion ItemName과 일치 │
|
||||||
|
│ │
|
||||||
|
│ 신호태그 (line 171) ← 문제: │
|
||||||
|
│ tag = sig["tag"] ← CSV row[2] 그대로 │
|
||||||
|
│ → "TI-6111B" ✗ Experion의 "TI-6111B.PV"와 불일치 │
|
||||||
|
│ │
|
||||||
|
│ 변수 (line 183): │
|
||||||
|
│ tag = var["tag"] ← CSV row[2] 그대로 │
|
||||||
|
│ → "VAR-xxxx" (Experion 매핑 확인 필요) │
|
||||||
|
└──────────────────────────┬──────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ register-map.json │
|
||||||
|
│ {"tag": "TI-6111B"} ← .PV 없음 │
|
||||||
|
└──────────────────────────┬──────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ load_map_master.py (line 64) │
|
||||||
|
│ tagname == hc900_tag == e["tag"] │
|
||||||
|
│ → hc900_map_master: tagname="TI-6111B", hc900_tag="TI-6111B" │
|
||||||
|
│ (register-map tag를 그대로 복사. register-map 수정하면 자동 따라옴) │
|
||||||
|
└──────────────────────────┬──────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ C++ Gateway (gateway.cpp) │
|
||||||
|
│ line 80: e.tag = item["tag"] ← register-map tag 그대로 저장 │
|
||||||
|
│ line 169: fresh[entry.tag] = cv ← cache key로 사용 │
|
||||||
|
│ line 189: tv->set_tag_name(name) ← gRPC 응답에 그대로 포함 │
|
||||||
|
│ (변형 없음, register-map tag가 100% tag name이 됨) │
|
||||||
|
└──────────────────────────┬──────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ C# Hc900RealtimeService │
|
||||||
|
│ line 203: tagLookup.TryGetValue(tv.TagName, ...) ← 매칭 │
|
||||||
|
│ line 227: INSERT INTO realtime_table (tagname, ...) │
|
||||||
|
│ → tagname = map_master의 tagname = register-map의 tag │
|
||||||
|
│ (변형 없음) │
|
||||||
|
└──────────────────────────┬──────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ PostgreSQL hc900 │
|
||||||
|
│ │
|
||||||
|
│ realtime_table: tagname="TI-6111B" ← 신규 데이터 │
|
||||||
|
│ history_table: tagname="TI-6111B.PV" ← Experion dump 원본 (5,180만 행) │
|
||||||
|
│ : tagname="TI-6111B" ← 신규 데이터 (분절) │
|
||||||
|
│ │
|
||||||
|
│ → 같은 물리 신호가 두 이름으로 분산 │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 부록: Experion Point Name 규칙
|
||||||
|
|
||||||
|
| 태그 종류 | HC Designer CSV tag | Experion point name | register-map tag | 상태 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 루프 (PID) | `FICQ-6101` | `FICQ-6101.PV` | `FICQ-6101.PV` | ✓ 일치 |
|
||||||
|
| 신호태그 | `TI-6111B` | `TI-6111B.PV` | `TI-6111B` | ✗ 불일치 |
|
||||||
|
| 신호태그 | `FI-6102` | `FI-6102.PV` | `FI-6102` | ✗ 불일치 |
|
||||||
|
| 변수 | `VAR-xxxx` | 확인 필요 | `VAR-xxxx` | ? 미확인 |
|
||||||
|
|
||||||
|
**Experion 규칙**: 모든 신호태그(Tag Type = TI/FI/LI/PI/DI/AI/AO 등)는 `.PV` suffix를 붙여 point name을 생성한다.
|
||||||
497
docs/diagnosis-steam-flow-display.md
Normal file
497
docs/diagnosis-steam-flow-display.md
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
# 진단 체크리스트 — 온도 프로파일 메타 영역을 표 형식으로 재구성 + 유량 표시
|
||||||
|
|
||||||
|
> **규칙**: diagnosis-checklist.md의 8단계를 준수. STEP 3(코드 읽기)과 STEP 6(교차 검증)을 반드시 거침.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 요구사항 요약
|
||||||
|
|
||||||
|
**현재**: 온도 프로파일 탭(`st-temp`)에서 차트 아래 `st-temp-meta`에 단별 온도/진공을 텍스트로 표시.
|
||||||
|
|
||||||
|
**목표**: `st-temp-meta`를 **두 개의 표(table)**로 재구성:
|
||||||
|
|
||||||
|
**왼쪽 표** — 온도 (기존 텍스트 → 표 형식):
|
||||||
|
```
|
||||||
|
┌──────────────┬────────┬──────────────┐
|
||||||
|
│ 항목 │ 현재값 │ 기준 │
|
||||||
|
├──────────────┼────────┼──────────────┤
|
||||||
|
│ eb-A(보텀) │ 85.6℃ │ 85.1±2.3 │
|
||||||
|
│ T_B │ 84.0℃ │ 83.3±2.1 │
|
||||||
|
│ T_C(민감단) │ 77.2℃ │ 77.0±1.4 │
|
||||||
|
│ T_D(탑) │ 74.2℃ │ 74.2±1.6 │
|
||||||
|
│ ΔT(A-D) │ 11.4℃ │ — │
|
||||||
|
│ 진공 │ 39.7 │ 40.1±3.8 │
|
||||||
|
└──────────────┴────────┴──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**오른쪽 표** — 유량 (신규):
|
||||||
|
```
|
||||||
|
┌────────┬────────┬────────┬──────────┬──────────┬──────────┐
|
||||||
|
│ │ FEED │ REFLUX │ 제품추출 │ 경비물 │ 중비물 │
|
||||||
|
│ │FICQ-610│ FICQ-6 │ FICQ-611 │ FICQ-611 │ FICQ-611 │
|
||||||
|
│ 태그명 │ 1.PV │ 13.PV │ 8.PV │ 4.PV │ 6.PV │
|
||||||
|
├────────┼────────┼────────┼──────────┼──────────┼──────────┤
|
||||||
|
│ PV │ 896.4 │ 652.4 │ 816.4 │ 14.4 │ 23.4 │
|
||||||
|
│ SP │ 900 │ 656 │ 820 │ 18.0 │ 27.0 │
|
||||||
|
│ OP │ 53.5 │ 64.5 │ 43.9 │ 33.7 │ 20.8 │
|
||||||
|
└────────┴────────┴────────┴──────────┴──────────┴──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
실시간/과거 스냅샷 모두 동일 형식.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 1 — 맥락 파악
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 파일 | `src/Hc900Crawler/wwwroot/js/steam.js` (프론트엔드), `SteamAdvisorController.cs` (백엔드) |
|
||||||
|
| 역할 | Steam Advisory 대시보드 — 온도 프로파일 이격 모니터 |
|
||||||
|
| 아키텍처 | 프론트 → API → C# 백엔드가 `realtime_table` + `tempref.json` 처리 |
|
||||||
|
| 관련 DB | Experion `realtime_table` (v_tag_summary 기반) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 2 — 구조 탐색
|
||||||
|
|
||||||
|
```
|
||||||
|
src/Hc900Crawler/
|
||||||
|
├── wwwroot/
|
||||||
|
│ ├── js/steam.js ← stRenderTemp(), stTempScrub()
|
||||||
|
│ └── panes/steam.html ← st-temp-meta DOM
|
||||||
|
├── Controllers/
|
||||||
|
│ └── SteamAdvisorController.cs ← TempProfile, TempProfileHistory API
|
||||||
|
└── appsettings.json ← SteamAdvisor.Columns
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 3 — 코드 읽기 (핵심 부분만)
|
||||||
|
|
||||||
|
### 3.1 `steam.js` — `stRenderTemp(snap)` (line 216-286)
|
||||||
|
|
||||||
|
- **line 279-285**: `st-temp-meta` DOM에 온도 텍스트 렌더링
|
||||||
|
```js
|
||||||
|
const lines = snapSrc.stages.map(s =>
|
||||||
|
`${(ST_STAGE_LABEL[s.stage] || s.stage).padEnd(12)} ${stFmt(s.current)}℃ 기준 ...`);
|
||||||
|
if (snapSrc.spanAD != null) lines.push(`ΔT(A-D) ${stFmt(snapSrc.spanAD)}℃`);
|
||||||
|
if (snapSrc.vacuum) lines.push(`진공 ${stFmt(snapSrc.vacuum.current)} ...`);
|
||||||
|
meta.textContent = lines.join('\n');
|
||||||
|
```
|
||||||
|
- **현재 `textContent` 사용** → 표 형식 변경 시 `innerHTML`로 변경 필요
|
||||||
|
|
||||||
|
### 3.2 `SteamAdvisorController.cs` — `TempProfile` (line 183-206)
|
||||||
|
|
||||||
|
- `FetchRealtimeValues(col, tref)` → 5개 온도/진공 태그 조회
|
||||||
|
- `ComputeStages(cur, tref)` → 제품 매칭 + z-score 계산
|
||||||
|
- 반환: `stages`, `vacuum`, `spanAD`
|
||||||
|
|
||||||
|
### 3.3 `SteamAdvisorController.cs` — `TagsFor(p)` (line 352-371)
|
||||||
|
|
||||||
|
- 온도/진공 태그 매핑만 포함. **FICQ 태그 없음**
|
||||||
|
|
||||||
|
### 3.4 `SteamAdvisorController.cs` — `TempProfileHistory` (line 211-273)
|
||||||
|
|
||||||
|
- history_table 조회 → 각 스냅샷에 `stages`, `vacuum`, `spanAD` 포함
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 4 — 호출 계층 지도
|
||||||
|
|
||||||
|
```
|
||||||
|
[Browser] steam.js stTempLoad() / stTempScrub()
|
||||||
|
└─ GET /api/steam/tempprofile/{col} ← 실시간
|
||||||
|
└─ GET /api/steam/tempprofile/{col}/history ← 과거
|
||||||
|
└─ SteamAdvisorController.TempProfile() / TempProfileHistory()
|
||||||
|
├─ FetchRealtimeValues() → HC900 DB (온도 4 + 진공 1)
|
||||||
|
└─ ComputeStages() → stages + vacuum + spanAD
|
||||||
|
└─ _db.QueryHistoryWithIntervalAsync() → history_table
|
||||||
|
└─ ComputeStages() for each snapshot
|
||||||
|
|
||||||
|
[Browser] steam.js stRenderTemp(snap)
|
||||||
|
└─ stTempUpdateBadges()
|
||||||
|
└─ ECharts 렌더
|
||||||
|
└─ st-temp-meta.innerHTML = ... ← 표 형식 (변경 필요)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 5 — 패턴 매칭 (체크리스트 순회)
|
||||||
|
|
||||||
|
### 🔴 런타임 즉시 실패
|
||||||
|
| 체크 | 항목 | 판단 기준 |
|
||||||
|
|------|------|-----------|
|
||||||
|
| [ ] | `textContent` → `innerHTML` 변경 | XSS 위험 없음 (모든 값은 `stFmt()`로 포맷된 숫자) |
|
||||||
|
| [ ] | API 응답 필드 누락 | `flow` 필드가 백엔드/프론트에서 일관되게 사용되는가? |
|
||||||
|
| [ ] | 잘못된 타입 | `flow` 값이 null/NaN일 때 `stFmt()`가 올바르게 처리하는가? |
|
||||||
|
|
||||||
|
### 🟠 동시성 / 비동기
|
||||||
|
| 체크 | 항목 | 판단 기준 |
|
||||||
|
|------|------|-----------|
|
||||||
|
| [ ] | API 응답 구조 변경 | 기존 `stages`/`vacuum`/`spanAD` 필드 유지 (breaking change 아님) |
|
||||||
|
|
||||||
|
### 🟢 코드 구조
|
||||||
|
| 체크 | 항목 | 판단 기준 |
|
||||||
|
|------|------|-----------|
|
||||||
|
| [ ] | FICQ 태그명 하드코딩 | `TagsFor()`에서 동적 생성 (코드 중복 없음) |
|
||||||
|
| [ ] | 미사용 import·변수 | 신규 코드에 미사용 import 없음 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 6 — 교차 검증
|
||||||
|
|
||||||
|
| 질문 | 확인 방법 | 결과 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| Q1. 이미 수정된 문제인가? | `steam.js`에 표 형식 로직이 이미 있는가? | **아니오** — 현재 텍스트 기반 |
|
||||||
|
| Q2. 다른 레이어에서 처리되고 있는가? | Live 패널에 표 형식이 있는가? | **아니오** — Live 패널은 별도 레이아웃 |
|
||||||
|
| Q3. 의도적 설계인가? | 표 형식 재구성이 요구사항에 명시된 기능인가? | **예** — 사용자가 명시적으로 요청 |
|
||||||
|
| Q4. 실제 장애 시나리오가 있는가? | 표 형식이 없으면 운전원 판단에 영향이 있는가? | **예** — 유량 데이터 없으면 물질수지 판단 불가 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 7 — 심각도 분류
|
||||||
|
|
||||||
|
| 등급 | 항목 |
|
||||||
|
|------|------|
|
||||||
|
| 🟡 LOW | 신규 UI 표시 기능 — 기존 동작에 영향 없음 |
|
||||||
|
| 🟡 LOW | 백엔드 API에 신규 필드 추가 — 기존 필드 유지 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 8 — 실행 플랜
|
||||||
|
|
||||||
|
### 작업 1 — 백엔드: `TagsFor()`에 FICQ 태그 추가
|
||||||
|
|
||||||
|
**파일**: `SteamAdvisorController.cs:352-371`
|
||||||
|
|
||||||
|
**변경 내용**: `TagsFor(p)`에 FICQ 5개 태그 매핑 추가
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private static Dictionary<string, string> TagsFor(string p)
|
||||||
|
{
|
||||||
|
var m = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["reb_temp"] = $"TICA-{p}A.PV",
|
||||||
|
["T_B"] = $"TI-{p}B.PV",
|
||||||
|
["T_C"] = $"TI-{p}C.PV",
|
||||||
|
["T_D"] = $"TI-{p}D.PV",
|
||||||
|
["vacuum"] = $"PICA-{p}.PV",
|
||||||
|
// 추가:
|
||||||
|
["feed"] = $"FICQ-{p}01.PV",
|
||||||
|
["reflux"] = $"FICQ-{p}13.PV",
|
||||||
|
["overhead"] = $"FICQ-{p}14.PV",
|
||||||
|
["bottom"] = $"FICQ-{p}16.PV",
|
||||||
|
["product"] = $"FICQ-{p}18.PV",
|
||||||
|
};
|
||||||
|
// ... 기존 switch (8111, 9111 등) 유지
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**교차 검증**: `v_tag_summary`에서 `ficq-6101`, `ficq-6113`, `ficq-6114`, `ficq-6116`, `ficq-6118`이 존재하고 PV 값을 가짐 확인 완료.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 작업 2 — 백엔드: `FetchRealtimeValues()` 변경 없음
|
||||||
|
|
||||||
|
`TagsFor()`가 모든 태그(온도 + 유량)를 반환하므로 `FetchRealtimeValues()`는 자동 반영. **변경 없음**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 작업 3 — 백엔드: `TempProfile` API 응답에 flow + metadata 필드 추가
|
||||||
|
|
||||||
|
**파일**: `SteamAdvisorController.cs:193-205`
|
||||||
|
|
||||||
|
**변경 내용**: 반환 객체에 `flow` + `flowTags` 필드 추가
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
column = tref.Column,
|
||||||
|
period = tref.Period,
|
||||||
|
matchedProduct = prod?.Label,
|
||||||
|
nProducts = tref.NProducts,
|
||||||
|
stages,
|
||||||
|
vacuum,
|
||||||
|
spanAD,
|
||||||
|
spanRef = prod?.SpanAD,
|
||||||
|
products = tref.Products,
|
||||||
|
timestamp = DateTime.UtcNow,
|
||||||
|
// 추가:
|
||||||
|
flow = new {
|
||||||
|
feed = cur.GetValueOrDefault("feed"),
|
||||||
|
reflux = cur.GetValueOrDefault("reflux"),
|
||||||
|
overhead = cur.GetValueOrDefault("overhead"),
|
||||||
|
bottom = cur.GetValueOrDefault("bottom"),
|
||||||
|
product = cur.GetValueOrDefault("product"),
|
||||||
|
},
|
||||||
|
flowTags = new {
|
||||||
|
feed = $"FICQ-{ToSuffix(col)}01.PV",
|
||||||
|
reflux = $"FICQ-{ToSuffix(col)}13.PV",
|
||||||
|
overhead = $"FICQ-{ToSuffix(col)}14.PV",
|
||||||
|
bottom = $"FICQ-{ToSuffix(col)}16.PV",
|
||||||
|
product = $"FICQ-{ToSuffix(col)}18.PV",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**동일 변경**: `TempProfileHistory` (line 247-254)에서도 각 스냅샷에 `flow` + `flowTags` 추가.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 작업 4 — 백엔드: `ComputeStages()`에서 온도/진공 값 구조화
|
||||||
|
|
||||||
|
**파일**: `SteamAdvisorController.cs:308-348`
|
||||||
|
|
||||||
|
**변경 내용**: `ComputeStages()` 반환값에 온도 현재값/기준값을 표 형식으로 렌더링할 수 있도록 구조화
|
||||||
|
|
||||||
|
현재 `stages`는 `{ stage, current, refMedian, refStd, z, deviated }` 객체 배열.
|
||||||
|
`vacuum`은 `{ current, refMedian, refStd, z, deviated }` 객체.
|
||||||
|
|
||||||
|
이 구조를 그대로 사용하되, 프론트엔드에서 표 형식으로 렌더링할 때:
|
||||||
|
- `stages[].current` → "현재값" 열
|
||||||
|
- `stages[].refMedian ± stages[].refStd` → "기준" 열
|
||||||
|
- `vacuum.current` → "현재값" 열
|
||||||
|
- `vacuum.refMedian ± vacuum.refStd` → "기준" 열
|
||||||
|
|
||||||
|
**백엔드 변경 없음**. 프론트엔드에서 기존 구조로 표 렌더링.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 작업 5 — 프론트엔드: `stRenderTemp()`를 표 형식으로 재작성
|
||||||
|
|
||||||
|
**파일**: `steam.js:216-286`
|
||||||
|
|
||||||
|
**변경 내용**: `textContent` → `innerHTML`, 두 개의 `<table>` 생성
|
||||||
|
|
||||||
|
```js
|
||||||
|
function stRenderTemp(snap) {
|
||||||
|
stTempUpdateBadges();
|
||||||
|
if (!stTempLive) {
|
||||||
|
const el = document.getElementById('st-chart-temp');
|
||||||
|
if (el) el.innerHTML = '<div style="padding:40px;text-align:center;color:#555">데이터 없음</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stRenderTempCol = document.getElementById('st-temp-col').value;
|
||||||
|
const stages = stTempLive.stages;
|
||||||
|
if (!stages || !stages.length) return;
|
||||||
|
|
||||||
|
// ... (ECharts 렌더링 기존 유지 — line 235-276) ...
|
||||||
|
|
||||||
|
// ── 메타 영역을 표 형식으로 렌더링 ──
|
||||||
|
const meta = document.getElementById('st-temp-meta');
|
||||||
|
meta.style.display = 'block';
|
||||||
|
|
||||||
|
const snapSrc = snap || stTempLive;
|
||||||
|
|
||||||
|
// 왼쪽 표: 온도
|
||||||
|
let tempRows = stages.map(s => {
|
||||||
|
const label = ST_STAGE_LABEL[s.stage] || s.stage;
|
||||||
|
const cur = stFmt(s.current) + '℃';
|
||||||
|
const ref = s.refMedian != null ? `${stFmt(s.refMedian)}±${stFmt(s.refStd)}` : '—';
|
||||||
|
return `<tr><td>${label}</td><td>${cur}</td><td>${ref}</td></tr>`;
|
||||||
|
});
|
||||||
|
// ΔT(A-D) 추가
|
||||||
|
if (snapSrc.spanAD != null) {
|
||||||
|
tempRows.push(`<tr><td>ΔT(A-D)</td><td>${stFmt(snapSrc.spanAD)}℃</td><td>—</td></tr>`);
|
||||||
|
}
|
||||||
|
// 진공 추가
|
||||||
|
if (snapSrc.vacuum) {
|
||||||
|
const v = snapSrc.vacuum;
|
||||||
|
const cur = stFmt(v.current);
|
||||||
|
const ref = v.refMedian != null ? `${stFmt(v.refMedian)}±${stFmt(v.refStd)}` : '—';
|
||||||
|
tempRows.push(`<tr><td>진공</td><td>${cur}</td><td>${ref}</td></tr>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 오른쪽 표: 유량
|
||||||
|
let flowHeaders, flowRows;
|
||||||
|
if (snapSrc.flow) {
|
||||||
|
const f = snapSrc.flow;
|
||||||
|
const ft = snapSrc.flowTags || {};
|
||||||
|
flowHeaders = [
|
||||||
|
'<th></th>',
|
||||||
|
`<th>FEED<br><small>${esc(ft.feed || 'FICQ-??01.PV')}</small></th>`,
|
||||||
|
`<th>REFLUX<br><small>${esc(ft.reflux || 'FICQ-??13.PV')}</small></th>`,
|
||||||
|
`<th>제품추출<br><small>${esc(ft.product || 'FICQ-??18.PV')}</small></th>`,
|
||||||
|
`<th>경비물<br><small>${esc(ft.overhead || 'FICQ-??14.PV')}</small></th>`,
|
||||||
|
`<th>중비물<br><small>${esc(ft.bottom || 'FICQ-??16.PV')}</small></th>`,
|
||||||
|
].join('');
|
||||||
|
flowRows = [
|
||||||
|
`<tr><td>PV</td><td>${stFmt(f.feed?.pv)}</td><td>${stFmt(f.reflux?.pv)}</td><td>${stFmt(f.product?.pv)}</td><td>${stFmt(f.overhead?.pv)}</td><td>${stFmt(f.bottom?.pv)}</td></tr>`,
|
||||||
|
`<tr><td>SP</td><td>${stFmt(f.feed?.sp)}</td><td>${stFmt(f.reflux?.sp)}</td><td>${stFmt(f.product?.sp)}</td><td>${stFmt(f.overhead?.sp)}</td><td>${stFmt(f.bottom?.sp)}</td></tr>`,
|
||||||
|
`<tr><td>OP</td><td>${stFmt(f.feed?.op)}</td><td>${stFmt(f.reflux?.op)}</td><td>${stFmt(f.product?.op)}</td><td>${stFmt(f.overhead?.op)}</td><td>${stFmt(f.bottom?.op)}</td></tr>`,
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
meta.innerHTML = `
|
||||||
|
<div class="st-meta-tables">
|
||||||
|
<table class="st-meta-temp">${tempRows.join('')}</table>
|
||||||
|
${flowHeaders ? `<table class="st-meta-flow"><thead><tr>${flowHeaders}</tr></thead><tbody>${flowRows}</tbody></table>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **참고**: SP/OP 값은 현재 API 응답에 없음. 작업 6에서 API 확장이 필요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 작업 6 — 백엔드: API 응답에 SP/OP 추가 (필수)
|
||||||
|
|
||||||
|
**파일**: `SteamAdvisorController.cs`
|
||||||
|
|
||||||
|
**현재 상황**: `RealtimePoint` 엔티티에는 `LiveValue`만 있음. SP/OP는 `v_tag_summary` 뷰에서 조회.
|
||||||
|
|
||||||
|
**변경 내용**: FICQ 태그의 PV/SP/OP를 `v_tag_summary`에서 조회
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// v_tag_summary에서 base_tag 기반 조회 (예: ficq-6101, ficq-6113 등)
|
||||||
|
private async Task<Dictionary<string, (double? pv, double? sp, double? op)>> FetchFlowValues(string col)
|
||||||
|
{
|
||||||
|
var p = ToSuffix(col);
|
||||||
|
var baseTags = new[] {
|
||||||
|
("feed", $"ficq-{p}01"),
|
||||||
|
("reflux", $"ficq-{p}13"),
|
||||||
|
("overhead", $"ficq-{p}14"),
|
||||||
|
("bottom", $"ficq-{p}16"),
|
||||||
|
("product", $"ficq-{p}18"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var placeholders = string.Join(",", baseTags.Select((_, i) => $"@p{i}"));
|
||||||
|
var sql = $"SELECT LOWER(base_tag) as base_tag, pv, sp, op FROM v_tag_summary WHERE LOWER(base_tag) IN ({placeholders})";
|
||||||
|
var params = baseTags.Select((bt, i) => new Npgsql.NpgsqlParameter($"p{i}", bt.Item2)).ToArray();
|
||||||
|
|
||||||
|
// Raw SQL 조회 결과 파싱
|
||||||
|
var rows = await _ctx.Database.SqlQueryRaw<(string base_tag, string? pv, string? sp, string? op)>(sql, params).ToListAsync();
|
||||||
|
|
||||||
|
var result = new Dictionary<string, (double? pv, double? sp, double? op)>();
|
||||||
|
foreach (var kv in baseTags)
|
||||||
|
{
|
||||||
|
var row = rows.FirstOrDefault(r => r.base_tag == kv.Item2);
|
||||||
|
result[kv.Item1] = (
|
||||||
|
pv: double.TryParse(row?.pv, out var v) ? (double?)v : null,
|
||||||
|
sp: double.TryParse(row?.sp, out var s) ? (double?)s : null,
|
||||||
|
op: double.TryParse(row?.op, out var o) ? (double?)o : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**API 응답 구조**:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var flow = await FetchFlowValues(col);
|
||||||
|
return Ok(new {
|
||||||
|
// ... 기존 필드 ...
|
||||||
|
flow = new {
|
||||||
|
feed = flow["feed"],
|
||||||
|
reflux = flow["reflux"],
|
||||||
|
overhead = flow["overhead"],
|
||||||
|
bottom = flow["bottom"],
|
||||||
|
product = flow["product"],
|
||||||
|
},
|
||||||
|
flowTags = new {
|
||||||
|
feed = $"FICQ-{p}01.PV",
|
||||||
|
reflux = $"FICQ-{p}13.PV",
|
||||||
|
overhead = $"FICQ-{p}14.PV",
|
||||||
|
bottom = $"FICQ-{p}16.PV",
|
||||||
|
product = $"FICQ-{p}18.PV",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 작업 7 — CSS 추가
|
||||||
|
|
||||||
|
**파일**: `steam.html` (style 블록) 또는 별도 CSS
|
||||||
|
|
||||||
|
```css
|
||||||
|
.st-meta-tables {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.st-meta-temp, .st-meta-flow {
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.st-meta-temp td, .st-meta-temp th {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-bottom: 1px solid #1a2a3a;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.st-meta-flow {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.st-meta-flow th {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-bottom: 1px solid #2a3a4a;
|
||||||
|
color: #888;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
.st-meta-flow th small {
|
||||||
|
display: block;
|
||||||
|
font-size: 9px;
|
||||||
|
color: #555;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.st-meta-flow td {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-bottom: 1px solid #1a2a3a;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
.st-meta-flow td:first-child {
|
||||||
|
color: #666;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 작업 8 — 테스트
|
||||||
|
|
||||||
|
| 테스트 | 방법 |
|
||||||
|
|--------|------|
|
||||||
|
| 실시간 조회 | `GET /api/steam/tempprofile/C-6111` → `flow` + `flowTags` 필드 확인 |
|
||||||
|
| 과거 조회 | `GET /api/steam/tempprofile/C-6111/history?from=...&to=...` → 각 스냅샷에 `flow` 포함 확인 |
|
||||||
|
| UI 렌더링 | 온도 프로파일 탭 → `st-temp-meta`에 두 표 표시 확인 |
|
||||||
|
| Scrubber | 슬라이더 이동 → 각 시점의 표 값 변경 확인 |
|
||||||
|
| 실시간 모드 | `stTempLive.flow` 표시 확인 |
|
||||||
|
| null 처리 | PV가 null인 태그 (예: C-9111) → `—` 표시 확인 |
|
||||||
|
| CSS 렌더링 | 두 표가 나란히 배치 확인 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 완료상황 체크
|
||||||
|
|
||||||
|
- [x] **작업 1**: `TagsFor()`에 FICQ 태그 5개 추가 (feed/reflux/overhead/bottom/product)
|
||||||
|
- [x] **작업 2**: `FetchFlowValues()` 신규 추가 — `v_tag_summary`에서 PV/SP/OP 조회
|
||||||
|
- [x] **작업 3**: `TempProfile` API 응답에 `flow` + `flowTags` 필드 추가
|
||||||
|
- [x] **작업 3-H**: `TempProfileHistory` API 응답에 각 스냅샷 `flow` + `flowTags` 추가
|
||||||
|
- [x] **작업 4**: `ComputeStages()` 변경 없음 확인
|
||||||
|
- [x] **작업 5**: `stRenderTemp()` 재작성 — `textContent` → `innerHTML`, 두 개의 table 나란히 배치
|
||||||
|
- [x] **작업 6**: SP/OP 표시 — API + 프론트엔드 모두 구현 완료 (PV/SP/OP 3행)
|
||||||
|
- [x] **작업 7**: CSS 추가 (`.st-meta-tables`, `.st-meta-temp`, `.st-meta-flow`)
|
||||||
|
- [x] **빌드**: `dotnet build` 성공 (0 Error, 8 Warning — 기존 경고만)
|
||||||
|
- [ ] **테스트 1**: 실시간 API — `GET /api/steam/tempprofile/C-6111` → `flow` + `flowTags` 확인
|
||||||
|
- [ ] **테스트 2**: 과거 API — `GET /api/steam/tempprofile/C-6111/history?from=...&to=...` → 스냅샷에 `flow` 포함 확인
|
||||||
|
- [ ] **테스트 3**: UI 렌더링 — 온도 프로파일 탭 → 두 표 나란히 표시 확인
|
||||||
|
- [ ] **테스트 4**: Scrubber — 슬라이더 이동 → 각 시점 표 값 변경 확인
|
||||||
|
- [ ] **테스트 5**: null 처리 — PV가 null인 태그 (예: C-9111) → `—` 표시 확인
|
||||||
|
- [ ] **작업 8**: 실시간 조회 API 테스트 — `flow` + `flowTags` 필드 정상 응답
|
||||||
|
- [ ] **작업 8**: 과거 조회 API 테스트 — 스냅샷에 `flow` 포함
|
||||||
|
- [ ] **작업 8**: UI 렌더링 테스트 — 두 표 나란히 표시
|
||||||
|
- [ ] **작업 8**: Scrubber 테스트 — 과거 시점 표 값 변경
|
||||||
|
- [ ] **작업 8**: null/미폴링 태그 테스트 — `—` 표시 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 이해 안 되는 부분 / 확인 사항
|
||||||
|
|
||||||
|
1. **SP/OP 표시**: 현재 API 응답에 SP/OP가 없음. `v_tag_summary`에는 존재하므로 API 확장이 필요. 표시할까요? (표에 PV만 표시하면 작업 6 스킵)
|
||||||
|
2. **FICQ-{NN}14 = Overhead(경비물), FICQ-{NN}16 = Bottom(중비물)** — 확인 완료
|
||||||
910
docs/베이직아키텍처-태그-디자인-전면재설계.md
Normal file
910
docs/베이직아키텍처-태그-디자인-전면재설계.md
Normal file
@@ -0,0 +1,910 @@
|
|||||||
|
# 베이직 아키텍처 · 태그 디자인 전면 재설계
|
||||||
|
|
||||||
|
> 본 문서는 HC900-AX 프로젝트의 전면 재설계 결과를 정의한다.
|
||||||
|
> 2026-06-08, 전면 개정.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 목차
|
||||||
|
|
||||||
|
1. [핵심 원칙](#1-핵심-원칙)
|
||||||
|
2. [HC900 Modbus 통신 규칙](#2-hc900-modbus-통신-규칙)
|
||||||
|
3. [Sinam_Tag_all.xlsx 데이터 구조](#3-sinam_tag_allxlsx-데이터-구조)
|
||||||
|
4. [주소 변환 규칙 (공식 검증 완료)](#4-주소-변환-규칙-공식-검증-완료)
|
||||||
|
5. [태그명 체계 및 등록 규칙](#5-태그명-체계-및-등록-규칙)
|
||||||
|
6. [register-map-cN.json 포맷](#6-register-map-cnjson-포맷)
|
||||||
|
7. [아카이브/모니터링 제어](#7-아카이브모니터링-제어)
|
||||||
|
8. [컨트롤러별 독립 맵 전략](#8-컨트롤러별-독립-맵-전략)
|
||||||
|
9. [데이터 흐름 (전면 개정)](#9-데이터-흐름-전면-개정)
|
||||||
|
10. [build_register_map_from_sinam.py 처리 규칙](#10-build_register_map_from_sinampy-처리-규칙)
|
||||||
|
11. [C# 서비스 수정 사항](#11-c-서비스-수정-사항)
|
||||||
|
12. [tag_metadata 적재](#12-tag_metadata-적재)
|
||||||
|
13. [알려진 문제](#13-알려진-문제)
|
||||||
|
14. [폐기 항목](#14-폐기-항목)
|
||||||
|
15. [마이그레이션 순서](#15-마이그레이션-순서)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 핵심 원칙
|
||||||
|
|
||||||
|
### 1.1 단일 진실 공급원 (Single Source of Truth)
|
||||||
|
- **`docs/Sinam_Tag_all.xlsx`** 가 모든 태그 정보의 유일한 출처
|
||||||
|
- HC Designer CSV (`SignalTags.csv`, `Variables.csv`, `SummaryFunctionBlockReport.csv`)는 **주소 검증용 보조 자료**
|
||||||
|
- Experion OPC UA legacy (소문자 태그명, node_id 등)는 **완전히 폐기**
|
||||||
|
|
||||||
|
### 1.2 태그명 표기법
|
||||||
|
- **모든 태그명은 대문자** (OPC UA 강제 소문자 규칙 폐기)
|
||||||
|
- 구분자: `-` (하이픈) + `.` (파라미터)
|
||||||
|
- 예: `FICQ-6101.PV`, `P-9114.STATE`, `LI-9100.PV`
|
||||||
|
- Experion ItemName 기준 (`FICQ-6101`), HC900 네이티브명(`FIQ6101`) 사용 안 함
|
||||||
|
|
||||||
|
### 1.3 컨트롤러별 독립 맵
|
||||||
|
- 각 HC900 컨트롤러(C1~C4)는 **독립적인 `register-map-cN.json`** 사용
|
||||||
|
- `config/gateway-config.json`에서 각 컨트롤러의 enabled/disabled 관리
|
||||||
|
- 웹 UI Setup 페이지에서 컨트롤러 추가/삭제/시작/중지 가능
|
||||||
|
|
||||||
|
### 1.4 레지스터 맵 엔트리 설계 원칙
|
||||||
|
- **❌ Loop 블록 통째로(192 regs) 등록하지 않음** — 게이트웨이의 배치 그룹핑이 개별 엔트리를 쪼갤 수 없으므로 `count > 120` 인 단일 엔트리는 단일 FC03 호출로 192 regs 읽기를 시도하여 HC900에서 실패
|
||||||
|
- **✅ 각 파라미터를 개별 엔트리로 등록** (PV=2regs, SP=2regs, OP=2regs, MODE=1reg, ...)
|
||||||
|
- 게이트웨이가 주소 순서로 정렬 후 인접 엔트리들을 동적으로 ≤120 배치로 묶어서 FC03 호출
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. HC900 Modbus 통신 규칙
|
||||||
|
|
||||||
|
### 2.1 기본 프로토콜
|
||||||
|
|
||||||
|
| 항목 | 값 | 근거 |
|
||||||
|
|------|-----|------|
|
||||||
|
| 전송 | Modbus TCP | `modbus_tcp.cpp` |
|
||||||
|
| HC900 IP | 컨트롤러별 설정 | `gateway-config.json` |
|
||||||
|
| 포트 | 502 | 기본값 |
|
||||||
|
| Unit ID | 1 | `modbus_tcp.cpp:13` |
|
||||||
|
| 소켓 timeout | 2초 | `modbus_tcp.cpp:14` |
|
||||||
|
| Watchdog | 5초 무응답 → Reconnecting | `modbus_tcp.cpp:15` |
|
||||||
|
| Max retry | 5회 → Fault (30초 후 자동 재시도) | `modbus_tcp.cpp:16` |
|
||||||
|
| Float format | **FP B** (IEEE 754 BigEndian, HighFirst, Normal) | HC900 매뉴얼, `vendor_formats.hpp` |
|
||||||
|
|
||||||
|
### 2.2 Function Codes
|
||||||
|
|
||||||
|
| 기능 | FC | 방향 | 구현 |
|
||||||
|
|------|----|------|------|
|
||||||
|
| Read Holding Registers | **FC03** | HC900 → Gateway | `modbus_tcp.cpp:171` |
|
||||||
|
| Write Multiple Registers | **FC16** (0x10) | Gateway → HC900 | `modbus_tcp.cpp:251` |
|
||||||
|
|
||||||
|
### 2.3 배치 읽기 규칙 (C++ 게이트웨이 자동 수행)
|
||||||
|
|
||||||
|
```
|
||||||
|
MAX_BATCH = 120 연속 레지스터 (gateway.cpp:126)
|
||||||
|
```
|
||||||
|
|
||||||
|
게이트웨이의 `ReadAllRegisters()`는 런타임에 register-map 엔트리들을 주소 순서로 정렬한 후, 동일한 120-register 윈도우 안에 들어가는 엔트리들을 한 번의 FC03로 묶어서 읽음:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// gateway.cpp:124-174 — 동적 배치 그룹핑
|
||||||
|
while (i < sorted_indices_.size()) {
|
||||||
|
uint32_t batch_start = registers_[sorted_indices_[i]].addr;
|
||||||
|
size_t j = i;
|
||||||
|
while (j < sorted_indices_.size()) {
|
||||||
|
const auto& e = registers_[sorted_indices_[j]];
|
||||||
|
if (e.addr + e.count - batch_start > MAX_BATCH) break;
|
||||||
|
++j;
|
||||||
|
}
|
||||||
|
// FC03 read_raw(batch_start, read_count)
|
||||||
|
// 각 엔트리를 버퍼에서 개별 decode
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**중요:** 개별 엔트리의 `addr + count`가 120을 넘으면 단일 FC03로 처리되므로, 192-regs 통째 등록은 불가능. 각 파라미터는 count=1(uint16) 또는 2(float32)로 등록해야 함.
|
||||||
|
|
||||||
|
### 2.4 Loop 블록 구조 (매뉴얼 Table 6-3)
|
||||||
|
|
||||||
|
각 PID 루프는 256개 레지스터 블록 (루프 블록 크기 = 192 regs, 0x40~0xFF).
|
||||||
|
|
||||||
|
| Offset | 파라미터 | 타입 | 본문 접근 |
|
||||||
|
|--------|---------|------|:---------:|
|
||||||
|
| 0x00 | PV | float32 | R |
|
||||||
|
| 0x02 | Remote SP (SP2) | float32 | R |
|
||||||
|
| 0x04 | **Working Set Point (SP)** | float32 | RW |
|
||||||
|
| 0x06 | **Output (OP)** | float32 | RW |
|
||||||
|
| 0x0C | Gain #1 | float32 | RW |
|
||||||
|
| 0x0E | Direction | float32 | R |
|
||||||
|
| 0x10 | Reset #1 | float32 | RW |
|
||||||
|
| 0x12 | Rate #1 | float32 | RW |
|
||||||
|
| 0x14 | Cycle Time #1 | float32 | R |
|
||||||
|
| 0x16 | PV Low Range | float32 | R |
|
||||||
|
| 0x18 | PV High Range | float32 | R |
|
||||||
|
| 0x1A | Alarm #1 SP #1 (AL1SP1) | float32 | RW |
|
||||||
|
| 0x1C | Alarm #1 SP #2 (AL1SP2) | float32 | RW |
|
||||||
|
| 0x20 | Gain #2 | float32 | RW |
|
||||||
|
| 0x2A | Local SP #1 (LSP1) | float32 | RW |
|
||||||
|
| 0x2C | Local SP #2 (LSP2) | float32 | RW |
|
||||||
|
| 0x2E | Alarm #2 SP #1 (AL2SP1) | float32 | RW |
|
||||||
|
| 0x30 | Alarm #2 SP #2 (AL2SP2) | float32 | RW |
|
||||||
|
| 0x34 | SP Low Limit | float32 | RW |
|
||||||
|
| 0x36 | SP High Limit | float32 | RW |
|
||||||
|
| 0x3A | Output Low Limit | float32 | RW |
|
||||||
|
| 0x3C | Output High Limit | float32 | RW |
|
||||||
|
| 0x3E | Working Output (OPWORK) | float32 | RW |
|
||||||
|
| 0x46 | Ratio | float32 | RW |
|
||||||
|
| 0x48 | Bias | float32 | RW |
|
||||||
|
| 0x4A | Deviation | float32 | R |
|
||||||
|
| 0x4E | Manual Reset | float32 | RW |
|
||||||
|
| 0x50 | Feedforward Gain | float32 | RW |
|
||||||
|
| 0x52 | Local %CO | float32 | RW |
|
||||||
|
| 0xB7 | Fuzzy Enable | uint16 | RW |
|
||||||
|
| 0xB8 | Autotune Request | uint16 | RW |
|
||||||
|
| 0xB9 | Anti-soot Enable | uint16 | RW |
|
||||||
|
| **0xBA** | **Auto/Manual State (MODE write target)** | uint16 | RW |
|
||||||
|
| 0xBB | SP Select State | uint16 | RW |
|
||||||
|
| 0xBC | Remote/Local SP State | uint16 | RW |
|
||||||
|
| **0xBE** | **Loop Status (MODE read source)** | uint16 | R |
|
||||||
|
|
||||||
|
### 2.5 루프 주소 계산
|
||||||
|
|
||||||
|
```
|
||||||
|
Loop 1~24: base = 0x0040 + (N-1) * 0x0100
|
||||||
|
Loop 25~32: base = 0x7840 + (N-25) * 0x0100
|
||||||
|
```
|
||||||
|
|
||||||
|
검증 완료 (SummaryFunctionBlockReport.csv):
|
||||||
|
|
||||||
|
| Loop | CSV 주소 | 계산 | 일치 |
|
||||||
|
|:----:|:--------:|------|:----:|
|
||||||
|
| #1 | 0x0040 | `0x0040 + 0*0x0100` | ✅ |
|
||||||
|
| #11 | 0x0A40 | `0x0040 + 10*0x0100` | ✅ |
|
||||||
|
| #18 | 0x1140 | `0x0040 + 17*0x0100` | ✅ |
|
||||||
|
| #24 | 0x1740 | `0x0040 + 23*0x0100` | ✅ |
|
||||||
|
| #25 | 0x7840 | `0x7840 + 0*0x0100` | ✅ |
|
||||||
|
| #32 | 0x7F40 | `0x7840 + 7*0x0100` | ✅ |
|
||||||
|
|
||||||
|
### 2.6 주소 영역
|
||||||
|
|
||||||
|
| 영역 | 시작 | 끝 | 설명 |
|
||||||
|
|------|:----:|:---:|------|
|
||||||
|
| Misc | 0x0000 | 0x003F | 기타 파라미터 |
|
||||||
|
| Loop 1~24 | 0x0040 | 0x15FF | 루프 블록 (각 256 regs) |
|
||||||
|
| Loop 25~32 | 0x7840 | 0x78FF | 루프 블록 확장 |
|
||||||
|
| Analog Input | 0x1800 | 0x187F | AI 값 (Function Code 03) |
|
||||||
|
| **Variable** | **0x18C0** | **0x1D6F** | R/W 변수 (MATH_VAR) |
|
||||||
|
| **Signal Tag** | **0x2000** | **0x27CF** | R 태그 (TAG) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Sinam_Tag_all.xlsx 데이터 구조
|
||||||
|
|
||||||
|
### 3.1 파일 개요
|
||||||
|
- 1개 시트 (Sheet1)
|
||||||
|
- **3407 데이터 행**, 150 컬럼
|
||||||
|
- 다중 섹션 구조
|
||||||
|
|
||||||
|
### 3.2 Section 구성
|
||||||
|
|
||||||
|
| Section | 행 범위 | 헤더 첫 컬럼 | 설명 |
|
||||||
|
|---------|---------|-------------|------|
|
||||||
|
| 1 (ItemName) | Row 2~ | `ItemName` | 메인 태그 정의 (AnalogPoint, StatusPoint, HistoryParameters) |
|
||||||
|
| 2+ (ParentItemName) | Row 1163~4424 | `ParentItemName` | FlexibleParameters 확장 속성 |
|
||||||
|
|
||||||
|
### 3.3 Section 1: ItemName — 주요 컬럼
|
||||||
|
|
||||||
|
| 컬럼명 | 0-indexed | 예시 | 설명 |
|
||||||
|
|--------|:--------:|------|------|
|
||||||
|
| `ItemName` | 0 | `FICQ-6101` | Experion 태그명 (대문자) |
|
||||||
|
| `Class` | 1 | `AnalogPoint` / `StatusPoint` / `HistoryParameters` | 태그 클래스 |
|
||||||
|
| `ItemDescription` | 5 | `FICQ-6101 PV` | 설명 |
|
||||||
|
| `DownloadedName` | 6 | `FICQ-6101` | 다운로드명 |
|
||||||
|
| `AreaCode` | 11 | `P6` | 영역 코드 |
|
||||||
|
| `TrendParameter` | 18 | `True` / `PV` / `False` | Experion 트렌드 대상 (참고용, 우리 시스템과 무관) |
|
||||||
|
| `SourceAddressPV` | 29 | `C3 LOOP 11 PV` / `C4 TAG 32 VALUE` | PV의 HC900 주소 |
|
||||||
|
| `SourceAddressOP` | 30 | `C3 LOOP 11 OPWORK` / `C4 MATH_VAR 5 VALUE` | OP의 HC900 주소 |
|
||||||
|
| `SourceAddressMD` | 31 | `C3 LOOP 11 LOOPSTAT` | MODE의 HC900 주소 |
|
||||||
|
| `DescriptorState0~7` | 61~68 | `RUN`, `STOP`, `FAULT` | 상태 레이블 (StatusPoint 한정) |
|
||||||
|
| `Units` | (AnalogPoint) | `kg/h` | 엔지니어링 단위 |
|
||||||
|
| `RangeLow` / `RangeHigh` | (AnalogPoint) | `0` / `100` | 측정 범위 |
|
||||||
|
|
||||||
|
### 3.4 Section 2+: ParentItemName (FlexibleParameters)
|
||||||
|
|
||||||
|
| 컬럼명 | 0-indexed | 예시 | 설명 |
|
||||||
|
|--------|:--------:|------|------|
|
||||||
|
| `ParentItemName` | 0 | `FICQ-6101` | 부모 태그명 |
|
||||||
|
| `Class` | 1 | `FlexibleParameters` | 고정 |
|
||||||
|
| `ParamName` | 2 | `QV`, `RST`, `STATE`, `AL1SP1`, `HZ` | 확장 파라미터명 |
|
||||||
|
| `TypeDatabaseReference` | 5 | `16 bit signed integer (INT2)` | 데이터타입 |
|
||||||
|
| `ScanPeriodPVUDSP` | 25 | `2` | 스캔 주기 (초) |
|
||||||
|
| `UnitsUDSP` | 26 | `kg` | 단위 |
|
||||||
|
| `RangeHighUDSP` / `RangeLowUDSP` | 27/28 | 범위 |
|
||||||
|
| `StatusNumStatesUDSP` | 32 | `2` | 상태 개수 |
|
||||||
|
| `DescriptorState0~7UDSP` | 33~40 | `OFF`, `ON` | 상태 레이블 (Status 한정) |
|
||||||
|
| `DestinationAddressPVUDSP` | 23 | `C3 MATH_VAR 37 VALUE` | 쓰기 주소 (있으면 RW, 없으면 R) |
|
||||||
|
|
||||||
|
### 3.5 SourceAddress 형식
|
||||||
|
|
||||||
|
| 형식 | 예시 | 변환 규칙 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `C{N} TAG {N} VALUE` | `C4 TAG 32 VALUE` | `addr = 0x2000 + (N-1)*2`, access=R |
|
||||||
|
| `C{N} MATH_VAR {N} VALUE` | `C4 MATH_VAR 5 VALUE` | `addr = 0x18C0 + (N-1)*2`, access=RW |
|
||||||
|
| `C{N} LOOP {N} {PARAM}` | `C3 LOOP 11 PV` | 루프 블록 내 파라미터 오프셋 |
|
||||||
|
| `C{N} LOOPX {N} {PARAM}` | `C3 LOOPX 25 WSP` | LOOPX = Loop 25~32와 동일 주소 체계 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 주소 변환 규칙 (공식 검증 완료)
|
||||||
|
|
||||||
|
Sinam_Tag_all.xlsx의 SourceAddress 문자열을 HC900 Modbus 주소로 변환하는 공식:
|
||||||
|
|
||||||
|
### 4.1 TAG N → Modbus 주소
|
||||||
|
```
|
||||||
|
addr = 0x2000 + (N-1) * 2
|
||||||
|
```
|
||||||
|
검증: CSV `TAG 4 = FIQ6101 @ 0x2006` ✅
|
||||||
|
|
||||||
|
### 4.2 MATH_VAR N → Modbus 주소
|
||||||
|
```
|
||||||
|
addr = 0x18C0 + (N-1) * 2
|
||||||
|
```
|
||||||
|
검증: CSV `VAR 2 = LT3211_LSET @ 0x18C2` ✅
|
||||||
|
|
||||||
|
### 4.3 LOOP N PARAM → Modbus 주소
|
||||||
|
```
|
||||||
|
base = loop_base(N)
|
||||||
|
off = MNEMONIC_OFFSET[PARAM]
|
||||||
|
addr = base + off
|
||||||
|
```
|
||||||
|
검증: `C2 LOOP 18 WSP → base=0x1140, off=0x04 → addr=0x1144` ✅
|
||||||
|
|
||||||
|
### 4.4 Mnemonic → Offset 매핑
|
||||||
|
|
||||||
|
| Mnemonic | Offset | Experion Attr |
|
||||||
|
|----------|:------:|:-------------:|
|
||||||
|
| PV | 0x00 | PV |
|
||||||
|
| RSP / SP2 | 0x02 | — |
|
||||||
|
| WSP / SPWORK | 0x04 | **SP** |
|
||||||
|
| OP / OPWORK | 0x06 | **OP** |
|
||||||
|
| GAIN1 / PROP1 | 0x0C | GAIN |
|
||||||
|
| DIR | 0x0E | — |
|
||||||
|
| RESET1 | 0x10 | RESET |
|
||||||
|
| RATE1 | 0x12 | RATE |
|
||||||
|
| PVLOW | 0x16 | — |
|
||||||
|
| PVHIGH | 0x18 | — |
|
||||||
|
| AL1SP1 | 0x1A | — |
|
||||||
|
| AL1SP2 | 0x1C | — |
|
||||||
|
| LSP1 | 0x2A | — |
|
||||||
|
| LSP2 | 0x2C | — |
|
||||||
|
| AL2SP1 | 0x2E | — |
|
||||||
|
| SPLOW | 0x34 | SP_LO |
|
||||||
|
| SPHIGH | 0x36 | SP_HI |
|
||||||
|
| OPLOW | 0x3A | OP_LO |
|
||||||
|
| OPHIGH | 0x3C | OP_HI |
|
||||||
|
| OPWORK | 0x3E | OP |
|
||||||
|
| DEV | 0x4A | — |
|
||||||
|
| LOOPSTAT | **0xBE** | **MD (읽기)** |
|
||||||
|
| MODEIN | **0xBA** | **MD (쓰기)** |
|
||||||
|
| AMSTAT | 0xBA | — |
|
||||||
|
|
||||||
|
**⚠️ MODE 주의:** 읽기는 LOOPSTAT(0xBE), 쓰기는 Auto/Man State(0xBA). `build_register_map_from_sinam.py` 초기 버전에서 write_addr이 0xBE로 잘못 설정된 버그 있음 → 수정 완료.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 태그명 체계 및 등록 규칙
|
||||||
|
|
||||||
|
### 5.1 태그명 포맷
|
||||||
|
|
||||||
|
```
|
||||||
|
{Experion-ItemName}.{Parameter}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 유형 | 태그명 예시 | 출처 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| Loop PV | `FICQ-6101.PV` | ItemName + Mnemonic |
|
||||||
|
| Loop SP | `FICQ-6101.SP` | WSP/SPWORK → SP |
|
||||||
|
| Loop OP | `FICQ-6101.OP` | OP/OPWORK → OP |
|
||||||
|
| Loop MD | `FICQ-6101.MD` | LOOPSTAT(읽기)/MODEIN(쓰기) → MD |
|
||||||
|
| Loop GAIN | `FICQ-6101.GAIN` | GAIN1 → GAIN |
|
||||||
|
| Loop RESET | `FICQ-6101.RESET` | RESET1 → RESET |
|
||||||
|
| Loop RATE | `FICQ-6101.RATE` | RATE1 → RATE |
|
||||||
|
| Loop 기타 | `FICQ-6101.Deviation` | HC900명 유지 |
|
||||||
|
| AnalogPoint (TAG source) | `LI-9100` | Class=AnalogPoint, PV source=TAG |
|
||||||
|
| StatusPoint PV (TAG) | `P-9114` | Class=StatusPoint, PV source=TAG |
|
||||||
|
| StatusPoint OP (MATH_VAR) | `P-9114.OP` | Class=StatusPoint, OP source=MATH_VAR |
|
||||||
|
| FlexibleParameter | `FICQ-6101.QV` / `P-9114.STATE` | ParentItemName.ParamName |
|
||||||
|
|
||||||
|
### 5.2 Loop 파라미터 Experion명 매핑
|
||||||
|
|
||||||
|
Loop 블록 내 모든 파라미터 중 Experion이 노출하는 것만 Experion 속성명 사용, 나머지는 HC900 네이티브명 유지:
|
||||||
|
|
||||||
|
```
|
||||||
|
LOOP_LAYOUT 오프셋 → EXPERION_ATTR 매핑:
|
||||||
|
0x00(PV) → .PV
|
||||||
|
0x04(WSP) → .SP
|
||||||
|
0x06(OP) → .OP
|
||||||
|
0x0C(GAIN1) → .GAIN
|
||||||
|
0x10(RESET1) → .RESET
|
||||||
|
0x12(RATE1) → .RATE
|
||||||
|
0xBE(LOOPSTAT) → .MD
|
||||||
|
그 외 → HC900명 유지 (예: .Deviation, .PV_LowRange)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 FlexibleParameter 등록
|
||||||
|
|
||||||
|
Section 2+에서 `ParentItemName.ParamName` 으로 등록:
|
||||||
|
|
||||||
|
| FlexibleParameter | 등록명 | 비고 |
|
||||||
|
|------------------|--------|------|
|
||||||
|
| FICQ-6101 / QV | `FICQ-6101.QV` | Quality Value (적산) |
|
||||||
|
| FICQ-6101 / RST | `FICQ-6101.RST` | Reset |
|
||||||
|
| FICQ-6101 / AL1SP1 | `FICQ-6101.AL1SP1` | Alarm Setpoint |
|
||||||
|
| P-9114 / STATE | `P-9114.STATE` | 운전 상태 |
|
||||||
|
| P-9114 / HZ | `P-9114.HZ` | 주파수 |
|
||||||
|
| P-9114 / HZSET | `P-9114.HZSET` | 주파수 설정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. register-map-cN.json 포맷
|
||||||
|
|
||||||
|
### 6.1 엔트리 구조
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tag": "FICQ-6101.PV",
|
||||||
|
"addr": 2624,
|
||||||
|
"write_addr": 2624,
|
||||||
|
"count": 2,
|
||||||
|
"type": "float32",
|
||||||
|
"access": "R",
|
||||||
|
"description": "LOOP #11 PV",
|
||||||
|
"archive": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 필드 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| `tag` | string | 태그명 (Experion ItemName + 파라미터) |
|
||||||
|
| `addr` | uint16 | Modbus holding register 주소 (0-based) |
|
||||||
|
| `write_addr` | uint16 | 쓰기 주소 (기본값 = addr, MODE 등 별도 쓰기 레지스터가 있는 경우 다름) |
|
||||||
|
| `count` | uint16 | 레지스터 수 (float32=2, uint16=1) |
|
||||||
|
| `type` | string | 데이터 타입 (`float32` 또는 `uint16`) |
|
||||||
|
| `access` | string | 접근 권한 (`R` 또는 `RW`) |
|
||||||
|
| `description` | string | 설명 |
|
||||||
|
| `archive` | bool | history_table 기록 대상 여부 |
|
||||||
|
|
||||||
|
### 6.2 Loop 엔트리 (개별 파라미터)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tag": "FICQ-6101.PV", "addr": 2624, "count": 2, "type": "float32", "access": "R", "archive": true,
|
||||||
|
"tag": "FICQ-6101.SP", "addr": 2628, "count": 2, "type": "float32", "access": "RW", "archive": true,
|
||||||
|
"tag": "FICQ-6101.OP", "addr": 2630, "count": 2, "type": "float32", "access": "RW", "archive": true,
|
||||||
|
"tag": "FICQ-6101.MD", "addr": 2750, "count": 1, "type": "uint16", "access": "R", "archive": true,
|
||||||
|
"tag": "FICQ-6101.GAIN", "addr": 2636, "count": 2, "type": "float32", "access": "RW", "archive": false,
|
||||||
|
"tag": "FICQ-6101.RESET", "addr": 2640, "count": 2, "type": "float32", "access": "RW", "archive": false,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 일반 TAG 엔트리
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tag": "LI-9100",
|
||||||
|
"addr": 8194,
|
||||||
|
"write_addr": 8194,
|
||||||
|
"count": 2,
|
||||||
|
"type": "float32",
|
||||||
|
"access": "R",
|
||||||
|
"description": "Signal Tag #2",
|
||||||
|
"archive": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 Variable 엔트리 (MATH_VAR)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tag": "P-9114.OP",
|
||||||
|
"addr": 6280,
|
||||||
|
"write_addr": 6280,
|
||||||
|
"count": 2,
|
||||||
|
"type": "float32",
|
||||||
|
"access": "RW",
|
||||||
|
"description": "Variable (MATH_VAR) #5",
|
||||||
|
"archive": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 FlexibleParameter 엔트리
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tag": "FICQ-6101.QV",
|
||||||
|
"addr": 8198,
|
||||||
|
"write_addr": 8198,
|
||||||
|
"count": 2,
|
||||||
|
"type": "float32",
|
||||||
|
"access": "R",
|
||||||
|
"description": "FlexibleParameter QV",
|
||||||
|
"archive": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 아카이브/모니터링 제어
|
||||||
|
|
||||||
|
### 7.1 문제 정의
|
||||||
|
|
||||||
|
현재 시스템은 다음과 같이 동작:
|
||||||
|
|
||||||
|
```
|
||||||
|
hc900_map_master WHERE is_active = TRUE
|
||||||
|
→ realtime_table upsert (1초)
|
||||||
|
→ history_table snapshot (60초, digital 제외)
|
||||||
|
```
|
||||||
|
|
||||||
|
**문제점:**
|
||||||
|
- `realtime_table`: GAIN1, RESET1, AL1SP1 등 불필요한 태그까지 표시
|
||||||
|
- `history_table`: 아카이브할 가치 없는 태그까지 60초마다 기록 (GAIN1, BIAS, RATIO, DEV 등)
|
||||||
|
- `is_active` 하나로만 제어 → 실시간 표시와 이력 기록을 분리 불가
|
||||||
|
|
||||||
|
### 7.2 대상 선별 규칙
|
||||||
|
|
||||||
|
| 태그 유형 | 예시 | realtime | history | 근거 |
|
||||||
|
|-----------|------|:--------:|:-------:|------|
|
||||||
|
| **Loop PV** | `FICQ-6101.PV` | ✅ | ✅ | 공정값, 추세 필수 |
|
||||||
|
| **Loop SP** | `FICQ-6101.SP` | ✅ | ✅ | 설정값 변경 이력 |
|
||||||
|
| **Loop OP** | `FICQ-6101.OP` | ✅ | ✅ | 출력값 추세 |
|
||||||
|
| **Loop MODE** | `FICQ-6101.MD` | ✅ | ✅ | Auto/Man 전환 이력 |
|
||||||
|
| **Loop QV** | `FICQ-6101.QV` | ✅ | ✅ | Quality Value 추세 |
|
||||||
|
| **AnalogPoint PV** | `LI-9100` | ✅ | ✅ | 일반 공정값 |
|
||||||
|
| **StatusPoint PV** | `P-9114` | ✅ | ✅ | 상태 변화 (event_history_table) |
|
||||||
|
| **Loop 기타 파라미터** | `FICQ-6101.GAIN1`, `.RESET1`, `.RATE1`, `.AL1SP1`, `.DIR`, `.PV_LO`, `.PV_HI`, `.SP_LO`, `.SP_HI`, `.DEV` | ✅ | ❌ | 설정값, 실시간 확인만 |
|
||||||
|
| **Variable (MATH_VAR)** | `P-9114.RST`, `FICQ-6101.RST` | ❌ | ❌ | 중간 계산값, 불필요 |
|
||||||
|
| **StatusPoint OP** | `P-9114.OP` | ❌ | ❌ | MATH_VAR 기반 |
|
||||||
|
| **기타 FlexibleParameter** | `P-9114.STATE`, `FICQ-6101.AL1SP1` | △ | ❌ | 필요시 사용자 활성화 |
|
||||||
|
|
||||||
|
### 7.3 archive 플래그 선별 로직
|
||||||
|
|
||||||
|
```python
|
||||||
|
def should_archive(kind: str, param_name: str | None) -> bool:
|
||||||
|
if kind == 'loop':
|
||||||
|
return param_name in ('PV', 'SP', 'OP', 'MD', 'QV')
|
||||||
|
if kind == 'tag': # AnalogPoint / StatusPoint TAG source
|
||||||
|
return True
|
||||||
|
if kind in ('var', 'raw'):
|
||||||
|
return False
|
||||||
|
if kind == 'flexparam':
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 hc900_map_master 컬럼 추가
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE hc900_map_master
|
||||||
|
ADD COLUMN IF NOT EXISTS realtime_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
ADD COLUMN IF NOT EXISTS archive_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
```
|
||||||
|
|
||||||
|
| 컬럼 | 설명 | 기본값 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `realtime_enabled` | realtime_table 표시 여부 | TRUE (모든 활성 태그) |
|
||||||
|
| `archive_enabled` | history_table 기록 여부 | register-map의 `archive` 필드 |
|
||||||
|
|
||||||
|
사용자는 PointBuilder UI에서 두 플래그를 개별적으로 override 가능:
|
||||||
|
|
||||||
|
| 태그명 | 실시간 | 이력 |
|
||||||
|
|--------|:-----:|:----:|
|
||||||
|
| FICQ-6101.PV | ☑ | ☑ |
|
||||||
|
| FICQ-6101.SP | ☑ | ☑ |
|
||||||
|
| FICQ-6101.OP | ☑ | ☑ |
|
||||||
|
| FICQ-6101.MD | ☑ | ☑ |
|
||||||
|
| FICQ-6101.QV | ☑ | ☑ |
|
||||||
|
| FICQ-6101.GAIN1 | ☑ | ☐ |
|
||||||
|
| LI-9100 | ☑ | ☑ |
|
||||||
|
| P-9114 | ☑ | ☑ |
|
||||||
|
| P-9114.RST | ☐ | ☐ |
|
||||||
|
|
||||||
|
### 7.5 마이그레이션
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 신규 컬럼 추가
|
||||||
|
ALTER TABLE hc900_map_master
|
||||||
|
ADD COLUMN IF NOT EXISTS realtime_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
ADD COLUMN IF NOT EXISTS archive_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- build_register_map_from_sinam.py 가 archive=true 인 태그 UPDATE
|
||||||
|
-- (스크립트가 hc900_map_master upsert 시 archive_enabled 도 함께 설정)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 컨트롤러별 독립 맵 전략
|
||||||
|
|
||||||
|
### 8.1 컨트롤러 구성
|
||||||
|
|
||||||
|
| 컨트롤러 | IP | 포트 | gRPC Port | 현재 상태 |
|
||||||
|
|:--------:|:--:|:----:|:---------:|:---------:|
|
||||||
|
| C1 | 192.168.0.250 | 502 | 50051 | 미연결 |
|
||||||
|
| C2 | 192.168.0.230 | 502 | 50052 | 미연결 |
|
||||||
|
| C3 | 192.168.0.240 | 502 | 50053 | **활성** |
|
||||||
|
| C4 | 192.168.0.220 | 502 | 50054 | 미연결 |
|
||||||
|
|
||||||
|
### 8.2 설정 파일
|
||||||
|
|
||||||
|
```json
|
||||||
|
// config/gateway-config.json
|
||||||
|
{
|
||||||
|
"controllers": [
|
||||||
|
{ "id": "C1", "host": "192.168.0.250", "port": 502,
|
||||||
|
"map_path": "docs/register-map-c1.json", "grpc_port": 50051, "enabled": false },
|
||||||
|
{ "id": "C2", "host": "192.168.0.230", "port": 502,
|
||||||
|
"map_path": "docs/register-map-c2.json", "grpc_port": 50052, "enabled": false },
|
||||||
|
{ "id": "C3", "host": "192.168.0.240", "port": 502,
|
||||||
|
"map_path": "docs/register-map-c3.json", "grpc_port": 50053, "enabled": true },
|
||||||
|
{ "id": "C4", "host": "192.168.0.220", "port": 502,
|
||||||
|
"map_path": "docs/register-map-c4.json", "grpc_port": 50054, "enabled": false }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 컨트롤러 등급별 구분
|
||||||
|
|
||||||
|
각 컨트롤러는 독립된 C++ 게이트웨이 프로세스로 동작:
|
||||||
|
- 각자의 register-map-cN.json 로드
|
||||||
|
- 각자의 IP/Port로 Modbus TCP 연결
|
||||||
|
- 각자의 gRPC 포트에서 서비스
|
||||||
|
- C# ControllerGrpcClientPool이 enabled 컨트롤러별로 클라이언트 생성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 데이터 흐름 (전면 개정)
|
||||||
|
|
||||||
|
### 9.1 전체 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
Sinam_Tag_all.xlsx (단일 진실 공급원)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
build_register_map_from_sinam.py
|
||||||
|
├─ 주소 변환 (TAG/MATH_VAR/LOOP)
|
||||||
|
├─ 컨트롤러별 분할
|
||||||
|
├─ archive 플래그 설정
|
||||||
|
├─ tag_metadata upsert (상태 레이블, 단위, desc, area)
|
||||||
|
└─ hc900_map_master upsert (is_active, realtime_enabled, archive_enabled)
|
||||||
|
│
|
||||||
|
▼ 각 컨트롤러별
|
||||||
|
register-map-c1.json ──── C++ Gateway C1 ── gRPC:50051
|
||||||
|
register-map-c2.json ──── C++ Gateway C2 ── gRPC:50052
|
||||||
|
register-map-c3.json ──── C++ Gateway C3 ── gRPC:50053
|
||||||
|
register-map-c4.json ──── C++ Gateway C4 ── gRPC:50054
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Hc900RealtimeService (C# BackgroundService)
|
||||||
|
1. hc900_map_master WHERE is_active=TRUE
|
||||||
|
AND realtime_enabled=TRUE AND controller_id=$1
|
||||||
|
2. client.ReadTagsAsync(tagNames) ← 1초 간격
|
||||||
|
3. BatchUpdateRealtimeTableAsync()
|
||||||
|
→ INSERT INTO realtime_table
|
||||||
|
(controller_id, tagname, node_id, livevalue, timestamp)
|
||||||
|
ON CONFLICT (controller_id, tagname) DO UPDATE ...
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
realtime_table (최신값, tag당 1행, 1초 갱신)
|
||||||
|
│
|
||||||
|
▼ 60초 간격
|
||||||
|
Hc900HistoryService (C# BackgroundService)
|
||||||
|
1. hc900_map_master WHERE archive_enabled=TRUE
|
||||||
|
2. realtime_table JOIN archive_enabled → tagged points
|
||||||
|
3. stamp DateTime.UtcNow
|
||||||
|
4. INSERT INTO history_table (TimeScaleDB hypertable)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
event_history_table (Hc900DigitalEventDetectorService)
|
||||||
|
└─ StatusPoint PV 변화 감지 → ALARM/TRIP/RUN/NORMAL
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 C++ 게이트웨이 상세
|
||||||
|
|
||||||
|
```
|
||||||
|
C++ Gateway (hc900_gateway)
|
||||||
|
LoadRegisterMap()
|
||||||
|
→ JSON 파싱 → registers_[] + tag_index_[]
|
||||||
|
→ sorted_indices_[] (addr 정렬)
|
||||||
|
|
||||||
|
PollLoop()
|
||||||
|
→ ReadAllRegisters():
|
||||||
|
registers_를 addr 순서로 스캔
|
||||||
|
≤120 regs 윈도우에 들어가는 엔트리들 배치
|
||||||
|
→ controller_->read_raw(batch_start, read_count)
|
||||||
|
→ 각 엔트리 decode_float(FP_B) → cache_[tag_name]
|
||||||
|
|
||||||
|
gRPC ReadTags()
|
||||||
|
→ cache_ 에서 즉시 반환 (Modbus I/O 없음)
|
||||||
|
|
||||||
|
gRPC WriteTag(tag, value)
|
||||||
|
→ transport_mutex_ 획득
|
||||||
|
→ controller_->write_raw(entry.write_addr, ...) ← FC16
|
||||||
|
→ cache_ 업데이트
|
||||||
|
|
||||||
|
gRPC HealthCheck()
|
||||||
|
→ 연결 상태, poll_count, last_poll_duration
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. build_register_map_from_sinam.py 처리 규칙
|
||||||
|
|
||||||
|
### 10.1 실행 명령어
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/build_register_map_from_sinam.py \
|
||||||
|
--sinam docs/Sinam_Tag_all.xlsx \
|
||||||
|
--controller C3 \
|
||||||
|
-o docs/register-map-c3.json \
|
||||||
|
[--db-conn "Host=...;Database=hc900;..."]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 처리 순서
|
||||||
|
|
||||||
|
1. **Sinam_Tag_all.xlsx 로드** (openpyxl, read_only=True, data_only=True)
|
||||||
|
2. **Section 1 처리** (ItemName 행):
|
||||||
|
- 각 행의 모든 셀에서 정규식으로 C{N} LOOP/TAG/MATH_VAR/LOOPX/RAW 검색
|
||||||
|
- `scan_point()` 분류: loop / tag / var / raw
|
||||||
|
- Loop: `build_loop_entries()` → 모든 LOOP_LAYOUT 파라미터를 개별 엔트리로 전개
|
||||||
|
- Tag: `build_point_entry()` → 단일 엔트리
|
||||||
|
- Var: `build_point_entry()` → 단일 엔트리
|
||||||
|
3. **Section 2+ 처리** (FlexibleParameters 행):
|
||||||
|
- `resolve_one()` 으로 개별 주소 해석
|
||||||
|
- `ParentItemName.ParamName` 태그명 생성
|
||||||
|
4. **`archive` 플래그 설정** (`should_archive()`)
|
||||||
|
5. **컨트롤러별 필터링** (--controller 인자)
|
||||||
|
6. **주소 순서 정렬** 후 JSON 출력
|
||||||
|
7. **선택적 DB 적재**:
|
||||||
|
- `tag_metadata` upsert (상태 레이블, 단위, desc, area)
|
||||||
|
- `hc900_map_master` upsert (is_active, realtime_enabled, archive_enabled)
|
||||||
|
|
||||||
|
### 10.3 Loop 엔트리 전개 상세
|
||||||
|
|
||||||
|
`build_loop_entries(item_name, loop_no, mnemonics)`:
|
||||||
|
|
||||||
|
```
|
||||||
|
입력: item_name="FICQ-6101", loop_no=11, mnemonics={PV, WSP, OPWORK, LOOPSTAT}
|
||||||
|
base = loop_base(11) = 0x0A40 = 2624
|
||||||
|
|
||||||
|
LOOP_LAYOUT 각 offset을 순회:
|
||||||
|
off=0x00 → suffix="PV", attr=EXPERION_ATTR.get("PV")="PV"
|
||||||
|
→ tag="FICQ-6101.PV", addr=2624, archive=true
|
||||||
|
off=0x04 → suffix="WSP", attr=EXPERION_ATTR.get("WSP")="SP"
|
||||||
|
→ tag="FICQ-6101.SP", addr=2628, archive=true
|
||||||
|
off=0x06 → suffix="Output", attr=EXPERION_ATTR.get("OPWORK")="OP"
|
||||||
|
→ tag="FICQ-6101.OP", addr=2630, archive=true
|
||||||
|
off=0xBE → has_mode=True → 별도 .MD 엔트리
|
||||||
|
→ tag="FICQ-6101.MD", addr=2750 (=2624+0xBE),
|
||||||
|
write_addr=2746 (=2624+0xBA), archive=true
|
||||||
|
|
||||||
|
나머지 LOOP_LAYOUT 파라미터는 HC900명 유지:
|
||||||
|
off=0x0C → tag="FICQ-6101.Gain1_PropBand1", addr=2636, archive=false
|
||||||
|
off=0x10 → tag="FICQ-6101.Reset1", addr=2640, archive=false
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.4 MODE write_addr 처리
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Loop Status 0xBE → read source
|
||||||
|
# Auto/Man State 0xBA → write target
|
||||||
|
if has_mode:
|
||||||
|
entries.append({
|
||||||
|
'tag': f'{item_name}.MD',
|
||||||
|
'addr': base + 0xBE, # 읽기 = Loop Status
|
||||||
|
'write_addr': base + 0xBA, # 쓰기 = Auto/Man State ⚠️
|
||||||
|
'count': 1,
|
||||||
|
'type': 'uint16',
|
||||||
|
'access': 'R', # 읽기전용 (게이트웨이가 MD 쓰기를 MODEIN으로 라우팅)
|
||||||
|
'description': f'LOOP #{n} Mode status',
|
||||||
|
'archive': True,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ 초기 버전에서 `write_addr = base + 0xBE`로 잘못 설정된 버그 수정 완료.
|
||||||
|
올바른 값: 읽기=0xBE(LoopStatus), 쓰기=0xBA(Auto/Man State).
|
||||||
|
|
||||||
|
### 10.5 archive 플래그 설정
|
||||||
|
|
||||||
|
```python
|
||||||
|
def should_archive(kind: str, is_loop: bool, param_name: str | None,
|
||||||
|
access: str, is_signal_tag: bool) -> bool:
|
||||||
|
"""history_table 기록 대상 선별"""
|
||||||
|
if is_loop and param_name in ('PV', 'SP', 'OP', 'MD', 'QV'):
|
||||||
|
return True
|
||||||
|
if not is_loop and access == 'R' and is_signal_tag:
|
||||||
|
return True # AnalogPoint / StatusPoint TAG source (PV)
|
||||||
|
return False
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. C# 서비스 수정 사항
|
||||||
|
|
||||||
|
### 11.1 Hc900MapEntry 엔티티
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// src/Core/Domain/Entities/Hc900Entities.cs
|
||||||
|
[Table("hc900_map_master")]
|
||||||
|
public class Hc900MapEntry
|
||||||
|
{
|
||||||
|
[Key] public int Id { get; set; }
|
||||||
|
|
||||||
|
[Column("tagname")] public string TagName { get; set; } = "";
|
||||||
|
[Column("hc900_tag")] public string Hc900Tag { get; set; } = "";
|
||||||
|
[Column("modbus_addr")] public int ModbusAddr { get; set; }
|
||||||
|
[Column("data_type")] public string DataType { get; set; } = "float32";
|
||||||
|
[Column("access")] public string Access { get; set; } = "R";
|
||||||
|
[Column("is_active")] public bool IsActive { get; set; } = true;
|
||||||
|
[Column("controller_id")] public string ControllerId { get; set; } = "HC1";
|
||||||
|
[Column("realtime_enabled")] public bool RealtimeEnabled { get; set; } = true; // 신규
|
||||||
|
[Column("archive_enabled")] public bool ArchiveEnabled { get; set; } = false; // 신규
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 Hc900RealtimeService.LoadMappingAsync
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 변경 전
|
||||||
|
"SELECT tagname, hc900_tag FROM hc900_map_master WHERE is_active = TRUE AND controller_id = $1"
|
||||||
|
|
||||||
|
// 변경 후
|
||||||
|
"SELECT tagname, hc900_tag FROM hc900_map_master WHERE is_active = TRUE AND realtime_enabled = TRUE AND controller_id = $1"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.3 Hc900HistoryService.SnapshotToHistoryAsync
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 변경 전
|
||||||
|
var digitalTagNames = await GetDigitalTagNamesCachedAsync();
|
||||||
|
var points = await _ctx.RealtimePoints
|
||||||
|
.Where(p => !digitalTagNames.Contains(p.TagName))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// 변경 후
|
||||||
|
var archiveTags = await _ctx.Hc900MapEntries
|
||||||
|
.Where(m => m.ArchiveEnabled)
|
||||||
|
.Select(m => m.TagName)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var points = await _ctx.RealtimePoints
|
||||||
|
.Where(p => archiveTags.Contains(p.TagName))
|
||||||
|
.ToListAsync();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.4 DDL (멱등)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await _ctx.Database.ExecuteSqlRawAsync("""
|
||||||
|
ALTER TABLE hc900_map_master
|
||||||
|
ADD COLUMN IF NOT EXISTS realtime_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
ADD COLUMN IF NOT EXISTS archive_enabled BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
""");
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. tag_metadata 적재
|
||||||
|
|
||||||
|
`build_register_map_from_sinam.py` 실행 시 `--db-conn` 옵션으로 tag_metadata도 함께 upsert:
|
||||||
|
|
||||||
|
| attribute | 출처 (Sinam_Tag_all.xlsx) |
|
||||||
|
|-----------|--------------------------|
|
||||||
|
| `desc` | `ItemDescription` 또는 `DownloadedName` |
|
||||||
|
| `area` | `AreaCode` (예: `P6`, `P9`) |
|
||||||
|
| `state0~7` | `DescriptorState0~7` (StatusPoint) / `DescriptorState0~7UDSP` (FlexibleParameters) |
|
||||||
|
| `units` | `Units` (AnalogPoint) / `UnitsUDSP` (FlexibleParameters) |
|
||||||
|
| `eulo` / `euhi` | `RangeLow` / `RangeHigh` (AnalogPoint) / `RangeLowUDSP` / `RangeHighUDSP` (FlexibleParameters) |
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO tag_metadata (base_tag, attribute, value, controller_id)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
ON CONFLICT (base_tag, attribute, controller_id)
|
||||||
|
DO UPDATE SET value = EXCLUDED.value, loaded_at = NOW()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 알려진 문제
|
||||||
|
|
||||||
|
### 13.1 MODE write_addr 버그 (수정 완료)
|
||||||
|
- `build_register_map_from_sinam.py` 초기 버전: MODE 엔트리의 `write_addr = base + 0xBE` (LoopStatus, 읽기전용)
|
||||||
|
- 올바름: `write_addr = base + 0xBA` (Auto/Man State, R/W)
|
||||||
|
- 영향: 게이트웨이를 통한 MODE 쓰기(MAN→AUTO 전환)가 HC900에 반영되지 않음
|
||||||
|
- 해결: 개정 코드에서 `MD_WRITE_OFFSET = 0xBA` 사용
|
||||||
|
|
||||||
|
### 13.2 LOOPX (Custom Map) 주소 검증
|
||||||
|
- `C4 LOOPX 25 AL1SP1` 등의 Custom Map 영역
|
||||||
|
- HC Designer Custom Map 보고서 필요 (현재 SummaryFunctionBlockReport.csv는 Fixed Map 기준)
|
||||||
|
- 주소 체계 검증: LOOPX 25~32는 Loop 25~32와 동일한 `0x7840 + (N-25)*0x0100` 사용한다고 가정
|
||||||
|
|
||||||
|
### 13.3 C4 PID Loop 데이터 부재
|
||||||
|
- `FICQ-9101`, `FICQ-9214` 등 C4 루프 태그는 Sinam_Tag_all.xlsx에 SourceAddress가 있음
|
||||||
|
- HC Designer CSV는 C4(Custom Map) PID Loop 데이터 없음 (빈 맵)
|
||||||
|
- PointBuilder로 수동 등록 또는 HC Designer Custom Map Export 필요
|
||||||
|
|
||||||
|
### 13.4 Config 게이트웨이 경로
|
||||||
|
- `gateway-config.json`이 구버전 `register-map-c3.json`(2189개 엔트리, HC900명) 참조 중
|
||||||
|
- 신규 `register-map-c3.json`(Experion명) 생성 후 경로 업데이트 필요
|
||||||
|
|
||||||
|
### 13.5 hc900_map_master.tagname = hc900_tag 중복
|
||||||
|
- 현재 `tagname`(소문자 OPC UA 명)과 `hc900_tag`(대문자 Experion 명)가 동일해짐
|
||||||
|
- 향후 `tagname` 컬럼 제거하고 `hc900_tag`로 통일 가능
|
||||||
|
- 마이그레이션 동안은 두 컬럼 모두 동일한 값(Experion 대문자명) 유지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 폐기 항목
|
||||||
|
|
||||||
|
| 항목 | 사유 | 대체 |
|
||||||
|
|------|------|------|
|
||||||
|
| `load_state_labels.py` | Sinam_Tag_all.xlsx 통합 | `build_register_map_from_sinam.py`에 통합 |
|
||||||
|
| HC Designer CSVs (`SignalTags.csv` 등) | 주소 검증용 보조 자료 | Sinam_Tag_all.xlsx가 주 소스 |
|
||||||
|
| OPC UA `node_id` | HC900 직결 방식에서 불필요 | 빈 문자열로 저장 |
|
||||||
|
| 소문자 태그명 (`ficq-6101.pv`) | OPC UA 강제 규칙 폐기 | 대문자 (`FICQ-6101.PV`) |
|
||||||
|
| `build_register_map.py` (기존) | CSV 의존, Experion명 미지원 | `build_register_map_from_sinam.py` |
|
||||||
|
| `build_register_map_from_csv.py` | CSV Full Map 단순 변환 | Sinam 기반 스크립트로 대체 |
|
||||||
|
| 개별 루프 파라미터 → 루프 블록 통째 | 게이트웨이 MAX_BATCH=120 제약 | 개별 파라미터 유지 (게이트웨이가 동적 배치) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 마이그레이션 순서
|
||||||
|
|
||||||
|
### Phase A: 스크립트 완성
|
||||||
|
|
||||||
|
| 단계 | 작업 | 담당 |
|
||||||
|
|:----:|------|------|
|
||||||
|
| A1 | `build_register_map_from_sinam.py` MODE write_addr 버그 수정 (0xBE→0xBA) | Python |
|
||||||
|
| A2 | `build_register_map_from_sinam.py`에 `archive` 필드 추가 + 선별 로직 구현 | Python |
|
||||||
|
| A3 | `build_register_map_from_sinam.py`에 tag_metadata upsert 로직 추가 | Python |
|
||||||
|
| A4 | `build_register_map_from_sinam.py`에 hc900_map_master upsert 로직 추가 | Python |
|
||||||
|
| A5 | C1~C4 전 컨트롤러 register-map 생성 테스트 | Python |
|
||||||
|
|
||||||
|
### Phase B: DB 마이그레이션
|
||||||
|
|
||||||
|
| 단계 | 작업 | 담당 |
|
||||||
|
|:----:|------|------|
|
||||||
|
| B1 | `hc900_map_master`에 `realtime_enabled`, `archive_enabled` DDL 추가 | C# |
|
||||||
|
| B2 | Phase A에서 생성한 register-map으로 hc900_map_master 초기 적재 | Python |
|
||||||
|
|
||||||
|
### Phase C: C# 서비스 수정
|
||||||
|
|
||||||
|
| 단계 | 작업 | 담당 |
|
||||||
|
|:----:|------|------|
|
||||||
|
| C1 | `Hc900MapEntry`에 `RealtimeEnabled`, `ArchiveEnabled` 속성 추가 | C# |
|
||||||
|
| C2 | `Hc900RealtimeService.LoadMappingAsync`에 `AND realtime_enabled = TRUE` 추가 | C# |
|
||||||
|
| C3 | `SnapshotToHistoryAsync`에 `archive_enabled` 필터 적용 | C# |
|
||||||
|
| C4 | PointBuilder UI에 실시간/이력 토글 컬럼 추가 | C# |
|
||||||
|
|
||||||
|
### Phase D: 배포
|
||||||
|
|
||||||
|
| 단계 | 작업 | 담당 |
|
||||||
|
|:----:|------|------|
|
||||||
|
| D1 | C++ 게이트웨이 신규 register-map-c3.json으로 교체 | Config |
|
||||||
|
| D2 | `gateway-config.json` 경로 업데이트 | Config |
|
||||||
|
| D3 | C# 서비스 재시작 | Ops |
|
||||||
|
| D4 | 기존 `archive_enabled = FALSE` → register-map 기준 UPDATE | SQL |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 관련 파일
|
||||||
|
|
||||||
|
| 파일 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| `docs/Sinam_Tag_all.xlsx` | **단일 진실 공급원** — 모든 태그 정의 + 메타데이터 |
|
||||||
|
| `docs/51-52-25-111-HC900-Process-Controller-Communications-manual.pdf` | HC900 통신 매뉴얼 (Table 6-1, 6-3) |
|
||||||
|
| `docs/register-map-cN.json` | 컨트롤러별 레지스터 맵 (build_register_map_from_sinam.py 생성) |
|
||||||
|
| `config/gateway-config.json` | 게이트웨이 프로세스 설정 (IP, port, 맵 경로) |
|
||||||
|
| `scripts/build_register_map_from_sinam.py` | **단일 빌드 스크립트** — 맵 생성 + 메타데이터 + archive 플래그 |
|
||||||
|
| `src/Infrastructure/Hc900/Hc900RealtimeService.cs` | 실시간 폴링 서비스 (gRPC → realtime_table) |
|
||||||
|
| `src/Infrastructure/Hc900/Hc900HistoryService.cs` | 이력 스냅샷 서비스 (realtime_table → history_table) |
|
||||||
|
| `src/Infrastructure/Database/Hc900DbContext.cs` | DB 컨텍스트 (테이블 정의, 마이그레이션 + SnapshotToHistoryAsync) |
|
||||||
|
| `src/Core/Domain/Entities/Hc900Entities.cs` | `Hc900MapEntry` 엔티티 (hc900_map_master 매핑) |
|
||||||
|
| `industrial-comm/cpp/src/gateway.cpp` | C++ 게이트웨이 (Modbus 폴링 + gRPC + 동적 배치 그룹핑) |
|
||||||
|
| `industrial-comm/cpp/src/modbus_tcp.cpp` | Modbus TCP 전송 계층 (FC03, FC16) |
|
||||||
|
| `industrial-comm/cpp/src/vendor_formats.hpp` | FP_B float format (`bigEndian, highFirst, normal`) |
|
||||||
162
docs/작업지시-PV일관성-롤아웃.md
Normal file
162
docs/작업지시-PV일관성-롤아웃.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# 작업지시: 신호점 PV 명명 일관화(.PV) 전체 롤아웃
|
||||||
|
|
||||||
|
> 2026-06-09. `build_register_map_from_sinam.py`는 이미 수정 완료(검증: bare 태그 0).
|
||||||
|
> 이 문서는 **DB/데이터/다운스트림까지 일관 적용**하는 절차. 깨진 중간상태 방지를 위해
|
||||||
|
> 모든 단계를 함께 수행할 것.
|
||||||
|
|
||||||
|
## 진단 결과 (diagnosis-checklist.md 기준)
|
||||||
|
|
||||||
|
| STEP | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| STEP 1-2 | Python 스크립트(`build_register_map_from_sinam.py`) + C# 서비스(Realtime/DigitalEvent/DbContext) |
|
||||||
|
| STEP 3 | 전체 파일 읽음 (670행 Python, 266행 RealtimeService, 199행 DigitalEventDetector, 3144행 DbContext) |
|
||||||
|
| STEP 4 | 호출 계층: DB → RealtimeService(폴링) → DigitalEventDetector(1s 감지) |
|
||||||
|
| STEP 5 | 🔴 HIGH 1건 발견 (아래 참조) |
|
||||||
|
| STEP 6 | 교차검증 통과 — 실제 코드에서 재확인 |
|
||||||
|
| STEP 7-8 | 아래 1건 보고 |
|
||||||
|
|
||||||
|
### [1]. GetDigitalTagNamesAsync 가 bare 태그명 반환 → 디지털 이벤트 감지 실패 (HIGH)
|
||||||
|
|
||||||
|
**문제**: `GetDigitalTagNamesAsync()` (`Hc900DbContext.cs:2273`) 는 `tag_metadata`에서 `base_tag`(예: `LI-6100`) 를 반환하고, `GetDigitalPointsAsync()` (`Hc900DbContext.cs:2303`) 는 이 bare 명으로 `realtime_table`을 조회한다. 그러나 PV 일관화 롤아웃 후 `realtime_table`의 태그명은 `LI-6100.PV`이므로 **매칭 실패 → 디지털 태그 0개 감지 → 이벤트 기록 완전 마비**.
|
||||||
|
|
||||||
|
**근거**:
|
||||||
|
- `Hc900DbContext.cs:2278-2281`: `SELECT DISTINCT BaseTag FROM tag_metadata WHERE attribute LIKE 'state%'` → bare 명 반환
|
||||||
|
- `Hc900DbContext.cs:2303-2304`: `WHERE tagname IN (bare 명들)` → `LI-6100.PV`과 매칭 안 됨
|
||||||
|
- `Hc900DigitalEventDetectorService.cs:71-73`: `GetDigitalTagNamesAsync()` 결과로 `_previousStates` 초기화
|
||||||
|
- `Hc900DigitalEventDetectorService.cs:103`: `GetRealtimeRecordsByTagNamesAsync(queryTags)` → 빈 결과
|
||||||
|
- `Hc900RealtimeService.cs:251-253`: `FormatValue`는 `baseTag = tagname.split('.')[0]`로 이미 `.PV` 대응 완료 → 수정 불필요
|
||||||
|
|
||||||
|
**영향**: 롤아웃 후 디지털 이벤트(TRIP/RUN/ALARM)가 **전부 기록되지 않음**. 운전원 알람 미수신, 이벤트 로그 공백.
|
||||||
|
|
||||||
|
**수정**: `GetDigitalPointsAsync()` 조회 시 bare + `.PV` 명 모두 매칭. `GetDigitalTagPairsAsync()` 반환값에 `.PV` 붙임.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Hc900DbContext.cs:2295-2308 수정
|
||||||
|
public async Task<IEnumerable<RealtimePoint>> GetDigitalPointsAsync()
|
||||||
|
{
|
||||||
|
var digitalTagNames = await GetDigitalTagNamesAsync();
|
||||||
|
var tagSet = new HashSet<string>(digitalTagNames);
|
||||||
|
|
||||||
|
if (tagSet.Count == 0)
|
||||||
|
return Enumerable.Empty<RealtimePoint>();
|
||||||
|
|
||||||
|
// bare 명(LI-6100) + .PV 명(LI-6100.PV) 모두 매칭 — PV 일관화 롤아웃 대응
|
||||||
|
return await _ctx.RealtimePoints
|
||||||
|
.Where(p => tagSet.Contains(p.TagName)
|
||||||
|
|| (p.TagName.LastIndexOf('.') > 0 && tagSet.Contains(p.TagName.Substring(0, p.TagName.LastIndexOf('.')))))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hc900DbContext.cs:2742-2759 수정
|
||||||
|
private async Task<HashSet<(string, string)>> GetDigitalTagPairsAsync()
|
||||||
|
{
|
||||||
|
var fromMetadata = await _ctx.TagMetadata
|
||||||
|
.Where(m => m.Attribute.StartsWith("state") && m.Value != null && m.Value != "")
|
||||||
|
.Select(m => new { m.ControllerId, m.BaseTag })
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (fromMetadata.Any())
|
||||||
|
return fromMetadata.Select(m => (m.ControllerId, m.BaseTag + ".PV")).ToHashSet();
|
||||||
|
|
||||||
|
var fromRealtime = await _ctx.RealtimePoints
|
||||||
|
.Where(p => p.LiveValue != null && p.LiveValue.StartsWith("{"))
|
||||||
|
.Select(p => new { p.ControllerId, p.TagName })
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync();
|
||||||
|
return fromRealtime.Select(p => (p.ControllerId, p.TagName)).ToHashSet();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GetDigitalTagPairsAsync()` 수정으로 `GetDigitalTagPairsCachedAsync()` 사용처(`Hc900DbContext.cs:1863`) 의 디지털 제외 필터도 자연 해결 → history_table에 디지털 쓰레기 데이터 적재 방지.
|
||||||
|
|
||||||
|
## 무엇이 바뀌나
|
||||||
|
|
||||||
|
기존: 신호/상태/아날로그 점의 PV가 **bare**(`LI-6100`, `AG-3202`), 그 외 속성만 suffix
|
||||||
|
(`AG-3202.OP`). → 같은 계기 안에서 PV만 suffix 없는 불일치.
|
||||||
|
|
||||||
|
변경: **모든 PV를 `.PV` suffix로** → 전 레지스터가 `{base}.{param}`.
|
||||||
|
- `LI-6100` → `LI-6100.PV`
|
||||||
|
- `AG-3202` → `AG-3202.PV` (+ `.OP/.MD`)
|
||||||
|
- 변수 `VP-8117` → `.PV/.OP/.MD`, SIG `TI-8117HSET` → `.PV/.SP`
|
||||||
|
- 루프(FICQ 등)는 이미 `.PV` — 영향 없음.
|
||||||
|
|
||||||
|
스크립트 변경점: `build_registers` 의 점 PV 엔트리 `add_entry(item_name, ...)` →
|
||||||
|
`add_entry(f'{item_name}.PV', ...)` (이미 반영됨).
|
||||||
|
|
||||||
|
### 추가: 지시계 흡수(dedup) — 이미 반영됨
|
||||||
|
짧은 지시계 prefix(≥2자, FI/LI/TI/PI…)가 **같은 번호의 더 긴 prefix(그 prefix로 시작하는
|
||||||
|
컨트롤러/적산계, 동일 측정)**에 흡수되어 중복 제거된다.
|
||||||
|
- 예) `FI-6101 → FICQ-6101`, `FI-5115 → FIQ-5115`, `LI-6128 → LICA-6128`, `PI/TI → PICA/TICA`.
|
||||||
|
- 대응 없는 독립 지시계(`FI-3203/3401/3402/6128` 등)는 유지.
|
||||||
|
- `P-`(펌프, 1자), `AG-/XV-/VP-`(교반/밸브/변수)는 ≥2자 규칙 + prefix-startswith 로 **보호**(흡수 안 됨).
|
||||||
|
- 검증(C3): 흡수 61개(FI 28, TI 18, PI 8, LI 7), 비지시계 흡수 0. 레지스터 2185→2123.
|
||||||
|
- 구현: `build_registers` 말미 후처리 — register 필터 + db_conn 시 흡수 base를 map_master/
|
||||||
|
tag_metadata 에서 DELETE(`split_part(...,'.',1)=base`).
|
||||||
|
|
||||||
|
## 롤아웃 순서 (반드시 함께)
|
||||||
|
|
||||||
|
### 1) register-map + map_master + metadata 재생성 (4 컨트롤러)
|
||||||
|
```bash
|
||||||
|
cd /home/windpacer/projects/hc900_ax
|
||||||
|
DSN="host=localhost port=5432 dbname=iiot_platform user=postgres password=postgres options=-csearch_path=hc900"
|
||||||
|
for C in C1 C2 C3 C4; do
|
||||||
|
python3 scripts/build_register_map_from_sinam.py --controller $C \
|
||||||
|
--sinam docs/Sinam_Tag_all.xlsx -o docs/register-map-${C,,}.json --db-conn "$DSN"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
→ map_master 가 새 .PV 태그로 갱신됨. 단, **이전 bare 태그가 map_master에 잔존**
|
||||||
|
(upsert는 삭제 안 함). 아래로 정리:
|
||||||
|
```bash
|
||||||
|
# 각 컨트롤러: register-map JSON 에 없는 map_master 태그 비활성/삭제 (고아 = 옛 bare명)
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json, subprocess
|
||||||
|
for c in ['C1','C2','C3','C4']:
|
||||||
|
js={r['tag'] for r in json.load(open(f'docs/register-map-{c.lower()}.json'))['registers']}
|
||||||
|
out=subprocess.run(["docker","exec","iiot-timescaledb","psql","-U","postgres","-d","iiot_platform","-At","-c",
|
||||||
|
f"SELECT tagname FROM hc900.hc900_map_master WHERE controller_id='{c}'"],capture_output=True,text=True).stdout
|
||||||
|
orphan=set(out.split())-js
|
||||||
|
print(c,'orphan',len(orphan))
|
||||||
|
if orphan:
|
||||||
|
vals=",".join("'"+t.replace("'","''")+"'" for t in orphan)
|
||||||
|
subprocess.run(["docker","exec","iiot-timescaledb","psql","-U","postgres","-d","iiot_platform","-c",
|
||||||
|
f"DELETE FROM hc900.hc900_map_master WHERE controller_id='{c}' AND tagname IN ({vals})"])
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2) 기존 realtime/history 데이터 마이그레이션 (bare → .PV)
|
||||||
|
옛 bare명 데이터를 새 .PV명으로 rename(아날로그 신호점 한정 — 디지털 PV는 history에
|
||||||
|
없음). map_master의 새 .PV 태그 중 옛 bare가 realtime/history에 있던 것만.
|
||||||
|
```sql
|
||||||
|
-- realtime_table: bare → bare||'.PV' (단, 이미 .PV/기타 suffix 없는 신호점만)
|
||||||
|
-- 안전을 위해 map_master 의 .PV 태그에서 base 도출해 매핑.
|
||||||
|
-- (대량 작업 — 백업 후 실행 권장)
|
||||||
|
```
|
||||||
|
> 대안(권장): 데이터 마이그레이션 대신 **realtime_table/history_table의 옛 bare 행은
|
||||||
|
> 폐기**하고 게이트웨이 재시작 후 새 .PV명으로 자연 재적재. history 트렌드 연속성이
|
||||||
|
> 필요하면 rename, 아니면 폐기가 단순.
|
||||||
|
|
||||||
|
### 3) 다운스트림 코드 (C# `src/Infrastructure/Database/Hc900DbContext.cs`)
|
||||||
|
**디지털 검출**: `GetDigitalTagNamesAsync`/`GetDigitalTagPairsAsync` 는 state 라벨 보유
|
||||||
|
**base_tag**(예: `P-9114`)를 반환하는데, 이제 realtime 태그명은 `P-9114.PV`다.
|
||||||
|
→ 반환값을 `base || '.PV'` 로 매핑하도록 수정(또는 detector가 base.PV로 조회).
|
||||||
|
- `Hc900RealtimeService.FormatValue` 는 `baseTag = tagname.split('.')[0]` 라 `.PV`여도
|
||||||
|
state 라벨 적용은 정상(수정 불필요).
|
||||||
|
- `Hc900DigitalEventDetectorService` 는 위 검출목록으로 realtime 조회 → 목록이 `.PV`면 동작.
|
||||||
|
|
||||||
|
### 4) 게이트웨이/앱 재시작 + 검증
|
||||||
|
```bash
|
||||||
|
pkill -f Hc900Crawler; pkill -f hc900_gateway; sleep 3
|
||||||
|
cd src/Hc900Crawler && setsid nohup dotnet run --no-build > /tmp/hc900_app.log 2>&1 < /dev/null &
|
||||||
|
# 검증: 디지털 태그 N개 로드 / 에러 0 / realtime 에 .PV 명 적재
|
||||||
|
```
|
||||||
|
|
||||||
|
## 검증 체크리스트
|
||||||
|
- register-map: bare(suffix 없는) 태그 **0** (모두 `{base}.{param}`).
|
||||||
|
- map_master == register-map JSON (고아 0).
|
||||||
|
- 디지털 검출: `디지털 태그 N개 로드` 로그, 이벤트 정상.
|
||||||
|
- 이력조회 드롭다운: `.PV` 명으로 표시(예: `LI-6100.PV`).
|
||||||
|
|
||||||
|
## 참고
|
||||||
|
- 관련 메모리: `sinam-xlsx-multisection-parsing`, `tag-naming-and-map-master`.
|
||||||
|
- 이 변경은 doc §5.1(AnalogPoint PV=bare)을 **개정**한다 — 일관성 위해 PV도 항상 suffix.
|
||||||
169
docs/작업지시-PointBuilder-Sinam파싱-UI통합.md
Normal file
169
docs/작업지시-PointBuilder-Sinam파싱-UI통합.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# 작업지시: Point Builder 탭에 Sinam xlsx 파싱 UI 통합
|
||||||
|
|
||||||
|
> 2026-06-09. `build_register_map_from_sinam.py`(다중섹션 파서, `.PV` 일관화 + 지시계 흡수
|
||||||
|
> 포함)를 웹 UI에서 구동하도록 Point Builder 탭에 기능 추가. 다른 세션/LLM이 이 문서로
|
||||||
|
> 구현 가능하도록 작성.
|
||||||
|
|
||||||
|
## 진단 결과 (diagnosis-checklist.md 기준)
|
||||||
|
|
||||||
|
| STEP | 결과 |
|
||||||
|
|------|------|
|
||||||
|
| STEP 1-2 | 설계 문서. Point Builder 탭 + Sinam xlsx 파싱 UI 통합. 의존: `PointBuilderController.cs`, `pb.html/js`, `build_register_map_from_sinam.py`, `ControllerProcessManager` |
|
||||||
|
| STEP 3 | 전체 파일 읽음 (설계문서 110행, Controller 204행, pb.html 294행, pb.js 321행, 스크립트 670행, ProcMgr 294행) |
|
||||||
|
| STEP 4 | `pb.js` → `PointBuilderController`(업로드/파싱) → `Process.Start`(Python spawn) → SetupController(게이트웨이 재시작) |
|
||||||
|
| STEP 5 | 🟠 MED 4건, 🟡 LOW 2건 (아래 참조) |
|
||||||
|
| STEP 6 | 교차검증 통과 (실제 코드와 대조 완료) |
|
||||||
|
| STEP 7-8 | 아래 보고 |
|
||||||
|
|
||||||
|
### [1]. 스크립트 stdout이 사람용 텍스트 — C# 파싱 불안정 (MED)
|
||||||
|
|
||||||
|
**문제**: 문서는 `stdout 파싱`을 전제하지만, `build_register_map_from_sinam.py:645-650`의 stdout은 사람용 자유형 텍스트(`f'{len(registers)} registers'`, `f'archive=true: {n_archive}'`, `f'흡수(중복 신호점 제거): {len(absorbed_bases)}개 base...'`). 정규식 파싱이 가능하나 출력 형식 변경 시 C# 파싱이 깨짐.
|
||||||
|
|
||||||
|
**근거**: `build_register_map_from_sinam.py:645-650` — 구조화 출력 없음
|
||||||
|
**영향**: UI 파싱 결과 요약(registers/archive/absorbed/loops)이 항상 0 또는 누락으로 표시됨
|
||||||
|
**수정**: 스크립트 말미에 machine-readable JSON summary 라인 추가 (또는 C#이 register-map JSON을 직접 읽어 결과 추출)
|
||||||
|
|
||||||
|
### [2]. 고아 정리 — `NOT IN`에 수천 개 IN-list는 위험 (MED)
|
||||||
|
|
||||||
|
**문제**: 문서 line 57-61 `NOT IN (<register-map JSON 태그 목록>)` — 수천 개 IN-list로 SQL 길이 폭발, PostgreSQL `NOT IN`의 NULL 처리 문제 발생 가능.
|
||||||
|
|
||||||
|
**근거**: 문서 line 57-61 — `WHERE m.tagname NOT IN (<JSON 태그 목록>)`
|
||||||
|
**영향**: 고아 정리 실패 또는 불완전 수행으로 map_master 잔존 태그 발생
|
||||||
|
**수정**: 임시 staging 테이블 + `NOT EXISTS` 패턴 사용 (문서 §3 반영)
|
||||||
|
|
||||||
|
### [3]. 에러 처리 및 타임아웃 부재 (MED)
|
||||||
|
|
||||||
|
**문제**: 문서 line 33-45에 Process 실패·타임아웃·stderr 캡처 명시 없음. 단발성 프로세스이므로 McpServerHostedService의 헬스체크 패턴과 다름.
|
||||||
|
|
||||||
|
**근거**: 문서 line 33-45 — stdout/결과 반환만 언급, 실패 경로 없음
|
||||||
|
**영향**: xlsx 파싱 실패 시 사용자에게 의미 있는 오류 없이 빈 결과
|
||||||
|
**수정**: 최대 제한시간 + ExitCode 확인 + stderr 캡처 명시 필요
|
||||||
|
|
||||||
|
### [4]. DSN 명령줄 인자 — 공백/따옴표 처리 위험 (MED)
|
||||||
|
|
||||||
|
**문제**: `--db-conn "host=... password=postgres options=-csearch_path=hc900"` — DSN 내 공백 다수. `shell=false` + 리스트 인자 필수, 문서는 `--db-conn "{psycopg2 DSN}"`로만 표기되어 구현자가 `shell=true` 선택 위험.
|
||||||
|
|
||||||
|
**근거**: 문서 line 34-40, 47-52 — DSN 변환 유틸 제시하나 인자 전달 방식 불명확
|
||||||
|
**영향**: 쉘 인젝션 또는 DSN 파싱 실패로 DB 연결 불가
|
||||||
|
**수정**: 문서에 `ArgumentList` 사용 강제 명시
|
||||||
|
|
||||||
|
### [5]. WorkingDirectory 하드코딩 (LOW)
|
||||||
|
|
||||||
|
**문제**: 문서 line 45 — `/home/windpacer/projects/hc900_ax` 절대경로. `McpServerHostedService.cs:19`는 `McpServer:WorkingDirectory` 설정값을 읽는 것과 대조적.
|
||||||
|
|
||||||
|
**근거**: 문서 line 45
|
||||||
|
**영향**: 다른 환경 배포 시 수정 필요
|
||||||
|
**수정**: appsettings.json 설정값 사용
|
||||||
|
|
||||||
|
### [6]. 연속 클릭 방지 구현 부재 (LOW)
|
||||||
|
|
||||||
|
**문제**: 문서 line 98 "버튼 비활성화 + 진행표시" 언급만 있고 pb.js에 구체 로직 없음. 파싱 중복 실행 가능.
|
||||||
|
|
||||||
|
**근거**: 문서 line 98, pb.js `pbSinamParse()` 미구현
|
||||||
|
**영향**: 파싱 중복 실행, DB upsert 충돌
|
||||||
|
**수정**: 버튼 비활성화/재활성화 패턴 명시
|
||||||
|
|
||||||
|
## 목표 (사용자 요구)
|
||||||
|
1. 웹에서 **xlsx 파일 선택(업로드)**.
|
||||||
|
2. **[파싱 시작]** 버튼 → 파싱 실행.
|
||||||
|
3. 파싱된 태그를 **목록에서 add/삭제·활성화/비활성화** (기존 Point Builder 목록 재사용).
|
||||||
|
|
||||||
|
## 설계 결정
|
||||||
|
- **위치 = Point Builder 탭.** 이미 map_master 태그셋 관리(목록·페이징·add·delete·build/apply)를
|
||||||
|
담당 → 파싱(태그셋 생성)을 같은 탭에 두는 게 일관. xlsx = 단일 진실 공급원(CLAUDE.md).
|
||||||
|
- **C#이 Python 스크립트를 spawn(재사용)**, C# 포팅 금지(다중섹션 파싱 중복 방지).
|
||||||
|
- 기존 패턴 재사용: `src/Infrastructure/Mcp/McpServerHostedService.cs`(Process.Start로
|
||||||
|
`uv run` 구동), 파일 업로드 `/api/kb/upload`·`/api/pid/upload`·`/api/docs/upload`.
|
||||||
|
|
||||||
|
## 백엔드 (`src/Hc900Crawler/Controllers/PointBuilderController.cs` 확장)
|
||||||
|
|
||||||
|
### 1) 업로드
|
||||||
|
```
|
||||||
|
POST /api/pointbuilder/sinam/upload (multipart/form-data)
|
||||||
|
```
|
||||||
|
- 검증: 확장자 `.xlsx`, 크기 상한(예: 50MB).
|
||||||
|
- 저장 위치: `docs/uploads/Sinam_<timestamp>.xlsx` (또는 임시 디렉터리). 반환: `{ file: "<path>" }`.
|
||||||
|
|
||||||
|
### 2) 파싱 (dry-run / apply)
|
||||||
|
```
|
||||||
|
POST /api/pointbuilder/sinam/parse
|
||||||
|
body: { file: string, controller: "C1|C2|C3|C4", applyDb: bool }
|
||||||
|
```
|
||||||
|
- C#에서 Process 구동:
|
||||||
|
```
|
||||||
|
python3 scripts/build_register_map_from_sinam.py
|
||||||
|
--controller {controller}
|
||||||
|
--sinam {file}
|
||||||
|
-o docs/register-map-{controller.lower()}.json
|
||||||
|
[--db-conn "{psycopg2 DSN}"] # applyDb=true 일 때만
|
||||||
|
```
|
||||||
|
- `applyDb=false`(dry-run): `--db-conn` 생략 → JSON만 생성, DB 미변경. 결과 미리보기용.
|
||||||
|
- `applyDb=true`: `--db-conn` 포함 → map_master/tag_metadata 갱신(흡수 DELETE 포함).
|
||||||
|
- stdout 파싱해서 반환: `{ registers, archive, absorbed, loops, stdout }`
|
||||||
|
(스크립트는 `N registers`, `흡수(중복 신호점 제거): M개`, `archive=true: K`, `N loops expanded` 출력).
|
||||||
|
- **WorkingDirectory = 프로젝트 루트**(`/home/windpacer/projects/hc900_ax`).
|
||||||
|
|
||||||
|
### DSN 변환 유틸 (필수)
|
||||||
|
C# 연결문자열(.NET) → psycopg2 DSN. appsettings `DefaultConnection`
|
||||||
|
(`Host=localhost;Port=5432;Database=iiot_platform;...;Search Path=hc900`) →
|
||||||
|
`host=localhost port=5432 dbname=iiot_platform user=postgres password=postgres options=-csearch_path=hc900`.
|
||||||
|
- 키 매핑: Host→host, Port→port, Database→dbname, Username→user, Password→password,
|
||||||
|
`Search Path=hc900` → `options=-csearch_path=hc900`.
|
||||||
|
|
||||||
|
### 3) 고아 정리 (apply 후 자동 호출 또는 parse 내부)
|
||||||
|
스크립트는 upsert만 하므로, 재생성된 register-map JSON에 **없는** 해당 컨트롤러 map_master
|
||||||
|
태그를 비활성/삭제해야 한다(롤아웃 문서와 동일):
|
||||||
|
```sql
|
||||||
|
DELETE FROM hc900.hc900_map_master m
|
||||||
|
WHERE m.controller_id = @ctrl
|
||||||
|
AND m.tagname NOT IN (<register-map JSON 태그 목록>);
|
||||||
|
```
|
||||||
|
(JSON 태그를 C#에서 읽어 파라미터화, 또는 임시 staging 테이블 사용.)
|
||||||
|
|
||||||
|
## 프론트엔드 (`wwwroot/panes/pb.html` 상단 카드 + `wwwroot/js/pb.js`)
|
||||||
|
|
||||||
|
### pb.html — 요약 카드 위에 "Sinam 파싱" 카드 추가
|
||||||
|
```html
|
||||||
|
<div class="pb-right-card"> <!-- 또는 상단 풀폭 카드 -->
|
||||||
|
<h3>Sinam xlsx 파싱</h3>
|
||||||
|
<input type="file" id="pb-sinam-file" accept=".xlsx">
|
||||||
|
<select id="pb-sinam-ctrl"><option>C1</option>...<option>C4</option></select>
|
||||||
|
<label><input type="checkbox" id="pb-sinam-apply"> DB 적용(미체크=미리보기)</label>
|
||||||
|
<button class="btn-a" onclick="pbSinamParse()">파싱 시작</button>
|
||||||
|
<button class="btn-c" onclick="pbSinamGwRestart()">게이트웨이 재시작</button>
|
||||||
|
<div id="pb-sinam-result"></div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### pb.js — 함수 추가
|
||||||
|
```js
|
||||||
|
async function pbSinamUpload(file){ /* FormData POST /sinam/upload → {file} */ }
|
||||||
|
async function pbSinamParse(){
|
||||||
|
// 1) 파일 업로드 → path
|
||||||
|
// 2) POST /sinam/parse {file, controller, applyDb}
|
||||||
|
// 3) 결과(registers/absorbed/loops) 표시
|
||||||
|
// 4) applyDb면 pbRefresh()+pbLoadSummary() 로 목록 갱신
|
||||||
|
}
|
||||||
|
function pbSinamGwRestart(){ /* 해당 컨트롤러 게이트웨이 재시작 API 호출 */ }
|
||||||
|
```
|
||||||
|
- 파싱 후 기존 **"전체 태그 목록"이 자동 갱신**되면 add/삭제·활성/비활성은 기존 기능 그대로 사용.
|
||||||
|
|
||||||
|
## 안전장치 (파싱 = 파괴적 재생성)
|
||||||
|
1. **dry-run → 확인 → 적용** 2단계. `applyDb=false`로 먼저 미리보기, 사용자 확인 후 적용.
|
||||||
|
2. **적용 시 confirm()** ("해당 컨트롤러 태그셋을 xlsx 기준으로 재생성합니다").
|
||||||
|
3. **고아 정리** 자동 수행(위 SQL).
|
||||||
|
4. **게이트웨이 재시작**: register-map JSON 변경 → 해당 컨트롤러 게이트웨이 재기동해야 새 태그
|
||||||
|
폴링. (`ControllerProcessManager` 재시작 엔드포인트 활용/추가.)
|
||||||
|
5. **동시성/장시간**: 파싱 수 초 소요 → 버튼 비활성화 + 진행표시. 대용량 업로드 타임아웃 주의.
|
||||||
|
|
||||||
|
## 선행 조건 (순서 중요)
|
||||||
|
지금 스크립트엔 **`.PV` 일관화 + 지시계 흡수**가 들어있다(`docs/작업지시-PV일관성-롤아웃.md`).
|
||||||
|
UI 파싱이 이를 그대로 적용하면 기존 bare명(realtime/history)·디지털 검출과 불일치가 생긴다.
|
||||||
|
→ **PV 롤아웃(다운스트림 디지털 검출 코드 수정 + 데이터 마이그레이션)을 먼저 완료**한 뒤
|
||||||
|
UI 파싱 표준화를 적용할 것. 그 전엔 UI 파싱을 dry-run 전용으로 두는 것도 방법.
|
||||||
|
|
||||||
|
## 참고
|
||||||
|
- 스크립트 CLI: `--controller`(필수) `--sinam`(필수) `-o`(출력) `--db-conn`(있으면 DB 적용)
|
||||||
|
`--validate-csv`(선택).
|
||||||
|
- 관련 메모리: `sinam-xlsx-multisection-parsing`, `tag-naming-and-map-master`.
|
||||||
|
- 관련 문서: `작업지시-PV일관성-롤아웃.md`, `작업지시-history-디지털잔재정리.md`.
|
||||||
95
docs/작업지시-history-디지털잔재정리.md
Normal file
95
docs/작업지시-history-디지털잔재정리.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# 작업지시: hc900.history_table 디지털 상태점 잔재 정리
|
||||||
|
|
||||||
|
> 다른 LLM/작업자가 이 문서만으로 실행할 수 있도록 작성. 2026-06-09 기준.
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
`hc900.history_table`에는 **디지털 상태점**(예: `AG-3202`, `C3601_RUN`, `CH-5601` —
|
||||||
|
`{0 | L-STOP | }` 형태, state 라벨 보유)의 과거 이력 약 **15만 행**이 잘못 쌓여 있다.
|
||||||
|
이들은 상태변경 이벤트라 `event_history_table`로 가야 하며 `history_table`(연속
|
||||||
|
측정값 60초 스냅샷)에는 있으면 안 된다.
|
||||||
|
|
||||||
|
이미 처리된 것(배경):
|
||||||
|
- register-map 재작성으로 디지털 상태점(StatusPoint PV)은 map_master에서
|
||||||
|
`archive_enabled = FALSE`로 설정됨.
|
||||||
|
- 실행 중 `SnapshotToHistoryAsync`는 `archive_enabled` + 디지털 제외(state 라벨)로
|
||||||
|
**신규 디지털 행을 더는 적재하지 않음**(검증됨: 재시작 후 신규 유입 0).
|
||||||
|
- 따라서 **남은 과거 잔재만 삭제**하면 된다. (비가역 → 반드시 백업 먼저)
|
||||||
|
|
||||||
|
## 판정 기준
|
||||||
|
|
||||||
|
`history_table`에서 **archive_enabled가 아닌** (controller_id, tagname) 행을 삭제한다.
|
||||||
|
이는 디지털 상태점 + 루프 내부 파라미터 등 비-archive 태그를 모두 정리한다.
|
||||||
|
유지: archive_enabled=TRUE (루프 PV/SP/OP/MD/QV + 아날로그 신호태그).
|
||||||
|
|
||||||
|
> 주의: 행수는 시점에 따라 변하므로 **실행 시점에 live로 재계산**할 것. 아래는 절차.
|
||||||
|
|
||||||
|
## 실행 절차 (복붙 가능)
|
||||||
|
|
||||||
|
### 1) 규모 확인
|
||||||
|
```bash
|
||||||
|
docker exec iiot-timescaledb psql -U postgres -d iiot_platform -At -c "
|
||||||
|
WITH arch AS (SELECT controller_id, tagname FROM hc900.hc900_map_master WHERE archive_enabled)
|
||||||
|
SELECT 'keep_rows', count(*) FROM hc900.history_table h
|
||||||
|
WHERE EXISTS (SELECT 1 FROM arch a WHERE a.tagname=h.tagname AND (a.controller_id=h.controller_id OR h.controller_id IS NULL))
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'delete_rows', count(*) FROM hc900.history_table h
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM arch a WHERE a.tagname=h.tagname AND (a.controller_id=h.controller_id OR h.controller_id IS NULL));
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2) 백업 (gzip CSV) — 삭제 대상 행
|
||||||
|
```bash
|
||||||
|
BK=/home/windpacer/db_backups; mkdir -p "$BK"
|
||||||
|
TS=$(date +%Y%m%d)
|
||||||
|
docker exec iiot-timescaledb psql -U postgres -d iiot_platform -c "
|
||||||
|
COPY (
|
||||||
|
SELECT h.* FROM hc900.history_table h
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM hc900.hc900_map_master a
|
||||||
|
WHERE a.archive_enabled AND a.tagname=h.tagname
|
||||||
|
AND (a.controller_id=h.controller_id OR h.controller_id IS NULL))
|
||||||
|
) TO STDOUT WITH CSV HEADER" | gzip > "$BK/history_nonarchive_digital_$TS.csv.gz"
|
||||||
|
echo "backup rows: $(( $(zcat "$BK/history_nonarchive_digital_$TS.csv.gz" | wc -l) - 1 ))"
|
||||||
|
ls -lh "$BK/history_nonarchive_digital_$TS.csv.gz"
|
||||||
|
```
|
||||||
|
→ 백업 행수가 1단계 `delete_rows`와 **정확히 일치**하는지 확인. 불일치면 중단.
|
||||||
|
|
||||||
|
### 3) 삭제 (트랜잭션)
|
||||||
|
```bash
|
||||||
|
docker exec iiot-timescaledb psql -U postgres -d iiot_platform -v ON_ERROR_STOP=1 -c "
|
||||||
|
BEGIN;
|
||||||
|
DELETE FROM hc900.history_table h
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM hc900.hc900_map_master a
|
||||||
|
WHERE a.archive_enabled AND a.tagname=h.tagname
|
||||||
|
AND (a.controller_id=h.controller_id OR h.controller_id IS NULL));
|
||||||
|
COMMIT;
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4) 검증
|
||||||
|
```bash
|
||||||
|
docker exec iiot-timescaledb psql -U postgres -d iiot_platform -At -c "
|
||||||
|
WITH dig AS (SELECT DISTINCT base_tag FROM hc900.tag_metadata WHERE attribute LIKE 'state%' AND value<>'')
|
||||||
|
SELECT 'history_rows', count(*)::text FROM hc900.history_table
|
||||||
|
UNION ALL SELECT 'history_distinct_tags', count(DISTINCT tagname)::text FROM hc900.history_table
|
||||||
|
UNION ALL SELECT 'digital_tags_left (0이어야)', count(*)::text FROM (
|
||||||
|
SELECT DISTINCT tagname FROM hc900.history_table WHERE tagname IN (SELECT base_tag FROM dig)) x;
|
||||||
|
"
|
||||||
|
```
|
||||||
|
→ `digital_tags_left = 0`, 남은 태그는 archive_enabled 집합과 일치해야 한다.
|
||||||
|
|
||||||
|
## 복구 (필요 시)
|
||||||
|
```bash
|
||||||
|
zcat /home/windpacer/db_backups/history_nonarchive_digital_<TS>.csv.gz | \
|
||||||
|
docker exec -i iiot-timescaledb psql -U postgres -d iiot_platform \
|
||||||
|
-c "COPY hc900.history_table FROM STDIN WITH CSV HEADER"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 참고
|
||||||
|
- DB: PostgreSQL/TimescaleDB 컨테이너 `iiot-timescaledb`, DB `iiot_platform`, 스키마 `hc900`.
|
||||||
|
- `history_table`은 일반 테이블(하이퍼테이블 아님). 대량 삭제 후 디스크 회수는
|
||||||
|
autovacuum이 점진 처리(즉시 필요 시 `VACUUM` — 라이브 테이블이라 `VACUUM FULL` 락 주의).
|
||||||
|
- 관련: 디지털 검출 기준은 `tag_metadata`의 state 라벨 보유(과거 `i=7594` 폐기).
|
||||||
|
값 N → state{N} 라벨, realtime는 `{N | label | }` 포맷(`Hc900RealtimeService.FormatValue`).
|
||||||
338
docs/작업플랜-온도프로파일-과거이력.md
Normal file
338
docs/작업플랜-온도프로파일-과거이력.md
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
# 작업플랜 — 온도 프로파일 과거 이력 구현 (2026-06-07)
|
||||||
|
|
||||||
|
## 진단 결과 (2026-06-07 checklist 기반)
|
||||||
|
|
||||||
|
| # | 항목 | 심각도 | 결론 |
|
||||||
|
|---|------|--------|------|
|
||||||
|
| 1 | `IExperionDbService` DI 등록 — 계획서 "변경 없음" 주석 오류 | LOW | 생성자에 `IExperionDbService db` 추가 필요 |
|
||||||
|
| 2 | `QueryHistoryWithIntervalAsync` 시그니처 — 계획서 서술과 DTO 불일치 | MED | `HistoryIntervalQueryRequest` DTO 객체 생성 필요. 서술만 명확히 하면 됨 |
|
||||||
|
| 3 | `stRenderTemp()` 시그니처 변경 — 기존 호환성 | LOW | JS optional 인자 처리로 기존 동작 유지 |
|
||||||
|
| 4 | `stTempLoad()` 버튼 — 초기 히스토리 미로드 | LOW | "조회" 버튼 클릭 시 `stTempHistoryLoad()`도 함께 호출 권장 |
|
||||||
|
| 5 | ECharts 64 series 5초 재렌더링 — GPU 메모리 누수 가능성 | LOW | history series는 한 번만 추가, 5초 폴링에서는 data만 업데이트하는 분리 필요 |
|
||||||
|
| 6 | 타임존 직렬화 — KST/UTC | LOW | `toISOString()` 자동 보정으로 실제 문제 없음 |
|
||||||
|
| 7 | 헬퍼 메서드 추출 — 계획서 서술 누락 | LOW | `MatchProduct()`, `ComputeStages()` 시그니처 명시 추가 필요 |
|
||||||
|
| 8 | `time_bucket` 비어있는 bucket — null 보간 필요 | LOW | null 값 전후 값으로 보간하거나 차트에서 연결 |
|
||||||
|
|
||||||
|
**HIGH 항목 없음**. 계획서 전체적으로 구현 가능성 높음. 주요 수정사항 3건: #1(DI 생성자 변경), #4(조회 버튼 시 초기 히스토리 로드), #5(ECharts series 관리 분리).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 1 — 맥락 파악
|
||||||
|
|
||||||
|
**현재 동작**: `steam.html:62-73` "온도 프로파일" 탭 → `steam.js:90-151` 5초 주기 `GET /api/steam/tempprofile/{col}` → `SteamAdvisorController.cs:180-247`가 `realtime_table`에서 현재값 5종(reb_temp/T_B/T_C/T_D/vacuum)만 조회 → ECharts에 "현재" 단일 라인으로 표시. 기준밴드(±2σ)는 `gen_temp_profiles.py`가 산출한 `{col}_tempref.json` 정적 파일.
|
||||||
|
|
||||||
|
**한계**: 과거 시점의 프로파일을 볼 수 없음. 운전자는 "30분 전 프로파일 vs 지금" 비교 불가. 이격(drift)이 언제 시작되었는지 추적 불가.
|
||||||
|
|
||||||
|
**사용 가능한 이력 인프라**: `history_table`(TimeScaleDB hypertable, 60초 스냅샷), `POST /api/history/query-interval` (time_bucket 집계), `IExperionDbService.QueryHistoryWithIntervalAsync`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 2 — 호출 계층 지도 (변경 대상)
|
||||||
|
|
||||||
|
```
|
||||||
|
steam.html (st-temp pane)
|
||||||
|
└─ steam.js stTempTick() ← 5s setInterval, GET /api/steam/tempprofile/{col}
|
||||||
|
└─ stRenderTemp(d) ← ECharts option.set ({기준밴드, 기준, 현재} 3 series)
|
||||||
|
|
||||||
|
SteamAdvisorController:
|
||||||
|
GET /api/steam/tempprofile/{col}
|
||||||
|
├─ read tempref.json ← 정적 기준밴드
|
||||||
|
├─ TagsFor(suffix) → 5개 태그명
|
||||||
|
├─ realtime_table 조회 (현재값)
|
||||||
|
└─ 제품매칭 + z-score → 단일 snapshot 반환
|
||||||
|
|
||||||
|
IExperionDbService (기존):
|
||||||
|
POST /api/history/query ← raw 조회
|
||||||
|
POST /api/history/query-interval ← time_bucket 집계
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 3 — 요구사항
|
||||||
|
|
||||||
|
| # | 요구사항 | 우선순위 |
|
||||||
|
|---|---------|---------|
|
||||||
|
| R1 | 과거 특정 시점의 온도 프로파일을 현재와 중첩 표시 | P0 |
|
||||||
|
| R2 | 시간 범위 선택 UI (30분/1시간/4시간/오늘/사용자지정) | P0 |
|
||||||
|
| R3 | 단계별 온도 추세를 미니 차트로 함께 표시 | P1 |
|
||||||
|
| R4 | z-score 추세 (어느 단계에서 이격이 시작되었는가) | P2 |
|
||||||
|
| R5 | 애니메이션 재생 (시간 경과에 따른 프로파일 변화) | P3 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 4 — 구현 구성
|
||||||
|
|
||||||
|
### 4-1. 백엔드: 신규 엔드포인트 `GET /api/steam/tempprofile/{col}/history`
|
||||||
|
|
||||||
|
**파일**: `SteamAdvisorController.cs` (lines 180-247 인접)
|
||||||
|
|
||||||
|
**시그니처**:
|
||||||
|
```csharp
|
||||||
|
[HttpGet("tempprofile/{col}/history")]
|
||||||
|
public async Task<IActionResult> TempProfileHistory(
|
||||||
|
string col,
|
||||||
|
[FromQuery] DateTime? from = null,
|
||||||
|
[FromQuery] DateTime? to = null,
|
||||||
|
[FromQuery] int limit = 100)
|
||||||
|
```
|
||||||
|
|
||||||
|
**동작**:
|
||||||
|
1. `from`/`to` 기본값: from=now-1h, to=now
|
||||||
|
2. `TagsFor(ToSuffix(col))`로 5개 태그명 획득
|
||||||
|
3. `IExperionDbService.QueryHistoryWithIntervalAsync()` 호출 — interval="1 minute", tags=5개 태그
|
||||||
|
4. 각 time_bucket 행마다 현재 TempProfile과 동일한 제품매칭+z-score 로직 적용
|
||||||
|
5. profile 스냅샷 배열 반환
|
||||||
|
|
||||||
|
**응답 구조**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"column": "C-6111",
|
||||||
|
"from": "...",
|
||||||
|
"to": "...",
|
||||||
|
"interval": "1 minute",
|
||||||
|
"n": 60,
|
||||||
|
"time_snapshots": [
|
||||||
|
{
|
||||||
|
"ts": "2026-06-07T10:00:00Z",
|
||||||
|
"matchedProduct": "P0",
|
||||||
|
"stages": [
|
||||||
|
{"stage": "reb_temp", "value": 128.5, "z": 0.3, "deviated": false},
|
||||||
|
...
|
||||||
|
],
|
||||||
|
"vacuum": {"value": 120, "z": -0.5, "deviated": false},
|
||||||
|
"spanAD": 45.2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 최소화**: 제품매칭 로직은 기존 TempProfile과 공유. 헬퍼 메서드(`MatchProduct`, `ComputeZ`, `ComputeStage`)로 추출.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4-2. 프론트엔드: 시간 범위 선택 UI
|
||||||
|
|
||||||
|
**파일**: `steam.html` (st-temp pane, lines 62-73)
|
||||||
|
|
||||||
|
**변경**:
|
||||||
|
- "조회" 버튼 우측에 시간 범위 버튼 그룹 추가: `[30분] [1시간] [4시간] [오늘]`
|
||||||
|
- "과거 프로파일" legend 항목 추가 (캡션용)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="st-bt-bar">
|
||||||
|
컬럼: <select id="st-temp-col">...</select>
|
||||||
|
<button class="btn-a" id="st-temp-load">조회</button>
|
||||||
|
<span class="st-temp-range-label">과거:</span>
|
||||||
|
<button class="st-temp-range active" data-range="30m">30분</button>
|
||||||
|
<button class="st-temp-range" data-range="1h">1시간</button>
|
||||||
|
<button class="st-temp-range" data-range="4h">4시간</button>
|
||||||
|
<button class="st-temp-range" data-range="today">오늘</button>
|
||||||
|
<span id="st-temp-status" style="margin-left:8px;font-size:11px;color:#888"></span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4-3. 프론트엔드: `stRenderTemp()` 개선 + 과거 히스토리 렌더러
|
||||||
|
|
||||||
|
**파일**: `steam.js` (lines 77-151)
|
||||||
|
|
||||||
|
**변경**:
|
||||||
|
|
||||||
|
#### 4-3a. `stTempTick()` — 5초 폴링 유지, 현재 스냅샷만 갱신
|
||||||
|
|
||||||
|
기존 5초 폴링 유지. 단, 히스토리 데이터는 분리 관리.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
let stTempHistory = null; // { time_snapshots: [...] }
|
||||||
|
let stTempHistoryRange = '30m'; // 현재 선택된 범위
|
||||||
|
|
||||||
|
async function stTempTick() {
|
||||||
|
const col = document.getElementById('st-temp-col').value;
|
||||||
|
const st = document.getElementById('st-temp-status');
|
||||||
|
try {
|
||||||
|
const d = await api('GET', `/api/steam/tempprofile/${col}`);
|
||||||
|
stRenderTemp(d, stTempHistory);
|
||||||
|
st.textContent = '갱신: ' + new Date().toLocaleTimeString();
|
||||||
|
} catch (e) {
|
||||||
|
st.textContent = '오류: ' + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4-3b. `stTempHistoryLoad(col, range)` — 히스토리 로드 신규
|
||||||
|
|
||||||
|
범위 변경/초기 로드시만 호출. 응답 캐싱.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function stTempHistoryLoad() {
|
||||||
|
const col = document.getElementById('st-temp-col').value;
|
||||||
|
const range = stTempHistoryRange;
|
||||||
|
const now = new Date();
|
||||||
|
let from;
|
||||||
|
if (range === '30m') from = new Date(now - 30*60000);
|
||||||
|
else if (range === '1h') from = new Date(now - 3600000);
|
||||||
|
else if (range === '4h') from = new Date(now - 4*3600000);
|
||||||
|
else if (range === 'today') {
|
||||||
|
from = new Date(now); from.setHours(0,0,0,0);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const h = await api('GET',
|
||||||
|
`/api/steam/tempprofile/${col}/history?from=${from.toISOString()}&to=${now.toISOString()}&limit=500`);
|
||||||
|
stTempHistory = h;
|
||||||
|
// 현재 스냅샷과 함께 재렌더링
|
||||||
|
const d = await api('GET', `/api/steam/tempprofile/${col}`);
|
||||||
|
stRenderTemp(d, stTempHistory);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[steam] history load fail:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4-3c. `stRenderTemp(d, history?)` — ECharts series 확장
|
||||||
|
|
||||||
|
기존 3 series(기준밴드/기준/현재) 유지 + history.time_snapshots를 추가 series로:
|
||||||
|
|
||||||
|
- **방식 A (단순)**: N개 스냅샷의 각 stage 평균을 구해 "과거 평균" 하나의 라인 + 반투명 밴드
|
||||||
|
- **방식 B (상세)**: N개 스냅샷을 개별 반투명 라인으로 표시 (hover 시 툴팁)
|
||||||
|
- **방식 C (추천)**: "과거 평균" 라인 + "최소/최대 밴드" + "현재" 강조 라인
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function stRenderTemp(d, history) {
|
||||||
|
const cats = d.stages.map(s => ST_STAGE_LABEL[s.stage] || s.stage);
|
||||||
|
const lo = d.stages.map(s => ...); // 기준 -2σ
|
||||||
|
const band = d.stages.map(s => ...); // 4σ
|
||||||
|
const med = d.stages.map(s => ...); // 기준 median
|
||||||
|
const cur = d.stages.map(s => ...); // 현재값 (굵은 파랑)
|
||||||
|
|
||||||
|
const series = [
|
||||||
|
{ name: '_lo', type: 'line', data: lo, ... },
|
||||||
|
{ name: '기준밴드', type: 'line', data: band, ... },
|
||||||
|
{ name: '기준', type: 'line', data: med, ... },
|
||||||
|
{ name: '현재', type: 'line', data: cur, ... },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 과거 히스토리 overlay
|
||||||
|
if (history && history.time_snapshots?.length > 1) {
|
||||||
|
const snapshots = history.time_snapshots;
|
||||||
|
// 과거 스냅샷별 라인 (최대 60개 제한, 투명도 0.1)
|
||||||
|
const maxHistory = Math.min(snapshots.length, 60);
|
||||||
|
for (let i = 0; i < maxHistory; i++) {
|
||||||
|
const s = snapshots[i];
|
||||||
|
const snapData = cats.map((_, j) => s.stages[j]?.value ?? null);
|
||||||
|
series.push({
|
||||||
|
name: s.ts, type: 'line', data: snapData,
|
||||||
|
lineStyle: { color: '#446', width: 1, opacity: 0.15 },
|
||||||
|
symbol: 'none', silent: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 과거 평균 라인 (선택)
|
||||||
|
const avgData = cats.map((_, j) => {
|
||||||
|
const vals = snapshots.map(s => s.stages[j]?.value).filter(v => v != null);
|
||||||
|
return vals.length ? vals.reduce((a,b) => a+b, 0) / vals.length : null;
|
||||||
|
});
|
||||||
|
series.push({
|
||||||
|
name: '과거평균', type: 'line', data: avgData,
|
||||||
|
lineStyle: { color: '#888', width: 1, type: 'dashed' }, symbol: 'none',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
chart.setOption({ ...series, ... });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4-3d. (P1) 단계별 추세 미니차트 `stRenderTempTrends(history)`
|
||||||
|
|
||||||
|
별도 ECharts 인스턴스 4~5개. x축=시간, y축=온도, 수평 기준밴드±2σ, 현재값 점 강조.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4-4. DI 등록
|
||||||
|
|
||||||
|
**파일**: `Program.cs`
|
||||||
|
|
||||||
|
`SteamAdvisorController`에 `IExperionDbService` 주입 필요 (현재는 `Hc900DbContext`만 주입).
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 변경 없음 — IExperionDbService는 이미 Program.cs에 등록되어 있음
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 5 — 주의사항 (교차 검증)
|
||||||
|
|
||||||
|
| Q | 질문 | 확인 |
|
||||||
|
|---|------|------|
|
||||||
|
| Q1 | 기존 Tempprofile 5초 폴링 유지? | 유지. 히스토리 로드는 최초/범위변경시만 별도 호출 — 트래픽 증가 없음 |
|
||||||
|
| Q2 | 제품매칭 로직 중복? | 헬퍼로 추출하여 `TempProfile`과 `TempProfileHistory`가 공유 |
|
||||||
|
| Q3 | history_table 데이터 볼륨? | 60초×5태그×1h=300행, 4h=1200행 — 부하 무시 가능. interval은 1분 고정 |
|
||||||
|
| Q4 | OOD 기간 처리? | OOD 기간의 profile은 z-score가 커도 "deviated=true"로만 표시, 제외하지 않음 |
|
||||||
|
| Q5 | 컬럼명칭 통일 선행 필요? | `작업플랜-스팀컬럼명칭통일.md` 완료 후 이 작업 시작. `TagsFor("6111")` 정상 동작 전제 |
|
||||||
|
| Q6 | ECharts series 과다? | 60개 history 라인 + 4개 기준 series = 64 series. ECharts는 수백 series까지 무리 없음. 단, tooltip trigger 최적화 필요(cursor로 변경) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 6 — 작업 순서
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 컬럼명칭 통일 완료 (선행)
|
||||||
|
└─ 작업플랜-스팀컬럼명칭통일.md
|
||||||
|
|
||||||
|
2. SteamAdvisorController — 헬퍼 추출
|
||||||
|
└─ MatchProduct(), ComputeStages()를 TempProfile에서 분리
|
||||||
|
└─ IExperionDbService 생성자 주입
|
||||||
|
|
||||||
|
3. SteamAdvisorController — GET /api/steam/tempprofile/{col}/history 신규
|
||||||
|
└─ QueryHistoryWithIntervalAsync → profile snapshot 배열
|
||||||
|
└─ 제품매칭+z-score 루프
|
||||||
|
|
||||||
|
4. steam.js — 시간범위 선택 UI + stTempHistoryLoad()
|
||||||
|
└─ 범위 버튼, 이벤트 핸들러, API 호출, 캐싱
|
||||||
|
|
||||||
|
5. steam.js — stRenderTemp() 확장 (과거 overlay)
|
||||||
|
└─ history.time_snapshots → 반투명/ECharts series
|
||||||
|
|
||||||
|
6. steam.html — 범위 선택 마크업 추가
|
||||||
|
└─ st-temp-range 버튼 그룹, CSS 스타일
|
||||||
|
|
||||||
|
7. (P1) steam.js — 단계별 추세 미니차트 (stRenderTempTrends)
|
||||||
|
|
||||||
|
8. 검증
|
||||||
|
└─ dotnet build 성공
|
||||||
|
└─ /api/steam/tempprofile/C-6111/history?from=...&to=... 정상 응답
|
||||||
|
└─ UI 30분 클릭 → 과거 라인 overlay 확인
|
||||||
|
└─ 5초 폴링 유지 → 현재 라인만 갱신
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 7 — 검증 시나리오
|
||||||
|
|
||||||
|
| # | 시나리오 | 기대 결과 |
|
||||||
|
|---|---------|----------|
|
||||||
|
| 1 | `GET /api/steam/tempprofile/C-6111/history?from=2026-06-07T09:00:00Z&to=2026-06-07T10:00:00Z` | 60개 스냅샷 + 각 스냅샷의 stages/matchedProduct 반환 |
|
||||||
|
| 2 | "30분" 버튼 클릭 | 30개 스냅샷 로드 → 차트에 반투명 과거 라인들 |
|
||||||
|
| 3 | 현재 5초 폴링 유지 확인 | "현재" 라인만 갱신, 히스토리 라인 변화 없음 |
|
||||||
|
| 4 | 범위 변경 (30분→4시간) | 히스토리 재조회 → overlay 업데이트 |
|
||||||
|
| 5 | missing_tags 상태에서 히스토리 | 빈 배열 반환, 차트는 기존 3 series만 표시 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 8 — 파일 변경 요약
|
||||||
|
|
||||||
|
| 파일 | 변경 내용 |
|
||||||
|
|------|---------|
|
||||||
|
| `SteamAdvisorController.cs` | +DI IExperionDbService, +헬퍼 추출, +`TempProfileHistory` endpoint |
|
||||||
|
| `steam.html` | +st-temp-range 버튼 그룹, CSS |
|
||||||
|
| `steam.js` | +`stTempHistory`, +`stTempHistoryRange`, +`stTempHistoryLoad()`, `stRenderTemp()` 확장, (P1)`stRenderTempTrends()` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STEP 9 — 리스크
|
||||||
|
|
||||||
|
| 항목 | 수준 | 대응 |
|
||||||
|
|------|------|------|
|
||||||
|
| history_table에 60초 간격보다 더 상세한 데이터 없음 | 낮 | 1분 간격이면 충분. 추세/이격 감지에 무리 없음 |
|
||||||
|
| ECharts series 60+개 렌더링 성능 | 낮 | canvas 기반, opacity 0.15로 가벼움. 필요시 `sampling: 'lttb'` |
|
||||||
|
| 제품매칭이 history snapshot마다 달라짐 | 중 | 동일 snapshot 내에선 일관됨. UI에 matchedProduct 변화 표시 |
|
||||||
|
| TagsFor 태그가 register-map에 없는 컬럼 (9·10차) | 중 | 컬럼명칭 통일 후에도 해당 태그가 없으면 `missing_tags` — 사전 등록 필요 |
|
||||||
479
docs/작업플랜-온도프로파일-기준선택.md
Normal file
479
docs/작업플랜-온도프로파일-기준선택.md
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
# 온도 프로파일 기준 프로파일 선택/생성 기능 — 상세 설계
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
현재 기준 프로파일(`{col}_tempref.json`)은 **2026-02-05~2026-06-05 고정**.
|
||||||
|
제품 구성·원료·계절 변화에 기준이 부정확해져 이격 감지 신뢰도 하락.
|
||||||
|
운전자가 특정 기간 기준을 여러 개 만들어 두고 전환 가능해야 함.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Solution: DB 저장 (Phase B, 권장)
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
[Python: gen/profiles] ──HTTP──▶ [POST /api/steam/tempprofile/{col}/profiles]
|
||||||
|
│
|
||||||
|
[temp_ref_profiles table]
|
||||||
|
│
|
||||||
|
[Browser] ──GET /api/steam/tempprofile/{col}?profile_id=N──▶ [LoadTempRef(id)] ──▶ [ComputeStages]
|
||||||
|
```
|
||||||
|
|
||||||
|
파일 I/O 없음, 여러 서버에서 동기화 문제 없음, 관리 API로 확장 용이.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1. DB 테이블
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE hc900.temp_ref_profiles (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
column_key TEXT NOT NULL, -- "C-6111"
|
||||||
|
label TEXT NOT NULL, -- "기본", "recent-30d", "2026-05-w1"
|
||||||
|
description TEXT NOT NULL DEFAULT '', -- "최근 30일(2026-05-08~2026-06-07)"
|
||||||
|
period_from TIMESTAMPTZ NOT NULL,
|
||||||
|
period_to TIMESTAMPTZ NOT NULL,
|
||||||
|
data JSONB NOT NULL, -- Tempref 전체 {stages_order, n_products, products: [...]}
|
||||||
|
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 컬럼별 조회 + 기본값 정렬
|
||||||
|
CREATE INDEX idx_trp_column ON hc900.temp_ref_profiles(column_key);
|
||||||
|
CREATE UNIQUE INDEX idx_trp_column_default ON hc900.temp_ref_profiles(column_key) WHERE is_default = TRUE;
|
||||||
|
```
|
||||||
|
|
||||||
|
**data 컬럼 JSONB 구조** (기존 `{col}_tempref.json`과 동일):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"stages_order": ["reb_temp", "T_B", "T_C", "T_D"],
|
||||||
|
"n_products": 3,
|
||||||
|
"products": [
|
||||||
|
{
|
||||||
|
"label": "P0",
|
||||||
|
"n_rows": 1240,
|
||||||
|
"span_AD": 42.5,
|
||||||
|
"vacuum": { "median": 48.2, "std": 1.5 },
|
||||||
|
"stages": {
|
||||||
|
"reb_temp": { "median": 176.2, "std": 2.1 },
|
||||||
|
"T_B": { "median": 112.5, "std": 3.2 },
|
||||||
|
"T_C": { "median": 88.7, "std": 4.5 },
|
||||||
|
"T_D": { "median": 66.2, "std": 2.8 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Hc900DbContext`에 `DbSet<TempRefProfileEntity>` 추가.
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| Entity 클래스 | `TempRefProfileEntity` (내부 클래스 또는 별도 파일) |
|
||||||
|
| 테이블명 | `temp_ref_profiles` |
|
||||||
|
| 스키마 | `hc900` |
|
||||||
|
| EF Core | `modelBuilder.Entity<TempRefProfileEntity>(e => { e.ToTable("temp_ref_profiles"); ... })` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Entity 클래스
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Infrastructure/Database/ 경로
|
||||||
|
public sealed class TempRefProfileEntity
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string ColumnKey { get; set; } = "";
|
||||||
|
public string Label { get; set; } = "";
|
||||||
|
public string Description { get; set; } = "";
|
||||||
|
public DateTime PeriodFrom { get; set; }
|
||||||
|
public DateTime PeriodTo { get; set; }
|
||||||
|
public string Data { get; set; } = ""; // JSON serialized TempRef
|
||||||
|
public bool IsDefault { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. EF Core 매핑 (`Hc900DbContext`)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public DbSet<TempRefProfileEntity> TempRefProfiles => Set<TempRefProfileEntity>();
|
||||||
|
|
||||||
|
// OnModelCreating:
|
||||||
|
modelBuilder.Entity<TempRefProfileEntity>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("temp_ref_profiles");
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
e.Property(x => x.ColumnKey).HasColumnName("column_key").IsRequired();
|
||||||
|
e.Property(x => x.Label).IsRequired();
|
||||||
|
e.Property(x => x.Description);
|
||||||
|
e.Property(x => x.PeriodFrom).HasColumnName("period_from");
|
||||||
|
e.Property(x => x.PeriodTo).HasColumnName("period_to");
|
||||||
|
e.Property(x => x.Data).HasColumnType("jsonb");
|
||||||
|
e.Property(x => x.IsDefault).HasColumnName("is_default");
|
||||||
|
e.Property(x => x.CreatedAt).HasColumnName("created_at");
|
||||||
|
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
|
||||||
|
e.HasIndex(x => x.ColumnKey);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`InitializeAsync()`에 `CREATE TABLE IF NOT EXISTS temp_ref_profiles (...)` 추가.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 백엔드 API — 상세
|
||||||
|
|
||||||
|
#### 4.1 프로파일 목록 조회
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/steam/tempprofile/{col}/profiles
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"column": "C-6111",
|
||||||
|
"profiles": [
|
||||||
|
{ "id": 1, "label": "기본", "description": "2026-02-05~2026-06-05", "isDefault": true, "nProducts": 3, "createdAt": "..." },
|
||||||
|
{ "id": 2, "label": "recent-30d","description": "최근 30일(2026-05-08~2026-06-07)", "isDefault": false, "nProducts": 2, "createdAt": "..." },
|
||||||
|
{ "id": 3, "label": "winter", "description": "2025-12-01~2026-02-28", "isDefault": false, "nProducts": 3, "createdAt": "..." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `column_key`로 필터링
|
||||||
|
- `is_default` 우선, 그 다음 `created_at DESC` 정렬
|
||||||
|
- 기존 파일 기반 `{col}_tempref.json`이 DB에 없으면 최초 조회 시 자동 import
|
||||||
|
|
||||||
|
#### 4.2 프로파일 생성
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/steam/tempprofile/{col}/profiles
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"label": "recent-30d",
|
||||||
|
"description": "최근 30일",
|
||||||
|
"from": "2026-05-08T00:00:00+09:00",
|
||||||
|
"to": "2026-06-07T23:59:59+09:00",
|
||||||
|
"setDefault": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
백엔드 동작:
|
||||||
|
1. 요청받은 `column_key` + `from`~`to` 기간 검증
|
||||||
|
2. `TagsFor(ToSuffix(col))`로 태그 목록 획득
|
||||||
|
3. `history_table`에서 해당 기간 데이터 조회
|
||||||
|
```sql
|
||||||
|
SELECT recorded_at, tagname, value
|
||||||
|
FROM hc900.history_table
|
||||||
|
WHERE tagname = ANY(...)
|
||||||
|
AND recorded_at BETWEEN @from AND @to
|
||||||
|
ORDER BY recorded_at
|
||||||
|
```
|
||||||
|
4. 조회된 데이터를 `gen_temp_profiles.py`와 동일한 로직으로 처리:
|
||||||
|
- 각 스냅샷 시간별로 (reb_temp, T_B, T_C, T_D, vacuum) 한 행으로 피벗
|
||||||
|
- `mode == "PROD"` 필터 (realtime_table에서 해당 기간 mode 태그 조회)
|
||||||
|
- feed > 50 필터
|
||||||
|
- NaN/null 제거
|
||||||
|
- KMeans 클러스터링 (k=3→2→1)
|
||||||
|
- 각 클러스터별 median/std 계산
|
||||||
|
5. `TempRef` 객체 구성 → `JsonSerializer.Serialize` → `data` JSONB 컬럼에 저장
|
||||||
|
6. `setDefault=true`면 기존 기본값 해제 후 이 프로파일을 기본으로 설정
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"profileId": 4,
|
||||||
|
"label": "recent-30d",
|
||||||
|
"nProducts": 2,
|
||||||
|
"period": "2026-05-08~2026-06-07",
|
||||||
|
"message": "기준 프로파일 생성 완료"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.3 프로파일 기본값 설정
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/steam/tempprofile/{col}/profiles/{id}/default
|
||||||
|
```
|
||||||
|
|
||||||
|
- 해당 `column_key`의 다른 모든 프로파일 `is_default = false`
|
||||||
|
- 지정한 `id`의 프로파일 `is_default = true`
|
||||||
|
|
||||||
|
#### 4.4 프로파일 삭제
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/steam/tempprofile/{col}/profiles/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 기본 프로파일(`is_default=true`)은 삭제 불가 (먼저 다른 프로파일을 기본으로 설정해야 함)
|
||||||
|
|
||||||
|
#### 4.5 프로파일 미리보기 (생성 전 검증)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/steam/tempprofile/{col}/profiles/preview
|
||||||
|
body: { "from": "...", "to": "..." }
|
||||||
|
→ { "success": true, "nRows": 3420, "nSnapshots": 57, "estimatedProducts": 3,
|
||||||
|
"stagesOrder": ["reb_temp","T_B","T_C","T_D"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. 기존 `TempProfile` / `TempProfileHistory` 수정
|
||||||
|
|
||||||
|
#### `LoadTempRef` 변경
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Before: 파일 기반
|
||||||
|
private async Task<TempRef?> LoadTempRef(string col)
|
||||||
|
|
||||||
|
// After: DB 기반
|
||||||
|
private async Task<TempRef?> LoadTempRef(string col, int? profileId = null)
|
||||||
|
{
|
||||||
|
TempRefProfileEntity? entity;
|
||||||
|
|
||||||
|
if (profileId.HasValue)
|
||||||
|
{
|
||||||
|
entity = await _ctx.TempRefProfiles
|
||||||
|
.FirstOrDefaultAsync(p => p.ColumnKey == col && p.Id == profileId.Value);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
entity = await _ctx.TempRefProfiles
|
||||||
|
.Where(p => p.ColumnKey == col && p.IsDefault)
|
||||||
|
.OrderByDescending(p => p.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity == null) return null;
|
||||||
|
|
||||||
|
return JsonSerializer.Deserialize<TempRef>(
|
||||||
|
entity.Data,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `TempProfile` 엔드포인트
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/steam/tempprofile/{col}?profile_id=2
|
||||||
|
```
|
||||||
|
|
||||||
|
- `profile_id` 생략 시 기본 프로파일 사용
|
||||||
|
- 응답에 현재 사용 중인 프로파일 정보 추가:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"column": "C-6111",
|
||||||
|
"profile": { "id": 2, "label": "recent-30d", "description": "..." },
|
||||||
|
"period": "2026-05-08~2026-06-07",
|
||||||
|
"matchedProduct": "P1",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `TempProfileHistory` 엔드포인트
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/steam/tempprofile/{col}/history?from=...&to=...&profile_id=2
|
||||||
|
```
|
||||||
|
|
||||||
|
- 동일한 `profile_id` 파라미터 지원
|
||||||
|
- 히스토리 각 스냅샷도 동일한 기준 프로파일로 z-score 계산
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Python 스크립트 — DB 직접 저장
|
||||||
|
|
||||||
|
#### `gen_temp_profiles.py` 확장
|
||||||
|
|
||||||
|
```
|
||||||
|
usage: gen_temp_profiles.py --loop-csv CSV --signal-csv CSV --variable-csv CSV
|
||||||
|
[--from DATE] [--to DATE] [--days N]
|
||||||
|
[--label LABEL] [--description DESC]
|
||||||
|
[--db-conn "Host=...;Database=...;Username=...;Password=..."]
|
||||||
|
[--o FILE] # 파일 출력 (기존 동작)
|
||||||
|
[--api-url http://...] # API 호출로 저장
|
||||||
|
```
|
||||||
|
|
||||||
|
- `--db-conn`: 직접 DB 연결하여 `temp_ref_profiles`에 INSERT
|
||||||
|
- `--api-url`: API 호출하여 저장 (권장, 서버 로직 재사용)
|
||||||
|
- `--label` 필수 (DB 저장 시)
|
||||||
|
- `--description`: 사람이 읽을 수 있는 설명
|
||||||
|
|
||||||
|
예시:
|
||||||
|
```bash
|
||||||
|
# API로 생성
|
||||||
|
python3 gen_temp_profiles.py --loop-csv ... \
|
||||||
|
--from 2026-05-01 --to 2026-06-07 \
|
||||||
|
--label "recent-30d" --description "최근 30일 기준" \
|
||||||
|
--api-url "http://localhost:5000/api/steam/tempprofile/C-6111/profiles"
|
||||||
|
|
||||||
|
# DB 직접 입력
|
||||||
|
python3 gen_temp_profiles.py --loop-csv ... \
|
||||||
|
--days 30 --label "rolling-30d" \
|
||||||
|
--db-conn "Host=localhost;Database=iiot_platform;Username=postgres"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `load_state_labels.py`와 유사한 전용 import 스크립트
|
||||||
|
|
||||||
|
별도 스크립트 `scripts/analysis/import_tempref_to_db.py`:
|
||||||
|
- 기존 `scripts/analysis/*_tempref.json` 파일을 DB에 일괄 등록
|
||||||
|
- `--col C-6111 --profile-id 1` 옵션으로 특정 프로파일만 업데이트 가능
|
||||||
|
- `--set-default` 옵션으로 기본값 지정
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. 프론트엔드 — 상세 UI/UX
|
||||||
|
|
||||||
|
#### 7.1 기준 프로파일 선택기
|
||||||
|
|
||||||
|
```
|
||||||
|
컬럼: [C-6111 ▼] 기준: [recent-30d ▼] [+ 새 기준] [조회]
|
||||||
|
├── 기본 (2026-02-05~2026-06-05)
|
||||||
|
├── recent-30d (최근 30일) ← 현재 선택
|
||||||
|
└── winter (2025-12~2026-02)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `<select id="st-temp-profile">`: 프로파일 목록
|
||||||
|
- 첫 로딩 시 `GET /profiles`로 목록 조회하여 dropdown 채움
|
||||||
|
- 선택 변경 시 → 자동으로 `stTempLoad()` 재실행 (프로파일 파라미터 포함)
|
||||||
|
- 차트 제목에 현재 프로파일 표시: `"C-6111 · 기준: recent-30d"`
|
||||||
|
|
||||||
|
#### 7.2 새 기준 생성 (모달)
|
||||||
|
|
||||||
|
`[+ 새 기준]` 버튼 클릭 → 모달 표시:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ 새 기준 프로파일 생성 ──────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ 레이블: [recent-30d ] │
|
||||||
|
│ 설명: [최근 30일 기준 ] │
|
||||||
|
│ │
|
||||||
|
│ ○ 최근 N일: [30]일 (현재 ~ N일 전) │
|
||||||
|
│ ● 기간 지정: │
|
||||||
|
│ 시작: [2026-05-08] 종료: [2026-06-07] │
|
||||||
|
│ │
|
||||||
|
│ [□ 이 프로파일을 기본으로 설정] │
|
||||||
|
│ │
|
||||||
|
│ [취소] [생성] │
|
||||||
|
└───────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- "생성" 클릭 → `POST /profiles` API 호출
|
||||||
|
- 성공 시 dropdown 갱신, 새 프로파일 선택됨
|
||||||
|
- 실패 시 오류 메시지 표시
|
||||||
|
|
||||||
|
#### 7.3 기준 프로파일 관리
|
||||||
|
|
||||||
|
`[⚙]` 버튼 (또는 컨텍스트 메뉴):
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ 기준 프로파일 관리 ─────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ ◎ 기본 (기본) [기본설정] [삭제] │
|
||||||
|
│ ○ recent-30d [기본설정] [삭제] │
|
||||||
|
│ ○ winter [기본설정] [삭제] │
|
||||||
|
│ │
|
||||||
|
│ [+ 새 기준 생성] │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. 마이그레이션: 기존 파일 → DB
|
||||||
|
|
||||||
|
#### 최초 1회 자동 import (서버 시작 or 최초 API 호출 시)
|
||||||
|
|
||||||
|
`Hc900DbContext.InitializeAsync()` 또는 `SteamAdvisorController` 생성자에서:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private async Task EnsureDefaultProfilesAsync()
|
||||||
|
{
|
||||||
|
var dir = _config.GetValue<string>("SteamAdvisor:ModelDir")
|
||||||
|
?? "/home/windpacer/projects/hc900_ax/scripts/analysis";
|
||||||
|
|
||||||
|
foreach (var col in SUPPORTED_COLUMNS) // ["C-6111", "C-6211", ...]
|
||||||
|
{
|
||||||
|
var hasAny = await _ctx.TempRefProfiles.AnyAsync(p => p.ColumnKey == col);
|
||||||
|
if (hasAny) continue;
|
||||||
|
|
||||||
|
var path = Path.Combine(dir, $"{col}_tempref.json");
|
||||||
|
if (!System.IO.File.Exists(path)) continue;
|
||||||
|
|
||||||
|
var json = await System.IO.File.ReadAllTextAsync(path);
|
||||||
|
var tref = JsonSerializer.Deserialize<TempRef>(json,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
if (tref == null) continue;
|
||||||
|
|
||||||
|
_ctx.TempRefProfiles.Add(new TempRefProfileEntity
|
||||||
|
{
|
||||||
|
ColumnKey = col,
|
||||||
|
Label = "기본",
|
||||||
|
Description = tref.Period,
|
||||||
|
PeriodFrom = ParsePeriodFrom(tref.Period), // "2026-02-05~2026-06-05" → DateTime
|
||||||
|
PeriodTo = ParsePeriodTo(tref.Period),
|
||||||
|
Data = json,
|
||||||
|
IsDefault = true,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await _ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. API 라우트 요약
|
||||||
|
|
||||||
|
| Method | Path | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `GET` | `/api/steam/tempprofile/{col}` | 실시간 프로파일 (profile_id query param) |
|
||||||
|
| `GET` | `/api/steam/tempprofile/{col}/history` | 과거 이력 (profile_id query param) |
|
||||||
|
| `GET` | `/api/steam/tempprofile/{col}/profiles` | 프로파일 목록 |
|
||||||
|
| `POST` | `/api/steam/tempprofile/{col}/profiles` | 새 프로파일 생성 |
|
||||||
|
| `POST` | `/api/steam/tempprofile/{col}/profiles/preview` | 생성 미리보기 |
|
||||||
|
| `PUT` | `/api/steam/tempprofile/{col}/profiles/{id}/default` | 기본값 설정 |
|
||||||
|
| `DELETE` | `/api/steam/tempprofile/{col}/profiles/{id}` | 프로파일 삭제 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. 구현 순서 (예상: 2~3일)
|
||||||
|
|
||||||
|
| 순서 | 작업 | 파일 | 예상시간 |
|
||||||
|
|------|------|------|---------|
|
||||||
|
| 1 | `TempRefProfileEntity` 클래스 작성 | `Infrastructure/Database/` | 0.5h |
|
||||||
|
| 2 | `Hc900DbContext`에 `DbSet` + `OnModelCreating` 매핑 + DDL | `Hc900DbContext.cs` | 1h |
|
||||||
|
| 3 | `SteamAdvisorController`에 `EnsureDefaultProfilesAsync` + 기존 파일 import | `SteamAdvisorController.cs` | 1h |
|
||||||
|
| 4 | `LoadTempRef` DB 버전으로 변경 | `SteamAdvisorController.cs` | 0.5h |
|
||||||
|
| 5 | `GET profiles` 목록 API | `SteamAdvisorController.cs` | 0.5h |
|
||||||
|
| 6 | `POST profiles` 생성 API (history_table 조회 → KMeans → JSONB 저장) | `SteamAdvisorController.cs` | 3h |
|
||||||
|
| 7 | `PUT/DELETE profiles` 관리 API | `SteamAdvisorController.cs` | 0.5h |
|
||||||
|
| 8 | `TempProfile`/`TempProfileHistory`에 `profile_id` 파라미터 추가 | `SteamAdvisorController.cs` | 0.5h |
|
||||||
|
| 9 | `gen_temp_profiles.py` DB/API 출력 옵션 | `gen_temp_profiles.py` | 1h |
|
||||||
|
| 10 | `steam.html` 프로파일 선택 dropdown + 생성 모달 | `steam.html` | 1h |
|
||||||
|
| 11 | `steam.js` 프로파일 로드/전환/생성 로직 | `steam.js` | 2h |
|
||||||
|
| 12 | 빌드 + 통합 테스트 | — | 1h |
|
||||||
|
| | **합계** | | **~12h** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11. 에지 케이스
|
||||||
|
|
||||||
|
| 상황 | 처리 |
|
||||||
|
|------|------|
|
||||||
|
| 프로파일이 하나도 없음 | `EnsureDefaultProfilesAsync`가 파일 시스템에서 자동 import 시도, 실패시 404 |
|
||||||
|
| 기본 프로파일 삭제 요청 | `400 Bad Request` — 먼저 다른 프로파일을 기본으로 설정해야 함 |
|
||||||
|
| 중복 레이블 | `column_key + label`에 unique 제약 or 409 Conflict |
|
||||||
|
| 생성 중 같은 기간 프로파일 존재 | 허용 (같은 기간으로 여러 번 생성 가능, 레이블로 구분) |
|
||||||
|
| DB 연결 실패 | 파일 기반 `LoadTempRef`로 fallback? or 명확한 503 에러 |
|
||||||
|
| 프로파일이 너무 많음 (50개+) | 기본 50개 제한, 생성 시 경고 |
|
||||||
|
| history_table 데이터 부족 (200행 미만) | 생성 API가 400 Bad Request + "데이터 부족" 메시지 (gen_temp_profiles.py의 최소 200행 조건과 동일) |
|
||||||
192
docs/작업플랜-온도프로파일-사용자정의.md
Normal file
192
docs/작업플랜-온도프로파일-사용자정의.md
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
# 온도 프로파일 — 사용자 정의 기준밴드 적용
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
|
||||||
|
현재 `gen_temp_profiles.py`가 4개월치 데이터를 k-means 클러스터링하여 P0/P1/P2를 자동 분류하고,
|
||||||
|
각 클러스터의 median/std를 `tempref.json`에 저장 → 시스템이 이를 기준밴드로 사용.
|
||||||
|
|
||||||
|
**문제**: 클러스터링 결과를 신뢰할 수 없고, reb_temp 노이즈에 따라 제품매칭이 5초~1분 단위로
|
||||||
|
튀어 밴드가 불안정함.
|
||||||
|
|
||||||
|
**해결 방향**: 운전자가 각 제품의 단계별 정상값(target + 허용편차)을 직접 정의하고,
|
||||||
|
선택한 제품 정의를 시스템이 고정 기준으로 사용함. 자동매칭 제거.
|
||||||
|
|
||||||
|
## 핵심 원칙
|
||||||
|
|
||||||
|
> **사용자가 선택한 제품 정의는 절대 자동으로 변경되지 않는다.**
|
||||||
|
> 실제 공정값이 기준에서 벗어나면 `deviated` 플래그만 표시하고, 기준 자체는 유지.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase A — 데이터 모델 + 백엔드 API
|
||||||
|
|
||||||
|
### A1. DB Entity: `UserTempProfile`
|
||||||
|
|
||||||
|
`Infrastructure/Database/` 에 추가.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class UserTempProfile
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string ColumnName { get; set; } = ""; // "C-6111"
|
||||||
|
public string Label { get; set; } = ""; // "경질원료"
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
// Stage definitions: serialized as JSON
|
||||||
|
// {"reb_temp": {"median":124, "std":2}, "T_B": {"median":111, "std":2}, ...}
|
||||||
|
public string StageDefsJson { get; set; } = "{}";
|
||||||
|
|
||||||
|
// Vacuum
|
||||||
|
public double VacuumTarget { get; set; }
|
||||||
|
public double VacuumDev { get; set; }
|
||||||
|
|
||||||
|
// Span AD
|
||||||
|
public double SpanAD { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`StageDefsJson`은 기존 `TempStat(median, std)` 구조와 동일하므로
|
||||||
|
`JsonSerializer.Deserialize<Dictionary<string, TempStat>>()`로 복원 가능.
|
||||||
|
|
||||||
|
`Hc900DbContext`에 `DbSet<UserTempProfile>` 추가 + migration.
|
||||||
|
|
||||||
|
### A2. API: 프로파일 CRUD
|
||||||
|
|
||||||
|
| Method | Path | 용도 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `GET` | `/api/steam/profiles?col=C-6111` | 컬럼별 프로파일 목록 |
|
||||||
|
| `POST` | `/api/steam/profiles` | 생성/갱신 (Id=0이면 INSERT, >0이면 UPDATE) |
|
||||||
|
| `DELETE` | `/api/steam/profiles/{id}` | 삭제 |
|
||||||
|
|
||||||
|
### A3. API: TempProfile / TempProfileHistory 수정
|
||||||
|
|
||||||
|
`GET /api/steam/tempprofile/{col}?profileLabel=경질원료`
|
||||||
|
|
||||||
|
- `profileLabel`이 있으면 → DB에서 해당 프로파일 로드 → `ComputeStages`에 전달
|
||||||
|
- `profileLabel`이 없으면 → 자동매칭 없음, 첫 번째 사용자 프로파일 사용 or 에러
|
||||||
|
- 자동매칭(기존 `tempref.json` P0/P1/P2)은 **완전 제거**
|
||||||
|
|
||||||
|
응답에 auto-generated 참고 데이터도 함께 반환 (비교용):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"column": "C-6111",
|
||||||
|
"userProfile": { "label": "경질원료", "description": "...", "stages": {...} },
|
||||||
|
"stages": [
|
||||||
|
{ "stage": "reb_temp", "current": 120.5,
|
||||||
|
"target": 124, "dev": 2, "z": -1.75, "deviated": false }
|
||||||
|
],
|
||||||
|
"autoRef": {
|
||||||
|
"matchedProduct": "P0",
|
||||||
|
"stages": [ { "stage": "reb_temp", "refMedian": 84.81, "refStd": 0.5, "z": ... } ]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `stages[i].target/dev` = 사용자 정의값
|
||||||
|
- `autoRef` = 기존 tempref.json의 자동매칭 결과 (참고용, 별도 색상으로 표시)
|
||||||
|
- 사용자 정의가 없으면 `autoRef`만 반환 (하위호환)
|
||||||
|
|
||||||
|
`GET /api/steam/tempprofile/{col}/history?profileLabel=경질원료`
|
||||||
|
|
||||||
|
- 모든 스냅샷이 동일한 사용자 정의 프로파일 기준으로 z-score 계산
|
||||||
|
- 각 스냅샷의 `stages`는 사용자 정의 `target/dev` 기준
|
||||||
|
- `autoRef`도 각 스냅샷별로 계산하여 함께 반환
|
||||||
|
|
||||||
|
### A4. ComputeStages 리팩터
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private static (List<object> stages, object vacuum, double? spanAD) ComputeStages(
|
||||||
|
Dictionary<string, double?> cur,
|
||||||
|
TempRef tref, // auto-generated reference (nullable)
|
||||||
|
UserTempProfile? userProfile) // user-defined profile (nullable)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `userProfile`이 있으면 → 그 값으로 z-score 계산 (median=target, std=dev)
|
||||||
|
- `tref` → autoRef 용도로만 사용 (비교 표시)
|
||||||
|
- 제품매칭 로직 제거
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase B — 프론트엔드: 프로파일 관리 UI
|
||||||
|
|
||||||
|
### B1. 프로파일 CRUD 화면
|
||||||
|
|
||||||
|
위치: steam.html 내 모달 또는 별도 관리 페이지
|
||||||
|
|
||||||
|
- 컬럼 선택 드롭다운 (C-6111, C-6211, ...)
|
||||||
|
- 제품 목록 테이블 (Label, Description, 각 단계 target/dev, vacuum, spanAD)
|
||||||
|
- Add / Edit (인라인 or 폼) / Delete 버튼
|
||||||
|
- 저장 시 POST /api/steam/profiles
|
||||||
|
|
||||||
|
입력 필드 (제품 하나당):
|
||||||
|
|
||||||
|
| 단계 | Target | 허용편차(±) |
|
||||||
|
|------|--------|------------|
|
||||||
|
| reb_temp | [input] | [input] |
|
||||||
|
| T_B | [input] | [input] |
|
||||||
|
| T_C | [input] | [input] |
|
||||||
|
| T_D | [input] | [input] |
|
||||||
|
| 진공 | [input] | [input] |
|
||||||
|
| Span AD | [input] | (단일값, std 없음) |
|
||||||
|
|
||||||
|
초기값 제안: 기존 tempref.json의 P0/P1/P2 데이터를 불러와서 "기본값으로 채우기" 버튼
|
||||||
|
|
||||||
|
### B2. 제품 선택기
|
||||||
|
|
||||||
|
steam.html 온도 프로파일 탭 상단에 드롭다운 추가.
|
||||||
|
|
||||||
|
- DB에 저장된 사용자 정의 프로파일 목록 표시
|
||||||
|
- 선택 즉시 `stTempLoad(profileLabel)` 호출 → 전체 차트 리로드
|
||||||
|
- 선택값은 `localStorage`에 저장 (페이지 새로고침 시 유지)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase C — 차트: 이중 밴드 표시
|
||||||
|
|
||||||
|
### C1. 두 개의 밴드
|
||||||
|
|
||||||
|
| 구분 | 색상 | 스타일 | 의미 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| 사용자 정의 밴드 | 녹계열 (#4caf50 fill) | 굵은 실선 중앙값 + 투명 fill | **기준 — 이게 정상이다** |
|
||||||
|
| 자동 (tempref.json) | 청회색 (#78909c fill) | 가는 점선 중앙값 + 연한 fill | **참고 — 실제 운전 통계** |
|
||||||
|
|
||||||
|
### C2. 실시간 라인
|
||||||
|
|
||||||
|
- 두 밴드 위에 실시간 current 값 표시 (파랑 굵은 실선, circle symbol)
|
||||||
|
|
||||||
|
### C3. 과거 스냅샷
|
||||||
|
|
||||||
|
- 선택 시점 line: amber (#fa3) 굵은 실선
|
||||||
|
- 현재 실시간 참조: thin dashed blue line
|
||||||
|
- 두 밴드는 동일하게 유지 (사용자 정의 + auto-ref)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 마이그레이션
|
||||||
|
|
||||||
|
1. 기존 `tempref.json`의 `products`를 초기 `UserTempProfile` seed data로 DB에 로드
|
||||||
|
2. 기존 자동매칭 코드를 deprecated 처리 (autoRef 비교용으로만 유지)
|
||||||
|
3. UI에 "데이터 초기화" 버튼 — tempref.json에서 다시 불러오기
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 작업 순서 (권장)
|
||||||
|
|
||||||
|
1. **A1** — Entity + DbContext + migration
|
||||||
|
2. **A2** — CRUD API endpoints
|
||||||
|
3. **A3** — TempProfile/TempProfileHistory 수정 + ComputeStages 리팩터
|
||||||
|
4. **B1** — 프로파일 관리 UI (모달)
|
||||||
|
5. **B2** — 제품 선택기 드롭다운
|
||||||
|
6. **C** — 이중 밴드 차트 렌더링
|
||||||
|
7. 시드 데이터 + 테스트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 보류 사항
|
||||||
|
|
||||||
|
- `gen_temp_profiles.py` 수정: 자동 클러스터링 유지, 출력에 user-defined 템플릿 포함
|
||||||
|
- 다중 컬럼 동시 표시 (현재 단일 컬럼, 필요시 별도 논의)
|
||||||
81
docs/작업플랜-온도프로파일-이력조회.md
Normal file
81
docs/작업플랜-온도프로파일-이력조회.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# 작업플랜 — 온도 프로파일 이력(History) 조회 (2026-06-07)
|
||||||
|
|
||||||
|
> 상위: `docs/작업플랜-컬럼온도프로파일-이격모니터.md`(실시간 모니터)의 시간축 확장.
|
||||||
|
> 현재 `GET /api/steam/tempprofile/{col}`은 **realtime_table 최신값만** 읽음 →
|
||||||
|
> **history_table(60초 스냅샷)** 로 과거 시점/구간 프로파일을 보게 한다.
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
steam 패널 "온도 프로파일 이격 모니터"가 지금은 실시간 1점만 본다.
|
||||||
|
운전자가 **과거 임의 시점의 단면 온도 프로파일**(reb-A/B/C/D + 진공 + 제품매칭 + z이격)을
|
||||||
|
조회하고, 나아가 **구간을 슬라이더/재생으로 스크럽**해 프로파일이 시간에 따라
|
||||||
|
어떻게 움직였는지(처짐/상승/붕괴) 보게 한다.
|
||||||
|
|
||||||
|
## 현 자산 (재활용, 중복금지)
|
||||||
|
- **엔드포인트**: `SteamAdvisorController.TempProfile(string col)` (SteamAdvisorController.cs:180~).
|
||||||
|
로직 = ① `{col}_tempref.json` 로드(정적 기준밴드) → ② **현재값 dict `cur`** 산출
|
||||||
|
(`_ctx.RealtimePoints`에서 태그별 최신) → ③ 제품매칭(reb_temp 최근접) → ④ z-score 이격 →
|
||||||
|
⑤ stages/vacuum/spanAD 응답. **②만 시간축으로 바꾸면 ③~⑤ 그대로 재사용**.
|
||||||
|
- **태그 매핑**: `TagsFor(ToSuffix(col))` → `{reb_temp, T_B, T_C, T_D, vacuum, ...}` (컬럼 무관, 재활용).
|
||||||
|
- **기준밴드 `tempref`**: 정적(period 기준) → 실시간/이력 **공통**, 변경 불필요.
|
||||||
|
- **히스토리 인프라**:
|
||||||
|
- `history_table(tagname, value, recorded_at, controller_id)`, 60초 간격, TimescaleDB hypertable.
|
||||||
|
- `IExperionDbService.QueryHistoryAsync(tags, from, to, limit)` (Hc900DbContext.cs:1599) — 구간 원시.
|
||||||
|
- `QueryHistoryWithIntervalAsync` + `time_bucket` 피벗(Hc900DbContext.cs:1719~) — 간격 집계.
|
||||||
|
- `POST /api/history/query`, `/api/history/query-interval` (HistoryController, Hc900Controllers.cs:130).
|
||||||
|
- **프론트**: `steam.js` `stTempTick`(realtime 폴링)/`stRenderTemp(d)`(ECharts 렌더, steam.js:108~).
|
||||||
|
`stRenderTemp`는 응답 `d` 형태에만 의존 → **이력 응답도 같은 형태면 렌더 재사용**.
|
||||||
|
|
||||||
|
## 설계 — 값 제공자(value provider)만 시간축화
|
||||||
|
`TempProfile`에서 `cur`(태그→값 dict) 만드는 부분을 **헬퍼로 분리**하고 두 소스를 둔다:
|
||||||
|
- **realtime** (기존): `RealtimePoints` 태그별 최신.
|
||||||
|
- **history@at**: history_table에서 각 태그의 **`recorded_at <= at` 최근접 1건**.
|
||||||
|
```sql
|
||||||
|
SELECT DISTINCT ON (tagname) tagname, value
|
||||||
|
FROM history_table
|
||||||
|
WHERE controller_id = @cid AND tagname = ANY(@tags) AND recorded_at <= @at
|
||||||
|
ORDER BY tagname, recorded_at DESC;
|
||||||
|
```
|
||||||
|
- **history frames(구간 재생)**: `time_bucket(@interval, recorded_at)` 피벗으로 태그×시점 행렬 →
|
||||||
|
프레임 배열. (재활용: `BuildHistoryIntervalQuerySql` 패턴.)
|
||||||
|
|
||||||
|
값 파싱은 기존과 동일(`double.TryParse`). 상태레이블(`{N|LABEL|}`) 태그는 온도엔 없음(숫자) → 안전.
|
||||||
|
|
||||||
|
## 단계
|
||||||
|
1. **백엔드 리팩터 (선행)** — `TempProfile`의 `cur` 생성부를
|
||||||
|
`BuildCurrent(tagMap, IValueSource)` 헬퍼로 분리. 기존 realtime 경로는 동작 보존(회귀 0).
|
||||||
|
2. **단일 시점 엔드포인트** — `GET /api/steam/tempprofile/{col}?at={ISO8601}`.
|
||||||
|
- `at` 없으면 기존 realtime 동작(하위호환).
|
||||||
|
- `at` 있으면 history@at 소스로 `cur` 산출 → 이후 로직(제품매칭·z·band) 동일.
|
||||||
|
- 응답에 `mode:"history"`, `at`(실제 매칭된 `recorded_at`), `staleSec`(at−매칭시각) 추가.
|
||||||
|
- 컨트롤러 해석: col→controller_id 매핑 필요(기존 매핑 경로 재활용; 없으면 모든 controller_id 허용).
|
||||||
|
3. **구간 재생 엔드포인트(옵션, 2차)** — `GET /api/steam/tempprofile/{col}/frames?from&to&interval`.
|
||||||
|
- time_bucket 피벗 → `{ at, stages, vacuum, spanAD, matchedProduct }[]` 프레임 배열.
|
||||||
|
- 제품매칭/z를 프레임마다 계산(reb_temp 변동 반영).
|
||||||
|
4. **프론트 — 모드 토글 (steam.js / steam.html)**:
|
||||||
|
- temp 탭에 **[실시간 | 이력]** 토글 + datetime-local 입력 + "조회".
|
||||||
|
- 이력 선택 시 `stTempTimer` 중지(폴링 멈춤), `?at=` 호출 → `stRenderTemp` 재사용.
|
||||||
|
- 배지/상태줄에 "이력 YYYY-MM-DD HH:mm (스냅샷 −Ns)" 표기.
|
||||||
|
5. **프론트 — 스크럽/재생(옵션, 2차)**:
|
||||||
|
- from~to + interval 입력 → frames 로드 → **슬라이더**로 프레임 이동, **▶재생**으로 애니메이션.
|
||||||
|
- 각 프레임 `stRenderTemp(frame)` 호출(차트 setOption 재사용). reb-A/C 처짐 추이가 눈에 보임.
|
||||||
|
|
||||||
|
## 검증기준
|
||||||
|
- `?at=` 미지정 → 기존 실시간 응답과 동일(회귀 없음).
|
||||||
|
- `?at=`(과거 1시간 전) → 그 시점 reb-A/B/C/D·진공이 history 스냅샷과 일치, 제품매칭·z 정상.
|
||||||
|
- history에 해당 시각 데이터 없음(컬럼 오프라인 구간) → `at`보다 과거 최근접 반환 + `staleSec` 큼, 또는 빈 결과 명시(에러 0).
|
||||||
|
- **임의 컬럼**(6-1/6-2/8/9/10차) 동일 동작 — col 키만 다름(하드코딩 금지).
|
||||||
|
- 프론트 이력 모드 진입 시 실시간 폴링 정지, 복귀 시 재개.
|
||||||
|
- (2차) frames 재생: 슬라이더 이동마다 프로파일 갱신, 재생 시 부드러운 전이.
|
||||||
|
|
||||||
|
## 주의
|
||||||
|
- **타임존**: `recorded_at`는 TIMESTAMPTZ(UTC). datetime-local(로컬 KST) → UTC 변환 후 질의.
|
||||||
|
응답 표기는 로컬로 환산(기존 `toLocaleTimeString` 일관).
|
||||||
|
- **controller_id 필터**: history_table은 멀티컨트롤러 공유 → 반드시 col→controller_id로 필터(태그명만으로 부족할 수 있음).
|
||||||
|
- **60초 해상도**: history는 60초 스냅샷 → `at` 분해능 60초. 더 촘촘한 건 fast_record(고속) 별도(범위 밖).
|
||||||
|
- **tempref는 정적 기준** — 이력 조회해도 기준밴드는 동일 period 기준(과거 시점의 당시 기준이 아님). 필요 시 후속 과제로 period-aware 기준 분리.
|
||||||
|
- **표시 전용** — 이력 조회는 제어와 무관(write 없음). 안전영향 0.
|
||||||
|
- 성능: 단일 `at`는 DISTINCT ON 인덱스(`recorded_at`)로 가벼움. frames는 interval·범위 클램프로 과대질의 방지.
|
||||||
|
|
||||||
|
## 권장순서
|
||||||
|
①백엔드 헬퍼 분리 → ②`?at=` 단일시점 + 프론트 토글(여기까지가 1차 목표) → ③frames + 슬라이더/재생(2차).
|
||||||
|
①②는 실시간 경로 회귀 없이 독립 적용 가능.
|
||||||
825
docs/포인트빌더-이식-설계서.md
Normal file
825
docs/포인트빌더-이식-설계서.md
Normal file
@@ -0,0 +1,825 @@
|
|||||||
|
# HC900 Crawler 포인트빌더 이식 설계서
|
||||||
|
|
||||||
|
> **목적**: `ExperionCrawler`의 `포인트빌더-페이지-구현-명세.md`에 정의된 Point Builder 페이지를
|
||||||
|
> HC900 Crawler 프로젝트에 맞게 변환하여 이식하기 위한 상세 설계서.
|
||||||
|
>
|
||||||
|
> **핵심 차이**: ExperionCrawler는 OPC UA `node_map_master`를 소스로 사용하지만,
|
||||||
|
> HC900 Crawler는 Modbus TCP `hc900_map_master`를 소스로 사용한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **진단**: 아래 내용은 `diagnosis-checklist.md` 진단 룰 8단계를 적용한 설계 검토 결과.
|
||||||
|
> 우선순위: 🔴 HIGH(즉시 수정 권장) > 🟠 MED > 🟡 LOW
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 진단 결과 요약
|
||||||
|
|
||||||
|
### 🔴 1. 설계 원칙과 구현 코드 모순 — TRUNCATE vs IsActive 플래그
|
||||||
|
|
||||||
|
**문제**: §4.2가 "`TRUNCATE realtime_table` 대신 `IsActive` 플래그 사용"이라고 명시했으나,
|
||||||
|
§6.5 `SyncRealtimeTableAsync`가 `TRUNCATE TABLE realtime_table RESTART IDENTITY`를 사용함.
|
||||||
|
|
||||||
|
**근거**: §4.2 L227 vs §6.5 L573-574
|
||||||
|
|
||||||
|
**영향**: 실시간 값(LiveValue)이 모두 null로 초기화됨. DB 유저에게 TRUNCATE 권한이 없으면
|
||||||
|
런타임 오류. 설계 원칙과 직접 충돌하여 구현 방향에 혼란 초래.
|
||||||
|
|
||||||
|
**수정**: `TRUNCATE` 대신 `DELETE FROM realtime_table WHERE tagname NOT IN (...active tags...)`
|
||||||
|
패턴으로 변경하여 LiveValue 보존.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔴 2. BuildRealtimeTableAsync 시그니처/내부 타입 불일치
|
||||||
|
|
||||||
|
**문제**: §6.1 `BuildRealtimeTableAsync(IEnumerable<Hc900PointBuilderGroupDto>)`의 foreach에서
|
||||||
|
`(groupKey, group)` 튜플 분해를 사용하지만 단일 DTO 타입이므로 컴파일 불가.
|
||||||
|
§6.2 `PreviewRealtimeBuildAsync`는 올바르게 튜플 타입을 사용함.
|
||||||
|
|
||||||
|
**근거**: §6.1 L431 (시그니처) vs L439 (foreach destructure)
|
||||||
|
|
||||||
|
**영향**: 해당 코드는 컴파일되지 않음.
|
||||||
|
|
||||||
|
**수정**: 시그니처를 `IEnumerable<(string GroupKey, Hc900PointBuilderGroupDto Group)>`로 변경.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟠 3. DB 트랜잭션 미적용
|
||||||
|
|
||||||
|
**문제**: Build/Apply/Append 모두 다중 DB 작업(전체 UPDATE → 조건부 UPDATE → TRUNCATE → INSERT)을
|
||||||
|
트랜잭션 없이 순차 실행. 중간 실패 시 `is_active`와 `realtime_table`이 불일치 상태로 남음.
|
||||||
|
|
||||||
|
**근거**: §6.1 L434-451, §6.3 L526-535, §6.4 L544-554
|
||||||
|
|
||||||
|
**수정**: `using var tx = await _ctx.Database.BeginTransactionAsync()`로 전체 작업 감싸기.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟠 4. Build/Apply 중 Gateway 폴링에 빈 활성 목록 노출
|
||||||
|
|
||||||
|
**문제**: "전체 `is_active=false`" → "조건부 `is_active=true`" 순서로 인해 두 UPDATE 사이에
|
||||||
|
Gateway가 폴링하면 모든 태그가 비활성 상태로 보임.
|
||||||
|
|
||||||
|
**근거**: §6.1 L434-435 (전체 비활성화) vs L447 (SaveChangesAsync)
|
||||||
|
|
||||||
|
**수정**: 조건부 UPDATE를 먼저 실행하거나 단일 SQL로 전환.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟠 5. BuildGroupQuery에 LoopNo 필터 미구현
|
||||||
|
|
||||||
|
**문제**: §4.1 Request Body에 `loopNo` 필드가 명세되었으나 §6.1 `BuildGroupQuery`는
|
||||||
|
`LoopNo` 조건을 구현하지 않음. SQL 명세(L176-182)와 C# 구현 불일치.
|
||||||
|
|
||||||
|
**근거**: §4.1 L159 (`loopNo` 필드) vs §6.1 L458-479 (LoopNo 조건 누락)
|
||||||
|
|
||||||
|
**수정**: `if (group.LoopNo.HasValue) q = q.Where(x => x.LoopNo == group.LoopNo.Value);`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟠 6. Hc900PointBuilderGroupDto 클래스 정의 누락
|
||||||
|
|
||||||
|
**문제**: 문서 전체에서 `Hc900PointBuilderGroupDto`를 사용하지만 필드 정의가 없음.
|
||||||
|
특히 `LoopNo` 필드의 타입(`int?`)과 `PointBuilderGroupDto`(Experion 잔재)와의 관계 불명확.
|
||||||
|
|
||||||
|
**근거**: §4.1 ~ §6.6 전반 — 사용만 있고 정의는 없음.
|
||||||
|
|
||||||
|
**수정**: §3.4로 명시적 클래스 정의 추가.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟡 7. TagPatterns 와일드카드 처리 불일치
|
||||||
|
|
||||||
|
**문제**: §4.1 명세는 "`ILIKE` 패턴 (`%` 와일드카드)"이나 §6.1 구현은 `%`를 제거 후
|
||||||
|
항상 `%p%`로 감싸 contains 매칭만 수행.
|
||||||
|
|
||||||
|
**수정**: 사용자 패턴을 그대로 ILIKE에 전달하거나, `%...%` 고정 규칙으로 명세 통일.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟡 8. 그룹별 도메인 필터 부재
|
||||||
|
|
||||||
|
**문제**: 5개 그룹(loop/signal/digital/variable/custom)이 모두 동일한 `BuildGroupQuery`를
|
||||||
|
사용하므로 그룹 간 도메인 경계가 무시됨.
|
||||||
|
|
||||||
|
**수정**: groupKey 기반 도메인 필터 추가 또는 "사용자 패턴에 전적으로 의존"한다고 문서화.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟡 9. API 에러 응답 명세 부재
|
||||||
|
|
||||||
|
**문제**: 모든 API가 성공 응답만 정의하고 HTTP status code별 에러 응답 형식이 없음.
|
||||||
|
|
||||||
|
**수정**: 400/404/500 각각의 에러 응답 예시 추가.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 목차
|
||||||
|
|
||||||
|
1. [현황 분석](#1-현황-분석)
|
||||||
|
2. [핵심 차이점](#2-핵심-차이점)
|
||||||
|
3. [데이터 모델 변경](#3-데이터-모델-변경)
|
||||||
|
4. [API 엔드포인트 설계](#4-api-엔드포인트-설계)
|
||||||
|
5. [프론트엔드 변경](#5-프론트엔드-변경)
|
||||||
|
6. [Hc900DbService 변경](#6-hc900dbservice-변경)
|
||||||
|
7. [마이그레이션 순서](#7-마이그레이션-순서)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 현황 분석
|
||||||
|
|
||||||
|
### 1.1 현재 구현된 것 (이식 대상 아님, 교체 필요)
|
||||||
|
|
||||||
|
| 파일 | 역할 | 현재 상태 |
|
||||||
|
|------|------|-----------|
|
||||||
|
| `wwwroot/panes/pb.html` (94줄) | 태그 관리 페이지 | Hc900MapEntry 조회/필터/활성화 토글 UI |
|
||||||
|
| `wwwroot/js/pb.js` (213줄) | 태그 관리 로직 | `pbReload`, `pbToggleOne`, `pbBulkSelected` 등 |
|
||||||
|
| `wwwroot/css/pb.css` (106줄) | CSS | ExperionCrawler 원본과 동일 (잔재) |
|
||||||
|
| `Controllers/Hc900Controllers.cs` | 컨트롤러 | `Hc900TagManagerController` (태그 CRUD), `RealtimeController` (point CRUD) |
|
||||||
|
| `Infrastructure/Database/Hc900DbContext.cs` | DbService | `BuildRealtimeTableAsync`, `PreviewRealtimeBuildAsync` → `NotImplementedException` |
|
||||||
|
|
||||||
|
### 1.2 ExperionCrawler 원본과의 대응 관계
|
||||||
|
|
||||||
|
| ExperionCrawler | HC900 Crawler |
|
||||||
|
|---|---|
|
||||||
|
| OPC UA 서버 연결 | C++ Gateway (gRPC) |
|
||||||
|
| `node_map_master` (OPC UA 노드 카탈로그) | `hc900_map_master` (Modbus 레지스터 맵) |
|
||||||
|
| OPC UA 구독 시작/중지 | Gateway Polling 시작/중지 (프로세스 제어) |
|
||||||
|
| `realtime_table` = 구독 대상 포인트 목록 | `realtime_table` = 게이트웨이가 폴링하는 값 캐시 |
|
||||||
|
| `IExperionRealtimeService.AddMonitoredItemAsync()` | `Hc900RealtimeService` (이미 폴링 중) |
|
||||||
|
|
||||||
|
### 1.3 이미 존재하는 것 (재사용 가능)
|
||||||
|
|
||||||
|
- `Hc900TagManagerController` — `/api/hc900/tags` (Hc900MapEntry CRUD, param-types, controller-ids)
|
||||||
|
- `RealtimeController` — `/api/realtime/points` (realtime_table 조회/삭제)
|
||||||
|
- `MetadataController` — `/api/metadata` (tag_metadata 조회)
|
||||||
|
- `SubAreaController` — `/api/subarea` (sub-area 관리)
|
||||||
|
- `Hc900GatewayClient` — gRPC ListTags, HealthCheck
|
||||||
|
- `Hc900RealtimeService` — 폴링 서비스 상태 노출
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 핵심 차이점
|
||||||
|
|
||||||
|
### 2.1 데이터 소스 차이
|
||||||
|
|
||||||
|
| 항목 | ExperionCrawler | HC900 Crawler |
|
||||||
|
|------|----------------|---------------|
|
||||||
|
| **소스 테이블** | `node_map_master` (`Level=3`, OPC UA Variables) | `hc900_map_master` (Modbus 레지스터) |
|
||||||
|
| **태그 식별자** | `NodeId` (`ns=1;s=ti-6101.pv`) | `TagName` (`FICQ-6101.PV`) |
|
||||||
|
| **데이터 타입** | `DataType` 컬럼 (Boolean/Double/Int16 ...) | `DataType` 컬럼 (float32/uint16/int32 ...) |
|
||||||
|
| **속성** | `Name` 컬럼 (pv/sp/op/md) | `ParamType` 컬럼 (PV/SP/OP/MODE/STATUS) |
|
||||||
|
| **그룹 기준** | OPC UA Browse 계층 (controller1/analogmon1/...) | HC900 구조 (Loop# + ParamType) |
|
||||||
|
| **태그 계층** | OPC UA NodeId의 ns/s prefix | `base_tag.attribute` dot notation |
|
||||||
|
|
||||||
|
### 2.2 동작 방식 차이
|
||||||
|
|
||||||
|
| 항목 | ExperionCrawler | HC900 Crawler |
|
||||||
|
|------|----------------|---------------|
|
||||||
|
| **포인트 등록** | `node_map_master` → `realtime_table` INSERT | `hc900_map_master.IsActive = true` 설정 |
|
||||||
|
| **포인트 삭제** | `realtime_table` DELETE | `hc900_map_master.IsActive = false` 설정 + `realtime_table` DELETE |
|
||||||
|
| **실시간 구독** | OPC UA AddMonitoredItem | Gateway가 `is_active` 태그 자동 폴링 |
|
||||||
|
| **구독 시작/중지** | OPC UA 세션 제어 | Gateway 프로세스 시작/중지 |
|
||||||
|
| **수동 추가** | OPC UA NodeId 입력 + 유효성 검증 | `hc900_map_master`에 직접 INSERT |
|
||||||
|
|
||||||
|
### 2.3 그룹 체계 차이
|
||||||
|
|
||||||
|
ExperionCrawler는 5개 그룹(`controller1`, `analogmon1`, `digital1`, `digital2`, `custom`)을
|
||||||
|
OPC UA Browse 결과에 따라 나누지만, HC900은 구조가 다르므로 아래와 같이 변환:
|
||||||
|
|
||||||
|
| ExperionCrawler 그룹 | HC900 Crawler 그룹 (대체) |
|
||||||
|
|---------------------|--------------------------|
|
||||||
|
| `controller1` | `loop` — PID Loop 파라미터 (PV/SP/OP/MODE 등) |
|
||||||
|
| `analogmon1` | `signal` — Signal Tag (아날로그 모니터링) |
|
||||||
|
| `digital1` | `digital` — 디지털 입력 (타입이 digital/Boolean인 것) |
|
||||||
|
| `digital2` | `variable` — Variable 태그 (R/W 커스텀 변수) |
|
||||||
|
| `custom` | `custom` — 사용자 정의 필터 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 데이터 모델 변경
|
||||||
|
|
||||||
|
### 3.1 `hc900_map_master` — 변경 없음 (이미 적절함)
|
||||||
|
|
||||||
|
현재 `Hc900MapEntry`의 컬럼이 Point Builder에 필요한 정보를 이미 포함:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class Hc900MapEntry {
|
||||||
|
int Id; // PK
|
||||||
|
string TagName; // e.g. "FICQ-6101.PV"
|
||||||
|
string Hc900Tag; // e.g. "FICQ-6101.PV"
|
||||||
|
int ModbusAddr; // 0x40 (0-based)
|
||||||
|
string DataType; // "float32", "uint16", "int32"
|
||||||
|
string Access; // "R" or "RW"
|
||||||
|
int? LoopNo; // PID loop number
|
||||||
|
string? ParamType; // "PV", "SP", "OP", "MODE", "STATUS", "SIG"
|
||||||
|
bool IsActive; // 폴링 대상 여부
|
||||||
|
string ControllerId; // "HC1", "C2", ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 `realtime_table` — 변경 없음
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class RealtimePoint {
|
||||||
|
int Id; // PK
|
||||||
|
string TagName; // e.g. "FICQ-6101.PV"
|
||||||
|
string NodeId; // hc900_tag (호환성 유지)
|
||||||
|
string? LiveValue; // 실시간 값
|
||||||
|
DateTime Timestamp; // 갱신 시각
|
||||||
|
string ControllerId; // "HC1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 `tag_metadata` — 변경 없음
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class TagMetadata {
|
||||||
|
int Id; // PK
|
||||||
|
string BaseTag; // e.g. "FICQ-6101"
|
||||||
|
string Attribute; // "desc", "area", "sub_area", "state0"~"state7"
|
||||||
|
string? Value; // actual value
|
||||||
|
string? NodeId; // unused in HC900
|
||||||
|
DateTime LoadedAt;
|
||||||
|
string ControllerId;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API 엔드포인트 설계
|
||||||
|
|
||||||
|
> **Base**: `/api/pointbuilder`
|
||||||
|
>
|
||||||
|
> 기존 `Hc900TagManagerController`는 유지. Point Builder는 별도 컨트롤러로 분리.
|
||||||
|
|
||||||
|
### 4.1 `POST /api/pointbuilder/preview`
|
||||||
|
|
||||||
|
hc900_map_master에서 조건에 맞는 태그를 미리보기.
|
||||||
|
|
||||||
|
**Request Body**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"controller1": { "tagPatterns": ["FICQ-61", "TICQ-61"], "paramTypes": ["PV","SP"], "dataType": null, "loopNo": null },
|
||||||
|
"analogmon1": { "tagPatterns": ["TI-61", "PI-62"], "paramTypes": ["PV"], "dataType": null, "loopNo": null },
|
||||||
|
"digital1": { "tagPatterns": ["YS-61", "YT-62"], "paramTypes": ["STATUS"], "dataType": null, "loopNo": null },
|
||||||
|
"variable": { "tagPatterns": [], "paramTypes": [], "dataType": null, "loopNo": null },
|
||||||
|
"custom": { "tagPatterns": [], "paramTypes": [], "dataType": null, "loopNo": null }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**필드 설명**:
|
||||||
|
| 필드 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| `tagPatterns` | `string[]` | `hc900_map_master.TagName`에 대한 `ILIKE` 패턴 (`%` 와일드카드) |
|
||||||
|
| `paramTypes` | `string[]` | `ParamType` 필터 (PV / SP / OP / MODE / STATUS / SIG / VAR) |
|
||||||
|
| `dataType` | `string?` | `DataType` 필터 (`float32` / `uint16` / `int32` etc). null=전체 |
|
||||||
|
| `loopNo` | `int?` | `LoopNo` 필터. null=전체 |
|
||||||
|
|
||||||
|
**DB 조건**:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM hc900_map_master
|
||||||
|
WHERE (TagName ILIKE <pattern1> OR TagName ILIKE <pattern2> ...)
|
||||||
|
AND ParamType IN (<paramTypes>) -- paramTypes가 비어있으면 생략
|
||||||
|
AND DataType = <dataType> -- dataType이 null이면 생략
|
||||||
|
AND (loop_no = <loopNo> OR <loopNo> IS NULL)
|
||||||
|
ORDER BY TagName
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"count": 42,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"tagName": "FICQ-6101.PV",
|
||||||
|
"hc900Tag": "FICQ-6101.PV",
|
||||||
|
"modbusAddr": 64,
|
||||||
|
"paramType": "PV",
|
||||||
|
"dataType": "float32",
|
||||||
|
"loopNo": 1,
|
||||||
|
"access": "R",
|
||||||
|
"controllerId": "HC1",
|
||||||
|
"group": "controller1",
|
||||||
|
"isActive": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**새 DTO** (`PointBuilderPreviewItem` 확장):
|
||||||
|
```csharp
|
||||||
|
public class Hc900PointBuilderPreviewItem
|
||||||
|
{
|
||||||
|
public string TagName { get; set; } = "";
|
||||||
|
public string Hc900Tag { get; set; } = "";
|
||||||
|
public int ModbusAddr { get; set; }
|
||||||
|
public string ParamType { get; set; } = "";
|
||||||
|
public string DataType { get; set; } = "";
|
||||||
|
public int? LoopNo { get; set; }
|
||||||
|
public string Access { get; set; } = "R";
|
||||||
|
public string ControllerId { get; set; } = "HC1";
|
||||||
|
public string Group { get; set; } = "";
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 `POST /api/pointbuilder/build`
|
||||||
|
|
||||||
|
조건에 맞는 모든 태그를 **활성화** (기존 활성 태그는 모두 **비활성화** 후 조건 매칭 태그만 활성화).
|
||||||
|
|
||||||
|
> **실제 동작**: `TRUNCATE realtime_table` 대신 `IsActive` 플래그 사용.
|
||||||
|
> 즉, 기존에 활성화된 태그 중 조건에 포함되지 않은 것은 `IsActive=false`로 변경.
|
||||||
|
|
||||||
|
**동작**:
|
||||||
|
1. `UPDATE hc900_map_master SET is_active = false` (전체 비활성화)
|
||||||
|
2. 조건 매칭된 태그들의 `is_active = true` 설정
|
||||||
|
3. 활성화된 태그를 `realtime_table`에 반영 (기존 행은 TRUNCATE 후 INSERT, 또는 upsert)
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"count": 42,
|
||||||
|
"message": "42개 포인트 활성화 완료"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 `POST /api/pointbuilder/apply`
|
||||||
|
|
||||||
|
미리보기에서 **선택한 태그만** 활성화 (기존 활성화 모두 비활성화 → 선택만 활성화).
|
||||||
|
|
||||||
|
**Request**: `{ "selectedTagNames": ["FICQ-6101.PV", "FICQ-6101.SP", ...] }`
|
||||||
|
|
||||||
|
### 4.4 `POST /api/pointbuilder/append`
|
||||||
|
|
||||||
|
선택한 태그를 **기존 활성화 목록에 추가** (중복 제외).
|
||||||
|
|
||||||
|
**Request**: `{ "selectedTagNames": [...] }`
|
||||||
|
|
||||||
|
### 4.5 `GET /api/pointbuilder/points`
|
||||||
|
|
||||||
|
등록된 모든 active 태그 조회 (= `GET /api/hc900/tags?active=true` + 실시간 값 join).
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total": 42,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"tagName": "FICQ-6101.PV",
|
||||||
|
"modbusAddr": 64,
|
||||||
|
"paramType": "PV",
|
||||||
|
"dataType": "float32",
|
||||||
|
"controllerId": "HC1",
|
||||||
|
"liveValue": "152.7",
|
||||||
|
"timestamp": "2026-06-08T12:34:56Z",
|
||||||
|
"isActive": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.6 `POST /api/pointbuilder/add`
|
||||||
|
|
||||||
|
태그를 수동으로 hc900_map_master에 추가하고 활성화.
|
||||||
|
|
||||||
|
**Request**: `{ "tagName": "FICQ-6201.PV", "modbusAddr": 320, "dataType": "float32", "loopNo": null, "paramType": "PV", "access": "R", "controllerId": "HC1" }`
|
||||||
|
|
||||||
|
**동작**:
|
||||||
|
1. `hc900_map_master`에 INSERT (중복 TagName+ControllerId 체크)
|
||||||
|
2. `is_active = true` 설정
|
||||||
|
3. C++ Gateway가 다음 폴링 사이클에 자동으로 포함
|
||||||
|
|
||||||
|
### 4.7 `DELETE /api/pointbuilder/{id}?purgeHistory=false`
|
||||||
|
|
||||||
|
포인트 삭제 (= 비활성화).
|
||||||
|
|
||||||
|
**실제 동작**:
|
||||||
|
1. `hc900_map_master`에서 `is_active = false`
|
||||||
|
2. `realtime_table`에서 해당 행 DELETE
|
||||||
|
3. 같은 base_tag의 잔여 행이 0이면 `tag_metadata` 고아 정리
|
||||||
|
4. `purgeHistory=true` → `DELETE FROM history_table WHERE tagname = ?`
|
||||||
|
|
||||||
|
### 4.8 보조 API (재사용, URL만 정리)
|
||||||
|
|
||||||
|
| Method | Endpoint | 설명 | 재사용 |
|
||||||
|
|--------|----------|------|--------|
|
||||||
|
| `GET` | `/api/gateway/health` | Gateway 헬스체크 | `GatewayController` 기존 |
|
||||||
|
| `GET` | `/api/gateway/status` | 폴링 서비스 상태 | `GatewayController` 기존 |
|
||||||
|
| `GET` | `/api/metadata` | tag_metadata 조회 | `MetadataController` 기존 |
|
||||||
|
| `GET` | `/api/subarea/{area}` | Sub-Area 현황 | `SubAreaController` 기존 |
|
||||||
|
| `PUT` | `/api/subarea/{baseTag}` | Sub-Area 수정 | `SubAreaController` 기존 |
|
||||||
|
| `POST` | `/api/subarea/seed` | Sub-Area 일괄 분류 | `SubAreaController` 기존 |
|
||||||
|
|
||||||
|
### 4.9 Gateway 구독 제어 (OPC UA 대체)
|
||||||
|
|
||||||
|
ExperionCrawler의 실시간 구독 시작/중지는 HC900에서 Gateway 프로세스 제어로 대체.
|
||||||
|
이는 `setup` 탭에 이미 구현되어 있으므로 Point Builder에서는 **상태 확인**만 제공:
|
||||||
|
|
||||||
|
| Method | Endpoint | 설명 |
|
||||||
|
|--------|----------|------|
|
||||||
|
| `GET` | `/api/gateway/health` | Gateway 상태 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 프론트엔드 변경
|
||||||
|
|
||||||
|
### 5.1 `pb.html` — 전체 교체 (ExperionCrawler 원본 기반)
|
||||||
|
|
||||||
|
ExperionCrawler 원본 `pb.html` (311줄)을 기반으로 HC900에 맞게 수정.
|
||||||
|
|
||||||
|
**수정 사항**:
|
||||||
|
|
||||||
|
| 섹션 | ExperionCrawler | HC900 변경 |
|
||||||
|
|------|----------------|------------|
|
||||||
|
| 헤더 | "포인트빌더 — node_map_master → realtime_table 구성" | "포인트빌더 — HC900 태그 활성화 관리" |
|
||||||
|
| 그룹명 | controller1/analogmon1/digital1/digital2/custom | loop/signal/digital/variable/custom |
|
||||||
|
| 속성 체크박스 | pv/op/sp/md | PV/SP/OP/MODE/STATUS/SIG/VAR |
|
||||||
|
| 데이터타입 선택 | Double/Boolean/String/Int16/UInt32/Float/... | float32/uint16/int32/int64/float64 |
|
||||||
|
| 패턴 매칭 | NodeId LIKE (`ns=1;s=ti-6101.pv`) | TagName ILIKE (`FICQ-6101.PV`) |
|
||||||
|
| 우측 카드: 구독 제어 | 서버IP/포트/계정 + 시작/중지 | **제거** (setup 탭에서 관리) |
|
||||||
|
| 우측 카드: 수동 추가 | Node ID 직접 입력 | **변경**: TagName + ModbusAddr + DataType + ParamType 입력 |
|
||||||
|
| 우측 하단: Sub-Area | 유지 | 유지 (동일 로직) |
|
||||||
|
| 포인트 목록 테이블 | NodeId/TagName/LiveValue/Timestamp | TagName/ParamType/DataType/ModbusAddr/LiveValue/Timestamp/ControllerId |
|
||||||
|
|
||||||
|
**그룹 카드 레이아웃 수정**:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- 루프 파라미터 (기존: controller1) -->
|
||||||
|
<div class="pb-group-card" data-group="loop">
|
||||||
|
<div class="pb-group-header">
|
||||||
|
<span class="card-sub-cap">PID 루프 파라미터 #1</span>
|
||||||
|
</div>
|
||||||
|
<input class="inp pb-pattern-input"
|
||||||
|
data-group="loop" data-field="tagPatterns"
|
||||||
|
placeholder="FICQ-61, TICQ-62, ... (쉼표 구분)">
|
||||||
|
<div class="pb-attr-checkboxes">
|
||||||
|
<label><input type="checkbox" value="PV" checked data-group="loop" data-field="paramTypes"> PV</label>
|
||||||
|
<label><input type="checkbox" value="SP" checked data-group="loop" data-field="paramTypes"> SP</label>
|
||||||
|
<label><input type="checkbox" value="OP" checked data-group="loop" data-field="paramTypes"> OP</label>
|
||||||
|
<label><input type="checkbox" value="MODE" data-group="loop" data-field="paramTypes"> MODE</label>
|
||||||
|
<label><input type="checkbox" value="STATUS" data-group="loop" data-field="paramTypes"> STATUS</label>
|
||||||
|
</div>
|
||||||
|
<div class="pb-custom-attr-inputs">
|
||||||
|
<input class="inp" data-group="loop" data-field="customParamTypes" placeholder="추가 속성 (SIG, VAR ...)">
|
||||||
|
</div>
|
||||||
|
<select class="inp pb-datatype-select" data-group="loop" data-field="dataType">
|
||||||
|
<option value="">모든 타입</option>
|
||||||
|
<option value="float32">float32</option>
|
||||||
|
<option value="uint16">uint16</option>
|
||||||
|
<option value="int32">int32</option>
|
||||||
|
<option value="int64">int64</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 `pb.js` — 전체 교체
|
||||||
|
|
||||||
|
**핵심 변경 함수**:
|
||||||
|
|
||||||
|
| 함수 | ExperionCrawler | HC900 변경 |
|
||||||
|
|------|----------------|------------|
|
||||||
|
| `PB_GROUPS` | `['controller1','analogmon1','digital1','digital2','custom']` | `['loop','signal','digital','variable','custom']` |
|
||||||
|
| `pbCollectGroupData()` | `querySelector` by `attributes`, `dataType` | `paramTypes`로 변경 |
|
||||||
|
| `pbBuild()` | POST `/api/pointbuilder/build` | 동일 엔드포인트, 새로운 동작 |
|
||||||
|
| `pbPreview()` | POST `/api/pointbuilder/preview` | 동일 (internal 로직 변경) |
|
||||||
|
| `pbRenderPreview()` | NodeId/TagName/Name/DataType/Group | TagName/ParamType/DataType/LoopNo/Group/ControllerId |
|
||||||
|
| `pbAddManual()` | NodeId 하나만 입력 | TagName + ModbusAddr + DataType + ParamType + Access + ControllerId |
|
||||||
|
| `pbRefresh()` | `GET /api/pointbuilder/points` | Active 태그 + 실시간 값 조인 |
|
||||||
|
| `rtStart()` / `rtStop()` / `rtStatus()` | OPC UA 구독 제어 | **삭제** (setup 탭에서 처리) |
|
||||||
|
|
||||||
|
**데이터 수집 함수 (pbCollectGroupData)**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// HC900 버전
|
||||||
|
function pbCollectGroupData(groupKey) {
|
||||||
|
const tagPatterns = document.querySelector(
|
||||||
|
`input[data-group="${groupKey}"][data-field="tagPatterns"]`
|
||||||
|
).value.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
const checkedParamTypes = Array.from(
|
||||||
|
document.querySelectorAll(
|
||||||
|
`input[data-group="${groupKey}"][data-field="paramTypes"]:checked`
|
||||||
|
)
|
||||||
|
).map(cb => cb.value);
|
||||||
|
|
||||||
|
const customInputs = document.querySelectorAll(
|
||||||
|
`input[data-group="${groupKey}"][data-field="customParamTypes"]`
|
||||||
|
);
|
||||||
|
customInputs.forEach(inp => {
|
||||||
|
if (inp.value.trim()) checkedParamTypes.push(inp.value.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
const dataType = document.querySelector(
|
||||||
|
`select[data-group="${groupKey}"][data-field="dataType"]`
|
||||||
|
)?.value || null;
|
||||||
|
|
||||||
|
return { tagPatterns, paramTypes: checkedParamTypes, dataType };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 `pb.css` — 재사용 가능
|
||||||
|
|
||||||
|
ExperionCrawler 원본 `pb.css` (106줄)은 그대로 재사용 가능하다.
|
||||||
|
`pb-group-card`, `pb-preview`, `.group-badge` 등의 클래스가 동일하게 적용된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Hc900DbService 변경
|
||||||
|
|
||||||
|
### 6.1 `BuildRealtimeTableAsync` — 새 구현
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<int> BuildRealtimeTableAsync(IEnumerable<Hc900PointBuilderGroupDto> groups)
|
||||||
|
{
|
||||||
|
// 1. 전체 비활성화
|
||||||
|
await _ctx.Database.ExecuteSqlRawAsync(
|
||||||
|
"UPDATE hc900_map_master SET is_active = false");
|
||||||
|
|
||||||
|
// 2. 조건별 태그 활성화
|
||||||
|
int total = 0;
|
||||||
|
foreach (var (groupKey, group) in groups)
|
||||||
|
{
|
||||||
|
var matched = await BuildGroupQuery(group).ToListAsync();
|
||||||
|
foreach (var entry in matched)
|
||||||
|
{
|
||||||
|
entry.IsActive = true;
|
||||||
|
total++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await _ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
// 3. realtime_table 동기화 (활성 태그만 존재하도록)
|
||||||
|
await SyncRealtimeTableAsync();
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<Hc900MapEntry> BuildGroupQuery(Hc900PointBuilderGroupDto group)
|
||||||
|
{
|
||||||
|
var q = _ctx.Hc900MapEntries.AsQueryable();
|
||||||
|
|
||||||
|
if (group.TagPatterns?.Any() == true)
|
||||||
|
{
|
||||||
|
var patterns = group.TagPatterns.Select(p => p.Replace("%", "").ToLower()).ToList();
|
||||||
|
var likeClauses = patterns.Select(p =>
|
||||||
|
(Expression<Func<Hc900MapEntry, bool>>)(x =>
|
||||||
|
EF.Functions.ILike(x.TagName, $"%{p}%")));
|
||||||
|
// OR 결합
|
||||||
|
q = q.Where(likeClauses.Aggregate((a, b) =>
|
||||||
|
Expression.Lambda<Func<Hc900MapEntry, bool>>(
|
||||||
|
Expression.OrElse(a.Body, Expression.Invoke(b, a.Parameters[0])),
|
||||||
|
a.Parameters[0])));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.ParamTypes?.Any() == true)
|
||||||
|
q = q.Where(x => group.ParamTypes.Contains(x.ParamType));
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(group.DataType))
|
||||||
|
q = q.Where(x => x.DataType == group.DataType);
|
||||||
|
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 `PreviewRealtimeBuildAsync` — 새 구현
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<Hc900PointBuilderPreviewResult> PreviewRealtimeBuildAsync(
|
||||||
|
IEnumerable<(string GroupKey, Hc900PointBuilderGroupDto Group)> groups)
|
||||||
|
{
|
||||||
|
var items = new List<Hc900PointBuilderPreviewItem>();
|
||||||
|
foreach (var (groupKey, group) in groups)
|
||||||
|
{
|
||||||
|
var matched = await BuildGroupQuery(group)
|
||||||
|
.Select(e => new Hc900PointBuilderPreviewItem
|
||||||
|
{
|
||||||
|
TagName = e.TagName,
|
||||||
|
Hc900Tag = e.Hc900Tag,
|
||||||
|
ModbusAddr = e.ModbusAddr,
|
||||||
|
ParamType = e.ParamType ?? "",
|
||||||
|
DataType = e.DataType,
|
||||||
|
LoopNo = e.LoopNo,
|
||||||
|
Access = e.Access,
|
||||||
|
ControllerId = e.ControllerId,
|
||||||
|
Group = groupKey,
|
||||||
|
IsActive = e.IsActive
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
items.AddRange(matched);
|
||||||
|
}
|
||||||
|
return new Hc900PointBuilderPreviewResult
|
||||||
|
{
|
||||||
|
Count = items.Count,
|
||||||
|
Items = items
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 `ApplySelectedPointsAsync` — 새 구현
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<int> ApplySelectedPointsAsync(IEnumerable<string> selectedTagNames)
|
||||||
|
{
|
||||||
|
var tagNames = selectedTagNames.Where(n => !string.IsNullOrEmpty(n)).ToList();
|
||||||
|
if (tagNames.Count == 0) return 0;
|
||||||
|
|
||||||
|
// 1. 전체 비활성화
|
||||||
|
await _ctx.Database.ExecuteSqlRawAsync(
|
||||||
|
"UPDATE hc900_map_master SET is_active = false");
|
||||||
|
|
||||||
|
// 2. 선택만 활성화
|
||||||
|
var count = await _ctx.Hc900MapEntries
|
||||||
|
.Where(x => tagNames.Contains(x.TagName))
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(x => x.IsActive, true));
|
||||||
|
|
||||||
|
// 3. realtime_table 동기화
|
||||||
|
await SyncRealtimeTableAsync();
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 `AppendPointsAsync` — 새 구현
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<int> AppendPointsAsync(IEnumerable<string> tagNames)
|
||||||
|
{
|
||||||
|
var tagNamesList = tagNames.Where(n => !string.IsNullOrEmpty(n)).ToList();
|
||||||
|
if (tagNamesList.Count == 0) return 0;
|
||||||
|
|
||||||
|
var count = await _ctx.Hc900MapEntries
|
||||||
|
.Where(x => tagNamesList.Contains(x.TagName) && !x.IsActive)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(x => x.IsActive, true));
|
||||||
|
|
||||||
|
await SyncRealtimeTableAsync();
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 `SyncRealtimeTableAsync` — 신규 헬퍼
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>
|
||||||
|
/// hc900_map_master의 활성 태그 목록과 realtime_table을 동기화.
|
||||||
|
/// realtime_table에는 활성 태그만 존재해야 함.
|
||||||
|
/// </summary>
|
||||||
|
private async Task SyncRealtimeTableAsync()
|
||||||
|
{
|
||||||
|
var activeTags = await _ctx.Hc900MapEntries
|
||||||
|
.Where(x => x.IsActive)
|
||||||
|
.Select(x => new { x.TagName, x.Hc900Tag, x.ControllerId })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// TRUNCATE 후 INSERT (간단한 approach)
|
||||||
|
await _ctx.Database.ExecuteSqlRawAsync(
|
||||||
|
"TRUNCATE TABLE realtime_table RESTART IDENTITY");
|
||||||
|
|
||||||
|
var points = activeTags.Select(t => new RealtimePoint
|
||||||
|
{
|
||||||
|
TagName = t.TagName,
|
||||||
|
NodeId = t.Hc900Tag,
|
||||||
|
LiveValue = null,
|
||||||
|
Timestamp = DateTime.UtcNow,
|
||||||
|
ControllerId = t.ControllerId
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
if (points.Count > 0)
|
||||||
|
{
|
||||||
|
_ctx.RealtimePoints.AddRange(points);
|
||||||
|
await _ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.6 `AddRealtimePointAsync` — 수정
|
||||||
|
|
||||||
|
NodeId 대신 TagName으로 Hc900MapEntry를 생성/조회:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public async Task<RealtimePoint> AddRealtimePointAsync(string tagName, string hc900Tag,
|
||||||
|
int modbusAddr, string dataType, string? paramType, string access, string controllerId)
|
||||||
|
{
|
||||||
|
// 1. hc900_map_master에 추가
|
||||||
|
var existing = await _ctx.Hc900MapEntries
|
||||||
|
.FirstOrDefaultAsync(x => x.TagName == tagName && x.ControllerId == controllerId);
|
||||||
|
if (existing == null)
|
||||||
|
{
|
||||||
|
existing = new Hc900MapEntry
|
||||||
|
{
|
||||||
|
TagName = tagName,
|
||||||
|
Hc900Tag = hc900Tag ?? tagName,
|
||||||
|
ModbusAddr = modbusAddr,
|
||||||
|
DataType = dataType,
|
||||||
|
ParamType = paramType,
|
||||||
|
Access = access,
|
||||||
|
ControllerId = controllerId,
|
||||||
|
IsActive = true
|
||||||
|
};
|
||||||
|
_ctx.Hc900MapEntries.Add(existing);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existing.IsActive = true;
|
||||||
|
}
|
||||||
|
await _ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
// 2. realtime_table에 반영
|
||||||
|
return await SyncSinglePointAsync(tagName, controllerId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 마이그레이션 순서
|
||||||
|
|
||||||
|
### Phase A: 백엔드 (1일)
|
||||||
|
|
||||||
|
1. **DTO 신규 작성**
|
||||||
|
- `Hc900PointBuilderGroupDto` — `PointBuilderGroupDto` 대체 (TagPatterns, ParamTypes, DataType)
|
||||||
|
- `Hc900PointBuilderPreviewItem` — preview item (TagName, ModbusAddr, ParamType, Group 등)
|
||||||
|
- `Hc900PointBuilderPreviewResult` — preview 결과
|
||||||
|
- `Hc900PointBuilderBuildDto` — build 요청
|
||||||
|
- `Hc900PointBuilderApplyDto` — apply 요청 (SelectedTagNames)
|
||||||
|
- `Hc900PointBuilderAddDto` — 수동 추가 요청
|
||||||
|
|
||||||
|
2. **Controller 신규 작성** — `PointBuilderController.cs`
|
||||||
|
- `/api/pointbuilder/preview`
|
||||||
|
- `/api/pointbuilder/build`
|
||||||
|
- `/api/pointbuilder/apply`
|
||||||
|
- `/api/pointbuilder/append`
|
||||||
|
- `/api/pointbuilder/points`
|
||||||
|
- `/api/pointbuilder/add`
|
||||||
|
- `DELETE /api/pointbuilder/{id}`
|
||||||
|
|
||||||
|
3. **Hc900DbService 구현**
|
||||||
|
- `BuildRealtimeTableAsync` — 새 구현 (기존 NotImplementedException 대체)
|
||||||
|
- `PreviewRealtimeBuildAsync` — 새 구현
|
||||||
|
- `ApplySelectedPointsAsync` — 새 구현
|
||||||
|
- `AppendPointsAsync` — 새 구현
|
||||||
|
- `AddRealtimePointAsync` — 수정
|
||||||
|
- `SyncRealtimeTableAsync` — 신규
|
||||||
|
|
||||||
|
### Phase B: 프론트엔드 (1일)
|
||||||
|
|
||||||
|
4. **`pb.html` 재작성**
|
||||||
|
- ExperionCrawler 원본 레이아웃 유지
|
||||||
|
- 그룹명/필드명 HC900에 맞게 수정
|
||||||
|
- 우측 컬럼: 구독 제어 → 제거 (Gateway 상태만 표시)
|
||||||
|
- 우측 컬럼: 수동 추가 → TagName + ModbusAddr + DataType + ParamType + Access + ControllerId
|
||||||
|
|
||||||
|
5. **`pb.js` 재작성**
|
||||||
|
- 데이터 수집: `attributes` → `paramTypes`
|
||||||
|
- Preview: `NodeId` → `TagName` + `ParamType` + `LoopNo`
|
||||||
|
- Build/Apply: 전체 교체는 `IsActive` 플립
|
||||||
|
- rtStart/rtStop/rtStatus → 제거
|
||||||
|
- 수동 추가: 확장된 필드
|
||||||
|
|
||||||
|
6. **`pb.css`** — 유지 (수정 불필요)
|
||||||
|
|
||||||
|
### Phase C: 통합 테스트 (0.5일)
|
||||||
|
|
||||||
|
7. **테스트 시나리오**
|
||||||
|
- Preview: 루프/시그널/디지털 그룹별 필터 확인
|
||||||
|
- Build: 전체 재구축 후 활성 태그 변경 확인
|
||||||
|
- Apply: 선택 적용 확인
|
||||||
|
- Append: 기존 유지 + 추가 확인
|
||||||
|
- Add: 수동 추가 후 Gateway 폴링 확인
|
||||||
|
- Delete: 비활성화 + 이력 삭제 확인
|
||||||
|
|
||||||
|
### Phase D: 기존 태그 관리 페이지 처리 (0.5일)
|
||||||
|
|
||||||
|
8. **기존 `pb.html` 태그 관리 기능**
|
||||||
|
- 현재 "태그 관리" 탭의 요약 카드, 필터, 테이블, 활성화 토글, Bulk 액션 등은
|
||||||
|
Point Builder의 "포인트 목록" 섹션과 통합하거나 별도 탭으로 유지
|
||||||
|
- **제안**: `pb` 탭을 Point Builder로 완전 교체하고,
|
||||||
|
기존 태그 관리 기능은 `setup` 탭에 통합하거나 `#pane-tag-manager`로 분리
|
||||||
|
- 또는 `pb` 탭에 두 가지 서브뷰 제공:
|
||||||
|
- **빌더 뷰** (기본): ExperionCrawler 스타일 포인트 선택/활성화
|
||||||
|
- **관리 뷰**: 현재의 태그 목록 테이블 + Bulk 액션
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록: API 라우트 비교표
|
||||||
|
|
||||||
|
| ExperionCrawler | HC900 (변경 후) | 비고 |
|
||||||
|
|----------------|----------------|------|
|
||||||
|
| `POST /api/pointbuilder/preview` | `POST /api/pointbuilder/preview` | 동일 (내부 로직 변경) |
|
||||||
|
| `POST /api/pointbuilder/build` | `POST /api/pointbuilder/build` | 동일 (IsActive 토글로 변경) |
|
||||||
|
| `POST /api/pointbuilder/apply` | `POST /api/pointbuilder/apply` | 동일 (IsActive 토글로 변경) |
|
||||||
|
| `POST /api/pointbuilder/append` | `POST /api/pointbuilder/append` | 동일 (IsActive 토글로 변경) |
|
||||||
|
| `GET /api/pointbuilder/points` | `GET /api/pointbuilder/points` | 동일 (쿼리만 변경) |
|
||||||
|
| `POST /api/pointbuilder/add` | `POST /api/pointbuilder/add` | 동일 (필드 확장) |
|
||||||
|
| `DELETE /api/pointbuilder/{id}` | `DELETE /api/pointbuilder/{id}` | 동일 |
|
||||||
|
| `POST /api/realtime/start` | `POST /api/gateway/start` | Gateway process 제어로 이동 |
|
||||||
|
| `POST /api/realtime/stop` | `POST /api/gateway/stop` | Gateway process 제어로 이동 |
|
||||||
|
| `GET /api/realtime/status` | `GET /api/gateway/status` | 이미 구현됨 |
|
||||||
|
| `POST /api/tags/metadata/reload` | — | 불필요 (OPC UA 재조회 불가) |
|
||||||
|
| `GET /api/tags/metadata` | `GET /api/metadata` | 이미 구현됨 |
|
||||||
|
| `GET /api/tags/sub-area` | `GET /api/subarea/{area}` | 이미 구현됨 |
|
||||||
|
| `PUT /api/tags/sub-area` | `PUT /api/subarea/{baseTag}` | 이미 구현됨 |
|
||||||
|
| `POST /api/tags/sub-area/seed` | `POST /api/subarea/seed` | 이미 구현됨 |
|
||||||
545
docs/포인트빌더-페이지-구현-명세.md
Normal file
545
docs/포인트빌더-페이지-구현-명세.md
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
# 포인트빌더 페이지 구현 명세
|
||||||
|
|
||||||
|
> ExperionCrawler 웹 UI의 Tab #06 — OPC UA node_map_master에서 실시간 모니터링할 포인트를 선택해 `realtime_table`을 구성하는 페이지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
|
||||||
|
**목적**: OPC UA 서버의 노드맵(`node_map_master` 테이블)에서 조건(태그명 패턴, 속성, 데이터타입)으로 필터링하여 실시간 구독할 포인트를 `realtime_table`에 등록/관리한다.
|
||||||
|
|
||||||
|
**사용자 플로우**:
|
||||||
|
1. 그룹별 태그 패턴 입력 → 미리보기로 대상 확인
|
||||||
|
2. 원하는 포인트 선택 → 적용(전체 교체) 또는 추가(기존 유지)
|
||||||
|
3. 등록된 포인트 목록 확인 / 개별 삭제
|
||||||
|
4. 실시간 구독 시작/중지
|
||||||
|
5. 메타데이터(desc/area) 갱신
|
||||||
|
6. Sub-Area 분류 관리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. API 엔드포인트
|
||||||
|
|
||||||
|
Base: `/api/pointbuilder`
|
||||||
|
|
||||||
|
### 2.1 `POST /api/pointbuilder/preview`
|
||||||
|
|
||||||
|
조건에 맞는 포인트를 미리보기 (읽기 전용, DB 변경 없음)
|
||||||
|
|
||||||
|
**Request Body** (`PointBuilderBuildDto`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"controller1": { "tagPatterns": ["%ctl-61%.pv","%ctl-62%.sp"], "attributes": ["pv","sp"], "dataType": null },
|
||||||
|
"analogmon1": { "tagPatterns": ["%ti-61%.pv","%pi-62%.pv"], "attributes": ["pv"], "dataType": null },
|
||||||
|
"digital1": { "tagPatterns": ["%ys-61%.pv","%yt-62%.pv"], "attributes": ["pv"], "dataType": "Boolean" },
|
||||||
|
"digital2": { "tagPatterns": [], "attributes": [], "dataType": null },
|
||||||
|
"custom": { "tagPatterns": [], "attributes": [], "dataType": null }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 필드 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| `tagPatterns` | `string[]` | SQL LIKE 패턴 (쉼표 구분 아님 — 프론트에서 분할하여 배열로 전달). `node_map_master.NodeId`에 대해 `LIKE` 검색 |
|
||||||
|
| `attributes` | `string[]` | `node_map_master.Name` 필터 (pv/sp/op/md 등). 커스텀 속성 입력도 여기에 병합 |
|
||||||
|
| `dataType` | `string?` | `node_map_master.DataType` 필터. null이면 전체 |
|
||||||
|
|
||||||
|
**group keys**: `controller1`, `analogmon1`, `digital1`, `digital2`, `custom`
|
||||||
|
|
||||||
|
**DB 조건**:
|
||||||
|
```sql
|
||||||
|
SELECT ... FROM node_map_master WHERE Level = 3
|
||||||
|
AND (NodeId LIKE <pattern1> OR NodeId LIKE <pattern2> ...)
|
||||||
|
AND Name IN (<attrs>)
|
||||||
|
AND DataType = <dataType> -- dataType이 null이면 생략
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"count": 42,
|
||||||
|
"items": [
|
||||||
|
{ "nodeId": "ns=1;s=ti-6101.pv", "tagName": "ti-6101.pv", "name": "pv", "dataType": "Double", "group": "analogmon1" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 `POST /api/pointbuilder/build`
|
||||||
|
|
||||||
|
조건에 맞는 포인트로 **기존 `realtime_table` 전체 교체** (TRUNCATE → INSERT)
|
||||||
|
|
||||||
|
- Request Body: `PointBuilderBuildDto` (preview와 동일한 구조)
|
||||||
|
- 동작: `TRUNCATE TABLE realtime_table RESTART IDENTITY` 후 조건 매칭된 포인트 INSERT
|
||||||
|
- 메타데이터 자동 로드: 빌드 완료 후 `IMetadataLoaderService.ReloadMetadataAsync()` 호출 (실패해도 Warning 로그만, 빌드는 완료)
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"count": 42,
|
||||||
|
"metaCount": 40,
|
||||||
|
"message": "42개 포인트 생성 완료 (메타데이터: 40개)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 `POST /api/pointbuilder/apply`
|
||||||
|
|
||||||
|
**미리보기에서 선택한 포인트만**으로 `realtime_table` **전체 교체**
|
||||||
|
|
||||||
|
- Request Body: `{ "selectedNodeIds": ["ns=1;s=ti-6101.pv", "ns=1;s=ficq-6113.pv"] }`
|
||||||
|
- 동작: TRUNCATE → 선택된 NodeId만 INSERT
|
||||||
|
- 메타데이터 자동 로드 포함
|
||||||
|
|
||||||
|
**Response**: `{ "success": true, "count": 42, "metaCount": 40, "message": "..." }`
|
||||||
|
|
||||||
|
### 2.4 `POST /api/pointbuilder/append`
|
||||||
|
|
||||||
|
**미리보기에서 선택한 포인트를 기존 데이터에 추가** (중복 제외, 기존 유지)
|
||||||
|
|
||||||
|
- Request Body: `{ "selectedNodeIds": [...] }`
|
||||||
|
- 동작: `NodeId` 기준 중복 체크 후 없는 것만 INSERT
|
||||||
|
- 메타데이터 자동 로드 포함
|
||||||
|
|
||||||
|
### 2.5 `GET /api/pointbuilder/points`
|
||||||
|
|
||||||
|
등록된 모든 realtime 포인트 조회
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total": 42,
|
||||||
|
"items": [
|
||||||
|
{ "id": 1, "tagName": "ti-6101.pv", "nodeId": "ns=1;s=ti-6101.pv", "liveValue": "25.3", "timestamp": "2026-06-08T12:34:56Z" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.6 `POST /api/pointbuilder/add`
|
||||||
|
|
||||||
|
Node ID를 직접 입력하여 수동 포인트 추가
|
||||||
|
|
||||||
|
**Request**: `{ "nodeId": "ns=1;s=my-custom-tag.pv" }`
|
||||||
|
|
||||||
|
동작:
|
||||||
|
1. `IExperionDbService.AddRealtimePointAsync()` — DB에 INSERT (중복이면 기존 반환)
|
||||||
|
2. `IExperionRealtimeService.AddMonitoredItemAsync()` — 구독 중이면 OPC UA에 핫 추가 + Node ID 유효성 검증
|
||||||
|
3. OPC UA 서버가 거부하면 DB 롤백
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{ "success": true, "point": { "id": 43, "tagName": "my-custom-tag.pv", "nodeId": "ns=1;s=my-custom-tag.pv" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.7 `DELETE /api/pointbuilder/{id}?purgeHistory=false`
|
||||||
|
|
||||||
|
포인트 삭제
|
||||||
|
|
||||||
|
| 파라미터 | 기본값 | 설명 |
|
||||||
|
|---------|-------|------|
|
||||||
|
| `purgeHistory` | `false` | `true`이면 해당 tagname의 `history_table` 이력까지 영구 삭제 (복구 불가, 명시적 opt-in) |
|
||||||
|
|
||||||
|
동작:
|
||||||
|
1. realtime_table 행 삭제
|
||||||
|
2. 같은 base_tag의 잔여 행이 0이면 → `tag_metadata`(desc/area/sub_area) 고아 정리
|
||||||
|
3. purgeHistory=true → `DELETE FROM history_table WHERE tagname = ?`
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "삭제 완료",
|
||||||
|
"baseTag": "ti-6101",
|
||||||
|
"metadataPurged": true,
|
||||||
|
"historyRowsDeleted": 1440
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 보조 API 엔드포인트 (같은 페이지에서 사용)
|
||||||
|
|
||||||
|
### 3.1 실시간 구독 제어
|
||||||
|
|
||||||
|
| Method | Endpoint | 설명 | Request |
|
||||||
|
|--------|----------|------|---------|
|
||||||
|
| `POST` | `/api/realtime/start` | 구독 시작 | `{ serverHostName, port, clientHostName, userName, password }` |
|
||||||
|
| `POST` | `/api/realtime/stop` | 구독 중지 | — |
|
||||||
|
| `GET` | `/api/realtime/status` | 상태 조회 | — |
|
||||||
|
|
||||||
|
**status response**: `{ "running": true, "subscribedCount": 42, "message": "..." }`
|
||||||
|
|
||||||
|
### 3.2 메타데이터 관리
|
||||||
|
|
||||||
|
| Method | Endpoint | 설명 |
|
||||||
|
|--------|----------|------|
|
||||||
|
| `POST` | `/api/tags/metadata/reload` | OPC UA에서 desc/area 재조회 → tag_metadata UPDATE |
|
||||||
|
| `GET` | `/api/tags/metadata` | 모든 tag_metadata 조회 |
|
||||||
|
|
||||||
|
### 3.3 Sub-Area 관리
|
||||||
|
|
||||||
|
| Method | Endpoint | 설명 |
|
||||||
|
|--------|----------|------|
|
||||||
|
| `GET` | `/api/tags/sub-area?area=P6&page=1&pageSize=500` | area별 sub_area 현황 조회 |
|
||||||
|
| `PUT` | `/api/tags/sub-area` | 단일 태그 sub_area 수정. Body: `{ baseTag, subArea }` (null=미분류) |
|
||||||
|
| `POST` | `/api/tags/sub-area/seed` | 번호 prefix + pid_equipment로 일괄 분류. Body: `{ dryRun: bool }` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. UI 레이아웃 (2-Column Grid + 하단 포인트 목록)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ [pane-hdr] 포인트빌더 — node_map_master → realtime_table 구성 │
|
||||||
|
├─────────────────────────────┬────────────────────────────────┤
|
||||||
|
│ [card: 빌더] │ [card: 수동 추가] │
|
||||||
|
│ ┌────────────────────────┐ │ ┌──────────────────────────┐ │
|
||||||
|
│ │ 카드 제목 │ │ │ Node ID 직접 입력 │ │
|
||||||
|
│ │ 조건으로 테이블 작성 │ │ │ [inp] │ │
|
||||||
|
│ │ │ │ │ [btn] + 추가 │ │
|
||||||
|
│ │ 그룹 카드 × 5개 │ │ │ [log] │ │
|
||||||
|
│ │ · 컨트롤러 #1 │ │ └──────────────────────────┘ │
|
||||||
|
│ │ · 아날로그 모니터 #2 │ │ [card: 구독 제어] │
|
||||||
|
│ │ · 디지털 #1 │ │ ┌──────────────────────────┐ │
|
||||||
|
│ │ · 디지털 #2 │ │ │ 서버IP/포트/계정/비번 │ │
|
||||||
|
│ │ · 사용자 정의 │ │ │ [btn] ▶ 구독 시작 │ │
|
||||||
|
│ │ ┌─────────────────┐ │ │ │ [btn] ■ 구독 중지 │ │
|
||||||
|
│ │ │ 태그명 패턴(inp)│ │ │ │ [btn] 상태 확인 │ │
|
||||||
|
│ │ │ [pv] [op] [sp] │ │ │ │ [log: 상태] │ │
|
||||||
|
│ │ │ [md] 체크박스 │ │ │ └──────────────────────────┘ │
|
||||||
|
│ │ │ [추가속성1][추가2]│ │ │ │
|
||||||
|
│ │ │ [데이터타입 선택] │ │ │ [card: 메타데이터 관리] │
|
||||||
|
│ │ └─────────────────┘ │ │ ┌──────────────────────────┐ │
|
||||||
|
│ │ │ │ │ [btn] 🔄 메타데이터 갱신 │ │
|
||||||
|
│ │ [btn] 미리보기 │ │ │ [btn] 📋 메타데이터 조회 │ │
|
||||||
|
│ │ [btn] 테이블 작성하기 │ │ │ [log] │ │
|
||||||
|
│ │ [btn] 테이블 조회 │ │ │ [view: metadata table] │ │
|
||||||
|
│ │ │ │ └──────────────────────────┘ │
|
||||||
|
│ │ [preview: 미리보기] │ │ │
|
||||||
|
│ │ ┌────────────────┐ │ │ [card: Sub-Area 관리] │
|
||||||
|
│ │ │ 전체선택/해제 │ │ │ ┌──────────────────────────┐ │
|
||||||
|
│ │ │ 역전 | 검색창 │ │ │ │ Area 선택 | 조회 │ │
|
||||||
|
│ │ │ ┌─── 테이블 ──┐│ │ │ │ Seed DryRun | Seed 실행 │ │
|
||||||
|
│ │ │ │ ☑ ID TagNam ││ │ │ │ [log] │ │
|
||||||
|
│ │ │ │ ☑ 1 ti-6101 ││ │ │ │ [view: subarea table] │ │
|
||||||
|
│ │ │ │ ☑ 2 pi-6102 ││ │ │ └──────────────────────────┘ │
|
||||||
|
│ │ │ └─────────────┘│ │ │ │
|
||||||
|
│ │ │ [취소][적용][추가]│ │ │ │
|
||||||
|
│ │ └────────────────┘ │ │ │
|
||||||
|
│ └────────────────────────┘ │ │
|
||||||
|
├─────────────────────────────┴────────────────────────────────┤
|
||||||
|
│ [card: 포인트 목록 (전체 width)] │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────┐│
|
||||||
|
│ │ ID | TagName | LiveValue | Timestamp | 이력/삭제││
|
||||||
|
│ │ 1 | TI-6101.PV | 25.3 | 06-08 12:34 | ☐이력 ✕ ││
|
||||||
|
│ │ 2 | FICQ-6113.PV | 152.7 | 06-08 12:34 | ☐이력 ✕ ││
|
||||||
|
│ └──────────────────────────────────────────────────────────┘│
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.1 그룹 카드 (pb-group-card)
|
||||||
|
|
||||||
|
총 5개의 그룹 카드가 존재하며, 각 카드는 동일한 구조:
|
||||||
|
|
||||||
|
| 요소 | CSS 셀렉터 | 설명 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| 헤더 | `.pb-group-header > .card-sub-cap` | 그룹명 (컨트롤러 포인트 #1, 아날로그 모니터링 포인트 #2, ...) |
|
||||||
|
| 태그 패턴 | `input[data-group="{key}"][data-field="tagPatterns"]` | 쉼표 구분 LIKE 패턴. placeholder에 예시 표시 |
|
||||||
|
| 속성 체크박스 | `.pb-attr-checkboxes > label > input[type=checkbox]` | pv/op/sp/md 4개. `data-group`, `data-field="attributes"` |
|
||||||
|
| 커스텀 속성 | `.pb-custom-attr-inputs > input[data-field="customAttrs"]` | 추가 속성 텍스트 입력 2개 (선택) |
|
||||||
|
| 데이터타입 선택 | `select[data-field="dataType"]` | Double / i=7594 / Boolean / String / Int16 / Int32 / UInt16 / UInt32 / Float / DateTime + "(전체)" |
|
||||||
|
|
||||||
|
data 속성으로 그룹 연결:
|
||||||
|
- `data-group`: `controller1` / `analogmon1` / `digital1` / `digital2` / `custom`
|
||||||
|
- `data-field`: 식별
|
||||||
|
|
||||||
|
### 4.2 미리보기 영역 (pb-preview)
|
||||||
|
|
||||||
|
- 초기 상태: `.hidden` 클래스로 숨김
|
||||||
|
- "미리보기" 버튼 클릭 시 표시
|
||||||
|
- 헤더: "미리보기 결과 (N개)" + 액션 버튼들 (전체 선택 / 전체 해제 / 역전 / 선택: X/Y)
|
||||||
|
- 검색창: 태그명/NodeId/Name 실시간 필터링 (oninput)
|
||||||
|
- 테이블: ☑ 선택체크박스 | # | TagName | NodeType | DataType | Group
|
||||||
|
- 선택되지 않은 행: `opacity: 0.5`
|
||||||
|
- Group 뱃지: `.group-badge` (파란색 배경)
|
||||||
|
- 하단 버튼: [취소] [✓ 선택된 포인트 적용하기] [+ 기존 데이터에 추가하기]
|
||||||
|
|
||||||
|
### 4.3 포인트 목록 (하단, 전체 폭)
|
||||||
|
|
||||||
|
- 빈 상태: "포인트가 없습니다. 위에서 테이블을 작성하세요." (회색)
|
||||||
|
- 테이블: ID | TagName (uppercase, bold) | LiveValue (fmtVal+parseEnumPv 적용) | Timestamp (fmtTs 적용) | 이력체크박스 / 삭제
|
||||||
|
- LiveValue가 null이면 `—` 표시
|
||||||
|
- 삭제 버튼: 붉은색 `✕`, confirm dialog 표시
|
||||||
|
- "이력" 체크박스 체크 시: ⚠️ 이력(history_table)까지 영구 삭제 메시지
|
||||||
|
- 미체크 시: 이력 보존 안내
|
||||||
|
- 삭제 완료 후 alert: "삭제 완료: TI-6101 (#1) (메타데이터 정리됨 · 이력 1,440행 삭제)" 등의 상세 정보
|
||||||
|
|
||||||
|
### 4.4 실시간 구독 제어
|
||||||
|
|
||||||
|
4개 필드 입력: server IP / port / client host / 계정 / 비밀번호 (password type)
|
||||||
|
- 2×2 CSS grid (비밀번호는 `grid-column: 1/-1` 전체 폭)
|
||||||
|
- 버튼: ▶ 구독 시작 / ■ 구독 중지 / 상태 확인
|
||||||
|
- 구독 상태 표시: logbox에 running 여부 + 구독 포인트 개수
|
||||||
|
|
||||||
|
### 4.5 Sub-Area 관리
|
||||||
|
|
||||||
|
- Area 선택 드롭다운: P6 / P9 / P10 / P1 / P2
|
||||||
|
- 버튼: 📋 조회 / 🧪 Seed DryRun / ⚙️ Seed 실행
|
||||||
|
- 조회 결과: 테이블 (BaseTag | Description | Sub-Area select)
|
||||||
|
- Sub-Area: `<select>`에 area별 옵션(예: P6-1, P6-2, P6-1,P6-2(공용)) + 현재값 표시
|
||||||
|
- 변경 시 onchange → PUT `/api/tags/sub-area`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 데이터 흐름
|
||||||
|
|
||||||
|
### 5.1 미리보기 → 적용 플로우
|
||||||
|
|
||||||
|
```
|
||||||
|
[사용자] 그룹 카드에 패턴/속성 입력
|
||||||
|
↓
|
||||||
|
pbPreview() → POST /api/pointbuilder/preview
|
||||||
|
↓
|
||||||
|
{ items: [{nodeId, tagName, name, dataType, group}] }
|
||||||
|
↓
|
||||||
|
pbPreviewData 배열 생성 (각 item에 selected:true, idx 추가)
|
||||||
|
↓
|
||||||
|
pbRenderPreview() → 체크박스 있는 테이블 렌더링
|
||||||
|
↓
|
||||||
|
[사용자] 체크박스 조작 / 검색 필터 / 선택/해제
|
||||||
|
↓
|
||||||
|
pbApplySelected() / pbAppendSelected()
|
||||||
|
↓
|
||||||
|
POST /api/pointbuilder/apply (또는 /append)
|
||||||
|
body: { selectedNodeIds: [...] }
|
||||||
|
↓
|
||||||
|
DB TRUNCATE + INSERT (apply) / 중복제외 INSERT (append)
|
||||||
|
↓
|
||||||
|
pbRefresh() → 포인트 목록 재조회
|
||||||
|
↓
|
||||||
|
포인트 목록 갱신 + 실시간 상태 갱신
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 그룹 데이터 수집 (pbCollectGroupData)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function pbCollectGroupData(groupKey) {
|
||||||
|
const tagPatterns = document.querySelector(`input[data-group="${groupKey}"][data-field="tagPatterns"]`)
|
||||||
|
.value.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
const checkedAttrs = Array.from(
|
||||||
|
document.querySelectorAll(`input[data-group="${groupKey}"][data-field="attributes"]:checked`)
|
||||||
|
).map(cb => cb.value);
|
||||||
|
|
||||||
|
const customInputs = document.querySelectorAll(`input[data-group="${groupKey}"][data-field="customAttrs"]`);
|
||||||
|
customInputs.forEach(inp => {
|
||||||
|
if (inp.value.trim()) checkedAttrs.push(inp.value.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
const dataType = document.querySelector(`select[data-group="${groupKey}"][data-field="dataType"]`)?.value || null;
|
||||||
|
|
||||||
|
return { tagPatterns, attributes: checkedAttrs, dataType };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 삭제 플로우
|
||||||
|
|
||||||
|
```
|
||||||
|
[사용자] "이력" 체크박스 선택 여부 → confirm 대화상자
|
||||||
|
↓
|
||||||
|
pbDelete(id, tagName) → DELETE /api/pointbuilder/{id}?purgeHistory={bool}
|
||||||
|
↓
|
||||||
|
{ success, baseTag, metadataPurged, historyRowsDeleted }
|
||||||
|
↓
|
||||||
|
alert("삭제 완료: TI-6101.PV (메타데이터 정리됨 · 이력 1,440행 삭제)")
|
||||||
|
↓
|
||||||
|
pbRefresh() → 목록 갱신
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. UI 상태 관리
|
||||||
|
|
||||||
|
### 6.1 전역 상태 (core.js)
|
||||||
|
|
||||||
|
| 함수 | 용도 |
|
||||||
|
|------|------|
|
||||||
|
| `setGlobal(type, msg)` | 하단 상태바 업데이트. type: 'busy'/'ok'/'err' |
|
||||||
|
| `esc(str)` | HTML 이스케이프 |
|
||||||
|
| `api(method, url, body?)` | fetch wrapper + JSON 파싱 |
|
||||||
|
| `fmtVal(val)` | 숫자 포맷팅 |
|
||||||
|
| `fmtTs(iso)` | UTC ISO → KST locale 문자열 변환 |
|
||||||
|
| `parseEnumPv(val)` | enum 값 파싱 |
|
||||||
|
| `log(elId, entries)` | logbox에 메시지 추가. entries: `[{c: 'ok'|'err'|'inf', t: 'msg'}]` |
|
||||||
|
| `setGlobal('busy', '포인트 빌드 중')` | 빌드/적용 중 busy 표시 |
|
||||||
|
| `setGlobal('ok', '구독 중')` | 성공 표시 |
|
||||||
|
|
||||||
|
### 6.2 지역 상태 (pb.js)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const PB_GROUPS = ['controller1', 'analogmon1', 'digital1', 'digital2', 'custom'];
|
||||||
|
let pbPreviewData = []; // [{ nodeId, tagName, name, dataType, group, selected, idx }]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. CSS 구조
|
||||||
|
|
||||||
|
### 7.1 pb.css 전용 스타일
|
||||||
|
|
||||||
|
```css
|
||||||
|
.pb-group-card /* 그룹 카드 배경/테두리 */
|
||||||
|
.pb-group-header /* 그룹 헤더 flex */
|
||||||
|
.pb-pattern-input /* 패턴 입력창 full width */
|
||||||
|
.pb-attr-checkboxes /* 체크박스 가로 배치 */
|
||||||
|
.pb-custom-attr-inputs /* 커스텀 속성 입력 flex */
|
||||||
|
.pb-datatype-select /* 데이터타입 선택 (max-width 260px) */
|
||||||
|
.pb-preview /* 미리보기 영역 */
|
||||||
|
.pb-preview-header /* 미리보기 헤더 */
|
||||||
|
.pb-preview-actions /* 액션 버튼 flex */
|
||||||
|
.pb-preview table th:first-child, td:first-child /* 체크박스 컬럼 36px */
|
||||||
|
.pb-preview .group-badge /* 그룹명 뱃지 */
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 공유 CSS 클래스 (style.css)
|
||||||
|
|
||||||
|
| 클래스 | 용도 |
|
||||||
|
|--------|------|
|
||||||
|
| `.card` | 카드 컨테이너 |
|
||||||
|
| `.card-cap` | 섹션 제목 |
|
||||||
|
| `.card-sub-cap` | 그룹 서브제목 |
|
||||||
|
| `.fg` | 폼 그룹 (label + input) |
|
||||||
|
| `.inp` | 입력 필드 |
|
||||||
|
| `.btn-a` | 주요 버튼 (파랑) |
|
||||||
|
| `.btn-b` | 보조 버튼 (테두리) |
|
||||||
|
| `.btn-sm` | 작은 버튼 |
|
||||||
|
| `.btn-row` | 버튼 행 flex |
|
||||||
|
| `.cols-2` | 2열 CSS grid |
|
||||||
|
| `.tbl-wrap` | 테이블 래퍼 (overflow-x: auto) |
|
||||||
|
| `.logbox` | 로그 출력 영역 |
|
||||||
|
| `.hidden` | display: none !important |
|
||||||
|
| `.mut` | 회색 텍스트 |
|
||||||
|
| `.mono` | 고정폭 폰트 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. DB 테이블 구조 (참조)
|
||||||
|
|
||||||
|
### 8.1 node_map_master (소스)
|
||||||
|
|
||||||
|
컬럼: `Id, Level, NodeClass, Name, NodeId, DataType, DisplayName, HasChildren`
|
||||||
|
|
||||||
|
PointBuilder 쿼리 조건: `Level = 3` (Variable), `NodeId LIKE`, `Name IN`, `DataType =`
|
||||||
|
|
||||||
|
### 8.2 realtime_table (대상)
|
||||||
|
|
||||||
|
컬럼: `Id, TagName, NodeId, LiveValue, Timestamp`
|
||||||
|
|
||||||
|
`TagName` = `node_map_master.NodeId` 에서 `ns=1;s=` prefix 제거 후 `.` 포함 전체 (예: `ti-6101.pv`)
|
||||||
|
|
||||||
|
### 8.3 tag_metadata
|
||||||
|
|
||||||
|
컬럼: `Id, BaseTag, Attribute, Value, LoadedAt`
|
||||||
|
|
||||||
|
EAV 패턴: `attribute` = `desc` / `area` / `sub_area` / `state0descriptor` ~ `state7descriptor`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 중요 구현 규칙
|
||||||
|
|
||||||
|
### 9.1 JSON camelCase 필수
|
||||||
|
|
||||||
|
`PropertyNamingPolicy = null`이므로 C# DTO의 PascalCase가 그대로 JSON 키가 된다.
|
||||||
|
프론트는 **모든 응답을 camelCase로 가정**하므로 Controller의 Ok()는 반드시 익명 객체에 camelCase 키를 명시:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ✅ 올바름
|
||||||
|
return Ok(new { success = true, count = x, items = ... });
|
||||||
|
|
||||||
|
// ❌ 금지 (JS가 undefined 받음)
|
||||||
|
return Ok(new { Success = true, Count = x });
|
||||||
|
return Ok(myDto); // typed DTO 그대로 반환 금지
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 미리보기 데이터의 selected 상태
|
||||||
|
|
||||||
|
프론트에서만 관리: `pbPreviewData[i].selected` (초기값 `true`). 서버에 저장하지 않음.
|
||||||
|
|
||||||
|
### 9.3 삭제 시 "이력" 체크박스 의미
|
||||||
|
|
||||||
|
- 기본값: `false` (이력 보존, `purgeHistory=false`)
|
||||||
|
- 체크 시: `purgeHistory=true` → `DELETE FROM history_table WHERE tagname = ?` (복구 불가)
|
||||||
|
- `confirm()` 대화상자에 미리 경고문 표시
|
||||||
|
|
||||||
|
### 9.4 메타데이터 자동 로드 실패 허용
|
||||||
|
|
||||||
|
Build/Apply/Append 후 메타데이터 로드는 실패해도 Warning 로그만 남기고 사용자에게는 빌드 성공으로 표시.
|
||||||
|
프론트 응답의 `metaCount` 필드로 정보 제공.
|
||||||
|
|
||||||
|
### 9.5 수동 추가 시 OPC UA 검증
|
||||||
|
|
||||||
|
수동 추가 → `AddMonitoredItemAsync()`에서 OPC UA 서버가 NodeId를 거부하면:
|
||||||
|
1. DB에 먼저 INSERT
|
||||||
|
2. OPC UA 검증 실패 시 `DeleteRealtimePointAsync()`로 롤백
|
||||||
|
3. 프론트에 실패 메시지 반환
|
||||||
|
|
||||||
|
### 9.6 Sub-Area 토큰 매칭
|
||||||
|
|
||||||
|
공용 설비는 `"P6-1,P6-2"` 형식으로 저장. 매칭은 항상:
|
||||||
|
```sql
|
||||||
|
'P6-1' = ANY(string_to_array(value, ','))
|
||||||
|
```
|
||||||
|
직접 비교 (`value = 'P6-1'`) 금지 — 공용 태그 누락 방지.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 전체 함수 목록 (pb.js)
|
||||||
|
|
||||||
|
| 함수 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| `pbCollectGroupData(groupKey)` | 그룹 카드 데이터 수집 → `{tagPatterns, attributes, dataType}` |
|
||||||
|
| `pbBuild()` | POST /build (전체 교체) |
|
||||||
|
| `pbPreview()` | POST /preview + 미리보기 렌더링 |
|
||||||
|
| `pbRenderPreview(data)` | 미리보기 테이블 렌더링 |
|
||||||
|
| `pbPreviewToggleItem(idx)` | 개별 체크박스 토글 |
|
||||||
|
| `pbPreviewToggleAll(checked)` | 전체 선택/해제 |
|
||||||
|
| `pbPreviewSelectAll()` | 모두 선택 |
|
||||||
|
| `pbPreviewDeselectAll()` | 모두 해제 |
|
||||||
|
| `pbPreviewInvert()` | 역전 |
|
||||||
|
| `pbGetFilteredPreview()` | 검색어로 필터링 |
|
||||||
|
| `pbPreviewFilter()` | oninput 핸들러 |
|
||||||
|
| `pbUpdatePreviewCount()` | 선택 개수 업데이트 |
|
||||||
|
| `pbCancelPreview()` | 미리보기 닫기 + 데이터 초기화 |
|
||||||
|
| `pbApplySelected()` | POST /apply (선택만 적용) |
|
||||||
|
| `pbAppendSelected()` | POST /append (선택만 추가) |
|
||||||
|
| `pbRefresh()` | GET /points → 목록 갱신 |
|
||||||
|
| `pbRender(points)` | 포인트 목록 테이블 렌더링 |
|
||||||
|
| `pbAddManual()` | POST /add (수동 추가) |
|
||||||
|
| `pbDelete(id, tagName)` | DELETE (이력 체크 포함) |
|
||||||
|
| `rtStart()` | POST /realtime/start |
|
||||||
|
| `rtStop()` | POST /realtime/stop |
|
||||||
|
| `rtStatus()` | GET /realtime/status |
|
||||||
|
| `metaReload()` | POST /tags/metadata/reload |
|
||||||
|
| `metaView()` | GET /tags/metadata |
|
||||||
|
| `subAreaLoad()` | GET /tags/sub-area |
|
||||||
|
| `subAreaUpdate(baseTag, subArea)` | PUT /tags/sub-area |
|
||||||
|
| `subAreaSeed(dryRun)` | POST /tags/sub-area/seed |
|
||||||
|
| `subAreaLabel(code)` | sub_area 코드 → 라벨 변환 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. HTML/JS/CSS 파일 목록
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `src/Web/wwwroot/index.html` | shell — nav item #06 "포인트빌더", pane #pane-pb, script 로드 |
|
||||||
|
| `src/Web/wwwroot/panes/pb.html` | pane HTML (311 lines) |
|
||||||
|
| `src/Web/wwwroot/js/pb.js` | pane JS (503 lines) |
|
||||||
|
| `src/Web/wwwroot/css/pb.css` | pane CSS (106 lines) |
|
||||||
|
| `src/Web/Controllers/ExperionControllers.cs` | `ExperionPointBuilderController` (190 lines) |
|
||||||
|
| `src/Core/Application/DTOs/ExperionDtos.cs` | `PointBuilderGroupDto`, `PointBuilderBuildDto`, `PointBuilderPreviewItem`, `PointBuilderPreviewResult`, `PointBuilderApplyDto`, `PointBuilderAddDto` |
|
||||||
|
| `src/Core/Application/DTOs/SubAreaDtos.cs` | `PointDeleteResult` |
|
||||||
|
| `src/Infrastructure/Database/ExperionDbContext.cs` | `BuildRealtimeTableAsync`, `PreviewRealtimeBuildAsync`, `ApplySelectedPointsAsync`, `AppendPointsAsync`, `GetRealtimePointsAsync`, `AddRealtimePointAsync`, `DeleteRealtimePointAsync` |
|
||||||
|
| `src/Core/Application/Interfaces/IExperionServices.cs` | `IExperionDbService` (lines 82-92) |
|
||||||
|
| `src/Web/wwwroot/js/core.js` | 공유 유틸: `api`, `esc`, `setGlobal`, `fmtVal`, `fmtTs`, `parseEnumPv`, `log` |
|
||||||
|
| `src/Web/wwwroot/css/style.css` | 디자인 시스템: 카드, 폼, 버튼, 테이블, 로그박스, CSS 커스텀 속성 |
|
||||||
Reference in New Issue
Block a user