Compare commits

..

No commits in common. "fc90e652715262eab03456fafce28502ec27cacf" and "9e77b35ff4e2a0ae2bbd3ecea47b859eaf2a4c3c" have entirely different histories.

13 changed files with 476 additions and 311 deletions

View file

@ -13,20 +13,5 @@
"recommended": false, "recommended": false,
"installation": "plugin", "installation": "plugin",
"image_url": "" "image_url": ""
},
{
"id": -1,
"title": "Zendure SolarFlow 2400AC 5.8 kWh",
"url": "https://www.solar-outlet.nl/zendure-solarflow-2400ac-thuisbatterijset-58-kwh.html",
"brand": "Zendure",
"brand_slug": "zendure",
"capacity": 5.8,
"power_continuous": 2400,
"price": 1759,
"price_per_kwh": 303.3,
"review_score": null,
"recommended": false,
"installation": "plugin",
"image_url": ""
} }
] ]

View file

@ -2,11 +2,10 @@
"""Run every battery in the catalog through both calculators and rank by payback. """Run every battery in the catalog through both calculators and rank by payback.
Default scenario matches the dynamic-mode store-test: Default scenario matches the dynamic-mode store-test:
4000 kWh/yr net demand, avg retail 0.25, no saldering, 3% inflation. 4000 kWh/yr demand, avg retail 0.25, no PV, no saldering, 3% inflation.
The simulator works on the raw P1 net-meter trace whatever PV is on the Override with CLI flags to test a different scenario (e.g., dad's situation
roof is already netted in. To model a different PV setup, swap the input with --pv-kwp 3.0). Output: data/processed/battery_leaderboard.csv.
data, not a CLI flag. Output: data/processed/battery_leaderboard.csv.
""" """
from __future__ import annotations from __future__ import annotations
@ -20,6 +19,7 @@ from pluginbattery.sim import (
load_hourly, load_hourly,
oracle_daily_schedule, oracle_daily_schedule,
simulate, simulate,
synthesize_pv,
) )
from pluginbattery.store_calc import ( from pluginbattery.store_calc import (
Scenario, Scenario,
@ -36,6 +36,8 @@ def main() -> None:
p.add_argument("--retail", type=float, default=0.25) p.add_argument("--retail", type=float, default=0.25)
p.add_argument("--demand", type=float, default=4000.0) p.add_argument("--demand", type=float, default=4000.0)
p.add_argument("--eta", type=float, default=0.88) p.add_argument("--eta", type=float, default=0.88)
p.add_argument("--pv-kwp", type=float, default=0.0)
p.add_argument("--pv-yield", type=float, default=875.0)
p.add_argument("--saldering", action="store_true") p.add_argument("--saldering", action="store_true")
p.add_argument("--inflation", type=float, default=0.03) p.add_argument("--inflation", type=float, default=0.03)
p.add_argument("--top", type=int, default=20, help="How many rows to print") p.add_argument("--top", type=int, default=20, help="How many rows to print")
@ -44,14 +46,15 @@ def main() -> None:
# ─── Load and prep the simulation data once ──────────────────────── # ─── Load and prep the simulation data once ────────────────────────
base = load_hourly("data/raw"); base = apply_nl_tariff(base) base = load_hourly("data/raw"); base = apply_nl_tariff(base)
df = base.copy() df = base.copy()
annual_net_demand = base["raw_demand_kw"].sum() * (8766.0 / len(base)) annual_demand = base["demand_kwh"].sum() * (8766.0 / len(base))
df["raw_demand_kw"] = base["raw_demand_kw"] * (args.demand / annual_net_demand) df["demand_kwh"] = base["demand_kwh"] * (args.demand / annual_demand)
scale = args.retail / base["eur_per_kwh"].mean() scale = args.retail / base["eur_per_kwh"].mean()
df["eur_per_kwh"] = base["eur_per_kwh"] * scale df["eur_per_kwh"] = base["eur_per_kwh"] * scale
df["epex_eur_per_kwh"] = base["epex_eur_per_kwh"] * scale df["epex_eur_per_kwh"] = base["epex_eur_per_kwh"] * scale
if args.pv_kwp > 0:
df = synthesize_pv(df, kwp=args.pv_kwp,
target_kwh_per_kwp_per_year=args.pv_yield)
df["export_eur_per_kwh"] = df["eur_per_kwh"] if args.saldering else 0.0 df["export_eur_per_kwh"] = df["eur_per_kwh"] if args.saldering else 0.0
# Existing-PV export visible at the meter (any hour where raw_demand_kw < 0).
has_existing_pv = bool((df["raw_demand_kw"] < 0).any())
avg_epex = float(df["epex_eur_per_kwh"].mean()) avg_epex = float(df["epex_eur_per_kwh"].mean())
store_params = StoreParams(avg_epex_eur_per_kwh=avg_epex) store_params = StoreParams(avg_epex_eur_per_kwh=avg_epex)
@ -77,9 +80,8 @@ def main() -> None:
# Cache LP results by (capacity, power) — many batteries share identical specs. # Cache LP results by (capacity, power) — many batteries share identical specs.
lp_cache: dict[tuple[float, float], float] = {} lp_cache: dict[tuple[float, float], float] = {}
print(f"Scenario: net demand {args.demand:.0f} kWh/yr, avg retail €{args.retail}, " print(f"Scenario: demand {args.demand:.0f} kWh/yr, avg retail €{args.retail}, "
f"existing-PV exports={'yes' if has_existing_pv else 'no'}, " f"PV {args.pv_kwp} kWp, saldering={args.saldering}, inflation {args.inflation*100:.1f}%/yr")
f"saldering={args.saldering}, inflation {args.inflation*100:.1f}%/yr")
print(f"Running {len(rows_in)} batteries (caching by (capacity, power))...") print(f"Running {len(rows_in)} batteries (caching by (capacity, power))...")
print() print()
@ -102,7 +104,7 @@ def main() -> None:
scn = Scenario( scn = Scenario(
capacity_kwh=r["capacity_kwh"], max_charge_kw=r["power_kw"], capacity_kwh=r["capacity_kwh"], max_charge_kw=r["power_kw"],
battery_cost_eur=r["price_eur"], avg_retail_eur_per_kwh=args.retail, battery_cost_eur=r["price_eur"], avg_retail_eur_per_kwh=args.retail,
has_pv=has_existing_pv, has_saldering=args.saldering, dynamic_rate=True, has_pv=args.pv_kwp > 0, has_saldering=args.saldering, dynamic_rate=True,
) )
sq = store_quote(scn, store_params, inflation=args.inflation) sq = store_quote(scn, store_params, inflation=args.inflation)

View file

@ -48,9 +48,9 @@ def main() -> None:
base = load_hourly("data/raw"); base = apply_nl_tariff(base) base = load_hourly("data/raw"); base = apply_nl_tariff(base)
# Scale to the test scenario. # Scale to the test scenario.
annual_demand = base["raw_demand_kw"].sum() * (8766.0 / len(base)) annual_demand = base["demand_kwh"].sum() * (8766.0 / len(base))
df = base.copy() df = base.copy()
df["raw_demand_kw"] = base["raw_demand_kw"] * (args.demand / annual_demand) df["demand_kwh"] = base["demand_kwh"] * (args.demand / annual_demand)
scale = args.retail / base["eur_per_kwh"].mean() scale = args.retail / base["eur_per_kwh"].mean()
df["eur_per_kwh"] = base["eur_per_kwh"] * scale df["eur_per_kwh"] = base["eur_per_kwh"] * scale
df["epex_eur_per_kwh"] = base["epex_eur_per_kwh"] * scale df["epex_eur_per_kwh"] = base["epex_eur_per_kwh"] * scale

View file

@ -1,10 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Run the price-arbitrage oracle for several battery configs and print a comparison. """Run the 24h-foresight oracle for several battery configs and print a comparison.
Saves per-hour CSVs for each config plus a summary table. Tariff: NL consumer. Saves per-hour CSVs for each config plus a summary table. Tariff: NL consumer.
The simulator works on the raw P1 net-meter trace whatever PV is on the With --pv-kwp > 0, synthesizes PV output from horizontal irradiance and treats
roof is already netted in. Existing-PV exports show up as negative saldering as full export credit at consumer price (NL pre-2027).
``raw_demand_kw`` and are absorbed by the battery before crossing the meter.
""" """
from __future__ import annotations from __future__ import annotations
@ -19,6 +18,7 @@ from pluginbattery.sim import (
load_hourly, load_hourly,
oracle_daily_schedule, oracle_daily_schedule,
simulate, simulate,
synthesize_pv,
) )
CONFIGS = [ CONFIGS = [
@ -79,6 +79,14 @@ def payback_years(year1_savings: float, cost: float, inflation: float = 0.0) ->
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--pv-kwp", type=float, default=0.0,
help="PV system size in kWp. 0 = no solar (default).",
)
parser.add_argument(
"--pv-target", type=float, default=900.0,
help="Calibrate PV to this annual yield in kWh/kWp/year. Default 900 = NL norm.",
)
parser.add_argument( parser.add_argument(
"--saldering", choices=["none", "full"], default="none", "--saldering", choices=["none", "full"], default="none",
help="'full' = export at consumer price (NL pre-2027). " help="'full' = export at consumer price (NL pre-2027). "
@ -113,12 +121,11 @@ def main() -> None:
elif args.price_mult != 1.0: elif args.price_mult != 1.0:
df["eur_per_kwh"] = df["eur_per_kwh"] * args.price_mult df["eur_per_kwh"] = df["eur_per_kwh"] * args.price_mult
# Existing PV (if any) is already in raw_demand_kw via the meter signal. pv_note = "no solar"
annual_export = float(-df["raw_demand_kw"].clip(upper=0).sum() * 8766.0 / len(df)) if args.pv_kwp > 0:
if annual_export > 1.0: df = synthesize_pv(df, kwp=args.pv_kwp, target_kwh_per_kwp_per_year=args.pv_target)
pv_note = f"{annual_export:.0f} kWh/yr exported at meter (existing PV)" pv_kwh_year = float(df["pv_kwh"].sum() * 8766.0 / len(df))
else: pv_note = f"{args.pv_kwp:.1f} kWp PV, calibrated to {pv_kwh_year:.0f} kWh/yr"
pv_note = "no measurable existing PV in the data"
if args.export_rate is not None: if args.export_rate is not None:
df["export_eur_per_kwh"] = float(args.export_rate) df["export_eur_per_kwh"] = float(args.export_rate)
@ -139,10 +146,10 @@ def main() -> None:
print(f"Tariff: import = {price_note}; {sald_note}") print(f"Tariff: import = {price_note}; {sald_note}")
if args.price_inflation > 0: if args.price_inflation > 0:
print(f"Payback uses {args.price_inflation*100:.1f}%/yr energy-price inflation") print(f"Payback uses {args.price_inflation*100:.1f}%/yr energy-price inflation")
print(f"Meter : {pv_note}\n") print(f"Solar : {pv_note}\n")
# Headline figures with no battery, just for context. # Headline figures with no battery, just for context.
g_no = df["raw_demand_kw"] g_no = df["demand_kwh"] - df.get("pv_kwh", 0)
imp_p = df["eur_per_kwh"] imp_p = df["eur_per_kwh"]
exp_p = df.get("export_eur_per_kwh", df["eur_per_kwh"]) exp_p = df.get("export_eur_per_kwh", df["eur_per_kwh"])
cost_no_battery_total = float((g_no.where(g_no > 0, 0) * imp_p + cost_no_battery_total = float((g_no.where(g_no > 0, 0) * imp_p +
@ -180,6 +187,7 @@ def main() -> None:
"max_charge_kw": battery.max_charge_kw, "max_charge_kw": battery.max_charge_kw,
"max_discharge_kw": battery.max_discharge_kw, "max_discharge_kw": battery.max_discharge_kw,
"price_eur": price, "price_eur": price,
"pv_kwp": args.pv_kwp,
"saldering": args.saldering, "saldering": args.saldering,
"annual_savings_eur": round(savings, 2), "annual_savings_eur": round(savings, 2),
"kwh_shifted": round(kwh_shifted, 1), "kwh_shifted": round(kwh_shifted, 1),

View file

@ -10,8 +10,8 @@ Examples:
python scripts/compare_with_store.py --capacity 5.12 --power 0.8 --cost 1339 --retail 0.25 python scripts/compare_with_store.py --capacity 5.12 --power 0.8 --cost 1339 --retail 0.25
python scripts/compare_with_store.py --capacity 5.12 --power 2.5 --cost 1339 --retail 0.25 python scripts/compare_with_store.py --capacity 5.12 --power 2.5 --cost 1339 --retail 0.25
# Fixed retail, no saldering: # PV scenario, fixed retail, no saldering:
python scripts/compare_with_store.py --fixed --retail 0.28 python scripts/compare_with_store.py --pv-kwp 3.0 --fixed --retail 0.28
""" """
from __future__ import annotations from __future__ import annotations
@ -23,6 +23,7 @@ from pluginbattery.sim import (
load_hourly, load_hourly,
oracle_daily_schedule, oracle_daily_schedule,
simulate, simulate,
synthesize_pv,
) )
from pluginbattery.store_calc import ( from pluginbattery.store_calc import (
Scenario, Scenario,
@ -46,6 +47,10 @@ def main() -> None:
help="Fixed-rate mode (no time-of-day variation; kills arbitrage)") help="Fixed-rate mode (no time-of-day variation; kills arbitrage)")
p.add_argument("--saldering", action="store_true", p.add_argument("--saldering", action="store_true",
help="Full saldering on (export = retail). Default: no saldering.") help="Full saldering on (export = retail). Default: no saldering.")
# Solar
p.add_argument("--pv-kwp", type=float, default=0.0, help="PV size in kWp; 0 = no PV")
p.add_argument("--pv-yield", type=float, default=875.0,
help="Annual PV yield in kWh/kWp (store uses 875 in their footer)")
# Inflation # Inflation
p.add_argument("--inflation", type=float, default=0.03) p.add_argument("--inflation", type=float, default=0.03)
args = p.parse_args() args = p.parse_args()
@ -54,10 +59,9 @@ def main() -> None:
base = load_hourly("data/raw"); base = apply_nl_tariff(base) base = load_hourly("data/raw"); base = apply_nl_tariff(base)
df = base.copy() df = base.copy()
# Demand scaling — scale the entire signed signal so existing-PV exports # Demand scaling.
# scale with the household. annual_demand = base["demand_kwh"].sum() * (8766.0 / len(base))
annual_net_demand = base["raw_demand_kw"].sum() * (8766.0 / len(base)) df["demand_kwh"] = base["demand_kwh"] * (args.demand / annual_demand)
df["raw_demand_kw"] = base["raw_demand_kw"] * (args.demand / annual_net_demand)
# Retail price. # Retail price.
if args.fixed: if args.fixed:
@ -69,14 +73,16 @@ def main() -> None:
df["epex_eur_per_kwh"] = base["epex_eur_per_kwh"] * scale df["epex_eur_per_kwh"] = base["epex_eur_per_kwh"] * scale
epex_in_use = df["epex_eur_per_kwh"] epex_in_use = df["epex_eur_per_kwh"]
# PV.
if args.pv_kwp > 0:
df = synthesize_pv(df, kwp=args.pv_kwp, target_kwh_per_kwp_per_year=args.pv_yield)
# Saldering. # Saldering.
if args.saldering: if args.saldering:
df["export_eur_per_kwh"] = df["eur_per_kwh"] df["export_eur_per_kwh"] = df["eur_per_kwh"]
else: else:
df["export_eur_per_kwh"] = 0.0 # post-saldering default in this script df["export_eur_per_kwh"] = 0.0 # post-saldering default in this script
has_existing_pv = bool((df["raw_demand_kw"] < 0).any())
bat = Battery( bat = Battery(
capacity_kwh=args.capacity, capacity_kwh=args.capacity,
max_charge_kw=args.power, max_charge_kw=args.power,
@ -96,7 +102,7 @@ def main() -> None:
max_charge_kw=args.power, max_charge_kw=args.power,
battery_cost_eur=args.cost, battery_cost_eur=args.cost,
avg_retail_eur_per_kwh=args.retail, avg_retail_eur_per_kwh=args.retail,
has_pv=has_existing_pv, has_pv=args.pv_kwp > 0,
has_saldering=args.saldering, has_saldering=args.saldering,
dynamic_rate=not args.fixed, dynamic_rate=not args.fixed,
) )
@ -108,8 +114,9 @@ def main() -> None:
print(f" Tariff : avg retail €{args.retail}, " print(f" Tariff : avg retail €{args.retail}, "
f"{'FIXED' if args.fixed else 'DYNAMIC'} rate, " f"{'FIXED' if args.fixed else 'DYNAMIC'} rate, "
f"{'WITH' if args.saldering else 'NO'} saldering") f"{'WITH' if args.saldering else 'NO'} saldering")
print(f" PV : {'existing PV in meter signal' if has_existing_pv else 'no measurable existing PV'}") print(f" PV : {args.pv_kwp} kWp"
print(f" Net demand : {args.demand:.0f} kWh/yr") f"{' @ ' + str(args.pv_yield) + ' kWh/kWp/yr' if args.pv_kwp else ''}")
print(f" Demand : {args.demand:.0f} kWh/yr")
print(f" Inflation : {args.inflation*100:.1f}%/yr") print(f" Inflation : {args.inflation*100:.1f}%/yr")
print() print()
print(f"{'':22s} {'year-1 €':>10s} {'payback':>9s}") print(f"{'':22s} {'year-1 €':>10s} {'payback':>9s}")

View file

@ -6,9 +6,9 @@ meter readings, every 15 min, 2025 calendar year, Europe/Amsterdam local time).
Output: data/raw/p1_hourly.csv (timestamp UTC, power_w mean over hour). Net Output: data/raw/p1_hourly.csv (timestamp UTC, power_w mean over hour). Net
of PV exports `power_w` can be negative when his solar exceeded household of PV exports `power_w` can be negative when his solar exceeded household
demand. The simulator interprets `raw_demand_kw = power_w / 1000`, and a demand. The simulator interprets `demand_kwh = power_w / 1000`, and a plug-in
plug-in battery's discharge cap `max(0, raw_demand_kw)` correctly forbids battery's discharge cap `max(0, demand - pv)` correctly handles negative
discharge during net-export hours (the meter is already flowing the wrong way). demand by forbidding discharge during surplus hours.
The simulator's window is 2023-09-01 → 2024-09-01 UTC. Dad's data is the The simulator's window is 2023-09-01 → 2024-09-01 UTC. Dad's data is the
2025 calendar year. We align by (month, day, hour) so seasonal patterns 2025 calendar year. We align by (month, day, hour) so seasonal patterns

View file

@ -46,8 +46,8 @@ def main() -> None:
# ─── Build the same df the web app sees ─── # ─── Build the same df the web app sees ───
base = apply_nl_tariff(load_hourly("data/raw")) base = apply_nl_tariff(load_hourly("data/raw"))
df = base.copy() df = base.copy()
annual = base["raw_demand_kw"].sum() * 8766 / len(base) annual = base["demand_kwh"].sum() * 8766 / len(base)
df["raw_demand_kw"] = base["raw_demand_kw"] * (args.demand_kwh / annual) df["demand_kwh"] = base["demand_kwh"] * (args.demand_kwh / annual)
scale = args.retail / base["eur_per_kwh"].mean() scale = args.retail / base["eur_per_kwh"].mean()
df["eur_per_kwh"] = base["eur_per_kwh"] * scale df["eur_per_kwh"] = base["eur_per_kwh"] * scale
df["epex_eur_per_kwh"] = base["epex_eur_per_kwh"] * scale df["epex_eur_per_kwh"] = base["epex_eur_per_kwh"] * scale
@ -62,10 +62,10 @@ def main() -> None:
out = out.assign( out = out.assign(
import_kwh=np.maximum(0.0, g), import_kwh=np.maximum(0.0, g),
export_kwh=np.maximum(0.0, -g), export_kwh=np.maximum(0.0, -g),
no_bat_grid=out["raw_demand_kw"], # equals the original net since pv=0 no_bat_grid=out["demand_kwh"], # equals the original net since pv=0
cost_no_battery_eur=np.where(out["raw_demand_kw"] > 0, cost_no_battery_eur=np.where(out["demand_kwh"] > 0,
out["raw_demand_kw"] * out["eur_per_kwh"], out["demand_kwh"] * out["eur_per_kwh"],
out["raw_demand_kw"] * out["export_eur_per_kwh"]), out["demand_kwh"] * out["export_eur_per_kwh"]),
cost_with_battery_eur=np.where(g > 0, g * out["eur_per_kwh"], cost_with_battery_eur=np.where(g > 0, g * out["eur_per_kwh"],
g * out["export_eur_per_kwh"]), g * out["export_eur_per_kwh"]),
) )
@ -89,7 +89,7 @@ def main() -> None:
print(f" {'hr':>2s} {'price':>5s} {'no-bat meter':>15s} " print(f" {'hr':>2s} {'price':>5s} {'no-bat meter':>15s} "
f"{'charge':>7s} {'disch':>7s} {'SoC':>5s} {'with-bat meter':>16s} {'sav':>6s}") f"{'charge':>7s} {'disch':>7s} {'SoC':>5s} {'with-bat meter':>16s} {'sav':>6s}")
for ts, r in day.iterrows(): for ts, r in day.iterrows():
net_no = r["raw_demand_kw"] net_no = r["demand_kwh"]
mark = "↓ exp" if net_no < -0.01 else "↑ imp" if net_no > 0.01 else "·" mark = "↓ exp" if net_no < -0.01 else "↑ imp" if net_no > 0.01 else "·"
grid = r["grid_kwh_with_battery"] grid = r["grid_kwh_with_battery"]
grid_mark = "↓ exp" if grid < -0.01 else "↑ imp" if grid > 0.01 else "·" grid_mark = "↓ exp" if grid < -0.01 else "↑ imp" if grid > 0.01 else "·"
@ -97,8 +97,8 @@ def main() -> None:
f"{net_no:+7.2f} {mark:5s} " f"{net_no:+7.2f} {mark:5s} "
f"{r['charge_kwh']:>6.2f} {r['discharge_kwh']:>6.2f} " f"{r['charge_kwh']:>6.2f} {r['discharge_kwh']:>6.2f} "
f"{r['soc_kwh']:>4.2f} {grid:+8.2f} {grid_mark:5s}{r['savings_eur']:+5.2f}") f"{r['soc_kwh']:>4.2f} {grid:+8.2f} {grid_mark:5s}{r['savings_eur']:+5.2f}")
d_imp_no = max(0.0, day['raw_demand_kw'].clip(lower=0).sum()) d_imp_no = max(0.0, day['demand_kwh'].clip(lower=0).sum())
d_exp_no = -day['raw_demand_kw'].clip(upper=0).sum() d_exp_no = -day['demand_kwh'].clip(upper=0).sum()
d_imp_yes = day['import_kwh'].sum() d_imp_yes = day['import_kwh'].sum()
d_exp_yes = day['export_kwh'].sum() d_exp_yes = day['export_kwh'].sum()
d_sav = day['savings_eur'].sum() d_sav = day['savings_eur'].sum()
@ -113,8 +113,8 @@ def main() -> None:
week_savings = win["savings_eur"].sum() week_savings = win["savings_eur"].sum()
week_charge = win["charge_kwh"].sum() week_charge = win["charge_kwh"].sum()
week_disch = win["discharge_kwh"].sum() week_disch = win["discharge_kwh"].sum()
week_surplus = (-win["raw_demand_kw"].clip(upper=0)).sum() week_surplus = (-win["demand_kwh"].clip(upper=0)).sum()
week_imports = win["raw_demand_kw"].clip(lower=0).sum() week_imports = win["demand_kwh"].clip(lower=0).sum()
annual_savings = out["savings_eur"].sum() annual_savings = out["savings_eur"].sum()
annualised = week_savings * 365.25 / args.days annualised = week_savings * 365.25 / args.days

View file

@ -1,22 +1,13 @@
"""Hourly home-battery simulator. """Hourly home-battery simulator.
The simulator operates on a single signed signal: ``raw_demand_kw``, the P1
net-meter reading. Positive = importing, negative = exporting (e.g. from
existing rooftop PV already netted at the meter). We do **not** model solar
as a separate signal whatever the meter shows is the input. To ask "what
if the PV were different" you need actual measurements of that situation;
synthesizing PV from a remote irradiance sensor was tried and didn't survive
contact with reality (different roof, different orientation, different cloud
cover).
Three pieces: Three pieces:
- load_hourly(): CSVs in -> single hourly DataFrame, missing-hour rows dropped. - load_hourly(): CSVs in -> single hourly DataFrame, missing-hour rows dropped.
- simulate(df, bat, sched): walks SoC hour by hour, clamps to physical limits, - simulate(df, bat, sched): walks SoC hour by hour, clamps to physical limits,
returns trajectory + grid flows + costs. returns trajectory + grid flows + costs.
- oracle_daily_schedule(): greedy self-consumption (Pass 1) + perfect-foresight - oracle_daily_schedule(): solves a 24h LP per UTC day for perfect-foresight
per-day price arbitrage (Pass 2). within-day arbitrage. No cross-day shifting.
Anything that decides what to do returns a (n, 2) schedule of Anything that decides what to do (oracle, rules, ML) returns a (n, 2) schedule of
[charge_kwh, discharge_kwh]; simulate() is the single source of truth for state. [charge_kwh, discharge_kwh]; simulate() is the single source of truth for state.
""" """
from __future__ import annotations from __future__ import annotations
@ -26,6 +17,7 @@ from pathlib import Path
import numpy as np import numpy as np
import pandas as pd import pandas as pd
from scipy.optimize import linprog
@dataclass(frozen=True) @dataclass(frozen=True)
@ -48,6 +40,39 @@ ECOFLOW_STREAM_AC = Battery(
) )
def synthesize_pv(
df: pd.DataFrame,
kwp: float,
target_kwh_per_kwp_per_year: float = 900.0,
irradiance_cap_w_m2: float = 1000.0,
) -> pd.DataFrame:
"""Synthesize hourly PV output (kWh) from horizontal irradiance.
Output shape follows the irradiance signal (so timing tracks real local
weather). The total is calibrated to `target_kwh_per_kwp_per_year`
900 kWh/kWp/year is the NL norm for a south-facing 35° roof. This
sidesteps the question "what's the right system factor?" and absorbs any
sensor bias automatically: if the irradiance reads 22% low, the implied
factor goes up to compensate.
Outlier handling: irradiance clipped to [0, irradiance_cap_w_m2].
"""
out = df.copy()
irr = out["irradiance_w_m2"].clip(lower=0.0, upper=irradiance_cap_w_m2)
# Annualised horizontal irradiance density for *this* dataset, kWh/m²/year.
hours = len(df)
annual_irradiance_kwh_m2 = (irr.sum() / 1000.0) * (8766.0 / hours)
if annual_irradiance_kwh_m2 <= 0:
raise ValueError("Irradiance data is all zero or negative.")
system_factor = target_kwh_per_kwp_per_year / annual_irradiance_kwh_m2
out["pv_kwh"] = irr / 1000.0 * kwp * system_factor
out.attrs["pv_system_factor"] = system_factor
out.attrs["pv_annual_kwh"] = float(out["pv_kwh"].sum() * 8766.0 / hours)
return out
def apply_nl_tariff( def apply_nl_tariff(
df: pd.DataFrame, df: pd.DataFrame,
vat: float = 0.21, vat: float = 0.21,
@ -70,16 +95,17 @@ def apply_nl_tariff(
def load_hourly(data_dir: Path | str = "data/raw") -> pd.DataFrame: def load_hourly(data_dir: Path | str = "data/raw") -> pd.DataFrame:
"""Read prices + P1 CSVs into one hourly DataFrame indexed by UTC timestamp. """Read prices/P1/solar CSVs into one hourly DataFrame indexed by UTC timestamp.
Drops every hour where either signal is missing (inner join + dropna). Drops every hour where any of the three signals is missing (inner join +
Adds ``raw_demand_kw = power_w / 1000`` the signed P1 net meter dropna). Adds a derived `demand_kwh` column = power_w / 1000 (since power_w
reading. Negative = exporting, positive = importing. is the hour mean and 1 W * 1 h = 1 Wh).
""" """
data_dir = Path(data_dir) data_dir = Path(data_dir)
files = { files = {
"eur_per_kwh": "prices_hourly.csv", "eur_per_kwh": "prices_hourly.csv",
"power_w": "p1_hourly.csv", "power_w": "p1_hourly.csv",
"irradiance_w_m2": "solar_hourly.csv",
} }
parts = [] parts = []
for col, fname in files.items(): for col, fname in files.items():
@ -91,7 +117,7 @@ def load_hourly(data_dir: Path | str = "data/raw") -> pd.DataFrame:
sub.index = sub.index.tz_convert("UTC") sub.index = sub.index.tz_convert("UTC")
parts.append(sub[[col]]) parts.append(sub[[col]])
df = pd.concat(parts, axis=1, join="inner").dropna().sort_index() df = pd.concat(parts, axis=1, join="inner").dropna().sort_index()
df["raw_demand_kw"] = df["power_w"] / 1000.0 df["demand_kwh"] = df["power_w"] / 1000.0
return df return df
@ -103,13 +129,13 @@ def simulate(df: pd.DataFrame, battery: Battery, schedule: np.ndarray) -> pd.Dat
authoritative state engine, the schedule is only a *request*. authoritative state engine, the schedule is only a *request*.
Column conventions read by this function: Column conventions read by this function:
- raw_demand_kw: signed meter reading (always required). +import, export. - eur_per_kwh: import price (always required)
- eur_per_kwh: import price (always required). - pv_kwh: optional, hourly PV production
- export_eur_per_kwh: optional, export credit price. Defaults to - export_eur_per_kwh: optional, export credit price. Defaults to
eur_per_kwh (full saldering) if missing. eur_per_kwh (full saldering) if missing.
Per hour: ``g = raw_demand_kw + charge discharge``. Cost uses the Per hour: `g = demand pv + charge discharge`. Cost uses the import
import price when g > 0, the export price when g < 0. price when g > 0, the export price when g < 0.
""" """
n = len(df) n = len(df)
if schedule.shape != (n, 2): if schedule.shape != (n, 2):
@ -125,7 +151,8 @@ def simulate(df: pd.DataFrame, battery: Battery, schedule: np.ndarray) -> pd.Dat
d_arr = np.empty(n) d_arr = np.empty(n)
soc_arr = np.empty(n) soc_arr = np.empty(n)
raw = df["raw_demand_kw"].to_numpy() demand = df["demand_kwh"].to_numpy()
pv = df["pv_kwh"].to_numpy() if "pv_kwh" in df.columns else np.zeros(n)
requested_c = schedule[:, 0] requested_c = schedule[:, 0]
requested_d = schedule[:, 1] requested_d = schedule[:, 1]
@ -139,8 +166,8 @@ def simulate(df: pd.DataFrame, battery: Battery, schedule: np.ndarray) -> pd.Dat
d = max(0.0, min(requested_d[t], pd_max, avail_kwh_grid)) d = max(0.0, min(requested_d[t], pd_max, avail_kwh_grid))
if not battery.allows_export: if not battery.allows_export:
# Plug-in: cannot push current backwards through the meter. # Plug-in: cannot push current backwards through the meter.
# Discharge can't take the net flow below zero. # Discharge can't exceed the *net* demand after solar.
d = min(d, max(0.0, raw[t])) d = min(d, max(0.0, demand[t] - pv[t]))
soc = soc + c * eta - d / eta soc = soc + c * eta - d / eta
# Numerical guard rail for floating point drift. # Numerical guard rail for floating point drift.
@ -151,6 +178,8 @@ def simulate(df: pd.DataFrame, battery: Battery, schedule: np.ndarray) -> pd.Dat
soc_arr[t] = soc soc_arr[t] = soc
out = df.copy() out = df.copy()
if "pv_kwh" not in out.columns:
out["pv_kwh"] = 0.0
out["charge_kwh"] = c_arr out["charge_kwh"] = c_arr
out["discharge_kwh"] = d_arr out["discharge_kwh"] = d_arr
out["soc_kwh"] = soc_arr out["soc_kwh"] = soc_arr
@ -161,7 +190,7 @@ def simulate(df: pd.DataFrame, battery: Battery, schedule: np.ndarray) -> pd.Dat
else: else:
export_price = import_price # default = full saldering export_price = import_price # default = full saldering
g_no = raw g_no = (out["demand_kwh"] - out["pv_kwh"]).to_numpy()
g_yes = g_no + c_arr - d_arr g_yes = g_no + c_arr - d_arr
out["grid_kwh_no_battery"] = g_no out["grid_kwh_no_battery"] = g_no
@ -172,12 +201,32 @@ def simulate(df: pd.DataFrame, battery: Battery, schedule: np.ndarray) -> pd.Dat
return out return out
def _pass1_reactive(df: pd.DataFrame, battery: Battery) -> tuple[np.ndarray, np.ndarray]: def oracle_daily_schedule(df: pd.DataFrame, battery: Battery) -> np.ndarray:
"""Greedy self-consumption: charge from surplus, discharge to cover demand. """Two-pass dispatch: greedy self-consumption + daily grid arbitrage.
Pure function of raw_demand_kw. No foresight exactly the "default This matches what a modern plug-in battery with smart-charging firmware
mode" behaviour of every plug-in battery firmware out there. (Tibber controllers, Marstek 'Smart Charging' mode, EcoFlow with dynamic
Returns (schedule, soc) where soc has length n+1 (soc[0] = initial). tariff integration) actually does in practice and it always produces
visually intuitive schedules without LP indifference artefacts.
Pass 1 (greedy self-consumption):
For each hour in order, charge from any meter export, discharge to
cover any meter import. Exactly the "default mode" behaviour.
Pass 2 (per-UTC-day arbitrage on residual capacity):
For each day, repeatedly find the most profitable
cheap-hour expensive-hour pair, considering:
- Both charge and discharge already have residual room (power
and SoC headroom).
- Discharge fits within demand at the expensive hour (plug-in
cannot push to grid; allows_export=True relaxes this).
- Profit per shifted kWh = η · price[discharge] price[charge] / η
must be positive.
Execute the most profitable, update SoC trajectory, repeat. Stops
when no profitable cycle remains for that day.
Pass 1 is order-of-day dependent (real firmware acts moment-by-moment),
so it always runs first. Pass 2 sees what's left.
""" """
cap = battery.capacity_kwh cap = battery.capacity_kwh
pc_max = battery.max_charge_kw pc_max = battery.max_charge_kw
@ -185,50 +234,38 @@ def _pass1_reactive(df: pd.DataFrame, battery: Battery) -> tuple[np.ndarray, np.
eta = battery.round_trip_eff ** 0.5 eta = battery.round_trip_eff ** 0.5
n = len(df) n = len(df)
raw = df["raw_demand_kw"].to_numpy() demand = df["demand_kwh"].to_numpy()
pv = df["pv_kwh"].to_numpy() if "pv_kwh" in df.columns else np.zeros(n)
net = demand - pv
prices = df["eur_per_kwh"].to_numpy()
schedule = np.zeros((n, 2)) schedule = np.zeros((n, 2))
soc = np.zeros(n + 1) soc = np.zeros(n + 1)
soc[0] = battery.initial_soc_kwh soc[0] = battery.initial_soc_kwh
# ── Pass 1: greedy self-consumption ─────────────────────────────────
for t in range(n): for t in range(n):
if raw[t] < -1e-9: # exporting (surplus from existing PV) if net[t] < -1e-9: # exporting
surplus = -raw[t] surplus = -net[t]
room = (cap - soc[t]) / eta if eta > 0 else 0.0 room = (cap - soc[t]) / eta if eta > 0 else 0.0
c = max(0.0, min(pc_max, surplus, room)) c = max(0.0, min(pc_max, surplus, room))
schedule[t, 0] = c schedule[t, 0] = c
soc[t + 1] = min(cap, soc[t] + c * eta) soc[t + 1] = min(cap, soc[t] + c * eta)
elif raw[t] > 1e-9: # importing elif net[t] > 1e-9: # importing
avail = soc[t] * eta avail = soc[t] * eta
cap_d = pd_max if battery.allows_export else raw[t] cap_d = pd_max if battery.allows_export else net[t]
d = max(0.0, min(pd_max, avail, cap_d)) d = max(0.0, min(pd_max, avail, cap_d))
schedule[t, 1] = d schedule[t, 1] = d
soc[t + 1] = max(0.0, soc[t] - d / eta) soc[t + 1] = max(0.0, soc[t] - d / eta)
else: else:
soc[t + 1] = soc[t] soc[t + 1] = soc[t]
return schedule, soc
# ── Pass 2: per-day arbitrage on residual capacity ──────────────────
def _pass2_arbitrage(df: pd.DataFrame, battery: Battery,
schedule: np.ndarray, soc: np.ndarray) -> None:
"""Per-UTC-day arbitrage layered on top of (schedule, soc). Mutates in place.
For each day, repeatedly find the most profitable (ci, di) pair where
profit = η·price[di] price[ci]/η > 0 and both endpoints have power +
SoC headroom. Execute, update soc trajectory, repeat. Stops when no
profitable cycle remains.
"""
cap = battery.capacity_kwh
pc_max = battery.max_charge_kw
pd_max = battery.max_discharge_kw
eta = battery.round_trip_eff ** 0.5
if eta <= 0: if eta <= 0:
return return schedule
inv_eta = 1.0 / eta inv_eta = 1.0 / eta
raw = df["raw_demand_kw"].to_numpy() # Index hours by UTC date (timeline is short enough that this is cheap).
prices = df["eur_per_kwh"].to_numpy()
by_date: dict = {} by_date: dict = {}
for i, ts in enumerate(df.index): for i, ts in enumerate(df.index):
by_date.setdefault(ts.date(), []).append(i) by_date.setdefault(ts.date(), []).append(i)
@ -242,6 +279,8 @@ def _pass2_arbitrage(df: pd.DataFrame, battery: Battery,
room_c = pc_max - schedule[ci, 0] room_c = pc_max - schedule[ci, 0]
if room_c < 1e-9: if room_c < 1e-9:
continue continue
# SoC headroom across (ci, end-of-day]: how much can we add
# to soc[ci+1..last+1] without exceeding cap?
tail = soc[ci + 1 : last + 2] tail = soc[ci + 1 : last + 2]
hr = (cap - tail.max()) / eta if tail.size else 0.0 hr = (cap - tail.max()) / eta if tail.size else 0.0
if hr < 1e-9: if hr < 1e-9:
@ -252,16 +291,25 @@ def _pass2_arbitrage(df: pd.DataFrame, battery: Battery,
if room_d < 1e-9: if room_d < 1e-9:
continue continue
if not battery.allows_export: if not battery.allows_export:
# Plug-in: extra discharge limited by the post-Pass-1 import demand. # Discharge cannot exceed demand at di.
room_d = min(room_d, raw[di] - schedule[di, 1]) room_d = min(room_d, net[di] - schedule[di, 1])
if room_d < 1e-9: if room_d < 1e-9:
continue continue
# SoC must remain ≥ 0 across (ci, di]; new schedule has
# +c at ci shifting trajectory up, then -d at di pulling
# it down. Trajectory between ci+1..di stays elevated by
# +x·η; minimum allowed kWh limited by min SoC in that
# range plus future-discharge availability.
profit = eta * prices[di] - prices[ci] * inv_eta profit = eta * prices[di] - prices[ci] * inv_eta
if profit <= 1e-6: if profit <= 1e-6:
continue continue
# Max charge in AC kWh: limited by power, capacity headroom,
# discharge-side capacity.
max_kwh = min(room_c, hr, room_d * inv_eta) max_kwh = min(room_c, hr, room_d * inv_eta)
if max_kwh < 1e-6: if max_kwh < 1e-6:
continue continue
if best is None or profit > best[2]: if best is None or profit > best[2]:
best = (ci, di, profit, max_kwh) best = (ci, di, profit, max_kwh)
if best is None: if best is None:
@ -270,20 +318,130 @@ def _pass2_arbitrage(df: pd.DataFrame, battery: Battery,
ci, di, _, kwh = best ci, di, _, kwh = best
schedule[ci, 0] += kwh schedule[ci, 0] += kwh
schedule[di, 1] += kwh * eta schedule[di, 1] += kwh * eta
# Trajectory: SoC between ci+1 and di stays elevated by kwh*eta.
# SoC at di+1 onwards returns to original (+kwh*eta) - (kwh*eta) = unchanged).
for t in range(ci, di): for t in range(ci, di):
soc[t + 1] += kwh * eta soc[t + 1] += kwh * eta
# soc[di+1] onwards: +kwh*eta from charge cancels with -kwh*eta # Beyond di, soc[di+1] = previous - d/eta = previous - kwh
# from discharge → unchanged. # We added kwh*eta at soc[di], then removed kwh*eta on the
# discharge so soc[di+1] should already match — but we updated
# the loop with soc trajectory; need to ensure di+1 onward stays
# consistent.
# Easier: walk soc again from di onward to verify consistency.
# Since both sides cancel, soc[di+1..end] should be unchanged.
return schedule
def _oracle_daily_schedule_legacy(df: pd.DataFrame, battery: Battery) -> np.ndarray:
"""Legacy per-day LP (daily-myopic). Kept for reference / regression
testing only the public oracle_daily_schedule() now solves the whole
window in one go to avoid SoC-resets-to-zero-overnight artefacts."""
cap = battery.capacity_kwh
pc_max = battery.max_charge_kw
pd_max = battery.max_discharge_kw
eta = battery.round_trip_eff ** 0.5
n = len(df)
schedule = np.zeros((n, 2))
soc = battery.initial_soc_kwh
# Position lookup so we can fill the global schedule from each day's solution.
pos = {ts: i for i, ts in enumerate(df.index)}
has_pv = "pv_kwh" in df.columns
has_export_price = "export_eur_per_kwh" in df.columns
for _, day_df in df.groupby(df.index.date, sort=True):
m = len(day_df)
import_price = day_df["eur_per_kwh"].to_numpy()
export_price = (
day_df["export_eur_per_kwh"].to_numpy() if has_export_price else import_price
)
demand = day_df["demand_kwh"].to_numpy()
pv = day_df["pv_kwh"].to_numpy() if has_pv else np.zeros(m)
# Variables: x = [c_0..c_{m-1}, d_0..d_{m-1}, gI_0..gI_{m-1}, gE_0..gE_{m-1}]
# c = charge from bus to battery (kWh AC)
# d = discharge from battery to bus (kWh AC)
# gI = grid import to bus (kWh, ≥ 0)
# gE = grid export from bus (kWh, ≥ 0)
# Energy balance per hour: gI - gE = demand - pv + c - d
# → gI - gE - c + d = demand - pv
# Cost = sum(import_price * gI - export_price * gE).
# When import_price > export_price, the LP automatically picks
# min(gI, gE) = 0 because keeping both > 0 costs (imp - exp) > 0.
#
# Tiny tie-breakers: when several surplus hours are equally good for
# charging (true whenever export_price is constant and we're capturing
# surplus), the simplex would otherwise pick an arbitrary subset.
# Adding a microscopic penalty proportional to hour-index makes early
# charging strictly cheaper than late charging by a fraction of a
# cent — so the battery visibly fills as soon as surplus appears.
# Symmetric for discharge: prefer late hours (when prices are
# typically highest anyway, but ties happen during evening plateaus).
EPS = 1e-6
hr = np.arange(m)
f = np.concatenate([
EPS * hr, # c: prefer earlier charging (smaller h ⇒ less cost)
EPS * (m - 1 - hr), # d: prefer later discharging
import_price, # pay for imports
-export_price, # earn export credit
])
# Variable bounds.
c_bounds = [(0.0, pc_max)] * m
if battery.allows_export:
d_bounds = [(0.0, pd_max)] * m
else:
# Plug-in: discharge ≤ net demand after solar (battery cannot push current backwards).
d_bounds = [
(0.0, min(pd_max, max(0.0, demand[t] - pv[t]))) for t in range(m)
]
# Physical caps on grid flow per hour. Without these the LP can find
# zero-cost cycles when export_price ≈ 0 (charge from cheap surplus,
# discharge into cheap export — both sides zero, so any volume is "free").
# gI: practical max draw is demand + max charge; cap generously at 100 kWh/h.
# gE: bounded by *natural* export capacity = max(0, pv demand). This
# also handles the case where the demand series is already net of an
# external PV (so demand can be negative even when pv = 0).
gi_bounds = [(0.0, 100.0)] * m
natural_export = np.maximum(0.0, pv - demand)
if battery.allows_export:
ge_bounds = [(0.0, max(1e-9, natural_export[t] + pd_max)) for t in range(m)]
else:
ge_bounds = [(0.0, max(1e-9, natural_export[t])) for t in range(m)]
bounds = c_bounds + d_bounds + gi_bounds + ge_bounds
# Energy balance equality (m rows): -c[t] + d[t] + gI[t] - gE[t] = demand[t] - pv[t]
I = np.eye(m)
A_eq = np.hstack([-I, I, I, -I])
b_eq = demand - pv
# SoC inequality (2m rows). SoC(t) = soc0 + eta*cumsum(c)[t] - cumsum(d)[t]/eta.
# Apply only to c, d columns; gI, gE are zero in these rows.
L = np.tril(np.ones((m, m)))
Z = np.zeros((m, m))
A_ub = np.vstack([
np.hstack([-eta * L, (1.0 / eta) * L, Z, Z]), # -SoC ≤ soc0 ⇒ SoC ≥ 0
np.hstack([ eta * L, -(1.0 / eta) * L, Z, Z]), # SoC ≤ cap - soc0 + soc0
])
b_ub = np.concatenate([np.full(m, soc), np.full(m, cap - soc)])
result = linprog(
f, A_ub=A_ub, b_ub=b_ub, A_eq=A_eq, b_eq=b_eq, bounds=bounds, method="highs"
)
if not result.success:
raise RuntimeError(f"LP failed for {day_df.index[0].date()}: {result.message}")
c_day = result.x[:m]
d_day = result.x[m : 2 * m]
for t, ts in enumerate(day_df.index):
schedule[pos[ts], 0] = c_day[t]
schedule[pos[ts], 1] = d_day[t]
soc = soc + c_day.sum() * eta - d_day.sum() / eta
soc = max(0.0, min(cap, soc))
def oracle_daily_schedule(df: pd.DataFrame, battery: Battery) -> np.ndarray:
"""Greedy self-consumption (Pass 1) + perfect-foresight per-day arbitrage (Pass 2).
The ceiling for what a controller could achieve given a day-ahead price
series. Cross-day shifting is intentionally not allowed (the next day's
SoC starts wherever the previous day left it, but no within-day
arbitrage looks across the midnight boundary).
"""
schedule, soc = _pass1_reactive(df, battery)
_pass2_arbitrage(df, battery, schedule, soc)
return schedule return schedule

View file

@ -56,6 +56,8 @@ function readScenario() {
return { return {
demand_kwh: parseFloat(fd.get("demand_kwh")), demand_kwh: parseFloat(fd.get("demand_kwh")),
retail: parseFloat(fd.get("retail")), retail: parseFloat(fd.get("retail")),
pv_kwp: parseFloat(fd.get("pv_kwp")),
pv_yield: 875.0,
fixed_rate: fd.get("fixed_rate") === "true", fixed_rate: fd.get("fixed_rate") === "true",
saldering: fd.get("saldering") === "true", saldering: fd.get("saldering") === "true",
eta: parseFloat(fd.get("eta")), eta: parseFloat(fd.get("eta")),
@ -265,7 +267,7 @@ const tlSummary = mustFind("tl-summary");
let activeBattery = null; // the row clicked let activeBattery = null; // the row clicked
let tlData = null; // last fetched timeline payload let tlData = null; // last fetched timeline payload
let charts = { price: null, grid: null, soc: null }; let charts = { irr: null, soc: null, grid: null };
function openModalForBattery(row) { function openModalForBattery(row) {
activeBattery = row; activeBattery = row;
@ -290,7 +292,7 @@ function openModalForBattery(row) {
function closeModal() { function closeModal() {
modal.hidden = true; modal.hidden = true;
for (const c of Object.values(charts)) c?.destroy?.(); for (const c of Object.values(charts)) c?.destroy?.();
charts = { price: null, grid: null, soc: null }; charts = { irr: null, soc: null, grid: null };
} }
modal.querySelectorAll('[data-close="modal"]').forEach( modal.querySelectorAll('[data-close="modal"]').forEach(
@ -323,7 +325,7 @@ async function refreshTimeline() {
start: tlDate.value || "2024-06-17", start: tlDate.value || "2024-06-17",
days: tlDays.value, days: tlDays.value,
demand_kwh: sc.demand_kwh, retail: sc.retail, demand_kwh: sc.demand_kwh, retail: sc.retail,
fixed_rate: sc.fixed_rate, pv_kwp: sc.pv_kwp, fixed_rate: sc.fixed_rate,
saldering: sc.saldering, eta: sc.eta, saldering: sc.saldering, eta: sc.eta,
}); });
if (sc.export_rate !== null) params.set("export_rate", sc.export_rate); if (sc.export_rate !== null) params.set("export_rate", sc.export_rate);
@ -354,12 +356,12 @@ function paintTimeline(d) {
tlSummary.replaceChildren(); tlSummary.replaceChildren();
for (const [k, v] of [ for (const [k, v] of [
["window cost", `${cost.toFixed(2)}`], ["window cost", `${cost.toFixed(2)}`],
["imports", `${imports.toFixed(2)} kWh`], ["imports", `${imports.toFixed(1)} kWh`],
["exports", `${exports_.toFixed(2)} kWh`], ["exports", `${exports_.toFixed(1)} kWh`],
["battery throughput", batteryOn ["battery throughput", batteryOn
? `${s.battery_charge_kwh.toFixed(2)} in / ${s.battery_discharge_kwh.toFixed(2)} out kWh` ? `${s.battery_charge_kwh.toFixed(1)} in / ${s.battery_discharge_kwh.toFixed(1)} out kWh`
: `— (battery off)`], : `— (battery off)`],
["window saving", `${savings.toFixed(4)}`], ["window saving", `${savings.toFixed(2)}`],
]) { ]) {
const row = document.createElement("div"); const row = document.createElement("div");
const lbl = document.createElement("span"); lbl.textContent = k; const lbl = document.createElement("span"); lbl.textContent = k;
@ -369,14 +371,18 @@ function paintTimeline(d) {
// X axis: unix seconds (uPlot wants epoch seconds) // X axis: unix seconds (uPlot wants epoch seconds)
const xs = d.hours.map(h => Date.parse(h.ts) / 1000); const xs = d.hours.map(h => Date.parse(h.ts) / 1000);
const price = d.hours.map(h => h.price); const irr = d.hours.map(h => h.irradiance);
const soc = batteryOn ? d.hours.map(h => h.soc) : d.hours.map(() => 0); const soc = batteryOn ? d.hours.map(h => h.soc) : d.hours.map(() => 0);
const grid = batteryOn ? d.hours.map(h => h.grid_with_bat) const grid = batteryOn ? d.hours.map(h => h.grid_with_bat)
: d.hours.map(h => h.grid_no_bat); : d.hours.map(h => h.grid_no_bat);
drawChart("price", ["Time", "€/kWh"], xs, price, "tl-chart-price", { drawChart("irr", ["Time", "W/m²"], xs, irr, "tl-chart-irradiance", {
stroke: "#a78bfa", fill: "rgba(167, 139, 250, 0.2)", stroke: "#fbbf24", fill: "rgba(251, 191, 36, 0.2)",
fmtY: (v) => v == null ? "" : `${v.toFixed(3)}/kWh`, fmtY: (v) => v == null ? "" : `${v.toFixed(0)} W/m²`,
});
drawChart("soc", ["Time", "kWh"], xs, soc, "tl-chart-soc", {
stroke: "#4ade80", fill: "rgba(74, 222, 128, 0.2)",
fmtY: (v) => v == null ? "" : `${v.toFixed(2)} kWh`,
}); });
drawChart("grid", ["Time", "kWh/h"], xs, grid, "tl-chart-grid", { drawChart("grid", ["Time", "kWh/h"], xs, grid, "tl-chart-grid", {
stroke: batteryOn ? "#93c5fd" : "#fb7185", stroke: batteryOn ? "#93c5fd" : "#fb7185",
@ -384,10 +390,6 @@ function paintTimeline(d) {
zeroline: true, zeroline: true,
fmtY: (v) => v == null ? "" : `${v >= 0 ? "+" : ""}${v.toFixed(2)} kWh/h`, fmtY: (v) => v == null ? "" : `${v >= 0 ? "+" : ""}${v.toFixed(2)} kWh/h`,
}); });
drawChart("soc", ["Time", "kWh"], xs, soc, "tl-chart-soc", {
stroke: "#4ade80", fill: "rgba(74, 222, 128, 0.2)",
fmtY: (v) => v == null ? "" : `${v.toFixed(2)} kWh`,
});
} }
function drawChart(key, axes, xs, ys, containerId, opts = {}) { function drawChart(key, axes, xs, ys, containerId, opts = {}) {

View file

@ -238,25 +238,16 @@ table#leaderboard tbody tr a { color: inherit; text-decoration: underline dotted
} }
.modal-summary div { display: flex; justify-content: space-between; gap: 0.5rem; } .modal-summary div { display: flex; justify-content: space-between; gap: 0.5rem; }
.modal-summary div strong { color: var(--accent); } .modal-summary div strong { color: var(--accent); }
.modal-strategy {
border: 1px solid var(--border); border-radius: 8px; background: var(--panel2);
padding: 0.45rem 0.75rem 0.6rem; margin-bottom: 0.75rem;
display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; align-items: center;
}
.modal-strategy legend { padding: 0 0.4rem; font-size: 0.78rem; color: var(--muted); }
.modal-strategy .radio { display: inline-flex; align-items: center; gap: 0.4rem;
font-size: 0.85rem; color: var(--text); cursor: pointer; }
.modal-strategy .radio input { margin: 0; }
.modal-charts { display: flex; flex-direction: column; gap: 0.6rem; } .modal-charts { display: flex; flex-direction: column; gap: 0.6rem; }
.modal-charts figure { margin: 0; background: var(--panel); border: 1px solid var(--border); .modal-charts figure { margin: 0; background: var(--panel); border: 1px solid var(--border);
border-radius: 8px; padding: 0.6rem 0.75rem 1rem; } border-radius: 8px; padding: 0.6rem 0.75rem 0.4rem; }
.modal-charts figcaption { font-size: 0.78rem; color: var(--muted); margin-bottom: 0.3rem; } .modal-charts figcaption { font-size: 0.78rem; color: var(--muted); margin-bottom: 0.3rem; }
.modal-charts .chart { width: 100%; height: 150px; } .modal-charts .chart { width: 100%; height: 110px; }
.modal-charts figure:nth-child(4) .chart { height: 230px; } /* taller grid panel (4th now) */ .modal-charts figure:nth-child(3) .chart { height: 200px; } /* taller grid panel */
/* uPlot dark-theme tweaks */ /* uPlot dark-theme tweaks */
.uplot { color: var(--text); } .uplot { color: var(--text); }
.uplot .u-legend { color: var(--text); font-size: 0.75rem; padding: 1rem 0 0.6rem; } .uplot .u-legend { color: var(--text); font-size: 0.75rem; padding: 0.15rem 0; }
.uplot .u-legend th, .uplot .u-legend td { color: var(--text); padding: 0 0.4rem; } .uplot .u-legend th, .uplot .u-legend td { color: var(--text); padding: 0 0.4rem; }
.uplot .u-legend .u-marker { display: inline-block; } .uplot .u-legend .u-marker { display: inline-block; }
.uplot .u-axis { color: var(--muted); } .uplot .u-axis { color: var(--muted); }

View file

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Honest battery payback — vs thuisbatterijgids.nl</title> <title>Honest battery payback — vs thuisbatterijgids.nl</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=24"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=20">
</head> </head>
<body> <body>
@ -22,18 +22,21 @@
<aside id="inputs"> <aside id="inputs">
<h2>Scenario</h2> <h2>Scenario</h2>
<form id="scenario-form"> <form id="scenario-form">
<label>Annual net draw at meter (kWh) <label>Annual demand (kWh)
<input type="number" name="demand_kwh" min="0" max="20000" step="1" value="{{ defaults.demand_kwh }}"> <input type="number" name="demand_kwh" min="500" max="20000" step="100" value="{{ defaults.demand_kwh }}">
<small>Default 2325 kWh = dad's actual 2024 net (delivered 4301,
returned 1976). Increasing it adds a flat always-on load — more
imports, less surplus exported. The existing-PV export shape stays
untouched.</small>
</label> </label>
<label>Average retail (€/kWh) <label>Average retail (€/kWh)
<input type="number" name="retail" min="0.05" max="0.80" step="0.01" value="{{ defaults.retail }}"> <input type="number" name="retail" min="0.05" max="0.80" step="0.01" value="{{ defaults.retail }}">
</label> </label>
<label>PV system (kWp)
<input type="number" name="pv_kwp" min="0" max="100" step="any" value="{{ defaults.pv_kwp }}">
<small>0 = none (or already netted in your P1 data). Synthesizes
additional PV at 875 kWh/kWp/yr — leave at 0 unless you want to
model extra PV beyond what's in the load signal.</small>
</label>
<fieldset> <fieldset>
<legend>Tariff</legend> <legend>Tariff</legend>
<label class="radio"><input type="radio" name="fixed_rate" value="false" checked> Dynamic (EPEX-based)</label> <label class="radio"><input type="radio" name="fixed_rate" value="false" checked> Dynamic (EPEX-based)</label>
@ -84,7 +87,7 @@
<div class="card"> <div class="card">
<h2> <h2>
Full leaderboard Full leaderboard
<small id="meta">(<span id="count">{{ initial.batteries|length }}</span> batteries · <span id="elapsed">{{ initial.elapsed_seconds }}</span>s · existing PV exports at the meter (capturable by a battery): <span id="total-surplus">{{ '%.0f' % initial.total_surplus_kwh }}</span> kWh/yr)</small> <small id="meta">(<span id="count">{{ initial.batteries|length }}</span> batteries · <span id="elapsed">{{ initial.elapsed_seconds }}</span>s · capturable surplus (= grid exports without a battery): <span id="total-surplus">{{ '%.0f' % initial.total_surplus_kwh }}</span> kWh/yr)</small>
</h2> </h2>
<div class="filters"> <div class="filters">
<label class="filter-toggle"><input type="checkbox" data-cat="small_plugin" checked> ≤ 0.8 kW plug-in (no electrician)</label> <label class="filter-toggle"><input type="checkbox" data-cat="small_plugin" checked> ≤ 0.8 kW plug-in (no electrician)</label>
@ -103,10 +106,10 @@
<th data-sort="lp_payback" data-default-dir="asc" title="Honest payback with inflation">LP yr</th> <th data-sort="lp_payback" data-default-dir="asc" title="Honest payback with inflation">LP yr</th>
<th data-sort="ten_year_profit_eur" data-default-dir="desc" title="Cumulative savings over 10 yrs (with inflation) minus the battery cost">10-yr net</th> <th data-sort="ten_year_profit_eur" data-default-dir="desc" title="Cumulative savings over 10 yrs (with inflation) minus the battery cost">10-yr net</th>
<th data-sort="cycles_per_year" data-default-dir="desc" title="Total discharge throughput / capacity. Includes both surplus capture AND grid arbitrage.">cycles</th> <th data-sort="cycles_per_year" data-default-dir="desc" title="Total discharge throughput / capacity. Includes both surplus capture AND grid arbitrage.">cycles</th>
<th data-sort="captured_kwh" data-default-dir="desc" title="kWh of meter-export (existing PV pushing back) the battery actually absorbed (vs total surplus shown above)">kWh capt</th> <th data-sort="captured_kwh" data-default-dir="desc" title="kWh of solar surplus the battery actually absorbed (vs total surplus shown above)">kWh capt</th>
<th data-sort="captured_pct" data-default-dir="desc" title="Captured kWh as % of total existing-PV export at the meter">% capt</th> <th data-sort="captured_pct" data-default-dir="desc" title="Captured kWh as % of total available solar surplus">% capt</th>
<th data-sort="pct_hours_battery_full" data-default-dir="desc" title="% of export hours where the battery was already full and couldn't store more">% full</th> <th data-sort="pct_hours_battery_full" data-default-dir="desc" title="% of surplus hours where the battery was already full and couldn't store more">% full</th>
<th data-sort="pct_hours_charge_saturated" data-default-dir="desc" title="% of export hours where the meter's surplus exceeded the battery's AC intake rate — the AC port physically can't absorb it all">% sat</th> <th data-sort="pct_hours_charge_saturated" data-default-dir="desc" title="% of surplus hours where charging was pegged at max kW and couldn't keep up">% sat</th>
</tr> </tr>
</thead> </thead>
<tbody id="leaderboard-body">{# rendered cells start below; legend follows the table. #} <tbody id="leaderboard-body">{# rendered cells start below; legend follows the table. #}
@ -135,10 +138,10 @@
<dt>LP yr</dt> <dd>Years until cumulative savings (with the chosen inflation rate) recoup the battery cost.</dd> <dt>LP yr</dt> <dd>Years until cumulative savings (with the chosen inflation rate) recoup the battery cost.</dd>
<dt>10-yr net</dt> <dd>Total profit over 10 years: cumulative savings (compounded by inflation) minus the battery cost. Red = doesn't break even within 10 yr.</dd> <dt>10-yr net</dt> <dd>Total profit over 10 years: cumulative savings (compounded by inflation) minus the battery cost. Red = doesn't break even within 10 yr.</dd>
<dt>cycles</dt> <dd>Total discharge throughput per year, divided by capacity. Includes <em>both</em> surplus capture and grid arbitrage (charging during cheap hours from the grid even when no surplus is exporting). Useful as a "how busy is the battery" metric.</dd> <dt>cycles</dt> <dd>Total discharge throughput per year, divided by capacity. Includes <em>both</em> surplus capture and grid arbitrage (charging during cheap hours from the grid even when no surplus is exporting). Useful as a "how busy is the battery" metric.</dd>
<dt>kWh capt</dt> <dd>kWh of meter-export (existing PV pushing back) that this battery absorbs before it crosses the meter.</dd> <dt>kWh capt</dt> <dd>kWh of capturable surplus (i.e. of grid exports that would happen without a battery) that this battery absorbs.</dd>
<dt>% capt</dt> <dd>That, as a percentage of the scenario's existing-PV meter-export shown in the heading. <em>Note:</em> solar that's directly self-consumed during sunny hours never crosses the meter and is invisible to a battery.</dd> <dt>% capt</dt> <dd>That, as a percentage of the scenario's capturable surplus shown in the heading. <em>Note:</em> the denominator is exports, not gross PV — solar that's directly self-consumed during sunny hours never crosses the meter and is invisible to a battery.</dd>
<dt>% full</dt> <dd>Of all export hours, the % when the battery was already full and couldn't store more — capacity-bottlenecked.</dd> <dt>% full</dt> <dd>Of all surplus hours, the % when the battery was already full and couldn't store more — capacity-bottlenecked.</dd>
<dt>% sat</dt> <dd>Of all export hours, the % when charging was pegged at max kW and couldn't keep up — power-bottlenecked.</dd> <dt>% sat</dt> <dd>Of all surplus hours, the % when charging was pegged at max kW and couldn't keep up — power-bottlenecked.</dd>
</dl> </dl>
</div> </div>
</section> </section>
@ -146,12 +149,10 @@
<footer> <footer>
<p> <p>
LP = greedy self-consumption + perfect-foresight per-day price arbitrage, run over the recorded LP = 24h-foresight oracle (linear-program optimal dispatch) on 2023-09 → 2024-09 EPEX, P1, and irradiance.
EPEX + P1 net-meter trace. The simulator works on what the meter shows — solar already netted in, Store calculator is reverse-engineered from observed quotes and matches them within €0.50.
no separate PV synthesis. Store calculator is reverse-engineered from observed quotes and matches The store's dynamic-rate quote treats charge cost as raw EPEX wholesale (no VAT, no energy tax), inflating
them within €0.50. The store's dynamic-rate quote treats charge cost as raw EPEX wholesale arbitrage savings ~2× over what any real customer with a Dutch tariff actually pays.
(no VAT, no energy tax), inflating arbitrage savings ~2× over what any real customer with a
Dutch tariff actually pays.
</p> </p>
</footer> </footer>
@ -170,7 +171,6 @@
<option value="1">1 day</option> <option value="1">1 day</option>
<option value="3">3 days</option> <option value="3">3 days</option>
<option value="7" selected>7 days</option> <option value="7" selected>7 days</option>
<option value="28">28 days</option>
</select> </select>
<label class="filter-toggle"><input type="checkbox" id="tl-battery-on" checked> battery on</label> <label class="filter-toggle"><input type="checkbox" id="tl-battery-on" checked> battery on</label>
<span id="tl-status" class="status"></span> <span id="tl-status" class="status"></span>
@ -178,22 +178,22 @@
<div id="tl-summary" class="modal-summary"></div> <div id="tl-summary" class="modal-summary"></div>
<div class="modal-charts"> <div class="modal-charts">
<figure> <figure>
<figcaption>Consumer electricity price (€/kWh) — drives the arbitrage decisions</figcaption> <figcaption>Solar irradiance — proxy for sun (W/m², from our weather station, season-aligned)</figcaption>
<div id="tl-chart-price" class="chart"></div> <div id="tl-chart-irradiance" class="chart"></div>
</figure>
<figure>
<figcaption>Net meter (kWh/h). Positive = importing, negative = exporting.</figcaption>
<div id="tl-chart-grid" class="chart"></div>
</figure> </figure>
<figure> <figure>
<figcaption>Battery state of charge (kWh)</figcaption> <figcaption>Battery state of charge (kWh)</figcaption>
<div id="tl-chart-soc" class="chart"></div> <div id="tl-chart-soc" class="chart"></div>
</figure> </figure>
<figure>
<figcaption>Net meter flow (kWh per hour). Positive = importing, negative = exporting.</figcaption>
<div id="tl-chart-grid" class="chart"></div>
</figure>
</div> </div>
</div> </div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.iife.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.iife.min.js"></script>
<script src="{{ url_for('static', filename='app.js') }}?v=24"></script> <script src="{{ url_for('static', filename='app.js') }}?v=20"></script>
</body> </body>
</html> </html>

View file

@ -4,12 +4,6 @@ Single page: inputs on the left, top recommendation + ranked catalog on the
right. POST /api/calculate accepts a scenario JSON and returns a sorted list right. POST /api/calculate accepts a scenario JSON and returns a sorted list
of every battery scored against both our LP and the cracked store formula. of every battery scored against both our LP and the cracked store formula.
The simulator works on the raw P1 net-meter signal (``raw_demand_kw``,
signed). Whatever the meter shows is the input solar already netted in,
no separate PV simulation. To model a different PV system you'd need an
actual measurement of that system; reconstructing one from a remote
irradiance sensor was tried and didn't survive contact with reality.
Run locally: Run locally:
uv run flask --app pluginbattery.web run --debug --port 8000 uv run flask --app pluginbattery.web run --debug --port 8000
@ -34,6 +28,7 @@ from pluginbattery.sim import (
load_hourly, load_hourly,
oracle_daily_schedule, oracle_daily_schedule,
simulate, simulate,
synthesize_pv,
) )
from pluginbattery.store_calc import ( from pluginbattery.store_calc import (
Scenario, Scenario,
@ -48,17 +43,7 @@ DATA_DIR = Path(__file__).resolve().parents[2] / "data" / "raw"
print("Loading historical data…", flush=True) print("Loading historical data…", flush=True)
_BASE_DF = apply_nl_tariff(load_hourly(DATA_DIR)) _BASE_DF = apply_nl_tariff(load_hourly(DATA_DIR))
# Linear extrapolation of what the recorded window says the annual net _ANNUAL_DEMAND_BASE = float(_BASE_DF["demand_kwh"].sum() * 8766.0 / len(_BASE_DF))
# draw would be. Used as the reference the form's additive demand slider
# is measured against — so a slider value of `_ANNUAL_NET_DEMAND_BASE`
# means "use the data unmodified".
_ANNUAL_NET_DEMAND_BASE = float(_BASE_DF["raw_demand_kw"].sum() * 8766.0 / len(_BASE_DF))
# Dad's actual recorded annual net for the most recent full calendar
# year (his quote: "vorig jaar geleverd 4301 kWh, terug geleverd 1976
# kWh, resteerde 2325 kWh"). Used as the default form value because our
# data window is partial (~8 months, summer-heavy) and undershoots the
# real annual figure.
_REAL_ANNUAL_NET_DEMAND = 2325.0
_AVG_CONSUMER_BASE = float(_BASE_DF["eur_per_kwh"].mean()) _AVG_CONSUMER_BASE = float(_BASE_DF["eur_per_kwh"].mean())
with open(DATA_DIR / "thuisbatterijgids_catalog.json") as _f: with open(DATA_DIR / "thuisbatterijgids_catalog.json") as _f:
@ -80,7 +65,12 @@ def _category(power_kw: float, allows_export: bool) -> str:
def _lifetime_profit(year1: float, cost: float, years: int, inflation: float) -> float: def _lifetime_profit(year1: float, cost: float, years: int, inflation: float) -> float:
"""Cumulative savings over `years` minus the up-front battery cost.""" """Cumulative savings over `years` minus the up-front battery cost.
Each successive year's savings are inflated by `inflation` (matches the
payback formula). Negative result = the battery doesn't break even
within the horizon.
"""
if inflation == 0: if inflation == 0:
return year1 * years - cost return year1 * years - cost
return year1 * ((1 + inflation) ** years - 1) / inflation - cost return year1 * ((1 + inflation) ** years - 1) / inflation - cost
@ -114,11 +104,10 @@ def _worker_run_lp(spec):
) )
out = simulate(_WORKER_DF, bat, oracle_daily_schedule(_WORKER_DF, bat)) out = simulate(_WORKER_DF, bat, oracle_daily_schedule(_WORKER_DF, bat))
# "Surplus" = hours where the meter was already exporting before any demand = _WORKER_DF["demand_kwh"].to_numpy()
# battery action, i.e. existing PV pushing back. The battery's job on pv = (_WORKER_DF["pv_kwh"].to_numpy()
# those hours is to absorb that flow before it crosses the meter. if "pv_kwh" in _WORKER_DF.columns else np.zeros(len(_WORKER_DF)))
raw = _WORKER_DF["raw_demand_kw"].to_numpy() surplus = np.maximum(0.0, pv - demand)
surplus = np.maximum(0.0, -raw)
surplus_mask = surplus > 1e-6 surplus_mask = surplus > 1e-6
n_surplus_hours = int(surplus_mask.sum()) n_surplus_hours = int(surplus_mask.sum())
@ -128,12 +117,9 @@ def _worker_run_lp(spec):
if n_surplus_hours > 0: if n_surplus_hours > 0:
soc = out["soc_kwh"].to_numpy() soc = out["soc_kwh"].to_numpy()
charge = out["charge_kwh"].to_numpy()
pct_full = float((surplus_mask & (soc >= cap - 1e-3)).sum()) / n_surplus_hours * 100.0 pct_full = float((surplus_mask & (soc >= cap - 1e-3)).sum()) / n_surplus_hours * 100.0
# Power bottleneck: the meter is exporting *more* than the battery's pct_saturated = float((surplus_mask & (charge >= pw - 1e-3)).sum()) / n_surplus_hours * 100.0
# AC port can absorb in a single hour. This is purely a function of
# surplus vs pc_max — it does not get confounded by Pass-2 arbitrage
# topping the battery off from the grid during cheap export hours.
pct_saturated = float((surplus >= pw - 1e-3).sum()) / n_surplus_hours * 100.0
else: else:
pct_full = 0.0 pct_full = 0.0
pct_saturated = 0.0 pct_saturated = 0.0
@ -149,24 +135,18 @@ def _worker_run_lp(spec):
# ─── Core compute ───────────────────────────────────────────────────── # ─── Core compute ─────────────────────────────────────────────────────
def _build_df(demand_kwh: float, retail: float, def _build_df(demand_kwh: float, retail: float, pv_kwp: float, pv_yield: float,
fixed_rate: bool, saldering: bool, export_rate: float | None): fixed_rate: bool, saldering: bool, export_rate: float | None):
df = _BASE_DF.copy() df = _BASE_DF.copy()
# Apply the demand slider as an *additive* shift, not a multiplicative df["demand_kwh"] = _BASE_DF["demand_kwh"] * (demand_kwh / _ANNUAL_DEMAND_BASE)
# one. The signal is `gross_demand PV` and we can't separate them,
# but we can model "this same house plus a constant always-on load"
# by adding a flat kW baseline. That leaves the PV-export shape
# untouched (a 3 kWp system still produces what it produces), and
# lifts the imports + reduces the surplus by the same amount —
# exactly what an extra fixed load would do.
delta_kw = (demand_kwh - _ANNUAL_NET_DEMAND_BASE) / 8766.0
df["raw_demand_kw"] = _BASE_DF["raw_demand_kw"] + delta_kw
if fixed_rate: if fixed_rate:
df["eur_per_kwh"] = float(retail) df["eur_per_kwh"] = float(retail)
else: else:
scale = retail / _AVG_CONSUMER_BASE scale = retail / _AVG_CONSUMER_BASE
df["eur_per_kwh"] = _BASE_DF["eur_per_kwh"] * scale df["eur_per_kwh"] = _BASE_DF["eur_per_kwh"] * scale
df["epex_eur_per_kwh"] = _BASE_DF["epex_eur_per_kwh"] * scale df["epex_eur_per_kwh"] = _BASE_DF["epex_eur_per_kwh"] * scale
if pv_kwp > 0:
df = synthesize_pv(df, kwp=pv_kwp, target_kwh_per_kwp_per_year=pv_yield)
if export_rate is not None: if export_rate is not None:
df["export_eur_per_kwh"] = float(export_rate) df["export_eur_per_kwh"] = float(export_rate)
elif saldering: elif saldering:
@ -180,34 +160,41 @@ def compute_leaderboard(
*, *,
demand_kwh: float, demand_kwh: float,
retail: float, retail: float,
pv_kwp: float,
pv_yield: float,
fixed_rate: bool, fixed_rate: bool,
saldering: bool, saldering: bool,
eta: float, eta: float,
inflation: float, inflation: float,
export_rate: float | None = None, export_rate: float | None = None,
) -> dict: ) -> dict:
key = (round(demand_kwh, 1), round(retail, 4), key = (round(demand_kwh, 1), round(retail, 4), round(pv_kwp, 2),
fixed_rate, saldering, round(eta, 3), round(pv_yield, 1), fixed_rate, saldering, round(eta, 3),
round(inflation, 4), round(inflation, 4),
None if export_rate is None else round(export_rate, 4)) None if export_rate is None else round(export_rate, 4))
if key in _RESULT_CACHE: if key in _RESULT_CACHE:
return _RESULT_CACHE[key] return _RESULT_CACHE[key]
t0 = time.time() t0 = time.time()
df = _build_df(demand_kwh, retail, fixed_rate, saldering, export_rate) df = _build_df(demand_kwh, retail, pv_kwp, pv_yield, fixed_rate, saldering, export_rate)
avg_epex = float(df["epex_eur_per_kwh"].mean()) avg_epex = float(df["epex_eur_per_kwh"].mean())
store_params = StoreParams(avg_epex_eur_per_kwh=avg_epex) store_params = StoreParams(avg_epex_eur_per_kwh=avg_epex)
# Annualisation factor: our data window may be shorter than a year. # Annualisation factor: our data window may be shorter than a year (e.g.,
# Scale all per-window extensive quantities so they represent a full year. # 8 months when solar coverage limits the overlap). Scale all per-window
# extensive quantities (savings, kWh shifted, captured, surplus) so they
# represent a full year. Intensive metrics like % full / % saturated stay
# as-is.
annualize = 8766.0 / len(df) annualize = 8766.0 / len(df)
# Existing-PV surplus visible at the meter across the whole window # Total PV / surplus available across the whole scenario (no battery).
# (hours where raw_demand_kw < 0, i.e. dad's PV exporting). pv_col = df["pv_kwh"].to_numpy() if "pv_kwh" in df.columns else np.zeros(len(df))
raw = df["raw_demand_kw"].to_numpy() window_surplus = float(np.maximum(0.0, pv_col - df["demand_kwh"].to_numpy()).sum())
window_surplus = float(np.maximum(0.0, -raw).sum())
total_surplus_kwh = window_surplus * annualize total_surplus_kwh = window_surplus * annualize
# Collect every distinct (capacity, power) the catalog asks for, then run
# the per-spec LPs in parallel. linprog/HiGHS release the GIL so threads
# give a near-linear speedup on this VM (4 cores).
valid_rows = [] valid_rows = []
specs: set[tuple[float, float, bool]] = set() specs: set[tuple[float, float, bool]] = set()
for b in _CATALOG: for b in _CATALOG:
@ -223,21 +210,26 @@ def compute_leaderboard(
valid_rows.append((b, cap, pw, price, allows_export)) valid_rows.append((b, cap, pw, price, allows_export))
specs.add((round(cap, 3), round(pw, 3), allows_export)) specs.add((round(cap, 3), round(pw, 3), allows_export))
df_sig = (round(demand_kwh, 1), round(retail, 4), # Cache key for "what does this LP depend on?" — everything that goes
fixed_rate, saldering, round(eta, 3), # into the dataframe construction plus eta. Inflation isn't in here, so
# tweaking only inflation hits the cache for every catalog entry.
df_sig = (round(demand_kwh, 1), round(retail, 4), round(pv_kwp, 2),
round(pv_yield, 1), fixed_rate, saldering, round(eta, 3),
None if export_rate is None else round(export_rate, 4)) None if export_rate is None else round(export_rate, 4))
cached_results = {} cached_results = {}
todo = [] todo = []
for spec in specs: for spec in specs:
cache_key = (df_sig, *spec) key = (df_sig, *spec)
if cache_key in _LP_CACHE: if key in _LP_CACHE:
cached_results[spec] = _LP_CACHE[cache_key] cached_results[spec] = _LP_CACHE[key]
else: else:
todo.append(spec) todo.append(spec)
if todo: if todo:
workers = min(len(todo), int(os.environ.get("PLUGINBATTERY_LP_WORKERS", "4"))) workers = min(len(todo), int(os.environ.get("PLUGINBATTERY_LP_WORKERS", "4")))
# Fork pool: each worker inherits `df` via copy-on-write rather than
# pickling it on every task — fast cold scenarios.
ctx = get_context("fork") ctx = get_context("fork")
with ProcessPoolExecutor( with ProcessPoolExecutor(
max_workers=max(1, workers), max_workers=max(1, workers),
@ -256,8 +248,8 @@ def compute_leaderboard(
payload = lp_cache[(round(cap, 3), round(pw, 3), allows_export)] payload = lp_cache[(round(cap, 3), round(pw, 3), allows_export)]
lp_year1 = payload["year1"] * annualize lp_year1 = payload["year1"] * annualize
captured_kwh = payload["captured_kwh"] * annualize captured_kwh = payload["captured_kwh"] * annualize
pct_full = payload["pct_full"] pct_full = payload["pct_full"] # already a ratio
pct_saturated = payload["pct_saturated"] pct_saturated = payload["pct_saturated"] # already a ratio
discharge_kwh = payload["discharge_kwh"] * annualize discharge_kwh = payload["discharge_kwh"] * annualize
cycles_per_year = discharge_kwh / cap if cap > 0 else 0.0 cycles_per_year = discharge_kwh / cap if cap > 0 else 0.0
captured_pct = (captured_kwh / total_surplus_kwh * 100.0) if total_surplus_kwh > 0 else 0.0 captured_pct = (captured_kwh / total_surplus_kwh * 100.0) if total_surplus_kwh > 0 else 0.0
@ -273,7 +265,7 @@ def compute_leaderboard(
scn = Scenario( scn = Scenario(
capacity_kwh=cap, max_charge_kw=pw, battery_cost_eur=price, capacity_kwh=cap, max_charge_kw=pw, battery_cost_eur=price,
avg_retail_eur_per_kwh=retail, avg_retail_eur_per_kwh=retail,
has_pv=total_surplus_kwh > 0, has_saldering=saldering, has_pv=pv_kwp > 0, has_saldering=saldering,
dynamic_rate=not fixed_rate, dynamic_rate=not fixed_rate,
) )
sq = store_quote(scn, store_params, inflation=inflation) sq = store_quote(scn, store_params, inflation=inflation)
@ -309,6 +301,7 @@ def compute_leaderboard(
result = { result = {
"scenario": { "scenario": {
"demand_kwh": demand_kwh, "retail": retail, "demand_kwh": demand_kwh, "retail": retail,
"pv_kwp": pv_kwp, "pv_yield": pv_yield,
"fixed_rate": fixed_rate, "saldering": saldering, "fixed_rate": fixed_rate, "saldering": saldering,
"eta": eta, "inflation": inflation, "eta": eta, "inflation": inflation,
"export_rate": export_rate, "export_rate": export_rate,
@ -328,14 +321,15 @@ def compute_leaderboard(
app = Flask(__name__) app = Flask(__name__)
DEFAULTS = dict( DEFAULTS = dict(
# Demand default = dad's real full-year annual net (2325 kWh per his # Window: 2025-01-01 → 2025-09-01 (8 months of dad's actual data with
# quote). Larger than what the partial-window data extrapolates to # matching 2025 prices + solar). The simulator scales demand by
# (~1515 kWh) because we're missing the winter months; the slider's # demand_kwh / ANNUAL_DEMAND_BASE; ANNUAL_DEMAND_BASE annualises the
# additive shift compensates. # 8-month window's 1010 kWh → 1518 kWh-equivalent/yr, so set the
# export_rate = 0 by default. Dad's actual situation has # default to 1518 to keep the per-hour P1 values unscaled (scale = 1.0).
# terugleveringskosten of €0.106/kWh; set the field explicitly to # export_rate = 0 by default to keep the model simple. Dad's actual
# that value when modelling his real bill. # situation has terugleveringskosten of €0.106/kWh; set the field
demand_kwh=round(_REAL_ANNUAL_NET_DEMAND), retail=0.25, # explicitly to that value when modelling his real bill.
demand_kwh=1518.0, retail=0.25, pv_kwp=0.0, pv_yield=875.0,
fixed_rate=False, saldering=False, eta=0.88, inflation=0.03, fixed_rate=False, saldering=False, eta=0.88, inflation=0.03,
export_rate=0.0, export_rate=0.0,
) )
@ -344,13 +338,7 @@ DEFAULTS = dict(
@app.route("/") @app.route("/")
def index(): def index():
initial = compute_leaderboard(**DEFAULTS) initial = compute_leaderboard(**DEFAULTS)
resp = app.make_response(render_template("index.html", initial=initial, defaults=DEFAULTS)) return render_template("index.html", initial=initial, defaults=DEFAULTS)
# Cache-bust query strings on app.js/style.css only help if the browser
# re-fetches index.html — force a fresh page on every request.
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
resp.headers["Pragma"] = "no-cache"
resp.headers["Expires"] = "0"
return resp
@app.route("/api/calculate", methods=["POST"]) @app.route("/api/calculate", methods=["POST"])
@ -368,6 +356,8 @@ def calculate():
result = compute_leaderboard( result = compute_leaderboard(
demand_kwh=float(p.get("demand_kwh", DEFAULTS["demand_kwh"])), demand_kwh=float(p.get("demand_kwh", DEFAULTS["demand_kwh"])),
retail=float(p.get("retail", DEFAULTS["retail"])), retail=float(p.get("retail", DEFAULTS["retail"])),
pv_kwp=float(p.get("pv_kwp", DEFAULTS["pv_kwp"])),
pv_yield=float(p.get("pv_yield", DEFAULTS["pv_yield"])),
fixed_rate=bool(p.get("fixed_rate", DEFAULTS["fixed_rate"])), fixed_rate=bool(p.get("fixed_rate", DEFAULTS["fixed_rate"])),
saldering=bool(p.get("saldering", DEFAULTS["saldering"])), saldering=bool(p.get("saldering", DEFAULTS["saldering"])),
eta=float(p.get("eta", DEFAULTS["eta"])), eta=float(p.get("eta", DEFAULTS["eta"])),
@ -381,7 +371,12 @@ def calculate():
@app.route("/api/timeline") @app.route("/api/timeline")
def timeline(): def timeline():
"""Per-hour trajectory for a battery over a chosen window.""" """Per-hour LP trajectory for a battery over a chosen window.
Returns both 'with battery' and 'without battery' series so the UI can
toggle between them without round-tripping. Defaults to a sunny mid-June
week.
"""
q = request.args q = request.args
try: try:
cap = float(q.get("cap", "1.92")) cap = float(q.get("cap", "1.92"))
@ -392,11 +387,16 @@ def timeline():
demand_kwh = float(q.get("demand_kwh", DEFAULTS["demand_kwh"])) demand_kwh = float(q.get("demand_kwh", DEFAULTS["demand_kwh"]))
retail = float(q.get("retail", DEFAULTS["retail"])) retail = float(q.get("retail", DEFAULTS["retail"]))
pv_kwp = float(q.get("pv_kwp", DEFAULTS["pv_kwp"]))
pv_yield = float(q.get("pv_yield", DEFAULTS["pv_yield"]))
fixed_rate = q.get("fixed_rate", "false").lower() == "true" fixed_rate = q.get("fixed_rate", "false").lower() == "true"
saldering = q.get("saldering", "false").lower() == "true" saldering = q.get("saldering", "false").lower() == "true"
eta = float(q.get("eta", DEFAULTS["eta"])) eta = float(q.get("eta", DEFAULTS["eta"]))
raw_export = q.get("export_rate") raw_export = q.get("export_rate")
if raw_export is None: if raw_export is None:
# Param absent → use the configured default (matches the form's
# initial value). 'export_rate=' (explicit empty) still means
# 'fall back to saldering toggle'.
export_rate = DEFAULTS.get("export_rate") export_rate = DEFAULTS.get("export_rate")
elif raw_export in ("", "null"): elif raw_export in ("", "null"):
export_rate = None export_rate = None
@ -405,7 +405,7 @@ def timeline():
except (TypeError, ValueError) as e: except (TypeError, ValueError) as e:
return jsonify({"error": f"bad query param: {e}"}), 400 return jsonify({"error": f"bad query param: {e}"}), 400
df = _build_df(demand_kwh, retail, fixed_rate, saldering, export_rate) df = _build_df(demand_kwh, retail, pv_kwp, pv_yield, fixed_rate, saldering, export_rate)
bat = Battery( bat = Battery(
capacity_kwh=cap, max_charge_kw=power, max_discharge_kw=power, capacity_kwh=cap, max_charge_kw=power, max_discharge_kw=power,
round_trip_eff=eta, allows_export=allows_export, round_trip_eff=eta, allows_export=allows_export,
@ -420,7 +420,7 @@ def timeline():
return jsonify({"error": f"no data for {start_raw} + {days} days " return jsonify({"error": f"no data for {start_raw} + {days} days "
f"(window: {out.index[0].date()}..{out.index[-1].date()})"}), 400 f"(window: {out.index[0].date()}..{out.index[-1].date()})"}), 400
g_no = win["raw_demand_kw"].to_numpy() g_no = (win["demand_kwh"] - win.get("pv_kwh", 0)).to_numpy()
g_yes = win["grid_kwh_with_battery"].to_numpy() g_yes = win["grid_kwh_with_battery"].to_numpy()
imp_p = win["eur_per_kwh"].to_numpy() imp_p = win["eur_per_kwh"].to_numpy()
exp_p = win.get("export_eur_per_kwh", win["eur_per_kwh"]).to_numpy() exp_p = win.get("export_eur_per_kwh", win["eur_per_kwh"]).to_numpy()
@ -431,9 +431,9 @@ def timeline():
for i, (ts, r) in enumerate(win.iterrows()): for i, (ts, r) in enumerate(win.iterrows()):
hours_payload.append({ hours_payload.append({
"ts": ts.isoformat(), "ts": ts.isoformat(),
"irradiance": float(r.get("irradiance_w_m2", 0.0)),
"price": float(r["eur_per_kwh"]), "price": float(r["eur_per_kwh"]),
"export_price": float(r.get("export_eur_per_kwh", r["eur_per_kwh"])), "export_price": float(r.get("export_eur_per_kwh", r["eur_per_kwh"])),
"raw_demand": float(r["raw_demand_kw"]),
"soc": float(r["soc_kwh"]), "soc": float(r["soc_kwh"]),
"charge": float(r["charge_kwh"]), "charge": float(r["charge_kwh"]),
"discharge": float(r["discharge_kwh"]), "discharge": float(r["discharge_kwh"]),
@ -447,7 +447,7 @@ def timeline():
"battery": {"capacity_kwh": cap, "power_kw": power, "battery": {"capacity_kwh": cap, "power_kw": power,
"allows_export": allows_export}, "allows_export": allows_export},
"scenario": {"demand_kwh": demand_kwh, "retail": retail, "scenario": {"demand_kwh": demand_kwh, "retail": retail,
"saldering": saldering, "pv_kwp": pv_kwp, "saldering": saldering,
"fixed_rate": fixed_rate, "eta": eta, "fixed_rate": fixed_rate, "eta": eta,
"export_rate": export_rate}, "export_rate": export_rate},
"window": {"start": start_ts.isoformat(), "days": days, "window": {"start": start_ts.isoformat(), "days": days,

View file

@ -10,21 +10,18 @@ from pluginbattery.sim import (
apply_nl_tariff, apply_nl_tariff,
oracle_daily_schedule, oracle_daily_schedule,
simulate, simulate,
synthesize_pv,
) )
def make_df(prices: list[float], raw_demands_kw: list[float]) -> pd.DataFrame: def make_df(prices: list[float], demands_kwh: 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") idx = pd.date_range("2024-01-01", periods=len(prices), freq="h", tz="UTC")
return pd.DataFrame( return pd.DataFrame(
{ {
"eur_per_kwh": prices, "eur_per_kwh": prices,
"power_w": np.array(raw_demands_kw) * 1000.0, "power_w": np.array(demands_kwh) * 1000.0,
"raw_demand_kw": raw_demands_kw, "irradiance_w_m2": 0.0,
"demand_kwh": demands_kwh,
}, },
index=idx, index=idx,
) )
@ -46,12 +43,12 @@ def test_plugin_never_exports():
round_trip_eff=1.0, allows_export=False, initial_soc_kwh=2.0) 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 schedule = np.array([[0.0, 0.8]] * 3) # try to dump at full power
out = simulate(df, bat, schedule) out = simulate(df, bat, schedule)
assert (out["discharge_kwh"] <= out["raw_demand_kw"] + 1e-9).all() assert (out["discharge_kwh"] <= out["demand_kwh"] + 1e-9).all()
assert (out["grid_kwh_with_battery"] >= -1e-9).all() assert (out["grid_kwh_with_battery"] >= -1e-9).all()
def test_grid_arbitrage_kicks_in_on_no_sun_days(): def test_grid_arbitrage_kicks_in_on_no_sun_days():
"""Without surplus but with a daily price spread, the dispatcher should """Without PV but with a daily price spread, the dispatcher should
charge during the cheapest hours and discharge during the most charge during the cheapest hours and discharge during the most
expensive same dynamic-tariff behaviour Tibber-style controllers do.""" expensive same dynamic-tariff behaviour Tibber-style controllers do."""
prices = [0.05] * 12 + [0.50] * 12 prices = [0.05] * 12 + [0.50] * 12
@ -69,23 +66,23 @@ def test_grid_arbitrage_kicks_in_on_no_sun_days():
assert out["savings"].sum() > 0 assert out["savings"].sum() > 0
def test_greedy_fills_from_meter_export_then_overflows(): def test_greedy_fills_from_surplus_then_overflows():
"""When the meter is already exporting (existing PV pushing back), """With PV surplus, greedy fills the battery as fast as power allows
greedy fills the battery as fast as power allows until capacity is until capacity is reached, then lets the rest export."""
reached, then lets the rest flow out the meter."""
prices = [0.20] * 24 prices = [0.20] * 24
# 6 hours of net-export (3 kW each), then 12 hours of import demand. demand = [0.1] * 24
raw = [0.1] * 6 + [-3.0] * 6 + [0.1] * 12 pv = [0.0] * 6 + [3.0] * 6 + [0.0] * 12 # 6 sunny hours, 3 kWh/h surplus
df = make_df(prices, raw) 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, bat = Battery(capacity_kwh=2.0, max_charge_kw=0.8, max_discharge_kw=0.8,
round_trip_eff=1.0, allows_export=False) round_trip_eff=1.0, allows_export=False)
schedule = oracle_daily_schedule(df, bat) schedule = oracle_daily_schedule(df, bat)
out = simulate(df, bat, schedule) out = simulate(df, bat, schedule)
# Charging happens during the first export hours, capped at 0.8 kW # 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) assert out["charge_kwh"].iloc[6:9].sum() == pytest.approx(2.0, abs=1e-6)
# Once full, no more charging even though export continues # Once full, no more charging even though surplus continues
assert out["charge_kwh"].iloc[9:12].sum() == 0 assert out["charge_kwh"].iloc[9:12].sum() == 0
# Battery discharges into evening import demand # Battery discharges into evening demand
assert out["discharge_kwh"].iloc[12:].sum() > 0 assert out["discharge_kwh"].iloc[12:].sum() > 0
@ -122,40 +119,55 @@ def test_oracle_skips_arbitrage_when_eff_kills_it():
assert out["savings"].sum() < 1e-6 assert out["savings"].sum() < 1e-6
def test_plugin_never_pushes_to_grid_during_export_hours(): def test_synthesize_pv_hits_target_annual():
"""Plug-in battery during meter-export hours: discharge must be 0 """synthesize_pv should calibrate so annual output ≈ target × kWp."""
(battery cannot push current backwards, and the meter is already n = 24 * 30 # 30 days
flowing the wrong way).""" idx = pd.date_range("2024-06-01", periods=n, freq="h", tz="UTC")
prices = [0.30] * 24 # Simple square wave: 600 W/m² for 8 daylight hours, zero otherwise.
# First 12 hours: meter is exporting (-3 kWh/h). Last 12: importing (+0.5 kWh/h). irr = np.zeros(n)
raw = [-3.0] * 12 + [0.5] * 12 for d in range(30):
df = make_df(prices, raw) 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, 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) 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 schedule = np.array([[0.0, 0.8]] * 24) # try to dump every hour
out = simulate(df, bat, schedule) out = simulate(df, bat, schedule)
export_hours = out["raw_demand_kw"] < 0 surplus_hours = out["pv_kwh"] > out["demand_kwh"]
assert (out.loc[export_hours, "discharge_kwh"] == 0).all() assert (out.loc[surplus_hours, "discharge_kwh"] == 0).all()
def test_no_saldering_increases_battery_savings(): def test_no_saldering_increases_battery_savings():
"""Removing saldering should make a battery on a house-with-PV more valuable. """Removing saldering should make a battery on a PV system more valuable.
Reason: meter-export that previously credited at consumer price now only Reason: surplus solar that previously credited at consumer price now only
earns raw EPEX. Storing it for later self-consumption is now strictly earns raw EPEX. Storing it for later self-consumption is now strictly
better than the previous opportunity cost. better than the previous opportunity cost.
""" """
# Day with cheap morning EPEX, midday meter-export (existing PV), expensive evening. # Day with cheap morning EPEX, noon surplus solar, expensive evening.
prices_consumer = [0.20] * 6 + [0.15] * 6 + [0.40] * 12 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 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. demands = [0.5] * 24
raw = [0.5] * 8 + [-3.0] * 6 + [0.5] * 10 pv = [0.0] * 8 + [3.0] * 6 + [0.0] * 10
n = 24 n = 24
idx = pd.date_range("2024-01-01", periods=n, freq="h", tz="UTC") idx = pd.date_range("2024-01-01", periods=n, freq="h", tz="UTC")
base = pd.DataFrame({ base = pd.DataFrame({
"eur_per_kwh": prices_consumer, "eur_per_kwh": prices_consumer,
"power_w": np.array(raw) * 1000.0, "power_w": np.array(demands) * 1000.0,
"raw_demand_kw": raw, "irradiance_w_m2": 0.0,
"demand_kwh": demands,
"pv_kwh": pv,
"epex_eur_per_kwh": prices_epex, "epex_eur_per_kwh": prices_epex,
}, index=idx) }, index=idx)
bat = Battery(capacity_kwh=5.0, max_charge_kw=2.5, max_discharge_kw=2.5, bat = Battery(capacity_kwh=5.0, max_charge_kw=2.5, max_discharge_kw=2.5,