feat(pid): pid_tag_parse, catalogue and ISA-5.1 resources, prompt rewritten onto the tools
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5
parent
d80ae17411
commit
c441609254
@@ -19,7 +19,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
|
||||
|
||||
</div>
|
||||
|
||||
> **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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 162 KiB |
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user