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