HU1: realistická studie přechodu na spot (den-po-dni solver_v2, 2 roky OTE)
scripts/harness/hu1_realistic_eval.py — realistická simulace BESS 128 kWh/36 kW na spotu místo perfect hindsight: D−1 plán přes solve_dispatch_v2 (informační množina = ceny zítřka z OTE D−1 13:30), SoC řetězené mezi dny, parametry baterie/grid z DB site 5 (fn_planning_site_context). Scénáře fix/spot × bez/s baterií, Kč/den po měsících a sezónách, roční projekce, citlivosti (degradace 0.15/0.5/1.0, spread compression −30 %), GAP vs 7denní hindsight. Varianty běží paralelně (ProcessPoolExecutor). HU1 nemá telemetrii → parametrizovaný průmyslový odběr (konstanty v hlavičce, přepsat čísly od majitele); fixní cena = proxy BA81 (--fix-buy). Výsledky (788 dní 2024-04-14…2026-06-12, 2 zimy): D−B (přechod na spot s baterií) −163,6 tis. Kč/rok base, konzervativně −110,1 tis.; léto −629, zima −254 Kč/den, nejhorší měsíc (12/2024) −41 Kč/den — stále úspora. GAP realistic vs hindsight ≈ 0 (ceny jsou D−1 známé) → dřívější horní mez byla nadhodnocená sezónností, ne neznalostí budoucnosti. Doc: docs/studies/hu1-spot-realistic.md (generuje skript, opakovatelné); README harness doplněn. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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/`. |
|
| `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. |
|
| `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/`. |
|
| `../../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
|
## 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