Files
autocad-mcp-pro/tests/test_understand_scale.py
T
Umut CANandClaude Opus 5.5 7aaead43d9 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
2026-09-24 11:55:57 +03:00

193 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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