#!/usr/bin/env python3 """ Fáze 2 – penalty audit: změř přínos každé heuristické penalty v objective. Pro každou ekonomickou konstantu (penalty/reward/bonus/surcharge) ji vynuluje ve VŠECH modulech, kde je importovaná (fasáda: planning_engine, planning.heuristics, planning.constants), přehraje golden fixtures (tests/golden/fixtures/) přes solve_dispatch_two_pass a porovná s baseline: - Δcashflow : změna reálných peněz plánu (− = plán vydělá víc bez penalty), - Δslotů : kolik slotů změnilo battery/grid setpoint (chování), - bind : jestli penalta vůbec něco dělá (Δ=0 na všech fixtures = mrtvá). POZOR na interpretaci: penalty mění modelované peníze za robustnost vůči chybě forecastu. Záporná Δcashflow ⇒ penalta v modelu stojí peníze — to samo o sobě neznamená „smazat“; mrtvé penalty (bind=NE) ale smazat lze bezpečně. Druhý krok auditu = economics_report nad reálnými dny. Spouštět z backend/: python3 ../scripts/harness/penalty_audit.py [--only NÁZEV] """ from __future__ import annotations import argparse import importlib.util import json import re import sys from pathlib import Path BACKEND = Path(__file__).resolve().parents[2] / "backend" sys.path.insert(0, str(BACKEND)) from services import planning_engine as pe # noqa: E402 from services.planning import constants as C # noqa: E402 from services.planning import heuristics as H # noqa: E402 # replay funkce z golden testu (bez duplikace logiky) _spec = importlib.util.spec_from_file_location( "golden_replay", BACKEND / "tests" / "test_golden_replay.py" ) _golden = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(_golden) FIXTURES = sorted((BACKEND / "tests" / "golden" / "fixtures").glob("*.json")) # Ekonomické konstanty k auditu: penalty/reward/bonus/surcharge/incentive/discourage AUDIT_PATTERN = re.compile( r"(PENALTY|SHORTFALL|DISCOURAGE|REWARD|SURCHARGE|INCENTIVE|BONUS)", re.I ) MODULES = (pe, C, H) def _audit_names() -> list[str]: names = [] for n in dir(C): if n.startswith("_") or not n.isupper(): continue if AUDIT_PATTERN.search(n) and isinstance(getattr(C, n), (int, float)): names.append(n) return sorted(names) def _set_const(name: str, value: float) -> dict: saved = {} for mod in MODULES: if hasattr(mod, name): saved[mod.__name__] = getattr(mod, name) setattr(mod, name, value) return saved def _restore_const(name: str, saved: dict) -> None: for mod in MODULES: if mod.__name__ in saved: setattr(mod, name, saved[mod.__name__]) def _replay_all() -> dict[str, dict]: out = {} for path in FIXTURES: fixture = json.loads(path.read_text(encoding="utf-8")) out[path.stem] = _golden._replay_fixture(fixture) return out def _diff(base: dict, new: dict) -> tuple[float, float, int, list[str]]: """(Δcashflow, Δpenalty, změněné sloty, změny feasibility) napříč fixtures.""" d_cash = d_pen = 0.0 changed = 0 feas: list[str] = [] for key, b in base.items(): n = new[key] b_err = "solver_error" in b n_err = "solver_error" in n if b_err or n_err: if b_err and not n_err: feas.append(f"{key}: INFEASIBLE → OK!") changed += 1 elif n_err and not b_err: feas.append(f"{key}: OK → INFEASIBLE") changed += 1 continue d_cash += n["totals"]["cashflow_czk"] - b["totals"]["cashflow_czk"] d_pen += n["totals"]["penalty_czk"] - b["totals"]["penalty_czk"] for rb, rn in zip(b["slots"], n["slots"]): if ( rb["battery_setpoint_w"] != rn["battery_setpoint_w"] or rb["grid_setpoint_w"] != rn["grid_setpoint_w"] or rb["pv_a_curtailed_w"] != rn["pv_a_curtailed_w"] ): changed += 1 return d_cash, d_pen, changed, feas def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--only", default=None, help="audit jen jedné konstanty") args = ap.parse_args() names = [args.only] if args.only else _audit_names() print(f"# Penalty audit — {len(names)} konstant × {len(FIXTURES)} fixtures") print("# Δcashflow: záporné = plán bez penalty vydělá víc (modelované Kč za horizonty fixtures)") print() baseline = _replay_all() base_cash = sum(r["totals"]["cashflow_czk"] for r in baseline.values() if "totals" in r) base_pen = sum(r["totals"]["penalty_czk"] for r in baseline.values() if "totals" in r) infeas = [k for k, r in baseline.items() if "solver_error" in r] print(f"baseline: cashflow {base_cash:.1f} Kč, penalty {base_pen:.1f} Kč; infeasible fixtures: {infeas}\n") header = f"{'konstanta':<55} {'hodnota':>9} {'Δcash':>8} {'Δpenalty':>9} {'Δsloty':>6} bind" print(header) print("-" * len(header)) rows = [] for name in names: value = getattr(C, name) saved = _set_const(name, 0.0) try: result = _replay_all() except Exception as exc: # infeasible apod. — informace sama o sobě print(f"{name:<55} {value:>9} {'ERROR':>8} {str(exc)[:40]}") _restore_const(name, saved) continue _restore_const(name, saved) d_cash, d_pen, changed, feas = _diff(baseline, result) bind = "NE (mrtvá?)" if changed == 0 and abs(d_cash) < 0.05 else "ano" if feas: bind = " | ".join(feas) rows.append((name, value, d_cash, d_pen, changed, bind)) print(f"{name:<55} {value:>9} {d_cash:>8.1f} {d_pen:>9.1f} {changed:>6} {bind}") dead = [r[0] for r in rows if r[5].startswith("NE")] print(f"\nMrtvé penalty (žádný vliv na 4 fixtures): {len(dead)}") for n in dead: print(f" - {n}") if __name__ == "__main__": main()