# 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 ```python @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__[_]`, 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` ```python @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` ```python @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: ```json {"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(...)` ```python 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(...)` ```python 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(...)` ```python 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\d{1,4})-)?(?P[A-Z]{1,5})[- ]?(?P\d{1,5})(?:[- ]?(?P[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: ```json {"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\d{1,4})-)?(?P[A-Z]{1,3})-(?P\d{1,5})(?P[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", 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`): ```json {"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 ``; 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.