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:
Umut CAN
2026-09-24 12:48:12 +03:00
co-authored by Claude Opus 5.5
16 changed files with 5215 additions and 7 deletions
+119
View File
@@ -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
+967
View File
@@ -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
+682
View File
@@ -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}
+63
View File
@@ -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