pluginbattery/tests/test_sim.py
Michiel Berger 9e77b35ff4 Hybrid dispatcher: greedy self-consumption + per-day grid arbitrage
Single algorithm with two passes:

Pass 1 — greedy self-consumption (real-firmware default):
    For each hour, charge any surplus, discharge into any demand.
    This nails sunny days: battery fills from morning surplus, exports
    only after capacity is reached, drains during evening peak.

Pass 2 — daily price-spread arbitrage on the residual capacity:
    For each UTC day, repeatedly find the most profitable
    cheap-charge → expensive-discharge pair (positive after round-trip
    efficiency), execute it, recompute SoC trajectory, repeat until no
    profitable cycle remains. Discharge must fit within hourly demand
    for plug-in batteries (no grid push). Sees only that day's prices,
    matching what a Tibber/Frank-style smart-charging controller does
    with day-ahead price visibility.

Effect:
- Sunny weeks (June 17-19): unchanged — greedy fill + evening discharge.
- No-sun weeks (Feb 7+): now generates arbitrage savings instead of €0.
- Mixed weeks: greedy handles surplus, arbitrage handles the rest.

Updated: test_grid_arbitrage_kicks_in_on_no_sun_days replaces the prior
"greedy doesn't arbitrage" test; now verifies the dispatcher charges
cheap hours and discharges expensive ones when no PV is available.
2026-05-01 10:36:07 +02:00

187 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Smoke tests for the battery state engine and oracle."""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from pluginbattery.sim import (
Battery,
apply_nl_tariff,
oracle_daily_schedule,
simulate,
synthesize_pv,
)
def make_df(prices: list[float], demands_kwh: list[float]) -> pd.DataFrame:
idx = pd.date_range("2024-01-01", periods=len(prices), freq="h", tz="UTC")
return pd.DataFrame(
{
"eur_per_kwh": prices,
"power_w": np.array(demands_kwh) * 1000.0,
"irradiance_w_m2": 0.0,
"demand_kwh": demands_kwh,
},
index=idx,
)
def test_simulate_clamps_charge_to_capacity():
df = make_df([0.1] * 6, [0.5] * 6)
bat = Battery(capacity_kwh=1.0, max_charge_kw=0.8, max_discharge_kw=0.8,
round_trip_eff=1.0)
schedule = np.array([[0.8, 0.0]] * 6) # ask for full charge every hour
out = simulate(df, bat, schedule)
assert out["soc_kwh"].max() <= 1.0 + 1e-9
assert out["soc_kwh"].min() >= 0.0
def test_plugin_never_exports():
df = make_df([0.5, 0.5, 0.5], [0.3, 0.3, 0.3])
bat = Battery(capacity_kwh=2.0, max_charge_kw=0.8, max_discharge_kw=0.8,
round_trip_eff=1.0, allows_export=False, initial_soc_kwh=2.0)
schedule = np.array([[0.0, 0.8]] * 3) # try to dump at full power
out = simulate(df, bat, schedule)
assert (out["discharge_kwh"] <= out["demand_kwh"] + 1e-9).all()
assert (out["grid_kwh_with_battery"] >= -1e-9).all()
def test_grid_arbitrage_kicks_in_on_no_sun_days():
"""Without PV but with a daily price spread, the dispatcher should
charge during the cheapest hours and discharge during the most
expensive — same dynamic-tariff behaviour Tibber-style controllers do."""
prices = [0.05] * 12 + [0.50] * 12
demands = [1.0] * 24
df = make_df(prices, demands)
bat = Battery(capacity_kwh=2.0, max_charge_kw=0.8, max_discharge_kw=0.8,
round_trip_eff=0.9, allows_export=False)
schedule = oracle_daily_schedule(df, bat)
out = simulate(df, bat, schedule)
# Charging happened during the cheap morning hours (0..11)
assert out["charge_kwh"].iloc[:12].sum() > 0
# Discharging happened during the expensive afternoon (12..23)
assert out["discharge_kwh"].iloc[12:].sum() > 0
# Arbitrage produced some saving
assert out["savings"].sum() > 0
def test_greedy_fills_from_surplus_then_overflows():
"""With PV surplus, greedy fills the battery as fast as power allows
until capacity is reached, then lets the rest export."""
prices = [0.20] * 24
demand = [0.1] * 24
pv = [0.0] * 6 + [3.0] * 6 + [0.0] * 12 # 6 sunny hours, 3 kWh/h surplus
df = make_df(prices, demand)
df["pv_kwh"] = pv
bat = Battery(capacity_kwh=2.0, max_charge_kw=0.8, max_discharge_kw=0.8,
round_trip_eff=1.0, allows_export=False)
schedule = oracle_daily_schedule(df, bat)
out = simulate(df, bat, schedule)
# Charging happens during the first surplus hours, capped at 0.8 kW
assert out["charge_kwh"].iloc[6:9].sum() == pytest.approx(2.0, abs=1e-6)
# Once full, no more charging even though surplus continues
assert out["charge_kwh"].iloc[9:12].sum() == 0
# Battery discharges into evening demand
assert out["discharge_kwh"].iloc[12:].sum() > 0
def test_apply_nl_tariff_matches_user_formula():
df = make_df([0.0, 0.10, -0.05], [1.0, 1.0, 1.0])
out = apply_nl_tariff(df)
expected = [0.0 * 1.21 + 0.136, 0.10 * 1.21 + 0.136, -0.05 * 1.21 + 0.136]
assert np.allclose(out["eur_per_kwh"].to_numpy(), expected)
assert np.allclose(out["epex_eur_per_kwh"].to_numpy(), [0.0, 0.10, -0.05])
def test_fixed_tax_reduces_optimal_cycle_count():
"""A flat per-kWh charge makes round-trip losses more expensive, so the
oracle should run fewer cycles when the same EPEX series is consumer-priced."""
prices = [0.05] * 12 + [0.20] * 12
df_raw = make_df(prices, [1.0] * 24)
df_consumer = apply_nl_tariff(df_raw)
bat = Battery(capacity_kwh=2.0, max_charge_kw=0.8, max_discharge_kw=0.8,
round_trip_eff=0.9, allows_export=False)
sched_raw = oracle_daily_schedule(df_raw, bat)
sched_cons = oracle_daily_schedule(df_consumer, bat)
# On consumer prices the opportunity cost of efficiency loss is higher,
# so total charge_kwh should not increase (often decreases or stays equal).
assert sched_cons[:, 0].sum() <= sched_raw[:, 0].sum() + 1e-6
def test_oracle_skips_arbitrage_when_eff_kills_it():
# Spread 0.20 → 0.21 against η_rt=0.5 means every cycle loses money.
df = make_df([0.20] * 12 + [0.21] * 12, [1.0] * 24)
bat = Battery(capacity_kwh=2.0, max_charge_kw=0.8, max_discharge_kw=0.8,
round_trip_eff=0.5, allows_export=False)
schedule = oracle_daily_schedule(df, bat)
out = simulate(df, bat, schedule)
assert out["savings"].sum() < 1e-6
def test_synthesize_pv_hits_target_annual():
"""synthesize_pv should calibrate so annual output ≈ target × kWp."""
n = 24 * 30 # 30 days
idx = pd.date_range("2024-06-01", periods=n, freq="h", tz="UTC")
# Simple square wave: 600 W/m² for 8 daylight hours, zero otherwise.
irr = np.zeros(n)
for d in range(30):
irr[d * 24 + 8 : d * 24 + 16] = 600.0
df = pd.DataFrame({
"eur_per_kwh": 0.20, "power_w": 0.0,
"irradiance_w_m2": irr, "demand_kwh": 0.0,
}, index=idx)
out = synthesize_pv(df, kwp=3.0, target_kwh_per_kwp_per_year=900.0)
annual_pv = out["pv_kwh"].sum() * (8766 / n)
assert abs(annual_pv - 3.0 * 900.0) < 1.0
def test_plugin_with_pv_does_not_push_to_grid():
"""Plug-in battery + surplus solar: discharge must be 0 in surplus hours."""
df = make_df([0.30] * 24, [0.5] * 24)
df["pv_kwh"] = [3.0] * 12 + [0.0] * 12 # huge midday surplus
bat = Battery(capacity_kwh=2.0, max_charge_kw=0.8, max_discharge_kw=0.8,
round_trip_eff=0.9, allows_export=False, initial_soc_kwh=2.0)
schedule = np.array([[0.0, 0.8]] * 24) # try to dump every hour
out = simulate(df, bat, schedule)
surplus_hours = out["pv_kwh"] > out["demand_kwh"]
assert (out.loc[surplus_hours, "discharge_kwh"] == 0).all()
def test_no_saldering_increases_battery_savings():
"""Removing saldering should make a battery on a PV system more valuable.
Reason: surplus solar that previously credited at consumer price now only
earns raw EPEX. Storing it for later self-consumption is now strictly
better than the previous opportunity cost.
"""
# Day with cheap morning EPEX, noon surplus solar, expensive evening.
prices_consumer = [0.20] * 6 + [0.15] * 6 + [0.40] * 12
prices_epex = [0.05] * 6 + [0.02] * 6 + [0.20] * 12 # before VAT/tax
demands = [0.5] * 24
pv = [0.0] * 8 + [3.0] * 6 + [0.0] * 10
n = 24
idx = pd.date_range("2024-01-01", periods=n, freq="h", tz="UTC")
base = pd.DataFrame({
"eur_per_kwh": prices_consumer,
"power_w": np.array(demands) * 1000.0,
"irradiance_w_m2": 0.0,
"demand_kwh": demands,
"pv_kwh": pv,
"epex_eur_per_kwh": prices_epex,
}, index=idx)
bat = Battery(capacity_kwh=5.0, max_charge_kw=2.5, max_discharge_kw=2.5,
round_trip_eff=0.9, allows_export=False)
# With full saldering (export = import).
df_sald = base.copy()
out_sald = simulate(df_sald, bat, oracle_daily_schedule(df_sald, bat))
savings_sald = out_sald["savings"].sum()
# Without saldering (export = raw EPEX).
df_no = base.copy()
df_no["export_eur_per_kwh"] = df_no["epex_eur_per_kwh"]
out_no = simulate(df_no, bat, oracle_daily_schedule(df_no, bat))
savings_no = out_no["savings"].sum()
assert savings_no > savings_sald