- 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>
267 lines
9.2 KiB
Python
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
|