feat(understand): drawing_scale_check - is this drawing to scale against that one
Equipment tags found exactly once in each drawing are matched (a tag found twice - a plan copy, a legend - is reported ambiguous and takes no part; two plan copies resolve to the cluster that matches the most tags). For every pair of matched tags at least min_pair_mm apart on the reference, the ratio of their distances is taken in millimetres by each drawing's inferred unit; the median, the quartiles, the share within +/-10 % of the median, the same per room of the reference, and the verdict: to_scale (>= 80 %, with its factor), schematic (<= 50 %), partly, or insufficient under three matched tags. server.py grows SECTION 26 to two tools with drawing_scale_check (core, read-only, tagged analysis/query); alias record, inventory, README release snapshot 243, frozen group sizes (analysis 14). Co-Authored-By: Claude Opus 5.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.5
parent
2459694880
commit
7aaead43d9
@@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping():
|
||||
|
||||
|
||||
async def test_tool_group_sizes_are_unchanged():
|
||||
"""Frozen snapshot of the surface (242 tools, 26 groups).
|
||||
"""Frozen snapshot of the surface (243 tools, 26 groups).
|
||||
|
||||
Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when
|
||||
v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and
|
||||
@@ -595,13 +595,14 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
Track H group U opened SECTION 26 (Understanding & QA) with
|
||||
`drawing_understand`, tagged `analysis` and `query`: `analysis` outranks
|
||||
`query` in `_GROUP_TAG_PRIORITY`, so the reader files under `analysis`
|
||||
(12 -> 13) beside the other whole-drawing queries. This snapshot is
|
||||
branch-local: the track H merge task recomputes it once the understand,
|
||||
network and diff branches have landed.
|
||||
(12 -> 13) beside the other whole-drawing queries, and
|
||||
`drawing_scale_check`, tagged the same, followed it (13 -> 14). This
|
||||
snapshot is branch-local: the track H merge task recomputes it once the
|
||||
understand, network and diff branches have landed.
|
||||
"""
|
||||
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
||||
assert sizes == {
|
||||
"analysis": 13,
|
||||
"analysis": 14,
|
||||
"architecture": 12,
|
||||
"batch": 3,
|
||||
"blocks": 9,
|
||||
@@ -628,4 +629,4 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
"validation": 1,
|
||||
"view": 4,
|
||||
}
|
||||
assert sum(sizes.values()) == 242
|
||||
assert sum(sizes.values()) == 243
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""drawing_scale_check: tag matching and ratio statistics.
|
||||
|
||||
The hand-built plans put six tags at coordinates written below; every pair
|
||||
distance and ratio in the comments is worked out from them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from itertools import combinations
|
||||
|
||||
import pytest
|
||||
|
||||
from engineering.understand.scale import match_tags, scale_check
|
||||
from engineering.understand.snapshot import EntityRecord, Snapshot, read_snapshot
|
||||
from tests.fixtures.plant_pair import build_plant_pair
|
||||
|
||||
EPS = 1e-12
|
||||
|
||||
#: Six tags of one plant, in millimetres. The closest pair is T4100-T4500,
|
||||
#: hypot(6000, 4000) = 7211.1, so every pair clears the 2 m floor.
|
||||
PLAN = {
|
||||
"T4100": (0.0, 0.0),
|
||||
"T4200": (12000.0, 0.0),
|
||||
"T4300": (12000.0, 9000.0),
|
||||
"T4400": (0.0, 9000.0),
|
||||
"T4500": (6000.0, 4000.0),
|
||||
"T4600": (20000.0, 15000.0),
|
||||
}
|
||||
|
||||
|
||||
def text(handle, value, x, y):
|
||||
return EntityRecord(
|
||||
handle=handle, type="TEXT", layer="TAGS", space="Model", points=((x, y),), text=value
|
||||
)
|
||||
|
||||
|
||||
def outline(handle, x0, y0, x1, y1):
|
||||
return EntityRecord(
|
||||
handle=handle,
|
||||
type="LWPOLYLINE",
|
||||
layer="0",
|
||||
space="Model",
|
||||
points=((x0, y0), (x1, y0), (x1, y1), (x0, y1)),
|
||||
bulges=(0.0, 0.0, 0.0, 0.0),
|
||||
closed=True,
|
||||
)
|
||||
|
||||
|
||||
def snapshot(records):
|
||||
return Snapshot(
|
||||
source="test",
|
||||
insunits=4,
|
||||
extmin=None,
|
||||
extmax=None,
|
||||
layers={},
|
||||
layouts=("Model",),
|
||||
records=tuple(records),
|
||||
)
|
||||
|
||||
|
||||
def tags(positions, prefix="H", factor=1.0, dx=0.0):
|
||||
return [
|
||||
text(f"{prefix}{i}", tag, x * factor + dx, y * factor)
|
||||
for i, (tag, (x, y)) in enumerate(sorted(positions.items()))
|
||||
]
|
||||
|
||||
|
||||
def test_a_drawing_at_exactly_half_scale_is_to_scale_with_factor_one_half():
|
||||
result = scale_check(snapshot(tags(PLAN, "A", 0.5)), snapshot(tags(PLAN, "B")))
|
||||
assert result["verdict"] == "to_scale"
|
||||
assert result["factor"] == pytest.approx(0.5, abs=EPS)
|
||||
assert result["median"] == pytest.approx(0.5, abs=EPS)
|
||||
assert result["iqr"] == pytest.approx(0.0, abs=EPS)
|
||||
assert result["within_10pct"] == 1.0
|
||||
assert result["pair_count"] == 15 # C(6, 2)
|
||||
assert result["pairs_below_floor"] == 0
|
||||
assert [m["tag"] for m in result["matched"]] == sorted(PLAN)
|
||||
assert result["ambiguous"] == []
|
||||
assert result["units"]["a"]["unit"] == result["units"]["b"]["unit"] == "mm"
|
||||
|
||||
|
||||
def test_a_rearranged_drawing_is_schematic():
|
||||
# The same six tags, shuffled onto each other's places and one moved off.
|
||||
rearranged = {
|
||||
"T4100": (12000.0, 9000.0),
|
||||
"T4200": (0.0, 0.0),
|
||||
"T4300": (20000.0, 15000.0),
|
||||
"T4400": (6000.0, 4000.0),
|
||||
"T4500": (12000.0, 0.0),
|
||||
"T4600": (0.0, 20000.0),
|
||||
}
|
||||
result = scale_check(snapshot(tags(rearranged, "A")), snapshot(tags(PLAN, "B")))
|
||||
ratios = sorted(
|
||||
math.dist(rearranged[i], rearranged[j]) / math.dist(PLAN[i], PLAN[j])
|
||||
for i, j in combinations(sorted(PLAN), 2)
|
||||
)
|
||||
median = ratios[7] # 15 ratios: the 8th
|
||||
within = sum(1 for r in ratios if abs(r - median) <= 0.1 * median) / 15
|
||||
assert result["median"] == pytest.approx(median, abs=EPS)
|
||||
assert result["within_10pct"] == pytest.approx(within, abs=EPS)
|
||||
assert within <= 0.5
|
||||
assert result["verdict"] == "schematic"
|
||||
assert result["factor"] is None
|
||||
|
||||
|
||||
def test_a_tag_found_twice_is_ambiguous_and_takes_no_part():
|
||||
# T4400 appears twice on the reference: neither copy is matched.
|
||||
reference = [*tags(PLAN, "B"), text("DUP", "T4400", 3000.0, 12000.0)]
|
||||
matched = match_tags(snapshot(tags(PLAN, "A", 0.5)), snapshot(reference))
|
||||
assert "T4400" not in matched["matched"]
|
||||
assert matched["ambiguous"] == ["T4400"]
|
||||
assert matched["occurrences"] == {"T4400": {"a": 1, "b": 2}}
|
||||
assert matched["matched"]["T4200"] == ((6000.0, 0.0), (12000.0, 0.0))
|
||||
|
||||
result = scale_check(snapshot(tags(PLAN, "A", 0.5)), snapshot(reference))
|
||||
assert result["ambiguous"] == ["T4400"]
|
||||
assert "T4400" not in [m["tag"] for m in result["matched"]]
|
||||
assert result["pair_count"] == 10 # C(5, 2): the ambiguous tag is in no pair
|
||||
assert all("T4400" not in row["tags"] for row in result["pairs"])
|
||||
assert result["verdict"] == "to_scale"
|
||||
assert result["factor"] == pytest.approx(0.5, abs=EPS)
|
||||
|
||||
|
||||
def test_two_plan_copies_on_the_reference_resolve_to_one_copy():
|
||||
# The reference holds the plan twice, 60 000 apart, each inside its own
|
||||
# outline: over the whole drawing every tag is ambiguous, inside one copy
|
||||
# every tag is unique.
|
||||
reference = [
|
||||
outline("O1", -1000.0, -1000.0, 21000.0, 16000.0),
|
||||
*tags(PLAN, "L"),
|
||||
outline("O2", 59000.0, -1000.0, 81000.0, 16000.0),
|
||||
*tags(PLAN, "R", dx=60000.0),
|
||||
]
|
||||
whole = match_tags(snapshot(tags(PLAN, "A", 0.5)), snapshot(reference))
|
||||
assert whole["matched"] == {}
|
||||
assert whole["ambiguous"] == sorted(PLAN)
|
||||
result = scale_check(snapshot(tags(PLAN, "A", 0.5)), snapshot(reference))
|
||||
assert result["scope"] == {"cluster_a": None, "cluster_b": "C1"}
|
||||
assert len(result["matched"]) == 6
|
||||
assert result["ambiguous"] == []
|
||||
assert result["verdict"] == "to_scale"
|
||||
assert result["factor"] == pytest.approx(0.5, abs=EPS)
|
||||
|
||||
|
||||
def test_cyrillic_look_alike_tags_match_their_latin_twins():
|
||||
# "Т4100" typed with a Cyrillic Т (U+0422) is the layout's Latin T4100.
|
||||
cyrillic = {("Т" + tag[1:] if tag == "T4100" else tag): xy for tag, xy in PLAN.items()}
|
||||
matched = match_tags(snapshot(tags(cyrillic, "A")), snapshot(tags(PLAN, "B")))
|
||||
assert "T4100" in matched["matched"]
|
||||
assert len(matched["matched"]) == 6
|
||||
|
||||
|
||||
def test_fewer_than_three_matched_tags_is_insufficient():
|
||||
two = {k: PLAN[k] for k in ("T4100", "T4200")}
|
||||
result = scale_check(snapshot(tags(two, "A")), snapshot(tags(PLAN, "B")))
|
||||
assert result["verdict"] == "insufficient"
|
||||
assert result["factor"] is None
|
||||
assert result["pair_count"] == 1
|
||||
|
||||
|
||||
def test_pairs_closer_than_the_floor_on_the_reference_are_left_out():
|
||||
# With a 13 m floor, the pairs of PLAN at least 13 000 apart on the
|
||||
# reference are T4100-T4300 (15 000), T4100-T4600 (25 000), T4200-T4600
|
||||
# (hypot(8000, 15000) = 17 000), T4400-T4600 (hypot(20000, 6000) = 20 881),
|
||||
# T4500-T4600 (hypot(14000, 11000) = 17 804), T4200-T4400 (15 000): six.
|
||||
result = scale_check(
|
||||
snapshot(tags(PLAN, "A", 0.5)), snapshot(tags(PLAN, "B")), min_pair_mm=13000.0
|
||||
)
|
||||
assert result["pair_count"] == 6
|
||||
assert result["pairs_below_floor"] == 9
|
||||
assert result["verdict"] == "to_scale"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("floor", [0.0, -5.0, float("nan"), float("inf")])
|
||||
def test_a_floor_that_is_not_a_positive_number_is_refused(floor):
|
||||
with pytest.raises(ValueError, match="min_pair_mm: must be a positive number"):
|
||||
scale_check(snapshot(tags(PLAN, "A")), snapshot(tags(PLAN, "B")), min_pair_mm=floor)
|
||||
|
||||
|
||||
def test_an_unknown_cluster_is_refused_with_the_ids_the_drawing_has():
|
||||
with pytest.raises(ValueError, match=r"cluster_b: no cluster 'C99'"):
|
||||
match_tags(snapshot(tags(PLAN, "A")), snapshot(tags(PLAN, "B")), cluster_b="C99")
|
||||
|
||||
|
||||
def test_the_synthetic_pid_is_schematic_against_its_layout(tmp_path):
|
||||
truth = build_plant_pair(tmp_path)
|
||||
result = scale_check(read_snapshot(truth["pid"]), read_snapshot(truth["layout"]))
|
||||
assert result["verdict"] == truth["scale_verdict"]
|
||||
assert len(result["matched"]) >= 3
|
||||
# The layout's two plan copies: the check must pick one of them.
|
||||
assert result["scope"]["cluster_b"] is not None
|
||||
@@ -0,0 +1,100 @@
|
||||
"""drawing_scale_check over the MCP wire, on a fresh headless document."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ezdxf
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastmcp import Client
|
||||
|
||||
import server
|
||||
from tests.fixtures.plant_pair import build_plant_pair
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
#: Six tags of one plant in millimetres; the closest pair is 7 211 apart.
|
||||
PLAN = {
|
||||
"T4100": (0.0, 0.0),
|
||||
"T4200": (12000.0, 0.0),
|
||||
"T4300": (12000.0, 9000.0),
|
||||
"T4400": (0.0, 9000.0),
|
||||
"T4500": (6000.0, 4000.0),
|
||||
"T4600": (20000.0, 15000.0),
|
||||
}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(monkeypatch):
|
||||
"""In-memory client on a fresh headless document - never the operator's AutoCAD."""
|
||||
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
|
||||
async with Client(server.mcp) as connected:
|
||||
status = (await connected.call_tool("system_status", {})).structured_content or {}
|
||||
assert status.get("backend") == "ezdxf", "these tests must never touch live AutoCAD"
|
||||
await connected.call_tool("drawing_new", {})
|
||||
yield connected
|
||||
|
||||
|
||||
def reference_file(tmp_path):
|
||||
"""The plan's tags at full size in a millimetre DXF (INSUNITS 4)."""
|
||||
doc = ezdxf.new(dxfversion="R2010", units=4)
|
||||
msp = doc.modelspace()
|
||||
for tag, (x, y) in PLAN.items():
|
||||
msp.add_text(tag, height=250.0, dxfattribs={"insert": (x, y), "layer": "TAGS"})
|
||||
path = tmp_path / "reference.dxf"
|
||||
doc.saveas(path)
|
||||
return str(path)
|
||||
|
||||
|
||||
async def test_the_synthetic_pid_is_schematic_against_its_layout(client, tmp_path):
|
||||
truth = build_plant_pair(tmp_path)
|
||||
result = await client.call_tool(
|
||||
"drawing_scale_check", {"path": truth["pid"], "reference": truth["layout"]}
|
||||
)
|
||||
assert result.structured_content["verdict"] == truth["scale_verdict"]
|
||||
|
||||
|
||||
async def test_the_current_document_at_half_scale_is_to_scale_with_factor_one_half(
|
||||
client, tmp_path
|
||||
):
|
||||
# The same tags at half the coordinates in the current document. Its
|
||||
# header says metres (drawing_new keeps ezdxf's default INSUNITS 6), but
|
||||
# 250-unit text and a 10 000-unit spread read as millimetres, and the
|
||||
# check converts by the inferred unit: the factor stays 0.5.
|
||||
for tag, (x, y) in PLAN.items():
|
||||
await client.call_tool(
|
||||
"entity_create_text",
|
||||
{"text": tag, "x": x * 0.5, "y": y * 0.5, "height": 250.0, "layer": "TAGS"},
|
||||
)
|
||||
before = (await client.call_tool("analysis_entity_stats", {})).structured_content
|
||||
result = await client.call_tool("drawing_scale_check", {"reference": reference_file(tmp_path)})
|
||||
after = (await client.call_tool("analysis_entity_stats", {})).structured_content
|
||||
assert after == before
|
||||
report = result.structured_content
|
||||
assert report["units"]["a"]["unit"] == report["units"]["b"]["unit"] == "mm"
|
||||
assert report["verdict"] == "to_scale"
|
||||
assert report["factor"] == pytest.approx(0.5, abs=1e-12)
|
||||
assert report["pair_count"] == 15
|
||||
assert report["ambiguous"] == []
|
||||
|
||||
|
||||
async def test_a_missing_reference_is_refused_by_name(client, tmp_path):
|
||||
result = await client.call_tool(
|
||||
"drawing_scale_check", {"reference": str(tmp_path / "none.dxf")}, raise_on_error=False
|
||||
)
|
||||
assert result.is_error is True
|
||||
assert "reference: no such file" in result.content[0].text
|
||||
|
||||
|
||||
async def test_a_non_positive_floor_is_refused(client, tmp_path):
|
||||
result = await client.call_tool(
|
||||
"drawing_scale_check",
|
||||
{"reference": reference_file(tmp_path), "min_pair_mm": 0.0},
|
||||
raise_on_error=False,
|
||||
)
|
||||
assert result.is_error is True
|
||||
assert "min_pair_mm" in result.content[0].text
|
||||
|
||||
|
||||
async def test_drawing_scale_check_is_advertised_read_only(client):
|
||||
tools = {tool.name: tool for tool in await client.list_tools()}
|
||||
assert tools["drawing_scale_check"].annotations.readOnlyHint is True
|
||||
Reference in New Issue
Block a user