"""S4 unit tests: debt schedule, sizing, IRR metrics, tariff solver.""" import math import pytest from remodel_engine.debt.schedule import build_debt_schedule from remodel_engine.debt.sizing import size_debt from remodel_engine.irr.metrics import ( compute_all_metrics, compute_dscr_metrics, compute_equity_irr, compute_lcoe, compute_llcr, compute_payback, compute_project_irr, ) from remodel_engine.schemas.debt import DebtConfig from remodel_engine.solver.tariff import solve_tariff # --------------------------------------------------------------------------- # Debt schedule # --------------------------------------------------------------------------- def _default_config(shape: str = "equal_principal") -> DebtConfig: return DebtConfig( interest_rate_annual=0.09, tenor_years=15, moratorium_years=1, de_ratio=3.0, min_dscr=1.20, avg_dscr=1.35, schedule_shape=shape, # type: ignore[arg-type] ) def test_debt_schedule_length() -> None: sched = build_debt_schedule(1000.0, [200.0] * 25, _default_config()) assert len(sched) == 25 def test_debt_schedule_balance_monotonic() -> None: sched = build_debt_schedule(1000.0, [200.0] * 25, _default_config()) for i in range(len(sched) - 1): assert sched[i + 1].opening_balance_cr <= sched[i].opening_balance_cr + 1e-4 def test_debt_schedule_zero_at_end() -> None: sched = build_debt_schedule(500.0, [200.0] * 25, _default_config()) # After tenor years, outstanding balance should be near 0 assert sched[14].closing_balance_cr < 1e-4 def test_debt_schedule_equal_principal_constant_principal() -> None: sched = build_debt_schedule(1000.0, [200.0] * 25, _default_config("equal_principal")) # After moratorium, principal should be constant repay = [r.principal_cr for r in sched[1:14]] # years 2-14 (repayment) assert all(abs(p - repay[0]) < 0.01 for p in repay) def test_debt_schedule_equal_installment_constant_dts() -> None: sched = build_debt_schedule(1000.0, [200.0] * 25, _default_config("equal_installment")) # Total debt service should be approximately constant in repayment period dts = [r.total_debt_service_cr for r in sched[1:14]] assert all(abs(d - dts[0]) < 0.5 for d in dts) # allow 0.5 Cr tolerance def test_debt_schedule_dscr_computed() -> None: sched = build_debt_schedule(500.0, [100.0] * 25, _default_config()) for r in sched: if r.total_debt_service_cr > 1e-4: assert r.dscr > 0 def test_debt_schedule_moratorium_no_principal() -> None: sched = build_debt_schedule(1000.0, [200.0] * 25, _default_config()) assert sched[0].principal_cr == pytest.approx(0.0, abs=1e-4) def test_debt_schedule_balloon() -> None: sched = build_debt_schedule(500.0, [200.0] * 25, _default_config("balloon")) # All principal in last year of tenor (year 15, index 14) for r in sched[:14]: assert r.principal_cr < 1e-4 assert sched[14].principal_cr > 400.0 # --------------------------------------------------------------------------- # Debt sizing # --------------------------------------------------------------------------- def test_size_debt_respects_de_ratio() -> None: cfg = DebtConfig(de_ratio=3.0, min_dscr=1.10, avg_dscr=1.20) cfads = [300.0] * 25 debt = size_debt(1000.0, cfads, cfg) max_de_debt = 1000.0 * 3.0 / (1 + 3.0) # 750 assert debt <= max_de_debt + 1.0 def test_size_debt_zero_cfads_gives_low_debt() -> None: cfg = DebtConfig(de_ratio=3.0, min_dscr=1.10, avg_dscr=1.20) cfads = [0.0] * 25 debt = size_debt(1000.0, cfads, cfg) assert debt >= 0.0 def test_size_debt_positive() -> None: cfg = DebtConfig(de_ratio=3.0, min_dscr=1.10, avg_dscr=1.20) debt = size_debt(2000.0, [500.0] * 25, cfg) assert debt > 0 # --------------------------------------------------------------------------- # IRR and metrics # --------------------------------------------------------------------------- def test_project_irr_simple_case() -> None: """Known case: invest 100, get 15/yr for 25yr → IRR ~14.8%.""" irr = compute_project_irr(100.0, [15.0] * 25) assert irr is not None assert 0.10 < irr < 0.20 def test_project_irr_negative_cashflows_returns_none_or_finite() -> None: """All-negative cashflows → no positive IRR.""" irr = compute_project_irr(1000.0, [-10.0] * 25) # numpy_financial may return nan or a negative number if irr is not None: assert irr < 0 or not math.isfinite(irr) def test_equity_irr_simple() -> None: irr = compute_equity_irr(250.0, [50.0] * 25) assert irr is not None assert 0.15 < irr < 0.25 def test_payback_exact() -> None: # capex=100, earn 25/yr → payback = 4 years pb = compute_payback(100.0, [25.0] * 25) assert pb == pytest.approx(4.0, abs=0.01) def test_payback_not_recovered() -> None: pb = compute_payback(1000.0, [1.0] * 25) assert pb is None def test_lcoe_reasonable_range() -> None: lcoe = compute_lcoe(1000.0, [30.0] * 25, [876000.0] * 25, 0.09) assert lcoe is not None # For 100 MW solar, LCOE should be roughly 2-5 INR/kWh assert 1.0 < lcoe < 10.0 def test_dscr_metrics() -> None: rows = build_debt_schedule(500.0, [100.0] * 25, _default_config()) min_d, avg_d = compute_dscr_metrics(rows) assert min_d > 0 assert avg_d >= min_d def test_llcr_positive() -> None: rows = build_debt_schedule(500.0, [100.0] * 25, _default_config()) llcr = compute_llcr([100.0] * 25, rows, 0.09) assert llcr is not None assert llcr > 0 def test_compute_all_metrics_returns_irr_metrics() -> None: rows = build_debt_schedule(750.0, [200.0] * 25, _default_config()) metrics = compute_all_metrics( total_capex_cr=1000.0, equity_cr=250.0, cfads_by_year=[200.0] * 25, pat_by_year=[80.0] * 25, opex_by_year=[50.0] * 25, generation_mwh_by_year=[876000.0] * 25, schedule=rows, ) assert metrics.project_irr is not None assert metrics.equity_irr is not None assert metrics.min_dscr is not None # --------------------------------------------------------------------------- # Tariff solver # --------------------------------------------------------------------------- def test_tariff_solver_converges() -> None: """Solver should find tariff where equity IRR = 18%.""" target = 0.18 def objective(tariff: float) -> float: # Synthetic: equity_irr = 0.05 + 0.04 * (tariff - 2.0) equity_irr = 0.05 + 0.04 * (tariff - 2.0) return equity_irr - target solved_tariff, _ = solve_tariff(objective, target) # Check: 0.05 + 0.04 * (t - 2) = 0.18 → t = 2 + (0.18-0.05)/0.04 = 5.25 assert solved_tariff == pytest.approx(5.25, abs=0.01) def test_tariff_solver_lower_bound_hit() -> None: """If IRR > target at lo, return lo.""" def obj(t: float) -> float: return 0.30 - 0.18 # always above target tariff, _ = solve_tariff(obj, 0.18, lo=2.0) assert tariff == 2.0 def test_tariff_solver_upper_bound_hit() -> None: """If IRR < target at hi, return hi.""" def obj(t: float) -> float: return 0.05 - 0.18 # always below target tariff, _ = solve_tariff(obj, 0.18, hi=8.0) assert tariff == 8.0