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
+3 -3
View File
@@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
</div>
> **v1.5 release snapshot:** 245 tools · 8 resources · 5 prompt templates · 4516 collected tests.
> 245 is the **registered** count; a default install advertises 240 over `tools/list`,
> **v1.5 release snapshot:** 247 tools · 8 resources · 5 prompt templates · 4516 collected tests.
> 247 is the **registered** count; a default install advertises 242 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
## Why this exists
@@ -122,7 +122,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) |
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
<sub>245 tools in 26 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools, the 22 environment tools, the 14 mechanical tools and the 12 architectural tools (<code>TOOL_PACKS=core,mech</code> keeps the mechanical ones, <code>TOOL_PACKS=core,arch</code> the architectural ones) from a client that needs none of them. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
<sub>247 tools in 26 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools, the 22 environment tools, the 14 mechanical tools and the 12 architectural tools (<code>TOOL_PACKS=core,mech</code> keeps the mechanical ones, <code>TOOL_PACKS=core,arch</code> the architectural ones) from a client that needs none of them. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
**Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy.
+39
View File
@@ -2538,6 +2538,45 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"kopuk hatlar",
),
),
"pipe_takeoff": ToolAliases(
acad=(),
synonyms=(
"pipe takeoff",
"piping takeoff",
"takeoff",
"pipe quantities",
"how much pipe",
"pipe lengths from a p&id",
"bill of quantities",
"boq",
"metraj",
"boru metrajı",
"boru metraji",
"metre cetveli",
"boru listesi",
"спецификация",
"ведомость",
"спецификация трубопроводов",
"ведомость трубопроводов",
),
),
"cable_takeoff": ToolAliases(
acad=(),
synonyms=(
"cable takeoff",
"cable schedule",
"cable lengths",
"cable list",
"how much cable",
"motor cable lengths",
"kablo metrajı",
"kablo metraji",
"kablo listesi",
"kablo cetveli",
"кабельный журнал",
"ведомость кабелей",
),
),
}
+32 -2
View File
@@ -3,13 +3,13 @@
"schema_version": 1,
"package_version": "1.5.1",
"totals": {
"tools": 245,
"tools": 247,
"resources": 8,
"prompts": 5,
"groups": 26
},
"groups": {
"analysis": 16,
"analysis": 18,
"architecture": 12,
"batch": 3,
"blocks": 9,
@@ -543,6 +543,21 @@
"destructiveHint": false
}
},
{
"name": "cable_takeoff",
"group": "analysis",
"cost": "safe",
"summary": "Cable takeoff: one row per load, Manhattan length to its panel, rounded up to the metre.",
"tags": [
"analysis",
"plant"
],
"annotations": {
"title": "Plant: Cable Takeoff",
"readOnlyHint": false,
"destructiveHint": false
}
},
{
"name": "cad_batch",
"group": "batch",
@@ -2580,6 +2595,21 @@
"readOnlyHint": true
}
},
{
"name": "pipe_takeoff",
"group": "analysis",
"cost": "safe",
"summary": "Pipe takeoff from a P&ID and its layout: rows by room, service and diameter.",
"tags": [
"analysis",
"plant"
],
"annotations": {
"title": "Plant: Pipe Takeoff",
"readOnlyHint": false,
"destructiveHint": false
}
},
{
"name": "plot_style_list",
"group": "page_setup",
+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
+265
View File
@@ -11028,6 +11028,271 @@ async def drawing_topology_check(
return report
# ---------------------------------------------------------------------------
# ── SECTION 27: Plant Takeoffs (2 tools) ────────────────────────────────────
# ---------------------------------------------------------------------------
@cad_tool(
summary="Pipe takeoff from a P&ID and its layout: rows by room, service and diameter.",
cost="safe",
)
@mcp.tool(
annotations={
"title": "Plant: Pipe Takeoff",
"readOnlyHint": False,
"destructiveHint": False,
},
tags={"plant", "analysis"},
)
async def pipe_takeoff(
pid_path: Annotated[
str | None,
Field(
default=None,
description="The P&ID (DXF headlessly; DXF or DWG on a live seat). Omit to read "
"the current drawing as the P&ID.",
),
] = None,
layout_path: Annotated[
str | None,
Field(
default=None,
description="The layout of the same plant. Omitted, every length is the P&ID's "
"drawn length and is marked scale_verified: false.",
),
] = None,
output: Annotated[
str | None,
Field(
default=None,
description="Workbook to write (.xlsx); the four sheets are also written as CSV "
"beside it. Omit to return the rows only.",
),
] = None,
lang: Annotated[
str, Field(default="en", description="Headers and method text: tr | en | ru.")
] = "en",
services: Annotated[
list[str] | None,
Field(
default=None,
description="Services to take off; default product, cip_supply, cip_return. Also: "
"glycol_supply, glycol_return, steam, condensate, cold_water, hot_water, "
"ice_water, compressed_air, drain, electrical, unknown.",
),
] = None,
allowance: Annotated[
float,
Field(
default=0.20,
ge=0.0,
le=1.0,
description="Allowance as a share of the net length (0.20 = 20 %); its own column.",
),
] = 0.20,
vertical_allowance: Annotated[
float,
Field(
default=0.0,
ge=0.0,
description="Metres added per connection for rises and drops (layout lengths).",
),
] = 0.0,
length_source: Annotated[
str,
Field(
default="auto",
description="auto | pid | layout. auto measures on the P&ID only when the scale "
"check calls it to scale, else on the layout.",
),
] = "auto",
force: Annotated[
bool,
Field(
default=False,
description="With length_source='pid', measure on a P&ID the scale check calls "
"schematic anyway.",
),
] = False,
layers: Annotated[
list[str] | None,
Field(
default=None,
description="P&ID layers holding the lines; default every layer the vocabulary "
"files under piping or electrical with confidence >= 0.9.",
),
] = None,
tol: Annotated[
float,
Field(
default=1.0,
gt=0.0,
description="Line ends closer than this (P&ID drawing units) are one junction.",
),
] = 1.0,
ctx: Context = None,
) -> dict:
"""A pipe takeoff: topology from the P&ID, lengths from the layout, rows by room x service x diameter.
The P&ID gives the runs (junctions merged, tees split, a line drawn twice
counted once, valves and reducers bridged), each run's service (the layer,
overridden by a supply / return word near the line), its diameters exactly
as labelled (direct, carried by continuity - never across a reducer - or
unassigned) and the equipment tags at its ends. The length comes from the
layout unless the scale check calls the P&ID to scale: the rectilinear
minimum spanning tree of the run's tag positions on the layout, each
connection along x first then y, plus `vertical_allowance` per connection.
The P&ID's drawn length stays beside it as `schematic_m`, never as the
answer. Lengths are metres from the unit the geometry implies; an INSUNITS
that disagrees is a warning, never a factor. Status A / B / C per run: a C
run (an end on no equipment, a tag missing from or written twice on the
layout) is on the Kontrol sheet with its schematic length, counted in the
total and flagged.
Read-only on both drawings. With `output` it writes one workbook - Metraj,
Özet, Kontrol, Metodoloji - as XLSX and, always, as CSV beside it.
Refused before a drawing is read: `lang` outside tr / en / ru, an unknown
service or length source, an allowance outside 0-1, a negative vertical
allowance, a non-positive tolerance, and `length_source='layout'` without
a layout. A missing, unreadable or oversize drawing is refused by the
reader, which names MAX_DXF_BYTES. Refused after the scale check:
`length_source='pid'` on a P&ID it calls schematic, unless `force=True`
(the refusal quotes the statistics). Without openpyxl the XLSX is not
written and `files.refused` carries `capability: "xlsx_write"` naming the
`office` extra; the CSV files are written all the same.
"""
from engineering.understand.draw import run_pipe_takeoff
pid = str(validate_path(pid_path)) if pid_path else None
layout = str(validate_path(layout_path)) if layout_path else None
target = str(validate_path(output, allow_write=True)) if output else None
await ctx.info(f"pipe_takeoff: {pid or 'current drawing'} / {layout or 'no layout'}")
try:
return await run_pipe_takeoff(
_backend(ctx),
pid_path=pid,
layout_path=layout,
output=target,
lang=lang,
services=services,
allowance=allowance,
vertical_allowance=vertical_allowance,
length_source=length_source,
force=force,
layers=layers,
tol=tol,
)
except (ValueError, OSError) as exc:
raise ToolError(str(exc)) from exc
@cad_tool(
summary="Cable takeoff: one row per load, Manhattan length to its panel, rounded up to the metre.",
cost="safe",
)
@mcp.tool(
annotations={
"title": "Plant: Cable Takeoff",
"readOnlyHint": False,
"destructiveHint": False,
},
tags={"plant", "analysis"},
)
async def cable_takeoff(
pid_path: Annotated[
str | None,
Field(
default=None,
description="The P&ID carrying the electrical texts (kW, phases, voltage, VFD). "
"Omit to read the current drawing.",
),
] = None,
layout_path: Annotated[
str | None,
Field(
default=None,
description="The layout with the tags, the panels and the 'Wiring to CPn' "
"callouts. Omitted, every length stays empty with an open item.",
),
] = None,
output: Annotated[
str | None,
Field(
default=None,
description="Workbook to write (.xlsx); the four sheets are also written as CSV "
"beside it. Omit to return the rows only.",
),
] = None,
lang: Annotated[
str, Field(default="en", description="Headers and method text: tr | en | ru.")
] = "en",
allowance: Annotated[
float,
Field(
default=0.20,
ge=0.0,
le=1.0,
description="Allowance before rounding up to the metre (0.20 = 20 %).",
),
] = 0.20,
section_rules: Annotated[
list[dict] | None,
Field(
default=None,
description="Your own section table: [{max_kw, section, phases (1|3, optional)}], "
"e.g. [{'max_kw': 5.5, 'section': '5x2.5'}]. Omitted, no section is proposed.",
),
] = None,
ctx: Context = None,
) -> dict:
"""A cable takeoff: one row per electrical load, the shop's cable method made repeatable.
Power, phases, voltage, neutral and VFD come from the P&ID's electrical
texts near each tag (Russian, Turkish and English spellings). **Power is
never invented**: a load with no stated kW keeps an empty cell and an open
item. The panel comes from the layout's wiring callouts ('Wiring to CP1'
and its variants, normalised to CP-1), else from the P&ID. The length is the
Manhattan distance on the layout from the load's tag to its panel's tag,
and the cable is ROUNDUP(length x (1 + allowance)) to the metre. Room and
panel totals count known power once: a load wired to a panel that states
its own power is part of that package, and the package's power is counted
instead of its children's - the rule is written on the method sheet.
Sections only from `section_rules`; no standard table is applied. Open
items: missing power, missing panel, a tag or panel not on the layout, no
layout at all, a load no section rule covers.
Read-only on both drawings. With `output` it writes one workbook - Metraj,
Özet, Kontrol, Metodoloji - as XLSX and, always, as CSV beside it.
Refused before a drawing is read: `lang` outside tr / en / ru, an allowance
outside 0-1 and a malformed section rule (named by its path,
`section_rules[0].section: ...`). A missing, unreadable or oversize drawing
is refused by the reader, which names MAX_DXF_BYTES. Without openpyxl the
XLSX is not written and `files.refused` carries `capability: "xlsx_write"`
naming the `office` extra; the CSV files are written all the same.
"""
from engineering.understand.draw import run_cable_takeoff
pid = str(validate_path(pid_path)) if pid_path else None
layout = str(validate_path(layout_path)) if layout_path else None
target = str(validate_path(output, allow_write=True)) if output else None
await ctx.info(f"cable_takeoff: {pid or 'current drawing'} / {layout or 'no layout'}")
try:
return await run_cable_takeoff(
_backend(ctx),
pid_path=pid,
layout_path=layout,
output=target,
lang=lang,
allowance=allowance,
section_rules=section_rules,
)
except (ValueError, OSError) as exc:
raise ToolError(str(exc)) from exc
# ---------------------------------------------------------------------------
# ── RESOURCES ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
+16 -2
View File
@@ -609,10 +609,24 @@ async def test_tool_group_sizes_are_unchanged():
Merged, the two SECTION 26 halves file all four readers under `analysis`
(12 -> 16).
`analysis` moved 12 -> 13 when track H's SECTION 27 opened with
`pipe_takeoff` on the network branch. It is tagged `plant` and
`analysis`, and `plant` is not in `_GROUP_TAG_PRIORITY` on that
branch, so it files under `analysis`. This snapshot is branch-local:
the track H integration task gives `plant` its pack and its group
once every branch has landed.
It moved 13 -> 14 when `cable_takeoff` joined the same section, for
the same branch-local reason.
With SECTION 27 merged beside them, the four readers and the two
takeoffs all file under `analysis` (12 -> 18) until the track H
integration task ranks `understand` and `plant`.
"""
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
assert sizes == {
"analysis": 16,
"analysis": 18,
"architecture": 12,
"batch": 3,
"blocks": 9,
@@ -639,4 +653,4 @@ async def test_tool_group_sizes_are_unchanged():
"validation": 1,
"view": 4,
}
assert sum(sizes.values()) == 245
assert sum(sizes.values()) == 247
+386
View File
@@ -0,0 +1,386 @@
"""Cable takeoff rows: loads from the P&ID, panels and Manhattan lengths from the layout.
The hand-built pair. On the P&ID: M82 with 'P = 18,5 кВт' and '3 ф. 400 В +
N + PE' under it, M87 with only '3 ф. 400 В + N + PE' (no power stated), the
package panel CP-M88 with 'P = 15 кВт' and its child motor M88 with
'P = 4 кВт'. On the layout (mm): two rooms split at x = 20000, the panel CP-1
at (15000, 1000), M82 (2000, 3000), M87 (4000, 3000), CP-M88 (25000, 5000),
M88 (26000, 8000), each with a 'Wiring to ...' callout 300 mm below it.
Every expected length is worked in the comment beside it. The last two tests
read the synthetic plant pair of Task 2 and compare every cable row with the
generator's own Manhattan lengths.
"""
from __future__ import annotations
import csv
import pytest
from engineering.understand.labels import normalize_panel
from engineering.understand.report import write_workbook
from engineering.understand.snapshot import EntityRecord, Snapshot, read_snapshot
from engineering.understand.takeoff import cable_rows, roundup_m
from engineering.understand.vocab import classify_layer, room_label
from tests.fixtures.plant_pair import build_plant_pair
EPS = 1e-9
WALLS = "WALLS"
def rec(handle, kind, layer, points=(), **extra):
return EntityRecord(
handle=handle, type=kind, layer=layer, space="Model", points=tuple(points), **extra
)
def text(handle, value, at, height, layer="TEXT"):
x, y = at
return rec(
handle,
"TEXT",
layer,
(at,),
text=value,
height=height,
bbox=(x, y, x + height * len(value) * 0.6, y + height),
)
def snap(records, insunits, source):
layers = {r.layer: {"color": 7, "linetype": "Continuous"} for r in records}
return Snapshot(
source=source,
insunits=insunits,
extmin=None,
extmax=None,
layers=layers,
layouts=(),
records=tuple(records),
)
def pid_records():
return [
text("a", "M82", (0.0, 0.0), 50.0),
text("a1", "P = 18,5 кВт", (0.0, -80.0), 50.0),
text("a2", "3 ф. 400 В + N + PE", (0.0, -160.0), 50.0),
text("b", "M87", (1000.0, 0.0), 50.0),
text("b1", "3 ф. 400 В + N + PE", (1000.0, -80.0), 50.0),
text("c", "CP-M88", (2000.0, 0.0), 50.0),
text("c1", "P = 15 кВт", (2000.0, -80.0), 50.0),
text("d", "M88", (3000.0, 0.0), 50.0),
text("d1", "P = 4 кВт", (3000.0, -80.0), 50.0),
]
def layout_records(skip=()):
places = {
"M82": (2000.0, 3000.0),
"M87": (4000.0, 3000.0),
"CP-M88": (25000.0, 5000.0),
"M88": (26000.0, 8000.0),
}
panels = {"M82": "CP1", "M87": "CP 1", "CP-M88": "CP-1", "M88": "CP-M88"}
records = [
rec(
"W",
"LWPOLYLINE",
WALLS,
((0, 0), (40000, 0), (40000, 20000), (0, 20000)),
closed=True,
bbox=(0.0, 0.0, 40000.0, 20000.0),
),
rec("X", "LINE", WALLS, ((20000, 0), (20000, 20000))),
text("R1", "ROOM 101", (5000.0, 10000.0), 250.0),
text("R2", "ROOM 102", (30000.0, 10000.0), 250.0),
text("P1", "CP-1", (15000.0, 1000.0), 250.0),
]
for tag, (x, y) in places.items():
if tag in skip:
continue
records.append(text("t" + tag, tag, (x, y), 250.0))
records.append(text("w" + tag, f"Wiring to {panels[tag]}", (x, y - 300.0), 250.0))
return records
def room_key(label):
found = room_label(label)
assert found is not None, f"the vocabulary must read {label!r} as a room label"
return str(found.get("number") or found.get("name"))
@pytest.fixture
def pair():
assert classify_layer(WALLS)["discipline"] == "architecture", "WALLS must be a wall layer"
return snap(pid_records(), 4, "pid.dxf"), snap(layout_records(), 4, "layout.dxf")
def by_tag(result):
return {row["tag"]: row for row in result["rows"]}
def test_every_load_gets_its_panel_length_and_rounded_cable(pair):
pid, layout = pair
rows = by_tag(cable_rows(pid, layout))
assert set(rows) == {"M82", "M87", "CP-M88", "M88"}
expected = {
# tag: (panel, Manhattan mm, ROUNDUP(m x 1.2))
"M82": ("CP-1", 15000.0, 18), # |15000-2000| + |1000-3000| = 13000 + 2000
"M87": ("CP-1", 13000.0, 16), # 11000 + 2000 -> 15.6 -> 16
"CP-M88": ("CP-1", 14000.0, 17), # 10000 + 4000 -> 16.8 -> 17
"M88": ("CP-M88", 4000.0, 5), # 1000 + 3000 -> 4.8 -> 5
}
for tag, (panel, mm, cable) in expected.items():
row = rows[tag]
assert row["panel"] == panel # 'CP1', 'CP 1' and 'CP-1' are one panel
assert row["panel_source"] == "layout"
assert row["length_m"] * 1000.0 == pytest.approx(mm, abs=1e-6)
assert row["cable_m"] == cable
def test_power_and_supply_are_read_from_the_pid_texts(pair):
pid, layout = pair
m82 = by_tag(cable_rows(pid, layout))["M82"]
assert m82["kw"] == pytest.approx(18.5)
assert (m82["phases"], m82["voltage"], m82["neutral"]) == (3, 400, True)
def test_power_is_never_invented(pair):
pid, layout = pair
result = cable_rows(pid, layout)
m87 = by_tag(result)["M87"]
assert m87["kw"] is None # the P&ID states phases and voltage, no kW
assert m87["cable_m"] == 16 # the cable is still measured
assert {
"tag": "M87",
"item": "missing_power",
"detail": "no power is stated near the tag on the P&ID",
} in result["open_items"]
assert all(item["tag"] == "M87" for item in result["open_items"])
def test_room_totals_count_a_package_and_not_its_children(pair):
pid, layout = pair
result = cable_rows(pid, layout)
rows = by_tag(result)
assert rows["M88"]["package"] == "CP-M88"
assert rows["M82"]["room"] == room_key("ROOM 101")
assert rows["CP-M88"]["room"] == room_key("ROOM 102")
rooms = {item["key"]: item for item in result["summary"]["by_room"]}
assert rooms[room_key("ROOM 101")]["kw"] == pytest.approx(18.5) # M82; M87 has none
assert rooms[room_key("ROOM 102")]["kw"] == pytest.approx(15.0) # CP-M88, not + M88's 4
assert rooms[room_key("ROOM 102")]["loads"] == 2
assert result["totals"]["known_kw"] == pytest.approx(33.5)
assert result["totals"]["cable_m"] == 18 + 16 + 17 + 5
def test_roundup_does_not_buy_a_metre_of_binary_noise():
assert 50.0 * 1.1 > 55.0 # 55.00000000000001 in floating point
assert roundup_m(50.0, 0.10) == 55
assert roundup_m(15.0, 0.20) == 18
assert roundup_m(15.001, 0.20) == 19 # 18.0012 -> 19
def test_without_a_layout_lengths_stay_empty_with_an_open_item(pair):
pid, _layout = pair
result = cable_rows(pid, None)
assert all(row["length_m"] is None and row["cable_m"] is None for row in result["rows"])
no_layout = [item["tag"] for item in result["open_items"] if item["item"] == "no_layout"]
assert sorted(no_layout) == ["CP-M88", "M82", "M87", "M88"]
# no callout on the P&ID either: the panel is open, never guessed
assert all(row["panel"] is None for row in result["rows"])
def test_a_tag_missing_from_the_layout_is_an_open_item(pair):
pid, _layout = pair
layout = snap(layout_records(skip=("M87",)), 4, "layout.dxf")
result = cable_rows(pid, layout)
items = {(item["tag"], item["item"]) for item in result["open_items"]}
assert ("M87", "tag_not_on_layout") in items
assert ("M87", "missing_panel") in items # its callout went with it
assert by_tag(result)["M87"]["cable_m"] is None
def test_sections_come_only_from_the_callers_rules(pair):
pid, layout = pair
assert all(row["section"] is None for row in cable_rows(pid, layout)["rows"])
rules = [{"max_kw": 40, "section": "5x10"}, {"max_kw": 5, "section": "5x2.5"}]
rows = by_tag(cable_rows(pid, layout, section_rules=rules))
assert rows["M82"]["section"] == "5x10" # 18.5 kW
assert rows["M88"]["section"] == "5x2.5" # 4 kW: the smaller rule is tried first
assert rows["M87"]["section"] is None # no power, no section
def test_a_load_beyond_every_rule_is_an_open_item(pair):
pid, layout = pair
result = cable_rows(pid, layout, section_rules=[{"max_kw": 10, "section": "5x4"}])
assert {"tag": "M82", "item": "no_section_rule", "detail": "no rule covers 18.5 kW"} in (
result["open_items"]
)
@pytest.mark.parametrize(
("kwargs", "message"),
[
({"allowance": 2.0}, "allowance: must lie between 0 and 1"),
({"section_rules": {"max_kw": 5}}, "section_rules: expected a list"),
(
{"section_rules": [{"max_kw": 0, "section": "x"}]},
r"section_rules\[0\]\.max_kw: must be",
),
({"section_rules": [{"max_kw": 5}]}, r"section_rules\[0\]\.section: expected a non-empty"),
(
{"section_rules": [{"max_kw": 5, "section": "x", "phases": 2}]},
r"section_rules\[0\]\.phases: 1 or 3",
),
(
{"section_rules": [{"max_kw": 5, "section": "x", "iec": True}]},
r"section_rules\[0\]: unknown key\(s\) iec",
),
],
)
def test_refusals_name_the_option(pair, kwargs, message):
pid, layout = pair
with pytest.raises(ValueError, match="^" + message):
cable_rows(pid, layout, **kwargs)
CABLE_HEADERS = {
"tr": [
"Etiket",
"Mahal",
"Güç (kW)",
"Faz",
"Gerilim (V)",
"Nötr",
"Sürücü (VFD)",
"Pano",
"Mesafe (m)",
"Pay oranı",
"Kablo (m)",
"Kesit",
"Paket",
],
"en": [
"Tag",
"Room",
"Power (kW)",
"Phases",
"Voltage (V)",
"Neutral",
"VFD",
"Panel",
"Distance (m)",
"Allowance",
"Cable (m)",
"Section",
"Package",
],
"ru": [
"Тег",
"Помещение",
"Мощность (кВт)",
"Фазы",
"Напряжение (В)",
"Нейтраль",
"ЧРП",
"Шкаф",
"Расстояние (м)",
"Запас",
"Кабель (м)",
"Сечение",
"Комплект",
],
}
@pytest.mark.parametrize("lang", ["tr", "en", "ru"])
def test_the_cable_workbook_speaks_three_languages(pair, tmp_path, lang):
pid, layout = pair
files = write_workbook(
cable_rows(pid, layout), kind="cable", lang=lang, path=str(tmp_path / "cables.xlsx")
)
with open(files["csv"][0], encoding="utf-8-sig", newline="") as handle:
metraj = list(csv.reader(handle))
assert metraj[0] == CABLE_HEADERS[lang]
assert metraj[-1][10] == str(18 + 16 + 17 + 5)
with open(files["csv"][2], encoding="utf-8-sig", newline="") as handle:
kontrol = list(csv.reader(handle))
assert [row[0] for row in kontrol[1:]] == ["M87"] # the one open item: no power
def test_the_method_sheet_states_the_package_rule_and_that_power_is_never_invented(pair, tmp_path):
pid, layout = pair
files = write_workbook(
cable_rows(pid, layout), kind="cable", lang="en", path=str(tmp_path / "cables.xlsx")
)
with open(files["csv"][3], encoding="utf-8-sig", newline="") as handle:
method = {row[0]: row[1] for row in list(csv.reader(handle))[1:]}
assert "never invented" in method["Rule: power"]
assert "counted twice" in method["Rule: panel packages"]
assert method["Section rules (the caller's)"] == "—"
# -- the synthetic plant pair (Task 2) -------------------------------------------
@pytest.fixture(scope="module")
def plant(tmp_path_factory):
truth = build_plant_pair(tmp_path_factory.mktemp("plant"))
return truth, read_snapshot(truth["pid"]), read_snapshot(truth["layout"])
def inside(point, polygon):
"""Even-odd ray cast over the generator's own room polygon."""
x, y = point
hit = False
for (x1, y1), (x2, y2) in zip(polygon, polygon[1:] + polygon[:1], strict=False):
if (y1 > y) != (y2 > y) and x < x1 + (y - y1) * (x2 - x1) / (y2 - y1):
hit = not hit
return hit
def test_every_synthetic_cable_row_equals_the_generators_truth(plant):
truth, pid, layout = plant
result = cable_rows(pid, layout)
rows = by_tag(result)
assert set(rows) == {expected["tag"] for expected in truth["cable_rows"]}
for expected in truth["cable_rows"]:
row = rows[expected["tag"]]
assert row["panel"] == expected["panel"], expected["tag"]
if expected["kw"] is None:
assert row["kw"] is None, expected["tag"]
assert {
"tag": expected["tag"],
"item": "missing_power",
"detail": "no power is stated near the tag on the P&ID",
} in result["open_items"]
else:
assert row["kw"] == pytest.approx(expected["kw"]), expected["tag"]
assert row["length_m"] * 1000.0 == pytest.approx(expected["length_mm"], abs=1e-6)
assert row["cable_m"] == expected["roundup_m"], expected["tag"]
def test_the_synthetic_room_totals_count_each_power_once(plant):
truth, pid, layout = plant
result = cable_rows(pid, layout)
rooms = [
(str(room["number"] or room["name"]), [tuple(p) for p in room["polygon"]])
for room in truth["rooms"]
]
for row in result["rows"]:
homes = [key for key, polygon in rooms if row["at"] and inside(row["at"], polygon)]
if homes:
assert row["room"] == homes[0], row["tag"]
stated = {
normalize_panel(r["tag"]) or r["tag"] for r in truth["cable_rows"] if r["kw"] is not None
}
def child(r):
return r["panel"] in stated and (normalize_panel(r["tag"]) or r["tag"]) != r["panel"]
expected_kw = sum(r["kw"] for r in truth["cable_rows"] if r["kw"] is not None and not child(r))
assert result["totals"]["known_kw"] == pytest.approx(expected_kw)
by_room = sum(item["kw"] for item in result["summary"]["by_room"])
assert by_room == pytest.approx(expected_kw)
+635
View File
@@ -0,0 +1,635 @@
"""The line-network engine: runs, duplicates once, fittings, diameters, services, equipment.
Every drawing below is built record by record, so each expected length is the
hand sum written beside it; nothing is read back from the implementation. The
last three tests read the synthetic plant pair of Task 2 and compare every run
with the generator's own truth.
"""
from __future__ import annotations
import pytest
from engineering.understand.network import (
Run,
build_network,
default_layers,
is_reducer,
label_search_default,
)
from engineering.understand.snapshot import EntityRecord, Snapshot, read_snapshot
from engineering.understand.vocab import classify_layer, equipment_kind
from tests.fixtures.plant_pair import build_plant_pair
EPS = 1e-9
PIPE = "PRODUCT"
def rec(handle, kind, layer=PIPE, points=(), **extra):
return EntityRecord(
handle=handle, type=kind, layer=layer, space="Model", points=tuple(points), **extra
)
def line(handle, a, b, layer=PIPE):
return rec(handle, "LINE", layer, (a, b))
def text(handle, value, at, height=5.0, layer="TEXT"):
x, y = at
return rec(
handle,
"TEXT",
layer,
(at,),
text=value,
height=height,
bbox=(x, y, x + height * len(value) * 0.6, y + height),
)
def tank(handle, tag, box, layer="EQUIPMENT"):
"""A closed rectangle with its tag written inside it."""
x0, y0, x1, y1 = box
outline = rec(
handle, "LWPOLYLINE", layer, ((x0, y0), (x1, y0), (x1, y1), (x0, y1)), closed=True, bbox=box
)
return [outline, text(handle + "T", tag, ((x0 + x1) / 2.0 - 10.0, (y0 + y1) / 2.0))]
def insert(handle, block, box, layer=PIPE):
return rec(
handle,
"INSERT",
layer,
(((box[0] + box[2]) / 2.0, (box[1] + box[3]) / 2.0),),
block=block,
bbox=box,
)
def snap(*records, insunits=4):
flat = []
for item in records:
flat.extend(item if isinstance(item, list) else [item])
layers = {r.layer: {"color": 7, "linetype": "Continuous"} for r in flat}
return Snapshot(
source="test",
insunits=insunits,
extmin=None,
extmax=None,
layers=layers,
layouts=(),
records=tuple(flat),
)
def total(run: Run) -> float:
return sum(edge["length"] for edge in run.edges)
def net(*records, **kw):
return build_network(snap(*records), layers=[PIPE], **kw)
# -- topology ------------------------------------------------------------------
def test_ends_within_tol_merge_into_one_run():
result = net(line("1", (0.0, 0.0), (1000.0, 0.0)), line("2", (1000.4, 0.0), (2000.0, 0.0)))
(run,) = result["runs"]
# the second line starts at the merged vertex (1000, 0): 1000 + 1000
assert abs(total(run) - 2000.0) < EPS
assert run.ends == ((0.0, 0.0), (2000.0, 0.0))
def test_a_branch_ending_on_a_header_is_a_t_junction():
result = net(line("1", (0.0, 0.0), (2000.0, 0.0)), line("2", (1000.0, 0.0), (1000.0, 800.0)))
(run,) = result["runs"]
assert abs(total(run) - 2800.0) < EPS # 2000 header + 800 branch
assert len(run.edges) == 3 # header split at the tee
assert run.ends == ((0.0, 0.0), (1000.0, 800.0), (2000.0, 0.0))
def test_two_lines_crossing_without_an_end_do_not_connect():
result = net(line("1", (0.0, 0.0), (2000.0, 0.0)), line("2", (1000.0, -500.0), (1000.0, 500.0)))
assert len(result["runs"]) == 2
# -- Review Focus 4: double-drawn pipe counts once ----------------------------
def test_a_line_drawn_twice_is_counted_once():
result = net(line("1", (0.0, 0.0), (2000.0, 0.0)), line("2", (0.0, 0.0), (2000.0, 0.0)))
(run,) = result["runs"]
assert abs(total(run) - 2000.0) < EPS # not 4000
assert abs(result["stats"]["overlap_length"] - 2000.0) < EPS
assert run.edges[0]["handles"] == ("1", "2")
def test_a_line_drawn_over_a_polyline_is_counted_once():
poly = rec("P", "LWPOLYLINE", PIPE, ((0.0, 0.0), (1000.0, 0.0), (3000.0, 0.0)))
result = net(line("L", (0.0, 0.0), (3000.0, 0.0)), poly)
(run,) = result["runs"]
# the line splits at the polyline's vertex (1000, 0): pieces 1000 + 2000, each once
assert abs(total(run) - 3000.0) < EPS
assert abs(result["stats"]["overlap_length"] - 3000.0) < EPS
assert all(edge["handles"] == ("L", "P") for edge in run.edges)
def test_a_partial_collinear_overlap_counts_the_shared_stretch_once():
result = net(line("1", (0.0, 0.0), (2000.0, 0.0)), line("2", (1500.0, 0.0), (3000.0, 0.0)))
(run,) = result["runs"]
assert abs(total(run) - 3000.0) < EPS # 0..3000 once; 1500..2000 was drawn twice
assert abs(result["stats"]["overlap_length"] - 500.0) < EPS
def test_an_arc_keeps_its_true_length():
# a quarter circle of radius 1000 centred on the origin, from 0 deg to 90 deg
arc = rec("A", "ARC", PIPE, ((0.0, 0.0),), radius=1000.0, angles=(0.0, 90.0))
(run,) = net(arc)["runs"]
assert abs(total(run) - 500.0 * 3.141592653589793) < 1e-6 # 2*pi*1000/4
ends = [c for point in run.ends for c in point]
assert ends == pytest.approx([0.0, 1000.0, 1000.0, 0.0], abs=1e-9)
# -- fittings ----------------------------------------------------------------
def test_an_inline_valve_joins_the_two_lines_and_adds_no_length():
result = net(
line("1", (0.0, 0.0), (1000.0, 0.0)),
line("2", (1100.0, 0.0), (2000.0, 0.0)),
insert("V", "GATE_VALVE", (1000.0, -50.0, 1100.0, 50.0)),
)
(run,) = result["runs"]
assert abs(total(run) - 1900.0) < EPS # 1000 + 900; the valve body is not pipe
assert run.ends == ((0.0, 0.0), (2000.0, 0.0))
assert result["stats"]["fittings"] == 1
@pytest.mark.parametrize(
("block", "reducer"),
[
("REDUCER_51_38", True),
("ПЕРЕХОД_51", True),
("REDÜKSİYON-51", True),
("verloop 51x38", True),
("REDUZIERSTÜCK_51", True),
("GATE_VALVE", False),
(None, False),
],
)
def test_reducer_words_in_four_languages(block, reducer):
assert is_reducer(block) is reducer
def test_reducer_words_agree_with_the_vocabulary():
# one reducer vocabulary: every block equipment_kind calls a reducer is one here
for block in ("REDUCER_51_38", "ПЕРЕХОД_51", "REDÜKSİYON-51", "VERLOOPSTUK", "REDUZIERSTÜCK"):
assert equipment_kind(block)["kind"] == "reducer"
assert is_reducer(block), block
def test_an_insert_holding_more_than_four_free_ends_is_not_a_fitting():
# a title-block sized INSERT over four separate lines: eight free ends inside
result = net(
*[line(str(n), (0.0, 100.0 * n), (500.0, 100.0 * n)) for n in range(4)],
insert("TB", "TITLE", (-10.0, -10.0, 510.0, 310.0)),
)
assert len(result["runs"]) == 4
assert result["stats"]["fittings"] == 0
assert result["stats"]["fittings_skipped"] == ["TB"]
# -- diameters ---------------------------------------------------------------
def test_a_label_is_direct_and_carries_by_continuity_across_a_tee():
result = net(
line("1", (0.0, 0.0), (1000.0, 0.0)),
line("2", (1000.0, 0.0), (2000.0, 0.0)),
line("3", (1000.0, 0.0), (1000.0, 800.0)),
text("D", "Ø51", (400.0, 5.0)),
)
(run,) = result["runs"]
by_handle = {edge["handles"]: edge for edge in run.edges}
assert by_handle[("1",)]["diameter_source"] == "direct"
assert by_handle[("1",)]["label"] == "D"
for handle in (("2",), ("3",)):
assert by_handle[handle]["diameter"] == "Ø51"
assert by_handle[handle]["diameter_source"] == "continuity"
assert result["unassigned"] == []
def test_a_label_holds_for_every_piece_of_its_drawn_entity():
poly = rec("P", "LWPOLYLINE", PIPE, ((0.0, 0.0), (1000.0, 0.0), (1000.0, 1000.0)))
(run,) = net(poly, text("D", "DN20", (400.0, 5.0)))["runs"]
assert [edge["diameter_source"] for edge in run.edges] == ["direct", "direct"]
assert {edge["diameter"] for edge in run.edges} == {"DN20"}
def test_continuity_stops_at_a_reducer():
result = net(
line("1", (0.0, 0.0), (1000.0, 0.0)),
line("2", (1060.0, 0.0), (2000.0, 0.0)),
insert("R", "REDUCER_51_38", (1000.0, -30.0, 1060.0, 30.0)),
text("D", "Ø51", (400.0, 5.0)),
)
(run,) = result["runs"]
by_handle = {edge["handles"]: edge for edge in run.edges}
assert by_handle[("1",)]["diameter"] == "Ø51"
assert by_handle[("2",)]["diameter"] is None
assert by_handle[("2",)]["diameter_source"] == "unassigned"
assert result["unassigned"][0]["reason"].startswith("no diameter label")
assert result["stats"]["reducers"] == 1
def test_continuity_stops_at_a_reducer_the_line_is_broken_at():
# BREAK-at-point: two LINEs share a vertex inside the reducer's box, no free end there
result = net(
line("1", (0.0, 0.0), (1030.0, 0.0)),
line("2", (1030.0, 0.0), (2000.0, 0.0)),
insert("R", "REDUCER_51_38", (1000.0, -30.0, 1060.0, 30.0)),
text("D", "Ø51", (400.0, 5.0)),
)
by_handle = {edge["handles"]: edge for edge in result["runs"][0].edges}
assert by_handle[("1",)]["diameter"] == "Ø51"
assert by_handle[("2",)]["diameter_source"] == "unassigned"
assert (result["stats"]["fittings"], result["stats"]["reducers"]) == (1, 1)
def test_a_polyline_drawn_through_a_reducer_is_cut_there():
poly = rec("P", "LWPOLYLINE", PIPE, ((0.0, 0.0), (2000.0, 0.0)))
result = net(
poly,
line("BR", (1500.0, 0.0), (1500.0, 800.0)),
insert("R", "REDUCER_51_38", (1000.0, -30.0, 1060.0, 30.0)),
text("D", "Ø51", (400.0, 5.0)),
)
(run,) = result["runs"]
# cut at the foot of the box centre (1030, 0): 1030 + 470 + 500 + 800 branch
assert abs(total(run) - 2800.0) < EPS
by_span = {(edge["a"][0], edge["b"][0], edge["handles"]): edge for edge in run.edges}
labelled = by_span[(0.0, 1030.0, ("P",))]
assert (labelled["diameter"], labelled["diameter_source"]) == ("Ø51", "direct")
# the label holds for the polyline only up to the reducer; nothing crosses it
others = [edge for edge in run.edges if edge is not labelled]
assert len(others) == 3
assert {edge["diameter_source"] for edge in others} == {"unassigned"}
assert result["stats"]["reducers"] == 1
def test_a_pipe_running_beside_a_reducer_is_not_cut():
# a parallel pipe grazing the reducer's box edge does not pass through its middle
result = net(
line("1", (0.0, 30.0), (2000.0, 30.0)),
insert("R", "REDUCER_51_38", (1000.0, -30.0, 1060.0, 30.0)),
text("D", "Ø51", (400.0, 35.0)),
)
(run,) = result["runs"]
assert len(run.edges) == 1
assert run.edges[0]["diameter_source"] == "direct"
assert result["stats"]["reducers"] == 0
@pytest.mark.parametrize("order", ["ABC", "BAC", "CAB", "ACB", "BCA", "CBA"])
def test_a_three_way_valve_guesses_nothing_whatever_the_record_order(order):
# C leaves the valve 1000 from A (Ø51) and 1000 from B (DN20): equally near
records = {
"A": line("A", (0.0, 0.0), (1000.0, 0.0)),
"B": line("B", (1100.0, 0.0), (2100.0, 0.0)),
"C": line("C", (1050.0, 50.0), (1050.0, 1050.0)),
}
result = net(
*[records[name] for name in order],
insert("V", "3WAY_VALVE", (1000.0, -50.0, 1100.0, 50.0)),
text("TA", "Ø51", (400.0, 5.0)),
text("TB", "DN20", (1500.0, 5.0)),
)
(run,) = result["runs"]
c = next(edge for edge in run.edges if edge["handles"] == ("C",))
assert (c["diameter"], c["diameter_source"]) == (None, "unassigned")
assert [u["reason"] for u in result["unassigned"]] == ["two different diameters equally near"]
assert run.ends == ((0.0, 0.0), (1050.0, 1050.0), (2100.0, 0.0))
def test_a_label_nearer_to_a_pipe_on_an_unread_layer_is_not_taken():
# the label stands 5 below the WATER line and 22.5 above PRODUCT (search 25)
drawing = snap(
line("1", (0.0, 0.0), (1000.0, 0.0), PIPE),
line("W", (0.0, 30.0), (1000.0, 30.0), "WATER"),
text("D", "DN20", (400.0, 20.0)),
)
assert "WATER" not in default_layers(drawing)
result = build_network(drawing)
assert result["runs"][0].edges[0]["diameter_source"] == "unassigned"
assert result["stats"]["labels_skipped"] == [
{
"handle": "D",
"text": "DN20",
"reason": "nearer to a line on layer WATER, which is not read",
}
]
def test_an_equipment_outline_does_not_take_a_pipe_label():
# closed outlines are shapes, not pipes: a label beside the pipe end stays the pipe's
result = net(
tank("A", "T4100", (-500.0, -500.0, 0.0, 500.0)),
line("1", (0.0, 0.0), (1000.0, 0.0)),
text("D", "Ø51", (2.0, 5.0)),
)
edge = result["runs"][0].edges[0]
assert (edge["diameter"], edge["diameter_source"]) == ("Ø51", "direct")
def test_continuity_crosses_a_valve():
result = net(
line("1", (0.0, 0.0), (1000.0, 0.0)),
line("2", (1100.0, 0.0), (2000.0, 0.0)),
insert("V", "GATE_VALVE", (1000.0, -50.0, 1100.0, 50.0)),
text("D", "Ø51", (400.0, 5.0)),
)
edge = next(e for e in result["runs"][0].edges if e["handles"] == ("2",))
assert (edge["diameter"], edge["diameter_source"]) == ("Ø51", "continuity")
def test_a_conflicting_label_stops_continuity_and_the_nearer_one_wins():
result = net(
line("1", (0.0, 0.0), (1000.0, 0.0)),
line("2", (1000.0, 0.0), (2000.0, 0.0)),
line("3", (2000.0, 0.0), (3000.0, 0.0)),
text("A", "Ø51", (400.0, 5.0)),
text("B", "DN20", (1400.0, 5.0)),
)
by_handle = {edge["handles"]: edge for edge in result["runs"][0].edges}
assert by_handle[("1",)]["diameter"] == "Ø51"
assert by_handle[("2",)]["diameter"] == "DN20"
# piece 3 is next to piece 2 (DN20), one piece further from piece 1 (Ø51)
assert (by_handle[("3",)]["diameter"], by_handle[("3",)]["diameter_source"]) == (
"DN20",
"continuity",
)
def test_two_diameters_equally_near_leave_the_piece_unassigned():
result = net(
line("1", (0.0, 0.0), (1000.0, 0.0)),
line("2", (1000.0, 0.0), (2000.0, 0.0)),
line("3", (2000.0, 0.0), (3000.0, 0.0)),
text("A", "Ø51", (400.0, 5.0)),
text("B", "DN20", (2400.0, 5.0)),
)
middle = next(e for e in result["runs"][0].edges if e["handles"] == ("2",))
assert middle["diameter_source"] == "unassigned"
assert result["unassigned"][0]["reason"] == "two different diameters equally near"
def test_labels_are_kept_exactly_as_drawn():
result = net(line("1", (0.0, 0.0), (1000.0, 0.0)), text("D", "SMS51", (400.0, 5.0)))
assert result["runs"][0].edges[0]["diameter"] == "SMS51"
def test_a_label_beyond_the_search_distance_is_not_read():
# median text height 5 -> label_search 25; the label's centre stands 200 away
result = net(line("1", (0.0, 0.0), (1000.0, 0.0)), text("D", "Ø51", (400.0, 200.0)))
assert result["stats"]["label_search"] == 25.0
assert result["runs"][0].edges[0]["diameter_source"] == "unassigned"
# -- services ----------------------------------------------------------------
def test_a_return_word_overrides_the_layer_for_the_whole_run():
# the layer says supply; only the word says this run returns
layer = "CIP_SUPPLY"
base = classify_layer(layer)["service"]
assert base in ("cip_supply", "cip_return"), "the vocabulary must file CIP_SUPPLY as CIP"
records = [
line("1", (0.0, 0.0), (1000.0, 0.0), layer),
line("2", (1000.0, 0.0), (1000.0, 1000.0), layer),
text("W", "ВОЗВРАТ", (400.0, 5.0)),
]
result = build_network(snap(*records), layers=[layer])
(run,) = result["runs"]
assert run.service == "cip_return"
assert {edge["service"] for edge in run.edges} == {"cip_return"}
def test_a_cip_layer_naming_no_direction_takes_it_from_the_word():
# classify_layer files a directionless CIP layer as `unknown` on purpose:
# the layer is not trusted with the direction, so the word must decide it
layer = "CIP_LINES"
found = classify_layer(layer)
assert (found["service"], found["keyword"]) == ("unknown", "cip")
records = [
line("1", (0.0, 0.0), (1000.0, 0.0), layer),
line("2", (1000.0, 0.0), (1000.0, 1000.0), layer),
text("W", "ВОЗВРАТ", (400.0, 5.0)),
]
result = build_network(snap(*records), layers=[layer])
(run,) = result["runs"]
assert run.service == "cip_return"
assert result["stats"]["service_conflicts"] == []
def test_a_directionless_cip_layer_without_a_word_stays_unknown():
(run,) = build_network(
snap(line("1", (0.0, 0.0), (1000.0, 0.0), "CIP_LINES")), layers=["CIP_LINES"]
)["runs"]
assert run.service == "unknown"
def test_without_a_word_the_run_keeps_its_layer_service():
(run,) = net(line("1", (0.0, 0.0), (1000.0, 0.0)))["runs"]
assert run.service == classify_layer(PIPE)["service"]
# -- equipment ---------------------------------------------------------------
def test_run_ends_attach_to_the_tags_whose_geometry_holds_them():
result = net(
tank("A", "T4100", (-500.0, -500.0, 0.0, 500.0)),
tank("B", "T4101", (2000.0, -500.0, 2500.0, 500.0)),
line("1", (0.0, 0.0), (2000.0, 0.0)),
)
(run,) = result["runs"]
assert run.tags == ("T4100", "T4101")
assert result["attachments"]["R1"] == [
{"at": (0.0, 0.0), "tag": "T4100"},
{"at": (2000.0, 0.0), "tag": "T4101"},
]
def test_a_tagged_insert_is_equipment_not_a_fitting():
pump = rec(
"PU",
"INSERT",
"EQUIPMENT",
((1050.0, 0.0),),
block="*U12",
attribs=(("TAG", "M82"),),
bbox=(1000.0, -50.0, 1100.0, 50.0),
)
result = net(
line("1", (0.0, 0.0), (1000.0, 0.0)), line("2", (1100.0, 0.0), (2000.0, 0.0)), pump
)
assert len(result["runs"]) == 2
assert all("M82" in run.tags for run in result["runs"])
assert result["stats"]["fittings"] == 0
def test_a_room_outline_around_two_tags_is_not_equipment():
room = rec(
"ROOM",
"LWPOLYLINE",
"ROOMS",
((-5000.0, -5000.0), (9000.0, -5000.0), (9000.0, 5000.0), (-5000.0, 5000.0)),
closed=True,
bbox=(-5000.0, -5000.0, 9000.0, 5000.0),
)
outline = rec(
"A",
"LWPOLYLINE",
"EQUIPMENT",
((-500.0, -500.0), (0.0, -500.0), (0.0, 500.0), (-500.0, 500.0)),
closed=True,
bbox=(-500.0, -500.0, 0.0, 500.0),
)
# T4100 is written 30 above its tank, inside the room: the room holds T4100
# and T4101, so it is not equipment, and the tank is the nearest shape
# within 2 x label_search = 50
result = net(
room,
outline,
text("AT", "T4100", (-400.0, 530.0)),
tank("B", "T4101", (2000.0, -500.0, 2500.0, 500.0)),
line("1", (0.0, 0.0), (2000.0, 0.0)),
)
assert result["equipment"]["T4100"][0]["handle"] == "A"
assert result["runs"][0].tags == ("T4100", "T4101")
def test_a_shape_far_larger_than_the_others_is_still_found():
# the grid keeps a box covering many cells aside; it must still be found
small = [
rec(
f"C{n}",
"CIRCLE",
"EQUIPMENT",
((5000.0 + 300.0 * n, 5000.0),),
radius=50.0,
bbox=(4950.0 + 300.0 * n, 4950.0, 5050.0 + 300.0 * n, 5050.0),
)
for n in range(5)
]
big = rec(
"BIG",
"LWPOLYLINE",
"EQUIPMENT",
((-500.0, -500.0), (-500.0 + 40000.0, -500.0), (39500.0, 39500.0), (-500.0, 39500.0)),
closed=True,
bbox=(-500.0, -500.0, 39500.0, 39500.0),
)
result = net(
*small, big, text("AT", "T4100", (-400.0, 0.0)), line("1", (0.0, 0.0), (0.0, -2000.0))
)
assert result["equipment"]["T4100"][0]["handle"] == "BIG"
assert result["runs"][0].tags == ("T4100",)
def test_a_free_end_attaches_to_nothing():
result = net(
tank("A", "T4100", (-500.0, -500.0, 0.0, 500.0)), line("1", (0.0, 0.0), (2000.0, 0.0))
)
assert result["attachments"]["R1"][1] == {"at": (2000.0, 0.0), "tag": None}
# -- layer choice and refusals -------------------------------------------------
def test_default_layers_are_the_confidently_classified_network_layers():
drawing = snap(line("1", (0.0, 0.0), (1.0, 0.0)), line("2", (0.0, 0.0), (1.0, 0.0), "MISC"))
expected = tuple(
name
for name in ("MISC", PIPE)
if classify_layer(name)["discipline"] in ("piping", "electrical")
and classify_layer(name)["confidence"] >= 0.9
)
assert default_layers(drawing) == expected
def test_label_search_defaults_to_five_median_text_heights():
drawing = snap(
text("a", "x", (0, 0), 2.0), text("b", "y", (0, 0), 4.0), text("c", "z", (0, 0), 9.0)
)
assert label_search_default(drawing) == 20.0 # 5 x median(2, 4, 9)
@pytest.mark.parametrize(
("kwargs", "message"),
[
({"tol": 0.0}, "tol: must be a finite number greater than zero"),
({"tol": float("nan")}, "tol: must be a finite number greater than zero"),
({"label_search": -1.0}, "label_search: must be a finite number greater than zero"),
({"layers": ["NOPE"]}, "layers: NOPE not in the drawing; it has: PRODUCT"),
({"layers": []}, "layers: an empty list reads nothing"),
],
)
def test_refusals_name_the_parameter(kwargs, message):
drawing = snap(line("1", (0.0, 0.0), (1.0, 0.0)))
kwargs = {"layers": [PIPE], **kwargs}
with pytest.raises(ValueError, match="^" + message.replace("(", r"\(")):
build_network(drawing, **kwargs)
# -- the synthetic plant pair (Task 2) -------------------------------------------
@pytest.fixture(scope="module")
def plant(tmp_path_factory):
truth = build_plant_pair(tmp_path_factory.mktemp("plant"))
return truth, read_snapshot(truth["pid"])
def run_key(tags, service):
return (tuple(sorted(tags)), service)
def test_the_generator_names_each_run_by_its_tags_and_service(plant):
truth, _pid = plant
keys = [run_key(run["tags"], run["service"]) for run in truth["pipe_runs"]]
assert len(keys) == len(set(keys))
def test_every_synthetic_run_has_its_service_diameter_and_tags(plant):
truth, pid = plant
found: dict = {}
for run in build_network(pid)["runs"]:
found.setdefault(run_key(run.tags, run.service), []).append(run)
for expected in truth["pipe_runs"]:
key = run_key(expected["tags"], expected["service"])
runs = found.get(key, [])
assert len(runs) == 1, f"{key}: {len(runs)} runs carry these tags and this service"
if expected["status"] == "C":
continue
edges = runs[0].edges
assert expected["diameter"] in {edge["diameter"] for edge in edges}, key
sources = {edge["diameter_source"] for edge in edges}
if expected["status"] == "A":
assert sources == {"direct"}, key
else:
assert sources & {"continuity", "unassigned"}, key
def test_the_synthetic_service_layers_are_read_by_default(plant):
truth, pid = plant
layers = set(build_network(pid)["stats"]["layers"])
service_layers = {
layer
for layer, service in truth["layer_services"].items()
if service not in ("electrical", "unknown")
}
assert service_layers <= layers
+253
View File
@@ -0,0 +1,253 @@
"""The takeoff workbook: four sheets, three languages, CSV always, XLSX with openpyxl.
The result written is `pipe_rows` over a hand-built pair: a P&ID with two
runs (T4100-T4101 labelled Ø51, T4101-T4102 unlabelled) and a layout whose
INSUNITS says inches over millimetre geometry, measured on the layout (28 m
and 12 m).
"""
from __future__ import annotations
import csv
from pathlib import Path
import pytest
import engineering.understand.report as report
from engineering.understand.network import build_network
from engineering.understand.report import LANGS, SHEETS, write_workbook
from engineering.understand.snapshot import EntityRecord, Snapshot
from engineering.understand.takeoff import pipe_rows
PIPE = "PRODUCT"
WALLS = "WALLS"
SCHEMATIC = {
"verdict": "schematic",
"within_10pct": 0.25,
"pair_count": 12,
"median": 1.3,
"iqr": 0.8,
"factor": None,
"matched": [],
"ambiguous": [],
}
def rec(handle, kind, layer, points=(), **extra):
return EntityRecord(
handle=handle, type=kind, layer=layer, space="Model", points=tuple(points), **extra
)
def text(handle, value, at, height, layer="TEXT"):
x, y = at
return rec(
handle,
"TEXT",
layer,
(at,),
text=value,
height=height,
bbox=(x, y, x + height * len(value) * 0.6, y + height),
)
def snap(records, insunits, source):
layers = {r.layer: {"color": 7, "linetype": "Continuous"} for r in records}
return Snapshot(
source=source,
insunits=insunits,
extmin=None,
extmax=None,
layers=layers,
layouts=(),
records=tuple(records),
)
def tank(handle, tag, x0, y0):
return [
rec(
handle,
"LWPOLYLINE",
"EQUIPMENT",
((x0, y0), (x0 + 500.0, y0), (x0 + 500.0, y0 + 500.0), (x0, y0 + 500.0)),
closed=True,
bbox=(x0, y0, x0 + 500.0, y0 + 500.0),
),
text(handle + "T", tag, (x0 + 150.0, y0 + 225.0), 50.0),
]
@pytest.fixture
def result():
pid = snap(
[
*tank("A", "T4100", 0.0, 0.0),
*tank("B", "T4101", 3000.0, 0.0),
*tank("C", "T4102", 3000.0, 2000.0),
rec("L1", "LINE", PIPE, ((500.0, 250.0), (3000.0, 250.0))),
rec("L2", "LINE", PIPE, ((3250.0, 500.0), (3250.0, 2000.0))),
text("D1", "Ø51", (1500.0, 260.0), 50.0),
],
4,
"pid.dxf",
)
layout = snap(
[
rec(
"W",
"LWPOLYLINE",
WALLS,
((0, 0), (40000, 0), (40000, 20000), (0, 20000)),
closed=True,
bbox=(0.0, 0.0, 40000.0, 20000.0),
),
rec("X", "LINE", WALLS, ((20000, 0), (20000, 20000))),
text("R1", "ROOM 101", (5000.0, 10000.0), 250.0),
text("R2", "ROOM 102", (30000.0, 10000.0), 250.0),
text("T0", "T4100", (2000.0, 3000.0), 250.0),
text("T1", "T4101", (30000.0, 3000.0), 250.0),
text("T2", "T4102", (30000.0, 15000.0), 250.0),
],
1,
"layout.dxf",
)
return pipe_rows(build_network(pid, layers=[PIPE]), pid, layout, scale=SCHEMATIC)
METRAJ_HEADERS = {
"tr": [
"Mahal",
"Hizmet",
"Çap",
"Doğrudan (m)",
"Süreklilik (m)",
"Atanmamış (m)",
"Net (m)",
"Pay oranı",
"Pay (m)",
"Paylı toplam (m)",
"Şematik (m)",
"Durum",
"Hatlar",
],
"en": [
"Room",
"Service",
"Diameter",
"Direct (m)",
"Continuity (m)",
"Unassigned (m)",
"Net (m)",
"Allowance",
"Allowance (m)",
"Total incl. allowance (m)",
"Schematic (m)",
"Status",
"Runs",
],
"ru": [
"Помещение",
"Среда",
"Диаметр",
"Прямо (м)",
"По непрерывности (м)",
"Не назначено (м)",
"Нетто (м)",
"Запас",
"Запас (м)",
"Итого с запасом (м)",
"По схеме (м)",
"Статус",
"Линии",
],
}
TOTAL = {"tr": "TOPLAM", "en": "TOTAL", "ru": "ИТОГО"}
def read_csv(path):
with open(path, encoding="utf-8-sig", newline="") as handle:
return list(csv.reader(handle))
@pytest.mark.parametrize("lang", LANGS)
def test_csv_sheets_carry_the_headers_of_each_language(result, tmp_path, lang):
files = write_workbook(result, kind="pipe", lang=lang, path=str(tmp_path / "takeoff.xlsx"))
names = [Path(p).name for p in files["csv"]]
assert names == [
"takeoff_metraj.csv",
"takeoff_ozet.csv",
"takeoff_kontrol.csv",
"takeoff_metodoloji.csv",
]
metraj = read_csv(files["csv"][0])
assert metraj[0] == METRAJ_HEADERS[lang]
total = metraj[-1]
assert total[0] == TOTAL[lang]
assert float(total[6]) == pytest.approx(40.0) # 28 m + 12 m net
assert float(total[9]) == pytest.approx(48.0) # with the 20 % allowance
def test_the_workbook_has_the_four_sheets_and_numbers_stay_numbers(result, tmp_path):
openpyxl = pytest.importorskip("openpyxl")
files = write_workbook(result, kind="pipe", lang="tr", path=str(tmp_path / "takeoff.xlsx"))
assert files["refused"] is None
book = openpyxl.load_workbook(files["xlsx"])
assert book.sheetnames == list(SHEETS) == ["Metraj", "Özet", "Kontrol", "Metodoloji"]
rows = list(book["Metraj"].iter_rows(values_only=True))
assert list(rows[0]) == METRAJ_HEADERS["tr"]
assert rows[-1][0] == "TOPLAM"
assert rows[-1][6] == pytest.approx(40.0)
assert isinstance(rows[1][6], (int, float)) # a number cell, not the text '18.0'
assert rows[1][6] == pytest.approx(18.0) # room 101's share of the Ø51 run
def test_the_method_sheet_is_generated_from_the_result(result, tmp_path):
files = write_workbook(result, kind="pipe", lang="en", path=str(tmp_path / "t.xlsx"))
method = dict((row[0], row[1]) for row in read_csv(files["csv"][3])[1:] if len(row) == 2)
assert method["Length source"] == "layout"
assert method["Scale check: verdict"] == "schematic"
assert method["Scale check: pairs"] == "12"
assert method["Scale verified"] == "yes"
assert method["Units: layout"] == "INSUNITS in → mm"
assert "x first, then y" in method["Rule: route"]
warnings = [row[1] for row in read_csv(files["csv"][3]) if row[0] == "Warning"]
assert any("INSUNITS declares in" in w for w in warnings)
def test_kontrol_lists_the_unassigned_diameter(result, tmp_path):
files = write_workbook(result, kind="pipe", lang="en", path=str(tmp_path / "t.xlsx"))
kontrol = read_csv(files["csv"][2])
assert kontrol[0] == ["Run", "Kind", "Service", "Tags", "Schematic (m)", "Reason"]
(row,) = kontrol[1:]
assert row[1] == "Diameter unassigned" and row[3] == "T4101, T4102"
assert row[5] == "No diameter label"
def test_without_openpyxl_the_csv_is_written_and_xlsx_is_refused(result, tmp_path, monkeypatch):
monkeypatch.setattr(report, "xlsx_available", lambda: False)
files = write_workbook(result, kind="pipe", lang="tr", path=str(tmp_path / "takeoff.xlsx"))
assert files["xlsx"] is None
assert not (tmp_path / "takeoff.xlsx").exists()
assert all(Path(p).exists() for p in files["csv"])
assert files["refused"]["capability"] == "xlsx_write"
assert files["refused"]["ok"] is False
assert '".[office]"' in files["refused"]["error"]
@pytest.mark.parametrize(
("kwargs", "message"),
[
({"kind": "steel"}, "kind: 'steel' unknown; choose from pipe"),
({"lang": "de"}, "lang: 'de' unknown; choose from tr, en, ru"),
({"path": "takeoff.ods"}, "path: the workbook is written as .xlsx"),
],
)
def test_refusals_come_before_any_file(result, tmp_path, kwargs, message):
arguments = {"kind": "pipe", "lang": "en", "path": str(tmp_path / "t.xlsx"), **kwargs}
if "path" in kwargs:
arguments["path"] = str(tmp_path / kwargs["path"])
with pytest.raises(ValueError, match="^" + message.replace(".", r"\.")):
write_workbook(result, **arguments)
assert list(tmp_path.iterdir()) == []
+48
View File
@@ -0,0 +1,48 @@
"""Orthogonal lengths: Manhattan distance, the rectilinear MST, the x-then-y route.
Every expected value is worked by hand in the comment beside it.
"""
from __future__ import annotations
from engineering.understand.route import manhattan, rmst, route_legs
EPS = 1e-9
def test_manhattan_is_the_sum_of_the_axis_distances():
assert manhattan((0.0, 0.0), (3.0, -4.0)) == 7.0 # 3 + 4, not the diagonal 5
def test_fewer_than_two_points_have_no_tree():
assert rmst([]) == (0.0, ())
assert rmst([(5.0, 5.0)]) == (0.0, ())
def test_a_two_ended_run_is_dx_plus_dy():
length, edges = rmst([(1000.0, 3000.0), (29000.0, 15000.0)])
assert abs(length - 40000.0) < EPS # 28000 + 12000
assert edges == ((0, 1),)
def test_a_square_of_four_tags_is_three_sides():
# Prim from point 0: (0,1) at 10 (tie with 3 goes to the lower index),
# then (1,2) at 10, then (0,3) at 10 -> 30, never the 40 of the full ring
length, edges = rmst([(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)])
assert abs(length - 30.0) < EPS
assert edges == ((0, 1), (1, 2), (0, 3))
def test_three_tags_in_a_row_chain_instead_of_a_star():
length, edges = rmst([(0.0, 0.0), (100.0, 0.0), (200.0, 0.0)])
assert abs(length - 200.0) < EPS # 100 + 100, not 100 + 200
assert edges == ((0, 1), (1, 2))
def test_the_route_runs_along_x_first_then_y():
assert route_legs((0.0, 0.0), (30.0, 40.0)) == (
((0.0, 0.0), (30.0, 0.0)),
((30.0, 0.0), (30.0, 40.0)),
)
assert route_legs((0.0, 0.0), (0.0, 40.0)) == (((0.0, 0.0), (0.0, 40.0)),)
assert route_legs((1.0, 1.0), (1.0, 1.0)) == ()
+423
View File
@@ -0,0 +1,423 @@
"""Pipe takeoff rows: layout lengths, statuses, rooms, units and the scale rule.
The hand-built pair: a P&ID (mm) with three tanks and two runs, and a layout
whose INSUNITS says inches over millimetre geometry - two rooms split by a
wall at x = 20000, the tags at T4100 (2000, 3000), T4101 (30000, 3000) and
T4102 (30000, 15000). Every expected length is worked in the comment beside it.
The last four tests read the synthetic plant pair of Task 2 and compare every
run with the generator's own hand-computed rectilinear MST.
"""
from __future__ import annotations
import pytest
from engineering.understand.network import build_network
from engineering.understand.snapshot import EntityRecord, Snapshot, read_snapshot
from engineering.understand.takeoff import (
DEFAULT_SERVICES,
layout_tags,
pipe_rows,
scale_stand_in,
unit_of,
)
from engineering.understand.vocab import classify_layer, room_label
from tests.fixtures.plant_pair import build_plant_pair
EPS = 1e-9
PIPE = "PRODUCT"
WALLS = "WALLS"
def rec(handle, kind, layer, points=(), **extra):
return EntityRecord(
handle=handle, type=kind, layer=layer, space="Model", points=tuple(points), **extra
)
def text(handle, value, at, height, layer="TEXT"):
x, y = at
return rec(
handle,
"TEXT",
layer,
(at,),
text=value,
height=height,
bbox=(x, y, x + height * len(value) * 0.6, y + height),
)
def snap(records, insunits, source):
layers = {r.layer: {"color": 7, "linetype": "Continuous"} for r in records}
return Snapshot(
source=source,
insunits=insunits,
extmin=None,
extmax=None,
layers=layers,
layouts=(),
records=tuple(records),
)
def tank(handle, tag, x0, y0):
box = (x0, y0, x0 + 500.0, y0 + 500.0)
return [
rec(
handle,
"LWPOLYLINE",
"EQUIPMENT",
((x0, y0), (x0 + 500.0, y0), (x0 + 500.0, y0 + 500.0), (x0, y0 + 500.0)),
closed=True,
bbox=box,
),
text(handle + "T", tag, (x0 + 150.0, y0 + 225.0), 50.0),
]
def pid_records():
return [
*tank("A", "T4100", 0.0, 0.0),
*tank("B", "T4101", 3000.0, 0.0),
*tank("C", "T4102", 3000.0, 2000.0),
rec("L1", "LINE", PIPE, ((500.0, 250.0), (3000.0, 250.0))),
rec("L2", "LINE", PIPE, ((3250.0, 500.0), (3250.0, 2000.0))),
text("D1", "Ø51", (1500.0, 260.0), 50.0),
]
def layout_records(dx=0.0, extra=()):
def at(x, y):
return (x + dx, y)
return [
rec(
f"W{dx}",
"LWPOLYLINE",
WALLS,
(at(0, 0), at(40000, 0), at(40000, 20000), at(0, 20000)),
closed=True,
bbox=(dx, 0.0, dx + 40000.0, 20000.0),
),
rec(f"X{dx}", "LINE", WALLS, (at(20000, 0), at(20000, 20000))),
text(f"R1{dx}", "ROOM 101", at(5000, 10000), 250.0),
text(f"R2{dx}", "ROOM 102", at(30000, 10000), 250.0),
text(f"T0{dx}", "T4100", at(2000, 3000), 250.0),
text(f"T1{dx}", "T4101", at(30000, 3000), 250.0),
text(f"T2{dx}", "T4102", at(30000, 15000), 250.0),
*extra,
]
def room_key(label):
found = room_label(label)
assert found is not None, f"the vocabulary must read {label!r} as a room label"
return str(found.get("number") or found.get("name"))
@pytest.fixture
def pair():
assert classify_layer(PIPE)["service"] == "product", "PRODUCT must be a product layer"
assert classify_layer(WALLS)["discipline"] == "architecture", "WALLS must be a wall layer"
pid = snap(pid_records(), 4, "pid.dxf")
layout = snap(layout_records(), 1, "layout.dxf") # INSUNITS 1 = inches, geometry in mm
return pid, layout, build_network(pid, layers=[PIPE])
def run_by_tags(result, *tags):
(run,) = [r for r in result["runs"] if r["tags"] == list(tags)]
return run
def test_layout_lengths_are_manhattan_between_the_tags(pair):
pid, layout, network = pair
result = pipe_rows(network, pid, layout, length_source="layout")
a = run_by_tags(result, "T4100", "T4101")
b = run_by_tags(result, "T4101", "T4102")
assert abs(a["layout_m"] - 28.0) < EPS # |30000 - 2000| + 0 mm
assert abs(b["layout_m"] - 12.0) < EPS # 0 + |15000 - 3000| mm
assert (a["status"], b["status"]) == ("A", "B") # b has no diameter label
assert result["length_source"] == "layout"
def test_review_focus_5_inches_declared_over_millimetres(pair):
pid, layout, network = pair
result = pipe_rows(network, pid, layout, length_source="layout")
units = result["units"]["layout"]
assert (units["declared"], units["inferred"], units["m_per_unit"]) == ("in", "mm", 0.001)
a = run_by_tags(result, "T4100", "T4101")
assert abs(a["layout_m"] - 28.0) < EPS # not 28000 x 0.0254 = 711.2
assert any("INSUNITS declares in" in w and "as mm" in w for w in result["warnings"])
assert unit_of(pid)["warning"] is None # the P&ID's mm is what its geometry says
def test_rooms_share_the_route_x_first(pair):
pid, layout, network = pair
result = pipe_rows(network, pid, layout, length_source="layout")
a = run_by_tags(result, "T4100", "T4101")
# (2000, 3000) -> (30000, 3000): 18000 mm left of the wall at x = 20000, 10000 right
assert a["rooms"] == pytest.approx(
{room_key("ROOM 101"): 18.0, room_key("ROOM 102"): 10.0}, abs=EPS
)
rows = {(r["room"], r["diameter"]): r for r in result["rows"]}
assert rows[(room_key("ROOM 101"), "Ø51")]["net_m"] == pytest.approx(18.0, abs=EPS)
assert rows[(room_key("ROOM 101"), "Ø51")]["direct_m"] == pytest.approx(18.0, abs=EPS)
assert rows[(room_key("ROOM 102"), "unassigned")]["unassigned_m"] == pytest.approx(
12.0, abs=EPS
)
def test_allowance_is_its_own_column_and_totals_add_up(pair):
pid, layout, network = pair
result = pipe_rows(network, pid, layout, length_source="layout", allowance=0.25)
for row in result["rows"]:
assert row["allowance"] == 0.25
assert row["allowance_m"] == pytest.approx(row["net_m"] * 0.25, abs=EPS)
assert row["total_m"] == pytest.approx(row["net_m"] * 1.25, abs=EPS)
totals = result["totals"]
assert totals["net_m"] == pytest.approx(40.0, abs=EPS) # 28 + 12
assert totals["total_m"] == pytest.approx(50.0, abs=EPS)
assert totals["flagged_runs"] == 0
def test_vertical_allowance_is_added_per_connection(pair):
pid, layout, network = pair
result = pipe_rows(network, pid, layout, length_source="layout", vertical_allowance=0.5)
a = run_by_tags(result, "T4100", "T4101")
assert a["vertical_m"] == 0.5 # one tree edge
assert a["length_m"] == pytest.approx(28.5, abs=EPS)
def test_the_schematic_length_is_kept_beside_the_answer(pair):
pid, layout, network = pair
result = pipe_rows(network, pid, layout, length_source="layout")
a = run_by_tags(result, "T4100", "T4101")
assert a["schematic_m"] == pytest.approx(2.5, abs=EPS) # the 2500 mm line on the P&ID
def test_a_tag_missing_from_the_layout_makes_the_run_c(pair):
pid, _layout, network = pair
layout = snap([r for r in layout_records() if r.text != "T4102"], 1, "layout.dxf")
result = pipe_rows(network, pid, layout, length_source="layout")
b = run_by_tags(result, "T4101", "T4102")
assert (b["status"], b["reason"]) == ("C", "tag_not_on_layout")
(row,) = [c for c in result["control"] if c["kind"] == "unroutable"]
assert row["schematic_m"] == pytest.approx(1.5, abs=EPS) # the 1500 mm line, drawn length only
assert result["totals"]["unroutable_m"] == pytest.approx(1.5, abs=EPS)
assert result["totals"]["flagged_runs"] == 1
def test_a_tag_written_twice_in_the_plan_is_ambiguous_not_picked(pair):
pid, _layout, network = pair
extra = [text("dup", "T4101", (35000.0, 18000.0), 250.0)]
layout = snap(layout_records(extra=extra), 1, "layout.dxf")
result = pipe_rows(network, pid, layout, length_source="layout")
assert {r["reason"] for r in result["runs"]} == {"tag_ambiguous"}
def test_of_two_plan_copies_one_is_chosen_and_nothing_is_ambiguous(pair):
pid, _layout, network = pair
layout = snap(layout_records() + layout_records(dx=100000.0), 1, "layout.dxf")
found = layout_tags(layout, ["T4100", "T4101", "T4102"])
assert found["clusters"] == 2
assert found["cluster"][2] < 100000.0 # the left copy: ties go to the leftmost
assert found["ambiguous"] == []
result = pipe_rows(network, pid, layout, length_source="layout")
assert run_by_tags(result, "T4100", "T4101")["layout_m"] == pytest.approx(28.0, abs=EPS)
def test_a_run_with_a_free_end_is_c(pair):
pid, layout, _network = pair
records = pid_records() + [rec("L3", "LINE", PIPE, ((0.0, 500.0), (0.0, 1500.0)))]
drawing = snap(records, 4, "pid.dxf")
result = pipe_rows(
build_network(drawing, layers=[PIPE]), drawing, layout, length_source="layout"
)
reasons = {r["reason"] for r in result["runs"] if r["status"] == "C"}
assert reasons == {"end_not_on_equipment"}
def test_other_services_are_left_out_unless_asked(pair):
pid, layout, network = pair
assert DEFAULT_SERVICES == ("product", "cip_supply", "cip_return")
result = pipe_rows(network, pid, layout, length_source="layout", services=["steam"])
assert result["rows"] == []
assert result["method"]["excluded_services"] == {"product": 2}
# -- the scale rule ----------------------------------------------------------
SCHEMATIC = {
"verdict": "schematic",
"within_10pct": 0.25,
"pair_count": 12,
"median": 1.3,
"iqr": 0.8,
"factor": None,
"matched": [],
"ambiguous": [],
}
def test_auto_measures_a_schematic_pid_on_the_layout(pair):
pid, layout, network = pair
result = pipe_rows(network, pid, layout, scale=SCHEMATIC)
assert result["length_source"] == "layout"
assert result["method"]["reason"] == "auto_schematic"
assert result["scale_verified"] is True
def test_pid_on_a_schematic_pid_is_refused_with_the_statistics(pair):
pid, layout, network = pair
with pytest.raises(ValueError) as excinfo:
pipe_rows(network, pid, layout, scale=SCHEMATIC, length_source="pid")
message = str(excinfo.value)
assert message.startswith("length_source='pid': the scale check calls this P&ID schematic")
assert "25% of 12 tag pairs" in message and "median ratio 1.3" in message
assert "force=True" in message
def test_force_measures_on_the_pid_and_says_it_is_unverified(pair):
pid, layout, network = pair
result = pipe_rows(network, pid, layout, scale=SCHEMATIC, length_source="pid", force=True)
assert result["length_source"] == "pid"
assert result["method"]["reason"] == "forced"
assert result["scale_verified"] is False
assert run_by_tags(result, "T4100", "T4101")["length_m"] == pytest.approx(2.5, abs=EPS)
assert any(w.startswith("scale_verified: false") for w in result["warnings"])
def test_a_to_scale_pid_is_measured_through_its_factor(pair):
pid, layout, network = pair
to_scale = dict(SCHEMATIC, verdict="to_scale", within_10pct=0.9, factor=0.1)
result = pipe_rows(network, pid, layout, scale=to_scale)
assert result["length_source"] == "pid"
# 2.5 m drawn on the P&ID / 0.1 P&ID mm per layout mm = 25 m
assert run_by_tags(result, "T4100", "T4101")["length_m"] == pytest.approx(25.0, abs=EPS)
def test_without_a_layout_auto_measures_the_pid_unverified(pair):
pid, _layout, network = pair
result = pipe_rows(network, pid, None)
assert (result["length_source"], result["scale_verified"]) == ("pid", False)
assert result["method"]["reason"] == "no_layout"
with pytest.raises(ValueError, match="^length_source='layout' needs a layout drawing"):
pipe_rows(network, pid, None, length_source="layout")
def test_the_stand_in_recognises_a_uniform_scale_and_a_rearranged_one():
places = {
"T4100": (2000.0, 3000.0),
"T4101": (30000.0, 3000.0),
"T4102": (30000.0, 15000.0),
"T4103": (8000.0, 17000.0),
}
layout = snap(
[text(t, t, p, 250.0) for t, p in places.items()]
+ [rec("W", "LINE", WALLS, ((0.0, 0.0), (40000.0, 20000.0)))],
4,
"l",
)
half = snap([text(t, t, (x * 0.5, y * 0.5), 50.0) for t, (x, y) in places.items()], 4, "p")
uniform = scale_stand_in(half, layout)
assert uniform["verdict"] == "to_scale"
assert uniform["factor"] == pytest.approx(0.5, abs=EPS) # P&ID mm per layout mm
assert uniform["pair_count"] == 6 # four tags, every pair at least 2 m apart
shuffled = {
"T4100": (30000.0, 15000.0),
"T4101": (2000.0, 3000.0),
"T4102": (8000.0, 17000.0),
"T4103": (30000.0, 3000.0),
}
other = snap([text(t, t, p, 50.0) for t, p in shuffled.items()], 4, "p")
assert scale_stand_in(other, layout)["verdict"] == "schematic"
def test_the_stand_in_calls_coinciding_pid_tags_insufficient_not_to_scale():
# every P&ID tag at one point: each ratio is 0, the median 0 - a factor of 0
# would divide every length by zero, so the verdict is insufficient (the
# same rule as group U's scale_check: a median that is not positive)
places = {"T4100": (2000.0, 3000.0), "T4101": (30000.0, 3000.0), "T4102": (30000.0, 15000.0)}
layout = snap([text(t, t, p, 250.0) for t, p in places.items()], 4, "l")
pid = snap([text(t, t, (100.0, 100.0), 50.0) for t in places], 4, "p")
result = scale_stand_in(pid, layout)
assert result["pair_count"] == 3
assert (result["verdict"], result["factor"]) == ("insufficient", None)
def test_an_insunits_code_this_reader_does_not_convert_is_named_not_called_none():
drawing = snap(layout_records(), 14, "layout.dxf") # 14 = decimetres
found = unit_of(drawing)
assert (found["declared"], found["inferred"]) == (None, "mm")
assert "INSUNITS declares code 14 (a unit this reader does not convert)" in found["warning"]
assert "no unit" not in found["warning"]
@pytest.mark.parametrize(
("kwargs", "message"),
[
({"allowance": 1.5}, "allowance: must lie between 0 and 1"),
({"allowance": -0.1}, "allowance: must lie between 0 and 1"),
({"vertical_allowance": -1.0}, "vertical_allowance: must be zero or more metres"),
({"length_source": "tape"}, "length_source: 'tape' unknown"),
({"services": ["lava"]}, "services: lava unknown"),
({"services": []}, "services: an empty list takes nothing off"),
],
)
def test_refusals_name_the_option(pair, kwargs, message):
pid, layout, network = pair
with pytest.raises(ValueError, match="^" + message):
pipe_rows(network, pid, layout, **kwargs)
# -- the synthetic plant pair (Task 2) -------------------------------------------
@pytest.fixture(scope="module")
def plant(tmp_path_factory):
truth = build_plant_pair(tmp_path_factory.mktemp("plant"))
pid, layout = read_snapshot(truth["pid"]), read_snapshot(truth["layout"])
return truth, pid, layout, build_network(pid)
def truth_services(truth):
return sorted({run["service"] for run in truth["pipe_runs"]})
def test_every_synthetic_layout_length_is_the_hand_computed_rmst(plant):
truth, pid, layout, network = plant
result = pipe_rows(network, pid, layout, services=truth_services(truth), length_source="layout")
runs = {(tuple(sorted(r["tags"])), r["service"]): r for r in result["runs"]}
for expected in truth["pipe_runs"]:
run = runs[(tuple(sorted(expected["tags"])), expected["service"])]
assert run["status"] == expected["status"], run["id"]
if expected["status"] != "C":
assert run["layout_m"] * 1000.0 == pytest.approx(expected["layout_length_mm"], abs=1e-6)
assert sum(run["rooms"].values()) == pytest.approx(run["length_m"], abs=1e-9)
def test_auto_calls_the_synthetic_pid_schematic_and_measures_the_layout(plant):
truth, pid, layout, network = plant
result = pipe_rows(network, pid, layout, services=truth_services(truth))
assert result["scale"]["verdict"] == truth["scale_verdict"] == "schematic"
assert result["length_source"] == "layout"
with pytest.raises(ValueError, match="the scale check calls this P&ID schematic"):
pipe_rows(network, pid, layout, services=truth_services(truth), length_source="pid")
def test_review_focus_5_the_synthetic_layout_is_read_in_millimetres(plant):
truth, pid, layout, network = plant
assert layout.insunits == truth["layout_insunits"] == 1
result = pipe_rows(network, pid, layout, services=truth_services(truth), length_source="layout")
assert result["units"]["layout"]["inferred"] == truth["layout_true_unit"] == "mm"
assert result["units"]["layout"]["m_per_unit"] == 0.001
assert any("INSUNITS declares in" in w for w in result["warnings"])
def test_routed_lengths_land_in_the_synthetic_rooms(plant):
truth, pid, layout, network = plant
result = pipe_rows(network, pid, layout, services=truth_services(truth), length_source="layout")
known = {str(room["number"] or room["name"]) for room in truth["rooms"]} | {""}
for run in result["runs"]:
assert set(run["rooms"]) <= known, run["id"]
+142
View File
@@ -0,0 +1,142 @@
"""pipe_takeoff and cable_takeoff over the MCP wire, on the synthetic plant pair - never on a
client drawing."""
from __future__ import annotations
from pathlib import Path
import pytest
import pytest_asyncio
from fastmcp import Client
import server
from tests.fixtures.plant_pair import build_plant_pair
pytestmark = pytest.mark.asyncio
CSV_NAMES = [
"takeoff_metraj.csv",
"takeoff_ozet.csv",
"takeoff_kontrol.csv",
"takeoff_metodoloji.csv",
]
@pytest_asyncio.fixture
async def client(monkeypatch):
"""In-memory client on the headless engine - never the operator's AutoCAD."""
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
async with Client(server.mcp) as connected:
status = (await connected.call_tool("system_status", {})).structured_content or {}
assert status.get("backend") == "ezdxf", "these tests must never touch live AutoCAD"
yield connected
@pytest.fixture
def plant(tmp_path):
return build_plant_pair(tmp_path)
async def test_pipe_takeoff_measures_on_the_layout_and_writes_the_workbook(client, plant, tmp_path):
output = tmp_path / "takeoff.xlsx"
arguments = {
"pid_path": plant["pid"],
"layout_path": plant["layout"],
"output": str(output),
"lang": "tr",
}
result = (await client.call_tool("pipe_takeoff", arguments)).structured_content
assert result["length_source"] == "layout"
assert result["scale"]["verdict"] == plant["scale_verdict"]
files = result["files"]
assert [Path(p).name for p in files["csv"]] == CSV_NAMES
assert all(Path(p).exists() for p in files["csv"])
if files["xlsx"] is None:
assert files["refused"]["capability"] == "xlsx_write"
else:
assert Path(files["xlsx"]) == output
assert output.exists()
async def test_pid_lengths_on_the_schematic_pid_are_refused_without_force(client, plant):
arguments = {"pid_path": plant["pid"], "layout_path": plant["layout"], "length_source": "pid"}
result = await client.call_tool("pipe_takeoff", arguments, raise_on_error=False)
assert result.is_error is True
text = result.content[0].text
assert "the scale check calls this P&ID schematic" in text
assert "force=True" in text
async def test_force_measures_on_the_pid_and_marks_it_unverified(client, plant):
arguments = {
"pid_path": plant["pid"],
"layout_path": plant["layout"],
"length_source": "pid",
"force": True,
}
result = (await client.call_tool("pipe_takeoff", arguments)).structured_content
assert (result["length_source"], result["scale_verified"]) == ("pid", False)
assert result["files"] is None # no output named, nothing written
async def test_an_unknown_language_is_refused_before_anything_is_read(client, tmp_path):
arguments = {"pid_path": str(tmp_path / "never_read.dxf"), "lang": "de"}
result = await client.call_tool("pipe_takeoff", arguments, raise_on_error=False)
assert result.is_error is True
assert "lang: 'de' unknown; choose from tr, en, ru" in result.content[0].text
async def test_the_drawings_are_never_modified(client, plant, tmp_path):
before = {key: Path(plant[key]).read_bytes() for key in ("pid", "layout")}
arguments = {
"pid_path": plant["pid"],
"layout_path": plant["layout"],
"output": str(tmp_path / "t.xlsx"),
}
await client.call_tool("pipe_takeoff", arguments)
assert {key: Path(plant[key]).read_bytes() for key in ("pid", "layout")} == before
async def test_the_tool_says_it_writes_a_file_and_destroys_nothing(client):
tools = {tool.name: tool for tool in await client.list_tools()}
annotations = tools["pipe_takeoff"].annotations
assert annotations.readOnlyHint is False
assert annotations.destructiveHint is False
async def test_cable_takeoff_gives_every_synthetic_load_its_rounded_cable(client, plant, tmp_path):
arguments = {
"pid_path": plant["pid"],
"layout_path": plant["layout"],
"output": str(tmp_path / "cables.xlsx"),
"lang": "ru",
}
result = (await client.call_tool("cable_takeoff", arguments)).structured_content
rows = {row["tag"]: row for row in result["rows"]}
for expected in plant["cable_rows"]:
assert rows[expected["tag"]]["cable_m"] == expected["roundup_m"], expected["tag"]
assert Path(result["files"]["csv"][0]).name == "cables_metraj.csv"
async def test_cable_takeoff_without_a_layout_leaves_every_length_empty(client, plant):
result = (
await client.call_tool("cable_takeoff", {"pid_path": plant["pid"]})
).structured_content
assert result["rows"], "the synthetic P&ID carries electrical loads"
assert all(row["cable_m"] is None for row in result["rows"])
no_layout = {item["tag"] for item in result["open_items"] if item["item"] == "no_layout"}
assert no_layout == {row["tag"] for row in result["rows"]}
async def test_a_malformed_section_rule_is_refused_by_path(client, plant):
arguments = {"pid_path": plant["pid"], "section_rules": [{"max_kw": 5}]}
result = await client.call_tool("cable_takeoff", arguments, raise_on_error=False)
assert result.is_error is True
assert "section_rules[0].section: expected a non-empty text" in result.content[0].text
async def test_the_cable_tool_says_it_writes_a_file_and_destroys_nothing(client):
tools = {tool.name: tool for tool in await client.list_tools()}
annotations = tools["cable_takeoff"].annotations
assert annotations.readOnlyHint is False
assert annotations.destructiveHint is False