- 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>
223 lines
7.2 KiB
Python
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
|