refactor(understand): one reading of units, plan copies, scale and rooms

Groups U and N were built side by side, so the takeoffs carried narrow
stand-ins for what group U reads properly. On main they read through U:

- describe.drawing_units is the one call every reader makes (kept records,
  labelled room faces as evidence); describe, scale_check and the takeoffs
  all use it. Without the room faces, a layout that declares inches over
  millimetres can tie three ways and fall to the header.
- layout_tags takes its plan copies from describe.prepare, room_regions its
  rooms from describe.find_rooms, and pipe_rows its scale from scale_check,
  per-room groups included. scale_stand_in and the takeoff's own unit bands
  and cluster grid are gone.
- test_understand_one_reading pins the agreement: one unit, one plan copy,
  one verdict and one set of rooms per drawing, across drawing_understand,
  drawing_scale_check and the two takeoffs.

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:
Umut CAN
2026-09-24 14:45:27 +03:00
co-authored by Claude Opus 5.5
parent c4759e696d
commit 8c1a6cb27c
6 changed files with 244 additions and 347 deletions
+132
View File
@@ -0,0 +1,132 @@
"""One reading of a drawing: the takeoffs see what drawing_understand and drawing_scale_check see.
Groups U and N were built side by side from one base, so Task 6 carried narrow
stand-ins: its own unit bands, a 2 % grid of plan copies, a scale statistic
without room groups, its own room faces. On main the takeoffs read units, plan
copies, scale and rooms through group U's readers, so the four tools never
give one drawing two units, two plan copies, two verdicts or two sets of rooms.
"""
from __future__ import annotations
import pytest
from engineering.understand import takeoff
from engineering.understand.describe import describe, drawing_units, prepare
from engineering.understand.network import build_network
from engineering.understand.scale import scale_check
from engineering.understand.snapshot import Snapshot, read_snapshot
from tests.fixtures.plant_pair import build_plant_pair
@pytest.fixture(scope="module")
def plant(tmp_path_factory):
truth = build_plant_pair(tmp_path_factory.mktemp("one_reading"))
return truth, read_snapshot(truth["pid"]), read_snapshot(truth["layout"])
def empty(insunits):
return Snapshot(
source="empty.dxf",
insunits=insunits,
extmin=None,
extmax=None,
layers={},
layouts=(),
records=(),
)
def test_one_unit_per_drawing(plant):
_truth, pid, layout = plant
check = scale_check(pid, layout)
for snap, side in ((pid, "a"), (layout, "b")):
unit = takeoff.unit_of(snap)
assert unit["inferred"] == describe(snap)["units"]["inferred"]
assert unit["inferred"] == check["units"][side]["unit"]
assert takeoff.unit_of(layout)["inferred"] == "mm" # INSUNITS says inches: Review Focus 5
def test_the_takeoff_unit_carries_drawing_units_evidence_and_warning(plant):
_truth, pid, layout = plant
for snap in (pid, layout):
mine, theirs = takeoff.unit_of(snap), drawing_units(snap)
assert mine["declared"] == theirs["declared"]["name"]
assert mine["m_per_unit"] == pytest.approx(theirs["mm_per_unit"] / 1000.0)
assert mine["evidence"] == theirs["evidence"]
expected = f"{snap.source}: {theirs['warning']}" if theirs["warning"] else None
assert mine["warning"] == expected
def test_a_unit_nothing_measures_keeps_the_declared_one():
unit = takeoff.unit_of(empty(5))
assert (unit["declared"], unit["inferred"], unit["m_per_unit"], unit["warning"]) == (
"cm",
"cm",
0.01,
None,
)
def test_a_unitless_drawing_nothing_measures_is_read_in_mm_and_says_so():
unit = takeoff.unit_of(empty(0))
assert (unit["declared"], unit["inferred"], unit["m_per_unit"]) == (None, "mm", 0.001)
assert unit["warning"] == (
"empty.dxf: INSUNITS declares Unitless (0) and no measurement settles the unit; "
"the takeoff reads it as mm."
)
def test_plan_copies_are_the_clusters_drawing_understand_reports(plant):
truth, _pid, layout = plant
found = takeoff.layout_tags(layout, truth["tag_positions"]["layout"])
boxes = [tuple(cluster["box"]) for cluster in prepare(layout)["clusters"]]
assert found["clusters"] == len(boxes) == truth["cluster_count"]
assert tuple(found["cluster"]) in boxes
def test_the_takeoff_scale_is_drawing_scale_check(plant):
_truth, pid, layout = plant
result = takeoff.pipe_rows(build_network(pid), pid, layout)
check = scale_check(pid, layout)
for key in ("verdict", "factor", "median", "within_10pct", "pair_count", "groups"):
assert result["scale"][key] == check[key], key
def test_rooms_are_the_rooms_drawing_understand_reports(plant):
truth, _pid, layout = plant
cluster = takeoff.layout_tags(layout, truth["tag_positions"]["layout"])["cluster"]
regions = takeoff.room_regions(layout, cluster)
assert sorted(key for key, _face in regions["faces"]) == sorted(
str(room["number"]) for room in truth["rooms"]
)
reported = {
(str(row["number"] or row["name"]), round(row["face"]["area"], 3))
for row in describe(layout)["rooms"]
if row["face"] is not None
}
for key, face in regions["faces"]:
assert (key, round(face.area, 3)) in reported
def test_no_second_reader_is_left():
for name in (
"scale_stand_in",
"INSUNITS_NAMES",
"TEXT_BAND_M",
"SPAN_BAND_M",
"TYPICAL_SPAN_M",
"TYPICAL_TEXT_M",
"CLUSTER_GAP_SHARE",
"MIN_PAIR_MM",
"MIN_TAGS",
"SCALE_BAND",
"TO_SCALE_SHARE",
"SCHEMATIC_SHARE",
"_model_points",
"_robust_box",
"_text_heights",
"_mark_segment",
"_clusters",
):
assert not hasattr(takeoff, name), name
+1 -1
View File
@@ -213,7 +213,7 @@ def test_the_method_sheet_is_generated_from_the_result(result, tmp_path):
assert method["Units: layout"] == "INSUNITS in → mm"
assert "x first, then y" in method["Rule: route"]
warnings = [row[1] for row in read_csv(files["csv"][3]) if row[0] == "Warning"]
assert any("INSUNITS declares in" in w for w in warnings)
assert any("INSUNITS declares Inches (1)" in w for w in warnings)
def test_kontrol_lists_the_unassigned_diameter(result, tmp_path):
+11 -9
View File
@@ -13,12 +13,12 @@ from __future__ import annotations
import pytest
from engineering.understand.network import build_network
from engineering.understand.scale import scale_check
from engineering.understand.snapshot import EntityRecord, Snapshot, read_snapshot
from engineering.understand.takeoff import (
DEFAULT_SERVICES,
layout_tags,
pipe_rows,
scale_stand_in,
unit_of,
)
from engineering.understand.vocab import classify_layer, room_label
@@ -148,7 +148,9 @@ def test_review_focus_5_inches_declared_over_millimetres(pair):
assert (units["declared"], units["inferred"], units["m_per_unit"]) == ("in", "mm", 0.001)
a = run_by_tags(result, "T4100", "T4101")
assert abs(a["layout_m"] - 28.0) < EPS # not 28000 x 0.0254 = 711.2
assert any("INSUNITS declares in" in w and "as mm" in w for w in result["warnings"])
assert any(
"INSUNITS declares Inches (1)" in w and "reads as mm" in w for w in result["warnings"]
)
assert unit_of(pid)["warning"] is None # the P&ID's mm is what its geometry says
@@ -306,7 +308,7 @@ def test_without_a_layout_auto_measures_the_pid_unverified(pair):
pipe_rows(network, pid, None, length_source="layout")
def test_the_stand_in_recognises_a_uniform_scale_and_a_rearranged_one():
def test_scale_check_recognises_a_uniform_scale_and_a_rearranged_one():
places = {
"T4100": (2000.0, 3000.0),
"T4101": (30000.0, 3000.0),
@@ -320,7 +322,7 @@ def test_the_stand_in_recognises_a_uniform_scale_and_a_rearranged_one():
"l",
)
half = snap([text(t, t, (x * 0.5, y * 0.5), 50.0) for t, (x, y) in places.items()], 4, "p")
uniform = scale_stand_in(half, layout)
uniform = scale_check(half, layout)
assert uniform["verdict"] == "to_scale"
assert uniform["factor"] == pytest.approx(0.5, abs=EPS) # P&ID mm per layout mm
assert uniform["pair_count"] == 6 # four tags, every pair at least 2 m apart
@@ -331,17 +333,17 @@ def test_the_stand_in_recognises_a_uniform_scale_and_a_rearranged_one():
"T4103": (30000.0, 3000.0),
}
other = snap([text(t, t, p, 50.0) for t, p in shuffled.items()], 4, "p")
assert scale_stand_in(other, layout)["verdict"] == "schematic"
assert scale_check(other, layout)["verdict"] == "schematic"
def test_the_stand_in_calls_coinciding_pid_tags_insufficient_not_to_scale():
def test_scale_check_calls_coinciding_pid_tags_insufficient_not_to_scale():
# every P&ID tag at one point: each ratio is 0, the median 0 - a factor of 0
# would divide every length by zero, so the verdict is insufficient (the
# same rule as group U's scale_check: a median that is not positive)
places = {"T4100": (2000.0, 3000.0), "T4101": (30000.0, 3000.0), "T4102": (30000.0, 15000.0)}
layout = snap([text(t, t, p, 250.0) for t, p in places.items()], 4, "l")
pid = snap([text(t, t, (100.0, 100.0), 50.0) for t in places], 4, "p")
result = scale_stand_in(pid, layout)
result = scale_check(pid, layout)
assert result["pair_count"] == 3
assert (result["verdict"], result["factor"]) == ("insufficient", None)
@@ -350,7 +352,7 @@ def test_an_insunits_code_this_reader_does_not_convert_is_named_not_called_none(
drawing = snap(layout_records(), 14, "layout.dxf") # 14 = decimetres
found = unit_of(drawing)
assert (found["declared"], found["inferred"]) == (None, "mm")
assert "INSUNITS declares code 14 (a unit this reader does not convert)" in found["warning"]
assert "INSUNITS declares Decimeters (14) but the geometry reads as mm" in found["warning"]
assert "no unit" not in found["warning"]
@@ -412,7 +414,7 @@ def test_review_focus_5_the_synthetic_layout_is_read_in_millimetres(plant):
result = pipe_rows(network, pid, layout, services=truth_services(truth), length_source="layout")
assert result["units"]["layout"]["inferred"] == truth["layout_true_unit"] == "mm"
assert result["units"]["layout"]["m_per_unit"] == 0.001
assert any("INSUNITS declares in" in w for w in result["warnings"])
assert any("INSUNITS declares Inches (1)" in w for w in result["warnings"])
def test_routed_lengths_land_in_the_synthetic_rooms(plant):