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:
co-authored by
Claude Opus 5.5
parent
db5b4d3ef8
commit
554daff8d0
@@ -192,6 +192,34 @@ def test_without_a_layout_lengths_stay_empty_with_an_open_item(pair):
|
||||
assert all(row["panel"] is None for row in result["rows"])
|
||||
|
||||
|
||||
def test_a_load_no_callout_places_takes_the_nearest_panel_flagged(pair):
|
||||
# M82's 'Wiring to CP1' callout is gone. By default the nearest panel on the
|
||||
# layout is taken - CP-1 at hypot(13000, 2000) = 13.2 m, not CP-M88 at 23.1 m
|
||||
# - and the row says so; with panel_rule='stated' the panel stays open.
|
||||
pid, _layout = pair
|
||||
layout = snap([r for r in layout_records() if r.handle != "wM82"], 4, "layout.dxf")
|
||||
result = cable_rows(pid, layout)
|
||||
m82 = by_tag(result)["M82"]
|
||||
assert (m82["panel"], m82["panel_source"]) == ("CP-1", "nearest")
|
||||
assert m82["length_m"] * 1000.0 == pytest.approx(15000.0, abs=1e-6) # Manhattan
|
||||
assert m82["cable_m"] == 18
|
||||
assert {
|
||||
"tag": "M82",
|
||||
"item": "panel_inferred",
|
||||
"detail": "no wiring callout names a panel; CP-1 is the nearest panel on the layout, "
|
||||
"13.2 m away",
|
||||
} in result["open_items"]
|
||||
assert ("M82", "missing_panel") not in {(i["tag"], i["item"]) for i in result["open_items"]}
|
||||
assert result["method"]["panel_rule"] == "nearest"
|
||||
stated = cable_rows(pid, layout, panel_rule="stated")
|
||||
assert by_tag(stated)["M82"]["panel"] is None
|
||||
assert ("M82", "missing_panel") in {(i["tag"], i["item"]) for i in stated["open_items"]}
|
||||
with pytest.raises(
|
||||
ValueError, match="^panel_rule: 'closest' unknown; choose from nearest, stated"
|
||||
):
|
||||
cable_rows(pid, layout, panel_rule="closest")
|
||||
|
||||
|
||||
def test_a_tag_missing_from_the_layout_is_an_open_item(pair):
|
||||
pid, _layout = pair
|
||||
layout = snap(layout_records(skip=("M87",)), 4, "layout.dxf")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,6 +11,7 @@ import pytest
|
||||
|
||||
from engineering.understand.labels import (
|
||||
CYRILLIC_LOOKALIKES,
|
||||
is_panel_name,
|
||||
normalize_panel,
|
||||
parse_diameters,
|
||||
parse_electrical,
|
||||
@@ -212,6 +213,15 @@ def test_wiring_callouts_in_four_languages():
|
||||
assert wiring_target("CP 2 panosuna") == "CP-2"
|
||||
|
||||
|
||||
def test_a_panel_name_is_told_by_its_letters():
|
||||
# normalize_panel reads any letters-and-digits token; only a panel prefix
|
||||
# (control panel, junction box, distribution board, MCC ...) makes a panel
|
||||
assert is_panel_name("CP-1") and is_panel_name("JB-2") and is_panel_name("CP-M82")
|
||||
assert is_panel_name("MCC-3") and is_panel_name("DB-12")
|
||||
assert not is_panel_name("M-87") and not is_panel_name("T-4100")
|
||||
assert not is_panel_name(None) and not is_panel_name("")
|
||||
|
||||
|
||||
def test_the_russian_to_before_a_panel_is_a_wiring_callout():
|
||||
# A Russian P&ID closes a load's text with the panel it is wired to: 'к CP1'
|
||||
# ('to CP1'), or 'к щиту CP-2' ('to switchboard CP-2'). The unit кВт holds
|
||||
|
||||
@@ -305,6 +305,69 @@ def test_without_room_faces_a_piece_goes_to_the_nearest_numbered_room():
|
||||
]
|
||||
|
||||
|
||||
def detail_pid():
|
||||
# three tanks spread over the plant, and a compact detail drawing of the same
|
||||
# three tanks at x = 60 m: the detail repeats their pipes
|
||||
return [
|
||||
*tank("A", "T4100", 0.0, 0.0),
|
||||
*tank("B", "T4101", 20000.0, 0.0),
|
||||
*tank("C", "T4102", 20000.0, 20000.0),
|
||||
rec("L1", "LINE", PIPE, ((500.0, 250.0), (20000.0, 250.0))), # 19.5 m
|
||||
rec("L2", "LINE", PIPE, ((20250.0, 500.0), (20250.0, 20000.0))), # 19.5 m
|
||||
*tank("a", "T4100", 60000.0, 0.0),
|
||||
*tank("b", "T4101", 61000.0, 0.0),
|
||||
*tank("c", "T4102", 61000.0, 1000.0),
|
||||
rec("L3", "LINE", PIPE, ((60500.0, 250.0), (61000.0, 250.0))), # 0.5 m
|
||||
rec("L4", "LINE", PIPE, ((61250.0, 500.0), (61250.0, 1000.0))), # 0.5 m
|
||||
text("D1", "Ø51", (10000.0, 260.0), 50.0),
|
||||
]
|
||||
|
||||
|
||||
def test_a_detail_drawn_twice_is_reported_and_left_out_on_request():
|
||||
drawing = snap(detail_pid(), 4, "pid.dxf")
|
||||
network = build_network(drawing, layers=[PIPE])
|
||||
counted = pipe_rows(network, drawing, None, services=["product"])
|
||||
(region,) = counted["detail_copies"]
|
||||
assert (region["id"], region["tags"]) == ("D1", ["T4100", "T4101", "T4102"])
|
||||
assert (region["kind"], region["base"]) == ("repeated", None)
|
||||
assert region["box"][0] > 55000.0 # the compact copy, not the spread-out plant
|
||||
assert counted["totals"]["net_m"] == pytest.approx(40.0, abs=EPS) # 19.5 + 19.5 + 0.5 + 0.5
|
||||
assert any("possible detail cop" in w and "D1" in w for w in counted["warnings"])
|
||||
left_out = pipe_rows(network, drawing, None, services=["product"], exclude_regions=["D1"])
|
||||
assert left_out["totals"]["net_m"] == pytest.approx(39.0, abs=EPS)
|
||||
assert left_out["totals"]["excluded_m"] == pytest.approx(1.0, abs=EPS)
|
||||
excluded = [c for c in left_out["control"] if c["kind"] == "excluded_region"]
|
||||
assert {c["reason"] for c in excluded} == {"D1"} and len(excluded) == 2
|
||||
with pytest.raises(ValueError, match="^exclude_regions: 'D9' unknown; the P&ID has D1"):
|
||||
pipe_rows(network, drawing, None, services=["product"], exclude_regions=["D9"])
|
||||
|
||||
|
||||
def test_a_package_of_one_equipments_parts_is_reported_as_a_region():
|
||||
# A skid drawn in detail: P200's own parts P200A / P200B / P200C sit close
|
||||
# together at x = 60 m, far from the plant's tanks. Their tags are written
|
||||
# once each - no copy - but the family is one package whose internal pipes
|
||||
# a site takeoff may leave to its vendor: reported, left out on request.
|
||||
records = [
|
||||
*tank("A", "T4100", 0.0, 0.0),
|
||||
*tank("B", "T4101", 20000.0, 0.0),
|
||||
rec("L1", "LINE", PIPE, ((500.0, 250.0), (20000.0, 250.0))), # 19.5 m
|
||||
*tank("p", "P200A", 60000.0, 0.0),
|
||||
*tank("q", "P200B", 61000.0, 0.0),
|
||||
*tank("r", "P200C", 61000.0, 1000.0),
|
||||
rec("L3", "LINE", PIPE, ((60500.0, 250.0), (61000.0, 250.0))), # 0.5 m
|
||||
rec("L4", "LINE", PIPE, ((61250.0, 500.0), (61250.0, 1000.0))), # 0.5 m
|
||||
]
|
||||
drawing = snap(records, 4, "pid.dxf")
|
||||
network = build_network(drawing, layers=[PIPE])
|
||||
counted = pipe_rows(network, drawing, None, services=["product"])
|
||||
(region,) = counted["detail_copies"]
|
||||
assert (region["kind"], region["base"]) == ("family", "P200")
|
||||
assert region["tags"] == ["P200A", "P200B", "P200C"]
|
||||
left_out = pipe_rows(network, drawing, None, services=["product"], exclude_regions=["D1"])
|
||||
assert left_out["totals"]["net_m"] == pytest.approx(19.5, abs=EPS)
|
||||
assert left_out["totals"]["excluded_m"] == pytest.approx(1.0, abs=EPS)
|
||||
|
||||
|
||||
def test_other_services_are_left_out_unless_asked(pair):
|
||||
pid, layout, network = pair
|
||||
assert DEFAULT_SERVICES == ("product", "cip_supply", "cip_return")
|
||||
|
||||
Reference in New Issue
Block a user