opencode 로 바꾸고 작업전 커밋

This commit is contained in:
windpacer
2026-05-08 17:22:10 +09:00
parent 15c17522c8
commit e923aab43b
202 changed files with 1336027 additions and 115 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,144 @@
import networkx as nx
import asyncio
import json
from typing import List, Optional, Dict, Any, Tuple
from pydantic import BaseModel, Field
from rapidfuzz import process, fuzz
from openai import AsyncOpenAI
# --- 응답 구조화를 위한 Pydantic 모델 ---
class MappingResult(BaseModel):
resolved_tag: str = Field(..., description="The final mapped system tag")
reason: str = Field(..., description="Reason for this mapping based on context")
confidence: float = Field(..., ge=0.0, le=1.0, description="Confidence score from 0 to 1")
class IntelligentMapper:
def __init__(self, graph: nx.Graph, system_tags: List[str], api_client: Optional[AsyncOpenAI] = None):
self.graph = graph # Phase 2에서 생성된 NetworkX 그래프
self.system_tags = system_tags # Experion 시스템의 전체 태그 리스트
self.client = api_client
def get_node_context(self, node_id: str) -> str:
"""노드의 주변 위상 정보를 텍스트로 변환 (확장된 컨텍스트 제공)"""
if not self.graph.has_node(node_id):
return "Node not found in graph"
node_attr = self.graph.nodes[node_id]
node_type = node_attr.get('type', 'Unknown')
node_val = node_attr.get('value', 'Unknown')
# 1. 직접 연결된 이웃 노드 정보
neighbors = list(self.graph.neighbors(node_id))
neighbor_info = []
for n in neighbors:
attr = self.graph.nodes[n]
val = attr.get('value', n)
typ = attr.get('type', 'Unknown')
# 엣지 속성(관계) 추가
rel = self.graph.get_edge_data(node_id, n).get('relation', 'connected')
neighbor_info.append(f"[{rel}] {val} (Type: {typ})")
# 2. 2-hop 연결 정보 (더 넓은 맥락 파악)
extended_context = []
for n in neighbors:
second_neighbors = list(self.graph.neighbors(n))
for sn in second_neighbors:
if sn == node_id: continue
s_attr = self.graph.nodes[sn]
extended_context.append(f"Indirectly connected via {self.graph.nodes[n].get('value', n)} to {s_attr.get('value', sn)} (Type: {s_attr.get('type', 'Unknown')})")
context_str = (
f"Target Node: {node_val} (Type: {node_type})\n"
f"Direct Neighbors: {', '.join(neighbor_info) if neighbor_info else 'None'}\n"
f"Extended Context: {', '.join(extended_context[:10]) if extended_context else 'None'}"
)
return context_str
async def _resolve_generic(self, node_id: str, category_prompt: str) -> MappingResult:
"""공통 매핑 로직 (비동기 + 구조화 응답)"""
if not self.client:
return MappingResult(resolved_tag="UNKNOWN", reason="API Client not provided", confidence=0.0)
# Phase 2에서 'value'에 clean_value가 저장됨
node_data = self.graph.nodes.get(node_id, {})
tag_text = node_data.get('value', '')
# 1차 후보 추출 (RapidFuzz)
candidates = process.extract(tag_text, self.system_tags, scorer=fuzz.WRatio, limit=5)
context = self.get_node_context(node_id)
prompt = f"""
{category_prompt}
P&ID 도면의 태그 '{tag_text}'를 실제 시스템 태그와 매핑해야 합니다.
위상 맥락: {context}
후보 리스트: {candidates}
반드시 다음 JSON 형식으로만 응답하세요:
{{
"resolved_tag": "태그명 또는 UNKNOWN",
"reason": "매핑 이유",
"confidence": 0.0~1.0
}}
"""
try:
response = await self.client.chat.completions.create(
model="Qwen3.6-27B-FP8", # MCP 서버 설정 모델 사용
messages=[{"role": "user", "content": prompt}],
response_format={ "type": "json_object" }
)
raw_content = response.choices[0].message.content
return MappingResult.model_validate_json(raw_content)
except Exception as e:
print(f"Error resolving node {node_id}: {e}")
return MappingResult(resolved_tag="UNKNOWN", reason=f"Error: {str(e)}", confidence=0.0)
# --- 전문화된 Worker 함수들 ---
async def extract_transmitters(self, node_ids: List[str]) -> Dict[str, MappingResult]:
prompt = "당신은 계측기 전문 엔지니어입니다. 특히 Pressure/Flow/Level Transmitter 매핑에 특화되어 있습니다."
tasks = [self._resolve_generic(nid, prompt) for nid in node_ids]
results = await asyncio.gather(*tasks)
return dict(zip(node_ids, results))
async def extract_valves(self, node_ids: List[str]) -> Dict[str, MappingResult]:
prompt = "당신은 밸브 및 액추에이터 전문 엔지니어입니다. 밸브의 개폐 상태 및 제어 태그 매핑에 특화되어 있습니다."
tasks = [self._resolve_generic(nid, prompt) for nid in node_ids]
results = await asyncio.gather(*tasks)
return dict(zip(node_ids, results))
async def extract_equipment(self, node_ids: List[str]) -> Dict[str, MappingResult]:
prompt = "당신은 공정 설비 전문 엔지니어입니다. 펌프, 탱크, 열교환기 등의 메인 설비 태그 매핑에 특화되어 있습니다."
tasks = [self._resolve_generic(nid, prompt) for nid in node_ids]
results = await asyncio.gather(*tasks)
return dict(zip(node_ids, results))
def validate_mapping(resolved_tag: str, symbol_type: str, tag_metadata: Dict[str, Any]) -> Tuple[bool, str]:
"""심볼 타입과 실제 태그 메타데이터의 엄격한 일치 여부 검증"""
if resolved_tag == "UNKNOWN":
return False, "Tag not resolved"
unit_map = {
"Pressure Transmitter": ["bar", "psi", "kPa", "Pa", "kg/cm2"],
"Flow Meter": ["m3/h", "lpm", "kg/h"],
"Temperature Sensor": ["°C", "C", "K", "°F"]
}
actual_unit = tag_metadata.get('unit', '').strip()
allowed_units = unit_map.get(symbol_type, [])
if actual_unit and actual_unit in allowed_units:
return True, "Unit Match"
actual_desc = tag_metadata.get('description', '').lower()
expected_keywords = {
"Pressure Transmitter": ["pressure", "press"],
"Flow Meter": ["flow", "flowrate"],
"Temperature Sensor": ["temp", "temperature"]
}
keywords = expected_keywords.get(symbol_type, [])
if any(kw in actual_desc for kw in keywords):
return True, "Description Match (Unit Missing)"
return False, "Mismatch: Symbol type and Tag metadata do not align"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,490 @@
#!/usr/bin/env python3
"""P&ID 파싱 전용 워커 프로세스
Usage: python pid_worker.py <port>
담당 도구:
extract_pid_tags, match_pid_tags,
parse_pid_dxf, parse_pid_pdf, parse_pid_drawing,
build_pid_graph_parallel, analyze_pid_impact
"""
from __future__ import annotations
import sys
import os
# mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import io
import json
import asyncio
import signal
import logging
import re
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
# ── 설정 ─────────────────────────────────────────────────────────────────────
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
_SERVER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STORAGE_DIR = os.path.join(_SERVER_DIR, "storage")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [pid_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── 싱글톤 ───────────────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm():
from openai import OpenAI
return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
@lru_cache(maxsize=1)
def _ocr():
from paddleocr import PaddleOCR
use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true"
try:
return PaddleOCR(use_angle_cls=True, lang="korean", use_gpu=use_gpu, show_log=False)
except Exception:
if use_gpu:
os.environ["PADDLE_USE_GPU"] = "false"
return _ocr()
raise
# ── DB ───────────────────────────────────────────────────────────────────────
def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
# ── 텍스트 추출 ──────────────────────────────────────────────────────────────
def _extract_text_from_dxf(filepath: str) -> str:
import ezdxf
from ezdxf.tools.text import plain_mtext
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
texts = []
for entity in msp:
if entity.dxftype() == "TEXT":
texts.append(entity.dxf.text)
elif entity.dxftype() == "MTEXT":
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
return "\n".join(texts)
def _extract_text_from_pdf(filepath: str) -> str:
import fitz
doc = fitz.open(filepath)
return "\n".join(page.get_text() for page in doc)
def _extract_text_from_pdf_ocr(filepath: str) -> str:
import fitz
from PIL import Image
import numpy as np
doc = fitz.open(filepath)
all_texts = []
for page in doc:
mat = fitz.Matrix(300 / 72)
pix = page.get_pixmap(matrix=mat)
img = Image.open(io.BytesIO(pix.tobytes("png")))
result = _ocr().ocr(np.array(img), cls=True)
if result and result[0]:
all_texts.extend(line[1][0] for line in result[0])
return "\n".join(all_texts)
# ── JSON 배열 파싱 유틸 ───────────────────────────────────────────────────────
def _parse_json_array(raw: str, finish_reason: str = "") -> list:
"""LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
if raw.startswith("```"):
lines = raw.splitlines()
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
if finish_reason == "length":
last_close = raw.rfind("}")
if last_close != -1:
raw = raw[:last_close + 1] + "]"
# 가장 긴 균형 잡힌 [...] 추출
depth = 0; start = -1; best = ""
for i, c in enumerate(raw):
if c == "[":
if depth == 0:
start = i
depth += 1
elif c == "]":
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i + 1]
if len(cand) > len(best):
best = cand
raw = best if best else "[]"
try:
return json.loads(raw)
except json.JSONDecodeError:
data = []
for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
try:
data.append(json.loads(obj))
except json.JSONDecodeError:
pass
return data
# ── 태그 추출/매핑 도구 ───────────────────────────────────────────────────────
def _extract_pid_tags(text: str, source_type: str) -> str:
system = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract all instrument and equipment tags from the provided text.\n"
"Return ONLY a valid JSON array. Each element must have exactly these fields:\n"
'{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV",'
'"lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n'
"Rules:\n"
"- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n"
" Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n"
"- instrumentType: leading letters of tagNo\n"
"- equipmentName: descriptive name if present near tag, else null\n"
"- lineNumber/pidDrawingNo: null unless explicitly associated\n"
"- confidence: 0.95 for clear tags, lower for ambiguous\n"
"- Output ONLY the JSON array, no markdown, no explanation.\n"
"- If no tags found, return: []\n"
)
truncated = text[:100000]
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"},
],
max_tokens=32768,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
logging.info(f"extract_pid_tags source={source_type} count={len(data)}")
return json.dumps({
"success": True,
"data": {"count": len(data), "tags": data},
"message": "태그 추출 완료"
}, ensure_ascii=False, indent=2)
def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
system = (
"You are a P&ID to Experion tag matching expert.\n"
"Match P&ID tags to Experion tags based on similarity.\n"
"Return ONLY a JSON array:\n"
'[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n'
"- If no good match: confidence < 0.5, experionTag null\n"
"- Output ONLY the JSON array.\n"
)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": (
f"P&ID Tags:\n{chr(10).join(pid_tags)}\n\n"
f"Experion Tags:\n{chr(10).join(experion_tags)}"
)},
],
max_tokens=16384,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
return json.dumps({
"success": True,
"data": {"count": len(data), "mappings": data},
"message": "태그 매핑 완료"
}, ensure_ascii=False, indent=2)
# ── 도면 파싱 도구 ────────────────────────────────────────────────────────────
_TAG_EXTRACT_SYSTEM = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract instrument and equipment tags from the provided text.\n"
"Return ONLY a JSON array:\n"
'[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FIT",'
'"lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n'
"Rules:\n"
"- tagNo: Instrument [Function]-[Number], Equipment [Type]-[Number]\n"
"- instrumentType: first 2-4 letters of tagNo\n"
"- equipmentName/lineNumber/pidDrawingNo: null if not present\n"
"- confidence: 0.0 to 1.0\n"
"- Output ONLY the JSON array, no markdown.\n"
"- If no tags found, return: []\n"
)
def _parse_pid_dxf(filepath: str) -> str:
text = _extract_text_from_dxf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: dxf\n\nText:\n{text[:8000]}"},
],
max_tokens=8192,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "DXF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
text = _extract_text_from_pdf_ocr(filepath) if use_ocr else _extract_text_from_pdf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"},
],
max_tokens=4096,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "PDF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_drawing(filepath: str) -> str:
ext = os.path.splitext(filepath)[1].lower()
if ext == ".dxf":
return _parse_pid_dxf(filepath)
elif ext == ".pdf":
return _parse_pid_pdf(filepath)
elif ext == ".dwg":
return json.dumps({
"success": False,
"data": None,
"error": "DWG 파일은 직접 파싱할 수 없습니다. DXF로 변환 후 사용하세요.",
"message": "지원하지 않는 파일 형식"
}, ensure_ascii=False)
else:
return json.dumps({
"success": False,
"error": f"지원하지 않는 형식: {ext}. 지원: .dxf, .pdf",
}, ensure_ascii=False)
# ── 그래프 도구 ───────────────────────────────────────────────────────────────
async def _build_pid_graph_parallel(filepath: str) -> str:
from pipeline.extractor import PidGeometricExtractor
from pipeline.topology import PidTopologyBuilder
from pipeline.mapper import IntelligentMapper
from openai import AsyncOpenAI
os.makedirs(STORAGE_DIR, exist_ok=True)
# Phase 1: 기하 추출
extractor = PidGeometricExtractor(filepath)
geo_data_path = os.path.join(STORAGE_DIR, os.path.basename(filepath) + "_geo.json")
extractor.extract_and_save(geo_data_path)
with open(geo_data_path, "r", encoding="utf-8") as f:
geo_data = json.load(f)
# 시스템 태그 조회
system_tags: list[str] = []
try:
conn = _get_db_connection()
with conn.cursor() as cur:
cur.execute("SELECT tagname FROM realtime_table")
system_tags = [r[0] for r in cur.fetchall()]
except Exception as e:
logging.warning(f"시스템 태그 조회 실패: {e}")
# Phase 2: 1차 위상 빌더 (Mapper용 그래프)
builder = PidTopologyBuilder(geo_data)
builder.build_graph()
# Phase 3: 병렬 LLM 매핑
api_client = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
mapper = IntelligentMapper(builder.G, system_tags, api_client=api_client)
transmitter_nodes = [
n for n, d in builder.G.nodes(data=True)
if (d.get("value") or "").upper() in {"FIT", "FT", "LT", "PT", "TE"}
]
valve_nodes = [
n for n, d in builder.G.nodes(data=True)
if (d.get("value") or "").upper() in {"FCV", "LCV", "TCV", "PCV", "XV"}
]
equipment_nodes = [
n for n, d in builder.G.nodes(data=True)
if d.get("type") not in {"TEXT", "LINE", "LWPOLYLINE"}
]
extracted_results = await asyncio.gather(
mapper.extract_transmitters(transmitter_nodes),
mapper.extract_valves(valve_nodes),
mapper.extract_equipment(equipment_nodes),
)
# 매핑 결과 통합
all_mapped_tags = []
for res_dict in extracted_results:
for node_id, mapping in res_dict.items():
if mapping.resolved_tag != "UNKNOWN":
node_data = builder.G.nodes[node_id]
all_mapped_tags.append({
"entity_id": node_id,
"tagName": mapping.resolved_tag,
"bbox": (
node_data["bbox"].bounds
if hasattr(node_data["bbox"], "bounds")
else node_data["bbox"]
),
"clean_value": mapping.resolved_tag,
})
# Phase 4: 최종 위상 모델링 + 저장
final_builder = PidTopologyBuilder(geo_data, all_extracted_tags=all_mapped_tags)
final_builder.build_graph()
graph_id = os.path.basename(filepath).replace(".dxf", "_graph.json")
graph_path = os.path.join(STORAGE_DIR, graph_id)
final_builder.save_graph(graph_path)
logging.info(f"build_pid_graph_parallel graph_id={graph_id} "
f"nodes={final_builder.G.number_of_nodes()} "
f"edges={final_builder.G.number_of_edges()}")
return json.dumps({
"success": True,
"graph_id": graph_id,
"graph_path": graph_path,
"nodes": final_builder.G.number_of_nodes(),
"edges": final_builder.G.number_of_edges(),
}, ensure_ascii=False)
def _analyze_pid_impact(graph_id: str, start_node_id: str) -> str:
from pipeline.analyzer import PidAnalysisEngine
graph_path = os.path.join(STORAGE_DIR, graph_id)
mapping_path = graph_path.replace("_graph.json", "_mapping.json")
analyzer = PidAnalysisEngine(graph_path, mapping_path)
result = analyzer.analyze_impact(start_node_id)
return json.dumps(result, ensure_ascii=False, indent=2)
# ── 요청 디스패처 ─────────────────────────────────────────────────────────────
async def _dispatch(tool: str, params: dict) -> str:
try:
match tool:
# blocking 함수는 asyncio.to_thread로 스레드풀 오프로드
case "extract_pid_tags":
return await asyncio.to_thread(_extract_pid_tags, **params)
case "match_pid_tags":
return await asyncio.to_thread(_match_pid_tags, **params)
case "parse_pid_dxf":
return await asyncio.to_thread(_parse_pid_dxf, **params)
case "parse_pid_pdf":
return await asyncio.to_thread(_parse_pid_pdf, **params)
case "parse_pid_drawing":
return await asyncio.to_thread(_parse_pid_drawing, **params)
case "analyze_pid_impact":
return await asyncio.to_thread(_analyze_pid_impact, **params)
# 이미 async — 직접 await
case "build_pid_graph_parallel":
return await _build_pid_graph_parallel(**params)
case _:
return json.dumps({"success": False, "error": f"알 수 없는 도구: {tool}"},
ensure_ascii=False)
except Exception as e:
logging.error(f"dispatch error tool={tool}: {e}", exc_info=True)
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
# ── 종료 예약 ─────────────────────────────────────────────────────────────────
def _schedule_shutdown():
"""
응답 전송 완료 후 프로세스 종료 예약.
FastAPI의 BackgroundTasks를 사용하여 응답이 완전히 전송된 후 종료되도록 유도함.
"""
async def _do():
# 네트워크 전송 및 커넥션 정리를 위한 최소한의 대기 시간
await asyncio.sleep(1.0)
logging.info("One-shot worker shutting down...")
os.kill(os.getpid(), signal.SIGTERM)
asyncio.create_task(_do())
# ── HTTP 엔드포인트 ───────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
body = await request.json()
return await _dispatch(body["tool"], body["params"])
from fastapi import BackgroundTasks
@app.post("/execute/one_shot")
async def execute_one_shot(request: Request, background_tasks: BackgroundTasks):
"""요청 처리 후 프로세스 자동 종료 (P&ID 워커 전용)."""
body = await request.json()
result = await _dispatch(body["tool"], body["params"])
# BackgroundTasks에 등록하여 응답 전송이 완료된 후 _schedule_shutdown이 실행되도록 함
background_tasks.add_function(_schedule_shutdown)
return result
# ── 진입점 ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5004
os.makedirs(STORAGE_DIR, exist_ok=True)
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")

View File

@@ -0,0 +1,506 @@
#!/usr/bin/env python3
"""P&ID 파싱 전용 워커 프로세스
Usage: python pid_worker.py <port>
담당 도구:
extract_pid_tags, match_pid_tags,
parse_pid_dxf, parse_pid_pdf, parse_pid_drawing,
build_pid_graph_parallel, analyze_pid_impact
"""
from __future__ import annotations
import sys
import os
# mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import io
import json
import asyncio
import signal
import logging
import re
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
# ── 설정 ─────────────────────────────────────────────────────────────────────
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
_SERVER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STORAGE_DIR = os.path.join(_SERVER_DIR, "storage")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [pid_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── 싱글톤 ───────────────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm():
from openai import OpenAI
return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
@lru_cache(maxsize=1)
def _ocr():
from paddleocr import PaddleOCR
use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true"
try:
return PaddleOCR(use_angle_cls=True, lang="korean", use_gpu=use_gpu, show_log=False)
except Exception:
if use_gpu:
os.environ["PADDLE_USE_GPU"] = "false"
return _ocr()
raise
# ── DB ───────────────────────────────────────────────────────────────────────
def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
# ── 텍스트 추출 ──────────────────────────────────────────────────────────────
def _extract_text_from_dxf(filepath: str) -> str:
import ezdxf
from ezdxf.tools.text import plain_mtext
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
texts = []
for entity in msp:
if entity.dxftype() == "TEXT":
texts.append(entity.dxf.text)
elif entity.dxftype() == "MTEXT":
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
return "\n".join(texts)
def _extract_text_from_pdf(filepath: str) -> str:
import fitz
doc = fitz.open(filepath)
return "\n".join(page.get_text() for page in doc)
def _extract_text_from_pdf_ocr(filepath: str) -> str:
import fitz
from PIL import Image
import numpy as np
doc = fitz.open(filepath)
all_texts = []
for page in doc:
mat = fitz.Matrix(300 / 72)
pix = page.get_pixmap(matrix=mat)
img = Image.open(io.BytesIO(pix.tobytes("png")))
result = _ocr().ocr(np.array(img), cls=True)
if result and result[0]:
all_texts.extend(line[1][0] for line in result[0])
return "\n".join(all_texts)
# ── JSON 배열 파싱 유틸 ───────────────────────────────────────────────────────
def _parse_json_array(raw: str, finish_reason: str = "") -> list:
"""LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
if raw.startswith("```"):
lines = raw.splitlines()
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
if finish_reason == "length":
last_close = raw.rfind("}")
if last_close != -1:
raw = raw[:last_close + 1] + "]"
# 가장 긴 균형 잡힌 [...] 추출
depth = 0; start = -1; best = ""
for i, c in enumerate(raw):
if c == "[":
if depth == 0:
start = i
depth += 1
elif c == "]":
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i + 1]
if len(cand) > len(best):
best = cand
raw = best if best else "[]"
try:
return json.loads(raw)
except json.JSONDecodeError:
data = []
for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
try:
data.append(json.loads(obj))
except json.JSONDecodeError:
pass
return data
# ── 태그 추출/매핑 도구 ───────────────────────────────────────────────────────
def _extract_pid_tags(text: str, source_type: str) -> str:
system = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract all instrument and equipment tags from the provided text.\n"
"Return ONLY a valid JSON array. Each element must have exactly these fields:\n"
'{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV",'
'"lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n'
"Rules:\n"
"- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n"
" Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n"
"- instrumentType: leading letters of tagNo\n"
"- equipmentName: descriptive name if present near tag, else null\n"
"- lineNumber/pidDrawingNo: null unless explicitly associated\n"
"- confidence: 0.95 for clear tags, lower for ambiguous\n"
"- Output ONLY the JSON array, no markdown, no explanation.\n"
"- If no tags found, return: []\n"
)
truncated = text[:100000]
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"},
],
max_tokens=32768,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
logging.info(f"extract_pid_tags source={source_type} count={len(data)}")
return json.dumps({
"success": True,
"data": {"count": len(data), "tags": data},
"message": "태그 추출 완료"
}, ensure_ascii=False, indent=2)
def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
system = (
"You are a P&ID to Experion tag matching expert.\n"
"Match P&ID tags to Experion tags based on similarity.\n"
"Return ONLY a JSON array:\n"
'[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n'
"- If no good match: confidence < 0.5, experionTag null\n"
"- Output ONLY the JSON array.\n"
)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": (
f"P&ID Tags:\n{chr(10).join(pid_tags)}\n\n"
f"Experion Tags:\n{chr(10).join(experion_tags)}"
)},
],
max_tokens=16384,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
return json.dumps({
"success": True,
"data": {"count": len(data), "mappings": data},
"message": "태그 매핑 완료"
}, ensure_ascii=False, indent=2)
# ── 도면 파싱 도구 ────────────────────────────────────────────────────────────
_TAG_EXTRACT_SYSTEM = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract instrument and equipment tags from the provided text.\n"
"Return ONLY a JSON array:\n"
'[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FIT",'
'"lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n'
"Rules:\n"
"- tagNo: Instrument [Function]-[Number], Equipment [Type]-[Number]\n"
"- instrumentType: first 2-4 letters of tagNo\n"
"- equipmentName/lineNumber/pidDrawingNo: null if not present\n"
"- confidence: 0.0 to 1.0\n"
"- Output ONLY the JSON array, no markdown.\n"
"- If no tags found, return: []\n"
)
def _parse_pid_dxf(filepath: str) -> str:
text = _extract_text_from_dxf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: dxf\n\nText:\n{text[:8000]}"},
],
max_tokens=8192,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "DXF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
text = _extract_text_from_pdf_ocr(filepath) if use_ocr else _extract_text_from_pdf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"},
],
max_tokens=4096,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "PDF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_drawing(filepath: str) -> str:
ext = os.path.splitext(filepath)[1].lower()
if ext == ".dxf":
return _parse_pid_dxf(filepath)
elif ext == ".pdf":
return _parse_pid_pdf(filepath)
elif ext == ".dwg":
return json.dumps({
"success": False,
"data": None,
"error": "DWG 파일은 직접 파싱할 수 없습니다. DXF로 변환 후 사용하세요.",
"message": "지원하지 않는 파일 형식"
}, ensure_ascii=False)
else:
return json.dumps({
"success": False,
"error": f"지원하지 않는 형식: {ext}. 지원: .dxf, .pdf",
}, ensure_ascii=False)
# ── 그래프 도구 ───────────────────────────────────────────────────────────────
import time
async def _build_pid_graph_parallel(filepath: str) -> str:
from pipeline.extractor import PidGeometricExtractor
from pipeline.topology import PidTopologyBuilder
from pipeline.mapper import IntelligentMapper
from openai import AsyncOpenAI
os.makedirs(STORAGE_DIR, exist_ok=True)
t0 = time.time()
basename = os.path.basename(filepath)
logging.info(f"[{basename}] Phase 1: 기하 추출 시작")
# Phase 1: 기하 추출
extractor = PidGeometricExtractor(filepath)
geo_data_path = os.path.join(STORAGE_DIR, basename + "_geo.json")
extractor.extract_and_save(geo_data_path)
with open(geo_data_path, "r", encoding="utf-8") as f:
geo_data = json.load(f)
logging.info(f"[{basename}] Phase 1 완료 ({time.time()-t0:.1f}s) - {len(geo_data)}개 엔티티 추출")
# 시스템 태그 조회
system_tags: list[str] = []
try:
conn = _get_db_connection()
with conn.cursor() as cur:
cur.execute("SELECT tagname FROM realtime_table")
system_tags = [r[0] for r in cur.fetchall()]
except Exception as e:
logging.warning(f"시스템 태그 조회 실패: {e}")
logging.info(f"[{basename}] 시스템 태그 {len(system_tags)}개 로드")
# Phase 2: 1차 위상 빌더 (Mapper용 그래프)
t1 = time.time()
builder = PidTopologyBuilder(geo_data)
builder.build_graph()
logging.info(f"[{basename}] Phase 2 완료 ({time.time()-t1:.1f}s) - 그래프: {builder.G.number_of_nodes()}노드, {builder.G.number_of_edges()}엣지")
# Phase 3: 병렬 LLM 매핑
t2 = time.time()
api_client = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
mapper = IntelligentMapper(builder.G, system_tags, api_client=api_client)
transmitter_nodes = [
n for n, d in builder.G.nodes(data=True)
if (d.get("value") or "").upper() in {"FIT", "FT", "LT", "PT", "TE"}
]
valve_nodes = [
n for n, d in builder.G.nodes(data=True)
if (d.get("value") or "").upper() in {"FCV", "LCV", "TCV", "PCV", "XV"}
]
equipment_nodes = [
n for n, d in builder.G.nodes(data=True)
if d.get("type") not in {"TEXT", "LINE", "LWPOLYLINE"}
]
logging.info(f"[{basename}] Phase 3 시작 - transmitter:{len(transmitter_nodes)}, valve:{len(valve_nodes)}, equipment:{len(equipment_nodes)}")
extracted_results = await asyncio.gather(
mapper.extract_transmitters(transmitter_nodes),
mapper.extract_valves(valve_nodes),
mapper.extract_equipment(equipment_nodes),
)
logging.info(f"[{basename}] Phase 3 완료 ({time.time()-t2:.1f}s)")
# 매핑 결과 통합
all_mapped_tags = []
for res_dict in extracted_results:
for node_id, mapping in res_dict.items():
if mapping.resolved_tag != "UNKNOWN":
node_data = builder.G.nodes[node_id]
all_mapped_tags.append({
"entity_id": node_id,
"tagName": mapping.resolved_tag,
"bbox": (
node_data["bbox"].bounds
if hasattr(node_data["bbox"], "bounds")
else node_data["bbox"]
),
"clean_value": mapping.resolved_tag,
})
logging.info(f"[{basename}] 매핑 완료: {len(all_mapped_tags)}개 태그 해결")
# Phase 4: 최종 위상 모델링 + 저장
t3 = time.time()
final_builder = PidTopologyBuilder(geo_data, all_extracted_tags=all_mapped_tags)
final_builder.build_graph()
graph_id = basename.replace(".dxf", "_graph.json")
graph_path = os.path.join(STORAGE_DIR, graph_id)
final_builder.save_graph(graph_path)
total_time = time.time() - t0
logging.info(f"[{basename}] 전체 완료 ({total_time:.1f}s) - graph_id={graph_id} "
f"nodes={final_builder.G.number_of_nodes()} "
f"edges={final_builder.G.number_of_edges()}")
return json.dumps({
"success": True,
"graph_id": graph_id,
"graph_path": graph_path,
"nodes": final_builder.G.number_of_nodes(),
"edges": final_builder.G.number_of_edges(),
"processing_time_sec": round(total_time, 1)
}, ensure_ascii=False)
def _analyze_pid_impact(graph_id: str, start_node_id: str) -> str:
from pipeline.analyzer import PidAnalysisEngine
graph_path = os.path.join(STORAGE_DIR, graph_id)
mapping_path = graph_path.replace("_graph.json", "_mapping.json")
analyzer = PidAnalysisEngine(graph_path, mapping_path)
result = analyzer.analyze_impact(start_node_id)
return json.dumps(result, ensure_ascii=False, indent=2)
# ── 요청 디스패처 ─────────────────────────────────────────────────────────────
async def _dispatch(tool: str, params: dict) -> str:
try:
match tool:
# blocking 함수는 asyncio.to_thread로 스레드풀 오프로드
case "extract_pid_tags":
return await asyncio.to_thread(_extract_pid_tags, **params)
case "match_pid_tags":
return await asyncio.to_thread(_match_pid_tags, **params)
case "parse_pid_dxf":
return await asyncio.to_thread(_parse_pid_dxf, **params)
case "parse_pid_pdf":
return await asyncio.to_thread(_parse_pid_pdf, **params)
case "parse_pid_drawing":
return await asyncio.to_thread(_parse_pid_drawing, **params)
case "analyze_pid_impact":
return await asyncio.to_thread(_analyze_pid_impact, **params)
# 이미 async — 직접 await
case "build_pid_graph_parallel":
return await _build_pid_graph_parallel(**params)
case _:
return json.dumps({"success": False, "error": f"알 수 없는 도구: {tool}"},
ensure_ascii=False)
except Exception as e:
logging.error(f"dispatch error tool={tool}: {e}", exc_info=True)
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
# ── 종료 예약 ─────────────────────────────────────────────────────────────────
def _schedule_shutdown():
"""
응답 전송 완료 후 프로세스 종료 예약.
FastAPI의 BackgroundTasks를 사용하여 응답이 완전히 전송된 후 종료되도록 유도함.
"""
async def _do():
# 네트워크 전송 및 커넥션 정리를 위한 최소한의 대기 시간
await asyncio.sleep(1.0)
logging.info("One-shot worker shutting down...")
os.kill(os.getpid(), signal.SIGTERM)
asyncio.create_task(_do())
# ── HTTP 엔드포인트 ───────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
body = await request.json()
return await _dispatch(body["tool"], body["params"])
from fastapi import BackgroundTasks
@app.post("/execute/one_shot")
async def execute_one_shot(request: Request, background_tasks: BackgroundTasks):
"""요청 처리 후 프로세스 자동 종료 (P&ID 워커 전용)."""
body = await request.json()
result = await _dispatch(body["tool"], body["params"])
# BackgroundTasks에 등록하여 응답 전송이 완료된 후 _schedule_shutdown이 실행되도록 함
background_tasks.add_function(_schedule_shutdown)
return result
# ── 진입점 ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5004
os.makedirs(STORAGE_DIR, exist_ok=True)
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")

View File

@@ -0,0 +1,189 @@
import ezdxf
import re
import json
import logging
from typing import List, Optional, Tuple, Union
from pydantic import BaseModel, Field
from shapely.geometry import box, Point
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# --- Data Models ---
class BoundingBox(BaseModel):
min_x: float
min_y: float
max_x: float
max_y: float
center: Tuple[float, float]
class GeometricEntity(BaseModel):
entity_id: str
entity_type: str # TEXT, MTEXT, LINE, LWPOLYLINE, CIRCLE, ARC
layer: str
bbox: BoundingBox
raw_value: Optional[str] = None
clean_value: Optional[str] = None
coordinates: List[Union[Tuple[float, float], List[float]]] = Field(default_factory=list)
properties: dict = Field(default_factory=dict)
# --- Extractor Implementation ---
class PidGeometricExtractor:
def __init__(self, file_path: str):
try:
self.doc = ezdxf.readfile(file_path)
self.msp = self.doc.modelspace()
except Exception as e:
raise IOError(f"Failed to load DXF file: {e}")
def clean_text(self, text: str) -> str:
"""
DXF 특수 제어 문자 및 MTEXT 포맷팅을 제거하여 정제된 텍스트 반환.
"""
if not text:
return ""
# 1. MTEXT 포맷팅 및 제어 문자 제거 (\P, \W, \L, \A, \C, \H, \S, \T 등)
text = re.sub(r'\\([P|W|L|A|C|H|S|T])\d*;?', ' ', text)
# 2. 중괄호 { } 제거
text = re.sub(r'[\{\}]', ' ', text)
# 3. DXF 특수 제어 문자 제거 (%%U: Underline, %%O: Overline, %%S: Strikethrough, %%R: Registered)
text = re.sub(r'%%[U|O|S|R]', ' ', text)
# 4. 불필요한 특수 기호 및 반복되는 공백 정제
text = re.sub(r'\s+', ' ', text).strip()
return text
def get_bbox(self, entity) -> Optional[BoundingBox]:
"""
엔티티 타입별로 동적인 Bounding Box를 계산하여 반환.
"""
try:
if entity.dxftype() == 'TEXT':
p = entity.dxf.insert
h = entity.dxf.height
# 텍스트 길이에 따른 대략적인 너비 계산 (글자수 * 높이 * 0.6)
width = len(entity.dxf.text) * h * 0.6
return self._create_bbox(p.x, p.y, p.x + width, p.y + h)
elif entity.dxftype() == 'MTEXT':
p = entity.dxf.insert
h = entity.dxf.char_height if hasattr(entity.dxf, 'char_height') else 2.5
w = entity.dxf.width if entity.dxf.width > 0 else len(entity.text) * h * 0.6
return self._create_bbox(p.x, p.y, p.x + w, p.y + h)
elif entity.dxftype() == 'LINE':
start = entity.dxf.start
end = entity.dxf.end
return self._create_bbox(
min(start.x, end.x), min(start.y, end.y),
max(start.x, end.x), max(start.y, end.y)
)
elif entity.dxftype() == 'LWPOLYLINE':
points = entity.get_points()
if not points: return None
xs = [p[0] for p in points]
ys = [p[1] for p in points]
return self._create_bbox(min(xs), min(ys), max(xs), max(ys))
elif entity.dxftype() in ('CIRCLE', 'ARC'):
center = entity.dxf.center
radius = entity.dxf.radius
return self._create_bbox(
center.x - radius, center.y - radius,
center.x + radius, center.y + radius
)
except Exception as e:
logger.error(f"Error calculating bbox for {entity.dxftype()} ({entity.dxf.handle}): {e}", exc_info=True)
return None
def _create_bbox(self, min_x, min_y, max_x, max_y) -> BoundingBox:
return BoundingBox(
min_x=min_x,
min_y=min_y,
max_x=max_x,
max_y=max_y,
center=((min_x + max_x) / 2, (min_y + max_y) / 2)
)
def extract_and_save(self, output_path: str):
"""
기하학적 데이터를 추출하여 JSON 파일로 저장.
"""
results = []
logger.info(f"Starting DXF extraction from {self.doc.filename if hasattr(self.doc, 'filename') else 'unknown file'}")
for entity in self.msp:
try:
bbox_obj = self.get_bbox(entity)
if not bbox_obj:
continue
raw_text = ""
if entity.dxftype() == 'TEXT':
raw_text = entity.dxf.text
elif entity.dxftype() == 'MTEXT':
raw_text = entity.text
# 좌표 추출 (3D 좌표를 2D로 변환)
coords = []
if hasattr(entity, 'get_points'):
# ezdxf의 get_points()는 (x, y, z) 튜플 리스트를 반환함
coords = [(p[0], p[1]) for p in entity.get_points()]
elif entity.dxftype() == 'LINE':
coords = [(entity.dxf.start.x, entity.dxf.start.y), (entity.dxf.end.x, entity.dxf.end.y)]
elif entity.dxftype() in ('CIRCLE', 'ARC'):
coords = [(entity.dxf.center.x, entity.dxf.center.y)]
entity_data = GeometricEntity(
entity_id=entity.dxf.handle,
entity_type=entity.dxftype(),
layer=entity.dxf.layer,
bbox=bbox_obj,
raw_value=raw_text if raw_text else None,
clean_value=self.clean_text(raw_text) if raw_text else None,
coordinates=coords,
properties={
"color": entity.dxf.color,
"lineweight": entity.dxf.lineweight if hasattr(entity.dxf, 'lineweight') else None,
}
)
results.append(entity_data.model_dump())
except Exception as e:
logger.error(f"Unexpected error processing entity {entity.dxftype()} ({entity.dxf.handle}): {e}")
continue
try:
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(results, f, ensure_ascii=False, indent=4)
logger.info(f"Successfully saved {len(results)} entities to {output_path}")
except Exception as e:
logger.error(f"Failed to save extraction results to {output_path}: {e}")
raise
return output_path
# --- Proximity Utilities ---
def is_near(bbox_a: BoundingBox, bbox_b: BoundingBox, threshold=5.0) -> bool:
"""
두 Bounding Box 간의 최단 거리가 임계값 이내인지 확인.
shapely box를 사용하여 거리 계산.
"""
box_a = box(bbox_a.min_x, bbox_a.min_y, bbox_a.max_x, bbox_a.max_y)
box_b = box(bbox_b.min_x, bbox_b.min_y, bbox_b.max_x, bbox_b.max_y)
return box_a.distance(box_b) <= threshold
def is_inside(point: Tuple[float, float], bbox: BoundingBox) -> bool:
"""
특정 점이 Bounding Box 내부에 있는지 확인.
"""
return (bbox.min_x <= point[0] <= bbox.max_x) and (bbox.min_y <= point[1] <= bbox.max_y)

View File

@@ -0,0 +1,200 @@
import networkx as nx
from shapely.geometry import box, Point, LineString
import json
from typing import List, Dict, Any, Optional, Tuple
class PidTopologyBuilder:
def __init__(self, geometric_data: List[Dict[str, Any]], all_extracted_tags: Optional[List[Dict[str, Any]]] = None, config: Optional[Dict[str, float]] = None):
"""
- geometric_data: Phase 1에서 추출된 기하학적 데이터 (List of dicts)
- all_extracted_tags: 통합된 태그 리스트
- config: {'dist_threshold': 50.0, 'tag_threshold': 100.0} 등 설정값
"""
self.data = geometric_data
self.all_tags = all_extracted_tags if all_extracted_tags else []
# 기본 설정값
default_config = {'dist_threshold': 50.0, 'tag_threshold': 100.0}
self.config = config if config else default_config
# 동적 스케일링 적용
self._apply_dynamic_scaling()
self.G = nx.DiGraph() # 방향성 그래프 생성
def _apply_dynamic_scaling(self):
"""도면의 전체 크기를 기반으로 임계값을 동적으로 조정"""
if not self.data:
return
# 모든 BBox를 포함하는 전체 영역 계산
all_min_x = min((item['bbox']['min_x'] for item in self.data if 'bbox' in item), default=0)
all_max_x = max((item['bbox']['max_x'] for item in self.data if 'bbox' in item), default=1000)
all_min_y = min((item['bbox']['min_y'] for item in self.data if 'bbox' in item), default=0)
all_max_y = max((item['bbox']['max_y'] for item in self.data if 'bbox' in item), default=1000)
drawing_width = all_max_x - all_min_x
drawing_height = all_max_y - all_min_y
diag = (drawing_width**2 + drawing_height**2)**0.5
if diag == 0: return
# 기준 대각선 길이 (예: 5000 units) 대비 현재 도면 크기 비율 계산
# 기준 도면 크기가 5000일 때 dist_threshold=50 (1%)
scale_factor = diag / 5000.0
# 임계값 업데이트 (최소/최대 범위 제한)
self.config['dist_threshold'] = max(5.0, min(200.0, 50.0 * scale_factor))
self.config['tag_threshold'] = max(20.0, min(500.0, 100.0 * scale_factor))
def build_graph(self):
# 1. 모든 객체를 노드로 추가
for item in self.data:
bbox_vals = item['bbox']
# BoundingBox 모델의 필드명에 맞춰 추출 (min_x, min_y, max_x, max_y)
bbox_geom = box(bbox_vals['min_x'], bbox_vals['min_y'], bbox_vals['max_x'], bbox_vals['max_y'])
self.G.add_node(item['entity_id'],
type=item['entity_type'],
bbox=bbox_geom,
value=item.get('clean_value'),
layer=item.get('layer'))
# 2. 분산 추출된 태그 통합 및 노드 추가
for tag in self.all_tags:
bbox_vals = tag['bbox']
bbox_geom = box(bbox_vals['min_x'], bbox_vals['min_y'], bbox_vals['max_x'], bbox_vals['max_y'])
self.G.add_node(tag['entity_id'],
type='TEXT',
bbox=bbox_geom,
value=tag.get('clean_value') or tag.get('tagName'))
# 3. 태그-설비 논리적 연결 (Association)
tags = [n for n, d in self.G.nodes(data=True) if d['type'] == 'TEXT']
equipments = [n for n, d in self.G.nodes(data=True) if d['type'] not in ['TEXT', 'LINE', 'LWPOLYLINE']]
for tag in tags:
best_match = self._find_nearest_equipment(tag, equipments)
if best_match:
self.G.add_edge(tag, best_match, relation='associated_with')
# 4. 배관 기반 물리적 연결 (Pipe) [개선됨: End-point 기반]
lines = [n for n, d in self.G.nodes(data=True) if d['type'] in ['LINE', 'LWPOLYLINE']]
for line_id in lines:
original_item = next((item for item in self.data if item['entity_id'] == line_id), None)
if not original_item or not original_item.get('coordinates'):
continue
coords = original_item['coordinates']
line_geom = LineString(coords)
# 개선: 끝점뿐만 아니라 라인 전체가 설비 BBox와 교차하거나 매우 가까운지 확인
connected_nodes = []
for eq_id in equipments:
eq_bbox = self.G.nodes[eq_id]['bbox']
# 1. 라인이 BBox와 교차하는지 확인 (관통 포함)
if line_geom.intersects(eq_bbox):
connected_nodes.append(eq_id)
# 2. 교차하지 않더라도 임계값 이내에 있는지 확인 (근접 연결)
elif line_geom.distance(eq_bbox) < self.config['dist_threshold']:
connected_nodes.append(eq_id)
# 중복 제거
connected_nodes = list(set(connected_nodes))
if len(connected_nodes) >= 2:
# 개선: 단순 순서가 아닌, 기하학적 좌표 기반의 흐름 방향 추론 (왼쪽 -> 오른쪽, 위 -> 아래 우선)
# 실제 공정 도면의 일반적인 흐름 방향을 반영
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')
else:
self.G.add_edge(connected_nodes[1], connected_nodes[0], relation='pipe', 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')
else:
self.G.add_edge(connected_nodes[1], connected_nodes[0], relation='pipe', flow_direction='forward')
elif len(connected_nodes) == 1:
# 단일 연결의 경우, 일단 엣지를 생성하되 방향은 미정(undirected-like)으로 처리하거나
# 추후 전파 로직에서 결정하도록 함
pass
def _find_nearest_equipment(self, tag_id, equipment_ids):
"""
단순 거리 기반 매핑에서 위상 기반 가중치 매핑으로 개선.
가중치 = 거리 점수 + 연결성 점수
"""
tag_bbox = self.G.nodes[tag_id]['bbox']
best_score = float('inf')
nearest = None
# 태그 노드와 연결된 배관(LINE/LWPOLYLINE) 확인
connected_pipes = [n for n in self.G.neighbors(tag_id) if self.G.nodes[n]['type'] in ['LINE', 'LWPOLYLINE']]
for eq_id in equipment_ids:
eq_bbox = self.G.nodes[eq_id]['bbox']
dist = tag_bbox.distance(eq_bbox)
if dist > self.config['tag_threshold']:
continue
# 1. 거리 점수 (낮을수록 좋음)
score = dist
# 2. 연결성 가중치 (태그와 설비가 동일한 배관에 연결되어 있다면 점수 대폭 감점 = 우선순위 상승)
# 태그가 직접 배관에 연결되어 있지는 않지만, 태그 근처의 배관이 설비에 연결되어 있는지 확인
for pipe_id in connected_pipes:
if self.G.has_edge(pipe_id, eq_id) or self.G.has_edge(eq_id, pipe_id):
score -= self.config['tag_threshold'] * 0.5 # 연결성 보너스
if score < best_score:
best_score = score
nearest = eq_id
return nearest
def validate_topology(self):
"""위상 무결성 검증"""
isolated = list(nx.isolates(self.G))
return {
"isolated_nodes": isolated,
"node_count": self.G.number_of_nodes(),
"edge_count": self.G.number_of_edges()
}
def save_graph(self, output_path: str):
"""그래프 구조를 JSON 형태로 저장"""
from networkx.readwrite import json_graph
data = json_graph.node_link_data(self.G)
# shapely geometry 객체는 JSON 직렬화가 안 되므로 변환
for node in data['nodes']:
if 'bbox' in node:
bbox = node['bbox']
node['bbox'] = {
'min_x': bbox.bounds[0],
'min_y': bbox.bounds[1],
'max_x': bbox.bounds[2],
'max_y': bbox.bounds[3]
}
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=4)
return output_path
def analyze_impact(graph, start_node):
"""특정 설비 장애 시 하류(Downstream)에 영향을 받는 모든 노드 추출"""
if start_node not in graph:
return []
# BFS를 통해 도달 가능한 모든 노드 탐색
impacted_nodes = nx.descendants(graph, start_node)
return list(impacted_nodes)

View File

@@ -0,0 +1,350 @@
# P&ID 재설계 - 코딩 계획
> 작성일: 2026-05-04
> 상태: 진행 중
> 목표: `No-10_Plant_PID.dxf`(28,819 엔티티) 처리 시 30분 타임아웃 해결
---
## 현황 요약
| 단계 | 내용 | 상태 |
|------|------|------|
| Step 1 | 현황 분석 (DXF 구조 파악) | ✅ 완료 |
| Step 2 | 성공 테스트 분석 (분할+병렬 성공 요인) | ✅ 완료 |
| Step 3 | 레전드 페이지 분석 (legend_parser.py 생성) | ✅ 완료 |
| Step 4 | 해결 방향 정의 | ✅ 완료 |
| Step 5~10 | 구현 (아래 상세 계획) | 🔲 미시작 |
---
## 핵심 전략
1. **도면 분할** — 한 DXF 파일 내 여러 도면을 좌표 기반으로 분리 (9개 도면 → 각각 독립 처리)
2. **계측기 유형별 병렬 LLM 호출** — Sensor/Valve/System/Gauge/Pump 동시 처리
3. **위치 정보 저장** — 태그의 DXF 상 좌표 + 근처 텍스트
4. **AREA 그룹핑** — 태그명에서 플랜트 번호 유추
---
## 변경 대상 파일
| 파일 | 변경 내용 |
|------|-----------|
| `mcp-server/pipeline/extractor.py` | 도면 분할 로직 추가 |
| `mcp-server/worker/pid_worker.py` | 도면 분할 + 병렬 LLM 호출 |
| `mcp-server/pipeline/mapper.py` | 기존 유지 (이미 배치 처리 구현됨) |
| `mcp-server/pipeline/topology.py` | 기존 유지 (이미 SpatialGrid 구현됨) |
| `mcp-server/pipeline/legend_parser.py` | 기존 유지 (이미 계측기 그룹 정의됨) |
---
## 상세 코딩 계획
### Phase 1: 도면 분할 로직 (extractor.py)
#### 1-1. 도면 분할 테스트 스크립트 작성
- **파일**: `test_drawing_split.py` (신규)
- **목표**: `No-10_Plant_PID.dxf`를 TITLE 레이어 LINE으로 분할하는 로직 프로토타입
- **작업 내용**:
- ezdxf로 DXF 로드
- TITLE 레이어의 LINE 엔티티 탐색
- 수직 LINE(X 좌표가 일정)을 도면 경계로 감지
- 각 도면별 X/Y 범위 출력
- **검증**: 9개 도면 영역이 올바르게 분리되는지 확인
- **완료 기준**: 콘솔에 9개 도면의 X/Y 범위가 출력됨
#### 1-2. PidGeometricExtractor에 도면 분할 메서드 추가
- **파일**: `mcp-server/pipeline/extractor.py`
- **목표**: `split_drawings()` 메서드 추가
- **작업 내용**:
- `split_drawings() -> List[DrawingRegion]` 메서드 추가
- DrawingRegion 데이터클래스 정의 (drawing_no, x_min, x_max, y_min, y_max)
- TITLE 레이어 LINE 기반 경계 감지
- 레전드 페이지(X < 2000) 제외
- FFD 페이지도 제외 (최상단 텍스트 기반)
- **완료 기준**: `split_drawings()` 호출 시 9개 DrawingRegion 반환
#### 1-3. 영역별 추출 메서드 추가
- **파일**: `mcp-server/pipeline/extractor.py`
- **목표**: `extract_region()` 메서드 추가
- **작업 내용**:
- `extract_region(region: DrawingRegion) -> List[GeometricEntity]` 메서드 추가
- bbox가 region 범위 내에 있는 엔티티만 필터링
- 기존 `extract_and_save()` 로직 재사용
- **완료 기준**: 각 도면별 엔티티 수 합계가 전체 엔티티 수와 일치
#### 1-4. 도면 분할 통합 테스트
- **파일**: `test_drawing_split.py`
- **목표**: 전체 파이프라인 테스트
- **작업 내용**:
- DXF 로드 → 분할 → 영역별 추출 → 결과 검증
- 각 도면별 엔티티 수, 태그 수 확인
- 처리 시간 측정
- **완료 기준**: 9개 도면 모두 정상 추출, 총 처리 시간 < 30초
---
### Phase 2: 계측기 유형별 병렬 LLM 호출 (pid_worker.py)
#### 2-1. 계측기 유형별 프롬프트 정의
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: 각 유형별 전용 프롬프트 상수 정의
- **작업 내용**:
- `_SENSOR_EXTRACT_SYSTEM` — FT, FIT, LT, PT, TE, PG, LG, TG 추출
- `_VALVE_EXTRACT_SYSTEM` — FCV, TCV, LCV, PCV, XV, FV, LV, PV, TV 추출
- `_SYSTEM_EXTRACT_SYSTEM` — LI, PI, TI, FIQ, FICQ, TICA, PICA, LICA 추출
- `_GAUGE_EXTRACT_SYSTEM` — PG, TG, LG 추출
- `_PUMP_EXTRACT_SYSTEM` — P-10101, VP-10117, DP-10101 등 펌프 추출
- 각 프롬프트는 `max_tokens=65536` 적용
- **완료 기준**: 5개 프롬프트 상수 정의 완료
#### 2-2. 유형별 추출 함수 작성
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: `_extract_tags_by_type()` 함수 추가
- **작업 내용**:
- `_extract_tags_by_type(text: str, type_name: str, system_prompt: str) -> List[dict]` 함수
- LLM 호출 → JSON 파싱 → 태그 목록 반환
- `max_tokens=65536` 적용
- finish_reason=length 복구 로직 포함
- **완료 기준**: 단일 유형 추출 테스트 통과
#### 2-3. 병렬 호출 로직 구현
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: `_extract_all_types_parallel()` async 함수 추가
- **작업 내용**:
- `asyncio.gather()`로 5개 유형 동시 호출
- 각 유형별 결과 통합
- 중복 태그 제거 (tagNo 기준)
- **완료 기준**: 5개 유형 병렬 호출 테스트, 총 처리 시간 < 120초
#### 2-4. `_build_pid_graph_parallel()` 리팩토링
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: 기존 함수를 도면 분할 + 병렬 LLM 호출로 변경
- **작업 내용**:
- Phase 1: 도면 분할 (extractor.split_drawings())
- Phase 2: 각 도면별 기하 추출 (extractor.extract_region())
- Phase 3: 각 도면별 유형별 병렬 LLM 호출
- Phase 4: 결과 통합 + 그래프 빌드
- Phase 5: 저장
- **완료 기준**: 기존 API 호환 유지, 처리 시간 30분 → 5분 이내
#### 2-5. 병렬 처리 통합 테스트
- **파일**: `test_parallel_extraction.py` (신규)
- **목표**: 전체 병렬 처리 파이프라인 테스트
- **작업 내용**:
- `No-10_Plant_PID.dxf` 전체 처리
- 각 단계별 시간 측정
- 추출된 태그 수, 매핑 수 확인
- **완료 기준**: 전체 처리 < 5분, 태그 추출 수 > 기존
---
### Phase 3: 위치 정보 저장
#### 3-1. 위치 정보 스키마 정의
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: 태그 추출 결과에 위치 정보 포함
- **작업 내용**:
- 각 태그에 `bbox` (min_x, min_y, max_x, max_y) 필드 추가
- `drawing_no` (도면 번호) 필드 추가
- `nearby_text` (근처 텍스트) 필드 추가
- **완료 기준**: 추출 결과 JSON에 위치 정보 포함
#### 3-2. 근처 텍스트 추출 로직
- **파일**: `mcp-server/pipeline/extractor.py`
- **목표**: `get_nearby_text()` 메서드 추가
- **작업 내용**:
- 특정 좌표 주변 threshold 이내 TEXT 엔티티 검색
- SpatialGrid 활용 (O(1) 조회)
- 상하좌우 위치 파악 (direction 필드)
- **완료 기준**: 태그 주변 텍스트 정상 추출
#### 3-3. 위치 정보 통합 테스트
- **파일**: `test_location_info.py` (신규)
- **목표**: 위치 정보 정확도 검증
- **작업 내용**:
- 알려진 태그 위치와 추출된 위치 비교
- 근처 텍스트 정확도 확인
- **완료 기준**: 위치 정보 정확도 > 90%
---
### Phase 4: AREA 그룹핑
#### 4-1. AREA 추출 로직 개선
- **파일**: `mcp-server/pipeline/legend_parser.py`
- **목표**: `extract_area_from_tag()` 정확도 향상
- **작업 내용**:
- FICQ-6113 → "6" (6호 플랜트)
- FICQ-10113 → "10" (10호 플랜트)
- 패턴 매칭 개선 (정규식 튜닝)
- **완료 기준**: 테스트 케이스 10개 모두 정확
#### 4-2. AREA별 그룹핑 함수
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: `_group_by_area()` 함수 추가
- **작업 내용**:
- 추출된 태그 목록을 AREA별로 그룹핑
- AREA 번호가 없는 태그는 "unknown" 그룹
- 그룹별 통계 출력
- **완료 기준**: AREA별 그룹핑 결과 정상 출력
---
### Phase 5: Experion 태그 매핑
#### 5-1. 기존 매핑 로직 확인
- **파일**: `mcp-server/pipeline/mapper.py`
- **목표**: 기존 IntelligentMapper 동작 확인
- **작업 내용**:
- `_batch_gather()` 배치 처리 확인 (이미 구현됨)
- RapidFuzz 기반 후보 추출 확인
- LLM 기반 최종 매핑 확인
- **완료 기준**: 기존 매핑 정확도 확인
#### 5-2. 매핑 결과 통합
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: `_build_pid_graph_parallel()`에 매핑 결과 통합
- **작업 내용**:
- Phase 3 결과 (추출된 태그)를 IntelligentMapper에 전달
- 매핑 결과를 그래프에 추가
- 매핑 통계 출력
- **완료 기준**: 매핑 결과 그래프에 정상 반영
---
### Phase 6: UI 연동
#### 6-1. API 엔드포인트 확인
- **파일**: `src/Web/Controllers/PidController.cs`
- **목표**: 기존 API 엔드포인트 확인
- **작업 내용**:
- 태그 검색 API 확인
- 위치 정보 반환 여부 확인
- **완료 기준**: API 스펙 파악
#### 6-2. 위치 정보 API 추가
- **파일**: `src/Web/Controllers/PidController.cs`
- **목표**: 태그 위치 조회 API 추가
- **작업 내용**:
- `GET /api/pid/tags/{tagName}/location` 엔드포인트 추가
- DXF 좌표, 도면 번호, 근처 텍스트 반환
- 현재 실시간 값 연동
- **완료 기준**: API 테스트 통과
#### 6-3. 프론트엔드 UI 수정
- **파일**: `src/Web/wwwroot/js/app.js`
- **목표**: 태그 검색 시 위치 표시
- **작업 내용**:
- 태그 검색 결과에 위치 정보 표시
- "6호 정제탑, 밑에서 3번째 온도 센서입니다. 현재 온도 105도" 형식
- DXF 도면 상 위치 시각화 (선택사항)
- **완료 기준**: UI에서 위치 정보 확인 가능
---
## 실행 순서 및 의존성
```
Phase 1 (도면 분할)
├── 1-1 → 1-2 → 1-3 → 1-4 (순차)
Phase 2 (병렬 LLM)
├── 2-1 → 2-2 → 2-3 → 2-4 → 2-5 (순차, Phase 1 완료 후)
Phase 3 (위치 정보)
├── 3-1 → 3-2 → 3-3 (순차, Phase 2 완료 후)
Phase 4 (AREA 그룹핑)
├── 4-1 → 4-2 (순차, 독립 실행 가능)
Phase 5 (매핑 통합)
├── 5-1 → 5-2 (순차, Phase 2+3 완료 후)
Phase 6 (UI 연동)
├── 6-1 → 6-2 → 6-3 (순차, Phase 5 완료 후)
```
---
## 각 단계 완료 기준
| 단계 | 완료 기준 | 예상 시간 |
|------|-----------|-----------|
| 1-1 | 9개 도면 영역 콘솔 출력 | 30분 |
| 1-2 | `split_drawings()` 9개 Region 반환 | 1시간 |
| 1-3 | `extract_region()` 정상 동작 | 30분 |
| 1-4 | 전체 분할 테스트 통과 | 30분 |
| 2-1 | 5개 프롬프트 상수 정의 | 30분 |
| 2-2 | 단일 유형 추출 테스트 통과 | 1시간 |
| 2-3 | 5개 유형 병렬 호출 테스트 | 1시간 |
| 2-4 | `_build_pid_graph_parallel()` 리팩토링 완료 | 2시간 |
| 2-5 | 전체 병렬 처리 테스트 통과 | 1시간 |
| 3-1 | 위치 정보 JSON 필드 추가 | 30분 |
| 3-2 | `get_nearby_text()` 구현 | 1시간 |
| 3-3 | 위치 정보 정확도 검증 | 30분 |
| 4-1 | AREA 추출 테스트 10개 통과 | 30분 |
| 4-2 | AREA별 그룹핑 결과 출력 | 30분 |
| 5-1 | 기존 매핑 정확도 확인 | 30분 |
| 5-2 | 매핑 결과 그래프 반영 | 1시간 |
| 6-1 | API 스펙 파악 | 30분 |
| 6-2 | 위치 조회 API 추가 | 1시간 |
| 6-3 | UI 수정 완료 | 1시간 |
**총 예상 시간: 약 18시간**
---
## 체크리스트
### Phase 1: 도면 분할
- [ ] 1-1. 도면 분할 테스트 스크립트 작성
- [ ] 1-2. PidGeometricExtractor에 도면 분할 메서드 추가
- [ ] 1-3. 영역별 추출 메서드 추가
- [ ] 1-4. 도면 분할 통합 테스트
### Phase 2: 계측기 유형별 병렬 LLM 호출
- [ ] 2-1. 계측기 유형별 프롬프트 정의
- [ ] 2-2. 유형별 추출 함수 작성
- [ ] 2-3. 병렬 호출 로직 구현
- [ ] 2-4. `_build_pid_graph_parallel()` 리팩토링
- [ ] 2-5. 병렬 처리 통합 테스트
### Phase 3: 위치 정보 저장
- [ ] 3-1. 위치 정보 스키마 정의
- [ ] 3-2. 근처 텍스트 추출 로직
- [ ] 3-3. 위치 정보 통합 테스트
### Phase 4: AREA 그룹핑
- [ ] 4-1. AREA 추출 로직 개선
- [ ] 4-2. AREA별 그룹핑 함수
### Phase 5: Experion 태그 매핑
- [ ] 5-1. 기존 매핑 로직 확인
- [ ] 5-2. 매핑 결과 통합
### Phase 6: UI 연동
- [ ] 6-1. API 엔드포인트 확인
- [ ] 6-2. 위치 정보 API 추가
- [ ] 6-3. 프론트엔드 UI 수정
---
## 주의 사항
1. **백업 필수**: 각 파일 수정 전 `.rooBackup/`에 백업
2. **diff 제시**: 변경 내용 diff 형식으로 제시 후 확인
3. **작은 단계**: 각 단계를 독립적으로 완료하고 검증
4. **테스트 우선**: 테스트 스크립트 먼저 작성 후 구현
5. **기존 코드 유지**: topology.py, mapper.py는 기존 유지 (이미 잘 구현됨)
---
## 다음 시작 시
1. 이 파일의 체크리스트에서 첫 번째 미완료 항목부터 시작
2. 각 단계 완료 시 체크리스트 업데이트
3. 문제가 발생하면 해당 단계에서 중단하고 원인 분석
4. 완료 기준을 충족해야 다음 단계로 진행

View File

@@ -0,0 +1,350 @@
# P&ID 재설계 - 코딩 계획
> 작성일: 2026-05-04
> 상태: 진행 중
> 목표: `No-10_Plant_PID.dxf`(28,819 엔티티) 처리 시 30분 타임아웃 해결
---
## 현황 요약
| 단계 | 내용 | 상태 |
|------|------|------|
| Step 1 | 현황 분석 (DXF 구조 파악) | ✅ 완료 |
| Step 2 | 성공 테스트 분석 (분할+병렬 성공 요인) | ✅ 완료 |
| Step 3 | 레전드 페이지 분석 (legend_parser.py 생성) | ✅ 완료 |
| Step 4 | 해결 방향 정의 | ✅ 완료 |
| Step 5~10 | 구현 (아래 상세 계획) | 🔲 미시작 |
---
## 핵심 전략
1. **도면 분할** — 한 DXF 파일 내 여러 도면을 좌표 기반으로 분리 (9개 도면 → 각각 독립 처리)
2. **계측기 유형별 병렬 LLM 호출** — Sensor/Valve/System/Gauge/Pump 동시 처리
3. **위치 정보 저장** — 태그의 DXF 상 좌표 + 근처 텍스트
4. **AREA 그룹핑** — 태그명에서 플랜트 번호 유추
---
## 변경 대상 파일
| 파일 | 변경 내용 |
|------|-----------|
| `mcp-server/pipeline/extractor.py` | 도면 분할 로직 추가 |
| `mcp-server/worker/pid_worker.py` | 도면 분할 + 병렬 LLM 호출 |
| `mcp-server/pipeline/mapper.py` | 기존 유지 (이미 배치 처리 구현됨) |
| `mcp-server/pipeline/topology.py` | 기존 유지 (이미 SpatialGrid 구현됨) |
| `mcp-server/pipeline/legend_parser.py` | 기존 유지 (이미 계측기 그룹 정의됨) |
---
## 상세 코딩 계획
### Phase 1: 도면 분할 로직 (extractor.py)
#### 1-1. 도면 분할 테스트 스크립트 작성
- **파일**: `test_drawing_split.py` (신규)
- **목표**: `No-10_Plant_PID.dxf`를 TITLE 레이어 LINE으로 분할하는 로직 프로토타입
- **작업 내용**:
- ezdxf로 DXF 로드
- TITLE 레이어의 LINE 엔티티 탐색
- 수직 LINE(X 좌표가 일정)을 도면 경계로 감지
- 각 도면별 X/Y 범위 출력
- **검증**: 9개 도면 영역이 올바르게 분리되는지 확인
- **완료 기준**: 콘솔에 9개 도면의 X/Y 범위가 출력됨
#### 1-2. PidGeometricExtractor에 도면 분할 메서드 추가
- **파일**: `mcp-server/pipeline/extractor.py`
- **목표**: `split_drawings()` 메서드 추가
- **작업 내용**:
- `split_drawings() -> List[DrawingRegion]` 메서드 추가
- DrawingRegion 데이터클래스 정의 (drawing_no, x_min, x_max, y_min, y_max)
- TITLE 레이어 LINE 기반 경계 감지
- 레전드 페이지(X < 2000) 제외
- FFD 페이지도 제외 (최상단 텍스트 기반)
- **완료 기준**: `split_drawings()` 호출 시 9개 DrawingRegion 반환
#### 1-3. 영역별 추출 메서드 추가
- **파일**: `mcp-server/pipeline/extractor.py`
- **목표**: `extract_region()` 메서드 추가
- **작업 내용**:
- `extract_region(region: DrawingRegion) -> List[GeometricEntity]` 메서드 추가
- bbox가 region 범위 내에 있는 엔티티만 필터링
- 기존 `extract_and_save()` 로직 재사용
- **완료 기준**: 각 도면별 엔티티 수 합계가 전체 엔티티 수와 일치
#### 1-4. 도면 분할 통합 테스트
- **파일**: `test_drawing_split.py`
- **목표**: 전체 파이프라인 테스트
- **작업 내용**:
- DXF 로드 → 분할 → 영역별 추출 → 결과 검증
- 각 도면별 엔티티 수, 태그 수 확인
- 처리 시간 측정
- **완료 기준**: 9개 도면 모두 정상 추출, 총 처리 시간 < 30초
---
### Phase 2: 계측기 유형별 병렬 LLM 호출 (pid_worker.py)
#### 2-1. 계측기 유형별 프롬프트 정의
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: 각 유형별 전용 프롬프트 상수 정의
- **작업 내용**:
- `_SENSOR_EXTRACT_SYSTEM` — FT, FIT, LT, PT, TE, PG, LG, TG 추출
- `_VALVE_EXTRACT_SYSTEM` — FCV, TCV, LCV, PCV, XV, FV, LV, PV, TV 추출
- `_SYSTEM_EXTRACT_SYSTEM` — LI, PI, TI, FIQ, FICQ, TICA, PICA, LICA 추출
- `_GAUGE_EXTRACT_SYSTEM` — PG, TG, LG 추출
- `_PUMP_EXTRACT_SYSTEM` — P-10101, VP-10117, DP-10101 등 펌프 추출
- 각 프롬프트는 `max_tokens=65536` 적용
- **완료 기준**: 5개 프롬프트 상수 정의 완료
#### 2-2. 유형별 추출 함수 작성
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: `_extract_tags_by_type()` 함수 추가
- **작업 내용**:
- `_extract_tags_by_type(text: str, type_name: str, system_prompt: str) -> List[dict]` 함수
- LLM 호출 → JSON 파싱 → 태그 목록 반환
- `max_tokens=65536` 적용
- finish_reason=length 복구 로직 포함
- **완료 기준**: 단일 유형 추출 테스트 통과
#### 2-3. 병렬 호출 로직 구현
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: `_extract_all_types_parallel()` async 함수 추가
- **작업 내용**:
- `asyncio.gather()`로 5개 유형 동시 호출
- 각 유형별 결과 통합
- 중복 태그 제거 (tagNo 기준)
- **완료 기준**: 5개 유형 병렬 호출 테스트, 총 처리 시간 < 120초
#### 2-4. `_build_pid_graph_parallel()` 리팩토링
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: 기존 함수를 도면 분할 + 병렬 LLM 호출로 변경
- **작업 내용**:
- Phase 1: 도면 분할 (extractor.split_drawings())
- Phase 2: 각 도면별 기하 추출 (extractor.extract_region())
- Phase 3: 각 도면별 유형별 병렬 LLM 호출
- Phase 4: 결과 통합 + 그래프 빌드
- Phase 5: 저장
- **완료 기준**: 기존 API 호환 유지, 처리 시간 30분 → 5분 이내
#### 2-5. 병렬 처리 통합 테스트
- **파일**: `test_parallel_extraction.py` (신규)
- **목표**: 전체 병렬 처리 파이프라인 테스트
- **작업 내용**:
- `No-10_Plant_PID.dxf` 전체 처리
- 각 단계별 시간 측정
- 추출된 태그 수, 매핑 수 확인
- **완료 기준**: 전체 처리 < 5분, 태그 추출 수 > 기존
---
### Phase 3: 위치 정보 저장
#### 3-1. 위치 정보 스키마 정의
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: 태그 추출 결과에 위치 정보 포함
- **작업 내용**:
- 각 태그에 `bbox` (min_x, min_y, max_x, max_y) 필드 추가
- `drawing_no` (도면 번호) 필드 추가
- `nearby_text` (근처 텍스트) 필드 추가
- **완료 기준**: 추출 결과 JSON에 위치 정보 포함
#### 3-2. 근처 텍스트 추출 로직
- **파일**: `mcp-server/pipeline/extractor.py`
- **목표**: `get_nearby_text()` 메서드 추가
- **작업 내용**:
- 특정 좌표 주변 threshold 이내 TEXT 엔티티 검색
- SpatialGrid 활용 (O(1) 조회)
- 상하좌우 위치 파악 (direction 필드)
- **완료 기준**: 태그 주변 텍스트 정상 추출
#### 3-3. 위치 정보 통합 테스트
- **파일**: `test_location_info.py` (신규)
- **목표**: 위치 정보 정확도 검증
- **작업 내용**:
- 알려진 태그 위치와 추출된 위치 비교
- 근처 텍스트 정확도 확인
- **완료 기준**: 위치 정보 정확도 > 90%
---
### Phase 4: AREA 그룹핑
#### 4-1. AREA 추출 로직 개선
- **파일**: `mcp-server/pipeline/legend_parser.py`
- **목표**: `extract_area_from_tag()` 정확도 향상
- **작업 내용**:
- FICQ-6113 → "6" (6호 플랜트)
- FICQ-10113 → "10" (10호 플랜트)
- 패턴 매칭 개선 (정규식 튜닝)
- **완료 기준**: 테스트 케이스 10개 모두 정확
#### 4-2. AREA별 그룹핑 함수
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: `_group_by_area()` 함수 추가
- **작업 내용**:
- 추출된 태그 목록을 AREA별로 그룹핑
- AREA 번호가 없는 태그는 "unknown" 그룹
- 그룹별 통계 출력
- **완료 기준**: AREA별 그룹핑 결과 정상 출력
---
### Phase 5: Experion 태그 매핑
#### 5-1. 기존 매핑 로직 확인
- **파일**: `mcp-server/pipeline/mapper.py`
- **목표**: 기존 IntelligentMapper 동작 확인
- **작업 내용**:
- `_batch_gather()` 배치 처리 확인 (이미 구현됨)
- RapidFuzz 기반 후보 추출 확인
- LLM 기반 최종 매핑 확인
- **완료 기준**: 기존 매핑 정확도 확인
#### 5-2. 매핑 결과 통합
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: `_build_pid_graph_parallel()`에 매핑 결과 통합
- **작업 내용**:
- Phase 3 결과 (추출된 태그)를 IntelligentMapper에 전달
- 매핑 결과를 그래프에 추가
- 매핑 통계 출력
- **완료 기준**: 매핑 결과 그래프에 정상 반영
---
### Phase 6: UI 연동
#### 6-1. API 엔드포인트 확인
- **파일**: `src/Web/Controllers/PidController.cs`
- **목표**: 기존 API 엔드포인트 확인
- **작업 내용**:
- 태그 검색 API 확인
- 위치 정보 반환 여부 확인
- **완료 기준**: API 스펙 파악
#### 6-2. 위치 정보 API 추가
- **파일**: `src/Web/Controllers/PidController.cs`
- **목표**: 태그 위치 조회 API 추가
- **작업 내용**:
- `GET /api/pid/tags/{tagName}/location` 엔드포인트 추가
- DXF 좌표, 도면 번호, 근처 텍스트 반환
- 현재 실시간 값 연동
- **완료 기준**: API 테스트 통과
#### 6-3. 프론트엔드 UI 수정
- **파일**: `src/Web/wwwroot/js/app.js`
- **목표**: 태그 검색 시 위치 표시
- **작업 내용**:
- 태그 검색 결과에 위치 정보 표시
- "6호 정제탑, 밑에서 3번째 온도 센서입니다. 현재 온도 105도" 형식
- DXF 도면 상 위치 시각화 (선택사항)
- **완료 기준**: UI에서 위치 정보 확인 가능
---
## 실행 순서 및 의존성
```
Phase 1 (도면 분할)
├── 1-1 → 1-2 → 1-3 → 1-4 (순차)
Phase 2 (병렬 LLM)
├── 2-1 → 2-2 → 2-3 → 2-4 → 2-5 (순차, Phase 1 완료 후)
Phase 3 (위치 정보)
├── 3-1 → 3-2 → 3-3 (순차, Phase 2 완료 후)
Phase 4 (AREA 그룹핑)
├── 4-1 → 4-2 (순차, 독립 실행 가능)
Phase 5 (매핑 통합)
├── 5-1 → 5-2 (순차, Phase 2+3 완료 후)
Phase 6 (UI 연동)
├── 6-1 → 6-2 → 6-3 (순차, Phase 5 완료 후)
```
---
## 각 단계 완료 기준
| 단계 | 완료 기준 | 예상 시간 |
|------|-----------|-----------|
| 1-1 | 9개 도면 영역 콘솔 출력 | 30분 |
| 1-2 | `split_drawings()` 9개 Region 반환 | 1시간 |
| 1-3 | `extract_region()` 정상 동작 | 30분 |
| 1-4 | 전체 분할 테스트 통과 | 30분 |
| 2-1 | 5개 프롬프트 상수 정의 | 30분 |
| 2-2 | 단일 유형 추출 테스트 통과 | 1시간 |
| 2-3 | 5개 유형 병렬 호출 테스트 | 1시간 |
| 2-4 | `_build_pid_graph_parallel()` 리팩토링 완료 | 2시간 |
| 2-5 | 전체 병렬 처리 테스트 통과 | 1시간 |
| 3-1 | 위치 정보 JSON 필드 추가 | 30분 |
| 3-2 | `get_nearby_text()` 구현 | 1시간 |
| 3-3 | 위치 정보 정확도 검증 | 30분 |
| 4-1 | AREA 추출 테스트 10개 통과 | 30분 |
| 4-2 | AREA별 그룹핑 결과 출력 | 30분 |
| 5-1 | 기존 매핑 정확도 확인 | 30분 |
| 5-2 | 매핑 결과 그래프 반영 | 1시간 |
| 6-1 | API 스펙 파악 | 30분 |
| 6-2 | 위치 조회 API 추가 | 1시간 |
| 6-3 | UI 수정 완료 | 1시간 |
**총 예상 시간: 약 18시간**
---
## 체크리스트
### Phase 1: 도면 분할
- [ ] 1-1. 도면 분할 테스트 스크립트 작성
- [ ] 1-2. PidGeometricExtractor에 도면 분할 메서드 추가
- [ ] 1-3. 영역별 추출 메서드 추가
- [ ] 1-4. 도면 분할 통합 테스트
### Phase 2: 계측기 유형별 병렬 LLM 호출
- [ ] 2-1. 계측기 유형별 프롬프트 정의
- [ ] 2-2. 유형별 추출 함수 작성
- [ ] 2-3. 병렬 호출 로직 구현
- [ ] 2-4. `_build_pid_graph_parallel()` 리팩토링
- [ ] 2-5. 병렬 처리 통합 테스트
### Phase 3: 위치 정보 저장
- [ ] 3-1. 위치 정보 스키마 정의
- [ ] 3-2. 근처 텍스트 추출 로직
- [ ] 3-3. 위치 정보 통합 테스트
### Phase 4: AREA 그룹핑
- [ ] 4-1. AREA 추출 로직 개선
- [ ] 4-2. AREA별 그룹핑 함수
### Phase 5: Experion 태그 매핑
- [ ] 5-1. 기존 매핑 로직 확인
- [ ] 5-2. 매핑 결과 통합
### Phase 6: UI 연동
- [ ] 6-1. API 엔드포인트 확인
- [ ] 6-2. 위치 정보 API 추가
- [ ] 6-3. 프론트엔드 UI 수정
---
## 주의 사항
1. **백업 필수**: 각 파일 수정 전 `.rooBackup/`에 백업
2. **diff 제시**: 변경 내용 diff 형식으로 제시 후 확인
3. **작은 단계**: 각 단계를 독립적으로 완료하고 검증
4. **테스트 우선**: 테스트 스크립트 먼저 작성 후 구현
5. **기존 코드 유지**: topology.py, mapper.py는 기존 유지 (이미 잘 구현됨)
---
## 다음 시작 시
1. 이 파일의 체크리스트에서 첫 번째 미완료 항목부터 시작
2. 각 단계 완료 시 체크리스트 업데이트
3. 문제가 발생하면 해당 단계에서 중단하고 원인 분석
4. 완료 기준을 충족해야 다음 단계로 진행

View File

@@ -0,0 +1,367 @@
# P&ID 재설계 - 코딩 계획 (독립 프로세스 병렬 아키텍처)
> 작성일: 2026-05-05
> 상태: 진행 중
> 목표: `No-10_Plant_PID.dxf`(28,819 엔티티) 처리 시 30분 타임아웃 해결
> 아키텍처: pid_worker.py(코디네이터) + 5개 독립 추출 프로세스
---
## 제안 아키텍처
```
┌─────────────────────────────────────────────────────────────────┐
│ pid_worker.py (코디네이터) │
│ │
│ Phase 1: 도면 분할 + 기하 추출 (순차) │
│ ├─ extractor.split_drawings() → 9개 도면 영역 │
│ └─ extractor.extract_and_save() → geo.json │
│ │
│ Phase 2: 전체 텍스트 1회 추출 │
│ └─ DXF에서 TEXT/MTEXT → full_text.txt (1회만 읽기) │
│ │
│ Phase 3: 5개 독립 프로세스 병렬 실행 ← 진짜 병렬 │
│ ├─ subprocess: pid_extract_sensor.py → results/sensor.json │
│ ├─ subprocess: pid_extract_valve.py → results/valve.json │
│ ├─ subprocess: pid_extract_system.py → results/system.json │
│ ├─ subprocess: pid_extract_gauge.py → results/gauge.json │
│ └─ subprocess: pid_extract_pump.py → results/pump.json │
│ │
│ Phase 4: pid_worker가 결과 파일 읽어서 통합 │
│ ├─ 5개 JSON 파일 로드 │
│ ├─ 중복 제거 (tagNo 기준) │
│ └─ 위상 그래프 빌드 + 태그 매핑 │
│ │
│ Phase 5: 저장 + 응답 │
└─────────────────────────────────────────────────────────────────┘
```
## 기존 접근 vs 개선안 비교
| 항목 | 기존 (asyncio.gather) | 개선안 (독립 프로세스) |
|------|----------------------|----------------------|
| vLLM 요청 | 단일 프로세스 → 단일 GPU | 5개 프로세스 → 5개 GPU/큐 병렬 |
| 실제 병렬 | ❌ 가짜 (순차 처리) | ✅ 진짜 동시 처리 |
| 메모리 | pid_worker가 모든 텍스트 보유 | 각 프로세스 독립 |
| 실패 격리 | 하나 실패 → 전체 실패 | 하나 실패 → 나머지 결과 활용 |
| 테스트 | 통합 테스트만 가능 | 각 추출기 독립 테스트 가능 |
---
## 변경 대상 파일
| 파일 | 변경 내용 |
|------|-----------|
| `mcp-server/pipeline/extractor.py` | 도면 분할 로직 추가 |
| `mcp-server/worker/pid_worker.py` | 코디네이터 로직: 프로세스 관리, 결과 통합 |
| `mcp-server/worker/pid_extract_sensor.py` | 신규: 센서 전용 추출기 (독립 프로세스) |
| `mcp-server/worker/pid_extract_valve.py` | 신규: 밸브 전용 추출기 (독립 프로세스) |
| `mcp-server/worker/pid_extract_system.py` | 신규: 시스템 전용 추출기 (독립 프로세스) |
| `mcp-server/worker/pid_extract_gauge.py` | 신규: 게이지 전용 추출기 (독립 프로세스) |
| `mcp-server/worker/pid_extract_pump.py` | 신규: 펌프 전용 추출기 (독립 프로세스) |
| `mcp-server/pipeline/mapper.py` | 기존 유지 (이미 배치 처리 구현됨) |
| `mcp-server/pipeline/topology.py` | 기존 유지 (이미 SpatialGrid 구현됨) |
| `mcp-server/pipeline/legend_parser.py` | 기존 유지 (이미 계측기 그룹 정의됨) |
---
## Phase 2: 독립 추출기 공통 템플릿
### 2-1. 공통 추출기 템플릿 작성
- **파일**: `mcp-server/worker/pid_extract_template.py` (신규)
- **목표**: 5개 추출기가 공유하는 공통 로직 템플릿
- **작업 내용**:
- CLI 인자 파싱 (input_text, output_path, system_prompt)
- vLLM HTTP 클라이언트 연결 (환경 변수 VLLM_ENDPOINT)
- LLM 호출 → JSON 파싱 → 태그 목록 반환
- `max_tokens=65536` 적용
- finish_reason=length 복구 로직
- 결과를 JSON 파일로 쓰기
- **완료 기준**: 템플릿 스크립트가 단독 실행 가능
### 2-2. 계측기 유형별 프롬프트 정의
- **파일**: 각 추출기 스크립트 상단에 상수로 정의
- **목표**: 각 유형별 전용 프롬프트
- **프롬프트 목록**:
- `_SENSOR_PROMPT` — FT, FIT, LT, PT, TE, PG, LG, TG
- `_VALVE_PROMPT` — FCV, TCV, LCV, PCV, XV, FV, LV, PV, TV
- `_SYSTEM_PROMPT` — LI, PI, TI, FIQ, FICQ, TICA, PICA, LICA
- `_GAUGE_PROMPT` — PG, TG, LG
- `_PUMP_PROMPT` — P-10101, VP-10117, DP-10101 등 펌프
- **완료 기준**: 5개 프롬프트 상수 정의 완료
---
## Phase 3: 5개 독립 추출기 스크립트 생성
### 3-1. pid_extract_sensor.py
- **파일**: `mcp-server/worker/pid_extract_sensor.py` (신규)
- **목표**: 센서/계측기 전용 추출
- **작업 내용**: Phase 2 템플릿 기반, _SENSOR_PROMPT 적용
- **완료 기준**: 단독 실행 시 sensor.json 출력
### 3-2. pid_extract_valve.py
- **파일**: `mcp-server/worker/pid_extract_valve.py` (신규)
- **목표**: 밸브 전용 추출
- **작업 내용**: Phase 2 템플릿 기반, _VALVE_PROMPT 적용
- **완료 기준**: 단독 실행 시 valve.json 출력
### 3-3. pid_extract_system.py
- **파일**: `mcp-server/worker/pid_extract_system.py` (신규)
- **목표**: 시스템/제어기 전용 추출
- **작업 내용**: Phase 2 템플릿 기반, _SYSTEM_PROMPT 적용
- **완료 기준**: 단독 실행 시 system.json 출력
### 3-4. pid_extract_gauge.py
- **파일**: `mcp-server/worker/pid_extract_gauge.py` (신규)
- **목표**: 게이지 전용 추출
- **작업 내용**: Phase 2 템플릿 기반, _GAUGE_PROMPT 적용
- **완료 기준**: 단독 실행 시 gauge.json 출력
### 3-5. pid_extract_pump.py
- **파일**: `mcp-server/worker/pid_extract_pump.py` (신규)
- **목표**: 펌프 전용 추출
- **작업 내용**: Phase 2 템플릿 기반, _PUMP_PROMPT 적용
- **완료 기준**: 단독 실행 시 pump.json 출력
### 3-6. 개별 추출기 테스트
- **파일**: `test_individual_extractors.py` (신규)
- **목표**: 5개 추출기 각각 단독 실행 테스트
- **작업 내용**:
- full_text.txt를 각 추출기에 입력
- 출력 JSON 검증 (schema, tagNo 필수 필드)
- 처리 시간 측정
- **완료 기준**: 5개 모두 정상 출력, 각각 < 60초
---
## Phase 4: pid_worker.py 코디네이터 리팩토링
### 4-1. 전체 텍스트 1회 추출 로직
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: DXF에서 TEXT/MTEXT를 한 번만 읽어 full_text.txt 생성
- **작업 내용**:
- ezdxf로 DXF 로드 → TEXT/MTEXT 엔티티 순회
- 좌표 순 정렬 후 텍스트 연결
- 결과를 임시 디렉토리의 `full_text.txt`에 저장
- **완료 기준**: full_text.txt 생성, 파일 크기 < 10MB
### 4-2. 5개 프로세스 병렬 실행 로직
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: `subprocess.Popen`으로 5개 추출기 동시 실행
- **작업 내용**:
- 임시 디렉토리 생성 (`results/` 폴더)
- 5개 스크립트 경로 확인 (동일 디렉토리)
- `subprocess.Popen()`으로 5개 프로세스 동시 시작
- `proc.wait()`로 전체 완료 대기 (timeout=300초)
- 각 프로세스 returncode 확인 (실패 시 로깅 + 계속)
- **완료 기준**: 5개 프로세스 동시 실행, results/에 5개 JSON 생성
### 4-3. 결과 통합 + 중복 제거
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: 5개 JSON 파일 로드 → tagNo 기준 중복 제거
- **작업 내용**:
- `results/*.json` 로드 (실패한 파일 스킵)
- tagNo를 키로 하는 딕셔너리에 병합 (첫 번째 우선)
- 통합 통계 출력 (총 태그 수, 유형별 수)
- **완료 기준**: 중복 없는 통합 태그 목록 생성
### 4-4. 위상 그래프 빌드 + 태그 매핑 호출
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: 기존 topology.py, mapper.py 호출
- **작업 내용**:
- 통합 태그 목록을 `IntelligentMapper`에 전달
- 매핑 결과를 `TopologyBuilder`에 전달
- 그래프 JSON 생성
- **완료 기준**: 기존 API 호환 유지
### 4-5. `_build_pid_graph_parallel()` 전체 리팩토링
- **파일**: `mcp-server/worker/pid_worker.py`
- **목표**: 기존 함수를 새 아키텍처로 교체
- **전체 흐름**:
```
1. 도면 분할 (extractor.split_drawings())
2. 기하 추출 (extractor.extract_and_save() → geo.json)
3. 전체 텍스트 1회 추출 (→ full_text.txt)
4. 5개 프로세스 병렬 실행 (→ results/*.json)
5. 결과 통합 + 중복 제거
6. 위상 그래프 빌드 + 태그 매핑
7. 저장 + 응답
```
- **완료 기준**: 전체 처리 < 5분, 기존 API 호환
---
## Phase 1: 도면 분할 로직 (extractor.py)
### 1-1. 도면 분할 테스트 스크립트 작성
- **파일**: `test_drawing_split.py` (신규)
- **목표**: `No-10_Plant_PID.dxf`를 TITLE 레이어 LINE으로 분할하는 로직 프로토타입
- **작업 내용**:
- ezdxf로 DXF 로드
- TITLE 레이어의 LINE 엔티티 탐색
- 수직 LINE(X 좌표가 일정)을 도면 경계로 감지
- 각 도면별 X/Y 범위 출력
- **검증**: 9개 도면 영역이 올바르게 분리되는지 확인
- **완료 기준**: 콘솔에 9개 도면의 X/Y 범위가 출력됨
### 1-2. PidGeometricExtractor에 도면 분할 메서드 추가
- **파일**: `mcp-server/pipeline/extractor.py`
- **목표**: `split_drawings()` 메서드 추가
- **작업 내용**:
- `split_drawings() -> List[DrawingRegion]` 메서드 추가
- DrawingRegion 데이터클래스 정의 (drawing_no, x_min, x_max, y_min, y_max)
- TITLE 레이어 LINE 기반 경계 감지
- 레전드 페이지(X < 2000) 제외
- FFD 페이지도 제외 (최상단 텍스트 기반)
- **완료 기준**: `split_drawings()` 호출 시 9개 DrawingRegion 반환
### 1-3. 영역별 추출 메서드 추가
- **파일**: `mcp-server/pipeline/extractor.py`
- **목표**: `extract_region()` 메서드 추가
- **작업 내용**:
- `extract_region(region: DrawingRegion) -> List[GeometricEntity]` 메서드 추가
- bbox가 region 범위 내에 있는 엔티티만 필터링
- 기존 `extract_and_save()` 로직 재사용
- **완료 기준**: 각 도면별 엔티티 수 합계가 전체 엔티티 수와 일치
### 1-4. 도면 분할 통합 테스트
- **파일**: `test_drawing_split.py`
- **목표**: 전체 파이프라인 테스트
- **작업 내용**:
- DXF 로드 → 분할 → 영역별 추출 → 결과 검증
- 각 도면별 엔티티 수, 태그 수 확인
- 처리 시간 측정
- **완료 기준**: 9개 도면 모두 정상 추출, 총 처리 시간 < 30초
---
## Phase 5: 통합 테스트 + 검증
### 5-1. 전체 파이프라인 통합 테스트
- **파일**: `test_full_pipeline_parallel.py` (신규)
- **목표**: `No-10_Plant_PID.dxf` 전체 처리
- **작업 내용**:
- `_build_pid_graph_parallel()` 호출
- 각 단계별 시간 측정 (Phase 1~5)
- 추출된 태그 수, 매핑 수, 그래프 노드/에지 수 확인
- **완료 기준**: 전체 처리 < 5분, 태그 추출 수 >= 기존
### 5-2. 실패 격리 테스트
- **파일**: `test_failure_isolation.py` (신규)
- **목표**: 일부 추출기 실패 시 나머지 결과 활용 확인
- **작업 내용**:
- 하나의 추출기 스크립트 고의 실패 유도
- 나머지 4개 결과로 정상 통합되는지 확인
- 에러 로깅 확인
- **완료 기준**: 4/5 성공 시 정상 통합, 에러 로그 출력
### 5-3. 메모리 사용량 측정
- **파일**: `test_memory_usage.py` (신규)
- **목표**: 각 프로세스 메모리 독립성 확인
- **작업 내용**:
- 5개 프로세스 각각 메모리 사용량 측정
- pid_worker 메모리 사용량 확인 (텍스트 불보유)
- **완료 기준**: pid_worker 메모리 < 500MB, 각 추출기 < 1GB
---
## 실행 순서 및 의존성
```
Phase 1 (도면 분할)
├── 1-1 → 1-2 → 1-3 → 1-4 (순차)
Phase 2 (공통 템플릿)
├── 2-1 → 2-2 (순차, Phase 1 완료 후)
Phase 3 (5개 추출기 생성)
├── 3-1 ~ 3-5 (병렬 가능, Phase 2 완료 후)
├── 3-6 (순차, 3-1~3-5 완료 후)
Phase 4 (pid_worker 코디네이터)
├── 4-1 → 4-2 → 4-3 → 4-4 → 4-5 (순차, Phase 3 완료 후)
Phase 5 (통합 테스트)
├── 5-1 → 5-2 → 5-3 (순차, Phase 4 완료 후)
```
---
## 각 단계 완료 기준 및 예상 시간
| 단계 | 완료 기준 | 예상 시간 |
|------|-----------|-----------|
| 1-1 | 9개 도면 영역 콘솔 출력 | 30분 |
| 1-2 | `split_drawings()` 9개 Region 반환 | 1시간 |
| 1-3 | `extract_region()` 정상 동작 | 30분 |
| 1-4 | 전체 분할 테스트 통과 | 30분 |
| 2-1 | 템플릿 스크립트 단독 실행 가능 | 1시간 |
| 2-2 | 5개 프롬프트 상수 정의 | 30분 |
| 3-1~3-5 | 5개 추출기 각각 JSON 출력 | 각 30분 (병렬 가능) |
| 3-6 | 5개 모두 단독 테스트 통과 | 30분 |
| 4-1 | full_text.txt 생성 | 30분 |
| 4-2 | 5개 프로세스 동시 실행 | 1시간 |
| 4-3 | 중복 없는 통합 태그 목록 | 30분 |
| 4-4 | 그래프 빌드 + 매핑 호출 | 30분 |
| 4-5 | 전체 리팩토링 완료, API 호환 | 1시간 |
| 5-1 | 전체 처리 < 5분 | 1시간 |
| 5-2 | 실패 격리 테스트 통과 | 30분 |
| 5-3 | 메모리 사용량 확인 | 30분 |
**총 예상 시간: 약 14시간**
---
## 체크리스트
### Phase 1: 도면 분할
- [ ] 1-1. 도면 분할 테스트 스크립트 작성
- [ ] 1-2. PidGeometricExtractor에 도면 분할 메서드 추가
- [ ] 1-3. 영역별 추출 메서드 추가
- [ ] 1-4. 도면 분할 통합 테스트
### Phase 2: 독립 추출기 공통 템플릿
- [ ] 2-1. 공통 추출기 템플릿 작성
- [ ] 2-2. 계측기 유형별 프롬프트 정의
### Phase 3: 5개 독립 추출기 스크립트
- [ ] 3-1. pid_extract_sensor.py
- [ ] 3-2. pid_extract_valve.py
- [ ] 3-3. pid_extract_system.py
- [ ] 3-4. pid_extract_gauge.py
- [ ] 3-5. pid_extract_pump.py
- [ ] 3-6. 개별 추출기 테스트
### Phase 4: pid_worker.py 코디네이터
- [ ] 4-1. 전체 텍스트 1회 추출 로직
- [ ] 4-2. 5개 프로세스 병렬 실행 로직
- [ ] 4-3. 결과 통합 + 중복 제거
- [ ] 4-4. 위상 그래프 빌드 + 태그 매핑 호출
- [ ] 4-5. `_build_pid_graph_parallel()` 전체 리팩토링
### Phase 5: 통합 테스트
- [ ] 5-1. 전체 파이프라인 통합 테스트
- [ ] 5-2. 실패 격리 테스트
- [ ] 5-3. 메모리 사용량 측정
---
## 주의 사항
1. **백업 필수**: 각 파일 수정 전 `.rooBackup/`에 백업
2. **diff 제시**: 변경 내용 diff 형식으로 제시 후 확인
3. **작은 단계**: 각 단계를 독립적으로 완료하고 검증
4. **테스트 우선**: 테스트 스크립트 먼저 작성 후 구현
5. **기존 코드 유지**: topology.py, mapper.py는 기존 유지 (이미 잘 구현됨)
6. **프로세스 간 통신**: 파일 기반 (JSON)으로만 통신. 공유 메모리 금지
7. **임시 파일 정리**: 각 요청 완료 후 임시 디렉토리 삭제
---
## 다음 시작 시
1. 이 파일의 체크리스트에서 첫 번째 미완료 항목부터 시작
2. 각 단계 완료 시 체크리스트 업데이트
3. 문제가 발생하면 해당 단계에서 중단하고 원인 분석
4. 완료 기준을 충족해야 다음 단계로 진행

View File

@@ -0,0 +1,528 @@
#!/usr/bin/env python3
"""P&ID 파싱 전용 워커 프로세스
Usage: python pid_worker.py <port>
담당 도구:
extract_pid_tags, match_pid_tags,
parse_pid_dxf, parse_pid_pdf, parse_pid_drawing,
build_pid_graph_parallel, analyze_pid_impact
"""
from __future__ import annotations
import sys
import os
# mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import io
import json
import asyncio
import signal
import logging
import re
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
# ── 설정 ─────────────────────────────────────────────────────────────────────
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
_SERVER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STORAGE_DIR = os.path.join(_SERVER_DIR, "storage")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [pid_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── 싱글톤 ───────────────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm():
from openai import OpenAI
return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
@lru_cache(maxsize=1)
def _ocr():
from paddleocr import PaddleOCR
use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true"
try:
return PaddleOCR(use_angle_cls=True, lang="korean", use_gpu=use_gpu, show_log=False)
except Exception:
if use_gpu:
os.environ["PADDLE_USE_GPU"] = "false"
return _ocr()
raise
# ── DB ───────────────────────────────────────────────────────────────────────
def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
# ── 텍스트 추출 ──────────────────────────────────────────────────────────────
def _extract_text_from_dxf(filepath: str) -> str:
import ezdxf
from ezdxf.tools.text import plain_mtext
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
texts = []
for entity in msp:
if entity.dxftype() == "TEXT":
texts.append(entity.dxf.text)
elif entity.dxftype() == "MTEXT":
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
return "\n".join(texts)
def _extract_text_from_pdf(filepath: str) -> str:
import fitz
doc = fitz.open(filepath)
return "\n".join(page.get_text() for page in doc)
def _extract_text_from_pdf_ocr(filepath: str) -> str:
import fitz
from PIL import Image
import numpy as np
doc = fitz.open(filepath)
all_texts = []
for page in doc:
mat = fitz.Matrix(300 / 72)
pix = page.get_pixmap(matrix=mat)
img = Image.open(io.BytesIO(pix.tobytes("png")))
result = _ocr().ocr(np.array(img), cls=True)
if result and result[0]:
all_texts.extend(line[1][0] for line in result[0])
return "\n".join(all_texts)
# ── JSON 배열 파싱 유틸 ───────────────────────────────────────────────────────
def _parse_json_array(raw: str, finish_reason: str = "") -> list:
"""LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
if raw.startswith("```"):
lines = raw.splitlines()
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
if finish_reason == "length":
last_close = raw.rfind("}")
if last_close != -1:
raw = raw[:last_close + 1] + "]"
# 가장 긴 균형 잡힌 [...] 추출
depth = 0; start = -1; best = ""
for i, c in enumerate(raw):
if c == "[":
if depth == 0:
start = i
depth += 1
elif c == "]":
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i + 1]
if len(cand) > len(best):
best = cand
raw = best if best else "[]"
try:
return json.loads(raw)
except json.JSONDecodeError:
data = []
for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
try:
data.append(json.loads(obj))
except json.JSONDecodeError:
pass
return data
# ── 태그 추출/매핑 도구 ───────────────────────────────────────────────────────
def _extract_pid_tags(text: str, source_type: str) -> str:
system = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract all instrument and equipment tags from the provided text.\n"
"Return ONLY a valid JSON array. Each element must have exactly these fields:\n"
'{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV",'
'"lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n'
"Rules:\n"
"- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n"
" Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n"
"- instrumentType: leading letters of tagNo\n"
"- equipmentName: descriptive name if present near tag, else null\n"
"- lineNumber/pidDrawingNo: null unless explicitly associated\n"
"- confidence: 0.95 for clear tags, lower for ambiguous\n"
"- Output ONLY the JSON array, no markdown, no explanation.\n"
"- If no tags found, return: []\n"
)
truncated = text[:100000]
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"},
],
max_tokens=32768,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
logging.info(f"extract_pid_tags source={source_type} count={len(data)}")
return json.dumps({
"success": True,
"data": {"count": len(data), "tags": data},
"message": "태그 추출 완료"
}, ensure_ascii=False, indent=2)
def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
system = (
"You are a P&ID to Experion tag matching expert.\n"
"Match P&ID tags to Experion tags based on similarity.\n"
"Return ONLY a JSON array:\n"
'[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n'
"- If no good match: confidence < 0.5, experionTag null\n"
"- Output ONLY the JSON array.\n"
)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": (
f"P&ID Tags:\n{chr(10).join(pid_tags)}\n\n"
f"Experion Tags:\n{chr(10).join(experion_tags)}"
)},
],
max_tokens=16384,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
return json.dumps({
"success": True,
"data": {"count": len(data), "mappings": data},
"message": "태그 매핑 완료"
}, ensure_ascii=False, indent=2)
# ── 도면 파싱 도구 ────────────────────────────────────────────────────────────
_TAG_EXTRACT_SYSTEM = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract instrument and equipment tags from the provided text.\n"
"Return ONLY a JSON array:\n"
'[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FIT",'
'"lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n'
"Rules:\n"
"- tagNo: Instrument [Function]-[Number], Equipment [Type]-[Number]\n"
"- instrumentType: first 2-4 letters of tagNo\n"
"- equipmentName/lineNumber/pidDrawingNo: null if not present\n"
"- confidence: 0.0 to 1.0\n"
"- Output ONLY the JSON array, no markdown.\n"
"- If no tags found, return: []\n"
)
def _parse_pid_dxf(filepath: str) -> str:
text = _extract_text_from_dxf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: dxf\n\nText:\n{text[:8000]}"},
],
max_tokens=8192,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "DXF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
text = _extract_text_from_pdf_ocr(filepath) if use_ocr else _extract_text_from_pdf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"},
],
max_tokens=4096,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "PDF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_drawing(filepath: str) -> str:
ext = os.path.splitext(filepath)[1].lower()
if ext == ".dxf":
return _parse_pid_dxf(filepath)
elif ext == ".pdf":
return _parse_pid_pdf(filepath)
elif ext == ".dwg":
return json.dumps({
"success": False,
"data": None,
"error": "DWG 파일은 직접 파싱할 수 없습니다. DXF로 변환 후 사용하세요.",
"message": "지원하지 않는 파일 형식"
}, ensure_ascii=False)
else:
return json.dumps({
"success": False,
"error": f"지원하지 않는 형식: {ext}. 지원: .dxf, .pdf",
}, ensure_ascii=False)
# ── 그래프 도구 ───────────────────────────────────────────────────────────────
import time
async def _build_pid_graph_parallel(filepath: str) -> str:
from pipeline.extractor import PidGeometricExtractor
from pipeline.topology import PidTopologyBuilder
from pipeline.mapper import IntelligentMapper
from openai import AsyncOpenAI
os.makedirs(STORAGE_DIR, exist_ok=True)
t0 = time.time()
basename = os.path.basename(filepath)
logging.info(f"[{basename}] Phase 1: 기하 추출 시작")
# Phase 1: 기하 추출
extractor = PidGeometricExtractor(filepath)
geo_data_path = os.path.join(STORAGE_DIR, basename + "_geo.json")
extractor.extract_and_save(geo_data_path)
with open(geo_data_path, "r", encoding="utf-8") as f:
geo_data = json.load(f)
logging.info(f"[{basename}] Phase 1 완료 ({time.time()-t0:.1f}s) - {len(geo_data)}개 엔티티 추출")
# 시스템 태그 조회
system_tags: list[str] = []
try:
conn = _get_db_connection()
with conn.cursor() as cur:
cur.execute("SELECT tagname FROM realtime_table")
system_tags = [r[0] for r in cur.fetchall()]
except Exception as e:
logging.warning(f"시스템 태그 조회 실패: {e}")
logging.info(f"[{basename}] 시스템 태그 {len(system_tags)}개 로드")
# Phase 2: 1차 위상 빌더 (Mapper용 그래프)
t1 = time.time()
builder = PidTopologyBuilder(geo_data)
builder.build_graph()
logging.info(f"[{basename}] Phase 2 완료 ({time.time()-t1:.1f}s) - 그래프: {builder.G.number_of_nodes()}노드, {builder.G.number_of_edges()}엣지")
# Phase 3: 병렬 LLM 매핑
t2 = time.time()
api_client = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
mapper = IntelligentMapper(builder.G, system_tags, api_client=api_client)
transmitter_nodes = [
n for n, d in builder.G.nodes(data=True)
if (d.get("value") or "").upper() in {"FIT", "FT", "LT", "PT", "TE"}
]
valve_nodes = [
n for n, d in builder.G.nodes(data=True)
if (d.get("value") or "").upper() in {"FCV", "LCV", "TCV", "PCV", "XV"}
]
equipment_nodes = [
n for n, d in builder.G.nodes(data=True)
if d.get("type") not in {"TEXT", "LINE", "LWPOLYLINE"}
]
logging.info(f"[{basename}] Phase 3 시작 - transmitter:{len(transmitter_nodes)}, valve:{len(valve_nodes)}, equipment:{len(equipment_nodes)}")
extracted_results = await asyncio.gather(
mapper.extract_transmitters(transmitter_nodes),
mapper.extract_valves(valve_nodes),
mapper.extract_equipment(equipment_nodes),
)
logging.info(f"[{basename}] Phase 3 완료 ({time.time()-t2:.1f}s)")
# 매핑 결과 통합
all_mapped_tags = []
for res_dict in extracted_results:
for node_id, mapping in res_dict.items():
if mapping.resolved_tag != "UNKNOWN":
node_data = builder.G.nodes[node_id]
all_mapped_tags.append({
"entity_id": node_id,
"tagName": mapping.resolved_tag,
"bbox": (
node_data["bbox"].bounds
if hasattr(node_data["bbox"], "bounds")
else node_data["bbox"]
),
"clean_value": mapping.resolved_tag,
})
logging.info(f"[{basename}] 매핑 완료: {len(all_mapped_tags)}개 태그 해결")
# Phase 4: 매핑 태그를 기존 그래프에 추가 + 저장 (build_graph() 중복 호출 제거)
t3 = time.time()
from shapely.geometry import box as shapely_box
# 기존 builder.G에 매핑 태그 노드를 추가하고 연결
for tag in all_mapped_tags:
bbox_vals = tag['bbox']
# bbox가 tuple 또는 dict 형태 처리
if isinstance(bbox_vals, (list, tuple)):
bbox_geom = shapely_box(bbox_vals[0], bbox_vals[1], bbox_vals[2], bbox_vals[3])
else:
bbox_geom = shapely_box(bbox_vals['min_x'], bbox_vals['min_y'],
bbox_vals['max_x'], bbox_vals['max_y'])
builder.G.add_node(tag['entity_id'],
type='TEXT',
bbox=bbox_geom,
value=tag.get('clean_value') or tag.get('tagName'))
# 매핑 태그-설비 연결 (SpatialGrid 사용)
equipments = [n for n, d in builder.G.nodes(data=True) if d['type'] not in ['TEXT', 'LINE', 'LWPOLYLINE']]
eq_grid = builder._build_spatial_grid(equipments)
new_tags = [tag['entity_id'] for tag in all_mapped_tags]
for tag_id in new_tags:
best_match = builder._find_nearest_equipment(tag_id, eq_grid)
if best_match:
builder.G.add_edge(tag_id, best_match, relation='associated_with')
graph_id = basename.replace(".dxf", "_graph.json")
graph_path = os.path.join(STORAGE_DIR, graph_id)
builder.save_graph(graph_path)
total_time = time.time() - t0
logging.info(f"[{basename}] 전체 완료 ({total_time:.1f}s) - graph_id={graph_id} "
f"nodes={builder.G.number_of_nodes()} "
f"edges={builder.G.number_of_edges()}")
return json.dumps({
"success": True,
"graph_id": graph_id,
"graph_path": graph_path,
"nodes": builder.G.number_of_nodes(),
"edges": builder.G.number_of_edges(),
"processing_time_sec": round(total_time, 1)
}, ensure_ascii=False)
def _analyze_pid_impact(graph_id: str, start_node_id: str) -> str:
from pipeline.analyzer import PidAnalysisEngine
graph_path = os.path.join(STORAGE_DIR, graph_id)
mapping_path = graph_path.replace("_graph.json", "_mapping.json")
analyzer = PidAnalysisEngine(graph_path, mapping_path)
result = analyzer.analyze_impact(start_node_id)
return json.dumps(result, ensure_ascii=False, indent=2)
# ── 요청 디스패처 ─────────────────────────────────────────────────────────────
async def _dispatch(tool: str, params: dict) -> str:
try:
match tool:
# blocking 함수는 asyncio.to_thread로 스레드풀 오프로드
case "extract_pid_tags":
return await asyncio.to_thread(_extract_pid_tags, **params)
case "match_pid_tags":
return await asyncio.to_thread(_match_pid_tags, **params)
case "parse_pid_dxf":
return await asyncio.to_thread(_parse_pid_dxf, **params)
case "parse_pid_pdf":
return await asyncio.to_thread(_parse_pid_pdf, **params)
case "parse_pid_drawing":
return await asyncio.to_thread(_parse_pid_drawing, **params)
case "analyze_pid_impact":
return await asyncio.to_thread(_analyze_pid_impact, **params)
# 이미 async — 직접 await
case "build_pid_graph_parallel":
return await _build_pid_graph_parallel(**params)
case _:
return json.dumps({"success": False, "error": f"알 수 없는 도구: {tool}"},
ensure_ascii=False)
except Exception as e:
logging.error(f"dispatch error tool={tool}: {e}", exc_info=True)
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
# ── 종료 예약 ─────────────────────────────────────────────────────────────────
def _schedule_shutdown():
"""
응답 전송 완료 후 프로세스 종료 예약.
FastAPI의 BackgroundTasks를 사용하여 응답이 완전히 전송된 후 종료되도록 유도함.
"""
async def _do():
# 네트워크 전송 및 커넥션 정리를 위한 최소한의 대기 시간
await asyncio.sleep(1.0)
logging.info("One-shot worker shutting down...")
os.kill(os.getpid(), signal.SIGTERM)
asyncio.create_task(_do())
# ── HTTP 엔드포인트 ───────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
body = await request.json()
return await _dispatch(body["tool"], body["params"])
from fastapi import BackgroundTasks
@app.post("/execute/one_shot")
async def execute_one_shot(request: Request, background_tasks: BackgroundTasks):
"""요청 처리 후 프로세스 자동 종료 (P&ID 워커 전용)."""
body = await request.json()
result = await _dispatch(body["tool"], body["params"])
# BackgroundTasks에 등록하여 응답 전송이 완료된 후 _schedule_shutdown이 실행되도록 함
background_tasks.add_function(_schedule_shutdown)
return result
# ── 진입점 ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5004
os.makedirs(STORAGE_DIR, exist_ok=True)
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")

View File

@@ -0,0 +1,190 @@
import ezdxf
import re
import json
import logging
from typing import List, Optional, Tuple, Union
from pydantic import BaseModel, Field
from shapely.geometry import box, Point
# 로깅 설정
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# --- Data Models ---
class BoundingBox(BaseModel):
min_x: float
min_y: float
max_x: float
max_y: float
center: Tuple[float, float]
class GeometricEntity(BaseModel):
entity_id: str
entity_type: str # TEXT, MTEXT, LINE, LWPOLYLINE, CIRCLE, ARC
layer: str
bbox: BoundingBox
raw_value: Optional[str] = None
clean_value: Optional[str] = None
coordinates: List[Union[Tuple[float, float], List[float]]] = Field(default_factory=list)
properties: dict = Field(default_factory=dict)
# --- Extractor Implementation ---
class PidGeometricExtractor:
def __init__(self, file_path: str):
try:
self.doc = ezdxf.readfile(file_path)
self.msp = self.doc.modelspace()
except Exception as e:
raise IOError(f"Failed to load DXF file: {e}")
def clean_text(self, text: str) -> str:
"""
DXF 특수 제어 문자 및 MTEXT 포맷팅을 제거하여 정제된 텍스트 반환.
"""
if not text:
return ""
# 1. MTEXT 포맷팅 및 제어 문자 제거 (\P, \W, \L, \A, \C, \H, \S, \T 등)
text = re.sub(r'\\([P|W|L|A|C|H|S|T])\d*;?', ' ', text)
# 2. 중괄호 { } 제거
text = re.sub(r'[\{\}]', ' ', text)
# 3. DXF 특수 제어 문자 제거 (%%U: Underline, %%O: Overline, %%S: Strikethrough, %%R: Registered)
text = re.sub(r'%%[U|O|S|R]', ' ', text)
# 4. 불필요한 특수 기호 및 반복되는 공백 정제
text = re.sub(r'\s+', ' ', text).strip()
return text
def get_bbox(self, entity) -> Optional[BoundingBox]:
"""
엔티티 타입별로 동적인 Bounding Box를 계산하여 반환.
"""
try:
if entity.dxftype() == 'TEXT':
p = entity.dxf.insert
h = entity.dxf.height
# 텍스트 길이에 따른 대략적인 너비 계산 (글자수 * 높이 * 0.6)
width = len(entity.dxf.text) * h * 0.6
return self._create_bbox(p.x, p.y, p.x + width, p.y + h)
elif entity.dxftype() == 'MTEXT':
p = entity.dxf.insert
h = entity.dxf.char_height if hasattr(entity.dxf, 'char_height') else 2.5
w = entity.dxf.width if entity.dxf.width > 0 else len(entity.text) * h * 0.6
return self._create_bbox(p.x, p.y, p.x + w, p.y + h)
elif entity.dxftype() == 'LINE':
start = entity.dxf.start
end = entity.dxf.end
return self._create_bbox(
min(start.x, end.x), min(start.y, end.y),
max(start.x, end.x), max(start.y, end.y)
)
elif entity.dxftype() == 'LWPOLYLINE':
points = entity.get_points()
if not points: return None
xs = [p[0] for p in points]
ys = [p[1] for p in points]
return self._create_bbox(min(xs), min(ys), max(xs), max(ys))
elif entity.dxftype() in ('CIRCLE', 'ARC'):
center = entity.dxf.center
radius = entity.dxf.radius
return self._create_bbox(
center.x - radius, center.y - radius,
center.x + radius, center.y + radius
)
except Exception as e:
logger.error(f"Error calculating bbox for {entity.dxftype()} ({entity.dxf.handle}): {e}", exc_info=True)
return None
def _create_bbox(self, min_x, min_y, max_x, max_y) -> BoundingBox:
return BoundingBox(
min_x=min_x,
min_y=min_y,
max_x=max_x,
max_y=max_y,
center=((min_x + max_x) / 2, (min_y + max_y) / 2)
)
def extract_and_save(self, output_path: str):
"""
기하학적 데이터를 추출하여 JSON 파일로 저장.
"""
results = []
logger.info(f"Starting DXF extraction from {self.doc.filename if hasattr(self.doc, 'filename') else 'unknown file'}")
for entity in self.msp:
try:
bbox_obj = self.get_bbox(entity)
if not bbox_obj:
continue
raw_text = ""
if entity.dxftype() == 'TEXT':
raw_text = entity.dxf.text
elif entity.dxftype() == 'MTEXT':
raw_text = entity.text
# 좌표 추출 (3D 좌표를 2D로 변환)
coords = []
if hasattr(entity, 'get_points'):
# ezdxf의 get_points()는 (x, y, z) 튜플 리스트를 반환함
coords = [(p[0], p[1]) for p in entity.get_points()]
elif entity.dxftype() == 'LINE':
coords = [(entity.dxf.start.x, entity.dxf.start.y), (entity.dxf.end.x, entity.dxf.end.y)]
elif entity.dxftype() in ('CIRCLE', 'ARC'):
coords = [(entity.dxf.center.x, entity.dxf.center.y)]
entity_data = GeometricEntity(
entity_id=entity.dxf.handle,
entity_type=entity.dxftype(),
layer=entity.dxf.layer,
bbox=bbox_obj,
raw_value=raw_text if raw_text else None,
clean_value=self.clean_text(raw_text) if raw_text else None,
coordinates=coords,
properties={
"color": entity.dxf.color,
"lineweight": entity.dxf.lineweight if hasattr(entity.dxf, 'lineweight') else None,
}
)
results.append(entity_data.model_dump())
except Exception as e:
logger.error(f"Unexpected error processing entity {entity.dxftype()} ({entity.dxf.handle}): {e}")
continue
try:
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(results, f, ensure_ascii=False, indent=4)
logger.info(f"Successfully saved {len(results)} entities to {output_path}")
except Exception as e:
logger.error(f"Failed to save extraction results to {output_path}: {e}")
raise
return output_path
# --- Proximity Utilities ---
def is_near(bbox_a: BoundingBox, bbox_b: BoundingBox, threshold=5.0) -> bool:
"""
두 Bounding Box 간의 최단 거리가 임계값 이내인지 확인.
shapely 없이 BBox 좌표만으로 O(1) 계산.
"""
dx = max(0, bbox_b.min_x - bbox_a.max_x, bbox_a.min_x - bbox_b.max_x)
dy = max(0, bbox_b.min_y - bbox_a.max_y, bbox_a.min_y - bbox_b.max_y)
dist = (dx * dx + dy * dy) ** 0.5
return dist <= threshold
def is_inside(point: Tuple[float, float], bbox: BoundingBox) -> bool:
"""
특정 점이 Bounding Box 내부에 있는지 확인.
"""
return (bbox.min_x <= point[0] <= bbox.max_x) and (bbox.min_y <= point[1] <= bbox.max_y)

View File

@@ -0,0 +1,528 @@
#!/usr/bin/env python3
"""P&ID 파싱 전용 워커 프로세스
Usage: python pid_worker.py <port>
담당 도구:
extract_pid_tags, match_pid_tags,
parse_pid_dxf, parse_pid_pdf, parse_pid_drawing,
build_pid_graph_parallel, analyze_pid_impact
"""
from __future__ import annotations
import sys
import os
# mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import io
import json
import asyncio
import signal
import logging
import re
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
# ── 설정 ─────────────────────────────────────────────────────────────────────
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
_SERVER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STORAGE_DIR = os.path.join(_SERVER_DIR, "storage")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [pid_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── 싱글톤 ───────────────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm():
from openai import OpenAI
return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
@lru_cache(maxsize=1)
def _ocr():
from paddleocr import PaddleOCR
use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true"
try:
return PaddleOCR(use_angle_cls=True, lang="korean", use_gpu=use_gpu, show_log=False)
except Exception:
if use_gpu:
os.environ["PADDLE_USE_GPU"] = "false"
return _ocr()
raise
# ── DB ───────────────────────────────────────────────────────────────────────
def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
# ── 텍스트 추출 ──────────────────────────────────────────────────────────────
def _extract_text_from_dxf(filepath: str) -> str:
import ezdxf
from ezdxf.tools.text import plain_mtext
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
texts = []
for entity in msp:
if entity.dxftype() == "TEXT":
texts.append(entity.dxf.text)
elif entity.dxftype() == "MTEXT":
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
return "\n".join(texts)
def _extract_text_from_pdf(filepath: str) -> str:
import fitz
doc = fitz.open(filepath)
return "\n".join(page.get_text() for page in doc)
def _extract_text_from_pdf_ocr(filepath: str) -> str:
import fitz
from PIL import Image
import numpy as np
doc = fitz.open(filepath)
all_texts = []
for page in doc:
mat = fitz.Matrix(300 / 72)
pix = page.get_pixmap(matrix=mat)
img = Image.open(io.BytesIO(pix.tobytes("png")))
result = _ocr().ocr(np.array(img), cls=True)
if result and result[0]:
all_texts.extend(line[1][0] for line in result[0])
return "\n".join(all_texts)
# ── JSON 배열 파싱 유틸 ───────────────────────────────────────────────────────
def _parse_json_array(raw: str, finish_reason: str = "") -> list:
"""LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
if raw.startswith("```"):
lines = raw.splitlines()
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
if finish_reason == "length":
last_close = raw.rfind("}")
if last_close != -1:
raw = raw[:last_close + 1] + "]"
# 가장 긴 균형 잡힌 [...] 추출
depth = 0; start = -1; best = ""
for i, c in enumerate(raw):
if c == "[":
if depth == 0:
start = i
depth += 1
elif c == "]":
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i + 1]
if len(cand) > len(best):
best = cand
raw = best if best else "[]"
try:
return json.loads(raw)
except json.JSONDecodeError:
data = []
for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
try:
data.append(json.loads(obj))
except json.JSONDecodeError:
pass
return data
# ── 태그 추출/매핑 도구 ───────────────────────────────────────────────────────
def _extract_pid_tags(text: str, source_type: str) -> str:
system = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract all instrument and equipment tags from the provided text.\n"
"Return ONLY a valid JSON array. Each element must have exactly these fields:\n"
'{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV",'
'"lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n'
"Rules:\n"
"- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n"
" Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n"
"- instrumentType: leading letters of tagNo\n"
"- equipmentName: descriptive name if present near tag, else null\n"
"- lineNumber/pidDrawingNo: null unless explicitly associated\n"
"- confidence: 0.95 for clear tags, lower for ambiguous\n"
"- Output ONLY the JSON array, no markdown, no explanation.\n"
"- If no tags found, return: []\n"
)
truncated = text[:100000]
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"},
],
max_tokens=32768,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
logging.info(f"extract_pid_tags source={source_type} count={len(data)}")
return json.dumps({
"success": True,
"data": {"count": len(data), "tags": data},
"message": "태그 추출 완료"
}, ensure_ascii=False, indent=2)
def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
system = (
"You are a P&ID to Experion tag matching expert.\n"
"Match P&ID tags to Experion tags based on similarity.\n"
"Return ONLY a JSON array:\n"
'[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n'
"- If no good match: confidence < 0.5, experionTag null\n"
"- Output ONLY the JSON array.\n"
)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": (
f"P&ID Tags:\n{chr(10).join(pid_tags)}\n\n"
f"Experion Tags:\n{chr(10).join(experion_tags)}"
)},
],
max_tokens=16384,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
return json.dumps({
"success": True,
"data": {"count": len(data), "mappings": data},
"message": "태그 매핑 완료"
}, ensure_ascii=False, indent=2)
# ── 도면 파싱 도구 ────────────────────────────────────────────────────────────
_TAG_EXTRACT_SYSTEM = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract instrument and equipment tags from the provided text.\n"
"Return ONLY a JSON array:\n"
'[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FIT",'
'"lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n'
"Rules:\n"
"- tagNo: Instrument [Function]-[Number], Equipment [Type]-[Number]\n"
"- instrumentType: first 2-4 letters of tagNo\n"
"- equipmentName/lineNumber/pidDrawingNo: null if not present\n"
"- confidence: 0.0 to 1.0\n"
"- Output ONLY the JSON array, no markdown.\n"
"- If no tags found, return: []\n"
)
def _parse_pid_dxf(filepath: str) -> str:
text = _extract_text_from_dxf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: dxf\n\nText:\n{text[:8000]}"},
],
max_tokens=8192,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "DXF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
text = _extract_text_from_pdf_ocr(filepath) if use_ocr else _extract_text_from_pdf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"},
],
max_tokens=4096,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "PDF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_drawing(filepath: str) -> str:
ext = os.path.splitext(filepath)[1].lower()
if ext == ".dxf":
return _parse_pid_dxf(filepath)
elif ext == ".pdf":
return _parse_pid_pdf(filepath)
elif ext == ".dwg":
return json.dumps({
"success": False,
"data": None,
"error": "DWG 파일은 직접 파싱할 수 없습니다. DXF로 변환 후 사용하세요.",
"message": "지원하지 않는 파일 형식"
}, ensure_ascii=False)
else:
return json.dumps({
"success": False,
"error": f"지원하지 않는 형식: {ext}. 지원: .dxf, .pdf",
}, ensure_ascii=False)
# ── 그래프 도구 ───────────────────────────────────────────────────────────────
import time
async def _build_pid_graph_parallel(filepath: str) -> str:
from pipeline.extractor import PidGeometricExtractor
from pipeline.topology import PidTopologyBuilder
from pipeline.mapper import IntelligentMapper
from openai import AsyncOpenAI
os.makedirs(STORAGE_DIR, exist_ok=True)
t0 = time.time()
basename = os.path.basename(filepath)
logging.info(f"[{basename}] Phase 1: 기하 추출 시작")
# Phase 1: 기하 추출
extractor = PidGeometricExtractor(filepath)
geo_data_path = os.path.join(STORAGE_DIR, basename + "_geo.json")
extractor.extract_and_save(geo_data_path)
with open(geo_data_path, "r", encoding="utf-8") as f:
geo_data = json.load(f)
logging.info(f"[{basename}] Phase 1 완료 ({time.time()-t0:.1f}s) - {len(geo_data)}개 엔티티 추출")
# 시스템 태그 조회
system_tags: list[str] = []
try:
conn = _get_db_connection()
with conn.cursor() as cur:
cur.execute("SELECT tagname FROM realtime_table")
system_tags = [r[0] for r in cur.fetchall()]
except Exception as e:
logging.warning(f"시스템 태그 조회 실패: {e}")
logging.info(f"[{basename}] 시스템 태그 {len(system_tags)}개 로드")
# Phase 2: 1차 위상 빌더 (Mapper용 그래프)
t1 = time.time()
builder = PidTopologyBuilder(geo_data)
builder.build_graph()
logging.info(f"[{basename}] Phase 2 완료 ({time.time()-t1:.1f}s) - 그래프: {builder.G.number_of_nodes()}노드, {builder.G.number_of_edges()}엣지")
# Phase 3: 병렬 LLM 매핑
t2 = time.time()
api_client = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
mapper = IntelligentMapper(builder.G, system_tags, api_client=api_client)
transmitter_nodes = [
n for n, d in builder.G.nodes(data=True)
if (d.get("value") or "").upper() in {"FIT", "FT", "LT", "PT", "TE"}
]
valve_nodes = [
n for n, d in builder.G.nodes(data=True)
if (d.get("value") or "").upper() in {"FCV", "LCV", "TCV", "PCV", "XV"}
]
equipment_nodes = [
n for n, d in builder.G.nodes(data=True)
if d.get("type") not in {"TEXT", "LINE", "LWPOLYLINE"}
]
logging.info(f"[{basename}] Phase 3 시작 - transmitter:{len(transmitter_nodes)}, valve:{len(valve_nodes)}, equipment:{len(equipment_nodes)}")
extracted_results = await asyncio.gather(
mapper.extract_transmitters(transmitter_nodes),
mapper.extract_valves(valve_nodes),
mapper.extract_equipment(equipment_nodes),
)
logging.info(f"[{basename}] Phase 3 완료 ({time.time()-t2:.1f}s)")
# 매핑 결과 통합
all_mapped_tags = []
for res_dict in extracted_results:
for node_id, mapping in res_dict.items():
if mapping.resolved_tag != "UNKNOWN":
node_data = builder.G.nodes[node_id]
all_mapped_tags.append({
"entity_id": node_id,
"tagName": mapping.resolved_tag,
"bbox": (
node_data["bbox"].bounds
if hasattr(node_data["bbox"], "bounds")
else node_data["bbox"]
),
"clean_value": mapping.resolved_tag,
})
logging.info(f"[{basename}] 매핑 완료: {len(all_mapped_tags)}개 태그 해결")
# Phase 4: 매핑 태그를 기존 그래프에 추가 + 저장 (build_graph() 중복 호출 제거)
t3 = time.time()
from shapely.geometry import box as shapely_box
# 기존 builder.G에 매핑 태그 노드를 추가하고 연결
for tag in all_mapped_tags:
bbox_vals = tag['bbox']
# bbox가 tuple 또는 dict 형태 처리
if isinstance(bbox_vals, (list, tuple)):
bbox_geom = shapely_box(bbox_vals[0], bbox_vals[1], bbox_vals[2], bbox_vals[3])
else:
bbox_geom = shapely_box(bbox_vals['min_x'], bbox_vals['min_y'],
bbox_vals['max_x'], bbox_vals['max_y'])
builder.G.add_node(tag['entity_id'],
type='TEXT',
bbox=bbox_geom,
value=tag.get('clean_value') or tag.get('tagName'))
# 매핑 태그-설비 연결 (SpatialGrid 사용)
equipments = [n for n, d in builder.G.nodes(data=True) if d['type'] not in ['TEXT', 'LINE', 'LWPOLYLINE']]
eq_grid = builder._build_spatial_grid(equipments)
new_tags = [tag['entity_id'] for tag in all_mapped_tags]
for tag_id in new_tags:
best_match = builder._find_nearest_equipment(tag_id, eq_grid)
if best_match:
builder.G.add_edge(tag_id, best_match, relation='associated_with')
graph_id = basename.replace(".dxf", "_graph.json")
graph_path = os.path.join(STORAGE_DIR, graph_id)
builder.save_graph(graph_path)
total_time = time.time() - t0
logging.info(f"[{basename}] 전체 완료 ({total_time:.1f}s) - graph_id={graph_id} "
f"nodes={builder.G.number_of_nodes()} "
f"edges={builder.G.number_of_edges()}")
return json.dumps({
"success": True,
"graph_id": graph_id,
"graph_path": graph_path,
"nodes": builder.G.number_of_nodes(),
"edges": builder.G.number_of_edges(),
"processing_time_sec": round(total_time, 1)
}, ensure_ascii=False)
def _analyze_pid_impact(graph_id: str, start_node_id: str) -> str:
from pipeline.analyzer import PidAnalysisEngine
graph_path = os.path.join(STORAGE_DIR, graph_id)
mapping_path = graph_path.replace("_graph.json", "_mapping.json")
analyzer = PidAnalysisEngine(graph_path, mapping_path)
result = analyzer.analyze_impact(start_node_id)
return json.dumps(result, ensure_ascii=False, indent=2)
# ── 요청 디스패처 ─────────────────────────────────────────────────────────────
async def _dispatch(tool: str, params: dict) -> str:
try:
match tool:
# blocking 함수는 asyncio.to_thread로 스레드풀 오프로드
case "extract_pid_tags":
return await asyncio.to_thread(_extract_pid_tags, **params)
case "match_pid_tags":
return await asyncio.to_thread(_match_pid_tags, **params)
case "parse_pid_dxf":
return await asyncio.to_thread(_parse_pid_dxf, **params)
case "parse_pid_pdf":
return await asyncio.to_thread(_parse_pid_pdf, **params)
case "parse_pid_drawing":
return await asyncio.to_thread(_parse_pid_drawing, **params)
case "analyze_pid_impact":
return await asyncio.to_thread(_analyze_pid_impact, **params)
# 이미 async — 직접 await
case "build_pid_graph_parallel":
return await _build_pid_graph_parallel(**params)
case _:
return json.dumps({"success": False, "error": f"알 수 없는 도구: {tool}"},
ensure_ascii=False)
except Exception as e:
logging.error(f"dispatch error tool={tool}: {e}", exc_info=True)
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
# ── 종료 예약 ─────────────────────────────────────────────────────────────────
def _schedule_shutdown():
"""
응답 전송 완료 후 프로세스 종료 예약.
FastAPI의 BackgroundTasks를 사용하여 응답이 완전히 전송된 후 종료되도록 유도함.
"""
async def _do():
# 네트워크 전송 및 커넥션 정리를 위한 최소한의 대기 시간
await asyncio.sleep(1.0)
logging.info("One-shot worker shutting down...")
os.kill(os.getpid(), signal.SIGTERM)
asyncio.create_task(_do())
# ── HTTP 엔드포인트 ───────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
body = await request.json()
return await _dispatch(body["tool"], body["params"])
from fastapi import BackgroundTasks
@app.post("/execute/one_shot")
async def execute_one_shot(request: Request, background_tasks: BackgroundTasks):
"""요청 처리 후 프로세스 자동 종료 (P&ID 워커 전용)."""
body = await request.json()
result = await _dispatch(body["tool"], body["params"])
# BackgroundTasks에 등록하여 응답 전송이 완료된 후 _schedule_shutdown이 실행되도록 함
background_tasks.add_function(_schedule_shutdown)
return result
# ── 진입점 ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5004
os.makedirs(STORAGE_DIR, exist_ok=True)
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")

View File

@@ -0,0 +1,153 @@
import networkx as nx
import asyncio
import json
from typing import List, Optional, Dict, Any, Tuple
from pydantic import BaseModel, Field
from rapidfuzz import process, fuzz
from openai import AsyncOpenAI
# --- 응답 구조화를 위한 Pydantic 모델 ---
class MappingResult(BaseModel):
resolved_tag: str = Field(..., description="The final mapped system tag")
reason: str = Field(..., description="Reason for this mapping based on context")
confidence: float = Field(..., ge=0.0, le=1.0, description="Confidence score from 0 to 1")
class IntelligentMapper:
def __init__(self, graph: nx.Graph, system_tags: List[str], api_client: Optional[AsyncOpenAI] = None):
self.graph = graph # Phase 2에서 생성된 NetworkX 그래프
self.system_tags = system_tags # Experion 시스템의 전체 태그 리스트
self.client = api_client
def get_node_context(self, node_id: str) -> str:
"""노드의 주변 위상 정보를 텍스트로 변환 (확장된 컨텍스트 제공)"""
if not self.graph.has_node(node_id):
return "Node not found in graph"
node_attr = self.graph.nodes[node_id]
node_type = node_attr.get('type', 'Unknown')
node_val = node_attr.get('value', 'Unknown')
# 1. 직접 연결된 이웃 노드 정보
neighbors = list(self.graph.neighbors(node_id))
neighbor_info = []
for n in neighbors:
attr = self.graph.nodes[n]
val = attr.get('value', n)
typ = attr.get('type', 'Unknown')
# 엣지 속성(관계) 추가
rel = self.graph.get_edge_data(node_id, n).get('relation', 'connected')
neighbor_info.append(f"[{rel}] {val} (Type: {typ})")
# 2. 2-hop 연결 정보 (더 넓은 맥락 파악)
extended_context = []
for n in neighbors:
second_neighbors = list(self.graph.neighbors(n))
for sn in second_neighbors:
if sn == node_id: continue
s_attr = self.graph.nodes[sn]
extended_context.append(f"Indirectly connected via {self.graph.nodes[n].get('value', n)} to {s_attr.get('value', sn)} (Type: {s_attr.get('type', 'Unknown')})")
context_str = (
f"Target Node: {node_val} (Type: {node_type})\n"
f"Direct Neighbors: {', '.join(neighbor_info) if neighbor_info else 'None'}\n"
f"Extended Context: {', '.join(extended_context[:10]) if extended_context else 'None'}"
)
return context_str
async def _resolve_generic(self, node_id: str, category_prompt: str) -> MappingResult:
"""공통 매핑 로직 (비동기 + 구조화 응답)"""
if not self.client:
return MappingResult(resolved_tag="UNKNOWN", reason="API Client not provided", confidence=0.0)
# Phase 2에서 'value'에 clean_value가 저장됨
node_data = self.graph.nodes.get(node_id, {})
tag_text = node_data.get('value', '')
# 1차 후보 추출 (RapidFuzz)
candidates = process.extract(tag_text, self.system_tags, scorer=fuzz.WRatio, limit=5)
context = self.get_node_context(node_id)
prompt = f"""
{category_prompt}
P&ID 도면의 태그 '{tag_text}'를 실제 시스템 태그와 매핑해야 합니다.
위상 맥락: {context}
후보 리스트: {candidates}
반드시 다음 JSON 형식으로만 응답하세요:
{{
"resolved_tag": "태그명 또는 UNKNOWN",
"reason": "매핑 이유",
"confidence": 0.0~1.0
}}
"""
try:
response = await self.client.chat.completions.create(
model="Qwen3.6-27B-FP8", # MCP 서버 설정 모델 사용
messages=[{"role": "user", "content": prompt}],
response_format={ "type": "json_object" }
)
raw_content = response.choices[0].message.content
return MappingResult.model_validate_json(raw_content)
except Exception as e:
print(f"Error resolving node {node_id}: {e}")
return MappingResult(resolved_tag="UNKNOWN", reason=f"Error: {str(e)}", confidence=0.0)
# --- 전문화된 Worker 함수들 ---
# vLLM 컨커런시 제한으로 인해 배치 처리 (한 번에 15개 요청)
_BATCH_SIZE = 15
async def _batch_gather(self, node_ids: List[str], prompt: str) -> Dict[str, MappingResult]:
"""노드를 배치로 나누어 순차적으로 LLM 요청.
모든 노드를 동시에 요청하면 vLLM 큐가 가득 차 타임아웃 발생.
"""
all_results = {}
for i in range(0, len(node_ids), self._BATCH_SIZE):
batch = node_ids[i : i + self._BATCH_SIZE]
tasks = [self._resolve_generic(nid, prompt) for nid in batch]
batch_results = await asyncio.gather(*tasks)
all_results.update(dict(zip(batch, batch_results)))
return all_results
async def extract_transmitters(self, node_ids: List[str]) -> Dict[str, MappingResult]:
prompt = "당신은 계측기 전문 엔지니어입니다. 특히 Pressure/Flow/Level Transmitter 매핑에 특화되어 있습니다."
return await self._batch_gather(node_ids, prompt)
async def extract_valves(self, node_ids: List[str]) -> Dict[str, MappingResult]:
prompt = "당신은 밸브 및 액추에이터 전문 엔지니어입니다. 밸브의 개폐 상태 및 제어 태그 매핑에 특화되어 있습니다."
return await self._batch_gather(node_ids, prompt)
async def extract_equipment(self, node_ids: List[str]) -> Dict[str, MappingResult]:
prompt = "당신은 공정 설비 전문 엔지니어입니다. 펌프, 탱크, 열교환기 등의 메인 설비 태그 매핑에 특화되어 있습니다."
return await self._batch_gather(node_ids, prompt)
def validate_mapping(resolved_tag: str, symbol_type: str, tag_metadata: Dict[str, Any]) -> Tuple[bool, str]:
"""심볼 타입과 실제 태그 메타데이터의 엄격한 일치 여부 검증"""
if resolved_tag == "UNKNOWN":
return False, "Tag not resolved"
unit_map = {
"Pressure Transmitter": ["bar", "psi", "kPa", "Pa", "kg/cm2"],
"Flow Meter": ["m3/h", "lpm", "kg/h"],
"Temperature Sensor": ["°C", "C", "K", "°F"]
}
actual_unit = tag_metadata.get('unit', '').strip()
allowed_units = unit_map.get(symbol_type, [])
if actual_unit and actual_unit in allowed_units:
return True, "Unit Match"
actual_desc = tag_metadata.get('description', '').lower()
expected_keywords = {
"Pressure Transmitter": ["pressure", "press"],
"Flow Meter": ["flow", "flowrate"],
"Temperature Sensor": ["temp", "temperature"]
}
keywords = expected_keywords.get(symbol_type, [])
if any(kw in actual_desc for kw in keywords):
return True, "Description Match (Unit Missing)"
return False, "Mismatch: Symbol type and Tag metadata do not align"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,339 @@
#!/usr/bin/env python3
"""NL2SQL 전용 워커 프로세스
Usage: python nl2sql_worker.py <port>
담당 도구:
run_sql, query_pv_history, get_tag_metadata, list_drawings, query_with_nl
특징:
- PostgreSQL 직접 연결
- LLM SQL 생성 + DB 실행 분리
- 메모리: ~1GB (SQL 생성용 LLM)
- 생명주기: 메인 서버 종료 시까지 유지
"""
from __future__ import annotations
import sys
import os
# mcp-server 디렉토리를 Python 경로에 추가
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import logging
import asyncio
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
import httpx
# ── 설정 ─────────────────────────────────────────────────────────────────────
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [nl2sql_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── DB 연결 풀 ───────────────────────────────────────────────────────────────
def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
# ── LLM 클라이언트 ───────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm_client():
from openai import AsyncOpenAI
return AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
# DB 스키마 — server.py::_DB_SCHEMA와 동일
DB_SCHEMA = """
PostgreSQL 시계열 데이터베이스 스키마
테이블: history_table (시계열 이력)
tagname TEXT - 태그명 (모두 소문자, 예: 'ficq-6113.pv') — 대소문자 구분
node_id TEXT - OPC UA 노드 ID
value TEXT - 측정값, 수치 연산 시 ::double precision 캐스트 필요
recorded_at TIMESTAMPTZ - 기록 시각(UTC), 스냅샷 주기 약 60초
테이블: realtime_table (실시간 최신값)
tagname TEXT - 태그명 (모두 소문자)
node_id TEXT - OPC UA 노드 ID
livevalue TEXT - 현재값
timestamp TIMESTAMPTZ - 최종 갱신 시각
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 공식 사용
"""
async def _generate_sql(natural_language: str) -> str:
"""자연어를 SQL로 변환."""
client = _llm_client()
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"
f"{DB_SCHEMA}"
)
response = await client.chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": natural_language},
],
max_tokens=8192,
temperature=0.1,
)
sql = response.choices[0].message.content.strip()
# 마크다운 코드 블록 제거
if sql.startswith("```"):
lines = sql.splitlines()
sql = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip()
return sql
# ── NL2SQL 도구 구현 ─────────────────────────────────────────────────────────
@app.get("/health")
async def health():
"""워커 헬스체크."""
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
"""HTTP 요청을 MCP 도구 호출로 변환."""
body = await request.json()
tool = body["tool"]
params = body["params"]
try:
if tool == "run_sql":
result = await _run_sql(**params)
elif tool == "query_pv_history":
result = await _query_pv_history(**params)
elif tool == "get_tag_metadata":
result = await _get_tag_metadata(**params)
elif tool == "list_drawings":
result = await _list_drawings(**params)
elif tool == "query_with_nl":
result = await _query_with_nl(**params)
else:
return {"success": False, "error": f"Unknown tool: {tool}"}
return result
except Exception as e:
logging.error(f"Error executing {tool}: {e}")
return {"success": False, "error": str(e)}
async def _run_sql(sql: str) -> str:
"""SQL 실행."""
conn = _get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(sql)
if cur.description:
columns = [desc[0] for desc in cur.description]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"columns": columns,
"count": len(data),
"data": data,
}
else:
conn.commit()
return {
"success": True,
"message": f"Query executed successfully. {cur.rowcount} rows affected.",
}
finally:
conn.close()
async def _query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str:
"""과거 값(PV) 히스토리 조회."""
if not tag_names:
return {"success": False, "error": "tag_names is required"}
conn = _get_db_connection()
try:
with conn.cursor() as cur:
# TimescaleDB의 time_bucket 함수 사용
cur.execute(
"""
SELECT time_bucket('1 min', ts) AS time, tag_name, value
FROM realtime_table
WHERE tag_name = ANY(%s)
AND ts >= %s
AND ts <= %s
ORDER BY time DESC
LIMIT %s
""",
(tag_names, time_from, time_to, limit),
)
columns = ["time", "tag_name", "value"]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"tag_names": tag_names,
"time_range": {"from": time_from, "to": time_to},
"limit": limit,
"count": len(data),
"data": data,
}
finally:
conn.close()
async def _get_tag_metadata(query: str, limit: int = 10) -> str:
"""태그 메타데이터 검색."""
conn = _get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT DISTINCT tag_name, unit, description
FROM realtime_table
WHERE tag_name ILIKE %s
ORDER BY tag_name
LIMIT %s
""",
(f"%{query}%", limit),
)
columns = ["tag_name", "unit", "description"]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"query": query,
"count": len(data),
"tags": data,
}
finally:
conn.close()
async def _list_drawings(unit_no: str = None) -> str:
"""단위별 도면 목록 조회."""
conn = _get_db_connection()
try:
with conn.cursor() as cur:
if unit_no:
cur.execute(
"""
SELECT DISTINCT name
FROM node_map_master
WHERE name LIKE %s
ORDER BY name
""",
(f"{unit_no}%",),
)
else:
cur.execute(
"""
SELECT DISTINCT name
FROM node_map_master
ORDER BY name
"""
)
columns = ["name"]
rows = cur.fetchall()
data = [dict(zip(columns, row[0])) for row in rows]
return {
"success": True,
"unit_no": unit_no,
"count": len(data),
"names": [d["name"] for d in data],
}
finally:
conn.close()
async def _query_with_nl(question: str) -> str:
"""자연어로 SQL 쿼리 실행."""
import json
sql = await _generate_sql(question)
# SQL이 비어있으면 오류 반환
if not sql:
return json.dumps({"success": False, "sql": "", "error": "LLM이 SQL을 생성하지 못했습니다."}, ensure_ascii=False)
conn = _get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(sql)
if cur.description:
columns = [desc[0] for desc in cur.description]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"sql": sql,
"columns": columns,
"count": len(data),
"data": data,
}
else:
conn.commit()
return {
"success": True,
"sql": sql,
"message": f"Query executed successfully. {cur.rowcount} rows affected.",
}
except Exception as db_error:
return {
"success": False,
"sql": sql,
"error": str(db_error),
}
finally:
conn.close()
# ── 메인 ─────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5003
logging.info(f"Starting NL2SQL worker on port {port}")
uvicorn.run(app, host="0.0.0.0", port=port)

View File

@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""P&ID 태그 추출기 공통 템플릿
독립 프로세스로서 CLI에서 실행되며,
입력 텍스트 파일에서 P&ID 태그를 추출하여 JSON 파일로 출력합니다.
사용법:
python pid_extract_template.py --input full_text.txt --output result.json --prompt "system prompt text"
python pid_extract_template.py --input full_text.txt --output result.json --prompt-file prompt.txt
환경 변수:
VLLM_BASE_URL: vLLM 엔드포인트 (기본: http://localhost:8000/v1)
VLLM_MODEL: 모델명 (기본: Qwen3.6-27B-FP8)
"""
import argparse
import json
import logging
import os
import re
import sys
import time
from typing import List
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
)
logger = logging.getLogger("pid_extractor")
def parse_json_array(raw: str, finish_reason: str = "") -> list:
"""LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
if raw.startswith("```"):
lines = raw.splitlines()
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
if finish_reason == "length":
last_close = raw.rfind("}")
if last_close != -1:
raw = raw[:last_close + 1] + "]"
# 가장 긴 균형 잡힌 [...] 추출
depth = 0
start = -1
best = ""
for i, c in enumerate(raw):
if c == "[":
if depth == 0:
start = i
depth += 1
elif c == "]":
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i + 1]
if len(cand) > len(best):
best = cand
raw = best if best else "[]"
try:
return json.loads(raw)
except json.JSONDecodeError:
data = []
for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
try:
data.append(json.loads(obj))
except json.JSONDecodeError:
pass
return data
def call_llm(system_prompt: str, user_text: str, max_tokens: int = 65536) -> List[dict]:
"""
vLLM에 LLM 호출하여 태그 목록 추출.
Args:
system_prompt: 시스템 프롬프트
user_text: 입력 텍스트
max_tokens: 최대 토큰 수
Returns:
추출된 태그 목록 (JSON 배열)
"""
from openai import OpenAI
base_url = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
model = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
client = OpenAI(base_url=base_url, api_key="dummy")
logger.info(f"vLLM 호출: {base_url}, 모델: {model}, max_tokens: {max_tokens}")
logger.info(f"입력 텍스트 길이: {len(user_text)}")
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_text},
],
max_tokens=max_tokens,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
finish_reason = resp.choices[0].finish_reason
logger.info(f"LLM 응답: finish_reason={finish_reason}, 응답 길이={len(raw)}")
data = parse_json_array(raw, finish_reason)
if finish_reason == "length":
logger.warning(f"finish_reason=length: 응답이 잘렸습니다. 복구 시도됨. 추출된 태그 수: {len(data)}")
return data
def main():
parser = argparse.ArgumentParser(description="P&ID 태그 추출기")
parser.add_argument("--input", required=True, help="입력 텍스트 파일 경로")
parser.add_argument("--output", required=True, help="출력 JSON 파일 경로")
parser.add_argument("--prompt", type=str, default=None, help="시스템 프롬프트 (인라인)")
parser.add_argument("--prompt-file", type=str, default=None, help="시스템 프롬프트 파일 경로")
parser.add_argument("--max-tokens", type=int, default=65536, help="최대 토큰 수 (기본: 65536)")
args = parser.parse_args()
# 1. 입력 텍스트 읽기
if not os.path.exists(args.input):
logger.error(f"입력 파일을 찾을 수 없습니다: {args.input}")
sys.exit(1)
with open(args.input, "r", encoding="utf-8") as f:
input_text = f.read()
logger.info(f"입력 파일 읽기 완료: {len(input_text)}")
# 2. 시스템 프롬프트 읽기
system_prompt = None
if args.prompt:
system_prompt = args.prompt
elif args.prompt_file:
if not os.path.exists(args.prompt_file):
logger.error(f"프롬프트 파일을 찾을 수 없습니다: {args.prompt_file}")
sys.exit(1)
with open(args.prompt_file, "r", encoding="utf-8") as f:
system_prompt = f.read()
else:
logger.error("--prompt 또는 --prompt-file 중 하나를 지정해야 합니다.")
sys.exit(1)
logger.info(f"시스템 프롬프트: {len(system_prompt)}")
# 3. LLM 호출
t0 = time.time()
tags = call_llm(system_prompt, input_text, max_tokens=args.max_tokens)
elapsed = time.time() - t0
logger.info(f"추출 완료: {len(tags)}개 태그, 소요 시간: {elapsed:.1f}")
# 4. 결과 JSON 쓰기
output_dir = os.path.dirname(args.output)
if output_dir:
os.makedirs(output_dir, exist_ok=True)
result = {
"success": True,
"count": len(tags),
"tags": tags,
"processing_time_sec": round(elapsed, 1),
}
with open(args.output, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
logger.info(f"결과 저장 완료: {args.output}")
# 5. 요약 출력
print(json.dumps({
"success": True,
"count": len(tags),
"time": round(elapsed, 1)
}, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,609 @@
#!/usr/bin/env python3
"""P&ID 파싱 전용 워커 프로세스
Usage: python pid_worker.py <port>
담당 도구:
extract_pid_tags, match_pid_tags,
parse_pid_dxf, parse_pid_pdf, parse_pid_drawing,
build_pid_graph_parallel, analyze_pid_impact
"""
from __future__ import annotations
import sys
import os
# mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import io
import json
import asyncio
import signal
import logging
import re
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
# ── 설정 ─────────────────────────────────────────────────────────────────────
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
_SERVER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STORAGE_DIR = os.path.join(_SERVER_DIR, "storage")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [pid_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── 싱글톤 ───────────────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm():
from openai import OpenAI
return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
@lru_cache(maxsize=1)
def _ocr():
from paddleocr import PaddleOCR
use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true"
try:
return PaddleOCR(use_angle_cls=True, lang="korean", use_gpu=use_gpu, show_log=False)
except Exception:
if use_gpu:
os.environ["PADDLE_USE_GPU"] = "false"
return _ocr()
raise
# ── DB ───────────────────────────────────────────────────────────────────────
def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
# ── 텍스트 추출 ──────────────────────────────────────────────────────────────
def _extract_text_from_dxf(filepath: str) -> str:
import ezdxf
from ezdxf.tools.text import plain_mtext
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
texts = []
for entity in msp:
if entity.dxftype() == "TEXT":
texts.append(entity.dxf.text)
elif entity.dxftype() == "MTEXT":
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
return "\n".join(texts)
def _extract_text_from_pdf(filepath: str) -> str:
import fitz
doc = fitz.open(filepath)
return "\n".join(page.get_text() for page in doc)
def _extract_text_from_pdf_ocr(filepath: str) -> str:
import fitz
from PIL import Image
import numpy as np
doc = fitz.open(filepath)
all_texts = []
for page in doc:
mat = fitz.Matrix(300 / 72)
pix = page.get_pixmap(matrix=mat)
img = Image.open(io.BytesIO(pix.tobytes("png")))
result = _ocr().ocr(np.array(img), cls=True)
if result and result[0]:
all_texts.extend(line[1][0] for line in result[0])
return "\n".join(all_texts)
# ── JSON 배열 파싱 유틸 ───────────────────────────────────────────────────────
def _parse_json_array(raw: str, finish_reason: str = "") -> list:
"""LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
if raw.startswith("```"):
lines = raw.splitlines()
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
if finish_reason == "length":
last_close = raw.rfind("}")
if last_close != -1:
raw = raw[:last_close + 1] + "]"
# 가장 긴 균형 잡힌 [...] 추출
depth = 0; start = -1; best = ""
for i, c in enumerate(raw):
if c == "[":
if depth == 0:
start = i
depth += 1
elif c == "]":
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i + 1]
if len(cand) > len(best):
best = cand
raw = best if best else "[]"
try:
return json.loads(raw)
except json.JSONDecodeError:
data = []
for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
try:
data.append(json.loads(obj))
except json.JSONDecodeError:
pass
return data
# ── 태그 추출/매핑 도구 ───────────────────────────────────────────────────────
def _extract_pid_tags(text: str, source_type: str) -> str:
system = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract all instrument and equipment tags from the provided text.\n"
"Return ONLY a valid JSON array. Each element must have exactly these fields:\n"
'{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV",'
'"lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n'
"Rules:\n"
"- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n"
" Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n"
"- instrumentType: leading letters of tagNo\n"
"- equipmentName: descriptive name if present near tag, else null\n"
"- lineNumber/pidDrawingNo: null unless explicitly associated\n"
"- confidence: 0.95 for clear tags, lower for ambiguous\n"
"- Output ONLY the JSON array, no markdown, no explanation.\n"
"- If no tags found, return: []\n"
)
truncated = text[:100000]
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"},
],
max_tokens=32768,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
logging.info(f"extract_pid_tags source={source_type} count={len(data)}")
return json.dumps({
"success": True,
"data": {"count": len(data), "tags": data},
"message": "태그 추출 완료"
}, ensure_ascii=False, indent=2)
def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
system = (
"You are a P&ID to Experion tag matching expert.\n"
"Match P&ID tags to Experion tags based on similarity.\n"
"Return ONLY a JSON array:\n"
'[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n'
"- If no good match: confidence < 0.5, experionTag null\n"
"- Output ONLY the JSON array.\n"
)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": (
f"P&ID Tags:\n{chr(10).join(pid_tags)}\n\n"
f"Experion Tags:\n{chr(10).join(experion_tags)}"
)},
],
max_tokens=16384,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
return json.dumps({
"success": True,
"data": {"count": len(data), "mappings": data},
"message": "태그 매핑 완료"
}, ensure_ascii=False, indent=2)
# ── 도면 파싱 도구 ────────────────────────────────────────────────────────────
_TAG_EXTRACT_SYSTEM = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract instrument and equipment tags from the provided text.\n"
"Return ONLY a JSON array:\n"
'[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FIT",'
'"lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n'
"Rules:\n"
"- tagNo: Instrument [Function]-[Number], Equipment [Type]-[Number]\n"
"- instrumentType: first 2-4 letters of tagNo\n"
"- equipmentName/lineNumber/pidDrawingNo: null if not present\n"
"- confidence: 0.0 to 1.0\n"
"- Output ONLY the JSON array, no markdown.\n"
"- If no tags found, return: []\n"
)
def _parse_pid_dxf(filepath: str) -> str:
text = _extract_text_from_dxf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: dxf\n\nText:\n{text[:8000]}"},
],
max_tokens=8192,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "DXF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
text = _extract_text_from_pdf_ocr(filepath) if use_ocr else _extract_text_from_pdf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"},
],
max_tokens=4096,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "PDF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_drawing(filepath: str) -> str:
ext = os.path.splitext(filepath)[1].lower()
if ext == ".dxf":
return _parse_pid_dxf(filepath)
elif ext == ".pdf":
return _parse_pid_pdf(filepath)
elif ext == ".dwg":
return json.dumps({
"success": False,
"data": None,
"error": "DWG 파일은 직접 파싱할 수 없습니다. DXF로 변환 후 사용하세요.",
"message": "지원하지 않는 파일 형식"
}, ensure_ascii=False)
else:
return json.dumps({
"success": False,
"error": f"지원하지 않는 형식: {ext}. 지원: .dxf, .pdf",
}, ensure_ascii=False)
# ── 그래프 도구 ───────────────────────────────────────────────────────────────
import time
async def _build_pid_graph_parallel(filepath: str) -> str:
"""
P&ID 그래프 빌드 — 독립 프로세스 병렬 아키텍처.
Phase 1: 도면 분할 + 기하 추출
Phase 2: 전체 텍스트 1회 추출
Phase 3: 5개 독립 추출 프로세스 병렬 실행 (subprocess.Popen)
Phase 4: 결과 통합 + tagNo 기준 중복 제거
Phase 5: 위상 그래프 빌드 + 저장
"""
from pipeline.extractor import PidGeometricExtractor
from pipeline.topology import PidTopologyBuilder
import subprocess
import tempfile
import shutil
os.makedirs(STORAGE_DIR, exist_ok=True)
t0 = time.time()
basename = os.path.basename(filepath)
worker_dir = os.path.dirname(os.path.abspath(__file__))
logging.info(f"[{basename}] === 독립 프로세스 병렬 아키텍처 시작 ===")
# ── Phase 1: 도면 분할 + 기하 추출 ──────────────────────────────
logging.info(f"[{basename}] Phase 1: 도면 분할 + 기하 추출 시작")
extractor = PidGeometricExtractor(filepath)
regions = extractor.split_drawings()
logging.info(f"[{basename}] 도면 분할: {len(regions)}개 영역")
geo_data_path = os.path.join(STORAGE_DIR, basename + "_geo.json")
extractor.extract_and_save(geo_data_path)
with open(geo_data_path, "r", encoding="utf-8") as f:
geo_data = json.load(f)
logging.info(f"[{basename}] Phase 1 완료 ({time.time()-t0:.1f}s) - {len(geo_data)}개 엔티티")
# ── Phase 2: 전체 텍스트 1회 추출 ───────────────────────────────
t2 = time.time()
logging.info(f"[{basename}] Phase 2: 전체 텍스트 1회 추출")
full_text = _extract_text_from_dxf(filepath)
# 임시 디렉토리 생성 (프로세스 간 통신용)
temp_dir = tempfile.mkdtemp(prefix=f"pid_{basename.replace('.dxf', '')}_")
text_path = os.path.join(temp_dir, "full_text.txt")
with open(text_path, "w", encoding="utf-8") as f:
f.write(full_text)
logging.info(f"[{basename}] Phase 2 완료 ({time.time()-t2:.1f}s) - {len(full_text)}")
# ── Phase 3: 5개 독립 추출 프로세스 병렬 실행 ───────────────────
t3 = time.time()
logging.info(f"[{basename}] Phase 3: 5개 독립 추출 프로세스 병렬 실행")
extractors = [
("sensor", "pid_extract_sensor.py"),
("valve", "pid_extract_valve.py"),
("system", "pid_extract_system.py"),
("gauge", "pid_extract_gauge.py"),
("pump", "pid_extract_pump.py"),
]
results_dir = os.path.join(temp_dir, "results")
os.makedirs(results_dir, exist_ok=True)
processes = []
for name, script in extractors:
output_path = os.path.join(results_dir, f"{name}.json")
script_path = os.path.join(worker_dir, script)
cmd = [
sys.executable, script_path,
"--input", text_path,
"--output", output_path,
]
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
processes.append((name, proc, output_path))
logging.info(f"[{basename}] 시작: {name} (pid={proc.pid})")
except Exception as e:
logging.error(f"[{basename}] 실행 실패 {name}: {e}")
# 모든 프로세스 대기 (timeout=300초)
for name, proc, output_path in processes:
try:
stdout, stderr = proc.communicate(timeout=300)
if proc.returncode != 0:
logging.error(f"[{basename}] {name} 실패 (rc={proc.returncode}): {stderr[:200]}")
else:
logging.info(f"[{basename}] {name} 완료: {stdout.strip()[:100]}")
except subprocess.TimeoutExpired:
proc.kill()
logging.error(f"[{basename}] {name} 타임아웃 (300초)")
logging.info(f"[{basename}] Phase 3 완료 ({time.time()-t3:.1f}s)")
# ── Phase 4: 결과 JSON 통합 + tagNo 기준 중복 제거 ──────────────
t4 = time.time()
logging.info(f"[{basename}] Phase 4: 결과 통합 + 중복 제거")
all_tags = []
seen_tagnos = set()
for name, _, output_path in processes:
if not os.path.exists(output_path):
logging.warning(f"[{basename}] 결과 없음: {name}")
continue
try:
with open(output_path, "r", encoding="utf-8") as f:
result = json.load(f)
tags = result.get("tags", [])
count_new = 0
for tag in tags:
tag_no = tag.get("tagNo", "")
if tag_no and tag_no not in seen_tagnos:
seen_tagnos.add(tag_no)
all_tags.append(tag)
count_new += 1
logging.info(f"[{basename}] {name}: {len(tags)}개 중 {count_new}개 신규")
except Exception as e:
logging.error(f"[{basename}] 결과 로드 실패 {name}: {e}")
logging.info(f"[{basename}] Phase 4 완료 ({time.time()-t4:.1f}s) - 총 {len(all_tags)}개 태그")
# ── Phase 5: 위상 그래프 빌드 + 저장 ────────────────────────────
t5 = time.time()
logging.info(f"[{basename}] Phase 5: 위상 그래프 빌드")
builder = PidTopologyBuilder(geo_data)
builder.build_graph()
# 추출된 태그를 그래프에 추가
from shapely.geometry import box as shapely_box
for tag in all_tags:
tag_no = tag.get("tagNo", "UNKNOWN")
eq_name = tag.get("equipmentName")
inst_type = tag.get("instrumentType")
confidence = tag.get("confidence", 0.5)
# 해당 태그의 bbox 찾기 (geo_data에서 clean_value 매칭)
matched_bbox = None
for entity in geo_data:
if entity.get("clean_value", "").upper() == tag_no.upper():
bbox = entity.get("bbox", {})
matched_bbox = (
bbox.get("min_x"), bbox.get("min_y"),
bbox.get("max_x"), bbox.get("max_y")
)
break
node_id = f"tag_{tag_no}"
if matched_bbox:
bbox_geom = shapely_box(matched_bbox[0], matched_bbox[1],
matched_bbox[2], matched_bbox[3])
builder.G.add_node(node_id,
type="TEXT",
bbox=bbox_geom,
value=tag_no,
equipment_name=eq_name,
instrument_type=inst_type,
confidence=confidence)
else:
builder.G.add_node(node_id,
type="TEXT",
bbox=shapely_box(0, 0, 1, 1),
value=tag_no,
equipment_name=eq_name,
instrument_type=inst_type,
confidence=confidence)
# 태그-설비 연결
equipments = [n for n, d in builder.G.nodes(data=True)
if d.get("type") not in ("TEXT", "LINE", "LWPOLYLINE")]
if equipments:
eq_grid = builder._build_spatial_grid(equipments)
tag_ids = [f"tag_{t.get('tagNo', '')}" for t in all_tags]
for tag_id in tag_ids:
if tag_id in builder.G:
best_match = builder._find_nearest_equipment(tag_id, eq_grid)
if best_match:
builder.G.add_edge(tag_id, best_match, relation="associated_with")
graph_id = basename.replace(".dxf", "_graph.json")
graph_path = os.path.join(STORAGE_DIR, graph_id)
builder.save_graph(graph_path)
# 임시 디렉토리 정리
shutil.rmtree(temp_dir, ignore_errors=True)
total_time = time.time() - t0
logging.info(f"[{basename}] 전체 완료 ({total_time:.1f}s) - graph_id={graph_id} "
f"nodes={builder.G.number_of_nodes()} "
f"edges={builder.G.number_of_edges()} "
f"tags={len(all_tags)}")
return json.dumps({
"success": True,
"graph_id": graph_id,
"graph_path": graph_path,
"nodes": builder.G.number_of_nodes(),
"edges": builder.G.number_of_edges(),
"tags_extracted": len(all_tags),
"processing_time_sec": round(total_time, 1)
}, ensure_ascii=False)
def _analyze_pid_impact(graph_id: str, start_node_id: str) -> str:
from pipeline.analyzer import PidAnalysisEngine
graph_path = os.path.join(STORAGE_DIR, graph_id)
mapping_path = graph_path.replace("_graph.json", "_mapping.json")
analyzer = PidAnalysisEngine(graph_path, mapping_path)
result = analyzer.analyze_impact(start_node_id)
return json.dumps(result, ensure_ascii=False, indent=2)
# ── 요청 디스패처 ─────────────────────────────────────────────────────────────
async def _dispatch(tool: str, params: dict) -> str:
try:
match tool:
# blocking 함수는 asyncio.to_thread로 스레드풀 오프로드
case "extract_pid_tags":
return await asyncio.to_thread(_extract_pid_tags, **params)
case "match_pid_tags":
return await asyncio.to_thread(_match_pid_tags, **params)
case "parse_pid_dxf":
return await asyncio.to_thread(_parse_pid_dxf, **params)
case "parse_pid_pdf":
return await asyncio.to_thread(_parse_pid_pdf, **params)
case "parse_pid_drawing":
return await asyncio.to_thread(_parse_pid_drawing, **params)
case "analyze_pid_impact":
return await asyncio.to_thread(_analyze_pid_impact, **params)
# 이미 async — 직접 await
case "build_pid_graph_parallel":
return await _build_pid_graph_parallel(**params)
case _:
return json.dumps({"success": False, "error": f"알 수 없는 도구: {tool}"},
ensure_ascii=False)
except Exception as e:
logging.error(f"dispatch error tool={tool}: {e}", exc_info=True)
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
# ── 종료 예약 ─────────────────────────────────────────────────────────────────
def _schedule_shutdown():
"""
응답 전송 완료 후 프로세스 종료 예약.
FastAPI의 BackgroundTasks를 사용하여 응답이 완전히 전송된 후 종료되도록 유도함.
"""
async def _do():
# 네트워크 전송 및 커넥션 정리를 위한 최소한의 대기 시간
await asyncio.sleep(1.0)
logging.info("One-shot worker shutting down...")
os.kill(os.getpid(), signal.SIGTERM)
asyncio.create_task(_do())
# ── HTTP 엔드포인트 ───────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
body = await request.json()
return await _dispatch(body["tool"], body["params"])
from fastapi import BackgroundTasks
@app.post("/execute/one_shot")
async def execute_one_shot(request: Request, background_tasks: BackgroundTasks):
"""요청 처리 후 프로세스 자동 종료 (P&ID 워커 전용)."""
body = await request.json()
result = await _dispatch(body["tool"], body["params"])
# BackgroundTasks에 등록하여 응답 전송이 완료된 후 _schedule_shutdown이 실행되도록 함
background_tasks.add_function(_schedule_shutdown)
return result
# ── 진입점 ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5004
os.makedirs(STORAGE_DIR, exist_ok=True)
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")

View File

@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""RAG 전용 워커 프로세스
Usage: python rag_worker.py <port>
담당 도구:
search_codebase, search_r530_docs, ask_iiot_llm, rag_query
특징:
- Ollama Embedding + Qdrant 검색 + vLLM LLM 조합
- 메모리: ~200MB (워커 자체, vLLM 외부 서비스 사용 시)
- 생명주기: 메인 서버 종료 시까지 유지
"""
from __future__ import annotations
import sys
import os
# mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import logging
import asyncio
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
import httpx
# ── 설정 ─────────────────────────────────────────────────────────────────────
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
QDRANT_URL = os.environ.get("QDRANT_URL", "http://localhost:6333")
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
EMBED_MODEL = os.environ.get("EMBED_MODEL", "nomic-embed-text")
COL_CODEBASE = os.environ.get("COL_CODEBASE", "ws-65f457145aee80b2")
COL_OPC_DOCS = os.environ.get("COL_OPC_DOCS", "experion-opc-docs")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [rag_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── HTTP 클라이언트 싱글톤 ────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _get_http_client():
return httpx.AsyncClient(timeout=30)
# ── 임베딩 (Ollama) ───────────────────────────────────────────────────────────
async def _embed(text: str) -> list[float]:
"""Ollama nomic-embed-text로 768-dim 벡터 생성."""
async with _get_http_client() as client:
resp = await client.post(
f"{OLLAMA_URL}/api/embeddings",
json={"model": EMBED_MODEL, "prompt": text},
)
resp.raise_for_status()
return resp.json()["embedding"]
# ── Qdrant 검색 ──────────────────────────────────────────────────────────────
async def _qdrant_search(collection: str, query_vector: list[float], top_k: int = 6) -> list[dict]:
"""Qdrant에서 벡터 유사도 검색."""
async with _get_http_client() as client:
resp = await client.post(
f"{QDRANT_URL}/collections/{collection}/points/search",
json={
"vector": query_vector,
"limit": top_k,
"with_payload": True,
},
)
resp.raise_for_status()
return resp.json().get("result", [])
# ── LLM (vLLM) ───────────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm_client():
from openai import AsyncOpenAI
return AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
async def _ask_llm(question: str, context: str = "") -> str:
"""vLLM LLM으로 질문 응답."""
client = _llm_client()
if context:
prompt = f"""주어진 컨텍스트를 바탕으로 질문에 답변하세요.
컨텍스트:
{context}
질문:
{question}
답변:"""
else:
prompt = question
response = await client.chat.completions.create(
model=VLLM_MODEL,
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": prompt},
],
max_tokens=4096,
temperature=0.1,
)
return response.choices[0].message.content
# ── RAG 도구 구현 ─────────────────────────────────────────────────────────────
@app.get("/health")
async def health():
"""워커 헬스체크."""
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
"""HTTP 요청을 MCP 도구 호출로 변환."""
body = await request.json()
tool = body["tool"]
params = body["params"]
try:
if tool == "search_codebase":
result = await _search_codebase(**params)
elif tool == "search_r530_docs":
result = await _search_r530_docs(**params)
elif tool == "ask_iiot_llm":
result = await _ask_iiot_llm(**params)
elif tool == "rag_query":
result = await _rag_query(**params)
else:
return {"success": False, "error": f"Unknown tool: {tool}"}
return result
except Exception as e:
logging.error(f"Error executing {tool}: {e}")
return {"success": False, "error": str(e)}
async def _search_codebase(query: str, top_k: int = 6) -> str:
"""소스코드 검색."""
query_vector = await _embed(query)
results = await _qdrant_search(COL_CODEBASE, query_vector, top_k)
items = []
for hit in results:
payload = hit.get("payload", {})
items.append({
"score": hit.get("score", 0),
"file": payload.get("file", "unknown"),
"content": payload.get("content", "")[:500],
})
return {
"success": True,
"count": len(items),
"items": items,
}
async def _search_r530_docs(query: str, top_k: int = 5) -> str:
"""Experion HS R530 공식 문서 검색."""
query_vector = await _embed(query)
results = await _qdrant_search(COL_OPC_DOCS, query_vector, top_k)
items = []
for hit in results:
payload = hit.get("payload", {})
items.append({
"score": hit.get("score", 0),
"title": payload.get("title", "unknown"),
"content": payload.get("content", "")[:500],
})
return {
"success": True,
"count": len(items),
"items": items,
}
async def _ask_iiot_llm(question: str, context: str = "") -> str:
"""IIoT/OPC UA 질문 응답."""
answer = await _ask_llm(question, context)
return {
"success": True,
"question": question,
"answer": answer,
}
async def _rag_query(question: str, search_code: bool = False, search_docs: bool = True) -> str:
"""통합 RAG 검색."""
contexts = []
if search_code:
query_vector = await _embed(question)
code_results = await _qdrant_search(COL_CODEBASE, query_vector, 3)
for hit in code_results:
contexts.append(hit.get("payload", {}).get("content", ""))
if search_docs:
query_vector = await _embed(question)
doc_results = await _qdrant_search(COL_OPC_DOCS, query_vector, 3)
for hit in doc_results:
contexts.append(hit.get("payload", {}).get("content", ""))
context = "\n\n".join(contexts[:5])
answer = await _ask_llm(question, context)
return {
"success": True,
"question": question,
"context_count": len(contexts),
"answer": answer,
}
# ── 메인 ─────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5002
logging.info(f"Starting RAG worker on port {port}")
uvicorn.run(app, host="0.0.0.0", port=port)

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""
DXF 파일에서 P&ID 태그 추출 스크립트
ezdxf + OpenAI API 사용 (청크 단위 3회 호출)
"""
import ezdxf
import json
import re
from ezdxf.tools.text import plain_mtext
from openai import OpenAI
# DXF 파일 경로
filepath = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100.dxf'
# DXF 파일 읽기
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
# 텍스트 추출
texts = []
for entity in msp:
if entity.dxftype() == 'TEXT':
texts.append(entity.dxf.text)
elif entity.dxftype() == 'MTEXT':
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
text = '\n'.join(texts)
print(f'총 텍스트: {len(text)}')
# OpenAI 클라이언트 생성 (타임아웃 1800초 = 30분)
llm = OpenAI(
base_url='http://localhost:8000/v1',
api_key='dummy',
timeout=1800
)
# 청크별 프롬프트
chunks = [
{
'name': 'Gauges',
'system': (
'You are a P&ID expert. Extract instruments tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: PG, TG, LG\n'
'Format: [{"tagNo":"PG-10101A","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of PG, TG,LG from the text below:\n\n{text}'
}
]
# 결과 저장
all_tags = []
seen_tags = set()
for chunk in chunks:
print(f'\n=== {chunk["name"]} ===')
# 프롬프트 생성
system = chunk['system']
user = chunk['user'].format(text=text[:100000])
# LLM 호출
resp = llm.chat.completions.create(
model='Qwen3.6-27B-FP8',
messages=[
{'role': 'system', 'content': system},
{'role': 'user', 'content': user},
],
max_tokens=65536,
temperature=0.1,
extra_body={'chat_template_kwargs': {'enable_thinking': False}},
)
raw = (resp.choices[0].message.content or '').strip()
finish = resp.choices[0].finish_reason
print(f'응답 길이: {len(raw)}자, finish_reason: {finish}')
# 잘린 경우 복구
if finish == 'length' and not raw.rstrip().endswith(']'):
last = raw.rfind('}')
if last != -1:
raw = raw[:last+1] + ']'
print('잘린 JSON 복구')
# 배열 추출
depth = 0
start = -1
best = ''
for i, c in enumerate(raw):
if c == '[':
if depth == 0:
start = i
depth += 1
elif c == ']':
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i+1]
if len(cand) > len(best):
best = cand
# JSON 파싱 및 결과 저장
try:
data = json.loads(best) if best else []
print(f'추출된 태그 수: {len(data)}')
for item in data:
tag_no = item.get('tagNo')
if tag_no and tag_no not in seen_tags:
seen_tags.add(tag_no)
all_tags.append({
'tagNo': tag_no,
'confidence': item.get('confidence', 0.95)
})
print(f'중복 제거 후 총 태그 수: {len(all_tags)}')
except json.JSONDecodeError as e:
print(f'파싱 실패: {e}')
print(repr(best[-300:] if best else ''))
# 최종 결과
print(f'\n=== 최종 결과 ===')
print(f'총 추출 태그 수: {len(all_tags)}')
print()
for t in all_tags[:30]:
print(f' {t["tagNo"]}')
if len(all_tags) > 30:
print(f' ... (총 {len(all_tags)}개)')
# JSON 파일로 저장
output_path = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100_extracted_PG.json'
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(all_tags, f, indent=2, ensure_ascii=False)
print(f'\n결과 저장: {output_path}')

View File

@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""
DXF 파일에서 P&ID 태그 추출 스크립트
ezdxf + OpenAI API 사용 (청크 단위 3회 호출)
"""
import ezdxf
import json
import re
from ezdxf.tools.text import plain_mtext
from openai import OpenAI
# DXF 파일 경로
filepath = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100.dxf'
# DXF 파일 읽기
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
# 텍스트 추출
texts = []
for entity in msp:
if entity.dxftype() == 'TEXT':
texts.append(entity.dxf.text)
elif entity.dxftype() == 'MTEXT':
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
text = '\n'.join(texts)
print(f'총 텍스트: {len(text)}')
# OpenAI 클라이언트 생성 (타임아웃 1800초 = 30분)
llm = OpenAI(
base_url='http://localhost:8000/v1',
api_key='dummy',
timeout=1800
)
# 청크별 프롬프트
chunks = [
{
'name': 'Field Instruments - Sensors',
'system': (
'You are a P&ID expert. Extract sensor tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: FT, FIT, LT, PT, TE, LI, PI, TI\n'
'Format: [{"tagNo":"FT-10101","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of FT, FIT, LT, PT, TE, LI, PI, TI, FIQ from the text below:\n\n{text}'
},
{
'name': 'Field Instruments - Valves',
'system': (
'You are a P&ID expert. Extract valve tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: FCV, TCV, LCV, PCV, XV, FV, LV, PV, TV\n'
'Format: [{"tagNo":"FCV-10101","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of FCV, TCV, LCV, PCV, XV, FV, LV, PV, TV from the text below:\n\n{text}'
},
{
'name': 'System Tags',
'system': (
'You are a P&ID expert. Extract system tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: LI, PI, TI, FIQ, FICQ, TICA, PICA, LICA, FIC, TIC, PIC, LIC\n'
'Format: [{"tagNo":"FICQ-10101","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of LI, PI, TI, FIQ, FICQ, TICA, PICA, LICA, FIC, TIC, PIC, LIC from the text below:\n\n{text}'
}
]
# 결과 저장
all_tags = []
seen_tags = set()
for chunk in chunks:
print(f'\n=== {chunk["name"]} ===')
# 프롬프트 생성
system = chunk['system']
user = chunk['user'].format(text=text[:100000])
# LLM 호출
resp = llm.chat.completions.create(
model='Qwen3.6-27B-FP8',
messages=[
{'role': 'system', 'content': system},
{'role': 'user', 'content': user},
],
max_tokens=65536,
temperature=0.7,
extra_body={'chat_template_kwargs': {'enable_thinking': False}},
)
raw = (resp.choices[0].message.content or '').strip()
finish = resp.choices[0].finish_reason
print(f'응답 길이: {len(raw)}자, finish_reason: {finish}')
# 잘린 경우 복구
if finish == 'length' and not raw.rstrip().endswith(']'):
last = raw.rfind('}')
if last != -1:
raw = raw[:last+1] + ']'
print('잘린 JSON 복구')
# 배열 추출
depth = 0
start = -1
best = ''
for i, c in enumerate(raw):
if c == '[':
if depth == 0:
start = i
depth += 1
elif c == ']':
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i+1]
if len(cand) > len(best):
best = cand
# JSON 파싱 및 결과 저장
try:
data = json.loads(best) if best else []
print(f'추출된 태그 수: {len(data)}')
for item in data:
tag_no = item.get('tagNo')
if tag_no and tag_no not in seen_tags:
seen_tags.add(tag_no)
all_tags.append({
'tagNo': tag_no,
'confidence': item.get('confidence', 0.95)
})
print(f'중복 제거 후 총 태그 수: {len(all_tags)}')
except json.JSONDecodeError as e:
print(f'파싱 실패: {e}')
print(repr(best[-300:] if best else ''))
# 최종 결과
print(f'\n=== 최종 결과 ===')
print(f'총 추출 태그 수: {len(all_tags)}')
print()
for t in all_tags[:30]:
print(f' {t["tagNo"]}')
if len(all_tags) > 30:
print(f' ... (총 {len(all_tags)}개)')
# JSON 파일로 저장
output_path = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100_extracted.json'
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(all_tags, f, indent=2, ensure_ascii=False)
print(f'\n결과 저장: {output_path}')

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""
DXF 파일에서 P&ID 태그 추출 스크립트
ezdxf + OpenAI API 사용 (청크 단위 3회 호출)
"""
import ezdxf
import json
import re
from ezdxf.tools.text import plain_mtext
from openai import OpenAI
# DXF 파일 경로
filepath = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100.dxf'
# DXF 파일 읽기
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
# 텍스트 추출
texts = []
for entity in msp:
if entity.dxftype() == 'TEXT':
texts.append(entity.dxf.text)
elif entity.dxftype() == 'MTEXT':
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
text = '\n'.join(texts)
print(f'총 텍스트: {len(text)}')
# OpenAI 클라이언트 생성 (타임아웃 1800초 = 30분)
llm = OpenAI(
base_url='http://localhost:8000/v1',
api_key='dummy',
timeout=1800
)
# 청크별 프롬프트
chunks = [
{
'name': 'Field Instruments - Sensors',
'system': (
'You are a P&ID expert. Extract sensor tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: FT, FIT, LT, PT, TE, PG, LG, TG\n'
'Format: [{"tagNo":"FT-10101","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of FT, FIT, LT, PT, TE, PG, LG, TG from the text below:\n\n{text}'
}
]
# 결과 저장
all_tags = []
seen_tags = set()
for chunk in chunks:
print(f'\n=== {chunk["name"]} ===')
# 프롬프트 생성
system = chunk['system']
user = chunk['user'].format(text=text[:100000])
# LLM 호출
resp = llm.chat.completions.create(
model='Qwen3.6-27B-FP8',
messages=[
{'role': 'system', 'content': system},
{'role': 'user', 'content': user},
],
max_tokens=65536,
temperature=0.1,
extra_body={'chat_template_kwargs': {'enable_thinking': False}},
)
raw = (resp.choices[0].message.content or '').strip()
finish = resp.choices[0].finish_reason
print(f'응답 길이: {len(raw)}자, finish_reason: {finish}')
# 잘린 경우 복구
if finish == 'length' and not raw.rstrip().endswith(']'):
last = raw.rfind('}')
if last != -1:
raw = raw[:last+1] + ']'
print('잘린 JSON 복구')
# 배열 추출
depth = 0
start = -1
best = ''
for i, c in enumerate(raw):
if c == '[':
if depth == 0:
start = i
depth += 1
elif c == ']':
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i+1]
if len(cand) > len(best):
best = cand
# JSON 파싱 및 결과 저장
try:
data = json.loads(best) if best else []
print(f'추출된 태그 수: {len(data)}')
for item in data:
tag_no = item.get('tagNo')
if tag_no and tag_no not in seen_tags:
seen_tags.add(tag_no)
all_tags.append({
'tagNo': tag_no,
'confidence': item.get('confidence', 0.95)
})
print(f'중복 제거 후 총 태그 수: {len(all_tags)}')
except json.JSONDecodeError as e:
print(f'파싱 실패: {e}')
print(repr(best[-300:] if best else ''))
# 최종 결과
print(f'\n=== 최종 결과 ===')
print(f'총 추출 태그 수: {len(all_tags)}')
print()
for t in all_tags[:30]:
print(f' {t["tagNo"]}')
if len(all_tags) > 30:
print(f' ... (총 {len(all_tags)}개)')
# JSON 파일로 저장
output_path = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100_extracted1.json'
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(all_tags, f, indent=2, ensure_ascii=False)
print(f'\n결과 저장: {output_path}')

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""
DXF 파일에서 P&ID 태그 추출 스크립트
ezdxf + OpenAI API 사용 (청크 단위 3회 호출)
"""
import ezdxf
import json
import re
from ezdxf.tools.text import plain_mtext
from openai import OpenAI
# DXF 파일 경로
filepath = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100.dxf'
# DXF 파일 읽기
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
# 텍스트 추출
texts = []
for entity in msp:
if entity.dxftype() == 'TEXT':
texts.append(entity.dxf.text)
elif entity.dxftype() == 'MTEXT':
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
text = '\n'.join(texts)
print(f'총 텍스트: {len(text)}')
# OpenAI 클라이언트 생성 (타임아웃 1800초 = 30분)
llm = OpenAI(
base_url='http://localhost:8000/v1',
api_key='dummy',
timeout=1800
)
# 청크별 프롬프트
chunks = [
{
'name': 'Field Instruments - Valves',
'system': (
'You are a P&ID expert. Extract valve tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: FCV, TCV, LCV, PCV, XV, FV, LV, PV, TV\n'
'Format: [{"tagNo":"FCV-10101","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of FCV, TCV, LCV, PCV, XV, FV, LV, PV, TV from the text below:\n\n{text}'
}
]
# 결과 저장
all_tags = []
seen_tags = set()
for chunk in chunks:
print(f'\n=== {chunk["name"]} ===')
# 프롬프트 생성
system = chunk['system']
user = chunk['user'].format(text=text[:100000])
# LLM 호출
resp = llm.chat.completions.create(
model='Qwen3.6-27B-FP8',
messages=[
{'role': 'system', 'content': system},
{'role': 'user', 'content': user},
],
max_tokens=65536,
temperature=0.1,
extra_body={'chat_template_kwargs': {'enable_thinking': False}},
)
raw = (resp.choices[0].message.content or '').strip()
finish = resp.choices[0].finish_reason
print(f'응답 길이: {len(raw)}자, finish_reason: {finish}')
# 잘린 경우 복구
if finish == 'length' and not raw.rstrip().endswith(']'):
last = raw.rfind('}')
if last != -1:
raw = raw[:last+1] + ']'
print('잘린 JSON 복구')
# 배열 추출
depth = 0
start = -1
best = ''
for i, c in enumerate(raw):
if c == '[':
if depth == 0:
start = i
depth += 1
elif c == ']':
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i+1]
if len(cand) > len(best):
best = cand
# JSON 파싱 및 결과 저장
try:
data = json.loads(best) if best else []
print(f'추출된 태그 수: {len(data)}')
for item in data:
tag_no = item.get('tagNo')
if tag_no and tag_no not in seen_tags:
seen_tags.add(tag_no)
all_tags.append({
'tagNo': tag_no,
'confidence': item.get('confidence', 0.95)
})
print(f'중복 제거 후 총 태그 수: {len(all_tags)}')
except json.JSONDecodeError as e:
print(f'파싱 실패: {e}')
print(repr(best[-300:] if best else ''))
# 최종 결과
print(f'\n=== 최종 결과 ===')
print(f'총 추출 태그 수: {len(all_tags)}')
print()
for t in all_tags[:30]:
print(f' {t["tagNo"]}')
if len(all_tags) > 30:
print(f' ... (총 {len(all_tags)}개)')
# JSON 파일로 저장
output_path = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100_extracted2.json'
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(all_tags, f, indent=2, ensure_ascii=False)
print(f'\n결과 저장: {output_path}')

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""
DXF 파일에서 P&ID 태그 추출 스크립트
ezdxf + OpenAI API 사용 (청크 단위 3회 호출)
"""
import ezdxf
import json
import re
from ezdxf.tools.text import plain_mtext
from openai import OpenAI
# DXF 파일 경로
filepath = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100.dxf'
# DXF 파일 읽기
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
# 텍스트 추출
texts = []
for entity in msp:
if entity.dxftype() == 'TEXT':
texts.append(entity.dxf.text)
elif entity.dxftype() == 'MTEXT':
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
text = '\n'.join(texts)
print(f'총 텍스트: {len(text)}')
# OpenAI 클라이언트 생성 (타임아웃 1800초 = 30분)
llm = OpenAI(
base_url='http://localhost:8000/v1',
api_key='dummy',
timeout=1800
)
# 청크별 프롬프트
chunks = [
{
'name': 'System Tags',
'system': (
'You are a P&ID expert. Extract system tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: LI, PI, TI, FIQ, FICQ, TICA, PICA, LICA, FIC, TIC, PIC, LIC\n'
'Format: [{"tagNo":"FICQ-10101","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of LI, PI, TI, FIQ, FICQ, TICA, PICA, LICA, FIC, TIC, PIC, LIC from the text below:\n\n{text}'
}
]
# 결과 저장
all_tags = []
seen_tags = set()
for chunk in chunks:
print(f'\n=== {chunk["name"]} ===')
# 프롬프트 생성
system = chunk['system']
user = chunk['user'].format(text=text[:100000])
# LLM 호출
resp = llm.chat.completions.create(
model='Qwen3.6-27B-FP8',
messages=[
{'role': 'system', 'content': system},
{'role': 'user', 'content': user},
],
max_tokens=65536,
temperature=0.1,
extra_body={'chat_template_kwargs': {'enable_thinking': False}},
)
raw = (resp.choices[0].message.content or '').strip()
finish = resp.choices[0].finish_reason
print(f'응답 길이: {len(raw)}자, finish_reason: {finish}')
# 잘린 경우 복구
if finish == 'length' and not raw.rstrip().endswith(']'):
last = raw.rfind('}')
if last != -1:
raw = raw[:last+1] + ']'
print('잘린 JSON 복구')
# 배열 추출
depth = 0
start = -1
best = ''
for i, c in enumerate(raw):
if c == '[':
if depth == 0:
start = i
depth += 1
elif c == ']':
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i+1]
if len(cand) > len(best):
best = cand
# JSON 파싱 및 결과 저장
try:
data = json.loads(best) if best else []
print(f'추출된 태그 수: {len(data)}')
for item in data:
tag_no = item.get('tagNo')
if tag_no and tag_no not in seen_tags:
seen_tags.add(tag_no)
all_tags.append({
'tagNo': tag_no,
'confidence': item.get('confidence', 0.95)
})
print(f'중복 제거 후 총 태그 수: {len(all_tags)}')
except json.JSONDecodeError as e:
print(f'파싱 실패: {e}')
print(repr(best[-300:] if best else ''))
# 최종 결과
print(f'\n=== 최종 결과 ===')
print(f'총 추출 태그 수: {len(all_tags)}')
print()
for t in all_tags[:30]:
print(f' {t["tagNo"]}')
if len(all_tags) > 30:
print(f' ... (총 {len(all_tags)}개)')
# JSON 파일로 저장
output_path = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100_extracted3.json'
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(all_tags, f, indent=2, ensure_ascii=False)
print(f'\n결과 저장: {output_path}')

View File

@@ -0,0 +1,609 @@
#!/usr/bin/env python3
"""P&ID 파싱 전용 워커 프로세스
Usage: python pid_worker.py <port>
담당 도구:
extract_pid_tags, match_pid_tags,
parse_pid_dxf, parse_pid_pdf, parse_pid_drawing,
build_pid_graph_parallel, analyze_pid_impact
"""
from __future__ import annotations
import sys
import os
# mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import io
import json
import asyncio
import signal
import logging
import re
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
# ── 설정 ─────────────────────────────────────────────────────────────────────
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "glm-4.7-flash")
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
_SERVER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STORAGE_DIR = os.path.join(_SERVER_DIR, "storage")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [pid_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── 싱글톤 ───────────────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm():
from openai import OpenAI
return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
@lru_cache(maxsize=1)
def _ocr():
from paddleocr import PaddleOCR
use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true"
try:
return PaddleOCR(use_angle_cls=True, lang="korean", use_gpu=use_gpu, show_log=False)
except Exception:
if use_gpu:
os.environ["PADDLE_USE_GPU"] = "false"
return _ocr()
raise
# ── DB ───────────────────────────────────────────────────────────────────────
def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
# ── 텍스트 추출 ──────────────────────────────────────────────────────────────
def _extract_text_from_dxf(filepath: str) -> str:
import ezdxf
from ezdxf.tools.text import plain_mtext
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
texts = []
for entity in msp:
if entity.dxftype() == "TEXT":
texts.append(entity.dxf.text)
elif entity.dxftype() == "MTEXT":
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
return "\n".join(texts)
def _extract_text_from_pdf(filepath: str) -> str:
import fitz
doc = fitz.open(filepath)
return "\n".join(page.get_text() for page in doc)
def _extract_text_from_pdf_ocr(filepath: str) -> str:
import fitz
from PIL import Image
import numpy as np
doc = fitz.open(filepath)
all_texts = []
for page in doc:
mat = fitz.Matrix(300 / 72)
pix = page.get_pixmap(matrix=mat)
img = Image.open(io.BytesIO(pix.tobytes("png")))
result = _ocr().ocr(np.array(img), cls=True)
if result and result[0]:
all_texts.extend(line[1][0] for line in result[0])
return "\n".join(all_texts)
# ── JSON 배열 파싱 유틸 ───────────────────────────────────────────────────────
def _parse_json_array(raw: str, finish_reason: str = "") -> list:
"""LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
if raw.startswith("```"):
lines = raw.splitlines()
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
if finish_reason == "length":
last_close = raw.rfind("}")
if last_close != -1:
raw = raw[:last_close + 1] + "]"
# 가장 긴 균형 잡힌 [...] 추출
depth = 0; start = -1; best = ""
for i, c in enumerate(raw):
if c == "[":
if depth == 0:
start = i
depth += 1
elif c == "]":
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i + 1]
if len(cand) > len(best):
best = cand
raw = best if best else "[]"
try:
return json.loads(raw)
except json.JSONDecodeError:
data = []
for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
try:
data.append(json.loads(obj))
except json.JSONDecodeError:
pass
return data
# ── 태그 추출/매핑 도구 ───────────────────────────────────────────────────────
def _extract_pid_tags(text: str, source_type: str) -> str:
system = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract all instrument and equipment tags from the provided text.\n"
"Return ONLY a valid JSON array. Each element must have exactly these fields:\n"
'{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV",'
'"lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n'
"Rules:\n"
"- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n"
" Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n"
"- instrumentType: leading letters of tagNo\n"
"- equipmentName: descriptive name if present near tag, else null\n"
"- lineNumber/pidDrawingNo: null unless explicitly associated\n"
"- confidence: 0.95 for clear tags, lower for ambiguous\n"
"- Output ONLY the JSON array, no markdown, no explanation.\n"
"- If no tags found, return: []\n"
)
truncated = text[:100000]
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"},
],
max_tokens=32768,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
logging.info(f"extract_pid_tags source={source_type} count={len(data)}")
return json.dumps({
"success": True,
"data": {"count": len(data), "tags": data},
"message": "태그 추출 완료"
}, ensure_ascii=False, indent=2)
def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
system = (
"You are a P&ID to Experion tag matching expert.\n"
"Match P&ID tags to Experion tags based on similarity.\n"
"Return ONLY a JSON array:\n"
'[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n'
"- If no good match: confidence < 0.5, experionTag null\n"
"- Output ONLY the JSON array.\n"
)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": (
f"P&ID Tags:\n{chr(10).join(pid_tags)}\n\n"
f"Experion Tags:\n{chr(10).join(experion_tags)}"
)},
],
max_tokens=16384,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
return json.dumps({
"success": True,
"data": {"count": len(data), "mappings": data},
"message": "태그 매핑 완료"
}, ensure_ascii=False, indent=2)
# ── 도면 파싱 도구 ────────────────────────────────────────────────────────────
_TAG_EXTRACT_SYSTEM = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract instrument and equipment tags from the provided text.\n"
"Return ONLY a JSON array:\n"
'[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FIT",'
'"lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n'
"Rules:\n"
"- tagNo: Instrument [Function]-[Number], Equipment [Type]-[Number]\n"
"- instrumentType: first 2-4 letters of tagNo\n"
"- equipmentName/lineNumber/pidDrawingNo: null if not present\n"
"- confidence: 0.0 to 1.0\n"
"- Output ONLY the JSON array, no markdown.\n"
"- If no tags found, return: []\n"
)
def _parse_pid_dxf(filepath: str) -> str:
text = _extract_text_from_dxf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: dxf\n\nText:\n{text[:8000]}"},
],
max_tokens=8192,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "DXF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
text = _extract_text_from_pdf_ocr(filepath) if use_ocr else _extract_text_from_pdf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"},
],
max_tokens=4096,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "PDF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_drawing(filepath: str) -> str:
ext = os.path.splitext(filepath)[1].lower()
if ext == ".dxf":
return _parse_pid_dxf(filepath)
elif ext == ".pdf":
return _parse_pid_pdf(filepath)
elif ext == ".dwg":
return json.dumps({
"success": False,
"data": None,
"error": "DWG 파일은 직접 파싱할 수 없습니다. DXF로 변환 후 사용하세요.",
"message": "지원하지 않는 파일 형식"
}, ensure_ascii=False)
else:
return json.dumps({
"success": False,
"error": f"지원하지 않는 형식: {ext}. 지원: .dxf, .pdf",
}, ensure_ascii=False)
# ── 그래프 도구 ───────────────────────────────────────────────────────────────
import time
async def _build_pid_graph_parallel(filepath: str) -> str:
"""
P&ID 그래프 빌드 — 독립 프로세스 병렬 아키텍처.
Phase 1: 도면 분할 + 기하 추출
Phase 2: 전체 텍스트 1회 추출
Phase 3: 5개 독립 추출 프로세스 병렬 실행 (subprocess.Popen)
Phase 4: 결과 통합 + tagNo 기준 중복 제거
Phase 5: 위상 그래프 빌드 + 저장
"""
from pipeline.extractor import PidGeometricExtractor
from pipeline.topology import PidTopologyBuilder
import subprocess
import tempfile
import shutil
os.makedirs(STORAGE_DIR, exist_ok=True)
t0 = time.time()
basename = os.path.basename(filepath)
worker_dir = os.path.dirname(os.path.abspath(__file__))
logging.info(f"[{basename}] === 독립 프로세스 병렬 아키텍처 시작 ===")
# ── Phase 1: 도면 분할 + 기하 추출 ──────────────────────────────
logging.info(f"[{basename}] Phase 1: 도면 분할 + 기하 추출 시작")
extractor = PidGeometricExtractor(filepath)
regions = extractor.split_drawings()
logging.info(f"[{basename}] 도면 분할: {len(regions)}개 영역")
geo_data_path = os.path.join(STORAGE_DIR, basename + "_geo.json")
extractor.extract_and_save(geo_data_path)
with open(geo_data_path, "r", encoding="utf-8") as f:
geo_data = json.load(f)
logging.info(f"[{basename}] Phase 1 완료 ({time.time()-t0:.1f}s) - {len(geo_data)}개 엔티티")
# ── Phase 2: 전체 텍스트 1회 추출 ───────────────────────────────
t2 = time.time()
logging.info(f"[{basename}] Phase 2: 전체 텍스트 1회 추출")
full_text = _extract_text_from_dxf(filepath)
# 임시 디렉토리 생성 (프로세스 간 통신용)
temp_dir = tempfile.mkdtemp(prefix=f"pid_{basename.replace('.dxf', '')}_")
text_path = os.path.join(temp_dir, "full_text.txt")
with open(text_path, "w", encoding="utf-8") as f:
f.write(full_text)
logging.info(f"[{basename}] Phase 2 완료 ({time.time()-t2:.1f}s) - {len(full_text)}")
# ── Phase 3: 5개 독립 추출 프로세스 병렬 실행 ───────────────────
t3 = time.time()
logging.info(f"[{basename}] Phase 3: 5개 독립 추출 프로세스 병렬 실행")
extractors = [
("sensor", "pid_extract_sensor.py"),
("valve", "pid_extract_valve.py"),
("system", "pid_extract_system.py"),
("gauge", "pid_extract_gauge.py"),
("pump", "pid_extract_pump.py"),
]
results_dir = os.path.join(temp_dir, "results")
os.makedirs(results_dir, exist_ok=True)
processes = []
for name, script in extractors:
output_path = os.path.join(results_dir, f"{name}.json")
script_path = os.path.join(worker_dir, script)
cmd = [
sys.executable, script_path,
"--input", text_path,
"--output", output_path,
]
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
processes.append((name, proc, output_path))
logging.info(f"[{basename}] 시작: {name} (pid={proc.pid})")
except Exception as e:
logging.error(f"[{basename}] 실행 실패 {name}: {e}")
# 모든 프로세스 대기 (timeout=300초)
for name, proc, output_path in processes:
try:
stdout, stderr = proc.communicate(timeout=300)
if proc.returncode != 0:
logging.error(f"[{basename}] {name} 실패 (rc={proc.returncode}): {stderr[:200]}")
else:
logging.info(f"[{basename}] {name} 완료: {stdout.strip()[:100]}")
except subprocess.TimeoutExpired:
proc.kill()
logging.error(f"[{basename}] {name} 타임아웃 (300초)")
logging.info(f"[{basename}] Phase 3 완료 ({time.time()-t3:.1f}s)")
# ── Phase 4: 결과 JSON 통합 + tagNo 기준 중복 제거 ──────────────
t4 = time.time()
logging.info(f"[{basename}] Phase 4: 결과 통합 + 중복 제거")
all_tags = []
seen_tagnos = set()
for name, _, output_path in processes:
if not os.path.exists(output_path):
logging.warning(f"[{basename}] 결과 없음: {name}")
continue
try:
with open(output_path, "r", encoding="utf-8") as f:
result = json.load(f)
tags = result.get("tags", [])
count_new = 0
for tag in tags:
tag_no = tag.get("tagNo", "")
if tag_no and tag_no not in seen_tagnos:
seen_tagnos.add(tag_no)
all_tags.append(tag)
count_new += 1
logging.info(f"[{basename}] {name}: {len(tags)}개 중 {count_new}개 신규")
except Exception as e:
logging.error(f"[{basename}] 결과 로드 실패 {name}: {e}")
logging.info(f"[{basename}] Phase 4 완료 ({time.time()-t4:.1f}s) - 총 {len(all_tags)}개 태그")
# ── Phase 5: 위상 그래프 빌드 + 저장 ────────────────────────────
t5 = time.time()
logging.info(f"[{basename}] Phase 5: 위상 그래프 빌드")
builder = PidTopologyBuilder(geo_data)
builder.build_graph()
# 추출된 태그를 그래프에 추가
from shapely.geometry import box as shapely_box
for tag in all_tags:
tag_no = tag.get("tagNo", "UNKNOWN")
eq_name = tag.get("equipmentName")
inst_type = tag.get("instrumentType")
confidence = tag.get("confidence", 0.5)
# 해당 태그의 bbox 찾기 (geo_data에서 clean_value 매칭)
matched_bbox = None
for entity in geo_data:
if entity.get("clean_value", "").upper() == tag_no.upper():
bbox = entity.get("bbox", {})
matched_bbox = (
bbox.get("min_x"), bbox.get("min_y"),
bbox.get("max_x"), bbox.get("max_y")
)
break
node_id = f"tag_{tag_no}"
if matched_bbox:
bbox_geom = shapely_box(matched_bbox[0], matched_bbox[1],
matched_bbox[2], matched_bbox[3])
builder.G.add_node(node_id,
type="TEXT",
bbox=bbox_geom,
value=tag_no,
equipment_name=eq_name,
instrument_type=inst_type,
confidence=confidence)
else:
builder.G.add_node(node_id,
type="TEXT",
bbox=shapely_box(0, 0, 1, 1),
value=tag_no,
equipment_name=eq_name,
instrument_type=inst_type,
confidence=confidence)
# 태그-설비 연결
equipments = [n for n, d in builder.G.nodes(data=True)
if d.get("type") not in ("TEXT", "LINE", "LWPOLYLINE")]
if equipments:
eq_grid = builder._build_spatial_grid(equipments)
tag_ids = [f"tag_{t.get('tagNo', '')}" for t in all_tags]
for tag_id in tag_ids:
if tag_id in builder.G:
best_match = builder._find_nearest_equipment(tag_id, eq_grid)
if best_match:
builder.G.add_edge(tag_id, best_match, relation="associated_with")
graph_id = basename.replace(".dxf", "_graph.json")
graph_path = os.path.join(STORAGE_DIR, graph_id)
builder.save_graph(graph_path)
# 임시 디렉토리 정리
shutil.rmtree(temp_dir, ignore_errors=True)
total_time = time.time() - t0
logging.info(f"[{basename}] 전체 완료 ({total_time:.1f}s) - graph_id={graph_id} "
f"nodes={builder.G.number_of_nodes()} "
f"edges={builder.G.number_of_edges()} "
f"tags={len(all_tags)}")
return json.dumps({
"success": True,
"graph_id": graph_id,
"graph_path": graph_path,
"nodes": builder.G.number_of_nodes(),
"edges": builder.G.number_of_edges(),
"tags_extracted": len(all_tags),
"processing_time_sec": round(total_time, 1)
}, ensure_ascii=False)
def _analyze_pid_impact(graph_id: str, start_node_id: str) -> str:
from pipeline.analyzer import PidAnalysisEngine
graph_path = os.path.join(STORAGE_DIR, graph_id)
mapping_path = graph_path.replace("_graph.json", "_mapping.json")
analyzer = PidAnalysisEngine(graph_path, mapping_path)
result = analyzer.analyze_impact(start_node_id)
return json.dumps(result, ensure_ascii=False, indent=2)
# ── 요청 디스패처 ─────────────────────────────────────────────────────────────
async def _dispatch(tool: str, params: dict) -> str:
try:
match tool:
# blocking 함수는 asyncio.to_thread로 스레드풀 오프로드
case "extract_pid_tags":
return await asyncio.to_thread(_extract_pid_tags, **params)
case "match_pid_tags":
return await asyncio.to_thread(_match_pid_tags, **params)
case "parse_pid_dxf":
return await asyncio.to_thread(_parse_pid_dxf, **params)
case "parse_pid_pdf":
return await asyncio.to_thread(_parse_pid_pdf, **params)
case "parse_pid_drawing":
return await asyncio.to_thread(_parse_pid_drawing, **params)
case "analyze_pid_impact":
return await asyncio.to_thread(_analyze_pid_impact, **params)
# 이미 async — 직접 await
case "build_pid_graph_parallel":
return await _build_pid_graph_parallel(**params)
case _:
return json.dumps({"success": False, "error": f"알 수 없는 도구: {tool}"},
ensure_ascii=False)
except Exception as e:
logging.error(f"dispatch error tool={tool}: {e}", exc_info=True)
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
# ── 종료 예약 ─────────────────────────────────────────────────────────────────
def _schedule_shutdown():
"""
응답 전송 완료 후 프로세스 종료 예약.
FastAPI의 BackgroundTasks를 사용하여 응답이 완전히 전송된 후 종료되도록 유도함.
"""
async def _do():
# 네트워크 전송 및 커넥션 정리를 위한 최소한의 대기 시간
await asyncio.sleep(1.0)
logging.info("One-shot worker shutting down...")
os.kill(os.getpid(), signal.SIGTERM)
asyncio.create_task(_do())
# ── HTTP 엔드포인트 ───────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
body = await request.json()
return await _dispatch(body["tool"], body["params"])
from fastapi import BackgroundTasks
@app.post("/execute/one_shot")
async def execute_one_shot(request: Request, background_tasks: BackgroundTasks):
"""요청 처리 후 프로세스 자동 종료 (P&ID 워커 전용)."""
body = await request.json()
result = await _dispatch(body["tool"], body["params"])
# BackgroundTasks에 등록하여 응답 전송이 완료된 후 _schedule_shutdown이 실행되도록 함
background_tasks.add_function(_schedule_shutdown)
return result
# ── 진입점 ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5004
os.makedirs(STORAGE_DIR, exist_ok=True)
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")

View File

@@ -0,0 +1,608 @@
#!/usr/bin/env python3
"""P&ID 파싱 전용 워커 프로세스 (테스트용 - 절대 경로 지원)
Usage: python pid_worker_test.py <port>
담당 도구:
extract_pid_tags, match_pid_tags,
parse_pid_dxf, parse_pid_pdf, parse_pid_drawing,
build_pid_graph_parallel, analyze_pid_impact
"""
from __future__ import annotations
import sys
import os
# mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import io
import json
import asyncio
import signal
import logging
import re
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
# ── 설정 ─────────────────────────────────────────────────────────────────────
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "glm-4.7-flash")
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
_SERVER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STORAGE_DIR = os.path.join(_SERVER_DIR, "storage")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [pid_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── 싱글톤 ───────────────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm():
from openai import OpenAI
return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
@lru_cache(maxsize=1)
def _ocr():
from paddleocr import PaddleOCR
use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true"
try:
return PaddleOCR(use_angle_cls=True, lang="korean", use_gpu=use_gpu, show_log=False)
except Exception:
if use_gpu:
os.environ["PADDLE_USE_GPU"] = "false"
return _ocr()
raise
# ── DB ───────────────────────────────────────────────────────────────────────
def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
# ── 텍스트 추출 ──────────────────────────────────────────────────────────────
def _extract_text_from_dxf(filepath: str) -> str:
import ezdxf
from ezdxf.tools.text import plain_mtext
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
texts = []
for entity in msp:
if entity.dxftype() == "TEXT":
texts.append(entity.dxf.text)
elif entity.dxftype() == "MTEXT":
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
return "\n".join(texts)
def _extract_text_from_pdf(filepath: str) -> str:
import fitz
doc = fitz.open(filepath)
return "\n".join(page.get_text() for page in doc)
def _extract_text_from_pdf_ocr(filepath: str) -> str:
import fitz
from PIL import Image
import numpy as np
doc = fitz.open(filepath)
all_texts = []
for page in doc:
mat = fitz.Matrix(300 / 72)
pix = page.get_pixmap(matrix=mat)
img = Image.open(io.BytesIO(pix.tobytes("png")))
result = _ocr().ocr(np.array(img), cls=True)
if result and result[0]:
all_texts.extend(line[1][0] for line in result[0])
return "\n".join(all_texts)
# ── JSON 배열 파싱 유틸 ───────────────────────────────────────────────────────
def _parse_json_array(raw: str, finish_reason: str = "") -> list:
"""LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
if raw.startswith("```"):
lines = raw.splitlines()
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
if finish_reason == "length":
last_close = raw.rfind("}")
if last_close != -1:
raw = raw[:last_close + 1] + "]"
# 가장 긴 균형 잡힌 [...] 추출
depth = 0; start = -1; best = ""
for i, c in enumerate(raw):
if c == "[":
if depth == 0:
start = i
depth += 1
elif c == "]":
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i + 1]
if len(cand) > len(best):
best = cand
raw = best if best else "[]"
try:
return json.loads(raw)
except json.JSONDecodeError:
data = []
for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
try:
data.append(json.loads(obj))
except json.JSONDecodeError:
pass
return data
# ── 태그 추출/매핑 도구 ───────────────────────────────────────────────────────
def _extract_pid_tags(text: str, source_type: str) -> str:
system = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract all instrument and equipment tags from the provided text.\n"
"Return ONLY a valid JSON array. Each element must have exactly these fields:\n"
'{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV",'
'"lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n'
"Rules:\n"
"- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n"
" Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n"
"- instrumentType: leading letters of tagNo\n"
"- equipmentName: descriptive name if present near tag, else null\n"
"- lineNumber/pidDrawingNo: null unless explicitly associated\n"
"- confidence: 0.95 for clear tags, lower for ambiguous\n"
"- Output ONLY the JSON array, no markdown, no explanation.\n"
"- If no tags found, return: []\n"
)
truncated = text[:100000]
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"},
],
max_tokens=32768,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
logging.info(f"extract_pid_tags source={source_type} count={len(data)}")
return json.dumps({
"success": True,
"data": {"count": len(data), "tags": data},
"message": "태그 추출 완료"
}, ensure_ascii=False, indent=2)
def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
system = (
"You are a P&ID to Experion tag matching expert.\n"
"Match P&ID tags to Experion tags based on similarity.\n"
"Return ONLY a JSON array:\n"
'[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n'
"- If no good match: confidence < 0.5, experionTag null\n"
"- Output ONLY the JSON array.\n"
)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": (
f"P&ID Tags:\n{chr(10).join(pid_tags)}\n\n"
f"Experion Tags:\n{chr(10).join(experion_tags)}"
)},
],
max_tokens=16384,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
return json.dumps({
"success": True,
"data": {"count": len(data), "mappings": data},
"message": "태그 매핑 완료"
}, ensure_ascii=False, indent=2)
# ── 도면 파싱 도구 ────────────────────────────────────────────────────────────
_TAG_EXTRACT_SYSTEM = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract instrument and equipment tags from the provided text.\n"
"Return ONLY a JSON array:\n"
'[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FIT",'
'"lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n'
"Rules:\n"
"- tagNo: Instrument [Function]-[Number], Equipment [Type]-[Number]\n"
"- instrumentType: first 2-4 letters of tagNo\n"
"- equipmentName/lineNumber/pidDrawingNo: null if not present\n"
"- confidence: 0.0 to 1.0\n"
"- Output ONLY the JSON array, no markdown.\n"
"- If no tags found, return: []\n"
)
def _parse_pid_dxf(filepath: str) -> str:
text = _extract_text_from_dxf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: dxf\n\nText:\n{text[:8000]}"},
],
max_tokens=8192,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "DXF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
text = _extract_text_from_pdf_ocr(filepath) if use_ocr else _extract_text_from_pdf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"},
],
max_tokens=4096,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "PDF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_drawing(filepath: str) -> str:
ext = os.path.splitext(filepath)[1].lower()
if ext == ".dxf":
return _parse_pid_dxf(filepath)
elif ext == ".pdf":
return _parse_pid_pdf(filepath)
elif ext == ".dwg":
return json.dumps({
"success": False,
"data": None,
"error": "DWG 파일은 직접 파싱할 수 없습니다. DXF로 변환 후 사용하세요.",
"message": "지원하지 않는 파일 형식"
}, ensure_ascii=False)
else:
return json.dumps({
"success": False,
"error": f"지원하지 않는 형식: {ext}. 지원: .dxf, .pdf",
}, ensure_ascii=False)
# ── 그래프 도구 ───────────────────────────────────────────────────────────────
import time
async def _build_pid_graph_parallel(filepath: str) -> str:
"""
P&ID 그래프 빌드 — 독립 프로세스 병렬 아키텍처.
Phase 1: 도면 분할 + 기하 추출
Phase 2: 전체 텍스트 1회 추출
Phase 3: 5개 독립 추출 프로세스 병렬 실행 (subprocess.Popen)
Phase 4: 결과 통합 + tagNo 기준 중복 제거
Phase 5: 위상 그래프 빌드 + 저장
"""
from pipeline.extractor import PidGeometricExtractor
from pipeline.topology import PidTopologyBuilder
import subprocess
import tempfile
import shutil
os.makedirs(STORAGE_DIR, exist_ok=True)
t0 = time.time()
# 절대 경로로 변환하여 파일 경로 문제 해결
filepath = os.path.abspath(filepath)
basename = os.path.basename(filepath)
worker_dir = os.path.dirname(os.path.abspath(__file__))
logging.info(f"[{basename}] === 독립 프로세스 병렬 아키텍처 시작 ===")
# ── Phase 1: 도면 분할 + 기하 추출 ──────────────────────────────
logging.info(f"[{basename}] Phase 1: 도면 분할 + 기하 추출 시작")
extractor = PidGeometricExtractor(filepath)
regions = extractor.split_drawings()
logging.info(f"[{basename}] 도면 분할: {len(regions)}개 영역")
geo_data_path = os.path.join(STORAGE_DIR, basename + "_geo.json")
extractor.extract_and_save(geo_data_path)
with open(geo_data_path, "r", encoding="utf-8") as f:
geo_data = json.load(f)
logging.info(f"[{basename}] Phase 1 완료 ({time.time()-t0:.1f}s) - {len(geo_data)}개 엔티티")
# ── Phase 2: 전체 텍스트 1회 추출 ───────────────────────────────
t2 = time.time()
logging.info(f"[{basename}] Phase 2: 전체 텍스트 1회 추출")
full_text = _extract_text_from_dxf(filepath)
# 임시 디렉토리 생성 (프로세스 간 통신용)
temp_dir = tempfile.mkdtemp(prefix=f"pid_{basename.replace('.dxf', '')}_")
text_path = os.path.join(temp_dir, "full_text.txt")
with open(text_path, "w", encoding="utf-8") as f:
f.write(full_text)
logging.info(f"[{basename}] Phase 2 완료 ({time.time()-t2:.1f}s) - {len(full_text)}")
# ── Phase 3: 5개 독립 추출 프로세스 병렬 실행 ───────────────────
t3 = time.time()
logging.info(f"[{basename}] Phase 3: 5개 독립 추출 프로세스 병렬 실행")
extractors = [
("sensor", "pid_extract_sensor.py"),
("valve", "pid_extract_valve.py"),
("system", "pid_extract_system.py"),
("gauge", "pid_extract_gauge.py"),
("pump", "pid_extract_pump.py"),
]
results_dir = os.path.join(temp_dir, "results")
os.makedirs(results_dir, exist_ok=True)
processes = []
for name, script in extractors:
output_path = os.path.join(results_dir, f"{name}.json")
script_path = os.path.join(worker_dir, script)
cmd = [
sys.executable, script_path,
"--input", text_path,
"--output", output_path,
]
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
processes.append((name, proc, output_path))
logging.info(f"[{basename}] 시작: {name} (pid={proc.pid})")
except Exception as e:
logging.error(f"[{basename}] 실행 실패 {name}: {e}")
# 모든 프로세스 대기 (timeout=300초)
for name, proc, output_path in processes:
try:
stdout, stderr = proc.communicate(timeout=300)
if proc.returncode != 0:
logging.error(f"[{basename}] {name} 실패 (rc={proc.returncode}): {stderr[:200]}")
else:
logging.info(f"[{basename}] {name} 완료: {stdout.strip()[:100]}")
except subprocess.TimeoutExpired:
proc.kill()
logging.error(f"[{basename}] {name} 타임아웃 (300초)")
logging.info(f"[{basename}] Phase 3 완료 ({time.time()-t3:.1f}s)")
# ── Phase 4: 결과 JSON 통합 + tagNo 기준 중복 제거 ──────────────
t4 = time.time()
logging.info(f"[{basename}] Phase 4: 결과 통합 + 중복 제거")
all_tags = []
seen_tagnos = set()
for name, _, output_path in processes:
if not os.path.exists(output_path):
logging.warning(f"[{basename}] 결과 없음: {name}")
continue
try:
with open(output_path, "r", encoding="utf-8") as f:
result = json.load(f)
tags = result.get("tags", [])
count_new = 0
for tag in tags:
tag_no = tag.get("tagNo", "")
if tag_no and tag_no not in seen_tagnos:
seen_tagnos.add(tag_no)
all_tags.append(tag)
count_new += 1
logging.info(f"[{basename}] {name}: {len(tags)}개 중 {count_new}개 신규")
except Exception as e:
logging.error(f"[{basename}] 결과 로드 실패 {name}: {e}")
logging.info(f"[{basename}] Phase 4 완료 ({time.time()-t4:.1f}s) - 총 {len(all_tags)}개 태그")
# ── Phase 5: 위상 그래프 빌드 + 저장 ────────────────────────────
t5 = time.time()
logging.info(f"[{basename}] Phase 5: 위상 그래프 빌드")
builder = PidTopologyBuilder(geo_data)
builder.build_graph()
# 추출된 태그를 그래프에 추가
from shapely.geometry import box as shapely_box
for tag in all_tags:
tag_no = tag.get("tagNo", "UNKNOWN")
eq_name = tag.get("equipmentName")
inst_type = tag.get("instrumentType")
confidence = tag.get("confidence", 0.5)
# 해당 태그의 bbox 찾기 (geo_data에서 clean_value 매칭)
matched_bbox = None
for entity in geo_data:
if entity.get("clean_value", "").upper() == tag_no.upper():
bbox = entity.get("bbox", {})
matched_bbox = (
bbox.get("min_x"), bbox.get("min_y"),
bbox.get("max_x"), bbox.get("max_y")
)
break
node_id = f"tag_{tag_no}"
if matched_bbox:
bbox_geom = shapely_box(matched_bbox[0], matched_bbox[1],
matched_bbox[2], matched_bbox[3])
builder.G.add_node(node_id,
type="TEXT",
bbox=bbox_geom,
value=tag_no,
equipment_name=eq_name,
instrument_type=inst_type,
confidence=confidence)
else:
builder.G.add_node(node_id,
type="TEXT",
bbox=shapely_box(0, 0, 1, 1),
value=tag_no,
equipment_name=eq_name,
instrument_type=inst_type,
confidence=confidence)
# 태그-설비 연결
equipments = [n for n, d in builder.G.nodes(data=True)
if d.get("type") not in ("TEXT", "LINE", "LWPOLYLINE")]
if equipments:
eq_grid = builder._build_spatial_grid(equipments)
tag_ids = [f"tag_{t.get('tagNo', '')}" for t in all_tags]
for tag_id in tag_ids:
if tag_id in builder.G:
best_match = builder._find_nearest_equipment(tag_id, eq_grid)
if best_match:
builder.G.add_edge(tag_id, best_match, relation="associated_with")
graph_id = basename.replace(".dxf", "_graph.json")
graph_path = os.path.join(STORAGE_DIR, graph_id)
builder.save_graph(graph_path)
# 임시 디렉토리 정리
shutil.rmtree(temp_dir, ignore_errors=True)
total_time = time.time() - t0
logging.info(f"[{basename}] 전체 완료 ({total_time:.1f}s) - graph_id={graph_id} "
f"nodes={builder.G.number_of_nodes()} "
f"edges={builder.G.number_of_edges()} "
f"tags={len(all_tags)}")
return json.dumps({
"success": True,
"graph_id": graph_id,
"graph_path": graph_path,
"nodes": builder.G.number_of_nodes(),
"edges": builder.G.number_of_edges(),
"tags_extracted": len(all_tags),
"processing_time_sec": round(total_time, 1)
}, ensure_ascii=False)
def _analyze_pid_impact(graph_id: str, start_node_id: str) -> str:
from pipeline.analyzer import PidAnalysisEngine
graph_path = os.path.join(STORAGE_DIR, graph_id)
mapping_path = graph_path.replace("_graph.json", "_mapping.json")
analyzer = PidAnalysisEngine(graph_path, mapping_path)
result = analyzer.analyze_impact(start_node_id)
return json.dumps(result, ensure_ascii=False, indent=2)
# ── 요청 디스패처 ─────────────────────────────────────────────────────────────
async def _dispatch(tool: str, params: dict) -> str:
try:
match tool:
# blocking 함수는 asyncio.to_thread로 스레드풀 오프로드
case "extract_pid_tags":
return await asyncio.to_thread(_extract_pid_tags, **params)
case "match_pid_tags":
return await asyncio.to_thread(_match_pid_tags, **params)
case "parse_pid_dxf":
return await asyncio.to_thread(_parse_pid_dxf, **params)
case "parse_pid_pdf":
return await asyncio.to_thread(_parse_pid_pdf, **params)
case "parse_pid_drawing":
return await asyncio.to_thread(_parse_pid_drawing, **params)
case "analyze_pid_impact":
return await asyncio.to_thread(_analyze_pid_impact, **params)
# 이미 async — 직접 await
case "build_pid_graph_parallel":
return await _build_pid_graph_parallel(**params)
case _:
return json.dumps({"success": False, "error": f"알 수 없는 도구: {tool}"},
ensure_ascii=False)
except Exception as e:
logging.error(f"dispatch error tool={tool}: {e}", exc_info=True)
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
# ── 종료 예약 ─────────────────────────────────────────────────────────────────
def _schedule_shutdown():
"""
응답 전송 완료 후 프로세스 종료 예약.
FastAPI의 BackgroundTasks를 사용하여 응답이 완전히 전송된 후 종료되도록 유도함.
"""
async def _do():
# 네트워크 전송 및 커넥션 정리를 위한 최소한의 대기 시간
await asyncio.sleep(1.0)
logging.info("One-shot worker shutting down...")
os.kill(os.getpid(), signal.SIGTERM)
asyncio.create_task(_do())
# ── HTTP 엔드포인트 ───────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
body = await request.json()
return await _dispatch(body["tool"], body["params"])
from fastapi import BackgroundTasks
@app.post("/execute/one_shot")
async def execute_one_shot(request: Request, background_tasks: BackgroundTasks):
"""요청 처리 후 프로세스 자동 종료 (P&ID 워커 전용)."""
body = await request.json()
result = await _dispatch(body["tool"], body["params"])
# BackgroundTasks에 등록하여 응답 전송이 완료된 후 _schedule_shutdown이 실행되도록 함
background_tasks.add_task(_schedule_shutdown)
return result
# ── 진입점 ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5004
os.makedirs(STORAGE_DIR, exist_ok=True)
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")

View File

@@ -0,0 +1,609 @@
#!/usr/bin/env python3
"""P&ID 파싱 전용 워커 프로세스 (테스트용 - 절대 경로 지원)
Usage: python pid_worker_test.py <port>
담당 도구:
extract_pid_tags, match_pid_tags,
parse_pid_dxf, parse_pid_pdf, parse_pid_drawing,
build_pid_graph_parallel, analyze_pid_impact
"""
from __future__ import annotations
import sys
import os
# 프로젝트 루트를 Python 경로에 추가
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, PROJECT_ROOT)
import io
import json
import asyncio
import signal
import logging
import re
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
# ── 설정 ─────────────────────────────────────────────────────────────────────
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "glm-4.7-flash")
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
_SERVER_DIR = PROJECT_ROOT
STORAGE_DIR = os.path.join(_SERVER_DIR, "mcp-server", "storage")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [pid_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── 싱글톤 ───────────────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm():
from openai import OpenAI
return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
@lru_cache(maxsize=1)
def _ocr():
from paddleocr import PaddleOCR
use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true"
try:
return PaddleOCR(use_angle_cls=True, lang="korean", use_gpu=use_gpu, show_log=False)
except Exception:
if use_gpu:
os.environ["PADDLE_USE_GPU"] = "false"
return _ocr()
raise
# ── DB ───────────────────────────────────────────────────────────────────────
def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
# ── 텍스트 추출 ──────────────────────────────────────────────────────────────
def _extract_text_from_dxf(filepath: str) -> str:
import ezdxf
from ezdxf.tools.text import plain_mtext
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
texts = []
for entity in msp:
if entity.dxftype() == "TEXT":
texts.append(entity.dxf.text)
elif entity.dxftype() == "MTEXT":
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
return "\n".join(texts)
def _extract_text_from_pdf(filepath: str) -> str:
import fitz
doc = fitz.open(filepath)
return "\n".join(page.get_text() for page in doc)
def _extract_text_from_pdf_ocr(filepath: str) -> str:
import fitz
from PIL import Image
import numpy as np
doc = fitz.open(filepath)
all_texts = []
for page in doc:
mat = fitz.Matrix(300 / 72)
pix = page.get_pixmap(matrix=mat)
img = Image.open(io.BytesIO(pix.tobytes("png")))
result = _ocr().ocr(np.array(img), cls=True)
if result and result[0]:
all_texts.extend(line[1][0] for line in result[0])
return "\n".join(all_texts)
# ── JSON 배열 파싱 유틸 ───────────────────────────────────────────────────────
def _parse_json_array(raw: str, finish_reason: str = "") -> list:
"""LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
if raw.startswith("```"):
lines = raw.splitlines()
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
if finish_reason == "length":
last_close = raw.rfind("}")
if last_close != -1:
raw = raw[:last_close + 1] + "]"
# 가장 긴 균형 잡힌 [...] 추출
depth = 0; start = -1; best = ""
for i, c in enumerate(raw):
if c == "[":
if depth == 0:
start = i
depth += 1
elif c == "]":
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i + 1]
if len(cand) > len(best):
best = cand
raw = best if best else "[]"
try:
return json.loads(raw)
except json.JSONDecodeError:
data = []
for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
try:
data.append(json.loads(obj))
except json.JSONDecodeError:
pass
return data
# ── 태그 추출/매핑 도구 ───────────────────────────────────────────────────────
def _extract_pid_tags(text: str, source_type: str) -> str:
system = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract all instrument and equipment tags from the provided text.\n"
"Return ONLY a valid JSON array. Each element must have exactly these fields:\n"
'{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV",'
'"lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n'
"Rules:\n"
"- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n"
" Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n"
"- instrumentType: leading letters of tagNo\n"
"- equipmentName: descriptive name if present near tag, else null\n"
"- lineNumber/pidDrawingNo: null unless explicitly associated\n"
"- confidence: 0.95 for clear tags, lower for ambiguous\n"
"- Output ONLY the JSON array, no markdown, no explanation.\n"
"- If no tags found, return: []\n"
)
truncated = text[:100000]
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"},
],
max_tokens=32768,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
logging.info(f"extract_pid_tags source={source_type} count={len(data)}")
return json.dumps({
"success": True,
"data": {"count": len(data), "tags": data},
"message": "태그 추출 완료"
}, ensure_ascii=False, indent=2)
def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
system = (
"You are a P&ID to Experion tag matching expert.\n"
"Match P&ID tags to Experion tags based on similarity.\n"
"Return ONLY a JSON array:\n"
'[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n'
"- If no good match: confidence < 0.5, experionTag null\n"
"- Output ONLY the JSON array.\n"
)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": (
f"P&ID Tags:\n{chr(10).join(pid_tags)}\n\n"
f"Experion Tags:\n{chr(10).join(experion_tags)}"
)},
],
max_tokens=16384,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
return json.dumps({
"success": True,
"data": {"count": len(data), "mappings": data},
"message": "태그 매핑 완료"
}, ensure_ascii=False, indent=2)
# ── 도면 파싱 도구 ────────────────────────────────────────────────────────────
_TAG_EXTRACT_SYSTEM = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract instrument and equipment tags from the provided text.\n"
"Return ONLY a JSON array:\n"
'[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FIT",'
'"lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n'
"Rules:\n"
"- tagNo: Instrument [Function]-[Number], Equipment [Type]-[Number]\n"
"- instrumentType: first 2-4 letters of tagNo\n"
"- equipmentName/lineNumber/pidDrawingNo: null if not present\n"
"- confidence: 0.0 to 1.0\n"
"- Output ONLY the JSON array, no markdown.\n"
"- If no tags found, return: []\n"
)
def _parse_pid_dxf(filepath: str) -> str:
text = _extract_text_from_dxf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: dxf\n\nText:\n{text[:8000]}"},
],
max_tokens=8192,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "DXF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
text = _extract_text_from_pdf_ocr(filepath) if use_ocr else _extract_text_from_pdf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"},
],
max_tokens=4096,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "PDF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_drawing(filepath: str) -> str:
ext = os.path.splitext(filepath)[1].lower()
if ext == ".dxf":
return _parse_pid_dxf(filepath)
elif ext == ".pdf":
return _parse_pid_pdf(filepath)
elif ext == ".dwg":
return json.dumps({
"success": False,
"data": None,
"error": "DWG 파일은 직접 파싱할 수 없습니다. DXF로 변환 후 사용하세요.",
"message": "지원하지 않는 파일 형식"
}, ensure_ascii=False)
else:
return json.dumps({
"success": False,
"error": f"지원하지 않는 형식: {ext}. 지원: .dxf, .pdf",
}, ensure_ascii=False)
# ── 그래프 도구 ───────────────────────────────────────────────────────────────
import time
async def _build_pid_graph_parallel(filepath: str) -> str:
"""
P&ID 그래프 빌드 — 독립 프로세스 병렬 아키텍처.
Phase 1: 도면 분할 + 기하 추출
Phase 2: 전체 텍스트 1회 추출
Phase 3: 5개 독립 추출 프로세스 병렬 실행 (subprocess.Popen)
Phase 4: 결과 통합 + tagNo 기준 중복 제거
Phase 5: 위상 그래프 빌드 + 저장
"""
from pipeline.extractor import PidGeometricExtractor
from pipeline.topology import PidTopologyBuilder
import subprocess
import tempfile
import shutil
os.makedirs(STORAGE_DIR, exist_ok=True)
t0 = time.time()
# 절대 경로로 변환하여 파일 경로 문제 해결
filepath = os.path.abspath(filepath)
basename = os.path.basename(filepath)
worker_dir = os.path.join(PROJECT_ROOT, "mcp-server", "worker")
logging.info(f"[{basename}] === 독립 프로세스 병렬 아키텍처 시작 ===")
# ── Phase 1: 도면 분할 + 기하 추출 ──────────────────────────────
logging.info(f"[{basename}] Phase 1: 도면 분할 + 기하 추출 시작")
extractor = PidGeometricExtractor(filepath)
regions = extractor.split_drawings()
logging.info(f"[{basename}] 도면 분할: {len(regions)}개 영역")
geo_data_path = os.path.join(STORAGE_DIR, basename + "_geo.json")
extractor.extract_and_save(geo_data_path)
with open(geo_data_path, "r", encoding="utf-8") as f:
geo_data = json.load(f)
logging.info(f"[{basename}] Phase 1 완료 ({time.time()-t0:.1f}s) - {len(geo_data)}개 엔티티")
# ── Phase 2: 전체 텍스트 1회 추출 ───────────────────────────────
t2 = time.time()
logging.info(f"[{basename}] Phase 2: 전체 텍스트 1회 추출")
full_text = _extract_text_from_dxf(filepath)
# 임시 디렉토리 생성 (프로세스 간 통신용)
temp_dir = tempfile.mkdtemp(prefix=f"pid_{basename.replace('.dxf', '')}_")
text_path = os.path.join(temp_dir, "full_text.txt")
with open(text_path, "w", encoding="utf-8") as f:
f.write(full_text)
logging.info(f"[{basename}] Phase 2 완료 ({time.time()-t2:.1f}s) - {len(full_text)}")
# ── Phase 3: 5개 독립 추출 프로세스 병렬 실행 ───────────────────
t3 = time.time()
logging.info(f"[{basename}] Phase 3: 5개 독립 추출 프로세스 병렬 실행")
extractors = [
("sensor", "pid_extract_sensor.py"),
("valve", "pid_extract_valve.py"),
("system", "pid_extract_system.py"),
("gauge", "pid_extract_gauge.py"),
("pump", "pid_extract_pump.py"),
]
results_dir = os.path.join(temp_dir, "results")
os.makedirs(results_dir, exist_ok=True)
processes = []
for name, script in extractors:
output_path = os.path.join(results_dir, f"{name}.json")
script_path = os.path.join(worker_dir, script)
cmd = [
sys.executable, script_path,
"--input", text_path,
"--output", output_path,
]
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
processes.append((name, proc, output_path))
logging.info(f"[{basename}] 시작: {name} (pid={proc.pid})")
except Exception as e:
logging.error(f"[{basename}] 실행 실패 {name}: {e}")
# 모든 프로세스 대기 (timeout=300초)
for name, proc, output_path in processes:
try:
stdout, stderr = proc.communicate(timeout=300)
if proc.returncode != 0:
logging.error(f"[{basename}] {name} 실패 (rc={proc.returncode}): {stderr[:200]}")
else:
logging.info(f"[{basename}] {name} 완료: {stdout.strip()[:100]}")
except subprocess.TimeoutExpired:
proc.kill()
logging.error(f"[{basename}] {name} 타임아웃 (300초)")
logging.info(f"[{basename}] Phase 3 완료 ({time.time()-t3:.1f}s)")
# ── Phase 4: 결과 JSON 통합 + tagNo 기준 중복 제거 ──────────────
t4 = time.time()
logging.info(f"[{basename}] Phase 4: 결과 통합 + 중복 제거")
all_tags = []
seen_tagnos = set()
for name, _, output_path in processes:
if not os.path.exists(output_path):
logging.warning(f"[{basename}] 결과 없음: {name}")
continue
try:
with open(output_path, "r", encoding="utf-8") as f:
result = json.load(f)
tags = result.get("tags", [])
count_new = 0
for tag in tags:
tag_no = tag.get("tagNo", "")
if tag_no and tag_no not in seen_tagnos:
seen_tagnos.add(tag_no)
all_tags.append(tag)
count_new += 1
logging.info(f"[{basename}] {name}: {len(tags)}개 중 {count_new}개 신규")
except Exception as e:
logging.error(f"[{basename}] 결과 로드 실패 {name}: {e}")
logging.info(f"[{basename}] Phase 4 완료 ({time.time()-t4:.1f}s) - 총 {len(all_tags)}개 태그")
# ── Phase 5: 위상 그래프 빌드 + 저장 ────────────────────────────
t5 = time.time()
logging.info(f"[{basename}] Phase 5: 위상 그래프 빌드")
builder = PidTopologyBuilder(geo_data)
builder.build_graph()
# 추출된 태그를 그래프에 추가
from shapely.geometry import box as shapely_box
for tag in all_tags:
tag_no = tag.get("tagNo", "UNKNOWN")
eq_name = tag.get("equipmentName")
inst_type = tag.get("instrumentType")
confidence = tag.get("confidence", 0.5)
# 해당 태그의 bbox 찾기 (geo_data에서 clean_value 매칭)
matched_bbox = None
for entity in geo_data:
if entity.get("clean_value", "").upper() == tag_no.upper():
bbox = entity.get("bbox", {})
matched_bbox = (
bbox.get("min_x"), bbox.get("min_y"),
bbox.get("max_x"), bbox.get("max_y")
)
break
node_id = f"tag_{tag_no}"
if matched_bbox:
bbox_geom = shapely_box(matched_bbox[0], matched_bbox[1],
matched_bbox[2], matched_bbox[3])
builder.G.add_node(node_id,
type="TEXT",
bbox=bbox_geom,
value=tag_no,
equipment_name=eq_name,
instrument_type=inst_type,
confidence=confidence)
else:
builder.G.add_node(node_id,
type="TEXT",
bbox=shapely_box(0, 0, 1, 1),
value=tag_no,
equipment_name=eq_name,
instrument_type=inst_type,
confidence=confidence)
# 태그-설비 연결
equipments = [n for n, d in builder.G.nodes(data=True)
if d.get("type") not in ("TEXT", "LINE", "LWPOLYLINE")]
if equipments:
eq_grid = builder._build_spatial_grid(equipments)
tag_ids = [f"tag_{t.get('tagNo', '')}" for t in all_tags]
for tag_id in tag_ids:
if tag_id in builder.G:
best_match = builder._find_nearest_equipment(tag_id, eq_grid)
if best_match:
builder.G.add_edge(tag_id, best_match, relation="associated_with")
graph_id = basename.replace(".dxf", "_graph.json")
graph_path = os.path.join(STORAGE_DIR, graph_id)
builder.save_graph(graph_path)
# 임시 디렉토리 정리
shutil.rmtree(temp_dir, ignore_errors=True)
total_time = time.time() - t0
logging.info(f"[{basename}] 전체 완료 ({total_time:.1f}s) - graph_id={graph_id} "
f"nodes={builder.G.number_of_nodes()} "
f"edges={builder.G.number_of_edges()} "
f"tags={len(all_tags)}")
return json.dumps({
"success": True,
"graph_id": graph_id,
"graph_path": graph_path,
"nodes": builder.G.number_of_nodes(),
"edges": builder.G.number_of_edges(),
"tags_extracted": len(all_tags),
"processing_time_sec": round(total_time, 1)
}, ensure_ascii=False)
def _analyze_pid_impact(graph_id: str, start_node_id: str) -> str:
from pipeline.analyzer import PidAnalysisEngine
graph_path = os.path.join(STORAGE_DIR, graph_id)
mapping_path = graph_path.replace("_graph.json", "_mapping.json")
analyzer = PidAnalysisEngine(graph_path, mapping_path)
result = analyzer.analyze_impact(start_node_id)
return json.dumps(result, ensure_ascii=False, indent=2)
# ── 요청 디스패처 ─────────────────────────────────────────────────────────────
async def _dispatch(tool: str, params: dict) -> str:
try:
match tool:
# blocking 함수는 asyncio.to_thread로 스레드풀 오프로드
case "extract_pid_tags":
return await asyncio.to_thread(_extract_pid_tags, **params)
case "match_pid_tags":
return await asyncio.to_thread(_match_pid_tags, **params)
case "parse_pid_dxf":
return await asyncio.to_thread(_parse_pid_dxf, **params)
case "parse_pid_pdf":
return await asyncio.to_thread(_parse_pid_pdf, **params)
case "parse_pid_drawing":
return await asyncio.to_thread(_parse_pid_drawing, **params)
case "analyze_pid_impact":
return await asyncio.to_thread(_analyze_pid_impact, **params)
# 이미 async — 직접 await
case "build_pid_graph_parallel":
return await _build_pid_graph_parallel(**params)
case _:
return json.dumps({"success": False, "error": f"알 수 없는 도구: {tool}"},
ensure_ascii=False)
except Exception as e:
logging.error(f"dispatch error tool={tool}: {e}", exc_info=True)
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
# ── 종료 예약 ─────────────────────────────────────────────────────────────────
def _schedule_shutdown():
"""
응답 전송 완료 후 프로세스 종료 예약.
FastAPI의 BackgroundTasks를 사용하여 응답이 완전히 전송된 후 종료되도록 유도함.
"""
async def _do():
# 네트워크 전송 및 커넥션 정리를 위한 최소한의 대기 시간
await asyncio.sleep(1.0)
logging.info("One-shot worker shutting down...")
os.kill(os.getpid(), signal.SIGTERM)
asyncio.create_task(_do())
# ── HTTP 엔드포인트 ───────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
body = await request.json()
return await _dispatch(body["tool"], body["params"])
from fastapi import BackgroundTasks
@app.post("/execute/one_shot")
async def execute_one_shot(request: Request, background_tasks: BackgroundTasks):
"""요청 처리 후 프로세스 자동 종료 (P&ID 워커 전용)."""
body = await request.json()
result = await _dispatch(body["tool"], body["params"])
# BackgroundTasks에 등록하여 응답 전송이 완료된 후 _schedule_shutdown이 실행되도록 함
background_tasks.add_task(_schedule_shutdown)
return result
# ── 진입점 ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5005
os.makedirs(STORAGE_DIR, exist_ok=True)
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")

View File

@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""
Qwen3-Coder-Next-FP8 출력 토큰 속도 벤치마크
- 스트리밍 모드로 수신하며 토큰/초 실시간 측정
- usage.completion_tokens 기반 최종 속도 산출
"""
import time
import sys
from openai import OpenAI
VLLM_BASE_URL = "http://localhost:8000/v1"
VLLM_MODEL = "glm-4.7-flash"
# ── 프로그램 작성 예제 프롬프트 ────────────────────────────────────────────────
PROMPT = """\
Python으로 다음 조건을 만족하는 TTL-LRU 캐시 클래스를 작성해줘.
요구사항:
1. `capacity` (최대 항목 수)와 `ttl_seconds` (항목 유효 시간)를 생성자에서 받는다.
2. `get(key)` — 없거나 만료된 항목은 None 반환.
3. `set(key, value)` — 캐시가 가득 차면 가장 오래된 항목을 제거한다.
4. `delete(key)` — 명시적 삭제.
5. `size()` — 현재 유효한 항목 수 반환 (만료된 항목 제외).
6. 스레드 안전해야 한다 (threading.Lock 사용).
7. 클래스 하단에 동작을 검증하는 `if __name__ == '__main__':` 테스트 코드를 포함한다.
추가 조건:
- 외부 라이브러리 사용 금지 (표준 라이브러리만).
- 타입 힌트를 모든 메서드에 명시한다.
- 각 메서드에 한 줄 docstring을 작성한다.
"""
def run_benchmark():
client = OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
print(f"모델 : {VLLM_MODEL}")
print(f"프롬프트 길이: {len(PROMPT)} chars")
print("=" * 60)
print()
# ── 스트리밍 요청 ──────────────────────────────────────────────
stream = client.chat.completions.create(
model=VLLM_MODEL,
messages=[
{
"role": "system",
"content": "당신은 숙련된 Python 개발자입니다. 명확하고 실용적인 코드를 작성합니다.",
},
{"role": "user", "content": PROMPT},
],
max_tokens=2048,
temperature=0.1,
stream=True,
stream_options={"include_usage": True}, # 마지막 청크에 usage 포함
)
# ── 스트리밍 수신 + 측정 ────────────────────────────────────────
first_token_time = None
start_time = time.perf_counter()
char_count = 0
completion_tokens = 0
full_text = []
for chunk in stream:
# usage 청크 (마지막)
if chunk.usage:
completion_tokens = chunk.usage.completion_tokens
if not chunk.choices:
continue
delta = chunk.choices[0].delta
if delta.content:
if first_token_time is None:
first_token_time = time.perf_counter()
ttft = first_token_time - start_time
print(f"[TTFT: {ttft:.3f}s] ", end="", flush=True)
sys.stdout.write(delta.content)
sys.stdout.flush()
full_text.append(delta.content)
char_count += len(delta.content)
end_time = time.perf_counter()
# ── 결과 출력 ──────────────────────────────────────────────────
total_time = end_time - start_time
gen_time = end_time - (first_token_time or start_time)
tps_wall = completion_tokens / total_time if total_time > 0 else 0
tps_gen = completion_tokens / gen_time if gen_time > 0 else 0
print()
print()
print("=" * 60)
print(f"총 출력 토큰 : {completion_tokens:,}")
print(f"총 소요 시간 : {total_time:.2f}s")
print(f"생성 시간 : {gen_time:.2f}s (첫 토큰 이후)")
print(f"TTFT : {(first_token_time or start_time) - start_time:.3f}s")
print(f"토큰 속도 : {tps_gen:.1f} tok/s (생성 구간)")
print(f"토큰 속도 : {tps_wall:.1f} tok/s (전체 구간, TTFT 포함)")
print("=" * 60)
if __name__ == "__main__":
run_benchmark()

View File

@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""
Qwen3-Coder-Next-FP8 RAG 연동 벤치마크
- Qdrant 코드베이스 + OPC UA 문서에서 컨텍스트 수집
- 수집된 실제 코드/문서 기반으로 복잡한 신규 기능 구현 요청
- 스트리밍으로 토큰/초 측정
"""
import time
import sys
import httpx
from openai import OpenAI
VLLM_BASE_URL = "http://localhost:8000/v1"
VLLM_MODEL = "glm-4.7-flash"
OLLAMA_URL = "http://localhost:11434"
EMBED_MODEL = "nomic-embed-text"
QDRANT_URL = "http://localhost:6333"
COL_CODEBASE = "ws-65f457145aee80b2"
COL_OPC_DOCS = "experion-opc-docs"
def embed(text: str) -> list[float]:
with httpx.Client(timeout=30) as c:
r = c.post(f"{OLLAMA_URL}/api/embeddings", json={"model": EMBED_MODEL, "prompt": text})
r.raise_for_status()
return r.json()["embedding"]
def search(collection: str, query: str, top_k: int = 5) -> list[dict]:
vec = embed(query)
with httpx.Client(timeout=20) as c:
r = c.post(
f"{QDRANT_URL}/collections/{collection}/points/search",
json={"vector": vec, "limit": top_k, "with_payload": True},
)
r.raise_for_status()
return r.json()["result"]
def fmt_hits(hits: list[dict], label: str) -> str:
chunks = []
for i, h in enumerate(hits, 1):
p = h["payload"]
src = p.get("file_path") or p.get("source") or p.get("filename") or "unknown"
text = p.get("text") or p.get("content") or p.get("chunk") or str(p)
score = h.get("score", 0)
chunks.append(f"[{label} #{i} | {src} | score={score:.3f}]\n{text}")
return "\n\n".join(chunks)
def run_benchmark():
client = OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
# ── RAG 컨텍스트 수집 ──────────────────────────────────────────────────────
print("RAG 검색 중...")
t0 = time.perf_counter()
# 코드베이스: 실시간 서비스 구조 + DB 저장 패턴
hits_realtime = search(COL_CODEBASE, "ExperionRealtimeService FlushLoop subscription MonitoredItem", top_k=4)
hits_db = search(COL_CODEBASE, "ExperionDbContext history snapshot PostgreSQL EF Core", top_k=3)
# OPC UA 문서: 알람/이벤트 관련
hits_alarm = search(COL_OPC_DOCS, "alarm event notification EventNotifier condition OPC UA", top_k=4)
rag_time = time.perf_counter() - t0
total_hits = len(hits_realtime) + len(hits_db) + len(hits_alarm)
print(f"검색 완료: {total_hits}개 청크 ({rag_time:.2f}s)")
print()
ctx_realtime = fmt_hits(hits_realtime, "코드베이스/Realtime")
ctx_db = fmt_hits(hits_db, "코드베이스/DB")
ctx_alarm = fmt_hits(hits_alarm, "OPC UA 문서/Alarm")
# ── 프롬프트 구성 ──────────────────────────────────────────────────────────
prompt = f"""\
아래는 ExperionCrawler 프로젝트의 실제 코드와 OPC UA 공식 문서 발췌입니다.
이 컨텍스트를 기반으로 새로운 기능을 구현해줘.
━━━ 코드베이스 컨텍스트 ━━━
{ctx_realtime}
{ctx_db}
━━━ OPC UA 문서 컨텍스트 ━━━
{ctx_alarm}
━━━ 구현 요청 ━━━
위 컨텍스트를 바탕으로 ExperionAlarmService를 C#으로 구현해줘.
요구사항:
1. `IHostedService` + `IExperionAlarmService` 패턴 (기존 ExperionRealtimeService와 동일한 구조).
2. OPC UA `EventNotifier` 방식으로 알람/이벤트를 구독한다.
구독 대상 EventType: ConditionType, AlarmConditionType (OPC UA 표준).
3. 이벤트 수신 시 다음 정보를 `alarm_history` PostgreSQL 테이블에 저장한다:
- `id` (bigserial), `tagname`, `event_type`, `severity` (int), `message`, `active` (bool), `occurred_at` (timestamptz)
4. 기존 `ExperionDbContext` / EF Core 패턴을 따른다 (새 DbSet 추가).
5. 컨트롤러 `ExperionAlarmController` — start/stop/status + 최근 알람 조회 (GET /api/alarm/recent?limit=50).
6. `appsettings.json`에 `AlarmServer` 섹션 추가 (NodeId 목록, MaxSeverityFilter).
7. 각 클래스/메서드에 한 줄 XML 문서 주석 포함.
코드는 완성된 형태로 작성하고, 파일별로 명확히 구분해줘.
"""
prompt_chars = len(prompt)
print(f"프롬프트 길이: {prompt_chars:,} chars (RAG 컨텍스트 포함)")
print(f"모델: {VLLM_MODEL}")
print("=" * 60)
print()
# ── 스트리밍 LLM 요청 ──────────────────────────────────────────────────────
stream = client.chat.completions.create(
model=VLLM_MODEL,
messages=[
{
"role": "system",
"content": (
"당신은 C#/.NET 백엔드와 OPC UA 프로토콜 전문가입니다. "
"ExperionCrawler 프로젝트의 기존 코드 스타일과 패턴을 그대로 따르며 "
"완성도 높은 코드를 작성합니다."
),
},
{"role": "user", "content": prompt},
],
max_tokens=4096,
temperature=0.1,
stream=True,
stream_options={"include_usage": True},
)
# ── 스트리밍 수신 + 측정 ────────────────────────────────────────────────────
first_token_time = None
start_time = time.perf_counter()
completion_tokens = 0
for chunk in stream:
if chunk.usage:
completion_tokens = chunk.usage.completion_tokens
if not chunk.choices:
continue
delta = chunk.choices[0].delta
if delta.content:
if first_token_time is None:
first_token_time = time.perf_counter()
ttft = first_token_time - start_time
print(f"[TTFT: {ttft:.3f}s] ", end="", flush=True)
sys.stdout.write(delta.content)
sys.stdout.flush()
end_time = time.perf_counter()
# ── 결과 출력 ──────────────────────────────────────────────────────────────
total_time = end_time - start_time
gen_time = end_time - (first_token_time or start_time)
tps_gen = completion_tokens / gen_time if gen_time > 0 else 0
tps_wall = completion_tokens / total_time if total_time > 0 else 0
print()
print()
print("=" * 60)
print(f"RAG 검색 시간 : {rag_time:.2f}s ({total_hits}개 청크)")
print(f"총 출력 토큰 : {completion_tokens:,}")
print(f"총 소요 시간 : {total_time:.2f}s")
print(f"생성 시간 : {gen_time:.2f}s (첫 토큰 이후)")
print(f"TTFT : {(first_token_time or start_time) - start_time:.3f}s")
print(f"토큰 속도 : {tps_gen:.1f} tok/s (생성 구간)")
print(f"토큰 속도 : {tps_wall:.1f} tok/s (전체 구간)")
print("=" * 60)
if __name__ == "__main__":
run_benchmark()

View File

@@ -0,0 +1,375 @@
#!/usr/bin/env python3
"""
DXF 파일에서 P&ID 태그를 추출하는 스크립트
- MCP 서버를 거치지 않고 LLM에 직접 요청
- 전처리 과정에서 의미 없는 텍스트는 필터링
- CSV 형식으로 LLM에 전달
"""
import sys
import json
import re
import csv
import io
from dataclasses import dataclass
from typing import List, Optional
import requests
@dataclass
class TextEntity:
"""DXF 텍스트 엔티티"""
entity_type: str
text: str
x: float
y: float
z: float
layer: str
height: float
style: str
def parse_dxf_text_entities(file_path: str) -> List[TextEntity]:
"""DXF 파일에서 TEXT, MTEXT, ATTRIB 엔티티를 파싱"""
entities = []
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
lines = f.readlines()
i = 0
while i < len(lines):
line = lines[i].strip()
if line in ('TEXT', 'MTEXT', 'ATTRIB'):
entity_type = line
entity = {
'entity_type': entity_type,
'text': '',
'x': 0.0,
'y': 0.0,
'z': 0.0,
'layer': '',
'height': 0.0,
'style': ''
}
i += 1
while i < len(lines):
code = lines[i].strip()
if code == '0':
break
if i + 1 < len(lines):
value = lines[i + 1].strip()
if code == '1':
if entity['text']:
entity['text'] += ' ' + value
else:
entity['text'] = value
elif code == '10':
entity['x'] = float(value)
elif code == '20':
entity['y'] = float(value)
elif code == '30':
entity['z'] = float(value)
elif code == '8':
entity['layer'] = value
elif code == '40':
entity['height'] = float(value)
elif code == '7':
entity['style'] = value
i += 1
i += 1
if entity['text']:
entities.append(TextEntity(
entity_type=entity['entity_type'],
text=entity['text'],
x=entity['x'],
y=entity['y'],
z=entity['z'],
layer=entity['layer'],
height=entity['height'],
style=entity['style']
))
else:
i += 1
return entities
def filter_meaningful_text(entities: List[TextEntity]) -> List[TextEntity]:
"""
의미 있는 텍스트만 필터링
"""
meaningful = []
remove_patterns = [
r'^\$[A-Z]+$', # DXV 시스템 변수
r'^[0-9]+$', # 숫자만 있는 텍스트
r'^[0-9.]+$', # 숫자와 점만 있는 텍스트
r'^[a-zA-Z0-9_]{1}$', # 1자 알파벳/숫자/언더스코어
r'^[ \t]+$', # 공백만 있는 텍스트
r'^[a-zA-Z0-9]{1,2}$', # 2자 이하의 알파벳/숫자 조합
]
for entity in entities:
text = entity.text.strip()
if not text:
continue
is_system_var = False
for pattern in remove_patterns:
if re.match(pattern, text):
is_system_var = True
break
if is_system_var:
continue
is_meaningful = False
# 태그명 패턴 확인 (예: P-101, PIC-6211, T-10101)
if re.match(r'^[A-Z]+[-_][A-Z0-9]+$', text):
is_meaningful = True
# 3자 이상이고 알파벳/숫자/한글이 포함된 경우
elif len(text) >= 3 and (re.search(r'[A-Z]', text) or re.search(r'[0-9]', text)):
is_meaningful = True
# 한글 포함
elif re.search(r'[가-힣]', text):
is_meaningful = True
if is_meaningful:
meaningful.append(TextEntity(
entity_type=entity.entity_type,
text=text,
x=entity.x,
y=entity.y,
z=entity.z,
layer=entity.layer,
height=entity.height,
style=entity.style
))
return meaningful
def filter_tag_candidates_strict(entities: List[TextEntity]) -> List[TextEntity]:
"""
P&ID 태그 후보만 필터링 (엄격한 기준 - 실제 태그 패턴에만 매칭)
"""
tag_candidates = []
for entity in entities:
text = entity.text.strip()
if not text:
continue
# 태그 패턴: P-101, PIC-6211, T-10101, FT-201 등
# 첫 글자는 대문자(1-4자), 뒤에 하이픈 또는 언더스코어, 그리고 알파벳/숫자가 옴
if re.match(r'^[A-Z]{1,4}[-_][A-Z0-9]+$', text):
tag_candidates.append(entity)
return tag_candidates
def export_to_csv(entities: List[TextEntity]) -> str:
"""CSV 형식으로 변환 (LLM 파싱 용이)"""
lines = []
# 헤더 추가
lines.append("entity_type,text,x,y,z,layer,height,style")
for entity in entities:
# CSV 이스케이프: 쉼표, 따옴표, 줄바꿈이 포함된 경우 따옴표로 감싸기
text = entity.text.replace('"', '""')
if ',' in text or '"' in text or '\n' in text:
text = f'"{text}"'
lines.append(f"{entity.entity_type},{text},{entity.x},{entity.y},{entity.z},{entity.layer},{entity.height},{entity.style}")
return "\n".join(lines)
def export_to_simple_text(entities: List[TextEntity]) -> str:
"""간단한 텍스트 형식으로 변환 (LLM 파싱 용이)"""
lines = []
for entity in entities:
lines.append(f"TEXT: {entity.text}")
return "\n".join(lines)
def extract_pid_tags_with_llm_simple(text_data: str, model_url: str = "http://localhost:8000/v1/chat/completions") -> dict:
"""
LLM을 사용하여 P&ID 태그 추출 (간단한 텍스트 형식)
MCP 서버를 거치지 않고 vLLM 직접 요청
"""
prompt = f"""당신은 P&ID(Piping and Instrumentation Diagram) 도면에서 태그 정보를 추출하는 전문가입니다.
주어진 텍스트는 DXF 파일에서 추출한 P&ID 태그 후보입니다. 이 데이터에서 실제 P&ID 태그를 추출해주세요.
**태그 형식 (예시):**
- P-101: Pump (펌프)
- PIC-6211: Pressure Indicating Controller (압력 측정 및 제어)
- T-10101: Tank (탱크)
- FT-201: Flow Transmitter (유량 측정)
- PT-101: Pressure Transmitter (압력 측정)
- LIC-6201: Level Indicating Controller (유량 측정 및 제어)
- FIC-6113: Flow Indicating Controller (유량 측정 및 제어)
- DP-10101: Differential Pressure (차압)
- VP-10117: Valve Positioner (밸브 포지셔너)
- SP-10601: Switch Pressure (압력 스위치)
**태그 패턴:**
- 첫 글자는 장비/계기 유형을 나타냅니다 (P, T, F, L, P, V, S, C, E, D 등)
- 뒤에 숫자가 붙어 고유 식별자를 만듭니다
- 계기 유형은 PIC, FIC, LIC, TIC 등으로 확장될 수 있습니다
**추출할 필드:**
- tagNo: 태그 번호 (예: P-101, PIC-6211)
- equipmentName: 장비 이름 (예: Pump, Tank, Pressure Transmitter)
- instrumentType: 계기 유형 (P, T, FT, PT, PIC, LIC, FIC, LV, MV 등)
- lineNumber: 파이프 라인 번호 (있는 경우)
- pidDrawingNo: 도면 번호 (있는 경우)
- confidence: 추출 신뢰도 (0.0 ~ 1.0)
**텍스트 데이터:**
{text_data}
**요청:**
1. 텍스트 데이터에서 실제 P&ID 태그만 추출하세요 (의미 없는 텍스트는 제외)
2. JSON 배열 형식으로 응답하세요
3. 각 태그는 위의 필드를 포함해야 합니다
4. 알 수 없는 정보는 null로 설정하세요
5. 신뢰도 점수를 부여하세요
**응답 형식 (JSON만, 추가 설명 없이):**
[
{{"tagNo": "P-101", "equipmentName": "Pump", "instrumentType": "P", "lineNumber": null, "pidDrawingNo": null, "confidence": 0.95}},
{{"tagNo": "PIC-6211", "equipmentName": "Pressure Indicating Controller", "instrumentType": "PIC", "lineNumber": null, "pidDrawingNo": null, "confidence": 0.90}}
]
"""
payload = {
"model": "glm-4.7-flash",
"messages": [
{
"role": "user",
"content": prompt
}
],
"temperature": 0.1,
"max_tokens": 8192,
"stream": False
}
try:
response = requests.post(model_url, json=payload, timeout=300)
response.raise_for_status()
result = response.json()
content = result.get('choices', [{}])[0].get('message', {}).get('content', '')
# JSON 파싱
try:
# 코드 블록으로 감싸진 JSON 제거
json_match = re.search(r'\[.*\]', content, re.DOTALL)
if json_match:
json_str = json_match.group()
return json.loads(json_str)
else:
return json.loads(content)
except json.JSONDecodeError as e:
# 원본 응답을 파일로 저장
error_output_path = dxf_path.replace('.dxf', '_error_response.txt')
with open(error_output_path, 'w', encoding='utf-8') as f:
f.write(content)
return {"error": f"JSON 파싱 실패: {str(e)}", "raw_response": content, "error_output_path": error_output_path}
except requests.exceptions.RequestException as e:
return {"error": f"LLM 요청 실패: {str(e)}"}
def main():
if len(sys.argv) < 2:
print("사용법: python extract_pid_tags.py <dxf_file_path> [model_url]")
sys.exit(1)
dxf_path = sys.argv[1]
model_url = sys.argv[2] if len(sys.argv) > 2 else "http://localhost:8000/v1/chat/completions"
print(f"DXF 파일 파싱 중: {dxf_path}")
entities = parse_dxf_text_entities(dxf_path)
print(f"{len(entities)}개 텍스트 엔티티 found")
print("의미 있는 텍스트 필터링 중...")
meaningful = filter_meaningful_text(entities)
print(f"의미 있는 텍스트: {len(meaningful)}")
# P&ID 태그 후보만 필터링 (엄격한 기준)
tag_candidates = filter_tag_candidates_strict(meaningful)
print(f"P&ID 태그 후보 (엄격한 기준): {len(tag_candidates)}")
# 상위 200개만 전달 (토큰 제한 대응)
top_meaningful = tag_candidates[:200]
print(f"LLM에 전달할 텍스트 수: {len(top_meaningful)}")
# 간단한 텍스트 형식으로 변환
simple_text = export_to_simple_text(top_meaningful)
print("\n" + "="*80)
print("LLM에 전달할 텍스트 데이터 (첫 50줄):")
print("="*80)
lines = simple_text.split('\n')
for line in lines[:50]:
print(line)
if len(lines) > 50:
print(f"... (총 {len(lines)}줄)")
print("\n" + "="*80)
print("LLM에 P&ID 태그 추출 요청 중...")
print("="*80)
result = extract_pid_tags_with_llm_simple(simple_text, model_url)
if 'error' in result:
print(f"오류: {result['error']}")
if 'raw_response' in result:
print(f"원본 응답: {result['raw_response'][:500]}")
if 'error_output_path' in result:
print(f"오류 응답 저장 경로: {result['error_output_path']}")
else:
print(f"\n성공적으로 추출된 태그: {len(result)}")
print("\n추출 결과:")
for i, tag in enumerate(result[:20], 1):
print(f"{i}. {tag.get('tagNo', 'N/A')} - {tag.get('equipmentName', 'N/A')} ({tag.get('instrumentType', 'N/A')}) - confidence: {tag.get('confidence', 0)}")
if len(result) > 20:
print(f"... (총 {len(result)}개)")
# 결과를 JSON 파일로 저장
json_output_path = dxf_path.replace('.dxf', '_extracted.json')
with open(json_output_path, 'w', encoding='utf-8') as f:
json.dump(result, f, ensure_ascii=False, indent=2)
print(f"\nJSON 결과가 저장되었습니다: {json_output_path}")
# 결과를 CSV 파일로 저장
csv_output_path = dxf_path.replace('.dxf', '_extracted.csv')
with open(csv_output_path, 'w', encoding='utf-8', newline='') as f:
writer = csv.writer(f)
writer.writerow(['tagNo', 'equipmentName', 'instrumentType', 'lineNumber', 'pidDrawingNo', 'confidence'])
for tag in result:
writer.writerow([
tag.get('tagNo', ''),
tag.get('equipmentName', ''),
tag.get('instrumentType', ''),
tag.get('lineNumber', ''),
tag.get('pidDrawingNo', ''),
tag.get('confidence', 0)
])
print(f"CSV 결과가 저장되었습니다: {csv_output_path}")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,153 @@
import networkx as nx
import asyncio
import json
from typing import List, Optional, Dict, Any, Tuple
from pydantic import BaseModel, Field
from rapidfuzz import process, fuzz
from openai import AsyncOpenAI
# --- 응답 구조화를 위한 Pydantic 모델 ---
class MappingResult(BaseModel):
resolved_tag: str = Field(..., description="The final mapped system tag")
reason: str = Field(..., description="Reason for this mapping based on context")
confidence: float = Field(..., ge=0.0, le=1.0, description="Confidence score from 0 to 1")
class IntelligentMapper:
def __init__(self, graph: nx.Graph, system_tags: List[str], api_client: Optional[AsyncOpenAI] = None):
self.graph = graph # Phase 2에서 생성된 NetworkX 그래프
self.system_tags = system_tags # Experion 시스템의 전체 태그 리스트
self.client = api_client
def get_node_context(self, node_id: str) -> str:
"""노드의 주변 위상 정보를 텍스트로 변환 (확장된 컨텍스트 제공)"""
if not self.graph.has_node(node_id):
return "Node not found in graph"
node_attr = self.graph.nodes[node_id]
node_type = node_attr.get('type', 'Unknown')
node_val = node_attr.get('value', 'Unknown')
# 1. 직접 연결된 이웃 노드 정보
neighbors = list(self.graph.neighbors(node_id))
neighbor_info = []
for n in neighbors:
attr = self.graph.nodes[n]
val = attr.get('value', n)
typ = attr.get('type', 'Unknown')
# 엣지 속성(관계) 추가
rel = self.graph.get_edge_data(node_id, n).get('relation', 'connected')
neighbor_info.append(f"[{rel}] {val} (Type: {typ})")
# 2. 2-hop 연결 정보 (더 넓은 맥락 파악)
extended_context = []
for n in neighbors:
second_neighbors = list(self.graph.neighbors(n))
for sn in second_neighbors:
if sn == node_id: continue
s_attr = self.graph.nodes[sn]
extended_context.append(f"Indirectly connected via {self.graph.nodes[n].get('value', n)} to {s_attr.get('value', sn)} (Type: {s_attr.get('type', 'Unknown')})")
context_str = (
f"Target Node: {node_val} (Type: {node_type})\n"
f"Direct Neighbors: {', '.join(neighbor_info) if neighbor_info else 'None'}\n"
f"Extended Context: {', '.join(extended_context[:10]) if extended_context else 'None'}"
)
return context_str
async def _resolve_generic(self, node_id: str, category_prompt: str) -> MappingResult:
"""공통 매핑 로직 (비동기 + 구조화 응답)"""
if not self.client:
return MappingResult(resolved_tag="UNKNOWN", reason="API Client not provided", confidence=0.0)
# Phase 2에서 'value'에 clean_value가 저장됨
node_data = self.graph.nodes.get(node_id, {})
tag_text = node_data.get('value', '')
# 1차 후보 추출 (RapidFuzz)
candidates = process.extract(tag_text, self.system_tags, scorer=fuzz.WRatio, limit=5)
context = self.get_node_context(node_id)
prompt = f"""
{category_prompt}
P&ID 도면의 태그 '{tag_text}'를 실제 시스템 태그와 매핑해야 합니다.
위상 맥락: {context}
후보 리스트: {candidates}
반드시 다음 JSON 형식으로만 응답하세요:
{{
"resolved_tag": "태그명 또는 UNKNOWN",
"reason": "매핑 이유",
"confidence": 0.0~1.0
}}
"""
try:
response = await self.client.chat.completions.create(
model="glm-4.7-flash", # MCP 서버 설정 모델 사용
messages=[{"role": "user", "content": prompt}],
response_format={ "type": "json_object" }
)
raw_content = response.choices[0].message.content
return MappingResult.model_validate_json(raw_content)
except Exception as e:
print(f"Error resolving node {node_id}: {e}")
return MappingResult(resolved_tag="UNKNOWN", reason=f"Error: {str(e)}", confidence=0.0)
# --- 전문화된 Worker 함수들 ---
# vLLM 컨커런시 제한으로 인해 배치 처리 (한 번에 15개 요청)
_BATCH_SIZE = 15
async def _batch_gather(self, node_ids: List[str], prompt: str) -> Dict[str, MappingResult]:
"""노드를 배치로 나누어 순차적으로 LLM 요청.
모든 노드를 동시에 요청하면 vLLM 큐가 가득 차 타임아웃 발생.
"""
all_results = {}
for i in range(0, len(node_ids), self._BATCH_SIZE):
batch = node_ids[i : i + self._BATCH_SIZE]
tasks = [self._resolve_generic(nid, prompt) for nid in batch]
batch_results = await asyncio.gather(*tasks)
all_results.update(dict(zip(batch, batch_results)))
return all_results
async def extract_transmitters(self, node_ids: List[str]) -> Dict[str, MappingResult]:
prompt = "당신은 계측기 전문 엔지니어입니다. 특히 Pressure/Flow/Level Transmitter 매핑에 특화되어 있습니다."
return await self._batch_gather(node_ids, prompt)
async def extract_valves(self, node_ids: List[str]) -> Dict[str, MappingResult]:
prompt = "당신은 밸브 및 액추에이터 전문 엔지니어입니다. 밸브의 개폐 상태 및 제어 태그 매핑에 특화되어 있습니다."
return await self._batch_gather(node_ids, prompt)
async def extract_equipment(self, node_ids: List[str]) -> Dict[str, MappingResult]:
prompt = "당신은 공정 설비 전문 엔지니어입니다. 펌프, 탱크, 열교환기 등의 메인 설비 태그 매핑에 특화되어 있습니다."
return await self._batch_gather(node_ids, prompt)
def validate_mapping(resolved_tag: str, symbol_type: str, tag_metadata: Dict[str, Any]) -> Tuple[bool, str]:
"""심볼 타입과 실제 태그 메타데이터의 엄격한 일치 여부 검증"""
if resolved_tag == "UNKNOWN":
return False, "Tag not resolved"
unit_map = {
"Pressure Transmitter": ["bar", "psi", "kPa", "Pa", "kg/cm2"],
"Flow Meter": ["m3/h", "lpm", "kg/h"],
"Temperature Sensor": ["°C", "C", "K", "°F"]
}
actual_unit = tag_metadata.get('unit', '').strip()
allowed_units = unit_map.get(symbol_type, [])
if actual_unit and actual_unit in allowed_units:
return True, "Unit Match"
actual_desc = tag_metadata.get('description', '').lower()
expected_keywords = {
"Pressure Transmitter": ["pressure", "press"],
"Flow Meter": ["flow", "flowrate"],
"Temperature Sensor": ["temp", "temperature"]
}
keywords = expected_keywords.get(symbol_type, [])
if any(kw in actual_desc for kw in keywords):
return True, "Description Match (Unit Missing)"
return False, "Mismatch: Symbol type and Tag metadata do not align"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,339 @@
#!/usr/bin/env python3
"""NL2SQL 전용 워커 프로세스
Usage: python nl2sql_worker.py <port>
담당 도구:
run_sql, query_pv_history, get_tag_metadata, list_drawings, query_with_nl
특징:
- PostgreSQL 직접 연결
- LLM SQL 생성 + DB 실행 분리
- 메모리: ~1GB (SQL 생성용 LLM)
- 생명주기: 메인 서버 종료 시까지 유지
"""
from __future__ import annotations
import sys
import os
# mcp-server 디렉토리를 Python 경로에 추가
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import logging
import asyncio
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
import httpx
# ── 설정 ─────────────────────────────────────────────────────────────────────
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "glm-4.7-flash")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [nl2sql_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── DB 연결 풀 ───────────────────────────────────────────────────────────────
def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
# ── LLM 클라이언트 ───────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm_client():
from openai import AsyncOpenAI
return AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
# DB 스키마 — server.py::_DB_SCHEMA와 동일
DB_SCHEMA = """
PostgreSQL 시계열 데이터베이스 스키마
테이블: history_table (시계열 이력)
tagname TEXT - 태그명 (모두 소문자, 예: 'ficq-6113.pv') — 대소문자 구분
node_id TEXT - OPC UA 노드 ID
value TEXT - 측정값, 수치 연산 시 ::double precision 캐스트 필요
recorded_at TIMESTAMPTZ - 기록 시각(UTC), 스냅샷 주기 약 60초
테이블: realtime_table (실시간 최신값)
tagname TEXT - 태그명 (모두 소문자)
node_id TEXT - OPC UA 노드 ID
livevalue TEXT - 현재값
timestamp TIMESTAMPTZ - 최종 갱신 시각
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 공식 사용
"""
async def _generate_sql(natural_language: str) -> str:
"""자연어를 SQL로 변환."""
client = _llm_client()
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"
f"{DB_SCHEMA}"
)
response = await client.chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": natural_language},
],
max_tokens=8192,
temperature=0.1,
)
sql = response.choices[0].message.content.strip()
# 마크다운 코드 블록 제거
if sql.startswith("```"):
lines = sql.splitlines()
sql = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip()
return sql
# ── NL2SQL 도구 구현 ─────────────────────────────────────────────────────────
@app.get("/health")
async def health():
"""워커 헬스체크."""
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
"""HTTP 요청을 MCP 도구 호출로 변환."""
body = await request.json()
tool = body["tool"]
params = body["params"]
try:
if tool == "run_sql":
result = await _run_sql(**params)
elif tool == "query_pv_history":
result = await _query_pv_history(**params)
elif tool == "get_tag_metadata":
result = await _get_tag_metadata(**params)
elif tool == "list_drawings":
result = await _list_drawings(**params)
elif tool == "query_with_nl":
result = await _query_with_nl(**params)
else:
return {"success": False, "error": f"Unknown tool: {tool}"}
return result
except Exception as e:
logging.error(f"Error executing {tool}: {e}")
return {"success": False, "error": str(e)}
async def _run_sql(sql: str) -> str:
"""SQL 실행."""
conn = _get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(sql)
if cur.description:
columns = [desc[0] for desc in cur.description]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"columns": columns,
"count": len(data),
"data": data,
}
else:
conn.commit()
return {
"success": True,
"message": f"Query executed successfully. {cur.rowcount} rows affected.",
}
finally:
conn.close()
async def _query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str:
"""과거 값(PV) 히스토리 조회."""
if not tag_names:
return {"success": False, "error": "tag_names is required"}
conn = _get_db_connection()
try:
with conn.cursor() as cur:
# TimescaleDB의 time_bucket 함수 사용
cur.execute(
"""
SELECT time_bucket('1 min', ts) AS time, tag_name, value
FROM realtime_table
WHERE tag_name = ANY(%s)
AND ts >= %s
AND ts <= %s
ORDER BY time DESC
LIMIT %s
""",
(tag_names, time_from, time_to, limit),
)
columns = ["time", "tag_name", "value"]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"tag_names": tag_names,
"time_range": {"from": time_from, "to": time_to},
"limit": limit,
"count": len(data),
"data": data,
}
finally:
conn.close()
async def _get_tag_metadata(query: str, limit: int = 10) -> str:
"""태그 메타데이터 검색."""
conn = _get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT DISTINCT tag_name, unit, description
FROM realtime_table
WHERE tag_name ILIKE %s
ORDER BY tag_name
LIMIT %s
""",
(f"%{query}%", limit),
)
columns = ["tag_name", "unit", "description"]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"query": query,
"count": len(data),
"tags": data,
}
finally:
conn.close()
async def _list_drawings(unit_no: str = None) -> str:
"""단위별 도면 목록 조회."""
conn = _get_db_connection()
try:
with conn.cursor() as cur:
if unit_no:
cur.execute(
"""
SELECT DISTINCT name
FROM node_map_master
WHERE name LIKE %s
ORDER BY name
""",
(f"{unit_no}%",),
)
else:
cur.execute(
"""
SELECT DISTINCT name
FROM node_map_master
ORDER BY name
"""
)
columns = ["name"]
rows = cur.fetchall()
data = [dict(zip(columns, row[0])) for row in rows]
return {
"success": True,
"unit_no": unit_no,
"count": len(data),
"names": [d["name"] for d in data],
}
finally:
conn.close()
async def _query_with_nl(question: str) -> str:
"""자연어로 SQL 쿼리 실행."""
import json
sql = await _generate_sql(question)
# SQL이 비어있으면 오류 반환
if not sql:
return json.dumps({"success": False, "sql": "", "error": "LLM이 SQL을 생성하지 못했습니다."}, ensure_ascii=False)
conn = _get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(sql)
if cur.description:
columns = [desc[0] for desc in cur.description]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"sql": sql,
"columns": columns,
"count": len(data),
"data": data,
}
else:
conn.commit()
return {
"success": True,
"sql": sql,
"message": f"Query executed successfully. {cur.rowcount} rows affected.",
}
except Exception as db_error:
return {
"success": False,
"sql": sql,
"error": str(db_error),
}
finally:
conn.close()
# ── 메인 ─────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5003
logging.info(f"Starting NL2SQL worker on port {port}")
uvicorn.run(app, host="0.0.0.0", port=port)

View File

@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""P&ID 태그 추출기 공통 템플릿
독립 프로세스로서 CLI에서 실행되며,
입력 텍스트 파일에서 P&ID 태그를 추출하여 JSON 파일로 출력합니다.
사용법:
python pid_extract_template.py --input full_text.txt --output result.json --prompt "system prompt text"
python pid_extract_template.py --input full_text.txt --output result.json --prompt-file prompt.txt
환경 변수:
VLLM_BASE_URL: vLLM 엔드포인트 (기본: http://localhost:8000/v1)
VLLM_MODEL: 모델명 (기본: glm-4.7-flash)
"""
import argparse
import json
import logging
import os
import re
import sys
import time
from typing import List
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
)
logger = logging.getLogger("pid_extractor")
def parse_json_array(raw: str, finish_reason: str = "") -> list:
"""LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
if raw.startswith("```"):
lines = raw.splitlines()
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
if finish_reason == "length":
last_close = raw.rfind("}")
if last_close != -1:
raw = raw[:last_close + 1] + "]"
# 가장 긴 균형 잡힌 [...] 추출
depth = 0
start = -1
best = ""
for i, c in enumerate(raw):
if c == "[":
if depth == 0:
start = i
depth += 1
elif c == "]":
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i + 1]
if len(cand) > len(best):
best = cand
raw = best if best else "[]"
try:
return json.loads(raw)
except json.JSONDecodeError:
data = []
for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
try:
data.append(json.loads(obj))
except json.JSONDecodeError:
pass
return data
def call_llm(system_prompt: str, user_text: str, max_tokens: int = 65536) -> List[dict]:
"""
vLLM에 LLM 호출하여 태그 목록 추출.
Args:
system_prompt: 시스템 프롬프트
user_text: 입력 텍스트
max_tokens: 최대 토큰 수
Returns:
추출된 태그 목록 (JSON 배열)
"""
from openai import OpenAI
base_url = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
model = os.environ.get("VLLM_MODEL", "glm-4.7-flash")
client = OpenAI(base_url=base_url, api_key="dummy")
logger.info(f"vLLM 호출: {base_url}, 모델: {model}, max_tokens: {max_tokens}")
logger.info(f"입력 텍스트 길이: {len(user_text)}")
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_text},
],
max_tokens=max_tokens,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
finish_reason = resp.choices[0].finish_reason
logger.info(f"LLM 응답: finish_reason={finish_reason}, 응답 길이={len(raw)}")
data = parse_json_array(raw, finish_reason)
if finish_reason == "length":
logger.warning(f"finish_reason=length: 응답이 잘렸습니다. 복구 시도됨. 추출된 태그 수: {len(data)}")
return data
def main():
parser = argparse.ArgumentParser(description="P&ID 태그 추출기")
parser.add_argument("--input", required=True, help="입력 텍스트 파일 경로")
parser.add_argument("--output", required=True, help="출력 JSON 파일 경로")
parser.add_argument("--prompt", type=str, default=None, help="시스템 프롬프트 (인라인)")
parser.add_argument("--prompt-file", type=str, default=None, help="시스템 프롬프트 파일 경로")
parser.add_argument("--max-tokens", type=int, default=65536, help="최대 토큰 수 (기본: 65536)")
args = parser.parse_args()
# 1. 입력 텍스트 읽기
if not os.path.exists(args.input):
logger.error(f"입력 파일을 찾을 수 없습니다: {args.input}")
sys.exit(1)
with open(args.input, "r", encoding="utf-8") as f:
input_text = f.read()
logger.info(f"입력 파일 읽기 완료: {len(input_text)}")
# 2. 시스템 프롬프트 읽기
system_prompt = None
if args.prompt:
system_prompt = args.prompt
elif args.prompt_file:
if not os.path.exists(args.prompt_file):
logger.error(f"프롬프트 파일을 찾을 수 없습니다: {args.prompt_file}")
sys.exit(1)
with open(args.prompt_file, "r", encoding="utf-8") as f:
system_prompt = f.read()
else:
logger.error("--prompt 또는 --prompt-file 중 하나를 지정해야 합니다.")
sys.exit(1)
logger.info(f"시스템 프롬프트: {len(system_prompt)}")
# 3. LLM 호출
t0 = time.time()
tags = call_llm(system_prompt, input_text, max_tokens=args.max_tokens)
elapsed = time.time() - t0
logger.info(f"추출 완료: {len(tags)}개 태그, 소요 시간: {elapsed:.1f}")
# 4. 결과 JSON 쓰기
output_dir = os.path.dirname(args.output)
if output_dir:
os.makedirs(output_dir, exist_ok=True)
result = {
"success": True,
"count": len(tags),
"tags": tags,
"processing_time_sec": round(elapsed, 1),
}
with open(args.output, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
logger.info(f"결과 저장 완료: {args.output}")
# 5. 요약 출력
print(json.dumps({
"success": True,
"count": len(tags),
"time": round(elapsed, 1)
}, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,609 @@
#!/usr/bin/env python3
"""P&ID 파싱 전용 워커 프로세스
Usage: python pid_worker.py <port>
담당 도구:
extract_pid_tags, match_pid_tags,
parse_pid_dxf, parse_pid_pdf, parse_pid_drawing,
build_pid_graph_parallel, analyze_pid_impact
"""
from __future__ import annotations
import sys
import os
# mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import io
import json
import asyncio
import signal
import logging
import re
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
# ── 설정 ─────────────────────────────────────────────────────────────────────
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "glm-4.7-flash")
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
_SERVER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STORAGE_DIR = os.path.join(_SERVER_DIR, "storage")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [pid_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── 싱글톤 ───────────────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm():
from openai import OpenAI
return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
@lru_cache(maxsize=1)
def _ocr():
from paddleocr import PaddleOCR
use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true"
try:
return PaddleOCR(use_angle_cls=True, lang="korean", use_gpu=use_gpu, show_log=False)
except Exception:
if use_gpu:
os.environ["PADDLE_USE_GPU"] = "false"
return _ocr()
raise
# ── DB ───────────────────────────────────────────────────────────────────────
def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
# ── 텍스트 추출 ──────────────────────────────────────────────────────────────
def _extract_text_from_dxf(filepath: str) -> str:
import ezdxf
from ezdxf.tools.text import plain_mtext
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
texts = []
for entity in msp:
if entity.dxftype() == "TEXT":
texts.append(entity.dxf.text)
elif entity.dxftype() == "MTEXT":
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
return "\n".join(texts)
def _extract_text_from_pdf(filepath: str) -> str:
import fitz
doc = fitz.open(filepath)
return "\n".join(page.get_text() for page in doc)
def _extract_text_from_pdf_ocr(filepath: str) -> str:
import fitz
from PIL import Image
import numpy as np
doc = fitz.open(filepath)
all_texts = []
for page in doc:
mat = fitz.Matrix(300 / 72)
pix = page.get_pixmap(matrix=mat)
img = Image.open(io.BytesIO(pix.tobytes("png")))
result = _ocr().ocr(np.array(img), cls=True)
if result and result[0]:
all_texts.extend(line[1][0] for line in result[0])
return "\n".join(all_texts)
# ── JSON 배열 파싱 유틸 ───────────────────────────────────────────────────────
def _parse_json_array(raw: str, finish_reason: str = "") -> list:
"""LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
if raw.startswith("```"):
lines = raw.splitlines()
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
if finish_reason == "length":
last_close = raw.rfind("}")
if last_close != -1:
raw = raw[:last_close + 1] + "]"
# 가장 긴 균형 잡힌 [...] 추출
depth = 0; start = -1; best = ""
for i, c in enumerate(raw):
if c == "[":
if depth == 0:
start = i
depth += 1
elif c == "]":
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i + 1]
if len(cand) > len(best):
best = cand
raw = best if best else "[]"
try:
return json.loads(raw)
except json.JSONDecodeError:
data = []
for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
try:
data.append(json.loads(obj))
except json.JSONDecodeError:
pass
return data
# ── 태그 추출/매핑 도구 ───────────────────────────────────────────────────────
def _extract_pid_tags(text: str, source_type: str) -> str:
system = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract all instrument and equipment tags from the provided text.\n"
"Return ONLY a valid JSON array. Each element must have exactly these fields:\n"
'{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV",'
'"lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n'
"Rules:\n"
"- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n"
" Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n"
"- instrumentType: leading letters of tagNo\n"
"- equipmentName: descriptive name if present near tag, else null\n"
"- lineNumber/pidDrawingNo: null unless explicitly associated\n"
"- confidence: 0.95 for clear tags, lower for ambiguous\n"
"- Output ONLY the JSON array, no markdown, no explanation.\n"
"- If no tags found, return: []\n"
)
truncated = text[:100000]
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"},
],
max_tokens=32768,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
logging.info(f"extract_pid_tags source={source_type} count={len(data)}")
return json.dumps({
"success": True,
"data": {"count": len(data), "tags": data},
"message": "태그 추출 완료"
}, ensure_ascii=False, indent=2)
def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
system = (
"You are a P&ID to Experion tag matching expert.\n"
"Match P&ID tags to Experion tags based on similarity.\n"
"Return ONLY a JSON array:\n"
'[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n'
"- If no good match: confidence < 0.5, experionTag null\n"
"- Output ONLY the JSON array.\n"
)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": (
f"P&ID Tags:\n{chr(10).join(pid_tags)}\n\n"
f"Experion Tags:\n{chr(10).join(experion_tags)}"
)},
],
max_tokens=16384,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
return json.dumps({
"success": True,
"data": {"count": len(data), "mappings": data},
"message": "태그 매핑 완료"
}, ensure_ascii=False, indent=2)
# ── 도면 파싱 도구 ────────────────────────────────────────────────────────────
_TAG_EXTRACT_SYSTEM = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract instrument and equipment tags from the provided text.\n"
"Return ONLY a JSON array:\n"
'[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FIT",'
'"lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n'
"Rules:\n"
"- tagNo: Instrument [Function]-[Number], Equipment [Type]-[Number]\n"
"- instrumentType: first 2-4 letters of tagNo\n"
"- equipmentName/lineNumber/pidDrawingNo: null if not present\n"
"- confidence: 0.0 to 1.0\n"
"- Output ONLY the JSON array, no markdown.\n"
"- If no tags found, return: []\n"
)
def _parse_pid_dxf(filepath: str) -> str:
text = _extract_text_from_dxf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: dxf\n\nText:\n{text[:8000]}"},
],
max_tokens=8192,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "DXF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
text = _extract_text_from_pdf_ocr(filepath) if use_ocr else _extract_text_from_pdf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"},
],
max_tokens=4096,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "PDF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_drawing(filepath: str) -> str:
ext = os.path.splitext(filepath)[1].lower()
if ext == ".dxf":
return _parse_pid_dxf(filepath)
elif ext == ".pdf":
return _parse_pid_pdf(filepath)
elif ext == ".dwg":
return json.dumps({
"success": False,
"data": None,
"error": "DWG 파일은 직접 파싱할 수 없습니다. DXF로 변환 후 사용하세요.",
"message": "지원하지 않는 파일 형식"
}, ensure_ascii=False)
else:
return json.dumps({
"success": False,
"error": f"지원하지 않는 형식: {ext}. 지원: .dxf, .pdf",
}, ensure_ascii=False)
# ── 그래프 도구 ───────────────────────────────────────────────────────────────
import time
async def _build_pid_graph_parallel(filepath: str) -> str:
"""
P&ID 그래프 빌드 — 독립 프로세스 병렬 아키텍처.
Phase 1: 도면 분할 + 기하 추출
Phase 2: 전체 텍스트 1회 추출
Phase 3: 5개 독립 추출 프로세스 병렬 실행 (subprocess.Popen)
Phase 4: 결과 통합 + tagNo 기준 중복 제거
Phase 5: 위상 그래프 빌드 + 저장
"""
from pipeline.extractor import PidGeometricExtractor
from pipeline.topology import PidTopologyBuilder
import subprocess
import tempfile
import shutil
os.makedirs(STORAGE_DIR, exist_ok=True)
t0 = time.time()
basename = os.path.basename(filepath)
worker_dir = os.path.dirname(os.path.abspath(__file__))
logging.info(f"[{basename}] === 독립 프로세스 병렬 아키텍처 시작 ===")
# ── Phase 1: 도면 분할 + 기하 추출 ──────────────────────────────
logging.info(f"[{basename}] Phase 1: 도면 분할 + 기하 추출 시작")
extractor = PidGeometricExtractor(filepath)
regions = extractor.split_drawings()
logging.info(f"[{basename}] 도면 분할: {len(regions)}개 영역")
geo_data_path = os.path.join(STORAGE_DIR, basename + "_geo.json")
extractor.extract_and_save(geo_data_path)
with open(geo_data_path, "r", encoding="utf-8") as f:
geo_data = json.load(f)
logging.info(f"[{basename}] Phase 1 완료 ({time.time()-t0:.1f}s) - {len(geo_data)}개 엔티티")
# ── Phase 2: 전체 텍스트 1회 추출 ───────────────────────────────
t2 = time.time()
logging.info(f"[{basename}] Phase 2: 전체 텍스트 1회 추출")
full_text = _extract_text_from_dxf(filepath)
# 임시 디렉토리 생성 (프로세스 간 통신용)
temp_dir = tempfile.mkdtemp(prefix=f"pid_{basename.replace('.dxf', '')}_")
text_path = os.path.join(temp_dir, "full_text.txt")
with open(text_path, "w", encoding="utf-8") as f:
f.write(full_text)
logging.info(f"[{basename}] Phase 2 완료 ({time.time()-t2:.1f}s) - {len(full_text)}")
# ── Phase 3: 5개 독립 추출 프로세스 병렬 실행 ───────────────────
t3 = time.time()
logging.info(f"[{basename}] Phase 3: 5개 독립 추출 프로세스 병렬 실행")
extractors = [
("sensor", "pid_extract_sensor.py"),
("valve", "pid_extract_valve.py"),
("system", "pid_extract_system.py"),
("gauge", "pid_extract_gauge.py"),
("pump", "pid_extract_pump.py"),
]
results_dir = os.path.join(temp_dir, "results")
os.makedirs(results_dir, exist_ok=True)
processes = []
for name, script in extractors:
output_path = os.path.join(results_dir, f"{name}.json")
script_path = os.path.join(worker_dir, script)
cmd = [
sys.executable, script_path,
"--input", text_path,
"--output", output_path,
]
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
processes.append((name, proc, output_path))
logging.info(f"[{basename}] 시작: {name} (pid={proc.pid})")
except Exception as e:
logging.error(f"[{basename}] 실행 실패 {name}: {e}")
# 모든 프로세스 대기 (timeout=300초)
for name, proc, output_path in processes:
try:
stdout, stderr = proc.communicate(timeout=300)
if proc.returncode != 0:
logging.error(f"[{basename}] {name} 실패 (rc={proc.returncode}): {stderr[:200]}")
else:
logging.info(f"[{basename}] {name} 완료: {stdout.strip()[:100]}")
except subprocess.TimeoutExpired:
proc.kill()
logging.error(f"[{basename}] {name} 타임아웃 (300초)")
logging.info(f"[{basename}] Phase 3 완료 ({time.time()-t3:.1f}s)")
# ── Phase 4: 결과 JSON 통합 + tagNo 기준 중복 제거 ──────────────
t4 = time.time()
logging.info(f"[{basename}] Phase 4: 결과 통합 + 중복 제거")
all_tags = []
seen_tagnos = set()
for name, _, output_path in processes:
if not os.path.exists(output_path):
logging.warning(f"[{basename}] 결과 없음: {name}")
continue
try:
with open(output_path, "r", encoding="utf-8") as f:
result = json.load(f)
tags = result.get("tags", [])
count_new = 0
for tag in tags:
tag_no = tag.get("tagNo", "")
if tag_no and tag_no not in seen_tagnos:
seen_tagnos.add(tag_no)
all_tags.append(tag)
count_new += 1
logging.info(f"[{basename}] {name}: {len(tags)}개 중 {count_new}개 신규")
except Exception as e:
logging.error(f"[{basename}] 결과 로드 실패 {name}: {e}")
logging.info(f"[{basename}] Phase 4 완료 ({time.time()-t4:.1f}s) - 총 {len(all_tags)}개 태그")
# ── Phase 5: 위상 그래프 빌드 + 저장 ────────────────────────────
t5 = time.time()
logging.info(f"[{basename}] Phase 5: 위상 그래프 빌드")
builder = PidTopologyBuilder(geo_data)
builder.build_graph()
# 추출된 태그를 그래프에 추가
from shapely.geometry import box as shapely_box
for tag in all_tags:
tag_no = tag.get("tagNo", "UNKNOWN")
eq_name = tag.get("equipmentName")
inst_type = tag.get("instrumentType")
confidence = tag.get("confidence", 0.5)
# 해당 태그의 bbox 찾기 (geo_data에서 clean_value 매칭)
matched_bbox = None
for entity in geo_data:
if entity.get("clean_value", "").upper() == tag_no.upper():
bbox = entity.get("bbox", {})
matched_bbox = (
bbox.get("min_x"), bbox.get("min_y"),
bbox.get("max_x"), bbox.get("max_y")
)
break
node_id = f"tag_{tag_no}"
if matched_bbox:
bbox_geom = shapely_box(matched_bbox[0], matched_bbox[1],
matched_bbox[2], matched_bbox[3])
builder.G.add_node(node_id,
type="TEXT",
bbox=bbox_geom,
value=tag_no,
equipment_name=eq_name,
instrument_type=inst_type,
confidence=confidence)
else:
builder.G.add_node(node_id,
type="TEXT",
bbox=shapely_box(0, 0, 1, 1),
value=tag_no,
equipment_name=eq_name,
instrument_type=inst_type,
confidence=confidence)
# 태그-설비 연결
equipments = [n for n, d in builder.G.nodes(data=True)
if d.get("type") not in ("TEXT", "LINE", "LWPOLYLINE")]
if equipments:
eq_grid = builder._build_spatial_grid(equipments)
tag_ids = [f"tag_{t.get('tagNo', '')}" for t in all_tags]
for tag_id in tag_ids:
if tag_id in builder.G:
best_match = builder._find_nearest_equipment(tag_id, eq_grid)
if best_match:
builder.G.add_edge(tag_id, best_match, relation="associated_with")
graph_id = basename.replace(".dxf", "_graph.json")
graph_path = os.path.join(STORAGE_DIR, graph_id)
builder.save_graph(graph_path)
# 임시 디렉토리 정리
shutil.rmtree(temp_dir, ignore_errors=True)
total_time = time.time() - t0
logging.info(f"[{basename}] 전체 완료 ({total_time:.1f}s) - graph_id={graph_id} "
f"nodes={builder.G.number_of_nodes()} "
f"edges={builder.G.number_of_edges()} "
f"tags={len(all_tags)}")
return json.dumps({
"success": True,
"graph_id": graph_id,
"graph_path": graph_path,
"nodes": builder.G.number_of_nodes(),
"edges": builder.G.number_of_edges(),
"tags_extracted": len(all_tags),
"processing_time_sec": round(total_time, 1)
}, ensure_ascii=False)
def _analyze_pid_impact(graph_id: str, start_node_id: str) -> str:
from pipeline.analyzer import PidAnalysisEngine
graph_path = os.path.join(STORAGE_DIR, graph_id)
mapping_path = graph_path.replace("_graph.json", "_mapping.json")
analyzer = PidAnalysisEngine(graph_path, mapping_path)
result = analyzer.analyze_impact(start_node_id)
return json.dumps(result, ensure_ascii=False, indent=2)
# ── 요청 디스패처 ─────────────────────────────────────────────────────────────
async def _dispatch(tool: str, params: dict) -> str:
try:
match tool:
# blocking 함수는 asyncio.to_thread로 스레드풀 오프로드
case "extract_pid_tags":
return await asyncio.to_thread(_extract_pid_tags, **params)
case "match_pid_tags":
return await asyncio.to_thread(_match_pid_tags, **params)
case "parse_pid_dxf":
return await asyncio.to_thread(_parse_pid_dxf, **params)
case "parse_pid_pdf":
return await asyncio.to_thread(_parse_pid_pdf, **params)
case "parse_pid_drawing":
return await asyncio.to_thread(_parse_pid_drawing, **params)
case "analyze_pid_impact":
return await asyncio.to_thread(_analyze_pid_impact, **params)
# 이미 async — 직접 await
case "build_pid_graph_parallel":
return await _build_pid_graph_parallel(**params)
case _:
return json.dumps({"success": False, "error": f"알 수 없는 도구: {tool}"},
ensure_ascii=False)
except Exception as e:
logging.error(f"dispatch error tool={tool}: {e}", exc_info=True)
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
# ── 종료 예약 ─────────────────────────────────────────────────────────────────
def _schedule_shutdown():
"""
응답 전송 완료 후 프로세스 종료 예약.
FastAPI의 BackgroundTasks를 사용하여 응답이 완전히 전송된 후 종료되도록 유도함.
"""
async def _do():
# 네트워크 전송 및 커넥션 정리를 위한 최소한의 대기 시간
await asyncio.sleep(1.0)
logging.info("One-shot worker shutting down...")
os.kill(os.getpid(), signal.SIGTERM)
asyncio.create_task(_do())
# ── HTTP 엔드포인트 ───────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
body = await request.json()
return await _dispatch(body["tool"], body["params"])
from fastapi import BackgroundTasks
@app.post("/execute/one_shot")
async def execute_one_shot(request: Request, background_tasks: BackgroundTasks):
"""요청 처리 후 프로세스 자동 종료 (P&ID 워커 전용)."""
body = await request.json()
result = await _dispatch(body["tool"], body["params"])
# BackgroundTasks에 등록하여 응답 전송이 완료된 후 _schedule_shutdown이 실행되도록 함
background_tasks.add_function(_schedule_shutdown)
return result
# ── 진입점 ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5004
os.makedirs(STORAGE_DIR, exist_ok=True)
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")

View File

@@ -0,0 +1,609 @@
#!/usr/bin/env python3
"""P&ID 파싱 전용 워커 프로세스 (테스트용 - 절대 경로 지원)
Usage: python pid_worker_test.py <port>
담당 도구:
extract_pid_tags, match_pid_tags,
parse_pid_dxf, parse_pid_pdf, parse_pid_drawing,
build_pid_graph_parallel, analyze_pid_impact
"""
from __future__ import annotations
import sys
import os
# 프로젝트 루트를 Python 경로에 추가
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, PROJECT_ROOT)
import io
import json
import asyncio
import signal
import logging
import re
from functools import lru_cache
from fastapi import FastAPI, Request, BackgroundTasks
import uvicorn
# ── 설정 ─────────────────────────────────────────────────────────────────────
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "glm-4.7-flash")
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
_SERVER_DIR = PROJECT_ROOT
STORAGE_DIR = os.path.join(_SERVER_DIR, "storage")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [pid_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── 싱글톤 ───────────────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm():
from openai import OpenAI
return OpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
@lru_cache(maxsize=1)
def _ocr():
from paddleocr import PaddleOCR
use_gpu = os.environ.get("PADDLE_USE_GPU", "true").lower() == "true"
try:
return PaddleOCR(use_angle_cls=True, lang="korean", use_gpu=use_gpu, show_log=False)
except Exception:
if use_gpu:
os.environ["PADDLE_USE_GPU"] = "false"
return _ocr()
raise
# ── DB ───────────────────────────────────────────────────────────────────────
def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
# ── 텍스트 추출 ──────────────────────────────────────────────────────────────
def _extract_text_from_dxf(filepath: str) -> str:
import ezdxf
from ezdxf.tools.text import plain_mtext
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
texts = []
for entity in msp:
if entity.dxftype() == "TEXT":
texts.append(entity.dxf.text)
elif entity.dxftype() == "MTEXT":
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
return "\n".join(texts)
def _extract_text_from_pdf(filepath: str) -> str:
import fitz
doc = fitz.open(filepath)
return "\n".join(page.get_text() for page in doc)
def _extract_text_from_pdf_ocr(filepath: str) -> str:
import fitz
from PIL import Image
import numpy as np
doc = fitz.open(filepath)
all_texts = []
for page in doc:
mat = fitz.Matrix(300 / 72)
pix = page.get_pixmap(matrix=mat)
img = Image.open(io.BytesIO(pix.tobytes("png")))
result = _ocr().ocr(np.array(img), cls=True)
if result and result[0]:
all_texts.extend(line[1][0] for line in result[0])
return "\n".join(all_texts)
# ── JSON 배열 파싱 유틸 ───────────────────────────────────────────────────────
def _parse_json_array(raw: str, finish_reason: str = "") -> list:
"""LLM 출력에서 JSON 배열 추출. finish_reason=length 잘림 복구 포함."""
if raw.startswith("```"):
lines = raw.splitlines()
raw = "\n".join(lines[1:-1] if lines and lines[-1].strip() == "```" else lines[1:]).strip()
if finish_reason == "length":
last_close = raw.rfind("}")
if last_close != -1:
raw = raw[:last_close + 1] + "]"
# 가장 긴 균형 잡힌 [...] 추출
depth = 0; start = -1; best = ""
for i, c in enumerate(raw):
if c == "[":
if depth == 0:
start = i
depth += 1
elif c == "]":
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i + 1]
if len(cand) > len(best):
best = cand
raw = best if best else "[]"
try:
return json.loads(raw)
except json.JSONDecodeError:
data = []
for obj in re.findall(r"\{[^{}]*\}", raw, re.DOTALL):
try:
data.append(json.loads(obj))
except json.JSONDecodeError:
pass
return data
# ── 태그 추출/매핑 도구 ───────────────────────────────────────────────────────
def _extract_pid_tags(text: str, source_type: str) -> str:
system = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract all instrument and equipment tags from the provided text.\n"
"Return ONLY a valid JSON array. Each element must have exactly these fields:\n"
'{"tagNo":"FCV-101","equipmentName":null,"instrumentType":"FCV",'
'"lineNumber":null,"pidDrawingNo":null,"confidence":0.95}\n'
"Rules:\n"
"- tagNo: any token matching [LETTERS]-[DIGITS] or [LETTERS]-[DIGITS]-[SUFFIX]\n"
" Examples: FCV-101, P-10101, T-10100, VG-6203-15A-F1A-n, BT-6200, DP-10101\n"
"- instrumentType: leading letters of tagNo\n"
"- equipmentName: descriptive name if present near tag, else null\n"
"- lineNumber/pidDrawingNo: null unless explicitly associated\n"
"- confidence: 0.95 for clear tags, lower for ambiguous\n"
"- Output ONLY the JSON array, no markdown, no explanation.\n"
"- If no tags found, return: []\n"
)
truncated = text[:100000]
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"},
],
max_tokens=32768,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
logging.info(f"extract_pid_tags source={source_type} count={len(data)}")
return json.dumps({
"success": True,
"data": {"count": len(data), "tags": data},
"message": "태그 추출 완료"
}, ensure_ascii=False, indent=2)
def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
system = (
"You are a P&ID to Experion tag matching expert.\n"
"Match P&ID tags to Experion tags based on similarity.\n"
"Return ONLY a JSON array:\n"
'[{"pidTag":"FT-101","experionTag":"ft-101.pv","confidence":0.92},...]\n'
"- If no good match: confidence < 0.5, experionTag null\n"
"- Output ONLY the JSON array.\n"
)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": (
f"P&ID Tags:\n{chr(10).join(pid_tags)}\n\n"
f"Experion Tags:\n{chr(10).join(experion_tags)}"
)},
],
max_tokens=16384,
temperature=0.1,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
return json.dumps({
"success": True,
"data": {"count": len(data), "mappings": data},
"message": "태그 매핑 완료"
}, ensure_ascii=False, indent=2)
# ── 도면 파싱 도구 ────────────────────────────────────────────────────────────
_TAG_EXTRACT_SYSTEM = (
"You are a P&ID (Piping and Instrumentation Diagram) expert.\n"
"Extract instrument and equipment tags from the provided text.\n"
"Return ONLY a JSON array:\n"
'[{"tagNo":"FIT-10115","equipmentName":"Flow Transmitter","instrumentType":"FIT",'
'"lineNumber":"L-101","pidDrawingNo":"P&ID-001","confidence":0.95},...]\n'
"Rules:\n"
"- tagNo: Instrument [Function]-[Number], Equipment [Type]-[Number]\n"
"- instrumentType: first 2-4 letters of tagNo\n"
"- equipmentName/lineNumber/pidDrawingNo: null if not present\n"
"- confidence: 0.0 to 1.0\n"
"- Output ONLY the JSON array, no markdown.\n"
"- If no tags found, return: []\n"
)
def _parse_pid_dxf(filepath: str) -> str:
text = _extract_text_from_dxf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: dxf\n\nText:\n{text[:8000]}"},
],
max_tokens=8192,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "DXF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
text = _extract_text_from_pdf_ocr(filepath) if use_ocr else _extract_text_from_pdf(filepath)
if not text.strip():
return json.dumps({"success": True, "text": "", "count": 0, "tags": []},
ensure_ascii=False, indent=2)
resp = _llm().chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
{"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"},
],
max_tokens=4096,
temperature=0.1,
)
raw = (resp.choices[0].message.content or "").strip()
data = _parse_json_array(raw, resp.choices[0].finish_reason)
if not isinstance(data, list):
data = []
return json.dumps({
"success": True,
"data": {"text": text[:10000], "count": len(data), "tags": data},
"message": "PDF 파싱 완료"
}, ensure_ascii=False, indent=2)
def _parse_pid_drawing(filepath: str) -> str:
ext = os.path.splitext(filepath)[1].lower()
if ext == ".dxf":
return _parse_pid_dxf(filepath)
elif ext == ".pdf":
return _parse_pid_pdf(filepath)
elif ext == ".dwg":
return json.dumps({
"success": False,
"data": None,
"error": "DWG 파일은 직접 파싱할 수 없습니다. DXF로 변환 후 사용하세요.",
"message": "지원하지 않는 파일 형식"
}, ensure_ascii=False)
else:
return json.dumps({
"success": False,
"error": f"지원하지 않는 형식: {ext}. 지원: .dxf, .pdf",
}, ensure_ascii=False)
# ── 그래프 도구 ───────────────────────────────────────────────────────────────
import time
async def _build_pid_graph_parallel(filepath: str) -> str:
"""
P&ID 그래프 빌드 — 독립 프로세스 병렬 아키텍처.
Phase 1: 도면 분할 + 기하 추출
Phase 2: 전체 텍스트 1회 추출
Phase 3: 5개 독립 추출 프로세스 병렬 실행 (subprocess.Popen)
Phase 4: 결과 통합 + tagNo 기준 중복 제거
Phase 5: 위상 그래프 빌드 + 저장
"""
from pipeline.extractor import PidGeometricExtractor
from pipeline.topology import PidTopologyBuilder
import subprocess
import tempfile
import shutil
os.makedirs(STORAGE_DIR, exist_ok=True)
t0 = time.time()
# 절대 경로로 변환하여 파일 경로 문제 해결
if not os.path.isabs(filepath):
filepath = os.path.join(PROJECT_ROOT, filepath)
filepath = os.path.abspath(filepath)
basename = os.path.basename(filepath)
worker_dir = os.path.join(PROJECT_ROOT, "mcp-server", "worker")
logging.info(f"[{basename}] === 독립 프로세스 병렬 아키텍처 시작 ===")
# ── Phase 1: 도면 분할 + 기하 추출 ──────────────────────────────
logging.info(f"[{basename}] Phase 1: 도면 분할 + 기하 추출 시작")
extractor = PidGeometricExtractor(filepath)
regions = extractor.split_drawings()
logging.info(f"[{basename}] 도면 분할: {len(regions)}개 영역")
geo_data_path = os.path.join(STORAGE_DIR, basename + "_geo.json")
extractor.extract_and_save(geo_data_path)
with open(geo_data_path, "r", encoding="utf-8") as f:
geo_data = json.load(f)
logging.info(f"[{basename}] Phase 1 완료 ({time.time()-t0:.1f}s) - {len(geo_data)}개 엔티티")
# ── Phase 2: 전체 텍스트 1회 추출 ───────────────────────────────
t2 = time.time()
logging.info(f"[{basename}] Phase 2: 전체 텍스트 1회 추출")
full_text = _extract_text_from_dxf(filepath)
# 임시 디렉토리 생성 (프로세스 간 통신용)
temp_dir = tempfile.mkdtemp(prefix=f"pid_{basename.replace('.dxf', '')}_")
text_path = os.path.join(temp_dir, "full_text.txt")
with open(text_path, "w", encoding="utf-8") as f:
f.write(full_text)
logging.info(f"[{basename}] Phase 2 완료 ({time.time()-t2:.1f}s) - {len(full_text)}")
# ── Phase 3: 5개 독립 추출 프로세스 병렬 실행 ───────────────────
t3 = time.time()
logging.info(f"[{basename}] Phase 3: 5개 독립 추출 프로세스 병렬 실행")
extractors = [
("sensor", "pid_extract_sensor.py"),
("valve", "pid_extract_valve.py"),
("system", "pid_extract_system.py"),
("gauge", "pid_extract_gauge.py"),
("pump", "pid_extract_pump.py"),
]
results_dir = os.path.join(temp_dir, "results")
os.makedirs(results_dir, exist_ok=True)
processes = []
for name, script in extractors:
output_path = os.path.join(results_dir, f"{name}.json")
script_path = os.path.join(worker_dir, script)
cmd = [
sys.executable, script_path,
"--input", text_path,
"--output", output_path,
]
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
processes.append((name, proc, output_path))
logging.info(f"[{basename}] 시작: {name} (pid={proc.pid})")
except Exception as e:
logging.error(f"[{basename}] 실행 실패 {name}: {e}")
# 모든 프로세스 대기 (timeout=300초)
for name, proc, output_path in processes:
try:
stdout, stderr = proc.communicate(timeout=300)
if proc.returncode != 0:
logging.error(f"[{basename}] {name} 실패 (rc={proc.returncode}): {stderr[:200]}")
else:
logging.info(f"[{basename}] {name} 완료: {stdout.strip()[:100]}")
except subprocess.TimeoutExpired:
proc.kill()
logging.error(f"[{basename}] {name} 타임아웃 (300초)")
logging.info(f"[{basename}] Phase 3 완료 ({time.time()-t3:.1f}s)")
# ── Phase 4: 결과 JSON 통합 + tagNo 기준 중복 제거 ──────────────
t4 = time.time()
logging.info(f"[{basename}] Phase 4: 결과 통합 + 중복 제거")
all_tags = []
seen_tagnos = set()
for name, _, output_path in processes:
if not os.path.exists(output_path):
logging.warning(f"[{basename}] 결과 없음: {name}")
continue
try:
with open(output_path, "r", encoding="utf-8") as f:
result = json.load(f)
tags = result.get("tags", [])
count_new = 0
for tag in tags:
tag_no = tag.get("tagNo", "")
if tag_no and tag_no not in seen_tagnos:
seen_tagnos.add(tag_no)
all_tags.append(tag)
count_new += 1
logging.info(f"[{basename}] {name}: {len(tags)}개 중 {count_new}개 신규")
except Exception as e:
logging.error(f"[{basename}] 결과 로드 실패 {name}: {e}")
logging.info(f"[{basename}] Phase 4 완료 ({time.time()-t4:.1f}s) - 총 {len(all_tags)}개 태그")
# ── Phase 5: 위상 그래프 빌드 + 저장 ────────────────────────────
t5 = time.time()
logging.info(f"[{basename}] Phase 5: 위상 그래프 빌드")
builder = PidTopologyBuilder(geo_data)
builder.build_graph()
# 추출된 태그를 그래프에 추가
from shapely.geometry import box as shapely_box
for tag in all_tags:
tag_no = tag.get("tagNo", "UNKNOWN")
eq_name = tag.get("equipmentName")
inst_type = tag.get("instrumentType")
confidence = tag.get("confidence", 0.5)
# 해당 태그의 bbox 찾기 (geo_data에서 clean_value 매칭)
matched_bbox = None
for entity in geo_data:
if entity.get("clean_value", "").upper() == tag_no.upper():
bbox = entity.get("bbox", {})
matched_bbox = (
bbox.get("min_x"), bbox.get("min_y"),
bbox.get("max_x"), bbox.get("max_y")
)
break
node_id = f"tag_{tag_no}"
if matched_bbox:
bbox_geom = shapely_box(matched_bbox[0], matched_bbox[1],
matched_bbox[2], matched_bbox[3])
builder.G.add_node(node_id,
type="TEXT",
bbox=bbox_geom,
value=tag_no,
equipment_name=eq_name,
instrument_type=inst_type,
confidence=confidence)
else:
builder.G.add_node(node_id,
type="TEXT",
bbox=shapely_box(0, 0, 1, 1),
value=tag_no,
equipment_name=eq_name,
instrument_type=inst_type,
confidence=confidence)
# 태그-설비 연결
equipments = [n for n, d in builder.G.nodes(data=True)
if d.get("type") not in ("TEXT", "LINE", "LWPOLYLINE")]
if equipments:
eq_grid = builder._build_spatial_grid(equipments)
tag_ids = [f"tag_{t.get('tagNo', '')}" for t in all_tags]
for tag_id in tag_ids:
if tag_id in builder.G:
best_match = builder._find_nearest_equipment(tag_id, eq_grid)
if best_match:
builder.G.add_edge(tag_id, best_match, relation="associated_with")
graph_id = basename.replace(".dxf", "_graph.json")
graph_path = os.path.join(STORAGE_DIR, graph_id)
builder.save_graph(graph_path)
# 임시 디렉토리 정리
shutil.rmtree(temp_dir, ignore_errors=True)
total_time = time.time() - t0
logging.info(f"[{basename}] 전체 완료 ({total_time:.1f}s) - graph_id={graph_id} "
f"nodes={builder.G.number_of_nodes()} "
f"edges={builder.G.number_of_edges()} "
f"tags={len(all_tags)}")
return json.dumps({
"success": True,
"graph_id": graph_id,
"graph_path": graph_path,
"nodes": builder.G.number_of_nodes(),
"edges": builder.G.number_of_edges(),
"tags_extracted": len(all_tags),
"processing_time_sec": round(total_time, 1)
}, ensure_ascii=False)
def _analyze_pid_impact(graph_id: str, start_node_id: str) -> str:
from pipeline.analyzer import PidAnalysisEngine
graph_path = os.path.join(STORAGE_DIR, graph_id)
mapping_path = graph_path.replace("_graph.json", "_mapping.json")
analyzer = PidAnalysisEngine(graph_path, mapping_path)
result = analyzer.analyze_impact(start_node_id)
return json.dumps(result, ensure_ascii=False, indent=2)
# ── 요청 디스패처 ─────────────────────────────────────────────────────────────
async def _dispatch(tool: str, params: dict) -> str:
try:
match tool:
# blocking 함수는 asyncio.to_thread로 스레드풀 오프로드
case "extract_pid_tags":
return await asyncio.to_thread(_extract_pid_tags, **params)
case "match_pid_tags":
return await asyncio.to_thread(_match_pid_tags, **params)
case "parse_pid_dxf":
return await asyncio.to_thread(_parse_pid_dxf, **params)
case "parse_pid_pdf":
return await asyncio.to_thread(_parse_pid_pdf, **params)
case "parse_pid_drawing":
return await asyncio.to_thread(_parse_pid_drawing, **params)
case "analyze_pid_impact":
return await asyncio.to_thread(_analyze_pid_impact, **params)
# 이미 async — 직접 await
case "build_pid_graph_parallel":
return await _build_pid_graph_parallel(**params)
case _:
return json.dumps({"success": False, "error": f"알 수 없는 도구: {tool}"},
ensure_ascii=False)
except Exception as e:
logging.error(f"dispatch error tool={tool}: {e}", exc_info=True)
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
# ── 종료 예약 ─────────────────────────────────────────────────────────────────
def _schedule_shutdown():
"""
응답 전송 완료 후 프로세스 종료 예약.
FastAPI의 BackgroundTasks를 사용하여 응답이 완전히 전송된 후 종료되도록 유도함.
"""
async def _do():
# 네트워크 전송 및 커넥션 정리를 위한 최소한의 대기 시간
await asyncio.sleep(1.0)
logging.info("One-shot worker shutting down...")
os.kill(os.getpid(), signal.SIGTERM)
asyncio.create_task(_do())
# ── HTTP 엔드포인트 ───────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
body = await request.json()
return await _dispatch(body["tool"], body["params"])
@app.post("/execute/one_shot")
async def execute_one_shot(request: Request, background_tasks: BackgroundTasks):
"""요청 처리 후 프로세스 자동 종료 (P&ID 워커 전용)."""
body = await request.json()
result = await _dispatch(body["tool"], body["params"])
# BackgroundTasks에 등록하여 응답 전송이 완료된 후 _schedule_shutdown이 실행되도록 함
background_tasks.add_task(_schedule_shutdown)
return result
# ── 진입점 ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5005
os.makedirs(STORAGE_DIR, exist_ok=True)
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")

View File

@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""RAG 전용 워커 프로세스
Usage: python rag_worker.py <port>
담당 도구:
search_codebase, search_r530_docs, ask_iiot_llm, rag_query
특징:
- Ollama Embedding + Qdrant 검색 + vLLM LLM 조합
- 메모리: ~200MB (워커 자체, vLLM 외부 서비스 사용 시)
- 생명주기: 메인 서버 종료 시까지 유지
"""
from __future__ import annotations
import sys
import os
# mcp-server 디렉토리를 Python 경로에 추가 (pipeline 패키지 접근)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import logging
import asyncio
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
import httpx
# ── 설정 ─────────────────────────────────────────────────────────────────────
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
QDRANT_URL = os.environ.get("QDRANT_URL", "http://localhost:6333")
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "glm-4.7-flash")
EMBED_MODEL = os.environ.get("EMBED_MODEL", "nomic-embed-text")
COL_CODEBASE = os.environ.get("COL_CODEBASE", "ws-65f457145aee80b2")
COL_OPC_DOCS = os.environ.get("COL_OPC_DOCS", "experion-opc-docs")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [rag_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── HTTP 클라이언트 싱글톤 ────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _get_http_client():
return httpx.AsyncClient(timeout=30)
# ── 임베딩 (Ollama) ───────────────────────────────────────────────────────────
async def _embed(text: str) -> list[float]:
"""Ollama nomic-embed-text로 768-dim 벡터 생성."""
async with _get_http_client() as client:
resp = await client.post(
f"{OLLAMA_URL}/api/embeddings",
json={"model": EMBED_MODEL, "prompt": text},
)
resp.raise_for_status()
return resp.json()["embedding"]
# ── Qdrant 검색 ──────────────────────────────────────────────────────────────
async def _qdrant_search(collection: str, query_vector: list[float], top_k: int = 6) -> list[dict]:
"""Qdrant에서 벡터 유사도 검색."""
async with _get_http_client() as client:
resp = await client.post(
f"{QDRANT_URL}/collections/{collection}/points/search",
json={
"vector": query_vector,
"limit": top_k,
"with_payload": True,
},
)
resp.raise_for_status()
return resp.json().get("result", [])
# ── LLM (vLLM) ───────────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm_client():
from openai import AsyncOpenAI
return AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
async def _ask_llm(question: str, context: str = "") -> str:
"""vLLM LLM으로 질문 응답."""
client = _llm_client()
if context:
prompt = f"""주어진 컨텍스트를 바탕으로 질문에 답변하세요.
컨텍스트:
{context}
질문:
{question}
답변:"""
else:
prompt = question
response = await client.chat.completions.create(
model="glm-4.7-flash",
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": prompt},
],
max_tokens=4096,
temperature=0.1,
)
return response.choices[0].message.content
# ── RAG 도구 구현 ─────────────────────────────────────────────────────────────
@app.get("/health")
async def health():
"""워커 헬스체크."""
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
"""HTTP 요청을 MCP 도구 호출로 변환."""
body = await request.json()
tool = body["tool"]
params = body["params"]
try:
if tool == "search_codebase":
result = await _search_codebase(**params)
elif tool == "search_r530_docs":
result = await _search_r530_docs(**params)
elif tool == "ask_iiot_llm":
result = await _ask_iiot_llm(**params)
elif tool == "rag_query":
result = await _rag_query(**params)
else:
return {"success": False, "error": f"Unknown tool: {tool}"}
return result
except Exception as e:
logging.error(f"Error executing {tool}: {e}")
return {"success": False, "error": str(e)}
async def _search_codebase(query: str, top_k: int = 6) -> str:
"""소스코드 검색."""
query_vector = await _embed(query)
results = await _qdrant_search(COL_CODEBASE, query_vector, top_k)
items = []
for hit in results:
payload = hit.get("payload", {})
items.append({
"score": hit.get("score", 0),
"file": payload.get("file", "unknown"),
"content": payload.get("content", "")[:500],
})
return {
"success": True,
"count": len(items),
"items": items,
}
async def _search_r530_docs(query: str, top_k: int = 5) -> str:
"""Experion HS R530 공식 문서 검색."""
query_vector = await _embed(query)
results = await _qdrant_search(COL_OPC_DOCS, query_vector, top_k)
items = []
for hit in results:
payload = hit.get("payload", {})
items.append({
"score": hit.get("score", 0),
"title": payload.get("title", "unknown"),
"content": payload.get("content", "")[:500],
})
return {
"success": True,
"count": len(items),
"items": items,
}
async def _ask_iiot_llm(question: str, context: str = "") -> str:
"""IIoT/OPC UA 질문 응답."""
answer = await _ask_llm(question, context)
return {
"success": True,
"question": question,
"answer": answer,
}
async def _rag_query(question: str, search_code: bool = False, search_docs: bool = True) -> str:
"""통합 RAG 검색."""
contexts = []
if search_code:
query_vector = await _embed(question)
code_results = await _qdrant_search(COL_CODEBASE, query_vector, 3)
for hit in code_results:
contexts.append(hit.get("payload", {}).get("content", ""))
if search_docs:
query_vector = await _embed(question)
doc_results = await _qdrant_search(COL_OPC_DOCS, query_vector, 3)
for hit in doc_results:
contexts.append(hit.get("payload", {}).get("content", ""))
context = "\n\n".join(contexts[:5])
answer = await _ask_llm(question, context)
return {
"success": True,
"question": question,
"context_count": len(contexts),
"answer": answer,
}
# ── 메인 ─────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5002
logging.info(f"Starting RAG worker on port {port}")
uvicorn.run(app, host="0.0.0.0", port=port)

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""
DXF 파일에서 P&ID 태그 추출 스크립트
ezdxf + OpenAI API 사용 (청크 단위 3회 호출)
"""
import ezdxf
import json
import re
from ezdxf.tools.text import plain_mtext
from openai import OpenAI
# DXF 파일 경로
filepath = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100.dxf'
# DXF 파일 읽기
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
# 텍스트 추출
texts = []
for entity in msp:
if entity.dxftype() == 'TEXT':
texts.append(entity.dxf.text)
elif entity.dxftype() == 'MTEXT':
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
text = '\n'.join(texts)
print(f'총 텍스트: {len(text)}')
# OpenAI 클라이언트 생성 (타임아웃 1800초 = 30분)
llm = OpenAI(
base_url='http://localhost:8000/v1',
api_key='dummy',
timeout=1800
)
# 청크별 프롬프트
chunks = [
{
'name': 'Gauges',
'system': (
'You are a P&ID expert. Extract instruments tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: PG, TG, LG\n'
'Format: [{"tagNo":"PG-10101A","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of PG, TG,LG from the text below:\n\n{text}'
}
]
# 결과 저장
all_tags = []
seen_tags = set()
for chunk in chunks:
print(f'\n=== {chunk["name"]} ===')
# 프롬프트 생성
system = chunk['system']
user = chunk['user'].format(text=text[:100000])
# LLM 호출
resp = llm.chat.completions.create(
model='glm-4.7-flash',
messages=[
{'role': 'system', 'content': system},
{'role': 'user', 'content': user},
],
max_tokens=65536,
temperature=0.1,
extra_body={'chat_template_kwargs': {'enable_thinking': False}},
)
raw = (resp.choices[0].message.content or '').strip()
finish = resp.choices[0].finish_reason
print(f'응답 길이: {len(raw)}자, finish_reason: {finish}')
# 잘린 경우 복구
if finish == 'length' and not raw.rstrip().endswith(']'):
last = raw.rfind('}')
if last != -1:
raw = raw[:last+1] + ']'
print('잘린 JSON 복구')
# 배열 추출
depth = 0
start = -1
best = ''
for i, c in enumerate(raw):
if c == '[':
if depth == 0:
start = i
depth += 1
elif c == ']':
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i+1]
if len(cand) > len(best):
best = cand
# JSON 파싱 및 결과 저장
try:
data = json.loads(best) if best else []
print(f'추출된 태그 수: {len(data)}')
for item in data:
tag_no = item.get('tagNo')
if tag_no and tag_no not in seen_tags:
seen_tags.add(tag_no)
all_tags.append({
'tagNo': tag_no,
'confidence': item.get('confidence', 0.95)
})
print(f'중복 제거 후 총 태그 수: {len(all_tags)}')
except json.JSONDecodeError as e:
print(f'파싱 실패: {e}')
print(repr(best[-300:] if best else ''))
# 최종 결과
print(f'\n=== 최종 결과 ===')
print(f'총 추출 태그 수: {len(all_tags)}')
print()
for t in all_tags[:30]:
print(f' {t["tagNo"]}')
if len(all_tags) > 30:
print(f' ... (총 {len(all_tags)}개)')
# JSON 파일로 저장
output_path = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100_extracted_PG.json'
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(all_tags, f, indent=2, ensure_ascii=False)
print(f'\n결과 저장: {output_path}')

View File

@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""
DXF 파일에서 P&ID 태그 추출 스크립트
ezdxf + OpenAI API 사용 (청크 단위 3회 호출)
"""
import ezdxf
import json
import re
from ezdxf.tools.text import plain_mtext
from openai import OpenAI
# DXF 파일 경로
filepath = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100.dxf'
# DXF 파일 읽기
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
# 텍스트 추출
texts = []
for entity in msp:
if entity.dxftype() == 'TEXT':
texts.append(entity.dxf.text)
elif entity.dxftype() == 'MTEXT':
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
text = '\n'.join(texts)
print(f'총 텍스트: {len(text)}')
# OpenAI 클라이언트 생성 (타임아웃 1800초 = 30분)
llm = OpenAI(
base_url='http://localhost:8000/v1',
api_key='dummy',
timeout=1800
)
# 청크별 프롬프트
chunks = [
{
'name': 'Field Instruments - Sensors',
'system': (
'You are a P&ID expert. Extract sensor tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: FT, FIT, LT, PT, TE, LI, PI, TI\n'
'Format: [{"tagNo":"FT-10101","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of FT, FIT, LT, PT, TE, LI, PI, TI, FIQ from the text below:\n\n{text}'
},
{
'name': 'Field Instruments - Valves',
'system': (
'You are a P&ID expert. Extract valve tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: FCV, TCV, LCV, PCV, XV, FV, LV, PV, TV\n'
'Format: [{"tagNo":"FCV-10101","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of FCV, TCV, LCV, PCV, XV, FV, LV, PV, TV from the text below:\n\n{text}'
},
{
'name': 'System Tags',
'system': (
'You are a P&ID expert. Extract system tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: LI, PI, TI, FIQ, FICQ, TICA, PICA, LICA, FIC, TIC, PIC, LIC\n'
'Format: [{"tagNo":"FICQ-10101","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of LI, PI, TI, FIQ, FICQ, TICA, PICA, LICA, FIC, TIC, PIC, LIC from the text below:\n\n{text}'
}
]
# 결과 저장
all_tags = []
seen_tags = set()
for chunk in chunks:
print(f'\n=== {chunk["name"]} ===')
# 프롬프트 생성
system = chunk['system']
user = chunk['user'].format(text=text[:100000])
# LLM 호출
resp = llm.chat.completions.create(
model='glm-4.7-flash',
messages=[
{'role': 'system', 'content': system},
{'role': 'user', 'content': user},
],
max_tokens=65536,
temperature=0.7,
extra_body={'chat_template_kwargs': {'enable_thinking': False}},
)
raw = (resp.choices[0].message.content or '').strip()
finish = resp.choices[0].finish_reason
print(f'응답 길이: {len(raw)}자, finish_reason: {finish}')
# 잘린 경우 복구
if finish == 'length' and not raw.rstrip().endswith(']'):
last = raw.rfind('}')
if last != -1:
raw = raw[:last+1] + ']'
print('잘린 JSON 복구')
# 배열 추출
depth = 0
start = -1
best = ''
for i, c in enumerate(raw):
if c == '[':
if depth == 0:
start = i
depth += 1
elif c == ']':
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i+1]
if len(cand) > len(best):
best = cand
# JSON 파싱 및 결과 저장
try:
data = json.loads(best) if best else []
print(f'추출된 태그 수: {len(data)}')
for item in data:
tag_no = item.get('tagNo')
if tag_no and tag_no not in seen_tags:
seen_tags.add(tag_no)
all_tags.append({
'tagNo': tag_no,
'confidence': item.get('confidence', 0.95)
})
print(f'중복 제거 후 총 태그 수: {len(all_tags)}')
except json.JSONDecodeError as e:
print(f'파싱 실패: {e}')
print(repr(best[-300:] if best else ''))
# 최종 결과
print(f'\n=== 최종 결과 ===')
print(f'총 추출 태그 수: {len(all_tags)}')
print()
for t in all_tags[:30]:
print(f' {t["tagNo"]}')
if len(all_tags) > 30:
print(f' ... (총 {len(all_tags)}개)')
# JSON 파일로 저장
output_path = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100_extracted.json'
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(all_tags, f, indent=2, ensure_ascii=False)
print(f'\n결과 저장: {output_path}')

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""
DXF 파일에서 P&ID 태그 추출 스크립트
ezdxf + OpenAI API 사용 (청크 단위 3회 호출)
"""
import ezdxf
import json
import re
from ezdxf.tools.text import plain_mtext
from openai import OpenAI
# DXF 파일 경로
filepath = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100.dxf'
# DXF 파일 읽기
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
# 텍스트 추출
texts = []
for entity in msp:
if entity.dxftype() == 'TEXT':
texts.append(entity.dxf.text)
elif entity.dxftype() == 'MTEXT':
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
text = '\n'.join(texts)
print(f'총 텍스트: {len(text)}')
# OpenAI 클라이언트 생성 (타임아웃 1800초 = 30분)
llm = OpenAI(
base_url='http://localhost:8000/v1',
api_key='dummy',
timeout=1800
)
# 청크별 프롬프트
chunks = [
{
'name': 'Field Instruments - Sensors',
'system': (
'You are a P&ID expert. Extract sensor tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: FT, FIT, LT, PT, TE, PG, LG, TG\n'
'Format: [{"tagNo":"FT-10101","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of FT, FIT, LT, PT, TE, PG, LG, TG from the text below:\n\n{text}'
}
]
# 결과 저장
all_tags = []
seen_tags = set()
for chunk in chunks:
print(f'\n=== {chunk["name"]} ===')
# 프롬프트 생성
system = chunk['system']
user = chunk['user'].format(text=text[:100000])
# LLM 호출
resp = llm.chat.completions.create(
model='glm-4.7-flash',
messages=[
{'role': 'system', 'content': system},
{'role': 'user', 'content': user},
],
max_tokens=65536,
temperature=0.1,
extra_body={'chat_template_kwargs': {'enable_thinking': False}},
)
raw = (resp.choices[0].message.content or '').strip()
finish = resp.choices[0].finish_reason
print(f'응답 길이: {len(raw)}자, finish_reason: {finish}')
# 잘린 경우 복구
if finish == 'length' and not raw.rstrip().endswith(']'):
last = raw.rfind('}')
if last != -1:
raw = raw[:last+1] + ']'
print('잘린 JSON 복구')
# 배열 추출
depth = 0
start = -1
best = ''
for i, c in enumerate(raw):
if c == '[':
if depth == 0:
start = i
depth += 1
elif c == ']':
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i+1]
if len(cand) > len(best):
best = cand
# JSON 파싱 및 결과 저장
try:
data = json.loads(best) if best else []
print(f'추출된 태그 수: {len(data)}')
for item in data:
tag_no = item.get('tagNo')
if tag_no and tag_no not in seen_tags:
seen_tags.add(tag_no)
all_tags.append({
'tagNo': tag_no,
'confidence': item.get('confidence', 0.95)
})
print(f'중복 제거 후 총 태그 수: {len(all_tags)}')
except json.JSONDecodeError as e:
print(f'파싱 실패: {e}')
print(repr(best[-300:] if best else ''))
# 최종 결과
print(f'\n=== 최종 결과 ===')
print(f'총 추출 태그 수: {len(all_tags)}')
print()
for t in all_tags[:30]:
print(f' {t["tagNo"]}')
if len(all_tags) > 30:
print(f' ... (총 {len(all_tags)}개)')
# JSON 파일로 저장
output_path = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100_extracted1.json'
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(all_tags, f, indent=2, ensure_ascii=False)
print(f'\n결과 저장: {output_path}')

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""
DXF 파일에서 P&ID 태그 추출 스크립트
ezdxf + OpenAI API 사용 (청크 단위 3회 호출)
"""
import ezdxf
import json
import re
from ezdxf.tools.text import plain_mtext
from openai import OpenAI
# DXF 파일 경로
filepath = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100.dxf'
# DXF 파일 읽기
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
# 텍스트 추출
texts = []
for entity in msp:
if entity.dxftype() == 'TEXT':
texts.append(entity.dxf.text)
elif entity.dxftype() == 'MTEXT':
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
text = '\n'.join(texts)
print(f'총 텍스트: {len(text)}')
# OpenAI 클라이언트 생성 (타임아웃 1800초 = 30분)
llm = OpenAI(
base_url='http://localhost:8000/v1',
api_key='dummy',
timeout=1800
)
# 청크별 프롬프트
chunks = [
{
'name': 'Field Instruments - Valves',
'system': (
'You are a P&ID expert. Extract valve tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: FCV, TCV, LCV, PCV, XV, FV, LV, PV, TV\n'
'Format: [{"tagNo":"FCV-10101","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of FCV, TCV, LCV, PCV, XV, FV, LV, PV, TV from the text below:\n\n{text}'
}
]
# 결과 저장
all_tags = []
seen_tags = set()
for chunk in chunks:
print(f'\n=== {chunk["name"]} ===')
# 프롬프트 생성
system = chunk['system']
user = chunk['user'].format(text=text[:100000])
# LLM 호출
resp = llm.chat.completions.create(
model='glm-4.7-flash',
messages=[
{'role': 'system', 'content': system},
{'role': 'user', 'content': user},
],
max_tokens=65536,
temperature=0.1,
extra_body={'chat_template_kwargs': {'enable_thinking': False}},
)
raw = (resp.choices[0].message.content or '').strip()
finish = resp.choices[0].finish_reason
print(f'응답 길이: {len(raw)}자, finish_reason: {finish}')
# 잘린 경우 복구
if finish == 'length' and not raw.rstrip().endswith(']'):
last = raw.rfind('}')
if last != -1:
raw = raw[:last+1] + ']'
print('잘린 JSON 복구')
# 배열 추출
depth = 0
start = -1
best = ''
for i, c in enumerate(raw):
if c == '[':
if depth == 0:
start = i
depth += 1
elif c == ']':
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i+1]
if len(cand) > len(best):
best = cand
# JSON 파싱 및 결과 저장
try:
data = json.loads(best) if best else []
print(f'추출된 태그 수: {len(data)}')
for item in data:
tag_no = item.get('tagNo')
if tag_no and tag_no not in seen_tags:
seen_tags.add(tag_no)
all_tags.append({
'tagNo': tag_no,
'confidence': item.get('confidence', 0.95)
})
print(f'중복 제거 후 총 태그 수: {len(all_tags)}')
except json.JSONDecodeError as e:
print(f'파싱 실패: {e}')
print(repr(best[-300:] if best else ''))
# 최종 결과
print(f'\n=== 최종 결과 ===')
print(f'총 추출 태그 수: {len(all_tags)}')
print()
for t in all_tags[:30]:
print(f' {t["tagNo"]}')
if len(all_tags) > 30:
print(f' ... (총 {len(all_tags)}개)')
# JSON 파일로 저장
output_path = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100_extracted2.json'
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(all_tags, f, indent=2, ensure_ascii=False)
print(f'\n결과 저장: {output_path}')

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""
DXF 파일에서 P&ID 태그 추출 스크립트
ezdxf + OpenAI API 사용 (청크 단위 3회 호출)
"""
import ezdxf
import json
import re
from ezdxf.tools.text import plain_mtext
from openai import OpenAI
# DXF 파일 경로
filepath = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100.dxf'
# DXF 파일 읽기
doc = ezdxf.readfile(filepath)
msp = doc.modelspace()
# 텍스트 추출
texts = []
for entity in msp:
if entity.dxftype() == 'TEXT':
texts.append(entity.dxf.text)
elif entity.dxftype() == 'MTEXT':
try:
plain = plain_mtext(entity.dxf.text)
if plain.strip():
texts.append(plain)
except Exception:
pass
text = '\n'.join(texts)
print(f'총 텍스트: {len(text)}')
# OpenAI 클라이언트 생성 (타임아웃 1800초 = 30분)
llm = OpenAI(
base_url='http://localhost:8000/v1',
api_key='dummy',
timeout=1800
)
# 청크별 프롬프트
chunks = [
{
'name': 'System Tags',
'system': (
'You are a P&ID expert. Extract system tags only.\n'
'Return ONLY a JSON array.\n'
'\n'
'Instrument types to extract: LI, PI, TI, FIQ, FICQ, TICA, PICA, LICA, FIC, TIC, PIC, LIC\n'
'Format: [{"tagNo":"FICQ-10101","confidence":0.95},...]\n'
),
'user': 'Extract ALL tags of LI, PI, TI, FIQ, FICQ, TICA, PICA, LICA, FIC, TIC, PIC, LIC from the text below:\n\n{text}'
}
]
# 결과 저장
all_tags = []
seen_tags = set()
for chunk in chunks:
print(f'\n=== {chunk["name"]} ===')
# 프롬프트 생성
system = chunk['system']
user = chunk['user'].format(text=text[:100000])
# LLM 호출
resp = llm.chat.completions.create(
model='glm-4.7-flash',
messages=[
{'role': 'system', 'content': system},
{'role': 'user', 'content': user},
],
max_tokens=65536,
temperature=0.1,
extra_body={'chat_template_kwargs': {'enable_thinking': False}},
)
raw = (resp.choices[0].message.content or '').strip()
finish = resp.choices[0].finish_reason
print(f'응답 길이: {len(raw)}자, finish_reason: {finish}')
# 잘린 경우 복구
if finish == 'length' and not raw.rstrip().endswith(']'):
last = raw.rfind('}')
if last != -1:
raw = raw[:last+1] + ']'
print('잘린 JSON 복구')
# 배열 추출
depth = 0
start = -1
best = ''
for i, c in enumerate(raw):
if c == '[':
if depth == 0:
start = i
depth += 1
elif c == ']':
depth -= 1
if depth == 0 and start >= 0:
cand = raw[start:i+1]
if len(cand) > len(best):
best = cand
# JSON 파싱 및 결과 저장
try:
data = json.loads(best) if best else []
print(f'추출된 태그 수: {len(data)}')
for item in data:
tag_no = item.get('tagNo')
if tag_no and tag_no not in seen_tags:
seen_tags.add(tag_no)
all_tags.append({
'tagNo': tag_no,
'confidence': item.get('confidence', 0.95)
})
print(f'중복 제거 후 총 태그 수: {len(all_tags)}')
except json.JSONDecodeError as e:
print(f'파싱 실패: {e}')
print(repr(best[-300:] if best else ''))
# 최종 결과
print(f'\n=== 최종 결과 ===')
print(f'총 추출 태그 수: {len(all_tags)}')
print()
for t in all_tags[:30]:
print(f' {t["tagNo"]}')
if len(all_tags) > 30:
print(f' ... (총 {len(all_tags)}개)')
# JSON 파일로 저장
output_path = '/home/windpacer/projects/ExperionCrawler/src/Web/uploads/pid/p-9100_extracted3.json'
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(all_tags, f, indent=2, ensure_ascii=False)
print(f'\n결과 저장: {output_path}')

View File

@@ -0,0 +1,440 @@
# DXF 정보추출용 캐드 작업 지침서
**목적**: DXF 파일만으로 P&ID 도면의 모든 정보를 자동 추출할 수 있도록 AutoCAD 작업 시 준수해야 할 규칙
**대상**: P&ID 도면 작성자, CAD 운영자
**버전**: v1.0
**작성일**: 2026-05-06
---
## 목차
1. [총칙](#1-총칙)
2. [레이어 규칙](#2-레이어-규칙)
3. [블록(Block) 규칙](#3-블록block-규칙)
4. [텍스트 규칙](#4-텍스트-규칙)
5. [설비(Equipment) 규칙](#5-설비equipment-규칙)
6. [배관(Piping) 규칙](#6-배관piping-규칙)
7. [흐름 방향(Flow Direction) 규칙](#7-흐름-방향flow-direction-규칙)
8. [설비 사양 정보 규칙](#8-설비-사양-정보-규칙)
9. [검증 체크리스트](#9-검증-체크리스트)
---
## 1. 총칙
### 1.1 목적
본 지침서는 DXF 파일만으로 P&ID 도면의 다음 정보를 100% 자동 추출할 수 있도록 AutoCAD 작업 시 준수해야 할 규칙을 정의합니다:
1. **태그명**: 장비, 펌프, 계측기기, 밸브의 고유 식별자
2. **설비 인식**: 장비 타입 판별 및 위치 파악
3. **연결관계**: 배관과 장비의 연결, 단일 장비에 연결된 계측기기/펌프/밸브
4. **흐름방향**: 유체의 이동 방향
5. **설비 사양**: SIZE, VOLUME, MATERIAL 등 도면 내 장비 정보
### 1.2 핵심 원칙
| 원칙 | 설명 |
|------|------|
| **블록 우선** | 모든 심볼은 블록(Block/INSERT)으로 작성 |
| **레이어 분리** | 요소별로 레이어를 명확히 분리 |
| **텍스트 표준화** | 태그명은 표준 형식으로 TEXT 엔티티 사용 |
| **속성 활용** | 블록 속성(Attribute)으로 메타데이터 저장 |
| **좌표 정렬** | 연결점은 정확한 좌표로 맞닿게 작성 |
---
## 2. 레이어 규칙
### 2.1 필수 레이어 목록
다음 레이어를 반드시 생성하고 요소별로 분리하여 작성해야 합니다:
| 레이어명 | 용도 | 필수 |
|----------|------|------|
| `EQUIPMENT` | 주요 설비 (탱크, 컬럼, 히터, 반응기) | ✅ |
| `PUMP` | 펌프 | ✅ |
| `VALVE` | 밸브 (모든 타입) | ✅ |
| `INSTRUMENT` | 계측기기 (센서, 트랜스미터, 컨트롤러) | ✅ |
| `PROCESS_LINE` | 공정 배관 | ✅ |
| `STEAM_LINE` | 스팀 배관 | ✅ |
| `WATER_LINE` | 물 배관 | ✅ |
| `ELECTRIC_SIGNAL` | 전기 신호 라인 (제어선) | ✅ |
| `TAG_TEXT` | 태그명 텍스트 | ✅ |
| `PIPELINE_NO` | 파이프 라인 번호 | ✅ |
| `FLOW_ARROW` | 흐름 방향 화살표 | ✅ |
| `SPEC_TEXT` | 설비 사양 텍스트 | ✅ |
### 2.2 레이어 사용 규칙
1. **혼용 금지**: 한 레이어에 서로 다른 타입의 요소를 섞지 마세요
2. **0 레이어 금지**: 0 레이어에 작업 요소를 작성하지 마세요
3. **일관성**: 모든 도면에서 동일한 레이어명 사용
---
## 3. 블록(Block) 규칙
### 3.1 블록 사용 의무
**모든 설비/밸브/계측기기는 반드시 블록(Block Reference/INSERT)으로 작성해야 합니다.**
블록을 사용하면:
- `INSERT.dxf.name`으로 심볼 타입을 직접 식별 가능
- `INSERT.dxf.insert`로 정확한 위치 추출 가능
- 속성(Attribute)으로 태그명/사양 저장 가능
### 3.2 블록 명명 규칙
블록명은 다음 형식을 따릅니다:
```
[타입]_[서브타입]
```
| 블록명 | 설명 | 예시 |
|---------|------|------|
| `EQUIP_TANK` | 탱크 | T-10100 |
| `EQUIP_COLUMN` | 컬럼/분리탑 | C-10111 |
| `EQUIP_HEATER` | 히터/교환기 | E-10119 |
| `EQUIP_REACTOR` | 반응기 | R-10101 |
| `PUMP_CENTRIFUGAL` | 원심펌프 | P-10101 |
| `PUMP_DIAPHRAGM` | 다이어프램 펌프 | DP-10101 |
| `PUMP_VACUUM` | 진공펌프 | VP-10117 |
| `VALVE_GLOBE` | 글로브 밸브 | FCV-10101 |
| `VALVE_BALL` | 볼 밸브 | XV-10111 |
| `VALVE_BUTTERFLY` | 버터플라이 밸브 | BV-10100 |
| `VALVE_CHECK` | 체크 밸브 | |
| `VALVE_PSV` | 안전밸브 | PSV-10101 |
| `INST_TRANSMITTER` | 트랜스미터 | PT-10111 |
| `INST_INDICATOR` | 지시기 | TI-10103 |
| `INST_CONTROLLER` | 컨트롤러 | FICQ-10101 |
| `INST_GAUGE` | 게이지 | PG-10101 |
| `FLOW_ARROW` | 흐름 방향 화살표 | |
### 3.3 블록 속성(Attribute) 규칙
각 블록에는 다음 속성을 반드시 포함합니다:
| 속성 태그 | 설명 | 예시 |
|-----------|------|------|
| `TAG` | 장비 태그명 | `T-10100` |
| `NAME` | 장비 명칭 | `FEED BUFFER TANK` |
| `SIZE` | 크기 | `Ø2,500 x 3,600H` |
| `VOLUME` | 용적 | `20.6M3` |
| `MATERIAL` | 재질 | `STS304` |
| `PRESSURE` | 압력 | `0.25MPa` |
| `TEMPERATURE` | 온도 | `80°C` |
**속성 작성 방법**:
1. AutoCAD에서 `ATTDEF` 명령으로 속성 정의
2. `BLOCK` 명령으로 블록 생성 시 속성 포함
3. `INSERT` 시 속성값 입력
---
## 4. 텍스트 규칙
### 4.1 태그명 텍스트
각 장비/밸브/계측기기 옆에 태그명을 TEXT 엔티티로 반드시 작성합니다:
| 규칙 | 내용 |
|------|------|
| 레이어 | `TAG_TEXT` 레이어 사용 |
| 형식 | `[접두사]-[번호]` (예: `T-10100`, `P-10101`, `FCV-10101`) |
| 위치 | 장비 블록에서 10~20 단위 떨어진 곳에 배치 |
| 폰트 | 표준 폰트 (고딕, Arial) — 특수 폰트 금지 |
| 회전 | 0도 (수평) — 회전된 텍스트 금지 |
### 4.2 파이프 라인 번호
배관 옆에 파이프 라인 번호를 TEXT 엔티티로 작성합니다:
| 규칙 | 내용 |
|------|------|
| 레이어 | `PIPELINE_NO` 레이어 사용 |
| 형식 | `[유체코드]-[라인번호]-[규격]-[등급]-[재질]` |
| 예시 | `P-10101-25A-F1A-n` |
| 위치 | 배관 라인에서 5~10 단위 떨어진 곳에 배치 |
### 4.3 금지 사항
- **MTEXT 금지**: 단순 태그명은 TEXT 사용 (MTEXT는 다중 줄 설명용)
- **특수 문자 금지**: `%%U`, `%%d` 등 AutoCAD 제어 문자 사용 금지
- **한글 태그 금지**: 태그명은 영문/숫자/하이픈만 사용
---
## 5. 설비(Equipment) 규칙
### 5.1 설비 블록 작성 규칙
각 설비는 블록으로 작성하고, 다음 정보를 포함합니다:
**예시: T-10100 FEED BUFFER TANK**
```
블록명: EQUIP_TANK
위치: (X, Y) — 탱크 중심점
속성:
TAG = T-10100
NAME = FEED BUFFER TANK
SIZE = Ø2,500 x 3,600H
VOLUME = 20.6M3
MATERIAL = STS304
PRESSURE = ATM
TEMPERATURE = 80°C
```
### 5.2 설비-배관 연결점 규칙
설비 블록과 배관이 연결되는 지점은 **정확한 좌표**로 맞닿게 작성합니다:
```
탱크 출구: (1672.2, 1755.3)
배관 시작: (1672.2, 1755.3) ← 동일한 좌표
```
**규칙**:
1. 연결점은 LINE 엔티티의 시작/끝 좌표가 정확히 일치해야 함
2. 연결점 간격 0.5 단위 이내로 허용
3. 연결점이 맞지 않으면 자동 추출 실패
### 5.3 설비-계측기 연결 규칙
설비에 연결된 계측기기는 `ELECTRIC_SIGNAL` 레이어의 선으로 연결합니다:
```
탱크(T-10100) → LT-10100 (수위 트랜스미터)
연결선: ELECTRIC_SIGNAL 레이어
시작: 탱크 블록 좌표
종료: 계측기 블록 좌표
```
**추출 로직**:
1. 설비 블록 위치 확인
2. 동일 위치에서 시작하는 ELECTRIC_SIGNAL 라인 추적
3. 라인 끝점에 있는 계측기 블록 식별
4. 연결 관계 기록: `T-10100 → LT-10100`
---
## 6. 배관(Piping) 규칙
### 6.1 배관 라인 작성 규칙
모든 배관은 LINE 또는 LWPOLYLINE 엔티티로 작성하며, 다음 규칙을 따릅니다:
| 규칙 | 내용 |
|------|------|
| 레이어 | 배관 타입별 레이어 사용 (`PROCESS_LINE`, `STEAM_LINE`, `WATER_LINE`) |
| 연결점 | LINE의 시작/끝 좌표가 설비 블록 연결점과 정확히 일치 |
| 연속성 | 배관 경로가 끊기지 않도록 연속적인 LINE 작성 |
| 교차 | 배관 교차점은 좌표가 정확히 일치 (0.5 단위 이내) |
### 6.2 배관-설비 연결 추출 로직
```
1. 설비 블록의 연결점 좌표 확인
2. 동일 좌표에서 시작하는 PROCESS_LINE 레이어의 LINE 찾기
3. LINE을 따라 다음 설비 블록까지 추적
4. 연결 관계 기록: [설비A] → [배관] → [설비B]
```
**예시**:
```
T-10100 출구: (1672.2, 1755.3)
→ PROCESS_LINE: (1672.2, 1755.3) → (1700.0, 1755.3)
→ P-10101 입구: (1700.0, 1755.3)
결과: T-10100 → P-10101 (배관: P-10101-25A-F1A-n)
```
### 6.3 밸브-배관 연결 규칙
밸브는 배관 라인 위에 배치하며, 연결점이 정확히 맞닿게 작성합니다:
```
배관: (1700, 1755) → (1720, 1755)
밸브 입구: (1720, 1755)
밸브 출구: (1730, 1755)
배관: (1730, 1755) → (1750, 1755)
```
**추출 로직**:
1. 배관 LINE의 끝점 확인
2. 동일 좌표에 밸브 블록이 있는지 확인
3. 밸브 출구에서 계속되는 배관 LINE 추적
4. 연결 관계: `배관 → 밸브 → 배관`
---
## 7. 흐름 방향(Flow Direction) 규칙
### 7.1 화살표 블록 사용 의무
**모든 배관 라인의 흐름 방향은 화살표 블록으로 명시해야 합니다.**
| 규칙 | 내용 |
|------|------|
| 블록명 | `FLOW_ARROW` |
| 레이어 | `FLOW_ARROW` |
| 위치 | 배관 라인 중간 또는 끝에 배치 |
| 회전 | 흐름 방향에 맞게 회전 (0°=우측, 90°=하측) |
### 7.2 화살표 블록 속성
```
블록명: FLOW_ARROW
속성:
DIRECTION = RIGHT / LEFT / UP / DOWN
PIPELINE = P-10101-25A-F1A-n (연결된 파이프 라인 번호)
```
### 7.3 흐름 방향 추출 로직
```
1. FLOW_ARROW 블록 찾기
2. INSERT.dxf.rotation으로 방향 확인
- 0° = 우측 흐름 (→)
- 180° = 좌측 흐름 (←)
- 90° = 상측 흐름 (↑)
- 270° = 하측 흐름 (↓)
3. 화살표 위치에서 연결된 배관 라인 찾기
4. 방향 정보 기록: [배관] → [방향]
```
### 7.4 탱크/컬럼 화살표 심볼
탱크와 컬럼은 기존처럼 **화살표 모양 경계선**을 유지할 수 있으나,
추가로 `FLOW_ARROW` 블록을 배관 라인에 배치해야 합니다:
```
T-10100 (탱크)
└→ FLOW_ARROW (rotation=0°, RIGHT)
└→ 배관: P-10101-25A-F1A-n
└→ P-10101 (펌프)
```
### 7.5 필수 배치 위치
화살표는 다음 위치에 반드시 배치합니다:
1. **설비 출구**: 탱크/컬럼/히터에서 나가는 배관
2. **분기점**: 배관이 분기되는 지점
3. **합류점**: 배관이 합류하는 지점
4. **도면 경계**: 도면 밖으로 나가는 배관
---
## 8. 설비 사양 정보 규칙
### 8.1 사양 정보 저장 방법
설비 사양은 **블록 속성(Attribute)**으로 저장합니다. PDF에 표시되는 텍스트는 시각용이며, DXF 추출의 기준은 블록 속성입니다.
### 8.2 필수 속성 목록
각 설비 블록에 다음 속성을 반드시 포함합니다:
| 속성 태그 | 설명 | 예시 | 필수 |
|-----------|------|------|------|
| `TAG` | 장비 태그명 | `T-10100` | ✅ |
| `NAME` | 장비 명칭 | `FEED BUFFER TANK` | ✅ |
| `SIZE` | 크기 | `Ø2,500 x 3,600H` | ✅ |
| `VOLUME` | 용적 | `20.6M3` | ✅ |
| `MATERIAL` | 재질 | `STS304` | ✅ |
| `DESIGN_PRESSURE` | 설계 압력 | `0.25MPa` | ✅ |
| `OPERATING_PRESSURE` | 운전 압력 | `ATM` | ✅ |
| `DESIGN_TEMP` | 설계 온도 | `80°C` | ✅ |
| `OPERATING_TEMP` | 운전 온도 | `AMB` | ✅ |
| `INSULATION` | 단열 | `H50` | ❌ |
### 8.3 펌프 전용 속성
| 속성 태그 | 설명 | 예시 |
|-----------|------|------|
| `CAPACITY` | 용량 | `60L/min` |
| `RPM` | 회전수 | `3,520` |
| `TYPE` | 펌프 타입 | `CENTRIFUGAL` |
### 8.4 예시: T-10100 FEED BUFFER TANK
```
블록명: EQUIP_TANK
레이어: EQUIPMENT
위치: (1662.6, 1754.5)
속성:
TAG = T-10100
NAME = FEED BUFFER TANK
SIZE = Ø2,500 x 3,600H
VOLUME = 20.6M3
MATERIAL = STS304
DESIGN_PRESSURE = ATM
OPERATING_PRESSURE = ATM
DESIGN_TEMP = 80°C
OPERATING_TEMP = AMB
```
### 8.5 예시: P-10101 FEED PUMP
```
블록명: PUMP_CENTRIFUGAL
레이어: PUMP
위치: (2055.3, 1738.6)
속성:
TAG = P-10101
NAME = FEED PUMP
SIZE = 25A/20A
MATERIAL = STS316
CAPACITY = 60L/min
RPM = 3,520
DESIGN_PRESSURE = 0.25MPa
TYPE = CENTRIFUGAL
```
---
## 9. 검증 체크리스트
도면 작성 완료 후 다음 항목을 반드시 확인하세요:
### 9.1 블록 검증
- [ ] 모든 설비가 블록(Block/INSERT)으로 작성되었는가?
- [ ] 블록명이 표준 형식을 따르는가? (`EQUIP_TANK`, `PUMP_CENTRIFUGAL` 등)
- [ ] 모든 블록에 필수 속성(TAG, NAME, SIZE, MATERIAL)이 포함되었는가?
- [ ] 속성값이 정확한가?
### 9.2 레이어 검증
- [ ] 모든 요소가 올바른 레이어에 배치되었는가?
- [ ] 0 레이어에 작업 요소가 없는가?
- [ ] 레이어명이 표준 목록과 일치하는가?
### 9.3 연결점 검증
- [ ] 설비-배관 연결점 좌표가 정확히 일치하는가? (0.5 단위 이내)
- [ ] 배관 라인이 끊기지 않고 연속적인가?
- [ ] 밸브가 배관 라인 위에 정확히 배치되었는가?
### 9.4 흐름 방향 검증
- [ ] 모든 배관 라인에 FLOW_ARROW 블록이 배치되었는가?
- [ ] 화살표 회전 각도가 흐름 방향과 일치하는가?
- [ ] 분기점/합류점에 화살표가 배치되었는가?
### 9.5 텍스트 검증
- [ ] 모든 태그명이 TAG_TEXT 레이어에 TEXT 엔티티로 작성되었는가?
- [ ] 태그명 형식이 표준을 따르는가? (`T-10100`, `P-10101`)
- [ ] 파이프 라인 번호가 PIPELINE_NO 레이어에 작성되었는가?
- [ ] 특수 문자(%%U, %%d)가 없는가?
### 9.6 DXF 내보내기 검증
- [ ] DXF 버전이 R2018 이상인가?
- [ ] 블록 정의가 정상적으로 내보내졌는가?
- [ ] 블록 속성(Attribute)이 포함되었는가?
- [ ] 레이어 정보가 유지되었는가?

View File

@@ -0,0 +1,469 @@
# DXF 정보추출용 캐드 작업 지침서
**목적**: DXF 파일만으로 P&ID 도면의 모든 정보를 자동 추출할 수 있도록 AutoCAD 작업 시 준수해야 할 규칙
**대상**: P&ID 도면 작성자, CAD 운영자
**버전**: v1.0
**작성일**: 2026-05-06
---
## 목차
1. [총칙](#1-총칙)
2. [레이어 규칙](#2-레이어-규칙)
3. [블록(Block) 규칙](#3-블록block-규칙)
4. [텍스트 규칙](#4-텍스트-규칙)
5. [설비(Equipment) 규칙](#5-설비equipment-규칙)
6. [배관(Piping) 규칙](#6-배관piping-규칙)
7. [흐름 방향(Flow Direction) 규칙](#7-흐름-방향flow-direction-규칙)
8. [설비 사양 정보 규칙](#8-설비-사양-정보-규칙)
9. [검증 체크리스트](#9-검증-체크리스트)
---
## 1. 총칙
### 1.1 목적
본 지침서는 DXF 파일만으로 P&ID 도면의 다음 정보를 100% 자동 추출할 수 있도록 AutoCAD 작업 시 준수해야 할 규칙을 정의합니다:
1. **태그명**: 장비, 펌프, 계측기기, 밸브의 고유 식별자
2. **설비 인식**: 장비 타입 판별 및 위치 파악
3. **연결관계**: 배관과 장비의 연결, 단일 장비에 연결된 계측기기/펌프/밸브
4. **흐름방향**: 유체의 이동 방향
5. **설비 사양**: SIZE, VOLUME, MATERIAL 등 도면 내 장비 정보
### 1.2 핵심 원칙
| 원칙 | 설명 |
|------|------|
| **블록 우선** | 모든 심볼은 블록(Block/INSERT)으로 작성 |
| **레이어 분리** | 요소별로 레이어를 명확히 분리 |
| **텍스트 표준화** | 태그명은 표준 형식으로 TEXT 엔티티 사용 |
| **속성 활용** | 블록 속성(Attribute)으로 메타데이터 저장 |
| **좌표 정렬** | 연결점은 정확한 좌표로 맞닿게 작성 |
---
## 2. 레이어 규칙
### 2.1 필수 레이어 목록
다음 레이어를 반드시 생성하고 요소별로 분리하여 작성해야 합니다:
| 레이어명 | 용도 | 필수 |
|----------|------|------|
| `EQUIPMENT` | 주요 설비 (탱크, 컬럼, 히터, 반응기) | ✅ |
| `PUMP` | 펌프 | ✅ |
| `VALVE` | 밸브 (모든 타입) | ✅ |
| `INSTRUMENT` | 계측기기 (센서, 트랜스미터, 컨트롤러) | ✅ |
| `PROCESS_LINE` | 공정 배관 (+ 흐름 화살표) | ✅ |
| `STEAM_LINE` | 스팀 배관 (+ 흐름 화살표) | ✅ |
| `WATER_LINE` | 물 배관 (+ 흐름 화살표) | ✅ |
| `AIR_LINE` | 공압 배관 (+ 흐름 화살표) | ✅ |
| `ELECTRIC_SIGNAL` | 전기 신호 라인 (제어선) | ✅ |
| `TAG_TEXT` | 태그명 텍스트 | ✅ |
| `PIPELINE_NO` | 파이프 라인 번호 | ✅ |
| `SPEC_TEXT` | 설비 사양 텍스트 | ✅ |
### 2.2 레이어 사용 규칙
1. **혼용 금지**: 한 레이어에 서로 다른 타입의 요소를 섞지 마세요
2. **0 레이어 금지**: 0 레이어에 작업 요소를 작성하지 마세요
3. **일관성**: 모든 도면에서 동일한 레이어명 사용
---
## 3. 블록(Block) 규칙
### 3.1 블록 사용 의무
**모든 설비/밸브/계측기기는 반드시 블록(Block Reference/INSERT)으로 작성해야 합니다.**
블록을 사용하면:
- `INSERT.dxf.name`으로 심볼 타입을 직접 식별 가능
- `INSERT.dxf.insert`로 정확한 위치 추출 가능
- 속성(Attribute)으로 태그명/사양 저장 가능
### 3.2 블록 명명 규칙
블록명은 다음 형식을 따릅니다:
```
[타입]_[서브타입]
```
| 블록명 | 설명 | 예시 |
|---------|------|------|
| `EQUIP_TANK` | 탱크 | T-10100 |
| `EQUIP_COLUMN` | 컬럼/분리탑 | C-10111 |
| `EQUIP_HEATER` | 히터/교환기 | E-10119 |
| `EQUIP_REACTOR` | 반응기 | R-10101 |
| `PUMP_CENTRIFUGAL` | 원심펌프 | P-10101 |
| `PUMP_DIAPHRAGM` | 다이어프램 펌프 | DP-10101 |
| `PUMP_VACUUM` | 진공펌프 | VP-10117 |
| `VALVE_GLOBE` | 글로브 밸브 | FCV-10101 |
| `VALVE_BALL` | 볼 밸브 | XV-10111 |
| `VALVE_BUTTERFLY` | 버터플라이 밸브 | BV-10100 |
| `VALVE_CHECK` | 체크 밸브 | |
| `VALVE_PSV` | 안전밸브 | PSV-10101 |
| `INST_TRANSMITTER` | 트랜스미터, 센서 | PT-10111, TE-10111A |
| `INST_CONTROLLER` | 컨트롤러 | FICQ-10101 |
| `INST_GAUGE` | 게이지 | PG-10101 |
### 3.3 블록 속성(Attribute) 규칙
각 블록에는 다음 속성을 반드시 포함합니다:
| 속성 태그 | 설명 | 예시 |
|-----------|------|------|
| `TAG` | 장비 태그명 | `T-10100` |
| `NAME` | 장비 명칭 | `FEED BUFFER TANK` |
| `SIZE` | 크기 | `Ø2,500 x 3,600H` |
| `VOLUME` | 용적 | `20.6M3` |
| `MATERIAL` | 재질 | `STS304` |
| `PRESSURE` | 압력 | `0.25MPa` |
| `TEMPERATURE` | 온도 | `80°C` |
**속성 작성 방법**:
1. AutoCAD에서 `ATTDEF` 명령으로 속성 정의
2. `BLOCK` 명령으로 블록 생성 시 속성 포함
3. `INSERT` 시 속성값 입력
---
## 4. 텍스트 규칙
### 4.1 태그명 텍스트
각 장비/밸브/계측기기 옆에 태그명을 TEXT 엔티티로 반드시 작성합니다:
| 규칙 | 내용 |
|------|------|
| 레이어 | `TAG_TEXT` 레이어 사용 |
| 형식 | `[접두사]-[번호]` (예: `T-10100`, `P-10101`, `FCV-10101`) |
| 위치 | 장비 블록에서 10~20 단위 떨어진 곳에 배치 |
| 폰트 | 표준 폰트 (고딕, Arial) — 특수 폰트 금지, 캐드용 SHA 금지 |
| 회전 | 0도 (수평) — 회전된 텍스트 금지 |
### 4.2 파이프 라인 번호
배관 옆에 파이프 라인 번호를 TEXT 엔티티로 작성합니다:
| 규칙 | 내용 |
|------|------|
| 레이어 | `PIPELINE_NO` 레이어 사용 |
| 형식 | `[유체코드]-[라인번호]-[규격]-[등급]-[재질]` |
| 예시 | `P-10101-25A-F1A-n` |
| 위치 | 배관 라인에서 5~10 단위 떨어진 곳에 배치 |
### 4.3 금지 사항
- **MTEXT 금지**: 단순 태그명은 TEXT 사용 (MTEXT는 다중 줄 설명용)
- **특수 문자 금지**: `%%U`, `%%d` 등 AutoCAD 제어 문자 사용 금지
- **한글 태그 금지**: 태그명은 영문/숫자/하이픈만 사용
---
## 5. 설비(Equipment) 규칙
### 5.1 설비 블록 작성 규칙
각 설비는 블록으로 작성하고, 다음 정보를 포함합니다:
**예시: T-10100 FEED BUFFER TANK**
```
블록명: EQUIP_TANK
위치: (X, Y) — 탱크 중심점
속성:
TAG = T-10100
NAME = FEED BUFFER TANK
SIZE = Ø2,500 x 3,600H
VOLUME = 20.6M3
MATERIAL = STS304
PRESSURE = ATM
TEMPERATURE = 80°C
```
### 5.2 설비-배관 연결점 규칙
설비 블록과 배관이 연결되는 지점은 **정확한 좌표**로 맞닿게 작성합니다:
```
탱크 출구: (1672.2, 1755.3)
배관 시작: (1672.2, 1755.3) ← 동일한 좌표
```
**규칙**:
1. 연결점은 LINE 엔티티의 시작/끝 좌표가 정확히 일치해야 함
2. 연결점 간격 0.5 단위 이내로 허용
3. 연결점이 맞지 않으면 자동 추출 실패
### 5.3 설비-계측기 연결 규칙
설비에 연결된 계측기기는 `ELECTRIC_SIGNAL` 레이어의 선으로 연결합니다:
```
탱크(T-10100) → LT-10100 (레벨 트랜스미터)
연결선: ELECTRIC_SIGNAL 레이어
시작: 탱크 블록 좌표
종료: 계측기 블록 좌표
```
**추출 로직**:
1. 설비 블록 위치 확인
2. 동일 위치에서 시작하는 ELECTRIC_SIGNAL 라인 추적
3. 라인 끝점에 있는 계측기 블록 식별
4. 연결 관계 기록: `T-10100 → LT-10100`
---
## 6. 배관(Piping) 규칙
### 6.1 배관 라인 작성 규칙
모든 배관은 **LWPOLYLINE** 엔티티로 작성하며, 다음 규칙을 따릅니다:
| 규칙 | 내용 |
|------|------|
| 엔티티 | LWPOLYLINE 사용 (LINE 대신) — 흐름 화살표를 버텍스 너비로 표현 |
| 레이어 | 배관 타입별 레이어 사용 (`PROCESS_LINE`, `STEAM_LINE`, `WATER_LINE`, `AIR_LINE`) |
| 연결점 | LWPOLYLINE의 시작/끝 좌표가 설비 블록 연결점과 정확히 일치 |
| 연속성 | 배관 경로가 끊기지 않도록 연속적인 LWPOLYLINE 작성 |
| 교차 | 배관 교차점은 좌표가 정확히 일치 (0.5 단위 이내) |
| 화살표 | LWPOLYLINE의 끝점 버텍스 너비(`end_width`)로 흐름 방향 화살표 표현 |
### 6.2 배관-설비 연결 추출 로직
```
1. 설비 블록의 연결점 좌표 확인
2. 동일 좌표에서 시작하는 PROCESS_LINE 레이어의 LWPOLYLINE 찾기
3. LWPOLYLINE을 따라 다음 설비 블록까지 추적
4. 연결 관계 기록: [설비A] → [배관] → [설비B]
```
**예시**:
```
T-10100 출구: (1672.2, 1755.3)
→ PROCESS_LINE: (1672.2, 1755.3) → (1700.0, 1755.3)
→ P-10101 입구: (1700.0, 1755.3)
결과: T-10100 → P-10101 (배관: P-10101-25A-F1A-n)
```
### 6.3 밸브-배관 연결 규칙
밸브는 배관 라인 위에 배치하며, 연결점이 정확히 맞닿게 작성합니다:
```
LWPOLYLINE: (1700, 1755) → (1720, 1755)
밸브 입구: (1720, 1755)
밸브 출구: (1730, 1755)
LWPOLYLINE: (1730, 1755) → (1750, 1755)
```
**추출 로직**:
1. 배관 LWPOLYLINE의 끝점 확인
2. 동일 좌표에 밸브 블록이 있는지 확인
3. 밸브 출구에서 계속되는 배관 LWPOLYLINE 추적
4. 연결 관계: `배관 → 밸브 → 배관`
---
## 7. 흐름 방향(Flow Direction) 규칙
### 7.1 LWPOLYLINE 버텍스 너비 기반 화살표
**모든 배관 라인의 흐름 방향은 LWPOLYLINE 엔티티의 버텍스 너비로 명시해야 합니다.**
AutoCAD에서 `PEDIT` 명령으로 LWPOLYLINE의 끝점 너비를 넓히면 화살표 모양이 됩니다. 이 방식은 배관 라인과 화살표를 **단일 엔티티**로 표현하여 작업 효율과 추출 정확도를 동시에 높입니다.
| 규칙 | 내용 |
|------|------|
| 엔티티 | LWPOLYLINE (LINE이 아님) |
| 화살표 표현 | 끝점 버텍스의 `end_width` 속성으로 화살표 크기 지정 |
| 화살표 크기 | `end_width = 3~5` 단위 (배관 너비 `start_width = 0`과 명확히 구분) |
| 방향 판별 | `end_width > start_width`인 방향이 흐름 방향 |
| 레이어 | 배관과 동일 (`PROCESS_LINE`, `STEAM_LINE`, `WATER_LINE`) |
**작성 방법 (AutoCAD)**:
1. `PLINE` 명령으로 배관 경로 작성
2. `PEDIT` 명령 선택 → '너비(W)' 옵션
3. 시작 너비: `0`, 끝 너비: `5` 입력
4. 결과: 끝점에 화살표 모양의 넓은 버텍스 생성
**DXF 저장 구조**:
```
LWPOLYLINE (PROCESS_LINE 레이어)
Vertex 0: (100, 100) start_width=0, end_width=0
Vertex 1: (200, 100) start_width=0, end_width=5 ← 화살표 끝 (우측 흐름)
```
### 7.2 흐름 방향 추출 로직
```
1. LWPOLYLINE 엔티티 찾기 (PROCESS_LINE/STEAM_LINE/WATER_LINE 레이어)
2. 버텍스 목록 획득: vertices = entity.get_vertices()
3. 각 버텍스의 start_width / end_width 확인
4. end_width > 0인 버텍스가 있는 방향 = 흐름 방향
- 마지막 버텍스 end_width > 0 → 시작→끝 방향 (forward)
- 첫 번째 버텍스 start_width > 0 → 끝→시작 방향 (backward)
5. 방향 정보 기록: [배관] → [방향]
```
**추출 코드 예시**:
```python
vertices = list(entity.get_vertices())
if len(vertices) >= 2:
last = vertices[-1]
if hasattr(last, 'end_width') and last.end_width > 0:
flow_direction = "forward" # 시작→끝 방향
first = vertices[0]
if hasattr(first, 'start_width') and first.start_width > 0:
flow_direction = "backward" # 끝→시작 방향
```
### 7.3 양방향 화살표
양방향 흐름이 필요한 경우, 양쪽 끝 버텍스에 너비를 설정합니다:
```
LWPOLYLINE (PROCESS_LINE 레이어)
Vertex 0: (100, 100) start_width=5, end_width=0 ← 좌측 화살표
Vertex 1: (200, 100) start_width=0, end_width=5 ← 우측 화살표
```
### 7.4 탱크/컬럼 화살표 심볼
탱크와 컬럼은 기존처럼 **화살표 모양 경계선**을 유지할 수 있으나,
배관 라인은 LWPOLYLINE 버텍스 너비로 화살표를 표현해야 합니다:
```
T-10100 (탱크)
└→ LWPOLYLINE (end_width=5, forward)
└→ 배관: P-10101-25A-F1A-n
└→ P-10101 (펌프)
```
### 7.5 필수 설정 위치
화살표(버텍스 너비)는 다음 위치에 반드시 설정합니다:
1. **설비 출구**: 탱크/컬럼/히터에서 나가는 배관
2. **분기점**: 배관이 분기되는 지점
3. **합류점**: 배관이 합류하는 지점
4. **도면 경계**: 도면 밖으로 나가는 배관
---
## 8. 설비 사양 정보 규칙
### 8.1 사양 정보 저장 방법
설비 사양은 **블록 속성(Attribute)**으로 저장합니다. PDF에 표시되는 텍스트는 시각용이며, DXF 추출의 기준은 블록 속성입니다.
### 8.2 필수 속성 목록
각 설비 블록에 다음 속성을 반드시 포함합니다:
| 속성 태그 | 설명 | 예시 | 필수 |
|-----------|------|------|------|
| `TAG` | 장비 태그명 | `T-10100` | ✅ |
| `NAME` | 장비 명칭 | `FEED BUFFER TANK` | ✅ |
| `SIZE` | 크기 | `Ø2,500 x 3,600H` | ✅ |
| `VOLUME` | 용적 | `20.6M3` | ✅ |
| `MATERIAL` | 재질 | `STS304` | ✅ |
| `DESIGN_PRESSURE` | 설계 압력 | `0.25MPa` | ✅ |
| `OPERATING_PRESSURE` | 운전 압력 | `ATM` | ✅ |
| `DESIGN_TEMP` | 설계 온도 | `80°C` | ✅ |
| `OPERATING_TEMP` | 운전 온도 | `AMB` | ✅ |
| `INSULATION` | 단열 | `H50` | ❌ |
### 8.3 펌프 전용 속성
| 속성 태그 | 설명 | 예시 |
|-----------|------|------|
| `CAPACITY` | 용량 | `60L/min` |
| `RPM` | 회전수 | `3,520` |
| `TYPE` | 펌프 타입 | `CENTRIFUGAL` |
### 8.4 예시: T-10100 FEED BUFFER TANK
```
블록명: EQUIP_TANK
레이어: EQUIPMENT
위치: (1662.6, 1754.5)
속성:
TAG = T-10100
NAME = FEED BUFFER TANK
SIZE = Ø2,500 x 3,600H
VOLUME = 20.6M3
MATERIAL = STS304
DESIGN_PRESSURE = ATM
OPERATING_PRESSURE = ATM
DESIGN_TEMP = 80°C
OPERATING_TEMP = AMB
```
### 8.5 예시: P-10101 FEED PUMP
```
블록명: PUMP_CENTRIFUGAL
레이어: PUMP
위치: (2055.3, 1738.6)
속성:
TAG = P-10101
NAME = FEED PUMP
SIZE = 25A/20A
MATERIAL = STS316
CAPACITY = 60L/min
RPM = 3,520
DESIGN_PRESSURE = 0.25MPa
TYPE = CENTRIFUGAL
```
---
## 9. 검증 체크리스트
도면 작성 완료 후 다음 항목을 반드시 확인하세요:
### 9.1 블록 검증
- [ ] 모든 설비가 블록(Block/INSERT)으로 작성되었는가?
- [ ] 블록명이 표준 형식을 따르는가? (`EQUIP_TANK`, `PUMP_CENTRIFUGAL` 등)
- [ ] 모든 블록에 필수 속성(TAG, NAME, SIZE, MATERIAL)이 포함되었는가?
- [ ] 속성값이 정확한가?
### 9.2 레이어 검증
- [ ] 모든 요소가 올바른 레이어에 배치되었는가?
- [ ] 0 레이어에 작업 요소가 없는가?
- [ ] 레이어명이 표준 목록과 일치하는가?
### 9.3 연결점 검증
- [ ] 설비-배관 연결점 좌표가 정확히 일치하는가? (0.5 단위 이내)
- [ ] 배관 라인이 끊기지 않고 연속적인가?
- [ ] 밸브가 배관 라인 위에 정확히 배치되었는가?
### 9.4 흐름 방향 검증
- [ ] 모든 배관이 LWPOLYLINE 엔티티로 작성되었는가? (LINE이 아닌가?)
- [ ] 배관 끝점 버텍스의 end_width가 3 이상으로 설정되었는가?
- [ ] 버텍스 너비 방향이 실제 흐름 방향과 일치하는가?
- [ ] 분기점/합류점 배관에 버텍스 너비가 설정되었는가?
### 9.5 텍스트 검증
- [ ] 모든 태그명이 TAG_TEXT 레이어에 TEXT 엔티티로 작성되었는가?
- [ ] 태그명 형식이 표준을 따르는가? (`T-10100`, `P-10101`)
- [ ] 파이프 라인 번호가 PIPELINE_NO 레이어에 작성되었는가?
- [ ] 특수 문자(%%U, %%d)가 없는가?
### 9.6 DXF 내보내기 검증
- [ ] DXF 버전이 R2018 이상인가?
- [ ] 블록 정의가 정상적으로 내보내졌는가?
- [ ] 블록 속성(Attribute)이 포함되었는가?
- [ ] 레이어 정보가 유지되었는가?

View File

@@ -0,0 +1,477 @@
# DXF 정보추출용 캐드 작업 지침서
**목적**: DXF 파일만으로 P&ID 도면의 모든 정보를 자동 추출할 수 있도록 AutoCAD 작업 시 준수해야 할 규칙
**대상**: P&ID 도면 작성자, CAD 운영자
**버전**: v1.0
**작성일**: 2026-05-06
---
## 목차
1. [총칙](#1-총칙)
2. [레이어 규칙](#2-레이어-규칙)
3. [블록(Block) 규칙](#3-블록block-규칙)
4. [텍스트 규칙](#4-텍스트-규칙)
5. [설비(Equipment) 규칙](#5-설비equipment-규칙)
6. [배관(Piping) 규칙](#6-배관piping-규칙)
7. [흐름 방향(Flow Direction) 규칙](#7-흐름-방향flow-direction-규칙)
8. [설비 사양 정보 규칙](#8-설비-사양-정보-규칙)
9. [검증 체크리스트](#9-검증-체크리스트)
---
## 1. 총칙
### 1.1 목적
본 지침서는 DXF 파일만으로 P&ID 도면의 다음 정보를 100% 자동 추출할 수 있도록 AutoCAD 작업 시 준수해야 할 규칙을 정의합니다:
1. **태그명**: 장비, 펌프, 계측기기, 밸브의 고유 식별자
2. **설비 인식**: 장비 타입 판별 및 위치 파악
3. **연결관계**: 배관과 장비의 연결, 단일 장비에 연결된 계측기기/펌프/밸브
4. **흐름방향**: 유체의 이동 방향
5. **설비 사양**: SIZE, VOLUME, MATERIAL 등 도면 내 장비 정보
### 1.2 핵심 원칙
| 원칙 | 설명 |
|------|------|
| **블록 우선** | 모든 심볼은 블록(Block/INSERT)으로 작성 |
| **속성 우선** | 태그명·사양은 블록 속성(Attribute)으로 저장 — 별도 TEXT 금지 |
| **레이어 분리** | 요소별로 레이어를 명확히 분리 |
| **엔티티 최소화** | 같은 정보를 여러 엔티티로 중복 저장하지 않음 |
| **좌표 정렬** | 연결점은 정확한 좌표로 맞닿게 작성 |
---
## 2. 레이어 규칙
### 2.1 필수 레이어 목록
다음 레이어를 반드시 생성하고 요소별로 분리하여 작성해야 합니다:
| 레이어명 | 용도 | 필수 |
|----------|------|------|
| `EQUIPMENT` | 주요 설비 (탱크, 컬럼, 히터, 반응기) | ✅ |
| `PUMP` | 펌프 | ✅ |
| `VALVE` | 밸브 (모든 타입) | ✅ |
| `INSTRUMENT` | 계측기기 (센서, 트랜스미터, 컨트롤러) | ✅ |
| `PROCESS_LINE` | 공정 배관 (+ 흐름 화살표) | ✅ |
| `STEAM_LINE` | 스팀 배관 (+ 흐름 화살표) | ✅ |
| `WATER_LINE` | 물 배관 (+ 흐름 화살표) | ✅ |
| `AIR_LINE` | 공압 배관 (+ 흐름 화살표) | ✅ |
| `ELECTRIC_SIGNAL` | 전기 신호 라인 (제어선) | ✅ |
| `PIPELINE_NO` | 파이프 라인 번호 (배관에만 별도 TEXT 필요) | ✅ |
### 2.2 레이어 사용 규칙
1. **혼용 금지**: 한 레이어에 서로 다른 타입의 요소를 섞지 마세요
2. **0 레이어 금지**: 0 레이어에 작업 요소를 작성하지 마세요
3. **일관성**: 모든 도면에서 동일한 레이어명 사용
---
## 3. 블록(Block) 규칙
### 3.1 블록 사용 의무
**모든 설비/밸브/계측기기는 반드시 블록(Block Reference/INSERT)으로 작성해야 합니다.**
블록을 사용하면:
- `INSERT.dxf.name`으로 심볼 타입을 직접 식별 가능
- `INSERT.dxf.insert`로 정확한 위치 추출 가능
- 속성(Attribute)으로 태그명/사양 저장 가능
### 3.2 블록 명명 규칙
블록명은 다음 형식을 따릅니다:
```
[타입]_[서브타입]
```
| 블록명 | 설명 | 예시 |
|---------|------|------|
| `EQUIP_TANK` | 탱크 | T-10100 |
| `EQUIP_COLUMN` | 컬럼/분리탑 | C-10111 |
| `EQUIP_HEATER` | 히터/교환기 | E-10119 |
| `EQUIP_REACTOR` | 반응기 | R-10101 |
| `PUMP_CENTRIFUGAL` | 원심펌프 | P-10101 |
| `PUMP_DIAPHRAGM` | 다이어프램 펌프 | DP-10101 |
| `PUMP_VACUUM` | 진공펌프 | VP-10117 |
| `VALVE_GLOBE` | 글로브 밸브 | FCV-10101 |
| `VALVE_BALL` | 볼 밸브 | XV-10111 |
| `VALVE_BUTTERFLY` | 버터플라이 밸브 | BV-10100 |
| `VALVE_CHECK` | 체크 밸브 | |
| `VALVE_PSV` | 안전밸브 | PSV-10101 |
| `INST_TRANSMITTER` | 트랜스미터, 센서 | PT-10111, TE-10111A |
| `INST_CONTROLLER` | 컨트롤러 | FICQ-10101 |
| `INST_GAUGE` | 게이지 | PG-10101 |
### 3.3 블록 속성(Attribute) 규칙
각 블록에는 다음 속성을 반드시 포함합니다:
| 속성 태그 | 설명 | 예시 |
|-----------|------|------|
| `TAG` | 장비 태그명 | `T-10100` |
| `NAME` | 장비 명칭 | `FEED BUFFER TANK` |
| `SIZE` | 크기 | `Ø2,500 x 3,600H` |
| `VOLUME` | 용적 | `20.6M3` |
| `MATERIAL` | 재질 | `STS304` |
| `PRESSURE` | 압력 | `0.25MPa` |
| `TEMPERATURE` | 온도 | `80°C` |
**속성 작성 방법**:
1. AutoCAD에서 `ATTDEF` 명령으로 속성 정의
2. `BLOCK` 명령으로 블록 생성 시 속성 포함
3. `INSERT` 시 속성값 입력
---
## 4. 텍스트 규칙
### 4.1 태그명 — 블록 속성만 사용 (별도 TEXT 금지)
**태그명은 블록 속성(Attribute)의 `TAG` 필드에 저장합니다. 별도의 TEXT 엔티티로 태그명을 배치하지 않습니다.**
이유:
- 블록 속성에 이미 TAG가 있으므로 별도 TEXT는 정보 중복
- ENTITY 수 감소 → DXF 파일 크기 절감, 추출 속도 향상
- CAD 작성자가 블록 삽입만 하면 됨 (텍스트 배치 불필요)
**태그명 형식**: `[접두사]-[번호]` (예: `T-10100`, `P-10101`, `FCV-10101`)
### 4.2 파이프 라인 번호
배관 옆에 파이프 라인 번호를 TEXT 엔티티로 작성합니다:
| 규칙 | 내용 |
|------|------|
| 레이어 | `PIPELINE_NO` 레이어 사용 |
| 형식 | `[유체코드]-[라인번호]-[규격]-[등급]-[재질]` |
| 예시 | `P-10101-25A-F1A-n` |
| 위치 | 배관 라인에서 5~10 단위 떨어진 곳에 배치 |
| 엔티티 | TEXT 사용 (MTEXT 금지) |
### 4.3 사양 정보 — 블록 속성만 사용 (별도 TEXT 금지)
**설비 사양(SIZE, VOLUME, MATERIAL 등)은 블록 속성(Attribute)에 저장합니다. 별도의 SPEC_TEXT 레이어로 텍스트를 배치하지 않습니다.**
이유:
- 블록 속성에 이미 모든 사양이 있으므로 별도 TEXT는 정보 중복
- ENTITY 수 감소 → DXF 파일 크기 절감, 추출 속도 향상
- CAD 작성자가 블록 속성 입력만 하면 됨
### 4.4 텍스트 금지 사항
- **MTEXT 금지**: 단순 태그명은 TEXT 사용 (MTEXT는 다중 줄 설명용)
- **특수 문자 금지**: `%%U`, `%%d` 등 AutoCAD 제어 문자 사용 금지
- **한글 태그 금지**: 태그명은 영문/숫자/하이픈만 사용
---
## 5. 설비(Equipment) 규칙
### 5.1 설비 블록 작성 규칙
각 설비는 블록으로 작성하고, 다음 정보를 포함합니다:
**예시: T-10100 FEED BUFFER TANK**
```
블록명: EQUIP_TANK
위치: (X, Y) — 탱크 중심점
속성:
TAG = T-10100
NAME = FEED BUFFER TANK
SIZE = Ø2,500 x 3,600H
VOLUME = 20.6M3
MATERIAL = STS304
PRESSURE = ATM
TEMPERATURE = 80°C
```
### 5.2 설비-배관 연결점 규칙
설비 블록과 배관이 연결되는 지점은 **정확한 좌표**로 맞닿게 작성합니다:
```
탱크 출구: (1672.2, 1755.3)
배관 시작: (1672.2, 1755.3) ← 동일한 좌표
```
**규칙**:
1. 연결점은 LWPOLYLINE의 시작/끝 좌표가 정확히 일치해야 함
2. 연결점 간격 0.5 단위 이내로 허용
3. 연결점이 맞지 않으면 자동 추출 실패
### 5.3 설비-계측기 연결 규칙
설비에 연결된 계측기기는 `ELECTRIC_SIGNAL` 레이어의 선으로 연결합니다:
```
탱크(T-10100) → LT-10100 (레벨 트랜스미터)
연결선: ELECTRIC_SIGNAL 레이어
시작: 탱크 블록 좌표
종료: 계측기 블록 좌표
```
**추출 로직**:
1. 설비 블록 위치 확인
2. 동일 위치에서 시작하는 ELECTRIC_SIGNAL 라인 추적
3. 라인 끝점에 있는 계측기 블록 식별
4. 연결 관계 기록: `T-10100 → LT-10100`
---
## 6. 배관(Piping) 규칙
### 6.1 배관 라인 작성 규칙
모든 배관은 **LWPOLYLINE** 엔티티로 작성하며, 다음 규칙을 따릅니다:
| 규칙 | 내용 |
|------|------|
| 엔티티 | LWPOLYLINE 사용 (LINE 대신) — 흐름 화살표를 버텍스 너비로 표현 |
| 레이어 | 배관 타입별 레이어 사용 (`PROCESS_LINE`, `STEAM_LINE`, `WATER_LINE`, `AIR_LINE`) |
| 연결점 | LWPOLYLINE의 시작/끝 좌표가 설비 블록 연결점과 정확히 일치 |
| 연속성 | 배관 경로가 끊기지 않도록 연속적인 LWPOLYLINE 작성 |
| 교차 | 배관 교차점은 좌표가 정확히 일치 (0.5 단위 이내) |
| 화살표 | LWPOLYLINE의 끝점 버텍스 너비(`end_width`)로 흐름 방향 화살표 표현 |
### 6.2 배관-설비 연결 추출 로직
```
1. 설비 블록의 연결점 좌표 확인
2. 동일 좌표에서 시작하는 PROCESS_LINE 레이어의 LWPOLYLINE 찾기
3. LWPOLYLINE을 따라 다음 설비 블록까지 추적
4. 연결 관계 기록: [설비A] → [배관] → [설비B]
```
**예시**:
```
T-10100 출구: (1672.2, 1755.3)
→ PROCESS_LINE: (1672.2, 1755.3) → (1700.0, 1755.3)
→ P-10101 입구: (1700.0, 1755.3)
결과: T-10100 → P-10101 (배관: P-10101-25A-F1A-n)
```
### 6.3 밸브-배관 연결 규칙
밸브는 배관 라인 위에 배치하며, 연결점이 정확히 맞닿게 작성합니다:
```
LWPOLYLINE: (1700, 1755) → (1720, 1755)
밸브 입구: (1720, 1755)
밸브 출구: (1730, 1755)
LWPOLYLINE: (1730, 1755) → (1750, 1755)
```
**추출 로직**:
1. 배관 LWPOLYLINE의 끝점 확인
2. 동일 좌표에 밸브 블록이 있는지 확인
3. 밸브 출구에서 계속되는 배관 LWPOLYLINE 추적
4. 연결 관계: `배관 → 밸브 → 배관`
---
## 7. 흐름 방향(Flow Direction) 규칙
### 7.1 LWPOLYLINE 버텍스 너비 기반 화살표
**모든 배관 라인의 흐름 방향은 LWPOLYLINE 엔티티의 버텍스 너비로 명시해야 합니다.**
AutoCAD에서 `PEDIT` 명령으로 LWPOLYLINE의 끝점 너비를 넓히면 화살표 모양이 됩니다. 이 방식은 배관 라인과 화살표를 **단일 엔티티**로 표현하여 작업 효율과 추출 정확도를 동시에 높입니다.
| 규칙 | 내용 |
|------|------|
| 엔티티 | LWPOLYLINE (LINE이 아님) |
| 화살표 표현 | 끝점 버텍스의 `end_width` 속성으로 화살표 크기 지정 |
| 화살표 크기 | `end_width = 3~5` 단위 (배관 너비 `start_width = 0`과 명확히 구분) |
| 방향 판별 | `end_width > start_width`인 방향이 흐름 방향 |
| 레이어 | 배관과 동일 (`PROCESS_LINE`, `STEAM_LINE`, `WATER_LINE`) |
**작성 방법 (AutoCAD)**:
1. `PLINE` 명령으로 배관 경로 작성
2. `PEDIT` 명령 선택 → '너비(W)' 옵션
3. 시작 너비: `0`, 끝 너비: `5` 입력
4. 결과: 끝점에 화살표 모양의 넓은 버텍스 생성
**DXF 저장 구조**:
```
LWPOLYLINE (PROCESS_LINE 레이어)
Vertex 0: (100, 100) start_width=0, end_width=0
Vertex 1: (200, 100) start_width=0, end_width=5 ← 화살표 끝 (우측 흐름)
```
### 7.2 흐름 방향 추출 로직
```
1. LWPOLYLINE 엔티티 찾기 (PROCESS_LINE/STEAM_LINE/WATER_LINE 레이어)
2. 버텍스 목록 획득: vertices = entity.get_vertices()
3. 각 버텍스의 start_width / end_width 확인
4. end_width > 0인 버텍스가 있는 방향 = 흐름 방향
- 마지막 버텍스 end_width > 0 → 시작→끝 방향 (forward)
- 첫 번째 버텍스 start_width > 0 → 끝→시작 방향 (backward)
5. 방향 정보 기록: [배관] → [방향]
```
**추출 코드 예시**:
```python
vertices = list(entity.get_vertices())
if len(vertices) >= 2:
last = vertices[-1]
if hasattr(last, 'end_width') and last.end_width > 0:
flow_direction = "forward" # 시작→끝 방향
first = vertices[0]
if hasattr(first, 'start_width') and first.start_width > 0:
flow_direction = "backward" # 끝→시작 방향
```
### 7.3 양방향 화살표
양방향 흐름이 필요한 경우, 양쪽 끝 버텍스에 너비를 설정합니다:
```
LWPOLYLINE (PROCESS_LINE 레이어)
Vertex 0: (100, 100) start_width=5, end_width=0 ← 좌측 화살표
Vertex 1: (200, 100) start_width=0, end_width=5 ← 우측 화살표
```
### 7.4 탱크/컬럼 화살표 심볼
탱크와 컬럼은 기존처럼 **화살표 모양 경계선**을 유지할 수 있으나,
배관 라인은 LWPOLYLINE 버텍스 너비로 화살표를 표현해야 합니다:
```
T-10100 (탱크)
└→ LWPOLYLINE (end_width=5, forward)
└→ 배관: P-10101-25A-F1A-n
└→ P-10101 (펌프)
```
### 7.5 필수 설정 위치
화살표(버텍스 너비)는 다음 위치에 반드시 설정합니다:
1. **설비 출구**: 탱크/컬럼/히터에서 나가는 배관
2. **분기점**: 배관이 분기되는 지점
3. **합류점**: 배관이 합류하는 지점
4. **도면 경계**: 도면 밖으로 나가는 배관
---
## 8. 설비 사양 정보 규칙
### 8.1 사양 정보 저장 방법
설비 사양은 **블록 속성(Attribute)**으로 저장합니다. PDF에 표시되는 텍스트는 시각용이며, DXF 추출의 기준은 블록 속성입니다.
### 8.2 필수 속성 목록
각 설비 블록에 다음 속성을 반드시 포함합니다:
| 속성 태그 | 설명 | 예시 | 필수 |
|-----------|------|------|------|
| `TAG` | 장비 태그명 | `T-10100` | ✅ |
| `NAME` | 장비 명칭 | `FEED BUFFER TANK` | ✅ |
| `SIZE` | 크기 | `Ø2,500 x 3,600H` | ✅ |
| `VOLUME` | 용적 | `20.6M3` | ✅ |
| `MATERIAL` | 재질 | `STS304` | ✅ |
| `DESIGN_PRESSURE` | 설계 압력 | `0.25MPa` | ✅ |
| `OPERATING_PRESSURE` | 운전 압력 | `ATM` | ✅ |
| `DESIGN_TEMP` | 설계 온도 | `80°C` | ✅ |
| `OPERATING_TEMP` | 운전 온도 | `AMB` | ✅ |
| `INSULATION` | 단열 | `H50` | ❌ |
### 8.3 펌프 전용 속성
| 속성 태그 | 설명 | 예시 |
|-----------|------|------|
| `CAPACITY` | 용량 | `60L/min` |
| `RPM` | 회전수 | `3,520` |
| `TYPE` | 펌프 타입 | `CENTRIFUGAL` |
### 8.4 예시: T-10100 FEED BUFFER TANK
```
블록명: EQUIP_TANK
레이어: EQUIPMENT
위치: (1662.6, 1754.5)
속성:
TAG = T-10100
NAME = FEED BUFFER TANK
SIZE = Ø2,500 x 3,600H
VOLUME = 20.6M3
MATERIAL = STS304
DESIGN_PRESSURE = ATM
OPERATING_PRESSURE = ATM
DESIGN_TEMP = 80°C
OPERATING_TEMP = AMB
```
### 8.5 예시: P-10101 FEED PUMP
```
블록명: PUMP_CENTRIFUGAL
레이어: PUMP
위치: (2055.3, 1738.6)
속성:
TAG = P-10101
NAME = FEED PUMP
SIZE = 25A/20A
MATERIAL = STS316
CAPACITY = 60L/min
RPM = 3,520
DESIGN_PRESSURE = 0.25MPa
TYPE = CENTRIFUGAL
```
---
## 9. 검증 체크리스트
도면 작성 완료 후 다음 항목을 반드시 확인하세요:
### 9.1 블록 검증
- [ ] 모든 설비가 블록(Block/INSERT)으로 작성되었는가?
- [ ] 블록명이 표준 형식을 따르는가? (`EQUIP_TANK`, `PUMP_CENTRIFUGAL` 등)
- [ ] 모든 블록에 필수 속성(TAG, NAME, SIZE, MATERIAL)이 포함되었는가?
- [ ] 속성값이 정확한가?
### 9.2 레이어 검증
- [ ] 모든 요소가 올바른 레이어에 배치되었는가?
- [ ] 0 레이어에 작업 요소가 없는가?
- [ ] 레이어명이 표준 목록과 일치하는가?
### 9.3 연결점 검증
- [ ] 설비-배관 연결점 좌표가 정확히 일치하는가? (0.5 단위 이내)
- [ ] 배관 라인이 끊기지 않고 연속적인가?
- [ ] 밸브가 배관 라인 위에 정확히 배치되었는가?
### 9.4 흐름 방향 검증
- [ ] 모든 배관이 LWPOLYLINE 엔티티로 작성되었는가? (LINE이 아닌가?)
- [ ] 배관 끝점 버텍스의 end_width가 3 이상으로 설정되었는가?
- [ ] 버텍스 너비 방향이 실제 흐름 방향과 일치하는가?
- [ ] 분기점/합류점 배관에 버텍스 너비가 설정되었는가?
### 9.5 텍스트 검증
- [ ] 모든 태그명이 블록 속성(Attribute)의 TAG 필드에 저장되었는가?
- [ ] 태그명 형식이 표준을 따르는가? (`T-10100`, `P-10101`)
- [ ] 파이프 라인 번호가 PIPELINE_NO 레이어에 TEXT로 작성되었는가?
- [ ] 특수 문자(%%U, %%d)가 없는가?
- [ ] TAG_TEXT 또는 SPEC_TEXT 레이어에 불필요한 TEXT 엔티티가 없는가?
### 9.6 DXF 내보내기 검증
- [ ] DXF 버전이 R2018 이상인가?
- [ ] 블록 정의가 정상적으로 내보내졌는가?
- [ ] 블록 속성(Attribute)이 포함되었는가?
- [ ] 레이어 정보가 유지되었는가?

View File

@@ -0,0 +1,571 @@
# DXF 정보추출용 캐드 작업 지침서
**목적**: DXF 파일만으로 P&ID 도면의 모든 정보를 자동 추출할 수 있도록 AutoCAD 작업 시 준수해야 할 규칙
**대상**: P&ID 도면 작성자, CAD 운영자
**버전**: v1.0
**작성일**: 2026-05-06
---
## 목차
1. [총칙](#1-총칙)
2. [레이어 규칙](#2-레이어-규칙)
3. [블록(Block) 규칙](#3-블록block-규칙)
4. [텍스트 규칙](#4-텍스트-규칙)
5. [설비(Equipment) 규칙](#5-설비equipment-규칙)
6. [배관(Piping) 규칙](#6-배관piping-규칙)
7. [흐름 방향(Flow Direction) 규칙](#7-흐름-방향flow-direction-규칙)
8. [설비 사양 정보 규칙](#8-설비-사양-정보-규칙)
9. [검증 체크리스트](#9-검증-체크리스트)
10. [DXF 파일 저장 규칙](#10-dxf-파일-저장-규칙)
---
## 1. 총칙
### 1.1 목적
본 지침서는 DXF 파일만으로 P&ID 도면의 다음 정보를 100% 자동 추출할 수 있도록 AutoCAD 작업 시 준수해야 할 규칙을 정의합니다:
1. **태그명**: 장비, 펌프, 계측기기, 밸브의 고유 식별자
2. **설비 인식**: 장비 타입 판별 및 위치 파악
3. **연결관계**: 배관과 장비의 연결, 단일 장비에 연결된 계측기기/펌프/밸브
4. **흐름방향**: 유체의 이동 방향
5. **설비 사양**: SIZE, VOLUME, MATERIAL 등 도면 내 장비 정보
### 1.2 핵심 원칙
| 원칙 | 설명 |
|------|------|
| **블록 우선** | 모든 심볼은 블록(Block/INSERT)으로 작성 |
| **속성 우선** | 태그명·사양은 블록 속성(Attribute)으로 저장 — 별도 TEXT 금지 |
| **레이어 분리** | 요소별로 레이어를 명확히 분리 |
| **엔티티 최소화** | 같은 정보를 여러 엔티티로 중복 저장하지 않음 |
| **좌표 정렬** | 연결점은 정확한 좌표로 맞닿게 작성 |
---
## 2. 레이어 규칙
### 2.1 필수 레이어 목록
다음 레이어를 반드시 생성하고 요소별로 분리하여 작성해야 합니다:
| 레이어명 | 용도 | 필수 |
|----------|------|------|
| `EQUIPMENT` | 주요 설비 (탱크, 컬럼, 히터, 반응기) | ✅ |
| `PUMP` | 펌프 | ✅ |
| `VALVE` | 밸브 (모든 타입) | ✅ |
| `INSTRUMENT` | 계측기기 (센서, 트랜스미터, 컨트롤러) | ✅ |
| `PROCESS_LINE` | 공정 배관 (+ 흐름 화살표) | ✅ |
| `STEAM_LINE` | 스팀 배관 (+ 흐름 화살표) | ✅ |
| `WATER_LINE` | 물 배관 (+ 흐름 화살표) | ✅ |
| `AIR_LINE` | 공압 배관 (+ 흐름 화살표) | ✅ |
| `ELECTRIC_SIGNAL` | 전기 신호 라인 (제어선) | ✅ |
| `PIPELINE_NO` | 파이프 라인 번호 (배관에만 별도 TEXT 필요) | ✅ |
### 2.2 레이어 사용 규칙
1. **혼용 금지**: 한 레이어에 서로 다른 타입의 요소를 섞지 마세요
2. **0 레이어 금지**: 0 레이어에 작업 요소를 작성하지 마세요
3. **일관성**: 모든 도면에서 동일한 레이어명 사용
---
## 3. 블록(Block) 규칙
### 3.1 블록 사용 의무
**모든 설비/밸브/계측기기는 반드시 블록(Block Reference/INSERT)으로 작성해야 합니다.**
블록을 사용하면:
- `INSERT.dxf.name`으로 심볼 타입을 직접 식별 가능
- `INSERT.dxf.insert`로 정확한 위치 추출 가능
- 속성(Attribute)으로 태그명/사양 저장 가능
### 3.2 블록 명명 규칙
블록명은 다음 형식을 따릅니다:
```
[타입]_[서브타입]
```
| 블록명 | 설명 | 예시 |
|---------|------|------|
| `EQUIP_TANK` | 탱크 | T-10100 |
| `EQUIP_COLUMN` | 컬럼/분리탑 | C-10111 |
| `EQUIP_HEATER` | 히터/교환기 | E-10119 |
| `EQUIP_REACTOR` | 반응기 | R-10101 |
| `PUMP_CENTRIFUGAL` | 원심펌프 | P-10101 |
| `PUMP_DIAPHRAGM` | 다이어프램 펌프 | DP-10101 |
| `PUMP_VACUUM` | 진공펌프 | VP-10117 |
| `VALVE_GLOBE` | 글로브 밸브 | FCV-10101 |
| `VALVE_BALL` | 볼 밸브 | XV-10111 |
| `VALVE_BUTTERFLY` | 버터플라이 밸브 | BV-10100 |
| `VALVE_CHECK` | 체크 밸브 | |
| `VALVE_PSV` | 안전밸브 | PSV-10101 |
| `INST_TRANSMITTER` | 트랜스미터, 센서 | PT-10111, TE-10111A |
| `INST_CONTROLLER` | 컨트롤러 | FICQ-10101 |
| `INST_GAUGE` | 게이지 | PG-10101 |
### 3.3 블록 속성(Attribute) 규칙
각 블록에는 다음 속성을 반드시 포함합니다:
| 속성 태그 | 설명 | 예시 |
|-----------|------|------|
| `TAG` | 장비 태그명 | `T-10100` |
| `NAME` | 장비 명칭 | `FEED BUFFER TANK` |
| `SIZE` | 크기 | `Ø2,500 x 3,600H` |
| `VOLUME` | 용적 | `20.6M3` |
| `MATERIAL` | 재질 | `STS304` |
| `PRESSURE` | 압력 | `0.25MPa` |
| `TEMPERATURE` | 온도 | `80°C` |
**속성 작성 방법**:
1. AutoCAD에서 `ATTDEF` 명령으로 속성 정의
2. `BLOCK` 명령으로 블록 생성 시 속성 포함
3. `INSERT` 시 속성값 입력
---
## 4. 텍스트 규칙
### 4.1 태그명 — 블록 속성만 사용 (별도 TEXT 금지)
**태그명은 블록 속성(Attribute)의 `TAG` 필드에 저장합니다. 별도의 TEXT 엔티티로 태그명을 배치하지 않습니다.**
이유:
- 블록 속성에 이미 TAG가 있으므로 별도 TEXT는 정보 중복
- ENTITY 수 감소 → DXF 파일 크기 절감, 추출 속도 향상
- CAD 작성자가 블록 삽입만 하면 됨 (텍스트 배치 불필요)
**태그명 형식**: `[접두사]-[번호]` (예: `T-10100`, `P-10101`, `FCV-10101`)
### 4.2 파이프 라인 번호
배관 옆에 파이프 라인 번호를 TEXT 엔티티로 작성합니다:
| 규칙 | 내용 |
|------|------|
| 레이어 | `PIPELINE_NO` 레이어 사용 |
| 형식 | `[유체코드]-[라인번호]-[규격]-[등급]-[재질]` |
| 예시 | `P-10101-25A-F1A-n` |
| 위치 | 배관 라인에서 5~10 단위 떨어진 곳에 배치 |
| 엔티티 | TEXT 사용 (MTEXT 금지) |
### 4.3 사양 정보 — 블록 속성만 사용 (별도 TEXT 금지)
**설비 사양(SIZE, VOLUME, MATERIAL 등)은 블록 속성(Attribute)에 저장합니다. 별도의 SPEC_TEXT 레이어로 텍스트를 배치하지 않습니다.**
이유:
- 블록 속성에 이미 모든 사양이 있으므로 별도 TEXT는 정보 중복
- ENTITY 수 감소 → DXF 파일 크기 절감, 추출 속도 향상
- CAD 작성자가 블록 속성 입력만 하면 됨
### 4.4 텍스트 금지 사항
- **MTEXT 금지**: 단순 태그명은 TEXT 사용 (MTEXT는 다중 줄 설명용)
- **특수 문자 금지**: `%%U`, `%%d` 등 AutoCAD 제어 문자 사용 금지
- **한글 태그 금지**: 태그명은 영문/숫자/하이픈만 사용
---
## 5. 설비(Equipment) 규칙
### 5.1 설비 블록 작성 규칙
각 설비는 블록으로 작성하고, 다음 정보를 포함합니다:
**예시: T-10100 FEED BUFFER TANK**
```
블록명: EQUIP_TANK
위치: (X, Y) — 탱크 중심점
속성:
TAG = T-10100
NAME = FEED BUFFER TANK
SIZE = Ø2,500 x 3,600H
VOLUME = 20.6M3
MATERIAL = STS304
PRESSURE = ATM
TEMPERATURE = 80°C
```
### 5.2 설비-배관 연결점 규칙
설비 블록과 배관이 연결되는 지점은 **정확한 좌표**로 맞닿게 작성합니다:
```
탱크 출구: (1672.2, 1755.3)
배관 시작: (1672.2, 1755.3) ← 동일한 좌표
```
**규칙**:
1. 연결점은 LWPOLYLINE의 시작/끝 좌표가 정확히 일치해야 함
2. 연결점 간격 0.5 단위 이내로 허용
3. 연결점이 맞지 않으면 자동 추출 실패
### 5.3 설비-계측기 연결 규칙
설비에 연결된 계측기기는 `ELECTRIC_SIGNAL` 레이어의 선으로 연결합니다:
```
탱크(T-10100) → LT-10100 (레벨 트랜스미터)
연결선: ELECTRIC_SIGNAL 레이어
시작: 탱크 블록 좌표
종료: 계측기 블록 좌표
```
**추출 로직**:
1. 설비 블록 위치 확인
2. 동일 위치에서 시작하는 ELECTRIC_SIGNAL 라인 추적
3. 라인 끝점에 있는 계측기 블록 식별
4. 연결 관계 기록: `T-10100 → LT-10100`
---
## 6. 배관(Piping) 규칙
### 6.1 배관 라인 작성 규칙
모든 배관은 **LWPOLYLINE** 엔티티로 작성하며, 다음 규칙을 따릅니다:
| 규칙 | 내용 |
|------|------|
| 엔티티 | LWPOLYLINE 사용 (LINE 대신) — 흐름 화살표를 버텍스 너비로 표현 |
| 레이어 | 배관 타입별 레이어 사용 (`PROCESS_LINE`, `STEAM_LINE`, `WATER_LINE`, `AIR_LINE`) |
| 연결점 | LWPOLYLINE의 시작/끝 좌표가 설비 블록 연결점과 정확히 일치 |
| 연속성 | 배관 경로가 끊기지 않도록 연속적인 LWPOLYLINE 작성 |
| 교차 | 배관 교차점은 좌표가 정확히 일치 (0.5 단위 이내) |
| 화살표 | LWPOLYLINE의 끝점 버텍스 너비(`end_width`)로 흐름 방향 화살표 표현 |
### 6.2 배관-설비 연결 추출 로직
```
1. 설비 블록의 연결점 좌표 확인
2. 동일 좌표에서 시작하는 PROCESS_LINE 레이어의 LWPOLYLINE 찾기
3. LWPOLYLINE을 따라 다음 설비 블록까지 추적
4. 연결 관계 기록: [설비A] → [배관] → [설비B]
```
**예시**:
```
T-10100 출구: (1672.2, 1755.3)
→ PROCESS_LINE: (1672.2, 1755.3) → (1700.0, 1755.3)
→ P-10101 입구: (1700.0, 1755.3)
결과: T-10100 → P-10101 (배관: P-10101-25A-F1A-n)
```
### 6.3 밸브-배관 연결 규칙
밸브는 배관 라인 위에 배치하며, 연결점이 정확히 맞닿게 작성합니다:
```
LWPOLYLINE: (1700, 1755) → (1720, 1755)
밸브 입구: (1720, 1755)
밸브 출구: (1730, 1755)
LWPOLYLINE: (1730, 1755) → (1750, 1755)
```
**추출 로직**:
1. 배관 LWPOLYLINE의 끝점 확인
2. 동일 좌표에 밸브 블록이 있는지 확인
3. 밸브 출구에서 계속되는 배관 LWPOLYLINE 추적
4. 연결 관계: `배관 → 밸브 → 배관`
---
## 7. 흐름 방향(Flow Direction) 규칙
### 7.1 LWPOLYLINE 버텍스 너비 기반 화살표
**모든 배관 라인의 흐름 방향은 LWPOLYLINE 엔티티의 버텍스 너비로 명시해야 합니다.**
AutoCAD에서 `PEDIT` 명령으로 LWPOLYLINE의 끝점 너비를 넓히면 화살표 모양이 됩니다. 이 방식은 배관 라인과 화살표를 **단일 엔티티**로 표현하여 작업 효율과 추출 정확도를 동시에 높입니다.
| 규칙 | 내용 |
|------|------|
| 엔티티 | LWPOLYLINE (LINE이 아님) |
| 화살표 표현 | 끝점 버텍스의 `end_width` 속성으로 화살표 크기 지정 |
| 화살표 크기 | `end_width = 3~5` 단위 (배관 너비 `start_width = 0`과 명확히 구분) |
| 방향 판별 | `end_width > start_width`인 방향이 흐름 방향 |
| 레이어 | 배관과 동일 (`PROCESS_LINE`, `STEAM_LINE`, `WATER_LINE`) |
**작성 방법 (AutoCAD)**:
1. `PLINE` 명령으로 배관 경로 작성
2. `PEDIT` 명령 선택 → '너비(W)' 옵션
3. 시작 너비: `0`, 끝 너비: `5` 입력
4. 결과: 끝점에 화살표 모양의 넓은 버텍스 생성
**DXF 저장 구조**:
```
LWPOLYLINE (PROCESS_LINE 레이어)
Vertex 0: (100, 100) start_width=0, end_width=0
Vertex 1: (200, 100) start_width=0, end_width=5 ← 화살표 끝 (우측 흐름)
```
### 7.2 흐름 방향 추출 로직
```
1. LWPOLYLINE 엔티티 찾기 (PROCESS_LINE/STEAM_LINE/WATER_LINE 레이어)
2. 버텍스 목록 획득: vertices = entity.get_vertices()
3. 각 버텍스의 start_width / end_width 확인
4. end_width > 0인 버텍스가 있는 방향 = 흐름 방향
- 마지막 버텍스 end_width > 0 → 시작→끝 방향 (forward)
- 첫 번째 버텍스 start_width > 0 → 끝→시작 방향 (backward)
5. 방향 정보 기록: [배관] → [방향]
```
**추출 코드 예시**:
```python
vertices = list(entity.get_vertices())
if len(vertices) >= 2:
last = vertices[-1]
if hasattr(last, 'end_width') and last.end_width > 0:
flow_direction = "forward" # 시작→끝 방향
first = vertices[0]
if hasattr(first, 'start_width') and first.start_width > 0:
flow_direction = "backward" # 끝→시작 방향
```
### 7.3 양방향 화살표
양방향 흐름이 필요한 경우, 양쪽 끝 버텍스에 너비를 설정합니다:
```
LWPOLYLINE (PROCESS_LINE 레이어)
Vertex 0: (100, 100) start_width=5, end_width=0 ← 좌측 화살표
Vertex 1: (200, 100) start_width=0, end_width=5 ← 우측 화살표
```
### 7.4 탱크/컬럼 화살표 심볼
탱크와 컬럼은 기존처럼 **화살표 모양 경계선**을 유지할 수 있으나,
배관 라인은 LWPOLYLINE 버텍스 너비로 화살표를 표현해야 합니다:
```
T-10100 (탱크)
└→ LWPOLYLINE (end_width=5, forward)
└→ 배관: P-10101-25A-F1A-n
└→ P-10101 (펌프)
```
### 7.5 필수 설정 위치
화살표(버텍스 너비)는 다음 위치에 반드시 설정합니다:
1. **설비 출구**: 탱크/컬럼/히터에서 나가는 배관
2. **분기점**: 배관이 분기되는 지점
3. **합류점**: 배관이 합류하는 지점
4. **도면 경계**: 도면 밖으로 나가는 배관
---
## 8. 설비 사양 정보 규칙
### 8.1 사양 정보 저장 방법
설비 사양은 **블록 속성(Attribute)**으로 저장합니다. PDF에 표시되는 텍스트는 시각용이며, DXF 추출의 기준은 블록 속성입니다.
### 8.2 필수 속성 목록
각 설비 블록에 다음 속성을 반드시 포함합니다:
| 속성 태그 | 설명 | 예시 | 필수 |
|-----------|------|------|------|
| `TAG` | 장비 태그명 | `T-10100` | ✅ |
| `NAME` | 장비 명칭 | `FEED BUFFER TANK` | ✅ |
| `SIZE` | 크기 | `Ø2,500 x 3,600H` | ✅ |
| `VOLUME` | 용적 | `20.6M3` | ✅ |
| `MATERIAL` | 재질 | `STS304` | ✅ |
| `DESIGN_PRESSURE` | 설계 압력 | `0.25MPa` | ✅ |
| `OPERATING_PRESSURE` | 운전 압력 | `ATM` | ✅ |
| `DESIGN_TEMP` | 설계 온도 | `80°C` | ✅ |
| `OPERATING_TEMP` | 운전 온도 | `AMB` | ✅ |
| `INSULATION` | 단열 | `H50` | ❌ |
### 8.3 펌프 전용 속성
| 속성 태그 | 설명 | 예시 |
|-----------|------|------|
| `CAPACITY` | 용량 | `60L/min` |
| `RPM` | 회전수 | `3,520` |
| `TYPE` | 펌프 타입 | `CENTRIFUGAL` |
### 8.4 예시: T-10100 FEED BUFFER TANK
```
블록명: EQUIP_TANK
레이어: EQUIPMENT
위치: (1662.6, 1754.5)
속성:
TAG = T-10100
NAME = FEED BUFFER TANK
SIZE = Ø2,500 x 3,600H
VOLUME = 20.6M3
MATERIAL = STS304
DESIGN_PRESSURE = ATM
OPERATING_PRESSURE = ATM
DESIGN_TEMP = 80°C
OPERATING_TEMP = AMB
```
### 8.5 예시: P-10101 FEED PUMP
```
블록명: PUMP_CENTRIFUGAL
레이어: PUMP
위치: (2055.3, 1738.6)
속성:
TAG = P-10101
NAME = FEED PUMP
SIZE = 25A/20A
MATERIAL = STS316
CAPACITY = 60L/min
RPM = 3,520
DESIGN_PRESSURE = 0.25MPa
TYPE = CENTRIFUGAL
```
---
## 9. 검증 체크리스트
도면 작성 완료 후 다음 항목을 반드시 확인하세요:
### 9.1 블록 검증
- [ ] 모든 설비가 블록(Block/INSERT)으로 작성되었는가?
- [ ] 블록명이 표준 형식을 따르는가? (`EQUIP_TANK`, `PUMP_CENTRIFUGAL` 등)
- [ ] 모든 블록에 필수 속성(TAG, NAME, SIZE, MATERIAL)이 포함되었는가?
- [ ] 속성값이 정확한가?
### 9.2 레이어 검증
- [ ] 모든 요소가 올바른 레이어에 배치되었는가?
- [ ] 0 레이어에 작업 요소가 없는가?
- [ ] 레이어명이 표준 목록과 일치하는가?
### 9.3 연결점 검증
- [ ] 설비-배관 연결점 좌표가 정확히 일치하는가? (0.5 단위 이내)
- [ ] 배관 라인이 끊기지 않고 연속적인가?
- [ ] 밸브가 배관 라인 위에 정확히 배치되었는가?
### 9.4 흐름 방향 검증
- [ ] 모든 배관이 LWPOLYLINE 엔티티로 작성되었는가? (LINE이 아닌가?)
- [ ] 배관 끝점 버텍스의 end_width가 3 이상으로 설정되었는가?
- [ ] 버텍스 너비 방향이 실제 흐름 방향과 일치하는가?
- [ ] 분기점/합류점 배관에 버텍스 너비가 설정되었는가?
### 9.5 텍스트 검증
- [ ] 모든 태그명이 블록 속성(Attribute)의 TAG 필드에 저장되었는가?
- [ ] 태그명 형식이 표준을 따르는가? (`T-10100`, `P-10101`)
- [ ] 파이프 라인 번호가 PIPELINE_NO 레이어에 TEXT로 작성되었는가?
- [ ] 특수 문자(%%U, %%d)가 없는가?
- [ ] TAG_TEXT 또는 SPEC_TEXT 레이어에 불필요한 TEXT 엔티티가 없는가?
### 9.6 DXF 내보내기 검증
- [ ] DXF 버전이 R2018 이상인가?
- [ ] 블록 정의가 정상적으로 내보내졌는가?
- [ ] 블록 속성(Attribute)이 포함되었는가?
- [ ] 레이어 정보가 유지되었는가?
---
## 10. DXF 파일 저장 규칙
### 10.1 1 도면 = 1 DXF 파일 원칙
**각 P&ID 도면은 반드시 별도의 DXF 파일로 저장합니다.**
| 규칙 | 내용 |
|------|------|
| 파일명 형식 | `[플랜트명]-drawing-[도면번호].dxf` |
| 예시 | `plant-10100-drawing-01.dxf`, `plant-10100-drawing-02.dxf` |
| 금지 | 여러 도면을 1개 DXF 파일에 포함하지 않음 |
**이유**:
- **매칭 연산량 감소**: 1개 파일에 N개 도면 → O(N²×M²) → 1개 도면/파일 → O(M²)
- **타임아웃 방지**: 처리량이 예측 가능하여 타임아웃 발생 제거
- **병렬 처리**: 파일 단위로 워커 분배 가능
- **에러 격리**: 1개 도면 실패가 다른 도면에 영향 없음
- **재처리 효율**: 변경된 도면만 재실행 가능
**CAD 작성자 참고**:
- AutoCAD 작업 시 여러 도면을 단일 DWG 파일로 작성해도 무방
- **DXF 내보내기 시** 도면 번호별로 분할하여 저장
### 10.2 AutoCAD DXF 내보내기 옵션
AutoCAD에서 DXF를 내보낼 때 다음 옵션을 반드시 설정합니다:
#### 필수 설정
| 옵션 | 설정값 | 이유 |
|------|--------|------|
| **DXF 버전** | **AutoCAD 2018 DXF** (또는 이후 버전) | ezdxf 호환성 보장 |
| **내보내기 범위** | **현재 도면(Current)** 또는 **선택(Selection)** | 불필요한 도면 제외 |
| **레이어** | **모든 레이어 포함** | 레이어 정보 필수 |
| **블록** | **블록 정의 포함** | 심볼 인식 필수 |
| **속성** | **속성 값 포함** | 태그명/사양 정보 필수 |
#### AutoCAD操作步骤
**방법 1: 다른 이름으로 저장 (권장)**
1. `파일``다른 이름으로 저장` 선택
2. 파일 형식: **`*.dxf`** 선택
3. 버전: **`AutoCAD 2018 DXF`** 선택
4. 파일명: `[플랜트명]-drawing-[도면번호].dxf` 형식
5. `저장` 클릭
**방법 2: 내보내기 명령**
1. `EXPORT` 또는 `DXFOUT` 명령 실행
2. 내보내기 범위: **현재 뷰(Current View)** 또는 **전체(Extents)**
3. 버전: **AutoCAD 2018** 이상 선택
4. 파일명 규칙 준수
#### 주의 사항
| 항목 | 내용 |
|------|------|
| **모델 스페이스 vs 레이아웃** | 모델 스페이스(Model Space)에서 작업한 경우, 모델 스페이스 내보내기 |
| **블록 정의** | DXF 내보내기 시 블록 정의(Block Definition)가 포함되는지 확인 |
| **속성 값** | 블록 속성(Attribute) 값이 텍스트로 저장되는지 확인 |
| **레이어 상태** | 숨겨진/잠긴 레이어도 포함되지만, 작업 요소는 반드시 ON/THAW 상태 |
| **0 레이어** | 0 레이어에 작업 요소가 없는지 확인 (9.2 체크리스트 참조) |
### 10.3 도면 분할 내보내기 워크플로우
여러 도면이 있는 DWG 파일을 DXF로 분할 내보낼 때:
1. **도면 영역 확인**: 각 도면의 Bounding Box 확인
2. **도면 1 내보내기**:
- 도면 1 영역으로 Zoom Extents
- `다른 이름으로 저장``plant-10100-drawing-01.dxf`
3. **도면 2 내보내기**:
- 도면 2 영역으로 Zoom Extents
- `다른 이름으로 저장``plant-10100-drawing-02.dxf`
4. **반복**: 모든 도면 완료까지 반복
> **참고**: 도면 분할이 번거로운 경우, Python 스크립트로 자동 분할 가능
> (bbox 기반 분할 로직은 `extractor.py`의 `split_drawings()` 메서드 참조)
### 10.4 DXF 파일 검증
내보내기 후 다음 항목을 확인하세요:
- [ ] 파일이 정상적으로 열리는가? (ezdxf 또는 AutoCAD로 확인)
- [ ] 블록 정의가 포함되어 있는가?
- [ ] 블록 속성(Attribute) 값이 있는가?
- [ ] 레이어 정보가 유지되었는가?
- [ ] LWPOLYLINE 버텍스 너비(end_width)가 유지되었는가?
- [ ] 파일 크기가 예상 범위 내인가? (도면 1개당 1~5MB)

View File

@@ -0,0 +1,579 @@
# DXF 정보추출용 캐드 작업 지침서
**목적**: DXF 파일만으로 P&ID 도면의 모든 정보를 자동 추출할 수 있도록 AutoCAD 작업 시 준수해야 할 규칙
**대상**: P&ID 도면 작성자, CAD 운영자
**버전**: v1.0
**작성일**: 2026-05-06
---
## 목차
1. [총칙](#1-총칙)
2. [레이어 규칙](#2-레이어-규칙)
3. [블록(Block) 규칙](#3-블록block-규칙)
4. [텍스트 규칙](#4-텍스트-규칙)
5. [설비(Equipment) 규칙](#5-설비equipment-규칙)
6. [배관(Piping) 규칙](#6-배관piping-규칙)
7. [흐름 방향(Flow Direction) 규칙](#7-흐름-방향flow-direction-규칙)
8. [설비 사양 정보 규칙](#8-설비-사양-정보-규칙)
9. [검증 체크리스트](#9-검증-체크리스트)
10. [DXF 파일 저장 규칙](#10-dxf-파일-저장-규칙)
---
## 1. 총칙
### 1.1 목적
본 지침서는 DXF 파일만으로 P&ID 도면의 다음 정보를 100% 자동 추출할 수 있도록 AutoCAD 작업 시 준수해야 할 규칙을 정의합니다:
1. **태그명**: 장비, 펌프, 계측기기, 밸브의 고유 식별자
2. **설비 인식**: 장비 타입 판별 및 위치 파악
3. **연결관계**: 배관과 장비의 연결, 단일 장비에 연결된 계측기기/펌프/밸브
4. **흐름방향**: 유체의 이동 방향
5. **설비 사양**: SIZE, VOLUME, MATERIAL 등 도면 내 장비 정보
### 1.2 핵심 원칙
| 원칙 | 설명 |
|------|------|
| **블록 우선** | 모든 심볼은 블록(Block/INSERT)으로 작성 |
| **이중 저장** | TAG/SPEC은 블록 속성(기계용) + TEXT(사람용)으로 모두 저장 |
| **레이어 분리** | 요소별로 레이어를 명확히 분리 |
| **좌표 정렬** | 연결점은 정확한 좌표로 맞닿게 작성 |
---
## 2. 레이어 규칙
### 2.1 필수 레이어 목록
다음 레이어를 반드시 생성하고 요소별로 분리하여 작성해야 합니다:
| 레이어명 | 용도 | 필수 |
|----------|------|------|
| `EQUIPMENT` | 주요 설비 (탱크, 컬럼, 히터, 반응기) | ✅ |
| `PUMP` | 펌프 | ✅ |
| `VALVE` | 밸브 (모든 타입) | ✅ |
| `INSTRUMENT` | 계측기기 (센서, 트랜스미터, 컨트롤러) | ✅ |
| `PROCESS_LINE` | 공정 배관 (+ 흐름 화살표) | ✅ |
| `STEAM_LINE` | 스팀 배관 (+ 흐름 화살표) | ✅ |
| `WATER_LINE` | 물 배관 (+ 흐름 화살표) | ✅ |
| `AIR_LINE` | 공압 배관 (+ 흐름 화살표) | ✅ |
| `ELECTRIC_SIGNAL` | 전기 신호 라인 (제어선) | ✅ |
| `TAG_TEXT` | 태그명 텍스트 (사람이 읽기 위해 필수) | ✅ |
| `PIPELINE_NO` | 파이프 라인 번호 | ✅ |
| `SPEC_TEXT` | 설비 사양 텍스트 (사람이 읽기 위해 필수) | ✅ |
### 2.2 레이어 사용 규칙
1. **혼용 금지**: 한 레이어에 서로 다른 타입의 요소를 섞지 마세요
2. **0 레이어 금지**: 0 레이어에 작업 요소를 작성하지 마세요
3. **일관성**: 모든 도면에서 동일한 레이어명 사용
---
## 3. 블록(Block) 규칙
### 3.1 블록 사용 의무
**모든 설비/밸브/계측기기는 반드시 블록(Block Reference/INSERT)으로 작성해야 합니다.**
블록을 사용하면:
- `INSERT.dxf.name`으로 심볼 타입을 직접 식별 가능
- `INSERT.dxf.insert`로 정확한 위치 추출 가능
- 속성(Attribute)으로 태그명/사양 저장 가능
### 3.2 블록 명명 규칙
블록명은 다음 형식을 따릅니다:
```
[타입]_[서브타입]
```
| 블록명 | 설명 | 예시 |
|---------|------|------|
| `EQUIP_TANK` | 탱크 | T-10100 |
| `EQUIP_COLUMN` | 컬럼/분리탑 | C-10111 |
| `EQUIP_HEATER` | 히터/교환기 | E-10119 |
| `EQUIP_REACTOR` | 반응기 | R-10101 |
| `PUMP_CENTRIFUGAL` | 원심펌프 | P-10101 |
| `PUMP_DIAPHRAGM` | 다이어프램 펌프 | DP-10101 |
| `PUMP_VACUUM` | 진공펌프 | VP-10117 |
| `VALVE_GLOBE` | 글로브 밸브 | FCV-10101 |
| `VALVE_BALL` | 볼 밸브 | XV-10111 |
| `VALVE_BUTTERFLY` | 버터플라이 밸브 | BV-10100 |
| `VALVE_CHECK` | 체크 밸브 | |
| `VALVE_PSV` | 안전밸브 | PSV-10101 |
| `INST_TRANSMITTER` | 트랜스미터, 센서 | PT-10111, TE-10111A |
| `INST_CONTROLLER` | 컨트롤러 | FICQ-10101 |
| `INST_GAUGE` | 게이지 | PG-10101 |
### 3.3 블록 속성(Attribute) 규칙
각 블록에는 다음 속성을 반드시 포함합니다:
| 속성 태그 | 설명 | 예시 |
|-----------|------|------|
| `TAG` | 장비 태그명 | `T-10100` |
| `NAME` | 장비 명칭 | `FEED BUFFER TANK` |
| `SIZE` | 크기 | `Ø2,500 x 3,600H` |
| `VOLUME` | 용적 | `20.6M3` |
| `MATERIAL` | 재질 | `STS304` |
| `PRESSURE` | 압력 | `0.25MPa` |
| `TEMPERATURE` | 온도 | `80°C` |
**속성 작성 방법**:
1. AutoCAD에서 `ATTDEF` 명령으로 속성 정의
2. `BLOCK` 명령으로 블록 생성 시 속성 포함
3. `INSERT` 시 속성값 입력
---
## 4. 텍스트 규칙
### 4.1 태그명 텍스트 (TAG_TEXT)
**CAD 도면의 제1원칙은 사람이 읽기 위한 것입니다.** 각 장비/밸브/계측기기 옆에 태그명을 TEXT 엔티티로 반드시 작성합니다:
| 규칙 | 내용 |
|------|------|
| 레이어 | `TAG_TEXT` 레이어 사용 |
| 형식 | `[접두사]-[번호]` (예: `T-10100`, `P-10101`, `FCV-10101`) |
| 위치 | 장비 블록에서 10~20 단위 떨어진 곳에 배치 |
| 폰트 | 표준 폰트 (고딕, Arial) — 특수 폰트 금지, 캐드용 SHA 금지 |
| 회전 | 0도 (수평) — 회전된 텍스트 금지 |
**추출자 참고**: 태그명은 블록 속성(Attribute)의 `TAG` 필드가 "진실의 원천(Source of Truth)"입니다. TAG_TEXT는 사람이 읽기 위한 것이며, 추출 시 블록 속성을 우선하고 TEXT는 검증용 보조로 사용합니다.
### 4.2 파이프 라인 번호
배관 옆에 파이프 라인 번호를 TEXT 엔티티로 작성합니다:
| 규칙 | 내용 |
|------|------|
| 레이어 | `PIPELINE_NO` 레이어 사용 |
| 형식 | `[유체코드]-[라인번호]-[규격]-[등급]-[재질]` |
| 예시 | `P-10101-25A-F1A-n` |
| 위치 | 배관 라인에서 5~10 단위 떨어진 곳에 배치 |
### 4.3 사양 정보 텍스트 (SPEC_TEXT)
**도면 하단에 장비/계기 사양 정보를 TEXT로 반드시 작성합니다.** 사람이 도면을 볼 때 사양을 확인할 수 있어야 합니다:
| 규칙 | 내용 |
|------|------|
| 레이어 | `SPEC_TEXT` 레이어 사용 |
| 내용 | SIZE, VOLUME, MATERIAL, PRESSURE, TEMPERATURE 등 |
| 위치 | 도면 하단 또는 장비 블록 근처 |
**추출자 참고**: 사양 정보도 블록 속성(Attribute)이 "진실의 원천"입니다. SPEC_TEXT는 사람이 읽기 위한 것이며, 추출 시 블록 속성을 우선합니다.
### 4.4 텍스트 금지 사항
- **MTEXT 금지**: 단순 태그명은 TEXT 사용 (MTEXT는 다중 줄 설명용)
- **특수 문자 금지**: `%%U`, `%%d` 등 AutoCAD 제어 문자 사용 금지
- **한글 태그 금지**: 태그명은 영문/숫자/하이픈만 사용
---
## 5. 설비(Equipment) 규칙
### 5.1 설비 블록 작성 규칙
각 설비는 블록으로 작성하고, 다음 정보를 포함합니다:
**예시: T-10100 FEED BUFFER TANK**
```
블록명: EQUIP_TANK
위치: (X, Y) — 탱크 중심점
속성:
TAG = T-10100
NAME = FEED BUFFER TANK
SIZE = Ø2,500 x 3,600H
VOLUME = 20.6M3
MATERIAL = STS304
PRESSURE = ATM
TEMPERATURE = 80°C
```
### 5.2 설비-배관 연결점 규칙
설비 블록과 배관이 연결되는 지점은 **정확한 좌표**로 맞닿게 작성합니다:
```
탱크 출구: (1672.2, 1755.3)
배관 시작: (1672.2, 1755.3) ← 동일한 좌표
```
**규칙**:
1. 연결점은 LWPOLYLINE의 시작/끝 좌표가 정확히 일치해야 함
2. 연결점 간격 0.5 단위 이내로 허용
3. 연결점이 맞지 않으면 자동 추출 실패
### 5.3 설비-계측기 연결 규칙
설비에 연결된 계측기기는 `ELECTRIC_SIGNAL` 레이어의 선으로 연결합니다:
```
탱크(T-10100) → LT-10100 (레벨 트랜스미터)
연결선: ELECTRIC_SIGNAL 레이어
시작: 탱크 블록 좌표
종료: 계측기 블록 좌표
```
**추출 로직**:
1. 설비 블록 위치 확인
2. 동일 위치에서 시작하는 ELECTRIC_SIGNAL 라인 추적
3. 라인 끝점에 있는 계측기 블록 식별
4. 연결 관계 기록: `T-10100 → LT-10100`
---
## 6. 배관(Piping) 규칙
### 6.1 배관 라인 작성 규칙
모든 배관은 **LWPOLYLINE** 엔티티로 작성하며, 다음 규칙을 따릅니다:
| 규칙 | 내용 |
|------|------|
| 엔티티 | LWPOLYLINE 사용 (LINE 대신) — 흐름 화살표를 버텍스 너비로 표현 |
| 레이어 | 배관 타입별 레이어 사용 (`PROCESS_LINE`, `STEAM_LINE`, `WATER_LINE`, `AIR_LINE`) |
| 연결점 | LWPOLYLINE의 시작/끝 좌표가 설비 블록 연결점과 정확히 일치 |
| 연속성 | 배관 경로가 끊기지 않도록 연속적인 LWPOLYLINE 작성 |
| 교차 | 배관 교차점은 좌표가 정확히 일치 (0.5 단위 이내) |
| 화살표 | LWPOLYLINE의 끝점 버텍스 너비(`end_width`)로 흐름 방향 화살표 표현 |
### 6.2 배관-설비 연결 추출 로직
```
1. 설비 블록의 연결점 좌표 확인
2. 동일 좌표에서 시작하는 PROCESS_LINE 레이어의 LWPOLYLINE 찾기
3. LWPOLYLINE을 따라 다음 설비 블록까지 추적
4. 연결 관계 기록: [설비A] → [배관] → [설비B]
```
**예시**:
```
T-10100 출구: (1672.2, 1755.3)
→ PROCESS_LINE: (1672.2, 1755.3) → (1700.0, 1755.3)
→ P-10101 입구: (1700.0, 1755.3)
결과: T-10100 → P-10101 (배관: P-10101-25A-F1A-n)
```
### 6.3 밸브-배관 연결 규칙
밸브는 배관 라인 위에 배치하며, 연결점이 정확히 맞닿게 작성합니다:
```
LWPOLYLINE: (1700, 1755) → (1720, 1755)
밸브 입구: (1720, 1755)
밸브 출구: (1730, 1755)
LWPOLYLINE: (1730, 1755) → (1750, 1755)
```
**추출 로직**:
1. 배관 LWPOLYLINE의 끝점 확인
2. 동일 좌표에 밸브 블록이 있는지 확인
3. 밸브 출구에서 계속되는 배관 LWPOLYLINE 추적
4. 연결 관계: `배관 → 밸브 → 배관`
---
## 7. 흐름 방향(Flow Direction) 규칙
### 7.1 LWPOLYLINE 버텍스 너비 기반 화살표
**모든 배관 라인의 흐름 방향은 LWPOLYLINE 엔티티의 버텍스 너비로 명시해야 합니다.**
AutoCAD에서 `PEDIT` 명령으로 LWPOLYLINE의 끝점 너비를 넓히면 화살표 모양이 됩니다. 이 방식은 배관 라인과 화살표를 **단일 엔티티**로 표현하여 작업 효율과 추출 정확도를 동시에 높입니다.
| 규칙 | 내용 |
|------|------|
| 엔티티 | LWPOLYLINE (LINE이 아님) |
| 화살표 표현 | 끝점 버텍스의 `end_width` 속성으로 화살표 크기 지정 |
| 화살표 크기 | `end_width = 3~5` 단위 (배관 너비 `start_width = 0`과 명확히 구분) |
| 방향 판별 | `end_width > start_width`인 방향이 흐름 방향 |
| 레이어 | 배관과 동일 (`PROCESS_LINE`, `STEAM_LINE`, `WATER_LINE`) |
**작성 방법 (AutoCAD)**:
1. `PLINE` 명령으로 배관 경로 작성
2. `PEDIT` 명령 선택 → '너비(W)' 옵션
3. 시작 너비: `0`, 끝 너비: `5` 입력
4. 결과: 끝점에 화살표 모양의 넓은 버텍스 생성
**DXF 저장 구조**:
```
LWPOLYLINE (PROCESS_LINE 레이어)
Vertex 0: (100, 100) start_width=0, end_width=0
Vertex 1: (200, 100) start_width=0, end_width=5 ← 화살표 끝 (우측 흐름)
```
### 7.2 흐름 방향 추출 로직
```
1. LWPOLYLINE 엔티티 찾기 (PROCESS_LINE/STEAM_LINE/WATER_LINE 레이어)
2. 버텍스 목록 획득: vertices = entity.get_vertices()
3. 각 버텍스의 start_width / end_width 확인
4. end_width > 0인 버텍스가 있는 방향 = 흐름 방향
- 마지막 버텍스 end_width > 0 → 시작→끝 방향 (forward)
- 첫 번째 버텍스 start_width > 0 → 끝→시작 방향 (backward)
5. 방향 정보 기록: [배관] → [방향]
```
**추출 코드 예시**:
```python
vertices = list(entity.get_vertices())
if len(vertices) >= 2:
last = vertices[-1]
if hasattr(last, 'end_width') and last.end_width > 0:
flow_direction = "forward" # 시작→끝 방향
first = vertices[0]
if hasattr(first, 'start_width') and first.start_width > 0:
flow_direction = "backward" # 끝→시작 방향
```
### 7.3 양방향 화살표
양방향 흐름이 필요한 경우, 양쪽 끝 버텍스에 너비를 설정합니다:
```
LWPOLYLINE (PROCESS_LINE 레이어)
Vertex 0: (100, 100) start_width=5, end_width=0 ← 좌측 화살표
Vertex 1: (200, 100) start_width=0, end_width=5 ← 우측 화살표
```
### 7.4 탱크/컬럼 화살표 심볼
탱크와 컬럼은 기존처럼 **화살표 모양 경계선**을 유지할 수 있으나,
배관 라인은 LWPOLYLINE 버텍스 너비로 화살표를 표현해야 합니다:
```
T-10100 (탱크)
└→ LWPOLYLINE (end_width=5, forward)
└→ 배관: P-10101-25A-F1A-n
└→ P-10101 (펌프)
```
### 7.5 필수 설정 위치
화살표(버텍스 너비)는 다음 위치에 반드시 설정합니다:
1. **설비 출구**: 탱크/컬럼/히터에서 나가는 배관
2. **분기점**: 배관이 분기되는 지점
3. **합류점**: 배관이 합류하는 지점
4. **도면 경계**: 도면 밖으로 나가는 배관
---
## 8. 설비 사양 정보 규칙
### 8.1 사양 정보 저장 방법
설비 사양은 **블록 속성(Attribute)**으로 저장합니다. PDF에 표시되는 텍스트는 시각용이며, DXF 추출의 기준은 블록 속성입니다.
### 8.2 필수 속성 목록
각 설비 블록에 다음 속성을 반드시 포함합니다:
| 속성 태그 | 설명 | 예시 | 필수 |
|-----------|------|------|------|
| `TAG` | 장비 태그명 | `T-10100` | ✅ |
| `NAME` | 장비 명칭 | `FEED BUFFER TANK` | ✅ |
| `SIZE` | 크기 | `Ø2,500 x 3,600H` | ✅ |
| `VOLUME` | 용적 | `20.6M3` | ✅ |
| `MATERIAL` | 재질 | `STS304` | ✅ |
| `DESIGN_PRESSURE` | 설계 압력 | `0.25MPa` | ✅ |
| `OPERATING_PRESSURE` | 운전 압력 | `ATM` | ✅ |
| `DESIGN_TEMP` | 설계 온도 | `80°C` | ✅ |
| `OPERATING_TEMP` | 운전 온도 | `AMB` | ✅ |
| `INSULATION` | 단열 | `H50` | ❌ |
### 8.3 펌프 전용 속성
| 속성 태그 | 설명 | 예시 |
|-----------|------|------|
| `CAPACITY` | 용량 | `60L/min` |
| `RPM` | 회전수 | `3,520` |
| `TYPE` | 펌프 타입 | `CENTRIFUGAL` |
### 8.4 예시: T-10100 FEED BUFFER TANK
```
블록명: EQUIP_TANK
레이어: EQUIPMENT
위치: (1662.6, 1754.5)
속성:
TAG = T-10100
NAME = FEED BUFFER TANK
SIZE = Ø2,500 x 3,600H
VOLUME = 20.6M3
MATERIAL = STS304
DESIGN_PRESSURE = ATM
OPERATING_PRESSURE = ATM
DESIGN_TEMP = 80°C
OPERATING_TEMP = AMB
```
### 8.5 예시: P-10101 FEED PUMP
```
블록명: PUMP_CENTRIFUGAL
레이어: PUMP
위치: (2055.3, 1738.6)
속성:
TAG = P-10101
NAME = FEED PUMP
SIZE = 25A/20A
MATERIAL = STS316
CAPACITY = 60L/min
RPM = 3,520
DESIGN_PRESSURE = 0.25MPa
TYPE = CENTRIFUGAL
```
---
## 9. 검증 체크리스트
도면 작성 완료 후 다음 항목을 반드시 확인하세요:
### 9.1 블록 검증
- [ ] 모든 설비가 블록(Block/INSERT)으로 작성되었는가?
- [ ] 블록명이 표준 형식을 따르는가? (`EQUIP_TANK`, `PUMP_CENTRIFUGAL` 등)
- [ ] 모든 블록에 필수 속성(TAG, NAME, SIZE, MATERIAL)이 포함되었는가?
- [ ] 속성값이 정확한가?
### 9.2 레이어 검증
- [ ] 모든 요소가 올바른 레이어에 배치되었는가?
- [ ] 0 레이어에 작업 요소가 없는가?
- [ ] 레이어명이 표준 목록과 일치하는가?
### 9.3 연결점 검증
- [ ] 설비-배관 연결점 좌표가 정확히 일치하는가? (0.5 단위 이내)
- [ ] 배관 라인이 끊기지 않고 연속적인가?
- [ ] 밸브가 배관 라인 위에 정확히 배치되었는가?
### 9.4 흐름 방향 검증
- [ ] 모든 배관이 LWPOLYLINE 엔티티로 작성되었는가? (LINE이 아닌가?)
- [ ] 배관 끝점 버텍스의 end_width가 3 이상으로 설정되었는가?
- [ ] 버텍스 너비 방향이 실제 흐름 방향과 일치하는가?
- [ ] 분기점/합류점 배관에 버텍스 너비가 설정되었는가?
### 9.5 텍스트 검증
- [ ] 모든 태그명이 TAG_TEXT 레이어에 TEXT 엔티티로 작성되었는가? (사람이 읽기 위해)
- [ ] 모든 태그명이 블록 속성(Attribute)의 TAG 필드에 저장되었는가? (추출을 위해)
- [ ] TAG_TEXT와 블록 속성 TAG 값이 일치하는가?
- [ ] 태그명 형식이 표준을 따르는가? (`T-10100`, `P-10101`)
- [ ] 파이프 라인 번호가 PIPELINE_NO 레이어에 작성되었는가?
- [ ] SPEC_TEXT 레이어에 사양 정보가 작성되었는가? (사람이 읽기 위해)
- [ ] 특수 문자(%%U, %%d)가 없는가?
### 9.6 DXF 내보내기 검증
- [ ] DXF 버전이 R2018 이상인가?
- [ ] 블록 정의가 정상적으로 내보내졌는가?
- [ ] 블록 속성(Attribute)이 포함되었는가?
- [ ] 레이어 정보가 유지되었는가?
---
## 10. DXF 파일 저장 규칙
### 10.1 1 도면 = 1 DXF 파일 원칙
**각 P&ID 도면은 반드시 별도의 DXF 파일로 저장합니다.**
| 규칙 | 내용 |
|------|------|
| 파일명 형식 | `[플랜트명]-drawing-[도면번호].dxf` |
| 예시 | `plant-10100-drawing-01.dxf`, `plant-10100-drawing-02.dxf` |
| 금지 | 여러 도면을 1개 DXF 파일에 포함하지 않음 |
**이유**:
- **매칭 연산량 감소**: 1개 파일에 N개 도면 → O(N²×M²) → 1개 도면/파일 → O(M²)
- **타임아웃 방지**: 처리량이 예측 가능하여 타임아웃 발생 제거
- **병렬 처리**: 파일 단위로 워커 분배 가능
- **에러 격리**: 1개 도면 실패가 다른 도면에 영향 없음
- **재처리 효율**: 변경된 도면만 재실행 가능
**CAD 작성자 참고**:
- AutoCAD 작업 시 여러 도면을 단일 DWG 파일로 작성해도 무방
- **DXF 내보내기 시** 도면 번호별로 분할하여 저장
### 10.2 AutoCAD DXF 내보내기 옵션
AutoCAD에서 DXF를 내보낼 때 다음 옵션을 반드시 설정합니다:
#### 필수 설정
| 옵션 | 설정값 | 이유 |
|------|--------|------|
| **DXF 버전** | **AutoCAD 2018 DXF** (또는 이후 버전) | ezdxf 호환성 보장 |
| **내보내기 범위** | **현재 도면(Current)** 또는 **선택(Selection)** | 불필요한 도면 제외 |
| **레이어** | **모든 레이어 포함** | 레이어 정보 필수 |
| **블록** | **블록 정의 포함** | 심볼 인식 필수 |
| **속성** | **속성 값 포함** | 태그명/사양 정보 필수 |
#### AutoCAD操作步骤
**방법 1: 다른 이름으로 저장 (권장)**
1. `파일``다른 이름으로 저장` 선택
2. 파일 형식: **`*.dxf`** 선택
3. 버전: **`AutoCAD 2018 DXF`** 선택
4. 파일명: `[플랜트명]-drawing-[도면번호].dxf` 형식
5. `저장` 클릭
**방법 2: 내보내기 명령**
1. `EXPORT` 또는 `DXFOUT` 명령 실행
2. 내보내기 범위: **현재 뷰(Current View)** 또는 **전체(Extents)**
3. 버전: **AutoCAD 2018** 이상 선택
4. 파일명 규칙 준수
#### 주의 사항
| 항목 | 내용 |
|------|------|
| **모델 스페이스 vs 레이아웃** | 모델 스페이스(Model Space)에서 작업한 경우, 모델 스페이스 내보내기 |
| **블록 정의** | DXF 내보내기 시 블록 정의(Block Definition)가 포함되는지 확인 |
| **속성 값** | 블록 속성(Attribute) 값이 텍스트로 저장되는지 확인 |
| **레이어 상태** | 숨겨진/잠긴 레이어도 포함되지만, 작업 요소는 반드시 ON/THAW 상태 |
| **0 레이어** | 0 레이어에 작업 요소가 없는지 확인 (9.2 체크리스트 참조) |
### 10.3 도면 분할 내보내기 워크플로우
여러 도면이 있는 DWG 파일을 DXF로 분할 내보낼 때:
1. **도면 영역 확인**: 각 도면의 Bounding Box 확인
2. **도면 1 내보내기**:
- 도면 1 영역으로 Zoom Extents
- `다른 이름으로 저장``plant-10100-drawing-01.dxf`
3. **도면 2 내보내기**:
- 도면 2 영역으로 Zoom Extents
- `다른 이름으로 저장``plant-10100-drawing-02.dxf`
4. **반복**: 모든 도면 완료까지 반복
> **참고**: 도면 분할이 번거로운 경우, Python 스크립트로 자동 분할 가능
> (bbox 기반 분할 로직은 `extractor.py`의 `split_drawings()` 메서드 참조)
### 10.4 DXF 파일 검증
내보내기 후 다음 항목을 확인하세요:
- [ ] 파일이 정상적으로 열리는가? (ezdxf 또는 AutoCAD로 확인)
- [ ] 블록 정의가 포함되어 있는가?
- [ ] 블록 속성(Attribute) 값이 있는가?
- [ ] 레이어 정보가 유지되었는가?
- [ ] LWPOLYLINE 버텍스 너비(end_width)가 유지되었는가?
- [ ] 파일 크기가 예상 범위 내인가? (도면 1개당 1~5MB)

View File

@@ -0,0 +1,784 @@
# 디지털 태그 추가 작업 계획 (Phase 1)
## 개요
`realtime-tag-expansion-design.md` 설계안을 기반으로 Phase 1 기본 확장을 구현합니다.
기존 `realtime_table` 구조를 유지하면서 디지털 장비(Pump, XV) 태그와 메타데이터 지원을 추가합니다.
---
## Todo List (작업 진행 상태 추적)
> 각 단계는 독립적으로 실행 가능하며, 완료 상태를 표시하여 다음 작업에서 바로 읽을 수 있습니다.
- [ ] **Step 1**: DB Migration — `tag_metadata` 테이블 + `v_tag_summary` 뷰 생성
- [ ] **Step 2**: TagMetadata Entity — 신규 Entity 클래스 + DbContext 등록
- [ ] **Step 3**: MetadataLoaderService — OPC UA에서 메타데이터 읽기/저장 서비스
- [ ] **Step 4**: API Endpoint — `POST /api/tags/metadata/reload` 컨트롤러 추가
- [ ] **Step 5**: Frontend UI 변경 — 포인트빌더 최대 10개, 메타데이터 재로드 버튼
- [ ] **Step 6**: NL2SQL DB_SCHEMA 업데이트 — `tag_metadata`, `v_tag_summary` 추가
- [ ] **Step 7**: 테스트 계획 — xv-6124 등록 및 OPC UA 구독 확인
---
## Step 1: DB Migration — `tag_metadata` 테이블 + `v_tag_summary` 뷰 생성
### 상태: [ ] 미완료
### 변경 파일
- `src/Infrastructure/Database/ExperionDbContext.cs`
### 변경 내용
[`ExperionDbService.InitializeAsync()`](src/Infrastructure/Database/ExperionDbContext.cs:176) 메서드 내에 다음 DDL을 추가합니다.
기존 DDL(`realtime_table` 생성 이후, `EnsureCreatedAsync` 설명 주석 이전)에 추가합니다.
**추가 위치:** 276번 라인 (`// realtime_table은 실시간 값 저장용이므로 하이퍼테이블로 변환하지 않음`) 직후
```sql
// tag_metadata ( - )
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS tag_metadata (
id SERIAL PRIMARY KEY,
base_tag TEXT NOT NULL,
attribute TEXT NOT NULL,
value TEXT,
node_id TEXT,
loaded_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(base_tag, attribute)
)
""");
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE INDEX IF NOT EXISTS idx_tag_metadata_base ON tag_metadata(base_tag)
""");
// v_tag_summary ( + )
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE OR REPLACE VIEW v_tag_summary AS
SELECT
rt_base.base_tag,
pv_rt.livevalue AS pv,
sp_rt.livevalue AS sp,
op_rt.livevalue AS op,
instate0_rt.livevalue AS instate0,
instate1_rt.livevalue AS instate1,
instate2_rt.livevalue AS instate2,
desc_md.value AS description,
area_md.value AS area,
s0d_md.value AS state0_descriptor,
s1d_md.value AS state1_descriptor,
s2d_md.value AS state2_descriptor
FROM (SELECT DISTINCT split_part(tagname, '.', 1) AS base_tag FROM realtime_table) rt_base
LEFT JOIN realtime_table pv_rt ON pv_rt.tagname = rt_base.base_tag || '.pv'
LEFT JOIN realtime_table sp_rt ON sp_rt.tagname = rt_base.base_tag || '.sp'
LEFT JOIN realtime_table op_rt ON op_rt.tagname = rt_base.base_tag || '.op'
LEFT JOIN realtime_table instate0_rt ON instate0_rt.tagname = rt_base.base_tag || '.instate0'
LEFT JOIN realtime_table instate1_rt ON instate1_rt.tagname = rt_base.base_tag || '.instate1'
LEFT JOIN realtime_table instate2_rt ON instate2_rt.tagname = rt_base.base_tag || '.instate2'
LEFT JOIN tag_metadata desc_md ON desc_md.base_tag = rt_base.base_tag AND desc_md.attribute = 'desc'
LEFT JOIN tag_metadata area_md ON area_md.base_tag = rt_base.base_tag AND area_md.attribute = 'area'
LEFT JOIN tag_metadata s0d_md ON s0d_md.base_tag = rt_base.base_tag AND s0d_md.attribute = 'state0descriptor'
LEFT JOIN tag_metadata s1d_md ON s1d_md.base_tag = rt_base.base_tag AND s1d_md.attribute = 'state1descriptor'
LEFT JOIN tag_metadata s2d_md ON s2d_md.base_tag = rt_base.base_tag AND s2d_md.attribute = 'state2descriptor'
""");
```
### 검증 방법
1. 앱을 실행하여 DB 초기화가 자동으로 수행되는지 확인
2. psql로 접속하여 `SELECT * FROM tag_metadata LIMIT 1;` 실행
3. `SELECT * FROM v_tag_summary LIMIT 5;` 실행하여 뷰가 정상 동작하는지 확인
---
## Step 2: TagMetadata Entity — 신규 Entity 클래스 + DbContext 등록
### 상태: [ ] 미완료
### 변경 파일
- `src/Core/Domain/Entities/ExperionEntities.cs` (신규 Entity 추가)
- `src/Infrastructure/Database/ExperionDbContext.cs` (DbSet + ModelBuilder 등록)
### 2.1 신규 Entity 클래스 추가
**파일:** [`src/Core/Domain/Entities/ExperionEntities.cs`](src/Core/Domain/Entities/ExperionEntities.cs:138)
[`FastRecord`](src/Core/Domain/Entities/ExperionEntities.cs:129) 클래스 직후에 다음 Entity를 추가합니다.
```csharp
/// <summary>tag_metadata — 태그 메타데이터 (변경 드묾)</summary>
[Table("tag_metadata")]
public class TagMetadata
{
[Column("id")] public int Id { get; set; }
[Column("base_tag")] public string BaseTag { get; set; } = string.Empty;
[Column("attribute")] public string Attribute { get; set; } = string.Empty;
[Column("value")] public string? Value { get; set; }
[Column("node_id")] public string? NodeId { get; set; }
[Column("loaded_at")] public DateTime LoadedAt { get; set; } = DateTime.UtcNow;
}
```
### 2.2 DbContext 에 DbSet 등록
**파일:** [`src/Infrastructure/Database/ExperionDbContext.cs`](src/Infrastructure/Database/ExperionDbContext.cs:20)
기존 DbSet 목록 ([`FastRecords`](src/Infrastructure/Database/ExperionDbContext.cs:23) 이후, P&ID DbSet 이전) 에 추가:
```csharp
public DbSet<TagMetadata> TagMetadata => Set<TagMetadata>();
```
### 2.3 ModelBuilder 설정 추가
**파일:** [`src/Infrastructure/Database/ExperionDbContext.cs`](src/Infrastructure/Database/ExperionDbContext.cs:30)
[`OnModelCreating()`](src/Infrastructure/Database/ExperionDbContext.cs:30) 메서드 내, [`FastRecord`](src/Infrastructure/Database/ExperionDbContext.cs:75) 설정 이후, P&ID 엔티티 설정 이전에 추가:
```csharp
modelBuilder.Entity<TagMetadata>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => new { x.BaseTag, x.Attribute }).IsUnique();
e.HasIndex(x => x.BaseTag);
});
```
### 2.4 변경 후 파일 구조 확인
[`ExperionEntities.cs`](src/Core/Domain/Entities/ExperionEntities.cs) 최종 구조:
```
ExperionTag (line 7)
ExperionRecord (line 21)
ExperionServerConfig (line 32)
RawNodeMap (line 47)
NodeMapMaster (line 59)
RealtimePoint (line 71)
HistoryRecord (line 82)
ExperionStatusCodeInfo (line 92)
PidGraphStatus (line 102)
FastSession (line 113)
FastRecord (line 130)
TagMetadata (신규 추가) ← 여기
```
[`ExperionDbContext.cs`](src/Infrastructure/Database/ExperionDbContext.cs) DbSet 최종 구조:
```csharp
public DbSet<ExperionRecord> ExperionRecords => Set<ExperionRecord>();
public DbSet<RawNodeMap> RawNodeMaps => Set<RawNodeMap>();
public DbSet<NodeMapMaster> NodeMapMasters => Set<NodeMapMaster>();
public DbSet<RealtimePoint> RealtimePoints => Set<RealtimePoint>();
public DbSet<HistoryRecord> HistoryRecords => Set<HistoryRecord>();
public DbSet<FastSession> FastSessions => Set<FastSession>();
public DbSet<FastRecord> FastRecords => Set<FastRecord>();
public DbSet<TagMetadata> TagMetadata => Set<TagMetadata>(); // ← 신규
// P&ID DbSet ...
```
### 검증 방법
1. `dotnet build` 컴파일 오류 없는지 확인
2. Entity 클래스가 `[Table("tag_metadata")]` 속성으로 DB 테이블과 매핑되는지 확인
3. DbContext에서 `TagMetadata` DbSet를 통해 CRUD가 가능한지 확인
---
## Step 3: MetadataLoaderService — OPC UA에서 메타데이터 읽기/저장 서비스
### 상태: [ ] 미완료
### 신규 파일
- `src/Infrastructure/OpcUa/MetadataLoaderService.cs`
### 변경 파일
- `src/Core/Application/Interfaces/IExperionServices.cs` (인터페이스 추가)
- `src/Web/Program.cs` (DI 등록)
### 3.1 인터페이스 정의
**파일:** [`src/Core/Application/Interfaces/IExperionServices.cs`](src/Core/Application/Interfaces/IExperionServices.cs)
파일 끝에 다음 인터페이스 추가:
```csharp
// ── Metadata Loader ──────────────────────────────────────────────────────────
public interface IMetadataLoaderService
{
/// <summary>
/// 태그 등록 시 메타데이터(desc, area, state0~7descriptor)를 OPC UA에서 한 번 읽어서 tag_metadata 저장
/// </summary>
Task<int> LoadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string> baseTags);
/// <summary>
/// UI 재로드 버튼 클릭 시 메타데이터 재조회 + tag_metadata UPDATE + node_map_master UPSERT
/// </summary>
Task<int> ReloadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string>? baseTags = null);
}
```
### 3.2 서비스 구현
**파일:** `src/Infrastructure/OpcUa/MetadataLoaderService.cs` (신규)
```csharp
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Domain.Entities;
using ExperionCrawler.Infrastructure.Database;
using Microsoft.Extensions.Logging;
using Microsoft.EntityFrameworkCore;
namespace ExperionCrawler.Infrastructure.OpcUa;
/// <summary>
/// 메타데이터(desc, area, state0~7descriptor)를 OPC UA에서 읽어서 tag_metadata 테이블에 저장/갱신
/// </summary>
public class MetadataLoaderService : IMetadataLoaderService
{
private readonly IExperionOpcClient _opcClient;
private readonly ExperionDbContext _ctx;
private readonly ILogger<MetadataLoaderService> _logger;
// 로드할 메타데이터 속성 목록
private static readonly string[] MetaAttributes =
{
"desc", "area",
"state0descriptor", "state1descriptor", "state2descriptor",
"state3descriptor", "state4descriptor", "state5descriptor",
"state6descriptor", "state7descriptor"
};
public MetadataLoaderService(
IExperionOpcClient opcClient,
ExperionDbContext ctx,
ILogger<MetadataLoaderService> logger)
{
_opcClient = opcClient;
_ctx = ctx;
_logger = logger;
}
public async Task<int> LoadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string> baseTags)
{
var count = 0;
var entries = new List<(string baseTag, string attr, string? value, string nodeId)>();
foreach (var baseTag in baseTags)
{
foreach (var attr in MetaAttributes)
{
var nodeId = $"{cfg.ServerHostName}:{baseTag}.{attr}";
var fullNodeId = $"ns=1;s={nodeId}";
try
{
var result = await _opcClient.ReadTagAsync(cfg, fullNodeId);
if (result.IsGood && result.Value != null)
{
entries.Add((baseTag.ToLowerInvariant(), attr, result.Value, fullNodeId));
count++;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[Metadata] 읽기 실패: {BaseTag}.{Attr}", baseTag, attr);
}
}
}
// 벌크 UPSERT
foreach (var (bt, attr, val, nid) in entries)
{
await _ctx.Database.ExecuteSqlRawAsync(@"
INSERT INTO tag_metadata (base_tag, attribute, value, node_id, loaded_at)
VALUES (@bt, @attr, @val, @nid, NOW())
ON CONFLICT (base_tag, attribute)
DO UPDATE SET value = @val, node_id = @nid, loaded_at = NOW()",
new NpgsqlTypes.NpgsqlParameter("@bt", bt),
new NpgsqlTypes.NpgsqlParameter("@attr", attr),
new NpgsqlTypes.NpgsqlParameter("@val", (object?)val ?? DBNull.Value),
new NpgsqlTypes.NpgsqlParameter("@nid", nid));
}
await _ctx.Database.ExecuteSqlRawAsync("REFRESH MATERIALIZED VIEW IF EXISTS v_tag_summary");
_logger.LogInformation("[Metadata] 로드 완료: {Count}개 속성 ({BaseTags}개 태그)", count, baseTags.Count());
return count;
}
public async Task<int> ReloadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string>? baseTags = null)
{
// baseTags가 null이면 tag_metadata에서 전체 base_tag 조회
var tags = baseTags ?? await _ctx.TagMetadata.Select(t => t.BaseTag).Distinct().ToListAsync();
return await LoadMetadataAsync(cfg, tags);
}
}
```
### 3.3 DI 등록
**파일:** `src/Web/Program.cs`
기존 서비스 등록 부분 근처에 추가:
```csharp
builder.Services.AddScoped<IMetadataLoaderService, MetadataLoaderService>();
```
### 검증 방법
1. `dotnet build` 컴파일 오류 없는지 확인
2. 테스트용 코드에서 `LoadMetadataAsync` 호출 시 OPC UA에서 desc/area 값이 정상 읽히는지 로그 확인
3. DB에서 `SELECT * FROM tag_metadata;` 실행하여 데이터 저장 확인
---
## Step 4: API Endpoint — `POST /api/tags/metadata/reload` 컨트롤러 추가
### 상태: [ ] 미완료
### 변경 파일
- `src/Web/Controllers/ExperionControllers.cs` (신규 컨트롤러 추가)
### 4.1 컨트롤러 추가
**파일:** [`src/Web/Controllers/ExperionControllers.cs`](src/Web/Controllers/ExperionControllers.cs)
[`ExperionPointBuilderController`](src/Web/Controllers/ExperionControllers.cs:272) 직후, [`ExperionRealtimeController`](src/Web/Controllers/ExperionControllers.cs:348) 이전에 추가:
```csharp
// ── 태그 메타데이터 관리 ──────────────────────────────────────────────────────
[ApiController]
[Route("api/tags/metadata")]
public class TagMetadataController : ControllerBase
{
private readonly IMetadataLoaderService _metaSvc;
private readonly IOpcUaConfigProvider _configProvider;
public TagMetadataController(
IMetadataLoaderService metaSvc,
IOpcUaConfigProvider configProvider)
{
_metaSvc = metaSvc;
_configProvider = configProvider;
}
/// <summary>
/// 지정된 태그(또는 전체)의 메타데이터를 OPC UA에서 재조회하여 갱신
/// </summary>
[HttpPost("reload")]
public async Task<IActionResult> Reload([FromBody] MetadataReloadRequest? req)
{
var cfg = new ExperionServerConfig
{
ServerHostName = req?.ServerHostName ?? "192.168.0.20",
Port = req?.Port ?? 4840,
ClientHostName = req?.ClientHostName ?? "dbsvr",
UserName = req?.UserName ?? "mngr",
Password = req?.Password ?? "mngr"
};
try
{
var count = await _metaSvc.ReloadMetadataAsync(cfg, req?.BaseTags);
return Ok(new { success = true, count, message = $"{count}개 메타데이터 갱신 완료" });
}
catch (Exception ex)
{
return StatusCode(500, new { success = false, message = ex.Message });
}
}
/// <summary>
/// tag_metadata 전체 조회
/// </summary>
[HttpGet]
public async Task<IActionResult> GetMetadata([FromQuery] string? baseTag = null)
{
using var ctx = new ExperionDbContext(/* DI에서 해결 필요 */);
var query = ctx.TagMetadata.AsQueryable();
if (!string.IsNullOrEmpty(baseTag))
query = query.Where(t => t.BaseTag == baseTag.ToLowerInvariant());
var items = await query.Select(t => new
{
t.BaseTag,
t.Attribute,
t.Value,
t.NodeId,
t.LoadedAt
}).ToListAsync();
return Ok(new { count = items.Count, items });
}
}
public record MetadataReloadRequest(
string? ServerHostName,
int? Port,
string? ClientHostName,
string? UserName,
string? Password,
IEnumerable<string>? BaseTags);
```
### 4.2 GET 엔드포인트용 DbContext 주입 수정
위 코드에서 `GetMetadata` 메서드는 DI를 통해 DbContext를 주입받는 방식으로 수정해야 합니다.
최종 형태:
```csharp
[ApiController]
[Route("api/tags/metadata")]
public class TagMetadataController : ControllerBase
{
private readonly IMetadataLoaderService _metaSvc;
private readonly ExperionDbContext _ctx;
public TagMetadataController(
IMetadataLoaderService metaSvc,
ExperionDbContext ctx)
{
_metaSvc = metaSvc;
_ctx = ctx;
}
[HttpPost("reload")]
public async Task<IActionResult> Reload([FromBody] MetadataReloadRequest? req)
{
// ... 동일
}
[HttpGet]
public async Task<IActionResult> GetMetadata([FromQuery] string? baseTag = null)
{
var query = _ctx.TagMetadata.AsQueryable();
if (!string.IsNullOrEmpty(baseTag))
query = query.Where(t => t.BaseTag == baseTag.ToLowerInvariant());
var items = await query.Select(t => new
{
t.BaseTag, t.Attribute, t.Value, t.NodeId, t.LoadedAt
}).ToListAsync();
return Ok(new { count = items.Count, items });
}
}
```
### 검증 방법
1. `dotnet build` 컴파일 오류 없는지 확인
2. Postman/curl로 `POST /api/tags/metadata/reload` 호출하여 메타데이터 갱신 확인
3. `GET /api/tags/metadata?baseTag=xv-6124` 호출하여 조회 확인
---
## Step 5: Frontend UI 변경 — 포인트빌더 최대 10개, 메타데이터 재로드 버튼
### 상태: [ ] 미완료
### 변경 파일
- `src/Web/wwwroot/index.html` (포인트빌더 탭 HTML 변경)
- `src/Web/wwwroot/js/app.js` (JS 로직 변경)
### 5.1 HTML 변경 — 포인트빌더 최대 10개로 확장
**파일:** [`src/Web/wwwroot/index.html`](src/Web/wwwroot/index.html:422)
현재 포인트빌더 탭(`pane-pb`) 내 name 선택 드롭다운이 8개(`pb-n1`~`pb-n8`)입니다. 10개로 확장:
**변경 위치:** `pb-name-grid` 내부 select 요소 2개 추가
```html
<!-- 신규 추가 -->
<select id="pb-n9" class="inp"><option value="">— 선택 안 함 —</option></select>
<select id="pb-n10" class="inp"><option value="">— 선택 안 함 —</option></select>
```
**레이블 텍스트 변경:**
```html
<label>이름(name) 선택 <em>(OR 조건, 최대 10개)</em>
```
### 5.2 HTML 변경 — 메타데이터 재로드 버튼 추가
**파일:** [`src/Web/wwwroot/index.html`](src/Web/wwwroot/index.html:482)
`pb-rt-status` 로그 박스 직후, 포인트 목록 카드 이전에 추가:
```html
<div class="card" style="margin-top:18px">
<div class="card-cap">메타데이터 관리</div>
<p style="color:var(--t2);font-size:13px;margin-bottom:12px">
태그의 desc, area, state descriptor 정보를 OPC UA에서 조회하여 저장합니다.
</p>
<div class="btn-row">
<button class="btn-a" onclick="metaReload()">🔄 메타데이터 재로드</button>
<button class="btn-b" onclick="metaView()">📋 메타데이터 조회</button>
</div>
<div id="meta-log" class="logbox hidden" style="margin-top:8px"></div>
<div id="meta-view" class="hidden" style="margin-top:10px;max-height:300px;overflow:auto"></div>
</div>
```
### 5.3 JS 변경 — PB_NAME_IDS 배열 확장
**파일:** [`src/Web/wwwroot/js/app.js`](src/Web/wwwroot/js/app.js:575)
```javascript
// 변경 전:
const PB_NAME_IDS = ['pb-n1','pb-n2','pb-n3','pb-n4','pb-n5','pb-n6','pb-n7','pb-n8'];
// 변경 후:
const PB_NAME_IDS = ['pb-n1','pb-n2','pb-n3','pb-n4','pb-n5','pb-n6','pb-n7','pb-n8','pb-n9','pb-n10'];
```
### 5.4 JS 변경 — 메타데이터 재로드/조회 함수 추가
**파일:** [`src/Web/wwwroot/js/app.js`](src/Web/wwwroot/js/app.js:723)
`rtStatus()` 함수 직후에 추가:
```javascript
/* ── 메타데이터 관리 ─────────────────────────────────────────── */
async function metaReload() {
const body = {
serverHostName: document.getElementById('pb-rt-ip').value.trim(),
port: parseInt(document.getElementById('pb-rt-port').value) || 4840,
clientHostName: document.getElementById('pb-rt-client').value.trim(),
userName: document.getElementById('pb-rt-user').value.trim(),
password: document.getElementById('pb-rt-pw').value
};
const logEl = document.getElementById('meta-log');
logEl.classList.remove('hidden');
logEl.innerHTML = '<div class="ll inf">⏳ 메타데이터 재로드 중...</div>';
try {
const d = await api('POST', '/api/tags/metadata/reload', body);
logEl.innerHTML = `<div class="ll ${d.success ? 'ok' : 'err'}">${d.success ? '✅' : '❌'} ${esc(d.message)}</div>`;
} catch (e) {
logEl.innerHTML = `<div class="ll err">❌ ${esc(e.message)}</div>`;
}
}
async function metaView() {
const viewEl = document.getElementById('meta-view');
viewEl.classList.remove('hidden');
viewEl.innerHTML = '<div class="ll inf">⏳ 조회 중...</div>';
try {
const d = await api('GET', '/api/tags/metadata');
const items = d.items || [];
if (items.length === 0) {
viewEl.innerHTML = '<div class="ll inf">메타데이터가 없습니다.</div>';
return;
}
viewEl.innerHTML = `
<table style="width:100%;font-size:12px">
<thead><tr><th>BaseTag</th><th>Attribute</th><th>Value</th><th>LoadedAt</th></tr></thead>
<tbody>
${items.map(m => `
<tr>
<td style="font-weight:600">${esc(m.baseTag)}</td>
<td>${esc(m.attribute)}</td>
<td>${esc(m.value || '-')}</td>
<td class="mut">${m.loadedAt ? new Date(m.loadedAt).toLocaleString('ko-KR') : '-'}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
} catch (e) {
viewEl.innerHTML = `<div class="ll err">❌ ${esc(e.message)}</div>`;
}
}
```
### 검증 방법
1. 브라우저에서 포인트빌더 탭 확인 — 드롭다운이 10개인지 확인
2. "메타데이터 재로드" 버튼 클릭 시 API 호출되고 결과 로그 표시 확인
3. "메타데이터 조회" 버튼 클릭 시 테이블 형태로 메타데이터 표시 확인
---
## Step 6: NL2SQL DB_SCHEMA 업데이트 — `tag_metadata`, `v_tag_summary` 추가
### 상태: [ ] 미완료
### 변경 파일
- `mcp-server/worker/nl2sql_worker.py` (DB_SCHEMA 변수 업데이트)
- `mcp-server/server.py` (_DB_SCHEMA 변수 업데이트)
### 6.1 nl2sql_worker.py 변경
**파일:** [`mcp-server/worker/nl2sql_worker.py`](mcp-server/worker/nl2sql_worker.py:61)
기존 `DB_SCHEMA` 변수에 `tag_metadata` 테이블과 `v_tag_summary` 뷰 정의를 추가:
```python
DB_SCHEMA = """
PostgreSQL 시계열 데이터베이스 스키마
테이블: history_table (시계열 이력)
tagname TEXT - 태그명 (모두 소문자, 예: 'ficq-6113.pv')
node_id TEXT - OPC UA 노드 ID
value TEXT - 측정값, 수치 연산 시 ::double precision 캐스트 필요
recorded_at TIMESTAMPTZ - 기록 시각(UTC), 스냅샷 주기 약 60초
테이블: 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', 'state0descriptor', ...)
value TEXT - 메타데이터 값
node_id TEXT - OPC UA 노드 ID
loaded_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)
state0_descriptor TEXT - 비트 0 의미 (예: "Run/Stop")
state1_descriptor TEXT - 비트 1 의미 (예: "Remote/Local")
state2_descriptor TEXT - 비트 2 의미 (예: "Trip/Normal")
새로운 태그 타입:
- 아날로그: 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), state0descriptor~7 (String)
BCD 상태 조회 팁:
- instate0~7은 Boolean (true/false)
- state0descriptor~7은 해당 비트의 의미 설명
- instate0=true이고 state0descriptor="Run/Stop"이면 → "Run" 상태
- v_tag_summary 뷰를 사용하면 실시간값+메타데이터 한 번에 조회 가능
N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용):
1분 버킷: date_trunc('minute', recorded_at) AS bucket
5분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/300)*300) AS bucket
N분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket
규칙:
- SELECT만 허용 (INSERT/UPDATE/DELETE/DROP 등 불가)
- tagname은 모두 소문자로 정확히 입력
- value 컬럼은 TEXT이므로 집계 시 ::double precision 캐스트 필수
- time_bucket 함수 사용 금지
"""
```
### 6.2 server.py 변경
**파일:** [`mcp-server/server.py`](mcp-server/server.py:430)
`_DB_SCHEMA` 변수도 동일하게 업데이트 (nl2sql_worker.py와 동일한 내용).
### 6.3 NL2SQL 사용 예시 (새로운 시나리오)
| 자연어 질문 | 생성 SQL |
|------------|----------|
| "xv-6124의 현재 상태와 설명을 알려줘" | `SELECT instate0, instate1, state0_descriptor, state1_descriptor, description FROM v_tag_summary WHERE base_tag = 'xv-6124'` |
| "Unit-A에 있는 모든 pump의 상태를 보여줘" | `SELECT base_tag, instate0, state0_descriptor, description FROM v_tag_summary WHERE area = 'Unit-A' AND base_tag LIKE 'p-%'` |
| "모든 XV 중 instate0이 true인 것" | `SELECT base_tag, instate0, state0_descriptor FROM v_tag_summary WHERE instate0 = 'True' AND base_tag LIKE 'xv-%'` |
### 검증 방법
1. MCP 서버 재시작 후 NL2SQL 도구 호출 시 새로운 스키마가 반영되는지 확인
2. "xv-6124 상태 알려줘" 같은 질문으로 v_tag_summary 조회 테스트
3. tag_metadata 테이블 조회가 정상적으로 되는지 확인
---
## Step 7: 테스트 계획 — xv-6124 등록 및 OPC UA 구독 확인
### 상태: [ ] 미완료
### 테스트 시나리오
#### 7.1 디지털 태그 등록 테스트
**목표:** xv-6124의 pv, op, instate0~2를 realtime_table에 등록하고 OPC UA 구독 확인
**단계:**
1. 포인트빌더 탭에서 이름에 `xv-6124` 선택
2. 데이터 타입에 `Int32`, `Boolean` 입력
3. "🔨 테이블 작성하기" 클릭
4. 포인트 목록에 xv-6124.pv, xv-6124.op, xv-6124.instate0~2가 생성되는지 확인
**기대 결과:**
- `realtime_table`에 xv-6124 관련 행이 INSERT됨
- 포인트 목록 UI에 표시됨
#### 7.2 OPC UA 구독 테스트
**목표:** 실시간 값 업데이트 확인
**단계:**
1. "▶ 구독 시작" 클릭
2. 구독 상태 로그에 xv-6124 관련 노드가 포함된지 확인
3. 몇 분 후 포인트 목록 새로고침
4. `livevalue` 컬럼에 값이 업데이트되는지 확인
**기대 결과:**
- xv-6124.pv → Int32 BCD 값 (예: 0, 1, 3, 5 등)
- xv-6124.instate0 → Boolean (true/false)
- 구독 카운터에 xv-6124 태그가 포함됨
#### 7.3 메타데이터 로드 테스트
**목표:** desc, area, state descriptor 값이 tag_metadata에 저장되는지 확인
**단계:**
1. "🔄 메타데이터 재로드" 버튼 클릭
2. 로그에 갱신된 속성 수 확인
3. "📋 메타데이터 조회" 버튼 클릭
4. xv-6124 관련 메타데이터 행 확인
**기대 결과:**
- `tag_metadata`에 xv-6124 관련 행 INSERT됨
- desc: 장비 설명 문자열
- area: 소속 플랜트 (예: "Unit-A")
- state0descriptor: "Open/Close" 또는 유사한 문자열
#### 7.4 v_tag_summary 뷰 테스트
**목표:** 실시간값 + 메타데이터 통합 조회 확인
**단계:**
```sql
-- psql에서 실행
SELECT * FROM v_tag_summary WHERE base_tag = 'xv-6124';
```
**기대 결과:**
| base_tag | pv | op | instate0 | instate1 | instate2 | description | area | state0_descriptor | state1_descriptor | state2_descriptor |
|----------|-----|-----|----------|----------|----------|-------------|------|-------------------|-------------------|-------------------|
| xv-6124 | 1 | 1 | true | false | false | XV-6124 설명 | Unit-A | Open/Close | Remote/Local | Fault/Normal |
#### 7.5 NL2SQL 통합 테스트
**목표:** 자연어 질문으로 디지털 태그 상태 조회
**단계:**
1. Text-to-SQL 탭에서 "xv-6124 현재 상태 알려줘" 입력
2. 생성된 SQL이 v_tag_summary를 사용하는지 확인
3. 결과에 instate0~2 값과 state descriptor가 포함되는지 확인
**기대 결과:**
- SQL: `SELECT instate0, instate1, state0_descriptor, state1_descriptor, description FROM v_tag_summary WHERE base_tag = 'xv-6124'`
- 결과 테이블에 상태 비트와 의미 표시됨
### 테스트 체크리스트
- [ ] xv-6124 포인트 realtime_table에 등록됨
- [ ] OPC UA 구독 시 livevalue 업데이트됨
- [ ] 메타데이터 재로드 시 tag_metadata에 저장됨
- [ ] v_tag_summary 뷰에서 통합 조회 가능
- [ ] NL2SQL에서 자연어 질문으로 상태 조회 가능
- [ ] 포인트빌더에서 최대 10개 태그 선택 가능

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,339 @@
#!/usr/bin/env python3
"""NL2SQL 전용 워커 프로세스
Usage: python nl2sql_worker.py <port>
담당 도구:
run_sql, query_pv_history, get_tag_metadata, list_drawings, query_with_nl
특징:
- PostgreSQL 직접 연결
- LLM SQL 생성 + DB 실행 분리
- 메모리: ~1GB (SQL 생성용 LLM)
- 생명주기: 메인 서버 종료 시까지 유지
"""
from __future__ import annotations
import sys
import os
# mcp-server 디렉토리를 Python 경로에 추가
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import logging
import asyncio
from functools import lru_cache
from fastapi import FastAPI, Request
import uvicorn
import httpx
# ── 설정 ─────────────────────────────────────────────────────────────────────
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [nl2sql_worker] %(levelname)s %(message)s",
)
app = FastAPI()
# ── DB 연결 풀 ───────────────────────────────────────────────────────────────
def _get_db_connection():
import psycopg
return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT)
# ── LLM 클라이언트 ───────────────────────────────────────────────────────────
@lru_cache(maxsize=1)
def _llm_client():
from openai import AsyncOpenAI
return AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
# DB 스키마 — server.py::_DB_SCHEMA와 동일
DB_SCHEMA = """
PostgreSQL 시계열 데이터베이스 스키마
테이블: history_table (시계열 이력)
tagname TEXT - 태그명 (모두 소문자, 예: 'ficq-6113.pv') — 대소문자 구분
node_id TEXT - OPC UA 노드 ID
value TEXT - 측정값, 수치 연산 시 ::double precision 캐스트 필요
recorded_at TIMESTAMPTZ - 기록 시각(UTC), 스냅샷 주기 약 60초
테이블: realtime_table (실시간 최신값)
tagname TEXT - 태그명 (모두 소문자)
node_id TEXT - OPC UA 노드 ID
livevalue TEXT - 현재값
timestamp TIMESTAMPTZ - 최종 갱신 시각
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 공식 사용
"""
async def _generate_sql(natural_language: str) -> str:
"""자연어를 SQL로 변환."""
client = _llm_client()
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"
f"{DB_SCHEMA}"
)
response = await client.chat.completions.create(
model="Qwen3.6-27B-FP8",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": natural_language},
],
max_tokens=8192,
temperature=0.1,
)
sql = response.choices[0].message.content.strip()
# 마크다운 코드 블록 제거
if sql.startswith("```"):
lines = sql.splitlines()
sql = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip()
return sql
# ── NL2SQL 도구 구현 ─────────────────────────────────────────────────────────
@app.get("/health")
async def health():
"""워커 헬스체크."""
return {"status": "ok"}
@app.post("/execute")
async def execute(request: Request):
"""HTTP 요청을 MCP 도구 호출로 변환."""
body = await request.json()
tool = body["tool"]
params = body["params"]
try:
if tool == "run_sql":
result = await _run_sql(**params)
elif tool == "query_pv_history":
result = await _query_pv_history(**params)
elif tool == "get_tag_metadata":
result = await _get_tag_metadata(**params)
elif tool == "list_drawings":
result = await _list_drawings(**params)
elif tool == "query_with_nl":
result = await _query_with_nl(**params)
else:
return {"success": False, "error": f"Unknown tool: {tool}"}
return result
except Exception as e:
logging.error(f"Error executing {tool}: {e}")
return {"success": False, "error": str(e)}
async def _run_sql(sql: str) -> str:
"""SQL 실행."""
conn = _get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(sql)
if cur.description:
columns = [desc[0] for desc in cur.description]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"columns": columns,
"count": len(data),
"data": data,
}
else:
conn.commit()
return {
"success": True,
"message": f"Query executed successfully. {cur.rowcount} rows affected.",
}
finally:
conn.close()
async def _query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str:
"""과거 값(PV) 히스토리 조회."""
if not tag_names:
return {"success": False, "error": "tag_names is required"}
conn = _get_db_connection()
try:
with conn.cursor() as cur:
# TimescaleDB의 time_bucket 함수 사용
cur.execute(
"""
SELECT time_bucket('1 min', ts) AS time, tag_name, value
FROM realtime_table
WHERE tag_name = ANY(%s)
AND ts >= %s
AND ts <= %s
ORDER BY time DESC
LIMIT %s
""",
(tag_names, time_from, time_to, limit),
)
columns = ["time", "tag_name", "value"]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"tag_names": tag_names,
"time_range": {"from": time_from, "to": time_to},
"limit": limit,
"count": len(data),
"data": data,
}
finally:
conn.close()
async def _get_tag_metadata(query: str, limit: int = 10) -> str:
"""태그 메타데이터 검색."""
conn = _get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT DISTINCT tag_name, unit, description
FROM realtime_table
WHERE tag_name ILIKE %s
ORDER BY tag_name
LIMIT %s
""",
(f"%{query}%", limit),
)
columns = ["tag_name", "unit", "description"]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"query": query,
"count": len(data),
"tags": data,
}
finally:
conn.close()
async def _list_drawings(unit_no: str = None) -> str:
"""단위별 도면 목록 조회."""
conn = _get_db_connection()
try:
with conn.cursor() as cur:
if unit_no:
cur.execute(
"""
SELECT DISTINCT name
FROM node_map_master
WHERE name LIKE %s
ORDER BY name
""",
(f"{unit_no}%",),
)
else:
cur.execute(
"""
SELECT DISTINCT name
FROM node_map_master
ORDER BY name
"""
)
columns = ["name"]
rows = cur.fetchall()
data = [dict(zip(columns, row[0])) for row in rows]
return {
"success": True,
"unit_no": unit_no,
"count": len(data),
"names": [d["name"] for d in data],
}
finally:
conn.close()
async def _query_with_nl(question: str) -> str:
"""자연어로 SQL 쿼리 실행."""
import json
sql = await _generate_sql(question)
# SQL이 비어있으면 오류 반환
if not sql:
return json.dumps({"success": False, "sql": "", "error": "LLM이 SQL을 생성하지 못했습니다."}, ensure_ascii=False)
conn = _get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(sql)
if cur.description:
columns = [desc[0] for desc in cur.description]
rows = cur.fetchall()
data = [dict(zip(columns, row)) for row in rows]
return {
"success": True,
"sql": sql,
"columns": columns,
"count": len(data),
"data": data,
}
else:
conn.commit()
return {
"success": True,
"sql": sql,
"message": f"Query executed successfully. {cur.rowcount} rows affected.",
}
except Exception as db_error:
return {
"success": False,
"sql": sql,
"error": str(db_error),
}
finally:
conn.close()
# ── 메인 ─────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5003
logging.info(f"Starting NL2SQL worker on port {port}")
uvicorn.run(app, host="0.0.0.0", port=port)

View File

@@ -0,0 +1,304 @@
using ExperionCrawler.Core.Application.DTOs;
using ExperionCrawler.Core.Domain.Entities;
using ExperionCrawler.Infrastructure.Database;
using Opc.Ua;
using System.Collections.Generic;
namespace ExperionCrawler.Core.Application.Interfaces;
// ── Certificate ──────────────────────────────────────────────────────────────
public interface IExperionCertificateService
{
/// <summary>인증서가 없으면 생성, 있으면 기존 반환</summary>
Task<ExperionCertResult> EnsureCertificateAsync(
string applicationUri,
string clientHostName,
IEnumerable<string> subjectAltNames,
string pfxPassword = "");
bool CertificateExists(string clientHostName);
ExperionCertInfo GetCertificateInfo(string clientHostName);
}
// ── OPC UA Client ────────────────────────────────────────────────────────────
/// <summary>OPC UA ApplicationConfiguration 생성을 위한 공유 설정 공급자</summary>
public interface IOpcUaConfigProvider
{
Task<ApplicationConfiguration> GetConfigAsync(ExperionServerConfig cfg);
}
public interface IExperionOpcClient
{
Task<ExperionConnectResult> TestConnectionAsync(ExperionServerConfig cfg);
Task<ExperionReadResult> ReadTagAsync(ExperionServerConfig cfg, string nodeId);
Task<IEnumerable<ExperionReadResult>> ReadTagsAsync(ExperionServerConfig cfg, IEnumerable<string> nodeIds);
Task<ExperionBrowseResult> BrowseNodesAsync(ExperionServerConfig cfg, string? startNodeId = null);
Task<ExperionNodeMapResult> BrowseAllNodesAsync(ExperionServerConfig cfg, int maxDepth = 10, CancellationToken ct = default);
}
// ── CSV ──────────────────────────────────────────────────────────────────────
public interface IExperionCsvService
{
Task<string> ExportAsync(IEnumerable<ExperionRecord> records, string fileName);
Task<IEnumerable<ExperionRecord>> ImportAsync(string filePath);
IEnumerable<string> GetAvailableFiles();
Task<string> ExportNodeMapAsync(IEnumerable<ExperionNodeMapEntry> entries, string fileName);
}
// ── Database ─────────────────────────────────────────────────────────────────
public interface IExperionDbService
{
Task<bool> InitializeAsync();
// ── TimeScaleDB 하이퍼테이블 ──────────────────────────────────────────────────
Task<HypertableStatusInfo> GetHypertableStatusAsync();
Task<HypertableCreateResult> CreateHypertableAsync(HypertableCreateRequest request);
Task<int> SaveRecordsAsync(IEnumerable<ExperionRecord> records);
Task<int> ClearRecordsAsync();
Task<int> BuildMasterFromRawAsync(bool truncate = false);
Task<IEnumerable<ExperionRecord>> GetRecordsAsync(DateTime? from = null, DateTime? to = null, int limit = 1000);
Task<int> GetTotalCountAsync();
Task<IEnumerable<string>> GetNameListAsync();
Task<NodeMapStats> GetMasterStatsAsync();
Task<NodeMapQueryResult> QueryMasterAsync(
int? minLevel, int? maxLevel, string? nodeClass,
IEnumerable<string>? names, string? nodeId, string? dataType,
int limit, int offset);
// ── RealtimeTable ─────────────────────────────────────────────────────────
Task<int> BuildRealtimeTableAsync(IEnumerable<string> names, IEnumerable<string> dataTypes);
Task<IEnumerable<RealtimePoint>> GetRealtimePointsAsync();
Task<RealtimePoint> AddRealtimePointAsync(string nodeId);
Task<bool> DeleteRealtimePointAsync(int id);
Task<int> UpdateLiveValueAsync(string nodeId, string? value, DateTime timestamp);
Task<int> BatchUpdateLiveValuesAsync(IEnumerable<LiveValueUpdate> updates);
// ── HistoryTable ──────────────────────────────────────────────────────────
Task<int> SnapshotToHistoryAsync();
Task<IEnumerable<string>> GetTagNamesAsync();
Task<HistoryQueryResult> QueryHistoryAsync(
IEnumerable<string> tagNames, DateTime? from, DateTime? to, int limit);
/// <summary>
/// 사용자 지정 간격으로 history 이력 조회
/// history_table의 기본 저장 간격(60초)을 기반으로 사용자가 요청한 간격으로 데이터 집계
/// </summary>
/// <param name="request">조회 요청 (태그명, 시간 범위, 간격 등)</param>
/// <returns>집계된 이력 데이터</returns>
Task<HistoryIntervalQueryResult> QueryHistoryWithIntervalAsync(HistoryIntervalQueryRequest request);
// ── OPC UA Server 지원 ────────────────────────────────────────────────────
/// <summary>realtime_table × node_map_master 조인 → nodeId → dataType 사전 반환</summary>
Task<IReadOnlyDictionary<string, string>> GetRealtimeNodeDataTypesAsync();
// ── FastSession ───────────────────────────────────────────────────────────────
Task<FastSession> CreateFastSessionAsync(FastSessionCreateRequest request);
Task UpdateFastSessionStatusAsync(int sessionId, string status);
Task UpdateFastSessionRowCountAsync(int sessionId, int rowCount);
Task UpdateFastSessionPinnedAsync(int sessionId, bool pinned);
Task<FastSession?> GetFastSessionAsync(int sessionId);
Task<IEnumerable<FastSession>> GetFastSessionsAsync();
Task DeleteFastSessionAsync(int sessionId);
Task<IEnumerable<FastSession>> GetExpiredFastSessionsAsync();
// ── FastRecord ────────────────────────────────────────────────────────────────
Task<FastQueryResult> GetFastRecordsAsync(int sessionId, DateTime? from, DateTime? to);
Task BatchInsertFastRecordsAsync(IEnumerable<FastRecord> records);
Task ExportFastRecordsToCsvAsync(int sessionId, Stream stream, DateTime? from, DateTime? to);
// ── Realtime → Fast 복사용 ────────────────────────────────────────────────────
/// <summary>realtime_table에서 태그명 목록으로 livevalue와 timestamp 가져오기</summary>
Task<IEnumerable<RealtimePoint>> GetRealtimeRecordsByTagNamesAsync(IEnumerable<string> tagNames);
// ── 공통 (이미 없는 경우만) ──────────────────────────────────────────────────
Task<string?> GetNodeIdByTagNameAsync(string tagName);
}
// ── Realtime Service ─────────────────────────────────────────────────────────
public interface IExperionRealtimeService
{
Task StartAsync(ExperionServerConfig cfg);
Task StopAsync();
RealtimeServiceStatus GetStatus();
/// <summary>
/// 구독 중이면 MonitoredItem 핫 추가 후 OPC UA 서버 응답 검증.
/// 구독 중이지 않으면 (true, "구독 중 아님") 반환.
/// </summary>
Task<(bool Success, string Message)> AddMonitoredItemAsync(string nodeId);
}
public record RealtimeServiceStatus(bool Running, int SubscribedCount, string Message);
// ── OPC UA Server ─────────────────────────────────────────────────────────────
public interface IExperionOpcServerService
{
Task StartServerAsync();
Task StopServerAsync();
OpcServerStatus GetStatus();
void UpdateNodeValue(string tagname, string? value, DateTime timestamp);
void RebuildAddressSpace(IEnumerable<ExperionCrawler.Core.Domain.Entities.RealtimePoint> points);
}
public record OpcServerStatus(
bool Running,
int ConnectedClientCount,
int NodeCount,
string EndpointUrl,
DateTime? StartedAt);
// ── Status Code ──────────────────────────────────────────────────────────────
public interface IExperionStatusCodeService
{
ExperionStatusCodeInfo? GetByHex(string hexCode);
ExperionStatusCodeInfo? GetByUint(uint statusCode);
int LoadedCount { get; }
}
// ── Result records ───────────────────────────────────────────────────────────
public record ExperionCertResult (bool Success, string Message, string? ThumbPrint = null);
public record ExperionCertInfo (bool Exists, string? SubjectName, DateTime? NotAfter, string? ThumbPrint, string FilePath);
// DTO는 record 타입으로 유지. DisplayName null 방지는 Infrastructure 레이어에서 처리
public record ExperionNodeInfo(string NodeId, string DisplayName, string NodeClass, bool HasChildren);
public record ExperionNodeMapEntry(int Level, string NodeClass, string DisplayName, string NodeId, string DataType);
public record ExperionConnectResult(bool Success, string Message, string? SessionId = null, string? PolicyUri = null);
public record ExperionReadResult(bool Success, string NodeId, object? Value, string StatusCode,
string? ErrorMessage = null, DateTime? Timestamp = null);
public record ExperionBrowseResult(bool Success, IEnumerable<ExperionNodeInfo> Nodes, string? ErrorMessage = null);
public record ExperionNodeMapResult(bool Success, IEnumerable<ExperionNodeMapEntry> Nodes, int TotalCount, string? ErrorMessage = null);
// DisplayName 필드는 null 방지를 위해 대체 기본값이 필요하지만,
// record는 null 불가 필드를 가질 수 없으므로 Infrastructure 레이어에서 null 대체 처리
public record NodeMapStats(int Total, int ObjectCount, int VariableCount, int MaxLevel, IEnumerable<string> DataTypes);
public record NodeMapQueryResult(int Total, IEnumerable<NodeMapMaster> Items);
public record HistoryQueryResult(IEnumerable<string> TagNames, IEnumerable<HistoryRow> Rows);
public record HistoryRow(DateTime RecordedAt, IReadOnlyDictionary<string, string?> Values);
// ── History Interval Query ─────────────────────────────────────────────────────
public record HistoryIntervalQueryRequest(
IEnumerable<string> TagNames,
DateTime? From,
DateTime? To,
string Interval,
int Limit);
public record HistoryIntervalQueryResult(
IEnumerable<string> TagNames,
IEnumerable<HistoryIntervalRow> Rows,
int BaseIntervalSeconds,
string QueryInterval);
public record HistoryIntervalRow(DateTime TimeBucket, IReadOnlyDictionary<string, string?> Values);
public record LiveValueUpdate(string NodeId, string? Value, DateTime Timestamp);
// ── fastTable DTOs ────────────────────────────────────────────────────────────
public record FastSessionInfo(
int Id,
string Name,
DateTime StartedAt,
DateTime? EndedAt,
string Status,
int SamplingMs,
int DurationSec,
string[] TagList,
int RowCount,
int? RetentionDays,
bool Pinned
);
public record FastSessionStartRequest(
string Name,
int SamplingMs,
int DurationSec,
string[] TagList,
int? RetentionDays = null
);
public record FastSessionCreateRequest(
string Name,
int SamplingMs,
int DurationSec,
string[] TagList,
int? RetentionDays = null
);
public record FastQueryResult(
int SessionId,
DateTime From,
DateTime To,
string[] TagNames,
IEnumerable<FastRecord> Items,
int TotalCount
);
public record PinRequest(bool Pinned);
// ── fastTable Service ─────────────────────────────────────────────────────────
public interface IExperionFastService
{
Task<FastSessionInfo> StartSessionAsync(FastSessionStartRequest request);
Task StopSessionAsync(int sessionId);
Task DeleteSessionAsync(int sessionId);
Task PinSessionAsync(int sessionId, bool pinned);
Task<FastSessionInfo?> GetSessionAsync(int sessionId);
Task<IEnumerable<FastSessionInfo>> GetSessionsAsync();
Task<FastQueryResult> GetRecordsAsync(int sessionId, DateTime? from, DateTime? to, string format = "long");
Task ExportCsvAsync(int sessionId, Stream stream, DateTime? from = null, DateTime? to = null);
}
// ── P&ID Extractor ─────────────────────────────────────────────────────────────
public interface IPidExtractorService
{
// 추출
Task<PidExtractionResult> ExtractFromFileAsync(string filePath, bool useImageMode = false);
Task<PidExtractionResult> ExtractFromStreamAsync(Stream stream, string fileName, bool useImageMode = false);
// 조회 (페이지네이션)
Task<(int Total, IEnumerable<PidEquipment> Items)> GetEquipmentAsync(
string? tagNo, int page, int pageSize);
Task<PidEquipment?> GetByIdAsync(long id);
// 업데이트
Task UpdateConfidenceAsync(long id, double confidence);
Task ActivateAsync(long id);
Task DeactivateAsync(long id);
// 통계
Task<int> GetTotalCountAsync();
Task<int> GetConfidenceItemsCountAsync();
Task<int> GetLowConfidenceItemsCountAsync();
Task<IDictionary<string, int>> GetConfidenceDistributionAsync();
Task<int> GetDrawingCountAsync();
// 내보내기
Task<string> ExportToCsvAsync(IEnumerable<PidEquipment> items);
Task<byte[]> ExportToExcelAsync(IEnumerable<PidEquipment> items);
}
// ── P&ID Tag Mapping ───────────────────────────────────────────────────────────
public interface ITagMappingService
{
Task<(int Total, IEnumerable<TagMappingResult> Items)> GetMappingsAsync(int page, int pageSize);
Task<TagMappingResult?> GetMappingByIdAsync(long id);
Task<TagMappingResult> CreateMappingAsync(CreateMappingRequest request);
Task UpdateMappingAsync(long id, UpdateMappingRequest request);
Task ClearMappingAsync(long id);
Task<int> GetUnmappedCountAsync();
Task<int> GetMappedCountAsync();
Task<IEnumerable<string>> GetAvailableTagsAsync();
}

View File

@@ -0,0 +1,138 @@
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
namespace ExperionCrawler.Core.Domain.Entities;
/// <summary>OPC UA 노드 태그 읽기 결과 (메모리)</summary>
public class ExperionTag
{
public int Id { get; set; }
public string NodeId { get; set; } = string.Empty;
public string TagName { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string? Value { get; set; }
public string? DataType { get; set; }
public string StatusCode { get; set; } = "Good";
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public bool IsGood => StatusCode == "Good";
}
/// <summary>크롤링으로 수집한 레코드 (DB/CSV 저장 단위)</summary>
public class ExperionRecord
{
public int Id { get; set; }
public string NodeId { get; set; } = string.Empty;
public string? Value { get; set; }
public string StatusCode { get; set; } = "Good";
public DateTime CollectedAt { get; set; } = DateTime.UtcNow;
public string? SessionId { get; set; }
}
/// <summary>Experion 서버 접속 설정</summary>
public class ExperionServerConfig
{
public int Id { get; set; }
public string ServerHostName { get; set; } = string.Empty;
public int Port { get; set; } = 4840;
public string ClientHostName { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string EndpointUrl => $"opc.tcp://{ServerHostName}:{Port}";
public string ApplicationUri => $"urn:{ClientHostName}:ExperionCrawlerClient";
}
/// <summary>OPC UA 전체 노드맵 원시 데이터 (AssetLoader binary COPY 대상)</summary>
[Table("raw_node_map")]
public class RawNodeMap
{
[Column("id")] public int Id { get; set; }
[Column("level")] public int Level { get; set; }
[Column("class")] public string Class { get; set; } = string.Empty;
[Column("name")] public string Name { get; set; } = string.Empty;
[Column("node_id")] public string NodeId { get; set; } = string.Empty;
[Column("data_type")]public string DataType { get; set; } = string.Empty;
}
/// <summary>raw_node_map 에서 빌드된 master 테이블</summary>
[Table("node_map_master")]
public class NodeMapMaster
{
[Column("id")] public int Id { get; set; }
[Column("level")] public int Level { get; set; }
[Column("class")] public string Class { get; set; } = string.Empty;
[Column("name")] public string Name { get; set; } = string.Empty;
[Column("node_id")] public string NodeId { get; set; } = string.Empty;
[Column("data_type")]public string DataType { get; set; } = string.Empty;
}
/// <summary>realtime_table — 실시간 모니터링 포인트</summary>
[Table("realtime_table")]
public class RealtimePoint
{
[Column("id")] public int Id { get; set; }
[Column("tagname")] public string TagName { get; set; } = string.Empty;
[Column("node_id")] public string NodeId { get; set; } = string.Empty;
[Column("livevalue")] public string? LiveValue { get; set; }
[Column("timestamp")] public DateTime Timestamp { get; set; } = DateTime.UtcNow;
}
/// <summary>history_table — 시계열 이력 스냅샷</summary>
[Table("history_table")]
public class HistoryRecord
{
[Column("id")] public int Id { get; set; }
[Column("tagname")] public string TagName { get; set; } = string.Empty;
[Column("node_id")] public string NodeId { get; set; } = string.Empty;
[Column("value")] public string? Value { get; set; }
[Column("recorded_at")] public DateTime RecordedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>statuscode.json 항목</summary>
public class ExperionStatusCodeInfo
{
public string Name { get; set; } = string.Empty;
public string Hex { get; set; } = string.Empty;
public ulong Decimal { get; set; }
public string Description { get; set; } = string.Empty;
}
/// <summary>P&ID 그래프 생성 작업 상태 (DB 저장용)</summary>
[Table("pid_graph_status")]
public class PidGraphStatus
{
[Column("task_id")] public string TaskId { get; set; } = string.Empty;
[Column("progress")] public double Progress { get; set; }
[Column("status")] public string Status { get; set; } = "Pending";
[Column("message")] public string Message { get; set; } = string.Empty;
[Column("updated_at")] public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>fastSession — 데이터 수집 세션 메타</summary>
[Table("fast_session")]
public class FastSession
{
[Column("id")] public int Id { get; set; }
[Column("name")] public string Name { get; set; } = string.Empty;
[Column("started_at")] public DateTime StartedAt { get; set; }
[Column("ended_at")] public DateTime? EndedAt { get; set; }
[Column("status")] public string Status { get; set; } = "Pending";
// Status 허용값: Pending / Running / Completed / Cancelled / Failed / RowLimitReached
[Column("sampling_ms")] public int SamplingMs { get; set; }
[Column("duration_sec")] public int DurationSec { get; set; }
[Column("tag_list")] public string TagList { get; set; } = "[]"; // JSONB → string[] 직렬화
[Column("row_count")] public int RowCount { get; set; }
[Column("retention_days")] public int? RetentionDays { get; set; } // null = 무한 보관
[Column("pinned")] public bool Pinned { get; set; }
}
/// <summary>fastRecord — 시계열 데이터 (Long 포맷: 태그 1행/시점)</summary>
[Table("fast_record")]
public class FastRecord
{
[NotMapped] public int Id { get; set; }
[Column("session_id")] public int SessionId { get; set; }
[Column("recorded_at")] public DateTime RecordedAt { get; set; }
[Column("tagname")] public string TagName { get; set; } = string.Empty;
[Column("value")] public string? Value { get; set; }
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,987 @@
using ExperionCrawler.Core.Application.DTOs;
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Application.Services;
using ExperionCrawler.Core.Domain.Entities;
using ExperionCrawler.Infrastructure.Csv;
using ExperionCrawler.Infrastructure.Database;
using Microsoft.AspNetCore.Mvc;
namespace ExperionCrawler.Web.Controllers;
// ── 인증서 ────────────────────────────────────────────────────────────────────
[ApiController]
[Route("api/certificate")]
public class ExperionCertificateController : ControllerBase
{
private readonly IExperionCertificateService _certSvc;
public ExperionCertificateController(IExperionCertificateService certSvc)
=> _certSvc = certSvc;
/// <summary>현재 인증서 상태 조회</summary>
[HttpGet("status")]
public IActionResult GetStatus([FromQuery] string clientHostName = "dbsvr")
{
var info = _certSvc.GetCertificateInfo(clientHostName);
return Ok(new
{
exists = info.Exists,
subjectName = info.SubjectName,
notAfter = info.NotAfter,
thumbPrint = info.ThumbPrint,
filePath = info.FilePath
});
}
/// <summary>인증서 생성 (없으면 생성, 있으면 기존 반환)</summary>
[HttpPost("create")]
public async Task<IActionResult> Create([FromBody] ExperionCertCreateDto dto)
{
var applicationUri = $"urn:{dto.ClientHostName}:ExperionCrawlerClient";
var result = await _certSvc.EnsureCertificateAsync(
applicationUri, dto.ClientHostName, dto.SubjectAltNames, dto.PfxPassword);
return Ok(new
{
success = result.Success,
message = result.Message,
thumbPrint = result.ThumbPrint
});
}
}
// ── 서버 접속 테스트 ──────────────────────────────────────────────────────────
[ApiController]
[Route("api/connection")]
public class ExperionConnectionController : ControllerBase
{
private readonly IExperionOpcClient _opcClient;
public ExperionConnectionController(IExperionOpcClient opcClient)
=> _opcClient = opcClient;
/// <summary>OPC UA 서버 접속 테스트</summary>
[HttpPost("test")]
public async Task<IActionResult> Test([FromBody] ExperionServerConfigDto dto)
{
var cfg = MapConfig(dto);
var r = await _opcClient.TestConnectionAsync(cfg);
return Ok(new { success = r.Success, message = r.Message, sessionId = r.SessionId, policyUri = r.PolicyUri });
}
/// <summary>단일 노드 태그 읽기</summary>
[HttpPost("read")]
public async Task<IActionResult> ReadTag([FromBody] ExperionReadTagRequestDto dto)
{
var cfg = MapConfig(dto.ServerConfig);
var r = await _opcClient.ReadTagAsync(cfg, dto.NodeId);
return Ok(new
{
success = r.Success,
nodeId = r.NodeId,
value = r.Value?.ToString(),
statusCode = r.StatusCode,
timestamp = r.Timestamp,
error = r.ErrorMessage
});
}
/// <summary>노드 탐색 (ObjectsFolder 기준)</summary>
[HttpPost("browse")]
public async Task<IActionResult> Browse([FromBody] ExperionBrowseRequestDto dto)
{
var cfg = MapConfig(dto.ServerConfig);
var r = await _opcClient.BrowseNodesAsync(cfg, dto.StartNodeId);
return Ok(new {
success = r.Success,
error = r.ErrorMessage,
nodes = r.Nodes.Select(n => new {
nodeId = n.NodeId,
displayName = n.DisplayName,
nodeClass = n.NodeClass,
hasChildren = n.HasChildren
})
});
}
private static ExperionServerConfig MapConfig(ExperionServerConfigDto dto) => new()
{
ServerHostName = dto.ServerHostName,
Port = dto.Port,
ClientHostName = dto.ClientHostName,
UserName = dto.UserName,
Password = dto.Password
};
}
// ── 크롤링 ────────────────────────────────────────────────────────────────────
[ApiController]
[Route("api/crawl")]
public class ExperionCrawlController : ControllerBase
{
private readonly ExperionCrawlService _crawlSvc;
public ExperionCrawlController(ExperionCrawlService crawlSvc)
=> _crawlSvc = crawlSvc;
/// <summary>전체 노드맵 크롤 (재귀 탐색 → CSV 저장)</summary>
[HttpPost("nodemap")]
public async Task<IActionResult> NodeMap(
[FromBody] ExperionNodeMapCrawlRequestDto dto,
CancellationToken ct)
{
var cfg = new ExperionServerConfig
{
ServerHostName = dto.ServerConfig.ServerHostName,
Port = dto.ServerConfig.Port,
ClientHostName = dto.ServerConfig.ClientHostName,
UserName = dto.ServerConfig.UserName,
Password = dto.ServerConfig.Password
};
var result = await _crawlSvc.RunNodeMapCrawlAsync(cfg, dto.MaxDepth, ct);
return Ok(new
{
success = result.Success,
totalCount = result.TotalCount,
csvPath = result.CsvPath,
error = result.ErrorMessage
});
}
/// <summary>크롤링 시작 (동기식 완료 후 결과 반환)</summary>
[HttpPost("start")]
public async Task<IActionResult> Start(
[FromBody] ExperionCrawlRequestDto dto,
CancellationToken ct)
{
var cfg = new ExperionServerConfig
{
ServerHostName = dto.ServerConfig.ServerHostName,
Port = dto.ServerConfig.Port,
ClientHostName = dto.ServerConfig.ClientHostName,
UserName = dto.ServerConfig.UserName,
Password = dto.ServerConfig.Password
};
var result = await _crawlSvc.RunCrawlAsync(
cfg, dto.NodeIds, dto.IntervalSeconds, dto.DurationSeconds, ct);
return Ok(new
{
success = true,
sessionId = result.SessionId,
totalRecords = result.TotalRecords,
csvPath = result.CsvPath,
preview = result.Records.Take(5).Select(r => new
{
r.NodeId, r.Value, r.StatusCode,
collectedAt = r.CollectedAt
})
});
}
}
// ── DB 저장 ───────────────────────────────────────────────────────────────────
[ApiController]
[Route("api/database")]
public class ExperionDatabaseController : ControllerBase
{
private readonly ExperionCrawlService _crawlSvc;
private readonly IExperionDbService _dbSvc;
private readonly IExperionCsvService _csvSvc;
private readonly AssetLoader _assetLoader;
public ExperionDatabaseController(
ExperionCrawlService crawlSvc,
IExperionDbService dbSvc,
IExperionCsvService csvSvc,
AssetLoader assetLoader)
{
_crawlSvc = crawlSvc;
_dbSvc = dbSvc;
_csvSvc = csvSvc;
_assetLoader = assetLoader;
}
/// <summary>수집된 CSV 파일 목록</summary>
[HttpGet("files")]
public IActionResult GetCsvFiles()
=> Ok(new { files = _csvSvc.GetAvailableFiles() });
/// <summary>CSV → DB 임포트 (ServerHostName 지정 시 → raw_node_map, 그 외 → ExperionRecords)</summary>
[HttpPost("import")]
public async Task<IActionResult> Import([FromBody] ExperionCsvImportDto dto)
{
// 경계 문자 및 경로 조작 방지: 파일명에 점, 역슬래시, 슬래시, 공백 제거
var safeFileName = dto.FileName.Trim();
var invalidChars = new char[] { '.', '\\', '/', ' ', '\t', '\n', '\r' };
if (string.IsNullOrEmpty(safeFileName) || invalidChars.Any(c => safeFileName.Contains(c)))
{
return BadRequest(new { error = "파일명에 허용되지 않는 문자가 포함되었습니다." });
}
if (!string.IsNullOrEmpty(dto.ServerHostName) &&
safeFileName.StartsWith(dto.ServerHostName, StringComparison.OrdinalIgnoreCase))
{
try
{
var fullPath = Path.Combine("data/csv", dto.FileName);
var rawCount = await _assetLoader.ImportFullMapAsync(fullPath, dto.Truncate);
var masterCount = await _dbSvc.BuildMasterFromRawAsync(dto.Truncate);
return Ok(new
{
success = true,
count = rawCount,
masterCount,
message = $"{rawCount}개 노드 → raw_node_map 적재, {masterCount}개 → node_map_master 빌드 완료"
});
}
catch (Exception ex)
{
return Ok(new { success = false, count = 0, masterCount = 0, message = $"오류: {ex.Message}" });
}
}
var result = await _crawlSvc.ImportCsvToDbAsync(dto.FileName, dto.Truncate);
return Ok(new { success = result.Success, count = result.Count, message = result.Message });
}
/// <summary>DB 레코드 조회</summary>
[HttpGet("records")]
public async Task<IActionResult> GetRecords(
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] int limit = 100)
{
var records = await _dbSvc.GetRecordsAsync(from, to, limit);
var total = await _dbSvc.GetTotalCountAsync();
return Ok(new { total, records });
}
}
// ── 포인트빌더 ────────────────────────────────────────────────────────────────
[ApiController]
[Route("api/pointbuilder")]
public class ExperionPointBuilderController : ControllerBase
{
private readonly IExperionDbService _dbSvc;
private readonly IExperionRealtimeService _realtimeSvc;
public ExperionPointBuilderController(
IExperionDbService dbSvc,
IExperionRealtimeService realtimeSvc)
{
_dbSvc = dbSvc;
_realtimeSvc = realtimeSvc;
}
/// <summary>node_map_master → realtime_table 빌드 (기존 데이터 전체 교체)</summary>
[HttpPost("build")]
public async Task<IActionResult> Build([FromBody] PointBuilderBuildDto dto)
{
var count = await _dbSvc.BuildRealtimeTableAsync(dto.Names, dto.DataTypes);
return Ok(new { success = true, count, message = $"{count}개 포인트 생성 완료" });
}
/// <summary>realtime_table 전체 조회</summary>
[HttpGet("points")]
public async Task<IActionResult> GetPoints()
{
var points = await _dbSvc.GetRealtimePointsAsync();
return Ok(new
{
total = points.Count(),
items = points.Select(p => new
{
id = p.Id,
tagName = p.TagName,
nodeId = p.NodeId,
liveValue = p.LiveValue,
timestamp = p.Timestamp
})
});
}
/// <summary>node_id 를 직접 입력해서 수동 추가</summary>
[HttpPost("add")]
public async Task<IActionResult> Add([FromBody] PointBuilderAddDto dto)
{
if (string.IsNullOrWhiteSpace(dto.NodeId))
return BadRequest(new { success = false, message = "node_id 는 필수입니다." });
var nodeId = dto.NodeId.Trim();
// DB에 먼저 추가
var point = await _dbSvc.AddRealtimePointAsync(nodeId);
// 구독 중이면 OPC UA 서버에 핫 추가 및 node_id 유효성 검증
var (ok, msg) = await _realtimeSvc.AddMonitoredItemAsync(nodeId);
if (!ok)
{
// OPC UA 서버가 거부 → DB 롤백
await _dbSvc.DeleteRealtimePointAsync(point.Id);
return BadRequest(new { success = false, message = msg });
}
return Ok(new { success = true, message = msg, point = new { id = point.Id, tagName = point.TagName, nodeId = point.NodeId } });
}
/// <summary>포인트 삭제</summary>
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
var deleted = await _dbSvc.DeleteRealtimePointAsync(id);
return Ok(new { success = deleted, message = deleted ? "삭제 완료" : "포인트를 찾을 수 없습니다." });
}
}
// ── 실시간 서비스 ─────────────────────────────────────────────────────────────
[ApiController]
[Route("api/realtime")]
public class ExperionRealtimeController : ControllerBase
{
private readonly IExperionRealtimeService _rtSvc;
public ExperionRealtimeController(IExperionRealtimeService rtSvc) => _rtSvc = rtSvc;
[HttpPost("start")]
public async Task<IActionResult> Start([FromBody] ExperionServerConfigDto dto)
{
var cfg = MapConfig(dto);
await _rtSvc.StartAsync(cfg);
return Ok(new { success = true, message = "실시간 구독 시작" });
}
[HttpPost("stop")]
public async Task<IActionResult> Stop()
{
await _rtSvc.StopAsync();
return Ok(new { success = true, message = "실시간 구독 중지" });
}
[HttpGet("status")]
public IActionResult Status()
{
var s = _rtSvc.GetStatus();
return Ok(new { running = s.Running, subscribedCount = s.SubscribedCount, message = s.Message });
}
private static ExperionServerConfig MapConfig(ExperionServerConfigDto dto) => new()
{
ServerHostName = dto.ServerHostName,
Port = dto.Port,
ClientHostName = dto.ClientHostName,
UserName = dto.UserName,
Password = dto.Password
};
}
// ── 이력 조회 ─────────────────────────────────────────────────────────────────
[ApiController]
[Route("api/history")]
public class ExperionHistoryController : ControllerBase
{
private readonly IExperionDbService _dbSvc;
private readonly ILogger<ExperionHistoryController> _logger;
public ExperionHistoryController(IExperionDbService dbSvc, ILogger<ExperionHistoryController> logger)
{
_dbSvc = dbSvc;
_logger = logger;
}
/// <summary>realtime_table 의 tagname 목록</summary>
[HttpGet("tagnames")]
public async Task<IActionResult> TagNames()
{
try
{
_logger.LogDebug("[History] tagname 목록 조회 시작");
var names = await _dbSvc.GetTagNamesAsync();
var count = names.Count();
_logger.LogDebug("[History] tagname 목록 조회 완료: {Count}개 태그", count);
return Ok(new { tagNames = names });
}
catch (Exception ex)
{
_logger.LogError(ex, "[History] tagname 목록 조회 실패");
return StatusCode(500, new { success = false, message = $"tagname 조회 실패: {ex.Message}" });
}
}
/// <summary>이력 조회 (tagname 다중, 시간범위, limit)</summary>
[HttpGet("query")]
public async Task<IActionResult> Query(
[FromQuery] List<string>? tagNames,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] int limit = 1000)
{
try
{
var tagList = tagNames ?? Enumerable.Empty<string>().ToList();
_logger.LogDebug("[History] 이력 조회 시작 - 태그 수: {TagCount}, FROM: {From}, TO: {To}, LIMIT: {Limit}",
tagList.Count(), from, to, limit);
var result = await _dbSvc.QueryHistoryAsync(
tagList, from, to, limit);
_logger.LogDebug("[History] 이력 조회 완료: {RowCount}행, {TagCount}태그",
result.Rows.Count(), result.TagNames.Count());
return Ok(new
{
tagNames = result.TagNames,
rows = result.Rows.Select(r => new
{
recordedAt = r.RecordedAt,
values = r.Values
})
});
}
catch (Exception ex)
{
_logger.LogError(ex, "[History] 이력 조회 실패 - tagNames: {@TagNames}, from: {From}, to: {To}",
tagNames, from, to);
return StatusCode(500, new
{
success = false,
message = $"이력 조회 실패: {ex.Message}",
detail = ex.InnerException?.Message ?? ex.StackTrace
});
}
}
}
// ── OPC UA 서버 ───────────────────────────────────────────────────────────────
[ApiController]
[Route("api/opcserver")]
public class ExperionOpcServerController : ControllerBase
{
private readonly IExperionOpcServerService _svc;
private readonly IExperionDbService _db;
public ExperionOpcServerController(
IExperionOpcServerService svc, IExperionDbService db)
{
_svc = svc;
_db = db;
}
/// <summary>OPC UA 서버 상태 조회</summary>
[HttpGet("status")]
public IActionResult Status()
{
var s = _svc.GetStatus();
return Ok(new
{
running = s.Running,
connectedClientCount = s.ConnectedClientCount,
nodeCount = s.NodeCount,
endpointUrl = s.EndpointUrl,
startedAt = s.StartedAt
});
}
/// <summary>OPC UA 서버 시작 (자동 재시작 플래그 저장)</summary>
[HttpPost("start")]
public async Task<IActionResult> Start()
{
try
{
await _svc.StartServerAsync();
return Ok(new { success = true, message = "OPC UA 서버 시작 완료" });
}
catch (Exception ex)
{
return StatusCode(500, new { success = false, message = ex.Message });
}
}
/// <summary>OPC UA 서버 중지 (자동 재시작 플래그 삭제)</summary>
[HttpPost("stop")]
public async Task<IActionResult> Stop()
{
await _svc.StopServerAsync();
return Ok(new { success = true, message = "OPC UA 서버 중지 완료" });
}
/// <summary>주소 공간 재구성 (포인트빌더 빌드 후 호출)</summary>
[HttpPost("rebuild")]
public async Task<IActionResult> Rebuild()
{
var points = (await _db.GetRealtimePointsAsync()).ToList();
_svc.RebuildAddressSpace(points);
return Ok(new { success = true, nodeCount = points.Count });
}
}
// ── 노드맵 대시보드 ───────────────────────────────────────────────────────────
[ApiController]
[Route("api/nodemap")]
public class ExperionNodeMapController : ControllerBase
{
private readonly IExperionDbService _dbSvc;
public ExperionNodeMapController(IExperionDbService dbSvc) => _dbSvc = dbSvc;
/// <summary>node_map_master 의 name 컬럼 고유값 목록</summary>
[HttpGet("names")]
public async Task<IActionResult> Names()
{
var names = await _dbSvc.GetNameListAsync();
return Ok(new { names });
}
/// <summary>node_map_master 통계 (총 수, 클래스별, 최대 레벨, 데이터타입 목록)</summary>
[HttpGet("stats")]
public async Task<IActionResult> Stats()
{
var s = await _dbSvc.GetMasterStatsAsync();
return Ok(new
{
total = s.Total,
objectCount = s.ObjectCount,
variableCount = s.VariableCount,
maxLevel = s.MaxLevel,
dataTypes = s.DataTypes
});
}
/// <summary>node_map_master 필터 조회 (페이지네이션 포함, names OR 조건)</summary>
[HttpGet("query")]
public async Task<IActionResult> Query(
[FromQuery] int? minLevel,
[FromQuery] int? maxLevel,
[FromQuery] string? nodeClass,
[FromQuery] List<string>? names,
[FromQuery] string? nodeId,
[FromQuery] string? dataType,
[FromQuery] int limit = 100,
[FromQuery] int offset = 0)
{
var r = await _dbSvc.QueryMasterAsync(
minLevel, maxLevel, nodeClass, names, nodeId, dataType, limit, offset);
return Ok(new
{
total = r.Total,
items = r.Items.Select(x => new
{
id = x.Id,
level = x.Level,
@class = x.Class,
name = x.Name,
nodeId = x.NodeId,
dataType = x.DataType
})
});
}
}
// ── 하이퍼테이블 관리 ───────────────────────────────────────────────────────────
[ApiController]
[Route("api/experion/hypertable")]
public class ExperionHypertableController : ControllerBase
{
private readonly IExperionDbService _dbSvc;
public ExperionHypertableController(IExperionDbService dbSvc) => _dbSvc = dbSvc;
/// <summary>하이퍼테이블 상태 조회</summary>
[HttpGet("status")]
public async Task<IActionResult> GetStatus()
{
var status = await _dbSvc.GetHypertableStatusAsync();
return Ok(new
{
isHypertable = status.IsHypertable,
tableName = status.TableName,
statusMessage = status.StatusMessage,
recordCount = status.RecordCount,
hasRetentionPolicy = status.HasRetentionPolicy,
hasCompression = status.HasCompression,
hasContinuousAggregate = status.HasContinuousAggregate
});
}
private static readonly System.Text.RegularExpressions.Regex _pgIdentifier =
new(@"^[a-z_][a-z0-9_]{0,62}$", System.Text.RegularExpressions.RegexOptions.Compiled);
private static readonly HashSet<string> _allowedTables =
new(StringComparer.OrdinalIgnoreCase) { "history_table" };
/// <summary>하이퍼테이블 수동 생성</summary>
[HttpPost("create")]
public async Task<IActionResult> Create([FromBody] HypertableCreateDto request)
{
var tableName = request.TableName ?? "history_table";
var timeColumn = request.TimeColumn ?? "recorded_at";
if (!_allowedTables.Contains(tableName))
return BadRequest(new { success = false, message = $"허용되지 않는 테이블명: {tableName}" });
if (!_pgIdentifier.IsMatch(tableName) || !_pgIdentifier.IsMatch(timeColumn))
return BadRequest(new { success = false, message = "테이블명/컬럼명은 영문 소문자, 숫자, 언더스코어만 허용됩니다." });
var createRequest = new HypertableCreateRequest
{
TableName = tableName,
TimeColumn = timeColumn,
TimeInterval = request.TimeInterval ?? "1 day",
MigrateData = request.MigrateData,
SetRetentionPolicy = request.SetRetentionPolicy,
RetentionPeriod = request.RetentionPeriod ?? "90 days",
EnableCompression = request.EnableCompression,
CompressionPeriod = request.CompressionPeriod ?? "1 day",
CreateContinuousAggregate = request.CreateContinuousAggregate
};
var result = await _dbSvc.CreateHypertableAsync(createRequest);
return result.Success
? Ok(new { result.Success, result.Message, result.TableName })
: (IActionResult)(result.TableName != null
? StatusCode(409, new { result.Success, result.Message })
: StatusCode(500, new { result.Success, result.Message }));
}
}
// ── FastTable / FastRecord ────────────────────────────────────────────────────
[ApiController]
[Route("api/fast")]
public class ExperionFastController : ControllerBase
{
private readonly IExperionFastService _fastSvc;
public ExperionFastController(IExperionFastService fastSvc)
=> _fastSvc = fastSvc;
/// <summary>새 fastSession 시작</summary>
[HttpPost("start")]
public async Task<IActionResult> Start([FromBody] FastSessionStartRequest request)
{
try
{
var session = await _fastSvc.StartSessionAsync(request);
return Ok(new { id = session.Id, name = session.Name, status = session.Status, startedAt = session.StartedAt });
}
catch (ArgumentException ex) { return BadRequest(new { error = ex.Message }); }
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
catch (Exception ex)
{
var msgs = new List<string>();
for (var e = ex; e != null; e = e.InnerException) msgs.Add(e.Message);
return StatusCode(500, new { error = msgs[0], detail = string.Join(" → ", msgs.Skip(1)) });
}
}
/// <summary>세션 중지</summary>
[HttpPost("{id:int}/stop")]
public async Task<IActionResult> Stop(int id)
{
try
{
await _fastSvc.StopSessionAsync(id);
return Ok(new { success = true, message = "세션이 중지되었습니다." });
}
catch (InvalidOperationException ex) { return NotFound(new { error = ex.Message }); }
}
/// <summary>세션 목록 조회</summary>
[HttpGet("sessions")]
public async Task<IActionResult> GetSessions()
{
var sessions = await _fastSvc.GetSessionsAsync();
return Ok(new
{
total = sessions.Count(),
items = sessions.Select(s => new
{
id = s.Id,
name = s.Name,
status = s.Status,
samplingMs = s.SamplingMs,
durationSec = s.DurationSec,
tagCount = s.TagList.Length,
rowCount = s.RowCount,
startedAt = s.StartedAt,
endedAt = s.EndedAt,
retentionDays = s.RetentionDays,
pinned = s.Pinned
})
});
}
/// <summary>세션 상세 정보</summary>
[HttpGet("{id:int}")]
public async Task<IActionResult> GetSession(int id)
{
var session = await _fastSvc.GetSessionAsync(id);
if (session == null) return NotFound();
return Ok(new
{
id = session.Id,
name = session.Name,
status = session.Status,
samplingMs = session.SamplingMs,
durationSec = session.DurationSec,
tagList = session.TagList,
rowCount = session.RowCount,
startedAt = session.StartedAt,
endedAt = session.EndedAt,
retentionDays = session.RetentionDays,
pinned = session.Pinned
});
}
/// <summary>레코드 조회 (Long 포맷)</summary>
[HttpGet("{id:int}/records")]
public async Task<IActionResult> GetRecords(int id,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] string format = "long")
{
var result = await _fastSvc.GetRecordsAsync(id, from, to, format);
return Ok(new
{
sessionId = result.SessionId,
from = result.From,
to = result.To,
tagNames = result.TagNames,
total = result.TotalCount,
items = result.Items.Select(r => new
{
sessionId = r.SessionId,
recordedAt = r.RecordedAt,
tagName = r.TagName,
value = r.Value
})
});
}
/// <summary>CSV Export (스트리밍)</summary>
[HttpGet("{id:int}/csv")]
public async Task<IActionResult> ExportCsv(int id,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to)
{
var ms = new MemoryStream();
await _fastSvc.ExportCsvAsync(id, ms, from, to);
ms.Position = 0;
return File(ms, "text/csv", $"fast-{id}-{DateTime.Now:yyyyMMddHHmm}.csv");
}
/// <summary>세션 삭제</summary>
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
try
{
await _fastSvc.DeleteSessionAsync(id);
return Ok(new { success = true, message = "세션이 삭제되었습니다." });
}
catch (InvalidOperationException ex) { return NotFound(new { error = ex.Message }); }
}
/// <summary>세션 고정/해제</summary>
[HttpPost("{id:int}/pin")]
public async Task<IActionResult> Pin(int id, [FromBody] PinRequest request)
{
try
{
await _fastSvc.PinSessionAsync(id, request.Pinned);
return Ok(new { success = true, pinned = request.Pinned });
}
catch (InvalidOperationException ex) { return NotFound(new { error = ex.Message }); }
}
}
// ── P&ID Controller ────────────────────────────────────────────────────────────
[ApiController]
[Route("api/pid")]
public class ExperionPidController : ControllerBase
{
private readonly IPidExtractorService _extractor;
private readonly ITagMappingService _mapping;
public ExperionPidController(IPidExtractorService extractor, ITagMappingService mapping)
{
_extractor = extractor;
_mapping = mapping;
}
[HttpPost("extract")]
[RequestSizeLimit(100 * 1024 * 1024)]
public async Task<IActionResult> Extract(IFormFile file)
{
if (file == null || file.Length == 0)
return BadRequest(new { error = "파일 없음" });
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (ext != ".dxf" && ext != ".pdf")
return BadRequest(new { error = "지원 형식: .dxf .pdf" });
using var stream = file.OpenReadStream();
var result = await _extractor.ExtractFromStreamAsync(stream, file.FileName);
return Ok(new
{
totalCount = result.TotalCount,
confidenceItems = result.ConfidenceItems,
lowConfidenceItems = result.LowConfidenceItems
});
}
[HttpGet("equipment")]
public async Task<IActionResult> GetEquipment(
[FromQuery] string? tagNo, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
{
var (total, items) = await _extractor.GetEquipmentAsync(tagNo, page, pageSize);
return Ok(new
{
total = total,
page = page,
pageSize = pageSize,
items = items.Select(x => new
{
id = x.Id,
tagName = x.TagNo,
equipmentName = x.EquipmentName,
instrumentType = x.InstrumentType,
lineNumber = x.LineNumber,
pidDrawingNo = x.PidDrawingNo,
confidence = x.Confidence,
isActive = x.IsActive,
extractedAt = x.ExtractedAt,
updatedAt = x.UpdatedAt,
experionTagId = x.ExperionTagId,
experionTagName = x.ExperionTag?.TagName
})
});
}
[HttpGet("statistics")]
public async Task<IActionResult> GetStatistics()
{
return Ok(new
{
total = await _extractor.GetTotalCountAsync(),
highConfidence = await _extractor.GetConfidenceItemsCountAsync(),
lowConfidence = await _extractor.GetLowConfidenceItemsCountAsync(),
drawingCount = await _extractor.GetDrawingCountAsync(),
mapped = await _mapping.GetMappedCountAsync(),
unmapped = await _mapping.GetUnmappedCountAsync(),
distribution = await _extractor.GetConfidenceDistributionAsync()
});
}
[HttpPut("{id:long}/confidence")]
public async Task<IActionResult> UpdateConfidence(long id, [FromBody] double confidence)
{
if (confidence < 0 || confidence > 1)
return BadRequest(new { error = "신뢰도는 0~1 범위" });
await _extractor.UpdateConfidenceAsync(id, confidence);
return Ok(new { message = "업데이트 완료" });
}
[HttpPost("{id:long}/activate")]
public async Task<IActionResult> Activate(long id)
{
await _extractor.ActivateAsync(id);
return Ok(new { message = "활성화 완료" });
}
[HttpPost("{id:long}/deactivate")]
public async Task<IActionResult> Deactivate(long id)
{
await _extractor.DeactivateAsync(id);
return Ok(new { message = "비활성화 완료" });
}
[HttpGet("mappings")]
public async Task<IActionResult> GetMappings([FromQuery] int page = 1, [FromQuery] int pageSize = 50)
{
var (total, items) = await _mapping.GetMappingsAsync(page, pageSize);
return Ok(new
{
total = total,
page = page,
pageSize = pageSize,
items = items.Select(x => new
{
id = x.PidEquipmentId,
tagName = x.TagNo,
equipmentName = x.EquipmentName,
confidence = x.Confidence,
experionTagId = x.ExperionTagId,
experionTagName = x.ExperionTagName
})
});
}
[HttpPost("mappings")]
public async Task<IActionResult> CreateMapping([FromBody] CreateMappingRequest req)
{
var result = await _mapping.CreateMappingAsync(req);
return Ok(new
{
success = true,
id = result.PidEquipmentId,
tagName = result.TagNo,
equipmentName = result.EquipmentName,
confidence = result.Confidence,
experionTagId = result.ExperionTagId,
experionTagName = result.ExperionTagName
});
}
[HttpPut("mappings/{id:long}")]
public async Task<IActionResult> UpdateMapping(long id, [FromBody] UpdateMappingRequest req)
{
await _mapping.UpdateMappingAsync(id, req);
return Ok(new { message = "매핑 업데이트 완료" });
}
[HttpDelete("mappings/{id:long}")]
public async Task<IActionResult> ClearMapping(long id)
{
await _mapping.ClearMappingAsync(id);
return Ok(new { message = "매핑 해제 완료" });
}
[HttpGet("mappings/available-tags")]
public async Task<IActionResult> GetAvailableTags()
{
var tags = await _mapping.GetAvailableTagsAsync();
return Ok(new { tags = tags });
}
[HttpGet("export/csv")]
public async Task<IActionResult> ExportCsv([FromQuery] string? tagNo)
{
var (_, items) = await _extractor.GetEquipmentAsync(tagNo, 1, int.MaxValue);
var csv = await _extractor.ExportToCsvAsync(items);
var bytes = System.Text.Encoding.UTF8.GetBytes(csv);
return File(bytes, "text/csv", $"pid-equipment-{DateTime.Now:yyyyMMdd}.csv");
}
[HttpGet("export/excel")]
public async Task<IActionResult> ExportExcel([FromQuery] string? tagNo)
{
var (_, items) = await _extractor.GetEquipmentAsync(tagNo, 1, int.MaxValue);
var excelBytes = await _extractor.ExportToExcelAsync(items);
return File(excelBytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", $"pid-equipment-{DateTime.Now:yyyyMMdd}.xlsx");
}
}
public record PinRequest(bool Pinned);

View File

@@ -0,0 +1,155 @@
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Application.Services;
using ExperionCrawler.Infrastructure.Certificates;
using ExperionCrawler.Infrastructure.Csv;
using ExperionCrawler.Infrastructure.Database;
using ExperionCrawler.Infrastructure.Mcp;
using ExperionCrawler.Infrastructure.OpcUa;
using ExperionCrawler.Web;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// ── MVC / Swagger ─────────────────────────────────────────────────────────────
var mvcBuilder = builder.Services.AddControllers()
.AddJsonOptions(opt =>
{
// JSON 직렬화 시 대소문자 구분 없이 처리하도록 PascalCase 유지
opt.JsonSerializerOptions.PropertyNamingPolicy = null;
});
// ── P&ID 컨트롤러 조건부 활성화 (기본: 비활성화) ─────────────────────────────
// PidControllers:Enabled = true 로 설정 시 P&ID 관련 컨트롤러 활성화
bool pidEnabled = builder.Configuration.GetValue<bool>("PidControllers:Enabled");
if (!pidEnabled)
{
var partManager = mvcBuilder.PartManager;
var excludedNames = new[] { "PidController", "ExperionPidController", "PidGraphController" };
var existingProvider = partManager.FeatureProviders
.OfType<Microsoft.AspNetCore.Mvc.Controllers.ControllerFeatureProvider>()
.FirstOrDefault();
if (existingProvider != null)
{
partManager.FeatureProviders.Remove(existingProvider);
partManager.FeatureProviders.Add(new ExcludedControllersFeatureProvider(existingProvider, excludedNames));
}
}
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
c.SwaggerDoc("v1", new() { Title = "ExperionCrawler API", Version = "v1" }));
// ── Infrastructure ────────────────────────────────────────────────────────────
builder.Services.AddSingleton<IExperionCertificateService, ExperionCertificateService>();
builder.Services.AddSingleton<IExperionStatusCodeService, ExperionStatusCodeService>();
builder.Services.AddSingleton<IOpcUaConfigProvider, OpcUaConfigProvider>();
builder.Services.AddScoped<IExperionOpcClient, ExperionOpcClient>();
builder.Services.AddScoped<IExperionCsvService, ExperionCsvService>();
builder.Services.AddScoped<AssetLoader>();
// PostgreSQL Ubuntu 서버에서 별도 설치 없이 동작
Directory.CreateDirectory("data");
builder.Services.AddDbContext<ExperionDbContext>(opt =>
opt.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped<IExperionDbService, ExperionDbService>();
// ── Application Services ──────────────────────────────────────────────────────
builder.Services.AddScoped<ExperionCrawlService>();
// ── KST 시간대 관리 서비스 ──────────────────────────────────────────────────
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddSingleton<KstClock>();
// ── 한글 시간 범위 추출기 ──────────────────────────────────────────────────
builder.Services.AddSingleton<KoreanTimeRangeExtractor>();
// ── Text-to-SQL Service ──────────────────────────────────────────────────────
builder.Services.AddSingleton<SqlValidatorOptions>(_ => new SqlValidatorOptions
{
RequiredTables = ["history_table"],
AllowedTables = ["history_table", "node_map_master"],
MaxSubqueryDepth = 4
});
builder.Services.AddSingleton<SqlValidator>();
builder.Services.AddScoped<ITextToSqlService, TextToSqlService>();
// ── Realtime & History BackgroundServices ─────────────────────────────────────
builder.Services.AddSingleton<ExperionRealtimeService>();
builder.Services.AddSingleton<IExperionRealtimeService>(
sp => sp.GetRequiredService<ExperionRealtimeService>());
builder.Services.AddHostedService(
sp => sp.GetRequiredService<ExperionRealtimeService>());
builder.Services.AddHostedService<ExperionHistoryService>();
// ── MCP Service ───────────────────────────────────────────────────────────────
// Python MCP 서버 (localhost:5001)와 통신
// McpClient: 저수준 HTTP 클라이언트 / McpService: IMcpService 구현 (McpClient 위임)
builder.Services.AddSingleton<McpClient>();
builder.Services.AddSingleton<IMcpService, McpService>();
builder.Services.AddHostedService<McpServerHostedService>();
// ── OPC UA Server BackgroundService ──────────────────────────────────────────
builder.Services.AddSingleton<ExperionOpcServerService>();
builder.Services.AddSingleton<IExperionOpcServerService>(
sp => sp.GetRequiredService<ExperionOpcServerService>());
builder.Services.AddHostedService(
sp => sp.GetRequiredService<ExperionOpcServerService>());
// ── FastTable Service ─────────────────────────────────────────────────────────
// 중요: Singleton으로 하나만 생성 후 IExperionFastService와 IHostedService 양쪽에 같은 인스턴스 공유
builder.Services.AddSingleton<ExperionFastService>();
builder.Services.AddSingleton<IExperionFastService>(sp => sp.GetRequiredService<ExperionFastService>());
builder.Services.AddHostedService(sp => sp.GetRequiredService<ExperionFastService>());
// ── P&ID Services ───────────────────────────────────────────────────────────────
builder.Services.AddScoped<IPidExtractorService, PidExtractorService>();
builder.Services.AddScoped<ITagMappingService, TagMappingService>();
builder.Services.AddScoped<IPidGraphService, PidGraphService>();
builder.Services.AddDbContextFactory<ExperionDbContext>();
builder.Services.AddScoped<IStatusStore, DbStatusStore>();
builder.Services.AddSingleton<IPidGraphEventBroadcaster, PidGraphEventBroadcaster>();
// ── FastTable Cleanup Service ─────────────────────────────────────────────────
builder.Services.AddHostedService<ExperionFastCleanupService>();
// ── CORS ──────────────────────────────────────────────────────────────────────
builder.Services.AddCors(opt =>
opt.AddDefaultPolicy(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()));
// ── 포트 설정 (Ubuntu 환경: 기본 5000) ───────────────────────────────────────
builder.WebHost.UseUrls("http://0.0.0.0:5000");
var app = builder.Build();
// ── DB 초기화 ─────────────────────────────────────────────────────────────────
try
{
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
await db.InitializeAsync();
}
}
catch (Exception ex)
{
// DB 초기화 실패 시 앱 시작 계속 — 기능 사용 시 지연 초기화
var logger = app.Services.GetRequiredService<ILogger<Program>>();
logger.LogWarning(ex, "[DB] 초기화 실패 — DB 관련 기능 비활성화 (앱 시작 계속)");
}
// ── Middleware ────────────────────────────────────────────────────────────────
app.UseCors();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseDefaultFiles(); // index.html
app.UseStaticFiles(); // wwwroot/
app.MapControllers();
app.MapFallbackToFile("index.html");
app.Run();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,101 @@
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Domain.Entities;
using ExperionCrawler.Infrastructure.Database;
using Microsoft.Extensions.Logging;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace ExperionCrawler.Infrastructure.OpcUa;
/// <summary>
/// 메타데이터(desc, area, state0~7descriptor)를 OPC UA에서 읽어서 tag_metadata 테이블에 저장/갱신
/// </summary>
public class MetadataLoaderService : IMetadataLoaderService
{
private readonly IExperionOpcClient _opcClient;
private readonly ExperionDbContext _ctx;
private readonly ILogger<MetadataLoaderService> _logger;
// 로드할 메타데이터 속성 목록
private static readonly string[] MetaAttributes =
{
"desc", "area",
"state0descriptor", "state1descriptor", "state2descriptor",
"state3descriptor", "state4descriptor", "state5descriptor",
"state6descriptor", "state7descriptor"
};
public MetadataLoaderService(
IExperionOpcClient opcClient,
ExperionDbContext ctx,
ILogger<MetadataLoaderService> logger)
{
_opcClient = opcClient;
_ctx = ctx;
_logger = logger;
}
public async Task<int> LoadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string> baseTags)
{
var baseTagList = baseTags.ToList(); // 이중 열거 방지
// ── Step 1: 모든 노드 ID 수집 ──────────────────────────────────────
var nodeMap = new Dictionary<string, (string baseTag, string attr)>();
foreach (var baseTag in baseTagList)
{
foreach (var attr in MetaAttributes)
{
var nodeId = $"{cfg.ServerHostName}:{baseTag}.{attr}";
var fullNodeId = $"ns=1;s={nodeId}";
nodeMap[fullNodeId] = (baseTag.ToLowerInvariant(), attr);
}
}
// ── Step 2: 배치 읽기 (ReadTagsAsync 사용) ────────────────────────
var results = await _opcClient.ReadTagsAsync(cfg, nodeMap.Keys);
var entries = new List<(string baseTag, string attr, string? value, string nodeId)>();
foreach (var result in results)
{
if (result.Success && result.Value != null && nodeMap.TryGetValue(result.NodeId, out var meta))
{
entries.Add((meta.baseTag, meta.attr, result.Value?.ToString(), result.NodeId));
}
}
// ── Step 3: 단일 배치 UPSERT ──────────────────────────────────────
if (entries.Count > 0)
{
// VALUES 절을 동적으로 생성하여 한 번에 INSERT
// CTE 컬럼(5개)과 VALUES 값(5개) 일치: base_tag, attribute, value, node_id, loaded_at
var valuesSql = string.Join(", ", entries.Select((e, i) =>
$"(@bt{i}, @attr{i}, @val{i}, @nid{i}, NOW())"));
await _ctx.Database.ExecuteSqlRawAsync(@"
WITH new_data (base_tag, attribute, value, node_id, loaded_at) AS (
VALUES " + valuesSql + @"
)
INSERT INTO tag_metadata (base_tag, attribute, value, node_id, loaded_at)
SELECT base_tag, attribute, value, node_id, loaded_at FROM new_data
ON CONFLICT (base_tag, attribute)
DO UPDATE SET value = excluded.value, node_id = excluded.node_id, loaded_at = NOW()",
entries.SelectMany((e, i) => new object[] {
new NpgsqlParameter($"@bt{i}", e.baseTag),
new NpgsqlParameter($"@attr{i}", e.attr),
new NpgsqlParameter($"@val{i}", (object?)e.value ?? DBNull.Value),
new NpgsqlParameter($"@nid{i}", e.nodeId)
}).ToArray());
}
// v_tag_summary는 일반 VIEW이므로 REFRESH 불필요 (조회 시 실시간 JOIN)
_logger.LogInformation("[Metadata] 로드 완료: {Count}개 속성 ({TagCount}개 태그)", entries.Count, baseTagList.Count);
return entries.Count;
}
public async Task<int> ReloadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string>? baseTags = null)
{
// baseTags가 null이면 tag_metadata에서 전체 base_tag 조회
var tags = baseTags?.ToList() ?? await _ctx.TagMetadata.Select(t => t.BaseTag).Distinct().ToListAsync();
return await LoadMetadataAsync(cfg, tags);
}
}

68
AGENTS.md Normal file
View File

@@ -0,0 +1,68 @@
# ExperionCrawler — Agent Instructions
## Build / Run / Test
| Action | Command | Working Dir |
|--------|---------|-------------|
| Build | `dotnet build src/Web/ExperionCrawler.csproj` | repo root |
| Run (dev) | `dotnet run` | `src/Web/` |
| Publish | `dotnet publish -c Release -o /opt/ExperionCrawler` | `src/Web/` |
| Tests | `dotnet test` | repo root |
Single project: `src/Web/ExperionCrawler.csproj`. Core and Infrastructure are included via `<Compile Include>` globs — there are no separate projects to build. Runtime target is `linux-arm64`.
## Architecture
```
src/
├── Core/ — Interfaces (IExperionServices.cs), Domain entities, DTOs, Application Services
├── Infrastructure/ — OpcUa/, Database/, Certificates/, Csv/, Mcp/
└── Web/ — Program.cs, Controllers/, wwwroot/ (SPA)
```
All controllers are in `src/Web/Controllers/ExperionControllers.cs` (single file). All interfaces are in `src/Core/Application/Interfaces/IExperionServices.cs` (single file).
## Database
**PostgreSQL** (NOT SQLite — README is stale). Connection strings in `src/Web/appsettings.json`. TimescaleDB extension may be enabled on `history_table` via DDL only; no app code changes needed.
## Critical Convention — JSON camelCase
`PropertyNamingPolicy = null` in Program.cs means C# PascalCase becomes JSON keys. **Frontend expects camelCase**. Every controller `Ok(...)` response MUST use explicit anonymous objects with camelCase keys:
```csharp
// ✅ Correct
return Ok(new { id = x.Id, tagName = x.TagName });
// ❌ Broken — JS gets undefined
return Ok(new { x.Id, x.TagName });
return Ok(myDto); // typed object
```
See `CODING_CONVENTIONS.md` for full details and checklist.
## Background Services (HostedServices in Program.cs)
- `ExperionRealtimeService` — OPC UA subscription, 500ms batch flush to DB
- `ExperionHistoryService` — periodic snapshot (60s) from realtime_table → history_table
- `ExperionOpcServerService` — exposes realtime data as OPC UA server (port 4841)
- `McpServerHostedService` — Python MCP server bridge
- `ExperionFastService` — high-frequency data capture sessions
- `ExperionFastCleanupService` — expired session cleanup
All registered as Singleton + HostedService. RealtimeService and OpcServerService share autostart flag files (`realtime_autostart.json`, `opcserver_autostart.json`) in the working directory.
## Frontend
Vanilla JS SPA. `wwwroot/index.html` + `js/app.js` + `css/style.css`. No build step. Tab-based navigation; tabs do NOT auto-fire API calls on entry (performance fix).
## OPC UA SDK Gotchas
- SDK v1.5.378.134 — `Session.Create()` is `[Obsolete]`; use `DefaultSessionFactory.CreateAsync()`
- `Subscription.Create()` / `Delete()` / `ApplyChanges()` also deprecated → async variants preferred
- Certificate validation must be attached AFTER `OpcUaConfigProvider.GetConfigAsync()` returns the config
- TCP connect timeout: wrap `SelectEndpointAsync` with a 10s `CancellationTokenSource` (OS default is 127s)
## Deploy
`sudo bash deploy.sh` — publishes to `/opt/ExperionCrawler`, creates systemd service `experioncrawler`, sets up PKI dirs. Service runs as `www-data`.

Binary file not shown.

View File

@@ -0,0 +1,107 @@
# DXF 그래프 데이터로 할 수 있는 작업
DXF 파일에서 추출한 P&ID 그래프 데이터는 **설비, 배관, 태그 간의 위상 관계**를 구조화한 것입니다. 이 데이터로 다음과 같은 작업을 수행할 수 있습니다.
---
## 1. 영향도 분석 (Impact Analysis)
특정 설비가 고장 났을 때 **하류(downstream)에 영향을 받는 설비**를 자동으로 찾아냅니다.
- **구현**: [`analyze_impact()`](mcp-server/pipeline/topology.py:287) — NetworkX의 `descendants()`를 사용하여 BFS 탐색
- **API**: [`AnalyzeImpactAsync()`](src/Core/Application/Services/PidGraphService.cs:54) — MCP 도구 `analyze_pid_impact` 호출
- **UI**: [`pid_graph_view.html`](src/Web/wwwroot/pid_graph_view.html:21) — 영향도 분석 결과 패널
**사용 시나리오**: "펌프 P-101이 고장났을 때 어떤 설비가 영향을 받나?" → 하류 밸브, 탱크, 컨트롤러 등을 시각화
---
## 2. P&ID 태그 → Experion 시스템 태그 매핑
DXF에서 추출한 태그명(예: `FIC-6113`)을 실제 Experion HS 시스템 태그(예: `ficq-6113.pv`)에 매핑합니다.
- **구현**: [`IntelligentMapper`](mcp-server/pipeline/mapper.py:15)
- **1차**: RapidFuzz로 문자열 유사도 기반 후보 추출
- **2차**: LLM(Qwen3.6-27B)이 위상 컨텍스트(이웃 노드, 연결 관계)를 고려하여 최종 매핑
- **컨텍스트 추출**: [`get_node_context()`](mcp-server/pipeline/mapper.py:21) — 1-hop, 2-hop 이웃 정보를 포함
**사용 시나리오**: 도면에 `FT-101`이 있으면 → 시스템 태그 `ft-101.pv`에 매핑 → 실시간 데이터 조회 가능
---
## 3. 배관 기반 물리적 연결 추론
DXF의 LINE/LWPOLYLINE 객체를 분석하여 설비 간 **배관 연결 관계**를 자동으로 추론합니다.
- **구현**: [`build_graph()`](mcp-server/pipeline/topology.py:153) — 배관 라인이 설비 BBox와 교차하거나 근접하면 연결 엣지 생성
- **흐름 방향 추론**: 좌표 기반으로 왼쪽→오른쪽, 위→아래 방향으로 흐름 방향 결정
- **성능 최적화**: SpatialGrid 인덱스로 O(n) → O(1) 탐색
**사용 시나리오**: "어떤 설비가 어떤 배관으로 연결되어 있는가?" → 공정 흐름도 자동 생성
---
## 4. 태그-설비 논리적 연결 (Association)
텍스트 태그(예: `FIC-6113`)와 가까운 설비 노드를 **거리 + 연결성 가중치**로 매핑합니다.
- **구현**: [`_find_nearest_equipment()`](mcp-server/pipeline/topology.py:212)
- 거리 점수 + 배관 연결성 보너스 조합
- SpatialGrid로 후보 집합을 빠르게 필터링
**사용 시나리오**: 도면에 적힌 태그명이 실제 어떤 설비 기호에 해당하는지 자동 연결
---
## 5. 위상 무결성 검증
그래프 구조의健全性을 검증합니다.
- **구현**: [`validate_topology()`](mcp-server/pipeline/topology.py:258)
- 고립 노드(isolated nodes) 감지
- 노드/엣지 카운트 통계
**사용 시나리오**: 추출 품질 확인 — 연결되지 않은 노드가 있으면 도면 파싱 실패 가능성
---
## 6. 시각화
Canvas 기반 P&ID 그래프 시각화 UI가 준비되어 있습니다.
- **구현**: [`pid_graph_view.html`](src/Web/wwwroot/pid_graph_view.html:14) — Canvas 렌더링
- 노드 클릭 시 상세 정보 표시
- 영향도 분석 결과를 시각적 목록으로 표시
---
## 7. OPC UA 실시간 데이터 연동 (간접)
매핑된 Experion 태그를 통해 실시간/히스토리 데이터와 연결할 수 있습니다.
- P&ID 태그 → Experion 태그 매핑 완료 후 → 시계열 데이터 조회
- NL2SQL을 통해 자연어로 P&ID 설비 데이터 조회 가능
---
## 요약 테이블
| # | 기능 | 핵심 함수 | 입력 | 출력 |
|---|------|-----------|------|------|
| 1 | 영향도 분석 | [`analyze_impact()`](mcp-server/pipeline/topology.py:287) | graph_id, start_node_id | 영향받는 노드 목록 |
| 2 | 태그 매핑 | [`IntelligentMapper`](mcp-server/pipeline/mapper.py:15) | P&ID 태그 + 시스템 태그 | 매핑 결과 + 신뢰도 |
| 3 | 배관 연결 추론 | [`build_graph()`](mcp-server/pipeline/topology.py:153) | DXF 기하 데이터 | 방향성 그래프 (노드+엣지) |
| 4 | 태그-설비 연결 | [`_find_nearest_equipment()`](mcp-server/pipeline/topology.py:212) | 태그 노드 + SpatialGrid | 매칭된 설비 노드 |
| 5 | 위상 검증 | [`validate_topology()`](mcp-server/pipeline/topology.py:258) | 그래프 | 고립 노드, 통계 |
| 6 | 시각화 | [`pid_graph_view.html`](src/Web/wwwroot/pid_graph_view.html:1) | 그래프 JSON | Canvas 렌더링 |
---
## 핵심 가치
DXF에서 추출한 기하학적 데이터를 **위상 그래프**로 변환하면, 다음을 수행할 수 있습니다:
- **공정 흐름 분석**: 설비 간 연결 관계와 흐름 방향을 자동 추론
- **장애 전파 시뮬레이션**: 특정 설비 고장 시 영향 범위 예측
- **태그 자동 매핑**: 도면 태그와 시스템 태그를 위상 컨텍스트로 매핑
- **실시간 모니터링 연동**: 매핑된 태그로 OPC UA 데이터와 연결

8916
Experion-htm/Plant6.htm Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,36 @@
<?xml version="1.0"?><shapefile version="1" type="dynamic" title="" description="" useFirstShapeForBadValue="0" width="34px" height="34px"><versionheaders><versionheader name="PS" value="6.11"/></versionheaders><parameters><parameter name="TAG" type="Point" description="" defaultvalue=""/></parameters><shapes><shape><group><element><html><content><![CDATA[<TEXTAREA tabIndex=0 id=ALV class=hsc.alpha.1 style="BORDER-TOP-STYLE: none; OVERFLOW: hidden; WORD-WRAP: normal; FONT-SIZE: 12pt; TEXT-DECORATION: none; HEIGHT: 55.88%; FONT-FAMILY: Arial; WIDTH: 67.64%; BORDER-BOTTOM-STYLE: none; POSITION: absolute; FONT-WEIGHT: 400; COLOR: #000000; FONT-STYLE: normal; TEXT-ALIGN: center; BORDER-RIGHT-STYLE: none; LEFT: 17.64%; BORDER-LEFT-STYLE: none; TOP: 23.52%; BEHAVIOR: url(#HDXAlphaBehavior) url(#BindingBehavior) url(#HoverBehavior); VISIBILITY: hidden; BACKGROUND-COLOR: transparent; ROWS: 1" hdxproperties="fillColor:transparent;HDXBINDINGID:-4;lineColor:black;numericDisplayFormat:%.2f;textColor:#000000;">9999.99</TEXTAREA>]]></content><scripts><script event="onupdate" language="VBScript"><![CDATA[if me.value > 5 then
OVAL.style.visibility = "visible"
else
OVAL.style.visibility = "hidden"
end if
if me.value = 9 then
HH.style.visibility = "visible"
else
HH.style.visibility = "hidden"
end if
if me.value = 7 then
H.style.visibility = "visible"
else
H.style.visibility = "hidden"
end if
if me.value = 6 then
L.style.visibility = "visible"
else
L.style.visibility = "hidden"
end if
if me.value = 8 then
LL.style.visibility = "visible"
else
LL.style.visibility = "hidden"
end if]]></script></scripts></html><binding><dataobject ID="dso1" class="progid:HSCDataSources.Application" type="HMIPage.Generic"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.3"/><property name="ObjectType" varianttype="VT_UI1" value="0"/><property name="UpdatePeriod" varianttype="VT_UI2" value="0"/><property name="SecurityLevel" varianttype="VT_UI1" value="0"/><property name="AddressType" varianttype="VT_UI1" value="0"/><property name="AddressFlags" varianttype="VT_UI1" value="0"/><property name="PointRefPointName" varianttype="VT_BSTR" value="{%Point::TAG%}"/><property name="PointRefParamName" varianttype="VT_BSTR" value="AlarmValue"/><property name="PointRefParamOffset" varianttype="VT_UI4" value="0"/><property name="PointRefFlags" varianttype="VT_UI4" value="0"/><property name="CalloutElement" varianttype="VT_BSTR" value=""/><property name="ParameterFormat" varianttype="VT_BOOL" value="0"/><property name="PresentationType" varianttype="VT_UI1" value="0"/></dataobjectdef></dataobject><class ID="HSC.AlphaReadOnly" refcount="1"/></binding></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=OVAL class=hvg.base.1 style="OVERFLOW: hidden; FONT-SIZE: 12pt; TEXT-DECORATION: none; HEIGHT: 100%; FONT-FAMILY: Arial; WIDTH: 100%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#oval)" hdxproperties="FillColor:#ffff00;fillColorBlink:True;Height:34;LineColor:#000000;lineColorBlink:False;Width:34;"></DIV>]]></content></html></element><group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=HH class=hvg.textbox.1 style="OVERFLOW: hidden; FONT-SIZE: 12pt; HEIGHT: 94.44%; FONT-FAMILY: Arial; WIDTH: 95.45%; POSITION: absolute; FONT-WEIGHT: 400; COLOR: #000000; FONT-STYLE: normal; TEXT-ALIGN: center; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="fillColorBlink:False;FillStyle:1;Height:17;lineColorBlink:False;LineStyle:0;textColor:#000000;textColorBlink:False;TotalRotation:-1;Width:21;">HH</DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=H class=hvg.textbox.1 style="OVERFLOW: hidden; FONT-SIZE: 12pt; HEIGHT: 94.44%; FONT-FAMILY: Arial; WIDTH: 95.45%; POSITION: absolute; FONT-WEIGHT: 400; COLOR: #000000; FONT-STYLE: normal; TEXT-ALIGN: center; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="fillColorBlink:False;FillStyle:1;Height:17;lineColorBlink:False;LineStyle:0;textColor:#000000;textColorBlink:False;TotalRotation:-1;Width:21;">H</DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=L class=hvg.textbox.1 style="OVERFLOW: hidden; FONT-SIZE: 12pt; HEIGHT: 94.44%; FONT-FAMILY: Arial; WIDTH: 95.45%; POSITION: absolute; FONT-WEIGHT: 400; COLOR: #000000; FONT-STYLE: normal; TEXT-ALIGN: center; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="fillColorBlink:False;FillStyle:1;Height:17;lineColorBlink:False;LineStyle:0;textColor:#000000;textColorBlink:False;TotalRotation:-1;Width:21;">L</DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=LL class=hvg.textbox.1 style="OVERFLOW: hidden; FONT-SIZE: 12pt; HEIGHT: 94.44%; FONT-FAMILY: Arial; WIDTH: 95.45%; POSITION: absolute; FONT-WEIGHT: 400; COLOR: #000000; FONT-STYLE: normal; TEXT-ALIGN: center; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="fillColorBlink:False;FillStyle:1;Height:17;lineColorBlink:False;LineStyle:0;textColor:#000000;textColorBlink:False;TotalRotation:-1;Width:21;">LL</DIV>]]></content></html></element><html><content><![CDATA[
<DIV tabIndex=-1 id=group001 class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 52.94%; WIDTH: 64.7%; POSITION: absolute; LEFT: 17.64%; TOP: 23.52%; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:18;lineColorBlink:False;Width:22;"></DIV>]]></content></html></group><html><content><![CDATA[
<DIV tabIndex=-1 id=group002 class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 34px; WIDTH: 34px; POSITION: absolute; LEFT: 112px; TOP: 143px; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:34;lineColorBlink:False;Width:34;"></DIV>]]></content></html></group></shape></shapes></shapefile>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0"?><shapefile version="1" type="dynamic" title="ControlValve" description="" useFirstShapeForBadValue="0" width="28px" height="36px"><versionheaders><versionheader name="PS" value="6.51"/></versionheaders><parameters><parameter name="VALVE" type="Point" description="" defaultvalue=""/></parameters><shapes><shape><group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=rect001 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 41.66%; FONT-FAMILY: Arial; WIDTH: 7.14%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 46.42%; TOP: 36.11%; BEHAVIOR: url(#HDXVectorFactory#rect) url(#BindingBehavior) url(#hscbreakpointbehavior)" hdxproperties="BreakpointType:0;ContinuousColors:6908265_9145088_13749760_16760576_13434880_15631086_16711935_6053069_255;ContinuousValues:1_10_20_30_40_50_60_80;fillColorBlink:False;HDXBINDINGID:-4;Height:15;LineColor:#000000;lineColorBlink:False;Width:2;"></DIV>]]></content></html><binding><dataobject ID="dso1" class="progid:HSCDataSources.Application" type="HMIPage.Generic"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.3"/><property name="ObjectType" varianttype="VT_UI1" value="0"/><property name="UpdatePeriod" varianttype="VT_UI2" value="0"/><property name="SecurityLevel" varianttype="VT_UI1" value="0"/><property name="AddressType" varianttype="VT_UI1" value="0"/><property name="AddressFlags" varianttype="VT_UI1" value="1"/><property name="PointRefPointName" varianttype="VT_BSTR" value=""/><property name="PointRefParamName" varianttype="VT_BSTR" value="PV"/><property name="PointRefParamOffset" varianttype="VT_UI4" value="0"/><property name="PointRefFlags" varianttype="VT_UI4" value="0"/><property name="CalloutElement" varianttype="VT_BSTR" value=""/><property name="ParameterFormat" varianttype="VT_BOOL" value="0"/><property name="PresentationType" varianttype="VT_UI1" value="0"/></dataobjectdef></dataobject><class ID="HSC.ColorBreakpoints" refcount="1"/><class ID="HSC.HVG" refcount="1"/></binding></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=A3 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 44.44%; FONT-FAMILY: Arial; WIDTH: 100%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 55.55%; BEHAVIOR: url(#HDXVectorFactory#polygon) url(#BindingBehavior) url(#hscbreakpointbehavior)" hdxproperties="BreakpointType:0;ContinuousColors:6908265_9145088_13749760_16760576_13434880_15631086_16711935_6053069_255;ContinuousValues:1_10_20_30_40_50_60_80;FillColor:#ffffff;fillColorBlink:False;HDXBINDINGID:-4;Height:16;LineColor:#000000;lineColorBlink:False;points:0.000000 0.000000 0.000000 100.000000 100.000000 0.543478 100.000000 100.000000 0.000000 0.543478 ;textColor:#000000;Width:28;"></DIV>]]></content></html><binding><dataobject ID="dso1" class="progid:HSCDataSources.Application" type="HMIPage.Generic"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.3"/><property name="ObjectType" varianttype="VT_UI1" value="0"/><property name="UpdatePeriod" varianttype="VT_UI2" value="0"/><property name="SecurityLevel" varianttype="VT_UI1" value="0"/><property name="AddressType" varianttype="VT_UI1" value="0"/><property name="AddressFlags" varianttype="VT_UI1" value="1"/><property name="PointRefPointName" varianttype="VT_BSTR" value="{%Point::VALVE%}"/><property name="PointRefParamName" varianttype="VT_BSTR" value="OP"/><property name="PointRefParamOffset" varianttype="VT_UI4" value="0"/><property name="PointRefFlags" varianttype="VT_UI4" value="0"/><property name="CalloutElement" varianttype="VT_BSTR" value=""/><property name="ParameterFormat" varianttype="VT_BOOL" value="0"/><property name="PresentationType" varianttype="VT_UI1" value="0"/></dataobjectdef></dataobject><class ID="HSC.ColorBreakpoints" refcount="1"/><class ID="HSC.HVG" refcount="1"/></binding></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=A1 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 38.88%; FONT-FAMILY: Arial; WIDTH: 100%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#hdxvectorfactory#wedge) url(#bindingbehavior) url(#hscbreakpointbehavior)" hdxproperties="BreakpointType:0;ContinuousColors:6908265_9145088_13749760_16760576_13434880_15631086_16711935_6053069_255;ContinuousValues:1_10_20_30_40_50_60_80;FillColor:#ffffff;fillColorBlink:False;HDXBINDINGID:-4;Height:14;LevelFilled:False;LineColor:#000000;lineColorBlink:False;ObjectType:10;Points:99.82143 89.64286 99.10715 80 97.85715 70.35714 96.07143 61.07143 93.92857 52.14286 91.42857 43.92857 88.57143 36.42857 85.35714 29.28572 81.78571 22.85714 78.03571 17.14286 73.92857 12.14286 69.46429 7.857143 64.82143 4.285714 60.17857 1.785714 55.17857 0.3571429 50 0 44.82143 0.3571429 40 1.785714 35.17857 4.285714 30.53572 7.857143 26.07143 12.14286 21.96428 17.14286 18.21428 22.85714 14.64286 29.28572 11.42857 36.42857 8.571431 43.92857 6.071431 52.14286 3.928571 61.07143 2.142857 70.35714 0.8928571 80 0.1785714 89.64286 0 100 100 100 ;PointTypes:m l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l l lx ;Quadrant:1;textColor:#000000;Width:28;"></DIV>]]></content></html><binding><dataobject ID="dso1" class="progid:HSCDataSources.Application" type="HMIPage.Generic"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.3"/><property name="ObjectType" varianttype="VT_UI1" value="0"/><property name="UpdatePeriod" varianttype="VT_UI2" value="0"/><property name="SecurityLevel" varianttype="VT_UI1" value="0"/><property name="AddressType" varianttype="VT_UI1" value="0"/><property name="AddressFlags" varianttype="VT_UI1" value="1"/><property name="PointRefPointName" varianttype="VT_BSTR" value="{%Point::VALVE%}"/><property name="PointRefParamName" varianttype="VT_BSTR" value="OP"/><property name="PointRefParamOffset" varianttype="VT_UI4" value="0"/><property name="PointRefFlags" varianttype="VT_UI4" value="0"/><property name="CalloutElement" varianttype="VT_BSTR" value=""/><property name="ParameterFormat" varianttype="VT_BOOL" value="0"/><property name="PresentationType" varianttype="VT_UI1" value="0"/></dataobjectdef></dataobject><class ID="HSC.ColorBreakpoints" refcount="1"/><class ID="HSC.HVG" refcount="1"/></binding></element><html><content><![CDATA[
<DIV tabIndex=-1 id=group001 class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 36px; WIDTH: 28px; POSITION: absolute; LEFT: 37px; TOP: 24px; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:36;lineColorBlink:False;Width:28;"></DIV>]]></content></html></group></shape></shapes></shapefile>

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 854 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 900 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0"?><shapefile version="1" type="dynamic" title="" description="" useFirstShapeForBadValue="0" width="62px" height="52px"><versionheaders><versionheader name="PS" value="6.51"/></versionheaders><parameters><parameter name="TAG" type="Point" description="" defaultvalue=""/></parameters><shapes><shape><group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=rect022 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 98.07%; FONT-FAMILY: Arial; WIDTH: 98.38%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#rect)" hdxproperties="FillColor:#696969;fillColorBlink:False;Height:51;LineColor:#000000;lineColorBlink:False;Width:61;"></DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=textbox001 class=hvg.textbox.1 style="FONT-SIZE: 10pt; OVERFLOW: hidden; TEXT-DECORATION: ; HEIGHT: 38.46%; FONT-FAMILY: Tahoma; WIDTH: 32.25%; POSITION: absolute; FONT-WEIGHT: normal; COLOR: #00ff00; FONT-STYLE: normal; TEXT-ALIGN: center; LEFT: 1.61%; TOP: 13.46%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="FillColor:transparent;fillColorBlink:False;FillStyle:1;Height:20;lineColorBlink:False;LineStyle:0;textColor:#00ff00;textColorBlink:False;TotalRotation:-1;Width:20;">Hz</DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=textbox002 class=hvg.textbox.1 style="FONT-SIZE: 10pt; OVERFLOW: hidden; HEIGHT: 38.46%; FONT-FAMILY: Tahoma; WIDTH: 32.25%; POSITION: absolute; FONT-WEIGHT: 400; COLOR: #ff00ff; FONT-STYLE: normal; TEXT-ALIGN: center; LEFT: 3.22%; TOP: 57.69%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="FillColor:transparent;fillColorBlink:False;FillStyle:1;Height:20;lineColorBlink:False;LineStyle:0;textColor:#ff00ff;textColorBlink:False;TotalRotation:-1;Width:20;">Set</DIV>]]></content></html></element><element><html><content><![CDATA[<TEXTAREA tabIndex=-1 id=alpha102 title="" class=hsc.alpha.1 style="BORDER-TOP-STYLE: none; FONT-SIZE: 14pt; OVERFLOW: hidden; WORD-WRAP: normal; TEXT-DECORATION: none; BORDER-LEFT-STYLE: none; HEIGHT: 44.23%; FONT-FAMILY: Tahoma; BORDER-TOP-COLOR: transparent; WIDTH: 59.67%; BORDER-BOTTOM-STYLE: none; POSITION: absolute; BORDER-LEFT-COLOR: transparent; FONT-WEIGHT: normal; COLOR: #00ff00; FONT-STYLE: normal; BORDER-BOTTOM-COLOR: transparent; TEXT-ALIGN: right; BORDER-RIGHT-STYLE: none; LEFT: 35.48%; BORDER-RIGHT-COLOR: transparent; TOP: 3.84%; BEHAVIOR: url(#HDXAlphaBehavior) url(#BindingBehavior) url(#HoverBehavior); BACKGROUND-COLOR: #000000; ROWS: 1" hdxproperties="fillColor:#000000;fillColorBlink:False;HDXBINDINGID:-4;lineColor:#ffffff;lineColorBlink:False;numberOfChars:9;numericDisplayFormat:%.1f;textColor:#00ff00;textColorBlink:False;" globalscripts printDisplayValue="9999.99">9999.99</TEXTAREA>]]></content></html><binding><dataobject ID="dso1" class="progid:HSCDataSources.Application" type="DataSourceObj.Generic"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.3"/><property name="ObjectType" varianttype="VT_UI1" value="0"/><property name="UpdatePeriod" varianttype="VT_UI2" value="0"/><property name="SecurityLevel" varianttype="VT_UI1" value="0"/><property name="AddressType" varianttype="VT_UI1" value="0"/><property name="AddressFlags" varianttype="VT_UI1" value="0"/><property name="PointRefPointName" varianttype="VT_BSTR" value="{%Point::TAG%}"/><property name="PointRefParamName" varianttype="VT_BSTR" value="HZ"/><property name="PointRefParamOffset" varianttype="VT_UI4" value="0"/><property name="PointRefFlags" varianttype="VT_UI4" value="0"/><property name="CalloutElement" varianttype="VT_BSTR" value=""/><property name="ParameterFormat" varianttype="VT_BOOL" value="0"/><property name="PresentationType" varianttype="VT_UI1" value="0"/></dataobjectdef></dataobject><class ID="HSC.AlphaReadOnly" refcount="1"/></binding></element><element><html><content><![CDATA[<TEXTAREA tabIndex=-1 id=alpha104 title="" class=hsc.alpha.1 style="BORDER-TOP-STYLE: none; FONT-SIZE: 14pt; OVERFLOW: hidden; WORD-WRAP: normal; TEXT-DECORATION: none; BORDER-LEFT-STYLE: none; HEIGHT: 44.23%; FONT-FAMILY: Tahoma; BORDER-TOP-COLOR: transparent; WIDTH: 59.67%; BORDER-BOTTOM-STYLE: none; POSITION: absolute; BORDER-LEFT-COLOR: transparent; FONT-WEIGHT: normal; COLOR: #ff00ff; FONT-STYLE: normal; BORDER-BOTTOM-COLOR: transparent; TEXT-ALIGN: right; BORDER-RIGHT-STYLE: none; LEFT: 35.48%; BORDER-RIGHT-COLOR: transparent; TOP: 50%; BEHAVIOR: url(#HDXAlphaBehavior) url(#BindingBehavior) url(#HoverBehavior); BACKGROUND-COLOR: #000000; ROWS: 1" hdxproperties="fillColor:#000000;fillColorBlink:False;HDXBINDINGID:-4;lineColor:#ffffff;lineColorBlink:False;numberOfChars:9;numericDisplayFormat:%.1f;textColor:#ff00ff;textColorBlink:False;" globalscripts printDisplayValue="9999.99">9999.99</TEXTAREA>]]></content></html><binding><dataobject ID="dso1" class="progid:HSCDataSources.Application" type="DataSourceObj.Generic"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.3"/><property name="ObjectType" varianttype="VT_UI1" value="0"/><property name="UpdatePeriod" varianttype="VT_UI2" value="0"/><property name="SecurityLevel" varianttype="VT_UI1" value="2"/><property name="AddressType" varianttype="VT_UI1" value="0"/><property name="AddressFlags" varianttype="VT_UI1" value="0"/><property name="PointRefPointName" varianttype="VT_BSTR" value="{%Point::TAG%}"/><property name="PointRefParamName" varianttype="VT_BSTR" value="HZSET"/><property name="PointRefParamOffset" varianttype="VT_UI4" value="0"/><property name="PointRefFlags" varianttype="VT_UI4" value="0"/><property name="CalloutElement" varianttype="VT_BSTR" value=""/><property name="ParameterFormat" varianttype="VT_BOOL" value="0"/><property name="PresentationType" varianttype="VT_UI1" value="0"/></dataobjectdef></dataobject><class ID="HSC.Alpha" refcount="1"/></binding></element><html><content><![CDATA[
<DIV tabIndex=-1 id=group001 class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 52px; WIDTH: 62px; POSITION: absolute; LEFT: 29px; TOP: 46px; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:52;lineColorBlink:False;Width:62;"></DIV>]]></content></html></group></shape></shapes></shapefile>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0"?><shapefile version="1" type="sequence" title="" description="" useFirstShapeForBadValue="0" width="31px" height="31px"><versionheaders><versionheader name="PS" value="6.11"/></versionheaders><shapes><shape><element><html><content><![CDATA[
<DIV tabIndex=-1 id=oval001 class=hvg.base.1 style="OVERFLOW: hidden; FONT-SIZE: 12pt; TEXT-DECORATION: none; HEIGHT: 31px; FONT-FAMILY: Arial; WIDTH: 31px; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 42px; TOP: 77px; BEHAVIOR: url(#HDXVectorFactory#oval); VISIBILITY: hidden" hdxproperties="fillColorBlink:False;Height:31;LineColor:#000000;lineColorBlink:False;Width:31;"></DIV>]]></content></html></element></shape><shape><group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=oval002 class=hvg.base.1 style="OVERFLOW: hidden; FONT-SIZE: 12pt; TEXT-DECORATION: none; HEIGHT: 96.87%; FONT-FAMILY: Arial; WIDTH: 96.87%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#oval)" hdxproperties="FillColor:#ff0000;fillColorBlink:True;Height:31;LineColor:#000000;lineColorBlink:False;Width:31;"></DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=textbox001 class=hvg.textbox.1 style="OVERFLOW: hidden; FONT-SIZE: 10pt; HEIGHT: 53.12%; FONT-FAMILY: Arial; WIDTH: 59.37%; POSITION: absolute; FONT-WEIGHT: bold; COLOR: #000000; FONT-STYLE: normal; TEXT-ALIGN: center; LEFT: 25%; TOP: 18.75%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="fillColorBlink:False;FillStyle:1;Height:17;lineColorBlink:False;LineStyle:0;textColor:#000000;textColorBlink:False;TotalRotation:-1;Width:19;">I/L</DIV>]]></content></html></element><html><content><![CDATA[
<DIV tabIndex=-1 id=group001 class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 32px; WIDTH: 32px; POSITION: absolute; LEFT: 78px; TOP: 77px; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:32;lineColorBlink:False;Width:32;"></DIV>]]></content><scripts><script event="onclick" language="VBScript"><![CDATA[window.external.Parent.RequestTask 21, 1, 0, 0, 0, "6TH-IL-ESD"]]></script></scripts></html></group></shape></shapes></shapefile>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0"?><shapefile version="1" type="dynamic" title="" description="" useFirstShapeForBadValue="0" width="94px" height="72px"><versionheaders><versionheader name="PS" value="6.51"/></versionheaders><parameters><parameter name="TAG" type="Point" description="" defaultvalue=""/></parameters><shapes><shape><group><group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=rect022 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 100%; FONT-FAMILY: Arial; WIDTH: 100%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#rect)" hdxproperties="FillColor:#696969;fillColorBlink:False;Height:72;LineColor:#000000;lineColorBlink:False;Width:92;"></DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=line029 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 1.38%; FONT-FAMILY: Arial; WIDTH: 98.91%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 1.08%; TOP: 29.16%; BEHAVIOR: url(#HDXVectorFactory#line)" hdxproperties="fillColorBlink:False;Height:1;LineColor:#000000;lineColorBlink:False;points:0 0 100 0 ;Width:91;"></DIV>]]></content></html></element><html><content><![CDATA[
<DIV tabIndex=-1 id=group008 class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 100%; WIDTH: 97.87%; POSITION: absolute; LEFT: 1.06%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:72;lineColorBlink:False;Width:92;"></DIV>]]></content></html></group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=textbox001 class=hvg.textbox.1 style="FONT-SIZE: 10pt; OVERFLOW: hidden; TEXT-DECORATION: ; HEIGHT: 27.77%; FONT-FAMILY: Tahoma; WIDTH: 21.27%; POSITION: absolute; FONT-WEIGHT: normal; COLOR: #00ff00; FONT-STYLE: normal; TEXT-ALIGN: center; LEFT: 0%; TOP: 38.88%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="FillColor:transparent;fillColorBlink:False;FillStyle:1;Height:20;lineColorBlink:False;LineStyle:0;textColor:#00ff00;textColorBlink:False;TotalRotation:-1;Width:20;">PV</DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=textbox002 class=hvg.textbox.1 style="FONT-SIZE: 10pt; OVERFLOW: hidden; HEIGHT: 27.77%; FONT-FAMILY: Tahoma; WIDTH: 21.27%; POSITION: absolute; FONT-WEIGHT: 400; COLOR: #ff00ff; FONT-STYLE: normal; TEXT-ALIGN: center; LEFT: 1.06%; TOP: 70.83%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="FillColor:transparent;fillColorBlink:False;FillStyle:1;Height:20;lineColorBlink:False;LineStyle:0;textColor:#ff00ff;textColorBlink:False;TotalRotation:-1;Width:20;">QV</DIV>]]></content></html></element><element><html><content><![CDATA[<TEXTAREA tabIndex=-1 id=alpha102 title="" class=hsc.alpha.1 style="BORDER-TOP-STYLE: none; FONT-SIZE: 14pt; OVERFLOW: hidden; WORD-WRAP: normal; TEXT-DECORATION: none; BORDER-LEFT-STYLE: none; HEIGHT: 31.94%; FONT-FAMILY: Tahoma; BORDER-TOP-COLOR: transparent; WIDTH: 74.46%; BORDER-BOTTOM-STYLE: none; POSITION: absolute; BORDER-LEFT-COLOR: transparent; FONT-WEIGHT: normal; COLOR: #00ff00; FONT-STYLE: normal; BORDER-BOTTOM-COLOR: transparent; TEXT-ALIGN: right; BORDER-RIGHT-STYLE: none; LEFT: 22.34%; BORDER-RIGHT-COLOR: transparent; TOP: 31.94%; BEHAVIOR: url(#HDXAlphaBehavior) url(#BindingBehavior) url(#HoverBehavior); BACKGROUND-COLOR: #000000; ROWS: 1" hdxproperties="fillColor:#000000;fillColorBlink:False;HDXBINDINGID:-4;lineColor:#ffffff;lineColorBlink:False;numberOfChars:9;numericDisplayFormat:%.1f;textColor:#00ff00;textColorBlink:False;" globalscripts printDisplayValue="9999.99">9999.99</TEXTAREA>]]></content></html><binding><dataobject ID="dso1" class="progid:HSCDataSources.Application" type="DataSourceObj.Generic"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.3"/><property name="ObjectType" varianttype="VT_UI1" value="0"/><property name="UpdatePeriod" varianttype="VT_UI2" value="0"/><property name="SecurityLevel" varianttype="VT_UI1" value="0"/><property name="AddressType" varianttype="VT_UI1" value="0"/><property name="AddressFlags" varianttype="VT_UI1" value="0"/><property name="PointRefPointName" varianttype="VT_BSTR" value="{%Point::TAG%}"/><property name="PointRefParamName" varianttype="VT_BSTR" value="PV"/><property name="PointRefParamOffset" varianttype="VT_UI4" value="0"/><property name="PointRefFlags" varianttype="VT_UI4" value="0"/><property name="CalloutElement" varianttype="VT_BSTR" value=""/><property name="ParameterFormat" varianttype="VT_BOOL" value="0"/><property name="PresentationType" varianttype="VT_UI1" value="0"/></dataobjectdef></dataobject><class ID="HSC.AlphaReadOnly" refcount="1"/></binding></element><element><html><content><![CDATA[<TEXTAREA tabIndex=-1 id=alpha104 title="" class=hsc.alpha.1 style="BORDER-TOP-STYLE: none; FONT-SIZE: 14pt; OVERFLOW: hidden; WORD-WRAP: normal; TEXT-DECORATION: none; BORDER-LEFT-STYLE: none; HEIGHT: 31.94%; FONT-FAMILY: Tahoma; BORDER-TOP-COLOR: transparent; WIDTH: 74.46%; BORDER-BOTTOM-STYLE: none; POSITION: absolute; BORDER-LEFT-COLOR: transparent; FONT-WEIGHT: normal; COLOR: #ff00ff; FONT-STYLE: normal; BORDER-BOTTOM-COLOR: transparent; TEXT-ALIGN: right; BORDER-RIGHT-STYLE: none; LEFT: 22.34%; BORDER-RIGHT-COLOR: transparent; TOP: 65.27%; BEHAVIOR: url(#HDXAlphaBehavior) url(#BindingBehavior) url(#HoverBehavior); BACKGROUND-COLOR: #000000; ROWS: 1" hdxproperties="fillColor:#000000;fillColorBlink:False;HDXBINDINGID:-4;lineColor:#ffffff;lineColorBlink:False;numberOfChars:9;numericDisplayFormat:%.1f;textColor:#ff00ff;textColorBlink:False;" globalscripts printDisplayValue="9999.99">9999.99</TEXTAREA>]]></content></html><binding><dataobject ID="dso1" class="progid:HSCDataSources.Application" type="DataSourceObj.Generic"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.3"/><property name="ObjectType" varianttype="VT_UI1" value="0"/><property name="UpdatePeriod" varianttype="VT_UI2" value="0"/><property name="SecurityLevel" varianttype="VT_UI1" value="0"/><property name="AddressType" varianttype="VT_UI1" value="0"/><property name="AddressFlags" varianttype="VT_UI1" value="0"/><property name="PointRefPointName" varianttype="VT_BSTR" value="{%Point::TAG%}"/><property name="PointRefParamName" varianttype="VT_BSTR" value="QV"/><property name="PointRefParamOffset" varianttype="VT_UI4" value="0"/><property name="PointRefFlags" varianttype="VT_UI4" value="0"/><property name="CalloutElement" varianttype="VT_BSTR" value=""/><property name="ParameterFormat" varianttype="VT_BOOL" value="0"/><property name="PresentationType" varianttype="VT_UI1" value="0"/></dataobjectdef></dataobject><class ID="HSC.AlphaReadOnly" refcount="1"/></binding></element><html><content><![CDATA[
<DIV tabIndex=-1 id=group002 class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 72px; WIDTH: 94px; POSITION: absolute; LEFT: 309px; TOP: 203px; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:72;lineColorBlink:False;Width:94;"></DIV>]]></content></html></group></shape></shapes></shapefile>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0"?><shapefile version="1" type="dynamic" title="" description="" useFirstShapeForBadValue="0" width="94px" height="72px"><versionheaders><versionheader name="PS" value="6.51"/></versionheaders><parameters><parameter name="TAG" type="Point" description="" defaultvalue=""/></parameters><shapes><shape><group><group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=rect022 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 100%; FONT-FAMILY: Arial; WIDTH: 100%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#rect)" hdxproperties="FillColor:#696969;fillColorBlink:False;Height:72;LineColor:#000000;lineColorBlink:False;Width:92;"></DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=line029 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 1.38%; FONT-FAMILY: Arial; WIDTH: 98.91%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 1.08%; TOP: 29.16%; BEHAVIOR: url(#HDXVectorFactory#line)" hdxproperties="fillColorBlink:False;Height:1;LineColor:#000000;lineColorBlink:False;points:0 0 100 0 ;Width:91;"></DIV>]]></content></html></element><html><content><![CDATA[
<DIV tabIndex=-1 id=group008 class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 100%; WIDTH: 97.87%; POSITION: absolute; LEFT: 1.06%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:72;lineColorBlink:False;Width:92;"></DIV>]]></content></html></group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=textbox001 class=hvg.textbox.1 style="FONT-SIZE: 10pt; OVERFLOW: hidden; TEXT-DECORATION: ; HEIGHT: 27.77%; FONT-FAMILY: Tahoma; WIDTH: 21.27%; POSITION: absolute; FONT-WEIGHT: normal; COLOR: #00ff00; FONT-STYLE: normal; TEXT-ALIGN: center; LEFT: 0%; TOP: 38.88%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="FillColor:transparent;fillColorBlink:False;FillStyle:1;Height:20;lineColorBlink:False;LineStyle:0;textColor:#00ff00;textColorBlink:False;TotalRotation:-1;Width:20;">PV</DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=textbox002 class=hvg.textbox.1 style="FONT-SIZE: 10pt; OVERFLOW: hidden; HEIGHT: 27.77%; FONT-FAMILY: Tahoma; WIDTH: 21.27%; POSITION: absolute; FONT-WEIGHT: 400; COLOR: #ff00ff; FONT-STYLE: normal; TEXT-ALIGN: center; LEFT: 1.06%; TOP: 70.83%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="FillColor:transparent;fillColorBlink:False;FillStyle:1;Height:20;lineColorBlink:False;LineStyle:0;textColor:#ff00ff;textColorBlink:False;TotalRotation:-1;Width:20;">SP</DIV>]]></content></html></element><element><html><content><![CDATA[<TEXTAREA tabIndex=-1 id=alpha102 title="" class=hsc.alpha.1 style="BORDER-TOP-STYLE: none; FONT-SIZE: 14pt; OVERFLOW: hidden; WORD-WRAP: normal; TEXT-DECORATION: none; BORDER-LEFT-STYLE: none; HEIGHT: 31.94%; FONT-FAMILY: Tahoma; BORDER-TOP-COLOR: transparent; WIDTH: 74.46%; BORDER-BOTTOM-STYLE: none; POSITION: absolute; BORDER-LEFT-COLOR: transparent; FONT-WEIGHT: normal; COLOR: #00ff00; FONT-STYLE: normal; BORDER-BOTTOM-COLOR: transparent; TEXT-ALIGN: right; BORDER-RIGHT-STYLE: none; LEFT: 22.34%; BORDER-RIGHT-COLOR: transparent; TOP: 31.94%; BEHAVIOR: url(#HDXAlphaBehavior) url(#BindingBehavior) url(#HoverBehavior); BACKGROUND-COLOR: #000000; ROWS: 1" hdxproperties="fillColor:#000000;fillColorBlink:False;HDXBINDINGID:-4;lineColor:#ffffff;lineColorBlink:False;numberOfChars:9;numericDisplayFormat:%.1f;textColor:#00ff00;textColorBlink:False;" globalscripts printDisplayValue="9999.99">9999.99</TEXTAREA>]]></content></html><binding><dataobject ID="dso1" class="progid:HSCDataSources.Application" type="DataSourceObj.Generic"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.3"/><property name="ObjectType" varianttype="VT_UI1" value="0"/><property name="UpdatePeriod" varianttype="VT_UI2" value="0"/><property name="SecurityLevel" varianttype="VT_UI1" value="0"/><property name="AddressType" varianttype="VT_UI1" value="0"/><property name="AddressFlags" varianttype="VT_UI1" value="0"/><property name="PointRefPointName" varianttype="VT_BSTR" value="{%Point::TAG%}"/><property name="PointRefParamName" varianttype="VT_BSTR" value="PV"/><property name="PointRefParamOffset" varianttype="VT_UI4" value="0"/><property name="PointRefFlags" varianttype="VT_UI4" value="0"/><property name="CalloutElement" varianttype="VT_BSTR" value=""/><property name="ParameterFormat" varianttype="VT_BOOL" value="0"/><property name="PresentationType" varianttype="VT_UI1" value="0"/></dataobjectdef></dataobject><class ID="HSC.AlphaReadOnly" refcount="1"/></binding></element><element><html><content><![CDATA[<TEXTAREA tabIndex=-1 id=alpha104 title="" class=hsc.alpha.1 style="BORDER-TOP-STYLE: none; FONT-SIZE: 14pt; OVERFLOW: hidden; WORD-WRAP: normal; TEXT-DECORATION: none; BORDER-LEFT-STYLE: none; HEIGHT: 31.94%; FONT-FAMILY: Tahoma; BORDER-TOP-COLOR: transparent; WIDTH: 74.46%; BORDER-BOTTOM-STYLE: none; POSITION: absolute; BORDER-LEFT-COLOR: transparent; FONT-WEIGHT: normal; COLOR: #ff00ff; FONT-STYLE: normal; BORDER-BOTTOM-COLOR: transparent; TEXT-ALIGN: right; BORDER-RIGHT-STYLE: none; LEFT: 22.34%; BORDER-RIGHT-COLOR: transparent; TOP: 65.27%; BEHAVIOR: url(#HDXAlphaBehavior) url(#BindingBehavior) url(#HoverBehavior); BACKGROUND-COLOR: #000000; ROWS: 1" hdxproperties="fillColor:#000000;fillColorBlink:False;HDXBINDINGID:-4;lineColor:#ffffff;lineColorBlink:False;numberOfChars:9;numericDisplayFormat:%.1f;textColor:#ff00ff;textColorBlink:False;" globalscripts printDisplayValue="9999.99">9999.99</TEXTAREA>]]></content></html><binding><dataobject ID="dso1" class="progid:HSCDataSources.Application" type="DataSourceObj.Generic"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.3"/><property name="ObjectType" varianttype="VT_UI1" value="0"/><property name="UpdatePeriod" varianttype="VT_UI2" value="0"/><property name="SecurityLevel" varianttype="VT_UI1" value="2"/><property name="AddressType" varianttype="VT_UI1" value="0"/><property name="AddressFlags" varianttype="VT_UI1" value="0"/><property name="PointRefPointName" varianttype="VT_BSTR" value="{%Point::TAG%}"/><property name="PointRefParamName" varianttype="VT_BSTR" value="SP"/><property name="PointRefParamOffset" varianttype="VT_UI4" value="0"/><property name="PointRefFlags" varianttype="VT_UI4" value="0"/><property name="CalloutElement" varianttype="VT_BSTR" value=""/><property name="ParameterFormat" varianttype="VT_BOOL" value="0"/><property name="PresentationType" varianttype="VT_UI1" value="0"/></dataobjectdef></dataobject><class ID="HSC.Alpha" refcount="1"/></binding></element><html><content><![CDATA[
<DIV tabIndex=-1 id=group001 class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 72px; WIDTH: 94px; POSITION: absolute; LEFT: 309px; TOP: 203px; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:72;lineColorBlink:False;Width:94;"></DIV>]]></content></html></group></shape></shapes></shapefile>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0"?><shapefile version="1" type="dynamic" title="" description="" useFirstShapeForBadValue="0" width="60px" height="44px"><versionheaders><versionheader name="PS" value="6.51"/></versionheaders><parameters><parameter name="Tag" type="Point" description="" defaultvalue=""/></parameters><shapes><shape><group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=rect022 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 100%; FONT-FAMILY: Arial; WIDTH: 96.66%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#rect)" hdxproperties="FillColor:#808080;fillColorBlink:False;Height:44;LineColor:#000000;lineColorBlink:False;Width:58;"></DIV>]]></content></html></element><element><html><content><![CDATA[<TEXTAREA tabIndex=-1 id=alpha121 title="" class=hsc.alpha.1 style="BORDER-TOP-STYLE: none; FONT-SIZE: 14pt; OVERFLOW: hidden; WORD-WRAP: normal; TEXT-DECORATION: none; BORDER-LEFT-STYLE: none; HEIGHT: 52.27%; FONT-FAMILY: Arial; BORDER-TOP-COLOR: transparent; WIDTH: 86.66%; BORDER-BOTTOM-STYLE: none; POSITION: absolute; BORDER-LEFT-COLOR: transparent; FONT-WEIGHT: normal; COLOR: #00ff00; FONT-STYLE: normal; BORDER-BOTTOM-COLOR: transparent; TEXT-ALIGN: right; BORDER-RIGHT-STYLE: none; LEFT: 5%; BORDER-RIGHT-COLOR: transparent; TOP: 45.45%; BEHAVIOR: url(#HDXAlphaBehavior) url(#BindingBehavior); BACKGROUND-COLOR: #000000; ROWS: 1" globalscripts hdxproperties="borderWidth:;durationFormat:-1;fillColor:#000000;fillColorBlink:False;HDXBINDINGID:-4;lineColor:#00ff00;lineColorBlink:False;numberOfChars:5;numericDisplayFormat:%.1f;textColor:#00ff00;textColorBlink:False;" printDisplayValue="9999.99">9999.99</TEXTAREA>]]></content></html><binding><dataobject ID="dso1" class="progid:HSCDataSources.Application" type="DataSourceObj.Generic"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.3"/><property name="ObjectType" varianttype="VT_UI1" value="0"/><property name="UpdatePeriod" varianttype="VT_UI2" value="0"/><property name="SecurityLevel" varianttype="VT_UI1" value="0"/><property name="AddressType" varianttype="VT_UI1" value="0"/><property name="AddressFlags" varianttype="VT_UI1" value="0"/><property name="PointRefPointName" varianttype="VT_BSTR" value="{%Point::Tag%}"/><property name="PointRefParamName" varianttype="VT_BSTR" value="PV"/><property name="PointRefParamOffset" varianttype="VT_UI4" value="0"/><property name="PointRefFlags" varianttype="VT_UI4" value="0"/><property name="CalloutElement" varianttype="VT_BSTR" value=""/><property name="ParameterFormat" varianttype="VT_BOOL" value="0"/><property name="PresentationType" varianttype="VT_UI1" value="0"/></dataobjectdef></dataobject><class ID="HSC.AlphaReadOnly" refcount="1"/></binding></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=line029 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 2.27%; FONT-FAMILY: Arial; WIDTH: 96.66%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 1.66%; TOP: 40.9%; BEHAVIOR: url(#HDXVectorFactory#line)" hdxproperties="fillColorBlink:False;Height:1;LineColor:#000000;lineColorBlink:False;points:0 0 100 0 ;Width:58;"></DIV>]]></content></html></element><html><content><![CDATA[
<DIV tabIndex=-1 id=group002 class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 44px; WIDTH: 60px; POSITION: absolute; LEFT: 410px; TOP: 100px; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:44;lineColorBlink:False;Width:60;"></DIV>]]></content></html></group></shape></shapes></shapefile>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0"?><shapefile version="1" type="dynamic" title="" description="" useFirstShapeForBadValue="0" width="78px" height="44px"><versionheaders><versionheader name="PS" value="6.51"/></versionheaders><parameters><parameter name="TAG" type="Point" description="" defaultvalue=""/></parameters><shapes><shape><group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=rect022 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 100%; FONT-FAMILY: Arial; WIDTH: 100%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#rect)" hdxproperties="FillColor:#808080;fillColorBlink:False;Height:44;LineColor:#000000;lineColorBlink:False;Width:78;"></DIV>]]></content></html></element><element><html><content><![CDATA[<TEXTAREA tabIndex=-1 id=alpha121 title="" class=hsc.alpha.1 style="BORDER-TOP-STYLE: none; FONT-SIZE: 14pt; OVERFLOW: hidden; WORD-WRAP: normal; TEXT-DECORATION: none; BORDER-LEFT-STYLE: none; HEIGHT: 52.27%; FONT-FAMILY: Arial; BORDER-TOP-COLOR: transparent; WIDTH: 96.15%; BORDER-BOTTOM-STYLE: none; POSITION: absolute; BORDER-LEFT-COLOR: transparent; FONT-WEIGHT: normal; COLOR: #00ff00; FONT-STYLE: normal; BORDER-BOTTOM-COLOR: transparent; TEXT-ALIGN: right; BORDER-RIGHT-STYLE: none; LEFT: 1.28%; BORDER-RIGHT-COLOR: transparent; TOP: 43.18%; BEHAVIOR: url(#HDXAlphaBehavior) url(#BindingBehavior); BACKGROUND-COLOR: #000000; ROWS: 1" hdxproperties="borderWidth:;fillColor:#000000;fillColorBlink:False;HDXBINDINGID:-4;lineColor:#00ff00;lineColorBlink:False;numberOfChars:9;numericDisplayFormat:%.1f;textColor:#00ff00;textColorBlink:False;" globalscripts printDisplayValue="9999.99">9999.99</TEXTAREA>]]></content></html><binding><dataobject ID="dso1" class="progid:HSCDataSources.Application" type="DataSourceObj.Generic"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.3"/><property name="ObjectType" varianttype="VT_UI1" value="0"/><property name="UpdatePeriod" varianttype="VT_UI2" value="0"/><property name="SecurityLevel" varianttype="VT_UI1" value="0"/><property name="AddressType" varianttype="VT_UI1" value="0"/><property name="AddressFlags" varianttype="VT_UI1" value="0"/><property name="PointRefPointName" varianttype="VT_BSTR" value="{%Point::TAG%}"/><property name="PointRefParamName" varianttype="VT_BSTR" value="PV"/><property name="PointRefParamOffset" varianttype="VT_UI4" value="0"/><property name="PointRefFlags" varianttype="VT_UI4" value="0"/><property name="CalloutElement" varianttype="VT_BSTR" value=""/><property name="ParameterFormat" varianttype="VT_BOOL" value="0"/><property name="PresentationType" varianttype="VT_UI1" value="0"/></dataobjectdef></dataobject><class ID="HSC.AlphaReadOnly" refcount="1"/></binding></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=line029 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 2.27%; FONT-FAMILY: Arial; WIDTH: 96.15%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 1.28%; TOP: 40.9%; BEHAVIOR: url(#HDXVectorFactory#line)" hdxproperties="fillColorBlink:False;Height:1;LineColor:#000000;lineColorBlink:False;points:0.000000 0.000000 100.000000 0.000000 ;Width:75;"></DIV>]]></content></html></element><html><content><![CDATA[
<DIV tabIndex=-1 id=group002 class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 44px; WIDTH: 78px; POSITION: absolute; LEFT: 200px; TOP: 86px; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:44;lineColorBlink:False;Width:78;"></DIV>]]></content></html></group></shape></shapes></shapefile>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,39 @@
<?xml version="1.0"?><shapefile version="1" type="dynamic" title="" description="" useFirstShapeForBadValue="0" width="44px" height="44px"><versionheaders><versionheader name="PS" value="6.51"/></versionheaders><parameters><parameter name="Pump" type="Point" description="" defaultvalue=""/></parameters><shapes><shape><group><element><html><content><![CDATA[<TEXTAREA tabIndex=0 id=alpha001 class="hsc.alpha.1 HSC.ScriptDataObject.1" style="BORDER-TOP-STYLE: none; FONT-SIZE: 8pt; OVERFLOW: hidden; WORD-WRAP: normal; TEXT-DECORATION: none; BORDER-LEFT-STYLE: none; HEIGHT: 38.63%; FONT-FAMILY: Tahoma; WIDTH: 34.09%; BORDER-BOTTOM-STYLE: none; POSITION: absolute; FONT-WEIGHT: normal; COLOR: #00ff00; FONT-STYLE: normal; TEXT-ALIGN: center; BORDER-RIGHT-STYLE: none; LEFT: 18.18%; TOP: 13.63%; BEHAVIOR: url(#HDXAlphaBehavior) url(#BindingBehavior) url(#HoverBehavior) url(#HSCScriptDataBehavior); VISIBILITY: hidden; BACKGROUND-COLOR: #000000; ROWS: 1" hdxproperties="fillColor:#000000;fillColorBlink:False;HDXBINDINGID:-4;lineColor:black;lineColorBlink:False;numberOfChars:9;numericDisplayFormat:%s;textColor:#00ff00;textColorBlink:False;">Text</TEXTAREA>]]></content><scripts><script event="ondatachange" language="VBScript"><![CDATA[if me.DataValue("Pump.pv") < 4 then
Remote.style.visibility = "hidden"
Local.style.visibility = "visible"
else
Local.style.visibility = "hidden"
Remote.style.visibility = "visible"
end if
if me.Datavalue("Pump.PV") = 0 or me.Datavalue("Pump.PV") = 4 then
PumpLeftGreen.style.visibility = "visible"
else
PumpLeftGreen.style.visibility = "hidden"
end if
if me.Datavalue("Pump.PV") = 1 or me.Datavalue("Pump.PV") = 5 then
PumpLeftRed.Style.visibility = "visible"
else
PumpLeftRed.style.visibility = "hidden"
end if
if me.Datavalue("Pump.PV") = 2 or me.Datavalue("Pump.PV") = 3 or me.Datavalue("Pump.PV") = 6 or me.DataValue("Pump.PV") = 7 then
PumpLeftYellow.Style.Visibility = "visible"
else
PumpLeftYellow.style.visibility = "hidden"
end if]]></script></scripts></html><binding><dataobject ID="sdo1" class="progid:HSCDataSources.Application" type="HMIPage.ScriptData"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.1"/><property name="CommaDelimitedPointNames" varianttype="VT_BSTR" value="{%Point::Pump%},"/><property name="CommaDelimitedParameters" varianttype="VT_BSTR" value="PV,"/><property name="CommaDelimitedPresentationTypes" varianttype="VT_BSTR" value="0,"/><property name="CommaDelimitedSecurityLevels" varianttype="VT_BSTR" value="2,"/><property name="CommaDelimitedUpdateRates" varianttype="VT_BSTR" value="0,"/><property name="CommaDelimitedAllowFastUpdateValues" varianttype="VT_BSTR" value="0,"/><property name="CommaDelimitedCalloutElements" varianttype="VT_BSTR" value=","/></dataobjectdef></dataobject><class ID="HSC.ScriptDataobject.1" refcount="1"/></binding></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=PumpLeftGrey class="hsc.image.1 HSC.ScriptDataObject.1" style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 100%; FONT-FAMILY: Arial; WIDTH: 97.72%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#image) url(#HSCScriptDataBehavior) url(#BindingBehavior)" hdxproperties="fillColorBlink:False;HDXBINDINGID:-4;Height:44;lineColorBlink:False;Src:.\Pump-Left-Trip-Remote_files\PumpMIdGrey.gif;Width:43;" shapesrc=".\Pump-Left-Trip-Remote_files\PumpMIdGrey.gif"></DIV>]]></content></html><binding><dataobject ID="sdo1" class="progid:HSCDataSources.Application" type="HMIPage.ScriptData"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.1"/><property name="CommaDelimitedPointNames" varianttype="VT_BSTR" value="{%Point::Pump%},"/><property name="CommaDelimitedParameters" varianttype="VT_BSTR" value="PV,"/><property name="CommaDelimitedPresentationTypes" varianttype="VT_BSTR" value="0,"/><property name="CommaDelimitedSecurityLevels" varianttype="VT_BSTR" value="2,"/><property name="CommaDelimitedUpdateRates" varianttype="VT_BSTR" value="0,"/><property name="CommaDelimitedAllowFastUpdateValues" varianttype="VT_BSTR" value="0,"/><property name="CommaDelimitedCalloutElements" varianttype="VT_BSTR" value=","/></dataobjectdef></dataobject><class ID="HSC.ScriptDataobject.1" refcount="1"/></binding></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=PumpLeftGreen class=hsc.image.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 100%; FONT-FAMILY: Arial; WIDTH: 97.72%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#image) url(#BindingBehavior); VISIBILITY: hidden" hdxproperties="fillColorBlink:False;HDXBINDINGID:-4;Height:44;indicateAlarmState:False;indicateScanState:False;lineColorBlink:False;Src:.\Pump-Left-Trip-Remote_files\PumpMidGreen.gif;Width:43;" shapesrc=".\Pump-Left-Trip-Remote_files\PumpMidGreen.gif"></DIV>]]></content></html><binding/></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=PumpLeftRed class=hsc.image.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 100%; FONT-FAMILY: Arial; WIDTH: 97.72%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#image) url(#BindingBehavior); VISIBILITY: hidden" hdxproperties="fillColorBlink:False;HDXBINDINGID:-4;Height:44;lineColorBlink:False;Src:.\Pump-Left-Trip-Remote_files\PumpMidRed.gif;Width:43;" shapesrc=".\Pump-Left-Trip-Remote_files\PumpMidRed.gif"></DIV>]]></content></html><binding/></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=PumpLeftYellow class=hsc.image.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 100%; FONT-FAMILY: Arial; WIDTH: 97.72%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#image) url(#BindingBehavior); VISIBILITY: hidden" hdxproperties="fillColorBlink:False;HDXBINDINGID:-4;Height:44;indicateAlarmState:False;indicateScanState:False;lineColorBlink:False;Src:.\Pump-Left-Trip-Remote_files\PumpMidYellowGreyFlickering.gif;Width:43;" shapesrc=".\Pump-Left-Trip-Remote_files\PumpMidYellowGreyFlickering.gif"></DIV>]]></content></html><binding/></element><group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=rect003 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 93.75%; FONT-FAMILY: Arial; WIDTH: 93.75%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#rect)" hdxproperties="FillColor:#ff0000;fillColorBlink:False;Height:15;LineColor:#000000;lineColorBlink:False;Width:15;"></DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=textbox001 class=hvg.textbox.1 style="FONT-SIZE: 9pt; OVERFLOW: hidden; TEXT-DECORATION: ; HEIGHT: 87.5%; FONT-FAMILY: Arial; WIDTH: 87.5%; POSITION: absolute; FONT-WEIGHT: normal; COLOR: #000000; FONT-STYLE: normal; TEXT-ALIGN: center; LEFT: 6.25%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="fillColorBlink:False;FillStyle:1;Height:14;lineColorBlink:False;LineStyle:0;textColor:#000000;textColorBlink:False;TotalRotation:-1;Width:14;">R</DIV>]]></content></html></element><html><content><![CDATA[
<DIV tabIndex=-1 id=REMOTE class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 36.36%; WIDTH: 36.36%; POSITION: absolute; LEFT: 36.36%; TOP: 25%; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:16;lineColorBlink:False;Width:16;"></DIV>]]></content></html></group><group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=rect004 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 93.75%; FONT-FAMILY: Arial; WIDTH: 93.75%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#rect)" hdxproperties="FillColor:#00ff00;fillColorBlink:False;Height:15;LineColor:#000000;lineColorBlink:False;Width:15;"></DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=textbox002 class=hvg.textbox.1 style="FONT-SIZE: 9pt; OVERFLOW: hidden; TEXT-DECORATION: ; HEIGHT: 93.75%; FONT-FAMILY: Arial; WIDTH: 75%; POSITION: absolute; FONT-WEIGHT: normal; COLOR: #000000; FONT-STYLE: normal; TEXT-ALIGN: center; LEFT: 12.5%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="fillColorBlink:False;FillStyle:1;Height:15;lineColorBlink:False;LineStyle:0;textColor:#000000;textColorBlink:False;TotalRotation:-1;Width:12;">L</DIV>]]></content></html></element><html><content><![CDATA[
<DIV tabIndex=-1 id=LOCAL class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 36.36%; WIDTH: 36.36%; POSITION: absolute; LEFT: 36.36%; TOP: 25%; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:16;lineColorBlink:False;Width:16;"></DIV>]]></content></html></group><html><content><![CDATA[
<DIV tabIndex=-1 id=group001 class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 44px; WIDTH: 44px; POSITION: absolute; LEFT: 284px; TOP: 246px; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:44;lineColorBlink:False;Width:44;"></DIV>]]></content><scripts><script event="onmouseout" language="VBScript"><![CDATA[window.event.srcElement.style.cursor = "auto"]]></script><script event="onmouseover" language="VBScript"><![CDATA[window.event.srcElement.style.cursor = "hand"]]></script></scripts></html></group></shape></shapes></shapefile>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -0,0 +1,39 @@
<?xml version="1.0"?><shapefile version="1" type="dynamic" title="" description="" useFirstShapeForBadValue="0" width="40px" height="44px"><versionheaders><versionheader name="PS" value="6.51"/></versionheaders><parameters><parameter name="Pump" type="Point" description="" defaultvalue=""/></parameters><shapes><shape><group><element><html><content><![CDATA[<TEXTAREA tabIndex=0 id=alpha001 class="hsc.alpha.1 HSC.ScriptDataObject.1" style="BORDER-TOP-STYLE: none; FONT-SIZE: 8pt; OVERFLOW: hidden; WORD-WRAP: normal; TEXT-DECORATION: none; BORDER-LEFT-STYLE: none; HEIGHT: 43.18%; FONT-FAMILY: Tahoma; WIDTH: 47.5%; BORDER-BOTTOM-STYLE: none; POSITION: absolute; FONT-WEIGHT: normal; COLOR: #00ff00; FONT-STYLE: normal; TEXT-ALIGN: center; BORDER-RIGHT-STYLE: none; LEFT: 20%; TOP: 20.45%; BEHAVIOR: url(#HDXAlphaBehavior) url(#BindingBehavior) url(#HoverBehavior) url(#HSCScriptDataBehavior); VISIBILITY: hidden; BACKGROUND-COLOR: #000000; ROWS: 1" hdxproperties="fillColor:#000000;fillColorBlink:False;HDXBINDINGID:-4;lineColor:black;lineColorBlink:False;numberOfChars:9;numericDisplayFormat:%s;textColor:#00ff00;textColorBlink:False;">Text</TEXTAREA>]]></content><scripts><script event="ondatachange" language="VBScript"><![CDATA[if me.DataValue("Pump.PV") < 4 then
Remote.style.visibility = "hidden"
Local.style.visibility = "visible"
else
Local.style.visibility = "hidden"
Remote.style.visibility = "visible"
end if
if me.Datavalue("Pump.PV") = 0 or me.Datavalue("Pump.PV") = 4 then
PumpRightGreen.style.visibility = "visible"
else
PumpRightGreen.style.visibility = "hidden"
end if
if me.Datavalue("Pump.PV") = 1 or me.Datavalue("Pump.PV") = 5 then
PumpRightRed.Style.visibility = "visible"
else
PumpRightRed.style.visibility = "hidden"
end if
if me.Datavalue("Pump.PV") = 2 or me.Datavalue("Pump.PV") = 6 then
PumpRightYellow.Style.Visibility = "visible"
else
PumpRightYellow.style.visibility = "hidden"
end if]]></script></scripts></html><binding><dataobject ID="sdo1" class="progid:HSCDataSources.Application" type="HMIPage.ScriptData"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.1"/><property name="CommaDelimitedPointNames" varianttype="VT_BSTR" value="{%Point::Pump%},"/><property name="CommaDelimitedParameters" varianttype="VT_BSTR" value="PV,"/><property name="CommaDelimitedPresentationTypes" varianttype="VT_BSTR" value="0,"/><property name="CommaDelimitedSecurityLevels" varianttype="VT_BSTR" value="2,"/><property name="CommaDelimitedUpdateRates" varianttype="VT_BSTR" value="0,"/><property name="CommaDelimitedAllowFastUpdateValues" varianttype="VT_BSTR" value="0,"/><property name="CommaDelimitedCalloutElements" varianttype="VT_BSTR" value=","/></dataobjectdef></dataobject><class ID="HSC.ScriptDataobject.1" refcount="1"/></binding></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=PumpRightGrey class=hsc.image.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 100%; FONT-FAMILY: Arial; WIDTH: 100%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#image)" hdxproperties="fillColorBlink:False;Height:44;lineColorBlink:False;Src:.\Pump-Right-Trip-Remote_files\PumpRightMidGrey.gif;Width:40;" shapesrc=".\Pump-Right-Trip-Remote_files\PumpRightMidGrey.gif"></DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=PumpRightGreen class=hsc.image.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 100%; FONT-FAMILY: Arial; WIDTH: 100%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#image)" hdxproperties="fillColorBlink:False;Height:44;lineColorBlink:False;Src:.\Pump-Right-Trip-Remote_files\PumpRightMidGreen.gif;Width:40;" shapesrc=".\Pump-Right-Trip-Remote_files\PumpRightMidGreen.gif"></DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=PumpRightRed class=hsc.image.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 100%; FONT-FAMILY: Arial; WIDTH: 100%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#image)" hdxproperties="fillColorBlink:False;Height:44;lineColorBlink:False;Src:.\Pump-Right-Trip-Remote_files\PumpRightMidRed.gif;Width:40;" shapesrc=".\Pump-Right-Trip-Remote_files\PumpRightMidRed.gif"></DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=PumpRightYellow class=hsc.image.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 100%; FONT-FAMILY: Arial; WIDTH: 100%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#image)" hdxproperties="fillColorBlink:False;Height:44;lineColorBlink:False;Src:.\Pump-Right-Trip-Remote_files\PumpRightMidYellowGreyFlickering.gif;Width:40;" shapesrc=".\Pump-Right-Trip-Remote_files\PumpRightMidYellowGreyFlickering.gif"></DIV>]]></content></html></element><group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=rect003 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 93.75%; FONT-FAMILY: Arial; WIDTH: 93.75%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#rect)" hdxproperties="FillColor:#ff0000;fillColorBlink:False;Height:15;LineColor:#000000;lineColorBlink:False;Width:15;"></DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=textbox001 class=hvg.textbox.1 style="FONT-SIZE: 9pt; OVERFLOW: hidden; TEXT-DECORATION: ; HEIGHT: 87.5%; FONT-FAMILY: Arial; WIDTH: 87.5%; POSITION: absolute; FONT-WEIGHT: normal; COLOR: #000000; FONT-STYLE: normal; TEXT-ALIGN: center; LEFT: 6.25%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="fillColorBlink:False;FillStyle:1;Height:14;lineColorBlink:False;LineStyle:0;textColor:#000000;textColorBlink:False;TotalRotation:-1;Width:14;">R</DIV>]]></content></html></element><html><content><![CDATA[
<DIV tabIndex=-1 id=REMOTE class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 36.36%; WIDTH: 40%; POSITION: absolute; LEFT: 27.5%; TOP: 27.27%; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:16;lineColorBlink:False;Width:16;"></DIV>]]></content></html></group><group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=rect004 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 93.75%; FONT-FAMILY: Arial; WIDTH: 93.75%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#rect)" hdxproperties="FillColor:#00ff00;fillColorBlink:False;Height:15;LineColor:#000000;lineColorBlink:False;Width:15;"></DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=textbox002 class=hvg.textbox.1 style="FONT-SIZE: 9pt; OVERFLOW: hidden; TEXT-DECORATION: ; HEIGHT: 93.75%; FONT-FAMILY: Arial; WIDTH: 75%; POSITION: absolute; FONT-WEIGHT: normal; COLOR: #000000; FONT-STYLE: normal; TEXT-ALIGN: center; LEFT: 12.5%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="fillColorBlink:False;FillStyle:1;Height:15;lineColorBlink:False;LineStyle:0;textColor:#000000;textColorBlink:False;TotalRotation:-1;Width:12;">L</DIV>]]></content></html></element><html><content><![CDATA[
<DIV tabIndex=-1 id=LOCAL class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 36.36%; WIDTH: 40%; POSITION: absolute; LEFT: 27.5%; TOP: 27.27%; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:16;lineColorBlink:False;Width:16;"></DIV>]]></content></html></group><html><content><![CDATA[
<DIV tabIndex=-1 id=group003 class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 44px; WIDTH: 40px; POSITION: absolute; LEFT: 426px; TOP: 257px; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:44;lineColorBlink:False;Width:40;"></DIV>]]></content><scripts><script event="onmouseout" language="VBScript"><![CDATA[window.event.srcElement.style.cursor = "auto"]]></script><script event="onmouseover" language="VBScript"><![CDATA[window.event.srcElement.style.cursor = "hand"]]></script></scripts></html></group></shape></shapes></shapefile>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -0,0 +1 @@
<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>

View File

@@ -0,0 +1,40 @@
<?xml version="1.0"?><shapefile version="1" type="dynamic" title="" description="" useFirstShapeForBadValue="0" width="22px" height="34px"><versionheaders><versionheader name="PS" value="6.51"/></versionheaders><parameters><parameter name="TAG" type="Point" description="" defaultvalue=""/></parameters><shapes><shape><group><element><html><content><![CDATA[<TEXTAREA tabIndex=0 id=alpha001 class="hsc.alpha.1 HSC.ScriptDataObject.1" style="BORDER-TOP-STYLE: none; FONT-SIZE: 8pt; OVERFLOW: hidden; WORD-WRAP: normal; TEXT-DECORATION: none; BORDER-LEFT-STYLE: none; HEIGHT: 29.41%; FONT-FAMILY: Tahoma; WIDTH: 63.63%; BORDER-BOTTOM-STYLE: none; POSITION: absolute; FONT-WEIGHT: normal; COLOR: #00ff00; FONT-STYLE: normal; TEXT-ALIGN: center; BORDER-RIGHT-STYLE: none; LEFT: 18.18%; TOP: 5.88%; BEHAVIOR: url(#HDXAlphaBehavior) url(#BindingBehavior) url(#HoverBehavior) url(#HSCScriptDataBehavior); VISIBILITY: hidden; BACKGROUND-COLOR: #000000; ROWS: 1" hdxproperties="fillColor:#000000;fillColorBlink:False;HDXBINDINGID:-4;indicateAlarmState:0;lineColor:black;lineColorBlink:False;numberOfChars:9;numericDisplayFormat:%s;textColor:#00ff00;textColorBlink:False;">Text</TEXTAREA>]]></content><scripts><script event="ondatachange" language="VBScript"><![CDATA[if me.DataValue("TAG.PV") > 4 then
Auto.InnerText = "R"
else
Auto.InnerText= "L"
end if
if me.Datavalue("TAG.PV") = 0 OR me.Datavalue("TAG.PV") > 2 then
Cyan.style.visibility = "visible"
else
Cyan.style.visibility = "hidden"
end if
if me.Datavalue("TAG.PV") = 1 OR me.DataValue("TAG.PV") = 5 then
Red.style.visibility = "visible"
else
Red.style.visibility = "hidden"
end if
if me.Datavalue("TAG.PV") = 2 OR me.DataValue("TAG.PV") = 6 then
Green.style.visibility = "visible"
else
Green.style.visibility = "hidden"
end if]]></script></scripts></html><binding><dataobject ID="sdo1" class="progid:HSCDataSources.Application" type="HMIPage.ScriptData"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.1"/><property name="CommaDelimitedPointNames" varianttype="VT_BSTR" value="{%Point::TAG%},"/><property name="CommaDelimitedParameters" varianttype="VT_BSTR" value="PV,"/><property name="CommaDelimitedPresentationTypes" varianttype="VT_BSTR" value="0,"/><property name="CommaDelimitedSecurityLevels" varianttype="VT_BSTR" value="2,"/><property name="CommaDelimitedUpdateRates" varianttype="VT_BSTR" value="0,"/><property name="CommaDelimitedAllowFastUpdateValues" varianttype="VT_BSTR" value="0,"/><property name="CommaDelimitedCalloutElements" varianttype="VT_BSTR" value=","/></dataobjectdef></dataobject><class ID="HSC.ScriptDataobject.1" refcount="1"/></binding></element><group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=line007 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 38.23%; FONT-FAMILY: Arial; WIDTH: 4.54%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 50%; TOP: 38.23%; BEHAVIOR: url(#HDXVectorFactory#line)" hdxproperties="fillColorBlink:False;Height:13;LineColor:#000000;lineColorBlink:False;points:0 100 0 0 ;Width:1;"></DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=polygon005 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 44.11%; FONT-FAMILY: Arial; WIDTH: 100%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 55.88%; BEHAVIOR: url(#HDXVectorFactory#polygon)" hdxproperties="FillColor:#00ffff;fillColorBlink:False;Height:15;LineColor:#000000;lineColorBlink:False;points:0.000000 0.000000 0.000000 100.000000 100.000000 0.000000 100.000000 100.000000 ;Width:22;"></DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=rect008 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 47.05%; FONT-FAMILY: Arial; WIDTH: 81.81%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 9.09%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#rect)" hdxproperties="FillColor:#00ffff;fillColorBlink:False;Height:16;LineColor:#000000;lineColorBlink:False;Width:18;"></DIV>]]></content></html></element><html><content><![CDATA[
<DIV tabIndex=-1 id=CYAN class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 100%; WIDTH: 100%; POSITION: absolute; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:34;lineColorBlink:False;Width:22;"></DIV>]]></content></html></group><group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=line003 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 38.23%; FONT-FAMILY: Arial; WIDTH: 4.54%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 50%; TOP: 35.29%; BEHAVIOR: url(#HDXVectorFactory#line)" hdxproperties="fillColorBlink:False;Height:13;LineColor:#000000;lineColorBlink:False;points:0 100 0 0 ;Width:1;"></DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=polygon001 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 44.11%; FONT-FAMILY: Arial; WIDTH: 100%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 52.94%; BEHAVIOR: url(#HDXVectorFactory#polygon)" hdxproperties="FillColor:#00ff00;fillColorBlink:False;Height:15;LineColor:#000000;lineColorBlink:False;points:0.000000 0.000000 0.000000 100.000000 100.000000 0.000000 100.000000 100.000000 ;Width:22;"></DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=rect001 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 47.05%; FONT-FAMILY: Arial; WIDTH: 81.81%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 9.09%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#rect)" hdxproperties="FillColor:#00ff00;fillColorBlink:False;Height:16;LineColor:#000000;lineColorBlink:False;Width:18;"></DIV>]]></content></html></element><html><content><![CDATA[
<DIV tabIndex=-1 id=GREEN class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 100%; WIDTH: 100%; POSITION: absolute; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:34;lineColorBlink:False;Width:22;"></DIV>]]></content></html></group><group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=line006 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 38.23%; FONT-FAMILY: Arial; WIDTH: 4.54%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 50%; TOP: 38.23%; BEHAVIOR: url(#HDXVectorFactory#line)" hdxproperties="fillColorBlink:False;Height:13;LineColor:#000000;lineColorBlink:False;points:0 100 0 0 ;Width:1;"></DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=polygon004 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 44.11%; FONT-FAMILY: Arial; WIDTH: 100%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 55.88%; BEHAVIOR: url(#HDXVectorFactory#polygon)" hdxproperties="FillColor:#ff0000;fillColorBlink:False;Height:15;LineColor:#000000;lineColorBlink:False;points:0.000000 0.000000 0.000000 100.000000 100.000000 0.000000 100.000000 100.000000 ;Width:22;"></DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=rect007 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 47.05%; FONT-FAMILY: Arial; WIDTH: 81.81%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 9.09%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#rect)" hdxproperties="FillColor:#ff0000;fillColorBlink:False;Height:16;LineColor:#000000;lineColorBlink:False;Width:18;"></DIV>]]></content></html></element><html><content><![CDATA[
<DIV tabIndex=-1 id=RED class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 100%; WIDTH: 100%; POSITION: absolute; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:34;lineColorBlink:False;Width:22;"></DIV>]]></content></html></group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=AUTO class=hvg.textbox.1 style="FONT-SIZE: 10pt; OVERFLOW: hidden; HEIGHT: 38.23%; FONT-FAMILY: MS UI Gothic; WIDTH: 45.45%; POSITION: absolute; FONT-WEIGHT: 400; COLOR: #000000; FONT-STYLE: normal; TEXT-ALIGN: left; LEFT: 27.27%; TOP: 2.94%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="fillColorBlink:False;FillStyle:1;Height:13;lineColorBlink:False;LineStyle:0;textColor:#000000;textColorBlink:False;TotalRotation:-1;Width:10;">M</DIV>]]></content></html></element><html><content><![CDATA[
<DIV tabIndex=-1 id=group001 class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 34px; WIDTH: 22px; POSITION: absolute; LEFT: 59px; TOP: 65px; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:34;lineColorBlink:False;Width:22;"></DIV>]]></content><scripts><script event="onmouseover" language="VBScript"><![CDATA[window.event.srcElement.style.cursor = "hand"]]></script><script event="onmouseout" language="VBScript"><![CDATA[window.event.srcElement.style.cursor = "auto"]]></script></scripts></html></group></shape></shapes></shapefile>

View File

@@ -0,0 +1,40 @@
<?xml version="1.0"?><shapefile version="1" type="dynamic" title="" description="" useFirstShapeForBadValue="0" width="22px" height="34px"><versionheaders><versionheader name="PS" value="6.51"/></versionheaders><parameters><parameter name="TAG" type="Point" description="" defaultvalue=""/></parameters><shapes><shape><group><element><html><content><![CDATA[<TEXTAREA tabIndex=0 id=alpha001 class="hsc.alpha.1 HSC.ScriptDataObject.1" style="BORDER-TOP-STYLE: none; FONT-SIZE: 8pt; OVERFLOW: hidden; WORD-WRAP: normal; TEXT-DECORATION: none; BORDER-LEFT-STYLE: none; HEIGHT: 29.41%; FONT-FAMILY: Tahoma; WIDTH: 63.63%; BORDER-BOTTOM-STYLE: none; POSITION: absolute; FONT-WEIGHT: normal; COLOR: #00ff00; FONT-STYLE: normal; TEXT-ALIGN: center; BORDER-RIGHT-STYLE: none; LEFT: 22.72%; TOP: 8.82%; BEHAVIOR: url(#HDXAlphaBehavior) url(#BindingBehavior) url(#HoverBehavior) url(#HSCScriptDataBehavior); VISIBILITY: hidden; BACKGROUND-COLOR: #000000; ROWS: 1" hdxproperties="fillColor:#000000;fillColorBlink:False;HDXBINDINGID:-4;indicateAlarmState:0;lineColor:black;lineColorBlink:False;numberOfChars:9;numericDisplayFormat:%s;textColor:#00ff00;textColorBlink:False;">Text</TEXTAREA>]]></content><scripts><script event="ondatachange" language="VBScript"><![CDATA[if me.DataValue("TAG.PV") > 4 then
Auto.InnerText = "A"
else
Auto.InnerText= "M"
end if
if me.Datavalue("TAG.PV") = 0 OR me.Datavalue("TAG.PV") > 2 then
Cyan.style.visibility = "visible"
else
Cyan.style.visibility = "hidden"
end if
if me.Datavalue("TAG.PV") = 1 then
Red.style.visibility = "visible"
else
Red.style.visibility = "hidden"
end if
if me.Datavalue("TAG.PV") = 2 then
Green.style.visibility = "visible"
else
Green.style.visibility = "hidden"
end if]]></script></scripts></html><binding><dataobject ID="sdo1" class="progid:HSCDataSources.Application" type="HMIPage.ScriptData"><dataobjectdef format="propertybag"><property name="version" varianttype="VT_R4" value="1.1"/><property name="CommaDelimitedPointNames" varianttype="VT_BSTR" value="{%Point::TAG%},"/><property name="CommaDelimitedParameters" varianttype="VT_BSTR" value="PV,"/><property name="CommaDelimitedPresentationTypes" varianttype="VT_BSTR" value="0,"/><property name="CommaDelimitedSecurityLevels" varianttype="VT_BSTR" value="2,"/><property name="CommaDelimitedUpdateRates" varianttype="VT_BSTR" value="0,"/><property name="CommaDelimitedAllowFastUpdateValues" varianttype="VT_BSTR" value="0,"/><property name="CommaDelimitedCalloutElements" varianttype="VT_BSTR" value=","/></dataobjectdef></dataobject><class ID="HSC.ScriptDataobject.1" refcount="1"/></binding></element><group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=line007 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 38.23%; FONT-FAMILY: Arial; WIDTH: 4.54%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 50%; TOP: 38.23%; BEHAVIOR: url(#HDXVectorFactory#line)" hdxproperties="fillColorBlink:False;Height:13;LineColor:#000000;lineColorBlink:False;points:0 100 0 0 ;Width:1;"></DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=polygon005 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 44.11%; FONT-FAMILY: Arial; WIDTH: 100%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 55.88%; BEHAVIOR: url(#HDXVectorFactory#polygon)" hdxproperties="FillColor:#00ffff;fillColorBlink:False;Height:15;LineColor:#000000;lineColorBlink:False;points:0.000000 0.000000 0.000000 100.000000 100.000000 0.000000 100.000000 100.000000 ;Width:22;"></DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=rect008 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 47.05%; FONT-FAMILY: Arial; WIDTH: 81.81%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 9.09%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#rect)" hdxproperties="FillColor:#00ffff;fillColorBlink:False;Height:16;LineColor:#000000;lineColorBlink:False;Width:18;"></DIV>]]></content></html></element><html><content><![CDATA[
<DIV tabIndex=-1 id=CYAN class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 100%; WIDTH: 100%; POSITION: absolute; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:34;lineColorBlink:False;Width:22;"></DIV>]]></content></html></group><group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=line003 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 38.23%; FONT-FAMILY: Arial; WIDTH: 4.54%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 50%; TOP: 35.29%; BEHAVIOR: url(#HDXVectorFactory#line)" hdxproperties="fillColorBlink:False;Height:13;LineColor:#000000;lineColorBlink:False;points:0 100 0 0 ;Width:1;"></DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=polygon001 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 44.11%; FONT-FAMILY: Arial; WIDTH: 100%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 52.94%; BEHAVIOR: url(#HDXVectorFactory#polygon)" hdxproperties="FillColor:#00ff00;fillColorBlink:False;Height:15;LineColor:#000000;lineColorBlink:False;points:0.000000 0.000000 0.000000 100.000000 100.000000 0.000000 100.000000 100.000000 ;Width:22;"></DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=rect001 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 47.05%; FONT-FAMILY: Arial; WIDTH: 81.81%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 9.09%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#rect)" hdxproperties="FillColor:#00ff00;fillColorBlink:False;Height:16;LineColor:#000000;lineColorBlink:False;Width:18;"></DIV>]]></content></html></element><html><content><![CDATA[
<DIV tabIndex=-1 id=GREEN class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 100%; WIDTH: 100%; POSITION: absolute; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:34;lineColorBlink:False;Width:22;"></DIV>]]></content></html></group><group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=line006 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 38.23%; FONT-FAMILY: Arial; WIDTH: 4.54%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 50%; TOP: 38.23%; BEHAVIOR: url(#HDXVectorFactory#line)" hdxproperties="fillColorBlink:False;Height:13;LineColor:#000000;lineColorBlink:False;points:0 100 0 0 ;Width:1;"></DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=polygon004 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 44.11%; FONT-FAMILY: Arial; WIDTH: 100%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 0%; TOP: 55.88%; BEHAVIOR: url(#HDXVectorFactory#polygon)" hdxproperties="FillColor:#ff0000;fillColorBlink:False;Height:15;LineColor:#000000;lineColorBlink:False;points:0.000000 0.000000 0.000000 100.000000 100.000000 0.000000 100.000000 100.000000 ;Width:22;"></DIV>]]></content></html></element><element><html><content><![CDATA[
<DIV tabIndex=-1 id=rect007 class=hvg.base.1 style="FONT-SIZE: 12pt; OVERFLOW: hidden; TEXT-DECORATION: none; HEIGHT: 47.05%; FONT-FAMILY: Arial; WIDTH: 81.81%; POSITION: absolute; FONT-WEIGHT: 400; FONT-STYLE: normal; LEFT: 9.09%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#rect)" hdxproperties="FillColor:#ff0000;fillColorBlink:False;Height:16;LineColor:#000000;lineColorBlink:False;Width:18;"></DIV>]]></content></html></element><html><content><![CDATA[
<DIV tabIndex=-1 id=RED class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 100%; WIDTH: 100%; POSITION: absolute; LEFT: 0%; TOP: 0%; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:34;lineColorBlink:False;Width:22;"></DIV>]]></content></html></group><element><html><content><![CDATA[
<DIV tabIndex=-1 id=AUTO class=hvg.textbox.1 style="FONT-SIZE: 10pt; OVERFLOW: hidden; HEIGHT: 38.23%; FONT-FAMILY: MS UI Gothic; WIDTH: 45.45%; POSITION: absolute; FONT-WEIGHT: 400; COLOR: #000000; FONT-STYLE: normal; TEXT-ALIGN: left; LEFT: 27.27%; TOP: 2.94%; BEHAVIOR: url(#HDXVectorFactory#text)" hdxproperties="fillColorBlink:False;FillStyle:1;Height:13;lineColorBlink:False;LineStyle:0;textColor:#000000;textColorBlink:False;TotalRotation:-1;Width:10;">M</DIV>]]></content></html></element><html><content><![CDATA[
<DIV tabIndex=-1 id=group004 class=hvg.group.1 style="FONT-SIZE: 0pt; HEIGHT: 34px; WIDTH: 22px; POSITION: absolute; LEFT: 21px; TOP: 7px; BEHAVIOR: url(#HDXVectorFactory#group)" hdxproperties="fillColorBlink:False;Height:34;lineColorBlink:False;Width:22;"></DIV>]]></content></html></group></shape></shapes></shapefile>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,243 @@
# P&ID 재설계 - 코딩 계획 (독립 프로세스 병렬 아키텍처)
> 작성일: 2026-05-05
> 상태: 코드 구현 완료 (Phase 1~4 완료, Phase 5는 vLLM 서버 필요)
> 완료 시각: 2026-05-05T14:34 KST
> 목표: `No-10_Plant_PID.dxf`(28,819 엔티티) 처리 시 30분 타임아웃 해결
> 아키텍처: pid_worker.py(코디네이터) + 5개 독립 추출 프로세스
---
## DXF 구조 분석 결과 (2026-05-05 확인)
**No-10_Plant_PID.dxf 실제 구조:**
- 전체 X 범위: -176.5 ~ 5582.9 (너비 5,759)
- 전체 Y 범위: 4822.0 ~ 6756.3 (높이 1,934)
- TITLE 레이어: 긴 수직 LINE 없음 (도면 경계 LINE X)
- X 축 분포: 3800-3900 부근 sparse gap (자연적 분할 지점)
- Y 축 분포: 5100-5300에 밀집 (메인 도면 영역)
**분할 전략 변경:** TITLE 레이어 LINE 기반 → X/Y 축 엔티티 밀도 기반 sparse region 감지
---
## 제안 아키텍처
```
┌─────────────────────────────────────────────────────────────────┐
│ pid_worker.py (코디네이터) │
│ │
│ Phase 1: 도면 분할 + 기하 추출 (순차) │
│ ├─ extractor.split_drawings() → 밀도 기반 도면 영역 분할 │
│ └─ extractor.extract_and_save() → geo.json │
│ │
│ Phase 2: 전체 텍스트 1회 추출 │
│ └─ DXF에서 TEXT/MTEXT → full_text.txt (1회만 읽기) │
│ │
│ Phase 3: 5개 독립 프로세스 병렬 실행 ← 진짜 병렬 │
│ ├─ subprocess: pid_extract_sensor.py → results/sensor.json │
│ ├─ subprocess: pid_extract_valve.py → results/valve.json │
│ ├─ subprocess: pid_extract_system.py → results/system.json │
│ ├─ subprocess: pid_extract_gauge.py → results/gauge.json │
│ └─ subprocess: pid_extract_pump.py → results/pump.json │
│ │
│ Phase 4: pid_worker가 결과 파일 읽어서 통합 │
│ ├─ 5개 JSON 파일 로드 │
│ ├─ 중복 제거 (tagNo 기준) │
│ └─ 위상 그래프 빌드 + 태그 매핑 │
│ │
│ Phase 5: 저장 + 응답 │
└─────────────────────────────────────────────────────────────────┘
```
---
## 변경 대상 파일
| 파일 | 변경 내용 |
|------|-----------|
| `mcp-server/pipeline/extractor.py` | 도면 분할 로직 추가 (밀도 기반) |
| `mcp-server/worker/pid_worker.py` | 코디네이터 로직: 프로세스 관리, 결과 통합 |
| `mcp-server/worker/pid_extract_sensor.py` | 신규: 센서 전용 추출기 (독립 프로세스) |
| `mcp-server/worker/pid_extract_valve.py` | 신규: 밸브 전용 추출기 (독립 프로세스) |
| `mcp-server/worker/pid_extract_system.py` | 신규: 시스템 전용 추출기 (독립 프로세스) |
| `mcp-server/worker/pid_extract_gauge.py` | 신규: 게이지 전용 추출기 (독립 프로세스) |
| `mcp-server/worker/pid_extract_pump.py` | 신규: 펌프 전용 추출기 (독립 프로세스) |
| `mcp-server/pipeline/mapper.py` | 기존 유지 (이미 배치 처리 구현됨) |
| `mcp-server/pipeline/topology.py` | 기존 유지 (이미 SpatialGrid 구현됨) |
| `mcp-server/pipeline/legend_parser.py` | 기존 유지 (이미 계측기 그룹 정의됨) |
---
## 체크리스트
### Phase 1: 도면 분할 (밀도 기반)
- [x] 1-1. DXF 구조 분석 스크립트 작성 (X/Y 밀도 히스토그램 출력)
- [x] 1-2. sparse region 감지 알고리즘 프로토타입
- [x] 1-3. DrawingRegion 데이터클래스 + split_drawings() 메서드 추가
- [x] 1-4. extract_region() 메서드 추가
- [x] 1-5. 도면 분할 통합 테스트 (2개 영역 감지, 2.6초 처리)
### Phase 2: 독립 추출기 공통 템플릿
- [x] 2-1. 공통 추출기 템플릿 스크립트 작성 (CLI + vLLM 호출 + JSON 출력)
- [x] 2-2. 5개 계측기 유형별 프롬프트 상수 정의
### Phase 3: 5개 독립 추출기 스크립트
- [x] 3-1. pid_extract_sensor.py 생성
- [x] 3-2. pid_extract_valve.py 생성
- [x] 3-3. pid_extract_system.py 생성
- [x] 3-4. pid_extract_gauge.py 생성
- [x] 3-5. pid_extract_pump.py 생성
- [x] 3-6. 개별 추출기 단독 테스트
### Phase 4: pid_worker.py 코디네이터
- [x] 4-1. 전체 텍스트 1회 추출 로직 (full_text.txt 생성)
- [x] 4-2. 5개 프로세스 subprocess.Popen 병렬 실행
- [x] 4-3. 결과 JSON 통합 + tagNo 기준 중복 제거
- [x] 4-4. 기존 topology.py 호출 연동
- [x] 4-5. _build_pid_graph_parallel() 전체 교체
### Phase 5: 통합 테스트
- [ ] 5-1. 전체 파이프라인 통합 테스트 (No-10_Plant_PID.dxf)
- [ ] 5-2. 실패 격리 테스트 (1개 추출기 고의 실패)
- [ ] 5-3. 메모리 사용량 측정
---
## 실행 순서 및 의존성
```
Phase 1 (도면 분할)
├── 1-1 → 1-2 → 1-3 → 1-4 → 1-5 (순차)
Phase 2 (공통 템플릿)
├── 2-1 → 2-2 (순차, Phase 1 완료 후)
Phase 3 (5개 추출기 생성)
├── 3-1 ~ 3-5 (병렬 가능, Phase 2 완료 후)
├── 3-6 (순차, 3-1~3-5 완료 후)
Phase 4 (pid_worker 코디네이터)
├── 4-1 → 4-2 → 4-3 → 4-4 → 4-5 (순차, Phase 3 완료 후)
Phase 5 (통합 테스트)
├── 5-1 → 5-2 → 5-3 (순차, Phase 4 완료 후)
```
---
## 각 단계 상세 설명
### 1-1. DXF 구조 분석 스크립트 작성
- **파일**: `test_drawing_split.py` (신규)
- **목표**: DXF의 X/Y 축 엔티티 밀도 분포를 히스토그램으로 출력
- **작업**:
1. ezdxf로 DXF 로드
2. 각 엔티티의 중심 좌표 수집
3. X/Y 축별 100 단위 버킷으로 밀도 계산
4. sparse region (엔티티가 거의 없는 구간) 출력
- **완료 기준**: 콘솔에 X/Y 밀도 히스토그램 + sparse region 출력
### 1-2. sparse region 감지 알고리즘 프로토타입
- **파일**: `test_drawing_split.py` (확장)
- **목표**: 밀도 히스토그램에서 sparse region을 자동으로 감지
- **작업**:
1. X/Y 축 밀도 배열에서 연속된 low-density 구간 감지
2. 임계값: 버킷당 엔티티 수 < 전체 평균의 10%
3. sparse region을 도면 경계로 사용
- **완료 기준**: No-10_Plant_PID.dxf에서 2-3개 도면 영역 감지
### 1-3. DrawingRegion + split_drawings() 메서드
- **파일**: `mcp-server/pipeline/extractor.py`
- **목표**: PidGeometricExtractor에 도면 분할 기능 추가
- **작업**:
1. `DrawingRegion` 데이터클래스 정의 (drawing_no, x_min, x_max, y_min, y_max)
2. `split_drawings() -> List[DrawingRegion]` 메서드 추가
3. X/Y 밀도 기반 sparse region 감지 로직 구현
- **완료 기준**: `split_drawings()` 호출 시 도면 영역 목록 반환
### 1-4. extract_region() 메서드
- **파일**: `mcp-server/pipeline/extractor.py`
- **목표**: 특정 도면 영역만 추출하는 메서드
- **작업**:
1. `extract_region(region: DrawingRegion) -> List[GeometricEntity]` 추가
2. bbox가 region 범위 내에 있는 엔티티만 필터링
- **완료 기준**: 각 도면별 엔티티 수 합계가 전체와 일치
### 1-5. 도면 분할 통합 테스트
- **파일**: `test_drawing_split.py`
- **목표**: DXF 로드 → 분할 → 영역별 추출 → 검증
- **완료 기준**: 모든 도면 영역 정상 추출, 총 처리 < 30초
### 2-1. 공통 추출기 템플릿
- **파일**: `mcp-server/worker/pid_extract_template.py` (신규)
- **목표**: 5개 추출기가 공유하는 공통 로직
- **작업**:
1. CLI 인자 파싱: `--input`, `--output`, `--prompt-file`
2. vLLM HTTP 클라이언트 연결 (VLLM_BASE_URL 환경변수)
3. LLM 호출 → JSON 파싱 → 태그 목록 반환
4. `max_tokens=65536`, finish_reason=length 복구
5. 결과를 JSON 파일로 쓰기
- **완료 기준**: 템플릿 스크립트 단독 실행 가능
### 2-2. 계측기 유형별 프롬프트 정의
- **파일**: 각 추출기 스크립트 상단 상수
- **프롬프트 목록**:
- `_SENSOR_PROMPT` — FT, FIT, LT, PT, TE, PG, LG, TG
- `_VALVE_PROMPT` — FCV, TCV, LCV, PCV, XV, FV, LV, PV, TV
- `_SYSTEM_PROMPT` — LI, PI, TI, FIQ, FICQ, TICA, PICA, LICA
- `_GAUGE_PROMPT` — PG, TG, LG
- `_PUMP_PROMPT` — P-10101, VP-10117, DP-10101 등
### 3-1 ~ 3-5. 5개 추출기 스크립트
- 각 템플릿 기반, 해당 프롬프트 적용
- 단독 실행 가능 (CLI 인자로 input/output 전달)
### 3-6. 개별 추출기 테스트
- **파일**: `test_individual_extractors.py` (신규)
- 5개 추출기 각각 단독 실행, JSON schema 검증
### 4-1. 전체 텍스트 1회 추출
- **파일**: `mcp-server/worker/pid_worker.py`
- DXF에서 TEXT/MTEXT 한 번만 읽어 full_text.txt 생성
### 4-2. 5개 프로세스 병렬 실행
- **파일**: `mcp-server/worker/pid_worker.py`
- `subprocess.Popen`으로 5개 추출기 동시 실행
- timeout=300초, 실패 시 로깅 + 계속
### 4-3. 결과 통합 + 중복 제거
- **파일**: `mcp-server/worker/pid_worker.py`
- 5개 JSON 로드 → tagNo 기준 병합 (첫 번째 우선)
### 4-4. 위상 그래프 빌드 + 태그 매핑
- **파일**: `mcp-server/worker/pid_worker.py`
- 기존 `IntelligentMapper`, `TopologyBuilder` 호출
### 4-5. _build_pid_graph_parallel() 전체 교체
- **파일**: `mcp-server/worker/pid_worker.py`
- 기존 asyncio.gather 기반 코드를 새 아키텍처로 교체
### 5-1 ~ 5-3. 통합 테스트
- 전체 파이프라인, 실패 격리, 메모리 측정
---
## 주의 사항
1. **백업 필수**: 각 파일 수정 전 `.rooBackup/`에 백업
2. **diff 제시**: 변경 내용 diff 형식으로 제시 후 확인
3. **작은 단계**: 각 단계를 독립적으로 완료하고 검증
4. **테스트 우선**: 테스트 스크립트 먼저 작성 후 구현
5. **기존 코드 유지**: topology.py, mapper.py는 기존 유지
6. **프로세스 간 통신**: 파일 기반 (JSON)으로만 통신
7. **임시 파일 정리**: 각 요청 완료 후 임시 디렉토리 삭제
---
## 다음 시작 시
1. 이 파일의 체크리스트에서 첫 번째 미완료 항목부터 시작
2. 각 단계 완료 시 체크리스트 업데이트 ([ ] → [x])
3. 문제가 발생하면 해당 단계에서 중단하고 원인 분석
4. 완료 기준을 충족해야 다음 단계로 진행

Some files were not shown because too many files have changed in this diff Show More