Files
autocad-mcp-pro/docs/superpowers/plans/2026-09-15-v1.6-pid-implementation.md
T
Umut CANandClaude Opus 5 352ce21030 fix(task-8): actuators stack on the body's real top and every stem meets its glyph
Two authoring slips in the valve x actuator composition:

- The diaphragm actuator's stem stopped at y=4 while the dome's flat base
  sat at y=6, so the canonical control-valve symbol (PID_VALVE_*_DIAPHRAGM,
  9 catalogue entries) shipped with its dome floating 2 mm above the stem.
- Actuators were placed at a fixed y=4 regardless of the body, so on the
  needle body (top 5.6) the piston/motor/solenoid glyphs were drawn over
  the knob and the hand bar across the needle's stem; the stem line(0,0,0,4)
  also duplicated the needle's own axial line (and the butterfly disc).

Placement is now body-relative: `_body` reports `axis_top` (where its own
geometry on x=0 ends) and the stem runs from there to the glyph base at
`body_top + STEM_CLEARANCE`; the diaphragm dome's base is the stem top
(base + DIAPHRAGM_YOKE), and the FAIL attribute follows the glyph base.
The reference 8x4 bodies keep their published numbers (globe+diaphragm
signal at y=9.0, bbox top 9.0).

Tests: per-combination check that the stem starts at the body's derived
axis top, the glyph's lowest edge equals the stem top, the glyph clears
the body, and the signal port is the topmost edge; plus a catalogue-wide
no-duplicate-primitives gate. 33 combinations fail on the old geometry.
The plan's Step 3 snippet is synced with the fixed module.

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

304 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: "
                             f"{parsed['errors'][0]}", [n["id"]],
                             {"errors": parsed["errors"], "hint": "block_set_attributes with a valid tag"}))
    return out


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


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


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


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

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


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

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

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.