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

184 lines
5.8 KiB
Python

"""S7-T05: Hand-validated dispatch tests."""
import pytest
from remodel_engine.dispatch.hybrid_rtc import DispatchConfig, run_dispatch
from remodel_engine.dispatch.mcp_settlement import (
build_mcp_price_profile,
compute_mcp_annual_revenue_cr,
)
# ---------------------------------------------------------------------------
# 24-hour hand-validated scenario
# ---------------------------------------------------------------------------
def _flat(v: float, n: int = 24) -> list[float]:
return [v] * n
def test_no_bess_no_shortfall_perfect_match() -> None:
"""Solar exactly matches RTC — no charge, no discharge, no shortfall."""
cfg = DispatchConfig(rtc_mw=10.0, bess_mwh=0.0, bess_mw=0.0)
summary = run_dispatch(_flat(10.0), _flat(0.0), cfg)
assert summary.total_shortfall_mwh == pytest.approx(0.0, abs=1e-4)
assert summary.total_curtailed_mwh == pytest.approx(0.0, abs=1e-4)
assert summary.total_net_injection_mwh == pytest.approx(240.0, abs=0.1)
def test_no_bess_all_shortfall() -> None:
"""Zero generation, no BESS — all hours are shortfall."""
cfg = DispatchConfig(rtc_mw=10.0, bess_mwh=0.0, bess_mw=0.0)
summary = run_dispatch(_flat(0.0), _flat(0.0), cfg)
assert summary.total_shortfall_mwh == pytest.approx(240.0, abs=0.1)
assert summary.total_net_injection_mwh == pytest.approx(0.0, abs=1e-4)
def test_bess_fills_nighttime_gap() -> None:
"""Solar 10 MW daytime (h0-11), BESS 120 MWh covers night (h12-23)."""
solar = [10.0] * 12 + [0.0] * 12
wind = [0.0] * 24
cfg = DispatchConfig(
rtc_mw=5.0,
bess_mwh=120.0,
bess_mw=10.0,
dod=1.0,
rte=1.0,
initial_soc_frac=0.0,
)
summary = run_dispatch(solar, wind, cfg)
# Daytime: 5 MW surplus per hour * 12 = 60 MWh charged (BESS fills to 60 MWh with rte=1)
# Nighttime: 5 MW shortfall per hour, BESS discharges 60 MWh total → covers all 12 hours
assert summary.total_shortfall_mwh == pytest.approx(0.0, abs=1e-4)
assert summary.rtc_cuf_achieved == pytest.approx(1.0, abs=0.01)
def test_bess_soc_bounded_by_dod() -> None:
"""SOC must not exceed bess_mwh * dod at any point."""
solar = _flat(20.0)
wind = _flat(0.0)
cfg = DispatchConfig(
rtc_mw=5.0,
bess_mwh=50.0,
bess_mw=10.0,
dod=0.9,
rte=1.0,
initial_soc_frac=0.0,
)
summary = run_dispatch(solar, wind, cfg)
soc_max = 50.0 * 0.9
for h in summary.hourly:
assert h.soc_mwh <= soc_max + 1e-6
def test_bess_soc_not_below_zero() -> None:
"""SOC must not go below 0."""
solar = _flat(0.0)
wind = _flat(0.0)
cfg = DispatchConfig(
rtc_mw=10.0,
bess_mwh=50.0,
bess_mw=10.0,
dod=0.9,
rte=1.0,
initial_soc_frac=0.5,
)
summary = run_dispatch(solar, wind, cfg)
for h in summary.hourly:
assert h.soc_mwh >= -1e-6
def test_surplus_curtailed_when_bess_full() -> None:
"""When BESS is full and gen > RTC, surplus is curtailed."""
solar = _flat(100.0)
wind = _flat(0.0)
cfg = DispatchConfig(
rtc_mw=10.0,
bess_mwh=10.0,
bess_mw=10.0,
dod=1.0,
rte=1.0,
initial_soc_frac=1.0,
)
summary = run_dispatch(solar, wind, cfg)
assert summary.total_curtailed_mwh > 0
def test_mcp_revenue_with_surplus() -> None:
"""When MCP is enabled and curtailed exists, mcp revenue should be positive."""
solar = _flat(100.0)
wind = _flat(0.0)
prices = [3000.0] * 24
cfg = DispatchConfig(
rtc_mw=10.0,
bess_mwh=10.0,
bess_mw=10.0,
dod=1.0,
rte=1.0,
initial_soc_frac=1.0,
mcp_enabled=True,
)
summary = run_dispatch(solar, wind, cfg, mcp_prices_inr_per_mwh=prices)
assert summary.total_mcp_revenue_inr > 0
def test_rtc_cuf_achieved_below_1_with_shortfall() -> None:
"""If there is shortfall, RTC CUF < 1."""
solar = _flat(3.0) # 3 MW vs 5 MW RTC
cfg = DispatchConfig(rtc_mw=5.0, bess_mwh=0.0, bess_mw=0.0)
summary = run_dispatch(solar, _flat(0.0), cfg)
assert summary.rtc_cuf_achieved < 1.0
def test_8760_hour_run() -> None:
"""Full year dispatch completes and returns 8760 hourly entries."""
n = 8760
solar = [5.0] * n
wind = [3.0] * n
cfg = DispatchConfig(rtc_mw=8.0, bess_mwh=20.0, bess_mw=5.0)
summary = run_dispatch(solar, wind, cfg)
assert len(summary.hourly) == n
assert summary.total_net_injection_mwh > 0
# ---------------------------------------------------------------------------
# MCP settlement helpers
# ---------------------------------------------------------------------------
def test_mcp_price_profile_length() -> None:
prices = build_mcp_price_profile()
assert len(prices) == 8760
def test_mcp_price_peak_premium() -> None:
prices = build_mcp_price_profile(base_price_inr_per_mwh=3000.0, peak_premium=2.0)
# Hour 18 (6pm) = peak
assert prices[18] == pytest.approx(6000.0)
# Hour 10 (10am) = off-peak
assert prices[10] == pytest.approx(3000.0)
def test_mcp_annual_revenue_cr_conversion() -> None:
from remodel_engine.dispatch.hybrid_rtc import DispatchSummary
dummy = DispatchSummary(
total_net_injection_mwh=0,
total_shortfall_mwh=0,
total_curtailed_mwh=0,
total_mcp_revenue_inr=1e7,
rtc_cuf_achieved=0,
avg_soc_frac=0,
)
assert compute_mcp_annual_revenue_cr(dummy) == pytest.approx(1.0)
# ---------------------------------------------------------------------------
# Parity gate placeholder (S7-T08)
# ---------------------------------------------------------------------------
@pytest.mark.skip(reason="Parity gate: requires Excel reference for hybrid RTC scenario")
def test_parity_gate_hybrid_rtc() -> None:
"""Hybrid RTC tariff must match Excel within 0.5%. RTC CUF within 0.5%."""
pass