opencode 로 바꾸고 작업전 커밋
This commit is contained in:
205
dxf-graph/Concept-P&ID Graph Pipeline.md
Normal file
205
dxf-graph/Concept-P&ID Graph Pipeline.md
Normal file
@@ -0,0 +1,205 @@
|
||||
✔ 🎯 End-to-End P&ID Graph Pipeline (실전 구조)
|
||||
|
||||
┌──────────────────────┐
|
||||
│ P&ID PDF Input │
|
||||
└─────────┬────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────┐
|
||||
│ 1. Document Parsing Layer │
|
||||
│ (layout + text + tables) │
|
||||
└─────────┬──────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────┐
|
||||
│ 2. Spatial Element Extraction │
|
||||
│ (symbols + coordinates) │
|
||||
└─────────┬──────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────┐
|
||||
│ 3. Entity Extraction (LLM) │
|
||||
│ FIC-101, Pump-01, Valve... │
|
||||
└─────────┬──────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────┐
|
||||
│ 4. Relationship Inference │
|
||||
│ (rules + LLM hybrid) │
|
||||
└─────────┬──────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────┐
|
||||
│ 5. Graph Builder │
|
||||
│ nodes + edges │
|
||||
└─────────┬──────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────┐
|
||||
│ 6. DB Integration Layer │
|
||||
│ (existing OPC + SQL system) │
|
||||
└─────────────────────────────────┘
|
||||
|
||||
|
||||
|
||||
✔ 1️⃣ Document Parsing Layer (PDF → 구조화)
|
||||
기술
|
||||
|
||||
Unstructured
|
||||
|
||||
역할
|
||||
텍스트 추출
|
||||
표 추출
|
||||
블록 segmentation
|
||||
page coordinate 유지
|
||||
출력 예시
|
||||
|
||||
{
|
||||
"page": 12,
|
||||
"elements": [
|
||||
{
|
||||
"text": "FIC-101",
|
||||
"bbox": [120, 300, 160, 320]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
👉 핵심: 좌표 반드시 유지
|
||||
|
||||
✔ 2️⃣ Spatial Element Extraction (핵심 단계)
|
||||
|
||||
여기서 P&ID가 살아난다.
|
||||
|
||||
해야 할 것
|
||||
symbol detection
|
||||
line detection
|
||||
proximity mapping
|
||||
결과
|
||||
JSON
|
||||
{
|
||||
"FIC-101": { "x": 120, "y": 300 },
|
||||
"FT-101": { "x": 110, "y": 220 },
|
||||
"Valve-203": { "x": 300, "y": 310 }
|
||||
}
|
||||
|
||||
|
||||
|
||||
✔ 3️⃣ Entity Extraction (LLM)
|
||||
모델
|
||||
|
||||
Qwen (via vLLM)
|
||||
|
||||
입력
|
||||
Extract all P&ID entities:
|
||||
- controller
|
||||
- sensor
|
||||
- valve
|
||||
- pump
|
||||
출력
|
||||
[
|
||||
{"name":"FIC-101","type":"controller"},
|
||||
{"name":"FT-101","type":"sensor"},
|
||||
{"name":"Valve-203","type":"valve"}
|
||||
]
|
||||
|
||||
|
||||
|
||||
✔ 4️⃣ Relationship Inference (가장 중요)
|
||||
|
||||
여기서 2개를 섞는다:
|
||||
|
||||
A. Rule Engine (필수)
|
||||
if sensor near controller:
|
||||
relation = "signal"
|
||||
|
||||
if controller connected to valve:
|
||||
relation = "control"
|
||||
|
||||
if pump → tank:
|
||||
relation = "flow"
|
||||
|
||||
|
||||
B. LLM 보조 판단
|
||||
Determine relationship based on P&ID context:
|
||||
Entities + coordinates
|
||||
최종 출력
|
||||
[
|
||||
{
|
||||
"source": "FT-101",
|
||||
"target": "FIC-101",
|
||||
"relation": "signal",
|
||||
"confidence": 0.93
|
||||
},
|
||||
{
|
||||
"source": "FIC-101",
|
||||
"target": "Valve-203",
|
||||
"relation": "control",
|
||||
"confidence": 0.91
|
||||
}
|
||||
]
|
||||
✔ 5️⃣ Graph Builder
|
||||
nodes = set()
|
||||
edges = []
|
||||
|
||||
for r in relations:
|
||||
nodes.add(r["source"])
|
||||
nodes.add(r["target"])
|
||||
edges.append(r)
|
||||
Graph 구조
|
||||
{
|
||||
"nodes": [...],
|
||||
"edges": [...]
|
||||
}
|
||||
✔ 6️⃣ DB Integration (너 기존 시스템)
|
||||
|
||||
여기서 너 강점:
|
||||
|
||||
OPC UA 있음 ✔
|
||||
NL2SQL 있음 ✔
|
||||
DB 있음 ✔
|
||||
저장 전략
|
||||
P&ID Graph DB
|
||||
↓
|
||||
Mapping Layer
|
||||
↓
|
||||
OPC Tags / SQL semantic layer
|
||||
|
||||
|
||||
✔ 🔥 핵심 설계 포인트 (진짜 중요)
|
||||
❗ 1. LLM은 “결정자”가 아니다
|
||||
|
||||
👉 “해석 보조” 역할
|
||||
|
||||
❗ 2. 좌표가 핵심이다
|
||||
|
||||
P&ID는 텍스트가 아니라:
|
||||
|
||||
공간 + 연결 구조
|
||||
|
||||
❗ 3. Rule Engine 없으면 실패한다
|
||||
|
||||
LLM 단독 = hallucination
|
||||
|
||||
|
||||
✔ 📌 최종 아키텍처 (완성형)
|
||||
P&ID PDF
|
||||
↓
|
||||
Unstructured Parser
|
||||
↓
|
||||
Spatial Engine
|
||||
↓
|
||||
Qwen (Entity Extraction)
|
||||
↓
|
||||
Rule Engine (relationship)
|
||||
↓
|
||||
Graph Builder
|
||||
↓
|
||||
DB + OPC Integration
|
||||
✔ 🚀 이 구조의 의미
|
||||
|
||||
이건 단순 기능이 아니라:
|
||||
|
||||
👉 “Engineering Knowledge Graph 생성 엔진”
|
||||
|
||||
✔ 다음 단계 (진짜 실전)
|
||||
|
||||
원하면 여기서 바로 이어서:
|
||||
|
||||
1️⃣ P&ID 실제 PDF 기준 코드
|
||||
2️⃣ Rule engine 상세 설계 (ISA 기반)
|
||||
3️⃣ Qwen prompt 최적화 (산업용)
|
||||
4️⃣ Graph DB vs SQL 구조 선택
|
||||
220
dxf-graph/Graph_Pipeline_Phase1.md
Normal file
220
dxf-graph/Graph_Pipeline_Phase1.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# 🛠️ Graph Pipeline Phase 1: 기하학적 데이터 추출 (Geometric Extraction)
|
||||
|
||||
이 문서는 P&ID Graph Pipeline의 첫 번째 단계인 **기하학적 데이터 추출**의 상세 구현 계획을 다룹니다. 목표는 단순한 텍스트 추출을 넘어, 도면 내 모든 객체의 **물리적 위치(좌표)**와 **기하학적 속성**을 보존하여 이후 위상 모델링(Topology Modeling)이 가능하도록 하는 것입니다.
|
||||
|
||||
---
|
||||
|
||||
## 📦 1. 필수 패키지 및 환경 설정
|
||||
|
||||
### 1.1 Python 패키지
|
||||
| 패키지 | 용도 | 비고 |
|
||||
|---|---|---|
|
||||
| `ezdxf` | DXF 파일 파싱 및 엔티티 추출 | 핵심 라이브러리 |
|
||||
| `shapely` | 기하학적 연산 (Intersection, Distance, Bounding Box) | 좌표 기반 분석 필수 |
|
||||
| `numpy` | 대량의 좌표 데이터 계산 및 행렬 연산 | 성능 최적화 |
|
||||
| `pandas` | 추출된 객체 데이터의 구조화 및 CSV/JSON 저장 | 데이터 관리 |
|
||||
| `pydantic` | 추출 데이터의 스키마 정의 및 유효성 검증 | 데이터 무결성 보장 |
|
||||
| `pytesseract` / `pdf2image` | PDF 도면의 영역 기반 OCR 추출 | PDF 처리 시 필요 |
|
||||
|
||||
### 1.2 설치 명령어
|
||||
```bash
|
||||
pip install ezdxf shapely numpy pandas pydantic pytesseract pdf2image
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📐 2. 상세 설계 구조
|
||||
|
||||
### 2.1 데이터 모델 (Schema)
|
||||
모든 추출 객체는 다음과 같은 공통 속성을 갖는 `GeometricEntity` 모델을 따릅니다.
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional, Union, Tuple
|
||||
|
||||
class BoundingBox(BaseModel):
|
||||
min_x: float
|
||||
min_y: float
|
||||
max_x: float
|
||||
max_y: float
|
||||
center: Tuple[float, float]
|
||||
|
||||
class GeometricEntity(BaseModel):
|
||||
entity_id: str
|
||||
entity_type: str # TEXT, LINE, CIRCLE, POLYLINE, ARC
|
||||
layer: str
|
||||
bbox: BoundingBox
|
||||
properties: dict # 텍스트 값, 색상, 선 굵기 등
|
||||
coordinates: List[Tuple[float, float]] # 시작점, 끝점 또는 정점 리스트
|
||||
```
|
||||
|
||||
### 2.2 처리 파이프라인 흐름
|
||||
1. **DXF Load:** `ezdxf.readfile()`을 통해 도면 로드.
|
||||
2. **Entity Iteration:** 모든 레이어의 엔티티를 순회하며 타입별 분류.
|
||||
3. **Coordinate Extraction:**
|
||||
* `TEXT`: 삽입점(Insertion Point) 및 텍스트 길이를 이용한 BBox 계산.
|
||||
* `LINE`: 시작점(Start)과 끝점(End) 추출.
|
||||
* `POLYLINE`: 모든 정점(Vertices) 리스트 추출.
|
||||
* `CIRCLE/ARC`: 중심점(Center)과 반지름(Radius) 추출.
|
||||
4. **Spatial Normalization:** 도면 좌표계를 분석 시스템 좌표계로 정규화.
|
||||
5. **Structured Export:** JSON 또는 DB(PostgreSQL/PostGIS)에 저장.
|
||||
|
||||
---
|
||||
|
||||
## 💻 3. 실제 구현 코딩 가이드 (Example)
|
||||
|
||||
### 3.1 DXF 기하학적 추출 핵심 코드
|
||||
```python
|
||||
import ezdxf
|
||||
import re
|
||||
import json
|
||||
from shapely.geometry import box, LineString, Point
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
class PidGeometricExtractor:
|
||||
def __init__(self, file_path: str):
|
||||
self.doc = ezdxf.readfile(file_path)
|
||||
self.msp = self.doc.modelspace()
|
||||
|
||||
def clean_text(self, text: str) -> str:
|
||||
"""DXF 특수 제어 문자 및 MTEXT 포맷팅을 최대한 제거하여 LLM 토큰 부하 감소"""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# 1. MTEXT 포맷팅 및 제어 문자 제거
|
||||
# \P(줄바꿈), \W(너비), \L(밑줄), \A(정렬), \C(색상), \H(높이), \S(스택), \T(탭) 및 관련 인자 제거
|
||||
text = re.sub(r'\\([P|W|L|A|C|H|S|T])\d*;?', ' ', text)
|
||||
|
||||
# 2. 중괄호 { } 제거 (MTEXT에서 서식 지정 시 사용됨)
|
||||
text = re.sub(r'[\{\}]', ' ', text)
|
||||
|
||||
# 3. DXF 특수 제어 문자 제거 (%%U: Underline, %%O: Overline, %%S: Strikethrough, %%R: Registered)
|
||||
text = re.sub(r'%%[U|O|S|R]', ' ', text)
|
||||
|
||||
# 4. 불필요한 특수 기호 및 반복되는 공백 정제
|
||||
# - 연속된 공백을 하나로 통합
|
||||
# - 텍스트 양 끝의 공백 제거
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
|
||||
return text
|
||||
|
||||
def get_bbox(self, entity) -> Optional[box]:
|
||||
"""엔티티의 Bounding Box를 계산하여 shapely box 객체로 반환"""
|
||||
try:
|
||||
if entity.dxftype() == 'TEXT':
|
||||
p = entity.dxf.insert
|
||||
h = entity.dxf.height
|
||||
# 텍스트 길이에 따른 대략적인 너비 계산 (글자수 * 높이 * 0.6)
|
||||
width = len(entity.dxf.text) * h * 0.6
|
||||
return box(p.x, p.y, p.x + width, p.y + h)
|
||||
|
||||
elif entity.dxftype() == 'MTEXT':
|
||||
p = entity.dxf.insert
|
||||
h = entity.dxf.char_height if hasattr(entity.dxf, 'char_height') else 2.5
|
||||
# MTEXT는 보통 width 속성이 정의되어 있음
|
||||
w = entity.dxf.width if entity.dxf.width > 0 else len(entity.text) * h * 0.6
|
||||
return box(p.x, p.y, p.x + w, p.y + h)
|
||||
|
||||
elif entity.dxftype() == 'LINE':
|
||||
start = entity.dxf.start
|
||||
end = entity.dxf.end
|
||||
return box(min(start.x, end.x), min(start.y, end.y),
|
||||
max(start.x, end.x), max(start.y, end.y))
|
||||
|
||||
elif entity.dxftype() == 'LWPOLYLINE':
|
||||
points = entity.get_points()
|
||||
xs = [p[0] for p in points]
|
||||
ys = [p[1] for p in points]
|
||||
return box(min(xs), min(ys), max(xs), max(ys))
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error calculating bbox for {entity.dxftype()}: {e}")
|
||||
return None
|
||||
|
||||
def extract_and_save(self, output_path: str):
|
||||
"""
|
||||
추출된 기하학적 데이터를 파일로 저장하여 Phase 3 Worker들이
|
||||
공유 메모리/파일 시스템을 통해 참조할 수 있도록 함 (Phase 5 병렬 아키텍처 반영)
|
||||
"""
|
||||
results = []
|
||||
for entity in self.msp:
|
||||
bbox_obj = self.get_bbox(entity)
|
||||
if bbox_obj:
|
||||
# 텍스트 값 추출 및 정제
|
||||
raw_text = ""
|
||||
if entity.dxftype() == 'TEXT':
|
||||
raw_text = entity.dxf.text
|
||||
elif entity.dxftype() == 'MTEXT':
|
||||
raw_text = entity.text
|
||||
|
||||
results.append({
|
||||
"id": entity.dxf.handle,
|
||||
"type": entity.dxftype(),
|
||||
"layer": entity.dxf.layer,
|
||||
"bbox": {
|
||||
"min_x": bbox_obj.bounds[0],
|
||||
"min_y": bbox_obj.bounds[1],
|
||||
"max_x": bbox_obj.bounds[2],
|
||||
"max_y": bbox_obj.bounds[3]
|
||||
},
|
||||
"raw_value": raw_text,
|
||||
"clean_value": self.clean_text(raw_text) if raw_text else None,
|
||||
"coordinates": entity.get_points() if hasattr(entity, 'get_points') else []
|
||||
})
|
||||
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(results, f, ensure_ascii=False, indent=4)
|
||||
|
||||
return output_path
|
||||
|
||||
# 사용 예시 (Phase 5 Orchestrator 관점)
|
||||
extractor = PidGeometricExtractor("plant_drawing.dxf")
|
||||
# 데이터를 직접 반환받지 않고 공유 저장소(파일)에 적재
|
||||
geo_data_path = extractor.extract_and_save("shared_geo_data.json")
|
||||
```
|
||||
|
||||
### 3.2 유틸리티 함수: 인접성 체크 (Proximity Utility)
|
||||
추후 2단계(위상 모델링)에서 사용할 핵심 유틸리티입니다.
|
||||
|
||||
```python
|
||||
from shapely.geometry import Point
|
||||
|
||||
def is_near(entity_a_bbox, entity_b_bbox, threshold=5.0):
|
||||
"""두 객체의 Bounding Box 간의 최단 거리가 임계값 이내인지 확인"""
|
||||
return entity_a_bbox.distance(entity_b_bbox) <= threshold
|
||||
|
||||
def is_inside(point, bbox):
|
||||
"""특정 점이 Bounding Box 내부에 있는지 확인"""
|
||||
return bbox.contains(Point(point))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 4. Phase 1 완료 기준 (Definition of Done)
|
||||
|
||||
- [ ] DXF 파일 내 모든 `TEXT`, `LINE`, `POLYLINE`의 좌표 데이터가 누락 없이 추출되는가?
|
||||
- [ ] 각 객체별로 정확한 `Bounding Box`가 계산되어 저장되는가?
|
||||
- [ ] 추출된 데이터가 `GeometricEntity` 스키마에 맞게 JSON 파일로 저장되어 Worker들이 공유 참조 가능한가? (Phase 5 반영)
|
||||
- [ ] (선택 사항) PDF 도면의 경우 OCR을 통해 텍스트의 좌표값이 추출되는가?
|
||||
|
||||
---
|
||||
|
||||
## 🧐 감독자 진단 결과 (2026-05-02)
|
||||
|
||||
### 1. 프로그램 설계 점검
|
||||
- **강점**: `ezdxf`와 `shapely`를 조합하여 기하학적 데이터(BBox, 좌표)를 보존하려는 접근 방식이 매우 적절함. 특히 Phase 5의 병렬 아키텍처를 고려하여 데이터를 파일/공유 저장소에 적재하는 구조는 확장성 면에서 우수함.
|
||||
- **보완 필요 사항**:
|
||||
- **MTEXT 처리**: 현재 예시 코드(`3.1`)는 `TEXT` 엔티티만 처리하고 있으나, 실제 DXF 파일 분석 결과 `MTEXT` 엔티티가 다수 존재함. `MTEXT`는 내부 포맷팅 코드(예: `\P`, `\W`)가 포함되어 있어 단순 텍스트 추출 시 정제가 필요함.
|
||||
- **BBox 계산 정밀도**: `TEXT` 엔티티의 BBox를 `p.x + 10, p.y + 5`와 같이 상수로 처리하고 있음. 실제 도면의 폰트 크기(`height`)와 정렬 방식(`align`)을 반영한 동적 계산 로직이 반드시 추가되어야 함.
|
||||
|
||||
### 2. 실제 도면(`No-10_Plant_PID.dxf`) 분석 기반 차이점
|
||||
- **엔티티 규모**: 총 28,819개의 엔티티가 존재하여 데이터 양이 상당함. 단순 리스트 저장보다는 인덱싱 전략이 필요할 수 있음.
|
||||
- **텍스트 복잡도**:
|
||||
- `MTEXT` 내에 `\P` (줄바꿈), `\L` (밑줄) 등 제어 문자가 포함된 수정 사항(Revision) 텍스트가 많음. 이를 그대로 추출하면 위상 분석 시 노이즈가 될 가능성이 높음.
|
||||
- `%%U` (Underline)와 같은 DXF 특수 제어 문자가 텍스트 값에 포함되어 있어, 이를 제거하는 전처리 과정이 필수적임.
|
||||
- **데이터 특성**: `IA-10922-25A-F1A-n`와 같은 복합 파이프라인 번호(Pipe Line Number) 형식이 확인됨. 이를 일반 태그(Tag Name)와 명확히 구분하여 추출하고 관리하는 로직이 Phase 2/3에서 중요하게 작용할 것으로 보임.
|
||||
|
||||
### 3. 최종 권고 사항
|
||||
1. **MTEXT 지원 추가**: `PidGeometricExtractor`에 `MTEXT` 처리 로직을 추가하고, 제어 문자를 제거하는 `clean_text()` 유틸리티 함수를 구현할 것.
|
||||
2. **동적 BBox 구현**: `entity.dxf.height`를 활용하여 텍스트 크기에 맞는 정확한 Bounding Box를 계산하도록 수정할 것.
|
||||
3. **전처리 파이프라인 강화**: 추출 단계에서 `%%U` 등의 특수 문자를 제거하는 정제 단계를 추가하여 데이터 품질을 높일 것.
|
||||
184
dxf-graph/Graph_Pipeline_Phase2.md
Normal file
184
dxf-graph/Graph_Pipeline_Phase2.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# 🕸️ Graph Pipeline Phase 2: 위상 모델링 (Topology Modeling)
|
||||
|
||||
이 문서는 P&ID Graph Pipeline의 두 번째 단계인 **위상 모델링**의 상세 구현 계획을 다룹니다. 1단계에서 추출한 기하학적 객체(좌표, BBox)를 기반으로, 설비 간의 **연결성(Connectivity)**과 **흐름(Flow)**을 정의하는 지식 그래프(Knowledge Graph)를 구축하는 것이 목표입니다.
|
||||
|
||||
---
|
||||
|
||||
## 🚩 [Supervisor's Audit] 진단 결과 및 개선 권고
|
||||
|
||||
**감독자 진단 일자:** 2026-05-02
|
||||
**진단 결과:** ⚠️ **부분적 보완 필요 (Partial Improvement Required)**
|
||||
|
||||
### 🔍 주요 진단 내용
|
||||
1. **연결성 추론의 단순성 (Critical):** 현재 `_find_connected_nodes`가 단순 BBox 교차(`intersects`)만 확인하고 있습니다. 실제 P&ID에서 배관(Line)은 설비의 외곽선에 닿거나 매우 근접한 형태로 나타나며, 단순 BBox 교차는 오탐(False Positive) 확률이 매우 높습니다.
|
||||
2. **방향성 정의 부재 (Medium):** `DiGraph`를 사용하지만, 실제 엣지에 방향성을 부여하는 구체적인 로직(화살표 인식, 공정 흐름 규칙)이 예시 코드에 누락되어 있습니다.
|
||||
3. **임계값 하드코딩 (Low):** `min_dist < 50.0`과 같은 임계값이 하드코딩되어 있어, 도면 스케일(Scale)이 변경될 경우 대응이 불가능합니다.
|
||||
4. **데이터 무결성 검증 부족 (Medium):** 그래프 생성 후 고립된 노드(Isolated Nodes)나 비정상적인 루프에 대한 검증 단계가 없습니다.
|
||||
|
||||
### 🛠️ 수정 및 반영 사항
|
||||
- **연결성 로직 고도화:** BBox 교차 방식에서 $\rightarrow$ **Line End-point 기반 근접 분석** 방식으로 변경.
|
||||
- **방향성 추론 단계 명시:** 화살표 심볼 및 공정 흐름 기반의 `source` $\rightarrow$ `target` 결정 로직 추가.
|
||||
- **설정의 외부화:** 임계값($\epsilon$)을 설정 파일이나 파라미터로 관리하도록 구조 변경.
|
||||
- **검증 단계 추가:** 그래프 구축 후 위상 무결성 검사(Topology Validation) 단계 도입.
|
||||
|
||||
---
|
||||
|
||||
## 📦 1. 필수 패키지 및 환경 설정
|
||||
|
||||
### 1.1 Python 패키지
|
||||
| 패키지 | 용도 | 비고 |
|
||||
|---|---|---|
|
||||
| `networkx` | 그래프 데이터 구조 생성 및 알고리즘 분석 | 핵심 라이브러리 |
|
||||
| `shapely` | 객체 간 거리 계산 및 포함 관계 분석 | 1단계와 연계 |
|
||||
| `scikit-learn` | (선택) KD-Tree를 이용한 고속 근접 이웃 검색 | 대규모 도면 최적화 |
|
||||
| `matplotlib` | 생성된 그래프의 위상 구조 시각화 검증 | 디버깅용 |
|
||||
|
||||
### 1.2 설치 명령어
|
||||
```bash
|
||||
pip install networkx shapely scikit-learn matplotlib
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📐 2. 상세 설계 구조
|
||||
|
||||
### 2.1 그래프 정의 (Graph Definition)
|
||||
* **노드 (Nodes):**
|
||||
* `Equipment`: 펌프, 탱크, 열교환기 등 (속성: ID, 타입, BBox, CenterPoint)
|
||||
* `Instrument`: 전송기, 밸브, 게이지 등 (속성: ID, 타입, BBox, CenterPoint)
|
||||
* `Tag`: 텍스트 기반 태그 (속성: TagName, Value, BBox)
|
||||
* **엣지 (Edges):**
|
||||
* `Pipe`: 설비-설비, 설비-계기 간의 물리적 연결 (속성: LineNumber, 방향성, 연결타입)
|
||||
* `Association`: 태그-설비 간의 논리적 연결 (속성: 관계 타입 - 예: 'belongs_to')
|
||||
|
||||
### 2.2 위상 추론 로직 (Topology Inference)
|
||||
1. **태그-설비 결합 (Tag-to-Entity Binding):**
|
||||
* 태그 텍스트의 BBox와 가장 가까운 심볼(Equipment/Instrument)을 찾아 `Association` 엣지를 생성합니다.
|
||||
2. **배관 연결성 분석 (Line Connectivity) [개선]:**
|
||||
* `LINE` 또는 `POLYLINE`의 **시작점과 끝점(End-points)**을 추출합니다.
|
||||
* 각 끝점이 특정 설비의 BBox 내부에 있거나, 설정된 임계 거리($\epsilon$) 이내에 있을 때만 `Pipe` 엣지로 연결합니다. (단순 BBox 교차 방식 지양)
|
||||
3. **흐름 방향성 부여 (Flow Direction) [추가]:**
|
||||
* 배관 상의 화살표 심볼 위치와 방향을 분석하여 `source` $\rightarrow$ `target`을 결정합니다.
|
||||
* 화살표가 없는 경우, 공정 표준(예: 탱크 $\rightarrow$ 펌프 $\rightarrow$ 밸브)에 따른 기본 방향을 부여합니다.
|
||||
4. **위상 무결성 검증 (Topology Validation) [추가]:**
|
||||
* 연결되지 않은 고립 노드 탐색 및 리포팅.
|
||||
* 비정상적인 사이클(Cycle) 또는 단절 구간 확인.
|
||||
|
||||
---
|
||||
|
||||
## 💻 3. 실제 구현 코딩 가이드 (Example)
|
||||
|
||||
### 3.1 그래프 구축 핵심 코드
|
||||
```python
|
||||
import networkx as nx
|
||||
from shapely.geometry import box, Point, LineString
|
||||
|
||||
class PidTopologyBuilder:
|
||||
def __init__(self, geometric_data, all_extracted_tags=None, config=None):
|
||||
"""
|
||||
- geometric_data: Phase 1에서 추출된 기하학적 데이터
|
||||
- all_extracted_tags: 통합된 태그 리스트
|
||||
- config: {'dist_threshold': 50.0, 'tag_threshold': 100.0} 등 설정값
|
||||
"""
|
||||
self.data = geometric_data
|
||||
self.all_tags = all_extracted_tags if all_extracted_tags else []
|
||||
self.config = config if config else {'dist_threshold': 50.0, 'tag_threshold': 100.0}
|
||||
self.G = nx.DiGraph() # 방향성 그래프 생성
|
||||
|
||||
def build_graph(self):
|
||||
# 1. 모든 객체를 노드로 추가
|
||||
for item in self.data:
|
||||
# Phase 1에서 'clean_value'로 저장했으므로 이를 value로 사용
|
||||
self.G.add_node(item['id'],
|
||||
type=item['type'],
|
||||
bbox=box(*item['bbox'].values()),
|
||||
value=item.get('clean_value'))
|
||||
|
||||
# 2. 분산 추출된 태그 통합 및 노드 추가
|
||||
for tag in self.all_tags:
|
||||
self.G.add_node(tag['id'],
|
||||
type='TEXT',
|
||||
bbox=box(*tag['bbox'].values()),
|
||||
value=tag.get('tagName'))
|
||||
|
||||
# 3. 태그-설비 논리적 연결 (Association)
|
||||
tags = [n for n, d in self.G.nodes(data=True) if d['type'] == 'TEXT']
|
||||
equipments = [n for n, d in self.G.nodes(data=True) if d['type'] != 'TEXT']
|
||||
|
||||
for tag in tags:
|
||||
best_match = self._find_nearest_equipment(tag, equipments)
|
||||
if best_match:
|
||||
self.G.add_edge(tag, best_match, relation='associated_with')
|
||||
|
||||
# 4. 배관 기반 물리적 연결 (Pipe) [개선됨]
|
||||
lines = [n for n, d in self.G.nodes(data=True) if d['type'] in ['LINE', 'POLYLINE']]
|
||||
for line_id in lines:
|
||||
# Phase 1에서 추출한 coordinates를 사용하여 LineString 생성
|
||||
coords = self.G.nodes[line_id].get('coordinates', [])
|
||||
if not coords:
|
||||
continue
|
||||
line_geom = LineString(coords)
|
||||
endpoints = [line_geom.coords[0], line_geom.coords[-1]]
|
||||
|
||||
connected_nodes = []
|
||||
for pt in endpoints:
|
||||
p = Point(pt)
|
||||
for eq_id in equipments:
|
||||
if self.G.nodes[eq_id]['bbox'].distance(p) < self.config['dist_threshold']:
|
||||
connected_nodes.append(eq_id)
|
||||
|
||||
if len(connected_nodes) >= 2:
|
||||
# 방향성 추론 로직 (단순화: 시작점 -> 끝점)
|
||||
self.G.add_edge(connected_nodes[0], connected_nodes[1], relation='pipe')
|
||||
|
||||
def _find_nearest_equipment(self, tag_id, equipment_ids):
|
||||
tag_bbox = self.G.nodes[tag_id]['bbox']
|
||||
min_dist = float('inf')
|
||||
nearest = None
|
||||
for eq_id in equipment_ids:
|
||||
eq_bbox = self.G.nodes[eq_id]['bbox']
|
||||
dist = tag_bbox.distance(eq_bbox)
|
||||
if dist < min_dist:
|
||||
min_dist = dist
|
||||
nearest = eq_id
|
||||
return nearest if min_dist < self.config['tag_threshold'] else None
|
||||
|
||||
def validate_topology(self):
|
||||
"""위상 무결성 검증"""
|
||||
isolated = list(nx.isolates(self.G))
|
||||
return {"isolated_nodes": isolated, "node_count": self.G.number_of_nodes(), "edge_count": self.G.number_of_edges()}
|
||||
|
||||
# 실행 예시
|
||||
all_tags = flatten_results([worker1_res, worker2_res])
|
||||
config = {'dist_threshold': 30.0, 'tag_threshold': 80.0}
|
||||
builder = PidTopologyBuilder(geometric_data, all_extracted_tags=all_tags, config=config)
|
||||
builder.build_graph()
|
||||
validation_res = builder.validate_topology()
|
||||
print(f"Validation Result: {validation_res}")
|
||||
```
|
||||
|
||||
### 3.2 위상 분석 유틸리티: 영향도 분석 (Impact Analysis)
|
||||
```python
|
||||
def analyze_impact(graph, start_node):
|
||||
"""특정 설비 장애 시 하류(Downstream)에 영향을 받는 모든 노드 추출"""
|
||||
# BFS를 통해 도달 가능한 모든 노드 탐색
|
||||
impacted_nodes = nx.descendants(graph, start_node)
|
||||
return list(impacted_nodes)
|
||||
|
||||
# 예: P-101 펌프 고장 시 영향 분석
|
||||
affected = analyze_impact(graph, "node_P101")
|
||||
print(f"Impacted Equipment: {affected}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 4. Phase 2 완료 기준 (Definition of Done)
|
||||
|
||||
- [ ] 모든 설비와 계기가 그래프의 **노드(Node)**로 변환되었는가?
|
||||
- [ ] 분산 추출된 태그 리스트가 `flatten_results`를 통해 통합되어 그래프에 반영되었는가?
|
||||
- [ ] 태그와 설비 간의 **논리적 연결(Association)**이 정확하게 매핑되었는가?
|
||||
- [ ] 배관(Line)의 **끝점 분석**을 통해 설비 간의 **물리적 연결(Pipe Edge)**이 생성되었는가? (BBox 교차 방식 배제)
|
||||
- [ ] 화살표 및 공정 규칙에 기반한 **방향성(Directionality)**이 엣지에 부여되었는가?
|
||||
- [ ] `validate_topology`를 통해 고립 노드 및 위상 오류가 검토되었는가?
|
||||
- [ ] `nx.descendants` 등을 통해 특정 노드로부터의 **흐름 추적(Flow Tracing)**이 가능한가?
|
||||
- [ ] 생성된 그래프 구조가 JSON(GraphML 등) 형태로 저장되어 Phase 3로 전달 가능한가?
|
||||
212
dxf-graph/Graph_Pipeline_Phase3.md
Normal file
212
dxf-graph/Graph_Pipeline_Phase3.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# 🧠 Graph Pipeline Phase 3: 지능형 매핑 및 검증 (Intelligent Mapping & Validation)
|
||||
|
||||
이 문서는 P&ID Graph Pipeline의 세 번째 단계인 **지능형 매핑 및 검증**의 상세 구현 계획을 다룹니다. 2단계에서 구축한 위상 그래프(Topology Graph)를 활용하여, 도면 상의 가상 노드들을 실제 Experion 시스템의 **실시간 태그(Real-time Tags)**와 정밀하게 연결하고 그 타당성을 검증하는 것이 목표입니다.
|
||||
|
||||
---
|
||||
|
||||
## 🚩 [Supervisor's Audit] 감독자 진단 결과 및 수정 사항
|
||||
|
||||
본 프로그램 설계에 대해 감독자 관점에서 정밀 진단을 수행하였으며, 다음과 같은 취약점과 개선 사항을 발견하여 반영하였습니다.
|
||||
|
||||
### 1. 진단 결과 (Audit Findings)
|
||||
|
||||
| 항목 | 진단 내용 | 심각도 | 수정 방향 |
|
||||
|---|---|---|---|
|
||||
| **에러 처리** | LLM 응답이 JSON 형식이 아니거나 `UNKNOWN`일 때의 예외 처리 로직 부족 | HIGH | 구조화된 출력(JSON) 강제 및 Fallback 전략 추가 |
|
||||
| **성능/비용** | 모든 노드에 대해 개별 LLM 호출 시 API 비용 급증 및 속도 저하 | MED | 배치(Batch) 처리 및 1차 필터링 강화 |
|
||||
| **검증 정밀도** | 단순 키워드 매칭 기반 검증은 오탐(False Positive) 가능성이 높음 | MED | 데이터 타입 및 엔지니어링 유닛(EU)의 엄격한 비교 로직 추가 |
|
||||
| **데이터 정합성** | 매핑 결과의 이력 관리 및 사람이 수동으로 수정할 수 있는 피드백 루프 부재 | LOW | 매핑 결과 저장 스키마에 `confidence` 및 `manual_override` 필드 추가 |
|
||||
|
||||
### 2. 수정 이유 (Rationale)
|
||||
- **안정성 확보:** LLM은 비결정론적 특성이 있으므로, 프로그램이 런타임에 중단되지 않도록 Pydantic을 이용한 엄격한 스키마 검증이 필수적입니다.
|
||||
- **효율성 최적화:** 수천 개의 태그를 개별 호출하는 것은 비효율적입니다. 유사도 기반으로 후보군을 좁히고, 유사 그룹을 묶어 배치 처리함으로써 비용을 절감합니다.
|
||||
- **신뢰도 향상:** 단순 텍스트 매칭을 넘어 실제 시스템의 메타데이터(Unit, Range 등)를 교차 검증해야 엔지니어링 관점에서 신뢰할 수 있는 결과가 됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 📦 1. 필수 패키지 및 환경 설정
|
||||
|
||||
### 1.1 Python 패키지
|
||||
| 패키지 | 용도 | 비고 |
|
||||
|---|---|---|
|
||||
| `openai` / `langchain` | LLM API 연동 및 프롬프트 체이닝 | 매핑 추론 및 검증 핵심 |
|
||||
| `fuzzywuzzy` / `rapidfuzz` | 태그 이름 간의 문자열 유사도 계산 | 1차 후보군 추출용 |
|
||||
| `networkx` | 그래프 기반 인접 노드(Context) 추출 | 2단계 그래프 활용 |
|
||||
| `pydantic` | 매핑 결과의 구조화 및 유효성 검사 | **[강화]** 데이터 정규화 및 런타임 타입 체크 |
|
||||
| `requests` | ExperionCrawler API (C#)와 통신 | 실제 태그 리스트 조회 |
|
||||
|
||||
### 1.2 설치 명령어
|
||||
```bash
|
||||
pip install openai langchain rapidfuzz networkx pydantic requests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📐 2. 상세 설계 구조
|
||||
|
||||
### 2.1 매핑 파이프라인 (Mapping Pipeline)
|
||||
단순 이름 매칭의 한계를 극복하기 위해 **[후보 추출 $\rightarrow$ 맥락 분석 $\rightarrow$ LLM 확정 $\rightarrow$ 스키마 검증]**의 4단계 프로세스를 거칩니다.
|
||||
|
||||
1. **1차 후보 추출 (Candidate Generation):**
|
||||
* 도면의 태그 텍스트와 Experion 시스템의 전체 태그 리스트를 `RapidFuzz`로 비교하여 유사도 상위 N개를 추출합니다.
|
||||
2. **맥락 정보 수집 (Context Gathering):**
|
||||
* 해당 노드의 그래프 상 인접 노드(1-hop, 2-hop) 정보를 수집합니다.
|
||||
* 예: "현재 노드는 `PT-101`이며, 상류에 `P-101(Pump)`이 있고 하류에 `V-101(Valve)`이 있음."
|
||||
3. **LLM 기반 최종 매핑 (LLM-based Resolution):**
|
||||
* 후보 태그 리스트와 위상 맥락을 LLM에게 전달하여 가장 타당한 태그를 선택하게 합니다.
|
||||
* **[개선]** JSON Mode를 사용하여 `{"tag": "...", "reason": "...", "confidence": 0.9}` 형태로 응답을 강제합니다.
|
||||
4. **구조적 검증 (Structural Validation):**
|
||||
* Pydantic 모델을 통해 LLM 응답의 형식을 검증하고, 실패 시 `UNKNOWN` 처리 및 로그를 남깁니다.
|
||||
|
||||
### 2.2 상호 검증 로직 (Cross-Validation)
|
||||
매핑된 결과가 실제 공정 데이터와 일치하는지 검증합니다.
|
||||
* **위상적 일관성:** 도면에서 `A $\rightarrow$ B` 순서라면, 실제 데이터에서도 `A`의 변화가 `B`에 영향을 주는지 상관관계 분석.
|
||||
* **속성 일치성:** 도면의 심볼 타입(예: Pressure Transmitter)과 실제 태그의 속성(예: Engineering Unit = 'bar' 또는 'psi')이 일치하는지 확인. **[강화]** 단순 키워드가 아닌 Unit 매핑 테이블을 통한 엄격한 비교.
|
||||
|
||||
---
|
||||
|
||||
## 💻 3. 실제 구현 코딩 가이드 (Example)
|
||||
|
||||
### 3.1 맥락 기반 매핑 엔진
|
||||
```python
|
||||
import networkx as nx
|
||||
import asyncio
|
||||
import json
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from rapidfuzz import process, fuzz
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
# --- [추가] 응답 구조화를 위한 Pydantic 모델 ---
|
||||
class MappingResult(BaseModel):
|
||||
resolved_tag: str = Field(..., description="The final mapped system tag")
|
||||
reason: str = Field(..., description="Reason for this mapping based on context")
|
||||
confidence: float = Field(..., ge=0.0, le=1.0, description="Confidence score from 0 to 1")
|
||||
|
||||
client = AsyncOpenAI(api_key="your-api-key")
|
||||
|
||||
class IntelligentMapper:
|
||||
def __init__(self, graph, system_tags):
|
||||
self.graph = graph # Phase 2에서 생성된 NetworkX 그래프
|
||||
self.system_tags = system_tags # Experion 시스템의 전체 태그 리스트
|
||||
|
||||
def get_node_context(self, node_id):
|
||||
"""노드의 주변 위상 정보를 텍스트로 변환"""
|
||||
neighbors = list(self.graph.neighbors(node_id))
|
||||
context = []
|
||||
for n in neighbors:
|
||||
attr = self.graph.nodes[n]
|
||||
context.append(f"Connected to {attr.get('value', n)} (Type: {attr.get('type')})")
|
||||
return ", ".join(context)
|
||||
|
||||
async def _resolve_generic(self, node_id, category_prompt):
|
||||
"""공통 매핑 로직 (비동기 + 구조화 응답)"""
|
||||
# Phase 2에서 'value'에 clean_value가 저장됨
|
||||
tag_text = self.graph.nodes[node_id].get('value', '')
|
||||
candidates = process.extract(tag_text, self.system_tags, scorer=fuzz.WRatio, limit=5)
|
||||
context = self.get_node_context(node_id)
|
||||
|
||||
prompt = f"""
|
||||
{category_prompt}
|
||||
P&ID 도면의 태그 '{tag_text}'를 실제 시스템 태그와 매핑해야 합니다.
|
||||
위상 맥락: {context}
|
||||
후보 리스트: {candidates}
|
||||
|
||||
반드시 다음 JSON 형식으로만 응답하세요:
|
||||
{{
|
||||
"resolved_tag": "태그명 또는 UNKNOWN",
|
||||
"reason": "매핑 이유",
|
||||
"confidence": 0.0~1.0
|
||||
}}
|
||||
"""
|
||||
|
||||
try:
|
||||
response = await client.chat.completions.create(
|
||||
model="gpt-4-turbo",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
response_format={ "type": "json_object" } # JSON 모드 강제
|
||||
)
|
||||
raw_content = response.choices[0].message.content
|
||||
# Pydantic을 통한 유효성 검사
|
||||
return MappingResult.model_validate_json(raw_content)
|
||||
except Exception as e:
|
||||
print(f"Error resolving node {node_id}: {e}")
|
||||
return MappingResult(resolved_tag="UNKNOWN", reason=f"Error: {str(e)}", confidence=0.0)
|
||||
|
||||
# --- 전문화된 Worker 함수들 ---
|
||||
async def extract_transmitters(self, node_ids):
|
||||
prompt = "당신은 계측기 전문 엔지니어입니다. 특히 Pressure/Flow/Level Transmitter 매핑에 특화되어 있습니다."
|
||||
return {nid: await self._resolve_generic(nid, prompt) for nid in node_ids}
|
||||
|
||||
async def extract_valves(self, node_ids):
|
||||
prompt = "당신은 밸브 및 액추에이터 전문 엔지니어입니다. 밸브의 개폐 상태 및 제어 태그 매핑에 특화되어 있습니다."
|
||||
return {nid: await self._resolve_generic(nid, prompt) for nid in node_ids}
|
||||
|
||||
async def extract_equipment(self, node_ids):
|
||||
prompt = "당신은 공정 설비 전문 엔지니어입니다. 펌프, 탱크, 열교환기 등의 메인 설비 태그 매핑에 특화되어 있습니다."
|
||||
return {nid: await self._resolve_generic(nid, prompt) for nid in node_ids}
|
||||
|
||||
# 사용 예시
|
||||
async def main():
|
||||
# 가상 데이터
|
||||
graph = nx.Graph()
|
||||
graph.add_node("node_1", value="PT-101", type="Pressure Transmitter")
|
||||
graph.add_node("node_2", value="P-101", type="Pump")
|
||||
graph.add_edge("node_1", "node_2")
|
||||
|
||||
mapper = IntelligentMapper(graph, ["PT-101.PV", "PT-102.PV", "P-101.STATUS"])
|
||||
|
||||
results = await asyncio.gather(
|
||||
mapper.extract_transmitters(["node_1"]),
|
||||
mapper.extract_equipment(["node_2"])
|
||||
)
|
||||
|
||||
final_mapping = {**results[0], **results[1]}
|
||||
print(f"Parallel Resolved Mapping: {final_mapping}")
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### 3.2 검증 유틸리티: 속성 일치 확인 (강화 버전)
|
||||
```python
|
||||
def validate_mapping(resolved_tag, symbol_type, tag_metadata):
|
||||
"""심볼 타입과 실제 태그 메타데이터의 엄격한 일치 여부 검증"""
|
||||
# 단순 키워드가 아닌 허용 단위(Unit) 정의
|
||||
unit_map = {
|
||||
"Pressure Transmitter": ["bar", "psi", "kPa", "Pa"],
|
||||
"Flow Meter": ["m3/h", "lpm", "kg/h"],
|
||||
"Temperature Sensor": ["°C", "C", "K", "°F"]
|
||||
}
|
||||
|
||||
actual_unit = tag_metadata.get('unit', '').strip()
|
||||
allowed_units = unit_map.get(symbol_type, [])
|
||||
|
||||
# 1. 단위 일치 확인 (최우선)
|
||||
if actual_unit and actual_unit in allowed_units:
|
||||
return True, "Unit Match"
|
||||
|
||||
# 2. 단위가 없는 경우 설명(Description) 기반 2차 검증
|
||||
actual_desc = tag_metadata.get('description', '').lower()
|
||||
expected_keywords = {
|
||||
"Pressure Transmitter": ["pressure", "press"],
|
||||
"Flow Meter": ["flow", "flowrate"],
|
||||
"Temperature Sensor": ["temp", "temperature"]
|
||||
}
|
||||
|
||||
keywords = expected_keywords.get(symbol_type, [])
|
||||
if any(kw in actual_desc for kw in keywords):
|
||||
return True, "Description Match (Unit Missing)"
|
||||
|
||||
return False, "Mismatch: Symbol type and Tag metadata do not align"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 4. Phase 3 완료 기준 (Definition of Done)
|
||||
|
||||
- [ ] 모든 도면 노드에 대해 **1차 후보군(Candidates)**이 자동으로 생성되는가?
|
||||
- [ ] `NetworkX` 그래프를 통해 **인접 노드 맥락(Context)**이 정확히 추출되는가?
|
||||
- [ ] LLM이 **JSON 형식**으로 최종 태그를 결정하고, 그 근거와 신뢰도를 제시하는가?
|
||||
- [ ] **Pydantic**을 통해 LLM 응답의 구조적 유효성이 검증되는가?
|
||||
- [ ] 매핑된 태그의 **엔지니어링 유닛(Unit)**과 도면 심볼 타입 간의 일치성이 엄격히 검증되는가?
|
||||
- [ ] 최종 매핑 결과가 `(도면노드ID, 시스템태그, 신뢰도, 검증결과, 매핑근거)` 형태로 저장되는가?
|
||||
197
dxf-graph/Graph_Pipeline_Phase4.md
Normal file
197
dxf-graph/Graph_Pipeline_Phase4.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# 🎨 Graph Pipeline Phase 4: 활용 및 시각화 (Application & Visualization)
|
||||
|
||||
이 문서는 P&ID Graph Pipeline의 최종 단계인 **활용 및 시각화**의 상세 구현 계획을 다룹니다. 앞선 단계에서 구축한 [기하학적 데이터 $\rightarrow$ 위상 그래프 $\rightarrow$ 시스템 태그 매핑] 결과물을 결합하여, 운영자가 도면 상에서 실시간 공정 상태를 파악하고 장애 영향도를 분석할 수 있는 인터페이스를 구현하는 것이 목표입니다.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 [Supervisor Diagnosis] 프로그램 진단 및 개선 권고
|
||||
|
||||
**진단 일자:** 2026-05-02
|
||||
**진단자:** Roo (Software Engineer / Supervisor)
|
||||
|
||||
### 1. 종합 진단 결과
|
||||
현재 계획은 기본적인 데이터 흐름(C# $\rightarrow$ Python $\rightarrow$ Frontend)을 잘 정의하고 있으나, **실제 산업 현장의 대규모 P&ID 도면 적용 시 발생할 수 있는 성능 및 안정성 문제**에 대한 고려가 부족합니다. 특히 실시간 데이터 오버레이의 부하 관리와 분석 결과의 신뢰성 검증 단계가 누락되어 있습니다.
|
||||
|
||||
### 2. 주요 진단 항목 및 수정 이유
|
||||
|
||||
| 항목 | 진단 결과 | 위험도 | 수정 이유 및 개선 방향 |
|
||||
|---|---|---|---|
|
||||
| **데이터 전송 효율** | WebSocket/API 폴링 방식의 단순 나열 | MED | 수천 개의 태그가 포함된 도면에서 개별 폴링/전송 시 네트워크 부하 급증 $\rightarrow$ **태그 그룹화 및 변경분 기반(Delta) 전송** 도입 필요 |
|
||||
| **프론트엔드 렌더링** | SVG/Canvas 단순 오버레이 | HIGH | 노드 수가 많아질 경우 DOM 요소 증가로 인한 브라우저 랙 발생 $\rightarrow$ **Canvas 기반 렌더링 최적화 및 Viewport 기반 가시 영역 렌더링** 전략 필요 |
|
||||
| **분석 엔진 신뢰성** | `nx.descendants` 단순 활용 | MED | 단순 위상 전파는 실제 공정의 '흐름 방향(Flow Direction)'과 '밸브 개폐 상태'를 무시함 $\rightarrow$ **엣지 속성(방향성, 상태)을 반영한 가중치 경로 분석**으로 고도화 |
|
||||
| **에러 핸들링** | Python 브릿지 통신 시 예외 처리 미흡 | LOW | 분석 엔진 다운 시 C# 서버의 블로킹 가능성 $\rightarrow$ **Circuit Breaker 패턴 및 타임아웃 설정** 명시 필요 |
|
||||
| **사용자 경험(UX)** | 단순 하이라이트 표시 | LOW | 영향도 결과가 많을 경우 도면이 빨간색으로 도배됨 $\rightarrow$ **단계별 영향도(1차, 2차...) 색상 구분 및 필터링** 기능 추가 |
|
||||
|
||||
---
|
||||
|
||||
## 📦 1. 필수 패키지 및 기술 스택
|
||||
|
||||
### 1.1 프론트엔드 (Visualization)
|
||||
| 기술/라이브러리 | 용도 | 비고 |
|
||||
|---|---|---|
|
||||
| `SVG / Canvas API` | P&ID 도면 렌더링 및 데이터 오버레이 | **Canvas API 우선 권장 (대규모 노드 성능 최적화)** |
|
||||
| `Cytoscape.js` / `D3.js` | 위상 그래프 시각화 및 인터랙티브 탐색 | 그래프 분석 뷰어 |
|
||||
| `Vue.js` / `React` | 전체 UI 프레임워크 및 상태 관리 | `src/Web` 구조와 통합 |
|
||||
| `Axios` / `WebSocket` | 실시간 OPC UA 데이터 수신 및 API 통신 | **SignalR (ASP.NET Core) 도입 권장 (실시간 양방향 통신 최적화)** |
|
||||
|
||||
### 1.2 백엔드 (API & Analysis)
|
||||
| 기술/라이브러리 | 용도 | 비고 |
|
||||
|---|---|---|
|
||||
| `ASP.NET Core` | Graph API 및 분석 엔드포인트 제공 | `ExperionCrawler` 메인 서버 |
|
||||
| `NetworkX` (Python) | 영향도 분석 및 경로 추적 알고리즘 실행 | 분석 엔진 (Phase 2 활용) |
|
||||
| `FastAPI` / `Flask` | Python 분석 엔진과 C# 서버 간의 브릿지 | 분석 마이크로서비스 |
|
||||
|
||||
---
|
||||
|
||||
## 📐 2. 상세 설계 구조
|
||||
|
||||
### 2.1 실시간 데이터 오버레이 (Real-time Overlay)
|
||||
도면의 좌표 정보와 매핑된 시스템 태그를 연결하여 실시간 값을 표시합니다.
|
||||
1. **매핑 데이터 로드:** `(도면노드ID, 시스템태그, 좌표)` 리스트를 프론트엔드로 전달.
|
||||
2. **실시간 스트리밍:** `OPC UA` $\rightarrow$ `C# Server` $\rightarrow$ `SignalR Hub` $\rightarrow$ `Frontend`. (**개선: 변경된 값만 전송하는 Delta Update 방식 적용**)
|
||||
3. **동적 렌더링:** 태그 값이 변경되면 해당 좌표의 Canvas 요소를 업데이트하거나 툴팁에 현재 값을 표시. (**개선: Viewport 내 요소만 업데이트하여 CPU 부하 감소**)
|
||||
|
||||
### 2.2 영향도 분석 엔진 (Impact Analysis Engine)
|
||||
특정 설비의 이상 발생 시 하류(Downstream) 영향을 계산합니다.
|
||||
1. **분석 요청:** 사용자가 도면에서 특정 노드(예: 펌프 P-101)를 클릭.
|
||||
2. **그래프 탐색:** Python 분석 엔진에서 `nx.descendants(G, 'P-101')` 실행. (**개선: 엣지의 `flow_direction` 속성을 확인하여 실제 유체 흐름 방향으로만 전파 계산**)
|
||||
3. **결과 반환:** 영향받는 모든 노드 ID 리스트, 경로(Path), 그리고 **영향 단계(Depth)**를 반환.
|
||||
4. **시각적 강조:** 도면 상에서 영향 경로를 단계별 색상(예: 1차-진한 빨강, 2차-연한 빨강)으로 하이라이트 처리.
|
||||
|
||||
---
|
||||
|
||||
## 💻 3. 실제 구현 코딩 가이드 (Example)
|
||||
|
||||
### 3.1 [Backend] 영향도 분석 API (C# $\rightarrow$ Python Bridge)
|
||||
```csharp
|
||||
// src/Web/Controllers/PidGraphController.cs
|
||||
|
||||
// 1. 분석 상태 추적을 위한 DTO
|
||||
public record AnalysisStatus(string taskId, double progress, string status, string message);
|
||||
|
||||
// 2. 실시간 진행 상태 조회 API (Phase 5 병렬 처리 반영)
|
||||
[HttpGet("status/{taskId}")]
|
||||
public async Task<IActionResult> GetAnalysisStatus(string taskId)
|
||||
{
|
||||
// Orchestrator가 관리하는 작업 상태 저장소(Redis/MemoryCache)에서 조회
|
||||
var status = await _statusService.GetStatusAsync(taskId);
|
||||
if (status == null) return NotFound();
|
||||
|
||||
return Ok(new {
|
||||
taskId = status.TaskId,
|
||||
progress = status.Progress, // 0.0 ~ 1.0
|
||||
status = status.Status, // "Processing", "Completed", "Failed"
|
||||
message = status.Message
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("impact/{nodeId}")]
|
||||
public async Task<IActionResult> GetImpactAnalysis(string nodeId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Python 분석 마이크로서비스에 요청 (Timeout 및 Circuit Breaker 적용 권장)
|
||||
var response = await _httpClient.GetAsync($"http://python-analysis-api/impact/{nodeId}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<ImpactResult>();
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
// 분석 엔진 연결 실패 시 적절한 에러 메시지 반환
|
||||
return StatusCode(503, new { error = "Analysis Engine is currently unavailable", details = ex.Message });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 [Frontend] Canvas 기반 데이터 오버레이 및 진행률 표시 (JavaScript)
|
||||
```javascript
|
||||
// src/Web/wwwroot/js/pid-viewer.js
|
||||
|
||||
// 1. 실시간 값 업데이트 (Canvas 최적화 버전)
|
||||
async function updateRealtimeValues(tagData) {
|
||||
// tagData: { "TAG_01": { value: 10.5, status: "OK" }, ... }
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
for (const [tag, data] of Object.entries(tagData)) {
|
||||
const node = nodeMap.get(tag); // 좌표 정보 맵
|
||||
if (node && isInViewport(node)) {
|
||||
// 뷰포트 내에 있을 때만 렌더링
|
||||
ctx.fillStyle = data.value > threshold ? 'red' : 'green';
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// 툴팁 데이터 업데이트
|
||||
updateTooltipData(tag, data.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 분석 진행 상태 표시 (Phase 5 병렬 처리 반영)
|
||||
async function trackAnalysisProgress(taskId) {
|
||||
const progressBar = document.getElementById('analysis-progress-bar');
|
||||
const statusText = document.getElementById('analysis-status-text');
|
||||
|
||||
const pollStatus = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/pid/status/${taskId}`);
|
||||
const data = await response.json();
|
||||
|
||||
// 프로그레스 바 업데이트
|
||||
progressBar.style.width = `${data.progress * 100}%`;
|
||||
statusText.innerText = `분석 중... ${Math.round(data.progress * 100)}% (${data.message})`;
|
||||
|
||||
if (data.status !== 'Completed' && data.status !== 'Failed') {
|
||||
setTimeout(pollStatus, 1000); // 1초 간격 폴링
|
||||
} else {
|
||||
statusText.innerText = data.status === 'Completed' ? '분석 완료!' : '분석 실패';
|
||||
}
|
||||
} catch (e) {
|
||||
statusText.innerText = '상태 조회 중 오류 발생';
|
||||
}
|
||||
};
|
||||
|
||||
pollStatus();
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 [Analysis] 흐름 방향 반영 경로 추적 (Python)
|
||||
```python
|
||||
import networkx as nx
|
||||
|
||||
def get_propagation_path_with_flow(graph, start_node):
|
||||
"""
|
||||
단순 descendants가 아닌, 엣지의 방향성(flow_direction)과
|
||||
상태(valve_open)를 고려한 실제 영향 전파 경로 추출
|
||||
"""
|
||||
# 1. 유효한 엣지만 필터링 (방향이 맞고 밸브가 열려있는 경로)
|
||||
valid_edges = [
|
||||
(u, v, d) for u, v, d in graph.edges(data=True)
|
||||
if d.get('flow_direction') == 'forward' and d.get('valve_status') == 'open'
|
||||
]
|
||||
filtered_graph = nx.DiGraph()
|
||||
filtered_graph.add_edges_from(valid_edges)
|
||||
|
||||
# 2. 전파 단계별 노드 추출 (BFS)
|
||||
propagation_levels = nx.single_source_shortest_path_length(filtered_graph, start_node)
|
||||
|
||||
# { node_id: distance } 형태로 반환하여 프론트엔드에서 색상 구분 가능하게 함
|
||||
return propagation_levels
|
||||
|
||||
# 예: P-101에서 시작되는 실제 유체 흐름 기반 영향도 분석
|
||||
impact_map = get_propagation_path_with_flow(topology_graph, "P-101")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 4. Phase 4 완료 기준 (Definition of Done)
|
||||
|
||||
- [ ] P&ID 도면(Canvas) 위에 **실시간 OPC UA 값**이 정확한 좌표에 표시되며, 뷰포트 최적화가 적용되었는가?
|
||||
- [ ] **SignalR 또는 Delta Update**를 통해 네트워크 부하를 최소화하며 실시간 데이터를 수신하는가?
|
||||
- [ ] 병렬 처리 중인 분석 작업의 **진행 상태(Progress Bar)**가 UI에 실시간으로 반영되는가?
|
||||
- [ ] 특정 노드 클릭 시 **유체 흐름 방향이 반영된 영향도 분석** 결과가 단계별 색상으로 하이라이트 되는가?
|
||||
- [ ] C# 서버와 Python 엔진 간 통신에 **타임아웃 및 예외 처리**가 적용되어 시스템 안정성이 확보되었는가?
|
||||
- [ ] 전체 파이프라인(`추출 $\rightarrow$ 모델링 $\rightarrow$ 매핑 $\rightarrow$ 시각화`)이 통합되어 동작하는가?
|
||||
140
dxf-graph/Graph_Pipeline_Phase5.md
Normal file
140
dxf-graph/Graph_Pipeline_Phase5.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# 🔌 Graph Pipeline Phase 5: MCP 서버 통합 및 고성능 병렬 아키텍처 (MCP Integration & Parallel Processing)
|
||||
|
||||
이 문서는 앞서 설계한 1~4단계의 Graph Pipeline을 현재 프로젝트의 **Unified MCP Server (`mcp-server/server.py`)**에 통합하는 방안을 다룹니다. 특히, 대용량 도면 처리 시 발생하는 지연과 버퍼 문제를 해결하기 위해 `PID_Parser_Plan_Revision.md`의 **분산 처리 기법**과 vLLM의 **Continuous Batching** 특성을 극대화한 병렬 아키텍처를 적용합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 1. 통합 아키텍처 설계
|
||||
|
||||
### 1.1 고성능 병렬 데이터 흐름 (Parallel End-to-End Flow)
|
||||
단일 순차 요청 방식에서 벗어나, **[전처리 $\rightarrow$ 병렬 분산 추출 $\rightarrow$ 통합 후처리]** 구조로 전환합니다.
|
||||
|
||||
`Frontend (UI)` $\rightarrow$ `C# Server (API)` $\rightarrow$ `MCP Server (Orchestrator)` $\rightarrow$ `Parallel Worker Tools (vLLM Batching)` $\rightarrow$ `Result Aggregator` $\rightarrow$ `C# Server`
|
||||
|
||||
1. **요청:** 사용자가 UI에서 도면 분석 시작 버튼 클릭.
|
||||
2. **전처리 (Orchestrator):** MCP 서버가 DXF를 로드하여 기하학적 데이터를 추출하고, 분석 대상(Transmitter, Valve, Pump 등)별로 데이터를 분할합니다.
|
||||
3. **병렬 호출 (Continuous Batching):**
|
||||
* 분할된 데이터를 기반으로 여러 개의 MCP 툴(또는 동일 툴의 다중 요청)을 **동시에(Asynchronously)** 호출합니다.
|
||||
* vLLM 서버는 이 다수의 요청을 **Continuous Batching**으로 묶어 처리함으로써, 개별 요청 시보다 전체 처리량(Throughput)을 획기적으로 높입니다.
|
||||
4. **통합 및 저장 (Aggregator):** 각 분산 툴이 반환한 결과를 취합하여 최종 위상 그래프를 구축하고 DB에 저장합니다.
|
||||
|
||||
### 1.2 MCP 서버 내 역할 분담 (분산 처리 모델)
|
||||
`PID_Parser_Plan_Revision.md`를 반영하여, 기능을 세분화하고 병렬 실행 가능하게 설계합니다.
|
||||
|
||||
| 구분 | MCP Tool / Module | 역할 | 병렬 처리 전략 |
|
||||
|---|---|---|---|
|
||||
| **Orchestrator** | `orchestrate_pid_pipeline` | 전체 공정 제어, 데이터 분할 및 결과 취합 | Asyncio 기반 비동기 제어 |
|
||||
| **Worker 1** | `extract_transmitters` | FIT, FT, LT, PT, TE 추출 | vLLM Batching 요청 |
|
||||
| **Worker 2** | `extract_valves` | FCV, LCV, TCV, PCV, XV 추출 | vLLM Batching 요청 |
|
||||
| **Worker 3** | `extract_gauges` | PG, TG, LG 추출 | vLLM Batching 요청 |
|
||||
| **Worker 4** | `extract_equipment` | Column, Tank, Filter, Drum, Heat Exchanger 등 추출 | vLLM Batching 요청 |
|
||||
| **Worker 5** | `extract_pumps` | P-xxxx, VP-xxxx 추출 | vLLM Batching 요청 |
|
||||
| **Analyzer** | `analyze_pid_impact` | 구축된 그래프 기반 영향도 분석 | Graph Algorithm (CPU) |
|
||||
|
||||
---
|
||||
|
||||
## 💻 2. MCP 서버 통합 구현 가이드
|
||||
|
||||
### 2.1 비동기 병렬 처리 설계 (Asyncio + vLLM Batching)
|
||||
`FastMCP` 환경에서 `asyncio.gather`를 사용하여 여러 추출 툴을 동시에 호출함으로써 vLLM의 Continuous Batching 효율을 극대화합니다.
|
||||
|
||||
```python
|
||||
# mcp-server/server.py 통합 설계 (개념 코드)
|
||||
import asyncio
|
||||
from typing import List
|
||||
|
||||
async def run_parallel_extraction(geo_data):
|
||||
"""
|
||||
분류별 추출 툴을 병렬로 호출하여 vLLM Batching 유도
|
||||
"""
|
||||
# 각 분류별 프롬프트와 데이터 준비
|
||||
tasks = [
|
||||
extract_transmitters_async(geo_data),
|
||||
extract_valves_async(geo_data),
|
||||
extract_gauges_async(geo_data),
|
||||
extract_equipment_async(geo_data),
|
||||
extract_pumps_async(geo_data)
|
||||
]
|
||||
|
||||
# 동시에 요청을 던져 vLLM이 내부적으로 Batch 처리하게 함
|
||||
results = await asyncio.gather(*tasks)
|
||||
return results
|
||||
|
||||
@mcp.tool()
|
||||
async def build_pid_graph_parallel(filepath: str) -> str:
|
||||
"""
|
||||
분산 처리 기법을 적용한 P&ID 그래프 생성 툴
|
||||
"""
|
||||
# 1. 전처리 (Phase 1)
|
||||
extractor = PidGeometricExtractor(filepath)
|
||||
geo_data = extractor.extract_and_save("shared_geo_data.json") # 파일 기반 공유 저장소 활용
|
||||
# 실제 구현 시 geo_data는 파일 경로 또는 로드된 JSON 리스트
|
||||
|
||||
# 2. 병렬 분산 추출 (vLLM Batching 활용)
|
||||
# 각 Worker 툴들이 LLM에 요청을 보낼 때 vLLM이 이를 묶어서 처리함
|
||||
extracted_parts = await run_parallel_extraction(geo_data)
|
||||
|
||||
# 3. 결과 통합 및 위상 모델링 (Phase 2)
|
||||
# extracted_parts는 각 Worker(Transmitter, Valve 등)가 반환한 매핑 결과 리스트
|
||||
all_tags = flatten_results(extracted_parts)
|
||||
builder = PidTopologyBuilder(geo_data, all_extracted_tags=all_tags)
|
||||
builder.build_graph()
|
||||
|
||||
# 4. 저장
|
||||
graph_id = os.path.basename(filepath).replace(".dxf", "_graph.json")
|
||||
nx.write_graphml(builder.G, f"storage/{graph_id}")
|
||||
|
||||
return json.dumps({"success": True, "graph_id": graph_id, "nodes": builder.G.number_of_nodes()})
|
||||
```
|
||||
|
||||
### 2.2 C# 서버와의 인터페이스 (`McpClient` 활용)
|
||||
C# 서버는 `src/Infrastructure/Mcp/McpClient.cs`를 통해 위 툴들을 호출합니다.
|
||||
|
||||
### 2.2 C# 서버와의 인터페이스 (`McpClient` 활용)
|
||||
C# 서버는 `src/Infrastructure/Mcp/McpClient.cs`를 통해 위 툴들을 호출합니다.
|
||||
|
||||
```csharp
|
||||
// src/Core/Application/Services/PidGraphService.cs (신규 서비스)
|
||||
public async Task<ImpactResult> GetImpactAnalysisAsync(string graphId, string nodeId)
|
||||
{
|
||||
var request = new McpToolRequest {
|
||||
ToolName = "analyze_pid_impact",
|
||||
Arguments = new { graph_id = graphId, start_node_id = nodeId }
|
||||
};
|
||||
|
||||
var jsonResponse = await _mcpClient.CallToolAsync(request);
|
||||
return JsonSerializer.Deserialize<ImpactResult>(jsonResponse);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 3. 프로그램 구성 및 배포 전략
|
||||
|
||||
### 3.1 디렉토리 구조 확장
|
||||
```text
|
||||
mcp-server/
|
||||
├── server.py # MCP 메인 서버 (툴 정의)
|
||||
├── pipeline/ # Graph Pipeline 핵심 로직 (Phase 1~4)
|
||||
│ ├── __init__.py
|
||||
│ ├── extractor.py # Phase 1: Geometric Extraction
|
||||
│ ├── topology.py # Phase 2: Topology Modeling
|
||||
│ ├── mapper.py # Phase 3: Intelligent Mapping
|
||||
│ └── analyzer.py # Phase 4: Impact Analysis
|
||||
└── storage/ # 생성된 그래프 파일 (.graphml) 저장소
|
||||
```
|
||||
|
||||
### 3.2 실행 프로세스
|
||||
1. **MCP 서버 기동:** `python mcp-server/server.py --http` (포트 5001)
|
||||
2. **C# 서버 기동:** `dotnet run` (포트 5000)
|
||||
3. **통신:** C# 서버 $\xrightarrow{HTTP/JSON}$ MCP 서버 $\xrightarrow{Python\ Libs}$ 결과 반환.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 4. 최종 완료 기준 (Definition of Done)
|
||||
|
||||
- [ ] `mcp-server/server.py`에 `build_pid_graph`, `analyze_pid_impact` 등 핵심 툴이 정의되었는가?
|
||||
- [ ] Phase 1~4의 Python 로직이 `mcp-server/pipeline/` 모듈로 구조화되어 통합되었는가?
|
||||
- [ ] C# `McpClient`를 통해 MCP 서버의 그래프 분석 툴을 호출하고 결과를 수신할 수 있는가?
|
||||
- [ ] 도면 업로드 $\rightarrow$ 그래프 생성 $\rightarrow$ 태그 매핑 $\rightarrow$ 영향도 분석으로 이어지는 **End-to-End 파이프라인**이 완성되었는가?
|
||||
- [ ] 모든 과정이 `json_response=True` 및 `stateless_http=True` 설정 하에 안정적으로 동작하는가?
|
||||
1174226
dxf-graph/No-10_Plant_PID.dxf
Normal file
1174226
dxf-graph/No-10_Plant_PID.dxf
Normal file
File diff suppressed because it is too large
Load Diff
BIN
dxf-graph/P&ID AX Plan.zip
Normal file
BIN
dxf-graph/P&ID AX Plan.zip
Normal file
Binary file not shown.
3
dxf-graph/P&ID AX 운전 문제점.md
Normal file
3
dxf-graph/P&ID AX 운전 문제점.md
Normal file
@@ -0,0 +1,3 @@
|
||||
1. 리모트 (웹브라우저 실행한 PC)에서 파일을 선택하면, 서버로 전달되지 않는다 ---> 추출시작시 에러남
|
||||
2. 파일선택 버튼을 누르면 리모트 PC의 파일을 읽는다. 원격 서버의 파일은 읽히지 않는다.
|
||||
3. 그러면 어쩌란 말인가 ????
|
||||
83
dxf-graph/P&ID Graph Pipeline Road Map.md
Normal file
83
dxf-graph/P&ID Graph Pipeline Road Map.md
Normal file
@@ -0,0 +1,83 @@
|
||||
P&ID 도면 분석을 고도화하여 **Graph Pipeline**을 구축하는 것은 단순한 텍스트 추출을 넘어, 설비 간의 **연결성(Connectivity)**과 **위상(Topology)**을 이해하는 것을 의미합니다.
|
||||
|
||||
제가 이 작업을 수행한다면, 다음과 같은 **4단계 전략**으로 접근하여 효율성을 극대화하겠습니다.
|
||||
|
||||
---
|
||||
|
||||
### 1. 데이터 추출 단계: "단순 텍스트 $\rightarrow$ 기하학적 객체"
|
||||
현재의 텍스트 기반 추출에서 벗어나, 객체의 **좌표(Coordinate)**와 **속성(Property)**을 보존하는 구조로 변경해야 합니다.
|
||||
|
||||
* **객체 중심 파싱:** DXF의 Entity(Line, Circle, Text, Polyline)를 개별 객체로 인식하고, 각 객체의 중심점과 경계 상자(Bounding Box)를 저장합니다.
|
||||
* **심볼 라이브러리 구축:** 밸브, 펌프, 탱크 등 반복되는 심볼의 기하학적 패턴을 정의하여, 텍스트가 없어도 "이 모양은 밸브다"라고 인식하는 패턴 매칭 로직을 도입합니다.
|
||||
* **OCR 고도화:** PDF의 경우, 단순 텍스트 추출이 아닌 영역 기반 OCR을 통해 텍스트의 물리적 위치를 정확히 파악하여 인접한 심볼과 연결합니다.
|
||||
|
||||
### 2. 그래프 모델링 단계: "객체 $\rightarrow$ 노드 및 엣지"
|
||||
추출된 객체들을 기반으로 **Knowledge Graph**를 생성합니다.
|
||||
|
||||
* **노드(Node):** 설비(Equipment), 계기(Instrument), 태그(Tag)를 노드로 정의합니다.
|
||||
* **엣지(Edge):** 배관(Line)을 엣지로 정의합니다.
|
||||
* **연결성 판단:** `Line`의 끝점이 `Equipment`의 경계 상자 내에 있거나 매우 근접해 있다면 두 노드를 연결된 것으로 간주합니다.
|
||||
* **방향성 부여:** 화살표 심볼이나 공정 흐름(Flow)을 분석하여 엣지에 방향성을 부여합니다.
|
||||
* **계층 구조 생성:** `Unit $\rightarrow$ Equipment $\rightarrow$ Component $\rightarrow$ Tag` 순의 계층적 그래프 구조를 설계합니다.
|
||||
|
||||
### 3. 지능형 매핑 및 검증 단계: "도면 $\rightarrow$ 실제 데이터"
|
||||
그래프 구조를 활용해 Experion 시스템의 실제 태그와 정밀하게 매핑합니다.
|
||||
|
||||
* **맥락 기반 매핑 (Contextual Mapping):** 단순히 이름이 비슷한 태그를 찾는 것이 아니라, "펌프 P-101 옆에 있는 PT-101은 P-101의 압력 전송기일 확률이 높다"는 그래프 상의 인접성을 활용합니다.
|
||||
* **상호 검증 (Cross-Validation):**
|
||||
* 도면 상의 연결 관계(P-101 $\rightarrow$ V-101)와 실제 공정 데이터의 상관관계(P-101 가동 시 V-101 유량 변화)를 비교하여 매핑의 정확도를 검증합니다.
|
||||
* **LLM 기반 추론:** 모호한 태그명이나 누락된 정보는 MCP 서버를 통해 LLM이 도면의 맥락과 R530 문서를 분석하여 최적의 매핑 후보를 추천하게 합니다.
|
||||
|
||||
### 4. 활용 및 시각화 단계: "분석 $\rightarrow$ 인사이트"
|
||||
구축된 그래프를 통해 운영자에게 실질적인 가치를 제공합니다.
|
||||
|
||||
* **영향도 분석 (Impact Analysis):** 특정 밸브(V-101)가 고장 났을 때, 그래프 탐색(BFS/DFS)을 통해 하류(Downstream)에 영향을 받는 모든 설비와 태그를 즉시 식별합니다.
|
||||
* **디지털 트윈 뷰:** P&ID 도면 위에 실시간 OPC UA 값을 오버레이하여, 도면을 보면서 현재 공정 상태를 한눈에 파악하는 인터페이스를 구현합니다.
|
||||
* **이상 징후 전파 경로 추적:** 특정 태그에서 알람이 발생했을 때, 그래프를 역추적하여 근본 원인(Root Cause)이 될 가능성이 높은 상류 설비를 추천합니다.
|
||||
|
||||
---
|
||||
|
||||
### 🚀 효율적인 실행을 위한 로드맵 (Priority)
|
||||
|
||||
1. **Short-term (Quick Win):** DXF 파서 수정 $\rightarrow$ 객체 좌표 저장 $\rightarrow$ 단순 인접성 기반 태그-설비 매핑.
|
||||
2. **Mid-term (Core):** 심볼 패턴 매칭 도입 $\rightarrow$ 배관(Line) 기반의 그래프 구조(NetworkX 등 활용) 구축.
|
||||
3. **Long-term (Advanced):** LLM 기반의 도면-데이터 추론 엔진 통합 $\rightarrow$ 실시간 데이터 오버레이 UI 구현.
|
||||
|
||||
이렇게 **[기하학적 추출 $\rightarrow$ 위상 모델링 $\rightarrow$ 맥락적 매핑 $\rightarrow$ 운영 인사이트]** 순으로 확장하는 것이 가장 리스크가 적고 효율적인 방법이라고 생각합니다.
|
||||
|
||||
---
|
||||
|
||||
### 🛠️ 단계별 설계 구조 및 실행 코딩 가이드
|
||||
|
||||
로드맵의 각 단계를 실제로 구현하기 위한 기술적 설계 구조와 핵심 코딩 전략입니다.
|
||||
|
||||
#### 1단계: 기하학적 데이터 추출 (Geometric Extraction)
|
||||
* **설계 구조:** `DXF Parser` $\rightarrow$ `Entity Mapper` $\rightarrow$ `Spatial Database (JSON/SQL)`
|
||||
* **실행 코딩 전략:**
|
||||
* **좌표 보존:** `ezdxf`를 사용하여 모든 `TEXT`, `LINE`, `CIRCLE`, `LWPOLYLINE`의 시작/끝점 및 중심 좌표를 추출하여 저장합니다.
|
||||
* **Bounding Box 계산:** 각 텍스트와 심볼의 최소/최대 X, Y 좌표를 계산하여 `Rect` 객체로 관리합니다.
|
||||
* **데이터 구조:**
|
||||
```json
|
||||
{ "id": "entity_1", "type": "TEXT", "value": "P-101", "bbox": {"x1": 10, "y1": 20, "x2": 15, "y2": 25} }
|
||||
```
|
||||
|
||||
#### 2단계: 위상 모델링 (Topology Modeling)
|
||||
* **설계 구조:** `Spatial Join` $\rightarrow$ `Graph Constructor` $\rightarrow$ `NetworkX Graph`
|
||||
* **실행 코딩 전략:**
|
||||
* **인접성 판단 (Proximity Search):** 텍스트 노드와 가장 가까운 심볼/라인을 찾기 위해 `KD-Tree` 또는 `R-Tree` 알고리즘을 사용합니다.
|
||||
* **연결성 추론:** `Line`의 끝점이 `Equipment`의 Bounding Box 내에 포함되는지 확인하여 엣지(Edge)를 생성합니다.
|
||||
* **그래프 구축:** Python의 `NetworkX` 라이브러리를 사용하여 `G.add_node(equipment)` 및 `G.add_edge(eq1, eq2, relation='pipe')` 형태로 모델링합니다.
|
||||
|
||||
#### 3단계: 맥락적 매핑 (Contextual Mapping)
|
||||
* **설계 구조:** `Graph Traversal` $\rightarrow$ `Tag Candidate Search` $\rightarrow$ `LLM Validator`
|
||||
* **실행 코딩 전략:**
|
||||
* **인접 태그 탐색:** 특정 설비 노드에서 1-hop 또는 2-hop 이내에 존재하는 모든 태그 노드를 수집합니다.
|
||||
* **매핑 스코어링:** `(이름 유사도 * 0.4) + (위상적 인접도 * 0.6)`와 같은 가중치 모델을 적용하여 최적의 Experion 태그를 매핑합니다.
|
||||
* **LLM 검증:** 매핑 결과와 도면의 맥락을 LLM에게 전달하여 "P-101 펌프의 토출측에 PT-101이 있는 것이 공정상 타당한가?"를 검증합니다.
|
||||
|
||||
#### 4단계: 운영 인사이트 구현 (Operational Insight)
|
||||
* **설계 구조:** `Real-time Data Stream` $\rightarrow$ `Graph Overlay` $\rightarrow$ `Impact Analysis Engine`
|
||||
* **실행 코딩 전략:**
|
||||
* **실시간 오버레이:** `OPC UA`로 수집된 실시간 값을 그래프 노드의 속성으로 업데이트하고, 이를 프론트엔드(Canvas/SVG)에 렌더링합니다.
|
||||
* **영향도 분석:** `nx.single_source_shortest_path` 또는 `BFS`를 사용하여 특정 노드 장애 시 영향을 받는 하류(Downstream) 노드 리스트를 즉시 추출합니다.
|
||||
* **루트 코즈 추적:** 알람 발생 노드로부터 상류(Upstream) 방향으로 역추적하여 이상 징후의 시작점을 식별합니다.
|
||||
533
dxf-graph/P&ID_AX_Plan.md
Normal file
533
dxf-graph/P&ID_AX_Plan.md
Normal file
@@ -0,0 +1,533 @@
|
||||
# P&ID 데이터베이스화 기능 통합 설계
|
||||
|
||||
## 📋 개요
|
||||
|
||||
DXF/PDF 형식의 P&ID 도면에서 장비 및 계기 정보를 AI로 자동 추출하여 ExperionCrawler 데이터베이스와 연동하는 기능입니다.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 목표
|
||||
|
||||
1. P&ID 도면에서 장비 정보를 추출
|
||||
2. 추출된 정보를 PostgreSQL 로 저장
|
||||
3. 기존 Experion 데이터와 연동
|
||||
4. 웹에서 시각화 및 관리
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 아키텍처 설계
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ ExperionCrawler │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │
|
||||
│ │ Frontend UI │◄────►│ Web API │◄────►│ DB │ │
|
||||
│ │ (app.js, .html)│ │ (Controllers) │ │ (Experion │ │
|
||||
│ └─────────────────┘ └─────────────────┘ │ DbContext)│ │
|
||||
│ │ │ └─────────────┘ │
|
||||
│ └──────────────────────────┼────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────┴───────────────┐ │
|
||||
│ │ P&ID Extraction Service │ │
|
||||
│ │ (AI 기반 추출) │ │
|
||||
│ └───────────────┬───────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────▼───────────────┐ │
|
||||
│ │ Image/Text Preprocessing │ │
|
||||
│ │ (PDF → PNG → OCR) │ │
|
||||
│ └───────────────┬───────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────▼───────────────┐ │
|
||||
│ │ Claude Vision API │ │
|
||||
│ │ (필드 추출) │ │
|
||||
│ └───────────────┬───────────────┘ │
|
||||
└────────────────────────────────────┼────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ PostgreSQL DB │
|
||||
│ ┌───────────────┐ │
|
||||
│ │ pid_equipment │ │
|
||||
│ │ Active │ │
|
||||
│ │ Audit Log │ │
|
||||
│ └───────────────┘ │
|
||||
│ ┌───────────────┐ │
|
||||
│ │ experion_tags │ │
|
||||
│ │ Active │ │
|
||||
│ └───────────────┘ │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 폴더 구조
|
||||
|
||||
```
|
||||
ExperionCrawler/
|
||||
├── src/
|
||||
│ ├── Web/
|
||||
│ │ └── Controllers/
|
||||
│ │ ├── ExperionControllers.cs (기존)
|
||||
│ │ └── PidController.cs (추가)
|
||||
│ ├── Core/
|
||||
│ │ ├── Application/
|
||||
│ │ │ ├── Interfaces/
|
||||
│ │ │ │ ├── IExperionServices.cs (기존)
|
||||
│ │ │ │ ├── IPidExtractorService.cs (추가)
|
||||
│ │ │ │ └── ITagMappingService.cs (추가)
|
||||
│ │ │ ├── Services/
|
||||
│ │ │ │ ├── TextToSqlService.cs (기존)
|
||||
│ │ │ │ ├── PidExtractorService.cs (추가)
|
||||
│ │ │ │ ├── AxImportGenerator.cs (추가)
|
||||
│ │ │ │ └── TagMappingService.cs (추가)
|
||||
│ │ │ └── Dtos/
|
||||
│ │ │ ├── PidEquipmentDto.cs (추가)
|
||||
│ │ │ └── TagCountDto.cs (추가)
|
||||
│ │ └── Domain/
|
||||
│ │ ├── Entities/
|
||||
│ │ │ ├── PidEquipment.cs (추가)
|
||||
│ │ │ └── PidAuditLog.cs (추가)
|
||||
│ │ └── ValueObjects/
|
||||
│ │ ├── ConfidenceScore.cs (추가)
|
||||
│ │ └── MeasurementUnit.cs (추가)
|
||||
│ └── Infrastructure/
|
||||
│ ├── Database/
|
||||
│ │ ├── ExperionDbContext.cs (기존 - 확장)
|
||||
│ │ └── PidDbContext.cs (추가)
|
||||
│ └── OpcUa/
|
||||
│ └── (기존)
|
||||
├── futurePlan/
|
||||
│ ├── temp/
|
||||
│ │ ├── pid_extractor.py (AI 추출기)
|
||||
│ │ ├── schema.sql (추구용 DB 스키마)
|
||||
│ │ └── requirements.txt (Python 의존성)
|
||||
│ └── P&ID_데이터베이스화_통합_설계.md
|
||||
├── src/Web/wwwroot/
|
||||
│ └── js/
|
||||
│ └── app.js (기존 - 확장)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔌 데이터베이스 스키마 확장
|
||||
|
||||
### PidDbContext.cs (새 파일)
|
||||
|
||||
```csharp
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ExperionCrawler.Infrastructure.Database;
|
||||
|
||||
public class PidDbContext : DbContext
|
||||
{
|
||||
public DbSet<PidEquipment> PidEquipment { get; set; }
|
||||
public DbSet<PidAuditLog> PidAuditLog { get; set; }
|
||||
|
||||
// 기존 ExperionDbContext와 통합
|
||||
public DbSet<TagInfo> TagInfo { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// PidEquipment 설정
|
||||
modelBuilder.Entity<PidEquipment>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.TagNo).IsRequired().HasMaxLength(50);
|
||||
entity.Property(e => e.EquipmentName).HasMaxLength(200);
|
||||
entity.Property(e => e.InstrumentType).HasMaxLength(10);
|
||||
entity.Property(e => e.LineNumber).HasMaxLength(100);
|
||||
entity.Property(e => e.PidDrawingNo).HasMaxLength(50);
|
||||
entity.Property(e => x => x.Confidence).HasPrecision(3, 2);
|
||||
entity.Property(e => x => x.IsActive).HasDefaultValue(true);
|
||||
|
||||
// 태그 번호로 Experion과 연동
|
||||
entity.HasOne(e => e.ExperionTag)
|
||||
.WithMany(t => t.PidEquipments)
|
||||
.HasForeignKey(e => e.ExperionTagId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// PidAuditLog 설정
|
||||
modelBuilder.Entity<PidAuditLog>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.UserId).HasMaxLength(100);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 기존 ExperionDbContext.cs 확장
|
||||
|
||||
```csharp
|
||||
public class ExperionDbContext : DbContext
|
||||
{
|
||||
// 기존 DbSet
|
||||
|
||||
// P&ID 데이터베이스용 DbSet 추가
|
||||
public DbSet<PidEquipment> PidEquipment { get; set; }
|
||||
public DbSet<PidAuditLog> PidAuditLog { get; set; }
|
||||
|
||||
// Expose PidDbContext connection string if needed
|
||||
public string PidConnectionString => Configuration.GetConnectionString("PidDb");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 필드 매핑
|
||||
|
||||
### P&ID 추출 필드 ↔ DB 필드
|
||||
|
||||
| 추출 필드 (AI) | DB 필드 (PidEquipment) | 설명 |
|
||||
|---------------------|--------------------------|----------------------------|
|
||||
| Tag No. | TagNo | 태그번호 (FT-1001, PT-2003) |
|
||||
| Equipment Name | EquipmentName | 장비명 (Flow Transmitter) |
|
||||
| Instrument Type | InstrumentType | 계기타입 (FT, PT, LT) |
|
||||
| Line Number | LineNumber | 라인번호 (6"-P-1001-A1A) |
|
||||
| P&ID Drawing No. | PidDrawingNo | 도면번호 (P&ID-100-001) |
|
||||
| Confidence | Confidence | 신뢰도 (0.0~1.0) |
|
||||
|
||||
---
|
||||
|
||||
## 💻 PidExtractorService.cs (핵심 서비스)
|
||||
|
||||
```csharp
|
||||
using Azure.AI.Vision.ImageAnalysis;
|
||||
using ExperionCrawler.Core.Application.Interfaces;
|
||||
using ExperionCrawler.Core.Domain.Entities;
|
||||
using ExperionCrawler.Infrastructure.Database;
|
||||
|
||||
namespace ExperionCrawler.Core.Application.Services;
|
||||
|
||||
public class PidExtractorService : IPidExtractorService
|
||||
{
|
||||
private readonly string _anthropicApiKey;
|
||||
private readonly BinaryData _systemPrompt;
|
||||
private readonly PidDbContext _pidDbContext;
|
||||
|
||||
public PidExtractorService(
|
||||
IConfiguration configuration,
|
||||
PidDbContext pidDbContext)
|
||||
{
|
||||
_anthropicApiKey = configuration["Anthropic:ApiKey"]!;
|
||||
_pidDbContext = pidDbContext;
|
||||
_systemPrompt = BinaryData.FromString(GetPrompt());
|
||||
}
|
||||
|
||||
public async Task<PidExtractionResult> ExtractFromFile(string filePath, bool useImageMode = false)
|
||||
{
|
||||
// 1. 파일 텍스트/이미지 변환
|
||||
var imageData = await PreprocessFile(filePath, useImageMode);
|
||||
|
||||
// 2. Claude Vision API 분석
|
||||
using var client = new ImageAnalysisClient(new Uri("https://vision.api.anthropic.com"),
|
||||
new System.ClientModel.ApiKeyCredential(_anthropicApiKey));
|
||||
|
||||
var result = await client.AnalyzeAsync(ImageAnalyzerOptions.Create(
|
||||
BinaryData.FromBytes(imageData),
|
||||
ImageAnalysisFeature.RecognizedText | ImageAnalysisFeature.DenseCaption
|
||||
));
|
||||
|
||||
// 3. JSON 파싱 및 검증
|
||||
var extractedItems = ParseExtractedData(result.Value.Text);
|
||||
|
||||
// 4. DB 저장
|
||||
var dbItems = new List<PidEquipment>();
|
||||
foreach (var item in extractedItems)
|
||||
{
|
||||
// 기존 태그와 매핑 확인
|
||||
var existingTag = await FindMatchingExperionTag(item.TagNo);
|
||||
var pidEquipment = new PidEquipment
|
||||
{
|
||||
TagNo = item.TagNo,
|
||||
EquipmentName = item.EquipmentName,
|
||||
InstrumentType = item.InstrumentType,
|
||||
LineNumber = item.LineNumber,
|
||||
PidDrawingNo = item.PidDrawingNo,
|
||||
Confidence = item.Confidence,
|
||||
ExperionTagId = existingTag?.Id,
|
||||
ExtractedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
dbItems.Add(pidEquipment);
|
||||
}
|
||||
|
||||
await _pidDbContext.PidEquipment.AddRangeAsync(dbItems);
|
||||
await _pidDbContext.SaveChangesAsync();
|
||||
|
||||
return new PidExtractionResult
|
||||
{
|
||||
TotalCount = dbItems.Count,
|
||||
ConfidenceItems = dbItems.Count(i => i.Confidence >= 0.7),
|
||||
LowConfidenceItems = dbItems.Count(i => i.Confidence < 0.5),
|
||||
CsvPath = $"output/pid_extracted_{DateTime.UtcNow:yyyyMMdd_HHmmss}.csv",
|
||||
ExcelPath = $"output/pid_AX_import_{DateTime.UtcNow:yyyyMMdd_HHmmss}.xlsx"
|
||||
};
|
||||
}
|
||||
|
||||
private string GetPrompt()
|
||||
{
|
||||
return @"
|
||||
Analyze the P&ID (Piping and Instrumentation Diagram) drawing and extract the following information.
|
||||
|
||||
Return ONLY pure JSON (no markdown, no explanations):
|
||||
{
|
||||
""items"": [
|
||||
{
|
||||
""tagNo"": ""Tag number (e.g., FT-1001, PT-2003, E-101, CV-123)"",
|
||||
""equipmentName"": ""Full equipment name (e.g., ""Flow Transmitter"")"",
|
||||
""instrumentType"": ""Short type code (FT, PT, LT, CV, E, V, P, etc.)"",
|
||||
""lineNumber"": ""Line reference (e.g., ""6\""-P-1001-A1A"")"",
|
||||
""pidDrawingNo"": ""P&ID drawing number (if identifiable)""
|
||||
}
|
||||
],
|
||||
""note"": ""Any items that cannot be clearly identified"" // optional
|
||||
}";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 PidController.cs (Web API)
|
||||
|
||||
```csharp
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ExperionCrawler.Core.Application.Interfaces;
|
||||
using ExperionCrawler.Core.Application.Dtos;
|
||||
|
||||
namespace ExperionCrawler.Web.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class PidController : ControllerBase
|
||||
{
|
||||
private readonly IPidExtractorService _pidExtractor;
|
||||
private readonly IExperionServices _experionServices;
|
||||
|
||||
public PidController(IPidExtractorService pidExtractor,
|
||||
IExperionServices experionServices)
|
||||
{
|
||||
_pidExtractor = pidExtractor;
|
||||
_experionServices = experionServices;
|
||||
}
|
||||
|
||||
[HttpPost("extract")]
|
||||
public async Task<IActionResult> ExtractFromFile(IFormFile file, bool useImageMode = false)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return BadRequest("파일이 없습니다.");
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _pidExtractor.ExtractFromStream(stream, file.FileName, useImageMode);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
totalCount = result.TotalCount,
|
||||
confidenceItems = result.ConfidenceItems,
|
||||
lowConfidenceItems = result.LowConfidenceItems,
|
||||
csvPath = result.CsvPath,
|
||||
excelPath = result.ExcelPath
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("equipment")]
|
||||
public async Task<IActionResult> GetEquipment(string tagNo = null, int page = 1, int pageSize = 50)
|
||||
{
|
||||
var query = _pidExtractor.GetQueryable();
|
||||
if (!string.IsNullOrEmpty(tagNo))
|
||||
query = query.Where(e => e.TagNo.Contains(tagNo));
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var items = await query
|
||||
.OrderByDescending(e => e.ExtractedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
confidenceRate = items.Sum(e => e.Confidence) / (items.Count > 0 ? items.Count : 1),
|
||||
items = items.Select(e => new
|
||||
{
|
||||
id = e.Id,
|
||||
tagNo = e.TagNo,
|
||||
equipmentName = e.EquipmentName,
|
||||
instrumentType = e.InstrumentType,
|
||||
lineNumber = e.LineNumber,
|
||||
pidDrawingNo = e.PidDrawingNo,
|
||||
confidence = e.Confidence,
|
||||
isActive = e.IsActive
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("statistics")]
|
||||
public async Task<IActionResult> GetStatistics()
|
||||
{
|
||||
var typeCount = await _pidExtractor.GetInstrumentTypeCount();
|
||||
var confidenceRange = await _pidExtractor.GetConfidenceDistribution();
|
||||
var drawingCount = await _pidExtractor.GetDrawingCount();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
typeCount,
|
||||
confidenceRange,
|
||||
drawingCount
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("{id}/confidence")]
|
||||
public async Task<IActionResult> UpdateConfidence(long id, decimal confidence)
|
||||
{
|
||||
if (confidence < 0 || confidence > 1)
|
||||
return BadRequest("신뢰도는 0~1 사이어야 합니다.");
|
||||
|
||||
await _pidExtractor.UpdateConfidence(id, confidence);
|
||||
return Ok(new { message = "신뢰도가 업데이트되었습니다." });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Frontend UI 확장 (app.js)
|
||||
|
||||
```javascript
|
||||
// P&ID 추출 및 관리 기능
|
||||
|
||||
class PidManager {
|
||||
constructor() {
|
||||
this.extractorFileInput = document.getElementById('pid-file-input');
|
||||
this.extractActionBtn = document.getElementById('extract-pid-btn');
|
||||
this.useImageMode = document.getElementById('use-image-mode');
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
this.extractActionBtn.addEventListener('click', () => this.handleExtract());
|
||||
|
||||
this.useImageMode.addEventListener('change', (e) => {
|
||||
this.extractActionBtn.textContent =
|
||||
e.target.checked ? '이미지 모드로 추출' : '텍스트 모드로 추출';
|
||||
});
|
||||
}
|
||||
|
||||
async handleExtract() {
|
||||
const file = this.extractorFileInput.files[0];
|
||||
if (!file) {
|
||||
alert('선택된 파일이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('useImageMode', this.useImageMode.checked);
|
||||
|
||||
this.extractActionBtn.disabled = true;
|
||||
this.extractActionBtn.textContent = '추출 중...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/pid/extract', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
this.showResult(result);
|
||||
this.loadEquipmentList();
|
||||
this.loadStatistics();
|
||||
|
||||
alert(`추출 완료! 총 ${result.totalCount}건 처리됨`);
|
||||
} catch (error) {
|
||||
console.error('추출 실패:', error);
|
||||
alert('추출 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
this.extractActionBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
showResult(result) {
|
||||
// 결과 표시 UI
|
||||
alert(`${result.totalCount}건 ${result.confidenceItems}건 신뢰도 높음`);
|
||||
}
|
||||
}
|
||||
|
||||
// 애플리케이션 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new PidManager();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 작업 순서
|
||||
|
||||
### 단계 1: DB 구조 생성
|
||||
1. [`PidDbContext.cs`](../src/Infrastructure/Database/PidDbContext.cs) 생성
|
||||
2. [`PidEquipment.cs`](../src/Core/Domain/Entities/PidEquipment.cs) 엔티티 생성
|
||||
3. [`PidAuditLog.cs`](../src/Core/Domain/Entities/PidAuditLog.cs) 엔티티 생성
|
||||
4. [`Program.cs`](../src/Web/Program.cs)에 서비스 등록 (`AddDbContext<PidDbContext>`)
|
||||
|
||||
### 단계 2: 커맨드라인 도구 개발
|
||||
1. [`PidExtractorService.cs`](../src/Core/Application/Services/PidExtractorService.cs) 개발
|
||||
2. CLIP 기반 추출기 연동 (Python `pid_extractor.py`)
|
||||
3. 테스트용 DXF/PDF 파일 생성
|
||||
4. 통합 테스트 수행
|
||||
|
||||
### 단계 3: Web API 개발
|
||||
1. [`IPidExtractorService.cs`](../src/Core/Application/Interfaces/IPidExtractorService.cs) 인터페이스 정의
|
||||
2. [`PidController.cs`](../src/Web/Controllers/PidController.cs) 개발
|
||||
3. CSV/Excel 다운로드 엔드포인트
|
||||
4. 검증된 데이터 필터링 기능
|
||||
|
||||
### 단계 4: Firebase 연동
|
||||
1. P&ID 추출된 태그와 Experion 실시간 태그 매핑
|
||||
2. 실시간 값 업데이트 동기화
|
||||
|
||||
### 단계 5: Frontend UI
|
||||
1. P&ID 추출 화면 추가
|
||||
2. 장비 목록 표시 및 필터링
|
||||
3. 신뢰도 시각화
|
||||
4. 검토 필요 항목 표시
|
||||
|
||||
### 단계 6: 최적화 및 모듈화
|
||||
1. PDF→이미지 변환 속도 최적화
|
||||
2. 대용량 파일 처리 스트리밍
|
||||
3. API 응답 최적화
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
1. **권한 문제**: `/temp/` 디렉토리에 PDF 변환된 이미지를 저장하므로 쓰기 권한 확인 필요
|
||||
2. **API 비용**: Claude Vision API 사용 시 비용 발생 가능 → 캐싱 전략 필요
|
||||
3. **대용량 파일**: DXF 이미지 모드는 느림 → 사용자에게 선택권 제공
|
||||
4. **네트워크**: Anthropic API 사용을 위해 외부 연결 필요
|
||||
|
||||
---
|
||||
|
||||
## 📊 성공 지표
|
||||
|
||||
- DXF/PDF 파일로부터 평균 성공 추출률 80% 이상
|
||||
- 100MB 이하 파일 처리 시 응답 시간 30초 이내
|
||||
- 신뢰도 0.7 이상 항목 자동 검증 기능
|
||||
- Redis 캐싱으로 API 요청 50% 감소
|
||||
|
||||
---
|
||||
|
||||
## 🚀 다음 단계
|
||||
|
||||
1. 현재 코드 베이스 검토 (`Program.cs`, `ExperionDbContext.cs`)
|
||||
2. `PID REST API` 기능 우선 구현
|
||||
3. Frontend 인터페이스
|
||||
4. Firebase 실시간 연동
|
||||
5. 모델 최적화 및 테스트
|
||||
1077
dxf-graph/P&ID_AX_Plan2.md
Normal file
1077
dxf-graph/P&ID_AX_Plan2.md
Normal file
File diff suppressed because it is too large
Load Diff
19
dxf-graph/PID_Parser_Plan_Revision.md
Normal file
19
dxf-graph/PID_Parser_Plan_Revision.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# 현재 문제점 분석
|
||||
한정된 자원의 하드웨어로 대용량의 일을 한번에 처리하려고 복잡한 프롬프트를 LLM 에게 주어 처리 시간의 지연과, 전달 및 응답 버퍼의 수신 문제발생
|
||||
|
||||
## 분산처리 기법 적용 및 로직 플로우
|
||||
|
||||
Reference Program : test_dxf_extract_pid1.py
|
||||
|
||||
1. Reference Program 같은 파일을 아래 5가지로 항목으로 작성하고,
|
||||
- dxf_extract_transmitter.py : FIT, FT, LT, PT, TE
|
||||
- dxf_extract_valve.py : FCV, LCV, TCV, PCV, XV
|
||||
- dxf_extract_gague.py : PG, TG, LG
|
||||
- dxf_extract_equipment.py : C-?????(Distilation Column), T-????(Tank), F-?????(Filter), D-?????(Drum,Condensor),E-?????(Heat Exchanger) B-?????(BOILER), CT-?????(COOLING TOWER), F-?????(COOLING FAN), CH-??????(CHILLER), K-?????(COMPRESSOR)
|
||||
- dxf_extract_pump.py : P-10106, VP-10117
|
||||
1. UI 추출시작 버튼 클릭 ->
|
||||
2. 메인 프로그램 시작 -> 파일 전처리(ezdxf)- 전달 받은 데이터 보유 후
|
||||
|
||||
3. 1항에서 작성한 프로그램들에 , 전처리 받은 데이터 전달하여, 5개 프로그램 모두 실행
|
||||
|
||||
4. 처리량에 따라 실행이 끝난 서브 프로그램들은 각각의 파일에 결과를 저장하게 프로그램 되어 있으니, , 이것을 메인프로그램이 서브 프로그램들의 종료 상태가 되면, 각각 후처리 과정(데이터베이스 저장 절차)을 진행
|
||||
1182
dxf-graph/PID_Parser_작업지시서_v3.md
Normal file
1182
dxf-graph/PID_Parser_작업지시서_v3.md
Normal file
File diff suppressed because it is too large
Load Diff
84
dxf-graph/dxf_extract_plan_revised.md
Normal file
84
dxf-graph/dxf_extract_plan_revised.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# 현재 문제점 분석
|
||||
한정된 자원의 하드웨어로 대용량의 일을 한번에 처리하려고 복잡한 프롬프트를 LLM 에게 주어 처리 시간의 지연과, 전달 및 응답 버퍼의 수신 문제발생
|
||||
|
||||
## 분산처리 기법 적용 및 로직 플로우
|
||||
|
||||
1. 추출시작 버튼 클릭 ->
|
||||
2. 메인 프로그램 시작 -> 파일 전처리(ezdxf)- 전달 받은 데이터 보유 후
|
||||
3. 미리 작성된 - 참조 파이썬 프로그램 :test_dxf_extract_pid1.py, ~pid2,py, ~pid3.py (for loop 없애고, 단일 chunk 실행으로 변경), 이 python과 같은 파일을 아래 5가지로 프로그램에 전달 받은 데이터 전달하며, 모두 실행 시킴
|
||||
- INSTRUMENTS
|
||||
- dxf_extract_transmitter.py : FIT, FT, LT, PT, TE
|
||||
- dxf_extract_valve.py : FCV, LCV, TCV, PCV, XV
|
||||
- dxf_extract_gague.py : PG, TG, LG
|
||||
- dxf_extract_equipment.py : C-?????(Distilation Column), T-????(Tank), F-?????(Filter), D-?????(Drum,Condensor),E-?????(Heat Exchanger) B-?????(BOILER), CT-?????(COOLING TOWER), F-?????(COOLING FAN), CH-??????(CHILLER), K-?????(COMPRESSOR)
|
||||
- dxf_extract_pump.py : P-10106, VP-10117
|
||||
3. 비동기로 실행이 끝난 서브 프로그램들은 각각의 파일에 결과가 저장될 것이고, 이것을 메인프로그램이 서브 프로그램들의 종료에 대하여 각각 후처리 과정(데이터베이스 저장 절차)을 진행
|
||||
|
||||
4. 위의 실증예 3개 프로그램 동시 실행시 , KV Cache 최대 사용량 30% 미만, 최대 95 token/sec, 실증됨.
|
||||
5. 각각의 max context length = 65536으로 설정할것
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
3. INSTRUMENTS 와 SYSTEM TAG (tagname)과의 관계설정
|
||||
- 예1) FICQ-10101.PV = FT-10101, FICQ-10101.OP = FCV-10101, FIQ-6115.PV = FT-6115, TI-6117.PV = TE-6117
|
||||
- LATER -->예2) P-10101.PV, P-10101.OP , XV-10111.PV, XV-10111.OP(LATER : INT 2BIT, 4BIT, 8BIT ENCODER OUTPUT)
|
||||
|
||||
### PLANT RESOURCE , FIELD INSTRUMENTS, EQUIPMENTS MANAGEMENT TABLE 신설
|
||||
4. PLANT RESOURCE : FILELD INSTRUMENTS용 데이터베이스 테이블 신설
|
||||
- Instruments Table Column:
|
||||
TagName,
|
||||
Type:
|
||||
- Flow Transmitter
|
||||
- (Type_sub) : Coriollis Mass Flowmeter
|
||||
- (Type_sub) : Variable Area Flowmeter
|
||||
- (Type_sub) : Rotameter
|
||||
- (Type_sub) : Magnetic Flowmeter
|
||||
- (Type_sub) :
|
||||
- (Type_sub) :
|
||||
- (Type_sub) :
|
||||
- (Type_sub) :
|
||||
- Pressure Transmitter
|
||||
- (Type_sub) : Absolute Pressure Transmitter (Vacuum)
|
||||
- (Type_sub) : Gauge Pressure Transmitter
|
||||
- (Type_sub) : DP Transmitter
|
||||
- Level Transmitter
|
||||
- (Type_sub) : Remote Sealed DP Transmitter
|
||||
- (Type_sub) : Float Level Transmitter
|
||||
- (Type_sub) :
|
||||
- Temperature Sensor
|
||||
- (Type_sub) : R.T.D (pt100)
|
||||
- (Type_sub) : Thermocople type k
|
||||
- (Type_sub) :
|
||||
- (Type_sub) :
|
||||
|
||||
- Control Valve
|
||||
- (Type_sub) : Globe 2 Way
|
||||
- (Size) : 25A
|
||||
- (Action) : Fail Close (Air to Open) / Fail Open (Air to Close)
|
||||
- (Type_sub) :
|
||||
- (Type_sub) :
|
||||
- On-Off Valve
|
||||
- (Type_sub) :
|
||||
- (Type_sub) :
|
||||
- Pressure Safety Vavle
|
||||
- (Type_sub) :
|
||||
- Pressure Relief Valve,
|
||||
- (Type_sub) :
|
||||
Type_sub,
|
||||
Range_Max,
|
||||
Range_Calibrated,
|
||||
Model_No,
|
||||
Installed_at,
|
||||
Repaired_at,
|
||||
Repair_history,
|
||||
Last_Calibrated_at,
|
||||
Recommended_Spare_parts
|
||||
Doc_No,
|
||||
TagName,
|
||||
DataSheet,
|
||||
Drawings,
|
||||
NamePlate, : (photo),
|
||||
Manual_No : (pdf file, numbering rule needed)
|
||||
|
||||
57
dxf-graph/extract_pdf.cs
Normal file
57
dxf-graph/extract_pdf.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
|
||||
using System;
|
||||
using System.Text;
|
||||
using UglyToad.PdfPig;
|
||||
using UglyToad.PdfPig.DocumentLayoutAnalysis.TextExtractor;
|
||||
|
||||
class Program
|
||||
{
|
||||
static void Main(string[] args)
|
||||
{
|
||||
string pdfPath = "/home/windpacer/projects/ExperionCrawler/futurePlan/plant-9100.pdf";
|
||||
string markdownPath = "/home/windpacer/projects/ExperionCrawler/futurePlan/plant-9100-extracted.md";
|
||||
|
||||
Console.WriteLine($"PdfPig 버전: {typeof(PdfDocument).Assembly.GetName().Version}");
|
||||
Console.WriteLine($"PDF 파일: {pdfPath}");
|
||||
Console.WriteLine();
|
||||
|
||||
using (var document = PdfDocument.Open(pdfPath))
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("# plant-9100.pdf 추출 결과");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## PDF 정보");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"- **버전**: {document.Version}");
|
||||
sb.AppendLine($"- **페이지 수**: {document.NumberOfPages}");
|
||||
sb.AppendLine($"- **제목**: {document.Information.Title ?? "(없음)"}");
|
||||
sb.AppendLine($"- **작성자**: {document.Information.Author ?? "(없음)"}");
|
||||
sb.AppendLine($"- **생성 프로그램**: {document.Information.Producer ?? "(없음)"}");
|
||||
sb.AppendLine($"- **생성일**: {document.Information.CreationDate ?? "(없음)"}");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var page in document.GetPages())
|
||||
{
|
||||
sb.AppendLine($"## 페이지 {page.Number}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"- **크기**: {page.Width} x {page.Height}");
|
||||
sb.AppendLine();
|
||||
|
||||
string text = page.Text;
|
||||
sb.AppendLine("### 추출 텍스트");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine(text);
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine();
|
||||
|
||||
Console.WriteLine($"페이지 {page.Number} 추출 완료 ({text.Length}자)");
|
||||
}
|
||||
|
||||
System.IO.File.WriteAllText(markdownPath, sb.ToString());
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"전체 추출 완료. 결과 저장: {markdownPath}");
|
||||
}
|
||||
}
|
||||
}
|
||||
11
dxf-graph/extract_pdf.csproj
Normal file
11
dxf-graph/extract_pdf.csproj
Normal file
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="PdfPig" Version="0.1.9" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
102
dxf-graph/extract_pdf.py
Normal file
102
dxf-graph/extract_pdf.py
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PdfPig를 사용하여 plant-9100.pdf에서 모든 문자열을 추출하여 마크다운 파일로 저장
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# C# 코드 작성
|
||||
csharp_code = '''
|
||||
using System;
|
||||
using System.Text;
|
||||
using UglyToad.PdfPig;
|
||||
using UglyToad.PdfPig.DocumentLayoutAnalysis.TextExtractor;
|
||||
|
||||
class Program
|
||||
{
|
||||
static void Main(string[] args)
|
||||
{
|
||||
string pdfPath = "/home/windpacer/projects/ExperionCrawler/futurePlan/plant-9100.pdf";
|
||||
string markdownPath = "/home/windpacer/projects/ExperionCrawler/futurePlan/plant-9100-extracted.md";
|
||||
|
||||
Console.WriteLine($"PdfPig 버전: {typeof(PdfDocument).Assembly.GetName().Version}");
|
||||
Console.WriteLine($"PDF 파일: {pdfPath}");
|
||||
Console.WriteLine();
|
||||
|
||||
using (var document = PdfDocument.Open(pdfPath))
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.AppendLine("# plant-9100.pdf 추출 결과");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## PDF 정보");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"- **버전**: {document.Version}");
|
||||
sb.AppendLine($"- **페이지 수**: {document.NumberOfPages}");
|
||||
sb.AppendLine($"- **제목**: {document.Information.Title ?? \"(없음)\"}");
|
||||
sb.AppendLine($"- **작성자**: {document.Information.Author ?? \"(없음)\"}");
|
||||
sb.AppendLine($"- **생성 프로그램**: {document.Information.Producer ?? \"(없음)\"}");
|
||||
sb.AppendLine($"- **생성일**: {document.Information.CreationDate ?? \"(없음)\"}");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var page in document.GetPages())
|
||||
{
|
||||
sb.AppendLine($"## 페이지 {page.Number}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"- **크기**: {page.Width} x {page.Height}");
|
||||
sb.AppendLine();
|
||||
|
||||
string text = page.Text;
|
||||
sb.AppendLine("### 추출 텍스트");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine(text);
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine();
|
||||
|
||||
Console.WriteLine($"페이지 {page.Number} 추출 완료 ({text.Length}자)");
|
||||
}
|
||||
|
||||
System.IO.File.WriteAllText(markdownPath, sb.ToString());
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"전체 추출 완료. 결과 저장: {markdownPath}");
|
||||
}
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
# C# 파일 저장
|
||||
with open('futurePlan/extract_pdf.cs', 'w') as f:
|
||||
f.write(csharp_code)
|
||||
print("C# 코드 작성 완료: futurePlan/extract_pdf.cs")
|
||||
|
||||
# 프로젝트 파일 생성
|
||||
project_code = '''<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="PdfPig" Version="0.1.9" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
'''
|
||||
|
||||
with open('futurePlan/extract_pdf.csproj', 'w') as f:
|
||||
f.write(project_code)
|
||||
print("프로젝트 파일 작성 완료: futurePlan/extract_pdf.csproj")
|
||||
|
||||
# 빌드 및 실행
|
||||
print()
|
||||
print("빌드 및 실행 중...")
|
||||
result = subprocess.run(
|
||||
['dotnet', 'run', '--project', 'futurePlan/extract_pdf.csproj', '--configuration', 'Release'],
|
||||
cwd='/home/windpacer/projects/ExperionCrawler',
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print("STDERR:", result.stderr)
|
||||
1079
dxf-graph/new_parser_coding_plan.md
Normal file
1079
dxf-graph/new_parser_coding_plan.md
Normal file
File diff suppressed because it is too large
Load Diff
1412
dxf-graph/p&id_ax-coding_plan.md
Normal file
1412
dxf-graph/p&id_ax-coding_plan.md
Normal file
File diff suppressed because it is too large
Load Diff
1203
dxf-graph/p&id_ax_UI_API_plan.md
Normal file
1203
dxf-graph/p&id_ax_UI_API_plan.md
Normal file
File diff suppressed because it is too large
Load Diff
1288
dxf-graph/p&id_ax_coding_plan2.md
Normal file
1288
dxf-graph/p&id_ax_coding_plan2.md
Normal file
File diff suppressed because it is too large
Load Diff
1477
dxf-graph/p&id_ax_coding_plan3.md
Normal file
1477
dxf-graph/p&id_ax_coding_plan3.md
Normal file
File diff suppressed because it is too large
Load Diff
795
dxf-graph/p&id_ax_coding_plan4.md
Normal file
795
dxf-graph/p&id_ax_coding_plan4.md
Normal file
@@ -0,0 +1,795 @@
|
||||
# P&ID AX 코딩 플랜 v4 (MCP 기반 로컬 LLM 구현)
|
||||
|
||||
> **수정일**: 2026-04-30 (v4 완료 — 백엔드 완료, MCP 툴 추가)
|
||||
> **수정일**: 2026-04-30 (v4.1 — 프론트엔드 완료)
|
||||
> **기준**: `p&id_ax_coding_plan3.md` 반영
|
||||
> **목적**: Anthropic Cloud Vision 제거 → MCP 경유 로컬 LLM(vLLM Qwen3-Coder-Next-FP8)으로 DXF/PDF 텍스트 추출 구현
|
||||
|
||||
**백엔드 완료 요약**:
|
||||
- 단계 1-8 완료 (P&ID 도메인, 서비스, 컨트롤러, DB, Program.cs 등록)
|
||||
- MCP 툴 추가: `extract_pid_tags`, `match_pid_tags` (mcp-server/server.py)
|
||||
- 빌드 검증: `dotnet build` 성공 (0 에러)
|
||||
- API 엔드포인트: `/api/pid/*` (13개)
|
||||
|
||||
**프론트엔드 완료 요약 (v4.1)**:
|
||||
- 단계 9-12 완료 (index.html, app.js, style.css, appsettings.json)
|
||||
- P&ID 추출 탭 추가 (11개 탭)
|
||||
- 추출 결과 테이블 + 페이지네이션
|
||||
- CSV/Excel 내보내기 기능
|
||||
- 통계 정보 표시
|
||||
|
||||
---
|
||||
|
||||
## 📋 개요
|
||||
|
||||
DXF/PDF 형식의 P&ID 도면에서 장비 및 계기 정보를 AI로 자동 추출하여 ExperionCrawler 데이터베이스와 연동하는 기능입니다.
|
||||
|
||||
**주요 변경사항**:
|
||||
- ❌ Anthropic Cloud Vision 제거
|
||||
- ✅ MCP 경유 로컬 LLM(vLLM Qwen3-Coder-Next-FP8) 사용
|
||||
- ✅ netDxf (DXF 파싱) + PdfPig (PDF 텍스트 추출) 사용
|
||||
|
||||
---
|
||||
|
||||
## 🎯 목표
|
||||
|
||||
1. P&ID 도면에서 장비 정보를 추출
|
||||
2. 추출된 정보를 PostgreSQL 로 저장
|
||||
3. 기존 Experion 데이터와 연동
|
||||
4. 웹에서 시각화 및 관리
|
||||
|
||||
---
|
||||
|
||||
## ✅ 완료된 단계 (v4) — 백엔드 완료 (2026-04-30)
|
||||
|
||||
| 단계 | 내용 | 상태 |
|
||||
|------|------|------|
|
||||
| 1 | P&ID 도메인 엔티티 생성 (`PidEquipment`, `PidAuditLog`) | ✅ 완료 |
|
||||
| 2 | DTOs 생성 (`PidEquipmentDto`, `PidExtractionResult`, `TagMappingDtos`) | ✅ 완료 |
|
||||
| 3 | Interface 정의 (`IPidExtractorService`, `ITagMappingService`) | ✅ 완료 |
|
||||
| 4 | TagMappingService 생성 | ✅ 완료 |
|
||||
| 5 | PidExtractorService 생성 (Anthropic → MCP 로컬 LLM) | ✅ 완료 |
|
||||
| 6 | Database Migration (`DbSet`, FK 설정) | ✅ 완료 |
|
||||
| 7 | PidController 추가 (`ExperionPidController` - 13개 엔드포인트) | ✅ 완료 |
|
||||
| 8 | Program.cs 등록 (`AddScoped<IPidExtractorService>`, `AddScoped<ITagMappingService>`) | ✅ 완료 |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 완료된 단계 (v4) — MCP 툴 추가 (2026-04-30)
|
||||
|
||||
| 단계 | 내용 | 상태 |
|
||||
|------|------|------|
|
||||
| 9 | Python MCP 툴 `extract_pid_tags` 구현 | ✅ 완료 |
|
||||
| 10 | Python MCP 툴 `match_pid_tags` 구현 | ✅ 완료 |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 완료된 단계 (v4) — 상세
|
||||
|
||||
### 단계 1: P&ID 도메인 엔티티 생성 ✅
|
||||
|
||||
**파일**: [`src/Core/Domain/Entities/PidEquipment.cs`](../src/Core/Domain/Entities/PidEquipment.cs)
|
||||
**파일**: [`src/Core/Domain/Entities/PidAuditLog.cs`](../src/Core/Domain/Entities/PidAuditLog.cs)
|
||||
|
||||
```csharp
|
||||
// PidEquipment.cs
|
||||
[Table("pid_equipment")]
|
||||
public class PidEquipment
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(50)]
|
||||
public string TagNo { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? EquipmentName { get; set; }
|
||||
|
||||
[MaxLength(10)]
|
||||
public string? InstrumentType { get; set; }
|
||||
|
||||
[MaxLength(100)]
|
||||
public string? LineNumber { get; set; }
|
||||
|
||||
[MaxLength(50)]
|
||||
public string? PidDrawingNo { get; set; }
|
||||
|
||||
public double Confidence { get; set; }
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
public DateTime ExtractedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
public int? ExperionTagId { get; set; }
|
||||
public RealtimePoint? ExperionTag { get; set; }
|
||||
}
|
||||
|
||||
// PidAuditLog.cs
|
||||
[Table("pid_audit_log")]
|
||||
public class PidAuditLog
|
||||
{
|
||||
public long Id { get; set; }
|
||||
[MaxLength(50)]
|
||||
public string Source { get; set; } = string.Empty;
|
||||
[MaxLength(50)]
|
||||
public string Action { get; set; } = string.Empty;
|
||||
[MaxLength(50)]
|
||||
public string TargetTagNo { get; set; } = string.Empty;
|
||||
public string? OldValue { get; set; }
|
||||
public string? NewValue { get; set; }
|
||||
public DateTime LoggedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
```
|
||||
|
||||
> **현황**: 이미 코드베이스에 완료됨. `UpdatedAt`이 `DateTime?` (nullable)으로 정의되어 있음.
|
||||
|
||||
---
|
||||
|
||||
### 단계 2: DTOs 생성 ✅
|
||||
|
||||
**파일**: [`src/Core/Application/DTOs/PidEquipmentDto.cs`](../src/Core/Application/DTOs/PidEquipmentDto.cs)
|
||||
**파일**: [`src/Core/Application/DTOs/PidExtractionResult.cs`](../src/Core/Application/DTOs/PidExtractionResult.cs)
|
||||
**파일**: [`src/Core/Application/DTOs/TagMappingDtos.cs`](../src/Core/Application/DTOs/TagMappingDtos.cs)
|
||||
|
||||
```csharp
|
||||
// PidEquipmentDto.cs
|
||||
public record PidEquipmentDto(
|
||||
long Id,
|
||||
string TagNo,
|
||||
string? EquipmentName,
|
||||
string? InstrumentType,
|
||||
string? LineNumber,
|
||||
string? PidDrawingNo,
|
||||
double Confidence,
|
||||
bool IsActive,
|
||||
DateTime ExtractedAt,
|
||||
DateTime? UpdatedAt,
|
||||
int? ExperionTagId,
|
||||
string? ExperionTagName);
|
||||
|
||||
// PidExtractionResult.cs
|
||||
public record PidExtractionResult(
|
||||
int TotalCount,
|
||||
int ConfidenceItems,
|
||||
int LowConfidenceItems);
|
||||
|
||||
// TagMappingDtos.cs
|
||||
public record TagMappingResult
|
||||
{
|
||||
public long PidEquipmentId { get; set; }
|
||||
public string TagNo { get; set; } = string.Empty;
|
||||
public string? EquipmentName { get; set; }
|
||||
public string? InstrumentType { get; set; }
|
||||
public string? LineNumber { get; set; }
|
||||
public string? PidDrawingNo { get; set; }
|
||||
public double Confidence { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public int? ExperionTagId { get; set; }
|
||||
public string? ExperionTagName { get; set; }
|
||||
public string? ExperionNodeId { get; set; }
|
||||
}
|
||||
|
||||
public record CreateMappingRequest(long PidEquipmentId, int ExperionTagId);
|
||||
public record UpdateMappingRequest(int? ExperionTagId, bool? IsActive);
|
||||
```
|
||||
|
||||
> **현황**: 이미 코드베이스에 완료됨. `ExtractedItem`/`MappingItem`은 `PidExtractorService.cs` 내부에 `public class`로 정의됨.
|
||||
|
||||
---
|
||||
|
||||
### 단계 3: Interface 정의 ✅
|
||||
|
||||
**파일**: [`src/Core/Application/Interfaces/IExperionServices.cs`](../src/Core/Application/Interfaces/IExperionServices.cs)
|
||||
|
||||
```csharp
|
||||
// P&ID Extractor
|
||||
public interface IPidExtractorService
|
||||
{
|
||||
Task<PidExtractionResult> ExtractFromFileAsync(string filePath, bool useImageMode = false);
|
||||
Task<PidExtractionResult> ExtractFromStreamAsync(Stream stream, string fileName, bool useImageMode = false);
|
||||
|
||||
Task<(int Total, IEnumerable<PidEquipment> Items)> GetEquipmentAsync(string? tagNo, int page, int pageSize);
|
||||
Task<PidEquipment?> GetByIdAsync(long id);
|
||||
|
||||
Task UpdateConfidenceAsync(long id, double confidence);
|
||||
Task ActivateAsync(long id);
|
||||
Task DeactivateAsync(long id);
|
||||
|
||||
Task<int> GetTotalCountAsync();
|
||||
Task<int> GetConfidenceItemsCountAsync();
|
||||
Task<int> GetLowConfidenceItemsCountAsync();
|
||||
Task<IDictionary<string, int>> GetConfidenceDistributionAsync();
|
||||
Task<int> GetDrawingCountAsync();
|
||||
|
||||
Task<string> ExportToCsvAsync(IEnumerable<PidEquipment> items);
|
||||
Task<byte[]> ExportToExcelAsync(IEnumerable<PidEquipment> items);
|
||||
}
|
||||
|
||||
// P&ID Tag Mapping
|
||||
public interface ITagMappingService
|
||||
{
|
||||
Task<(int Total, IEnumerable<TagMappingResult> Items)> GetMappingsAsync(int page, int pageSize);
|
||||
Task<TagMappingResult?> GetMappingByIdAsync(long id);
|
||||
Task<TagMappingResult> CreateMappingAsync(CreateMappingRequest request);
|
||||
Task UpdateMappingAsync(long id, UpdateMappingRequest request);
|
||||
Task ClearMappingAsync(long id);
|
||||
|
||||
Task<int> GetUnmappedCountAsync();
|
||||
Task<int> GetMappedCountAsync();
|
||||
Task<IEnumerable<string>> GetAvailableTagsAsync();
|
||||
}
|
||||
```
|
||||
|
||||
> **현황**: 이미 코드베이스에 완료됨. `IExperionServices.cs`에 `IPidExtractorService`, `ITagMappingService` 모두 정의됨.
|
||||
|
||||
---
|
||||
|
||||
### 단계 4: TagMappingService 생성 ✅
|
||||
|
||||
**파일**: [`src/Core/Application/Services/TagMappingService.cs`](../src/Core/Application/Services/TagMappingService.cs)
|
||||
|
||||
```csharp
|
||||
public class TagMappingService : ITagMappingService
|
||||
{
|
||||
private readonly ExperionDbContext _dbContext;
|
||||
|
||||
public TagMappingService(ExperionDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
public async Task<(int Total, IEnumerable<TagMappingResult> Items)> GetMappingsAsync(int page, int pageSize)
|
||||
{
|
||||
var query = from pe in _dbContext.PidEquipment
|
||||
join rt in _dbContext.RealtimePoints
|
||||
on pe.ExperionTagId equals rt.Id into joined
|
||||
from rt in joined.DefaultIfEmpty()
|
||||
select new TagMappingResult { ... };
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var items = await query
|
||||
.OrderByDescending(e => e.Confidence)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
return (total, items);
|
||||
}
|
||||
|
||||
public async Task<TagMappingResult?> GetMappingByIdAsync(long id) { ... }
|
||||
public async Task<TagMappingResult> CreateMappingAsync(CreateMappingRequest request) { ... }
|
||||
public async Task UpdateMappingAsync(long id, UpdateMappingRequest request) { ... }
|
||||
public async Task ClearMappingAsync(long id) { ... }
|
||||
|
||||
public async Task<int> GetUnmappedCountAsync()
|
||||
=> await _dbContext.PidEquipment.CountAsync(e => e.ExperionTagId == null);
|
||||
|
||||
public async Task<int> GetMappedCountAsync()
|
||||
=> await _dbContext.PidEquipment.CountAsync(e => e.ExperionTagId != null);
|
||||
|
||||
public async Task<IEnumerable<string>> GetAvailableTagsAsync()
|
||||
{
|
||||
var mappedTagIds = await _dbContext.PidEquipment
|
||||
.Where(e => e.ExperionTagId != null)
|
||||
.Select(e => e.ExperionTagId)
|
||||
.ToListAsync();
|
||||
|
||||
return await _dbContext.RealtimePoints
|
||||
.Where(t => !mappedTagIds.Contains(t.Id))
|
||||
.Select(t => t.TagName)
|
||||
.OrderBy(t => t)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **현황**: 이미 코드베이스에 완료됨. `GetAvailableTagsAsync()`가 이미 매핑된 태그를 제외한 목록만 반환하는 올바른 구현이 있음.
|
||||
|
||||
---
|
||||
|
||||
### 단계 5: PidExtractorService 생성 ✅
|
||||
|
||||
**파일**: [`src/Core/Application/Services/PidExtractorService.cs`](../src/Core/Application/Services/PidExtractorService.cs)
|
||||
|
||||
```csharp
|
||||
public class PidExtractorService : IPidExtractorService
|
||||
{
|
||||
private readonly McpClient _mcp;
|
||||
private readonly ExperionDbContext _dbContext;
|
||||
private readonly ILogger<PidExtractorService> _logger;
|
||||
|
||||
public PidExtractorService(McpClient mcp, ExperionDbContext dbContext, ILogger<PidExtractorService> logger)
|
||||
{
|
||||
_mcp = mcp;
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<PidExtractionResult> ExtractFromFileAsync(string filePath, bool useImageMode = false)
|
||||
{
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
return await ExtractFromStreamAsync(stream, Path.GetFileName(filePath), useImageMode);
|
||||
}
|
||||
|
||||
public async Task<PidExtractionResult> ExtractFromStreamAsync(Stream stream, string fileName, bool useImageMode = false)
|
||||
{
|
||||
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
|
||||
string text = ext switch
|
||||
{
|
||||
".dxf" => ExtractDxfText(stream),
|
||||
".pdf" => ExtractPdfText(stream),
|
||||
_ => throw new NotSupportedException($"지원 형식: .dxf .pdf (스캔본 이미지는 Vision 모드 필요)")
|
||||
};
|
||||
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return new PidExtractionResult(0, 0, 0);
|
||||
|
||||
// MCP → vLLM 태그 추출
|
||||
var sourceType = ext.TrimStart('.');
|
||||
var json = await _mcp.ExtractPidTagsAsync(text, sourceType);
|
||||
var extractedItems = ParseJson(json);
|
||||
|
||||
if (extractedItems.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("P&ID 추출 결과 0건 — 파일: {FileName}", fileName);
|
||||
return new PidExtractionResult(0, 0, 0);
|
||||
}
|
||||
|
||||
// MCP → vLLM 태그 매핑 제안
|
||||
var pidTagNos = extractedItems.Select(i => i.TagNo).Distinct().ToList();
|
||||
var experionTagNames = await _dbContext.RealtimePoints.Select(r => r.TagName).ToListAsync();
|
||||
var mappingJson = await _mcp.MatchPidTagsAsync(pidTagNos, experionTagNames);
|
||||
var mappings = ParseMappingJson(mappingJson);
|
||||
|
||||
// DB 저장
|
||||
var dbItems = new List<PidEquipment>();
|
||||
foreach (var item in extractedItems)
|
||||
{
|
||||
mappings.TryGetValue(item.TagNo, out var matched);
|
||||
var experionTag = matched != null
|
||||
? await _dbContext.RealtimePoints.FirstOrDefaultAsync(r => r.TagName == matched)
|
||||
: await FindFallbackTagAsync(item.TagNo);
|
||||
|
||||
dbItems.Add(new PidEquipment
|
||||
{
|
||||
TagNo = item.TagNo,
|
||||
EquipmentName = item.EquipmentName,
|
||||
InstrumentType = item.InstrumentType,
|
||||
LineNumber = item.LineNumber,
|
||||
PidDrawingNo = item.PidDrawingNo,
|
||||
Confidence = item.Confidence,
|
||||
ExperionTagId = experionTag?.Id,
|
||||
ExtractedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
await _dbContext.PidEquipment.AddRangeAsync(dbItems);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("P&ID 추출 완료: {Total}건 저장 (파일: {FileName})", dbItems.Count, fileName);
|
||||
|
||||
return new PidExtractionResult(
|
||||
TotalCount: dbItems.Count,
|
||||
ConfidenceItems: dbItems.Count(i => i.Confidence >= 0.7),
|
||||
LowConfidenceItems: dbItems.Count(i => i.Confidence < 0.5));
|
||||
}
|
||||
|
||||
private string ExtractDxfText(Stream stream) { ... }
|
||||
private string ExtractPdfText(Stream stream) { ... }
|
||||
private List<ExtractedItem> ParseJson(string json) { ... }
|
||||
private Dictionary<string, string> ParseMappingJson(string json) { ... }
|
||||
private async Task<RealtimePoint?> FindFallbackTagAsync(string tagNo) { ... }
|
||||
|
||||
// CRUD / 통계 / 내보내기 메서드들...
|
||||
}
|
||||
```
|
||||
|
||||
**내부 파싱용 모델**:
|
||||
```csharp
|
||||
public class ExtractedItem
|
||||
{
|
||||
public string TagNo { get; set; } = "";
|
||||
public string? EquipmentName { get; set; }
|
||||
public string? InstrumentType { get; set; }
|
||||
public string? LineNumber { get; set; }
|
||||
public string? PidDrawingNo { get; set; }
|
||||
public double Confidence { get; set; } = 0.5;
|
||||
}
|
||||
|
||||
public class MappingItem
|
||||
{
|
||||
public string PidTag { get; set; } = "";
|
||||
public string? ExperionTag { get; set; }
|
||||
public double Confidence { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
> **현황**: `PidExtractorService.cs`가 MCP 기반으로 완료됨. `netDxf`, `PdfPig`, `EPPlus` 패키지 추가 완료.
|
||||
|
||||
---
|
||||
|
||||
## 📦 추가된 패키지
|
||||
|
||||
| 패키지 | 버전 | 용도 |
|
||||
|--------|------|------|
|
||||
| netDxf | 2022.11.2 | DXF 파일 파싱 |
|
||||
| PdfPig | 0.1.9 | PDF 텍스트 추출 |
|
||||
| EPPlus | 7.4.2 | Excel 내보내기 |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 완료된 단계 (v4) - 추가
|
||||
|
||||
### 단계 6: Database Migration ✅
|
||||
|
||||
**파일**: [`src/Infrastructure/Database/ExperionDbContext.cs`](../src/Infrastructure/Database/ExperionDbContext.cs)
|
||||
|
||||
```csharp
|
||||
public class ExperionDbContext : DbContext
|
||||
{
|
||||
public DbSet<PidEquipment> PidEquipment => Set<PidEquipment>();
|
||||
public DbSet<PidAuditLog> PidAuditLog => Set<PidAuditLog>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// PidEquipment → RealtimePoint FK
|
||||
modelBuilder.Entity<PidEquipment>(entity =>
|
||||
{
|
||||
entity.HasOne(e => e.ExperionTag)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.ExperionTagId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **현황**: 이미 코드베이스에 완료됨. `DbSet`과 FK 설정 모두 정상 구현됨.
|
||||
|
||||
---
|
||||
|
||||
### 단계 7: PidController 추가 ✅
|
||||
|
||||
**파일**: [`src/Web/Controllers/ExperionControllers.cs`](../src/Web/Controllers/ExperionControllers.cs) (810-985행)
|
||||
|
||||
```csharp
|
||||
[ApiController]
|
||||
[Route("api/pid")]
|
||||
public class ExperionPidController : ControllerBase
|
||||
{
|
||||
private readonly IPidExtractorService _extractor;
|
||||
private readonly ITagMappingService _mapping;
|
||||
|
||||
public ExperionPidController(IPidExtractorService extractor, ITagMappingService mapping)
|
||||
{
|
||||
_extractor = extractor;
|
||||
_mapping = mapping;
|
||||
}
|
||||
|
||||
[HttpPost("extract")]
|
||||
[RequestSizeLimit(100 * 1024 * 1024)]
|
||||
public async Task<IActionResult> Extract(IFormFile file) { ... }
|
||||
|
||||
[HttpGet("equipment")]
|
||||
public async Task<IActionResult> GetEquipment(...) { ... }
|
||||
|
||||
[HttpGet("statistics")]
|
||||
public async Task<IActionResult> GetStatistics() { ... }
|
||||
|
||||
[HttpPut("{id:long}/confidence")]
|
||||
public async Task<IActionResult> UpdateConfidence(long id, [FromBody] double confidence) { ... }
|
||||
|
||||
[HttpPost("{id:long}/activate")]
|
||||
public async Task<IActionResult> Activate(long id) { ... }
|
||||
|
||||
[HttpPost("{id:long}/deactivate")]
|
||||
public async Task<IActionResult> Deactivate(long id) { ... }
|
||||
|
||||
[HttpGet("mappings")]
|
||||
public async Task<IActionResult> GetMappings(...) { ... }
|
||||
|
||||
[HttpPost("mappings")]
|
||||
public async Task<IActionResult> CreateMapping([FromBody] CreateMappingRequest req) { ... }
|
||||
|
||||
[HttpPut("mappings/{id:long}")]
|
||||
public async Task<IActionResult> UpdateMapping(long id, [FromBody] UpdateMappingRequest req) { ... }
|
||||
|
||||
[HttpDelete("mappings/{id:long}")]
|
||||
public async Task<IActionResult> ClearMapping(long id) { ... }
|
||||
|
||||
[HttpGet("mappings/available-tags")]
|
||||
public async Task<IActionResult> GetAvailableTags() { ... }
|
||||
|
||||
[HttpGet("export/csv")]
|
||||
public async Task<IActionResult> ExportCsv([FromQuery] string? tagNo) { ... }
|
||||
|
||||
[HttpGet("export/excel")]
|
||||
public async Task<IActionResult> ExportExcel([FromQuery] string? tagNo) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
> **현황**: `ExperionControllers.cs`에 `ExperionPidController`가 완료됨.
|
||||
> **API 엔드포인트**: `/api/pid/*` (13개 엔드포인트)
|
||||
|
||||
---
|
||||
|
||||
### 단계 8: Program.cs 등록 ✅
|
||||
|
||||
**파일**: [`src/Web/Program.cs`](../src/Web/Program.cs)
|
||||
|
||||
```csharp
|
||||
// Line 85-86
|
||||
builder.Services.AddScoped<IPidExtractorService, PidExtractorService>();
|
||||
builder.Services.AddScoped<ITagMappingService, TagMappingService>();
|
||||
```
|
||||
|
||||
> **현황**: 이미 코드베이스에 완료됨. `AddScoped` 등록 정상 구현됨.
|
||||
|
||||
---
|
||||
|
||||
### 단계 9~14: Frontend 및 기타 (2026-04-30)
|
||||
|
||||
> **상태**: 단계 8까지 백엔드 완료. MCP 툴(`extract_pid_tags`, `match_pid_tags`) 구현 완료.
|
||||
> 단계 9-12 프론트엔드 구현 완료. 다음 단계는 통합 테스트입니다.
|
||||
|
||||
| 단계 | 내용 | 상태 |
|
||||
|------|------|------|
|
||||
| 9 | `index.html`에 P&ID 탭 + pane 추가 | ✅ 완료 (2026-04-30) |
|
||||
| 10 | `app.js`에 P&ID 함수 추가 | ✅ 완료 (2026-04-30) |
|
||||
| 11 | `style.css`에 P&ID 스타일 추가 | ✅ 완료 (2026-04-30) |
|
||||
| 12 | `appsettings.json` Kestrel 설정 추가 | ✅ 완료 (2026-04-30) |
|
||||
| 13 | 실제 DXF/PDF 파일 업로드 테스트 | 대기 |
|
||||
| 14 | 수동 매핑 UI 동작 확인 | 대기 |
|
||||
|
||||
---
|
||||
|
||||
### 단계 9: index.html에 P&ID 탭 + pane 추가 ✅
|
||||
|
||||
**파일**: [`src/Web/wwwroot/index.html`](../src/Web/wwwroot/index.html)
|
||||
|
||||
**변경 내용**:
|
||||
1. 탭 목록에 P&ID 탭 추가 (11번째 탭)
|
||||
2. `pane-pid` 섹션 추가 (fastRecord 다음)
|
||||
|
||||
**구조**:
|
||||
```html
|
||||
<li class="nav-item" data-tab="pid">
|
||||
<span class="ni">11</span>
|
||||
<span class="nl">P&ID 추출</span>
|
||||
</li>
|
||||
|
||||
<section class="pane" id="pane-pid">
|
||||
<!-- 파일 업로드 카드 -->
|
||||
<div class="card">
|
||||
<div class="card-cap">P&ID 파일 업로드</div>
|
||||
<input type="file" id="pid-file-input" accept=".dxf,.pdf"/>
|
||||
<button onclick="pidExtract()">🚀 추출 시작</button>
|
||||
</div>
|
||||
|
||||
<!-- 추출 결과 테이블 -->
|
||||
<div class="card">
|
||||
<table class="table" id="pid-table">
|
||||
<thead>...</thead>
|
||||
<tbody id="pid-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 통계 카드 -->
|
||||
<div class="card">
|
||||
<div class="stat-box">
|
||||
<div class="stat-label">총 추출 건수</div>
|
||||
<div class="stat-value" id="pid-stat-total">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
> **현황**: index.html에 P&ID 탭 및 pane 완료. fastRecord 다음에 추가.
|
||||
|
||||
---
|
||||
|
||||
### 단계 10: app.js에 P&ID 함수 추가 ✅
|
||||
|
||||
**파일**: [`src/Web/wwwroot/js/app.js`](../src/Web/wwwroot/js/app.js)
|
||||
|
||||
**추가된 함수**:
|
||||
- `pidExtract()` — 파일 업로드 및 추출 시작
|
||||
- `pidLoadTable(page)` — 추출 결과 테이블 로드
|
||||
- `pidRenderPagination(total, currentPage)` — 페이지네이션 렌더링
|
||||
- `pidUpdateStats()` — 통계 정보 업데이트
|
||||
- `pidClearLog()` — 로그 지우기
|
||||
- `pidOpenMapping(id)` — 매핑 모달 열기
|
||||
|
||||
**API 연동**:
|
||||
- `/api/pid/extract` — POST (파일 업로드)
|
||||
- `/api/pid/equipment` — GET (목록 조회)
|
||||
- `/api/pid/export/csv` — GET (CSV 내보내기)
|
||||
- `/api/pid/export/excel` — GET (Excel 내보내기)
|
||||
|
||||
> **현황**: app.js에 P&ID 함수 완료. 기존 패턴(인증서 관리, Text-to-SQL) 따름.
|
||||
|
||||
---
|
||||
|
||||
### 단계 11: style.css에 P&ID 스타일 추가 ✅
|
||||
|
||||
**파일**: [`src/Web/wwwroot/css/style.css`](../src/Web/wwwroot/css/style.css)
|
||||
|
||||
**추가된 스타일**:
|
||||
- `#pane-pid .btn-sm` — 버튼 스타일 (btn-a, btn-b)
|
||||
- `#pane-pid .badge` — 배지 스타일 (ok, warn, err)
|
||||
- `#pane-pid .stat-box` — 통계 박스 스타일
|
||||
- `#pane-pid .pagination` — 페이지네이션 스타일
|
||||
- `#pane-pid .logbox` — 로그 박스 스타일
|
||||
|
||||
> **현황**: style.css에 P&ID 스타일 완료. 다크 테마 색상 사용.
|
||||
|
||||
---
|
||||
|
||||
### 단계 12: appsettings.json Kestrel 설정 추가 ✅
|
||||
|
||||
**파일**: [`src/Web/appsettings.json`](../src/Web/appsettings.json)
|
||||
|
||||
**추가된 설정**:
|
||||
```json
|
||||
{
|
||||
"Kestrel": {
|
||||
"Endpoints": {
|
||||
"Http": {
|
||||
"Url": "http://0.0.0.0:5000"
|
||||
}
|
||||
},
|
||||
"Limits": {
|
||||
"MaxRequestBodySize": 104857600
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**설명**:
|
||||
- `MaxRequestBodySize`: 100MB (DXF/PDF 파일 업로드용)
|
||||
- `Url`: 모든 네트워크 인터페이스에서 수신
|
||||
|
||||
> **현황**: appsettings.json에 Kestrel 설정 완료.
|
||||
|
||||
---
|
||||
|
||||
## 📋 구현 순서 및 체크리스트
|
||||
|
||||
### Phase 1 — 백엔드 (완료)
|
||||
|
||||
- [x] **단계 1**: `ExperionCrawler.csproj`에 `netDxf`, `PdfPig`, `EPPlus` 패키지 추가 후 `dotnet build` 확인
|
||||
- [x] **단계 2**: `McpClient.cs`에 `ExtractPidTagsAsync`, `MatchPidTagsAsync` 메서드 추가
|
||||
- [x] **단계 3**: Python MCP 서버에 `extract_pid_tags`, `match_pid_tags` 툴 추가 및 테스트
|
||||
- [x] **단계 4**: `PidExtractorService.cs` 전체 교체
|
||||
- [x] **단계 5**: `ExperionControllers.cs`에 `ExperionPidController` 추가
|
||||
- [x] **단계 6**: `dotnet build` 에러 0건 확인 (2026-04-30 확인)
|
||||
- [ ] **단계 7**: Swagger(`/swagger`)에서 `/api/pid/*` 엔드포인트 노출 확인 (대기)
|
||||
|
||||
### Phase 2 — 프론트엔드 (완료)
|
||||
|
||||
- [x] **단계 9**: `index.html`에 P&ID 탭 + pane 추가 (2026-04-30)
|
||||
- [x] **단계 10**: `app.js`에 P&ID 함수 추가 (2026-04-30)
|
||||
- [x] **단계 11**: `style.css`에 P&ID 스타일 추가 (2026-04-30)
|
||||
- [x] **단계 12**: `appsettings.json` Kestrel 설정 추가 (2026-04-30)
|
||||
|
||||
### Phase 3 — 통합 테스트 (대기)
|
||||
|
||||
- [ ] 실제 DXF 파일 업로드 → 태그 추출 확인
|
||||
- [ ] 실제 PDF(텍스트) 파일 업로드 → 태그 추출 확인
|
||||
- [ ] 추출 결과 → Experion 태그 자동 매핑 제안 확인
|
||||
- [ ] 수동 매핑 UI 동작 확인
|
||||
- [ ] CSV/Excel 내보내기 확인
|
||||
- [ ] MCP 서버 다운 시 에러 처리 확인
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 주요 주의사항
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| MCP 서버 의존성 | `localhost:5001` 응답 없으면 추출 실패 — UI에서 `ping` 사전 확인 권장 |
|
||||
| netDxf 임시 파일 | DXF 파싱 시 `/tmp`에 임시 파일 생성/삭제 — 권한 및 디스크 여유 확인 |
|
||||
| PDF 텍스트 추출 실패 | 스캔본 PDF는 `ExtractPdfText()`가 빈 문자열 반환 → Vision 미구현 안내 메시지 |
|
||||
| 프롬프트 튜닝 | 도면 특성(한국어/영어, 태그 표기 방식)에 따라 MCP 서버 프롬프트 조정 필요 |
|
||||
| 대용량 DXF | 1만 개 이상 엔티티 시 LLM 컨텍스트 초과 가능 → `text[:12000]` 슬라이싱으로 제한 중 |
|
||||
| 태그 매핑 확신도 | `confidence < 0.7` 자동 매핑은 저장하지 않음 — 수동 매핑 유도 |
|
||||
|
||||
---
|
||||
|
||||
## 📝 코딩 가이드
|
||||
|
||||
### 1. 기존 패턴 엄수
|
||||
|
||||
```
|
||||
새 기능 추가 시 반드시 기존 코드 패턴을 따를 것
|
||||
|
||||
✅ 엔티티: [Table("테이블명")], [Column("컬럼명")] 어트리뷰트 필수
|
||||
✅ DbContext: ExperionDbContext 단일 컨텍스트 — 별도 DbContext 생성 금지
|
||||
✅ 서비스 등록: Program.cs에 AddScoped<Interface, Implementation>() 형태
|
||||
✅ 컨트롤러: ExperionControllers.cs 단일 파일에 추가 (별도 파일 생성 금지)
|
||||
✅ 탭 진입: API 자동 호출 금지 — 버튼 클릭으로만 동작
|
||||
✅ DOM 렌더링: innerHTML += 루프 금지 — rows 배열 .join('') 후 한번에 설정
|
||||
✅ 로깅: Console.WriteLine 금지 — ILogger<T> 사용
|
||||
```
|
||||
|
||||
### 2. MCP 서버 연동 패턴
|
||||
|
||||
```
|
||||
C# 서비스에서 MCP 호출 시 McpClient를 직접 주입.
|
||||
IMcpService를 통하지 않아도 됨 (McpClient는 Singleton 등록).
|
||||
```
|
||||
|
||||
```csharp
|
||||
// 올바른 패턴
|
||||
public MyService(McpClient mcp, ExperionDbContext db, ILogger<MyService> logger)
|
||||
|
||||
// 금지
|
||||
public MyService(IMcpService mcp) // 래핑 레이어가 필요할 때만 사용
|
||||
```
|
||||
|
||||
### 3. Python MCP 툴 작성 규칙
|
||||
|
||||
```python
|
||||
# 응답은 반드시 순수 JSON 문자열 반환
|
||||
# 코드펜스(```json) 제거 후 반환
|
||||
# LLM 응답에서 JSON 배열 추출: re.search(r'\[.*\]', raw, re.DOTALL)
|
||||
# temperature=0.1 고정 (결정론적 출력)
|
||||
# 텍스트 슬라이싱: text[:12000] (컨텍스트 초과 방지)
|
||||
```
|
||||
|
||||
### 4. 프론트엔드 규칙
|
||||
|
||||
```
|
||||
✅ 함수 기반 작성 — class 사용 금지
|
||||
✅ 기존 헬퍼 함수 재사용: esc(), log(), setGlobal(), api()
|
||||
✅ 페이지네이션: pidPagination() 패턴 — 전체 페이지 버튼 생성 금지 (±3 범위)
|
||||
✅ 다크 테마 색상 사용: #1e1e1e, #2d2d2d, #ccc, .ok/.err/.warn CSS 클래스
|
||||
✅ Bootstrap 클래스 사용 금지
|
||||
✅ fetch 에러 처리: if (!res.ok) throw new Error(...)
|
||||
```
|
||||
|
||||
### 5. 빌드 검증 체크포인트
|
||||
|
||||
```bash
|
||||
# 각 단계 완료 후 실행
|
||||
dotnet build src/Web/ExperionCrawler.csproj
|
||||
|
||||
# 목표: 경고 N건, 에러 0건
|
||||
# netDxf/PdfPig/EPPlus 추가 후 새 경고 없어야 정상
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧐 감독자 진단 — 단계 8까지 완료 여부 (2026-04-30 기준)
|
||||
|
||||
| 항목 | 확인 내용 | 상태 |
|
||||
|------|-----------|------|
|
||||
| **엔티티** | `PidEquipment.cs`, `PidAuditLog.cs` 존재 | ✅ |
|
||||
| **DbSet** | `ExperionDbContext.PidEquipment`, `PidAuditLog` 등록 | ✅ |
|
||||
| **FK 설정** | `ExperionTagId` → `RealtimePoint.Id` FK 설정 | ✅ |
|
||||
| **DTOs** | `PidEquipmentDto`, `PidExtractionResult`, `TagMappingDtos` 존재 | ✅ |
|
||||
| **인터페이스** | `IPidExtractorService`, `ITagMappingService` 정의 | ✅ |
|
||||
| **서비스** | `TagMappingService`, `PidExtractorService` 구현 | ✅ |
|
||||
| **컨트롤러** | `ExperionPidController` (13개 엔드포인트) | ✅ |
|
||||
| **Program.cs** | `AddScoped<IPidExtractorService>`, `AddScoped<ITagMappingService>` | ✅ |
|
||||
| **MCP 툴** | `extract_pid_tags`, `match_pid_tags` 구현 | ✅ |
|
||||
| **빌드** | `dotnet build` 에러 0건 | ✅ |
|
||||
|
||||
> **진단 결과**: 단계 1-8 완료. MCP 툴 추가 완료. 다음 단계는 프론트엔드 구현(단계 9-12) 및 통합 테스트(단계 13-14)입니다.
|
||||
|
||||
### 7. MCP 툴 독립 테스트
|
||||
|
||||
```bash
|
||||
# Python MCP 서버 직접 테스트 (C# 없이)
|
||||
curl -X POST http://localhost:5001/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "mcp-protocol-version: 2025-03-26" \
|
||||
-d '{"jsonrpc":"2.0","id":"1","method":"tools/call",
|
||||
"params":{"name":"extract_pid_tags",
|
||||
"arguments":{"text":"FT-101 Flow Transmitter\nPT-201 Pressure","source_type":"dxf"}}}'
|
||||
# 기대 응답: [{"tagNo":"FT-101",...},{"tagNo":"PT-201",...}]
|
||||
```
|
||||
BIN
dxf-graph/p-9100-모형-page1.png
Normal file
BIN
dxf-graph/p-9100-모형-page1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
dxf-graph/p-9100-모형.pdf
Normal file
BIN
dxf-graph/p-9100-모형.pdf
Normal file
Binary file not shown.
104
dxf-graph/pid_analysis_engine.py
Normal file
104
dxf-graph/pid_analysis_engine.py
Normal file
@@ -0,0 +1,104 @@
|
||||
import networkx as nx
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Dict, List, Optional
|
||||
import uvicorn
|
||||
import json
|
||||
import os
|
||||
|
||||
app = FastAPI(title="P&ID Analysis Engine")
|
||||
|
||||
# 전역 변수로 그래프 및 매핑 데이터 로드
|
||||
TOPOLOGY_FILE = "futurePlan/End-to-End P&ID Graph Pipeline/pid_graph_topology.json"
|
||||
MAPPING_FILE = "futurePlan/End-to-End P&ID Graph Pipeline/pid_final_mapping.json"
|
||||
|
||||
topology_graph = nx.DiGraph()
|
||||
tag_mapping = {}
|
||||
|
||||
def load_data():
|
||||
global topology_graph, tag_mapping
|
||||
try:
|
||||
if os.path.exists(TOPOLOGY_FILE):
|
||||
with open(TOPOLOGY_FILE, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
# NetworkX 그래프 생성
|
||||
for node in data.get('nodes', []):
|
||||
topology_graph.add_node(node['id'], **node)
|
||||
for edge in data.get('edges', []):
|
||||
topology_graph.add_edge(edge['source'], edge['target'], **edge)
|
||||
print(f"Successfully loaded topology from {TOPOLOGY_FILE}")
|
||||
|
||||
if os.path.exists(MAPPING_FILE):
|
||||
with open(MAPPING_FILE, 'r', encoding='utf-8') as f:
|
||||
tag_mapping = json.load(f)
|
||||
print(f"Successfully loaded mapping from {MAPPING_FILE}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading data: {e}")
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
load_data()
|
||||
|
||||
class ImpactRequest(BaseModel):
|
||||
nodeId: str
|
||||
|
||||
class ImpactResult(BaseModel):
|
||||
startNode: str
|
||||
impactedNodes: Dict[str, int] # { nodeId: depth }
|
||||
path: List[List[str]]
|
||||
|
||||
def get_propagation_path_with_flow(graph, start_node):
|
||||
"""
|
||||
엣지의 방향성(flow_direction)과 상태(valve_status)를 고려한 실제 영향 전파 경로 추출
|
||||
"""
|
||||
if start_node not in graph:
|
||||
return {}
|
||||
|
||||
# 1. 유효한 엣지만 필터링 (방향이 forward이고 밸브가 open인 경로)
|
||||
# 실제 데이터에 flow_direction이나 valve_status가 없을 경우를 대비해 기본값 설정
|
||||
valid_edges = [
|
||||
(u, v) for u, v, d in graph.edges(data=True)
|
||||
if d.get('flow_direction', 'forward') == 'forward'
|
||||
and d.get('valve_status', 'open') == 'open'
|
||||
]
|
||||
|
||||
filtered_graph = nx.DiGraph()
|
||||
filtered_graph.add_edges_from(valid_edges)
|
||||
|
||||
# 2. 전파 단계별 노드 추출 (BFS)
|
||||
try:
|
||||
propagation_levels = nx.single_source_shortest_path_length(filtered_graph, start_node)
|
||||
return propagation_levels
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@app.get("/impact/{nodeId}")
|
||||
async def analyze_impact(nodeId: str):
|
||||
if nodeId not in topology_graph:
|
||||
raise HTTPException(status_code=404, detail=f"Node {nodeId} not found in topology")
|
||||
|
||||
impact_map = get_propagation_path_with_flow(topology_graph, nodeId)
|
||||
|
||||
# 경로 추출 (시각화를 위해 간단하게 모든 영향 노드로의 최단 경로 포함)
|
||||
paths = []
|
||||
for target in impact_map.keys():
|
||||
if target != nodeId:
|
||||
try:
|
||||
path = nx.shortest_path(topology_graph, source=nodeId, target=target)
|
||||
paths.append(path)
|
||||
except nx.NetworkXNoPath:
|
||||
continue
|
||||
|
||||
return {
|
||||
"startNode": nodeId,
|
||||
"impactedNodes": impact_map,
|
||||
"paths": paths
|
||||
}
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy", "nodes": topology_graph.number_of_nodes(), "edges": topology_graph.number_of_edges()}
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
612
dxf-graph/pid_extractor.py
Normal file
612
dxf-graph/pid_extractor.py
Normal file
@@ -0,0 +1,612 @@
|
||||
"""
|
||||
P&ID Extractor - DXF/PDF → Claude Vision API → CSV → PostgreSQL
|
||||
Extracts: Equipment Name, Tag No., Instrument Type, Line Number, P&ID Drawing No.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import csv
|
||||
import base64
|
||||
import re
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Optional
|
||||
import anthropic
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler("logs/extractor.log"),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Data Model
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class PIDItem:
|
||||
"""Single extracted item from a P&ID drawing."""
|
||||
pid_drawing_no: str = ""
|
||||
tag_no: str = ""
|
||||
equipment_name: str = ""
|
||||
instrument_type: str = ""
|
||||
line_number: str = ""
|
||||
service_description: str = ""
|
||||
confidence: float = 0.0
|
||||
source_file: str = ""
|
||||
extracted_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# File Converters
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def dxf_to_image(dxf_path: str, output_dir: str = "output") -> list[str]:
|
||||
"""
|
||||
Convert DXF file to PNG image(s) using ezdxf + matplotlib.
|
||||
Returns list of image paths.
|
||||
"""
|
||||
try:
|
||||
import ezdxf
|
||||
from ezdxf.addons.drawing import RenderContext, Frontend
|
||||
from ezdxf.addons.drawing.matplotlib import MatplotlibBackend
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
doc = ezdxf.readfile(dxf_path)
|
||||
msp = doc.modelspace()
|
||||
|
||||
fig = plt.figure(figsize=(24, 18), dpi=150)
|
||||
ax = fig.add_axes([0, 0, 1, 1])
|
||||
ctx = RenderContext(doc)
|
||||
out = MatplotlibBackend(ax)
|
||||
Frontend(ctx, out).draw_layout(msp, finalize=True)
|
||||
|
||||
output_path = Path(output_dir) / (Path(dxf_path).stem + ".png")
|
||||
fig.savefig(output_path, dpi=150, bbox_inches="tight",
|
||||
facecolor="white", edgecolor="none")
|
||||
plt.close(fig)
|
||||
|
||||
log.info(f"DXF converted: {output_path}")
|
||||
return [str(output_path)]
|
||||
|
||||
except ImportError:
|
||||
log.warning("ezdxf/matplotlib not installed. Run: pip install ezdxf matplotlib")
|
||||
return []
|
||||
except Exception as e:
|
||||
log.error(f"DXF conversion failed: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def pdf_to_images(pdf_path: str, output_dir: str = "output",
|
||||
dpi: int = 200) -> list[str]:
|
||||
"""
|
||||
Convert PDF pages to PNG images using pdf2image.
|
||||
Returns list of image paths.
|
||||
"""
|
||||
try:
|
||||
from pdf2image import convert_from_path
|
||||
|
||||
pages = convert_from_path(pdf_path, dpi=dpi)
|
||||
paths = []
|
||||
stem = Path(pdf_path).stem
|
||||
|
||||
for i, page in enumerate(pages):
|
||||
out_path = Path(output_dir) / f"{stem}_page{i+1:03d}.png"
|
||||
page.save(str(out_path), "PNG")
|
||||
paths.append(str(out_path))
|
||||
log.info(f"PDF page {i+1} saved: {out_path}")
|
||||
|
||||
return paths
|
||||
|
||||
except ImportError:
|
||||
log.warning("pdf2image not installed. Run: pip install pdf2image")
|
||||
return []
|
||||
except Exception as e:
|
||||
log.error(f"PDF conversion failed: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def dxf_text_extract(dxf_path: str) -> str:
|
||||
"""
|
||||
Directly extract all text entities from DXF file (faster, no image needed).
|
||||
Returns concatenated text for pre-filtering.
|
||||
"""
|
||||
try:
|
||||
import ezdxf
|
||||
|
||||
doc = ezdxf.readfile(dxf_path)
|
||||
texts = []
|
||||
for entity in doc.modelspace():
|
||||
if entity.dxftype() in ("TEXT", "MTEXT", "ATTRIB", "ATTDEF"):
|
||||
try:
|
||||
txt = entity.dxf.text if hasattr(entity.dxf, "text") else ""
|
||||
if txt.strip():
|
||||
texts.append(txt.strip())
|
||||
except Exception:
|
||||
pass
|
||||
return "\n".join(texts)
|
||||
except Exception as e:
|
||||
log.error(f"DXF text extraction failed: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Claude Vision Analyzer
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
EXTRACTION_PROMPT = """You are an expert P&ID (Piping and Instrumentation Diagram) engineer.
|
||||
Analyze this P&ID drawing image and extract ALL of the following items:
|
||||
|
||||
1. **P&ID Drawing Number** (도면번호) - usually found in title block
|
||||
2. **Tag Numbers** (태그번호) - e.g. FT-1001, PT-2003, LT-1005, E-101, V-201
|
||||
3. **Equipment Names** (장비명) - e.g. Heat Exchanger, Pump, Vessel, Compressor
|
||||
4. **Instrument Types** (계기타입) - e.g. Flow Transmitter, Pressure Indicator, Level Controller
|
||||
5. **Line Numbers** (라인번호) - e.g. 6\"-P-1001-A1A, 3\"-IA-2001
|
||||
|
||||
For each item found, return a JSON array with this exact structure:
|
||||
[
|
||||
{
|
||||
"pid_drawing_no": "P&ID drawing number or sheet number",
|
||||
"tag_no": "instrument or equipment tag (e.g. FT-1001)",
|
||||
"equipment_name": "descriptive name in English (e.g. Flow Transmitter)",
|
||||
"instrument_type": "ISA instrument type abbreviation (e.g. FT, PT, LT, E, V, P)",
|
||||
"line_number": "pipe line number if associated",
|
||||
"service_description": "brief service description if visible",
|
||||
"confidence": 0.0 to 1.0 confidence score
|
||||
}
|
||||
]
|
||||
|
||||
Rules:
|
||||
- Extract EVERY tag and instrument visible, do not skip any
|
||||
- If a field is not visible/applicable, use empty string ""
|
||||
- Return ONLY valid JSON array, no markdown, no explanation
|
||||
- confidence: 1.0 = clearly readable, 0.5 = partially legible, 0.2 = guessed
|
||||
"""
|
||||
|
||||
TEXT_EXTRACTION_PROMPT = """You are an expert P&ID engineer.
|
||||
Below is raw text extracted from a DXF P&ID file.
|
||||
Parse and extract ALL instrument tags, equipment tags, line numbers, and drawing info.
|
||||
|
||||
Text content:
|
||||
{text_content}
|
||||
|
||||
Return a JSON array with this exact structure:
|
||||
[
|
||||
{{
|
||||
"pid_drawing_no": "drawing number if found",
|
||||
"tag_no": "tag number (e.g. FT-1001, E-101)",
|
||||
"equipment_name": "equipment or instrument name",
|
||||
"instrument_type": "ISA type abbreviation",
|
||||
"line_number": "line number if found",
|
||||
"service_description": "service description if found",
|
||||
"confidence": 0.8
|
||||
}}
|
||||
]
|
||||
|
||||
Return ONLY valid JSON, no markdown.
|
||||
"""
|
||||
|
||||
|
||||
class PIDAnalyzer:
|
||||
"""Claude-powered P&ID analyzer supporting both image and text modes."""
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None):
|
||||
self.client = anthropic.Anthropic(
|
||||
api_key=api_key or os.environ.get("ANTHROPIC_API_KEY")
|
||||
)
|
||||
self.model = "claude-opus-4-20250514"
|
||||
|
||||
def analyze_image(self, image_path: str) -> list[PIDItem]:
|
||||
"""Analyze a P&ID image using Claude Vision."""
|
||||
log.info(f"Analyzing image: {image_path}")
|
||||
|
||||
with open(image_path, "rb") as f:
|
||||
image_data = base64.standard_b64encode(f.read()).decode("utf-8")
|
||||
|
||||
# Detect media type
|
||||
suffix = Path(image_path).suffix.lower()
|
||||
media_map = {".png": "image/png", ".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg", ".gif": "image/gif",
|
||||
".webp": "image/webp"}
|
||||
media_type = media_map.get(suffix, "image/png")
|
||||
|
||||
response = self.client.messages.create(
|
||||
model=self.model,
|
||||
max_tokens=4096,
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": media_type,
|
||||
"data": image_data
|
||||
}
|
||||
},
|
||||
{"type": "text", "text": EXTRACTION_PROMPT}
|
||||
]
|
||||
}]
|
||||
)
|
||||
|
||||
raw = response.content[0].text
|
||||
return self._parse_response(raw, source_file=image_path)
|
||||
|
||||
def analyze_dxf_text(self, dxf_path: str) -> list[PIDItem]:
|
||||
"""Analyze DXF by extracting text entities and sending to Claude."""
|
||||
log.info(f"Analyzing DXF text: {dxf_path}")
|
||||
text_content = dxf_text_extract(dxf_path)
|
||||
|
||||
if not text_content.strip():
|
||||
log.warning("No text found in DXF, falling back to image mode")
|
||||
images = dxf_to_image(dxf_path)
|
||||
results = []
|
||||
for img in images:
|
||||
results.extend(self.analyze_image(img))
|
||||
return results
|
||||
|
||||
prompt = TEXT_EXTRACTION_PROMPT.format(
|
||||
text_content=text_content[:8000] # token limit guard
|
||||
)
|
||||
|
||||
response = self.client.messages.create(
|
||||
model=self.model,
|
||||
max_tokens=4096,
|
||||
messages=[{"role": "user", "content": prompt}]
|
||||
)
|
||||
|
||||
raw = response.content[0].text
|
||||
return self._parse_response(raw, source_file=dxf_path)
|
||||
|
||||
def _parse_response(self, raw: str, source_file: str) -> list[PIDItem]:
|
||||
"""Parse Claude's JSON response into PIDItem list."""
|
||||
try:
|
||||
# Strip markdown fences if present
|
||||
clean = re.sub(r"```(?:json)?|```", "", raw).strip()
|
||||
|
||||
# Find JSON array
|
||||
match = re.search(r"\[.*\]", clean, re.DOTALL)
|
||||
if not match:
|
||||
log.warning("No JSON array found in response")
|
||||
return []
|
||||
|
||||
data = json.loads(match.group())
|
||||
items = []
|
||||
for d in data:
|
||||
if not isinstance(d, dict):
|
||||
continue
|
||||
item = PIDItem(
|
||||
pid_drawing_no=d.get("pid_drawing_no", ""),
|
||||
tag_no=d.get("tag_no", ""),
|
||||
equipment_name=d.get("equipment_name", ""),
|
||||
instrument_type=d.get("instrument_type", ""),
|
||||
line_number=d.get("line_number", ""),
|
||||
service_description=d.get("service_description", ""),
|
||||
confidence=float(d.get("confidence", 0.5)),
|
||||
source_file=Path(source_file).name,
|
||||
)
|
||||
items.append(item)
|
||||
|
||||
log.info(f"Extracted {len(items)} items from {Path(source_file).name}")
|
||||
return items
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
log.error(f"JSON parse error: {e}\nRaw: {raw[:500]}")
|
||||
return []
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# CSV Exporter
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
CSV_COLUMNS = [
|
||||
"pid_drawing_no", "tag_no", "equipment_name", "instrument_type",
|
||||
"line_number", "service_description", "confidence",
|
||||
"source_file", "extracted_at"
|
||||
]
|
||||
|
||||
|
||||
def export_csv(items: list[PIDItem], output_path: str) -> str:
|
||||
"""Export extracted items to CSV file."""
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(output_path, "w", newline="", encoding="utf-8-sig") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=CSV_COLUMNS)
|
||||
writer.writeheader()
|
||||
for item in items:
|
||||
writer.writerow(item.to_dict())
|
||||
|
||||
log.info(f"CSV saved: {output_path} ({len(items)} rows)")
|
||||
return output_path
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# PostgreSQL Loader
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
CREATE_TABLE_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS pid_equipment (
|
||||
id SERIAL PRIMARY KEY,
|
||||
pid_drawing_no VARCHAR(100),
|
||||
tag_no VARCHAR(100),
|
||||
equipment_name VARCHAR(255),
|
||||
instrument_type VARCHAR(50),
|
||||
line_number VARCHAR(100),
|
||||
service_description TEXT,
|
||||
confidence FLOAT,
|
||||
source_file VARCHAR(255),
|
||||
extracted_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Useful indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_pid_tag_no ON pid_equipment(tag_no);
|
||||
CREATE INDEX IF NOT EXISTS idx_pid_drawing_no ON pid_equipment(pid_drawing_no);
|
||||
CREATE INDEX IF NOT EXISTS idx_pid_instrument_type ON pid_equipment(instrument_type);
|
||||
"""
|
||||
|
||||
INSERT_SQL = """
|
||||
INSERT INTO pid_equipment
|
||||
(pid_drawing_no, tag_no, equipment_name, instrument_type,
|
||||
line_number, service_description, confidence, source_file, extracted_at)
|
||||
VALUES
|
||||
(%(pid_drawing_no)s, %(tag_no)s, %(equipment_name)s, %(instrument_type)s,
|
||||
%(line_number)s, %(service_description)s, %(confidence)s,
|
||||
%(source_file)s, %(extracted_at)s)
|
||||
ON CONFLICT DO NOTHING;
|
||||
"""
|
||||
|
||||
|
||||
def load_to_postgres(items: list[PIDItem], dsn: str) -> int:
|
||||
"""
|
||||
Load extracted items into PostgreSQL.
|
||||
DSN format: postgresql://user:password@host:5432/dbname
|
||||
Returns number of rows inserted.
|
||||
"""
|
||||
try:
|
||||
import psycopg2
|
||||
|
||||
conn = psycopg2.connect(dsn)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Create table if needed
|
||||
cur.execute(CREATE_TABLE_SQL)
|
||||
|
||||
# Insert rows
|
||||
rows = [item.to_dict() for item in items]
|
||||
cur.executemany(INSERT_SQL, rows)
|
||||
conn.commit()
|
||||
|
||||
count = cur.rowcount
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
log.info(f"Inserted {count} rows into PostgreSQL")
|
||||
return count
|
||||
|
||||
except ImportError:
|
||||
log.error("psycopg2 not installed. Run: pip install psycopg2-binary")
|
||||
return 0
|
||||
except Exception as e:
|
||||
log.error(f"PostgreSQL error: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# AX (Asset Excellence) Formatter
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
AX_COLUMN_MAP = {
|
||||
"tag_no": "Tag Number",
|
||||
"equipment_name": "Asset Description",
|
||||
"instrument_type": "Equipment Class",
|
||||
"pid_drawing_no": "P&ID Reference",
|
||||
"line_number": "Line Reference",
|
||||
"service_description": "Service",
|
||||
}
|
||||
|
||||
|
||||
def export_ax_excel(items: list[PIDItem], output_path: str) -> str:
|
||||
"""
|
||||
Export data in AX (Asset Excellence / Hexagon) compatible Excel format.
|
||||
Columns mapped to typical AX field names.
|
||||
"""
|
||||
try:
|
||||
import openpyxl
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "AX_Import"
|
||||
|
||||
# Header style
|
||||
header_fill = PatternFill("solid", fgColor="1F4E79")
|
||||
header_font = Font(color="FFFFFF", bold=True, size=11)
|
||||
header_align = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
thin = Side(style="thin", color="CCCCCC")
|
||||
border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
|
||||
ax_columns = list(AX_COLUMN_MAP.values())
|
||||
ax_columns.append("Confidence")
|
||||
|
||||
# Write header
|
||||
for col_idx, col_name in enumerate(ax_columns, start=1):
|
||||
cell = ws.cell(row=1, column=col_idx, value=col_name)
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = header_align
|
||||
cell.border = border
|
||||
|
||||
# Write data rows
|
||||
for row_idx, item in enumerate(items, start=2):
|
||||
row_data = [
|
||||
item.tag_no,
|
||||
item.equipment_name,
|
||||
item.instrument_type,
|
||||
item.pid_drawing_no,
|
||||
item.line_number,
|
||||
item.service_description,
|
||||
item.confidence,
|
||||
]
|
||||
for col_idx, value in enumerate(row_data, start=1):
|
||||
cell = ws.cell(row=row_idx, column=col_idx, value=value)
|
||||
cell.border = border
|
||||
cell.alignment = Alignment(vertical="center")
|
||||
|
||||
# Confidence color coding
|
||||
if col_idx == len(ax_columns):
|
||||
if isinstance(value, float):
|
||||
if value >= 0.8:
|
||||
cell.fill = PatternFill("solid", fgColor="C6EFCE") # green
|
||||
elif value >= 0.5:
|
||||
cell.fill = PatternFill("solid", fgColor="FFEB9C") # yellow
|
||||
else:
|
||||
cell.fill = PatternFill("solid", fgColor="FFC7CE") # red
|
||||
|
||||
# Column widths
|
||||
col_widths = [20, 35, 25, 20, 20, 30, 12]
|
||||
for i, w in enumerate(col_widths, start=1):
|
||||
ws.column_dimensions[
|
||||
openpyxl.utils.get_column_letter(i)
|
||||
].width = w
|
||||
|
||||
ws.row_dimensions[1].height = 35
|
||||
ws.freeze_panes = "A2"
|
||||
|
||||
wb.save(output_path)
|
||||
log.info(f"AX Excel saved: {output_path} ({len(items)} rows)")
|
||||
return output_path
|
||||
|
||||
except ImportError:
|
||||
log.error("openpyxl not installed. Run: pip install openpyxl")
|
||||
return ""
|
||||
except Exception as e:
|
||||
log.error(f"Excel export error: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Main Pipeline
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def run_pipeline(
|
||||
input_files: list[str],
|
||||
output_dir: str = "output",
|
||||
db_dsn: Optional[str] = None,
|
||||
use_image_mode: bool = False,
|
||||
) -> dict:
|
||||
"""
|
||||
Full pipeline: files → AI extraction → CSV + Excel + optional DB load.
|
||||
|
||||
Args:
|
||||
input_files: List of DXF or PDF file paths
|
||||
output_dir: Directory for output files
|
||||
db_dsn: PostgreSQL DSN (optional)
|
||||
use_image_mode: Force image conversion even for DXF
|
||||
|
||||
Returns:
|
||||
Summary dict with counts and output paths
|
||||
"""
|
||||
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||
Path("logs").mkdir(exist_ok=True)
|
||||
|
||||
analyzer = PIDAnalyzer()
|
||||
all_items: list[PIDItem] = []
|
||||
|
||||
for file_path in input_files:
|
||||
suffix = Path(file_path).suffix.lower()
|
||||
log.info(f"Processing: {file_path}")
|
||||
|
||||
try:
|
||||
if suffix == ".pdf":
|
||||
image_paths = pdf_to_images(file_path, output_dir)
|
||||
for img in image_paths:
|
||||
all_items.extend(analyzer.analyze_image(img))
|
||||
|
||||
elif suffix == ".dxf":
|
||||
if use_image_mode:
|
||||
image_paths = dxf_to_image(file_path, output_dir)
|
||||
for img in image_paths:
|
||||
all_items.extend(analyzer.analyze_image(img))
|
||||
else:
|
||||
all_items.extend(analyzer.analyze_dxf_text(file_path))
|
||||
|
||||
elif suffix in (".png", ".jpg", ".jpeg"):
|
||||
all_items.extend(analyzer.analyze_image(file_path))
|
||||
|
||||
else:
|
||||
log.warning(f"Unsupported file type: {suffix}")
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Failed processing {file_path}: {e}")
|
||||
|
||||
if not all_items:
|
||||
log.warning("No items extracted from any file")
|
||||
return {"total": 0, "csv": None, "excel": None, "db_rows": 0}
|
||||
|
||||
# Sort by drawing + tag
|
||||
all_items.sort(key=lambda x: (x.pid_drawing_no, x.tag_no))
|
||||
|
||||
# Export CSV
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
csv_path = str(Path(output_dir) / f"pid_extracted_{ts}.csv")
|
||||
export_csv(all_items, csv_path)
|
||||
|
||||
# Export AX Excel
|
||||
excel_path = str(Path(output_dir) / f"pid_AX_import_{ts}.xlsx")
|
||||
export_ax_excel(all_items, excel_path)
|
||||
|
||||
# Load to DB
|
||||
db_rows = 0
|
||||
if db_dsn:
|
||||
db_rows = load_to_postgres(all_items, db_dsn)
|
||||
|
||||
summary = {
|
||||
"total": len(all_items),
|
||||
"csv": csv_path,
|
||||
"excel": excel_path,
|
||||
"db_rows": db_rows,
|
||||
"files_processed": len(input_files),
|
||||
}
|
||||
|
||||
log.info(f"Pipeline complete: {summary}")
|
||||
return summary
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# CLI Entry Point
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="P&ID Extractor: DXF/PDF → CSV/Excel/PostgreSQL"
|
||||
)
|
||||
parser.add_argument("files", nargs="+", help="DXF or PDF file paths")
|
||||
parser.add_argument("--output-dir", default="output", help="Output directory")
|
||||
parser.add_argument("--db-dsn", help="PostgreSQL DSN (optional)")
|
||||
parser.add_argument("--image-mode", action="store_true",
|
||||
help="Force DXF → image conversion (slower but more accurate)")
|
||||
args = parser.parse_args()
|
||||
|
||||
result = run_pipeline(
|
||||
input_files=args.files,
|
||||
output_dir=args.output_dir,
|
||||
db_dsn=args.db_dsn,
|
||||
use_image_mode=args.image_mode,
|
||||
)
|
||||
|
||||
print("\n===== Extraction Summary =====")
|
||||
for k, v in result.items():
|
||||
print(f" {k}: {v}")
|
||||
1
dxf-graph/pid_final_mapping.json
Normal file
1
dxf-graph/pid_final_mapping.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
188
dxf-graph/pid_geometric_extractor.py
Normal file
188
dxf-graph/pid_geometric_extractor.py
Normal file
@@ -0,0 +1,188 @@
|
||||
import ezdxf
|
||||
import re
|
||||
import json
|
||||
from typing import List, Optional, Tuple, Union
|
||||
from pydantic import BaseModel, Field
|
||||
from shapely.geometry import box, Point
|
||||
|
||||
# --- Data Models ---
|
||||
|
||||
class BoundingBox(BaseModel):
|
||||
min_x: float
|
||||
min_y: float
|
||||
max_x: float
|
||||
max_y: float
|
||||
center: Tuple[float, float]
|
||||
|
||||
class GeometricEntity(BaseModel):
|
||||
entity_id: str
|
||||
entity_type: str # TEXT, MTEXT, LINE, LWPOLYLINE, CIRCLE, ARC
|
||||
layer: str
|
||||
bbox: BoundingBox
|
||||
raw_value: Optional[str] = None
|
||||
clean_value: Optional[str] = None
|
||||
coordinates: List[Union[Tuple[float, float], List[float]]] = Field(default_factory=list)
|
||||
properties: dict = Field(default_factory=dict)
|
||||
|
||||
# --- Extractor Implementation ---
|
||||
|
||||
class PidGeometricExtractor:
|
||||
def __init__(self, file_path: str):
|
||||
try:
|
||||
self.doc = ezdxf.readfile(file_path)
|
||||
self.msp = self.doc.modelspace()
|
||||
except Exception as e:
|
||||
raise IOError(f"Failed to load DXF file: {e}")
|
||||
|
||||
def clean_text(self, text: str) -> str:
|
||||
"""
|
||||
DXF 특수 제어 문자 및 MTEXT 포맷팅을 제거하여 정제된 텍스트 반환.
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# 1. MTEXT 포맷팅 및 제어 문자 제거 (\P, \W, \L, \A, \C, \H, \S, \T 등)
|
||||
text = re.sub(r'\\([P|W|L|A|C|H|S|T])\d*;?', ' ', text)
|
||||
|
||||
# 2. 중괄호 { } 제거
|
||||
text = re.sub(r'[\{\}]', ' ', text)
|
||||
|
||||
# 3. DXF 특수 제어 문자 제거 (%%U: Underline, %%O: Overline, %%S: Strikethrough, %%R: Registered)
|
||||
text = re.sub(r'%%[U|O|S|R]', ' ', text)
|
||||
|
||||
# 4. 불필요한 특수 기호 및 반복되는 공백 정제
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
|
||||
return text
|
||||
|
||||
def get_bbox(self, entity) -> Optional[BoundingBox]:
|
||||
"""
|
||||
엔티티 타입별로 동적인 Bounding Box를 계산하여 반환.
|
||||
"""
|
||||
try:
|
||||
if entity.dxftype() == 'TEXT':
|
||||
p = entity.dxf.insert
|
||||
h = entity.dxf.height
|
||||
# 텍스트 길이에 따른 대략적인 너비 계산 (글자수 * 높이 * 0.6)
|
||||
width = len(entity.dxf.text) * h * 0.6
|
||||
return self._create_bbox(p.x, p.y, p.x + width, p.y + h)
|
||||
|
||||
elif entity.dxftype() == 'MTEXT':
|
||||
p = entity.dxf.insert
|
||||
h = entity.dxf.char_height if hasattr(entity.dxf, 'char_height') else 2.5
|
||||
w = entity.dxf.width if entity.dxf.width > 0 else len(entity.text) * h * 0.6
|
||||
return self._create_bbox(p.x, p.y, p.x + w, p.y + h)
|
||||
|
||||
elif entity.dxftype() == 'LINE':
|
||||
start = entity.dxf.start
|
||||
end = entity.dxf.end
|
||||
return self._create_bbox(
|
||||
min(start.x, end.x), min(start.y, end.y),
|
||||
max(start.x, end.x), max(start.y, end.y)
|
||||
)
|
||||
|
||||
elif entity.dxftype() == 'LWPOLYLINE':
|
||||
points = entity.get_points()
|
||||
if not points: return None
|
||||
xs = [p[0] for p in points]
|
||||
ys = [p[1] for p in points]
|
||||
return self._create_bbox(min(xs), min(ys), max(xs), max(ys))
|
||||
|
||||
elif entity.dxftype() in ('CIRCLE', 'ARC'):
|
||||
center = entity.dxf.center
|
||||
radius = entity.dxf.radius
|
||||
return self._create_bbox(
|
||||
center.x - radius, center.y - radius,
|
||||
center.x + radius, center.y + radius
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error calculating bbox for {entity.dxftype()} ({entity.dxf.handle}): {e}")
|
||||
return None
|
||||
|
||||
def _create_bbox(self, min_x, min_y, max_x, max_y) -> BoundingBox:
|
||||
return BoundingBox(
|
||||
min_x=min_x,
|
||||
min_y=min_y,
|
||||
max_x=max_x,
|
||||
max_y=max_y,
|
||||
center=((min_x + max_x) / 2, (min_y + max_y) / 2)
|
||||
)
|
||||
|
||||
def extract_and_save(self, output_path: str):
|
||||
"""
|
||||
기하학적 데이터를 추출하여 JSON 파일로 저장.
|
||||
"""
|
||||
results = []
|
||||
for entity in self.msp:
|
||||
bbox_obj = self.get_bbox(entity)
|
||||
if not bbox_obj:
|
||||
continue
|
||||
|
||||
raw_text = ""
|
||||
if entity.dxftype() == 'TEXT':
|
||||
raw_text = entity.dxf.text
|
||||
elif entity.dxftype() == 'MTEXT':
|
||||
raw_text = entity.text
|
||||
|
||||
# 좌표 추출 (3D 좌표를 2D로 변환)
|
||||
coords = []
|
||||
if hasattr(entity, 'get_points'):
|
||||
# ezdxf의 get_points()는 (x, y, z) 튜플 리스트를 반환함
|
||||
coords = [(p[0], p[1]) for p in entity.get_points()]
|
||||
elif entity.dxftype() == 'LINE':
|
||||
coords = [(entity.dxf.start.x, entity.dxf.start.y), (entity.dxf.end.x, entity.dxf.end.y)]
|
||||
elif entity.dxftype() in ('CIRCLE', 'ARC'):
|
||||
coords = [(entity.dxf.center.x, entity.dxf.center.y)]
|
||||
|
||||
entity_data = GeometricEntity(
|
||||
entity_id=entity.dxf.handle,
|
||||
entity_type=entity.dxftype(),
|
||||
layer=entity.dxf.layer,
|
||||
bbox=bbox_obj,
|
||||
raw_value=raw_text if raw_text else None,
|
||||
clean_value=self.clean_text(raw_text) if raw_text else None,
|
||||
coordinates=coords,
|
||||
properties={
|
||||
"color": entity.dxf.color,
|
||||
"lineweight": entity.dxf.lineweight if hasattr(entity.dxf, 'lineweight') else None,
|
||||
}
|
||||
)
|
||||
results.append(entity_data.model_dump())
|
||||
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(results, f, ensure_ascii=False, indent=4)
|
||||
|
||||
return output_path
|
||||
|
||||
# --- Proximity Utilities ---
|
||||
|
||||
def is_near(bbox_a: BoundingBox, bbox_b: BoundingBox, threshold=5.0) -> bool:
|
||||
"""
|
||||
두 Bounding Box 간의 최단 거리가 임계값 이내인지 확인.
|
||||
shapely box를 사용하여 거리 계산.
|
||||
"""
|
||||
box_a = box(bbox_a.min_x, bbox_a.min_y, bbox_a.max_x, bbox_a.max_y)
|
||||
box_b = box(bbox_b.min_x, bbox_b.min_y, bbox_b.max_x, bbox_b.max_y)
|
||||
return box_a.distance(box_b) <= threshold
|
||||
|
||||
def is_inside(point: Tuple[float, float], bbox: BoundingBox) -> bool:
|
||||
"""
|
||||
특정 점이 Bounding Box 내부에 있는지 확인.
|
||||
"""
|
||||
return (bbox.min_x <= point[0] <= bbox.max_x) and (bbox.min_y <= point[1] <= bbox.max_y)
|
||||
|
||||
# --- Execution Block ---
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 테스트 파일 경로 (환경에 맞게 수정)
|
||||
input_dxf = "futurePlan/End-to-End P&ID Graph Pipeline/No-10_Plant_PID.dxf"
|
||||
output_json = "futurePlan/End-to-End P&ID Graph Pipeline/shared_geo_data.json"
|
||||
|
||||
print(f"Starting extraction from {input_dxf}...")
|
||||
try:
|
||||
extractor = PidGeometricExtractor(input_dxf)
|
||||
saved_path = extractor.extract_and_save(output_json)
|
||||
print(f"Successfully saved geometric data to {saved_path}")
|
||||
except Exception as e:
|
||||
print(f"Extraction failed: {e}")
|
||||
112293
dxf-graph/pid_graph_topology.json
Normal file
112293
dxf-graph/pid_graph_topology.json
Normal file
File diff suppressed because it is too large
Load Diff
126
dxf-graph/pid_intelligent_mapper.py
Normal file
126
dxf-graph/pid_intelligent_mapper.py
Normal file
@@ -0,0 +1,126 @@
|
||||
import networkx as nx
|
||||
import asyncio
|
||||
import json
|
||||
from typing import List, Optional, Dict, Any, Tuple
|
||||
from pydantic import BaseModel, Field
|
||||
from rapidfuzz import process, fuzz
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
# --- 응답 구조화를 위한 Pydantic 모델 ---
|
||||
class MappingResult(BaseModel):
|
||||
resolved_tag: str = Field(..., description="The final mapped system tag")
|
||||
reason: str = Field(..., description="Reason for this mapping based on context")
|
||||
confidence: float = Field(..., ge=0.0, le=1.0, description="Confidence score from 0 to 1")
|
||||
|
||||
class IntelligentMapper:
|
||||
def __init__(self, graph: nx.Graph, system_tags: List[str], api_key: str = None):
|
||||
self.graph = graph # Phase 2에서 생성된 NetworkX 그래프
|
||||
self.system_tags = system_tags # Experion 시스템의 전체 태그 리스트
|
||||
self.client = AsyncOpenAI(api_key=api_key) if api_key else None
|
||||
|
||||
def get_node_context(self, node_id: str) -> str:
|
||||
"""노드의 주변 위상 정보를 텍스트로 변환"""
|
||||
if not self.graph.has_node(node_id):
|
||||
return "Node not found in graph"
|
||||
|
||||
neighbors = list(self.graph.neighbors(node_id))
|
||||
context = []
|
||||
for n in neighbors:
|
||||
attr = self.graph.nodes[n]
|
||||
val = attr.get('value', n)
|
||||
typ = attr.get('type', 'Unknown')
|
||||
context.append(f"Connected to {val} (Type: {typ})")
|
||||
|
||||
return ", ".join(context) if context else "No connected neighbors"
|
||||
|
||||
async def _resolve_generic(self, node_id: str, category_prompt: str) -> MappingResult:
|
||||
"""공통 매핑 로직 (비동기 + 구조화 응답)"""
|
||||
if not self.client:
|
||||
return MappingResult(resolved_tag="UNKNOWN", reason="API Key not provided", confidence=0.0)
|
||||
|
||||
# Phase 2에서 'value'에 clean_value가 저장됨
|
||||
node_data = self.graph.nodes.get(node_id, {})
|
||||
tag_text = node_data.get('value', '')
|
||||
|
||||
# 1차 후보 추출 (RapidFuzz)
|
||||
candidates = process.extract(tag_text, self.system_tags, scorer=fuzz.WRatio, limit=5)
|
||||
context = self.get_node_context(node_id)
|
||||
|
||||
prompt = f"""
|
||||
{category_prompt}
|
||||
P&ID 도면의 태그 '{tag_text}'를 실제 시스템 태그와 매핑해야 합니다.
|
||||
위상 맥락: {context}
|
||||
후보 리스트: {candidates}
|
||||
|
||||
반드시 다음 JSON 형식으로만 응답하세요:
|
||||
{{
|
||||
"resolved_tag": "태그명 또는 UNKNOWN",
|
||||
"reason": "매핑 이유",
|
||||
"confidence": 0.0~1.0
|
||||
}}
|
||||
"""
|
||||
|
||||
try:
|
||||
response = await self.client.chat.completions.create(
|
||||
model="gpt-4-turbo",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
response_format={ "type": "json_object" } # JSON 모드 강제
|
||||
)
|
||||
raw_content = response.choices[0].message.content
|
||||
# Pydantic을 통한 유효성 검사
|
||||
return MappingResult.model_validate_json(raw_content)
|
||||
except Exception as e:
|
||||
print(f"Error resolving node {node_id}: {e}")
|
||||
return MappingResult(resolved_tag="UNKNOWN", reason=f"Error: {str(e)}", confidence=0.0)
|
||||
|
||||
# --- 전문화된 Worker 함수들 ---
|
||||
async def extract_transmitters(self, node_ids: List[str]) -> Dict[str, MappingResult]:
|
||||
prompt = "당신은 계측기 전문 엔지니어입니다. 특히 Pressure/Flow/Level Transmitter 매핑에 특화되어 있습니다."
|
||||
tasks = [self._resolve_generic(nid, prompt) for nid in node_ids]
|
||||
results = await asyncio.gather(*tasks)
|
||||
return dict(zip(node_ids, results))
|
||||
|
||||
async def extract_valves(self, node_ids: List[str]) -> Dict[str, MappingResult]:
|
||||
prompt = "당신은 밸브 및 액추에이터 전문 엔지니어입니다. 밸브의 개폐 상태 및 제어 태그 매핑에 특화되어 있습니다."
|
||||
tasks = [self._resolve_generic(nid, prompt) for nid in node_ids]
|
||||
results = await asyncio.gather(*tasks)
|
||||
return dict(zip(node_ids, results))
|
||||
|
||||
async def extract_equipment(self, node_ids: List[str]) -> Dict[str, MappingResult]:
|
||||
prompt = "당신은 공정 설비 전문 엔지니어입니다. 펌프, 탱크, 열교환기 등의 메인 설비 태그 매핑에 특화되어 있습니다."
|
||||
tasks = [self._resolve_generic(nid, prompt) for nid in node_ids]
|
||||
results = await asyncio.gather(*tasks)
|
||||
return dict(zip(node_ids, results))
|
||||
|
||||
def validate_mapping(resolved_tag: str, symbol_type: str, tag_metadata: Dict[str, Any]) -> Tuple[bool, str]:
|
||||
"""심볼 타입과 실제 태그 메타데이터의 엄격한 일치 여부 검증"""
|
||||
if resolved_tag == "UNKNOWN":
|
||||
return False, "Tag not resolved"
|
||||
|
||||
# 단순 키워드가 아닌 허용 단위(Unit) 정의
|
||||
unit_map = {
|
||||
"Pressure Transmitter": ["bar", "psi", "kPa", "Pa"],
|
||||
"Flow Meter": ["m3/h", "lpm", "kg/h"],
|
||||
"Temperature Sensor": ["°C", "C", "K", "°F"]
|
||||
}
|
||||
|
||||
actual_unit = tag_metadata.get('unit', '').strip()
|
||||
allowed_units = unit_map.get(symbol_type, [])
|
||||
|
||||
# 1. 단위 일치 확인 (최우선)
|
||||
if actual_unit and actual_unit in allowed_units:
|
||||
return True, "Unit Match"
|
||||
|
||||
# 2. 단위가 없는 경우 설명(Description) 기반 2차 검증
|
||||
actual_desc = tag_metadata.get('description', '').lower()
|
||||
expected_keywords = {
|
||||
"Pressure Transmitter": ["pressure", "press"],
|
||||
"Flow Meter": ["flow", "flowrate"],
|
||||
"Temperature Sensor": ["temp", "temperature"]
|
||||
}
|
||||
|
||||
keywords = expected_keywords.get(symbol_type, [])
|
||||
if any(kw in actual_desc for kw in keywords):
|
||||
return True, "Description Match (Unit Missing)"
|
||||
|
||||
return False, "Mismatch: Symbol type and Tag metadata do not align"
|
||||
1014
dxf-graph/pid_parser_coding_plan.md
Normal file
1014
dxf-graph/pid_parser_coding_plan.md
Normal file
File diff suppressed because it is too large
Load Diff
190
dxf-graph/pid_topology_builder.py
Normal file
190
dxf-graph/pid_topology_builder.py
Normal file
@@ -0,0 +1,190 @@
|
||||
import networkx as nx
|
||||
from shapely.geometry import box, Point, LineString
|
||||
import json
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
|
||||
class PidTopologyBuilder:
|
||||
def __init__(self, geometric_data: List[Dict[str, Any]], all_extracted_tags: Optional[List[Dict[str, Any]]] = None, config: Optional[Dict[str, float]] = None):
|
||||
"""
|
||||
- geometric_data: Phase 1에서 추출된 기하학적 데이터 (List of dicts)
|
||||
- all_extracted_tags: 통합된 태그 리스트
|
||||
- config: {'dist_threshold': 50.0, 'tag_threshold': 100.0} 등 설정값
|
||||
"""
|
||||
self.data = geometric_data
|
||||
self.all_tags = all_extracted_tags if all_extracted_tags else []
|
||||
|
||||
if config:
|
||||
self.config = config
|
||||
else:
|
||||
try:
|
||||
with open('futurePlan/End-to-End P&ID Graph Pipeline/topology_config.json', 'r') as f:
|
||||
self.config = json.load(f)
|
||||
except Exception:
|
||||
self.config = {'dist_threshold': 50.0, 'tag_threshold': 100.0, 'merge_threshold': 2.0}
|
||||
|
||||
self.G = nx.DiGraph() # 방향성 그래프 생성
|
||||
|
||||
def build_graph(self):
|
||||
# 1. 노드 병합 및 추가 (Merging)
|
||||
self.merged_data = self._merge_nodes()
|
||||
for item in self.merged_data:
|
||||
bbox_vals = item['bbox']
|
||||
bbox_geom = box(bbox_vals['min_x'], bbox_vals['min_y'], bbox_vals['max_x'], bbox_vals['max_y'])
|
||||
|
||||
self.G.add_node(item['entity_id'],
|
||||
type=item['entity_type'],
|
||||
bbox=bbox_geom,
|
||||
value=item.get('clean_value'),
|
||||
layer=item.get('layer'))
|
||||
|
||||
# 2. 분산 추출된 태그 통합 및 노드 추가
|
||||
for tag in self.all_tags:
|
||||
bbox_vals = tag['bbox']
|
||||
bbox_geom = box(bbox_vals['min_x'], bbox_vals['min_y'], bbox_vals['max_x'], bbox_vals['max_y'])
|
||||
self.G.add_node(tag['entity_id'],
|
||||
type='TEXT',
|
||||
bbox=bbox_geom,
|
||||
value=tag.get('clean_value') or tag.get('tagName'))
|
||||
|
||||
# 3. 태그-설비 논리적 연결 (Association)
|
||||
tags = [n for n, d in self.G.nodes(data=True) if d['type'] == 'TEXT']
|
||||
equipments = [n for n, d in self.G.nodes(data=True) if d['type'] not in ['TEXT', 'LINE', 'LWPOLYLINE']]
|
||||
|
||||
for tag in tags:
|
||||
best_match = self._find_nearest_equipment(tag, equipments)
|
||||
if best_match:
|
||||
self.G.add_edge(tag, best_match, relation='associated_with')
|
||||
|
||||
# 4. 배관 기반 물리적 연결 (Pipe) [개선: Proximity 기반]
|
||||
lines = [n for n, d in self.G.nodes(data=True) if d['type'] in ['LINE', 'LWPOLYLINE']]
|
||||
for line_id in lines:
|
||||
# 저장된 merged_data에서 coordinates 찾기
|
||||
original_item = next((item for item in self.merged_data if item['entity_id'] == line_id), None)
|
||||
if not original_item:
|
||||
original_item = next((item for item in self.data if item['entity_id'] == line_id), None)
|
||||
|
||||
if not original_item or not original_item.get('coordinates'):
|
||||
continue
|
||||
|
||||
coords = original_item['coordinates']
|
||||
line_geom = LineString(coords)
|
||||
|
||||
connected_nodes = []
|
||||
for eq_id in equipments:
|
||||
eq_bbox = self.G.nodes[eq_id]['bbox']
|
||||
# End-point뿐만 아니라 Line 전체와 BBox 간의 최단 거리 측정
|
||||
if line_geom.distance(eq_bbox) < self.config['dist_threshold']:
|
||||
connected_nodes.append(eq_id)
|
||||
|
||||
# 중복 제거
|
||||
connected_nodes = list(set(connected_nodes))
|
||||
|
||||
if len(connected_nodes) >= 2:
|
||||
# 방향성 추론 (단순화: 첫 번째 -> 두 번째)
|
||||
self.G.add_edge(connected_nodes[0], connected_nodes[1], relation='pipe')
|
||||
elif len(connected_nodes) == 1:
|
||||
# 단일 연결 노드 처리 (나중에 분석용)
|
||||
pass
|
||||
|
||||
def _find_nearest_equipment(self, tag_id, equipment_ids):
|
||||
tag_bbox = self.G.nodes[tag_id]['bbox']
|
||||
min_dist = float('inf')
|
||||
nearest = None
|
||||
for eq_id in equipment_ids:
|
||||
eq_bbox = self.G.nodes[eq_id]['bbox']
|
||||
dist = tag_bbox.distance(eq_bbox)
|
||||
if dist < min_dist:
|
||||
min_dist = dist
|
||||
nearest = eq_id
|
||||
return nearest if min_dist < self.config['tag_threshold'] else None
|
||||
|
||||
def validate_topology(self):
|
||||
"""위상 무결성 검증"""
|
||||
isolated = list(nx.isolates(self.G))
|
||||
return {
|
||||
"isolated_nodes": isolated,
|
||||
"node_count": self.G.number_of_nodes(),
|
||||
"edge_count": self.G.number_of_edges()
|
||||
}
|
||||
|
||||
def _merge_nodes(self) -> List[Dict[str, Any]]:
|
||||
"""기하학적으로 거의 동일한 노드들을 병합하여 그래프 단순화"""
|
||||
if not self.data:
|
||||
return []
|
||||
|
||||
merge_threshold = self.config.get('merge_threshold', 2.0)
|
||||
merged = []
|
||||
visited = set()
|
||||
|
||||
for i in range(len(self.data)):
|
||||
if i in visited:
|
||||
continue
|
||||
|
||||
current = self.data[i]
|
||||
current_bbox = box(*(current['bbox']['min_x'], current['bbox']['min_y'], current['bbox']['max_x'], current['bbox']['max_y']))
|
||||
|
||||
# 동일 타입이면서 BBox 거리가 매우 가까운 노드들 탐색
|
||||
cluster = [current]
|
||||
visited.add(i)
|
||||
|
||||
for j in range(i + 1, len(self.data)):
|
||||
if j in visited:
|
||||
continue
|
||||
|
||||
target = self.data[j]
|
||||
if target['entity_type'] != current['entity_type']:
|
||||
continue
|
||||
|
||||
target_bbox = box(*(target['bbox']['min_x'], target['bbox']['min_y'], target['bbox']['max_x'], target['bbox']['max_y']))
|
||||
if current_bbox.distance(target_bbox) < merge_threshold:
|
||||
cluster.append(target)
|
||||
visited.add(j)
|
||||
|
||||
# 클러스터 대표값 설정 (첫 번째 노드 기준, BBox는 합집합으로 확장)
|
||||
if len(cluster) > 1:
|
||||
# BBox 합집합 계산
|
||||
min_x = min(c['bbox']['min_x'] for c in cluster)
|
||||
min_y = min(c['bbox']['min_y'] for c in cluster)
|
||||
max_x = max(c['bbox']['max_x'] for c in cluster)
|
||||
max_y = max(c['bbox']['max_y'] for c in cluster)
|
||||
|
||||
representative = cluster[0].copy()
|
||||
representative['bbox'] = {'min_x': min_x, 'min_y': min_y, 'max_x': max_x, 'max_y': max_y}
|
||||
# 병합된 원본 ID 리스트 저장
|
||||
representative['merged_ids'] = [c['entity_id'] for c in cluster]
|
||||
merged.append(representative)
|
||||
else:
|
||||
merged.append(current)
|
||||
|
||||
return merged
|
||||
|
||||
def save_graph(self, output_path: str):
|
||||
"""그래프 구조를 JSON 형태로 저장 (NetworkX의 node_link_data 활용) {
|
||||
"nodes": [...],
|
||||
"links": [...]
|
||||
}"""
|
||||
from networkx.readwrite import json_graph
|
||||
data = json_graph.node_link_data(self.G)
|
||||
|
||||
# shapely geometry 객체는 JSON 직렬화가 안 되므로 변환
|
||||
for node in data['nodes']:
|
||||
if 'bbox' in node:
|
||||
bbox = node['bbox']
|
||||
node['bbox'] = {
|
||||
'min_x': bbox.bounds[0],
|
||||
'min_y': bbox.bounds[1],
|
||||
'max_x': bbox.bounds[2],
|
||||
'max_y': bbox.bounds[3]
|
||||
}
|
||||
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=4)
|
||||
return output_path
|
||||
|
||||
def analyze_impact(graph, start_node):
|
||||
"""특정 설비 장애 시 하류(Downstream)에 영향을 받는 모든 노드 추출"""
|
||||
if start_node not in graph:
|
||||
return []
|
||||
# BFS를 통해 도달 가능한 모든 노드 탐색
|
||||
impacted_nodes = nx.descendants(graph, start_node)
|
||||
return list(impacted_nodes)
|
||||
21
dxf-graph/plant-9100-extracted.md
Normal file
21
dxf-graph/plant-9100-extracted.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# plant-9100.pdf 추출 결과
|
||||
|
||||
## PDF 정보
|
||||
|
||||
- **버전**: 1.6
|
||||
- **페이지 수**: 1
|
||||
- **제목**: (없음)
|
||||
- **작성자**: (없음)
|
||||
- **생성 프로그램**: ezPDF Builder Supreme
|
||||
- **생성일**: D:20260430083400+09'00'
|
||||
|
||||
## 페이지 1
|
||||
|
||||
- **크기**: 595.2 x 841.92
|
||||
|
||||
### 추출 텍스트
|
||||
|
||||
```
|
||||
E-1011710th PLANT 증설공사주식회사 한울T-10101P-10114P-10101F-10102A/BP-10116E-10115BC-10111E-10117E-10119T-10100T-3210VP-10117SP-10601D-10113E-10112P-10118DP-1010112024.04.04K.S.YJ.O.YH.J.IAS BUILT23---4---5---6---TTE-10115A2025.06.17---REVISIONE-10103OGDEN PUMPTOGDEN PUMP2025.06.20REVISIONREVISION2025.06.232025.07.02REVISION2025.07.07REVISION
|
||||
```
|
||||
|
||||
70
dxf-graph/plant-9100-extracted.txt
Normal file
70
dxf-graph/plant-9100-extracted.txt
Normal file
@@ -0,0 +1,70 @@
|
||||
--- Page 1 ---
|
||||
|
||||
주식회사
|
||||
한울
|
||||
10th
|
||||
PLANT
|
||||
증설공사
|
||||
1
|
||||
2024.04.04
|
||||
AS
|
||||
BUILT
|
||||
K.S.Y
|
||||
J.O.Y
|
||||
H.J.I
|
||||
2
|
||||
2025.06.17
|
||||
REVISION
|
||||
-
|
||||
-
|
||||
-
|
||||
E-10115A
|
||||
E-10119
|
||||
3
|
||||
2025.06.20
|
||||
REVISION
|
||||
-
|
||||
-
|
||||
-
|
||||
P-10101
|
||||
4
|
||||
2025.06.23
|
||||
REVISION
|
||||
-
|
||||
-
|
||||
-
|
||||
P-10116
|
||||
5
|
||||
2025.07.02
|
||||
REVISION
|
||||
-
|
||||
-
|
||||
-
|
||||
6
|
||||
2025.07.07
|
||||
REVISION
|
||||
-
|
||||
-
|
||||
-
|
||||
F-10102A/B
|
||||
T
|
||||
E-10103
|
||||
OGDEN
|
||||
PUMP
|
||||
T
|
||||
P-10118
|
||||
E-10115B
|
||||
T-10101
|
||||
T-10100
|
||||
DP-10101
|
||||
E-10117
|
||||
P-10114
|
||||
OGDEN
|
||||
PUMP
|
||||
T
|
||||
E-10117
|
||||
T-3210
|
||||
SP-10601
|
||||
VP-10117
|
||||
C-10111
|
||||
D-10113 E-10112
|
||||
BIN
dxf-graph/plant-9100-page1.png
Normal file
BIN
dxf-graph/plant-9100-page1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 874 KiB |
0
dxf-graph/plant-9100-tag.md
Normal file
0
dxf-graph/plant-9100-tag.md
Normal file
BIN
dxf-graph/plant-9100.pdf
Normal file
BIN
dxf-graph/plant-9100.pdf
Normal file
Binary file not shown.
BIN
dxf-graph/plant-9200.pdf
Normal file
BIN
dxf-graph/plant-9200.pdf
Normal file
Binary file not shown.
848536
dxf-graph/shared_geo_data.json
Normal file
848536
dxf-graph/shared_geo_data.json
Normal file
File diff suppressed because it is too large
Load Diff
61
dxf-graph/test_pipeline_phase2.py
Normal file
61
dxf-graph/test_pipeline_phase2.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 경로 설정을 위해 현재 파일의 디렉토리를 sys.path에 추가
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.append(current_dir)
|
||||
|
||||
from pid_geometric_extractor import PidGeometricExtractor
|
||||
from pid_topology_builder import PidTopologyBuilder, analyze_impact
|
||||
|
||||
def run_pipeline():
|
||||
# 1. 경로 설정 (현재 디렉토리 기준 상대 경로)
|
||||
input_dxf = os.path.join(current_dir, "No-10_Plant_PID.dxf")
|
||||
geo_json_path = os.path.join(current_dir, "shared_geo_data.json")
|
||||
graph_json_path = os.path.join(current_dir, "pid_graph_topology.json")
|
||||
|
||||
print("--- Phase 1: Geometric Extraction ---")
|
||||
try:
|
||||
extractor = PidGeometricExtractor(input_dxf)
|
||||
extractor.extract_and_save(geo_json_path)
|
||||
print(f"Geometric data saved to {geo_json_path}")
|
||||
except Exception as e:
|
||||
print(f"Phase 1 failed: {e}")
|
||||
return
|
||||
|
||||
print("\n--- Phase 2: Topology Modeling ---")
|
||||
try:
|
||||
with open(geo_json_path, 'r', encoding='utf-8') as f:
|
||||
geometric_data = json.load(f)
|
||||
|
||||
# 테스트를 위해 all_extracted_tags는 빈 리스트로 전달
|
||||
# config를 None으로 전달하여 topology_config.json 설정을 사용하도록 함
|
||||
builder = PidTopologyBuilder(
|
||||
geometric_data=geometric_data,
|
||||
all_extracted_tags=[],
|
||||
config=None
|
||||
)
|
||||
builder.build_graph()
|
||||
|
||||
# 위상 검증
|
||||
validation = builder.validate_topology()
|
||||
print(f"Topology Validation: {validation}")
|
||||
|
||||
# 그래프 저장
|
||||
builder.save_graph(graph_json_path)
|
||||
print(f"Graph topology saved to {graph_json_path}")
|
||||
|
||||
# 영향도 분석 테스트 (노드가 존재하는 경우)
|
||||
if validation['node_count'] > 0:
|
||||
sample_node = list(builder.G.nodes())[0]
|
||||
impacted = analyze_impact(builder.G, sample_node)
|
||||
print(f"Impact analysis for node {sample_node}: {impacted}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Phase 2 failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_pipeline()
|
||||
134
dxf-graph/test_pipeline_phase3.py
Normal file
134
dxf-graph/test_pipeline_phase3.py
Normal file
@@ -0,0 +1,134 @@
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
import asyncio
|
||||
import networkx as nx
|
||||
|
||||
# 경로 설정을 위해 현재 파일의 디렉토리를 sys.path에 추가
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.append(current_dir)
|
||||
|
||||
from pid_geometric_extractor import PidGeometricExtractor
|
||||
from pid_topology_builder import PidTopologyBuilder
|
||||
from pid_intelligent_mapper import IntelligentMapper, validate_mapping
|
||||
|
||||
async def run_full_pipeline():
|
||||
# 1. 경로 설정
|
||||
input_dxf = os.path.join(current_dir, "No-10_Plant_PID.dxf")
|
||||
geo_json_path = os.path.join(current_dir, "shared_geo_data.json")
|
||||
graph_json_path = os.path.join(current_dir, "pid_graph_topology.json")
|
||||
mapping_result_path = os.path.join(current_dir, "pid_final_mapping.json")
|
||||
|
||||
# --- Phase 1: Geometric Extraction ---
|
||||
print("\n--- Phase 1: Geometric Extraction ---")
|
||||
try:
|
||||
extractor = PidGeometricExtractor(input_dxf)
|
||||
extractor.extract_and_save(geo_json_path)
|
||||
print(f"Geometric data saved to {geo_json_path}")
|
||||
except Exception as e:
|
||||
print(f"Phase 1 failed: {e}")
|
||||
return
|
||||
|
||||
# --- Phase 2: Topology Modeling ---
|
||||
print("\n--- Phase 2: Topology Modeling ---")
|
||||
try:
|
||||
with open(geo_json_path, 'r', encoding='utf-8') as f:
|
||||
geometric_data = json.load(f)
|
||||
|
||||
builder = PidTopologyBuilder(
|
||||
geometric_data=geometric_data,
|
||||
all_extracted_tags=[],
|
||||
config={'dist_threshold': 50.0, 'tag_threshold': 100.0}
|
||||
)
|
||||
builder.build_graph()
|
||||
builder.save_graph(graph_json_path)
|
||||
print(f"Graph topology saved to {graph_json_path}")
|
||||
except Exception as e:
|
||||
print(f"Phase 2 failed: {e}")
|
||||
return
|
||||
|
||||
# --- Phase 3: Intelligent Mapping ---
|
||||
print("\n--- Phase 3: Intelligent Mapping ---")
|
||||
try:
|
||||
# 1. 그래프 로드
|
||||
with open(graph_json_path, 'r', encoding='utf-8') as f:
|
||||
graph_data = json.load(f)
|
||||
|
||||
# NetworkX 그래프 복원 (node_link_data 형식 대응)
|
||||
from networkx.readwrite import json_graph
|
||||
G = json_graph.node_link_graph(graph_data)
|
||||
|
||||
# 2. 시스템 태그 리스트 (실제로는 API나 DB에서 가져와야 함)
|
||||
# 테스트를 위한 가상 태그 리스트
|
||||
system_tags = [
|
||||
"PT-101.PV", "PT-102.PV", "FT-201.PV", "LT-301.PV",
|
||||
"P-101.STATUS", "P-101.SPEED", "V-101.OPEN", "V-101.CLOSE",
|
||||
"T-101.TEMP", "TK-101.LEVEL"
|
||||
]
|
||||
|
||||
# 3. 매퍼 초기화 (API Key는 환경변수나 설정파일에서 가져오는 것을 권장)
|
||||
api_key = os.getenv("OPENAI_API_KEY", "your-api-key-here")
|
||||
mapper = IntelligentMapper(G, system_tags, api_key=api_key)
|
||||
|
||||
# 4. 노드 분류 및 매핑 실행
|
||||
nodes = list(G.nodes())
|
||||
transmitter_nodes = [n for n in nodes if "Transmitter" in G.nodes[n].get('type', '')]
|
||||
valve_nodes = [n for n in nodes if "Valve" in G.nodes[n].get('type', '')]
|
||||
equipment_nodes = [n for n in nodes if "Equipment" in G.nodes[n].get('type', '') or "Pump" in G.nodes[n].get('type', '')]
|
||||
|
||||
print(f"Mapping {len(transmitter_nodes)} transmitters, {len(valve_nodes)} valves, {len(equipment_nodes)} equipment...")
|
||||
|
||||
# 비동기 실행
|
||||
results = await asyncio.gather(
|
||||
mapper.extract_transmitters(transmitter_nodes),
|
||||
mapper.extract_valves(valve_nodes),
|
||||
mapper.extract_equipment(equipment_nodes)
|
||||
)
|
||||
|
||||
# 결과 통합
|
||||
final_mapping_raw = {}
|
||||
for res in results:
|
||||
final_mapping_raw.update(res)
|
||||
|
||||
# 5. 검증 및 최종 결과 정리
|
||||
# 가상 메타데이터 (실제로는 시스템에서 조회)
|
||||
mock_metadata = {
|
||||
"PT-101.PV": {"unit": "bar", "description": "Pressure Transmitter 101"},
|
||||
"P-101.STATUS": {"unit": "", "description": "Pump 101 Status"},
|
||||
}
|
||||
|
||||
final_results = []
|
||||
for node_id, mapping in final_mapping_raw.items():
|
||||
symbol_type = G.nodes[node_id].get('type', 'Unknown')
|
||||
tag = mapping.resolved_tag
|
||||
meta = mock_metadata.get(tag, {"unit": "", "description": ""})
|
||||
|
||||
is_valid, val_msg = validate_mapping(tag, symbol_type, meta)
|
||||
|
||||
final_results.append({
|
||||
"node_id": node_id,
|
||||
"symbol_type": symbol_type,
|
||||
"original_text": G.nodes[node_id].get('value', ''),
|
||||
"resolved_tag": tag,
|
||||
"confidence": mapping.confidence,
|
||||
"reason": mapping.reason,
|
||||
"validation": {
|
||||
"is_valid": is_valid,
|
||||
"message": val_msg
|
||||
}
|
||||
})
|
||||
|
||||
# 6. 결과 저장
|
||||
with open(mapping_result_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(final_results, f, indent=4, ensure_ascii=False)
|
||||
|
||||
print(f"Final mapping results saved to {mapping_result_path}")
|
||||
print(f"Successfully mapped {len(final_results)} nodes.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Phase 3 failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_full_pipeline())
|
||||
5
dxf-graph/topology_config.json
Normal file
5
dxf-graph/topology_config.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dist_threshold": 20.0,
|
||||
"tag_threshold": 60.0,
|
||||
"merge_threshold": 2.0
|
||||
}
|
||||
Reference in New Issue
Block a user