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