#!/usr/bin/env python3
"""
Studie navýšení kapacity baterie (BA81 → 32 kWh, KV1 → 25 kWh) nad REÁLNÝMI daty.

Metoda: perfect-hindsight MILP (rozšířený oracle z economics_report) nad skutečnou
PV výrobou, spotřebou a efektivními cenami z ems.audit_interval — po TÝDENNÍCH
oknech s navazujícím SoC mezi okny (zachytí vícedenní arbitráž). Pro každou
lokalitu tři konfigurace:
  1. current      — stávající baterie z DB,
  2. upgrade/keepP — cílová kapacita, výkon beze změny (střídačem limitované),
  3. upgrade/0.5C  — cílová kapacita, výkon 0.5C jako dnes (BMS limitované).

Δ oracle cashflow = HORNÍ MEZ přínosu (dokonalá předpověď). Reálný plánovač
zachytí část — viz capture ratio v reportu. Extrapolace na rok je poctivě
označená (data jaro/léto: vysoká PV, záporné ceny → zima přinese méně).

BA81 specifikum: pole B (GEN mikroinvertory) lze fyzicky odpojit (gen cutoff)
→ model dovoluje shed PV B (jinak by nucený export při sell<0 zkresloval).

Spouštět odkudkoli:  EMS_DB_DSN=… python3 scripts/harness/battery_upgrade_study.py
"""

from __future__ import annotations

import asyncio
import os
import sys
from dataclasses import dataclass, replace
from datetime import datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo

import asyncpg
import pulp

PRAGUE = ZoneInfo("Europe/Prague")
INTERVAL_H = 0.25

STUDY = [
    # (site_code, target_usable_kwh)
    ("BA81", 32.0),
    ("KV1", 25.0),
]
WINDOW_DAYS = 7


@dataclass
class Bat:
    usable_wh: float
    min_pct: float
    max_pct: float
    eff_c: float
    eff_d: float
    deg_czk_kwh: float
    max_charge_w: float
    max_discharge_w: float

    @property
    def min_wh(self) -> float:
        return self.min_pct / 100.0 * self.usable_wh

    @property
    def max_wh(self) -> float:
        return self.max_pct / 100.0 * self.usable_wh


@dataclass
class Slot:
    ts: datetime
    buy: float
    sell: float
    pv_a_wh: float
    pv_b_wh: float
    load_wh: float


def _dsn() -> str:
    return os.environ.get(
        "EMS_DB_DSN",
        "postgresql://ems_user:dev_password@10.200.200.1:5432/ems",
    )


async def _load_site(conn: asyncpg.Connection, code: str):
    row = await conn.fetchrow(
        """
        select s.id as site_id, b.usable_capacity_wh, b.min_soc_percent, b.max_soc_percent,
               b.charge_efficiency, b.discharge_efficiency, b.degradation_cost_czk_kwh,
               b.max_charge_c_rate, b.bms_max_charge_w, b.bms_max_discharge_w,
               g.max_import_power_w, g.max_export_power_w, g.block_export_on_negative_sell,
               coalesce(i.deye_gen_microinverter_cutoff_enabled, false) as gen_cutoff,
               i.max_ac_output_w, i.max_battery_charge_w as inv_bat_charge_w,
               i.max_battery_discharge_w as inv_bat_discharge_w
        from ems.asset_battery b
        join ems.site s on s.id = b.site_id
        join ems.asset_inverter i on i.id = b.inverter_id
        left join ems.site_grid_connection g on g.site_id = s.id
        where s.code = $1
        """,
        code,
    )
    if row is None:
        raise SystemExit(f"site {code} nenalezen")
    inv_chg = float(row["inv_bat_charge_w"] or 10**9)
    inv_dis = float(row["inv_bat_discharge_w"] or 10**9)
    bat = Bat(
        usable_wh=float(row["usable_capacity_wh"]),
        min_pct=float(row["min_soc_percent"]),
        max_pct=float(row["max_soc_percent"]),
        eff_c=float(row["charge_efficiency"]),
        eff_d=float(row["discharge_efficiency"]),
        deg_czk_kwh=float(row["degradation_cost_czk_kwh"]),
        max_charge_w=min(float(row["bms_max_charge_w"]), inv_chg),
        max_discharge_w=min(float(row["bms_max_discharge_w"]), inv_dis),
    )
    grid = {
        "imp_w": float(row["max_import_power_w"]),
        "exp_w": float(row["max_export_power_w"]),
        "block_neg": bool(row["block_export_on_negative_sell"]),
        "pv_b_shed": bool(row["gen_cutoff"]),
        "c_rate": float(row["max_charge_c_rate"] or 0.5),
        "ac_w": float(row["max_ac_output_w"] or 10**9),       # strop AC výstupu hybridu
        "inv_bat_w": min(inv_chg, inv_dis),                    # strop bateriové cesty střídače
    }
    return int(row["site_id"]), bat, grid


async def _load_slots(conn: asyncpg.Connection, site_id: int) -> list[Slot]:
    rows = await conn.fetch(
        """
        select a.interval_start,
               p.effective_buy_price_czk_kwh as buy,
               p.effective_sell_price_czk_kwh as sell,
               -- POTENCIÁL: při sell<0 lokalita škrtí výrobu (reg 340 / GEN cutoff),
               -- telemetrie ji nevidí → použij max(skutečnost, predikce) per pole.
               case when p.effective_sell_price_czk_kwh < 0
                 then greatest(coalesce(a.actual_pv_production_wh,0) - coalesce(a.pv_b_production_wh,0),
                               coalesce(fc.fc_a_wh, 0))
                 else greatest(0, coalesce(a.actual_pv_production_wh,0) - coalesce(a.pv_b_production_wh,0))
               end as pv_a,
               case when p.effective_sell_price_czk_kwh < 0
                 then greatest(coalesce(a.pv_b_production_wh,0), coalesce(fc.fc_b_wh, 0))
                 else coalesce(a.pv_b_production_wh,0)
               end as pv_b,
               coalesce(a.actual_load_consumption_wh,0) as load
        from ems.audit_interval a
        join ems.vw_site_effective_price p
          on p.site_id = a.site_id and p.interval_start = a.interval_start
        left join lateral (
          select
            sum(power_w) filter (where pa.controllable) * 0.25 as fc_a_wh,
            sum(power_w) filter (where not pa.controllable) * 0.25 as fc_b_wh
          from (
            select distinct on (fpr.pv_array_id) fpi2.power_w, fpr.pv_array_id
            from ems.forecast_pv_interval fpi2
            join ems.forecast_pv_run fpr on fpr.id = fpi2.run_id
            where fpi2.interval_start = a.interval_start
            order by fpr.pv_array_id, fpr.created_at desc
          ) x
          join ems.asset_pv_array pa on pa.id = x.pv_array_id and pa.site_id = a.site_id
        ) fc on true
        where a.site_id = $1 and a.actual_load_consumption_wh is not null
        order by a.interval_start
        """,
        site_id,
    )
    return [
        Slot(r["interval_start"], float(r["buy"]), float(r["sell"]),
             float(r["pv_a"]), float(r["pv_b"]), float(r["load"]))
        for r in rows
    ]


def solve_window(slots: list[Slot], bat: Bat, grid: dict, soc0: float) -> tuple[float, float]:
    """Vrátí (cash+deg náklad okna v Kč, koncový SoC Wh). Min = lepší."""
    n = len(slots)
    prob = pulp.LpProblem("w", pulp.LpMinimize)
    mc = bat.max_charge_w * INTERVAL_H
    md = bat.max_discharge_w * INTERVAL_H
    mi = grid["imp_w"] * INTERVAL_H
    me = grid["exp_w"] * INTERVAL_H

    gi = [pulp.LpVariable(f"gi{t}", 0, mi) for t in range(n)]
    ge = [pulp.LpVariable(f"ge{t}", 0, me) for t in range(n)]
    bc = [pulp.LpVariable(f"bc{t}", 0, mc) for t in range(n)]
    bd = [pulp.LpVariable(f"bd{t}", 0, md) for t in range(n)]
    pa = [pulp.LpVariable(f"pa{t}", 0, slots[t].pv_a_wh) for t in range(n)]
    pb = [pulp.LpVariable(f"pb{t}", 0, slots[t].pv_b_wh) for t in range(n)]
    soc = [pulp.LpVariable(f"s{t}", bat.min_wh, bat.max_wh) for t in range(n)]
    y = [pulp.LpVariable(f"y{t}", cat="Binary") for t in range(n)]

    for t in range(n):
        s = slots[t]
        pb_used = pb[t] if grid["pv_b_shed"] else s.pv_b_wh
        prob += pa[t] + pb_used + gi[t] + bd[t] == s.load_wh + bc[t] + ge[t]
        prev = soc0 if t == 0 else soc[t - 1]
        prob += soc[t] == prev + bc[t] * bat.eff_c - bd[t] / bat.eff_d
        prob += gi[t] <= mi * y[t]
        prob += ge[t] <= me * (1 - y[t])
        # AC strop hybridního střídače: export jde přes Deye kromě pole B (GEN)
        prob += ge[t] - (pb[t] if grid["pv_b_shed"] else s.pv_b_wh) <= grid["ac_w"] * INTERVAL_H
        if s.sell < 0 and grid["block_neg"]:
            prob += ge[t] == 0
        if s.buy < 0:
            prob += ge[t] == 0

    avg_buy = sum(s.buy for s in slots) / n
    cash = pulp.lpSum(gi[t] / 1000 * slots[t].buy - ge[t] / 1000 * slots[t].sell for t in range(n))
    deg = pulp.lpSum(0.5 * (bc[t] + bd[t]) / 1000 * bat.deg_czk_kwh for t in range(n))
    prob += cash + deg - soc[n - 1] / 1000 * max(0.0, avg_buy)

    solver = pulp.HiGHS_CMD(msg=False, timeLimit=30) if pulp.HiGHS_CMD().available() else pulp.PULP_CBC_CMD(msg=False)
    prob.solve(solver)
    if pulp.LpStatus[prob.status] != "Optimal":
        raise RuntimeError(pulp.LpStatus[prob.status])
    val = float(pulp.value(cash)) + float(pulp.value(deg))
    return val, float(soc[n - 1].value())


def run_config(slots: list[Slot], bat: Bat, grid: dict) -> tuple[float, int]:
    """Sekvenčně po oknech, navazující SoC. Vrátí (Σ náklad Kč, počet dní)."""
    total = 0.0
    soc = bat.min_wh + 0.3 * (bat.max_wh - bat.min_wh)
    days = 0
    i = 0
    while i < len(slots):
        win = slots[i : i + WINDOW_DAYS * 96]
        if len(win) < 96:  # neúplný zbytek
            break
        cost, soc = solve_window(win, bat, grid, soc)
        total += cost
        days += len(win) // 96
        i += len(win)
    return total, days


async def main() -> None:
    conn = await asyncpg.connect(_dsn())
    try:
        print("# Studie navýšení baterie — perfect-hindsight nad reálnými daty (audit_interval)")
        print(f"# Okna {WINDOW_DAYS} dní s navazujícím SoC; Δ = horní mez ročního přínosu\n")
        for code, target_kwh in STUDY:
            site_id, bat_cur, grid = await _load_site(conn, code)
            slots = await _load_slots(conn, site_id)
            if not slots:
                print(f"{code}: žádná audit data, přeskočeno")
                continue
            d0, d1 = slots[0].ts.date(), slots[-1].ts.date()

            bat_up_keep = replace(bat_cur, usable_wh=target_kwh * 1000.0)
            p_c = min(target_kwh * 1000.0 * grid["c_rate"], grid["ac_w"])
            bat_up_c = replace(
                bat_cur,
                usable_wh=target_kwh * 1000.0,
                max_charge_w=p_c,
                max_discharge_w=p_c,
            )
            configs = [
                (f"current {bat_cur.usable_wh/1000:.1f} kWh / {bat_cur.max_charge_w/1000:.2f} kW", bat_cur),
                (f"upgrade {target_kwh:.0f} kWh / {bat_up_keep.max_charge_w/1000:.2f} kW (výkon beze změny)", bat_up_keep),
                (f"upgrade {target_kwh:.0f} kWh / {bat_up_c.max_charge_w/1000:.2f} kW (0.5C, cap AC stridace)", bat_up_c),
            ]
            print(f"## {code}  ({d0} … {d1}; block_neg={grid['block_neg']}, pv_b_shed={grid['pv_b_shed']}, export cap {grid['exp_w']/1000:.0f} kW)")
            base_cost = None
            base_days = None
            for label, bat in configs:
                cost, days = run_config(slots, bat, grid)
                if base_cost is None:
                    base_cost, base_days = cost, days
                    print(f"  {label:<55} {cost:>10.0f} Kč / {days} dní")
                else:
                    d = base_cost - cost
                    per_day = d / max(1, days)
                    print(
                        f"  {label:<55} {cost:>10.0f} Kč   Δ {d:+.0f} Kč "
                        f"({per_day:+.2f} Kč/den; rok ~{per_day*365*0.6:.0f}–{per_day*365:.0f} Kč)"
                    )
            print()
        print("Pozn.: rok = Kč/den × 365; dolní odhad ×0.6 (zima: méně PV, menší spready).")
        print("Horní mez (dokonalá předpověď) — reálný plánovač zachytí typicky 70–90 %.")
    finally:
        await conn.close()


if __name__ == "__main__":
    sys.exit(asyncio.run(main()))
