feat(understand): the line-network engine - runs, services, diameters, equipment
build_network reads the segments of a foreign P&ID's piping and electrical layers into runs: ends merged within tol, a branch ending on a header split into a tee, a crossing without an end left unconnected, and a line drawn twice - or a line over a polyline - cut into identical pieces that are kept once, the doubled length reported as overlap_length. An INSERT holding two to four free ends is an inline fitting joined through; a reducer word in its block name (reducer, perekhod, reduksiyon, verloop, reduzier) stops diameter continuity. Each diameter label goes to the nearest piece and holds for its drawn entity (direct); unlabelled pieces take the nearest direct diameter along the run (continuity) or stay unassigned - two sizes equally near are never guessed. Labels stay as drawn. A supply or return word near a run overrides its layer's service for the whole run. Run ends attach to the tag whose labelled geometry holds them; a shape holding two tags (a room, a skid) is not equipment. Tests on hand-built drawings and on the synthetic plant pair. Beyond the plan: a CIP / glycol layer naming no direction (classify_layer files it `unknown` on purpose) now takes its direction from the word instead of staying unknown; is_reducer matches on vocab.fold and knows the German word equipment_kind already had; the fitting, equipment and attachment lookups go through grid indexes - measured 149 s -> 2.8 s on a 59,200-record synthetic drawing, results identical to the plan engine on it and on the plant pair. No ActiveX call is added (pure module). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
704a44b190
commit
1426c53fd3
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user