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