diff --git a/engineering/understand/network.py b/engineering/understand/network.py new file mode 100644 index 0000000..fe98db0 --- /dev/null +++ b/engineering/understand/network.py @@ -0,0 +1,867 @@ +"""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 those ends are + joined through it; 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. +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) and holds for every + piece of that drawn entity (*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) -> list[tuple[Pt, Pt, float, str, str]]: + 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 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 _PieceIndex: + """Grid of pieces for 'nearest piece within `search`' queries.""" + + def __init__(self, pieces: list[dict], points: list[Pt], search: float) -> None: + lengths = [p["length"] for p in pieces] or [search] + self.cell = max(search, statistics.median(lengths), _EPS) + self.search = search + self.cells: dict[tuple[int, int], list[int]] = {} + for k, piece in enumerate(pieces): + a, b = points[piece["u"]], points[piece["v"]] + x0 = math.floor((min(a[0], b[0]) - search) / self.cell) + x1 = math.floor((max(a[0], b[0]) + search) / self.cell) + y0 = math.floor((min(a[1], b[1]) - search) / self.cell) + y1 = math.floor((max(a[1], b[1]) + 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 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)) + 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) + + # 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} + + free = sorted(vertex for vertex, ks in at_vertex.items() if len(ks) == 1) + 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 + ] + free_grid = _PointGrid( + ((vertex, pts[vertex]) for vertex in free), + _typical_size([rec.bbox for rec in inserts], search), + ) + bridges: list[tuple[int, int, bool, str]] = [] + fittings, reducers, skipped = 0, 0, [] + for rec in inserts: + inside = free_grid.in_box(rec.bbox, tol) + if len(inside) < 2: + continue + if len(inside) > MAX_FITTING_ENDS: + skipped.append(rec.handle) + continue + reducer = is_reducer(rec.block) + fittings += 1 + reducers += int(reducer) + for vertex in inside[1:]: + bridges.append((inside[0], vertex, 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 that are not reducers + neighbours: dict[int, set[tuple[int, bool]]] = {k: set() for k in range(len(pieces))} + for ks in at_vertex.values(): + for k in ks: + neighbours[k].update((j, False) 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 + index = _PieceIndex(pieces, pts, search) + by_handle: dict[str, list[int]] = {} + for k, piece in enumerate(pieces): + for handle in piece["handles"]: + by_handle.setdefault(handle, []).append(k) + + 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 + + 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 + if len(tokens) > 1: + labels_skipped.append( + {"handle": rec.handle, "text": rec.text, "reason": "more than one diameter"} + ) + elif tokens: + for handle in pieces[k]["handles"]: + for j in by_handle[handle]: + 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/tests/test_understand_network.py b/tests/test_understand_network.py new file mode 100644 index 0000000..cc70d77 --- /dev/null +++ b/tests/test_understand_network.py @@ -0,0 +1,536 @@ +"""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_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