refactor(understand): one reading of units, plan copies, scale and rooms
Groups U and N were built side by side, so the takeoffs carried narrow stand-ins for what group U reads properly. On main they read through U: - describe.drawing_units is the one call every reader makes (kept records, labelled room faces as evidence); describe, scale_check and the takeoffs all use it. Without the room faces, a layout that declares inches over millimetres can tie three ways and fall to the header. - layout_tags takes its plan copies from describe.prepare, room_regions its rooms from describe.find_rooms, and pipe_rows its scale from scale_check, per-room groups included. scale_stand_in and the takeoff's own unit bands and cluster grid are gone. - test_understand_one_reading pins the agreement: one unit, one plan copy, one verdict and one set of rooms per drawing, across drawing_understand, drawing_scale_check and the two takeoffs. 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
c4759e696d
commit
8c1a6cb27c
@@ -39,6 +39,7 @@ from engineering.understand.vocab import classify_layer, room_label
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"describe",
|
"describe",
|
||||||
|
"drawing_units",
|
||||||
"find_rooms",
|
"find_rooms",
|
||||||
"prepare",
|
"prepare",
|
||||||
"tag_occurrences",
|
"tag_occurrences",
|
||||||
@@ -232,6 +233,25 @@ def find_rooms(records: Sequence[EntityRecord], *, tol: float, membership: dict)
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def drawing_units(snap: Snapshot, *, prep: dict | None = None, rooms: dict | None = None) -> dict:
|
||||||
|
"""`units.infer_units` the one way every reader calls it: on the records kept
|
||||||
|
after the outliers, with the labelled room faces as evidence.
|
||||||
|
|
||||||
|
`describe`, `scale.scale_check` and the takeoffs all read a drawing's unit
|
||||||
|
here, so no two tools report two units for one drawing. ``prep`` and
|
||||||
|
``rooms`` are a `prepare` / `find_rooms` result the caller already holds.
|
||||||
|
"""
|
||||||
|
prep = prep or prepare(snap)
|
||||||
|
if rooms is None:
|
||||||
|
tol = prep["diag"] * FACE_TOL_FRACTION if prep["diag"] > 0 else 1.0
|
||||||
|
rooms = find_rooms(prep["kept"], tol=tol, membership=prep["membership"])
|
||||||
|
return infer_units(
|
||||||
|
snap,
|
||||||
|
records=prep["kept"],
|
||||||
|
room_areas=[row["face"]["area"] for row, _face in rooms["labelled"]],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def text_language(text: str) -> str:
|
def text_language(text: str) -> str:
|
||||||
"""``cyrillic`` | ``turkish`` | ``latin`` | ``none`` by character class, in
|
"""``cyrillic`` | ``turkish`` | ``latin`` | ``none`` by character class, in
|
||||||
that precedence (a Russian label with a Latin tag in it is Cyrillic)."""
|
that precedence (a Russian label with a Latin tag in it is Cyrillic)."""
|
||||||
@@ -385,11 +405,7 @@ def describe(snap: Snapshot) -> dict:
|
|||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
tol = prep["diag"] * FACE_TOL_FRACTION if prep["diag"] > 0 else 1.0
|
tol = prep["diag"] * FACE_TOL_FRACTION if prep["diag"] > 0 else 1.0
|
||||||
rooms = find_rooms(prep["kept"], tol=tol, membership=prep["membership"])
|
rooms = find_rooms(prep["kept"], tol=tol, membership=prep["membership"])
|
||||||
units = infer_units(
|
units = drawing_units(snap, prep=prep, rooms=rooms)
|
||||||
snap,
|
|
||||||
records=prep["kept"],
|
|
||||||
room_areas=[row["face"]["area"] for row, _face in rooms["labelled"]],
|
|
||||||
)
|
|
||||||
if units["warning"]:
|
if units["warning"]:
|
||||||
warnings.append(units["warning"])
|
warnings.append(units["warning"])
|
||||||
extents = dict(prep["extents"])
|
extents = dict(prep["extents"])
|
||||||
|
|||||||
@@ -44,9 +44,14 @@ import statistics
|
|||||||
from itertools import combinations
|
from itertools import combinations
|
||||||
|
|
||||||
from engineering.arch.faces import face_containing
|
from engineering.arch.faces import face_containing
|
||||||
from engineering.understand.describe import FACE_TOL_FRACTION, find_rooms, prepare, tag_occurrences
|
from engineering.understand.describe import (
|
||||||
|
FACE_TOL_FRACTION,
|
||||||
|
drawing_units,
|
||||||
|
find_rooms,
|
||||||
|
prepare,
|
||||||
|
tag_occurrences,
|
||||||
|
)
|
||||||
from engineering.understand.snapshot import Snapshot
|
from engineering.understand.snapshot import Snapshot
|
||||||
from engineering.understand.units import infer_units
|
|
||||||
|
|
||||||
__all__ = ["MIN_TAGS", "SCHEMATIC_SHARE", "TO_SCALE_SHARE", "WITHIN", "match_tags", "scale_check"]
|
__all__ = ["MIN_TAGS", "SCHEMATIC_SHARE", "TO_SCALE_SHARE", "WITHIN", "match_tags", "scale_check"]
|
||||||
|
|
||||||
@@ -190,8 +195,7 @@ def scale_check(a: Snapshot, b: Snapshot, *, min_pair_mm=2000.0) -> dict:
|
|||||||
("a", a, side_a, None),
|
("a", a, side_a, None),
|
||||||
("b", b, side_b, rooms_b),
|
("b", b, side_b, rooms_b),
|
||||||
):
|
):
|
||||||
areas = [row["face"]["area"] for row, _f in rooms["labelled"]] if rooms else []
|
u = drawing_units(snap, prep=side, rooms=rooms)
|
||||||
u = infer_units(snap, records=side["kept"], room_areas=areas)
|
|
||||||
units[key] = {
|
units[key] = {
|
||||||
"unit": u["inferred"],
|
"unit": u["inferred"],
|
||||||
"mm_per_unit": u["mm_per_unit"],
|
"mm_per_unit": u["mm_per_unit"],
|
||||||
|
|||||||
@@ -14,12 +14,10 @@ ways, and the rows say which:
|
|||||||
no layout (every row then carries `scale_verified: false`), and on a
|
no layout (every row then carries `scale_verified: false`), and on a
|
||||||
schematic P&ID only with `force=True`.
|
schematic P&ID only with `force=True`.
|
||||||
|
|
||||||
This module also carries the takeoff's own stand-ins for three readers another
|
Units, plan copies, scale and rooms are read through group U's readers
|
||||||
branch of track H owns - the unit inference, the plan-copy clusters and the
|
(`describe.drawing_units`, `describe.prepare`, `scale.scale_check`,
|
||||||
scale check (group U's `units`, `clusters` and `scale`). They follow the
|
`describe.find_rooms`), so a takeoff sees exactly what `drawing_understand`
|
||||||
spec's rules (§5, §6) in the narrow form a takeoff needs; `pipe_rows` uses a
|
and `drawing_scale_check` report for the same drawing.
|
||||||
`scale` result it is given before its own, so the full scale check replaces
|
|
||||||
the stand-in wherever a caller passes it.
|
|
||||||
|
|
||||||
Every length leaves this module in metres, converted from the unit the
|
Every length leaves this module in metres, converted from the unit the
|
||||||
geometry implies - never blindly from `INSUNITS`. Nothing here touches a
|
geometry implies - never blindly from `INSUNITS`. Nothing here touches a
|
||||||
@@ -29,10 +27,10 @@ drawing.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
import statistics
|
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
|
|
||||||
from engineering.arch.faces import DEFAULT_TOL, Face, face_containing, planar_faces
|
from engineering.arch.faces import face_containing
|
||||||
|
from engineering.understand.describe import FACE_TOL_FRACTION, drawing_units, find_rooms, prepare
|
||||||
from engineering.understand.labels import normalize_panel, parse_electrical, wiring_target
|
from engineering.understand.labels import normalize_panel, parse_electrical, wiring_target
|
||||||
from engineering.understand.network import (
|
from engineering.understand.network import (
|
||||||
MODEL,
|
MODEL,
|
||||||
@@ -41,8 +39,9 @@ from engineering.understand.network import (
|
|||||||
tag_occurrences,
|
tag_occurrences,
|
||||||
)
|
)
|
||||||
from engineering.understand.route import manhattan, rmst, route_legs
|
from engineering.understand.route import manhattan, rmst, route_legs
|
||||||
|
from engineering.understand.scale import scale_check
|
||||||
from engineering.understand.snapshot import Pt, Snapshot
|
from engineering.understand.snapshot import Pt, Snapshot
|
||||||
from engineering.understand.vocab import SERVICES, classify_layer, room_label
|
from engineering.understand.vocab import SERVICES
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CABLE_SEARCH_SHARE",
|
"CABLE_SEARCH_SHARE",
|
||||||
@@ -59,7 +58,6 @@ __all__ = [
|
|||||||
"room_at",
|
"room_at",
|
||||||
"room_regions",
|
"room_regions",
|
||||||
"roundup_m",
|
"roundup_m",
|
||||||
"scale_stand_in",
|
|
||||||
"section_for",
|
"section_for",
|
||||||
"unit_of",
|
"unit_of",
|
||||||
]
|
]
|
||||||
@@ -69,33 +67,8 @@ DEFAULT_SERVICES = ("product", "cip_supply", "cip_return")
|
|||||||
LENGTH_SOURCES = ("auto", "pid", "layout")
|
LENGTH_SOURCES = ("auto", "pid", "layout")
|
||||||
UNASSIGNED = "unassigned"
|
UNASSIGNED = "unassigned"
|
||||||
|
|
||||||
#: INSUNITS codes this module reads, and metres per unit of each.
|
#: Metres per unit of each unit a takeoff converts.
|
||||||
INSUNITS_NAMES = {1: "in", 2: "ft", 4: "mm", 5: "cm", 6: "m"}
|
|
||||||
UNIT_METRES = {"mm": 0.001, "cm": 0.01, "m": 1.0, "in": 0.0254, "ft": 0.3048}
|
UNIT_METRES = {"mm": 0.001, "cm": 0.01, "m": 1.0, "in": 0.0254, "ft": 0.3048}
|
||||||
#: This module's heuristic, not a standard: the model-space text of any plant
|
|
||||||
#: drawing - a P&ID sheet at 1:1 or a plan at 1:200 - stands between 0.5 mm and
|
|
||||||
#: 1 m tall, and the drawing spans between 0.1 m and 5 km. A declared unit that
|
|
||||||
#: puts the geometry outside either band is not believed.
|
|
||||||
TEXT_BAND_M = (0.0005, 1.0)
|
|
||||||
SPAN_BAND_M = (0.1, 5000.0)
|
|
||||||
#: Among the units that fit both bands, the one nearest (in log) a 50 m plant
|
|
||||||
#: with 0.25 m text is chosen.
|
|
||||||
TYPICAL_SPAN_M = 50.0
|
|
||||||
TYPICAL_TEXT_M = 0.25
|
|
||||||
|
|
||||||
#: Plan copies are told apart when the gap between them exceeds 2 % of the
|
|
||||||
#: drawing's robust span (5th-95th percentile).
|
|
||||||
CLUSTER_GAP_SHARE = 0.02
|
|
||||||
#: Spec §6: pairs of matched tags at least 2 m apart, a ±10 % band, 80 % / 50 %.
|
|
||||||
MIN_PAIR_MM = 2000.0
|
|
||||||
SCALE_BAND = 0.10
|
|
||||||
TO_SCALE_SHARE = 0.80
|
|
||||||
SCHEMATIC_SHARE = 0.50
|
|
||||||
#: Fewer matched tags than this and the verdict is `insufficient`.
|
|
||||||
MIN_TAGS = 3
|
|
||||||
#: A wall layer is one `classify_layer` files under architecture this surely.
|
|
||||||
WALL_CONFIDENCE = 0.9
|
|
||||||
|
|
||||||
|
|
||||||
# -- options -----------------------------------------------------------------
|
# -- options -----------------------------------------------------------------
|
||||||
|
|
||||||
@@ -145,177 +118,45 @@ def check_pipe_options(*, services, allowance, vertical_allowance, length_source
|
|||||||
# -- units -------------------------------------------------------------------
|
# -- units -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _model_points(snap: Snapshot) -> list[Pt]:
|
def unit_of(snap: Snapshot, *, prep: dict | None = None) -> dict:
|
||||||
out: list[Pt] = []
|
"""The unit a drawing's geometry implies - `describe.drawing_units`, the reading
|
||||||
for rec in snap.records:
|
`drawing_understand` and `drawing_scale_check` give - in the shape a takeoff reads.
|
||||||
if rec.space != MODEL:
|
|
||||||
continue
|
|
||||||
out.extend(rec.points)
|
|
||||||
if rec.bbox is not None:
|
|
||||||
out.extend(((rec.bbox[0], rec.bbox[1]), (rec.bbox[2], rec.bbox[3])))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
``{"declared", "inferred", "m_per_unit", "warning", "evidence"}``: ``declared``
|
||||||
def _robust_box(points: list[Pt]) -> tuple[float, float, float, float] | None:
|
is the INSUNITS abbreviation (None when it names none of mm / cm / m / in /
|
||||||
"""The 5th-95th percentile box of the points: one stray entity does not stretch it."""
|
ft), ``evidence`` is `infer_units`' own list. When no measurement settles
|
||||||
if len(points) < 2:
|
the unit the declared one stands, else mm, and the warning says which; a
|
||||||
return None
|
takeoff never leaves a length in unknown units.
|
||||||
qx = statistics.quantiles([p[0] for p in points], n=20, method="inclusive")
|
|
||||||
qy = statistics.quantiles([p[1] for p in points], n=20, method="inclusive")
|
|
||||||
return (qx[0], qy[0], qx[-1], qy[-1])
|
|
||||||
|
|
||||||
|
|
||||||
def _text_heights(snap: Snapshot) -> list[float]:
|
|
||||||
return [
|
|
||||||
rec.height
|
|
||||||
for rec in snap.records
|
|
||||||
if rec.space == MODEL and rec.type in TEXT_TYPES and rec.height and rec.height > 0.0
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def unit_of(snap: Snapshot) -> dict:
|
|
||||||
"""The unit a drawing's geometry implies, against the one INSUNITS declares.
|
|
||||||
|
|
||||||
``{"declared", "inferred", "m_per_unit", "warning", "evidence"}``. The
|
|
||||||
declared unit (mm when INSUNITS declares none) stands when it puts the
|
|
||||||
median model-space text height between 0.5 mm and 1 m and the robust span
|
|
||||||
(5th-95th percentile) between 0.1 m and 5 km - this module's heuristic.
|
|
||||||
Otherwise the unit of mm / cm / m / in / ft that fits both bands and lies
|
|
||||||
nearest a 50 m drawing with 0.25 m text is taken. A disagreement is a
|
|
||||||
warning with the numbers - never a silent correction.
|
|
||||||
"""
|
"""
|
||||||
declared = INSUNITS_NAMES.get(snap.insunits)
|
found = drawing_units(snap, prep=prep)
|
||||||
box = _robust_box(_model_points(snap))
|
name = found["declared"]["name"]
|
||||||
span = max(box[2] - box[0], box[3] - box[1]) if box else 0.0
|
declared = name if name in UNIT_METRES else None
|
||||||
heights = _text_heights(snap)
|
inferred, warning = found["inferred"], found["warning"]
|
||||||
height = statistics.median(heights) if heights else 0.0
|
if inferred is None:
|
||||||
|
inferred = declared or "mm"
|
||||||
def fits(unit: str) -> bool:
|
|
||||||
m = UNIT_METRES[unit]
|
|
||||||
return (span <= 0.0 or SPAN_BAND_M[0] <= span * m <= SPAN_BAND_M[1]) and (
|
|
||||||
height <= 0.0 or TEXT_BAND_M[0] <= height * m <= TEXT_BAND_M[1]
|
|
||||||
)
|
|
||||||
|
|
||||||
def score(unit: str) -> float:
|
|
||||||
m = UNIT_METRES[unit]
|
|
||||||
total = abs(math.log(span * m / TYPICAL_SPAN_M)) if span > 0.0 else 0.0
|
|
||||||
return total + (abs(math.log(height * m / TYPICAL_TEXT_M)) if height > 0.0 else 0.0)
|
|
||||||
|
|
||||||
base = declared or "mm"
|
|
||||||
inferred = base
|
|
||||||
if not fits(base):
|
|
||||||
candidates = [unit for unit in UNIT_METRES if fits(unit)]
|
|
||||||
if candidates:
|
|
||||||
inferred = min(candidates, key=lambda unit: (score(unit), unit))
|
|
||||||
if declared is not None:
|
|
||||||
said = declared
|
|
||||||
elif snap.insunits:
|
|
||||||
said = f"code {snap.insunits} (a unit this reader does not convert)"
|
|
||||||
else:
|
|
||||||
said = "no unit"
|
|
||||||
warning = None
|
|
||||||
if inferred != declared:
|
|
||||||
warning = (
|
warning = (
|
||||||
f"{snap.source}: INSUNITS declares {said} but the geometry reads "
|
f"INSUNITS declares {found['declared']['label']} ({found['declared']['code']}) and "
|
||||||
f"as {inferred} (median text height {height:g}, robust span {span:g} drawing "
|
f"no measurement settles the unit; the takeoff reads it as {inferred}."
|
||||||
f"units); lengths are taken in {inferred}"
|
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"declared": declared,
|
"declared": declared,
|
||||||
"inferred": inferred,
|
"inferred": inferred,
|
||||||
"m_per_unit": UNIT_METRES[inferred],
|
"m_per_unit": UNIT_METRES[inferred],
|
||||||
"warning": warning,
|
"warning": f"{snap.source}: {warning}" if warning else None,
|
||||||
"evidence": {"robust_span": span, "median_text_height": height},
|
"evidence": found["evidence"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# -- plan copies and tag positions ---------------------------------------------
|
# -- plan copies and tag positions ---------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _mark_segment(cells: set, a: Pt, b: Pt, gap: float, limit: float) -> None:
|
def layout_tags(snap: Snapshot, wanted: Iterable[str] = (), *, prep: dict | None = None) -> dict:
|
||||||
length = math.dist(a, b)
|
|
||||||
if length > limit:
|
|
||||||
steps = 0 # a stray line across the world marks only its ends
|
|
||||||
else:
|
|
||||||
steps = int(length / (gap / 2.0))
|
|
||||||
for i in range(steps + 1):
|
|
||||||
t = i / steps if steps else 0.0
|
|
||||||
x, y = a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t
|
|
||||||
cells.add((math.floor(x / gap), math.floor(y / gap)))
|
|
||||||
cells.add((math.floor(b[0] / gap), math.floor(b[1] / gap)))
|
|
||||||
|
|
||||||
|
|
||||||
def _clusters(snap: Snapshot) -> tuple[float, list[tuple[float, float, float, float]]]:
|
|
||||||
"""(cell size, cluster boxes): separate bodies of model-space content.
|
|
||||||
|
|
||||||
Every record's geometry marks the cells of a grid whose cell is 2 % of the
|
|
||||||
robust span; 8-connected cells form bodies; bodies whose boxes overlap are
|
|
||||||
one cluster (a label in the middle of a room belongs to the plan around it).
|
|
||||||
"""
|
|
||||||
points = _model_points(snap)
|
|
||||||
box = _robust_box(points)
|
|
||||||
if box is None:
|
|
||||||
return 1.0, []
|
|
||||||
span = max(box[2] - box[0], box[3] - box[1], 1.0)
|
|
||||||
gap = CLUSTER_GAP_SHARE * span
|
|
||||||
limit = 10.0 * span
|
|
||||||
cells: set[tuple[int, int]] = set()
|
|
||||||
for rec in snap.records:
|
|
||||||
if rec.space != MODEL:
|
|
||||||
continue
|
|
||||||
pts = list(rec.points)
|
|
||||||
if rec.type in ("LINE", "LWPOLYLINE", "POLYLINE") and len(pts) >= 2:
|
|
||||||
ring = pts + ([pts[0]] if rec.closed else [])
|
|
||||||
for a, b in zip(ring, ring[1:], strict=False):
|
|
||||||
_mark_segment(cells, a, b, gap, limit)
|
|
||||||
elif rec.bbox is not None:
|
|
||||||
x0, y0, x1, y1 = rec.bbox
|
|
||||||
corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)]
|
|
||||||
for a, b in zip(corners, corners[1:], strict=False):
|
|
||||||
_mark_segment(cells, a, b, gap, limit)
|
|
||||||
for p in pts[:1]:
|
|
||||||
cells.add((math.floor(p[0] / gap), math.floor(p[1] / gap)))
|
|
||||||
seen: set[tuple[int, int]] = set()
|
|
||||||
bodies: list[list[float]] = []
|
|
||||||
for start in sorted(cells):
|
|
||||||
if start in seen:
|
|
||||||
continue
|
|
||||||
seen.add(start)
|
|
||||||
stack, body = [start], [start[0], start[1], start[0], start[1]]
|
|
||||||
while stack:
|
|
||||||
cx, cy = stack.pop()
|
|
||||||
body = [min(body[0], cx), min(body[1], cy), max(body[2], cx), max(body[3], cy)]
|
|
||||||
for dx in (-1, 0, 1):
|
|
||||||
for dy in (-1, 0, 1):
|
|
||||||
n = (cx + dx, cy + dy)
|
|
||||||
if n in cells and n not in seen:
|
|
||||||
seen.add(n)
|
|
||||||
stack.append(n)
|
|
||||||
bodies.append(body)
|
|
||||||
merged = True
|
|
||||||
while merged:
|
|
||||||
merged = False
|
|
||||||
for i in range(len(bodies)):
|
|
||||||
for j in range(i + 1, len(bodies)):
|
|
||||||
a, b = bodies[i], bodies[j]
|
|
||||||
if a[0] <= b[2] and b[0] <= a[2] and a[1] <= b[3] and b[1] <= a[3]:
|
|
||||||
bodies[i] = [min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3])]
|
|
||||||
del bodies[j]
|
|
||||||
merged = True
|
|
||||||
break
|
|
||||||
if merged:
|
|
||||||
break
|
|
||||||
boxes = [(b[0] * gap, b[1] * gap, (b[2] + 1) * gap, (b[3] + 1) * gap) for b in sorted(bodies)]
|
|
||||||
return gap, boxes
|
|
||||||
|
|
||||||
|
|
||||||
def layout_tags(snap: Snapshot, wanted: Iterable[str] = ()) -> dict:
|
|
||||||
"""Tag positions on a drawing, using only tags that are unique where they are read.
|
"""Tag positions on a drawing, using only tags that are unique where they are read.
|
||||||
|
|
||||||
When no tag is written twice the whole model space is read. Otherwise the
|
When no tag is written twice the whole model space is read. Otherwise the
|
||||||
drawing is split into clusters (plan copies, sheets, a far outlier) and the
|
drawing's clusters - `describe.prepare`'s, the plan copies, sheets and
|
||||||
cluster holding the most `wanted` tags is chosen (ties: the one furthest
|
outliers `drawing_understand` reports - are read, and the cluster holding
|
||||||
left, then lowest); a tag written more than once inside it is `ambiguous`,
|
the most `wanted` tags is chosen (ties: the one furthest left, then lowest); a tag written more than once inside it is `ambiguous`,
|
||||||
never picked arbitrarily. ``{"positions": {tag: Pt}, "ambiguous": [...],
|
never picked arbitrarily. ``{"positions": {tag: Pt}, "ambiguous": [...],
|
||||||
"cluster": box | None, "clusters": n}``.
|
"cluster": box | None, "clusters": n}``.
|
||||||
"""
|
"""
|
||||||
@@ -328,7 +169,8 @@ def layout_tags(snap: Snapshot, wanted: Iterable[str] = ()) -> dict:
|
|||||||
"cluster": None,
|
"cluster": None,
|
||||||
"clusters": 1,
|
"clusters": 1,
|
||||||
}
|
}
|
||||||
_gap, boxes = _clusters(snap)
|
prep = prep or prepare(snap)
|
||||||
|
boxes = [tuple(cluster["box"]) for cluster in prep["clusters"]]
|
||||||
|
|
||||||
def inside(p: Pt, box) -> bool:
|
def inside(p: Pt, box) -> bool:
|
||||||
return box[0] <= p[0] <= box[2] and box[1] <= p[1] <= box[3]
|
return box[0] <= p[0] <= box[2] and box[1] <= p[1] <= box[3]
|
||||||
@@ -357,140 +199,37 @@ def layout_tags(snap: Snapshot, wanted: Iterable[str] = ()) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def scale_stand_in(
|
|
||||||
pid: Snapshot, layout: Snapshot, *, pid_tags=None, layout_found=None, layout_unit=None
|
|
||||||
) -> dict:
|
|
||||||
"""Spec §6 on the tags unique in both drawings - the takeoff's stand-in for scale_check.
|
|
||||||
|
|
||||||
The result has `scale_check`'s shape (group U, Task 4): for every pair of
|
|
||||||
matched tags at least 2 m apart on the layout, ``ratio`` is their P&ID
|
|
||||||
distance over their layout distance, both in millimetres of each drawing's
|
|
||||||
inferred unit; ``median``, ``q1``, ``q3``, ``iqr`` and ``within_10pct`` (the
|
|
||||||
share within ±10 % of the median) describe them. Verdict ``to_scale`` at
|
|
||||||
>= 80 %, ``schematic`` at <= 50 %, ``partly`` between, ``insufficient``
|
|
||||||
with fewer than three matched tags or no pair; ``factor`` is the median
|
|
||||||
only when ``to_scale``; a median that is not positive (every P&ID tag at
|
|
||||||
one point) is ``insufficient``. There are no per-room ``groups`` here. Tag labels
|
|
||||||
are not equipment centres: the band and the 2 m floor absorb that.
|
|
||||||
"""
|
|
||||||
pid_occ = pid_tags if pid_tags is not None else tag_occurrences(pid)
|
|
||||||
pid_positions = {tag: places[0]["at"] for tag, places in pid_occ.items() if len(places) == 1}
|
|
||||||
found = layout_found if layout_found is not None else layout_tags(layout, pid_positions)
|
|
||||||
unit = layout_unit if layout_unit is not None else unit_of(layout)
|
|
||||||
mm_layout = unit["m_per_unit"] * 1000.0
|
|
||||||
mm_pid = unit_of(pid)["m_per_unit"] * 1000.0
|
|
||||||
tags = [tag for tag in sorted(pid_positions) if tag in found["positions"]]
|
|
||||||
matched = {tag: (pid_positions[tag], found["positions"][tag]) for tag in tags}
|
|
||||||
ambiguous = sorted(
|
|
||||||
{tag for tag, places in pid_occ.items() if len(places) > 1} | set(found["ambiguous"])
|
|
||||||
)
|
|
||||||
pairs = []
|
|
||||||
for i, a in enumerate(tags):
|
|
||||||
for b in tags[i + 1 :]:
|
|
||||||
d_layout = math.dist(matched[a][1], matched[b][1]) * mm_layout
|
|
||||||
if d_layout >= MIN_PAIR_MM:
|
|
||||||
d_pid = math.dist(matched[a][0], matched[b][0]) * mm_pid
|
|
||||||
pairs.append(
|
|
||||||
{"tags": [a, b], "a_mm": d_pid, "b_mm": d_layout, "ratio": d_pid / d_layout}
|
|
||||||
)
|
|
||||||
out = {
|
|
||||||
"verdict": "insufficient",
|
|
||||||
"factor": None,
|
|
||||||
"median": None,
|
|
||||||
"q1": None,
|
|
||||||
"q3": None,
|
|
||||||
"iqr": None,
|
|
||||||
"within_10pct": None,
|
|
||||||
"pair_count": len(pairs),
|
|
||||||
"pairs": pairs,
|
|
||||||
"matched": [
|
|
||||||
{"tag": tag, "a": list(matched[tag][0]), "b": list(matched[tag][1])} for tag in tags
|
|
||||||
],
|
|
||||||
"ambiguous": ambiguous,
|
|
||||||
"groups": [],
|
|
||||||
"notes": ["takeoff stand-in for scale_check (spec §6): no per-room groups"],
|
|
||||||
}
|
|
||||||
ratios = [pair["ratio"] for pair in pairs]
|
|
||||||
if len(tags) < MIN_TAGS or not ratios:
|
|
||||||
return out
|
|
||||||
median = statistics.median(ratios)
|
|
||||||
if median <= 0.0:
|
|
||||||
return out # every P&ID tag at one point: no factor to divide by (as scale_check)
|
|
||||||
q1, q3 = (
|
|
||||||
statistics.quantiles(ratios, n=4, method="inclusive")[::2]
|
|
||||||
if len(ratios) >= 2
|
|
||||||
else (ratios[0], ratios[0])
|
|
||||||
)
|
|
||||||
within = sum(1 for r in ratios if abs(r - median) <= SCALE_BAND * median) / len(ratios)
|
|
||||||
if within >= TO_SCALE_SHARE:
|
|
||||||
verdict = "to_scale"
|
|
||||||
elif within <= SCHEMATIC_SHARE:
|
|
||||||
verdict = "schematic"
|
|
||||||
else:
|
|
||||||
verdict = "partly"
|
|
||||||
out.update(
|
|
||||||
verdict=verdict,
|
|
||||||
factor=median if verdict == "to_scale" else None,
|
|
||||||
median=median,
|
|
||||||
q1=q1,
|
|
||||||
q3=q3,
|
|
||||||
iqr=q3 - q1,
|
|
||||||
within_10pct=within,
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
# -- rooms -------------------------------------------------------------------
|
# -- rooms -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def room_regions(snap: Snapshot, cluster=None) -> dict:
|
def room_regions(snap: Snapshot, cluster=None, *, prep: dict | None = None) -> dict:
|
||||||
"""Room polygons and room labels of a drawing.
|
"""Room faces and labels - `describe.find_rooms`, the rooms `drawing_understand`
|
||||||
|
reports - inside ``cluster`` (a box) when one is given.
|
||||||
|
|
||||||
Labels are texts `room_label` reads; polygons are the faces of the wall
|
``{"faces": [(key, Face)], "labels": [(key, Pt)]}``; the key is the room
|
||||||
layers (`classify_layer` architecture, confidence >= 0.9, straight
|
number, else its name. A record belongs to the cluster when every point
|
||||||
segments) that hold a label, found by track F's planar-face finder. Inside
|
it has lies in the box.
|
||||||
`cluster` only, when one is given. ``{"faces": [(key, Face)], "labels":
|
|
||||||
[(key, Pt)]}``; the key is the room number, else its name.
|
|
||||||
"""
|
"""
|
||||||
|
prep = prep or prepare(snap)
|
||||||
|
|
||||||
def keep(p: Pt) -> bool:
|
def keep(rec) -> bool:
|
||||||
return cluster is None or (
|
return cluster is None or all(
|
||||||
cluster[0] <= p[0] <= cluster[2] and cluster[1] <= p[1] <= cluster[3]
|
cluster[0] <= p[0] <= cluster[2] and cluster[1] <= p[1] <= cluster[3]
|
||||||
|
for p in rec.points
|
||||||
)
|
)
|
||||||
|
|
||||||
labels = []
|
tol = prep["diag"] * FACE_TOL_FRACTION if prep["diag"] > 0 else 1.0
|
||||||
for rec in snap.records:
|
found = find_rooms(
|
||||||
if rec.space != MODEL or rec.type not in TEXT_TYPES or not rec.text or not rec.points:
|
[rec for rec in prep["kept"] if keep(rec)], tol=tol, membership=prep["membership"]
|
||||||
continue
|
)
|
||||||
found = room_label(rec.text)
|
|
||||||
if found and keep(rec.points[0]):
|
def key(row: dict) -> str:
|
||||||
key = found.get("number") or found.get("name") or rec.text
|
return str(row["number"] or row["name"] or row["text"])
|
||||||
labels.append((str(key), rec.points[0]))
|
|
||||||
walls = set()
|
return {
|
||||||
for layer in {rec.layer for rec in snap.records}: # classify each layer once
|
"faces": [(key(row), face) for row, face in found["labelled"]],
|
||||||
info = classify_layer(layer)
|
"labels": sorted((key(row), (row["at"][0], row["at"][1])) for row in found["rooms"]),
|
||||||
if info["discipline"] == "architecture" and info["confidence"] >= WALL_CONFIDENCE:
|
}
|
||||||
walls.add(layer)
|
|
||||||
segments = []
|
|
||||||
for rec in snap.records:
|
|
||||||
if rec.space != MODEL or rec.layer not in walls:
|
|
||||||
continue
|
|
||||||
if rec.type not in ("LINE", "LWPOLYLINE", "POLYLINE") or len(rec.points) < 2:
|
|
||||||
continue
|
|
||||||
pts = list(rec.points) + ([rec.points[0]] if rec.closed else [])
|
|
||||||
bulges = list(rec.bulges) + [0.0] * len(pts)
|
|
||||||
for i, (a, b) in enumerate(zip(pts, pts[1:], strict=False)):
|
|
||||||
if bulges[i] == 0.0 and keep(a) and keep(b):
|
|
||||||
segments.append((a, b))
|
|
||||||
faces = planar_faces(segments, tol=DEFAULT_TOL) if segments else ()
|
|
||||||
rooms: list[tuple[str, Face]] = []
|
|
||||||
taken: set[int] = set()
|
|
||||||
for key, at in sorted(labels):
|
|
||||||
face = face_containing(faces, at)
|
|
||||||
if face is not None and id(face) not in taken:
|
|
||||||
taken.add(id(face))
|
|
||||||
rooms.append((key, face))
|
|
||||||
return {"faces": rooms, "labels": sorted(labels)}
|
|
||||||
|
|
||||||
|
|
||||||
def _rooms_mode(regions: dict) -> str:
|
def _rooms_mode(regions: dict) -> str:
|
||||||
@@ -597,9 +336,8 @@ def pipe_rows(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Takeoff rows by room x service x diameter, with the runs, checks and method behind them.
|
"""Takeoff rows by room x service x diameter, with the runs, checks and method behind them.
|
||||||
|
|
||||||
`network` is `build_network(pid)`'s result. `scale` is a scale-check
|
`network` is `build_network(pid)`'s result. `scale` is a `scale_check`
|
||||||
result (`scale_check`'s shape); omitted, the stand-in runs when a layout
|
result; omitted, `scale_check(pid, layout)` runs when a layout is given. Refused by name before anything is measured: an unknown service
|
||||||
is given. Refused by name before anything is measured: an unknown service
|
|
||||||
or length source, an allowance outside 0-1, a negative vertical
|
or length source, an allowance outside 0-1, a negative vertical
|
||||||
allowance, `length_source='layout'` without a layout, and
|
allowance, `length_source='layout'` without a layout, and
|
||||||
`length_source='pid'` on a P&ID the scale check calls schematic unless
|
`length_source='pid'` on a P&ID the scale check calls schematic unless
|
||||||
@@ -621,13 +359,15 @@ def pipe_rows(
|
|||||||
)
|
)
|
||||||
allowance = options["allowance"]
|
allowance = options["allowance"]
|
||||||
vertical = options["vertical_allowance"]
|
vertical = options["vertical_allowance"]
|
||||||
pid_unit = unit_of(pid)
|
prep_pid = prepare(pid)
|
||||||
layout_unit = unit_of(layout) if layout is not None else None
|
prep_layout = prepare(layout) if layout is not None else None
|
||||||
|
pid_unit = unit_of(pid, prep=prep_pid)
|
||||||
|
layout_unit = unit_of(layout, prep=prep_layout) if layout is not None else None
|
||||||
wanted = {tag for run in network["runs"] for tag in run.tags}
|
wanted = {tag for run in network["runs"] for tag in run.tags}
|
||||||
found = layout_tags(layout, wanted) if layout is not None else None
|
found = layout_tags(layout, wanted, prep=prep_layout) if layout is not None else None
|
||||||
if scale is None:
|
if scale is None:
|
||||||
scale = (
|
scale = (
|
||||||
scale_stand_in(pid, layout, layout_found=found, layout_unit=layout_unit)
|
scale_check(pid, layout)
|
||||||
if layout is not None
|
if layout is not None
|
||||||
else {"verdict": "not_checked", "pair_count": 0, "matched": [], "ambiguous": []}
|
else {"verdict": "not_checked", "pair_count": 0, "matched": [], "ambiguous": []}
|
||||||
)
|
)
|
||||||
@@ -636,9 +376,9 @@ def pipe_rows(
|
|||||||
positions = found["positions"] if found else {}
|
positions = found["positions"] if found else {}
|
||||||
ambiguous = set(found["ambiguous"]) if found else set()
|
ambiguous = set(found["ambiguous"]) if found else set()
|
||||||
regions = (
|
regions = (
|
||||||
room_regions(layout, found["cluster"])
|
room_regions(layout, found["cluster"], prep=prep_layout)
|
||||||
if found is not None and source == "layout"
|
if found is not None and source == "layout"
|
||||||
else room_regions(pid)
|
else room_regions(pid, prep=prep_pid)
|
||||||
)
|
)
|
||||||
m_pid = pid_unit["m_per_unit"]
|
m_pid = pid_unit["m_per_unit"]
|
||||||
m_layout = layout_unit["m_per_unit"] if layout_unit else None
|
m_layout = layout_unit["m_per_unit"] if layout_unit else None
|
||||||
@@ -980,11 +720,12 @@ def cable_rows(
|
|||||||
positions: dict[str, Pt] = {}
|
positions: dict[str, Pt] = {}
|
||||||
ambiguous: set[str] = set()
|
ambiguous: set[str] = set()
|
||||||
panels: dict[str, tuple[str, str]] = {}
|
panels: dict[str, tuple[str, str]] = {}
|
||||||
layout_unit = unit_of(layout) if layout is not None else None
|
prep_layout = prepare(layout) if layout is not None else None
|
||||||
|
layout_unit = unit_of(layout, prep=prep_layout) if layout is not None else None
|
||||||
cluster = None
|
cluster = None
|
||||||
layout_search = None
|
layout_search = None
|
||||||
if layout is not None:
|
if layout is not None:
|
||||||
placed = layout_tags(layout, set(loads) | set(pid_callouts))
|
placed = layout_tags(layout, set(loads) | set(pid_callouts), prep=prep_layout)
|
||||||
positions, ambiguous, cluster = (
|
positions, ambiguous, cluster = (
|
||||||
placed["positions"],
|
placed["positions"],
|
||||||
set(placed["ambiguous"]),
|
set(placed["ambiguous"]),
|
||||||
@@ -1008,7 +749,9 @@ def cable_rows(
|
|||||||
name = normalize_panel(tag)
|
name = normalize_panel(tag)
|
||||||
if name is not None:
|
if name is not None:
|
||||||
panel_at[name] = p
|
panel_at[name] = p
|
||||||
regions = room_regions(layout, cluster) if layout is not None else room_regions(pid)
|
regions = (
|
||||||
|
room_regions(layout, cluster, prep=prep_layout) if layout is not None else room_regions(pid)
|
||||||
|
)
|
||||||
|
|
||||||
rows, items = [], []
|
rows, items = [], []
|
||||||
for tag in sorted(set(loads) | set(panels)):
|
for tag in sorted(set(loads) | set(panels)):
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""One reading of a drawing: the takeoffs see what drawing_understand and drawing_scale_check see.
|
||||||
|
|
||||||
|
Groups U and N were built side by side from one base, so Task 6 carried narrow
|
||||||
|
stand-ins: its own unit bands, a 2 % grid of plan copies, a scale statistic
|
||||||
|
without room groups, its own room faces. On main the takeoffs read units, plan
|
||||||
|
copies, scale and rooms through group U's readers, so the four tools never
|
||||||
|
give one drawing two units, two plan copies, two verdicts or two sets of rooms.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from engineering.understand import takeoff
|
||||||
|
from engineering.understand.describe import describe, drawing_units, prepare
|
||||||
|
from engineering.understand.network import build_network
|
||||||
|
from engineering.understand.scale import scale_check
|
||||||
|
from engineering.understand.snapshot import Snapshot, read_snapshot
|
||||||
|
from tests.fixtures.plant_pair import build_plant_pair
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def plant(tmp_path_factory):
|
||||||
|
truth = build_plant_pair(tmp_path_factory.mktemp("one_reading"))
|
||||||
|
return truth, read_snapshot(truth["pid"]), read_snapshot(truth["layout"])
|
||||||
|
|
||||||
|
|
||||||
|
def empty(insunits):
|
||||||
|
return Snapshot(
|
||||||
|
source="empty.dxf",
|
||||||
|
insunits=insunits,
|
||||||
|
extmin=None,
|
||||||
|
extmax=None,
|
||||||
|
layers={},
|
||||||
|
layouts=(),
|
||||||
|
records=(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_unit_per_drawing(plant):
|
||||||
|
_truth, pid, layout = plant
|
||||||
|
check = scale_check(pid, layout)
|
||||||
|
for snap, side in ((pid, "a"), (layout, "b")):
|
||||||
|
unit = takeoff.unit_of(snap)
|
||||||
|
assert unit["inferred"] == describe(snap)["units"]["inferred"]
|
||||||
|
assert unit["inferred"] == check["units"][side]["unit"]
|
||||||
|
assert takeoff.unit_of(layout)["inferred"] == "mm" # INSUNITS says inches: Review Focus 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_takeoff_unit_carries_drawing_units_evidence_and_warning(plant):
|
||||||
|
_truth, pid, layout = plant
|
||||||
|
for snap in (pid, layout):
|
||||||
|
mine, theirs = takeoff.unit_of(snap), drawing_units(snap)
|
||||||
|
assert mine["declared"] == theirs["declared"]["name"]
|
||||||
|
assert mine["m_per_unit"] == pytest.approx(theirs["mm_per_unit"] / 1000.0)
|
||||||
|
assert mine["evidence"] == theirs["evidence"]
|
||||||
|
expected = f"{snap.source}: {theirs['warning']}" if theirs["warning"] else None
|
||||||
|
assert mine["warning"] == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_unit_nothing_measures_keeps_the_declared_one():
|
||||||
|
unit = takeoff.unit_of(empty(5))
|
||||||
|
assert (unit["declared"], unit["inferred"], unit["m_per_unit"], unit["warning"]) == (
|
||||||
|
"cm",
|
||||||
|
"cm",
|
||||||
|
0.01,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_unitless_drawing_nothing_measures_is_read_in_mm_and_says_so():
|
||||||
|
unit = takeoff.unit_of(empty(0))
|
||||||
|
assert (unit["declared"], unit["inferred"], unit["m_per_unit"]) == (None, "mm", 0.001)
|
||||||
|
assert unit["warning"] == (
|
||||||
|
"empty.dxf: INSUNITS declares Unitless (0) and no measurement settles the unit; "
|
||||||
|
"the takeoff reads it as mm."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_copies_are_the_clusters_drawing_understand_reports(plant):
|
||||||
|
truth, _pid, layout = plant
|
||||||
|
found = takeoff.layout_tags(layout, truth["tag_positions"]["layout"])
|
||||||
|
boxes = [tuple(cluster["box"]) for cluster in prepare(layout)["clusters"]]
|
||||||
|
assert found["clusters"] == len(boxes) == truth["cluster_count"]
|
||||||
|
assert tuple(found["cluster"]) in boxes
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_takeoff_scale_is_drawing_scale_check(plant):
|
||||||
|
_truth, pid, layout = plant
|
||||||
|
result = takeoff.pipe_rows(build_network(pid), pid, layout)
|
||||||
|
check = scale_check(pid, layout)
|
||||||
|
for key in ("verdict", "factor", "median", "within_10pct", "pair_count", "groups"):
|
||||||
|
assert result["scale"][key] == check[key], key
|
||||||
|
|
||||||
|
|
||||||
|
def test_rooms_are_the_rooms_drawing_understand_reports(plant):
|
||||||
|
truth, _pid, layout = plant
|
||||||
|
cluster = takeoff.layout_tags(layout, truth["tag_positions"]["layout"])["cluster"]
|
||||||
|
regions = takeoff.room_regions(layout, cluster)
|
||||||
|
assert sorted(key for key, _face in regions["faces"]) == sorted(
|
||||||
|
str(room["number"]) for room in truth["rooms"]
|
||||||
|
)
|
||||||
|
reported = {
|
||||||
|
(str(row["number"] or row["name"]), round(row["face"]["area"], 3))
|
||||||
|
for row in describe(layout)["rooms"]
|
||||||
|
if row["face"] is not None
|
||||||
|
}
|
||||||
|
for key, face in regions["faces"]:
|
||||||
|
assert (key, round(face.area, 3)) in reported
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_second_reader_is_left():
|
||||||
|
for name in (
|
||||||
|
"scale_stand_in",
|
||||||
|
"INSUNITS_NAMES",
|
||||||
|
"TEXT_BAND_M",
|
||||||
|
"SPAN_BAND_M",
|
||||||
|
"TYPICAL_SPAN_M",
|
||||||
|
"TYPICAL_TEXT_M",
|
||||||
|
"CLUSTER_GAP_SHARE",
|
||||||
|
"MIN_PAIR_MM",
|
||||||
|
"MIN_TAGS",
|
||||||
|
"SCALE_BAND",
|
||||||
|
"TO_SCALE_SHARE",
|
||||||
|
"SCHEMATIC_SHARE",
|
||||||
|
"_model_points",
|
||||||
|
"_robust_box",
|
||||||
|
"_text_heights",
|
||||||
|
"_mark_segment",
|
||||||
|
"_clusters",
|
||||||
|
):
|
||||||
|
assert not hasattr(takeoff, name), name
|
||||||
@@ -213,7 +213,7 @@ def test_the_method_sheet_is_generated_from_the_result(result, tmp_path):
|
|||||||
assert method["Units: layout"] == "INSUNITS in → mm"
|
assert method["Units: layout"] == "INSUNITS in → mm"
|
||||||
assert "x first, then y" in method["Rule: route"]
|
assert "x first, then y" in method["Rule: route"]
|
||||||
warnings = [row[1] for row in read_csv(files["csv"][3]) if row[0] == "Warning"]
|
warnings = [row[1] for row in read_csv(files["csv"][3]) if row[0] == "Warning"]
|
||||||
assert any("INSUNITS declares in" in w for w in warnings)
|
assert any("INSUNITS declares Inches (1)" in w for w in warnings)
|
||||||
|
|
||||||
|
|
||||||
def test_kontrol_lists_the_unassigned_diameter(result, tmp_path):
|
def test_kontrol_lists_the_unassigned_diameter(result, tmp_path):
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ from __future__ import annotations
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from engineering.understand.network import build_network
|
from engineering.understand.network import build_network
|
||||||
|
from engineering.understand.scale import scale_check
|
||||||
from engineering.understand.snapshot import EntityRecord, Snapshot, read_snapshot
|
from engineering.understand.snapshot import EntityRecord, Snapshot, read_snapshot
|
||||||
from engineering.understand.takeoff import (
|
from engineering.understand.takeoff import (
|
||||||
DEFAULT_SERVICES,
|
DEFAULT_SERVICES,
|
||||||
layout_tags,
|
layout_tags,
|
||||||
pipe_rows,
|
pipe_rows,
|
||||||
scale_stand_in,
|
|
||||||
unit_of,
|
unit_of,
|
||||||
)
|
)
|
||||||
from engineering.understand.vocab import classify_layer, room_label
|
from engineering.understand.vocab import classify_layer, room_label
|
||||||
@@ -148,7 +148,9 @@ def test_review_focus_5_inches_declared_over_millimetres(pair):
|
|||||||
assert (units["declared"], units["inferred"], units["m_per_unit"]) == ("in", "mm", 0.001)
|
assert (units["declared"], units["inferred"], units["m_per_unit"]) == ("in", "mm", 0.001)
|
||||||
a = run_by_tags(result, "T4100", "T4101")
|
a = run_by_tags(result, "T4100", "T4101")
|
||||||
assert abs(a["layout_m"] - 28.0) < EPS # not 28000 x 0.0254 = 711.2
|
assert abs(a["layout_m"] - 28.0) < EPS # not 28000 x 0.0254 = 711.2
|
||||||
assert any("INSUNITS declares in" in w and "as mm" in w for w in result["warnings"])
|
assert any(
|
||||||
|
"INSUNITS declares Inches (1)" in w and "reads as mm" in w for w in result["warnings"]
|
||||||
|
)
|
||||||
assert unit_of(pid)["warning"] is None # the P&ID's mm is what its geometry says
|
assert unit_of(pid)["warning"] is None # the P&ID's mm is what its geometry says
|
||||||
|
|
||||||
|
|
||||||
@@ -306,7 +308,7 @@ def test_without_a_layout_auto_measures_the_pid_unverified(pair):
|
|||||||
pipe_rows(network, pid, None, length_source="layout")
|
pipe_rows(network, pid, None, length_source="layout")
|
||||||
|
|
||||||
|
|
||||||
def test_the_stand_in_recognises_a_uniform_scale_and_a_rearranged_one():
|
def test_scale_check_recognises_a_uniform_scale_and_a_rearranged_one():
|
||||||
places = {
|
places = {
|
||||||
"T4100": (2000.0, 3000.0),
|
"T4100": (2000.0, 3000.0),
|
||||||
"T4101": (30000.0, 3000.0),
|
"T4101": (30000.0, 3000.0),
|
||||||
@@ -320,7 +322,7 @@ def test_the_stand_in_recognises_a_uniform_scale_and_a_rearranged_one():
|
|||||||
"l",
|
"l",
|
||||||
)
|
)
|
||||||
half = snap([text(t, t, (x * 0.5, y * 0.5), 50.0) for t, (x, y) in places.items()], 4, "p")
|
half = snap([text(t, t, (x * 0.5, y * 0.5), 50.0) for t, (x, y) in places.items()], 4, "p")
|
||||||
uniform = scale_stand_in(half, layout)
|
uniform = scale_check(half, layout)
|
||||||
assert uniform["verdict"] == "to_scale"
|
assert uniform["verdict"] == "to_scale"
|
||||||
assert uniform["factor"] == pytest.approx(0.5, abs=EPS) # P&ID mm per layout mm
|
assert uniform["factor"] == pytest.approx(0.5, abs=EPS) # P&ID mm per layout mm
|
||||||
assert uniform["pair_count"] == 6 # four tags, every pair at least 2 m apart
|
assert uniform["pair_count"] == 6 # four tags, every pair at least 2 m apart
|
||||||
@@ -331,17 +333,17 @@ def test_the_stand_in_recognises_a_uniform_scale_and_a_rearranged_one():
|
|||||||
"T4103": (30000.0, 3000.0),
|
"T4103": (30000.0, 3000.0),
|
||||||
}
|
}
|
||||||
other = snap([text(t, t, p, 50.0) for t, p in shuffled.items()], 4, "p")
|
other = snap([text(t, t, p, 50.0) for t, p in shuffled.items()], 4, "p")
|
||||||
assert scale_stand_in(other, layout)["verdict"] == "schematic"
|
assert scale_check(other, layout)["verdict"] == "schematic"
|
||||||
|
|
||||||
|
|
||||||
def test_the_stand_in_calls_coinciding_pid_tags_insufficient_not_to_scale():
|
def test_scale_check_calls_coinciding_pid_tags_insufficient_not_to_scale():
|
||||||
# every P&ID tag at one point: each ratio is 0, the median 0 - a factor of 0
|
# every P&ID tag at one point: each ratio is 0, the median 0 - a factor of 0
|
||||||
# would divide every length by zero, so the verdict is insufficient (the
|
# would divide every length by zero, so the verdict is insufficient (the
|
||||||
# same rule as group U's scale_check: a median that is not positive)
|
# same rule as group U's scale_check: a median that is not positive)
|
||||||
places = {"T4100": (2000.0, 3000.0), "T4101": (30000.0, 3000.0), "T4102": (30000.0, 15000.0)}
|
places = {"T4100": (2000.0, 3000.0), "T4101": (30000.0, 3000.0), "T4102": (30000.0, 15000.0)}
|
||||||
layout = snap([text(t, t, p, 250.0) for t, p in places.items()], 4, "l")
|
layout = snap([text(t, t, p, 250.0) for t, p in places.items()], 4, "l")
|
||||||
pid = snap([text(t, t, (100.0, 100.0), 50.0) for t in places], 4, "p")
|
pid = snap([text(t, t, (100.0, 100.0), 50.0) for t in places], 4, "p")
|
||||||
result = scale_stand_in(pid, layout)
|
result = scale_check(pid, layout)
|
||||||
assert result["pair_count"] == 3
|
assert result["pair_count"] == 3
|
||||||
assert (result["verdict"], result["factor"]) == ("insufficient", None)
|
assert (result["verdict"], result["factor"]) == ("insufficient", None)
|
||||||
|
|
||||||
@@ -350,7 +352,7 @@ def test_an_insunits_code_this_reader_does_not_convert_is_named_not_called_none(
|
|||||||
drawing = snap(layout_records(), 14, "layout.dxf") # 14 = decimetres
|
drawing = snap(layout_records(), 14, "layout.dxf") # 14 = decimetres
|
||||||
found = unit_of(drawing)
|
found = unit_of(drawing)
|
||||||
assert (found["declared"], found["inferred"]) == (None, "mm")
|
assert (found["declared"], found["inferred"]) == (None, "mm")
|
||||||
assert "INSUNITS declares code 14 (a unit this reader does not convert)" in found["warning"]
|
assert "INSUNITS declares Decimeters (14) but the geometry reads as mm" in found["warning"]
|
||||||
assert "no unit" not in found["warning"]
|
assert "no unit" not in found["warning"]
|
||||||
|
|
||||||
|
|
||||||
@@ -412,7 +414,7 @@ def test_review_focus_5_the_synthetic_layout_is_read_in_millimetres(plant):
|
|||||||
result = pipe_rows(network, pid, layout, services=truth_services(truth), length_source="layout")
|
result = pipe_rows(network, pid, layout, services=truth_services(truth), length_source="layout")
|
||||||
assert result["units"]["layout"]["inferred"] == truth["layout_true_unit"] == "mm"
|
assert result["units"]["layout"]["inferred"] == truth["layout_true_unit"] == "mm"
|
||||||
assert result["units"]["layout"]["m_per_unit"] == 0.001
|
assert result["units"]["layout"]["m_per_unit"] == 0.001
|
||||||
assert any("INSUNITS declares in" in w for w in result["warnings"])
|
assert any("INSUNITS declares Inches (1)" in w for w in result["warnings"])
|
||||||
|
|
||||||
|
|
||||||
def test_routed_lengths_land_in_the_synthetic_rooms(plant):
|
def test_routed_lengths_land_in_the_synthetic_rooms(plant):
|
||||||
|
|||||||
Reference in New Issue
Block a user