diff --git a/CHANGELOG.md b/CHANGELOG.md
index 275f5fb..7f45963 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,175 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+Track A of 1.6: the server becomes a P&ID expert and a P&ID reader. Nine
+`pid_*` tools in a new SECTION 17, two generic contracts every engine
+implements, six critique focuses that join `drawing_finalize`, and a
+`TOOL_PACKS` gate so a mechanical-only client never pays for the P&ID
+surface. Spec: `docs/superpowers/specs/2026-09-15-v1.6-pid-design.md`.
+
+### Added
+
+- **P&ID — 9 tools, pack `pid`.**
+ - `pid_symbol_list` / `pid_symbol_insert`: a 43-symbol ISO 10628-2 /
+ ISA-5.1 catalogue authored in code (11 valve bodies × 6 actuators, 6
+ rotating machines, 4 heat exchangers, 6 parametric vessels with nozzle
+ ports, 7 inline items, the instrument bubble in 4 types × 5 locations,
+ 5 line markers, 3 connectors), placed as real blocks with a `TAG`
+ ATTRIB, named ports, and an `ACADMCP_PID` XDATA payload. Ports are
+ transformed through the INSERT's real rotation, scale and mirror; a
+ stretched symbol (`|x_scale| ≠ |y_scale|`) has no circle for a radial
+ port and is refused rather than answered with a wrong point.
+ - `pid_line_draw`: port-to-port orthogonal routing (`auto` picks from seven
+ candidates by fewest bends, then length; `direct`; or waypoints), eight
+ ISA-5.1 line classes mapped to layer / linetype / marker blocks, and line
+ numbers filled from a format (default
+ `{size}-{service}-{seq}-{spec}-{insulation}`; an empty field drops out
+ with its separator). A route that cannot honour the `stub` on both
+ ports is refused by name (`route((0,0), 0°, (3,8), 180°, stub=5)`
+ raises) rather than drawn short; a non-axis port direction, a NaN
+ coordinate and an unfillable format token are refused before anything
+ is written. Lines it crosses are **counted and
+ reported** (`crossings`), never silently drawn over — and never refused.
+ - `pid_tag_parse`: the ISA-5.1 identification-letter grammar (Table 4.1,
+ pinned by `tests/data/isa51_tags.json`), with the equipment-prefix map.
+ - `pid_graph`: the reader. Every symbol INSERT becomes a node with a
+ `source` and a `confidence` — catalogue name 1.0, `ACADMCP_PID` payload
+ 0.95, `TAG`-style attribute or keyword block name 0.6 with inferred
+ ports, an INSERT a plain line merely touches 0.3. Confidence is the
+ minimum of its inputs, never raised by agreement between heuristics.
+ Each line end resolves to a port, a junction on another line, or
+ `dangling` with the nearest connectable port as a hint, always within
+ its own space (`scope="all"` never joins two layouts). Edge `length` is
+ the bulge-aware length the engine reports, not a chord walk (12.5 %
+ short on a semicircular jump).
+ - `pid_instrument_index` / `pid_line_list` / `pid_equipment_list`: derived
+ from the graph only, optionally written as CSV. An instrument tapping a
+ pipe reports the pipe's line number, never its own signal wire.
+ - `pid_from_spec`: a whole sheet from one JSON spec in one transaction —
+ rolled back entirely on the first refusal — returning the handles, the
+ graph and the critique; `dry_run=True` routes in memory and touches
+ nothing.
+- **`block_define(name, entities, attdefs)`** on both engines: a block from
+ typed primitive specs (line, circle, arc, polyline, text, solid) and
+ ATTDEFs, no source entities needed; `overwrite=True` replaces the contents
+ so existing INSERTs keep the name. Validation runs before any write: a
+ DXF-illegal layer name, a duplicate or trailing-newline ATTDEF tag, a
+ multi-line or trailing-caret string are `TypeError`/`ValueError` naming
+ `entities[i]` / `attdefs[i]`, and nothing is created.
+- **`entity_get_xdata` / `entity_set_xdata`** on both engines, typed by DXF
+ group code. The two limits AutoCAD enforces silently are enforced loudly
+ and before the write: a string over **255 characters** and an entity
+ budget of **16 KB across every application** (three 15 KB apps on one
+ entity used to pass headlessly and would have failed `SetXData` live).
+ LF/CR in a string is refused — it split a DXF tag, the saved file failed
+ to reopen and, because every undo snapshot is a DXF save,
+ `transaction_rollback` broke after one such write. `nan`/`inf` are refused
+ rather than written as literal text that reads back as `null`.
+- **Six critique focuses** — `pid_dangling_line`, `pid_duplicate_tag`,
+ `pid_incompatible_connection`, `pid_untagged_instrument`,
+ `pid_illegal_tag`, `pid_unconnected_equipment` — run under `focus=None`
+ and inside `drawing_finalize`, over one cached graph. They score
+ evidence only: a drawing without a recognised P&ID node returns `[]`, a
+ 0.3 touch guess can neither create nor suppress a finding, and a line
+ ending on a sheet frame (an INSERT the drawing does not vouch for) is
+ dangling. `drawing_refine` hints name only ports `pid_line_draw` accepts.
+- **Two resources:** `autocad://pid/symbols` (the catalogue with ports and
+ parameter schemas) and `autocad://standards/isa51` (the letter tables and
+ the grammar's disambiguation rules).
+- **Benchmark evidence.** `benchmarks/tasks_v4.py` adds `pid_roundtrip`
+ (the example sheet drawn through `pid_from_spec`, read back by a graph
+ builder that never sees the spec); the correctness suite grows to 29 with
+ three P&ID checks. A/B against `v1.5.1`: **26 / 29 → 29 / 29, three
+ `miss → pass`, zero regressed** (`ab-v1.5.1-vs-v1.6.0-dev.json`).
+- `scripts/smoke_pid_com.py` runs the track once against the live AutoCAD
+ on the machine, in a new document; `scripts/render_pid_catalog.py` and
+ `scripts/render_readme_pid.py` rebuild the two README images.
+
+### Fixed
+
+- **Headless `block_insert(attributes=…)` lost its ATTRIBs.** The ezdxf path
+ used `add_auto_blockref`, which wraps the INSERT in an anonymous `*U`
+ block: the handle it returned reported `block_name` `*U1`, and
+ `block_get_attributes` on it returned `{}`. Reproduce on v1.5.1 with
+ `block_create_from_entities` of a block with one ATTDEF, then
+ `block_insert(name, attributes={"TAG": "P-101"})` and
+ `block_get_attributes(handle)`. It now inserts the named block with
+ `add_auto_attribs`, so the INSERT carries the name and the ATTRIBs, on
+ both engines alike. `block_insert` of an undefined name is refused before
+ writing (it used to return a handle whose dangling INSERT `drawing_audit`
+ later deleted).
+- Live LWPOLYLINEs report as `POLYLINE` through ActiveX (`AcDbPolyline`),
+ which the P&ID line reader's type filter excluded: on AutoCAD every
+ auto-built line number restarted at seq 1 and `crossings` was always 0.
+ Both spellings are accepted; the COM `_entity_info` polyline branch now
+ returns `points` (and per-vertex `bulges`) for a live LWPOLYLINE.
+- Polyline `bulges` now leave both engines in WCS: a mirrored polyline's
+ arcs were handed out in the entity frame next to WCS points, so a reader
+ tested them on the wrong side of the chord.
+- INSERT properties gain `geometry_bbox` (drawn geometry only, ATTRIBs
+ excluded) and `mirrored` on both engines. The COM box is exact at right
+ angles and measured off them; a member ActiveX cannot measure is carried
+ by its corners and the box says `approximate: true`.
+
+### Changed
+
+- **`TOOL_PACKS=all|core,pid`** (default `all`) advertises only the vertical
+ packs a client uses; `core` is always on and unknown names are ignored
+ with a warning. It composes with `TOOL_PROFILE=lean` (which now carries
+ `pid_symbol_insert`, `pid_line_draw` and `pid_graph`: 50 tools, 47 with
+ `TOOL_PACKS=core`) and `ENABLE_3D`; `system_about` reports `tool_packs`.
+ Measured idle cost: 161 tools / 44,930 tokens by default, 152 / 41,420
+ with `TOOL_PACKS=core`.
+- `PID_LAYERS` (`drawing_apply_iso_layers("pid")`) gains `PROCESS-LINE-TEXT`;
+ `drawing_plan` / `drawing_preflight` report `LAYER_SET_INTENT_MISMATCH`
+ when a P&ID intent is planned onto a non-`pid` layer set.
+- The `prompt_pid_diagram` prompt template is rewritten onto the tools (`pid_from_spec`,
+ `pid_symbol_insert`, `pid_line_draw`, `pid_graph`) instead of describing
+ primitives.
+- `CritiqueFocus` grows by the six `pid_*` values; benchmark matrix v4
+ (`--matrix v3` / `v2` reproduce the earlier sets); correctness suite 29.
+- README release snapshot: 166 tools · 8 resources · 5 prompts · 2423
+ collected tests. The collected-test figure is this machine's; CI has not
+ run on this tree yet, and the pre-existing 1369-vs-1363 drift between the
+ README and Linux collection predates the track and is superseded by the
+ new figure.
+
+### Live COM smoke
+
+`scripts/smoke_pid_com.py`, AutoCAD 2026 (`25.1s (LMS Tech)`), Windows 11,
+2026-09-16, in a new document (`Drawing2.dwg`) — `block_define` →
+`pid_symbol_insert` → `entity_set_xdata` → `pid_line_draw` → `pid_graph` →
+six focuses → `entity_get_xdata`, all through the COM engine, exit 0:
+
+```json
+{
+ "autocad": "25.1s (LMS Tech)",
+ "active_document": "Drawing2.dwg",
+ "handles": {"P-101": "98", "V-201": "A8", "FCV-101": "B8", "FIC-101": "C5", "OP-1": "CF"},
+ "graph_stats": {
+ "nodes_by_kind": {"connector": 1, "equipment": 2, "instrument": 1, "valve": 1},
+ "edges_by_class": {"electric": 1, "process_major": 3},
+ "dangling": 0,
+ "unclassified": 0,
+ "confidence_min": 1.0,
+ "entities_scanned": 16
+ },
+ "crossings_total": 0,
+ "critique_issues": 0,
+ "critique": [],
+ "xdata_chunks": 2
+}
+```
+
+The headless engine reports the identical `graph_stats`, `crossings_total`,
+`critique_issues` and `xdata_chunks` on the same spec. The first attempt
+exited 2 on this machine because of a stale `win32com` `gen_py` cache for the
+AutoCAD type library (a package directory holding only a `__pycache__`) — an
+environment fault, not a backend one; deleting `%TEMP%\gen_py\3.11` cleared
+it and no backend change was needed.
+
## [1.5.1] — 2026-08-06
A patch for three defects found *after* 1.5.0 went to PyPI, two of them by the
diff --git a/CLAUDE.md b/CLAUDE.md
index ada94d8..dcfad51 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
-AutoCAD MCP Pro is a FastMCP 3.0 server that exposes ~154 tools, 6 resources, and 5 prompt templates for AutoCAD automation. (The exact tool count is reported dynamically by `system_status` / `system_about` — never hardcode it.) It runs with a dual-engine architecture: a live COM backend (Windows/AutoCAD required) and a headless ezdxf backend (works anywhere).
+AutoCAD MCP Pro is a FastMCP 3.0 server that exposes ~166 tools, 8 resources, and 5 prompt templates for AutoCAD automation. (The exact tool count is reported dynamically by `system_status` / `system_about` — never hardcode it.) It runs with a dual-engine architecture: a live COM backend (Windows/AutoCAD required) and a headless ezdxf backend (works anywhere).
## Running the Server
@@ -76,9 +76,9 @@ Tools are organized into sections (counts are indicative — `system_about` is a
2. Entity Creation (14 tools): `entity_create_*` (includes `entity_create_table`, `leader_create_mleader`)
3. Dimensions (5 tools): `dimension_*`
4. Entity Modification (16 tools): `entity_move/copy/rotate/scale/mirror/offset/delete/array_*`, corner ops (`entity_trim/extend/fillet/chamfer`), plus in-place `entity_edit_text` (TEXT/MTEXT content/height/rotation) and `entity_edit_geometry` (CIRCLE/LINE/ARC — center/radius/endpoints/angles), both handle-preserving
-5. Entity Query (4 tools): `entity_get`, `entity_list`, `entity_delete_many`, `selection_get`
+5. Entity Query (9 tools): `entity_get`, `entity_list`, `entity_delete_many`, `selection_get`, `selection_window/polygon/filter`, and `entity_get_xdata` / `entity_set_xdata` (typed by group code)
6. Layer Management (14 tools incl. 2 linetype_*): `layer_*`, `linetype_list`, `linetype_load`
-7. Block Operations (7 tools): `block_*`
+7. Block Operations (8 tools): `block_*` — includes `block_define` (a block from typed primitive specs + ATTDEFs, no source entities needed)
8. Analysis & Query (8 tools): `analysis_*` — plus Batch (2), Templates (2), Validation (1)
9. View & Screenshot (4 tools — includes `view_zoom_and_screenshot`): `view_*`
10. Transactions (3 tools): `transaction_begin/commit/rollback`
@@ -88,8 +88,11 @@ Tools are organized into sections (counts are indicative — `system_about` is a
14. GD&T (ISO 1101 / ASME Y14.5): `gd_frame` (feature control frames), `datum_feature` — enforced by the `gdt` critique focus
15. Layouts & Paper Space (12 tools): tab lifecycle `layout_list/create/set_current/delete/rename/copy`, viewports `viewport_create/list/set_scale/lock/delete`, and `entity_change_space` (CHSPACE — tagged `layout` *and* `modify`, and `layout` wins the group priority, so it files here). `drawing_export_pdf` takes an optional `layout` param and **does** project model content through viewports on both engines (`viewport_render` — v1.4 declared this COM-only and was wrong; the headless renderer only omits the viewport borders). `entity_change_space` is the mirror case — ezdxf-only (`chspace`), because ActiveX exposes no change-space member and the COM route is unverified.
16. 3D Solids (5 tools, opt-in via `ENABLE_3D=true`, COM only): `solid_box/cylinder/extrude/revolve/boolean` — hidden from discovery and rejected while disabled; ezdxf reports `solid_3d` as unsupported (no headless ACIS).
+17. P&ID (9 tools, pack `pid`): `pid_symbol_list/insert` (ISO 10628-2 + ISA-5.1 catalogue authored in code, real blocks with TAG attributes, named ports, ACADMCP_PID XDATA), `pid_line_draw` (port-to-port orthogonal routing, ISA-5.1 line classes → layer/linetype/marker blocks, line numbers), `pid_tag_parse` (ISA-5.1 grammar), the reader `pid_graph` and its deliverables `pid_instrument_index/line_list/equipment_list`, and `pid_from_spec` (whole sheet, one transaction). Six `pid_*` critique focuses (`pid_dangling_line`, `pid_duplicate_tag`, `pid_incompatible_connection`, `pid_untagged_instrument`, `pid_illegal_tag`, `pid_unconnected_equipment`) run under `focus=None` and inside `drawing_finalize`; they return nothing on a drawing without P&ID nodes. Two resources (`autocad://pid/symbols`, `autocad://standards/isa51`) carry the catalogue and the ISA-5.1 letter tables. Both engines implement every P&ID contract; the COM paths were executed live once by `scripts/smoke_pid_com.py` (AutoCAD 2026).
-**Tool profiles:** `TOOL_PROFILE=lean|full` (default `full`) controls the advertised surface — `lean` ≈ 47 curated drafting tools (including `cad_batch`, since a client with a tight tool cap is exactly the client paying most per turn). Applied in the lifespan; reported by `system_about`. The `core` profile was removed in v1.5.0 (the discovery layer replaced it); `TOOL_PROFILE=core` falls back to `full` with a warning.
+**Tool profiles:** `TOOL_PROFILE=lean|full` (default `full`) controls the advertised surface — `lean` ≈ 50 curated drafting tools (47 with `TOOL_PACKS=core`; including `cad_batch`, since a client with a tight tool cap is exactly the client paying most per turn). Applied in the lifespan; reported by `system_about`. The `core` profile was removed in v1.5.0 (the discovery layer replaced it); `TOOL_PROFILE=core` falls back to `full` with a warning.
+
+**Tool packs:** `TOOL_PACKS=all|core,pid` (default `all`) advertises only the vertical packs a client uses; `core` is always on and is everything no other pack claims; unknown names are ignored with a warning; a `lean` profile intersects with the enabled packs. `system_about` reports `tool_packs`.
**Tool discovery:** `DISCOVERY_MODE=off|search` (default `off`). `search` replaces the advertised catalog with `search_tools` + `call_tool`, ranking hits over an AutoCAD command / synonym corpus (`discovery/aliases.py`) layered on fastmcp's BM25 index.
@@ -101,7 +104,7 @@ Tools are organized into sections (counts are indicative — `system_about` is a
**Measuring:** never read vertices back and shoelace them — that loses 28.2% of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry, and its payload states its own accuracy: `exact`, `flatten_tolerance`, `assumed_closed`, `self_intersecting`. A HATCH reports the area it *fills* (outer loops minus islands) plus `hatch_style`; on a curved hatch edge `flatten_tolerance` is not the accuracy knob, because ezdxf hands boundaries over as cubic Beziers whose ~0.028% circle error is already baked in. `boundary_trace` / `boundary_from_entities` report the same number `analysis_measure_entity` gives for the polyline they just drew — if those ever diverge, one of them is approximating and neither says so.
-**Benchmark matrix:** `benchmarks/tasks_v4.py` is the current set (16 tasks; `--matrix v3` / `--matrix v2` reproduce the earlier sets). The published competitor reports are pinned v1.4 runs against v2 and are **never** back-filled for the five v3 tasks or the v4 `pid_roundtrip` — the chart shows them `not run`, not zero. `benchmarks/correctness_suite.py` (29 checks) is the release A/B gate: `python benchmarks/compare_versions.py v1.4.0`.
+**Benchmark matrix:** `benchmarks/tasks_v4.py` is the current set (16 tasks; `--matrix v3` / `--matrix v2` reproduce the earlier sets). The published competitor reports are pinned v1.4 runs against v2 and are **never** back-filled for the five v3 tasks or the v4 `pid_roundtrip` — the chart shows them `not run`, not zero. `benchmarks/correctness_suite.py` (29 checks) is the release A/B gate: `python benchmarks/compare_versions.py v1.5.1` (the published report is `benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json`; `scripts/check_doc_numbers.py` names it in `AB_REPORT`).
### Adding a New Tool
@@ -126,6 +129,9 @@ Tools are organized into sections (counts are indicative — `system_about` is a
- **Screenshot**: COM uses Win32 window capture (Pillow required); ezdxf renders via matplotlib.
- **`_dc(obj)`**: converts dataclasses to dicts recursively for JSON serialization.
- **Engineering layer scaffold**: `drawing_new` auto-bootstraps standard linetypes (CENTER, HIDDEN, PHANTOM) and engineering layers (GEOMETRY, DIM, CENTER, HIDDEN, PHANTOM, HATCH, TEXT, TITLEBLOCK). Pass `bootstrap=False` to opt out.
+- **Generic contracts added in 1.6:** `block_define(name, entities, attdefs)` (typed primitives + ATTDEFs, both engines; `overwrite` replaces contents so INSERTs keep the name) and `entity_get/set_xdata` (typed by group code; 255-char strings, 16 KB per entity, enforced before writing).
+- **P&ID reader rules:** `pid_graph` never modifies the drawing, reads geometry from the drawing (an INSERT's real rotation/scale) rather than the catalogue, and reports `source`/`confidence` per node (catalog 1.0, xdata 0.95, heuristic 0.6, inferred 0.3) — never raised by agreement between heuristics. Read `stats.confidence_min` before trusting a foreign drawing.
+- **`TOOL_PACKS=all|core,pid`** advertises only the packs a client uses; `core` is always on; `system_about` reports `tool_packs`.
- **Production drawings**: For real engineering output, use the `engineering/` package primitives via the `gear_*` / `keyway_*` / `titleblock_*` MCP tools — do NOT hand-draw teeth/keyways/sections with raw `entity_create_*` calls. Always end with `drawing_finalize` for the 8-step validator.
### Premium Drawing Rules
diff --git a/README.md b/README.md
index c6c6e92..0864612 100644
--- a/README.md
+++ b/README.md
@@ -17,22 +17,31 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
Not a mockup. Every line on this sheet was drawn by the tools this server exposes — ISO layers, involute gear geometry, DIN 6885 keyway, section A-A, ISO 129 dimensions, an ISO 286 H7 bore fit, ISO 7200 title block — then rendered headlessly by view_screenshot. drawing_critique returns 0 issues on it. Rebuild it with python scripts/render_readme_showcase.py.
+
+
+The same server as a P&ID drafter. pid_from_spec placed five catalogue symbols as real blocks with TAG attributes and named ports, routed four port-to-port lines with ISA-5.1 classes and line numbers in one transaction, then pid_graph read the sheet back: 5 nodes, 4 edges, 0 dangling ends, confidence_min 1.0. Rebuild it with python scripts/render_readme_pid.py.
+
+
+
+The catalogue, authored in code. 43 ISO 10628-2 / ISA-5.1 symbols — 11 valve bodies × 6 actuators, 6 rotating machines, 4 heat exchangers, 6 parametric vessels, 7 inline items, the instrument bubble in 4 types × 5 locations, 5 line markers, 3 connectors — every one placed through pid_symbol_insert's own path. Rebuild it with python scripts/render_pid_catalog.py.
+
-> **v1.5 release snapshot:** 166 tools · 8 resources · 5 prompt templates · 1369 collected tests.
-> 154 is the **registered** count; a default install advertises 149 over `tools/list`,
+> **v1.5 release snapshot:** 166 tools · 8 resources · 5 prompt templates · 2423 collected tests.
+> 166 is the **registered** count; a default install advertises 161 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
## Why this exists
-**A big MCP server is expensive to be connected to.** The full catalog costs a client **40,305 tokens** before it has asked for anything. Discovery mode replaces it with two tools and costs **356**.
+**A big MCP server is expensive to be connected to.** The full catalog costs a client **44,930 tokens** before it has asked for anything. Discovery mode replaces it with two tools and costs **356**.
-**A drafter searches for `FILLET`, not `entity_fillet`.** Those command names appeared in no tool name or description — `df = 0` against a stock index, not badly ranked but *absent*. The fix was data: an authored corpus of **158 AutoCAD command names and 672 synonym phrases** covering all 154 tools. A test refuses to let a tool exist without one.
+**A drafter searches for `FILLET`, not `entity_fillet`.** Those command names appeared in no tool name or description — `df = 0` against a stock index, not badly ranked but *absent*. The fix was data: an authored corpus of **162 AutoCAD command names and 731 synonym phrases** covering all 166 tools. A test refuses to let a tool exist without one.
| Advertised surface | Tools seen | Idle cost |
|---|---:|---:|
-| `TOOL_PROFILE=full` (default) | 149 | 40,305 tokens |
-| `TOOL_PROFILE=lean` | 47 | 12,131 tokens |
+| `TOOL_PROFILE=full` (default) | 161 | 44,930 tokens |
+| `TOOL_PACKS=core` (full profile) | 152 | 41,420 tokens |
+| `TOOL_PROFILE=lean` | 50 | 14,007 tokens |
| `DISCOVERY_MODE=search` | 2 | **356 tokens** |
> [!NOTE]
@@ -91,7 +100,8 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
| Geometry | lines, arcs, polylines, splines, hatches, trim/extend/fillet/chamfer, handle-preserving edits |
| Annotation | ISO 129 toleranced dimensions, ISO 286 fits (`fit="H7"`), TABLE, MLEADER, GD&T frames and datums (ISO 1101) |
| Engineering generators | involute gears (front + section A-A), DIN 6885 keyed bores, ISO A3 title block |
-| Discovery | `search_tools` ranked over an AutoCAD command and synonym corpus — `FILLET`, `BPOLY`, `QSELECT`, `WBLOCK`, `OVERKILL`, `CHSPACE` each rank **#1** of the 149-tool advertised catalog |
+| P&ID | catalogue blocks with ports and tags (ISO 10628-2 / ISA-5.1), port-to-port lines with ISA-5.1 classes and line numbers, `pid_graph` reads any P&ID back with confidence, instrument index / line list / equipment list, `pid_from_spec` one-call sheets |
+| Discovery | `search_tools` ranked over an AutoCAD command and synonym corpus — `FILLET`, `BPOLY`, `QSELECT`, `WBLOCK`, `OVERKILL`, `CHSPACE` each rank **#1** of the 161-tool advertised catalog |
| Batching | `cad_batch` runs a step list in one round trip; `fields=` projects 11 result-heavy tools |
| Paper space | tab lifecycle, viewports, `entity_change_space` (CHSPACE), `drawing_export_pdf(layout=…)` |
| Selection | window vs crossing stated back to the caller; a polygon tested against its own shape, not its bounding box |
@@ -103,7 +113,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) |
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
-154 tools in 19 groups. Plus 6 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status) and 5 prompt templates.
+166 tools in 20 groups; TOOL_PACKS=core hides the nine pid_* tools from a client that never draws a P&ID. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates.
**Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy.
@@ -147,22 +157,23 @@ Self-measurement, produced by scripts in [`benchmarks/`](https://github.com/U-C4
| Version | Checks passing | Pass rate | Fixed | Regressed |
|---|---:|---:|---:|---:|
-| v1.5.0 *(baseline)* | 24 / 26 | 92.3 % | — | — |
-| **v1.5.1** *(this release)* | **26 / 26** | **100 %** | 2 | **0** |
+| v1.5.1 *(baseline)* | 26 / 29 | 89.7 % | — | — |
+| **v1.6.0-dev** *(this branch)* | **29 / 29** | **100 %** | 3 | **0** |
-Against the older `v1.4.0` baseline the same suite reports **21 / 26 → 26 / 26, five fixed, zero regressed**. Three of the five are new capability (`miss → pass`); two are repaired defects (`fail → pass`) — the diameter and radius callouts, which measured the leader as geometry and dimensioned a 40 mm bore as 60 at default settings.
+The three fixed rows are the P&ID checks, new capability (`miss → pass`): `block_define` round-trips an ATTDEF, `pid_tag_parse` reads `FIC-101`, and `pid_graph` counts the example sheet's four edges. The 26 checks v1.5.1 was gated on all still pass. Against the older `v1.4.0` baseline the same 26 reported **21 / 26 → 26 / 26, five fixed, zero regressed** — two of those were repaired defects (`fail → pass`), the diameter and radius callouts, which measured the leader as geometry and dimensioned a 40 mm bore as 60 at default settings.
-### The task matrix — five tasks that can fail
+### The task matrix — six tasks that can fail
-An earlier matrix scored this server 10/10, which carried no information: every task in it exercised something the server was built around. Five were added because they *can* fail, and three did while being written.
+An earlier matrix scored this server 10/10, which carried no information: every task in it exercised something the server was built around. Five were added in 1.5 because they *can* fail, and three did while being written; 1.6 adds a sixth that the P&ID reader can fail on its own.
| Task | Verified against |
|---|---|
| `tool_discovery` | six AutoCAD command names, each ranking #1 |
-| `token_budget` | 40,305 → 356 tokens, against a ceiling fixed in advance |
+| `token_budget` | 44,930 → 356 tokens, against a ceiling fixed in advance |
| `hatch_islands` | 300 filled with the island, 400 ignoring it |
| `selection_filter` | window 1, crossing 2, bounding box 3, polygon 1 |
| `measure_from_handle` | 139.2699 against the 100.0 a vertex shoelace gives |
+| `pid_roundtrip` | the example sheet drawn by `pid_from_spec`, read back by `pid_graph`, which never sees the spec: 5 nodes, 4 edges, 0 dangling, `confidence_min` 1.0, `FIC-101` wired to `FCV-101` |
### Headless performance
@@ -184,6 +195,14 @@ A page that only lists strengths is a page that has not been measured.
**Non-AutoCAD ProgIDs are unverified.** `CAD_PROGID` changes which COM application the backend attaches to; nothing beyond the connection has been tested against BricsCAD, ZWCAD or GstarCAD.
+### Known limitations of the P&ID track
+
+**The router is orthogonal and blind.** `pid_line_draw` picks `auto` from seven orthogonal candidates — straight, one bend, two bends through the midline, or a stub out of each port joined by three bends — by fewest bends then length (or takes `direct`, or the waypoints you pass), and *counts* the lines it crosses — `crossings` comes back in every result and `pid_from_spec` sums it — but it does not route around them. Obstacle-avoiding routing is out of 1.6; move the symbol or pass waypoints.
+
+**A foreign drawing is read at the confidence it earns, and no higher.** A block this server did not place classifies by its `TAG`-style attribute or a keyword in its name at 0.6 with inferred ports, or at 0.3 when a line merely touches it — and a 0.3 guess can neither raise nor suppress a critique finding. On the live engine an INSERT turned off a right angle whose members ActiveX cannot measure reports an *approximate* box; `pid_graph` takes the tighter wall of that and the attribute-inclusive box on every side, but a line ending on such a body's true edge can still be reported `dangling` with the nearest port as the hint. Read `stats.confidence_min` first.
+
+**Same-file only, DXF headlessly.** Off-page connectors link within one drawing; links across files are not resolved. The headless engine reads DXF; a DWG P&ID is read through the live backend. No DEXPI/Proteus export and no ISA-5.2 binary-logic symbols in this release, recorded in the spec so nobody re-derives the cut.
+
## Configuration
Nothing loads a `.env` file — export these, or set them in your MCP client's `env` block.
diff --git a/benchmarks/README.md b/benchmarks/README.md
index e3a8c7a..58d83fb 100644
--- a/benchmarks/README.md
+++ b/benchmarks/README.md
@@ -214,29 +214,45 @@ python benchmarks/compare_versions.py v1.0.0 # vs a tag/ref
python benchmarks/compare_versions.py --json results.json
```
-### Result — v1.5.1 vs v1.5.0 (release gate)
+### Result — this branch (1.6.0-dev) vs v1.5.1 (release gate)
29 checks, ezdxf backend, one subprocess per check. Machine-readable report:
+[`results/published/ab-v1.5.1-vs-v1.6.0-dev.json`](results/published/ab-v1.5.1-vs-v1.6.0-dev.json).
+
+| Version | Checks passing | Pass rate | Fixed | Regressed |
+|---------|----------------|-----------|-------|-----------|
+| **v1.5.1** (baseline) | 26 / 29 | 89.7 % | — | — |
+| **v1.6.0-dev** (this branch) | 29 / 29 | 100 % | 3 | **0** |
+
+The three are the P&ID checks Track A added — `pid_block_define_attdef_roundtrip`,
+`pid_tag_parse_fic`, `pid_graph_edge_count` — all `miss → pass`: v1.5.1 has
+none of the methods. Every one of the 26 checks it was released on still
+passes, so the track added capability without moving a number it had already
+earned.
+
+### Result — v1.5.1 vs v1.5.0
+
+26 checks at the time, ezdxf backend, one subprocess per check. Machine-readable report:
[`results/published/ab-v1.5.0-vs-v1.5.1.json`](results/published/ab-v1.5.0-vs-v1.5.1.json).
| Version | Checks passing | Pass rate | Fixed | Regressed |
|---------|----------------|-----------|-------|-----------|
-| **v1.5.0** (baseline) | 24 / 26 | 92.3 % | — | — |
-| **v1.5.1** (this release) | 26 / 26 | 100 % | 2 | **0** |
+| v1.5.0 (baseline) | 24 / 26 | 92.3 % | — | — |
+| v1.5.1 (that release) | 26 / 26 | 100 % | 2 | **0** |
v1.5.0 fails `diameter_dim_measures_the_diameter` and
`radius_dim_ignores_the_leader_length`: it has both methods and gets both
wrong. That is the defect 1.5.1 exists to fix and the reason 1.5.0 is yanked.
-### Result — this release vs v1.4.0
+### Result — v1.5.1 vs v1.4.0
-Same suite, older baseline:
+Same 26-check suite, older baseline:
[`results/published/ab-v1.4.0-vs-v1.5.1.json`](results/published/ab-v1.4.0-vs-v1.5.1.json).
| Version | Checks passing | Pass rate | Fixed | Regressed |
|---------|----------------|-----------|-------|-----------|
-| **v1.4.0** (baseline) | 21 / 26 | 80.8 % | — | — |
-| **v1.5.1** (this release) | 26 / 26 | 100 % | 5 | **0** |
+| v1.4.0 (baseline) | 21 / 26 | 80.8 % | — | — |
+| v1.5.1 (that release) | 26 / 26 | 100 % | 5 | **0** |
> This row used to read `v1.5.0 | 24 / 26 | 92.3 % | 5`, which was impossible on
> its face — 21 passing plus 5 fixed is 26, not 24 — and it contradicted the
diff --git a/benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json b/benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json
new file mode 100644
index 0000000..45109b1
--- /dev/null
+++ b/benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json
@@ -0,0 +1,188 @@
+{
+ "summary": {
+ "baseline_ref": "v1.5.1",
+ "total": 29,
+ "old_pass": 26,
+ "new_pass": 29,
+ "old_pct": 89.7,
+ "new_pct": 100.0,
+ "fixed": 3,
+ "regressed": 0
+ },
+ "rows": [
+ {
+ "check": "core_line_length",
+ "category": "Core",
+ "old": "pass",
+ "new": "pass"
+ },
+ {
+ "check": "core_circle_radius",
+ "category": "Core",
+ "old": "pass",
+ "new": "pass"
+ },
+ {
+ "check": "core_layer_create",
+ "category": "Core",
+ "old": "pass",
+ "new": "pass"
+ },
+ {
+ "check": "core_polyline_closed",
+ "category": "Core",
+ "old": "pass",
+ "new": "pass"
+ },
+ {
+ "check": "core_linear_dim",
+ "category": "Core",
+ "old": "pass",
+ "new": "pass"
+ },
+ {
+ "check": "core_save_dxf_roundtrip",
+ "category": "Core",
+ "old": "pass",
+ "new": "pass"
+ },
+ {
+ "check": "dim_aligned_no_error",
+ "category": "Dimensions",
+ "old": "pass",
+ "new": "pass"
+ },
+ {
+ "check": "dim_angular_no_error",
+ "category": "Dimensions",
+ "old": "pass",
+ "new": "pass"
+ },
+ {
+ "check": "array_polar_360_distinct",
+ "category": "Modify",
+ "old": "pass",
+ "new": "pass"
+ },
+ {
+ "check": "point_intersection_line_line",
+ "category": "Geometry",
+ "old": "pass",
+ "new": "pass"
+ },
+ {
+ "check": "point_tangent_external",
+ "category": "Geometry",
+ "old": "pass",
+ "new": "pass"
+ },
+ {
+ "check": "arc_has_length",
+ "category": "Query",
+ "old": "pass",
+ "new": "pass"
+ },
+ {
+ "check": "arc_select_by_length",
+ "category": "Query",
+ "old": "pass",
+ "new": "pass"
+ },
+ {
+ "check": "ezdxf_bounding_box",
+ "category": "Query",
+ "old": "pass",
+ "new": "pass"
+ },
+ {
+ "check": "mtext_rotation_roundtrip",
+ "category": "Entities",
+ "old": "pass",
+ "new": "pass"
+ },
+ {
+ "check": "screenshot_png",
+ "category": "Render",
+ "old": "pass",
+ "new": "pass"
+ },
+ {
+ "check": "dim_overlap_critique_fires",
+ "category": "Quality gate",
+ "old": "pass",
+ "new": "pass"
+ },
+ {
+ "check": "iso13567_dim_layer",
+ "category": "Quality gate",
+ "old": "pass",
+ "new": "pass"
+ },
+ {
+ "check": "construction_left_iso_caught",
+ "category": "Quality gate",
+ "old": "pass",
+ "new": "pass"
+ },
+ {
+ "check": "gear_no_self_overlap",
+ "category": "Engineering",
+ "old": "pass",
+ "new": "pass"
+ },
+ {
+ "check": "center_linetype_applied",
+ "category": "Entities",
+ "old": "pass",
+ "new": "pass"
+ },
+ {
+ "check": "hatch_area_subtracts_its_island",
+ "category": "Measurement",
+ "old": "pass",
+ "new": "pass"
+ },
+ {
+ "check": "boundary_area_agrees_with_measure",
+ "category": "Measurement",
+ "old": "pass",
+ "new": "pass"
+ },
+ {
+ "check": "two_vertex_circle_has_area",
+ "category": "Measurement",
+ "old": "pass",
+ "new": "pass"
+ },
+ {
+ "check": "diameter_dim_measures_the_diameter",
+ "category": "Dimensions",
+ "old": "pass",
+ "new": "pass"
+ },
+ {
+ "check": "radius_dim_ignores_the_leader_length",
+ "category": "Dimensions",
+ "old": "pass",
+ "new": "pass"
+ },
+ {
+ "check": "pid_block_define_attdef_roundtrip",
+ "category": "P&ID",
+ "old": "miss",
+ "new": "pass"
+ },
+ {
+ "check": "pid_tag_parse_fic",
+ "category": "P&ID",
+ "old": "miss",
+ "new": "pass"
+ },
+ {
+ "check": "pid_graph_edge_count",
+ "category": "P&ID",
+ "old": "miss",
+ "new": "pass"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/docs/superpowers/plans/2026-09-15-v1.6-pid-implementation.md b/docs/superpowers/plans/2026-09-15-v1.6-pid-implementation.md
index 4592d3b..87a1020 100644
--- a/docs/superpowers/plans/2026-09-15-v1.6-pid-implementation.md
+++ b/docs/superpowers/plans/2026-09-15-v1.6-pid-implementation.md
@@ -341,7 +341,9 @@ def _number(spec: dict, key: str, where: str) -> float:
return _scalar(spec.get(key), f"{where}: {key!r}", positive=key in _POSITIVE)
-def _points(spec: dict, where: str, *, minimum: int, maximum: int | None = None) -> list[tuple[float, float]]:
+def _points(
+ spec: dict, where: str, *, minimum: int, maximum: int | None = None
+) -> list[tuple[float, float]]:
raw = spec.get("points")
if not isinstance(raw, (list, tuple)) or len(raw) < minimum:
raise TypeError(f"{where}: 'points' must be a list of at least {minimum} [x, y] pairs")
@@ -377,7 +379,9 @@ def _layer(spec: dict, where: str) -> str:
def validate_entity_specs(entities) -> list[dict]:
"""Normalise ``block_define`` primitive specs; refuse the whole list on the first defect."""
if not isinstance(entities, (list, tuple)) or not entities:
- raise ValueError("block_define: 'entities' is empty — a block that draws nothing is refused")
+ raise ValueError(
+ "block_define: 'entities' is empty — a block that draws nothing is refused"
+ )
out: list[dict] = []
for i, spec in enumerate(entities):
where = f"entities[{i}]"
@@ -553,7 +557,17 @@ async def test_defines_geometry_and_attdefs(backend):
}
blk = backend._doc.blocks.get("PID_VALVE_TEST")
types_ = sorted(e.dxftype() for e in blk)
- assert types_ == ["ARC", "ATTDEF", "ATTDEF", "CIRCLE", "LINE", "LWPOLYLINE", "LWPOLYLINE", "SOLID", "TEXT"]
+ assert types_ == [
+ "ARC",
+ "ATTDEF",
+ "ATTDEF",
+ "CIRCLE",
+ "LINE",
+ "LWPOLYLINE",
+ "LWPOLYLINE",
+ "SOLID",
+ "TEXT",
+ ]
attdefs = {e.dxf.tag: e for e in blk if e.dxftype() == "ATTDEF"}
assert attdefs["LINK"].is_invisible is True
assert attdefs["LINK"].dxf.text == "L-1"
@@ -581,7 +595,9 @@ async def test_name_clash_is_refused_unless_overwrite(backend):
async def test_overwrite_keeps_existing_inserts_pointing_at_the_name(backend):
await backend.block_define("PID_X", VALVE)
ref = await backend.block_insert("PID_X", 0, 0)
- await backend.block_define("PID_X", [{"type": "circle", "cx": 0, "cy": 0, "r": 9}], overwrite=True)
+ await backend.block_define(
+ "PID_X", [{"type": "circle", "cx": 0, "cy": 0, "r": 9}], overwrite=True
+ )
info = await backend.entity_get(ref.handle)
assert info.properties["block_name"] == "PID_X"
virtual = list(backend._doc.entitydb[ref.handle].virtual_entities())
@@ -736,124 +752,122 @@ Append to `BlockContract` in `backends/contracts/blocks.py`:
Add after `block_create_from_entities` in `backends/ezdxf_backend.py`:
```python
- async def block_define(
- self,
- name,
- entities,
- attdefs=None,
- base_x=0.0,
- base_y=0.0,
- overwrite=False,
- ) -> dict:
- """A block definition with ATTDEFs from typed specs.
+async def block_define(
+ self,
+ name,
+ entities,
+ attdefs=None,
+ base_x=0.0,
+ base_y=0.0,
+ overwrite=False,
+) -> dict:
+ """A block definition with ATTDEFs from typed specs.
- Validation runs before any write, so a malformed entry leaves no
- definition behind. ``overwrite`` replaces the *contents* of an existing
- definition rather than deleting it: existing INSERTs keep pointing at
- the name and show the new geometry (deleting a referenced block is
- refused by both engines; replacing contents works on both).
- """
- from ezdxf.enums import TextEntityAlignment
+ Validation runs before any write, so a malformed entry leaves no
+ definition behind. ``overwrite`` replaces the *contents* of an existing
+ definition rather than deleting it: existing INSERTs keep pointing at
+ the name and show the new geometry (deleting a referenced block is
+ refused by both engines; replacing contents works on both).
+ """
+ from ezdxf.enums import TextEntityAlignment
- from backends.block_specs import (
- solid_vertices,
- validate_attdef_specs,
- validate_entity_specs,
- )
- from security import sanitize_symbol_name
+ from backends.block_specs import (
+ solid_vertices,
+ validate_attdef_specs,
+ validate_entity_specs,
+ )
+ from security import sanitize_symbol_name
- clean_name = sanitize_symbol_name(name, kind="block")
- if clean_name.startswith("*"):
- raise ValueError("block_define: anonymous block names (*...) are refused")
- ents = validate_entity_specs(entities)
- atts = validate_attdef_specs(attdefs or [])
- align_map = {
- "left": TextEntityAlignment.LEFT,
- "center": TextEntityAlignment.CENTER,
- "right": TextEntityAlignment.RIGHT,
- "middle_center": TextEntityAlignment.MIDDLE_CENTER,
- }
+ clean_name = sanitize_symbol_name(name, kind="block")
+ if clean_name.startswith("*"):
+ raise ValueError("block_define: anonymous block names (*...) are refused")
+ ents = validate_entity_specs(entities)
+ atts = validate_attdef_specs(attdefs or [])
+ align_map = {
+ "left": TextEntityAlignment.LEFT,
+ "center": TextEntityAlignment.CENTER,
+ "right": TextEntityAlignment.RIGHT,
+ "middle_center": TextEntityAlignment.MIDDLE_CENTER,
+ }
- def _sync():
- doc = self._require_doc()
- existing = doc.blocks.get(clean_name) if clean_name in doc.blocks else None
- if existing is not None and not overwrite:
- raise ValueError(
- f"block_define: block {clean_name!r} already exists; "
- "pass overwrite=true to replace its contents"
+ def _sync():
+ doc = self._require_doc()
+ existing = doc.blocks.get(clean_name) if clean_name in doc.blocks else None
+ if existing is not None and not overwrite:
+ raise ValueError(
+ f"block_define: block {clean_name!r} already exists; "
+ "pass overwrite=true to replace its contents"
+ )
+ replaced = existing is not None
+ base = (float(base_x), float(base_y), 0.0)
+ if existing is None:
+ blk = doc.blocks.new(name=clean_name, base_point=base)
+ else:
+ blk = existing
+ blk.delete_all_entities()
+ blk.block.dxf.base_point = base
+ for spec in ents:
+ attribs = {"layer": spec["layer"], "color": 0, "linetype": "ByBlock"}
+ kind = spec["type"]
+ if kind == "line":
+ blk.add_line((spec["x1"], spec["y1"]), (spec["x2"], spec["y2"]), dxfattribs=attribs)
+ elif kind == "circle":
+ blk.add_circle((spec["cx"], spec["cy"]), spec["r"], dxfattribs=attribs)
+ elif kind == "arc":
+ blk.add_arc(
+ (spec["cx"], spec["cy"]),
+ spec["r"],
+ spec["start_deg"],
+ spec["end_deg"],
+ dxfattribs=attribs,
)
- replaced = existing is not None
- base = (float(base_x), float(base_y), 0.0)
- if existing is None:
- blk = doc.blocks.new(name=clean_name, base_point=base)
- else:
- blk = existing
- blk.delete_all_entities()
- blk.block.dxf.base_point = base
- for spec in ents:
- attribs = {"layer": spec["layer"], "color": 0, "linetype": "ByBlock"}
- kind = spec["type"]
- if kind == "line":
- blk.add_line(
- (spec["x1"], spec["y1"]), (spec["x2"], spec["y2"]), dxfattribs=attribs
- )
- elif kind == "circle":
- blk.add_circle((spec["cx"], spec["cy"]), spec["r"], dxfattribs=attribs)
- elif kind == "arc":
- blk.add_arc(
- (spec["cx"], spec["cy"]),
- spec["r"],
- spec["start_deg"],
- spec["end_deg"],
- dxfattribs=attribs,
- )
- elif kind == "polyline":
- bulges = spec["bulges"] or [0.0] * len(spec["points"])
- blk.add_lwpolyline(
- [(x, y, b) for (x, y), b in zip(spec["points"], bulges)],
- format="xyb",
- close=spec["closed"],
- dxfattribs=attribs,
- )
- elif kind == "text":
- txt = blk.add_text(
- spec["text"],
- dxfattribs={
- **attribs,
- "height": spec["height"],
- "rotation": spec["rotation_deg"],
- },
- )
- txt.set_placement((spec["x"], spec["y"]), align=align_map[spec["align"]])
- elif kind == "solid":
- blk.add_solid(solid_vertices(spec["points"]), dxfattribs=attribs)
- for att in atts:
- attdef = blk.add_attdef(
- att["tag"],
- insert=(att["x"], att["y"]),
- text=att["default"],
+ elif kind == "polyline":
+ bulges = spec["bulges"] or [0.0] * len(spec["points"])
+ blk.add_lwpolyline(
+ [(x, y, b) for (x, y), b in zip(spec["points"], bulges)],
+ format="xyb",
+ close=spec["closed"],
+ dxfattribs=attribs,
+ )
+ elif kind == "text":
+ txt = blk.add_text(
+ spec["text"],
dxfattribs={
- "layer": "0",
- "color": 0,
- "height": att["height"],
- "rotation": att["rotation_deg"],
- "prompt": att["prompt"],
+ **attribs,
+ "height": spec["height"],
+ "rotation": spec["rotation_deg"],
},
)
- attdef.set_placement((att["x"], att["y"]), align=align_map[att["align"]])
- if att["invisible"]:
- attdef.is_invisible = True
- self._mark_dirty()
- return {
- "ok": True,
- "name": clean_name,
- "entity_count": len(ents),
- "attdef_count": len(atts),
- "replaced": replaced,
- "backend": "ezdxf",
- }
+ txt.set_placement((spec["x"], spec["y"]), align=align_map[spec["align"]])
+ elif kind == "solid":
+ blk.add_solid(solid_vertices(spec["points"]), dxfattribs=attribs)
+ for att in atts:
+ attdef = blk.add_attdef(
+ att["tag"],
+ insert=(att["x"], att["y"]),
+ text=att["default"],
+ dxfattribs={
+ "layer": "0",
+ "color": 0,
+ "height": att["height"],
+ "rotation": att["rotation_deg"],
+ "prompt": att["prompt"],
+ },
+ )
+ attdef.set_placement((att["x"], att["y"]), align=align_map[att["align"]])
+ if att["invisible"]:
+ attdef.is_invisible = True
+ self._mark_dirty()
+ return {
+ "ok": True,
+ "name": clean_name,
+ "entity_count": len(ents),
+ "attdef_count": len(atts),
+ "replaced": replaced,
+ "backend": "ezdxf",
+ }
- return await self._async(_sync)
+ return await self._async(_sync)
```
- [ ] **Step 5: Implement on COM**
@@ -1014,7 +1028,9 @@ async def block_define(
] = None,
base_x: Annotated[float, "Block base point X"] = 0.0,
base_y: Annotated[float, "Block base point Y"] = 0.0,
- overwrite: Annotated[bool, "Replace the contents of an existing definition of this name"] = False,
+ overwrite: Annotated[
+ bool, "Replace the contents of an existing definition of this name"
+ ] = False,
ctx: Context = None,
) -> dict:
"""Create a block definition with attribute definitions from typed specs.
@@ -1303,7 +1319,9 @@ def encode_values(values) -> list[tuple[int, object]]:
raise TypeError(f"xdata: {where} booleans have no XDATA group code")
if isinstance(value, str):
if len(value) > MAX_STRING:
- raise ValueError(f"xdata: {where} is {len(value)} characters; the limit is {MAX_STRING}")
+ raise ValueError(
+ f"xdata: {where} is {len(value)} characters; the limit is {MAX_STRING}"
+ )
tags.append((1000, value))
size += len(value.encode("utf-8")) + 2
elif isinstance(value, int):
@@ -1371,130 +1389,131 @@ def split_by_app(codes, values) -> dict[str, list]:
Append to the query contract class in `backends/contracts/entity_query.py`:
```python
- @abstractmethod
- async def entity_get_xdata(self, handle: str, app_name: str | None = None) -> dict:
- """Extended entity data as ``{"handle", "xdata": {app: [values]}, "backend"}``.
- ``app_name=None`` returns every app; an absent app is ``{}``, not an error."""
- ...
+@abstractmethod
+async def entity_get_xdata(self, handle: str, app_name: str | None = None) -> dict:
+ """Extended entity data as ``{"handle", "xdata": {app: [values]}, "backend"}``.
+ ``app_name=None`` returns every app; an absent app is ``{}``, not an error."""
+ ...
- @abstractmethod
- async def entity_set_xdata(self, handle: str, app_name: str, values: list) -> dict:
- """Replace the app's XDATA (registering the APPID if needed). Values are typed
- by ``backends/xdata_specs.py``; an empty list removes the app's XDATA."""
- ...
+
+@abstractmethod
+async def entity_set_xdata(self, handle: str, app_name: str, values: list) -> dict:
+ """Replace the app's XDATA (registering the APPID if needed). Values are typed
+ by ``backends/xdata_specs.py``; an empty list removes the app's XDATA."""
+ ...
```
- [ ] **Step 5: Implement on ezdxf** (add near `block_get_attributes`)
```python
- async def entity_get_xdata(self, handle, app_name=None) -> dict:
- from backends.xdata_specs import decode_values, validate_app_name
+async def entity_get_xdata(self, handle, app_name=None) -> dict:
+ from backends.xdata_specs import decode_values, validate_app_name
- wanted = validate_app_name(app_name) if app_name else None
+ wanted = validate_app_name(app_name) if app_name else None
- def _sync():
- ent = self._get_entity(handle)
- out: dict[str, list] = {}
- if ent.xdata is not None:
- apps = [wanted] if wanted else list(ent.xdata.data.keys())
- for app in apps:
- if not ent.has_xdata(app):
- continue
- tags = ent.get_xdata(app)
- out[app] = decode_values((tag.code, tag.value) for tag in tags)
- return {"handle": handle, "xdata": out, "backend": "ezdxf"}
+ def _sync():
+ ent = self._get_entity(handle)
+ out: dict[str, list] = {}
+ if ent.xdata is not None:
+ apps = [wanted] if wanted else list(ent.xdata.data.keys())
+ for app in apps:
+ if not ent.has_xdata(app):
+ continue
+ tags = ent.get_xdata(app)
+ out[app] = decode_values((tag.code, tag.value) for tag in tags)
+ return {"handle": handle, "xdata": out, "backend": "ezdxf"}
- return await self._async(_sync)
+ return await self._async(_sync)
- async def entity_set_xdata(self, handle, app_name, values) -> dict:
- from backends.xdata_specs import encode_values, validate_app_name
- app = validate_app_name(app_name)
- tags = encode_values(values)
+async def entity_set_xdata(self, handle, app_name, values) -> dict:
+ from backends.xdata_specs import encode_values, validate_app_name
- def _sync():
- doc = self._require_doc()
- ent = self._get_entity(handle)
- if not tags:
- ent.discard_xdata(app)
- self._mark_dirty()
- return {
- "ok": True,
- "handle": handle,
- "app_name": app,
- "value_count": 0,
- "bytes": 0,
- "removed": True,
- "backend": "ezdxf",
- }
- if app not in doc.appids:
- doc.appids.add(app)
- ent.set_xdata(app, tags)
+ app = validate_app_name(app_name)
+ tags = encode_values(values)
+
+ def _sync():
+ doc = self._require_doc()
+ ent = self._get_entity(handle)
+ if not tags:
+ ent.discard_xdata(app)
self._mark_dirty()
return {
"ok": True,
"handle": handle,
"app_name": app,
- "value_count": len(tags),
- "bytes": sum(len(str(v)) for _, v in tags),
- "removed": False,
+ "value_count": 0,
+ "bytes": 0,
+ "removed": True,
"backend": "ezdxf",
}
+ if app not in doc.appids:
+ doc.appids.add(app)
+ ent.set_xdata(app, tags)
+ self._mark_dirty()
+ return {
+ "ok": True,
+ "handle": handle,
+ "app_name": app,
+ "value_count": len(tags),
+ "bytes": sum(len(str(v)) for _, v in tags),
+ "removed": False,
+ "backend": "ezdxf",
+ }
- return await self._async(_sync)
+ return await self._async(_sync)
```
- [ ] **Step 6: Implement on COM** (add near `block_get_attributes`)
```python
- async def entity_get_xdata(self, handle, app_name=None) -> dict:
- from backends.xdata_specs import split_by_app, validate_app_name
+async def entity_get_xdata(self, handle, app_name=None) -> dict:
+ from backends.xdata_specs import split_by_app, validate_app_name
- wanted = validate_app_name(app_name) if app_name else ""
+ wanted = validate_app_name(app_name) if app_name else ""
- def _sync():
- doc = _acad_doc()
- ent = doc.HandleToObject(str(handle).strip().upper())
- codes, values = ent.GetXData(wanted)
- grouped = split_by_app(list(codes or []), list(values or []))
- if wanted:
- grouped = {k: v for k, v in grouped.items() if k == wanted}
- return {"handle": handle, "xdata": grouped, "backend": "com"}
+ def _sync():
+ doc = _acad_doc()
+ ent = doc.HandleToObject(str(handle).strip().upper())
+ codes, values = ent.GetXData(wanted)
+ grouped = split_by_app(list(codes or []), list(values or []))
+ if wanted:
+ grouped = {k: v for k, v in grouped.items() if k == wanted}
+ return {"handle": handle, "xdata": grouped, "backend": "com"}
- return await self._run(_sync)
+ return await self._run(_sync)
- async def entity_set_xdata(self, handle, app_name, values) -> dict:
- from backends.xdata_specs import encode_values, validate_app_name
- app = validate_app_name(app_name)
- tags = encode_values(values)
+async def entity_set_xdata(self, handle, app_name, values) -> dict:
+ from backends.xdata_specs import encode_values, validate_app_name
- def _sync():
- doc = _acad_doc()
- ent = doc.HandleToObject(str(handle).strip().upper())
- try:
- doc.RegisteredApplications.Add(app)
- except Exception as exc: # already registered
- log.debug("RegisteredApplications.Add(%s): %s", app, exc)
- codes = [1001] + [code for code, _ in tags]
- payload = [app] + [
- _apoint(*value) if code == 1010 else value for code, value in tags
- ]
- ent.SetXData(
- _ai(codes),
- win32com.client.VARIANT(pythoncom.VT_ARRAY | pythoncom.VT_VARIANT, payload),
- )
- return {
- "ok": True,
- "handle": handle,
- "app_name": app,
- "value_count": len(tags),
- "bytes": sum(len(str(v)) for _, v in tags),
- "removed": not tags,
- "backend": "com",
- }
+ app = validate_app_name(app_name)
+ tags = encode_values(values)
- return await self._run(_sync)
+ def _sync():
+ doc = _acad_doc()
+ ent = doc.HandleToObject(str(handle).strip().upper())
+ try:
+ doc.RegisteredApplications.Add(app)
+ except Exception as exc: # already registered
+ log.debug("RegisteredApplications.Add(%s): %s", app, exc)
+ codes = [1001] + [code for code, _ in tags]
+ payload = [app] + [_apoint(*value) if code == 1010 else value for code, value in tags]
+ ent.SetXData(
+ _ai(codes),
+ win32com.client.VARIANT(pythoncom.VT_ARRAY | pythoncom.VT_VARIANT, payload),
+ )
+ return {
+ "ok": True,
+ "handle": handle,
+ "app_name": app,
+ "value_count": len(tags),
+ "bytes": sum(len(str(v)) for _, v in tags),
+ "removed": not tags,
+ "backend": "com",
+ }
+
+ return await self._run(_sync)
```
- [ ] **Step 7: Run the tests**
@@ -1624,8 +1643,17 @@ def test_decode_rejects_foreign_or_broken_values():
def test_builders_carry_the_documented_keys():
- line = line_payload("process_major", "100-P-1001", "100", "P", "CS1", "IH", 1001,
- {"handle": "2F", "port": "discharge"}, {"handle": "31", "port": "N1"})
+ line = line_payload(
+ "process_major",
+ "100-P-1001",
+ "100",
+ "P",
+ "CS1",
+ "IH",
+ 1001,
+ {"handle": "2F", "port": "discharge"},
+ {"handle": "31", "port": "N1"},
+ )
assert line["kind"] == "line" and line["class"] == "process_major"
assert line["from"] == {"handle": "2F", "port": "discharge"} and line["seq"] == 1001
assert marker_payload("3A") == {"v": 1, "kind": "marker", "line": "3A"}
@@ -1720,9 +1748,7 @@ def symbol_payload(spec: Any, params: dict | None = None) -> dict:
"symbol": spec.symbol,
"variant": spec.variant,
"params": dict(params or spec.params),
- "ports": {
- p.name: [p.x, p.y, p.direction_deg, p.kind, p.radius] for p in spec.ports
- },
+ "ports": {p.name: [p.x, p.y, p.direction_deg, p.kind, p.radius] for p in spec.ports},
}
@@ -1921,7 +1947,12 @@ def test_invalid_tags_name_the_offending_letter(tag, fragment):
@pytest.mark.parametrize("tag, area, letters, loop, suffix", FIXTURE["formats"])
def test_tag_formats(tag, area, letters, loop, suffix):
out = parse_tag(tag)
- assert (out["area"], out["letters"], out["loop"], out["suffix"]) == (area, letters, loop, suffix)
+ assert (out["area"], out["letters"], out["loop"], out["suffix"]) == (
+ area,
+ letters,
+ loop,
+ suffix,
+ )
@pytest.mark.parametrize("tag, kind, number, suffix", FIXTURE["equipment"])
@@ -1987,37 +2018,92 @@ import re
#: First letter → measured/initiating variable. ``None`` = user's choice.
FIRST_LETTERS: dict[str, str | None] = {
- "A": "Analysis", "B": "Burner", "C": None, "D": None, "E": "Voltage",
- "F": "Flow", "G": None, "H": "Hand", "I": "Current", "J": "Power",
- "K": "Time", "L": "Level", "M": None, "N": None, "O": None,
- "P": "Pressure", "Q": "Quantity", "R": "Radiation", "S": "Speed",
- "T": "Temperature", "U": "Multivariable", "V": "Vibration",
- "W": "Weight", "X": "Unclassified", "Y": "Event", "Z": "Position",
+ "A": "Analysis",
+ "B": "Burner",
+ "C": None,
+ "D": None,
+ "E": "Voltage",
+ "F": "Flow",
+ "G": None,
+ "H": "Hand",
+ "I": "Current",
+ "J": "Power",
+ "K": "Time",
+ "L": "Level",
+ "M": None,
+ "N": None,
+ "O": None,
+ "P": "Pressure",
+ "Q": "Quantity",
+ "R": "Radiation",
+ "S": "Speed",
+ "T": "Temperature",
+ "U": "Multivariable",
+ "V": "Vibration",
+ "W": "Weight",
+ "X": "Unclassified",
+ "Y": "Event",
+ "Z": "Position",
}
MODIFIERS: dict[str, str] = {
- "D": "Differential", "F": "Ratio", "K": "Rate of change", "Q": "Totalizing", "S": "Safety",
+ "D": "Differential",
+ "F": "Ratio",
+ "K": "Rate of change",
+ "Q": "Totalizing",
+ "S": "Safety",
}
#: Readout / passive function letters → (adjective form, noun form). ``None`` = user's choice.
READOUT: dict[str, tuple[str, str] | None] = {
- "A": ("Alarm", "Alarm"), "B": None, "E": ("Element", "Element"),
- "G": ("Gauge", "Gauge"), "I": ("Indicating", "Indicator"), "L": ("Light", "Light"),
- "N": None, "O": ("Orifice", "Orifice"), "P": ("Test point", "Test point"),
- "R": ("Recording", "Recorder"), "U": ("Multifunction", "Multifunction"),
- "W": ("Well", "Well"), "X": None,
+ "A": ("Alarm", "Alarm"),
+ "B": None,
+ "E": ("Element", "Element"),
+ "G": ("Gauge", "Gauge"),
+ "I": ("Indicating", "Indicator"),
+ "L": ("Light", "Light"),
+ "N": None,
+ "O": ("Orifice", "Orifice"),
+ "P": ("Test point", "Test point"),
+ "R": ("Recording", "Recorder"),
+ "U": ("Multifunction", "Multifunction"),
+ "W": ("Well", "Well"),
+ "X": None,
}
#: Output / active function letters → word. ``None`` = user's choice.
OUTPUT: dict[str, str | None] = {
- "C": "Controller", "K": "Control station", "S": "Switch", "T": "Transmitter",
- "U": "Multifunction", "V": "Valve", "X": None, "Y": "Relay", "Z": "Driver",
+ "C": "Controller",
+ "K": "Control station",
+ "S": "Switch",
+ "T": "Transmitter",
+ "U": "Multifunction",
+ "V": "Valve",
+ "X": None,
+ "Y": "Relay",
+ "Z": "Driver",
}
TRAILING: dict[str, str] = {
- "HH": "High-high", "LL": "Low-low", "H": "High", "L": "Low", "M": "Middle",
- "O": "Open", "C": "Closed",
+ "HH": "High-high",
+ "LL": "Low-low",
+ "H": "High",
+ "L": "Low",
+ "M": "Middle",
+ "O": "Open",
+ "C": "Closed",
}
DEFAULT_EQUIPMENT_PREFIXES: dict[str, str] = {
- "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",
+ "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",
}
_INSTRUMENT_RE = re.compile(
@@ -2080,10 +2166,14 @@ def _parse_letters(letters: str) -> dict:
after_alarm = bool(readout) and readout[-1] == "A" and not output
after_switch = bool(output) and output[-1] == "S"
if trailing in ("O", "C") and not after_switch:
- errors.append(f"{letters}: trailing {trailing} (open/closed) is only valid after a switch")
+ errors.append(
+ f"{letters}: trailing {trailing} (open/closed) is only valid after a switch"
+ )
return {"errors": errors}
if trailing in ("H", "L", "M", "HH", "LL") and not (after_alarm or after_switch):
- errors.append(f"{letters}: trailing {trailing} needs an alarm (A) or switch (S) before it")
+ errors.append(
+ f"{letters}: trailing {trailing} needs an alarm (A) or switch (S) before it"
+ )
return {"errors": errors}
return {
"first": first,
@@ -2128,17 +2218,33 @@ def _describe(parts: dict) -> tuple[str, bool]:
def _parse_instrument(raw: str) -> dict:
out = {
- "tag": raw, "valid": False, "kind": "instrument", "area": None, "letters": None,
- "first": None, "modifier": None, "readout": [], "output": [], "trailing": None,
- "loop": None, "suffix": None, "description": None, "user_defined": False, "errors": [],
+ "tag": raw,
+ "valid": False,
+ "kind": "instrument",
+ "area": None,
+ "letters": None,
+ "first": None,
+ "modifier": None,
+ "readout": [],
+ "output": [],
+ "trailing": None,
+ "loop": None,
+ "suffix": None,
+ "description": None,
+ "user_defined": False,
+ "errors": [],
}
tag = _normalise(raw)
match = _INSTRUMENT_RE.match(tag)
if not match:
out["errors"] = [f"{raw!r}: not in [area-]LETTERS[-]loop[suffix] format"]
return out
- out.update(area=match.group("area"), letters=match.group("letters"),
- loop=match.group("loop"), suffix=match.group("suffix"))
+ out.update(
+ area=match.group("area"),
+ letters=match.group("letters"),
+ loop=match.group("loop"),
+ suffix=match.group("suffix"),
+ )
parts = _parse_letters(match.group("letters"))
if parts["errors"]:
out["errors"] = parts["errors"]
@@ -2149,9 +2255,12 @@ def _parse_instrument(raw: str) -> dict:
first={"letter": parts["first"], "meaning": FIRST_LETTERS[parts["first"]]},
modifier=(
{"letter": parts["modifier"], "meaning": MODIFIERS[parts["modifier"]]}
- if parts["modifier"] else None
+ if parts["modifier"]
+ else None
),
- readout=[{"letter": l, "meaning": (READOUT[l] or (None, None))[1]} for l in parts["readout"]],
+ readout=[
+ {"letter": l, "meaning": (READOUT[l] or (None, None))[1]} for l in parts["readout"]
+ ],
output=[{"letter": l, "meaning": OUTPUT[l]} for l in parts["output"]],
trailing=parts["trailing"],
description=description,
@@ -2162,16 +2271,27 @@ def _parse_instrument(raw: str) -> dict:
def _parse_equipment(raw: str, prefixes: dict[str, str]) -> dict:
out = {
- "tag": raw, "valid": False, "kind": "equipment", "area": None, "prefix": None,
- "equipment_kind": None, "number": None, "suffix": "", "errors": [],
+ "tag": raw,
+ "valid": False,
+ "kind": "equipment",
+ "area": None,
+ "prefix": None,
+ "equipment_kind": None,
+ "number": None,
+ "suffix": "",
+ "errors": [],
}
match = _EQUIPMENT_RE.match(_normalise(raw))
if not match:
out["errors"] = [f"{raw!r}: not in [area-]PREFIX-number[suffix] format"]
return out
prefix = match.group("prefix")
- out.update(area=match.group("area"), prefix=prefix, number=match.group("number"),
- suffix=match.group("suffix") or "")
+ out.update(
+ area=match.group("area"),
+ prefix=prefix,
+ number=match.group("number"),
+ suffix=match.group("suffix") or "",
+ )
kind = prefixes.get(prefix)
if kind is None:
out["errors"] = [f"{raw!r}: prefix {prefix} is not in the equipment prefix map"]
@@ -2320,10 +2440,14 @@ def test_ports_sit_on_the_boundary_or_at_the_centre(spec):
assert port.radius > 0 and (port.x, port.y) == (0.0, 0.0)
continue
on_edge = (
- abs(port.x - xmin) < EPS or abs(port.x - xmax) < EPS
- or abs(port.y - ymin) < EPS or abs(port.y - ymax) < EPS
+ abs(port.x - xmin) < EPS
+ or abs(port.x - xmax) < EPS
+ or abs(port.y - ymin) < EPS
+ or abs(port.y - ymax) < EPS
+ )
+ assert on_edge, (
+ f"{spec.name}.{port.name} at ({port.x}, {port.y}) is inside bbox {spec.bbox}"
)
- assert on_edge, f"{spec.name}.{port.name} at ({port.x}, {port.y}) is inside bbox {spec.bbox}"
assert port.direction_deg in (0.0, 90.0, 180.0, 270.0)
assert port.kind in ("process", "signal")
@@ -2346,8 +2470,11 @@ def test_catalogue_names_are_unique_and_versioned():
def test_instrument_generator_covers_four_types_by_five_locations():
- specs = {(t, loc): resolve("instrument", type=t, location=loc)
- for t in INSTRUMENT_TYPES for loc in INSTRUMENT_LOCATIONS}
+ specs = {
+ (t, loc): resolve("instrument", type=t, location=loc)
+ for t in INSTRUMENT_TYPES
+ for loc in INSTRUMENT_LOCATIONS
+ }
assert len(specs) == 20
field = specs[("discrete", "field")]
assert field.name == "PID_INST_DISCRETE_FIELD"
@@ -2402,7 +2529,9 @@ def test_list_symbols_reports_variants_and_ports():
}
assert rows["offpage"]["variants"] == {"direction": ["in", "out"]}
assert rows["arrow_flow"]["ports"] == []
- assert list_symbols(family="marker") and all(r["family"] == "marker" for r in list_symbols(family="marker"))
+ assert list_symbols(family="marker") and all(
+ r["family"] == "marker" for r in list_symbols(family="marker")
+ )
```
- [ ] **Step 2: Run them to verify they fail**
@@ -2430,27 +2559,53 @@ def circle(cx: float, cy: float, r: float) -> dict:
def arc(cx: float, cy: float, r: float, start_deg: float, end_deg: float) -> dict:
- return {"type": "arc", "cx": float(cx), "cy": float(cy), "r": float(r),
- "start_deg": float(start_deg), "end_deg": float(end_deg)}
+ return {
+ "type": "arc",
+ "cx": float(cx),
+ "cy": float(cy),
+ "r": float(r),
+ "start_deg": float(start_deg),
+ "end_deg": float(end_deg),
+ }
def polyline(points, closed: bool = True, bulges=None) -> dict:
- spec = {"type": "polyline", "points": [[float(x), float(y)] for x, y in points], "closed": closed}
+ spec = {
+ "type": "polyline",
+ "points": [[float(x), float(y)] for x, y in points],
+ "closed": closed,
+ }
if bulges is not None:
spec["bulges"] = [float(b) for b in bulges]
return spec
-def text(t: str, x: float, y: float, height: float, align: str = "middle_center", rotation_deg: float = 0.0) -> dict:
- return {"type": "text", "text": t, "x": float(x), "y": float(y), "height": float(height),
- "align": align, "rotation_deg": float(rotation_deg)}
+def text(
+ t: str,
+ x: float,
+ y: float,
+ height: float,
+ align: str = "middle_center",
+ rotation_deg: float = 0.0,
+) -> dict:
+ return {
+ "type": "text",
+ "text": t,
+ "x": float(x),
+ "y": float(y),
+ "height": float(height),
+ "align": align,
+ "rotation_deg": float(rotation_deg),
+ }
def solid(points) -> dict:
return {"type": "solid", "points": [[float(x), float(y)] for x, y in points]}
-def dashed_line(x1: float, y1: float, x2: float, y2: float, on: float = 1.0, off: float = 1.0) -> list[dict]:
+def dashed_line(
+ x1: float, y1: float, x2: float, y2: float, on: float = 1.0, off: float = 1.0
+) -> list[dict]:
"""A dashed line as short LINE primitives, so the block needs no linetype."""
length = math.hypot(x2 - x1, y2 - y1)
ux, uy = (x2 - x1) / length, (y2 - y1) / length
@@ -2463,18 +2618,37 @@ def dashed_line(x1: float, y1: float, x2: float, y2: float, on: float = 1.0, off
return out
-def regular_polygon(cx: float, cy: float, r: float, n: int, start_deg: float = 0.0) -> list[tuple[float, float]]:
+def regular_polygon(
+ cx: float, cy: float, r: float, n: int, start_deg: float = 0.0
+) -> list[tuple[float, float]]:
return [
- (cx + r * math.cos(math.radians(start_deg + 360.0 * i / n)),
- cy + r * math.sin(math.radians(start_deg + 360.0 * i / n)))
+ (
+ cx + r * math.cos(math.radians(start_deg + 360.0 * i / n)),
+ cy + r * math.sin(math.radians(start_deg + 360.0 * i / n)),
+ )
for i in range(n)
]
-def attdef(tag: str, x: float, y: float, height: float, align: str = "center",
- invisible: bool = False, default: str = "") -> dict:
- return {"tag": tag, "prompt": tag, "default": default, "x": float(x), "y": float(y),
- "height": float(height), "align": align, "invisible": invisible}
+def attdef(
+ tag: str,
+ x: float,
+ y: float,
+ height: float,
+ align: str = "center",
+ invisible: bool = False,
+ default: str = "",
+) -> dict:
+ return {
+ "tag": tag,
+ "prompt": tag,
+ "default": default,
+ "x": float(x),
+ "y": float(y),
+ "height": float(height),
+ "align": align,
+ "invisible": invisible,
+ }
def _arc_extent(cx, cy, r, start_deg, end_deg):
@@ -2568,8 +2742,14 @@ class Port:
radius: float = 0.0 # radial ports only
def to_dict(self) -> dict:
- return {"name": self.name, "x": self.x, "y": self.y, "direction_deg": self.direction_deg,
- "kind": self.kind, "radius": self.radius}
+ return {
+ "name": self.name,
+ "x": self.x,
+ "y": self.y,
+ "direction_deg": self.direction_deg,
+ "kind": self.kind,
+ "radius": self.radius,
+ }
@dataclass(frozen=True)
@@ -2588,18 +2768,31 @@ class SymbolSpec:
params: dict = field(default_factory=dict)
def to_dict(self) -> dict:
- return {"name": self.name, "family": self.family, "symbol": self.symbol, "variant": self.variant,
- "ports": [p.to_dict() for p in self.ports], "bbox": list(self.bbox),
- "layer_class": self.layer_class, "source": self.source,
- "parametric": self.parametric, "params": dict(self.params)}
+ return {
+ "name": self.name,
+ "family": self.family,
+ "symbol": self.symbol,
+ "variant": self.variant,
+ "ports": [p.to_dict() for p in self.ports],
+ "bbox": list(self.bbox),
+ "layer_class": self.layer_class,
+ "source": self.source,
+ "parametric": self.parametric,
+ "params": dict(self.params),
+ }
Builder = Callable[..., SymbolSpec]
_REGISTRY: dict[str, tuple[str, Builder, dict[str, list[str]], dict | None]] = {}
-def register(symbol: str, family: str, builder: Builder, variants: dict[str, list[str]] | None = None,
- params_schema: dict | None = None) -> None:
+def register(
+ symbol: str,
+ family: str,
+ builder: Builder,
+ variants: dict[str, list[str]] | None = None,
+ params_schema: dict | None = None,
+) -> None:
if family not in FAMILY_LAYER:
raise ValueError(f"unknown family {family!r}")
_REGISTRY[symbol] = (family, builder, dict(variants or {}), params_schema)
@@ -2608,7 +2801,9 @@ def register(symbol: str, family: str, builder: Builder, variants: dict[str, lis
def resolve(symbol: str, **options) -> SymbolSpec:
"""Build the SymbolSpec for ``symbol`` with the given variant options."""
if symbol not in _REGISTRY:
- raise ValueError(f"unknown symbol {symbol!r}; valid symbols: {', '.join(sorted(_REGISTRY))}")
+ raise ValueError(
+ f"unknown symbol {symbol!r}; valid symbols: {', '.join(sorted(_REGISTRY))}"
+ )
family, builder, variants, schema = _REGISTRY[symbol]
for key, value in options.items():
if value is None:
@@ -2620,7 +2815,9 @@ def resolve(symbol: str, **options) -> SymbolSpec:
if key not in variants:
raise ValueError(f"{symbol} takes no option {key!r}")
if value not in variants[key]:
- raise ValueError(f"{symbol}: {key} must be one of {', '.join(variants[key])}, got {value!r}")
+ raise ValueError(
+ f"{symbol}: {key} must be one of {', '.join(variants[key])}, got {value!r}"
+ )
return builder(**{k: v for k, v in options.items() if v is not None})
@@ -2630,9 +2827,18 @@ def list_symbols(family: str | None = None) -> list[dict]:
if family and fam != family:
continue
base = builder()
- rows.append({"symbol": symbol, "family": fam, "variants": variants,
- "ports": [p.to_dict() for p in base.ports], "parametric": base.parametric,
- "params_schema": schema or {}, "source": base.source, "block_name": base.name})
+ rows.append(
+ {
+ "symbol": symbol,
+ "family": fam,
+ "variants": variants,
+ "ports": [p.to_dict() for p in base.ports],
+ "parametric": base.parametric,
+ "params_schema": schema or {},
+ "source": base.source,
+ "block_name": base.name,
+ }
+ )
return rows
@@ -2662,12 +2868,23 @@ def transform_port(port: Port, x: float, y: float, rotation_deg: float, scale: f
wx = x + lx * math.cos(a) - ly * math.sin(a)
wy = y + lx * math.sin(a) + ly * math.cos(a)
direction = None if port.direction_deg is None else (port.direction_deg + rotation_deg) % 360.0
- return {"name": port.name, "x": wx, "y": wy, "direction_deg": direction, "kind": port.kind,
- "radius": port.radius * scale, "inferred": False}
+ return {
+ "name": port.name,
+ "x": wx,
+ "y": wy,
+ "direction_deg": direction,
+ "kind": port.kind,
+ "radius": port.radius * scale,
+ "inferred": False,
+ }
def params_hash(params: dict) -> str:
- return hashlib.sha256(json.dumps(params, sort_keys=True, separators=(",", ":")).encode()).hexdigest()[:8].upper()
+ return (
+ hashlib.sha256(json.dumps(params, sort_keys=True, separators=(",", ":")).encode())
+ .hexdigest()[:8]
+ .upper()
+ )
def tag_attdefs(ymax: float, with_desc: bool = True) -> tuple[dict, ...]:
@@ -2677,13 +2894,33 @@ def tag_attdefs(ymax: float, with_desc: bool = True) -> tuple[dict, ...]:
return tuple(out)
-def make_spec(name: str, family: str, symbol: str, variant: str | None, primitives, attdefs, ports,
- source: str, bbox=None, parametric: bool = False, params: dict | None = None) -> SymbolSpec:
+def make_spec(
+ name: str,
+ family: str,
+ symbol: str,
+ variant: str | None,
+ primitives,
+ attdefs,
+ ports,
+ source: str,
+ bbox=None,
+ parametric: bool = False,
+ params: dict | None = None,
+) -> SymbolSpec:
prims = tuple(primitives)
return SymbolSpec(
- name=name, family=family, symbol=symbol, variant=variant, primitives=prims,
- attdefs=tuple(attdefs), ports=tuple(ports), bbox=tuple(bbox) if bbox else bbox_of(prims),
- layer_class=FAMILY_LAYER[family], source=source, parametric=parametric, params=dict(params or {}),
+ name=name,
+ family=family,
+ symbol=symbol,
+ variant=variant,
+ primitives=prims,
+ attdefs=tuple(attdefs),
+ ports=tuple(ports),
+ bbox=tuple(bbox) if bbox else bbox_of(prims),
+ layer_class=FAMILY_LAYER[family],
+ source=source,
+ parametric=parametric,
+ params=dict(params or {}),
)
@@ -2700,20 +2937,39 @@ def build_instrument(type: str = "discrete", location: str = "field") -> SymbolS
prims.append(polyline(regular_polygon(0, 0, r, 6, 0.0)))
elif type == "plc":
prims += [square, polyline([(0, r), (r, 0), (0, -r), (-r, 0)])]
- ys = {"field": [], "primary": [0.0], "auxiliary": [0.8, -0.8],
- "primary_rear": [0.0], "auxiliary_rear": [0.8, -0.8]}[location]
+ ys = {
+ "field": [],
+ "primary": [0.0],
+ "auxiliary": [0.8, -0.8],
+ "primary_rear": [0.0],
+ "auxiliary_rear": [0.8, -0.8],
+ }[location]
for y in ys:
prims += dashed_line(-r, y, r, y) if location.endswith("_rear") else [line(-r, y, r, y)]
- attdefs = (attdef("FUNC", 0.0, 2.0, DESC_HEIGHT, align="middle_center"),
- attdef("LOOP", 0.0, -2.0, DESC_HEIGHT, align="middle_center"))
+ attdefs = (
+ attdef("FUNC", 0.0, 2.0, DESC_HEIGHT, align="middle_center"),
+ attdef("LOOP", 0.0, -2.0, DESC_HEIGHT, align="middle_center"),
+ )
ports = (Port("signal", 0.0, 0.0, None, "signal", r),)
- return make_spec(f"PID_INST_{type.upper()}_{location.upper()}", "instrument", "instrument",
- f"{type}_{location}", prims, attdefs, ports,
- "ISA-5.1-2009 Table 5.4.1", bbox=(-r, -r, r, r))
+ return make_spec(
+ f"PID_INST_{type.upper()}_{location.upper()}",
+ "instrument",
+ "instrument",
+ f"{type}_{location}",
+ prims,
+ attdefs,
+ ports,
+ "ISA-5.1-2009 Table 5.4.1",
+ bbox=(-r, -r, r, r),
+ )
-register("instrument", "instrument", build_instrument,
- variants={"type": list(INSTRUMENT_TYPES), "location": list(INSTRUMENT_LOCATIONS)})
+register(
+ "instrument",
+ "instrument",
+ build_instrument,
+ variants={"type": list(INSTRUMENT_TYPES), "location": list(INSTRUMENT_LOCATIONS)},
+)
# ── markers (decoration on a line; never graph nodes) ────────────────────────
@@ -2727,12 +2983,20 @@ def build_marker(which: str) -> SymbolSpec:
"mark_hydraulic": [line(-1, 1.5, -1, -1.5), line(-1, -1.5, 1.5, -1.5)],
"mark_data": [circle(0, 0, 0.8)],
}
- return make_spec(f"PID_MARKER_{which.upper()}", "marker", which, None, shapes[which], (), (),
- "ISA-5.1-2009 Table 5.3.1")
+ return make_spec(
+ f"PID_MARKER_{which.upper()}",
+ "marker",
+ which,
+ None,
+ shapes[which],
+ (),
+ (),
+ "ISA-5.1-2009 Table 5.3.1",
+ )
for _which in ("arrow_flow", "mark_pneumatic", "mark_capillary", "mark_hydraulic", "mark_data"):
- register(_which, "marker", (lambda w: (lambda: build_marker(w)))(_which))
+ register(_which, "marker", (lambda w: lambda: build_marker(w))(_which))
# ── connectors ───────────────────────────────────────────────────────────────
@@ -2746,25 +3010,55 @@ def build_connector(which: str = "offpage", direction: str = "out") -> SymbolSpe
else:
pts = [(8, -3), (-4, -3), (-8, 0), (-4, 3), (8, 3)]
port = Port("process", 8.0, 0.0, 0.0, "process")
- attdefs = (attdef("TAG", -2.0 if direction == "out" else 2.0, 0.0, DESC_HEIGHT, align="middle_center"),
- attdef("LINK", 0.0, -5.0, DESC_HEIGHT, invisible=True))
- return make_spec(f"PID_CONNECTOR_OFFPAGE_{direction.upper()}", "connector", "offpage", direction,
- [polyline(pts)], attdefs, (port,), "PIP PIC001 off-page connector", bbox=(-8, -3, 8, 3))
+ attdefs = (
+ attdef(
+ "TAG", -2.0 if direction == "out" else 2.0, 0.0, DESC_HEIGHT, align="middle_center"
+ ),
+ attdef("LINK", 0.0, -5.0, DESC_HEIGHT, invisible=True),
+ )
+ return make_spec(
+ f"PID_CONNECTOR_OFFPAGE_{direction.upper()}",
+ "connector",
+ "offpage",
+ direction,
+ [polyline(pts)],
+ attdefs,
+ (port,),
+ "PIP PIC001 off-page connector",
+ bbox=(-8, -3, 8, 3),
+ )
if which == "tie_in":
- return make_spec("PID_CONNECTOR_TIE_IN", "connector", "tie_in", None, [circle(0, 0, 3)],
- (attdef("TAG", 0.0, 0.0, DESC_HEIGHT, align="middle_center"),),
- (Port("in", -3.0, 0.0, 180.0), Port("out", 3.0, 0.0, 0.0)),
- "PIP PIC001 tie-in point")
+ return make_spec(
+ "PID_CONNECTOR_TIE_IN",
+ "connector",
+ "tie_in",
+ None,
+ [circle(0, 0, 3)],
+ (attdef("TAG", 0.0, 0.0, DESC_HEIGHT, align="middle_center"),),
+ (Port("in", -3.0, 0.0, 180.0), Port("out", 3.0, 0.0, 0.0)),
+ "PIP PIC001 tie-in point",
+ )
if which == "spec_break":
- return make_spec("PID_CONNECTOR_SPEC_BREAK", "connector", "spec_break", None, [line(0, -3, 0, 3)],
- (attdef("TAG", 0.0, 4.5, DESC_HEIGHT),),
- (Port("in", -0.3, 0.0, 180.0), Port("out", 0.3, 0.0, 0.0)),
- "PIP PIC001 specification break", bbox=(-0.3, -3, 0.3, 4.5))
+ return make_spec(
+ "PID_CONNECTOR_SPEC_BREAK",
+ "connector",
+ "spec_break",
+ None,
+ [line(0, -3, 0, 3)],
+ (attdef("TAG", 0.0, 4.5, DESC_HEIGHT),),
+ (Port("in", -0.3, 0.0, 180.0), Port("out", 0.3, 0.0, 0.0)),
+ "PIP PIC001 specification break",
+ bbox=(-0.3, -3, 0.3, 4.5),
+ )
raise ValueError(f"unknown connector {which!r}")
-register("offpage", "connector", lambda direction="out": build_connector("offpage", direction),
- variants={"direction": ["in", "out"]})
+register(
+ "offpage",
+ "connector",
+ lambda direction="out": build_connector("offpage", direction),
+ variants={"direction": ["in", "out"]},
+)
register("tie_in", "connector", lambda: build_connector("tie_in"))
register("spec_break", "connector", lambda: build_connector("spec_break"))
@@ -2817,7 +3111,9 @@ def test_valve_bodies_and_actuators_enumerate():
gate = build_valve("gate")
assert gate.name == "PID_VALVE_GATE" and gate.bbox == (-4.0, -2.0, 4.0, 2.0)
assert {p.name: (p.x, p.y, p.direction_deg) for p in gate.ports} == {
- "in": (-4.0, 0.0, 180.0), "out": (4.0, 0.0, 0.0)}
+ "in": (-4.0, 0.0, 180.0),
+ "out": (4.0, 0.0, 0.0),
+ }
assert [a["tag"] for a in gate.attdefs] == ["TAG", "DESC"]
@@ -2829,7 +3125,9 @@ def test_actuators_add_a_signal_port_and_a_fail_attribute():
assert cv.bbox[3] == 9.0
assert "FAIL" in [a["tag"] for a in cv.attdefs]
hand = build_valve("gate", "hand")
- assert all(p.name != "signal" for p in hand.ports) and "FAIL" not in [a["tag"] for a in hand.attdefs]
+ assert all(p.name != "signal" for p in hand.ports) and "FAIL" not in [
+ a["tag"] for a in hand.attdefs
+ ]
def test_check_and_relief_refuse_actuators():
@@ -3086,74 +3384,173 @@ from .symbols import Port, SymbolSpec, make_spec, register, tag_attdefs
ROTATING = ("centrifugal_pump", "pd_pump", "compressor", "fan", "agitator", "ejector")
HEAT = ("shell_tube_hx", "plate_hx", "air_cooler", "fired_heater")
-MISC = ("filter", "strainer_y", "reducer", "flange_pair", "spectacle_blind", "orifice_plate", "drain_vent")
+MISC = (
+ "filter",
+ "strainer_y",
+ "reducer",
+ "flange_pair",
+ "spectacle_blind",
+ "orifice_plate",
+ "drain_vent",
+)
_SRC = "ISO 10628-2:2012"
_IN, _OUT = Port("in", -4.0, 0.0, 180.0), Port("out", 4.0, 0.0, 0.0)
def _rotating(symbol: str):
if symbol == "centrifugal_pump":
- return ([circle(0, 0, 4), line(0, 4, 0, 6), polyline([(-2, -1), (-2, 1), (1, 0)])],
- (Port("suction", -4.0, 0.0, 180.0), Port("discharge", 0.0, 6.0, 90.0)), (-4, -4, 4, 6))
+ return (
+ [circle(0, 0, 4), line(0, 4, 0, 6), polyline([(-2, -1), (-2, 1), (1, 0)])],
+ (Port("suction", -4.0, 0.0, 180.0), Port("discharge", 0.0, 6.0, 90.0)),
+ (-4, -4, 4, 6),
+ )
if symbol == "pd_pump":
- return ([circle(0, 0, 4), polyline([(-2, -1.5), (2, -1.5), (0, 2)])], (_IN, _OUT), (-4, -4, 4, 4))
+ return (
+ [circle(0, 0, 4), polyline([(-2, -1.5), (2, -1.5), (0, 2)])],
+ (_IN, _OUT),
+ (-4, -4, 4, 4),
+ )
if symbol == "compressor":
return ([polyline([(-4, -3), (4, -1.5), (4, 1.5), (-4, 3)])], (_IN, _OUT), (-4, -3, 4, 3))
if symbol == "fan":
- return ([circle(0, 0, 4), line(-2.8, -2.8, 2.8, 2.8), line(-2.8, 2.8, 2.8, -2.8)], (_IN, _OUT), (-4, -4, 4, 4))
+ return (
+ [circle(0, 0, 4), line(-2.8, -2.8, 2.8, 2.8), line(-2.8, 2.8, 2.8, -2.8)],
+ (_IN, _OUT),
+ (-4, -4, 4, 4),
+ )
if symbol == "agitator":
- return ([line(0, 0, 0, 8), circle(0, 10, 2), text("M", 0, 10, 2.0), line(-3, 0, 3, 0)],
- (Port("shaft", 0.0, 0.0, 270.0),), (-3, 0, 3, 12))
+ return (
+ [line(0, 0, 0, 8), circle(0, 10, 2), text("M", 0, 10, 2.0), line(-3, 0, 3, 0)],
+ (Port("shaft", 0.0, 0.0, 270.0),),
+ (-3, 0, 3, 12),
+ )
if symbol == "ejector":
- return ([polyline([(-4, -1.5), (0, -1.5), (4, -0.75), (4, 0.75), (0, 1.5), (-4, 1.5)]), line(0, -1.5, 0, -4)],
- (Port("motive", -4.0, 0.0, 180.0), Port("suction", 0.0, -4.0, 270.0), _OUT), (-4, -4, 4, 1.5))
+ return (
+ [
+ polyline([(-4, -1.5), (0, -1.5), (4, -0.75), (4, 0.75), (0, 1.5), (-4, 1.5)]),
+ line(0, -1.5, 0, -4),
+ ],
+ (Port("motive", -4.0, 0.0, 180.0), Port("suction", 0.0, -4.0, 270.0), _OUT),
+ (-4, -4, 4, 1.5),
+ )
raise ValueError(symbol)
def _heat(symbol: str):
if symbol == "shell_tube_hx":
- return ([polyline([(-12, -4), (12, -4), (12, 4), (-12, 4)]), line(-9, -4, -9, 4), line(9, -4, 9, 4)],
- (Port("tube_in", -12.0, 0.0, 180.0), Port("tube_out", 12.0, 0.0, 0.0),
- Port("shell_in", -4.0, 4.0, 90.0), Port("shell_out", 4.0, -4.0, 270.0)), (-12, -4, 12, 4))
+ return (
+ [
+ polyline([(-12, -4), (12, -4), (12, 4), (-12, 4)]),
+ line(-9, -4, -9, 4),
+ line(9, -4, 9, 4),
+ ],
+ (
+ Port("tube_in", -12.0, 0.0, 180.0),
+ Port("tube_out", 12.0, 0.0, 0.0),
+ Port("shell_in", -4.0, 4.0, 90.0),
+ Port("shell_out", 4.0, -4.0, 270.0),
+ ),
+ (-12, -4, 12, 4),
+ )
if symbol == "plate_hx":
- return ([polyline([(-4, -6), (4, -6), (4, 6), (-4, 6)]), line(-4, -6, 4, 6), line(-4, 6, 4, -6)],
- (Port("hot_in", -4.0, 3.0, 180.0), Port("hot_out", 4.0, -3.0, 0.0),
- Port("cold_in", 4.0, 3.0, 0.0), Port("cold_out", -4.0, -3.0, 180.0)), (-4, -6, 4, 6))
+ return (
+ [
+ polyline([(-4, -6), (4, -6), (4, 6), (-4, 6)]),
+ line(-4, -6, 4, 6),
+ line(-4, 6, 4, -6),
+ ],
+ (
+ Port("hot_in", -4.0, 3.0, 180.0),
+ Port("hot_out", 4.0, -3.0, 0.0),
+ Port("cold_in", 4.0, 3.0, 0.0),
+ Port("cold_out", -4.0, -3.0, 180.0),
+ ),
+ (-4, -6, 4, 6),
+ )
if symbol == "air_cooler":
- return ([polyline([(-10, -3), (10, -3), (10, 3), (-10, 3)]), circle(0, -6, 2.5),
- line(-1.8, -7.8, 1.8, -4.2), line(-1.8, -4.2, 1.8, -7.8)],
- (Port("in", -10.0, 0.0, 180.0), Port("out", 10.0, 0.0, 0.0)), (-10, -8.5, 10, 3))
+ return (
+ [
+ polyline([(-10, -3), (10, -3), (10, 3), (-10, 3)]),
+ circle(0, -6, 2.5),
+ line(-1.8, -7.8, 1.8, -4.2),
+ line(-1.8, -4.2, 1.8, -7.8),
+ ],
+ (Port("in", -10.0, 0.0, 180.0), Port("out", 10.0, 0.0, 0.0)),
+ (-10, -8.5, 10, 3),
+ )
if symbol == "fired_heater":
- return ([polyline([(-6, -8), (6, -8), (6, 4), (2, 4), (2, 10), (-2, 10), (-2, 4), (-6, 4)]),
- polyline([(-4, -6), (4, -4), (-4, -2), (4, 0), (-4, 2)], closed=False)],
- (Port("in", -6.0, -4.0, 180.0), Port("out", 6.0, 2.0, 0.0), Port("flue", 0.0, 10.0, 90.0)),
- (-6, -8, 6, 10))
+ return (
+ [
+ polyline([(-6, -8), (6, -8), (6, 4), (2, 4), (2, 10), (-2, 10), (-2, 4), (-6, 4)]),
+ polyline([(-4, -6), (4, -4), (-4, -2), (4, 0), (-4, 2)], closed=False),
+ ],
+ (
+ Port("in", -6.0, -4.0, 180.0),
+ Port("out", 6.0, 2.0, 0.0),
+ Port("flue", 0.0, 10.0, 90.0),
+ ),
+ (-6, -8, 6, 10),
+ )
raise ValueError(symbol)
def _misc(symbol: str, shape: str = "concentric"):
if symbol == "filter":
- return ([polyline([(-4, -4), (4, -4), (4, 4), (-4, 4)]), line(-4, 4, 4, -4)], (_IN, _OUT), (-4, -4, 4, 4))
+ return (
+ [polyline([(-4, -4), (4, -4), (4, 4), (-4, 4)]), line(-4, 4, 4, -4)],
+ (_IN, _OUT),
+ (-4, -4, 4, 4),
+ )
if symbol == "strainer_y":
- return ([line(-4, 0, 4, 0), line(0, 0, 3, -4), line(2, -4.7, 4, -3.3)], (_IN, _OUT), (-4, -5, 4, 0))
+ return (
+ [line(-4, 0, 4, 0), line(0, 0, 3, -4), line(2, -4.7, 4, -3.3)],
+ (_IN, _OUT),
+ (-4, -5, 4, 0),
+ )
if symbol == "reducer":
if shape == "eccentric":
- return ([polyline([(-3, -2), (3, -2), (3, 0), (-3, 2)])],
- (Port("in", -3.0, 0.0, 180.0), Port("out", 3.0, -1.0, 0.0)), (-3, -2, 3, 2))
- return ([polyline([(-3, -2), (3, -1), (3, 1), (-3, 2)])],
- (Port("in", -3.0, 0.0, 180.0), Port("out", 3.0, 0.0, 0.0)), (-3, -2, 3, 2))
+ return (
+ [polyline([(-3, -2), (3, -2), (3, 0), (-3, 2)])],
+ (Port("in", -3.0, 0.0, 180.0), Port("out", 3.0, -1.0, 0.0)),
+ (-3, -2, 3, 2),
+ )
+ return (
+ [polyline([(-3, -2), (3, -1), (3, 1), (-3, 2)])],
+ (Port("in", -3.0, 0.0, 180.0), Port("out", 3.0, 0.0, 0.0)),
+ (-3, -2, 3, 2),
+ )
if symbol == "flange_pair":
- return ([line(-0.6, -2.5, -0.6, 2.5), line(0.6, -2.5, 0.6, 2.5)],
- (Port("in", -0.6, 0.0, 180.0), Port("out", 0.6, 0.0, 0.0)), (-0.6, -2.5, 0.6, 2.5))
+ return (
+ [line(-0.6, -2.5, -0.6, 2.5), line(0.6, -2.5, 0.6, 2.5)],
+ (Port("in", -0.6, 0.0, 180.0), Port("out", 0.6, 0.0, 0.0)),
+ (-0.6, -2.5, 0.6, 2.5),
+ )
if symbol == "spectacle_blind":
- return ([line(0, -3.5, 0, 3.5), circle(0, 2, 1.5), solid([(0, -0.5), (1.5, -2), (0, -3.5), (-1.5, -2)])],
- (Port("in", -1.5, 0.0, 180.0), Port("out", 1.5, 0.0, 0.0)), (-1.5, -3.5, 1.5, 3.5))
+ return (
+ [
+ line(0, -3.5, 0, 3.5),
+ circle(0, 2, 1.5),
+ solid([(0, -0.5), (1.5, -2), (0, -3.5), (-1.5, -2)]),
+ ],
+ (Port("in", -1.5, 0.0, 180.0), Port("out", 1.5, 0.0, 0.0)),
+ (-1.5, -3.5, 1.5, 3.5),
+ )
if symbol == "orifice_plate":
- return ([line(-0.5, -3, -0.5, 3), line(0.5, -3, 0.5, 3)],
- (Port("in", -0.5, 0.0, 180.0), Port("out", 0.5, 0.0, 0.0), Port("signal", 0.0, 3.0, 90.0, "signal")),
- (-0.5, -3, 0.5, 3))
+ return (
+ [line(-0.5, -3, -0.5, 3), line(0.5, -3, 0.5, 3)],
+ (
+ Port("in", -0.5, 0.0, 180.0),
+ Port("out", 0.5, 0.0, 0.0),
+ Port("signal", 0.0, 3.0, 90.0, "signal"),
+ ),
+ (-0.5, -3, 0.5, 3),
+ )
if symbol == "drain_vent":
- return ([line(0, 0, 0, -4), line(-1.5, -4, 1.5, -4)], (Port("in", 0.0, 0.0, 90.0),), (-1.5, -4, 1.5, 0))
+ return (
+ [line(0, 0, 0, -4), line(-1.5, -4, 1.5, -4)],
+ (Port("in", 0.0, 0.0, 90.0),),
+ (-1.5, -4, 1.5, 0),
+ )
raise ValueError(symbol)
@@ -3168,16 +3565,22 @@ def build_equipment(symbol: str, shape: str = "concentric") -> SymbolSpec:
raise ValueError(f"unknown equipment {symbol!r}")
variant = shape if symbol == "reducer" else None
name = f"PID_{family.upper()}_{symbol.upper()}" + (f"_{variant.upper()}" if variant else "")
- return make_spec(name, family, symbol, variant, prims, tag_attdefs(box[3]), ports, _SRC, bbox=box)
+ return make_spec(
+ name, family, symbol, variant, prims, tag_attdefs(box[3]), ports, _SRC, bbox=box
+ )
for _name in ROTATING + HEAT + MISC:
if _name == "reducer":
- register(_name, "misc", lambda shape="concentric": build_equipment("reducer", shape),
- variants={"shape": ["concentric", "eccentric"]})
+ register(
+ _name,
+ "misc",
+ lambda shape="concentric": build_equipment("reducer", shape),
+ variants={"shape": ["concentric", "eccentric"]},
+ )
else:
_family = "rotating" if _name in ROTATING else ("heat" if _name in HEAT else "misc")
- register(_name, _family, (lambda s: (lambda: build_equipment(s)))(_name))
+ register(_name, _family, (lambda s: lambda: build_equipment(s))(_name))
```
- [ ] **Step 4: Run the harness** — `uv run --frozen pytest tests/test_pid_symbols.py -v` → all PASS (the `agitator` bbox `(-3, 0, 3, 12)` puts its shaft port on the bottom edge; `strainer_y`'s ports sit on its top edge `y = 0`).
@@ -3213,7 +3616,12 @@ def test_vessels_are_parametric_and_hash_named():
v = build_vessel("vertical_vessel")
assert v.parametric is True and v.name.startswith("PID_VESSEL_VERTICAL_VESSEL_")
assert len(v.name.rsplit("_", 1)[1]) == 8
- assert {p.name: p.direction_deg for p in v.ports} == {"N1": 90.0, "N2": 270.0, "N3": 180.0, "N4": 0.0}
+ assert {p.name: p.direction_deg for p in v.ports} == {
+ "N1": 90.0,
+ "N2": 270.0,
+ "N3": 180.0,
+ "N4": 0.0,
+ }
same = build_vessel("vertical_vessel", {"width": 20, "height": 40})
assert same.name == v.name, "identical parameters share one definition"
other = build_vessel("vertical_vessel", {"width": 30})
@@ -3221,8 +3629,10 @@ def test_vessels_are_parametric_and_hash_named():
def test_vessel_nozzles_are_where_the_parameters_say():
- v = build_vessel("vertical_vessel", {"width": 20, "height": 40,
- "nozzles": [{"name": "F", "side": "right", "fraction": 0.25}]})
+ v = build_vessel(
+ "vertical_vessel",
+ {"width": 20, "height": 40, "nozzles": [{"name": "F", "side": "right", "fraction": 0.25}]},
+ )
port = v.ports[0]
assert (port.name, port.x, port.y, port.direction_deg) == ("F", 13.0, -10.0, 0.0)
assert v.bbox == (-10.0, -20.0, 13.0, 20.0)
@@ -3239,16 +3649,27 @@ def test_vessel_variants_draw_their_features():
assert {p.direction_deg for p in hopper.ports} == {90.0, 270.0}
-@pytest.mark.parametrize("params, key", [
- ({"width": 0}, "width"),
- ({"height": -1}, "height"),
- ({"nozzles": [{"name": "A", "side": "up", "fraction": 0.5}]}, "side"),
- ({"nozzles": [{"name": "A", "side": "top", "fraction": 1.5}]}, "fraction"),
- ({"nozzles": [{"name": "A", "side": "top", "fraction": 0.5}, {"name": "A", "side": "top", "fraction": 0.2}]}, "name"),
- ({"trays": -2}, "trays"),
- ({"roof": "dome"}, "roof"),
- ({"bogus": 1}, "bogus"),
-])
+@pytest.mark.parametrize(
+ "params, key",
+ [
+ ({"width": 0}, "width"),
+ ({"height": -1}, "height"),
+ ({"nozzles": [{"name": "A", "side": "up", "fraction": 0.5}]}, "side"),
+ ({"nozzles": [{"name": "A", "side": "top", "fraction": 1.5}]}, "fraction"),
+ (
+ {
+ "nozzles": [
+ {"name": "A", "side": "top", "fraction": 0.5},
+ {"name": "A", "side": "top", "fraction": 0.2},
+ ]
+ },
+ "name",
+ ),
+ ({"trays": -2}, "trays"),
+ ({"roof": "dome"}, "roof"),
+ ({"bogus": 1}, "bogus"),
+ ],
+)
def test_vessel_params_are_validated(params, key):
symbol = "column" if "trays" in params else ("tank" if "roof" in params else "vertical_vessel")
with pytest.raises(ValueError, match=key):
@@ -3285,9 +3706,12 @@ _DEFAULTS = {
"hopper": {"width": 24.0, "height": 24.0},
}
PARAMS_SCHEMA = {
- "width": "float > 0 (mm)", "height": "float > 0 (mm)",
+ "width": "float > 0 (mm)",
+ "height": "float > 0 (mm)",
"nozzles": "[{name, side: top|bottom|left|right, fraction: 0..1}]",
- "roof": "tank only: flat | cone", "trays": "column only: int >= 0", "jacketed": "reactor only: bool",
+ "roof": "tank only: flat | cone",
+ "trays": "column only: int >= 0",
+ "jacketed": "reactor only: bool",
}
STUB = 3.0
@@ -3301,7 +3725,9 @@ def validate_vessel_params(symbol: str, params: dict | None) -> dict:
merged["nozzles"] = [dict(n) for n in DEFAULT_NOZZLES[:2]]
for key, value in (params or {}).items():
if key not in merged:
- raise ValueError(f"{symbol}: unknown parameter {key!r}; valid: {', '.join(sorted(merged))}")
+ raise ValueError(
+ f"{symbol}: unknown parameter {key!r}; valid: {', '.join(sorted(merged))}"
+ )
merged[key] = value
for key in ("width", "height"):
v = merged[key]
@@ -3310,7 +3736,11 @@ def validate_vessel_params(symbol: str, params: dict | None) -> dict:
merged[key] = float(v)
if "roof" in merged and merged["roof"] not in ("flat", "cone"):
raise ValueError(f"{symbol}: roof must be 'flat' or 'cone'")
- if "trays" in merged and (isinstance(merged["trays"], bool) or not isinstance(merged["trays"], int) or merged["trays"] < 0):
+ if "trays" in merged and (
+ isinstance(merged["trays"], bool)
+ or not isinstance(merged["trays"], int)
+ or merged["trays"] < 0
+ ):
raise ValueError(f"{symbol}: trays must be an int >= 0")
if "jacketed" in merged and not isinstance(merged["jacketed"], bool):
raise ValueError(f"{symbol}: jacketed must be a bool")
@@ -3343,29 +3773,43 @@ def _nozzles(params: dict, half_w: float, half_h: float, extra: float = 0.0):
side, f = n["side"], n["fraction"]
if side == "top":
x, y0, y1, d = -half_w + f * 2 * half_w, half_h, half_h + stub, 90.0
- prims.append(line(x, y0, x, y1)); ports.append(Port(n["name"], x, y1, d))
+ prims.append(line(x, y0, x, y1))
+ ports.append(Port(n["name"], x, y1, d))
elif side == "bottom":
x, y0, y1, d = -half_w + f * 2 * half_w, -half_h, -half_h - stub, 270.0
- prims.append(line(x, y0, x, y1)); ports.append(Port(n["name"], x, y1, d))
+ prims.append(line(x, y0, x, y1))
+ ports.append(Port(n["name"], x, y1, d))
elif side == "left":
y, x0, x1, d = -half_h + f * 2 * half_h, -half_w, -half_w - stub, 180.0
- prims.append(line(x0, y, x1, y)); ports.append(Port(n["name"], x1, y, d))
+ prims.append(line(x0, y, x1, y))
+ ports.append(Port(n["name"], x1, y, d))
else:
y, x0, x1, d = -half_h + f * 2 * half_h, half_w, half_w + stub, 0.0
- prims.append(line(x0, y, x1, y)); ports.append(Port(n["name"], x1, y, d))
+ prims.append(line(x0, y, x1, y))
+ ports.append(Port(n["name"], x1, y, d))
return prims, ports
def _capsule_vertical(w: float, h: float) -> list[dict]:
r = w / 2
body = h / 2 - r
- return [line(-r, -body, -r, body), line(r, -body, r, body), arc(0, body, r, 0, 180), arc(0, -body, r, 180, 360)]
+ return [
+ line(-r, -body, -r, body),
+ line(r, -body, r, body),
+ arc(0, body, r, 0, 180),
+ arc(0, -body, r, 180, 360),
+ ]
def _capsule_horizontal(w: float, h: float) -> list[dict]:
r = h / 2
body = w / 2 - r
- return [line(-body, -r, body, -r), line(-body, r, body, r), arc(body, 0, r, 270, 90), arc(-body, 0, r, 90, 270)]
+ return [
+ line(-body, -r, body, -r),
+ line(-body, r, body, r),
+ arc(body, 0, r, 270, 90),
+ arc(-body, 0, r, 90, 270),
+ ]
def build_vessel(symbol: str = "vertical_vessel", params: dict | None = None) -> SymbolSpec:
@@ -3379,9 +3823,22 @@ def build_vessel(symbol: str = "vertical_vessel", params: dict | None = None) ->
prims = _capsule_horizontal(w, h)
elif symbol == "tank":
roof = w / 6 if p["roof"] == "cone" else 0.0
- prims = [polyline([(-half_w, -half_h), (half_w, -half_h), (half_w, half_h - roof), (-half_w, half_h - roof)])]
+ prims = [
+ polyline(
+ [
+ (-half_w, -half_h),
+ (half_w, -half_h),
+ (half_w, half_h - roof),
+ (-half_w, half_h - roof),
+ ]
+ )
+ ]
if roof:
- prims.append(polyline([(-half_w, half_h - roof), (0, half_h), (half_w, half_h - roof)], closed=False))
+ prims.append(
+ polyline(
+ [(-half_w, half_h - roof), (0, half_h), (half_w, half_h - roof)], closed=False
+ )
+ )
elif symbol == "column":
prims = _capsule_vertical(w, h)
trays = p["trays"]
@@ -3392,22 +3849,42 @@ def build_vessel(symbol: str = "vertical_vessel", params: dict | None = None) ->
prims = _capsule_vertical(w, h)
if p["jacketed"]:
body = half_h - half_w
- prims += [line(-half_w - 2, -body, -half_w - 2, body), line(half_w + 2, -body, half_w + 2, body)]
+ prims += [
+ line(-half_w - 2, -body, -half_w - 2, body),
+ line(half_w + 2, -body, half_w + 2, body),
+ ]
extra = 2.0
else: # hopper
- prims = [polyline([(-half_w, half_h), (half_w, half_h), (w / 6, -half_h), (-w / 6, -half_h)])]
+ prims = [
+ polyline([(-half_w, half_h), (half_w, half_h), (w / 6, -half_h), (-w / 6, -half_h)])
+ ]
nozzle_prims, ports = _nozzles(p, half_w, half_h, extra)
prims += nozzle_prims
xs = [-half_w - extra, half_w + extra] + [pt.x for pt in ports]
ys = [-half_h, half_h] + [pt.y for pt in ports]
box = (min(xs), min(ys), max(xs), max(ys))
- return make_spec(f"PID_VESSEL_{symbol.upper()}_{params_hash(p)}", "vessel", symbol, None, prims,
- tag_attdefs(box[3]), ports, "ISO 10628-2:2012 vessels", bbox=box, parametric=True, params=p)
+ return make_spec(
+ f"PID_VESSEL_{symbol.upper()}_{params_hash(p)}",
+ "vessel",
+ symbol,
+ None,
+ prims,
+ tag_attdefs(box[3]),
+ ports,
+ "ISO 10628-2:2012 vessels",
+ bbox=box,
+ parametric=True,
+ params=p,
+ )
for _name in VESSELS:
- register(_name, "vessel", (lambda s: (lambda params=None: build_vessel(s, params)))(_name),
- params_schema=PARAMS_SCHEMA)
+ register(
+ _name,
+ "vessel",
+ (lambda s: lambda params=None: build_vessel(s, params))(_name),
+ params_schema=PARAMS_SCHEMA,
+ )
```
- [ ] **Step 4: Run the harness** — `uv run --frozen pytest tests/test_pid_symbols.py -v` → all PASS.
@@ -3460,7 +3937,14 @@ async def test_first_insert_defines_the_block_and_creates_its_layer(backend):
assert result["defined"] is True and result["layers_created"] == ["PROCESS-VALVES"]
assert result["block_name"] == "PID_VALVE_GATE" and result["layer"] == "PROCESS-VALVES"
assert result["ports"]["in"] == {
- "name": "in", "x": 96.0, "y": 50.0, "direction_deg": 180.0, "kind": "process", "radius": 0.0, "inferred": False}
+ "name": "in",
+ "x": 96.0,
+ "y": 50.0,
+ "direction_deg": 180.0,
+ "kind": "process",
+ "radius": 0.0,
+ "inferred": False,
+ }
assert result["tag"] == "HV-101" and result["tag_warnings"] == []
assert await backend.block_get_attributes(result["handle"]) == {"TAG": "HV-101", "DESC": ""}
again = await place_symbol(backend, "gate", 200.0, 50.0, tag="HV-102")
@@ -3468,16 +3952,25 @@ async def test_first_insert_defines_the_block_and_creates_its_layer(backend):
async def test_rotation_and_scale_move_the_ports(backend):
- result = await place_symbol(backend, "centrifugal_pump", 0.0, 0.0, rotation=90.0, scale=2.0, tag="P-101")
+ result = await place_symbol(
+ backend, "centrifugal_pump", 0.0, 0.0, rotation=90.0, scale=2.0, tag="P-101"
+ )
discharge = result["ports"]["discharge"]
- assert (discharge["x"], discharge["y"], discharge["direction_deg"]) == pytest.approx((-12.0, 0.0, 180.0))
+ assert (discharge["x"], discharge["y"], discharge["direction_deg"]) == pytest.approx(
+ (-12.0, 0.0, 180.0)
+ )
async def test_instrument_tag_splits_into_func_and_loop(backend):
- result = await place_symbol(backend, "instrument", 10, 10, tag="10-FIC-101A", type="dcs", location="primary")
+ result = await place_symbol(
+ backend, "instrument", 10, 10, tag="10-FIC-101A", type="dcs", location="primary"
+ )
assert await backend.block_get_attributes(result["handle"]) == {"FUNC": "FIC", "LOOP": "101A"}
assert result["block_name"] == "PID_INST_DCS_PRIMARY" and result["layer"] == "INSTRUMENT-SYMBOL"
- assert result["ports"]["signal"]["radius"] == 5.0 and result["ports"]["signal"]["direction_deg"] is None
+ assert (
+ result["ports"]["signal"]["radius"] == 5.0
+ and result["ports"]["signal"]["direction_deg"] is None
+ )
async def test_invalid_tag_is_written_and_warned_not_refused(backend):
@@ -3492,15 +3985,18 @@ async def test_actuator_fail_and_offpage_link_attributes(backend):
assert "signal" in cv["ports"]
with pytest.raises(ValueError, match="FO, FC, FL"):
await place_symbol(backend, "globe", 0, 0, actuator="diaphragm", fail="XX")
- off = await place_symbol(backend, "offpage", 0, 0, tag="TO P&ID-002", link="L-17", direction="out")
+ off = await place_symbol(
+ backend, "offpage", 0, 0, tag="TO P&ID-002", link="L-17", direction="out"
+ )
assert (await backend.block_get_attributes(off["handle"]))["LINK"] == "L-17"
with pytest.raises(ValueError, match="link"):
await place_symbol(backend, "gate", 0, 0, link="L-1")
async def test_xdata_payload_carries_ports_and_params(backend):
- result = await place_symbol(backend, "vertical_vessel", 0, 0, tag="V-201",
- params={"width": 20, "height": 40})
+ result = await place_symbol(
+ backend, "vertical_vessel", 0, 0, tag="V-201", params={"width": 20, "height": 40}
+ )
payload = await read_payload(backend, result["handle"])
assert payload["kind"] == "symbol" and payload["catalog"] == "1"
assert payload["params"]["width"] == 20.0 and set(payload["ports"]) == {"N1", "N2", "N3", "N4"}
@@ -3521,7 +4017,9 @@ async def test_layer_override_and_ensure_layer(backend):
assert await ensure_layer(backend, "PROCESS-LINE-TEXT") is True
assert await ensure_layer(backend, "PROCESS-LINE-TEXT") is False
layers = {lyr.name: lyr for lyr in await backend.layer_list()}
- assert layers["PROCESS-LINE-TEXT"].lineweight == 25.0, "LayerInfo reports DXF hundredths of a millimetre"
+ assert layers["PROCESS-LINE-TEXT"].lineweight == 25.0, (
+ "LayerInfo reports DXF hundredths of a millimetre"
+ )
result = await place_symbol(backend, "gate", 0, 0, layer="MY-VALVES")
assert result["layer"] == "MY-VALVES" and result["layers_created"] == ["MY-VALVES"]
spec = resolve("gate")
@@ -3535,7 +4033,7 @@ async def test_layer_override_and_ensure_layer(backend):
In `engineering/layers.py`, insert into `PID_LAYERS` after the `INSTRUMENT-TAG-TEXT` entry:
```python
- ("PROCESS-LINE-TEXT", 7, "Continuous", 0.25, "line numbers"),
+(("PROCESS-LINE-TEXT", 7, "Continuous", 0.25, "line numbers"),)
```
```python
@@ -3566,7 +4064,9 @@ async def ensure_layer(backend: AutoCADBackend, layer_name: str) -> bool:
if layer_name.lower() in existing:
return False
color, linetype, lineweight = _PID_LAYER_DEFS.get(layer_name, (7, "Continuous", 0.25))
- await backend.layer_create(name=layer_name, color=color, linetype=linetype, lineweight=lineweight)
+ await backend.layer_create(
+ name=layer_name, color=color, linetype=linetype, lineweight=lineweight
+ )
return True
@@ -3574,7 +4074,9 @@ async def ensure_block(backend: AutoCADBackend, spec: SymbolSpec) -> bool:
names = {blk.name for blk in await backend.block_list()}
if spec.name in names:
return False
- await backend.block_define(spec.name, list(spec.primitives), list(spec.attdefs), 0.0, 0.0, False)
+ await backend.block_define(
+ spec.name, list(spec.primitives), list(spec.attdefs), 0.0, 0.0, False
+ )
return True
@@ -3596,7 +4098,9 @@ async def insert_symbol(
defined = await ensure_block(backend, spec)
values = {att["tag"]: att["default"] for att in spec.attdefs}
values.update({k: str(v) for k, v in (attributes or {}).items()})
- ref = await backend.block_insert(spec.name, x, y, scale, scale, rotation, values or None, layer_name)
+ ref = await backend.block_insert(
+ spec.name, x, y, scale, scale, rotation, values or None, layer_name
+ )
await write_payload(backend, ref.handle, payload or symbol_payload(spec))
ports = {p.name: transform_port(p, x, y, rotation, scale) for p in spec.ports}
return {
@@ -3684,7 +4188,9 @@ In `server.py`, before the `# ── RESOURCES` block, add:
# ---------------------------------------------------------------------------
-@cad_tool(summary="List the P&ID symbol catalogue: families, variants, ports, parameters.", cost="read")
+@cad_tool(
+ summary="List the P&ID symbol catalogue: families, variants, ports, parameters.", cost="read"
+)
@mcp.tool(
annotations={"title": "P&ID: Symbol Catalogue", "readOnlyHint": True},
tags={"pid", "query"},
@@ -3716,18 +4222,30 @@ async def pid_symbol_list(
tags={"pid", "create"},
)
async def pid_symbol_insert(
- symbol: Annotated[str, "Catalogue symbol name, e.g. gate, centrifugal_pump, vertical_vessel, instrument, offpage"],
+ symbol: Annotated[
+ str,
+ "Catalogue symbol name, e.g. gate, centrifugal_pump, vertical_vessel, instrument, offpage",
+ ],
x: Annotated[float, "Insertion X (WCS)"],
y: Annotated[float, "Insertion Y (WCS)"],
rotation: Annotated[float, "Rotation in degrees; 0 = flow left to right"] = 0.0,
scale: Annotated[float, Field(default=1.0, gt=0, description="Uniform scale")] = 1.0,
- tag: Annotated[str | None, "Tag: equipment P-101, valve HV-101, instrument FIC-101 (split into FUNC/LOOP)"] = None,
+ tag: Annotated[
+ str | None, "Tag: equipment P-101, valve HV-101, instrument FIC-101 (split into FUNC/LOOP)"
+ ] = None,
desc: Annotated[str | None, "Description attribute"] = None,
- actuator: Annotated[str | None, "Valves: none | diaphragm | piston | motor | solenoid | hand"] = None,
+ actuator: Annotated[
+ str | None, "Valves: none | diaphragm | piston | motor | solenoid | hand"
+ ] = None,
fail: Annotated[str | None, "Actuated valves: FO | FC | FL"] = None,
type: Annotated[str | None, "Instruments: discrete | dcs | computer | plc"] = None,
- location: Annotated[str | None, "Instruments: field | primary | auxiliary | primary_rear | auxiliary_rear"] = None,
- params: Annotated[dict | None, "Parametric vessels: {width, height, nozzles: [{name, side, fraction}], roof, trays, jacketed}"] = None,
+ location: Annotated[
+ str | None, "Instruments: field | primary | auxiliary | primary_rear | auxiliary_rear"
+ ] = None,
+ params: Annotated[
+ dict | None,
+ "Parametric vessels: {width, height, nozzles: [{name, side, fraction}], roof, trays, jacketed}",
+ ] = None,
shape: Annotated[str | None, "Reducer: concentric | eccentric"] = None,
direction: Annotated[str | None, "Off-page connector: in | out"] = None,
link: Annotated[str | None, "Off-page connector link id shared by both ends"] = None,
@@ -3746,8 +4264,23 @@ async def pid_symbol_insert(
await ctx.info(f"P&ID symbol {symbol} at ({x}, {y})")
return await place_symbol(
- _backend(ctx), symbol, x, y, rotation, scale, tag, desc, fail, link, layer,
- actuator=actuator, type=type, location=location, params=params, shape=shape, direction=direction,
+ _backend(ctx),
+ symbol,
+ x,
+ y,
+ rotation,
+ scale,
+ tag,
+ desc,
+ fail,
+ link,
+ layer,
+ actuator=actuator,
+ type=type,
+ location=location,
+ params=params,
+ shape=shape,
+ direction=direction,
)
```
@@ -3812,11 +4345,25 @@ from engineering.pid.lines import (
def test_line_classes_map_to_layers_and_markers():
- assert set(LINE_CLASSES) == {"process_major", "process_minor", "utility", "pneumatic",
- "electric", "hydraulic", "capillary", "data"}
+ assert set(LINE_CLASSES) == {
+ "process_major",
+ "process_minor",
+ "utility",
+ "pneumatic",
+ "electric",
+ "hydraulic",
+ "capillary",
+ "data",
+ }
assert LINE_CLASSES["process_major"].layer == "PROCESS-PIPING-MAIN"
- assert LINE_CLASSES["electric"].layer == "INSTRUMENT-LINE-SIGNAL" and LINE_CLASSES["electric"].linetype is None
- assert LINE_CLASSES["pneumatic"].linetype == "Continuous" and LINE_CLASSES["pneumatic"].marker == "mark_pneumatic"
+ assert (
+ LINE_CLASSES["electric"].layer == "INSTRUMENT-LINE-SIGNAL"
+ and LINE_CLASSES["electric"].linetype is None
+ )
+ assert (
+ LINE_CLASSES["pneumatic"].linetype == "Continuous"
+ and LINE_CLASSES["pneumatic"].marker == "mark_pneumatic"
+ )
assert LINE_CLASSES["process_major"].arrow is True and LINE_CLASSES["pneumatic"].arrow is False
assert LINE_CLASSES["utility"].kind == "process" and LINE_CLASSES["data"].kind == "signal"
@@ -3850,7 +4397,9 @@ def test_ports_facing_away_on_one_axis_need_waypoints():
def test_stub_is_honoured_or_refused():
with pytest.raises(ValueError, match="stub"):
route((0, 0), 0.0, (3, 8), 180.0, stub=5.0)
- assert route((0, 0), 0.0, (3, 0), 180.0, stub=5.0) == [(0.0, 0.0), (3.0, 0.0)], "a straight run is exempt"
+ assert route((0, 0), 0.0, (3, 0), 180.0, stub=5.0) == [(0.0, 0.0), (3.0, 0.0)], (
+ "a straight run is exempt"
+ )
def test_radial_ends_take_the_axis_towards_the_other_end():
@@ -3860,7 +4409,11 @@ def test_radial_ends_take_the_axis_towards_the_other_end():
def test_direct_and_waypoints():
assert route((0, 0), 0.0, (10, 7), 90.0, mode="direct") == [(0.0, 0.0), (10.0, 7.0)]
- assert route((0, 0), 0.0, (10, 7), 90.0, mode=[(10, 0)]) == [(0.0, 0.0), (10.0, 0.0), (10.0, 7.0)]
+ assert route((0, 0), 0.0, (10, 7), 90.0, mode=[(10, 0)]) == [
+ (0.0, 0.0),
+ (10.0, 0.0),
+ (10.0, 7.0),
+ ]
with pytest.raises(ValueError, match="repeat"):
route((0, 0), 0.0, (10, 7), 90.0, mode=[(0, 0)])
with pytest.raises(ValueError, match="zero"):
@@ -3884,13 +4437,22 @@ def test_markers_only_on_segments_long_enough():
def test_crossings_count_proper_intersections_only():
others = [[(5, -5), (5, 5)], [(20, 0), (20, 10)], [(0, 3), (8, 3)]]
assert count_crossings([(0, 0), (10, 0)], others) == 1
- assert count_crossings([(0, 0), (20, 0)], [[(20, 0), (20, 10)]]) == 0, "touching an endpoint is a junction"
+ assert count_crossings([(0, 0), (20, 0)], [[(20, 0), (20, 10)]]) == 0, (
+ "touching an endpoint is a junction"
+ )
def test_line_number_format_drops_empty_fields():
assert DEFAULT_NUMBER_FORMAT == "{size}-{service}-{seq}-{spec}-{insulation}"
- assert format_line_number(DEFAULT_NUMBER_FORMAT, size="100", service="P", seq=1001, spec="CS1", insulation="IH") == "100-P-1001-CS1-IH"
- assert format_line_number(DEFAULT_NUMBER_FORMAT, size="100", service="P", seq=1001) == "100-P-1001"
+ assert (
+ format_line_number(
+ DEFAULT_NUMBER_FORMAT, size="100", service="P", seq=1001, spec="CS1", insulation="IH"
+ )
+ == "100-P-1001-CS1-IH"
+ )
+ assert (
+ format_line_number(DEFAULT_NUMBER_FORMAT, size="100", service="P", seq=1001) == "100-P-1001"
+ )
assert format_line_number("L-{seq}", seq=7) == "L-7"
with pytest.raises(ValueError, match="unknown"):
format_line_number("{bogus}", seq=1)
@@ -3924,10 +4486,16 @@ LINE_CLASSES: dict[str, LineClass] = {
"process_major": LineClass("PROCESS-PIPING-MAIN", None, None, True, "process"),
"process_minor": LineClass("PROCESS-PIPING-SECONDARY", None, None, True, "process"),
"utility": LineClass("UTILITY-LINE", None, None, True, "process"),
- "pneumatic": LineClass("INSTRUMENT-LINE-SIGNAL", "Continuous", "mark_pneumatic", False, "signal"),
+ "pneumatic": LineClass(
+ "INSTRUMENT-LINE-SIGNAL", "Continuous", "mark_pneumatic", False, "signal"
+ ),
"electric": LineClass("INSTRUMENT-LINE-SIGNAL", None, None, False, "signal"),
- "hydraulic": LineClass("INSTRUMENT-LINE-SIGNAL", "Continuous", "mark_hydraulic", False, "signal"),
- "capillary": LineClass("INSTRUMENT-LINE-SIGNAL", "Continuous", "mark_capillary", False, "signal"),
+ "hydraulic": LineClass(
+ "INSTRUMENT-LINE-SIGNAL", "Continuous", "mark_hydraulic", False, "signal"
+ ),
+ "capillary": LineClass(
+ "INSTRUMENT-LINE-SIGNAL", "Continuous", "mark_capillary", False, "signal"
+ ),
"data": LineClass("INSTRUMENT-LINE-SIGNAL", "Continuous", "mark_data", False, "signal"),
}
#: Layer → class when a line carries no payload. ``None`` = decide by linetype.
@@ -4122,7 +4690,9 @@ def format_line_number(fmt: str, **fields) -> str:
continue
name = match.group(1)
if name not in _KNOWN_FIELDS:
- raise ValueError(f"unknown line-number field {{{name}}}; known: {', '.join(sorted(_KNOWN_FIELDS))}")
+ raise ValueError(
+ f"unknown line-number field {{{name}}}; known: {', '.join(sorted(_KNOWN_FIELDS))}"
+ )
value = fields.get(name)
if value is None or value == "":
continue
@@ -4177,9 +4747,15 @@ async def _pump_and_vessel(backend):
async def test_process_line_between_ports(backend):
pump, vessel = await _pump_and_vessel(backend)
- result = await draw_line(backend, {"handle": pump["handle"], "port": "discharge"},
- {"handle": vessel["handle"], "port": "N3"},
- line_class="process_major", size="100", service="P", spec="CS1")
+ result = await draw_line(
+ backend,
+ {"handle": pump["handle"], "port": "discharge"},
+ {"handle": vessel["handle"], "port": "N3"},
+ line_class="process_major",
+ size="100",
+ service="P",
+ spec="CS1",
+ )
assert result["layer"] == "PROCESS-PIPING-MAIN" and result["line_class"] == "process_major"
assert result["vertices"][0] == [100.0, 106.0] and result["vertices"][-1] == [207.0, 128.0]
assert result["line_number"] == "100-P-1-CS1" and result["label_handle"]
@@ -4188,31 +4764,57 @@ async def test_process_line_between_ports(backend):
info = await backend.entity_get(result["handle"])
assert info.type == "LWPOLYLINE" and info.layer == "PROCESS-PIPING-MAIN"
payload = await read_payload(backend, result["handle"])
- assert payload["kind"] == "line" and payload["from"] == {"handle": pump["handle"], "port": "discharge"}
+ assert payload["kind"] == "line" and payload["from"] == {
+ "handle": pump["handle"],
+ "port": "discharge",
+ }
assert payload["seq"] == 1
label = await backend.entity_get(result["label_handle"])
assert label.layer == "PROCESS-LINE-TEXT" and label.properties["text"] == "100-P-1-CS1"
arrow = await backend.entity_get(result["arrow_handle"])
- assert arrow.properties["block_name"] == "PID_MARKER_ARROW_FLOW" and arrow.layer == "PROCESS-PIPING-MAIN"
+ assert (
+ arrow.properties["block_name"] == "PID_MARKER_ARROW_FLOW"
+ and arrow.layer == "PROCESS-PIPING-MAIN"
+ )
async def test_sequence_increments_and_number_can_be_given(backend):
pump, vessel = await _pump_and_vessel(backend)
- first = await draw_line(backend, {"handle": pump["handle"], "port": "discharge"},
- {"handle": vessel["handle"], "port": "N3"}, size="100", service="P")
- second = await draw_line(backend, {"handle": vessel["handle"], "port": "N4"}, {"x": 300, "y": 128},
- size="80", service="P")
+ first = await draw_line(
+ backend,
+ {"handle": pump["handle"], "port": "discharge"},
+ {"handle": vessel["handle"], "port": "N3"},
+ size="100",
+ service="P",
+ )
+ second = await draw_line(
+ backend,
+ {"handle": vessel["handle"], "port": "N4"},
+ {"x": 300, "y": 128},
+ size="80",
+ service="P",
+ )
assert (first["line_number"], second["line_number"]) == ("100-P-1", "80-P-2")
- third = await draw_line(backend, {"handle": vessel["handle"], "port": "N1"}, {"x": 210, "y": 200},
- line_number="VENT-01")
+ third = await draw_line(
+ backend,
+ {"handle": vessel["handle"], "port": "N1"},
+ {"x": 210, "y": 200},
+ line_number="VENT-01",
+ )
assert third["line_number"] == "VENT-01"
async def test_signal_line_from_a_bubble_gets_markers_and_no_arrow(backend):
cv = await place_symbol(backend, "globe", 100, 100, tag="FCV-1", actuator="diaphragm")
- fic = await place_symbol(backend, "instrument", 100, 160, tag="FIC-1", type="dcs", location="primary")
- result = await draw_line(backend, {"handle": fic["handle"]}, {"handle": cv["handle"], "port": "signal"},
- line_class="pneumatic")
+ fic = await place_symbol(
+ backend, "instrument", 100, 160, tag="FIC-1", type="dcs", location="primary"
+ )
+ result = await draw_line(
+ backend,
+ {"handle": fic["handle"]},
+ {"handle": cv["handle"], "port": "signal"},
+ line_class="pneumatic",
+ )
assert result["vertices"][0] == [100.0, 155.0], "the line starts on the bubble's circle"
assert result["vertices"][-1] == [100.0, 109.0]
assert result["arrow_handle"] is None and len(result["marker_handles"]) == 1
@@ -4221,31 +4823,46 @@ async def test_signal_line_from_a_bubble_gets_markers_and_no_arrow(backend):
assert mark.layer == "INSTRUMENT-LINE-SIGNAL"
line = await backend.entity_get(result["handle"])
assert line.linetype == "Continuous"
- electric = await draw_line(backend, {"handle": fic["handle"]}, {"x": 160, "y": 160}, line_class="electric")
+ electric = await draw_line(
+ backend, {"handle": fic["handle"]}, {"x": 160, "y": 160}, line_class="electric"
+ )
assert (await backend.entity_get(electric["handle"])).linetype == "ByLayer"
async def test_port_reuse_and_crossings_are_reported_not_refused(backend):
pump, vessel = await _pump_and_vessel(backend)
- await draw_line(backend, {"handle": pump["handle"], "port": "discharge"}, {"handle": vessel["handle"], "port": "N3"})
- again = await draw_line(backend, {"handle": pump["handle"], "port": "discharge"}, {"x": 100, "y": 200})
+ await draw_line(
+ backend,
+ {"handle": pump["handle"], "port": "discharge"},
+ {"handle": vessel["handle"], "port": "N3"},
+ )
+ again = await draw_line(
+ backend, {"handle": pump["handle"], "port": "discharge"}, {"x": 100, "y": 200}
+ )
assert again["port_reuse"] == [{"handle": pump["handle"], "port": "discharge"}]
- crossing = await draw_line(backend, {"x": 150, "y": 90}, {"x": 150, "y": 140}, line_class="utility")
+ crossing = await draw_line(
+ backend, {"x": 150, "y": 90}, {"x": 150, "y": 140}, line_class="utility"
+ )
assert crossing["crossings"] == 1
async def test_refusals(backend):
pump, vessel = await _pump_and_vessel(backend)
with pytest.raises(ValueError, match="same"):
- await draw_line(backend, {"handle": pump["handle"], "port": "discharge"},
- {"handle": pump["handle"], "port": "discharge"})
+ await draw_line(
+ backend,
+ {"handle": pump["handle"], "port": "discharge"},
+ {"handle": pump["handle"], "port": "discharge"},
+ )
with pytest.raises(ValueError, match="discharge, suction"): # payload ports are JSON-sorted
await draw_line(backend, {"handle": pump["handle"], "port": "outlet"}, {"x": 0, "y": 0})
with pytest.raises(ValueError, match="choose a port"):
await draw_line(backend, {"handle": pump["handle"]}, {"x": 0, "y": 0})
with pytest.raises(ValueError, match="line_class"):
await draw_line(backend, {"x": 0, "y": 0}, {"x": 10, "y": 0}, line_class="steam")
- first = await draw_line(backend, {"handle": pump["handle"], "port": "discharge"}, {"x": 100, "y": 200})
+ first = await draw_line(
+ backend, {"handle": pump["handle"], "port": "discharge"}, {"x": 100, "y": 200}
+ )
with pytest.raises(ValueError, match="decoration"):
await draw_line(backend, {"handle": first["arrow_handle"], "port": "in"}, {"x": 0, "y": 0})
with pytest.raises(ValueError, match="not a P&ID symbol"):
@@ -4317,13 +4934,25 @@ async def resolve_endpoint(backend: AutoCADBackend, ref: dict) -> dict:
ins = info.properties["insertion"]
world = transform_port(
Port(port_name, float(lx), float(ly), ldir, kind, float(radius)),
- float(ins[0]), float(ins[1]),
- float(info.properties.get("rotation_deg", 0.0)), float(info.properties.get("x_scale", 1.0)),
+ float(ins[0]),
+ float(ins[1]),
+ float(info.properties.get("rotation_deg", 0.0)),
+ float(info.properties.get("x_scale", 1.0)),
)
return {**world, "handle": handle, "port": port_name, "block_name": block_name}
if "x" in ref and "y" in ref:
- return {"name": None, "x": float(ref["x"]), "y": float(ref["y"]), "direction_deg": None, "kind": None,
- "radius": 0.0, "inferred": False, "handle": None, "port": None, "block_name": None}
+ return {
+ "name": None,
+ "x": float(ref["x"]),
+ "y": float(ref["y"]),
+ "direction_deg": None,
+ "kind": None,
+ "radius": 0.0,
+ "inferred": False,
+ "handle": None,
+ "port": None,
+ "block_name": None,
+ }
raise ValueError("endpoint must be {handle, port} or {x, y}")
@@ -4344,7 +4973,9 @@ async def existing_pid_lines(backend: AutoCADBackend) -> list[dict]:
for info in page:
if info.layer.upper() not in PID_LINE_LAYERS:
continue
- payload = await read_payload(backend, info.handle) if info.type == "LWPOLYLINE" else None
+ payload = (
+ await read_payload(backend, info.handle) if info.type == "LWPOLYLINE" else None
+ )
out.append({"handle": info.handle, "vertices": _vertices(info), "payload": payload})
if len(page) < 1000:
break
@@ -4357,7 +4988,10 @@ def _anchor(endpoint: dict, other: dict) -> tuple[tuple[float, float], float | N
if endpoint["radius"] > 0:
axis = snap_axis(other["x"] - endpoint["x"], other["y"] - endpoint["y"])
ux, uy = AXIS[axis]
- return (endpoint["x"] + ux * endpoint["radius"], endpoint["y"] + uy * endpoint["radius"]), axis
+ return (
+ endpoint["x"] + ux * endpoint["radius"],
+ endpoint["y"] + uy * endpoint["radius"],
+ ), axis
return (endpoint["x"], endpoint["y"]), endpoint["direction_deg"]
@@ -4412,15 +5046,28 @@ async def draw_line(
seq = max_seq + 1
if line_number is None:
fmt = number_format or DEFAULT_NUMBER_FORMAT
- fields = {"size": size, "service": service, "seq": seq, "spec": spec, "insulation": insulation}
+ fields = {
+ "size": size,
+ "service": service,
+ "seq": seq,
+ "spec": spec,
+ "insulation": insulation,
+ }
line_number = format_line_number(fmt, **fields) or None
await ensure_layer(backend, cls.layer)
- poly = await backend.entity_create_polyline([list(p) for p in path], closed=False, layer=cls.layer)
+ poly = await backend.entity_create_polyline(
+ [list(p) for p in path], closed=False, layer=cls.layer
+ )
if cls.linetype:
await backend.entity_set_properties(poly.handle, linetype=cls.linetype)
- await write_payload(backend, poly.handle, line_payload(
- line_class, line_number, size, service, spec, insulation, seq, _ref(start), _ref(end)))
+ await write_payload(
+ backend,
+ poly.handle,
+ line_payload(
+ line_class, line_number, size, service, spec, insulation, seq, _ref(start), _ref(end)
+ ),
+ )
label_handle = None
if label and line_number:
@@ -4431,24 +5078,47 @@ async def draw_line(
shift = len(line_number) * TEXT_HEIGHT * 0.7 / 2.0
lx -= math.cos(math.radians(rot)) * shift
ly -= math.sin(math.radians(rot)) * shift
- text = await backend.entity_create_text(line_number, lx, ly, TEXT_HEIGHT, rot, layer=LABEL_LAYER)
+ text = await backend.entity_create_text(
+ line_number, lx, ly, TEXT_HEIGHT, rot, layer=LABEL_LAYER
+ )
label_handle = text.handle
want_arrow = cls.arrow if arrow is None else arrow
arrow_handle = None
if want_arrow:
(ax0, ay0), (ax1, ay1) = path[-2], path[-1]
- angle = snap_axis(ax1 - ax0, ay1 - ay0) if abs(ax1 - ax0) < 1e-9 or abs(ay1 - ay0) < 1e-9 else \
- math.degrees(math.atan2(ay1 - ay0, ax1 - ax0)) % 360.0
- placed = await insert_symbol(backend, resolve("arrow_flow"), ax1, ay1, angle, 1.0, None, cls.layer,
- marker_payload(poly.handle))
+ angle = (
+ snap_axis(ax1 - ax0, ay1 - ay0)
+ if abs(ax1 - ax0) < 1e-9 or abs(ay1 - ay0) < 1e-9
+ else math.degrees(math.atan2(ay1 - ay0, ax1 - ax0)) % 360.0
+ )
+ placed = await insert_symbol(
+ backend,
+ resolve("arrow_flow"),
+ ax1,
+ ay1,
+ angle,
+ 1.0,
+ None,
+ cls.layer,
+ marker_payload(poly.handle),
+ )
arrow_handle = placed["handle"]
marker_handles: list[str] = []
if cls.marker:
for mx, my, angle in marker_positions(path):
- placed = await insert_symbol(backend, resolve(cls.marker), mx, my, angle, 1.0, None, cls.layer,
- marker_payload(poly.handle))
+ placed = await insert_symbol(
+ backend,
+ resolve(cls.marker),
+ mx,
+ my,
+ angle,
+ 1.0,
+ None,
+ cls.layer,
+ marker_payload(poly.handle),
+ )
marker_handles.append(placed["handle"])
return {
@@ -4457,7 +5127,12 @@ async def draw_line(
"line_class": line_class,
"layer": cls.layer,
"line_number": line_number,
- "from": {"handle": start["handle"], "port": start["port"], "x": s_point[0], "y": s_point[1]},
+ "from": {
+ "handle": start["handle"],
+ "port": start["port"],
+ "x": s_point[0],
+ "y": s_point[1],
+ },
"to": {"handle": end["handle"], "port": end["port"], "x": e_point[0], "y": e_point[1]},
"port_reuse": port_reuse,
"crossings": crossings,
@@ -4489,18 +5164,27 @@ async def pid_line_draw(
from_: Annotated[dict, "{handle, port} of a placed symbol, or {x, y}"],
to: Annotated[dict, "{handle, port} of a placed symbol, or {x, y}"],
line_class: Annotated[
- str, "process_major | process_minor | utility | pneumatic | electric | hydraulic | capillary | data"
+ str,
+ "process_major | process_minor | utility | pneumatic | electric | hydraulic | capillary | data",
] = "process_major",
route: Annotated[str | list, "auto (orthogonal) | direct | [[x, y], ...] waypoints"] = "auto",
- stub: Annotated[float, Field(default=5.0, ge=0, description="Minimum straight run leaving each port (mm)")] = 5.0,
- line_number: Annotated[str | None, "Verbatim line number; omit to build one from the fields"] = None,
+ stub: Annotated[
+ float, Field(default=5.0, ge=0, description="Minimum straight run leaving each port (mm)")
+ ] = 5.0,
+ line_number: Annotated[
+ str | None, "Verbatim line number; omit to build one from the fields"
+ ] = None,
size: Annotated[str | None, "Nominal size, e.g. 100"] = None,
service: Annotated[str | None, "Service code, e.g. P"] = None,
spec: Annotated[str | None, "Piping spec, e.g. CS1"] = None,
insulation: Annotated[str | None, "Insulation code, e.g. IH"] = None,
- number_format: Annotated[str | None, "Default {size}-{service}-{seq}-{spec}-{insulation}; empty fields drop out"] = None,
+ number_format: Annotated[
+ str | None, "Default {size}-{service}-{seq}-{spec}-{insulation}; empty fields drop out"
+ ] = None,
label: Annotated[bool, "Write the line number along the longest segment"] = True,
- arrow: Annotated[bool | None, "Flow arrow at the end; default per class (process yes, signal no)"] = None,
+ arrow: Annotated[
+ bool | None, "Flow arrow at the end; default per class (process yes, signal no)"
+ ] = None,
ctx: Context = None,
) -> dict:
"""Connect two ports with one LWPOLYLINE on the class's layer.
@@ -4517,8 +5201,20 @@ async def pid_line_draw(
await ctx.info(f"P&ID line {line_class}")
return await draw_line(
- _backend(ctx), from_, to, line_class, route, stub, line_number, size, service, spec,
- insulation, number_format, label, arrow,
+ _backend(ctx),
+ from_,
+ to,
+ line_class,
+ route,
+ stub,
+ line_number,
+ size,
+ service,
+ spec,
+ insulation,
+ number_format,
+ label,
+ arrow,
)
```
@@ -4577,12 +5273,26 @@ async def _small_pid(backend):
pump = await place_symbol(backend, "centrifugal_pump", 100, 100, tag="P-101", desc="Feed pump")
hv = await place_symbol(backend, "gate", 160, 104, tag="HV-101", actuator="hand")
vessel = await place_symbol(backend, "vertical_vessel", 220, 120, tag="V-201")
- fic = await place_symbol(backend, "instrument", 160, 160, tag="FIC-101", type="dcs", location="primary")
- l1 = await draw_line(backend, {"handle": pump["handle"], "port": "discharge"}, {"handle": hv["handle"], "port": "in"},
- size="100", service="P", spec="CS1")
- l2 = await draw_line(backend, {"handle": hv["handle"], "port": "out"}, {"handle": vessel["handle"], "port": "N3"},
- line_number=l1["line_number"])
- sig = await draw_line(backend, {"handle": fic["handle"]}, {"x": 160, "y": 112}, line_class="electric")
+ fic = await place_symbol(
+ backend, "instrument", 160, 160, tag="FIC-101", type="dcs", location="primary"
+ )
+ l1 = await draw_line(
+ backend,
+ {"handle": pump["handle"], "port": "discharge"},
+ {"handle": hv["handle"], "port": "in"},
+ size="100",
+ service="P",
+ spec="CS1",
+ )
+ l2 = await draw_line(
+ backend,
+ {"handle": hv["handle"], "port": "out"},
+ {"handle": vessel["handle"], "port": "N3"},
+ line_number=l1["line_number"],
+ )
+ sig = await draw_line(
+ backend, {"handle": fic["handle"]}, {"x": 160, "y": 112}, line_class="electric"
+ )
return {"pump": pump, "hv": hv, "vessel": vessel, "fic": fic, "l1": l1, "l2": l2, "sig": sig}
@@ -4590,10 +5300,21 @@ async def test_tool_drawn_pid_is_read_back_exactly(backend):
ids = await _small_pid(backend)
graph = await build_graph(backend)
nodes = {n["id"]: n for n in graph["nodes"]}
- assert set(nodes) == {ids["pump"]["handle"], ids["hv"]["handle"], ids["vessel"]["handle"], ids["fic"]["handle"]}
- assert nodes[ids["pump"]["handle"]]["kind"] == "equipment" and nodes[ids["pump"]["handle"]]["tag"] == "P-101"
+ assert set(nodes) == {
+ ids["pump"]["handle"],
+ ids["hv"]["handle"],
+ ids["vessel"]["handle"],
+ ids["fic"]["handle"],
+ }
+ assert (
+ nodes[ids["pump"]["handle"]]["kind"] == "equipment"
+ and nodes[ids["pump"]["handle"]]["tag"] == "P-101"
+ )
assert nodes[ids["pump"]["handle"]]["description"] == "Feed pump"
- assert nodes[ids["fic"]["handle"]]["kind"] == "instrument" and nodes[ids["fic"]["handle"]]["tag"] == "FIC-101"
+ assert (
+ nodes[ids["fic"]["handle"]]["kind"] == "instrument"
+ and nodes[ids["fic"]["handle"]]["tag"] == "FIC-101"
+ )
assert nodes[ids["fic"]["handle"]]["tag_parsed"]["description"] == "Flow Indicating Controller"
assert all(n["source"] == "catalog" and n["confidence"] == 1.0 for n in graph["nodes"])
edges = {e["id"]: e for e in graph["edges"]}
@@ -4601,10 +5322,17 @@ async def test_tool_drawn_pid_is_read_back_exactly(backend):
l1 = edges[ids["l1"]["handle"]]
assert l1["from"] == {"node": ids["pump"]["handle"], "port": "discharge"}
assert l1["to"] == {"node": ids["hv"]["handle"], "port": "in"}
- assert l1["line_number"] == "100-P-1-CS1" and l1["number_source"] == "xdata" and l1["class_source"] == "xdata"
+ assert (
+ l1["line_number"] == "100-P-1-CS1"
+ and l1["number_source"] == "xdata"
+ and l1["class_source"] == "xdata"
+ )
assert nodes[ids["hv"]["handle"]]["ports"]["in"]["edges"] == [ids["l1"]["handle"]]
sig = edges[ids["sig"]["handle"]]
- assert sig["from"] == {"node": ids["fic"]["handle"], "port": "signal"} and sig["line_class"] == "electric"
+ assert (
+ sig["from"] == {"node": ids["fic"]["handle"], "port": "signal"}
+ and sig["line_class"] == "electric"
+ )
assert len(graph["dangling"]) == 1 and graph["dangling"][0]["edge"] == ids["sig"]["handle"]
assert graph["stats"]["nodes_by_kind"] == {"equipment": 2, "instrument": 1, "valve": 1}
assert graph["stats"]["confidence_min"] == 1.0 and graph["stats"]["dangling"] == 1
@@ -4618,7 +5346,11 @@ async def test_a_nudged_symbol_makes_its_line_dangle_with_a_hint(backend):
dangling = {d["edge"]: d for d in graph["dangling"]}
assert ids["l1"]["handle"] in dangling and ids["l2"]["handle"] in dangling
hint = dangling[ids["l1"]["handle"]]["nearest"]
- assert hint["node"] == ids["hv"]["handle"] and hint["port"] == "in" and hint["distance"] == pytest.approx(1.0)
+ assert (
+ hint["node"] == ids["hv"]["handle"]
+ and hint["port"] == "in"
+ and hint["distance"] == pytest.approx(1.0)
+ )
assert await _no_dangling_after(backend, tolerance=1.5)
@@ -4630,7 +5362,9 @@ async def _no_dangling_after(backend, tolerance):
async def test_branch_onto_a_line_becomes_a_junction(backend):
ids = await _small_pid(backend)
# l1 runs (100,106)->(100,111)->(151,111)->(151,104)->(156,104); (130,111) is on its second segment
- branch = await draw_line(backend, {"x": 130, "y": 111}, {"x": 130, "y": 60}, line_class="process_minor")
+ branch = await draw_line(
+ backend, {"x": 130, "y": 111}, {"x": 130, "y": 60}, line_class="process_minor"
+ )
graph = await build_graph(backend)
edge = next(e for e in graph["edges"] if e["id"] == branch["handle"])
assert edge["from"] and "junction" in edge["from"]
@@ -4640,7 +5374,9 @@ async def test_branch_onto_a_line_becomes_a_junction(backend):
async def test_rotated_and_scaled_symbols_are_measured_not_assumed(backend):
- pump = await place_symbol(backend, "centrifugal_pump", 0, 0, rotation=90.0, scale=2.0, tag="P-1")
+ pump = await place_symbol(
+ backend, "centrifugal_pump", 0, 0, rotation=90.0, scale=2.0, tag="P-1"
+ )
graph = await build_graph(backend)
node = graph["nodes"][0]
assert node["ports"]["discharge"]["x"] == pytest.approx(-12.0)
@@ -4701,9 +5437,13 @@ async def test_scope_all_walks_every_layout_and_restores_the_current_one(backend
async def test_offpage_links_pair_by_link_id(backend):
out = await place_symbol(backend, "offpage", 0, 0, tag="TO 002", link="L-17", direction="out")
- inn = await place_symbol(backend, "offpage", 100, 0, tag="FROM 001", link="L-17", direction="in")
+ inn = await place_symbol(
+ backend, "offpage", 100, 0, tag="FROM 001", link="L-17", direction="in"
+ )
graph = await build_graph(backend)
- assert graph["offpage_links"] == [{"link": "L-17", "nodes": sorted([out["handle"], inn["handle"]])}]
+ assert graph["offpage_links"] == [
+ {"link": "L-17", "nodes": sorted([out["handle"], inn["handle"]])}
+ ]
async def test_geometry_is_optional(backend):
@@ -4748,9 +5488,18 @@ LINE_TYPES = {"LINE"}
TEXT_TYPES = {"TEXT", "MTEXT"}
ATTRIBUTE_TAGS = ("TAG", "TAGNO", "TAGNUMBER", "EQUIP_TAG", "EQUIPMENT_TAG", "INST_TAG", "INSTTAG")
KEYWORDS = {
- "VALVE": "valve", "VLV": "valve", "PUMP": "equipment", "TANK": "equipment", "VESSEL": "equipment",
- "DRUM": "equipment", "INST": "instrument", "BUBBLE": "instrument", "HX": "equipment",
- "EXCH": "equipment", "FILTER": "equipment", "COMP": "equipment",
+ "VALVE": "valve",
+ "VLV": "valve",
+ "PUMP": "equipment",
+ "TANK": "equipment",
+ "VESSEL": "equipment",
+ "DRUM": "equipment",
+ "INST": "instrument",
+ "BUBBLE": "instrument",
+ "HX": "equipment",
+ "EXCH": "equipment",
+ "FILTER": "equipment",
+ "COMP": "equipment",
}
FAMILY_KIND = {"instrument": "instrument", "valve": "valve", "connector": "connector"}
LINE_NUMBER_RE = re.compile(r'^\d+(?:"|MM)?-[A-Z]{1,4}-\d+', re.IGNORECASE)
@@ -4783,12 +5532,24 @@ class _Node:
if self.tag:
parsed = parse_tag(self.tag, kind="instrument" if self.kind == "instrument" else "auto")
return {
- "id": self.id, "kind": self.kind, "block_name": self.block_name, "family": self.family,
- "symbol": self.symbol, "tag": self.tag, "tag_parsed": parsed, "tag_source": self.tag_source,
- "x": self.x, "y": self.y, "rotation_deg": self.rotation_deg, "scale": self.scale,
- "layer": self.layer, "space": self.space, "ports": self.ports,
+ "id": self.id,
+ "kind": self.kind,
+ "block_name": self.block_name,
+ "family": self.family,
+ "symbol": self.symbol,
+ "tag": self.tag,
+ "tag_parsed": parsed,
+ "tag_source": self.tag_source,
+ "x": self.x,
+ "y": self.y,
+ "rotation_deg": self.rotation_deg,
+ "scale": self.scale,
+ "layer": self.layer,
+ "space": self.space,
+ "ports": self.ports,
"description": self.attributes.get("DESC") or None,
- "source": self.source, "confidence": self.confidence,
+ "source": self.source,
+ "confidence": self.confidence,
}
@@ -4811,12 +5572,23 @@ class _Edge:
def to_dict(self, include_geometry: bool) -> dict:
p = self.payload or {}
out = {
- "id": self.id, "line_class": self.line_class, "class_source": self.class_source,
- "line_number": self.line_number, "number_source": self.number_source,
- "size": p.get("size"), "service": p.get("service"), "spec": p.get("spec"),
- "insulation": p.get("insulation"), "from": self.from_ref, "to": self.to_ref,
- "length": round(sum(math.dist(a, b) for a, b in zip(self.vertices, self.vertices[1:])), 6),
- "layer": self.layer, "space": self.space, "source": self.source,
+ "id": self.id,
+ "line_class": self.line_class,
+ "class_source": self.class_source,
+ "line_number": self.line_number,
+ "number_source": self.number_source,
+ "size": p.get("size"),
+ "service": p.get("service"),
+ "spec": p.get("spec"),
+ "insulation": p.get("insulation"),
+ "from": self.from_ref,
+ "to": self.to_ref,
+ "length": round(
+ sum(math.dist(a, b) for a, b in zip(self.vertices, self.vertices[1:])), 6
+ ),
+ "layer": self.layer,
+ "space": self.space,
+ "source": self.source,
}
if include_geometry:
out["vertices"] = [[x, y] for x, y in self.vertices]
@@ -4939,16 +5711,37 @@ async def build_graph(
ins = info.properties.get("insertion", [0.0, 0.0])
rotation = float(info.properties.get("rotation_deg", 0.0))
scale = float(info.properties.get("x_scale", 1.0))
- base = dict(id=info.handle, block_name=name, x=float(ins[0]), y=float(ins[1]), rotation_deg=rotation,
- scale=scale, layer=info.layer, space=space, bbox=_bbox(info), attributes=attributes)
+ base = dict(
+ id=info.handle,
+ block_name=name,
+ x=float(ins[0]),
+ y=float(ins[1]),
+ rotation_deg=rotation,
+ scale=scale,
+ layer=info.layer,
+ space=space,
+ bbox=_bbox(info),
+ attributes=attributes,
+ )
if payload and payload.get("kind") == "symbol":
source = "catalog" if name.startswith("PID_") else "xdata"
family = payload.get("family")
- node = _Node(kind=FAMILY_KIND.get(family, "equipment"), family=family, symbol=payload.get("symbol"),
- source=source, confidence=CONFIDENCE[source], **base)
+ node = _Node(
+ kind=FAMILY_KIND.get(family, "equipment"),
+ family=family,
+ symbol=payload.get("symbol"),
+ source=source,
+ confidence=CONFIDENCE[source],
+ **base,
+ )
for pname, (lx, ly, ldir, kind, radius) in payload.get("ports", {}).items():
- port = transform_port(Port(pname, float(lx), float(ly), ldir, kind, float(radius)),
- node.x, node.y, rotation, scale)
+ port = transform_port(
+ Port(pname, float(lx), float(ly), ldir, kind, float(radius)),
+ node.x,
+ node.y,
+ rotation,
+ scale,
+ )
node.ports[pname] = {**port, "edges": []}
nodes[node.id] = node
continue
@@ -4958,12 +5751,24 @@ async def build_graph(
keyword_kind = next((k for word, k in KEYWORDS.items() if word in upper), None)
tag_key = next((k for k in ATTRIBUTE_TAGS if k in attributes), None)
if keyword_kind or tag_key:
- node = _Node(kind=keyword_kind or "equipment", family=None, symbol=None, source="heuristic",
- confidence=CONFIDENCE["heuristic"], **base)
+ node = _Node(
+ kind=keyword_kind or "equipment",
+ family=None,
+ symbol=None,
+ source="heuristic",
+ confidence=CONFIDENCE["heuristic"],
+ **base,
+ )
nodes[node.id] = node
elif base["bbox"] is not None:
- candidates[info.handle] = _Node(kind="unknown_block", family=None, symbol=None, source="inferred",
- confidence=CONFIDENCE["inferred"], **base)
+ candidates[info.handle] = _Node(
+ kind="unknown_block",
+ family=None,
+ symbol=None,
+ source="inferred",
+ confidence=CONFIDENCE["inferred"],
+ **base,
+ )
# 2 — candidate edges
edges: dict[str, _Edge] = {}
@@ -4976,8 +5781,15 @@ async def build_graph(
continue
payload = await read_payload(backend, info.handle) if info.type in POLY_TYPES else None
layer = info.layer.upper()
- edge = _Edge(id=info.handle, vertices=verts, layer=info.layer, linetype=info.linetype, space=space,
- payload=payload if payload and payload.get("kind") == "line" else None, source="touch")
+ edge = _Edge(
+ id=info.handle,
+ vertices=verts,
+ layer=info.layer,
+ linetype=info.linetype,
+ space=space,
+ payload=payload if payload and payload.get("kind") == "line" else None,
+ source="touch",
+ )
if edge.payload:
edge.source = "xdata"
edge.line_class, edge.class_source = edge.payload.get("class"), "xdata"
@@ -4988,7 +5800,11 @@ async def build_graph(
edge.source = "layer"
cls = LAYER_TO_CLASS[layer]
if cls is None:
- effective = info.linetype.upper() if info.linetype.upper() != "BYLAYER" else layer_linetypes.get(layer, "")
+ effective = (
+ info.linetype.upper()
+ if info.linetype.upper() != "BYLAYER"
+ else layer_linetypes.get(layer, "")
+ )
cls = "electric" if "DASH" in effective else "signal_unknown"
edge.line_class, edge.class_source = cls, "layer"
edges[edge.id] = edge
@@ -5040,12 +5856,24 @@ async def build_graph(
nodes[node_id].ports[pname]["edges"].append(edge.id)
return {"node": node_id, "port": pname}
for node in list(nodes.values()) + list(candidates.values()):
- if node.source in ("heuristic", "inferred") and node.bbox and _on_bbox_boundary((x, y), node.bbox, tolerance):
+ if (
+ node.source in ("heuristic", "inferred")
+ and node.bbox
+ and _on_bbox_boundary((x, y), node.bbox, tolerance)
+ ):
if node.id in candidates:
nodes[node.id] = candidates.pop(node.id)
pname = f"p{len(node.ports) + 1}"
- node.ports[pname] = {"name": pname, "x": x, "y": y, "direction_deg": None, "kind": "process",
- "radius": 0.0, "inferred": True, "edges": [edge.id]}
+ node.ports[pname] = {
+ "name": pname,
+ "x": x,
+ "y": y,
+ "direction_deg": None,
+ "kind": "process",
+ "radius": 0.0,
+ "inferred": True,
+ "edges": [edge.id],
+ }
grid.add(x, y, ("port", node.id, pname))
return {"node": node.id, "port": pname}
for d, item in grid.near(x, y, tolerance):
@@ -5061,11 +5889,19 @@ async def build_graph(
for d, item in grid.near(x, y, 10.0 * tolerance):
if item[0] == "port" and (nearest is None or d < nearest[0]):
nearest = (d, item)
- dangling.append({
- "edge": edge.id, "end": end, "x": x, "y": y,
- "nearest": ({"node": nearest[1][1], "port": nearest[1][2], "distance": round(nearest[0], 6)}
- if nearest else None),
- })
+ dangling.append(
+ {
+ "edge": edge.id,
+ "end": end,
+ "x": x,
+ "y": y,
+ "nearest": (
+ {"node": nearest[1][1], "port": nearest[1][2], "distance": round(nearest[0], 6)}
+ if nearest
+ else None
+ ),
+ }
+ )
return None
for edge in list(edges.values()):
@@ -5173,16 +6009,28 @@ async def build_graph(
SECTION 17 header → `(4 tools)`; add:
```python
-@cad_tool(summary="Read the P&ID back as a graph: symbols, lines, junctions, dangling ends, confidence.", cost="read")
+@cad_tool(
+ summary="Read the P&ID back as a graph: symbols, lines, junctions, dangling ends, confidence.",
+ cost="read",
+)
@mcp.tool(
annotations={"title": "P&ID: Connectivity Graph", "readOnlyHint": True},
tags={"pid", "query"},
)
async def pid_graph(
- tolerance: Annotated[float, Field(default=0.5, gt=0, description="Endpoint-to-port snap distance (mm)")] = 0.5,
- label_search: Annotated[float, Field(default=15.0, gt=0, description="How far to look for tag/line-number text (mm)")] = 15.0,
- scope: Annotated[str, "current_space | all (every layout, current restored afterwards)"] = "current_space",
- include_foreign: Annotated[bool, "Classify blocks not placed by this server (confidence < 1)"] = True,
+ tolerance: Annotated[
+ float, Field(default=0.5, gt=0, description="Endpoint-to-port snap distance (mm)")
+ ] = 0.5,
+ label_search: Annotated[
+ float,
+ Field(default=15.0, gt=0, description="How far to look for tag/line-number text (mm)"),
+ ] = 15.0,
+ scope: Annotated[
+ str, "current_space | all (every layout, current restored afterwards)"
+ ] = "current_space",
+ include_foreign: Annotated[
+ bool, "Classify blocks not placed by this server (confidence < 1)"
+ ] = True,
include_geometry: Annotated[bool, "Include edge vertices"] = False,
ctx: Context = None,
) -> dict:
@@ -5198,7 +6046,9 @@ async def pid_graph(
"""
from engineering.pid.graph import build_graph
- return await build_graph(_backend(ctx), tolerance, label_search, scope, include_foreign, include_geometry)
+ return await build_graph(
+ _backend(ctx), tolerance, label_search, scope, include_foreign, include_geometry
+ )
```
Alias:
@@ -5245,30 +6095,91 @@ import csv
import pytest
-from engineering.pid.deliverables import deliverable, equipment_list, instrument_index, line_list, natural_key
+from engineering.pid.deliverables import (
+ deliverable,
+ equipment_list,
+ instrument_index,
+ line_list,
+ natural_key,
+)
from engineering.pid.drawlines import draw_line
from engineering.pid.graph import build_graph
from engineering.pid.insert import place_symbol
pytestmark = pytest.mark.asyncio
-INDEX_KEYS = ["tag", "func", "loop", "description", "type", "location", "connected_to", "signal_lines", "handle", "confidence"]
-LINE_KEYS = ["line_number", "line_class", "size", "service", "spec", "insulation", "from_tag", "from_port",
- "to_tag", "to_port", "length_mm", "handle", "number_source"]
-EQUIP_KEYS = ["tag", "kind", "symbol", "description", "ports", "connected_lines", "unconnected_ports", "handle", "confidence"]
+INDEX_KEYS = [
+ "tag",
+ "func",
+ "loop",
+ "description",
+ "type",
+ "location",
+ "connected_to",
+ "signal_lines",
+ "handle",
+ "confidence",
+]
+LINE_KEYS = [
+ "line_number",
+ "line_class",
+ "size",
+ "service",
+ "spec",
+ "insulation",
+ "from_tag",
+ "from_port",
+ "to_tag",
+ "to_port",
+ "length_mm",
+ "handle",
+ "number_source",
+]
+EQUIP_KEYS = [
+ "tag",
+ "kind",
+ "symbol",
+ "description",
+ "ports",
+ "connected_lines",
+ "unconnected_ports",
+ "handle",
+ "confidence",
+]
async def _pid(backend):
pump = await place_symbol(backend, "centrifugal_pump", 100, 100, tag="P-101", desc="Feed pump")
vessel = await place_symbol(backend, "vertical_vessel", 220, 120, tag="V-201")
cv = await place_symbol(backend, "globe", 160, 106, tag="FCV-101", actuator="diaphragm")
- fic = await place_symbol(backend, "instrument", 160, 160, tag="FIC-101", type="dcs", location="primary")
- await place_symbol(backend, "instrument", 100, 160, tag="PT-10", type="discrete", location="field")
- await draw_line(backend, {"handle": pump["handle"], "port": "discharge"}, {"handle": cv["handle"], "port": "in"},
- size="100", service="P", spec="CS1")
- await draw_line(backend, {"handle": cv["handle"], "port": "out"}, {"handle": vessel["handle"], "port": "N3"},
- size="100", service="P", spec="CS1")
- await draw_line(backend, {"handle": fic["handle"]}, {"handle": cv["handle"], "port": "signal"}, line_class="pneumatic")
+ fic = await place_symbol(
+ backend, "instrument", 160, 160, tag="FIC-101", type="dcs", location="primary"
+ )
+ await place_symbol(
+ backend, "instrument", 100, 160, tag="PT-10", type="discrete", location="field"
+ )
+ await draw_line(
+ backend,
+ {"handle": pump["handle"], "port": "discharge"},
+ {"handle": cv["handle"], "port": "in"},
+ size="100",
+ service="P",
+ spec="CS1",
+ )
+ await draw_line(
+ backend,
+ {"handle": cv["handle"], "port": "out"},
+ {"handle": vessel["handle"], "port": "N3"},
+ size="100",
+ service="P",
+ spec="CS1",
+ )
+ await draw_line(
+ backend,
+ {"handle": fic["handle"]},
+ {"handle": cv["handle"], "port": "signal"},
+ line_class="pneumatic",
+ )
return await build_graph(backend)
@@ -5278,7 +6189,11 @@ async def test_instrument_index_rows(backend):
assert [list(r) for r in rows] == [INDEX_KEYS] * 2
assert [r["tag"] for r in rows] == ["FIC-101", "PT-10"]
fic = rows[0]
- assert fic["description"] == "Flow Indicating Controller" and fic["type"] == "dcs" and fic["location"] == "primary"
+ assert (
+ fic["description"] == "Flow Indicating Controller"
+ and fic["type"] == "dcs"
+ and fic["location"] == "primary"
+ )
assert fic["connected_to"] == "FCV-101" and fic["signal_lines"] == 1
assert rows[1]["signal_lines"] == 0 and rows[1]["connected_to"] == ""
@@ -5300,7 +6215,9 @@ async def test_equipment_list_rows(backend):
assert [list(r) for r in rows] == [EQUIP_KEYS] * 3
assert [r["tag"] for r in rows] == ["FCV-101", "P-101", "V-201"]
pump = next(r for r in rows if r["tag"] == "P-101")
- assert pump["description"] == "Feed pump" and pump["ports"] == 2 and pump["connected_lines"] == 1
+ assert (
+ pump["description"] == "Feed pump" and pump["ports"] == 2 and pump["connected_lines"] == 1
+ )
assert pump["unconnected_ports"] == "suction"
@@ -5382,20 +6299,24 @@ def instrument_index(graph: dict) -> list[dict]:
for ref in (edge["from"], edge["to"]):
if ref and "node" in ref and ref["node"] != node["id"]:
other = nodes[ref["node"]]
- connected.append(other.get("tag") or (edge.get("line_number") or other["id"]))
+ connected.append(
+ other.get("tag") or (edge.get("line_number") or other["id"])
+ )
variant = (node.get("symbol") or "", node.get("block_name") or "")
- rows.append({
- "tag": node.get("tag"),
- "func": parsed.get("letters"),
- "loop": parsed.get("loop"),
- "description": parsed.get("description"),
- "type": _bubble_part(node, 2),
- "location": _bubble_part(node, 3),
- "connected_to": ", ".join(dict.fromkeys(connected)),
- "signal_lines": signal_lines,
- "handle": node["id"],
- "confidence": node["confidence"],
- })
+ rows.append(
+ {
+ "tag": node.get("tag"),
+ "func": parsed.get("letters"),
+ "loop": parsed.get("loop"),
+ "description": parsed.get("description"),
+ "type": _bubble_part(node, 2),
+ "location": _bubble_part(node, 3),
+ "connected_to": ", ".join(dict.fromkeys(connected)),
+ "signal_lines": signal_lines,
+ "handle": node["id"],
+ "confidence": node["confidence"],
+ }
+ )
rows.sort(key=lambda r: natural_key(r["tag"]))
return rows
@@ -5417,21 +6338,23 @@ def line_list(graph: dict) -> list[dict]:
for edge in graph["edges"]:
from_tag, from_port = _tag_of(edge["from"], nodes)
to_tag, to_port = _tag_of(edge["to"], nodes)
- rows.append({
- "line_number": edge.get("line_number"),
- "line_class": edge.get("line_class"),
- "size": edge.get("size"),
- "service": edge.get("service"),
- "spec": edge.get("spec"),
- "insulation": edge.get("insulation"),
- "from_tag": from_tag,
- "from_port": from_port,
- "to_tag": to_tag,
- "to_port": to_port,
- "length_mm": round(float(edge.get("length") or 0.0), 1),
- "handle": edge["id"],
- "number_source": edge.get("number_source"),
- })
+ rows.append(
+ {
+ "line_number": edge.get("line_number"),
+ "line_class": edge.get("line_class"),
+ "size": edge.get("size"),
+ "service": edge.get("service"),
+ "spec": edge.get("spec"),
+ "insulation": edge.get("insulation"),
+ "from_tag": from_tag,
+ "from_port": from_port,
+ "to_tag": to_tag,
+ "to_port": to_port,
+ "length_mm": round(float(edge.get("length") or 0.0), 1),
+ "handle": edge["id"],
+ "number_source": edge.get("number_source"),
+ }
+ )
rows.sort(key=lambda r: natural_key(r["line_number"]))
return rows
@@ -5443,17 +6366,20 @@ def equipment_list(graph: dict) -> list[dict]:
continue
ports = node["ports"]
connected = sum(1 for p in ports.values() if p["edges"])
- rows.append({
- "tag": node.get("tag"),
- "kind": node["kind"],
- "symbol": node.get("symbol"),
- "description": node.get("description") or (node.get("tag_parsed") or {}).get("equipment_kind"),
- "ports": len(ports),
- "connected_lines": sum(len(p["edges"]) for p in ports.values()),
- "unconnected_ports": ", ".join(name for name, p in ports.items() if not p["edges"]),
- "handle": node["id"],
- "confidence": node["confidence"],
- })
+ rows.append(
+ {
+ "tag": node.get("tag"),
+ "kind": node["kind"],
+ "symbol": node.get("symbol"),
+ "description": node.get("description")
+ or (node.get("tag_parsed") or {}).get("equipment_kind"),
+ "ports": len(ports),
+ "connected_lines": sum(len(p["edges"]) for p in ports.values()),
+ "unconnected_ports": ", ".join(name for name, p in ports.items() if not p["edges"]),
+ "handle": node["id"],
+ "confidence": node["confidence"],
+ }
+ )
rows.sort(key=lambda r: natural_key(r["tag"]))
return rows
@@ -5472,10 +6398,16 @@ def write_csv(rows: list[dict], path: str) -> str:
return str(target)
-_BUILDERS = {"instrument_index": instrument_index, "line_list": line_list, "equipment_list": equipment_list}
+_BUILDERS = {
+ "instrument_index": instrument_index,
+ "line_list": line_list,
+ "equipment_list": equipment_list,
+}
-async def deliverable(backend: AutoCADBackend, which: str, csv_path: str | None = None, **graph_kwargs) -> dict:
+async def deliverable(
+ backend: AutoCADBackend, which: str, csv_path: str | None = None, **graph_kwargs
+) -> dict:
graph = await build_graph(backend, **graph_kwargs)
rows = _BUILDERS[which](graph)
written = write_csv(rows, csv_path) if csv_path else None
@@ -5487,8 +6419,13 @@ async def deliverable(backend: AutoCADBackend, which: str, csv_path: str | None
- [ ] **Step 5: Register the three tools** (SECTION 17 → `(7 tools)`)
```python
-@cad_tool(summary="Instrument index derived from the P&ID graph (tag, function, loop, connections); optional CSV.", cost="read")
-@mcp.tool(annotations={"title": "P&ID: Instrument Index", "readOnlyHint": True}, tags={"pid", "query"})
+@cad_tool(
+ summary="Instrument index derived from the P&ID graph (tag, function, loop, connections); optional CSV.",
+ cost="read",
+)
+@mcp.tool(
+ annotations={"title": "P&ID: Instrument Index", "readOnlyHint": True}, tags={"pid", "query"}
+)
async def pid_instrument_index(
csv_path: Annotated[str | None, "Write the rows as CSV here (inside ALLOWED_PATHS)"] = None,
tolerance: Annotated[float, Field(default=0.5, gt=0)] = 0.5,
@@ -5502,10 +6439,15 @@ async def pid_instrument_index(
"""
from engineering.pid.deliverables import deliverable
- return await deliverable(_backend(ctx), "instrument_index", csv_path, tolerance=tolerance, scope=scope)
+ return await deliverable(
+ _backend(ctx), "instrument_index", csv_path, tolerance=tolerance, scope=scope
+ )
-@cad_tool(summary="Line list derived from the P&ID graph (number, class, size, spec, from/to); optional CSV.", cost="read")
+@cad_tool(
+ summary="Line list derived from the P&ID graph (number, class, size, spec, from/to); optional CSV.",
+ cost="read",
+)
@mcp.tool(annotations={"title": "P&ID: Line List", "readOnlyHint": True}, tags={"pid", "query"})
async def pid_line_list(
csv_path: Annotated[str | None, "Write the rows as CSV here (inside ALLOWED_PATHS)"] = None,
@@ -5519,8 +6461,13 @@ async def pid_line_list(
return await deliverable(_backend(ctx), "line_list", csv_path, tolerance=tolerance, scope=scope)
-@cad_tool(summary="Equipment list derived from the P&ID graph (tag, kind, ports, connections); optional CSV.", cost="read")
-@mcp.tool(annotations={"title": "P&ID: Equipment List", "readOnlyHint": True}, tags={"pid", "query"})
+@cad_tool(
+ summary="Equipment list derived from the P&ID graph (tag, kind, ports, connections); optional CSV.",
+ cost="read",
+)
+@mcp.tool(
+ annotations={"title": "P&ID: Equipment List", "readOnlyHint": True}, tags={"pid", "query"}
+)
async def pid_equipment_list(
csv_path: Annotated[str | None, "Write the rows as CSV here (inside ALLOWED_PATHS)"] = None,
tolerance: Annotated[float, Field(default=0.5, gt=0)] = 0.5,
@@ -5530,7 +6477,9 @@ async def pid_equipment_list(
"""Every equipment, valve and connector node with its ports and how many lines reach them."""
from engineering.pid.deliverables import deliverable
- return await deliverable(_backend(ctx), "equipment_list", csv_path, tolerance=tolerance, scope=scope)
+ return await deliverable(
+ _backend(ctx), "equipment_list", csv_path, tolerance=tolerance, scope=scope
+ )
```
Aliases:
@@ -5581,8 +6530,14 @@ from engineering.preflight import pid_plan_warnings, preflight_drawing
pytestmark = pytest.mark.asyncio
-PID_FOCUSES = ("pid_dangling_line", "pid_duplicate_tag", "pid_incompatible_connection",
- "pid_untagged_instrument", "pid_illegal_tag", "pid_unconnected_equipment")
+PID_FOCUSES = (
+ "pid_dangling_line",
+ "pid_duplicate_tag",
+ "pid_incompatible_connection",
+ "pid_untagged_instrument",
+ "pid_illegal_tag",
+ "pid_unconnected_equipment",
+)
def test_focuses_are_in_the_closed_enum():
@@ -5593,10 +6548,21 @@ def test_focuses_are_in_the_closed_enum():
async def _clean(backend):
pump = await place_symbol(backend, "centrifugal_pump", 100, 100, tag="P-101")
cv = await place_symbol(backend, "globe", 160, 106, tag="FCV-101", actuator="diaphragm")
- fic = await place_symbol(backend, "instrument", 160, 140, tag="FIC-101", type="dcs", location="primary")
- await draw_line(backend, {"handle": pump["handle"], "port": "discharge"}, {"handle": cv["handle"], "port": "in"})
+ fic = await place_symbol(
+ backend, "instrument", 160, 140, tag="FIC-101", type="dcs", location="primary"
+ )
+ await draw_line(
+ backend,
+ {"handle": pump["handle"], "port": "discharge"},
+ {"handle": cv["handle"], "port": "in"},
+ )
await draw_line(backend, {"handle": cv["handle"], "port": "out"}, {"x": 220, "y": 106})
- await draw_line(backend, {"handle": fic["handle"]}, {"handle": cv["handle"], "port": "signal"}, line_class="electric")
+ await draw_line(
+ backend,
+ {"handle": fic["handle"]},
+ {"handle": cv["handle"], "port": "signal"},
+ line_class="electric",
+ )
return pump, cv, fic
@@ -5623,24 +6589,42 @@ async def test_duplicate_and_illegal_and_untagged(backend):
await place_symbol(backend, "instrument", 60, 0, tag="FCI-102")
await place_symbol(backend, "instrument", 90, 0)
await place_symbol(backend, "gate", 120, 0, tag="FIC-101")
- issues = await backend.drawing_critique(["pid_duplicate_tag", "pid_illegal_tag", "pid_untagged_instrument"])
+ issues = await backend.drawing_critique(
+ ["pid_duplicate_tag", "pid_illegal_tag", "pid_untagged_instrument"]
+ )
by_focus = {}
for issue in issues:
by_focus.setdefault(issue.focus, []).append(issue)
- assert len(by_focus["pid_duplicate_tag"]) == 1 and len(by_focus["pid_duplicate_tag"][0].handles) == 3
+ assert (
+ len(by_focus["pid_duplicate_tag"]) == 1
+ and len(by_focus["pid_duplicate_tag"][0].handles) == 3
+ )
assert by_focus["pid_duplicate_tag"][0].severity == "error"
- assert len(by_focus["pid_illegal_tag"]) == 1 and by_focus["pid_illegal_tag"][0].severity == "warning"
+ assert (
+ len(by_focus["pid_illegal_tag"]) == 1
+ and by_focus["pid_illegal_tag"][0].severity == "warning"
+ )
assert len(by_focus["pid_untagged_instrument"]) == 1
async def test_incompatible_connection(backend):
pump = await place_symbol(backend, "centrifugal_pump", 100, 100, tag="P-101")
fic = await place_symbol(backend, "instrument", 100, 160, tag="FIC-1")
- await draw_line(backend, {"handle": fic["handle"]}, {"handle": pump["handle"], "port": "discharge"}, line_class="electric")
+ await draw_line(
+ backend,
+ {"handle": fic["handle"]},
+ {"handle": pump["handle"], "port": "discharge"},
+ line_class="electric",
+ )
issues = await backend.drawing_critique(["pid_incompatible_connection"])
assert len(issues) == 1 and issues[0].severity == "error"
- assert issues[0].detail == {"expected": "signal", "actual": "process", "port": "discharge",
- "line_class": "electric", "hint": issues[0].detail["hint"]}
+ assert issues[0].detail == {
+ "expected": "signal",
+ "actual": "process",
+ "port": "discharge",
+ "line_class": "electric",
+ "hint": issues[0].detail["hint"],
+ }
async def test_unconnected_equipment_is_informational(backend):
@@ -5661,20 +6645,26 @@ async def test_mechanical_drawing_is_untouched_by_pid_focuses(backend):
async def test_refine_reports_pid_issues_as_manual_and_moves_nothing(backend):
await _clean(backend)
before = await backend.entity_count()
- result = await backend.drawing_refine(max_rounds=1, min_score=100.0, focus=list(PID_FOCUSES), dry_run=True)
+ result = await backend.drawing_refine(
+ max_rounds=1, min_score=100.0, focus=list(PID_FOCUSES), dry_run=True
+ )
assert all(a["status"] == "manual_required" for r in result["rounds"] for a in r["actions"])
assert await backend.entity_count() == before
def test_preflight_flags_a_pid_intent_on_the_wrong_layer_set():
- result = preflight_drawing("P&ID for the feed section", {"units": "mm", "part_type": "pid",
- "dimensions": {}, "tolerance_policy": "none"},
- layer_set_id="mech")
+ result = preflight_drawing(
+ "P&ID for the feed section",
+ {"units": "mm", "part_type": "pid", "dimensions": {}, "tolerance_policy": "none"},
+ layer_set_id="mech",
+ )
codes = [c.code for c in result.conflicts]
assert "LAYER_SET_INTENT_MISMATCH" in codes
- ok = preflight_drawing("P&ID for the feed section", {"units": "mm", "part_type": "pid",
- "dimensions": {}, "tolerance_policy": "none"},
- layer_set_id="pid")
+ ok = preflight_drawing(
+ "P&ID for the feed section",
+ {"units": "mm", "part_type": "pid", "dimensions": {}, "tolerance_policy": "none"},
+ layer_set_id="pid",
+ )
assert "LAYER_SET_INTENT_MISMATCH" not in [c.code for c in ok.conflicts]
assert pid_plan_warnings("Bearing housing", "mech") == []
assert pid_plan_warnings("piping and instrumentation diagram", "mech")
@@ -5687,12 +6677,12 @@ def test_preflight_flags_a_pid_intent_on_the_wrong_layer_set():
In `engineering/plan_spec.py` add to both `CritiqueFocus` and `ALL_CRITIQUE_FOCUSES`, after `"gdt"`:
```python
- "pid_dangling_line",
- "pid_duplicate_tag",
- "pid_incompatible_connection",
- "pid_untagged_instrument",
- "pid_illegal_tag",
- "pid_unconnected_equipment",
+("pid_dangling_line",)
+("pid_duplicate_tag",)
+("pid_incompatible_connection",)
+("pid_untagged_instrument",)
+("pid_illegal_tag",)
+("pid_unconnected_equipment",)
```
- [ ] **Step 4: Write the checks**
@@ -5735,25 +6725,44 @@ def _dangling(graph: dict) -> list[Issue]:
for d in graph["dangling"]:
nearest = d.get("nearest")
if nearest:
- hint = (f"pid_line_draw(from_={{'handle': ..., 'port': ...}}, to={{'handle': '{nearest['node']}', "
- f"'port': '{nearest['port']}'}}) — nearest port {nearest['distance']} mm away")
+ hint = (
+ f"pid_line_draw(from_={{'handle': ..., 'port': ...}}, to={{'handle': '{nearest['node']}', "
+ f"'port': '{nearest['port']}'}}) — nearest port {nearest['distance']} mm away"
+ )
else:
hint = "no port within reach; connect this end with pid_line_draw or delete the line"
- out.append(Issue("error", "pid_dangling_line",
- f"Line {d['edge']} {d['end']} end at ({d['x']:.2f}, {d['y']:.2f}) connects to nothing.",
- [d["edge"]], {"end": d["end"], "x": d["x"], "y": d["y"], "nearest": nearest, "hint": hint}))
+ out.append(
+ Issue(
+ "error",
+ "pid_dangling_line",
+ f"Line {d['edge']} {d['end']} end at ({d['x']:.2f}, {d['y']:.2f}) connects to nothing.",
+ [d["edge"]],
+ {"end": d["end"], "x": d["x"], "y": d["y"], "nearest": nearest, "hint": hint},
+ )
+ )
return out
def _duplicate_tags(graph: dict) -> list[Issue]:
seen: dict[str, list[str]] = {}
for node in graph["nodes"]:
- if node.get("tag") and node["kind"] != "connector": # connectors carry free text ("TO P&ID-002")
+ if (
+ node.get("tag") and node["kind"] != "connector"
+ ): # connectors carry free text ("TO P&ID-002")
seen.setdefault(node["tag"].upper(), []).append(node["id"])
return [
- Issue("error", "pid_duplicate_tag", f"Tag {tag} is carried by {len(handles)} symbols.", handles,
- {"tag": tag, "hint": "block_set_attributes(handle, {'TAG'|'FUNC'/'LOOP': ...}) on all but one"})
- for tag, handles in seen.items() if len(handles) > 1
+ Issue(
+ "error",
+ "pid_duplicate_tag",
+ f"Tag {tag} is carried by {len(handles)} symbols.",
+ handles,
+ {
+ "tag": tag,
+ "hint": "block_set_attributes(handle, {'TAG'|'FUNC'/'LOOP': ...}) on all but one",
+ },
+ )
+ for tag, handles in seen.items()
+ if len(handles) > 1
]
@@ -5770,21 +6779,35 @@ def _incompatible(graph: dict) -> list[Issue]:
port = nodes[ref["node"]]["ports"].get(ref["port"], {})
actual = port.get("kind")
if actual in ("process", "signal") and actual != cls.kind:
- out.append(Issue(
- "error", "pid_incompatible_connection",
- f"{cls.kind} line {edge['id']} ends on {actual} port {ref['port']} of {ref['node']}.",
- [edge["id"], ref["node"]],
- {"expected": cls.kind, "actual": actual, "port": ref["port"], "line_class": edge["line_class"],
- "hint": f"redraw with a {actual} line class or connect to a {cls.kind} port"},
- ))
+ out.append(
+ Issue(
+ "error",
+ "pid_incompatible_connection",
+ f"{cls.kind} line {edge['id']} ends on {actual} port {ref['port']} of {ref['node']}.",
+ [edge["id"], ref["node"]],
+ {
+ "expected": cls.kind,
+ "actual": actual,
+ "port": ref["port"],
+ "line_class": edge["line_class"],
+ "hint": f"redraw with a {actual} line class or connect to a {cls.kind} port",
+ },
+ )
+ )
return out
def _untagged(graph: dict) -> list[Issue]:
return [
- Issue("warning", "pid_untagged_instrument", f"Instrument {n['id']} has no tag.", [n["id"]],
- {"hint": "block_set_attributes(handle, {'FUNC': 'FIC', 'LOOP': '101'})"})
- for n in graph["nodes"] if n["kind"] == "instrument" and not n.get("tag")
+ Issue(
+ "warning",
+ "pid_untagged_instrument",
+ f"Instrument {n['id']} has no tag.",
+ [n["id"]],
+ {"hint": "block_set_attributes(handle, {'FUNC': 'FIC', 'LOOP': '101'})"},
+ )
+ for n in graph["nodes"]
+ if n["kind"] == "instrument" and not n.get("tag")
]
@@ -5795,18 +6818,31 @@ def _illegal(graph: dict) -> list[Issue]:
continue
parsed = parse_tag(n["tag"], kind="instrument" if n["kind"] == "instrument" else "auto")
if not parsed["valid"]:
- out.append(Issue("warning", "pid_illegal_tag", f"Tag {n['tag']} on {n['id']} is not ISA-5.1: "
- f"{parsed['errors'][0]}", [n["id"]],
- {"errors": parsed["errors"], "hint": "block_set_attributes with a valid tag"}))
+ out.append(
+ Issue(
+ "warning",
+ "pid_illegal_tag",
+ f"Tag {n['tag']} on {n['id']} is not ISA-5.1: {parsed['errors'][0]}",
+ [n["id"]],
+ {"errors": parsed["errors"], "hint": "block_set_attributes with a valid tag"},
+ )
+ )
return out
def _unconnected(graph: dict) -> list[Issue]:
return [
- Issue("info", "pid_unconnected_equipment", f"{n['kind']} {n.get('tag') or n['id']} has no line on any port.",
- [n["id"]], {"hint": "informational — connect it with pid_line_draw or leave it if intended"})
+ Issue(
+ "info",
+ "pid_unconnected_equipment",
+ f"{n['kind']} {n.get('tag') or n['id']} has no line on any port.",
+ [n["id"]],
+ {"hint": "informational — connect it with pid_line_draw or leave it if intended"},
+ )
for n in graph["nodes"]
- if n["kind"] in ("equipment", "valve") and n["ports"] and not any(p["edges"] for p in n["ports"].values())
+ if n["kind"] in ("equipment", "valve")
+ and n["ports"]
+ and not any(p["edges"] for p in n["ports"].values())
]
@@ -5872,7 +6908,9 @@ and change the dispatch loop in `run_critique` to
In `engineering/preflight.py` add near the top:
```python
-PID_INTENT_RE = re.compile(r"\b(p&id|pid|piping and instrumentation|process flow|instrument)\b", re.IGNORECASE)
+PID_INTENT_RE = re.compile(
+ r"\b(p&id|pid|piping and instrumentation|process flow|instrument)\b", re.IGNORECASE
+)
def intent_reads_as_pid(intent: str) -> bool:
@@ -5881,16 +6919,21 @@ def intent_reads_as_pid(intent: str) -> bool:
def pid_plan_warnings(intent: str, layer_set_id: str) -> list[str]:
if intent_reads_as_pid(intent) and str(layer_set_id).strip().lower() != "pid":
- return [f"intent reads as a P&ID but the layer set is '{layer_set_id}'; pass layer_set_id='pid'"]
+ return [
+ f"intent reads as a P&ID but the layer set is '{layer_set_id}'; pass layer_set_id='pid'"
+ ]
return []
```
(add `import re` to the module imports), and inside `preflight_drawing` after the `parameter_rules` loop:
```python
- for message in pid_plan_warnings(intent, normalized_layer_set):
- conflicts.append(PreflightConflict("LAYER_SET_INTENT_MISMATCH", "layer_set_id",
- [layer_set_id, "pid"], message))
+for message in pid_plan_warnings(intent, normalized_layer_set):
+ conflicts.append(
+ PreflightConflict(
+ "LAYER_SET_INTENT_MISMATCH", "layer_set_id", [layer_set_id, "pid"], message
+ )
+ )
```
In `server.py::drawing_plan`, replace `return _dc(plan)` with
@@ -5953,7 +6996,12 @@ async def test_example_draws_a_connected_pid(backend):
result = await run_spec(backend, EXAMPLE_SPEC)
assert set(result["handles"]) == {"P-101", "V-201", "FCV-101", "FIC-101", "OP-1"}
graph = result["graph"]
- assert graph["stats"]["nodes_by_kind"] == {"connector": 1, "equipment": 2, "instrument": 1, "valve": 1}
+ assert graph["stats"]["nodes_by_kind"] == {
+ "connector": 1,
+ "equipment": 2,
+ "instrument": 1,
+ "valve": 1,
+ }
assert len(graph["edges"]) == 4 and graph["stats"]["dangling"] == 0
assert result["critique"] == [] and result["crossings_total"] == 0
assert backend.get_plan_spec()["layer_set_id"] == "pid"
@@ -5976,13 +7024,19 @@ async def test_dry_run_routes_in_memory_and_writes_nothing(backend):
assert await backend.entity_count() == before
-@pytest.mark.parametrize("mutate, fragment", [
- (lambda s: s["equipment"].append({"id": "P-101", "symbol": "fan", "x": 0, "y": 0}), "equipment[2].id"),
- (lambda s: s["lines"][0].update({"class": "steam"}), "lines[0].class"),
- (lambda s: s["instruments"][0].pop("x"), "instruments[0].x"),
- (lambda s: s.update({"bogus": []}), "bogus"),
- (lambda s: s["connectors"][0].update({"to": "V-201.N1"}), "connectors[0]"),
-])
+@pytest.mark.parametrize(
+ "mutate, fragment",
+ [
+ (
+ lambda s: s["equipment"].append({"id": "P-101", "symbol": "fan", "x": 0, "y": 0}),
+ "equipment[2].id",
+ ),
+ (lambda s: s["lines"][0].update({"class": "steam"}), "lines[0].class"),
+ (lambda s: s["instruments"][0].pop("x"), "instruments[0].x"),
+ (lambda s: s.update({"bogus": []}), "bogus"),
+ (lambda s: s["connectors"][0].update({"to": "V-201.N1"}), "connectors[0]"),
+ ],
+)
def test_validation_names_the_path(mutate, fragment):
spec = copy.deepcopy(EXAMPLE_SPEC)
mutate(spec)
@@ -6033,27 +7087,78 @@ if TYPE_CHECKING:
EXAMPLE_SPEC: dict = {
"sheet": {"layer_set": "pid", "sheet_size": "A3", "scale": 1.0, "intent": "Feed section P&ID"},
"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, "tag": "V-201",
- "params": {"width": 20, "height": 40,
- "nozzles": [{"name": "N1", "side": "top", "fraction": 0.5},
- {"name": "N2", "side": "left", "fraction": 0.3}]}},
+ {
+ "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,
+ "tag": "V-201",
+ "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"},
+ {
+ "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"},
+ {
+ "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": "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"},
+ {
+ "id": "OP-1",
+ "direction": "out",
+ "x": 300,
+ "y": 120,
+ "tag": "TO P&ID-002",
+ "link": "L-17",
+ "from": "V-201.N1",
+ "class": "process_major",
+ },
],
}
@@ -6062,9 +7167,34 @@ _ITEM_OPTIONS = {
"equipment": {"symbol", "x", "y", "rotation", "scale", "tag", "desc", "params", "shape"},
"valves": {"symbol", "x", "y", "rotation", "scale", "tag", "desc", "actuator", "fail"},
"instruments": {"x", "y", "rotation", "scale", "tag", "type", "location"},
- "connectors": {"x", "y", "rotation", "scale", "tag", "link", "direction", "from", "to", "class", "number"},
+ "connectors": {
+ "x",
+ "y",
+ "rotation",
+ "scale",
+ "tag",
+ "link",
+ "direction",
+ "from",
+ "to",
+ "class",
+ "number",
+ },
+}
+_LINE_KEYS = {
+ "from",
+ "to",
+ "class",
+ "number",
+ "size",
+ "service",
+ "spec",
+ "insulation",
+ "route",
+ "stub",
+ "label",
+ "arrow",
}
-_LINE_KEYS = {"from", "to", "class", "number", "size", "service", "spec", "insulation", "route", "stub", "label", "arrow"}
def _num(value, where):
@@ -6157,7 +7287,12 @@ def _spec_for(section: str, item: dict):
return resolve("instrument", type=item.get("type"), location=item.get("location"))
if section == "connectors":
return resolve("offpage", direction=item["direction"])
- return resolve(item["symbol"], actuator=item.get("actuator"), params=item.get("params"), shape=item.get("shape"))
+ return resolve(
+ item["symbol"],
+ actuator=item.get("actuator"),
+ params=item.get("params"),
+ shape=item.get("shape"),
+ )
def _plan_lines(norm: dict) -> list[dict]:
@@ -6166,8 +7301,12 @@ def _plan_lines(norm: dict) -> list[dict]:
for section in ("equipment", "valves", "instruments", "connectors"):
for item in norm[section]:
spec = _spec_for(section, item)
- ports = {p.name: transform_port(p, item["x"], item["y"], item.get("rotation", 0.0), item.get("scale", 1.0))
- for p in spec.ports}
+ ports = {
+ p.name: transform_port(
+ p, item["x"], item["y"], item.get("rotation", 0.0), item.get("scale", 1.0)
+ )
+ for p in spec.ports
+ }
placed[item["id"]] = {"family": spec.family, "ports": ports}
def endpoint(ref: str) -> dict:
@@ -6192,7 +7331,8 @@ def _plan_lines(norm: dict) -> list[dict]:
return (ep["x"], ep["y"]), ep["direction_deg"]
all_lines = list(norm["lines"]) + [
- {"from": c["from"], "to": f"{c['id']}", "class": c["class"]} if c["direction"] == "out"
+ {"from": c["from"], "to": f"{c['id']}", "class": c["class"]}
+ if c["direction"] == "out"
else {"from": f"{c['id']}", "to": c["to"], "class": c["class"]}
for c in norm["connectors"]
]
@@ -6201,10 +7341,20 @@ def _plan_lines(norm: dict) -> list[dict]:
start, end = endpoint(line["from"]), endpoint(line["to"])
s, sd = anchor(start, end)
e, ed = anchor(end, start)
- vertices = route(s, sd, e, ed, stub=float(line.get("stub", 5.0)), mode=line.get("route", "auto"))
+ vertices = route(
+ s, sd, e, ed, stub=float(line.get("stub", 5.0)), mode=line.get("route", "auto")
+ )
crossings = count_crossings(vertices, [p["vertices"] for p in planned])
- planned.append({"index": i, "from": line["from"], "to": line["to"], "class": line["class"],
- "vertices": [[x, y] for x, y in vertices], "crossings": crossings})
+ planned.append(
+ {
+ "index": i,
+ "from": line["from"],
+ "to": line["to"],
+ "class": line["class"],
+ "vertices": [[x, y] for x, y in vertices],
+ "crossings": crossings,
+ }
+ )
return planned
@@ -6212,12 +7362,22 @@ async def run_spec(backend: AutoCADBackend, spec: dict, dry_run: bool = False) -
norm = validate_spec(spec)
if dry_run:
planned = _plan_lines(norm)
- return {"dry_run": True, "items": sum(len(norm[s]) for s in ("equipment", "valves", "instruments", "connectors")),
- "lines": planned, "crossings_total": sum(p["crossings"] for p in planned)}
+ return {
+ "dry_run": True,
+ "items": sum(
+ len(norm[s]) for s in ("equipment", "valves", "instruments", "connectors")
+ ),
+ "lines": planned,
+ "crossings_total": sum(p["crossings"] for p in planned),
+ }
sheet = norm["sheet"]
if backend.get_plan_spec() is None:
- await backend.drawing_plan(sheet.get("intent", "P&ID"), sheet.get("sheet_size", "A3"),
- float(sheet.get("scale", 1.0)), sheet.get("layer_set", "pid"))
+ await backend.drawing_plan(
+ sheet.get("intent", "P&ID"),
+ sheet.get("sheet_size", "A3"),
+ float(sheet.get("scale", 1.0)),
+ sheet.get("layer_set", "pid"),
+ )
begun = await backend.transaction_begin()
if not isinstance(begun, dict) or not begun.get("ok"):
raise RuntimeError(f"pid_from_spec: could not open a transaction: {begun}")
@@ -6229,40 +7389,107 @@ async def run_spec(backend: AutoCADBackend, spec: dict, dry_run: bool = False) -
where = f"{section}[{i}]"
try:
if section == "instruments":
- placed = await place_symbol(backend, "instrument", item["x"], item["y"], item.get("rotation", 0.0),
- item.get("scale", 1.0), item.get("tag"), None, None, None, None,
- type=item.get("type"), location=item.get("location"))
+ placed = await place_symbol(
+ backend,
+ "instrument",
+ item["x"],
+ item["y"],
+ item.get("rotation", 0.0),
+ item.get("scale", 1.0),
+ item.get("tag"),
+ None,
+ None,
+ None,
+ None,
+ type=item.get("type"),
+ location=item.get("location"),
+ )
elif section == "connectors":
- placed = await place_symbol(backend, "offpage", item["x"], item["y"], item.get("rotation", 0.0),
- item.get("scale", 1.0), item.get("tag"), None, None, item.get("link"),
- None, direction=item["direction"])
+ placed = await place_symbol(
+ backend,
+ "offpage",
+ item["x"],
+ item["y"],
+ item.get("rotation", 0.0),
+ item.get("scale", 1.0),
+ item.get("tag"),
+ None,
+ None,
+ item.get("link"),
+ None,
+ direction=item["direction"],
+ )
else:
- placed = await place_symbol(backend, item["symbol"], item["x"], item["y"], item.get("rotation", 0.0),
- item.get("scale", 1.0), item.get("tag"), item.get("desc"),
- item.get("fail"), None, None, actuator=item.get("actuator"),
- params=item.get("params"), shape=item.get("shape"))
+ placed = await place_symbol(
+ backend,
+ item["symbol"],
+ item["x"],
+ item["y"],
+ item.get("rotation", 0.0),
+ item.get("scale", 1.0),
+ item.get("tag"),
+ item.get("desc"),
+ item.get("fail"),
+ None,
+ None,
+ actuator=item.get("actuator"),
+ params=item.get("params"),
+ shape=item.get("shape"),
+ )
except ValueError as exc:
raise ValueError(f"{where}: {exc}") from exc
handles[item["id"]] = placed["handle"]
def ref(value: str) -> dict:
item_id, _, port = value.partition(".")
- return {"handle": handles[item_id], "port": port} if port else {"handle": handles[item_id]}
+ return (
+ {"handle": handles[item_id], "port": port} if port else {"handle": handles[item_id]}
+ )
all_lines = [(f"lines[{i}]", line) for i, line in enumerate(norm["lines"])]
for i, conn in enumerate(norm["connectors"]):
if conn["direction"] == "out":
- all_lines.append((f"connectors[{i}]", {"from": conn["from"], "to": conn["id"], "class": conn["class"],
- "number": conn.get("number")}))
+ all_lines.append(
+ (
+ f"connectors[{i}]",
+ {
+ "from": conn["from"],
+ "to": conn["id"],
+ "class": conn["class"],
+ "number": conn.get("number"),
+ },
+ )
+ )
else:
- all_lines.append((f"connectors[{i}]", {"from": conn["id"], "to": conn["to"], "class": conn["class"],
- "number": conn.get("number")}))
+ all_lines.append(
+ (
+ f"connectors[{i}]",
+ {
+ "from": conn["id"],
+ "to": conn["to"],
+ "class": conn["class"],
+ "number": conn.get("number"),
+ },
+ )
+ )
for where, line in all_lines:
try:
- drawn = await draw_line(backend, ref(line["from"]), ref(line["to"]), line["class"],
- line.get("route", "auto"), float(line.get("stub", 5.0)), line.get("number"),
- line.get("size"), line.get("service"), line.get("spec"), line.get("insulation"),
- None, bool(line.get("label", True)), line.get("arrow"))
+ drawn = await draw_line(
+ backend,
+ ref(line["from"]),
+ ref(line["to"]),
+ line["class"],
+ line.get("route", "auto"),
+ float(line.get("stub", 5.0)),
+ line.get("number"),
+ line.get("size"),
+ line.get("service"),
+ line.get("spec"),
+ line.get("insulation"),
+ None,
+ bool(line.get("label", True)),
+ line.get("arrow"),
+ )
except ValueError as exc:
raise ValueError(f"{where}: {exc}") from exc
crossings_total += drawn["crossings"]
@@ -6272,19 +7499,30 @@ async def run_spec(backend: AutoCADBackend, spec: dict, dry_run: bool = False) -
await backend.transaction_commit()
graph = await build_graph(backend)
critique = [issue.to_dict() for focus in PID_FOCUSES for issue in issues_for(focus, graph)]
- return {"handles": handles, "graph": graph, "critique": critique, "crossings_total": crossings_total}
+ return {
+ "handles": handles,
+ "graph": graph,
+ "critique": critique,
+ "crossings_total": crossings_total,
+ }
```
- [ ] **Step 4: Register the tool** (SECTION 17 → `(8 tools)`)
```python
-@cad_tool(summary="Draw a whole P&ID from one declarative spec in one transaction; returns graph + critique.", cost="mutate")
+@cad_tool(
+ summary="Draw a whole P&ID from one declarative spec in one transaction; returns graph + critique.",
+ cost="mutate",
+)
@mcp.tool(
annotations={"title": "P&ID: From Spec", "readOnlyHint": False},
tags={"pid", "create", "batch"},
)
async def pid_from_spec(
- spec: Annotated[dict, "{sheet, equipment[], valves[], instruments[], lines[], connectors[]} — see the P&ID prompt"],
+ spec: Annotated[
+ dict,
+ "{sheet, equipment[], valves[], instruments[], lines[], connectors[]} — see the P&ID prompt",
+ ],
dry_run: Annotated[bool, "Validate and route in memory; draw nothing"] = False,
ctx: Context = None,
) -> dict:
@@ -6319,7 +7557,7 @@ Inventory; README `165 tools`.
# scripts/render_readme_pid.py
"""Render the P&ID the README shows, using only the server's own tools.
- AUTOCAD_MCP_BACKEND=ezdxf uv run --frozen python scripts/render_readme_pid.py
+AUTOCAD_MCP_BACKEND=ezdxf uv run --frozen python scripts/render_readme_pid.py
"""
from __future__ import annotations
@@ -6349,8 +7587,12 @@ async def render(output: Path) -> dict:
png = await backend.view_screenshot()
output.parent.mkdir(parents=True, exist_ok=True)
output.write_bytes(png)
- return {"output": str(output), "bytes": len(png), "dangling": result["graph"]["stats"]["dangling"],
- "critique": len(result["critique"])}
+ return {
+ "output": str(output),
+ "bytes": len(png),
+ "dangling": result["graph"]["stats"]["dangling"],
+ "critique": len(result["critique"]),
+ }
finally:
await backend.disconnect()
@@ -6407,8 +7649,17 @@ import server
pytestmark = pytest.mark.asyncio
-PID_TOOLS = {"pid_symbol_list", "pid_symbol_insert", "pid_line_draw", "pid_tag_parse", "pid_graph",
- "pid_instrument_index", "pid_line_list", "pid_equipment_list", "pid_from_spec"}
+PID_TOOLS = {
+ "pid_symbol_list",
+ "pid_symbol_insert",
+ "pid_line_draw",
+ "pid_tag_parse",
+ "pid_graph",
+ "pid_instrument_index",
+ "pid_line_list",
+ "pid_equipment_list",
+ "pid_from_spec",
+}
@pytest_asyncio.fixture(autouse=True)
@@ -6439,11 +7690,17 @@ async def test_all_is_the_default_and_enables_pid(monkeypatch):
assert info["tool_packs"]["enabled"] == ["core", "pid"]
-async def test_unknown_pack_is_ignored_with_a_warning_and_core_cannot_be_dropped(monkeypatch, caplog):
+async def test_unknown_pack_is_ignored_with_a_warning_and_core_cannot_be_dropped(
+ monkeypatch, caplog
+):
monkeypatch.setattr(config.settings, "tool_packs", "pid,mech3000")
with caplog.at_level(logging.WARNING, logger="autocad_mcp"):
info = await server._apply_tool_profile("full")
- assert info["tool_packs"] == {"enabled": ["core", "pid"], "available": ["core", "pid"], "ignored": ["mech3000"]}
+ assert info["tool_packs"] == {
+ "enabled": ["core", "pid"],
+ "available": ["core", "pid"],
+ "ignored": ["mech3000"],
+ }
assert "mech3000" in caplog.text
@@ -6451,7 +7708,9 @@ async def test_lean_intersects_with_packs(monkeypatch):
assert {"pid_symbol_insert", "pid_line_draw", "pid_graph"} <= server.LEAN_TOOL_NAMES
monkeypatch.setattr(config.settings, "tool_packs", "core")
info = await server._apply_tool_profile("lean")
- assert "pid_graph" in info["disabled_tools"] and "entity_create_line" not in info["disabled_tools"]
+ assert (
+ "pid_graph" in info["disabled_tools"] and "entity_create_line" not in info["disabled_tools"]
+ )
async def test_system_about_reports_packs():
@@ -6511,7 +7770,11 @@ def _enabled_packs() -> tuple[set[str], list[str]]:
enabled.add(pack)
else:
ignored.append(pack)
- log.warning("Unknown TOOL_PACKS entry %r ignored. Valid packs: %s.", pack, ", ".join(TOOL_PACK_NAMES))
+ log.warning(
+ "Unknown TOOL_PACKS entry %r ignored. Valid packs: %s.",
+ pack,
+ ", ".join(TOOL_PACK_NAMES),
+ )
return enabled, ignored
```
@@ -6520,7 +7783,9 @@ Change `_profile_enabled_names`:
```python
def _profile_enabled_names(profile: str, registered: set[str]) -> set[str]:
packs, _ignored = _enabled_packs()
- hidden_by_pack = set().union(*(names for pack, names in PACK_TOOL_NAMES.items() if pack not in packs))
+ hidden_by_pack = set().union(
+ *(names for pack, names in PACK_TOOL_NAMES.items() if pack not in packs)
+ )
enabled = set(registered) - hidden_by_pack
if profile == "lean":
enabled &= LEAN_TOOL_NAMES
@@ -6600,8 +7865,12 @@ async def client(monkeypatch):
async def test_tag_parse_tool(client):
out = (await client.call_tool("pid_tag_parse", {"tag": "10-FIC-101A"})).structured_content
assert out["valid"] is True and out["description"] == "Flow Indicating Controller"
- out = (await client.call_tool("pid_tag_parse", {"tag": "Q-1", "kind": "equipment",
- "equipment_prefixes": {"Q": "quench"}})).structured_content
+ out = (
+ await client.call_tool(
+ "pid_tag_parse",
+ {"tag": "Q-1", "kind": "equipment", "equipment_prefixes": {"Q": "quench"}},
+ )
+ ).structured_content
assert out["equipment_kind"] == "quench"
@@ -6609,16 +7878,30 @@ async def test_resources_serve_the_catalogue_and_the_letter_tables(client):
uris = {str(r.uri) for r in await client.list_resources()}
assert {"autocad://pid/symbols", "autocad://standards/isa51"} <= uris
symbols = json.loads((await client.read_resource("autocad://pid/symbols"))[0].text)
- assert symbols["catalog_version"] == "1" and any(s["symbol"] == "gate" for s in symbols["symbols"])
+ assert symbols["catalog_version"] == "1" and any(
+ s["symbol"] == "gate" for s in symbols["symbols"]
+ )
isa = json.loads((await client.read_resource("autocad://standards/isa51"))[0].text)
assert isa["first_letters"]["F"] == "Flow" and "rules" in isa
async def test_prompt_walks_the_tool_workflow(client):
- prompt = await client.get_prompt("prompt_pid_diagram", {"project_name": "Unit 7", "revision": "B"})
+ prompt = await client.get_prompt(
+ "prompt_pid_diagram", {"project_name": "Unit 7", "revision": "B"}
+ )
text = "\n".join(m.content.text for m in prompt.messages)
- for needle in ("drawing_plan", "layer_set_id=\"pid\"", "pid_symbol_insert", "pid_line_draw", "pid_graph",
- "drawing_critique", "drawing_finalize", "pid_from_spec", "Unit 7", "Ø10"):
+ for needle in (
+ "drawing_plan",
+ 'layer_set_id="pid"',
+ "pid_symbol_insert",
+ "pid_line_draw",
+ "pid_graph",
+ "drawing_critique",
+ "drawing_finalize",
+ "pid_from_spec",
+ "Unit 7",
+ "Ø10",
+ ):
assert needle in text, needle
assert "circle with triangle" not in text, "no hand-drawn symbols"
@@ -6638,12 +7921,17 @@ def test_catalog_contact_sheet_renders_every_symbol(tmp_path):
SECTION 17 → `(9 tools)`; add:
```python
-@cad_tool(summary="Parse an ISA-5.1 instrument tag or an equipment tag; explains every letter.", cost="read")
+@cad_tool(
+ summary="Parse an ISA-5.1 instrument tag or an equipment tag; explains every letter.",
+ cost="read",
+)
@mcp.tool(annotations={"title": "P&ID: Parse Tag", "readOnlyHint": True}, tags={"pid", "query"})
async def pid_tag_parse(
tag: Annotated[str, "e.g. FIC-101, 10-PDT-203A, P-101A"],
kind: Annotated[str, "auto | instrument | equipment"] = "auto",
- equipment_prefixes: Annotated[dict | None, "Replace the default equipment prefix map, e.g. {'Q': 'quench tower'}"] = None,
+ equipment_prefixes: Annotated[
+ dict | None, "Replace the default equipment prefix map, e.g. {'Q': 'quench tower'}"
+ ] = None,
ctx: Context = None,
) -> dict:
"""ISA-5.1-2009 Table 4.1 as a grammar: first letter, modifier, readout and
@@ -6737,7 +8025,7 @@ Regenerate the inventory (now 8 resources); README snapshot `166 tools · 8 reso
# scripts/render_pid_catalog.py
"""Render every catalogue symbol on one labelled sheet (docs/assets/pid-catalog.png).
- uv run --frozen python scripts/render_pid_catalog.py
+uv run --frozen python scripts/render_pid_catalog.py
"""
from __future__ import annotations
@@ -6772,8 +8060,13 @@ async def _render(output: Path) -> dict:
x, y = col * CELL, -row * CELL
layer = "PROCESS-PIPING-MAIN" if spec.family == "marker" else None
await insert_symbol(backend, spec, x, y, 0.0, 1.0, {}, layer)
- await backend.entity_create_text(spec.name.replace("PID_", ""), x - CELL / 2 + 2, y - CELL / 2 + 2, 1.6,
- layer="TEXT-NOTES")
+ await backend.entity_create_text(
+ spec.name.replace("PID_", ""),
+ x - CELL / 2 + 2,
+ y - CELL / 2 + 2,
+ 1.6,
+ layer="TEXT-NOTES",
+ )
png = await backend.view_screenshot()
output.parent.mkdir(parents=True, exist_ok=True)
output.write_bytes(png)
@@ -6872,7 +8165,12 @@ from benchmarks.tasks_v2 import TASKS_V2, TaskSpec
from benchmarks.tasks_v3 import TASKS_V3
NEW_TASKS_V4: tuple[TaskSpec, ...] = (
- TaskSpec("pid_roundtrip", "pid", "Draw a P&ID from a spec and read back a matching graph, index and line list", 1.0),
+ TaskSpec(
+ "pid_roundtrip",
+ "pid",
+ "Draw a P&ID from a spec and read back a matching graph, index and line list",
+ 1.0,
+ ),
)
TASKS_V4: tuple[TaskSpec, ...] = TASKS_V3 + NEW_TASKS_V4
MATRICES: dict[str, tuple[TaskSpec, ...]] = {"v2": TASKS_V2, "v3": TASKS_V3, "v4": TASKS_V4}
@@ -6889,31 +8187,39 @@ def task_by_id(task_id: str, matrix: str = DEFAULT_MATRIX) -> TaskSpec:
Adapter method (in `benchmarks/adapters/autocad_mcp_pro.py`, after `_task_measure_from_handle`):
```python
- async def _task_pid_roundtrip(self):
- """Five symbols, four lines, read back with nothing dangling."""
- from engineering.layers import apply_layer_set
- from engineering.pid.deliverables import instrument_index, line_list
- from engineering.pid.spec import EXAMPLE_SPEC, run_spec
+async def _task_pid_roundtrip(self):
+ """Five symbols, four lines, read back with nothing dangling."""
+ from engineering.layers import apply_layer_set
+ from engineering.pid.deliverables import instrument_index, line_list
+ from engineering.pid.spec import EXAMPLE_SPEC, run_spec
- await apply_layer_set(self.backend, "pid")
- result = await run_spec(self.backend, EXAMPLE_SPEC)
- graph = result["graph"]
- index = instrument_index(graph)
- lines = line_list(graph)
- passed = (
- len(graph["nodes"]) == 5
- and len(graph["edges"]) == 4
- and graph["stats"]["dangling"] == 0
- and graph["stats"]["confidence_min"] == 1.0
- and result["critique"] == []
- and [r["tag"] for r in index] == ["FIC-101"]
- and index[0]["connected_to"] == "FCV-101"
- and sorted(r["line_number"] for r in lines if r["line_number"]) == ["100-P-1001-CS1", "100-P-1001-CS1"]
- )
- return passed, {
- "nodes": len(graph["nodes"]), "edges": len(graph["edges"]), "dangling": graph["stats"]["dangling"],
- "confidence_min": graph["stats"]["confidence_min"], "critique_issues": len(result["critique"]),
- }, []
+ await apply_layer_set(self.backend, "pid")
+ result = await run_spec(self.backend, EXAMPLE_SPEC)
+ graph = result["graph"]
+ index = instrument_index(graph)
+ lines = line_list(graph)
+ passed = (
+ len(graph["nodes"]) == 5
+ and len(graph["edges"]) == 4
+ and graph["stats"]["dangling"] == 0
+ and graph["stats"]["confidence_min"] == 1.0
+ and result["critique"] == []
+ and [r["tag"] for r in index] == ["FIC-101"]
+ and index[0]["connected_to"] == "FCV-101"
+ and sorted(r["line_number"] for r in lines if r["line_number"])
+ == ["100-P-1001-CS1", "100-P-1001-CS1"]
+ )
+ return (
+ passed,
+ {
+ "nodes": len(graph["nodes"]),
+ "edges": len(graph["edges"]),
+ "dangling": graph["stats"]["dangling"],
+ "confidence_min": graph["stats"]["confidence_min"],
+ "critique_issues": len(result["critique"]),
+ },
+ [],
+ )
```
`run_competitors.py` and `render_matrix_chart.py`: change `from benchmarks.tasks_v3 import ...` to `from benchmarks.tasks_v4 import ...` (the chart renders `TASKS_V4` rows; competitor rows for the v3/v4 additions render `not_run` exactly as they do today for v3's five).
@@ -6923,8 +8229,11 @@ Correctness checks (append to `benchmarks/correctness_suite.py` before `CHECKS`)
```python
async def pid_block_define_attdef_roundtrip():
b = await _b()
- await b.block_define("PID_T", [{"type": "line", "x1": 0, "y1": 0, "x2": 8, "y2": 0}],
- [{"tag": "TAG", "x": 0, "y": 3, "height": 2.5}])
+ await b.block_define(
+ "PID_T",
+ [{"type": "line", "x1": 0, "y1": 0, "x2": 8, "y2": 0}],
+ [{"tag": "TAG", "x": 0, "y": 3, "height": 2.5}],
+ )
ref = await b.block_insert("PID_T", 5, 5, attributes={"TAG": "HV-1"})
return await b.block_get_attributes(ref.handle) == {"TAG": "HV-1"}
diff --git a/docs/superpowers/specs/2026-09-15-v1.6-pid-design.md b/docs/superpowers/specs/2026-09-15-v1.6-pid-design.md
index 0bda9a6..dcda35e 100644
--- a/docs/superpowers/specs/2026-09-15-v1.6-pid-design.md
+++ b/docs/superpowers/specs/2026-09-15-v1.6-pid-design.md
@@ -1,7 +1,7 @@
# 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) ·
+**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
@@ -104,25 +104,28 @@ get the whole track for the price of the two new contracts.
```python
@dataclass(frozen=True)
class Port:
- name: str # "in", "out", "suction", "discharge", "N1", "signal"
+ name: str # "in", "out", "suction", "discharge", "N1", "signal"
x: float
y: float
- direction_deg: float | None # outward direction a line leaves along; None = radial
+ 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
+ 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
+ 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
+ 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
+ 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
@@ -253,6 +256,7 @@ cost `mutate`, aliases `acad=("BLOCK", "BEDIT", "ATTDEF")`.
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"}"""
diff --git a/scripts/check_doc_numbers.py b/scripts/check_doc_numbers.py
index f52b6ad..1088895 100644
--- a/scripts/check_doc_numbers.py
+++ b/scripts/check_doc_numbers.py
@@ -27,6 +27,12 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
+#: The published A/B report the README and benchmarks/README quote. Bump this,
+#: the two row labels below and the prose together, or the gate reports drift.
+AB_REPORT = "benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json"
+AB_BASELINE_LABEL = r"v1\.5\.1"
+AB_CURRENT_LABEL = r"v1\.6\.0-dev"
+
def _read(relative: str) -> str:
return (ROOT / relative).read_text(encoding="utf-8")
@@ -66,7 +72,7 @@ def derived() -> dict[str, object]:
"""Every number a document is allowed to quote, computed from the tree."""
sys.path.insert(0, str(ROOT))
inventory = _json("docs/tool-inventory.json")["totals"]
- ab = _json("benchmarks/results/published/ab-v1.5.0-vs-v1.5.1.json")["summary"]
+ ab = _json(AB_REPORT)["summary"]
perf = {
w["workload_id"]: w["wall_ms"]
for w in _json("benchmarks/results/published/perf-ezdxf.json")["workloads"]
@@ -81,6 +87,7 @@ def derived() -> dict[str, object]:
"resources": inventory["resources"],
"prompts": inventory["prompts"],
"groups": inventory["groups"],
+ "ab_baseline_ref": ab["baseline_ref"],
"ab_total": ab["total"],
"ab_old_pass": ab["old_pass"],
"ab_old_pct": ab["old_pct"],
@@ -112,19 +119,29 @@ CHECKS: list[tuple[str, str, str, str]] = [
"prompts",
),
("README.md", "A/B suite size", r"(\d+) deterministic headless checks", "ab_total"),
- ("README.md", "A/B baseline pass", r"v1\.5\.0 \*\(baseline\)\* \| (\d+) / \d+", "ab_old_pass"),
- ("README.md", "A/B suite denominator", r"v1\.5\.0 \*\(baseline\)\* \| \d+ / (\d+)", "ab_total"),
+ (
+ "README.md",
+ "A/B baseline pass",
+ AB_BASELINE_LABEL + r" \*\(baseline\)\* \| (\d+) / \d+",
+ "ab_old_pass",
+ ),
+ (
+ "README.md",
+ "A/B suite denominator",
+ AB_BASELINE_LABEL + r" \*\(baseline\)\* \| \d+ / (\d+)",
+ "ab_total",
+ ),
("benchmarks/README.md", "A/B suite size", r"(\d+) checks, ezdxf backend", "ab_total"),
(
"benchmarks/README.md",
"A/B baseline pass",
- r"\*\*v1\.5\.0\*\* \(baseline\)\s*\| (\d+) / \d+",
+ r"\*\*" + AB_BASELINE_LABEL + r"\*\* \(baseline\)\s*\| (\d+) / \d+",
"ab_old_pass",
),
(
"benchmarks/README.md",
"A/B suite denominator",
- r"\*\*v1\.5\.0\*\* \(baseline\)\s*\| \d+ / (\d+)",
+ r"\*\*" + AB_BASELINE_LABEL + r"\*\* \(baseline\)\s*\| \d+ / (\d+)",
"ab_total",
),
(
@@ -136,25 +153,26 @@ CHECKS: list[tuple[str, str, str, str]] = [
(
"README.md",
"A/B baseline pass rate",
- r"v1\.5\.0 \*\(baseline\)\* \| \d+ / \d+ \| ([\d.]+) %",
+ AB_BASELINE_LABEL + r" \*\(baseline\)\* \| \d+ / \d+ \| ([\d.]+) %",
"ab_old_pct",
),
(
"README.md",
"A/B fixed count",
- r"v1\.5\.1\*\* \*\(this release\)\* \| \*\*\d+ / \d+\*\* \| \*\*\d+ %\*\* \| (\d+)",
+ AB_CURRENT_LABEL
+ + r"\*\* \*\(this branch\)\* \| \*\*\d+ / \d+\*\* \| \*\*\d+ %\*\* \| (\d+)",
"ab_fixed",
),
(
"benchmarks/README.md",
"A/B baseline pass rate",
- r"\*\*v1\.5\.0\*\* \(baseline\)\s*\| \d+ / \d+ \| ([\d.]+) %",
+ r"\*\*" + AB_BASELINE_LABEL + r"\*\* \(baseline\)\s*\| \d+ / \d+ \| ([\d.]+) %",
"ab_old_pct",
),
(
"benchmarks/README.md",
"A/B fixed count",
- r"\*\*v1\.5\.1\*\* \(this release\) \| \d+ / \d+ \| \d+ % \| (\d+)",
+ r"\*\*" + AB_CURRENT_LABEL + r"\*\* \(this branch\) \| \d+ / \d+ \| \d+ % \| (\d+)",
"ab_fixed",
),
("CLAUDE.md", "A/B suite size", r"correctness_suite\.py` \((\d+) checks\)", "ab_total"),
@@ -169,8 +187,8 @@ def run(verbose: bool = False) -> int:
print(
f"DRIFT correctness_suite.py has {values['suite_checks']} checks but the published "
f"A/B report was run over {values['ab_total']} -- regenerate the report:\n"
- f" python benchmarks/compare_versions.py v1.5.0 "
- f"--json benchmarks/results/published/ab-v1.5.0-vs-v1.5.1.json"
+ f" python benchmarks/compare_versions.py {values['ab_baseline_ref']} "
+ f"--json {AB_REPORT}"
)
return 1
diff --git a/scripts/smoke_pid_com.py b/scripts/smoke_pid_com.py
new file mode 100644
index 0000000..9196168
--- /dev/null
+++ b/scripts/smoke_pid_com.py
@@ -0,0 +1,86 @@
+"""Exercise the P&ID track once against the live AutoCAD on this machine.
+
+Creates a NEW document (never touches the operator's open drawing), then runs
+the whole track through the same code the tools call:
+
+ apply_layer_set("pid") -> run_spec(EXAMPLE_SPEC)
+ = block_define -> pid_symbol_insert -> ACADMCP_PID xdata
+ -> pid_line_draw -> pid_graph -> six pid_* critique focuses
+ -> entity_get_xdata on the pump
+
+and prints the graph stats as JSON. Exit codes:
+
+ 0 every line landed on a port (``dangling == 0``)
+ 1 the sheet drew but the reader found a dangling end
+ 2 no live CAD application reachable (pywin32 missing, or the ProgID
+ cannot be created) -- the COM paths then remain fake-tested only
+
+The backend's ``connect()`` is lazy on purpose (the server must start before
+AutoCAD is open), so reachability is read from ``system_status()`` rather than
+from ``connect()``. Note that the backend's ``Dispatch`` fallback *launches*
+the ProgID when nothing is running; a cold AutoCAD start can take longer than
+``COM_CALL_TIMEOUT`` (60 s), so open AutoCAD first when the first call times
+out.
+
+ AUTOCAD_MCP_BACKEND=com uv run --frozen python scripts/smoke_pid_com.py
+ uv run --frozen python scripts/smoke_pid_com.py --keep # leave the document open
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+XDATA_APP = "ACADMCP_PID"
+
+
+async def main(keep_document: bool = False) -> int:
+ try:
+ from backends.com_backend import ComBackend
+ except ImportError as exc:
+ print(f"pywin32 not available: {exc}")
+ return 2
+ backend = ComBackend()
+ try:
+ await backend.connect()
+ except Exception as exc:
+ print(f"no live AutoCAD reachable: {exc}")
+ return 2
+ try:
+ status = await backend.system_status()
+ if not status.get("connected"):
+ print(f"no live AutoCAD reachable: {status.get('error', status)}")
+ return 2
+
+ from engineering.layers import apply_layer_set
+ from engineering.pid.spec import EXAMPLE_SPEC, run_spec
+
+ await backend.drawing_new()
+ await apply_layer_set(backend, "pid")
+ result = await run_spec(backend, EXAMPLE_SPEC)
+ got = await backend.entity_get_xdata(result["handles"]["P-101"], XDATA_APP)
+ report = {
+ "autocad": status.get("autocad_version") or status.get("cad_progid"),
+ "active_document": (await backend.system_status()).get("active_document"),
+ "handles": result["handles"],
+ "graph_stats": result["graph"]["stats"],
+ "crossings_total": result["crossings_total"],
+ "critique_issues": len(result["critique"]),
+ "critique": result["critique"],
+ "xdata_chunks": len(got["xdata"].get(XDATA_APP, [])),
+ }
+ print(json.dumps(report, indent=2))
+ if not keep_document:
+ await backend.drawing_close(save=False)
+ return 0 if result["graph"]["stats"]["dangling"] == 0 else 1
+ finally:
+ await backend.disconnect()
+
+
+if __name__ == "__main__":
+ raise SystemExit(asyncio.run(main(keep_document="--keep" in sys.argv[1:])))