Files
ExperionCrawler/.rooBackup/2026-05-03_08-42/mcp-server/pipeline/topology.py

126 lines
5.7 KiB
Python

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)
# 개선: 끝점뿐만 아니라 라인 전체가 설비 BBox와 교차하거나 매우 가까운지 확인
connected_nodes = []
for eq_id in equipments:
eq_bbox = self.G.nodes[eq_id]['bbox']
# 1. 라인이 BBox와 교차하는지 확인 (관통 포함)
if line_geom.intersects(eq_bbox):
connected_nodes.append(eq_id)
# 2. 교차하지 않더라도 임계값 이내에 있는지 확인 (근접 연결)
elif 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 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)