Two authoring slips in the valve x actuator composition: - The diaphragm actuator's stem stopped at y=4 while the dome's flat base sat at y=6, so the canonical control-valve symbol (PID_VALVE_*_DIAPHRAGM, 9 catalogue entries) shipped with its dome floating 2 mm above the stem. - Actuators were placed at a fixed y=4 regardless of the body, so on the needle body (top 5.6) the piston/motor/solenoid glyphs were drawn over the knob and the hand bar across the needle's stem; the stem line(0,0,0,4) also duplicated the needle's own axial line (and the butterfly disc). Placement is now body-relative: `_body` reports `axis_top` (where its own geometry on x=0 ends) and the stem runs from there to the glyph base at `body_top + STEM_CLEARANCE`; the diaphragm dome's base is the stem top (base + DIAPHRAGM_YOKE), and the FAIL attribute follows the glyph base. The reference 8x4 bodies keep their published numbers (globe+diaphragm signal at y=9.0, bbox top 9.0). Tests: per-combination check that the stem starts at the body's derived axis top, the glyph's lowest edge equals the stem top, the glyph clears the body, and the signal port is the topmost edge; plus a catalogue-wide no-duplicate-primitives gate. 33 combinations fail on the old geometry. The plan's Step 3 snippet is synced with the fixed module. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
7082 lines
304 KiB
Markdown
7082 lines
304 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: "
|
||
f"{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`.
|