Equipment tags found exactly once in each drawing are matched (a tag found twice - a plan copy, a legend - is reported ambiguous and takes no part; two plan copies resolve to the cluster that matches the most tags). For every pair of matched tags at least min_pair_mm apart on the reference, the ratio of their distances is taken in millimetres by each drawing's inferred unit; the median, the quartiles, the share within +/-10 % of the median, the same per room of the reference, and the verdict: to_scale (>= 80 %, with its factor), schematic (<= 50 %), partly, or insufficient under three matched tags. server.py grows SECTION 26 to two tools with drawing_scale_check (core, read-only, tagged analysis/query); alias record, inventory, README release snapshot 243, frozen group sizes (analysis 14). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
273 lines
11 KiB
Python
273 lines
11 KiB
Python
"""drawing_scale_check: is this drawing to scale against that one?
|
|
|
|
A P&ID and a layout of one plant carry the same equipment tags. If the P&ID
|
|
were drawn to scale, the distance between two tanks on it would be the distance
|
|
on the layout times one factor, for every pair. On a schematic it is not - and
|
|
a pipe length measured on a schematic is a length of drawn line, not of pipe.
|
|
|
|
The check, in the order it runs:
|
|
|
|
1. **Scope.** Model-space tags are read (`describe.tag_occurrences`) and the
|
|
drawings split into clusters (`clusters.find_clusters`, outliers left out).
|
|
Every pair of scopes - the whole drawing or one of its eight largest
|
|
clusters, on each side - is tried, and the pair that matches the most tags
|
|
wins (ties: the whole drawing first, then the larger cluster). Two copies
|
|
of one plan side by side therefore resolve to one copy instead of making
|
|
every tag ambiguous.
|
|
2. **Match.** Only a tag found exactly once in each scope is matched. A tag
|
|
found more than once in either (a plan copy, a legend, a second sheet) is
|
|
``ambiguous`` and takes no part: picking one of its positions would be a
|
|
guess.
|
|
3. **Pairs.** For every two matched tags whose distance on the reference
|
|
drawing ``b`` is at least ``min_pair_mm``, the ratio of their distance on
|
|
``a`` to that on ``b`` is taken, both converted to millimetres by each
|
|
drawing's inferred unit (`units.infer_units`).
|
|
4. **Statistics.** The median ratio, the quartiles and their range, and the
|
|
share of pairs within +/-10 % of the median - overall, and per room of the
|
|
reference drawing (the labelled faces `describe.find_rooms` finds) or, for
|
|
tags in no room, per cluster.
|
|
5. **Verdict.** ``to_scale`` when at least 80 % of the pairs are within the
|
|
band (``factor`` is then the median: a drawing at a different but uniform
|
|
scale is recognised); ``schematic`` at 50 % or less; ``partly`` in between;
|
|
``insufficient`` with fewer than three matched tags or no pair above the
|
|
floor.
|
|
|
|
A tag label is not an equipment centre: the band and the floor are there to
|
|
absorb the offset between the two, and the result says so. Pure; neither
|
|
drawing is touched.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import statistics
|
|
from itertools import combinations
|
|
|
|
from engineering.arch.faces import face_containing
|
|
from engineering.understand.describe import FACE_TOL_FRACTION, find_rooms, prepare, tag_occurrences
|
|
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"]
|
|
|
|
#: The band around the median a pair must fall in to count as consistent.
|
|
WITHIN = 0.10
|
|
TO_SCALE_SHARE = 0.80
|
|
SCHEMATIC_SHARE = 0.50
|
|
MIN_TAGS = 3
|
|
#: Clusters per drawing tried as a scope, largest first.
|
|
MAX_SCOPE_CLUSTERS = 8
|
|
#: Pair rows listed in the result, farthest from the median first.
|
|
PAIR_ROW_CAP = 200
|
|
NOTE = (
|
|
"Tag labels are not equipment centres; the +/-10 % band and the min_pair_mm floor absorb "
|
|
"the offset between a label and what it labels."
|
|
)
|
|
|
|
|
|
def _side(snap: Snapshot) -> dict:
|
|
prep = prepare(snap)
|
|
prep["tags"] = [o for o in tag_occurrences(snap, prep=prep) if o["space"] == "Model"]
|
|
return prep
|
|
|
|
|
|
def _scoped(side: dict, cluster: str | None, name: str) -> list[dict]:
|
|
if cluster is None:
|
|
return side["tags"]
|
|
ids = [c["id"] for c in side["clusters"]]
|
|
if cluster not in ids:
|
|
raise ValueError(f"{name}: no cluster {cluster!r}; the drawing has {ids}")
|
|
return [o for o in side["tags"] if o["cluster"] == cluster]
|
|
|
|
|
|
def _match(side_a: dict, side_b: dict, cluster_a: str | None, cluster_b: str | None) -> dict:
|
|
occ_a = _scoped(side_a, cluster_a, "cluster_a")
|
|
occ_b = _scoped(side_b, cluster_b, "cluster_b")
|
|
count_a: dict[str, list[dict]] = {}
|
|
count_b: dict[str, list[dict]] = {}
|
|
for o in occ_a:
|
|
count_a.setdefault(o["tag"], []).append(o)
|
|
for o in occ_b:
|
|
count_b.setdefault(o["tag"], []).append(o)
|
|
matched = {
|
|
tag: (tuple(count_a[tag][0]["at"]), tuple(count_b[tag][0]["at"]))
|
|
for tag in sorted(set(count_a) & set(count_b))
|
|
if len(count_a[tag]) == 1 and len(count_b[tag]) == 1
|
|
}
|
|
ambiguous = sorted(
|
|
tag
|
|
for tag in set(count_a) | set(count_b)
|
|
if len(count_a.get(tag, ())) > 1 or len(count_b.get(tag, ())) > 1
|
|
)
|
|
return {
|
|
"matched": matched,
|
|
"ambiguous": ambiguous,
|
|
"occurrences": {
|
|
tag: {"a": len(count_a.get(tag, ())), "b": len(count_b.get(tag, ()))}
|
|
for tag in ambiguous
|
|
},
|
|
"only_a": sorted(t for t in count_a if t not in count_b and len(count_a[t]) == 1),
|
|
"only_b": sorted(t for t in count_b if t not in count_a and len(count_b[t]) == 1),
|
|
"cluster_a": cluster_a,
|
|
"cluster_b": cluster_b,
|
|
}
|
|
|
|
|
|
def match_tags(a: Snapshot, b: Snapshot, *, cluster_a=None, cluster_b=None) -> dict:
|
|
"""``{"matched": {tag: (pa, pb)}, "ambiguous": [tag, ...], ...}`` within the
|
|
given scopes (a cluster id such as ``"C1"``, or None for the whole model
|
|
space). ``pa`` / ``pb`` are the tag's positions in drawing units. Also
|
|
``occurrences`` (per ambiguous tag, how often each side has it),
|
|
``only_a`` / ``only_b`` and the scopes used. An unknown cluster id is
|
|
refused with the ids the drawing has."""
|
|
return _match(_side(a), _side(b), cluster_a, cluster_b)
|
|
|
|
|
|
def _stats(ratios: list[float]) -> dict:
|
|
if not ratios:
|
|
return {"median": None, "q1": None, "q3": None, "iqr": None, "within_10pct": None}
|
|
median = statistics.median(ratios)
|
|
if len(ratios) >= 2:
|
|
q1, _q2, q3 = statistics.quantiles(ratios, n=4, method="inclusive")
|
|
else:
|
|
q1 = q3 = ratios[0]
|
|
within = sum(1 for r in ratios if abs(r - median) <= WITHIN * median) / len(ratios)
|
|
return {"median": median, "q1": q1, "q3": q3, "iqr": q3 - q1, "within_10pct": within}
|
|
|
|
|
|
def _verdict(tag_count: int, pair_count: int, stats: dict) -> str:
|
|
if tag_count < MIN_TAGS or pair_count == 0 or not stats["median"] or stats["median"] <= 0:
|
|
return "insufficient"
|
|
if stats["within_10pct"] >= TO_SCALE_SHARE:
|
|
return "to_scale"
|
|
if stats["within_10pct"] <= SCHEMATIC_SHARE:
|
|
return "schematic"
|
|
return "partly"
|
|
|
|
|
|
def _summary(tags: list[str], rows: list[dict]) -> dict:
|
|
stats = _stats([r["ratio"] for r in rows])
|
|
verdict = _verdict(len(tags), len(rows), stats)
|
|
return {
|
|
"verdict": verdict,
|
|
"factor": stats["median"] if verdict == "to_scale" else None,
|
|
**stats,
|
|
"pair_count": len(rows),
|
|
}
|
|
|
|
|
|
def scale_check(a: Snapshot, b: Snapshot, *, min_pair_mm=2000.0) -> dict:
|
|
"""Is drawing ``a`` to scale against the reference drawing ``b``?
|
|
|
|
Returns ``{"verdict", "factor", "median", "q1", "q3", "iqr", "within_10pct",
|
|
"pair_count", "pairs", "pairs_truncated", "pairs_below_floor", "matched",
|
|
"ambiguous", "occurrences", "only_a", "only_b", "groups", "scope", "units",
|
|
"min_pair_mm", "thresholds", "notes"}``. ``pairs`` lists at most 200 rows,
|
|
farthest from the median first; ``matched`` is ``[{"tag", "a": [x, y],
|
|
"b": [x, y]}]`` in drawing units. Refused before anything is computed: a
|
|
``min_pair_mm`` that is not a positive finite number.
|
|
"""
|
|
try:
|
|
floor = float(min_pair_mm)
|
|
except (TypeError, ValueError):
|
|
raise ValueError(f"min_pair_mm: expected a number, got {min_pair_mm!r}") from None
|
|
if not math.isfinite(floor) or floor <= 0.0:
|
|
raise ValueError(f"min_pair_mm: must be a positive number of millimetres, got {floor:g}")
|
|
side_a, side_b = _side(a), _side(b)
|
|
|
|
best = None
|
|
for ca in [None] + [c["id"] for c in side_a["clusters"][:MAX_SCOPE_CLUSTERS]]:
|
|
for cb in [None] + [c["id"] for c in side_b["clusters"][:MAX_SCOPE_CLUSTERS]]:
|
|
m = _match(side_a, side_b, ca, cb)
|
|
if best is None or len(m["matched"]) > len(best["matched"]):
|
|
best = m
|
|
|
|
tol_b = side_b["diag"] * FACE_TOL_FRACTION if side_b["diag"] > 0 else 1.0
|
|
rooms_b = find_rooms(side_b["kept"], tol=tol_b, membership=side_b["membership"])
|
|
units = {}
|
|
notes = [NOTE]
|
|
for key, snap, side, rooms in (
|
|
("a", a, side_a, None),
|
|
("b", b, side_b, rooms_b),
|
|
):
|
|
areas = [row["face"]["area"] for row, _f in rooms["labelled"]] if rooms else []
|
|
u = infer_units(snap, records=side["kept"], room_areas=areas)
|
|
units[key] = {
|
|
"unit": u["inferred"],
|
|
"mm_per_unit": u["mm_per_unit"],
|
|
"declared": u["declared"],
|
|
}
|
|
if u["mm_per_unit"] is None:
|
|
notes.append(
|
|
f"drawing {key}: no unit could be settled; its drawing units are taken as mm"
|
|
)
|
|
fa = units["a"]["mm_per_unit"] or 1.0
|
|
fb = units["b"]["mm_per_unit"] or 1.0
|
|
|
|
matched = best["matched"]
|
|
tags = sorted(matched)
|
|
rows: list[dict] = []
|
|
below = 0
|
|
for ti, tj in combinations(tags, 2):
|
|
d_b = math.dist(matched[ti][1], matched[tj][1]) * fb
|
|
if d_b < floor:
|
|
below += 1
|
|
continue
|
|
d_a = math.dist(matched[ti][0], matched[tj][0]) * fa
|
|
rows.append({"tags": [ti, tj], "a_mm": d_a, "b_mm": d_b, "ratio": d_a / d_b})
|
|
overall = _summary(tags, rows)
|
|
|
|
faces = [face for _row, face in rooms_b["labelled"]]
|
|
room_of = {id(face): row for row, face in rooms_b["labelled"]}
|
|
groups: dict[str, dict] = {}
|
|
for tag in tags:
|
|
pb = matched[tag][1]
|
|
face = face_containing(faces, pb) if faces else None
|
|
if face is not None:
|
|
row = room_of[id(face)]
|
|
key = " ".join(p for p in ("room", row["number"], row["name"]) if p)
|
|
kind = "room"
|
|
else:
|
|
cluster = next(
|
|
(o["cluster"] for o in side_b["tags"] if o["tag"] == tag and tuple(o["at"]) == pb),
|
|
None,
|
|
)
|
|
key, kind = (cluster or "unclustered"), "cluster"
|
|
groups.setdefault(key, {"group": key, "kind": kind, "tags": []})["tags"].append(tag)
|
|
group_rows = []
|
|
for key in sorted(groups):
|
|
g = groups[key]
|
|
if len(g["tags"]) < 2:
|
|
continue
|
|
members = set(g["tags"])
|
|
inside = [r for r in rows if r["tags"][0] in members and r["tags"][1] in members]
|
|
group_rows.append({**g, **_summary(g["tags"], inside)})
|
|
|
|
median = overall["median"]
|
|
listed = sorted(
|
|
rows,
|
|
key=lambda r: (-(abs(r["ratio"] - median) if median else 0.0), r["tags"]),
|
|
)[:PAIR_ROW_CAP]
|
|
return {
|
|
**overall,
|
|
"pairs": listed,
|
|
"pairs_truncated": len(rows) > PAIR_ROW_CAP,
|
|
"pairs_below_floor": below,
|
|
"matched": [{"tag": t, "a": list(matched[t][0]), "b": list(matched[t][1])} for t in tags],
|
|
"ambiguous": best["ambiguous"],
|
|
"occurrences": best["occurrences"],
|
|
"only_a": best["only_a"],
|
|
"only_b": best["only_b"],
|
|
"groups": group_rows,
|
|
"scope": {"cluster_a": best["cluster_a"], "cluster_b": best["cluster_b"]},
|
|
"units": units,
|
|
"min_pair_mm": floor,
|
|
"thresholds": {
|
|
"within": WITHIN,
|
|
"to_scale_share": TO_SCALE_SHARE,
|
|
"schematic_share": SCHEMATIC_SHARE,
|
|
"min_tags": MIN_TAGS,
|
|
},
|
|
"notes": notes,
|
|
}
|