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:
co-authored by
Claude Opus 5.5
parent
c4759e696d
commit
8c1a6cb27c
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user