aa zas oprava
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-06-06 23:25:36 +02:00
parent 37df01d43c
commit 3ad5bec76b
3 changed files with 137 additions and 12 deletions

View File

@@ -71,7 +71,7 @@ NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 100.0
PRE_NEG_CHARGE_PENALTY_CZK_KWH = 400.0 PRE_NEG_CHARGE_PENALTY_CZK_KWH = 400.0
PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0 PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0 PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0
PLANNER_BUILD_TAG = "2026-06-06-home01-late-replan-infeasible-v1" PLANNER_BUILD_TAG = "2026-06-06-home01-late-replan-infeasible-v2"
SOLVER_RELAX_STEPS: tuple[str, ...] = ( SOLVER_RELAX_STEPS: tuple[str, ...] = (
"strict", "strict",
"relaxed_expensive_import", "relaxed_expensive_import",
@@ -80,6 +80,7 @@ SOLVER_RELAX_STEPS: tuple[str, ...] = (
"relaxed_neg_prep_window", "relaxed_neg_prep_window",
"neg_sell_phases_fallback", "neg_sell_phases_fallback",
"relaxed_pos_sell_ge_block", "relaxed_pos_sell_ge_block",
"relaxed_solver_masks",
) )
# Ranní slabá FVE: neaplikovat pv_store ge_pv=0 (jinak curtail při sell < večerní peak). # Ranní slabá FVE: neaplikovat pv_store ge_pv=0 (jinak curtail při sell < večerní peak).
DAWN_LOW_PV_NO_CURTAIL_W = 1500 DAWN_LOW_PV_NO_CURTAIL_W = 1500
@@ -151,6 +152,7 @@ def _solver_relax_chain(
relaxed_neg_prep_window: bool = False, relaxed_neg_prep_window: bool = False,
neg_sell_phases_fallback: bool = False, neg_sell_phases_fallback: bool = False,
relaxed_pos_sell_ge_block: bool = False, relaxed_pos_sell_ge_block: bool = False,
relaxed_solver_masks: bool = False,
) -> list[str]: ) -> list[str]:
flags = { flags = {
"relaxed_expensive_import": relaxed_expensive_import, "relaxed_expensive_import": relaxed_expensive_import,
@@ -159,6 +161,7 @@ def _solver_relax_chain(
"relaxed_neg_prep_window": relaxed_neg_prep_window, "relaxed_neg_prep_window": relaxed_neg_prep_window,
"neg_sell_phases_fallback": neg_sell_phases_fallback, "neg_sell_phases_fallback": neg_sell_phases_fallback,
"relaxed_pos_sell_ge_block": relaxed_pos_sell_ge_block, "relaxed_pos_sell_ge_block": relaxed_pos_sell_ge_block,
"relaxed_solver_masks": relaxed_solver_masks,
} }
chain = [SOLVER_RELAX_STEPS[0]] chain = [SOLVER_RELAX_STEPS[0]]
for step in SOLVER_RELAX_STEPS[1:]: for step in SOLVER_RELAX_STEPS[1:]:
@@ -2356,8 +2359,52 @@ def _pv_forced_vent_export_allowed(
return False return False
def _relax_solver_slot_masks(slots: list[PlanningSlot]) -> list[PlanningSlot]:
"""Nouzově permissivní allow_* — SQL masky nesmí učinit LP neřešitelným."""
return [
replace(
s,
allow_charge=True,
allow_discharge_export=float(s.sell_price) >= 0.0,
)
for s in slots
]
def _unlock_late_replan_evening_slots(
slots: list[PlanningSlot],
*,
current_soc_wh: float,
reserve_soc_wh: float,
) -> None:
"""Pozdní replan: večer D0 povolit grid import + export (SQL allow_charge často false)."""
if not slots or current_soc_wh <= float(reserve_soc_wh) + 500.0:
return
if not any(float(s.buy_price) < 0.0 for s in slots):
return
replan_day = _prague_calendar_date(slots[0])
unlocked = 0
for i, s in enumerate(slots):
if _prague_calendar_date(s) != replan_day:
continue
if float(s.sell_price) < 0.0:
continue
if not _in_evening_push_hour_window(s):
continue
if s.allow_charge and s.allow_discharge_export:
continue
slots[i] = replace(s, allow_charge=True, allow_discharge_export=True)
unlocked += 1
if unlocked:
logger.info(
"Late replan: unlocked evening slot masks on %d slot(s) (soc=%.0f Wh)",
unlocked,
float(current_soc_wh),
)
def _solve_dispatch_relax_carryover(snap: dict[str, Any]) -> dict[str, Any]: def _solve_dispatch_relax_carryover(snap: dict[str, Any]) -> dict[str, Any]:
"""Pass2 two-pass: neopakovat Infeasible řetězec, pokud pass1 skončil v nouzovém režimu.""" """Pass2 two-pass: přenést nouzové relax flagy z pass1, ať pass2 nezačne od strict."""
inp = snap.get("inputs") inp = snap.get("inputs")
if not isinstance(inp, dict): if not isinstance(inp, dict):
return {} return {}
@@ -2368,6 +2415,8 @@ def _solve_dispatch_relax_carryover(snap: dict[str, Any]) -> dict[str, Any]:
"relaxed_neg_prep_hold_only", "relaxed_neg_prep_hold_only",
"relaxed_neg_prep_window", "relaxed_neg_prep_window",
"neg_sell_phases_fallback", "neg_sell_phases_fallback",
"relaxed_pos_sell_ge_block",
"relaxed_solver_masks",
): ):
if inp.get(key): if inp.get(key):
out[key] = True out[key] = True
@@ -2443,8 +2492,9 @@ def solve_dispatch_two_pass(
evening_push_ts_override=None, evening_push_ts_override=None,
**relax_carry, **relax_carry,
) )
except RuntimeError as exc: except (RuntimeError, PlannerSolverError) as exc:
if "Infeasible" in str(exc): infeasible = isinstance(exc, PlannerSolverError) or "Infeasible" in str(exc)
if infeasible:
logger.warning( logger.warning(
"two_pass pass2 Infeasible (%s), using pass1 solution", "two_pass pass2 Infeasible (%s), using pass1 solution",
exc, exc,
@@ -2471,6 +2521,8 @@ def _evening_push_override_for_solve(
relaxed_neg_prep_hold_only: bool, relaxed_neg_prep_hold_only: bool,
relaxed_neg_prep_window: bool, relaxed_neg_prep_window: bool,
neg_sell_phases_fallback: bool, neg_sell_phases_fallback: bool,
relaxed_pos_sell_ge_block: bool = False,
relaxed_solver_masks: bool = False,
) -> Optional[set[int]]: ) -> Optional[set[int]]:
"""Po Infeasible nesmí retry držet hysterézní push z minulého běhu.""" """Po Infeasible nesmí retry držet hysterézní push z minulého běhu."""
if evening_push_ts_override is None: if evening_push_ts_override is None:
@@ -2478,8 +2530,11 @@ def _evening_push_override_for_solve(
if ( if (
relaxed_expensive_import relaxed_expensive_import
or relaxed_neg_buy_charge or relaxed_neg_buy_charge
or relaxed_neg_prep_hold_only
or relaxed_neg_prep_window or relaxed_neg_prep_window
or neg_sell_phases_fallback or neg_sell_phases_fallback
or relaxed_pos_sell_ge_block
or relaxed_solver_masks
): ):
return None return None
return set(evening_push_ts_override) return set(evening_push_ts_override)
@@ -2529,6 +2584,7 @@ def solve_dispatch(
relaxed_neg_prep_window: bool = False, relaxed_neg_prep_window: bool = False,
neg_sell_phases_fallback: bool = False, neg_sell_phases_fallback: bool = False,
relaxed_pos_sell_ge_block: bool = False, relaxed_pos_sell_ge_block: bool = False,
relaxed_solver_masks: bool = False,
evening_push_ts_override: Optional[set[int]] = None, evening_push_ts_override: Optional[set[int]] = None,
) -> tuple[list[DispatchResult], int, dict[str, Any]]: ) -> tuple[list[DispatchResult], int, dict[str, Any]]:
""" """
@@ -2538,17 +2594,21 @@ def solve_dispatch(
relaxed_neg_buy_charge: druhý nouzový retry bez neg_buy charge shortfall. relaxed_neg_buy_charge: druhý nouzový retry bez neg_buy charge shortfall.
relaxed_neg_prep_hold_only: třetí retry — bez prep_soc_shortfall a prep hold binárek (evening push zůstává). relaxed_neg_prep_hold_only: třetí retry — bez prep_soc_shortfall a prep hold binárek (evening push zůstává).
relaxed_neg_prep_window: čtvrtý retry — vypne strict pre-neg bundle; future_neg_buy večerní export zůstává. relaxed_neg_prep_window: čtvrtý retry — vypne strict pre-neg bundle; future_neg_buy večerní export zůstává.
relaxed_pos_sell_ge_block: poslední retry — neaplikovat ge=0 v pos_sell před buy<0 (zbylá Infeasible). relaxed_pos_sell_ge_block: retry — neaplikovat ge=0 v pos_sell před buy<0.
relaxed_solver_masks: poslední retry — permissivní SQL masky + vypnutí tvrdých neg-večer větví.
""" """
T = len(slots) T = len(slots)
if T < 1: if T < 1:
raise RuntimeError("solve_dispatch requires at least one slot") raise RuntimeError("solve_dispatch requires at least one slot")
if relaxed_solver_masks or relaxed_pos_sell_ge_block:
slots = _relax_solver_slot_masks(slots)
any_relaxed = ( any_relaxed = (
relaxed_expensive_import relaxed_expensive_import
or relaxed_neg_buy_charge or relaxed_neg_buy_charge
or relaxed_neg_prep_window or relaxed_neg_prep_window
or neg_sell_phases_fallback or neg_sell_phases_fallback
or relaxed_pos_sell_ge_block or relaxed_pos_sell_ge_block
or relaxed_solver_masks
) )
prep_hold_relaxed = relaxed_neg_prep_hold_only or relaxed_neg_prep_window prep_hold_relaxed = relaxed_neg_prep_hold_only or relaxed_neg_prep_window
EV = len(vehicles) # počet EV (typicky 2) EV = len(vehicles) # počet EV (typicky 2)
@@ -3013,6 +3073,8 @@ def solve_dispatch(
relaxed_neg_prep_hold_only=relaxed_neg_prep_hold_only, relaxed_neg_prep_hold_only=relaxed_neg_prep_hold_only,
relaxed_neg_prep_window=relaxed_neg_prep_window, relaxed_neg_prep_window=relaxed_neg_prep_window,
neg_sell_phases_fallback=neg_sell_phases_fallback, neg_sell_phases_fallback=neg_sell_phases_fallback,
relaxed_pos_sell_ge_block=relaxed_pos_sell_ge_block,
relaxed_solver_masks=relaxed_solver_masks,
) )
push_override_eff = None push_override_eff = None
if push_override_raw: if push_override_raw:
@@ -3113,7 +3175,7 @@ def solve_dispatch(
for t in discharge_export_slots: for t in discharge_export_slots:
if _prague_calendar_date(slots[t]) == replan_day: if _prague_calendar_date(slots[t]) == replan_day:
charge_slots.add(t) charge_slots.add(t)
if relaxed_pos_sell_ge_block: if relaxed_pos_sell_ge_block or relaxed_solver_masks:
# Poslední retry: SQL allow_charge / drahý import nesmí zablokovat fyzicky dosažitelný plán. # Poslední retry: SQL allow_charge / drahý import nesmí zablokovat fyzicky dosažitelný plán.
charge_slots = set(range(T)) charge_slots = set(range(T))
discharge_export_slots = { discharge_export_slots = {
@@ -3123,6 +3185,16 @@ def solve_dispatch(
} }
else: else:
battery_export_defer_pv_ts = set() battery_export_defer_pv_ts = set()
if relaxed_solver_masks and om == "AUTO":
future_neg_buy_discharge_en = False
neg_evening_discharge_active = False
neg_evening_push_ts = set()
neg_evening_before_neg_ts = set()
neg_evening_reserve_anchors = []
evening_push_ts = set()
evening_early_export_penalty_ts = set()
battery_export_defer_pv_ts = set()
evening_push_hard_suppressed = True
pre_neg_buy_soc_ceiling_wh = _pre_neg_buy_soc_ceiling_wh( pre_neg_buy_soc_ceiling_wh = _pre_neg_buy_soc_ceiling_wh(
slots, slots,
first_neg_buy_idx=first_neg_buy_idx, first_neg_buy_idx=first_neg_buy_idx,
@@ -4620,6 +4692,39 @@ def solve_dispatch(
relaxed_pos_sell_ge_block=True, relaxed_pos_sell_ge_block=True,
evening_push_ts_override=evening_push_ts_override, evening_push_ts_override=evening_push_ts_override,
) )
if not relaxed_solver_masks:
logger.warning(
"solve_dispatch still Infeasible, retry with relaxed_solver_masks "
"(permissive slot masks; neg-evening hard bundle off)"
)
battery_no_phases = SimpleNamespace(
**{
**vars(battery),
"planner_neg_sell_prep_soc_percent": 100.0,
}
)
return solve_dispatch(
slots,
battery_no_phases,
heat_pump,
grid,
ev_sessions,
vehicles,
current_soc_wh,
current_tuv_temp_c,
tuv_delta_stats=tuv_delta_stats,
operating_mode=operating_mode,
charge_commitment_prev_w=charge_commitment_prev_w,
planner_version=planner_version,
relaxed_expensive_import=True,
relaxed_neg_buy_charge=True,
relaxed_neg_prep_hold_only=True,
relaxed_neg_prep_window=True,
neg_sell_phases_fallback=True,
relaxed_pos_sell_ge_block=True,
relaxed_solver_masks=True,
evening_push_ts_override=evening_push_ts_override,
)
raise PlannerSolverError( raise PlannerSolverError(
pulp.LpStatus[status], pulp.LpStatus[status],
relax_chain=_solver_relax_chain( relax_chain=_solver_relax_chain(
@@ -4629,6 +4734,7 @@ def solve_dispatch(
relaxed_neg_prep_window=relaxed_neg_prep_window, relaxed_neg_prep_window=relaxed_neg_prep_window,
neg_sell_phases_fallback=neg_sell_phases_fallback, neg_sell_phases_fallback=neg_sell_phases_fallback,
relaxed_pos_sell_ge_block=relaxed_pos_sell_ge_block, relaxed_pos_sell_ge_block=relaxed_pos_sell_ge_block,
relaxed_solver_masks=relaxed_solver_masks,
), ),
) )
@@ -4981,6 +5087,7 @@ def solve_dispatch(
"relaxed_neg_prep_window": relaxed_neg_prep_window, "relaxed_neg_prep_window": relaxed_neg_prep_window,
"neg_sell_phases_fallback": neg_sell_phases_fallback, "neg_sell_phases_fallback": neg_sell_phases_fallback,
"relaxed_pos_sell_ge_block": relaxed_pos_sell_ge_block, "relaxed_pos_sell_ge_block": relaxed_pos_sell_ge_block,
"relaxed_solver_masks": relaxed_solver_masks,
"relax_chain": _solver_relax_chain( "relax_chain": _solver_relax_chain(
relaxed_expensive_import=relaxed_expensive_import, relaxed_expensive_import=relaxed_expensive_import,
relaxed_neg_buy_charge=relaxed_neg_buy_charge, relaxed_neg_buy_charge=relaxed_neg_buy_charge,
@@ -4988,6 +5095,7 @@ def solve_dispatch(
relaxed_neg_prep_window=relaxed_neg_prep_window, relaxed_neg_prep_window=relaxed_neg_prep_window,
neg_sell_phases_fallback=neg_sell_phases_fallback, neg_sell_phases_fallback=neg_sell_phases_fallback,
relaxed_pos_sell_ge_block=relaxed_pos_sell_ge_block, relaxed_pos_sell_ge_block=relaxed_pos_sell_ge_block,
relaxed_solver_masks=relaxed_solver_masks,
), ),
"charge_acquisition_buy_czk_kwh": charge_acquisition_czk_kwh, "charge_acquisition_buy_czk_kwh": charge_acquisition_czk_kwh,
"charge_acquisition_cutoff_at": ( "charge_acquisition_cutoff_at": (
@@ -5092,6 +5200,13 @@ async def run_daily_plan(
) )
planner_version_resolved = _planner_engine_version(planner_version) planner_version_resolved = _planner_engine_version(planner_version)
slots = await _load_slots(site_id, horizon_from, horizon_to, db, soc_wh=soc_wh) slots = await _load_slots(site_id, horizon_from, horizon_to, db, soc_wh=soc_wh)
_unlock_late_replan_evening_slots(
slots,
current_soc_wh=float(soc_wh),
reserve_soc_wh=float(
getattr(battery, "reserve_soc_wh", getattr(battery, "arb_floor_wh", 0.0))
),
)
om = operating_mode or "AUTO" om = operating_mode or "AUTO"
try: try:
@@ -5282,6 +5397,13 @@ async def run_rolling_replan(
return None, None return None, None
slots = await _load_slots(site_id, replan_from, horizon_to, db, soc_wh=soc_wh) slots = await _load_slots(site_id, replan_from, horizon_to, db, soc_wh=soc_wh)
_unlock_late_replan_evening_slots(
slots,
current_soc_wh=float(soc_wh),
reserve_soc_wh=float(
getattr(battery, "reserve_soc_wh", getattr(battery, "arb_floor_wh", 0.0))
),
)
# PV forecast korekce je kanonicky v DB (delta + rolling faktor + decay) a do LP vstupuje přes # PV forecast korekce je kanonicky v DB (delta + rolling faktor + decay) a do LP vstupuje přes
# ems.fn_load_planning_slots_full. Pro audit/debug ale chceme ukládat i RAW (bez korekcí). # ems.fn_load_planning_slots_full. Pro audit/debug ale chceme ukládat i RAW (bez korekcí).
correction_factor, correction_log = 1.0, { correction_factor, correction_log = 1.0, {

View File

@@ -3285,7 +3285,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
self.assertEqual(push, [0, 1, 2, 3][: len(push)]) self.assertEqual(push, [0, 1, 2, 3][: len(push)])
def test_evening_push_override_cleared_on_relaxed_retry(self) -> None: def test_evening_push_override_cleared_on_relaxed_retry(self) -> None:
"""v53: hysterézní override se nepřenáší do Infeasible retry větví.""" """v53/v2: hysterézní override se nepřenáší do Infeasible retry větví."""
kept = _evening_push_override_for_solve( kept = _evening_push_override_for_solve(
{2, 5}, {2, 5},
relaxed_expensive_import=False, relaxed_expensive_import=False,
@@ -3295,7 +3295,8 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
neg_sell_phases_fallback=False, neg_sell_phases_fallback=False,
) )
self.assertEqual(kept, {2, 5}) self.assertEqual(kept, {2, 5})
kept_prep_hold = _evening_push_override_for_solve( # v2: stale override from active plan must drop already at prep_hold_only
dropped_prep_hold = _evening_push_override_for_solve(
{2, 5}, {2, 5},
relaxed_expensive_import=False, relaxed_expensive_import=False,
relaxed_neg_buy_charge=False, relaxed_neg_buy_charge=False,
@@ -3303,7 +3304,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
relaxed_neg_prep_window=False, relaxed_neg_prep_window=False,
neg_sell_phases_fallback=False, neg_sell_phases_fallback=False,
) )
self.assertEqual(kept_prep_hold, {2, 5}) self.assertIsNone(dropped_prep_hold)
dropped = _evening_push_override_for_solve( dropped = _evening_push_override_for_solve(
{2, 5}, {2, 5},
relaxed_expensive_import=True, relaxed_expensive_import=True,

View File

@@ -11,16 +11,18 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen
**Příčina:** SQL maska `allow_charge=false` ve večerních slotech (drahý `buy`, `sell` < `buy`) + guard drahého importu vyžadoval baseload z baterie (`bd`), zatímco **v64 `future_neg_buy_discharge`** současně vynucoval večerní vývoz — LP bez rozšíření `charge_slots` neměl řešení. **Příčina:** SQL maska `allow_charge=false` ve večerních slotech (drahý `buy`, `sell` < `buy`) + guard drahého importu vyžadoval baseload z baterie (`bd`), zatímco **v64 `future_neg_buy_discharge`** současně vynucoval večerní vývoz — LP bez rozšíření `charge_slots` neměl řešení.
**Oprava (tag `2026-06-06-home01-late-replan-infeasible-v1`):** **Oprava (tag `2026-06-06-home01-late-replan-infeasible-v1`, doplněno **v2**):**
- Při **`future_neg_buy_discharge`**: rozšířit `charge_slots` o večerní / exportní sloty dne replanu (grid smí krmit load během vývozu). - Při **`future_neg_buy_discharge`**: rozšířit `charge_slots` o večerní / exportní sloty dne replanu (grid smí krmit load během vývozu).
- Nový poslední retry **`relaxed_pos_sell_ge_block`** (+ nouzové rozšíření masek) v `SOLVER_RELAX_STEPS`. - **`_unlock_late_replan_evening_slots`** po `fn_load_planning_slots_full` — večer D0 `allow_charge` + export z DB.
- Nový retry **`relaxed_pos_sell_ge_block`** (+ **`relaxed_solver_masks`** nouzový) v `SOLVER_RELAX_STEPS`.
- **v2:** two-pass pass2 dědí všechny relax flagy; při pass2 Infeasible fallback na pass1; override push zrušen už od `relaxed_neg_prep_hold_only`.
**Soubory:** `planning_engine.py`, `scripts/repro_home01_23840.py`, test `test_home01_late_replan_high_soc_realistic_masks`. **Soubory:** `planning_engine.py`, `scripts/repro_home01_23840.py`, test `test_home01_late_replan_high_soc_realistic_masks`.
**Ověření:** **Ověření:**
- `PYTHONPATH=backend python3 scripts/repro_home01_23840.py``OK two_pass` - `PYTHONPATH=backend python3 scripts/repro_home01_23840.py``OK two_pass`
- `pytest backend/tests/test_planning_dispatch_milp.py -k home01_late_replan` - `pytest backend/tests/test_planning_dispatch_milp.py -k home01_late_replan`
- Po deployi: ruční replan v AUTO → `planning_run.status=active`, večerní sloty `grid_setpoint_w < 0`. - Po deployi: ruční replan v AUTO → `planning_run.status=active`, `planner_build_tag` končí **`infeasible-v2`**, večerní sloty `grid_setpoint_w < 0`.
--- ---