Files
ems/backend/tests/test_golden_replay.py
Dusan Vojacek 9a2229641d Fáze 2.1: 4 zastaralé testy → expectedFailure; +2 fixtures vč. Infeasible reproduceru
Analýza (agent + ručně): všechny 4 failující testy vynucují heuristické chování
před retry-chain v5; současné chování je ekonomicky správné nebo jde o korektní
fallback. Scénáře zachovány s @unittest.expectedFailure + zdůvodněním —
přepsat na ekonomické asserty ve Fázi 3. Suite: 120 passed, 4 xfailed.

Nové golden fixtures home-01: 2026-05-01 extreme_neg_buy (buy −13.26;
ZACHYCENO: solver Infeasible po celém relax řetězci — zmrazeno jako golden
failure snapshot), 2026-05-25 evening_push. Golden replay i penalty audit
umí solver_error výsledky (penalta měnící feasibility se zviditelní).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 13:56:12 +02:00

206 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Fáze 0 golden replay gate plánovače (bez DB).
Pro každou fixture v tests/golden/fixtures/ (kompletní vstupy solveru zmrazené
z reálné DB skriptem scripts/harness/extract_fixtures.py) spustí
solve_dispatch_two_pass a porovná normalizovaný výstup s golden snapshotem
v tests/golden/snapshots/.
Účel: regresní brána pro dekompozici planning_engine.py — identity refactor
musí držet výstupy bit-perfektně (floaty zaokrouhleny na 4 d.m.).
Regenerace snapshotů (vědomá změna chování):
GOLDEN_UPDATE=1 python3 -m pytest tests/test_golden_replay.py -q
Replay jde STEJNOU cestou jako produkce: _load_site_context + _load_slots nad
fixture stubem DB → žádná duplikace mapování DB → objekty.
"""
from __future__ import annotations
import asyncio
import json
import os
import unittest
from datetime import datetime
from pathlib import Path
from services import planning_engine as pe
GOLDEN_DIR = Path(__file__).resolve().parent / "golden"
FIXTURES_DIR = GOLDEN_DIR / "fixtures"
SNAPSHOTS_DIR = GOLDEN_DIR / "snapshots"
_DT_SLOT_KEYS = ("interval_start", "charge_acquisition_cutoff_at")
class _FixtureDB:
"""Stub asyncpg connection: vrací zmrazený context a sloty z fixture."""
def __init__(self, fixture: dict):
self._fixture = fixture
async def fetchval(self, query: str, *args):
assert "fn_planning_site_context" in query, f"Nečekaný fetchval: {query!r}"
return json.dumps(self._fixture["context_json"])
async def fetch(self, query: str, *args):
assert "fn_load_planning_slots_full" in query, f"Nečekaný fetch: {query!r}"
rows: list[dict] = []
for raw in self._fixture["slot_rows"]:
d = dict(raw)
for key in _DT_SLOT_KEYS:
if d.get(key):
d[key] = datetime.fromisoformat(d[key])
rows.append(d)
return rows
def _round(val: float, places: int = 4) -> float:
out = round(float(val), places)
return 0.0 if out == 0.0 else out # normalizace -0.0
def _normalize_results(results: list) -> dict:
rows = []
for r in results:
rows.append(
{
"interval_start": r.interval_start.isoformat(),
"battery_setpoint_w": int(r.battery_setpoint_w),
"battery_soc_target": _round(r.battery_soc_target, 2),
"grid_setpoint_w": int(r.grid_setpoint_w),
"export_limit_w": int(r.export_limit_w),
"export_mode": r.export_mode,
"deye_physical_mode": r.deye_physical_mode,
"deye_gen_cutoff_enabled": r.deye_gen_cutoff_enabled,
"ev1_setpoint_w": r.ev1_setpoint_w,
"ev2_setpoint_w": r.ev2_setpoint_w,
"ev1_via_bat_w": int(r.ev1_via_bat_w),
"ev2_via_bat_w": int(r.ev2_via_bat_w),
"heat_pump_enabled": bool(r.heat_pump_enabled),
"heat_pump_setpoint_w": int(r.heat_pump_setpoint_w),
"pv_a_curtailed_w": int(r.pv_a_curtailed_w),
"expected_cost_czk": _round(r.expected_cost_czk),
"cashflow_czk": _round(r.cashflow_czk),
"battery_arbitrage_czk": _round(r.battery_arbitrage_czk),
"penalty_czk": _round(r.penalty_czk),
"green_bonus_czk": _round(r.green_bonus_czk),
}
)
totals = {
"slots": len(rows),
"expected_cost_czk": _round(sum(r["expected_cost_czk"] for r in rows), 3),
"cashflow_czk": _round(sum(r["cashflow_czk"] for r in rows), 3),
"penalty_czk": _round(sum(r["penalty_czk"] for r in rows), 3),
"grid_import_slots": sum(1 for r in rows if r["grid_setpoint_w"] > 0),
"grid_export_slots": sum(1 for r in rows if r["grid_setpoint_w"] < 0),
"curtail_slots": sum(1 for r in rows if r["pv_a_curtailed_w"] > 0),
}
return {"totals": totals, "slots": rows}
def _replay_fixture(fixture: dict) -> dict:
async def _run() -> dict:
db = _FixtureDB(fixture)
meta = fixture["meta"]
(
battery,
heat_pump,
grid,
vehicles,
ev_sessions,
soc_wh,
tuv_temp,
operating_mode,
tuv_stats,
) = await pe._load_site_context(int(meta["site_id"]), db)
slots = await pe._load_slots(
int(meta["site_id"]),
datetime.fromisoformat(meta["window_from"]),
datetime.fromisoformat(meta["window_to"]),
db,
soc_wh=soc_wh,
)
try:
results, _ms, _snap = pe.solve_dispatch_two_pass(
slots,
battery,
heat_pump,
grid,
ev_sessions,
vehicles,
soc_wh,
tuv_temp,
tuv_delta_stats=tuv_stats,
operating_mode=operating_mode or "AUTO",
planner_version=pe._planner_engine_version(),
)
except pe.PlannerSolverError as exc:
# Selhání solveru je taky chování k zafixování (např. home-01 2026-05-01:
# Infeasible po celém relax řetězci). Až ho Fáze 2/3 opraví, golden diff
# to zviditelní a snapshot se vědomě zregeneruje.
return {
"solver_error": exc.solver_status,
"relax_chain": list(exc.relax_chain),
}
return _normalize_results(results)
return asyncio.run(_run())
def _fixture_paths() -> list[Path]:
return sorted(FIXTURES_DIR.glob("*.json"))
class GoldenReplayTests(unittest.TestCase):
maxDiff = None
def test_fixtures_exist(self) -> None:
self.assertTrue(
_fixture_paths(),
f"Žádné fixtures v {FIXTURES_DIR} spusť scripts/harness/extract_fixtures.py",
)
def _make_test(path: Path):
def test(self: GoldenReplayTests) -> None:
fixture = json.loads(path.read_text(encoding="utf-8"))
actual = _replay_fixture(fixture)
snap_path = SNAPSHOTS_DIR / path.name
if os.environ.get("GOLDEN_UPDATE") == "1":
SNAPSHOTS_DIR.mkdir(parents=True, exist_ok=True)
snap_path.write_text(
json.dumps(actual, ensure_ascii=False, indent=1) + "\n", encoding="utf-8"
)
return
self.assertTrue(
snap_path.exists(),
f"Chybí snapshot {snap_path.name} vygeneruj přes GOLDEN_UPDATE=1",
)
expected = json.loads(snap_path.read_text(encoding="utf-8"))
if "solver_error" in expected or "solver_error" in actual:
self.assertEqual(expected, actual, f"{path.name}: změna výsledku/selhání solveru")
return
self.assertEqual(
expected["totals"],
actual["totals"],
f"{path.name}: změna agregátů plánu (totals)",
)
self.assertEqual(
expected["slots"],
actual["slots"],
f"{path.name}: změna plánu per slot",
)
return test
for _path in _fixture_paths():
_name = "test_golden_" + _path.stem.replace("-", "_").replace(".", "_")
setattr(GoldenReplayTests, _name, _make_test(_path))
if __name__ == "__main__":
unittest.main()