Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 53 additions & 2 deletions src/ropeway/cost.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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

Expand All @@ -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"

Expand All @@ -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))
Expand Down
122 changes: 122 additions & 0 deletions tests/test_urban_capex.py
Original file line number Diff line number Diff line change
@@ -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
Loading