P0 셀프서비스 결정론 리포트 — 적산·물질수지 폐합·cleaning 마스크 (+ P1 온라인 스펙) #1

Open
windpacer wants to merge 43 commits from feat/p0-selfservice-report into main
15 changed files with 5026 additions and 0 deletions
Showing only changes of commit d88784635e - Show all commits

35
HANOVER.md Normal file
View File

@@ -0,0 +1,35 @@
# 인수인계 — 다음 LLM 작업자를 위한 진입점
> 기준 커밋: `1f989bd` (`git log -1`으로 확인 후 진행)
## 착수 순서 (2순위)
| 순위 | 작업 | 진입점 |
|------|------|--------|
| **1순위** | 모듈1 shadow 검증 | `FeedforwardEngine.cs:163-201` Normal 분기(SteamRecOp 산출). `FfTrackingStore`에 shadow 로깅 재활용. |
| **2순위** | 모듈4-A 하강 램프 | `FeedRampCalculator.Compute()` 하강 분기(현재 `FeedRampCalculator.cs:87-88` — warning만 + 시간 0). `StreamConfig.RateDnPerMin`(이미 존재) 사용. |
## 스펙 문서 (이것만 읽으면 됨)
- `docs/작업플랜-민감단온도-전환복귀제어.md` — 구현 상태 표 + 모듈별 file:line
- `docs/작업플랜-FF온도계위젯-C민감단-sweetspot.md` — 구현 상태 표 + 일반화 원칙
## ★ 외부 LLM이 놓치기 쉬운 5가지 제약 (반드시 전달)
1. **★ closed-loop 자동제어 금지** — 전 모드 advisory-only 우선, 운전원 개별 follow 인가 시에만 write (메모리: 현장 조건 실변동 중).
2. **`TcReturnTcTarget`/`Band` 이중용도** — 위젯 시각화 밴드 = 엔진 복귀 게이트(`FeedforwardEngine.cs:497-498`). 위젯에서 이 값 편집 UI 만들지 말 것.
3. **현재 C3/6차만 online (C4 미연결)** — first-cut·검증은 6-1차. 위젯 단 라벨 config화는 C4 붙기 직전까지 보류.
4. **하드코딩 금지** — 단 개수·민감단 위치는 `temps[]` 순회 + `sensitiveTrayTag` 매칭으로. 잔여 하드코딩은 라벨 텍스트(`trayLabels`/`trayPcts`, `ff.js:288~`)뿐.
5. **range·기준값은 realtime/config live** — xlsx·상수 금지.
## 상태 요약
| 모듈 | 상태 |
|------|------|
| 모듈1 — T_C 유지 SP 제안(Normal) | ✅ 완료 |
| 모듈2 — 전환류(Recovering) 트리거 | ⏳ 미착수 |
| 모듈3 — 복귀(Returning) 게이트 | ⏳ 미착수 |
| 모듈4-A — 온도 하강 램프 | ⏳ 미착수 (2순위) |
| 모듈4-B — feed-steam 동반 램프 | ⏳ 후속 작업 |
| 모듈4-C — Bumpless 보장 | ⏳ 미착수 |
| 위젯 온도계 (ffThermometer) | ✅ 완료 (라벨 config화 잔여) |

View File

@@ -0,0 +1,64 @@
# LLM 채팅 태그 구조 문제점
> 발견일: 2026-06-09
## 문제 요약
LLM 채팅에서 "6-1차 플랜트(P6-1)의 현재 운전 상황" 조회 시 데이터 없음.
다음 4가지 원인이 중첩됨.
---
## 문제 1 (해결): MCP 서버 실행 경로
**원인:** `~/.config/opencode/opencode.json`에서 `ExperionCrawler/mcp-server`(구버전)로
설정되어 있었음. 구버전은 `search_path=hc900`이 없어 `v_tag_summary`를 찾지 못함.
**조치:** `hc900_ax/mcp-server`로 경로 변경 (`opencode.json` 수정)
## 문제 2 (해결): `v_tag_summary` 뷰 접미사 대소문자
**원인:** 뷰가 `.pv`(소문자)로 JOIN하지만 `realtime_table``.PV`(대문자) → 모든 PV=NULL
**조치:** 뷰 재정의 — `.PV`, `.SP`, `.OP` (대문자 접미사)
## 문제 3 (해결): `v_plant_running_state*` 뷰 영역/패턴 대소문자
**원인:**
- `split_part(area, '|', 2)`가 단축 영역코드(`P6`)에서 빈 문자열 반환
- `base_tag ~~ 'p-%'`가 대문자 태그명(`P-6101`)에 불일치
**조치:**
- `COALESCE` + 영역코드 정규화 (두 형식 모두 처리)
- `~~*` (ILIKE)로 대소문자 무시 매칭
## 문제 4 (해결): `tag_metadata`에 `sub_area` 미등록
**원인:** `build_register_map.py``sub_area` attribute를 생성하지 않음
**조치:** 태그번호 prefix 기반 sub_area 자동 매핑 스크립트 실행 → 802건 등록
## 문제 5 (진행중): 실시간 데이터 부족
**현상:** `event_history_table`에 P6-1 이벤트 없음
**원인:** HC900 게이트웨이 가동 2일차, 디지털 이벤트 수집 미설정
**조치:** 시간이 해결할 문제
---
## 현재 상태
| 항목 | 수정 전 | 수정 후 |
|------|---------|---------|
| `v_tag_summary` PV not null | 0건 (전체 NULL) | P6 118건 전부 정상 |
| `v_tag_summary` sub_area | 0건 | 345건 (전 area) |
| `v_plant_running_state` | 빈 결과 | P6=16펌프/5RUNNING |
| `find_tags(sub_area='P6-1')` | 작동 불가 | 재시작 후 정상 예상 |
| 라이브 데이터 | 2일치 | 충분치 않음 |
## 필요조치
- opencode 재시작 → `~/.config/opencode/opencode.json` 경로변경 반영
- (선택) `build_register_map.py`에 sub_area 생성 로직 추가

220
docs/MSDS_PMA_PGMEA_EL.md Normal file
View File

@@ -0,0 +1,220 @@
# MSDS 요약: PMA / PGMEA / EL
> 본 문서는 반도체 공정용 주요 용제 3종의 물질안전보건자료(MSDS)를 요약한 것입니다.
> 참고용으로만 사용하며, 최신 공식 MSDS를 반드시 병행 확인하십시오.
---
## 1. PMA (Propylene Glycol Monomethyl Ether Acetate)
### 1.1 기본 정보
| 항목 | 내용 |
|------|------|
| 화학명 | 1-Methoxy-2-propyl acetate |
| CAS No. | 108-65-6 |
| 분자식 | C₆H₁₂O₃ |
| 분자량 | 132.16 g/mol |
| 외관 | 무색 투명 액체 |
| 냄새 | 약한 에테르/에스테르 향 |
> **주의:** PMA와 PGMEA는 동일 물질입니다. 업계에서는 제조사·용도에 따라 혼용되며, 본 문서에서는 구분 표기합니다.
### 1.2 물리·화학적 특성
| 항목 | 값 |
|------|-----|
| 비점 | 146 °C |
| 융점 | 87 °C |
| 인화점 | 43 °C (밀폐컵) |
| 발화점 | 270 °C |
| 증기압 | 3.7 mmHg @ 20 °C |
| 증기밀도 | 4.6 (공기 = 1) |
| 비중 | 0.966 g/mL @ 20 °C |
| 수용해도 | 부분 용해 |
| 폭발한계 | 1.0 8.0 vol% |
### 1.3 유해·위험성
| 구분 | GHS 분류 |
|------|----------|
| 인화성 액체 | GHS02 — 구분 3 |
| 눈 자극 | GHS07 — 구분 2 |
| 특정 표적 장기 독성 (단회) | GHS08 — 구분 3 (마취 효과) |
- 증기 흡입 시 두통, 어지러움, 마취 증상
- 눈·피부 접촉 시 자극
- 생식독성 우려 (동물실험 참고)
### 1.4 응급조치
| 노출 경로 | 조치 |
|-----------|------|
| 흡입 | 신선한 공기로 이동, 증상 지속 시 의사 진찰 |
| 피부 | 오염 의복 제거 후 다량의 물로 세척 |
| 눈 | 흐르는 물로 15분 이상 세안, 즉시 의사 진찰 |
| 섭취 | 구토 유도 금지, 즉시 의사 진찰 |
### 1.5 취급·저장
- 점화원·열원으로부터 격리, 정전기 방지
- 밀폐 용기에 서늘하고 환기 좋은 장소 저장
- 산화제, 강산, 강염기와 격리
- 사용 구역 금연·금화기
### 1.6 누출 대응
- 점화원 제거 후 환기
- 모래, 흡수제로 회수 — 하수구·수계 유입 방지
- 폐기물 관련 법령에 따라 처리
### 1.7 노출 기준 및 보호구
| 항목 | 값 |
|------|-----|
| ACGIH TLV-TWA | 50 ppm |
| OSHA PEL | 100 ppm |
| 호흡 보호구 | 유기증기 카트리지 방독마스크 |
| 눈 보호구 | 화학용 보안경 또는 페이스 실드 |
| 피부 보호구 | 내화학성 장갑 (니트릴 또는 부틸) |
---
## 2. PGMEA (Propylene Glycol Monomethyl Ether Acetate)
> PMA와 동일 물질 (CAS 108-65-6).
> 반도체 포토레지스트 공정에서 주로 사용되는 명칭으로, 순도 및 금속 불순물 규격이 엄격하게 관리됩니다.
### 2.1 기본 정보
| 항목 | 내용 |
|------|------|
| 화학명 | Propylene Glycol Methyl Ether Acetate |
| 별칭 | PMA, PGMEA, 1-MPA |
| CAS No. | 108-65-6 |
| 주요 용도 | 포토레지스트 용제, 잉크, 코팅 |
| 등급 | Electronic Grade (반도체 공정용) |
### 2.2 물리·화학적 특성
PMA 항목과 동일 (1.2 참조)
### 2.3 반도체 공정 관련 주의사항
| 항목 | 내용 |
|------|------|
| 금속 불순물 | 각 원소 < 1 ppb (Electronic Grade) |
| 수분 함량 | < 50 ppm |
| 여과 | 사용 전 0.2 μm 필터 여과 권장 |
| 포장 | N₂ 퍼징 밀봉 용기 사용 |
### 2.4 유해·위험성 (PMA와 동일)
GHS 분류, 응급조치, 취급·저장, 노출 기준 모두 PMA 항목(1.3 ~ 1.7) 동일 적용.
---
## 3. EL (Ethyl Lactate)
### 3.1 기본 정보
| 항목 | 내용 |
|------|------|
| 화학명 | Ethyl (S)-(+)-Lactate (또는 Ethyl 2-hydroxypropanoate) |
| CAS No. | 97-64-3 |
| 분자식 | C₅H₁₀O₃ |
| 분자량 | 118.13 g/mol |
| 외관 | 무색 투명 액체 |
| 냄새 | 약한 과일향 |
| 주요 용도 | 포토레지스트 용제, 세정제, 식품 향료 |
### 3.2 물리·화학적 특성
| 항목 | 값 |
|------|-----|
| 비점 | 154 °C |
| 융점 | 25 °C |
| 인화점 | 53 °C (밀폐컵) |
| 발화점 | 400 °C |
| 증기압 | 1.5 mmHg @ 20 °C |
| 증기밀도 | 4.1 (공기 = 1) |
| 비중 | 1.031 g/mL @ 20 °C |
| 수용해도 | 혼화성 (임의 비율) |
| 폭발한계 | 1.5 8.5 vol% |
### 3.3 유해·위험성
| 구분 | GHS 분류 |
|------|----------|
| 인화성 액체 | GHS02 — 구분 3 |
| 눈 자극 | GHS07 — 구분 2 |
- 비교적 낮은 독성 (생분해성 우수, 친환경 용제로 분류)
- 고농도 증기 장시간 흡입 시 두통, 어지러움
- 눈·피부 경도 자극
> ✅ EL은 FDA 승인 식품 첨가물(GRAS)이며, PGMEA 대비 독성·환경 부담이 낮아 그린 용제로 주목받습니다.
### 3.4 응급조치
| 노출 경로 | 조치 |
|-----------|------|
| 흡입 | 신선한 공기로 이동, 증상 지속 시 의사 진찰 |
| 피부 | 다량의 물과 비누로 세척 |
| 눈 | 흐르는 물로 15분 이상 세안 |
| 섭취 | 입 헹굼, 다량의 물 음용, 의사 진찰 |
### 3.5 취급·저장
- 점화원으로부터 격리, 통풍이 양호한 장소 저장
- 서늘하고 건조한 환경 (권장 보관 온도: 15 ~ 25 °C)
- 강산화제, 강알칼리와 격리
- 흡습성 있으므로 밀봉 보관
### 3.6 누출 대응
- 점화원 제거 후 환기
- 흡수제 또는 모래로 회수
- 물에 희석 가능하나 대량 유출 시 수계 오염 방지
### 3.7 노출 기준 및 보호구
| 항목 | 값 |
|------|-----|
| ACGIH TLV | 설정 없음 (독성 낮음) |
| 호흡 보호구 | 유기증기 방독마스크 (고농도 작업 시) |
| 눈 보호구 | 화학용 보안경 |
| 피부 보호구 | 내화학성 장갑 (니트릴) |
---
## 4. 3종 비교 요약
| 항목 | PMA / PGMEA | EL |
|------|-------------|-----|
| CAS No. | 108-65-6 | 97-64-3 |
| 비점 | 146 °C | 154 °C |
| 인화점 | 43 °C | 53 °C |
| 비중 | 0.966 | 1.031 |
| 수용해도 | 부분 용해 | 혼화성 |
| 증기압 (20 °C) | 3.7 mmHg | 1.5 mmHg |
| GHS 인화성 | 구분 3 | 구분 3 |
| 생식독성 우려 | 있음 (동물) | 없음 |
| 친환경성 | 보통 | 우수 (생분해성) |
| 반도체 용도 | PR 용제, 현상 | PR 용제, 세정 |
| TLV-TWA | 50 ppm | 미설정 |
---
## 5. 공통 비상 연락처
| 기관 | 연락처 |
|------|--------|
| 화학물질안전원 (NICS) | 043-830-4000 |
| 119 (화재·응급) | 119 |
| 중독정보센터 | 1899-2252 |
---
*작성 기준: GHS Rev.9, ACGIH 2023, 각 제조사 공개 SDS 기반 요약*
*최종 검토일: 2026-06-08*

View File

@@ -0,0 +1,414 @@
# 진단 보고서 — 신호태그 .PV 누락으로 인한 history_table 분절
**진단일**: 2026-06-08
**발생일**: 2026-06-03 (commit 7409fab 컬럼명칭 통일) 이후 신규 데이터부터
**영향**: `history_table` 1억 7,900만 행 중 5,180만 행 (19GB) 이력 분절
**등급**: 🔴 HIGH — 데이터 무결성 결함
---
## STEP 1 — 맥락 파악
**질문: 이 문제는 무엇을 하는 시스템의 문제인가?**
HC900 프로세스 컨트롤러 → Modbus TCP → C++ 게이트웨이 → gRPC → C# 크롤러 → PostgreSQL
```
HC900 ──Modbus TCP──▶ C++ Gateway ──gRPC──▶ C# Hc900Crawler ──▶ PostgreSQL (hc900.history_table)
```
세 가지 Python 스크립트가 태그명 생성의 근원:
| 스크립트 | 역할 | 파일 |
|---|---|---|
| `build_register_map.py` | HC Designer CSV → register-map.json | `scripts/build_register_map.py` |
| `load_map_master.py` | register-map.json → `hc900_map_master` 테이블 | `scripts/load_map_master.py` |
| `Hc900RealtimeService.cs` | 게이트웨이 폴링 → `realtime_table``history_table` | `src/Infrastructure/Hc900/Hc900RealtimeService.cs` |
> 이 단계를 건너뛰면 "의도적 설계"를 "버그"로 오인한다.
---
## STEP 2 — 구조 탐색
**의존 파일 및 데이터 흐름:**
```
docs/SignalTags.csv ← HC Designer export (원본)
docs/SummaryFucntionBlockReport.csv ← 루프 정의 (원본)
docs/Variables.csv ← 변수 정의 (원본)
▼ build_register_map.py
docs/register-map.json ← 게이트웨이 레지스터 맵
▼ load_map_master.py
PostgreSQL hc900_map_master ← DB 매핑 테이블
▼ Hc900RealtimeService (C#)
PostgreSQL realtime_table → history_table
```
**핵심 발견:** `build_register_map.py`는 루프와 신호태그를 **서로 다른 로직**으로 처리한다.
---
## STEP 3 — 코드 읽기
### `build_register_map.py` — 루프 처리부 (line 153-168)
```python
# line 159: 루프는 Experion point name을 재구성
tag_name = f"{loop['tag']}.{name}"
# 결과: "FICQ-6101.PV", "FICQ-6101.SP", "FICQ-6101.OP" ← Experion ItemName과 일치 ✓
```
`LOOP_PARAM_OFFSETS`에서 `.PV`, `.SP`, `.OP` 등을 붙여 Experion 시스템의 point name 형식을 재구성한다.
### `build_register_map.py` — 신호태그 처리부 (line 171-180)
```python
# line 171-180: 신호태그는 CSV raw tag를 그대로 사용
for sig in signals:
registers.append({
"tag": sig["tag"], 문제: CSV row[2] 그대로, .PV suffix 없음
"addr": sig["addr"],
...
})
# 결과: "TI-6111B" ← Experion의 "TI-6111B.PV"와 불일치 ✗
```
**근거:** `parse_signal_or_variable_csv()` (line 102-140)에서 `tag = row[2].strip()` — HC Designer CSV의 3번째 컬럼을 그대로 가져온다. HC Designer export에는 `.PV` suffix가 붙어있지 않다.
### `load_map_master.py` — DB 로딩 (line 59-65)
```python
# line 64: tagname == hc900_tag == e["tag"]
cur.execute(
"INSERT INTO hc900_map_master (tagname, hc900_tag, ...) VALUES (%s, %s, ...)",
(e["tag"], e["tag"], ...))
```
register-map의 `tag` 필드를 `tagname``hc900_tag`**동일하게** 저장. register-map tag가 `.PV` 붙으면 자동으로 따라옴. 수정 불필요.
### `Hc900RealtimeService.cs` — 데이터 기록 (line 203, 227-231)
```csharp
// line 203: 게이트웨이 응답을 tagLookup으로 매칭
if (tagLookup.TryGetValue(tv.TagName, out var tagnames))
{
foreach (var tagname in tagnames)
{
rows.Add((tagname, livevalue, ts)); // ← tagname이 DB에 기록됨
}
}
// line 227-231: realtime_table INSERT
INSERT INTO realtime_table (controller_id, tagname, ..., livevalue, timestamp)
VALUES ($1, $2, ..., $3, $4)
ON CONFLICT (controller_id, tagname) DO UPDATE ...
```
전구간 tag name 변형 없음. register-map tag가 그대로 `realtime_table``history_table`에 기록된다.
---
## STEP 4 — 호출 계층 지도
```
HC Designer CSV (SignalTags.csv)
│ row[2] = "TI-6111B" ← .PV 없음
build_register_map.py:131 tag = row[2].strip()
│ line 159 (루프): f"{loop['tag']}.{name}" → "FICQ-6101.PV" ✓
│ line 171 (신호): sig["tag"] → "TI-6111B" ✗
register-map.json: "tag": "TI-6111B"
load_map_master.py:64 tagname == hc900_tag == "TI-6111B"
C++ gateway (gateway.cpp:80, 169, 189) ← tag field 그대로 전달, 변형 없음
C# Hc900RealtimeService (line 203, 227) ← tagname 그대로 realtime_table INSERT
history_table (SnapshotToHistoryAsync) ← realtime_table에서 복사
```
**결론:** tag name 결정은 `build_register_map.py` line 171에서 한 번에 발생. 이후 전구간 무변형 전달.
---
## STEP 5 — 패턴 매칭 (체크리스트 순회)
### 🔴 런타임 즉시 실패
| 체크 | 항목 | 판단 |
|------|------|------|
| [x] | **데이터 불일치** | Experion dump 원본(`TI-6111B.PV`) vs 우리 추출(`TI-6111B`) |
| [ ] | 미정의 변수·함수 참조 | 해당 없음 |
| [ ] | 잘못된 타입 | 해당 없음 |
### 🟠 데이터 무결성
| 체크 | 항목 | 판단 |
|------|------|------|
| [x] | **이력 분절** | 같은 물리 신호가 두 이름으로 `history_table`에 분산 저장 |
| [x] | **과거 데이터 권위** | Experion dump(`.PV` 포함)가 현장 권위 원본 |
| [ ] | Race Condition | 해당 없음 |
### 🟡 코드 구조
| 체크 | 항목 | 판단 |
|------|------|------|
| [x] | **처리 로직 불일치** | 루프는 Experion point name 재구성, 신호태그는 raw 사용 |
| [ ] | 설정 하드코딩 | 해당 없음 |
---
## STEP 6 — 교차 검증
STEP 5에서 발견한 각 항목에 대해 4개 질문 모두 확인:
### 항목 1: 신호태그 .PV 누락 (HIGH)
| 질문 | 확인 | 결과 |
|------|------|------|
| Q1. 이미 수정된 문제인가? | `build_register_map.py` 현재 상태 grep | **아니오** — line 171 여전히 `sig["tag"]` |
| Q2. 다른 레이어에서 처리되고 있는가? | C++ gateway, C# RealtimeService grep | **아니오** — 전구간 무변형 전달 |
| Q3. 의도적 설계인가? | 문서·주석 확인 | **아니오** — 루프는 `.PV` 붙이는데 신호는 안 붙이는 이유 없음 |
| Q4. 실제 장애 시나리오가 있는가? | `history_table`에서 `TI-6111B.PV` vs `TI-6111B` 분산 | **예** — 5,180만 행 분절, 시계열 조회 시 끊김 |
**→ Q1~Q4 통과. 보고서 포함.**
### 항목 2: 이력 분절 (HIGH)
| 질문 | 확인 | 결과 |
|------|------|------|
| Q1. 이미 수정된 문제인가? | `history_table` 행 수 확인 | **아니오** — 5,180만 행 여전히 옛 이름으로 존재 |
| Q2. 다른 레이어에서 처리되고 있는가? | rename 훅, 데이터 이관 로직 grep | **아니오** — tagname 변경 시 이관 코드 없음 |
| Q3. 의도적 설계인가? | 아키텍처 문서 확인 | **아니오** — 단일 신호가 두 이름으로 저장되는 것은 의도 아님 |
| Q4. 실제 장애 시나리오가 있는가? | 시계열 조회 시 두 이름 모두 검색 필요 | **예** — 운영상 실수 유발 |
**→ Q1~Q4 통과. 보고서 포함.**
---
## STEP 7 — 심각도 분류
| 등급 | 항목 | 기준 |
|------|------|------|
| 🔴 HIGH | 신호태그 .PV 누락 | 데이터 무결성 결함. Experion dump 원본과 불일치. 재현 가능 |
| 🔴 HIGH | history_table 이력 분절 | 5,180만 행 분산. 시계열 조회 시 끊김. 데이터 손실과 동등 |
---
## STEP 8 — 진단 결과 및 수정 계획
### [1]. 신호태그 .PV 누락 (HIGH)
**문제**: `build_register_map.py`가 신호태그(CSV row[2])를 Experion point name 규칙(`TAGNAME.PV`) 없이 그대로 register-map에 기록. 루프는 `.PV` 등을 붙이는데 신호태그만 누락.
**근거**: `scripts/build_register_map.py:171``"tag": sig["tag"]` (CSV raw tag, `.PV` suffix 없음)
**영향**: Experion dump 원본(`TI-6111B.PV`)과 불일치 → `history_table`에 같은 물리 신호가 두 이름으로 분산 저장. 5,180만 행 분절.
**수정**: `build_register_map.py` line 171 부근에서 신호태그에 `.PV` suffix 추가.
```python
# 수정 전 (line 171-180):
for sig in signals:
registers.append({
"tag": sig["tag"], # "TI-6111B"
...
})
# 수정 후:
for sig in signals:
# Experion point naming convention: TI-6111B → TI-6111B.PV
experion_tag = sig["tag"] + ".PV"
registers.append({
"tag": experion_tag, # "TI-6111B.PV"
...
})
```
**변수태그 확인 필요**: `Variables.csv`의 tag name이 Experion에서 어떻게 매핑되는지 확인. 변수는 R/W 태그로 HC900 내부 변수이므로 `.PV` 규칙이 적용되는지 별도 검증 필요.
---
### [2]. history_table 이력 분절 (HIGH)
**문제**: tagname 변경 시 과거 데이터 이관 훅이 없음. 6/3 이후 신규 데이터가 옛 이름(`.PV` 없음)으로 쌓이면서 같은 물리 신호가 두 이름으로 분절.
**근거**: `Hc900RealtimeService.cs``realtime_table``history_table` 복사 시 tagname 변환 로직 없음. `Hc900DbContext.cs` SnapshotToHistoryAsync도 tagname 그대로 복사.
**영향**: 시계열 조회 시 두 이름 모두 검색 필요. 운영상 실수 유발.
**수정**: 단계별 진행 필요 (아래 STEP 9~11 참조).
---
## STEP 9 — 수정 실행 계획 (완료 서명)
### 단계 1: `build_register_map.py` 수정
- [ ] 신호태그 처리부(line 171)에 `.PV` suffix 추가
- [ ] 변수태그 처리부(line 183)도 Experion point name 규칙 적용 여부 확인
- [ ] 수정 후 register-map 재생성 테스트
**수정자**: ____________________
**작성일**: ____/____/____
**검토자**: ____________________
**검토일**: ____/____/____
---
### 단계 2: register-map 재생성 및 DB 반영
- [ ] 수정된 `build_register_map.py`로 register-map 재생성
- [ ] `load_map_master.py``hc900_map_master` 갱신
- [ ] `hc900_map_master`에서 신호태그 tagname에 `.PV`가 붙었는지 확인
```sql
-- 확인 쿼리
SELECT tagname, hc900_tag FROM hc900_map_master
WHERE tagname ~ '^[TFDLI]P?[A-Z]?-\d{4,5}$'
AND tagname NOT LIKE '%.%'
LIMIT 20;
-- 결과가 없어야 정상 (모든 신호태그에 .PV 붙음)
```
**실행자**: ____________________
**실행일**: ____/____/____
**검토자**: ____________________
**검토일**: ____/____/____
---
### 단계 3: history_table 과거 데이터 정합화
- [ ] `history_table`에서 옛 이름(`.PV` 없음) 행 수 확인
```sql
-- 옛 이름 잔존 행 수 확인
SELECT COUNT(*) FROM hc900.history_table
WHERE tagname ~ '^[TFDLI]P?[A-Z]?-\d{4,5}$'
AND tagname NOT LIKE '%.%'
AND tagname NOT IN (
-- 변수태그 등 .PV 없이 정상인 태그 제외
SELECT tagname FROM hc900.map_master WHERE ...
);
```
- [ ] 옛 이름 → 새 이름(.PV 붙임) UPDATE 실행
- [ ] UPDATE 후 행 수 확인 (분절 없이 단일 이름으로 통합)
```sql
-- UPDATE 예시 (본격 실행 전 반드시 트랜잭션으로 테스트)
BEGIN;
UPDATE hc900.history_table
SET tagname = tagname || '.PV'
WHERE tagname = 'TI-6111B';
-- 확인: SELECT COUNT(*) WHERE tagname = 'TI-6111B.PV'
COMMIT;
```
**실행자**: ____________________
**실행일**: ____/____/____
**검토자**: ____________________
**검토일**: ____/____/____
---
### 단계 4: 재발 방지 설계
- [ ] `hc900_map_master.tagname` 변경 시 `history_table` 이관을 강제하는 절차 문서화
- [ ] 또는: 장기 저장 식별자를 tagname이 아닌 안정 키(Modbus addr / hc900_tag)로 변경 검토
- [ ] display name은 read 시점에 `map_master`로 해석하는 아키텍처로 전환 검토
**설계자**: ____________________
**작성일**: ____/____/____
**검토자**: ____________________
**검토일**: ____/____/____
---
## 부록: 데이터 흐름 전체 도식
```
┌─────────────────────────────────────────────────────────────────────┐
│ HC Designer CSV Export (현장 dump) │
│ SignalTags.csv: row[2] = "TI-6111B" ← .PV 없음 │
│ SummaryFucntionBlockReport.csv: row[2] = "FICQ-6101" │
└──────────────────────────┬──────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ build_register_map.py │
│ │
│ 루프 (line 159): │
│ tag_name = f"{loop['tag']}.{name}" │
│ → "FICQ-6101.PV" ✓ Experion ItemName과 일치 │
│ │
│ 신호태그 (line 171) ← 문제: │
│ tag = sig["tag"] ← CSV row[2] 그대로 │
│ → "TI-6111B" ✗ Experion의 "TI-6111B.PV"와 불일치 │
│ │
│ 변수 (line 183): │
│ tag = var["tag"] ← CSV row[2] 그대로 │
│ → "VAR-xxxx" (Experion 매핑 확인 필요) │
└──────────────────────────┬──────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ register-map.json │
│ {"tag": "TI-6111B"} ← .PV 없음 │
└──────────────────────────┬──────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ load_map_master.py (line 64) │
│ tagname == hc900_tag == e["tag"] │
│ → hc900_map_master: tagname="TI-6111B", hc900_tag="TI-6111B" │
│ (register-map tag를 그대로 복사. register-map 수정하면 자동 따라옴) │
└──────────────────────────┬──────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ C++ Gateway (gateway.cpp) │
│ line 80: e.tag = item["tag"] ← register-map tag 그대로 저장 │
│ line 169: fresh[entry.tag] = cv ← cache key로 사용 │
│ line 189: tv->set_tag_name(name) ← gRPC 응답에 그대로 포함 │
│ (변형 없음, register-map tag가 100% tag name이 됨) │
└──────────────────────────┬──────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ C# Hc900RealtimeService │
│ line 203: tagLookup.TryGetValue(tv.TagName, ...) ← 매칭 │
│ line 227: INSERT INTO realtime_table (tagname, ...) │
│ → tagname = map_master의 tagname = register-map의 tag │
│ (변형 없음) │
└──────────────────────────┬──────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ PostgreSQL hc900 │
│ │
│ realtime_table: tagname="TI-6111B" ← 신규 데이터 │
│ history_table: tagname="TI-6111B.PV" ← Experion dump 원본 (5,180만 행) │
│ : tagname="TI-6111B" ← 신규 데이터 (분절) │
│ │
│ → 같은 물리 신호가 두 이름으로 분산 │
└─────────────────────────────────────────────────────────────────────┘
```
## 부록: Experion Point Name 규칙
| 태그 종류 | HC Designer CSV tag | Experion point name | register-map tag | 상태 |
|---|---|---|---|---|
| 루프 (PID) | `FICQ-6101` | `FICQ-6101.PV` | `FICQ-6101.PV` | ✓ 일치 |
| 신호태그 | `TI-6111B` | `TI-6111B.PV` | `TI-6111B` | ✗ 불일치 |
| 신호태그 | `FI-6102` | `FI-6102.PV` | `FI-6102` | ✗ 불일치 |
| 변수 | `VAR-xxxx` | 확인 필요 | `VAR-xxxx` | ? 미확인 |
**Experion 규칙**: 모든 신호태그(Tag Type = TI/FI/LI/PI/DI/AI/AO 등)는 `.PV` suffix를 붙여 point name을 생성한다.

View File

@@ -0,0 +1,497 @@
# 진단 체크리스트 — 온도 프로파일 메타 영역을 표 형식으로 재구성 + 유량 표시
> **규칙**: diagnosis-checklist.md의 8단계를 준수. STEP 3(코드 읽기)과 STEP 6(교차 검증)을 반드시 거침.
---
## 0. 요구사항 요약
**현재**: 온도 프로파일 탭(`st-temp`)에서 차트 아래 `st-temp-meta`에 단별 온도/진공을 텍스트로 표시.
**목표**: `st-temp-meta`를 **두 개의 표(table)**로 재구성:
**왼쪽 표** — 온도 (기존 텍스트 → 표 형식):
```
┌──────────────┬────────┬──────────────┐
│ 항목 │ 현재값 │ 기준 │
├──────────────┼────────┼──────────────┤
│ eb-A(보텀) │ 85.6℃ │ 85.1±2.3 │
│ T_B │ 84.0℃ │ 83.3±2.1 │
│ T_C(민감단) │ 77.2℃ │ 77.0±1.4 │
│ T_D(탑) │ 74.2℃ │ 74.2±1.6 │
│ ΔT(A-D) │ 11.4℃ │ — │
│ 진공 │ 39.7 │ 40.1±3.8 │
└──────────────┴────────┴──────────────┘
```
**오른쪽 표** — 유량 (신규):
```
┌────────┬────────┬────────┬──────────┬──────────┬──────────┐
│ │ FEED │ REFLUX │ 제품추출 │ 경비물 │ 중비물 │
│ │FICQ-610│ FICQ-6 │ FICQ-611 │ FICQ-611 │ FICQ-611 │
│ 태그명 │ 1.PV │ 13.PV │ 8.PV │ 4.PV │ 6.PV │
├────────┼────────┼────────┼──────────┼──────────┼──────────┤
│ PV │ 896.4 │ 652.4 │ 816.4 │ 14.4 │ 23.4 │
│ SP │ 900 │ 656 │ 820 │ 18.0 │ 27.0 │
│ OP │ 53.5 │ 64.5 │ 43.9 │ 33.7 │ 20.8 │
└────────┴────────┴────────┴──────────┴──────────┴──────────┘
```
실시간/과거 스냅샷 모두 동일 형식.
---
## STEP 1 — 맥락 파악
| 항목 | 내용 |
|------|------|
| 파일 | `src/Hc900Crawler/wwwroot/js/steam.js` (프론트엔드), `SteamAdvisorController.cs` (백엔드) |
| 역할 | Steam Advisory 대시보드 — 온도 프로파일 이격 모니터 |
| 아키텍처 | 프론트 → API → C# 백엔드가 `realtime_table` + `tempref.json` 처리 |
| 관련 DB | Experion `realtime_table` (v_tag_summary 기반) |
---
## STEP 2 — 구조 탐색
```
src/Hc900Crawler/
├── wwwroot/
│ ├── js/steam.js ← stRenderTemp(), stTempScrub()
│ └── panes/steam.html ← st-temp-meta DOM
├── Controllers/
│ └── SteamAdvisorController.cs ← TempProfile, TempProfileHistory API
└── appsettings.json ← SteamAdvisor.Columns
```
---
## STEP 3 — 코드 읽기 (핵심 부분만)
### 3.1 `steam.js` — `stRenderTemp(snap)` (line 216-286)
- **line 279-285**: `st-temp-meta` DOM에 온도 텍스트 렌더링
```js
const lines = snapSrc.stages.map(s =>
`${(ST_STAGE_LABEL[s.stage] || s.stage).padEnd(12)} ${stFmt(s.current)}℃ 기준 ...`);
if (snapSrc.spanAD != null) lines.push(`ΔT(A-D) ${stFmt(snapSrc.spanAD)}℃`);
if (snapSrc.vacuum) lines.push(`진공 ${stFmt(snapSrc.vacuum.current)} ...`);
meta.textContent = lines.join('\n');
```
- **현재 `textContent` 사용** → 표 형식 변경 시 `innerHTML`로 변경 필요
### 3.2 `SteamAdvisorController.cs` — `TempProfile` (line 183-206)
- `FetchRealtimeValues(col, tref)` → 5개 온도/진공 태그 조회
- `ComputeStages(cur, tref)` → 제품 매칭 + z-score 계산
- 반환: `stages`, `vacuum`, `spanAD`
### 3.3 `SteamAdvisorController.cs` — `TagsFor(p)` (line 352-371)
- 온도/진공 태그 매핑만 포함. **FICQ 태그 없음**
### 3.4 `SteamAdvisorController.cs` — `TempProfileHistory` (line 211-273)
- history_table 조회 → 각 스냅샷에 `stages`, `vacuum`, `spanAD` 포함
---
## STEP 4 — 호출 계층 지도
```
[Browser] steam.js stTempLoad() / stTempScrub()
└─ GET /api/steam/tempprofile/{col} ← 실시간
└─ GET /api/steam/tempprofile/{col}/history ← 과거
└─ SteamAdvisorController.TempProfile() / TempProfileHistory()
├─ FetchRealtimeValues() → HC900 DB (온도 4 + 진공 1)
└─ ComputeStages() → stages + vacuum + spanAD
└─ _db.QueryHistoryWithIntervalAsync() → history_table
└─ ComputeStages() for each snapshot
[Browser] steam.js stRenderTemp(snap)
└─ stTempUpdateBadges()
└─ ECharts 렌더
└─ st-temp-meta.innerHTML = ... ← 표 형식 (변경 필요)
```
---
## STEP 5 — 패턴 매칭 (체크리스트 순회)
### 🔴 런타임 즉시 실패
| 체크 | 항목 | 판단 기준 |
|------|------|-----------|
| [ ] | `textContent` → `innerHTML` 변경 | XSS 위험 없음 (모든 값은 `stFmt()`로 포맷된 숫자) |
| [ ] | API 응답 필드 누락 | `flow` 필드가 백엔드/프론트에서 일관되게 사용되는가? |
| [ ] | 잘못된 타입 | `flow` 값이 null/NaN일 때 `stFmt()`가 올바르게 처리하는가? |
### 🟠 동시성 / 비동기
| 체크 | 항목 | 판단 기준 |
|------|------|-----------|
| [ ] | API 응답 구조 변경 | 기존 `stages`/`vacuum`/`spanAD` 필드 유지 (breaking change 아님) |
### 🟢 코드 구조
| 체크 | 항목 | 판단 기준 |
|------|------|-----------|
| [ ] | FICQ 태그명 하드코딩 | `TagsFor()`에서 동적 생성 (코드 중복 없음) |
| [ ] | 미사용 import·변수 | 신규 코드에 미사용 import 없음 |
---
## STEP 6 — 교차 검증
| 질문 | 확인 방법 | 결과 |
|------|-----------|------|
| Q1. 이미 수정된 문제인가? | `steam.js`에 표 형식 로직이 이미 있는가? | **아니오** — 현재 텍스트 기반 |
| Q2. 다른 레이어에서 처리되고 있는가? | Live 패널에 표 형식이 있는가? | **아니오** — Live 패널은 별도 레이아웃 |
| Q3. 의도적 설계인가? | 표 형식 재구성이 요구사항에 명시된 기능인가? | **예** — 사용자가 명시적으로 요청 |
| Q4. 실제 장애 시나리오가 있는가? | 표 형식이 없으면 운전원 판단에 영향이 있는가? | **예** — 유량 데이터 없으면 물질수지 판단 불가 |
---
## STEP 7 — 심각도 분류
| 등급 | 항목 |
|------|------|
| 🟡 LOW | 신규 UI 표시 기능 — 기존 동작에 영향 없음 |
| 🟡 LOW | 백엔드 API에 신규 필드 추가 — 기존 필드 유지 |
---
## STEP 8 — 실행 플랜
### 작업 1 — 백엔드: `TagsFor()`에 FICQ 태그 추가
**파일**: `SteamAdvisorController.cs:352-371`
**변경 내용**: `TagsFor(p)`에 FICQ 5개 태그 매핑 추가
```csharp
private static Dictionary<string, string> TagsFor(string p)
{
var m = new Dictionary<string, string>
{
["reb_temp"] = $"TICA-{p}A.PV",
["T_B"] = $"TI-{p}B.PV",
["T_C"] = $"TI-{p}C.PV",
["T_D"] = $"TI-{p}D.PV",
["vacuum"] = $"PICA-{p}.PV",
// 추가:
["feed"] = $"FICQ-{p}01.PV",
["reflux"] = $"FICQ-{p}13.PV",
["overhead"] = $"FICQ-{p}14.PV",
["bottom"] = $"FICQ-{p}16.PV",
["product"] = $"FICQ-{p}18.PV",
};
// ... 기존 switch (8111, 9111 등) 유지
return m;
}
```
**교차 검증**: `v_tag_summary`에서 `ficq-6101`, `ficq-6113`, `ficq-6114`, `ficq-6116`, `ficq-6118`이 존재하고 PV 값을 가짐 확인 완료.
---
### 작업 2 — 백엔드: `FetchRealtimeValues()` 변경 없음
`TagsFor()`가 모든 태그(온도 + 유량)를 반환하므로 `FetchRealtimeValues()`는 자동 반영. **변경 없음**.
---
### 작업 3 — 백엔드: `TempProfile` API 응답에 flow + metadata 필드 추가
**파일**: `SteamAdvisorController.cs:193-205`
**변경 내용**: 반환 객체에 `flow` + `flowTags` 필드 추가
```csharp
return Ok(new
{
column = tref.Column,
period = tref.Period,
matchedProduct = prod?.Label,
nProducts = tref.NProducts,
stages,
vacuum,
spanAD,
spanRef = prod?.SpanAD,
products = tref.Products,
timestamp = DateTime.UtcNow,
// 추가:
flow = new {
feed = cur.GetValueOrDefault("feed"),
reflux = cur.GetValueOrDefault("reflux"),
overhead = cur.GetValueOrDefault("overhead"),
bottom = cur.GetValueOrDefault("bottom"),
product = cur.GetValueOrDefault("product"),
},
flowTags = new {
feed = $"FICQ-{ToSuffix(col)}01.PV",
reflux = $"FICQ-{ToSuffix(col)}13.PV",
overhead = $"FICQ-{ToSuffix(col)}14.PV",
bottom = $"FICQ-{ToSuffix(col)}16.PV",
product = $"FICQ-{ToSuffix(col)}18.PV",
},
});
```
**동일 변경**: `TempProfileHistory` (line 247-254)에서도 각 스냅샷에 `flow` + `flowTags` 추가.
---
### 작업 4 — 백엔드: `ComputeStages()`에서 온도/진공 값 구조화
**파일**: `SteamAdvisorController.cs:308-348`
**변경 내용**: `ComputeStages()` 반환값에 온도 현재값/기준값을 표 형식으로 렌더링할 수 있도록 구조화
현재 `stages`는 `{ stage, current, refMedian, refStd, z, deviated }` 객체 배열.
`vacuum`은 `{ current, refMedian, refStd, z, deviated }` 객체.
이 구조를 그대로 사용하되, 프론트엔드에서 표 형식으로 렌더링할 때:
- `stages[].current` → "현재값" 열
- `stages[].refMedian ± stages[].refStd` → "기준" 열
- `vacuum.current` → "현재값" 열
- `vacuum.refMedian ± vacuum.refStd` → "기준" 열
**백엔드 변경 없음**. 프론트엔드에서 기존 구조로 표 렌더링.
---
### 작업 5 — 프론트엔드: `stRenderTemp()`를 표 형식으로 재작성
**파일**: `steam.js:216-286`
**변경 내용**: `textContent` → `innerHTML`, 두 개의 `<table>` 생성
```js
function stRenderTemp(snap) {
stTempUpdateBadges();
if (!stTempLive) {
const el = document.getElementById('st-chart-temp');
if (el) el.innerHTML = '<div style="padding:40px;text-align:center;color:#555">데이터 없음</div>';
return;
}
stRenderTempCol = document.getElementById('st-temp-col').value;
const stages = stTempLive.stages;
if (!stages || !stages.length) return;
// ... (ECharts 렌더링 기존 유지 — line 235-276) ...
// ── 메타 영역을 표 형식으로 렌더링 ──
const meta = document.getElementById('st-temp-meta');
meta.style.display = 'block';
const snapSrc = snap || stTempLive;
// 왼쪽 표: 온도
let tempRows = stages.map(s => {
const label = ST_STAGE_LABEL[s.stage] || s.stage;
const cur = stFmt(s.current) + '℃';
const ref = s.refMedian != null ? `${stFmt(s.refMedian)}±${stFmt(s.refStd)}` : '—';
return `<tr><td>${label}</td><td>${cur}</td><td>${ref}</td></tr>`;
});
// ΔT(A-D) 추가
if (snapSrc.spanAD != null) {
tempRows.push(`<tr><td>ΔT(A-D)</td><td>${stFmt(snapSrc.spanAD)}℃</td><td>—</td></tr>`);
}
// 진공 추가
if (snapSrc.vacuum) {
const v = snapSrc.vacuum;
const cur = stFmt(v.current);
const ref = v.refMedian != null ? `${stFmt(v.refMedian)}±${stFmt(v.refStd)}` : '—';
tempRows.push(`<tr><td>진공</td><td>${cur}</td><td>${ref}</td></tr>`);
}
// 오른쪽 표: 유량
let flowHeaders, flowRows;
if (snapSrc.flow) {
const f = snapSrc.flow;
const ft = snapSrc.flowTags || {};
flowHeaders = [
'<th></th>',
`<th>FEED<br><small>${esc(ft.feed || 'FICQ-??01.PV')}</small></th>`,
`<th>REFLUX<br><small>${esc(ft.reflux || 'FICQ-??13.PV')}</small></th>`,
`<th>제품추출<br><small>${esc(ft.product || 'FICQ-??18.PV')}</small></th>`,
`<th>경비물<br><small>${esc(ft.overhead || 'FICQ-??14.PV')}</small></th>`,
`<th>중비물<br><small>${esc(ft.bottom || 'FICQ-??16.PV')}</small></th>`,
].join('');
flowRows = [
`<tr><td>PV</td><td>${stFmt(f.feed?.pv)}</td><td>${stFmt(f.reflux?.pv)}</td><td>${stFmt(f.product?.pv)}</td><td>${stFmt(f.overhead?.pv)}</td><td>${stFmt(f.bottom?.pv)}</td></tr>`,
`<tr><td>SP</td><td>${stFmt(f.feed?.sp)}</td><td>${stFmt(f.reflux?.sp)}</td><td>${stFmt(f.product?.sp)}</td><td>${stFmt(f.overhead?.sp)}</td><td>${stFmt(f.bottom?.sp)}</td></tr>`,
`<tr><td>OP</td><td>${stFmt(f.feed?.op)}</td><td>${stFmt(f.reflux?.op)}</td><td>${stFmt(f.product?.op)}</td><td>${stFmt(f.overhead?.op)}</td><td>${stFmt(f.bottom?.op)}</td></tr>`,
].join('');
}
meta.innerHTML = `
<div class="st-meta-tables">
<table class="st-meta-temp">${tempRows.join('')}</table>
${flowHeaders ? `<table class="st-meta-flow"><thead><tr>${flowHeaders}</tr></thead><tbody>${flowRows}</tbody></table>` : ''}
</div>
`;
}
```
> **참고**: SP/OP 값은 현재 API 응답에 없음. 작업 6에서 API 확장이 필요.
---
### 작업 6 — 백엔드: API 응답에 SP/OP 추가 (필수)
**파일**: `SteamAdvisorController.cs`
**현재 상황**: `RealtimePoint` 엔티티에는 `LiveValue`만 있음. SP/OP는 `v_tag_summary` 뷰에서 조회.
**변경 내용**: FICQ 태그의 PV/SP/OP를 `v_tag_summary`에서 조회
```csharp
// v_tag_summary에서 base_tag 기반 조회 (예: ficq-6101, ficq-6113 등)
private async Task<Dictionary<string, (double? pv, double? sp, double? op)>> FetchFlowValues(string col)
{
var p = ToSuffix(col);
var baseTags = new[] {
("feed", $"ficq-{p}01"),
("reflux", $"ficq-{p}13"),
("overhead", $"ficq-{p}14"),
("bottom", $"ficq-{p}16"),
("product", $"ficq-{p}18"),
};
var placeholders = string.Join(",", baseTags.Select((_, i) => $"@p{i}"));
var sql = $"SELECT LOWER(base_tag) as base_tag, pv, sp, op FROM v_tag_summary WHERE LOWER(base_tag) IN ({placeholders})";
var params = baseTags.Select((bt, i) => new Npgsql.NpgsqlParameter($"p{i}", bt.Item2)).ToArray();
// Raw SQL 조회 결과 파싱
var rows = await _ctx.Database.SqlQueryRaw<(string base_tag, string? pv, string? sp, string? op)>(sql, params).ToListAsync();
var result = new Dictionary<string, (double? pv, double? sp, double? op)>();
foreach (var kv in baseTags)
{
var row = rows.FirstOrDefault(r => r.base_tag == kv.Item2);
result[kv.Item1] = (
pv: double.TryParse(row?.pv, out var v) ? (double?)v : null,
sp: double.TryParse(row?.sp, out var s) ? (double?)s : null,
op: double.TryParse(row?.op, out var o) ? (double?)o : null
);
}
return result;
}
```
**API 응답 구조**:
```csharp
var flow = await FetchFlowValues(col);
return Ok(new {
// ... 기존 필드 ...
flow = new {
feed = flow["feed"],
reflux = flow["reflux"],
overhead = flow["overhead"],
bottom = flow["bottom"],
product = flow["product"],
},
flowTags = new {
feed = $"FICQ-{p}01.PV",
reflux = $"FICQ-{p}13.PV",
overhead = $"FICQ-{p}14.PV",
bottom = $"FICQ-{p}16.PV",
product = $"FICQ-{p}18.PV",
},
});
```
---
### 작업 7 — CSS 추가
**파일**: `steam.html` (style 블록) 또는 별도 CSS
```css
.st-meta-tables {
display: flex;
gap: 16px;
margin-top: 8px;
font-size: 12px;
font-family: monospace;
}
.st-meta-temp, .st-meta-flow {
border-collapse: collapse;
}
.st-meta-temp td, .st-meta-temp th {
padding: 2px 8px;
border-bottom: 1px solid #1a2a3a;
text-align: left;
white-space: nowrap;
}
.st-meta-flow {
font-size: 11px;
}
.st-meta-flow th {
padding: 2px 6px;
border-bottom: 1px solid #2a3a4a;
color: #888;
font-weight: normal;
}
.st-meta-flow th small {
display: block;
font-size: 9px;
color: #555;
margin-top: 2px;
}
.st-meta-flow td {
padding: 2px 6px;
border-bottom: 1px solid #1a2a3a;
color: #ccc;
}
.st-meta-flow td:first-child {
color: #666;
font-weight: bold;
}
```
---
### 작업 8 — 테스트
| 테스트 | 방법 |
|--------|------|
| 실시간 조회 | `GET /api/steam/tempprofile/C-6111` → `flow` + `flowTags` 필드 확인 |
| 과거 조회 | `GET /api/steam/tempprofile/C-6111/history?from=...&to=...` → 각 스냅샷에 `flow` 포함 확인 |
| UI 렌더링 | 온도 프로파일 탭 → `st-temp-meta`에 두 표 표시 확인 |
| Scrubber | 슬라이더 이동 → 각 시점의 표 값 변경 확인 |
| 실시간 모드 | `stTempLive.flow` 표시 확인 |
| null 처리 | PV가 null인 태그 (예: C-9111) → `` 표시 확인 |
| CSS 렌더링 | 두 표가 나란히 배치 확인 |
---
## 완료상황 체크
- [x] **작업 1**: `TagsFor()`에 FICQ 태그 5개 추가 (feed/reflux/overhead/bottom/product)
- [x] **작업 2**: `FetchFlowValues()` 신규 추가 — `v_tag_summary`에서 PV/SP/OP 조회
- [x] **작업 3**: `TempProfile` API 응답에 `flow` + `flowTags` 필드 추가
- [x] **작업 3-H**: `TempProfileHistory` API 응답에 각 스냅샷 `flow` + `flowTags` 추가
- [x] **작업 4**: `ComputeStages()` 변경 없음 확인
- [x] **작업 5**: `stRenderTemp()` 재작성 — `textContent` → `innerHTML`, 두 개의 table 나란히 배치
- [x] **작업 6**: SP/OP 표시 — API + 프론트엔드 모두 구현 완료 (PV/SP/OP 3행)
- [x] **작업 7**: CSS 추가 (`.st-meta-tables`, `.st-meta-temp`, `.st-meta-flow`)
- [x] **빌드**: `dotnet build` 성공 (0 Error, 8 Warning — 기존 경고만)
- [ ] **테스트 1**: 실시간 API — `GET /api/steam/tempprofile/C-6111` → `flow` + `flowTags` 확인
- [ ] **테스트 2**: 과거 API — `GET /api/steam/tempprofile/C-6111/history?from=...&to=...` → 스냅샷에 `flow` 포함 확인
- [ ] **테스트 3**: UI 렌더링 — 온도 프로파일 탭 → 두 표 나란히 표시 확인
- [ ] **테스트 4**: Scrubber — 슬라이더 이동 → 각 시점 표 값 변경 확인
- [ ] **테스트 5**: null 처리 — PV가 null인 태그 (예: C-9111) → `` 표시 확인
- [ ] **작업 8**: 실시간 조회 API 테스트 — `flow` + `flowTags` 필드 정상 응답
- [ ] **작업 8**: 과거 조회 API 테스트 — 스냅샷에 `flow` 포함
- [ ] **작업 8**: UI 렌더링 테스트 — 두 표 나란히 표시
- [ ] **작업 8**: Scrubber 테스트 — 과거 시점 표 값 변경
- [ ] **작업 8**: null/미폴링 태그 테스트 — `` 표시 확인
---
## 이해 안 되는 부분 / 확인 사항
1. **SP/OP 표시**: 현재 API 응답에 SP/OP가 없음. `v_tag_summary`에는 존재하므로 API 확장이 필요. 표시할까요? (표에 PV만 표시하면 작업 6 스킵)
2. **FICQ-{NN}14 = Overhead(경비물), FICQ-{NN}16 = Bottom(중비물)** — 확인 완료

View File

@@ -0,0 +1,910 @@
# 베이직 아키텍처 · 태그 디자인 전면 재설계
> 본 문서는 HC900-AX 프로젝트의 전면 재설계 결과를 정의한다.
> 2026-06-08, 전면 개정.
---
## 목차
1. [핵심 원칙](#1-핵심-원칙)
2. [HC900 Modbus 통신 규칙](#2-hc900-modbus-통신-규칙)
3. [Sinam_Tag_all.xlsx 데이터 구조](#3-sinam_tag_allxlsx-데이터-구조)
4. [주소 변환 규칙 (공식 검증 완료)](#4-주소-변환-규칙-공식-검증-완료)
5. [태그명 체계 및 등록 규칙](#5-태그명-체계-및-등록-규칙)
6. [register-map-cN.json 포맷](#6-register-map-cnjson-포맷)
7. [아카이브/모니터링 제어](#7-아카이브모니터링-제어)
8. [컨트롤러별 독립 맵 전략](#8-컨트롤러별-독립-맵-전략)
9. [데이터 흐름 (전면 개정)](#9-데이터-흐름-전면-개정)
10. [build_register_map_from_sinam.py 처리 규칙](#10-build_register_map_from_sinampy-처리-규칙)
11. [C# 서비스 수정 사항](#11-c-서비스-수정-사항)
12. [tag_metadata 적재](#12-tag_metadata-적재)
13. [알려진 문제](#13-알려진-문제)
14. [폐기 항목](#14-폐기-항목)
15. [마이그레이션 순서](#15-마이그레이션-순서)
---
## 1. 핵심 원칙
### 1.1 단일 진실 공급원 (Single Source of Truth)
- **`docs/Sinam_Tag_all.xlsx`** 가 모든 태그 정보의 유일한 출처
- HC Designer CSV (`SignalTags.csv`, `Variables.csv`, `SummaryFunctionBlockReport.csv`)는 **주소 검증용 보조 자료**
- Experion OPC UA legacy (소문자 태그명, node_id 등)는 **완전히 폐기**
### 1.2 태그명 표기법
- **모든 태그명은 대문자** (OPC UA 강제 소문자 규칙 폐기)
- 구분자: `-` (하이픈) + `.` (파라미터)
- 예: `FICQ-6101.PV`, `P-9114.STATE`, `LI-9100.PV`
- Experion ItemName 기준 (`FICQ-6101`), HC900 네이티브명(`FIQ6101`) 사용 안 함
### 1.3 컨트롤러별 독립 맵
- 각 HC900 컨트롤러(C1~C4)는 **독립적인 `register-map-cN.json`** 사용
- `config/gateway-config.json`에서 각 컨트롤러의 enabled/disabled 관리
- 웹 UI Setup 페이지에서 컨트롤러 추가/삭제/시작/중지 가능
### 1.4 레지스터 맵 엔트리 설계 원칙
- **❌ Loop 블록 통째로(192 regs) 등록하지 않음** — 게이트웨이의 배치 그룹핑이 개별 엔트리를 쪼갤 수 없으므로 `count > 120` 인 단일 엔트리는 단일 FC03 호출로 192 regs 읽기를 시도하여 HC900에서 실패
- **✅ 각 파라미터를 개별 엔트리로 등록** (PV=2regs, SP=2regs, OP=2regs, MODE=1reg, ...)
- 게이트웨이가 주소 순서로 정렬 후 인접 엔트리들을 동적으로 ≤120 배치로 묶어서 FC03 호출
---
## 2. HC900 Modbus 통신 규칙
### 2.1 기본 프로토콜
| 항목 | 값 | 근거 |
|------|-----|------|
| 전송 | Modbus TCP | `modbus_tcp.cpp` |
| HC900 IP | 컨트롤러별 설정 | `gateway-config.json` |
| 포트 | 502 | 기본값 |
| Unit ID | 1 | `modbus_tcp.cpp:13` |
| 소켓 timeout | 2초 | `modbus_tcp.cpp:14` |
| Watchdog | 5초 무응답 → Reconnecting | `modbus_tcp.cpp:15` |
| Max retry | 5회 → Fault (30초 후 자동 재시도) | `modbus_tcp.cpp:16` |
| Float format | **FP B** (IEEE 754 BigEndian, HighFirst, Normal) | HC900 매뉴얼, `vendor_formats.hpp` |
### 2.2 Function Codes
| 기능 | FC | 방향 | 구현 |
|------|----|------|------|
| Read Holding Registers | **FC03** | HC900 → Gateway | `modbus_tcp.cpp:171` |
| Write Multiple Registers | **FC16** (0x10) | Gateway → HC900 | `modbus_tcp.cpp:251` |
### 2.3 배치 읽기 규칙 (C++ 게이트웨이 자동 수행)
```
MAX_BATCH = 120 연속 레지스터 (gateway.cpp:126)
```
게이트웨이의 `ReadAllRegisters()`는 런타임에 register-map 엔트리들을 주소 순서로 정렬한 후, 동일한 120-register 윈도우 안에 들어가는 엔트리들을 한 번의 FC03로 묶어서 읽음:
```cpp
// gateway.cpp:124-174 — 동적 배치 그룹핑
while (i < sorted_indices_.size()) {
uint32_t batch_start = registers_[sorted_indices_[i]].addr;
size_t j = i;
while (j < sorted_indices_.size()) {
const auto& e = registers_[sorted_indices_[j]];
if (e.addr + e.count - batch_start > MAX_BATCH) break;
++j;
}
// FC03 read_raw(batch_start, read_count)
// 각 엔트리를 버퍼에서 개별 decode
i = j;
}
```
**중요:** 개별 엔트리의 `addr + count`가 120을 넘으면 단일 FC03로 처리되므로, 192-regs 통째 등록은 불가능. 각 파라미터는 count=1(uint16) 또는 2(float32)로 등록해야 함.
### 2.4 Loop 블록 구조 (매뉴얼 Table 6-3)
각 PID 루프는 256개 레지스터 블록 (루프 블록 크기 = 192 regs, 0x40~0xFF).
| Offset | 파라미터 | 타입 | 본문 접근 |
|--------|---------|------|:---------:|
| 0x00 | PV | float32 | R |
| 0x02 | Remote SP (SP2) | float32 | R |
| 0x04 | **Working Set Point (SP)** | float32 | RW |
| 0x06 | **Output (OP)** | float32 | RW |
| 0x0C | Gain #1 | float32 | RW |
| 0x0E | Direction | float32 | R |
| 0x10 | Reset #1 | float32 | RW |
| 0x12 | Rate #1 | float32 | RW |
| 0x14 | Cycle Time #1 | float32 | R |
| 0x16 | PV Low Range | float32 | R |
| 0x18 | PV High Range | float32 | R |
| 0x1A | Alarm #1 SP #1 (AL1SP1) | float32 | RW |
| 0x1C | Alarm #1 SP #2 (AL1SP2) | float32 | RW |
| 0x20 | Gain #2 | float32 | RW |
| 0x2A | Local SP #1 (LSP1) | float32 | RW |
| 0x2C | Local SP #2 (LSP2) | float32 | RW |
| 0x2E | Alarm #2 SP #1 (AL2SP1) | float32 | RW |
| 0x30 | Alarm #2 SP #2 (AL2SP2) | float32 | RW |
| 0x34 | SP Low Limit | float32 | RW |
| 0x36 | SP High Limit | float32 | RW |
| 0x3A | Output Low Limit | float32 | RW |
| 0x3C | Output High Limit | float32 | RW |
| 0x3E | Working Output (OPWORK) | float32 | RW |
| 0x46 | Ratio | float32 | RW |
| 0x48 | Bias | float32 | RW |
| 0x4A | Deviation | float32 | R |
| 0x4E | Manual Reset | float32 | RW |
| 0x50 | Feedforward Gain | float32 | RW |
| 0x52 | Local %CO | float32 | RW |
| 0xB7 | Fuzzy Enable | uint16 | RW |
| 0xB8 | Autotune Request | uint16 | RW |
| 0xB9 | Anti-soot Enable | uint16 | RW |
| **0xBA** | **Auto/Manual State (MODE write target)** | uint16 | RW |
| 0xBB | SP Select State | uint16 | RW |
| 0xBC | Remote/Local SP State | uint16 | RW |
| **0xBE** | **Loop Status (MODE read source)** | uint16 | R |
### 2.5 루프 주소 계산
```
Loop 1~24: base = 0x0040 + (N-1) * 0x0100
Loop 25~32: base = 0x7840 + (N-25) * 0x0100
```
검증 완료 (SummaryFunctionBlockReport.csv):
| Loop | CSV 주소 | 계산 | 일치 |
|:----:|:--------:|------|:----:|
| #1 | 0x0040 | `0x0040 + 0*0x0100` | ✅ |
| #11 | 0x0A40 | `0x0040 + 10*0x0100` | ✅ |
| #18 | 0x1140 | `0x0040 + 17*0x0100` | ✅ |
| #24 | 0x1740 | `0x0040 + 23*0x0100` | ✅ |
| #25 | 0x7840 | `0x7840 + 0*0x0100` | ✅ |
| #32 | 0x7F40 | `0x7840 + 7*0x0100` | ✅ |
### 2.6 주소 영역
| 영역 | 시작 | 끝 | 설명 |
|------|:----:|:---:|------|
| Misc | 0x0000 | 0x003F | 기타 파라미터 |
| Loop 1~24 | 0x0040 | 0x15FF | 루프 블록 (각 256 regs) |
| Loop 25~32 | 0x7840 | 0x78FF | 루프 블록 확장 |
| Analog Input | 0x1800 | 0x187F | AI 값 (Function Code 03) |
| **Variable** | **0x18C0** | **0x1D6F** | R/W 변수 (MATH_VAR) |
| **Signal Tag** | **0x2000** | **0x27CF** | R 태그 (TAG) |
---
## 3. Sinam_Tag_all.xlsx 데이터 구조
### 3.1 파일 개요
- 1개 시트 (Sheet1)
- **3407 데이터 행**, 150 컬럼
- 다중 섹션 구조
### 3.2 Section 구성
| Section | 행 범위 | 헤더 첫 컬럼 | 설명 |
|---------|---------|-------------|------|
| 1 (ItemName) | Row 2~ | `ItemName` | 메인 태그 정의 (AnalogPoint, StatusPoint, HistoryParameters) |
| 2+ (ParentItemName) | Row 1163~4424 | `ParentItemName` | FlexibleParameters 확장 속성 |
### 3.3 Section 1: ItemName — 주요 컬럼
| 컬럼명 | 0-indexed | 예시 | 설명 |
|--------|:--------:|------|------|
| `ItemName` | 0 | `FICQ-6101` | Experion 태그명 (대문자) |
| `Class` | 1 | `AnalogPoint` / `StatusPoint` / `HistoryParameters` | 태그 클래스 |
| `ItemDescription` | 5 | `FICQ-6101 PV` | 설명 |
| `DownloadedName` | 6 | `FICQ-6101` | 다운로드명 |
| `AreaCode` | 11 | `P6` | 영역 코드 |
| `TrendParameter` | 18 | `True` / `PV` / `False` | Experion 트렌드 대상 (참고용, 우리 시스템과 무관) |
| `SourceAddressPV` | 29 | `C3 LOOP 11 PV` / `C4 TAG 32 VALUE` | PV의 HC900 주소 |
| `SourceAddressOP` | 30 | `C3 LOOP 11 OPWORK` / `C4 MATH_VAR 5 VALUE` | OP의 HC900 주소 |
| `SourceAddressMD` | 31 | `C3 LOOP 11 LOOPSTAT` | MODE의 HC900 주소 |
| `DescriptorState0~7` | 61~68 | `RUN`, `STOP`, `FAULT` | 상태 레이블 (StatusPoint 한정) |
| `Units` | (AnalogPoint) | `kg/h` | 엔지니어링 단위 |
| `RangeLow` / `RangeHigh` | (AnalogPoint) | `0` / `100` | 측정 범위 |
### 3.4 Section 2+: ParentItemName (FlexibleParameters)
| 컬럼명 | 0-indexed | 예시 | 설명 |
|--------|:--------:|------|------|
| `ParentItemName` | 0 | `FICQ-6101` | 부모 태그명 |
| `Class` | 1 | `FlexibleParameters` | 고정 |
| `ParamName` | 2 | `QV`, `RST`, `STATE`, `AL1SP1`, `HZ` | 확장 파라미터명 |
| `TypeDatabaseReference` | 5 | `16 bit signed integer (INT2)` | 데이터타입 |
| `ScanPeriodPVUDSP` | 25 | `2` | 스캔 주기 (초) |
| `UnitsUDSP` | 26 | `kg` | 단위 |
| `RangeHighUDSP` / `RangeLowUDSP` | 27/28 | 범위 |
| `StatusNumStatesUDSP` | 32 | `2` | 상태 개수 |
| `DescriptorState0~7UDSP` | 33~40 | `OFF`, `ON` | 상태 레이블 (Status 한정) |
| `DestinationAddressPVUDSP` | 23 | `C3 MATH_VAR 37 VALUE` | 쓰기 주소 (있으면 RW, 없으면 R) |
### 3.5 SourceAddress 형식
| 형식 | 예시 | 변환 규칙 |
|------|------|----------|
| `C{N} TAG {N} VALUE` | `C4 TAG 32 VALUE` | `addr = 0x2000 + (N-1)*2`, access=R |
| `C{N} MATH_VAR {N} VALUE` | `C4 MATH_VAR 5 VALUE` | `addr = 0x18C0 + (N-1)*2`, access=RW |
| `C{N} LOOP {N} {PARAM}` | `C3 LOOP 11 PV` | 루프 블록 내 파라미터 오프셋 |
| `C{N} LOOPX {N} {PARAM}` | `C3 LOOPX 25 WSP` | LOOPX = Loop 25~32와 동일 주소 체계 |
---
## 4. 주소 변환 규칙 (공식 검증 완료)
Sinam_Tag_all.xlsx의 SourceAddress 문자열을 HC900 Modbus 주소로 변환하는 공식:
### 4.1 TAG N → Modbus 주소
```
addr = 0x2000 + (N-1) * 2
```
검증: CSV `TAG 4 = FIQ6101 @ 0x2006`
### 4.2 MATH_VAR N → Modbus 주소
```
addr = 0x18C0 + (N-1) * 2
```
검증: CSV `VAR 2 = LT3211_LSET @ 0x18C2`
### 4.3 LOOP N PARAM → Modbus 주소
```
base = loop_base(N)
off = MNEMONIC_OFFSET[PARAM]
addr = base + off
```
검증: `C2 LOOP 18 WSP → base=0x1140, off=0x04 → addr=0x1144`
### 4.4 Mnemonic → Offset 매핑
| Mnemonic | Offset | Experion Attr |
|----------|:------:|:-------------:|
| PV | 0x00 | PV |
| RSP / SP2 | 0x02 | — |
| WSP / SPWORK | 0x04 | **SP** |
| OP / OPWORK | 0x06 | **OP** |
| GAIN1 / PROP1 | 0x0C | GAIN |
| DIR | 0x0E | — |
| RESET1 | 0x10 | RESET |
| RATE1 | 0x12 | RATE |
| PVLOW | 0x16 | — |
| PVHIGH | 0x18 | — |
| AL1SP1 | 0x1A | — |
| AL1SP2 | 0x1C | — |
| LSP1 | 0x2A | — |
| LSP2 | 0x2C | — |
| AL2SP1 | 0x2E | — |
| SPLOW | 0x34 | SP_LO |
| SPHIGH | 0x36 | SP_HI |
| OPLOW | 0x3A | OP_LO |
| OPHIGH | 0x3C | OP_HI |
| OPWORK | 0x3E | OP |
| DEV | 0x4A | — |
| LOOPSTAT | **0xBE** | **MD (읽기)** |
| MODEIN | **0xBA** | **MD (쓰기)** |
| AMSTAT | 0xBA | — |
**⚠️ MODE 주의:** 읽기는 LOOPSTAT(0xBE), 쓰기는 Auto/Man State(0xBA). `build_register_map_from_sinam.py` 초기 버전에서 write_addr이 0xBE로 잘못 설정된 버그 있음 → 수정 완료.
---
## 5. 태그명 체계 및 등록 규칙
### 5.1 태그명 포맷
```
{Experion-ItemName}.{Parameter}
```
| 유형 | 태그명 예시 | 출처 |
|------|-----------|------|
| Loop PV | `FICQ-6101.PV` | ItemName + Mnemonic |
| Loop SP | `FICQ-6101.SP` | WSP/SPWORK → SP |
| Loop OP | `FICQ-6101.OP` | OP/OPWORK → OP |
| Loop MD | `FICQ-6101.MD` | LOOPSTAT(읽기)/MODEIN(쓰기) → MD |
| Loop GAIN | `FICQ-6101.GAIN` | GAIN1 → GAIN |
| Loop RESET | `FICQ-6101.RESET` | RESET1 → RESET |
| Loop RATE | `FICQ-6101.RATE` | RATE1 → RATE |
| Loop 기타 | `FICQ-6101.Deviation` | HC900명 유지 |
| AnalogPoint (TAG source) | `LI-9100` | Class=AnalogPoint, PV source=TAG |
| StatusPoint PV (TAG) | `P-9114` | Class=StatusPoint, PV source=TAG |
| StatusPoint OP (MATH_VAR) | `P-9114.OP` | Class=StatusPoint, OP source=MATH_VAR |
| FlexibleParameter | `FICQ-6101.QV` / `P-9114.STATE` | ParentItemName.ParamName |
### 5.2 Loop 파라미터 Experion명 매핑
Loop 블록 내 모든 파라미터 중 Experion이 노출하는 것만 Experion 속성명 사용, 나머지는 HC900 네이티브명 유지:
```
LOOP_LAYOUT 오프셋 → EXPERION_ATTR 매핑:
0x00(PV) → .PV
0x04(WSP) → .SP
0x06(OP) → .OP
0x0C(GAIN1) → .GAIN
0x10(RESET1) → .RESET
0x12(RATE1) → .RATE
0xBE(LOOPSTAT) → .MD
그 외 → HC900명 유지 (예: .Deviation, .PV_LowRange)
```
### 5.3 FlexibleParameter 등록
Section 2+에서 `ParentItemName.ParamName` 으로 등록:
| FlexibleParameter | 등록명 | 비고 |
|------------------|--------|------|
| FICQ-6101 / QV | `FICQ-6101.QV` | Quality Value (적산) |
| FICQ-6101 / RST | `FICQ-6101.RST` | Reset |
| FICQ-6101 / AL1SP1 | `FICQ-6101.AL1SP1` | Alarm Setpoint |
| P-9114 / STATE | `P-9114.STATE` | 운전 상태 |
| P-9114 / HZ | `P-9114.HZ` | 주파수 |
| P-9114 / HZSET | `P-9114.HZSET` | 주파수 설정 |
---
## 6. register-map-cN.json 포맷
### 6.1 엔트리 구조
```json
{
"tag": "FICQ-6101.PV",
"addr": 2624,
"write_addr": 2624,
"count": 2,
"type": "float32",
"access": "R",
"description": "LOOP #11 PV",
"archive": true
}
```
| 필드 | 타입 | 설명 |
|------|------|------|
| `tag` | string | 태그명 (Experion ItemName + 파라미터) |
| `addr` | uint16 | Modbus holding register 주소 (0-based) |
| `write_addr` | uint16 | 쓰기 주소 (기본값 = addr, MODE 등 별도 쓰기 레지스터가 있는 경우 다름) |
| `count` | uint16 | 레지스터 수 (float32=2, uint16=1) |
| `type` | string | 데이터 타입 (`float32` 또는 `uint16`) |
| `access` | string | 접근 권한 (`R` 또는 `RW`) |
| `description` | string | 설명 |
| `archive` | bool | history_table 기록 대상 여부 |
### 6.2 Loop 엔트리 (개별 파라미터)
```json
{
"tag": "FICQ-6101.PV", "addr": 2624, "count": 2, "type": "float32", "access": "R", "archive": true,
"tag": "FICQ-6101.SP", "addr": 2628, "count": 2, "type": "float32", "access": "RW", "archive": true,
"tag": "FICQ-6101.OP", "addr": 2630, "count": 2, "type": "float32", "access": "RW", "archive": true,
"tag": "FICQ-6101.MD", "addr": 2750, "count": 1, "type": "uint16", "access": "R", "archive": true,
"tag": "FICQ-6101.GAIN", "addr": 2636, "count": 2, "type": "float32", "access": "RW", "archive": false,
"tag": "FICQ-6101.RESET", "addr": 2640, "count": 2, "type": "float32", "access": "RW", "archive": false,
...
}
```
### 6.3 일반 TAG 엔트리
```json
{
"tag": "LI-9100",
"addr": 8194,
"write_addr": 8194,
"count": 2,
"type": "float32",
"access": "R",
"description": "Signal Tag #2",
"archive": true
}
```
### 6.4 Variable 엔트리 (MATH_VAR)
```json
{
"tag": "P-9114.OP",
"addr": 6280,
"write_addr": 6280,
"count": 2,
"type": "float32",
"access": "RW",
"description": "Variable (MATH_VAR) #5",
"archive": false
}
```
### 6.5 FlexibleParameter 엔트리
```json
{
"tag": "FICQ-6101.QV",
"addr": 8198,
"write_addr": 8198,
"count": 2,
"type": "float32",
"access": "R",
"description": "FlexibleParameter QV",
"archive": true
}
```
---
## 7. 아카이브/모니터링 제어
### 7.1 문제 정의
현재 시스템은 다음과 같이 동작:
```
hc900_map_master WHERE is_active = TRUE
→ realtime_table upsert (1초)
→ history_table snapshot (60초, digital 제외)
```
**문제점:**
- `realtime_table`: GAIN1, RESET1, AL1SP1 등 불필요한 태그까지 표시
- `history_table`: 아카이브할 가치 없는 태그까지 60초마다 기록 (GAIN1, BIAS, RATIO, DEV 등)
- `is_active` 하나로만 제어 → 실시간 표시와 이력 기록을 분리 불가
### 7.2 대상 선별 규칙
| 태그 유형 | 예시 | realtime | history | 근거 |
|-----------|------|:--------:|:-------:|------|
| **Loop PV** | `FICQ-6101.PV` | ✅ | ✅ | 공정값, 추세 필수 |
| **Loop SP** | `FICQ-6101.SP` | ✅ | ✅ | 설정값 변경 이력 |
| **Loop OP** | `FICQ-6101.OP` | ✅ | ✅ | 출력값 추세 |
| **Loop MODE** | `FICQ-6101.MD` | ✅ | ✅ | Auto/Man 전환 이력 |
| **Loop QV** | `FICQ-6101.QV` | ✅ | ✅ | Quality Value 추세 |
| **AnalogPoint PV** | `LI-9100` | ✅ | ✅ | 일반 공정값 |
| **StatusPoint PV** | `P-9114` | ✅ | ✅ | 상태 변화 (event_history_table) |
| **Loop 기타 파라미터** | `FICQ-6101.GAIN1`, `.RESET1`, `.RATE1`, `.AL1SP1`, `.DIR`, `.PV_LO`, `.PV_HI`, `.SP_LO`, `.SP_HI`, `.DEV` | ✅ | ❌ | 설정값, 실시간 확인만 |
| **Variable (MATH_VAR)** | `P-9114.RST`, `FICQ-6101.RST` | ❌ | ❌ | 중간 계산값, 불필요 |
| **StatusPoint OP** | `P-9114.OP` | ❌ | ❌ | MATH_VAR 기반 |
| **기타 FlexibleParameter** | `P-9114.STATE`, `FICQ-6101.AL1SP1` | △ | ❌ | 필요시 사용자 활성화 |
### 7.3 archive 플래그 선별 로직
```python
def should_archive(kind: str, param_name: str | None) -> bool:
if kind == 'loop':
return param_name in ('PV', 'SP', 'OP', 'MD', 'QV')
if kind == 'tag': # AnalogPoint / StatusPoint TAG source
return True
if kind in ('var', 'raw'):
return False
if kind == 'flexparam':
return False
return False
```
### 7.4 hc900_map_master 컬럼 추가
```sql
ALTER TABLE hc900_map_master
ADD COLUMN IF NOT EXISTS realtime_enabled BOOLEAN NOT NULL DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS archive_enabled BOOLEAN NOT NULL DEFAULT FALSE;
```
| 컬럼 | 설명 | 기본값 |
|------|------|--------|
| `realtime_enabled` | realtime_table 표시 여부 | TRUE (모든 활성 태그) |
| `archive_enabled` | history_table 기록 여부 | register-map의 `archive` 필드 |
사용자는 PointBuilder UI에서 두 플래그를 개별적으로 override 가능:
| 태그명 | 실시간 | 이력 |
|--------|:-----:|:----:|
| FICQ-6101.PV | ☑ | ☑ |
| FICQ-6101.SP | ☑ | ☑ |
| FICQ-6101.OP | ☑ | ☑ |
| FICQ-6101.MD | ☑ | ☑ |
| FICQ-6101.QV | ☑ | ☑ |
| FICQ-6101.GAIN1 | ☑ | ☐ |
| LI-9100 | ☑ | ☑ |
| P-9114 | ☑ | ☑ |
| P-9114.RST | ☐ | ☐ |
### 7.5 마이그레이션
```sql
-- 신규 컬럼 추가
ALTER TABLE hc900_map_master
ADD COLUMN IF NOT EXISTS realtime_enabled BOOLEAN NOT NULL DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS archive_enabled BOOLEAN NOT NULL DEFAULT FALSE;
-- build_register_map_from_sinam.py 가 archive=true 인 태그 UPDATE
-- (스크립트가 hc900_map_master upsert 시 archive_enabled 도 함께 설정)
```
---
## 8. 컨트롤러별 독립 맵 전략
### 8.1 컨트롤러 구성
| 컨트롤러 | IP | 포트 | gRPC Port | 현재 상태 |
|:--------:|:--:|:----:|:---------:|:---------:|
| C1 | 192.168.0.250 | 502 | 50051 | 미연결 |
| C2 | 192.168.0.230 | 502 | 50052 | 미연결 |
| C3 | 192.168.0.240 | 502 | 50053 | **활성** |
| C4 | 192.168.0.220 | 502 | 50054 | 미연결 |
### 8.2 설정 파일
```json
// config/gateway-config.json
{
"controllers": [
{ "id": "C1", "host": "192.168.0.250", "port": 502,
"map_path": "docs/register-map-c1.json", "grpc_port": 50051, "enabled": false },
{ "id": "C2", "host": "192.168.0.230", "port": 502,
"map_path": "docs/register-map-c2.json", "grpc_port": 50052, "enabled": false },
{ "id": "C3", "host": "192.168.0.240", "port": 502,
"map_path": "docs/register-map-c3.json", "grpc_port": 50053, "enabled": true },
{ "id": "C4", "host": "192.168.0.220", "port": 502,
"map_path": "docs/register-map-c4.json", "grpc_port": 50054, "enabled": false }
]
}
```
### 8.3 컨트롤러 등급별 구분
각 컨트롤러는 독립된 C++ 게이트웨이 프로세스로 동작:
- 각자의 register-map-cN.json 로드
- 각자의 IP/Port로 Modbus TCP 연결
- 각자의 gRPC 포트에서 서비스
- C# ControllerGrpcClientPool이 enabled 컨트롤러별로 클라이언트 생성
---
## 9. 데이터 흐름 (전면 개정)
### 9.1 전체 흐름
```
Sinam_Tag_all.xlsx (단일 진실 공급원)
build_register_map_from_sinam.py
├─ 주소 변환 (TAG/MATH_VAR/LOOP)
├─ 컨트롤러별 분할
├─ archive 플래그 설정
├─ tag_metadata upsert (상태 레이블, 단위, desc, area)
└─ hc900_map_master upsert (is_active, realtime_enabled, archive_enabled)
▼ 각 컨트롤러별
register-map-c1.json ──── C++ Gateway C1 ── gRPC:50051
register-map-c2.json ──── C++ Gateway C2 ── gRPC:50052
register-map-c3.json ──── C++ Gateway C3 ── gRPC:50053
register-map-c4.json ──── C++ Gateway C4 ── gRPC:50054
Hc900RealtimeService (C# BackgroundService)
1. hc900_map_master WHERE is_active=TRUE
AND realtime_enabled=TRUE AND controller_id=$1
2. client.ReadTagsAsync(tagNames) ← 1초 간격
3. BatchUpdateRealtimeTableAsync()
→ INSERT INTO realtime_table
(controller_id, tagname, node_id, livevalue, timestamp)
ON CONFLICT (controller_id, tagname) DO UPDATE ...
realtime_table (최신값, tag당 1행, 1초 갱신)
▼ 60초 간격
Hc900HistoryService (C# BackgroundService)
1. hc900_map_master WHERE archive_enabled=TRUE
2. realtime_table JOIN archive_enabled → tagged points
3. stamp DateTime.UtcNow
4. INSERT INTO history_table (TimeScaleDB hypertable)
event_history_table (Hc900DigitalEventDetectorService)
└─ StatusPoint PV 변화 감지 → ALARM/TRIP/RUN/NORMAL
```
### 9.2 C++ 게이트웨이 상세
```
C++ Gateway (hc900_gateway)
LoadRegisterMap()
→ JSON 파싱 → registers_[] + tag_index_[]
→ sorted_indices_[] (addr 정렬)
PollLoop()
→ ReadAllRegisters():
registers_를 addr 순서로 스캔
≤120 regs 윈도우에 들어가는 엔트리들 배치
→ controller_->read_raw(batch_start, read_count)
→ 각 엔트리 decode_float(FP_B) → cache_[tag_name]
gRPC ReadTags()
→ cache_ 에서 즉시 반환 (Modbus I/O 없음)
gRPC WriteTag(tag, value)
→ transport_mutex_ 획득
→ controller_->write_raw(entry.write_addr, ...) ← FC16
→ cache_ 업데이트
gRPC HealthCheck()
→ 연결 상태, poll_count, last_poll_duration
```
---
## 10. build_register_map_from_sinam.py 처리 규칙
### 10.1 실행 명령어
```bash
python3 scripts/build_register_map_from_sinam.py \
--sinam docs/Sinam_Tag_all.xlsx \
--controller C3 \
-o docs/register-map-c3.json \
[--db-conn "Host=...;Database=hc900;..."]
```
### 10.2 처리 순서
1. **Sinam_Tag_all.xlsx 로드** (openpyxl, read_only=True, data_only=True)
2. **Section 1 처리** (ItemName 행):
- 각 행의 모든 셀에서 정규식으로 C{N} LOOP/TAG/MATH_VAR/LOOPX/RAW 검색
- `scan_point()` 분류: loop / tag / var / raw
- Loop: `build_loop_entries()` → 모든 LOOP_LAYOUT 파라미터를 개별 엔트리로 전개
- Tag: `build_point_entry()` → 단일 엔트리
- Var: `build_point_entry()` → 단일 엔트리
3. **Section 2+ 처리** (FlexibleParameters 행):
- `resolve_one()` 으로 개별 주소 해석
- `ParentItemName.ParamName` 태그명 생성
4. **`archive` 플래그 설정** (`should_archive()`)
5. **컨트롤러별 필터링** (--controller 인자)
6. **주소 순서 정렬** 후 JSON 출력
7. **선택적 DB 적재**:
- `tag_metadata` upsert (상태 레이블, 단위, desc, area)
- `hc900_map_master` upsert (is_active, realtime_enabled, archive_enabled)
### 10.3 Loop 엔트리 전개 상세
`build_loop_entries(item_name, loop_no, mnemonics)`:
```
입력: item_name="FICQ-6101", loop_no=11, mnemonics={PV, WSP, OPWORK, LOOPSTAT}
base = loop_base(11) = 0x0A40 = 2624
LOOP_LAYOUT 각 offset을 순회:
off=0x00 → suffix="PV", attr=EXPERION_ATTR.get("PV")="PV"
→ tag="FICQ-6101.PV", addr=2624, archive=true
off=0x04 → suffix="WSP", attr=EXPERION_ATTR.get("WSP")="SP"
→ tag="FICQ-6101.SP", addr=2628, archive=true
off=0x06 → suffix="Output", attr=EXPERION_ATTR.get("OPWORK")="OP"
→ tag="FICQ-6101.OP", addr=2630, archive=true
off=0xBE → has_mode=True → 별도 .MD 엔트리
→ tag="FICQ-6101.MD", addr=2750 (=2624+0xBE),
write_addr=2746 (=2624+0xBA), archive=true
나머지 LOOP_LAYOUT 파라미터는 HC900명 유지:
off=0x0C → tag="FICQ-6101.Gain1_PropBand1", addr=2636, archive=false
off=0x10 → tag="FICQ-6101.Reset1", addr=2640, archive=false
...
```
### 10.4 MODE write_addr 처리
```python
# Loop Status 0xBE → read source
# Auto/Man State 0xBA → write target
if has_mode:
entries.append({
'tag': f'{item_name}.MD',
'addr': base + 0xBE, # 읽기 = Loop Status
'write_addr': base + 0xBA, # 쓰기 = Auto/Man State ⚠️
'count': 1,
'type': 'uint16',
'access': 'R', # 읽기전용 (게이트웨이가 MD 쓰기를 MODEIN으로 라우팅)
'description': f'LOOP #{n} Mode status',
'archive': True,
})
```
⚠️ 초기 버전에서 `write_addr = base + 0xBE`로 잘못 설정된 버그 수정 완료.
올바른 값: 읽기=0xBE(LoopStatus), 쓰기=0xBA(Auto/Man State).
### 10.5 archive 플래그 설정
```python
def should_archive(kind: str, is_loop: bool, param_name: str | None,
access: str, is_signal_tag: bool) -> bool:
"""history_table 기록 대상 선별"""
if is_loop and param_name in ('PV', 'SP', 'OP', 'MD', 'QV'):
return True
if not is_loop and access == 'R' and is_signal_tag:
return True # AnalogPoint / StatusPoint TAG source (PV)
return False
```
---
## 11. C# 서비스 수정 사항
### 11.1 Hc900MapEntry 엔티티
```csharp
// src/Core/Domain/Entities/Hc900Entities.cs
[Table("hc900_map_master")]
public class Hc900MapEntry
{
[Key] public int Id { get; set; }
[Column("tagname")] public string TagName { get; set; } = "";
[Column("hc900_tag")] public string Hc900Tag { get; set; } = "";
[Column("modbus_addr")] public int ModbusAddr { get; set; }
[Column("data_type")] public string DataType { get; set; } = "float32";
[Column("access")] public string Access { get; set; } = "R";
[Column("is_active")] public bool IsActive { get; set; } = true;
[Column("controller_id")] public string ControllerId { get; set; } = "HC1";
[Column("realtime_enabled")] public bool RealtimeEnabled { get; set; } = true; // 신규
[Column("archive_enabled")] public bool ArchiveEnabled { get; set; } = false; // 신규
}
```
### 11.2 Hc900RealtimeService.LoadMappingAsync
```csharp
// 변경 전
"SELECT tagname, hc900_tag FROM hc900_map_master WHERE is_active = TRUE AND controller_id = $1"
// 변경 후
"SELECT tagname, hc900_tag FROM hc900_map_master WHERE is_active = TRUE AND realtime_enabled = TRUE AND controller_id = $1"
```
### 11.3 Hc900HistoryService.SnapshotToHistoryAsync
```csharp
// 변경 전
var digitalTagNames = await GetDigitalTagNamesCachedAsync();
var points = await _ctx.RealtimePoints
.Where(p => !digitalTagNames.Contains(p.TagName))
.ToListAsync();
// 변경 후
var archiveTags = await _ctx.Hc900MapEntries
.Where(m => m.ArchiveEnabled)
.Select(m => m.TagName)
.ToListAsync();
var points = await _ctx.RealtimePoints
.Where(p => archiveTags.Contains(p.TagName))
.ToListAsync();
```
### 11.4 DDL (멱등)
```csharp
await _ctx.Database.ExecuteSqlRawAsync("""
ALTER TABLE hc900_map_master
ADD COLUMN IF NOT EXISTS realtime_enabled BOOLEAN NOT NULL DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS archive_enabled BOOLEAN NOT NULL DEFAULT FALSE
""");
```
---
## 12. tag_metadata 적재
`build_register_map_from_sinam.py` 실행 시 `--db-conn` 옵션으로 tag_metadata도 함께 upsert:
| attribute | 출처 (Sinam_Tag_all.xlsx) |
|-----------|--------------------------|
| `desc` | `ItemDescription` 또는 `DownloadedName` |
| `area` | `AreaCode` (예: `P6`, `P9`) |
| `state0~7` | `DescriptorState0~7` (StatusPoint) / `DescriptorState0~7UDSP` (FlexibleParameters) |
| `units` | `Units` (AnalogPoint) / `UnitsUDSP` (FlexibleParameters) |
| `eulo` / `euhi` | `RangeLow` / `RangeHigh` (AnalogPoint) / `RangeLowUDSP` / `RangeHighUDSP` (FlexibleParameters) |
```sql
INSERT INTO tag_metadata (base_tag, attribute, value, controller_id)
VALUES (%s, %s, %s, %s)
ON CONFLICT (base_tag, attribute, controller_id)
DO UPDATE SET value = EXCLUDED.value, loaded_at = NOW()
```
---
## 13. 알려진 문제
### 13.1 MODE write_addr 버그 (수정 완료)
- `build_register_map_from_sinam.py` 초기 버전: MODE 엔트리의 `write_addr = base + 0xBE` (LoopStatus, 읽기전용)
- 올바름: `write_addr = base + 0xBA` (Auto/Man State, R/W)
- 영향: 게이트웨이를 통한 MODE 쓰기(MAN→AUTO 전환)가 HC900에 반영되지 않음
- 해결: 개정 코드에서 `MD_WRITE_OFFSET = 0xBA` 사용
### 13.2 LOOPX (Custom Map) 주소 검증
- `C4 LOOPX 25 AL1SP1` 등의 Custom Map 영역
- HC Designer Custom Map 보고서 필요 (현재 SummaryFunctionBlockReport.csv는 Fixed Map 기준)
- 주소 체계 검증: LOOPX 25~32는 Loop 25~32와 동일한 `0x7840 + (N-25)*0x0100` 사용한다고 가정
### 13.3 C4 PID Loop 데이터 부재
- `FICQ-9101`, `FICQ-9214` 등 C4 루프 태그는 Sinam_Tag_all.xlsx에 SourceAddress가 있음
- HC Designer CSV는 C4(Custom Map) PID Loop 데이터 없음 (빈 맵)
- PointBuilder로 수동 등록 또는 HC Designer Custom Map Export 필요
### 13.4 Config 게이트웨이 경로
- `gateway-config.json`이 구버전 `register-map-c3.json`(2189개 엔트리, HC900명) 참조 중
- 신규 `register-map-c3.json`(Experion명) 생성 후 경로 업데이트 필요
### 13.5 hc900_map_master.tagname = hc900_tag 중복
- 현재 `tagname`(소문자 OPC UA 명)과 `hc900_tag`(대문자 Experion 명)가 동일해짐
- 향후 `tagname` 컬럼 제거하고 `hc900_tag`로 통일 가능
- 마이그레이션 동안은 두 컬럼 모두 동일한 값(Experion 대문자명) 유지
---
## 14. 폐기 항목
| 항목 | 사유 | 대체 |
|------|------|------|
| `load_state_labels.py` | Sinam_Tag_all.xlsx 통합 | `build_register_map_from_sinam.py`에 통합 |
| HC Designer CSVs (`SignalTags.csv` 등) | 주소 검증용 보조 자료 | Sinam_Tag_all.xlsx가 주 소스 |
| OPC UA `node_id` | HC900 직결 방식에서 불필요 | 빈 문자열로 저장 |
| 소문자 태그명 (`ficq-6101.pv`) | OPC UA 강제 규칙 폐기 | 대문자 (`FICQ-6101.PV`) |
| `build_register_map.py` (기존) | CSV 의존, Experion명 미지원 | `build_register_map_from_sinam.py` |
| `build_register_map_from_csv.py` | CSV Full Map 단순 변환 | Sinam 기반 스크립트로 대체 |
| 개별 루프 파라미터 → 루프 블록 통째 | 게이트웨이 MAX_BATCH=120 제약 | 개별 파라미터 유지 (게이트웨이가 동적 배치) |
---
## 15. 마이그레이션 순서
### Phase A: 스크립트 완성
| 단계 | 작업 | 담당 |
|:----:|------|------|
| A1 | `build_register_map_from_sinam.py` MODE write_addr 버그 수정 (0xBE→0xBA) | Python |
| A2 | `build_register_map_from_sinam.py``archive` 필드 추가 + 선별 로직 구현 | Python |
| A3 | `build_register_map_from_sinam.py`에 tag_metadata upsert 로직 추가 | Python |
| A4 | `build_register_map_from_sinam.py`에 hc900_map_master upsert 로직 추가 | Python |
| A5 | C1~C4 전 컨트롤러 register-map 생성 테스트 | Python |
### Phase B: DB 마이그레이션
| 단계 | 작업 | 담당 |
|:----:|------|------|
| B1 | `hc900_map_master``realtime_enabled`, `archive_enabled` DDL 추가 | C# |
| B2 | Phase A에서 생성한 register-map으로 hc900_map_master 초기 적재 | Python |
### Phase C: C# 서비스 수정
| 단계 | 작업 | 담당 |
|:----:|------|------|
| C1 | `Hc900MapEntry``RealtimeEnabled`, `ArchiveEnabled` 속성 추가 | C# |
| C2 | `Hc900RealtimeService.LoadMappingAsync``AND realtime_enabled = TRUE` 추가 | C# |
| C3 | `SnapshotToHistoryAsync``archive_enabled` 필터 적용 | C# |
| C4 | PointBuilder UI에 실시간/이력 토글 컬럼 추가 | C# |
### Phase D: 배포
| 단계 | 작업 | 담당 |
|:----:|------|------|
| D1 | C++ 게이트웨이 신규 register-map-c3.json으로 교체 | Config |
| D2 | `gateway-config.json` 경로 업데이트 | Config |
| D3 | C# 서비스 재시작 | Ops |
| D4 | 기존 `archive_enabled = FALSE` → register-map 기준 UPDATE | SQL |
---
## 관련 파일
| 파일 | 설명 |
|------|------|
| `docs/Sinam_Tag_all.xlsx` | **단일 진실 공급원** — 모든 태그 정의 + 메타데이터 |
| `docs/51-52-25-111-HC900-Process-Controller-Communications-manual.pdf` | HC900 통신 매뉴얼 (Table 6-1, 6-3) |
| `docs/register-map-cN.json` | 컨트롤러별 레지스터 맵 (build_register_map_from_sinam.py 생성) |
| `config/gateway-config.json` | 게이트웨이 프로세스 설정 (IP, port, 맵 경로) |
| `scripts/build_register_map_from_sinam.py` | **단일 빌드 스크립트** — 맵 생성 + 메타데이터 + archive 플래그 |
| `src/Infrastructure/Hc900/Hc900RealtimeService.cs` | 실시간 폴링 서비스 (gRPC → realtime_table) |
| `src/Infrastructure/Hc900/Hc900HistoryService.cs` | 이력 스냅샷 서비스 (realtime_table → history_table) |
| `src/Infrastructure/Database/Hc900DbContext.cs` | DB 컨텍스트 (테이블 정의, 마이그레이션 + SnapshotToHistoryAsync) |
| `src/Core/Domain/Entities/Hc900Entities.cs` | `Hc900MapEntry` 엔티티 (hc900_map_master 매핑) |
| `industrial-comm/cpp/src/gateway.cpp` | C++ 게이트웨이 (Modbus 폴링 + gRPC + 동적 배치 그룹핑) |
| `industrial-comm/cpp/src/modbus_tcp.cpp` | Modbus TCP 전송 계층 (FC03, FC16) |
| `industrial-comm/cpp/src/vendor_formats.hpp` | FP_B float format (`bigEndian, highFirst, normal`) |

View File

@@ -0,0 +1,162 @@
# 작업지시: 신호점 PV 명명 일관화(.PV) 전체 롤아웃
> 2026-06-09. `build_register_map_from_sinam.py`는 이미 수정 완료(검증: bare 태그 0).
> 이 문서는 **DB/데이터/다운스트림까지 일관 적용**하는 절차. 깨진 중간상태 방지를 위해
> 모든 단계를 함께 수행할 것.
## 진단 결과 (diagnosis-checklist.md 기준)
| STEP | 결과 |
|------|------|
| STEP 1-2 | Python 스크립트(`build_register_map_from_sinam.py`) + C# 서비스(Realtime/DigitalEvent/DbContext) |
| STEP 3 | 전체 파일 읽음 (670행 Python, 266행 RealtimeService, 199행 DigitalEventDetector, 3144행 DbContext) |
| STEP 4 | 호출 계층: DB → RealtimeService(폴링) → DigitalEventDetector(1s 감지) |
| STEP 5 | 🔴 HIGH 1건 발견 (아래 참조) |
| STEP 6 | 교차검증 통과 — 실제 코드에서 재확인 |
| STEP 7-8 | 아래 1건 보고 |
### [1]. GetDigitalTagNamesAsync 가 bare 태그명 반환 → 디지털 이벤트 감지 실패 (HIGH)
**문제**: `GetDigitalTagNamesAsync()` (`Hc900DbContext.cs:2273`) 는 `tag_metadata`에서 `base_tag`(예: `LI-6100`) 를 반환하고, `GetDigitalPointsAsync()` (`Hc900DbContext.cs:2303`) 는 이 bare 명으로 `realtime_table`을 조회한다. 그러나 PV 일관화 롤아웃 후 `realtime_table`의 태그명은 `LI-6100.PV`이므로 **매칭 실패 → 디지털 태그 0개 감지 → 이벤트 기록 완전 마비**.
**근거**:
- `Hc900DbContext.cs:2278-2281`: `SELECT DISTINCT BaseTag FROM tag_metadata WHERE attribute LIKE 'state%'` → bare 명 반환
- `Hc900DbContext.cs:2303-2304`: `WHERE tagname IN (bare 명들)``LI-6100.PV`과 매칭 안 됨
- `Hc900DigitalEventDetectorService.cs:71-73`: `GetDigitalTagNamesAsync()` 결과로 `_previousStates` 초기화
- `Hc900DigitalEventDetectorService.cs:103`: `GetRealtimeRecordsByTagNamesAsync(queryTags)` → 빈 결과
- `Hc900RealtimeService.cs:251-253`: `FormatValue``baseTag = tagname.split('.')[0]`로 이미 `.PV` 대응 완료 → 수정 불필요
**영향**: 롤아웃 후 디지털 이벤트(TRIP/RUN/ALARM)가 **전부 기록되지 않음**. 운전원 알람 미수신, 이벤트 로그 공백.
**수정**: `GetDigitalPointsAsync()` 조회 시 bare + `.PV` 명 모두 매칭. `GetDigitalTagPairsAsync()` 반환값에 `.PV` 붙임.
```csharp
// Hc900DbContext.cs:2295-2308 수정
public async Task<IEnumerable<RealtimePoint>> GetDigitalPointsAsync()
{
var digitalTagNames = await GetDigitalTagNamesAsync();
var tagSet = new HashSet<string>(digitalTagNames);
if (tagSet.Count == 0)
return Enumerable.Empty<RealtimePoint>();
// bare 명(LI-6100) + .PV 명(LI-6100.PV) 모두 매칭 — PV 일관화 롤아웃 대응
return await _ctx.RealtimePoints
.Where(p => tagSet.Contains(p.TagName)
|| (p.TagName.LastIndexOf('.') > 0 && tagSet.Contains(p.TagName.Substring(0, p.TagName.LastIndexOf('.')))))
.ToListAsync();
}
// Hc900DbContext.cs:2742-2759 수정
private async Task<HashSet<(string, string)>> GetDigitalTagPairsAsync()
{
var fromMetadata = await _ctx.TagMetadata
.Where(m => m.Attribute.StartsWith("state") && m.Value != null && m.Value != "")
.Select(m => new { m.ControllerId, m.BaseTag })
.Distinct()
.ToListAsync();
if (fromMetadata.Any())
return fromMetadata.Select(m => (m.ControllerId, m.BaseTag + ".PV")).ToHashSet();
var fromRealtime = await _ctx.RealtimePoints
.Where(p => p.LiveValue != null && p.LiveValue.StartsWith("{"))
.Select(p => new { p.ControllerId, p.TagName })
.Distinct()
.ToListAsync();
return fromRealtime.Select(p => (p.ControllerId, p.TagName)).ToHashSet();
}
```
`GetDigitalTagPairsAsync()` 수정으로 `GetDigitalTagPairsCachedAsync()` 사용처(`Hc900DbContext.cs:1863`) 의 디지털 제외 필터도 자연 해결 → history_table에 디지털 쓰레기 데이터 적재 방지.
## 무엇이 바뀌나
기존: 신호/상태/아날로그 점의 PV가 **bare**(`LI-6100`, `AG-3202`), 그 외 속성만 suffix
(`AG-3202.OP`). → 같은 계기 안에서 PV만 suffix 없는 불일치.
변경: **모든 PV를 `.PV` suffix로** → 전 레지스터가 `{base}.{param}`.
- `LI-6100``LI-6100.PV`
- `AG-3202``AG-3202.PV` (+ `.OP/.MD`)
- 변수 `VP-8117``.PV/.OP/.MD`, SIG `TI-8117HSET``.PV/.SP`
- 루프(FICQ 등)는 이미 `.PV` — 영향 없음.
스크립트 변경점: `build_registers` 의 점 PV 엔트리 `add_entry(item_name, ...)`
`add_entry(f'{item_name}.PV', ...)` (이미 반영됨).
### 추가: 지시계 흡수(dedup) — 이미 반영됨
짧은 지시계 prefix(≥2자, FI/LI/TI/PI…)가 **같은 번호의 더 긴 prefix(그 prefix로 시작하는
컨트롤러/적산계, 동일 측정)**에 흡수되어 중복 제거된다.
- 예) `FI-6101 → FICQ-6101`, `FI-5115 → FIQ-5115`, `LI-6128 → LICA-6128`, `PI/TI → PICA/TICA`.
- 대응 없는 독립 지시계(`FI-3203/3401/3402/6128` 등)는 유지.
- `P-`(펌프, 1자), `AG-/XV-/VP-`(교반/밸브/변수)는 ≥2자 규칙 + prefix-startswith 로 **보호**(흡수 안 됨).
- 검증(C3): 흡수 61개(FI 28, TI 18, PI 8, LI 7), 비지시계 흡수 0. 레지스터 2185→2123.
- 구현: `build_registers` 말미 후처리 — register 필터 + db_conn 시 흡수 base를 map_master/
tag_metadata 에서 DELETE(`split_part(...,'.',1)=base`).
## 롤아웃 순서 (반드시 함께)
### 1) register-map + map_master + metadata 재생성 (4 컨트롤러)
```bash
cd /home/windpacer/projects/hc900_ax
DSN="host=localhost port=5432 dbname=iiot_platform user=postgres password=postgres options=-csearch_path=hc900"
for C in C1 C2 C3 C4; do
python3 scripts/build_register_map_from_sinam.py --controller $C \
--sinam docs/Sinam_Tag_all.xlsx -o docs/register-map-${C,,}.json --db-conn "$DSN"
done
```
→ map_master 가 새 .PV 태그로 갱신됨. 단, **이전 bare 태그가 map_master에 잔존**
(upsert는 삭제 안 함). 아래로 정리:
```bash
# 각 컨트롤러: register-map JSON 에 없는 map_master 태그 비활성/삭제 (고아 = 옛 bare명)
python3 - <<'PY'
import json, subprocess
for c in ['C1','C2','C3','C4']:
js={r['tag'] for r in json.load(open(f'docs/register-map-{c.lower()}.json'))['registers']}
out=subprocess.run(["docker","exec","iiot-timescaledb","psql","-U","postgres","-d","iiot_platform","-At","-c",
f"SELECT tagname FROM hc900.hc900_map_master WHERE controller_id='{c}'"],capture_output=True,text=True).stdout
orphan=set(out.split())-js
print(c,'orphan',len(orphan))
if orphan:
vals=",".join("'"+t.replace("'","''")+"'" for t in orphan)
subprocess.run(["docker","exec","iiot-timescaledb","psql","-U","postgres","-d","iiot_platform","-c",
f"DELETE FROM hc900.hc900_map_master WHERE controller_id='{c}' AND tagname IN ({vals})"])
PY
```
### 2) 기존 realtime/history 데이터 마이그레이션 (bare → .PV)
옛 bare명 데이터를 새 .PV명으로 rename(아날로그 신호점 한정 — 디지털 PV는 history에
없음). map_master의 새 .PV 태그 중 옛 bare가 realtime/history에 있던 것만.
```sql
-- realtime_table: bare → bare||'.PV' (단, 이미 .PV/기타 suffix 없는 신호점만)
-- 안전을 위해 map_master 의 .PV 태그에서 base 도출해 매핑.
-- (대량 작업 — 백업 후 실행 권장)
```
> 대안(권장): 데이터 마이그레이션 대신 **realtime_table/history_table의 옛 bare 행은
> 폐기**하고 게이트웨이 재시작 후 새 .PV명으로 자연 재적재. history 트렌드 연속성이
> 필요하면 rename, 아니면 폐기가 단순.
### 3) 다운스트림 코드 (C# `src/Infrastructure/Database/Hc900DbContext.cs`)
**디지털 검출**: `GetDigitalTagNamesAsync`/`GetDigitalTagPairsAsync` 는 state 라벨 보유
**base_tag**(예: `P-9114`)를 반환하는데, 이제 realtime 태그명은 `P-9114.PV`다.
→ 반환값을 `base || '.PV'` 로 매핑하도록 수정(또는 detector가 base.PV로 조회).
- `Hc900RealtimeService.FormatValue``baseTag = tagname.split('.')[0]``.PV`여도
state 라벨 적용은 정상(수정 불필요).
- `Hc900DigitalEventDetectorService` 는 위 검출목록으로 realtime 조회 → 목록이 `.PV`면 동작.
### 4) 게이트웨이/앱 재시작 + 검증
```bash
pkill -f Hc900Crawler; pkill -f hc900_gateway; sleep 3
cd src/Hc900Crawler && setsid nohup dotnet run --no-build > /tmp/hc900_app.log 2>&1 < /dev/null &
# 검증: 디지털 태그 N개 로드 / 에러 0 / realtime 에 .PV 명 적재
```
## 검증 체크리스트
- register-map: bare(suffix 없는) 태그 **0** (모두 `{base}.{param}`).
- map_master == register-map JSON (고아 0).
- 디지털 검출: `디지털 태그 N개 로드` 로그, 이벤트 정상.
- 이력조회 드롭다운: `.PV` 명으로 표시(예: `LI-6100.PV`).
## 참고
- 관련 메모리: `sinam-xlsx-multisection-parsing`, `tag-naming-and-map-master`.
- 이 변경은 doc §5.1(AnalogPoint PV=bare)을 **개정**한다 — 일관성 위해 PV도 항상 suffix.

View File

@@ -0,0 +1,169 @@
# 작업지시: Point Builder 탭에 Sinam xlsx 파싱 UI 통합
> 2026-06-09. `build_register_map_from_sinam.py`(다중섹션 파서, `.PV` 일관화 + 지시계 흡수
> 포함)를 웹 UI에서 구동하도록 Point Builder 탭에 기능 추가. 다른 세션/LLM이 이 문서로
> 구현 가능하도록 작성.
## 진단 결과 (diagnosis-checklist.md 기준)
| STEP | 결과 |
|------|------|
| STEP 1-2 | 설계 문서. Point Builder 탭 + Sinam xlsx 파싱 UI 통합. 의존: `PointBuilderController.cs`, `pb.html/js`, `build_register_map_from_sinam.py`, `ControllerProcessManager` |
| STEP 3 | 전체 파일 읽음 (설계문서 110행, Controller 204행, pb.html 294행, pb.js 321행, 스크립트 670행, ProcMgr 294행) |
| STEP 4 | `pb.js``PointBuilderController`(업로드/파싱) → `Process.Start`(Python spawn) → SetupController(게이트웨이 재시작) |
| STEP 5 | 🟠 MED 4건, 🟡 LOW 2건 (아래 참조) |
| STEP 6 | 교차검증 통과 (실제 코드와 대조 완료) |
| STEP 7-8 | 아래 보고 |
### [1]. 스크립트 stdout이 사람용 텍스트 — C# 파싱 불안정 (MED)
**문제**: 문서는 `stdout 파싱`을 전제하지만, `build_register_map_from_sinam.py:645-650`의 stdout은 사람용 자유형 텍스트(`f'{len(registers)} registers'`, `f'archive=true: {n_archive}'`, `f'흡수(중복 신호점 제거): {len(absorbed_bases)}개 base...'`). 정규식 파싱이 가능하나 출력 형식 변경 시 C# 파싱이 깨짐.
**근거**: `build_register_map_from_sinam.py:645-650` — 구조화 출력 없음
**영향**: UI 파싱 결과 요약(registers/archive/absorbed/loops)이 항상 0 또는 누락으로 표시됨
**수정**: 스크립트 말미에 machine-readable JSON summary 라인 추가 (또는 C#이 register-map JSON을 직접 읽어 결과 추출)
### [2]. 고아 정리 — `NOT IN`에 수천 개 IN-list는 위험 (MED)
**문제**: 문서 line 57-61 `NOT IN (<register-map JSON 태그 목록>)` — 수천 개 IN-list로 SQL 길이 폭발, PostgreSQL `NOT IN`의 NULL 처리 문제 발생 가능.
**근거**: 문서 line 57-61 — `WHERE m.tagname NOT IN (<JSON 태그 목록>)`
**영향**: 고아 정리 실패 또는 불완전 수행으로 map_master 잔존 태그 발생
**수정**: 임시 staging 테이블 + `NOT EXISTS` 패턴 사용 (문서 §3 반영)
### [3]. 에러 처리 및 타임아웃 부재 (MED)
**문제**: 문서 line 33-45에 Process 실패·타임아웃·stderr 캡처 명시 없음. 단발성 프로세스이므로 McpServerHostedService의 헬스체크 패턴과 다름.
**근거**: 문서 line 33-45 — stdout/결과 반환만 언급, 실패 경로 없음
**영향**: xlsx 파싱 실패 시 사용자에게 의미 있는 오류 없이 빈 결과
**수정**: 최대 제한시간 + ExitCode 확인 + stderr 캡처 명시 필요
### [4]. DSN 명령줄 인자 — 공백/따옴표 처리 위험 (MED)
**문제**: `--db-conn "host=... password=postgres options=-csearch_path=hc900"` — DSN 내 공백 다수. `shell=false` + 리스트 인자 필수, 문서는 `--db-conn "{psycopg2 DSN}"`로만 표기되어 구현자가 `shell=true` 선택 위험.
**근거**: 문서 line 34-40, 47-52 — DSN 변환 유틸 제시하나 인자 전달 방식 불명확
**영향**: 쉘 인젝션 또는 DSN 파싱 실패로 DB 연결 불가
**수정**: 문서에 `ArgumentList` 사용 강제 명시
### [5]. WorkingDirectory 하드코딩 (LOW)
**문제**: 문서 line 45 — `/home/windpacer/projects/hc900_ax` 절대경로. `McpServerHostedService.cs:19``McpServer:WorkingDirectory` 설정값을 읽는 것과 대조적.
**근거**: 문서 line 45
**영향**: 다른 환경 배포 시 수정 필요
**수정**: appsettings.json 설정값 사용
### [6]. 연속 클릭 방지 구현 부재 (LOW)
**문제**: 문서 line 98 "버튼 비활성화 + 진행표시" 언급만 있고 pb.js에 구체 로직 없음. 파싱 중복 실행 가능.
**근거**: 문서 line 98, pb.js `pbSinamParse()` 미구현
**영향**: 파싱 중복 실행, DB upsert 충돌
**수정**: 버튼 비활성화/재활성화 패턴 명시
## 목표 (사용자 요구)
1. 웹에서 **xlsx 파일 선택(업로드)**.
2. **[파싱 시작]** 버튼 → 파싱 실행.
3. 파싱된 태그를 **목록에서 add/삭제·활성화/비활성화** (기존 Point Builder 목록 재사용).
## 설계 결정
- **위치 = Point Builder 탭.** 이미 map_master 태그셋 관리(목록·페이징·add·delete·build/apply)를
담당 → 파싱(태그셋 생성)을 같은 탭에 두는 게 일관. xlsx = 단일 진실 공급원(CLAUDE.md).
- **C#이 Python 스크립트를 spawn(재사용)**, C# 포팅 금지(다중섹션 파싱 중복 방지).
- 기존 패턴 재사용: `src/Infrastructure/Mcp/McpServerHostedService.cs`(Process.Start로
`uv run` 구동), 파일 업로드 `/api/kb/upload`·`/api/pid/upload`·`/api/docs/upload`.
## 백엔드 (`src/Hc900Crawler/Controllers/PointBuilderController.cs` 확장)
### 1) 업로드
```
POST /api/pointbuilder/sinam/upload (multipart/form-data)
```
- 검증: 확장자 `.xlsx`, 크기 상한(예: 50MB).
- 저장 위치: `docs/uploads/Sinam_<timestamp>.xlsx` (또는 임시 디렉터리). 반환: `{ file: "<path>" }`.
### 2) 파싱 (dry-run / apply)
```
POST /api/pointbuilder/sinam/parse
body: { file: string, controller: "C1|C2|C3|C4", applyDb: bool }
```
- C#에서 Process 구동:
```
python3 scripts/build_register_map_from_sinam.py
--controller {controller}
--sinam {file}
-o docs/register-map-{controller.lower()}.json
[--db-conn "{psycopg2 DSN}"] # applyDb=true 일 때만
```
- `applyDb=false`(dry-run): `--db-conn` 생략 → JSON만 생성, DB 미변경. 결과 미리보기용.
- `applyDb=true`: `--db-conn` 포함 → map_master/tag_metadata 갱신(흡수 DELETE 포함).
- stdout 파싱해서 반환: `{ registers, archive, absorbed, loops, stdout }`
(스크립트는 `N registers`, `흡수(중복 신호점 제거): M개`, `archive=true: K`, `N loops expanded` 출력).
- **WorkingDirectory = 프로젝트 루트**(`/home/windpacer/projects/hc900_ax`).
### DSN 변환 유틸 (필수)
C# 연결문자열(.NET) → psycopg2 DSN. appsettings `DefaultConnection`
(`Host=localhost;Port=5432;Database=iiot_platform;...;Search Path=hc900`) →
`host=localhost port=5432 dbname=iiot_platform user=postgres password=postgres options=-csearch_path=hc900`.
- 키 매핑: Host→host, Port→port, Database→dbname, Username→user, Password→password,
`Search Path=hc900` → `options=-csearch_path=hc900`.
### 3) 고아 정리 (apply 후 자동 호출 또는 parse 내부)
스크립트는 upsert만 하므로, 재생성된 register-map JSON에 **없는** 해당 컨트롤러 map_master
태그를 비활성/삭제해야 한다(롤아웃 문서와 동일):
```sql
DELETE FROM hc900.hc900_map_master m
WHERE m.controller_id = @ctrl
AND m.tagname NOT IN (<register-map JSON 태그 목록>);
```
(JSON 태그를 C#에서 읽어 파라미터화, 또는 임시 staging 테이블 사용.)
## 프론트엔드 (`wwwroot/panes/pb.html` 상단 카드 + `wwwroot/js/pb.js`)
### pb.html — 요약 카드 위에 "Sinam 파싱" 카드 추가
```html
<div class="pb-right-card"> <!-- 또는 상단 풀폭 카드 -->
<h3>Sinam xlsx 파싱</h3>
<input type="file" id="pb-sinam-file" accept=".xlsx">
<select id="pb-sinam-ctrl"><option>C1</option>...<option>C4</option></select>
<label><input type="checkbox" id="pb-sinam-apply"> DB 적용(미체크=미리보기)</label>
<button class="btn-a" onclick="pbSinamParse()">파싱 시작</button>
<button class="btn-c" onclick="pbSinamGwRestart()">게이트웨이 재시작</button>
<div id="pb-sinam-result"></div>
</div>
```
### pb.js — 함수 추가
```js
async function pbSinamUpload(file){ /* FormData POST /sinam/upload → {file} */ }
async function pbSinamParse(){
// 1) 파일 업로드 → path
// 2) POST /sinam/parse {file, controller, applyDb}
// 3) 결과(registers/absorbed/loops) 표시
// 4) applyDb면 pbRefresh()+pbLoadSummary() 로 목록 갱신
}
function pbSinamGwRestart(){ /* 해당 컨트롤러 게이트웨이 재시작 API 호출 */ }
```
- 파싱 후 기존 **"전체 태그 목록"이 자동 갱신**되면 add/삭제·활성/비활성은 기존 기능 그대로 사용.
## 안전장치 (파싱 = 파괴적 재생성)
1. **dry-run → 확인 → 적용** 2단계. `applyDb=false`로 먼저 미리보기, 사용자 확인 후 적용.
2. **적용 시 confirm()** ("해당 컨트롤러 태그셋을 xlsx 기준으로 재생성합니다").
3. **고아 정리** 자동 수행(위 SQL).
4. **게이트웨이 재시작**: register-map JSON 변경 → 해당 컨트롤러 게이트웨이 재기동해야 새 태그
폴링. (`ControllerProcessManager` 재시작 엔드포인트 활용/추가.)
5. **동시성/장시간**: 파싱 수 초 소요 → 버튼 비활성화 + 진행표시. 대용량 업로드 타임아웃 주의.
## 선행 조건 (순서 중요)
지금 스크립트엔 **`.PV` 일관화 + 지시계 흡수**가 들어있다(`docs/작업지시-PV일관성-롤아웃.md`).
UI 파싱이 이를 그대로 적용하면 기존 bare명(realtime/history)·디지털 검출과 불일치가 생긴다.
→ **PV 롤아웃(다운스트림 디지털 검출 코드 수정 + 데이터 마이그레이션)을 먼저 완료**한 뒤
UI 파싱 표준화를 적용할 것. 그 전엔 UI 파싱을 dry-run 전용으로 두는 것도 방법.
## 참고
- 스크립트 CLI: `--controller`(필수) `--sinam`(필수) `-o`(출력) `--db-conn`(있으면 DB 적용)
`--validate-csv`(선택).
- 관련 메모리: `sinam-xlsx-multisection-parsing`, `tag-naming-and-map-master`.
- 관련 문서: `작업지시-PV일관성-롤아웃.md`, `작업지시-history-디지털잔재정리.md`.

View File

@@ -0,0 +1,95 @@
# 작업지시: hc900.history_table 디지털 상태점 잔재 정리
> 다른 LLM/작업자가 이 문서만으로 실행할 수 있도록 작성. 2026-06-09 기준.
## 목적
`hc900.history_table`에는 **디지털 상태점**(예: `AG-3202`, `C3601_RUN`, `CH-5601`
`{0 | L-STOP | }` 형태, state 라벨 보유)의 과거 이력 약 **15만 행**이 잘못 쌓여 있다.
이들은 상태변경 이벤트라 `event_history_table`로 가야 하며 `history_table`(연속
측정값 60초 스냅샷)에는 있으면 안 된다.
이미 처리된 것(배경):
- register-map 재작성으로 디지털 상태점(StatusPoint PV)은 map_master에서
`archive_enabled = FALSE`로 설정됨.
- 실행 중 `SnapshotToHistoryAsync``archive_enabled` + 디지털 제외(state 라벨)로
**신규 디지털 행을 더는 적재하지 않음**(검증됨: 재시작 후 신규 유입 0).
- 따라서 **남은 과거 잔재만 삭제**하면 된다. (비가역 → 반드시 백업 먼저)
## 판정 기준
`history_table`에서 **archive_enabled가 아닌** (controller_id, tagname) 행을 삭제한다.
이는 디지털 상태점 + 루프 내부 파라미터 등 비-archive 태그를 모두 정리한다.
유지: archive_enabled=TRUE (루프 PV/SP/OP/MD/QV + 아날로그 신호태그).
> 주의: 행수는 시점에 따라 변하므로 **실행 시점에 live로 재계산**할 것. 아래는 절차.
## 실행 절차 (복붙 가능)
### 1) 규모 확인
```bash
docker exec iiot-timescaledb psql -U postgres -d iiot_platform -At -c "
WITH arch AS (SELECT controller_id, tagname FROM hc900.hc900_map_master WHERE archive_enabled)
SELECT 'keep_rows', count(*) FROM hc900.history_table h
WHERE EXISTS (SELECT 1 FROM arch a WHERE a.tagname=h.tagname AND (a.controller_id=h.controller_id OR h.controller_id IS NULL))
UNION ALL
SELECT 'delete_rows', count(*) FROM hc900.history_table h
WHERE NOT EXISTS (SELECT 1 FROM arch a WHERE a.tagname=h.tagname AND (a.controller_id=h.controller_id OR h.controller_id IS NULL));
"
```
### 2) 백업 (gzip CSV) — 삭제 대상 행
```bash
BK=/home/windpacer/db_backups; mkdir -p "$BK"
TS=$(date +%Y%m%d)
docker exec iiot-timescaledb psql -U postgres -d iiot_platform -c "
COPY (
SELECT h.* FROM hc900.history_table h
WHERE NOT EXISTS (
SELECT 1 FROM hc900.hc900_map_master a
WHERE a.archive_enabled AND a.tagname=h.tagname
AND (a.controller_id=h.controller_id OR h.controller_id IS NULL))
) TO STDOUT WITH CSV HEADER" | gzip > "$BK/history_nonarchive_digital_$TS.csv.gz"
echo "backup rows: $(( $(zcat "$BK/history_nonarchive_digital_$TS.csv.gz" | wc -l) - 1 ))"
ls -lh "$BK/history_nonarchive_digital_$TS.csv.gz"
```
→ 백업 행수가 1단계 `delete_rows`와 **정확히 일치**하는지 확인. 불일치면 중단.
### 3) 삭제 (트랜잭션)
```bash
docker exec iiot-timescaledb psql -U postgres -d iiot_platform -v ON_ERROR_STOP=1 -c "
BEGIN;
DELETE FROM hc900.history_table h
WHERE NOT EXISTS (
SELECT 1 FROM hc900.hc900_map_master a
WHERE a.archive_enabled AND a.tagname=h.tagname
AND (a.controller_id=h.controller_id OR h.controller_id IS NULL));
COMMIT;
"
```
### 4) 검증
```bash
docker exec iiot-timescaledb psql -U postgres -d iiot_platform -At -c "
WITH dig AS (SELECT DISTINCT base_tag FROM hc900.tag_metadata WHERE attribute LIKE 'state%' AND value<>'')
SELECT 'history_rows', count(*)::text FROM hc900.history_table
UNION ALL SELECT 'history_distinct_tags', count(DISTINCT tagname)::text FROM hc900.history_table
UNION ALL SELECT 'digital_tags_left (0이어야)', count(*)::text FROM (
SELECT DISTINCT tagname FROM hc900.history_table WHERE tagname IN (SELECT base_tag FROM dig)) x;
"
```
`digital_tags_left = 0`, 남은 태그는 archive_enabled 집합과 일치해야 한다.
## 복구 (필요 시)
```bash
zcat /home/windpacer/db_backups/history_nonarchive_digital_<TS>.csv.gz | \
docker exec -i iiot-timescaledb psql -U postgres -d iiot_platform \
-c "COPY hc900.history_table FROM STDIN WITH CSV HEADER"
```
## 참고
- DB: PostgreSQL/TimescaleDB 컨테이너 `iiot-timescaledb`, DB `iiot_platform`, 스키마 `hc900`.
- `history_table`은 일반 테이블(하이퍼테이블 아님). 대량 삭제 후 디스크 회수는
autovacuum이 점진 처리(즉시 필요 시 `VACUUM` — 라이브 테이블이라 `VACUUM FULL` 락 주의).
- 관련: 디지털 검출 기준은 `tag_metadata`의 state 라벨 보유(과거 `i=7594` 폐기).
값 N → state{N} 라벨, realtime는 `{N | label | }` 포맷(`Hc900RealtimeService.FormatValue`).

View File

@@ -0,0 +1,338 @@
# 작업플랜 — 온도 프로파일 과거 이력 구현 (2026-06-07)
## 진단 결과 (2026-06-07 checklist 기반)
| # | 항목 | 심각도 | 결론 |
|---|------|--------|------|
| 1 | `IExperionDbService` DI 등록 — 계획서 "변경 없음" 주석 오류 | LOW | 생성자에 `IExperionDbService db` 추가 필요 |
| 2 | `QueryHistoryWithIntervalAsync` 시그니처 — 계획서 서술과 DTO 불일치 | MED | `HistoryIntervalQueryRequest` DTO 객체 생성 필요. 서술만 명확히 하면 됨 |
| 3 | `stRenderTemp()` 시그니처 변경 — 기존 호환성 | LOW | JS optional 인자 처리로 기존 동작 유지 |
| 4 | `stTempLoad()` 버튼 — 초기 히스토리 미로드 | LOW | "조회" 버튼 클릭 시 `stTempHistoryLoad()`도 함께 호출 권장 |
| 5 | ECharts 64 series 5초 재렌더링 — GPU 메모리 누수 가능성 | LOW | history series는 한 번만 추가, 5초 폴링에서는 data만 업데이트하는 분리 필요 |
| 6 | 타임존 직렬화 — KST/UTC | LOW | `toISOString()` 자동 보정으로 실제 문제 없음 |
| 7 | 헬퍼 메서드 추출 — 계획서 서술 누락 | LOW | `MatchProduct()`, `ComputeStages()` 시그니처 명시 추가 필요 |
| 8 | `time_bucket` 비어있는 bucket — null 보간 필요 | LOW | null 값 전후 값으로 보간하거나 차트에서 연결 |
**HIGH 항목 없음**. 계획서 전체적으로 구현 가능성 높음. 주요 수정사항 3건: #1(DI 생성자 변경), #4(조회 버튼 시 초기 히스토리 로드), #5(ECharts series 관리 분리).
---
## STEP 1 — 맥락 파악
**현재 동작**: `steam.html:62-73` "온도 프로파일" 탭 → `steam.js:90-151` 5초 주기 `GET /api/steam/tempprofile/{col}``SteamAdvisorController.cs:180-247``realtime_table`에서 현재값 5종(reb_temp/T_B/T_C/T_D/vacuum)만 조회 → ECharts에 "현재" 단일 라인으로 표시. 기준밴드(±2σ)는 `gen_temp_profiles.py`가 산출한 `{col}_tempref.json` 정적 파일.
**한계**: 과거 시점의 프로파일을 볼 수 없음. 운전자는 "30분 전 프로파일 vs 지금" 비교 불가. 이격(drift)이 언제 시작되었는지 추적 불가.
**사용 가능한 이력 인프라**: `history_table`(TimeScaleDB hypertable, 60초 스냅샷), `POST /api/history/query-interval` (time_bucket 집계), `IExperionDbService.QueryHistoryWithIntervalAsync`
---
## STEP 2 — 호출 계층 지도 (변경 대상)
```
steam.html (st-temp pane)
└─ steam.js stTempTick() ← 5s setInterval, GET /api/steam/tempprofile/{col}
└─ stRenderTemp(d) ← ECharts option.set ({기준밴드, 기준, 현재} 3 series)
SteamAdvisorController:
GET /api/steam/tempprofile/{col}
├─ read tempref.json ← 정적 기준밴드
├─ TagsFor(suffix) → 5개 태그명
├─ realtime_table 조회 (현재값)
└─ 제품매칭 + z-score → 단일 snapshot 반환
IExperionDbService (기존):
POST /api/history/query ← raw 조회
POST /api/history/query-interval ← time_bucket 집계
```
---
## STEP 3 — 요구사항
| # | 요구사항 | 우선순위 |
|---|---------|---------|
| R1 | 과거 특정 시점의 온도 프로파일을 현재와 중첩 표시 | P0 |
| R2 | 시간 범위 선택 UI (30분/1시간/4시간/오늘/사용자지정) | P0 |
| R3 | 단계별 온도 추세를 미니 차트로 함께 표시 | P1 |
| R4 | z-score 추세 (어느 단계에서 이격이 시작되었는가) | P2 |
| R5 | 애니메이션 재생 (시간 경과에 따른 프로파일 변화) | P3 |
---
## STEP 4 — 구현 구성
### 4-1. 백엔드: 신규 엔드포인트 `GET /api/steam/tempprofile/{col}/history`
**파일**: `SteamAdvisorController.cs` (lines 180-247 인접)
**시그니처**:
```csharp
[HttpGet("tempprofile/{col}/history")]
public async Task<IActionResult> TempProfileHistory(
string col,
[FromQuery] DateTime? from = null,
[FromQuery] DateTime? to = null,
[FromQuery] int limit = 100)
```
**동작**:
1. `from`/`to` 기본값: from=now-1h, to=now
2. `TagsFor(ToSuffix(col))`로 5개 태그명 획득
3. `IExperionDbService.QueryHistoryWithIntervalAsync()` 호출 — interval="1 minute", tags=5개 태그
4. 각 time_bucket 행마다 현재 TempProfile과 동일한 제품매칭+z-score 로직 적용
5. profile 스냅샷 배열 반환
**응답 구조**:
```json
{
"column": "C-6111",
"from": "...",
"to": "...",
"interval": "1 minute",
"n": 60,
"time_snapshots": [
{
"ts": "2026-06-07T10:00:00Z",
"matchedProduct": "P0",
"stages": [
{"stage": "reb_temp", "value": 128.5, "z": 0.3, "deviated": false},
...
],
"vacuum": {"value": 120, "z": -0.5, "deviated": false},
"spanAD": 45.2
}
]
}
```
**변경 최소화**: 제품매칭 로직은 기존 TempProfile과 공유. 헬퍼 메서드(`MatchProduct`, `ComputeZ`, `ComputeStage`)로 추출.
---
### 4-2. 프론트엔드: 시간 범위 선택 UI
**파일**: `steam.html` (st-temp pane, lines 62-73)
**변경**:
- "조회" 버튼 우측에 시간 범위 버튼 그룹 추가: `[30분] [1시간] [4시간] [오늘]`
- "과거 프로파일" legend 항목 추가 (캡션용)
```html
<div class="st-bt-bar">
컬럼: <select id="st-temp-col">...</select>
<button class="btn-a" id="st-temp-load">조회</button>
<span class="st-temp-range-label">과거:</span>
<button class="st-temp-range active" data-range="30m">30분</button>
<button class="st-temp-range" data-range="1h">1시간</button>
<button class="st-temp-range" data-range="4h">4시간</button>
<button class="st-temp-range" data-range="today">오늘</button>
<span id="st-temp-status" style="margin-left:8px;font-size:11px;color:#888"></span>
</div>
```
---
### 4-3. 프론트엔드: `stRenderTemp()` 개선 + 과거 히스토리 렌더러
**파일**: `steam.js` (lines 77-151)
**변경**:
#### 4-3a. `stTempTick()` — 5초 폴링 유지, 현재 스냅샷만 갱신
기존 5초 폴링 유지. 단, 히스토리 데이터는 분리 관리.
```javascript
let stTempHistory = null; // { time_snapshots: [...] }
let stTempHistoryRange = '30m'; // 현재 선택된 범위
async function stTempTick() {
const col = document.getElementById('st-temp-col').value;
const st = document.getElementById('st-temp-status');
try {
const d = await api('GET', `/api/steam/tempprofile/${col}`);
stRenderTemp(d, stTempHistory);
st.textContent = '갱신: ' + new Date().toLocaleTimeString();
} catch (e) {
st.textContent = '오류: ' + e.message;
}
}
```
#### 4-3b. `stTempHistoryLoad(col, range)` — 히스토리 로드 신규
범위 변경/초기 로드시만 호출. 응답 캐싱.
```javascript
async function stTempHistoryLoad() {
const col = document.getElementById('st-temp-col').value;
const range = stTempHistoryRange;
const now = new Date();
let from;
if (range === '30m') from = new Date(now - 30*60000);
else if (range === '1h') from = new Date(now - 3600000);
else if (range === '4h') from = new Date(now - 4*3600000);
else if (range === 'today') {
from = new Date(now); from.setHours(0,0,0,0);
}
try {
const h = await api('GET',
`/api/steam/tempprofile/${col}/history?from=${from.toISOString()}&to=${now.toISOString()}&limit=500`);
stTempHistory = h;
// 현재 스냅샷과 함께 재렌더링
const d = await api('GET', `/api/steam/tempprofile/${col}`);
stRenderTemp(d, stTempHistory);
} catch (e) {
console.warn('[steam] history load fail:', e);
}
}
```
#### 4-3c. `stRenderTemp(d, history?)` — ECharts series 확장
기존 3 series(기준밴드/기준/현재) 유지 + history.time_snapshots를 추가 series로:
- **방식 A (단순)**: N개 스냅샷의 각 stage 평균을 구해 "과거 평균" 하나의 라인 + 반투명 밴드
- **방식 B (상세)**: N개 스냅샷을 개별 반투명 라인으로 표시 (hover 시 툴팁)
- **방식 C (추천)**: "과거 평균" 라인 + "최소/최대 밴드" + "현재" 강조 라인
```javascript
function stRenderTemp(d, history) {
const cats = d.stages.map(s => ST_STAGE_LABEL[s.stage] || s.stage);
const lo = d.stages.map(s => ...); // 기준 -2σ
const band = d.stages.map(s => ...); // 4σ
const med = d.stages.map(s => ...); // 기준 median
const cur = d.stages.map(s => ...); // 현재값 (굵은 파랑)
const series = [
{ name: '_lo', type: 'line', data: lo, ... },
{ name: '기준밴드', type: 'line', data: band, ... },
{ name: '기준', type: 'line', data: med, ... },
{ name: '현재', type: 'line', data: cur, ... },
];
// 과거 히스토리 overlay
if (history && history.time_snapshots?.length > 1) {
const snapshots = history.time_snapshots;
// 과거 스냅샷별 라인 (최대 60개 제한, 투명도 0.1)
const maxHistory = Math.min(snapshots.length, 60);
for (let i = 0; i < maxHistory; i++) {
const s = snapshots[i];
const snapData = cats.map((_, j) => s.stages[j]?.value ?? null);
series.push({
name: s.ts, type: 'line', data: snapData,
lineStyle: { color: '#446', width: 1, opacity: 0.15 },
symbol: 'none', silent: true,
});
}
// 과거 평균 라인 (선택)
const avgData = cats.map((_, j) => {
const vals = snapshots.map(s => s.stages[j]?.value).filter(v => v != null);
return vals.length ? vals.reduce((a,b) => a+b, 0) / vals.length : null;
});
series.push({
name: '과거평균', type: 'line', data: avgData,
lineStyle: { color: '#888', width: 1, type: 'dashed' }, symbol: 'none',
});
}
chart.setOption({ ...series, ... });
}
```
#### 4-3d. (P1) 단계별 추세 미니차트 `stRenderTempTrends(history)`
별도 ECharts 인스턴스 4~5개. x축=시간, y축=온도, 수평 기준밴드±2σ, 현재값 점 강조.
---
### 4-4. DI 등록
**파일**: `Program.cs`
`SteamAdvisorController``IExperionDbService` 주입 필요 (현재는 `Hc900DbContext`만 주입).
```csharp
// 변경 없음 — IExperionDbService는 이미 Program.cs에 등록되어 있음
```
---
## STEP 5 — 주의사항 (교차 검증)
| Q | 질문 | 확인 |
|---|------|------|
| Q1 | 기존 Tempprofile 5초 폴링 유지? | 유지. 히스토리 로드는 최초/범위변경시만 별도 호출 — 트래픽 증가 없음 |
| Q2 | 제품매칭 로직 중복? | 헬퍼로 추출하여 `TempProfile``TempProfileHistory`가 공유 |
| Q3 | history_table 데이터 볼륨? | 60초×5태그×1h=300행, 4h=1200행 — 부하 무시 가능. interval은 1분 고정 |
| Q4 | OOD 기간 처리? | OOD 기간의 profile은 z-score가 커도 "deviated=true"로만 표시, 제외하지 않음 |
| Q5 | 컬럼명칭 통일 선행 필요? | `작업플랜-스팀컬럼명칭통일.md` 완료 후 이 작업 시작. `TagsFor("6111")` 정상 동작 전제 |
| Q6 | ECharts series 과다? | 60개 history 라인 + 4개 기준 series = 64 series. ECharts는 수백 series까지 무리 없음. 단, tooltip trigger 최적화 필요(cursor로 변경) |
---
## STEP 6 — 작업 순서
```
1. 컬럼명칭 통일 완료 (선행)
└─ 작업플랜-스팀컬럼명칭통일.md
2. SteamAdvisorController — 헬퍼 추출
└─ MatchProduct(), ComputeStages()를 TempProfile에서 분리
└─ IExperionDbService 생성자 주입
3. SteamAdvisorController — GET /api/steam/tempprofile/{col}/history 신규
└─ QueryHistoryWithIntervalAsync → profile snapshot 배열
└─ 제품매칭+z-score 루프
4. steam.js — 시간범위 선택 UI + stTempHistoryLoad()
└─ 범위 버튼, 이벤트 핸들러, API 호출, 캐싱
5. steam.js — stRenderTemp() 확장 (과거 overlay)
└─ history.time_snapshots → 반투명/ECharts series
6. steam.html — 범위 선택 마크업 추가
└─ st-temp-range 버튼 그룹, CSS 스타일
7. (P1) steam.js — 단계별 추세 미니차트 (stRenderTempTrends)
8. 검증
└─ dotnet build 성공
└─ /api/steam/tempprofile/C-6111/history?from=...&to=... 정상 응답
└─ UI 30분 클릭 → 과거 라인 overlay 확인
└─ 5초 폴링 유지 → 현재 라인만 갱신
```
---
## STEP 7 — 검증 시나리오
| # | 시나리오 | 기대 결과 |
|---|---------|----------|
| 1 | `GET /api/steam/tempprofile/C-6111/history?from=2026-06-07T09:00:00Z&to=2026-06-07T10:00:00Z` | 60개 스냅샷 + 각 스냅샷의 stages/matchedProduct 반환 |
| 2 | "30분" 버튼 클릭 | 30개 스냅샷 로드 → 차트에 반투명 과거 라인들 |
| 3 | 현재 5초 폴링 유지 확인 | "현재" 라인만 갱신, 히스토리 라인 변화 없음 |
| 4 | 범위 변경 (30분→4시간) | 히스토리 재조회 → overlay 업데이트 |
| 5 | missing_tags 상태에서 히스토리 | 빈 배열 반환, 차트는 기존 3 series만 표시 |
---
## STEP 8 — 파일 변경 요약
| 파일 | 변경 내용 |
|------|---------|
| `SteamAdvisorController.cs` | +DI IExperionDbService, +헬퍼 추출, +`TempProfileHistory` endpoint |
| `steam.html` | +st-temp-range 버튼 그룹, CSS |
| `steam.js` | +`stTempHistory`, +`stTempHistoryRange`, +`stTempHistoryLoad()`, `stRenderTemp()` 확장, (P1)`stRenderTempTrends()` |
---
## STEP 9 — 리스크
| 항목 | 수준 | 대응 |
|------|------|------|
| history_table에 60초 간격보다 더 상세한 데이터 없음 | 낮 | 1분 간격이면 충분. 추세/이격 감지에 무리 없음 |
| ECharts series 60+개 렌더링 성능 | 낮 | canvas 기반, opacity 0.15로 가벼움. 필요시 `sampling: 'lttb'` |
| 제품매칭이 history snapshot마다 달라짐 | 중 | 동일 snapshot 내에선 일관됨. UI에 matchedProduct 변화 표시 |
| TagsFor 태그가 register-map에 없는 컬럼 (9·10차) | 중 | 컬럼명칭 통일 후에도 해당 태그가 없으면 `missing_tags` — 사전 등록 필요 |

View File

@@ -0,0 +1,479 @@
# 온도 프로파일 기준 프로파일 선택/생성 기능 — 상세 설계
## Problem
현재 기준 프로파일(`{col}_tempref.json`)은 **2026-02-05~2026-06-05 고정**.
제품 구성·원료·계절 변화에 기준이 부정확해져 이격 감지 신뢰도 하락.
운전자가 특정 기간 기준을 여러 개 만들어 두고 전환 가능해야 함.
---
## Solution: DB 저장 (Phase B, 권장)
### Architecture
```
[Python: gen/profiles] ──HTTP──▶ [POST /api/steam/tempprofile/{col}/profiles]
[temp_ref_profiles table]
[Browser] ──GET /api/steam/tempprofile/{col}?profile_id=N──▶ [LoadTempRef(id)] ──▶ [ComputeStages]
```
파일 I/O 없음, 여러 서버에서 동기화 문제 없음, 관리 API로 확장 용이.
---
### 1. DB 테이블
```sql
CREATE TABLE hc900.temp_ref_profiles (
id SERIAL PRIMARY KEY,
column_key TEXT NOT NULL, -- "C-6111"
label TEXT NOT NULL, -- "기본", "recent-30d", "2026-05-w1"
description TEXT NOT NULL DEFAULT '', -- "최근 30일(2026-05-08~2026-06-07)"
period_from TIMESTAMPTZ NOT NULL,
period_to TIMESTAMPTZ NOT NULL,
data JSONB NOT NULL, -- Tempref 전체 {stages_order, n_products, products: [...]}
is_default BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 컬럼별 조회 + 기본값 정렬
CREATE INDEX idx_trp_column ON hc900.temp_ref_profiles(column_key);
CREATE UNIQUE INDEX idx_trp_column_default ON hc900.temp_ref_profiles(column_key) WHERE is_default = TRUE;
```
**data 컬럼 JSONB 구조** (기존 `{col}_tempref.json`과 동일):
```json
{
"stages_order": ["reb_temp", "T_B", "T_C", "T_D"],
"n_products": 3,
"products": [
{
"label": "P0",
"n_rows": 1240,
"span_AD": 42.5,
"vacuum": { "median": 48.2, "std": 1.5 },
"stages": {
"reb_temp": { "median": 176.2, "std": 2.1 },
"T_B": { "median": 112.5, "std": 3.2 },
"T_C": { "median": 88.7, "std": 4.5 },
"T_D": { "median": 66.2, "std": 2.8 }
}
}
]
}
```
`Hc900DbContext``DbSet<TempRefProfileEntity>` 추가.
| 항목 | 값 |
|------|-----|
| Entity 클래스 | `TempRefProfileEntity` (내부 클래스 또는 별도 파일) |
| 테이블명 | `temp_ref_profiles` |
| 스키마 | `hc900` |
| EF Core | `modelBuilder.Entity<TempRefProfileEntity>(e => { e.ToTable("temp_ref_profiles"); ... })` |
---
### 2. Entity 클래스
```csharp
// Infrastructure/Database/ 경로
public sealed class TempRefProfileEntity
{
public int Id { get; set; }
public string ColumnKey { get; set; } = "";
public string Label { get; set; } = "";
public string Description { get; set; } = "";
public DateTime PeriodFrom { get; set; }
public DateTime PeriodTo { get; set; }
public string Data { get; set; } = ""; // JSON serialized TempRef
public bool IsDefault { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
```
---
### 3. EF Core 매핑 (`Hc900DbContext`)
```csharp
public DbSet<TempRefProfileEntity> TempRefProfiles => Set<TempRefProfileEntity>();
// OnModelCreating:
modelBuilder.Entity<TempRefProfileEntity>(e =>
{
e.ToTable("temp_ref_profiles");
e.HasKey(x => x.Id);
e.Property(x => x.ColumnKey).HasColumnName("column_key").IsRequired();
e.Property(x => x.Label).IsRequired();
e.Property(x => x.Description);
e.Property(x => x.PeriodFrom).HasColumnName("period_from");
e.Property(x => x.PeriodTo).HasColumnName("period_to");
e.Property(x => x.Data).HasColumnType("jsonb");
e.Property(x => x.IsDefault).HasColumnName("is_default");
e.Property(x => x.CreatedAt).HasColumnName("created_at");
e.Property(x => x.UpdatedAt).HasColumnName("updated_at");
e.HasIndex(x => x.ColumnKey);
});
```
`InitializeAsync()``CREATE TABLE IF NOT EXISTS temp_ref_profiles (...)` 추가.
---
### 4. 백엔드 API — 상세
#### 4.1 프로파일 목록 조회
```
GET /api/steam/tempprofile/{col}/profiles
```
Response:
```json
{
"success": true,
"column": "C-6111",
"profiles": [
{ "id": 1, "label": "기본", "description": "2026-02-05~2026-06-05", "isDefault": true, "nProducts": 3, "createdAt": "..." },
{ "id": 2, "label": "recent-30d","description": "최근 30일(2026-05-08~2026-06-07)", "isDefault": false, "nProducts": 2, "createdAt": "..." },
{ "id": 3, "label": "winter", "description": "2025-12-01~2026-02-28", "isDefault": false, "nProducts": 3, "createdAt": "..." }
]
}
```
- `column_key`로 필터링
- `is_default` 우선, 그 다음 `created_at DESC` 정렬
- 기존 파일 기반 `{col}_tempref.json`이 DB에 없으면 최초 조회 시 자동 import
#### 4.2 프로파일 생성
```
POST /api/steam/tempprofile/{col}/profiles
Content-Type: application/json
{
"label": "recent-30d",
"description": "최근 30일",
"from": "2026-05-08T00:00:00+09:00",
"to": "2026-06-07T23:59:59+09:00",
"setDefault": false
}
```
백엔드 동작:
1. 요청받은 `column_key` + `from`~`to` 기간 검증
2. `TagsFor(ToSuffix(col))`로 태그 목록 획득
3. `history_table`에서 해당 기간 데이터 조회
```sql
SELECT recorded_at, tagname, value
FROM hc900.history_table
WHERE tagname = ANY(...)
AND recorded_at BETWEEN @from AND @to
ORDER BY recorded_at
```
4. 조회된 데이터를 `gen_temp_profiles.py`와 동일한 로직으로 처리:
- 각 스냅샷 시간별로 (reb_temp, T_B, T_C, T_D, vacuum) 한 행으로 피벗
- `mode == "PROD"` 필터 (realtime_table에서 해당 기간 mode 태그 조회)
- feed > 50 필터
- NaN/null 제거
- KMeans 클러스터링 (k=3→2→1)
- 각 클러스터별 median/std 계산
5. `TempRef` 객체 구성 → `JsonSerializer.Serialize` → `data` JSONB 컬럼에 저장
6. `setDefault=true`면 기존 기본값 해제 후 이 프로파일을 기본으로 설정
Response:
```json
{
"success": true,
"profileId": 4,
"label": "recent-30d",
"nProducts": 2,
"period": "2026-05-08~2026-06-07",
"message": "기준 프로파일 생성 완료"
}
```
#### 4.3 프로파일 기본값 설정
```
PUT /api/steam/tempprofile/{col}/profiles/{id}/default
```
- 해당 `column_key`의 다른 모든 프로파일 `is_default = false`
- 지정한 `id`의 프로파일 `is_default = true`
#### 4.4 프로파일 삭제
```
DELETE /api/steam/tempprofile/{col}/profiles/{id}
```
- 기본 프로파일(`is_default=true`)은 삭제 불가 (먼저 다른 프로파일을 기본으로 설정해야 함)
#### 4.5 프로파일 미리보기 (생성 전 검증)
```
POST /api/steam/tempprofile/{col}/profiles/preview
body: { "from": "...", "to": "..." }
→ { "success": true, "nRows": 3420, "nSnapshots": 57, "estimatedProducts": 3,
"stagesOrder": ["reb_temp","T_B","T_C","T_D"] }
```
---
### 5. 기존 `TempProfile` / `TempProfileHistory` 수정
#### `LoadTempRef` 변경
```csharp
// Before: 파일 기반
private async Task<TempRef?> LoadTempRef(string col)
// After: DB 기반
private async Task<TempRef?> LoadTempRef(string col, int? profileId = null)
{
TempRefProfileEntity? entity;
if (profileId.HasValue)
{
entity = await _ctx.TempRefProfiles
.FirstOrDefaultAsync(p => p.ColumnKey == col && p.Id == profileId.Value);
}
else
{
entity = await _ctx.TempRefProfiles
.Where(p => p.ColumnKey == col && p.IsDefault)
.OrderByDescending(p => p.CreatedAt)
.FirstOrDefaultAsync();
}
if (entity == null) return null;
return JsonSerializer.Deserialize<TempRef>(
entity.Data,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
```
#### `TempProfile` 엔드포인트
```
GET /api/steam/tempprofile/{col}?profile_id=2
```
- `profile_id` 생략 시 기본 프로파일 사용
- 응답에 현재 사용 중인 프로파일 정보 추가:
```json
{
"column": "C-6111",
"profile": { "id": 2, "label": "recent-30d", "description": "..." },
"period": "2026-05-08~2026-06-07",
"matchedProduct": "P1",
...
}
```
#### `TempProfileHistory` 엔드포인트
```
GET /api/steam/tempprofile/{col}/history?from=...&to=...&profile_id=2
```
- 동일한 `profile_id` 파라미터 지원
- 히스토리 각 스냅샷도 동일한 기준 프로파일로 z-score 계산
---
### 6. Python 스크립트 — DB 직접 저장
#### `gen_temp_profiles.py` 확장
```
usage: gen_temp_profiles.py --loop-csv CSV --signal-csv CSV --variable-csv CSV
[--from DATE] [--to DATE] [--days N]
[--label LABEL] [--description DESC]
[--db-conn "Host=...;Database=...;Username=...;Password=..."]
[--o FILE] # 파일 출력 (기존 동작)
[--api-url http://...] # API 호출로 저장
```
- `--db-conn`: 직접 DB 연결하여 `temp_ref_profiles`에 INSERT
- `--api-url`: API 호출하여 저장 (권장, 서버 로직 재사용)
- `--label` 필수 (DB 저장 시)
- `--description`: 사람이 읽을 수 있는 설명
예시:
```bash
# API로 생성
python3 gen_temp_profiles.py --loop-csv ... \
--from 2026-05-01 --to 2026-06-07 \
--label "recent-30d" --description "최근 30일 기준" \
--api-url "http://localhost:5000/api/steam/tempprofile/C-6111/profiles"
# DB 직접 입력
python3 gen_temp_profiles.py --loop-csv ... \
--days 30 --label "rolling-30d" \
--db-conn "Host=localhost;Database=iiot_platform;Username=postgres"
```
#### `load_state_labels.py`와 유사한 전용 import 스크립트
별도 스크립트 `scripts/analysis/import_tempref_to_db.py`:
- 기존 `scripts/analysis/*_tempref.json` 파일을 DB에 일괄 등록
- `--col C-6111 --profile-id 1` 옵션으로 특정 프로파일만 업데이트 가능
- `--set-default` 옵션으로 기본값 지정
---
### 7. 프론트엔드 — 상세 UI/UX
#### 7.1 기준 프로파일 선택기
```
컬럼: [C-6111 ▼] 기준: [recent-30d ▼] [+ 새 기준] [조회]
├── 기본 (2026-02-05~2026-06-05)
├── recent-30d (최근 30일) ← 현재 선택
└── winter (2025-12~2026-02)
```
- `<select id="st-temp-profile">`: 프로파일 목록
- 첫 로딩 시 `GET /profiles`로 목록 조회하여 dropdown 채움
- 선택 변경 시 → 자동으로 `stTempLoad()` 재실행 (프로파일 파라미터 포함)
- 차트 제목에 현재 프로파일 표시: `"C-6111 · 기준: recent-30d"`
#### 7.2 새 기준 생성 (모달)
`[+ 새 기준]` 버튼 클릭 → 모달 표시:
```
┌─ 새 기준 프로파일 생성 ──────────────────────┐
│ │
│ 레이블: [recent-30d ] │
│ 설명: [최근 30일 기준 ] │
│ │
│ ○ 최근 N일: [30]일 (현재 ~ N일 전) │
│ ● 기간 지정: │
│ 시작: [2026-05-08] 종료: [2026-06-07] │
│ │
│ [□ 이 프로파일을 기본으로 설정] │
│ │
│ [취소] [생성] │
└───────────────────────────────────────────────┘
```
- "생성" 클릭 → `POST /profiles` API 호출
- 성공 시 dropdown 갱신, 새 프로파일 선택됨
- 실패 시 오류 메시지 표시
#### 7.3 기준 프로파일 관리
`[⚙]` 버튼 (또는 컨텍스트 메뉴):
```
┌─ 기준 프로파일 관리 ─────────────────────┐
│ │
│ ◎ 기본 (기본) [기본설정] [삭제] │
│ ○ recent-30d [기본설정] [삭제] │
│ ○ winter [기본설정] [삭제] │
│ │
│ [+ 새 기준 생성] │
└──────────────────────────────────────────┘
```
---
### 8. 마이그레이션: 기존 파일 → DB
#### 최초 1회 자동 import (서버 시작 or 최초 API 호출 시)
`Hc900DbContext.InitializeAsync()` 또는 `SteamAdvisorController` 생성자에서:
```csharp
private async Task EnsureDefaultProfilesAsync()
{
var dir = _config.GetValue<string>("SteamAdvisor:ModelDir")
?? "/home/windpacer/projects/hc900_ax/scripts/analysis";
foreach (var col in SUPPORTED_COLUMNS) // ["C-6111", "C-6211", ...]
{
var hasAny = await _ctx.TempRefProfiles.AnyAsync(p => p.ColumnKey == col);
if (hasAny) continue;
var path = Path.Combine(dir, $"{col}_tempref.json");
if (!System.IO.File.Exists(path)) continue;
var json = await System.IO.File.ReadAllTextAsync(path);
var tref = JsonSerializer.Deserialize<TempRef>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (tref == null) continue;
_ctx.TempRefProfiles.Add(new TempRefProfileEntity
{
ColumnKey = col,
Label = "기본",
Description = tref.Period,
PeriodFrom = ParsePeriodFrom(tref.Period), // "2026-02-05~2026-06-05" → DateTime
PeriodTo = ParsePeriodTo(tref.Period),
Data = json,
IsDefault = true,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
});
await _ctx.SaveChangesAsync();
}
}
```
---
### 9. API 라우트 요약
| Method | Path | 설명 |
|--------|------|------|
| `GET` | `/api/steam/tempprofile/{col}` | 실시간 프로파일 (profile_id query param) |
| `GET` | `/api/steam/tempprofile/{col}/history` | 과거 이력 (profile_id query param) |
| `GET` | `/api/steam/tempprofile/{col}/profiles` | 프로파일 목록 |
| `POST` | `/api/steam/tempprofile/{col}/profiles` | 새 프로파일 생성 |
| `POST` | `/api/steam/tempprofile/{col}/profiles/preview` | 생성 미리보기 |
| `PUT` | `/api/steam/tempprofile/{col}/profiles/{id}/default` | 기본값 설정 |
| `DELETE` | `/api/steam/tempprofile/{col}/profiles/{id}` | 프로파일 삭제 |
---
### 10. 구현 순서 (예상: 2~3일)
| 순서 | 작업 | 파일 | 예상시간 |
|------|------|------|---------|
| 1 | `TempRefProfileEntity` 클래스 작성 | `Infrastructure/Database/` | 0.5h |
| 2 | `Hc900DbContext`에 `DbSet` + `OnModelCreating` 매핑 + DDL | `Hc900DbContext.cs` | 1h |
| 3 | `SteamAdvisorController`에 `EnsureDefaultProfilesAsync` + 기존 파일 import | `SteamAdvisorController.cs` | 1h |
| 4 | `LoadTempRef` DB 버전으로 변경 | `SteamAdvisorController.cs` | 0.5h |
| 5 | `GET profiles` 목록 API | `SteamAdvisorController.cs` | 0.5h |
| 6 | `POST profiles` 생성 API (history_table 조회 → KMeans → JSONB 저장) | `SteamAdvisorController.cs` | 3h |
| 7 | `PUT/DELETE profiles` 관리 API | `SteamAdvisorController.cs` | 0.5h |
| 8 | `TempProfile`/`TempProfileHistory`에 `profile_id` 파라미터 추가 | `SteamAdvisorController.cs` | 0.5h |
| 9 | `gen_temp_profiles.py` DB/API 출력 옵션 | `gen_temp_profiles.py` | 1h |
| 10 | `steam.html` 프로파일 선택 dropdown + 생성 모달 | `steam.html` | 1h |
| 11 | `steam.js` 프로파일 로드/전환/생성 로직 | `steam.js` | 2h |
| 12 | 빌드 + 통합 테스트 | — | 1h |
| | **합계** | | **~12h** |
---
### 11. 에지 케이스
| 상황 | 처리 |
|------|------|
| 프로파일이 하나도 없음 | `EnsureDefaultProfilesAsync`가 파일 시스템에서 자동 import 시도, 실패시 404 |
| 기본 프로파일 삭제 요청 | `400 Bad Request` — 먼저 다른 프로파일을 기본으로 설정해야 함 |
| 중복 레이블 | `column_key + label`에 unique 제약 or 409 Conflict |
| 생성 중 같은 기간 프로파일 존재 | 허용 (같은 기간으로 여러 번 생성 가능, 레이블로 구분) |
| DB 연결 실패 | 파일 기반 `LoadTempRef`로 fallback? or 명확한 503 에러 |
| 프로파일이 너무 많음 (50개+) | 기본 50개 제한, 생성 시 경고 |
| history_table 데이터 부족 (200행 미만) | 생성 API가 400 Bad Request + "데이터 부족" 메시지 (gen_temp_profiles.py의 최소 200행 조건과 동일) |

View File

@@ -0,0 +1,192 @@
# 온도 프로파일 — 사용자 정의 기준밴드 적용
## 배경
현재 `gen_temp_profiles.py`가 4개월치 데이터를 k-means 클러스터링하여 P0/P1/P2를 자동 분류하고,
각 클러스터의 median/std를 `tempref.json`에 저장 → 시스템이 이를 기준밴드로 사용.
**문제**: 클러스터링 결과를 신뢰할 수 없고, reb_temp 노이즈에 따라 제품매칭이 5초~1분 단위로
튀어 밴드가 불안정함.
**해결 방향**: 운전자가 각 제품의 단계별 정상값(target + 허용편차)을 직접 정의하고,
선택한 제품 정의를 시스템이 고정 기준으로 사용함. 자동매칭 제거.
## 핵심 원칙
> **사용자가 선택한 제품 정의는 절대 자동으로 변경되지 않는다.**
> 실제 공정값이 기준에서 벗어나면 `deviated` 플래그만 표시하고, 기준 자체는 유지.
---
## Phase A — 데이터 모델 + 백엔드 API
### A1. DB Entity: `UserTempProfile`
`Infrastructure/Database/` 에 추가.
```csharp
public class UserTempProfile
{
public int Id { get; set; }
public string ColumnName { get; set; } = ""; // "C-6111"
public string Label { get; set; } = ""; // "경질원료"
public string? Description { get; set; }
// Stage definitions: serialized as JSON
// {"reb_temp": {"median":124, "std":2}, "T_B": {"median":111, "std":2}, ...}
public string StageDefsJson { get; set; } = "{}";
// Vacuum
public double VacuumTarget { get; set; }
public double VacuumDev { get; set; }
// Span AD
public double SpanAD { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
```
`StageDefsJson`은 기존 `TempStat(median, std)` 구조와 동일하므로
`JsonSerializer.Deserialize<Dictionary<string, TempStat>>()`로 복원 가능.
`Hc900DbContext``DbSet<UserTempProfile>` 추가 + migration.
### A2. API: 프로파일 CRUD
| Method | Path | 용도 |
|--------|------|------|
| `GET` | `/api/steam/profiles?col=C-6111` | 컬럼별 프로파일 목록 |
| `POST` | `/api/steam/profiles` | 생성/갱신 (Id=0이면 INSERT, >0이면 UPDATE) |
| `DELETE` | `/api/steam/profiles/{id}` | 삭제 |
### A3. API: TempProfile / TempProfileHistory 수정
`GET /api/steam/tempprofile/{col}?profileLabel=경질원료`
- `profileLabel`이 있으면 → DB에서 해당 프로파일 로드 → `ComputeStages`에 전달
- `profileLabel`이 없으면 → 자동매칭 없음, 첫 번째 사용자 프로파일 사용 or 에러
- 자동매칭(기존 `tempref.json` P0/P1/P2)은 **완전 제거**
응답에 auto-generated 참고 데이터도 함께 반환 (비교용):
```json
{
"column": "C-6111",
"userProfile": { "label": "경질원료", "description": "...", "stages": {...} },
"stages": [
{ "stage": "reb_temp", "current": 120.5,
"target": 124, "dev": 2, "z": -1.75, "deviated": false }
],
"autoRef": {
"matchedProduct": "P0",
"stages": [ { "stage": "reb_temp", "refMedian": 84.81, "refStd": 0.5, "z": ... } ]
}
}
```
- `stages[i].target/dev` = 사용자 정의값
- `autoRef` = 기존 tempref.json의 자동매칭 결과 (참고용, 별도 색상으로 표시)
- 사용자 정의가 없으면 `autoRef`만 반환 (하위호환)
`GET /api/steam/tempprofile/{col}/history?profileLabel=경질원료`
- 모든 스냅샷이 동일한 사용자 정의 프로파일 기준으로 z-score 계산
- 각 스냅샷의 `stages`는 사용자 정의 `target/dev` 기준
- `autoRef`도 각 스냅샷별로 계산하여 함께 반환
### A4. ComputeStages 리팩터
```csharp
private static (List<object> stages, object vacuum, double? spanAD) ComputeStages(
Dictionary<string, double?> cur,
TempRef tref, // auto-generated reference (nullable)
UserTempProfile? userProfile) // user-defined profile (nullable)
```
- `userProfile`이 있으면 → 그 값으로 z-score 계산 (median=target, std=dev)
- `tref` → autoRef 용도로만 사용 (비교 표시)
- 제품매칭 로직 제거
---
## Phase B — 프론트엔드: 프로파일 관리 UI
### B1. 프로파일 CRUD 화면
위치: steam.html 내 모달 또는 별도 관리 페이지
- 컬럼 선택 드롭다운 (C-6111, C-6211, ...)
- 제품 목록 테이블 (Label, Description, 각 단계 target/dev, vacuum, spanAD)
- Add / Edit (인라인 or 폼) / Delete 버튼
- 저장 시 POST /api/steam/profiles
입력 필드 (제품 하나당):
| 단계 | Target | 허용편차(±) |
|------|--------|------------|
| reb_temp | [input] | [input] |
| T_B | [input] | [input] |
| T_C | [input] | [input] |
| T_D | [input] | [input] |
| 진공 | [input] | [input] |
| Span AD | [input] | (단일값, std 없음) |
초기값 제안: 기존 tempref.json의 P0/P1/P2 데이터를 불러와서 "기본값으로 채우기" 버튼
### B2. 제품 선택기
steam.html 온도 프로파일 탭 상단에 드롭다운 추가.
- DB에 저장된 사용자 정의 프로파일 목록 표시
- 선택 즉시 `stTempLoad(profileLabel)` 호출 → 전체 차트 리로드
- 선택값은 `localStorage`에 저장 (페이지 새로고침 시 유지)
---
## Phase C — 차트: 이중 밴드 표시
### C1. 두 개의 밴드
| 구분 | 색상 | 스타일 | 의미 |
|------|------|--------|------|
| 사용자 정의 밴드 | 녹계열 (#4caf50 fill) | 굵은 실선 중앙값 + 투명 fill | **기준 — 이게 정상이다** |
| 자동 (tempref.json) | 청회색 (#78909c fill) | 가는 점선 중앙값 + 연한 fill | **참고 — 실제 운전 통계** |
### C2. 실시간 라인
- 두 밴드 위에 실시간 current 값 표시 (파랑 굵은 실선, circle symbol)
### C3. 과거 스냅샷
- 선택 시점 line: amber (#fa3) 굵은 실선
- 현재 실시간 참조: thin dashed blue line
- 두 밴드는 동일하게 유지 (사용자 정의 + auto-ref)
---
## 마이그레이션
1. 기존 `tempref.json``products`를 초기 `UserTempProfile` seed data로 DB에 로드
2. 기존 자동매칭 코드를 deprecated 처리 (autoRef 비교용으로만 유지)
3. UI에 "데이터 초기화" 버튼 — tempref.json에서 다시 불러오기
---
## 작업 순서 (권장)
1. **A1** — Entity + DbContext + migration
2. **A2** — CRUD API endpoints
3. **A3** — TempProfile/TempProfileHistory 수정 + ComputeStages 리팩터
4. **B1** — 프로파일 관리 UI (모달)
5. **B2** — 제품 선택기 드롭다운
6. **C** — 이중 밴드 차트 렌더링
7. 시드 데이터 + 테스트
---
## 보류 사항
- `gen_temp_profiles.py` 수정: 자동 클러스터링 유지, 출력에 user-defined 템플릿 포함
- 다중 컬럼 동시 표시 (현재 단일 컬럼, 필요시 별도 논의)

View File

@@ -0,0 +1,81 @@
# 작업플랜 — 온도 프로파일 이력(History) 조회 (2026-06-07)
> 상위: `docs/작업플랜-컬럼온도프로파일-이격모니터.md`(실시간 모니터)의 시간축 확장.
> 현재 `GET /api/steam/tempprofile/{col}`은 **realtime_table 최신값만** 읽음 →
> **history_table(60초 스냅샷)** 로 과거 시점/구간 프로파일을 보게 한다.
## 목적
steam 패널 "온도 프로파일 이격 모니터"가 지금은 실시간 1점만 본다.
운전자가 **과거 임의 시점의 단면 온도 프로파일**(reb-A/B/C/D + 진공 + 제품매칭 + z이격)을
조회하고, 나아가 **구간을 슬라이더/재생으로 스크럽**해 프로파일이 시간에 따라
어떻게 움직였는지(처짐/상승/붕괴) 보게 한다.
## 현 자산 (재활용, 중복금지)
- **엔드포인트**: `SteamAdvisorController.TempProfile(string col)` (SteamAdvisorController.cs:180~).
로직 = ① `{col}_tempref.json` 로드(정적 기준밴드) → ② **현재값 dict `cur`** 산출
(`_ctx.RealtimePoints`에서 태그별 최신) → ③ 제품매칭(reb_temp 최근접) → ④ z-score 이격 →
⑤ stages/vacuum/spanAD 응답. **②만 시간축으로 바꾸면 ③~⑤ 그대로 재사용**.
- **태그 매핑**: `TagsFor(ToSuffix(col))``{reb_temp, T_B, T_C, T_D, vacuum, ...}` (컬럼 무관, 재활용).
- **기준밴드 `tempref`**: 정적(period 기준) → 실시간/이력 **공통**, 변경 불필요.
- **히스토리 인프라**:
- `history_table(tagname, value, recorded_at, controller_id)`, 60초 간격, TimescaleDB hypertable.
- `IExperionDbService.QueryHistoryAsync(tags, from, to, limit)` (Hc900DbContext.cs:1599) — 구간 원시.
- `QueryHistoryWithIntervalAsync` + `time_bucket` 피벗(Hc900DbContext.cs:1719~) — 간격 집계.
- `POST /api/history/query`, `/api/history/query-interval` (HistoryController, Hc900Controllers.cs:130).
- **프론트**: `steam.js` `stTempTick`(realtime 폴링)/`stRenderTemp(d)`(ECharts 렌더, steam.js:108~).
`stRenderTemp`는 응답 `d` 형태에만 의존 → **이력 응답도 같은 형태면 렌더 재사용**.
## 설계 — 값 제공자(value provider)만 시간축화
`TempProfile`에서 `cur`(태그→값 dict) 만드는 부분을 **헬퍼로 분리**하고 두 소스를 둔다:
- **realtime** (기존): `RealtimePoints` 태그별 최신.
- **history@at**: history_table에서 각 태그의 **`recorded_at <= at` 최근접 1건**.
```sql
SELECT DISTINCT ON (tagname) tagname, value
FROM history_table
WHERE controller_id = @cid AND tagname = ANY(@tags) AND recorded_at <= @at
ORDER BY tagname, recorded_at DESC;
```
- **history frames(구간 재생)**: `time_bucket(@interval, recorded_at)` 피벗으로 태그×시점 행렬 →
프레임 배열. (재활용: `BuildHistoryIntervalQuerySql` 패턴.)
값 파싱은 기존과 동일(`double.TryParse`). 상태레이블(`{N|LABEL|}`) 태그는 온도엔 없음(숫자) → 안전.
## 단계
1. **백엔드 리팩터 (선행)** — `TempProfile`의 `cur` 생성부를
`BuildCurrent(tagMap, IValueSource)` 헬퍼로 분리. 기존 realtime 경로는 동작 보존(회귀 0).
2. **단일 시점 엔드포인트** — `GET /api/steam/tempprofile/{col}?at={ISO8601}`.
- `at` 없으면 기존 realtime 동작(하위호환).
- `at` 있으면 history@at 소스로 `cur` 산출 → 이후 로직(제품매칭·z·band) 동일.
- 응답에 `mode:"history"`, `at`(실제 매칭된 `recorded_at`), `staleSec`(at매칭시각) 추가.
- 컨트롤러 해석: col→controller_id 매핑 필요(기존 매핑 경로 재활용; 없으면 모든 controller_id 허용).
3. **구간 재생 엔드포인트(옵션, 2차)** — `GET /api/steam/tempprofile/{col}/frames?from&to&interval`.
- time_bucket 피벗 → `{ at, stages, vacuum, spanAD, matchedProduct }[]` 프레임 배열.
- 제품매칭/z를 프레임마다 계산(reb_temp 변동 반영).
4. **프론트 — 모드 토글 (steam.js / steam.html)**:
- temp 탭에 **[실시간 | 이력]** 토글 + datetime-local 입력 + "조회".
- 이력 선택 시 `stTempTimer` 중지(폴링 멈춤), `?at=` 호출 → `stRenderTemp` 재사용.
- 배지/상태줄에 "이력 YYYY-MM-DD HH:mm (스냅샷 Ns)" 표기.
5. **프론트 — 스크럽/재생(옵션, 2차)**:
- from~to + interval 입력 → frames 로드 → **슬라이더**로 프레임 이동, **▶재생**으로 애니메이션.
- 각 프레임 `stRenderTemp(frame)` 호출(차트 setOption 재사용). reb-A/C 처짐 추이가 눈에 보임.
## 검증기준
- `?at=` 미지정 → 기존 실시간 응답과 동일(회귀 없음).
- `?at=`(과거 1시간 전) → 그 시점 reb-A/B/C/D·진공이 history 스냅샷과 일치, 제품매칭·z 정상.
- history에 해당 시각 데이터 없음(컬럼 오프라인 구간) → `at`보다 과거 최근접 반환 + `staleSec` 큼, 또는 빈 결과 명시(에러 0).
- **임의 컬럼**(6-1/6-2/8/9/10차) 동일 동작 — col 키만 다름(하드코딩 금지).
- 프론트 이력 모드 진입 시 실시간 폴링 정지, 복귀 시 재개.
- (2차) frames 재생: 슬라이더 이동마다 프로파일 갱신, 재생 시 부드러운 전이.
## 주의
- **타임존**: `recorded_at`는 TIMESTAMPTZ(UTC). datetime-local(로컬 KST) → UTC 변환 후 질의.
응답 표기는 로컬로 환산(기존 `toLocaleTimeString` 일관).
- **controller_id 필터**: history_table은 멀티컨트롤러 공유 → 반드시 col→controller_id로 필터(태그명만으로 부족할 수 있음).
- **60초 해상도**: history는 60초 스냅샷 → `at` 분해능 60초. 더 촘촘한 건 fast_record(고속) 별도(범위 밖).
- **tempref는 정적 기준** — 이력 조회해도 기준밴드는 동일 period 기준(과거 시점의 당시 기준이 아님). 필요 시 후속 과제로 period-aware 기준 분리.
- **표시 전용** — 이력 조회는 제어와 무관(write 없음). 안전영향 0.
- 성능: 단일 `at`는 DISTINCT ON 인덱스(`recorded_at`)로 가벼움. frames는 interval·범위 클램프로 과대질의 방지.
## 권장순서
①백엔드 헬퍼 분리 → ②`?at=` 단일시점 + 프론트 토글(여기까지가 1차 목표) → ③frames + 슬라이더/재생(2차).
①②는 실시간 경로 회귀 없이 독립 적용 가능.

View File

@@ -0,0 +1,825 @@
# HC900 Crawler 포인트빌더 이식 설계서
> **목적**: `ExperionCrawler`의 `포인트빌더-페이지-구현-명세.md`에 정의된 Point Builder 페이지를
> HC900 Crawler 프로젝트에 맞게 변환하여 이식하기 위한 상세 설계서.
>
> **핵심 차이**: ExperionCrawler는 OPC UA `node_map_master`를 소스로 사용하지만,
> HC900 Crawler는 Modbus TCP `hc900_map_master`를 소스로 사용한다.
---
> **진단**: 아래 내용은 `diagnosis-checklist.md` 진단 룰 8단계를 적용한 설계 검토 결과.
> 우선순위: 🔴 HIGH(즉시 수정 권장) > 🟠 MED > 🟡 LOW
---
## 진단 결과 요약
### 🔴 1. 설계 원칙과 구현 코드 모순 — TRUNCATE vs IsActive 플래그
**문제**: §4.2가 "`TRUNCATE realtime_table` 대신 `IsActive` 플래그 사용"이라고 명시했으나,
§6.5 `SyncRealtimeTableAsync``TRUNCATE TABLE realtime_table RESTART IDENTITY`를 사용함.
**근거**: §4.2 L227 vs §6.5 L573-574
**영향**: 실시간 값(LiveValue)이 모두 null로 초기화됨. DB 유저에게 TRUNCATE 권한이 없으면
런타임 오류. 설계 원칙과 직접 충돌하여 구현 방향에 혼란 초래.
**수정**: `TRUNCATE` 대신 `DELETE FROM realtime_table WHERE tagname NOT IN (...active tags...)`
패턴으로 변경하여 LiveValue 보존.
---
### 🔴 2. BuildRealtimeTableAsync 시그니처/내부 타입 불일치
**문제**: §6.1 `BuildRealtimeTableAsync(IEnumerable<Hc900PointBuilderGroupDto>)`의 foreach에서
`(groupKey, group)` 튜플 분해를 사용하지만 단일 DTO 타입이므로 컴파일 불가.
§6.2 `PreviewRealtimeBuildAsync`는 올바르게 튜플 타입을 사용함.
**근거**: §6.1 L431 (시그니처) vs L439 (foreach destructure)
**영향**: 해당 코드는 컴파일되지 않음.
**수정**: 시그니처를 `IEnumerable<(string GroupKey, Hc900PointBuilderGroupDto Group)>`로 변경.
---
### 🟠 3. DB 트랜잭션 미적용
**문제**: Build/Apply/Append 모두 다중 DB 작업(전체 UPDATE → 조건부 UPDATE → TRUNCATE → INSERT)을
트랜잭션 없이 순차 실행. 중간 실패 시 `is_active``realtime_table`이 불일치 상태로 남음.
**근거**: §6.1 L434-451, §6.3 L526-535, §6.4 L544-554
**수정**: `using var tx = await _ctx.Database.BeginTransactionAsync()`로 전체 작업 감싸기.
---
### 🟠 4. Build/Apply 중 Gateway 폴링에 빈 활성 목록 노출
**문제**: "전체 `is_active=false`" → "조건부 `is_active=true`" 순서로 인해 두 UPDATE 사이에
Gateway가 폴링하면 모든 태그가 비활성 상태로 보임.
**근거**: §6.1 L434-435 (전체 비활성화) vs L447 (SaveChangesAsync)
**수정**: 조건부 UPDATE를 먼저 실행하거나 단일 SQL로 전환.
---
### 🟠 5. BuildGroupQuery에 LoopNo 필터 미구현
**문제**: §4.1 Request Body에 `loopNo` 필드가 명세되었으나 §6.1 `BuildGroupQuery`
`LoopNo` 조건을 구현하지 않음. SQL 명세(L176-182)와 C# 구현 불일치.
**근거**: §4.1 L159 (`loopNo` 필드) vs §6.1 L458-479 (LoopNo 조건 누락)
**수정**: `if (group.LoopNo.HasValue) q = q.Where(x => x.LoopNo == group.LoopNo.Value);`
---
### 🟠 6. Hc900PointBuilderGroupDto 클래스 정의 누락
**문제**: 문서 전체에서 `Hc900PointBuilderGroupDto`를 사용하지만 필드 정의가 없음.
특히 `LoopNo` 필드의 타입(`int?`)과 `PointBuilderGroupDto`(Experion 잔재)와의 관계 불명확.
**근거**: §4.1 ~ §6.6 전반 — 사용만 있고 정의는 없음.
**수정**: §3.4로 명시적 클래스 정의 추가.
---
### 🟡 7. TagPatterns 와일드카드 처리 불일치
**문제**: §4.1 명세는 "`ILIKE` 패턴 (`%` 와일드카드)"이나 §6.1 구현은 `%`를 제거 후
항상 `%p%`로 감싸 contains 매칭만 수행.
**수정**: 사용자 패턴을 그대로 ILIKE에 전달하거나, `%...%` 고정 규칙으로 명세 통일.
---
### 🟡 8. 그룹별 도메인 필터 부재
**문제**: 5개 그룹(loop/signal/digital/variable/custom)이 모두 동일한 `BuildGroupQuery`
사용하므로 그룹 간 도메인 경계가 무시됨.
**수정**: groupKey 기반 도메인 필터 추가 또는 "사용자 패턴에 전적으로 의존"한다고 문서화.
---
### 🟡 9. API 에러 응답 명세 부재
**문제**: 모든 API가 성공 응답만 정의하고 HTTP status code별 에러 응답 형식이 없음.
**수정**: 400/404/500 각각의 에러 응답 예시 추가.
---
## 목차
1. [현황 분석](#1-현황-분석)
2. [핵심 차이점](#2-핵심-차이점)
3. [데이터 모델 변경](#3-데이터-모델-변경)
4. [API 엔드포인트 설계](#4-api-엔드포인트-설계)
5. [프론트엔드 변경](#5-프론트엔드-변경)
6. [Hc900DbService 변경](#6-hc900dbservice-변경)
7. [마이그레이션 순서](#7-마이그레이션-순서)
---
## 1. 현황 분석
### 1.1 현재 구현된 것 (이식 대상 아님, 교체 필요)
| 파일 | 역할 | 현재 상태 |
|------|------|-----------|
| `wwwroot/panes/pb.html` (94줄) | 태그 관리 페이지 | Hc900MapEntry 조회/필터/활성화 토글 UI |
| `wwwroot/js/pb.js` (213줄) | 태그 관리 로직 | `pbReload`, `pbToggleOne`, `pbBulkSelected` 등 |
| `wwwroot/css/pb.css` (106줄) | CSS | ExperionCrawler 원본과 동일 (잔재) |
| `Controllers/Hc900Controllers.cs` | 컨트롤러 | `Hc900TagManagerController` (태그 CRUD), `RealtimeController` (point CRUD) |
| `Infrastructure/Database/Hc900DbContext.cs` | DbService | `BuildRealtimeTableAsync`, `PreviewRealtimeBuildAsync``NotImplementedException` |
### 1.2 ExperionCrawler 원본과의 대응 관계
| ExperionCrawler | HC900 Crawler |
|---|---|
| OPC UA 서버 연결 | C++ Gateway (gRPC) |
| `node_map_master` (OPC UA 노드 카탈로그) | `hc900_map_master` (Modbus 레지스터 맵) |
| OPC UA 구독 시작/중지 | Gateway Polling 시작/중지 (프로세스 제어) |
| `realtime_table` = 구독 대상 포인트 목록 | `realtime_table` = 게이트웨이가 폴링하는 값 캐시 |
| `IExperionRealtimeService.AddMonitoredItemAsync()` | `Hc900RealtimeService` (이미 폴링 중) |
### 1.3 이미 존재하는 것 (재사용 가능)
- `Hc900TagManagerController``/api/hc900/tags` (Hc900MapEntry CRUD, param-types, controller-ids)
- `RealtimeController``/api/realtime/points` (realtime_table 조회/삭제)
- `MetadataController``/api/metadata` (tag_metadata 조회)
- `SubAreaController``/api/subarea` (sub-area 관리)
- `Hc900GatewayClient` — gRPC ListTags, HealthCheck
- `Hc900RealtimeService` — 폴링 서비스 상태 노출
---
## 2. 핵심 차이점
### 2.1 데이터 소스 차이
| 항목 | ExperionCrawler | HC900 Crawler |
|------|----------------|---------------|
| **소스 테이블** | `node_map_master` (`Level=3`, OPC UA Variables) | `hc900_map_master` (Modbus 레지스터) |
| **태그 식별자** | `NodeId` (`ns=1;s=ti-6101.pv`) | `TagName` (`FICQ-6101.PV`) |
| **데이터 타입** | `DataType` 컬럼 (Boolean/Double/Int16 ...) | `DataType` 컬럼 (float32/uint16/int32 ...) |
| **속성** | `Name` 컬럼 (pv/sp/op/md) | `ParamType` 컬럼 (PV/SP/OP/MODE/STATUS) |
| **그룹 기준** | OPC UA Browse 계층 (controller1/analogmon1/...) | HC900 구조 (Loop# + ParamType) |
| **태그 계층** | OPC UA NodeId의 ns/s prefix | `base_tag.attribute` dot notation |
### 2.2 동작 방식 차이
| 항목 | ExperionCrawler | HC900 Crawler |
|------|----------------|---------------|
| **포인트 등록** | `node_map_master``realtime_table` INSERT | `hc900_map_master.IsActive = true` 설정 |
| **포인트 삭제** | `realtime_table` DELETE | `hc900_map_master.IsActive = false` 설정 + `realtime_table` DELETE |
| **실시간 구독** | OPC UA AddMonitoredItem | Gateway가 `is_active` 태그 자동 폴링 |
| **구독 시작/중지** | OPC UA 세션 제어 | Gateway 프로세스 시작/중지 |
| **수동 추가** | OPC UA NodeId 입력 + 유효성 검증 | `hc900_map_master`에 직접 INSERT |
### 2.3 그룹 체계 차이
ExperionCrawler는 5개 그룹(`controller1`, `analogmon1`, `digital1`, `digital2`, `custom`)을
OPC UA Browse 결과에 따라 나누지만, HC900은 구조가 다르므로 아래와 같이 변환:
| ExperionCrawler 그룹 | HC900 Crawler 그룹 (대체) |
|---------------------|--------------------------|
| `controller1` | `loop` — PID Loop 파라미터 (PV/SP/OP/MODE 등) |
| `analogmon1` | `signal` — Signal Tag (아날로그 모니터링) |
| `digital1` | `digital` — 디지털 입력 (타입이 digital/Boolean인 것) |
| `digital2` | `variable` — Variable 태그 (R/W 커스텀 변수) |
| `custom` | `custom` — 사용자 정의 필터 |
---
## 3. 데이터 모델 변경
### 3.1 `hc900_map_master` — 변경 없음 (이미 적절함)
현재 `Hc900MapEntry`의 컬럼이 Point Builder에 필요한 정보를 이미 포함:
```csharp
public class Hc900MapEntry {
int Id; // PK
string TagName; // e.g. "FICQ-6101.PV"
string Hc900Tag; // e.g. "FICQ-6101.PV"
int ModbusAddr; // 0x40 (0-based)
string DataType; // "float32", "uint16", "int32"
string Access; // "R" or "RW"
int? LoopNo; // PID loop number
string? ParamType; // "PV", "SP", "OP", "MODE", "STATUS", "SIG"
bool IsActive; // 폴링 대상 여부
string ControllerId; // "HC1", "C2", ...
}
```
### 3.2 `realtime_table` — 변경 없음
```csharp
public class RealtimePoint {
int Id; // PK
string TagName; // e.g. "FICQ-6101.PV"
string NodeId; // hc900_tag (호환성 유지)
string? LiveValue; // 실시간 값
DateTime Timestamp; // 갱신 시각
string ControllerId; // "HC1"
}
```
### 3.3 `tag_metadata` — 변경 없음
```csharp
public class TagMetadata {
int Id; // PK
string BaseTag; // e.g. "FICQ-6101"
string Attribute; // "desc", "area", "sub_area", "state0"~"state7"
string? Value; // actual value
string? NodeId; // unused in HC900
DateTime LoadedAt;
string ControllerId;
}
```
---
## 4. API 엔드포인트 설계
> **Base**: `/api/pointbuilder`
>
> 기존 `Hc900TagManagerController`는 유지. Point Builder는 별도 컨트롤러로 분리.
### 4.1 `POST /api/pointbuilder/preview`
hc900_map_master에서 조건에 맞는 태그를 미리보기.
**Request Body**:
```json
{
"controller1": { "tagPatterns": ["FICQ-61", "TICQ-61"], "paramTypes": ["PV","SP"], "dataType": null, "loopNo": null },
"analogmon1": { "tagPatterns": ["TI-61", "PI-62"], "paramTypes": ["PV"], "dataType": null, "loopNo": null },
"digital1": { "tagPatterns": ["YS-61", "YT-62"], "paramTypes": ["STATUS"], "dataType": null, "loopNo": null },
"variable": { "tagPatterns": [], "paramTypes": [], "dataType": null, "loopNo": null },
"custom": { "tagPatterns": [], "paramTypes": [], "dataType": null, "loopNo": null }
}
```
**필드 설명**:
| 필드 | 타입 | 설명 |
|------|------|------|
| `tagPatterns` | `string[]` | `hc900_map_master.TagName`에 대한 `ILIKE` 패턴 (`%` 와일드카드) |
| `paramTypes` | `string[]` | `ParamType` 필터 (PV / SP / OP / MODE / STATUS / SIG / VAR) |
| `dataType` | `string?` | `DataType` 필터 (`float32` / `uint16` / `int32` etc). null=전체 |
| `loopNo` | `int?` | `LoopNo` 필터. null=전체 |
**DB 조건**:
```sql
SELECT * FROM hc900_map_master
WHERE (TagName ILIKE <pattern1> OR TagName ILIKE <pattern2> ...)
AND ParamType IN (<paramTypes>) -- paramTypes가 비어있으면 생략
AND DataType = <dataType> -- dataType이 null이면 생략
AND (loop_no = <loopNo> OR <loopNo> IS NULL)
ORDER BY TagName
```
**Response**:
```json
{
"count": 42,
"items": [
{
"tagName": "FICQ-6101.PV",
"hc900Tag": "FICQ-6101.PV",
"modbusAddr": 64,
"paramType": "PV",
"dataType": "float32",
"loopNo": 1,
"access": "R",
"controllerId": "HC1",
"group": "controller1",
"isActive": true
}
]
}
```
**새 DTO** (`PointBuilderPreviewItem` 확장):
```csharp
public class Hc900PointBuilderPreviewItem
{
public string TagName { get; set; } = "";
public string Hc900Tag { get; set; } = "";
public int ModbusAddr { get; set; }
public string ParamType { get; set; } = "";
public string DataType { get; set; } = "";
public int? LoopNo { get; set; }
public string Access { get; set; } = "R";
public string ControllerId { get; set; } = "HC1";
public string Group { get; set; } = "";
public bool IsActive { get; set; }
}
```
### 4.2 `POST /api/pointbuilder/build`
조건에 맞는 모든 태그를 **활성화** (기존 활성 태그는 모두 **비활성화** 후 조건 매칭 태그만 활성화).
> **실제 동작**: `TRUNCATE realtime_table` 대신 `IsActive` 플래그 사용.
> 즉, 기존에 활성화된 태그 중 조건에 포함되지 않은 것은 `IsActive=false`로 변경.
**동작**:
1. `UPDATE hc900_map_master SET is_active = false` (전체 비활성화)
2. 조건 매칭된 태그들의 `is_active = true` 설정
3. 활성화된 태그를 `realtime_table`에 반영 (기존 행은 TRUNCATE 후 INSERT, 또는 upsert)
**Response**:
```json
{
"success": true,
"count": 42,
"message": "42개 포인트 활성화 완료"
}
```
### 4.3 `POST /api/pointbuilder/apply`
미리보기에서 **선택한 태그만** 활성화 (기존 활성화 모두 비활성화 → 선택만 활성화).
**Request**: `{ "selectedTagNames": ["FICQ-6101.PV", "FICQ-6101.SP", ...] }`
### 4.4 `POST /api/pointbuilder/append`
선택한 태그를 **기존 활성화 목록에 추가** (중복 제외).
**Request**: `{ "selectedTagNames": [...] }`
### 4.5 `GET /api/pointbuilder/points`
등록된 모든 active 태그 조회 (= `GET /api/hc900/tags?active=true` + 실시간 값 join).
**Response**:
```json
{
"total": 42,
"items": [
{
"id": 1,
"tagName": "FICQ-6101.PV",
"modbusAddr": 64,
"paramType": "PV",
"dataType": "float32",
"controllerId": "HC1",
"liveValue": "152.7",
"timestamp": "2026-06-08T12:34:56Z",
"isActive": true
}
]
}
```
### 4.6 `POST /api/pointbuilder/add`
태그를 수동으로 hc900_map_master에 추가하고 활성화.
**Request**: `{ "tagName": "FICQ-6201.PV", "modbusAddr": 320, "dataType": "float32", "loopNo": null, "paramType": "PV", "access": "R", "controllerId": "HC1" }`
**동작**:
1. `hc900_map_master`에 INSERT (중복 TagName+ControllerId 체크)
2. `is_active = true` 설정
3. C++ Gateway가 다음 폴링 사이클에 자동으로 포함
### 4.7 `DELETE /api/pointbuilder/{id}?purgeHistory=false`
포인트 삭제 (= 비활성화).
**실제 동작**:
1. `hc900_map_master`에서 `is_active = false`
2. `realtime_table`에서 해당 행 DELETE
3. 같은 base_tag의 잔여 행이 0이면 `tag_metadata` 고아 정리
4. `purgeHistory=true``DELETE FROM history_table WHERE tagname = ?`
### 4.8 보조 API (재사용, URL만 정리)
| Method | Endpoint | 설명 | 재사용 |
|--------|----------|------|--------|
| `GET` | `/api/gateway/health` | Gateway 헬스체크 | `GatewayController` 기존 |
| `GET` | `/api/gateway/status` | 폴링 서비스 상태 | `GatewayController` 기존 |
| `GET` | `/api/metadata` | tag_metadata 조회 | `MetadataController` 기존 |
| `GET` | `/api/subarea/{area}` | Sub-Area 현황 | `SubAreaController` 기존 |
| `PUT` | `/api/subarea/{baseTag}` | Sub-Area 수정 | `SubAreaController` 기존 |
| `POST` | `/api/subarea/seed` | Sub-Area 일괄 분류 | `SubAreaController` 기존 |
### 4.9 Gateway 구독 제어 (OPC UA 대체)
ExperionCrawler의 실시간 구독 시작/중지는 HC900에서 Gateway 프로세스 제어로 대체.
이는 `setup` 탭에 이미 구현되어 있으므로 Point Builder에서는 **상태 확인**만 제공:
| Method | Endpoint | 설명 |
|--------|----------|------|
| `GET` | `/api/gateway/health` | Gateway 상태 |
---
## 5. 프론트엔드 변경
### 5.1 `pb.html` — 전체 교체 (ExperionCrawler 원본 기반)
ExperionCrawler 원본 `pb.html` (311줄)을 기반으로 HC900에 맞게 수정.
**수정 사항**:
| 섹션 | ExperionCrawler | HC900 변경 |
|------|----------------|------------|
| 헤더 | "포인트빌더 — node_map_master → realtime_table 구성" | "포인트빌더 — HC900 태그 활성화 관리" |
| 그룹명 | controller1/analogmon1/digital1/digital2/custom | loop/signal/digital/variable/custom |
| 속성 체크박스 | pv/op/sp/md | PV/SP/OP/MODE/STATUS/SIG/VAR |
| 데이터타입 선택 | Double/Boolean/String/Int16/UInt32/Float/... | float32/uint16/int32/int64/float64 |
| 패턴 매칭 | NodeId LIKE (`ns=1;s=ti-6101.pv`) | TagName ILIKE (`FICQ-6101.PV`) |
| 우측 카드: 구독 제어 | 서버IP/포트/계정 + 시작/중지 | **제거** (setup 탭에서 관리) |
| 우측 카드: 수동 추가 | Node ID 직접 입력 | **변경**: TagName + ModbusAddr + DataType + ParamType 입력 |
| 우측 하단: Sub-Area | 유지 | 유지 (동일 로직) |
| 포인트 목록 테이블 | NodeId/TagName/LiveValue/Timestamp | TagName/ParamType/DataType/ModbusAddr/LiveValue/Timestamp/ControllerId |
**그룹 카드 레이아웃 수정**:
```html
<!-- 루프 파라미터 (기존: controller1) -->
<div class="pb-group-card" data-group="loop">
<div class="pb-group-header">
<span class="card-sub-cap">PID 루프 파라미터 #1</span>
</div>
<input class="inp pb-pattern-input"
data-group="loop" data-field="tagPatterns"
placeholder="FICQ-61, TICQ-62, ... (쉼표 구분)">
<div class="pb-attr-checkboxes">
<label><input type="checkbox" value="PV" checked data-group="loop" data-field="paramTypes"> PV</label>
<label><input type="checkbox" value="SP" checked data-group="loop" data-field="paramTypes"> SP</label>
<label><input type="checkbox" value="OP" checked data-group="loop" data-field="paramTypes"> OP</label>
<label><input type="checkbox" value="MODE" data-group="loop" data-field="paramTypes"> MODE</label>
<label><input type="checkbox" value="STATUS" data-group="loop" data-field="paramTypes"> STATUS</label>
</div>
<div class="pb-custom-attr-inputs">
<input class="inp" data-group="loop" data-field="customParamTypes" placeholder="추가 속성 (SIG, VAR ...)">
</div>
<select class="inp pb-datatype-select" data-group="loop" data-field="dataType">
<option value="">모든 타입</option>
<option value="float32">float32</option>
<option value="uint16">uint16</option>
<option value="int32">int32</option>
<option value="int64">int64</option>
</select>
</div>
```
### 5.2 `pb.js` — 전체 교체
**핵심 변경 함수**:
| 함수 | ExperionCrawler | HC900 변경 |
|------|----------------|------------|
| `PB_GROUPS` | `['controller1','analogmon1','digital1','digital2','custom']` | `['loop','signal','digital','variable','custom']` |
| `pbCollectGroupData()` | `querySelector` by `attributes`, `dataType` | `paramTypes`로 변경 |
| `pbBuild()` | POST `/api/pointbuilder/build` | 동일 엔드포인트, 새로운 동작 |
| `pbPreview()` | POST `/api/pointbuilder/preview` | 동일 (internal 로직 변경) |
| `pbRenderPreview()` | NodeId/TagName/Name/DataType/Group | TagName/ParamType/DataType/LoopNo/Group/ControllerId |
| `pbAddManual()` | NodeId 하나만 입력 | TagName + ModbusAddr + DataType + ParamType + Access + ControllerId |
| `pbRefresh()` | `GET /api/pointbuilder/points` | Active 태그 + 실시간 값 조인 |
| `rtStart()` / `rtStop()` / `rtStatus()` | OPC UA 구독 제어 | **삭제** (setup 탭에서 처리) |
**데이터 수집 함수 (pbCollectGroupData)**:
```javascript
// HC900 버전
function pbCollectGroupData(groupKey) {
const tagPatterns = document.querySelector(
`input[data-group="${groupKey}"][data-field="tagPatterns"]`
).value.split(',').map(s => s.trim()).filter(Boolean);
const checkedParamTypes = Array.from(
document.querySelectorAll(
`input[data-group="${groupKey}"][data-field="paramTypes"]:checked`
)
).map(cb => cb.value);
const customInputs = document.querySelectorAll(
`input[data-group="${groupKey}"][data-field="customParamTypes"]`
);
customInputs.forEach(inp => {
if (inp.value.trim()) checkedParamTypes.push(inp.value.trim());
});
const dataType = document.querySelector(
`select[data-group="${groupKey}"][data-field="dataType"]`
)?.value || null;
return { tagPatterns, paramTypes: checkedParamTypes, dataType };
}
```
### 5.3 `pb.css` — 재사용 가능
ExperionCrawler 원본 `pb.css` (106줄)은 그대로 재사용 가능하다.
`pb-group-card`, `pb-preview`, `.group-badge` 등의 클래스가 동일하게 적용된다.
---
## 6. Hc900DbService 변경
### 6.1 `BuildRealtimeTableAsync` — 새 구현
```csharp
public async Task<int> BuildRealtimeTableAsync(IEnumerable<Hc900PointBuilderGroupDto> groups)
{
// 1. 전체 비활성화
await _ctx.Database.ExecuteSqlRawAsync(
"UPDATE hc900_map_master SET is_active = false");
// 2. 조건별 태그 활성화
int total = 0;
foreach (var (groupKey, group) in groups)
{
var matched = await BuildGroupQuery(group).ToListAsync();
foreach (var entry in matched)
{
entry.IsActive = true;
total++;
}
}
await _ctx.SaveChangesAsync();
// 3. realtime_table 동기화 (활성 태그만 존재하도록)
await SyncRealtimeTableAsync();
return total;
}
private IQueryable<Hc900MapEntry> BuildGroupQuery(Hc900PointBuilderGroupDto group)
{
var q = _ctx.Hc900MapEntries.AsQueryable();
if (group.TagPatterns?.Any() == true)
{
var patterns = group.TagPatterns.Select(p => p.Replace("%", "").ToLower()).ToList();
var likeClauses = patterns.Select(p =>
(Expression<Func<Hc900MapEntry, bool>>)(x =>
EF.Functions.ILike(x.TagName, $"%{p}%")));
// OR 결합
q = q.Where(likeClauses.Aggregate((a, b) =>
Expression.Lambda<Func<Hc900MapEntry, bool>>(
Expression.OrElse(a.Body, Expression.Invoke(b, a.Parameters[0])),
a.Parameters[0])));
}
if (group.ParamTypes?.Any() == true)
q = q.Where(x => group.ParamTypes.Contains(x.ParamType));
if (!string.IsNullOrEmpty(group.DataType))
q = q.Where(x => x.DataType == group.DataType);
return q;
}
```
### 6.2 `PreviewRealtimeBuildAsync` — 새 구현
```csharp
public async Task<Hc900PointBuilderPreviewResult> PreviewRealtimeBuildAsync(
IEnumerable<(string GroupKey, Hc900PointBuilderGroupDto Group)> groups)
{
var items = new List<Hc900PointBuilderPreviewItem>();
foreach (var (groupKey, group) in groups)
{
var matched = await BuildGroupQuery(group)
.Select(e => new Hc900PointBuilderPreviewItem
{
TagName = e.TagName,
Hc900Tag = e.Hc900Tag,
ModbusAddr = e.ModbusAddr,
ParamType = e.ParamType ?? "",
DataType = e.DataType,
LoopNo = e.LoopNo,
Access = e.Access,
ControllerId = e.ControllerId,
Group = groupKey,
IsActive = e.IsActive
})
.ToListAsync();
items.AddRange(matched);
}
return new Hc900PointBuilderPreviewResult
{
Count = items.Count,
Items = items
};
}
```
### 6.3 `ApplySelectedPointsAsync` — 새 구현
```csharp
public async Task<int> ApplySelectedPointsAsync(IEnumerable<string> selectedTagNames)
{
var tagNames = selectedTagNames.Where(n => !string.IsNullOrEmpty(n)).ToList();
if (tagNames.Count == 0) return 0;
// 1. 전체 비활성화
await _ctx.Database.ExecuteSqlRawAsync(
"UPDATE hc900_map_master SET is_active = false");
// 2. 선택만 활성화
var count = await _ctx.Hc900MapEntries
.Where(x => tagNames.Contains(x.TagName))
.ExecuteUpdateAsync(s => s.SetProperty(x => x.IsActive, true));
// 3. realtime_table 동기화
await SyncRealtimeTableAsync();
return count;
}
```
### 6.4 `AppendPointsAsync` — 새 구현
```csharp
public async Task<int> AppendPointsAsync(IEnumerable<string> tagNames)
{
var tagNamesList = tagNames.Where(n => !string.IsNullOrEmpty(n)).ToList();
if (tagNamesList.Count == 0) return 0;
var count = await _ctx.Hc900MapEntries
.Where(x => tagNamesList.Contains(x.TagName) && !x.IsActive)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.IsActive, true));
await SyncRealtimeTableAsync();
return count;
}
```
### 6.5 `SyncRealtimeTableAsync` — 신규 헬퍼
```csharp
/// <summary>
/// hc900_map_master의 활성 태그 목록과 realtime_table을 동기화.
/// realtime_table에는 활성 태그만 존재해야 함.
/// </summary>
private async Task SyncRealtimeTableAsync()
{
var activeTags = await _ctx.Hc900MapEntries
.Where(x => x.IsActive)
.Select(x => new { x.TagName, x.Hc900Tag, x.ControllerId })
.ToListAsync();
// TRUNCATE 후 INSERT (간단한 approach)
await _ctx.Database.ExecuteSqlRawAsync(
"TRUNCATE TABLE realtime_table RESTART IDENTITY");
var points = activeTags.Select(t => new RealtimePoint
{
TagName = t.TagName,
NodeId = t.Hc900Tag,
LiveValue = null,
Timestamp = DateTime.UtcNow,
ControllerId = t.ControllerId
}).ToList();
if (points.Count > 0)
{
_ctx.RealtimePoints.AddRange(points);
await _ctx.SaveChangesAsync();
}
}
```
### 6.6 `AddRealtimePointAsync` — 수정
NodeId 대신 TagName으로 Hc900MapEntry를 생성/조회:
```csharp
public async Task<RealtimePoint> AddRealtimePointAsync(string tagName, string hc900Tag,
int modbusAddr, string dataType, string? paramType, string access, string controllerId)
{
// 1. hc900_map_master에 추가
var existing = await _ctx.Hc900MapEntries
.FirstOrDefaultAsync(x => x.TagName == tagName && x.ControllerId == controllerId);
if (existing == null)
{
existing = new Hc900MapEntry
{
TagName = tagName,
Hc900Tag = hc900Tag ?? tagName,
ModbusAddr = modbusAddr,
DataType = dataType,
ParamType = paramType,
Access = access,
ControllerId = controllerId,
IsActive = true
};
_ctx.Hc900MapEntries.Add(existing);
}
else
{
existing.IsActive = true;
}
await _ctx.SaveChangesAsync();
// 2. realtime_table에 반영
return await SyncSinglePointAsync(tagName, controllerId);
}
```
---
## 7. 마이그레이션 순서
### Phase A: 백엔드 (1일)
1. **DTO 신규 작성**
- `Hc900PointBuilderGroupDto``PointBuilderGroupDto` 대체 (TagPatterns, ParamTypes, DataType)
- `Hc900PointBuilderPreviewItem` — preview item (TagName, ModbusAddr, ParamType, Group 등)
- `Hc900PointBuilderPreviewResult` — preview 결과
- `Hc900PointBuilderBuildDto` — build 요청
- `Hc900PointBuilderApplyDto` — apply 요청 (SelectedTagNames)
- `Hc900PointBuilderAddDto` — 수동 추가 요청
2. **Controller 신규 작성**`PointBuilderController.cs`
- `/api/pointbuilder/preview`
- `/api/pointbuilder/build`
- `/api/pointbuilder/apply`
- `/api/pointbuilder/append`
- `/api/pointbuilder/points`
- `/api/pointbuilder/add`
- `DELETE /api/pointbuilder/{id}`
3. **Hc900DbService 구현**
- `BuildRealtimeTableAsync` — 새 구현 (기존 NotImplementedException 대체)
- `PreviewRealtimeBuildAsync` — 새 구현
- `ApplySelectedPointsAsync` — 새 구현
- `AppendPointsAsync` — 새 구현
- `AddRealtimePointAsync` — 수정
- `SyncRealtimeTableAsync` — 신규
### Phase B: 프론트엔드 (1일)
4. **`pb.html` 재작성**
- ExperionCrawler 원본 레이아웃 유지
- 그룹명/필드명 HC900에 맞게 수정
- 우측 컬럼: 구독 제어 → 제거 (Gateway 상태만 표시)
- 우측 컬럼: 수동 추가 → TagName + ModbusAddr + DataType + ParamType + Access + ControllerId
5. **`pb.js` 재작성**
- 데이터 수집: `attributes``paramTypes`
- Preview: `NodeId``TagName` + `ParamType` + `LoopNo`
- Build/Apply: 전체 교체는 `IsActive` 플립
- rtStart/rtStop/rtStatus → 제거
- 수동 추가: 확장된 필드
6. **`pb.css`** — 유지 (수정 불필요)
### Phase C: 통합 테스트 (0.5일)
7. **테스트 시나리오**
- Preview: 루프/시그널/디지털 그룹별 필터 확인
- Build: 전체 재구축 후 활성 태그 변경 확인
- Apply: 선택 적용 확인
- Append: 기존 유지 + 추가 확인
- Add: 수동 추가 후 Gateway 폴링 확인
- Delete: 비활성화 + 이력 삭제 확인
### Phase D: 기존 태그 관리 페이지 처리 (0.5일)
8. **기존 `pb.html` 태그 관리 기능**
- 현재 "태그 관리" 탭의 요약 카드, 필터, 테이블, 활성화 토글, Bulk 액션 등은
Point Builder의 "포인트 목록" 섹션과 통합하거나 별도 탭으로 유지
- **제안**: `pb` 탭을 Point Builder로 완전 교체하고,
기존 태그 관리 기능은 `setup` 탭에 통합하거나 `#pane-tag-manager`로 분리
- 또는 `pb` 탭에 두 가지 서브뷰 제공:
- **빌더 뷰** (기본): ExperionCrawler 스타일 포인트 선택/활성화
- **관리 뷰**: 현재의 태그 목록 테이블 + Bulk 액션
---
## 부록: API 라우트 비교표
| ExperionCrawler | HC900 (변경 후) | 비고 |
|----------------|----------------|------|
| `POST /api/pointbuilder/preview` | `POST /api/pointbuilder/preview` | 동일 (내부 로직 변경) |
| `POST /api/pointbuilder/build` | `POST /api/pointbuilder/build` | 동일 (IsActive 토글로 변경) |
| `POST /api/pointbuilder/apply` | `POST /api/pointbuilder/apply` | 동일 (IsActive 토글로 변경) |
| `POST /api/pointbuilder/append` | `POST /api/pointbuilder/append` | 동일 (IsActive 토글로 변경) |
| `GET /api/pointbuilder/points` | `GET /api/pointbuilder/points` | 동일 (쿼리만 변경) |
| `POST /api/pointbuilder/add` | `POST /api/pointbuilder/add` | 동일 (필드 확장) |
| `DELETE /api/pointbuilder/{id}` | `DELETE /api/pointbuilder/{id}` | 동일 |
| `POST /api/realtime/start` | `POST /api/gateway/start` | Gateway process 제어로 이동 |
| `POST /api/realtime/stop` | `POST /api/gateway/stop` | Gateway process 제어로 이동 |
| `GET /api/realtime/status` | `GET /api/gateway/status` | 이미 구현됨 |
| `POST /api/tags/metadata/reload` | — | 불필요 (OPC UA 재조회 불가) |
| `GET /api/tags/metadata` | `GET /api/metadata` | 이미 구현됨 |
| `GET /api/tags/sub-area` | `GET /api/subarea/{area}` | 이미 구현됨 |
| `PUT /api/tags/sub-area` | `PUT /api/subarea/{baseTag}` | 이미 구현됨 |
| `POST /api/tags/sub-area/seed` | `POST /api/subarea/seed` | 이미 구현됨 |

View File

@@ -0,0 +1,545 @@
# 포인트빌더 페이지 구현 명세
> ExperionCrawler 웹 UI의 Tab #06 — OPC UA node_map_master에서 실시간 모니터링할 포인트를 선택해 `realtime_table`을 구성하는 페이지
---
## 1. 개요
**목적**: OPC UA 서버의 노드맵(`node_map_master` 테이블)에서 조건(태그명 패턴, 속성, 데이터타입)으로 필터링하여 실시간 구독할 포인트를 `realtime_table`에 등록/관리한다.
**사용자 플로우**:
1. 그룹별 태그 패턴 입력 → 미리보기로 대상 확인
2. 원하는 포인트 선택 → 적용(전체 교체) 또는 추가(기존 유지)
3. 등록된 포인트 목록 확인 / 개별 삭제
4. 실시간 구독 시작/중지
5. 메타데이터(desc/area) 갱신
6. Sub-Area 분류 관리
---
## 2. API 엔드포인트
Base: `/api/pointbuilder`
### 2.1 `POST /api/pointbuilder/preview`
조건에 맞는 포인트를 미리보기 (읽기 전용, DB 변경 없음)
**Request Body** (`PointBuilderBuildDto`):
```json
{
"controller1": { "tagPatterns": ["%ctl-61%.pv","%ctl-62%.sp"], "attributes": ["pv","sp"], "dataType": null },
"analogmon1": { "tagPatterns": ["%ti-61%.pv","%pi-62%.pv"], "attributes": ["pv"], "dataType": null },
"digital1": { "tagPatterns": ["%ys-61%.pv","%yt-62%.pv"], "attributes": ["pv"], "dataType": "Boolean" },
"digital2": { "tagPatterns": [], "attributes": [], "dataType": null },
"custom": { "tagPatterns": [], "attributes": [], "dataType": null }
}
```
| 필드 | 타입 | 설명 |
|------|------|------|
| `tagPatterns` | `string[]` | SQL LIKE 패턴 (쉼표 구분 아님 — 프론트에서 분할하여 배열로 전달). `node_map_master.NodeId`에 대해 `LIKE` 검색 |
| `attributes` | `string[]` | `node_map_master.Name` 필터 (pv/sp/op/md 등). 커스텀 속성 입력도 여기에 병합 |
| `dataType` | `string?` | `node_map_master.DataType` 필터. null이면 전체 |
**group keys**: `controller1`, `analogmon1`, `digital1`, `digital2`, `custom`
**DB 조건**:
```sql
SELECT ... FROM node_map_master WHERE Level = 3
AND (NodeId LIKE <pattern1> OR NodeId LIKE <pattern2> ...)
AND Name IN (<attrs>)
AND DataType = <dataType> -- dataType이 null이면 생략
```
**Response**:
```json
{
"count": 42,
"items": [
{ "nodeId": "ns=1;s=ti-6101.pv", "tagName": "ti-6101.pv", "name": "pv", "dataType": "Double", "group": "analogmon1" }
]
}
```
### 2.2 `POST /api/pointbuilder/build`
조건에 맞는 포인트로 **기존 `realtime_table` 전체 교체** (TRUNCATE → INSERT)
- Request Body: `PointBuilderBuildDto` (preview와 동일한 구조)
- 동작: `TRUNCATE TABLE realtime_table RESTART IDENTITY` 후 조건 매칭된 포인트 INSERT
- 메타데이터 자동 로드: 빌드 완료 후 `IMetadataLoaderService.ReloadMetadataAsync()` 호출 (실패해도 Warning 로그만, 빌드는 완료)
**Response**:
```json
{
"success": true,
"count": 42,
"metaCount": 40,
"message": "42개 포인트 생성 완료 (메타데이터: 40개)"
}
```
### 2.3 `POST /api/pointbuilder/apply`
**미리보기에서 선택한 포인트만**으로 `realtime_table` **전체 교체**
- Request Body: `{ "selectedNodeIds": ["ns=1;s=ti-6101.pv", "ns=1;s=ficq-6113.pv"] }`
- 동작: TRUNCATE → 선택된 NodeId만 INSERT
- 메타데이터 자동 로드 포함
**Response**: `{ "success": true, "count": 42, "metaCount": 40, "message": "..." }`
### 2.4 `POST /api/pointbuilder/append`
**미리보기에서 선택한 포인트를 기존 데이터에 추가** (중복 제외, 기존 유지)
- Request Body: `{ "selectedNodeIds": [...] }`
- 동작: `NodeId` 기준 중복 체크 후 없는 것만 INSERT
- 메타데이터 자동 로드 포함
### 2.5 `GET /api/pointbuilder/points`
등록된 모든 realtime 포인트 조회
**Response**:
```json
{
"total": 42,
"items": [
{ "id": 1, "tagName": "ti-6101.pv", "nodeId": "ns=1;s=ti-6101.pv", "liveValue": "25.3", "timestamp": "2026-06-08T12:34:56Z" }
]
}
```
### 2.6 `POST /api/pointbuilder/add`
Node ID를 직접 입력하여 수동 포인트 추가
**Request**: `{ "nodeId": "ns=1;s=my-custom-tag.pv" }`
동작:
1. `IExperionDbService.AddRealtimePointAsync()` — DB에 INSERT (중복이면 기존 반환)
2. `IExperionRealtimeService.AddMonitoredItemAsync()` — 구독 중이면 OPC UA에 핫 추가 + Node ID 유효성 검증
3. OPC UA 서버가 거부하면 DB 롤백
**Response**:
```json
{ "success": true, "point": { "id": 43, "tagName": "my-custom-tag.pv", "nodeId": "ns=1;s=my-custom-tag.pv" } }
```
### 2.7 `DELETE /api/pointbuilder/{id}?purgeHistory=false`
포인트 삭제
| 파라미터 | 기본값 | 설명 |
|---------|-------|------|
| `purgeHistory` | `false` | `true`이면 해당 tagname의 `history_table` 이력까지 영구 삭제 (복구 불가, 명시적 opt-in) |
동작:
1. realtime_table 행 삭제
2. 같은 base_tag의 잔여 행이 0이면 → `tag_metadata`(desc/area/sub_area) 고아 정리
3. purgeHistory=true → `DELETE FROM history_table WHERE tagname = ?`
**Response**:
```json
{
"success": true,
"message": "삭제 완료",
"baseTag": "ti-6101",
"metadataPurged": true,
"historyRowsDeleted": 1440
}
```
---
## 3. 보조 API 엔드포인트 (같은 페이지에서 사용)
### 3.1 실시간 구독 제어
| Method | Endpoint | 설명 | Request |
|--------|----------|------|---------|
| `POST` | `/api/realtime/start` | 구독 시작 | `{ serverHostName, port, clientHostName, userName, password }` |
| `POST` | `/api/realtime/stop` | 구독 중지 | — |
| `GET` | `/api/realtime/status` | 상태 조회 | — |
**status response**: `{ "running": true, "subscribedCount": 42, "message": "..." }`
### 3.2 메타데이터 관리
| Method | Endpoint | 설명 |
|--------|----------|------|
| `POST` | `/api/tags/metadata/reload` | OPC UA에서 desc/area 재조회 → tag_metadata UPDATE |
| `GET` | `/api/tags/metadata` | 모든 tag_metadata 조회 |
### 3.3 Sub-Area 관리
| Method | Endpoint | 설명 |
|--------|----------|------|
| `GET` | `/api/tags/sub-area?area=P6&page=1&pageSize=500` | area별 sub_area 현황 조회 |
| `PUT` | `/api/tags/sub-area` | 단일 태그 sub_area 수정. Body: `{ baseTag, subArea }` (null=미분류) |
| `POST` | `/api/tags/sub-area/seed` | 번호 prefix + pid_equipment로 일괄 분류. Body: `{ dryRun: bool }` |
---
## 4. UI 레이아웃 (2-Column Grid + 하단 포인트 목록)
```
┌──────────────────────────────────────────────────────────────┐
│ [pane-hdr] 포인트빌더 — node_map_master → realtime_table 구성 │
├─────────────────────────────┬────────────────────────────────┤
│ [card: 빌더] │ [card: 수동 추가] │
│ ┌────────────────────────┐ │ ┌──────────────────────────┐ │
│ │ 카드 제목 │ │ │ Node ID 직접 입력 │ │
│ │ 조건으로 테이블 작성 │ │ │ [inp] │ │
│ │ │ │ │ [btn] 추가 │ │
│ │ 그룹 카드 × 5개 │ │ │ [log] │ │
│ │ · 컨트롤러 #1 │ │ └──────────────────────────┘ │
│ │ · 아날로그 모니터 #2 │ │ [card: 구독 제어] │
│ │ · 디지털 #1 │ │ ┌──────────────────────────┐ │
│ │ · 디지털 #2 │ │ │ 서버IP/포트/계정/비번 │ │
│ │ · 사용자 정의 │ │ │ [btn] ▶ 구독 시작 │ │
│ │ ┌─────────────────┐ │ │ │ [btn] ■ 구독 중지 │ │
│ │ │ 태그명 패턴(inp)│ │ │ │ [btn] 상태 확인 │ │
│ │ │ [pv] [op] [sp] │ │ │ │ [log: 상태] │ │
│ │ │ [md] 체크박스 │ │ │ └──────────────────────────┘ │
│ │ │ [추가속성1][추가2]│ │ │ │
│ │ │ [데이터타입 선택] │ │ │ [card: 메타데이터 관리] │
│ │ └─────────────────┘ │ │ ┌──────────────────────────┐ │
│ │ │ │ │ [btn] 🔄 메타데이터 갱신 │ │
│ │ [btn] 미리보기 │ │ │ [btn] 📋 메타데이터 조회 │ │
│ │ [btn] 테이블 작성하기 │ │ │ [log] │ │
│ │ [btn] 테이블 조회 │ │ │ [view: metadata table] │ │
│ │ │ │ └──────────────────────────┘ │
│ │ [preview: 미리보기] │ │ │
│ │ ┌────────────────┐ │ │ [card: Sub-Area 관리] │
│ │ │ 전체선택/해제 │ │ │ ┌──────────────────────────┐ │
│ │ │ 역전 | 검색창 │ │ │ │ Area 선택 | 조회 │ │
│ │ │ ┌─── 테이블 ──┐│ │ │ │ Seed DryRun | Seed 실행 │ │
│ │ │ │ ☑ ID TagNam ││ │ │ │ [log] │ │
│ │ │ │ ☑ 1 ti-6101 ││ │ │ │ [view: subarea table] │ │
│ │ │ │ ☑ 2 pi-6102 ││ │ │ └──────────────────────────┘ │
│ │ │ └─────────────┘│ │ │ │
│ │ │ [취소][적용][추가]│ │ │ │
│ │ └────────────────┘ │ │ │
│ └────────────────────────┘ │ │
├─────────────────────────────┴────────────────────────────────┤
│ [card: 포인트 목록 (전체 width)] │
│ ┌──────────────────────────────────────────────────────────┐│
│ │ ID | TagName | LiveValue | Timestamp | 이력/삭제││
│ │ 1 | TI-6101.PV | 25.3 | 06-08 12:34 | ☐이력 ✕ ││
│ │ 2 | FICQ-6113.PV | 152.7 | 06-08 12:34 | ☐이력 ✕ ││
│ └──────────────────────────────────────────────────────────┘│
└──────────────────────────────────────────────────────────────┘
```
### 4.1 그룹 카드 (pb-group-card)
총 5개의 그룹 카드가 존재하며, 각 카드는 동일한 구조:
| 요소 | CSS 셀렉터 | 설명 |
|------|-----------|------|
| 헤더 | `.pb-group-header > .card-sub-cap` | 그룹명 (컨트롤러 포인트 #1, 아날로그 모니터링 포인트 #2, ...) |
| 태그 패턴 | `input[data-group="{key}"][data-field="tagPatterns"]` | 쉼표 구분 LIKE 패턴. placeholder에 예시 표시 |
| 속성 체크박스 | `.pb-attr-checkboxes > label > input[type=checkbox]` | pv/op/sp/md 4개. `data-group`, `data-field="attributes"` |
| 커스텀 속성 | `.pb-custom-attr-inputs > input[data-field="customAttrs"]` | 추가 속성 텍스트 입력 2개 (선택) |
| 데이터타입 선택 | `select[data-field="dataType"]` | Double / i=7594 / Boolean / String / Int16 / Int32 / UInt16 / UInt32 / Float / DateTime + "(전체)" |
data 속성으로 그룹 연결:
- `data-group`: `controller1` / `analogmon1` / `digital1` / `digital2` / `custom`
- `data-field`: 식별
### 4.2 미리보기 영역 (pb-preview)
- 초기 상태: `.hidden` 클래스로 숨김
- "미리보기" 버튼 클릭 시 표시
- 헤더: "미리보기 결과 (N개)" + 액션 버튼들 (전체 선택 / 전체 해제 / 역전 / 선택: X/Y)
- 검색창: 태그명/NodeId/Name 실시간 필터링 (oninput)
- 테이블: ☑ 선택체크박스 | # | TagName | NodeType | DataType | Group
- 선택되지 않은 행: `opacity: 0.5`
- Group 뱃지: `.group-badge` (파란색 배경)
- 하단 버튼: [취소] [✓ 선택된 포인트 적용하기] [ 기존 데이터에 추가하기]
### 4.3 포인트 목록 (하단, 전체 폭)
- 빈 상태: "포인트가 없습니다. 위에서 테이블을 작성하세요." (회색)
- 테이블: ID | TagName (uppercase, bold) | LiveValue (fmtVal+parseEnumPv 적용) | Timestamp (fmtTs 적용) | 이력체크박스 / 삭제
- LiveValue가 null이면 `—` 표시
- 삭제 버튼: 붉은색 `✕`, confirm dialog 표시
- "이력" 체크박스 체크 시: ⚠️ 이력(history_table)까지 영구 삭제 메시지
- 미체크 시: 이력 보존 안내
- 삭제 완료 후 alert: "삭제 완료: TI-6101 (#1) (메타데이터 정리됨 · 이력 1,440행 삭제)" 등의 상세 정보
### 4.4 실시간 구독 제어
4개 필드 입력: server IP / port / client host / 계정 / 비밀번호 (password type)
- 2×2 CSS grid (비밀번호는 `grid-column: 1/-1` 전체 폭)
- 버튼: ▶ 구독 시작 / ■ 구독 중지 / 상태 확인
- 구독 상태 표시: logbox에 running 여부 + 구독 포인트 개수
### 4.5 Sub-Area 관리
- Area 선택 드롭다운: P6 / P9 / P10 / P1 / P2
- 버튼: 📋 조회 / 🧪 Seed DryRun / ⚙️ Seed 실행
- 조회 결과: 테이블 (BaseTag | Description | Sub-Area select)
- Sub-Area: `<select>`에 area별 옵션(예: P6-1, P6-2, P6-1,P6-2(공용)) + 현재값 표시
- 변경 시 onchange → PUT `/api/tags/sub-area`
---
## 5. 데이터 흐름
### 5.1 미리보기 → 적용 플로우
```
[사용자] 그룹 카드에 패턴/속성 입력
pbPreview() → POST /api/pointbuilder/preview
{ items: [{nodeId, tagName, name, dataType, group}] }
pbPreviewData 배열 생성 (각 item에 selected:true, idx 추가)
pbRenderPreview() → 체크박스 있는 테이블 렌더링
[사용자] 체크박스 조작 / 검색 필터 / 선택/해제
pbApplySelected() / pbAppendSelected()
POST /api/pointbuilder/apply (또는 /append)
body: { selectedNodeIds: [...] }
DB TRUNCATE + INSERT (apply) / 중복제외 INSERT (append)
pbRefresh() → 포인트 목록 재조회
포인트 목록 갱신 + 실시간 상태 갱신
```
### 5.2 그룹 데이터 수집 (pbCollectGroupData)
```javascript
function pbCollectGroupData(groupKey) {
const tagPatterns = document.querySelector(`input[data-group="${groupKey}"][data-field="tagPatterns"]`)
.value.split(',').map(s => s.trim()).filter(Boolean);
const checkedAttrs = Array.from(
document.querySelectorAll(`input[data-group="${groupKey}"][data-field="attributes"]:checked`)
).map(cb => cb.value);
const customInputs = document.querySelectorAll(`input[data-group="${groupKey}"][data-field="customAttrs"]`);
customInputs.forEach(inp => {
if (inp.value.trim()) checkedAttrs.push(inp.value.trim());
});
const dataType = document.querySelector(`select[data-group="${groupKey}"][data-field="dataType"]`)?.value || null;
return { tagPatterns, attributes: checkedAttrs, dataType };
}
```
### 5.3 삭제 플로우
```
[사용자] "이력" 체크박스 선택 여부 → confirm 대화상자
pbDelete(id, tagName) → DELETE /api/pointbuilder/{id}?purgeHistory={bool}
{ success, baseTag, metadataPurged, historyRowsDeleted }
alert("삭제 완료: TI-6101.PV (메타데이터 정리됨 · 이력 1,440행 삭제)")
pbRefresh() → 목록 갱신
```
---
## 6. UI 상태 관리
### 6.1 전역 상태 (core.js)
| 함수 | 용도 |
|------|------|
| `setGlobal(type, msg)` | 하단 상태바 업데이트. type: 'busy'/'ok'/'err' |
| `esc(str)` | HTML 이스케이프 |
| `api(method, url, body?)` | fetch wrapper + JSON 파싱 |
| `fmtVal(val)` | 숫자 포맷팅 |
| `fmtTs(iso)` | UTC ISO → KST locale 문자열 변환 |
| `parseEnumPv(val)` | enum 값 파싱 |
| `log(elId, entries)` | logbox에 메시지 추가. entries: `[{c: 'ok'|'err'|'inf', t: 'msg'}]` |
| `setGlobal('busy', '포인트 빌드 중')` | 빌드/적용 중 busy 표시 |
| `setGlobal('ok', '구독 중')` | 성공 표시 |
### 6.2 지역 상태 (pb.js)
```javascript
const PB_GROUPS = ['controller1', 'analogmon1', 'digital1', 'digital2', 'custom'];
let pbPreviewData = []; // [{ nodeId, tagName, name, dataType, group, selected, idx }]
```
---
## 7. CSS 구조
### 7.1 pb.css 전용 스타일
```css
.pb-group-card /* 그룹 카드 배경/테두리 */
.pb-group-header /* 그룹 헤더 flex */
.pb-pattern-input /* 패턴 입력창 full width */
.pb-attr-checkboxes /* 체크박스 가로 배치 */
.pb-custom-attr-inputs /* 커스텀 속성 입력 flex */
.pb-datatype-select /* 데이터타입 선택 (max-width 260px) */
.pb-preview /* 미리보기 영역 */
.pb-preview-header /* 미리보기 헤더 */
.pb-preview-actions /* 액션 버튼 flex */
.pb-preview table th:first-child, td:first-child /* 체크박스 컬럼 36px */
.pb-preview .group-badge /* 그룹명 뱃지 */
```
### 7.2 공유 CSS 클래스 (style.css)
| 클래스 | 용도 |
|--------|------|
| `.card` | 카드 컨테이너 |
| `.card-cap` | 섹션 제목 |
| `.card-sub-cap` | 그룹 서브제목 |
| `.fg` | 폼 그룹 (label + input) |
| `.inp` | 입력 필드 |
| `.btn-a` | 주요 버튼 (파랑) |
| `.btn-b` | 보조 버튼 (테두리) |
| `.btn-sm` | 작은 버튼 |
| `.btn-row` | 버튼 행 flex |
| `.cols-2` | 2열 CSS grid |
| `.tbl-wrap` | 테이블 래퍼 (overflow-x: auto) |
| `.logbox` | 로그 출력 영역 |
| `.hidden` | display: none !important |
| `.mut` | 회색 텍스트 |
| `.mono` | 고정폭 폰트 |
---
## 8. DB 테이블 구조 (참조)
### 8.1 node_map_master (소스)
컬럼: `Id, Level, NodeClass, Name, NodeId, DataType, DisplayName, HasChildren`
PointBuilder 쿼리 조건: `Level = 3` (Variable), `NodeId LIKE`, `Name IN`, `DataType =`
### 8.2 realtime_table (대상)
컬럼: `Id, TagName, NodeId, LiveValue, Timestamp`
`TagName` = `node_map_master.NodeId` 에서 `ns=1;s=` prefix 제거 후 `.` 포함 전체 (예: `ti-6101.pv`)
### 8.3 tag_metadata
컬럼: `Id, BaseTag, Attribute, Value, LoadedAt`
EAV 패턴: `attribute` = `desc` / `area` / `sub_area` / `state0descriptor` ~ `state7descriptor`
---
## 9. 중요 구현 규칙
### 9.1 JSON camelCase 필수
`PropertyNamingPolicy = null`이므로 C# DTO의 PascalCase가 그대로 JSON 키가 된다.
프론트는 **모든 응답을 camelCase로 가정**하므로 Controller의 Ok()는 반드시 익명 객체에 camelCase 키를 명시:
```csharp
// ✅ 올바름
return Ok(new { success = true, count = x, items = ... });
// ❌ 금지 (JS가 undefined 받음)
return Ok(new { Success = true, Count = x });
return Ok(myDto); // typed DTO 그대로 반환 금지
```
### 9.2 미리보기 데이터의 selected 상태
프론트에서만 관리: `pbPreviewData[i].selected` (초기값 `true`). 서버에 저장하지 않음.
### 9.3 삭제 시 "이력" 체크박스 의미
- 기본값: `false` (이력 보존, `purgeHistory=false`)
- 체크 시: `purgeHistory=true``DELETE FROM history_table WHERE tagname = ?` (복구 불가)
- `confirm()` 대화상자에 미리 경고문 표시
### 9.4 메타데이터 자동 로드 실패 허용
Build/Apply/Append 후 메타데이터 로드는 실패해도 Warning 로그만 남기고 사용자에게는 빌드 성공으로 표시.
프론트 응답의 `metaCount` 필드로 정보 제공.
### 9.5 수동 추가 시 OPC UA 검증
수동 추가 → `AddMonitoredItemAsync()`에서 OPC UA 서버가 NodeId를 거부하면:
1. DB에 먼저 INSERT
2. OPC UA 검증 실패 시 `DeleteRealtimePointAsync()`로 롤백
3. 프론트에 실패 메시지 반환
### 9.6 Sub-Area 토큰 매칭
공용 설비는 `"P6-1,P6-2"` 형식으로 저장. 매칭은 항상:
```sql
'P6-1' = ANY(string_to_array(value, ','))
```
직접 비교 (`value = 'P6-1'`) 금지 — 공용 태그 누락 방지.
---
## 10. 전체 함수 목록 (pb.js)
| 함수 | 설명 |
|------|------|
| `pbCollectGroupData(groupKey)` | 그룹 카드 데이터 수집 → `{tagPatterns, attributes, dataType}` |
| `pbBuild()` | POST /build (전체 교체) |
| `pbPreview()` | POST /preview + 미리보기 렌더링 |
| `pbRenderPreview(data)` | 미리보기 테이블 렌더링 |
| `pbPreviewToggleItem(idx)` | 개별 체크박스 토글 |
| `pbPreviewToggleAll(checked)` | 전체 선택/해제 |
| `pbPreviewSelectAll()` | 모두 선택 |
| `pbPreviewDeselectAll()` | 모두 해제 |
| `pbPreviewInvert()` | 역전 |
| `pbGetFilteredPreview()` | 검색어로 필터링 |
| `pbPreviewFilter()` | oninput 핸들러 |
| `pbUpdatePreviewCount()` | 선택 개수 업데이트 |
| `pbCancelPreview()` | 미리보기 닫기 + 데이터 초기화 |
| `pbApplySelected()` | POST /apply (선택만 적용) |
| `pbAppendSelected()` | POST /append (선택만 추가) |
| `pbRefresh()` | GET /points → 목록 갱신 |
| `pbRender(points)` | 포인트 목록 테이블 렌더링 |
| `pbAddManual()` | POST /add (수동 추가) |
| `pbDelete(id, tagName)` | DELETE (이력 체크 포함) |
| `rtStart()` | POST /realtime/start |
| `rtStop()` | POST /realtime/stop |
| `rtStatus()` | GET /realtime/status |
| `metaReload()` | POST /tags/metadata/reload |
| `metaView()` | GET /tags/metadata |
| `subAreaLoad()` | GET /tags/sub-area |
| `subAreaUpdate(baseTag, subArea)` | PUT /tags/sub-area |
| `subAreaSeed(dryRun)` | POST /tags/sub-area/seed |
| `subAreaLabel(code)` | sub_area 코드 → 라벨 변환 |
---
## 11. HTML/JS/CSS 파일 목록
| 파일 | 역할 |
|------|------|
| `src/Web/wwwroot/index.html` | shell — nav item #06 "포인트빌더", pane #pane-pb, script 로드 |
| `src/Web/wwwroot/panes/pb.html` | pane HTML (311 lines) |
| `src/Web/wwwroot/js/pb.js` | pane JS (503 lines) |
| `src/Web/wwwroot/css/pb.css` | pane CSS (106 lines) |
| `src/Web/Controllers/ExperionControllers.cs` | `ExperionPointBuilderController` (190 lines) |
| `src/Core/Application/DTOs/ExperionDtos.cs` | `PointBuilderGroupDto`, `PointBuilderBuildDto`, `PointBuilderPreviewItem`, `PointBuilderPreviewResult`, `PointBuilderApplyDto`, `PointBuilderAddDto` |
| `src/Core/Application/DTOs/SubAreaDtos.cs` | `PointDeleteResult` |
| `src/Infrastructure/Database/ExperionDbContext.cs` | `BuildRealtimeTableAsync`, `PreviewRealtimeBuildAsync`, `ApplySelectedPointsAsync`, `AppendPointsAsync`, `GetRealtimePointsAsync`, `AddRealtimePointAsync`, `DeleteRealtimePointAsync` |
| `src/Core/Application/Interfaces/IExperionServices.cs` | `IExperionDbService` (lines 82-92) |
| `src/Web/wwwroot/js/core.js` | 공유 유틸: `api`, `esc`, `setGlobal`, `fmtVal`, `fmtTs`, `parseEnumPv`, `log` |
| `src/Web/wwwroot/css/style.css` | 디자인 시스템: 카드, 폼, 버튼, 테이블, 로그박스, CSS 커스텀 속성 |