merge: track H group N
The merge agent stopped this branch on an add/add conflict it had not been told to expect: groups U and N each created tests/test_understand_tools.py. Resolved by hand: - tests/test_understand_tools.py stays U's (the drawing_understand wire tests), and N's takeoff wire tests move whole to tests/test_understand_takeoff_tools.py. Each file keeps its own client fixture: U's opens a fresh document, N's reads files only. - server.py: SECTION 26 (4 tools, U then D) is kept as merged, and SECTION 27 (Plant Takeoffs, 2 tools) follows it whole, above the RESOURCES banner. - discovery/aliases.py: additive; the two takeoff records follow the four readers. - tests/test_tool_registry.py: all three branches' paragraphs are kept. The snapshot is recomputed from the live registry: analysis 18, 247 tools. Task 11 ranks `understand` and `plant`. - docs/tool-inventory.json regenerated (247 tools, 26 groups); README counts are 247 registered / 242 advertised. The collected-tests figure is left to Task 11. ruff check passes. Suite: 4919 passed, 1 xfailed. The one deselected test is the collected-tests figure. 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:
@@ -0,0 +1,119 @@
|
||||
"""The async layer of track H's takeoffs: read the drawings, run the pure engine, write the workbook.
|
||||
|
||||
The only module of the takeoffs that touches a backend, and it only reads:
|
||||
`take_snapshot` gives one bulk read of each drawing (a file, or the current
|
||||
document), the pure modules compute in a worker thread so a 35,000-entity
|
||||
P&ID does not stall the server, and the workbook is a file beside the path the
|
||||
caller names. The drawings themselves are never modified.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
|
||||
from engineering.understand.network import build_network
|
||||
from engineering.understand.report import check_lang, write_workbook
|
||||
from engineering.understand.snapshot import take_snapshot
|
||||
from engineering.understand.takeoff import (
|
||||
cable_rows,
|
||||
check_allowance,
|
||||
check_pipe_options,
|
||||
check_section_rules,
|
||||
pipe_rows,
|
||||
)
|
||||
|
||||
__all__ = ["run_cable_takeoff", "run_pipe_takeoff"]
|
||||
|
||||
|
||||
def _check_tol(tol) -> float:
|
||||
try:
|
||||
value = float(tol)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f"tol: expected a number, got {tol!r}") from None
|
||||
if not math.isfinite(value) or value <= 0.0:
|
||||
raise ValueError(f"tol: must be a finite number greater than zero, got {tol!r}")
|
||||
return value
|
||||
|
||||
|
||||
async def run_pipe_takeoff(
|
||||
backend,
|
||||
*,
|
||||
pid_path: str | None = None,
|
||||
layout_path: str | None = None,
|
||||
output: str | None = None,
|
||||
lang: str = "en",
|
||||
services=None,
|
||||
allowance: float = 0.20,
|
||||
vertical_allowance: float = 0.0,
|
||||
length_source: str = "auto",
|
||||
force: bool = False,
|
||||
layers=None,
|
||||
tol: float = 1.0,
|
||||
) -> dict:
|
||||
"""`pipe_rows` over a P&ID and an optional layout, plus the workbook when `output` is named.
|
||||
|
||||
Every option is checked before a drawing is read. `pid_path=None` reads the
|
||||
current document as the P&ID. The result is `pipe_rows`'s dict with
|
||||
``files`` added: `write_workbook`'s answer, or None when no `output` was named.
|
||||
"""
|
||||
check_lang(lang)
|
||||
options = check_pipe_options(
|
||||
services=services,
|
||||
allowance=allowance,
|
||||
vertical_allowance=vertical_allowance,
|
||||
length_source=length_source,
|
||||
)
|
||||
tol = _check_tol(tol)
|
||||
if length_source == "layout" and not layout_path:
|
||||
raise ValueError("length_source='layout' needs a layout drawing; none was given")
|
||||
pid = await take_snapshot(backend, pid_path)
|
||||
layout = await take_snapshot(backend, layout_path) if layout_path else None
|
||||
|
||||
def compute() -> dict:
|
||||
network = build_network(pid, layers=layers, tol=tol)
|
||||
return pipe_rows(
|
||||
network,
|
||||
pid,
|
||||
layout,
|
||||
services=options["services"],
|
||||
allowance=options["allowance"],
|
||||
vertical_allowance=options["vertical_allowance"],
|
||||
length_source=options["length_source"],
|
||||
force=bool(force),
|
||||
)
|
||||
|
||||
result = await asyncio.to_thread(compute)
|
||||
result["files"] = (
|
||||
await asyncio.to_thread(write_workbook, result, kind="pipe", lang=lang, path=output)
|
||||
if output
|
||||
else None
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def run_cable_takeoff(
|
||||
backend,
|
||||
*,
|
||||
pid_path: str | None = None,
|
||||
layout_path: str | None = None,
|
||||
output: str | None = None,
|
||||
lang: str = "en",
|
||||
allowance: float = 0.20,
|
||||
section_rules=None,
|
||||
) -> dict:
|
||||
"""`cable_rows` over a P&ID and an optional layout, plus the workbook when `output` is named."""
|
||||
check_lang(lang)
|
||||
allowance = check_allowance(allowance)
|
||||
rules = check_section_rules(section_rules)
|
||||
pid = await take_snapshot(backend, pid_path)
|
||||
layout = await take_snapshot(backend, layout_path) if layout_path else None
|
||||
result = await asyncio.to_thread(
|
||||
cable_rows, pid, layout, allowance=allowance, section_rules=list(rules) or None
|
||||
)
|
||||
result["files"] = (
|
||||
await asyncio.to_thread(write_workbook, result, kind="cable", lang=lang, path=output)
|
||||
if output
|
||||
else None
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,967 @@
|
||||
"""Line networks read off a foreign P&ID: runs, services, diameters, equipment.
|
||||
|
||||
`engineering.pid.graph` reads a P&ID this server drew; on a foreign one it sees
|
||||
lines of class `unknown` between blocks of kind `unknown_block` (spec §1). This
|
||||
module is the reader the takeoffs stand on (spec §7). It works on a
|
||||
`Snapshot` - one bulk read - and never touches a drawing.
|
||||
|
||||
The algorithm, in the order it runs:
|
||||
|
||||
1. **Segments.** LINE, LWPOLYLINE, POLYLINE and ARC records in model space on
|
||||
the chosen layers (default: every layer `classify_layer` files under piping
|
||||
or electrical with confidence >= 0.9). A bulged polyline segment and an ARC
|
||||
stay one arc piece: never split, never flattened.
|
||||
2. **Merge.** Endpoints closer than `tol` become one vertex; the first one seen
|
||||
keeps its coordinates.
|
||||
3. **Split.** A straight segment is split at every vertex lying on it within
|
||||
`tol` - the T-junction, where a branch ends on a header. A crossing where
|
||||
neither line ends is *not* a junction: two P&ID lines that cross without a
|
||||
dot do not connect.
|
||||
4. **Duplicates once.** The same split turns a line drawn twice, or a line
|
||||
drawn over a polyline, into identical pieces; each piece is kept once and
|
||||
the length the copies would have added is `stats.overlap_length`.
|
||||
5. **Fittings.** An INSERT whose box (grown by `tol`) holds two to four free
|
||||
ends is an inline fitting - valve, coupling, reducer - and every one of
|
||||
those ends is joined to every other through it (a three- or four-way
|
||||
fitting has no hub port, so nothing depends on record order); the fitting
|
||||
adds no length. An INSERT that a tag labels is equipment and never a
|
||||
fitting. A block whose name contains reducer / переход / redüksiyon /
|
||||
verloop / Reduzier(stück) is a reducer, and a reducer is found however the
|
||||
pipe meets it: free ends in its box, pieces meeting at a vertex inside its
|
||||
box (a line broken at the reducer), or a straight segment drawn through
|
||||
the middle of its box (its line within a quarter of the box's smaller side
|
||||
of the centre), which is cut at the foot of the centre before step 3.
|
||||
6. **Runs.** A connected component of pieces and fittings is a run.
|
||||
7. **Diameter.** Each diameter label goes to the piece nearest to it within
|
||||
`label_search` (default 5 x the median text height) - unless an open line
|
||||
on a layer that is not read stands strictly nearer, in which case the label
|
||||
is that line's and is listed in `stats.labels_skipped` - and holds for
|
||||
every piece of that drawn entity on the same side of every reducer
|
||||
(*direct*; when two labels reach one piece, the nearer wins and the pair is
|
||||
listed in `stats.diameter_conflicts`). An unlabelled piece takes the
|
||||
diameter of the nearest direct piece along the run, measured along the
|
||||
pipe and never across a reducer (*continuity*); two different diameters
|
||||
equally near, or none, leave it *unassigned*. Labels are kept as drawn:
|
||||
Ø51, SMS51 and DN20 are different sizes.
|
||||
8. **Service.** A run's service is the length-weighted service of its layers,
|
||||
overridden for the whole run by a supply / return word within
|
||||
`label_search` of one of its pieces - a layer alone never splits CIP supply
|
||||
from CIP return. A CIP or glycol layer that names no direction (which
|
||||
`classify_layer` files as ``unknown``) takes its direction from that word.
|
||||
9. **Equipment.** A run end inside the box (grown by `tol`) of the geometry a
|
||||
tag labels is attached to that tag; the smallest such box wins. A tag labels
|
||||
the smallest closed shape holding it, else the nearest within twice
|
||||
`label_search`; a shape holding two different tags (a room, a skid, a sheet
|
||||
frame) is never equipment.
|
||||
|
||||
Arc pieces are measured to their chord for the label search only; their length
|
||||
is the true arc length. Every "which X lies near Y" question (free ends in a
|
||||
fitting's box, shapes around a tag, tags inside a shape, equipment around a run
|
||||
end) goes through a grid index, so a field-sized drawing is read in seconds,
|
||||
not minutes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import heapq
|
||||
import math
|
||||
import statistics
|
||||
from bisect import bisect_left, bisect_right
|
||||
from dataclasses import dataclass
|
||||
|
||||
from engineering.understand.labels import parse_diameters, parse_tag, wiring_target
|
||||
from engineering.understand.snapshot import EntityRecord, Pt, Snapshot
|
||||
from engineering.understand.vocab import classify_layer, fold, supply_return
|
||||
|
||||
__all__ = [
|
||||
"EQUIPMENT_SEARCH_SHARE",
|
||||
"LABEL_SEARCH_FACTOR",
|
||||
"LINE_TYPES",
|
||||
"MAX_FITTING_ENDS",
|
||||
"NETWORK_CONFIDENCE",
|
||||
"REDUCER_WORDS",
|
||||
"Run",
|
||||
"build_network",
|
||||
"default_layers",
|
||||
"equipment_boxes",
|
||||
"is_reducer",
|
||||
"label_search_default",
|
||||
"tag_occurrences",
|
||||
]
|
||||
|
||||
MODEL = "Model"
|
||||
LINE_TYPES = frozenset({"LINE", "LWPOLYLINE", "POLYLINE", "ARC"})
|
||||
TEXT_TYPES = frozenset({"TEXT", "MTEXT", "MULTILEADER"})
|
||||
#: Words that make an INSERT a reducer, matched in its block name folded by
|
||||
#: `vocab.fold` (casefolded, diacritics stripped: REDÜKSİYON reads reduksiyon).
|
||||
#: The same five words `vocab.equipment_kind` files under ``reducer``.
|
||||
REDUCER_WORDS = ("reducer", "переход", "reduksiyon", "verloop", "reduzier")
|
||||
NETWORK_DISCIPLINES = ("piping", "electrical")
|
||||
NETWORK_CONFIDENCE = 0.9
|
||||
#: A box touched by more free ends than this is not an inline fitting (a
|
||||
#: valve has two ports, a three- or four-way valve three or four).
|
||||
MAX_FITTING_ENDS = 4
|
||||
LABEL_SEARCH_FACTOR = 5.0
|
||||
#: A tag written beside its symbol is looked for twice as far as a label.
|
||||
EQUIPMENT_SEARCH_SHARE = 2.0
|
||||
#: With no text on the drawing at all, labels are searched this many `tol` away.
|
||||
LABEL_SEARCH_FALLBACK = 50.0
|
||||
_PAIRS = {"cip": ("cip_supply", "cip_return"), "glycol": ("glycol_supply", "glycol_return")}
|
||||
_EPS = 1e-9
|
||||
#: A box covering more grid cells than this is kept aside and checked on every query.
|
||||
_WIDE_CELLS = 256
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Run:
|
||||
"""One connected line network: its pieces, its service, its free ends and the tags on them."""
|
||||
|
||||
id: str
|
||||
edges: tuple[dict, ...]
|
||||
service: str
|
||||
ends: tuple[Pt, ...]
|
||||
tags: tuple[str, ...]
|
||||
|
||||
|
||||
def _positive(value, name: str) -> float:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f"{name}: expected a number, got {value!r}") from None
|
||||
if not math.isfinite(number) or number <= 0.0:
|
||||
raise ValueError(f"{name}: must be a finite number greater than zero, got {value!r}")
|
||||
return number
|
||||
|
||||
|
||||
def _in_box(p: Pt, box, grow: float) -> bool:
|
||||
return box[0] - grow <= p[0] <= box[2] + grow and box[1] - grow <= p[1] <= box[3] + grow
|
||||
|
||||
|
||||
def _box_area(box) -> float:
|
||||
return (box[2] - box[0]) * (box[3] - box[1])
|
||||
|
||||
|
||||
def _box_distance(p: Pt, box) -> float:
|
||||
dx = max(box[0] - p[0], 0.0, p[0] - box[2])
|
||||
dy = max(box[1] - p[1], 0.0, p[1] - box[3])
|
||||
return math.hypot(dx, dy)
|
||||
|
||||
|
||||
def _centre(rec: EntityRecord) -> Pt | None:
|
||||
"""Where a label stands: its box centre, else its insertion point."""
|
||||
if rec.bbox is not None:
|
||||
return ((rec.bbox[0] + rec.bbox[2]) / 2.0, (rec.bbox[1] + rec.bbox[3]) / 2.0)
|
||||
return rec.points[0] if rec.points else None
|
||||
|
||||
|
||||
def _segment_distance(p: Pt, a: Pt, b: Pt) -> float:
|
||||
dx, dy = b[0] - a[0], b[1] - a[1]
|
||||
length2 = dx * dx + dy * dy
|
||||
if length2 == 0.0:
|
||||
return math.dist(p, a)
|
||||
t = max(0.0, min(1.0, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / length2))
|
||||
return math.dist(p, (a[0] + t * dx, a[1] + t * dy))
|
||||
|
||||
|
||||
def _along(p: Pt, a: Pt, b: Pt) -> tuple[float, float]:
|
||||
"""(distance along a->b of p's foot, distance of p from the line)."""
|
||||
dx, dy = b[0] - a[0], b[1] - a[1]
|
||||
length = math.hypot(dx, dy)
|
||||
ux, uy = dx / length, dy / length
|
||||
px, py = p[0] - a[0], p[1] - a[1]
|
||||
return (px * ux + py * uy, abs(ux * py - uy * px))
|
||||
|
||||
|
||||
def _piece_length(a: Pt, b: Pt, bulge: float) -> float:
|
||||
chord = math.dist(a, b)
|
||||
if bulge == 0.0 or chord == 0.0:
|
||||
return chord
|
||||
theta = 4.0 * math.atan(abs(bulge))
|
||||
return chord / (2.0 * math.sin(theta / 2.0)) * theta
|
||||
|
||||
|
||||
def is_reducer(block: str | None) -> bool:
|
||||
"""True when the folded block name contains a reducer word (`REDUCER_WORDS`)."""
|
||||
if not block:
|
||||
return False
|
||||
name = fold(block)
|
||||
return any(word in name for word in REDUCER_WORDS)
|
||||
|
||||
|
||||
def _typical_size(boxes, fallback: float) -> float:
|
||||
"""The median larger side of `boxes`, never below `fallback`: a grid cell size."""
|
||||
sides = [max(box[2] - box[0], box[3] - box[1]) for box in boxes]
|
||||
return max(statistics.median(sides) if sides else 0.0, fallback, _EPS)
|
||||
|
||||
|
||||
def _cell_range(box, grow: float, cell: float) -> tuple[int, int, int, int]:
|
||||
return (
|
||||
math.floor((box[0] - grow) / cell),
|
||||
math.floor((box[1] - grow) / cell),
|
||||
math.floor((box[2] + grow) / cell),
|
||||
math.floor((box[3] + grow) / cell),
|
||||
)
|
||||
|
||||
|
||||
class _PointGrid:
|
||||
"""Points bucketed on a square grid, for 'which points lie in this box' queries."""
|
||||
|
||||
def __init__(self, items, cell: float) -> None:
|
||||
self.cell = cell
|
||||
self.items: list[tuple[object, Pt]] = list(items)
|
||||
self.cells: dict[tuple[int, int], list[int]] = {}
|
||||
for n, (_key, p) in enumerate(self.items):
|
||||
key = (math.floor(p[0] / cell), math.floor(p[1] / cell))
|
||||
self.cells.setdefault(key, []).append(n)
|
||||
|
||||
def in_box(self, box, grow: float) -> list:
|
||||
"""The keys of the points inside `box` grown by `grow`, in insertion order."""
|
||||
x0, y0, x1, y1 = _cell_range(box, grow, self.cell)
|
||||
if (x1 - x0 + 1) * (y1 - y0 + 1) > len(self.items):
|
||||
found = range(len(self.items))
|
||||
else:
|
||||
found = sorted(
|
||||
n
|
||||
for cx in range(x0, x1 + 1)
|
||||
for cy in range(y0, y1 + 1)
|
||||
for n in self.cells.get((cx, cy), ())
|
||||
)
|
||||
return [self.items[n][0] for n in found if _in_box(self.items[n][1], box, grow)]
|
||||
|
||||
|
||||
class _BoxGrid:
|
||||
"""Boxes bucketed on a square grid, for 'which boxes hold, or lie within `grow` of, p'.
|
||||
|
||||
A box is filed in every cell its box grown by `grow` covers, so a point
|
||||
inside that grown box finds it in the point's own cell; a box covering more
|
||||
than `_WIDE_CELLS` cells (a sheet frame, a room) is kept aside and returned
|
||||
by every query. Callers filter the candidates exactly.
|
||||
"""
|
||||
|
||||
def __init__(self, items, *, grow: float, cell: float) -> None:
|
||||
self.cell = cell
|
||||
self.cells: dict[tuple[int, int], list] = {}
|
||||
self.wide: list = []
|
||||
for box, payload in items:
|
||||
x0, y0, x1, y1 = _cell_range(box, grow, cell)
|
||||
if (x1 - x0 + 1) * (y1 - y0 + 1) > _WIDE_CELLS:
|
||||
self.wide.append(payload)
|
||||
continue
|
||||
for cx in range(x0, x1 + 1):
|
||||
for cy in range(y0, y1 + 1):
|
||||
self.cells.setdefault((cx, cy), []).append(payload)
|
||||
|
||||
def near(self, p: Pt) -> list:
|
||||
key = (math.floor(p[0] / self.cell), math.floor(p[1] / self.cell))
|
||||
return self.cells.get(key, []) + self.wide
|
||||
|
||||
|
||||
def _layer_names(snap: Snapshot) -> list[str]:
|
||||
return sorted(set(snap.layers) | {rec.layer for rec in snap.records})
|
||||
|
||||
|
||||
def default_layers(snap: Snapshot) -> tuple[str, ...]:
|
||||
"""Every layer `classify_layer` files under piping or electrical with confidence >= 0.9."""
|
||||
chosen = []
|
||||
for name in _layer_names(snap):
|
||||
found = classify_layer(name)
|
||||
if found["discipline"] in NETWORK_DISCIPLINES and found["confidence"] >= NETWORK_CONFIDENCE:
|
||||
chosen.append(name)
|
||||
return tuple(chosen)
|
||||
|
||||
|
||||
def label_search_default(snap: Snapshot, *, tol: float = 1.0) -> float:
|
||||
"""5 x the median model-space text height; 50 x `tol` on a drawing with no text."""
|
||||
heights = [
|
||||
rec.height
|
||||
for rec in snap.records
|
||||
if rec.space == MODEL and rec.type in TEXT_TYPES and rec.height and rec.height > 0.0
|
||||
]
|
||||
if heights:
|
||||
return LABEL_SEARCH_FACTOR * statistics.median(heights)
|
||||
return LABEL_SEARCH_FALLBACK * tol
|
||||
|
||||
|
||||
def tag_occurrences(snap: Snapshot) -> dict[str, list[dict]]:
|
||||
"""Every model-space place a tag is written, by tag.
|
||||
|
||||
A TEXT / MTEXT / MULTILEADER whose plain text `parse_tag` reads - unless it
|
||||
is a diameter label or a wiring callout - and an INSERT attribute whose tag
|
||||
name contains TAG. ``at`` is the insertion point; ``insert`` is the handle
|
||||
of the INSERT that carries the tag, or None for a text.
|
||||
"""
|
||||
found: dict[str, list[dict]] = {}
|
||||
for rec in snap.records:
|
||||
if rec.space != MODEL or not rec.points:
|
||||
continue
|
||||
if rec.type in TEXT_TYPES and rec.text:
|
||||
if parse_diameters(rec.text) or wiring_target(rec.text):
|
||||
continue
|
||||
tag = parse_tag(rec.text)
|
||||
if tag:
|
||||
found.setdefault(tag, []).append(
|
||||
{
|
||||
"tag": tag,
|
||||
"at": rec.points[0],
|
||||
"handle": rec.handle,
|
||||
"box": rec.bbox,
|
||||
"insert": None,
|
||||
}
|
||||
)
|
||||
elif rec.type == "INSERT":
|
||||
for name, value in rec.attribs:
|
||||
tag = parse_tag(value) if "TAG" in name.upper() else None
|
||||
if tag:
|
||||
found.setdefault(tag, []).append(
|
||||
{
|
||||
"tag": tag,
|
||||
"at": rec.points[0],
|
||||
"handle": rec.handle,
|
||||
"box": rec.bbox,
|
||||
"insert": rec.handle,
|
||||
}
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
def _is_shape(rec: EntityRecord) -> bool:
|
||||
if rec.type in ("CIRCLE", "ELLIPSE", "INSERT"):
|
||||
return True
|
||||
return rec.type in ("LWPOLYLINE", "POLYLINE") and rec.closed
|
||||
|
||||
|
||||
def equipment_boxes(
|
||||
snap: Snapshot, occurrences: dict[str, list[dict]], *, exclude_layers=(), search: float
|
||||
) -> dict[str, list[dict]]:
|
||||
"""The box of the geometry each tag occurrence labels.
|
||||
|
||||
An INSERT carrying the tag is its own geometry. A text labels the smallest
|
||||
closed shape (CIRCLE, ELLIPSE, closed polyline, INSERT) off the network
|
||||
layers whose box holds its insertion point, else the nearest one within
|
||||
`search`, else only itself (its own box, or its insertion point). A shape
|
||||
whose box holds the tags of two or more different items is a room, a skid
|
||||
or a sheet frame - not equipment - and is never chosen.
|
||||
"""
|
||||
excluded = set(exclude_layers)
|
||||
candidates = [
|
||||
rec
|
||||
for rec in snap.records
|
||||
if rec.space == MODEL
|
||||
and rec.bbox is not None
|
||||
and rec.layer not in excluded
|
||||
and _is_shape(rec)
|
||||
]
|
||||
cell = _typical_size([rec.bbox for rec in candidates], search)
|
||||
anchors = _PointGrid(
|
||||
((tag, place["at"]) for tag, places in occurrences.items() for place in places), cell
|
||||
)
|
||||
shapes = [rec for rec in candidates if len(set(anchors.in_box(rec.bbox, 0.0))) <= 1]
|
||||
index = _BoxGrid(((rec.bbox, rec) for rec in shapes), grow=search, cell=cell)
|
||||
out: dict[str, list[dict]] = {}
|
||||
for tag, places in sorted(occurrences.items()):
|
||||
for place in places:
|
||||
x, y = place["at"]
|
||||
if place["insert"] is not None and place["box"] is not None:
|
||||
box, handle = place["box"], place["insert"]
|
||||
else:
|
||||
pool = index.near((x, y))
|
||||
inside = [s for s in pool if _in_box((x, y), s.bbox, 0.0)]
|
||||
near = [
|
||||
(_box_distance((x, y), s.bbox), _box_area(s.bbox), s.handle, s) for s in pool
|
||||
]
|
||||
near = [n for n in near if n[0] <= search]
|
||||
if inside:
|
||||
best = min(inside, key=lambda s: (_box_area(s.bbox), s.handle))
|
||||
box, handle = best.bbox, best.handle
|
||||
elif near:
|
||||
best = min(near, key=lambda n: n[:3])[3]
|
||||
box, handle = best.bbox, best.handle
|
||||
else:
|
||||
box, handle = place["box"] or (x, y, x, y), place["handle"]
|
||||
out.setdefault(tag, []).append({"box": box, "handle": handle, "at": (x, y)})
|
||||
return out
|
||||
|
||||
|
||||
class _Vertices:
|
||||
"""Snap points to vertices: a point within ``tol`` of a vertex *is* that vertex."""
|
||||
|
||||
def __init__(self, tol: float) -> None:
|
||||
self.tol = tol
|
||||
self.points: list[Pt] = []
|
||||
self._grid: dict[tuple[int, int], list[int]] = {}
|
||||
|
||||
def index(self, p: Pt) -> int:
|
||||
cx, cy = math.floor(p[0] / self.tol), math.floor(p[1] / self.tol)
|
||||
best, best_d = None, self.tol
|
||||
for dx in (-1, 0, 1):
|
||||
for dy in (-1, 0, 1):
|
||||
for i in self._grid.get((cx + dx, cy + dy), ()):
|
||||
d = math.dist(self.points[i], p)
|
||||
if d <= best_d:
|
||||
best, best_d = i, d
|
||||
if best is not None:
|
||||
return best
|
||||
self.points.append((float(p[0]), float(p[1])))
|
||||
self._grid.setdefault((cx, cy), []).append(len(self.points) - 1)
|
||||
return len(self.points) - 1
|
||||
|
||||
|
||||
def _choose_layers(snap: Snapshot, layers) -> tuple[str, ...]:
|
||||
known = _layer_names(snap)
|
||||
if layers is None:
|
||||
chosen = default_layers(snap)
|
||||
if not chosen:
|
||||
raise ValueError(
|
||||
"layers: no layer is classified piping or electrical with confidence >= 0.9; "
|
||||
f"name the layers to read. The drawing has: {', '.join(known)}"
|
||||
)
|
||||
return chosen
|
||||
names = [str(name) for name in layers]
|
||||
if not names:
|
||||
raise ValueError("layers: an empty list reads nothing; omit it for the default")
|
||||
missing = [name for name in names if name not in known]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"layers: {', '.join(missing)} not in the drawing; it has: {', '.join(known)}"
|
||||
)
|
||||
return tuple(dict.fromkeys(names))
|
||||
|
||||
|
||||
def _arc_segment(rec: EntityRecord):
|
||||
if not rec.points or rec.radius is None or rec.angles is None:
|
||||
return None
|
||||
(cx, cy), r = rec.points[0], rec.radius
|
||||
start, end = rec.angles
|
||||
sweep = (end - start) % 360.0
|
||||
if sweep == 0.0:
|
||||
return None
|
||||
a = (cx + r * math.cos(math.radians(start)), cy + r * math.sin(math.radians(start)))
|
||||
b = (cx + r * math.cos(math.radians(end)), cy + r * math.sin(math.radians(end)))
|
||||
return a, b, math.tan(math.radians(sweep) / 4.0)
|
||||
|
||||
|
||||
def _segments(snap: Snapshot, layers, *, open_only=False) -> list[tuple[Pt, Pt, float, str, str]]:
|
||||
"""Every LINE_TYPES segment in model space on `layers`; `open_only` skips closed polylines."""
|
||||
wanted = set(layers)
|
||||
out = []
|
||||
for rec in snap.records:
|
||||
if rec.space != MODEL or rec.layer not in wanted or rec.type not in LINE_TYPES:
|
||||
continue
|
||||
if open_only and rec.closed and rec.type in ("LWPOLYLINE", "POLYLINE"):
|
||||
continue
|
||||
if rec.type == "LINE":
|
||||
if len(rec.points) >= 2:
|
||||
out.append((rec.points[0], rec.points[1], 0.0, rec.handle, rec.layer))
|
||||
elif rec.type == "ARC":
|
||||
arc = _arc_segment(rec)
|
||||
if arc is not None:
|
||||
out.append((arc[0], arc[1], arc[2], rec.handle, rec.layer))
|
||||
else:
|
||||
pts = list(rec.points)
|
||||
bulges = list(rec.bulges) + [0.0] * (len(pts) - len(rec.bulges))
|
||||
count = len(pts) if rec.closed else len(pts) - 1
|
||||
for i in range(max(count, 0)):
|
||||
out.append(
|
||||
(pts[i], pts[(i + 1) % len(pts)], float(bulges[i]), rec.handle, rec.layer)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
class _ChordIndex:
|
||||
"""Grid of chords (a, b) for 'which chords lie within `search` of p, or cross this box'."""
|
||||
|
||||
def __init__(self, chords: list[tuple[Pt, Pt]], search: float) -> None:
|
||||
lengths = [math.dist(a, b) for a, b in chords] or [search]
|
||||
self.cell = max(search, statistics.median(lengths), _EPS)
|
||||
self.search = search
|
||||
self.cells: dict[tuple[int, int], list[int]] = {}
|
||||
for k, (a, b) in enumerate(chords):
|
||||
box = (min(a[0], b[0]), min(a[1], b[1]), max(a[0], b[0]), max(a[1], b[1]))
|
||||
x0, y0, x1, y1 = _cell_range(box, search, self.cell)
|
||||
for cx in range(x0, x1 + 1):
|
||||
for cy in range(y0, y1 + 1):
|
||||
self.cells.setdefault((cx, cy), []).append(k)
|
||||
|
||||
def candidates(self, p: Pt) -> list[int]:
|
||||
return self.cells.get((math.floor(p[0] / self.cell), math.floor(p[1] / self.cell)), [])
|
||||
|
||||
def in_box(self, box) -> list[int]:
|
||||
"""The chords filed in any cell `box` covers, each once, in index order."""
|
||||
x0, y0, x1, y1 = _cell_range(box, 0.0, self.cell)
|
||||
return sorted(
|
||||
{
|
||||
k
|
||||
for cx in range(x0, x1 + 1)
|
||||
for cy in range(y0, y1 + 1)
|
||||
for k in self.cells.get((cx, cy), ())
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _through_middle(a: Pt, b: Pt, box, tol: float) -> Pt | None:
|
||||
"""Where a straight segment passing through the middle of `box` is cut, else None.
|
||||
|
||||
The middle: the segment's line runs within a quarter of the box's smaller
|
||||
side (plus `tol`) of the box centre - a pipe drawn through a symbol, not a
|
||||
parallel pipe grazing its edge. The cut is the foot of the centre on the
|
||||
segment, which must lie strictly between the segment's ends.
|
||||
"""
|
||||
length = math.dist(a, b)
|
||||
if length <= _EPS:
|
||||
return None
|
||||
centre = ((box[0] + box[2]) / 2.0, (box[1] + box[3]) / 2.0)
|
||||
s, off = _along(centre, a, b)
|
||||
reach = min(box[2] - box[0], box[3] - box[1]) / 4.0 + tol
|
||||
if off > reach or not tol < s < length - tol:
|
||||
return None
|
||||
t = s / length
|
||||
return (a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1]))
|
||||
|
||||
|
||||
def build_network(snap: Snapshot, *, layers=None, tol=1.0, label_search=None) -> dict:
|
||||
"""Runs of the line network on `layers` - see the module docstring for every rule.
|
||||
|
||||
Returns ``{"runs": [Run], "unassigned": [...], "attachments": {run id: [{"at", "tag"}]},
|
||||
"equipment": {tag: [{"box", "handle", "at"}]}, "stats": {...}}``. Refused by
|
||||
name before anything is read: a non-positive `tol` or `label_search`, a
|
||||
named layer the drawing does not have (with the list it has), an empty
|
||||
`layers`, and - when `layers` is omitted - a drawing with no layer filed
|
||||
under piping or electrical at confidence >= 0.9.
|
||||
"""
|
||||
tol = _positive(tol, "tol")
|
||||
search = (
|
||||
label_search_default(snap, tol=tol)
|
||||
if label_search is None
|
||||
else _positive(label_search, "label_search")
|
||||
)
|
||||
chosen = _choose_layers(snap, layers)
|
||||
raw = _segments(snap, chosen)
|
||||
verts = _Vertices(tol)
|
||||
snapped = []
|
||||
for a, b, bulge, handle, layer in raw:
|
||||
u, v = verts.index(a), verts.index(b)
|
||||
if u != v:
|
||||
snapped.append((u, v, bulge, handle, layer))
|
||||
|
||||
# equipment first: an INSERT a tag labels is never a fitting
|
||||
occurrences = tag_occurrences(snap)
|
||||
equipment = equipment_boxes(
|
||||
snap, occurrences, exclude_layers=chosen, search=EQUIPMENT_SEARCH_SHARE * search
|
||||
)
|
||||
equipment_handles = {box["handle"] for boxes in equipment.values() for box in boxes}
|
||||
inserts = [
|
||||
rec
|
||||
for rec in snap.records
|
||||
if rec.space == MODEL
|
||||
and rec.type == "INSERT"
|
||||
and rec.bbox is not None
|
||||
and rec.handle not in equipment_handles
|
||||
]
|
||||
reducer_boxes = [rec.bbox for rec in inserts if is_reducer(rec.block)]
|
||||
|
||||
# a straight segment drawn through the middle of a reducer is cut there, so
|
||||
# the reducer separates its two sides (the split below makes the cut)
|
||||
if reducer_boxes:
|
||||
chords = [(verts.points[u], verts.points[v]) for u, v, _b, _h, _l in snapped]
|
||||
chord_index = _ChordIndex(chords, tol)
|
||||
for box in reducer_boxes:
|
||||
for n in chord_index.in_box((box[0] - tol, box[1] - tol, box[2] + tol, box[3] + tol)):
|
||||
u, v, bulge, _handle, _layer = snapped[n]
|
||||
a, b = verts.points[u], verts.points[v]
|
||||
if bulge != 0.0 or _in_box(a, box, tol) or _in_box(b, box, tol):
|
||||
continue
|
||||
cut = _through_middle(a, b, box, tol)
|
||||
if cut is not None:
|
||||
verts.index(cut)
|
||||
pts = verts.points
|
||||
|
||||
# split straight segments at every vertex lying on them (T-junctions, overlaps)
|
||||
order = sorted(range(len(pts)), key=lambda i: pts[i][0])
|
||||
xs = [pts[i][0] for i in order]
|
||||
pieces_by_key: dict[tuple[int, int, float], dict] = {}
|
||||
overlap_count, overlap_length = 0, 0.0
|
||||
for u, v, bulge, handle, layer in snapped:
|
||||
chain = [u, v]
|
||||
if bulge == 0.0:
|
||||
pa, pb = pts[u], pts[v]
|
||||
length = math.dist(pa, pb)
|
||||
lo = bisect_left(xs, min(pa[0], pb[0]) - tol)
|
||||
hi = bisect_right(xs, max(pa[0], pb[0]) + tol)
|
||||
inner = []
|
||||
for k in order[lo:hi]:
|
||||
if k in (u, v):
|
||||
continue
|
||||
p = pts[k]
|
||||
if not min(pa[1], pb[1]) - tol <= p[1] <= max(pa[1], pb[1]) + tol:
|
||||
continue
|
||||
s, off = _along(p, pa, pb)
|
||||
if off <= tol and tol < s < length - tol:
|
||||
inner.append((s, k))
|
||||
chain = [u] + [k for _s, k in sorted(inner)] + [v]
|
||||
for p, q in zip(chain, chain[1:], strict=False):
|
||||
if p == q:
|
||||
continue
|
||||
b_signed = bulge if p < q else -bulge
|
||||
key = (min(p, q), max(p, q), round(b_signed, 9))
|
||||
piece_length = _piece_length(pts[p], pts[q], bulge)
|
||||
known = pieces_by_key.get(key)
|
||||
if known is not None:
|
||||
overlap_count += 1
|
||||
overlap_length += piece_length
|
||||
known["handles"].add(handle)
|
||||
known["layers"].add(layer)
|
||||
continue
|
||||
pieces_by_key[key] = {
|
||||
"u": key[0],
|
||||
"v": key[1],
|
||||
"bulge": key[2],
|
||||
"length": piece_length,
|
||||
"layer": layer,
|
||||
"layers": {layer},
|
||||
"handles": {handle},
|
||||
}
|
||||
pieces = [pieces_by_key[key] for key in sorted(pieces_by_key)]
|
||||
|
||||
at_vertex: dict[int, list[int]] = {}
|
||||
for k, piece in enumerate(pieces):
|
||||
at_vertex.setdefault(piece["u"], []).append(k)
|
||||
at_vertex.setdefault(piece["v"], []).append(k)
|
||||
|
||||
free = sorted(vertex for vertex, ks in at_vertex.items() if len(ks) == 1)
|
||||
joined = sorted(vertex for vertex, ks in at_vertex.items() if len(ks) > 1)
|
||||
cell = _typical_size([rec.bbox for rec in inserts], search)
|
||||
free_grid = _PointGrid(((vertex, pts[vertex]) for vertex in free), cell)
|
||||
joined_grid = _PointGrid(((vertex, pts[vertex]) for vertex in joined), cell)
|
||||
bridges: list[tuple[int, int, bool, str]] = []
|
||||
# vertices where pieces meet inside a reducer's box: a line broken at the
|
||||
# reducer, or cut above where it was drawn straight through it
|
||||
reducer_vertices: set[int] = set()
|
||||
fittings, reducers, skipped = 0, 0, []
|
||||
for rec in inserts:
|
||||
reducer = is_reducer(rec.block)
|
||||
inside = free_grid.in_box(rec.bbox, tol)
|
||||
through = joined_grid.in_box(rec.bbox, tol) if reducer else []
|
||||
if len(inside) > MAX_FITTING_ENDS:
|
||||
skipped.append(rec.handle)
|
||||
continue
|
||||
if len(inside) < 2 and not through:
|
||||
continue
|
||||
fittings += 1
|
||||
reducers += int(reducer)
|
||||
reducer_vertices.update(through)
|
||||
if len(inside) < 2:
|
||||
continue
|
||||
# every port joins every other: a three- or four-way fitting has no hub
|
||||
for n, u in enumerate(inside):
|
||||
for v in inside[n + 1 :]:
|
||||
bridges.append((u, v, reducer, rec.handle))
|
||||
|
||||
# runs: union-find over pieces and bridges
|
||||
parent = list(range(len(pts)))
|
||||
|
||||
def find(i: int) -> int:
|
||||
while parent[i] != i:
|
||||
parent[i] = parent[parent[i]]
|
||||
i = parent[i]
|
||||
return i
|
||||
|
||||
for piece in pieces:
|
||||
parent[find(piece["u"])] = find(piece["v"])
|
||||
for u, v, _reducer, _handle in bridges:
|
||||
parent[find(u)] = find(v)
|
||||
groups: dict[int, list[int]] = {}
|
||||
for k, piece in enumerate(pieces):
|
||||
groups.setdefault(find(piece["u"]), []).append(k)
|
||||
|
||||
def first_point(ks: list[int]) -> tuple[float, float]:
|
||||
return min(min(pts[pieces[k]["u"]], pts[pieces[k]["v"]]) for k in ks)
|
||||
|
||||
ordered = sorted(groups.values(), key=first_point)
|
||||
run_of: dict[int, int] = {}
|
||||
for r, ks in enumerate(ordered):
|
||||
for k in ks:
|
||||
run_of[k] = r
|
||||
|
||||
# neighbours for continuity: shared vertices and bridges, each flagged when a
|
||||
# reducer stands between the two pieces
|
||||
neighbours: dict[int, set[tuple[int, bool]]] = {k: set() for k in range(len(pieces))}
|
||||
for vertex, ks in at_vertex.items():
|
||||
across = vertex in reducer_vertices
|
||||
for k in ks:
|
||||
neighbours[k].update((j, across) for j in ks if j != k)
|
||||
for u, v, reducer, _handle in bridges:
|
||||
for k in at_vertex.get(u, ()):
|
||||
neighbours[k].update((j, reducer) for j in at_vertex.get(v, ()))
|
||||
for k in at_vertex.get(v, ()):
|
||||
neighbours[k].update((j, reducer) for j in at_vertex.get(u, ()))
|
||||
|
||||
# labels: diameters and supply / return words go to the nearest piece, unless
|
||||
# a line on a layer that is not read stands nearer - the label is that line's
|
||||
index = _ChordIndex([(pts[piece["u"]], pts[piece["v"]]) for piece in pieces], search)
|
||||
unread = set(_layer_names(snap)) - set(chosen)
|
||||
others = [(a, b, layer) for a, b, _bulge, _h, layer in _segments(snap, unread, open_only=True)]
|
||||
other_index = _ChordIndex([(a, b) for a, b, _layer in others], search)
|
||||
|
||||
def nearest(p: Pt) -> tuple[int, float] | None:
|
||||
found = []
|
||||
for k in index.candidates(p):
|
||||
piece = pieces[k]
|
||||
d = _segment_distance(p, pts[piece["u"]], pts[piece["v"]])
|
||||
if d <= search:
|
||||
found.append((d, k))
|
||||
if not found:
|
||||
return None
|
||||
d, k = min(found)
|
||||
return k, d
|
||||
|
||||
def nearer_unread(p: Pt, dist: float) -> str | None:
|
||||
"""The layer of the nearest unread line strictly nearer to p than `dist`, else None."""
|
||||
found = []
|
||||
for n in other_index.candidates(p):
|
||||
a, b, layer = others[n]
|
||||
d = _segment_distance(p, a, b)
|
||||
if d < dist - _EPS * max(1.0, dist):
|
||||
found.append((d, layer))
|
||||
return min(found)[1] if found else None
|
||||
|
||||
def entity_side(k: int) -> list[int]:
|
||||
"""The pieces of k's drawn entities reached from k without crossing a reducer."""
|
||||
handles = pieces[k]["handles"]
|
||||
seen, stack = {k}, [k]
|
||||
while stack:
|
||||
i = stack.pop()
|
||||
for j, via_reducer in neighbours[i]:
|
||||
if via_reducer or j in seen or not pieces[j]["handles"] & handles:
|
||||
continue
|
||||
seen.add(j)
|
||||
stack.append(j)
|
||||
return sorted(seen)
|
||||
|
||||
reaching: dict[int, list[tuple[float, str, str]]] = {}
|
||||
votes: dict[int, list[tuple[float, str, str | None, str]]] = {}
|
||||
labels_skipped = []
|
||||
for rec in snap.records:
|
||||
if rec.space != MODEL or rec.type not in TEXT_TYPES or not rec.text:
|
||||
continue
|
||||
where = _centre(rec)
|
||||
if where is None:
|
||||
continue
|
||||
tokens = parse_diameters(rec.text)
|
||||
direction = supply_return(rec.text)
|
||||
if not tokens and direction is None:
|
||||
continue
|
||||
hit = nearest(where)
|
||||
if hit is None:
|
||||
continue
|
||||
k, dist = hit
|
||||
owner = nearer_unread(where, dist)
|
||||
if owner is not None:
|
||||
labels_skipped.append(
|
||||
{
|
||||
"handle": rec.handle,
|
||||
"text": rec.text,
|
||||
"reason": f"nearer to a line on layer {owner}, which is not read",
|
||||
}
|
||||
)
|
||||
continue
|
||||
if len(tokens) > 1:
|
||||
labels_skipped.append(
|
||||
{"handle": rec.handle, "text": rec.text, "reason": "more than one diameter"}
|
||||
)
|
||||
elif tokens:
|
||||
for j in entity_side(k):
|
||||
q = pieces[j]
|
||||
reaching.setdefault(j, []).append(
|
||||
(_segment_distance(where, pts[q["u"]], pts[q["v"]]), tokens[0], rec.handle)
|
||||
)
|
||||
if direction is not None:
|
||||
family = _family_of(rec.text)
|
||||
votes.setdefault(run_of[k], []).append((dist, direction, family, rec.handle))
|
||||
|
||||
direct: dict[int, tuple[str, str]] = {}
|
||||
diameter_conflicts = []
|
||||
for k, found in sorted(reaching.items()):
|
||||
found.sort()
|
||||
direct[k] = (found[0][1], found[0][2])
|
||||
others = sorted({token for _d, token, _h in found} - {found[0][1]})
|
||||
if others:
|
||||
diameter_conflicts.append({"piece": k, "kept": found[0][1], "also": others})
|
||||
|
||||
best: dict[int, list] = {k: [0.0, {token}] for k, (token, _h) in direct.items()}
|
||||
heap = [(0.0, k, token) for k, (token, _h) in direct.items()]
|
||||
heapq.heapify(heap)
|
||||
while heap:
|
||||
d, k, token = heapq.heappop(heap)
|
||||
current = best[k]
|
||||
if d > current[0] + _EPS * max(1.0, d) or token not in current[1]:
|
||||
continue
|
||||
for j, via_reducer in neighbours[k]:
|
||||
if via_reducer or j in direct:
|
||||
continue
|
||||
nd = d + (pieces[k]["length"] + pieces[j]["length"]) / 2.0
|
||||
known = best.get(j)
|
||||
if known is None or nd < known[0] - _EPS * max(1.0, nd):
|
||||
best[j] = [nd, {token}]
|
||||
heapq.heappush(heap, (nd, j, token))
|
||||
elif abs(nd - known[0]) <= _EPS * max(1.0, nd) and token not in known[1]:
|
||||
known[1].add(token)
|
||||
heapq.heappush(heap, (nd, j, token))
|
||||
|
||||
# a free end joined through a fitting is no longer a run end
|
||||
bridged: dict[int, int] = {}
|
||||
for u, v, _reducer, _handle in bridges:
|
||||
bridged[u] = bridged.get(u, 0) + 1
|
||||
bridged[v] = bridged.get(v, 0) + 1
|
||||
equipment_boxes_all = [(box["box"], tag) for tag, boxes in equipment.items() for box in boxes]
|
||||
equipment_index = _BoxGrid(
|
||||
((box, (box, tag)) for box, tag in equipment_boxes_all),
|
||||
grow=tol,
|
||||
cell=_typical_size([box for box, _tag in equipment_boxes_all], search),
|
||||
)
|
||||
|
||||
runs, unassigned, attachments, service_conflicts = [], [], {}, []
|
||||
for r, ks in enumerate(ordered):
|
||||
run_id = f"R{r + 1}"
|
||||
service = _run_service([pieces[k] for k in ks], votes.get(r, []), run_id, service_conflicts)
|
||||
edges = []
|
||||
for n, k in enumerate(sorted(ks), start=1):
|
||||
piece = pieces[k]
|
||||
if k in direct:
|
||||
diameter, source, label = direct[k][0], "direct", direct[k][1]
|
||||
elif k in best and len(best[k][1]) == 1:
|
||||
diameter, source, label = next(iter(best[k][1])), "continuity", None
|
||||
else:
|
||||
diameter, source, label = None, "unassigned", None
|
||||
unassigned.append(
|
||||
{
|
||||
"run": run_id,
|
||||
"edge": f"{run_id}.{n}",
|
||||
"length": piece["length"],
|
||||
"reason": "two different diameters equally near"
|
||||
if k in best
|
||||
else "no diameter label within label_search and none carried along the run",
|
||||
}
|
||||
)
|
||||
edges.append(
|
||||
{
|
||||
"id": f"{run_id}.{n}",
|
||||
"a": pts[piece["u"]],
|
||||
"b": pts[piece["v"]],
|
||||
"bulge": piece["bulge"],
|
||||
"length": piece["length"],
|
||||
"layer": piece["layer"],
|
||||
"layers": tuple(sorted(piece["layers"])),
|
||||
"handles": tuple(sorted(piece["handles"])),
|
||||
"service": service,
|
||||
"diameter": diameter,
|
||||
"diameter_source": source,
|
||||
"label": label,
|
||||
}
|
||||
)
|
||||
degree: dict[int, int] = {}
|
||||
for k in ks:
|
||||
for vertex in (pieces[k]["u"], pieces[k]["v"]):
|
||||
degree[vertex] = degree.get(vertex, 0) + 1
|
||||
for vertex in degree:
|
||||
degree[vertex] += bridged.get(vertex, 0)
|
||||
end_points = sorted(pts[vertex] for vertex, count in degree.items() if count == 1)
|
||||
attached = [{"at": p, "tag": _attach(p, equipment_index, tol)} for p in end_points]
|
||||
attachments[run_id] = attached
|
||||
runs.append(
|
||||
Run(
|
||||
id=run_id,
|
||||
edges=tuple(edges),
|
||||
service=service,
|
||||
ends=tuple(end_points),
|
||||
tags=tuple(sorted({a["tag"] for a in attached if a["tag"] is not None})),
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"runs": runs,
|
||||
"unassigned": unassigned,
|
||||
"attachments": attachments,
|
||||
"equipment": equipment,
|
||||
"stats": {
|
||||
"layers": list(chosen),
|
||||
"tol": tol,
|
||||
"label_search": search,
|
||||
"segments": len(raw),
|
||||
"pieces": len(pieces),
|
||||
"overlap_removed": overlap_count,
|
||||
"overlap_length": overlap_length,
|
||||
"fittings": fittings,
|
||||
"reducers": reducers,
|
||||
"fittings_skipped": skipped,
|
||||
"runs": len(runs),
|
||||
"labels_skipped": labels_skipped,
|
||||
"diameter_conflicts": diameter_conflicts,
|
||||
"service_conflicts": service_conflicts,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _family(service: str | None) -> str | None:
|
||||
for family, pair in _PAIRS.items():
|
||||
if service in pair:
|
||||
return family
|
||||
return None
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def _classified(name: str) -> dict:
|
||||
"""`classify_layer`, once per distinct name (a run has thousands of pieces, few layers)."""
|
||||
return classify_layer(name)
|
||||
|
||||
|
||||
def _family_of(name: str | None) -> str | None:
|
||||
"""The supply / return pair (``cip``, ``glycol``) a layer name or a text names.
|
||||
|
||||
`classify_layer` files a CIP or glycol name without a direction word as
|
||||
service ``unknown`` - the layer alone is not trusted with the direction -
|
||||
so the pair is recovered by asking again with a direction word appended.
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
found = _classified(name)
|
||||
family = _family(found["service"])
|
||||
if family is None and found["service"] == "unknown" and found["keyword"] is not None:
|
||||
family = _family(_classified(f"{name} supply")["service"])
|
||||
return family
|
||||
|
||||
|
||||
def _run_service(pieces: list[dict], votes: list, run_id: str, conflicts: list) -> str:
|
||||
weight: dict[str, float] = {}
|
||||
families: dict[str, float] = {}
|
||||
for piece in pieces:
|
||||
service = _classified(piece["layer"])["service"]
|
||||
weight[service] = weight.get(service, 0.0) + piece["length"]
|
||||
family = _family_of(piece["layer"])
|
||||
if family is not None:
|
||||
families[family] = families.get(family, 0.0) + piece["length"]
|
||||
known = {s: w for s, w in weight.items() if s != "unknown"} or weight
|
||||
base = min(known, key=lambda s: (-known[s], s))
|
||||
if not votes:
|
||||
return base
|
||||
votes = sorted(votes)
|
||||
direction = votes[0][1]
|
||||
if len({vote[1] for vote in votes}) > 1:
|
||||
conflicts.append({"run": run_id, "kept": direction, "reason": "supply and return words"})
|
||||
family = _family(base)
|
||||
if family is None and base == "unknown" and families:
|
||||
# a CIP / glycol layer that names no direction: the word decides it
|
||||
family = min(families, key=lambda f: (-families[f], f))
|
||||
if family is None:
|
||||
family = next((vote[2] for vote in votes if vote[2]), None)
|
||||
if family is None:
|
||||
conflicts.append(
|
||||
{"run": run_id, "kept": base, "reason": f"{direction} word on a line with no pair"}
|
||||
)
|
||||
return base
|
||||
return _PAIRS[family][0 if direction == "supply" else 1]
|
||||
|
||||
|
||||
def _attach(p: Pt, index: _BoxGrid, tol: float) -> str | None:
|
||||
hits = [(_box_area(box), tag) for box, tag in index.near(p) if _in_box(p, box, tol)]
|
||||
return min(hits)[1] if hits else None
|
||||
@@ -0,0 +1,682 @@
|
||||
"""The takeoff workbook: Metraj, Özet, Kontrol and Metodoloji, as XLSX and as CSV.
|
||||
|
||||
The sheets follow the user's own takeoff workbook (spec §8): *Metraj* holds
|
||||
the rows, *Özet* the sums, *Kontrol* what needs a person's eye and
|
||||
*Metodoloji* how every number was reached - generated from the result, never
|
||||
typed. The sheet names stay as the user's workbook names them; the headers,
|
||||
the service names and the method text follow `lang` (tr / en / ru).
|
||||
|
||||
CSV is always written, one file per sheet beside the workbook
|
||||
(``<stem>_metraj.csv`` ...), UTF-8 with a byte-order mark so a spreadsheet
|
||||
opens the Cyrillic and Turkish letters, a point as the decimal separator. XLSX
|
||||
needs openpyxl (the `office` extra); without it the CSV files are still
|
||||
written and the result carries the `xlsx_write` refusal instead of a path.
|
||||
Numbers are written rounded to 1 mm (three decimals of a metre).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
from backends.capability import UnsupportedCapabilityError
|
||||
from engineering.sheet.extract import xlsx_available
|
||||
|
||||
__all__ = ["CSV_SLUGS", "LABELS", "LANGS", "SHEETS", "check_lang", "write_workbook"]
|
||||
|
||||
LANGS = ("tr", "en", "ru")
|
||||
SHEETS = ("Metraj", "Özet", "Kontrol", "Metodoloji")
|
||||
CSV_SLUGS = {"Metraj": "metraj", "Özet": "ozet", "Kontrol": "kontrol", "Metodoloji": "metodoloji"}
|
||||
_INDEX = {lang: i for i, lang in enumerate(LANGS)}
|
||||
|
||||
#: Every word the workbook prints, as (tr, en, ru).
|
||||
LABELS: dict[str, tuple[str, str, str]] = {
|
||||
# Metraj
|
||||
"room": ("Mahal", "Room", "Помещение"),
|
||||
"service": ("Hizmet", "Service", "Среда"),
|
||||
"diameter": ("Çap", "Diameter", "Диаметр"),
|
||||
"direct_m": ("Doğrudan (m)", "Direct (m)", "Прямо (м)"),
|
||||
"continuity_m": ("Süreklilik (m)", "Continuity (m)", "По непрерывности (м)"),
|
||||
"unassigned_m": ("Atanmamış (m)", "Unassigned (m)", "Не назначено (м)"),
|
||||
"net_m": ("Net (m)", "Net (m)", "Нетто (м)"),
|
||||
"allowance": ("Pay oranı", "Allowance", "Запас"),
|
||||
"allowance_m": ("Pay (m)", "Allowance (m)", "Запас (м)"),
|
||||
"total_m": ("Paylı toplam (m)", "Total incl. allowance (m)", "Итого с запасом (м)"),
|
||||
"schematic_m": ("Şematik (m)", "Schematic (m)", "По схеме (м)"),
|
||||
"status": ("Durum", "Status", "Статус"),
|
||||
"runs": ("Hatlar", "Runs", "Линии"),
|
||||
# Özet, Kontrol, Metodoloji
|
||||
"group": ("Grup", "Group", "Группа"),
|
||||
"key": ("Anahtar", "Key", "Ключ"),
|
||||
"run": ("Hat", "Run", "Линия"),
|
||||
"kind": ("Tür", "Kind", "Вид"),
|
||||
"tags": ("Etiketler", "Tags", "Теги"),
|
||||
"reason": ("Neden", "Reason", "Причина"),
|
||||
"topic": ("Konu", "Topic", "Раздел"),
|
||||
"value": ("Değer", "Value", "Значение"),
|
||||
"by_service": ("Hizmete göre", "By service", "По среде"),
|
||||
"by_diameter": ("Çapa göre", "By diameter", "По диаметру"),
|
||||
"by_room": ("Mahale göre", "By room", "По помещению"),
|
||||
"total": ("TOPLAM", "TOTAL", "ИТОГО"),
|
||||
"routed": ("Yönlendirilen", "Routed", "Трассировано"),
|
||||
"unroutable_total": ("Yönlendirilemeyen (C)", "Unroutable (C)", "Без трассы (C)"),
|
||||
"yes": ("evet", "yes", "да"),
|
||||
"no": ("hayır", "no", "нет"),
|
||||
# services
|
||||
"product": ("Ürün", "Product", "Продукт"),
|
||||
"cip_supply": ("CIP besleme", "CIP supply", "CIP подача"),
|
||||
"cip_return": ("CIP dönüş", "CIP return", "CIP возврат"),
|
||||
"glycol_supply": ("Glikol besleme", "Glycol supply", "Гликоль подача"),
|
||||
"glycol_return": ("Glikol dönüş", "Glycol return", "Гликоль возврат"),
|
||||
"steam": ("Buhar", "Steam", "Пар"),
|
||||
"condensate": ("Kondens", "Condensate", "Конденсат"),
|
||||
"cold_water": ("Soğuk su", "Cold water", "Холодная вода"),
|
||||
"hot_water": ("Sıcak su", "Hot water", "Горячая вода"),
|
||||
"ice_water": ("Buzlu su", "Ice water", "Ледяная вода"),
|
||||
"compressed_air": ("Basınçlı hava", "Compressed air", "Сжатый воздух"),
|
||||
"drain": ("Drenaj", "Drain", "Дренаж"),
|
||||
"electrical": ("Elektrik", "Electrical", "Электрика"),
|
||||
"unknown": ("Bilinmeyen", "Unknown", "Неизвестно"),
|
||||
# Kontrol kinds and reasons
|
||||
"unassigned_diameter": ("Çap atanmamış", "Diameter unassigned", "Диаметр не назначен"),
|
||||
"unroutable": ("Yönlendirilemez", "Unroutable", "Трассировка невозможна"),
|
||||
"end_not_on_equipment": (
|
||||
"Bir uç ekipmanda değil",
|
||||
"An end is on no equipment",
|
||||
"Конец не на оборудовании",
|
||||
),
|
||||
"single_tag": ("Tek etiket", "Only one tag", "Только один тег"),
|
||||
"tag_not_on_layout": (
|
||||
"Etiket yerleşimde yok",
|
||||
"Tag not on the layout",
|
||||
"Тега нет на планировке",
|
||||
),
|
||||
"tag_ambiguous": (
|
||||
"Etiket yerleşimde birden çok kez yazılı",
|
||||
"Tag written more than once on the layout",
|
||||
"Тег повторяется на планировке",
|
||||
),
|
||||
"no_diameter_label": ("Çap etiketi yok", "No diameter label", "Нет обозначения диаметра"),
|
||||
# Metodoloji topics
|
||||
"source_pid": ("Kaynak: P&ID", "Source: P&ID", "Источник: P&ID"),
|
||||
"source_layout": ("Kaynak: yerleşim", "Source: layout", "Источник: планировка"),
|
||||
"length_source": ("Uzunluk kaynağı", "Length source", "Источник длины"),
|
||||
"choice": ("Seçim gerekçesi", "Why this source", "Почему этот источник"),
|
||||
"scale_verdict": ("Ölçek kontrolü: sonuç", "Scale check: verdict", "Проверка масштаба: вывод"),
|
||||
"scale_factor": (
|
||||
"Ölçek kontrolü: oran (P&ID mm / yerleşim mm)",
|
||||
"Scale check: factor (P&ID mm per layout mm)",
|
||||
"Проверка масштаба: коэффициент (мм P&ID на мм планировки)",
|
||||
),
|
||||
"scale_iqr": ("Ölçek kontrolü: IQR", "Scale check: IQR", "Проверка масштаба: IQR"),
|
||||
"scale_within": (
|
||||
"Ölçek kontrolü: ±%10 içindeki çiftler",
|
||||
"Scale check: pairs within ±10 %",
|
||||
"Проверка масштаба: пары в пределах ±10 %",
|
||||
),
|
||||
"scale_pairs": ("Ölçek kontrolü: çift sayısı", "Scale check: pairs", "Проверка масштаба: пар"),
|
||||
"scale_matched": (
|
||||
"Ölçek kontrolü: eşleşen etiketler",
|
||||
"Scale check: matched tags",
|
||||
"Проверка масштаба: совпавшие теги",
|
||||
),
|
||||
"scale_ambiguous": (
|
||||
"Ölçek kontrolü: belirsiz etiketler",
|
||||
"Scale check: ambiguous tags",
|
||||
"Проверка масштаба: неоднозначные теги",
|
||||
),
|
||||
"scale_note": ("Ölçek kontrolü: not", "Scale check: note", "Проверка масштаба: примечание"),
|
||||
"scale_verified": ("Ölçek doğrulandı", "Scale verified", "Масштаб подтверждён"),
|
||||
"units_pid": ("Birim: P&ID", "Units: P&ID", "Единицы: P&ID"),
|
||||
"units_layout": ("Birim: yerleşim", "Units: layout", "Единицы: планировка"),
|
||||
"rule_route": ("Kural: güzergâh", "Rule: route", "Правило: трасса"),
|
||||
"rule_vertical": (
|
||||
"Kural: bağlantı başına düşey pay (m)",
|
||||
"Rule: vertical allowance per connection (m)",
|
||||
"Правило: вертикальный запас на соединение (м)",
|
||||
),
|
||||
"rule_allowance": ("Kural: pay oranı", "Rule: allowance", "Правило: запас"),
|
||||
"rule_diameter": ("Kural: çap", "Rule: diameter", "Правило: диаметр"),
|
||||
"rule_service": ("Kural: hizmet", "Rule: service", "Правило: среда"),
|
||||
"rule_status": ("Kural: durum", "Rule: status", "Правило: статус"),
|
||||
"rule_rooms": ("Kural: mahaller", "Rule: rooms", "Правило: помещения"),
|
||||
"rule_multi": (
|
||||
"Kural: çok çaplı hat",
|
||||
"Rule: several diameters",
|
||||
"Правило: несколько диаметров",
|
||||
),
|
||||
"tol": ("Tolerans (çizim birimi)", "Tolerance (drawing units)", "Допуск (ед. чертежа)"),
|
||||
"label_search": (
|
||||
"Etiket arama mesafesi (çizim birimi)",
|
||||
"Label search distance (drawing units)",
|
||||
"Радиус поиска обозначений (ед. чертежа)",
|
||||
),
|
||||
"overlap": (
|
||||
"Çift çizilmiş, bir kez sayılan boru (m)",
|
||||
"Double-drawn pipe counted once (m)",
|
||||
"Дважды начерченная труба, учтена один раз (м)",
|
||||
),
|
||||
"services": ("Kapsanan hizmetler", "Services taken off", "Учтённые среды"),
|
||||
"warning": ("Uyarı", "Warning", "Предупреждение"),
|
||||
# cable takeoff
|
||||
"tag": ("Etiket", "Tag", "Тег"),
|
||||
"kw": ("Güç (kW)", "Power (kW)", "Мощность (кВт)"),
|
||||
"phases": ("Faz", "Phases", "Фазы"),
|
||||
"voltage": ("Gerilim (V)", "Voltage (V)", "Напряжение (В)"),
|
||||
"neutral": ("Nötr", "Neutral", "Нейтраль"),
|
||||
"vfd": ("Sürücü (VFD)", "VFD", "ЧРП"),
|
||||
"panel": ("Pano", "Panel", "Шкаф"),
|
||||
"length_m": ("Mesafe (m)", "Distance (m)", "Расстояние (м)"),
|
||||
"cable_m": ("Kablo (m)", "Cable (m)", "Кабель (м)"),
|
||||
"section": ("Kesit", "Section", "Сечение"),
|
||||
"package": ("Paket", "Package", "Комплект"),
|
||||
"loads": ("Yük sayısı", "Loads", "Нагрузок"),
|
||||
"item": ("Madde", "Item", "Пункт"),
|
||||
"detail": ("Ayrıntı", "Detail", "Подробности"),
|
||||
"by_panel": ("Panoya göre", "By panel", "По шкафу"),
|
||||
"missing_power": ("Güç yazılmamış", "No power stated", "Мощность не указана"),
|
||||
"missing_panel": ("Pano belirtilmemiş", "No panel named", "Шкаф не указан"),
|
||||
"no_layout": ("Yerleşim yok", "No layout", "Нет планировки"),
|
||||
"panel_not_on_layout": (
|
||||
"Pano yerleşimde yok",
|
||||
"Panel not on the layout",
|
||||
"Шкафа нет на планировке",
|
||||
),
|
||||
"no_section_rule": ("Kesit kuralı yok", "No section rule covers it", "Нет правила сечения"),
|
||||
"conflicting_power": (
|
||||
"Çelişen güç yazıları",
|
||||
"Conflicting power texts",
|
||||
"Противоречивая мощность",
|
||||
),
|
||||
"rule_cable_route": ("Kural: kablo güzergâhı", "Rule: cable route", "Правило: трасса кабеля"),
|
||||
"rule_rounding": ("Kural: yuvarlama", "Rule: rounding", "Правило: округление"),
|
||||
"rule_power": ("Kural: güç", "Rule: power", "Правило: мощность"),
|
||||
"rule_panel": ("Kural: pano", "Rule: panel", "Правило: шкаф"),
|
||||
"rule_package": ("Kural: pano paketleri", "Rule: panel packages", "Правило: комплектные шкафы"),
|
||||
"rule_section": ("Kural: kesit", "Rule: section", "Правило: сечение"),
|
||||
"section_rules": (
|
||||
"Kesit kuralları (çağıranın)",
|
||||
"Section rules (the caller's)",
|
||||
"Правила сечений (вызывающего)",
|
||||
),
|
||||
}
|
||||
|
||||
#: The method sheet's sentences, as (tr, en, ru).
|
||||
TEXTS: dict[str, tuple[str, str, str]] = {
|
||||
"requested": ("çağıran seçti", "chosen by the caller", "выбран вызывающим"),
|
||||
"forced": (
|
||||
"ölçek kontrolü P&ID'yi şematik bulduğu halde istek üzerine (force=True) P&ID'den ölçüldü",
|
||||
"measured on the P&ID on request (force=True) although the scale check calls it schematic",
|
||||
"измерено по P&ID по запросу (force=True), хотя проверка масштаба считает его схемой",
|
||||
),
|
||||
"no_layout": (
|
||||
"yerleşim verilmedi: P&ID'den ölçüldü, ölçek doğrulanmadı",
|
||||
"no layout given: measured on the P&ID, scale not verified",
|
||||
"планировка не задана: измерено по P&ID, масштаб не подтверждён",
|
||||
),
|
||||
"auto_to_scale": (
|
||||
"ölçek kontrolü P&ID'yi ölçekli buldu: oran üzerinden P&ID'den ölçüldü",
|
||||
"the scale check calls the P&ID to scale: measured on it through the factor",
|
||||
"проверка масштаба считает P&ID масштабным: измерено по нему через коэффициент",
|
||||
),
|
||||
"auto_layout": (
|
||||
"ölçek kontrolü P&ID'yi ölçekli bulmadı: yerleşimden ölçüldü",
|
||||
"the scale check does not call the P&ID to scale: measured on the layout",
|
||||
"проверка масштаба не считает P&ID масштабным: измерено по планировке",
|
||||
),
|
||||
"scale_note": (
|
||||
"Etiketler ekipman merkezi değildir: ±%10 bant ve 2 m alt sınır bunu karşılar.",
|
||||
"Tag labels are not equipment centres: the ±10 % band and the 2 m floor absorb that.",
|
||||
"Теги — не центры оборудования: полоса ±10 % и порог 2 м это учитывают.",
|
||||
),
|
||||
"rule_route": (
|
||||
"Yerleşim uzunluğu = hattın bağladığı etiket konumlarının dik açılı (Manhattan) en küçük "
|
||||
"kapsayan ağacı; her bağlantı önce x, sonra y boyunca.",
|
||||
"Layout length = the rectilinear (Manhattan) minimum spanning tree of the tag positions "
|
||||
"the run joins; each connection along x first, then y.",
|
||||
"Длина по планировке = прямоугольное (манхэттенское) минимальное остовное дерево "
|
||||
"позиций тегов, которые соединяет линия; каждое соединение сначала по x, затем по y.",
|
||||
),
|
||||
"rule_diameter": (
|
||||
"Çap etiketleri çizildiği gibi alınır (Ø51, SMS51, DN20 farklı ölçülerdir). Doğrudan: "
|
||||
"arama mesafesindeki etiket; süreklilik: etiketli parçadan hat boyunca taşınır, "
|
||||
"redüksiyondan geçmez; atanmamış: ikisi de değil (Kontrol sayfasında).",
|
||||
"Diameter labels are kept as drawn (Ø51, SMS51 and DN20 are different sizes). Direct: a "
|
||||
"label within the search distance; continuity: carried along the run from a labelled "
|
||||
"piece, never across a reducer; unassigned: neither (listed on Kontrol).",
|
||||
"Обозначения диаметров сохраняются как на чертеже (Ø51, SMS51 и DN20 — разные "
|
||||
"размеры). Прямо: обозначение в радиусе поиска; по непрерывности: перенос вдоль линии "
|
||||
"от обозначенного участка, но не через переход; не назначено: ни то, ни другое (лист "
|
||||
"Kontrol).",
|
||||
),
|
||||
"rule_service": (
|
||||
"Hizmet katmandan alınır; hattın yakınındaki besleme / dönüş sözcüğü (ПОДАЧА, ВОЗВРАТ, "
|
||||
"BESLEME, DÖNÜŞ, SUPPLY, RETURN, AANVOER, RETOUR) tüm hat için onu geçersiz kılar.",
|
||||
"Service comes from the layer; a supply / return word near the run (ПОДАЧА, ВОЗВРАТ, "
|
||||
"BESLEME, DÖNÜŞ, SUPPLY, RETURN, AANVOER, RETOUR) overrides it for the whole run.",
|
||||
"Среда определяется по слою; слово подачи / возврата рядом с линией (ПОДАЧА, ВОЗВРАТ, "
|
||||
"BESLEME, DÖNÜŞ, SUPPLY, RETURN, AANVOER, RETOUR) переопределяет её для всей линии.",
|
||||
),
|
||||
"rule_status": (
|
||||
"A: her uç yerleşimde bulunan bir etikette ve her çap doğrudan. B: süreklilikle ya da "
|
||||
"atanmamış çap. C: yönlendirilemez - yalnız şematik uzunluk, Kontrol sayfasında, "
|
||||
"toplama katılır ve işaretlenir.",
|
||||
"A: every end on a tag placed on the layout and every diameter direct. B: a diameter by "
|
||||
"continuity or unassigned. C: not routable - schematic length only, on Kontrol, counted "
|
||||
"in the total and flagged.",
|
||||
"A: каждый конец на теге, найденном на планировке, и каждый диаметр прямо. B: диаметр "
|
||||
"по непрерывности или не назначен. C: трассировка невозможна — только длина по схеме, "
|
||||
"на листе Kontrol, входит в итог и помечена.",
|
||||
),
|
||||
"rule_rooms": (
|
||||
"Yönlendirilen uzunluk, güzergâhın geçtiği yerleşim mahallerine paylaştırılır (duvar "
|
||||
"yüzleri ve mahal etiketlerinden çokgenler); çokgen yoksa hatta en yakın mahal etiketine.",
|
||||
"A routed length is shared among the layout rooms its route crosses (room polygons from "
|
||||
"the wall faces and room labels); without polygons, the room label nearest the run.",
|
||||
"Трассированная длина распределяется по помещениям планировки, через которые проходит "
|
||||
"трасса (контуры из граней стен и обозначений помещений); без контуров — ближайшему "
|
||||
"обозначению помещения.",
|
||||
),
|
||||
"rule_multi": (
|
||||
"Birden çok çaplı bir hat, uzunluğunu P&ID üzerindeki çizili uzunlukları oranında "
|
||||
"paylaştırır.",
|
||||
"A run holding several diameters shares its length among them in proportion to their "
|
||||
"drawn length on the P&ID.",
|
||||
"Линия с несколькими диаметрами делит длину между ними пропорционально их длине на "
|
||||
"схеме P&ID.",
|
||||
),
|
||||
"rule_cable_route": (
|
||||
"Uzunluk = yerleşimde yükün etiketinden panosunun etiketine Manhattan mesafesi.",
|
||||
"Length = the Manhattan distance on the layout from the load's tag to its panel's tag.",
|
||||
"Длина = манхэттенское расстояние на планировке от тега нагрузки до тега её шкафа.",
|
||||
),
|
||||
"rule_rounding": (
|
||||
"Kablo = YUKARIYUVARLA(uzunluk × (1 + pay)) tam metreye; uzunluk önce 1e-6 m'ye "
|
||||
"yuvarlanır.",
|
||||
"Cable = ROUNDUP(length × (1 + allowance)) to the whole metre; the length is rounded to "
|
||||
"1e-6 m first.",
|
||||
"Кабель = ОКРВВЕРХ(длина × (1 + запас)) до целого метра; длина сначала округляется до "
|
||||
"1e-6 м.",
|
||||
),
|
||||
"rule_power": (
|
||||
"Güç, P&ID'de her etiketin yakınındaki elektrik yazılarından okunur; gücü yazılmamış "
|
||||
"yükün hücresi boş kalır ve açık madde olur - güç asla uydurulmaz.",
|
||||
"Power is read from the P&ID's electrical texts near each tag; a load with no stated "
|
||||
"power keeps an empty cell and an open item - power is never invented.",
|
||||
"Мощность берётся из электрических надписей P&ID рядом с каждым тегом; у нагрузки без "
|
||||
"указанной мощности ячейка пуста и заводится открытый вопрос — мощность никогда не "
|
||||
"выдумывается.",
|
||||
),
|
||||
"rule_panel": (
|
||||
"Pano, yerleşimdeki bağlantı notlarından ('Wiring to CP1' gibi), yoksa P&ID'den alınır; "
|
||||
"adlar normalleştirilir (CP 1 → CP-1).",
|
||||
"The panel comes from the layout's wiring callouts ('Wiring to CP1' and its variants), "
|
||||
"else from the P&ID; names are normalised (CP 1 → CP-1).",
|
||||
"Шкаф берётся из выносок подключения на планировке ('Wiring to CP1' и варианты), иначе "
|
||||
"из P&ID; имена нормализуются (CP 1 → CP-1).",
|
||||
),
|
||||
"rule_package": (
|
||||
"Pano paketleri: kendisi güç belirten bir panoya bağlı yük o paketin parçasıdır; mahal ve "
|
||||
"pano toplamları paketin gücünü sayar, alt yüklerini saymaz - hiçbir güç iki kez "
|
||||
"sayılmaz.",
|
||||
"Panel packages: 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 child loads, so "
|
||||
"no power is counted twice.",
|
||||
"Комплектные шкафы: нагрузка, подключённая к шкафу с собственной указанной мощностью, "
|
||||
"входит в его комплект; итоги по помещениям и шкафам учитывают мощность комплекта, а не "
|
||||
"его дочерних нагрузок, — ничего не учитывается дважды.",
|
||||
),
|
||||
"rule_section": (
|
||||
"Kablo kesitleri yalnız çağıranın section_rules tablosundan gelir; tablo yoksa sütun "
|
||||
"boş kalır. Hiçbir standart tablo uygulanmaz.",
|
||||
"Cable sections come only from the caller's section_rules; without them the column "
|
||||
"stays blank. No standard table is applied.",
|
||||
"Сечения кабелей — только из таблицы section_rules вызывающего; без неё столбец пуст. "
|
||||
"Никакая стандартная таблица не применяется.",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def check_lang(lang: str) -> str:
|
||||
"""Refuse a language the workbook does not speak, naming the three it does."""
|
||||
if lang not in LANGS:
|
||||
raise ValueError(f"lang: {lang!r} unknown; choose from {', '.join(LANGS)}")
|
||||
return lang
|
||||
|
||||
|
||||
def _t(key: str, lang: str) -> str:
|
||||
"""A label in `lang`; a key with no label (a diameter, a room number) prints as itself."""
|
||||
entry = LABELS.get(key)
|
||||
return entry[_INDEX[lang]] if entry else key
|
||||
|
||||
|
||||
def _say(key: str, lang: str) -> str:
|
||||
"""One of the method sheet's sentences in `lang`."""
|
||||
return TEXTS[key][_INDEX[lang]]
|
||||
|
||||
|
||||
def _num(value):
|
||||
return None if value is None else round(float(value), 3)
|
||||
|
||||
|
||||
def _yes(value, lang: str):
|
||||
return None if value is None else _t("yes" if value else "no", lang)
|
||||
|
||||
|
||||
def _pipe_sheets(result: dict, lang: str) -> dict[str, tuple[list, list]]:
|
||||
columns = (
|
||||
"room",
|
||||
"service",
|
||||
"diameter",
|
||||
"direct_m",
|
||||
"continuity_m",
|
||||
"unassigned_m",
|
||||
"net_m",
|
||||
"allowance",
|
||||
"allowance_m",
|
||||
"total_m",
|
||||
"schematic_m",
|
||||
"status",
|
||||
"runs",
|
||||
)
|
||||
rows = [
|
||||
[
|
||||
row["room"],
|
||||
_t(row["service"], lang),
|
||||
row["diameter"],
|
||||
_num(row["direct_m"]),
|
||||
_num(row["continuity_m"]),
|
||||
_num(row["unassigned_m"]),
|
||||
_num(row["net_m"]),
|
||||
row["allowance"],
|
||||
_num(row["allowance_m"]),
|
||||
_num(row["total_m"]),
|
||||
_num(row["schematic_m"]),
|
||||
row["status"],
|
||||
", ".join(row["runs"]),
|
||||
]
|
||||
for row in result["rows"]
|
||||
]
|
||||
totals = result["totals"]
|
||||
a = totals["allowance"]
|
||||
rows.append(
|
||||
[
|
||||
_t("routed", lang),
|
||||
None,
|
||||
None,
|
||||
_num(sum(r["direct_m"] for r in result["rows"])),
|
||||
_num(sum(r["continuity_m"] for r in result["rows"])),
|
||||
_num(sum(r["unassigned_m"] for r in result["rows"])),
|
||||
_num(totals["routed_m"]),
|
||||
a,
|
||||
_num(totals["routed_m"] * a),
|
||||
_num(totals["routed_m"] * (1.0 + a)),
|
||||
_num(sum(r["schematic_m"] for r in result["rows"])),
|
||||
None,
|
||||
None,
|
||||
]
|
||||
)
|
||||
rows.append(
|
||||
[
|
||||
_t("unroutable_total", lang),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
_num(totals["unroutable_m"]),
|
||||
a,
|
||||
_num(totals["unroutable_m"] * a),
|
||||
_num(totals["unroutable_m"] * (1.0 + a)),
|
||||
_num(totals["unroutable_m"]),
|
||||
"C",
|
||||
str(totals["flagged_runs"]),
|
||||
]
|
||||
)
|
||||
rows.append(
|
||||
[
|
||||
_t("total", lang),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
_num(totals["net_m"]),
|
||||
a,
|
||||
_num(totals["allowance_m"]),
|
||||
_num(totals["total_m"]),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
]
|
||||
)
|
||||
|
||||
summary = []
|
||||
for group, field in (
|
||||
("by_service", "service"),
|
||||
("by_diameter", "diameter"),
|
||||
("by_room", "room"),
|
||||
):
|
||||
for item in result["summary"][group]:
|
||||
key = _t(item["key"], lang) if field == "service" else item["key"]
|
||||
summary.append([_t(group, lang), key, _num(item["net_m"]), _num(item["total_m"])])
|
||||
summary.append([_t("total", lang), None, _num(totals["net_m"]), _num(totals["total_m"])])
|
||||
|
||||
control = [
|
||||
[
|
||||
c["run"],
|
||||
_t(c["kind"], lang),
|
||||
_t(c["service"], lang),
|
||||
", ".join(c["tags"]),
|
||||
_num(c["schematic_m"]),
|
||||
_t(c["reason"], lang),
|
||||
]
|
||||
for c in result["control"]
|
||||
]
|
||||
return {
|
||||
"Metraj": ([_t(c, lang) for c in columns], rows),
|
||||
"Özet": ([_t(c, lang) for c in ("group", "key", "net_m", "total_m")], summary),
|
||||
"Kontrol": (
|
||||
[_t(c, lang) for c in ("run", "kind", "service", "tags", "schematic_m", "reason")],
|
||||
control,
|
||||
),
|
||||
"Metodoloji": ([_t("topic", lang), _t("value", lang)], _pipe_method(result, lang)),
|
||||
}
|
||||
|
||||
|
||||
def _unit_text(unit: dict | None) -> str:
|
||||
if not unit:
|
||||
return "—"
|
||||
return f"INSUNITS {unit['declared'] or '—'} → {unit['inferred']}"
|
||||
|
||||
|
||||
def _pipe_method(result: dict, lang: str) -> list[list]:
|
||||
method, scale = result["method"], result["scale"]
|
||||
reason = method["reason"]
|
||||
if reason.startswith("auto_") and reason != "auto_to_scale":
|
||||
reason = "auto_layout"
|
||||
rows = [
|
||||
["source_pid", method["sources"]["pid"]],
|
||||
["source_layout", method["sources"]["layout"] or "—"],
|
||||
["length_source", method["length_source"]],
|
||||
["choice", _say(reason, lang)],
|
||||
["scale_verdict", scale.get("verdict")],
|
||||
["scale_factor", None if scale.get("factor") is None else round(float(scale["factor"]), 6)],
|
||||
["scale_iqr", None if scale.get("iqr") is None else round(float(scale["iqr"]), 6)],
|
||||
[
|
||||
"scale_within",
|
||||
None if scale.get("within_10pct") is None else round(float(scale["within_10pct"]), 4),
|
||||
],
|
||||
["scale_pairs", scale.get("pair_count")],
|
||||
["scale_matched", len(scale.get("matched", {}))],
|
||||
["scale_ambiguous", ", ".join(scale.get("ambiguous", [])) or "—"],
|
||||
["scale_note", _say("scale_note", lang)],
|
||||
["scale_verified", _yes(result["scale_verified"], lang)],
|
||||
["units_pid", _unit_text(result["units"]["pid"])],
|
||||
["units_layout", _unit_text(result["units"]["layout"])],
|
||||
["services", ", ".join(_t(s, lang) for s in method["services"])],
|
||||
["rule_route", _say("rule_route", lang)],
|
||||
["rule_vertical", method["vertical_allowance"]],
|
||||
["rule_allowance", method["allowance"]],
|
||||
["rule_diameter", _say("rule_diameter", lang)],
|
||||
["rule_service", _say("rule_service", lang)],
|
||||
["rule_status", _say("rule_status", lang)],
|
||||
["rule_rooms", _say("rule_rooms", lang)],
|
||||
["rule_multi", _say("rule_multi", lang)],
|
||||
["tol", method["tol"]],
|
||||
["label_search", round(float(method["label_search"]), 6)],
|
||||
["overlap", _num(method["overlap_length_m"])],
|
||||
]
|
||||
rows += [["warning", warning] for warning in result["warnings"]]
|
||||
return [[_t(topic, lang), value] for topic, value in rows]
|
||||
|
||||
|
||||
def _cable_sheets(result: dict, lang: str) -> dict[str, tuple[list, list]]:
|
||||
columns = (
|
||||
"tag",
|
||||
"room",
|
||||
"kw",
|
||||
"phases",
|
||||
"voltage",
|
||||
"neutral",
|
||||
"vfd",
|
||||
"panel",
|
||||
"length_m",
|
||||
"allowance",
|
||||
"cable_m",
|
||||
"section",
|
||||
"package",
|
||||
)
|
||||
rows = [
|
||||
[
|
||||
row["tag"],
|
||||
row["room"],
|
||||
row["kw"],
|
||||
row["phases"],
|
||||
row["voltage"],
|
||||
_yes(row["neutral"], lang),
|
||||
_yes(row["vfd"], lang),
|
||||
row["panel"],
|
||||
_num(row["length_m"]),
|
||||
row["allowance"],
|
||||
row["cable_m"],
|
||||
row["section"],
|
||||
row["package"],
|
||||
]
|
||||
for row in result["rows"]
|
||||
]
|
||||
totals = result["totals"]
|
||||
rows.append(
|
||||
[
|
||||
_t("total", lang),
|
||||
None,
|
||||
_num(totals["known_kw"]),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
totals["cable_m"],
|
||||
None,
|
||||
None,
|
||||
]
|
||||
)
|
||||
summary = []
|
||||
for group in ("by_room", "by_panel"):
|
||||
for item in result["summary"][group]:
|
||||
summary.append(
|
||||
[_t(group, lang), item["key"], item["loads"], _num(item["kw"]), item["cable_m"]]
|
||||
)
|
||||
summary.append(
|
||||
[_t("total", lang), None, totals["loads"], _num(totals["known_kw"]), totals["cable_m"]]
|
||||
)
|
||||
control = [
|
||||
[item["tag"], _t(item["item"], lang), item["detail"]] for item in result["open_items"]
|
||||
]
|
||||
return {
|
||||
"Metraj": ([_t(c, lang) for c in columns], rows),
|
||||
"Özet": ([_t(c, lang) for c in ("group", "key", "loads", "kw", "cable_m")], summary),
|
||||
"Kontrol": ([_t(c, lang) for c in ("tag", "item", "detail")], control),
|
||||
"Metodoloji": ([_t("topic", lang), _t("value", lang)], _cable_method(result, lang)),
|
||||
}
|
||||
|
||||
|
||||
def _cable_method(result: dict, lang: str) -> list[list]:
|
||||
method = result["method"]
|
||||
rules = method["section_rules"]
|
||||
rows = [
|
||||
["source_pid", method["sources"]["pid"]],
|
||||
["source_layout", method["sources"]["layout"] or "—"],
|
||||
["units_pid", _unit_text(result["units"]["pid"])],
|
||||
["units_layout", _unit_text(result["units"]["layout"])],
|
||||
["rule_cable_route", _say("rule_cable_route", lang)],
|
||||
["rule_allowance", method["allowance"]],
|
||||
["rule_rounding", _say("rule_rounding", lang)],
|
||||
["rule_power", _say("rule_power", lang)],
|
||||
["rule_panel", _say("rule_panel", lang)],
|
||||
["rule_package", _say("rule_package", lang)],
|
||||
["rule_section", _say("rule_section", lang)],
|
||||
["section_rules", "; ".join(_rule_text(rule) for rule in rules) or "—"],
|
||||
["label_search", round(float(method["search"]["pid"]), 6)],
|
||||
]
|
||||
rows += [["warning", warning] for warning in result["warnings"]]
|
||||
return [[_t(topic, lang), value] for topic, value in rows]
|
||||
|
||||
|
||||
def _rule_text(rule: dict) -> str:
|
||||
phases = "" if rule["phases"] is None else f" / {rule['phases']} ph"
|
||||
return f"<= {rule['max_kw']:g} kW{phases}: {rule['section']}"
|
||||
|
||||
|
||||
_BUILDERS = {"pipe": _pipe_sheets, "cable": _cable_sheets}
|
||||
|
||||
|
||||
def write_workbook(result: dict, *, kind: str, lang: str, path: str) -> dict:
|
||||
"""Write the takeoff: CSV always, XLSX when openpyxl is installed.
|
||||
|
||||
``{"xlsx": path | None, "csv": [paths], "refused": {...} | None}``. Refused
|
||||
before a file is written: a `kind` it builds no sheets for, a `lang` other
|
||||
than tr / en / ru, and a `path` with a suffix other than .xlsx. Without
|
||||
openpyxl the CSV files are written and `refused` carries the
|
||||
`xlsx_write` capability refusal naming the `office` extra.
|
||||
"""
|
||||
if kind not in _BUILDERS:
|
||||
raise ValueError(f"kind: {kind!r} unknown; choose from {', '.join(_BUILDERS)}")
|
||||
check_lang(lang)
|
||||
target = Path(path)
|
||||
if target.suffix.lower() not in ("", ".xlsx"):
|
||||
raise ValueError(
|
||||
f"path: the workbook is written as .xlsx with CSV files beside it; got {target.suffix!r}"
|
||||
)
|
||||
sheets = _BUILDERS[kind](result, lang)
|
||||
xlsx = target.with_suffix(".xlsx")
|
||||
xlsx.parent.mkdir(parents=True, exist_ok=True)
|
||||
written = []
|
||||
for name, (header, rows) in sheets.items():
|
||||
csv_path = xlsx.with_name(f"{xlsx.stem}_{CSV_SLUGS[name]}.csv")
|
||||
with open(csv_path, "w", newline="", encoding="utf-8-sig") as handle:
|
||||
writer = csv.writer(handle)
|
||||
writer.writerow(header)
|
||||
writer.writerows([["" if cell is None else cell for cell in row] for row in rows])
|
||||
written.append(str(csv_path))
|
||||
if not xlsx_available():
|
||||
refusal = UnsupportedCapabilityError(
|
||||
"xlsx_write",
|
||||
"The takeoff workbook needs openpyxl for XLSX, which is not installed. Install it "
|
||||
'with: pip install -e ".[office]" (or pip install openpyxl). The same sheets were '
|
||||
f"written as CSV: {', '.join(written)}",
|
||||
)
|
||||
return {"xlsx": None, "csv": written, "refused": refusal.to_dict()}
|
||||
from openpyxl import Workbook
|
||||
|
||||
book = Workbook()
|
||||
for i, (name, (header, rows)) in enumerate(sheets.items()):
|
||||
sheet = book.active if i == 0 else book.create_sheet()
|
||||
sheet.title = name
|
||||
sheet.append(header)
|
||||
for row in rows:
|
||||
sheet.append(row)
|
||||
book.save(xlsx)
|
||||
return {"xlsx": str(xlsx), "csv": written, "refused": None}
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Orthogonal lengths on a layout: Manhattan distance and the rectilinear MST.
|
||||
|
||||
A pipe or cable on a plant floor runs along the building grid, so its length
|
||||
between two points is estimated as |dx| + |dy| - never the diagonal. A run that
|
||||
joins more than two pieces of equipment is estimated as the minimum spanning
|
||||
tree of their positions under that distance (Prim's algorithm); a two-ended run
|
||||
is simply |dx| + |dy|. This is the tree on the given points only - no Steiner
|
||||
points are added - so it is the length a drafter gets by routing each
|
||||
connection separately, and it is exact for the points given.
|
||||
|
||||
A route is drawn along x first, then y (`route_legs`): deterministic, and
|
||||
stated on every method sheet that uses it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
|
||||
from engineering.understand.snapshot import Pt
|
||||
|
||||
__all__ = ["manhattan", "rmst", "route_legs"]
|
||||
|
||||
|
||||
def manhattan(a: Pt, b: Pt) -> float:
|
||||
"""|dx| + |dy| between two points."""
|
||||
return abs(a[0] - b[0]) + abs(a[1] - b[1])
|
||||
|
||||
|
||||
def rmst(points: Sequence[Pt]) -> tuple[float, tuple[tuple[int, int], ...]]:
|
||||
"""Rectilinear minimum spanning tree: (total length, edges as (from, to) indices).
|
||||
|
||||
Prim's algorithm from point 0; each edge is (the tree vertex it grows from,
|
||||
the new vertex), in the order the tree grows. Ties go to the lower index,
|
||||
so the answer is deterministic. Fewer than two points: (0.0, ()).
|
||||
"""
|
||||
n = len(points)
|
||||
if n < 2:
|
||||
return 0.0, ()
|
||||
in_tree = [False] * n
|
||||
best = [math.inf] * n
|
||||
parent = [-1] * n
|
||||
best[0] = 0.0
|
||||
total = 0.0
|
||||
edges: list[tuple[int, int]] = []
|
||||
for _ in range(n):
|
||||
k = min((i for i in range(n) if not in_tree[i]), key=lambda i: (best[i], i))
|
||||
in_tree[k] = True
|
||||
if parent[k] >= 0:
|
||||
edges.append((parent[k], k))
|
||||
total += best[k]
|
||||
for j in range(n):
|
||||
if not in_tree[j]:
|
||||
d = manhattan(points[k], points[j])
|
||||
if d < best[j]:
|
||||
best[j], parent[j] = d, k
|
||||
return total, tuple(edges)
|
||||
|
||||
|
||||
def route_legs(a: Pt, b: Pt) -> tuple[tuple[Pt, Pt], ...]:
|
||||
"""The orthogonal route from a to b: along x first, then y; zero-length legs dropped."""
|
||||
corner = (b[0], a[1])
|
||||
return tuple(leg for leg in ((a, corner), (corner, b)) if leg[0] != leg[1])
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user