Files
autocad-mcp-pro/docs/superpowers/specs/2026-09-15-v1.6-pid-design.md
T
Umut CANandClaude Opus 5 2be5e4180d docs(pid): track A shipped — CLAUDE.md, README, changelog with the live COM smoke record
scripts/smoke_pid_com.py runs the whole track against the live AutoCAD in
a new document. ComBackend.connect() is lazy by design, so the script reads
reachability from system_status()["connected"] rather than from connect()
as the plan sketched, and closes its document without saving unless --keep
is passed. On this machine (AutoCAD 2026, "25.1s (LMS Tech)") it exits 0:
5 nodes, 4 edges, 0 dangling, confidence_min 1.0, 0 critique issues,
2 XDATA chunks — identical to the headless engine on the same spec. The
first attempt exited 2 on a stale win32com gen_py cache (a package folder
holding only __pycache__); an environment fault, no backend change needed.

A/B against v1.5.1 published as ab-v1.5.1-vs-v1.6.0-dev.json: 26/29 ->
29/29, three miss -> pass, zero regressed. check_doc_numbers.py now names
the report and the two row labels in one place (AB_REPORT, AB_*_LABEL),
and README.md / benchmarks/README.md carry the new rows; the v1.5.0 and
v1.4.0 tables stay as history.

README: P&ID row, the showcase and catalogue images, the token table
re-measured (161 / 44,930 by default, 152 / 41,420 with TOOL_PACKS=core,
lean 50 / 14,007), every stale count reconciled (154 -> 166 registered,
149 -> 161 advertised, corpus 158/672 -> 162/731 by the same count, 19 ->
20 groups, 6 -> 8 resources, six-task matrix with pid_roundtrip), a
"Known limitations of the P&ID track" subsection, and the snapshot line at
this machine's 2423 collected tests (CI has not run on this tree; noted in
the changelog with the pre-existing 1369 vs 1363 drift).

CLAUDE.md: section 17, sections 5 and 7 counts, TOOL_PACKS, the two 1.6
contracts and the reader rules. Spec status -> implemented. The plan and
spec markdown were reformatted by ruff (fenced Python only) because
`ruff format --check .` was already red on them at HEAD and the gate
requires the whole tree clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 13:34:06 +03:00

47 KiB
Raw Blame History

v1.6 Track A — P&ID expert and reader

Date: 2026-09-15 · Status: implemented 2026-09-16 (21 tasks of docs/superpowers/plans/2026-09-15-v1.6-pid-implementation.md; live COM smoke on AutoCAD 2026 recorded in CHANGELOG.md Unreleased; A/B vs v1.5.1 29 / 29) · Parent: 2026-09-15-v1.6-roadmap-design.md (track A) · Baseline: v1.5.1

1. Summary

v1.5.1 supports P&IDs with a prompt template that tells the model to hand-draw a pump as a circle plus a triangle — the "no coordinate guessing" rule, broken by our own prompt. Three rival servers ship a pid tool, all bound to the commercial CAD Tools Online symbol library.

This track makes the server a P&ID expert (a symbol catalogue authored in code, real blocks with tags and ports, orthogonal line drawing with ISA-5.1 line classes and line numbers, an ISA-5.1 tag grammar) and a P&ID reader (pid_graph turns any P&ID — ours or foreign — into a connectivity graph, and derives the instrument index, line list and equipment list from it). Six critique focuses make P&ID correctness part of drawing_finalize's score.

Two generic contracts are added because the catalogue needs them and nothing in v1.5.1 provides them: block_define (a block definition with ATTDEFs from typed primitive specs) and entity_get_xdata / entity_set_xdata.

Tool budget: +12 (3 generic, 9 pid_*) → 166 registered.

2. Scope

In: everything in sections 4–13. Out of 1.6 (recorded so nobody re-derives it): DEXPI/Proteus export (pyDEXPI is AGPL; writing Proteus XML from scratch is its own project), ISA-5.2 binary-logic symbols, obstacle-avoiding routing, off-page links across files (same-file links are in), electrical IEC 60617 symbols (1.7, on this infrastructure), reading DWG headlessly (DWG is read by the live backend; headless reads DXF).

3. Architecture

engineering/pid/
  __init__.py        public surface re-exported by engineering/__init__.py
  symbols.py         SymbolSpec catalogue (pure data + generators), ports, naming
  geometry.py        primitive-spec helpers shared by symbols (arcs, polygons, text)
  tags.py            ISA-5.1 grammar, letter tables, equipment-prefix map
  lines.py           line classes, routing candidates, labels, markers, arrows
  xdata.py           ACADMCP_PID payload encode/decode (chunking)
  graph.py           PidGraph builder: nodes/edges/junctions/dangling, foreign heuristics
  deliverables.py    instrument index, line list, equipment list rows (+CSV)
  spec.py            pid_from_spec schema validation and execution
  critique.py        the six pid_* focuses (registered into engineering.critique)

backends/contracts/blocks.py        + block_define
backends/contracts/entity_query.py  + entity_get_xdata / entity_set_xdata
backends/ezdxf_backend.py, backends/com_backend.py   implementations
server.py                           SECTION 17: P&ID (9 tools) + 3 generic tools in
                                    their existing sections; TOOL_PACKS; 2 resources
discovery/aliases.py                12 alias records

Data flow for drawing: pid_symbol_insert → catalogue → block_define (once per block name per document) → block_insert(attributes=…) → entity_set_xdata → returns handle + WCS ports. pid_line_draw → resolve ports → routing → entity_create_polyline on the class layer → label entity_create_text → marker/arrow block_insert → entity_set_xdata.

Data flow for reading: pid_graph → entity_list (paged, one pass) + block_get_attributes per INSERT + entity_get_xdata per candidate → classification → spatial index → endpoint resolution → graph → deliverables and critique consume the graph, never the drawing directly.

Every geometry write goes through existing backend primitives, so both engines get the whole track for the price of the two new contracts.

4. Symbol catalogue (engineering/pid/symbols.py)

4.1 Standards

  • Instrumentation (bubbles, signal line classes, identification letters): ISA-5.1-2009.
  • Equipment, valves, fittings: ISO 10628-2:2012 shapes. Where ISO 10628-2 has no symbol (e.g. spectacle blind), the PIP PIC001 shape is used and the record's source says so.
  • Every SymbolSpec carries source: str naming the standard and figure/table it was authored from. A test asserts the field is non-empty and matches ^(ISO 10628-2|ISA-5\.1|PIP PIC001).

4.2 Units and conventions

  • Symbol-local coordinates in millimetres at A3/A1 paper scale; the insertion scale parameter handles other sheets. Reference sizes: instrument bubble Ø10; valve body 8 × 4 (two triangles meeting at the centre); pump circle Ø8; shell-and-tube exchanger 24 × 8; default vertical vessel 20 × 40; grid 5. Text: equipment/valve TAG height 2.5, bubble FUNC/LOOP height 2.0 (ISO 3098 sizes).
  • Origin (0, 0) = insertion point = centre of the body's bounding box. Rotation 0 = flow left→right (process in port on −X, out on +X).
  • All primitives inside the block are on layer "0" with colour/linetype/ lineweight ByBlock, so the INSERT's layer (section 4.6) supplies the ISO 128 lineweight. Filled shapes (flow arrow, blind side of a spectacle blind) are DXF SOLID entities; block_define supports {"type": "solid", "points": [...]} for exactly this.

4.3 Record structure

@dataclass(frozen=True)
class Port:
    name: str  # "in", "out", "suction", "discharge", "N1", "signal"
    x: float
    y: float
    direction_deg: float | None  # outward direction a line leaves along; None = radial
    kind: Literal["process", "signal"]
    radius: float = 0.0  # radial ports only (bubbles): line starts on the circle


@dataclass(frozen=True)
class SymbolSpec:
    name: str  # block name, see 4.5
    family: Literal[
        "valve", "rotating", "vessel", "heat", "misc", "instrument", "connector", "marker"
    ]
    primitives: tuple[dict, ...]  # block_define entity specs (section 5.1)
    attdefs: tuple[dict, ...]  # block_define attdef specs
    ports: tuple[Port, ...]
    bbox: tuple[float, float, float, float]  # xmin, ymin, xmax, ymax — declared, tested
    layer_class: Literal["PROCESS-VALVES", "PROCESS-EQUIPMENT", "INSTRUMENT-SYMBOL", "line"]
    source: str
    parametric: bool = False
    params: dict = field(default_factory=dict)  # the parameters a parametric spec was built from

CATALOG_VERSION = "1" is written into every INSERT's XDATA. A change to any shipped symbol's geometry or ports bumps it; the graph builder reads geometry from the drawing, not the catalogue, so old drawings keep working.

4.4 Catalogue v1

Base symbols (43: 34 equipment/valve bodies, the instrument generator, 3 connectors, 5 markers) — every one is a test case:

Family Symbols Ports
valve (11 bodies) gate, globe, ball, butterfly, check, needle, plug, diaphragm, three_way, angle, relief in(−X), out(+X); three_way adds branch(−Y); angle: in(−X), out(−Y); relief: in(−Y), out(+X)
valve actuators (6, composed onto any body except check/relief) none, diaphragm, piston, motor, solenoid, hand adds signal(+Y) for every actuator except none/hand; fail position attribute FAIL ∈ {"", "FO", "FC", "FL"} drawn as text beside the actuator
rotating (6) centrifugal_pump, pd_pump, compressor, fan, agitator, ejector pumps: suction(−X), discharge(+Y); compressor/fan: in(−X), out(+X); agitator: shaft(−Y, process, mounts on a vessel); ejector: motive(−X), suction(−Y), out(+X)
vessel (6, parametric) vertical_vessel, horizontal_vessel, tank (roof ∈ {flat, cone}), column (trays int ≥ 0), reactor (jacketed bool), hopper nozzles from params["nozzles"] = [{"name","side","fraction"}], side ∈ {top, bottom, left, right}, fraction ∈ [0,1] along that side; defaults: vertical_vessel N1 top, N2 bottom, N3 left 0.7, N4 right 0.3
heat (4) shell_tube_hx, plate_hx, air_cooler, fired_heater shell: shell_in(+Y), shell_out(−Y), tube_in(−X), tube_out(+X); others: in/out per side
misc (7) filter, strainer_y, reducer (concentric/eccentric), flange_pair, spectacle_blind, orifice_plate, drain_vent inline: in(−X), out(+X); orifice_plate adds signal(+Y); drain_vent: in(+Y) only
instrument (1 generator → 20) type ∈ {discrete, dcs, computer, plc} × location ∈ {field, primary, auxiliary, primary_rear, auxiliary_rear} one radial signal port, radius 5
connector (3) offpage (direction ∈ {in, out}), tie_in, spec_break offpage: one process port on the open side; tie_in/spec_break: in/out
marker (5) arrow_flow, mark_pneumatic (//), mark_capillary (X), mark_hydraulic (L), mark_data (o) none — markers are decoration on a line and never graph nodes

Instrument bubble by ISA-5.1-2009 Table 5.4.1: discrete = circle; dcs = circle inside a square; computer = hexagon; plc = diamond inside a square. Location line through the middle: field none; primary one solid; auxiliary two solid; primary_rear one dashed; auxiliary_rear two dashed (dashes drawn as short line primitives, 1 mm on / 1 mm off, so no linetype dependency inside the block).

4.5 Block naming

PID_<FAMILY>_<SYMBOL>[_<VARIANT>], upper case, e.g. PID_VALVE_GATE, PID_VALVE_GATE_DIAPHRAGM, PID_ROTATING_CENTRIFUGAL_PUMP, PID_INST_DCS_PRIMARY, PID_CONNECTOR_OFFPAGE_OUT, PID_MARKER_ARROW_FLOW. Parametric vessels: PID_VESSEL_VERTICAL_<8 hex> where the hex is the first 8 characters of SHA-256 over the canonical JSON of params — identical parameters share one definition, different ones never collide in practice.

4.6 Layers

INSERTs go on layer_class: valves → PROCESS-VALVES, rotating/vessel/heat/ misc/connector → PROCESS-EQUIPMENT, instruments → INSTRUMENT-SYMBOL, markers → the layer of the line they decorate. PID_LAYERS gains ("PROCESS-LINE-TEXT", 7, "Continuous", 0.25, "line numbers") as its 16th entry. pid_symbol_insert and pid_line_draw create a missing target layer from the PID_LAYERS definition (idempotent, same code path as drawing_apply_iso_layers) and report it in layers_created; they do not apply the whole set.

4.7 ATTDEFs

Family Tags Placement (symbol-local)
valve, rotating, vessel, heat, misc, connector TAG (height 2.5, centred at (0, ymax + 3.5)); DESC (height 2.0, centred at (0, ymax + 6.5), visible; pid_symbol_insert writes "" when no description is given — an empty ATTRIB renders nothing, and per-insert invisibility is not something block_insert(attributes=…) exposes) above the body
valve with actuator + FAIL (height 2.0, right of the actuator)
instrument FUNC (height 2.0, middle-centre at (0, +2.0)), LOOP (height 2.0, middle-centre at (0, −2.0)) the two halves of the bubble
offpage TAG inside the arrow box; LINK invisible

Attribute values are what block_get_attributes returns and what the reader uses first; XDATA (section 5.2) duplicates the machine-readable part.

5. Generic contracts

5.1 block_define

@abstractmethod
async def block_define(
    self,
    name: str,
    entities: list[dict],
    attdefs: list[dict] | None = None,
    base_x: float = 0.0,
    base_y: float = 0.0,
    overwrite: bool = False,
) -> dict:
    """→ {"ok": True, "name", "entity_count", "attdef_count", "replaced": bool, "backend"}"""

Entity specs (all coordinates block-local; layer optional, default "0"; color/linetype/lineweight optional, default ByBlock):

type Required keys
line x1, y1, x2, y2
circle cx, cy, r (r > 0)
arc cx, cy, r, start_deg, end_deg (CCW, r > 0)
polyline points: [[x, y], ...] (≥ 2), closed: bool, optional bulges: [float, ...] same length as points
text text, x, y, height (> 0), optional rotation_deg (0), align ∈ {left, center, right, middle_center} (left)
solid points (3 or 4, polygon order) — written as a DXF SOLID entity (add_solid / AddSolid); a quad's third and fourth points are swapped into SOLID's bow-tie vertex order

ATTDEF specs: tag (^[A-Z][A-Z0-9_]{0,30}$, unique within the block), prompt (default = tag), default (default ""), x, y, height, optional rotation_deg, align (as text), invisible: bool (False).

Rules, all tested:

  • The whole request is validated before anything is written (the hatch_add_boundary pattern). An invalid entry raises TypeError naming the index and the key; no block is created or modified.
  • entities empty → ValueError ("nothing to define") — a definition with only attributes is refused too, because it draws nothing.
  • Name: sanitize_symbol_name(name, kind="block") (existing helper); anonymous names (*U…) refused.
  • Name exists and overwrite=False → ValueError naming the block and the flag. overwrite=True clears the existing definition's entities and re-adds rather than deleting the block, so existing INSERTs keep pointing at the name and now show the new geometry; replaced: true says so. ezdxf: blk.delete_all_entities(); COM: iterate and Delete() each entity in the Block object. (Deleting a block that has references is refused by both engines; replacing contents is the semantics that works on both.)
  • ezdxf: doc.blocks.new(name, base_point=…), add_line/add_circle/add_arc/ add_lwpolyline/add_text/add_attdef/add_hatch. COM: Blocks.Add(pt, name), then AddLine/AddCircle/AddArc/AddLightWeightPolyline/AddText/AddAttribute/ AddHatch on the Block object; AddAttribute(height, mode, prompt, pt, tag, value) with mode = acAttributeModeInvisible (1) for invisible ATTDEFs, else acAttributeModeNormal (0); text alignment through Alignment + TextAlignmentPoint.
  • Both engines: abstract (no @capability) — both can do it.

Registered as block_define in SECTION 7 (Blocks), tags {"block", "create"}, cost mutate, aliases acad=("BLOCK", "BEDIT", "ATTDEF").

5.2 entity_get_xdata / entity_set_xdata

@abstractmethod
async def entity_get_xdata(self, handle: str, app_name: str | None = None) -> dict:
    """→ {"handle", "xdata": {app: [value, ...]}, "backend"}; unknown app → {} not an error"""


@abstractmethod
async def entity_set_xdata(self, handle: str, app_name: str, values: list) -> dict:
    """→ {"ok": True, "handle", "app_name", "value_count", "bytes", "backend"}"""
  • app_name: ^[A-Za-z_][A-Za-z0-9_]{0,30}$; registered in the APPID table if missing (doc.appids.add / RegisteredApplications.Add).
  • Value typing by group code: str → 1000, int → 1071, float → 1040, [x, y] or [x, y, z] → 1010; anything else → TypeError. Strings longer than 255 characters → ValueError (DXF limit; callers chunk). Total encoded size > 16 KB → ValueError (AutoCAD limit) before any write. values empty → the app's XDATA is removed (documented, tested).
  • set replaces the app's XDATA wholesale; other apps' XDATA untouched.
  • ezdxf: entity.set_xdata / get_xdata / discard_xdata. COM: SetXData(types, values) / GetXData(app, types, values) with variant arrays.
  • Both engines: abstract.

Registered in SECTION 5 (Entity Query): entity_get_xdata (tags {"entity", "query"}, cost read, aliases acad=("XDATA", "XDLIST")), entity_set_xdata (tags {"entity", "modify"}, cost safe).

engineering/pid/xdata.py encodes the P&ID payload as ["json", chunk1, chunk2, ...] (≤ 255 chars each) under app ACADMCP_PID and decodes by joining every 1000-string after the "json" marker. Payloads:

{"v": 1, "kind": "symbol", "catalog": "1", "name": "PID_VALVE_GATE_DIAPHRAGM",
 "family": "valve", "symbol": "gate", "actuator": "diaphragm",
 "params": {}, "ports": {"in": [-4, 0, 180], "out": [4, 0, 0], "signal": [0, 9, 90]}}
{"v": 1, "kind": "line", "class": "process_major", "number": "100-P-1001-CS1-IH",
 "size": "100", "service": "P", "spec": "CS1", "insulation": "IH",
 "from": {"handle": "2F", "port": "discharge"}, "to": {"handle": "31", "port": "N1"}}
{"v": 1, "kind": "marker", "line": "3A"}

Ports in XDATA are symbol-local; the reader applies the INSERT transform. They are written for every symbol so a reader without the catalogue (another tool, a future version) still sees them.

6. Tools (SECTION 17: P&ID, 9 tools)

All in pack pid (section 12). Tags {"pid", ...}; pid is appended to _GROUP_TAG_PRIORITY ahead of entity so system_about files them under one pid group.

6.1 pid_symbol_list(family: str | None = None) -> dict

Catalogue listing: {"catalog_version", "symbols": [{"symbol", "family", "variants", "ports": [...], "parametric", "params_schema", "source"}]}. Cost read. Also exposed as resource autocad://pid/symbols (section 13).

6.2 pid_symbol_insert(...)

pid_symbol_insert(symbol: str, x: float, y: float, rotation: float = 0.0, scale: float = 1.0,
                  tag: str | None = None, desc: str | None = None,
                  actuator: str | None = None, fail: str | None = None,
                  type: str | None = None, location: str | None = None,   # instruments
                  params: dict | None = None,                              # parametric vessels
                  shape: str | None = None,                                # reducer: concentric | eccentric
                  direction: str | None = None,                            # offpage: in | out
                  link: str | None = None,                                 # offpage
                  layer: str | None = None)
→ {"handle", "block_name", "defined": bool, "layers_created": [...],
   "ports": {"in": {"x","y","direction_deg","kind"}, ...},   # WCS
   "tag": str | None, "tag_warnings": [...], "backend"}
  • Instruments take tag="FIC-101" and split it through the grammar into FUNC/LOOP (area prefix and suffix kept in LOOP text as written, e.g. 101A); or func/loop may be given in params. An invalid tag is written and warned (tag_warnings), never refused — critique catches it.
  • Unknown symbol/actuator/type/location → ValueError listing the valid values. actuator on check/relief → ValueError. params on a non-parametric symbol → ValueError; parametric symbols validate params (sizes > 0, nozzle sides/fractions in range, unique nozzle names).
  • scale ≤ 0 → ValueError. layer overrides the family layer.
  • Defines the block on first use (defined: true), inserts with attributes, writes XDATA, returns WCS ports (rotation and scale applied).
  • Cost mutate. Aliases acad=("INSERT",), synonyms "valve", "pump", "tank", "vessel", "instrument bubble", "p&id symbol", "control valve"...

6.3 pid_line_draw(...)

pid_line_draw(from_: dict, to: dict, line_class: str = "process_major",
              route: str | list = "auto", stub: float = 5.0,
              line_number: str | None = None, size: str | None = None, service: str | None = None,
              spec: str | None = None, insulation: str | None = None, number_format: str | None = None,
              label: bool = True, arrow: bool | None = None)
→ {"handle", "vertices": [[x, y], ...], "line_class", "layer", "line_number",
   "from": {"handle","port","x","y"} | {"x","y"}, "to": ..., "port_reuse": [...],
   "crossings": int, "label_handle", "arrow_handle", "marker_handles": [...], "backend"}
  • from_/to: {"handle": H, "port": name} or {"x": .., "y": ..} (a free point, e.g. a junction on an existing line). A bare point gets no direction; routing treats it as radial.
  • Line classes (ISA-5.1 Table 5.3.1) → layer / linetype override / marker:
class layer linetype marker arrow default
process_major PROCESS-PIPING-MAIN ByLayer (Continuous) — yes
process_minor PROCESS-PIPING-SECONDARY ByLayer — yes
utility UTILITY-LINE ByLayer (PHANTOM) — yes
pneumatic INSTRUMENT-LINE-SIGNAL Continuous (override) mark_pneumatic no
electric INSTRUMENT-LINE-SIGNAL ByLayer (DASHED) — no
hydraulic INSTRUMENT-LINE-SIGNAL Continuous mark_hydraulic no
capillary INSTRUMENT-LINE-SIGNAL Continuous mark_capillary no
data INSTRUMENT-LINE-SIGNAL Continuous mark_data no

Markers are blocks (section 4.4) placed at the midpoint of every segment ≥ 15 mm, rotated with the segment; they are decoration, ignored by the reader. Rationale: SHX-shape complex linetypes are not portable across the two engines' renderers; blocks are.

  • Line number: given verbatim, or built from number_format (default "{size}-{service}-{seq}-{spec}-{insulation}", empty fields and their separators dropped, seq auto-incremented per document from XDATA of existing lines). Label: TEXT height 2.5 on PROCESS-LINE-TEXT, 1.5 mm off the longest segment's midpoint on its left-hand side, rotated with the segment and never upside-down (angles in (90°, 270°] are flipped by 180°).
  • Arrow: arrow_flow block at the end point, rotated with the last segment; default per class, parameter overrides.
  • crossings: intersections between the new segments and existing P&ID lines (lines on P&ID line layers or carrying line XDATA), excluding touches at a shared endpoint. Reported, never refused.
  • port_reuse: ports that already had a line attached (branching is legal; the fact is reported).
  • Refusals (ValueError): same handle+port at both ends; unknown port (the message lists the symbol's ports); a marker/arrow block as an endpoint; any segment shorter than 0.01 mm; route waypoints that repeat a point; stub < 0.
  • One LWPOLYLINE per call. XDATA as in 5.2. Cost mutate. Aliases acad=("PLINE",) plus synonyms "process line", "pipe run", "signal line", "connect equipment", "instrument line".

6.4 pid_tag_parse(tag: str, kind: str = "auto", equipment_prefixes: dict | None = None) -> dict

Section 8. Cost read. Also used internally by insert and critique.

6.5 pid_graph(...)

pid_graph(tolerance: float = 0.5, label_search: float = 15.0,
          scope: str = "current_space",        # or "all"
          include_foreign: bool = True, include_geometry: bool = False)
→ {"nodes": [...], "edges": [...], "junctions": [...], "dangling": [...],
   "unclassified": [...], "offpage_links": [...],
   "stats": {"nodes_by_kind": {}, "edges_by_class": {}, "dangling": n,
             "unclassified": n, "confidence_min": float, "entities_scanned": n},
   "tolerance", "scope", "backend"}

Section 9. Cost read. Aliases synonyms "p&id connectivity", "instrument index", "what is connected to", "trace the line".

6.6 pid_instrument_index / pid_line_list / pid_equipment_list(csv_path: str | None = None, **graph_kwargs)

Section 9.5. Each returns {"rows": [...], "count", "csv_path": str | None, "graph_stats": {...}}. csv_path goes through validate_path(path, allow_write=True); the CSV header is the row keys in the documented order. Cost read — the drawing is never mutated; the optional CSV write is named in the tool summary so a client gating on cost is not surprised by a file.

6.7 pid_from_spec(spec: dict, dry_run: bool = False) -> dict

Section 10. Cost mutate.

7. Routing (engineering/pid/lines.py)

Inputs: start point S, start direction dS (unit axis vector or None), end point E, end direction dE, stub. Radial ports (bubbles, free points) take their direction from the vector towards the other end, snapped to the nearest axis, and their start point on the circle at centre + r·dir.

route="auto" enumerates candidates and picks the first that is valid, in this order; ties inside a candidate class are broken by total length:

  1. straight — S→E when both lie on one axis line and each end's direction points towards the other (or is None).
  2. L — one bend at (E.x, S.y) or (S.x, E.y); valid when the first segment leaves S along dS and the last arrives at E against dE.
  3. Z — two bends through the midline between the stub points, along dS's axis; then along dE's axis.
  4. U — S→S₁→(corner)→E₁→E with both stubs honoured when the ends face each other or away (three bends).

Validity for every candidate: the first segment is colinear with dS and ≥ stub long, the last is colinear with −dE and ≥ stub long, no segment is shorter than stub except the whole route when straight, and consecutive segments are perpendicular. If no candidate is valid (e.g. stub larger than the gap), the tool raises ValueError naming the gap and the stub, so the caller can shorten the stub or pass waypoints. route="direct" draws S→E. route=[...] draws S→w₁→…→E verbatim after the refusal checks.

Everything here is pure geometry in lines.py, tested without a backend.

8. ISA-5.1 tag grammar (engineering/pid/tags.py)

Format: ^(?:(?P<area>\d{1,4})-)?(?P<letters>[A-Z]{1,5})[- ]?(?P<loop>\d{1,5})(?:[- ]?(?P<suffix>[A-Z]))?$ after upper-casing and trimming. Letters are then parsed with the grammar:

tag      := first modifier? readout* output* trailing?
first    := A|B|C|D|E|F|G|H|I|J|K|L|M|N|O|P|Q|R|S|T|U|V|W|X|Y|Z
modifier := D | F | K | Q | S            # differential, ratio, rate of change, integrate, safety
readout  := A | B | E | G | I | L | N | O | P | R | U | W | X
output   := C | K | S | T | U | V | X | Y | Z
trailing := HH | LL | H | L | M | O | C  # high-high, low-low, high, low, middle, open, closed

Disambiguation rules (fixed, tested):

  • S in second position is the safety modifier only when followed by V or E (PSV, TSE); otherwise it is the switch output (PSL, LSHH).
  • D in second position is always differential (PDT, PDIC).
  • F in second position is ratio only when the first letter is also F (FFC); otherwise it is parsed as a readout error.
  • K in second position is rate of change only when followed by an output letter; K elsewhere is the control-station output (HIK).
  • Q in second position is integrate (FQI, FQ).
  • H, L and M are trailing modifiers (high/low/middle) when they follow A (alarm), S (switch) or another trailing letter — LAL is Level Alarm Low, PSL is Pressure Switch Low. L anywhere else is the readout light (PL).
  • O and C are trailing (open/closed) only as the last letter directly after S (ZSO, ZSC); otherwise C is the control output (TIC) and O the readout orifice/restriction (FO).
  • A first letter in {C, D, G, M, N, O} is user's choice and a readout/output in {B, N, X} is unclassified: the parse succeeds with user_defined: true and no description word for that letter.
  • Anything the grammar rejects → valid: false with errors naming the first offending letter and position; the tag is still echoed back.

Output:

{"tag": "10-FIC-101A", "valid": true, "kind": "instrument", "area": "10", "letters": "FIC",
 "first": {"letter": "F", "meaning": "Flow rate"}, "modifier": null,
 "readout": [{"letter": "I", "meaning": "Indicate"}], "output": [{"letter": "C", "meaning": "Control"}],
 "trailing": null, "loop": "101", "suffix": "A", "description": "Flow Indicating Controller",
 "user_defined": false, "errors": []}

Equipment tags: ^(?:(?P<area>\d{1,4})-)?(?P<prefix>[A-Z]{1,3})-(?P<number>\d{1,5})(?P<suffix>[A-Z]{0,2})$ with the default prefix map {P: pump, V: vessel, T: tank, E: exchanger, K: compressor, C: column, R: reactor, F: filter, D: drum, M: mixer, H: heater, S: separator, B: blower, X: package} — ISO 10628 does not standardise these, so equipment_prefixes replaces the map per call and kind="equipment" forces the equipment parse. kind="auto" tries instrument first, then equipment.

Fixture: tests/data/isa51_tags.json with ≥ 40 valid tags and their expected descriptions and ≥ 10 invalid ones with the expected first error — the file a reader checks against ISA-5.1 Table 4.1.

9. Graph model and algorithm (engineering/pid/graph.py)

9.1 Model

Node      {id: handle, kind: instrument|equipment|valve|connector|unknown_block,
           block_name, family, symbol, tag, tag_parsed, tag_source: attribute|xdata|text|null,
           x, y, rotation_deg, scale, layer,
           ports: {name: {x, y, direction_deg, kind, inferred: bool, edges: [edge ids]}},
           source: catalog|xdata|heuristic|inferred, confidence: 1.0|0.95|0.6|0.3}
Edge      {id: handle, line_class, class_source: xdata|layer|null, line_number, number_source: xdata|label|null,
           size, service, spec, insulation, from: {node, port}|{junction}|null, to: ..., length,
           vertices (only with include_geometry), layer, source: xdata|layer|touch}
Junction  {id: "J<n>", x, y, edges: [...]}          # a line endpoint on another line's vertex or segment
Dangling  {edge, end: "from"|"to", x, y, nearest: {node, port, distance}|null}
Offpage   {link, nodes: [...]}                        # connectors sharing a LINK value

9.2 Algorithm

  1. Collect with entity_list paged (limit=1000, offset loop) over the current space (or every layout when scope="all", each entity tagged with its space).
  2. Classify INSERTs: block name in the catalogue (PID_*, markers and arrows excluded) → catalog 1.0; else XDATA ACADMCP_PID with kind="symbol" → xdata 0.95; else heuristic 0.6 when include_foreign and either an attribute tag in {TAG, TAGNO, TAGNUMBER, EQUIP_TAG, EQUIPMENT_TAG, INST_TAG, INSTTAG} exists or the block name contains one of VALVE, VLV, PUMP, TANK, VESSEL, DRUM, INST, BUBBLE, HX, EXCH, FILTER, COMP (kind from the keyword); else the INSERT is kept only if step 5 finds a line touching its bounding box → unknown_block 0.3, otherwise dropped (title blocks, logos, north arrows).
  3. Ports: catalogue/XDATA ports transformed by the INSERT's rotation and scale about its insertion point. Foreign/unknown blocks: ports are inferred in step 5 — every distinct line endpoint within tolerance of the block's bounding-box boundary becomes p1..pn with inferred: true.
  4. Candidate edges: LINE and LWPOLYLINE (and POLYLINE) entities that (a) carry line XDATA, or (b) lie on a P&ID line layer (PROCESS-PIPING-MAIN, -SECONDARY, UTILITY-LINE, INSTRUMENT-LINE-SIGNAL, ELECTRICAL-LINE), or (c) when include_foreign, touch a node's port or bounding box at an endpoint. line_class from XDATA, else from the layer: PROCESS-PIPING-MAIN → process_major, PROCESS-PIPING-SECONDARY → process_minor, UTILITY-LINE → utility, ELECTRICAL-LINE → electric, INSTRUMENT-LINE-SIGNAL → electric when the effective linetype is dashed, otherwise signal_unknown (pneumatic is never assumed from a plain line); any other layer → null.
  5. Resolve endpoints through a uniform grid (cell = 10·tolerance): a bubble's radial port matches a line end on its circle (|dist(end, centre) − r| ≤ tolerance); any other port within tolerance → {node, port}; else another edge's vertex within tolerance → junction; else a point within tolerance of another edge's segment interior → junction (the touched edge is not split); else dangling with the nearest port within 10·tolerance as a hint.
  6. Tags: instrument FUNC+LOOP → TAG attribute → the TEXT/MTEXT whose insertion lies inside the bubble radius → null. Equipment/valve: TAG attribute → nearest TEXT within label_search above the bounding box → null. Line numbers: XDATA → nearest TEXT within label_search of the longest segment whose content matches ^\d+(?:"|mm)?-[A-Z]{1,4}-\d+ → null. Every source is reported.
  7. Off-page links: connectors with equal LINK values (same file) are joined in offpage_links; the reader does not invent an edge between them.
  8. Stats as in 6.5; confidence_min over nodes is the number a caller must read before trusting a foreign graph.

Complexity O((n + m)·k) with the grid; performance is measured in the perf lane on a 5 000-entity synthetic P&ID, not asserted in unit tests.

9.3 Foreign drawings

Headless: drawing_open(dxf) then pid_graph. Live: drawing_open(dwg). CTO-library drawings classify through their TAG-style attributes and keyword block names at confidence 0.6 with inferred ports; a test fixture (hand-authored blocks GATE_VALVE, CENTRIFUGAL_PUMP with a TAG attribute, plain lines) pins that path.

9.4 Reader rules

  • The reader never modifies the drawing.
  • It reads geometry from the drawing, never from the catalogue: a scaled or rotated INSERT is measured, not assumed.
  • Confidence is never raised by agreement between heuristics; it is the minimum of its inputs.

9.5 Deliverables (engineering/pid/deliverables.py)

Tool Row keys, in order
pid_instrument_index tag, func, loop, description, type, location, connected_to (comma-joined equipment/valve tags or line numbers), signal_lines (count), handle, confidence
pid_line_list line_number, line_class, size, service, spec, insulation, from_tag, from_port, to_tag, to_port, length_mm (rounded 1), handle, number_source
pid_equipment_list tag, kind, symbol, description, ports (count), connected_lines (count), unconnected_ports (comma-joined), handle, confidence

Rows are sorted by tag (natural sort: P-2 before P-10), untagged rows last with tag: null.

10. pid_from_spec

Schema (validated with the same strictness as block_define; every error names the path, e.g. lines[2].from):

{"sheet": {"layer_set": "pid", "sheet_size": "A3", "scale": 1.0, "intent": "Feed section"},
 "equipment": [{"id": "P-101", "symbol": "centrifugal_pump", "x": 100, "y": 100, "rotation": 0,
                "tag": "P-101", "desc": "Feed pump"},
               {"id": "V-201", "symbol": "vertical_vessel", "x": 220, "y": 120,
                "params": {"width": 20, "height": 40, "nozzles": [{"name": "N1", "side": "top", "fraction": 0.5},
                                                                   {"name": "N2", "side": "left", "fraction": 0.3}]}}],
 "valves": [{"id": "FCV-101", "symbol": "globe", "actuator": "diaphragm", "x": 160, "y": 104, "tag": "FCV-101", "fail": "FC"}],
 "instruments": [{"id": "FIC-101", "type": "dcs", "location": "primary", "x": 160, "y": 140, "tag": "FIC-101"}],
 "lines": [{"from": "P-101.discharge", "to": "FCV-101.in", "class": "process_major", "number": "100-P-1001-CS1"},
           {"from": "FCV-101.out", "to": "V-201.N2", "class": "process_major", "number": "100-P-1001-CS1"},
           {"from": "FIC-101", "to": "FCV-101.signal", "class": "electric"}],
 "connectors": [{"id": "OP-1", "direction": "out", "x": 300, "y": 120, "tag": "TO P&ID-002", "link": "L-17",
                 "from": "V-201.N1", "class": "process_major"}]}

Behaviour: drawing_plan is called with the sheet block when no plan is held (so critique replays it); then, inside transaction_begin, symbols are inserted in the order equipment → valves → instruments → connectors, then lines (id.port references resolve to handles; an instrument reference without a port means its radial signal port). A connector with direction: "out" carries from (the line runs into the connector); one with direction: "in" carries to (the line runs out of it); each becomes one pid_line_draw call with the connector's single port. On any error the transaction is rolled back and the error names the failing item; on success the transaction is committed and the result is {"handles": {id: handle}, "graph": pid_graph(), "critique": [pid issues], "crossings_total"}. dry_run=True validates the spec and resolves the routing in memory, returning the planned vertices and crossings without touching the drawing.

11. Critique, refine, plan, finalize, prompt

11.1 Focuses (added to CritiqueFocus and ALL_CRITIQUE_FOCUSES)

focus severity condition handles / detail
pid_dangling_line error an edge end resolves to nothing edge; end, x, y, nearest
pid_duplicate_tag error two nodes carry the same full tag (instrument FUNC-LOOP including suffix; equipment tag) both nodes; tag
pid_incompatible_connection error a signal-class edge ends on a process port, or a process-class edge ends on a signal port edge + node; expected, actual
pid_untagged_instrument warning an instrument node with empty FUNC or LOOP node
pid_illegal_tag warning pid_tag_parse returns valid: false for a node's tag node; errors
pid_unconnected_equipment info an equipment/valve node with zero edges on every port node

All six build one pid_graph(include_foreign=True) per critique run (cached within the run) and return [] when stats["nodes_by_kind"] is empty, so a drawing without P&ID content is never scored on these rules. They run under focus=None and therefore inside drawing_finalize.

11.2 Refine hints

No deterministic repair moves geometry, so no P&ID focus registers a repair handler: drawing_refine reports them as manual_required and carries each issue through unchanged. The hint rides in the issue itself, detail["hint"]: pid_dangling_line → the nearest port and the pid_line_draw call that would connect it; pid_duplicate_tag / pid_illegal_tag / pid_untagged_instrument → the node and block_set_attributes; pid_incompatible_connection → redraw with the right class; pid_unconnected_equipment → informational.

11.3 Plan and preflight

One rule, in engineering/preflight.py: intent text matches \b(p&id|pid|piping and instrumentation|process flow|instrument)\b (case- insensitive) while layer_set_id != "pid". preflight_drawing reports it as the conflict LAYER_SET_INTENT_MISMATCH (so the preflight is not ready), and drawing_plan — which can be called without a preflight — returns the same sentence under a warnings key: "intent reads as a P&ID but the layer set is <id>; pass layer_set_id='pid'".

11.4 Prompt

prompt_pid_diagram(project_name, revision) is rewritten to the tool workflow: drawing_plan(layer_set_id="pid") → drawing_apply_iso_layers("pid") → pid_symbol_insert per equipment/valve/instrument (tags in ISA-5.1 form) → pid_line_draw port-to-port (never free points for equipment connections) → pid_graph and read dangling/confidence_min → drawing_critique(focus=None) must be empty → drawing_finalize. It states the reference sizes (section 4.2) and that pid_from_spec does the whole sheet in one call.

11.5 Validator

No change: none of the eight steps assumes mechanical content. A test runs drawing_finalize on the spec example and asserts the score is computed and ≥ 90 with zero P&ID issues.

12. Tool packs (TOOL_PACKS)

  • config.settings.tool_packs: str = "all"; parsed as a comma list, trimmed, lower-cased. Valid packs in 1.6.0 after this track: core, pid (later tracks add mech, arch, plant3d, settings). core is every registered tool not claimed by another pack and cannot be disabled; an unknown pack name logs a warning, is reported in system_about["tool_packs"]["ignored"], and is otherwise ignored (the server boots).
  • PACK_TOOL_NAMES: dict[str, frozenset[str]] next to LEAN_TOOL_NAMES; _profile_enabled_names becomes enabled = registered ∩ packs_enabled, then ∩ LEAN_TOOL_NAMES for lean, then the ENABLE_3D subtraction.
  • LEAN_TOOL_NAMES gains pid_symbol_insert, pid_line_draw, pid_graph (lean = 50).
  • system_about reports tool_packs: {"enabled": [...], "available": [...], "ignored": [...]}; _active_tool_profile records the same. The README token table gains a row per pack (measured with benchmarks/token_suite.py).
  • The unknown_tool rejection message names TOOL_PACKS alongside TOOL_PROFILE / ENABLE_3D.

13. Resources

  • autocad://pid/symbols — the pid_symbol_list() payload.
  • autocad://standards/isa51 — the letter tables and grammar summary from tags.py, so a model can read the standard instead of guessing.

Both are static, cost nothing in the tool budget, and are added to docs/tool-inventory.json (resources section) by the generator.

14. Error handling summary

  • Validation errors are ValueError/TypeError raised before any write, with the offending key or index in the message; the server wrapper turns them into ToolError as it does today.
  • Engine boundaries: none in this track — every method is abstract on both engines. (drawing_open of a DWG headlessly stays the existing refusal.)
  • Reader results carry source/confidence fields instead of raising on uncertainty; only a failure to read the drawing at all raises.
  • Nothing in pid_* calls system_run_command / system_run_lisp.

15. Testing and evidence

Unit (headless, CI):

  • tests/test_block_insert_attributes.py — the prerequisite fix: v1.5.1's headless block_insert(attributes=…) used add_auto_blockref, which wraps the INSERT in an anonymous *U block, so the returned handle names *U1 and block_get_attributes finds nothing (reproduced 2026-09-15). The fix attaches ATTRIBs to the INSERT itself; the test pins block name and values.
  • tests/test_pid_symbols.py — for every catalogue entry and every generated variant: primitives pass block_define validation, declared bbox contains every primitive, every port lies on the bbox boundary (or at the centre for radial ports), ≥ 1 port, ATTDEF tags unique, name matches the pattern, source non-empty; parametric hash stability; actuator composition adds exactly one signal port.
  • tests/test_block_define.py — ezdxf: definition, ATTDEF count, insert with attributes, overwrite semantics (existing INSERT shows new geometry), refusals (empty, bad spec index, name clash, anonymous name); COM: the fake-ActiveX pattern from test_block_from_entities.py asserting the Blocks.Add / AddAttribute call sequence and the invisible mode flag.
  • tests/test_xdata.py — both engines: typing by group code, round trip, removal on empty list, 255-char and 16 KB refusals, APPID registration, chunked JSON round trip through pid/xdata.py.
  • tests/test_pid_lines.py — pure routing: straight/L/Z/U selection, stub honoured, no-candidate refusal, waypoint validation, upside-down label angle correction, crossing count against an existing line, marker placement on segments ≥ 15 mm only, class → layer/linetype table, line number format with empty fields, seq auto-increment.
  • tests/test_pid_tags.py — the JSON fixture (≥ 40 valid / ≥ 10 invalid) plus the disambiguation rules one by one; equipment prefix override.
  • tests/test_pid_graph.py — a P&ID drawn with the tools: expected node kinds, edge count, zero dangling; nudge one symbol by 1 mm (> tolerance) → dangling and nearest reported; a branch line onto a segment → junction; scale 2 / rotation 90 INSERT → ports measured correctly; foreign fixture → heuristic nodes at 0.6, inferred ports, confidence_min < 1; a title block INSERT is not a node; scope="all" across two layouts; off-page link pairing.
  • tests/test_pid_deliverables.py — row order and keys, natural sort, CSV written under ALLOWED_PATHS and refused outside.
  • tests/test_pid_critique.py — each focus positive and negative; all six return [] on the README gear sheet; drawing_refine emits hints and moves nothing.
  • tests/test_pid_from_spec.py — the section 10 example end to end; a bad reference rolls back (entity count unchanged); dry_run writes nothing; drawing_finalize score ≥ 90.
  • tests/test_pid_insert.py, tests/test_pid_line_draw.py, tests/test_pid_resources_prompt.py, tests/test_benchmark_v4.py — the insertion service, the line tool end to end, the two resources and the rewritten prompt, and the v4 matrix.
  • tests/test_tool_packs.py — TOOL_PACKS=core hides every pid_* tool and keeps block_define; unknown pack ignored with ignored; core cannot be disabled; interaction with lean and ENABLE_3D.
  • Existing gates extended: alias records for 12 tools (test_discovery_aliases), docs/tool-inventory.json regenerated (test_tool_inventory), README snapshot line and SECTION 17: P&ID (9 tools) header (test_release_consistency), capability maps unchanged (test_capability_contract).

Render evidence: scripts/render_pid_catalog.py renders every base symbol and the 20 bubble variants to docs/assets/pid-catalog.png (contact sheet, labelled) and scripts/render_readme_pid.py renders the section 10 example to docs/assets/pid-showcase.png; a test asserts each render is non-blank.

Live COM: scripts/smoke_pid_com.py runs block_define → pid_symbol_insert → entity_set/get_xdata → pid_line_draw → pid_graph in a new document on the running AutoCAD (never the operator's open drawing), prints the graph stats, and its output plus the AutoCAD version go into the CHANGELOG entry. If no AutoCAD is reachable the script exits 2 and says so; the changelog then states the COM paths are fake-tested only.

Benchmark: benchmarks/tasks_v4.py = TASKS_V3 + ("pid_roundtrip", "pid", "Draw a three-equipment P&ID and read it back with matching index and line list", 1.0); run_competitors --matrix v4; competitor reports stay unfilled (not run). Correctness suite +3: pid_block_define_attdef_roundtrip, pid_tag_parse_fic, pid_graph_edge_count — all miss → pass against v1.5.1.

16. Documentation and gates to update

  • CLAUDE.md: section "17. P&ID (9 tools)", the two generic contracts, the TOOL_PACKS variable, the reader rules (section 9.4) in Key Conventions.
  • README.md: "What you get" row for P&ID, the catalogue contact sheet, the token table row per pack, Configuration entry for TOOL_PACKS, snapshot line (166 tools, 8 resources).
  • CHANGELOG.md: Unreleased → 1.6.0 entry for track A with the measured refusals and the live COM smoke record.
  • .env.example: TOOL_PACKS.
  • docs/tool-inventory.json: regenerated.

17. Milestones (input to the implementation plan)

  1. Contracts: block_define + XDATA on both engines, tests, aliases, inventory.
  2. Catalogue: symbols.py + geometry.py, catalogue tests, contact-sheet script.
  3. pid_symbol_list/insert, layers, XDATA payloads.
  4. lines.py + pid_line_draw, markers, arrows, labels.
  5. tags.py + pid_tag_parse + fixture + resource.
  6. graph.py + pid_graph (+ foreign fixture), deliverables.
  7. Critique focuses, refine hints, plan rule, prompt rewrite, finalize test.
  8. pid_from_spec, README showcase render.
  9. Tool packs, system_about, token table.
  10. Benchmark v4 task, correctness checks, live COM smoke, docs, gates green.