battery simulator
All checks were successful
deploy / deploy (push) Successful in 13s
test / smoke-test (push) Successful in 3s

This commit is contained in:
Dusan Vojacek
2026-04-12 22:24:32 +02:00
parent f0dfcefd54
commit 3da738e7e9
3 changed files with 785 additions and 0 deletions

View File

@@ -0,0 +1,368 @@
#!/usr/bin/env python3
"""
Ekonomický screening velikosti baterie (15min, jednobusová energie).
Typicky BA81: fixní nákup + prodej spot, limity výkonu z baterie min(0,5C, střídač),
export/import podle připojení. Načte OTE z Postgres (stejná DB jako EMS) nebo z CSV.
Příklad:
python3 scripts/analysis/battery_sizing_screen.py \\
--db \\
--date-from 2024-04-01 --date-to 2026-04-01 \\
--battery-kwh 12.5 32 48 \\
--load-kw 1.2 \\
--pv-daily-kwh-summer 55 --pv-daily-kwh-winter 12 \\
--sell-margin-fixed -0.02 \\
--buy-vat-kwh 4.443 \\
--capex-per-kwh 9000
Vyžaduje: pip install pulp (volitelně psycopg2 pro --db).
Omezení modelu: syntetický denní tvar FVE (kalibruj --pv-daily-kwh-* podle měření);
mikroinvertory / GEN nejsou; zelený bonus není v účelové funkci; nákup je jedna flat
sazba vč. DPH (reálné NT/VT přes HDO přidej později). Výsledek = screening, ne nabídka.
"""
from __future__ import annotations
import argparse
import csv
import math
import os
import sys
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from typing import Iterable, Sequence
try:
import pulp
except ImportError:
print("Instaluj PuLP: pip install pulp", file=sys.stderr)
raise
DT_H = 0.25 # 15 min
SLOTS_PER_DAY = 96
@dataclass
class SiteLimits:
max_export_w: float = 16_000.0
max_import_w: float = 17_000.0
inv_batt_max_w: float = 12_000.0 # strop střídače z baterie / nabíjení
c_rate: float = 0.5 # P_batt = min(c_rate * E_kWh * 1000, inv_batt_max_w)
eta_charge: float = 0.95
eta_discharge: float = 0.95
soc_min_frac: float = 0.10
soc_max_frac: float = 0.95
def batt_power_cap_w(usable_kwh: float, site: SiteLimits) -> float:
return min(site.c_rate * usable_kwh * 1000.0, site.inv_batt_max_w)
def summer_day(d: date) -> bool:
m = d.month
return m >= 4 and m <= 9
def pv_shape_96() -> list[float]:
"""Nenormalizovaný denní tvar (96 slotů), plocha = 1 po normalizaci."""
w = [0.0] * SLOTS_PER_DAY
for t in range(SLOTS_PER_DAY):
h = t / 4.0 # hodiny od půlnoci
if 5.5 <= h <= 20.5:
w[t] = max(0.0, math.sin(math.pi * (h - 5.5) / 15.0)) ** 1.2
else:
w[t] = 0.0
s = sum(w)
if s <= 0:
return [1.0 / SLOTS_PER_DAY] * SLOTS_PER_DAY
return [x / s for x in w]
def daily_pv_wh(d: date, summer_kwh: float, winter_kwh: float, shape: Sequence[float]) -> list[float]:
base = summer_kwh if summer_day(d) else winter_kwh
return [base * 1000.0 * sh for sh in shape]
def daily_load_wh(load_kw: float) -> list[float]:
e_per_slot = load_kw * 1000.0 * DT_H
return [e_per_slot] * SLOTS_PER_DAY
def effective_sell_kc_kwh(raw_ote: float, margin_fixed: float, margin_pct: float) -> float:
return raw_ote + margin_fixed + (raw_ote * margin_pct / 100.0)
def load_prices_csv(path: str) -> list[tuple[datetime, float]]:
out: list[tuple[datetime, float]] = []
with open(path, newline="", encoding="utf-8") as f:
r = csv.DictReader(f)
for row in r:
ts = datetime.fromisoformat(row["interval_start"].replace("Z", "+00:00"))
px = float(row["sell_raw_price_czk_kwh"])
out.append((ts, px))
out.sort(key=lambda x: x[0])
return out
def load_prices_db(date_from: date, date_to: date) -> list[tuple[datetime, float]]:
from datetime import timezone
try:
import psycopg2
except ImportError as e:
raise SystemExit("Pro --db instaluj psycopg2-binary nebo použij --price-csv") from e
from zoneinfo import ZoneInfo
prg = ZoneInfo("Europe/Prague")
t0 = datetime.combine(date_from, datetime.min.time(), tzinfo=prg).astimezone(timezone.utc)
t1 = datetime.combine(date_to, datetime.min.time(), tzinfo=prg).astimezone(timezone.utc)
conn = psycopg2.connect(
host=os.environ.get("PGHOST", "127.0.0.1"),
port=int(os.environ.get("PGPORT", "5432")),
dbname=os.environ.get("PGDATABASE", "ems"),
user=os.environ.get("PGUSER", os.environ.get("DB_USER", "ems_user")),
password=os.environ.get("PGPASSWORD", os.environ.get("DB_PASSWORD", "")),
)
cur = conn.cursor()
cur.execute(
"""
SELECT interval_start, sell_raw_price_czk_kwh::float
FROM ems.market_interval_price
WHERE market_source = 'OTE_CZ'
AND interval_start >= %s
AND interval_start < %s
ORDER BY interval_start
""",
(t0, t1),
)
rows = cur.fetchall()
conn.close()
return [(r[0], float(r[1])) for r in rows]
def prices_by_calendar_day(
series: list[tuple[datetime, float]],
) -> dict[date, list[float]]:
"""96 hodnot Kč/kWh (raw OTE) na kalendářní den Europe/Prague."""
from zoneinfo import ZoneInfo
prg = ZoneInfo("Europe/Prague")
buckets: dict[date, dict[int, float]] = {}
for ts, px in series:
local = ts.astimezone(prg)
d = local.date()
slot = local.hour * 4 + local.minute // 15
buckets.setdefault(d, {})[slot] = px
out: dict[date, list[float]] = {}
for d, mp in buckets.items():
if len(mp) < SLOTS_PER_DAY:
continue
out[d] = [mp[i] for i in range(SLOTS_PER_DAY)]
return out
def solve_one_day(
pv_wh: Sequence[float],
load_wh: Sequence[float],
p_sell: Sequence[float],
p_buy_flat: float,
e_usable_wh: float,
p_batt_w: float,
site: SiteLimits,
soc_start_wh: float,
) -> tuple[float, float, float, float]:
"""
Vrátí (cash_kc, soc_end_wh, curtailed_wh, discharged_wh_sum).
cash = příjem z exportu nákup z DS (jen energie, Kč).
"""
e_min = site.soc_min_frac * e_usable_wh
e_max = site.soc_max_frac * e_usable_wh
max_ch = p_batt_w * DT_H
max_dis = p_batt_w * DT_H
max_exp = site.max_export_w * DT_H
max_imp = site.max_import_w * DT_H
prob = pulp.LpProblem("ems_day", pulp.LpMaximize)
soc = pulp.LpVariable.dicts("soc", range(SLOTS_PER_DAY + 1), lowBound=e_min, upBound=e_max)
ch = pulp.LpVariable.dicts("ch", range(SLOTS_PER_DAY), lowBound=0)
dis = pulp.LpVariable.dicts("dis", range(SLOTS_PER_DAY), lowBound=0)
gexp = pulp.LpVariable.dicts("gexp", range(SLOTS_PER_DAY), lowBound=0)
gimp = pulp.LpVariable.dicts("gimp", range(SLOTS_PER_DAY), lowBound=0)
curt = pulp.LpVariable.dicts("curt", range(SLOTS_PER_DAY), lowBound=0)
prob += soc[0] == soc_start_wh
obj = []
for t in range(SLOTS_PER_DAY):
prob += ch[t] <= max_ch
prob += dis[t] <= max_dis
prob += gexp[t] <= max_exp
prob += gimp[t] <= max_imp
prob += curt[t] <= pv_wh[t]
prob += (
pv_wh[t] - curt[t] + dis[t] + gimp[t] == load_wh[t] + ch[t] + gexp[t]
), f"balance_{t}"
prob += (
soc[t + 1]
== soc[t] + site.eta_charge * ch[t] - dis[t] / site.eta_discharge
), f"socdyn_{t}"
obj.append(p_sell[t] * gexp[t] / 1000.0 - p_buy_flat * gimp[t] / 1000.0)
prob += pulp.lpSum(obj)
solver = pulp.PULP_CBC_CMD(msg=False, timeLimit=60)
prob.solve(solver)
if prob.status != pulp.LpStatusOptimal:
raise RuntimeError(f"LP status {pulp.LpStatus[prob.status]}")
cash = float(pulp.value(prob.objective))
soc_end = float(pulp.value(soc[SLOTS_PER_DAY]))
curt_total = sum(float(pulp.value(curt[t])) for t in range(SLOTS_PER_DAY))
dis_total = sum(float(pulp.value(dis[t])) for t in range(SLOTS_PER_DAY))
return cash, soc_end, curt_total, dis_total
def simulate_year(
days: Iterable[date],
px_day: dict[date, list[float]],
usable_kwh: float,
site: SiteLimits,
sell_margin_fixed: float,
sell_margin_pct: float,
buy_vat_kwh: float,
summer_kwh: float,
winter_kwh: float,
load_kw: float,
shape: Sequence[float],
) -> dict[str, float]:
e_wh = usable_kwh * 1000.0
p_batt = batt_power_cap_w(usable_kwh, site)
load_wh = daily_load_wh(load_kw)
cash_total = 0.0
curt_total = 0.0
dis_total = 0.0
soc_state = 0.5 * (site.soc_min_frac + site.soc_max_frac) * e_wh
n_days = 0
for d in days:
if d not in px_day:
continue
raw = px_day[d]
p_sell = [effective_sell_kc_kwh(x, sell_margin_fixed, sell_margin_pct) for x in raw]
pv_wh = daily_pv_wh(d, summer_kwh, winter_kwh, shape)
cash, soc_state, curt, dis = solve_one_day(
pv_wh, load_wh, p_sell, buy_vat_kwh, e_wh, p_batt, site, soc_state
)
cash_total += cash
curt_total += curt
dis_total += dis
n_days += 1
feq = (dis_total / e_wh / n_days) if n_days and e_wh > 0 else 0.0
return {
"cash_kc": cash_total,
"days": float(n_days),
"curt_wh": curt_total,
"dis_wh": dis_total,
"feq_cycles_per_day": feq,
}
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--db", action="store_true", help="Načti OTE z Postgres (env PG* / DB_*)")
ap.add_argument("--price-csv", type=str, default="", help="CSV: interval_start, sell_raw_price_czk_kwh")
ap.add_argument("--date-from", type=str, required=True)
ap.add_argument("--date-to", type=str, required=True)
ap.add_argument("--battery-kwh", type=float, nargs="+", required=True, help="Užitkové kWh (např. 12.5 32 48)")
ap.add_argument("--load-kw", type=float, default=1.0, help="Průměrný odběr (konstanta přes den)")
ap.add_argument("--pv-daily-kwh-summer", type=float, default=50.0)
ap.add_argument("--pv-daily-kwh-winter", type=float, default=10.0)
ap.add_argument("--sell-margin-fixed", type=float, default=-0.02)
ap.add_argument("--sell-margin-pct", type=float, default=0.0)
ap.add_argument("--buy-vat-kwh", type=float, default=4.443, help="Efektivní nákup Kč/kWh vč. DPH (flat screening)")
ap.add_argument("--max-export-w", type=float, default=16_000.0)
ap.add_argument("--max-import-w", type=float, default=17_000.0)
ap.add_argument("--inv-batt-max-w", type=float, default=12_000.0)
ap.add_argument("--c-rate", type=float, default=0.5)
ap.add_argument("--capex-per-kwh", type=float, default=0.0, help="CAPEX za 1 kWh rozšíření; vypíše jednoduchou návratnost vs. nejmenší baterie")
args = ap.parse_args()
d0 = date.fromisoformat(args.date_from)
d1 = date.fromisoformat(args.date_to)
if args.db:
series = load_prices_db(d0, d1)
elif args.price_csv:
series = load_prices_csv(args.price_csv)
else:
ap.error("Zadej --db nebo --price-csv")
px_day = prices_by_calendar_day(series)
shape = pv_shape_96()
site = SiteLimits(
max_export_w=args.max_export_w,
max_import_w=args.max_import_w,
inv_batt_max_w=args.inv_batt_max_w,
c_rate=args.c_rate,
)
day_list = [d0 + timedelta(days=i) for i in range((d1 - d0).days)]
results = []
for kwh in sorted(args.battery_kwh):
r = simulate_year(
day_list,
px_day,
kwh,
site,
args.sell_margin_fixed,
args.sell_margin_pct,
args.buy_vat_kwh,
args.pv_daily_kwh_summer,
args.pv_daily_kwh_winter,
args.load_kw,
shape,
)
results.append((kwh, r))
baseline_kwh = min(args.battery_kwh)
base = dict(results)[baseline_kwh]
print("Parametry: prodej = OTE + sell_margin_fixed (+ %), nákup = flat buy_vat_kwh")
print(f" FVE tvar = syntetický den, léto {args.pv_daily_kwh_summer} kWh/d, zima {args.pv_daily_kwh_winter} kWh/d, load {args.load_kw} kW")
print(f" Limity: export {args.max_export_w} W, import {args.max_import_w} W, P_batt = min({args.c_rate}*E_kWh, {args.inv_batt_max_w} W)")
print()
print(f"{'kWh':>8} {'P_batt_kW':>10} {'cash_kc/rok':>14} {'Δ vs min':>12} {'curt_MWh/y':>12} {'Feq/den':>8}")
for kwh, r in results:
pkw = batt_power_cap_w(kwh, site) / 1000.0
days = max(int(r["days"]), 1)
cash_y = r["cash_kc"] * (365.0 / days)
curt_mwh = r["curt_wh"] / 1e6 * (365.0 / days)
delta = cash_y - base["cash_kc"] * (365.0 / days) if kwh != baseline_kwh else 0.0
print(
f"{kwh:8.1f} {pkw:10.2f} {cash_y:14.0f} {delta:12.0f} {curt_mwh:12.2f} {r['feq_cycles_per_day']:8.2f}"
)
if args.capex_per_kwh > 0:
print()
base_cash = base["cash_kc"] * (365.0 / max(int(base["days"]), 1))
for kwh, r in results:
if kwh <= baseline_kwh:
continue
cash_y = r["cash_kc"] * (365.0 / max(int(r["days"]), 1))
delta = cash_y - base_cash
extra_kwh = kwh - baseline_kwh
capex = extra_kwh * args.capex_per_kwh
if delta > 0:
years = capex / delta
print(
f"vs {baseline_kwh} kWh → +{extra_kwh:.0f} kWh CAPEX ~{capex:,.0f} Kč, "
f"odhad +{delta:,.0f} Kč/rok → návratnost ~{years:.1f} r"
)
else:
print(f"vs {baseline_kwh} kWh → +{extra_kwh:.0f} kWh: model neukazuje vyšší roční cash ({delta:,.0f} Kč/rok)")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,288 @@
2026-04-09,00:00,2.6720
2026-04-09,00:15,2.5505
2026-04-09,00:30,2.4905
2026-04-09,00:45,2.4618
2026-04-09,01:00,2.4598
2026-04-09,01:15,2.4830
2026-04-09,01:30,2.4660
2026-04-09,01:45,2.4603
2026-04-09,02:00,2.5270
2026-04-09,02:15,2.4995
2026-04-09,02:30,2.5213
2026-04-09,02:45,2.5305
2026-04-09,03:00,2.5265
2026-04-09,03:15,2.5483
2026-04-09,03:30,2.5655
2026-04-09,03:45,2.6203
2026-04-09,04:00,2.5300
2026-04-09,04:15,2.6015
2026-04-09,04:30,2.6713
2026-04-09,04:45,2.8440
2026-04-09,05:00,2.6540
2026-04-09,05:15,2.7750
2026-04-09,05:30,3.0300
2026-04-09,05:45,3.2593
2026-04-09,06:00,2.9765
2026-04-09,06:15,3.4128
2026-04-09,06:30,3.7728
2026-04-09,06:45,3.8188
2026-04-09,07:00,3.9350
2026-04-09,07:15,3.8950
2026-04-09,07:30,3.6658
2026-04-09,07:45,3.2233
2026-04-09,08:00,3.9688
2026-04-09,08:15,3.3950
2026-04-09,08:30,2.9795
2026-04-09,08:45,2.4095
2026-04-09,09:00,3.1465
2026-04-09,09:15,2.6005
2026-04-09,09:30,2.1475
2026-04-09,09:45,1.6428
2026-04-09,10:00,2.0153
2026-04-09,10:15,1.6153
2026-04-09,10:30,0.9560
2026-04-09,10:45,0.2468
2026-04-09,11:00,0.1235
2026-04-09,11:15,0.0003
2026-04-09,11:30,0.0000
2026-04-09,11:45,-0.0003
2026-04-09,12:00,-0.0003
2026-04-09,12:15,-0.0025
2026-04-09,12:30,-0.0313
2026-04-09,12:45,-0.0760
2026-04-09,13:00,-0.0808
2026-04-09,13:15,-0.1385
2026-04-09,13:30,-0.2323
2026-04-09,13:45,-0.3113
2026-04-09,14:00,-0.2458
2026-04-09,14:15,-0.2328
2026-04-09,14:30,-0.1720
2026-04-09,14:45,-0.1265
2026-04-09,15:00,-0.0748
2026-04-09,15:15,-0.0053
2026-04-09,15:30,0.0035
2026-04-09,15:45,0.0003
2026-04-09,16:00,0.0003
2026-04-09,16:15,0.1078
2026-04-09,16:30,0.9075
2026-04-09,16:45,1.7903
2026-04-09,17:00,0.7188
2026-04-09,17:15,1.9618
2026-04-09,17:30,2.6005
2026-04-09,17:45,3.1290
2026-04-09,18:00,2.4015
2026-04-09,18:15,2.8648
2026-04-09,18:30,3.1983
2026-04-09,18:45,3.4253
2026-04-09,19:00,3.4435
2026-04-09,19:15,3.6148
2026-04-09,19:30,3.8523
2026-04-09,19:45,3.8893
2026-04-09,20:00,4.1890
2026-04-09,20:15,3.8510
2026-04-09,20:30,3.5613
2026-04-09,20:45,3.1660
2026-04-09,21:00,3.3128
2026-04-09,21:15,3.0373
2026-04-09,21:30,2.6313
2026-04-09,21:45,2.2740
2026-04-09,22:00,2.9655
2026-04-09,22:15,2.7910
2026-04-09,22:30,2.5323
2026-04-09,22:45,2.2793
2026-04-09,23:00,2.4410
2026-04-09,23:15,2.2813
2026-04-09,23:30,2.1535
2026-04-09,23:45,1.9268
2026-04-11,00:00,3.5698
2026-04-11,00:15,3.2533
2026-04-11,00:30,2.9323
2026-04-11,00:45,2.7985
2026-04-11,01:00,3.2713
2026-04-11,01:15,3.1870
2026-04-11,01:30,2.9530
2026-04-11,01:45,2.9568
2026-04-11,02:00,2.9888
2026-04-11,02:15,2.9133
2026-04-11,02:30,2.9605
2026-04-11,02:45,2.9473
2026-04-11,03:00,3.0010
2026-04-11,03:15,2.9330
2026-04-11,03:30,2.9580
2026-04-11,03:45,2.8618
2026-04-11,04:00,2.9760
2026-04-11,04:15,2.9570
2026-04-11,04:30,2.9235
2026-04-11,04:45,2.8015
2026-04-11,05:00,2.9408
2026-04-11,05:15,2.8243
2026-04-11,05:30,2.8233
2026-04-11,05:45,2.7850
2026-04-11,06:00,2.8100
2026-04-11,06:15,2.8503
2026-04-11,06:30,2.8383
2026-04-11,06:45,2.6828
2026-04-11,07:00,2.9005
2026-04-11,07:15,2.8145
2026-04-11,07:30,2.4048
2026-04-11,07:45,1.7628
2026-04-11,08:00,2.7298
2026-04-11,08:15,2.3223
2026-04-11,08:30,1.6730
2026-04-11,08:45,0.5990
2026-04-11,09:00,1.4445
2026-04-11,09:15,0.5615
2026-04-11,09:30,0.2000
2026-04-11,09:45,0.0003
2026-04-11,10:00,0.1933
2026-04-11,10:15,0.0000
2026-04-11,10:30,-0.0305
2026-04-11,10:45,-0.2223
2026-04-11,11:00,-0.0308
2026-04-11,11:15,0.0000
2026-04-11,11:30,0.0250
2026-04-11,11:45,0.1450
2026-04-11,12:00,-0.4685
2026-04-11,12:15,-1.0418
2026-04-11,12:30,-0.9168
2026-04-11,12:45,-1.2465
2026-04-11,13:00,-1.5560
2026-04-11,13:15,-1.7465
2026-04-11,13:30,-2.1328
2026-04-11,13:45,-2.4545
2026-04-11,14:00,-1.8465
2026-04-11,14:15,-1.8458
2026-04-11,14:30,-1.7578
2026-04-11,14:45,-1.4893
2026-04-11,15:00,-1.7045
2026-04-11,15:15,-1.3535
2026-04-11,15:30,-1.0890
2026-04-11,15:45,-0.6923
2026-04-11,16:00,-0.7728
2026-04-11,16:15,-0.3718
2026-04-11,16:30,-0.2075
2026-04-11,16:45,-0.3675
2026-04-11,17:00,0.3510
2026-04-11,17:15,-0.1903
2026-04-11,17:30,-0.0250
2026-04-11,17:45,0.1198
2026-04-11,18:00,-0.4573
2026-04-11,18:15,1.8173
2026-04-11,18:30,2.3750
2026-04-11,18:45,2.9240
2026-04-11,19:00,2.3338
2026-04-11,19:15,2.6563
2026-04-11,19:30,2.8090
2026-04-11,19:45,3.0140
2026-04-11,20:00,2.8350
2026-04-11,20:15,2.4450
2026-04-11,20:30,2.4565
2026-04-11,20:45,2.2635
2026-04-11,21:00,3.0835
2026-04-11,21:15,2.1543
2026-04-11,21:30,1.9325
2026-04-11,21:45,1.8295
2026-04-11,22:00,3.3630
2026-04-11,22:15,2.5945
2026-04-11,22:30,1.7628
2026-04-11,22:45,1.4008
2026-04-11,23:00,2.4143
2026-04-11,23:15,1.7565
2026-04-11,23:30,1.5728
2026-04-11,23:45,1.5035
2026-04-12,00:00,1.5390
2026-04-12,00:15,1.5708
2026-04-12,00:30,1.7003
2026-04-12,00:45,1.7023
2026-04-12,01:00,1.7770
2026-04-12,01:15,1.7503
2026-04-12,01:30,1.8833
2026-04-12,01:45,2.0250
2026-04-12,02:00,1.8593
2026-04-12,02:15,1.9398
2026-04-12,02:30,1.9483
2026-04-12,02:45,2.0395
2026-04-12,03:00,2.0418
2026-04-12,03:15,2.1465
2026-04-12,03:30,2.1558
2026-04-12,03:45,2.2473
2026-04-12,04:00,2.3520
2026-04-12,04:15,2.4003
2026-04-12,04:30,2.4923
2026-04-12,04:45,2.5645
2026-04-12,05:00,2.4895
2026-04-12,05:15,2.5743
2026-04-12,05:30,2.5745
2026-04-12,05:45,2.5400
2026-04-12,06:00,2.6265
2026-04-12,06:15,2.6030
2026-04-12,06:30,2.5383
2026-04-12,06:45,2.4885
2026-04-12,07:00,2.7490
2026-04-12,07:15,2.5110
2026-04-12,07:30,2.3325
2026-04-12,07:45,2.2773
2026-04-12,08:00,2.1050
2026-04-12,08:15,1.8573
2026-04-12,08:30,1.7498
2026-04-12,08:45,1.3183
2026-04-12,09:00,1.4575
2026-04-12,09:15,1.3108
2026-04-12,09:30,0.8415
2026-04-12,09:45,0.6773
2026-04-12,10:00,0.8375
2026-04-12,10:15,0.4075
2026-04-12,10:30,0.3775
2026-04-12,10:45,0.3193
2026-04-12,11:00,0.4165
2026-04-12,11:15,0.3638
2026-04-12,11:30,0.3085
2026-04-12,11:45,0.2470
2026-04-12,12:00,0.2023
2026-04-12,12:15,0.1435
2026-04-12,12:30,0.1298
2026-04-12,12:45,0.0615
2026-04-12,13:00,0.0473
2026-04-12,13:15,0.0173
2026-04-12,13:30,0.0015
2026-04-12,13:45,0.0013
2026-04-12,14:00,0.0000
2026-04-12,14:15,0.0278
2026-04-12,14:30,0.0688
2026-04-12,14:45,0.0628
2026-04-12,15:00,0.1483
2026-04-12,15:15,0.2505
2026-04-12,15:30,0.4290
2026-04-12,15:45,0.7538
2026-04-12,16:00,0.8148
2026-04-12,16:15,1.4820
2026-04-12,16:30,1.5620
2026-04-12,16:45,2.4738
2026-04-12,17:00,1.5428
2026-04-12,17:15,2.2933
2026-04-12,17:30,3.1040
2026-04-12,17:45,3.4375
2026-04-12,18:00,2.8968
2026-04-12,18:15,3.1310
2026-04-12,18:30,3.4105
2026-04-12,18:45,3.6880
2026-04-12,19:00,3.3903
2026-04-12,19:15,3.3798
2026-04-12,19:30,3.6125
2026-04-12,19:45,3.9263
2026-04-12,20:00,3.7030
2026-04-12,20:15,3.6903
2026-04-12,20:30,3.6473
2026-04-12,20:45,3.5030
2026-04-12,21:00,3.5230
2026-04-12,21:15,3.3825
2026-04-12,21:30,3.4325
2026-04-12,21:45,3.3353
2026-04-12,22:00,3.4898
2026-04-12,22:15,3.3675
2026-04-12,22:30,3.2180
2026-04-12,22:45,2.9855
2026-04-12,23:00,3.3763
2026-04-12,23:15,2.9848
2026-04-12,23:30,2.9855
2026-04-12,23:45,2.7558
1 2026-04-09 00:00 2.6720
2 2026-04-09 00:15 2.5505
3 2026-04-09 00:30 2.4905
4 2026-04-09 00:45 2.4618
5 2026-04-09 01:00 2.4598
6 2026-04-09 01:15 2.4830
7 2026-04-09 01:30 2.4660
8 2026-04-09 01:45 2.4603
9 2026-04-09 02:00 2.5270
10 2026-04-09 02:15 2.4995
11 2026-04-09 02:30 2.5213
12 2026-04-09 02:45 2.5305
13 2026-04-09 03:00 2.5265
14 2026-04-09 03:15 2.5483
15 2026-04-09 03:30 2.5655
16 2026-04-09 03:45 2.6203
17 2026-04-09 04:00 2.5300
18 2026-04-09 04:15 2.6015
19 2026-04-09 04:30 2.6713
20 2026-04-09 04:45 2.8440
21 2026-04-09 05:00 2.6540
22 2026-04-09 05:15 2.7750
23 2026-04-09 05:30 3.0300
24 2026-04-09 05:45 3.2593
25 2026-04-09 06:00 2.9765
26 2026-04-09 06:15 3.4128
27 2026-04-09 06:30 3.7728
28 2026-04-09 06:45 3.8188
29 2026-04-09 07:00 3.9350
30 2026-04-09 07:15 3.8950
31 2026-04-09 07:30 3.6658
32 2026-04-09 07:45 3.2233
33 2026-04-09 08:00 3.9688
34 2026-04-09 08:15 3.3950
35 2026-04-09 08:30 2.9795
36 2026-04-09 08:45 2.4095
37 2026-04-09 09:00 3.1465
38 2026-04-09 09:15 2.6005
39 2026-04-09 09:30 2.1475
40 2026-04-09 09:45 1.6428
41 2026-04-09 10:00 2.0153
42 2026-04-09 10:15 1.6153
43 2026-04-09 10:30 0.9560
44 2026-04-09 10:45 0.2468
45 2026-04-09 11:00 0.1235
46 2026-04-09 11:15 0.0003
47 2026-04-09 11:30 0.0000
48 2026-04-09 11:45 -0.0003
49 2026-04-09 12:00 -0.0003
50 2026-04-09 12:15 -0.0025
51 2026-04-09 12:30 -0.0313
52 2026-04-09 12:45 -0.0760
53 2026-04-09 13:00 -0.0808
54 2026-04-09 13:15 -0.1385
55 2026-04-09 13:30 -0.2323
56 2026-04-09 13:45 -0.3113
57 2026-04-09 14:00 -0.2458
58 2026-04-09 14:15 -0.2328
59 2026-04-09 14:30 -0.1720
60 2026-04-09 14:45 -0.1265
61 2026-04-09 15:00 -0.0748
62 2026-04-09 15:15 -0.0053
63 2026-04-09 15:30 0.0035
64 2026-04-09 15:45 0.0003
65 2026-04-09 16:00 0.0003
66 2026-04-09 16:15 0.1078
67 2026-04-09 16:30 0.9075
68 2026-04-09 16:45 1.7903
69 2026-04-09 17:00 0.7188
70 2026-04-09 17:15 1.9618
71 2026-04-09 17:30 2.6005
72 2026-04-09 17:45 3.1290
73 2026-04-09 18:00 2.4015
74 2026-04-09 18:15 2.8648
75 2026-04-09 18:30 3.1983
76 2026-04-09 18:45 3.4253
77 2026-04-09 19:00 3.4435
78 2026-04-09 19:15 3.6148
79 2026-04-09 19:30 3.8523
80 2026-04-09 19:45 3.8893
81 2026-04-09 20:00 4.1890
82 2026-04-09 20:15 3.8510
83 2026-04-09 20:30 3.5613
84 2026-04-09 20:45 3.1660
85 2026-04-09 21:00 3.3128
86 2026-04-09 21:15 3.0373
87 2026-04-09 21:30 2.6313
88 2026-04-09 21:45 2.2740
89 2026-04-09 22:00 2.9655
90 2026-04-09 22:15 2.7910
91 2026-04-09 22:30 2.5323
92 2026-04-09 22:45 2.2793
93 2026-04-09 23:00 2.4410
94 2026-04-09 23:15 2.2813
95 2026-04-09 23:30 2.1535
96 2026-04-09 23:45 1.9268
97 2026-04-11 00:00 3.5698
98 2026-04-11 00:15 3.2533
99 2026-04-11 00:30 2.9323
100 2026-04-11 00:45 2.7985
101 2026-04-11 01:00 3.2713
102 2026-04-11 01:15 3.1870
103 2026-04-11 01:30 2.9530
104 2026-04-11 01:45 2.9568
105 2026-04-11 02:00 2.9888
106 2026-04-11 02:15 2.9133
107 2026-04-11 02:30 2.9605
108 2026-04-11 02:45 2.9473
109 2026-04-11 03:00 3.0010
110 2026-04-11 03:15 2.9330
111 2026-04-11 03:30 2.9580
112 2026-04-11 03:45 2.8618
113 2026-04-11 04:00 2.9760
114 2026-04-11 04:15 2.9570
115 2026-04-11 04:30 2.9235
116 2026-04-11 04:45 2.8015
117 2026-04-11 05:00 2.9408
118 2026-04-11 05:15 2.8243
119 2026-04-11 05:30 2.8233
120 2026-04-11 05:45 2.7850
121 2026-04-11 06:00 2.8100
122 2026-04-11 06:15 2.8503
123 2026-04-11 06:30 2.8383
124 2026-04-11 06:45 2.6828
125 2026-04-11 07:00 2.9005
126 2026-04-11 07:15 2.8145
127 2026-04-11 07:30 2.4048
128 2026-04-11 07:45 1.7628
129 2026-04-11 08:00 2.7298
130 2026-04-11 08:15 2.3223
131 2026-04-11 08:30 1.6730
132 2026-04-11 08:45 0.5990
133 2026-04-11 09:00 1.4445
134 2026-04-11 09:15 0.5615
135 2026-04-11 09:30 0.2000
136 2026-04-11 09:45 0.0003
137 2026-04-11 10:00 0.1933
138 2026-04-11 10:15 0.0000
139 2026-04-11 10:30 -0.0305
140 2026-04-11 10:45 -0.2223
141 2026-04-11 11:00 -0.0308
142 2026-04-11 11:15 0.0000
143 2026-04-11 11:30 0.0250
144 2026-04-11 11:45 0.1450
145 2026-04-11 12:00 -0.4685
146 2026-04-11 12:15 -1.0418
147 2026-04-11 12:30 -0.9168
148 2026-04-11 12:45 -1.2465
149 2026-04-11 13:00 -1.5560
150 2026-04-11 13:15 -1.7465
151 2026-04-11 13:30 -2.1328
152 2026-04-11 13:45 -2.4545
153 2026-04-11 14:00 -1.8465
154 2026-04-11 14:15 -1.8458
155 2026-04-11 14:30 -1.7578
156 2026-04-11 14:45 -1.4893
157 2026-04-11 15:00 -1.7045
158 2026-04-11 15:15 -1.3535
159 2026-04-11 15:30 -1.0890
160 2026-04-11 15:45 -0.6923
161 2026-04-11 16:00 -0.7728
162 2026-04-11 16:15 -0.3718
163 2026-04-11 16:30 -0.2075
164 2026-04-11 16:45 -0.3675
165 2026-04-11 17:00 0.3510
166 2026-04-11 17:15 -0.1903
167 2026-04-11 17:30 -0.0250
168 2026-04-11 17:45 0.1198
169 2026-04-11 18:00 -0.4573
170 2026-04-11 18:15 1.8173
171 2026-04-11 18:30 2.3750
172 2026-04-11 18:45 2.9240
173 2026-04-11 19:00 2.3338
174 2026-04-11 19:15 2.6563
175 2026-04-11 19:30 2.8090
176 2026-04-11 19:45 3.0140
177 2026-04-11 20:00 2.8350
178 2026-04-11 20:15 2.4450
179 2026-04-11 20:30 2.4565
180 2026-04-11 20:45 2.2635
181 2026-04-11 21:00 3.0835
182 2026-04-11 21:15 2.1543
183 2026-04-11 21:30 1.9325
184 2026-04-11 21:45 1.8295
185 2026-04-11 22:00 3.3630
186 2026-04-11 22:15 2.5945
187 2026-04-11 22:30 1.7628
188 2026-04-11 22:45 1.4008
189 2026-04-11 23:00 2.4143
190 2026-04-11 23:15 1.7565
191 2026-04-11 23:30 1.5728
192 2026-04-11 23:45 1.5035
193 2026-04-12 00:00 1.5390
194 2026-04-12 00:15 1.5708
195 2026-04-12 00:30 1.7003
196 2026-04-12 00:45 1.7023
197 2026-04-12 01:00 1.7770
198 2026-04-12 01:15 1.7503
199 2026-04-12 01:30 1.8833
200 2026-04-12 01:45 2.0250
201 2026-04-12 02:00 1.8593
202 2026-04-12 02:15 1.9398
203 2026-04-12 02:30 1.9483
204 2026-04-12 02:45 2.0395
205 2026-04-12 03:00 2.0418
206 2026-04-12 03:15 2.1465
207 2026-04-12 03:30 2.1558
208 2026-04-12 03:45 2.2473
209 2026-04-12 04:00 2.3520
210 2026-04-12 04:15 2.4003
211 2026-04-12 04:30 2.4923
212 2026-04-12 04:45 2.5645
213 2026-04-12 05:00 2.4895
214 2026-04-12 05:15 2.5743
215 2026-04-12 05:30 2.5745
216 2026-04-12 05:45 2.5400
217 2026-04-12 06:00 2.6265
218 2026-04-12 06:15 2.6030
219 2026-04-12 06:30 2.5383
220 2026-04-12 06:45 2.4885
221 2026-04-12 07:00 2.7490
222 2026-04-12 07:15 2.5110
223 2026-04-12 07:30 2.3325
224 2026-04-12 07:45 2.2773
225 2026-04-12 08:00 2.1050
226 2026-04-12 08:15 1.8573
227 2026-04-12 08:30 1.7498
228 2026-04-12 08:45 1.3183
229 2026-04-12 09:00 1.4575
230 2026-04-12 09:15 1.3108
231 2026-04-12 09:30 0.8415
232 2026-04-12 09:45 0.6773
233 2026-04-12 10:00 0.8375
234 2026-04-12 10:15 0.4075
235 2026-04-12 10:30 0.3775
236 2026-04-12 10:45 0.3193
237 2026-04-12 11:00 0.4165
238 2026-04-12 11:15 0.3638
239 2026-04-12 11:30 0.3085
240 2026-04-12 11:45 0.2470
241 2026-04-12 12:00 0.2023
242 2026-04-12 12:15 0.1435
243 2026-04-12 12:30 0.1298
244 2026-04-12 12:45 0.0615
245 2026-04-12 13:00 0.0473
246 2026-04-12 13:15 0.0173
247 2026-04-12 13:30 0.0015
248 2026-04-12 13:45 0.0013
249 2026-04-12 14:00 0.0000
250 2026-04-12 14:15 0.0278
251 2026-04-12 14:30 0.0688
252 2026-04-12 14:45 0.0628
253 2026-04-12 15:00 0.1483
254 2026-04-12 15:15 0.2505
255 2026-04-12 15:30 0.4290
256 2026-04-12 15:45 0.7538
257 2026-04-12 16:00 0.8148
258 2026-04-12 16:15 1.4820
259 2026-04-12 16:30 1.5620
260 2026-04-12 16:45 2.4738
261 2026-04-12 17:00 1.5428
262 2026-04-12 17:15 2.2933
263 2026-04-12 17:30 3.1040
264 2026-04-12 17:45 3.4375
265 2026-04-12 18:00 2.8968
266 2026-04-12 18:15 3.1310
267 2026-04-12 18:30 3.4105
268 2026-04-12 18:45 3.6880
269 2026-04-12 19:00 3.3903
270 2026-04-12 19:15 3.3798
271 2026-04-12 19:30 3.6125
272 2026-04-12 19:45 3.9263
273 2026-04-12 20:00 3.7030
274 2026-04-12 20:15 3.6903
275 2026-04-12 20:30 3.6473
276 2026-04-12 20:45 3.5030
277 2026-04-12 21:00 3.5230
278 2026-04-12 21:15 3.3825
279 2026-04-12 21:30 3.4325
280 2026-04-12 21:45 3.3353
281 2026-04-12 22:00 3.4898
282 2026-04-12 22:15 3.3675
283 2026-04-12 22:30 3.2180
284 2026-04-12 22:45 2.9855
285 2026-04-12 23:00 3.3763
286 2026-04-12 23:15 2.9848
287 2026-04-12 23:30 2.9855
288 2026-04-12 23:45 2.7558

View File

@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Join Deye inverter export (wide xlsx) with OTE 15min sell prices for BA81-style analysis.
OTE CSV: regenerate from EMS DB (MCP or psql), example:
SELECT string_agg(
to_char((interval_start AT TIME ZONE 'Europe/Prague')::date, 'YYYY-MM-DD') || ',' ||
to_char(interval_start AT TIME ZONE 'Europe/Prague', 'HH24:MI') || ',' ||
trim(to_char(sell_raw_price_czk_kwh, 'FM9999990.0000')),
chr(10) ORDER BY interval_start
)
FROM ems.market_interval_price
WHERE market_source = 'OTE_CZ'
AND (interval_start AT TIME ZONE 'Europe/Prague')::date IN (...);
Convention in sample logs: negative Battery Power(W) ≈ charging, positive ≈ discharging.
Total Grid Power(W): small positive ≈ little/no export (sign per site firmware).
Requires: openpyxl. Use read_only=False (these exports report max_row=1 in read_only mode).
"""
from __future__ import annotations
import argparse
import statistics as st
from collections import defaultdict
from datetime import datetime
from pathlib import Path
import openpyxl
COLS = [
"Time",
"Total Solar Power(W)",
"Total Inverter Output Power(W)",
"Total Grid Power(W)",
"Battery Power(W)",
"SoC(%)",
]
def load_ote_csv(path: Path) -> dict[tuple[str, str], float]:
ote: dict[tuple[str, str], float] = {}
for line in path.read_text().splitlines():
line = line.strip()
if not line:
continue
d, hm, s = line.split(",")
ote[(d, hm)] = float(s)
return ote
def floor_15(dt: datetime) -> datetime:
m = (dt.minute // 15) * 15
return dt.replace(minute=m, second=0, microsecond=0)
def slot_key(dt: datetime) -> tuple[str, str]:
f = floor_15(dt)
return f.strftime("%Y-%m-%d"), f.strftime("%H:%M")
def load_inverter_rows(fp: Path) -> list[dict[str, object]]:
wb = openpyxl.load_workbook(fp, read_only=False, data_only=True)
ws = wb.active
it = ws.iter_rows(values_only=True)
header = next(it)
idx = {str(h).strip(): i for i, h in enumerate(header) if h}
rows: list[dict[str, object]] = []
for r in it:
if not r or r[idx["Time"]] is None:
continue
rows.append({c: r[idx[c]] for c in COLS})
wb.close()
return rows
def main() -> None:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--ote-csv", type=Path, required=True)
p.add_argument("xlsx", type=Path, nargs="+")
args = p.parse_args()
ote = load_ote_csv(args.ote_csv)
for fp in args.xlsx:
data = load_inverter_rows(fp)
neg: list[tuple[float, datetime, dict]] = []
for r in data:
t = r["Time"]
if isinstance(t, str):
t = datetime.strptime(t, "%Y/%m/%d %H:%M:%S")
dk, hm = slot_key(t)
sell = ote.get((dk, hm))
if sell is None or sell >= 0:
continue
neg.append((sell, t, r))
print(f"\n=== {fp.name} rows={len(data)} OTE sell<0 samples={len(neg)}")
if not neg:
continue
socs = [float(x[2]["SoC(%)"]) for x in neg]
grids = [float(x[2]["Total Grid Power(W)"]) for x in neg]
bats = [float(x[2]["Battery Power(W)"]) for x in neg]
sols = [float(x[2]["Total Solar Power(W)"]) for x in neg]
print(f" SoC %: mean={st.mean(socs):.1f} min={min(socs):.0f} max={max(socs):.0f}")
print(f" Grid W: mean={st.mean(grids):.0f} med={st.median(grids):.0f}")
print(f" Bat W: mean={st.mean(bats):.0f} med={st.median(bats):.0f}")
print(f" Solar W: mean={st.mean(sols):.0f} med={st.median(sols):.0f}")
buckets: dict[str, list] = defaultdict(list)
for sell, t, r in neg:
if t.hour < 9 or t.hour > 18:
continue
_, hm = slot_key(t)
buckets[hm].append((sell, r))
print(" 15min buckets (OTE<0, 09-18h) medians:")
for hm in sorted(buckets.keys()):
b = buckets[hm]
sell = b[0][0]
socs_b = [float(x[1]["SoC(%)"]) for x in b]
print(
f" {hm} sell={sell:+.3f} n={len(b):2d} "
f"SoC_med={st.median(socs_b):.0f}% "
f"Pgrid_med={st.median([float(x[1]['Total Grid Power(W)']) for x in b]):.0f}W "
f"Psol_med={st.median([float(x[1]['Total Solar Power(W)']) for x in b]):.0f}W"
)
if __name__ == "__main__":
main()