- 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>
184 lines
5.8 KiB
Python
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
|