Plan covers the prerequisite block_insert attribute fix (ezdxf wrapped the INSERT in an anonymous *U block and lost its ATTRIBs), the two generic contracts, the 43-symbol catalogue, routing, the ISA-5.1 grammar, the graph reader, deliverables, six critique focuses, pid_from_spec, TOOL_PACKS, resources, benchmark v4, and the live COM smoke. Spec reconciled with the plan: 43 base symbols, FUNC/LOOP at +-2.0, solid = DXF SOLID entity, shape/direction options, example spec uses an actuated FCV-101 (a hand valve has no signal port), refine hints in detail["hint"], preflight LAYER_SET_INTENT_MISMATCH, radial port matching on the circle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
46 KiB
v1.6 Track A — P&ID expert and reader
Date: 2026-09-15 · Status: approved; implementation plan at
docs/superpowers/plans/2026-09-15-v1.6-pid-implementation.md · 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
sourcesays so. - Every
SymbolSpeccarriessource: strnaming 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
scaleparameter 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/valveTAGheight 2.5, bubbleFUNC/LOOPheight 2.0 (ISO 3098 sizes). - Origin
(0, 0)= insertion point = centre of the body's bounding box. Rotation 0 = flow left→right (processinport on −X,outon +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_definesupports{"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_boundarypattern). An invalid entry raisesTypeErrornaming the index and the key; no block is created or modified. entitiesempty →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→ValueErrornaming the block and the flag.overwrite=Trueclears 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: truesays so. ezdxf:blk.delete_all_entities(); COM: iterate andDelete()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), thenAddLine/AddCircle/AddArc/AddLightWeightPolyline/AddText/AddAttribute/ AddHatchon the Block object;AddAttribute(height, mode, prompt, pt, tag, value)withmode = acAttributeModeInvisible (1)for invisible ATTDEFs, elseacAttributeModeNormal (0); text alignment throughAlignment+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.valuesempty → the app's XDATA is removed (documented, tested). setreplaces 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 intoFUNC/LOOP(area prefix and suffix kept inLOOPtext as written, e.g.101A); orfunc/loopmay be given inparams. An invalid tag is written and warned (tag_warnings), never refused — critique catches it. - Unknown
symbol/actuator/type/location→ValueErrorlisting the valid values.actuatoroncheck/relief→ValueError.paramson a non-parametric symbol →ValueError; parametric symbols validate params (sizes > 0, nozzle sides/fractions in range, unique nozzle names). scale ≤ 0→ValueError.layeroverrides 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. Aliasesacad=("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,seqauto-incremented per document from XDATA of existing lines). Label: TEXT height 2.5 onPROCESS-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_flowblock 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;routewaypoints that repeat a point;stub< 0. - One LWPOLYLINE per call. XDATA as in 5.2. Cost
mutate. Aliasesacad=("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:
- straight —
S→Ewhen both lie on one axis line and each end's direction points towards the other (or isNone). - L — one bend at
(E.x, S.y)or(S.x, E.y); valid when the first segment leavesSalongdSand the last arrives atEagainstdE. - Z — two bends through the midline between the stub points, along
dS's axis; then alongdE's axis. - U —
S→S₁→(corner)→E₁→Ewith 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):
Sin second position is the safety modifier only when followed byVorE(PSV,TSE); otherwise it is the switch output (PSL,LSHH).Din second position is always differential (PDT,PDIC).Fin second position is ratio only when the first letter is alsoF(FFC); otherwise it is parsed as a readout error.Kin second position is rate of change only when followed by an output letter;Kelsewhere is the control-station output (HIK).Qin second position is integrate (FQI,FQ).H,LandMare trailing modifiers (high/low/middle) when they followA(alarm),S(switch) or another trailing letter —LALis Level Alarm Low,PSLis Pressure Switch Low.Lanywhere else is the readout light (PL).OandCare trailing (open/closed) only as the last letter directly afterS(ZSO,ZSC); otherwiseCis the control output (TIC) andOthe 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 withuser_defined: trueand no description word for that letter. - Anything the grammar rejects →
valid: falsewitherrorsnaming 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
- Collect with
entity_listpaged (limit=1000,offsetloop) over the current space (or every layout whenscope="all", each entity tagged with its space). - Classify INSERTs: block name in the catalogue (
PID_*, markers and arrows excluded) →catalog1.0; else XDATAACADMCP_PIDwithkind="symbol"→xdata0.95; else heuristic 0.6 wheninclude_foreignand either an attribute tag in{TAG, TAGNO, TAGNUMBER, EQUIP_TAG, EQUIPMENT_TAG, INST_TAG, INSTTAG}exists or the block name contains one ofVALVE, 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_block0.3, otherwise dropped (title blocks, logos, north arrows). - 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
toleranceof the block's bounding-box boundary becomesp1..pnwithinferred: true. - 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) wheninclude_foreign, touch a node's port or bounding box at an endpoint.line_classfrom XDATA, else from the layer:PROCESS-PIPING-MAIN → process_major,PROCESS-PIPING-SECONDARY → process_minor,UTILITY-LINE → utility,ELECTRICAL-LINE → electric,INSTRUMENT-LINE-SIGNAL → electricwhen the effective linetype is dashed, otherwisesignal_unknown(pneumaticis never assumed from a plain line); any other layer →null. - 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 withintolerance→{node, port}; else another edge's vertex withintolerance→ junction; else a point withintoleranceof another edge's segment interior → junction (the touched edge is not split); elsedanglingwith the nearest port within10·toleranceas a hint. - Tags: instrument
FUNC+LOOP→TAGattribute → the TEXT/MTEXT whose insertion lies inside the bubble radius → null. Equipment/valve:TAGattribute → nearest TEXT withinlabel_searchabove the bounding box → null. Line numbers: XDATA → nearest TEXT withinlabel_searchof the longest segment whose content matches^\d+(?:"|mm)?-[A-Z]{1,4}-\d+→ null. Every source is reported. - Off-page links: connectors with equal
LINKvalues (same file) are joined inoffpage_links; the reader does not invent an edge between them. - Stats as in 6.5;
confidence_minover 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 addmech,arch,plant3d,settings).coreis every registered tool not claimed by another pack and cannot be disabled; an unknown pack name logs a warning, is reported insystem_about["tool_packs"]["ignored"], and is otherwise ignored (the server boots).PACK_TOOL_NAMES: dict[str, frozenset[str]]next toLEAN_TOOL_NAMES;_profile_enabled_namesbecomesenabled = registered ∩ packs_enabled, then∩ LEAN_TOOL_NAMESforlean, then theENABLE_3Dsubtraction.LEAN_TOOL_NAMESgainspid_symbol_insert,pid_line_draw,pid_graph(lean = 50).system_aboutreportstool_packs: {"enabled": [...], "available": [...], "ignored": [...]};_active_tool_profilerecords the same. The README token table gains a row per pack (measured withbenchmarks/token_suite.py).- The
unknown_toolrejection message namesTOOL_PACKSalongsideTOOL_PROFILE/ENABLE_3D.
13. Resources
autocad://pid/symbols— thepid_symbol_list()payload.autocad://standards/isa51— the letter tables and grammar summary fromtags.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/TypeErrorraised before any write, with the offending key or index in the message; the server wrapper turns them intoToolErroras it does today. - Engine boundaries: none in this track — every method is abstract on both
engines. (
drawing_openof a DWG headlessly stays the existing refusal.) - Reader results carry
source/confidencefields instead of raising on uncertainty; only a failure to read the drawing at all raises. - Nothing in
pid_*callssystem_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 headlessblock_insert(attributes=…)usedadd_auto_blockref, which wraps the INSERT in an anonymous*Ublock, so the returned handle names*U1andblock_get_attributesfinds 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 passblock_definevalidation, declaredbboxcontains 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,sourcenon-empty; parametric hash stability; actuator composition adds exactly onesignalport.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 fromtest_block_from_entities.pyasserting theBlocks.Add/AddAttributecall 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 throughpid/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,seqauto-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) →danglingandnearestreported; 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 underALLOWED_PATHSand refused outside.tests/test_pid_critique.py— each focus positive and negative; all six return[]on the README gear sheet;drawing_refineemits 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_runwrites nothing;drawing_finalizescore ≥ 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=corehides everypid_*tool and keepsblock_define; unknown pack ignored withignored;corecannot be disabled; interaction withleanandENABLE_3D.- Existing gates extended: alias records for 12 tools (
test_discovery_aliases),docs/tool-inventory.jsonregenerated (test_tool_inventory), README snapshot line andSECTION 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, theTOOL_PACKSvariable, 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,Configurationentry forTOOL_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)
- Contracts:
block_define+ XDATA on both engines, tests, aliases, inventory. - Catalogue:
symbols.py+geometry.py, catalogue tests, contact-sheet script. pid_symbol_list/insert, layers, XDATA payloads.lines.py+pid_line_draw, markers, arrows, labels.tags.py+pid_tag_parse+ fixture + resource.graph.py+pid_graph(+ foreign fixture), deliverables.- Critique focuses, refine hints, plan rule, prompt rewrite, finalize test.
pid_from_spec, README showcase render.- Tool packs,
system_about, token table. - Benchmark v4 task, correctness checks, live COM smoke, docs, gates green.