Remodel/packages/engine/tests/unit/test_debt_irr.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

223 lines
7.2 KiB
Python

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