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>
This commit is contained in:
parent
e2fb72a0ab
commit
fc90e65271
12 changed files with 296 additions and 476 deletions
|
|
@ -10,18 +10,21 @@ from pluginbattery.sim import (
|
|||
apply_nl_tariff,
|
||||
oracle_daily_schedule,
|
||||
simulate,
|
||||
synthesize_pv,
|
||||
)
|
||||
|
||||
|
||||
def make_df(prices: list[float], demands_kwh: list[float]) -> pd.DataFrame:
|
||||
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(demands_kwh) * 1000.0,
|
||||
"irradiance_w_m2": 0.0,
|
||||
"demand_kwh": demands_kwh,
|
||||
"power_w": np.array(raw_demands_kw) * 1000.0,
|
||||
"raw_demand_kw": raw_demands_kw,
|
||||
},
|
||||
index=idx,
|
||||
)
|
||||
|
|
@ -43,12 +46,12 @@ def test_plugin_never_exports():
|
|||
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["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 PV but with a daily price spread, the dispatcher should
|
||||
"""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
|
||||
|
|
@ -66,23 +69,23 @@ def test_grid_arbitrage_kicks_in_on_no_sun_days():
|
|||
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."""
|
||||
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
|
||||
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
|
||||
# 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 surplus hours, capped at 0.8 kW
|
||||
# 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 surplus continues
|
||||
# Once full, no more charging even though export continues
|
||||
assert out["charge_kwh"].iloc[9:12].sum() == 0
|
||||
# Battery discharges into evening demand
|
||||
# Battery discharges into evening import demand
|
||||
assert out["discharge_kwh"].iloc[12:].sum() > 0
|
||||
|
||||
|
||||
|
|
@ -119,55 +122,40 @@ def test_oracle_skips_arbitrage_when_eff_kills_it():
|
|||
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
|
||||
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)
|
||||
surplus_hours = out["pv_kwh"] > out["demand_kwh"]
|
||||
assert (out.loc[surplus_hours, "discharge_kwh"] == 0).all()
|
||||
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 PV system more valuable.
|
||||
"""Removing saldering should make a battery on a house-with-PV more valuable.
|
||||
|
||||
Reason: surplus solar that previously credited at consumer price now only
|
||||
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, noon surplus solar, expensive evening.
|
||||
# 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
|
||||
demands = [0.5] * 24
|
||||
pv = [0.0] * 8 + [3.0] * 6 + [0.0] * 10
|
||||
# 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(demands) * 1000.0,
|
||||
"irradiance_w_m2": 0.0,
|
||||
"demand_kwh": demands,
|
||||
"pv_kwh": pv,
|
||||
"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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue