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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
104
futurePlan/End-to-End P&ID Graph Pipeline/pid_analysis_engine.py
Normal file
104
futurePlan/End-to-End P&ID Graph Pipeline/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)
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -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
futurePlan/End-to-End P&ID Graph Pipeline/pid_graph_topology.json
Normal file
112293
futurePlan/End-to-End P&ID Graph Pipeline/pid_graph_topology.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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)
|
||||
848536
futurePlan/End-to-End P&ID Graph Pipeline/shared_geo_data.json
Normal file
848536
futurePlan/End-to-End P&ID Graph Pipeline/shared_geo_data.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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())
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dist_threshold": 20.0,
|
||||
"tag_threshold": 60.0,
|
||||
"merge_threshold": 2.0
|
||||
}
|
||||
BIN
mcp-server/pipeline/__pycache__/analyzer.cpython-312.pyc
Normal file
BIN
mcp-server/pipeline/__pycache__/analyzer.cpython-312.pyc
Normal file
Binary file not shown.
BIN
mcp-server/pipeline/__pycache__/extractor.cpython-312.pyc
Normal file
BIN
mcp-server/pipeline/__pycache__/extractor.cpython-312.pyc
Normal file
Binary file not shown.
BIN
mcp-server/pipeline/__pycache__/mapper.cpython-312.pyc
Normal file
BIN
mcp-server/pipeline/__pycache__/mapper.cpython-312.pyc
Normal file
Binary file not shown.
BIN
mcp-server/pipeline/__pycache__/topology.cpython-312.pyc
Normal file
BIN
mcp-server/pipeline/__pycache__/topology.cpython-312.pyc
Normal file
Binary file not shown.
78
mcp-server/pipeline/analyzer.py
Normal file
78
mcp-server/pipeline/analyzer.py
Normal 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
|
||||
}
|
||||
173
mcp-server/pipeline/extractor.py
Normal file
173
mcp-server/pipeline/extractor.py
Normal 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)
|
||||
122
mcp-server/pipeline/mapper.py
Normal file
122
mcp-server/pipeline/mapper.py
Normal 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"
|
||||
123
mcp-server/pipeline/topology.py
Normal file
123
mcp-server/pipeline/topology.py
Normal 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)
|
||||
@@ -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 도면 파싱.
|
||||
|
||||
16
src/Core/Application/DTOs/PidGraphDtos.cs
Normal file
16
src/Core/Application/DTOs/PidGraphDtos.cs
Normal 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
|
||||
);
|
||||
97
src/Core/Application/Services/PidGraphService.cs
Normal file
97
src/Core/Application/Services/PidGraphService.cs
Normal 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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ────────────────────────────────────────────
|
||||
|
||||
@@ -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] 구독 중지 완료 (앱 종료 — 자동 재시작 플래그 유지)");
|
||||
}
|
||||
|
||||
128
src/Web/Controllers/PidGraphController.cs
Normal file
128
src/Web/Controllers/PidGraphController.cs
Normal 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);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>();
|
||||
|
||||
112
src/Web/wwwroot/css/pid_graph.css
Normal file
112
src/Web/wwwroot/css/pid_graph.css
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
385
src/Web/wwwroot/js/pid-viewer.js
Normal file
385
src/Web/wwwroot/js/pid-viewer.js
Normal 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();
|
||||
}
|
||||
27
src/Web/wwwroot/pid_graph_view.html
Normal file
27
src/Web/wwwroot/pid_graph_view.html
Normal 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
24
test_extract_tags.py
Normal 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())
|
||||
Reference in New Issue
Block a user