From 2be5e4180d74d0f2052db7a2bb9d765e54718f30 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 16 Sep 2026 13:34:06 +0300 Subject: [PATCH] =?UTF-8?q?docs(pid):=20track=20A=20shipped=20=E2=80=94=20?= =?UTF-8?q?CLAUDE.md,=20README,=20changelog=20with=20the=20live=20COM=20sm?= =?UTF-8?q?oke=20record?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/smoke_pid_com.py runs the whole track against the live AutoCAD in a new document. ComBackend.connect() is lazy by design, so the script reads reachability from system_status()["connected"] rather than from connect() as the plan sketched, and closes its document without saving unless --keep is passed. On this machine (AutoCAD 2026, "25.1s (LMS Tech)") it exits 0: 5 nodes, 4 edges, 0 dangling, confidence_min 1.0, 0 critique issues, 2 XDATA chunks — identical to the headless engine on the same spec. The first attempt exited 2 on a stale win32com gen_py cache (a package folder holding only __pycache__); an environment fault, no backend change needed. A/B against v1.5.1 published as ab-v1.5.1-vs-v1.6.0-dev.json: 26/29 -> 29/29, three miss -> pass, zero regressed. check_doc_numbers.py now names the report and the two row labels in one place (AB_REPORT, AB_*_LABEL), and README.md / benchmarks/README.md carry the new rows; the v1.5.0 and v1.4.0 tables stay as history. README: P&ID row, the showcase and catalogue images, the token table re-measured (161 / 44,930 by default, 152 / 41,420 with TOOL_PACKS=core, lean 50 / 14,007), every stale count reconciled (154 -> 166 registered, 149 -> 161 advertised, corpus 158/672 -> 162/731 by the same count, 19 -> 20 groups, 6 -> 8 resources, six-task matrix with pid_roundtrip), a "Known limitations of the P&ID track" subsection, and the snapshot line at this machine's 2423 collected tests (CI has not run on this tree; noted in the changelog with the pre-existing 1369 vs 1363 drift). CLAUDE.md: section 17, sections 5 and 7 counts, TOOL_PACKS, the two 1.6 contracts and the reader rules. Spec status -> implemented. The plan and spec markdown were reformatted by ruff (fenced Python only) because `ruff format --check .` was already red on them at HEAD and the gate requires the whole tree clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- CHANGELOG.md | 169 + CLAUDE.md | 16 +- README.md | 47 +- benchmarks/README.md | 30 +- .../published/ab-v1.5.1-vs-v1.6.0-dev.json | 188 ++ .../2026-09-15-v1.6-pid-implementation.md | 2791 ++++++++++++----- .../specs/2026-09-15-v1.6-pid-design.md | 26 +- scripts/check_doc_numbers.py | 40 +- scripts/smoke_pid_com.py | 86 + 9 files changed, 2604 insertions(+), 789 deletions(-) create mode 100644 benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json create mode 100644 scripts/smoke_pid_com.py 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. +P&ID sheet: a centrifugal pump, a diaphragm-actuated globe control valve, a vertical vessel with two nozzles, a flow controller bubble and an off-page connector, joined by numbered process lines and an electric signal line + +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. + +Contact sheet of the 43 P&ID catalogue symbols: valve bodies with actuators, pumps and compressors, heat exchangers, vessels, instrument bubbles, line markers and connectors + +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:])))