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