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:
parent
df66a44d53
commit
91ea434912
6 changed files with 8935 additions and 8783 deletions
17548
data/raw/p1_hourly.csv
17548
data/raw/p1_hourly.csv
File diff suppressed because it is too large
Load diff
105
scripts/import_dad_p1.py
Normal file
105
scripts/import_dad_p1.py
Normal 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()
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ 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")),
|
||||
|
|
@ -61,6 +62,7 @@ function readScenario() {
|
|||
saldering: fd.get("saldering") === "true",
|
||||
eta: parseFloat(fd.get("eta")),
|
||||
inflation: parseFloat(fd.get("inflation")),
|
||||
export_rate: expRaw === null || expRaw === "" ? null : parseFloat(expRaw),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue