feat: P&ID 그래프 파이프라인 및 MCP 서버 개선

- P&ID 그래프 파이프라인 구현 (py)
  - pid_geometric_extractor.py: 기하학적 특징 추출
  - pid_intelligent_mapper.py: 태그 매핑
  - pid_topology_builder.py: 위상 구축
  - test_pipeline_phase2.py, test_pipeline_phase3.py: 테스트

- MCP 서버 개선
  - server.py: 멀티프로세싱 지원
  - pipeline/: 분석, 추출, 매핑, 위상 모듈 추가

- C# P&ID 그래프 서비스
  - PidGraphDtos.cs: DTO 정의
  - PidGraphService.cs: 비즈니스 로직
  - PidGraphController.cs: API 컨트롤러

- OPC UA 서비스 개선
  - ExperionOpcServerService.cs
  - ExperionRealtimeService.cs
  - ExperionFastService.cs

- MCP 클라이언트 및 호스팅 서비스 개선
  - McpClient.cs
  - McpServerHostedService.cs

- 웹 UI 개선
  - pid_graph_view.html: P&ID 그래프 뷰어
  - pid-viewer.js: 뷰어 로직
  - app.js: 메인 앱
  - pid_graph.css: 스타일

- 프로젝트 설정 업데이트
  - ExperionCrawler.csproj
  - Program.cs
This commit is contained in:
windpacer
2026-05-03 03:50:20 +09:00
parent 30182bf020
commit f71ec310e4
37 changed files with 963115 additions and 41 deletions

View 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)

View File

@@ -0,0 +1 @@
[]

View 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}")

File diff suppressed because it is too large Load Diff

View 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"

View 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)

File diff suppressed because it is too large Load Diff

View 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()

View 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())

View File

@@ -0,0 +1,5 @@
{
"dist_threshold": 20.0,
"tag_threshold": 60.0,
"merge_threshold": 2.0
}

Binary file not shown.

View File

@@ -0,0 +1,78 @@
import networkx as nx
from typing import Dict, List, Optional
import json
import os
class PidAnalysisEngine:
def __init__(self, topology_file: str, mapping_file: str):
self.topology_file = topology_file
self.mapping_file = mapping_file
self.graph = nx.DiGraph()
self.tag_mapping = {}
self.load_data()
def load_data(self):
"""그래프 및 매핑 데이터 로드"""
try:
if os.path.exists(self.topology_file):
with open(self.topology_file, 'r', encoding='utf-8') as f:
data = json.load(f)
# NetworkX 그래프 생성 (node_link_data 형식 가정)
for node in data.get('nodes', []):
self.graph.add_node(node['id'], **node)
for edge in data.get('links', []): # node_link_data는 'links' 사용
self.graph.add_edge(edge['source'], edge['target'], **edge)
if os.path.exists(self.mapping_file):
with open(self.mapping_file, 'r', encoding='utf-8') as f:
self.tag_mapping = json.load(f)
except Exception as e:
print(f"Error loading analysis data: {e}")
def get_propagation_path_with_flow(self, start_node: str):
"""
엣지의 방향성(flow_direction)과 상태(valve_status)를 고려한 실제 영향 전파 경로 추출
"""
if start_node not in self.graph:
return {}
# 1. 유효한 엣지만 필터링 (방향이 forward이고 밸브가 open인 경로)
valid_edges = [
(u, v) for u, v, d in self.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 {}
def analyze_impact(self, node_id: str):
"""특정 노드 장애 시 영향도 분석 결과 반환"""
if node_id not in self.graph:
return {"success": False, "error": f"Node {node_id} not found in topology"}
impact_map = self.get_propagation_path_with_flow(node_id)
# 경로 추출 (시각화를 위해 모든 영향 노드로의 최단 경로 포함)
paths = []
for target in impact_map.keys():
if target != node_id:
try:
path = nx.shortest_path(self.graph, source=node_id, target=target)
paths.append(path)
except nx.NetworkXNoPath:
continue
return {
"success": True,
"startNode": node_id,
"impactedNodes": impact_map,
"paths": paths
}

View File

@@ -0,0 +1,173 @@
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)

View File

@@ -0,0 +1,122 @@
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_client: Optional[AsyncOpenAI] = None):
self.graph = graph # Phase 2에서 생성된 NetworkX 그래프
self.system_tags = system_tags # Experion 시스템의 전체 태그 리스트
self.client = api_client
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 Client 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="Qwen/Qwen3-Coder-Next-FP8", # MCP 서버 설정 모델 사용
messages=[{"role": "user", "content": prompt}],
response_format={ "type": "json_object" }
)
raw_content = response.choices[0].message.content
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_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, [])
if actual_unit and actual_unit in allowed_units:
return True, "Unit Match"
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"

View File

@@ -0,0 +1,123 @@
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 []
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:
bbox_vals = item['bbox']
# BoundingBox 모델의 필드명에 맞춰 추출 (min_x, min_y, max_x, max_y)
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) [개선됨: End-point 기반]
lines = [n for n, d in self.G.nodes(data=True) if d['type'] in ['LINE', 'LWPOLYLINE']]
for line_id in lines:
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)
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)
# 중복 제거
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 save_graph(self, output_path: str):
"""그래프 구조를 JSON 형태로 저장"""
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)

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""
ExperionCrawler Unified MCP Server
- RAG: Qdrant + Ollama nomic-embed-text + vLLM Qwen3-Coder-Next-FP8
- RAG: Qdrant + Ollama nomic-embed-text + vLLM Qwen/Qwen3-Coder-Next-FP8
- NL2SQL: 자연어 → LLM SQL 생성 → PostgreSQL 실행
- 사용처:
stdio 모드 (기본): Claude Code MCP / Roo Code MCP
@@ -41,6 +41,15 @@ mcp = FastMCP(
stateless_http=True,
)
# Pipeline Imports
from pipeline.extractor import PidGeometricExtractor
from pipeline.topology import PidTopologyBuilder
from pipeline.mapper import IntelligentMapper
from pipeline.analyzer import PidAnalysisEngine
import networkx as nx
import os
import asyncio
# ── 임베딩 (Ollama) ───────────────────────────────────────────────────────────
def _embed(text: str) -> list[float]:
@@ -53,7 +62,7 @@ def _embed(text: str) -> list[float]:
resp.raise_for_status()
return resp.json()["embedding"]
# ── LLM (vLLM / Qwen3-Coder-Next-FP8) ───────────────────────────────────────
# ── LLM (vLLM / Qwen/Qwen3-Coder-Next-FP8) ─────────────────────────────────────
@lru_cache(maxsize=1)
def _llm():
@@ -302,7 +311,7 @@ def search_r530_docs(query: str, top_k: int = 5) -> str:
@mcp.tool()
def ask_iiot_llm(question: str, context: str = "") -> str:
"""Qwen3-Coder-Next에게 IIoT/OPC UA 질문 (컨텍스트 없이 LLM 직접 질문).
"""Qwen/Qwen3-Coder-Next-FP8에게 IIoT/OPC UA 질문 (컨텍스트 없이 LLM 직접 질문).
사용 시점: search_codebase 또는 search_r530_docs 결과를 context로 넘겨
종합 분석·답변이 필요할 때. 또는 일반 IIoT/OPC UA 개념 질문.
@@ -331,7 +340,7 @@ def ask_iiot_llm(question: str, context: str = "") -> str:
@mcp.tool()
def rag_query(question: str, search_code: bool = False, search_docs: bool = True) -> str:
"""검색 → Qwen3-Coder-Next 답변 생성 (통합 RAG).
"""검색 → Qwen/Qwen3-Coder-Next-FP8 답변 생성 (통합 RAG).
기본값: Experion HS R530 공식 문서만 검색 (search_docs=True, search_code=False).
ExperionCrawler 코드도 함께 보려면 search_code=True 추가.
@@ -938,6 +947,108 @@ def parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
return json.dumps({"success": False, "error": f"PDF 파싱 실패: {e}"}, ensure_ascii=False)
@mcp.tool()
async def build_pid_graph_parallel(filepath: str) -> str:
"""
분산 처리 기법을 적용한 P&ID 그래프 생성 툴.
전처리 -> 병렬 분산 추출 -> 위상 모델링 -> 저장 과정을 수행합니다.
"""
try:
# 1. 전처리 (Phase 1: Geometric Extraction)
extractor = PidGeometricExtractor(filepath)
geo_data_path = f"mcp-server/storage/{os.path.basename(filepath)}_geo.json"
geo_data_list = extractor.extract_and_save(geo_data_path)
# geo_data_list는 경로를 반환하므로 다시 로드
with open(geo_data_path, 'r', encoding='utf-8') as f:
geo_data = json.load(f)
# 2. 병렬 분산 추출 (Phase 3: Intelligent Mapping)
# 시스템 태그 목록 가져오기 (DB에서 조회하는 로직 필요, 여기서는 예시로 빈 리스트 또는 기본값)
# 실제로는 get_tag_metadata 등을 통해 전체 태그 리스트를 확보해야 함
system_tags = []
try:
conn = _get_db_connection()
with conn.cursor() as cur:
cur.execute("SELECT tagname FROM realtime_table")
system_tags = [r[0] for r in cur.fetchall()]
except Exception as e:
logging.warning(f"Failed to fetch system tags: {e}")
# 그래프 임시 생성 (Mapper가 위상 정보를 사용하므로 필요)
builder = PidTopologyBuilder(geo_data)
builder.build_graph()
# Mapper 설정
from openai import AsyncOpenAI
api_client = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
mapper = IntelligentMapper(builder.G, system_tags, api_client=api_client)
# 분류별 노드 분리
nodes = list(builder.G.nodes())
transmitter_nodes = [n for n, d in builder.G.nodes(data=True) if d.get('value', '').upper() in ['FIT', 'FT', 'LT', 'PT', 'TE']] # 단순화된 필터
valve_nodes = [n for n, d in builder.G.nodes(data=True) if d.get('value', '').upper() in ['FCV', 'LCV', 'TCV', 'PCV', 'XV']]
equipment_nodes = [n for n, d in builder.G.nodes(data=True) if d.get('type') not in ['TEXT', 'LINE', 'LWPOLYLINE']]
# 병렬 호출 (vLLM Batching 유도)
tasks = [
mapper.extract_transmitters(transmitter_nodes),
mapper.extract_valves(valve_nodes),
mapper.extract_equipment(equipment_nodes)
]
extracted_results = await asyncio.gather(*tasks)
# 결과 통합
all_mapped_tags = []
for res_dict in extracted_results:
for node_id, mapping in res_dict.items():
if mapping.resolved_tag != "UNKNOWN":
# TopologyBuilder가 기대하는 형식으로 변환
node_data = builder.G.nodes[node_id]
all_mapped_tags.append({
"entity_id": node_id,
"tagName": mapping.resolved_tag,
"bbox": node_data['bbox'].bounds if hasattr(node_data['bbox'], 'bounds') else node_data['bbox'],
"clean_value": mapping.resolved_tag
})
# 3. 최종 위상 모델링 (Phase 2)
final_builder = PidTopologyBuilder(geo_data, all_extracted_tags=all_mapped_tags)
final_builder.build_graph()
# 4. 저장
graph_id = os.path.basename(filepath).replace(".dxf", "_graph.json")
graph_path = f"mcp-server/storage/{graph_id}"
final_builder.save_graph(graph_path)
return json.dumps({
"success": True,
"graph_id": graph_id,
"graph_path": graph_path,
"nodes": final_builder.G.number_of_nodes(),
"edges": final_builder.G.number_of_edges()
}, ensure_ascii=False)
except Exception as e:
logging.error(f"build_pid_graph_parallel failed: {e}")
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
@mcp.tool()
def analyze_pid_impact(graph_id: str, start_node_id: str) -> str:
"""
구축된 그래프를 기반으로 특정 설비 장애 시 영향도 분석을 수행합니다.
"""
try:
graph_path = f"mcp-server/storage/{graph_id}"
mapping_path = graph_path.replace("_graph.json", "_mapping.json") # 매핑 파일이 따로 저장된다고 가정
analyzer = PidAnalysisEngine(graph_path, mapping_path)
result = analyzer.analyze_impact(start_node_id)
return json.dumps(result, ensure_ascii=False, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": f"Impact analysis failed: {e}"}, ensure_ascii=False)
@mcp.tool()
def parse_pid_drawing(filepath: str) -> str:
"""확장자 자동 감지하여 P&ID 도면 파싱.

View File

@@ -0,0 +1,16 @@
namespace ExperionCrawler.Core.Application.DTOs;
using System.Collections.Generic;
public record ImpactResult(
string StartNode,
Dictionary<string, int> ImpactedNodes,
List<List<string>> Paths
);
public record AnalysisStatus(
string TaskId,
double Progress,
string Status,
string Message
);

View File

@@ -0,0 +1,97 @@
using System.Text.Json;
using ExperionCrawler.Infrastructure.Mcp;
using ExperionCrawler.Core.Application.DTOs;
namespace ExperionCrawler.Core.Application.Services;
public interface IPidGraphService
{
Task<PidGraphBuildResult> BuildPidGraphAsync(string filepath, Action<double, string>? progressHandler = null, CancellationToken ct = default);
Task<PidImpactResult> AnalyzeImpactAsync(string graphId, string nodeId, CancellationToken ct = default);
}
public class PidGraphService : IPidGraphService
{
private readonly McpClient _mcpClient;
private readonly ILogger<PidGraphService> _logger;
public PidGraphService(McpClient mcpClient, ILogger<PidGraphService> logger)
{
_mcpClient = mcpClient;
_logger = logger;
}
public async Task<PidGraphBuildResult> BuildPidGraphAsync(string filepath, Action<double, string>? progressHandler = null, CancellationToken ct = default)
{
try
{
progressHandler?.Invoke(10, "MCP 서버에 추출 요청 전송 중...");
var args = new Dictionary<string, object>
{
["filepath"] = filepath
};
progressHandler?.Invoke(30, "도면 기하학적 데이터 추출 중 (Phase 1)...");
var jsonResponse = await _mcpClient.CallToolAsync("build_pid_graph_parallel", args, ct);
progressHandler?.Invoke(70, "지능형 태그 매핑 및 위상 분석 중 (Phase 2 & 3)...");
var result = JsonSerializer.Deserialize<PidGraphBuildResult>(jsonResponse, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
progressHandler?.Invoke(90, "최종 그래프 구조 생성 및 저장 중...");
return result ?? throw new Exception("Failed to deserialize MCP response");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error building PID graph for file {Filepath}", filepath);
return new PidGraphBuildResult { Success = false, Error = ex.Message };
}
}
public async Task<PidImpactResult> AnalyzeImpactAsync(string graphId, string nodeId, CancellationToken ct = default)
{
try
{
var args = new Dictionary<string, object>
{
["graph_id"] = graphId,
["start_node_id"] = nodeId
};
var jsonResponse = await _mcpClient.CallToolAsync("analyze_pid_impact", args, ct);
var result = JsonSerializer.Deserialize<PidImpactResult>(jsonResponse, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return result ?? throw new Exception("Failed to deserialize MCP response");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error analyzing impact for graph {GraphId} node {NodeId}", graphId, nodeId);
return new PidImpactResult { Success = false, Error = ex.Message };
}
}
}
public class PidGraphBuildResult
{
public bool Success { get; set; }
public string? GraphId { get; set; }
public string? GraphPath { get; set; }
public int Nodes { get; set; }
public int Edges { get; set; }
public string? Error { get; set; }
}
public class PidImpactResult
{
public bool Success { get; set; }
public string? StartNode { get; set; }
public Dictionary<string, int>? ImpactedNodes { get; set; }
public List<List<string>>? Paths { get; set; }
public string? Error { get; set; }
}

View File

@@ -42,7 +42,7 @@ public class McpClient
}
}
public async Task<List<McpTool>> ListToolsAsync()
public async Task<List<McpTool>> ListToolsAsync(CancellationToken ct = default)
{
var request = new
{
@@ -51,14 +51,14 @@ public class McpClient
method = "tools/list"
};
var response = await SendRequestAsync(request);
var response = await SendRequestAsync(request, ct);
if (response?.result?.tools == null)
return [];
return [.. response.result.tools];
}
public async Task<string> CallToolAsync(string toolName, Dictionary<string, object> arguments)
public async Task<string> CallToolAsync(string toolName, Dictionary<string, object> arguments, CancellationToken ct = default)
{
var request = new
{
@@ -70,7 +70,7 @@ public class McpClient
try
{
var response = await SendRequestAsync(request);
var response = await SendRequestAsync(request, ct);
var content = response?.result?.content;
if (content == null || content.Length == 0)
return "호출 결과 없음";
@@ -91,65 +91,65 @@ public class McpClient
}
}
public Task<string> RunSqlAsync(string sql) =>
CallToolAsync("run_sql", new Dictionary<string, object> { ["sql"] = sql });
public Task<string> RunSqlAsync(string sql, CancellationToken ct = default) =>
CallToolAsync("run_sql", new Dictionary<string, object> { ["sql"] = sql }, ct);
public Task<string> QueryPvHistoryAsync(
List<string> tagNames, string timeFrom, string timeTo, int limit = 100) =>
List<string> tagNames, string timeFrom, string timeTo, int limit = 100, CancellationToken ct = default) =>
CallToolAsync("query_pv_history", new Dictionary<string, object>
{
["tag_names"] = tagNames,
["time_from"] = timeFrom,
["time_to"] = timeTo,
["limit"] = limit
});
}, ct);
public Task<string> GetTagMetadataAsync(string query, int limit = 10) =>
public Task<string> GetTagMetadataAsync(string query, int limit = 10, CancellationToken ct = default) =>
CallToolAsync("get_tag_metadata", new Dictionary<string, object>
{
["query"] = query,
["limit"] = limit
});
}, ct);
public Task<string> ListDrawingsAsync(string? unitNo = null)
public Task<string> ListDrawingsAsync(string? unitNo = null, CancellationToken ct = default)
{
var args = new Dictionary<string, object>();
if (!string.IsNullOrEmpty(unitNo))
args["unit_no"] = unitNo;
return CallToolAsync("list_drawings", args);
return CallToolAsync("list_drawings", args, ct);
}
public Task<string> QueryWithNlAsync(string question) =>
CallToolAsync("query_with_nl", new Dictionary<string, object> { ["question"] = question });
public Task<string> QueryWithNlAsync(string question, CancellationToken ct = default) =>
CallToolAsync("query_with_nl", new Dictionary<string, object> { ["question"] = question }, ct);
public Task<string> ExtractPidTagsAsync(string text, string sourceType) =>
public Task<string> ExtractPidTagsAsync(string text, string sourceType, CancellationToken ct = default) =>
CallToolAsync("extract_pid_tags", new Dictionary<string, object>
{
["text"] = text,
["source_type"] = sourceType
});
}, ct);
public Task<string> MatchPidTagsAsync(IEnumerable<string> pidTags, IEnumerable<string> experionTags) =>
public Task<string> MatchPidTagsAsync(IEnumerable<string> pidTags, IEnumerable<string> experionTags, CancellationToken ct = default) =>
CallToolAsync("match_pid_tags", new Dictionary<string, object>
{
["pid_tags"] = pidTags.ToList(),
["experion_tags"] = experionTags.ToList()
});
}, ct);
public Task<string> ParsePidDxfAsync(string filepath) =>
CallToolAsync("parse_pid_dxf", new Dictionary<string, object> { ["filepath"] = filepath });
public Task<string> ParsePidDxfAsync(string filepath, CancellationToken ct = default) =>
CallToolAsync("parse_pid_dxf", new Dictionary<string, object> { ["filepath"] = filepath }, ct);
public Task<string> ParsePidPdfAsync(string filepath, bool useOcr = true) =>
public Task<string> ParsePidPdfAsync(string filepath, bool useOcr = true, CancellationToken ct = default) =>
CallToolAsync("parse_pid_pdf", new Dictionary<string, object>
{
["filepath"] = filepath,
["use_ocr"] = useOcr
});
}, ct);
public Task<string> ParsePidDrawingAsync(string filepath) =>
CallToolAsync("parse_pid_drawing", new Dictionary<string, object> { ["filepath"] = filepath });
public Task<string> ParsePidDrawingAsync(string filepath, CancellationToken ct = default) =>
CallToolAsync("parse_pid_drawing", new Dictionary<string, object> { ["filepath"] = filepath }, ct);
private async Task<McpResponse?> SendRequestAsync(object request)
private async Task<McpResponse?> SendRequestAsync(object request, CancellationToken ct)
{
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
@@ -162,11 +162,11 @@ public class McpClient
httpRequest.Headers.Add("Accept", "application/json");
httpRequest.Headers.Add("mcp-protocol-version", "2025-03-26");
var response = await _httpClient.SendAsync(httpRequest);
var response = await _httpClient.SendAsync(httpRequest, ct);
if (!response.IsSuccessStatusCode)
return null;
var body = await response.Content.ReadAsStringAsync();
var body = await response.Content.ReadAsStringAsync(ct);
return JsonSerializer.Deserialize<McpResponse>(body, _jsonOptions);
}
}

View File

@@ -85,9 +85,16 @@ public class McpServerHostedService : IHostedService
public Task StopAsync(CancellationToken cancellationToken)
{
// 앱 종료 시 MCP 서버 강제 종료 로직 제거
// (사용자가 수동으로 종료하거나, 프로세스가 자동으로 종료되도록 허용)
_logger.LogInformation("[McpServer] 앱 종료 — MCP 서버 종료 로직 제거됨");
if (_process != null && !_process.HasExited)
{
try
{
_logger.LogInformation("[McpServer] 앱 종료 — MCP 서버 프로세스 종료 중...");
_process.Kill(entireProcessTree: true);
}
catch (Exception ex) { _logger.LogWarning(ex, "[McpServer] 프로세스 종료 중 오류"); }
}
_logger.LogInformation("[McpServer] 앱 종료 완료");
return Task.CompletedTask;
}
}

View File

@@ -58,7 +58,14 @@ public class ExperionFastService : IExperionFastService, IHostedService, IDispos
{
_cts?.Cancel();
if (_monitorTask != null)
await _monitorTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
{
try
{
// 종료 시 대기 시간을 2초로 단축하여 빠른 셧다운 유도
await _monitorTask.WaitAsync(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) { _logger.LogDebug(ex, "[Fast] StopAsync 대기 중 타임아웃 또는 취소 발생"); }
}
}
public void Dispose() => _cts?.Dispose();

View File

@@ -87,11 +87,26 @@ public class ExperionOpcServerService : IExperionOpcServerService, IHostedServic
}
}
Task IHostedService.StopAsync(CancellationToken ct)
async Task IHostedService.StopAsync(CancellationToken ct)
{
// 앱 종료 시: 서버 인스턴스 정리만, 플래그 파일은 유지 → 재기동 후 자동 시작
StopInternal(deleteFlag: false);
return Task.CompletedTask;
if (_server != null)
{
try
{
_logger.LogInformation("[OpcServer] 앱 종료 — OPC UA 서버 비동기 중지 중...");
await _server.StopAsync(ct).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[OpcServer] StopAsync 중 오류 발생");
StopInternal(deleteFlag: false);
}
}
else
{
StopInternal(deleteFlag: false);
}
}
// ── IExperionOpcServerService ────────────────────────────────────────────

View File

@@ -92,7 +92,14 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService,
var tasks = new[] { _monitorTask, _flushTask }
.Where(t => t != null).Select(t => t!).ToArray();
if (tasks.Length > 0)
await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
{
try
{
// 종료 시 대기 시간을 2초로 단축하여 빠른 셧다운 유도
await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) { _logger.LogDebug(ex, "[Realtime] StopAsync 대기 중 타임아웃 또는 취소 발생"); }
}
_running = false;
_logger.LogInformation("[Realtime] 구독 중지 완료 (앱 종료 — 자동 재시작 플래그 유지)");
}

View File

@@ -0,0 +1,128 @@
using Microsoft.AspNetCore.Mvc;
using ExperionCrawler.Core.Application.DTOs;
using ExperionCrawler.Core.Application.Services;
using System.Net.Http.Json;
using System.Collections.Concurrent;
namespace ExperionCrawler.Web.Controllers;
[ApiController]
[Route("api/[controller]")]
public class PidGraphController : ControllerBase
{
private readonly IPidGraphService _pidGraphService;
private readonly ILogger<PidGraphController> _logger;
// 간단한 인메모리 상태 저장소 (실제 운영 환경에서는 Redis/DistributedCache 권장)
private static readonly ConcurrentDictionary<string, AnalysisStatus> _statusStore = new();
public PidGraphController(IPidGraphService pidGraphService, ILogger<PidGraphController> logger)
{
_pidGraphService = pidGraphService;
_logger = logger;
}
[HttpGet("impact/{graphId}/{nodeId}")]
public async Task<IActionResult> GetImpactAnalysis(string graphId, string nodeId, CancellationToken ct)
{
try
{
_logger.LogInformation("Requesting impact analysis for graph: {GraphId}, node: {NodeId}", graphId, nodeId);
var result = await _pidGraphService.AnalyzeImpactAsync(graphId, nodeId, ct);
if (!result.Success)
{
return NotFound(new { error = result.Error });
}
// 프론트엔드 camelCase 규칙 준수
return Ok(new
{
startNode = result.StartNode,
impactedNodes = result.ImpactedNodes,
paths = result.Paths
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error during impact analysis");
return StatusCode(500, new { error = "Internal server error", details = ex.Message });
}
}
[HttpGet("status/{taskId}")]
public IActionResult GetAnalysisStatus(string taskId)
{
if (_statusStore.TryGetValue(taskId, out var status))
{
return Ok(new
{
taskId = status.TaskId,
progress = status.Progress,
status = status.Status,
message = status.Message
});
}
return NotFound();
}
// 그래프 생성 API
[HttpPost("build")]
public async Task<IActionResult> BuildGraph([FromBody] BuildGraphRequest request)
{
if (string.IsNullOrEmpty(request.Filepath))
return BadRequest(new { error = "Filepath is required" });
var taskId = Guid.NewGuid().ToString();
_statusStore[taskId] = new AnalysisStatus(taskId, 0, "Starting", "추출 준비 중...");
// 백그라운드 작업으로 실행하여 taskId 즉시 반환
// IHostApplicationLifetime을 주입받아 앱 종료 시 취소 가능하도록 설정
var lifetime = HttpContext.RequestServices.GetRequiredService<Microsoft.Extensions.Hosting.IHostApplicationLifetime>();
var cts = new CancellationTokenSource();
// 앱 종료 시 cts 취소 등록
lifetime.ApplicationStopping.Register(() => cts.Cancel());
_ = Task.Run(async () =>
{
try
{
_statusStore[taskId] = _statusStore[taskId] with { Progress = 10, Status = "Processing", Message = "도면 기하학적 데이터 추출 중..." };
var result = await _pidGraphService.BuildPidGraphAsync(request.Filepath, (progress, msg) =>
{
_statusStore[taskId] = _statusStore[taskId] with { Progress = progress, Message = msg };
}, cts.Token);
if (result.Success)
{
_statusStore[taskId] = _statusStore[taskId] with { Progress = 100, Status = "Completed", Message = "추출 완료" };
}
else
{
_statusStore[taskId] = _statusStore[taskId] with { Status = "Failed", Message = result.Error };
}
}
catch (OperationCanceledException)
{
_logger.LogInformation("Background graph build task {TaskId} was cancelled due to application shutdown", taskId);
_statusStore[taskId] = _statusStore[taskId] with { Status = "Cancelled", Message = "애플리케이션 종료로 인해 작업이 취소되었습니다." };
}
catch (Exception ex)
{
_logger.LogError(ex, "Background graph build error for task {TaskId}", taskId);
_statusStore[taskId] = _statusStore[taskId] with { Status = "Failed", Message = ex.Message };
}
finally
{
cts.Dispose();
}
});
return Ok(new { taskId = taskId });
}
public record BuildGraphRequest(string Filepath);
}

View File

@@ -29,7 +29,7 @@
<!-- Swagger (개발 편의) -->
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.8.1" />
<!-- P&ID 추출 -->
<PackageReference Include="netDxf" Version="3.1.0" />
<PackageReference Include="netDxf" Version="2022.11.2" />
<PackageReference Include="PdfPig" Version="0.1.9" />
<!-- Excel 내보내기 -->
<PackageReference Include="EPPlus" Version="7.4.2" />

View File

@@ -85,6 +85,7 @@ builder.Services.AddHostedService(sp => sp.GetRequiredService<ExperionFastServic
// ── P&ID Services ───────────────────────────────────────────────────────────────
builder.Services.AddScoped<IPidExtractorService, PidExtractorService>();
builder.Services.AddScoped<ITagMappingService, TagMappingService>();
builder.Services.AddScoped<IPidGraphService, PidGraphService>();
// ── FastTable Cleanup Service ─────────────────────────────────────────────────
builder.Services.AddHostedService<ExperionFastCleanupService>();

View File

@@ -0,0 +1,112 @@
/* ── P&ID Graph Visualization Styles ────────────────────────────────────────── */
.pid-graph-container {
display: grid;
grid-template-columns: 1fr 300px;
grid-template-rows: 50px 1fr;
height: calc(100vh - 100px);
gap: 10px;
background: #1e1e1e;
color: #ddd;
font-family: var(--fm);
}
.pid-toolbar {
grid-column: 1 / 3;
display: flex;
align-items: center;
gap: 15px;
padding: 0 15px;
background: #2d2d2d;
border-bottom: 1px solid #444;
}
.pid-canvas-wrap {
position: relative;
overflow: hidden;
background: #000;
cursor: crosshair;
}
#pid-canvas {
display: block;
}
.pid-tooltip {
position: absolute;
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
pointer-events: none;
z-index: 100;
border: 1px solid #666;
white-space: nowrap;
}
.pid-info-panel {
background: #252526;
border-left: 1px solid #444;
padding: 15px;
display: flex;
flex-direction: column;
gap: 20px;
overflow-y: auto;
}
.info-box {
background: #1e1e1e;
padding: 10px;
border-radius: 4px;
border: 1px solid #333;
font-size: 13px;
line-height: 1.5;
}
.impact-list {
font-size: 13px;
}
.impact-list ul {
list-style: none;
padding: 0;
margin: 10px 0 0 0;
}
.impact-list li {
padding: 5px 8px;
margin-bottom: 4px;
background: #333;
border-radius: 3px;
display: flex;
justify-content: space-between;
cursor: pointer;
}
.impact-list li:hover {
background: #444;
}
.status-bar {
margin-left: auto;
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
}
.progress-wrap {
width: 150px;
height: 10px;
background: #444;
border-radius: 5px;
overflow: hidden;
}
.progress-bar {
width: 0%;
height: 100%;
background: #4caf50;
transition: width 0.3s ease;
}

View File

@@ -2729,7 +2729,7 @@ document.getElementById('btn-pid-export-excel')?.addEventListener('click', async
// 탭 진입 시 초기화
document.querySelectorAll('[data-tab="pid"]').forEach(item => {
item.addEventListener('click', () => {
item.addEventListener('click', async () => {
pidCurrentPage = 1;
pidLastResult = null;
document.getElementById('pid-file-input').value = '';
@@ -2739,5 +2739,8 @@ document.querySelectorAll('[data-tab="pid"]').forEach(item => {
document.getElementById('pid-stat-total').textContent = '0';
document.getElementById('pid-stat-high').textContent = '0';
document.getElementById('pid-stat-mapped').textContent = '0';
// 탭 진입 시 최신 목록 자동 갱신
await pidLoadTable(1);
});
});

View File

@@ -0,0 +1,385 @@
/* ── P&ID Graph Viewer Logic ────────────────────────────────────────── */
let pidCanvas, pidCtx;
let pidNodeMap = new Map(); // { nodeId: {x, y, label, ...} }
let pidTopology = null;
let pidImpactResult = null;
let pidZoom = 1.0;
let pidOffset = { x: 0, y: 0 };
let pidIsDragging = false;
let pidLastMouse = { x: 0, y: 0 };
async function pidInit() {
pidCanvas = document.getElementById('pid-canvas');
if (!pidCanvas) return;
pidCtx = pidCanvas.getContext('2d');
window.addEventListener('resize', pidResize);
pidResize();
// 마우스 이벤트 설정
pidCanvas.addEventListener('mousedown', pidOnMouseDown);
pidCanvas.addEventListener('mousemove', pidOnMouseMove);
pidCanvas.addEventListener('mouseup', pidOnMouseUp);
pidCanvas.addEventListener('wheel', pidOnWheel);
pidCanvas.addEventListener('click', pidOnClick);
// 페이지 재진입 시 진행 중인 작업 복구
await pidRestoreBuildStatus();
}
function pidResize() {
const wrap = pidCanvas.parentElement;
pidCanvas.width = wrap.clientWidth;
pidCanvas.height = wrap.clientHeight;
pidRender();
}
async function pidRestoreBuildStatus() {
const savedTaskId = localStorage.getItem('pid_build_task_id');
const savedStartTime = localStorage.getItem('pid_build_start_time');
if (!savedTaskId || !savedStartTime) return;
const statusTxt = document.getElementById('pid-status-txt');
const progWrap = document.getElementById('pid-progress-wrap');
const progBar = document.getElementById('pid-progress-bar');
try {
const statusRes = await api('GET', `/api/pidgraph/status/${savedTaskId}`);
if (statusRes.status === 'Processing' || statusRes.status === 'Starting') {
progWrap.classList.remove('hidden');
const startTime = parseInt(savedStartTime);
const updateTimer = () => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const mins = String(Math.floor(elapsed / 60)).padStart(2, '0');
const secs = String(elapsed % 60).padStart(2, '0');
const timeStr = `[${mins}:${secs}] `;
const currentMsg = statusTxt.textContent;
// 시간 표시 부분([00:00])을 제외한 실제 메시지만 추출
const cleanMsg = currentMsg.startsWith('[') && currentMsg.includes(']')
? currentMsg.substring(currentMsg.indexOf(']') + 1).trim()
: currentMsg;
statusTxt.textContent = timeStr + cleanMsg;
};
const poll = async () => {
try {
const res = await api('GET', `/api/pidgraph/status/${savedTaskId}`);
progBar.style.width = `${res.progress}%`;
statusTxt.textContent = res.message;
updateTimer();
if (res.status === 'Completed') {
statusTxt.textContent = '추출이 완료되었습니다.';
progWrap.classList.add('hidden');
localStorage.removeItem('pid_build_task_id');
localStorage.removeItem('pid_build_start_time');
} else if (res.status === 'Failed') {
statusTxt.textContent = `오류 발생: ${res.message}`;
progWrap.classList.add('hidden');
localStorage.removeItem('pid_build_task_id');
localStorage.removeItem('pid_build_start_time');
} else {
setTimeout(poll, 1000);
}
} catch (e) {
console.error('Polling error during restore:', e);
}
};
setInterval(updateTimer, 1000);
poll();
} else if (statusRes.status === 'Completed') {
localStorage.removeItem('pid_build_task_id');
localStorage.removeItem('pid_build_start_time');
}
} catch (e) {
console.error('Restore status error:', e);
localStorage.removeItem('pid_build_task_id');
localStorage.removeItem('pid_build_start_time');
}
}
async function pidLoadDrawing() {
setGlobal('busy', '도면 데이터 로드 중');
try {
// 1. 기하학적 데이터 로드 (Phase 1 결과물)
const geoRes = await api('GET', '/api/pid/geometry'); // 가상 엔드포인트 (필요시 구현)
// 실제로는 파일에서 직접 읽거나 API를 통해 가져옴.
// 여기서는 예시로 shared_geo_data.json 형태의 데이터를 가정
const geoData = await (await fetch('/futurePlan/End-to-End P&ID Graph Pipeline/shared_geo_data.json')).json();
pidNodeMap.clear();
geoData.nodes.forEach(n => {
pidNodeMap.set(n.id, n);
});
// 2. 위상 데이터 로드 (Phase 2 결과물)
const topoData = await (await fetch('/futurePlan/End-to-End P&ID Graph Pipeline/pid_graph_topology.json')).json();
pidTopology = topoData;
document.getElementById('pid-status-txt').textContent = `도면 로드 완료: ${pidNodeMap.size}개 노드`;
pidRender();
setGlobal('ok', '로드 완료');
} catch (e) {
console.error(e);
document.getElementById('pid-status-txt').textContent = '도면 로드 실패';
setGlobal('err', '로드 실패');
}
}
async function pidBuildGraph(filepath) {
const statusTxt = document.getElementById('pid-status-txt');
const progWrap = document.getElementById('pid-progress-wrap');
const progBar = document.getElementById('pid-progress-bar');
let startTime = Date.now();
let timerInterval = null;
const updateTimer = () => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const mins = String(Math.floor(elapsed / 60)).padStart(2, '0');
const secs = String(elapsed % 60).padStart(2, '0');
const timeStr = `[${mins}:${secs}] `;
// 현재 메시지에서 시간 표시 부분([00:00])을 제외한 실제 메시지만 추출
const currentMsg = statusTxt.textContent;
const cleanMsg = currentMsg.startsWith('[') && currentMsg.includes(']')
? currentMsg.substring(currentMsg.indexOf(']') + 1).trim()
: currentMsg;
statusTxt.textContent = timeStr + cleanMsg;
};
try {
progWrap.classList.remove('hidden');
progBar.style.width = '0%';
// 타이머 시작
timerInterval = setInterval(updateTimer, 1000);
updateTimer();
// 1. 빌드 요청
statusTxt.textContent = '추출 요청 중...';
const res = await api('POST', '/api/pidgraph/build', { filepath });
const taskId = res.taskId;
// 상태 복구를 위해 localStorage에 저장
localStorage.setItem('pid_build_task_id', taskId);
localStorage.setItem('pid_build_start_time', Date.now().toString());
// 2. 폴링 시작
let completed = false;
while (!completed) {
const statusRes = await api('GET', `/api/pidgraph/status/${taskId}`);
progBar.style.width = `${statusRes.progress}%`;
statusTxt.textContent = statusRes.message;
updateTimer(); // 메시지 변경 후 타이머 다시 적용
if (statusRes.status === 'Completed') {
completed = true;
setGlobal('ok', '추출 완료');
} else if (statusRes.status === 'Failed') {
throw new Error(statusRes.message);
}
await new Promise(resolve => setTimeout(resolve, 1000));
}
statusTxt.textContent = '추출이 성공적으로 완료되었습니다.';
progWrap.classList.add('hidden');
} catch (e) {
console.error(e);
statusTxt.textContent = `오류 발생: ${e.message}`;
progWrap.classList.add('hidden');
setGlobal('err', '추출 실패');
} finally {
if (timerInterval) clearInterval(timerInterval);
}
}
function pidRender() {
if (!pidCtx) return;
pidCtx.clearRect(0, 0, pidCanvas.width, pidCanvas.height);
pidCtx.save();
pidCtx.translate(pidOffset.x, pidOffset.y);
pidCtx.scale(pidZoom, pidZoom);
// 1. 엣지(배관) 렌더링
if (pidTopology && pidTopology.edges) {
pidCtx.strokeStyle = '#555';
pidCtx.lineWidth = 1 / pidZoom;
pidCtx.beginPath();
pidTopology.edges.forEach(edge => {
const s = pidNodeMap.get(edge.source);
const t = pidNodeMap.get(edge.target);
if (s && t) {
// 영향도 분석 결과에 포함된 경로라면 하이라이트
if (pidImpactResult && pidImpactResult.paths?.some(p => p.includes(edge.source) && p.includes(edge.target))) {
pidCtx.strokeStyle = '#ff4444';
pidCtx.lineWidth = 3 / pidZoom;
} else {
pidCtx.strokeStyle = '#555';
pidCtx.lineWidth = 1 / pidZoom;
}
pidCtx.moveTo(s.x, s.y);
pidCtx.lineTo(t.x, t.y);
}
});
pidCtx.stroke();
}
// 2. 노드(설비) 렌더링
pidNodeMap.forEach((node, id) => {
const isImpacted = pidImpactResult?.impactedNodes?.[id] !== undefined;
const depth = pidImpactResult?.impactedNodes?.[id] || 0;
pidCtx.fillStyle = isImpacted ? `rgba(255, ${Math.max(0, 255 - depth * 50)}, 0, 0.8)` : '#aaa';
pidCtx.beginPath();
pidCtx.arc(node.x, node.y, 4 / pidZoom, 0, Math.PI * 2);
pidCtx.fill();
if (pidZoom > 1.5) {
pidCtx.fillStyle = '#fff';
pidCtx.font = `${10 / pidZoom}px Arial`;
pidCtx.fillText(node.label || id, node.x + 5 / pidZoom, node.y - 5 / pidZoom);
}
});
pidCtx.restore();
}
// --- 인터랙션 이벤트 ---
function pidOnMouseDown(e) {
pidIsDragging = true;
pidLastMouse = { x: e.clientX, y: e.clientY };
}
function pidOnMouseMove(e) {
if (pidIsDragging) {
pidOffset.x += e.clientX - pidLastMouse.x;
pidOffset.y += e.clientY - pidLastMouse.y;
pidLastMouse = { x: e.clientX, y: e.clientY };
pidRender();
}
// 툴팁 처리
const rect = pidCanvas.getBoundingClientRect();
const worldX = (e.clientX - rect.left - pidOffset.x) / pidZoom;
const worldY = (e.clientY - rect.top - pidOffset.y) / pidZoom;
let found = null;
pidNodeMap.forEach((node, id) => {
const dist = Math.hypot(node.x - worldX, node.y - worldY);
if (dist < 10 / pidZoom) found = { id, ...node };
});
const tooltip = document.getElementById('pid-tooltip');
if (found) {
tooltip.classList.remove('hidden');
tooltip.style.left = (e.clientX - rect.left + 10) + 'px';
tooltip.style.top = (e.clientY - rect.top + 10) + 'px';
tooltip.innerHTML = `<strong>${found.label || found.id}</strong><br>ID: ${found.id}`;
} else {
tooltip.classList.add('hidden');
}
}
function pidOnMouseUp() {
pidIsDragging = false;
}
function pidOnWheel(e) {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
pidZoom *= delta;
pidZoom = Math.min(Math.max(pidZoom, 0.1), 10);
pidRender();
}
async function pidOnClick(e) {
const rect = pidCanvas.getBoundingClientRect();
const worldX = (e.clientX - rect.left - pidOffset.x) / pidZoom;
const worldY = (e.clientY - rect.top - pidOffset.y) / pidZoom;
let clickedNode = null;
pidNodeMap.forEach((node, id) => {
const dist = Math.hypot(node.x - worldX, node.y - worldY);
if (dist < 10 / pidZoom) clickedNode = id;
});
if (clickedNode) {
const node = pidNodeMap.get(clickedNode);
document.getElementById('pid-node-info').innerHTML = `
<strong>노드 ID:</strong> ${clickedNode}<br>
<strong>라벨:</strong> ${node.label || '-'}<br>
<strong>좌표:</strong> (${node.x}, ${node.y})
`;
await pidRequestImpactAnalysis(clickedNode);
}
}
async function pidRequestImpactAnalysis(nodeId) {
const statusTxt = document.getElementById('pid-status-txt');
const progWrap = document.getElementById('pid-progress-wrap');
const progBar = document.getElementById('pid-progress-bar');
statusTxt.textContent = `분석 요청 중: ${nodeId}...`;
progWrap.classList.remove('hidden');
progBar.style.width = '0%';
try {
// 1. 분석 시작 요청 (현재는 graphId가 필요하므로, 로드된 topoData의 ID나 파일명을 사용해야 함)
// 여기서는 단순화를 위해 현재 로드된 도면의 graphId를 가정하거나,
// 실제로는 pidLoadDrawing 시점에 graphId를 저장해두어야 함.
const graphId = "No-10_Plant_PID_graph.json"; // 예시 ID
const startRes = await api('GET', `/api/pidgraph/impact/${graphId}/${nodeId}`);
// 2. 결과 처리 (이제 API가 즉시 결과를 반환하므로 폴링 불필요)
pidImpactResult = startRes;
pidRender();
pidRenderImpactList(startRes);
statusTxt.textContent = '분석 완료';
progWrap.classList.add('hidden');
} catch (e) {
statusTxt.textContent = '분석 오류 발생';
progWrap.classList.add('hidden');
console.error(e);
}
}
function pidRenderImpactList(result) {
const list = document.getElementById('pid-impact-items');
list.innerHTML = '';
const sortedNodes = Object.entries(result.impactedNodes)
.sort((a, b) => a[1] - b[1]);
sortedNodes.forEach(([id, depth]) => {
const li = document.createElement('li');
li.innerHTML = `<span>${id}</span><span class="mut">Depth: ${depth}</span>`;
li.onclick = () => {
const node = pidNodeMap.get(id);
if (node) {
pidOffset.x = pidCanvas.width/2 - node.x * pidZoom;
pidOffset.y = pidCanvas.height/2 - node.y * pidZoom;
pidRender();
}
};
list.appendChild(li);
});
}
function pidClearAnalysis() {
pidImpactResult = null;
document.getElementById('pid-impact-items').innerHTML = '';
document.getElementById('pid-node-info').textContent = '노드를 클릭하면 상세 정보가 표시됩니다.';
pidRender();
}

View File

@@ -0,0 +1,27 @@
<div id="pane-pidgraph" class="pane hidden">
<div class="pid-graph-container">
<div class="pid-toolbar">
<button class="btn-sm" onclick="pidLoadDrawing()">도면 로드</button>
<button class="btn-sm" onclick="pidClearAnalysis()">분석 초기화</button>
<div id="pid-status-bar" class="status-bar">
<span id="pid-status-txt">도면을 로드하세요.</span>
<div id="pid-progress-wrap" class="progress-wrap hidden">
<div id="pid-progress-bar" class="progress-bar"></div>
</div>
</div>
</div>
<div class="pid-canvas-wrap">
<canvas id="pid-canvas"></canvas>
<div id="pid-tooltip" class="pid-tooltip hidden"></div>
</div>
<div class="pid-info-panel">
<h3>P&ID 분석 정보</h3>
<div id="pid-node-info" class="info-box">노드를 클릭하면 상세 정보가 표시됩니다.</div>
<div id="pid-impact-list" class="impact-list">
<h4>영향도 분석 결과</h4>
<ul id="pid-impact-items"></ul>
</div>
</div>
</div>
</div>

24
test_extract_tags.py Normal file
View File

@@ -0,0 +1,24 @@
import json
import httpx
import asyncio
async def test_extraction():
url = "http://localhost:5001/call_tool"
payload = {
"name": "parse_pid_drawing",
"arguments": {
"filepath": "src/Web/uploads/pid/No-10_Plant_PID.dxf"
}
}
try:
async with httpx.AsyncClient(timeout=60.0) as client:
print(f"Calling tool parse_pid_drawing for No-10_Plant_PID.dxf...")
resp = await client.post(url, json=payload)
print(f"Status Code: {resp.status_code}")
print(f"Response: {resp.text}")
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
asyncio.run(test_extraction())