diff --git a/docs/04-modules/market-prices.md b/docs/04-modules/market-prices.md index 1044498..38e558c 100644 --- a/docs/04-modules/market-prices.md +++ b/docs/04-modules/market-prices.md @@ -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. --- diff --git a/scripts/analysis/battery_sizing_screen.py b/scripts/analysis/battery_sizing_screen.py index c64fbe9..a49eb4d 100644 --- a/scripts/analysis/battery_sizing_screen.py +++ b/scripts/analysis/battery_sizing_screen.py @@ -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(