Remodel/packages/engine/tests/unit/test_capex.py
Mannu e6dc39aa33 [S1-T12/T13] P&L revenue breakdown + collapsible rows + UI polish
- Engine: Add ppa_revenue_cr, mcp_revenue_cr, tariff, units to PnLRow
- Engine: Split PPA vs MCP revenue in P&L computation
- Web: Collapsible rows for PPA/MCP Revenue and Opex
- Web: Highlighted rows (Total Revenue, EBITDA, EBIT, PBT, PAT)
- Web: Units above Tariff in breakdown, bg-blue-50 highlight
- Fix sticky column z-index for horizontal scroll
- CLAUDE.md: Add project documentation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 10:42:36 +05:30

267 lines
9.2 KiB
Python

"""S2 capex unit tests: cost items, phasing, IDC solver."""
import pytest
from remodel_engine.capex.cost_items import (
ProjectCapacity,
compute_capex,
evaluate_cost_item,
)
from remodel_engine.capex.idc import compute_idc, monthly_idc_schedule
from remodel_engine.capex.phasing import load_phasing, trim_or_extend_phasing, validate_phasing
from remodel_engine.catalog.cost_items import DEFAULT_COST_ITEMS
from remodel_engine.schemas.capex import CostItem, DrawdownCurve, PhasingCurve
# ---------------------------------------------------------------------------
# CostItem evaluation
# ---------------------------------------------------------------------------
def test_per_wp_dc() -> None:
item = CostItem(
id="x", name="x", category="HardCost",
basis="PER_WP_DC", value=20.0,
depr_class="Plant", phasing_id="solar_standard_18mo", attribution="SolarOnly",
)
cap = ProjectCapacity(solar_mwp_dc=100.0)
result = evaluate_cost_item(item, cap)
# 20 INR/Wp * 100 MWp * 1e6 Wp/MWp = 2e9 INR = 200 Cr
assert abs(result - 200.0) < 1e-6
def test_per_mwh_bess() -> None:
item = CostItem(
id="x", name="x", category="HardCost",
basis="PER_MWH_BESS", value=0.20,
depr_class="BESS", phasing_id="solar_standard_18mo", attribution="BESSOnly",
)
cap = ProjectCapacity(bess_mwh=500.0)
assert abs(evaluate_cost_item(item, cap) - 100.0) < 1e-6
def test_per_kwh_usd() -> None:
item = CostItem(
id="x", name="x", category="HardCost",
basis="PER_KWH_USD", value=80.0, fx_rate=84.0,
depr_class="BESS", phasing_id="solar_standard_18mo", attribution="BESSOnly",
)
cap = ProjectCapacity(bess_mwh=500.0)
# 80 USD/kWh * 84 INR/USD * 500 MWh * 1000 kWh/MWh = 3.36e9 INR = 336 Cr
assert abs(evaluate_cost_item(item, cap) - 336.0) < 1e-4
def test_abs_inr_cr() -> None:
item = CostItem(
id="x", name="x", category="HardCost",
basis="ABS_INR_CR", value=15.0,
depr_class="Plant", phasing_id="solar_standard_18mo", attribution="Common",
)
assert evaluate_cost_item(item, ProjectCapacity()) == 15.0
def test_pct_of_hardcost_returns_zero_without_resolution() -> None:
item = CostItem(
id="x", name="x", category="EPCOverhead",
basis="PCT_OF_HARDCOST", value=0.05,
depr_class="Capitalized_NoDepr", phasing_id="solar_standard_18mo", attribution="Common",
)
assert evaluate_cost_item(item, ProjectCapacity()) == 0.0
def test_compute_capex_resolves_pct() -> None:
items = [
CostItem(
id="a", name="a", category="HardCost",
basis="ABS_INR_CR", value=100.0,
depr_class="Plant", phasing_id="solar_standard_18mo", attribution="Common",
),
CostItem(
id="b", name="b", category="EPCMargin",
basis="PCT_OF_HARDCOST", value=0.05,
depr_class="Capitalized_NoDepr", phasing_id="solar_standard_18mo", attribution="Common",
),
]
bd = compute_capex(items, ProjectCapacity())
assert abs(bd.hard_cost_cr - 100.0) < 1e-6
assert abs(bd.total_cr - 105.0) < 1e-6
def test_default_catalog_loads() -> None:
assert len(DEFAULT_COST_ITEMS) >= 25
def test_default_catalog_all_phasing_ids_known() -> None:
known = {"solar_standard_18mo", "wind_standard_24mo", "hybrid_rtc_36mo"}
for item in DEFAULT_COST_ITEMS:
assert item.phasing_id in known, f"{item.id} has unknown phasing_id {item.phasing_id!r}"
# ---------------------------------------------------------------------------
# Phasing templates
# ---------------------------------------------------------------------------
def test_phasing_sums_to_one() -> None:
for pid in ["solar_standard_18mo", "wind_standard_24mo", "hybrid_rtc_36mo"]:
curve = load_phasing(pid)
assert abs(sum(curve.monthly_pct) - 1.0) < 1e-6, f"{pid} does not sum to 1"
def test_phasing_no_negatives() -> None:
for pid in ["solar_standard_18mo", "wind_standard_24mo", "hybrid_rtc_36mo"]:
curve = load_phasing(pid)
assert all(p >= 0 for p in curve.monthly_pct)
def test_phasing_validate_clean() -> None:
curve = load_phasing("solar_standard_18mo")
assert validate_phasing(curve) == []
def test_phasing_validate_bad_sum() -> None:
# PhasingCurve validator rejects bad sums at construction; validate_phasing also catches it
curve = PhasingCurve.model_construct(id="x", name="x", monthly_pct=[0.5, 0.6])
errors = validate_phasing(curve)
assert any("sum" in e for e in errors)
def test_load_phasing_unknown_raises() -> None:
with pytest.raises(ValueError, match="Unknown phasing_id"):
load_phasing("nonexistent")
def test_trim_phasing() -> None:
curve = load_phasing("solar_standard_18mo")
trimmed = trim_or_extend_phasing(curve, 12)
assert len(trimmed.monthly_pct) == 12
assert abs(sum(trimmed.monthly_pct) - 1.0) < 1e-6
def test_extend_phasing() -> None:
curve = PhasingCurve(id="x", name="x", monthly_pct=[0.5, 0.3, 0.2])
extended = trim_or_extend_phasing(curve, 5)
assert len(extended.monthly_pct) == 5
assert abs(sum(extended.monthly_pct) - 1.0) < 1e-6
# ---------------------------------------------------------------------------
# IDC solver
# ---------------------------------------------------------------------------
def _flat_debt_curve(n: int) -> DrawdownCurve:
"""Even debt drawdown over n months."""
step = 1.0 / n
cum = [round(step * (m + 1), 10) for m in range(n)]
cum[-1] = 1.0
return DrawdownCurve(id="test", name="test", cum_pct=cum)
def test_idc_zero_debt_gives_zero_idc() -> None:
curve = _flat_debt_curve(12)
idc, debt, iters = compute_idc(
base_capex_cr=1000.0,
debt_fraction=0.0,
interest_rate_annual=0.09,
debt_curve=curve,
n_months=12,
)
assert idc == pytest.approx(0.0, abs=1e-9)
assert debt == pytest.approx(0.0, abs=1e-9)
def test_idc_deterministic_2_month() -> None:
"""Hand-computed IDC for 2-month construction with all debt drawn in month 1.
base_capex = 100 Cr, debt_fraction = 0.75, rate = 12% pa
Iteration 1: TPC=100, debt=75, all drawn m1, IDC = 75 * 1% = 0.75 Cr
Iteration 2: TPC=100.75, debt=75.5625, IDC = 75.5625 * 1% = 0.756 Cr
...converges near ~0.757 Cr
"""
# Debt all drawn in month 1 (cum=[1.0, 1.0])
curve = DrawdownCurve(id="t", name="t", cum_pct=[1.0, 1.0])
idc, debt, iters = compute_idc(
base_capex_cr=100.0,
debt_fraction=0.75,
interest_rate_annual=0.12,
debt_curve=curve,
n_months=2,
)
# Hand derivation: all debt drawn at end of month 1, 1 month remaining
# IDC = debt * r = 0.75*TPC * 0.01; TPC = 100+IDC
# IDC = 0.0075*(100+IDC) → IDC*(1-0.0075)=0.75 → IDC = 0.75/0.9925
expected_idc = 0.75 / 0.9925 # ≈ 0.7557 Cr
assert idc == pytest.approx(expected_idc, rel=1e-3)
def test_idc_zero_rate_gives_zero_idc() -> None:
curve = _flat_debt_curve(24)
idc, _, _ = compute_idc(
base_capex_cr=500.0,
debt_fraction=0.75,
interest_rate_annual=0.0,
debt_curve=curve,
n_months=24,
)
assert idc == pytest.approx(0.0, abs=1e-6)
def test_idc_converges_within_tolerance() -> None:
curve = _flat_debt_curve(24)
idc, debt, iters = compute_idc(
base_capex_cr=800.0,
debt_fraction=0.75,
interest_rate_annual=0.09,
debt_curve=curve,
n_months=24,
)
# Verify self-consistency: IDC = sum(debt_monthly * r * remaining)
delta = debt / 24.0
r = 0.09 / 12.0
expected_idc = sum(delta * r * (24 - m - 1) for m in range(24))
assert abs(idc - expected_idc) < 0.1 # within 10 paise
def test_idc_monthly_schedule_length() -> None:
curve = _flat_debt_curve(12)
schedule = monthly_idc_schedule(100.0, 0.75, 0.09, curve, n_months=12)
assert len(schedule) == 12
assert all(v >= 0 for v in schedule)
def test_idc_monthly_schedule_increasing() -> None:
"""Interest accrues on growing outstanding balance — monthly amount must increase."""
curve = _flat_debt_curve(12)
schedule = monthly_idc_schedule(100.0, 0.75, 0.09, curve, n_months=12)
for i in range(1, len(schedule)):
assert schedule[i] >= schedule[i - 1]
def test_idc_larger_construction_gives_more_idc() -> None:
"""Longer construction period → more IDC (all else equal)."""
base = 1000.0
frac = 0.75
rate = 0.09
curve_12 = _flat_debt_curve(12)
curve_24 = _flat_debt_curve(24)
idc_12, _, _ = compute_idc(base, frac, rate, curve_12, 12)
idc_24, _, _ = compute_idc(base, frac, rate, curve_24, 24)
assert idc_24 > idc_12
def test_idc_36mo_hybrid_catalog_curve() -> None:
"""IDC on a realistic hybrid RTC scenario should be > 0 and < 10% of base capex."""
from remodel_engine.catalog.phasing import DRAWDOWN_TEMPLATES
_, debt_curve = DRAWDOWN_TEMPLATES["hybrid_rtc_36mo"]
base_capex = 2500.0 # INR Cr — 500 MW hybrid
idc, debt, iters = compute_idc(
base_capex_cr=base_capex,
debt_fraction=0.75,
interest_rate_annual=0.095,
debt_curve=debt_curve,
n_months=36,
)
assert idc > 0
assert idc < 0.20 * base_capex # 36-month construction at 9.5% → IDC ~15% of capex
assert iters < 50