scripts/smoke_pid_com.py runs the whole track against the live AutoCAD in a new document. ComBackend.connect() is lazy by design, so the script reads reachability from system_status()["connected"] rather than from connect() as the plan sketched, and closes its document without saving unless --keep is passed. On this machine (AutoCAD 2026, "25.1s (LMS Tech)") it exits 0: 5 nodes, 4 edges, 0 dangling, confidence_min 1.0, 0 critique issues, 2 XDATA chunks — identical to the headless engine on the same spec. The first attempt exited 2 on a stale win32com gen_py cache (a package folder holding only __pycache__); an environment fault, no backend change needed. A/B against v1.5.1 published as ab-v1.5.1-vs-v1.6.0-dev.json: 26/29 -> 29/29, three miss -> pass, zero regressed. check_doc_numbers.py now names the report and the two row labels in one place (AB_REPORT, AB_*_LABEL), and README.md / benchmarks/README.md carry the new rows; the v1.5.0 and v1.4.0 tables stay as history. README: P&ID row, the showcase and catalogue images, the token table re-measured (161 / 44,930 by default, 152 / 41,420 with TOOL_PACKS=core, lean 50 / 14,007), every stale count reconciled (154 -> 166 registered, 149 -> 161 advertised, corpus 158/672 -> 162/731 by the same count, 19 -> 20 groups, 6 -> 8 resources, six-task matrix with pid_roundtrip), a "Known limitations of the P&ID track" subsection, and the snapshot line at this machine's 2423 collected tests (CI has not run on this tree; noted in the changelog with the pre-existing 1369 vs 1363 drift). CLAUDE.md: section 17, sections 5 and 7 counts, TOOL_PACKS, the two 1.6 contracts and the reader rules. Spec status -> implemented. The plan and spec markdown were reformatted by ruff (fenced Python only) because `ruff format --check .` was already red on them at HEAD and the gate requires the whole tree clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
8391 lines
315 KiB
Markdown
8391 lines
315 KiB
Markdown
# v1.6 Track A — P&ID Expert and Reader Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Make the server a P&ID expert (code-authored ISO 10628-2 / ISA-5.1 symbol catalogue as real blocks with tags and ports, orthogonal line drawing with ISA-5.1 line classes, an ISA-5.1 tag grammar) and a P&ID reader (`pid_graph` turns any P&ID into a connectivity graph; instrument index, line list and equipment list derive from it; six critique focuses join `drawing_finalize`).
|
||
|
||
**Architecture:** Two new generic backend contracts (`block_define`, `entity_get/set_xdata`) implemented on both engines; everything else composes existing primitives from a new `engineering/pid/` package (symbols as data, pure routing, pure tag grammar, a graph builder that reads the drawing back). Nine `pid_*` tools in a new `SECTION 17` of `server.py`, gated by a new `TOOL_PACKS` mechanism.
|
||
|
||
**Tech Stack:** Python 3.11+, FastMCP 3, ezdxf 1.4, pywin32 (COM, fake-tested + live smoke), pytest-asyncio.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-09-15-v1.6-pid-design.md` (parent: `docs/superpowers/specs/2026-09-15-v1.6-roadmap-design.md`, track A).
|
||
|
||
## Global Constraints
|
||
|
||
- Run every command through the project environment: `uv run --frozen pytest ...`, `uv run --frozen python ...` (the shell's default `python` is 3.14 without the project's dependencies).
|
||
- Every coordinate in or out of a tool is WCS on both engines; symbol geometry is authored in symbol-local mm and transformed at insertion.
|
||
- Every new tool needs: a `@cad_tool(summary=..., cost=...)` line above its `@mcp.tool(...)`, `tags={...}`, an alias record in `discovery/aliases.py` (the gate `tests/test_discovery_aliases.py::test_every_registered_tool_has_an_alias_record` fails otherwise), a regenerated `docs/tool-inventory.json` (`uv run --frozen python scripts/generate_tool_inventory.py`), the README release-snapshot line updated to the new tool count, and the `SECTION` header comment count updated (`tests/test_release_consistency.py::test_section_header_counts_match_decorators` parses decorators per section).
|
||
- The README release-snapshot line today reads `> **v1.5 release snapshot:** 154 tools · 6 resources · 5 prompt templates · 1369 collected tests.` — bump the tool/resource counts in the same task that adds them; leave the minor version at `v1.5` until the release task (a `v1.6` label there requires `pyproject.toml` to say 1.6).
|
||
- Both engines implement every new contract method as `@abstractmethod` (no `@capability` in this track). COM paths are unit-tested against fake ActiveX objects (the `tests/test_block_from_entities.py` pattern) and executed once live by `scripts/smoke_pid_com.py` in the last task.
|
||
- Validation happens **before** any write: a malformed request raises `TypeError`/`ValueError` naming the offending index/key, and nothing is created.
|
||
- Authored standards data (symbols, ISA letter tables) is pinned by tests a reader can check against the standard.
|
||
- P&ID critique focuses return `[]` on a drawing with no P&ID nodes.
|
||
- Reference sizes (symbol-local mm): instrument bubble Ø10; valve body 8 × 4; pump Ø8; grid 5; equipment `TAG` text height 2.5; bubble `FUNC`/`LOOP` height 2.0; stub 5.
|
||
- Commit after every task with a conventional-commit message; end every commit message with the two attribution lines this session's harness requires (`Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>` and `Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd`). If `git commit` reports "Author identity unknown", pass `-c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com"` (the repository's existing author) — do not change global git config.
|
||
|
||
## File Structure
|
||
|
||
| Path | Responsibility |
|
||
|---|---|
|
||
| `backends/block_specs.py` (new) | Validation/normalisation of `block_define` primitive and ATTDEF specs; SOLID vertex order. Shared by both engines. |
|
||
| `backends/xdata_specs.py` (new) | XDATA app-name validation, value ↔ (group code, value) encoding, size limits, per-app splitting. Shared by both engines. |
|
||
| `backends/contracts/blocks.py` | `+ block_define` |
|
||
| `backends/contracts/entity_query.py` | `+ entity_get_xdata`, `+ entity_set_xdata` |
|
||
| `backends/ezdxf_backend.py` | `block_insert` attribute fix; `block_define`; XDATA methods |
|
||
| `backends/com_backend.py` | `block_define`; XDATA methods |
|
||
| `engineering/pid/__init__.py` (new) | Public re-exports |
|
||
| `engineering/pid/geometry.py` (new) | Primitive-spec helpers (line/circle/arc/polyline/text/solid/dashed_line/regular_polygon/bbox_of/attdef) |
|
||
| `engineering/pid/symbols.py` (new) | `Port`, `SymbolSpec`, registry, `resolve`, `list_symbols`, `all_specs`, `transform_port`, instrument/marker/connector builders |
|
||
| `engineering/pid/symbols_valves.py` (new) | 11 valve bodies × 6 actuators |
|
||
| `engineering/pid/symbols_equipment.py` (new) | rotating (6), heat (4), misc (7) |
|
||
| `engineering/pid/symbols_vessels.py` (new) | 6 parametric vessels |
|
||
| `engineering/pid/xdata.py` (new) | `ACADMCP_PID` payload encode/decode/read/write, payload builders |
|
||
| `engineering/pid/tags.py` (new) | ISA-5.1 grammar + tables, equipment prefix map, `parse_tag`, `split_instrument_tag` |
|
||
| `engineering/pid/insert.py` (new) | `ensure_layer`, `ensure_block`, `insert_symbol` |
|
||
| `engineering/pid/lines.py` (new) | `LINE_CLASSES`, `route`, `label_placement`, `marker_positions`, `count_crossings`, `format_line_number` (pure) |
|
||
| `engineering/pid/drawlines.py` (new) | `draw_line(backend, ...)` orchestration |
|
||
| `engineering/pid/graph.py` (new) | `build_graph(backend, ...)` |
|
||
| `engineering/pid/deliverables.py` (new) | `instrument_index`, `line_list`, `equipment_list`, `write_csv` |
|
||
| `engineering/pid/critique.py` (new) | six `pid_*` focus checks over one cached graph |
|
||
| `engineering/pid/spec.py` (new) | `validate_spec`, `run_spec(backend, spec, dry_run)` |
|
||
| `engineering/layers.py` | `PID_LAYERS` + `PROCESS-LINE-TEXT` |
|
||
| `engineering/plan_spec.py` | `CritiqueFocus` + 6 values |
|
||
| `engineering/critique.py` | shared-context dispatch for the pid focuses |
|
||
| `engineering/preflight.py` | `LAYER_SET_INTENT_MISMATCH` conflict |
|
||
| `config.py` | `tool_packs` |
|
||
| `server.py` | SECTION 5 (+2), SECTION 7 (+1), SECTION 17 (9 tools), `TOOL_PACKS`, 2 resources, prompt rewrite, `drawing_plan` warning |
|
||
| `discovery/aliases.py` | 12 alias records |
|
||
| `benchmarks/tasks_v4.py` (new), `benchmarks/adapters/autocad_mcp_pro.py`, `benchmarks/correctness_suite.py`, `benchmarks/run_competitors.py`, `benchmarks/render_matrix_chart.py` | `pid_roundtrip` task, +3 correctness checks |
|
||
| `scripts/render_pid_catalog.py`, `scripts/render_readme_pid.py`, `scripts/smoke_pid_com.py` (new) | contact sheet, README showcase, live COM smoke |
|
||
| `tests/test_block_insert_attributes.py`, `tests/test_block_specs.py`, `tests/test_block_define.py`, `tests/test_xdata.py`, `tests/test_pid_xdata.py`, `tests/test_pid_tags.py`, `tests/test_pid_symbols.py`, `tests/test_pid_insert.py`, `tests/test_pid_lines.py`, `tests/test_pid_line_draw.py`, `tests/test_pid_graph.py`, `tests/test_pid_deliverables.py`, `tests/test_pid_critique.py`, `tests/test_pid_from_spec.py`, `tests/test_tool_packs.py`, `tests/test_pid_resources_prompt.py`, `tests/test_benchmark_v4.py`, `tests/data/isa51_tags.json` | tests and fixtures |
|
||
|
||
---
|
||
|
||
### Task 1: Fix `block_insert(attributes=…)` on the headless engine
|
||
|
||
The ezdxf `block_insert` uses `msp.add_auto_blockref`, which wraps the INSERT in an anonymous `*U…` block: the returned handle references `*U1`, and `block_get_attributes` on it returns `{}`. Verified 2026-09-15 on v1.5.1. Everything in this track reads attributes back, so this goes first.
|
||
|
||
**Files:**
|
||
- Modify: `backends/ezdxf_backend.py` (`block_insert`, around line 4436)
|
||
- Test: `tests/test_block_insert_attributes.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: `block_insert(name, x, y, scale_x, scale_y, rotation, attributes, layer) -> EntityInfo` whose `properties["block_name"] == name` and whose ATTRIBs are attached to the INSERT itself.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```python
|
||
# tests/test_block_insert_attributes.py
|
||
"""block_insert(attributes=...) must reference the named block, not an anonymous wrapper.
|
||
|
||
ezdxf's `add_auto_blockref` wraps the INSERT in an anonymous `*U` block. The
|
||
handle it returns then reports block_name `*U1`, and `block_get_attributes`
|
||
finds no ATTRIBs on it — an insert with attributes silently lost them.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
pytestmark = pytest.mark.asyncio
|
||
|
||
|
||
def _define_tagged_block(backend, name: str = "TB") -> None:
|
||
blk = backend._doc.blocks.new(name=name)
|
||
blk.add_line((0, 0), (10, 0))
|
||
blk.add_attdef("TAG", insert=(0, 3), text="", dxfattribs={"height": 2.5})
|
||
|
||
|
||
async def test_insert_with_attributes_references_the_named_block(backend):
|
||
_define_tagged_block(backend)
|
||
ref = await backend.block_insert("TB", 10, 10, attributes={"TAG": "P-101"})
|
||
|
||
info = await backend.entity_get(ref.handle)
|
||
assert info.properties["block_name"] == "TB"
|
||
assert await backend.block_get_attributes(ref.handle) == {"TAG": "P-101"}
|
||
anonymous = [b.name for b in backend._doc.blocks if b.name.startswith("*U")]
|
||
assert anonymous == [], "no anonymous wrapper block may be created"
|
||
|
||
|
||
async def test_insert_ignores_values_for_tags_the_block_does_not_define(backend):
|
||
_define_tagged_block(backend)
|
||
ref = await backend.block_insert("TB", 0, 0, attributes={"TAG": "V-1", "NOPE": "x"})
|
||
assert await backend.block_get_attributes(ref.handle) == {"TAG": "V-1"}
|
||
|
||
|
||
async def test_insert_without_attributes_is_unchanged(backend):
|
||
_define_tagged_block(backend)
|
||
ref = await backend.block_insert("TB", 0, 0)
|
||
info = await backend.entity_get(ref.handle)
|
||
assert info.properties["block_name"] == "TB"
|
||
assert await backend.block_get_attributes(ref.handle) == {}
|
||
```
|
||
|
||
- [ ] **Step 2: Run it to verify it fails**
|
||
|
||
Run: `uv run --frozen pytest tests/test_block_insert_attributes.py -v`
|
||
Expected: the first two tests FAIL (`block_name == '*U1'`, attributes `{}`); the third passes.
|
||
|
||
- [ ] **Step 3: Replace `add_auto_blockref` with `add_blockref(...).add_auto_attribs(...)`**
|
||
|
||
In `backends/ezdxf_backend.py::block_insert`, replace the `if attributes: ... else: ...` pair with:
|
||
|
||
```python
|
||
ref = msp.add_blockref(name, (float(x), float(y)), dxfattribs=attribs)
|
||
if attributes:
|
||
# `add_auto_blockref` wraps the INSERT in an anonymous *U block,
|
||
# so the returned handle names `*U1` and carries no ATTRIBs.
|
||
# `add_auto_attribs` attaches ATTRIBs to this INSERT for every
|
||
# ATTDEF the definition has; values for tags it lacks are ignored.
|
||
ref.add_auto_attribs({str(k): str(v) for k, v in attributes.items()})
|
||
```
|
||
|
||
- [ ] **Step 4: Run the tests to verify they pass**
|
||
|
||
Run: `uv run --frozen pytest tests/test_block_insert_attributes.py tests/test_layer_block.py tests/test_block_from_entities.py -v`
|
||
Expected: all PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backends/ezdxf_backend.py tests/test_block_insert_attributes.py
|
||
git commit -m "fix(ezdxf): block_insert with attributes referenced an anonymous *U block and lost its ATTRIBs"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Shared primitive/ATTDEF spec validation (`backends/block_specs.py`)
|
||
|
||
**Files:**
|
||
- Create: `backends/block_specs.py`
|
||
- Test: `tests/test_block_specs.py`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `ALLOWED_TYPES = ("line", "circle", "arc", "polyline", "text", "solid")`, `TEXT_ALIGNMENTS = ("left", "center", "right", "middle_center")`
|
||
- `validate_entity_specs(entities: list[dict]) -> list[dict]` — normalised copies (floats; `layer` default `"0"`; polyline `points: list[tuple[float, float]]`, `closed: bool`, `bulges: list[float] | None`; text `rotation_deg`, `align`; solid `points` 3–4 pairs). Raises `ValueError` when empty, `TypeError` naming `entities[i]` and the key otherwise.
|
||
- `validate_attdef_specs(attdefs: list[dict]) -> list[dict]` — keys `tag, prompt, default, x, y, height, rotation_deg, align, invisible`; tag `^[A-Z][A-Z0-9_]{0,30}$` and unique.
|
||
- `solid_vertices(points) -> list[tuple[float, float]]` — DXF SOLID bow-tie order (`[a, b, d, c]` for a quad, unchanged for a triangle).
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```python
|
||
# tests/test_block_specs.py
|
||
"""block_define request validation: refuse the whole request before any write."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
from backends.block_specs import (
|
||
ALLOWED_TYPES,
|
||
solid_vertices,
|
||
validate_attdef_specs,
|
||
validate_entity_specs,
|
||
)
|
||
|
||
|
||
def test_allowed_types_are_the_documented_six():
|
||
assert ALLOWED_TYPES == ("line", "circle", "arc", "polyline", "text", "solid")
|
||
|
||
|
||
def test_empty_entities_is_refused():
|
||
with pytest.raises(ValueError, match="empty"):
|
||
validate_entity_specs([])
|
||
|
||
|
||
def test_each_type_normalises_to_floats_and_layer_zero():
|
||
out = validate_entity_specs(
|
||
[
|
||
{"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 0},
|
||
{"type": "circle", "cx": 0, "cy": 0, "r": 2},
|
||
{"type": "arc", "cx": 0, "cy": 0, "r": 2, "start_deg": 0, "end_deg": 180},
|
||
{"type": "polyline", "points": [[0, 0], [1, 0], [1, 1]], "closed": True},
|
||
{"type": "text", "text": "M", "x": 0, "y": 0, "height": 2},
|
||
{"type": "solid", "points": [[0, 0], [1, 0], [0, 1]]},
|
||
]
|
||
)
|
||
assert [e["type"] for e in out] == list(ALLOWED_TYPES)
|
||
assert out[0] == {"type": "line", "layer": "0", "x1": 0.0, "y1": 0.0, "x2": 1.0, "y2": 0.0}
|
||
assert out[3]["points"] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)]
|
||
assert out[3]["closed"] is True and out[3]["bulges"] is None
|
||
assert out[4]["align"] == "left" and out[4]["rotation_deg"] == 0.0
|
||
assert out[5]["points"] == [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0)]
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"bad, fragment",
|
||
[
|
||
({"type": "square"}, "type"),
|
||
({"type": "line", "x1": 0, "y1": 0, "x2": 1}, "y2"),
|
||
({"type": "circle", "cx": 0, "cy": 0, "r": 0}, "r"),
|
||
({"type": "circle", "cx": 0, "cy": 0, "r": float("nan")}, "finite"),
|
||
({"type": "text", "text": "M", "x": 0, "y": 0, "height": -1}, "height"),
|
||
({"type": "text", "text": 5, "x": 0, "y": 0, "height": 1}, "text"),
|
||
({"type": "text", "text": "M", "x": 0, "y": 0, "height": 1, "align": "top"}, "align"),
|
||
({"type": "polyline", "points": [[0, 0]]}, "points"),
|
||
({"type": "polyline", "points": [[0, 0], [1]]}, "points[1]"),
|
||
({"type": "polyline", "points": [[0, 0], [1, 1]], "bulges": [0.5]}, "bulges"),
|
||
({"type": "polyline", "points": [[0, 0], [1, 1]], "closed": "yes"}, "closed"),
|
||
({"type": "solid", "points": [[0, 0], [1, 0]]}, "points"),
|
||
({"type": "solid", "points": [[0, 0], [1, 0], [1, 1], [0, 1], [2, 2]]}, "at most 4"),
|
||
({"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 0, "layer": ""}, "layer"),
|
||
({"type": "line", "x1": True, "y1": 0, "x2": 1, "y2": 0}, "x1"),
|
||
],
|
||
)
|
||
def test_bad_entity_names_the_index_and_key(bad, fragment):
|
||
good = {"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 0}
|
||
with pytest.raises(TypeError, match=r"entities\[1\]") as exc:
|
||
validate_entity_specs([good, bad])
|
||
assert fragment in str(exc.value)
|
||
|
||
|
||
def test_attdefs_normalise_and_default():
|
||
out = validate_attdef_specs([{"tag": "TAG", "x": 0, "y": 3, "height": 2.5}])
|
||
assert out == [
|
||
{
|
||
"tag": "TAG",
|
||
"prompt": "TAG",
|
||
"default": "",
|
||
"x": 0.0,
|
||
"y": 3.0,
|
||
"height": 2.5,
|
||
"rotation_deg": 0.0,
|
||
"align": "left",
|
||
"invisible": False,
|
||
}
|
||
]
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"bad, fragment",
|
||
[
|
||
({"tag": "tag", "x": 0, "y": 0, "height": 1}, "tag"),
|
||
({"tag": "T-1", "x": 0, "y": 0, "height": 1}, "tag"),
|
||
({"tag": "TAG", "x": 0, "y": 0, "height": 0}, "height"),
|
||
({"tag": "TAG", "x": 0, "y": 0, "height": 1, "invisible": 1}, "invisible"),
|
||
({"tag": "TAG", "x": 0, "y": 0, "height": 1, "default": 3}, "default"),
|
||
],
|
||
)
|
||
def test_bad_attdef_names_the_index_and_key(bad, fragment):
|
||
with pytest.raises(TypeError, match=r"attdefs\[0\]") as exc:
|
||
validate_attdef_specs([bad])
|
||
assert fragment in str(exc.value)
|
||
|
||
|
||
def test_duplicate_attdef_tags_are_refused():
|
||
with pytest.raises(TypeError, match="duplicate"):
|
||
validate_attdef_specs(
|
||
[
|
||
{"tag": "TAG", "x": 0, "y": 0, "height": 1},
|
||
{"tag": "TAG", "x": 0, "y": 5, "height": 1},
|
||
]
|
||
)
|
||
|
||
|
||
def test_solid_vertices_use_dxf_bowtie_order():
|
||
quad = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]
|
||
assert solid_vertices(quad) == [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)]
|
||
tri = [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0)]
|
||
assert solid_vertices(tri) == tri
|
||
```
|
||
|
||
- [ ] **Step 2: Run them to verify they fail**
|
||
|
||
Run: `uv run --frozen pytest tests/test_block_specs.py -v`
|
||
Expected: FAIL with `ModuleNotFoundError: No module named 'backends.block_specs'`.
|
||
|
||
- [ ] **Step 3: Write the module**
|
||
|
||
```python
|
||
# backends/block_specs.py
|
||
"""Typed primitive specs for ``block_define``, validated before any write.
|
||
|
||
Both engines consume the same normalised specs, so a malformed request is
|
||
refused identically on COM and ezdxf and nothing is written on either. Every
|
||
error names the offending entry (``entities[i]`` / ``attdefs[i]``) and key.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
import re
|
||
|
||
ALLOWED_TYPES = ("line", "circle", "arc", "polyline", "text", "solid")
|
||
TEXT_ALIGNMENTS = ("left", "center", "right", "middle_center")
|
||
ATTDEF_TAG_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,30}$")
|
||
|
||
_REQUIRED = {
|
||
"line": ("x1", "y1", "x2", "y2"),
|
||
"circle": ("cx", "cy", "r"),
|
||
"arc": ("cx", "cy", "r", "start_deg", "end_deg"),
|
||
"polyline": ("points",),
|
||
"text": ("text", "x", "y", "height"),
|
||
"solid": ("points",),
|
||
}
|
||
_POSITIVE = {"r", "height"}
|
||
|
||
|
||
def _scalar(value, where: str, *, positive: bool = False) -> float:
|
||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||
raise TypeError(f"{where} must be a number, got {value!r}")
|
||
number = float(value)
|
||
if not math.isfinite(number):
|
||
raise TypeError(f"{where} must be finite")
|
||
if positive and number <= 0:
|
||
raise TypeError(f"{where} must be > 0, got {number}")
|
||
return number
|
||
|
||
|
||
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]]:
|
||
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")
|
||
if maximum is not None and len(raw) > maximum:
|
||
raise TypeError(f"{where}: 'points' must have at most {maximum} pairs")
|
||
out: list[tuple[float, float]] = []
|
||
for i, pt in enumerate(raw):
|
||
if not isinstance(pt, (list, tuple)) or len(pt) != 2:
|
||
raise TypeError(f"{where}: points[{i}] must be an [x, y] pair")
|
||
out.append(
|
||
(
|
||
_scalar(pt[0], f"{where}: points[{i}].x"),
|
||
_scalar(pt[1], f"{where}: points[{i}].y"),
|
||
)
|
||
)
|
||
return out
|
||
|
||
|
||
def _align(spec: dict, where: str) -> str:
|
||
align = spec.get("align", "left")
|
||
if align not in TEXT_ALIGNMENTS:
|
||
raise TypeError(f"{where}: 'align' must be one of {TEXT_ALIGNMENTS}, got {align!r}")
|
||
return align
|
||
|
||
|
||
def _layer(spec: dict, where: str) -> str:
|
||
layer = spec.get("layer", "0")
|
||
if not isinstance(layer, str) or not layer.strip():
|
||
raise TypeError(f"{where}: 'layer' must be a non-empty string")
|
||
return layer.strip()
|
||
|
||
|
||
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"
|
||
)
|
||
out: list[dict] = []
|
||
for i, spec in enumerate(entities):
|
||
where = f"entities[{i}]"
|
||
if not isinstance(spec, dict):
|
||
raise TypeError(f"{where}: must be an object")
|
||
kind = spec.get("type")
|
||
if kind not in ALLOWED_TYPES:
|
||
raise TypeError(f"{where}: 'type' must be one of {ALLOWED_TYPES}, got {kind!r}")
|
||
for key in _REQUIRED[kind]:
|
||
if key not in spec:
|
||
raise TypeError(f"{where}: {kind} requires {key!r}")
|
||
norm: dict = {"type": kind, "layer": _layer(spec, where)}
|
||
if kind == "line":
|
||
for key in ("x1", "y1", "x2", "y2"):
|
||
norm[key] = _number(spec, key, where)
|
||
elif kind == "circle":
|
||
for key in ("cx", "cy", "r"):
|
||
norm[key] = _number(spec, key, where)
|
||
elif kind == "arc":
|
||
for key in ("cx", "cy", "r", "start_deg", "end_deg"):
|
||
norm[key] = _number(spec, key, where)
|
||
elif kind == "polyline":
|
||
pts = _points(spec, where, minimum=2)
|
||
closed = spec.get("closed", False)
|
||
if not isinstance(closed, bool):
|
||
raise TypeError(f"{where}: 'closed' must be a boolean")
|
||
bulges = spec.get("bulges")
|
||
if bulges is not None:
|
||
if not isinstance(bulges, (list, tuple)) or len(bulges) != len(pts):
|
||
raise TypeError(f"{where}: 'bulges' must list one value per point")
|
||
bulges = [_scalar(b, f"{where}: bulges[{j}]") for j, b in enumerate(bulges)]
|
||
norm.update(points=pts, closed=closed, bulges=list(bulges) if bulges else None)
|
||
elif kind == "text":
|
||
text = spec.get("text")
|
||
if not isinstance(text, str):
|
||
raise TypeError(f"{where}: 'text' must be a string")
|
||
norm.update(
|
||
text=text,
|
||
x=_number(spec, "x", where),
|
||
y=_number(spec, "y", where),
|
||
height=_number(spec, "height", where),
|
||
rotation_deg=_scalar(spec.get("rotation_deg", 0.0), f"{where}: 'rotation_deg'"),
|
||
align=_align(spec, where),
|
||
)
|
||
elif kind == "solid":
|
||
norm["points"] = _points(spec, where, minimum=3, maximum=4)
|
||
out.append(norm)
|
||
return out
|
||
|
||
|
||
def validate_attdef_specs(attdefs) -> list[dict]:
|
||
"""Normalise ATTDEF specs: tag pattern, uniqueness, numeric fields, flags."""
|
||
if attdefs is None:
|
||
return []
|
||
if not isinstance(attdefs, (list, tuple)):
|
||
raise TypeError("block_define: 'attdefs' must be a list")
|
||
out: list[dict] = []
|
||
seen: set[str] = set()
|
||
for i, spec in enumerate(attdefs):
|
||
where = f"attdefs[{i}]"
|
||
if not isinstance(spec, dict):
|
||
raise TypeError(f"{where}: must be an object")
|
||
tag = spec.get("tag")
|
||
if not isinstance(tag, str) or not ATTDEF_TAG_RE.match(tag):
|
||
raise TypeError(f"{where}: 'tag' must match {ATTDEF_TAG_RE.pattern}, got {tag!r}")
|
||
if tag in seen:
|
||
raise TypeError(f"{where}: duplicate tag {tag!r}")
|
||
seen.add(tag)
|
||
prompt = spec.get("prompt", tag)
|
||
default = spec.get("default", "")
|
||
invisible = spec.get("invisible", False)
|
||
if not isinstance(prompt, str):
|
||
raise TypeError(f"{where}: 'prompt' must be a string")
|
||
if not isinstance(default, str):
|
||
raise TypeError(f"{where}: 'default' must be a string")
|
||
if not isinstance(invisible, bool):
|
||
raise TypeError(f"{where}: 'invisible' must be a boolean")
|
||
out.append(
|
||
{
|
||
"tag": tag,
|
||
"prompt": prompt,
|
||
"default": default,
|
||
"x": _number(spec, "x", where),
|
||
"y": _number(spec, "y", where),
|
||
"height": _number(spec, "height", where),
|
||
"rotation_deg": _scalar(spec.get("rotation_deg", 0.0), f"{where}: 'rotation_deg'"),
|
||
"align": _align(spec, where),
|
||
"invisible": invisible,
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
def solid_vertices(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
|
||
"""DXF SOLID draws vtx0→vtx1→vtx3→vtx2, so a quad given in polygon order
|
||
(a, b, c, d) is stored as (a, b, d, c). Triangles are stored as given."""
|
||
pts = [(float(x), float(y)) for x, y in points]
|
||
if len(pts) == 4:
|
||
return [pts[0], pts[1], pts[3], pts[2]]
|
||
return pts
|
||
```
|
||
|
||
- [ ] **Step 4: Run the tests**
|
||
|
||
Run: `uv run --frozen pytest tests/test_block_specs.py -v`
|
||
Expected: all PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backends/block_specs.py tests/test_block_specs.py
|
||
git commit -m "feat(blocks): shared validation for block_define primitive and ATTDEF specs"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: `block_define` on both engines + tool + gates
|
||
|
||
**Files:**
|
||
- Modify: `backends/contracts/blocks.py` (append abstract method)
|
||
- Modify: `backends/ezdxf_backend.py` (after `block_create_from_entities`)
|
||
- Modify: `backends/com_backend.py` (after `block_create_from_entities`)
|
||
- Modify: `server.py` (SECTION 7 header `(7 tools)` → `(8 tools)`; new tool after `block_create_from_entities`)
|
||
- Modify: `discovery/aliases.py` (record `block_define`)
|
||
- Modify: `README.md` snapshot line `154 tools` → `155 tools`; regenerate `docs/tool-inventory.json`
|
||
- Test: `tests/test_block_define.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `validate_entity_specs`, `validate_attdef_specs`, `solid_vertices` (Task 2); `sanitize_symbol_name` (`security.py`).
|
||
- Produces: `await backend.block_define(name, entities, attdefs=None, base_x=0.0, base_y=0.0, overwrite=False) -> {"ok": True, "name", "entity_count", "attdef_count", "replaced": bool, "backend"}`; MCP tool `block_define` with the same parameters.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```python
|
||
# tests/test_block_define.py
|
||
"""block_define: a definition with ATTDEFs from typed specs, on both engines."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import types
|
||
|
||
import pytest
|
||
|
||
pytestmark = pytest.mark.asyncio
|
||
|
||
VALVE = [
|
||
{"type": "polyline", "points": [[-4, -2], [-4, 2], [0, 0]], "closed": True},
|
||
{"type": "polyline", "points": [[4, -2], [4, 2], [0, 0]], "closed": True},
|
||
{"type": "circle", "cx": 0, "cy": 0, "r": 1.5},
|
||
{"type": "arc", "cx": 0, "cy": 2, "r": 2, "start_deg": 0, "end_deg": 180},
|
||
{"type": "line", "x1": 0, "y1": 0, "x2": 0, "y2": 4},
|
||
{"type": "text", "text": "M", "x": 0, "y": 6, "height": 2, "align": "middle_center"},
|
||
{"type": "solid", "points": [[0, 0], [-3, 1.2], [-3, -1.2]]},
|
||
]
|
||
ATTDEFS = [
|
||
{"tag": "TAG", "x": 0, "y": 5.5, "height": 2.5, "align": "center"},
|
||
{"tag": "LINK", "x": 0, "y": -5, "height": 2.0, "invisible": True, "default": "L-1"},
|
||
]
|
||
|
||
|
||
# ── headless engine ─────────────────────────────────────────────────────────
|
||
|
||
|
||
async def test_defines_geometry_and_attdefs(backend):
|
||
result = await backend.block_define("PID_VALVE_TEST", VALVE, ATTDEFS)
|
||
assert result == {
|
||
"ok": True,
|
||
"name": "PID_VALVE_TEST",
|
||
"entity_count": 7,
|
||
"attdef_count": 2,
|
||
"replaced": False,
|
||
"backend": "ezdxf",
|
||
}
|
||
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",
|
||
]
|
||
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"
|
||
assert attdefs["TAG"].is_invisible is False
|
||
for e in blk:
|
||
assert e.dxf.layer == "0"
|
||
assert e.dxf.color == 0, "ByBlock colour so the INSERT's layer drives it"
|
||
|
||
|
||
async def test_insert_after_define_carries_the_attributes(backend):
|
||
await backend.block_define("PID_VALVE_TEST", VALVE, ATTDEFS)
|
||
ref = await backend.block_insert("PID_VALVE_TEST", 10, 10, attributes={"TAG": "HV-101"})
|
||
assert await backend.block_get_attributes(ref.handle) == {"TAG": "HV-101", "LINK": "L-1"}
|
||
|
||
|
||
async def test_name_clash_is_refused_unless_overwrite(backend):
|
||
await backend.block_define("PID_X", VALVE)
|
||
with pytest.raises(ValueError, match="overwrite"):
|
||
await backend.block_define("PID_X", VALVE)
|
||
result = await backend.block_define("PID_X", VALVE[:1], overwrite=True)
|
||
assert result["replaced"] is True and result["entity_count"] == 1
|
||
assert len(list(backend._doc.blocks.get("PID_X"))) == 1
|
||
|
||
|
||
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
|
||
)
|
||
info = await backend.entity_get(ref.handle)
|
||
assert info.properties["block_name"] == "PID_X"
|
||
virtual = list(backend._doc.entitydb[ref.handle].virtual_entities())
|
||
assert [e.dxftype() for e in virtual] == ["CIRCLE"]
|
||
|
||
|
||
async def test_invalid_request_writes_nothing(backend):
|
||
with pytest.raises(TypeError, match=r"entities\[1\]"):
|
||
await backend.block_define("PID_BAD", [VALVE[0], {"type": "circle", "cx": 0, "cy": 0}])
|
||
assert "PID_BAD" not in backend._doc.blocks
|
||
with pytest.raises(ValueError, match="empty"):
|
||
await backend.block_define("PID_EMPTY", [])
|
||
assert "PID_EMPTY" not in backend._doc.blocks
|
||
|
||
|
||
async def test_anonymous_names_are_refused(backend):
|
||
with pytest.raises(ValueError, match="anonymous"):
|
||
await backend.block_define("*U9", VALVE)
|
||
|
||
|
||
async def test_solid_uses_bowtie_order(backend):
|
||
await backend.block_define(
|
||
"PID_SOLID", [{"type": "solid", "points": [[0, 0], [1, 0], [1, 1], [0, 1]]}]
|
||
)
|
||
solid = next(e for e in backend._doc.blocks.get("PID_SOLID") if e.dxftype() == "SOLID")
|
||
assert tuple(solid.dxf.vtx2)[:2] == (0.0, 1.0)
|
||
assert tuple(solid.dxf.vtx3)[:2] == (1.0, 1.0)
|
||
|
||
|
||
# ── live engine, against a fake ActiveX surface ──────────────────────────────
|
||
|
||
|
||
class _Recorder:
|
||
"""Records every method call made on it, returns a styled child."""
|
||
|
||
def __init__(self, name):
|
||
self.Name = name
|
||
self.calls: list[tuple[str, tuple]] = []
|
||
self.Count = 0
|
||
|
||
def __getattr__(self, attr):
|
||
if attr.startswith("Add"):
|
||
|
||
def _call(*args):
|
||
self.calls.append((attr, args))
|
||
return types.SimpleNamespace()
|
||
|
||
return _call
|
||
raise AttributeError(attr)
|
||
|
||
|
||
class _FakeBlocks:
|
||
def __init__(self):
|
||
self.blocks: dict[str, _Recorder] = {}
|
||
|
||
def Add(self, base_point, name):
|
||
self.blocks[name] = _Recorder(name)
|
||
self.blocks[name].base_point = tuple(base_point.value)
|
||
return self.blocks[name]
|
||
|
||
def Item(self, name):
|
||
if name not in self.blocks:
|
||
raise RuntimeError(f"no block {name}")
|
||
return self.blocks[name]
|
||
|
||
|
||
@pytest.fixture
|
||
def com_backend(monkeypatch):
|
||
pytest.importorskip("win32com.client", reason="pywin32 not installed")
|
||
from backends import com_backend as module
|
||
|
||
document = types.SimpleNamespace(Blocks=_FakeBlocks())
|
||
monkeypatch.setattr(module, "_acad_doc", lambda: document)
|
||
monkeypatch.setattr(module, "_regen", lambda: None)
|
||
backend = module.ComBackend()
|
||
|
||
async def _run_inline(func, *args, **kwargs):
|
||
return func(*args, **kwargs)
|
||
|
||
monkeypatch.setattr(backend, "_run", _run_inline)
|
||
return backend, document
|
||
|
||
|
||
async def test_com_issues_one_activex_call_per_primitive_and_attdef(com_backend):
|
||
backend, document = com_backend
|
||
result = await backend.block_define("PID_VALVE_TEST", VALVE, ATTDEFS, 1.0, 2.0)
|
||
assert result["ok"] is True and result["backend"] == "com" and result["replaced"] is False
|
||
block = document.Blocks.blocks["PID_VALVE_TEST"]
|
||
assert block.base_point == (1.0, 2.0, 0.0)
|
||
names = [name for name, _ in block.calls]
|
||
assert names == [
|
||
"AddLightWeightPolyline",
|
||
"AddLightWeightPolyline",
|
||
"AddCircle",
|
||
"AddArc",
|
||
"AddLine",
|
||
"AddText",
|
||
"AddSolid",
|
||
"AddAttribute",
|
||
"AddAttribute",
|
||
]
|
||
tag_call = block.calls[7][1]
|
||
link_call = block.calls[8][1]
|
||
assert tag_call[0] == 2.5 and tag_call[1] == 0 and tag_call[4] == "TAG"
|
||
assert link_call[1] == 1, "invisible ATTDEF uses acAttributeModeInvisible"
|
||
assert link_call[5] == "L-1"
|
||
|
||
|
||
async def test_com_refuses_a_clash_without_overwrite(com_backend):
|
||
backend, document = com_backend
|
||
await backend.block_define("PID_X", VALVE[:1])
|
||
with pytest.raises(ValueError, match="overwrite"):
|
||
await backend.block_define("PID_X", VALVE[:1])
|
||
|
||
|
||
async def test_com_validates_before_touching_activex(com_backend):
|
||
backend, document = com_backend
|
||
with pytest.raises(TypeError):
|
||
await backend.block_define("PID_BAD", [{"type": "circle", "cx": 0}])
|
||
assert document.Blocks.blocks == {}
|
||
```
|
||
|
||
- [ ] **Step 2: Run them to verify they fail**
|
||
|
||
Run: `uv run --frozen pytest tests/test_block_define.py -v`
|
||
Expected: FAIL with `AttributeError: 'EzdxfBackend' object has no attribute 'block_define'`.
|
||
|
||
- [ ] **Step 3: Add the contract method**
|
||
|
||
Append to `BlockContract` in `backends/contracts/blocks.py`:
|
||
|
||
```python
|
||
@abstractmethod
|
||
async def block_define(
|
||
self,
|
||
name: str,
|
||
entities: list[dict],
|
||
attdefs: list[dict] | None = None,
|
||
base_x: float = 0.0,
|
||
base_y: float = 0.0,
|
||
overwrite: bool = False,
|
||
) -> dict:
|
||
"""Create (or with ``overwrite`` replace the contents of) a block
|
||
definition from typed primitive specs and ATTDEF specs — see
|
||
``backends/block_specs.py`` for the spec vocabulary. Returns
|
||
``{ok, name, entity_count, attdef_count, replaced, backend}``."""
|
||
...
|
||
```
|
||
|
||
- [ ] **Step 4: Implement on ezdxf**
|
||
|
||
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.
|
||
|
||
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
|
||
|
||
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"
|
||
)
|
||
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"],
|
||
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)
|
||
```
|
||
|
||
- [ ] **Step 5: Implement on COM**
|
||
|
||
Add after `block_create_from_entities` in `backends/com_backend.py` (module already imports `deg2rad`, `win32com`, `pythoncom`, `_apoint`, `_av`, `_acad_doc`, `_regen`):
|
||
|
||
```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, through ActiveX.
|
||
|
||
``Blocks.Add`` creates the definition; each primitive is one ``Add*``
|
||
call on the Block object, each ATTDEF one ``AddAttribute``. Overwrite
|
||
deletes the existing definition's members and re-adds — the name and
|
||
its INSERTs survive. Unit-tested against a fake ActiveX surface;
|
||
executed live by ``scripts/smoke_pid_com.py``.
|
||
"""
|
||
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 [])
|
||
# acAlignmentLeft / Center / Right / MiddleCenter
|
||
align_map = {"left": 0, "center": 1, "right": 2, "middle_center": 10}
|
||
|
||
def _style(obj, layer: str) -> None:
|
||
obj.Layer = layer
|
||
obj.Color = 0 # acByBlock
|
||
obj.Linetype = "ByBlock"
|
||
|
||
def _sync():
|
||
doc = _acad_doc()
|
||
try:
|
||
existing = doc.Blocks.Item(clean_name)
|
||
except Exception:
|
||
existing = 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 = _apoint(float(base_x), float(base_y), 0.0)
|
||
if existing is None:
|
||
block = doc.Blocks.Add(base, clean_name)
|
||
else:
|
||
block = existing
|
||
members = [block.Item(i) for i in range(block.Count)]
|
||
for member in members:
|
||
member.Delete()
|
||
block.Origin = base
|
||
for spec in ents:
|
||
kind = spec["type"]
|
||
if kind == "line":
|
||
obj = block.AddLine(
|
||
_apoint(spec["x1"], spec["y1"]), _apoint(spec["x2"], spec["y2"])
|
||
)
|
||
elif kind == "circle":
|
||
obj = block.AddCircle(_apoint(spec["cx"], spec["cy"]), float(spec["r"]))
|
||
elif kind == "arc":
|
||
obj = block.AddArc(
|
||
_apoint(spec["cx"], spec["cy"]),
|
||
float(spec["r"]),
|
||
deg2rad(spec["start_deg"]),
|
||
deg2rad(spec["end_deg"]),
|
||
)
|
||
elif kind == "polyline":
|
||
flat = [coord for pt in spec["points"] for coord in pt]
|
||
obj = block.AddLightWeightPolyline(_av(flat))
|
||
obj.Closed = bool(spec["closed"])
|
||
for index, bulge in enumerate(spec["bulges"] or []):
|
||
if bulge:
|
||
obj.SetBulge(index, float(bulge))
|
||
elif kind == "text":
|
||
obj = block.AddText(
|
||
spec["text"], _apoint(spec["x"], spec["y"]), float(spec["height"])
|
||
)
|
||
obj.Rotation = deg2rad(spec["rotation_deg"])
|
||
if spec["align"] != "left":
|
||
obj.Alignment = align_map[spec["align"]]
|
||
obj.TextAlignmentPoint = _apoint(spec["x"], spec["y"])
|
||
elif kind == "solid":
|
||
verts = solid_vertices(spec["points"])
|
||
verts = verts + [verts[-1]] * (4 - len(verts))
|
||
obj = block.AddSolid(*[_apoint(x, y) for x, y in verts])
|
||
_style(obj, spec["layer"])
|
||
for att in atts:
|
||
mode = 1 if att["invisible"] else 0 # acAttributeModeInvisible / Normal
|
||
obj = block.AddAttribute(
|
||
float(att["height"]),
|
||
mode,
|
||
att["prompt"],
|
||
_apoint(att["x"], att["y"]),
|
||
att["tag"],
|
||
att["default"],
|
||
)
|
||
obj.Rotation = deg2rad(att["rotation_deg"])
|
||
if att["align"] != "left":
|
||
obj.Alignment = align_map[att["align"]]
|
||
obj.TextAlignmentPoint = _apoint(att["x"], att["y"])
|
||
_style(obj, "0")
|
||
_regen()
|
||
return {
|
||
"ok": True,
|
||
"name": clean_name,
|
||
"entity_count": len(ents),
|
||
"attdef_count": len(atts),
|
||
"replaced": replaced,
|
||
"backend": "com",
|
||
}
|
||
|
||
return await self._run(_sync)
|
||
```
|
||
|
||
- [ ] **Step 6: Run the tests**
|
||
|
||
Run: `uv run --frozen pytest tests/test_block_define.py tests/test_capability_contract.py -v`
|
||
Expected: all PASS (the capability-contract test proves both backends still instantiate).
|
||
|
||
- [ ] **Step 7: Register the tool, alias, header count, inventory, README**
|
||
|
||
In `server.py`, change the header `# ── SECTION 7: Block Operations (7 tools)` to `(8 tools)` and add after `block_create_from_entities`:
|
||
|
||
```python
|
||
@cad_tool(
|
||
summary="Define a block from typed primitives and attribute definitions, on both engines.",
|
||
cost="mutate",
|
||
)
|
||
@mcp.tool(
|
||
annotations={"title": "Define Block", "readOnlyHint": False},
|
||
tags={"block", "create"},
|
||
)
|
||
async def block_define(
|
||
name: Annotated[str, "New block definition name"],
|
||
entities: Annotated[
|
||
list[dict],
|
||
"Primitives in block-local coordinates. type=line {x1,y1,x2,y2} | circle {cx,cy,r} | "
|
||
"arc {cx,cy,r,start_deg,end_deg} | polyline {points,closed,bulges?} | "
|
||
"text {text,x,y,height,rotation_deg?,align?} | solid {points (3-4)}. Optional layer (default 0).",
|
||
],
|
||
attdefs: Annotated[
|
||
list[dict] | None,
|
||
"Attribute definitions: {tag, x, y, height, prompt?, default?, rotation_deg?, align?, invisible?}",
|
||
] = 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,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Create a block definition with attribute definitions from typed specs.
|
||
|
||
The whole request is validated before anything is written: one malformed
|
||
entry refuses the call and leaves no definition behind. `overwrite=true`
|
||
replaces the *contents* of an existing block so its INSERTs keep pointing
|
||
at the name and show the new geometry; `replaced` reports it. Geometry
|
||
inside the block is ByBlock so the INSERT's layer supplies colour and
|
||
lineweight. Insert with `block_insert(attributes={TAG: value})`.
|
||
"""
|
||
await ctx.info(f"Defining block '{name}' from {len(entities)} primitives")
|
||
return await _backend(ctx).block_define(name, entities, attdefs, base_x, base_y, overwrite)
|
||
```
|
||
|
||
In `discovery/aliases.py`, add to `TOOL_ALIASES` next to `block_create_from_entities`:
|
||
|
||
```python
|
||
"block_define": ToolAliases(
|
||
acad=("BLOCK", "BEDIT", "ATTDEF"),
|
||
synonyms=(
|
||
"define a block",
|
||
"make a symbol",
|
||
"block with attributes",
|
||
"attribute definition",
|
||
"create a block definition from scratch",
|
||
),
|
||
),
|
||
```
|
||
|
||
Then: `uv run --frozen python scripts/generate_tool_inventory.py` and change the README snapshot line to `155 tools`.
|
||
|
||
- [ ] **Step 8: Run the gates**
|
||
|
||
Run: `uv run --frozen pytest tests/test_block_define.py tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py -v`
|
||
Expected: all PASS. (If `test_document_numbers_match_the_tree` fails only on the README `collected tests` figure, that drift predates this track — leave it for Task 21.)
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add backends/contracts/blocks.py backends/ezdxf_backend.py backends/com_backend.py server.py discovery/aliases.py docs/tool-inventory.json README.md tests/test_block_define.py
|
||
git commit -m "feat(blocks): block_define — typed primitives + ATTDEFs on both engines"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: XDATA read/write on both engines + tools
|
||
|
||
**Files:**
|
||
- Create: `backends/xdata_specs.py`
|
||
- Modify: `backends/contracts/entity_query.py` (append two abstract methods)
|
||
- Modify: `backends/ezdxf_backend.py`, `backends/com_backend.py`
|
||
- Modify: `server.py` (SECTION 5 header `(7 tools)` → `(9 tools)`; two tools after `entity_set_properties`)
|
||
- Modify: `discovery/aliases.py`, `README.md` (`155 tools` → `157 tools`), regenerate `docs/tool-inventory.json`
|
||
- Test: `tests/test_xdata.py`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `backends/xdata_specs.py`: `validate_app_name(app) -> str`; `encode_values(values: list) -> list[tuple[int, object]]` (`str`→1000, `int`→1071, `float`→1040, `[x, y(, z)]`→1010 as `(x, y, z)`); `decode_values(tags) -> list` (inverse; 1010 → `[x, y, z]`); `split_by_app(codes, values) -> dict[str, list]`; `MAX_STRING = 255`, `MAX_BYTES = 16384`.
|
||
- `await backend.entity_get_xdata(handle, app_name=None) -> {"handle", "xdata": {app: [values]}, "backend"}`
|
||
- `await backend.entity_set_xdata(handle, app_name, values) -> {"ok": True, "handle", "app_name", "value_count", "bytes", "removed": bool, "backend"}` — empty `values` removes the app's XDATA.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```python
|
||
# tests/test_xdata.py
|
||
"""XDATA round trips, typing by group code, limits, removal — both engines."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import types
|
||
|
||
import pytest
|
||
|
||
from backends.xdata_specs import (
|
||
MAX_BYTES,
|
||
MAX_STRING,
|
||
decode_values,
|
||
encode_values,
|
||
split_by_app,
|
||
validate_app_name,
|
||
)
|
||
|
||
pytestmark = pytest.mark.asyncio
|
||
|
||
|
||
# ── pure encoding ────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_values_are_typed_by_group_code():
|
||
assert encode_values(["json", 7, 2.5, [1, 2], [1, 2, 3]]) == [
|
||
(1000, "json"),
|
||
(1071, 7),
|
||
(1040, 2.5),
|
||
(1010, (1.0, 2.0, 0.0)),
|
||
(1010, (1.0, 2.0, 3.0)),
|
||
]
|
||
|
||
|
||
def test_decode_is_the_inverse():
|
||
tags = encode_values(["a", 1, 1.5, [3, 4]])
|
||
assert decode_values(tags) == ["a", 1, 1.5, [3.0, 4.0, 0.0]]
|
||
|
||
|
||
@pytest.mark.parametrize("bad", [True, None, {"a": 1}, [1], [1, 2, 3, 4], ["x", "y"], 2**31])
|
||
def test_untypeable_values_are_refused(bad):
|
||
with pytest.raises((TypeError, ValueError)):
|
||
encode_values([bad])
|
||
|
||
|
||
def test_string_over_255_is_refused():
|
||
encode_values(["x" * MAX_STRING])
|
||
with pytest.raises(ValueError, match="255"):
|
||
encode_values(["x" * (MAX_STRING + 1)])
|
||
|
||
|
||
def test_total_size_over_16k_is_refused():
|
||
with pytest.raises(ValueError, match="16"):
|
||
encode_values(["x" * 255] * (MAX_BYTES // 255 + 1))
|
||
|
||
|
||
@pytest.mark.parametrize("bad", ["", "1abc", "a-b", "a" * 32, "ACAD"])
|
||
def test_bad_app_names_are_refused(bad):
|
||
with pytest.raises(ValueError):
|
||
validate_app_name(bad)
|
||
|
||
|
||
def test_split_by_app_groups_a_flat_activex_stream():
|
||
codes = [1001, 1000, 1071, 1001, 1040]
|
||
values = ["APP_A", "hello", 3, "APP_B", 1.5]
|
||
assert split_by_app(codes, values) == {"APP_A": ["hello", 3], "APP_B": [1.5]}
|
||
|
||
|
||
# ── headless engine ─────────────────────────────────────────────────────────
|
||
|
||
|
||
async def test_roundtrip_registers_the_appid(backend):
|
||
line = await backend.entity_create_line(0, 0, 1, 1)
|
||
result = await backend.entity_set_xdata(line.handle, "ACADMCP_PID", ["json", 42, 1.5, [1, 2]])
|
||
assert result["ok"] is True and result["value_count"] == 4 and result["removed"] is False
|
||
assert "ACADMCP_PID" in backend._doc.appids
|
||
got = await backend.entity_get_xdata(line.handle, "ACADMCP_PID")
|
||
assert got["xdata"] == {"ACADMCP_PID": ["json", 42, 1.5, [1.0, 2.0, 0.0]]}
|
||
|
||
|
||
async def test_get_without_app_returns_every_app(backend):
|
||
line = await backend.entity_create_line(0, 0, 1, 1)
|
||
await backend.entity_set_xdata(line.handle, "APP_A", ["a"])
|
||
await backend.entity_set_xdata(line.handle, "APP_B", ["b"])
|
||
got = await backend.entity_get_xdata(line.handle)
|
||
assert got["xdata"] == {"APP_A": ["a"], "APP_B": ["b"]}
|
||
|
||
|
||
async def test_unknown_app_is_empty_not_an_error(backend):
|
||
line = await backend.entity_create_line(0, 0, 1, 1)
|
||
got = await backend.entity_get_xdata(line.handle, "NOPE")
|
||
assert got["xdata"] == {}
|
||
|
||
|
||
async def test_set_replaces_and_empty_removes(backend):
|
||
line = await backend.entity_create_line(0, 0, 1, 1)
|
||
await backend.entity_set_xdata(line.handle, "APP_A", ["one", "two"])
|
||
await backend.entity_set_xdata(line.handle, "APP_A", ["three"])
|
||
assert (await backend.entity_get_xdata(line.handle, "APP_A"))["xdata"] == {"APP_A": ["three"]}
|
||
result = await backend.entity_set_xdata(line.handle, "APP_A", [])
|
||
assert result["removed"] is True
|
||
assert (await backend.entity_get_xdata(line.handle))["xdata"] == {}
|
||
|
||
|
||
async def test_limits_are_enforced_before_writing(backend):
|
||
line = await backend.entity_create_line(0, 0, 1, 1)
|
||
with pytest.raises(ValueError, match="255"):
|
||
await backend.entity_set_xdata(line.handle, "APP_A", ["x" * 256])
|
||
assert (await backend.entity_get_xdata(line.handle))["xdata"] == {}
|
||
|
||
|
||
# ── live engine, fake ActiveX ────────────────────────────────────────────────
|
||
|
||
|
||
class _FakeEntity:
|
||
def __init__(self):
|
||
self.stored: tuple | None = None
|
||
|
||
def SetXData(self, types_, values):
|
||
self.stored = (list(types_.value), list(values.value))
|
||
|
||
def GetXData(self, app):
|
||
if self.stored is None:
|
||
return ([], [])
|
||
codes, values = self.stored
|
||
if app:
|
||
return (codes, values)
|
||
return (codes, values)
|
||
|
||
|
||
@pytest.fixture
|
||
def com_backend(monkeypatch):
|
||
pytest.importorskip("win32com.client", reason="pywin32 not installed")
|
||
from backends import com_backend as module
|
||
|
||
entity = _FakeEntity()
|
||
registered: list[str] = []
|
||
document = types.SimpleNamespace(
|
||
HandleToObject=lambda handle: entity,
|
||
RegisteredApplications=types.SimpleNamespace(Add=registered.append),
|
||
)
|
||
monkeypatch.setattr(module, "_acad_doc", lambda: document)
|
||
backend = module.ComBackend()
|
||
|
||
async def _run_inline(func, *args, **kwargs):
|
||
return func(*args, **kwargs)
|
||
|
||
monkeypatch.setattr(backend, "_run", _run_inline)
|
||
return backend, entity, registered
|
||
|
||
|
||
async def test_com_set_writes_the_1001_marker_first(com_backend):
|
||
backend, entity, registered = com_backend
|
||
await backend.entity_set_xdata("2F", "ACADMCP_PID", ["json", 3, [1, 2]])
|
||
assert registered == ["ACADMCP_PID"]
|
||
codes, values = entity.stored
|
||
assert codes == [1001, 1000, 1071, 1010]
|
||
assert values[0] == "ACADMCP_PID" and values[1] == "json" and values[2] == 3
|
||
assert tuple(values[3].value) == (1.0, 2.0, 0.0)
|
||
|
||
|
||
async def test_com_get_splits_by_app(com_backend):
|
||
backend, entity, _ = com_backend
|
||
await backend.entity_set_xdata("2F", "ACADMCP_PID", ["json"])
|
||
got = await backend.entity_get_xdata("2F", "ACADMCP_PID")
|
||
assert got["xdata"] == {"ACADMCP_PID": ["json"]}
|
||
|
||
|
||
async def test_com_empty_values_write_only_the_marker(com_backend):
|
||
backend, entity, _ = com_backend
|
||
result = await backend.entity_set_xdata("2F", "ACADMCP_PID", [])
|
||
assert result["removed"] is True
|
||
assert entity.stored == ([1001], ["ACADMCP_PID"])
|
||
```
|
||
|
||
- [ ] **Step 2: Run them to verify they fail**
|
||
|
||
Run: `uv run --frozen pytest tests/test_xdata.py -v`
|
||
Expected: FAIL with `ModuleNotFoundError: No module named 'backends.xdata_specs'`.
|
||
|
||
- [ ] **Step 3: Write `backends/xdata_specs.py`**
|
||
|
||
```python
|
||
# backends/xdata_specs.py
|
||
"""XDATA value typing and limits, shared by both engines.
|
||
|
||
DXF XDATA is a list of (group code, value) pairs under a registered APPID.
|
||
This module is the one place that decides how a JSON value becomes a group
|
||
code and enforces the two limits AutoCAD enforces silently: 255 characters
|
||
per string and 16 KB per entity.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
|
||
APP_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,30}$")
|
||
RESERVED_APPS = {"ACAD"}
|
||
MAX_STRING = 255
|
||
MAX_BYTES = 16 * 1024
|
||
INT_MIN, INT_MAX = -(2**31), 2**31 - 1
|
||
|
||
|
||
def validate_app_name(app) -> str:
|
||
if not isinstance(app, str) or not APP_NAME_RE.match(app):
|
||
raise ValueError(f"xdata: app name must match {APP_NAME_RE.pattern}, got {app!r}")
|
||
if app.upper() in RESERVED_APPS:
|
||
raise ValueError(f"xdata: app name {app!r} is reserved")
|
||
return app
|
||
|
||
|
||
def encode_values(values) -> list[tuple[int, object]]:
|
||
"""JSON values → (group code, value). Raises TypeError/ValueError; writes nothing."""
|
||
if not isinstance(values, (list, tuple)):
|
||
raise TypeError("xdata: values must be a list")
|
||
tags: list[tuple[int, object]] = []
|
||
size = 0
|
||
for i, value in enumerate(values):
|
||
where = f"values[{i}]"
|
||
if isinstance(value, bool):
|
||
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}"
|
||
)
|
||
tags.append((1000, value))
|
||
size += len(value.encode("utf-8")) + 2
|
||
elif isinstance(value, int):
|
||
if not INT_MIN <= value <= INT_MAX:
|
||
raise ValueError(f"xdata: {where} is outside the 32-bit integer range")
|
||
tags.append((1071, int(value)))
|
||
size += 6
|
||
elif isinstance(value, float):
|
||
tags.append((1040, float(value)))
|
||
size += 10
|
||
elif isinstance(value, (list, tuple)) and len(value) in (2, 3):
|
||
try:
|
||
coords = tuple(float(c) for c in value)
|
||
except (TypeError, ValueError) as exc:
|
||
raise TypeError(f"xdata: {where} point coordinates must be numbers") from exc
|
||
if any(isinstance(c, bool) for c in value):
|
||
raise TypeError(f"xdata: {where} point coordinates must be numbers")
|
||
if len(coords) == 2:
|
||
coords = (coords[0], coords[1], 0.0)
|
||
tags.append((1010, coords))
|
||
size += 26
|
||
else:
|
||
raise TypeError(f"xdata: {where} must be a string, int, float or [x, y(, z)] point")
|
||
if size > MAX_BYTES:
|
||
raise ValueError(f"xdata: encoded size {size} bytes exceeds the 16 KB per-entity limit")
|
||
return tags
|
||
|
||
|
||
def decode_values(tags) -> list:
|
||
"""(group code, value) → JSON values. Skips the 1001 app marker and 1002 braces."""
|
||
out: list = []
|
||
for code, value in tags:
|
||
if code in (1001, 1002):
|
||
continue
|
||
if code == 1010 or code in (1011, 1012, 1013):
|
||
out.append([float(c) for c in value])
|
||
elif code in (1040, 1041, 1042):
|
||
out.append(float(value))
|
||
elif code in (1070, 1071):
|
||
out.append(int(value))
|
||
else:
|
||
out.append(str(value))
|
||
return out
|
||
|
||
|
||
def split_by_app(codes, values) -> dict[str, list]:
|
||
"""A flat ActiveX GetXData stream (1001 markers inline) → {app: values}."""
|
||
out: dict[str, list] = {}
|
||
current: str | None = None
|
||
pending: list[tuple[int, object]] = []
|
||
for code, value in zip(codes, values):
|
||
if code == 1001:
|
||
if current is not None:
|
||
out[current] = decode_values(pending)
|
||
current, pending = str(value), []
|
||
else:
|
||
pending.append((code, value))
|
||
if current is not None:
|
||
out[current] = decode_values(pending)
|
||
return out
|
||
```
|
||
|
||
- [ ] **Step 4: Add the contract methods**
|
||
|
||
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_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
|
||
|
||
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"}
|
||
|
||
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)
|
||
|
||
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)
|
||
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)
|
||
```
|
||
|
||
- [ ] **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
|
||
|
||
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"}
|
||
|
||
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)
|
||
|
||
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**
|
||
|
||
Run: `uv run --frozen pytest tests/test_xdata.py tests/test_capability_contract.py -v`
|
||
Expected: all PASS.
|
||
|
||
- [ ] **Step 8: Tools, aliases, header, inventory, README**
|
||
|
||
`server.py`: SECTION 5 header `(7 tools)` → `(9 tools)`; add after `entity_set_properties`:
|
||
|
||
```python
|
||
@cad_tool(summary="Read an entity's extended data (XDATA) by application name.", cost="read")
|
||
@mcp.tool(
|
||
annotations={"title": "Get Entity XDATA", "readOnlyHint": True},
|
||
tags={"entity", "query"},
|
||
)
|
||
async def entity_get_xdata(
|
||
handle: Annotated[str, "Entity handle"],
|
||
app_name: Annotated[str | None, "Registered application name; omit for every app"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Extended entity data as {app: [values]}. An app with no XDATA is simply absent."""
|
||
return await _backend(ctx).entity_get_xdata(handle, app_name)
|
||
|
||
|
||
@cad_tool(summary="Attach or replace extended data (XDATA) on an entity.", cost="safe")
|
||
@mcp.tool(
|
||
annotations={"title": "Set Entity XDATA", "readOnlyHint": False},
|
||
tags={"entity", "modify"},
|
||
)
|
||
async def entity_set_xdata(
|
||
handle: Annotated[str, "Entity handle"],
|
||
app_name: Annotated[str, "Registered application name (created if missing)"],
|
||
values: Annotated[
|
||
list,
|
||
"Values typed by kind: strings (≤255 chars) → 1000, ints → 1071, floats → 1040, [x,y] → 1010",
|
||
],
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Replace the named app's XDATA on one entity; an empty list removes it.
|
||
|
||
Limits are AutoCAD's and are enforced before writing: 255 characters per
|
||
string and 16 KB per entity.
|
||
"""
|
||
return await _backend(ctx).entity_set_xdata(handle, app_name, values)
|
||
```
|
||
|
||
`discovery/aliases.py`, two records:
|
||
|
||
```python
|
||
"entity_get_xdata": ToolAliases(
|
||
acad=("XDATA", "XDLIST"),
|
||
synonyms=("extended data", "read xdata", "entity metadata", "registered application data"),
|
||
),
|
||
"entity_set_xdata": ToolAliases(
|
||
acad=("XDATA",),
|
||
synonyms=("attach extended data", "write xdata", "tag an entity with metadata"),
|
||
),
|
||
```
|
||
|
||
Regenerate the inventory; README snapshot `157 tools`.
|
||
|
||
- [ ] **Step 9: Run the gates and commit**
|
||
|
||
Run: `uv run --frozen pytest tests/test_xdata.py tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py -v`
|
||
Expected: PASS (same caveat about the pre-existing collected-tests drift).
|
||
|
||
```bash
|
||
git add backends/xdata_specs.py backends/contracts/entity_query.py backends/ezdxf_backend.py backends/com_backend.py server.py discovery/aliases.py docs/tool-inventory.json README.md tests/test_xdata.py
|
||
git commit -m "feat(entity): entity_get_xdata / entity_set_xdata on both engines"
|
||
```
|
||
|
||
---
|
||
### Task 5: `engineering/pid` package + `ACADMCP_PID` payloads
|
||
|
||
**Files:**
|
||
- Create: `engineering/pid/__init__.py`, `engineering/pid/xdata.py`
|
||
- Test: `tests/test_pid_xdata.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `backend.entity_get_xdata`, `backend.entity_set_xdata` (Task 4).
|
||
- Produces (`engineering/pid/xdata.py`): `APP_NAME = "ACADMCP_PID"`, `MARKER = "json"`, `CHUNK = 255`, `encode_payload(payload: dict) -> list[str]`, `decode_payload(values: list) -> dict | None`, `async write_payload(backend, handle, payload) -> dict`, `async read_payload(backend, handle) -> dict | None`, `symbol_payload(spec, params=None) -> dict`, `line_payload(line_class, number, size, service, spec, insulation, seq, from_ref, to_ref) -> dict`, `marker_payload(line_handle) -> dict`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```python
|
||
# tests/test_pid_xdata.py
|
||
"""ACADMCP_PID payloads survive the 255-character XDATA string limit."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
from engineering.pid.xdata import (
|
||
APP_NAME,
|
||
MARKER,
|
||
decode_payload,
|
||
encode_payload,
|
||
line_payload,
|
||
marker_payload,
|
||
read_payload,
|
||
write_payload,
|
||
)
|
||
|
||
pytestmark = pytest.mark.asyncio
|
||
|
||
|
||
def test_short_payload_is_marker_plus_one_chunk():
|
||
values = encode_payload({"v": 1, "kind": "marker", "line": "3A"})
|
||
assert values[0] == MARKER and len(values) == 2
|
||
assert decode_payload(values) == {"kind": "marker", "line": "3A", "v": 1}
|
||
|
||
|
||
def test_long_payload_is_chunked_at_255_and_rejoined():
|
||
payload = {"v": 1, "kind": "symbol", "ports": {f"N{i}": [i, i, 90] for i in range(60)}}
|
||
values = encode_payload(payload)
|
||
assert all(len(v) <= 255 for v in values)
|
||
assert len(values) > 2
|
||
assert decode_payload(values) == payload
|
||
|
||
|
||
def test_decode_rejects_foreign_or_broken_values():
|
||
assert decode_payload([]) is None
|
||
assert decode_payload(["not-json", "{}"]) is None
|
||
assert decode_payload([MARKER, "{not json"]) is None
|
||
|
||
|
||
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"},
|
||
)
|
||
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"}
|
||
|
||
|
||
async def test_roundtrip_through_a_backend(backend):
|
||
line = await backend.entity_create_line(0, 0, 10, 0)
|
||
payload = {"v": 1, "kind": "line", "class": "electric", "number": None}
|
||
await write_payload(backend, line.handle, payload)
|
||
assert await read_payload(backend, line.handle) == payload
|
||
got = await backend.entity_get_xdata(line.handle, APP_NAME)
|
||
assert got["xdata"][APP_NAME][0] == MARKER
|
||
|
||
|
||
async def test_read_payload_is_none_without_xdata(backend):
|
||
line = await backend.entity_create_line(0, 0, 10, 0)
|
||
assert await read_payload(backend, line.handle) is None
|
||
```
|
||
|
||
- [ ] **Step 2: Run them to verify they fail**
|
||
|
||
Run: `uv run --frozen pytest tests/test_pid_xdata.py -v`
|
||
Expected: FAIL with `ModuleNotFoundError: No module named 'engineering.pid'`.
|
||
|
||
- [ ] **Step 3: Write the package init and the module**
|
||
|
||
```python
|
||
# engineering/pid/__init__.py
|
||
"""P&ID expert and reader: symbol catalogue, lines, ISA-5.1 tags, graph."""
|
||
```
|
||
|
||
```python
|
||
# engineering/pid/xdata.py
|
||
"""``ACADMCP_PID`` XDATA payloads.
|
||
|
||
XDATA strings are capped at 255 characters, so a JSON payload is stored as
|
||
``["json", chunk, chunk, ...]`` and re-joined on read. Every symbol INSERT and
|
||
every P&ID line carries one, so a reader without the catalogue (another tool,
|
||
a later catalogue version) still sees ports, classes and numbers.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from typing import TYPE_CHECKING, Any
|
||
|
||
if TYPE_CHECKING:
|
||
from backends.base import AutoCADBackend
|
||
|
||
APP_NAME = "ACADMCP_PID"
|
||
MARKER = "json"
|
||
CHUNK = 255
|
||
PAYLOAD_VERSION = 1
|
||
|
||
|
||
def encode_payload(payload: dict) -> list[str]:
|
||
text = json.dumps(payload, separators=(",", ":"), sort_keys=True, ensure_ascii=True)
|
||
return [MARKER] + [text[i : i + CHUNK] for i in range(0, len(text), CHUNK)]
|
||
|
||
|
||
def decode_payload(values: list) -> dict | None:
|
||
if not values or values[0] != MARKER:
|
||
return None
|
||
parts = [v for v in values[1:] if isinstance(v, str)]
|
||
try:
|
||
decoded = json.loads("".join(parts))
|
||
except json.JSONDecodeError:
|
||
return None
|
||
return decoded if isinstance(decoded, dict) else None
|
||
|
||
|
||
async def write_payload(backend: AutoCADBackend, handle: str, payload: dict) -> dict:
|
||
return await backend.entity_set_xdata(handle, APP_NAME, encode_payload(payload))
|
||
|
||
|
||
async def read_payload(backend: AutoCADBackend, handle: str) -> dict | None:
|
||
result = await backend.entity_get_xdata(handle, APP_NAME)
|
||
return decode_payload(result.get("xdata", {}).get(APP_NAME, []))
|
||
|
||
|
||
def symbol_payload(spec: Any, params: dict | None = None) -> dict:
|
||
"""``spec`` is an ``engineering.pid.symbols.SymbolSpec`` (typed loosely to
|
||
avoid an import cycle). Ports are symbol-local ``[x, y, direction, kind, radius]``."""
|
||
from .symbols import CATALOG_VERSION
|
||
|
||
return {
|
||
"v": PAYLOAD_VERSION,
|
||
"kind": "symbol",
|
||
"catalog": CATALOG_VERSION,
|
||
"name": spec.name,
|
||
"family": spec.family,
|
||
"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},
|
||
}
|
||
|
||
|
||
def line_payload(
|
||
line_class: str,
|
||
number: str | None,
|
||
size: str | None,
|
||
service: str | None,
|
||
spec: str | None,
|
||
insulation: str | None,
|
||
seq: int | None,
|
||
from_ref: dict | None,
|
||
to_ref: dict | None,
|
||
) -> dict:
|
||
return {
|
||
"v": PAYLOAD_VERSION,
|
||
"kind": "line",
|
||
"class": line_class,
|
||
"number": number,
|
||
"size": size,
|
||
"service": service,
|
||
"spec": spec,
|
||
"insulation": insulation,
|
||
"seq": seq,
|
||
"from": from_ref,
|
||
"to": to_ref,
|
||
}
|
||
|
||
|
||
def marker_payload(line_handle: str) -> dict:
|
||
return {"v": PAYLOAD_VERSION, "kind": "marker", "line": line_handle}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the tests (the `symbol_payload` import of `CATALOG_VERSION` is exercised in Task 7)**
|
||
|
||
Run: `uv run --frozen pytest tests/test_pid_xdata.py -v`
|
||
Expected: all PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add engineering/pid/__init__.py engineering/pid/xdata.py tests/test_pid_xdata.py
|
||
git commit -m "feat(pid): ACADMCP_PID payload encoding with 255-char chunking"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: ISA-5.1 tag grammar (`engineering/pid/tags.py`)
|
||
|
||
**Files:**
|
||
- Create: `engineering/pid/tags.py`, `tests/data/isa51_tags.json`
|
||
- Test: `tests/test_pid_tags.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: `parse_tag(tag: str, kind: str = "auto", equipment_prefixes: dict | None = None) -> dict` (shape in spec §8), `split_instrument_tag(tag) -> tuple[str, str]` (`("FIC", "101A")`; area kept out, suffix kept in the loop text), `DEFAULT_EQUIPMENT_PREFIXES`, `FIRST_LETTERS`, `MODIFIERS`, `READOUT`, `OUTPUT`, `TRAILING`, `describe_tables() -> dict` (for the resource).
|
||
|
||
- [ ] **Step 1: Write the fixture**
|
||
|
||
```json
|
||
// tests/data/isa51_tags.json (JSON has no comments — drop this line when writing the file)
|
||
{
|
||
"valid": [
|
||
["FIC-101", "Flow Indicating Controller", "101"],
|
||
["FT-101", "Flow Transmitter", "101"],
|
||
["FE-101", "Flow Element", "101"],
|
||
["FQI-102", "Flow Totalizing Indicator", "102"],
|
||
["FFC-103", "Flow Ratio Controller", "103"],
|
||
["FY-104", "Flow Relay", "104"],
|
||
["FCV-105", "Flow Control Valve", "105"],
|
||
["FIT-106", "Flow Indicating Transmitter", "106"],
|
||
["PT-201", "Pressure Transmitter", "201"],
|
||
["PI-201", "Pressure Indicator", "201"],
|
||
["PG-202", "Pressure Gauge", "202"],
|
||
["PDT-203", "Pressure Differential Transmitter", "203"],
|
||
["PDI-203", "Pressure Differential Indicator", "203"],
|
||
["PDIC-204", "Pressure Differential Indicating Controller", "204"],
|
||
["PSV-205", "Pressure Safety Valve", "205"],
|
||
["PSL-206", "Pressure Switch Low", "206"],
|
||
["PSHH-207", "Pressure Switch High-high", "207"],
|
||
["PCV-208", "Pressure Control Valve", "208"],
|
||
["PIC-209", "Pressure Indicating Controller", "209"],
|
||
["LT-301", "Level Transmitter", "301"],
|
||
["LG-302", "Level Gauge", "302"],
|
||
["LAHH-303", "Level Alarm High-high", "303"],
|
||
["LAL-304", "Level Alarm Low", "304"],
|
||
["LSLL-305", "Level Switch Low-low", "305"],
|
||
["LIC-306", "Level Indicating Controller", "306"],
|
||
["LCV-307", "Level Control Valve", "307"],
|
||
["LS-308", "Level Switch", "308"],
|
||
["TE-401", "Temperature Element", "401"],
|
||
["TW-402", "Temperature Well", "402"],
|
||
["TIC-403", "Temperature Indicating Controller", "403"],
|
||
["TSE-404", "Temperature Safety Element", "404"],
|
||
["TAH-405", "Temperature Alarm High", "405"],
|
||
["TT-406", "Temperature Transmitter", "406"],
|
||
["TCV-407", "Temperature Control Valve", "407"],
|
||
["AIT-501", "Analysis Indicating Transmitter", "501"],
|
||
["HV-601", "Hand Valve", "601"],
|
||
["HS-602", "Hand Switch", "602"],
|
||
["HIC-603", "Hand Indicating Controller", "603"],
|
||
["ZSO-701", "Position Switch Open", "701"],
|
||
["ZSC-702", "Position Switch Closed", "702"],
|
||
["ZT-703", "Position Transmitter", "703"],
|
||
["SIC-801", "Speed Indicating Controller", "801"],
|
||
["WIT-802", "Weight Indicating Transmitter", "802"],
|
||
["VT-803", "Vibration Transmitter", "803"],
|
||
["JI-804", "Power Indicator", "804"],
|
||
["EI-805", "Voltage Indicator", "805"],
|
||
["II-806", "Current Indicator", "806"],
|
||
["BE-807", "Burner Element", "807"],
|
||
["RE-808", "Radiation Element", "808"],
|
||
["UY-809", "Multivariable Relay", "809"],
|
||
["PL-810", "Pressure Light", "810"]
|
||
],
|
||
"user_defined": [
|
||
["XV-901", "Unclassified Valve", "901"],
|
||
["NV-902", "Valve", "902"],
|
||
["CIC-903", "Indicating Controller", "903"]
|
||
],
|
||
"invalid": [
|
||
["FCI-101", "I"],
|
||
["FIH-102", "H"],
|
||
["F-103", "function"],
|
||
["FICC-104", "repeated"],
|
||
["PSVX-105", "X"],
|
||
["ABCDEF-106", "D"],
|
||
["FQQ-107", "Q"],
|
||
["1FC", "format"],
|
||
["FIC", "format"],
|
||
["fic-101-", "format"],
|
||
["LSO-108", "O"]
|
||
],
|
||
"formats": [
|
||
["10-FIC-101A", "10", "FIC", "101", "A"],
|
||
["FIC 101", null, "FIC", "101", null],
|
||
["FIC101", null, "FIC", "101", null],
|
||
["fic-101b", null, "FIC", "101", "B"]
|
||
],
|
||
"equipment": [
|
||
["P-101A", "pump", "101", "A"],
|
||
["V-201", "vessel", "201", ""],
|
||
["E-301", "exchanger", "301", ""],
|
||
["10-K-401", "compressor", "401", ""]
|
||
]
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Write the failing tests**
|
||
|
||
```python
|
||
# tests/test_pid_tags.py
|
||
"""ISA-5.1 identification-letter grammar, pinned against a reader-checkable fixture."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from engineering.pid.tags import (
|
||
DEFAULT_EQUIPMENT_PREFIXES,
|
||
describe_tables,
|
||
parse_tag,
|
||
split_instrument_tag,
|
||
)
|
||
|
||
FIXTURE = json.loads((Path(__file__).parent / "data" / "isa51_tags.json").read_text("utf-8"))
|
||
|
||
|
||
@pytest.mark.parametrize("tag, description, loop", FIXTURE["valid"])
|
||
def test_valid_tags_parse_with_the_standard_description(tag, description, loop):
|
||
out = parse_tag(tag)
|
||
assert out["valid"] is True, out["errors"]
|
||
assert out["kind"] == "instrument"
|
||
assert out["description"] == description
|
||
assert out["loop"] == loop
|
||
assert out["user_defined"] is False
|
||
|
||
|
||
@pytest.mark.parametrize("tag, description, loop", FIXTURE["user_defined"])
|
||
def test_user_choice_letters_parse_as_user_defined(tag, description, loop):
|
||
out = parse_tag(tag)
|
||
assert out["valid"] is True and out["user_defined"] is True
|
||
assert out["description"] == description
|
||
|
||
|
||
@pytest.mark.parametrize("tag, fragment", FIXTURE["invalid"])
|
||
def test_invalid_tags_name_the_offending_letter(tag, fragment):
|
||
out = parse_tag(tag, kind="instrument")
|
||
assert out["valid"] is False
|
||
assert out["tag"] == tag
|
||
assert any(fragment in err for err in out["errors"]), out["errors"]
|
||
|
||
|
||
@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,
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize("tag, kind, number, suffix", FIXTURE["equipment"])
|
||
def test_equipment_tags(tag, kind, number, suffix):
|
||
out = parse_tag(tag)
|
||
assert out["kind"] == "equipment" and out["valid"] is True
|
||
assert out["equipment_kind"] == kind and out["number"] == number and out["suffix"] == suffix
|
||
|
||
|
||
def test_equipment_prefixes_can_be_replaced_per_call():
|
||
out = parse_tag("Q-1", kind="equipment", equipment_prefixes={"Q": "quench tower"})
|
||
assert out["valid"] is True and out["equipment_kind"] == "quench tower"
|
||
default = parse_tag("Q-1", kind="equipment")
|
||
assert default["valid"] is False and "prefix" in default["errors"][0]
|
||
assert DEFAULT_EQUIPMENT_PREFIXES["P"] == "pump"
|
||
|
||
|
||
def test_auto_kind_prefers_instrument_then_equipment_then_reports_instrument_errors():
|
||
assert parse_tag("PT-1")["kind"] == "instrument"
|
||
assert parse_tag("P-1")["kind"] == "equipment"
|
||
bad = parse_tag("FCI-1")
|
||
assert bad["kind"] == "instrument" and bad["valid"] is False
|
||
|
||
|
||
def test_split_instrument_tag_feeds_func_and_loop_attributes():
|
||
assert split_instrument_tag("10-FIC-101A") == ("FIC", "101A")
|
||
assert split_instrument_tag("PT 7") == ("PT", "7")
|
||
assert split_instrument_tag("garbage") == ("GARBAGE", "")
|
||
|
||
|
||
def test_describe_tables_is_json_serialisable_and_names_every_first_letter():
|
||
tables = describe_tables()
|
||
json.dumps(tables)
|
||
assert set(tables["first_letters"]) == set("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||
```
|
||
|
||
- [ ] **Step 3: Run them to verify they fail**
|
||
|
||
Run: `uv run --frozen pytest tests/test_pid_tags.py -v`
|
||
Expected: FAIL with `ModuleNotFoundError: No module named 'engineering.pid.tags'`.
|
||
|
||
- [ ] **Step 4: Write the module**
|
||
|
||
```python
|
||
# engineering/pid/tags.py
|
||
"""ISA-5.1-2009 identification letters (Table 4.1) as a deterministic grammar.
|
||
|
||
tag := first modifier? readout* output* trailing?
|
||
modifier := D | F | K | Q | S
|
||
readout := A | B | E | G | I | L | N | O | P | R | U | W | X
|
||
output := C | K | S | T | U | V | X | Y | Z
|
||
trailing := HH | LL | H | L | M | O | C
|
||
|
||
The disambiguation rules (safety S vs switch S, trailing L vs light L, ZSO/ZSC,
|
||
ratio F only after F, rate-of-change K only before an output) are the ones in
|
||
the design spec; the fixture ``tests/data/isa51_tags.json`` pins every one of
|
||
them against the standard's own examples.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
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",
|
||
}
|
||
MODIFIERS: dict[str, str] = {
|
||
"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,
|
||
}
|
||
#: 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",
|
||
}
|
||
TRAILING: dict[str, str] = {
|
||
"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",
|
||
}
|
||
|
||
_INSTRUMENT_RE = re.compile(
|
||
r"^(?:(?P<area>\d{1,4})-)?(?P<letters>[A-Z]{1,5})[- ]?(?P<loop>\d{1,5})(?:[- ]?(?P<suffix>[A-Z]))?$"
|
||
)
|
||
_EQUIPMENT_RE = re.compile(
|
||
r"^(?:(?P<area>\d{1,4})-)?(?P<prefix>[A-Z]{1,3})-(?P<number>\d{1,5})(?P<suffix>[A-Z]{0,2})$"
|
||
)
|
||
|
||
|
||
def _normalise(tag) -> str:
|
||
return str(tag or "").strip().upper()
|
||
|
||
|
||
def _parse_letters(letters: str) -> dict:
|
||
"""Split ``letters`` into first/modifier/readout/output/trailing or report the first error."""
|
||
errors: list[str] = []
|
||
first = letters[0]
|
||
i, n = 1, len(letters)
|
||
if n == 1:
|
||
return {"errors": [f"{letters}: a tag needs at least one function letter after {first}"]}
|
||
modifier = None
|
||
if letters[i] in MODIFIERS:
|
||
candidate = letters[i]
|
||
following = letters[i + 1] if i + 1 < n else ""
|
||
take = (
|
||
candidate in ("D", "Q")
|
||
or (candidate == "S" and following in ("V", "E"))
|
||
or (candidate == "F" and first == "F")
|
||
or (candidate == "K" and following in OUTPUT)
|
||
)
|
||
if take:
|
||
modifier = candidate
|
||
i += 1
|
||
readout: list[str] = []
|
||
while i < n and letters[i] in READOUT:
|
||
letter = letters[i]
|
||
if letter in ("H", "L", "M") and readout and readout[-1] == "A":
|
||
break # trailing high/low/middle after an alarm
|
||
if readout and readout[-1] == letter:
|
||
errors.append(f"{letters}: repeated letter {letter} at position {i + 1}")
|
||
return {"errors": errors}
|
||
readout.append(letter)
|
||
i += 1
|
||
output: list[str] = []
|
||
while i < n and letters[i] in OUTPUT:
|
||
letter = letters[i]
|
||
if letter == "C" and output and output[-1] == "S" and i == n - 1:
|
||
break # ZSC: closed, not controller
|
||
if output and output[-1] == letter:
|
||
errors.append(f"{letters}: repeated letter {letter} at position {i + 1}")
|
||
return {"errors": errors}
|
||
output.append(letter)
|
||
i += 1
|
||
trailing = letters[i:]
|
||
if trailing:
|
||
if trailing not in TRAILING:
|
||
errors.append(f"{letters}: unexpected letter {trailing[0]} at position {i + 1}")
|
||
return {"errors": errors}
|
||
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"
|
||
)
|
||
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"
|
||
)
|
||
return {"errors": errors}
|
||
return {
|
||
"first": first,
|
||
"modifier": modifier,
|
||
"readout": readout,
|
||
"output": output,
|
||
"trailing": trailing or None,
|
||
"errors": [],
|
||
}
|
||
|
||
|
||
def _describe(parts: dict) -> tuple[str, bool]:
|
||
words: list[str] = []
|
||
user_defined = False
|
||
first_word = FIRST_LETTERS[parts["first"]]
|
||
if first_word is None:
|
||
user_defined = True
|
||
else:
|
||
words.append(first_word)
|
||
if parts["modifier"]:
|
||
words.append(MODIFIERS[parts["modifier"]])
|
||
readout, output = parts["readout"], parts["output"]
|
||
for index, letter in enumerate(readout):
|
||
forms = READOUT[letter]
|
||
if forms is None:
|
||
user_defined = True
|
||
continue
|
||
last_function = index == len(readout) - 1 and not output
|
||
words.append(forms[1] if last_function else forms[0])
|
||
for index, letter in enumerate(output):
|
||
word = OUTPUT[letter]
|
||
if word is None:
|
||
user_defined = True
|
||
continue
|
||
if letter == "C" and index + 1 < len(output) and output[index + 1] == "V":
|
||
word = "Control"
|
||
words.append(word)
|
||
if parts["trailing"]:
|
||
words.append(TRAILING[parts["trailing"]])
|
||
return " ".join(words), user_defined
|
||
|
||
|
||
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 = _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"),
|
||
)
|
||
parts = _parse_letters(match.group("letters"))
|
||
if parts["errors"]:
|
||
out["errors"] = parts["errors"]
|
||
return out
|
||
description, user_defined = _describe(parts)
|
||
out.update(
|
||
valid=True,
|
||
first={"letter": parts["first"], "meaning": FIRST_LETTERS[parts["first"]]},
|
||
modifier=(
|
||
{"letter": parts["modifier"], "meaning": MODIFIERS[parts["modifier"]]}
|
||
if parts["modifier"]
|
||
else None
|
||
),
|
||
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,
|
||
user_defined=user_defined,
|
||
)
|
||
return out
|
||
|
||
|
||
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": [],
|
||
}
|
||
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 "",
|
||
)
|
||
kind = prefixes.get(prefix)
|
||
if kind is None:
|
||
out["errors"] = [f"{raw!r}: prefix {prefix} is not in the equipment prefix map"]
|
||
return out
|
||
out.update(valid=True, equipment_kind=kind)
|
||
return out
|
||
|
||
|
||
def parse_tag(tag: str, kind: str = "auto", equipment_prefixes: dict | None = None) -> dict:
|
||
"""Parse an ISA-5.1 instrument tag or an equipment tag.
|
||
|
||
``kind``: ``"instrument"``, ``"equipment"`` or ``"auto"`` (instrument first,
|
||
then equipment, else the instrument parse with its errors).
|
||
"""
|
||
prefixes = {k.upper(): v for k, v in (equipment_prefixes or DEFAULT_EQUIPMENT_PREFIXES).items()}
|
||
if kind == "instrument":
|
||
return _parse_instrument(tag)
|
||
if kind == "equipment":
|
||
return _parse_equipment(tag, prefixes)
|
||
if kind != "auto":
|
||
raise ValueError("kind must be 'auto', 'instrument' or 'equipment'")
|
||
instrument = _parse_instrument(tag)
|
||
if instrument["valid"]:
|
||
return instrument
|
||
equipment = _parse_equipment(tag, prefixes)
|
||
return equipment if equipment["valid"] else instrument
|
||
|
||
|
||
def split_instrument_tag(tag: str) -> tuple[str, str]:
|
||
"""``FUNC`` and ``LOOP`` attribute texts for a bubble: letters, and loop+suffix.
|
||
Anything unparseable goes wholesale into ``FUNC`` so nothing is dropped."""
|
||
match = _INSTRUMENT_RE.match(_normalise(tag))
|
||
if not match:
|
||
return _normalise(tag), ""
|
||
return match.group("letters"), match.group("loop") + (match.group("suffix") or "")
|
||
|
||
|
||
def describe_tables() -> dict:
|
||
"""The letter tables, for the ``autocad://standards/isa51`` resource."""
|
||
return {
|
||
"standard": "ISA-5.1-2009 Table 4.1",
|
||
"grammar": "first modifier? readout* output* trailing?",
|
||
"first_letters": {k: v or "user's choice" for k, v in FIRST_LETTERS.items()},
|
||
"modifiers": dict(MODIFIERS),
|
||
"readout": {k: (v[1] if v else "user's choice") for k, v in READOUT.items()},
|
||
"output": {k: v or "user's choice" for k, v in OUTPUT.items()},
|
||
"trailing": dict(TRAILING),
|
||
"equipment_prefixes": dict(DEFAULT_EQUIPMENT_PREFIXES),
|
||
"rules": [
|
||
"S in second position is the safety modifier only before V or E (PSV, TSE); otherwise a switch.",
|
||
"D in second position is always differential.",
|
||
"F in second position is ratio only when the first letter is F (FFC).",
|
||
"K in second position is rate of change only before an output letter.",
|
||
"H/L/M after an alarm (A) or switch (S) are trailing high/low/middle; L elsewhere is a light.",
|
||
"O/C as the last letter after a switch are open/closed (ZSO, ZSC).",
|
||
],
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run the tests**
|
||
|
||
Run: `uv run --frozen pytest tests/test_pid_tags.py -v`
|
||
Expected: all PASS. If a fixture row fails, check the row against ISA-5.1 Table 4.1 before touching the grammar — the fixture is the reader's contract.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add engineering/pid/tags.py tests/data/isa51_tags.json tests/test_pid_tags.py
|
||
git commit -m "feat(pid): ISA-5.1 identification-letter grammar with a pinned fixture"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Symbol catalogue framework, instruments, markers, connectors
|
||
|
||
**Files:**
|
||
- Create: `engineering/pid/geometry.py`, `engineering/pid/symbols.py`
|
||
- Test: `tests/test_pid_symbols.py`
|
||
|
||
**Interfaces:**
|
||
- Produces (`geometry.py`): `line(x1,y1,x2,y2)`, `circle(cx,cy,r)`, `arc(cx,cy,r,start_deg,end_deg)`, `polyline(points, closed=True, bulges=None)`, `text(t,x,y,height,align="middle_center",rotation_deg=0.0)`, `solid(points)`, `dashed_line(x1,y1,x2,y2,on=1.0,off=1.0) -> list[dict]`, `regular_polygon(cx,cy,r,n,start_deg=0.0) -> list[tuple]`, `attdef(tag,x,y,height,align="center",invisible=False,default="")`, `bbox_of(primitives) -> tuple`.
|
||
- Produces (`symbols.py`): `Port`, `SymbolSpec`, `CATALOG_VERSION = "1"`, `FAMILY_LAYER`, `INSTRUMENT_TYPES`, `INSTRUMENT_LOCATIONS`, `BUBBLE_RADIUS = 5.0`, `register(symbol, family, builder, variants=())`, `resolve(symbol, **options) -> SymbolSpec`, `list_symbols(family=None) -> list[dict]`, `all_specs() -> list[SymbolSpec]`, `transform_port(port, x, y, rotation_deg, scale) -> dict`, `build_instrument(type, location)`, `build_marker(which)`, `build_connector(which, direction="out")`, `tag_attdefs(ymax, with_desc=True)`.
|
||
- Later tasks add builders by importing `register` from this module (`symbols_valves.py`, `symbols_equipment.py`, `symbols_vessels.py`) and `symbols.py` imports them at the bottom so `resolve` sees the whole catalogue.
|
||
|
||
- [ ] **Step 1: Write the failing tests (the harness parametrises over `all_specs()`, so Tasks 8–10 extend coverage without new test code)**
|
||
|
||
```python
|
||
# tests/test_pid_symbols.py
|
||
"""Every catalogue symbol: valid primitives, honest bbox, ports on the boundary."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
import re
|
||
|
||
import pytest
|
||
|
||
from backends.block_specs import validate_attdef_specs, validate_entity_specs
|
||
from engineering.pid import symbols
|
||
from engineering.pid.geometry import bbox_of
|
||
from engineering.pid.symbols import (
|
||
BUBBLE_RADIUS,
|
||
CATALOG_VERSION,
|
||
INSTRUMENT_LOCATIONS,
|
||
INSTRUMENT_TYPES,
|
||
Port,
|
||
all_specs,
|
||
list_symbols,
|
||
resolve,
|
||
transform_port,
|
||
)
|
||
|
||
NAME_RE = re.compile(r"^PID_[A-Z0-9_]+$")
|
||
SOURCE_RE = re.compile(r"^(ISO 10628-2|ISA-5\.1|PIP PIC001)")
|
||
EPS = 1e-6
|
||
|
||
|
||
def _ids():
|
||
return [spec.name for spec in all_specs()]
|
||
|
||
|
||
@pytest.mark.parametrize("spec", all_specs(), ids=_ids())
|
||
def test_primitives_and_attdefs_validate(spec):
|
||
validate_entity_specs(list(spec.primitives))
|
||
validate_attdef_specs(list(spec.attdefs))
|
||
|
||
|
||
@pytest.mark.parametrize("spec", all_specs(), ids=_ids())
|
||
def test_declared_bbox_contains_every_primitive(spec):
|
||
xmin, ymin, xmax, ymax = bbox_of(spec.primitives)
|
||
dxmin, dymin, dxmax, dymax = spec.bbox
|
||
assert dxmin - EPS <= xmin and dymin - EPS <= ymin
|
||
assert xmax <= dxmax + EPS and ymax <= dymax + EPS
|
||
assert dxmax > dxmin and dymax > dymin
|
||
|
||
|
||
@pytest.mark.parametrize("spec", all_specs(), ids=_ids())
|
||
def test_ports_sit_on_the_boundary_or_at_the_centre(spec):
|
||
if spec.family == "marker":
|
||
assert spec.ports == ()
|
||
return
|
||
assert spec.ports, "every non-marker symbol needs at least one port"
|
||
xmin, ymin, xmax, ymax = spec.bbox
|
||
for port in spec.ports:
|
||
if port.direction_deg is None:
|
||
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
|
||
)
|
||
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")
|
||
|
||
|
||
@pytest.mark.parametrize("spec", all_specs(), ids=_ids())
|
||
def test_naming_source_and_layer(spec):
|
||
assert NAME_RE.match(spec.name)
|
||
assert SOURCE_RE.match(spec.source), spec.source
|
||
assert spec.layer_class in ("PROCESS-VALVES", "PROCESS-EQUIPMENT", "INSTRUMENT-SYMBOL", "line")
|
||
tags = [a["tag"] for a in spec.attdefs]
|
||
assert len(tags) == len(set(tags))
|
||
for prim in spec.primitives:
|
||
assert prim.get("layer", "0") == "0"
|
||
|
||
|
||
def test_catalogue_names_are_unique_and_versioned():
|
||
names = [s.name for s in all_specs()]
|
||
assert len(names) == len(set(names))
|
||
assert CATALOG_VERSION == "1"
|
||
|
||
|
||
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
|
||
}
|
||
assert len(specs) == 20
|
||
field = specs[("discrete", "field")]
|
||
assert field.name == "PID_INST_DISCRETE_FIELD"
|
||
assert [a["tag"] for a in field.attdefs] == ["FUNC", "LOOP"]
|
||
assert field.ports == (Port("signal", 0.0, 0.0, None, "signal", BUBBLE_RADIUS),)
|
||
assert sum(1 for p in field.primitives if p["type"] == "circle") == 1
|
||
primary = specs[("discrete", "primary")]
|
||
assert sum(1 for p in primary.primitives if p["type"] == "line") == 1
|
||
aux_rear = specs[("plc", "auxiliary_rear")]
|
||
assert sum(1 for p in aux_rear.primitives if p["type"] == "line") > 2, "dashed pair"
|
||
assert sum(1 for p in aux_rear.primitives if p["type"] == "polyline") == 2, "square + diamond"
|
||
hexagon = next(p for p in specs[("computer", "field")].primitives if p["type"] == "polyline")
|
||
assert len(hexagon["points"]) == 6
|
||
|
||
|
||
def test_unknown_symbol_or_option_names_the_valid_values():
|
||
with pytest.raises(ValueError, match="instrument"):
|
||
resolve("thermometer")
|
||
with pytest.raises(ValueError, match="field"):
|
||
resolve("instrument", type="dcs", location="ceiling")
|
||
|
||
|
||
def test_markers_and_connectors():
|
||
arrow = resolve("arrow_flow")
|
||
assert arrow.family == "marker" and arrow.layer_class == "line"
|
||
assert any(p["type"] == "solid" for p in arrow.primitives)
|
||
for which in ("mark_pneumatic", "mark_capillary", "mark_hydraulic", "mark_data"):
|
||
assert resolve(which).family == "marker"
|
||
out = resolve("offpage", direction="out")
|
||
inn = resolve("offpage", direction="in")
|
||
assert out.name == "PID_CONNECTOR_OFFPAGE_OUT" and inn.name == "PID_CONNECTOR_OFFPAGE_IN"
|
||
assert out.ports[0] == Port("process", -8.0, 0.0, 180.0, "process")
|
||
assert inn.ports[0] == Port("process", 8.0, 0.0, 0.0, "process")
|
||
assert [a["tag"] for a in out.attdefs] == ["TAG", "LINK"]
|
||
assert next(a for a in out.attdefs if a["tag"] == "LINK")["invisible"] is True
|
||
|
||
|
||
def test_transform_port_applies_rotation_and_scale_about_the_insertion():
|
||
port = Port("out", 4.0, 0.0, 0.0, "process")
|
||
moved = transform_port(port, 100.0, 50.0, 90.0, 2.0)
|
||
assert moved["x"] == pytest.approx(100.0) and moved["y"] == pytest.approx(58.0)
|
||
assert moved["direction_deg"] == pytest.approx(90.0)
|
||
radial = transform_port(Port("signal", 0.0, 0.0, None, "signal", 5.0), 10.0, 10.0, 45.0, 3.0)
|
||
assert radial["direction_deg"] is None and radial["radius"] == pytest.approx(15.0)
|
||
|
||
|
||
def test_list_symbols_reports_variants_and_ports():
|
||
rows = {row["symbol"]: row for row in list_symbols()}
|
||
assert rows["instrument"]["variants"] == {
|
||
"type": list(INSTRUMENT_TYPES),
|
||
"location": list(INSTRUMENT_LOCATIONS),
|
||
}
|
||
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")
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 2: Run them to verify they fail**
|
||
|
||
Run: `uv run --frozen pytest tests/test_pid_symbols.py -v`
|
||
Expected: FAIL with `ModuleNotFoundError`.
|
||
|
||
- [ ] **Step 3: Write `geometry.py`**
|
||
|
||
```python
|
||
# engineering/pid/geometry.py
|
||
"""Primitive-spec helpers for authoring symbols (block-local millimetres)."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
|
||
|
||
def line(x1: float, y1: float, x2: float, y2: float) -> dict:
|
||
return {"type": "line", "x1": float(x1), "y1": float(y1), "x2": float(x2), "y2": float(y2)}
|
||
|
||
|
||
def circle(cx: float, cy: float, r: float) -> dict:
|
||
return {"type": "circle", "cx": float(cx), "cy": float(cy), "r": float(r)}
|
||
|
||
|
||
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),
|
||
}
|
||
|
||
|
||
def polyline(points, closed: bool = True, bulges=None) -> dict:
|
||
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 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]:
|
||
"""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
|
||
out: list[dict] = []
|
||
pos = 0.0
|
||
while pos < length - 1e-9:
|
||
end = min(pos + on, length)
|
||
out.append(line(x1 + ux * pos, y1 + uy * pos, x1 + ux * end, y1 + uy * end))
|
||
pos = end + off
|
||
return out
|
||
|
||
|
||
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)),
|
||
)
|
||
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 _arc_extent(cx, cy, r, start_deg, end_deg):
|
||
pts = []
|
||
sweep = (end_deg - start_deg) % 360.0 or 360.0
|
||
for k in range(0, int(sweep) + 1):
|
||
a = math.radians(start_deg + k)
|
||
pts.append((cx + r * math.cos(a), cy + r * math.sin(a)))
|
||
a = math.radians(end_deg)
|
||
pts.append((cx + r * math.cos(a), cy + r * math.sin(a)))
|
||
return pts
|
||
|
||
|
||
def bbox_of(primitives) -> tuple[float, float, float, float]:
|
||
"""Extent of the drawn geometry (text counts by its insertion point only)."""
|
||
xs: list[float] = []
|
||
ys: list[float] = []
|
||
for p in primitives:
|
||
kind = p["type"]
|
||
if kind == "line":
|
||
xs += [p["x1"], p["x2"]]
|
||
ys += [p["y1"], p["y2"]]
|
||
elif kind == "circle":
|
||
xs += [p["cx"] - p["r"], p["cx"] + p["r"]]
|
||
ys += [p["cy"] - p["r"], p["cy"] + p["r"]]
|
||
elif kind == "arc":
|
||
for x, y in _arc_extent(p["cx"], p["cy"], p["r"], p["start_deg"], p["end_deg"]):
|
||
xs.append(x)
|
||
ys.append(y)
|
||
elif kind in ("polyline", "solid"):
|
||
for x, y in p["points"]:
|
||
xs.append(x)
|
||
ys.append(y)
|
||
elif kind == "text":
|
||
xs.append(p["x"])
|
||
ys.append(p["y"])
|
||
if not xs:
|
||
return (0.0, 0.0, 0.0, 0.0)
|
||
return (min(xs), min(ys), max(xs), max(ys))
|
||
```
|
||
|
||
- [ ] **Step 4: Write `symbols.py`**
|
||
|
||
```python
|
||
# engineering/pid/symbols.py
|
||
"""The P&ID symbol catalogue: symbols as data, blocks on demand.
|
||
|
||
Every symbol is a ``SymbolSpec`` — typed primitives in symbol-local mm, ATTDEFs,
|
||
named ports with an outward direction, a declared bbox — authored from
|
||
ISO 10628-2 (equipment, valves) or ISA-5.1 (instrumentation). Builders live in
|
||
``symbols_valves.py``, ``symbols_equipment.py`` and ``symbols_vessels.py`` and
|
||
register here; this module holds the framework plus the instrument bubble,
|
||
markers and connectors.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import math
|
||
from collections.abc import Callable
|
||
from dataclasses import dataclass, field
|
||
|
||
from .geometry import attdef, bbox_of, circle, dashed_line, line, polyline, regular_polygon, solid
|
||
|
||
CATALOG_VERSION = "1"
|
||
BUBBLE_RADIUS = 5.0
|
||
FAMILY_LAYER = {
|
||
"valve": "PROCESS-VALVES",
|
||
"rotating": "PROCESS-EQUIPMENT",
|
||
"vessel": "PROCESS-EQUIPMENT",
|
||
"heat": "PROCESS-EQUIPMENT",
|
||
"misc": "PROCESS-EQUIPMENT",
|
||
"instrument": "INSTRUMENT-SYMBOL",
|
||
"connector": "PROCESS-EQUIPMENT",
|
||
"marker": "line",
|
||
}
|
||
INSTRUMENT_TYPES = ("discrete", "dcs", "computer", "plc")
|
||
INSTRUMENT_LOCATIONS = ("field", "primary", "auxiliary", "primary_rear", "auxiliary_rear")
|
||
TAG_HEIGHT = 2.5
|
||
DESC_HEIGHT = 2.0
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Port:
|
||
name: str
|
||
x: float
|
||
y: float
|
||
direction_deg: float | None # outward direction a line leaves along; None = radial
|
||
kind: str = "process" # "process" | "signal"
|
||
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,
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class SymbolSpec:
|
||
name: str
|
||
family: str
|
||
symbol: str
|
||
variant: str | None
|
||
primitives: tuple[dict, ...]
|
||
attdefs: tuple[dict, ...]
|
||
ports: tuple[Port, ...]
|
||
bbox: tuple[float, float, float, float]
|
||
layer_class: str
|
||
source: str
|
||
parametric: bool = False
|
||
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),
|
||
}
|
||
|
||
|
||
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:
|
||
if family not in FAMILY_LAYER:
|
||
raise ValueError(f"unknown family {family!r}")
|
||
_REGISTRY[symbol] = (family, builder, dict(variants or {}), params_schema)
|
||
|
||
|
||
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))}"
|
||
)
|
||
family, builder, variants, schema = _REGISTRY[symbol]
|
||
for key, value in options.items():
|
||
if value is None:
|
||
continue
|
||
if key == "params":
|
||
if schema is None:
|
||
raise ValueError(f"{symbol} is not parametric; 'params' is only for vessels")
|
||
continue
|
||
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}"
|
||
)
|
||
return builder(**{k: v for k, v in options.items() if v is not None})
|
||
|
||
|
||
def list_symbols(family: str | None = None) -> list[dict]:
|
||
rows = []
|
||
for symbol, (fam, builder, variants, schema) in sorted(_REGISTRY.items()):
|
||
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,
|
||
}
|
||
)
|
||
return rows
|
||
|
||
|
||
def all_specs() -> list[SymbolSpec]:
|
||
"""Every base symbol plus every enumerable variant (for tests and the contact sheet)."""
|
||
specs: list[SymbolSpec] = []
|
||
for symbol, (_fam, builder, variants, _schema) in sorted(_REGISTRY.items()):
|
||
if not variants:
|
||
specs.append(builder())
|
||
continue
|
||
keys = sorted(variants)
|
||
combos: list[dict] = [{}]
|
||
for key in keys:
|
||
combos = [{**c, key: v} for c in combos for v in variants[key]]
|
||
for combo in combos:
|
||
try:
|
||
specs.append(builder(**combo))
|
||
except ValueError:
|
||
continue # a combination the builder refuses (e.g. an actuator on a check valve)
|
||
return specs
|
||
|
||
|
||
def transform_port(port: Port, x: float, y: float, rotation_deg: float, scale: float) -> dict:
|
||
"""Symbol-local port → WCS, for an INSERT at (x, y) rotated by ``rotation_deg`` and uniformly scaled."""
|
||
a = math.radians(rotation_deg)
|
||
lx, ly = port.x * scale, port.y * scale
|
||
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,
|
||
}
|
||
|
||
|
||
def params_hash(params: dict) -> str:
|
||
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, ...]:
|
||
out = [attdef("TAG", 0.0, ymax + 3.5, TAG_HEIGHT)]
|
||
if with_desc:
|
||
out.append(attdef("DESC", 0.0, ymax + 6.5, DESC_HEIGHT))
|
||
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:
|
||
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 {}),
|
||
)
|
||
|
||
|
||
# ── instrument bubble (ISA-5.1-2009 Table 5.4.1) ─────────────────────────────
|
||
|
||
|
||
def build_instrument(type: str = "discrete", location: str = "field") -> SymbolSpec:
|
||
r = BUBBLE_RADIUS
|
||
prims: list[dict] = [circle(0, 0, r)]
|
||
square = polyline([(-r, -r), (r, -r), (r, r), (-r, r)])
|
||
if type == "dcs":
|
||
prims.append(square)
|
||
elif type == "computer":
|
||
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]
|
||
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"),
|
||
)
|
||
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),
|
||
)
|
||
|
||
|
||
register(
|
||
"instrument",
|
||
"instrument",
|
||
build_instrument,
|
||
variants={"type": list(INSTRUMENT_TYPES), "location": list(INSTRUMENT_LOCATIONS)},
|
||
)
|
||
|
||
|
||
# ── markers (decoration on a line; never graph nodes) ────────────────────────
|
||
|
||
|
||
def build_marker(which: str) -> SymbolSpec:
|
||
shapes = {
|
||
"arrow_flow": [solid([(0, 0), (-3, 1.2), (-3, -1.2)])],
|
||
"mark_pneumatic": [line(-1.5, -1.5, 0, 1.5), line(0.5, -1.5, 2, 1.5)],
|
||
"mark_capillary": [line(-1.2, -1.2, 1.2, 1.2), line(-1.2, 1.2, 1.2, -1.2)],
|
||
"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",
|
||
)
|
||
|
||
|
||
for _which in ("arrow_flow", "mark_pneumatic", "mark_capillary", "mark_hydraulic", "mark_data"):
|
||
register(_which, "marker", (lambda w: lambda: build_marker(w))(_which))
|
||
|
||
|
||
# ── connectors ───────────────────────────────────────────────────────────────
|
||
|
||
|
||
def build_connector(which: str = "offpage", direction: str = "out") -> SymbolSpec:
|
||
if which == "offpage":
|
||
if direction == "out":
|
||
pts = [(-8, -3), (4, -3), (8, 0), (4, 3), (-8, 3)]
|
||
port = Port("process", -8.0, 0.0, 180.0, "process")
|
||
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),
|
||
)
|
||
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",
|
||
)
|
||
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),
|
||
)
|
||
raise ValueError(f"unknown connector {which!r}")
|
||
|
||
|
||
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"))
|
||
|
||
# Builders for valves, equipment and vessels register themselves on import.
|
||
from . import symbols_equipment as _symbols_equipment # noqa: E402,F401
|
||
from . import symbols_valves as _symbols_valves # noqa: E402,F401
|
||
from . import symbols_vessels as _symbols_vessels # noqa: E402,F401
|
||
```
|
||
|
||
Until Tasks 8–10 exist, create the three builder modules as empty stubs so the import succeeds:
|
||
|
||
```python
|
||
# engineering/pid/symbols_valves.py (replaced in Task 8)
|
||
"""Valve bodies and actuators — filled in by Task 8."""
|
||
```
|
||
(same one-line docstring for `symbols_equipment.py` and `symbols_vessels.py`).
|
||
|
||
- [ ] **Step 5: Run the tests**
|
||
|
||
Run: `uv run --frozen pytest tests/test_pid_symbols.py tests/test_pid_xdata.py -v`
|
||
Expected: all PASS (29 catalogue specs so far: 20 bubbles, 5 markers, 4 connectors).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add engineering/pid/geometry.py engineering/pid/symbols.py engineering/pid/symbols_valves.py engineering/pid/symbols_equipment.py engineering/pid/symbols_vessels.py tests/test_pid_symbols.py
|
||
git commit -m "feat(pid): symbol catalogue framework — instruments, markers, connectors"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Valve bodies × actuators
|
||
|
||
**Files:**
|
||
- Replace: `engineering/pid/symbols_valves.py`
|
||
- Test: `tests/test_pid_symbols.py` (append the valve-specific tests below)
|
||
|
||
**Interfaces:**
|
||
- Produces: `VALVE_BODIES = ("gate","globe","ball","butterfly","check","needle","plug","diaphragm","three_way","angle","relief")`, `ACTUATORS = ("none","diaphragm","piston","motor","solenoid","hand")`, `build_valve(body, actuator="none") -> SymbolSpec`; symbols registered as `resolve(body, actuator=...)` (one registry entry per body; variant key `actuator`).
|
||
|
||
- [ ] **Step 1: Append the failing tests**
|
||
|
||
```python
|
||
# append to tests/test_pid_symbols.py
|
||
from engineering.pid.symbols_valves import ACTUATORS, VALVE_BODIES, build_valve
|
||
|
||
|
||
def test_valve_bodies_and_actuators_enumerate():
|
||
assert len(VALVE_BODIES) == 11 and len(ACTUATORS) == 6
|
||
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),
|
||
}
|
||
assert [a["tag"] for a in gate.attdefs] == ["TAG", "DESC"]
|
||
|
||
|
||
def test_actuators_add_a_signal_port_and_a_fail_attribute():
|
||
cv = build_valve("globe", "diaphragm")
|
||
assert cv.name == "PID_VALVE_GLOBE_DIAPHRAGM" and cv.variant == "diaphragm"
|
||
signal = next(p for p in cv.ports if p.name == "signal")
|
||
assert (signal.x, signal.y, signal.direction_deg, signal.kind) == (0.0, 9.0, 90.0, "signal")
|
||
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
|
||
]
|
||
|
||
|
||
def test_check_and_relief_refuse_actuators():
|
||
for body in ("check", "relief"):
|
||
with pytest.raises(ValueError, match="actuator"):
|
||
build_valve(body, "motor")
|
||
|
||
|
||
def test_three_way_angle_and_relief_ports():
|
||
assert {p.name for p in build_valve("three_way").ports} == {"in", "out", "branch"}
|
||
angle = build_valve("angle")
|
||
assert {p.name: p.direction_deg for p in angle.ports} == {"in": 180.0, "out": 270.0}
|
||
relief = build_valve("relief")
|
||
assert {p.name: p.direction_deg for p in relief.ports} == {"in": 270.0, "out": 0.0}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify they fail**
|
||
|
||
Run: `uv run --frozen pytest tests/test_pid_symbols.py -k "valve or actuator or relief" -v`
|
||
Expected: FAIL (`ImportError: cannot import name 'ACTUATORS'`).
|
||
|
||
- [ ] **Step 3: Write the module**
|
||
|
||
Actuator placement is body-relative: the stem starts at the body's `axis_top` (where its own geometry on x = 0 ends) and the glyph base sits `STEM_CLEARANCE` above the body's top edge, so the needle body (top 5.6) is never overprinted and the diaphragm dome's base coincides with the stem top (no floating dome).
|
||
|
||
```python
|
||
# engineering/pid/symbols_valves.py
|
||
"""Valve bodies (ISO 10628-2) composed with actuators (ISA-5.1 Table 5.5).
|
||
|
||
Every body is authored once in symbol-local mm (reference size 8 × 4, process
|
||
ports on the ``in`` / ``out`` faces) and any actuator stacks on top of it along
|
||
the stem at x = 0: the stem starts where the body's own geometry on that axis
|
||
ends (``axis_top``) and the actuator glyph sits ``STEM_CLEARANCE`` above the
|
||
body's top edge, so a tall body (needle, diaphragm) pushes the actuator up
|
||
instead of being overprinted by it. An actuated valve gains a ``signal`` port
|
||
on its topmost edge and a ``FAIL`` attribute; check and relief valves take no
|
||
actuator.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from .geometry import arc, attdef, bbox_of, circle, line, polyline, text
|
||
from .symbols import DESC_HEIGHT, Port, SymbolSpec, make_spec, register, tag_attdefs
|
||
|
||
VALVE_BODIES = (
|
||
"gate",
|
||
"globe",
|
||
"ball",
|
||
"butterfly",
|
||
"check",
|
||
"needle",
|
||
"plug",
|
||
"diaphragm",
|
||
"three_way",
|
||
"angle",
|
||
"relief",
|
||
)
|
||
ACTUATORS = ("none", "diaphragm", "piston", "motor", "solenoid", "hand")
|
||
NO_ACTUATOR = frozenset({"check", "relief"})
|
||
SOURCE = "ISO 10628-2:2012 valves; ISA-5.1-2009 Table 5.5 actuators"
|
||
|
||
_LEFT = polyline([(-4, -2), (-4, 2), (0, 0)])
|
||
_RIGHT = polyline([(4, -2), (4, 2), (0, 0)])
|
||
_BOTTOM = polyline([(-2, -4), (2, -4), (0, 0)])
|
||
_IN = Port("in", -4.0, 0.0, 180.0)
|
||
_OUT = Port("out", 4.0, 0.0, 0.0)
|
||
_BOX = (-4.0, -2.0, 4.0, 2.0)
|
||
# Gap between a body's top edge and the actuator glyph — the visible stem on the
|
||
# reference 8 × 4 body (stem 0..4, glyph from y = 4).
|
||
STEM_CLEARANCE = 2.0
|
||
# Extra stem (the yoke) between the clearance point and a diaphragm dome's base.
|
||
DIAPHRAGM_YOKE = 2.0
|
||
|
||
# (primitives, process ports, declared bbox, axis_top) — ``axis_top`` is the y
|
||
# where the body's own geometry on the x = 0 axis ends, i.e. where an actuator
|
||
# stem may start without overprinting it.
|
||
Body = tuple[list[dict], tuple[Port, ...], tuple[float, float, float, float], float]
|
||
|
||
|
||
def _body(body: str) -> Body:
|
||
"""Primitives, process ports, declared bbox and axis top of a bare valve body."""
|
||
std = [_LEFT, _RIGHT]
|
||
if body == "gate":
|
||
return std, (_IN, _OUT), _BOX, 0.0
|
||
if body == "globe":
|
||
return std + [circle(0, 0, 1.0)], (_IN, _OUT), _BOX, 1.0
|
||
if body == "ball":
|
||
return std + [circle(0, 0, 1.8)], (_IN, _OUT), _BOX, 1.8
|
||
if body == "butterfly":
|
||
return std + [line(0, -2, 0, 2), circle(0, 0, 0.8)], (_IN, _OUT), _BOX, 2.0
|
||
if body == "check":
|
||
return std + [arc(0, 0, 2.5, 20, 160)], (_IN, _OUT), (-4.0, -2.0, 4.0, 2.5), 2.5
|
||
if body == "needle":
|
||
prims = std + [line(0, 0, 0, 5), circle(0, 5, 0.6)]
|
||
return prims, (_IN, _OUT), (-4.0, -2.0, 4.0, 5.6), 5.6
|
||
if body == "plug":
|
||
return std + [polyline([(-1, -1), (1, -1), (1, 1), (-1, 1)])], (_IN, _OUT), _BOX, 1.0
|
||
if body == "diaphragm":
|
||
return std + [arc(0, 2, 2, 0, 180)], (_IN, _OUT), (-4.0, -2.0, 4.0, 4.0), 4.0
|
||
if body == "three_way":
|
||
ports = (_IN, _OUT, Port("branch", 0.0, -4.0, 270.0))
|
||
return std + [_BOTTOM], ports, (-4.0, -4.0, 4.0, 2.0), 0.0
|
||
if body == "angle":
|
||
ports = (_IN, Port("out", 0.0, -4.0, 270.0))
|
||
return [_LEFT, _BOTTOM], ports, (-4.0, -4.0, 2.0, 2.0), 0.0
|
||
if body == "relief":
|
||
spring = polyline(
|
||
[(0, 0), (-1, 1.5), (1, 2.5), (-1, 3.5), (1, 4.5), (0, 6)],
|
||
closed=False,
|
||
)
|
||
ports = (Port("in", 0.0, -4.0, 270.0), _OUT)
|
||
return [_BOTTOM, _RIGHT, spring], ports, (-2.0, -4.0, 4.0, 6.0), 6.0
|
||
raise ValueError(f"unknown valve body {body!r}; valid: {', '.join(VALVE_BODIES)}")
|
||
|
||
|
||
def _actuator(
|
||
actuator: str, body_top: float, axis_top: float = 0.0
|
||
) -> tuple[list[dict], Port | None, float]:
|
||
"""Primitives above the body, the signal port (or None), and the new top y.
|
||
|
||
The stem (always the first primitive) runs from ``axis_top`` — where the
|
||
body's axial geometry ends — to the glyph base at
|
||
``body_top + STEM_CLEARANCE``; every glyph's lowest edge sits exactly on
|
||
the stem's top, so an actuator is never drawn detached from, or over, its
|
||
body.
|
||
"""
|
||
if actuator == "none":
|
||
return [], None, body_top
|
||
base = body_top + STEM_CLEARANCE
|
||
if actuator == "hand":
|
||
return [line(0, axis_top, 0, base), line(-2.5, base, 2.5, base)], None, base
|
||
if actuator == "diaphragm":
|
||
dome = base + DIAPHRAGM_YOKE
|
||
prims = [line(0, axis_top, 0, dome), arc(0, dome, 3, 0, 180), line(-3, dome, 3, dome)]
|
||
return prims, Port("signal", 0.0, dome + 3.0, 90.0, "signal"), dome + 3.0
|
||
stem = line(0, axis_top, 0, base)
|
||
if actuator == "piston":
|
||
top = base + 4.0
|
||
prims = [stem, polyline([(-2, base), (2, base), (2, top), (-2, top)])]
|
||
return prims, Port("signal", 0.0, top, 90.0, "signal"), top
|
||
if actuator == "motor":
|
||
top = base + 4.0
|
||
prims = [stem, circle(0, base + 2.0, 2), text("M", 0, base + 2.0, 2.0)]
|
||
return prims, Port("signal", 0.0, top, 90.0, "signal"), top
|
||
if actuator == "solenoid":
|
||
top = base + 3.0
|
||
box = polyline([(-1.5, base), (1.5, base), (1.5, top), (-1.5, top)])
|
||
prims = [stem, box, text("S", 0, base + 1.5, 1.8)]
|
||
return prims, Port("signal", 0.0, top, 90.0, "signal"), top
|
||
raise ValueError(f"unknown actuator {actuator!r}; valid: {', '.join(ACTUATORS)}")
|
||
|
||
|
||
def build_valve(body: str = "gate", actuator: str = "none") -> SymbolSpec:
|
||
"""Compose ``body`` with ``actuator`` into a ``SymbolSpec``.
|
||
|
||
Raises ``ValueError`` for an unknown body or actuator, and for an actuator
|
||
on a check or relief valve (they are self-acting).
|
||
"""
|
||
prims, ports, box, axis_top = _body(body)
|
||
if actuator not in ACTUATORS:
|
||
raise ValueError(f"unknown actuator {actuator!r}; valid: {', '.join(ACTUATORS)}")
|
||
if actuator != "none" and body in NO_ACTUATOR:
|
||
raise ValueError(f"{body} valves take no actuator (got {actuator!r})")
|
||
glyph_base = box[3] + STEM_CLEARANCE
|
||
extra, signal, top = _actuator(actuator, box[3], axis_top)
|
||
prims = list(prims) + extra
|
||
ports = tuple(ports) + ((signal,) if signal else ())
|
||
if extra:
|
||
# An actuator can be wider than a narrow body (angle: xmax 2 vs. a hand
|
||
# bar at ±2.5 or a diaphragm at ±3), so union its real extent, not just y.
|
||
ax0, ay0, ax1, ay1 = bbox_of(extra)
|
||
box = (min(box[0], ax0), min(box[1], ay0), max(box[2], ax1), max(box[3], top, ay1))
|
||
attdefs = list(tag_attdefs(box[3]))
|
||
if signal is not None:
|
||
# Beside the actuator glyph (spec 4.7): right of its widest extent (±3
|
||
# for a dome), 1.5 above the glyph base.
|
||
attdefs.append(attdef("FAIL", 3.5, glyph_base + 1.5, DESC_HEIGHT, align="left"))
|
||
variant = None if actuator == "none" else actuator
|
||
name = f"PID_VALVE_{body.upper()}" + (f"_{actuator.upper()}" if variant else "")
|
||
return make_spec(name, "valve", body, variant, prims, attdefs, ports, SOURCE, bbox=box)
|
||
|
||
|
||
def _valve_builder(body: str):
|
||
def build(actuator: str = "none") -> SymbolSpec:
|
||
return build_valve(body, actuator)
|
||
|
||
return build
|
||
|
||
|
||
for _body_name in VALVE_BODIES:
|
||
register(
|
||
_body_name, "valve", _valve_builder(_body_name), variants={"actuator": list(ACTUATORS)}
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 4: Run the whole catalogue harness**
|
||
|
||
Run: `uv run --frozen pytest tests/test_pid_symbols.py -v`
|
||
Expected: all PASS — the harness now covers 11 bodies × 6 actuators minus the refused combinations (2 bodies × 5 actuators) = 56 valve specs plus the 29 from Task 7.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add engineering/pid/symbols_valves.py tests/test_pid_symbols.py
|
||
git commit -m "feat(pid): 11 ISO 10628-2 valve bodies composed with 6 ISA-5.1 actuators"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Rotating equipment, heat transfer, miscellaneous inline symbols
|
||
|
||
**Files:**
|
||
- Replace: `engineering/pid/symbols_equipment.py`
|
||
- Test: `tests/test_pid_symbols.py` (append)
|
||
|
||
**Interfaces:**
|
||
- Produces: `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")`, `build_equipment(symbol, **options)`; `reducer` has variant `shape ∈ {concentric, eccentric}`.
|
||
|
||
- [ ] **Step 1: Append the failing tests**
|
||
|
||
```python
|
||
# append to tests/test_pid_symbols.py
|
||
from engineering.pid.symbols_equipment import HEAT, MISC, ROTATING
|
||
|
||
|
||
def test_equipment_families_register():
|
||
assert len(ROTATING) == 6 and len(HEAT) == 4 and len(MISC) == 7
|
||
pump = resolve("centrifugal_pump")
|
||
assert pump.family == "rotating" and pump.layer_class == "PROCESS-EQUIPMENT"
|
||
assert {p.name: p.direction_deg for p in pump.ports} == {"suction": 180.0, "discharge": 90.0}
|
||
hx = resolve("shell_tube_hx")
|
||
assert {p.name for p in hx.ports} == {"shell_in", "shell_out", "tube_in", "tube_out"}
|
||
orifice = resolve("orifice_plate")
|
||
assert next(p for p in orifice.ports if p.name == "signal").kind == "signal"
|
||
ecc = resolve("reducer", shape="eccentric")
|
||
assert ecc.name == "PID_MISC_REDUCER_ECCENTRIC"
|
||
assert next(p for p in ecc.ports if p.name == "out").y == pytest.approx(-1.0)
|
||
assert resolve("reducer").name == "PID_MISC_REDUCER_CONCENTRIC"
|
||
assert resolve("agitator").ports[0] == Port("shaft", 0.0, 0.0, 270.0, "process")
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify they fail** — `uv run --frozen pytest tests/test_pid_symbols.py -k equipment -v` → ImportError.
|
||
|
||
- [ ] **Step 3: Write the module**
|
||
|
||
```python
|
||
# engineering/pid/symbols_equipment.py
|
||
"""Rotating equipment, heat transfer and inline miscellany (ISO 10628-2)."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from .geometry import circle, line, polyline, solid, text
|
||
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",
|
||
)
|
||
_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),
|
||
)
|
||
if symbol == "pd_pump":
|
||
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),
|
||
)
|
||
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),
|
||
)
|
||
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),
|
||
)
|
||
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),
|
||
)
|
||
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),
|
||
)
|
||
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),
|
||
)
|
||
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),
|
||
)
|
||
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),
|
||
)
|
||
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),
|
||
)
|
||
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),
|
||
)
|
||
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),
|
||
)
|
||
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),
|
||
)
|
||
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),
|
||
)
|
||
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),
|
||
)
|
||
raise ValueError(symbol)
|
||
|
||
|
||
def build_equipment(symbol: str, shape: str = "concentric") -> SymbolSpec:
|
||
if symbol in ROTATING:
|
||
family, (prims, ports, box) = "rotating", _rotating(symbol)
|
||
elif symbol in HEAT:
|
||
family, (prims, ports, box) = "heat", _heat(symbol)
|
||
elif symbol in MISC:
|
||
family, (prims, ports, box) = "misc", _misc(symbol, shape)
|
||
else:
|
||
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
|
||
)
|
||
|
||
|
||
for _name in ROTATING + HEAT + MISC:
|
||
if _name == "reducer":
|
||
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))
|
||
```
|
||
|
||
- [ ] **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`).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add engineering/pid/symbols_equipment.py tests/test_pid_symbols.py
|
||
git commit -m "feat(pid): rotating, heat-transfer and inline equipment symbols"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Parametric vessels
|
||
|
||
**Files:**
|
||
- Replace: `engineering/pid/symbols_vessels.py`
|
||
- Test: `tests/test_pid_symbols.py` (append)
|
||
|
||
**Interfaces:**
|
||
- Produces: `VESSELS = ("vertical_vessel","horizontal_vessel","tank","column","reactor","hopper")`, `build_vessel(symbol, params=None) -> SymbolSpec` with `parametric=True`, `params` validated (`validate_vessel_params(symbol, params) -> dict` raising `ValueError` naming the key), block name `PID_VESSEL_<SYMBOL>_<8 hex>`; default nozzles `N1 top 0.5, N2 bottom 0.5, N3 left 0.7, N4 right 0.3`.
|
||
- Registry: `resolve("vertical_vessel", params={...})`; `list_symbols()` rows carry `params_schema`.
|
||
|
||
- [ ] **Step 1: Append the failing tests**
|
||
|
||
```python
|
||
# append to tests/test_pid_symbols.py
|
||
from engineering.pid.symbols_vessels import VESSELS, build_vessel, validate_vessel_params
|
||
|
||
|
||
def test_vessels_are_parametric_and_hash_named():
|
||
assert len(VESSELS) == 6
|
||
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,
|
||
}
|
||
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})
|
||
assert other.name != v.name
|
||
|
||
|
||
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}]},
|
||
)
|
||
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)
|
||
|
||
|
||
def test_vessel_variants_draw_their_features():
|
||
column = build_vessel("column", {"trays": 5})
|
||
assert sum(1 for p in column.primitives if p["type"] == "line") >= 5 + 2
|
||
cone = build_vessel("tank", {"roof": "cone"})
|
||
assert any(p["type"] == "polyline" and not p["closed"] for p in cone.primitives)
|
||
jacketed = build_vessel("reactor", {"jacketed": True})
|
||
assert jacketed.bbox[2] == pytest.approx(12.0 + 5.0), "half width 12 + jacket 2 + stub 3"
|
||
hopper = build_vessel("hopper")
|
||
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"),
|
||
],
|
||
)
|
||
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):
|
||
validate_vessel_params(symbol, params)
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify they fail** — ImportError.
|
||
|
||
- [ ] **Step 3: Write the module**
|
||
|
||
```python
|
||
# engineering/pid/symbols_vessels.py
|
||
"""Parametric vessels (ISO 10628-2): size and nozzles come from the caller."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from .geometry import arc, line, polyline
|
||
from .symbols import Port, SymbolSpec, make_spec, params_hash, register, tag_attdefs
|
||
|
||
VESSELS = ("vertical_vessel", "horizontal_vessel", "tank", "column", "reactor", "hopper")
|
||
SIDES = ("top", "bottom", "left", "right")
|
||
DEFAULT_NOZZLES = [
|
||
{"name": "N1", "side": "top", "fraction": 0.5},
|
||
{"name": "N2", "side": "bottom", "fraction": 0.5},
|
||
{"name": "N3", "side": "left", "fraction": 0.7},
|
||
{"name": "N4", "side": "right", "fraction": 0.3},
|
||
]
|
||
_DEFAULTS = {
|
||
"vertical_vessel": {"width": 20.0, "height": 40.0},
|
||
"horizontal_vessel": {"width": 40.0, "height": 20.0},
|
||
"tank": {"width": 24.0, "height": 24.0, "roof": "flat"},
|
||
"column": {"width": 16.0, "height": 60.0, "trays": 0},
|
||
"reactor": {"width": 24.0, "height": 36.0, "jacketed": False},
|
||
"hopper": {"width": 24.0, "height": 24.0},
|
||
}
|
||
PARAMS_SCHEMA = {
|
||
"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",
|
||
}
|
||
STUB = 3.0
|
||
|
||
|
||
def validate_vessel_params(symbol: str, params: dict | None) -> dict:
|
||
if symbol not in VESSELS:
|
||
raise ValueError(f"unknown vessel {symbol!r}; valid: {', '.join(VESSELS)}")
|
||
merged = dict(_DEFAULTS[symbol])
|
||
merged["nozzles"] = [dict(n) for n in DEFAULT_NOZZLES]
|
||
if symbol == "hopper":
|
||
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))}"
|
||
)
|
||
merged[key] = value
|
||
for key in ("width", "height"):
|
||
v = merged[key]
|
||
if isinstance(v, bool) or not isinstance(v, (int, float)) or v <= 0:
|
||
raise ValueError(f"{symbol}: {key} must be a number > 0")
|
||
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
|
||
):
|
||
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")
|
||
nozzles = merged["nozzles"]
|
||
if not isinstance(nozzles, list):
|
||
raise ValueError(f"{symbol}: nozzles must be a list")
|
||
names: set[str] = set()
|
||
clean = []
|
||
for i, n in enumerate(nozzles):
|
||
if not isinstance(n, dict) or not isinstance(n.get("name"), str) or not n["name"]:
|
||
raise ValueError(f"{symbol}: nozzles[{i}].name must be a non-empty string")
|
||
if n["name"] in names:
|
||
raise ValueError(f"{symbol}: nozzles[{i}].name {n['name']!r} is repeated")
|
||
names.add(n["name"])
|
||
if n.get("side") not in SIDES:
|
||
raise ValueError(f"{symbol}: nozzles[{i}].side must be one of {', '.join(SIDES)}")
|
||
f = n.get("fraction", 0.5)
|
||
if isinstance(f, bool) or not isinstance(f, (int, float)) or not 0.0 <= f <= 1.0:
|
||
raise ValueError(f"{symbol}: nozzles[{i}].fraction must be within 0..1")
|
||
clean.append({"name": n["name"], "side": n["side"], "fraction": float(f)})
|
||
merged["nozzles"] = clean
|
||
return merged
|
||
|
||
|
||
def _nozzles(params: dict, half_w: float, half_h: float, extra: float = 0.0):
|
||
"""Nozzle stub primitives and ports on a box of ±half_w × ±half_h."""
|
||
prims, ports = [], []
|
||
stub = STUB + extra
|
||
for n in params["nozzles"]:
|
||
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))
|
||
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))
|
||
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))
|
||
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))
|
||
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),
|
||
]
|
||
|
||
|
||
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),
|
||
]
|
||
|
||
|
||
def build_vessel(symbol: str = "vertical_vessel", params: dict | None = None) -> SymbolSpec:
|
||
p = validate_vessel_params(symbol, params)
|
||
w, h = p["width"], p["height"]
|
||
half_w, half_h = w / 2, h / 2
|
||
extra = 0.0
|
||
if symbol == "vertical_vessel":
|
||
prims = _capsule_vertical(w, h)
|
||
elif symbol == "horizontal_vessel":
|
||
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),
|
||
]
|
||
)
|
||
]
|
||
if roof:
|
||
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"]
|
||
for i in range(trays):
|
||
y = -half_h + (i + 1) * h / (trays + 1)
|
||
prims.append(line(-half_w + 1, y, half_w - 1, y))
|
||
elif symbol == "reactor":
|
||
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),
|
||
]
|
||
extra = 2.0
|
||
else: # hopper
|
||
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,
|
||
)
|
||
|
||
|
||
for _name in VESSELS:
|
||
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.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add engineering/pid/symbols_vessels.py tests/test_pid_symbols.py
|
||
git commit -m "feat(pid): six parametric vessel symbols with nozzle ports"
|
||
```
|
||
|
||
---
|
||
### Task 11: Symbol insertion service + `pid_symbol_list` / `pid_symbol_insert` (SECTION 17 opens)
|
||
|
||
**Files:**
|
||
- Modify: `engineering/layers.py` (`PID_LAYERS` + `PROCESS-LINE-TEXT`)
|
||
- Create: `engineering/pid/insert.py`
|
||
- Modify: `server.py` (new `SECTION 17: P&ID (2 tools)` header before the RESOURCES block; the count grows in later tasks), `discovery/aliases.py`, `README.md` (`157 tools` → `159 tools`), regenerate inventory
|
||
- Modify: `engineering/pid/__init__.py` (re-exports)
|
||
- Test: `tests/test_pid_insert.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `resolve`, `transform_port`, `FAMILY_LAYER` (Task 7), `symbol_payload`, `write_payload`, `marker_payload` (Task 5), `parse_tag`, `split_instrument_tag` (Task 6), `backend.block_define`, `backend.block_insert`, `backend.block_list`, `backend.layer_list`, `backend.layer_create`.
|
||
- Produces (`engineering/pid/insert.py`):
|
||
- `async ensure_layer(backend, layer_name) -> bool` (True when created; P&ID layers get their `PID_LAYERS` definition, anything else colour 7 / Continuous)
|
||
- `async ensure_block(backend, spec) -> bool` (True when defined now)
|
||
- `async insert_symbol(backend, spec, x, y, rotation=0.0, scale=1.0, attributes=None, layer=None, payload=None) -> dict` with keys `handle, block_name, defined, layers_created, ports, layer`
|
||
- `async place_symbol(backend, symbol, x, y, rotation=0.0, scale=1.0, tag=None, desc=None, fail=None, link=None, layer=None, **options) -> dict` — the tool body; adds `tag`, `tag_warnings`, `backend`.
|
||
- `FAIL_POSITIONS = ("", "FO", "FC", "FL")`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```python
|
||
# tests/test_pid_insert.py
|
||
"""pid_symbol_insert: define on first use, insert with attributes, WCS ports, XDATA."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
from engineering.pid.insert import ensure_layer, place_symbol
|
||
from engineering.pid.symbols import resolve
|
||
from engineering.pid.xdata import read_payload
|
||
|
||
pytestmark = pytest.mark.asyncio
|
||
|
||
|
||
async def test_first_insert_defines_the_block_and_creates_its_layer(backend):
|
||
result = await place_symbol(backend, "gate", 100.0, 50.0, tag="HV-101")
|
||
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,
|
||
}
|
||
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")
|
||
assert again["defined"] is False and again["layers_created"] == []
|
||
|
||
|
||
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"
|
||
)
|
||
discharge = result["ports"]["discharge"]
|
||
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"
|
||
)
|
||
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
|
||
)
|
||
|
||
|
||
async def test_invalid_tag_is_written_and_warned_not_refused(backend):
|
||
result = await place_symbol(backend, "instrument", 0, 0, tag="FCI-9")
|
||
assert await backend.block_get_attributes(result["handle"]) == {"FUNC": "FCI", "LOOP": "9"}
|
||
assert result["tag_warnings"] and "I" in result["tag_warnings"][0]
|
||
|
||
|
||
async def test_actuator_fail_and_offpage_link_attributes(backend):
|
||
cv = await place_symbol(backend, "globe", 0, 0, tag="FCV-105", actuator="diaphragm", fail="FC")
|
||
assert (await backend.block_get_attributes(cv["handle"]))["FAIL"] == "FC"
|
||
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"
|
||
)
|
||
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}
|
||
)
|
||
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"}
|
||
|
||
|
||
async def test_refusals(backend):
|
||
with pytest.raises(ValueError, match="scale"):
|
||
await place_symbol(backend, "gate", 0, 0, scale=0)
|
||
with pytest.raises(ValueError, match="pid_line_draw"):
|
||
await place_symbol(backend, "arrow_flow", 0, 0)
|
||
with pytest.raises(ValueError, match="params"):
|
||
await place_symbol(backend, "gate", 0, 0, params={"width": 1})
|
||
with pytest.raises(ValueError, match="actuator"):
|
||
await place_symbol(backend, "check", 0, 0, actuator="motor")
|
||
|
||
|
||
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"
|
||
)
|
||
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")
|
||
assert spec.name in {b.name for b in await backend.block_list()}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify they fail** — `uv run --frozen pytest tests/test_pid_insert.py -v` → ModuleNotFoundError.
|
||
|
||
- [ ] **Step 3: Add the layer and write the service**
|
||
|
||
In `engineering/layers.py`, insert into `PID_LAYERS` after the `INSTRUMENT-TAG-TEXT` entry:
|
||
|
||
```python
|
||
(("PROCESS-LINE-TEXT", 7, "Continuous", 0.25, "line numbers"),)
|
||
```
|
||
|
||
```python
|
||
# engineering/pid/insert.py
|
||
"""Placing catalogue symbols: define the block on first use, insert with
|
||
attributes, write the ACADMCP_PID payload, return the ports in WCS."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import TYPE_CHECKING
|
||
|
||
from engineering.layers import PID_LAYERS
|
||
|
||
from .symbols import FAMILY_LAYER, SymbolSpec, resolve, transform_port
|
||
from .tags import parse_tag, split_instrument_tag
|
||
from .xdata import symbol_payload, write_payload
|
||
|
||
if TYPE_CHECKING:
|
||
from backends.base import AutoCADBackend
|
||
|
||
FAIL_POSITIONS = ("", "FO", "FC", "FL")
|
||
_PID_LAYER_DEFS = {name: (color, linetype, lw) for name, color, linetype, lw, _desc in PID_LAYERS}
|
||
|
||
|
||
async def ensure_layer(backend: AutoCADBackend, layer_name: str) -> bool:
|
||
"""Create ``layer_name`` if missing — from PID_LAYERS when it is one of ours."""
|
||
existing = {lyr.name.lower() for lyr in await backend.layer_list()}
|
||
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
|
||
)
|
||
return True
|
||
|
||
|
||
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
|
||
)
|
||
return True
|
||
|
||
|
||
async def insert_symbol(
|
||
backend: AutoCADBackend,
|
||
spec: SymbolSpec,
|
||
x: float,
|
||
y: float,
|
||
rotation: float = 0.0,
|
||
scale: float = 1.0,
|
||
attributes: dict | None = None,
|
||
layer: str | None = None,
|
||
payload: dict | None = None,
|
||
) -> dict:
|
||
layer_name = layer or FAMILY_LAYER[spec.family]
|
||
if layer_name == "line":
|
||
raise ValueError(f"{spec.name}: markers need an explicit layer (the line's)")
|
||
created = await ensure_layer(backend, layer_name)
|
||
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
|
||
)
|
||
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 {
|
||
"handle": ref.handle,
|
||
"block_name": spec.name,
|
||
"defined": defined,
|
||
"layers_created": [layer_name] if created else [],
|
||
"ports": ports,
|
||
"layer": layer_name,
|
||
}
|
||
|
||
|
||
async def place_symbol(
|
||
backend: AutoCADBackend,
|
||
symbol: str,
|
||
x: float,
|
||
y: float,
|
||
rotation: float = 0.0,
|
||
scale: float = 1.0,
|
||
tag: str | None = None,
|
||
desc: str | None = None,
|
||
fail: str | None = None,
|
||
link: str | None = None,
|
||
layer: str | None = None,
|
||
**options,
|
||
) -> dict:
|
||
"""The ``pid_symbol_insert`` tool body."""
|
||
if scale <= 0:
|
||
raise ValueError("scale must be > 0")
|
||
spec = resolve(symbol, **options)
|
||
if spec.family == "marker":
|
||
raise ValueError(f"{symbol} is a line marker; pid_line_draw places markers and arrows")
|
||
tags_present = {att["tag"] for att in spec.attdefs}
|
||
attributes: dict[str, str] = {}
|
||
warnings: list[str] = []
|
||
tag_text: str | None = None
|
||
if tag:
|
||
tag_text = tag.strip()
|
||
if spec.family == "instrument":
|
||
parsed = parse_tag(tag_text, kind="instrument")
|
||
func, loop = split_instrument_tag(tag_text)
|
||
attributes.update(FUNC=func, LOOP=loop)
|
||
else:
|
||
parsed = parse_tag(tag_text, kind="auto")
|
||
attributes["TAG"] = tag_text
|
||
if not parsed["valid"]:
|
||
warnings.extend(parsed["errors"])
|
||
if desc is not None:
|
||
if "DESC" not in tags_present:
|
||
warnings.append(f"{symbol} has no DESC attribute; description ignored")
|
||
else:
|
||
attributes["DESC"] = desc
|
||
if fail is not None:
|
||
if "FAIL" not in tags_present:
|
||
raise ValueError(f"{symbol}: 'fail' needs an actuator (no FAIL attribute)")
|
||
if fail.upper() not in FAIL_POSITIONS:
|
||
raise ValueError("fail must be one of FO, FC, FL (or empty)")
|
||
attributes["FAIL"] = fail.upper()
|
||
if link is not None:
|
||
if "LINK" not in tags_present:
|
||
raise ValueError(f"{symbol}: 'link' is only valid for offpage connectors")
|
||
attributes["LINK"] = link
|
||
result = await insert_symbol(backend, spec, x, y, rotation, scale, attributes, layer)
|
||
result.update(tag=tag_text, tag_warnings=warnings, backend=backend.name)
|
||
return result
|
||
```
|
||
|
||
Add to `engineering/pid/__init__.py`:
|
||
|
||
```python
|
||
from .insert import ensure_block, ensure_layer, insert_symbol, place_symbol # noqa: F401
|
||
from .symbols import CATALOG_VERSION, Port, SymbolSpec, all_specs, list_symbols, resolve # noqa: F401
|
||
from .tags import parse_tag, split_instrument_tag # noqa: F401
|
||
```
|
||
|
||
- [ ] **Step 4: Run the tests** — `uv run --frozen pytest tests/test_pid_insert.py tests/test_pid_symbols.py -v` → all PASS. (`backend.name` is the existing backend name property used by `system_status`; it returns `"ezdxf"` / `"com"`.)
|
||
|
||
- [ ] **Step 5: Register the two tools**
|
||
|
||
In `server.py`, before the `# ── RESOURCES` block, add:
|
||
|
||
```python
|
||
# ---------------------------------------------------------------------------
|
||
# ── SECTION 17: P&ID (2 tools) ──────────────────────────────────────────────
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@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"},
|
||
)
|
||
async def pid_symbol_list(
|
||
family: Annotated[
|
||
str | None,
|
||
"valve | rotating | vessel | heat | misc | instrument | connector | marker; omit for all",
|
||
] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""The symbols `pid_symbol_insert` can place, with their ports and variant options.
|
||
|
||
Authored from ISO 10628-2 (equipment, valves) and ISA-5.1 (instrumentation);
|
||
each row's `source` names the figure. Symbol-local sizes are millimetres at
|
||
A3/A1 paper scale (bubble Ø10, valve 8×4); `scale` at insertion rescales.
|
||
"""
|
||
from engineering.pid.symbols import CATALOG_VERSION, list_symbols
|
||
|
||
return {"catalog_version": CATALOG_VERSION, "symbols": list_symbols(family)}
|
||
|
||
|
||
@cad_tool(
|
||
summary="Place a P&ID symbol (valve, pump, vessel, instrument bubble, connector) as a tagged block with ports.",
|
||
cost="mutate",
|
||
)
|
||
@mcp.tool(
|
||
annotations={"title": "P&ID: Insert Symbol", "readOnlyHint": False},
|
||
tags={"pid", "create"},
|
||
)
|
||
async def pid_symbol_insert(
|
||
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,
|
||
desc: Annotated[str | None, "Description attribute"] = 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,
|
||
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,
|
||
layer: Annotated[str | None, "Override the family layer"] = None,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Insert a catalogue symbol as a real block with TAG attributes and named ports.
|
||
|
||
Defines the block on first use (`defined`), creates a missing family layer
|
||
from the P&ID layer set (`layers_created`), writes an ACADMCP_PID payload
|
||
so readers without the catalogue still see the ports, and returns every
|
||
port in WCS — connect them with `pid_line_draw`. An invalid ISA-5.1 tag is
|
||
written and reported in `tag_warnings`; `drawing_critique` flags it.
|
||
"""
|
||
from engineering.pid.insert import place_symbol
|
||
|
||
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,
|
||
)
|
||
```
|
||
|
||
Also insert `"pid"` at the **front** of `_GROUP_TAG_PRIORITY` and add `"pid": "pid"` to `_GROUP_TAG_LABELS`.
|
||
|
||
Alias records:
|
||
|
||
```python
|
||
"pid_symbol_list": ToolAliases(
|
||
acad=(),
|
||
synonyms=("p&id symbols", "pid symbol catalogue", "which valves can i draw", "instrument bubble types"),
|
||
),
|
||
"pid_symbol_insert": ToolAliases(
|
||
acad=("INSERT",),
|
||
synonyms=("p&id symbol", "insert a valve", "place a pump", "draw a vessel", "instrument bubble",
|
||
"control valve", "off-page connector", "process equipment symbol"),
|
||
),
|
||
```
|
||
|
||
Regenerate the inventory; README `159 tools`.
|
||
|
||
- [ ] **Step 6: Run the gates and commit**
|
||
|
||
Run: `uv run --frozen pytest tests/test_pid_insert.py tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py -v`
|
||
|
||
```bash
|
||
git add engineering/layers.py engineering/pid/insert.py engineering/pid/__init__.py server.py discovery/aliases.py docs/tool-inventory.json README.md tests/test_pid_insert.py
|
||
git commit -m "feat(pid): pid_symbol_list and pid_symbol_insert — blocks with tags, ports and XDATA"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Pure routing, labels, markers, crossings, line numbers (`engineering/pid/lines.py`)
|
||
|
||
**Files:**
|
||
- Create: `engineering/pid/lines.py`
|
||
- Test: `tests/test_pid_lines.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: `LineClass(layer, linetype, marker, arrow, kind)`, `LINE_CLASSES: dict[str, LineClass]`, `LAYER_TO_CLASS`, `PID_LINE_LAYERS`, `AXIS`, `snap_axis(dx, dy) -> float`, `route(start, start_dir, end, end_dir, stub=5.0, mode="auto") -> list[tuple[float, float]]`, `simplify(points)`, `path_length(points)`, `label_placement(vertices, offset=1.5) -> (x, y, rotation_deg, segment_index)`, `marker_positions(vertices, min_len=15.0) -> list[(x, y, rotation_deg)]`, `segments_cross(p1, p2, q1, q2) -> bool`, `count_crossings(vertices, others) -> int`, `format_line_number(fmt, **fields) -> str`, `DEFAULT_NUMBER_FORMAT`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```python
|
||
# tests/test_pid_lines.py
|
||
"""Routing is geometry, so it is tested without a backend."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
from engineering.pid.lines import (
|
||
DEFAULT_NUMBER_FORMAT,
|
||
LINE_CLASSES,
|
||
count_crossings,
|
||
format_line_number,
|
||
label_placement,
|
||
marker_positions,
|
||
route,
|
||
snap_axis,
|
||
)
|
||
|
||
|
||
def test_line_classes_map_to_layers_and_markers():
|
||
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["process_major"].arrow is True and LINE_CLASSES["pneumatic"].arrow is False
|
||
assert LINE_CLASSES["utility"].kind == "process" and LINE_CLASSES["data"].kind == "signal"
|
||
|
||
|
||
def test_snap_axis_picks_the_dominant_component():
|
||
assert snap_axis(10, 1) == 0.0 and snap_axis(-10, 1) == 180.0
|
||
assert snap_axis(1, 10) == 90.0 and snap_axis(1, -10) == 270.0
|
||
|
||
|
||
def test_straight_when_aligned_and_facing():
|
||
assert route((0, 0), 0.0, (50, 0), 180.0) == [(0.0, 0.0), (50.0, 0.0)]
|
||
|
||
|
||
def test_l_route_when_perpendicular():
|
||
path = route((0, 0), 0.0, (50, 30), 270.0)
|
||
assert path == [(0.0, 0.0), (50.0, 0.0), (50.0, 30.0)]
|
||
|
||
|
||
def test_z_route_when_parallel_and_offset():
|
||
path = route((0, 0), 0.0, (60, 20), 180.0, stub=5.0)
|
||
assert path == [(0.0, 0.0), (30.0, 0.0), (30.0, 20.0), (60.0, 20.0)]
|
||
|
||
|
||
def test_ports_facing_away_on_one_axis_need_waypoints():
|
||
with pytest.raises(ValueError, match="waypoints"):
|
||
route((0, 0), 180.0, (40, 0), 0.0, stub=5.0)
|
||
path = route((0, 0), 180.0, (40, 0), 0.0, mode=[(-5, 0), (-5, -10), (45, -10), (45, 0)])
|
||
assert len(path) == 6
|
||
|
||
|
||
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"
|
||
)
|
||
|
||
|
||
def test_radial_ends_take_the_axis_towards_the_other_end():
|
||
path = route((0, 0), None, (0, 40), None)
|
||
assert path == [(0.0, 0.0), (0.0, 40.0)]
|
||
|
||
|
||
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),
|
||
]
|
||
with pytest.raises(ValueError, match="repeat"):
|
||
route((0, 0), 0.0, (10, 7), 90.0, mode=[(0, 0)])
|
||
with pytest.raises(ValueError, match="zero"):
|
||
route((0, 0), 0.0, (0, 0), 90.0)
|
||
|
||
|
||
def test_label_sits_left_of_the_longest_segment_and_never_upside_down():
|
||
x, y, rot, index = label_placement([(0, 0), (10, 0), (10, 40), (0, 40)])
|
||
assert index == 1 and rot == 90.0
|
||
assert (x, y) == pytest.approx((8.5, 20.0))
|
||
x, y, rot, index = label_placement([(50, 0), (0, 0)])
|
||
assert rot == 0.0 and y == pytest.approx(1.5)
|
||
|
||
|
||
def test_markers_only_on_segments_long_enough():
|
||
marks = marker_positions([(0, 0), (10, 0), (10, 40)], min_len=15.0)
|
||
assert marks == [(10.0, 20.0, 90.0)]
|
||
assert marker_positions([(0, 0), (30, 0)]) == [(15.0, 0.0, 0.0)]
|
||
|
||
|
||
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"
|
||
)
|
||
|
||
|
||
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("L-{seq}", seq=7) == "L-7"
|
||
with pytest.raises(ValueError, match="unknown"):
|
||
format_line_number("{bogus}", seq=1)
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify they fail** — ModuleNotFoundError.
|
||
|
||
- [ ] **Step 3: Write the module**
|
||
|
||
```python
|
||
# engineering/pid/lines.py
|
||
"""Line classes (ISA-5.1 Table 5.3.1), orthogonal routing, labels, markers, crossings."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
import re
|
||
from dataclasses import dataclass
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class LineClass:
|
||
layer: str
|
||
linetype: str | None # None = ByLayer
|
||
marker: str | None # marker symbol placed on long segments
|
||
arrow: bool # flow arrow at the end by default
|
||
kind: str # "process" | "signal"
|
||
|
||
|
||
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"
|
||
),
|
||
"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"
|
||
),
|
||
"data": LineClass("INSTRUMENT-LINE-SIGNAL", "Continuous", "mark_data", False, "signal"),
|
||
}
|
||
#: Layer → class when a line carries no payload. ``None`` = decide by linetype.
|
||
LAYER_TO_CLASS: dict[str, str | None] = {
|
||
"PROCESS-PIPING-MAIN": "process_major",
|
||
"PROCESS-PIPING-SECONDARY": "process_minor",
|
||
"UTILITY-LINE": "utility",
|
||
"ELECTRICAL-LINE": "electric",
|
||
"INSTRUMENT-LINE-SIGNAL": None,
|
||
}
|
||
PID_LINE_LAYERS = frozenset(LAYER_TO_CLASS)
|
||
AXIS = {0.0: (1.0, 0.0), 90.0: (0.0, 1.0), 180.0: (-1.0, 0.0), 270.0: (0.0, -1.0)}
|
||
DEFAULT_NUMBER_FORMAT = "{size}-{service}-{seq}-{spec}-{insulation}"
|
||
_EPS = 1e-9
|
||
|
||
Point = tuple[float, float]
|
||
|
||
|
||
def snap_axis(dx: float, dy: float) -> float:
|
||
if abs(dx) >= abs(dy):
|
||
return 0.0 if dx >= 0 else 180.0
|
||
return 90.0 if dy >= 0 else 270.0
|
||
|
||
|
||
def _unit(direction_deg: float) -> Point:
|
||
return AXIS[float(direction_deg) % 360.0]
|
||
|
||
|
||
def _dist(a: Point, b: Point) -> float:
|
||
return math.hypot(b[0] - a[0], b[1] - a[1])
|
||
|
||
|
||
def path_length(points) -> float:
|
||
return sum(_dist(a, b) for a, b in zip(points, points[1:]))
|
||
|
||
|
||
def simplify(points) -> list[Point]:
|
||
"""Drop repeated points and merge collinear consecutive segments."""
|
||
pts: list[Point] = []
|
||
for p in points:
|
||
q = (float(p[0]), float(p[1]))
|
||
if not pts or _dist(pts[-1], q) > _EPS:
|
||
pts.append(q)
|
||
out: list[Point] = []
|
||
for p in pts:
|
||
if len(out) >= 2:
|
||
a, b = out[-2], out[-1]
|
||
cross = (b[0] - a[0]) * (p[1] - b[1]) - (b[1] - a[1]) * (p[0] - b[0])
|
||
dot = (b[0] - a[0]) * (p[0] - b[0]) + (b[1] - a[1]) * (p[1] - b[1])
|
||
if abs(cross) < _EPS and dot > 0:
|
||
out[-1] = p
|
||
continue
|
||
out.append(p)
|
||
return out
|
||
|
||
|
||
def _direction(a: Point, b: Point) -> Point:
|
||
length = _dist(a, b)
|
||
return ((b[0] - a[0]) / length, (b[1] - a[1]) / length)
|
||
|
||
|
||
def _same(u: Point, v: Point) -> bool:
|
||
return abs(u[0] - v[0]) < 1e-6 and abs(u[1] - v[1]) < 1e-6
|
||
|
||
|
||
def _validate(candidate, ds: float | None, de: float | None, stub: float) -> list[Point] | None:
|
||
pts = simplify(candidate)
|
||
if len(pts) < 2:
|
||
return None
|
||
segs = list(zip(pts, pts[1:]))
|
||
straight = len(segs) == 1
|
||
if ds is not None and not _same(_direction(*segs[0]), _unit(ds)):
|
||
return None
|
||
if de is not None:
|
||
ue = _unit(de)
|
||
if not _same(_direction(*segs[-1]), (-ue[0], -ue[1])):
|
||
return None
|
||
minimum = 0.01 if straight else max(stub, 0.01)
|
||
if any(_dist(a, b) < minimum - _EPS for a, b in segs):
|
||
return None
|
||
for (a1, b1), (a2, b2) in zip(segs, segs[1:]):
|
||
u, v = _direction(a1, b1), _direction(a2, b2)
|
||
if abs(u[0] * v[0] + u[1] * v[1]) > 1e-6:
|
||
return None
|
||
return pts
|
||
|
||
|
||
def route(start, start_dir, end, end_dir, stub: float = 5.0, mode="auto") -> list[Point]:
|
||
"""Orthogonal path from ``start`` (leaving along ``start_dir``) to ``end``
|
||
(arriving against ``end_dir``). ``None`` directions are radial: the axis
|
||
towards the other end. Raises ``ValueError`` when no candidate honours the stub."""
|
||
s: Point = (float(start[0]), float(start[1]))
|
||
e: Point = (float(end[0]), float(end[1]))
|
||
if isinstance(mode, (list, tuple)):
|
||
path = [s] + [(float(p[0]), float(p[1])) for p in mode] + [e]
|
||
for a, b in zip(path, path[1:]):
|
||
if _dist(a, b) < 0.01:
|
||
raise ValueError("waypoints repeat a point (segment shorter than 0.01 mm)")
|
||
return path
|
||
if _dist(s, e) < 0.01:
|
||
raise ValueError("zero-length line: both ends are the same point")
|
||
if mode == "direct":
|
||
return [s, e]
|
||
if mode != "auto":
|
||
raise ValueError("route must be 'auto', 'direct' or a list of waypoints")
|
||
if stub < 0:
|
||
raise ValueError("stub must be >= 0")
|
||
ds = snap_axis(e[0] - s[0], e[1] - s[1]) if start_dir is None else float(start_dir) % 360.0
|
||
de = snap_axis(s[0] - e[0], s[1] - e[1]) if end_dir is None else float(end_dir) % 360.0
|
||
us, ue = _unit(ds), _unit(de)
|
||
s1 = (s[0] + us[0] * stub, s[1] + us[1] * stub)
|
||
e1 = (e[0] + ue[0] * stub, e[1] + ue[1] * stub)
|
||
xm, ym = (s1[0] + e1[0]) / 2.0, (s1[1] + e1[1]) / 2.0
|
||
candidates = [
|
||
[s, e],
|
||
[s, (e[0], s[1]), e],
|
||
[s, (s[0], e[1]), e],
|
||
[s, (xm, s[1]), (xm, e[1]), e],
|
||
[s, (s[0], ym), (e[0], ym), e],
|
||
[s, s1, (s1[0], e1[1]), e1, e],
|
||
[s, s1, (e1[0], s1[1]), e1, e],
|
||
]
|
||
valid = [p for p in (_validate(c, ds, de, stub) for c in candidates) if p]
|
||
if not valid:
|
||
raise ValueError(
|
||
f"no orthogonal route from {s} to {e} honours a {stub} mm stub on both ports; "
|
||
"shorten `stub` or pass waypoints"
|
||
)
|
||
valid.sort(key=lambda p: (len(p) - 2, path_length(p)))
|
||
return valid[0]
|
||
|
||
|
||
def label_placement(vertices, offset: float = 1.5) -> tuple[float, float, float, int]:
|
||
"""Midpoint of the longest segment, offset to its left, readable rotation."""
|
||
pts = [(float(x), float(y)) for x, y in vertices]
|
||
segs = list(zip(pts, pts[1:]))
|
||
index = max(range(len(segs)), key=lambda i: _dist(*segs[i]))
|
||
(x0, y0), (x1, y1) = segs[index]
|
||
angle = math.degrees(math.atan2(y1 - y0, x1 - x0)) % 360.0
|
||
if 90.0 < angle <= 270.0:
|
||
angle = (angle - 180.0) % 360.0
|
||
ux, uy = math.cos(math.radians(angle)), math.sin(math.radians(angle))
|
||
mx, my = (x0 + x1) / 2.0, (y0 + y1) / 2.0
|
||
return (mx - uy * offset, my + ux * offset, round(angle, 6), index)
|
||
|
||
|
||
def marker_positions(vertices, min_len: float = 15.0) -> list[tuple[float, float, float]]:
|
||
pts = [(float(x), float(y)) for x, y in vertices]
|
||
out = []
|
||
for (x0, y0), (x1, y1) in zip(pts, pts[1:]):
|
||
if _dist((x0, y0), (x1, y1)) < min_len:
|
||
continue
|
||
angle = math.degrees(math.atan2(y1 - y0, x1 - x0)) % 360.0
|
||
out.append(((x0 + x1) / 2.0, (y0 + y1) / 2.0, round(angle, 6)))
|
||
return out
|
||
|
||
|
||
def _orient(a: Point, b: Point, c: Point) -> float:
|
||
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
|
||
|
||
|
||
def segments_cross(p1, p2, q1, q2) -> bool:
|
||
"""Proper crossing only: shared endpoints and T-touches are junctions, not crossings."""
|
||
d1, d2 = _orient(q1, q2, p1), _orient(q1, q2, p2)
|
||
d3, d4 = _orient(p1, p2, q1), _orient(p1, p2, q2)
|
||
return d1 * d2 < -_EPS and d3 * d4 < -_EPS
|
||
|
||
|
||
def count_crossings(vertices, others) -> int:
|
||
pts = [(float(x), float(y)) for x, y in vertices]
|
||
count = 0
|
||
for a, b in zip(pts, pts[1:]):
|
||
for other in others:
|
||
opts = [(float(x), float(y)) for x, y in other]
|
||
for c, d in zip(opts, opts[1:]):
|
||
if segments_cross(a, b, c, d):
|
||
count += 1
|
||
return count
|
||
|
||
|
||
_FIELD_RE = re.compile(r"^\{(\w+)\}$")
|
||
_KNOWN_FIELDS = {"size", "service", "seq", "spec", "insulation", "area", "unit"}
|
||
|
||
|
||
def format_line_number(fmt: str, **fields) -> str:
|
||
"""Fill ``fmt`` (``-``-separated tokens) and drop the empty fields with their separators."""
|
||
kept: list[str] = []
|
||
for token in fmt.split("-"):
|
||
match = _FIELD_RE.match(token)
|
||
if not match:
|
||
kept.append(token)
|
||
continue
|
||
name = match.group(1)
|
||
if name not in _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
|
||
kept.append(str(value))
|
||
return "-".join(kept)
|
||
```
|
||
|
||
- [ ] **Step 4: Run the tests** — `uv run --frozen pytest tests/test_pid_lines.py -v` → all PASS. (Ports facing away along one axis have no orthogonal route: every U candidate collapses to a reversal after `simplify`, which the perpendicular check rejects — that is why the test expects the refusal and passes waypoints. The U candidates still serve ports facing away on *different* axes.)
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add engineering/pid/lines.py tests/test_pid_lines.py
|
||
git commit -m "feat(pid): line classes, orthogonal routing, labels, markers, crossings"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: `pid_line_draw`
|
||
|
||
**Files:**
|
||
- Create: `engineering/pid/drawlines.py`
|
||
- Modify: `server.py` (SECTION 17 header → `(3 tools)`), `discovery/aliases.py`, `README.md` (`160 tools`), inventory
|
||
- Test: `tests/test_pid_line_draw.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `route`, `label_placement`, `marker_positions`, `count_crossings`, `format_line_number`, `LINE_CLASSES`, `PID_LINE_LAYERS`, `snap_axis`, `AXIS` (Task 12); `insert_symbol`, `ensure_layer` (Task 11); `read_payload`, `write_payload`, `line_payload`, `marker_payload` (Task 5); `resolve`, `Port`, `transform_port` (Task 7); backend `entity_get`, `entity_list`, `entity_create_polyline`, `entity_set_properties`, `entity_create_text`.
|
||
- Produces: `async resolve_endpoint(backend, ref) -> dict` (`x, y, direction_deg, kind, radius, handle, port, block_name`), `async existing_pid_lines(backend) -> list[dict]` (`handle, vertices, payload`), `async draw_line(backend, from_, to, line_class="process_major", route_mode="auto", stub=5.0, line_number=None, size=None, service=None, spec=None, insulation=None, number_format=None, label=True, arrow=None) -> dict` with keys `handle, vertices, line_class, layer, line_number, from, to, port_reuse, crossings, label_handle, arrow_handle, marker_handles, backend`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```python
|
||
# tests/test_pid_line_draw.py
|
||
"""pid_line_draw: port-to-port orthogonal lines with class, number, markers, arrows."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
from engineering.pid.drawlines import draw_line
|
||
from engineering.pid.insert import place_symbol
|
||
from engineering.pid.xdata import read_payload
|
||
|
||
pytestmark = pytest.mark.asyncio
|
||
|
||
|
||
async def _pump_and_vessel(backend):
|
||
pump = await place_symbol(backend, "centrifugal_pump", 100, 100, tag="P-101")
|
||
vessel = await place_symbol(backend, "vertical_vessel", 220, 120, tag="V-201")
|
||
return pump, vessel
|
||
|
||
|
||
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",
|
||
)
|
||
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"]
|
||
assert result["arrow_handle"] and result["marker_handles"] == []
|
||
assert result["crossings"] == 0 and result["port_reuse"] == []
|
||
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["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"
|
||
)
|
||
|
||
|
||
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",
|
||
)
|
||
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",
|
||
)
|
||
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",
|
||
)
|
||
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
|
||
mark = await backend.entity_get(result["marker_handles"][0])
|
||
assert mark.properties["block_name"] == "PID_MARKER_MARK_PNEUMATIC"
|
||
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"
|
||
)
|
||
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}
|
||
)
|
||
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"
|
||
)
|
||
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"},
|
||
)
|
||
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}
|
||
)
|
||
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"):
|
||
line = await backend.entity_create_line(0, 0, 1, 1)
|
||
await draw_line(backend, {"handle": line.handle, "port": "in"}, {"x": 0, "y": 0})
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify they fail** — ModuleNotFoundError.
|
||
|
||
- [ ] **Step 3: Write the orchestration**
|
||
|
||
```python
|
||
# engineering/pid/drawlines.py
|
||
"""``pid_line_draw``: resolve ports, route, draw, label, mark, tag with XDATA."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import TYPE_CHECKING
|
||
|
||
from .insert import ensure_layer, insert_symbol
|
||
from .lines import (
|
||
AXIS,
|
||
DEFAULT_NUMBER_FORMAT,
|
||
LINE_CLASSES,
|
||
PID_LINE_LAYERS,
|
||
count_crossings,
|
||
format_line_number,
|
||
label_placement,
|
||
marker_positions,
|
||
route,
|
||
snap_axis,
|
||
)
|
||
from .symbols import Port, resolve, transform_port
|
||
from .xdata import line_payload, marker_payload, read_payload, write_payload
|
||
|
||
if TYPE_CHECKING:
|
||
from backends.base import AutoCADBackend
|
||
|
||
TEXT_HEIGHT = 2.5
|
||
LABEL_LAYER = "PROCESS-LINE-TEXT"
|
||
_INSERT_TYPES = {"INSERT", "BLOCKREFERENCE"}
|
||
|
||
|
||
async def resolve_endpoint(backend: AutoCADBackend, ref: dict) -> dict:
|
||
"""``{handle, port}`` → the port in WCS (payload ports + INSERT transform); ``{x, y}`` → a free point."""
|
||
if not isinstance(ref, dict):
|
||
raise ValueError("endpoint must be {handle, port} or {x, y}")
|
||
if "handle" in ref:
|
||
handle = str(ref["handle"])
|
||
info = await backend.entity_get(handle)
|
||
block_name = str(info.properties.get("block_name", ""))
|
||
if block_name.startswith("PID_MARKER_"):
|
||
raise ValueError(f"{handle} is a marker/arrow — decoration on a line, not an endpoint")
|
||
payload = await read_payload(backend, handle)
|
||
if info.type not in _INSERT_TYPES or payload is None or payload.get("kind") != "symbol":
|
||
raise ValueError(f"{handle} is not a P&ID symbol placed by pid_symbol_insert")
|
||
ports = payload["ports"]
|
||
port_name = ref.get("port")
|
||
if port_name is None:
|
||
if len(ports) == 1:
|
||
port_name = next(iter(ports))
|
||
elif payload.get("family") == "instrument" and "signal" in ports:
|
||
port_name = "signal"
|
||
else:
|
||
raise ValueError(f"{handle}: choose a port: {', '.join(ports)}")
|
||
if port_name not in ports:
|
||
raise ValueError(f"{handle} has no port {port_name!r}; ports: {', '.join(ports)}")
|
||
lx, ly, ldir, kind, radius = ports[port_name]
|
||
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)),
|
||
)
|
||
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,
|
||
}
|
||
raise ValueError("endpoint must be {handle, port} or {x, y}")
|
||
|
||
|
||
def _vertices(info) -> list[tuple[float, float]]:
|
||
if info.type == "LINE":
|
||
s, e = info.properties["start"], info.properties["end"]
|
||
return [(float(s[0]), float(s[1])), (float(e[0]), float(e[1]))]
|
||
return [(float(p[0]), float(p[1])) for p in info.properties.get("points", [])]
|
||
|
||
|
||
async def existing_pid_lines(backend: AutoCADBackend) -> list[dict]:
|
||
"""Every LINE/LWPOLYLINE on a P&ID line layer, with its payload when it has one."""
|
||
out = []
|
||
for type_filter in ("LWPOLYLINE", "LINE"):
|
||
offset = 0
|
||
while True:
|
||
page = await backend.entity_list(type_filter=type_filter, limit=1000, offset=offset)
|
||
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
|
||
)
|
||
out.append({"handle": info.handle, "vertices": _vertices(info), "payload": payload})
|
||
if len(page) < 1000:
|
||
break
|
||
offset += 1000
|
||
return out
|
||
|
||
|
||
def _anchor(endpoint: dict, other: dict) -> tuple[tuple[float, float], float | None]:
|
||
"""Where the line actually starts/ends: on the circle for radial ports."""
|
||
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"], endpoint["y"]), endpoint["direction_deg"]
|
||
|
||
|
||
def _ref(endpoint: dict) -> dict | None:
|
||
return {"handle": endpoint["handle"], "port": endpoint["port"]} if endpoint["handle"] else None
|
||
|
||
|
||
async def draw_line(
|
||
backend: AutoCADBackend,
|
||
from_: dict,
|
||
to: dict,
|
||
line_class: str = "process_major",
|
||
route_mode="auto",
|
||
stub: float = 5.0,
|
||
line_number: str | None = None,
|
||
size: str | None = None,
|
||
service: str | None = None,
|
||
spec: str | None = None,
|
||
insulation: str | None = None,
|
||
number_format: str | None = None,
|
||
label: bool = True,
|
||
arrow: bool | None = None,
|
||
) -> dict:
|
||
if line_class not in LINE_CLASSES:
|
||
raise ValueError(f"line_class must be one of {', '.join(LINE_CLASSES)}, got {line_class!r}")
|
||
cls = LINE_CLASSES[line_class]
|
||
start = await resolve_endpoint(backend, from_)
|
||
end = await resolve_endpoint(backend, to)
|
||
if start["handle"] and start["handle"] == end["handle"] and start["port"] == end["port"]:
|
||
raise ValueError("from and to are the same port")
|
||
s_point, s_dir = _anchor(start, end)
|
||
e_point, e_dir = _anchor(end, start)
|
||
path = route(s_point, s_dir, e_point, e_dir, stub=stub, mode=route_mode)
|
||
|
||
existing = await existing_pid_lines(backend)
|
||
crossings = count_crossings(path, [ln["vertices"] for ln in existing])
|
||
used: set[tuple[str, str]] = set()
|
||
max_seq = 0
|
||
for ln in existing:
|
||
payload = ln["payload"] or {}
|
||
for key in ("from", "to"):
|
||
ref = payload.get(key)
|
||
if ref:
|
||
used.add((ref["handle"], ref["port"]))
|
||
if isinstance(payload.get("seq"), int):
|
||
max_seq = max(max_seq, payload["seq"])
|
||
port_reuse = [
|
||
{"handle": ep["handle"], "port": ep["port"]}
|
||
for ep in (start, end)
|
||
if ep["handle"] and (ep["handle"], ep["port"]) in used
|
||
]
|
||
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,
|
||
}
|
||
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
|
||
)
|
||
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)
|
||
),
|
||
)
|
||
|
||
label_handle = None
|
||
if label and line_number:
|
||
await ensure_layer(backend, LABEL_LAYER)
|
||
lx, ly, rot, _index = label_placement(path)
|
||
import math
|
||
|
||
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
|
||
)
|
||
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),
|
||
)
|
||
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),
|
||
)
|
||
marker_handles.append(placed["handle"])
|
||
|
||
return {
|
||
"handle": poly.handle,
|
||
"vertices": [[x, y] for x, y in path],
|
||
"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],
|
||
},
|
||
"to": {"handle": end["handle"], "port": end["port"], "x": e_point[0], "y": e_point[1]},
|
||
"port_reuse": port_reuse,
|
||
"crossings": crossings,
|
||
"label_handle": label_handle,
|
||
"arrow_handle": arrow_handle,
|
||
"marker_handles": marker_handles,
|
||
"backend": backend.name,
|
||
}
|
||
```
|
||
|
||
Move `import math` to the module's top-level imports (it is used in two places).
|
||
|
||
- [ ] **Step 4: Run the tests** — `uv run --frozen pytest tests/test_pid_line_draw.py -v` → all PASS. The geometry behind the first test: the pump's `discharge` at rotation 0 is `(100, 106)` leaving along +Y; the vessel's `N3` (left side, fraction 0.7 of height 40, 3 mm stub) is `(220 - 10 - 3, 120 - 20 + 28) = (207, 128)` entered travelling +X; the one-bend route `[(100,106), (100,128), (207,128)]` satisfies both directions and the stub, so it is chosen.
|
||
|
||
- [ ] **Step 5: Register the tool**
|
||
|
||
`server.py` SECTION 17 header → `(3 tools)`; add after `pid_symbol_insert`:
|
||
|
||
```python
|
||
@cad_tool(
|
||
summary="Draw a P&ID line port-to-port: orthogonal route, ISA-5.1 class, line number, markers, arrow.",
|
||
cost="mutate",
|
||
)
|
||
@mcp.tool(
|
||
annotations={"title": "P&ID: Draw Line", "readOnlyHint": False},
|
||
tags={"pid", "create"},
|
||
)
|
||
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",
|
||
] = "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,
|
||
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,
|
||
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,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Connect two ports with one LWPOLYLINE on the class's layer.
|
||
|
||
Ports come from `pid_symbol_insert`; a bubble's radial port is entered
|
||
without a name. `auto` picks the shortest orthogonal route that leaves each
|
||
port straight for `stub` mm; when none exists the call refuses and says so
|
||
— pass waypoints. `crossings` counts existing P&ID lines the new one cuts
|
||
(reported, never refused) and `port_reuse` names ports that already had a
|
||
line (branching is legal). Signal classes get ISA-5.1 markers (`//`, `X`,
|
||
`L`, `o`) as small blocks on segments ≥ 15 mm; electric is dashed by layer.
|
||
"""
|
||
from engineering.pid.drawlines import draw_line
|
||
|
||
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,
|
||
)
|
||
```
|
||
|
||
Alias record:
|
||
|
||
```python
|
||
"pid_line_draw": ToolAliases(
|
||
acad=("PLINE",),
|
||
synonyms=("process line", "pipe run", "signal line", "connect equipment", "instrument line",
|
||
"line number", "connect the pump to the vessel", "pneumatic signal"),
|
||
),
|
||
```
|
||
|
||
Regenerate inventory; README `160 tools`.
|
||
|
||
- [ ] **Step 6: Gates and commit**
|
||
|
||
Run: `uv run --frozen pytest tests/test_pid_line_draw.py tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py -v`
|
||
|
||
```bash
|
||
git add engineering/pid/drawlines.py server.py discovery/aliases.py docs/tool-inventory.json README.md tests/test_pid_line_draw.py
|
||
git commit -m "feat(pid): pid_line_draw — port-to-port orthogonal lines with class, number, markers"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: The reader — `engineering/pid/graph.py` + `pid_graph`
|
||
|
||
**Files:**
|
||
- Create: `engineering/pid/graph.py`
|
||
- Modify: `server.py` (SECTION 17 → `(4 tools)`), `discovery/aliases.py`, `README.md` (`161 tools`), inventory
|
||
- Test: `tests/test_pid_graph.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `read_payload`, `Port`, `transform_port`, `LAYER_TO_CLASS`, `PID_LINE_LAYERS`, `parse_tag`, backend `entity_list`, `entity_get_xdata`, `block_get_attributes`, `layer_list`, `layout_list`, `layout_set_current`.
|
||
- Produces: `async build_graph(backend, tolerance=0.5, label_search=15.0, scope="current_space", include_foreign=True, include_geometry=False) -> dict` with keys `nodes, edges, junctions, dangling, unclassified, offpage_links, stats, tolerance, scope, backend` (shapes in spec §9.1). Node `ports[name]` carries `edges: [edge handles]`. Edge `from`/`to` are `{"node": handle, "port": name}`, `{"junction": id}` or `None`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```python
|
||
# tests/test_pid_graph.py
|
||
"""pid_graph reads the drawing back: our symbols exactly, foreign ones with confidence."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
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
|
||
|
||
|
||
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"
|
||
)
|
||
return {"pump": pump, "hv": hv, "vessel": vessel, "fic": fic, "l1": l1, "l2": l2, "sig": sig}
|
||
|
||
|
||
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 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"]]["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"]}
|
||
assert set(edges) == {ids["l1"]["handle"], ids["l2"]["handle"], ids["sig"]["handle"]}
|
||
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 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 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
|
||
assert graph["junctions"] == [] and graph["unclassified"] == []
|
||
|
||
|
||
async def test_a_nudged_symbol_makes_its_line_dangle_with_a_hint(backend):
|
||
ids = await _small_pid(backend)
|
||
await backend.entity_move(ids["hv"]["handle"], 0.0, 1.0)
|
||
graph = await build_graph(backend, tolerance=0.5)
|
||
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 await _no_dangling_after(backend, tolerance=1.5)
|
||
|
||
|
||
async def _no_dangling_after(backend, tolerance):
|
||
graph = await build_graph(backend, tolerance=tolerance)
|
||
return graph["stats"]["dangling"] == 1 # only the deliberately open signal line
|
||
|
||
|
||
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"
|
||
)
|
||
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"]
|
||
junction = next(j for j in graph["junctions"] if j["id"] == edge["from"]["junction"])
|
||
assert (junction["x"], junction["y"]) == pytest.approx((130.0, 111.0))
|
||
assert set(junction["edges"]) == {branch["handle"], ids["l1"]["handle"]}
|
||
|
||
|
||
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"
|
||
)
|
||
graph = await build_graph(backend)
|
||
node = graph["nodes"][0]
|
||
assert node["ports"]["discharge"]["x"] == pytest.approx(-12.0)
|
||
assert node["ports"]["discharge"]["direction_deg"] == pytest.approx(180.0)
|
||
assert node["rotation_deg"] == pytest.approx(90.0) and node["scale"] == pytest.approx(2.0)
|
||
|
||
|
||
async def test_foreign_blocks_classify_with_reduced_confidence_and_inferred_ports(backend):
|
||
blk = backend._doc.blocks.new(name="GATE_VALVE")
|
||
blk.add_lwpolyline([(-4, -2), (-4, 2), (0, 0)], close=True)
|
||
blk.add_lwpolyline([(4, -2), (4, 2), (0, 0)], close=True)
|
||
blk.add_attdef("TAG", insert=(0, 4), text="", dxfattribs={"height": 2.5})
|
||
logo = backend._doc.blocks.new(name="COMPANY_LOGO")
|
||
logo.add_circle((0, 0), 5)
|
||
await backend.block_insert("GATE_VALVE", 50, 50, attributes={"TAG": "HV-9"})
|
||
await backend.block_insert("COMPANY_LOGO", 300, 300)
|
||
await backend.entity_create_line(10, 50, 46, 50, layer="PROCESS-PIPING-MAIN")
|
||
graph = await build_graph(backend)
|
||
assert len(graph["nodes"]) == 1
|
||
node = graph["nodes"][0]
|
||
assert node["kind"] == "valve" and node["source"] == "heuristic" and node["confidence"] == 0.6
|
||
assert node["tag"] == "HV-9" and node["tag_source"] == "attribute"
|
||
port = next(iter(node["ports"].values()))
|
||
assert port["inferred"] is True and (port["x"], port["y"]) == pytest.approx((46.0, 50.0))
|
||
assert graph["stats"]["confidence_min"] == 0.6
|
||
edge = graph["edges"][0]
|
||
assert edge["line_class"] == "process_major" and edge["class_source"] == "layer"
|
||
assert graph["stats"]["entities_scanned"] >= 3
|
||
|
||
|
||
async def test_include_foreign_false_ignores_untagged_blocks(backend):
|
||
blk = backend._doc.blocks.new(name="GATE_VALVE")
|
||
blk.add_line((0, 0), (1, 0))
|
||
await backend.block_insert("GATE_VALVE", 50, 50)
|
||
graph = await build_graph(backend, include_foreign=False)
|
||
assert graph["nodes"] == [] and graph["edges"] == []
|
||
|
||
|
||
async def test_line_number_and_tags_from_nearby_text(backend):
|
||
await backend.entity_create_line(0, 0, 100, 0, layer="PROCESS-PIPING-MAIN")
|
||
await backend.entity_create_text('6"-P-1234-CS1', 40, 2, 2.5, layer="PROCESS-LINE-TEXT")
|
||
graph = await build_graph(backend)
|
||
edge = graph["edges"][0]
|
||
assert edge["line_number"] == '6"-P-1234-CS1' and edge["number_source"] == "label"
|
||
|
||
|
||
async def test_scope_all_walks_every_layout_and_restores_the_current_one(backend):
|
||
await place_symbol(backend, "gate", 0, 0, tag="HV-1")
|
||
await backend.layout_create("SHEET-2")
|
||
await backend.layout_set_current("SHEET-2")
|
||
await place_symbol(backend, "gate", 0, 0, tag="HV-2")
|
||
await backend.layout_set_current("Model")
|
||
graph = await build_graph(backend, scope="all")
|
||
assert sorted(n["tag"] for n in graph["nodes"]) == ["HV-1", "HV-2"]
|
||
assert sorted(n["space"] for n in graph["nodes"]) == ["Model", "SHEET-2"]
|
||
assert (await backend.layout_list())["current"] == "Model"
|
||
|
||
|
||
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"
|
||
)
|
||
graph = await build_graph(backend)
|
||
assert graph["offpage_links"] == [
|
||
{"link": "L-17", "nodes": sorted([out["handle"], inn["handle"]])}
|
||
]
|
||
|
||
|
||
async def test_geometry_is_optional(backend):
|
||
await _small_pid(backend)
|
||
lean = await build_graph(backend)
|
||
assert "vertices" not in lean["edges"][0]
|
||
full = await build_graph(backend, include_geometry=True)
|
||
assert len(full["edges"][0]["vertices"]) >= 2
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify they fail** — ModuleNotFoundError.
|
||
|
||
- [ ] **Step 3: Write the graph builder**
|
||
|
||
```python
|
||
# engineering/pid/graph.py
|
||
"""Read a P&ID back as a graph: nodes (symbols), edges (lines), junctions, dangling ends.
|
||
|
||
Rules the reader keeps (spec §9.4): it never modifies the drawing, it reads
|
||
geometry from the drawing rather than the catalogue, and confidence is the
|
||
minimum of its inputs — never raised by agreement between heuristics.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
import re
|
||
from dataclasses import dataclass, field
|
||
from typing import TYPE_CHECKING
|
||
|
||
from .lines import LAYER_TO_CLASS, PID_LINE_LAYERS
|
||
from .symbols import Port, transform_port
|
||
from .tags import parse_tag
|
||
from .xdata import read_payload
|
||
|
||
if TYPE_CHECKING:
|
||
from backends.base import AutoCADBackend, EntityInfo
|
||
|
||
INSERT_TYPES = {"INSERT", "BLOCKREFERENCE"}
|
||
POLY_TYPES = {"LWPOLYLINE", "POLYLINE", "2DPOLYLINE"}
|
||
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",
|
||
}
|
||
FAMILY_KIND = {"instrument": "instrument", "valve": "valve", "connector": "connector"}
|
||
LINE_NUMBER_RE = re.compile(r'^\d+(?:"|MM)?-[A-Z]{1,4}-\d+', re.IGNORECASE)
|
||
CONFIDENCE = {"catalog": 1.0, "xdata": 0.95, "heuristic": 0.6, "inferred": 0.3}
|
||
|
||
|
||
@dataclass
|
||
class _Node:
|
||
id: str
|
||
kind: str
|
||
block_name: str
|
||
family: str | None
|
||
symbol: str | None
|
||
x: float
|
||
y: float
|
||
rotation_deg: float
|
||
scale: float
|
||
layer: str
|
||
space: str
|
||
bbox: tuple[float, float, float, float] | None
|
||
source: str
|
||
confidence: float
|
||
attributes: dict
|
||
ports: dict = field(default_factory=dict)
|
||
tag: str | None = None
|
||
tag_source: str | None = None
|
||
|
||
def to_dict(self) -> dict:
|
||
parsed = None
|
||
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,
|
||
"description": self.attributes.get("DESC") or None,
|
||
"source": self.source,
|
||
"confidence": self.confidence,
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class _Edge:
|
||
id: str
|
||
vertices: list[tuple[float, float]]
|
||
layer: str
|
||
linetype: str
|
||
space: str
|
||
payload: dict | None
|
||
source: str
|
||
line_class: str | None = None
|
||
class_source: str | None = None
|
||
line_number: str | None = None
|
||
number_source: str | None = None
|
||
from_ref: dict | None = None
|
||
to_ref: dict | None = None
|
||
|
||
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,
|
||
}
|
||
if include_geometry:
|
||
out["vertices"] = [[x, y] for x, y in self.vertices]
|
||
return out
|
||
|
||
|
||
class _Grid:
|
||
"""Uniform grid for nearest-within-tolerance queries."""
|
||
|
||
def __init__(self, cell: float):
|
||
self.cell = cell
|
||
self.cells: dict[tuple[int, int], list] = {}
|
||
|
||
def _key(self, x: float, y: float) -> tuple[int, int]:
|
||
return (int(math.floor(x / self.cell)), int(math.floor(y / self.cell)))
|
||
|
||
def add(self, x: float, y: float, item) -> None:
|
||
self.cells.setdefault(self._key(x, y), []).append((x, y, item))
|
||
|
||
def near(self, x: float, y: float, radius: float):
|
||
kx, ky = self._key(x, y)
|
||
reach = int(math.ceil(radius / self.cell))
|
||
for dx in range(-reach, reach + 1):
|
||
for dy in range(-reach, reach + 1):
|
||
for px, py, item in self.cells.get((kx + dx, ky + dy), []):
|
||
d = math.hypot(px - x, py - y)
|
||
if d <= radius:
|
||
yield d, item
|
||
|
||
|
||
def _vertices(info: EntityInfo) -> list[tuple[float, float]]:
|
||
if info.type in LINE_TYPES:
|
||
s, e = info.properties["start"], info.properties["end"]
|
||
return [(float(s[0]), float(s[1])), (float(e[0]), float(e[1]))]
|
||
return [(float(p[0]), float(p[1])) for p in info.properties.get("points", [])]
|
||
|
||
|
||
def _point_segment_distance(p, a, b) -> float:
|
||
ax, ay = a
|
||
bx, by = b
|
||
px, py = p
|
||
dx, dy = bx - ax, by - ay
|
||
if dx == 0 and dy == 0:
|
||
return math.hypot(px - ax, py - ay)
|
||
t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy)))
|
||
return math.hypot(px - (ax + t * dx), py - (ay + t * dy))
|
||
|
||
|
||
def _bbox(info: EntityInfo):
|
||
bb = info.properties.get("bounding_box")
|
||
if not bb:
|
||
return None
|
||
return (float(bb["min"][0]), float(bb["min"][1]), float(bb["max"][0]), float(bb["max"][1]))
|
||
|
||
|
||
def _on_bbox_boundary(p, bbox, tolerance) -> bool:
|
||
x, y = p
|
||
xmin, ymin, xmax, ymax = bbox
|
||
inside_x = xmin - tolerance <= x <= xmax + tolerance
|
||
inside_y = ymin - tolerance <= y <= ymax + tolerance
|
||
near_vertical = min(abs(x - xmin), abs(x - xmax)) <= tolerance and inside_y
|
||
near_horizontal = min(abs(y - ymin), abs(y - ymax)) <= tolerance and inside_x
|
||
return near_vertical or near_horizontal
|
||
|
||
|
||
async def _collect(backend: AutoCADBackend, scope: str) -> list[tuple[EntityInfo, str]]:
|
||
async def page_all(space: str):
|
||
out = []
|
||
offset = 0
|
||
while True:
|
||
page = await backend.entity_list(limit=1000, offset=offset)
|
||
out.extend((info, space) for info in page)
|
||
if len(page) < 1000:
|
||
return out
|
||
offset += 1000
|
||
|
||
layouts = await backend.layout_list()
|
||
current = layouts.get("current") or "Model"
|
||
if scope != "all":
|
||
return await page_all(current)
|
||
entities = []
|
||
try:
|
||
for name in layouts.get("layouts", [current]):
|
||
await backend.layout_set_current(name)
|
||
entities.extend(await page_all(name))
|
||
finally:
|
||
await backend.layout_set_current(current)
|
||
return entities
|
||
|
||
|
||
async def build_graph(
|
||
backend: AutoCADBackend,
|
||
tolerance: float = 0.5,
|
||
label_search: float = 15.0,
|
||
scope: str = "current_space",
|
||
include_foreign: bool = True,
|
||
include_geometry: bool = False,
|
||
) -> dict:
|
||
if tolerance <= 0:
|
||
raise ValueError("tolerance must be > 0")
|
||
if scope not in ("current_space", "all"):
|
||
raise ValueError("scope must be 'current_space' or 'all'")
|
||
entities = await _collect(backend, scope)
|
||
layer_linetypes = {lyr.name.upper(): lyr.linetype.upper() for lyr in await backend.layer_list()}
|
||
|
||
# 1 — classify INSERTs
|
||
nodes: dict[str, _Node] = {}
|
||
candidates: dict[str, _Node] = {} # unknown blocks kept only if a line touches them
|
||
for info, space in entities:
|
||
if info.type not in INSERT_TYPES:
|
||
continue
|
||
name = str(info.properties.get("block_name", ""))
|
||
if name.startswith("PID_MARKER_"):
|
||
continue
|
||
payload = await read_payload(backend, info.handle)
|
||
try:
|
||
attributes = await backend.block_get_attributes(info.handle)
|
||
except Exception:
|
||
attributes = {}
|
||
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,
|
||
)
|
||
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,
|
||
)
|
||
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,
|
||
)
|
||
node.ports[pname] = {**port, "edges": []}
|
||
nodes[node.id] = node
|
||
continue
|
||
if not include_foreign:
|
||
continue
|
||
upper = name.upper()
|
||
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,
|
||
)
|
||
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,
|
||
)
|
||
|
||
# 2 — candidate edges
|
||
edges: dict[str, _Edge] = {}
|
||
foreign_edges: dict[str, _Edge] = {}
|
||
for info, space in entities:
|
||
if info.type not in POLY_TYPES | LINE_TYPES:
|
||
continue
|
||
verts = _vertices(info)
|
||
if len(verts) < 2:
|
||
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",
|
||
)
|
||
if edge.payload:
|
||
edge.source = "xdata"
|
||
edge.line_class, edge.class_source = edge.payload.get("class"), "xdata"
|
||
edge.line_number = edge.payload.get("number")
|
||
edge.number_source = "xdata" if edge.line_number else None
|
||
edges[edge.id] = edge
|
||
elif layer in PID_LINE_LAYERS:
|
||
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, "")
|
||
)
|
||
cls = "electric" if "DASH" in effective else "signal_unknown"
|
||
edge.line_class, edge.class_source = cls, "layer"
|
||
edges[edge.id] = edge
|
||
elif include_foreign:
|
||
foreign_edges[edge.id] = edge
|
||
|
||
# 3 — spatial index of ports and vertices
|
||
grid = _Grid(cell=10.0 * tolerance)
|
||
radial_ports: list[tuple[str, str, float, float, float]] = []
|
||
for node in nodes.values():
|
||
for pname, port in node.ports.items():
|
||
if port.get("radius", 0.0) > 0:
|
||
# a bubble's line starts on its circle, not at its centre
|
||
radial_ports.append((node.id, pname, port["x"], port["y"], port["radius"]))
|
||
else:
|
||
grid.add(port["x"], port["y"], ("port", node.id, pname))
|
||
all_edges = {**edges, **foreign_edges}
|
||
for edge in all_edges.values():
|
||
for v in edge.vertices:
|
||
grid.add(v[0], v[1], ("vertex", edge.id))
|
||
|
||
# 4 — resolve endpoints
|
||
junctions: list[dict] = []
|
||
dangling: list[dict] = []
|
||
|
||
def _junction_at(x, y, edge_id, other_id) -> dict:
|
||
for j in junctions:
|
||
if math.hypot(j["x"] - x, j["y"] - y) <= tolerance:
|
||
for e in (edge_id, other_id):
|
||
if e not in j["edges"]:
|
||
j["edges"].append(e)
|
||
return {"junction": j["id"]}
|
||
j = {"id": f"J{len(junctions) + 1}", "x": x, "y": y, "edges": [edge_id, other_id]}
|
||
junctions.append(j)
|
||
return {"junction": j["id"]}
|
||
|
||
def _resolve(edge: _Edge, end: str) -> dict | None:
|
||
x, y = edge.vertices[0] if end == "from" else edge.vertices[-1]
|
||
for node_id, pname, cx, cy, r in radial_ports:
|
||
if abs(math.hypot(x - cx, y - cy) - r) <= tolerance:
|
||
nodes[node_id].ports[pname]["edges"].append(edge.id)
|
||
return {"node": node_id, "port": pname}
|
||
best = None
|
||
for d, item in grid.near(x, y, tolerance):
|
||
if item[0] == "port" and (best is None or d < best[0]):
|
||
best = (d, item)
|
||
if best:
|
||
_, (_kind, node_id, pname) = best
|
||
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.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],
|
||
}
|
||
grid.add(x, y, ("port", node.id, pname))
|
||
return {"node": node.id, "port": pname}
|
||
for d, item in grid.near(x, y, tolerance):
|
||
if item[0] == "vertex" and item[1] != edge.id:
|
||
return _junction_at(x, y, edge.id, item[1])
|
||
for other in all_edges.values():
|
||
if other.id == edge.id:
|
||
continue
|
||
for a, b in zip(other.vertices, other.vertices[1:]):
|
||
if _point_segment_distance((x, y), a, b) <= tolerance:
|
||
return _junction_at(x, y, edge.id, other.id)
|
||
nearest = None
|
||
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
|
||
),
|
||
}
|
||
)
|
||
return None
|
||
|
||
for edge in list(edges.values()):
|
||
edge.from_ref = _resolve(edge, "from")
|
||
edge.to_ref = _resolve(edge, "to")
|
||
for edge in list(foreign_edges.values()):
|
||
# a foreign line joins the graph only through a node
|
||
probe_from = _resolve(edge, "from")
|
||
probe_to = _resolve(edge, "to")
|
||
attached = any(ref and "node" in ref for ref in (probe_from, probe_to))
|
||
if attached:
|
||
edge.from_ref, edge.to_ref = probe_from, probe_to
|
||
edges[edge.id] = edge
|
||
else:
|
||
dangling[:] = [d for d in dangling if d["edge"] != edge.id]
|
||
for j in junctions:
|
||
if edge.id in j["edges"]:
|
||
j["edges"].remove(edge.id)
|
||
junctions[:] = [j for j in junctions if len(j["edges"]) >= 2]
|
||
|
||
# 5 — tags and line numbers from attributes or nearby text
|
||
texts = [(info, space) for info, space in entities if info.type in TEXT_TYPES]
|
||
|
||
def _text_near(x, y, radius, pattern=None):
|
||
best = None
|
||
for info, _space in texts:
|
||
ins = info.properties.get("insertion")
|
||
content = str(info.properties.get("text", "")).strip()
|
||
if not ins or not content or (pattern and not pattern.match(content)):
|
||
continue
|
||
d = math.hypot(float(ins[0]) - x, float(ins[1]) - y)
|
||
if d <= radius and (best is None or d < best[0]):
|
||
best = (d, content)
|
||
return best[1] if best else None
|
||
|
||
for node in nodes.values():
|
||
attrs = node.attributes
|
||
if node.kind == "instrument" and attrs.get("FUNC"):
|
||
node.tag, node.tag_source = f"{attrs['FUNC']}-{attrs['LOOP']}".rstrip("-"), "attribute"
|
||
else:
|
||
key = next((k for k in ATTRIBUTE_TAGS if attrs.get(k)), None)
|
||
if key:
|
||
node.tag, node.tag_source = str(attrs[key]).strip(), "attribute"
|
||
if node.tag is None:
|
||
radius = max((p.get("radius", 0.0) for p in node.ports.values()), default=0.0)
|
||
if node.kind == "instrument" and radius:
|
||
found = _text_near(node.x, node.y, radius)
|
||
else:
|
||
cx = ((node.bbox[0] + node.bbox[2]) / 2.0) if node.bbox else node.x
|
||
top = node.bbox[3] if node.bbox else node.y
|
||
found = _text_near(cx, top, label_search)
|
||
if found:
|
||
node.tag, node.tag_source = found, "text"
|
||
for edge in edges.values():
|
||
if edge.line_number:
|
||
continue
|
||
a, b = max(zip(edge.vertices, edge.vertices[1:]), key=lambda s: math.dist(*s))
|
||
found = _text_near((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0, label_search, LINE_NUMBER_RE)
|
||
if found:
|
||
edge.line_number, edge.number_source = found, "label"
|
||
|
||
# 6 — off-page links
|
||
links: dict[str, list[str]] = {}
|
||
for node in nodes.values():
|
||
link = node.attributes.get("LINK")
|
||
if node.kind == "connector" and link:
|
||
links.setdefault(str(link), []).append(node.id)
|
||
offpage_links = [{"link": k, "nodes": sorted(v)} for k, v in sorted(links.items())]
|
||
|
||
# 7 — assemble
|
||
node_rows = [n.to_dict() for n in nodes.values()]
|
||
edge_rows = [e.to_dict(include_geometry) for e in edges.values()]
|
||
by_kind: dict[str, int] = {}
|
||
for n in node_rows:
|
||
by_kind[n["kind"]] = by_kind.get(n["kind"], 0) + 1
|
||
by_class: dict[str, int] = {}
|
||
for e in edge_rows:
|
||
key = e["line_class"] or "unknown"
|
||
by_class[key] = by_class.get(key, 0) + 1
|
||
return {
|
||
"nodes": node_rows,
|
||
"edges": edge_rows,
|
||
"junctions": junctions,
|
||
"dangling": dangling,
|
||
"unclassified": [{"handle": c.id, "block_name": c.block_name} for c in candidates.values()],
|
||
"offpage_links": offpage_links,
|
||
"stats": {
|
||
"nodes_by_kind": dict(sorted(by_kind.items())),
|
||
"edges_by_class": dict(sorted(by_class.items())),
|
||
"dangling": len(dangling),
|
||
"unclassified": len(candidates),
|
||
"confidence_min": min((n["confidence"] for n in node_rows), default=1.0),
|
||
"entities_scanned": len(entities),
|
||
},
|
||
"tolerance": tolerance,
|
||
"scope": scope,
|
||
"backend": backend.name,
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the tests** — `uv run --frozen pytest tests/test_pid_graph.py -v` → PASS. Note for the foreign test: the INSERT of `GATE_VALVE` at (50, 50) has a bounding box of x ∈ [46, 54]; the line ends at (46, 50), on its left edge, so `_on_bbox_boundary` accepts it and creates `p1`.
|
||
|
||
- [ ] **Step 5: Register `pid_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",
|
||
)
|
||
@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,
|
||
include_geometry: Annotated[bool, "Include edge vertices"] = False,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Connectivity of the drawing as it *is*, not as it was drawn.
|
||
|
||
Nodes are block references classified by catalogue name (confidence 1.0),
|
||
ACADMCP_PID payload (0.95), tag attributes / block-name keywords (0.6) or
|
||
a line touching an unknown block (0.3); every node says which. Ports are
|
||
measured from the INSERT's real rotation and scale. Edges are lines and
|
||
polylines; each end resolves to a port, a junction on another line, or
|
||
`dangling` with the nearest port as a hint. `stats.confidence_min` is the
|
||
number to read before trusting a foreign drawing.
|
||
"""
|
||
from engineering.pid.graph import build_graph
|
||
|
||
return await build_graph(
|
||
_backend(ctx), tolerance, label_search, scope, include_foreign, include_geometry
|
||
)
|
||
```
|
||
|
||
Alias:
|
||
|
||
```python
|
||
"pid_graph": ToolAliases(
|
||
acad=(),
|
||
synonyms=("p&id connectivity", "what is connected to", "trace the line", "read the p&id",
|
||
"dangling lines", "instrument loop graph", "process graph"),
|
||
),
|
||
```
|
||
|
||
Inventory; README `161 tools`.
|
||
|
||
- [ ] **Step 6: Gates and commit**
|
||
|
||
```bash
|
||
git add engineering/pid/graph.py server.py discovery/aliases.py docs/tool-inventory.json README.md tests/test_pid_graph.py
|
||
git commit -m "feat(pid): pid_graph reads the drawing back with ports, junctions, dangling ends and confidence"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 15: Deliverables — instrument index, line list, equipment list
|
||
|
||
**Files:**
|
||
- Create: `engineering/pid/deliverables.py`
|
||
- Modify: `server.py` (SECTION 17 → `(7 tools)`), `discovery/aliases.py`, `README.md` (`164 tools`), inventory
|
||
- Test: `tests/test_pid_deliverables.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `build_graph` (Task 14), `validate_path` (`security.py`).
|
||
- Produces: `instrument_index(graph) -> list[dict]`, `line_list(graph) -> list[dict]`, `equipment_list(graph) -> list[dict]`, `natural_key(tag)`, `write_csv(rows, path) -> str`, `async deliverable(backend, which, csv_path=None, **graph_kwargs) -> dict` (`rows, count, csv_path, graph_stats`). Row keys exactly as spec §9.5.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```python
|
||
# tests/test_pid_deliverables.py
|
||
"""Instrument index, line list and equipment list are derived from the graph."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import csv
|
||
|
||
import pytest
|
||
|
||
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",
|
||
]
|
||
|
||
|
||
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",
|
||
)
|
||
return await build_graph(backend)
|
||
|
||
|
||
async def test_instrument_index_rows(backend):
|
||
graph = await _pid(backend)
|
||
rows = instrument_index(graph)
|
||
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["connected_to"] == "FCV-101" and fic["signal_lines"] == 1
|
||
assert rows[1]["signal_lines"] == 0 and rows[1]["connected_to"] == ""
|
||
|
||
|
||
async def test_line_list_rows(backend):
|
||
graph = await _pid(backend)
|
||
rows = line_list(graph)
|
||
assert [list(r) for r in rows] == [LINE_KEYS] * 3
|
||
numbered = [r for r in rows if r["line_number"]]
|
||
assert [r["line_number"] for r in numbered] == ["100-P-1-CS1", "100-P-2-CS1"]
|
||
assert numbered[0]["from_tag"] == "P-101" and numbered[0]["from_port"] == "discharge"
|
||
assert numbered[0]["to_tag"] == "FCV-101" and numbered[0]["length_mm"] > 0
|
||
assert rows[-1]["line_number"] is None, "untagged rows sort last"
|
||
|
||
|
||
async def test_equipment_list_rows(backend):
|
||
graph = await _pid(backend)
|
||
rows = equipment_list(graph)
|
||
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["unconnected_ports"] == "suction"
|
||
|
||
|
||
def test_natural_sort():
|
||
assert sorted(["P-10", "P-2", "P-1A"], key=natural_key) == ["P-1A", "P-2", "P-10"]
|
||
assert natural_key(None) > natural_key("Z-999"), "untagged rows sort last"
|
||
|
||
|
||
async def test_csv_is_written_inside_allowed_paths(backend, tmp_path, monkeypatch):
|
||
import config
|
||
|
||
monkeypatch.setattr(config.settings, "allowed_paths", [tmp_path.resolve()])
|
||
await _pid(backend)
|
||
target = tmp_path / "index.csv"
|
||
result = await deliverable(backend, "instrument_index", csv_path=str(target))
|
||
assert result["count"] == 2 and result["csv_path"] == str(target.resolve())
|
||
with target.open(newline="", encoding="utf-8") as fh:
|
||
reader = list(csv.reader(fh))
|
||
assert reader[0] == INDEX_KEYS and reader[1][0] == "FIC-101"
|
||
with pytest.raises(Exception):
|
||
await deliverable(backend, "line_list", csv_path=str(tmp_path.parent / "outside.csv"))
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify they fail** — ModuleNotFoundError.
|
||
|
||
- [ ] **Step 3: Write the module**
|
||
|
||
```python
|
||
# engineering/pid/deliverables.py
|
||
"""Instrument index, line list and equipment list — rows derived from the graph."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import csv
|
||
import re
|
||
from pathlib import Path
|
||
from typing import TYPE_CHECKING
|
||
|
||
from .graph import build_graph
|
||
|
||
if TYPE_CHECKING:
|
||
from backends.base import AutoCADBackend
|
||
|
||
_NUM = re.compile(r"(\d+)")
|
||
|
||
|
||
def natural_key(tag):
|
||
if tag is None:
|
||
return (1, [])
|
||
return (0, [int(part) if part.isdigit() else part for part in _NUM.split(str(tag))])
|
||
|
||
|
||
def _node_map(graph: dict) -> dict[str, dict]:
|
||
return {n["id"]: n for n in graph["nodes"]}
|
||
|
||
|
||
def _tag_of(ref: dict | None, nodes: dict) -> tuple[str | None, str | None]:
|
||
if not ref:
|
||
return None, None
|
||
if "node" in ref:
|
||
return nodes[ref["node"]].get("tag"), ref.get("port")
|
||
return ref.get("junction"), None
|
||
|
||
|
||
def instrument_index(graph: dict) -> list[dict]:
|
||
nodes = _node_map(graph)
|
||
edges = {e["id"]: e for e in graph["edges"]}
|
||
rows = []
|
||
for node in graph["nodes"]:
|
||
if node["kind"] != "instrument":
|
||
continue
|
||
parsed = node.get("tag_parsed") or {}
|
||
connected = []
|
||
signal_lines = 0
|
||
for port in node["ports"].values():
|
||
for edge_id in port["edges"]:
|
||
edge = edges[edge_id]
|
||
signal_lines += 1
|
||
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"])
|
||
)
|
||
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.sort(key=lambda r: natural_key(r["tag"]))
|
||
return rows
|
||
|
||
|
||
def _bubble_part(node: dict, index: int) -> str | None:
|
||
"""PID_INST_<TYPE>_<LOCATION...> → type (index 2) or location (index 3+)."""
|
||
name = node.get("block_name") or ""
|
||
if not name.startswith("PID_INST_"):
|
||
return None
|
||
parts = name.split("_")
|
||
if index == 2:
|
||
return parts[2].lower() if len(parts) > 2 else None
|
||
return "_".join(parts[3:]).lower() if len(parts) > 3 else None
|
||
|
||
|
||
def line_list(graph: dict) -> list[dict]:
|
||
nodes = _node_map(graph)
|
||
rows = []
|
||
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.sort(key=lambda r: natural_key(r["line_number"]))
|
||
return rows
|
||
|
||
|
||
def equipment_list(graph: dict) -> list[dict]:
|
||
rows = []
|
||
for node in graph["nodes"]:
|
||
if node["kind"] not in ("equipment", "valve", "connector", "unknown_block"):
|
||
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.sort(key=lambda r: natural_key(r["tag"]))
|
||
return rows
|
||
|
||
|
||
def write_csv(rows: list[dict], path: str) -> str:
|
||
from security import validate_path
|
||
|
||
target = validate_path(path, allow_write=True)
|
||
Path(target).parent.mkdir(parents=True, exist_ok=True)
|
||
with open(target, "w", newline="", encoding="utf-8") as fh:
|
||
writer = csv.writer(fh)
|
||
if rows:
|
||
writer.writerow(list(rows[0]))
|
||
for row in rows:
|
||
writer.writerow(["" if v is None else v for v in row.values()])
|
||
return str(target)
|
||
|
||
|
||
_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:
|
||
graph = await build_graph(backend, **graph_kwargs)
|
||
rows = _BUILDERS[which](graph)
|
||
written = write_csv(rows, csv_path) if csv_path else None
|
||
return {"rows": rows, "count": len(rows), "csv_path": written, "graph_stats": graph["stats"]}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the tests** — `uv run --frozen pytest tests/test_pid_deliverables.py tests/test_pid_graph.py -v` → PASS.
|
||
|
||
- [ ] **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"}
|
||
)
|
||
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,
|
||
scope: Annotated[str, "current_space | all"] = "current_space",
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Every instrument bubble with its ISA-5.1 reading and what it connects to.
|
||
|
||
Rows come from `pid_graph`, never from typed-in lists; the drawing is not
|
||
modified. `csv_path` is the only file this tool writes.
|
||
"""
|
||
from engineering.pid.deliverables import deliverable
|
||
|
||
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",
|
||
)
|
||
@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,
|
||
tolerance: Annotated[float, Field(default=0.5, gt=0)] = 0.5,
|
||
scope: Annotated[str, "current_space | all"] = "current_space",
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Every P&ID line with its number, class, size/service/spec/insulation and endpoints."""
|
||
from engineering.pid.deliverables import deliverable
|
||
|
||
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"}
|
||
)
|
||
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,
|
||
scope: Annotated[str, "current_space | all"] = "current_space",
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""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
|
||
)
|
||
```
|
||
|
||
Aliases:
|
||
|
||
```python
|
||
"pid_instrument_index": ToolAliases(acad=(), synonyms=("instrument index", "instrument list", "loop list", "tag list")),
|
||
"pid_line_list": ToolAliases(acad=(), synonyms=("line list", "pipe list", "line schedule", "line numbers")),
|
||
"pid_equipment_list": ToolAliases(acad=(), synonyms=("equipment list", "equipment schedule", "valve list")),
|
||
```
|
||
|
||
Inventory; README `164 tools`.
|
||
|
||
- [ ] **Step 6: Gates and commit**
|
||
|
||
```bash
|
||
git add engineering/pid/deliverables.py server.py discovery/aliases.py docs/tool-inventory.json README.md tests/test_pid_deliverables.py
|
||
git commit -m "feat(pid): instrument index, line list and equipment list from the graph"
|
||
```
|
||
|
||
---
|
||
### Task 16: Critique focuses, refine hints, plan/preflight rule
|
||
|
||
**Files:**
|
||
- Modify: `engineering/plan_spec.py` (`CritiqueFocus`, `ALL_CRITIQUE_FOCUSES`)
|
||
- Create: `engineering/pid/critique.py`
|
||
- Modify: `engineering/critique.py` (`_FOCUS_DISPATCH` registration + shared context in `run_critique`)
|
||
- Modify: `engineering/preflight.py` (`LAYER_SET_INTENT_MISMATCH`), `server.py` (`drawing_plan` warnings; `drawing_critique`/`drawing_finalize` docstrings list the new focuses)
|
||
- Test: `tests/test_pid_critique.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `build_graph` (Task 14), `LINE_CLASSES` (Task 12), `parse_tag` (Task 6), `Issue`.
|
||
- Produces: `PID_FOCUSES = ("pid_dangling_line", "pid_duplicate_tag", "pid_incompatible_connection", "pid_untagged_instrument", "pid_illegal_tag", "pid_unconnected_equipment")`; pure checks `issues_for(focus, graph) -> list[Issue]`; async dispatch entries `check(backend, shared)` with `check.needs_shared = True`; `engineering/preflight.py::PID_INTENT_RE`, `intent_reads_as_pid(intent) -> bool`, `pid_plan_warnings(intent, layer_set_id) -> list[str]`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```python
|
||
# tests/test_pid_critique.py
|
||
"""Six P&ID focuses; all silent on a drawing with no P&ID content."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
from engineering.pid.drawlines import draw_line
|
||
from engineering.pid.insert import place_symbol
|
||
from engineering.plan_spec import ALL_CRITIQUE_FOCUSES
|
||
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",
|
||
)
|
||
|
||
|
||
def test_focuses_are_in_the_closed_enum():
|
||
for focus in PID_FOCUSES:
|
||
assert focus in ALL_CRITIQUE_FOCUSES
|
||
|
||
|
||
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"},
|
||
)
|
||
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",
|
||
)
|
||
return pump, cv, fic
|
||
|
||
|
||
async def test_clean_pid_has_no_pid_issues_except_the_free_end(backend):
|
||
await _clean(backend)
|
||
issues = await backend.drawing_critique(list(PID_FOCUSES))
|
||
focuses = [i.focus for i in issues]
|
||
assert focuses == ["pid_dangling_line"], "the line to a free point is the only finding"
|
||
assert issues[0].severity == "error" and "hint" in issues[0].detail
|
||
|
||
|
||
async def test_dangling_carries_the_nearest_port_hint(backend):
|
||
pump, cv, _ = await _clean(backend)
|
||
await backend.entity_move(cv["handle"], 0.0, 1.0)
|
||
issues = await backend.drawing_critique(["pid_dangling_line"])
|
||
hinted = [i for i in issues if i.detail.get("nearest")]
|
||
assert hinted and hinted[0].detail["nearest"]["node"] == cv["handle"]
|
||
assert "pid_line_draw" in hinted[0].detail["hint"]
|
||
|
||
|
||
async def test_duplicate_and_illegal_and_untagged(backend):
|
||
await place_symbol(backend, "instrument", 0, 0, tag="FIC-101")
|
||
await place_symbol(backend, "instrument", 30, 0, tag="FIC-101")
|
||
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"]
|
||
)
|
||
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 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_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",
|
||
)
|
||
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"],
|
||
}
|
||
|
||
|
||
async def test_unconnected_equipment_is_informational(backend):
|
||
await place_symbol(backend, "vertical_vessel", 0, 0, tag="V-1")
|
||
issues = await backend.drawing_critique(["pid_unconnected_equipment"])
|
||
assert len(issues) == 1 and issues[0].severity == "info"
|
||
|
||
|
||
async def test_mechanical_drawing_is_untouched_by_pid_focuses(backend):
|
||
from engineering.layers import ensure_engineering_layers
|
||
|
||
await ensure_engineering_layers(backend)
|
||
await backend.entity_create_circle(0, 0, 20, layer="GEOMETRY")
|
||
await backend.entity_create_line(-30, 0, 30, 0, layer="CENTER")
|
||
assert await backend.drawing_critique(list(PID_FOCUSES)) == []
|
||
|
||
|
||
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
|
||
)
|
||
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",
|
||
)
|
||
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",
|
||
)
|
||
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")
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify they fail** — `uv run --frozen pytest tests/test_pid_critique.py -v` → the enum test fails first.
|
||
|
||
- [ ] **Step 3: Extend the enum**
|
||
|
||
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",)
|
||
```
|
||
|
||
- [ ] **Step 4: Write the checks**
|
||
|
||
```python
|
||
# engineering/pid/critique.py
|
||
"""Six P&ID focuses over one graph per critique run.
|
||
|
||
Every check is pure over the graph dict; ``run_critique`` builds the graph once
|
||
into the per-run ``shared`` context so six focuses cost one read. A drawing
|
||
with no P&ID nodes yields no issues — mechanical sheets are never scored here.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import TYPE_CHECKING
|
||
|
||
from engineering.plan_spec import Issue
|
||
|
||
from .graph import build_graph
|
||
from .lines import LINE_CLASSES
|
||
from .tags import parse_tag
|
||
|
||
if TYPE_CHECKING:
|
||
from backends.base import AutoCADBackend
|
||
|
||
PID_FOCUSES = (
|
||
"pid_dangling_line",
|
||
"pid_duplicate_tag",
|
||
"pid_incompatible_connection",
|
||
"pid_untagged_instrument",
|
||
"pid_illegal_tag",
|
||
"pid_unconnected_equipment",
|
||
)
|
||
_KEY = "pid_graph"
|
||
|
||
|
||
def _dangling(graph: dict) -> list[Issue]:
|
||
out = []
|
||
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"
|
||
)
|
||
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},
|
||
)
|
||
)
|
||
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")
|
||
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
|
||
]
|
||
|
||
|
||
def _incompatible(graph: dict) -> list[Issue]:
|
||
nodes = {n["id"]: n for n in graph["nodes"]}
|
||
out = []
|
||
for edge in graph["edges"]:
|
||
cls = LINE_CLASSES.get(edge.get("line_class") or "")
|
||
if cls is None:
|
||
continue
|
||
for ref in (edge["from"], edge["to"]):
|
||
if not ref or "node" not in ref:
|
||
continue
|
||
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",
|
||
},
|
||
)
|
||
)
|
||
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")
|
||
]
|
||
|
||
|
||
def _illegal(graph: dict) -> list[Issue]:
|
||
out = []
|
||
for n in graph["nodes"]:
|
||
if not n.get("tag") or n["kind"] == "connector":
|
||
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: {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"},
|
||
)
|
||
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())
|
||
]
|
||
|
||
|
||
_CHECKS = {
|
||
"pid_dangling_line": _dangling,
|
||
"pid_duplicate_tag": _duplicate_tags,
|
||
"pid_incompatible_connection": _incompatible,
|
||
"pid_untagged_instrument": _untagged,
|
||
"pid_illegal_tag": _illegal,
|
||
"pid_unconnected_equipment": _unconnected,
|
||
}
|
||
|
||
|
||
def issues_for(focus: str, graph: dict) -> list[Issue]:
|
||
if not graph["nodes"]:
|
||
return []
|
||
return _CHECKS[focus](graph)
|
||
|
||
|
||
def _make(focus: str):
|
||
async def check(backend: AutoCADBackend, shared: dict) -> list[Issue]:
|
||
graph = shared.get(_KEY)
|
||
if graph is None:
|
||
graph = shared[_KEY] = await build_graph(backend, include_foreign=True)
|
||
return issues_for(focus, graph)
|
||
|
||
check.needs_shared = True # type: ignore[attr-defined]
|
||
check.__name__ = f"check_{focus}"
|
||
return check
|
||
|
||
|
||
PID_DISPATCH = {focus: _make(focus) for focus in PID_FOCUSES}
|
||
```
|
||
|
||
In `engineering/critique.py`: after `_FOCUS_DISPATCH = {...}` add
|
||
|
||
```python
|
||
from .pid.critique import PID_DISPATCH # noqa: E402 (registered after the mechanical focuses)
|
||
|
||
_FOCUS_DISPATCH.update(PID_DISPATCH)
|
||
```
|
||
|
||
and change the dispatch loop in `run_critique` to
|
||
|
||
```python
|
||
shared: dict = {}
|
||
for f in foci:
|
||
check = _FOCUS_DISPATCH.get(f)
|
||
if check is None:
|
||
... # unchanged
|
||
continue
|
||
if getattr(check, "needs_shared", False):
|
||
issues.extend(await check(backend, shared))
|
||
else:
|
||
issues.extend(await check(backend))
|
||
return issues
|
||
```
|
||
|
||
(`engineering/pid/critique.py` imports `engineering.plan_spec`, not `engineering.critique`, so there is no cycle.)
|
||
|
||
- [ ] **Step 5: Preflight conflict and plan warning**
|
||
|
||
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
|
||
)
|
||
|
||
|
||
def intent_reads_as_pid(intent: str) -> bool:
|
||
return bool(PID_INTENT_RE.search(str(intent or "")))
|
||
|
||
|
||
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 []
|
||
```
|
||
|
||
(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
|
||
)
|
||
)
|
||
```
|
||
|
||
In `server.py::drawing_plan`, replace `return _dc(plan)` with
|
||
|
||
```python
|
||
from engineering.preflight import pid_plan_warnings
|
||
|
||
out = _dc(plan)
|
||
warnings = pid_plan_warnings(intent, layer_set_id)
|
||
if warnings:
|
||
out["warnings"] = warnings
|
||
return out
|
||
```
|
||
|
||
Update the `drawing_critique` `focus` description and the `drawing_finalize` docstring to list the six `pid_*` focuses (both currently enumerate the mechanical ones).
|
||
|
||
- [ ] **Step 6: Run the tests** — `uv run --frozen pytest tests/test_pid_critique.py tests/test_meta_tools.py tests/test_refiner.py tests/test_preflight.py -v` → PASS.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add engineering/plan_spec.py engineering/pid/critique.py engineering/critique.py engineering/preflight.py server.py tests/test_pid_critique.py
|
||
git commit -m "feat(pid): six P&ID critique focuses join drawing_finalize; plan/preflight flag a P&ID intent on the wrong layer set"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 17: `pid_from_spec` + README showcase render
|
||
|
||
**Files:**
|
||
- Create: `engineering/pid/spec.py`, `scripts/render_readme_pid.py`
|
||
- Modify: `server.py` (SECTION 17 → `(8 tools)`), `discovery/aliases.py`, `README.md` (`165 tools`), inventory
|
||
- Test: `tests/test_pid_from_spec.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `place_symbol`, `draw_line`, `build_graph`, `issues_for`/`PID_FOCUSES`, `resolve`, `transform_port`, `route`, `count_crossings`, backend `transaction_begin/commit/rollback`, `drawing_plan`, `get_plan_spec`.
|
||
- Produces: `EXAMPLE_SPEC: dict` (the spec §10 example), `validate_spec(spec) -> dict`, `async run_spec(backend, spec, dry_run=False) -> dict`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```python
|
||
# tests/test_pid_from_spec.py
|
||
"""pid_from_spec: one call, one transaction, graph + critique back."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
|
||
import pytest
|
||
import pytest_asyncio
|
||
from fastmcp import Client
|
||
|
||
import server
|
||
from engineering.pid.spec import EXAMPLE_SPEC, run_spec, validate_spec
|
||
|
||
pytestmark = pytest.mark.asyncio
|
||
|
||
|
||
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 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"
|
||
|
||
|
||
async def test_bad_reference_rolls_back(backend):
|
||
before = await backend.entity_count()
|
||
bad = copy.deepcopy(EXAMPLE_SPEC)
|
||
bad["lines"].append({"from": "P-101.discharge", "to": "V-201.NOPE", "class": "process_major"})
|
||
with pytest.raises(ValueError, match=r"lines\[3\]"):
|
||
await run_spec(backend, bad)
|
||
assert await backend.entity_count() == before
|
||
|
||
|
||
async def test_dry_run_routes_in_memory_and_writes_nothing(backend):
|
||
before = await backend.entity_count()
|
||
result = await run_spec(backend, EXAMPLE_SPEC, dry_run=True)
|
||
assert result["dry_run"] is True and len(result["lines"]) == 4
|
||
assert all(len(line["vertices"]) >= 2 for line in result["lines"])
|
||
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]"),
|
||
],
|
||
)
|
||
def test_validation_names_the_path(mutate, fragment):
|
||
spec = copy.deepcopy(EXAMPLE_SPEC)
|
||
mutate(spec)
|
||
with pytest.raises(ValueError) as exc:
|
||
validate_spec(spec)
|
||
assert fragment in str(exc.value)
|
||
|
||
|
||
@pytest_asyncio.fixture
|
||
async def client(monkeypatch):
|
||
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
|
||
async with Client(server.mcp) as connected:
|
||
await connected.call_tool("drawing_new", {})
|
||
yield connected
|
||
|
||
|
||
async def test_finalize_scores_the_example_at_ninety_or_better(client):
|
||
result = (await client.call_tool("pid_from_spec", {"spec": EXAMPLE_SPEC})).structured_content
|
||
assert result["graph"]["stats"]["dangling"] == 0
|
||
final = (await client.call_tool("drawing_finalize", {})).structured_content
|
||
assert final["score"] >= 90, final
|
||
assert not [i for i in final.get("critique", []) if str(i.get("focus", "")).startswith("pid_")]
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify they fail** — ModuleNotFoundError.
|
||
|
||
- [ ] **Step 3: Write the module**
|
||
|
||
```python
|
||
# engineering/pid/spec.py
|
||
"""``pid_from_spec``: a whole P&ID from one declarative document, in one transaction."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
from typing import TYPE_CHECKING
|
||
|
||
from .critique import PID_FOCUSES, issues_for
|
||
from .drawlines import draw_line
|
||
from .graph import build_graph
|
||
from .insert import place_symbol
|
||
from .lines import LINE_CLASSES, count_crossings, route, snap_axis, AXIS
|
||
from .symbols import resolve, transform_port
|
||
|
||
if TYPE_CHECKING:
|
||
from backends.base import AutoCADBackend
|
||
|
||
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},
|
||
],
|
||
},
|
||
},
|
||
],
|
||
"valves": [
|
||
{
|
||
"id": "FCV-101",
|
||
"symbol": "globe",
|
||
"actuator": "diaphragm",
|
||
"x": 160,
|
||
"y": 104,
|
||
"tag": "FCV-101",
|
||
"fail": "FC",
|
||
},
|
||
],
|
||
"instruments": [
|
||
{
|
||
"id": "FIC-101",
|
||
"type": "dcs",
|
||
"location": "primary",
|
||
"x": 160,
|
||
"y": 140,
|
||
"tag": "FIC-101",
|
||
},
|
||
],
|
||
"lines": [
|
||
{
|
||
"from": "P-101.discharge",
|
||
"to": "FCV-101.in",
|
||
"class": "process_major",
|
||
"number": "100-P-1001-CS1",
|
||
},
|
||
{
|
||
"from": "FCV-101.out",
|
||
"to": "V-201.N2",
|
||
"class": "process_major",
|
||
"number": "100-P-1001-CS1",
|
||
},
|
||
{"from": "FIC-101", "to": "FCV-101.signal", "class": "electric"},
|
||
],
|
||
"connectors": [
|
||
{
|
||
"id": "OP-1",
|
||
"direction": "out",
|
||
"x": 300,
|
||
"y": 120,
|
||
"tag": "TO P&ID-002",
|
||
"link": "L-17",
|
||
"from": "V-201.N1",
|
||
"class": "process_major",
|
||
},
|
||
],
|
||
}
|
||
|
||
_TOP_KEYS = {"sheet", "equipment", "valves", "instruments", "lines", "connectors"}
|
||
_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",
|
||
},
|
||
}
|
||
_LINE_KEYS = {
|
||
"from",
|
||
"to",
|
||
"class",
|
||
"number",
|
||
"size",
|
||
"service",
|
||
"spec",
|
||
"insulation",
|
||
"route",
|
||
"stub",
|
||
"label",
|
||
"arrow",
|
||
}
|
||
|
||
|
||
def _num(value, where):
|
||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||
raise ValueError(f"{where} must be a number")
|
||
return float(value)
|
||
|
||
|
||
def _ref(value, where, ids: set[str]) -> tuple[str, str | None]:
|
||
if not isinstance(value, str) or not value:
|
||
raise ValueError(f"{where} must be 'ID' or 'ID.port'")
|
||
item_id, _, port = value.partition(".")
|
||
if item_id not in ids:
|
||
raise ValueError(f"{where}: unknown id {item_id!r}")
|
||
return item_id, port or None
|
||
|
||
|
||
def validate_spec(spec: dict) -> dict:
|
||
if not isinstance(spec, dict):
|
||
raise ValueError("spec must be an object")
|
||
unknown = set(spec) - _TOP_KEYS
|
||
if unknown:
|
||
raise ValueError(f"unknown top-level key(s): {', '.join(sorted(unknown))}")
|
||
out = {key: copy.deepcopy(spec.get(key) or ([] if key != "sheet" else {})) for key in _TOP_KEYS}
|
||
if not isinstance(out["sheet"], dict):
|
||
raise ValueError("sheet must be an object")
|
||
ids: set[str] = set()
|
||
for section in ("equipment", "valves", "instruments", "connectors"):
|
||
items = out[section]
|
||
if not isinstance(items, list):
|
||
raise ValueError(f"{section} must be a list")
|
||
for i, item in enumerate(items):
|
||
where = f"{section}[{i}]"
|
||
if not isinstance(item, dict):
|
||
raise ValueError(f"{where} must be an object")
|
||
item_id = item.get("id")
|
||
if not isinstance(item_id, str) or not item_id:
|
||
raise ValueError(f"{where}.id must be a non-empty string")
|
||
if item_id in ids:
|
||
raise ValueError(f"{where}.id {item_id!r} is repeated")
|
||
ids.add(item_id)
|
||
bad = set(item) - _ITEM_OPTIONS[section] - {"id"}
|
||
if bad:
|
||
raise ValueError(f"{where}: unknown key(s) {', '.join(sorted(bad))}")
|
||
for key in ("x", "y"):
|
||
if key not in item:
|
||
raise ValueError(f"{where}.{key} is required")
|
||
item[key] = _num(item[key], f"{where}.{key}")
|
||
if section in ("equipment", "valves") and not isinstance(item.get("symbol"), str):
|
||
raise ValueError(f"{where}.symbol is required")
|
||
if section == "connectors":
|
||
if item.get("direction") not in ("in", "out"):
|
||
raise ValueError(f"{where}.direction must be 'in' or 'out'")
|
||
has_from, has_to = "from" in item, "to" in item
|
||
if item["direction"] == "out" and (not has_from or has_to):
|
||
raise ValueError(f"{where}: an 'out' connector carries exactly 'from'")
|
||
if item["direction"] == "in" and (not has_to or has_from):
|
||
raise ValueError(f"{where}: an 'in' connector carries exactly 'to'")
|
||
lines = out["lines"]
|
||
if not isinstance(lines, list):
|
||
raise ValueError("lines must be a list")
|
||
for i, line in enumerate(lines):
|
||
where = f"lines[{i}]"
|
||
if not isinstance(line, dict):
|
||
raise ValueError(f"{where} must be an object")
|
||
bad = set(line) - _LINE_KEYS
|
||
if bad:
|
||
raise ValueError(f"{where}: unknown key(s) {', '.join(sorted(bad))}")
|
||
for key in ("from", "to"):
|
||
if key not in line:
|
||
raise ValueError(f"{where}.{key} is required")
|
||
_ref(line[key], f"{where}.{key}", ids)
|
||
cls = line.get("class", "process_major")
|
||
if cls not in LINE_CLASSES:
|
||
raise ValueError(f"{where}.class must be one of {', '.join(LINE_CLASSES)}")
|
||
line["class"] = cls
|
||
for i, conn in enumerate(out["connectors"]):
|
||
key = "from" if conn["direction"] == "out" else "to"
|
||
_ref(conn[key], f"connectors[{i}].{key}", ids)
|
||
cls = conn.get("class", "process_major")
|
||
if cls not in LINE_CLASSES:
|
||
raise ValueError(f"connectors[{i}].class must be one of {', '.join(LINE_CLASSES)}")
|
||
conn["class"] = cls
|
||
return out
|
||
|
||
|
||
def _spec_for(section: str, item: dict):
|
||
"""Resolve the SymbolSpec an item would place (no backend)."""
|
||
if section == "instruments":
|
||
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"),
|
||
)
|
||
|
||
|
||
def _plan_lines(norm: dict) -> list[dict]:
|
||
"""Route every line in memory from catalogue ports — the dry run."""
|
||
placed: dict[str, 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
|
||
}
|
||
placed[item["id"]] = {"family": spec.family, "ports": ports}
|
||
|
||
def endpoint(ref: str) -> dict:
|
||
item_id, port = ref.partition(".")[::2]
|
||
entry = placed[item_id]
|
||
if not port:
|
||
if len(entry["ports"]) == 1:
|
||
port = next(iter(entry["ports"]))
|
||
elif entry["family"] == "instrument":
|
||
port = "signal"
|
||
else:
|
||
raise ValueError(f"{ref}: choose a port: {', '.join(entry['ports'])}")
|
||
if port not in entry["ports"]:
|
||
raise ValueError(f"{ref}: no port {port!r}; ports: {', '.join(entry['ports'])}")
|
||
return entry["ports"][port]
|
||
|
||
def anchor(ep: dict, other: dict):
|
||
if ep["radius"] > 0:
|
||
axis = snap_axis(other["x"] - ep["x"], other["y"] - ep["y"])
|
||
ux, uy = AXIS[axis]
|
||
return (ep["x"] + ux * ep["radius"], ep["y"] + uy * ep["radius"]), axis
|
||
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"
|
||
else {"from": f"{c['id']}", "to": c["to"], "class": c["class"]}
|
||
for c in norm["connectors"]
|
||
]
|
||
planned: list[dict] = []
|
||
for i, line in enumerate(all_lines):
|
||
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")
|
||
)
|
||
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,
|
||
}
|
||
)
|
||
return planned
|
||
|
||
|
||
async def run_spec(backend: AutoCADBackend, spec: dict, dry_run: bool = False) -> dict:
|
||
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),
|
||
}
|
||
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"),
|
||
)
|
||
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}")
|
||
handles: dict[str, str] = {}
|
||
crossings_total = 0
|
||
try:
|
||
for section in ("equipment", "valves", "instruments", "connectors"):
|
||
for i, item in enumerate(norm[section]):
|
||
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"),
|
||
)
|
||
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"],
|
||
)
|
||
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"),
|
||
)
|
||
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]}
|
||
)
|
||
|
||
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"),
|
||
},
|
||
)
|
||
)
|
||
else:
|
||
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"),
|
||
)
|
||
except ValueError as exc:
|
||
raise ValueError(f"{where}: {exc}") from exc
|
||
crossings_total += drawn["crossings"]
|
||
except Exception:
|
||
await backend.transaction_rollback()
|
||
raise
|
||
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,
|
||
}
|
||
```
|
||
|
||
- [ ] **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",
|
||
)
|
||
@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",
|
||
],
|
||
dry_run: Annotated[bool, "Validate and route in memory; draw nothing"] = False,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""Equipment, valves, instruments and connectors placed, then every line
|
||
drawn port-to-port, inside one transaction: any bad item rolls the whole
|
||
sheet back and the error names it (`lines[2].to`). The response carries the
|
||
graph read back from the drawing and the P&ID critique issues, so the
|
||
caller sees dangling ends or duplicate tags in the same round trip.
|
||
`dry_run` returns the planned vertices and crossings without touching the
|
||
drawing.
|
||
"""
|
||
from engineering.pid.spec import run_spec
|
||
|
||
await ctx.info("P&ID from spec" + (" (dry run)" if dry_run else ""))
|
||
return await run_spec(_backend(ctx), spec, dry_run)
|
||
```
|
||
|
||
Alias:
|
||
|
||
```python
|
||
"pid_from_spec": ToolAliases(
|
||
acad=(),
|
||
synonyms=("whole p&id in one call", "p&id from json", "draw the process from a spec", "batch p&id"),
|
||
),
|
||
```
|
||
|
||
Inventory; README `165 tools`.
|
||
|
||
- [ ] **Step 5: README showcase render script**
|
||
|
||
```python
|
||
# 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
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import asyncio
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
DEFAULT_OUTPUT = ROOT / "docs" / "assets" / "pid-showcase.png"
|
||
|
||
|
||
async def render(output: Path) -> dict:
|
||
from backends.ezdxf_backend import EzdxfBackend
|
||
from engineering.layers import apply_layer_set
|
||
from engineering.pid.spec import EXAMPLE_SPEC, run_spec
|
||
|
||
backend = EzdxfBackend()
|
||
await backend.connect()
|
||
try:
|
||
await backend.drawing_new()
|
||
await apply_layer_set(backend, "pid")
|
||
result = await run_spec(backend, EXAMPLE_SPEC)
|
||
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"]),
|
||
}
|
||
finally:
|
||
await backend.disconnect()
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
|
||
args = parser.parse_args()
|
||
print(asyncio.run(render(args.output)))
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|
||
```
|
||
|
||
Run it once (`uv run --frozen python scripts/render_readme_pid.py`) and commit the PNG.
|
||
|
||
- [ ] **Step 6: Gates and commit**
|
||
|
||
Run: `uv run --frozen pytest tests/test_pid_from_spec.py tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py -v`
|
||
|
||
```bash
|
||
git add engineering/pid/spec.py scripts/render_readme_pid.py docs/assets/pid-showcase.png server.py discovery/aliases.py docs/tool-inventory.json README.md tests/test_pid_from_spec.py
|
||
git commit -m "feat(pid): pid_from_spec — a whole P&ID in one transaction, graph and critique returned"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 18: Tool packs (`TOOL_PACKS`)
|
||
|
||
**Files:**
|
||
- Modify: `config.py` (`tool_packs`), `server.py` (`TOOL_PACK_NAMES`, `PACK_TOOL_NAMES`, `_enabled_packs`, `_profile_enabled_names`, `_apply_tool_profile`, `system_about`, the `unknown_tool` rejection text, `LEAN_TOOL_NAMES` + 3), `.env.example`, `README.md` (Configuration table row)
|
||
- Test: `tests/test_tool_packs.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: `config.settings.tool_packs: str` (default `"all"`); `server.TOOL_PACK_NAMES = ("core", "pid")`; `server.PACK_TOOL_NAMES = {"pid": frozenset({...9 tools...})}`; `server._enabled_packs() -> tuple[set[str], list[str]]` (enabled, ignored); `_active_tool_profile["tool_packs"] = {"enabled": [...], "available": [...], "ignored": [...]}`; `system_about()["tool_packs"]`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```python
|
||
# tests/test_tool_packs.py
|
||
"""TOOL_PACKS: advertise only the vertical packs a client uses; core is always on."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
|
||
import pytest
|
||
import pytest_asyncio
|
||
|
||
import config
|
||
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",
|
||
}
|
||
|
||
|
||
@pytest_asyncio.fixture(autouse=True)
|
||
async def _restore(monkeypatch):
|
||
yield
|
||
monkeypatch.setattr(config.settings, "tool_packs", "all")
|
||
await server._apply_tool_profile("full")
|
||
|
||
|
||
def test_pack_registry_names_real_tools_and_only_pid_ones():
|
||
assert server.TOOL_PACK_NAMES == ("core", "pid")
|
||
assert server.PACK_TOOL_NAMES["pid"] == frozenset(PID_TOOLS)
|
||
|
||
|
||
async def test_core_only_hides_every_pid_tool_but_keeps_block_define(monkeypatch):
|
||
monkeypatch.setattr(config.settings, "tool_packs", "core")
|
||
info = await server._apply_tool_profile("full")
|
||
disabled = set(info["disabled_tools"])
|
||
assert PID_TOOLS <= disabled
|
||
assert "block_define" not in disabled and "entity_set_xdata" not in disabled
|
||
assert info["tool_packs"] == {"enabled": ["core"], "available": ["core", "pid"], "ignored": []}
|
||
|
||
|
||
async def test_all_is_the_default_and_enables_pid(monkeypatch):
|
||
monkeypatch.setattr(config.settings, "tool_packs", "all")
|
||
info = await server._apply_tool_profile("full")
|
||
assert not (PID_TOOLS & set(info["disabled_tools"]))
|
||
assert info["tool_packs"]["enabled"] == ["core", "pid"]
|
||
|
||
|
||
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 "mech3000" in caplog.text
|
||
|
||
|
||
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"]
|
||
)
|
||
|
||
|
||
async def test_system_about_reports_packs():
|
||
from fastmcp import Client
|
||
|
||
async with Client(server.mcp) as client:
|
||
about = (await client.call_tool("system_about", {})).structured_content
|
||
assert about["tool_packs"]["available"] == ["core", "pid"]
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify they fail** — `AttributeError: module 'server' has no attribute 'TOOL_PACK_NAMES'`.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
`config.py`, next to `tool_profile`:
|
||
|
||
```python
|
||
# Tool packs: "all" (default) advertises every vertical pack; a comma
|
||
# list ("core,pid") advertises only those. core cannot be disabled.
|
||
self.tool_packs: str = os.environ.get("TOOL_PACKS", "all").lower().strip()
|
||
```
|
||
|
||
`server.py`, after `SOLID_TOOL_NAMES`:
|
||
|
||
```python
|
||
# Tool packs: vertical domains a client can opt out of advertising. `core` is
|
||
# everything not claimed by another pack and is always on. ~300 tools would
|
||
# push the full catalog to ~80k idle tokens; a client that only drafts
|
||
# mechanically should not pay for the P&ID surface.
|
||
TOOL_PACK_NAMES = ("core", "pid")
|
||
PACK_TOOL_NAMES: dict[str, frozenset[str]] = {
|
||
"pid": frozenset(
|
||
{
|
||
"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",
|
||
}
|
||
),
|
||
}
|
||
|
||
|
||
def _enabled_packs() -> tuple[set[str], list[str]]:
|
||
raw = (config.settings.tool_packs or "all").lower()
|
||
requested = [p.strip() for p in raw.split(",") if p.strip()]
|
||
if not requested or "all" in requested:
|
||
return set(TOOL_PACK_NAMES), []
|
||
enabled = {"core"}
|
||
ignored: list[str] = []
|
||
for pack in requested:
|
||
if pack in TOOL_PACK_NAMES:
|
||
enabled.add(pack)
|
||
else:
|
||
ignored.append(pack)
|
||
log.warning(
|
||
"Unknown TOOL_PACKS entry %r ignored. Valid packs: %s.",
|
||
pack,
|
||
", ".join(TOOL_PACK_NAMES),
|
||
)
|
||
return enabled, ignored
|
||
```
|
||
|
||
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)
|
||
)
|
||
enabled = set(registered) - hidden_by_pack
|
||
if profile == "lean":
|
||
enabled &= LEAN_TOOL_NAMES
|
||
if not config.settings.enable_3d:
|
||
enabled -= SOLID_TOOL_NAMES
|
||
return enabled
|
||
```
|
||
|
||
In `_apply_tool_profile`, after building `_active_tool_profile`, add:
|
||
|
||
```python
|
||
packs, ignored = _enabled_packs()
|
||
_active_tool_profile["tool_packs"] = {
|
||
"enabled": sorted(packs),
|
||
"available": list(TOOL_PACK_NAMES),
|
||
"ignored": ignored,
|
||
}
|
||
```
|
||
|
||
In `system_about`, add `"tool_packs": (_active_tool_profile or {}).get("tool_packs") or {"enabled": sorted(_enabled_packs()[0]), "available": list(TOOL_PACK_NAMES), "ignored": _enabled_packs()[1]}`. Add the three pid tools to `LEAN_TOOL_NAMES`. Find the `unknown_tool` rejection text (`server.py` ~line 4059: "the active TOOL_PROFILE / ENABLE_3D gate") and make it "the active TOOL_PROFILE / TOOL_PACKS / ENABLE_3D gate".
|
||
|
||
`.env.example`, after `TOOL_PROFILE=full`:
|
||
|
||
```
|
||
# Tool packs: all (default) | comma list of core,pid — advertise only the
|
||
# vertical packs this client uses. core is always on.
|
||
TOOL_PACKS=all
|
||
```
|
||
|
||
README Configuration table: a row `| TOOL_PACKS | all | Vertical packs to advertise: core,pid (core always on) |` matching the table's existing column layout.
|
||
|
||
- [ ] **Step 4: Run** — `uv run --frozen pytest tests/test_tool_packs.py tests/test_tool_profiles.py tests/test_tool_registry.py -v` → PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add config.py server.py .env.example README.md tests/test_tool_packs.py
|
||
git commit -m "feat(server): TOOL_PACKS — advertise only the vertical packs a client uses"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 19: `pid_tag_parse` tool, prompt rewrite, two resources, catalogue contact sheet
|
||
|
||
**Files:**
|
||
- Modify: `server.py` (SECTION 17 → `(9 tools)`; `prompt_pid_diagram` rewritten; two `@mcp.resource`), `discovery/aliases.py`, `README.md` (`166 tools`, `8 resources`), inventory
|
||
- Create: `scripts/render_pid_catalog.py`, `docs/assets/pid-catalog.png`
|
||
- Test: `tests/test_pid_resources_prompt.py`
|
||
|
||
**Interfaces:**
|
||
- Produces: MCP tool `pid_tag_parse(tag, kind="auto", equipment_prefixes=None)`; resources `autocad://pid/symbols`, `autocad://standards/isa51`; `scripts/render_pid_catalog.py::render(output) -> dict`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```python
|
||
# tests/test_pid_resources_prompt.py
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
|
||
import pytest
|
||
import pytest_asyncio
|
||
from fastmcp import Client
|
||
|
||
import server
|
||
|
||
pytestmark = pytest.mark.asyncio
|
||
|
||
|
||
@pytest_asyncio.fixture
|
||
async def client(monkeypatch):
|
||
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
|
||
async with Client(server.mcp) as connected:
|
||
yield connected
|
||
|
||
|
||
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
|
||
assert out["equipment_kind"] == "quench"
|
||
|
||
|
||
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"]
|
||
)
|
||
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"}
|
||
)
|
||
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",
|
||
):
|
||
assert needle in text, needle
|
||
assert "circle with triangle" not in text, "no hand-drawn symbols"
|
||
|
||
|
||
def test_catalog_contact_sheet_renders_every_symbol(tmp_path):
|
||
pytest.importorskip("matplotlib")
|
||
from scripts.render_pid_catalog import render
|
||
|
||
result = render(tmp_path / "sheet.png")
|
||
assert result["symbols"] >= 100 and result["bytes"] > 10_000
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify they fail**.
|
||
|
||
- [ ] **Step 3: Tool, resources, prompt**
|
||
|
||
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",
|
||
)
|
||
@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,
|
||
ctx: Context = None,
|
||
) -> dict:
|
||
"""ISA-5.1-2009 Table 4.1 as a grammar: first letter, modifier, readout and
|
||
output functions, trailing high/low, loop number and suffix — with the
|
||
standard's description ("Flow Indicating Controller") or the first
|
||
offending letter. Equipment prefixes are not standardised, so the default
|
||
map (P pump, V vessel, E exchanger, ...) can be replaced per call.
|
||
"""
|
||
from engineering.pid.tags import parse_tag
|
||
|
||
return parse_tag(tag, kind, equipment_prefixes)
|
||
```
|
||
|
||
Alias: `"pid_tag_parse": ToolAliases(acad=(), synonyms=("what does FIC mean", "isa 5.1 tag", "instrument tag letters", "decode the tag", "loop number"))`.
|
||
|
||
Resources (in the RESOURCES block):
|
||
|
||
```python
|
||
@mcp.resource(
|
||
"autocad://pid/symbols",
|
||
name="P&ID Symbol Catalogue",
|
||
description="Every P&ID symbol pid_symbol_insert can place: families, variants, ports, parameters",
|
||
mime_type="application/json",
|
||
annotations={"readOnlyHint": True},
|
||
tags={"pid"},
|
||
)
|
||
async def resource_pid_symbols() -> str:
|
||
from engineering.pid.symbols import CATALOG_VERSION, list_symbols
|
||
|
||
return json.dumps({"catalog_version": CATALOG_VERSION, "symbols": list_symbols()}, indent=2)
|
||
|
||
|
||
@mcp.resource(
|
||
"autocad://standards/isa51",
|
||
name="ISA-5.1 Identification Letters",
|
||
description="ISA-5.1-2009 Table 4.1 letter tables, the tag grammar and its disambiguation rules",
|
||
mime_type="application/json",
|
||
annotations={"readOnlyHint": True},
|
||
tags={"pid", "standards"},
|
||
)
|
||
async def resource_isa51() -> str:
|
||
from engineering.pid.tags import describe_tables
|
||
|
||
return json.dumps(describe_tables(), indent=2)
|
||
```
|
||
|
||
Replace the body of `prompt_pid_diagram` with:
|
||
|
||
```python
|
||
return f"""You are creating a P&ID for '{project_name}' ({revision}) with the server's P&ID tools.
|
||
Never hand-draw a symbol from lines and circles: every valve, pump, vessel and instrument is a
|
||
catalogue block with ports, and every line is drawn port-to-port.
|
||
|
||
REFERENCE SIZES (symbol-local mm at A3/A1 paper scale): instrument bubble Ø10, valve body 8×4,
|
||
pump Ø8, grid 5. Keep equipment ≥ 40 mm apart so line numbers and tags have room.
|
||
|
||
WORKFLOW
|
||
1. drawing_plan(intent="{project_name} P&ID", sheet_size="A3", layer_set_id="pid")
|
||
2. drawing_apply_iso_layers("pid")
|
||
3. pid_symbol_list() — read the catalogue once; pid_tag_parse(tag) when unsure about ISA-5.1 letters
|
||
4. pid_symbol_insert(symbol, x, y, tag=...) for each item:
|
||
equipment tag P-101 / V-201 / E-301 (desc="Feed pump")
|
||
valves symbol=gate|globe|ball|... with actuator=diaphragm|piston|motor|solenoid|hand and fail=FO|FC|FL
|
||
instruments symbol="instrument", type=discrete|dcs|computer|plc, location=field|primary|auxiliary|..., tag="FIC-101"
|
||
vessels params={{"width", "height", "nozzles": [{{"name": "N1", "side": "top", "fraction": 0.5}}]}}
|
||
Each call returns the block's ports in WCS — use them, never guess coordinates.
|
||
5. pid_line_draw(from_={{"handle", "port"}}, to={{"handle", "port"}}, line_class=process_major|process_minor|
|
||
utility|pneumatic|electric|hydraulic|capillary|data, size="100", service="P", spec="CS1")
|
||
A bubble's radial port needs no name. Read `crossings` and `port_reuse` in the response.
|
||
6. pid_graph() — `dangling` must be empty and `stats.confidence_min` 1.0 for a drawing you made.
|
||
7. drawing_critique(focus=None) — must return [] (pid_dangling_line, pid_duplicate_tag,
|
||
pid_incompatible_connection, pid_untagged_instrument, pid_illegal_tag, pid_unconnected_equipment
|
||
are part of it).
|
||
8. pid_instrument_index() / pid_line_list() / pid_equipment_list() for the deliverables.
|
||
9. drawing_finalize(save_path=...) — the score is the objective quality metric.
|
||
|
||
ONE-CALL ALTERNATIVE: pid_from_spec(spec) draws the whole sheet in one transaction from
|
||
{{sheet, equipment[], valves[], instruments[], lines[], connectors[]}} and returns the graph and the
|
||
critique; use dry_run=true first to see the planned routes and crossings.
|
||
|
||
OFF-PAGE: pid_symbol_insert("offpage", direction="out"|"in", tag="TO P&ID-002", link="L-17"); two connectors
|
||
sharing a link are paired by pid_graph.
|
||
"""
|
||
```
|
||
|
||
Regenerate the inventory (now 8 resources); README snapshot `166 tools · 8 resources`.
|
||
|
||
- [ ] **Step 4: Contact-sheet script**
|
||
|
||
```python
|
||
# 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
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import asyncio
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
DEFAULT_OUTPUT = ROOT / "docs" / "assets" / "pid-catalog.png"
|
||
CELL = 80.0 # the tallest vessel spans 66 mm with its nozzle stubs
|
||
COLUMNS = 10
|
||
|
||
|
||
async def _render(output: Path) -> dict:
|
||
from backends.ezdxf_backend import EzdxfBackend
|
||
from engineering.layers import apply_layer_set
|
||
from engineering.pid.insert import insert_symbol
|
||
from engineering.pid.symbols import all_specs
|
||
|
||
backend = EzdxfBackend()
|
||
await backend.connect()
|
||
try:
|
||
await backend.drawing_new()
|
||
await apply_layer_set(backend, "pid")
|
||
specs = all_specs()
|
||
for index, spec in enumerate(specs):
|
||
col, row = index % COLUMNS, index // COLUMNS
|
||
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",
|
||
)
|
||
png = await backend.view_screenshot()
|
||
output.parent.mkdir(parents=True, exist_ok=True)
|
||
output.write_bytes(png)
|
||
return {"output": str(output), "symbols": len(specs), "bytes": len(png)}
|
||
finally:
|
||
await backend.disconnect()
|
||
|
||
|
||
def render(output: Path) -> dict:
|
||
return asyncio.run(_render(output))
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
|
||
print(render(parser.parse_args().output))
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|
||
```
|
||
|
||
Run it and commit the PNG. (`scripts/` has no `__init__.py`; the test imports `scripts.render_pid_catalog` — add an empty `scripts/__init__.py` if the import fails, and keep it out of the wheel: check `pyproject.toml`'s hatch `include` list does not pick it up.)
|
||
|
||
- [ ] **Step 5: Run** — `uv run --frozen pytest tests/test_pid_resources_prompt.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_discovery_aliases.py -v`.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add server.py discovery/aliases.py docs/tool-inventory.json README.md scripts/render_pid_catalog.py docs/assets/pid-catalog.png tests/test_pid_resources_prompt.py
|
||
git commit -m "feat(pid): pid_tag_parse, catalogue and ISA-5.1 resources, prompt rewritten onto the tools"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 20: Benchmark matrix v4 (`pid_roundtrip`) + three correctness checks
|
||
|
||
**Files:**
|
||
- Create: `benchmarks/tasks_v4.py`, `tests/test_benchmark_v4.py`
|
||
- Modify: `benchmarks/adapters/autocad_mcp_pro.py` (`_task_pid_roundtrip`), `benchmarks/run_competitors.py` and `benchmarks/render_matrix_chart.py` (import from `tasks_v4`), `benchmarks/correctness_suite.py` (+3 checks), `scripts/check_doc_numbers.py` (task-count pattern → v4), `CLAUDE.md`/`README.md`/`benchmarks/README.md` numbers (26 → 29 checks; 15 → 16 tasks)
|
||
|
||
**Interfaces:**
|
||
- Produces: `benchmarks.tasks_v4.NEW_TASKS_V4`, `TASKS_V4 = TASKS_V3 + NEW_TASKS_V4`, `MATRICES = {"v2", "v3", "v4"}`, `DEFAULT_MATRIX = "v4"`, `task_by_id`; correctness checks `pid_block_define_attdef_roundtrip`, `pid_tag_parse_fic`, `pid_graph_edge_count` under group `"P&ID"`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```python
|
||
# tests/test_benchmark_v4.py
|
||
from __future__ import annotations
|
||
|
||
from benchmarks.tasks_v3 import TASKS_V3
|
||
from benchmarks.tasks_v4 import DEFAULT_MATRIX, MATRICES, NEW_TASKS_V4, TASKS_V4, task_by_id
|
||
|
||
|
||
def test_v4_extends_v3_without_touching_it():
|
||
assert TASKS_V4[: len(TASKS_V3)] == TASKS_V3
|
||
assert [t.task_id for t in NEW_TASKS_V4] == ["pid_roundtrip"]
|
||
assert MATRICES["v3"] == TASKS_V3 and MATRICES["v4"] == TASKS_V4 and DEFAULT_MATRIX == "v4"
|
||
assert task_by_id("pid_roundtrip").category == "pid"
|
||
|
||
|
||
def test_the_adapter_implements_every_v4_task():
|
||
from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter
|
||
|
||
for task in TASKS_V4:
|
||
assert hasattr(AutoCADMCPProAdapter, f"_task_{task.task_id}"), task.task_id
|
||
|
||
|
||
def test_correctness_suite_has_the_pid_checks():
|
||
from benchmarks.correctness_suite import CHECKS
|
||
|
||
for name in ("pid_block_define_attdef_roundtrip", "pid_tag_parse_fic", "pid_graph_edge_count"):
|
||
assert CHECKS[name][1] == "P&ID"
|
||
assert len(CHECKS) == 29
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails** — ModuleNotFoundError.
|
||
|
||
- [ ] **Step 3: Write the matrix, the adapter task, the checks**
|
||
|
||
```python
|
||
# benchmarks/tasks_v4.py
|
||
"""The v4 task matrix: v3 plus the P&ID round trip (v1.6, track A).
|
||
|
||
``pid_roundtrip`` draws a five-item P&ID through the same code the tools call
|
||
and reads it back: the edge count, the absence of dangling ends and the
|
||
instrument index are each derived independently of the drawing code. The
|
||
published competitor reports were never asked this question; the chart shows
|
||
``not_run`` for them, not zero.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
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,
|
||
),
|
||
)
|
||
TASKS_V4: tuple[TaskSpec, ...] = TASKS_V3 + NEW_TASKS_V4
|
||
MATRICES: dict[str, tuple[TaskSpec, ...]] = {"v2": TASKS_V2, "v3": TASKS_V3, "v4": TASKS_V4}
|
||
DEFAULT_MATRIX = "v4"
|
||
|
||
|
||
def task_by_id(task_id: str, matrix: str = DEFAULT_MATRIX) -> TaskSpec:
|
||
for task in MATRICES[matrix]:
|
||
if task.task_id == task_id:
|
||
return task
|
||
raise KeyError(f"{task_id} is not in matrix {matrix}")
|
||
```
|
||
|
||
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
|
||
|
||
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).
|
||
|
||
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}],
|
||
)
|
||
ref = await b.block_insert("PID_T", 5, 5, attributes={"TAG": "HV-1"})
|
||
return await b.block_get_attributes(ref.handle) == {"TAG": "HV-1"}
|
||
|
||
|
||
async def pid_tag_parse_fic():
|
||
from engineering.pid.tags import parse_tag
|
||
|
||
return parse_tag("FIC-101")["description"] == "Flow Indicating Controller"
|
||
|
||
|
||
async def pid_graph_edge_count():
|
||
from engineering.pid.spec import EXAMPLE_SPEC, run_spec
|
||
|
||
b = await _b()
|
||
result = await run_spec(b, EXAMPLE_SPEC)
|
||
return len(result["graph"]["edges"]) == 4 and result["graph"]["stats"]["dangling"] == 0
|
||
```
|
||
|
||
and the three `CHECKS` entries with group `"P&ID"`.
|
||
|
||
- [ ] **Step 4: Doc numbers**
|
||
|
||
`uv run --frozen python scripts/check_doc_numbers.py` lists every drift. Update: README "26 deterministic headless checks" → 29 and the A/B table (see Task 21 for the table re-run); `benchmarks/README.md` "26 checks, ezdxf backend" → 29; `CLAUDE.md` "`correctness_suite.py` (26 checks)" → 29 and "`benchmarks/tasks_v3.py` is the current set (15 tasks; `--matrix v2` reproduces the v1.4 ten)" → "`benchmarks/tasks_v4.py` is the current set (16 tasks; `--matrix v3` / `--matrix v2` reproduce the earlier sets)"; in `scripts/check_doc_numbers.py` change the `v3_tasks` source to `from benchmarks.tasks_v4 import TASKS_V4` and the CLAUDE.md pattern to `r"the current set \((\d+) tasks"` (unchanged text shape).
|
||
|
||
- [ ] **Step 5: Run** — `uv run --frozen pytest tests/test_benchmark_v4.py tests/test_benchmark_v3.py tests/test_benchmark_v2.py -v` and `uv run --frozen python benchmarks/run_competitors.py --matrix v4 --server autocad-mcp-pro --task pid_roundtrip` → `pid_roundtrip: pass`.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add benchmarks/tasks_v4.py benchmarks/adapters/autocad_mcp_pro.py benchmarks/run_competitors.py benchmarks/render_matrix_chart.py benchmarks/correctness_suite.py scripts/check_doc_numbers.py CLAUDE.md README.md benchmarks/README.md tests/test_benchmark_v4.py
|
||
git commit -m "bench: matrix v4 adds pid_roundtrip; correctness suite grows to 29 with three P&ID checks"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 21: Live COM smoke, documentation, A/B re-run, full gate
|
||
|
||
**Files:**
|
||
- Create: `scripts/smoke_pid_com.py`
|
||
- Modify: `CLAUDE.md` (§17 P&ID, contracts, `TOOL_PACKS`, reader rules), `README.md` (What-you-get row, catalogue + showcase images, token table per pack, A/B table), `CHANGELOG.md` (`Unreleased` entry), `docs/superpowers/specs/2026-09-15-v1.6-pid-design.md` (status line → implemented)
|
||
|
||
- [ ] **Step 1: Live COM smoke script**
|
||
|
||
```python
|
||
# scripts/smoke_pid_com.py
|
||
"""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
|
||
block_define -> pid_symbol_insert -> xdata -> pid_line_draw -> pid_graph and
|
||
prints the graph stats. Exit 2 when no AutoCAD is reachable.
|
||
|
||
AUTOCAD_MCP_BACKEND=com uv run --frozen python scripts/smoke_pid_com.py
|
||
"""
|
||
|
||
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))
|
||
|
||
|
||
async def main() -> 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:
|
||
await backend.drawing_new()
|
||
status = await backend.system_status()
|
||
from engineering.layers import apply_layer_set
|
||
from engineering.pid.spec import EXAMPLE_SPEC, run_spec
|
||
|
||
await apply_layer_set(backend, "pid")
|
||
result = await run_spec(backend, EXAMPLE_SPEC)
|
||
got = await backend.entity_get_xdata(result["handles"]["P-101"], "ACADMCP_PID")
|
||
report = {
|
||
"autocad": status.get("autocad_version") or status.get("cad_progid"),
|
||
"graph_stats": result["graph"]["stats"],
|
||
"critique_issues": len(result["critique"]),
|
||
"xdata_chunks": len(got["xdata"].get("ACADMCP_PID", [])),
|
||
}
|
||
print(json.dumps(report, indent=2))
|
||
return 0 if result["graph"]["stats"]["dangling"] == 0 else 1
|
||
finally:
|
||
await backend.disconnect()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(asyncio.run(main()))
|
||
```
|
||
|
||
Run it with AutoCAD open: `AUTOCAD_MCP_BACKEND=com uv run --frozen python scripts/smoke_pid_com.py`. Paste the printed report into the CHANGELOG entry. If it exits 2, the CHANGELOG says the COM paths are fake-tested only. If a COM call fails (a wrong ActiveX member name, a variant shape), fix the backend, re-run the fake tests, and re-run the smoke until it passes — that is the point of running it.
|
||
|
||
- [ ] **Step 2: CLAUDE.md**
|
||
|
||
Add to the tool sections list:
|
||
|
||
```
|
||
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 run under `focus=None` and inside `drawing_finalize`; they return nothing on a drawing without P&ID nodes.
|
||
```
|
||
|
||
Add to Key Conventions:
|
||
|
||
```
|
||
- **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`.
|
||
```
|
||
|
||
- [ ] **Step 3: README**
|
||
|
||
- "What you get" table: a row `| 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 |`.
|
||
- Under the hero image add the catalogue and P&ID showcase images (`docs/assets/pid-catalog.png`, `docs/assets/pid-showcase.png`) with a one-line caption each, the same `<sub>` style as the hero.
|
||
- Token table: measure `uv run --frozen python benchmarks/token_suite.py` once with `TOOL_PACKS=core` and once with the default and add a row `| TOOL_PACKS=core (full profile) | <N> | <tokens> |`.
|
||
- A/B table: run `uv run --frozen python benchmarks/compare_versions.py v1.5.1`; rewrite the rows as `v1.5.1 *(baseline)*` and `v1.6.0-dev *(this branch)*` with the printed numbers (expect `26 / 29` → `29 / 29`, three `miss → pass`), and update the two `v1.5.0`/`v1.5.1` regexes in `scripts/check_doc_numbers.py` to the new labels so the gate keeps matching.
|
||
- Snapshot line: `166 tools · 8 resources · 5 prompt templates · <collected> collected tests` — take `<collected>` from `uv run --frozen pytest --collect-only -q | tail -1` on this machine, and note in the CHANGELOG if it differs from the number CI reports on Linux (the 1369 vs 1363 drift found on 2026-09-15 predates this track; resolve it now by using the CI number if CI is green, else this machine's).
|
||
|
||
- [ ] **Step 4: CHANGELOG `Unreleased`**
|
||
|
||
Headings: **Added** (the nine tools with the measured refusals from the tests — the 255-char/16 KB XDATA limits, the stub refusal, the crossing count, the confidence ladder, the `*U1` fix), **Fixed** (`block_insert` with attributes lost its ATTRIBs headlessly — Task 1, with the reproduction), **Changed** (`TOOL_PACKS`, `PID_LAYERS` + `PROCESS-LINE-TEXT`, prompt rewrite, critique enum +6, benchmark v4, correctness 29), and the live COM smoke report verbatim.
|
||
|
||
- [ ] **Step 5: Full gate**
|
||
|
||
Run, in order, and fix anything red before committing:
|
||
|
||
```
|
||
uv run --frozen ruff check . && uv run --frozen ruff format --check .
|
||
uv run --frozen pytest -x -q
|
||
uv run --frozen python scripts/generate_tool_inventory.py --check
|
||
uv run --frozen python scripts/check_doc_numbers.py
|
||
uv run --frozen python benchmarks/compare_versions.py v1.5.1
|
||
```
|
||
|
||
Expected: ruff clean; pytest green (every new test file included); inventory unchanged; doc numbers agree; A/B `29 / 29` with zero regressions.
|
||
|
||
- [ ] **Step 6: Commit and mark the spec implemented**
|
||
|
||
```bash
|
||
git add scripts/smoke_pid_com.py CLAUDE.md README.md CHANGELOG.md scripts/check_doc_numbers.py docs/superpowers/specs/2026-09-15-v1.6-pid-design.md
|
||
git commit -m "docs(pid): track A shipped — CLAUDE.md, README, changelog with the live COM smoke record"
|
||
```
|
||
|
||
Track A is done when the gate in Step 5 is green and the roadmap's criteria 1 and 2 are demonstrated by `tests/test_pid_from_spec.py::test_finalize_scores_the_example_at_ninety_or_better` and `tests/test_pid_graph.py::test_foreign_blocks_classify_with_reduced_confidence_and_inferred_ports`.
|