The takeoffs were run again on a real P&ID pair (kept outside the repository)
and compared with the hand-made workbooks. The user chose how three gaps
should close:
- Rooms drawn as frames. A room label that no wall face holds takes the
smallest closed, straight-sided polyline around it, provided it holds no
other room number and is at least ten label heights a side (label+frame,
0.8). A P&ID draws its rooms this way, so P&ID lengths are now cut at the
frames rather than sent to the nearest label. An unnumbered mention inside
a frame ('hot room') does not deny it.
- The nearest panel, flagged. cable_takeoff(panel_rule="nearest") is the
default: a load that no callout places takes the nearest panel on the
layout, among names with a panel prefix (is_panel_name: CP, JB, DB,
MCC ...) or names a callout gives. The row is flagged panel_inferred, and
an inferred panel never makes a package. panel_rule="stated" keeps the old
behaviour. The takeoff_roundtrip gate and the cable correctness check read
stated panels only: a guess is no evidence.
- Possible packages and detail copies. pipe_takeoff reports detail_copies:
compact groups of tags drawn twice (repeated), and compact groups of one
equipment's own parts, such as M150B, M150C and M150D of M150 (family). A
vendor package or an equipment detail shows up this way. Nothing is
removed until exclude_regions names a region. The runs left out go to the
control rows as excluded_region and to excluded_m. The workbook's method
sheet names the regions.
Each change has a synthetic test that failed first. Suite: 4986 passed, 1
xfailed. The inventory and the document numbers match (README: 4987
collected).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
1208 lines
49 KiB
Python
1208 lines
49 KiB
Python
"""Pipe takeoff rows: topology from the P&ID, lengths from the layout (spec §8).
|
|
|
|
A P&ID says which equipment a line joins, what it carries and how big it is;
|
|
it does not say how long the pipe is unless the scale check calls it to scale.
|
|
So each run found by `engineering.understand.network` is measured one of two
|
|
ways, and the rows say which:
|
|
|
|
* **layout** - the positions of the tags the run joins, on the layout, joined
|
|
by the rectilinear minimum spanning tree (`route.rmst`), plus
|
|
`vertical_allowance` metres per tree edge. The P&ID's drawn length is kept
|
|
beside it as `schematic_m`, a comparison column, never the answer.
|
|
* **pid** - the drawn length on the P&ID: divided by the scale factor when the
|
|
scale check calls it `to_scale`, taken in the P&ID's own unit when there is
|
|
no layout (every row then carries `scale_verified: false`), and on a
|
|
schematic P&ID only with `force=True`.
|
|
|
|
Units, plan copies, scale and rooms are read through group U's readers
|
|
(`describe.drawing_units`, `describe.prepare`, `scale.scale_check`,
|
|
`describe.find_rooms`), so a takeoff sees exactly what `drawing_understand`
|
|
and `drawing_scale_check` report for the same drawing.
|
|
|
|
Every length leaves this module in metres, converted from the unit the
|
|
geometry implies - never blindly from `INSUNITS`. Nothing here touches a
|
|
drawing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import re
|
|
from collections.abc import Iterable
|
|
|
|
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 (
|
|
is_panel_name,
|
|
normalize_panel,
|
|
parse_electrical,
|
|
wiring_target,
|
|
)
|
|
from engineering.understand.network import (
|
|
MODEL,
|
|
TEXT_TYPES,
|
|
label_search_default,
|
|
tag_occurrences,
|
|
)
|
|
from engineering.understand.route import manhattan, rmst, route_legs
|
|
from engineering.understand.scale import scale_check
|
|
from engineering.understand.snapshot import Pt, Snapshot
|
|
from engineering.understand.vocab import SERVICES
|
|
|
|
__all__ = [
|
|
"CABLE_SEARCH_SHARE",
|
|
"DEFAULT_SERVICES",
|
|
"LENGTH_SOURCES",
|
|
"UNIT_METRES",
|
|
"apportion",
|
|
"cable_rows",
|
|
"check_allowance",
|
|
"check_pipe_options",
|
|
"check_section_rules",
|
|
"layout_tags",
|
|
"pipe_rows",
|
|
"room_at",
|
|
"room_regions",
|
|
"roundup_m",
|
|
"section_for",
|
|
"unit_of",
|
|
]
|
|
|
|
#: The user's own takeoff scope; utilities only on request (spec §8).
|
|
DEFAULT_SERVICES = ("product", "cip_supply", "cip_return")
|
|
LENGTH_SOURCES = ("auto", "pid", "layout")
|
|
UNASSIGNED = "unassigned"
|
|
|
|
#: Metres per unit of each unit a takeoff converts.
|
|
UNIT_METRES = {"mm": 0.001, "cm": 0.01, "m": 1.0, "in": 0.0254, "ft": 0.3048}
|
|
|
|
# -- options -----------------------------------------------------------------
|
|
|
|
|
|
def _number(value, name: str) -> float:
|
|
try:
|
|
number = float(value)
|
|
except (TypeError, ValueError):
|
|
raise ValueError(f"{name}: expected a number, got {value!r}") from None
|
|
if not math.isfinite(number):
|
|
raise ValueError(f"{name}: must be finite, got {value!r}")
|
|
return number
|
|
|
|
|
|
def check_allowance(allowance) -> float:
|
|
number = _number(allowance, "allowance")
|
|
if not 0.0 <= number <= 1.0:
|
|
raise ValueError(f"allowance: must lie between 0 and 1 (0.20 is 20 %), got {number:g}")
|
|
return number
|
|
|
|
|
|
def check_pipe_options(*, services, allowance, vertical_allowance, length_source) -> dict:
|
|
"""Refuse bad takeoff options by name, before a drawing is read."""
|
|
chosen = DEFAULT_SERVICES if services is None else tuple(dict.fromkeys(services))
|
|
if not chosen:
|
|
raise ValueError("services: an empty list takes nothing off; omit it for the default")
|
|
unknown = [s for s in chosen if s not in SERVICES]
|
|
if unknown:
|
|
raise ValueError(
|
|
f"services: {', '.join(map(str, unknown))} unknown; choose from {', '.join(SERVICES)}"
|
|
)
|
|
vertical = _number(vertical_allowance, "vertical_allowance")
|
|
if vertical < 0.0:
|
|
raise ValueError(f"vertical_allowance: must be zero or more metres, got {vertical:g}")
|
|
if length_source not in LENGTH_SOURCES:
|
|
raise ValueError(
|
|
f"length_source: {length_source!r} unknown; choose from {', '.join(LENGTH_SOURCES)}"
|
|
)
|
|
return {
|
|
"services": chosen,
|
|
"allowance": check_allowance(allowance),
|
|
"vertical_allowance": vertical,
|
|
"length_source": length_source,
|
|
}
|
|
|
|
|
|
# -- units -------------------------------------------------------------------
|
|
|
|
|
|
def unit_of(snap: Snapshot, *, prep: dict | None = None) -> dict:
|
|
"""The unit a drawing's geometry implies - `describe.drawing_units`, the reading
|
|
`drawing_understand` and `drawing_scale_check` give - in the shape a takeoff reads.
|
|
|
|
``{"declared", "inferred", "m_per_unit", "warning", "evidence"}``: ``declared``
|
|
is the INSUNITS abbreviation (None when it names none of mm / cm / m / in /
|
|
ft), ``evidence`` is `infer_units`' own list. When no measurement settles
|
|
the unit the declared one stands, else mm, and the warning says which; a
|
|
takeoff never leaves a length in unknown units.
|
|
"""
|
|
found = drawing_units(snap, prep=prep)
|
|
name = found["declared"]["name"]
|
|
declared = name if name in UNIT_METRES else None
|
|
inferred, warning = found["inferred"], found["warning"]
|
|
if inferred is None:
|
|
inferred = declared or "mm"
|
|
warning = (
|
|
f"INSUNITS declares {found['declared']['label']} ({found['declared']['code']}) and "
|
|
f"no measurement settles the unit; the takeoff reads it as {inferred}."
|
|
)
|
|
return {
|
|
"declared": declared,
|
|
"inferred": inferred,
|
|
"m_per_unit": UNIT_METRES[inferred],
|
|
"warning": f"{snap.source}: {warning}" if warning else None,
|
|
"evidence": found["evidence"],
|
|
}
|
|
|
|
|
|
# -- plan copies and tag positions ---------------------------------------------
|
|
|
|
|
|
def layout_tags(snap: Snapshot, wanted: Iterable[str] = (), *, prep: dict | None = None) -> dict:
|
|
"""Tag positions on a drawing, using only tags that are unique where they are read.
|
|
|
|
When no tag is written twice the whole model space is read. Otherwise the
|
|
drawing's clusters - `describe.prepare`'s, the plan copies, sheets and
|
|
outliers `drawing_understand` reports - are read, and the cluster holding
|
|
the most `wanted` tags is chosen (ties: the one furthest left, then lowest); a tag written more than once inside it is `ambiguous`,
|
|
never picked arbitrarily. ``{"positions": {tag: Pt}, "ambiguous": [...],
|
|
"cluster": box | None, "clusters": n}``.
|
|
"""
|
|
occurrences = tag_occurrences(snap)
|
|
wanted = set(wanted)
|
|
if all(len(places) == 1 for places in occurrences.values()):
|
|
return {
|
|
"positions": {tag: places[0]["at"] for tag, places in occurrences.items()},
|
|
"ambiguous": [],
|
|
"cluster": None,
|
|
"clusters": 1,
|
|
}
|
|
prep = prep or prepare(snap)
|
|
boxes = [tuple(cluster["box"]) for cluster in prep["clusters"]]
|
|
|
|
def inside(p: Pt, box) -> bool:
|
|
return box[0] <= p[0] <= box[2] and box[1] <= p[1] <= box[3]
|
|
|
|
def weight(box) -> tuple:
|
|
count = sum(
|
|
1
|
|
for tag, places in occurrences.items()
|
|
if tag in wanted and any(inside(place["at"], box) for place in places)
|
|
)
|
|
return (-count, box[0], box[1])
|
|
|
|
chosen = min(boxes, key=weight) if boxes else None
|
|
positions, ambiguous = {}, []
|
|
for tag, places in sorted(occurrences.items()):
|
|
here = [place for place in places if chosen is None or inside(place["at"], chosen)]
|
|
if len(here) == 1:
|
|
positions[tag] = here[0]["at"]
|
|
elif len(here) > 1:
|
|
ambiguous.append(tag)
|
|
return {
|
|
"positions": positions,
|
|
"ambiguous": ambiguous,
|
|
"cluster": chosen,
|
|
"clusters": len(boxes),
|
|
}
|
|
|
|
|
|
# -- rooms -------------------------------------------------------------------
|
|
|
|
|
|
def room_regions(snap: Snapshot, cluster=None, *, prep: dict | None = None) -> dict:
|
|
"""Room faces and labels - `describe.find_rooms`, the rooms `drawing_understand`
|
|
reports - inside ``cluster`` (a box) when one is given.
|
|
|
|
``{"faces": [(key, Face)], "labels": [(key, Pt)]}``; the key is the room
|
|
number, else its name. A record belongs to the cluster when every point
|
|
it has lies in the box.
|
|
"""
|
|
prep = prep or prepare(snap)
|
|
|
|
def keep(rec) -> bool:
|
|
return cluster is None or all(
|
|
cluster[0] <= p[0] <= cluster[2] and cluster[1] <= p[1] <= cluster[3]
|
|
for p in rec.points
|
|
)
|
|
|
|
tol = prep["diag"] * FACE_TOL_FRACTION if prep["diag"] > 0 else 1.0
|
|
found = find_rooms(
|
|
[rec for rec in prep["kept"] if keep(rec)], tol=tol, membership=prep["membership"]
|
|
)
|
|
|
|
def key(row: dict) -> str:
|
|
return str(row["number"] or row["name"] or row["text"])
|
|
|
|
# without a face a piece goes to the nearest label: when the drawing numbers
|
|
# its rooms, an unnumbered mention ('to room storage') is a note, not a room
|
|
numbered = [row for row in found["rooms"] if row["number"]]
|
|
candidates = numbered or found["rooms"]
|
|
return {
|
|
"faces": [(key(row), face) for row, face in found["labelled"]],
|
|
"labels": sorted((key(row), (row["at"][0], row["at"][1])) for row in candidates),
|
|
}
|
|
|
|
|
|
def _rooms_mode(regions: dict) -> str:
|
|
if regions["faces"]:
|
|
return "polygons"
|
|
return "nearest_label" if regions["labels"] else "none"
|
|
|
|
|
|
def room_at(regions: dict, p: Pt) -> str:
|
|
"""The room a point stands in; else the nearest room label; else ''."""
|
|
face = face_containing([face for _key, face in regions["faces"]], p)
|
|
if face is not None:
|
|
return next(key for key, f in regions["faces"] if f is face)
|
|
if regions["labels"]:
|
|
return min(regions["labels"], key=lambda item: (math.dist(item[1], p), item[0]))[0]
|
|
return ""
|
|
|
|
|
|
def apportion(legs, regions: dict) -> dict[str, float]:
|
|
"""Length of each orthogonal leg inside each room polygon (drawing units).
|
|
|
|
A leg is cut at every crossing with a room outline or hole; each piece goes
|
|
to the room its midpoint stands in, or to '' outside every room. Nothing is
|
|
counted twice: the pieces add up to the leg.
|
|
"""
|
|
faces = [face for _key, face in regions["faces"]]
|
|
key_of = {id(face): key for key, face in regions["faces"]}
|
|
out: dict[str, float] = {}
|
|
for a, b in legs:
|
|
length = math.dist(a, b)
|
|
if length == 0.0:
|
|
continue
|
|
cuts = {0.0, 1.0}
|
|
for face in faces:
|
|
for ring in (face.loop, *face.holes):
|
|
for i in range(len(ring)):
|
|
t = _cut(a, b, ring[i], ring[(i + 1) % len(ring)])
|
|
if t is not None:
|
|
cuts.add(t)
|
|
ts = sorted(cuts)
|
|
for t0, t1 in zip(ts, ts[1:], strict=False):
|
|
if t1 - t0 <= 0.0:
|
|
continue
|
|
tm = (t0 + t1) / 2.0
|
|
mid = (a[0] + (b[0] - a[0]) * tm, a[1] + (b[1] - a[1]) * tm)
|
|
face = face_containing(faces, mid)
|
|
key = key_of[id(face)] if face is not None else ""
|
|
out[key] = out.get(key, 0.0) + (t1 - t0) * length
|
|
return out
|
|
|
|
|
|
def _cut(a: Pt, b: Pt, c: Pt, d: Pt) -> float | None:
|
|
"""Parameter along a->b where it crosses segment c-d, or None."""
|
|
rx, ry = b[0] - a[0], b[1] - a[1]
|
|
sx, sy = d[0] - c[0], d[1] - c[1]
|
|
denom = rx * sy - ry * sx
|
|
if denom == 0.0:
|
|
return None
|
|
qx, qy = c[0] - a[0], c[1] - a[1]
|
|
t = (qx * sy - qy * sx) / denom
|
|
u = (qx * ry - qy * rx) / denom
|
|
if 0.0 < t < 1.0 and 0.0 <= u <= 1.0:
|
|
return t
|
|
return None
|
|
|
|
|
|
# -- pipe rows ---------------------------------------------------------------
|
|
|
|
|
|
def _choose_source(requested: str, layout, scale: dict, force: bool) -> tuple[str, str]:
|
|
verdict = scale.get("verdict")
|
|
if requested == "layout":
|
|
if layout is None:
|
|
raise ValueError("length_source='layout' needs a layout drawing; none was given")
|
|
return "layout", "requested"
|
|
if requested == "pid":
|
|
if layout is not None and verdict == "schematic" and not force:
|
|
raise ValueError(
|
|
"length_source='pid': the scale check calls this P&ID schematic - "
|
|
f"{scale['within_10pct']:.0%} of {scale['pair_count']} tag pairs lie within "
|
|
f"±10 % of the median ratio {scale['median']:.6g} (IQR {scale['iqr']:.6g}); "
|
|
"a length measured on it is drawn line, not pipe. Pass force=True to measure "
|
|
"on it anyway, or use length_source='layout'."
|
|
)
|
|
return "pid", "forced" if layout is not None and verdict == "schematic" else "requested"
|
|
if layout is None:
|
|
return "pid", "no_layout"
|
|
if verdict == "to_scale":
|
|
return "pid", "auto_to_scale"
|
|
return "layout", f"auto_{verdict}"
|
|
|
|
|
|
def _row(rows: dict, room: str, service: str, diameter: str) -> dict:
|
|
return rows.setdefault(
|
|
(room, service, diameter),
|
|
{
|
|
"room": room,
|
|
"service": service,
|
|
"diameter": diameter,
|
|
"direct_m": 0.0,
|
|
"continuity_m": 0.0,
|
|
"unassigned_m": 0.0,
|
|
"net_m": 0.0,
|
|
"schematic_m": 0.0,
|
|
"status": "A",
|
|
"runs": [],
|
|
},
|
|
)
|
|
|
|
|
|
def _pid_cells(edges, regions: dict, m_per_unit: float) -> dict:
|
|
"""{(room, diameter): {net, direct, continuity, unassigned}} in metres, piece by piece.
|
|
|
|
With room faces a piece is cut at the outlines (`apportion` on its chord,
|
|
scaled to the piece's true length, so a bend keeps its arc length); without
|
|
faces the whole piece goes to the room label nearest its midpoint.
|
|
"""
|
|
cells: dict[tuple[str, str], dict] = {}
|
|
for edge in edges:
|
|
a, b = edge["a"], edge["b"]
|
|
metres = edge["length"] * m_per_unit
|
|
pieces = apportion([(a, b)], regions) if regions["faces"] else {}
|
|
chord = sum(pieces.values())
|
|
if chord > 0.0:
|
|
shares = {room: value / chord for room, value in pieces.items()}
|
|
else:
|
|
shares = {room_at(regions, ((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0)): 1.0}
|
|
diameter = edge["diameter"] or UNASSIGNED
|
|
for room, share in shares.items():
|
|
cell = cells.setdefault(
|
|
(room, diameter), {"net": 0.0, "direct": 0.0, "continuity": 0.0, "unassigned": 0.0}
|
|
)
|
|
cell["net"] += metres * share
|
|
cell[edge["diameter_source"]] += metres * share
|
|
return cells
|
|
|
|
|
|
#: How a load no callout places gets its panel: the nearest one on the layout
|
|
#: (flagged ``panel_inferred``), or none (``missing_panel``).
|
|
PANEL_RULES = ("nearest", "stated")
|
|
|
|
|
|
def _nearest_panel(here: Pt, panel_at: dict, *, exclude=None) -> tuple[str, float] | None:
|
|
"""(panel, distance in layout units) of the panel nearest ``here``; ties by name."""
|
|
candidates = [(math.dist(here, p), name) for name, p in panel_at.items() if name != exclude]
|
|
if not candidates:
|
|
return None
|
|
distance, name = min(candidates)
|
|
return name, distance
|
|
|
|
|
|
#: Detail copies: tags drawn twice are grouped when their occurrences lie within
|
|
#: this share of the P&ID's robust diagonal of each other (single linkage) ...
|
|
DETAIL_LINK_SHARE = 0.03
|
|
#: ... and a group is a possible detail copy when it holds at least this many
|
|
#: distinct repeated tags, each of which also stands somewhere outside it. The
|
|
#: same count of an equipment's own parts (M150 -> M150B, M150C, M150D) standing
|
|
#: within DETAIL_FAMILY_SHARE of the diagonal is a possible package or detail.
|
|
DETAIL_MIN_TAGS = 3
|
|
DETAIL_FAMILY_SHARE = 0.15
|
|
_FAMILY_TAG = re.compile(r"^(?P<base>[A-Z]{1,4}-?\d{1,5})(?P<part>[A-Z]{1,2})$")
|
|
|
|
|
|
def detail_copy_regions(pid: Snapshot, *, prep: dict | None = None) -> list[dict]:
|
|
"""Where a P&ID may draw pipes twice: ``[{"id", "box", "tags", "kind", "base"}]``.
|
|
|
|
``repeated``: tags written more than once (readings of one tag closer than
|
|
half the link are one) grouped by where they stand; a compact group of at
|
|
least ``DETAIL_MIN_TAGS`` such tags, every one also written outside it, is a
|
|
possible detail copy. ``family``: at least ``DETAIL_MIN_TAGS`` parts of one
|
|
equipment (``M150B``, ``M150C`` ... of ``M150``) standing close together, a
|
|
possible package or detail whose internal pipes a site takeoff may leave to
|
|
its vendor. Ids run D1, D2 ... from the left. A heuristic that reports and
|
|
never removes: the caller decides.
|
|
"""
|
|
prep = prep or prepare(pid)
|
|
link = DETAIL_LINK_SHARE * prep["diag"]
|
|
if link <= 0.0:
|
|
return []
|
|
occurrences = {
|
|
tag: [place["at"] for place in places] for tag, places in tag_occurrences(pid).items()
|
|
}
|
|
points = [
|
|
(tag, at)
|
|
for tag, places in occurrences.items()
|
|
for at in _distinct(places, link / 2.0)
|
|
if len(_distinct(places, link / 2.0)) > 1
|
|
]
|
|
parent = list(range(len(points)))
|
|
|
|
def find(k: int) -> int:
|
|
while parent[k] != k:
|
|
parent[k] = parent[parent[k]]
|
|
k = parent[k]
|
|
return k
|
|
|
|
grid: dict[tuple[int, int], list[int]] = {}
|
|
for k, (_tag, at) in enumerate(points):
|
|
grid.setdefault((math.floor(at[0] / link), math.floor(at[1] / link)), []).append(k)
|
|
for (cx, cy), members in grid.items():
|
|
for dx in (-1, 0, 1):
|
|
for dy in (-1, 0, 1):
|
|
for j in grid.get((cx + dx, cy + dy), ()):
|
|
for i in members:
|
|
if i < j and math.dist(points[i][1], points[j][1]) <= link:
|
|
parent[find(i)] = find(j)
|
|
groups: dict[int, list[int]] = {}
|
|
for k in range(len(points)):
|
|
groups.setdefault(find(k), []).append(k)
|
|
total = {}
|
|
for tag, _at in points:
|
|
total[tag] = total.get(tag, 0) + 1
|
|
regions = []
|
|
for members in groups.values():
|
|
tags = sorted({points[k][0] for k in members})
|
|
if len(tags) < DETAIL_MIN_TAGS:
|
|
continue
|
|
here = {}
|
|
for k in members:
|
|
here[points[k][0]] = here.get(points[k][0], 0) + 1
|
|
if any(here[tag] >= total[tag] for tag in tags):
|
|
continue # a tag written only here: this is where it lives, not a copy
|
|
xs = [points[k][1][0] for k in members]
|
|
ys = [points[k][1][1] for k in members]
|
|
regions.append(
|
|
{
|
|
"box": [min(xs) - link, min(ys) - link, max(xs) + link, max(ys) + link],
|
|
"tags": tags,
|
|
"kind": "repeated",
|
|
"base": None,
|
|
}
|
|
)
|
|
families: dict[str, dict[str, list]] = {}
|
|
for tag, places in occurrences.items():
|
|
match = _FAMILY_TAG.match(tag)
|
|
if match:
|
|
families.setdefault(match["base"], {})[tag] = places
|
|
for base, parts in sorted(families.items()):
|
|
if len(parts) < DETAIL_MIN_TAGS:
|
|
continue
|
|
xs = [p[0] for places in parts.values() for p in places]
|
|
ys = [p[1] for places in parts.values() for p in places]
|
|
if math.hypot(max(xs) - min(xs), max(ys) - min(ys)) > DETAIL_FAMILY_SHARE * prep["diag"]:
|
|
continue
|
|
regions.append(
|
|
{
|
|
"box": [min(xs) - link, min(ys) - link, max(xs) + link, max(ys) + link],
|
|
"tags": sorted(parts),
|
|
"kind": "family",
|
|
"base": base,
|
|
}
|
|
)
|
|
regions.sort(key=lambda region: (region["box"][0], region["box"][1]))
|
|
return [{"id": f"D{i}", **region} for i, region in enumerate(regions, start=1)]
|
|
|
|
|
|
def _distinct(places, radius: float) -> list:
|
|
"""The places of one tag, readings closer than ``radius`` to a kept one dropped."""
|
|
kept: list = []
|
|
for at in places:
|
|
if all(math.dist(at, other) > radius for other in kept):
|
|
kept.append(at)
|
|
return kept
|
|
|
|
|
|
def _majority_region(edges, boxes: dict) -> str | None:
|
|
"""The region holding more than half a run's drawn length, by piece midpoints."""
|
|
total = sum(edge["length"] for edge in edges)
|
|
if not boxes or total <= 0.0:
|
|
return None
|
|
share: dict[str, float] = {}
|
|
for edge in edges:
|
|
mid = ((edge["a"][0] + edge["b"][0]) / 2.0, (edge["a"][1] + edge["b"][1]) / 2.0)
|
|
for region_id, box in boxes.items():
|
|
if box[0] <= mid[0] <= box[2] and box[1] <= mid[1] <= box[3]:
|
|
share[region_id] = share.get(region_id, 0.0) + edge["length"]
|
|
break
|
|
best = max(share.items(), key=lambda item: item[1], default=None)
|
|
return best[0] if best is not None and best[1] > total / 2.0 else None
|
|
|
|
|
|
def _reasons(flagged: list[dict]) -> str:
|
|
counts: dict[str, int] = {}
|
|
for run in flagged:
|
|
counts[run["reason"]] = counts.get(run["reason"], 0) + 1
|
|
return ", ".join(f"{reason} {n}" for reason, n in sorted(counts.items()))
|
|
|
|
|
|
def pipe_rows(
|
|
network,
|
|
pid: Snapshot,
|
|
layout: Snapshot | None,
|
|
*,
|
|
scale=None,
|
|
services=DEFAULT_SERVICES,
|
|
allowance=0.20,
|
|
vertical_allowance=0.0,
|
|
length_source="auto",
|
|
force=False,
|
|
exclude_regions=(),
|
|
) -> dict:
|
|
"""Takeoff rows by room x service x diameter, with the runs, checks and method behind them.
|
|
|
|
`network` is `build_network(pid)`'s result. `scale` is a `scale_check`
|
|
result; omitted, `scale_check(pid, layout)` runs when a layout is given. Refused by name before anything is measured: an unknown service
|
|
or length source, an allowance outside 0-1, a negative vertical
|
|
allowance, `length_source='layout'` without a layout, and
|
|
`length_source='pid'` on a P&ID the scale check calls schematic unless
|
|
`force=True` (the refusal quotes the statistics).
|
|
|
|
Status per run: **A** every piece's diameter read directly; **B** a
|
|
diameter carried by continuity or unassigned; **C**, on the layout only,
|
|
not routable - an end on no equipment, fewer than two tags, a tag missing
|
|
from (or written twice on) the layout. A C run goes to `control` with its
|
|
schematic length only, is counted in the totals and is flagged, and a
|
|
warning says how much could not be routed. Measured on the P&ID a run needs
|
|
no equipment at its ends - the drawn geometry is the length - and each
|
|
piece goes to the P&ID room it lies in (cut at the room outlines when the
|
|
P&ID has room faces, else the nearest room label). On the layout a run
|
|
holding several diameters shares its routed length among them in
|
|
proportion to their drawn length on the P&ID.
|
|
|
|
Detail copies: where several tags the P&ID draws twice sit close together
|
|
(`detail_copy_regions`), a detail of equipment may repeat its pipes. Each
|
|
such region is reported in ``detail_copies`` and counted like any other
|
|
drawing; a run with more than half its drawn length in a region named in
|
|
``exclude_regions`` is left out, listed in ``control`` as
|
|
``excluded_region`` and totalled in ``excluded_m``. An unknown region id is
|
|
refused with the ids the P&ID has. Which copy is the detail is the
|
|
engineer's call: the drawing does not say.
|
|
"""
|
|
options = check_pipe_options(
|
|
services=services,
|
|
allowance=allowance,
|
|
vertical_allowance=vertical_allowance,
|
|
length_source=length_source,
|
|
)
|
|
allowance = options["allowance"]
|
|
vertical = options["vertical_allowance"]
|
|
prep_pid = prepare(pid)
|
|
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}
|
|
found = layout_tags(layout, wanted, prep=prep_layout) if layout is not None else None
|
|
if scale is None:
|
|
scale = (
|
|
scale_check(pid, layout)
|
|
if layout is not None
|
|
else {"verdict": "not_checked", "pair_count": 0, "matched": [], "ambiguous": []}
|
|
)
|
|
source, reason = _choose_source(length_source, layout, scale, bool(force))
|
|
scale_verified = source == "layout" or scale.get("verdict") == "to_scale"
|
|
positions = found["positions"] if found else {}
|
|
ambiguous = set(found["ambiguous"]) if found else set()
|
|
regions = (
|
|
room_regions(layout, found["cluster"], prep=prep_layout)
|
|
if found is not None and source == "layout"
|
|
else room_regions(pid, prep=prep_pid)
|
|
)
|
|
m_pid = pid_unit["m_per_unit"]
|
|
m_layout = layout_unit["m_per_unit"] if layout_unit else None
|
|
copies = detail_copy_regions(pid, prep=prep_pid)
|
|
known_ids = [region["id"] for region in copies]
|
|
left_out = [str(region_id) for region_id in (exclude_regions or ())]
|
|
for region_id in left_out:
|
|
if region_id not in known_ids:
|
|
raise ValueError(
|
|
f"exclude_regions: {region_id!r} unknown; the P&ID has "
|
|
f"{', '.join(known_ids) or 'no detail copy'}"
|
|
)
|
|
boxes = {region["id"]: region["box"] for region in copies}
|
|
for region in copies:
|
|
region["pipe_m"] = 0.0
|
|
region["excluded"] = region["id"] in left_out
|
|
|
|
rows: dict[tuple[str, str, str], dict] = {}
|
|
runs_out, control, excluded = [], [], {}
|
|
excluded_m = 0.0
|
|
for run in network["runs"]:
|
|
if run.service not in options["services"]:
|
|
excluded[run.service] = excluded.get(run.service, 0) + 1
|
|
continue
|
|
inside = _majority_region(run.edges, boxes)
|
|
if inside is not None:
|
|
region = next(r for r in copies if r["id"] == inside)
|
|
region["pipe_m"] += sum(edge["length"] for edge in run.edges) * m_pid
|
|
if inside in left_out:
|
|
drawn_m = sum(edge["length"] for edge in run.edges) * m_pid
|
|
excluded_m += drawn_m
|
|
control.append(
|
|
{
|
|
"run": run.id,
|
|
"kind": "excluded_region",
|
|
"service": run.service,
|
|
"tags": list(run.tags),
|
|
"schematic_m": drawn_m,
|
|
"reason": inside,
|
|
}
|
|
)
|
|
continue
|
|
ends = network["attachments"].get(run.id, [])
|
|
drawn = sum(edge["length"] for edge in run.edges)
|
|
schematic_m = drawn * m_pid
|
|
parts: dict[str, dict] = {}
|
|
for edge in run.edges:
|
|
part = parts.setdefault(
|
|
edge["diameter"] or UNASSIGNED,
|
|
{"drawn": 0.0, "direct": 0.0, "continuity": 0.0, "unassigned": 0.0},
|
|
)
|
|
part["drawn"] += edge["length"]
|
|
part[edge["diameter_source"]] += edge["length"]
|
|
why = None
|
|
if source == "layout":
|
|
if any(end["tag"] is None for end in ends) or not ends:
|
|
why = "end_not_on_equipment"
|
|
elif len(run.tags) < 2:
|
|
why = "single_tag"
|
|
else:
|
|
missing = [tag for tag in run.tags if tag not in positions]
|
|
if missing:
|
|
why = (
|
|
"tag_ambiguous"
|
|
if any(t in ambiguous for t in missing)
|
|
else "tag_not_on_layout"
|
|
)
|
|
detail = {
|
|
"id": run.id,
|
|
"service": run.service,
|
|
"tags": list(run.tags),
|
|
"diameters": {d: p["drawn"] * m_pid for d, p in sorted(parts.items())},
|
|
"schematic_m": schematic_m,
|
|
"layout_m": None,
|
|
"vertical_m": 0.0,
|
|
"length_m": None,
|
|
"rooms": {},
|
|
"status": "C",
|
|
"reason": why,
|
|
}
|
|
runs_out.append(detail)
|
|
for diameter, part in sorted(parts.items()):
|
|
if diameter == UNASSIGNED:
|
|
control.append(
|
|
{
|
|
"run": run.id,
|
|
"kind": "unassigned_diameter",
|
|
"service": run.service,
|
|
"tags": list(run.tags),
|
|
"schematic_m": part["drawn"] * m_pid,
|
|
"reason": "no_diameter_label",
|
|
}
|
|
)
|
|
if why is not None:
|
|
control.append(
|
|
{
|
|
"run": run.id,
|
|
"kind": "unroutable",
|
|
"service": run.service,
|
|
"tags": list(run.tags),
|
|
"schematic_m": schematic_m,
|
|
"reason": why,
|
|
}
|
|
)
|
|
continue
|
|
if source == "layout":
|
|
points = [positions[tag] for tag in run.tags]
|
|
length_units, tree = rmst(points)
|
|
if regions["faces"]:
|
|
legs = [leg for i, j in tree for leg in route_legs(points[i], points[j])]
|
|
rooms = {key: value * m_layout for key, value in apportion(legs, regions).items()}
|
|
else:
|
|
centre = (
|
|
sum(p[0] for p in points) / len(points),
|
|
sum(p[1] for p in points) / len(points),
|
|
)
|
|
rooms = {room_at(regions, centre): length_units * m_layout}
|
|
for _i, j in tree:
|
|
key = room_at(regions, points[j])
|
|
rooms[key] = rooms.get(key, 0.0) + vertical
|
|
detail["layout_m"] = length_units * m_layout
|
|
detail["vertical_m"] = vertical * len(tree)
|
|
length_m = detail["layout_m"] + detail["vertical_m"]
|
|
else:
|
|
factor = (
|
|
scale["factor"]
|
|
if layout is not None and scale.get("verdict") == "to_scale"
|
|
else 1.0
|
|
)
|
|
length_m = schematic_m / factor
|
|
cells = _pid_cells(run.edges, regions, m_pid / factor)
|
|
rooms = {}
|
|
for (room, _diameter), cell in cells.items():
|
|
rooms[room] = rooms.get(room, 0.0) + cell["net"]
|
|
detail["length_m"] = length_m
|
|
detail["rooms"] = rooms
|
|
status = (
|
|
"A"
|
|
if all(p["continuity"] == 0.0 and p["unassigned"] == 0.0 for p in parts.values())
|
|
else "B"
|
|
)
|
|
detail["status"] = status
|
|
if source == "pid":
|
|
# measured piece by piece: each cell is one room and one diameter
|
|
for (room, diameter), cell in cells.items():
|
|
row = _row(rows, room, run.service, diameter)
|
|
row["net_m"] += cell["net"]
|
|
row["direct_m"] += cell["direct"]
|
|
row["continuity_m"] += cell["continuity"]
|
|
row["unassigned_m"] += cell["unassigned"]
|
|
row["schematic_m"] += cell["net"] * factor
|
|
if cell["continuity"] or cell["unassigned"]:
|
|
row["status"] = "B"
|
|
if run.id not in row["runs"]:
|
|
row["runs"].append(run.id)
|
|
continue
|
|
for diameter, part in parts.items():
|
|
share = part["drawn"] / drawn if drawn else 0.0
|
|
for room, room_m in rooms.items():
|
|
x = room_m * share
|
|
row = _row(rows, room, run.service, diameter)
|
|
row["net_m"] += x
|
|
row["direct_m"] += x * part["direct"] / part["drawn"]
|
|
row["continuity_m"] += x * part["continuity"] / part["drawn"]
|
|
row["unassigned_m"] += x * part["unassigned"] / part["drawn"]
|
|
row["schematic_m"] += schematic_m * share * (room_m / length_m if length_m else 0.0)
|
|
if part["continuity"] or part["unassigned"]:
|
|
row["status"] = "B"
|
|
if run.id not in row["runs"]:
|
|
row["runs"].append(run.id)
|
|
|
|
order = {service: i for i, service in enumerate(SERVICES)}
|
|
table = []
|
|
for key in sorted(rows, key=lambda k: (k[0], order.get(k[1], len(order)), k[2])):
|
|
row = rows[key]
|
|
row["allowance"] = allowance
|
|
row["allowance_m"] = row["net_m"] * allowance
|
|
row["total_m"] = row["net_m"] + row["allowance_m"]
|
|
table.append(row)
|
|
|
|
routed = sum(row["net_m"] for row in table)
|
|
unroutable = sum(c["schematic_m"] for c in control if c["kind"] == "unroutable")
|
|
net_total = routed + unroutable
|
|
summary = {
|
|
name: [
|
|
{
|
|
"key": key,
|
|
"net_m": sum(r["net_m"] for r in table if r[field] == key),
|
|
"total_m": sum(r["total_m"] for r in table if r[field] == key),
|
|
}
|
|
for key in sorted({r[field] for r in table})
|
|
]
|
|
for name, field in (
|
|
("by_service", "service"),
|
|
("by_diameter", "diameter"),
|
|
("by_room", "room"),
|
|
)
|
|
}
|
|
warnings = [u["warning"] for u in (pid_unit, layout_unit) if u and u["warning"]]
|
|
dropped = int(network.get("stats", {}).get("segments_below_tol") or 0)
|
|
if dropped:
|
|
warnings.append(
|
|
f"{dropped} P&ID segment(s) shorter than the junction tolerance "
|
|
f"({network['stats']['tol']:g} drawing units) collapsed to a point and are not "
|
|
"counted"
|
|
)
|
|
flagged = [r for r in runs_out if r["status"] == "C"]
|
|
if source == "layout" and flagged:
|
|
warnings.append(
|
|
f"{len(flagged)} of {len(runs_out)} runs could not be routed on the layout "
|
|
f"({_reasons(flagged)}); their {unroutable:.1f} m drawn on the P&ID are in the "
|
|
"control rows, not in the table. length_source='pid' with force=True measures "
|
|
"every run on the P&ID instead, and says the scale is not verified."
|
|
)
|
|
counted_copies = [region for region in copies if not region["excluded"]]
|
|
if counted_copies:
|
|
listed = "; ".join(
|
|
f"{region['id']}: {', '.join(region['tags'])} ({region['pipe_m']:.1f} m)"
|
|
for region in counted_copies
|
|
)
|
|
warnings.append(
|
|
f"{len(counted_copies)} possible detail cop{'y' if len(counted_copies) == 1 else 'ies'} "
|
|
f"- tags drawn twice, close together - are counted like the rest ({listed}). If one "
|
|
"repeats pipes drawn elsewhere, pass its id in exclude_regions."
|
|
)
|
|
if not scale_verified:
|
|
warnings.append(
|
|
"scale_verified: false - lengths are drawn lengths on a P&ID that no scale check "
|
|
"has confirmed; they are not physical pipe lengths"
|
|
)
|
|
return {
|
|
"kind": "pipe",
|
|
"rows": table,
|
|
"runs": runs_out,
|
|
"control": control,
|
|
"summary": summary,
|
|
"totals": {
|
|
"routed_m": routed,
|
|
"unroutable_m": unroutable,
|
|
"net_m": net_total,
|
|
"allowance": allowance,
|
|
"allowance_m": net_total * allowance,
|
|
"total_m": net_total * (1.0 + allowance),
|
|
"flagged_runs": sum(1 for r in runs_out if r["status"] == "C"),
|
|
"excluded_m": excluded_m,
|
|
},
|
|
"detail_copies": copies,
|
|
"length_source": source,
|
|
"scale": scale,
|
|
"scale_verified": scale_verified,
|
|
"units": {"pid": pid_unit, "layout": layout_unit},
|
|
"warnings": warnings,
|
|
"method": {
|
|
"sources": {"pid": pid.source, "layout": layout.source if layout else None},
|
|
"length_source": source,
|
|
"requested": length_source,
|
|
"reason": reason,
|
|
"allowance": allowance,
|
|
"vertical_allowance": vertical,
|
|
"services": list(options["services"]),
|
|
"excluded_services": excluded,
|
|
"tol": network["stats"]["tol"],
|
|
"label_search": network["stats"]["label_search"],
|
|
"overlap_length_m": network["stats"]["overlap_length"] * m_pid,
|
|
"route": "rectilinear MST of the tag positions; each connection x first, then y",
|
|
"rooms": _rooms_mode(regions),
|
|
"exclude_regions": left_out,
|
|
},
|
|
}
|
|
|
|
|
|
# -- cable rows ----------------------------------------------------------------
|
|
|
|
#: A load's power block stands under its symbol rather than on a line, so its
|
|
#: electrical texts and its wiring callout are searched twice as far as a
|
|
#: diameter label: 10 x the median text height.
|
|
CABLE_SEARCH_SHARE = 2.0
|
|
SECTION_RULE_KEYS = ("max_kw", "section", "phases")
|
|
|
|
|
|
def check_section_rules(section_rules) -> tuple[dict, ...]:
|
|
"""The caller's own cable-section rule table, validated and sorted by max_kw.
|
|
|
|
Each rule is ``{"max_kw": > 0, "section": text, "phases": 1 | 3 (optional)}``;
|
|
a load takes the first rule whose max_kw it does not exceed and whose
|
|
phases, when the rule names them, equal the load's. No standard table exists
|
|
in this repository: without rules no section is proposed.
|
|
"""
|
|
if section_rules is None:
|
|
return ()
|
|
if not isinstance(section_rules, (list, tuple)):
|
|
raise ValueError("section_rules: expected a list of {max_kw, section[, phases]}")
|
|
rules = []
|
|
for i, rule in enumerate(section_rules):
|
|
where = f"section_rules[{i}]"
|
|
if not isinstance(rule, dict):
|
|
raise ValueError(f"{where}: expected {{max_kw, section[, phases]}}, got {rule!r}")
|
|
unknown = sorted(set(rule) - set(SECTION_RULE_KEYS))
|
|
if unknown:
|
|
raise ValueError(f"{where}: unknown key(s) {', '.join(map(str, unknown))}")
|
|
max_kw = _number(rule.get("max_kw"), f"{where}.max_kw")
|
|
if max_kw <= 0.0:
|
|
raise ValueError(f"{where}.max_kw: must be greater than zero, got {max_kw:g}")
|
|
section = rule.get("section")
|
|
if not isinstance(section, str) or not section.strip():
|
|
raise ValueError(
|
|
f"{where}.section: expected a non-empty text such as '5x2.5', got {section!r}"
|
|
)
|
|
phases = rule.get("phases")
|
|
if phases is not None and phases not in (1, 3):
|
|
raise ValueError(f"{where}.phases: 1 or 3, got {phases!r}")
|
|
rules.append({"max_kw": max_kw, "section": section.strip(), "phases": phases})
|
|
return tuple(sorted(rules, key=lambda r: (r["max_kw"], r["phases"] or 0)))
|
|
|
|
|
|
def section_for(kw: float | None, phases: int | None, rules) -> str | None:
|
|
"""The first rule the load fits, or None - never a guessed section."""
|
|
if kw is None:
|
|
return None
|
|
for rule in rules:
|
|
if kw <= rule["max_kw"] and (rule["phases"] is None or rule["phases"] == phases):
|
|
return rule["section"]
|
|
return None
|
|
|
|
|
|
def roundup_m(length_m: float, allowance: float) -> int:
|
|
"""ROUNDUP(length x (1 + allowance)) to the metre, on the value rounded to 1e-6 m first.
|
|
|
|
The rounding keeps binary noise from buying a metre of cable: 50 m with a
|
|
10 % allowance is 55.00000000000001 in floating point, and 55 m on the sheet.
|
|
"""
|
|
return math.ceil(round(length_m * (1.0 + allowance), 6))
|
|
|
|
|
|
def _nearest_tag(at: Pt, places: dict[str, list[Pt]], search: float) -> str | None:
|
|
found = [
|
|
(math.dist(at, p), tag)
|
|
for tag, points in places.items()
|
|
for p in points
|
|
if math.dist(at, p) <= search
|
|
]
|
|
return min(found)[1] if found else None
|
|
|
|
|
|
def _inside_box(p: Pt, box) -> bool:
|
|
return box is None or (box[0] <= p[0] <= box[2] and box[1] <= p[1] <= box[3])
|
|
|
|
|
|
def cable_rows(
|
|
pid: Snapshot,
|
|
layout: Snapshot | None,
|
|
*,
|
|
allowance=0.20,
|
|
section_rules=None,
|
|
panel_rule="nearest",
|
|
) -> dict:
|
|
"""One row per electrical load, the cable method made repeatable (spec §9).
|
|
|
|
Loads are the P&ID tags with electrical texts near them (`parse_electrical`:
|
|
kW, phases, voltage, + N, VFD) and the tags a wiring callout names. The
|
|
panel comes from the layout's callouts (`wiring_target`: 'Wiring to CP1'
|
|
-> CP-1), else the P&ID's; with ``panel_rule='nearest'`` (the default) a
|
|
load no callout places, whose tag is on the layout, takes the nearest panel
|
|
there and is flagged ``panel_inferred`` - ``'stated'`` leaves it open. An
|
|
inferred panel never makes a load part of a package. The length is the Manhattan distance on the
|
|
layout from the load's tag to its panel's tag, and the cable is
|
|
`roundup_m(length, allowance)`. **Power is never invented**: a load with no
|
|
stated kW keeps an empty cell and an open item. With no layout, lengths
|
|
stay empty and every row gets an open item - the P&ID is not a length
|
|
source for cables. A load wired to a panel that itself states a power is
|
|
part of that package: room and panel totals count the package's power and
|
|
not its children's. Sections come only from `section_rules`.
|
|
|
|
Refused by name before anything is read: an allowance outside 0-1 and a
|
|
malformed section rule.
|
|
"""
|
|
allowance = check_allowance(allowance)
|
|
rules = check_section_rules(section_rules)
|
|
if panel_rule not in PANEL_RULES:
|
|
raise ValueError(
|
|
f"panel_rule: {panel_rule!r} unknown; choose from {', '.join(PANEL_RULES)}"
|
|
)
|
|
pid_places = {tag: [p["at"] for p in places] for tag, places in tag_occurrences(pid).items()}
|
|
pid_search = CABLE_SEARCH_SHARE * label_search_default(pid)
|
|
|
|
loads: dict[str, list] = {}
|
|
orphans: list[str] = []
|
|
pid_callouts: dict[str, str] = {}
|
|
for rec in pid.records:
|
|
if rec.space != MODEL or rec.type not in TEXT_TYPES or not rec.text or not rec.points:
|
|
continue
|
|
at = rec.points[0]
|
|
target = wiring_target(rec.text)
|
|
if target is not None:
|
|
tag = _nearest_tag(at, pid_places, pid_search)
|
|
if tag is not None:
|
|
pid_callouts.setdefault(tag, target)
|
|
continue
|
|
found = parse_electrical(rec.text)
|
|
if found["kw"] is None and not (found["phases"] or found["voltage"] or found["vfd"]):
|
|
continue
|
|
tag = _nearest_tag(at, pid_places, pid_search)
|
|
if tag is None:
|
|
orphans.append(rec.handle)
|
|
continue
|
|
distance = min(math.dist(at, p) for p in pid_places[tag])
|
|
loads.setdefault(tag, []).append((distance, rec.handle, found))
|
|
|
|
positions: dict[str, Pt] = {}
|
|
ambiguous: set[str] = set()
|
|
panels: dict[str, tuple[str, str]] = {}
|
|
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
|
|
layout_search = None
|
|
if layout is not None:
|
|
placed = layout_tags(layout, set(loads) | set(pid_callouts), prep=prep_layout)
|
|
positions, ambiguous, cluster = (
|
|
placed["positions"],
|
|
set(placed["ambiguous"]),
|
|
placed["cluster"],
|
|
)
|
|
layout_search = CABLE_SEARCH_SHARE * label_search_default(layout)
|
|
layout_places = {tag: [p] for tag, p in positions.items()}
|
|
for rec in layout.records:
|
|
if rec.space != MODEL or rec.type not in TEXT_TYPES or not rec.text or not rec.points:
|
|
continue
|
|
target = wiring_target(rec.text)
|
|
if target is None or not _inside_box(rec.points[0], cluster):
|
|
continue
|
|
tag = _nearest_tag(rec.points[0], layout_places, layout_search)
|
|
if tag is not None:
|
|
panels.setdefault(tag, (target, "layout"))
|
|
for tag, target in pid_callouts.items():
|
|
panels.setdefault(tag, (target, "pid"))
|
|
panel_at = {}
|
|
for tag, p in positions.items():
|
|
name = normalize_panel(tag)
|
|
if name is not None:
|
|
panel_at[name] = p
|
|
regions = (
|
|
room_regions(layout, cluster, prep=prep_layout) if layout is not None else room_regions(pid)
|
|
)
|
|
|
|
# the panels a callout names anywhere, besides every tag with a panel prefix
|
|
named = {target for target, _source in panels.values()}
|
|
rows, items = [], []
|
|
for tag in sorted(set(loads) | set(panels)):
|
|
texts = sorted(loads.get(tag, []), key=lambda item: item[:2])
|
|
powers = [(d, f["kw"]) for d, _h, f in texts if f["kw"] is not None]
|
|
kw = powers[0][1] if powers else None
|
|
if len({value for _d, value in powers}) > 1:
|
|
items.append(
|
|
{
|
|
"tag": tag,
|
|
"item": "conflicting_power",
|
|
"detail": ", ".join(f"{value:g} kW" for _d, value in powers),
|
|
}
|
|
)
|
|
phases = next((f["phases"] for _d, _h, f in texts if f["phases"]), None)
|
|
voltage = next((f["voltage"] for _d, _h, f in texts if f["voltage"]), None)
|
|
panel, panel_source = panels.get(tag, (None, None))
|
|
inferred = None
|
|
if panel is None and panel_rule == "nearest" and layout is not None:
|
|
here_now = positions.get(tag)
|
|
if here_now is not None:
|
|
candidates = {
|
|
name: p for name, p in panel_at.items() if name in named or is_panel_name(name)
|
|
}
|
|
inferred = _nearest_panel(here_now, candidates, exclude=normalize_panel(tag))
|
|
if inferred is not None:
|
|
panel, panel_source = inferred[0], "nearest"
|
|
row = {
|
|
"tag": tag,
|
|
"room": "",
|
|
"at": None,
|
|
"kw": kw,
|
|
"phases": phases,
|
|
"voltage": voltage,
|
|
"neutral": any(f["neutral"] for _d, _h, f in texts),
|
|
"vfd": any(f["vfd"] for _d, _h, f in texts),
|
|
"panel": panel,
|
|
"panel_source": panel_source,
|
|
"length_m": None,
|
|
"allowance": allowance,
|
|
"cable_m": None,
|
|
"section": section_for(kw, phases, rules),
|
|
"package": None,
|
|
}
|
|
if kw is None:
|
|
items.append(
|
|
{
|
|
"tag": tag,
|
|
"item": "missing_power",
|
|
"detail": "no power is stated near the tag on the P&ID",
|
|
}
|
|
)
|
|
if panel is None:
|
|
items.append(
|
|
{
|
|
"tag": tag,
|
|
"item": "missing_panel",
|
|
"detail": "no wiring callout names a panel for this tag",
|
|
}
|
|
)
|
|
elif panel_source == "nearest":
|
|
items.append(
|
|
{
|
|
"tag": tag,
|
|
"item": "panel_inferred",
|
|
"detail": "no wiring callout names a panel; "
|
|
f"{panel} is the nearest panel on the layout, "
|
|
f"{inferred[1] * layout_unit['m_per_unit']:.1f} m away",
|
|
}
|
|
)
|
|
if rules and kw is not None and row["section"] is None:
|
|
items.append(
|
|
{"tag": tag, "item": "no_section_rule", "detail": f"no rule covers {kw:g} kW"}
|
|
)
|
|
if layout is None:
|
|
items.append(
|
|
{
|
|
"tag": tag,
|
|
"item": "no_layout",
|
|
"detail": "no layout given: the P&ID is not a length source for cables",
|
|
}
|
|
)
|
|
if pid_places.get(tag):
|
|
row["room"] = room_at(regions, pid_places[tag][0])
|
|
else:
|
|
here = positions.get(tag)
|
|
if here is None:
|
|
items.append(
|
|
{
|
|
"tag": tag,
|
|
"item": "tag_ambiguous" if tag in ambiguous else "tag_not_on_layout",
|
|
"detail": "the load's tag cannot be placed on the layout",
|
|
}
|
|
)
|
|
else:
|
|
row["at"] = here
|
|
row["room"] = room_at(regions, here)
|
|
if panel is not None and panel not in panel_at:
|
|
items.append(
|
|
{
|
|
"tag": tag,
|
|
"item": "panel_not_on_layout",
|
|
"detail": f"{panel} is not written once on the layout",
|
|
}
|
|
)
|
|
if here is not None and panel in panel_at:
|
|
row["length_m"] = manhattan(here, panel_at[panel]) * layout_unit["m_per_unit"]
|
|
row["cable_m"] = roundup_m(row["length_m"], allowance)
|
|
rows.append(row)
|
|
|
|
stated = {
|
|
(normalize_panel(r["tag"]) or r["tag"]): r["tag"] for r in rows if r["kw"] is not None
|
|
}
|
|
for row in rows:
|
|
# only a panel the drawing names makes a package; a nearest guess does not
|
|
known = row["panel"] and row["panel_source"] != "nearest"
|
|
owner = stated.get(row["panel"]) if known else None
|
|
if owner is not None and owner != row["tag"]:
|
|
row["package"] = owner
|
|
|
|
def counted(row: dict) -> float:
|
|
return row["kw"] if row["kw"] is not None and row["package"] is None else 0.0
|
|
|
|
summary = {}
|
|
for name, field in (("by_room", "room"), ("by_panel", "panel")):
|
|
summary[name] = [
|
|
{
|
|
"key": key,
|
|
"loads": sum(1 for r in rows if (r[field] or "") == key),
|
|
"kw": sum(counted(r) for r in rows if (r[field] or "") == key),
|
|
"cable_m": sum(r["cable_m"] or 0 for r in rows if (r[field] or "") == key),
|
|
}
|
|
for key in sorted({row[field] or "" for row in rows})
|
|
]
|
|
units = {"pid": unit_of(pid), "layout": layout_unit}
|
|
warnings = [unit["warning"] for unit in units.values() if unit and unit["warning"]]
|
|
if orphans:
|
|
warnings.append(f"electrical text(s) near no tag, not used: {', '.join(orphans)}")
|
|
return {
|
|
"kind": "cable",
|
|
"rows": rows,
|
|
"open_items": items,
|
|
"summary": summary,
|
|
"totals": {
|
|
"loads": len(rows),
|
|
"known_kw": sum(counted(r) for r in rows),
|
|
"cable_m": sum(r["cable_m"] or 0 for r in rows),
|
|
"open_items": len(items),
|
|
"packages": sum(1 for r in rows if r["package"]),
|
|
},
|
|
"units": units,
|
|
"warnings": warnings,
|
|
"method": {
|
|
"sources": {"pid": pid.source, "layout": layout.source if layout else None},
|
|
"allowance": allowance,
|
|
"search": {"pid": pid_search, "layout": layout_search},
|
|
"section_rules": [dict(rule) for rule in rules],
|
|
"panel_rule": panel_rule,
|
|
"rooms": _rooms_mode(regions),
|
|
},
|
|
}
|