diff --git a/README.md b/README.md index bfd25ff..4721fa6 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **v1.5 release snapshot:** 165 tools · 6 resources · 5 prompt templates · 1369 collected tests. +> **v1.5 release snapshot:** 166 tools · 8 resources · 5 prompt templates · 1369 collected tests. > 154 is the **registered** count; a default install advertises 149 over `tools/list`, > because `ENABLE_3D` is unset. `system_about` is the runtime authority. diff --git a/discovery/aliases.py b/discovery/aliases.py index fec4ebe..e8b9af9 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -1417,6 +1417,16 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "batch p&id", ), ), + "pid_tag_parse": ToolAliases( + acad=(), + synonyms=( + "what does fic mean", + "isa 5.1 tag", + "instrument tag letters", + "decode the tag", + "loop number", + ), + ), } diff --git a/docs/assets/pid-catalog.png b/docs/assets/pid-catalog.png new file mode 100644 index 0000000..761984e Binary files /dev/null and b/docs/assets/pid-catalog.png differ diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index cdd5895..264451c 100644 --- a/docs/tool-inventory.json +++ b/docs/tool-inventory.json @@ -3,8 +3,8 @@ "schema_version": 1, "package_version": "1.5.1", "totals": { - "tools": 165, - "resources": 6, + "tools": 166, + "resources": 8, "prompts": 5, "groups": 20 }, @@ -21,7 +21,7 @@ "entity_query": 9, "layers": 14, "layouts": 12, - "pid": 8, + "pid": 9, "premium": 12, "solids": 5, "system": 7, @@ -1830,6 +1830,20 @@ "readOnlyHint": true } }, + { + "name": "pid_tag_parse", + "group": "pid", + "cost": "read", + "summary": "Parse an ISA-5.1 instrument tag or an equipment tag; explains every letter.", + "tags": [ + "pid", + "query" + ], + "annotations": { + "title": "P&ID: Parse Tag", + "readOnlyHint": true + } + }, { "name": "point_from_snap", "group": "premium", @@ -2392,6 +2406,29 @@ "templated": false, "parameters": [] }, + { + "uri": "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", + "tags": [ + "pid" + ], + "templated": false, + "parameters": [] + }, + { + "uri": "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", + "tags": [ + "pid", + "standards" + ], + "templated": false, + "parameters": [] + }, { "uri": "autocad://system/status", "name": "Server Status", diff --git a/scripts/render_pid_catalog.py b/scripts/render_pid_catalog.py new file mode 100644 index 0000000..9aa0dcf --- /dev/null +++ b/scripts/render_pid_catalog.py @@ -0,0 +1,93 @@ +"""Render every catalogue symbol on one labelled sheet (docs/assets/pid-catalog.png). + +Each symbol is placed through ``insert_symbol`` -- the same path +``pid_symbol_insert`` takes -- so a regressed builder changes the picture. +The sheet is rendered through the same ezdxf/matplotlib frontend +``view_screenshot`` uses, only at a figure size a 10 x 11 grid of 8 mm +symbols stays legible on (the tool's 16 x 9 in screenshot is sized for a +single drawing, not a catalogue). + + 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 +FIGSIZE = (20.0, 22.0) # inches; the grid is 800 x 880 mm +DPI = 110 + + +def _render_png(backend, figsize: tuple[float, float], dpi: int) -> bytes: + """The document as a PNG, at a size of our choosing.""" + import io + + from ezdxf.addons.drawing import Frontend, RenderContext + from ezdxf.addons.drawing.matplotlib import MatplotlibBackend + + from backends.ezdxf_backend import _new_agg_figure + + doc = backend._require_doc() + fig = _new_agg_figure(figsize=figsize, dpi=dpi) + ax = fig.add_axes([0, 0, 1, 1]) + out = MatplotlibBackend(ax, adjust_figure=False) # keep FIGSIZE; the default shrinks it to 5 in + Frontend(RenderContext(doc), out).draw_layout(doc.modelspace(), finalize=True) + buf = io.BytesIO() + fig.savefig(buf, format="png", bbox_inches="tight", dpi=dpi) + return buf.getvalue() + + +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 asyncio.to_thread(_render_png, backend, FIGSIZE, DPI) + 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()) diff --git a/server.py b/server.py index 6916f80..eaf966e 100644 --- a/server.py +++ b/server.py @@ -6369,7 +6369,7 @@ async def solid_boolean( # --------------------------------------------------------------------------- -# ── SECTION 17: P&ID (8 tools) ────────────────────────────────────────────── +# ── SECTION 17: P&ID (9 tools) ────────────────────────────────────────────── # --------------------------------------------------------------------------- @@ -6713,6 +6713,34 @@ async def pid_from_spec( return await run_spec(_backend(ctx), spec, dry_run) +@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) + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- @@ -6838,6 +6866,34 @@ async def resource_entities_by_layer(layer_name: str, ctx: Context = None) -> st return json.dumps({"error": str(exc)}) +@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) + + # --------------------------------------------------------------------------- # ── PROMPTS ────────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- @@ -6887,38 +6943,39 @@ def prompt_pid_diagram( revision: str = "Rev A", ) -> str: """Generate a prompt for creating a P&ID (Piping and Instrumentation Diagram).""" - return f"""You are creating a P&ID for '{project_name}' ({revision}). + 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. -LAYER SETUP: - - PROCESS_LINES color=7 lineweight=50 (main process piping) - - UTILITY_LINES color=3 lineweight=25 (utility services) - - INSTRUMENTS color=2 lineweight=25 (instrument circles) - - EQUIPMENT color=5 lineweight=50 (vessels, pumps, HX) - - VALVES color=4 lineweight=25 (valve symbols) - - TAGS color=7 lineweight=13 (tag numbers) - - ANNOTATIONS color=8 lineweight=13 (notes) - - BORDER color=7 lineweight=100 (drawing border) +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. -STANDARD SYMBOLS (draw as entities): - - Vessels: rectangle with domed ends - - Pumps: circle with triangle (impeller) - - Heat Exchangers: two overlapping rectangles - - Valves: two triangles point-to-point - - Control valves: valve symbol + circle above - - Instruments: circle with tag number +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. -INSTRUMENT TAG FORMAT: [Function][Loop Number][Suffix] - Examples: FT-101 (flow transmitter), FIC-101 (flow indicator controller) +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. -WORKFLOW: -1. layer_create() for all layers -2. entity_create_rectangle() for drawing border -3. Place major equipment first (vessels, columns) -4. Draw process lines (polylines) connecting equipment -5. Place valve symbols at control points -6. Add instrument bubbles (circles + text) -7. Add line numbers and stream labels -8. Add title block text +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. """ diff --git a/tests/test_pid_resources_prompt.py b/tests/test_pid_resources_prompt.py new file mode 100644 index 0000000..9786ca0 --- /dev/null +++ b/tests/test_pid_resources_prompt.py @@ -0,0 +1,73 @@ +"""pid_tag_parse tool, the two P&ID resources, the rewritten prompt, the contact sheet.""" + +from __future__ import annotations + +import json + +import pytest +import pytest_asyncio +from fastmcp import Client + +import server + + +@pytest_asyncio.fixture +async def client(monkeypatch): + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") + async with Client(server.mcp) as connected: + yield connected + + +@pytest.mark.asyncio +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" + + +@pytest.mark.asyncio +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 + + +@pytest.mark.asyncio +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 diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index 2218e9b..13ab419 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -512,8 +512,10 @@ async def test_tool_group_sizes_are_unchanged(): `pid_symbol_insert`, then 2 -> 3 when `pid_line_draw` joined, 3 -> 4 when the reader `pid_graph` joined and 4 -> 7 when the three graph deliverables (`pid_instrument_index` / `pid_line_list` / - `pid_equipment_list`) joined and 7 -> 8 when `pid_from_spec` (a whole - sheet from one declarative spec, in one transaction) joined; the `pid` + `pid_equipment_list`) joined, 7 -> 8 when `pid_from_spec` (a whole + sheet from one declarative spec, in one transaction) joined and 8 -> 9 + when `pid_tag_parse` (the ISA-5.1 tag grammar as a read-only tool) + joined; the `pid` tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under `pid` rather than under their secondary `query` / `create` tags. Every other number here has been unchanged since the snapshot was taken. @@ -532,7 +534,7 @@ async def test_tool_group_sizes_are_unchanged(): "entity_query": 9, "layers": 14, "layouts": 12, - "pid": 8, + "pid": 9, "premium": 12, "solids": 5, "system": 7, @@ -541,4 +543,4 @@ async def test_tool_group_sizes_are_unchanged(): "validation": 1, "view": 4, } - assert sum(sizes.values()) == 165 + assert sum(sizes.values()) == 166