Zadání majitele: čistý BESS bez odběru, export povolen, varianta bez ztrát. Výsledky (365 dní vč. zimy 25/26): arbitráž na spotu +129.4 tis. Kč/rok (η=1.0) / +110.0 tis. (η=0.9); konzervativně (spready −30 % + deg 0.5) +63.4 tis. Na fixní smlouvě jen ~24–35 tis. → spot je podmínka smysluplnosti BESS. Léto ~378 Kč/den, zima ~308. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
810 lines
34 KiB
Python
810 lines
34 KiB
Python
#!/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=(RT_EFF_OVERRIDE ** 0.5 if RT_EFF_OVERRIDE is not None
|
||
else float(b["charge_efficiency"])),
|
||
discharge_efficiency=(RT_EFF_OVERRIDE ** 0.5 if RT_EFF_OVERRIDE is not None
|
||
else 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
|
||
# ---------------------------------------------------------------------------
|
||
PURE_BESS = False #: True → nulový odběr (--pure-bess)
|
||
RT_EFF_OVERRIDE: float | None = None #: --rt-eff (1.0 = bez ztrát)
|
||
|
||
|
||
def load_profile_w(ts_utc: datetime) -> float:
|
||
"""Parametrizovaný průmyslový odběr (W) pro slot začínající ts_utc."""
|
||
if PURE_BESS:
|
||
return 0.0
|
||
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:
|
||
global PURE_BESS, RT_EFF_OVERRIDE
|
||
PURE_BESS = bool(getattr(args, "pure_bess", False))
|
||
RT_EFF_OVERRIDE = getattr(args, "rt_eff", 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("--pure-bess", action="store_true",
|
||
help="čistý BESS: nulový odběr (jen arbitráž nákup/prodej)")
|
||
p.add_argument("--rt-eff", default=None, type=float,
|
||
help="přepiš round-trip účinnost (např. 1.0 = bez ztrát, 0.9 = realisticky); default = hodnoty z DB")
|
||
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()
|