diff --git a/docs/studies/hu1-spot-realistic.md b/docs/studies/hu1-spot-realistic.md new file mode 100644 index 0000000..c85ea57 --- /dev/null +++ b/docs/studies/hu1-spot-realistic.md @@ -0,0 +1,154 @@ +# HU1 (hulin-bess) — přechod na spot: realistická studie + +*Generováno skriptem `scripts/harness/hu1_realistic_eval.py` (2026-06-12).* +*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 0.9 z DB) oceňuje energii na konci dne. +- **SoC řetězené mezi dny** (konec dne N = start dne N+1); start 50 %. +- **Žádný perfect hindsight** v hlavních číslech; hindsight (7denní okna, + plná znalost) běží zvlášť jen kvůli GAPu. +- Parametry **přesně z DB site 5**: baterie 128 kWh, + 36 kW nabíjení/vybíjení, η 0.95/0.95, + SoC 10–95 %, + degradace 0.15 Kč/kWh; grid import 43 kW / + **export 42 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 5 + (buy +0.05, sell -0.02 Kč/kWh; poplatky 0.00). + +### Předpoklady (nahradit reálnými čísly!) + +- **Fixní nákupní cena 3.38 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 (0 řádků + v `telemetry_inverter`): bazál 4 kW 24/7 + špička +16 kW + 6–18 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`): **2024-04-14 … 2026-06-12**, + 788 kompletních pražských dní; díry: 2025-03-30, 2025-07-04. + 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). + +## Kč/den po sezónách + +| období | dní | A fix bez bat | B fix+bat | C spot bez bat | D spot+bat | D−A | D−B | +|--------|-----|---------------|-----------|----------------|------------|-----|-----| +| léto (4–9) | 425 | 789 | 760 | 460 | 131 | -658 | -629 | +| zima (10–3) | 363 | 789 | 744 | 736 | 490 | -300 | -254 | + +## Kč/den po měsících (SoC-adjusted, vč. degradace) + +| období | dní | A fix bez bat | B fix+bat | C spot bez bat | D spot+bat | D−A | D−B | +|--------|-----|---------------|-----------|----------------|------------|-----|-----| +| 2024-04 | 17 | 783 | 780 | 458 | 226 | -556 | -553 | +| 2024-05 | 31 | 806 | 804 | 394 | 130 | -676 | -673 | +| 2024-06 | 30 | 757 | 734 | 448 | 147 | -610 | -587 | +| 2024-07 | 31 | 806 | 788 | 425 | 135 | -671 | -653 | +| 2024-08 | 31 | 785 | 739 | 498 | 159 | -626 | -580 | +| 2024-09 | 30 | 779 | 736 | 493 | 181 | -598 | -555 | +| 2024-10 | 31 | 806 | 778 | 581 | 290 | -516 | -488 | +| 2024-11 | 30 | 779 | 692 | 839 | 594 | -185 | -98 | +| 2024-12 | 31 | 785 | 687 | 904 | 647 | -138 | -41 | +| 2025-01 | 31 | 806 | 754 | 851 | 635 | -171 | -119 | +| 2025-02 | 28 | 788 | 750 | 833 | 635 | -153 | -115 | +| 2025-03 | 30 | 779 | 753 | 585 | 277 | -502 | -476 | +| 2025-04 | 30 | 800 | 791 | 457 | 110 | -690 | -680 | +| 2025-05 | 31 | 785 | 773 | 385 | 36 | -749 | -737 | +| 2025-06 | 30 | 779 | 743 | 395 | 16 | -763 | -728 | +| 2025-07 | 30 | 800 | 771 | 527 | 312 | -489 | -459 | +| 2025-08 | 31 | 764 | 743 | 402 | 100 | -664 | -643 | +| 2025-09 | 30 | 800 | 719 | 558 | 136 | -664 | -583 | +| 2025-10 | 31 | 806 | 746 | 636 | 300 | -506 | -445 | +| 2025-11 | 30 | 757 | 723 | 717 | 506 | -251 | -216 | +| 2025-12 | 31 | 806 | 797 | 701 | 577 | -229 | -220 | +| 2026-01 | 31 | 785 | 739 | 878 | 669 | -116 | -70 | +| 2026-02 | 28 | 788 | 785 | 689 | 546 | -242 | -240 | +| 2026-03 | 31 | 785 | 728 | 622 | 218 | -566 | -510 | +| 2026-04 | 30 | 800 | 782 | 449 | 29 | -772 | -753 | +| 2026-05 | 31 | 764 | 717 | 485 | 75 | -689 | -641 | +| 2026-06 | 12 | 865 | 823 | 631 | 344 | -521 | -479 | + +## Roční projekce (posledních 365 simulovaných dní) + +| metrika | Kč/rok | +|---------|--------| +| A fix bez baterie | 287615 | +| B fix + baterie (dnešní stav) | 273979 | +| C spot bez baterie | 218077 | +| D spot + baterie (návrh) | 110392 | +| **D−B: přechod na spot (s baterií)** | **-163587** | +| D−A: spot+baterie vs fix bez baterie | -177223 | +| C−A: jen změna smlouvy, bez baterie | -69538 | +| C−D: hodnota baterie na spotu | +107685 | + +**Interval nejistoty D−B: -163587 … -110069 Kč/rok** +(konzervativní = spready −30 % + degradace 0.50 Kč/kWh na obou stranách). + +## GAP realistic vs perfect hindsight (scénář D) + +realistic 296.2 vs hindsight 296.2 Kč/den; GAP -0.1 Kč/den (-51 Kč za 788 dní) + +## Citlivosti (roční projekce, Kč/rok) + +| varianta | Kč/rok | +|----------|--------| +| D deg 0.15 | 110392 | +| D deg 0.50 | 130657 | +| D deg 1.00 | 153008 | +| D compress −30 % | 153358 | +| D compress −30 % + deg 0.50 | 170648 | +| B compress −30 % | 280717 | +| C compress −30 % | 220469 | + +## 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 — + léto (4–9): -629 Kč/den; zima (10–3): -254 Kč/den. + Nejslabší měsíc v datech (2024-12): D−B -41 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 + **-163587 Kč/rok** (D−B), konzervativně -110069 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 + +0.05/-0.02 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. diff --git a/scripts/harness/README.md b/scripts/harness/README.md index 86de647..449125c 100644 --- a/scripts/harness/README.md +++ b/scripts/harness/README.md @@ -10,6 +10,7 @@ projektu / plánu „Čistý plánovač“. |--------|------| | `extract_fixtures.py` | Stáhne z EMS DB kompletní vstupy plánovače (context + sloty `fn_load_planning_slots_full`) pro zadanou site a pražský den → JSON fixture do `backend/tests/golden/fixtures/`. | | `economics_report.py` | Pro rozsah dní spočítá skutečný cashflow (audit_interval) vs. **oracle LP** (perfect hindsight, čistý model bez heuristických penalt) → tabulka GAP per den. | +| `hu1_realistic_eval.py` | Investiční studie HU1 (hulin-bess): realistická den-po-dni simulace BESS na spotu přes `solve_dispatch_v2` (D−1 informační množina, řetězené SoC), scénáře fix/spot × bez/s baterií, citlivosti, GAP vs hindsight; generuje `docs/studies/hu1-spot-realistic.md`. Perfect-hindsight horní mez: `hu1_bess_study.py`. | | `../../backend/tests/test_golden_replay.py` | Pytest gate: replay fixtures přes `solve_dispatch_two_pass`, porovnání s golden snapshoty v `backend/tests/golden/snapshots/`. | ## Připojení k DB diff --git a/scripts/harness/hu1_realistic_eval.py b/scripts/harness/hu1_realistic_eval.py new file mode 100644 index 0000000..f9a2f60 --- /dev/null +++ b/scripts/harness/hu1_realistic_eval.py @@ -0,0 +1,794 @@ +#!/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()