Merge commit '826c776'
This commit is contained in:
154
docs/studies/hu1-spot-realistic.md
Normal file
154
docs/studies/hu1-spot-realistic.md
Normal 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
|
||||
|
||||
- **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.
|
||||
@@ -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
|
||||
|
||||
794
scripts/harness/hu1_realistic_eval.py
Normal file
794
scripts/harness/hu1_realistic_eval.py
Normal 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Č:
|
||||
|
||||
- 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()
|
||||
Reference in New Issue
Block a user