#!/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()
