From d88784635ee6b14ec9a705fd38f268db2b3d55f3 Mon Sep 17 00:00:00 2001 From: windpacer Date: Wed, 10 Jun 2026 08:12:01 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20=EC=9E=91=EC=97=85=EC=A7=80=EC=8B=9C?= =?UTF-8?q?=C2=B7=EC=A7=84=EB=8B=A8=C2=B7=EC=95=84=ED=82=A4=ED=85=8D?= =?UTF-8?q?=EC=B2=98=20=EC=84=A4=EA=B3=84=20=EB=AC=B8=EC=84=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 온도프로파일/PV일관성/PointBuilder/history 작업지시, 신호태그·스팀유량 진단, 베이직아키텍처 재설계, MSDS, LLM채팅 구조 등. Co-Authored-By: Claude Opus 4.8 --- HANOVER.md | 35 + docs/LLM-채팅-태그-구조-문제점.md | 64 ++ docs/MSDS_PMA_PGMEA_EL.md | 220 +++++ docs/diagnosis-signal-tag-pv-missing.md | 414 ++++++++ docs/diagnosis-steam-flow-display.md | 497 ++++++++++ docs/베이직아키텍처-태그-디자인-전면재설계.md | 910 ++++++++++++++++++ docs/작업지시-PV일관성-롤아웃.md | 162 ++++ .../작업지시-PointBuilder-Sinam파싱-UI통합.md | 169 ++++ docs/작업지시-history-디지털잔재정리.md | 95 ++ docs/작업플랜-온도프로파일-과거이력.md | 338 +++++++ docs/작업플랜-온도프로파일-기준선택.md | 479 +++++++++ docs/작업플랜-온도프로파일-사용자정의.md | 192 ++++ docs/작업플랜-온도프로파일-이력조회.md | 81 ++ docs/포인트빌더-이식-설계서.md | 825 ++++++++++++++++ docs/포인트빌더-페이지-구현-명세.md | 545 +++++++++++ 15 files changed, 5026 insertions(+) create mode 100644 HANOVER.md create mode 100644 docs/LLM-채팅-태그-구조-문제점.md create mode 100644 docs/MSDS_PMA_PGMEA_EL.md create mode 100644 docs/diagnosis-signal-tag-pv-missing.md create mode 100644 docs/diagnosis-steam-flow-display.md create mode 100644 docs/베이직아키텍처-태그-디자인-전면재설계.md create mode 100644 docs/작업지시-PV일관성-롤아웃.md create mode 100644 docs/작업지시-PointBuilder-Sinam파싱-UI통합.md create mode 100644 docs/작업지시-history-디지털잔재정리.md create mode 100644 docs/작업플랜-온도프로파일-과거이력.md create mode 100644 docs/작업플랜-온도프로파일-기준선택.md create mode 100644 docs/작업플랜-온도프로파일-사용자정의.md create mode 100644 docs/작업플랜-온도프로파일-이력조회.md create mode 100644 docs/포인트빌더-이식-설계서.md create mode 100644 docs/포인트빌더-페이지-구현-명세.md diff --git a/HANOVER.md b/HANOVER.md new file mode 100644 index 0000000..81e1364 --- /dev/null +++ b/HANOVER.md @@ -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화 잔여) | diff --git a/docs/LLM-채팅-태그-구조-문제점.md b/docs/LLM-채팅-태그-구조-문제점.md new file mode 100644 index 0000000..01ba925 --- /dev/null +++ b/docs/LLM-채팅-태그-구조-문제점.md @@ -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 생성 로직 추가 diff --git a/docs/MSDS_PMA_PGMEA_EL.md b/docs/MSDS_PMA_PGMEA_EL.md new file mode 100644 index 0000000..a5418c8 --- /dev/null +++ b/docs/MSDS_PMA_PGMEA_EL.md @@ -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* diff --git a/docs/diagnosis-signal-tag-pv-missing.md b/docs/diagnosis-signal-tag-pv-missing.md new file mode 100644 index 0000000..31e7ffa --- /dev/null +++ b/docs/diagnosis-signal-tag-pv-missing.md @@ -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을 생성한다. diff --git a/docs/diagnosis-steam-flow-display.md b/docs/diagnosis-steam-flow-display.md new file mode 100644 index 0000000..37c36be --- /dev/null +++ b/docs/diagnosis-steam-flow-display.md @@ -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 TagsFor(string p) +{ + var m = new Dictionary + { + ["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`, 두 개의 `` 생성 + +```js +function stRenderTemp(snap) { + stTempUpdateBadges(); + if (!stTempLive) { + const el = document.getElementById('st-chart-temp'); + if (el) el.innerHTML = '
데이터 없음
'; + 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 ``; + }); + // ΔT(A-D) 추가 + if (snapSrc.spanAD != null) { + tempRows.push(``); + } + // 진공 추가 + 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(``); + } + + // 오른쪽 표: 유량 + let flowHeaders, flowRows; + if (snapSrc.flow) { + const f = snapSrc.flow; + const ft = snapSrc.flowTags || {}; + flowHeaders = [ + '', + ``, + ``, + ``, + ``, + ``, + ].join(''); + flowRows = [ + ``, + ``, + ``, + ].join(''); + } + + meta.innerHTML = ` +
+
${label}${cur}${ref}
ΔT(A-D)${stFmt(snapSrc.spanAD)}℃
진공${cur}${ref}
FEED
${esc(ft.feed || 'FICQ-??01.PV')}
REFLUX
${esc(ft.reflux || 'FICQ-??13.PV')}
제품추출
${esc(ft.product || 'FICQ-??18.PV')}
경비물
${esc(ft.overhead || 'FICQ-??14.PV')}
중비물
${esc(ft.bottom || 'FICQ-??16.PV')}
PV${stFmt(f.feed?.pv)}${stFmt(f.reflux?.pv)}${stFmt(f.product?.pv)}${stFmt(f.overhead?.pv)}${stFmt(f.bottom?.pv)}
SP${stFmt(f.feed?.sp)}${stFmt(f.reflux?.sp)}${stFmt(f.product?.sp)}${stFmt(f.overhead?.sp)}${stFmt(f.bottom?.sp)}
OP${stFmt(f.feed?.op)}${stFmt(f.reflux?.op)}${stFmt(f.product?.op)}${stFmt(f.overhead?.op)}${stFmt(f.bottom?.op)}
${tempRows.join('')}
+ ${flowHeaders ? `${flowHeaders}${flowRows}
` : ''} + + `; +} +``` + +> **참고**: 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> 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(); + 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(중비물)** — 확인 완료 diff --git a/docs/베이직아키텍처-태그-디자인-전면재설계.md b/docs/베이직아키텍처-태그-디자인-전면재설계.md new file mode 100644 index 0000000..20ca8b3 --- /dev/null +++ b/docs/베이직아키텍처-태그-디자인-전면재설계.md @@ -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`) | diff --git a/docs/작업지시-PV일관성-롤아웃.md b/docs/작업지시-PV일관성-롤아웃.md new file mode 100644 index 0000000..c9a0904 --- /dev/null +++ b/docs/작업지시-PV일관성-롤아웃.md @@ -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> GetDigitalPointsAsync() +{ + var digitalTagNames = await GetDigitalTagNamesAsync(); + var tagSet = new HashSet(digitalTagNames); + + if (tagSet.Count == 0) + return Enumerable.Empty(); + + // 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> 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. diff --git a/docs/작업지시-PointBuilder-Sinam파싱-UI통합.md b/docs/작업지시-PointBuilder-Sinam파싱-UI통합.md new file mode 100644 index 0000000..e0f1eb3 --- /dev/null +++ b/docs/작업지시-PointBuilder-Sinam파싱-UI통합.md @@ -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 ()` — 수천 개 IN-list로 SQL 길이 폭발, PostgreSQL `NOT IN`의 NULL 처리 문제 발생 가능. + +**근거**: 문서 line 57-61 — `WHERE m.tagname NOT IN ()` +**영향**: 고아 정리 실패 또는 불완전 수행으로 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_.xlsx` (또는 임시 디렉터리). 반환: `{ file: "" }`. + +### 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 (); +``` +(JSON 태그를 C#에서 읽어 파라미터화, 또는 임시 staging 테이블 사용.) + +## 프론트엔드 (`wwwroot/panes/pb.html` 상단 카드 + `wwwroot/js/pb.js`) + +### pb.html — 요약 카드 위에 "Sinam 파싱" 카드 추가 +```html +
+

Sinam xlsx 파싱

+ + + + + +
+
+``` + +### 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`. diff --git a/docs/작업지시-history-디지털잔재정리.md b/docs/작업지시-history-디지털잔재정리.md new file mode 100644 index 0000000..e8cec39 --- /dev/null +++ b/docs/작업지시-history-디지털잔재정리.md @@ -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_.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`). diff --git a/docs/작업플랜-온도프로파일-과거이력.md b/docs/작업플랜-온도프로파일-과거이력.md new file mode 100644 index 0000000..f4cc715 --- /dev/null +++ b/docs/작업플랜-온도프로파일-과거이력.md @@ -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 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 +
+ 컬럼: + + 과거: + + + + + +
+``` + +--- + +### 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` — 사전 등록 필요 | diff --git a/docs/작업플랜-온도프로파일-기준선택.md b/docs/작업플랜-온도프로파일-기준선택.md new file mode 100644 index 0000000..5e36ae2 --- /dev/null +++ b/docs/작업플랜-온도프로파일-기준선택.md @@ -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` 추가. + +| 항목 | 값 | +|------|-----| +| Entity 클래스 | `TempRefProfileEntity` (내부 클래스 또는 별도 파일) | +| 테이블명 | `temp_ref_profiles` | +| 스키마 | `hc900` | +| EF Core | `modelBuilder.Entity(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 TempRefProfiles => Set(); + +// OnModelCreating: +modelBuilder.Entity(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 LoadTempRef(string col) + +// After: DB 기반 +private async Task 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( + 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) +``` + +- ` +
+ + + + + +
+
+ +
+ + +``` + +### 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 BuildRealtimeTableAsync(IEnumerable 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 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>)(x => + EF.Functions.ILike(x.TagName, $"%{p}%"))); + // OR 결합 + q = q.Where(likeClauses.Aggregate((a, b) => + Expression.Lambda>( + 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 PreviewRealtimeBuildAsync( + IEnumerable<(string GroupKey, Hc900PointBuilderGroupDto Group)> groups) +{ + var items = new List(); + 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 ApplySelectedPointsAsync(IEnumerable 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 AppendPointsAsync(IEnumerable 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 +/// +/// hc900_map_master의 활성 태그 목록과 realtime_table을 동기화. +/// realtime_table에는 활성 태그만 존재해야 함. +/// +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 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` | 이미 구현됨 | diff --git a/docs/포인트빌더-페이지-구현-명세.md b/docs/포인트빌더-페이지-구현-명세.md new file mode 100644 index 0000000..d29e811 --- /dev/null +++ b/docs/포인트빌더-페이지-구현-명세.md @@ -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 OR NodeId LIKE ...) + AND Name IN () + AND 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: `