feat: P&ID 연결 분석, LLM 에이전트 모드, KB 확장, MCP 서버 리팩토링
- P&ID: 연결 분석 API, Prefix 규칙 관리, 카테고리 분류, DXF 그래프 빌드 - LLM: 대화 요약, tool card 영구 보존, 시계열 차트(uPlot), 에이전트 모드 - KB: 청크 미리보기, Field Instrument Inference, 인증/Qdrant 클라이언트 - MCP: 서버 기능 확장, 파이프라인 수정, timeout 개선 - Frontend: P&ID UI, LLM UI, KB UI, OPC UA Write 탭 추가 - 설정: AGENTS.md, plant_context, README, opencode.json 업데이트 - 정리: 진단 체크리스트 문서 삭제
This commit is contained in:
3141
mcp-server/block_template_library.json
Normal file
3141
mcp-server/block_template_library.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ import os
|
||||
_SERVER_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_MODEL_FILE = os.path.join(_SERVER_DIR, "llm-model.json")
|
||||
|
||||
_DEFAULT_MODEL = "Qwen3.6-27B-FP8"
|
||||
_DEFAULT_MODEL = "Qwen3.6-35B-A3B-FP8"
|
||||
|
||||
|
||||
def get_vllm_model() -> str:
|
||||
|
||||
1
mcp-server/instrument_inference/__init__.py
Normal file
1
mcp-server/instrument_inference/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Field instrument inference from DCS base_tag to field instruments."""
|
||||
171
mcp-server/instrument_inference/excel.py
Normal file
171
mcp-server/instrument_inference/excel.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""3시트 Excel 초안 생성기 (§3 스키마)."""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
|
||||
import openpyxl
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
|
||||
from .rules import get_all_measurements, get_all_modifiers, get_all_special_prefixes
|
||||
|
||||
|
||||
INSTRUMENT_COLS = [
|
||||
"instrument_id", "display_name", "parent_base_tag", "role", "loop",
|
||||
"area", "measures", "data_points", "from", "to", "description",
|
||||
"confidence", "needs_review", "inference_basis", "operator_notes", "delete",
|
||||
]
|
||||
|
||||
POWER_EQUIPMENT_COLS = [
|
||||
"instrument_id", "display_name", "parent_base_tag", "role", "loop",
|
||||
"area", "equipment_type", "data_points", "description",
|
||||
"confidence", "needs_review", "inference_basis", "operator_notes", "delete",
|
||||
]
|
||||
|
||||
HEADER_FILL = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||||
HEADER_FONT = Font(bold=True, color="FFFFFF", size=11)
|
||||
LOW_FILL = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
|
||||
MEDIUM_FILL = PatternFill(start_color="FFEB9C", end_color="FFEB9C", fill_type="solid")
|
||||
|
||||
|
||||
def generate_excel(instruments: list[dict], unmatched: list[str], power_equipment: list[dict] | None = None) -> str:
|
||||
"""
|
||||
4시트 Excel 생성 후 파일 경로 반환.
|
||||
|
||||
Args:
|
||||
instruments: infer.py 결과 (instrument dict 리스트)
|
||||
unmatched: 룰 미매칭 base_tag 목록
|
||||
power_equipment: 동력기기 (펌프, 압축기, 교반기 등)
|
||||
output_dir: 출력 디렉토리 (기본: /tmp)
|
||||
|
||||
Returns:
|
||||
생성된 xlsx 파일의 절대 경로
|
||||
"""
|
||||
output_dir = tempfile.gettempdir()
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filepath = os.path.join(output_dir, f"instruments_draft_{ts}.xlsx")
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
|
||||
# ── 시트 1: instruments ──
|
||||
_build_instruments_sheet(wb, instruments)
|
||||
|
||||
# ── 시트 2: power_equipment ──
|
||||
_build_power_equipment_sheet(wb, power_equipment or [])
|
||||
|
||||
# ── 시트 3: unmatched_tags ──
|
||||
_build_unmatched_sheet(wb, unmatched)
|
||||
|
||||
# ── 시트 4: naming_convention ──
|
||||
_build_naming_convention_sheet(wb)
|
||||
|
||||
wb.save(filepath)
|
||||
return filepath
|
||||
|
||||
|
||||
def _build_instruments_sheet(wb: openpyxl.Workbook, instruments: list[dict]) -> None:
|
||||
ws = wb.active
|
||||
ws.title = "instruments"
|
||||
|
||||
# 헤더
|
||||
for col_idx, col_name in enumerate(INSTRUMENT_COLS, 1):
|
||||
cell = ws.cell(row=1, column=col_idx, value=col_name)
|
||||
cell.font = HEADER_FONT
|
||||
cell.fill = HEADER_FILL
|
||||
cell.alignment = Alignment(horizontal="center", wrap_text=True)
|
||||
|
||||
# 데이터 행
|
||||
for row_idx, inst in enumerate(instruments, 2):
|
||||
for col_idx, col_name in enumerate(INSTRUMENT_COLS, 1):
|
||||
val = inst.get(col_name, "")
|
||||
cell = ws.cell(row=row_idx, column=col_idx, value=val)
|
||||
|
||||
# confidence별 색상
|
||||
if col_name == "confidence":
|
||||
if val == "low":
|
||||
cell.fill = LOW_FILL
|
||||
elif val == "medium":
|
||||
cell.fill = MEDIUM_FILL
|
||||
|
||||
# 컬럼 너비 자동 조정
|
||||
for col_idx in range(1, len(INSTRUMENT_COLS) + 1):
|
||||
max_len = 0
|
||||
for row in range(1, ws.max_row + 1):
|
||||
val = str(ws.cell(row=row, column=col_idx).value or "")
|
||||
max_len = max(max_len, len(val))
|
||||
ws.column_dimensions[openpyxl.utils.get_column_letter(col_idx)].width = min(max_len + 4, 40)
|
||||
|
||||
|
||||
def _build_naming_convention_sheet(wb: openpyxl.Workbook) -> None:
|
||||
ws = wb.create_sheet("naming_convention")
|
||||
|
||||
# 측정량 표
|
||||
ws.cell(row=1, column=1, value="측정량 (첫 글자)").font = HEADER_FONT
|
||||
ws.cell(row=1, column=2, value="의미").font = HEADER_FONT
|
||||
meas = get_all_measurements()
|
||||
for i, (letter, meaning) in enumerate(meas.items(), 2):
|
||||
ws.cell(row=i, column=1, value=letter)
|
||||
ws.cell(row=i, column=2, value=meaning)
|
||||
|
||||
# 수식어 표 (D열부터)
|
||||
ws.cell(row=1, column=4, value="수식어 (두 번째 이후)").font = HEADER_FONT
|
||||
ws.cell(row=1, column=5, value="role").font = HEADER_FONT
|
||||
ws.cell(row=1, column=6, value="가상").font = HEADER_FONT
|
||||
mods = get_all_modifiers()
|
||||
for i, (letter, info) in enumerate(mods.items(), 2):
|
||||
ws.cell(row=i, column=4, value=letter)
|
||||
ws.cell(row=i, column=5, value=info.get("role", ""))
|
||||
ws.cell(row=i, column=6, value=info.get("virtual", False))
|
||||
|
||||
# 특수 prefix 표 (G열부터)
|
||||
ws.cell(row=1, column=7, value="특수 prefix").font = HEADER_FONT
|
||||
ws.cell(row=1, column=8, value="role").font = HEADER_FONT
|
||||
sps = get_all_special_prefixes()
|
||||
for i, (prefix, info) in enumerate(sps.items(), 2):
|
||||
ws.cell(row=i, column=7, value=prefix)
|
||||
ws.cell(row=i, column=8, value=info.get("role", ""))
|
||||
|
||||
|
||||
def _build_unmatched_sheet(wb: openpyxl.Workbook, unmatched: list[str]) -> None:
|
||||
ws = wb.create_sheet("unmatched_tags")
|
||||
|
||||
ws.cell(row=1, column=1, value="base_tag").font = HEADER_FONT
|
||||
ws.cell(row=1, column=2, value="area").font = HEADER_FONT
|
||||
ws.cell(row=1, column=3, value="action").font = HEADER_FONT
|
||||
ws.cell(row=1, column=4, value="operator_notes").font = HEADER_FONT
|
||||
|
||||
for i, tag in enumerate(unmatched, 2):
|
||||
ws.cell(row=i, column=1, value=tag)
|
||||
ws.cell(row=i, column=2, value="(none)")
|
||||
ws.cell(row=i, column=3, value="운영자가 instruments 시트에 행 추가 필요")
|
||||
ws.cell(row=i, column=4, value="")
|
||||
|
||||
|
||||
def _build_power_equipment_sheet(wb: openpyxl.Workbook, equipment: list[dict]) -> None:
|
||||
ws = wb.create_sheet("power_equipment")
|
||||
|
||||
for col_idx, col_name in enumerate(POWER_EQUIPMENT_COLS, 1):
|
||||
cell = ws.cell(row=1, column=col_idx, value=col_name)
|
||||
cell.font = HEADER_FONT
|
||||
cell.fill = HEADER_FILL
|
||||
cell.alignment = Alignment(horizontal="center", wrap_text=True)
|
||||
|
||||
for row_idx, eq in enumerate(equipment, 2):
|
||||
for col_idx, col_name in enumerate(POWER_EQUIPMENT_COLS, 1):
|
||||
val = eq.get(col_name, "")
|
||||
cell = ws.cell(row=row_idx, column=col_idx, value=val)
|
||||
|
||||
if col_name == "confidence":
|
||||
if val == "low":
|
||||
cell.fill = LOW_FILL
|
||||
elif val == "medium":
|
||||
cell.fill = MEDIUM_FILL
|
||||
|
||||
for col_idx in range(1, len(POWER_EQUIPMENT_COLS) + 1):
|
||||
max_len = 0
|
||||
for row in range(1, ws.max_row + 1):
|
||||
val = str(ws.cell(row=row, column=col_idx).value or "")
|
||||
max_len = max(max_len, len(val))
|
||||
ws.column_dimensions[openpyxl.utils.get_column_letter(col_idx)].width = min(max_len + 4, 40)
|
||||
329
mcp-server/instrument_inference/infer.py
Normal file
329
mcp-server/instrument_inference/infer.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""DCS base_tag → 현장 계기 자동 유추 알고리즘."""
|
||||
from __future__ import annotations
|
||||
import re
|
||||
|
||||
from .rules import get_measurement, get_modifier, get_special_prefix
|
||||
|
||||
|
||||
def split_tag(base_tag: str) -> tuple[str, str]:
|
||||
"""'ficq-6101' → ('ficq', '6101') / 'xv-6124' → ('xv', '6124')."""
|
||||
m = re.match(r"^([a-zA-Z]+)-?(.*)", base_tag)
|
||||
if not m:
|
||||
return base_tag, ""
|
||||
return m.group(1).lower(), m.group(2)
|
||||
|
||||
|
||||
def infer_instruments_for_base_tag(
|
||||
base_tag: str,
|
||||
data_points: list[str],
|
||||
area: str,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
단일 base_tag에 대해 현장 계기 목록을 유추.
|
||||
|
||||
Returns:
|
||||
instrument dict 리스트 (§3.1 스키마와 일치).
|
||||
매칭 실패 시 1행 포함 (confidence=low, needs_review=True).
|
||||
"""
|
||||
head, loop = split_tag(base_tag)
|
||||
if not loop:
|
||||
loop = "unknown"
|
||||
|
||||
dp_set = set(data_points) if data_points else set()
|
||||
|
||||
# 규칙에서 어긋난 태그 → unmatched
|
||||
# loop에 '_' 포함 (fica-3102_op 등) 또는 'esd' 포함 (lt-9113-lo-esd 등 시스템 포인트)
|
||||
if "_" in loop or "esd" in loop.lower():
|
||||
return _build_unmatched(base_tag, area, head, loop)
|
||||
|
||||
# 1. 특수 prefix 우선 (정확한 전체 일치)
|
||||
sp = get_special_prefix(head)
|
||||
if sp:
|
||||
return _build_special(head, loop, area, sp, dp_set)
|
||||
|
||||
# 2. 첫 글자 = 측정량
|
||||
first = head[0]
|
||||
meas = get_measurement(first)
|
||||
if not meas:
|
||||
return _build_unmatched(base_tag, area, head, loop)
|
||||
|
||||
instruments = []
|
||||
has_transmitter = False
|
||||
has_controller = False
|
||||
|
||||
# DCS 내부 기능: controller, totalizer, alarm, switch는 현장 계기가 아님
|
||||
_dcs_internal_roles = {"controller", "totalizer", "alarm", "switch"}
|
||||
|
||||
# 3. 수식어 글자별로 계기 생성
|
||||
for letter in head[1:]:
|
||||
mod = get_modifier(letter)
|
||||
if not mod:
|
||||
continue
|
||||
if mod.get("virtual"):
|
||||
continue # I, R은 가상
|
||||
|
||||
role = mod["role"]
|
||||
|
||||
# DCS 내부 기능은 instruments에서 제외
|
||||
if role in _dcs_internal_roles:
|
||||
if role == "controller":
|
||||
has_controller = True
|
||||
continue
|
||||
|
||||
inst = _build_instrument(first, meas, role, loop, area, mod, dp_set, base_tag)
|
||||
instruments.append(inst)
|
||||
|
||||
if role == "transmitter":
|
||||
has_transmitter = True
|
||||
|
||||
# 4. T 글자가 명시 안 됐어도 컨트롤러가 있으면 송신기 암시
|
||||
if not has_transmitter and has_controller:
|
||||
inst = _build_implicit_transmitter(first, meas, loop, area, dp_set, base_tag)
|
||||
instruments.insert(0, inst)
|
||||
has_transmitter = True
|
||||
|
||||
# 5. 컨트롤러 → 제어밸브 자동 생성 (auto_pair)
|
||||
if has_controller:
|
||||
inst = _build_paired_valve(first, meas, loop, area, dp_set, base_tag)
|
||||
instruments.append(inst)
|
||||
|
||||
# [H-4 수정] 빈 리스트일 때 implicit transmitter 강제 추가
|
||||
# pi-XXXX/ti-XXXX 등 "측정량 + I(virtual)"만 있는 경우 instruments가 비어 있음
|
||||
if not instruments:
|
||||
inst = _build_implicit_transmitter(first, meas, loop, area, dp_set, base_tag)
|
||||
instruments.append(inst)
|
||||
|
||||
# 6. from/to 채우기
|
||||
_link_signal_flow(instruments, base_tag, area)
|
||||
|
||||
# 7. confidence 계산
|
||||
for inst in instruments:
|
||||
inst["confidence"] = _score_confidence(inst, dp_set)
|
||||
inst["needs_review"] = inst["confidence"] == "low"
|
||||
|
||||
return instruments
|
||||
|
||||
|
||||
def _build_instrument(
|
||||
first_letter: str, meas: str, role: str, loop: str, area: str, mod: dict, dp_set: set, parent_tag: str
|
||||
) -> dict:
|
||||
role_id = _role_to_id(first_letter, role, loop)
|
||||
display = f"{first_letter.upper()}{_role_suffix(role).upper()}-{loop.upper()}"
|
||||
dps = mod.get("data_points", [])
|
||||
matched_dps = [d for d in dps if d in dp_set]
|
||||
|
||||
return {
|
||||
"instrument_id": role_id,
|
||||
"display_name": display,
|
||||
"parent_base_tag": parent_tag,
|
||||
"role": role,
|
||||
"loop": loop,
|
||||
"area": area or "(none)",
|
||||
"measures": meas,
|
||||
"data_points": ",".join(matched_dps) if matched_dps else "(none)",
|
||||
"from": "(none)",
|
||||
"to": "(none)",
|
||||
"description": "",
|
||||
"confidence": "medium",
|
||||
"needs_review": False,
|
||||
"inference_basis": f"{first_letter.upper()}+{mod.get('role','?')}",
|
||||
"operator_notes": "",
|
||||
"delete": False,
|
||||
}
|
||||
|
||||
|
||||
def _build_special(head: str, loop: str, area: str, sp: dict, dp_set: set) -> list[dict]:
|
||||
role = sp["role"]
|
||||
meas = sp.get("measures")
|
||||
equip_type = sp.get("equipment_type")
|
||||
role_id = f"{head}-{loop}"
|
||||
display = f"{head.upper()}-{loop.upper()}"
|
||||
|
||||
dp_map = {
|
||||
"shutdown-valve": [".instate0", ".instate1"],
|
||||
"interlock-relay": [".instate0", ".instate1"],
|
||||
"positioner": [".op"],
|
||||
"power_equipment": [".run", ".fault"],
|
||||
}
|
||||
expected_dps = dp_map.get(role, [])
|
||||
matched_dps = [d for d in expected_dps if d in dp_set]
|
||||
|
||||
inst = {
|
||||
"instrument_id": role_id,
|
||||
"display_name": display,
|
||||
"parent_base_tag": f"{head}-{loop}",
|
||||
"role": role,
|
||||
"loop": loop,
|
||||
"area": area or "(none)",
|
||||
"measures": meas or "(none)",
|
||||
"data_points": ",".join(matched_dps) if matched_dps else "(none)",
|
||||
"from": "(none)",
|
||||
"to": "(none)",
|
||||
"description": "",
|
||||
"confidence": "medium",
|
||||
"needs_review": False,
|
||||
"inference_basis": f"special_prefix:{head}",
|
||||
"operator_notes": "",
|
||||
"delete": False,
|
||||
}
|
||||
|
||||
if role == "power_equipment" and equip_type:
|
||||
inst["equipment_type"] = equip_type
|
||||
return [inst]
|
||||
|
||||
|
||||
def _build_unmatched(base_tag: str, area: str, head: str, loop: str) -> list[dict]:
|
||||
return [{
|
||||
"instrument_id": f"{head}-{loop}",
|
||||
"display_name": f"{head.upper()}-{loop.upper()}",
|
||||
"parent_base_tag": base_tag,
|
||||
"role": "equipment",
|
||||
"loop": loop,
|
||||
"area": area or "(none)",
|
||||
"measures": "(none)",
|
||||
"data_points": "(none)",
|
||||
"from": "(none)",
|
||||
"to": "(none)",
|
||||
"description": "",
|
||||
"confidence": "low",
|
||||
"needs_review": True,
|
||||
"inference_basis": "unmatched_prefix",
|
||||
"operator_notes": "",
|
||||
"delete": False,
|
||||
}]
|
||||
|
||||
|
||||
def _build_implicit_transmitter(first: str, meas: str, loop: str, area: str, dp_set: set, parent: str) -> dict:
|
||||
role_id = f"{first}t-{loop}"
|
||||
return {
|
||||
"instrument_id": role_id,
|
||||
"display_name": f"{first.upper()}T-{loop.upper()}",
|
||||
"parent_base_tag": parent,
|
||||
"role": "transmitter",
|
||||
"loop": loop,
|
||||
"area": area or "(none)",
|
||||
"measures": meas,
|
||||
"data_points": ".pv" if ".pv" in dp_set else "(none)",
|
||||
"from": f"process/{area or 'unknown'}-{loop}-inlet",
|
||||
"to": f"tag/{parent}",
|
||||
"description": "",
|
||||
"confidence": "medium",
|
||||
"needs_review": False,
|
||||
"inference_basis": f"{first.upper()}+(implied T)",
|
||||
"operator_notes": "",
|
||||
"delete": False,
|
||||
}
|
||||
|
||||
|
||||
def _build_paired_valve(first: str, meas: str, loop: str, area: str, dp_set: set, parent: str) -> dict:
|
||||
role_id = f"{first}cv-{loop}"
|
||||
return {
|
||||
"instrument_id": role_id,
|
||||
"display_name": f"{first.upper()}CV-{loop.upper()}",
|
||||
"parent_base_tag": parent,
|
||||
"role": "control-valve",
|
||||
"loop": loop,
|
||||
"area": area or "(none)",
|
||||
"measures": "(none)",
|
||||
"data_points": ".op" if ".op" in dp_set else "(none)",
|
||||
"from": f"tag/{first}ic-{loop}",
|
||||
"to": f"process/{area or 'unknown'}-{loop}-downstream",
|
||||
"description": "",
|
||||
"confidence": "high",
|
||||
"needs_review": False,
|
||||
"inference_basis": "C -> CV auto_pair",
|
||||
"operator_notes": "",
|
||||
"delete": False,
|
||||
}
|
||||
|
||||
|
||||
def _role_suffix(role: str) -> str:
|
||||
mapping = {
|
||||
"transmitter": "t",
|
||||
"controller": "ic",
|
||||
"totalizer": "q",
|
||||
"switch": "s",
|
||||
"alarm": "a",
|
||||
"interlock-relay": "y",
|
||||
"positioner": "z",
|
||||
}
|
||||
return mapping.get(role, role[:2])
|
||||
|
||||
|
||||
def _role_to_id(first_letter: str, role: str, loop: str) -> str:
|
||||
return f"{first_letter}{_role_suffix(role)}-{loop}"
|
||||
|
||||
|
||||
def _link_signal_flow(instruments: list[dict], parent_tag: str, area: str) -> None:
|
||||
"""role별 from/to 기본값 채음 (§5.1)."""
|
||||
transmitters = [i for i in instruments if i["role"] == "transmitter"]
|
||||
valves = [i for i in instruments if i["role"] == "control-valve"]
|
||||
|
||||
for t in transmitters:
|
||||
if t["from"] == "(none)":
|
||||
t["from"] = f"process/{area or 'unknown'}-{t['loop']}-inlet"
|
||||
if t["to"] == "(none)":
|
||||
t["to"] = f"tag/{parent_tag}"
|
||||
|
||||
for v in valves:
|
||||
if v["from"] == "(none)":
|
||||
v["from"] = f"tag/{parent_tag}"
|
||||
if v["to"] == "(none)":
|
||||
v["to"] = f"process/{area or 'unknown'}-{v['loop']}-downstream"
|
||||
|
||||
|
||||
def _score_confidence(inst: dict, dp_set: set) -> str:
|
||||
"""data_point 일치도 + prefix 매칭으로 신뢰도 계산."""
|
||||
role = inst["role"]
|
||||
basis = inst.get("inference_basis", "")
|
||||
|
||||
if basis == "unmatched_prefix":
|
||||
return "low"
|
||||
|
||||
expected = {
|
||||
"transmitter": [".pv"],
|
||||
"controller": [".sp", ".op"],
|
||||
"totalizer": [".qv"],
|
||||
"switch": [".instate0"],
|
||||
"shutdown-valve": [".instate0"],
|
||||
}
|
||||
|
||||
checks = expected.get(role, [])
|
||||
if not checks:
|
||||
return "medium" if basis.startswith("special") else "high"
|
||||
|
||||
matched = sum(1 for c in checks if c in dp_set)
|
||||
if matched == len(checks):
|
||||
return "high"
|
||||
if matched > 0:
|
||||
return "medium"
|
||||
return "low"
|
||||
|
||||
|
||||
def _role_to_korean_description(role: str, meas: str) -> str:
|
||||
"""role + 측정량 → 한국어 설명 초안."""
|
||||
meas_ko = {
|
||||
"flow": "유량", "pressure": "압력", "temperature": "온도",
|
||||
"level": "위차", "analysis": "분석", "speed": "회전수",
|
||||
"weight": "중량", "density": "비중", "power": "전력", "moisture": "함량",
|
||||
}
|
||||
m = meas_ko.get(meas, meas)
|
||||
|
||||
role_desc = {
|
||||
"transmitter": f"{m} 송신기",
|
||||
"controller": f"{m} 제어기",
|
||||
"totalizer": f"{m} 적산기",
|
||||
"switch": f"{m} 스위치",
|
||||
"alarm": f"{m} 알람",
|
||||
"control-valve": f"{m} 제어밸브",
|
||||
"shutdown-valve": "차단밸브",
|
||||
"interlock-relay": "인터록 릴레이",
|
||||
"positioner": "포지셔너",
|
||||
"motor": "모터",
|
||||
"pump": "펌프",
|
||||
"compressor": "압축기",
|
||||
"agitator": "교반기",
|
||||
"blower": "송풍기",
|
||||
"fan": "송풍기",
|
||||
}
|
||||
return role_desc.get(role, role)
|
||||
47
mcp-server/instrument_inference/rules.py
Normal file
47
mcp-server/instrument_inference/rules.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""YAML 룰 로더 — prompts/instrument_inference.yaml을 읽고 캐싱."""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import yaml
|
||||
from functools import lru_cache
|
||||
|
||||
_RULES_PATH = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
"..", "..", "prompts", "instrument_inference.yaml",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_rules() -> dict:
|
||||
"""YAML 룰 파일 로드 (lru_cache로 1회만 로드)."""
|
||||
with open(_RULES_PATH, "r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def get_measurement(letter: str) -> str | None:
|
||||
"""첫 글자 → 측정량 반환."""
|
||||
return load_rules().get("measurement", {}).get(letter)
|
||||
|
||||
|
||||
def get_modifier(letter: str) -> dict | None:
|
||||
"""수식어 글자 → role 정보 반환."""
|
||||
return load_rules().get("modifiers", {}).get(letter)
|
||||
|
||||
|
||||
def get_special_prefix(head: str) -> dict | None:
|
||||
"""특수 prefix → role 정보 반환."""
|
||||
return load_rules().get("special_prefixes", {}).get(head)
|
||||
|
||||
|
||||
def get_all_measurements() -> dict:
|
||||
"""측정량 전체 표 반환 (naming_convention 시트용)."""
|
||||
return load_rules().get("measurement", {})
|
||||
|
||||
|
||||
def get_all_modifiers() -> dict:
|
||||
"""수식어 전체 표 반환 (naming_convention 시트용)."""
|
||||
return load_rules().get("modifiers", {})
|
||||
|
||||
|
||||
def get_all_special_prefixes() -> dict:
|
||||
"""특수 prefix 전체 표 반환 (naming_convention 시트용)."""
|
||||
return load_rules().get("special_prefixes", {})
|
||||
129
mcp-server/legend_probe.py
Normal file
129
mcp-server/legend_probe.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
범례 심볼 프로브 — 좌표 박스 내 프리미티브 추출 + 정규화 시그니처 도출.
|
||||
|
||||
사용:
|
||||
python3 mcp-server/legend_probe.py XMIN YMIN XMAX YMAX [라벨]
|
||||
|
||||
출력:
|
||||
1) 원시 프리미티브 (LINE/ARC/CIRCLE/LWPOLYLINE)
|
||||
2) 정규화 시그니처: 앵커=박스 좌하단 기준 상대좌표,
|
||||
도형종류별 개수, 선분 길이·각도, 공유 정점(연결 토폴로지),
|
||||
폐합 삼각형 등 패턴 단서
|
||||
"""
|
||||
import sys, math, collections
|
||||
import ezdxf
|
||||
from ezdxf import recover
|
||||
|
||||
DXF = "dxf-graph/No-10_Plant_PID.dxf"
|
||||
|
||||
|
||||
def load():
|
||||
try:
|
||||
return ezdxf.readfile(DXF)
|
||||
except ezdxf.DXFStructureError:
|
||||
d, _ = recover.readfile(DXF)
|
||||
return d
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 5:
|
||||
print("usage: legend_probe.py XMIN YMIN XMAX YMAX [label]")
|
||||
sys.exit(1)
|
||||
x0, y0, x1, y1 = map(float, sys.argv[1:5])
|
||||
label = sys.argv[5] if len(sys.argv) > 5 else "?"
|
||||
if x0 > x1:
|
||||
x0, x1 = x1, x0
|
||||
if y0 > y1:
|
||||
y0, y1 = y1, y0
|
||||
|
||||
msp = load().modelspace()
|
||||
|
||||
def inb(x, y):
|
||||
return x0 <= x <= x1 and y0 <= y <= y1
|
||||
|
||||
lines, arcs, circles, polys, texts = [], [], [], [], []
|
||||
for e in msp:
|
||||
t = e.dxftype()
|
||||
try:
|
||||
if t == "LINE":
|
||||
s, en = e.dxf.start, e.dxf.end
|
||||
if inb(s.x, s.y) or inb(en.x, en.y):
|
||||
lines.append(((s.x, s.y), (en.x, en.y)))
|
||||
elif t == "ARC":
|
||||
c = e.dxf.center
|
||||
if inb(c.x, c.y):
|
||||
arcs.append((c.x, c.y, e.dxf.radius,
|
||||
e.dxf.start_angle, e.dxf.end_angle))
|
||||
elif t == "CIRCLE":
|
||||
c = e.dxf.center
|
||||
if inb(c.x, c.y):
|
||||
circles.append((c.x, c.y, e.dxf.radius))
|
||||
elif t == "LWPOLYLINE":
|
||||
p = [(a, b) for a, b in e.get_points("xy")]
|
||||
if p and inb(p[0][0], p[0][1]):
|
||||
polys.append(p)
|
||||
elif t in ("TEXT", "MTEXT"):
|
||||
ip = e.dxf.insert
|
||||
if inb(ip.x, ip.y):
|
||||
v = (e.plain_text() if t == "MTEXT" else e.dxf.text).strip()
|
||||
if v:
|
||||
texts.append((round(ip.x, 1), round(ip.y, 1), v[:40]))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f"=== '{label}' box=({x0:.1f},{y0:.1f})-({x1:.1f},{y1:.1f}) ===")
|
||||
print(f"LINE={len(lines)} ARC={len(arcs)} CIRCLE={len(circles)} "
|
||||
f"LWPOLY={len(polys)} TEXT={len(texts)}")
|
||||
if texts:
|
||||
print("텍스트:", [t[2] for t in texts])
|
||||
|
||||
# 앵커 = 비텍스트 프리미티브 최소 x,y
|
||||
pts = []
|
||||
for a, b in lines:
|
||||
pts += [a, b]
|
||||
for cx, cy, r, *_ in arcs:
|
||||
pts += [(cx - r, cy - r), (cx + r, cy + r)]
|
||||
for cx, cy, r in circles:
|
||||
pts += [(cx - r, cy - r), (cx + r, cy + r)]
|
||||
for p in polys:
|
||||
pts += p
|
||||
if not pts:
|
||||
print("(비텍스트 프리미티브 없음)")
|
||||
return
|
||||
ax = min(p[0] for p in pts)
|
||||
ay = min(p[1] for p in pts)
|
||||
w = max(p[0] for p in pts) - ax
|
||||
h = max(p[1] for p in pts) - ay
|
||||
print(f"앵커=({ax:.2f},{ay:.2f}) 정규화 bbox= {w:.2f} x {h:.2f}")
|
||||
|
||||
def n(x, y):
|
||||
return (round(x - ax, 2), round(y - ay, 2))
|
||||
|
||||
print("\n-- LINE (상대좌표 | 길이 | 각도°) --")
|
||||
seg = []
|
||||
for (sx, sy), (ex, ey) in sorted(lines):
|
||||
ln = math.hypot(ex - sx, ey - sy)
|
||||
ang = round(math.degrees(math.atan2(ey - sy, ex - sx)) % 180, 1)
|
||||
print(f" {n(sx,sy)} → {n(ex,ey)} len={ln:.2f} ang={ang}")
|
||||
seg.append((n(sx, sy), n(ex, ey), round(ln, 2), ang))
|
||||
for cx, cy, r in circles:
|
||||
print(f"-- CIRCLE c={n(cx,cy)} r={r:.2f}")
|
||||
for cx, cy, r, sa, ea in arcs:
|
||||
print(f"-- ARC c={n(cx,cy)} r={r:.2f} {sa:.0f}°→{ea:.0f}°")
|
||||
for p in polys:
|
||||
print(f"-- LWPOLY {[n(x,y) for x,y in p]}")
|
||||
|
||||
# 공유 정점 (연결 토폴로지) — 0.3u 이내 동일점
|
||||
vtx = collections.defaultdict(int)
|
||||
for s, e, *_ in seg:
|
||||
vtx[s] += 1
|
||||
vtx[e] += 1
|
||||
shared = {v: c for v, c in vtx.items() if c >= 3}
|
||||
print(f"\n공유정점(차수≥3 = apex/junction): {shared}")
|
||||
ang_hist = collections.Counter(s[3] for s in seg)
|
||||
print(f"각도 분포: {dict(ang_hist)}")
|
||||
print(f"선분 길이 분포: {sorted(s[2] for s in seg)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
144
mcp-server/legend_symbols.json
Normal file
144
mcp-server/legend_symbols.json
Normal file
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"_doc": "범례 역설계 심볼 시그니처 카탈로그. 좌표는 No-10_Plant_PID.dxf 범례 기준. 도면 매칭은 위치/스케일 무관(상대 토폴로지)로 적용.",
|
||||
"_unit_note": "길이 단위 = DXF drawing unit. 범례 글리프 기준 측정.",
|
||||
"connection_rules": {
|
||||
"flange_joint": {
|
||||
"pattern": "기기 양단의 평행 수직선 2개(len~2.27, ~0.68 간격) = [배관측 상대(짝)플랜지(바깥) | 기기측 플랜지(안)] 한 쌍의 플랜지 조인트.",
|
||||
"graph_attach": "배관 라인은 바깥쪽(배관측) 플랜지 수직선에 종단. 기기는 플랜지조인트를 '통과 연결'(한쪽 바깥플랜지→다른쪽 바깥플랜지).",
|
||||
"applies_to": ["GATE_VALVE(도면 실제 표기)", "MASS_FLOW_METER", "인라인 기기 일반"]
|
||||
}
|
||||
},
|
||||
"families": {
|
||||
"FLOW_METER": {
|
||||
"pattern": "닫힌 사각 LWPOLYLINE 본체(~7.2 x 2.27) + 내부 약어 TEXT + 양단 플랜지조인트 + 중심 흐름선 stub.",
|
||||
"type_by_text": "내부 TEXT가 계기 종류 확정: MASS=코리올리/질량, VORTEX=와류 등. 본체 형상 동일, 텍스트만 상이."
|
||||
}
|
||||
},
|
||||
"symbols": [
|
||||
{
|
||||
"name": "GATE_VALVE",
|
||||
"status": "confirmed",
|
||||
"skeleton": "bowtie: 마주보는 두 삼각형, 수직변 2개 len~2.27, 좌우 수평 stub",
|
||||
"diagonal_len": 2.14,
|
||||
"diagonal_count": 4,
|
||||
"center_marker": "none",
|
||||
"diagonal_variant": "변형A(범례): 중점 apex에서 만나는 단대각선 4개 len~2.14. 변형B(도면): 코너-투-코너 교차 대각선 2개 len~4.29(32°/148°). 매칭 불변량 = 수직변2 len~2.27 + 교차대각선 + 중심이 stub선상.",
|
||||
"discriminator": "중심에 마커(SOLID/CIRCLE/채움) 없음 + bowtie 교차 대각선만. stem/상단박스 없음(있으면 ON/OFF).",
|
||||
"in_drawing_note": "실제 도면에서는 bowtie 양쪽에 동일길이 분리 수직선(플랜지)이 있고 배관은 그 플랜지선에 연결. 밸브는 플랜지→플랜지 통과연결.",
|
||||
"legend_ref": {"box": [1654, 5804, 1670, 5809], "label_xy": [1674.2, 5805.3]}
|
||||
},
|
||||
{
|
||||
"name": "GLOBE_VALVE",
|
||||
"status": "confirmed",
|
||||
"skeleton": "bowtie: 수직변 2개 len~2.27, 좌우 stub, 대각선 4개 len 2.14 중심 닿음",
|
||||
"diagonal_len": 2.14,
|
||||
"diagonal_count": 4,
|
||||
"center_marker": "filled",
|
||||
"center_marker_entity": "CLOSED LWPOLYLINE (width 부여 → 채워진 디스크) ~폭 0.74. SOLID/HATCH(solid)도 채움으로 간주.",
|
||||
"discriminator": "bowtie 중심에 채움 엔티티(닫힌 폴리라인/SOLID/solid HATCH) 존재",
|
||||
"legend_ref": {"box": [1650, 5797.6, 1672, 5804.0], "label_xy": [1674.2, 5799.7]}
|
||||
},
|
||||
{
|
||||
"name": "BALL_VALVE",
|
||||
"status": "confirmed",
|
||||
"skeleton": "bowtie 골격(수직변 2개 len~2.27, 좌우 stub) + 중심 빈 CIRCLE",
|
||||
"diagonal_len": 1.46,
|
||||
"diagonal_count": 4,
|
||||
"center_marker": "empty_circle",
|
||||
"center_marker_entity": "CIRCLE r~0.68, 채움 엔티티 없음. 대각선이 짧아(1.46) 중심에 안 닿고 원과 간격.",
|
||||
"discriminator": "bowtie 중심에 빈 CIRCLE + 대각선 len 1.46(중심 미도달)",
|
||||
"legend_ref": {"box": [1650, 5792.0, 1672, 5798.4], "label_xy": [1674.2, 5794.1]}
|
||||
},
|
||||
{
|
||||
"name": "CHECK_VALVE",
|
||||
"status": "confirmed",
|
||||
"skeleton": "수직변 2개 len~2.27 + 좌우 stub (gate 계열 공통 골격)",
|
||||
"diagonal_len": 2.14,
|
||||
"diagonal_count": 1,
|
||||
"center_marker": "filled_triangle",
|
||||
"center_marker_entity": "SOLID 엔티티(채워진 삼각형) 1개, 중심서 우측 offset. 단일 사선 1개(대칭 4대각선 아님).",
|
||||
"discriminator": "SOLID(채워진 삼각형) 존재 + 단일 사선(비대칭) → 대칭 bowtie와 구분. 방향성 있음(SOLID dart가 허용 흐름방향).",
|
||||
"directional": true,
|
||||
"legend_ref": {"box": [1650, 5775.4, 1672, 5781.4], "label_xy": [1674.2, 5777.3], "anchor": [1660.03, 5777.3]}
|
||||
},
|
||||
{
|
||||
"name": "ON_OFF_VALVE",
|
||||
"status": "confirmed",
|
||||
"skeleton": "GATE bowtie 몸체(수직변 2개 len~2.27 + 코너교차 대각선 2개 len~4.29 + 좌우 stub)",
|
||||
"extra": "중심(stub선)에서 위로 단일 수직 stem len~2.03 + 상단 사각 액추에이터 박스 ~3.41 x 1.49 (내부 수평 분할선 1개, 수평선 3개)",
|
||||
"center_marker": "none",
|
||||
"discriminator": "GATE bowtie + 중심 수직 stem + 상단 사각박스(액추에이터). SOLID/CIRCLE/HATCH 채움 없음. → 박스+stem 유무가 GATE와의 분기점.",
|
||||
"legend_ref": {"box": [1650, 5770.0, 1672, 5777.2], "label_xy": [1674.2, 5771.7], "anchor": [1660.03, 5771.7]}
|
||||
},
|
||||
{
|
||||
"name": "CONTROL_VALVE",
|
||||
"status": "confirmed",
|
||||
"skeleton": "GATE bowtie 몸체(수직변 2개 len~2.27 + 코너교차 대각선 len~4.29 + 좌우 stub)",
|
||||
"extra": "중심에서 위로 수직 stem len~2.03 + 상단 ARC 돔(다이어프램 액추에이터, r~1.75, ~반원 12°→168°) + 돔 하단 수평 base선 len~3.41",
|
||||
"center_marker": "none",
|
||||
"actuator": "arc_dome",
|
||||
"discriminator": "GATE bowtie + 중심 stem + 상단 ARC 돔. 액추에이터가 ARC 돔(↔ON/OFF는 사각박스). stem 있음(↔DIAPHRAGM은 stem 없음).",
|
||||
"legend_ref": {"box": [1650, 5759.0, 1672, 5764.5], "label_xy": [1674.1, 5760.5], "anchor": [1660.03, 5760.5]}
|
||||
},
|
||||
{
|
||||
"name": "DIAPHRAGM_VALVE",
|
||||
"status": "confirmed",
|
||||
"skeleton": "GATE bowtie 몸체(수직변 2개 len~2.27 + 코너교차 대각선 len~4.29 + 좌우 stub)",
|
||||
"extra": "상단 ARC 돔(다이어프램 액추에이터, r~1.82, 32°→148°). 중심 stem 없음.",
|
||||
"center_marker": "none",
|
||||
"actuator": "arc_dome",
|
||||
"discriminator": "GATE bowtie + 상단 ARC 돔, 단 중심 수직 stem 없음. ↔ CONTROL_VALVE 는 동일 형태 + stem 있음. (사용자 확정 판별자: stem 유무)",
|
||||
"legend_ref": {"box": [1650, 5765.5, 1672, 5769.5], "label_xy": [1674.1, 5766.1], "anchor": [1660.03, 5766.1]}
|
||||
},
|
||||
{
|
||||
"name": "MASS_FLOW_METER",
|
||||
"status": "confirmed",
|
||||
"family": "FLOW_METER",
|
||||
"skeleton": "닫힌 사각 LWPOLYLINE 본체 ~7.21 x 2.27 + 내부 TEXT \"MASS\"",
|
||||
"extra": "양단 플랜지조인트(각 측 수직 2개 len~2.27, ~0.68 간격: 배관측 상대플랜지+기기측 플랜지) + 기기플랜지→본체 short선 len~1.6 + 중심 흐름선 stub(y중심), 본체폭 valve(~3.6)보다 큼(~7.2)",
|
||||
"center_marker": "text:MASS",
|
||||
"discriminator": "닫힌 사각본체 + 내부 약어 TEXT 'MASS'. bowtie/SOLID/CIRCLE 없음. FLOW_METER 패밀리 — 내부텍스트로 종류 확정. 연결은 connection_rules.flange_joint 적용.",
|
||||
"legend_ref": {"box": [1650, 5670.5, 1672, 5677.2], "label_xy": [1673.7, 5673.2], "anchor": [1654.0, 5671.5]}
|
||||
},
|
||||
{
|
||||
"name": "GENERAL_PUMP",
|
||||
"status": "confirmed",
|
||||
"kind": "equipment",
|
||||
"skeleton": "CIRCLE r~2.96 (펌프 케이싱) + 중심 동심 ARC r~0.66 (노즐/디테일)",
|
||||
"extra": "케이싱 하단에서 좌우 대칭 다리 2개 len~3.34 (각 ~60.5°/119.5°) → 수평 base선 len~6.88 (페디스털 받침)",
|
||||
"center_marker": "concentric_arc",
|
||||
"discriminator": "큰 CIRCLE(r≈3) + 좌우대칭 다리 2개 + 수평 base선. bowtie/SOLID/flange-joint 없음 → 인라인 아닌 장비. 연결=케이싱 노즐(흡입/토출).",
|
||||
"calibration_note": "펌프 케이싱 r≈2~3 (실측 2.96, 도면 P-10101 r≈1.98). sim_line_connection 의 '큰원 r≥4.5' 임계는 펌프 미검출 → 펌프는 r≈2~3 CIRCLE + 다리+base 로 검출해야 함.",
|
||||
"legend_ref": {"box": [1808, 5659.0, 1842, 5675.0], "label_xy": [1841.3, 5666.8], "anchor": [1810.0, 5660.0]}
|
||||
},
|
||||
{
|
||||
"name": "VACUUM_PUMP",
|
||||
"status": "confirmed",
|
||||
"kind": "equipment",
|
||||
"skeleton": "CIRCLE r~2.96 + 좌우대칭 다리 2개 len~3.34 (60.5°/119.5°) + 자기 base선 len~6.89 (GENERAL_PUMP 골격 동일)",
|
||||
"extra": "원 위 X 마크(대각선 2개 len~5.11, 60°/120°) + 원 중심 관통 수평 지름선 len~5.11. GENERAL_PUMP의 중심 소형 동심 ARC 없음.",
|
||||
"center_marker": "X_plus_diameter",
|
||||
"discriminator": "펌프 골격(원 r~3 + 다리 + base) + 원에 X 마크 + 수평 지름선 → VACUUM. (↔ GENERAL_PUMP 은 원에 소형 동심 ARC)",
|
||||
"legend_ref": {"box": [1808, 5648.0, 1842, 5660.0], "label_xy": [1841.1, 5655.4], "anchor": [1810.0, 5648.0],
|
||||
"note": "박스가 위 GENERAL_PUMP 행 침범 시 abs y~5663.3 의 len6.89 수평선(=GENERAL base)은 제외. 펌프 행간격~11.4."}
|
||||
},
|
||||
{
|
||||
"name": "DIAPHRAGM_PUMP",
|
||||
"status": "confirmed",
|
||||
"kind": "equipment",
|
||||
"skeleton": "닫힌 사각 LWPOLY body ~4.65 x 2.65 (CIRCLE 없음 — 박스형 정변위 펌프)",
|
||||
"extra": "양측 flange_joint(짧은 평행 수직선 스택 len~2.27/2.39/2.64) + 중심 흐름선(len~3.44 양방향) + body 측면 소형 노즐 len~0.97",
|
||||
"center_marker": "none",
|
||||
"discriminator": "원 없음 + 사각 LWPOLY body + 양측 flange_joint → DIAPHRAGM(박스형). ↔ GENERAL/VACUUM 은 CIRCLE 케이싱.",
|
||||
"legend_ref": {"box": [1808, 5636.0, 1842, 5648.0], "label_xy": [1841.1, 5643.7], "anchor": [1810.0, 5636.0],
|
||||
"note": "박스가 위 VACUUM_PUMP 행 침범 시 abs y~5651.94 의 len6.89 수평선(=VACUUM base)은 제외."}
|
||||
}
|
||||
],
|
||||
"_probe_lesson": "펌프/다행 심볼은 박스를 단일 행 높이(±~5.7)로 좁혀라. 위 행의 base 수평선(len~6.89)이 침범하면 제외. 펌프 행간격≈11.4 (base: GENERAL 5663.3 / VACUUM 5651.94 / DIAPHRAGM ~5640).",
|
||||
"ball_valve_variants": {
|
||||
"BALL_VALVE": "수평, r~0.68, 대각 1.46, 바 2.27, bbox 3.64x2.27",
|
||||
"BALL_VALVE_VERTICAL": "수직(흐름 상하), r~0.32, 대각 0.66, 바 1.01 (수평바4). 수평형의 ~0.46배 축소 + 90° 회전. 글리프 외 블록에 연결배관선(len~13.58) 포함.",
|
||||
"detection_lesson": "원-앵커 매칭 시 r 필터를 0.4 미만까지(>=0.28) 확장 필수. r=0.32~0.35 대역에 소형 볼밸브가 대량(히스토그램 139+180). 매칭은 스케일 불변(비율) + 회전 불변이어야 함. 소형은 faceted(원이 작아 거의 점) — 원 존재 자체를 앵커로, 주변 bowtie 바/대각 비율로 확정.",
|
||||
"flange_inclusion_rule": "BALL_VALVE_VERTICAL 블록은 플랜지(수평 바 4개 len~1.01)를 포함. 클러스터 전체(원 r0.32 + 대각4 len0.66 + 플랜지바4 + 연결선)를 볼밸브 1기로 판정 — 플랜지를 별도 식별/분리하지 말 것. 단일 통과형 기기로 처리(흐름 통과)."
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"vllm_model":"Qwen3.6-27B-FP8"}
|
||||
{"vllm_model":"Qwen3.6-35B-A3B-FP8"}
|
||||
6151
mcp-server/pid_trace_algorithm-오퍼스대화록.md.
Normal file
6151
mcp-server/pid_trace_algorithm-오퍼스대화록.md.
Normal file
File diff suppressed because it is too large
Load Diff
160
mcp-server/pid_trace_algorithm.md
Normal file
160
mcp-server/pid_trace_algorithm.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# P&ID 흐름 추적 · From/To 추출 알고리즘 사양서
|
||||
|
||||
> 출처: P10-EQP-BLOCK.dxf 협업 역설계로 검증된 규칙 통합 (2026-05-18).
|
||||
> 참조 자산: `mcp-server/legend_symbols.json`(심볼 시그니처), `mcp-server/block_template_library.json`(블록 템플릿),
|
||||
> `mcp-server/legend_probe.py`(프로브). 관련 메모리: feedback-trace-reporting-granularity,
|
||||
> reference-lineno-leader-rule, project-legend-symbol-catalog.
|
||||
|
||||
---
|
||||
|
||||
## 0. 목적과 핵심 전제 (지배 규칙)
|
||||
|
||||
- **목적**: 도면에서 유체 흐름을 추적해 **랜드마크 간 From/To 연결그래프**와 **라인번호·출처**를 추출.
|
||||
- **답변 단위**: "FCV-10101 어디야?" → "라인 P-10149-40A-F1A-n (출처 T-10100) 상, P-10101 흡입헤더·토출·F-10102A/B 거쳐 FIT-10101 하류". **수동밸브/체크/호스/레듀서 등 commodity는 절대 열거 안 함.**
|
||||
- **정밀 카운트 아님**: 견적용 개수 검출이 목적이 아니라 연결·위상 파악이 목적.
|
||||
|
||||
### 노드 분류
|
||||
|
||||
| 분류 | 예 | 처리 |
|
||||
|---|---|---|
|
||||
| **랜드마크 노드** | 설비(P-,T-,E-,C-,D-,F-,SC-…), 컨트롤밸브/태그계기(FCV-,FIT-,LIC-…), 모터 | From/To 그래프의 정점 |
|
||||
| **투명 통과 (commodity)** | 수동 볼/체크/글로브밸브, 레듀서, 플렉시블호스, 스트레이너, 플랜지 | 흐름 안 끊기게 **브리지만**, 노드 아님, 출력에 미열거 |
|
||||
| **연결 매체** | 배관(pipings) | 엣지. line_number·출처 보유 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 입력 자산
|
||||
|
||||
1. **설비 블록**: 태그명 INSERT (이름=태그). 위치 = `virtual_entities()` 월드 bbox (insert (0,0)+내부절대좌표 / 또는 실좌표 — 변환이 흡수).
|
||||
2. **심볼 템플릿 라이브러리**: 명명 블록 정의의 정규화 시그니처(상대좌표·길이·각도·토폴로지, 원/호/SOLID 수). 회전·미러 **불변** 종류식별 + 월드기하 방향산출 2계층.
|
||||
3. **raw 지오메트리**: modelspace LINE/LWPOLYLINE/ARC/CIRCLE/SOLID/HATCH — 실제 그려진 배관·commodity 심볼.
|
||||
4. **OFFPAGE_CONNECTOR**: 방향성 블록(LINE 5~6) + **본체 bbox 내부 TEXT 2개**(출처설비/서비스, 라인번호/연속키).
|
||||
5. **라인번호 TEXT + 지시선**: 직접배치 또는 leader(SOLID/선-구성 chevron 화살촉 + stem).
|
||||
|
||||
---
|
||||
|
||||
## 2. 알고리즘 단계
|
||||
|
||||
### Phase 1 — 랜드마크 노드 추출
|
||||
- 모든 INSERT 순회. 이름이 태그패턴(`^[A-Z0-9]{1,4}-?\d{3,5}` 또는 명시 설비명)이면 **설비 노드**: tag, world bbox 중심, 카테고리(prefix 규칙).
|
||||
- 태그계기/컨트롤밸브: 심볼(글로브밸브 등) + 인접 태그TEXT(FCV-/FIT-…) → **태그계기 노드**. 태그=인접 라벨, 위치=심볼 월드좌표.
|
||||
- `-SAME`/중복 복제 = **동일 노드로 병합**(같은 물리설비).
|
||||
|
||||
### Phase 2 — 배관망 + commodity 투명 브리지
|
||||
- raw LINE/LWPOLYLINE → 끝점 그래프(좌표 양자화). 세그먼트 = 잠재 배관.
|
||||
- **commodity 심볼 인식 → 투명 통과**:
|
||||
- 소형 빈 CIRCLE r≈0.28~0.46 (+ bowtie 바/대각) = 볼밸브 → pass-through. (r 필터 하한 0.28 필수; faceted·무원 변형은 클러스터 패턴으로)
|
||||
- SOLID 삼각형 + 단일사선 = 체크밸브(방향성) → pass-through
|
||||
- 닫힌 LWPOLY 채움 = 글로브 / ARC 돔+stem = 컨트롤·다이어프램 (단 **태그 있으면 랜드마크**, 없으면 commodity)
|
||||
- 레듀서/플렉시블호스/스트레이너/플랜지 = pass-through
|
||||
- 회전·미러는 `virtual_entities()` 월드기하로 흡수. **블록명은 힌트, 방향근거 금지.**
|
||||
- **gap-통과 브리지**: 배관 끊김 → 진행 흐름벡터 방향(콘)으로 lookahead, gap에 commodity 심볼 있으면 그 너머 공선 세그먼트로 연결 계속.
|
||||
|
||||
### Phase 3 — 위상 인식 (흐름방향·분기·합류·루프)
|
||||
- **흐름방향 = 의미적**: 발원지(OFFPAGE/탱크/펌프)에서 누적 흐름벡터. **DXF 선 정점순서 무시, 역행 금지, 방문엣지 가드(진동 방지).**
|
||||
- **FLOW_DIRECTION 화살표**: 만나면 그 위치 흐름방향을 **월드기하 apex→tail 벡터로 확정**(이름 아님). 의미적 추정보다 우선.
|
||||
- **엘보**: 직각 굽힘은 끝점공유로 따라감(수평→수직 등).
|
||||
- **TEE = 분기/합류**: 한 점에 3선 이상. 분기 시 본류+지선 분할.
|
||||
- **재순환(킥백) 루프**: 한 지선이 이미 방문한 흡입측으로 복귀 = **정상 사이클**. 무한루프 아님 → `recirculation` 엣지로 표기, 그 지선 종료.
|
||||
- **헤더/매니폴드**: 수평선에 수직 분기 다수 → 합류 매니폴드. 좌단 블라인드플랜지=막힘(흐름 반대편).
|
||||
- **병렬 트레인**: 동일 x/y의 평행 분기→합류 (예 F-10102A/B 수직병렬).
|
||||
- **OFFPAGE 짝**: 본체 내부 라인번호/연속키 TEXT 추출. **동일 연속키 가진 커넥터끼리 페어** → 라인 점프(시트/위치). 방향 = 커넥터 월드기하.
|
||||
|
||||
### Phase 4 — 라인번호 ↔ 배관 귀속
|
||||
- **케이스 A 직접배치**: 텍스트가 배관 복도 근접(수평배관 위 평행, **또는 수직배관 옆 span중간 가로배치**). 박스 내 최근접 세그먼트.
|
||||
- **케이스 B 지시선**: 화살촉(SOLID **또는** 1.0바+0.66대각 선-구성 chevron) → **tip 좌표가 닿는 배관 = 대상.** **tip↔배관 매칭 허용오차 극소(sub-0.5u, ≈0)** — 밀집부 오매칭 방지. stem(LINE/LWPOLY/SPLINE/LEADER 무관) → landing → 텍스트 역추적도 성립.
|
||||
- **모호 분해**: 근접 평행 수직 2개 등 기하로 불가 시 → **흐름-토폴로지 컨텍스트**(어느 배관이 추적경로/설비에 실제 연결)로 결정.
|
||||
|
||||
### Phase 5 — 랜드마크 From/To 환원
|
||||
- 투명 commodity 체인 **붕괴(collapse)**: 랜드마크→(commodity*)→랜드마크 를 **랜드마크↔랜드마크 단일 엣지**로.
|
||||
- 엣지 = `from_tag`(상류 랜드마크), `to_tag`(하류 랜드마크). **From/To에 배관번호 안 넣음.**
|
||||
- 상/하류 = 흐름방향으로 결정. 미결정 시 무방향(from/to 임의·플래그).
|
||||
- 배관번호·출처 = **pipings 레코드/엣지 속성**(권위). 랜드마크 위치질의는 랜드마크→인접배관→line_number+OFFPAGE출처 **조인 조립**.
|
||||
|
||||
---
|
||||
|
||||
## 3. 출력 스키마 (`pid_equipment` 정합)
|
||||
|
||||
| 카테고리 | from_tag / to_tag | line_number |
|
||||
|---|---|---|
|
||||
| equipment / control_valve / motor / instrument | 인접 **랜드마크 태그** ↔ 랜드마크 태그 | (옵션) 얹힌 라인 단일 참조, From/To 불포함 |
|
||||
| pipings | 라인 양끝 랜드마크 / OFFPAGE 짝 | 그 배관 라인번호 (권위) |
|
||||
|
||||
- `recirculation`/`bypass` 엣지는 타입 플래그로 구분(정상 위상, 누락·추측 금지).
|
||||
- 재실행 결정성: 기존 From/To 초기화 후 재계산하되 **수동 import 잠금(ConnectionLocked) 보존**.
|
||||
|
||||
---
|
||||
|
||||
## 4. 핵심 불변식 (검증된 함정 회피)
|
||||
|
||||
1. 흐름방향은 의미적(발원지 기준), 선 정점순서·블록명 불신. 방문엣지 가드 필수(self-oscillation 방지).
|
||||
2. 회전/미러 → 종류는 불변시그니처, 방향은 `virtual_entities()` 월드기하. 동일형상 다른이름(FLOW_DIR 3종 등) 주의.
|
||||
3. 볼밸브 r≈0.28~0.46 (소형 지배, 도면 다수). 펌프 케이싱 r≈2~3 (≥4.5 아님).
|
||||
4. 재순환선 = 정상 사이클. 끊지 말고 루프로 표기.
|
||||
5. 화살촉 tip↔배관 = sub-0.5u 준-정확. 화살촉은 SOLID 또는 선-구성 chevron.
|
||||
6. From/To = 랜드마크↔랜드마크. 배관번호는 엣지/pipings 속성. commodity 미열거(답변 단위 전제).
|
||||
|
||||
---
|
||||
|
||||
## 5. 정직한 한계 / 미검증
|
||||
|
||||
- raw 패턴매칭은 퍼지: 재현율 100% 보장 못 함(회전·스케일·작도변형·배관병합).
|
||||
- 밀집부(토출·HATCH다수)는 좁은 허용오차 없이는 오탐.
|
||||
- 지시선 변형: SOLID·선-구성 chevron 검증됨. **SPLINE 곡선 stem 미검증**.
|
||||
- 직접배치 평행수직 모호 → 토폴로지 컨텍스트 의존(자동화 시 흐름추적 선행 필요).
|
||||
- OFFPAGE 짝 매칭은 연속키 텍스트 정확추출 전제(밀집·회전 텍스트 노이즈 가능).
|
||||
|
||||
---
|
||||
|
||||
## 6. 구현 상태 · 다음 세션 인계 (2026-05-18 2차 세션 종료 시점)
|
||||
|
||||
### 산출물 (확정·보존)
|
||||
- 사양서: `mcp-server/pid_trace_algorithm.md` (이 문서)
|
||||
- 구현체: `mcp-server/pid_tracer.py` — Phase 1·1b·2·3·4·5 구현 (미드스트림 라우팅 미완)
|
||||
- 카탈로그: `mcp-server/legend_symbols.json`, `mcp-server/block_template_library.json`, `mcp-server/legend_probe.py`
|
||||
- 출력: `mcp-server/storage/P10-EQP-BLOCK_connections.json` (C# 소비 포맷 호환)
|
||||
- C# 소비경로: `AnalyzeConnectionsAsync` 이미 `<prefix>_connections.json` edges 소비 (이전 세션 구현)
|
||||
|
||||
### 정답 레퍼런스 (협업 검증된 ground truth)
|
||||
- P-10101 시스템 전체 위상 = **흡입 T-10100/T-10101 합류 + 킥백 재순환 루프 +
|
||||
토출 → 병렬필터 F-10102A/B → MASS_FLOW_METER(FIT-10101) → FCV(+바이패스) → E-10103**
|
||||
- 주의: 핸드오프 기억상 "FCV-10101" 이나 도면 실제 인접 버블은 **FCV-10116** (도면 권위)
|
||||
|
||||
### 현재 어디까지 (2026-05-18 2차 세션 (a) 보강)
|
||||
|
||||
**현재 트레이서 실측 출력:**
|
||||
- 랜드마크 57, OFFPAGE 시드 1, 볼앵커 372, raw 14,764, 라인번호귀속 227
|
||||
- `OFFPAGE T-10100` 시드: T-10100 노드만 (공급선 P-10101 미완주)
|
||||
- `P-10101 discharge` 시드: **P-10101 → F-10102B → F-10102A → FIT-10101** (여기서 멈춤)
|
||||
|
||||
**완료·검증:**
|
||||
1. ✅ **OFFPAGE 발원 소스 시드**: OFFPAGE_CONNECTOR 4종 중 LEFT_TO_RIGHT 처리.
|
||||
apex = 본체 주축 두 극단 중 '뾰족한'(정점 1개) 쪽, 방향 = 뒷변중점→apex (월드기하
|
||||
권위). 본체 근접 TEXT 분류 → 출처태그 `T-10100` 발원 노드 등록 + 라인번호
|
||||
`P-10149-40A-F1A-n` 시드 부착. (공급선이 P-10101 까지 *완주*는 미달 — §아래)
|
||||
2. ⬆️ **랜드마크 통과-후-재개**: `resume_nozzle` — 블록 실체범위(SOLID/원반경 포함
|
||||
`world_extent`) 밖, **안정적 시드 흐름축** 투영 최대 미방문 노즐로 재개. 결과:
|
||||
`P-10101 → F-10102B → F-10102A → FIT-10101` (이전: F-10102A 에서 멈춤).
|
||||
3. ⬆️ **명명 심볼 = 태그계기 합성**: MASS_FLOW_METER/CONTROL_VALVE_GLOBE/
|
||||
FLOW_METER_VARIABLE-AREA 를 commodity 에서 제외, 인접 함수코드+번호 버블 TEXT 로
|
||||
태그 합성 (`FIT`+`10101`→**FIT-10101**, `FCV`+`10116`→**FCV-10116**). 블록명은
|
||||
종류 힌트, 태그는 인접 TEXT 권위.
|
||||
4. ⬆️ **일반 전방 gap-브리지**: commodity/작도 끊김을 흐름축 콘(dot>0.85+수직오프셋
|
||||
최소)으로 점프, 착지점에서 정상 conn 재전진(역행 억제).
|
||||
5. ⬆️ **볼앵커 과포함 정밀화**: r 0.28~0.46 + 동반 단선(≤2.6) ≥2 조건 → 560→372.
|
||||
6. 🧩 **Phase 4·5 실코드화**: 라인번호 귀속(SOLID tip sub-0.5u / 직접배치, 227건),
|
||||
재순환 엣지·OFFPAGE 라인번호 엣지 속성.
|
||||
|
||||
**남은 미완 (다음 세션 계속 (a)2/(a)3):**
|
||||
- **밀집 미드스트림 라우팅**: `FIT-10101 → FCV-10116 → E-10103` 장거리 런이
|
||||
계기 테이크오프·샘플 분기 fitting 기하에 갇힘. 계기-테이크오프 거부 +
|
||||
헤더/TEE 추종 필요.
|
||||
- **공급선 P-10101 완주**: OFFPAGE 시드가 흡입헤더까지 라우팅 미완(하향 드리프트).
|
||||
- TEE 분기 본류/지선 분할·OFFPAGE 동일연속키 짝 매칭 = 미구현(골격).
|
||||
|
||||
**(b) C# 연동·DB 반영 (착수 전):**
|
||||
1. 트레이서 의미 엣지 산출 후 `AnalyzeConnectionsAsync` 를 P10-EQP-BLOCK 출력 연결
|
||||
2. From/To = 랜드마크↔랜드마크만(배관번호 미내장, pipings/엣지 속성) — §3 스키마대로 DB 반영
|
||||
3. 수동 import 잠금(ConnectionLocked) 보존 회귀 확인
|
||||
|
||||
검증 진입점(SEEDS)·정답 위상은 `pid_tracer.py` 주석 및 본 문서 §2·메모리에 보존됨.
|
||||
497
mcp-server/pid_tracer.py
Normal file
497
mcp-server/pid_tracer.py
Normal file
@@ -0,0 +1,497 @@
|
||||
"""
|
||||
P&ID 흐름 추적 · 랜드마크 From/To 추출 (pid_trace_algorithm.md 구현)
|
||||
|
||||
Phase 1(랜드마크+OFFPAGE 소스) → Phase 2(배관망+commodity 투명) →
|
||||
Phase 3(흐름추적: 방문가드·엘보·TEE·재순환·랜드마크 통과-후-재개) →
|
||||
Phase 4(라인번호 귀속) → Phase 5(랜드마크 From/To 환원).
|
||||
출력: <prefix>_connections.json (C# AnalyzeConnectionsAsync 소비 포맷 호환).
|
||||
|
||||
usage:
|
||||
python3 mcp-server/pid_tracer.py [DXF경로] (기본 src/Web/uploads/pid/P10-EQP-BLOCK.dxf)
|
||||
PID_TRACE_DEBUG=1 python3 mcp-server/pid_tracer.py (경로 좌표 덤프)
|
||||
"""
|
||||
import sys, os, re, math, json, collections
|
||||
import ezdxf
|
||||
from ezdxf import recover
|
||||
|
||||
DXF = sys.argv[1] if len(sys.argv) > 1 else "src/Web/uploads/pid/P10-EQP-BLOCK.dxf"
|
||||
DEBUG = os.environ.get("PID_TRACE_DEBUG") == "1"
|
||||
|
||||
# 랜드마크 태그 패턴 / commodity 블록명(투명 통과)
|
||||
EQP_TAG = re.compile(r'^[A-Z]{1,4}-?\d{3,5}[A-Z]?$')
|
||||
INSTR_TAG = re.compile(r'^(FCV|FIT|FIC|LIC|LCV|PCV|PIC|TIC|TCV|FV|LV|PV|TV|AT|FT|LT|PT|TT)-?\d', re.I)
|
||||
# 라인번호: 사이즈코드/연속세그먼트 다수 (예 P-10149-40A-F1A-n, 25Ax32A)
|
||||
LINE_NO = re.compile(r'^[A-Z]{1,3}-?\d{3,5}-\w+-\w+|^\d+A?x\d+A?$', re.I)
|
||||
COMMODITY = re.compile(
|
||||
r'BALL|CHECK|GLOBE|GATE|REDUCER|FLEX|HOSE|TUBE|STRAIN|FLANGE|VALVE_BALL|ON-?OFF|'
|
||||
r'DIAPHRAGM|NEEDLE|PLUG|BUTTERFLY|TRAP|EXPAN|CONE', re.I)
|
||||
JUNK = {"asda", "QQ", "sm", "bv", "GENAXEH", "D660198", "RC11", "HS_BOM",
|
||||
"IC", "ORDERNO", "ASDADAS", "1", "F", "SG", "BFT", "EX1"}
|
||||
# 명명 심볼 블록 = 태그계기/컨트롤밸브 (인접 버블 TEXT 로 태그 합성). commodity 아님.
|
||||
NAMED_INSTRUMENT = {"MASS_FLOW_METER", "CONTROL_VALVE_GLOBE",
|
||||
"FLOW_METER_VARIABLE-AREA"}
|
||||
FUNC_CODE = re.compile(
|
||||
r'^(FIT|FCV|FIC|FE|FQ|FQI|FI|FT|LIC|LCV|LI|LT|LSL|LSH|PIC|PCV|PI|PT|PSV|PSH|'
|
||||
r'TIC|TCV|TI|TT|AT|AIT|FV|LV|PV|TV)$')
|
||||
NUM_TXT = re.compile(r'^\d{3,6}[A-Z]?$')
|
||||
|
||||
|
||||
def load():
|
||||
try:
|
||||
return ezdxf.readfile(DXF)
|
||||
except ezdxf.DXFStructureError:
|
||||
d, _ = recover.readfile(DXF)
|
||||
return d
|
||||
|
||||
|
||||
def world_extent(insert):
|
||||
"""SOLID/ARC/CIRCLE 반경까지 포함한 월드 bbox (블록 실체 범위)."""
|
||||
xs, ys = [], []
|
||||
try:
|
||||
for ve in insert.virtual_entities():
|
||||
t = ve.dxftype()
|
||||
if t == "LINE":
|
||||
xs += [ve.dxf.start.x, ve.dxf.end.x]
|
||||
ys += [ve.dxf.start.y, ve.dxf.end.y]
|
||||
elif t in ("CIRCLE", "ARC"):
|
||||
r = getattr(ve.dxf, "radius", 0.0)
|
||||
xs += [ve.dxf.center.x - r, ve.dxf.center.x + r]
|
||||
ys += [ve.dxf.center.y - r, ve.dxf.center.y + r]
|
||||
elif t == "ELLIPSE":
|
||||
xs += [ve.dxf.center.x]
|
||||
ys += [ve.dxf.center.y]
|
||||
elif t == "SOLID":
|
||||
for k in ("vtx0", "vtx1", "vtx2", "vtx3"):
|
||||
p = getattr(ve.dxf, k, None)
|
||||
if p is not None:
|
||||
xs.append(p.x)
|
||||
ys.append(p.y)
|
||||
except Exception:
|
||||
pass
|
||||
if not xs:
|
||||
ip = insert.dxf.insert
|
||||
return (ip.x, ip.y, ip.x, ip.y)
|
||||
return (min(xs), min(ys), max(xs), max(ys))
|
||||
|
||||
|
||||
def is_landmark_block(name):
|
||||
if name.startswith(("A$", "*")) or name in JUNK:
|
||||
return False
|
||||
if name.startswith("OFFPAGE") or name.startswith("FLOW_DIRECTION"):
|
||||
return False
|
||||
if COMMODITY.search(name):
|
||||
return False
|
||||
base = name.split("-SAME")[0].strip()
|
||||
return bool(EQP_TAG.match(base)) or name in ("IBC TANK", "3-10203") or bool(INSTR_TAG.match(name))
|
||||
|
||||
|
||||
def main():
|
||||
doc = load()
|
||||
msp = doc.modelspace()
|
||||
|
||||
all_text = []
|
||||
for e in msp:
|
||||
if e.dxftype() in ("TEXT", "MTEXT"):
|
||||
v = (e.plain_text() if e.dxftype() == "MTEXT" else e.dxf.text).strip()
|
||||
if v:
|
||||
all_text.append((v, e.dxf.insert.x, e.dxf.insert.y))
|
||||
|
||||
# ── Phase 1: 랜드마크 노드 (INSERT 블록 + 태그계기 TEXT) ───────────
|
||||
landmarks = {} # tag -> (cx, cy)
|
||||
lm_block = {} # tag -> (cx, cy, (x0,y0,x1,y1)) INSERT 실체 범위
|
||||
for e in msp:
|
||||
if e.dxftype() != "INSERT":
|
||||
continue
|
||||
nm = e.dxf.name
|
||||
if not is_landmark_block(nm):
|
||||
continue
|
||||
tag = nm.split("-SAME")[0].strip() # T-3210-SAME* → T-3210 병합
|
||||
x0, y0, x1, y1 = world_extent(e)
|
||||
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
|
||||
landmarks.setdefault(tag, (cx, cy))
|
||||
lm_block.setdefault(tag, (cx, cy, (x0, y0, x1, y1)))
|
||||
|
||||
# 태그계기: 인접 TEXT 가 FCV-/FIT- 인 commodity-형상 심볼 (월드 텍스트로 근사)
|
||||
for v, tx, ty in all_text:
|
||||
vk = v.split()[0].replace(" ", "")
|
||||
if INSTR_TAG.match(vk) and not LINE_NO.match(vk):
|
||||
landmarks.setdefault(vk, (tx, ty))
|
||||
|
||||
# 명명 심볼 블록(MASS_FLOW_METER 등) = 태그계기. 인접 함수코드+번호 버블로
|
||||
# 태그 합성(FIT+10101 → FIT-10101). 블록명은 종류힌트, 태그는 인접 TEXT.
|
||||
func_txt = [(v, x, y) for v, x, y in all_text if FUNC_CODE.match(v.strip())]
|
||||
num_txt = [(v, x, y) for v, x, y in all_text if NUM_TXT.match(v.strip())]
|
||||
for e in msp:
|
||||
if e.dxftype() != "INSERT" or e.dxf.name not in NAMED_INSTRUMENT:
|
||||
continue
|
||||
x0, y0, x1, y1 = world_extent(e)
|
||||
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
|
||||
code = min(((math.hypot(fx - cx, fy - cy), fv, fx, fy)
|
||||
for fv, fx, fy in func_txt), default=None)
|
||||
if not code or code[0] > 12:
|
||||
continue
|
||||
_, fv, fx, fy = code
|
||||
num = min(((math.hypot(nx - fx, ny - fy), nv)
|
||||
for nv, nx, ny in num_txt
|
||||
if math.hypot(nx - fx, ny - fy) <= 4.5), default=None)
|
||||
if not num:
|
||||
continue
|
||||
tag = f"{fv.strip()}-{num[1].strip()}"
|
||||
landmarks.setdefault(tag, (cx, cy))
|
||||
lm_block.setdefault(tag, (cx, cy, (x0, y0, x1, y1)))
|
||||
|
||||
# ── Phase 1b: OFFPAGE_CONNECTOR 발원 소스 시드 ───────────────────
|
||||
# 본체 라인 → apex(수렴 정점) 와 흐름방향(centroid→apex, 월드기하 권위).
|
||||
# 본체 근접 TEXT → 출처설비태그(EQP/INSTR) + 라인번호(LINE_NO).
|
||||
offpage_seeds = [] # (src_tag, (ax,ay), (dx,dy), line_no, conn_name)
|
||||
for e in msp:
|
||||
if e.dxftype() != "INSERT" or not e.dxf.name.startswith("OFFPAGE"):
|
||||
continue
|
||||
pts = []
|
||||
for ve in e.virtual_entities():
|
||||
if ve.dxftype() == "LINE":
|
||||
pts += [(ve.dxf.start.x, ve.dxf.start.y), (ve.dxf.end.x, ve.dxf.end.y)]
|
||||
if not pts:
|
||||
continue
|
||||
cxp = sum(p[0] for p in pts) / len(pts)
|
||||
cyp = sum(p[1] for p in pts) / len(pts)
|
||||
bx0, by0 = min(p[0] for p in pts), min(p[1] for p in pts)
|
||||
bx1, by1 = max(p[0] for p in pts), max(p[1] for p in pts)
|
||||
# apex = 본체 주축(긴 변)의 두 극단 중 '뾰족한' 쪽(정점 1개) — 평평한
|
||||
# 뒷변(정점 2개)과 구분. 방향 = 뒷변 중점 → apex (월드기하 권위).
|
||||
ax_is_x = (bx1 - bx0) >= (by1 - by0)
|
||||
key = (lambda p: p[0]) if ax_is_x else (lambda p: p[1])
|
||||
lo, hi = min(pts, key=key), max(pts, key=key)
|
||||
lo_n = sum(1 for p in pts if abs(key(p) - key(lo)) <= 0.5)
|
||||
hi_n = sum(1 for p in pts if abs(key(p) - key(hi)) <= 0.5)
|
||||
apex_pt, back_pts = (hi, [p for p in pts if abs(key(p) - key(lo)) <= 0.5]) \
|
||||
if hi_n <= lo_n else (lo, [p for p in pts if abs(key(p) - key(hi)) <= 0.5])
|
||||
ax, ay = apex_pt
|
||||
bmx = sum(p[0] for p in back_pts) / len(back_pts)
|
||||
bmy = sum(p[1] for p in back_pts) / len(back_pts)
|
||||
dvx, dvy = ax - bmx, ay - bmy
|
||||
dn = math.hypot(dvx, dvy) or 1.0
|
||||
dvx, dvy = dvx / dn, dvy / dn
|
||||
src_tag, line_no = None, None
|
||||
best_src = 9e9
|
||||
for v, tx, ty in all_text:
|
||||
if not (bx0 - 4 <= tx <= bx1 + 4 and by0 - 4 <= ty <= by1 + 4):
|
||||
continue
|
||||
vk = v.split()[0].replace(" ", "")
|
||||
if LINE_NO.match(vk) or re.search(r'\d+A?x\d+A?', vk):
|
||||
if line_no is None:
|
||||
line_no = v
|
||||
elif EQP_TAG.match(vk) or INSTR_TAG.match(vk):
|
||||
d = math.hypot(tx - cxp, ty - cyp)
|
||||
if d < best_src:
|
||||
best_src, src_tag = d, vk
|
||||
if src_tag:
|
||||
landmarks.setdefault(src_tag, (cxp, cyp)) # 발원 소스 노드 등록
|
||||
offpage_seeds.append((src_tag, (ax, ay), (dvx, dvy), line_no, e.dxf.name))
|
||||
|
||||
# ── Phase 2: 배관망 + commodity 투명 앵커 ──────────────────────────
|
||||
raw = []
|
||||
for e in msp:
|
||||
if e.dxftype() == "LINE":
|
||||
s, en = e.dxf.start, e.dxf.end
|
||||
if math.hypot(en.x - s.x, en.y - s.y) > 0.05:
|
||||
raw.append((round(s.x, 2), round(s.y, 2), round(en.x, 2), round(en.y, 2)))
|
||||
elif e.dxftype() == "LWPOLYLINE":
|
||||
p = [(round(a, 2), round(b, 2)) for a, b in e.get_points("xy")]
|
||||
for i in range(len(p) - 1):
|
||||
raw.append((p[i][0], p[i][1], p[i + 1][0], p[i + 1][1]))
|
||||
|
||||
# commodity 볼앵커: 소형 빈 원 + 동반 bowtie/레버 단선 2개 이상 (과포함 정밀화)
|
||||
circ = [(x.dxf.center.x, x.dxf.center.y, round(x.dxf.radius, 3))
|
||||
for x in msp if x.dxftype() == "CIRCLE"]
|
||||
seg_mid = collections.defaultdict(list)
|
||||
for x1, y1, x2, y2 in raw:
|
||||
L = math.hypot(x2 - x1, y2 - y1)
|
||||
if L <= 2.6:
|
||||
mx, my = (x1 + x2) / 2, (y1 + y2) / 2
|
||||
seg_mid[(round(mx), round(my))].append((mx, my))
|
||||
|
||||
def companion_count(cx, cy, R=0.8):
|
||||
n = 0
|
||||
for gx in (int(cx) - 1, int(cx), int(cx) + 1):
|
||||
for gy in (int(cy) - 1, int(cy), int(cy) + 1):
|
||||
for mx, my in seg_mid.get((gx, gy), ()):
|
||||
if math.hypot(mx - cx, my - cy) <= R:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
ball_anchors = [(x, y) for x, y, r in circ
|
||||
if 0.28 <= r <= 0.46 and companion_count(x, y) >= 2]
|
||||
|
||||
Q = 0.6
|
||||
node = collections.defaultdict(list)
|
||||
for idx, (x1, y1, x2, y2) in enumerate(raw):
|
||||
L = math.hypot(x2 - x1, y2 - y1)
|
||||
node[(round(x1 / Q), round(y1 / Q))].append((idx, (x1, y1), (x2, y2), L))
|
||||
node[(round(x2 / Q), round(y2 / Q))].append((idx, (x2, y2), (x1, y1), L))
|
||||
|
||||
def conn(p):
|
||||
out = []
|
||||
k = (round(p[0] / Q), round(p[1] / Q))
|
||||
for i in (-1, 0, 1):
|
||||
for j in (-1, 0, 1):
|
||||
for idx, a, b, L in node.get((k[0] + i, k[1] + j), ()):
|
||||
if math.hypot(a[0] - p[0], a[1] - p[1]) <= 0.8:
|
||||
out.append((idx, a, b, L))
|
||||
return out
|
||||
|
||||
def ball_ahead(p, dv, ml=14):
|
||||
best, bd = None, ml
|
||||
for x, y in ball_anchors:
|
||||
v = (x - p[0], y - p[1])
|
||||
d = math.hypot(*v)
|
||||
if 0.5 < d <= ml and (v[0] * dv[0] + v[1] * dv[1]) / d > 0.65 and d < bd:
|
||||
bd, best = d, (x, y)
|
||||
return best
|
||||
|
||||
lm_pts = list(landmarks.items())
|
||||
|
||||
def nearest_landmark(p, r=10):
|
||||
best, bd = None, r
|
||||
for t, (lx, ly) in lm_pts:
|
||||
d = math.hypot(lx - p[0], ly - p[1])
|
||||
if d < bd:
|
||||
bd, best = d, t
|
||||
return best
|
||||
|
||||
def resume_nozzle(lm_tag, pt, flow, visited):
|
||||
"""랜드마크 블록 통과 후 하류 노즐에서 흐름 재개점 산출.
|
||||
블록 실체 범위 밖, 안정적 흐름축(flow) 투영 최대인 미방문 배관 끝점.
|
||||
(직전 세그먼트 came 이 아닌 시드 흐름축을 써 병렬·수직 홉 노이즈 흡수.)"""
|
||||
if lm_tag not in lm_block:
|
||||
return None
|
||||
cx, cy, (bx0, by0, bx1, by1) = lm_block[lm_tag]
|
||||
half = max(bx1 - bx0, by1 - by0) / 2
|
||||
rng = max(half + 8.0, 10.0)
|
||||
best, bscore = None, 0.6
|
||||
for idx, (x1, y1, x2, y2) in enumerate(raw):
|
||||
if idx in visited:
|
||||
continue
|
||||
for ex, ey, ox, oy in ((x1, y1, x2, y2), (x2, y2, x1, y1)):
|
||||
# 블록 실체 내부 끝점 제외(노이즈), 중심 기준 하류측만
|
||||
if bx0 - 0.5 <= ex <= bx1 + 0.5 and by0 - 0.5 <= ey <= by1 + 0.5:
|
||||
continue
|
||||
vx, vy = ex - cx, ey - cy
|
||||
d = math.hypot(vx, vy)
|
||||
if not (half * 0.4 < d <= rng):
|
||||
continue
|
||||
proj = (vx * flow[0] + vy * flow[1]) / d
|
||||
if proj > bscore:
|
||||
bscore, best = proj, (idx, (ex, ey), (ox, oy))
|
||||
return best
|
||||
|
||||
# ── Phase 3: 흐름 추적 ────────────────────────────────────────────
|
||||
def trace(start, came, max_steps=400):
|
||||
flow_axis = came # 안정적 시드 흐름축(통과 재개 기준)
|
||||
pt = start
|
||||
visited = set()
|
||||
path = [] # [(kind, detail, (x,y))]
|
||||
hit = collections.OrderedDict() # 만난 랜드마크 순서
|
||||
exited = set() # 통과-후-재개 완료한 랜드마크
|
||||
recirc = None
|
||||
for _ in range(max_steps):
|
||||
lm = nearest_landmark(pt, 10)
|
||||
if lm:
|
||||
if lm in hit and lm not in exited and len(hit) > 2:
|
||||
# 이미 방문 랜드마크 복귀 = 재순환(킥백) 루프 → 분기 종료
|
||||
recirc = lm
|
||||
path.append(("recirculation", lm, pt))
|
||||
break
|
||||
hit.setdefault(lm, pt)
|
||||
if lm not in exited:
|
||||
exited.add(lm)
|
||||
nz = resume_nozzle(lm, pt, flow_axis, visited)
|
||||
if nz:
|
||||
idx, ep, op = nz
|
||||
visited.add(idx)
|
||||
d = (op[0] - ep[0], op[1] - ep[1])
|
||||
n = math.hypot(*d) or 1
|
||||
came = (d[0] / n, d[1] / n)
|
||||
pt = (op[0], op[1])
|
||||
path.append(("through", lm, pt))
|
||||
continue
|
||||
cand = [(idx, a, b, L) for idx, a, b, L in conn(pt) if idx not in visited]
|
||||
if cand:
|
||||
cand.sort(key=lambda c: -((c[2][0] - c[1][0]) * came[0] + (c[2][1] - c[1][1]) * came[1])
|
||||
/ (math.hypot(c[2][0] - c[1][0], c[2][1] - c[1][1]) or 1))
|
||||
idx, a, b, L = cand[0]
|
||||
visited.add(idx)
|
||||
d = (b[0] - a[0], b[1] - a[1])
|
||||
n = math.hypot(*d) or 1
|
||||
came = (d[0] / n, d[1] / n)
|
||||
pt = (b[0], b[1])
|
||||
path.append(("pipe", round(L, 1), pt))
|
||||
continue
|
||||
bv = ball_ahead(pt, came)
|
||||
if bv:
|
||||
best, bd = None, 18
|
||||
for i, (x1, y1, x2, y2) in enumerate(raw):
|
||||
if i in visited:
|
||||
continue
|
||||
for ex, ey in ((x1, y1), (x2, y2)):
|
||||
v = (ex - pt[0], ey - pt[1])
|
||||
dist = math.hypot(*v)
|
||||
if 1.0 < dist < bd and (v[0] * came[0] + v[1] * came[1]) / dist > 0.7:
|
||||
bd, best = dist, (ex, ey)
|
||||
path.append(("commodity_passthrough", None, pt))
|
||||
if best:
|
||||
pt = best
|
||||
continue
|
||||
# 일반 전방 gap-브리지: commodity/작도 끊김을 흐름방향 콘으로 점프.
|
||||
# 안정적 흐름축(flow_axis) 콘 사용 — through/병렬 홉 직후 came 역행 억제.
|
||||
# (좁은 콘 dot>0.85 + 수직오프셋 최소 → 밀집부 누수 억제)
|
||||
cone = flow_axis if (path and path[-1][0] in ("through", "gap_bridge")) else came
|
||||
gb, gdist = None, 12.0
|
||||
for i, (x1, y1, x2, y2) in enumerate(raw):
|
||||
if i in visited:
|
||||
continue
|
||||
for ex, ey, ox, oy in ((x1, y1, x2, y2), (x2, y2, x1, y1)):
|
||||
vx, vy = ex - pt[0], ey - pt[1]
|
||||
dist = math.hypot(vx, vy)
|
||||
if not (0.8 < dist <= gdist):
|
||||
continue
|
||||
al = (vx * cone[0] + vy * cone[1]) / dist
|
||||
if al <= 0.85:
|
||||
continue
|
||||
perp = abs(vx * cone[1] - vy * cone[0])
|
||||
score = dist + perp * 4
|
||||
if score < gdist:
|
||||
gdist, gb = score, (i, (ex, ey), (ox, oy))
|
||||
if gb:
|
||||
idx, ep, op = gb
|
||||
# 착지점(ep)으로 이동, 흐름방향(cone) 유지 — 이후 정상 conn 으로 전진.
|
||||
# (먼 끝 op 로 강제 점프 시 역행 가능 → 착지 후 재선택)
|
||||
jl = math.hypot(ep[0] - pt[0], ep[1] - pt[1])
|
||||
came = cone
|
||||
pt = (ep[0], ep[1])
|
||||
path.append(("gap_bridge", round(jl, 1), pt))
|
||||
continue
|
||||
break
|
||||
return list(hit.keys()), path, recirc
|
||||
|
||||
# 시드: OFFPAGE 발원 소스 + 검증된 ground-truth 진입점
|
||||
SEEDS = []
|
||||
for src_tag, apex, dv, line_no, cn in offpage_seeds:
|
||||
SEEDS.append((f"OFFPAGE {src_tag}→({cn})", apex, dv, src_tag, line_no))
|
||||
SEEDS.append(("P-10101 discharge", (1702.52, 5217.69), (1, 0), "P-10101", None))
|
||||
|
||||
results = []
|
||||
for label, sp, dv, src_tag, line_no in SEEDS:
|
||||
lms, path, recirc = trace(sp, dv)
|
||||
if src_tag and (not lms or lms[0] != src_tag):
|
||||
lms = [src_tag] + lms
|
||||
pc = sum(1 for k, _, _ in path if k == "pipe")
|
||||
cm = sum(1 for k, _, _ in path if k == "commodity_passthrough")
|
||||
th = sum(1 for k, _, _ in path if k == "through")
|
||||
gb_n = sum(1 for k, _, _ in path if k == "gap_bridge")
|
||||
results.append({"seed": label, "start": [round(sp[0], 2), round(sp[1], 2)],
|
||||
"source_tag": src_tag, "line_number": line_no,
|
||||
"landmarks_in_order": lms,
|
||||
"pipe_segments": pc, "commodity_passed": cm,
|
||||
"blocks_through": th, "gap_bridges": gb_n,
|
||||
"recirculation": recirc})
|
||||
if DEBUG:
|
||||
print(f"\n[DEBUG {label}] steps={len(path)}")
|
||||
for k, det, xy in path:
|
||||
if k in ("through", "recirculation", "gap_bridge"):
|
||||
print(f" {k:<22} {det} @({xy[0]:.1f},{xy[1]:.1f})")
|
||||
|
||||
# ── Phase 4: 라인번호 ↔ 배관 귀속 (직접배치 + 지시선 tip) ──────────
|
||||
# SOLID 화살촉 tip ↔ 배관 끝점 sub-0.5u 매칭, 또는 텍스트 복도 근접.
|
||||
solids = []
|
||||
for e in msp:
|
||||
if e.dxftype() == "SOLID":
|
||||
vs = [getattr(e.dxf, k, None) for k in ("vtx0", "vtx1", "vtx2", "vtx3")]
|
||||
vs = [(p.x, p.y) for p in vs if p is not None]
|
||||
if len(vs) >= 3:
|
||||
solids.append(vs)
|
||||
pipe_ends = []
|
||||
for idx, (x1, y1, x2, y2) in enumerate(raw):
|
||||
pipe_ends.append((idx, x1, y1))
|
||||
pipe_ends.append((idx, x2, y2))
|
||||
|
||||
def pipe_near(px, py, tol):
|
||||
best, bd = None, tol
|
||||
for idx, ex, ey in pipe_ends:
|
||||
d = math.hypot(ex - px, ey - py)
|
||||
if d < bd:
|
||||
bd, best = d, idx
|
||||
return best
|
||||
|
||||
line_no_attr = [] # {line_number, pipe_idx, mode}
|
||||
for v, tx, ty in all_text:
|
||||
vk = v.split()[0].replace(" ", "")
|
||||
if not (LINE_NO.match(vk) or re.search(r'\d+A?x\d+A?', vk)):
|
||||
continue
|
||||
# 케이스 B: 텍스트 근방 SOLID 화살촉 → tip 이 닿는 배관
|
||||
attached = None
|
||||
for vs in solids:
|
||||
sc = (sum(p[0] for p in vs) / len(vs), sum(p[1] for p in vs) / len(vs))
|
||||
if math.hypot(sc[0] - tx, sc[1] - ty) > 6.0:
|
||||
continue
|
||||
for tipx, tipy in vs:
|
||||
pi = pipe_near(tipx, tipy, 0.5)
|
||||
if pi is not None:
|
||||
attached = (pi, "leader")
|
||||
break
|
||||
if attached:
|
||||
break
|
||||
# 케이스 A: 직접배치 — 텍스트 박스 근접 배관
|
||||
if attached is None:
|
||||
pi = pipe_near(tx, ty, 3.0)
|
||||
if pi is not None:
|
||||
attached = (pi, "direct")
|
||||
if attached:
|
||||
line_no_attr.append({"line_number": v, "pipe_idx": attached[0],
|
||||
"mode": attached[1]})
|
||||
|
||||
# ── Phase 5: 랜드마크 From/To 엣지 환원 ───────────────────────────
|
||||
edges = []
|
||||
for r in results:
|
||||
lm = r["landmarks_in_order"]
|
||||
for i in range(len(lm) - 1):
|
||||
edges.append({"from": lm[i], "to": lm[i + 1], "type": "process",
|
||||
"via_seed": r["seed"],
|
||||
"line_number": r["line_number"] if i == 0 else None})
|
||||
if r["recirculation"] and lm:
|
||||
edges.append({"from": lm[-1], "to": r["recirculation"],
|
||||
"type": "recirculation", "via_seed": r["seed"],
|
||||
"line_number": None})
|
||||
|
||||
out = {
|
||||
"drawing": os.path.basename(DXF),
|
||||
"stats": {"landmarks": len(landmarks),
|
||||
"offpage_seeds": len(offpage_seeds),
|
||||
"ball_anchors": len(ball_anchors),
|
||||
"raw_segments": len(raw),
|
||||
"traces": len(results),
|
||||
"edges": len(edges),
|
||||
"line_number_attributions": len(line_no_attr)},
|
||||
"landmarks": [{"tag": t, "x": round(xy[0], 1), "y": round(xy[1], 1)}
|
||||
for t, xy in sorted(landmarks.items())],
|
||||
"traces": results,
|
||||
"edges": edges,
|
||||
}
|
||||
prefix = os.path.basename(DXF).split("_")[0].split(".")[0]
|
||||
op = os.path.join("mcp-server", "storage", f"{prefix}_connections.json")
|
||||
os.makedirs(os.path.dirname(op), exist_ok=True)
|
||||
json.dump(out, open(op, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
||||
|
||||
print(f"landmarks={len(landmarks)} offpage_seeds={len(offpage_seeds)} "
|
||||
f"ball_anchors={len(ball_anchors)} raw_seg={len(raw)} "
|
||||
f"edges={len(edges)} lineno_attr={len(line_no_attr)}")
|
||||
for r in results:
|
||||
print(f"\n[{r['seed']}] pipe={r['pipe_segments']} "
|
||||
f"through={r['blocks_through']} commodity={r['commodity_passed']}"
|
||||
f"{' recirc=' + r['recirculation'] if r['recirculation'] else ''}")
|
||||
print(" 랜드마크 순서:", " → ".join(r["landmarks_in_order"]) or "(없음)")
|
||||
print(f"\n→ {op}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -13,7 +13,7 @@ class MappingResult(BaseModel):
|
||||
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, model_name: str = "Qwen3.6-27B-FP8"):
|
||||
def __init__(self, graph: nx.Graph, system_tags: List[str], api_client: Optional[AsyncOpenAI] = None, model_name: str = "Qwen3.6-35B-A3B-FP8"):
|
||||
self.graph = graph
|
||||
self.system_tags = system_tags
|
||||
self.client = api_client
|
||||
|
||||
@@ -114,6 +114,12 @@ class PidTopologyBuilder:
|
||||
grid.add(nid, self.G.nodes[nid]['bbox'])
|
||||
return grid
|
||||
|
||||
# 시그널 레이어 이름 집합 (ELECTRIC SIGNAL, INSTRUMENT signal선 등)
|
||||
_SIGNAL_LAYERS = frozenset({'ELECTRIC SIGNAL', 'SIGNAL', 'ELEC', 'CABLE', 'WIRE'})
|
||||
|
||||
def _relation_for_layer(self, layer: str) -> str:
|
||||
return 'signal' if (layer or '').upper() in {s.upper() for s in self._SIGNAL_LAYERS} else 'pipe'
|
||||
|
||||
def build_graph(self):
|
||||
# 1. 모든 객체를 노드로 추가
|
||||
for item in self.data:
|
||||
@@ -150,7 +156,7 @@ class PidTopologyBuilder:
|
||||
if best_match:
|
||||
self.G.add_edge(tag, best_match, relation='associated_with')
|
||||
|
||||
# 4. 배관 기반 물리적 연결 (Pipe) — SpatialGrid 사용
|
||||
# 4. 배관/시그널 기반 연결 — SpatialGrid 사용
|
||||
lines = [n for n, d in self.G.nodes(data=True) if d['type'] in ['LINE', 'LWPOLYLINE']]
|
||||
|
||||
for line_id in lines:
|
||||
@@ -161,6 +167,8 @@ class PidTopologyBuilder:
|
||||
coords = original_item['coordinates']
|
||||
line_geom = LineString(coords)
|
||||
line_bbox = line_geom.bounds
|
||||
layer = original_item.get('layer', '')
|
||||
relation = self._relation_for_layer(layer)
|
||||
|
||||
# SpatialGrid로 후보 집합 조회 (O(1) 그리드 셀 기반)
|
||||
nearby_equipment_ids = eq_grid.query(
|
||||
@@ -183,27 +191,22 @@ class PidTopologyBuilder:
|
||||
connected_nodes = list(set(connected_nodes))
|
||||
|
||||
if len(connected_nodes) >= 2:
|
||||
# 개선: 단순 순서가 아닌, 기하학적 좌표 기반의 흐름 방향 추론 (왼쪽 -> 오른쪽, 위 -> 아래 우선)
|
||||
# 실제 공정 도면의 일반적인 흐름 방향을 반영
|
||||
node0_bbox = self.G.nodes[connected_nodes[0]]['bbox']
|
||||
node1_bbox = self.G.nodes[connected_nodes[1]]['bbox']
|
||||
|
||||
center0 = ((node0_bbox.bounds[0] + node0_bbox.bounds[2])/2, (node0_bbox.bounds[1] + node0_bbox.bounds[3])/2)
|
||||
center1 = ((node1_bbox.bounds[0] + node1_bbox.bounds[2])/2, (node1_bbox.bounds[1] + node1_bbox.bounds[3])/2)
|
||||
|
||||
# X축 차이가 Y축 차이보다 크면 X축 기준, 아니면 Y축 기준으로 방향 결정
|
||||
if abs(center1[0] - center0[0]) > abs(center1[1] - center0[1]):
|
||||
# X축 기준: 왼쪽 -> 오른쪽
|
||||
if center0[0] < center1[0]:
|
||||
self.G.add_edge(connected_nodes[0], connected_nodes[1], relation='pipe', flow_direction='forward')
|
||||
self.G.add_edge(connected_nodes[0], connected_nodes[1], relation=relation, flow_direction='forward')
|
||||
else:
|
||||
self.G.add_edge(connected_nodes[1], connected_nodes[0], relation='pipe', flow_direction='forward')
|
||||
self.G.add_edge(connected_nodes[1], connected_nodes[0], relation=relation, flow_direction='forward')
|
||||
else:
|
||||
# Y축 기준: 위 -> 아래 (도면 좌표계에 따라 다를 수 있으나 일반적인 관례 적용)
|
||||
if center0[1] > center1[1]:
|
||||
self.G.add_edge(connected_nodes[0], connected_nodes[1], relation='pipe', flow_direction='forward')
|
||||
self.G.add_edge(connected_nodes[0], connected_nodes[1], relation=relation, flow_direction='forward')
|
||||
else:
|
||||
self.G.add_edge(connected_nodes[1], connected_nodes[0], relation='pipe', flow_direction='forward')
|
||||
self.G.add_edge(connected_nodes[1], connected_nodes[0], relation=relation, flow_direction='forward')
|
||||
elif len(connected_nodes) == 1:
|
||||
# 단일 연결의 경우, 일단 엣지를 생성하되 방향은 미정(undirected-like)으로 처리하거나
|
||||
# 추후 전파 로직에서 결정하도록 함
|
||||
|
||||
@@ -28,6 +28,8 @@ dependencies = [
|
||||
"openpyxl>=3.1.0",
|
||||
"python-docx>=1.1.0",
|
||||
"pdfplumber>=0.11.0",
|
||||
# 계기 유추
|
||||
"pyyaml>=6.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -38,4 +40,4 @@ requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
only-include = ["server.py", "index_opc_docs.py"]
|
||||
only-include = ["server.py", "index_opc_docs.py", "instrument_inference/", "parsers/", "pipeline/", "worker/", "config.py"]
|
||||
|
||||
@@ -104,6 +104,24 @@ def _llm():
|
||||
return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
|
||||
|
||||
|
||||
def _strip_think(text: str) -> str:
|
||||
"""Qwen/DeepSeek 계열 모델의 <think>...</think> reasoning 블록 제거"""
|
||||
if not text:
|
||||
return text
|
||||
for tag in ("think", "skip", "reason"):
|
||||
pat = re.compile(rf"</?{tag}>.*?</?{tag}>", re.DOTALL)
|
||||
text = pat.sub("", text).strip()
|
||||
# 태그가 열렸으나 닫히지 않은 경우 (truncated) — 태그부터 끝까지 제거
|
||||
for tag in ("think", "skip", "reason"):
|
||||
if f"<{tag}>" in text and f"</{tag}>" not in text:
|
||||
idx = text.index(f"<{tag}>")
|
||||
text = text[:idx].strip()
|
||||
if f"</{tag}>" in text and f"<{tag}>" not in text:
|
||||
idx = text.index(f"</{tag}>") + len(f"</{tag}>")
|
||||
text = text[idx:].strip()
|
||||
return text
|
||||
|
||||
|
||||
# ── PaddleOCR 싱글톤 (PDF fallback용) ──────────────────────────────────────────
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
@@ -603,8 +621,8 @@ async def _get_db_connection():
|
||||
|
||||
def _validate_sql(sql: str) -> tuple[bool, str]:
|
||||
"""SQL 안전 검증 — SELECT/WITH만 허용, 위험 키워드 차단."""
|
||||
if len(sql) > 2000:
|
||||
return False, "쿼리 길이 2000자를 초과했습니다."
|
||||
if len(sql) > 4000:
|
||||
return False, "쿼리 길이 4000자를 초과했습니다."
|
||||
dangerous = ['EXEC', 'DROP', 'DELETE', 'UPDATE', 'INSERT', 'ALTER', 'CREATE', 'GRANT', 'REVOKE', 'TRUNCATE', 'COPY']
|
||||
sql_upper = sql.upper()
|
||||
for kw in dangerous:
|
||||
@@ -635,85 +653,22 @@ def _apply_sql_guards(sql: str, max_rows: int = SQL_MAX_ROWS) -> str:
|
||||
return f"SELECT * FROM ({s}) _capped LIMIT {max_rows}"
|
||||
|
||||
|
||||
# DB 스키마 — LLM SQL 생성 시 컨텍스트로 사용
|
||||
# Compact DB schema for LLM SQL generation
|
||||
_DB_SCHEMA = """
|
||||
PostgreSQL 시계열 데이터베이스 스키마
|
||||
Tables:
|
||||
history_table(tagname TEXT, value TEXT, recorded_at TIMESTAMPTZ)
|
||||
realtime_table(tagname TEXT, livevalue TEXT, timestamp TIMESTAMPTZ)
|
||||
tag_metadata(base_tag TEXT, attribute TEXT, value TEXT)
|
||||
event_history_table(tagname TEXT, prev_value TEXT, curr_value TEXT, event_type TEXT, event_time TIMESTAMPTZ, duration_seconds INT)
|
||||
|
||||
테이블: history_table (시계열 이력)
|
||||
tagname TEXT - 태그명 (모두 소문자, 예: 'ficq-6113.pv') — 대소문자 구분
|
||||
node_id TEXT - OPC UA 노드 ID
|
||||
value TEXT - 측정값, 수치 연산 시 ::double precision 캐스트 필요
|
||||
recorded_at TIMESTAMPTZ - 기록 시각(UTC), 스냅샷 주기 약 60초
|
||||
Views:
|
||||
v_tag_summary(base_tag TEXT, pv TEXT, sp TEXT, op TEXT, description TEXT, area TEXT)
|
||||
|
||||
테이블: realtime_table (실시간 최신값)
|
||||
tagname TEXT - 태그명 (모두 소문자)
|
||||
node_id TEXT - OPC UA 노드 ID
|
||||
livevalue TEXT - 현재값
|
||||
timestamp TIMESTAMPTZ - 최종 갱신 시각
|
||||
|
||||
테이블: tag_metadata (태그 메타데이터 - 변경 드묾)
|
||||
base_tag TEXT - 기본 태그명 (예: 'ficq-6101', 'xv-6124')
|
||||
attribute TEXT - 속성명 ('desc', 'area')
|
||||
value TEXT - 메타데이터 값
|
||||
node_id TEXT - OPC UA 노드 ID
|
||||
loaded_at TIMESTAMPTZ - 마지막 로드 시각
|
||||
|
||||
테이블: event_history_table (디지털 포인트 상태 변경 이벤트)
|
||||
id BIGSERIAL - PK
|
||||
tagname TEXT - 태그명 (소문자)
|
||||
node_id TEXT
|
||||
prev_value TEXT - 직전 값
|
||||
curr_value TEXT - 현재 값
|
||||
event_type TEXT - 'ALARM' / 'TRIP' / 'NORMAL' / 'RUN' / 'CHANGE'
|
||||
event_time TIMESTAMPTZ - 이벤트 발생 시각(UTC)
|
||||
area TEXT - tag_metadata.area 복사본
|
||||
section TEXT - 태그명 패턴에서 추출한 차수(예: '6-1차')
|
||||
duration_seconds INT - 직전 상태에서 머문 시간
|
||||
metadata JSONB - 부가 정보 (interlock 등)
|
||||
created_at TIMESTAMPTZ
|
||||
|
||||
뷰: v_tag_summary (실시간값 + 메타데이터 통합 뷰)
|
||||
base_tag TEXT - 기본 태그명
|
||||
pv TEXT - 현재 프로세스 값
|
||||
sp TEXT - 설정값
|
||||
op TEXT - 출력값
|
||||
instate0 TEXT - 상태 비트 0 (true/false)
|
||||
instate1 TEXT - 상태 비트 1 (true/false)
|
||||
instate2 TEXT - 상태 비트 2 (true/false)
|
||||
description TEXT - 장비 설명 (tag_metadata.desc)
|
||||
area TEXT - 소속 플랜트 (tag_metadata.area)
|
||||
|
||||
새로운 태그 타입:
|
||||
- 아날로그: ficq-6101.pv/sp/op (Double)
|
||||
- 디지털 XV: xv-6124.pv/op (Int32), xv-6124.instate0~7 (Boolean)
|
||||
- Pump: p-6102.pv/op (Int32), p-6102.instate0~7 (Boolean)
|
||||
- 메타데이터: desc (String), area (Enum)
|
||||
|
||||
BCD 상태 조회 팁:
|
||||
- instate0~7은 Boolean (true/false)
|
||||
- pv 값이 EnumValueType 형식인 경우 `{코드 | DisplayName | }`에서 DisplayName으로 상태 확인 가능
|
||||
- v_tag_summary 뷰를 사용하면 실시간값+메타데이터 한 번에 조회 가능
|
||||
|
||||
N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용):
|
||||
1분 버킷: date_trunc('minute', recorded_at) AS bucket
|
||||
2분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AS bucket
|
||||
5분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/300)*300) AS bucket
|
||||
10분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/600)*600) AS bucket
|
||||
N분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket
|
||||
|
||||
예시 (2분 간격, 여러 태그):
|
||||
SELECT to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AS bucket,
|
||||
tagname, AVG(value::double precision) AS avg_val
|
||||
FROM history_table
|
||||
WHERE tagname IN ('tag1', 'tag2')
|
||||
AND recorded_at >= NOW() - INTERVAL '3 hours'
|
||||
GROUP BY bucket, tagname ORDER BY bucket, tagname
|
||||
|
||||
규칙:
|
||||
- SELECT만 허용 (INSERT/UPDATE/DELETE/DROP 등 불가)
|
||||
- tagname은 모두 소문자로 정확히 입력
|
||||
- value 컬럼은 TEXT이므로 집계 시 ::double precision 캐스트 필수
|
||||
- time_bucket 함수 사용 금지 — 위의 to_timestamp/FLOOR/EPOCH 공식 사용
|
||||
Rules:
|
||||
- SELECT only. tagname lowercase exact match.
|
||||
- value is TEXT; cast ::double precision when aggregating.
|
||||
- time_bucket() banned. For N-min buckets: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60))
|
||||
- KST input = UTC-9 in DB.
|
||||
"""
|
||||
|
||||
# ── RAG 도구 ─────────────────────────────────────────────────────────────────
|
||||
@@ -774,7 +729,8 @@ def ask_iiot_llm(question: str, context: str = "") -> str:
|
||||
max_tokens=2048,
|
||||
temperature=0.1,
|
||||
)
|
||||
return resp.choices[0].message.content or "(응답 없음)"
|
||||
content = resp.choices[0].message.content or "(응답 없음)"
|
||||
return _strip_think(content)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@@ -1184,22 +1140,13 @@ async def query_with_nl(question: str) -> str:
|
||||
|
||||
system = (
|
||||
"You are a PostgreSQL SQL expert.\n"
|
||||
"Convert the user's question into a SELECT SQL using the schema below.\n"
|
||||
"IMPORTANT rules:\n"
|
||||
"- Use ONLY PostgreSQL syntax. No DATE_FORMAT, no INTERVAL N DAY.\n"
|
||||
"- Time column is 'recorded_at' (TIMESTAMPTZ). Do NOT use 'timestamp'.\n"
|
||||
"- NEVER use time_bucket(). For N-minute buckets use to_timestamp/FLOOR/EPOCH formula.\n"
|
||||
"- INTERVAL rule:\n"
|
||||
" * If the question specifies an interval (e.g. '2분 간격', '5-minute interval'):\n"
|
||||
" use: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket\n"
|
||||
" with GROUP BY bucket, tagname and AVG(value::double precision) AS avg_val\n"
|
||||
" * If NO interval is specified: SELECT recorded_at, tagname, value — NO GROUP BY.\n"
|
||||
"- Current year is 2026. '4월 27일' means 2026-04-27.\n"
|
||||
"- All times in DB are UTC. Korean input is KST (UTC+9). Convert: KST 12:00 = UTC 03:00.\n"
|
||||
"- value column is TEXT; cast with ::double precision only when aggregating.\n"
|
||||
"- All tagnames are lowercase (e.g. 'ficq-6113.pv'). Match exactly.\n"
|
||||
"- PostgreSQL LIKE: dot has no special meaning, no escaping needed.\n"
|
||||
"- Return ONLY the SQL statement. No explanation, no markdown.\n\n"
|
||||
"Convert the user's question into a SELECT SQL.\n"
|
||||
"Return ONLY the SQL. No explanation, no markdown, NO <think> tags.\n"
|
||||
"Use PostgreSQL syntax. tagname lowercase exact match.\n"
|
||||
"value is TEXT; cast ::double precision when aggregating.\n"
|
||||
"KST input = UTC-9. Example: KST 12:00 = UTC 03:00.\n"
|
||||
"For N-min buckets: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)).\n"
|
||||
"No GROUP BY if no interval specified.\n\n"
|
||||
f"{_DB_SCHEMA}"
|
||||
)
|
||||
|
||||
@@ -1216,7 +1163,7 @@ async def query_with_nl(question: str) -> str:
|
||||
)
|
||||
|
||||
resp = await asyncio.to_thread(_call_llm)
|
||||
sql = (resp.choices[0].message.content or "").strip()
|
||||
sql = _strip_think(resp.choices[0].message.content or "").strip()
|
||||
# 마크다운 코드 블록 제거
|
||||
if sql.startswith("```"):
|
||||
lines = sql.splitlines()
|
||||
@@ -1319,6 +1266,139 @@ async def find_tags(query: str, area: str | None = None, top_k: int = 20) -> str
|
||||
conn.close()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def trace_connections(start_tag: str, direction: str = "downstream", max_depth: int = 20) -> str:
|
||||
"""pid_equipment 테이블의 from_tag/to_tag를 활용해 장비 연결 경로를 추적.
|
||||
|
||||
사용 시점: "스팀 경로 설명해줘", "원료 흐름 따라가줘", "T-203에서 어디로 가?" 같은 질문.
|
||||
개별 태그 검색 + SQL 조합(10+ 라운드) → trace_connections 1회 호출로 대체.
|
||||
|
||||
Args:
|
||||
start_tag: 시작 태그명 (예: 'FT-6115', 'T-203')
|
||||
direction: 'downstream'(하류) 또는 'upstream'(상류). 기본 downstream.
|
||||
max_depth: 최대 추적 깊이 (기본 20)
|
||||
|
||||
Returns:
|
||||
JSON: { success, start_tag, direction, path: [{step, from_tag, to_tag, role, tag_no}] }
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
start_tag = start_tag.strip().upper()
|
||||
direction = direction.strip().lower()
|
||||
if direction not in ("downstream", "upstream"):
|
||||
return json.dumps({"success": False, "error": "direction은 'downstream' 또는 'upstream'"}, ensure_ascii=False)
|
||||
|
||||
conn = await _get_db_connection()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"SET statement_timeout = {SQL_STATEMENT_TIMEOUT_MS}")
|
||||
|
||||
def _split_tags(tag_str):
|
||||
if not tag_str:
|
||||
return []
|
||||
return [t.strip() for t in tag_str.split(',') if t.strip()]
|
||||
|
||||
def _build_or_condition(tags):
|
||||
if not tags:
|
||||
return "", []
|
||||
conditions = []
|
||||
params = []
|
||||
for t in tags:
|
||||
conditions.append("from_tag LIKE %s")
|
||||
params.append(f'%{t}%')
|
||||
conditions.append("tag_no = %s")
|
||||
params.append(t)
|
||||
return f"({' OR '.join(conditions)})", params
|
||||
|
||||
def _trace_downstream(current_tags, visited, depth):
|
||||
if depth > max_depth or not current_tags:
|
||||
return
|
||||
or_clause, params = _build_or_condition(current_tags)
|
||||
if not or_clause:
|
||||
return
|
||||
cur.execute(f"""
|
||||
SELECT tag_no, from_tag, to_tag, role
|
||||
FROM pid_equipment
|
||||
WHERE {or_clause}
|
||||
ORDER BY tag_no
|
||||
""", params)
|
||||
rows = cur.fetchall()
|
||||
for row in rows:
|
||||
tag_no = row[0]
|
||||
if tag_no in visited:
|
||||
continue
|
||||
visited.add(tag_no)
|
||||
path.append({
|
||||
"step": len(path) + 1,
|
||||
"tag_no": tag_no,
|
||||
"from_tag": row[1],
|
||||
"to_tag": row[2],
|
||||
"role": row[3],
|
||||
})
|
||||
next_tags = _split_tags(row[2])
|
||||
_trace_downstream(next_tags, visited, depth + 1)
|
||||
|
||||
path = []
|
||||
visited = set()
|
||||
visited.add(start_tag)
|
||||
start_tags = _split_tags(start_tag)
|
||||
_trace_downstream(start_tags, visited, 0)
|
||||
|
||||
# upstream
|
||||
if direction == "upstream":
|
||||
def _trace_upstream(current_tags, visited, depth):
|
||||
if depth > max_depth or not current_tags:
|
||||
return
|
||||
conditions = []
|
||||
params = []
|
||||
for t in current_tags:
|
||||
conditions.append("to_tag LIKE %s")
|
||||
params.append(f'%{t}%')
|
||||
conditions.append("tag_no = %s")
|
||||
params.append(t)
|
||||
if not conditions:
|
||||
return
|
||||
or_clause = f"({' OR '.join(conditions)})"
|
||||
cur.execute(f"""
|
||||
SELECT tag_no, from_tag, to_tag, role
|
||||
FROM pid_equipment
|
||||
WHERE {or_clause}
|
||||
ORDER BY tag_no
|
||||
""", params)
|
||||
rows = cur.fetchall()
|
||||
for row in rows:
|
||||
tag_no = row[0]
|
||||
if tag_no in visited:
|
||||
continue
|
||||
visited.add(tag_no)
|
||||
path.append({
|
||||
"step": len(path) + 1,
|
||||
"tag_no": tag_no,
|
||||
"from_tag": row[1],
|
||||
"to_tag": row[2],
|
||||
"role": row[3],
|
||||
})
|
||||
prev_tags = _split_tags(row[1])
|
||||
_trace_upstream(prev_tags, visited, depth + 1)
|
||||
|
||||
path = []
|
||||
visited = set()
|
||||
visited.add(start_tag)
|
||||
start_tags = _split_tags(start_tag)
|
||||
_trace_upstream(start_tags, visited, 0)
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"start_tag": start_tag,
|
||||
"direction": direction,
|
||||
"path": path,
|
||||
}, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": f"연결 추적 실패: {e}"}, ensure_ascii=False)
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def query_events(
|
||||
tag_name: str | None = None,
|
||||
@@ -1540,7 +1620,7 @@ async def summarize_events(
|
||||
|
||||
try:
|
||||
resp = await asyncio.to_thread(_call)
|
||||
summary = resp.choices[0].message.content or "(요약 없음)"
|
||||
summary = _strip_think(resp.choices[0].message.content) or "(요약 없음)"
|
||||
except Exception as e:
|
||||
summary = f"(LLM 요약 실패: {e})"
|
||||
|
||||
@@ -1626,7 +1706,7 @@ async def generate_status_report(area: str | None = None, hours: int = 24) -> st
|
||||
|
||||
try:
|
||||
resp = await asyncio.to_thread(_call)
|
||||
report = resp.choices[0].message.content or "(보고서 생성 실패)"
|
||||
report = _strip_think(resp.choices[0].message.content) or "(보고서 생성 실패)"
|
||||
except Exception as e:
|
||||
report = f"(LLM 보고서 실패: {e})"
|
||||
|
||||
@@ -1845,7 +1925,7 @@ async def parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
|
||||
|
||||
resp = await asyncio.to_thread(_call_llm)
|
||||
|
||||
raw = (resp.choices[0].message.content or "").strip()
|
||||
raw = _strip_think(resp.choices[0].message.content or "").strip()
|
||||
|
||||
# 마크다운 코드 블록 제거
|
||||
if raw.startswith("```"):
|
||||
|
||||
717
mcp-server/sim_line_connection.py
Normal file
717
mcp-server/sim_line_connection.py
Normal file
@@ -0,0 +1,717 @@
|
||||
"""
|
||||
P&ID 연결 분석 v2 — 좌표 근접성 기반
|
||||
======================================
|
||||
|
||||
원리: C-10111 분석에서 발견한 패턴
|
||||
- 설비(컬럼/탱크) = 긴 수직 LINE(≥200u) 또는 큰 원(r≥4.5)
|
||||
- 계기(instrument balloon)는 설비와 Y축 겹침 + 수평 200u 이내
|
||||
- LINE 연결이 없어도 좌표 근접성으로 연결 판단
|
||||
|
||||
순서:
|
||||
1. DXF 로드, LINE/CIRCLE/TEXT 추출
|
||||
2. instrument balloon 식별 (CIRCLE r≥1.5 + func+num)
|
||||
3. equipment anchor 식별 (긴 수직 LINE + 큰 원)
|
||||
4. 좌표 근접성 기반 연결 (Y겹침 + 수평거리 ≤ 200)
|
||||
5. LINE endpoint 연결과 비교
|
||||
"""
|
||||
|
||||
import ezdxf, re, time, math
|
||||
from shapely.geometry import LineString, Point, MultiLineString, box
|
||||
from shapely.strtree import STRtree
|
||||
from shapely.ops import linemerge
|
||||
from collections import defaultdict
|
||||
|
||||
DXF_PATH = "src/Web/uploads/pid/No-10_Plant_PID.dxf"
|
||||
TAG_RE = re.compile(r'^[A-Z]{1,6}-\d{2,6}(-[A-Z0-9]+)*$', re.I)
|
||||
FUNC_RE = re.compile(r'^[FPLTASHQWVXZBDCRK][ICTREVYSAQGZ]{1,3}$')
|
||||
NUM_RE = re.compile(r'^\d{3,6}[A-Z]?$')
|
||||
PIPE_RE = re.compile(r'^\w+-\d{3,6}-\d+[A-Z]?-[A-Z][A-Z0-9]', re.I)
|
||||
LOOP_RE = re.compile(r'\d{3,6}[A-Z]?')
|
||||
|
||||
# ── 1-2. DXF 로드 + LINE/CIRCLE/TEXT ──────────────────────────────────
|
||||
t0 = time.time()
|
||||
doc = ezdxf.readfile(DXF_PATH)
|
||||
msp = doc.modelspace()
|
||||
print(f"1. DXF 로드: {time.time()-t0:.2f}s", flush=True)
|
||||
|
||||
# LINE 추출
|
||||
lines_raw = []
|
||||
for e in msp:
|
||||
t = e.dxftype()
|
||||
if t == 'LINE':
|
||||
s = (e.dxf.start.x, e.dxf.start.y)
|
||||
e2 = (e.dxf.end.x, e.dxf.end.y)
|
||||
if s != e2:
|
||||
lines_raw.append(LineString([s, e2]))
|
||||
elif t == 'LWPOLYLINE':
|
||||
pts = [(p[0], p[1]) for p in e.vertices()]
|
||||
if len(pts) >= 2:
|
||||
lines_raw.append(LineString(pts))
|
||||
|
||||
# linemerge
|
||||
merged = linemerge(MultiLineString(lines_raw))
|
||||
merged_list = list(merged.geoms) if merged.geom_type == 'MultiLineString' else [merged]
|
||||
|
||||
# CIRCLE
|
||||
circles = [(e.dxf.center.x, e.dxf.center.y, e.dxf.radius) for e in msp if e.dxftype() == 'CIRCLE']
|
||||
|
||||
# TEXT
|
||||
text_entries = []
|
||||
for e in msp:
|
||||
if e.dxftype() == 'TEXT':
|
||||
v = e.dxf.text.strip().replace('%%U', '').replace('%%C', 'Φ')
|
||||
if v:
|
||||
text_entries.append((e.dxf.insert.x, e.dxf.insert.y, v))
|
||||
|
||||
print(f"2. 추출: LINE={len(lines_raw)}, CIRCLE={len(circles)}, TEXT={len(text_entries)}", flush=True)
|
||||
|
||||
# ── 3. Instrument balloon 식별 ────────────────────────────────────────
|
||||
t0 = time.time()
|
||||
|
||||
# TEXT → CIRCLE 포함 (r≥1.5)
|
||||
text_in_circle = defaultdict(list)
|
||||
for x, y, v in text_entries:
|
||||
for cx, cy, r in circles:
|
||||
if r >= 1.5 and (x-cx)**2 + (y-cy)**2 <= r*r:
|
||||
text_in_circle[v].append((cx, cy, r))
|
||||
|
||||
# circle center → func+num
|
||||
circ_data = {}
|
||||
for v, occ in text_in_circle.items():
|
||||
for cx, cy, r in occ:
|
||||
k = (round(cx, 1), round(cy, 1))
|
||||
if k not in circ_data:
|
||||
circ_data[k] = {'r': r, 'x': cx, 'y': cy}
|
||||
if FUNC_RE.match(v):
|
||||
circ_data[k]['func'] = v
|
||||
elif NUM_RE.match(v):
|
||||
circ_data[k]['num'] = v
|
||||
elif TAG_RE.match(v):
|
||||
circ_data[k]['tag'] = v
|
||||
|
||||
instruments = []
|
||||
for k, d in circ_data.items():
|
||||
if 'func' in d:
|
||||
tag = f"{d['func']}-{d.get('num', '?')}"
|
||||
# prefer explicit tag if available
|
||||
if 'tag' in d:
|
||||
tag = d['tag']
|
||||
instruments.append({'x': d['x'], 'y': d['y'], 'tag': tag, 'r': d['r'],
|
||||
'loop': re.search(r'\d{3,6}', tag).group() if re.search(r'\d{3,6}', tag) else None})
|
||||
|
||||
# loop number별 계기 그룹
|
||||
loop_inst = defaultdict(list)
|
||||
for inst in instruments:
|
||||
if inst['loop']:
|
||||
loop_inst[inst['loop']].append(inst)
|
||||
|
||||
print(f"3-A. Instrument: {len(instruments)}개")
|
||||
print(f" Loop 번호 그룹: {len(loop_inst)}개")
|
||||
|
||||
# ── 3-B. 방향표지판 검출 ──────────────────────────────────────────
|
||||
t0 = time.time()
|
||||
|
||||
# 방향별 분류: h_seg는 raw LINE만 (O(n²) 회피), v/d_seg는 merged+raw
|
||||
h_lines = []
|
||||
v_lines = []
|
||||
d_lines = []
|
||||
for l in lines_raw:
|
||||
coords = list(l.coords)
|
||||
if len(coords) < 2:
|
||||
continue
|
||||
x1, y1 = coords[0]
|
||||
x2, y2 = coords[-1]
|
||||
dx = abs(x2 - x1)
|
||||
dy = abs(y2 - y1)
|
||||
if dy <= 1 and dx >= 3:
|
||||
h_lines.append({'xl': min(x1, x2), 'xr': max(x1, x2), 'y': y1})
|
||||
elif dx <= 1 and dy >= 2.5:
|
||||
v_lines.append({'x': x1, 'y1': min(y1, y2), 'y2': max(y1, y2)})
|
||||
elif dx >= 1 and dy >= 1:
|
||||
angle = math.degrees(math.atan2(dy, dx))
|
||||
if 20 <= angle <= 70 or 110 <= angle <= 160:
|
||||
d_lines.append({'x1': x1, 'y1': y1, 'x2': x2, 'y2': y2})
|
||||
# merged LINE 보강 (v/d만) - merged LINE은 길어서 relaxed threshold
|
||||
for l in merged_list:
|
||||
coords = list(l.coords)
|
||||
if len(coords) < 2:
|
||||
continue
|
||||
x1, y1 = coords[0]
|
||||
x2, y2 = coords[-1]
|
||||
dx = abs(x2 - x1)
|
||||
dy = abs(y2 - y1)
|
||||
if dx <= 1 and dy >= 2.5:
|
||||
v_lines.append({'x': x1, 'y1': min(y1, y2), 'y2': max(y1, y2)})
|
||||
elif dx >= 1 and dy >= 1:
|
||||
angle = math.degrees(math.atan2(dy, dx))
|
||||
if 20 <= angle <= 70 or 110 <= angle <= 160:
|
||||
d_lines.append({'x1': x1, 'y1': y1, 'x2': x2, 'y2': y2})
|
||||
|
||||
# 수평선쌍: 같은 xl/xr + y간격 2~8u (xr 기준 버킷으로 O(n²) 회피)
|
||||
h_by_xr = defaultdict(list)
|
||||
for h in h_lines:
|
||||
h_by_xr[round(h['xr'] / 5) * 5].append(h)
|
||||
h_pairs = []
|
||||
seen_pair = set() # (xl_round, xr_round, y_mid_round) 중복 제거
|
||||
for bucket in h_by_xr.values():
|
||||
for i, h1 in enumerate(bucket):
|
||||
for h2 in bucket[i+1:]:
|
||||
if abs(h1['y'] - h2['y']) < 2:
|
||||
continue
|
||||
if abs(h1['xl'] - h2['xl']) <= 3 and abs(h1['xr'] - h2['xr']) <= 3:
|
||||
ylo = min(h1['y'], h2['y'])
|
||||
yhi = max(h1['y'], h2['y'])
|
||||
gap = yhi - ylo
|
||||
if 2 <= gap <= 8:
|
||||
xl = (h1['xl']+h2['xl'])/2
|
||||
xr = (h1['xr']+h2['xr'])/2
|
||||
k = (round(xl), round(xr), round((ylo+yhi)/2))
|
||||
if k not in seen_pair:
|
||||
seen_pair.add(k)
|
||||
h_pairs.append({'xl': xl, 'xr': xr, 'ylo': ylo, 'yhi': yhi, 'y_mid': (ylo+yhi)/2})
|
||||
|
||||
def _has_left_vert(hp, vlist, tol=8):
|
||||
xl, ym = hp['xl'], hp['y_mid']
|
||||
return any(abs(v['x'] - xl) <= tol and v['y1'] <= ym <= v['y2'] for v in vlist)
|
||||
|
||||
def _count_right_diag(hp, dlist, tol=8):
|
||||
xr, ym = hp['xr'], hp['y_mid']
|
||||
return sum(1 for d in dlist
|
||||
if abs(d['x1'] - xr) <= tol and abs(d['y1'] - ym) <= tol
|
||||
or abs(d['x2'] - xr) <= tol and abs(d['y2'] - ym) <= tol)
|
||||
|
||||
def _nearest_tag_to(hp, texts, limit=25):
|
||||
mx, my = (hp['xl']+hp['xr'])/2, hp['y_mid']
|
||||
best, best_d = None, 999
|
||||
for tx, ty, tv in texts:
|
||||
d = math.hypot(tx - mx, ty - my)
|
||||
if d < best_d and d <= limit:
|
||||
best_d, best = d, tv
|
||||
return best, best_d
|
||||
|
||||
print(f" marker filter start... v={len(v_lines)} d={len(d_lines)} texts={len(text_entries)}")
|
||||
markers = []
|
||||
for i, hp in enumerate(h_pairs):
|
||||
if not _has_left_vert(hp, v_lines):
|
||||
continue
|
||||
cnt_diag = _count_right_diag(hp, d_lines)
|
||||
if cnt_diag < 2:
|
||||
continue
|
||||
tag, tag_dist = _nearest_tag_to(hp, text_entries)
|
||||
if tag is None:
|
||||
continue
|
||||
mx = round((hp['xl']+hp['xr'])/2, 1)
|
||||
my = round(hp['y_mid'], 1)
|
||||
markers.append({'tag': tag, 'mx': mx, 'my': my,
|
||||
'xl': hp['xl'], 'xr': hp['xr'],
|
||||
'ylo': hp['ylo'], 'yhi': hp['yhi'], 'dr': cnt_diag})
|
||||
|
||||
et = time.time()
|
||||
# 위치 중복 제거 (동일태그 + 동일좌표 → 1개)
|
||||
seen = set()
|
||||
deduped = []
|
||||
for m in markers:
|
||||
k = (m['tag'], round(m['mx'], 0), round(m['my'], 0))
|
||||
if k not in seen:
|
||||
seen.add(k)
|
||||
deduped.append(m)
|
||||
markers = deduped
|
||||
|
||||
print(f"3-B. 방향표지판(마커): {len(markers)}개 (h_pairs={len(h_pairs)}, elapsed={et-t0:.2f}s)")
|
||||
print(f" ROI(y≥5100): {sum(1 for m in markers if m['my'] >= 5100)}개")
|
||||
tag_groups = defaultdict(list)
|
||||
for m in markers:
|
||||
tag_groups[m['tag']].append(m)
|
||||
multi = {t: v for t, v in tag_groups.items() if len(v) >= 2}
|
||||
print(f" 태그 있는 마커: {sum(1 for m in markers if m['tag'])}개")
|
||||
print(f" 동일태그 그룹(≥2): {len(multi)}개")
|
||||
for t, v in sorted(multi.items(), key=lambda x: -len(x[1]))[:10]:
|
||||
poss = ', '.join(f"({m['mx']:.0f},{m['my']:.0f})" for m in v)
|
||||
print(f" {t}: {len(v)}개 [{poss}]")
|
||||
|
||||
# ── 4. Equipment anchor 식별 ──────────────────────────────────────────
|
||||
t0_4 = time.time()
|
||||
|
||||
# (A) 긴 수직 LINE (≥200u, almost vertical)
|
||||
vert_lines = []
|
||||
for l in merged_list:
|
||||
c = list(l.coords)
|
||||
xs = [p[0] for p in c]
|
||||
ys = [p[1] for p in c]
|
||||
dx = max(xs) - min(xs)
|
||||
dy = max(ys) - min(ys)
|
||||
if dy >= 200 and dy > dx * 3: # vertical-ish
|
||||
vert_lines.append({
|
||||
'x': round(sum(xs)/len(xs), 1),
|
||||
'y_min': min(ys), 'y_max': max(ys),
|
||||
'length': l.length, 'bounds': l.bounds
|
||||
})
|
||||
|
||||
# dedup by x
|
||||
vert_grouped = defaultdict(list)
|
||||
for v in vert_lines:
|
||||
vert_grouped[round(v['x'])].append(v)
|
||||
|
||||
vert_anchors = []
|
||||
for x, vl in vert_grouped.items():
|
||||
y_min = min(v['y_min'] for v in vl)
|
||||
y_max = max(v['y_max'] for v in vl)
|
||||
vert_anchors.append({'x': x, 'y_min': y_min, 'y_max': y_max,
|
||||
'count': len(vl), 'total_len': sum(v['length'] for v in vl)})
|
||||
|
||||
# (B) 큰 원 (r≥4.5, empty or with text)
|
||||
large_circs = [(cx, cy, r) for cx, cy, r in circles if r >= 4.5]
|
||||
# Find nearest TAG text
|
||||
eqp_by_circle = []
|
||||
for cx, cy, r in large_circs:
|
||||
best_tag = None
|
||||
best_d = 999
|
||||
for tx, ty, v in text_entries:
|
||||
if TAG_RE.match(v):
|
||||
d = math.hypot(tx-cx, ty-cy)
|
||||
if d < best_d and d > r and d < 200:
|
||||
best_d = d
|
||||
best_tag = v
|
||||
eqp_by_circle.append({'x': cx, 'y': cy, 'tag': best_tag or f'CIRCLE@{cx:.0f},{cy:.0f}',
|
||||
'r': r, 'kind': 'large_circle'})
|
||||
|
||||
print(f"\n4. Equipment anchor:")
|
||||
print(f" 수직 LINE(≥200u): {len(vert_anchors)}개")
|
||||
for va in sorted(vert_anchors, key=lambda v: v['x']):
|
||||
print(f" x={va['x']:6.1f}, y=[{va['y_min']:.0f}, {va['y_max']:.0f}], 높이={va['y_max']-va['y_min']:.0f}")
|
||||
print(f" 큰 원(r≥4.5): {len(eqp_by_circle)}개")
|
||||
for eq in eqp_by_circle:
|
||||
print(f" ({eq['x']:6.1f}, {eq['y']:6.1f}) r={eq['r']:.1f} tag={eq['tag']}")
|
||||
|
||||
# (C) 작은 원 (r<4.5, r≥1.5, exclude instrument balloons)
|
||||
# instrument balloon circles already identified as those containing TEXT
|
||||
balloon_circles = set()
|
||||
for v, occ in text_in_circle.items():
|
||||
for cx, cy, r in occ:
|
||||
balloon_circles.add((round(cx, 1), round(cy, 1)))
|
||||
small_circs = [(cx, cy, r) for cx, cy, r in circles
|
||||
if 1.5 <= r < 4.5 and (round(cx,1), round(cy,1)) not in balloon_circles]
|
||||
small_eqp = []
|
||||
for cx, cy, r in small_circs:
|
||||
best_tag = None
|
||||
best_d = 999
|
||||
for tx, ty, v in text_entries:
|
||||
if TAG_RE.match(v):
|
||||
d = math.hypot(tx-cx, ty-cy)
|
||||
if d < best_d and d > r and d < 100:
|
||||
best_d = d
|
||||
best_tag = v
|
||||
small_eqp.append({'x': cx, 'y': cy, 'tag': best_tag or f'SMALL@{cx:.0f},{cy:.0f}',
|
||||
'r': r, 'kind': 'small_circle'})
|
||||
eqp_by_small = small_eqp
|
||||
|
||||
print(f" 작은 원(1.5≤r<4.5, excl. balloon): {len(eqp_by_small)}개")
|
||||
for eq in eqp_by_small[:10]:
|
||||
print(f" ({eq['x']:6.1f}, {eq['y']:6.1f}) r={eq['r']:.1f} tag={eq['tag']}")
|
||||
|
||||
# Merge small into _what_at equipment list
|
||||
all_eqp = eqp_by_circle + eqp_by_small
|
||||
|
||||
# ── 3-C. 방향표지판 LINE endpoint 추적 (2-hop BFS, 직각굽힘 허용) ──
|
||||
t0 = time.time()
|
||||
|
||||
# Precompute: endpoint spatial index (grid bucketing)
|
||||
# 30u limit → grid cell size 15 so we only check 3×3 neighbors
|
||||
GRID = 15
|
||||
ep_grid = defaultdict(list)
|
||||
line_ep = {}
|
||||
for li, l in enumerate(merged_list):
|
||||
coords = list(l.coords)
|
||||
if not coords:
|
||||
continue
|
||||
s, e = coords[0], coords[-1]
|
||||
for ep, ei in [(s, 0), (e, -1)]:
|
||||
gx, gy = int(ep[0] // GRID), int(ep[1] // GRID)
|
||||
ep_grid[(gx, gy)].append((li, ei, ep))
|
||||
line_ep[li] = {'s': s, 'e': e}
|
||||
|
||||
def _grid_neighbors(pt):
|
||||
gx, gy = int(pt[0] // GRID), int(pt[1] // GRID)
|
||||
return [(gx+dx, gy+dy) for dx in (-1,0,1) for dy in (-1,0,1)]
|
||||
|
||||
def _find_ep_idx(pt, limit=30):
|
||||
best = None
|
||||
for gk in _grid_neighbors(pt):
|
||||
for li, ei, ep in ep_grid.get(gk, []):
|
||||
d = math.hypot(pt[0]-ep[0], pt[1]-ep[1])
|
||||
if d <= limit and (best is None or d < best[0]):
|
||||
best = (d, li, ei, ep)
|
||||
return best
|
||||
|
||||
def _follow_line(li, ei):
|
||||
ep = line_ep[li]
|
||||
return ep['e'] if ei == 0 else ep['s']
|
||||
|
||||
def _connected_at(pt, tol=5):
|
||||
result = []
|
||||
for gk in _grid_neighbors(pt):
|
||||
for li, ei, ep in ep_grid.get(gk, []):
|
||||
d = math.hypot(pt[0]-ep[0], pt[1]-ep[1])
|
||||
if d <= tol:
|
||||
result.append((li, ei, ep))
|
||||
return result
|
||||
|
||||
def _is_origin(kind, tag, pt, origin_tag, origin_pos, span=40):
|
||||
"""추적 결과가 출발 마커 자기자신인지 (cycle) 판정."""
|
||||
if origin_tag is None or kind != 'marker' or tag != origin_tag:
|
||||
return False
|
||||
return math.hypot(pt[0] - origin_pos[0], pt[1] - origin_pos[1]) <= span
|
||||
|
||||
|
||||
def _trace_2hop(pt, markers, vert_anchors, all_eqp, limit=30,
|
||||
origin_tag=None, origin_pos=None):
|
||||
"""마커 endpoint → 2-hop 추적. 출발 마커로 되돌아오는 사이클 차단 + 중복 제거."""
|
||||
hop0 = _find_ep_idx(pt, limit)
|
||||
if not hop0:
|
||||
return []
|
||||
_, li1, ei1, ep1 = hop0
|
||||
op1 = _follow_line(li1, ei1)
|
||||
best = {} # (kind, tag) → (total_len, path) 최단 1개만 유지
|
||||
|
||||
def _offer(kind, tag, total_len, path, at_pt):
|
||||
if kind == 'empty':
|
||||
return
|
||||
if _is_origin(kind, tag, at_pt, origin_tag, origin_pos):
|
||||
return # self-cycle: 출발 마커로 회귀
|
||||
k = (kind, tag)
|
||||
if k not in best or total_len < best[k][0]:
|
||||
best[k] = (total_len, path)
|
||||
|
||||
# hop1: 같은 endpoint를 공유하는 세그먼트(중복 li2 제거)
|
||||
seen_li2 = set()
|
||||
for li2, ei2, ep2 in _connected_at(op1):
|
||||
if li2 == li1 or li2 in seen_li2:
|
||||
continue
|
||||
seen_li2.add(li2)
|
||||
op2 = _follow_line(li2, ei2)
|
||||
kind, tag = _what_at_pt(op2, markers, vert_anchors, all_eqp, limit)
|
||||
_offer(kind, tag, merged_list[li1].length + merged_list[li2].length,
|
||||
f"seg#{li1}→seg#{li2}", op2)
|
||||
|
||||
# hop0 자체의 반대쪽 끝
|
||||
kind1, tag1 = _what_at_pt(op1, markers, vert_anchors, all_eqp, limit)
|
||||
_offer(kind1, tag1, merged_list[li1].length, f"seg#{li1}", op1)
|
||||
|
||||
return [(k[0], k[1], v[0], v[1]) for k, v in best.items()]
|
||||
|
||||
# Precompute marker grid for fast what_at lookup
|
||||
marker_grid = defaultdict(list)
|
||||
for mi, m in enumerate(markers):
|
||||
gx, gy = int(m['mx'] // GRID), int(m['my'] // GRID)
|
||||
marker_grid[(gx, gy)].append(mi)
|
||||
|
||||
def _what_at_pt(pt, markers, vert_anchors, all_eqp, limit=30):
|
||||
px, py = pt
|
||||
for gk in _grid_neighbors(pt):
|
||||
for mi in marker_grid.get(gk, []):
|
||||
m = markers[mi]
|
||||
if math.hypot(px-m['mx'], py-m['my']) <= limit:
|
||||
return 'marker', m['tag']
|
||||
for va in vert_anchors:
|
||||
if va['y_min'] <= py <= va['y_max'] and abs(px-va['x']) <= limit:
|
||||
return 'equipment', f"VLINE@{va['x']:.0f}"
|
||||
for eq in all_eqp:
|
||||
if math.hypot(px-eq['x'], py-eq['y']) <= limit:
|
||||
return 'equipment', eq['tag']
|
||||
return 'empty', ''
|
||||
|
||||
marker_traces = []
|
||||
trace_log = []
|
||||
for m in markers:
|
||||
mx, my, xl, xr, tag = m['mx'], m['my'], m['xl'], m['xr'], m['tag']
|
||||
for side, pt in [('left', (xl, my)), ('right', (xr, my))]:
|
||||
hops = _trace_2hop(pt, markers, vert_anchors, all_eqp,
|
||||
origin_tag=tag, origin_pos=(mx, my))
|
||||
for kind, to_tag, total_len, path in hops:
|
||||
marker_traces.append({
|
||||
'from': tag, 'from_pos': f"({mx:.0f},{my:.0f})",
|
||||
'side': side, 'to_kind': kind, 'to_tag': to_tag,
|
||||
'total_len': total_len, 'path': path
|
||||
})
|
||||
if tag == 'P-10101':
|
||||
trace_log.append(f" {side} ({pt[0]:.1f},{pt[1]:.1f}) → {path}: {kind}={to_tag} ({total_len:.1f}u)")
|
||||
|
||||
et = time.time()
|
||||
print(f"3-C. 마커 LINE endpoint 추적 (2-hop BFS, elapsed={et-t0:.2f}s):")
|
||||
print(f" 추적 엣지: {len(marker_traces)}개")
|
||||
lk = defaultdict(int)
|
||||
for e in marker_traces:
|
||||
lk[e['to_kind']] += 1
|
||||
for k, c in sorted(lk.items()):
|
||||
print(f" → {k}: {c}개")
|
||||
if trace_log:
|
||||
print(f" P-10101 상세 추적:")
|
||||
for line in trace_log:
|
||||
print(line)
|
||||
|
||||
# ── 3-D. 태그 매칭 연결 ─────────────────────────────────────────
|
||||
t0 = time.time()
|
||||
|
||||
tag_match_edges = []
|
||||
for tag, ml in multi.items():
|
||||
for i in range(len(ml)):
|
||||
for j in range(i+1, len(ml)):
|
||||
tag_match_edges.append({
|
||||
'tag': tag,
|
||||
'from_pos': f"({ml[i]['mx']:.0f},{ml[i]['my']:.0f})",
|
||||
'to_pos': f"({ml[j]['mx']:.0f},{ml[j]['my']:.0f})",
|
||||
'span': abs(ml[i]['mx'] - ml[j]['mx'])
|
||||
})
|
||||
|
||||
et = time.time()
|
||||
print(f"3-D. 태그 매칭 연결: {len(tag_match_edges)}쌍 (elapsed={et-t0:.2f}s)")
|
||||
for t, ml in sorted(multi.items(), key=lambda x: -len(x[1]))[:10]:
|
||||
xs = [m['mx'] for m in ml]
|
||||
span = max(xs) - min(xs)
|
||||
print(f" {t}: {len(ml)}개 위치, 스팬={span:.0f}u")
|
||||
|
||||
# ── 5. 좌표 근접성 기반 연결 ──────────────────────────────────────────
|
||||
t0 = time.time()
|
||||
|
||||
# 각 instrument → 가장 가까운 equipment anchor 찾기
|
||||
# 기준: Y범위 겹침 + 수평거리 ≤ 200
|
||||
HORIZONTAL_LIMIT = 200
|
||||
|
||||
inst_eqp_connections = [] # (inst, eqp, dist, method)
|
||||
unconnected_inst = []
|
||||
|
||||
for inst in instruments:
|
||||
ix, iy = inst['x'], inst['y']
|
||||
best_eqp = None
|
||||
best_d = 999
|
||||
|
||||
# 수직 LINE anchor
|
||||
for va in vert_anchors:
|
||||
if va['y_min'] <= iy <= va['y_max']:
|
||||
d = abs(ix - va['x'])
|
||||
if d < best_d and d <= HORIZONTAL_LIMIT:
|
||||
best_d = d
|
||||
best_eqp = (f"VLINE@{va['x']:.0f}", va, 'vline')
|
||||
|
||||
# 큰 원 anchor
|
||||
for eq in eqp_by_circle:
|
||||
d = math.hypot(ix - eq['x'], iy - eq['y'])
|
||||
if d < best_d and d <= HORIZONTAL_LIMIT:
|
||||
best_d = d
|
||||
best_eqp = (eq['tag'], eq, 'circle')
|
||||
|
||||
if best_eqp:
|
||||
inst_eqp_connections.append((inst, best_eqp[0], best_d, best_eqp[2]))
|
||||
else:
|
||||
unconnected_inst.append(inst)
|
||||
|
||||
print(f"\n5. 좌표 근접 연결 (수평≤{HORIZONTAL_LIMIT}):")
|
||||
print(f" 계기-설비 연결: {len(inst_eqp_connections)}개")
|
||||
print(f" 미연결 계기: {len(unconnected_inst)}개")
|
||||
|
||||
# 연결 분류
|
||||
by_method = defaultdict(int)
|
||||
for *_, method in inst_eqp_connections:
|
||||
by_method[method] += 1
|
||||
for m, c in by_method.items():
|
||||
print(f" {m}: {c}개")
|
||||
|
||||
# loop별 연결 분포
|
||||
loop_connected = defaultdict(list)
|
||||
loop_unconnected = defaultdict(list)
|
||||
for inst, eqp_tag, d, method in inst_eqp_connections:
|
||||
loop_connected[inst['loop']].append((inst, eqp_tag, d))
|
||||
for inst in unconnected_inst:
|
||||
loop_unconnected[inst['loop']].append(inst)
|
||||
|
||||
print(f"\n Loop별 연결/미연결:")
|
||||
for loop in sorted([k for k in loop_connected if k is not None], key=int)[:20]:
|
||||
conn = len(loop_connected[loop])
|
||||
unconn = len(loop_unconnected.get(loop, []))
|
||||
total = conn + unconn
|
||||
if total >= 3:
|
||||
eqps = set(e for _, e, _ in loop_connected[loop])
|
||||
print(f" Loop {loop}: {conn}/{total} 연결 → 설비: {', '.join(eqps)}")
|
||||
|
||||
# ── 6. LINE endpoint 연결과 비교 ──────────────────────────────────────
|
||||
t0 = time.time()
|
||||
|
||||
# long lines (≥30)
|
||||
long_lines = [l for l in merged_list if l.length >= 30]
|
||||
|
||||
# build anchor index
|
||||
inst_tree = STRtree([Point(inst['x'], inst['y']) for inst in instruments])
|
||||
inst_list = instruments
|
||||
|
||||
BUFFER = 50
|
||||
|
||||
def nearest_inst(pt):
|
||||
idxs = inst_tree.query(box(pt.x-BUFFER, pt.y-BUFFER, pt.x+BUFFER, pt.y+BUFFER), predicate='intersects')
|
||||
if len(idxs) == 0:
|
||||
return None
|
||||
best_d = 999
|
||||
best = None
|
||||
for idx in idxs:
|
||||
p = inst_tree.geometries[idx]
|
||||
d = p.distance(pt)
|
||||
if d < best_d:
|
||||
best_d = d
|
||||
best = (inst_list[idx], best_d)
|
||||
if best and best[1] <= BUFFER:
|
||||
return best
|
||||
return None
|
||||
|
||||
line_connections = []
|
||||
inst_seen = set()
|
||||
for l in long_lines:
|
||||
ep1 = Point(l.coords[0])
|
||||
ep2 = Point(l.coords[-1])
|
||||
n1 = nearest_inst(ep1)
|
||||
n2 = nearest_inst(ep2)
|
||||
if n1 and n2:
|
||||
i1, d1 = n1
|
||||
i2, d2 = n2
|
||||
if i1['tag'] != i2['tag']:
|
||||
line_connections.append((i1, i2, l.length, d1, d2))
|
||||
|
||||
print(f"\n6. LINE endpoint 연결 (≥30u, buffer={BUFFER}):")
|
||||
print(f" 계기-계기 연결: {len(line_connections)}개")
|
||||
|
||||
# 같은 loop 내 연결 vs 다른 loop 연결
|
||||
same_loop = 0
|
||||
diff_loop = 0
|
||||
for i1, i2, ll, d1, d2 in line_connections:
|
||||
if i1['loop'] and i2['loop'] and i1['loop'] == i2['loop']:
|
||||
same_loop += 1
|
||||
else:
|
||||
diff_loop += 1
|
||||
print(f" 같은 loop: {same_loop}개")
|
||||
print(f" 다른 loop: {diff_loop}개")
|
||||
|
||||
# ── 7. Loop 기반 통합 연결 ────────────────────────────────────────────
|
||||
# loop → 가장 가까운 equipment anchor
|
||||
print(f"\n7. Loop 기반 연결 분석:")
|
||||
for loop in sorted(loop_inst, key=int):
|
||||
insts = loop_inst[loop]
|
||||
# Y범위
|
||||
ys = [i['y'] for i in insts]
|
||||
y_min, y_max = min(ys), max(ys)
|
||||
xs = [i['x'] for i in insts]
|
||||
x_min, x_max = min(xs), max(xs)
|
||||
|
||||
# 가장 가까운 equipment 찾기
|
||||
cx, cy = sum(xs)/len(xs), sum(ys)/len(ys)
|
||||
best_eqp = None
|
||||
best_d = 999
|
||||
|
||||
for va in vert_anchors:
|
||||
if va['y_min'] <= cy <= va['y_max']:
|
||||
d = abs(cx - va['x'])
|
||||
if d < best_d and d <= HORIZONTAL_LIMIT:
|
||||
best_d = d
|
||||
best_eqp = f"VLINE@{va['x']:.0f}"
|
||||
|
||||
for eq in eqp_by_circle:
|
||||
d = math.hypot(cx - eq['x'], cy - eq['y'])
|
||||
if d < best_d and d <= HORIZONTAL_LIMIT:
|
||||
best_d = d
|
||||
best_eqp = eq['tag']
|
||||
|
||||
if best_eqp:
|
||||
connected = sum(1 for inst in insts if any(inst['tag'] == ii['tag'] for ii, *_ in loop_connected.get(loop, [])))
|
||||
print(f" Loop {loop}: {len(insts)}개 계기 → {best_eqp} (거리={best_d:.0f}, Y=[{y_min:.0f},{y_max:.0f}], X=[{x_min:.0f},{x_max:.0f}])")
|
||||
|
||||
# ── 8. 방향성 부여 + 유향 그래프 + JSON 출력 ──────────────────────────────
|
||||
# ▶ 마커(V자=우측)는 국소 흐름이 좌→우.
|
||||
# side='left' 추적대상 = 상류(upstream) → edge: target → marker
|
||||
# side='right' 추적대상 = 하류(downstream)→ edge: marker → target
|
||||
# 태그매칭: 같은 태그가 여러 x위치 = off-page connector 연속. x오름차순 체인(좌→우).
|
||||
import json
|
||||
try:
|
||||
import networkx as _nx
|
||||
_HAVE_NX = True
|
||||
except ImportError:
|
||||
_HAVE_NX = False
|
||||
|
||||
directed_edges = [] # {from, to, type, basis, weight}
|
||||
_seen_de = set()
|
||||
def _add_de(frm, to, typ, basis, w):
|
||||
if not frm or not to or frm == to:
|
||||
return
|
||||
k = (frm, to, typ)
|
||||
if k in _seen_de:
|
||||
return
|
||||
_seen_de.add(k)
|
||||
directed_edges.append({'from': frm, 'to': to, 'type': typ,
|
||||
'basis': basis, 'weight': round(w, 1)})
|
||||
|
||||
# (1) trace 기반 유향 엣지 (사이클 차단된 marker_traces 사용)
|
||||
for e in marker_traces:
|
||||
if e['to_kind'] == 'empty' or not e['to_tag'] or e['to_tag'] == e['from']:
|
||||
continue
|
||||
if e['side'] == 'left':
|
||||
_add_de(e['to_tag'], e['from'], 'trace', 'marker-left(upstream)', e['total_len'])
|
||||
else:
|
||||
_add_de(e['from'], e['to_tag'], 'trace', 'marker-right(downstream)', e['total_len'])
|
||||
|
||||
# (2) 태그매칭 유향 엣지 (x오름차순 체인)
|
||||
for tag, ml in multi.items():
|
||||
ordered = sorted(ml, key=lambda m: m['mx'])
|
||||
for a, b in zip(ordered, ordered[1:]):
|
||||
_add_de(f"{tag}@{a['mx']:.0f},{a['my']:.0f}",
|
||||
f"{tag}@{b['mx']:.0f},{b['my']:.0f}",
|
||||
'tagmatch', 'offpage-connector(+x)',
|
||||
abs(b['mx'] - a['mx']))
|
||||
|
||||
# 유향 그래프 → 약연결 성분
|
||||
components_out = []
|
||||
if _HAVE_NX:
|
||||
DG = _nx.DiGraph()
|
||||
for de in directed_edges:
|
||||
DG.add_edge(de['from'], de['to'])
|
||||
for comp in sorted(_nx.weakly_connected_components(DG), key=len, reverse=True):
|
||||
if len(comp) >= 2:
|
||||
components_out.append(sorted(comp))
|
||||
|
||||
out = {
|
||||
'drawing': DXF_PATH,
|
||||
'stats': {
|
||||
'instruments': len(instruments),
|
||||
'markers': len(markers),
|
||||
'marker_tag_groups': len(multi),
|
||||
'trace_edges_raw': len(marker_traces),
|
||||
'directed_edges': len(directed_edges),
|
||||
'tagmatch_directed': sum(1 for d in directed_edges if d['type'] == 'tagmatch'),
|
||||
'trace_directed': sum(1 for d in directed_edges if d['type'] == 'trace'),
|
||||
'components': len(components_out),
|
||||
},
|
||||
'markers': [{'tag': m['tag'], 'x': m['mx'], 'y': m['my'],
|
||||
'dir': 'right', 'diag': m['dr']} for m in markers],
|
||||
'equipment': [{'tag': eq['tag'], 'kind': eq['kind'],
|
||||
'x': round(eq['x'], 1), 'y': round(eq['y'], 1)}
|
||||
for eq in all_eqp],
|
||||
'edges': directed_edges,
|
||||
'components': components_out,
|
||||
}
|
||||
OUT_JSON = "mcp-server/storage/No-10_connections.json"
|
||||
import os as _os
|
||||
_os.makedirs(_os.path.dirname(OUT_JSON), exist_ok=True)
|
||||
with open(OUT_JSON, 'w', encoding='utf-8') as f:
|
||||
json.dump(out, f, ensure_ascii=False, indent=1)
|
||||
print(f"\n8. 방향성 + JSON 출력:")
|
||||
print(f" 유향 엣지: {len(directed_edges)}개 "
|
||||
f"(태그매칭 {out['stats']['tagmatch_directed']}, trace {out['stats']['trace_directed']})")
|
||||
print(f" 유향 약연결 성분(≥2): {len(components_out)}개")
|
||||
print(f" → {OUT_JSON} ({_os.path.getsize(OUT_JSON)/1024:.0f} KB)")
|
||||
|
||||
# ── 요약 ──────────────────────────────────────────────────────────────────
|
||||
print(f"\n{'='*60}")
|
||||
print(f"요약")
|
||||
print(f"{'='*60}")
|
||||
print(f"Instruments (balloon): {len(instruments)}개")
|
||||
print(f"방향표지판 (marker): {len(markers)}개 (태그있음={sum(1 for m in markers if m['tag'])}개)")
|
||||
print(f"동일태그 그룹(≥2): {len(multi)}개, 태그매칭 엣지: {len(tag_match_edges)}쌍")
|
||||
print(f"Equipment anchors: 수직LINE {len(vert_anchors)}개 + 큰원 {len(eqp_by_circle)}개 + 작은원 {len(eqp_by_small)}개")
|
||||
print(f"좌표 근접 연결 (수평≤{HORIZONTAL_LIMIT}): {len(inst_eqp_connections)}개 / {len(instruments)}개 ({len(inst_eqp_connections)/max(len(instruments),1)*100:.1f}%)")
|
||||
print(f"LINE endpoint 연결 (balloon-balloon): {len(line_connections)}쌍 (같은 loop {same_loop}개)")
|
||||
print(f"Marker trace 엣지: {len(marker_traces)}개")
|
||||
2
mcp-server/uv.lock
generated
2
mcp-server/uv.lock
generated
@@ -1234,6 +1234,7 @@ dependencies = [
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
{ name = "pymupdf" },
|
||||
{ name = "python-docx" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "qdrant-client" },
|
||||
{ name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
@@ -1257,6 +1258,7 @@ requires-dist = [
|
||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.1.0" },
|
||||
{ name = "pymupdf", specifier = ">=1.24.0" },
|
||||
{ name = "python-docx", specifier = ">=1.1.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.0" },
|
||||
{ name = "qdrant-client", specifier = ">=1.9.0" },
|
||||
{ name = "scikit-learn", specifier = ">=1.3.0" },
|
||||
{ name = "sentence-transformers", specifier = ">=3.0.0" },
|
||||
|
||||
Reference in New Issue
Block a user