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
837 lines
47 KiB
Markdown
837 lines
47 KiB
Markdown
# 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_<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`
|
||
|
||
```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<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:
|
||
|
||
```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<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`):
|
||
|
||
```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
|
||
`<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.
|