diff --git a/README.md b/README.md
index 6273618..09f8697 100644
--- a/README.md
+++ b/README.md
@@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
-> **v1.5 release snapshot:** 245 tools · 8 resources · 5 prompt templates · 4516 collected tests.
-> 245 is the **registered** count; a default install advertises 240 over `tools/list`,
+> **v1.5 release snapshot:** 247 tools · 8 resources · 5 prompt templates · 4516 collected tests.
+> 247 is the **registered** count; a default install advertises 242 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
## Why this exists
@@ -122,7 +122,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) |
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
-245 tools in 26 groups; TOOL_PACKS=core hides the nine pid_* tools, the 22 environment tools, the 14 mechanical tools and the 12 architectural tools (TOOL_PACKS=core,mech keeps the mechanical ones, TOOL_PACKS=core,arch the architectural ones) from a client that needs none of them. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates.
+247 tools in 26 groups; TOOL_PACKS=core hides the nine pid_* tools, the 22 environment tools, the 14 mechanical tools and the 12 architectural tools (TOOL_PACKS=core,mech keeps the mechanical ones, TOOL_PACKS=core,arch the architectural ones) from a client that needs none of them. Plus 8 resources that cost nothing in the tool budget (autocad://drawing/info, layers, blocks, entities/stats, entities/{layer_name}, system/status, pid/symbols, standards/isa51) and 5 prompt templates.
**Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy.
diff --git a/discovery/aliases.py b/discovery/aliases.py
index c536ba4..371dc96 100644
--- a/discovery/aliases.py
+++ b/discovery/aliases.py
@@ -2538,6 +2538,45 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"kopuk hatlar",
),
),
+ "pipe_takeoff": ToolAliases(
+ acad=(),
+ synonyms=(
+ "pipe takeoff",
+ "piping takeoff",
+ "takeoff",
+ "pipe quantities",
+ "how much pipe",
+ "pipe lengths from a p&id",
+ "bill of quantities",
+ "boq",
+ "metraj",
+ "boru metrajı",
+ "boru metraji",
+ "metre cetveli",
+ "boru listesi",
+ "спецификация",
+ "ведомость",
+ "спецификация трубопроводов",
+ "ведомость трубопроводов",
+ ),
+ ),
+ "cable_takeoff": ToolAliases(
+ acad=(),
+ synonyms=(
+ "cable takeoff",
+ "cable schedule",
+ "cable lengths",
+ "cable list",
+ "how much cable",
+ "motor cable lengths",
+ "kablo metrajı",
+ "kablo metraji",
+ "kablo listesi",
+ "kablo cetveli",
+ "кабельный журнал",
+ "ведомость кабелей",
+ ),
+ ),
}
diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json
index 336ebd1..320abf3 100644
--- a/docs/tool-inventory.json
+++ b/docs/tool-inventory.json
@@ -3,13 +3,13 @@
"schema_version": 1,
"package_version": "1.5.1",
"totals": {
- "tools": 245,
+ "tools": 247,
"resources": 8,
"prompts": 5,
"groups": 26
},
"groups": {
- "analysis": 16,
+ "analysis": 18,
"architecture": 12,
"batch": 3,
"blocks": 9,
@@ -543,6 +543,21 @@
"destructiveHint": false
}
},
+ {
+ "name": "cable_takeoff",
+ "group": "analysis",
+ "cost": "safe",
+ "summary": "Cable takeoff: one row per load, Manhattan length to its panel, rounded up to the metre.",
+ "tags": [
+ "analysis",
+ "plant"
+ ],
+ "annotations": {
+ "title": "Plant: Cable Takeoff",
+ "readOnlyHint": false,
+ "destructiveHint": false
+ }
+ },
{
"name": "cad_batch",
"group": "batch",
@@ -2580,6 +2595,21 @@
"readOnlyHint": true
}
},
+ {
+ "name": "pipe_takeoff",
+ "group": "analysis",
+ "cost": "safe",
+ "summary": "Pipe takeoff from a P&ID and its layout: rows by room, service and diameter.",
+ "tags": [
+ "analysis",
+ "plant"
+ ],
+ "annotations": {
+ "title": "Plant: Pipe Takeoff",
+ "readOnlyHint": false,
+ "destructiveHint": false
+ }
+ },
{
"name": "plot_style_list",
"group": "page_setup",
diff --git a/engineering/understand/draw.py b/engineering/understand/draw.py
new file mode 100644
index 0000000..c1f3dbd
--- /dev/null
+++ b/engineering/understand/draw.py
@@ -0,0 +1,119 @@
+"""The async layer of track H's takeoffs: read the drawings, run the pure engine, write the workbook.
+
+The only module of the takeoffs that touches a backend, and it only reads:
+`take_snapshot` gives one bulk read of each drawing (a file, or the current
+document), the pure modules compute in a worker thread so a 35,000-entity
+P&ID does not stall the server, and the workbook is a file beside the path the
+caller names. The drawings themselves are never modified.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import math
+
+from engineering.understand.network import build_network
+from engineering.understand.report import check_lang, write_workbook
+from engineering.understand.snapshot import take_snapshot
+from engineering.understand.takeoff import (
+ cable_rows,
+ check_allowance,
+ check_pipe_options,
+ check_section_rules,
+ pipe_rows,
+)
+
+__all__ = ["run_cable_takeoff", "run_pipe_takeoff"]
+
+
+def _check_tol(tol) -> float:
+ try:
+ value = float(tol)
+ except (TypeError, ValueError):
+ raise ValueError(f"tol: expected a number, got {tol!r}") from None
+ if not math.isfinite(value) or value <= 0.0:
+ raise ValueError(f"tol: must be a finite number greater than zero, got {tol!r}")
+ return value
+
+
+async def run_pipe_takeoff(
+ backend,
+ *,
+ pid_path: str | None = None,
+ layout_path: str | None = None,
+ output: str | None = None,
+ lang: str = "en",
+ services=None,
+ allowance: float = 0.20,
+ vertical_allowance: float = 0.0,
+ length_source: str = "auto",
+ force: bool = False,
+ layers=None,
+ tol: float = 1.0,
+) -> dict:
+ """`pipe_rows` over a P&ID and an optional layout, plus the workbook when `output` is named.
+
+ Every option is checked before a drawing is read. `pid_path=None` reads the
+ current document as the P&ID. The result is `pipe_rows`'s dict with
+ ``files`` added: `write_workbook`'s answer, or None when no `output` was named.
+ """
+ check_lang(lang)
+ options = check_pipe_options(
+ services=services,
+ allowance=allowance,
+ vertical_allowance=vertical_allowance,
+ length_source=length_source,
+ )
+ tol = _check_tol(tol)
+ if length_source == "layout" and not layout_path:
+ raise ValueError("length_source='layout' needs a layout drawing; none was given")
+ pid = await take_snapshot(backend, pid_path)
+ layout = await take_snapshot(backend, layout_path) if layout_path else None
+
+ def compute() -> dict:
+ network = build_network(pid, layers=layers, tol=tol)
+ return pipe_rows(
+ network,
+ pid,
+ layout,
+ services=options["services"],
+ allowance=options["allowance"],
+ vertical_allowance=options["vertical_allowance"],
+ length_source=options["length_source"],
+ force=bool(force),
+ )
+
+ result = await asyncio.to_thread(compute)
+ result["files"] = (
+ await asyncio.to_thread(write_workbook, result, kind="pipe", lang=lang, path=output)
+ if output
+ else None
+ )
+ return result
+
+
+async def run_cable_takeoff(
+ backend,
+ *,
+ pid_path: str | None = None,
+ layout_path: str | None = None,
+ output: str | None = None,
+ lang: str = "en",
+ allowance: float = 0.20,
+ section_rules=None,
+) -> dict:
+ """`cable_rows` over a P&ID and an optional layout, plus the workbook when `output` is named."""
+ check_lang(lang)
+ allowance = check_allowance(allowance)
+ rules = check_section_rules(section_rules)
+ pid = await take_snapshot(backend, pid_path)
+ layout = await take_snapshot(backend, layout_path) if layout_path else None
+ result = await asyncio.to_thread(
+ cable_rows, pid, layout, allowance=allowance, section_rules=list(rules) or None
+ )
+ result["files"] = (
+ await asyncio.to_thread(write_workbook, result, kind="cable", lang=lang, path=output)
+ if output
+ else None
+ )
+ return result
diff --git a/engineering/understand/network.py b/engineering/understand/network.py
new file mode 100644
index 0000000..459d19f
--- /dev/null
+++ b/engineering/understand/network.py
@@ -0,0 +1,967 @@
+"""Line networks read off a foreign P&ID: runs, services, diameters, equipment.
+
+`engineering.pid.graph` reads a P&ID this server drew; on a foreign one it sees
+lines of class `unknown` between blocks of kind `unknown_block` (spec §1). This
+module is the reader the takeoffs stand on (spec §7). It works on a
+`Snapshot` - one bulk read - and never touches a drawing.
+
+The algorithm, in the order it runs:
+
+1. **Segments.** LINE, LWPOLYLINE, POLYLINE and ARC records in model space on
+ the chosen layers (default: every layer `classify_layer` files under piping
+ or electrical with confidence >= 0.9). A bulged polyline segment and an ARC
+ stay one arc piece: never split, never flattened.
+2. **Merge.** Endpoints closer than `tol` become one vertex; the first one seen
+ keeps its coordinates.
+3. **Split.** A straight segment is split at every vertex lying on it within
+ `tol` - the T-junction, where a branch ends on a header. A crossing where
+ neither line ends is *not* a junction: two P&ID lines that cross without a
+ dot do not connect.
+4. **Duplicates once.** The same split turns a line drawn twice, or a line
+ drawn over a polyline, into identical pieces; each piece is kept once and
+ the length the copies would have added is `stats.overlap_length`.
+5. **Fittings.** An INSERT whose box (grown by `tol`) holds two to four free
+ ends is an inline fitting - valve, coupling, reducer - and every one of
+ those ends is joined to every other through it (a three- or four-way
+ fitting has no hub port, so nothing depends on record order); the fitting
+ adds no length. An INSERT that a tag labels is equipment and never a
+ fitting. A block whose name contains reducer / переход / redüksiyon /
+ verloop / Reduzier(stück) is a reducer, and a reducer is found however the
+ pipe meets it: free ends in its box, pieces meeting at a vertex inside its
+ box (a line broken at the reducer), or a straight segment drawn through
+ the middle of its box (its line within a quarter of the box's smaller side
+ of the centre), which is cut at the foot of the centre before step 3.
+6. **Runs.** A connected component of pieces and fittings is a run.
+7. **Diameter.** Each diameter label goes to the piece nearest to it within
+ `label_search` (default 5 x the median text height) - unless an open line
+ on a layer that is not read stands strictly nearer, in which case the label
+ is that line's and is listed in `stats.labels_skipped` - and holds for
+ every piece of that drawn entity on the same side of every reducer
+ (*direct*; when two labels reach one piece, the nearer wins and the pair is
+ listed in `stats.diameter_conflicts`). An unlabelled piece takes the
+ diameter of the nearest direct piece along the run, measured along the
+ pipe and never across a reducer (*continuity*); two different diameters
+ equally near, or none, leave it *unassigned*. Labels are kept as drawn:
+ Ø51, SMS51 and DN20 are different sizes.
+8. **Service.** A run's service is the length-weighted service of its layers,
+ overridden for the whole run by a supply / return word within
+ `label_search` of one of its pieces - a layer alone never splits CIP supply
+ from CIP return. A CIP or glycol layer that names no direction (which
+ `classify_layer` files as ``unknown``) takes its direction from that word.
+9. **Equipment.** A run end inside the box (grown by `tol`) of the geometry a
+ tag labels is attached to that tag; the smallest such box wins. A tag labels
+ the smallest closed shape holding it, else the nearest within twice
+ `label_search`; a shape holding two different tags (a room, a skid, a sheet
+ frame) is never equipment.
+
+Arc pieces are measured to their chord for the label search only; their length
+is the true arc length. Every "which X lies near Y" question (free ends in a
+fitting's box, shapes around a tag, tags inside a shape, equipment around a run
+end) goes through a grid index, so a field-sized drawing is read in seconds,
+not minutes.
+"""
+
+from __future__ import annotations
+
+import functools
+import heapq
+import math
+import statistics
+from bisect import bisect_left, bisect_right
+from dataclasses import dataclass
+
+from engineering.understand.labels import parse_diameters, parse_tag, wiring_target
+from engineering.understand.snapshot import EntityRecord, Pt, Snapshot
+from engineering.understand.vocab import classify_layer, fold, supply_return
+
+__all__ = [
+ "EQUIPMENT_SEARCH_SHARE",
+ "LABEL_SEARCH_FACTOR",
+ "LINE_TYPES",
+ "MAX_FITTING_ENDS",
+ "NETWORK_CONFIDENCE",
+ "REDUCER_WORDS",
+ "Run",
+ "build_network",
+ "default_layers",
+ "equipment_boxes",
+ "is_reducer",
+ "label_search_default",
+ "tag_occurrences",
+]
+
+MODEL = "Model"
+LINE_TYPES = frozenset({"LINE", "LWPOLYLINE", "POLYLINE", "ARC"})
+TEXT_TYPES = frozenset({"TEXT", "MTEXT", "MULTILEADER"})
+#: Words that make an INSERT a reducer, matched in its block name folded by
+#: `vocab.fold` (casefolded, diacritics stripped: REDÜKSİYON reads reduksiyon).
+#: The same five words `vocab.equipment_kind` files under ``reducer``.
+REDUCER_WORDS = ("reducer", "переход", "reduksiyon", "verloop", "reduzier")
+NETWORK_DISCIPLINES = ("piping", "electrical")
+NETWORK_CONFIDENCE = 0.9
+#: A box touched by more free ends than this is not an inline fitting (a
+#: valve has two ports, a three- or four-way valve three or four).
+MAX_FITTING_ENDS = 4
+LABEL_SEARCH_FACTOR = 5.0
+#: A tag written beside its symbol is looked for twice as far as a label.
+EQUIPMENT_SEARCH_SHARE = 2.0
+#: With no text on the drawing at all, labels are searched this many `tol` away.
+LABEL_SEARCH_FALLBACK = 50.0
+_PAIRS = {"cip": ("cip_supply", "cip_return"), "glycol": ("glycol_supply", "glycol_return")}
+_EPS = 1e-9
+#: A box covering more grid cells than this is kept aside and checked on every query.
+_WIDE_CELLS = 256
+
+
+@dataclass(frozen=True)
+class Run:
+ """One connected line network: its pieces, its service, its free ends and the tags on them."""
+
+ id: str
+ edges: tuple[dict, ...]
+ service: str
+ ends: tuple[Pt, ...]
+ tags: tuple[str, ...]
+
+
+def _positive(value, name: str) -> float:
+ try:
+ number = float(value)
+ except (TypeError, ValueError):
+ raise ValueError(f"{name}: expected a number, got {value!r}") from None
+ if not math.isfinite(number) or number <= 0.0:
+ raise ValueError(f"{name}: must be a finite number greater than zero, got {value!r}")
+ return number
+
+
+def _in_box(p: Pt, box, grow: float) -> bool:
+ return box[0] - grow <= p[0] <= box[2] + grow and box[1] - grow <= p[1] <= box[3] + grow
+
+
+def _box_area(box) -> float:
+ return (box[2] - box[0]) * (box[3] - box[1])
+
+
+def _box_distance(p: Pt, box) -> float:
+ dx = max(box[0] - p[0], 0.0, p[0] - box[2])
+ dy = max(box[1] - p[1], 0.0, p[1] - box[3])
+ return math.hypot(dx, dy)
+
+
+def _centre(rec: EntityRecord) -> Pt | None:
+ """Where a label stands: its box centre, else its insertion point."""
+ if rec.bbox is not None:
+ return ((rec.bbox[0] + rec.bbox[2]) / 2.0, (rec.bbox[1] + rec.bbox[3]) / 2.0)
+ return rec.points[0] if rec.points else None
+
+
+def _segment_distance(p: Pt, a: Pt, b: Pt) -> float:
+ dx, dy = b[0] - a[0], b[1] - a[1]
+ length2 = dx * dx + dy * dy
+ if length2 == 0.0:
+ return math.dist(p, a)
+ t = max(0.0, min(1.0, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / length2))
+ return math.dist(p, (a[0] + t * dx, a[1] + t * dy))
+
+
+def _along(p: Pt, a: Pt, b: Pt) -> tuple[float, float]:
+ """(distance along a->b of p's foot, distance of p from the line)."""
+ dx, dy = b[0] - a[0], b[1] - a[1]
+ length = math.hypot(dx, dy)
+ ux, uy = dx / length, dy / length
+ px, py = p[0] - a[0], p[1] - a[1]
+ return (px * ux + py * uy, abs(ux * py - uy * px))
+
+
+def _piece_length(a: Pt, b: Pt, bulge: float) -> float:
+ chord = math.dist(a, b)
+ if bulge == 0.0 or chord == 0.0:
+ return chord
+ theta = 4.0 * math.atan(abs(bulge))
+ return chord / (2.0 * math.sin(theta / 2.0)) * theta
+
+
+def is_reducer(block: str | None) -> bool:
+ """True when the folded block name contains a reducer word (`REDUCER_WORDS`)."""
+ if not block:
+ return False
+ name = fold(block)
+ return any(word in name for word in REDUCER_WORDS)
+
+
+def _typical_size(boxes, fallback: float) -> float:
+ """The median larger side of `boxes`, never below `fallback`: a grid cell size."""
+ sides = [max(box[2] - box[0], box[3] - box[1]) for box in boxes]
+ return max(statistics.median(sides) if sides else 0.0, fallback, _EPS)
+
+
+def _cell_range(box, grow: float, cell: float) -> tuple[int, int, int, int]:
+ return (
+ math.floor((box[0] - grow) / cell),
+ math.floor((box[1] - grow) / cell),
+ math.floor((box[2] + grow) / cell),
+ math.floor((box[3] + grow) / cell),
+ )
+
+
+class _PointGrid:
+ """Points bucketed on a square grid, for 'which points lie in this box' queries."""
+
+ def __init__(self, items, cell: float) -> None:
+ self.cell = cell
+ self.items: list[tuple[object, Pt]] = list(items)
+ self.cells: dict[tuple[int, int], list[int]] = {}
+ for n, (_key, p) in enumerate(self.items):
+ key = (math.floor(p[0] / cell), math.floor(p[1] / cell))
+ self.cells.setdefault(key, []).append(n)
+
+ def in_box(self, box, grow: float) -> list:
+ """The keys of the points inside `box` grown by `grow`, in insertion order."""
+ x0, y0, x1, y1 = _cell_range(box, grow, self.cell)
+ if (x1 - x0 + 1) * (y1 - y0 + 1) > len(self.items):
+ found = range(len(self.items))
+ else:
+ found = sorted(
+ n
+ for cx in range(x0, x1 + 1)
+ for cy in range(y0, y1 + 1)
+ for n in self.cells.get((cx, cy), ())
+ )
+ return [self.items[n][0] for n in found if _in_box(self.items[n][1], box, grow)]
+
+
+class _BoxGrid:
+ """Boxes bucketed on a square grid, for 'which boxes hold, or lie within `grow` of, p'.
+
+ A box is filed in every cell its box grown by `grow` covers, so a point
+ inside that grown box finds it in the point's own cell; a box covering more
+ than `_WIDE_CELLS` cells (a sheet frame, a room) is kept aside and returned
+ by every query. Callers filter the candidates exactly.
+ """
+
+ def __init__(self, items, *, grow: float, cell: float) -> None:
+ self.cell = cell
+ self.cells: dict[tuple[int, int], list] = {}
+ self.wide: list = []
+ for box, payload in items:
+ x0, y0, x1, y1 = _cell_range(box, grow, cell)
+ if (x1 - x0 + 1) * (y1 - y0 + 1) > _WIDE_CELLS:
+ self.wide.append(payload)
+ continue
+ for cx in range(x0, x1 + 1):
+ for cy in range(y0, y1 + 1):
+ self.cells.setdefault((cx, cy), []).append(payload)
+
+ def near(self, p: Pt) -> list:
+ key = (math.floor(p[0] / self.cell), math.floor(p[1] / self.cell))
+ return self.cells.get(key, []) + self.wide
+
+
+def _layer_names(snap: Snapshot) -> list[str]:
+ return sorted(set(snap.layers) | {rec.layer for rec in snap.records})
+
+
+def default_layers(snap: Snapshot) -> tuple[str, ...]:
+ """Every layer `classify_layer` files under piping or electrical with confidence >= 0.9."""
+ chosen = []
+ for name in _layer_names(snap):
+ found = classify_layer(name)
+ if found["discipline"] in NETWORK_DISCIPLINES and found["confidence"] >= NETWORK_CONFIDENCE:
+ chosen.append(name)
+ return tuple(chosen)
+
+
+def label_search_default(snap: Snapshot, *, tol: float = 1.0) -> float:
+ """5 x the median model-space text height; 50 x `tol` on a drawing with no text."""
+ heights = [
+ rec.height
+ for rec in snap.records
+ if rec.space == MODEL and rec.type in TEXT_TYPES and rec.height and rec.height > 0.0
+ ]
+ if heights:
+ return LABEL_SEARCH_FACTOR * statistics.median(heights)
+ return LABEL_SEARCH_FALLBACK * tol
+
+
+def tag_occurrences(snap: Snapshot) -> dict[str, list[dict]]:
+ """Every model-space place a tag is written, by tag.
+
+ A TEXT / MTEXT / MULTILEADER whose plain text `parse_tag` reads - unless it
+ is a diameter label or a wiring callout - and an INSERT attribute whose tag
+ name contains TAG. ``at`` is the insertion point; ``insert`` is the handle
+ of the INSERT that carries the tag, or None for a text.
+ """
+ found: dict[str, list[dict]] = {}
+ for rec in snap.records:
+ if rec.space != MODEL or not rec.points:
+ continue
+ if rec.type in TEXT_TYPES and rec.text:
+ if parse_diameters(rec.text) or wiring_target(rec.text):
+ continue
+ tag = parse_tag(rec.text)
+ if tag:
+ found.setdefault(tag, []).append(
+ {
+ "tag": tag,
+ "at": rec.points[0],
+ "handle": rec.handle,
+ "box": rec.bbox,
+ "insert": None,
+ }
+ )
+ elif rec.type == "INSERT":
+ for name, value in rec.attribs:
+ tag = parse_tag(value) if "TAG" in name.upper() else None
+ if tag:
+ found.setdefault(tag, []).append(
+ {
+ "tag": tag,
+ "at": rec.points[0],
+ "handle": rec.handle,
+ "box": rec.bbox,
+ "insert": rec.handle,
+ }
+ )
+ return found
+
+
+def _is_shape(rec: EntityRecord) -> bool:
+ if rec.type in ("CIRCLE", "ELLIPSE", "INSERT"):
+ return True
+ return rec.type in ("LWPOLYLINE", "POLYLINE") and rec.closed
+
+
+def equipment_boxes(
+ snap: Snapshot, occurrences: dict[str, list[dict]], *, exclude_layers=(), search: float
+) -> dict[str, list[dict]]:
+ """The box of the geometry each tag occurrence labels.
+
+ An INSERT carrying the tag is its own geometry. A text labels the smallest
+ closed shape (CIRCLE, ELLIPSE, closed polyline, INSERT) off the network
+ layers whose box holds its insertion point, else the nearest one within
+ `search`, else only itself (its own box, or its insertion point). A shape
+ whose box holds the tags of two or more different items is a room, a skid
+ or a sheet frame - not equipment - and is never chosen.
+ """
+ excluded = set(exclude_layers)
+ candidates = [
+ rec
+ for rec in snap.records
+ if rec.space == MODEL
+ and rec.bbox is not None
+ and rec.layer not in excluded
+ and _is_shape(rec)
+ ]
+ cell = _typical_size([rec.bbox for rec in candidates], search)
+ anchors = _PointGrid(
+ ((tag, place["at"]) for tag, places in occurrences.items() for place in places), cell
+ )
+ shapes = [rec for rec in candidates if len(set(anchors.in_box(rec.bbox, 0.0))) <= 1]
+ index = _BoxGrid(((rec.bbox, rec) for rec in shapes), grow=search, cell=cell)
+ out: dict[str, list[dict]] = {}
+ for tag, places in sorted(occurrences.items()):
+ for place in places:
+ x, y = place["at"]
+ if place["insert"] is not None and place["box"] is not None:
+ box, handle = place["box"], place["insert"]
+ else:
+ pool = index.near((x, y))
+ inside = [s for s in pool if _in_box((x, y), s.bbox, 0.0)]
+ near = [
+ (_box_distance((x, y), s.bbox), _box_area(s.bbox), s.handle, s) for s in pool
+ ]
+ near = [n for n in near if n[0] <= search]
+ if inside:
+ best = min(inside, key=lambda s: (_box_area(s.bbox), s.handle))
+ box, handle = best.bbox, best.handle
+ elif near:
+ best = min(near, key=lambda n: n[:3])[3]
+ box, handle = best.bbox, best.handle
+ else:
+ box, handle = place["box"] or (x, y, x, y), place["handle"]
+ out.setdefault(tag, []).append({"box": box, "handle": handle, "at": (x, y)})
+ return out
+
+
+class _Vertices:
+ """Snap points to vertices: a point within ``tol`` of a vertex *is* that vertex."""
+
+ def __init__(self, tol: float) -> None:
+ self.tol = tol
+ self.points: list[Pt] = []
+ self._grid: dict[tuple[int, int], list[int]] = {}
+
+ def index(self, p: Pt) -> int:
+ cx, cy = math.floor(p[0] / self.tol), math.floor(p[1] / self.tol)
+ best, best_d = None, self.tol
+ for dx in (-1, 0, 1):
+ for dy in (-1, 0, 1):
+ for i in self._grid.get((cx + dx, cy + dy), ()):
+ d = math.dist(self.points[i], p)
+ if d <= best_d:
+ best, best_d = i, d
+ if best is not None:
+ return best
+ self.points.append((float(p[0]), float(p[1])))
+ self._grid.setdefault((cx, cy), []).append(len(self.points) - 1)
+ return len(self.points) - 1
+
+
+def _choose_layers(snap: Snapshot, layers) -> tuple[str, ...]:
+ known = _layer_names(snap)
+ if layers is None:
+ chosen = default_layers(snap)
+ if not chosen:
+ raise ValueError(
+ "layers: no layer is classified piping or electrical with confidence >= 0.9; "
+ f"name the layers to read. The drawing has: {', '.join(known)}"
+ )
+ return chosen
+ names = [str(name) for name in layers]
+ if not names:
+ raise ValueError("layers: an empty list reads nothing; omit it for the default")
+ missing = [name for name in names if name not in known]
+ if missing:
+ raise ValueError(
+ f"layers: {', '.join(missing)} not in the drawing; it has: {', '.join(known)}"
+ )
+ return tuple(dict.fromkeys(names))
+
+
+def _arc_segment(rec: EntityRecord):
+ if not rec.points or rec.radius is None or rec.angles is None:
+ return None
+ (cx, cy), r = rec.points[0], rec.radius
+ start, end = rec.angles
+ sweep = (end - start) % 360.0
+ if sweep == 0.0:
+ return None
+ a = (cx + r * math.cos(math.radians(start)), cy + r * math.sin(math.radians(start)))
+ b = (cx + r * math.cos(math.radians(end)), cy + r * math.sin(math.radians(end)))
+ return a, b, math.tan(math.radians(sweep) / 4.0)
+
+
+def _segments(snap: Snapshot, layers, *, open_only=False) -> list[tuple[Pt, Pt, float, str, str]]:
+ """Every LINE_TYPES segment in model space on `layers`; `open_only` skips closed polylines."""
+ wanted = set(layers)
+ out = []
+ for rec in snap.records:
+ if rec.space != MODEL or rec.layer not in wanted or rec.type not in LINE_TYPES:
+ continue
+ if open_only and rec.closed and rec.type in ("LWPOLYLINE", "POLYLINE"):
+ continue
+ if rec.type == "LINE":
+ if len(rec.points) >= 2:
+ out.append((rec.points[0], rec.points[1], 0.0, rec.handle, rec.layer))
+ elif rec.type == "ARC":
+ arc = _arc_segment(rec)
+ if arc is not None:
+ out.append((arc[0], arc[1], arc[2], rec.handle, rec.layer))
+ else:
+ pts = list(rec.points)
+ bulges = list(rec.bulges) + [0.0] * (len(pts) - len(rec.bulges))
+ count = len(pts) if rec.closed else len(pts) - 1
+ for i in range(max(count, 0)):
+ out.append(
+ (pts[i], pts[(i + 1) % len(pts)], float(bulges[i]), rec.handle, rec.layer)
+ )
+ return out
+
+
+class _ChordIndex:
+ """Grid of chords (a, b) for 'which chords lie within `search` of p, or cross this box'."""
+
+ def __init__(self, chords: list[tuple[Pt, Pt]], search: float) -> None:
+ lengths = [math.dist(a, b) for a, b in chords] or [search]
+ self.cell = max(search, statistics.median(lengths), _EPS)
+ self.search = search
+ self.cells: dict[tuple[int, int], list[int]] = {}
+ for k, (a, b) in enumerate(chords):
+ box = (min(a[0], b[0]), min(a[1], b[1]), max(a[0], b[0]), max(a[1], b[1]))
+ x0, y0, x1, y1 = _cell_range(box, search, self.cell)
+ for cx in range(x0, x1 + 1):
+ for cy in range(y0, y1 + 1):
+ self.cells.setdefault((cx, cy), []).append(k)
+
+ def candidates(self, p: Pt) -> list[int]:
+ return self.cells.get((math.floor(p[0] / self.cell), math.floor(p[1] / self.cell)), [])
+
+ def in_box(self, box) -> list[int]:
+ """The chords filed in any cell `box` covers, each once, in index order."""
+ x0, y0, x1, y1 = _cell_range(box, 0.0, self.cell)
+ return sorted(
+ {
+ k
+ for cx in range(x0, x1 + 1)
+ for cy in range(y0, y1 + 1)
+ for k in self.cells.get((cx, cy), ())
+ }
+ )
+
+
+def _through_middle(a: Pt, b: Pt, box, tol: float) -> Pt | None:
+ """Where a straight segment passing through the middle of `box` is cut, else None.
+
+ The middle: the segment's line runs within a quarter of the box's smaller
+ side (plus `tol`) of the box centre - a pipe drawn through a symbol, not a
+ parallel pipe grazing its edge. The cut is the foot of the centre on the
+ segment, which must lie strictly between the segment's ends.
+ """
+ length = math.dist(a, b)
+ if length <= _EPS:
+ return None
+ centre = ((box[0] + box[2]) / 2.0, (box[1] + box[3]) / 2.0)
+ s, off = _along(centre, a, b)
+ reach = min(box[2] - box[0], box[3] - box[1]) / 4.0 + tol
+ if off > reach or not tol < s < length - tol:
+ return None
+ t = s / length
+ return (a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1]))
+
+
+def build_network(snap: Snapshot, *, layers=None, tol=1.0, label_search=None) -> dict:
+ """Runs of the line network on `layers` - see the module docstring for every rule.
+
+ Returns ``{"runs": [Run], "unassigned": [...], "attachments": {run id: [{"at", "tag"}]},
+ "equipment": {tag: [{"box", "handle", "at"}]}, "stats": {...}}``. Refused by
+ name before anything is read: a non-positive `tol` or `label_search`, a
+ named layer the drawing does not have (with the list it has), an empty
+ `layers`, and - when `layers` is omitted - a drawing with no layer filed
+ under piping or electrical at confidence >= 0.9.
+ """
+ tol = _positive(tol, "tol")
+ search = (
+ label_search_default(snap, tol=tol)
+ if label_search is None
+ else _positive(label_search, "label_search")
+ )
+ chosen = _choose_layers(snap, layers)
+ raw = _segments(snap, chosen)
+ verts = _Vertices(tol)
+ snapped = []
+ for a, b, bulge, handle, layer in raw:
+ u, v = verts.index(a), verts.index(b)
+ if u != v:
+ snapped.append((u, v, bulge, handle, layer))
+
+ # equipment first: an INSERT a tag labels is never a fitting
+ occurrences = tag_occurrences(snap)
+ equipment = equipment_boxes(
+ snap, occurrences, exclude_layers=chosen, search=EQUIPMENT_SEARCH_SHARE * search
+ )
+ equipment_handles = {box["handle"] for boxes in equipment.values() for box in boxes}
+ inserts = [
+ rec
+ for rec in snap.records
+ if rec.space == MODEL
+ and rec.type == "INSERT"
+ and rec.bbox is not None
+ and rec.handle not in equipment_handles
+ ]
+ reducer_boxes = [rec.bbox for rec in inserts if is_reducer(rec.block)]
+
+ # a straight segment drawn through the middle of a reducer is cut there, so
+ # the reducer separates its two sides (the split below makes the cut)
+ if reducer_boxes:
+ chords = [(verts.points[u], verts.points[v]) for u, v, _b, _h, _l in snapped]
+ chord_index = _ChordIndex(chords, tol)
+ for box in reducer_boxes:
+ for n in chord_index.in_box((box[0] - tol, box[1] - tol, box[2] + tol, box[3] + tol)):
+ u, v, bulge, _handle, _layer = snapped[n]
+ a, b = verts.points[u], verts.points[v]
+ if bulge != 0.0 or _in_box(a, box, tol) or _in_box(b, box, tol):
+ continue
+ cut = _through_middle(a, b, box, tol)
+ if cut is not None:
+ verts.index(cut)
+ pts = verts.points
+
+ # split straight segments at every vertex lying on them (T-junctions, overlaps)
+ order = sorted(range(len(pts)), key=lambda i: pts[i][0])
+ xs = [pts[i][0] for i in order]
+ pieces_by_key: dict[tuple[int, int, float], dict] = {}
+ overlap_count, overlap_length = 0, 0.0
+ for u, v, bulge, handle, layer in snapped:
+ chain = [u, v]
+ if bulge == 0.0:
+ pa, pb = pts[u], pts[v]
+ length = math.dist(pa, pb)
+ lo = bisect_left(xs, min(pa[0], pb[0]) - tol)
+ hi = bisect_right(xs, max(pa[0], pb[0]) + tol)
+ inner = []
+ for k in order[lo:hi]:
+ if k in (u, v):
+ continue
+ p = pts[k]
+ if not min(pa[1], pb[1]) - tol <= p[1] <= max(pa[1], pb[1]) + tol:
+ continue
+ s, off = _along(p, pa, pb)
+ if off <= tol and tol < s < length - tol:
+ inner.append((s, k))
+ chain = [u] + [k for _s, k in sorted(inner)] + [v]
+ for p, q in zip(chain, chain[1:], strict=False):
+ if p == q:
+ continue
+ b_signed = bulge if p < q else -bulge
+ key = (min(p, q), max(p, q), round(b_signed, 9))
+ piece_length = _piece_length(pts[p], pts[q], bulge)
+ known = pieces_by_key.get(key)
+ if known is not None:
+ overlap_count += 1
+ overlap_length += piece_length
+ known["handles"].add(handle)
+ known["layers"].add(layer)
+ continue
+ pieces_by_key[key] = {
+ "u": key[0],
+ "v": key[1],
+ "bulge": key[2],
+ "length": piece_length,
+ "layer": layer,
+ "layers": {layer},
+ "handles": {handle},
+ }
+ pieces = [pieces_by_key[key] for key in sorted(pieces_by_key)]
+
+ at_vertex: dict[int, list[int]] = {}
+ for k, piece in enumerate(pieces):
+ at_vertex.setdefault(piece["u"], []).append(k)
+ at_vertex.setdefault(piece["v"], []).append(k)
+
+ free = sorted(vertex for vertex, ks in at_vertex.items() if len(ks) == 1)
+ joined = sorted(vertex for vertex, ks in at_vertex.items() if len(ks) > 1)
+ cell = _typical_size([rec.bbox for rec in inserts], search)
+ free_grid = _PointGrid(((vertex, pts[vertex]) for vertex in free), cell)
+ joined_grid = _PointGrid(((vertex, pts[vertex]) for vertex in joined), cell)
+ bridges: list[tuple[int, int, bool, str]] = []
+ # vertices where pieces meet inside a reducer's box: a line broken at the
+ # reducer, or cut above where it was drawn straight through it
+ reducer_vertices: set[int] = set()
+ fittings, reducers, skipped = 0, 0, []
+ for rec in inserts:
+ reducer = is_reducer(rec.block)
+ inside = free_grid.in_box(rec.bbox, tol)
+ through = joined_grid.in_box(rec.bbox, tol) if reducer else []
+ if len(inside) > MAX_FITTING_ENDS:
+ skipped.append(rec.handle)
+ continue
+ if len(inside) < 2 and not through:
+ continue
+ fittings += 1
+ reducers += int(reducer)
+ reducer_vertices.update(through)
+ if len(inside) < 2:
+ continue
+ # every port joins every other: a three- or four-way fitting has no hub
+ for n, u in enumerate(inside):
+ for v in inside[n + 1 :]:
+ bridges.append((u, v, reducer, rec.handle))
+
+ # runs: union-find over pieces and bridges
+ parent = list(range(len(pts)))
+
+ def find(i: int) -> int:
+ while parent[i] != i:
+ parent[i] = parent[parent[i]]
+ i = parent[i]
+ return i
+
+ for piece in pieces:
+ parent[find(piece["u"])] = find(piece["v"])
+ for u, v, _reducer, _handle in bridges:
+ parent[find(u)] = find(v)
+ groups: dict[int, list[int]] = {}
+ for k, piece in enumerate(pieces):
+ groups.setdefault(find(piece["u"]), []).append(k)
+
+ def first_point(ks: list[int]) -> tuple[float, float]:
+ return min(min(pts[pieces[k]["u"]], pts[pieces[k]["v"]]) for k in ks)
+
+ ordered = sorted(groups.values(), key=first_point)
+ run_of: dict[int, int] = {}
+ for r, ks in enumerate(ordered):
+ for k in ks:
+ run_of[k] = r
+
+ # neighbours for continuity: shared vertices and bridges, each flagged when a
+ # reducer stands between the two pieces
+ neighbours: dict[int, set[tuple[int, bool]]] = {k: set() for k in range(len(pieces))}
+ for vertex, ks in at_vertex.items():
+ across = vertex in reducer_vertices
+ for k in ks:
+ neighbours[k].update((j, across) for j in ks if j != k)
+ for u, v, reducer, _handle in bridges:
+ for k in at_vertex.get(u, ()):
+ neighbours[k].update((j, reducer) for j in at_vertex.get(v, ()))
+ for k in at_vertex.get(v, ()):
+ neighbours[k].update((j, reducer) for j in at_vertex.get(u, ()))
+
+ # labels: diameters and supply / return words go to the nearest piece, unless
+ # a line on a layer that is not read stands nearer - the label is that line's
+ index = _ChordIndex([(pts[piece["u"]], pts[piece["v"]]) for piece in pieces], search)
+ unread = set(_layer_names(snap)) - set(chosen)
+ others = [(a, b, layer) for a, b, _bulge, _h, layer in _segments(snap, unread, open_only=True)]
+ other_index = _ChordIndex([(a, b) for a, b, _layer in others], search)
+
+ def nearest(p: Pt) -> tuple[int, float] | None:
+ found = []
+ for k in index.candidates(p):
+ piece = pieces[k]
+ d = _segment_distance(p, pts[piece["u"]], pts[piece["v"]])
+ if d <= search:
+ found.append((d, k))
+ if not found:
+ return None
+ d, k = min(found)
+ return k, d
+
+ def nearer_unread(p: Pt, dist: float) -> str | None:
+ """The layer of the nearest unread line strictly nearer to p than `dist`, else None."""
+ found = []
+ for n in other_index.candidates(p):
+ a, b, layer = others[n]
+ d = _segment_distance(p, a, b)
+ if d < dist - _EPS * max(1.0, dist):
+ found.append((d, layer))
+ return min(found)[1] if found else None
+
+ def entity_side(k: int) -> list[int]:
+ """The pieces of k's drawn entities reached from k without crossing a reducer."""
+ handles = pieces[k]["handles"]
+ seen, stack = {k}, [k]
+ while stack:
+ i = stack.pop()
+ for j, via_reducer in neighbours[i]:
+ if via_reducer or j in seen or not pieces[j]["handles"] & handles:
+ continue
+ seen.add(j)
+ stack.append(j)
+ return sorted(seen)
+
+ reaching: dict[int, list[tuple[float, str, str]]] = {}
+ votes: dict[int, list[tuple[float, str, str | None, str]]] = {}
+ labels_skipped = []
+ for rec in snap.records:
+ if rec.space != MODEL or rec.type not in TEXT_TYPES or not rec.text:
+ continue
+ where = _centre(rec)
+ if where is None:
+ continue
+ tokens = parse_diameters(rec.text)
+ direction = supply_return(rec.text)
+ if not tokens and direction is None:
+ continue
+ hit = nearest(where)
+ if hit is None:
+ continue
+ k, dist = hit
+ owner = nearer_unread(where, dist)
+ if owner is not None:
+ labels_skipped.append(
+ {
+ "handle": rec.handle,
+ "text": rec.text,
+ "reason": f"nearer to a line on layer {owner}, which is not read",
+ }
+ )
+ continue
+ if len(tokens) > 1:
+ labels_skipped.append(
+ {"handle": rec.handle, "text": rec.text, "reason": "more than one diameter"}
+ )
+ elif tokens:
+ for j in entity_side(k):
+ q = pieces[j]
+ reaching.setdefault(j, []).append(
+ (_segment_distance(where, pts[q["u"]], pts[q["v"]]), tokens[0], rec.handle)
+ )
+ if direction is not None:
+ family = _family_of(rec.text)
+ votes.setdefault(run_of[k], []).append((dist, direction, family, rec.handle))
+
+ direct: dict[int, tuple[str, str]] = {}
+ diameter_conflicts = []
+ for k, found in sorted(reaching.items()):
+ found.sort()
+ direct[k] = (found[0][1], found[0][2])
+ others = sorted({token for _d, token, _h in found} - {found[0][1]})
+ if others:
+ diameter_conflicts.append({"piece": k, "kept": found[0][1], "also": others})
+
+ best: dict[int, list] = {k: [0.0, {token}] for k, (token, _h) in direct.items()}
+ heap = [(0.0, k, token) for k, (token, _h) in direct.items()]
+ heapq.heapify(heap)
+ while heap:
+ d, k, token = heapq.heappop(heap)
+ current = best[k]
+ if d > current[0] + _EPS * max(1.0, d) or token not in current[1]:
+ continue
+ for j, via_reducer in neighbours[k]:
+ if via_reducer or j in direct:
+ continue
+ nd = d + (pieces[k]["length"] + pieces[j]["length"]) / 2.0
+ known = best.get(j)
+ if known is None or nd < known[0] - _EPS * max(1.0, nd):
+ best[j] = [nd, {token}]
+ heapq.heappush(heap, (nd, j, token))
+ elif abs(nd - known[0]) <= _EPS * max(1.0, nd) and token not in known[1]:
+ known[1].add(token)
+ heapq.heappush(heap, (nd, j, token))
+
+ # a free end joined through a fitting is no longer a run end
+ bridged: dict[int, int] = {}
+ for u, v, _reducer, _handle in bridges:
+ bridged[u] = bridged.get(u, 0) + 1
+ bridged[v] = bridged.get(v, 0) + 1
+ equipment_boxes_all = [(box["box"], tag) for tag, boxes in equipment.items() for box in boxes]
+ equipment_index = _BoxGrid(
+ ((box, (box, tag)) for box, tag in equipment_boxes_all),
+ grow=tol,
+ cell=_typical_size([box for box, _tag in equipment_boxes_all], search),
+ )
+
+ runs, unassigned, attachments, service_conflicts = [], [], {}, []
+ for r, ks in enumerate(ordered):
+ run_id = f"R{r + 1}"
+ service = _run_service([pieces[k] for k in ks], votes.get(r, []), run_id, service_conflicts)
+ edges = []
+ for n, k in enumerate(sorted(ks), start=1):
+ piece = pieces[k]
+ if k in direct:
+ diameter, source, label = direct[k][0], "direct", direct[k][1]
+ elif k in best and len(best[k][1]) == 1:
+ diameter, source, label = next(iter(best[k][1])), "continuity", None
+ else:
+ diameter, source, label = None, "unassigned", None
+ unassigned.append(
+ {
+ "run": run_id,
+ "edge": f"{run_id}.{n}",
+ "length": piece["length"],
+ "reason": "two different diameters equally near"
+ if k in best
+ else "no diameter label within label_search and none carried along the run",
+ }
+ )
+ edges.append(
+ {
+ "id": f"{run_id}.{n}",
+ "a": pts[piece["u"]],
+ "b": pts[piece["v"]],
+ "bulge": piece["bulge"],
+ "length": piece["length"],
+ "layer": piece["layer"],
+ "layers": tuple(sorted(piece["layers"])),
+ "handles": tuple(sorted(piece["handles"])),
+ "service": service,
+ "diameter": diameter,
+ "diameter_source": source,
+ "label": label,
+ }
+ )
+ degree: dict[int, int] = {}
+ for k in ks:
+ for vertex in (pieces[k]["u"], pieces[k]["v"]):
+ degree[vertex] = degree.get(vertex, 0) + 1
+ for vertex in degree:
+ degree[vertex] += bridged.get(vertex, 0)
+ end_points = sorted(pts[vertex] for vertex, count in degree.items() if count == 1)
+ attached = [{"at": p, "tag": _attach(p, equipment_index, tol)} for p in end_points]
+ attachments[run_id] = attached
+ runs.append(
+ Run(
+ id=run_id,
+ edges=tuple(edges),
+ service=service,
+ ends=tuple(end_points),
+ tags=tuple(sorted({a["tag"] for a in attached if a["tag"] is not None})),
+ )
+ )
+
+ return {
+ "runs": runs,
+ "unassigned": unassigned,
+ "attachments": attachments,
+ "equipment": equipment,
+ "stats": {
+ "layers": list(chosen),
+ "tol": tol,
+ "label_search": search,
+ "segments": len(raw),
+ "pieces": len(pieces),
+ "overlap_removed": overlap_count,
+ "overlap_length": overlap_length,
+ "fittings": fittings,
+ "reducers": reducers,
+ "fittings_skipped": skipped,
+ "runs": len(runs),
+ "labels_skipped": labels_skipped,
+ "diameter_conflicts": diameter_conflicts,
+ "service_conflicts": service_conflicts,
+ },
+ }
+
+
+def _family(service: str | None) -> str | None:
+ for family, pair in _PAIRS.items():
+ if service in pair:
+ return family
+ return None
+
+
+@functools.lru_cache(maxsize=4096)
+def _classified(name: str) -> dict:
+ """`classify_layer`, once per distinct name (a run has thousands of pieces, few layers)."""
+ return classify_layer(name)
+
+
+def _family_of(name: str | None) -> str | None:
+ """The supply / return pair (``cip``, ``glycol``) a layer name or a text names.
+
+ `classify_layer` files a CIP or glycol name without a direction word as
+ service ``unknown`` - the layer alone is not trusted with the direction -
+ so the pair is recovered by asking again with a direction word appended.
+ """
+ if not name:
+ return None
+ found = _classified(name)
+ family = _family(found["service"])
+ if family is None and found["service"] == "unknown" and found["keyword"] is not None:
+ family = _family(_classified(f"{name} supply")["service"])
+ return family
+
+
+def _run_service(pieces: list[dict], votes: list, run_id: str, conflicts: list) -> str:
+ weight: dict[str, float] = {}
+ families: dict[str, float] = {}
+ for piece in pieces:
+ service = _classified(piece["layer"])["service"]
+ weight[service] = weight.get(service, 0.0) + piece["length"]
+ family = _family_of(piece["layer"])
+ if family is not None:
+ families[family] = families.get(family, 0.0) + piece["length"]
+ known = {s: w for s, w in weight.items() if s != "unknown"} or weight
+ base = min(known, key=lambda s: (-known[s], s))
+ if not votes:
+ return base
+ votes = sorted(votes)
+ direction = votes[0][1]
+ if len({vote[1] for vote in votes}) > 1:
+ conflicts.append({"run": run_id, "kept": direction, "reason": "supply and return words"})
+ family = _family(base)
+ if family is None and base == "unknown" and families:
+ # a CIP / glycol layer that names no direction: the word decides it
+ family = min(families, key=lambda f: (-families[f], f))
+ if family is None:
+ family = next((vote[2] for vote in votes if vote[2]), None)
+ if family is None:
+ conflicts.append(
+ {"run": run_id, "kept": base, "reason": f"{direction} word on a line with no pair"}
+ )
+ return base
+ return _PAIRS[family][0 if direction == "supply" else 1]
+
+
+def _attach(p: Pt, index: _BoxGrid, tol: float) -> str | None:
+ hits = [(_box_area(box), tag) for box, tag in index.near(p) if _in_box(p, box, tol)]
+ return min(hits)[1] if hits else None
diff --git a/engineering/understand/report.py b/engineering/understand/report.py
new file mode 100644
index 0000000..59d210b
--- /dev/null
+++ b/engineering/understand/report.py
@@ -0,0 +1,682 @@
+"""The takeoff workbook: Metraj, Özet, Kontrol and Metodoloji, as XLSX and as CSV.
+
+The sheets follow the user's own takeoff workbook (spec §8): *Metraj* holds
+the rows, *Özet* the sums, *Kontrol* what needs a person's eye and
+*Metodoloji* how every number was reached - generated from the result, never
+typed. The sheet names stay as the user's workbook names them; the headers,
+the service names and the method text follow `lang` (tr / en / ru).
+
+CSV is always written, one file per sheet beside the workbook
+(``_metraj.csv`` ...), UTF-8 with a byte-order mark so a spreadsheet
+opens the Cyrillic and Turkish letters, a point as the decimal separator. XLSX
+needs openpyxl (the `office` extra); without it the CSV files are still
+written and the result carries the `xlsx_write` refusal instead of a path.
+Numbers are written rounded to 1 mm (three decimals of a metre).
+"""
+
+from __future__ import annotations
+
+import csv
+from pathlib import Path
+
+from backends.capability import UnsupportedCapabilityError
+from engineering.sheet.extract import xlsx_available
+
+__all__ = ["CSV_SLUGS", "LABELS", "LANGS", "SHEETS", "check_lang", "write_workbook"]
+
+LANGS = ("tr", "en", "ru")
+SHEETS = ("Metraj", "Özet", "Kontrol", "Metodoloji")
+CSV_SLUGS = {"Metraj": "metraj", "Özet": "ozet", "Kontrol": "kontrol", "Metodoloji": "metodoloji"}
+_INDEX = {lang: i for i, lang in enumerate(LANGS)}
+
+#: Every word the workbook prints, as (tr, en, ru).
+LABELS: dict[str, tuple[str, str, str]] = {
+ # Metraj
+ "room": ("Mahal", "Room", "Помещение"),
+ "service": ("Hizmet", "Service", "Среда"),
+ "diameter": ("Çap", "Diameter", "Диаметр"),
+ "direct_m": ("Doğrudan (m)", "Direct (m)", "Прямо (м)"),
+ "continuity_m": ("Süreklilik (m)", "Continuity (m)", "По непрерывности (м)"),
+ "unassigned_m": ("Atanmamış (m)", "Unassigned (m)", "Не назначено (м)"),
+ "net_m": ("Net (m)", "Net (m)", "Нетто (м)"),
+ "allowance": ("Pay oranı", "Allowance", "Запас"),
+ "allowance_m": ("Pay (m)", "Allowance (m)", "Запас (м)"),
+ "total_m": ("Paylı toplam (m)", "Total incl. allowance (m)", "Итого с запасом (м)"),
+ "schematic_m": ("Şematik (m)", "Schematic (m)", "По схеме (м)"),
+ "status": ("Durum", "Status", "Статус"),
+ "runs": ("Hatlar", "Runs", "Линии"),
+ # Özet, Kontrol, Metodoloji
+ "group": ("Grup", "Group", "Группа"),
+ "key": ("Anahtar", "Key", "Ключ"),
+ "run": ("Hat", "Run", "Линия"),
+ "kind": ("Tür", "Kind", "Вид"),
+ "tags": ("Etiketler", "Tags", "Теги"),
+ "reason": ("Neden", "Reason", "Причина"),
+ "topic": ("Konu", "Topic", "Раздел"),
+ "value": ("Değer", "Value", "Значение"),
+ "by_service": ("Hizmete göre", "By service", "По среде"),
+ "by_diameter": ("Çapa göre", "By diameter", "По диаметру"),
+ "by_room": ("Mahale göre", "By room", "По помещению"),
+ "total": ("TOPLAM", "TOTAL", "ИТОГО"),
+ "routed": ("Yönlendirilen", "Routed", "Трассировано"),
+ "unroutable_total": ("Yönlendirilemeyen (C)", "Unroutable (C)", "Без трассы (C)"),
+ "yes": ("evet", "yes", "да"),
+ "no": ("hayır", "no", "нет"),
+ # services
+ "product": ("Ürün", "Product", "Продукт"),
+ "cip_supply": ("CIP besleme", "CIP supply", "CIP подача"),
+ "cip_return": ("CIP dönüş", "CIP return", "CIP возврат"),
+ "glycol_supply": ("Glikol besleme", "Glycol supply", "Гликоль подача"),
+ "glycol_return": ("Glikol dönüş", "Glycol return", "Гликоль возврат"),
+ "steam": ("Buhar", "Steam", "Пар"),
+ "condensate": ("Kondens", "Condensate", "Конденсат"),
+ "cold_water": ("Soğuk su", "Cold water", "Холодная вода"),
+ "hot_water": ("Sıcak su", "Hot water", "Горячая вода"),
+ "ice_water": ("Buzlu su", "Ice water", "Ледяная вода"),
+ "compressed_air": ("Basınçlı hava", "Compressed air", "Сжатый воздух"),
+ "drain": ("Drenaj", "Drain", "Дренаж"),
+ "electrical": ("Elektrik", "Electrical", "Электрика"),
+ "unknown": ("Bilinmeyen", "Unknown", "Неизвестно"),
+ # Kontrol kinds and reasons
+ "unassigned_diameter": ("Çap atanmamış", "Diameter unassigned", "Диаметр не назначен"),
+ "unroutable": ("Yönlendirilemez", "Unroutable", "Трассировка невозможна"),
+ "end_not_on_equipment": (
+ "Bir uç ekipmanda değil",
+ "An end is on no equipment",
+ "Конец не на оборудовании",
+ ),
+ "single_tag": ("Tek etiket", "Only one tag", "Только один тег"),
+ "tag_not_on_layout": (
+ "Etiket yerleşimde yok",
+ "Tag not on the layout",
+ "Тега нет на планировке",
+ ),
+ "tag_ambiguous": (
+ "Etiket yerleşimde birden çok kez yazılı",
+ "Tag written more than once on the layout",
+ "Тег повторяется на планировке",
+ ),
+ "no_diameter_label": ("Çap etiketi yok", "No diameter label", "Нет обозначения диаметра"),
+ # Metodoloji topics
+ "source_pid": ("Kaynak: P&ID", "Source: P&ID", "Источник: P&ID"),
+ "source_layout": ("Kaynak: yerleşim", "Source: layout", "Источник: планировка"),
+ "length_source": ("Uzunluk kaynağı", "Length source", "Источник длины"),
+ "choice": ("Seçim gerekçesi", "Why this source", "Почему этот источник"),
+ "scale_verdict": ("Ölçek kontrolü: sonuç", "Scale check: verdict", "Проверка масштаба: вывод"),
+ "scale_factor": (
+ "Ölçek kontrolü: oran (P&ID mm / yerleşim mm)",
+ "Scale check: factor (P&ID mm per layout mm)",
+ "Проверка масштаба: коэффициент (мм P&ID на мм планировки)",
+ ),
+ "scale_iqr": ("Ölçek kontrolü: IQR", "Scale check: IQR", "Проверка масштаба: IQR"),
+ "scale_within": (
+ "Ölçek kontrolü: ±%10 içindeki çiftler",
+ "Scale check: pairs within ±10 %",
+ "Проверка масштаба: пары в пределах ±10 %",
+ ),
+ "scale_pairs": ("Ölçek kontrolü: çift sayısı", "Scale check: pairs", "Проверка масштаба: пар"),
+ "scale_matched": (
+ "Ölçek kontrolü: eşleşen etiketler",
+ "Scale check: matched tags",
+ "Проверка масштаба: совпавшие теги",
+ ),
+ "scale_ambiguous": (
+ "Ölçek kontrolü: belirsiz etiketler",
+ "Scale check: ambiguous tags",
+ "Проверка масштаба: неоднозначные теги",
+ ),
+ "scale_note": ("Ölçek kontrolü: not", "Scale check: note", "Проверка масштаба: примечание"),
+ "scale_verified": ("Ölçek doğrulandı", "Scale verified", "Масштаб подтверждён"),
+ "units_pid": ("Birim: P&ID", "Units: P&ID", "Единицы: P&ID"),
+ "units_layout": ("Birim: yerleşim", "Units: layout", "Единицы: планировка"),
+ "rule_route": ("Kural: güzergâh", "Rule: route", "Правило: трасса"),
+ "rule_vertical": (
+ "Kural: bağlantı başına düşey pay (m)",
+ "Rule: vertical allowance per connection (m)",
+ "Правило: вертикальный запас на соединение (м)",
+ ),
+ "rule_allowance": ("Kural: pay oranı", "Rule: allowance", "Правило: запас"),
+ "rule_diameter": ("Kural: çap", "Rule: diameter", "Правило: диаметр"),
+ "rule_service": ("Kural: hizmet", "Rule: service", "Правило: среда"),
+ "rule_status": ("Kural: durum", "Rule: status", "Правило: статус"),
+ "rule_rooms": ("Kural: mahaller", "Rule: rooms", "Правило: помещения"),
+ "rule_multi": (
+ "Kural: çok çaplı hat",
+ "Rule: several diameters",
+ "Правило: несколько диаметров",
+ ),
+ "tol": ("Tolerans (çizim birimi)", "Tolerance (drawing units)", "Допуск (ед. чертежа)"),
+ "label_search": (
+ "Etiket arama mesafesi (çizim birimi)",
+ "Label search distance (drawing units)",
+ "Радиус поиска обозначений (ед. чертежа)",
+ ),
+ "overlap": (
+ "Çift çizilmiş, bir kez sayılan boru (m)",
+ "Double-drawn pipe counted once (m)",
+ "Дважды начерченная труба, учтена один раз (м)",
+ ),
+ "services": ("Kapsanan hizmetler", "Services taken off", "Учтённые среды"),
+ "warning": ("Uyarı", "Warning", "Предупреждение"),
+ # cable takeoff
+ "tag": ("Etiket", "Tag", "Тег"),
+ "kw": ("Güç (kW)", "Power (kW)", "Мощность (кВт)"),
+ "phases": ("Faz", "Phases", "Фазы"),
+ "voltage": ("Gerilim (V)", "Voltage (V)", "Напряжение (В)"),
+ "neutral": ("Nötr", "Neutral", "Нейтраль"),
+ "vfd": ("Sürücü (VFD)", "VFD", "ЧРП"),
+ "panel": ("Pano", "Panel", "Шкаф"),
+ "length_m": ("Mesafe (m)", "Distance (m)", "Расстояние (м)"),
+ "cable_m": ("Kablo (m)", "Cable (m)", "Кабель (м)"),
+ "section": ("Kesit", "Section", "Сечение"),
+ "package": ("Paket", "Package", "Комплект"),
+ "loads": ("Yük sayısı", "Loads", "Нагрузок"),
+ "item": ("Madde", "Item", "Пункт"),
+ "detail": ("Ayrıntı", "Detail", "Подробности"),
+ "by_panel": ("Panoya göre", "By panel", "По шкафу"),
+ "missing_power": ("Güç yazılmamış", "No power stated", "Мощность не указана"),
+ "missing_panel": ("Pano belirtilmemiş", "No panel named", "Шкаф не указан"),
+ "no_layout": ("Yerleşim yok", "No layout", "Нет планировки"),
+ "panel_not_on_layout": (
+ "Pano yerleşimde yok",
+ "Panel not on the layout",
+ "Шкафа нет на планировке",
+ ),
+ "no_section_rule": ("Kesit kuralı yok", "No section rule covers it", "Нет правила сечения"),
+ "conflicting_power": (
+ "Çelişen güç yazıları",
+ "Conflicting power texts",
+ "Противоречивая мощность",
+ ),
+ "rule_cable_route": ("Kural: kablo güzergâhı", "Rule: cable route", "Правило: трасса кабеля"),
+ "rule_rounding": ("Kural: yuvarlama", "Rule: rounding", "Правило: округление"),
+ "rule_power": ("Kural: güç", "Rule: power", "Правило: мощность"),
+ "rule_panel": ("Kural: pano", "Rule: panel", "Правило: шкаф"),
+ "rule_package": ("Kural: pano paketleri", "Rule: panel packages", "Правило: комплектные шкафы"),
+ "rule_section": ("Kural: kesit", "Rule: section", "Правило: сечение"),
+ "section_rules": (
+ "Kesit kuralları (çağıranın)",
+ "Section rules (the caller's)",
+ "Правила сечений (вызывающего)",
+ ),
+}
+
+#: The method sheet's sentences, as (tr, en, ru).
+TEXTS: dict[str, tuple[str, str, str]] = {
+ "requested": ("çağıran seçti", "chosen by the caller", "выбран вызывающим"),
+ "forced": (
+ "ölçek kontrolü P&ID'yi şematik bulduğu halde istek üzerine (force=True) P&ID'den ölçüldü",
+ "measured on the P&ID on request (force=True) although the scale check calls it schematic",
+ "измерено по P&ID по запросу (force=True), хотя проверка масштаба считает его схемой",
+ ),
+ "no_layout": (
+ "yerleşim verilmedi: P&ID'den ölçüldü, ölçek doğrulanmadı",
+ "no layout given: measured on the P&ID, scale not verified",
+ "планировка не задана: измерено по P&ID, масштаб не подтверждён",
+ ),
+ "auto_to_scale": (
+ "ölçek kontrolü P&ID'yi ölçekli buldu: oran üzerinden P&ID'den ölçüldü",
+ "the scale check calls the P&ID to scale: measured on it through the factor",
+ "проверка масштаба считает P&ID масштабным: измерено по нему через коэффициент",
+ ),
+ "auto_layout": (
+ "ölçek kontrolü P&ID'yi ölçekli bulmadı: yerleşimden ölçüldü",
+ "the scale check does not call the P&ID to scale: measured on the layout",
+ "проверка масштаба не считает P&ID масштабным: измерено по планировке",
+ ),
+ "scale_note": (
+ "Etiketler ekipman merkezi değildir: ±%10 bant ve 2 m alt sınır bunu karşılar.",
+ "Tag labels are not equipment centres: the ±10 % band and the 2 m floor absorb that.",
+ "Теги — не центры оборудования: полоса ±10 % и порог 2 м это учитывают.",
+ ),
+ "rule_route": (
+ "Yerleşim uzunluğu = hattın bağladığı etiket konumlarının dik açılı (Manhattan) en küçük "
+ "kapsayan ağacı; her bağlantı önce x, sonra y boyunca.",
+ "Layout length = the rectilinear (Manhattan) minimum spanning tree of the tag positions "
+ "the run joins; each connection along x first, then y.",
+ "Длина по планировке = прямоугольное (манхэттенское) минимальное остовное дерево "
+ "позиций тегов, которые соединяет линия; каждое соединение сначала по x, затем по y.",
+ ),
+ "rule_diameter": (
+ "Çap etiketleri çizildiği gibi alınır (Ø51, SMS51, DN20 farklı ölçülerdir). Doğrudan: "
+ "arama mesafesindeki etiket; süreklilik: etiketli parçadan hat boyunca taşınır, "
+ "redüksiyondan geçmez; atanmamış: ikisi de değil (Kontrol sayfasında).",
+ "Diameter labels are kept as drawn (Ø51, SMS51 and DN20 are different sizes). Direct: a "
+ "label within the search distance; continuity: carried along the run from a labelled "
+ "piece, never across a reducer; unassigned: neither (listed on Kontrol).",
+ "Обозначения диаметров сохраняются как на чертеже (Ø51, SMS51 и DN20 — разные "
+ "размеры). Прямо: обозначение в радиусе поиска; по непрерывности: перенос вдоль линии "
+ "от обозначенного участка, но не через переход; не назначено: ни то, ни другое (лист "
+ "Kontrol).",
+ ),
+ "rule_service": (
+ "Hizmet katmandan alınır; hattın yakınındaki besleme / dönüş sözcüğü (ПОДАЧА, ВОЗВРАТ, "
+ "BESLEME, DÖNÜŞ, SUPPLY, RETURN, AANVOER, RETOUR) tüm hat için onu geçersiz kılar.",
+ "Service comes from the layer; a supply / return word near the run (ПОДАЧА, ВОЗВРАТ, "
+ "BESLEME, DÖNÜŞ, SUPPLY, RETURN, AANVOER, RETOUR) overrides it for the whole run.",
+ "Среда определяется по слою; слово подачи / возврата рядом с линией (ПОДАЧА, ВОЗВРАТ, "
+ "BESLEME, DÖNÜŞ, SUPPLY, RETURN, AANVOER, RETOUR) переопределяет её для всей линии.",
+ ),
+ "rule_status": (
+ "A: her uç yerleşimde bulunan bir etikette ve her çap doğrudan. B: süreklilikle ya da "
+ "atanmamış çap. C: yönlendirilemez - yalnız şematik uzunluk, Kontrol sayfasında, "
+ "toplama katılır ve işaretlenir.",
+ "A: every end on a tag placed on the layout and every diameter direct. B: a diameter by "
+ "continuity or unassigned. C: not routable - schematic length only, on Kontrol, counted "
+ "in the total and flagged.",
+ "A: каждый конец на теге, найденном на планировке, и каждый диаметр прямо. B: диаметр "
+ "по непрерывности или не назначен. C: трассировка невозможна — только длина по схеме, "
+ "на листе Kontrol, входит в итог и помечена.",
+ ),
+ "rule_rooms": (
+ "Yönlendirilen uzunluk, güzergâhın geçtiği yerleşim mahallerine paylaştırılır (duvar "
+ "yüzleri ve mahal etiketlerinden çokgenler); çokgen yoksa hatta en yakın mahal etiketine.",
+ "A routed length is shared among the layout rooms its route crosses (room polygons from "
+ "the wall faces and room labels); without polygons, the room label nearest the run.",
+ "Трассированная длина распределяется по помещениям планировки, через которые проходит "
+ "трасса (контуры из граней стен и обозначений помещений); без контуров — ближайшему "
+ "обозначению помещения.",
+ ),
+ "rule_multi": (
+ "Birden çok çaplı bir hat, uzunluğunu P&ID üzerindeki çizili uzunlukları oranında "
+ "paylaştırır.",
+ "A run holding several diameters shares its length among them in proportion to their "
+ "drawn length on the P&ID.",
+ "Линия с несколькими диаметрами делит длину между ними пропорционально их длине на "
+ "схеме P&ID.",
+ ),
+ "rule_cable_route": (
+ "Uzunluk = yerleşimde yükün etiketinden panosunun etiketine Manhattan mesafesi.",
+ "Length = the Manhattan distance on the layout from the load's tag to its panel's tag.",
+ "Длина = манхэттенское расстояние на планировке от тега нагрузки до тега её шкафа.",
+ ),
+ "rule_rounding": (
+ "Kablo = YUKARIYUVARLA(uzunluk × (1 + pay)) tam metreye; uzunluk önce 1e-6 m'ye "
+ "yuvarlanır.",
+ "Cable = ROUNDUP(length × (1 + allowance)) to the whole metre; the length is rounded to "
+ "1e-6 m first.",
+ "Кабель = ОКРВВЕРХ(длина × (1 + запас)) до целого метра; длина сначала округляется до "
+ "1e-6 м.",
+ ),
+ "rule_power": (
+ "Güç, P&ID'de her etiketin yakınındaki elektrik yazılarından okunur; gücü yazılmamış "
+ "yükün hücresi boş kalır ve açık madde olur - güç asla uydurulmaz.",
+ "Power is read from the P&ID's electrical texts near each tag; a load with no stated "
+ "power keeps an empty cell and an open item - power is never invented.",
+ "Мощность берётся из электрических надписей P&ID рядом с каждым тегом; у нагрузки без "
+ "указанной мощности ячейка пуста и заводится открытый вопрос — мощность никогда не "
+ "выдумывается.",
+ ),
+ "rule_panel": (
+ "Pano, yerleşimdeki bağlantı notlarından ('Wiring to CP1' gibi), yoksa P&ID'den alınır; "
+ "adlar normalleştirilir (CP 1 → CP-1).",
+ "The panel comes from the layout's wiring callouts ('Wiring to CP1' and its variants), "
+ "else from the P&ID; names are normalised (CP 1 → CP-1).",
+ "Шкаф берётся из выносок подключения на планировке ('Wiring to CP1' и варианты), иначе "
+ "из P&ID; имена нормализуются (CP 1 → CP-1).",
+ ),
+ "rule_package": (
+ "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 "
+ "sayılmaz.",
+ "Panel packages: a load wired to a panel that itself states a power is part of that "
+ "package; room and panel totals count the package's power and not its child loads, so "
+ "no power is counted twice.",
+ "Комплектные шкафы: нагрузка, подключённая к шкафу с собственной указанной мощностью, "
+ "входит в его комплект; итоги по помещениям и шкафам учитывают мощность комплекта, а не "
+ "его дочерних нагрузок, — ничего не учитывается дважды.",
+ ),
+ "rule_section": (
+ "Kablo kesitleri yalnız çağıranın section_rules tablosundan gelir; tablo yoksa sütun "
+ "boş kalır. Hiçbir standart tablo uygulanmaz.",
+ "Cable sections come only from the caller's section_rules; without them the column "
+ "stays blank. No standard table is applied.",
+ "Сечения кабелей — только из таблицы section_rules вызывающего; без неё столбец пуст. "
+ "Никакая стандартная таблица не применяется.",
+ ),
+}
+
+
+def check_lang(lang: str) -> str:
+ """Refuse a language the workbook does not speak, naming the three it does."""
+ if lang not in LANGS:
+ raise ValueError(f"lang: {lang!r} unknown; choose from {', '.join(LANGS)}")
+ return lang
+
+
+def _t(key: str, lang: str) -> str:
+ """A label in `lang`; a key with no label (a diameter, a room number) prints as itself."""
+ entry = LABELS.get(key)
+ return entry[_INDEX[lang]] if entry else key
+
+
+def _say(key: str, lang: str) -> str:
+ """One of the method sheet's sentences in `lang`."""
+ return TEXTS[key][_INDEX[lang]]
+
+
+def _num(value):
+ return None if value is None else round(float(value), 3)
+
+
+def _yes(value, lang: str):
+ return None if value is None else _t("yes" if value else "no", lang)
+
+
+def _pipe_sheets(result: dict, lang: str) -> dict[str, tuple[list, list]]:
+ columns = (
+ "room",
+ "service",
+ "diameter",
+ "direct_m",
+ "continuity_m",
+ "unassigned_m",
+ "net_m",
+ "allowance",
+ "allowance_m",
+ "total_m",
+ "schematic_m",
+ "status",
+ "runs",
+ )
+ rows = [
+ [
+ row["room"],
+ _t(row["service"], lang),
+ row["diameter"],
+ _num(row["direct_m"]),
+ _num(row["continuity_m"]),
+ _num(row["unassigned_m"]),
+ _num(row["net_m"]),
+ row["allowance"],
+ _num(row["allowance_m"]),
+ _num(row["total_m"]),
+ _num(row["schematic_m"]),
+ row["status"],
+ ", ".join(row["runs"]),
+ ]
+ for row in result["rows"]
+ ]
+ totals = result["totals"]
+ a = totals["allowance"]
+ rows.append(
+ [
+ _t("routed", lang),
+ None,
+ None,
+ _num(sum(r["direct_m"] for r in result["rows"])),
+ _num(sum(r["continuity_m"] for r in result["rows"])),
+ _num(sum(r["unassigned_m"] for r in result["rows"])),
+ _num(totals["routed_m"]),
+ a,
+ _num(totals["routed_m"] * a),
+ _num(totals["routed_m"] * (1.0 + a)),
+ _num(sum(r["schematic_m"] for r in result["rows"])),
+ None,
+ None,
+ ]
+ )
+ rows.append(
+ [
+ _t("unroutable_total", lang),
+ None,
+ None,
+ None,
+ None,
+ None,
+ _num(totals["unroutable_m"]),
+ a,
+ _num(totals["unroutable_m"] * a),
+ _num(totals["unroutable_m"] * (1.0 + a)),
+ _num(totals["unroutable_m"]),
+ "C",
+ str(totals["flagged_runs"]),
+ ]
+ )
+ rows.append(
+ [
+ _t("total", lang),
+ None,
+ None,
+ None,
+ None,
+ None,
+ _num(totals["net_m"]),
+ a,
+ _num(totals["allowance_m"]),
+ _num(totals["total_m"]),
+ None,
+ None,
+ None,
+ ]
+ )
+
+ summary = []
+ for group, field in (
+ ("by_service", "service"),
+ ("by_diameter", "diameter"),
+ ("by_room", "room"),
+ ):
+ for item in result["summary"][group]:
+ key = _t(item["key"], lang) if field == "service" else item["key"]
+ summary.append([_t(group, lang), key, _num(item["net_m"]), _num(item["total_m"])])
+ summary.append([_t("total", lang), None, _num(totals["net_m"]), _num(totals["total_m"])])
+
+ control = [
+ [
+ c["run"],
+ _t(c["kind"], lang),
+ _t(c["service"], lang),
+ ", ".join(c["tags"]),
+ _num(c["schematic_m"]),
+ _t(c["reason"], lang),
+ ]
+ for c in result["control"]
+ ]
+ return {
+ "Metraj": ([_t(c, lang) for c in columns], rows),
+ "Özet": ([_t(c, lang) for c in ("group", "key", "net_m", "total_m")], summary),
+ "Kontrol": (
+ [_t(c, lang) for c in ("run", "kind", "service", "tags", "schematic_m", "reason")],
+ control,
+ ),
+ "Metodoloji": ([_t("topic", lang), _t("value", lang)], _pipe_method(result, lang)),
+ }
+
+
+def _unit_text(unit: dict | None) -> str:
+ if not unit:
+ return "—"
+ return f"INSUNITS {unit['declared'] or '—'} → {unit['inferred']}"
+
+
+def _pipe_method(result: dict, lang: str) -> list[list]:
+ method, scale = result["method"], result["scale"]
+ reason = method["reason"]
+ if reason.startswith("auto_") and reason != "auto_to_scale":
+ reason = "auto_layout"
+ rows = [
+ ["source_pid", method["sources"]["pid"]],
+ ["source_layout", method["sources"]["layout"] or "—"],
+ ["length_source", method["length_source"]],
+ ["choice", _say(reason, lang)],
+ ["scale_verdict", scale.get("verdict")],
+ ["scale_factor", None if scale.get("factor") is None else round(float(scale["factor"]), 6)],
+ ["scale_iqr", None if scale.get("iqr") is None else round(float(scale["iqr"]), 6)],
+ [
+ "scale_within",
+ None if scale.get("within_10pct") is None else round(float(scale["within_10pct"]), 4),
+ ],
+ ["scale_pairs", scale.get("pair_count")],
+ ["scale_matched", len(scale.get("matched", {}))],
+ ["scale_ambiguous", ", ".join(scale.get("ambiguous", [])) or "—"],
+ ["scale_note", _say("scale_note", lang)],
+ ["scale_verified", _yes(result["scale_verified"], lang)],
+ ["units_pid", _unit_text(result["units"]["pid"])],
+ ["units_layout", _unit_text(result["units"]["layout"])],
+ ["services", ", ".join(_t(s, lang) for s in method["services"])],
+ ["rule_route", _say("rule_route", lang)],
+ ["rule_vertical", method["vertical_allowance"]],
+ ["rule_allowance", method["allowance"]],
+ ["rule_diameter", _say("rule_diameter", lang)],
+ ["rule_service", _say("rule_service", lang)],
+ ["rule_status", _say("rule_status", lang)],
+ ["rule_rooms", _say("rule_rooms", lang)],
+ ["rule_multi", _say("rule_multi", lang)],
+ ["tol", method["tol"]],
+ ["label_search", round(float(method["label_search"]), 6)],
+ ["overlap", _num(method["overlap_length_m"])],
+ ]
+ rows += [["warning", warning] for warning in result["warnings"]]
+ return [[_t(topic, lang), value] for topic, value in rows]
+
+
+def _cable_sheets(result: dict, lang: str) -> dict[str, tuple[list, list]]:
+ columns = (
+ "tag",
+ "room",
+ "kw",
+ "phases",
+ "voltage",
+ "neutral",
+ "vfd",
+ "panel",
+ "length_m",
+ "allowance",
+ "cable_m",
+ "section",
+ "package",
+ )
+ rows = [
+ [
+ row["tag"],
+ row["room"],
+ row["kw"],
+ row["phases"],
+ row["voltage"],
+ _yes(row["neutral"], lang),
+ _yes(row["vfd"], lang),
+ row["panel"],
+ _num(row["length_m"]),
+ row["allowance"],
+ row["cable_m"],
+ row["section"],
+ row["package"],
+ ]
+ for row in result["rows"]
+ ]
+ totals = result["totals"]
+ rows.append(
+ [
+ _t("total", lang),
+ None,
+ _num(totals["known_kw"]),
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ totals["cable_m"],
+ None,
+ None,
+ ]
+ )
+ summary = []
+ for group in ("by_room", "by_panel"):
+ for item in result["summary"][group]:
+ summary.append(
+ [_t(group, lang), item["key"], item["loads"], _num(item["kw"]), item["cable_m"]]
+ )
+ summary.append(
+ [_t("total", lang), None, totals["loads"], _num(totals["known_kw"]), totals["cable_m"]]
+ )
+ control = [
+ [item["tag"], _t(item["item"], lang), item["detail"]] for item in result["open_items"]
+ ]
+ return {
+ "Metraj": ([_t(c, lang) for c in columns], rows),
+ "Özet": ([_t(c, lang) for c in ("group", "key", "loads", "kw", "cable_m")], summary),
+ "Kontrol": ([_t(c, lang) for c in ("tag", "item", "detail")], control),
+ "Metodoloji": ([_t("topic", lang), _t("value", lang)], _cable_method(result, lang)),
+ }
+
+
+def _cable_method(result: dict, lang: str) -> list[list]:
+ method = result["method"]
+ rules = method["section_rules"]
+ rows = [
+ ["source_pid", method["sources"]["pid"]],
+ ["source_layout", method["sources"]["layout"] or "—"],
+ ["units_pid", _unit_text(result["units"]["pid"])],
+ ["units_layout", _unit_text(result["units"]["layout"])],
+ ["rule_cable_route", _say("rule_cable_route", lang)],
+ ["rule_allowance", method["allowance"]],
+ ["rule_rounding", _say("rule_rounding", lang)],
+ ["rule_power", _say("rule_power", lang)],
+ ["rule_panel", _say("rule_panel", lang)],
+ ["rule_package", _say("rule_package", lang)],
+ ["rule_section", _say("rule_section", lang)],
+ ["section_rules", "; ".join(_rule_text(rule) for rule in rules) or "—"],
+ ["label_search", round(float(method["search"]["pid"]), 6)],
+ ]
+ rows += [["warning", warning] for warning in result["warnings"]]
+ return [[_t(topic, lang), value] for topic, value in rows]
+
+
+def _rule_text(rule: dict) -> str:
+ phases = "" if rule["phases"] is None else f" / {rule['phases']} ph"
+ return f"<= {rule['max_kw']:g} kW{phases}: {rule['section']}"
+
+
+_BUILDERS = {"pipe": _pipe_sheets, "cable": _cable_sheets}
+
+
+def write_workbook(result: dict, *, kind: str, lang: str, path: str) -> dict:
+ """Write the takeoff: CSV always, XLSX when openpyxl is installed.
+
+ ``{"xlsx": path | None, "csv": [paths], "refused": {...} | None}``. Refused
+ before a file is written: a `kind` it builds no sheets for, a `lang` other
+ than tr / en / ru, and a `path` with a suffix other than .xlsx. Without
+ openpyxl the CSV files are written and `refused` carries the
+ `xlsx_write` capability refusal naming the `office` extra.
+ """
+ if kind not in _BUILDERS:
+ raise ValueError(f"kind: {kind!r} unknown; choose from {', '.join(_BUILDERS)}")
+ check_lang(lang)
+ target = Path(path)
+ if target.suffix.lower() not in ("", ".xlsx"):
+ raise ValueError(
+ f"path: the workbook is written as .xlsx with CSV files beside it; got {target.suffix!r}"
+ )
+ sheets = _BUILDERS[kind](result, lang)
+ xlsx = target.with_suffix(".xlsx")
+ xlsx.parent.mkdir(parents=True, exist_ok=True)
+ written = []
+ for name, (header, rows) in sheets.items():
+ csv_path = xlsx.with_name(f"{xlsx.stem}_{CSV_SLUGS[name]}.csv")
+ with open(csv_path, "w", newline="", encoding="utf-8-sig") as handle:
+ writer = csv.writer(handle)
+ writer.writerow(header)
+ writer.writerows([["" if cell is None else cell for cell in row] for row in rows])
+ written.append(str(csv_path))
+ if not xlsx_available():
+ refusal = UnsupportedCapabilityError(
+ "xlsx_write",
+ "The takeoff workbook needs openpyxl for XLSX, which is not installed. Install it "
+ 'with: pip install -e ".[office]" (or pip install openpyxl). The same sheets were '
+ f"written as CSV: {', '.join(written)}",
+ )
+ return {"xlsx": None, "csv": written, "refused": refusal.to_dict()}
+ from openpyxl import Workbook
+
+ book = Workbook()
+ for i, (name, (header, rows)) in enumerate(sheets.items()):
+ sheet = book.active if i == 0 else book.create_sheet()
+ sheet.title = name
+ sheet.append(header)
+ for row in rows:
+ sheet.append(row)
+ book.save(xlsx)
+ return {"xlsx": str(xlsx), "csv": written, "refused": None}
diff --git a/engineering/understand/route.py b/engineering/understand/route.py
new file mode 100644
index 0000000..99e14be
--- /dev/null
+++ b/engineering/understand/route.py
@@ -0,0 +1,63 @@
+"""Orthogonal lengths on a layout: Manhattan distance and the rectilinear MST.
+
+A pipe or cable on a plant floor runs along the building grid, so its length
+between two points is estimated as |dx| + |dy| - never the diagonal. A run that
+joins more than two pieces of equipment is estimated as the minimum spanning
+tree of their positions under that distance (Prim's algorithm); a two-ended run
+is simply |dx| + |dy|. This is the tree on the given points only - no Steiner
+points are added - so it is the length a drafter gets by routing each
+connection separately, and it is exact for the points given.
+
+A route is drawn along x first, then y (`route_legs`): deterministic, and
+stated on every method sheet that uses it.
+"""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Sequence
+
+from engineering.understand.snapshot import Pt
+
+__all__ = ["manhattan", "rmst", "route_legs"]
+
+
+def manhattan(a: Pt, b: Pt) -> float:
+ """|dx| + |dy| between two points."""
+ return abs(a[0] - b[0]) + abs(a[1] - b[1])
+
+
+def rmst(points: Sequence[Pt]) -> tuple[float, tuple[tuple[int, int], ...]]:
+ """Rectilinear minimum spanning tree: (total length, edges as (from, to) indices).
+
+ Prim's algorithm from point 0; each edge is (the tree vertex it grows from,
+ the new vertex), in the order the tree grows. Ties go to the lower index,
+ so the answer is deterministic. Fewer than two points: (0.0, ()).
+ """
+ n = len(points)
+ if n < 2:
+ return 0.0, ()
+ in_tree = [False] * n
+ best = [math.inf] * n
+ parent = [-1] * n
+ best[0] = 0.0
+ total = 0.0
+ edges: list[tuple[int, int]] = []
+ for _ in range(n):
+ k = min((i for i in range(n) if not in_tree[i]), key=lambda i: (best[i], i))
+ in_tree[k] = True
+ if parent[k] >= 0:
+ edges.append((parent[k], k))
+ total += best[k]
+ for j in range(n):
+ if not in_tree[j]:
+ d = manhattan(points[k], points[j])
+ if d < best[j]:
+ best[j], parent[j] = d, k
+ return total, tuple(edges)
+
+
+def route_legs(a: Pt, b: Pt) -> tuple[tuple[Pt, Pt], ...]:
+ """The orthogonal route from a to b: along x first, then y; zero-length legs dropped."""
+ corner = (b[0], a[1])
+ return tuple(leg for leg in ((a, corner), (corner, b)) if leg[0] != leg[1])
diff --git a/engineering/understand/takeoff.py b/engineering/understand/takeoff.py
new file mode 100644
index 0000000..5d35b57
--- /dev/null
+++ b/engineering/understand/takeoff.py
@@ -0,0 +1,1142 @@
+"""Pipe takeoff rows: topology from the P&ID, lengths from the layout (spec §8).
+
+A P&ID says which equipment a line joins, what it carries and how big it is;
+it does not say how long the pipe is unless the scale check calls it to scale.
+So each run found by `engineering.understand.network` is measured one of two
+ways, and the rows say which:
+
+* **layout** - the positions of the tags the run joins, on the layout, joined
+ by the rectilinear minimum spanning tree (`route.rmst`), plus
+ `vertical_allowance` metres per tree edge. The P&ID's drawn length is kept
+ beside it as `schematic_m`, a comparison column, never the answer.
+* **pid** - the drawn length on the P&ID: divided by the scale factor when the
+ scale check calls it `to_scale`, taken in the P&ID's own unit when there is
+ no layout (every row then carries `scale_verified: false`), and on a
+ schematic P&ID only with `force=True`.
+
+This module also carries the takeoff's own stand-ins for three readers another
+branch of track H owns - the unit inference, the plan-copy clusters and the
+scale check (group U's `units`, `clusters` and `scale`). They follow the
+spec's rules (§5, §6) in the narrow form a takeoff needs; `pipe_rows` uses a
+`scale` result it is given before its own, so the full scale check replaces
+the stand-in wherever a caller passes it.
+
+Every length leaves this module in metres, converted from the unit the
+geometry implies - never blindly from `INSUNITS`. Nothing here touches a
+drawing.
+"""
+
+from __future__ import annotations
+
+import math
+import statistics
+from collections.abc import Iterable
+
+from engineering.arch.faces import DEFAULT_TOL, Face, face_containing, planar_faces
+from engineering.understand.labels import normalize_panel, parse_electrical, wiring_target
+from engineering.understand.network import (
+ MODEL,
+ TEXT_TYPES,
+ label_search_default,
+ tag_occurrences,
+)
+from engineering.understand.route import manhattan, rmst, route_legs
+from engineering.understand.snapshot import Pt, Snapshot
+from engineering.understand.vocab import SERVICES, classify_layer, room_label
+
+__all__ = [
+ "CABLE_SEARCH_SHARE",
+ "DEFAULT_SERVICES",
+ "LENGTH_SOURCES",
+ "UNIT_METRES",
+ "apportion",
+ "cable_rows",
+ "check_allowance",
+ "check_pipe_options",
+ "check_section_rules",
+ "layout_tags",
+ "pipe_rows",
+ "room_at",
+ "room_regions",
+ "roundup_m",
+ "scale_stand_in",
+ "section_for",
+ "unit_of",
+]
+
+#: The user's own takeoff scope; utilities only on request (spec §8).
+DEFAULT_SERVICES = ("product", "cip_supply", "cip_return")
+LENGTH_SOURCES = ("auto", "pid", "layout")
+UNASSIGNED = "unassigned"
+
+#: INSUNITS codes this module reads, and metres per unit of each.
+INSUNITS_NAMES = {1: "in", 2: "ft", 4: "mm", 5: "cm", 6: "m"}
+UNIT_METRES = {"mm": 0.001, "cm": 0.01, "m": 1.0, "in": 0.0254, "ft": 0.3048}
+#: This module's heuristic, not a standard: the model-space text of any plant
+#: drawing - a P&ID sheet at 1:1 or a plan at 1:200 - stands between 0.5 mm and
+#: 1 m tall, and the drawing spans between 0.1 m and 5 km. A declared unit that
+#: puts the geometry outside either band is not believed.
+TEXT_BAND_M = (0.0005, 1.0)
+SPAN_BAND_M = (0.1, 5000.0)
+#: Among the units that fit both bands, the one nearest (in log) a 50 m plant
+#: with 0.25 m text is chosen.
+TYPICAL_SPAN_M = 50.0
+TYPICAL_TEXT_M = 0.25
+
+#: Plan copies are told apart when the gap between them exceeds 2 % of the
+#: drawing's robust span (5th-95th percentile).
+CLUSTER_GAP_SHARE = 0.02
+#: Spec §6: pairs of matched tags at least 2 m apart, a ±10 % band, 80 % / 50 %.
+MIN_PAIR_MM = 2000.0
+SCALE_BAND = 0.10
+TO_SCALE_SHARE = 0.80
+SCHEMATIC_SHARE = 0.50
+#: Fewer matched tags than this and the verdict is `insufficient`.
+MIN_TAGS = 3
+#: A wall layer is one `classify_layer` files under architecture this surely.
+WALL_CONFIDENCE = 0.9
+
+
+# -- options -----------------------------------------------------------------
+
+
+def _number(value, name: str) -> float:
+ try:
+ number = float(value)
+ except (TypeError, ValueError):
+ raise ValueError(f"{name}: expected a number, got {value!r}") from None
+ if not math.isfinite(number):
+ raise ValueError(f"{name}: must be finite, got {value!r}")
+ return number
+
+
+def check_allowance(allowance) -> float:
+ number = _number(allowance, "allowance")
+ if not 0.0 <= number <= 1.0:
+ raise ValueError(f"allowance: must lie between 0 and 1 (0.20 is 20 %), got {number:g}")
+ return number
+
+
+def check_pipe_options(*, services, allowance, vertical_allowance, length_source) -> dict:
+ """Refuse bad takeoff options by name, before a drawing is read."""
+ chosen = DEFAULT_SERVICES if services is None else tuple(dict.fromkeys(services))
+ if not chosen:
+ raise ValueError("services: an empty list takes nothing off; omit it for the default")
+ unknown = [s for s in chosen if s not in SERVICES]
+ if unknown:
+ raise ValueError(
+ f"services: {', '.join(map(str, unknown))} unknown; choose from {', '.join(SERVICES)}"
+ )
+ vertical = _number(vertical_allowance, "vertical_allowance")
+ if vertical < 0.0:
+ raise ValueError(f"vertical_allowance: must be zero or more metres, got {vertical:g}")
+ if length_source not in LENGTH_SOURCES:
+ raise ValueError(
+ f"length_source: {length_source!r} unknown; choose from {', '.join(LENGTH_SOURCES)}"
+ )
+ return {
+ "services": chosen,
+ "allowance": check_allowance(allowance),
+ "vertical_allowance": vertical,
+ "length_source": length_source,
+ }
+
+
+# -- units -------------------------------------------------------------------
+
+
+def _model_points(snap: Snapshot) -> list[Pt]:
+ out: list[Pt] = []
+ for rec in snap.records:
+ if rec.space != MODEL:
+ continue
+ out.extend(rec.points)
+ if rec.bbox is not None:
+ out.extend(((rec.bbox[0], rec.bbox[1]), (rec.bbox[2], rec.bbox[3])))
+ return out
+
+
+def _robust_box(points: list[Pt]) -> tuple[float, float, float, float] | None:
+ """The 5th-95th percentile box of the points: one stray entity does not stretch it."""
+ if len(points) < 2:
+ return None
+ qx = statistics.quantiles([p[0] for p in points], n=20, method="inclusive")
+ qy = statistics.quantiles([p[1] for p in points], n=20, method="inclusive")
+ return (qx[0], qy[0], qx[-1], qy[-1])
+
+
+def _text_heights(snap: Snapshot) -> list[float]:
+ return [
+ rec.height
+ for rec in snap.records
+ if rec.space == MODEL and rec.type in TEXT_TYPES and rec.height and rec.height > 0.0
+ ]
+
+
+def unit_of(snap: Snapshot) -> dict:
+ """The unit a drawing's geometry implies, against the one INSUNITS declares.
+
+ ``{"declared", "inferred", "m_per_unit", "warning", "evidence"}``. The
+ declared unit (mm when INSUNITS declares none) stands when it puts the
+ median model-space text height between 0.5 mm and 1 m and the robust span
+ (5th-95th percentile) between 0.1 m and 5 km - this module's heuristic.
+ Otherwise the unit of mm / cm / m / in / ft that fits both bands and lies
+ nearest a 50 m drawing with 0.25 m text is taken. A disagreement is a
+ warning with the numbers - never a silent correction.
+ """
+ declared = INSUNITS_NAMES.get(snap.insunits)
+ box = _robust_box(_model_points(snap))
+ span = max(box[2] - box[0], box[3] - box[1]) if box else 0.0
+ heights = _text_heights(snap)
+ height = statistics.median(heights) if heights else 0.0
+
+ def fits(unit: str) -> bool:
+ m = UNIT_METRES[unit]
+ return (span <= 0.0 or SPAN_BAND_M[0] <= span * m <= SPAN_BAND_M[1]) and (
+ height <= 0.0 or TEXT_BAND_M[0] <= height * m <= TEXT_BAND_M[1]
+ )
+
+ def score(unit: str) -> float:
+ m = UNIT_METRES[unit]
+ total = abs(math.log(span * m / TYPICAL_SPAN_M)) if span > 0.0 else 0.0
+ return total + (abs(math.log(height * m / TYPICAL_TEXT_M)) if height > 0.0 else 0.0)
+
+ base = declared or "mm"
+ inferred = base
+ if not fits(base):
+ candidates = [unit for unit in UNIT_METRES if fits(unit)]
+ if candidates:
+ inferred = min(candidates, key=lambda unit: (score(unit), unit))
+ if declared is not None:
+ said = declared
+ elif snap.insunits:
+ said = f"code {snap.insunits} (a unit this reader does not convert)"
+ else:
+ said = "no unit"
+ warning = None
+ if inferred != declared:
+ warning = (
+ f"{snap.source}: INSUNITS declares {said} but the geometry reads "
+ f"as {inferred} (median text height {height:g}, robust span {span:g} drawing "
+ f"units); lengths are taken in {inferred}"
+ )
+ return {
+ "declared": declared,
+ "inferred": inferred,
+ "m_per_unit": UNIT_METRES[inferred],
+ "warning": warning,
+ "evidence": {"robust_span": span, "median_text_height": height},
+ }
+
+
+# -- plan copies and tag positions ---------------------------------------------
+
+
+def _mark_segment(cells: set, a: Pt, b: Pt, gap: float, limit: float) -> None:
+ length = math.dist(a, b)
+ if length > limit:
+ steps = 0 # a stray line across the world marks only its ends
+ else:
+ steps = int(length / (gap / 2.0))
+ for i in range(steps + 1):
+ t = i / steps if steps else 0.0
+ x, y = a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t
+ cells.add((math.floor(x / gap), math.floor(y / gap)))
+ cells.add((math.floor(b[0] / gap), math.floor(b[1] / gap)))
+
+
+def _clusters(snap: Snapshot) -> tuple[float, list[tuple[float, float, float, float]]]:
+ """(cell size, cluster boxes): separate bodies of model-space content.
+
+ Every record's geometry marks the cells of a grid whose cell is 2 % of the
+ robust span; 8-connected cells form bodies; bodies whose boxes overlap are
+ one cluster (a label in the middle of a room belongs to the plan around it).
+ """
+ points = _model_points(snap)
+ box = _robust_box(points)
+ if box is None:
+ return 1.0, []
+ span = max(box[2] - box[0], box[3] - box[1], 1.0)
+ gap = CLUSTER_GAP_SHARE * span
+ limit = 10.0 * span
+ cells: set[tuple[int, int]] = set()
+ for rec in snap.records:
+ if rec.space != MODEL:
+ continue
+ pts = list(rec.points)
+ if rec.type in ("LINE", "LWPOLYLINE", "POLYLINE") and len(pts) >= 2:
+ ring = pts + ([pts[0]] if rec.closed else [])
+ for a, b in zip(ring, ring[1:], strict=False):
+ _mark_segment(cells, a, b, gap, limit)
+ elif rec.bbox is not None:
+ x0, y0, x1, y1 = rec.bbox
+ corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)]
+ for a, b in zip(corners, corners[1:], strict=False):
+ _mark_segment(cells, a, b, gap, limit)
+ for p in pts[:1]:
+ cells.add((math.floor(p[0] / gap), math.floor(p[1] / gap)))
+ seen: set[tuple[int, int]] = set()
+ bodies: list[list[float]] = []
+ for start in sorted(cells):
+ if start in seen:
+ continue
+ seen.add(start)
+ stack, body = [start], [start[0], start[1], start[0], start[1]]
+ while stack:
+ cx, cy = stack.pop()
+ body = [min(body[0], cx), min(body[1], cy), max(body[2], cx), max(body[3], cy)]
+ for dx in (-1, 0, 1):
+ for dy in (-1, 0, 1):
+ n = (cx + dx, cy + dy)
+ if n in cells and n not in seen:
+ seen.add(n)
+ stack.append(n)
+ bodies.append(body)
+ merged = True
+ while merged:
+ merged = False
+ for i in range(len(bodies)):
+ for j in range(i + 1, len(bodies)):
+ a, b = bodies[i], bodies[j]
+ if a[0] <= b[2] and b[0] <= a[2] and a[1] <= b[3] and b[1] <= a[3]:
+ bodies[i] = [min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3])]
+ del bodies[j]
+ merged = True
+ break
+ if merged:
+ break
+ boxes = [(b[0] * gap, b[1] * gap, (b[2] + 1) * gap, (b[3] + 1) * gap) for b in sorted(bodies)]
+ return gap, boxes
+
+
+def layout_tags(snap: Snapshot, wanted: Iterable[str] = ()) -> dict:
+ """Tag positions on a drawing, using only tags that are unique where they are read.
+
+ When no tag is written twice the whole model space is read. Otherwise the
+ drawing is split into clusters (plan copies, sheets, a far outlier) and the
+ cluster holding the most `wanted` tags is chosen (ties: the one furthest
+ left, then lowest); a tag written more than once inside it is `ambiguous`,
+ never picked arbitrarily. ``{"positions": {tag: Pt}, "ambiguous": [...],
+ "cluster": box | None, "clusters": n}``.
+ """
+ occurrences = tag_occurrences(snap)
+ wanted = set(wanted)
+ if all(len(places) == 1 for places in occurrences.values()):
+ return {
+ "positions": {tag: places[0]["at"] for tag, places in occurrences.items()},
+ "ambiguous": [],
+ "cluster": None,
+ "clusters": 1,
+ }
+ _gap, boxes = _clusters(snap)
+
+ def inside(p: Pt, box) -> bool:
+ return box[0] <= p[0] <= box[2] and box[1] <= p[1] <= box[3]
+
+ def weight(box) -> tuple:
+ count = sum(
+ 1
+ for tag, places in occurrences.items()
+ if tag in wanted and any(inside(place["at"], box) for place in places)
+ )
+ return (-count, box[0], box[1])
+
+ chosen = min(boxes, key=weight) if boxes else None
+ positions, ambiguous = {}, []
+ for tag, places in sorted(occurrences.items()):
+ here = [place for place in places if chosen is None or inside(place["at"], chosen)]
+ if len(here) == 1:
+ positions[tag] = here[0]["at"]
+ elif len(here) > 1:
+ ambiguous.append(tag)
+ return {
+ "positions": positions,
+ "ambiguous": ambiguous,
+ "cluster": chosen,
+ "clusters": len(boxes),
+ }
+
+
+def scale_stand_in(
+ pid: Snapshot, layout: Snapshot, *, pid_tags=None, layout_found=None, layout_unit=None
+) -> dict:
+ """Spec §6 on the tags unique in both drawings - the takeoff's stand-in for scale_check.
+
+ The result has `scale_check`'s shape (group U, Task 4): for every pair of
+ matched tags at least 2 m apart on the layout, ``ratio`` is their P&ID
+ distance over their layout distance, both in millimetres of each drawing's
+ inferred unit; ``median``, ``q1``, ``q3``, ``iqr`` and ``within_10pct`` (the
+ share within ±10 % of the median) describe them. Verdict ``to_scale`` at
+ >= 80 %, ``schematic`` at <= 50 %, ``partly`` between, ``insufficient``
+ with fewer than three matched tags or no pair; ``factor`` is the median
+ only when ``to_scale``; a median that is not positive (every P&ID tag at
+ one point) is ``insufficient``. There are no per-room ``groups`` here. Tag labels
+ are not equipment centres: the band and the 2 m floor absorb that.
+ """
+ pid_occ = pid_tags if pid_tags is not None else tag_occurrences(pid)
+ pid_positions = {tag: places[0]["at"] for tag, places in pid_occ.items() if len(places) == 1}
+ found = layout_found if layout_found is not None else layout_tags(layout, pid_positions)
+ unit = layout_unit if layout_unit is not None else unit_of(layout)
+ mm_layout = unit["m_per_unit"] * 1000.0
+ mm_pid = unit_of(pid)["m_per_unit"] * 1000.0
+ tags = [tag for tag in sorted(pid_positions) if tag in found["positions"]]
+ matched = {tag: (pid_positions[tag], found["positions"][tag]) for tag in tags}
+ ambiguous = sorted(
+ {tag for tag, places in pid_occ.items() if len(places) > 1} | set(found["ambiguous"])
+ )
+ pairs = []
+ for i, a in enumerate(tags):
+ for b in tags[i + 1 :]:
+ d_layout = math.dist(matched[a][1], matched[b][1]) * mm_layout
+ if d_layout >= MIN_PAIR_MM:
+ d_pid = math.dist(matched[a][0], matched[b][0]) * mm_pid
+ pairs.append(
+ {"tags": [a, b], "a_mm": d_pid, "b_mm": d_layout, "ratio": d_pid / d_layout}
+ )
+ out = {
+ "verdict": "insufficient",
+ "factor": None,
+ "median": None,
+ "q1": None,
+ "q3": None,
+ "iqr": None,
+ "within_10pct": None,
+ "pair_count": len(pairs),
+ "pairs": pairs,
+ "matched": [
+ {"tag": tag, "a": list(matched[tag][0]), "b": list(matched[tag][1])} for tag in tags
+ ],
+ "ambiguous": ambiguous,
+ "groups": [],
+ "notes": ["takeoff stand-in for scale_check (spec §6): no per-room groups"],
+ }
+ ratios = [pair["ratio"] for pair in pairs]
+ if len(tags) < MIN_TAGS or not ratios:
+ return out
+ median = statistics.median(ratios)
+ if median <= 0.0:
+ return out # every P&ID tag at one point: no factor to divide by (as scale_check)
+ q1, q3 = (
+ statistics.quantiles(ratios, n=4, method="inclusive")[::2]
+ if len(ratios) >= 2
+ else (ratios[0], ratios[0])
+ )
+ within = sum(1 for r in ratios if abs(r - median) <= SCALE_BAND * median) / len(ratios)
+ if within >= TO_SCALE_SHARE:
+ verdict = "to_scale"
+ elif within <= SCHEMATIC_SHARE:
+ verdict = "schematic"
+ else:
+ verdict = "partly"
+ out.update(
+ verdict=verdict,
+ factor=median if verdict == "to_scale" else None,
+ median=median,
+ q1=q1,
+ q3=q3,
+ iqr=q3 - q1,
+ within_10pct=within,
+ )
+ return out
+
+
+# -- rooms -------------------------------------------------------------------
+
+
+def room_regions(snap: Snapshot, cluster=None) -> dict:
+ """Room polygons and room labels of a drawing.
+
+ Labels are texts `room_label` reads; polygons are the faces of the wall
+ layers (`classify_layer` architecture, confidence >= 0.9, straight
+ segments) that hold a label, found by track F's planar-face finder. Inside
+ `cluster` only, when one is given. ``{"faces": [(key, Face)], "labels":
+ [(key, Pt)]}``; the key is the room number, else its name.
+ """
+
+ def keep(p: Pt) -> bool:
+ return cluster is None or (
+ cluster[0] <= p[0] <= cluster[2] and cluster[1] <= p[1] <= cluster[3]
+ )
+
+ labels = []
+ for rec in snap.records:
+ if rec.space != MODEL or rec.type not in TEXT_TYPES or not rec.text or not rec.points:
+ continue
+ found = room_label(rec.text)
+ if found and keep(rec.points[0]):
+ key = found.get("number") or found.get("name") or rec.text
+ labels.append((str(key), rec.points[0]))
+ walls = set()
+ for layer in {rec.layer for rec in snap.records}: # classify each layer once
+ info = classify_layer(layer)
+ if info["discipline"] == "architecture" and info["confidence"] >= WALL_CONFIDENCE:
+ walls.add(layer)
+ segments = []
+ for rec in snap.records:
+ if rec.space != MODEL or rec.layer not in walls:
+ continue
+ if rec.type not in ("LINE", "LWPOLYLINE", "POLYLINE") or len(rec.points) < 2:
+ continue
+ pts = list(rec.points) + ([rec.points[0]] if rec.closed else [])
+ bulges = list(rec.bulges) + [0.0] * len(pts)
+ for i, (a, b) in enumerate(zip(pts, pts[1:], strict=False)):
+ if bulges[i] == 0.0 and keep(a) and keep(b):
+ segments.append((a, b))
+ faces = planar_faces(segments, tol=DEFAULT_TOL) if segments else ()
+ rooms: list[tuple[str, Face]] = []
+ taken: set[int] = set()
+ for key, at in sorted(labels):
+ face = face_containing(faces, at)
+ if face is not None and id(face) not in taken:
+ taken.add(id(face))
+ rooms.append((key, face))
+ return {"faces": rooms, "labels": sorted(labels)}
+
+
+def _rooms_mode(regions: dict) -> str:
+ if regions["faces"]:
+ return "polygons"
+ return "nearest_label" if regions["labels"] else "none"
+
+
+def room_at(regions: dict, p: Pt) -> str:
+ """The room a point stands in; else the nearest room label; else ''."""
+ face = face_containing([face for _key, face in regions["faces"]], p)
+ if face is not None:
+ return next(key for key, f in regions["faces"] if f is face)
+ if regions["labels"]:
+ return min(regions["labels"], key=lambda item: (math.dist(item[1], p), item[0]))[0]
+ return ""
+
+
+def apportion(legs, regions: dict) -> dict[str, float]:
+ """Length of each orthogonal leg inside each room polygon (drawing units).
+
+ A leg is cut at every crossing with a room outline or hole; each piece goes
+ to the room its midpoint stands in, or to '' outside every room. Nothing is
+ counted twice: the pieces add up to the leg.
+ """
+ faces = [face for _key, face in regions["faces"]]
+ key_of = {id(face): key for key, face in regions["faces"]}
+ out: dict[str, float] = {}
+ for a, b in legs:
+ length = math.dist(a, b)
+ if length == 0.0:
+ continue
+ cuts = {0.0, 1.0}
+ for face in faces:
+ for ring in (face.loop, *face.holes):
+ for i in range(len(ring)):
+ t = _cut(a, b, ring[i], ring[(i + 1) % len(ring)])
+ if t is not None:
+ cuts.add(t)
+ ts = sorted(cuts)
+ for t0, t1 in zip(ts, ts[1:], strict=False):
+ if t1 - t0 <= 0.0:
+ continue
+ tm = (t0 + t1) / 2.0
+ mid = (a[0] + (b[0] - a[0]) * tm, a[1] + (b[1] - a[1]) * tm)
+ face = face_containing(faces, mid)
+ key = key_of[id(face)] if face is not None else ""
+ out[key] = out.get(key, 0.0) + (t1 - t0) * length
+ return out
+
+
+def _cut(a: Pt, b: Pt, c: Pt, d: Pt) -> float | None:
+ """Parameter along a->b where it crosses segment c-d, or None."""
+ rx, ry = b[0] - a[0], b[1] - a[1]
+ sx, sy = d[0] - c[0], d[1] - c[1]
+ denom = rx * sy - ry * sx
+ if denom == 0.0:
+ return None
+ qx, qy = c[0] - a[0], c[1] - a[1]
+ t = (qx * sy - qy * sx) / denom
+ u = (qx * ry - qy * rx) / denom
+ if 0.0 < t < 1.0 and 0.0 <= u <= 1.0:
+ return t
+ return None
+
+
+# -- pipe rows ---------------------------------------------------------------
+
+
+def _choose_source(requested: str, layout, scale: dict, force: bool) -> tuple[str, str]:
+ verdict = scale.get("verdict")
+ if requested == "layout":
+ if layout is None:
+ raise ValueError("length_source='layout' needs a layout drawing; none was given")
+ return "layout", "requested"
+ if requested == "pid":
+ if layout is not None and verdict == "schematic" and not force:
+ raise ValueError(
+ "length_source='pid': the scale check calls this P&ID schematic - "
+ f"{scale['within_10pct']:.0%} of {scale['pair_count']} tag pairs lie within "
+ f"±10 % of the median ratio {scale['median']:.6g} (IQR {scale['iqr']:.6g}); "
+ "a length measured on it is drawn line, not pipe. Pass force=True to measure "
+ "on it anyway, or use length_source='layout'."
+ )
+ return "pid", "forced" if layout is not None and verdict == "schematic" else "requested"
+ if layout is None:
+ return "pid", "no_layout"
+ if verdict == "to_scale":
+ return "pid", "auto_to_scale"
+ return "layout", f"auto_{verdict}"
+
+
+def pipe_rows(
+ network,
+ pid: Snapshot,
+ layout: Snapshot | None,
+ *,
+ scale=None,
+ services=DEFAULT_SERVICES,
+ allowance=0.20,
+ vertical_allowance=0.0,
+ length_source="auto",
+ force=False,
+) -> dict:
+ """Takeoff rows by room x service x diameter, with the runs, checks and method behind them.
+
+ `network` is `build_network(pid)`'s result. `scale` is a scale-check
+ result (`scale_check`'s shape); omitted, the stand-in runs when a layout
+ is given. Refused by name before anything is measured: an unknown service
+ or length source, an allowance outside 0-1, a negative vertical
+ allowance, `length_source='layout'` without a layout, and
+ `length_source='pid'` on a P&ID the scale check calls schematic unless
+ `force=True` (the refusal quotes the statistics).
+
+ Status per run: **A** every end on a tag placed on the layout and every
+ piece's diameter read directly; **B** a diameter carried by continuity or
+ unassigned; **C** not routable - an end on no equipment, fewer than two
+ tags, a tag missing from (or written twice on) the layout. A C run goes to
+ `control` with its schematic length only, is counted in the totals and is
+ flagged. A run holding several diameters shares its length among them in
+ proportion to their drawn length on the P&ID.
+ """
+ options = check_pipe_options(
+ services=services,
+ allowance=allowance,
+ vertical_allowance=vertical_allowance,
+ length_source=length_source,
+ )
+ allowance = options["allowance"]
+ vertical = options["vertical_allowance"]
+ pid_unit = unit_of(pid)
+ layout_unit = unit_of(layout) if layout is not None else None
+ wanted = {tag for run in network["runs"] for tag in run.tags}
+ found = layout_tags(layout, wanted) if layout is not None else None
+ if scale is None:
+ scale = (
+ scale_stand_in(pid, layout, layout_found=found, layout_unit=layout_unit)
+ if layout is not None
+ else {"verdict": "not_checked", "pair_count": 0, "matched": [], "ambiguous": []}
+ )
+ source, reason = _choose_source(length_source, layout, scale, bool(force))
+ scale_verified = source == "layout" or scale.get("verdict") == "to_scale"
+ positions = found["positions"] if found else {}
+ ambiguous = set(found["ambiguous"]) if found else set()
+ regions = (
+ room_regions(layout, found["cluster"])
+ if found is not None and source == "layout"
+ else room_regions(pid)
+ )
+ m_pid = pid_unit["m_per_unit"]
+ m_layout = layout_unit["m_per_unit"] if layout_unit else None
+
+ rows: dict[tuple[str, str, str], dict] = {}
+ runs_out, control, excluded = [], [], {}
+ for run in network["runs"]:
+ if run.service not in options["services"]:
+ excluded[run.service] = excluded.get(run.service, 0) + 1
+ continue
+ ends = network["attachments"].get(run.id, [])
+ drawn = sum(edge["length"] for edge in run.edges)
+ schematic_m = drawn * m_pid
+ parts: dict[str, dict] = {}
+ for edge in run.edges:
+ part = parts.setdefault(
+ edge["diameter"] or UNASSIGNED,
+ {"drawn": 0.0, "direct": 0.0, "continuity": 0.0, "unassigned": 0.0},
+ )
+ part["drawn"] += edge["length"]
+ part[edge["diameter_source"]] += edge["length"]
+ why = None
+ if any(end["tag"] is None for end in ends) or not ends:
+ why = "end_not_on_equipment"
+ elif len(run.tags) < 2:
+ why = "single_tag"
+ elif source == "layout":
+ missing = [tag for tag in run.tags if tag not in positions]
+ if missing:
+ why = (
+ "tag_ambiguous" if any(t in ambiguous for t in missing) else "tag_not_on_layout"
+ )
+ detail = {
+ "id": run.id,
+ "service": run.service,
+ "tags": list(run.tags),
+ "diameters": {d: p["drawn"] * m_pid for d, p in sorted(parts.items())},
+ "schematic_m": schematic_m,
+ "layout_m": None,
+ "vertical_m": 0.0,
+ "length_m": None,
+ "rooms": {},
+ "status": "C",
+ "reason": why,
+ }
+ runs_out.append(detail)
+ for diameter, part in sorted(parts.items()):
+ if diameter == UNASSIGNED:
+ control.append(
+ {
+ "run": run.id,
+ "kind": "unassigned_diameter",
+ "service": run.service,
+ "tags": list(run.tags),
+ "schematic_m": part["drawn"] * m_pid,
+ "reason": "no_diameter_label",
+ }
+ )
+ if why is not None:
+ control.append(
+ {
+ "run": run.id,
+ "kind": "unroutable",
+ "service": run.service,
+ "tags": list(run.tags),
+ "schematic_m": schematic_m,
+ "reason": why,
+ }
+ )
+ continue
+ if source == "layout":
+ points = [positions[tag] for tag in run.tags]
+ length_units, tree = rmst(points)
+ if regions["faces"]:
+ legs = [leg for i, j in tree for leg in route_legs(points[i], points[j])]
+ rooms = {key: value * m_layout for key, value in apportion(legs, regions).items()}
+ else:
+ centre = (
+ sum(p[0] for p in points) / len(points),
+ sum(p[1] for p in points) / len(points),
+ )
+ rooms = {room_at(regions, centre): length_units * m_layout}
+ for _i, j in tree:
+ key = room_at(regions, points[j])
+ rooms[key] = rooms.get(key, 0.0) + vertical
+ detail["layout_m"] = length_units * m_layout
+ detail["vertical_m"] = vertical * len(tree)
+ length_m = detail["layout_m"] + detail["vertical_m"]
+ else:
+ if layout is not None and scale.get("verdict") == "to_scale":
+ length_m = schematic_m / scale["factor"]
+ else:
+ length_m = schematic_m
+ centre = (
+ sum(end["at"][0] for end in ends) / len(ends),
+ sum(end["at"][1] for end in ends) / len(ends),
+ )
+ rooms = {room_at(regions, centre): length_m}
+ detail["length_m"] = length_m
+ detail["rooms"] = rooms
+ status = (
+ "A"
+ if all(p["continuity"] == 0.0 and p["unassigned"] == 0.0 for p in parts.values())
+ else "B"
+ )
+ detail["status"] = status
+ for diameter, part in parts.items():
+ share = part["drawn"] / drawn if drawn else 0.0
+ for room, room_m in rooms.items():
+ x = room_m * share
+ row = rows.setdefault(
+ (room, run.service, diameter),
+ {
+ "room": room,
+ "service": run.service,
+ "diameter": diameter,
+ "direct_m": 0.0,
+ "continuity_m": 0.0,
+ "unassigned_m": 0.0,
+ "net_m": 0.0,
+ "schematic_m": 0.0,
+ "status": "A",
+ "runs": [],
+ },
+ )
+ row["net_m"] += x
+ row["direct_m"] += x * part["direct"] / part["drawn"]
+ row["continuity_m"] += x * part["continuity"] / part["drawn"]
+ row["unassigned_m"] += x * part["unassigned"] / part["drawn"]
+ row["schematic_m"] += schematic_m * share * (room_m / length_m if length_m else 0.0)
+ if part["continuity"] or part["unassigned"]:
+ row["status"] = "B"
+ if run.id not in row["runs"]:
+ row["runs"].append(run.id)
+
+ order = {service: i for i, service in enumerate(SERVICES)}
+ table = []
+ for key in sorted(rows, key=lambda k: (k[0], order.get(k[1], len(order)), k[2])):
+ row = rows[key]
+ row["allowance"] = allowance
+ row["allowance_m"] = row["net_m"] * allowance
+ row["total_m"] = row["net_m"] + row["allowance_m"]
+ table.append(row)
+
+ routed = sum(row["net_m"] for row in table)
+ unroutable = sum(c["schematic_m"] for c in control if c["kind"] == "unroutable")
+ net_total = routed + unroutable
+ summary = {
+ name: [
+ {
+ "key": key,
+ "net_m": sum(r["net_m"] for r in table if r[field] == key),
+ "total_m": sum(r["total_m"] for r in table if r[field] == key),
+ }
+ for key in sorted({r[field] for r in table})
+ ]
+ for name, field in (
+ ("by_service", "service"),
+ ("by_diameter", "diameter"),
+ ("by_room", "room"),
+ )
+ }
+ warnings = [u["warning"] for u in (pid_unit, layout_unit) if u and u["warning"]]
+ if not scale_verified:
+ warnings.append(
+ "scale_verified: false - lengths are drawn lengths on a P&ID that no scale check "
+ "has confirmed; they are not physical pipe lengths"
+ )
+ return {
+ "kind": "pipe",
+ "rows": table,
+ "runs": runs_out,
+ "control": control,
+ "summary": summary,
+ "totals": {
+ "routed_m": routed,
+ "unroutable_m": unroutable,
+ "net_m": net_total,
+ "allowance": allowance,
+ "allowance_m": net_total * allowance,
+ "total_m": net_total * (1.0 + allowance),
+ "flagged_runs": sum(1 for r in runs_out if r["status"] == "C"),
+ },
+ "length_source": source,
+ "scale": scale,
+ "scale_verified": scale_verified,
+ "units": {"pid": pid_unit, "layout": layout_unit},
+ "warnings": warnings,
+ "method": {
+ "sources": {"pid": pid.source, "layout": layout.source if layout else None},
+ "length_source": source,
+ "requested": length_source,
+ "reason": reason,
+ "allowance": allowance,
+ "vertical_allowance": vertical,
+ "services": list(options["services"]),
+ "excluded_services": excluded,
+ "tol": network["stats"]["tol"],
+ "label_search": network["stats"]["label_search"],
+ "overlap_length_m": network["stats"]["overlap_length"] * m_pid,
+ "route": "rectilinear MST of the tag positions; each connection x first, then y",
+ "rooms": _rooms_mode(regions),
+ },
+ }
+
+
+# -- cable rows ----------------------------------------------------------------
+
+#: A load's power block stands under its symbol rather than on a line, so its
+#: electrical texts and its wiring callout are searched twice as far as a
+#: diameter label: 10 x the median text height.
+CABLE_SEARCH_SHARE = 2.0
+SECTION_RULE_KEYS = ("max_kw", "section", "phases")
+
+
+def check_section_rules(section_rules) -> tuple[dict, ...]:
+ """The caller's own cable-section rule table, validated and sorted by max_kw.
+
+ Each rule is ``{"max_kw": > 0, "section": text, "phases": 1 | 3 (optional)}``;
+ a load takes the first rule whose max_kw it does not exceed and whose
+ phases, when the rule names them, equal the load's. No standard table exists
+ in this repository: without rules no section is proposed.
+ """
+ if section_rules is None:
+ return ()
+ if not isinstance(section_rules, (list, tuple)):
+ raise ValueError("section_rules: expected a list of {max_kw, section[, phases]}")
+ rules = []
+ for i, rule in enumerate(section_rules):
+ where = f"section_rules[{i}]"
+ if not isinstance(rule, dict):
+ raise ValueError(f"{where}: expected {{max_kw, section[, phases]}}, got {rule!r}")
+ unknown = sorted(set(rule) - set(SECTION_RULE_KEYS))
+ if unknown:
+ raise ValueError(f"{where}: unknown key(s) {', '.join(map(str, unknown))}")
+ max_kw = _number(rule.get("max_kw"), f"{where}.max_kw")
+ if max_kw <= 0.0:
+ raise ValueError(f"{where}.max_kw: must be greater than zero, got {max_kw:g}")
+ section = rule.get("section")
+ if not isinstance(section, str) or not section.strip():
+ raise ValueError(
+ f"{where}.section: expected a non-empty text such as '5x2.5', got {section!r}"
+ )
+ phases = rule.get("phases")
+ if phases is not None and phases not in (1, 3):
+ raise ValueError(f"{where}.phases: 1 or 3, got {phases!r}")
+ rules.append({"max_kw": max_kw, "section": section.strip(), "phases": phases})
+ return tuple(sorted(rules, key=lambda r: (r["max_kw"], r["phases"] or 0)))
+
+
+def section_for(kw: float | None, phases: int | None, rules) -> str | None:
+ """The first rule the load fits, or None - never a guessed section."""
+ if kw is None:
+ return None
+ for rule in rules:
+ if kw <= rule["max_kw"] and (rule["phases"] is None or rule["phases"] == phases):
+ return rule["section"]
+ return None
+
+
+def roundup_m(length_m: float, allowance: float) -> int:
+ """ROUNDUP(length x (1 + allowance)) to the metre, on the value rounded to 1e-6 m first.
+
+ The rounding keeps binary noise from buying a metre of cable: 50 m with a
+ 10 % allowance is 55.00000000000001 in floating point, and 55 m on the sheet.
+ """
+ return math.ceil(round(length_m * (1.0 + allowance), 6))
+
+
+def _nearest_tag(at: Pt, places: dict[str, list[Pt]], search: float) -> str | None:
+ found = [
+ (math.dist(at, p), tag)
+ for tag, points in places.items()
+ for p in points
+ if math.dist(at, p) <= search
+ ]
+ return min(found)[1] if found else None
+
+
+def _inside_box(p: Pt, box) -> bool:
+ return box is None or (box[0] <= p[0] <= box[2] and box[1] <= p[1] <= box[3])
+
+
+def cable_rows(
+ pid: Snapshot, layout: Snapshot | None, *, allowance=0.20, section_rules=None
+) -> dict:
+ """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`:
+ 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'
+ -> CP-1), else the P&ID's. The length is the Manhattan distance on the
+ 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
+ stated kW keeps an empty cell and an open item. With no layout, lengths
+ stay empty and every row gets an open item - the P&ID is not a length
+ source for cables. A load wired to a panel that itself states a power is
+ part of that package: room and panel totals count the package's power and
+ not its children's. Sections come only from `section_rules`.
+
+ Refused by name before anything is read: an allowance outside 0-1 and a
+ malformed section rule.
+ """
+ allowance = check_allowance(allowance)
+ rules = check_section_rules(section_rules)
+ 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)
+
+ loads: dict[str, list] = {}
+ orphans: list[str] = []
+ pid_callouts: dict[str, str] = {}
+ for rec in pid.records:
+ if rec.space != MODEL or rec.type not in TEXT_TYPES or not rec.text or not rec.points:
+ continue
+ at = rec.points[0]
+ target = wiring_target(rec.text)
+ if target is not None:
+ tag = _nearest_tag(at, pid_places, pid_search)
+ if tag is not None:
+ pid_callouts.setdefault(tag, target)
+ continue
+ found = parse_electrical(rec.text)
+ if found["kw"] is None and not (found["phases"] or found["voltage"] or found["vfd"]):
+ continue
+ tag = _nearest_tag(at, pid_places, pid_search)
+ if tag is None:
+ orphans.append(rec.handle)
+ continue
+ distance = min(math.dist(at, p) for p in pid_places[tag])
+ loads.setdefault(tag, []).append((distance, rec.handle, found))
+
+ positions: dict[str, Pt] = {}
+ ambiguous: set[str] = set()
+ panels: dict[str, tuple[str, str]] = {}
+ layout_unit = unit_of(layout) if layout is not None else None
+ cluster = None
+ layout_search = None
+ if layout is not None:
+ placed = layout_tags(layout, set(loads) | set(pid_callouts))
+ positions, ambiguous, cluster = (
+ placed["positions"],
+ set(placed["ambiguous"]),
+ placed["cluster"],
+ )
+ layout_search = CABLE_SEARCH_SHARE * label_search_default(layout)
+ layout_places = {tag: [p] for tag, p in positions.items()}
+ for rec in layout.records:
+ if rec.space != MODEL or rec.type not in TEXT_TYPES or not rec.text or not rec.points:
+ continue
+ target = wiring_target(rec.text)
+ if target is None or not _inside_box(rec.points[0], cluster):
+ continue
+ tag = _nearest_tag(rec.points[0], layout_places, layout_search)
+ if tag is not None:
+ panels.setdefault(tag, (target, "layout"))
+ for tag, target in pid_callouts.items():
+ panels.setdefault(tag, (target, "pid"))
+ panel_at = {}
+ for tag, p in positions.items():
+ name = normalize_panel(tag)
+ if name is not None:
+ panel_at[name] = p
+ regions = room_regions(layout, cluster) if layout is not None else room_regions(pid)
+
+ rows, items = [], []
+ for tag in sorted(set(loads) | set(panels)):
+ texts = sorted(loads.get(tag, []), key=lambda item: item[:2])
+ powers = [(d, f["kw"]) for d, _h, f in texts if f["kw"] is not None]
+ kw = powers[0][1] if powers else None
+ if len({value for _d, value in powers}) > 1:
+ items.append(
+ {
+ "tag": tag,
+ "item": "conflicting_power",
+ "detail": ", ".join(f"{value:g} kW" for _d, value in powers),
+ }
+ )
+ 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)
+ panel, panel_source = panels.get(tag, (None, None))
+ row = {
+ "tag": tag,
+ "room": "",
+ "at": None,
+ "kw": kw,
+ "phases": phases,
+ "voltage": voltage,
+ "neutral": any(f["neutral"] for _d, _h, f in texts),
+ "vfd": any(f["vfd"] for _d, _h, f in texts),
+ "panel": panel,
+ "panel_source": panel_source,
+ "length_m": None,
+ "allowance": allowance,
+ "cable_m": None,
+ "section": section_for(kw, phases, rules),
+ "package": None,
+ }
+ if kw is None:
+ items.append(
+ {
+ "tag": tag,
+ "item": "missing_power",
+ "detail": "no power is stated near the tag on the P&ID",
+ }
+ )
+ if panel is None:
+ items.append(
+ {
+ "tag": tag,
+ "item": "missing_panel",
+ "detail": "no wiring callout names a panel for this tag",
+ }
+ )
+ if rules and kw is not None and row["section"] is None:
+ items.append(
+ {"tag": tag, "item": "no_section_rule", "detail": f"no rule covers {kw:g} kW"}
+ )
+ if layout is None:
+ items.append(
+ {
+ "tag": tag,
+ "item": "no_layout",
+ "detail": "no layout given: the P&ID is not a length source for cables",
+ }
+ )
+ if pid_places.get(tag):
+ row["room"] = room_at(regions, pid_places[tag][0])
+ else:
+ here = positions.get(tag)
+ if here is None:
+ items.append(
+ {
+ "tag": tag,
+ "item": "tag_ambiguous" if tag in ambiguous else "tag_not_on_layout",
+ "detail": "the load's tag cannot be placed on the layout",
+ }
+ )
+ else:
+ row["at"] = here
+ row["room"] = room_at(regions, here)
+ if panel is not None and panel not in panel_at:
+ items.append(
+ {
+ "tag": tag,
+ "item": "panel_not_on_layout",
+ "detail": f"{panel} is not written once on the layout",
+ }
+ )
+ if here is not None and panel in panel_at:
+ row["length_m"] = manhattan(here, panel_at[panel]) * layout_unit["m_per_unit"]
+ row["cable_m"] = roundup_m(row["length_m"], allowance)
+ rows.append(row)
+
+ stated = {
+ (normalize_panel(r["tag"]) or r["tag"]): r["tag"] for r in rows if r["kw"] is not None
+ }
+ for row in rows:
+ owner = stated.get(row["panel"]) if row["panel"] else None
+ if owner is not None and owner != row["tag"]:
+ row["package"] = owner
+
+ def counted(row: dict) -> float:
+ return row["kw"] if row["kw"] is not None and row["package"] is None else 0.0
+
+ summary = {}
+ for name, field in (("by_room", "room"), ("by_panel", "panel")):
+ summary[name] = [
+ {
+ "key": key,
+ "loads": sum(1 for r in rows if (r[field] or "") == key),
+ "kw": sum(counted(r) for r in rows if (r[field] or "") == key),
+ "cable_m": sum(r["cable_m"] or 0 for r in rows if (r[field] or "") == key),
+ }
+ for key in sorted({row[field] or "" for row in rows})
+ ]
+ units = {"pid": unit_of(pid), "layout": layout_unit}
+ warnings = [unit["warning"] for unit in units.values() if unit and unit["warning"]]
+ if orphans:
+ warnings.append(f"electrical text(s) near no tag, not used: {', '.join(orphans)}")
+ return {
+ "kind": "cable",
+ "rows": rows,
+ "open_items": items,
+ "summary": summary,
+ "totals": {
+ "loads": len(rows),
+ "known_kw": sum(counted(r) for r in rows),
+ "cable_m": sum(r["cable_m"] or 0 for r in rows),
+ "open_items": len(items),
+ "packages": sum(1 for r in rows if r["package"]),
+ },
+ "units": units,
+ "warnings": warnings,
+ "method": {
+ "sources": {"pid": pid.source, "layout": layout.source if layout else None},
+ "allowance": allowance,
+ "search": {"pid": pid_search, "layout": layout_search},
+ "section_rules": [dict(rule) for rule in rules],
+ "rooms": _rooms_mode(regions),
+ },
+ }
diff --git a/server.py b/server.py
index da75c52..0d4ebf4 100644
--- a/server.py
+++ b/server.py
@@ -11028,6 +11028,271 @@ async def drawing_topology_check(
return report
+# ---------------------------------------------------------------------------
+# ── SECTION 27: Plant Takeoffs (2 tools) ────────────────────────────────────
+# ---------------------------------------------------------------------------
+
+
+@cad_tool(
+ summary="Pipe takeoff from a P&ID and its layout: rows by room, service and diameter.",
+ cost="safe",
+)
+@mcp.tool(
+ annotations={
+ "title": "Plant: Pipe Takeoff",
+ "readOnlyHint": False,
+ "destructiveHint": False,
+ },
+ tags={"plant", "analysis"},
+)
+async def pipe_takeoff(
+ pid_path: Annotated[
+ str | None,
+ Field(
+ default=None,
+ description="The P&ID (DXF headlessly; DXF or DWG on a live seat). Omit to read "
+ "the current drawing as the P&ID.",
+ ),
+ ] = None,
+ layout_path: Annotated[
+ str | None,
+ Field(
+ default=None,
+ description="The layout of the same plant. Omitted, every length is the P&ID's "
+ "drawn length and is marked scale_verified: false.",
+ ),
+ ] = None,
+ output: Annotated[
+ str | None,
+ Field(
+ default=None,
+ description="Workbook to write (.xlsx); the four sheets are also written as CSV "
+ "beside it. Omit to return the rows only.",
+ ),
+ ] = None,
+ lang: Annotated[
+ str, Field(default="en", description="Headers and method text: tr | en | ru.")
+ ] = "en",
+ services: Annotated[
+ list[str] | None,
+ Field(
+ default=None,
+ description="Services to take off; default product, cip_supply, cip_return. Also: "
+ "glycol_supply, glycol_return, steam, condensate, cold_water, hot_water, "
+ "ice_water, compressed_air, drain, electrical, unknown.",
+ ),
+ ] = None,
+ allowance: Annotated[
+ float,
+ Field(
+ default=0.20,
+ ge=0.0,
+ le=1.0,
+ description="Allowance as a share of the net length (0.20 = 20 %); its own column.",
+ ),
+ ] = 0.20,
+ vertical_allowance: Annotated[
+ float,
+ Field(
+ default=0.0,
+ ge=0.0,
+ description="Metres added per connection for rises and drops (layout lengths).",
+ ),
+ ] = 0.0,
+ length_source: Annotated[
+ str,
+ Field(
+ default="auto",
+ description="auto | pid | layout. auto measures on the P&ID only when the scale "
+ "check calls it to scale, else on the layout.",
+ ),
+ ] = "auto",
+ force: Annotated[
+ bool,
+ Field(
+ default=False,
+ description="With length_source='pid', measure on a P&ID the scale check calls "
+ "schematic anyway.",
+ ),
+ ] = False,
+ layers: Annotated[
+ list[str] | None,
+ Field(
+ default=None,
+ description="P&ID layers holding the lines; default every layer the vocabulary "
+ "files under piping or electrical with confidence >= 0.9.",
+ ),
+ ] = None,
+ tol: Annotated[
+ float,
+ Field(
+ default=1.0,
+ gt=0.0,
+ description="Line ends closer than this (P&ID drawing units) are one junction.",
+ ),
+ ] = 1.0,
+ ctx: Context = None,
+) -> dict:
+ """A pipe takeoff: topology from the P&ID, lengths from the layout, rows by room x service x diameter.
+
+ The P&ID gives the runs (junctions merged, tees split, a line drawn twice
+ counted once, valves and reducers bridged), each run's service (the layer,
+ overridden by a supply / return word near the line), its diameters exactly
+ as labelled (direct, carried by continuity - never across a reducer - or
+ unassigned) and the equipment tags at its ends. The length comes from the
+ layout unless the scale check calls the P&ID to scale: the rectilinear
+ minimum spanning tree of the run's tag positions on the layout, each
+ connection along x first then y, plus `vertical_allowance` per connection.
+ The P&ID's drawn length stays beside it as `schematic_m`, never as the
+ answer. Lengths are metres from the unit the geometry implies; an INSUNITS
+ that disagrees is a warning, never a factor. Status A / B / C per run: a C
+ run (an end on no equipment, a tag missing from or written twice on the
+ layout) is on the Kontrol sheet with its schematic length, counted in the
+ total and flagged.
+
+ Read-only on both drawings. With `output` it writes one workbook - Metraj,
+ Özet, Kontrol, Metodoloji - as XLSX and, always, as CSV beside it.
+
+ Refused before a drawing is read: `lang` outside tr / en / ru, an unknown
+ service or length source, an allowance outside 0-1, a negative vertical
+ allowance, a non-positive tolerance, and `length_source='layout'` without
+ a layout. A missing, unreadable or oversize drawing is refused by the
+ reader, which names MAX_DXF_BYTES. Refused after the scale check:
+ `length_source='pid'` on a P&ID it calls schematic, unless `force=True`
+ (the refusal quotes the statistics). Without openpyxl the XLSX is not
+ written and `files.refused` carries `capability: "xlsx_write"` naming the
+ `office` extra; the CSV files are written all the same.
+ """
+ from engineering.understand.draw import run_pipe_takeoff
+
+ pid = str(validate_path(pid_path)) if pid_path else None
+ layout = str(validate_path(layout_path)) if layout_path else None
+ target = str(validate_path(output, allow_write=True)) if output else None
+ await ctx.info(f"pipe_takeoff: {pid or 'current drawing'} / {layout or 'no layout'}")
+ try:
+ return await run_pipe_takeoff(
+ _backend(ctx),
+ pid_path=pid,
+ layout_path=layout,
+ output=target,
+ lang=lang,
+ services=services,
+ allowance=allowance,
+ vertical_allowance=vertical_allowance,
+ length_source=length_source,
+ force=force,
+ layers=layers,
+ tol=tol,
+ )
+ except (ValueError, OSError) as exc:
+ raise ToolError(str(exc)) from exc
+
+
+@cad_tool(
+ summary="Cable takeoff: one row per load, Manhattan length to its panel, rounded up to the metre.",
+ cost="safe",
+)
+@mcp.tool(
+ annotations={
+ "title": "Plant: Cable Takeoff",
+ "readOnlyHint": False,
+ "destructiveHint": False,
+ },
+ tags={"plant", "analysis"},
+)
+async def cable_takeoff(
+ pid_path: Annotated[
+ str | None,
+ Field(
+ default=None,
+ description="The P&ID carrying the electrical texts (kW, phases, voltage, VFD). "
+ "Omit to read the current drawing.",
+ ),
+ ] = None,
+ layout_path: Annotated[
+ str | None,
+ Field(
+ default=None,
+ description="The layout with the tags, the panels and the 'Wiring to CPn' "
+ "callouts. Omitted, every length stays empty with an open item.",
+ ),
+ ] = None,
+ output: Annotated[
+ str | None,
+ Field(
+ default=None,
+ description="Workbook to write (.xlsx); the four sheets are also written as CSV "
+ "beside it. Omit to return the rows only.",
+ ),
+ ] = None,
+ lang: Annotated[
+ str, Field(default="en", description="Headers and method text: tr | en | ru.")
+ ] = "en",
+ allowance: Annotated[
+ float,
+ Field(
+ default=0.20,
+ ge=0.0,
+ le=1.0,
+ description="Allowance before rounding up to the metre (0.20 = 20 %).",
+ ),
+ ] = 0.20,
+ section_rules: Annotated[
+ list[dict] | None,
+ Field(
+ default=None,
+ description="Your own section table: [{max_kw, section, phases (1|3, optional)}], "
+ "e.g. [{'max_kw': 5.5, 'section': '5x2.5'}]. Omitted, no section is proposed.",
+ ),
+ ] = None,
+ ctx: Context = None,
+) -> dict:
+ """A cable takeoff: one row per electrical load, the shop's cable method made repeatable.
+
+ Power, phases, voltage, neutral and VFD come from the P&ID's electrical
+ 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
+ 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
+ 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
+ panel totals count known power once: a load wired to a panel that states
+ its own power is part of that package, and the package's power is counted
+ instead of its children's - the rule is written on the method sheet.
+ Sections only from `section_rules`; no standard table is applied. Open
+ items: missing power, missing panel, a tag or panel not on the layout, no
+ layout at all, a load no section rule covers.
+
+ Read-only on both drawings. With `output` it writes one workbook - Metraj,
+ Özet, Kontrol, Metodoloji - as XLSX and, always, as CSV beside it.
+
+ Refused before a drawing is read: `lang` outside tr / en / ru, an allowance
+ outside 0-1 and a malformed section rule (named by its path,
+ `section_rules[0].section: ...`). A missing, unreadable or oversize drawing
+ is refused by the reader, which names MAX_DXF_BYTES. Without openpyxl the
+ XLSX is not written and `files.refused` carries `capability: "xlsx_write"`
+ naming the `office` extra; the CSV files are written all the same.
+ """
+ from engineering.understand.draw import run_cable_takeoff
+
+ pid = str(validate_path(pid_path)) if pid_path else None
+ layout = str(validate_path(layout_path)) if layout_path else None
+ target = str(validate_path(output, allow_write=True)) if output else None
+ await ctx.info(f"cable_takeoff: {pid or 'current drawing'} / {layout or 'no layout'}")
+ try:
+ return await run_cable_takeoff(
+ _backend(ctx),
+ pid_path=pid,
+ layout_path=layout,
+ output=target,
+ lang=lang,
+ allowance=allowance,
+ section_rules=section_rules,
+ )
+ except (ValueError, OSError) as exc:
+ raise ToolError(str(exc)) from exc
+
+
# ---------------------------------------------------------------------------
# ── RESOURCES ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py
index 45cc862..3e729d5 100644
--- a/tests/test_tool_registry.py
+++ b/tests/test_tool_registry.py
@@ -609,10 +609,24 @@ async def test_tool_group_sizes_are_unchanged():
Merged, the two SECTION 26 halves file all four readers under `analysis`
(12 -> 16).
+
+ `analysis` moved 12 -> 13 when track H's SECTION 27 opened with
+ `pipe_takeoff` on the network branch. It is tagged `plant` and
+ `analysis`, and `plant` is not in `_GROUP_TAG_PRIORITY` on that
+ branch, so it files under `analysis`. This snapshot is branch-local:
+ the track H integration task gives `plant` its pack and its group
+ once every branch has landed.
+
+ It moved 13 -> 14 when `cable_takeoff` joined the same section, for
+ the same branch-local reason.
+
+ With SECTION 27 merged beside them, the four readers and the two
+ takeoffs all file under `analysis` (12 -> 18) until the track H
+ integration task ranks `understand` and `plant`.
"""
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
assert sizes == {
- "analysis": 16,
+ "analysis": 18,
"architecture": 12,
"batch": 3,
"blocks": 9,
@@ -639,4 +653,4 @@ async def test_tool_group_sizes_are_unchanged():
"validation": 1,
"view": 4,
}
- assert sum(sizes.values()) == 245
+ assert sum(sizes.values()) == 247
diff --git a/tests/test_understand_cable.py b/tests/test_understand_cable.py
new file mode 100644
index 0000000..ef9f56a
--- /dev/null
+++ b/tests/test_understand_cable.py
@@ -0,0 +1,386 @@
+"""Cable takeoff rows: loads from the P&ID, panels and Manhattan lengths from the layout.
+
+The hand-built pair. On the P&ID: M82 with 'P = 18,5 кВт' and '3 ф. 400 В +
+N + PE' under it, M87 with only '3 ф. 400 В + N + PE' (no power stated), the
+package panel CP-M88 with 'P = 15 кВт' and its child motor M88 with
+'P = 4 кВт'. On the layout (mm): two rooms split at x = 20000, the panel CP-1
+at (15000, 1000), M82 (2000, 3000), M87 (4000, 3000), CP-M88 (25000, 5000),
+M88 (26000, 8000), each with a 'Wiring to ...' callout 300 mm below it.
+Every expected length is worked in the comment beside it. The last two tests
+read the synthetic plant pair of Task 2 and compare every cable row with the
+generator's own Manhattan lengths.
+"""
+
+from __future__ import annotations
+
+import csv
+
+import pytest
+
+from engineering.understand.labels import normalize_panel
+from engineering.understand.report import write_workbook
+from engineering.understand.snapshot import EntityRecord, Snapshot, read_snapshot
+from engineering.understand.takeoff import cable_rows, roundup_m
+from engineering.understand.vocab import classify_layer, room_label
+from tests.fixtures.plant_pair import build_plant_pair
+
+EPS = 1e-9
+WALLS = "WALLS"
+
+
+def rec(handle, kind, layer, points=(), **extra):
+ return EntityRecord(
+ handle=handle, type=kind, layer=layer, space="Model", points=tuple(points), **extra
+ )
+
+
+def text(handle, value, at, height, layer="TEXT"):
+ x, y = at
+ return rec(
+ handle,
+ "TEXT",
+ layer,
+ (at,),
+ text=value,
+ height=height,
+ bbox=(x, y, x + height * len(value) * 0.6, y + height),
+ )
+
+
+def snap(records, insunits, source):
+ layers = {r.layer: {"color": 7, "linetype": "Continuous"} for r in records}
+ return Snapshot(
+ source=source,
+ insunits=insunits,
+ extmin=None,
+ extmax=None,
+ layers=layers,
+ layouts=(),
+ records=tuple(records),
+ )
+
+
+def pid_records():
+ return [
+ text("a", "M82", (0.0, 0.0), 50.0),
+ text("a1", "P = 18,5 кВт", (0.0, -80.0), 50.0),
+ text("a2", "3 ф. 400 В + N + PE", (0.0, -160.0), 50.0),
+ text("b", "M87", (1000.0, 0.0), 50.0),
+ text("b1", "3 ф. 400 В + N + PE", (1000.0, -80.0), 50.0),
+ text("c", "CP-M88", (2000.0, 0.0), 50.0),
+ text("c1", "P = 15 кВт", (2000.0, -80.0), 50.0),
+ text("d", "M88", (3000.0, 0.0), 50.0),
+ text("d1", "P = 4 кВт", (3000.0, -80.0), 50.0),
+ ]
+
+
+def layout_records(skip=()):
+ places = {
+ "M82": (2000.0, 3000.0),
+ "M87": (4000.0, 3000.0),
+ "CP-M88": (25000.0, 5000.0),
+ "M88": (26000.0, 8000.0),
+ }
+ panels = {"M82": "CP1", "M87": "CP 1", "CP-M88": "CP-1", "M88": "CP-M88"}
+ records = [
+ rec(
+ "W",
+ "LWPOLYLINE",
+ WALLS,
+ ((0, 0), (40000, 0), (40000, 20000), (0, 20000)),
+ closed=True,
+ bbox=(0.0, 0.0, 40000.0, 20000.0),
+ ),
+ rec("X", "LINE", WALLS, ((20000, 0), (20000, 20000))),
+ text("R1", "ROOM 101", (5000.0, 10000.0), 250.0),
+ text("R2", "ROOM 102", (30000.0, 10000.0), 250.0),
+ text("P1", "CP-1", (15000.0, 1000.0), 250.0),
+ ]
+ for tag, (x, y) in places.items():
+ if tag in skip:
+ continue
+ records.append(text("t" + tag, tag, (x, y), 250.0))
+ records.append(text("w" + tag, f"Wiring to {panels[tag]}", (x, y - 300.0), 250.0))
+ return records
+
+
+def room_key(label):
+ found = room_label(label)
+ assert found is not None, f"the vocabulary must read {label!r} as a room label"
+ return str(found.get("number") or found.get("name"))
+
+
+@pytest.fixture
+def pair():
+ assert classify_layer(WALLS)["discipline"] == "architecture", "WALLS must be a wall layer"
+ return snap(pid_records(), 4, "pid.dxf"), snap(layout_records(), 4, "layout.dxf")
+
+
+def by_tag(result):
+ return {row["tag"]: row for row in result["rows"]}
+
+
+def test_every_load_gets_its_panel_length_and_rounded_cable(pair):
+ pid, layout = pair
+ rows = by_tag(cable_rows(pid, layout))
+ assert set(rows) == {"M82", "M87", "CP-M88", "M88"}
+ expected = {
+ # tag: (panel, Manhattan mm, ROUNDUP(m x 1.2))
+ "M82": ("CP-1", 15000.0, 18), # |15000-2000| + |1000-3000| = 13000 + 2000
+ "M87": ("CP-1", 13000.0, 16), # 11000 + 2000 -> 15.6 -> 16
+ "CP-M88": ("CP-1", 14000.0, 17), # 10000 + 4000 -> 16.8 -> 17
+ "M88": ("CP-M88", 4000.0, 5), # 1000 + 3000 -> 4.8 -> 5
+ }
+ for tag, (panel, mm, cable) in expected.items():
+ row = rows[tag]
+ assert row["panel"] == panel # 'CP1', 'CP 1' and 'CP-1' are one panel
+ assert row["panel_source"] == "layout"
+ assert row["length_m"] * 1000.0 == pytest.approx(mm, abs=1e-6)
+ assert row["cable_m"] == cable
+
+
+def test_power_and_supply_are_read_from_the_pid_texts(pair):
+ pid, layout = pair
+ m82 = by_tag(cable_rows(pid, layout))["M82"]
+ assert m82["kw"] == pytest.approx(18.5)
+ assert (m82["phases"], m82["voltage"], m82["neutral"]) == (3, 400, True)
+
+
+def test_power_is_never_invented(pair):
+ pid, layout = pair
+ result = cable_rows(pid, layout)
+ m87 = by_tag(result)["M87"]
+ assert m87["kw"] is None # the P&ID states phases and voltage, no kW
+ assert m87["cable_m"] == 16 # the cable is still measured
+ assert {
+ "tag": "M87",
+ "item": "missing_power",
+ "detail": "no power is stated near the tag on the P&ID",
+ } in result["open_items"]
+ assert all(item["tag"] == "M87" for item in result["open_items"])
+
+
+def test_room_totals_count_a_package_and_not_its_children(pair):
+ pid, layout = pair
+ result = cable_rows(pid, layout)
+ rows = by_tag(result)
+ assert rows["M88"]["package"] == "CP-M88"
+ assert rows["M82"]["room"] == room_key("ROOM 101")
+ assert rows["CP-M88"]["room"] == room_key("ROOM 102")
+ rooms = {item["key"]: item for item in result["summary"]["by_room"]}
+ assert rooms[room_key("ROOM 101")]["kw"] == pytest.approx(18.5) # M82; M87 has none
+ assert rooms[room_key("ROOM 102")]["kw"] == pytest.approx(15.0) # CP-M88, not + M88's 4
+ assert rooms[room_key("ROOM 102")]["loads"] == 2
+ assert result["totals"]["known_kw"] == pytest.approx(33.5)
+ assert result["totals"]["cable_m"] == 18 + 16 + 17 + 5
+
+
+def test_roundup_does_not_buy_a_metre_of_binary_noise():
+ assert 50.0 * 1.1 > 55.0 # 55.00000000000001 in floating point
+ assert roundup_m(50.0, 0.10) == 55
+ assert roundup_m(15.0, 0.20) == 18
+ assert roundup_m(15.001, 0.20) == 19 # 18.0012 -> 19
+
+
+def test_without_a_layout_lengths_stay_empty_with_an_open_item(pair):
+ pid, _layout = pair
+ result = cable_rows(pid, None)
+ assert all(row["length_m"] is None and row["cable_m"] is None for row in result["rows"])
+ no_layout = [item["tag"] for item in result["open_items"] if item["item"] == "no_layout"]
+ assert sorted(no_layout) == ["CP-M88", "M82", "M87", "M88"]
+ # no callout on the P&ID either: the panel is open, never guessed
+ assert all(row["panel"] is None for row in result["rows"])
+
+
+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")
+ result = cable_rows(pid, layout)
+ items = {(item["tag"], item["item"]) for item in result["open_items"]}
+ assert ("M87", "tag_not_on_layout") in items
+ assert ("M87", "missing_panel") in items # its callout went with it
+ assert by_tag(result)["M87"]["cable_m"] is None
+
+
+def test_sections_come_only_from_the_callers_rules(pair):
+ pid, layout = pair
+ assert all(row["section"] is None for row in cable_rows(pid, layout)["rows"])
+ rules = [{"max_kw": 40, "section": "5x10"}, {"max_kw": 5, "section": "5x2.5"}]
+ rows = by_tag(cable_rows(pid, layout, section_rules=rules))
+ assert rows["M82"]["section"] == "5x10" # 18.5 kW
+ assert rows["M88"]["section"] == "5x2.5" # 4 kW: the smaller rule is tried first
+ assert rows["M87"]["section"] is None # no power, no section
+
+
+def test_a_load_beyond_every_rule_is_an_open_item(pair):
+ pid, layout = pair
+ result = cable_rows(pid, layout, section_rules=[{"max_kw": 10, "section": "5x4"}])
+ assert {"tag": "M82", "item": "no_section_rule", "detail": "no rule covers 18.5 kW"} in (
+ result["open_items"]
+ )
+
+
+@pytest.mark.parametrize(
+ ("kwargs", "message"),
+ [
+ ({"allowance": 2.0}, "allowance: must lie between 0 and 1"),
+ ({"section_rules": {"max_kw": 5}}, "section_rules: expected a list"),
+ (
+ {"section_rules": [{"max_kw": 0, "section": "x"}]},
+ r"section_rules\[0\]\.max_kw: must be",
+ ),
+ ({"section_rules": [{"max_kw": 5}]}, r"section_rules\[0\]\.section: expected a non-empty"),
+ (
+ {"section_rules": [{"max_kw": 5, "section": "x", "phases": 2}]},
+ r"section_rules\[0\]\.phases: 1 or 3",
+ ),
+ (
+ {"section_rules": [{"max_kw": 5, "section": "x", "iec": True}]},
+ r"section_rules\[0\]: unknown key\(s\) iec",
+ ),
+ ],
+)
+def test_refusals_name_the_option(pair, kwargs, message):
+ pid, layout = pair
+ with pytest.raises(ValueError, match="^" + message):
+ cable_rows(pid, layout, **kwargs)
+
+
+CABLE_HEADERS = {
+ "tr": [
+ "Etiket",
+ "Mahal",
+ "Güç (kW)",
+ "Faz",
+ "Gerilim (V)",
+ "Nötr",
+ "Sürücü (VFD)",
+ "Pano",
+ "Mesafe (m)",
+ "Pay oranı",
+ "Kablo (m)",
+ "Kesit",
+ "Paket",
+ ],
+ "en": [
+ "Tag",
+ "Room",
+ "Power (kW)",
+ "Phases",
+ "Voltage (V)",
+ "Neutral",
+ "VFD",
+ "Panel",
+ "Distance (m)",
+ "Allowance",
+ "Cable (m)",
+ "Section",
+ "Package",
+ ],
+ "ru": [
+ "Тег",
+ "Помещение",
+ "Мощность (кВт)",
+ "Фазы",
+ "Напряжение (В)",
+ "Нейтраль",
+ "ЧРП",
+ "Шкаф",
+ "Расстояние (м)",
+ "Запас",
+ "Кабель (м)",
+ "Сечение",
+ "Комплект",
+ ],
+}
+
+
+@pytest.mark.parametrize("lang", ["tr", "en", "ru"])
+def test_the_cable_workbook_speaks_three_languages(pair, tmp_path, lang):
+ pid, layout = pair
+ files = write_workbook(
+ cable_rows(pid, layout), kind="cable", lang=lang, path=str(tmp_path / "cables.xlsx")
+ )
+ with open(files["csv"][0], encoding="utf-8-sig", newline="") as handle:
+ metraj = list(csv.reader(handle))
+ assert metraj[0] == CABLE_HEADERS[lang]
+ assert metraj[-1][10] == str(18 + 16 + 17 + 5)
+ with open(files["csv"][2], encoding="utf-8-sig", newline="") as handle:
+ kontrol = list(csv.reader(handle))
+ assert [row[0] for row in kontrol[1:]] == ["M87"] # the one open item: no power
+
+
+def test_the_method_sheet_states_the_package_rule_and_that_power_is_never_invented(pair, tmp_path):
+ pid, layout = pair
+ files = write_workbook(
+ cable_rows(pid, layout), kind="cable", lang="en", path=str(tmp_path / "cables.xlsx")
+ )
+ with open(files["csv"][3], encoding="utf-8-sig", newline="") as handle:
+ method = {row[0]: row[1] for row in list(csv.reader(handle))[1:]}
+ assert "never invented" in method["Rule: power"]
+ assert "counted twice" in method["Rule: panel packages"]
+ assert method["Section rules (the caller's)"] == "—"
+
+
+# -- the synthetic plant pair (Task 2) -------------------------------------------
+
+
+@pytest.fixture(scope="module")
+def plant(tmp_path_factory):
+ truth = build_plant_pair(tmp_path_factory.mktemp("plant"))
+ return truth, read_snapshot(truth["pid"]), read_snapshot(truth["layout"])
+
+
+def inside(point, polygon):
+ """Even-odd ray cast over the generator's own room polygon."""
+ x, y = point
+ hit = False
+ for (x1, y1), (x2, y2) in zip(polygon, polygon[1:] + polygon[:1], strict=False):
+ if (y1 > y) != (y2 > y) and x < x1 + (y - y1) * (x2 - x1) / (y2 - y1):
+ hit = not hit
+ return hit
+
+
+def test_every_synthetic_cable_row_equals_the_generators_truth(plant):
+ truth, pid, layout = plant
+ result = cable_rows(pid, layout)
+ rows = by_tag(result)
+ assert set(rows) == {expected["tag"] for expected in truth["cable_rows"]}
+ for expected in truth["cable_rows"]:
+ row = rows[expected["tag"]]
+ assert row["panel"] == expected["panel"], expected["tag"]
+ if expected["kw"] is None:
+ assert row["kw"] is None, expected["tag"]
+ assert {
+ "tag": expected["tag"],
+ "item": "missing_power",
+ "detail": "no power is stated near the tag on the P&ID",
+ } in result["open_items"]
+ else:
+ assert row["kw"] == pytest.approx(expected["kw"]), expected["tag"]
+ assert row["length_m"] * 1000.0 == pytest.approx(expected["length_mm"], abs=1e-6)
+ assert row["cable_m"] == expected["roundup_m"], expected["tag"]
+
+
+def test_the_synthetic_room_totals_count_each_power_once(plant):
+ truth, pid, layout = plant
+ result = cable_rows(pid, layout)
+ rooms = [
+ (str(room["number"] or room["name"]), [tuple(p) for p in room["polygon"]])
+ for room in truth["rooms"]
+ ]
+ for row in result["rows"]:
+ homes = [key for key, polygon in rooms if row["at"] and inside(row["at"], polygon)]
+ if homes:
+ assert row["room"] == homes[0], row["tag"]
+ stated = {
+ normalize_panel(r["tag"]) or r["tag"] for r in truth["cable_rows"] if r["kw"] is not None
+ }
+
+ def child(r):
+ return r["panel"] in stated and (normalize_panel(r["tag"]) or r["tag"]) != r["panel"]
+
+ expected_kw = sum(r["kw"] for r in truth["cable_rows"] if r["kw"] is not None and not child(r))
+ assert result["totals"]["known_kw"] == pytest.approx(expected_kw)
+ by_room = sum(item["kw"] for item in result["summary"]["by_room"])
+ assert by_room == pytest.approx(expected_kw)
diff --git a/tests/test_understand_network.py b/tests/test_understand_network.py
new file mode 100644
index 0000000..282d576
--- /dev/null
+++ b/tests/test_understand_network.py
@@ -0,0 +1,635 @@
+"""The line-network engine: runs, duplicates once, fittings, diameters, services, equipment.
+
+Every drawing below is built record by record, so each expected length is the
+hand sum written beside it; nothing is read back from the implementation. The
+last three tests read the synthetic plant pair of Task 2 and compare every run
+with the generator's own truth.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from engineering.understand.network import (
+ Run,
+ build_network,
+ default_layers,
+ is_reducer,
+ label_search_default,
+)
+from engineering.understand.snapshot import EntityRecord, Snapshot, read_snapshot
+from engineering.understand.vocab import classify_layer, equipment_kind
+from tests.fixtures.plant_pair import build_plant_pair
+
+EPS = 1e-9
+PIPE = "PRODUCT"
+
+
+def rec(handle, kind, layer=PIPE, points=(), **extra):
+ return EntityRecord(
+ handle=handle, type=kind, layer=layer, space="Model", points=tuple(points), **extra
+ )
+
+
+def line(handle, a, b, layer=PIPE):
+ return rec(handle, "LINE", layer, (a, b))
+
+
+def text(handle, value, at, height=5.0, layer="TEXT"):
+ x, y = at
+ return rec(
+ handle,
+ "TEXT",
+ layer,
+ (at,),
+ text=value,
+ height=height,
+ bbox=(x, y, x + height * len(value) * 0.6, y + height),
+ )
+
+
+def tank(handle, tag, box, layer="EQUIPMENT"):
+ """A closed rectangle with its tag written inside it."""
+ x0, y0, x1, y1 = box
+ outline = rec(
+ handle, "LWPOLYLINE", layer, ((x0, y0), (x1, y0), (x1, y1), (x0, y1)), closed=True, bbox=box
+ )
+ return [outline, text(handle + "T", tag, ((x0 + x1) / 2.0 - 10.0, (y0 + y1) / 2.0))]
+
+
+def insert(handle, block, box, layer=PIPE):
+ return rec(
+ handle,
+ "INSERT",
+ layer,
+ (((box[0] + box[2]) / 2.0, (box[1] + box[3]) / 2.0),),
+ block=block,
+ bbox=box,
+ )
+
+
+def snap(*records, insunits=4):
+ flat = []
+ for item in records:
+ flat.extend(item if isinstance(item, list) else [item])
+ layers = {r.layer: {"color": 7, "linetype": "Continuous"} for r in flat}
+ return Snapshot(
+ source="test",
+ insunits=insunits,
+ extmin=None,
+ extmax=None,
+ layers=layers,
+ layouts=(),
+ records=tuple(flat),
+ )
+
+
+def total(run: Run) -> float:
+ return sum(edge["length"] for edge in run.edges)
+
+
+def net(*records, **kw):
+ return build_network(snap(*records), layers=[PIPE], **kw)
+
+
+# -- topology ------------------------------------------------------------------
+
+
+def test_ends_within_tol_merge_into_one_run():
+ result = net(line("1", (0.0, 0.0), (1000.0, 0.0)), line("2", (1000.4, 0.0), (2000.0, 0.0)))
+ (run,) = result["runs"]
+ # the second line starts at the merged vertex (1000, 0): 1000 + 1000
+ assert abs(total(run) - 2000.0) < EPS
+ assert run.ends == ((0.0, 0.0), (2000.0, 0.0))
+
+
+def test_a_branch_ending_on_a_header_is_a_t_junction():
+ result = net(line("1", (0.0, 0.0), (2000.0, 0.0)), line("2", (1000.0, 0.0), (1000.0, 800.0)))
+ (run,) = result["runs"]
+ assert abs(total(run) - 2800.0) < EPS # 2000 header + 800 branch
+ assert len(run.edges) == 3 # header split at the tee
+ assert run.ends == ((0.0, 0.0), (1000.0, 800.0), (2000.0, 0.0))
+
+
+def test_two_lines_crossing_without_an_end_do_not_connect():
+ result = net(line("1", (0.0, 0.0), (2000.0, 0.0)), line("2", (1000.0, -500.0), (1000.0, 500.0)))
+ assert len(result["runs"]) == 2
+
+
+# -- Review Focus 4: double-drawn pipe counts once ----------------------------
+
+
+def test_a_line_drawn_twice_is_counted_once():
+ result = net(line("1", (0.0, 0.0), (2000.0, 0.0)), line("2", (0.0, 0.0), (2000.0, 0.0)))
+ (run,) = result["runs"]
+ assert abs(total(run) - 2000.0) < EPS # not 4000
+ assert abs(result["stats"]["overlap_length"] - 2000.0) < EPS
+ assert run.edges[0]["handles"] == ("1", "2")
+
+
+def test_a_line_drawn_over_a_polyline_is_counted_once():
+ poly = rec("P", "LWPOLYLINE", PIPE, ((0.0, 0.0), (1000.0, 0.0), (3000.0, 0.0)))
+ result = net(line("L", (0.0, 0.0), (3000.0, 0.0)), poly)
+ (run,) = result["runs"]
+ # the line splits at the polyline's vertex (1000, 0): pieces 1000 + 2000, each once
+ assert abs(total(run) - 3000.0) < EPS
+ assert abs(result["stats"]["overlap_length"] - 3000.0) < EPS
+ assert all(edge["handles"] == ("L", "P") for edge in run.edges)
+
+
+def test_a_partial_collinear_overlap_counts_the_shared_stretch_once():
+ result = net(line("1", (0.0, 0.0), (2000.0, 0.0)), line("2", (1500.0, 0.0), (3000.0, 0.0)))
+ (run,) = result["runs"]
+ assert abs(total(run) - 3000.0) < EPS # 0..3000 once; 1500..2000 was drawn twice
+ assert abs(result["stats"]["overlap_length"] - 500.0) < EPS
+
+
+def test_an_arc_keeps_its_true_length():
+ # a quarter circle of radius 1000 centred on the origin, from 0 deg to 90 deg
+ arc = rec("A", "ARC", PIPE, ((0.0, 0.0),), radius=1000.0, angles=(0.0, 90.0))
+ (run,) = net(arc)["runs"]
+ assert abs(total(run) - 500.0 * 3.141592653589793) < 1e-6 # 2*pi*1000/4
+ ends = [c for point in run.ends for c in point]
+ assert ends == pytest.approx([0.0, 1000.0, 1000.0, 0.0], abs=1e-9)
+
+
+# -- fittings ----------------------------------------------------------------
+
+
+def test_an_inline_valve_joins_the_two_lines_and_adds_no_length():
+ result = net(
+ line("1", (0.0, 0.0), (1000.0, 0.0)),
+ line("2", (1100.0, 0.0), (2000.0, 0.0)),
+ insert("V", "GATE_VALVE", (1000.0, -50.0, 1100.0, 50.0)),
+ )
+ (run,) = result["runs"]
+ assert abs(total(run) - 1900.0) < EPS # 1000 + 900; the valve body is not pipe
+ assert run.ends == ((0.0, 0.0), (2000.0, 0.0))
+ assert result["stats"]["fittings"] == 1
+
+
+@pytest.mark.parametrize(
+ ("block", "reducer"),
+ [
+ ("REDUCER_51_38", True),
+ ("ПЕРЕХОД_51", True),
+ ("REDÜKSİYON-51", True),
+ ("verloop 51x38", True),
+ ("REDUZIERSTÜCK_51", True),
+ ("GATE_VALVE", False),
+ (None, False),
+ ],
+)
+def test_reducer_words_in_four_languages(block, reducer):
+ assert is_reducer(block) is reducer
+
+
+def test_reducer_words_agree_with_the_vocabulary():
+ # one reducer vocabulary: every block equipment_kind calls a reducer is one here
+ for block in ("REDUCER_51_38", "ПЕРЕХОД_51", "REDÜKSİYON-51", "VERLOOPSTUK", "REDUZIERSTÜCK"):
+ assert equipment_kind(block)["kind"] == "reducer"
+ assert is_reducer(block), block
+
+
+def test_an_insert_holding_more_than_four_free_ends_is_not_a_fitting():
+ # a title-block sized INSERT over four separate lines: eight free ends inside
+ result = net(
+ *[line(str(n), (0.0, 100.0 * n), (500.0, 100.0 * n)) for n in range(4)],
+ insert("TB", "TITLE", (-10.0, -10.0, 510.0, 310.0)),
+ )
+ assert len(result["runs"]) == 4
+ assert result["stats"]["fittings"] == 0
+ assert result["stats"]["fittings_skipped"] == ["TB"]
+
+
+# -- diameters ---------------------------------------------------------------
+
+
+def test_a_label_is_direct_and_carries_by_continuity_across_a_tee():
+ result = net(
+ line("1", (0.0, 0.0), (1000.0, 0.0)),
+ line("2", (1000.0, 0.0), (2000.0, 0.0)),
+ line("3", (1000.0, 0.0), (1000.0, 800.0)),
+ text("D", "Ø51", (400.0, 5.0)),
+ )
+ (run,) = result["runs"]
+ by_handle = {edge["handles"]: edge for edge in run.edges}
+ assert by_handle[("1",)]["diameter_source"] == "direct"
+ assert by_handle[("1",)]["label"] == "D"
+ for handle in (("2",), ("3",)):
+ assert by_handle[handle]["diameter"] == "Ø51"
+ assert by_handle[handle]["diameter_source"] == "continuity"
+ assert result["unassigned"] == []
+
+
+def test_a_label_holds_for_every_piece_of_its_drawn_entity():
+ poly = rec("P", "LWPOLYLINE", PIPE, ((0.0, 0.0), (1000.0, 0.0), (1000.0, 1000.0)))
+ (run,) = net(poly, text("D", "DN20", (400.0, 5.0)))["runs"]
+ assert [edge["diameter_source"] for edge in run.edges] == ["direct", "direct"]
+ assert {edge["diameter"] for edge in run.edges} == {"DN20"}
+
+
+def test_continuity_stops_at_a_reducer():
+ result = net(
+ line("1", (0.0, 0.0), (1000.0, 0.0)),
+ line("2", (1060.0, 0.0), (2000.0, 0.0)),
+ insert("R", "REDUCER_51_38", (1000.0, -30.0, 1060.0, 30.0)),
+ text("D", "Ø51", (400.0, 5.0)),
+ )
+ (run,) = result["runs"]
+ by_handle = {edge["handles"]: edge for edge in run.edges}
+ assert by_handle[("1",)]["diameter"] == "Ø51"
+ assert by_handle[("2",)]["diameter"] is None
+ assert by_handle[("2",)]["diameter_source"] == "unassigned"
+ assert result["unassigned"][0]["reason"].startswith("no diameter label")
+ assert result["stats"]["reducers"] == 1
+
+
+def test_continuity_stops_at_a_reducer_the_line_is_broken_at():
+ # BREAK-at-point: two LINEs share a vertex inside the reducer's box, no free end there
+ result = net(
+ line("1", (0.0, 0.0), (1030.0, 0.0)),
+ line("2", (1030.0, 0.0), (2000.0, 0.0)),
+ insert("R", "REDUCER_51_38", (1000.0, -30.0, 1060.0, 30.0)),
+ text("D", "Ø51", (400.0, 5.0)),
+ )
+ by_handle = {edge["handles"]: edge for edge in result["runs"][0].edges}
+ assert by_handle[("1",)]["diameter"] == "Ø51"
+ assert by_handle[("2",)]["diameter_source"] == "unassigned"
+ assert (result["stats"]["fittings"], result["stats"]["reducers"]) == (1, 1)
+
+
+def test_a_polyline_drawn_through_a_reducer_is_cut_there():
+ poly = rec("P", "LWPOLYLINE", PIPE, ((0.0, 0.0), (2000.0, 0.0)))
+ result = net(
+ poly,
+ line("BR", (1500.0, 0.0), (1500.0, 800.0)),
+ insert("R", "REDUCER_51_38", (1000.0, -30.0, 1060.0, 30.0)),
+ text("D", "Ø51", (400.0, 5.0)),
+ )
+ (run,) = result["runs"]
+ # cut at the foot of the box centre (1030, 0): 1030 + 470 + 500 + 800 branch
+ assert abs(total(run) - 2800.0) < EPS
+ by_span = {(edge["a"][0], edge["b"][0], edge["handles"]): edge for edge in run.edges}
+ labelled = by_span[(0.0, 1030.0, ("P",))]
+ assert (labelled["diameter"], labelled["diameter_source"]) == ("Ø51", "direct")
+ # the label holds for the polyline only up to the reducer; nothing crosses it
+ others = [edge for edge in run.edges if edge is not labelled]
+ assert len(others) == 3
+ assert {edge["diameter_source"] for edge in others} == {"unassigned"}
+ assert result["stats"]["reducers"] == 1
+
+
+def test_a_pipe_running_beside_a_reducer_is_not_cut():
+ # a parallel pipe grazing the reducer's box edge does not pass through its middle
+ result = net(
+ line("1", (0.0, 30.0), (2000.0, 30.0)),
+ insert("R", "REDUCER_51_38", (1000.0, -30.0, 1060.0, 30.0)),
+ text("D", "Ø51", (400.0, 35.0)),
+ )
+ (run,) = result["runs"]
+ assert len(run.edges) == 1
+ assert run.edges[0]["diameter_source"] == "direct"
+ assert result["stats"]["reducers"] == 0
+
+
+@pytest.mark.parametrize("order", ["ABC", "BAC", "CAB", "ACB", "BCA", "CBA"])
+def test_a_three_way_valve_guesses_nothing_whatever_the_record_order(order):
+ # C leaves the valve 1000 from A (Ø51) and 1000 from B (DN20): equally near
+ records = {
+ "A": line("A", (0.0, 0.0), (1000.0, 0.0)),
+ "B": line("B", (1100.0, 0.0), (2100.0, 0.0)),
+ "C": line("C", (1050.0, 50.0), (1050.0, 1050.0)),
+ }
+ result = net(
+ *[records[name] for name in order],
+ insert("V", "3WAY_VALVE", (1000.0, -50.0, 1100.0, 50.0)),
+ text("TA", "Ø51", (400.0, 5.0)),
+ text("TB", "DN20", (1500.0, 5.0)),
+ )
+ (run,) = result["runs"]
+ c = next(edge for edge in run.edges if edge["handles"] == ("C",))
+ assert (c["diameter"], c["diameter_source"]) == (None, "unassigned")
+ assert [u["reason"] for u in result["unassigned"]] == ["two different diameters equally near"]
+ assert run.ends == ((0.0, 0.0), (1050.0, 1050.0), (2100.0, 0.0))
+
+
+def test_a_label_nearer_to_a_pipe_on_an_unread_layer_is_not_taken():
+ # the label stands 5 below the WATER line and 22.5 above PRODUCT (search 25)
+ drawing = snap(
+ line("1", (0.0, 0.0), (1000.0, 0.0), PIPE),
+ line("W", (0.0, 30.0), (1000.0, 30.0), "WATER"),
+ text("D", "DN20", (400.0, 20.0)),
+ )
+ assert "WATER" not in default_layers(drawing)
+ result = build_network(drawing)
+ assert result["runs"][0].edges[0]["diameter_source"] == "unassigned"
+ assert result["stats"]["labels_skipped"] == [
+ {
+ "handle": "D",
+ "text": "DN20",
+ "reason": "nearer to a line on layer WATER, which is not read",
+ }
+ ]
+
+
+def test_an_equipment_outline_does_not_take_a_pipe_label():
+ # closed outlines are shapes, not pipes: a label beside the pipe end stays the pipe's
+ result = net(
+ tank("A", "T4100", (-500.0, -500.0, 0.0, 500.0)),
+ line("1", (0.0, 0.0), (1000.0, 0.0)),
+ text("D", "Ø51", (2.0, 5.0)),
+ )
+ edge = result["runs"][0].edges[0]
+ assert (edge["diameter"], edge["diameter_source"]) == ("Ø51", "direct")
+
+
+def test_continuity_crosses_a_valve():
+ result = net(
+ line("1", (0.0, 0.0), (1000.0, 0.0)),
+ line("2", (1100.0, 0.0), (2000.0, 0.0)),
+ insert("V", "GATE_VALVE", (1000.0, -50.0, 1100.0, 50.0)),
+ text("D", "Ø51", (400.0, 5.0)),
+ )
+ edge = next(e for e in result["runs"][0].edges if e["handles"] == ("2",))
+ assert (edge["diameter"], edge["diameter_source"]) == ("Ø51", "continuity")
+
+
+def test_a_conflicting_label_stops_continuity_and_the_nearer_one_wins():
+ result = net(
+ line("1", (0.0, 0.0), (1000.0, 0.0)),
+ line("2", (1000.0, 0.0), (2000.0, 0.0)),
+ line("3", (2000.0, 0.0), (3000.0, 0.0)),
+ text("A", "Ø51", (400.0, 5.0)),
+ text("B", "DN20", (1400.0, 5.0)),
+ )
+ by_handle = {edge["handles"]: edge for edge in result["runs"][0].edges}
+ assert by_handle[("1",)]["diameter"] == "Ø51"
+ assert by_handle[("2",)]["diameter"] == "DN20"
+ # piece 3 is next to piece 2 (DN20), one piece further from piece 1 (Ø51)
+ assert (by_handle[("3",)]["diameter"], by_handle[("3",)]["diameter_source"]) == (
+ "DN20",
+ "continuity",
+ )
+
+
+def test_two_diameters_equally_near_leave_the_piece_unassigned():
+ result = net(
+ line("1", (0.0, 0.0), (1000.0, 0.0)),
+ line("2", (1000.0, 0.0), (2000.0, 0.0)),
+ line("3", (2000.0, 0.0), (3000.0, 0.0)),
+ text("A", "Ø51", (400.0, 5.0)),
+ text("B", "DN20", (2400.0, 5.0)),
+ )
+ middle = next(e for e in result["runs"][0].edges if e["handles"] == ("2",))
+ assert middle["diameter_source"] == "unassigned"
+ assert result["unassigned"][0]["reason"] == "two different diameters equally near"
+
+
+def test_labels_are_kept_exactly_as_drawn():
+ result = net(line("1", (0.0, 0.0), (1000.0, 0.0)), text("D", "SMS51", (400.0, 5.0)))
+ assert result["runs"][0].edges[0]["diameter"] == "SMS51"
+
+
+def test_a_label_beyond_the_search_distance_is_not_read():
+ # median text height 5 -> label_search 25; the label's centre stands 200 away
+ result = net(line("1", (0.0, 0.0), (1000.0, 0.0)), text("D", "Ø51", (400.0, 200.0)))
+ assert result["stats"]["label_search"] == 25.0
+ assert result["runs"][0].edges[0]["diameter_source"] == "unassigned"
+
+
+# -- services ----------------------------------------------------------------
+
+
+def test_a_return_word_overrides_the_layer_for_the_whole_run():
+ # the layer says supply; only the word says this run returns
+ layer = "CIP_SUPPLY"
+ base = classify_layer(layer)["service"]
+ assert base in ("cip_supply", "cip_return"), "the vocabulary must file CIP_SUPPLY as CIP"
+ records = [
+ line("1", (0.0, 0.0), (1000.0, 0.0), layer),
+ line("2", (1000.0, 0.0), (1000.0, 1000.0), layer),
+ text("W", "ВОЗВРАТ", (400.0, 5.0)),
+ ]
+ result = build_network(snap(*records), layers=[layer])
+ (run,) = result["runs"]
+ assert run.service == "cip_return"
+ assert {edge["service"] for edge in run.edges} == {"cip_return"}
+
+
+def test_a_cip_layer_naming_no_direction_takes_it_from_the_word():
+ # classify_layer files a directionless CIP layer as `unknown` on purpose:
+ # the layer is not trusted with the direction, so the word must decide it
+ layer = "CIP_LINES"
+ found = classify_layer(layer)
+ assert (found["service"], found["keyword"]) == ("unknown", "cip")
+ records = [
+ line("1", (0.0, 0.0), (1000.0, 0.0), layer),
+ line("2", (1000.0, 0.0), (1000.0, 1000.0), layer),
+ text("W", "ВОЗВРАТ", (400.0, 5.0)),
+ ]
+ result = build_network(snap(*records), layers=[layer])
+ (run,) = result["runs"]
+ assert run.service == "cip_return"
+ assert result["stats"]["service_conflicts"] == []
+
+
+def test_a_directionless_cip_layer_without_a_word_stays_unknown():
+ (run,) = build_network(
+ snap(line("1", (0.0, 0.0), (1000.0, 0.0), "CIP_LINES")), layers=["CIP_LINES"]
+ )["runs"]
+ assert run.service == "unknown"
+
+
+def test_without_a_word_the_run_keeps_its_layer_service():
+ (run,) = net(line("1", (0.0, 0.0), (1000.0, 0.0)))["runs"]
+ assert run.service == classify_layer(PIPE)["service"]
+
+
+# -- equipment ---------------------------------------------------------------
+
+
+def test_run_ends_attach_to_the_tags_whose_geometry_holds_them():
+ result = net(
+ tank("A", "T4100", (-500.0, -500.0, 0.0, 500.0)),
+ tank("B", "T4101", (2000.0, -500.0, 2500.0, 500.0)),
+ line("1", (0.0, 0.0), (2000.0, 0.0)),
+ )
+ (run,) = result["runs"]
+ assert run.tags == ("T4100", "T4101")
+ assert result["attachments"]["R1"] == [
+ {"at": (0.0, 0.0), "tag": "T4100"},
+ {"at": (2000.0, 0.0), "tag": "T4101"},
+ ]
+
+
+def test_a_tagged_insert_is_equipment_not_a_fitting():
+ pump = rec(
+ "PU",
+ "INSERT",
+ "EQUIPMENT",
+ ((1050.0, 0.0),),
+ block="*U12",
+ attribs=(("TAG", "M82"),),
+ bbox=(1000.0, -50.0, 1100.0, 50.0),
+ )
+ result = net(
+ line("1", (0.0, 0.0), (1000.0, 0.0)), line("2", (1100.0, 0.0), (2000.0, 0.0)), pump
+ )
+ assert len(result["runs"]) == 2
+ assert all("M82" in run.tags for run in result["runs"])
+ assert result["stats"]["fittings"] == 0
+
+
+def test_a_room_outline_around_two_tags_is_not_equipment():
+ room = rec(
+ "ROOM",
+ "LWPOLYLINE",
+ "ROOMS",
+ ((-5000.0, -5000.0), (9000.0, -5000.0), (9000.0, 5000.0), (-5000.0, 5000.0)),
+ closed=True,
+ bbox=(-5000.0, -5000.0, 9000.0, 5000.0),
+ )
+ outline = rec(
+ "A",
+ "LWPOLYLINE",
+ "EQUIPMENT",
+ ((-500.0, -500.0), (0.0, -500.0), (0.0, 500.0), (-500.0, 500.0)),
+ closed=True,
+ bbox=(-500.0, -500.0, 0.0, 500.0),
+ )
+ # T4100 is written 30 above its tank, inside the room: the room holds T4100
+ # and T4101, so it is not equipment, and the tank is the nearest shape
+ # within 2 x label_search = 50
+ result = net(
+ room,
+ outline,
+ text("AT", "T4100", (-400.0, 530.0)),
+ tank("B", "T4101", (2000.0, -500.0, 2500.0, 500.0)),
+ line("1", (0.0, 0.0), (2000.0, 0.0)),
+ )
+ assert result["equipment"]["T4100"][0]["handle"] == "A"
+ assert result["runs"][0].tags == ("T4100", "T4101")
+
+
+def test_a_shape_far_larger_than_the_others_is_still_found():
+ # the grid keeps a box covering many cells aside; it must still be found
+ small = [
+ rec(
+ f"C{n}",
+ "CIRCLE",
+ "EQUIPMENT",
+ ((5000.0 + 300.0 * n, 5000.0),),
+ radius=50.0,
+ bbox=(4950.0 + 300.0 * n, 4950.0, 5050.0 + 300.0 * n, 5050.0),
+ )
+ for n in range(5)
+ ]
+ big = rec(
+ "BIG",
+ "LWPOLYLINE",
+ "EQUIPMENT",
+ ((-500.0, -500.0), (-500.0 + 40000.0, -500.0), (39500.0, 39500.0), (-500.0, 39500.0)),
+ closed=True,
+ bbox=(-500.0, -500.0, 39500.0, 39500.0),
+ )
+ result = net(
+ *small, big, text("AT", "T4100", (-400.0, 0.0)), line("1", (0.0, 0.0), (0.0, -2000.0))
+ )
+ assert result["equipment"]["T4100"][0]["handle"] == "BIG"
+ assert result["runs"][0].tags == ("T4100",)
+
+
+def test_a_free_end_attaches_to_nothing():
+ result = net(
+ tank("A", "T4100", (-500.0, -500.0, 0.0, 500.0)), line("1", (0.0, 0.0), (2000.0, 0.0))
+ )
+ assert result["attachments"]["R1"][1] == {"at": (2000.0, 0.0), "tag": None}
+
+
+# -- layer choice and refusals -------------------------------------------------
+
+
+def test_default_layers_are_the_confidently_classified_network_layers():
+ drawing = snap(line("1", (0.0, 0.0), (1.0, 0.0)), line("2", (0.0, 0.0), (1.0, 0.0), "MISC"))
+ expected = tuple(
+ name
+ for name in ("MISC", PIPE)
+ if classify_layer(name)["discipline"] in ("piping", "electrical")
+ and classify_layer(name)["confidence"] >= 0.9
+ )
+ assert default_layers(drawing) == expected
+
+
+def test_label_search_defaults_to_five_median_text_heights():
+ drawing = snap(
+ text("a", "x", (0, 0), 2.0), text("b", "y", (0, 0), 4.0), text("c", "z", (0, 0), 9.0)
+ )
+ assert label_search_default(drawing) == 20.0 # 5 x median(2, 4, 9)
+
+
+@pytest.mark.parametrize(
+ ("kwargs", "message"),
+ [
+ ({"tol": 0.0}, "tol: must be a finite number greater than zero"),
+ ({"tol": float("nan")}, "tol: must be a finite number greater than zero"),
+ ({"label_search": -1.0}, "label_search: must be a finite number greater than zero"),
+ ({"layers": ["NOPE"]}, "layers: NOPE not in the drawing; it has: PRODUCT"),
+ ({"layers": []}, "layers: an empty list reads nothing"),
+ ],
+)
+def test_refusals_name_the_parameter(kwargs, message):
+ drawing = snap(line("1", (0.0, 0.0), (1.0, 0.0)))
+ kwargs = {"layers": [PIPE], **kwargs}
+ with pytest.raises(ValueError, match="^" + message.replace("(", r"\(")):
+ build_network(drawing, **kwargs)
+
+
+# -- the synthetic plant pair (Task 2) -------------------------------------------
+
+
+@pytest.fixture(scope="module")
+def plant(tmp_path_factory):
+ truth = build_plant_pair(tmp_path_factory.mktemp("plant"))
+ return truth, read_snapshot(truth["pid"])
+
+
+def run_key(tags, service):
+ return (tuple(sorted(tags)), service)
+
+
+def test_the_generator_names_each_run_by_its_tags_and_service(plant):
+ truth, _pid = plant
+ keys = [run_key(run["tags"], run["service"]) for run in truth["pipe_runs"]]
+ assert len(keys) == len(set(keys))
+
+
+def test_every_synthetic_run_has_its_service_diameter_and_tags(plant):
+ truth, pid = plant
+ found: dict = {}
+ for run in build_network(pid)["runs"]:
+ found.setdefault(run_key(run.tags, run.service), []).append(run)
+ for expected in truth["pipe_runs"]:
+ key = run_key(expected["tags"], expected["service"])
+ runs = found.get(key, [])
+ assert len(runs) == 1, f"{key}: {len(runs)} runs carry these tags and this service"
+ if expected["status"] == "C":
+ continue
+ edges = runs[0].edges
+ assert expected["diameter"] in {edge["diameter"] for edge in edges}, key
+ sources = {edge["diameter_source"] for edge in edges}
+ if expected["status"] == "A":
+ assert sources == {"direct"}, key
+ else:
+ assert sources & {"continuity", "unassigned"}, key
+
+
+def test_the_synthetic_service_layers_are_read_by_default(plant):
+ truth, pid = plant
+ layers = set(build_network(pid)["stats"]["layers"])
+ service_layers = {
+ layer
+ for layer, service in truth["layer_services"].items()
+ if service not in ("electrical", "unknown")
+ }
+ assert service_layers <= layers
diff --git a/tests/test_understand_report.py b/tests/test_understand_report.py
new file mode 100644
index 0000000..4c4b307
--- /dev/null
+++ b/tests/test_understand_report.py
@@ -0,0 +1,253 @@
+"""The takeoff workbook: four sheets, three languages, CSV always, XLSX with openpyxl.
+
+The result written is `pipe_rows` over a hand-built pair: a P&ID with two
+runs (T4100-T4101 labelled Ø51, T4101-T4102 unlabelled) and a layout whose
+INSUNITS says inches over millimetre geometry, measured on the layout (28 m
+and 12 m).
+"""
+
+from __future__ import annotations
+
+import csv
+from pathlib import Path
+
+import pytest
+
+import engineering.understand.report as report
+from engineering.understand.network import build_network
+from engineering.understand.report import LANGS, SHEETS, write_workbook
+from engineering.understand.snapshot import EntityRecord, Snapshot
+from engineering.understand.takeoff import pipe_rows
+
+PIPE = "PRODUCT"
+WALLS = "WALLS"
+SCHEMATIC = {
+ "verdict": "schematic",
+ "within_10pct": 0.25,
+ "pair_count": 12,
+ "median": 1.3,
+ "iqr": 0.8,
+ "factor": None,
+ "matched": [],
+ "ambiguous": [],
+}
+
+
+def rec(handle, kind, layer, points=(), **extra):
+ return EntityRecord(
+ handle=handle, type=kind, layer=layer, space="Model", points=tuple(points), **extra
+ )
+
+
+def text(handle, value, at, height, layer="TEXT"):
+ x, y = at
+ return rec(
+ handle,
+ "TEXT",
+ layer,
+ (at,),
+ text=value,
+ height=height,
+ bbox=(x, y, x + height * len(value) * 0.6, y + height),
+ )
+
+
+def snap(records, insunits, source):
+ layers = {r.layer: {"color": 7, "linetype": "Continuous"} for r in records}
+ return Snapshot(
+ source=source,
+ insunits=insunits,
+ extmin=None,
+ extmax=None,
+ layers=layers,
+ layouts=(),
+ records=tuple(records),
+ )
+
+
+def tank(handle, tag, x0, y0):
+ return [
+ rec(
+ handle,
+ "LWPOLYLINE",
+ "EQUIPMENT",
+ ((x0, y0), (x0 + 500.0, y0), (x0 + 500.0, y0 + 500.0), (x0, y0 + 500.0)),
+ closed=True,
+ bbox=(x0, y0, x0 + 500.0, y0 + 500.0),
+ ),
+ text(handle + "T", tag, (x0 + 150.0, y0 + 225.0), 50.0),
+ ]
+
+
+@pytest.fixture
+def result():
+ pid = snap(
+ [
+ *tank("A", "T4100", 0.0, 0.0),
+ *tank("B", "T4101", 3000.0, 0.0),
+ *tank("C", "T4102", 3000.0, 2000.0),
+ rec("L1", "LINE", PIPE, ((500.0, 250.0), (3000.0, 250.0))),
+ rec("L2", "LINE", PIPE, ((3250.0, 500.0), (3250.0, 2000.0))),
+ text("D1", "Ø51", (1500.0, 260.0), 50.0),
+ ],
+ 4,
+ "pid.dxf",
+ )
+ layout = snap(
+ [
+ rec(
+ "W",
+ "LWPOLYLINE",
+ WALLS,
+ ((0, 0), (40000, 0), (40000, 20000), (0, 20000)),
+ closed=True,
+ bbox=(0.0, 0.0, 40000.0, 20000.0),
+ ),
+ rec("X", "LINE", WALLS, ((20000, 0), (20000, 20000))),
+ text("R1", "ROOM 101", (5000.0, 10000.0), 250.0),
+ text("R2", "ROOM 102", (30000.0, 10000.0), 250.0),
+ text("T0", "T4100", (2000.0, 3000.0), 250.0),
+ text("T1", "T4101", (30000.0, 3000.0), 250.0),
+ text("T2", "T4102", (30000.0, 15000.0), 250.0),
+ ],
+ 1,
+ "layout.dxf",
+ )
+ return pipe_rows(build_network(pid, layers=[PIPE]), pid, layout, scale=SCHEMATIC)
+
+
+METRAJ_HEADERS = {
+ "tr": [
+ "Mahal",
+ "Hizmet",
+ "Çap",
+ "Doğrudan (m)",
+ "Süreklilik (m)",
+ "Atanmamış (m)",
+ "Net (m)",
+ "Pay oranı",
+ "Pay (m)",
+ "Paylı toplam (m)",
+ "Şematik (m)",
+ "Durum",
+ "Hatlar",
+ ],
+ "en": [
+ "Room",
+ "Service",
+ "Diameter",
+ "Direct (m)",
+ "Continuity (m)",
+ "Unassigned (m)",
+ "Net (m)",
+ "Allowance",
+ "Allowance (m)",
+ "Total incl. allowance (m)",
+ "Schematic (m)",
+ "Status",
+ "Runs",
+ ],
+ "ru": [
+ "Помещение",
+ "Среда",
+ "Диаметр",
+ "Прямо (м)",
+ "По непрерывности (м)",
+ "Не назначено (м)",
+ "Нетто (м)",
+ "Запас",
+ "Запас (м)",
+ "Итого с запасом (м)",
+ "По схеме (м)",
+ "Статус",
+ "Линии",
+ ],
+}
+TOTAL = {"tr": "TOPLAM", "en": "TOTAL", "ru": "ИТОГО"}
+
+
+def read_csv(path):
+ with open(path, encoding="utf-8-sig", newline="") as handle:
+ return list(csv.reader(handle))
+
+
+@pytest.mark.parametrize("lang", LANGS)
+def test_csv_sheets_carry_the_headers_of_each_language(result, tmp_path, lang):
+ files = write_workbook(result, kind="pipe", lang=lang, path=str(tmp_path / "takeoff.xlsx"))
+ names = [Path(p).name for p in files["csv"]]
+ assert names == [
+ "takeoff_metraj.csv",
+ "takeoff_ozet.csv",
+ "takeoff_kontrol.csv",
+ "takeoff_metodoloji.csv",
+ ]
+ metraj = read_csv(files["csv"][0])
+ assert metraj[0] == METRAJ_HEADERS[lang]
+ total = metraj[-1]
+ assert total[0] == TOTAL[lang]
+ assert float(total[6]) == pytest.approx(40.0) # 28 m + 12 m net
+ assert float(total[9]) == pytest.approx(48.0) # with the 20 % allowance
+
+
+def test_the_workbook_has_the_four_sheets_and_numbers_stay_numbers(result, tmp_path):
+ openpyxl = pytest.importorskip("openpyxl")
+ files = write_workbook(result, kind="pipe", lang="tr", path=str(tmp_path / "takeoff.xlsx"))
+ assert files["refused"] is None
+ book = openpyxl.load_workbook(files["xlsx"])
+ assert book.sheetnames == list(SHEETS) == ["Metraj", "Özet", "Kontrol", "Metodoloji"]
+ rows = list(book["Metraj"].iter_rows(values_only=True))
+ assert list(rows[0]) == METRAJ_HEADERS["tr"]
+ assert rows[-1][0] == "TOPLAM"
+ assert rows[-1][6] == pytest.approx(40.0)
+ assert isinstance(rows[1][6], (int, float)) # a number cell, not the text '18.0'
+ assert rows[1][6] == pytest.approx(18.0) # room 101's share of the Ø51 run
+
+
+def test_the_method_sheet_is_generated_from_the_result(result, tmp_path):
+ files = write_workbook(result, kind="pipe", lang="en", path=str(tmp_path / "t.xlsx"))
+ method = dict((row[0], row[1]) for row in read_csv(files["csv"][3])[1:] if len(row) == 2)
+ assert method["Length source"] == "layout"
+ assert method["Scale check: verdict"] == "schematic"
+ assert method["Scale check: pairs"] == "12"
+ assert method["Scale verified"] == "yes"
+ assert method["Units: layout"] == "INSUNITS in → mm"
+ assert "x first, then y" in method["Rule: route"]
+ warnings = [row[1] for row in read_csv(files["csv"][3]) if row[0] == "Warning"]
+ assert any("INSUNITS declares in" in w for w in warnings)
+
+
+def test_kontrol_lists_the_unassigned_diameter(result, tmp_path):
+ files = write_workbook(result, kind="pipe", lang="en", path=str(tmp_path / "t.xlsx"))
+ kontrol = read_csv(files["csv"][2])
+ assert kontrol[0] == ["Run", "Kind", "Service", "Tags", "Schematic (m)", "Reason"]
+ (row,) = kontrol[1:]
+ assert row[1] == "Diameter unassigned" and row[3] == "T4101, T4102"
+ assert row[5] == "No diameter label"
+
+
+def test_without_openpyxl_the_csv_is_written_and_xlsx_is_refused(result, tmp_path, monkeypatch):
+ monkeypatch.setattr(report, "xlsx_available", lambda: False)
+ files = write_workbook(result, kind="pipe", lang="tr", path=str(tmp_path / "takeoff.xlsx"))
+ assert files["xlsx"] is None
+ assert not (tmp_path / "takeoff.xlsx").exists()
+ assert all(Path(p).exists() for p in files["csv"])
+ assert files["refused"]["capability"] == "xlsx_write"
+ assert files["refused"]["ok"] is False
+ assert '".[office]"' in files["refused"]["error"]
+
+
+@pytest.mark.parametrize(
+ ("kwargs", "message"),
+ [
+ ({"kind": "steel"}, "kind: 'steel' unknown; choose from pipe"),
+ ({"lang": "de"}, "lang: 'de' unknown; choose from tr, en, ru"),
+ ({"path": "takeoff.ods"}, "path: the workbook is written as .xlsx"),
+ ],
+)
+def test_refusals_come_before_any_file(result, tmp_path, kwargs, message):
+ arguments = {"kind": "pipe", "lang": "en", "path": str(tmp_path / "t.xlsx"), **kwargs}
+ if "path" in kwargs:
+ arguments["path"] = str(tmp_path / kwargs["path"])
+ with pytest.raises(ValueError, match="^" + message.replace(".", r"\.")):
+ write_workbook(result, **arguments)
+ assert list(tmp_path.iterdir()) == []
diff --git a/tests/test_understand_route.py b/tests/test_understand_route.py
new file mode 100644
index 0000000..2aa823d
--- /dev/null
+++ b/tests/test_understand_route.py
@@ -0,0 +1,48 @@
+"""Orthogonal lengths: Manhattan distance, the rectilinear MST, the x-then-y route.
+
+Every expected value is worked by hand in the comment beside it.
+"""
+
+from __future__ import annotations
+
+from engineering.understand.route import manhattan, rmst, route_legs
+
+EPS = 1e-9
+
+
+def test_manhattan_is_the_sum_of_the_axis_distances():
+ assert manhattan((0.0, 0.0), (3.0, -4.0)) == 7.0 # 3 + 4, not the diagonal 5
+
+
+def test_fewer_than_two_points_have_no_tree():
+ assert rmst([]) == (0.0, ())
+ assert rmst([(5.0, 5.0)]) == (0.0, ())
+
+
+def test_a_two_ended_run_is_dx_plus_dy():
+ length, edges = rmst([(1000.0, 3000.0), (29000.0, 15000.0)])
+ assert abs(length - 40000.0) < EPS # 28000 + 12000
+ assert edges == ((0, 1),)
+
+
+def test_a_square_of_four_tags_is_three_sides():
+ # Prim from point 0: (0,1) at 10 (tie with 3 goes to the lower index),
+ # then (1,2) at 10, then (0,3) at 10 -> 30, never the 40 of the full ring
+ length, edges = rmst([(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)])
+ assert abs(length - 30.0) < EPS
+ assert edges == ((0, 1), (1, 2), (0, 3))
+
+
+def test_three_tags_in_a_row_chain_instead_of_a_star():
+ length, edges = rmst([(0.0, 0.0), (100.0, 0.0), (200.0, 0.0)])
+ assert abs(length - 200.0) < EPS # 100 + 100, not 100 + 200
+ assert edges == ((0, 1), (1, 2))
+
+
+def test_the_route_runs_along_x_first_then_y():
+ assert route_legs((0.0, 0.0), (30.0, 40.0)) == (
+ ((0.0, 0.0), (30.0, 0.0)),
+ ((30.0, 0.0), (30.0, 40.0)),
+ )
+ assert route_legs((0.0, 0.0), (0.0, 40.0)) == (((0.0, 0.0), (0.0, 40.0)),)
+ assert route_legs((1.0, 1.0), (1.0, 1.0)) == ()
diff --git a/tests/test_understand_takeoff.py b/tests/test_understand_takeoff.py
new file mode 100644
index 0000000..f332908
--- /dev/null
+++ b/tests/test_understand_takeoff.py
@@ -0,0 +1,423 @@
+"""Pipe takeoff rows: layout lengths, statuses, rooms, units and the scale rule.
+
+The hand-built pair: a P&ID (mm) with three tanks and two runs, and a layout
+whose INSUNITS says inches over millimetre geometry - two rooms split by a
+wall at x = 20000, the tags at T4100 (2000, 3000), T4101 (30000, 3000) and
+T4102 (30000, 15000). Every expected length is worked in the comment beside it.
+The last four tests read the synthetic plant pair of Task 2 and compare every
+run with the generator's own hand-computed rectilinear MST.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from engineering.understand.network import build_network
+from engineering.understand.snapshot import EntityRecord, Snapshot, read_snapshot
+from engineering.understand.takeoff import (
+ DEFAULT_SERVICES,
+ layout_tags,
+ pipe_rows,
+ scale_stand_in,
+ unit_of,
+)
+from engineering.understand.vocab import classify_layer, room_label
+from tests.fixtures.plant_pair import build_plant_pair
+
+EPS = 1e-9
+PIPE = "PRODUCT"
+WALLS = "WALLS"
+
+
+def rec(handle, kind, layer, points=(), **extra):
+ return EntityRecord(
+ handle=handle, type=kind, layer=layer, space="Model", points=tuple(points), **extra
+ )
+
+
+def text(handle, value, at, height, layer="TEXT"):
+ x, y = at
+ return rec(
+ handle,
+ "TEXT",
+ layer,
+ (at,),
+ text=value,
+ height=height,
+ bbox=(x, y, x + height * len(value) * 0.6, y + height),
+ )
+
+
+def snap(records, insunits, source):
+ layers = {r.layer: {"color": 7, "linetype": "Continuous"} for r in records}
+ return Snapshot(
+ source=source,
+ insunits=insunits,
+ extmin=None,
+ extmax=None,
+ layers=layers,
+ layouts=(),
+ records=tuple(records),
+ )
+
+
+def tank(handle, tag, x0, y0):
+ box = (x0, y0, x0 + 500.0, y0 + 500.0)
+ return [
+ rec(
+ handle,
+ "LWPOLYLINE",
+ "EQUIPMENT",
+ ((x0, y0), (x0 + 500.0, y0), (x0 + 500.0, y0 + 500.0), (x0, y0 + 500.0)),
+ closed=True,
+ bbox=box,
+ ),
+ text(handle + "T", tag, (x0 + 150.0, y0 + 225.0), 50.0),
+ ]
+
+
+def pid_records():
+ return [
+ *tank("A", "T4100", 0.0, 0.0),
+ *tank("B", "T4101", 3000.0, 0.0),
+ *tank("C", "T4102", 3000.0, 2000.0),
+ rec("L1", "LINE", PIPE, ((500.0, 250.0), (3000.0, 250.0))),
+ rec("L2", "LINE", PIPE, ((3250.0, 500.0), (3250.0, 2000.0))),
+ text("D1", "Ø51", (1500.0, 260.0), 50.0),
+ ]
+
+
+def layout_records(dx=0.0, extra=()):
+ def at(x, y):
+ return (x + dx, y)
+
+ return [
+ rec(
+ f"W{dx}",
+ "LWPOLYLINE",
+ WALLS,
+ (at(0, 0), at(40000, 0), at(40000, 20000), at(0, 20000)),
+ closed=True,
+ bbox=(dx, 0.0, dx + 40000.0, 20000.0),
+ ),
+ rec(f"X{dx}", "LINE", WALLS, (at(20000, 0), at(20000, 20000))),
+ text(f"R1{dx}", "ROOM 101", at(5000, 10000), 250.0),
+ text(f"R2{dx}", "ROOM 102", at(30000, 10000), 250.0),
+ text(f"T0{dx}", "T4100", at(2000, 3000), 250.0),
+ text(f"T1{dx}", "T4101", at(30000, 3000), 250.0),
+ text(f"T2{dx}", "T4102", at(30000, 15000), 250.0),
+ *extra,
+ ]
+
+
+def room_key(label):
+ found = room_label(label)
+ assert found is not None, f"the vocabulary must read {label!r} as a room label"
+ return str(found.get("number") or found.get("name"))
+
+
+@pytest.fixture
+def pair():
+ assert classify_layer(PIPE)["service"] == "product", "PRODUCT must be a product layer"
+ assert classify_layer(WALLS)["discipline"] == "architecture", "WALLS must be a wall layer"
+ pid = snap(pid_records(), 4, "pid.dxf")
+ layout = snap(layout_records(), 1, "layout.dxf") # INSUNITS 1 = inches, geometry in mm
+ return pid, layout, build_network(pid, layers=[PIPE])
+
+
+def run_by_tags(result, *tags):
+ (run,) = [r for r in result["runs"] if r["tags"] == list(tags)]
+ return run
+
+
+def test_layout_lengths_are_manhattan_between_the_tags(pair):
+ pid, layout, network = pair
+ result = pipe_rows(network, pid, layout, length_source="layout")
+ a = run_by_tags(result, "T4100", "T4101")
+ b = run_by_tags(result, "T4101", "T4102")
+ assert abs(a["layout_m"] - 28.0) < EPS # |30000 - 2000| + 0 mm
+ assert abs(b["layout_m"] - 12.0) < EPS # 0 + |15000 - 3000| mm
+ assert (a["status"], b["status"]) == ("A", "B") # b has no diameter label
+ assert result["length_source"] == "layout"
+
+
+def test_review_focus_5_inches_declared_over_millimetres(pair):
+ pid, layout, network = pair
+ result = pipe_rows(network, pid, layout, length_source="layout")
+ units = result["units"]["layout"]
+ assert (units["declared"], units["inferred"], units["m_per_unit"]) == ("in", "mm", 0.001)
+ a = run_by_tags(result, "T4100", "T4101")
+ assert abs(a["layout_m"] - 28.0) < EPS # not 28000 x 0.0254 = 711.2
+ assert any("INSUNITS declares in" in w and "as mm" in w for w in result["warnings"])
+ assert unit_of(pid)["warning"] is None # the P&ID's mm is what its geometry says
+
+
+def test_rooms_share_the_route_x_first(pair):
+ pid, layout, network = pair
+ result = pipe_rows(network, pid, layout, length_source="layout")
+ a = run_by_tags(result, "T4100", "T4101")
+ # (2000, 3000) -> (30000, 3000): 18000 mm left of the wall at x = 20000, 10000 right
+ assert a["rooms"] == pytest.approx(
+ {room_key("ROOM 101"): 18.0, room_key("ROOM 102"): 10.0}, abs=EPS
+ )
+ rows = {(r["room"], r["diameter"]): r for r in result["rows"]}
+ assert rows[(room_key("ROOM 101"), "Ø51")]["net_m"] == pytest.approx(18.0, abs=EPS)
+ assert rows[(room_key("ROOM 101"), "Ø51")]["direct_m"] == pytest.approx(18.0, abs=EPS)
+ assert rows[(room_key("ROOM 102"), "unassigned")]["unassigned_m"] == pytest.approx(
+ 12.0, abs=EPS
+ )
+
+
+def test_allowance_is_its_own_column_and_totals_add_up(pair):
+ pid, layout, network = pair
+ result = pipe_rows(network, pid, layout, length_source="layout", allowance=0.25)
+ for row in result["rows"]:
+ assert row["allowance"] == 0.25
+ assert row["allowance_m"] == pytest.approx(row["net_m"] * 0.25, abs=EPS)
+ assert row["total_m"] == pytest.approx(row["net_m"] * 1.25, abs=EPS)
+ totals = result["totals"]
+ assert totals["net_m"] == pytest.approx(40.0, abs=EPS) # 28 + 12
+ assert totals["total_m"] == pytest.approx(50.0, abs=EPS)
+ assert totals["flagged_runs"] == 0
+
+
+def test_vertical_allowance_is_added_per_connection(pair):
+ pid, layout, network = pair
+ result = pipe_rows(network, pid, layout, length_source="layout", vertical_allowance=0.5)
+ a = run_by_tags(result, "T4100", "T4101")
+ assert a["vertical_m"] == 0.5 # one tree edge
+ assert a["length_m"] == pytest.approx(28.5, abs=EPS)
+
+
+def test_the_schematic_length_is_kept_beside_the_answer(pair):
+ pid, layout, network = pair
+ result = pipe_rows(network, pid, layout, length_source="layout")
+ a = run_by_tags(result, "T4100", "T4101")
+ assert a["schematic_m"] == pytest.approx(2.5, abs=EPS) # the 2500 mm line on the P&ID
+
+
+def test_a_tag_missing_from_the_layout_makes_the_run_c(pair):
+ pid, _layout, network = pair
+ layout = snap([r for r in layout_records() if r.text != "T4102"], 1, "layout.dxf")
+ result = pipe_rows(network, pid, layout, length_source="layout")
+ b = run_by_tags(result, "T4101", "T4102")
+ assert (b["status"], b["reason"]) == ("C", "tag_not_on_layout")
+ (row,) = [c for c in result["control"] if c["kind"] == "unroutable"]
+ assert row["schematic_m"] == pytest.approx(1.5, abs=EPS) # the 1500 mm line, drawn length only
+ assert result["totals"]["unroutable_m"] == pytest.approx(1.5, abs=EPS)
+ assert result["totals"]["flagged_runs"] == 1
+
+
+def test_a_tag_written_twice_in_the_plan_is_ambiguous_not_picked(pair):
+ pid, _layout, network = pair
+ extra = [text("dup", "T4101", (35000.0, 18000.0), 250.0)]
+ layout = snap(layout_records(extra=extra), 1, "layout.dxf")
+ result = pipe_rows(network, pid, layout, length_source="layout")
+ assert {r["reason"] for r in result["runs"]} == {"tag_ambiguous"}
+
+
+def test_of_two_plan_copies_one_is_chosen_and_nothing_is_ambiguous(pair):
+ pid, _layout, network = pair
+ layout = snap(layout_records() + layout_records(dx=100000.0), 1, "layout.dxf")
+ found = layout_tags(layout, ["T4100", "T4101", "T4102"])
+ assert found["clusters"] == 2
+ assert found["cluster"][2] < 100000.0 # the left copy: ties go to the leftmost
+ assert found["ambiguous"] == []
+ result = pipe_rows(network, pid, layout, length_source="layout")
+ assert run_by_tags(result, "T4100", "T4101")["layout_m"] == pytest.approx(28.0, abs=EPS)
+
+
+def test_a_run_with_a_free_end_is_c(pair):
+ pid, layout, _network = pair
+ records = pid_records() + [rec("L3", "LINE", PIPE, ((0.0, 500.0), (0.0, 1500.0)))]
+ drawing = snap(records, 4, "pid.dxf")
+ result = pipe_rows(
+ build_network(drawing, layers=[PIPE]), drawing, layout, length_source="layout"
+ )
+ reasons = {r["reason"] for r in result["runs"] if r["status"] == "C"}
+ assert reasons == {"end_not_on_equipment"}
+
+
+def test_other_services_are_left_out_unless_asked(pair):
+ pid, layout, network = pair
+ assert DEFAULT_SERVICES == ("product", "cip_supply", "cip_return")
+ result = pipe_rows(network, pid, layout, length_source="layout", services=["steam"])
+ assert result["rows"] == []
+ assert result["method"]["excluded_services"] == {"product": 2}
+
+
+# -- the scale rule ----------------------------------------------------------
+
+SCHEMATIC = {
+ "verdict": "schematic",
+ "within_10pct": 0.25,
+ "pair_count": 12,
+ "median": 1.3,
+ "iqr": 0.8,
+ "factor": None,
+ "matched": [],
+ "ambiguous": [],
+}
+
+
+def test_auto_measures_a_schematic_pid_on_the_layout(pair):
+ pid, layout, network = pair
+ result = pipe_rows(network, pid, layout, scale=SCHEMATIC)
+ assert result["length_source"] == "layout"
+ assert result["method"]["reason"] == "auto_schematic"
+ assert result["scale_verified"] is True
+
+
+def test_pid_on_a_schematic_pid_is_refused_with_the_statistics(pair):
+ pid, layout, network = pair
+ with pytest.raises(ValueError) as excinfo:
+ pipe_rows(network, pid, layout, scale=SCHEMATIC, length_source="pid")
+ message = str(excinfo.value)
+ assert message.startswith("length_source='pid': the scale check calls this P&ID schematic")
+ assert "25% of 12 tag pairs" in message and "median ratio 1.3" in message
+ assert "force=True" in message
+
+
+def test_force_measures_on_the_pid_and_says_it_is_unverified(pair):
+ pid, layout, network = pair
+ result = pipe_rows(network, pid, layout, scale=SCHEMATIC, length_source="pid", force=True)
+ assert result["length_source"] == "pid"
+ assert result["method"]["reason"] == "forced"
+ assert result["scale_verified"] is False
+ assert run_by_tags(result, "T4100", "T4101")["length_m"] == pytest.approx(2.5, abs=EPS)
+ assert any(w.startswith("scale_verified: false") for w in result["warnings"])
+
+
+def test_a_to_scale_pid_is_measured_through_its_factor(pair):
+ pid, layout, network = pair
+ to_scale = dict(SCHEMATIC, verdict="to_scale", within_10pct=0.9, factor=0.1)
+ result = pipe_rows(network, pid, layout, scale=to_scale)
+ assert result["length_source"] == "pid"
+ # 2.5 m drawn on the P&ID / 0.1 P&ID mm per layout mm = 25 m
+ assert run_by_tags(result, "T4100", "T4101")["length_m"] == pytest.approx(25.0, abs=EPS)
+
+
+def test_without_a_layout_auto_measures_the_pid_unverified(pair):
+ pid, _layout, network = pair
+ result = pipe_rows(network, pid, None)
+ assert (result["length_source"], result["scale_verified"]) == ("pid", False)
+ assert result["method"]["reason"] == "no_layout"
+ with pytest.raises(ValueError, match="^length_source='layout' needs a layout drawing"):
+ pipe_rows(network, pid, None, length_source="layout")
+
+
+def test_the_stand_in_recognises_a_uniform_scale_and_a_rearranged_one():
+ places = {
+ "T4100": (2000.0, 3000.0),
+ "T4101": (30000.0, 3000.0),
+ "T4102": (30000.0, 15000.0),
+ "T4103": (8000.0, 17000.0),
+ }
+ layout = snap(
+ [text(t, t, p, 250.0) for t, p in places.items()]
+ + [rec("W", "LINE", WALLS, ((0.0, 0.0), (40000.0, 20000.0)))],
+ 4,
+ "l",
+ )
+ half = snap([text(t, t, (x * 0.5, y * 0.5), 50.0) for t, (x, y) in places.items()], 4, "p")
+ uniform = scale_stand_in(half, layout)
+ assert uniform["verdict"] == "to_scale"
+ assert uniform["factor"] == pytest.approx(0.5, abs=EPS) # P&ID mm per layout mm
+ assert uniform["pair_count"] == 6 # four tags, every pair at least 2 m apart
+ shuffled = {
+ "T4100": (30000.0, 15000.0),
+ "T4101": (2000.0, 3000.0),
+ "T4102": (8000.0, 17000.0),
+ "T4103": (30000.0, 3000.0),
+ }
+ other = snap([text(t, t, p, 50.0) for t, p in shuffled.items()], 4, "p")
+ assert scale_stand_in(other, layout)["verdict"] == "schematic"
+
+
+def test_the_stand_in_calls_coinciding_pid_tags_insufficient_not_to_scale():
+ # every P&ID tag at one point: each ratio is 0, the median 0 - a factor of 0
+ # would divide every length by zero, so the verdict is insufficient (the
+ # same rule as group U's scale_check: a median that is not positive)
+ places = {"T4100": (2000.0, 3000.0), "T4101": (30000.0, 3000.0), "T4102": (30000.0, 15000.0)}
+ layout = snap([text(t, t, p, 250.0) for t, p in places.items()], 4, "l")
+ pid = snap([text(t, t, (100.0, 100.0), 50.0) for t in places], 4, "p")
+ result = scale_stand_in(pid, layout)
+ assert result["pair_count"] == 3
+ assert (result["verdict"], result["factor"]) == ("insufficient", None)
+
+
+def test_an_insunits_code_this_reader_does_not_convert_is_named_not_called_none():
+ drawing = snap(layout_records(), 14, "layout.dxf") # 14 = decimetres
+ found = unit_of(drawing)
+ assert (found["declared"], found["inferred"]) == (None, "mm")
+ assert "INSUNITS declares code 14 (a unit this reader does not convert)" in found["warning"]
+ assert "no unit" not in found["warning"]
+
+
+@pytest.mark.parametrize(
+ ("kwargs", "message"),
+ [
+ ({"allowance": 1.5}, "allowance: must lie between 0 and 1"),
+ ({"allowance": -0.1}, "allowance: must lie between 0 and 1"),
+ ({"vertical_allowance": -1.0}, "vertical_allowance: must be zero or more metres"),
+ ({"length_source": "tape"}, "length_source: 'tape' unknown"),
+ ({"services": ["lava"]}, "services: lava unknown"),
+ ({"services": []}, "services: an empty list takes nothing off"),
+ ],
+)
+def test_refusals_name_the_option(pair, kwargs, message):
+ pid, layout, network = pair
+ with pytest.raises(ValueError, match="^" + message):
+ pipe_rows(network, pid, layout, **kwargs)
+
+
+# -- the synthetic plant pair (Task 2) -------------------------------------------
+
+
+@pytest.fixture(scope="module")
+def plant(tmp_path_factory):
+ truth = build_plant_pair(tmp_path_factory.mktemp("plant"))
+ pid, layout = read_snapshot(truth["pid"]), read_snapshot(truth["layout"])
+ return truth, pid, layout, build_network(pid)
+
+
+def truth_services(truth):
+ return sorted({run["service"] for run in truth["pipe_runs"]})
+
+
+def test_every_synthetic_layout_length_is_the_hand_computed_rmst(plant):
+ truth, pid, layout, network = plant
+ result = pipe_rows(network, pid, layout, services=truth_services(truth), length_source="layout")
+ runs = {(tuple(sorted(r["tags"])), r["service"]): r for r in result["runs"]}
+ for expected in truth["pipe_runs"]:
+ run = runs[(tuple(sorted(expected["tags"])), expected["service"])]
+ assert run["status"] == expected["status"], run["id"]
+ if expected["status"] != "C":
+ assert run["layout_m"] * 1000.0 == pytest.approx(expected["layout_length_mm"], abs=1e-6)
+ assert sum(run["rooms"].values()) == pytest.approx(run["length_m"], abs=1e-9)
+
+
+def test_auto_calls_the_synthetic_pid_schematic_and_measures_the_layout(plant):
+ truth, pid, layout, network = plant
+ result = pipe_rows(network, pid, layout, services=truth_services(truth))
+ assert result["scale"]["verdict"] == truth["scale_verdict"] == "schematic"
+ assert result["length_source"] == "layout"
+ with pytest.raises(ValueError, match="the scale check calls this P&ID schematic"):
+ pipe_rows(network, pid, layout, services=truth_services(truth), length_source="pid")
+
+
+def test_review_focus_5_the_synthetic_layout_is_read_in_millimetres(plant):
+ truth, pid, layout, network = plant
+ assert layout.insunits == truth["layout_insunits"] == 1
+ result = pipe_rows(network, pid, layout, services=truth_services(truth), length_source="layout")
+ assert result["units"]["layout"]["inferred"] == truth["layout_true_unit"] == "mm"
+ assert result["units"]["layout"]["m_per_unit"] == 0.001
+ assert any("INSUNITS declares in" in w for w in result["warnings"])
+
+
+def test_routed_lengths_land_in_the_synthetic_rooms(plant):
+ truth, pid, layout, network = plant
+ result = pipe_rows(network, pid, layout, services=truth_services(truth), length_source="layout")
+ known = {str(room["number"] or room["name"]) for room in truth["rooms"]} | {""}
+ for run in result["runs"]:
+ assert set(run["rooms"]) <= known, run["id"]
diff --git a/tests/test_understand_takeoff_tools.py b/tests/test_understand_takeoff_tools.py
new file mode 100644
index 0000000..db15d8e
--- /dev/null
+++ b/tests/test_understand_takeoff_tools.py
@@ -0,0 +1,142 @@
+"""pipe_takeoff and cable_takeoff over the MCP wire, on the synthetic plant pair - never on a
+client drawing."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+import pytest_asyncio
+from fastmcp import Client
+
+import server
+from tests.fixtures.plant_pair import build_plant_pair
+
+pytestmark = pytest.mark.asyncio
+
+CSV_NAMES = [
+ "takeoff_metraj.csv",
+ "takeoff_ozet.csv",
+ "takeoff_kontrol.csv",
+ "takeoff_metodoloji.csv",
+]
+
+
+@pytest_asyncio.fixture
+async def client(monkeypatch):
+ """In-memory client on the headless engine - never the operator's AutoCAD."""
+ monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
+ async with Client(server.mcp) as connected:
+ status = (await connected.call_tool("system_status", {})).structured_content or {}
+ assert status.get("backend") == "ezdxf", "these tests must never touch live AutoCAD"
+ yield connected
+
+
+@pytest.fixture
+def plant(tmp_path):
+ return build_plant_pair(tmp_path)
+
+
+async def test_pipe_takeoff_measures_on_the_layout_and_writes_the_workbook(client, plant, tmp_path):
+ output = tmp_path / "takeoff.xlsx"
+ arguments = {
+ "pid_path": plant["pid"],
+ "layout_path": plant["layout"],
+ "output": str(output),
+ "lang": "tr",
+ }
+ result = (await client.call_tool("pipe_takeoff", arguments)).structured_content
+ assert result["length_source"] == "layout"
+ assert result["scale"]["verdict"] == plant["scale_verdict"]
+ files = result["files"]
+ assert [Path(p).name for p in files["csv"]] == CSV_NAMES
+ assert all(Path(p).exists() for p in files["csv"])
+ if files["xlsx"] is None:
+ assert files["refused"]["capability"] == "xlsx_write"
+ else:
+ assert Path(files["xlsx"]) == output
+ assert output.exists()
+
+
+async def test_pid_lengths_on_the_schematic_pid_are_refused_without_force(client, plant):
+ arguments = {"pid_path": plant["pid"], "layout_path": plant["layout"], "length_source": "pid"}
+ result = await client.call_tool("pipe_takeoff", arguments, raise_on_error=False)
+ assert result.is_error is True
+ text = result.content[0].text
+ assert "the scale check calls this P&ID schematic" in text
+ assert "force=True" in text
+
+
+async def test_force_measures_on_the_pid_and_marks_it_unverified(client, plant):
+ arguments = {
+ "pid_path": plant["pid"],
+ "layout_path": plant["layout"],
+ "length_source": "pid",
+ "force": True,
+ }
+ result = (await client.call_tool("pipe_takeoff", arguments)).structured_content
+ assert (result["length_source"], result["scale_verified"]) == ("pid", False)
+ assert result["files"] is None # no output named, nothing written
+
+
+async def test_an_unknown_language_is_refused_before_anything_is_read(client, tmp_path):
+ arguments = {"pid_path": str(tmp_path / "never_read.dxf"), "lang": "de"}
+ result = await client.call_tool("pipe_takeoff", arguments, raise_on_error=False)
+ assert result.is_error is True
+ assert "lang: 'de' unknown; choose from tr, en, ru" in result.content[0].text
+
+
+async def test_the_drawings_are_never_modified(client, plant, tmp_path):
+ before = {key: Path(plant[key]).read_bytes() for key in ("pid", "layout")}
+ arguments = {
+ "pid_path": plant["pid"],
+ "layout_path": plant["layout"],
+ "output": str(tmp_path / "t.xlsx"),
+ }
+ await client.call_tool("pipe_takeoff", arguments)
+ assert {key: Path(plant[key]).read_bytes() for key in ("pid", "layout")} == before
+
+
+async def test_the_tool_says_it_writes_a_file_and_destroys_nothing(client):
+ tools = {tool.name: tool for tool in await client.list_tools()}
+ annotations = tools["pipe_takeoff"].annotations
+ assert annotations.readOnlyHint is False
+ assert annotations.destructiveHint is False
+
+
+async def test_cable_takeoff_gives_every_synthetic_load_its_rounded_cable(client, plant, tmp_path):
+ arguments = {
+ "pid_path": plant["pid"],
+ "layout_path": plant["layout"],
+ "output": str(tmp_path / "cables.xlsx"),
+ "lang": "ru",
+ }
+ result = (await client.call_tool("cable_takeoff", arguments)).structured_content
+ rows = {row["tag"]: row for row in result["rows"]}
+ for expected in plant["cable_rows"]:
+ assert rows[expected["tag"]]["cable_m"] == expected["roundup_m"], expected["tag"]
+ assert Path(result["files"]["csv"][0]).name == "cables_metraj.csv"
+
+
+async def test_cable_takeoff_without_a_layout_leaves_every_length_empty(client, plant):
+ result = (
+ await client.call_tool("cable_takeoff", {"pid_path": plant["pid"]})
+ ).structured_content
+ assert result["rows"], "the synthetic P&ID carries electrical loads"
+ assert all(row["cable_m"] is None for row in result["rows"])
+ no_layout = {item["tag"] for item in result["open_items"] if item["item"] == "no_layout"}
+ assert no_layout == {row["tag"] for row in result["rows"]}
+
+
+async def test_a_malformed_section_rule_is_refused_by_path(client, plant):
+ arguments = {"pid_path": plant["pid"], "section_rules": [{"max_kw": 5}]}
+ result = await client.call_tool("cable_takeoff", arguments, raise_on_error=False)
+ assert result.is_error is True
+ assert "section_rules[0].section: expected a non-empty text" in result.content[0].text
+
+
+async def test_the_cable_tool_says_it_writes_a_file_and_destroys_nothing(client):
+ tools = {tool.name: tool for tool in await client.list_tools()}
+ annotations = tools["cable_takeoff"].annotations
+ assert annotations.readOnlyHint is False
+ assert annotations.destructiveHint is False