feat(pid): six P&ID critique focuses join drawing_finalize; plan/preflight flag a P&ID intent on the wrong layer set

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-16 11:17:55 +03:00
co-authored by Claude Opus 5
parent 346eac4ac6
commit b4c1423c8e
6 changed files with 406 additions and 4 deletions
+11 -1
View File
@@ -408,6 +408,10 @@ _FOCUS_DISPATCH = {
"gdt": _check_gdt,
}
from .pid.critique import PID_DISPATCH # noqa: E402 (registered after the mechanical focuses)
_FOCUS_DISPATCH.update(PID_DISPATCH)
async def run_critique(
backend: AutoCADBackend,
@@ -416,6 +420,9 @@ async def run_critique(
"""Dispatch each requested focus check and return aggregated issues."""
foci = list(focus) if focus is not None else list(ALL_CRITIQUE_FOCUSES)
issues: list[Issue] = []
# Per-run context: the P&ID focuses build the connectivity graph once
# here and share it, so six focuses cost one read of the drawing.
shared: dict = {}
for f in foci:
check = _FOCUS_DISPATCH.get(f)
if check is None:
@@ -427,5 +434,8 @@ async def run_critique(
)
)
continue
issues.extend(await check(backend))
if getattr(check, "needs_shared", False):
issues.extend(await check(backend, shared))
else:
issues.extend(await check(backend))
return issues
+190
View File
@@ -0,0 +1,190 @@
"""Six P&ID focuses over one graph per critique run.
Every check is pure over the graph dict; ``run_critique`` builds the graph once
into the per-run ``shared`` context so six focuses cost one read. A drawing
with no P&ID nodes yields no issues — mechanical sheets are never scored here.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from engineering.plan_spec import Issue
from .graph import build_graph
from .lines import LINE_CLASSES
from .tags import parse_tag
if TYPE_CHECKING:
from backends.base import AutoCADBackend
PID_FOCUSES = (
"pid_dangling_line",
"pid_duplicate_tag",
"pid_incompatible_connection",
"pid_untagged_instrument",
"pid_illegal_tag",
"pid_unconnected_equipment",
)
_KEY = "pid_graph"
def _dangling(graph: dict) -> list[Issue]:
out = []
for d in graph["dangling"]:
nearest = d.get("nearest")
if nearest:
hint = (
f"pid_line_draw(from_={{'handle': ..., 'port': ...}}, "
f"to={{'handle': '{nearest['node']}', 'port': '{nearest['port']}'}}) "
f"— nearest port {nearest['distance']} mm away"
)
else:
hint = "no port within reach; connect this end with pid_line_draw or delete the line"
out.append(
Issue(
"error",
"pid_dangling_line",
f"Line {d['edge']} {d['end']} end at ({d['x']:.2f}, {d['y']:.2f}) "
"connects to nothing.",
[d["edge"]],
{"end": d["end"], "x": d["x"], "y": d["y"], "nearest": nearest, "hint": hint},
)
)
return out
def _duplicate_tags(graph: dict) -> list[Issue]:
seen: dict[str, list[str]] = {}
for node in graph["nodes"]:
# connectors carry free text ("TO P&ID-002"), never a unique tag
if node.get("tag") and node["kind"] != "connector":
seen.setdefault(node["tag"].upper(), []).append(node["id"])
return [
Issue(
"error",
"pid_duplicate_tag",
f"Tag {tag} is carried by {len(handles)} symbols.",
handles,
{
"tag": tag,
"hint": "block_set_attributes(handle, {'TAG'|'FUNC'/'LOOP': ...}) on all but one",
},
)
for tag, handles in seen.items()
if len(handles) > 1
]
def _incompatible(graph: dict) -> list[Issue]:
nodes = {n["id"]: n for n in graph["nodes"]}
out = []
for edge in graph["edges"]:
cls = LINE_CLASSES.get(edge.get("line_class") or "")
if cls is None:
continue
for ref in (edge["from"], edge["to"]):
if not ref or "node" not in ref:
continue
port = nodes[ref["node"]]["ports"].get(ref["port"], {})
actual = port.get("kind")
if actual in ("process", "signal") and actual != cls.kind:
out.append(
Issue(
"error",
"pid_incompatible_connection",
f"{cls.kind} line {edge['id']} ends on {actual} port {ref['port']} "
f"of {ref['node']}.",
[edge["id"], ref["node"]],
{
"expected": cls.kind,
"actual": actual,
"port": ref["port"],
"line_class": edge["line_class"],
"hint": (
f"redraw with a {actual} line class or connect to a {cls.kind} port"
),
},
)
)
return out
def _untagged(graph: dict) -> list[Issue]:
return [
Issue(
"warning",
"pid_untagged_instrument",
f"Instrument {n['id']} has no tag.",
[n["id"]],
{"hint": "block_set_attributes(handle, {'FUNC': 'FIC', 'LOOP': '101'})"},
)
for n in graph["nodes"]
if n["kind"] == "instrument" and not n.get("tag")
]
def _illegal(graph: dict) -> list[Issue]:
out = []
for n in graph["nodes"]:
if not n.get("tag") or n["kind"] == "connector":
continue
parsed = parse_tag(n["tag"], kind="instrument" if n["kind"] == "instrument" else "auto")
if not parsed["valid"]:
out.append(
Issue(
"warning",
"pid_illegal_tag",
f"Tag {n['tag']} on {n['id']} is not ISA-5.1: {parsed['errors'][0]}",
[n["id"]],
{"errors": parsed["errors"], "hint": "block_set_attributes with a valid tag"},
)
)
return out
def _unconnected(graph: dict) -> list[Issue]:
return [
Issue(
"info",
"pid_unconnected_equipment",
f"{n['kind']} {n.get('tag') or n['id']} has no line on any port.",
[n["id"]],
{"hint": "informational — connect it with pid_line_draw or leave it if intended"},
)
for n in graph["nodes"]
if n["kind"] in ("equipment", "valve")
and n["ports"]
and not any(p["edges"] for p in n["ports"].values())
]
_CHECKS = {
"pid_dangling_line": _dangling,
"pid_duplicate_tag": _duplicate_tags,
"pid_incompatible_connection": _incompatible,
"pid_untagged_instrument": _untagged,
"pid_illegal_tag": _illegal,
"pid_unconnected_equipment": _unconnected,
}
def issues_for(focus: str, graph: dict) -> list[Issue]:
if not graph["nodes"]:
return []
return _CHECKS[focus](graph)
def _make(focus: str):
async def check(backend: AutoCADBackend, shared: dict) -> list[Issue]:
graph = shared.get(_KEY)
if graph is None:
graph = shared[_KEY] = await build_graph(backend, include_foreign=True)
return issues_for(focus, graph)
check.needs_shared = True # type: ignore[attr-defined]
check.__name__ = f"check_{focus}"
return check
PID_DISPATCH = {focus: _make(focus) for focus in PID_FOCUSES}
+12
View File
@@ -21,6 +21,12 @@ CritiqueFocus = Literal[
"duplicate_entities",
"construction_left",
"gdt",
"pid_dangling_line",
"pid_duplicate_tag",
"pid_incompatible_connection",
"pid_untagged_instrument",
"pid_illegal_tag",
"pid_unconnected_equipment",
]
SnapType = Literal["end", "mid", "center", "quad", "int", "perp", "near"]
@@ -32,6 +38,12 @@ ALL_CRITIQUE_FOCUSES: tuple[CritiqueFocus, ...] = (
"duplicate_entities",
"construction_left",
"gdt",
"pid_dangling_line",
"pid_duplicate_tag",
"pid_incompatible_connection",
"pid_untagged_instrument",
"pid_illegal_tag",
"pid_unconnected_equipment",
)
+23
View File
@@ -5,10 +5,27 @@ from __future__ import annotations
import hashlib
import json
import math
import re
from dataclasses import dataclass, field
from numbers import Real
from typing import Any
PID_INTENT_RE = re.compile(
r"\b(p&id|pid|piping and instrumentation|process flow|instrument)\b", re.IGNORECASE
)
def intent_reads_as_pid(intent: str) -> bool:
return bool(PID_INTENT_RE.search(str(intent or "")))
def pid_plan_warnings(intent: str, layer_set_id: str) -> list[str]:
if intent_reads_as_pid(intent) and str(layer_set_id).strip().lower() != "pid":
return [
f"intent reads as a P&ID but the layer set is '{layer_set_id}'; pass layer_set_id='pid'"
]
return []
@dataclass(frozen=True)
class PreflightQuestion:
@@ -201,6 +218,12 @@ def preflight_drawing(
for valid, code, field_name, value, message in parameter_rules:
if not valid:
conflicts.append(PreflightConflict(code, field_name, [value], message))
for message in pid_plan_warnings(intent, normalized_layer_set):
conflicts.append(
PreflightConflict(
"LAYER_SET_INTENT_MISMATCH", "layer_set_id", [layer_set_id, "pid"], message
)
)
normalized_spec = {
"intent": str(intent).strip(),