uprava toolu pro battery sizing
Some checks failed
CI and deploy / migration-check (push) Failing after 22s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-12 21:49:32 +02:00
parent 64327af8e0
commit 851ec2b637
2 changed files with 223 additions and 33 deletions

View File

@@ -127,6 +127,30 @@ Marže se konfigurují v `site_market_config`:
Denní ekonomika v DB (`ems.fn_economics_daily_for_window`, repeatable `R__068_fn_economics_daily_month.sql`) musí používat stejnou kombinaci jako `fn_effective_buy_price` (komentář ve funkci).
### Screening skript pro dimenzování baterie
Analytický skript `scripts/analysis/battery_sizing_screen.py` umí pro nákup v režimu spot simulovat dva užitečné screening režimy bez vazby na konkrétní `site_market_config`:
- `--buy-spot-add-fixed-kwh X`: základ nákupu = `raw_ote + X`
- `--buy-spot-asym-pct P`: základ nákupu = `raw_ote × (1 + P/100)` pro `raw_ote >= 0`, resp. `raw_ote × (1 - P/100)` pro `raw_ote < 0`
V obou případech skript ke každému importnímu slotu fixně přičte:
- `--buy-distribution-kwh`
- `--buy-other-fees-kwh`
Volitelně pak na celý součet aplikuje:
- `--buy-vat-multiplier` (např. `1.21`)
Tato logika je implementovaná přímo ve `build_buy_prices_96()` v `scripts/analysis/battery_sizing_screen.py`. Účel je screening nové lokality nebo obchodního modelu ještě před seedem do DB; nejde o náhradu `ems.fn_effective_buy_price`.
Ověření:
- spusť skript nad krátkým vzorkem OTE (`--price-csv` nebo `--db`) a zkontroluj vypsané shrnutí režimu nákupu
- pro asymetrickou variantu ověř, že záporné ceny používají faktor `1 - P/100`, nikoli `1 + P/100`
- pro arbitráž bez FVE použij `--pv-daily-kwh-summer 0 --pv-daily-kwh-winter 0 --load-kw 0`
**Zelený bonus** není součástí `fn_effective_sell_price` ani view efektivní prodejní ceny jde o samostatný příjem z výroby, viz níže.
---

View File

@@ -26,14 +26,23 @@ Příklad (PVGIS měsíční E_d + NT/VT):
--buy-nt-kwh 5.25 --buy-vt-surcharge-kwh 2.0 --nt-from-hour 22 --nt-to-hour 6 \\
... (ostatní jako výše)
Příklad (čistá arbitráž, nákup = spot + fixní adder + distribuce/poplatky):
python3 scripts/analysis/battery_sizing_screen.py --db \\
--load-kw 0 --pv-daily-kwh-summer 0 --pv-daily-kwh-winter 0 \\
--buy-spot-add-fixed-kwh 0.25 \\
--buy-distribution-kwh 1.80 --buy-other-fees-kwh 0.20 --buy-vat-multiplier 1.21 \\
... (ostatní jako výše)
Vyžaduje: pip install pulp (volitelně psycopg2 pro --db).
Omezení modelu: FVE buď syntetický denní tvar (--pv-daily-kwh-*), nebo součet měsíčních
E_d z PVGIS CSV (--pvgis-csv, opakovat pro více orientací); denní energie = E_d měsíce
× normalizovaný tvar (stejný profil každý den v měsíci). Nákup: buď flat (--buy-vat-kwh),
nebo NT/VT podle hodin Europe/Prague: --buy-nt-kwh, VT = NT + --buy-vt-surcharge-kwh,
okno NT --nt-from-hour až --nt-to-hour (přes půlnoc, pokud from > to). Mikroinvertory / GEN
nejsou; zelený bonus není v účelové funkci. Výsledek = screening, ne nabídka.
× normalizovaný tvar (stejný profil každý den v měsíci). Nákup: flat (--buy-vat-kwh),
NT/VT podle hodin Europe/Prague (--buy-nt-kwh, VT = NT + --buy-vt-surcharge-kwh),
nebo od raw OTE spotu: --buy-spot-add-fixed-kwh / --buy-spot-asym-pct; u všech režimů
lze přičíst --buy-distribution-kwh a --buy-other-fees-kwh a výslednou cenu násobit
--buy-vat-multiplier. Mikroinvertory / GEN nejsou; zelený bonus není v účelové funkci.
Výsledek = screening, ne nabídka.
"""
from __future__ import annotations
@@ -69,6 +78,21 @@ class SiteLimits:
soc_max_frac: float = 0.95
@dataclass(frozen=True)
class BuyPricingConfig:
mode: str = "flat"
flat_kwh: float = 4.443
nt_kwh: float | None = None
vt_kwh: float | None = None
nt_from_hour: int = 22
nt_to_hour: int = 6
spot_add_fixed_kwh: float | None = None
spot_asym_pct: float | None = None
distribution_kwh: float = 0.0
other_fees_kwh: float = 0.0
vat_multiplier: float = 1.0
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)
@@ -173,6 +197,51 @@ def effective_sell_kc_kwh(raw_ote: float, margin_fixed: float, margin_pct: float
return raw_ote + margin_fixed + (raw_ote * margin_pct / 100.0)
def effective_buy_spot_add_fixed_kc_kwh(raw_ote: float, add_fixed_kwh: float) -> float:
return raw_ote + add_fixed_kwh
def effective_buy_spot_asym_pct_kc_kwh(raw_ote: float, asym_pct: float) -> float:
if raw_ote >= 0:
return raw_ote * (1.0 + asym_pct / 100.0)
return raw_ote * (1.0 - asym_pct / 100.0)
def build_buy_prices_96(raw_ote_96: Sequence[float], cfg: BuyPricingConfig) -> list[float]:
fixed_fees_kwh = cfg.distribution_kwh + cfg.other_fees_kwh
if cfg.mode == "spot_add_fixed":
if cfg.spot_add_fixed_kwh is None:
raise ValueError("Pro mode=spot_add_fixed chybí spot_add_fixed_kwh")
return [
(effective_buy_spot_add_fixed_kc_kwh(px, cfg.spot_add_fixed_kwh) + fixed_fees_kwh)
* cfg.vat_multiplier
for px in raw_ote_96
]
if cfg.mode == "spot_asym_pct":
if cfg.spot_asym_pct is None:
raise ValueError("Pro mode=spot_asym_pct chybí spot_asym_pct")
return [
(effective_buy_spot_asym_pct_kc_kwh(px, cfg.spot_asym_pct) + fixed_fees_kwh)
* cfg.vat_multiplier
for px in raw_ote_96
]
if cfg.mode == "nt_vt":
if cfg.nt_kwh is None or cfg.vt_kwh is None:
raise ValueError("Pro mode=nt_vt chybí NT/VT cena")
return [
(base + fixed_fees_kwh) * cfg.vat_multiplier
for base in buy_prices_96_nt_vt(
cfg.nt_kwh,
cfg.vt_kwh,
cfg.nt_from_hour,
cfg.nt_to_hour,
)
]
if cfg.mode != "flat":
raise ValueError(f"Neznámý buy mode: {cfg.mode}")
return [(cfg.flat_kwh + fixed_fees_kwh) * cfg.vat_multiplier] * SLOTS_PER_DAY
def load_env_file(path: Path) -> None:
if not path.is_file():
return
@@ -211,11 +280,30 @@ def sync_pg_env_from_db_vars() -> None:
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))
first_line = f.readline()
f.seek(0)
if "interval_start" in first_line and "sell_raw_price_czk_kwh" in first_line:
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))
else:
from zoneinfo import ZoneInfo
prg = ZoneInfo("Europe/Prague")
r = csv.reader(f)
for row in r:
if len(row) < 3:
continue
date_s = row[0].strip()
time_s = row[1].strip()
price_s = row[2].strip()
if not date_s or not time_s or not price_s:
continue
ts = datetime.fromisoformat(f"{date_s}T{time_s}").replace(tzinfo=prg)
px = float(price_s)
out.append((ts, px))
out.sort(key=lambda x: x[0])
return out
@@ -356,8 +444,7 @@ def simulate_year(
site: SiteLimits,
sell_margin_fixed: float,
sell_margin_pct: float,
buy_flat_kwh: float,
buy_prices_96: Sequence[float] | None,
buy_cfg: BuyPricingConfig,
summer_kwh: float,
winter_kwh: float,
load_kw: float,
@@ -367,12 +454,6 @@ def simulate_year(
e_wh = usable_kwh * 1000.0
p_batt = batt_power_cap_w(usable_kwh, site)
load_wh = daily_load_wh(load_kw)
if buy_prices_96 is not None:
if len(buy_prices_96) != SLOTS_PER_DAY:
raise ValueError("buy_prices_96 musí mít 96 hodnot")
p_buy_day: Sequence[float] = buy_prices_96
else:
p_buy_day = [buy_flat_kwh] * SLOTS_PER_DAY
cash_total = 0.0
curt_total = 0.0
dis_total = 0.0
@@ -383,12 +464,13 @@ def simulate_year(
continue
raw = px_day[d]
p_sell = [effective_sell_kc_kwh(x, sell_margin_fixed, sell_margin_pct) for x in raw]
p_buy = build_buy_prices_96(raw, buy_cfg)
if monthly_ed_kwh is not None:
pv_wh = daily_pv_wh_monthly(d, monthly_ed_kwh, shape)
else:
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, p_buy_day, e_wh, p_batt, site, soc_state
pv_wh, load_wh, p_sell, p_buy, e_wh, p_batt, site, soc_state
)
cash_total += cash
curt_total += curt
@@ -419,7 +501,12 @@ def main() -> None:
default="",
help="Přepíše PGHOST (např. stejná IP jako EMS_DB_BIND ve compose)",
)
ap.add_argument("--price-csv", type=str, default="", help="CSV: interval_start, sell_raw_price_czk_kwh")
ap.add_argument(
"--price-csv",
type=str,
default="",
help="CSV buď s hlavičkou interval_start,sell_raw_price_czk_kwh, nebo legacy bez hlavičky: date,time,price",
)
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)")
@@ -432,7 +519,7 @@ def main() -> None:
"--buy-vat-kwh",
type=float,
default=4.443,
help="Flat nákup Kč/kWh (když není --buy-nt-kwh)",
help="Flat základní nákup Kč/kWh (když není spotový ani NT/VT režim)",
)
ap.add_argument(
"--buy-nt-kwh",
@@ -448,6 +535,36 @@ def main() -> None:
)
ap.add_argument("--nt-from-hour", type=int, default=22, help="Začátek NT (hodina 023)")
ap.add_argument("--nt-to-hour", type=int, default=6, help="Konec NT: první hodina VT (023); přes půlnoc pokud from > to")
ap.add_argument(
"--buy-spot-add-fixed-kwh",
type=float,
default=None,
help="Základ nákupu = raw OTE + tento fixní adder Kč/kWh; pak se přičtou distribuce a ostatní poplatky",
)
ap.add_argument(
"--buy-spot-asym-pct",
type=float,
default=None,
help="Základ nákupu = raw OTE × (1 + p/100) pro raw >= 0, raw OTE × (1 - p/100) pro raw < 0",
)
ap.add_argument(
"--buy-distribution-kwh",
type=float,
default=0.0,
help="Fixně přičtená distribuční složka Kč/kWh ke každému nákupnímu slotu",
)
ap.add_argument(
"--buy-other-fees-kwh",
type=float,
default=0.0,
help="Fixně přičtené ostatní poplatky Kč/kWh (OTE, systémové služby apod.) ke každému nákupnímu slotu",
)
ap.add_argument(
"--buy-vat-multiplier",
type=float,
default=1.0,
help="Násobitel DPH aplikovaný na finální nákupní cenu po přičtení distribuce a ostatních poplatků (např. 1.21)",
)
ap.add_argument(
"--pvgis-csv",
action="append",
@@ -464,6 +581,18 @@ def main() -> None:
d0 = date.fromisoformat(args.date_from)
d1 = date.fromisoformat(args.date_to)
base_buy_modes = [
args.buy_nt_kwh is not None,
args.buy_spot_add_fixed_kwh is not None,
args.buy_spot_asym_pct is not None,
]
if sum(base_buy_modes) > 1:
ap.error("Zvol jen jeden režim základu nákupu: flat, NT/VT, --buy-spot-add-fixed-kwh nebo --buy-spot-asym-pct")
if args.buy_vat_multiplier <= 0:
ap.error("--buy-vat-multiplier musí být > 0")
for hour_arg, hour_value in (("nt-from-hour", args.nt_from_hour), ("nt-to-hour", args.nt_to_hour)):
if not (0 <= hour_value <= 23):
ap.error(f"--{hour_arg} musí být v rozsahu 0..23")
if args.db:
if not args.no_auto_env:
apply_auto_env_files()
@@ -491,18 +620,42 @@ def main() -> None:
if args.pvgis_csv:
monthly_ed = merge_pvgis_monthly_ed_kwh([Path(p) for p in args.pvgis_csv])
if args.buy_nt_kwh is not None:
vt = args.buy_nt_kwh + args.buy_vt_surcharge_kwh
buy_prices_96 = buy_prices_96_nt_vt(
args.buy_nt_kwh,
vt,
args.nt_from_hour,
args.nt_to_hour,
if args.buy_spot_add_fixed_kwh is not None:
buy_cfg = BuyPricingConfig(
mode="spot_add_fixed",
spot_add_fixed_kwh=args.buy_spot_add_fixed_kwh,
distribution_kwh=args.buy_distribution_kwh,
other_fees_kwh=args.buy_other_fees_kwh,
vat_multiplier=args.buy_vat_multiplier,
)
elif args.buy_spot_asym_pct is not None:
buy_cfg = BuyPricingConfig(
mode="spot_asym_pct",
spot_asym_pct=args.buy_spot_asym_pct,
distribution_kwh=args.buy_distribution_kwh,
other_fees_kwh=args.buy_other_fees_kwh,
vat_multiplier=args.buy_vat_multiplier,
)
elif args.buy_nt_kwh is not None:
vt = args.buy_nt_kwh + args.buy_vt_surcharge_kwh
buy_cfg = BuyPricingConfig(
mode="nt_vt",
nt_kwh=args.buy_nt_kwh,
vt_kwh=vt,
nt_from_hour=args.nt_from_hour,
nt_to_hour=args.nt_to_hour,
distribution_kwh=args.buy_distribution_kwh,
other_fees_kwh=args.buy_other_fees_kwh,
vat_multiplier=args.buy_vat_multiplier,
)
buy_flat = args.buy_vat_kwh
else:
buy_prices_96 = None
buy_flat = args.buy_vat_kwh
buy_cfg = BuyPricingConfig(
mode="flat",
flat_kwh=args.buy_vat_kwh,
distribution_kwh=args.buy_distribution_kwh,
other_fees_kwh=args.buy_other_fees_kwh,
vat_multiplier=args.buy_vat_multiplier,
)
day_list = [d0 + timedelta(days=i) for i in range((d1 - d0).days)]
@@ -515,8 +668,7 @@ def main() -> None:
site,
args.sell_margin_fixed,
args.sell_margin_pct,
buy_flat,
buy_prices_96,
buy_cfg,
args.pv_daily_kwh_summer,
args.pv_daily_kwh_winter,
args.load_kw,
@@ -529,14 +681,28 @@ def main() -> None:
base = dict(results)[baseline_kwh]
print("Parametry: prodej = OTE + sell_margin_fixed (+ %)")
if buy_prices_96 is not None:
if buy_cfg.mode == "nt_vt":
vt_show = args.buy_nt_kwh + args.buy_vt_surcharge_kwh
print(
f" Nákup = NT/VT: NT {args.buy_nt_kwh} Kč/kWh, VT {vt_show} Kč/kWh "
f"(okno NT {args.nt_from_hour:02d}{args.nt_to_hour:02d} h lokální)"
)
elif buy_cfg.mode == "spot_add_fixed":
print(f" Nákup = raw OTE + {args.buy_spot_add_fixed_kwh} Kč/kWh")
elif buy_cfg.mode == "spot_asym_pct":
print(
f" Nákup = raw OTE × (1 + {args.buy_spot_asym_pct}/100) pro raw >= 0, "
f"raw OTE × (1 - {args.buy_spot_asym_pct}/100) pro raw < 0"
)
else:
print(f" Nákup = flat {args.buy_vat_kwh} Kč/kWh")
if args.buy_distribution_kwh or args.buy_other_fees_kwh:
print(
f" Fixní add-on k nákupu: distribuce {args.buy_distribution_kwh} Kč/kWh, "
f"ostatní poplatky {args.buy_other_fees_kwh} Kč/kWh"
)
if args.buy_vat_multiplier != 1.0:
print(f" DPH násobitel na finální nákupní cenu: {args.buy_vat_multiplier}")
if monthly_ed is not None:
edv = [monthly_ed[m] for m in range(1, 13)]
print(