Fáze 0: ekonomický regresní harness plánovače

- scripts/harness/extract_fixtures.py: extrakce vstupů solveru
  (fn_planning_site_context + fn_load_planning_slots_full) do JSON fixtures
- backend/tests/test_golden_replay.py: golden gate — replay fixtures přes
  solve_dispatch_two_pass, bit-perfektní diff proti snapshotům (GOLDEN_UPDATE=1
  pro vědomou regeneraci); 4 scénáře: home-01 neg-sell extrém / normal, BA81, KV1
- scripts/harness/economics_report.py: actual (audit_interval) vs oracle MILP
  (perfect hindsight, čistá ekonomika bez heuristických penalt), SoC-adjusted

Baseline home-01 2026-05-12..06-09: GAP 2185 Kč / 29 dní (~27 %).
Známý stav: 4/124 testů test_planning_dispatch_milp.py failuje už na main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dusan Vojacek
2026-06-11 10:48:13 +02:00
parent edc8ae9774
commit 484f1f85fc
12 changed files with 35461 additions and 0 deletions

View File

@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""
Fáze 0 ekonomický regresní harness: extrakce golden fixtures z EMS DB.
Pro zadanou lokalitu a pražský den stáhne KOMPLETNÍ vstupy plánovače:
- ems.fn_planning_site_context(site_id) → context jsonb (battery, grid, TČ, vozidla, TUV stats)
- ems.fn_load_planning_slots_full(...) → všechny sloupce slotů (ceny, forecast, masky, charge budget)
- SoC na začátku okna z ems.audit_interval (actual_battery_soc_pct)
a uloží je jako JSON fixture do backend/tests/golden/fixtures/. Fixtures jsou
vstupem replay runneru (test_golden_replay.py), který nad nimi spouští
solve_dispatch_two_pass a porovnává výstup s golden snapshotem.
Čtení z DB je read-only (SELECT). DSN: --dsn > EMS_DB_DSN > DB_HOST/DB_PORT/
DB_USER/DB_PASSWORD (default 127.0.0.1:5432/ems jako docker-compose).
Příklady:
python3 scripts/harness/extract_fixtures.py --site-code home-01 --day 2026-06-07 --tag neg_sell_deep
EMS_DB_DSN=postgresql://ems_user:***@10.200.200.1:5432/ems \
python3 scripts/harness/extract_fixtures.py --site-code KV1 --day 2026-06-09 --tag fixed_normal
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
from datetime import datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
import asyncpg
PRAGUE = ZoneInfo("Europe/Prague")
REPO_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_OUT_DIR = REPO_ROOT / "backend" / "tests" / "golden" / "fixtures"
FIXTURE_VERSION = 1
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}"
def _json_default(obj: object) -> str:
if isinstance(obj, datetime):
return obj.isoformat()
return str(obj)
async def _fetch_site_id(conn: asyncpg.Connection, site_code: str) -> int:
row = await conn.fetchrow("select id from ems.site where code = $1", site_code)
if row is None:
raise SystemExit(f"Site code '{site_code}' nenalezen v ems.site")
return int(row["id"])
async def _fetch_context(conn: asyncpg.Connection, site_id: int) -> dict:
raw = await conn.fetchval("select ems.fn_planning_site_context($1::int)", site_id)
ctx = raw if isinstance(raw, dict) else json.loads(raw)
if ctx.get("error") == "unknown_site":
raise SystemExit(f"fn_planning_site_context: unknown_site pro id={site_id}")
return ctx
async def _fetch_soc_at(conn: asyncpg.Connection, site_id: int, at: datetime, usable_wh: float) -> float | None:
"""SoC (Wh) na začátku okna z audit_interval; None pokud audit chybí."""
row = await conn.fetchrow(
"""
select actual_battery_soc_pct
from ems.audit_interval
where site_id = $1 and interval_start = $2
""",
site_id,
at,
)
if row is None or row["actual_battery_soc_pct"] is None:
return None
return float(row["actual_battery_soc_pct"]) / 100.0 * usable_wh
async def _fetch_slots(
conn: asyncpg.Connection, site_id: int, from_dt: datetime, to_dt: datetime, soc_wh: float
) -> list[dict]:
rows = await conn.fetch(
"""
select slot_ord, interval_start, buy_price, sell_price, is_predicted_price,
pv_a_forecast_w, pv_b_forecast_w, load_baseline_w,
ev1_connected, ev2_connected, allow_charge, allow_discharge_export,
night_baseload_target_wh, night_baseload_buffer_wh, safety_soc_target_wh,
future_avoided_buy_czk_kwh, future_sell_opportunity_czk_kwh,
is_daytime_pv_surplus_slot,
charge_acquisition_buy_czk_kwh, charge_acquisition_cutoff_at,
min_buy_before_cutoff_czk_kwh, pv_charge_wh_ahead, neg_buy_wh_ahead,
grid_charge_suppressed_reason,
charge_target_wh, pre_window_wh, in_window_wh,
charge_slot_wh, charge_cum_wh, charge_layer, charge_slot_reason
from ems.fn_load_planning_slots_full(
$1::int, $2::timestamptz, $3::timestamptz, $4::numeric
)
""",
site_id,
from_dt,
to_dt,
soc_wh,
)
out: list[dict] = []
for r in rows:
d = dict(r)
for key, val in list(d.items()):
if isinstance(val, datetime):
d[key] = val.isoformat()
elif val is not None and type(val).__name__ == "Decimal":
d[key] = float(val)
out.append(d)
return out
async def extract(args: argparse.Namespace) -> Path:
dsn = _build_dsn(args)
conn = await asyncpg.connect(dsn)
try:
site_id = await _fetch_site_id(conn, args.site_code)
ctx = await _fetch_context(conn, site_id)
day = datetime.strptime(args.day, "%Y-%m-%d").replace(tzinfo=PRAGUE)
from_dt = day
to_dt = day + timedelta(hours=args.hours)
usable_wh = float(ctx["battery"]["usable_capacity_wh"])
soc_wh = await _fetch_soc_at(conn, site_id, from_dt, usable_wh)
soc_source = "audit_interval"
if soc_wh is None:
soc_wh = 0.5 * usable_wh
soc_source = "fallback_50pct"
slot_rows = await _fetch_slots(conn, site_id, from_dt, to_dt, soc_wh)
if not slot_rows:
raise SystemExit(
f"fn_load_planning_slots_full nevrátila žádné sloty pro {args.site_code} {args.day}"
)
# Determinismus replay:
# - SoC/TUV fixujeme do contextu (přepis aktuálních hodnot historickými / extrakčními),
# - otevřené EV sessions z doby extrakce nepatří k historickému oknu → vynulovat,
# - operating_mode fixně AUTO (plný solver, srovnatelnost napříč fixtures).
ctx["soc_wh"] = soc_wh
ctx["ev_sessions"] = []
ctx["operating_mode"] = "AUTO"
fixture = {
"fixture_version": FIXTURE_VERSION,
"meta": {
"site_id": site_id,
"site_code": args.site_code,
"prague_day": args.day,
"window_from": from_dt.isoformat(),
"window_to": to_dt.isoformat(),
"horizon_hours": args.hours,
"soc_wh": round(soc_wh, 1),
"soc_source": soc_source,
"tag": args.tag,
"extracted_at": datetime.now(tz=PRAGUE).isoformat(),
"dsn_host": dsn.split("@")[-1].split("/")[0] if "@" in dsn else "?",
"note": (
"Vstupy plánovače zmrazené k okamžiku extrakce (context = aktuální konfigurace, "
"sloty = fn_load_planning_slots_full nad historickými cenami/forecasty). "
"EV sessions vynulovány, operating_mode=AUTO."
),
},
"context_json": ctx,
"slot_rows": slot_rows,
}
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
name = f"{args.site_code}_{args.day}_{args.tag}.json".replace("/", "-")
out_path = out_dir / name
out_path.write_text(
json.dumps(fixture, ensure_ascii=False, indent=1, default=_json_default) + "\n",
encoding="utf-8",
)
print(
f"OK {out_path.relative_to(REPO_ROOT)}: {len(slot_rows)} slotů, "
f"soc={soc_wh:.0f} Wh ({soc_source}), "
f"buy {min(s['buy_price'] for s in slot_rows):.2f}..{max(s['buy_price'] for s in slot_rows):.2f}, "
f"sell {min(s['sell_price'] for s in slot_rows):.2f}..{max(s['sell_price'] for s in slot_rows):.2f} Kč/kWh"
)
return out_path
finally:
await conn.close()
def main() -> None:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--site-code", required=True, help="ems.site.code (home-01, BA81, KV1, …)")
p.add_argument("--day", required=True, help="Pražský den YYYY-MM-DD (začátek okna 00:00)")
p.add_argument("--hours", type=int, default=36, help="Délka okna v hodinách (default 36)")
p.add_argument("--tag", required=True, help="Krátký popis scénáře (neg_sell_deep, normal, …)")
p.add_argument("--dsn", default=None, help="postgresql:// DSN (jinak EMS_DB_DSN / DB_* env)")
p.add_argument("--out-dir", default=str(DEFAULT_OUT_DIR), help="Cílový adresář fixtures")
args = p.parse_args()
asyncio.run(extract(args))
if __name__ == "__main__":
sys.exit(main())