pluginbattery/tests/test_sim.py
Michiel Berger fc90e65271 refactor: drop PV synthesis, model raw P1 net signal directly
The simulator used to reconstruct "gross household demand" by adding
back a synthesized PV trace (irradiance × kWp peak-match) to the P1 net
meter, then re-subtract a different synthesized PV per scenario. That
reconstruction was leaky — Michiel's horizontal pyranometer is at a
different location and orientation than dad's SE-facing array, so the
synthesis can't reproduce dad's actual production curve. Result: 511
hours of negative "gross demand" and phantom export inflation up to
~6 kW peak in scenarios where pv_kwp ≠ 3.

New shape: simulator works on a single signed signal, raw_demand_kw
(the P1 reading as recorded). No solar synthesis. Whatever the meter
shows is the input.

Concretely:
  - sim.py: drop synthesize_pv, reconstruct_gross_demand,
    schedule_with_planning_pv, no_foresight_schedule, groundhog_schedule,
    _oracle_daily_schedule_legacy. Rename column convention demand_kwh →
    raw_demand_kw. Plug-in discharge cap becomes max(0, raw_demand_kw).
  - web.py: drop pv_kwp/pv_yield/strategy form params. Demand slider
    now applies as an *additive* baseline shift (not multiplicative —
    multiplying scaled the export bursts too, which is wrong since dad's
    PV stays the same regardless of household consumption). Default
    demand_kwh = 2325 (dad's actual full-year net per his quote;
    extrapolated 8-month window comes out to ~1515, partial coverage).
    Saturation metric now measures (surplus ≥ pc_max), not (charge ≥
    pc_max) — the latter conflated arbitrage top-off with power-bottleneck.
  - templates/index.html: drop PV input, drop strategy radios, drop
    irradiance chart. Modal charts collapsed from 4 to 3: price, net
    meter (toggles between with/without battery), SoC.
  - app.js: mirror the above, drop pv_kwp/strategy plumbing.
  - tests: rebase fixtures on raw_demand_kw, drop synthesize_pv test.
  - scripts: drop --pv-kwp/--pv-yield flags throughout, switch column
    references to raw_demand_kw.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:32:59 +02:00

175 lines
7.4 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,
)
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