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
+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