Merge commit '826c776'
All checks were successful
CI and deploy / migration-check (push) Successful in 27s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-06-12 22:00:45 +02:00
3 changed files with 949 additions and 0 deletions

View File

@@ -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
- **D1 plán**: `solve_dispatch_v2` (produkční čisté jádro v2) s informační
množinou OTE D1 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 1095 %,
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
618 h popá (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 | DA | DB |
|--------|-----|---------------|-----------|----------------|------------|-----|-----|
| léto (49) | 425 | 789 | 760 | 460 | 131 | -658 | -629 |
| zima (103) | 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 | DA | DB |
|--------|-----|---------------|-----------|----------------|------------|-----|-----|
| 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 |
| **DB: přechod na spot (s baterií)** | **-163587** |
| DA: spot+baterie vs fix bez baterie | -177223 |
| CA: jen změna smlouvy, bez baterie | -69538 |
| CD: hodnota baterie na spotu | +107685 |
**Interval nejistoty DB: -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 D1 známé a odběr je
v modelu deterministický → reálný D1 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 DB —
léto (49): -629 Kč/den; zima (103): -254 Kč/den.
Nejslabší měsíc v datech (2024-12): DB -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** (DB), 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.

View File

@@ -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` (D1 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

View File

@@ -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Č:
- D1 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ý D1 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 popá (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“ = dubenzáří
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 (D1 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 (49)" if d.month in SUMMER_MONTHS else "zima (103)"
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 | DA | DB |",
"|--------|-----|---------------|-----------|----------------|------------|-----|-----|",
]
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 popá)")
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" DA (spot+bat vs dnešní fix bez bat): {ann['D'] - ann['A']:+10.0f} Kč/rok")
print(f" DB (spot+bat vs dnešní fix+bat): {ann['D'] - ann['B']:+10.0f} Kč/rok")
print(f" CA (jen smlouva, bez baterie): {ann['C'] - ann['A']:+10.0f} Kč/rok")
print(f" CD (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" DB 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
- **D1 plán**: `solve_dispatch_v2` (produkční čisté jádro v2) s informační
množinou OTE D1 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 popá (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} |
| **DB: přechod na spot (s baterií)** | **{base_db:+.0f}** |
| DA: spot+baterie vs fix bez baterie | {ann['D'] - ann['A']:+.0f} |
| CA: jen změna smlouvy, bez baterie | {ann['C'] - ann['A']:+.0f} |
| CD: hodnota baterie na spotu | {ann['C'] - ann['D']:+.0f} |
**Interval nejistoty DB: {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 D1 známé a odběr je
v modelu deterministický → reálný D1 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 DB —
{'; '.join(f'{g}: {v[4]-v[2]:+.0f} Kč/den' for g, v in seasons.items())}.
Nejslabší měsíc v datech ({worst_m}): DB {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** (DB), 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()