The takeoffs were run again on a real P&ID pair (kept outside the repository)
and compared with the hand-made workbooks. The user chose how three gaps
should close:
- Rooms drawn as frames. A room label that no wall face holds takes the
smallest closed, straight-sided polyline around it, provided it holds no
other room number and is at least ten label heights a side (label+frame,
0.8). A P&ID draws its rooms this way, so P&ID lengths are now cut at the
frames rather than sent to the nearest label. An unnumbered mention inside
a frame ('hot room') does not deny it.
- The nearest panel, flagged. cable_takeoff(panel_rule="nearest") is the
default: a load that no callout places takes the nearest panel on the
layout, among names with a panel prefix (is_panel_name: CP, JB, DB,
MCC ...) or names a callout gives. The row is flagged panel_inferred, and
an inferred panel never makes a package. panel_rule="stated" keeps the old
behaviour. The takeoff_roundtrip gate and the cable correctness check read
stated panels only: a guess is no evidence.
- Possible packages and detail copies. pipe_takeoff reports detail_copies:
compact groups of tags drawn twice (repeated), and compact groups of one
equipment's own parts, such as M150B, M150C and M150D of M150 (family). A
vendor package or an equipment detail shows up this way. Nothing is
removed until exclude_regions names a region. The runs left out go to the
control rows as excluded_region and to excluded_m. The workbook's method
sheet names the regions.
Each change has a synthetic test that failed first. Suite: 4986 passed, 1
xfailed. The inventory and the document numbers match (README: 4987
collected).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
350 lines
13 KiB
Python
350 lines
13 KiB
Python
"""drawing_understand's report: the synthetic plant pair, then hand-built snapshots.
|
|
|
|
The plant-pair facts come from the generator's truth dict (Task 2), never from
|
|
a real drawing; the hand-built cases compute their expectations in comments.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
|
|
import pytest
|
|
|
|
from engineering.understand.describe import describe, tag_occurrences, text_language
|
|
from engineering.understand.labels import parse_tag
|
|
from engineering.understand.snapshot import EntityRecord, Snapshot, read_snapshot
|
|
from engineering.understand.vocab import classify_layer, room_label
|
|
from tests.fixtures.plant_pair import build_plant_pair
|
|
|
|
EPS = 1e-9
|
|
|
|
|
|
def shoelace(polygon):
|
|
total = 0.0
|
|
for (x1, y1), (x2, y2) in zip(polygon, polygon[1:] + polygon[:1], strict=True):
|
|
total += x1 * y2 - x2 * y1
|
|
return abs(total) / 2.0
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def plant(tmp_path_factory):
|
|
truth = build_plant_pair(tmp_path_factory.mktemp("plant"))
|
|
return {
|
|
"truth": truth,
|
|
"pid": describe(read_snapshot(truth["pid"])),
|
|
"layout": describe(read_snapshot(truth["layout"])),
|
|
}
|
|
|
|
|
|
# ── the synthetic plant pair ────────────────────────────────────────────────
|
|
|
|
|
|
def test_the_layout_declares_inches_over_millimetre_geometry(plant):
|
|
units = plant["layout"]["units"]
|
|
assert units["declared"]["code"] == plant["truth"]["layout_insunits"]
|
|
assert units["inferred"] == plant["truth"]["layout_true_unit"]
|
|
assert units["agree"] is False
|
|
assert any(w.startswith("INSUNITS declares Inches (1)") for w in plant["layout"]["warnings"])
|
|
|
|
|
|
def test_the_far_outlier_is_named_by_handle(plant):
|
|
outliers = plant["layout"]["extents"]["outliers"]
|
|
assert outliers[0]["handle"] == plant["truth"]["outlier_handle"]
|
|
assert any(plant["truth"]["outlier_handle"] in w for w in plant["layout"]["warnings"])
|
|
|
|
|
|
def test_the_two_plan_copies_are_two_clusters(plant):
|
|
assert len(plant["layout"]["clusters"]) == plant["truth"]["cluster_count"]
|
|
|
|
|
|
def test_service_layers_are_classified(plant):
|
|
rows = {r["name"]: r for r in plant["layout"]["layers"]}
|
|
rows.update({r["name"]: r for r in plant["pid"]["layers"]})
|
|
for layer, service in plant["truth"]["layer_services"].items():
|
|
assert rows[layer]["service"] == service, layer
|
|
assert rows[layer]["confidence"] > 0.0, layer
|
|
|
|
|
|
def test_every_equipment_tag_of_the_truth_is_found_on_the_pid(plant):
|
|
found = {t["tag"] for t in plant["pid"]["equipment_tags"]}
|
|
expected = {tag for run in plant["truth"]["pipe_runs"] for tag in run["tags"]}
|
|
expected |= {row["tag"] for row in plant["truth"]["cable_rows"]}
|
|
assert expected <= found
|
|
|
|
|
|
def test_layout_rooms_carry_the_truth_numbers_names_and_measured_faces(plant):
|
|
for room in plant["truth"]["rooms"]:
|
|
rows = [
|
|
r
|
|
for r in plant["layout"]["rooms"]
|
|
if r["number"] == room["number"] and r["name"] == room["name"]
|
|
]
|
|
assert rows, room["number"]
|
|
expected = shoelace([tuple(p) for p in room["polygon"]])
|
|
for row in rows:
|
|
assert row["face"] is not None, room["number"]
|
|
assert abs(row["face"]["area"] - expected) <= 1e-9 * expected, room["number"]
|
|
assert row["confidence"] == 0.9
|
|
|
|
|
|
def test_the_pid_texts_are_read_as_cyrillic(plant):
|
|
assert plant["pid"]["languages"]["by_class"]["cyrillic"] > 0
|
|
|
|
|
|
# ── hand-built snapshots ────────────────────────────────────────────────────
|
|
|
|
|
|
def line(handle, a, b, layer="WALL", space="Model"):
|
|
return EntityRecord(handle=handle, type="LINE", layer=layer, space=space, points=(a, b))
|
|
|
|
|
|
def text(handle, value, x, y, layer="TEXT", height=250.0):
|
|
return EntityRecord(
|
|
handle=handle,
|
|
type="TEXT",
|
|
layer=layer,
|
|
space="Model",
|
|
points=((x, y),),
|
|
text=value,
|
|
height=height,
|
|
)
|
|
|
|
|
|
def insert(handle, block, x, y, attribs=(), space="Model"):
|
|
return EntityRecord(
|
|
handle=handle,
|
|
type="INSERT",
|
|
layer="0",
|
|
space=space,
|
|
points=((x, y),),
|
|
block=block,
|
|
attribs=tuple(attribs),
|
|
)
|
|
|
|
|
|
def rect(prefix, x0, y0, x1, y1, layer="WALL"):
|
|
return [
|
|
line(f"{prefix}1", (x0, y0), (x1, y0), layer),
|
|
line(f"{prefix}2", (x1, y0), (x1, y1), layer),
|
|
line(f"{prefix}3", (x1, y1), (x0, y1), layer),
|
|
line(f"{prefix}4", (x0, y1), (x0, y0), layer),
|
|
]
|
|
|
|
|
|
def snapshot(records, insunits=4, layers=None, layouts=("Model",)):
|
|
return Snapshot(
|
|
source="test",
|
|
insunits=insunits,
|
|
extmin=None,
|
|
extmax=None,
|
|
layers=layers or {},
|
|
layouts=layouts,
|
|
records=tuple(records),
|
|
)
|
|
|
|
|
|
def test_a_room_label_inside_the_wall_faces_is_measured():
|
|
assert classify_layer("WALL")["discipline"] == "architecture"
|
|
parsed = room_label("ROOM 101 PROCESS")
|
|
assert parsed is not None
|
|
# Inner face (200,200)-(6200,4200): 6000 x 4000 = 24 000 000 unit^2.
|
|
records = [
|
|
*rect("O", 0.0, 0.0, 6400.0, 4400.0),
|
|
*rect("I", 200.0, 200.0, 6200.0, 4200.0),
|
|
text("R", "ROOM 101 PROCESS", 1000.0, 2000.0),
|
|
]
|
|
report = describe(snapshot(records))
|
|
(room,) = report["rooms"]
|
|
assert room["number"] == parsed["number"]
|
|
assert room["name"] == parsed["name"]
|
|
assert room["face"]["area"] == pytest.approx(24_000_000.0, abs=EPS)
|
|
assert room["face"]["polygon"] == [
|
|
[200.0, 200.0],
|
|
[6200.0, 200.0],
|
|
[6200.0, 4200.0],
|
|
[200.0, 4200.0],
|
|
]
|
|
assert room["source"] == "label+face"
|
|
assert room["confidence"] == 0.9
|
|
# The wall ring between the outlines is the one unlabelled face.
|
|
assert report["room_faces"]["faces"] == 2
|
|
assert report["room_faces"]["unlabelled"] == 1
|
|
|
|
|
|
def frame(handle, x0, y0, x1, y1, layer="0"):
|
|
return EntityRecord(
|
|
handle=handle,
|
|
type="LWPOLYLINE",
|
|
layer=layer,
|
|
space="Model",
|
|
points=((x0, y0), (x1, y0), (x1, y1), (x0, y1)),
|
|
closed=True,
|
|
bbox=(x0, y0, x1, y1),
|
|
)
|
|
|
|
|
|
def test_a_room_drawn_as_a_frame_is_measured_by_its_frame():
|
|
# A P&ID draws its rooms as closed frames on no wall layer. Each numbered
|
|
# label takes the smallest frame around it that holds no other room label;
|
|
# the sheet border (both rooms) and a box drawn tight around a label (under
|
|
# ten text heights a side) are not rooms.
|
|
records = [
|
|
frame("B", 0.0, 0.0, 20000.0, 10000.0), # the border: both labels inside
|
|
frame("F1", 1000.0, 1000.0, 9000.0, 9000.0), # room 1: 8000 x 8000
|
|
frame("F2", 11000.0, 1000.0, 19000.0, 7000.0), # room 2: 8000 x 6000
|
|
frame("K", 1900.0, 7900.0, 4100.0, 8400.0), # a box around room 1's label
|
|
text("R1", "ROOM 1", 2000.0, 8000.0),
|
|
text("R2", "ROOM 2", 12000.0, 6000.0),
|
|
# an unnumbered mention inside room 2 ('hot room') does not deny its frame
|
|
text("H", "HOT ROOM", 15000.0, 3000.0),
|
|
]
|
|
report = describe(snapshot(records))
|
|
rooms = {room["number"]: room for room in report["rooms"]}
|
|
assert rooms["1"]["source"] == rooms["2"]["source"] == "label+frame"
|
|
assert rooms["1"]["face"]["area"] == pytest.approx(64_000_000.0, abs=EPS)
|
|
assert rooms["2"]["face"]["area"] == pytest.approx(48_000_000.0, abs=EPS)
|
|
# a frame is weaker evidence than a face of wall layers
|
|
assert rooms["1"]["confidence"] == rooms["2"]["confidence"] == 0.8
|
|
|
|
|
|
def test_a_cabinet_label_naming_its_room_is_not_a_room():
|
|
# 'CONTROL CABINET / FILLING ROOM' labels the cabinet, not a room: it names
|
|
# equipment and carries no room number. A numbered room named after its
|
|
# equipment ('PUMP ROOM 3') is still a room.
|
|
records = [
|
|
text("C", "CONTROL CABINET FILLING ROOM", 1000.0, 1000.0),
|
|
text("P", "PUMP ROOM 3", 5000.0, 1000.0),
|
|
]
|
|
report = describe(snapshot(records))
|
|
assert [(room["number"], room["name"]) for room in report["rooms"]] == [("3", "PUMP")]
|
|
|
|
|
|
def test_an_attribute_and_a_text_of_one_tag_are_one_occurrence_and_a_copy_is_two():
|
|
assert parse_tag("T4100") == "T4100"
|
|
records = [
|
|
line("L", (0.0, 0.0), (20000.0, 0.0), "0"),
|
|
insert("B1", "TANK", 1000.0, 0.0, attribs=[("TAG", "T4100")]),
|
|
# 150 units away: inside 1 % of the 20 000 diagonal (200), so the same one.
|
|
text("X1", "T4100", 1150.0, 0.0),
|
|
# 18 000 away: a second occurrence.
|
|
text("X2", "T4100", 19000.0, 0.0),
|
|
]
|
|
snap = snapshot(records)
|
|
occurrences = tag_occurrences(snap)
|
|
assert [(o["handle"], o["source"]) for o in occurrences] == [
|
|
("B1", "attribute"),
|
|
("X2", "text"),
|
|
]
|
|
report = describe(snap)
|
|
(row,) = report["equipment_tags"]
|
|
assert row["tag"] == "T4100"
|
|
assert row["count"] == 2
|
|
assert row["ambiguous"] is True
|
|
assert row["confidence"] == 0.95
|
|
assert any("appear more than once" in w for w in report["warnings"])
|
|
|
|
|
|
def test_text_languages_are_counted_by_character_class():
|
|
assert text_language("ПОДАЧА CIP") == "cyrillic"
|
|
assert text_language("DÖNÜŞ") == "turkish"
|
|
assert text_language("DÖNÜS") == "latin" # ö and ü alone are German too
|
|
assert text_language("SUPPLY") == "latin"
|
|
assert text_language("3,5") == "none"
|
|
records = [
|
|
text("A", "ПОДАЧА", 0.0, 0.0),
|
|
text("B", "BESLEME DÖNÜŞ", 0.0, 500.0),
|
|
text("C", "SUPPLY", 0.0, 1000.0),
|
|
text("D", "12", 0.0, 1500.0),
|
|
]
|
|
languages = describe(snapshot(records))["languages"]
|
|
assert languages["by_class"] == {"latin": 1, "turkish": 1, "cyrillic": 1, "none": 1}
|
|
assert languages["share"]["cyrillic"] == 0.25
|
|
assert languages["samples"]["cyrillic"] == ["A"]
|
|
|
|
|
|
def test_blocks_are_split_named_and_anonymous_with_kinds_by_name():
|
|
records = [
|
|
insert("A1", "*U12", 0.0, 0.0),
|
|
insert("A2", "*U12", 100.0, 0.0),
|
|
insert("G1", "GATE_VALVE", 200.0, 0.0),
|
|
insert("G2", "GATE_VALVE", 300.0, 0.0),
|
|
insert("G3", "GATE_VALVE", 400.0, 0.0),
|
|
insert("P1", "PUMP_A", 500.0, 0.0),
|
|
]
|
|
blocks = describe(snapshot(records))["blocks"]
|
|
assert blocks["inserts"] == 6
|
|
assert [(r["name"], r["inserts"], r["kind"]) for r in blocks["named"]] == [
|
|
("GATE_VALVE", 3, "valve"),
|
|
("PUMP_A", 1, "equipment"),
|
|
]
|
|
assert blocks["anonymous"] == {"names": 1, "inserts": 2}
|
|
assert blocks["kinds"] == {"equipment": 1, "valve": 1}
|
|
|
|
|
|
def test_the_title_block_is_the_named_paper_space_block_with_its_attributes():
|
|
title = insert(
|
|
"TB",
|
|
"A1_TITLE",
|
|
0.0,
|
|
0.0,
|
|
attribs=[("DWG_NO", "001"), ("REV", "B"), ("SCALE", "1:100"), ("DATE", "2026-09-24")],
|
|
space="Layout1",
|
|
)
|
|
other = insert(
|
|
"M",
|
|
"NOTE_BOX",
|
|
0.0,
|
|
0.0,
|
|
attribs=[("A", "1"), ("B", "2"), ("C", "3"), ("D", "4"), ("E", "5")],
|
|
)
|
|
report = describe(snapshot([title, other], layouts=("Model", "Layout1")))
|
|
block = report["title_block"]
|
|
assert block["found"] is True
|
|
assert block["handle"] == "TB"
|
|
assert block["attributes"] == {
|
|
"DWG_NO": "001",
|
|
"REV": "B",
|
|
"SCALE": "1:100",
|
|
"DATE": "2026-09-24",
|
|
}
|
|
assert block["confidence"] == 0.9
|
|
assert report["layouts"] == [
|
|
{"name": "Model", "entities": 1},
|
|
{"name": "Layout1", "entities": 1},
|
|
]
|
|
|
|
|
|
def test_no_title_block_is_said_not_invented():
|
|
block = describe(snapshot([insert("M", "X", 0.0, 0.0, attribs=[("A", "1")])]))["title_block"]
|
|
assert block == {"found": False, "reason": "no block reference with three or more attributes"}
|
|
|
|
|
|
def test_a_layer_without_a_keyword_is_classified_by_its_texts():
|
|
expected = classify_layer("CIP SUPPLY")
|
|
assert expected["service"] != "unknown"
|
|
assert classify_layer("L-17")["service"] == "unknown"
|
|
assert classify_layer("L-17")["discipline"] == "unknown"
|
|
records = [
|
|
line("P1", (0.0, 0.0), (3000.0, 0.0), "L-17"),
|
|
line("P2", (3000.0, 0.0), (3000.0, 4000.0), "L-17"),
|
|
text("T1", "CIP SUPPLY", 1000.0, 100.0, layer="L-17"),
|
|
text("T2", "CIP SUPPLY", 3100.0, 2000.0, layer="L-17"),
|
|
]
|
|
layers = {r["name"]: r for r in describe(snapshot(records))["layers"]}
|
|
row = layers["L-17"]
|
|
assert row["service"] == expected["service"]
|
|
assert row["discipline"] == expected["discipline"]
|
|
assert row["confidence"] == 0.6
|
|
assert row["entities"] == 4
|
|
assert row["length"] == pytest.approx(7000.0, abs=EPS) # 3000 + 4000
|
|
assert row["length_m"] == pytest.approx(7.0, abs=EPS)
|
|
assert row["evidence"].startswith("2 of 2 texts on the layer read as")
|
|
|
|
|
|
def test_describe_leaves_the_snapshot_as_it_was():
|
|
records = [*rect("O", 0.0, 0.0, 6400.0, 4400.0), text("R", "T4100", 100.0, 100.0)]
|
|
snap = snapshot(records)
|
|
before = copy.deepcopy(snap)
|
|
describe(snap)
|
|
assert snap == before
|