"""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, ) def make_df(prices: list[float], raw_demands_kw: list[float]) -> pd.DataFrame: """Build an hourly fixture DataFrame. raw_demands_kw is signed: positive = importing, negative = exporting (existing PV pushing back through the meter). """ 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(raw_demands_kw) * 1000.0, "raw_demand_kw": raw_demands_kw, }, 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["raw_demand_kw"] + 1e-9).all() assert (out["grid_kwh_with_battery"] >= -1e-9).all() def test_grid_arbitrage_kicks_in_on_no_sun_days(): """Without surplus 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_meter_export_then_overflows(): """When the meter is already exporting (existing PV pushing back), greedy fills the battery as fast as power allows until capacity is reached, then lets the rest flow out the meter.""" prices = [0.20] * 24 # 6 hours of net-export (−3 kW each), then 12 hours of import demand. raw = [0.1] * 6 + [-3.0] * 6 + [0.1] * 12 df = make_df(prices, raw) 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 export 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 export continues assert out["charge_kwh"].iloc[9:12].sum() == 0 # Battery discharges into evening import 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_plugin_never_pushes_to_grid_during_export_hours(): """Plug-in battery during meter-export hours: discharge must be 0 (battery cannot push current backwards, and the meter is already flowing the wrong way).""" prices = [0.30] * 24 # First 12 hours: meter is exporting (-3 kWh/h). Last 12: importing (+0.5 kWh/h). raw = [-3.0] * 12 + [0.5] * 12 df = make_df(prices, 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, initial_soc_kwh=2.0) schedule = np.array([[0.0, 0.8]] * 24) # try to dump every hour out = simulate(df, bat, schedule) export_hours = out["raw_demand_kw"] < 0 assert (out.loc[export_hours, "discharge_kwh"] == 0).all() def test_no_saldering_increases_battery_savings(): """Removing saldering should make a battery on a house-with-PV more valuable. Reason: meter-export 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, midday meter-export (existing PV), 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 # Net: small import morning, big export midday (PV peak), evening import. raw = [0.5] * 8 + [-3.0] * 6 + [0.5] * 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(raw) * 1000.0, "raw_demand_kw": raw, "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