Use dad's real P1 data; add export-rate input for terugleveringskosten

- scripts/import_dad_p1.py converts his 15-min cumulative meter dump
  (~/Downloads/P1e-2025-1-1-2026-1-1.csv) into our hourly format.
  Net of PV exports — power_w can be negative on surplus hours.
  Aligned to our 2023-09 → 2024-09 window by (month, day, hour) so
  prices and irradiance stay seasonally matched.
- data/raw/p1_hourly.csv is now dad's data; ours preserved as
  p1_hourly_michiel.csv for diff/comparison.
- LP gE upper bound was 'pv[t]'; now max(0, pv − demand). This was
  wrong before but invisible because demand was always positive; with
  dad's net data demand goes negative and the LP went infeasible.
- New 'Export rate' field in Advanced. Override saldering toggle with
  a literal €/kWh — set to −0.106 for terugleveringskosten.
This commit is contained in:
Michiel Berger 2026-04-30 21:07:49 +02:00
parent df66a44d53
commit 91ea434912
6 changed files with 8935 additions and 8783 deletions

File diff suppressed because it is too large Load diff

105
scripts/import_dad_p1.py Normal file
View file

@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Convert dad's 15-min P1 export into our hourly p1 format.
Source: ~/Downloads/P1e-2025-1-1-2026-1-1.csv (cumulative T1/T2 import/export
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
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
battery's discharge cap `max(0, demand - pv)` correctly handles negative
demand by forbidding discharge during surplus hours.
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
(winter peaks, summer surplus) line up with our prices and irradiance.
Feb 29 in our 2024 leap year falls back to Feb 28 from dad's 2025.
Run from project root:
uv run python scripts/import_dad_p1.py
"""
from __future__ import annotations
import argparse
from pathlib import Path
import pandas as pd
def main() -> None:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--src", type=Path,
default=Path.home() / "Downloads/P1e-2025-1-1-2026-1-1.csv")
p.add_argument("--out", type=Path, default=Path("data/raw/p1_hourly.csv"))
p.add_argument("--window-start", default="2023-09-01T00:00:00Z")
p.add_argument("--window-end", default="2024-09-01T00:00:00Z")
args = p.parse_args()
print(f"Reading dad's CSV from {args.src}")
dad = pd.read_csv(args.src, parse_dates=["time"])
dad["time"] = dad["time"].dt.tz_localize(
"Europe/Amsterdam", ambiguous="infer", nonexistent="shift_forward"
)
dad = dad.set_index("time").sort_index()
# Cumulative net energy (signed). Imports add, exports subtract.
net_cum = (
dad["Import T1 kWh"] + dad["Import T2 kWh"]
- dad["Export T1 kWh"] - dad["Export T2 kWh"]
)
# Sample at exact hour boundaries (the meter records every 15 min, so the
# H:00 reading exists). Diff to get energy consumed in each hour.
hourly_stock = net_cum.resample("1h").first()
hourly_kwh = hourly_stock.diff().dropna()
# Now hourly_kwh.index[i] = end of the hour just consumed; shift to start.
hourly_kwh.index = hourly_kwh.index - pd.Timedelta(hours=1)
hourly_kwh = hourly_kwh.tz_convert("UTC")
annual = hourly_kwh.sum()
print(f"Dad's hourly net (kWh): n={len(hourly_kwh)}, "
f"annual net = {annual:.0f} kWh "
f"(min hour {hourly_kwh.min():.2f}, max {hourly_kwh.max():.2f}, "
f"{(hourly_kwh < 0).sum()} export hours)")
# Build (month, day, hour) → kWh lookup. dad's full year covers every key
# except a possible duplicate around DST (averaged).
lookup = (
hourly_kwh.groupby([
hourly_kwh.index.month,
hourly_kwh.index.day,
hourly_kwh.index.hour,
]).mean()
)
# Build target timestamps for our simulator window.
target = pd.date_range(args.window_start, args.window_end,
freq="1h", inclusive="left", tz="UTC")
out = []
missing = 0
for ts in target:
m, d, h = ts.month, ts.day, ts.hour
if (m, d, h) in lookup.index:
kwh = lookup.loc[(m, d, h)]
elif m == 2 and d == 29 and (2, 28, h) in lookup.index:
kwh = lookup.loc[(2, 28, h)]
else:
missing += 1
continue
out.append((ts, kwh * 1000.0)) # kWh per hour → mean watts
if missing:
print(f"WARNING: {missing} target hours had no dad-data match")
df_out = pd.DataFrame(out, columns=["timestamp", "power_w"])
df_out["timestamp"] = df_out["timestamp"].dt.strftime("%Y-%m-%dT%H:%M:%SZ")
args.out.parent.mkdir(parents=True, exist_ok=True)
df_out.to_csv(args.out, index=False)
print(f"Wrote {len(df_out)} hours → {args.out}")
print(f" annualised mean: {df_out['power_w'].mean():.0f} W "
f"({df_out['power_w'].sum() / 1000:.0f} kWh net per year)")
if __name__ == "__main__":
main()

View file

@ -258,12 +258,15 @@ def oracle_daily_schedule(df: pd.DataFrame, battery: Battery) -> np.ndarray:
# 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: cannot exceed PV production (plug-in) or PV + battery discharge (hybrid).
# 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, pv[t] + pd_max)) for t in range(m)]
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, pv[t])) for t in range(m)]
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]

View file

@ -52,15 +52,17 @@ function fmtPayback(p) {
function readScenario() {
const fd = new FormData(form);
const expRaw = fd.get("export_rate");
return {
demand_kwh: parseFloat(fd.get("demand_kwh")),
retail: parseFloat(fd.get("retail")),
pv_kwp: parseFloat(fd.get("pv_kwp")),
pv_yield: 875.0,
fixed_rate: fd.get("fixed_rate") === "true",
saldering: fd.get("saldering") === "true",
eta: parseFloat(fd.get("eta")),
inflation: parseFloat(fd.get("inflation")),
demand_kwh: parseFloat(fd.get("demand_kwh")),
retail: parseFloat(fd.get("retail")),
pv_kwp: parseFloat(fd.get("pv_kwp")),
pv_yield: 875.0,
fixed_rate: fd.get("fixed_rate") === "true",
saldering: fd.get("saldering") === "true",
eta: parseFloat(fd.get("eta")),
inflation: parseFloat(fd.get("inflation")),
export_rate: expRaw === null || expRaw === "" ? null : parseFloat(expRaw),
};
}

View file

@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Honest battery payback — vs thuisbatterijgids.nl</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=8">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=9">
</head>
<body>
@ -54,6 +54,10 @@
<label>Energy-price inflation
<input type="number" name="inflation" min="0" max="0.20" step="0.005" value="{{ defaults.inflation }}">
</label>
<label>Export rate (€/kWh, overrides saldering)
<input type="number" name="export_rate" step="any" placeholder="(use saldering setting)" value="{{ defaults.export_rate if defaults.export_rate is not none else '' }}">
<small>Set negative for terugleveringskosten (e.g. 0.106). Blank = use saldering toggle above.</small>
</label>
</details>
<button type="submit" id="recalc-btn">Recalculate</button>
@ -134,6 +138,6 @@
</p>
</footer>
<script src="{{ url_for('static', filename='app.js') }}?v=8"></script>
<script src="{{ url_for('static', filename='app.js') }}?v=9"></script>
</body>
</html>

View file

@ -108,7 +108,7 @@ def _worker_run_lp(spec):
# ─── Core compute ─────────────────────────────────────────────────────
def _build_df(demand_kwh: float, retail: float, pv_kwp: float, pv_yield: float,
fixed_rate: bool, saldering: bool):
fixed_rate: bool, saldering: bool, export_rate: float | None):
df = _BASE_DF.copy()
df["demand_kwh"] = _BASE_DF["demand_kwh"] * (demand_kwh / _ANNUAL_DEMAND_BASE)
if fixed_rate:
@ -119,7 +119,12 @@ def _build_df(demand_kwh: float, retail: float, pv_kwp: float, pv_yield: float,
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)
df["export_eur_per_kwh"] = df["eur_per_kwh"] if saldering else 0.0
if export_rate is not None:
df["export_eur_per_kwh"] = float(export_rate)
elif saldering:
df["export_eur_per_kwh"] = df["eur_per_kwh"]
else:
df["export_eur_per_kwh"] = 0.0
return df
@ -133,15 +138,17 @@ def compute_leaderboard(
saldering: bool,
eta: float,
inflation: float,
export_rate: float | None = None,
) -> dict:
key = (round(demand_kwh, 1), round(retail, 4), round(pv_kwp, 2),
round(pv_yield, 1), fixed_rate, saldering, round(eta, 3),
round(inflation, 4))
round(inflation, 4),
None if export_rate is None else round(export_rate, 4))
if key in _RESULT_CACHE:
return _RESULT_CACHE[key]
t0 = time.time()
df = _build_df(demand_kwh, retail, pv_kwp, pv_yield, fixed_rate, saldering)
df = _build_df(demand_kwh, retail, pv_kwp, pv_yield, fixed_rate, saldering, export_rate)
avg_epex = float(df["epex_eur_per_kwh"].mean())
store_params = StoreParams(avg_epex_eur_per_kwh=avg_epex)
@ -167,7 +174,8 @@ def compute_leaderboard(
# into the dataframe construction plus eta. Inflation isn't in here, so
# tweaking only inflation hits the cache for every catalog entry.
df_sig = (round(demand_kwh, 1), round(retail, 4), round(pv_kwp, 2),
round(pv_yield, 1), fixed_rate, saldering, round(eta, 3))
round(pv_yield, 1), fixed_rate, saldering, round(eta, 3),
None if export_rate is None else round(export_rate, 4))
cached_results = {}
todo = []
@ -261,6 +269,7 @@ app = Flask(__name__)
DEFAULTS = dict(
demand_kwh=4000.0, retail=0.25, pv_kwp=3.0, pv_yield=875.0,
fixed_rate=False, saldering=False, eta=0.88, inflation=0.03,
export_rate=None,
)
@ -273,6 +282,14 @@ def index():
@app.route("/api/calculate", methods=["POST"])
def calculate():
p = request.get_json(silent=True) or {}
raw_export = p.get("export_rate")
if raw_export in (None, "", "null"):
export_rate = None
else:
try:
export_rate = float(raw_export)
except (ValueError, TypeError):
return jsonify({"error": f"bad export_rate: {raw_export!r}"}), 400
try:
result = compute_leaderboard(
demand_kwh=float(p.get("demand_kwh", DEFAULTS["demand_kwh"])),
@ -283,6 +300,7 @@ def calculate():
saldering=bool(p.get("saldering", DEFAULTS["saldering"])),
eta=float(p.get("eta", DEFAULTS["eta"])),
inflation=float(p.get("inflation", DEFAULTS["inflation"])),
export_rate=export_rate,
)
except (ValueError, TypeError) as e:
return jsonify({"error": str(e)}), 400