uprava toolu pro battery sizing
This commit is contained in:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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 0–23)")
|
||||
ap.add_argument("--nt-to-hour", type=int, default=6, help="Konec NT: první hodina VT (0–23); 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(
|
||||
|
||||
Reference in New Issue
Block a user