- 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>
296 lines
10 KiB
Python
296 lines
10 KiB
Python
"""S3 financial module unit tests: depreciation, tax, WC, P&L, CFS, BS."""
|
|
|
|
import pytest
|
|
|
|
from remodel_engine.financial.bs import build_bs
|
|
from remodel_engine.financial.cfs import build_cfs
|
|
from remodel_engine.financial.depreciation import (
|
|
AssetBlock,
|
|
build_depreciation_schedule,
|
|
compute_slm_depreciation,
|
|
compute_wdv_depreciation,
|
|
)
|
|
from remodel_engine.financial.pnl import build_pnl, compute_opex, compute_revenue
|
|
from remodel_engine.financial.tax import compute_current_tax, compute_tax_schedule
|
|
from remodel_engine.financial.working_capital import compute_working_capital
|
|
from remodel_engine.schemas.financial import CommercialConfig, OpexConfig, TaxConfig
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Depreciation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_slm_depreciation_length() -> None:
|
|
depr = compute_slm_depreciation(100.0, 25.0)
|
|
assert len(depr) == 25
|
|
|
|
|
|
def test_slm_depreciation_sum() -> None:
|
|
depr = compute_slm_depreciation(100.0, 25.0)
|
|
assert abs(sum(depr) - 100.0) < 1e-4
|
|
|
|
|
|
def test_slm_depreciation_even() -> None:
|
|
depr = compute_slm_depreciation(100.0, 25.0)
|
|
assert all(abs(d - 4.0) < 1e-4 for d in depr)
|
|
|
|
|
|
def test_slm_depreciation_zero_after_life() -> None:
|
|
depr = compute_slm_depreciation(60.0, 12.0, n_years=25)
|
|
assert all(d == 0.0 for d in depr[12:])
|
|
assert abs(sum(depr[:12]) - 60.0) < 1e-4
|
|
|
|
|
|
def test_wdv_never_fully_zero() -> None:
|
|
"""WDV never reaches zero (declining balance property)."""
|
|
depr = compute_wdv_depreciation(100.0, 0.40, n_years=25)
|
|
assert all(d > 0 for d in depr)
|
|
|
|
|
|
def test_wdv_sum_less_than_cost() -> None:
|
|
"""WDV depreciation total is less than cost over 25 years (book value remains)."""
|
|
depr = compute_wdv_depreciation(100.0, 0.40, n_years=25)
|
|
assert sum(depr) < 100.0
|
|
|
|
|
|
def test_depreciation_schedule_plant_block() -> None:
|
|
blocks = [AssetBlock(depr_class="Plant", gross_cost_cr=1000.0)]
|
|
sched = build_depreciation_schedule(blocks, n_years=25)
|
|
assert len(sched.book_depr) == 25
|
|
assert abs(sched.book_depr[0] - 40.0) < 0.1 # 1000/25
|
|
assert sched.net_block_book[-1] >= 0
|
|
|
|
|
|
def test_depreciation_schedule_mixed_blocks() -> None:
|
|
blocks = [
|
|
AssetBlock(depr_class="Plant", gross_cost_cr=800.0),
|
|
AssetBlock(depr_class="BESS", gross_cost_cr=200.0),
|
|
AssetBlock(depr_class="Land_NoDepr", gross_cost_cr=50.0),
|
|
]
|
|
sched = build_depreciation_schedule(blocks, n_years=25)
|
|
# Plant: 800/25=32/yr; BESS: 200/12=16.67/yr (first 12 years), 0 after
|
|
assert abs(sched.book_depr[0] - (32.0 + 200.0 / 12.0)) < 0.1
|
|
assert sched.book_depr[12] == pytest.approx(32.0, abs=0.1) # only Plant remains
|
|
|
|
|
|
def test_depreciation_tax_faster_than_book() -> None:
|
|
"""For Plant, WDV 40% depreciates faster than SLM 25yr in early years."""
|
|
blocks = [AssetBlock(depr_class="Plant", gross_cost_cr=100.0)]
|
|
sched = build_depreciation_schedule(blocks, n_years=25)
|
|
assert sched.tax_depr[0] > sched.book_depr[0] # 40% > 4%
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tax
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_current_tax_positive_pbt() -> None:
|
|
assert abs(compute_current_tax(100.0, 0.2517) - 25.17) < 1e-4
|
|
|
|
|
|
def test_current_tax_negative_pbt_gives_zero() -> None:
|
|
assert compute_current_tax(-50.0, 0.2517) == 0.0
|
|
|
|
|
|
def test_tax_schedule_length() -> None:
|
|
cfg = TaxConfig()
|
|
pbt = [10.0] * 25
|
|
book = [4.0] * 25
|
|
tax_d = [40.0] + [24.0] * 24
|
|
ct, dt, dtl = compute_tax_schedule(pbt, book, tax_d, cfg)
|
|
assert len(ct) == len(dt) == len(dtl) == 25
|
|
|
|
|
|
def test_deferred_tax_increases_when_tax_depr_exceeds_book() -> None:
|
|
cfg = TaxConfig()
|
|
pbt = [100.0] * 25
|
|
book_d = [4.0] * 25 # 4 Cr/yr
|
|
tax_d = [40.0] * 25 # 40 Cr/yr (much faster)
|
|
_, dt_mv, dtl = compute_tax_schedule(pbt, book_d, tax_d, cfg)
|
|
assert dt_mv[0] > 0 # DTL increases in early years
|
|
assert dtl[0] > 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Revenue and OpEx
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _default_comm_config() -> CommercialConfig:
|
|
return CommercialConfig(
|
|
tariff_inr_per_kwh=3.50,
|
|
ppa_capacity_mw=100.0,
|
|
receivable_days=45.0,
|
|
payable_days=30.0,
|
|
)
|
|
|
|
|
|
def test_revenue_single_year() -> None:
|
|
rev = compute_revenue(
|
|
ac_gen_mwh_by_year=[876000.0], # 100 MW * 8760 hr
|
|
tariff_inr_per_kwh=3.50,
|
|
aux_pct=0.005,
|
|
tx_loss_pct=0.01,
|
|
dsm_loss_pct=0.02,
|
|
)
|
|
# net_kwh = 876e6 * (0.995) * (0.99) * (0.98) = roughly 847e6
|
|
# revenue = 847e6 * 3.5 / 1e7 ≈ 296.5 Cr
|
|
assert len(rev) == 1
|
|
assert 280.0 < rev[0] < 320.0
|
|
|
|
|
|
def test_revenue_zero_generation_gives_zero() -> None:
|
|
rev = compute_revenue([0.0] * 25, 3.5, 0.005, 0.01, 0.02)
|
|
assert all(v == 0.0 for v in rev)
|
|
|
|
|
|
def test_opex_escalates() -> None:
|
|
cfg = OpexConfig(om_escalation_pct=0.04, misc_cr=0.0, am_fee_pct_of_revenue=0.0)
|
|
rev = [100.0] * 25
|
|
opex = compute_opex(
|
|
rev, solar_mw=100.0, wind_mw=0.0, bess_mwh=0.0, base_capex_cr=500.0, config=cfg
|
|
)
|
|
# Should escalate each year
|
|
for y in range(1, 25):
|
|
assert opex[y] > opex[y - 1]
|
|
|
|
|
|
def test_opex_length() -> None:
|
|
cfg = OpexConfig()
|
|
rev = [100.0] * 25
|
|
opex = compute_opex(rev, 100.0, 50.0, 200.0, 1000.0, cfg)
|
|
assert len(opex) == 25
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Working Capital
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_wc_receivables_positive() -> None:
|
|
cfg = _default_comm_config()
|
|
rev = [100.0] * 25
|
|
opex = [20.0] * 25
|
|
wc, dwc = compute_working_capital(rev, opex, cfg)
|
|
assert all(w > 0 for w in wc) # receivables > payables
|
|
|
|
|
|
def test_wc_delta_length() -> None:
|
|
cfg = _default_comm_config()
|
|
wc, dwc = compute_working_capital([100.0] * 25, [20.0] * 25, cfg)
|
|
assert len(dwc) == 25
|
|
|
|
|
|
def test_wc_stable_revenue_zero_subsequent_delta() -> None:
|
|
"""Constant revenue/opex → WC stable → delta WC = 0 from year 2 onwards."""
|
|
cfg = _default_comm_config()
|
|
wc, dwc = compute_working_capital([100.0] * 25, [20.0] * 25, cfg)
|
|
assert all(abs(d) < 1e-6 for d in dwc[1:])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# P&L integration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_pnl_length() -> None:
|
|
cfg = OpexConfig()
|
|
rev = [300.0] * 25
|
|
opex = [50.0] * 25
|
|
depr = [40.0] * 25
|
|
interest = [60.0] * 25
|
|
ct = [30.0] * 25
|
|
dt = [5.0] * 25
|
|
rows = build_pnl(rev, rev, 3.5, [0.0] * 25, [0.0] * 25, opex, depr, interest, ct, dt, 100.0, 50.0, 200.0, 1000.0, cfg)
|
|
assert len(rows) == 25
|
|
|
|
|
|
def test_pnl_ebitda_formula() -> None:
|
|
cfg = OpexConfig(
|
|
om_solar_cr_per_mw=0.0,
|
|
om_wind_cr_per_mw=0.0,
|
|
om_bess_cr_per_mwh=0.0,
|
|
insurance_pct_of_capex=0.0,
|
|
land_lease_cr=0.0,
|
|
am_fee_pct_of_revenue=0.0,
|
|
misc_cr=0.0,
|
|
)
|
|
rev = [100.0] * 25
|
|
rows = build_pnl(rev, rev, 3.5, [0.0] * 25, [0.0] * 25, [0.0] * 25, [10.0] * 25, [20.0] * 25, [0.0] * 25, [0.0] * 25,
|
|
0.0, 0.0, 0.0, 0.0, cfg)
|
|
assert rows[0].ebitda_cr == pytest.approx(100.0, abs=1e-4)
|
|
assert rows[0].ebit_cr == pytest.approx(90.0, abs=1e-4)
|
|
assert rows[0].pbt_cr == pytest.approx(70.0, abs=1e-4)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CFS integration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_cfs_closing_cash_accumulates() -> None:
|
|
rows = build_cfs(
|
|
pnl_pat=[100.0] * 25,
|
|
pnl_depr=[40.0] * 25,
|
|
delta_wc_by_year=[5.0] + [0.0] * 24,
|
|
capex_cr=1000.0,
|
|
debt_drawdown_by_year=[0.0] * 25,
|
|
debt_repayment_by_year=[50.0] * 25,
|
|
equity_injection_by_year=[0.0] * 25,
|
|
opening_cash_cr=50.0,
|
|
)
|
|
assert len(rows) == 25
|
|
# CFO year 1 = PAT + depr - delta_wc = 100 + 40 - 5 = 135; CFF = -50; net = 85
|
|
assert rows[0].cfo_cr == pytest.approx(135.0, abs=1e-4)
|
|
assert rows[0].closing_cash_cr == pytest.approx(50.0 + 135.0 - 50.0, abs=1e-4)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# BS integration (with reconciliation)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_bs_reconciles() -> None:
|
|
"""A consistent BS (assets = liab) should not raise."""
|
|
n = 25
|
|
gross = 1000.0
|
|
equity = 300.0
|
|
# Build simple scenario where retained earnings absorb the difference
|
|
net_block = [1000.0 - 40.0 * (y + 1) for y in range(n)]
|
|
net_block = [max(0.0, nb) for nb in net_block]
|
|
acc_depr = [min(40.0 * (y + 1), 1000.0) for y in range(n)]
|
|
cash = [100.0 + 90.0 * y for y in range(n)]
|
|
receivables = [12.0] * n
|
|
debt = [700.0 - 30.0 * y for y in range(n)]
|
|
debt = [max(0.0, d) for d in debt]
|
|
payables = [5.0] * n
|
|
dtl = [10.0 * (y + 1) * 0.2 for y in range(n)]
|
|
|
|
# retained_earnings = total_assets - equity - debt - payables - dtl
|
|
retained = [
|
|
net_block[y] + cash[y] + receivables[y] - equity - debt[y] - payables[y] - dtl[y]
|
|
for y in range(n)
|
|
]
|
|
rows = build_bs(gross, acc_depr, net_block, cash, receivables,
|
|
equity, retained, debt, payables, dtl)
|
|
assert len(rows) == 25
|
|
for row in rows:
|
|
assert abs(row.total_assets_cr - row.total_liabilities_cr) < 0.05
|
|
|
|
|
|
def test_bs_reconciliation_fails_on_mismatch() -> None:
|
|
"""Deliberately mismatched BS should raise AssertionError."""
|
|
with pytest.raises(AssertionError, match="BS reconciliation fail"):
|
|
build_bs(
|
|
gross_block_cr=1000.0,
|
|
accumulated_depr_by_year=[40.0] * 25,
|
|
net_block_by_year=[960.0] * 25,
|
|
cash_by_year=[100.0] * 25,
|
|
receivables_by_year=[10.0] * 25,
|
|
equity_cr=300.0,
|
|
retained_earnings_by_year=[0.0] * 25, # wrong — will mismatch
|
|
debt_outstanding_by_year=[700.0] * 25,
|
|
payables_by_year=[5.0] * 25,
|
|
dtl_by_year=[0.0] * 25,
|
|
)
|