feat(understand): cable_takeoff - one row per load, rounded to the metre

cable_rows reads each load's power, phases, voltage, neutral and VFD from the
P&ID's electrical texts near its tag, and its panel from the layout's wiring
callouts (else the P&ID's), names normalised CP 1 -> CP-1. The length is the
Manhattan distance on the layout from the tag to its panel, and the cable
ROUNDUP(length x (1 + allowance)) to the metre, on the length rounded to
1e-6 m so binary noise never buys a metre. Power is never invented: a load
with no stated kW keeps an empty cell and an open item. Without a layout the
lengths stay empty with an open item per row. Room and panel totals count a
panel package's power once, never its children's as well; the rule is on the
method sheet. Sections only from the caller's section_rules - no standard
table exists here.

The workbook gains the cable shape (Metraj, Ozet by room and panel, Kontrol
as the open-items list, Metodoloji) in the same three languages;
cable_takeoff joins SECTION 27. Inventory regenerated; README snapshot 243
tools (238 advertised); registry snapshot analysis: 14.

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:28:59 +03:00
co-authored by Claude Opus 5.5
parent 653535bcc1
commit 459d3b9228
10 changed files with 1110 additions and 15 deletions
+6 -3
View File
@@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping():
async def test_tool_group_sizes_are_unchanged():
"""Frozen snapshot of the surface (242 tools, 26 groups).
"""Frozen snapshot of the surface (243 tools, 26 groups).
Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when
v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and
@@ -598,10 +598,13 @@ async def test_tool_group_sizes_are_unchanged():
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.
"""
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
assert sizes == {
"analysis": 13,
"analysis": 14,
"architecture": 12,
"batch": 3,
"blocks": 9,
@@ -628,4 +631,4 @@ async def test_tool_group_sizes_are_unchanged():
"validation": 1,
"view": 4,
}
assert sum(sizes.values()) == 242
assert sum(sizes.values()) == 243
+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)
+40 -1
View File
@@ -1,4 +1,5 @@
"""pipe_takeoff over the MCP wire, on the synthetic plant pair - never on a client drawing."""
"""pipe_takeoff and cable_takeoff over the MCP wire, on the synthetic plant pair - never on a
client drawing."""
from __future__ import annotations
@@ -101,3 +102,41 @@ async def test_the_tool_says_it_writes_a_file_and_destroys_nothing(client):
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