191 lines
8.2 KiB
Python
191 lines
8.2 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 []
|
|
|
|
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)
|