Compare commits
2 commits
9e77b35ff4
...
fc90e65271
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc90e65271 | ||
|
|
e2fb72a0ab |
13 changed files with 311 additions and 476 deletions
|
|
@ -13,5 +13,20 @@
|
||||||
"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": ""
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,11 @@
|
||||||
"""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 demand, avg retail €0.25, no PV, no saldering, 3% inflation.
|
4000 kWh/yr net demand, avg retail €0.25, no saldering, 3% inflation.
|
||||||
|
|
||||||
Override with CLI flags to test a different scenario (e.g., dad's situation
|
The simulator works on the raw P1 net-meter trace — whatever PV is on the
|
||||||
with --pv-kwp 3.0). Output: data/processed/battery_leaderboard.csv.
|
roof is already netted in. To model a different PV setup, swap the input
|
||||||
|
data, not a CLI flag. Output: data/processed/battery_leaderboard.csv.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -19,7 +20,6 @@ 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,8 +36,6 @@ 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")
|
||||||
|
|
@ -46,15 +44,14 @@ 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_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)
|
||||||
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)
|
||||||
|
|
@ -80,8 +77,9 @@ 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: demand {args.demand:.0f} kWh/yr, avg retail €{args.retail}, "
|
print(f"Scenario: net demand {args.demand:.0f} kWh/yr, avg retail €{args.retail}, "
|
||||||
f"PV {args.pv_kwp} kWp, saldering={args.saldering}, inflation {args.inflation*100:.1f}%/yr")
|
f"existing-PV exports={'yes' if has_existing_pv else 'no'}, "
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
@ -104,7 +102,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=args.pv_kwp > 0, has_saldering=args.saldering, dynamic_rate=True,
|
has_pv=has_existing_pv, 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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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["demand_kwh"].sum() * (8766.0 / len(base))
|
annual_demand = base["raw_demand_kw"].sum() * (8766.0 / len(base))
|
||||||
df = base.copy()
|
df = base.copy()
|
||||||
df["demand_kwh"] = base["demand_kwh"] * (args.demand / annual_demand)
|
df["raw_demand_kw"] = base["raw_demand_kw"] * (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
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Run the 24h-foresight oracle for several battery configs and print a comparison.
|
"""Run the price-arbitrage 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.
|
||||||
With --pv-kwp > 0, synthesizes PV output from horizontal irradiance and treats
|
The simulator works on the raw P1 net-meter trace — whatever PV is on the
|
||||||
saldering as full export credit at consumer price (NL pre-2027).
|
roof is already netted in. Existing-PV exports show up as negative
|
||||||
|
``raw_demand_kw`` and are absorbed by the battery before crossing the meter.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -18,7 +19,6 @@ from pluginbattery.sim import (
|
||||||
load_hourly,
|
load_hourly,
|
||||||
oracle_daily_schedule,
|
oracle_daily_schedule,
|
||||||
simulate,
|
simulate,
|
||||||
synthesize_pv,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
CONFIGS = [
|
CONFIGS = [
|
||||||
|
|
@ -79,14 +79,6 @@ 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). "
|
||||||
|
|
@ -121,11 +113,12 @@ 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
|
||||||
|
|
||||||
pv_note = "no solar"
|
# Existing PV (if any) is already in raw_demand_kw via the meter signal.
|
||||||
if args.pv_kwp > 0:
|
annual_export = float(-df["raw_demand_kw"].clip(upper=0).sum() * 8766.0 / len(df))
|
||||||
df = synthesize_pv(df, kwp=args.pv_kwp, target_kwh_per_kwp_per_year=args.pv_target)
|
if annual_export > 1.0:
|
||||||
pv_kwh_year = float(df["pv_kwh"].sum() * 8766.0 / len(df))
|
pv_note = f"{annual_export:.0f} kWh/yr exported at meter (existing PV)"
|
||||||
pv_note = f"{args.pv_kwp:.1f} kWp PV, calibrated to {pv_kwh_year:.0f} kWh/yr"
|
else:
|
||||||
|
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)
|
||||||
|
|
@ -146,10 +139,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"Solar : {pv_note}\n")
|
print(f"Meter : {pv_note}\n")
|
||||||
|
|
||||||
# Headline figures with no battery, just for context.
|
# Headline figures with no battery, just for context.
|
||||||
g_no = df["demand_kwh"] - df.get("pv_kwh", 0)
|
g_no = df["raw_demand_kw"]
|
||||||
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 +
|
||||||
|
|
@ -187,7 +180,6 @@ 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),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
# PV scenario, fixed retail, no saldering:
|
# Fixed retail, no saldering:
|
||||||
python scripts/compare_with_store.py --pv-kwp 3.0 --fixed --retail 0.28
|
python scripts/compare_with_store.py --fixed --retail 0.28
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -23,7 +23,6 @@ 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,
|
||||||
|
|
@ -47,10 +46,6 @@ 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()
|
||||||
|
|
@ -59,9 +54,10 @@ 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.
|
# Demand scaling — scale the entire signed signal so existing-PV exports
|
||||||
annual_demand = base["demand_kwh"].sum() * (8766.0 / len(base))
|
# scale with the household.
|
||||||
df["demand_kwh"] = base["demand_kwh"] * (args.demand / annual_demand)
|
annual_net_demand = base["raw_demand_kw"].sum() * (8766.0 / len(base))
|
||||||
|
df["raw_demand_kw"] = base["raw_demand_kw"] * (args.demand / annual_net_demand)
|
||||||
|
|
||||||
# Retail price.
|
# Retail price.
|
||||||
if args.fixed:
|
if args.fixed:
|
||||||
|
|
@ -73,16 +69,14 @@ 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,
|
||||||
|
|
@ -102,7 +96,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=args.pv_kwp > 0,
|
has_pv=has_existing_pv,
|
||||||
has_saldering=args.saldering,
|
has_saldering=args.saldering,
|
||||||
dynamic_rate=not args.fixed,
|
dynamic_rate=not args.fixed,
|
||||||
)
|
)
|
||||||
|
|
@ -114,9 +108,8 @@ 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 : {args.pv_kwp} kWp"
|
print(f" PV : {'existing PV in meter signal' if has_existing_pv else 'no measurable existing PV'}")
|
||||||
f"{' @ ' + str(args.pv_yield) + ' kWh/kWp/yr' if args.pv_kwp else ''}")
|
print(f" Net demand : {args.demand:.0f} kWh/yr")
|
||||||
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}")
|
||||||
|
|
|
||||||
|
|
@ -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 `demand_kwh = power_w / 1000`, and a plug-in
|
demand. The simulator interprets `raw_demand_kw = power_w / 1000`, and a
|
||||||
battery's discharge cap `max(0, demand - pv)` correctly handles negative
|
plug-in battery's discharge cap `max(0, raw_demand_kw)` correctly forbids
|
||||||
demand by forbidding discharge during surplus hours.
|
discharge during net-export hours (the meter is already flowing the wrong way).
|
||||||
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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["demand_kwh"].sum() * 8766 / len(base)
|
annual = base["raw_demand_kw"].sum() * 8766 / len(base)
|
||||||
df["demand_kwh"] = base["demand_kwh"] * (args.demand_kwh / annual)
|
df["raw_demand_kw"] = base["raw_demand_kw"] * (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["demand_kwh"], # equals the original net since pv=0
|
no_bat_grid=out["raw_demand_kw"], # equals the original net since pv=0
|
||||||
cost_no_battery_eur=np.where(out["demand_kwh"] > 0,
|
cost_no_battery_eur=np.where(out["raw_demand_kw"] > 0,
|
||||||
out["demand_kwh"] * out["eur_per_kwh"],
|
out["raw_demand_kw"] * out["eur_per_kwh"],
|
||||||
out["demand_kwh"] * out["export_eur_per_kwh"]),
|
out["raw_demand_kw"] * 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["demand_kwh"]
|
net_no = r["raw_demand_kw"]
|
||||||
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['demand_kwh'].clip(lower=0).sum())
|
d_imp_no = max(0.0, day['raw_demand_kw'].clip(lower=0).sum())
|
||||||
d_exp_no = -day['demand_kwh'].clip(upper=0).sum()
|
d_exp_no = -day['raw_demand_kw'].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["demand_kwh"].clip(upper=0)).sum()
|
week_surplus = (-win["raw_demand_kw"].clip(upper=0)).sum()
|
||||||
week_imports = win["demand_kwh"].clip(lower=0).sum()
|
week_imports = win["raw_demand_kw"].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
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,22 @@
|
||||||
"""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(): solves a 24h LP per UTC day for perfect-foresight
|
- oracle_daily_schedule(): greedy self-consumption (Pass 1) + perfect-foresight
|
||||||
within-day arbitrage. No cross-day shifting.
|
per-day price arbitrage (Pass 2).
|
||||||
|
|
||||||
Anything that decides what to do (oracle, rules, ML) returns a (n, 2) schedule of
|
Anything that decides what to do 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
|
||||||
|
|
@ -17,7 +26,6 @@ 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)
|
||||||
|
|
@ -40,39 +48,6 @@ 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,
|
||||||
|
|
@ -95,17 +70,16 @@ 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/solar CSVs into one hourly DataFrame indexed by UTC timestamp.
|
"""Read prices + P1 CSVs into one hourly DataFrame indexed by UTC timestamp.
|
||||||
|
|
||||||
Drops every hour where any of the three signals is missing (inner join +
|
Drops every hour where either signal is missing (inner join + dropna).
|
||||||
dropna). Adds a derived `demand_kwh` column = power_w / 1000 (since power_w
|
Adds ``raw_demand_kw = power_w / 1000`` — the signed P1 net meter
|
||||||
is the hour mean and 1 W * 1 h = 1 Wh).
|
reading. Negative = exporting, positive = importing.
|
||||||
"""
|
"""
|
||||||
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():
|
||||||
|
|
@ -117,7 +91,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["demand_kwh"] = df["power_w"] / 1000.0
|
df["raw_demand_kw"] = df["power_w"] / 1000.0
|
||||||
return df
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -129,13 +103,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:
|
||||||
- eur_per_kwh: import price (always required)
|
- raw_demand_kw: signed meter reading (always required). +import, −export.
|
||||||
- pv_kwh: optional, hourly PV production
|
- eur_per_kwh: import price (always required).
|
||||||
- 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 = demand − pv + charge − discharge`. Cost uses the import
|
Per hour: ``g = raw_demand_kw + charge − discharge``. Cost uses the
|
||||||
price when g > 0, the export price when g < 0.
|
import 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):
|
||||||
|
|
@ -151,8 +125,7 @@ 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)
|
||||||
|
|
||||||
demand = df["demand_kwh"].to_numpy()
|
raw = df["raw_demand_kw"].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]
|
||||||
|
|
||||||
|
|
@ -166,8 +139,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 exceed the *net* demand after solar.
|
# Discharge can't take the net flow below zero.
|
||||||
d = min(d, max(0.0, demand[t] - pv[t]))
|
d = min(d, max(0.0, raw[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.
|
||||||
|
|
@ -178,8 +151,6 @@ 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
|
||||||
|
|
@ -190,7 +161,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 = (out["demand_kwh"] - out["pv_kwh"]).to_numpy()
|
g_no = raw
|
||||||
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
|
||||||
|
|
@ -201,32 +172,12 @@ def simulate(df: pd.DataFrame, battery: Battery, schedule: np.ndarray) -> pd.Dat
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def oracle_daily_schedule(df: pd.DataFrame, battery: Battery) -> np.ndarray:
|
def _pass1_reactive(df: pd.DataFrame, battery: Battery) -> tuple[np.ndarray, np.ndarray]:
|
||||||
"""Two-pass dispatch: greedy self-consumption + daily grid arbitrage.
|
"""Greedy self-consumption: charge from surplus, discharge to cover demand.
|
||||||
|
|
||||||
This matches what a modern plug-in battery with smart-charging firmware
|
Pure function of raw_demand_kw. No foresight — exactly the "default
|
||||||
(Tibber controllers, Marstek 'Smart Charging' mode, EcoFlow with dynamic
|
mode" behaviour of every plug-in battery firmware out there.
|
||||||
tariff integration) actually does in practice — and it always produces
|
Returns (schedule, soc) where soc has length n+1 (soc[0] = initial).
|
||||||
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
|
||||||
|
|
@ -234,38 +185,50 @@ def oracle_daily_schedule(df: pd.DataFrame, battery: Battery) -> np.ndarray:
|
||||||
eta = battery.round_trip_eff ** 0.5
|
eta = battery.round_trip_eff ** 0.5
|
||||||
n = len(df)
|
n = len(df)
|
||||||
|
|
||||||
demand = df["demand_kwh"].to_numpy()
|
raw = df["raw_demand_kw"].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 net[t] < -1e-9: # exporting
|
if raw[t] < -1e-9: # exporting (surplus from existing PV)
|
||||||
surplus = -net[t]
|
surplus = -raw[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 net[t] > 1e-9: # importing
|
elif raw[t] > 1e-9: # importing
|
||||||
avail = soc[t] * eta
|
avail = soc[t] * eta
|
||||||
cap_d = pd_max if battery.allows_export else net[t]
|
cap_d = pd_max if battery.allows_export else raw[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 schedule
|
return
|
||||||
inv_eta = 1.0 / eta
|
inv_eta = 1.0 / eta
|
||||||
|
|
||||||
# Index hours by UTC date (timeline is short enough that this is cheap).
|
raw = df["raw_demand_kw"].to_numpy()
|
||||||
|
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)
|
||||||
|
|
@ -279,8 +242,6 @@ def oracle_daily_schedule(df: pd.DataFrame, battery: Battery) -> np.ndarray:
|
||||||
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:
|
||||||
|
|
@ -291,25 +252,16 @@ def oracle_daily_schedule(df: pd.DataFrame, battery: Battery) -> np.ndarray:
|
||||||
if room_d < 1e-9:
|
if room_d < 1e-9:
|
||||||
continue
|
continue
|
||||||
if not battery.allows_export:
|
if not battery.allows_export:
|
||||||
# Discharge cannot exceed demand at di.
|
# Plug-in: extra discharge limited by the post-Pass-1 import demand.
|
||||||
room_d = min(room_d, net[di] - schedule[di, 1])
|
room_d = min(room_d, raw[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:
|
||||||
|
|
@ -318,130 +270,20 @@ def oracle_daily_schedule(df: pd.DataFrame, battery: Battery) -> np.ndarray:
|
||||||
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
|
||||||
# Beyond di, soc[di+1] = previous - d/eta = previous - kwh
|
# soc[di+1] onwards: +kwh*eta from charge cancels with -kwh*eta
|
||||||
# We added kwh*eta at soc[di], then removed kwh*eta on the
|
# from discharge → unchanged.
|
||||||
# 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
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,6 @@ 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")),
|
||||||
|
|
@ -267,7 +265,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 = { irr: null, soc: null, grid: null };
|
let charts = { price: null, grid: null, soc: null };
|
||||||
|
|
||||||
function openModalForBattery(row) {
|
function openModalForBattery(row) {
|
||||||
activeBattery = row;
|
activeBattery = row;
|
||||||
|
|
@ -292,7 +290,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 = { irr: null, soc: null, grid: null };
|
charts = { price: null, grid: null, soc: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
modal.querySelectorAll('[data-close="modal"]').forEach(
|
modal.querySelectorAll('[data-close="modal"]').forEach(
|
||||||
|
|
@ -325,7 +323,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,
|
||||||
pv_kwp: sc.pv_kwp, fixed_rate: sc.fixed_rate,
|
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);
|
||||||
|
|
@ -356,12 +354,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(1)} kWh`],
|
["imports", `${imports.toFixed(2)} kWh`],
|
||||||
["exports", `${exports_.toFixed(1)} kWh`],
|
["exports", `${exports_.toFixed(2)} kWh`],
|
||||||
["battery throughput", batteryOn
|
["battery throughput", batteryOn
|
||||||
? `${s.battery_charge_kwh.toFixed(1)} in / ${s.battery_discharge_kwh.toFixed(1)} out kWh`
|
? `${s.battery_charge_kwh.toFixed(2)} in / ${s.battery_discharge_kwh.toFixed(2)} out kWh`
|
||||||
: `— (battery off)`],
|
: `— (battery off)`],
|
||||||
["window saving", `€${savings.toFixed(2)}`],
|
["window saving", `€${savings.toFixed(4)}`],
|
||||||
]) {
|
]) {
|
||||||
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;
|
||||||
|
|
@ -371,18 +369,14 @@ 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 irr = d.hours.map(h => h.irradiance);
|
const price = d.hours.map(h => h.price);
|
||||||
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("irr", ["Time", "W/m²"], xs, irr, "tl-chart-irradiance", {
|
drawChart("price", ["Time", "€/kWh"], xs, price, "tl-chart-price", {
|
||||||
stroke: "#fbbf24", fill: "rgba(251, 191, 36, 0.2)",
|
stroke: "#a78bfa", fill: "rgba(167, 139, 250, 0.2)",
|
||||||
fmtY: (v) => v == null ? "" : `${v.toFixed(0)} W/m²`,
|
fmtY: (v) => v == null ? "" : `€${v.toFixed(3)}/kWh`,
|
||||||
});
|
|
||||||
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",
|
||||||
|
|
@ -390,6 +384,10 @@ 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 = {}) {
|
||||||
|
|
|
||||||
|
|
@ -238,16 +238,25 @@ 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 0.4rem; }
|
border-radius: 8px; padding: 0.6rem 0.75rem 1rem; }
|
||||||
.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: 110px; }
|
.modal-charts .chart { width: 100%; height: 150px; }
|
||||||
.modal-charts figure:nth-child(3) .chart { height: 200px; } /* taller grid panel */
|
.modal-charts figure:nth-child(4) .chart { height: 230px; } /* taller grid panel (4th now) */
|
||||||
|
|
||||||
/* 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: 0.15rem 0; }
|
.uplot .u-legend { color: var(--text); font-size: 0.75rem; padding: 1rem 0 0.6rem; }
|
||||||
.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); }
|
||||||
|
|
|
||||||
|
|
@ -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=20">
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=24">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|
@ -22,21 +22,18 @@
|
||||||
<aside id="inputs">
|
<aside id="inputs">
|
||||||
<h2>Scenario</h2>
|
<h2>Scenario</h2>
|
||||||
<form id="scenario-form">
|
<form id="scenario-form">
|
||||||
<label>Annual demand (kWh)
|
<label>Annual net draw at meter (kWh)
|
||||||
<input type="number" name="demand_kwh" min="500" max="20000" step="100" value="{{ defaults.demand_kwh }}">
|
<input type="number" name="demand_kwh" min="0" max="20000" step="1" 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>
|
||||||
|
|
@ -87,7 +84,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 · capturable surplus (= grid exports without 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 · existing PV exports at the meter (capturable by 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>
|
||||||
|
|
@ -106,10 +103,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 solar surplus the battery actually absorbed (vs total surplus shown above)">kWh capt</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_pct" data-default-dir="desc" title="Captured kWh as % of total available solar surplus">% 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="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_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_charge_saturated" data-default-dir="desc" title="% of surplus hours where charging was pegged at max kW and couldn't keep up">% sat</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>
|
||||||
</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. #}
|
||||||
|
|
@ -138,10 +135,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 capturable surplus (i.e. of grid exports that would happen without a battery) that this battery absorbs.</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>% 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>% 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>% full</dt> <dd>Of all surplus hours, the % when the battery was already full and couldn't store more — capacity-bottlenecked.</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>% sat</dt> <dd>Of all surplus hours, the % when charging was pegged at max kW and couldn't keep up — power-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>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -149,10 +146,12 @@
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<p>
|
<p>
|
||||||
LP = 24h-foresight oracle (linear-program optimal dispatch) on 2023-09 → 2024-09 EPEX, P1, and irradiance.
|
LP = greedy self-consumption + perfect-foresight per-day price arbitrage, run over the recorded
|
||||||
Store calculator is reverse-engineered from observed quotes and matches them within €0.50.
|
EPEX + P1 net-meter trace. The simulator works on what the meter shows — solar already netted in,
|
||||||
The store's dynamic-rate quote treats charge cost as raw EPEX wholesale (no VAT, no energy tax), inflating
|
no separate PV synthesis. Store calculator is reverse-engineered from observed quotes and matches
|
||||||
arbitrage savings ~2× over what any real customer with a Dutch tariff actually pays.
|
them within €0.50. The store's dynamic-rate quote treats charge cost as raw EPEX wholesale
|
||||||
|
(no VAT, no energy tax), inflating arbitrage savings ~2× over what any real customer with a
|
||||||
|
Dutch tariff actually pays.
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|
@ -171,6 +170,7 @@
|
||||||
<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>Solar irradiance — proxy for sun (W/m², from our weather station, season-aligned)</figcaption>
|
<figcaption>Consumer electricity price (€/kWh) — drives the arbitrage decisions</figcaption>
|
||||||
<div id="tl-chart-irradiance" class="chart"></div>
|
<div id="tl-chart-price" 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=20"></script>
|
<script src="{{ url_for('static', filename='app.js') }}?v=24"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,12 @@ 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
|
||||||
|
|
||||||
|
|
@ -28,7 +34,6 @@ 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,
|
||||||
|
|
@ -43,7 +48,17 @@ 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))
|
||||||
_ANNUAL_DEMAND_BASE = float(_BASE_DF["demand_kwh"].sum() * 8766.0 / len(_BASE_DF))
|
# Linear extrapolation of what the recorded window says the annual net
|
||||||
|
# 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:
|
||||||
|
|
@ -65,12 +80,7 @@ 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
|
||||||
|
|
@ -104,10 +114,11 @@ 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))
|
||||||
|
|
||||||
demand = _WORKER_DF["demand_kwh"].to_numpy()
|
# "Surplus" = hours where the meter was already exporting before any
|
||||||
pv = (_WORKER_DF["pv_kwh"].to_numpy()
|
# battery action, i.e. existing PV pushing back. The battery's job on
|
||||||
if "pv_kwh" in _WORKER_DF.columns else np.zeros(len(_WORKER_DF)))
|
# those hours is to absorb that flow before it crosses the meter.
|
||||||
surplus = np.maximum(0.0, pv - demand)
|
raw = _WORKER_DF["raw_demand_kw"].to_numpy()
|
||||||
|
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())
|
||||||
|
|
||||||
|
|
@ -117,9 +128,12 @@ 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
|
||||||
pct_saturated = float((surplus_mask & (charge >= pw - 1e-3)).sum()) / n_surplus_hours * 100.0
|
# Power bottleneck: the meter is exporting *more* than the battery's
|
||||||
|
# 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
|
||||||
|
|
@ -135,18 +149,24 @@ def _worker_run_lp(spec):
|
||||||
|
|
||||||
|
|
||||||
# ─── Core compute ─────────────────────────────────────────────────────
|
# ─── Core compute ─────────────────────────────────────────────────────
|
||||||
def _build_df(demand_kwh: float, retail: float, pv_kwp: float, pv_yield: float,
|
def _build_df(demand_kwh: float, retail: 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()
|
||||||
df["demand_kwh"] = _BASE_DF["demand_kwh"] * (demand_kwh / _ANNUAL_DEMAND_BASE)
|
# Apply the demand slider as an *additive* shift, not a multiplicative
|
||||||
|
# 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:
|
||||||
|
|
@ -160,41 +180,34 @@ 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), round(pv_kwp, 2),
|
key = (round(demand_kwh, 1), round(retail, 4),
|
||||||
round(pv_yield, 1), fixed_rate, saldering, round(eta, 3),
|
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, pv_kwp, pv_yield, fixed_rate, saldering, export_rate)
|
df = _build_df(demand_kwh, retail, 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 (e.g.,
|
# Annualisation factor: our data window may be shorter than a year.
|
||||||
# 8 months when solar coverage limits the overlap). Scale all per-window
|
# Scale all per-window extensive quantities so they represent a full year.
|
||||||
# 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)
|
||||||
|
|
||||||
# Total PV / surplus available across the whole scenario (no battery).
|
# Existing-PV surplus visible at the meter across the whole window
|
||||||
pv_col = df["pv_kwh"].to_numpy() if "pv_kwh" in df.columns else np.zeros(len(df))
|
# (hours where raw_demand_kw < 0, i.e. dad's PV exporting).
|
||||||
window_surplus = float(np.maximum(0.0, pv_col - df["demand_kwh"].to_numpy()).sum())
|
raw = df["raw_demand_kw"].to_numpy()
|
||||||
|
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:
|
||||||
|
|
@ -210,26 +223,21 @@ 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))
|
||||||
|
|
||||||
# Cache key for "what does this LP depend on?" — everything that goes
|
df_sig = (round(demand_kwh, 1), round(retail, 4),
|
||||||
# into the dataframe construction plus eta. Inflation isn't in here, so
|
fixed_rate, saldering, round(eta, 3),
|
||||||
# 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:
|
||||||
key = (df_sig, *spec)
|
cache_key = (df_sig, *spec)
|
||||||
if key in _LP_CACHE:
|
if cache_key in _LP_CACHE:
|
||||||
cached_results[spec] = _LP_CACHE[key]
|
cached_results[spec] = _LP_CACHE[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),
|
||||||
|
|
@ -248,8 +256,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"] # already a ratio
|
pct_full = payload["pct_full"]
|
||||||
pct_saturated = payload["pct_saturated"] # already a ratio
|
pct_saturated = payload["pct_saturated"]
|
||||||
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
|
||||||
|
|
@ -265,7 +273,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=pv_kwp > 0, has_saldering=saldering,
|
has_pv=total_surplus_kwh > 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)
|
||||||
|
|
@ -301,7 +309,6 @@ 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,
|
||||||
|
|
@ -321,15 +328,14 @@ def compute_leaderboard(
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
DEFAULTS = dict(
|
DEFAULTS = dict(
|
||||||
# Window: 2025-01-01 → 2025-09-01 (8 months of dad's actual data with
|
# Demand default = dad's real full-year annual net (2325 kWh per his
|
||||||
# matching 2025 prices + solar). The simulator scales demand by
|
# quote). Larger than what the partial-window data extrapolates to
|
||||||
# demand_kwh / ANNUAL_DEMAND_BASE; ANNUAL_DEMAND_BASE annualises the
|
# (~1515 kWh) because we're missing the winter months; the slider's
|
||||||
# 8-month window's 1010 kWh → 1518 kWh-equivalent/yr, so set the
|
# additive shift compensates.
|
||||||
# default to 1518 to keep the per-hour P1 values unscaled (scale = 1.0).
|
# export_rate = 0 by default. Dad's actual situation has
|
||||||
# export_rate = 0 by default to keep the model simple. Dad's actual
|
# terugleveringskosten of −€0.106/kWh; set the field explicitly to
|
||||||
# situation has terugleveringskosten of −€0.106/kWh; set the field
|
# that value when modelling his real bill.
|
||||||
# explicitly to that value when modelling his real bill.
|
demand_kwh=round(_REAL_ANNUAL_NET_DEMAND), retail=0.25,
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
|
@ -338,7 +344,13 @@ DEFAULTS = dict(
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
initial = compute_leaderboard(**DEFAULTS)
|
initial = compute_leaderboard(**DEFAULTS)
|
||||||
return render_template("index.html", initial=initial, defaults=DEFAULTS)
|
resp = app.make_response(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"])
|
||||||
|
|
@ -356,8 +368,6 @@ 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"])),
|
||||||
|
|
@ -371,12 +381,7 @@ def calculate():
|
||||||
|
|
||||||
@app.route("/api/timeline")
|
@app.route("/api/timeline")
|
||||||
def timeline():
|
def timeline():
|
||||||
"""Per-hour LP trajectory for a battery over a chosen window.
|
"""Per-hour 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"))
|
||||||
|
|
@ -387,16 +392,11 @@ 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, pv_kwp, pv_yield, fixed_rate, saldering, export_rate)
|
df = _build_df(demand_kwh, retail, 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["demand_kwh"] - win.get("pv_kwh", 0)).to_numpy()
|
g_no = win["raw_demand_kw"].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,
|
||||||
"pv_kwp": pv_kwp, "saldering": saldering,
|
"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,
|
||||||
|
|
|
||||||
|
|
@ -10,18 +10,21 @@ 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], 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")
|
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(demands_kwh) * 1000.0,
|
"power_w": np.array(raw_demands_kw) * 1000.0,
|
||||||
"irradiance_w_m2": 0.0,
|
"raw_demand_kw": raw_demands_kw,
|
||||||
"demand_kwh": demands_kwh,
|
|
||||||
},
|
},
|
||||||
index=idx,
|
index=idx,
|
||||||
)
|
)
|
||||||
|
|
@ -43,12 +46,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["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()
|
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 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
|
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
|
||||||
|
|
@ -66,23 +69,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_surplus_then_overflows():
|
def test_greedy_fills_from_meter_export_then_overflows():
|
||||||
"""With PV surplus, greedy fills the battery as fast as power allows
|
"""When the meter is already exporting (existing PV pushing back),
|
||||||
until capacity is reached, then lets the rest export."""
|
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
|
prices = [0.20] * 24
|
||||||
demand = [0.1] * 24
|
# 6 hours of net-export (−3 kW each), then 12 hours of import demand.
|
||||||
pv = [0.0] * 6 + [3.0] * 6 + [0.0] * 12 # 6 sunny hours, 3 kWh/h surplus
|
raw = [0.1] * 6 + [-3.0] * 6 + [0.1] * 12
|
||||||
df = make_df(prices, demand)
|
df = make_df(prices, raw)
|
||||||
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 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)
|
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
|
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
|
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
|
assert out["savings"].sum() < 1e-6
|
||||||
|
|
||||||
|
|
||||||
def test_synthesize_pv_hits_target_annual():
|
def test_plugin_never_pushes_to_grid_during_export_hours():
|
||||||
"""synthesize_pv should calibrate so annual output ≈ target × kWp."""
|
"""Plug-in battery during meter-export hours: discharge must be 0
|
||||||
n = 24 * 30 # 30 days
|
(battery cannot push current backwards, and the meter is already
|
||||||
idx = pd.date_range("2024-06-01", periods=n, freq="h", tz="UTC")
|
flowing the wrong way)."""
|
||||||
# Simple square wave: 600 W/m² for 8 daylight hours, zero otherwise.
|
prices = [0.30] * 24
|
||||||
irr = np.zeros(n)
|
# First 12 hours: meter is exporting (-3 kWh/h). Last 12: importing (+0.5 kWh/h).
|
||||||
for d in range(30):
|
raw = [-3.0] * 12 + [0.5] * 12
|
||||||
irr[d * 24 + 8 : d * 24 + 16] = 600.0
|
df = make_df(prices, raw)
|
||||||
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)
|
||||||
surplus_hours = out["pv_kwh"] > out["demand_kwh"]
|
export_hours = out["raw_demand_kw"] < 0
|
||||||
assert (out.loc[surplus_hours, "discharge_kwh"] == 0).all()
|
assert (out.loc[export_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 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
|
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, 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_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
|
||||||
demands = [0.5] * 24
|
# Net: small import morning, big export midday (PV peak), evening import.
|
||||||
pv = [0.0] * 8 + [3.0] * 6 + [0.0] * 10
|
raw = [0.5] * 8 + [-3.0] * 6 + [0.5] * 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(demands) * 1000.0,
|
"power_w": np.array(raw) * 1000.0,
|
||||||
"irradiance_w_m2": 0.0,
|
"raw_demand_kw": raw,
|
||||||
"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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue