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
+22
View File
@@ -401,6 +401,28 @@ member. Spec: `docs/superpowers/specs/2026-09-24-v1.6-understand-design.md`.
naming the room it serves, or a note pointing into a room. An unnumbered naming the room it serves, or a note pointing into a room. An unnumbered
label naming equipment is no room. Without room faces, when the drawing label naming equipment is no room. Without room faces, when the drawing
numbers its rooms, only numbered labels take pieces. numbers its rooms, only numbered labels take pieces.
- The bare Russian "to" before a panel (`к CP1`, `к щиту CP-2`) is a
wiring callout.
- **Track H, what the field test asked for** (each with a synthetic test):
- **Rooms drawn as frames.** A room label no wall face holds takes the
smallest closed, straight-sided polyline around it that holds no other
room number and is at least ten label heights a side (`label+frame`,
confidence 0.8). This is how a P&ID draws its rooms, so P&ID lengths are
cut at the room frames.
- **The nearest panel, flagged.** `cable_takeoff(panel_rule="nearest")`,
the default: a load that no callout places, whose tag is on the layout,
takes the nearest panel there. Only names with a panel prefix
(`is_panel_name`: CP, JB, DB, MCC ...) or names a callout gives are
candidates. The row is flagged `panel_inferred`, and an inferred panel
never makes a package. `panel_rule="stated"` leaves such a load open. The
benchmark and the correctness check read stated panels only.
- **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 pasteurizer drawn in detail, whose internal
pipes its vendor supplies, shows up this way. Nothing is removed until
`exclude_regions=["D2"]` names it. The excluded runs go to the control
rows as `excluded_region` and are totalled in `excluded_m`.
### Changed ### Changed
+1 -1
View File
@@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
</div> </div>
> **v1.5 release snapshot:** 247 tools · 8 resources · 5 prompt templates · 4982 collected tests. > **v1.5 release snapshot:** 247 tools · 8 resources · 5 prompt templates · 4987 collected tests.
> 247 is the **registered** count; a default install advertises 242 over `tools/list`, > 247 is the **registered** count; a default install advertises 242 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority. > because `ENABLE_3D` is unset. `system_about` is the runtime authority.
+3 -1
View File
@@ -837,7 +837,9 @@ class AutoCADMCPProAdapter(BenchmarkAdapter):
if reasons: if reasons:
pipe_mismatch.append({"run": run["id"], "reason": "; ".join(reasons)}) pipe_mismatch.append({"run": run["id"], "reason": "; ".join(reasons)})
cable = {row["tag"]: row for row in cable_rows(pid, layout)["rows"]} # panel_rule="stated": the gate reads what the drawing says; a nearest-panel
# guess is no evidence
cable = {row["tag"]: row for row in cable_rows(pid, layout, panel_rule="stated")["rows"]}
cable_mismatch = [] cable_mismatch = []
for want in truth["cable_rows"]: for want in truth["cable_rows"]:
got = cable.get(want["tag"]) got = cable.get(want["tag"])
+3 -1
View File
@@ -915,7 +915,9 @@ async def cable_takeoff_roundup():
truth = _plant_pair() truth = _plant_pair()
rows = { rows = {
row["tag"]: row row["tag"]: row
for row in cable_rows(read_snapshot(truth["pid"]), read_snapshot(truth["layout"]))["rows"] for row in cable_rows(
read_snapshot(truth["pid"]), read_snapshot(truth["layout"]), panel_rule="stated"
)["rows"]
} }
expected = {"M11": ("CP-1", 9), "T102": ("CP-1", 17), "M12": ("CP-2", 19), "T202": ("CP-2", 10)} expected = {"M11": ("CP-1", 9), "T102": ("CP-1", 17), "M12": ("CP-2", 19), "T202": ("CP-2", 10)}
for tag, (panel, metres) in expected.items(): for tag, (panel, metres) in expected.items():
+91 -8
View File
@@ -52,6 +52,11 @@ ATTRIBUTE_TAG_CONFIDENCE = 0.95
TEXT_TAG_CONFIDENCE = 0.8 TEXT_TAG_CONFIDENCE = 0.8
ROOM_FACE_CONFIDENCE = 0.9 ROOM_FACE_CONFIDENCE = 0.9
ROOM_LABEL_CONFIDENCE = 0.6 ROOM_LABEL_CONFIDENCE = 0.6
#: A room read from a closed frame around its label: weaker than wall faces.
ROOM_FRAME_CONFIDENCE = 0.8
#: A frame is a room only when each side is at least this many label text
#: heights long: a box drawn tight around the label is no room.
FRAME_MIN_TEXT_HEIGHTS = 10.0
TITLE_NAME_CONFIDENCE = 0.9 TITLE_NAME_CONFIDENCE = 0.9
TITLE_ATTRIBUTE_CONFIDENCE = 0.6 TITLE_ATTRIBUTE_CONFIDENCE = 0.6
#: The face finder pairs every wall segment with every other; above this many #: The face finder pairs every wall segment with every other; above this many
@@ -178,8 +183,12 @@ def find_rooms(records: Sequence[EntityRecord], *, tol: float, membership: dict)
A label is any text `vocab.room_label` recognises. Wall layers are the A label is any text `vocab.room_label` recognises. Wall layers are the
ones `vocab.classify_layer` files under ``architecture``; their straight ones `vocab.classify_layer` files under ``architecture``; their straight
segments go through `engineering.arch.faces.planar_faces` (arcs are left segments go through `engineering.arch.faces.planar_faces` (arcs are left
out and counted). ``labelled`` pairs each room row with its `Face` for a out and counted). A label no wall face holds takes the smallest closed,
caller that needs the geometry; ``rooms`` is JSON-ready. straight-sided polyline around it that holds no other room label and is at
least ``FRAME_MIN_TEXT_HEIGHTS`` label heights a side - how a P&ID draws
its rooms - at ``ROOM_FRAME_CONFIDENCE``. ``labelled`` pairs each room row
with its `Face` for a caller that needs the geometry; ``rooms`` is
JSON-ready.
""" """
labels = [] labels = []
for rec in records: for rec in records:
@@ -205,8 +214,12 @@ def find_rooms(records: Sequence[EntityRecord], *, tol: float, membership: dict)
rooms: list[dict] = [] rooms: list[dict] = []
labelled: list[tuple[dict, Face]] = [] labelled: list[tuple[dict, Face]] = []
used: set[int] = set() used: set[int] = set()
frames = _frames(records)
for rec, parsed, at in labels: for rec, parsed, at in labels:
face = face_containing(faces, at) if faces else None face = face_containing(faces, at) if faces else None
from_frame = None
if face is None and frames:
from_frame = _frame_around(rec, parsed, at, frames, labels)
row = { row = {
"number": parsed.get("number"), "number": parsed.get("number"),
"name": parsed.get("name"), "name": parsed.get("name"),
@@ -221,14 +234,16 @@ def find_rooms(records: Sequence[EntityRecord], *, tol: float, membership: dict)
} }
if face is not None: if face is not None:
used.add(id(face)) used.add(id(face))
chosen = face or from_frame
if chosen is not None:
row["face"] = { row["face"] = {
"area": face.area, "area": chosen.area,
"polygon": [[p[0], p[1]] for p in face.loop], "polygon": [[p[0], p[1]] for p in chosen.loop],
"centroid": [face.centroid[0], face.centroid[1]], "centroid": [chosen.centroid[0], chosen.centroid[1]],
} }
row["source"] = "label+face" row["source"] = "label+face" if face is not None else "label+frame"
row["confidence"] = ROOM_FACE_CONFIDENCE row["confidence"] = ROOM_FACE_CONFIDENCE if face is not None else ROOM_FRAME_CONFIDENCE
labelled.append((row, face)) labelled.append((row, chosen))
rooms.append(row) rooms.append(row)
rooms.sort(key=lambda r: (r["cluster"] or "", r["number"] or "", r["name"] or "", r["at"])) rooms.sort(key=lambda r: (r["cluster"] or "", r["number"] or "", r["name"] or "", r["at"]))
return { return {
@@ -242,6 +257,74 @@ def find_rooms(records: Sequence[EntityRecord], *, tol: float, membership: dict)
} }
def _frames(records: Sequence[EntityRecord]) -> list[tuple[EntityRecord, Face]]:
"""Closed, straight-sided polylines as faces: the frames a room may be drawn as."""
out = []
for rec in records:
if rec.type not in ("LWPOLYLINE", "POLYLINE") or not rec.closed or len(rec.points) < 3:
continue
if any(rec.bulges):
continue
face = _polygon_face(rec.points)
if face is not None:
out.append((rec, face))
return out
def _polygon_face(points) -> Face | None:
pts = [(float(x), float(y)) for x, y in points]
signed = 0.0
cx = cy = 0.0
for (x0, y0), (x1, y1) in zip(pts, pts[1:] + pts[:1], strict=True):
cross = x0 * y1 - x1 * y0
signed += cross
cx += (x0 + x1) * cross
cy += (y0 + y1) * cross
signed /= 2.0
if abs(signed) <= 0.0:
return None
centroid = (cx / (6.0 * signed), cy / (6.0 * signed))
if signed < 0.0:
pts.reverse()
start = min(range(len(pts)), key=lambda i: (pts[i][1], pts[i][0]))
loop = tuple(pts[start:] + pts[:start])
return Face(loop=loop, area=abs(signed), centroid=centroid)
def _frame_around(rec, parsed, at, frames, labels) -> Face | None:
"""The smallest frame around ``at`` that is a room: no label of another room
number in it (an unnumbered mention - 'hot room' - does not count), each side
at least FRAME_MIN_TEXT_HEIGHTS label heights."""
least = FRAME_MIN_TEXT_HEIGHTS * float(rec.height or 0.0)
best = None
for frame_rec, face in frames:
box = frame_rec.bbox or (
min(p[0] for p in face.loop),
min(p[1] for p in face.loop),
max(p[0] for p in face.loop),
max(p[1] for p in face.loop),
)
if not (box[0] <= at[0] <= box[2] and box[1] <= at[1] <= box[3]):
continue
if min(box[2] - box[0], box[3] - box[1]) < least:
continue
if face_containing([face], at) is None:
continue
others = [
other_at
for other, other_parsed, other_at in labels
if other is not rec
and other_parsed["number"]
and other_parsed["number"] != parsed["number"]
and face_containing([face], other_at) is not None
]
if others:
continue
if best is None or face.area < best.area:
best = face
return best
def drawing_units(snap: Snapshot, *, prep: dict | None = None, rooms: dict | None = None) -> dict: def drawing_units(snap: Snapshot, *, prep: dict | None = None, rooms: dict | None = None) -> dict:
"""`units.infer_units` the one way every reader calls it: on the records kept """`units.infer_units` the one way every reader calls it: on the records kept
after the outliers, with the labelled room faces as evidence. after the outliers, with the labelled room faces as evidence.
+14 -1
View File
@@ -16,6 +16,7 @@ from engineering.understand.network import build_network
from engineering.understand.report import check_lang, write_workbook from engineering.understand.report import check_lang, write_workbook
from engineering.understand.snapshot import take_snapshot from engineering.understand.snapshot import take_snapshot
from engineering.understand.takeoff import ( from engineering.understand.takeoff import (
PANEL_RULES,
cable_rows, cable_rows,
check_allowance, check_allowance,
check_pipe_options, check_pipe_options,
@@ -51,6 +52,7 @@ async def run_pipe_takeoff(
force: bool = False, force: bool = False,
layers=None, layers=None,
tol: float | None = None, tol: float | None = None,
exclude_regions=None,
) -> dict: ) -> dict:
"""`pipe_rows` over a P&ID and an optional layout, plus the workbook when `output` is named. """`pipe_rows` over a P&ID and an optional layout, plus the workbook when `output` is named.
@@ -85,6 +87,7 @@ async def run_pipe_takeoff(
vertical_allowance=options["vertical_allowance"], vertical_allowance=options["vertical_allowance"],
length_source=options["length_source"], length_source=options["length_source"],
force=bool(force), force=bool(force),
exclude_regions=tuple(exclude_regions or ()),
) )
result = await asyncio.to_thread(compute) result = await asyncio.to_thread(compute)
@@ -105,15 +108,25 @@ async def run_cable_takeoff(
lang: str = "en", lang: str = "en",
allowance: float = 0.20, allowance: float = 0.20,
section_rules=None, section_rules=None,
panel_rule: str = "nearest",
) -> dict: ) -> dict:
"""`cable_rows` over a P&ID and an optional layout, plus the workbook when `output` is named.""" """`cable_rows` over a P&ID and an optional layout, plus the workbook when `output` is named."""
check_lang(lang) check_lang(lang)
allowance = check_allowance(allowance) allowance = check_allowance(allowance)
rules = check_section_rules(section_rules) rules = check_section_rules(section_rules)
if panel_rule not in PANEL_RULES:
raise ValueError(
f"panel_rule: {panel_rule!r} unknown; choose from {', '.join(PANEL_RULES)}"
)
pid = await take_snapshot(backend, pid_path) pid = await take_snapshot(backend, pid_path)
layout = await take_snapshot(backend, layout_path) if layout_path else None layout = await take_snapshot(backend, layout_path) if layout_path else None
result = await asyncio.to_thread( result = await asyncio.to_thread(
cable_rows, pid, layout, allowance=allowance, section_rules=list(rules) or None cable_rows,
pid,
layout,
allowance=allowance,
section_rules=list(rules) or None,
panel_rule=panel_rule,
) )
result["files"] = ( result["files"] = (
await asyncio.to_thread(write_workbook, result, kind="cable", lang=lang, path=output) await asyncio.to_thread(write_workbook, result, kind="cable", lang=lang, path=output)
+16
View File
@@ -32,6 +32,7 @@ import re
__all__ = [ __all__ = [
"CYRILLIC_LOOKALIKES", "CYRILLIC_LOOKALIKES",
"fold_lookalikes", "fold_lookalikes",
"is_panel_name",
"normalize_panel", "normalize_panel",
"parse_diameters", "parse_diameters",
"parse_electrical", "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: def wiring_target(text: str | None) -> str | None:
"""The panel a wiring callout points to, normalised. """The panel a wiring callout points to, normalised.
+34 -1
View File
@@ -80,6 +80,12 @@ LABELS: dict[str, tuple[str, str, str]] = {
# Kontrol kinds and reasons # Kontrol kinds and reasons
"unassigned_diameter": ("Çap atanmamış", "Diameter unassigned", "Диаметр не назначен"), "unassigned_diameter": ("Çap atanmamış", "Diameter unassigned", "Диаметр не назначен"),
"unroutable": ("Yönlendirilemez", "Unroutable", "Трассировка невозможна"), "unroutable": ("Yönlendirilemez", "Unroutable", "Трассировка невозможна"),
"excluded_region": (
"Detay kopyası (hariç)",
"Detail copy (left out)",
"Копия детали (исключена)",
),
"detail_copies": ("Olası detay kopyaları", "Possible detail copies", "Возможные копии деталей"),
"end_not_on_equipment": ( "end_not_on_equipment": (
"Bir uç ekipmanda değil", "Bir uç ekipmanda değil",
"An end is on no equipment", "An end is on no equipment",
@@ -192,6 +198,7 @@ LABELS: dict[str, tuple[str, str, str]] = {
"rule_rounding": ("Kural: yuvarlama", "Rule: rounding", "Правило: округление"), "rule_rounding": ("Kural: yuvarlama", "Rule: rounding", "Правило: округление"),
"rule_power": ("Kural: güç", "Rule: power", "Правило: мощность"), "rule_power": ("Kural: güç", "Rule: power", "Правило: мощность"),
"rule_panel": ("Kural: pano", "Rule: panel", "Правило: шкаф"), "rule_panel": ("Kural: pano", "Rule: panel", "Правило: шкаф"),
"panel_inferred": ("Pano tahmini (en yakın)", "Panel inferred (nearest)", "Шкаф по близости"),
"rule_package": ("Kural: pano paketleri", "Rule: panel packages", "Правило: комплектные шкафы"), "rule_package": ("Kural: pano paketleri", "Rule: panel packages", "Правило: комплектные шкафы"),
"rule_section": ("Kural: kesit", "Rule: section", "Правило: сечение"), "rule_section": ("Kural: kesit", "Rule: section", "Правило: сечение"),
"section_rules": ( "section_rules": (
@@ -315,6 +322,17 @@ TEXTS: dict[str, tuple[str, str, str]] = {
"Шкаф берётся из выносок подключения на планировке ('Wiring to CP1' и варианты), иначе " "Шкаф берётся из выносок подключения на планировке ('Wiring to CP1' и варианты), иначе "
"из P&ID; имена нормализуются (CP 1 → CP-1).", "из P&ID; имена нормализуются (CP 1 → CP-1).",
), ),
"rule_panel_nearest": (
"Pano, yerleşimdeki bağlantı notlarından, yoksa P&ID'den alınır; hiçbiri yoksa ve yükün "
"etiketi yerleşimde ise yerleşimdeki en yakın pano seçilir ve satır Kontrol sayfasında "
"'pano tahmini' olarak işaretlenir. Adlar normalleştirilir (CP 1 → CP-1).",
"The panel comes from the layout's wiring callouts, else from the P&ID; when neither "
"names one and the load's tag is on the layout, the nearest panel there is taken and the "
"row is flagged 'panel inferred' on the control sheet. Names are normalised (CP 1 → CP-1).",
"Шкаф берётся из выносок подключения на планировке, иначе из P&ID; если нет ни того, ни "
"другого, а тег нагрузки есть на планировке, берётся ближайший шкаф, и строка отмечается "
"на листе контроля как 'шкаф по близости'. Имена нормализуются (CP 1 → CP-1).",
),
"rule_package": ( "rule_package": (
"Pano paketleri: kendisi güç belirten bir panoya bağlı yük o paketin parçasıdır; mahal ve " "Pano paketleri: kendisi güç belirten bir panoya bağlı yük o paketin parçasıdır; mahal ve "
"pano toplamları paketin gücünü sayar, alt yüklerini saymaz - hiçbir güç iki kez " "pano toplamları paketin gücünü sayar, alt yüklerini saymaz - hiçbir güç iki kez "
@@ -526,6 +544,15 @@ def _pipe_method(result: dict, lang: str) -> list[list]:
["tol", method["tol"]], ["tol", method["tol"]],
["label_search", round(float(method["label_search"]), 6)], ["label_search", round(float(method["label_search"]), 6)],
["overlap", _num(method["overlap_length_m"])], ["overlap", _num(method["overlap_length_m"])],
[
"detail_copies",
"; ".join(
f"{region['id']} ({', '.join(region['tags'])})"
+ (" - " + _t("excluded_region", lang) if region["excluded"] else "")
for region in result.get("detail_copies", [])
)
or "—",
],
] ]
rows += [["warning", warning] for warning in result["warnings"]] rows += [["warning", warning] for warning in result["warnings"]]
return [[_t(topic, lang), value] for topic, value in rows] return [[_t(topic, lang), value] for topic, value in rows]
@@ -615,7 +642,13 @@ def _cable_method(result: dict, lang: str) -> list[list]:
["rule_allowance", method["allowance"]], ["rule_allowance", method["allowance"]],
["rule_rounding", _say("rule_rounding", lang)], ["rule_rounding", _say("rule_rounding", lang)],
["rule_power", _say("rule_power", lang)], ["rule_power", _say("rule_power", lang)],
["rule_panel", _say("rule_panel", lang)], [
"rule_panel",
_say(
"rule_panel_nearest" if method.get("panel_rule") == "nearest" else "rule_panel",
lang,
),
],
["rule_package", _say("rule_package", lang)], ["rule_package", _say("rule_package", lang)],
["rule_section", _say("rule_section", lang)], ["rule_section", _say("rule_section", lang)],
["section_rules", "; ".join(_rule_text(rule) for rule in rules) or "—"], ["section_rules", "; ".join(_rule_text(rule) for rule in rules) or "—"],
+247 -4
View File
@@ -27,11 +27,17 @@ drawing.
from __future__ import annotations from __future__ import annotations
import math import math
import re
from collections.abc import Iterable from collections.abc import Iterable
from engineering.arch.faces import face_containing from engineering.arch.faces import face_containing
from engineering.understand.describe import FACE_TOL_FRACTION, drawing_units, find_rooms, prepare from engineering.understand.describe import FACE_TOL_FRACTION, drawing_units, find_rooms, prepare
from engineering.understand.labels import normalize_panel, parse_electrical, wiring_target from engineering.understand.labels import (
is_panel_name,
normalize_panel,
parse_electrical,
wiring_target,
)
from engineering.understand.network import ( from engineering.understand.network import (
MODEL, MODEL,
TEXT_TYPES, TEXT_TYPES,
@@ -371,6 +377,150 @@ def _pid_cells(edges, regions: dict, m_per_unit: float) -> dict:
return cells return cells
#: How a load no callout places gets its panel: the nearest one on the layout
#: (flagged ``panel_inferred``), or none (``missing_panel``).
PANEL_RULES = ("nearest", "stated")
def _nearest_panel(here: Pt, panel_at: dict, *, exclude=None) -> tuple[str, float] | None:
"""(panel, distance in layout units) of the panel nearest ``here``; ties by name."""
candidates = [(math.dist(here, p), name) for name, p in panel_at.items() if name != exclude]
if not candidates:
return None
distance, name = min(candidates)
return name, distance
#: Detail copies: tags drawn twice are grouped when their occurrences lie within
#: this share of the P&ID's robust diagonal of each other (single linkage) ...
DETAIL_LINK_SHARE = 0.03
#: ... and a group is a possible detail copy when it holds at least this many
#: distinct repeated tags, each of which also stands somewhere outside it. The
#: same count of an equipment's own parts (M150 -> M150B, M150C, M150D) standing
#: within DETAIL_FAMILY_SHARE of the diagonal is a possible package or detail.
DETAIL_MIN_TAGS = 3
DETAIL_FAMILY_SHARE = 0.15
_FAMILY_TAG = re.compile(r"^(?P<base>[A-Z]{1,4}-?\d{1,5})(?P<part>[A-Z]{1,2})$")
def detail_copy_regions(pid: Snapshot, *, prep: dict | None = None) -> list[dict]:
"""Where a P&ID may draw pipes twice: ``[{"id", "box", "tags", "kind", "base"}]``.
``repeated``: tags written more than once (readings of one tag closer than
half the link are one) grouped by where they stand; a compact group of at
least ``DETAIL_MIN_TAGS`` such tags, every one also written outside it, is a
possible detail copy. ``family``: at least ``DETAIL_MIN_TAGS`` parts of one
equipment (``M150B``, ``M150C`` ... of ``M150``) standing close together, a
possible package or detail whose internal pipes a site takeoff may leave to
its vendor. Ids run D1, D2 ... from the left. A heuristic that reports and
never removes: the caller decides.
"""
prep = prep or prepare(pid)
link = DETAIL_LINK_SHARE * prep["diag"]
if link <= 0.0:
return []
occurrences = {
tag: [place["at"] for place in places] for tag, places in tag_occurrences(pid).items()
}
points = [
(tag, at)
for tag, places in occurrences.items()
for at in _distinct(places, link / 2.0)
if len(_distinct(places, link / 2.0)) > 1
]
parent = list(range(len(points)))
def find(k: int) -> int:
while parent[k] != k:
parent[k] = parent[parent[k]]
k = parent[k]
return k
grid: dict[tuple[int, int], list[int]] = {}
for k, (_tag, at) in enumerate(points):
grid.setdefault((math.floor(at[0] / link), math.floor(at[1] / link)), []).append(k)
for (cx, cy), members in grid.items():
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
for j in grid.get((cx + dx, cy + dy), ()):
for i in members:
if i < j and math.dist(points[i][1], points[j][1]) <= link:
parent[find(i)] = find(j)
groups: dict[int, list[int]] = {}
for k in range(len(points)):
groups.setdefault(find(k), []).append(k)
total = {}
for tag, _at in points:
total[tag] = total.get(tag, 0) + 1
regions = []
for members in groups.values():
tags = sorted({points[k][0] for k in members})
if len(tags) < DETAIL_MIN_TAGS:
continue
here = {}
for k in members:
here[points[k][0]] = here.get(points[k][0], 0) + 1
if any(here[tag] >= total[tag] for tag in tags):
continue # a tag written only here: this is where it lives, not a copy
xs = [points[k][1][0] for k in members]
ys = [points[k][1][1] for k in members]
regions.append(
{
"box": [min(xs) - link, min(ys) - link, max(xs) + link, max(ys) + link],
"tags": tags,
"kind": "repeated",
"base": None,
}
)
families: dict[str, dict[str, list]] = {}
for tag, places in occurrences.items():
match = _FAMILY_TAG.match(tag)
if match:
families.setdefault(match["base"], {})[tag] = places
for base, parts in sorted(families.items()):
if len(parts) < DETAIL_MIN_TAGS:
continue
xs = [p[0] for places in parts.values() for p in places]
ys = [p[1] for places in parts.values() for p in places]
if math.hypot(max(xs) - min(xs), max(ys) - min(ys)) > DETAIL_FAMILY_SHARE * prep["diag"]:
continue
regions.append(
{
"box": [min(xs) - link, min(ys) - link, max(xs) + link, max(ys) + link],
"tags": sorted(parts),
"kind": "family",
"base": base,
}
)
regions.sort(key=lambda region: (region["box"][0], region["box"][1]))
return [{"id": f"D{i}", **region} for i, region in enumerate(regions, start=1)]
def _distinct(places, radius: float) -> list:
"""The places of one tag, readings closer than ``radius`` to a kept one dropped."""
kept: list = []
for at in places:
if all(math.dist(at, other) > radius for other in kept):
kept.append(at)
return kept
def _majority_region(edges, boxes: dict) -> str | None:
"""The region holding more than half a run's drawn length, by piece midpoints."""
total = sum(edge["length"] for edge in edges)
if not boxes or total <= 0.0:
return None
share: dict[str, float] = {}
for edge in edges:
mid = ((edge["a"][0] + edge["b"][0]) / 2.0, (edge["a"][1] + edge["b"][1]) / 2.0)
for region_id, box in boxes.items():
if box[0] <= mid[0] <= box[2] and box[1] <= mid[1] <= box[3]:
share[region_id] = share.get(region_id, 0.0) + edge["length"]
break
best = max(share.items(), key=lambda item: item[1], default=None)
return best[0] if best is not None and best[1] > total / 2.0 else None
def _reasons(flagged: list[dict]) -> str: def _reasons(flagged: list[dict]) -> str:
counts: dict[str, int] = {} counts: dict[str, int] = {}
for run in flagged: for run in flagged:
@@ -389,6 +539,7 @@ def pipe_rows(
vertical_allowance=0.0, vertical_allowance=0.0,
length_source="auto", length_source="auto",
force=False, force=False,
exclude_regions=(),
) -> dict: ) -> dict:
"""Takeoff rows by room x service x diameter, with the runs, checks and method behind them. """Takeoff rows by room x service x diameter, with the runs, checks and method behind them.
@@ -410,6 +561,15 @@ def pipe_rows(
P&ID has room faces, else the nearest room label). On the layout a run P&ID has room faces, else the nearest room label). On the layout a run
holding several diameters shares its routed length among them in holding several diameters shares its routed length among them in
proportion to their drawn length on the P&ID. proportion to their drawn length on the P&ID.
Detail copies: where several tags the P&ID draws twice sit close together
(`detail_copy_regions`), a detail of equipment may repeat its pipes. Each
such region is reported in ``detail_copies`` and counted like any other
drawing; a run with more than half its drawn length in a region named in
``exclude_regions`` is left out, listed in ``control`` as
``excluded_region`` and totalled in ``excluded_m``. An unknown region id is
refused with the ids the P&ID has. Which copy is the detail is the
engineer's call: the drawing does not say.
""" """
options = check_pipe_options( options = check_pipe_options(
services=services, services=services,
@@ -442,13 +602,45 @@ def pipe_rows(
) )
m_pid = pid_unit["m_per_unit"] m_pid = pid_unit["m_per_unit"]
m_layout = layout_unit["m_per_unit"] if layout_unit else None m_layout = layout_unit["m_per_unit"] if layout_unit else None
copies = detail_copy_regions(pid, prep=prep_pid)
known_ids = [region["id"] for region in copies]
left_out = [str(region_id) for region_id in (exclude_regions or ())]
for region_id in left_out:
if region_id not in known_ids:
raise ValueError(
f"exclude_regions: {region_id!r} unknown; the P&ID has "
f"{', '.join(known_ids) or 'no detail copy'}"
)
boxes = {region["id"]: region["box"] for region in copies}
for region in copies:
region["pipe_m"] = 0.0
region["excluded"] = region["id"] in left_out
rows: dict[tuple[str, str, str], dict] = {} rows: dict[tuple[str, str, str], dict] = {}
runs_out, control, excluded = [], [], {} runs_out, control, excluded = [], [], {}
excluded_m = 0.0
for run in network["runs"]: for run in network["runs"]:
if run.service not in options["services"]: if run.service not in options["services"]:
excluded[run.service] = excluded.get(run.service, 0) + 1 excluded[run.service] = excluded.get(run.service, 0) + 1
continue continue
inside = _majority_region(run.edges, boxes)
if inside is not None:
region = next(r for r in copies if r["id"] == inside)
region["pipe_m"] += sum(edge["length"] for edge in run.edges) * m_pid
if inside in left_out:
drawn_m = sum(edge["length"] for edge in run.edges) * m_pid
excluded_m += drawn_m
control.append(
{
"run": run.id,
"kind": "excluded_region",
"service": run.service,
"tags": list(run.tags),
"schematic_m": drawn_m,
"reason": inside,
}
)
continue
ends = network["attachments"].get(run.id, []) ends = network["attachments"].get(run.id, [])
drawn = sum(edge["length"] for edge in run.edges) drawn = sum(edge["length"] for edge in run.edges)
schematic_m = drawn * m_pid schematic_m = drawn * m_pid
@@ -621,6 +813,17 @@ def pipe_rows(
"control rows, not in the table. length_source='pid' with force=True measures " "control rows, not in the table. length_source='pid' with force=True measures "
"every run on the P&ID instead, and says the scale is not verified." "every run on the P&ID instead, and says the scale is not verified."
) )
counted_copies = [region for region in copies if not region["excluded"]]
if counted_copies:
listed = "; ".join(
f"{region['id']}: {', '.join(region['tags'])} ({region['pipe_m']:.1f} m)"
for region in counted_copies
)
warnings.append(
f"{len(counted_copies)} possible detail cop{'y' if len(counted_copies) == 1 else 'ies'} "
f"- tags drawn twice, close together - are counted like the rest ({listed}). If one "
"repeats pipes drawn elsewhere, pass its id in exclude_regions."
)
if not scale_verified: if not scale_verified:
warnings.append( warnings.append(
"scale_verified: false - lengths are drawn lengths on a P&ID that no scale check " "scale_verified: false - lengths are drawn lengths on a P&ID that no scale check "
@@ -640,7 +843,9 @@ def pipe_rows(
"allowance_m": net_total * allowance, "allowance_m": net_total * allowance,
"total_m": net_total * (1.0 + allowance), "total_m": net_total * (1.0 + allowance),
"flagged_runs": sum(1 for r in runs_out if r["status"] == "C"), "flagged_runs": sum(1 for r in runs_out if r["status"] == "C"),
"excluded_m": excluded_m,
}, },
"detail_copies": copies,
"length_source": source, "length_source": source,
"scale": scale, "scale": scale,
"scale_verified": scale_verified, "scale_verified": scale_verified,
@@ -660,6 +865,7 @@ def pipe_rows(
"overlap_length_m": network["stats"]["overlap_length"] * m_pid, "overlap_length_m": network["stats"]["overlap_length"] * m_pid,
"route": "rectilinear MST of the tag positions; each connection x first, then y", "route": "rectilinear MST of the tag positions; each connection x first, then y",
"rooms": _rooms_mode(regions), "rooms": _rooms_mode(regions),
"exclude_regions": left_out,
}, },
} }
@@ -742,14 +948,22 @@ def _inside_box(p: Pt, box) -> bool:
def cable_rows( def cable_rows(
pid: Snapshot, layout: Snapshot | None, *, allowance=0.20, section_rules=None pid: Snapshot,
layout: Snapshot | None,
*,
allowance=0.20,
section_rules=None,
panel_rule="nearest",
) -> dict: ) -> dict:
"""One row per electrical load, the cable method made repeatable (spec §9). """One row per electrical load, the cable method made repeatable (spec §9).
Loads are the P&ID tags with electrical texts near them (`parse_electrical`: Loads are the P&ID tags with electrical texts near them (`parse_electrical`:
kW, phases, voltage, + N, VFD) and the tags a wiring callout names. The kW, phases, voltage, + N, VFD) and the tags a wiring callout names. The
panel comes from the layout's callouts (`wiring_target`: 'Wiring to CP1' panel comes from the layout's callouts (`wiring_target`: 'Wiring to CP1'
-> CP-1), else the P&ID's. The length is the Manhattan distance on the -> CP-1), else the P&ID's; with ``panel_rule='nearest'`` (the default) a
load no callout places, whose tag is on the layout, takes the nearest panel
there and is flagged ``panel_inferred`` - ``'stated'`` leaves it open. An
inferred panel never makes a load part of a package. The length is the Manhattan distance on the
layout from the load's tag to its panel's tag, and the cable is layout from the load's tag to its panel's tag, and the cable is
`roundup_m(length, allowance)`. **Power is never invented**: a load with no `roundup_m(length, allowance)`. **Power is never invented**: a load with no
stated kW keeps an empty cell and an open item. With no layout, lengths stated kW keeps an empty cell and an open item. With no layout, lengths
@@ -763,6 +977,10 @@ def cable_rows(
""" """
allowance = check_allowance(allowance) allowance = check_allowance(allowance)
rules = check_section_rules(section_rules) rules = check_section_rules(section_rules)
if panel_rule not in PANEL_RULES:
raise ValueError(
f"panel_rule: {panel_rule!r} unknown; choose from {', '.join(PANEL_RULES)}"
)
pid_places = {tag: [p["at"] for p in places] for tag, places in tag_occurrences(pid).items()} pid_places = {tag: [p["at"] for p in places] for tag, places in tag_occurrences(pid).items()}
pid_search = CABLE_SEARCH_SHARE * label_search_default(pid) pid_search = CABLE_SEARCH_SHARE * label_search_default(pid)
@@ -825,6 +1043,8 @@ def cable_rows(
room_regions(layout, cluster, prep=prep_layout) if layout is not None else room_regions(pid) room_regions(layout, cluster, prep=prep_layout) if layout is not None else room_regions(pid)
) )
# the panels a callout names anywhere, besides every tag with a panel prefix
named = {target for target, _source in panels.values()}
rows, items = [], [] rows, items = [], []
for tag in sorted(set(loads) | set(panels)): for tag in sorted(set(loads) | set(panels)):
texts = sorted(loads.get(tag, []), key=lambda item: item[:2]) texts = sorted(loads.get(tag, []), key=lambda item: item[:2])
@@ -841,6 +1061,16 @@ def cable_rows(
phases = next((f["phases"] for _d, _h, f in texts if f["phases"]), None) phases = next((f["phases"] for _d, _h, f in texts if f["phases"]), None)
voltage = next((f["voltage"] for _d, _h, f in texts if f["voltage"]), None) voltage = next((f["voltage"] for _d, _h, f in texts if f["voltage"]), None)
panel, panel_source = panels.get(tag, (None, None)) panel, panel_source = panels.get(tag, (None, None))
inferred = None
if panel is None and panel_rule == "nearest" and layout is not None:
here_now = positions.get(tag)
if here_now is not None:
candidates = {
name: p for name, p in panel_at.items() if name in named or is_panel_name(name)
}
inferred = _nearest_panel(here_now, candidates, exclude=normalize_panel(tag))
if inferred is not None:
panel, panel_source = inferred[0], "nearest"
row = { row = {
"tag": tag, "tag": tag,
"room": "", "room": "",
@@ -874,6 +1104,16 @@ def cable_rows(
"detail": "no wiring callout names a panel for this tag", "detail": "no wiring callout names a panel for this tag",
} }
) )
elif panel_source == "nearest":
items.append(
{
"tag": tag,
"item": "panel_inferred",
"detail": "no wiring callout names a panel; "
f"{panel} is the nearest panel on the layout, "
f"{inferred[1] * layout_unit['m_per_unit']:.1f} m away",
}
)
if rules and kw is not None and row["section"] is None: if rules and kw is not None and row["section"] is None:
items.append( items.append(
{"tag": tag, "item": "no_section_rule", "detail": f"no rule covers {kw:g} kW"} {"tag": tag, "item": "no_section_rule", "detail": f"no rule covers {kw:g} kW"}
@@ -918,7 +1158,9 @@ def cable_rows(
(normalize_panel(r["tag"]) or r["tag"]): r["tag"] for r in rows if r["kw"] is not None (normalize_panel(r["tag"]) or r["tag"]): r["tag"] for r in rows if r["kw"] is not None
} }
for row in rows: for row in rows:
owner = stated.get(row["panel"]) if row["panel"] else None # only a panel the drawing names makes a package; a nearest guess does not
known = row["panel"] and row["panel_source"] != "nearest"
owner = stated.get(row["panel"]) if known else None
if owner is not None and owner != row["tag"]: if owner is not None and owner != row["tag"]:
row["package"] = owner row["package"] = owner
@@ -959,6 +1201,7 @@ def cable_rows(
"allowance": allowance, "allowance": allowance,
"search": {"pid": pid_search, "layout": layout_search}, "search": {"pid": pid_search, "layout": layout_search},
"section_rules": [dict(rule) for rule in rules], "section_rules": [dict(rule) for rule in rules],
"panel_rule": panel_rule,
"rooms": _rooms_mode(regions), "rooms": _rooms_mode(regions),
}, },
} }
+21 -1
View File
@@ -11149,6 +11149,14 @@ async def pipe_takeoff(
"drawing, 0.001 on a metre one).", "drawing, 0.001 on a metre one).",
), ),
] = None, ] = None,
exclude_regions: Annotated[
list[str] | None,
Field(
default=None,
description="Ids of reported detail copies (detail_copies: D1, D2 ...) whose runs "
"repeat pipes drawn elsewhere and are left out. Omitted, every copy is counted.",
),
] = None,
ctx: Context = None, ctx: Context = None,
) -> dict: ) -> dict:
"""A pipe takeoff: topology from the P&ID, lengths from the layout, rows by room x service x diameter. """A pipe takeoff: topology from the P&ID, lengths from the layout, rows by room x service x diameter.
@@ -11201,6 +11209,7 @@ async def pipe_takeoff(
force=force, force=force,
layers=layers, layers=layers,
tol=tol, tol=tol,
exclude_regions=exclude_regions,
) )
except (ValueError, OSError) as exc: except (ValueError, OSError) as exc:
raise ToolError(str(exc)) from exc raise ToolError(str(exc)) from exc
@@ -11263,6 +11272,14 @@ async def cable_takeoff(
"e.g. [{'max_kw': 5.5, 'section': '5x2.5'}]. Omitted, no section is proposed.", "e.g. [{'max_kw': 5.5, 'section': '5x2.5'}]. Omitted, no section is proposed.",
), ),
] = None, ] = None,
panel_rule: Annotated[
str,
Field(
default="nearest",
description="A load no wiring callout places: 'nearest' takes the nearest panel on "
"the layout and flags the row panel_inferred; 'stated' leaves it open.",
),
] = "nearest",
ctx: Context = None, ctx: Context = None,
) -> dict: ) -> dict:
"""A cable takeoff: one row per electrical load, the shop's cable method made repeatable. """A cable takeoff: one row per electrical load, the shop's cable method made repeatable.
@@ -11271,7 +11288,9 @@ async def cable_takeoff(
texts near each tag (Russian, Turkish and English spellings). **Power is texts near each tag (Russian, Turkish and English spellings). **Power is
never invented**: a load with no stated kW keeps an empty cell and an open never invented**: a load with no stated kW keeps an empty cell and an open
item. The panel comes from the layout's wiring callouts ('Wiring to CP1' item. The panel comes from the layout's wiring callouts ('Wiring to CP1'
and its variants, normalised to CP-1), else from the P&ID. The length is the and its variants, normalised to CP-1), else from the P&ID ('к CP1'); a load
neither places takes the nearest panel on the layout, flagged
`panel_inferred` (`panel_rule='stated'` leaves it open). The length is the
Manhattan distance on the layout from the load's tag to its panel's tag, Manhattan distance on the layout from the load's tag to its panel's tag,
and the cable is ROUNDUP(length x (1 + allowance)) to the metre. Room and and the cable is ROUNDUP(length x (1 + allowance)) to the metre. Room and
panel totals count known power once: a load wired to a panel that states panel totals count known power once: a load wired to a panel that states
@@ -11306,6 +11325,7 @@ async def cable_takeoff(
lang=lang, lang=lang,
allowance=allowance, allowance=allowance,
section_rules=section_rules, section_rules=section_rules,
panel_rule=panel_rule,
) )
except (ValueError, OSError) as exc: except (ValueError, OSError) as exc:
raise ToolError(str(exc)) from exc raise ToolError(str(exc)) from exc
+28
View File
@@ -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"]) 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): def test_a_tag_missing_from_the_layout_is_an_open_item(pair):
pid, _layout = pair pid, _layout = pair
layout = snap(layout_records(skip=("M87",)), 4, "layout.dxf") layout = snap(layout_records(skip=("M87",)), 4, "layout.dxf")
+36
View File
@@ -171,6 +171,42 @@ def test_a_room_label_inside_the_wall_faces_is_measured():
assert report["room_faces"]["unlabelled"] == 1 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(): def test_a_cabinet_label_naming_its_room_is_not_a_room():
# 'CONTROL CABINET / FILLING ROOM' labels the cabinet, not a room: it names # '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 and carries no room number. A numbered room named after its
+10
View File
@@ -11,6 +11,7 @@ import pytest
from engineering.understand.labels import ( from engineering.understand.labels import (
CYRILLIC_LOOKALIKES, CYRILLIC_LOOKALIKES,
is_panel_name,
normalize_panel, normalize_panel,
parse_diameters, parse_diameters,
parse_electrical, parse_electrical,
@@ -212,6 +213,15 @@ def test_wiring_callouts_in_four_languages():
assert wiring_target("CP 2 panosuna") == "CP-2" 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(): 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' # 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 # ('to CP1'), or 'к щиту CP-2' ('to switchboard CP-2'). The unit кВт holds
+63
View File
@@ -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): def test_other_services_are_left_out_unless_asked(pair):
pid, layout, network = pair pid, layout, network = pair
assert DEFAULT_SERVICES == ("product", "cip_supply", "cip_return") assert DEFAULT_SERVICES == ("product", "cip_supply", "cip_return")