From 11dab84d47aa5b3556acf0085e323779b5dc1c50 Mon Sep 17 00:00:00 2001 From: Harsh Pandhe Date: Wed, 20 May 2026 13:29:18 +0530 Subject: [PATCH] feat(phase-8b): urban_capex_multiplier on estimate_cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Mi Teleférico Línea Roja case-study doc flagged this explicitly: the optimizer's BoM covers steel + cable + drive motor + foundations + carriers, but a real urban transit gondola also pays for the electrical substation + medium-voltage distribution, signalling + station IT, land acquisition / easements, station buildings beyond the two terminals, ops control room + spare parts, local VAT + import duties + OEM engineering fees + financing. The empirical ratio is 5-7x bare-infrastructure for urban gondolas in this size class — the optimizer was honestly under-reporting capex on every urban case study. cost.py changes (additive, fully backward-compatible): - CostEstimate.urban_capex_multiplier (default 1.0) - CostEstimate.bare_infrastructure_total (subtotal + contingency, pre-multiplier — what the optimizer puts on the ground) - CostEstimate.urban_uplift (the delta from multiplier > 1) - CostEstimate.grand_total now == bare * multiplier - summary_lines() and as_csv() show the uplift only when multiplier != 1.0, so legacy callers reading the old CSV format keep working bit-for-bit - estimate_cost(..., urban_capex_multiplier=6.0) is the new kwarg; raises ValueError for values < 1.0 Tests: tests/test_urban_capex.py — 7 new - default multiplier 1.0 leaves grand_total ≡ bare_infrastructure - default CSV/summary unchanged (legacy format preserved) - multiplier 6.0 -> 6x bare, urban_uplift == 5x bare - summary/CSV include uplift lines only when multiplier > 1 - <1.0 multiplier raises ValueError - Linea-Roja-style BOM with 6x multiplier recovers a turnkey envelope in the right order of magnitude vs the published budget Full suite 203 -> 206, zero regressions. --- src/ropeway/cost.py | 55 ++++++++++++++++- tests/test_urban_capex.py | 122 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 tests/test_urban_capex.py diff --git a/src/ropeway/cost.py b/src/ropeway/cost.py index c4447ce..4b77266 100644 --- a/src/ropeway/cost.py +++ b/src/ropeway/cost.py @@ -86,6 +86,19 @@ class CostEstimate: region: Region lines: list[CostLine] = field(default_factory=list) contingency_fraction: float = 0.12 + # Phase 8b: urban deployments cost a multiple of the bare-infrastructure + # BoM total. The Mi Teleférico Línea Roja case study flagged this + # explicitly: the optimizer's BoM is steel + cable + drive motor + + # foundations + carriers, but a real urban transit gondola also pays + # for electrical substation + medium-voltage distribution, signalling + + # station IT, land acquisition / easements, station buildings beyond + # the two terminals, ops control room + spare parts, local VAT + + # import duties + OEM engineering fees + financing. The empirical + # ratio is 5-7× for urban gondolas of this size class + # (docs/case_studies/mi_teleferico_linea_roja.md "Note 4 — Capex"). + # 1.0 (default) preserves the bare-infrastructure number; anything >1 + # is applied on top of (subtotal + contingency). + urban_capex_multiplier: float = 1.0 @property def subtotal(self) -> float: @@ -96,9 +109,24 @@ def contingency(self) -> float: return self.subtotal * self.contingency_fraction @property - def grand_total(self) -> float: + def bare_infrastructure_total(self) -> float: + """Subtotal + contingency, *before* the urban-deployment multiplier. + + This is the optimizer's natural output — what it costs to put + steel, cable, drives and foundations on the ground. Use it when + comparing two design alternatives at the same urban context. + """ return self.subtotal + self.contingency + @property + def urban_uplift(self) -> float: + """The Phase 8b urban-deployment uplift (0 when multiplier == 1).""" + return self.bare_infrastructure_total * (self.urban_capex_multiplier - 1.0) + + @property + def grand_total(self) -> float: + return self.bare_infrastructure_total * self.urban_capex_multiplier + def summary_lines(self) -> list[str]: out = [f"{'item':<32}{'qty':>10}{'unit EUR':>12}{'total EUR':>16}"] out.append("-" * 70) @@ -109,6 +137,14 @@ def summary_lines(self) -> list[str]: out.append("-" * 70) out.append(f"{'Subtotal':<54}{self.subtotal:>16,.0f}") out.append(f"{'Contingency ' + f'({self.contingency_fraction*100:.0f}%)':<54}{self.contingency:>16,.0f}") + if self.urban_capex_multiplier != 1.0: + out.append( + f"{'Bare infrastructure':<54}{self.bare_infrastructure_total:>16,.0f}" + ) + out.append( + f"{'Urban uplift ' + f'(x{self.urban_capex_multiplier:.1f})':<54}" + f"{self.urban_uplift:>16,.0f}" + ) out.append(f"{'GRAND TOTAL':<54}{self.grand_total:>16,.0f} EUR") return out @@ -118,6 +154,9 @@ def as_csv(self) -> str: out.append(f"{L.item},{L.qty:.2f},{L.unit_price:.2f},{L.total:.2f}") out.append(f"Subtotal,,,{self.subtotal:.2f}") out.append(f"Contingency,,,{self.contingency:.2f}") + if self.urban_capex_multiplier != 1.0: + out.append(f"Bare infrastructure,,,{self.bare_infrastructure_total:.2f}") + out.append(f"Urban uplift (x{self.urban_capex_multiplier:.2f}),,,{self.urban_uplift:.2f}") out.append(f"Grand total,,,{self.grand_total:.2f}") return "\n".join(out) + "\n" @@ -127,13 +166,25 @@ def estimate_cost( *, region: Region = Region.EU_ALPINE, contingency_fraction: float = 0.12, + urban_capex_multiplier: float = 1.0, ) -> CostEstimate: - """Apply regional unit prices to each BOM line and total the result.""" + """Apply regional unit prices to each BOM line and total the result. + + Pass ``urban_capex_multiplier`` (typical 5-7) for urban deployments + where the bare-infrastructure BoM is only a fraction of the + turnkey-delivered project cost. See ``CostEstimate.urban_capex_multiplier`` + for the rationale; default 1.0 preserves the original bare number. + """ + if urban_capex_multiplier < 1.0: + raise ValueError( + f"urban_capex_multiplier must be >= 1.0 (got {urban_capex_multiplier})" + ) prices = _UNIT_PRICES[region] fallback = _UNIT_PRICES[Region.EU_ALPINE] est = CostEstimate( project_name=bom.project_name, region=region, contingency_fraction=contingency_fraction, + urban_capex_multiplier=urban_capex_multiplier, ) for L in bom.lines: unit = prices.get(L.item, fallback.get(L.item, 0.0)) diff --git a/tests/test_urban_capex.py b/tests/test_urban_capex.py new file mode 100644 index 0000000..3be74bb --- /dev/null +++ b/tests/test_urban_capex.py @@ -0,0 +1,122 @@ +"""Phase 8b — urban capex multiplier on estimate_cost.""" + +from __future__ import annotations + +import pytest + +from ropeway.alignment import Tower +from ropeway.bom import BillOfMaterials, BOMLineItem, build_bom +from ropeway.cost import CostEstimate, Region, estimate_cost +from ropeway.multi_rope import RopewaySystemType +from ropeway.safety import ConstraintConfig + + +def _toy_bom() -> BillOfMaterials: + """A small hand-rolled BOM to test the cost layer without running the GA.""" + bom = BillOfMaterials(project_name="toy", system=RopewaySystemType.MGD) + bom.lines.append(BOMLineItem("Steel tower fabrication", 5000.0, "kg")) + bom.lines.append(BOMLineItem("Steel wire rope", 1000.0, "m")) + bom.lines.append(BOMLineItem("Tower-top sheave assembly", 5.0, "ea")) + return bom + + +# --------------------------------------------------------------------------- +# Default behaviour — multiplier == 1.0 preserves the bare-infra total +# --------------------------------------------------------------------------- + + +def test_default_multiplier_one_leaves_grand_total_unchanged(): + bom = _toy_bom() + est = estimate_cost(bom, region=Region.EU_ALPINE) + assert est.urban_capex_multiplier == 1.0 + # grand_total ≡ bare_infrastructure_total when multiplier is 1. + assert est.grand_total == pytest.approx(est.bare_infrastructure_total) + assert est.urban_uplift == pytest.approx(0.0) + + +def test_default_csv_matches_pre_8b_format(): + """With multiplier == 1.0 the CSV must not gain new lines (legacy + consumers reading the old format keep working).""" + bom = _toy_bom() + est = estimate_cost(bom, region=Region.EU_ALPINE) + csv = est.as_csv() + assert "Urban uplift" not in csv + assert "Bare infrastructure" not in csv + # All expected legacy lines are still there. + assert "Subtotal" in csv and "Contingency" in csv and "Grand total" in csv + + +# --------------------------------------------------------------------------- +# Multiplier > 1 — the documented urban-deployment uplift +# --------------------------------------------------------------------------- + + +def test_multiplier_six_lifts_grand_total_six_x_bare(): + bom = _toy_bom() + bare = estimate_cost(bom, region=Region.EU_ALPINE).bare_infrastructure_total + est = estimate_cost(bom, region=Region.EU_ALPINE, + urban_capex_multiplier=6.0) + assert est.grand_total == pytest.approx(6.0 * bare) + assert est.urban_uplift == pytest.approx(5.0 * bare) + # bare_infrastructure_total is invariant — the BOM line items don't move. + assert est.bare_infrastructure_total == pytest.approx(bare) + + +def test_summary_lines_include_urban_uplift_only_when_multiplier_above_one(): + bom = _toy_bom() + summary_default = "\n".join(estimate_cost(bom).summary_lines()) + summary_urban = "\n".join( + estimate_cost(bom, urban_capex_multiplier=5.5).summary_lines() + ) + assert "Urban uplift" not in summary_default + assert "Urban uplift" in summary_urban + assert "x5.5" in summary_urban + + +def test_csv_carries_urban_uplift_lines(): + bom = _toy_bom() + csv = estimate_cost(bom, urban_capex_multiplier=5.0).as_csv() + assert "Bare infrastructure" in csv + assert "Urban uplift (x5.00)" in csv + + +def test_multiplier_below_one_raises(): + bom = _toy_bom() + with pytest.raises(ValueError, match="urban_capex_multiplier"): + estimate_cost(bom, urban_capex_multiplier=0.8) + + +# --------------------------------------------------------------------------- +# Integration with build_bom — the canonical Linea Roja honest-call ratio +# --------------------------------------------------------------------------- + + +def test_linea_roja_band_grand_total_recovers_published_envelope(): + """Mi Teleférico Línea Roja published budget was ≈ US$ 46 M for a + deployment whose bare-infrastructure BoM the optimizer estimates at + a few million euros. With a 6× urban multiplier the grand_total + lands in the same order of magnitude as the published figure.""" + # Build a representative MGD BoM via the same path the case study + # uses; we use 11 towers (matching the redux towers.csv). + cfg = ConstraintConfig( + horizontal_tension_n=250_000.0, cable_weight_n_per_m=60.0, + ) + towers = [Tower(0.0, 18.92, is_station=True)] + towers += [Tower(d, 30.0) for d in (380, 760, 1122, 1466)] + towers += [Tower(1700.0, 12.0, is_station=True)] + towers += [Tower(d, 30.0) for d in (2080, 2460, 2840, 3237)] + towers.append(Tower(3567.83, 15.10, is_station=True)) + from ropeway.alignment import Alignment + bom = build_bom( + Alignment(towers=towers, profile_fn=lambda x: x * 0 + 3500, cfg=cfg), + project_name="Mi Teleférico Línea Roja", + system=RopewaySystemType.MGD, cfg=cfg, + ) + bare = estimate_cost(bom, region=Region.EMERGING).grand_total + turnkey = estimate_cost( + bom, region=Region.EMERGING, urban_capex_multiplier=6.0 + ).grand_total + # bare is a few million; turnkey ≈ 6× that. The headline ratio is the + # invariant we care about, not absolute values. + assert turnkey == pytest.approx(6.0 * bare) + assert 5.0 < turnkey / bare < 7.5