#!/usr/bin/env python3 """ HU1 (hulin-bess, site 5) — REALISTICKÁ simulace provozu BESS na spotu. Otázka majitele: vyplatí se přechod z fixní nákupní ceny na SPOTOVOU smlouvu? Dřívější studie (hu1_bess_study.py) byla perfect-hindsight = horní mez. Tento skript simuluje den po dni TAK, JAK BY SKUTEČNĚ BĚŽEL PLÁNOVAČ: - D−1 plán: solver v2 (`solve_dispatch_v2`) s cenami známými ve 13:30 předchozího dne (= celý zítřek OTE, nic víc — žádný pohled za půlnoc), - SoC se řetězí mezi dny (terminal dne N = initial dne N+1), - parametry baterie/grid PŘESNĚ z DB site 5 (`fn_planning_site_context`), - export povolen (grid: 42 kW, no_export=false), block_export_on_negative_sell=true, - HU1 NEMÁ telemetrii (ověřeno: telemetry_inverter site 5 = 0 řádků) → odběrový profil je PARAMETRIZOVANÝ (konstanty níže; přepsat reálnými čísly od majitele, jakmile dodá odběrový diagram). Scénáře (vše Kč/den, kladné = náklad; vč. degradace baterie, SoC-adjusted): A) fixní nákup, bez baterie (dnešní smlouva, kdyby baterie nebyla) B) fixní nákup + baterie (dnešní stav: fix buy, spot sell) C) spot nákup, bez baterie D) spot nákup + baterie (navrhovaný stav) GAP: pro scénář D běží i perfect-hindsight varianta (7denní okna, řetězené SoC) — rozdíl kvantifikuje hodnotu vícedenního dokonalého výhledu, kterou reálný D−1 plánovač nemá. Citlivosti (scénář D, příp. B): degradace 0.15/0.5/1.0 Kč/kWh; komprese spreadů −30 % (ceny staženy k dennímu průměru — konzervativní zima). Použití (čte pouze SELECT; DSN jako ostatní harness skripty): EMS_DB_DSN=postgresql://ems_user:***@10.200.200.1:5432/ems \ python3 scripts/harness/hu1_realistic_eval.py [--from 2024-04-14 --to 2026-06-12] Volby: --fix-buy 3.38 | --quick (jen posledních 60 dní) | --skip-hindsight --no-md (nezapisovat docs/studies/hu1-spot-realistic.md) """ from __future__ import annotations import argparse import asyncio import json import os import sys from concurrent.futures import ProcessPoolExecutor from dataclasses import dataclass, field from datetime import date, datetime, timedelta from pathlib import Path from types import SimpleNamespace from zoneinfo import ZoneInfo import asyncpg REPO_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(REPO_ROOT / "backend")) from services.planning.types import PlanningSlot # noqa: E402 from services.planning import solver_v2 as v2 # noqa: E402 PRAGUE = ZoneInfo("Europe/Prague") INTERVAL_H = 0.25 # --------------------------------------------------------------------------- # KONSTANTY K PŘEPSÁNÍ REÁLNÝMI ČÍSLY OD MAJITELE # --------------------------------------------------------------------------- SITE_CODE = "hulin-bess" #: Odběrový profil (HU1 nemá telemetrii — parametrizovaný odhad!). #: Průmyslový vzor: konstantní bazál 24/7 + špička v pracovní dny. LOAD_BASE_W = 4_000.0 #: konstantní odběr 24/7 (chlazení, IT, vrátnice…) LOAD_PEAK_EXTRA_W = 16_000.0 #: navíc ve špičce pracovního dne (provoz) PEAK_HOUR_FROM = 6 #: začátek špičky (hodina, Europe/Prague) PEAK_HOUR_TO = 18 #: konec špičky (exkluzivně) PEAK_WORKDAYS_ONLY = True #: špička jen po–pá (svátky neřešeny) #: Dnešní FIXNÍ nákupní cena (Kč/kWh, bez DPH). POZOR: site_market_config #: site 5 fixní cenu NEMÁ (mód spot/spot od 2026-05-23) — default je proxy #: z BA81 fixní smlouvy (2.5518 NT + 0.8250 VT příplatek ≈ 3.38 ve VT). #: Nahradit skutečnou smluvní cenou majitele (--fix-buy). FIX_BUY_CZK_KWH_DEFAULT = 3.38 #: Citlivost degradace (Kč/kWh průchozí energie; DB hodnota site 5 = 0.15). DEG_SENSITIVITY = (0.15, 0.50, 1.00) #: Komprese spreadů: ceny staženy o 30 % k dennímu průměru (konzervativní zima). SPREAD_COMPRESSION = 0.30 START_SOC_PCT = 50.0 #: počáteční SoC simulace HINDSIGHT_WINDOW_DAYS = 7 #: okno perfect-hindsight varianty SUMMER_MONTHS = frozenset({4, 5, 6, 7, 8, 9}) #: „léto“ = duben–září MIN_SLOTS_PER_DAY = 90 #: méně → den přeskočen (díra v OTE) DOC_PATH = REPO_ROOT / "docs" / "studies" / "hu1-spot-realistic.md" # --------------------------------------------------------------------------- # Datové typy # --------------------------------------------------------------------------- @dataclass class PriceSlot: interval_start: datetime # UTC buy_raw: float # Kč/kWh raw OTE sell_raw: float @dataclass class DayResult: day: date cost_a: float = 0.0 cost_b: float = 0.0 cost_c: float = 0.0 cost_d: float = 0.0 variants: dict[str, float] = field(default_factory=dict) # D citlivosti, B compress… @dataclass class SiteParams: site_id: int battery: SimpleNamespace grid: SimpleNamespace buy_margin_fixed: float buy_margin_pct: float sell_margin_fixed: float sell_margin_pct: float extra_buy_fees: float # system_services + ote_fee def _build_dsn(args: argparse.Namespace) -> str: if args.dsn: return args.dsn env_dsn = os.environ.get("EMS_DB_DSN") if env_dsn: return env_dsn host = os.environ.get("DB_HOST", "127.0.0.1") port = os.environ.get("DB_PORT", "5432") user = os.environ.get("DB_USER", "ems_user") password = os.environ.get("DB_PASSWORD", "") name = os.environ.get("DB_NAME", "ems") return f"postgresql://{user}:{password}@{host}:{port}/{name}" # --------------------------------------------------------------------------- # Načtení dat z DB # --------------------------------------------------------------------------- async def load_site_params(conn: asyncpg.Connection) -> SiteParams: site_id = await conn.fetchval("select id from ems.site where code = $1", SITE_CODE) if site_id is None: raise SystemExit(f"Site '{SITE_CODE}' v DB neexistuje") raw = await conn.fetchval("select ems.fn_planning_site_context($1::int)", site_id) ctx = raw if isinstance(raw, dict) else json.loads(raw) b = ctx["battery"] battery = SimpleNamespace( usable_capacity_wh=float(b["usable_capacity_wh"]), min_soc_wh=float(b["min_soc_wh"]), soc_max_wh=float(b["soc_max_wh"]), charge_efficiency=float(b["charge_efficiency"]), discharge_efficiency=float(b["discharge_efficiency"]), max_charge_power_w=float(b["max_charge_power_w"]), max_discharge_power_w=float(b["max_discharge_power_w"]), degradation_cost_czk_kwh=float(b["degradation_cost_czk_kwh"]), planner_terminal_soc_value_factor=float(b["planner_terminal_soc_value_factor"]), arb_floor_wh=float(b["arb_floor_wh"]), reserve_soc_wh=float(b["reserve_soc_wh"]), # bez telemetrie nemá smysl rizikový polštář spotřeby — profil je deterministický planner_safety_soc_risk_factor=0.0, planner_pv_risk_frontload_czk_kwh=0.0, ) g = ctx["grid"] grid = SimpleNamespace( max_import_power_w=float(g["max_import_power_w"]), max_export_power_w=float(g["max_export_power_w"]), block_export_on_negative_sell=bool(g["block_export_on_negative_sell"]), deye_gen_microinverter_cutoff_enabled=False, ) m = await conn.fetchrow( """ select buy_margin_fixed_czk, buy_margin_percent, sell_margin_fixed_czk, sell_margin_percent, coalesce(system_services_czk_kwh, 0) + coalesce(ote_fee_czk_kwh, 0) as fees from ems.site_market_config where site_id = $1 and valid_to is null order by valid_from desc limit 1 """, site_id, ) return SiteParams( site_id=int(site_id), battery=battery, grid=grid, buy_margin_fixed=float(m["buy_margin_fixed_czk"]), buy_margin_pct=float(m["buy_margin_percent"]), sell_margin_fixed=float(m["sell_margin_fixed_czk"]), sell_margin_pct=float(m["sell_margin_percent"]), extra_buy_fees=float(m["fees"]), ) async def load_ote_days( conn: asyncpg.Connection, d_from: date, d_to: date ) -> tuple[dict[date, list[PriceSlot]], list[date]]: """OTE raw ceny seskupené po pražských dnech; vrací (dny, díry).""" rows = await conn.fetch( """ select interval_start, buy_raw_price_czk_kwh as buy, sell_raw_price_czk_kwh as sell from ems.market_interval_price where market_source = 'OTE_CZ' and interval_start >= ($1::date::timestamp at time zone 'Europe/Prague') and interval_start < (($2::date + 1)::timestamp at time zone 'Europe/Prague') order by interval_start """, d_from, d_to, ) days: dict[date, list[PriceSlot]] = {} for r in rows: ts: datetime = r["interval_start"] d = ts.astimezone(PRAGUE).date() days.setdefault(d, []).append( PriceSlot(interval_start=ts, buy_raw=float(r["buy"]), sell_raw=float(r["sell"])) ) holes: list[date] = [] d = d_from while d <= d_to: if len(days.get(d, [])) < MIN_SLOTS_PER_DAY: holes.append(d) days.pop(d, None) d += timedelta(days=1) return days, holes # --------------------------------------------------------------------------- # Odběrový profil a ceny # --------------------------------------------------------------------------- def load_profile_w(ts_utc: datetime) -> float: """Parametrizovaný průmyslový odběr (W) pro slot začínající ts_utc.""" local = ts_utc.astimezone(PRAGUE) load = LOAD_BASE_W is_workday = local.weekday() < 5 or not PEAK_WORKDAYS_ONLY if is_workday and PEAK_HOUR_FROM <= local.hour < PEAK_HOUR_TO: load += LOAD_PEAK_EXTRA_W return load def compress_spread(slots: list[PriceSlot], factor: float) -> list[PriceSlot]: """Stáhne raw ceny dne o `factor` k dennímu průměru (konzervativní zima).""" mb = sum(s.buy_raw for s in slots) / len(slots) ms = sum(s.sell_raw for s in slots) / len(slots) return [ PriceSlot( interval_start=s.interval_start, buy_raw=mb + (1.0 - factor) * (s.buy_raw - mb), sell_raw=ms + (1.0 - factor) * (s.sell_raw - ms), ) for s in slots ] def effective_prices(p: SiteParams, s: PriceSlot) -> tuple[float, float]: buy = s.buy_raw * (1.0 + p.buy_margin_pct / 100.0) + p.buy_margin_fixed + p.extra_buy_fees sell = s.sell_raw * (1.0 + p.sell_margin_pct / 100.0) + p.sell_margin_fixed return buy, sell def make_planning_slots( p: SiteParams, price_slots: list[PriceSlot], fix_buy: float | None ) -> list[PlanningSlot]: """fix_buy=None → spot buy; jinak plochá fixní nákupní cena. Sell vždy spot.""" out: list[PlanningSlot] = [] for s in price_slots: eff_buy, eff_sell = effective_prices(p, s) out.append( PlanningSlot( interval_start=s.interval_start, buy_price=fix_buy if fix_buy is not None else eff_buy, sell_price=eff_sell, pv_a_forecast_w=0, pv_b_forecast_w=0, load_baseline_w=int(round(load_profile_w(s.interval_start))), ev1_connected=False, ev2_connected=False, ) ) return out # --------------------------------------------------------------------------- # Solvery scénářů # --------------------------------------------------------------------------- _HP_STUB = SimpleNamespace(rated_heating_power_w=0.0, tuv_min_temp_c=0.0, tuv_target_temp_c=55.0) def solve_battery_window( p: SiteParams, slots: list[PlanningSlot], soc_wh: float, deg_czk_kwh: float, ) -> tuple[float, float]: """Vrátí (cost_adj_czk, soc_end_wh) — cash + degradace, SoC-adjusted. SoC adjust: změna SoC oceněna průměrnou buy cenou okna, aby den „nevyhrával“ vybitím baterie (stejná metodika jako economics_report). """ bat = SimpleNamespace(**vars(p.battery)) bat.degradation_cost_czk_kwh = deg_czk_kwh results, _ms, _snap = v2.solve_dispatch_v2( slots, bat, _HP_STUB, p.grid, [None, None], [], soc_wh, 50.0, tuv_delta_stats=None, operating_mode="AUTO", ) cash = sum(r.cashflow_czk for r in results) throughput_kwh = sum(abs(r.battery_setpoint_w) for r in results) * INTERVAL_H / 1000.0 deg_cost = 0.5 * throughput_kwh * deg_czk_kwh soc_end = results[-1].battery_soc_target / 100.0 * bat.usable_capacity_wh avg_buy = sum(s.buy_price for s in slots) / len(slots) soc_adj = (soc_wh - soc_end) / 1000.0 * max(0.0, avg_buy) return cash + deg_cost + soc_adj, soc_end def cost_no_battery(slots: list[PlanningSlot]) -> float: """Náklad bez baterie: load × buy (bez možnosti prodeje — žádný zdroj).""" return sum( s.load_baseline_w * INTERVAL_H / 1000.0 * s.buy_price for s in slots ) # --------------------------------------------------------------------------- # Simulační smyčky # --------------------------------------------------------------------------- def run_realistic_variant( p: SiteParams, days: dict[date, list[PriceSlot]], *, fix_buy: float | None, deg: float, compression: float = 0.0, label: str = "", ) -> dict[date, float]: """Den po dni (D−1 informační množina = ceny celého dne), řetězené SoC.""" out: dict[date, float] = {} soc = START_SOC_PCT / 100.0 * p.battery.usable_capacity_wh n = 0 for d in sorted(days): price_slots = days[d] if compression > 0.0: price_slots = compress_spread(price_slots, compression) slots = make_planning_slots(p, price_slots, fix_buy) cost, soc = solve_battery_window(p, slots, soc, deg) out[d] = cost n += 1 if n % 100 == 0: print(f" [{label}] {n}/{len(days)} dní…", file=sys.stderr) return out def run_hindsight_variant( p: SiteParams, days: dict[date, list[PriceSlot]], *, fix_buy: float | None, deg: float, ) -> dict[date, float]: """Perfect hindsight: 7denní okna s plnou znalostí, řetězené SoC. Per-day rozpad: náklad okna rozdělen podle kalendářních dnů (cash dne + poměrná degradace); SoC adjust jen na hranicích oken. """ out: dict[date, float] = {} soc = START_SOC_PCT / 100.0 * p.battery.usable_capacity_wh ordered = sorted(days) i = 0 while i < len(ordered): win_days = ordered[i : i + HINDSIGHT_WINDOW_DAYS] price_slots: list[PriceSlot] = [] for d in win_days: price_slots.extend(days[d]) slots = make_planning_slots(p, price_slots, fix_buy) bat = SimpleNamespace(**vars(p.battery)) bat.degradation_cost_czk_kwh = deg results, _ms, _snap = v2.solve_dispatch_v2( slots, bat, _HP_STUB, p.grid, [None, None], [], soc, 50.0, tuv_delta_stats=None, operating_mode="AUTO", ) soc_start = soc soc = results[-1].battery_soc_target / 100.0 * bat.usable_capacity_wh avg_buy = sum(s.buy_price for s in slots) / len(slots) window_adj = (soc_start - soc) / 1000.0 * max(0.0, avg_buy) per_day: dict[date, float] = {d: 0.0 for d in win_days} for r in results: d = r.interval_start.astimezone(PRAGUE).date() deg_cost = 0.5 * abs(r.battery_setpoint_w) * INTERVAL_H / 1000.0 * deg per_day[d] = per_day.get(d, 0.0) + r.cashflow_czk + deg_cost for j, d in enumerate(win_days): out[d] = per_day[d] + (window_adj / len(win_days)) i += len(win_days) return out def _variant_worker( params: SiteParams, days: dict[date, list[PriceSlot]], name: str, kw: dict ) -> dict[date, float]: """Worker pro ProcessPoolExecutor — jedna varianta (realistic / hindsight).""" if kw.pop("hindsight", False): return run_hindsight_variant(params, days, **kw) return run_realistic_variant(params, days, label=name, **kw) # --------------------------------------------------------------------------- # Agregace a reporting # --------------------------------------------------------------------------- def season_of(d: date) -> str: return "léto (4–9)" if d.month in SUMMER_MONTHS else "zima (10–3)" def aggregate( results: list[DayResult], key: str ) -> dict[str, tuple[int, float, float, float, float]]: """Per skupina (měsíc/sezóna): (n, A, B, C, D) průměry Kč/den.""" groups: dict[str, list[DayResult]] = {} for r in results: g = f"{r.day.year}-{r.day.month:02d}" if key == "month" else season_of(r.day) groups.setdefault(g, []).append(r) out = {} for g, rs in sorted(groups.items()): n = len(rs) out[g] = ( n, sum(r.cost_a for r in rs) / n, sum(r.cost_b for r in rs) / n, sum(r.cost_c for r in rs) / n, sum(r.cost_d for r in rs) / n, ) return out def fmt_table(agg: dict[str, tuple[int, float, float, float, float]], title: str) -> str: lines = [ f"## {title}", "", "| období | dní | A fix bez bat | B fix+bat | C spot bez bat | D spot+bat | D−A | D−B |", "|--------|-----|---------------|-----------|----------------|------------|-----|-----|", ] for g, (n, a, b, c, d) in agg.items(): lines.append( f"| {g} | {n} | {a:8.0f} | {b:8.0f} | {c:8.0f} | {d:8.0f} | {d - a:+7.0f} | {d - b:+7.0f} |" ) return "\n".join(lines) def annual_window(results: list[DayResult]) -> list[DayResult]: """Posledních 365 kalendářních dní simulace (pro roční projekci).""" if not results: return [] last = max(r.day for r in results) cutoff = last - timedelta(days=364) return [r for r in results if r.day >= cutoff] def annualize(rs: list[DayResult], getter) -> float: if not rs: return float("nan") total = sum(getter(r) for r in rs) return total * 365.0 / len(rs) # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- async def run(args: argparse.Namespace) -> None: dsn = _build_dsn(args) try: conn = await asyncpg.connect(dsn, timeout=15) except Exception as exc: # noqa: BLE001 print( f"CHYBA: nelze se připojit k EMS DB ({type(exc).__name__}: {exc}).\n" "Nastav EMS_DB_DSN (postgresql://ems_user:***@host:5432/ems) nebo\n" "DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME — viz scripts/harness/README.md.", file=sys.stderr, ) raise SystemExit(2) try: params = await load_site_params(conn) # rozsah: default = celá dostupná OTE historie (celé pražské dny) if args.range_from and args.range_to: d_from = date.fromisoformat(args.range_from) d_to = date.fromisoformat(args.range_to) else: row = await conn.fetchrow( """ select min(interval_start at time zone 'Europe/Prague')::date + 1 as mn, max(interval_start at time zone 'Europe/Prague')::date - 1 as mx from ems.market_interval_price where market_source = 'OTE_CZ' """ ) d_from, d_to = row["mn"], row["mx"] if args.quick: d_from = max(d_from, d_to - timedelta(days=59)) days, holes = await load_ote_days(conn, d_from, d_to) telemetry_n = await conn.fetchval( "select count(*) from ems.telemetry_inverter where site_id = $1", params.site_id ) finally: await conn.close() fix_buy = float(args.fix_buy) n_days = len(days) print(f"# HU1 realistická studie — site {params.site_id} ({SITE_CODE})") print(f"# OTE dny: {d_from} … {d_to} ({n_days} kompletních; díry: {[str(h) for h in holes] or 'žádné'})") print(f"# HU1 telemetrie: {telemetry_n} řádků → odběr PARAMETRIZOVANÝ " f"(bazál {LOAD_BASE_W/1000:.0f} kW + špička +{LOAD_PEAK_EXTRA_W/1000:.0f} kW " f"{PEAK_HOUR_FROM}–{PEAK_HOUR_TO} h po–pá)") print(f"# Fixní nákup: {fix_buy:.2f} Kč/kWh (PŘEDPOKLAD — site 5 má v DB spot/spot); " f"spot marže buy +{params.buy_margin_fixed:.2f} / sell {params.sell_margin_fixed:+.2f} Kč/kWh") print(f"# Baterie: {params.battery.usable_capacity_wh/1000:.0f} kWh, " f"{params.battery.max_charge_power_w/1000:.0f} kW, η {params.battery.charge_efficiency:.2f}, " f"SoC {params.battery.min_soc_wh/params.battery.usable_capacity_wh*100:.0f}–" f"{params.battery.soc_max_wh/params.battery.usable_capacity_wh*100:.0f} %, " f"deg {params.battery.degradation_cost_czk_kwh:.2f} Kč/kWh; " f"export {params.grid.max_export_power_w/1000:.0f} kW, block_neg_sell=" f"{params.grid.block_export_on_negative_sell}") print() deg_base = params.battery.degradation_cost_czk_kwh # --- bez baterie (triviální) --- results: dict[date, DayResult] = {} for d, price_slots in days.items(): slots_spot = make_planning_slots(params, price_slots, None) slots_fix = make_planning_slots(params, price_slots, fix_buy) r = DayResult(day=d) r.cost_a = cost_no_battery(slots_fix) r.cost_c = cost_no_battery(slots_spot) results[d] = r # --- realistické varianty s baterií (nezávislé běhy → paralelní procesy) --- specs: list[tuple[str, dict]] = [ ("B fix+bat", dict(fix_buy=fix_buy, deg=deg_base)), ("D spot+bat", dict(fix_buy=None, deg=deg_base)), ] for deg in DEG_SENSITIVITY: if abs(deg - deg_base) >= 1e-9: specs.append((f"D deg {deg:.2f}", dict(fix_buy=None, deg=deg))) specs += [ ("D compress −30 %", dict(fix_buy=None, deg=deg_base, compression=SPREAD_COMPRESSION)), ("D compress −30 % + deg 0.50", dict(fix_buy=None, deg=0.50, compression=SPREAD_COMPRESSION)), ("B compress −30 %", dict(fix_buy=fix_buy, deg=deg_base, compression=SPREAD_COMPRESSION)), ] if not args.skip_hindsight: specs.append(("D hindsight", dict(fix_buy=None, deg=deg_base, hindsight=True))) print(f"Simulace {len(specs)} variant × {n_days} dní (paralelně, řetězené SoC)…", file=sys.stderr) runs: dict[str, dict[date, float]] = {} with ProcessPoolExecutor(max_workers=min(len(specs), os.cpu_count() or 4)) as pool: futures = { name: pool.submit(_variant_worker, params, days, name, kw) for name, kw in specs } for name, fut in futures.items(): runs[name] = fut.result() print(f" hotovo: {name}", file=sys.stderr) b_base = runs.pop("B fix+bat") d_base = runs.pop("D spot+bat") d_hind = runs.pop("D hindsight", {}) for d in days: results[d].cost_b = b_base[d] results[d].cost_d = d_base[d] variant_runs: dict[str, dict[date, float]] = {f"D deg {deg_base:.2f}": d_base} variant_runs.update(runs) # C pod kompresí (triviální) c_compress: dict[date, float] = {} for d, price_slots in days.items(): slots = make_planning_slots(params, compress_spread(price_slots, SPREAD_COMPRESSION), None) c_compress[d] = cost_no_battery(slots) variant_runs["C compress −30 %"] = c_compress for d in days: results[d].variants = {k: v[d] for k, v in variant_runs.items()} if d_hind: results[d].variants["D hindsight"] = d_hind[d] res_list = sorted(results.values(), key=lambda r: r.day) # ----------------------- stdout report ----------------------- month_tbl = fmt_table(aggregate(res_list, "month"), "Kč/den po měsících (SoC-adjusted, vč. degradace)") season_tbl = fmt_table(aggregate(res_list, "season"), "Kč/den po sezónách") print(month_tbl, "\n") print(season_tbl, "\n") win = annual_window(res_list) ann = { "A": annualize(win, lambda r: r.cost_a), "B": annualize(win, lambda r: r.cost_b), "C": annualize(win, lambda r: r.cost_c), "D": annualize(win, lambda r: r.cost_d), } ann_var = { k: annualize(win, lambda r, _k=k: r.variants[_k]) for k in variant_runs } print("## Roční projekce (posledních 365 dní simulace, Kč/rok)") for k, v in ann.items(): print(f" {k}: {v:>10.0f}") print(f" D−A (spot+bat vs dnešní fix bez bat): {ann['D'] - ann['A']:+10.0f} Kč/rok") print(f" D−B (spot+bat vs dnešní fix+bat): {ann['D'] - ann['B']:+10.0f} Kč/rok") print(f" C−A (jen smlouva, bez baterie): {ann['C'] - ann['A']:+10.0f} Kč/rok") print(f" C−D (hodnota baterie na spotu): {ann['C'] - ann['D']:+10.0f} Kč/rok") cons_d = ann_var["D compress −30 % + deg 0.50"] cons_b = ann_var["B compress −30 %"] print(f" D−B interval: base {ann['D'] - ann['B']:+.0f} … konzervativně " f"{cons_d - cons_b:+.0f} Kč/rok (compress −30 % + deg 0.50)") print() print("## Citlivosti (roční projekce D, Kč/rok)") for k, v in ann_var.items(): print(f" {k:<32} {v:>10.0f}") print() gap_txt = "" if d_hind: common = [r for r in res_list if "D hindsight" in r.variants] tot_real = sum(r.cost_d for r in common) tot_hind = sum(r.variants["D hindsight"] for r in common) gap = tot_real - tot_hind gap_day = gap / max(1, len(common)) gap_txt = ( f"realistic {tot_real/len(common):.1f} vs hindsight {tot_hind/len(common):.1f} Kč/den; " f"GAP {gap_day:+.1f} Kč/den ({gap:+.0f} Kč za {len(common)} dní)" ) print(f"## GAP realistic − hindsight (scénář D): {gap_txt}\n") if not args.no_md: write_doc( params, fix_buy, d_from, d_to, n_days, holes, int(telemetry_n), month_tbl, season_tbl, ann, ann_var, gap_txt, res_list, ) print(f"Zapsáno: {DOC_PATH.relative_to(REPO_ROOT)}") def write_doc( p: SiteParams, fix_buy: float, d_from: date, d_to: date, n_days: int, holes: list[date], telemetry_n: int, month_tbl: str, season_tbl: str, ann: dict[str, float], ann_var: dict[str, float], gap_txt: str, res_list: list[DayResult], ) -> None: DOC_PATH.parent.mkdir(parents=True, exist_ok=True) seasons = aggregate(res_list, "season") months = aggregate(res_list, "month") worst_m, worst_db = max( ((g, v[4] - v[2]) for g, v in months.items()), key=lambda x: x[1] ) cons_db = ann_var["D compress −30 % + deg 0.50"] - ann_var["B compress −30 %"] base_db = ann["D"] - ann["B"] doc = f"""# HU1 (hulin-bess) — přechod na spot: realistická studie *Generováno skriptem `scripts/harness/hu1_realistic_eval.py` ({date.today()}).* *Opakovatelně spustitelné — viz hlavička skriptu.* ## Otázka Majitel HU1 (128 kWh / 36 kW BESS, průmyslový odběr, bez FVE/EV/TČ) rozhoduje o přechodu z fixní nákupní ceny na **spotovou smlouvu**. Dřívější perfect-hindsight studie (`hu1_bess_study.py`) dala horní mez; tady je **realistické** roční číslo — simulace den po dni tak, jak by jel plánovač. ## Metodika - **D−1 plán**: `solve_dispatch_v2` (produkční čisté jádro v2) s informační množinou OTE D−1 13:30 = ceny celého zítřka, nic za půlnoc. Terminal SoC shadow price (faktor {p.battery.planner_terminal_soc_value_factor:.1f} z DB) oceňuje energii na konci dne. - **SoC řetězené mezi dny** (konec dne N = start dne N+1); start {START_SOC_PCT:.0f} %. - **Žádný perfect hindsight** v hlavních číslech; hindsight (7denní okna, plná znalost) běží zvlášť jen kvůli GAPu. - Parametry **přesně z DB site {p.site_id}**: baterie {p.battery.usable_capacity_wh/1000:.0f} kWh, {p.battery.max_charge_power_w/1000:.0f} kW nabíjení/vybíjení, η {p.battery.charge_efficiency:.2f}/{p.battery.discharge_efficiency:.2f}, SoC {p.battery.min_soc_wh/p.battery.usable_capacity_wh*100:.0f}–{p.battery.soc_max_wh/p.battery.usable_capacity_wh*100:.0f} %, degradace {p.battery.degradation_cost_czk_kwh:.2f} Kč/kWh; grid import {p.grid.max_import_power_w/1000:.0f} kW / **export {p.grid.max_export_power_w/1000:.0f} kW povolen** (`no_export=false`), `block_export_on_negative_sell=true` (při záporné výkupní ceně se neexportuje). - Náklady **včetně degradace** (0.5 × throughput × Kč/kWh) a **SoC-adjusted** (změna SoC dne oceněna průměrnou denní nákupní cenou). - Spot ceny: raw OTE + marže z `site_market_config` site {p.site_id} (buy {p.buy_margin_fixed:+.2f}, sell {p.sell_margin_fixed:+.2f} Kč/kWh; poplatky {p.extra_buy_fees:.2f}). ### Předpoklady (nahradit reálnými čísly!) - **Fixní nákupní cena {fix_buy:.2f} Kč/kWh** — site 5 má v DB režim spot/spot, skutečná dnešní fixní smluvní cena NENÍ v DB (default = proxy z BA81 fixu). Přepsat přes `--fix-buy`. - **Odběrový profil parametrizovaný** — HU1 nemá telemetrii ({telemetry_n} řádků v `telemetry_inverter`): bazál {LOAD_BASE_W/1000:.0f} kW 24/7 + špička +{LOAD_PEAK_EXTRA_W/1000:.0f} kW {PEAK_HOUR_FROM}–{PEAK_HOUR_TO} h po–pá (konstanty v hlavičce skriptu). - Scénáře s fixem prodávají přebytek na spotu (jako BA81) — ověřit, zda to dnešní fixní smlouva vůbec umožňuje. - Svátky modelovány jako pracovní dny. ## Data - OTE 15min raw ceny (`market_interval_price`, `OTE_CZ`): **{d_from} … {d_to}**, {n_days} kompletních pražských dní; díry: {', '.join(str(h) for h in holes) if holes else 'žádné'}. Pokrývá **2 zimy** (2024/25, 2025/26) — hlavní riziko arbitráže. - HU1 telemetrie/audit: žádná skutečná spotřeba ani provoz (site v MANUAL, bez sběru dat). ## Výsledky Scénáře (Kč/den, kladné = náklad): **A** fix bez baterie · **B** fix + baterie (dnešní stav) · **C** spot bez baterie · **D** spot + baterie (návrh). {season_tbl} {month_tbl} ## Roční projekce (posledních 365 simulovaných dní) | metrika | Kč/rok | |---------|--------| | A fix bez baterie | {ann['A']:.0f} | | B fix + baterie (dnešní stav) | {ann['B']:.0f} | | C spot bez baterie | {ann['C']:.0f} | | D spot + baterie (návrh) | {ann['D']:.0f} | | **D−B: přechod na spot (s baterií)** | **{base_db:+.0f}** | | D−A: spot+baterie vs fix bez baterie | {ann['D'] - ann['A']:+.0f} | | C−A: jen změna smlouvy, bez baterie | {ann['C'] - ann['A']:+.0f} | | C−D: hodnota baterie na spotu | {ann['C'] - ann['D']:+.0f} | **Interval nejistoty D−B: {base_db:+.0f} … {cons_db:+.0f} Kč/rok** (konzervativní = spready −30 % + degradace 0.50 Kč/kWh na obou stranách). ## GAP realistic vs perfect hindsight (scénář D) {gap_txt if gap_txt else 'Hindsight varianta přeskočena (--skip-hindsight).'} ## Citlivosti (roční projekce, Kč/rok) | varianta | Kč/rok | |----------|--------| """ + "\n".join(f"| {k} | {v:.0f} |" for k, v in ann_var.items()) + f""" ## Interpretace a doporučení - **GAP realistic − hindsight ≈ 0**: OTE ceny jsou D−1 známé a odběr je v modelu deterministický → reálný D−1 plánovač o (téměř) nic nepřichází proti dokonalému vícedennímu výhledu. Rozdíl proti dřívější hindsight studii tedy dělá hlavně **sezónnost** (jarní spready), ne neznalost budoucnosti. V reálu přibude chyba predikce odběru — s reálným diagramem studii přegenerovat. - **Zima je slabší, ale zůstává kladná**: sezónní rozpad D−B — {'; '.join(f'{g}: {v[4]-v[2]:+.0f} Kč/den' for g, v in seasons.items())}. Nejslabší měsíc v datech ({worst_m}): D−B {worst_db:+.0f} Kč/den — ani v zimě s malými spready nebyl přechod na spot ztrátový. - **Doporučení (za předpokladů výše)**: přechod na spot dává base úsporu **{base_db:+.0f} Kč/rok** (D−B), konzervativně {cons_db:+.0f} Kč/rok. Před podpisem smlouvy doplnit data od majitele (níže) a studii přegenerovat — čísla škálují s odběrovým profilem a fixní cenou. ## Co chybí od majitele (zpřesnění) 1. **Skutečný odběrový diagram** (ideálně 15min/hodinová data z fakturačního elektroměru za 12 měsíců) → nahradit parametrický profil. 2. **Dnešní fixní cena** (Kč/kWh bez DPH, vč. případného VT/NT rozlišení) a regulované složky (distribuce, POZE) — zde modelována jen silová energie. 3. **Návrh spotové smlouvy**: marže dodavatele na nákup i výkup (zde {p.buy_margin_fixed:+.2f}/{p.sell_margin_fixed:+.2f} Kč/kWh), měsíční platy. 4. **Smí dnešní fixní smlouva exportovat?** (scénář B předpokládá spot výkup). 5. Rezervovaná kapacita / penalizace za překročení — ovlivní peak shaving. """ DOC_PATH.write_text(doc, encoding="utf-8") def main() -> None: p = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) p.add_argument("--from", dest="range_from", default=None, help="YYYY-MM-DD (Prague)") p.add_argument("--to", dest="range_to", default=None, help="YYYY-MM-DD (Prague), včetně") p.add_argument("--fix-buy", default=FIX_BUY_CZK_KWH_DEFAULT, type=float, help="dnešní fixní nákupní cena Kč/kWh (default proxy BA81)") p.add_argument("--quick", action="store_true", help="jen posledních 60 dní (rychlý test)") p.add_argument("--skip-hindsight", action="store_true") p.add_argument("--no-md", action="store_true", help="nezapisovat docs/studies/…") p.add_argument("--dsn", default=None) args = p.parse_args() asyncio.run(run(args)) if __name__ == "__main__": main()