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

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,
)