feat(understand): room frames, the nearest panel and possible packages — what the field test asked for

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
This commit is contained in:
Umut CAN
2026-09-24 17:18:18 +03:00
co-authored by Claude Opus 5.5
parent db5b4d3ef8
commit 554daff8d0
14 changed files with 589 additions and 18 deletions
+36
View File
@@ -171,6 +171,42 @@ def test_a_room_label_inside_the_wall_faces_is_measured():
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