Files
autocad-mcp-pro/docs/superpowers/plans/2026-09-15-v1.6-pid-implementation.md
T
Umut CANandClaude Opus 5 2be5e4180d docs(pid): track A shipped — CLAUDE.md, README, changelog with the live COM smoke record
scripts/smoke_pid_com.py runs the whole track against the live AutoCAD in
a new document. ComBackend.connect() is lazy by design, so the script reads
reachability from system_status()["connected"] rather than from connect()
as the plan sketched, and closes its document without saving unless --keep
is passed. On this machine (AutoCAD 2026, "25.1s (LMS Tech)") it exits 0:
5 nodes, 4 edges, 0 dangling, confidence_min 1.0, 0 critique issues,
2 XDATA chunks — identical to the headless engine on the same spec. The
first attempt exited 2 on a stale win32com gen_py cache (a package folder
holding only __pycache__); an environment fault, no backend change needed.

A/B against v1.5.1 published as ab-v1.5.1-vs-v1.6.0-dev.json: 26/29 ->
29/29, three miss -> pass, zero regressed. check_doc_numbers.py now names
the report and the two row labels in one place (AB_REPORT, AB_*_LABEL),
and README.md / benchmarks/README.md carry the new rows; the v1.5.0 and
v1.4.0 tables stay as history.

README: P&ID row, the showcase and catalogue images, the token table
re-measured (161 / 44,930 by default, 152 / 41,420 with TOOL_PACKS=core,
lean 50 / 14,007), every stale count reconciled (154 -> 166 registered,
149 -> 161 advertised, corpus 158/672 -> 162/731 by the same count, 19 ->
20 groups, 6 -> 8 resources, six-task matrix with pid_roundtrip), a
"Known limitations of the P&ID track" subsection, and the snapshot line at
this machine's 2423 collected tests (CI has not run on this tree; noted in
the changelog with the pre-existing 1369 vs 1363 drift).

CLAUDE.md: section 17, sections 5 and 7 counts, TOOL_PACKS, the two 1.6
contracts and the reader rules. Spec status -> implemented. The plan and
spec markdown were reformatted by ruff (fenced Python only) because
`ruff format --check .` was already red on them at HEAD and the gate
requires the whole tree clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-16 13:34:06 +03:00

315 KiB
Raw Blame History

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

# 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:

            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
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

# 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
# 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
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

# 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:

    @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:

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):

    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:

@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:

    "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
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

# 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
# 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:

@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)
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)
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:

@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:

    "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).

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

# 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
# engineering/pid/__init__.py
"""P&ID expert and reader: symbol catalogue, lines, ISA-5.1 tags, graph."""
# 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
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

// 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
# 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
# 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
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)

# 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
# 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
# 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:

# 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
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

# 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).

# 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
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

# 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

# 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

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

# 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

# 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

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

# 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:

(("PROCESS-LINE-TEXT", 7, "Continuous", 0.25, "line numbers"),)
# 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:

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:

# ---------------------------------------------------------------------------
# ── 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:

    "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

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

# 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

# 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

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

# 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

# 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:

@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:

    "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

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

# 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

# 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:

@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:

    "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
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

# 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

# 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))

@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:

    "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
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

# 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":

("pid_dangling_line",)
("pid_duplicate_tag",)
("pid_incompatible_connection",)
("pid_untagged_instrument",)
("pid_illegal_tag",)
("pid_unconnected_equipment",)
  • Step 4: Write the checks
# engineering/pid/critique.py
"""Six P&ID focuses over one graph per critique run.

Every check is pure over the graph dict; ``run_critique`` builds the graph once
into the per-run ``shared`` context so six focuses cost one read. A drawing
with no P&ID nodes yields no issues — mechanical sheets are never scored here.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from engineering.plan_spec import Issue

from .graph import build_graph
from .lines import LINE_CLASSES
from .tags import parse_tag

if TYPE_CHECKING:
    from backends.base import AutoCADBackend

PID_FOCUSES = (
    "pid_dangling_line",
    "pid_duplicate_tag",
    "pid_incompatible_connection",
    "pid_untagged_instrument",
    "pid_illegal_tag",
    "pid_unconnected_equipment",
)
_KEY = "pid_graph"


def _dangling(graph: dict) -> list[Issue]:
    out = []
    for d in graph["dangling"]:
        nearest = d.get("nearest")
        if nearest:
            hint = (
                f"pid_line_draw(from_={{'handle': ..., 'port': ...}}, to={{'handle': '{nearest['node']}', "
                f"'port': '{nearest['port']}'}}) — nearest port {nearest['distance']} mm away"
            )
        else:
            hint = "no port within reach; connect this end with pid_line_draw or delete the line"
        out.append(
            Issue(
                "error",
                "pid_dangling_line",
                f"Line {d['edge']} {d['end']} end at ({d['x']:.2f}, {d['y']:.2f}) connects to nothing.",
                [d["edge"]],
                {"end": d["end"], "x": d["x"], "y": d["y"], "nearest": nearest, "hint": hint},
            )
        )
    return out


def _duplicate_tags(graph: dict) -> list[Issue]:
    seen: dict[str, list[str]] = {}
    for node in graph["nodes"]:
        if (
            node.get("tag") and node["kind"] != "connector"
        ):  # connectors carry free text ("TO P&ID-002")
            seen.setdefault(node["tag"].upper(), []).append(node["id"])
    return [
        Issue(
            "error",
            "pid_duplicate_tag",
            f"Tag {tag} is carried by {len(handles)} symbols.",
            handles,
            {
                "tag": tag,
                "hint": "block_set_attributes(handle, {'TAG'|'FUNC'/'LOOP': ...}) on all but one",
            },
        )
        for tag, handles in seen.items()
        if len(handles) > 1
    ]


def _incompatible(graph: dict) -> list[Issue]:
    nodes = {n["id"]: n for n in graph["nodes"]}
    out = []
    for edge in graph["edges"]:
        cls = LINE_CLASSES.get(edge.get("line_class") or "")
        if cls is None:
            continue
        for ref in (edge["from"], edge["to"]):
            if not ref or "node" not in ref:
                continue
            port = nodes[ref["node"]]["ports"].get(ref["port"], {})
            actual = port.get("kind")
            if actual in ("process", "signal") and actual != cls.kind:
                out.append(
                    Issue(
                        "error",
                        "pid_incompatible_connection",
                        f"{cls.kind} line {edge['id']} ends on {actual} port {ref['port']} of {ref['node']}.",
                        [edge["id"], ref["node"]],
                        {
                            "expected": cls.kind,
                            "actual": actual,
                            "port": ref["port"],
                            "line_class": edge["line_class"],
                            "hint": f"redraw with a {actual} line class or connect to a {cls.kind} port",
                        },
                    )
                )
    return out


def _untagged(graph: dict) -> list[Issue]:
    return [
        Issue(
            "warning",
            "pid_untagged_instrument",
            f"Instrument {n['id']} has no tag.",
            [n["id"]],
            {"hint": "block_set_attributes(handle, {'FUNC': 'FIC', 'LOOP': '101'})"},
        )
        for n in graph["nodes"]
        if n["kind"] == "instrument" and not n.get("tag")
    ]


def _illegal(graph: dict) -> list[Issue]:
    out = []
    for n in graph["nodes"]:
        if not n.get("tag") or n["kind"] == "connector":
            continue
        parsed = parse_tag(n["tag"], kind="instrument" if n["kind"] == "instrument" else "auto")
        if not parsed["valid"]:
            out.append(
                Issue(
                    "warning",
                    "pid_illegal_tag",
                    f"Tag {n['tag']} on {n['id']} is not ISA-5.1: {parsed['errors'][0]}",
                    [n["id"]],
                    {"errors": parsed["errors"], "hint": "block_set_attributes with a valid tag"},
                )
            )
    return out


def _unconnected(graph: dict) -> list[Issue]:
    return [
        Issue(
            "info",
            "pid_unconnected_equipment",
            f"{n['kind']} {n.get('tag') or n['id']} has no line on any port.",
            [n["id"]],
            {"hint": "informational — connect it with pid_line_draw or leave it if intended"},
        )
        for n in graph["nodes"]
        if n["kind"] in ("equipment", "valve")
        and n["ports"]
        and not any(p["edges"] for p in n["ports"].values())
    ]


_CHECKS = {
    "pid_dangling_line": _dangling,
    "pid_duplicate_tag": _duplicate_tags,
    "pid_incompatible_connection": _incompatible,
    "pid_untagged_instrument": _untagged,
    "pid_illegal_tag": _illegal,
    "pid_unconnected_equipment": _unconnected,
}


def issues_for(focus: str, graph: dict) -> list[Issue]:
    if not graph["nodes"]:
        return []
    return _CHECKS[focus](graph)


def _make(focus: str):
    async def check(backend: AutoCADBackend, shared: dict) -> list[Issue]:
        graph = shared.get(_KEY)
        if graph is None:
            graph = shared[_KEY] = await build_graph(backend, include_foreign=True)
        return issues_for(focus, graph)

    check.needs_shared = True  # type: ignore[attr-defined]
    check.__name__ = f"check_{focus}"
    return check


PID_DISPATCH = {focus: _make(focus) for focus in PID_FOCUSES}

In engineering/critique.py: after _FOCUS_DISPATCH = {...} add

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

    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:

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:

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

    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

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

# 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

# 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))
@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:

    "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
# 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

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

# 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:

        # 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:

# 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:

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:

    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

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

# 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:

@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):

@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:

    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
# 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

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

# 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

# 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):

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):

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

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

# 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_graphreads 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
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.