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
+16
View File
@@ -32,6 +32,7 @@ import re
__all__ = [
"CYRILLIC_LOOKALIKES",
"fold_lookalikes",
"is_panel_name",
"normalize_panel",
"parse_diameters",
"parse_electrical",
@@ -299,6 +300,21 @@ def parse_electrical(text: str | None) -> dict:
}
#: Letter prefixes that name what a load is wired to: control panel, junction
#: box, distribution board, motor control centre, local / motor control panel,
#: power panel, switchboard. A convention list, not a standard.
PANEL_PREFIXES = ("CP", "JB", "DB", "MCC", "LCP", "MCP", "PP", "SB", "SWB")
def is_panel_name(name: str | None) -> bool:
"""A normalised name whose letters say it is a panel: ``CP-1``, ``JB-2``,
``CP-M82``, ``MCC-3`` - not ``M-87``, which `normalize_panel` would also
shape, because any letters-and-digits token has a panel's form."""
if not name:
return False
return str(name).split("-", 1)[0] in PANEL_PREFIXES
def wiring_target(text: str | None) -> str | None:
"""The panel a wiring callout points to, normalised.