369 lines
13 KiB
Python
369 lines
13 KiB
Python
#!/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()
|