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_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.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, ...] = (
"strict",
"relaxed_expensive_import",
@@ -80,6 +80,7 @@ SOLVER_RELAX_STEPS: tuple[str, ...] = (
"relaxed_neg_prep_window",
"neg_sell_phases_fallback",
"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).
DAWN_LOW_PV_NO_CURTAIL_W = 1500
@@ -151,6 +152,7 @@ def _solver_relax_chain(
relaxed_neg_prep_window: bool = False,
neg_sell_phases_fallback: bool = False,
relaxed_pos_sell_ge_block: bool = False,
relaxed_solver_masks: bool = False,
) -> list[str]:
flags = {
"relaxed_expensive_import": relaxed_expensive_import,
@@ -159,6 +161,7 @@ def _solver_relax_chain(
"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": relaxed_solver_masks,
}
chain = [SOLVER_RELAX_STEPS[0]]
for step in SOLVER_RELAX_STEPS[1:]:
@@ -2356,8 +2359,52 @@ def _pv_forced_vent_export_allowed(
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]:
"""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")
if not isinstance(inp, dict):
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_window",
"neg_sell_phases_fallback",
"relaxed_pos_sell_ge_block",
"relaxed_solver_masks",
):
if inp.get(key):
out[key] = True
@@ -2443,8 +2492,9 @@ def solve_dispatch_two_pass(
evening_push_ts_override=None,
**relax_carry,
)
except RuntimeError as exc:
if "Infeasible" in str(exc):
except (RuntimeError, PlannerSolverError) as exc:
infeasible = isinstance(exc, PlannerSolverError) or "Infeasible" in str(exc)
if infeasible:
logger.warning(
"two_pass pass2 Infeasible (%s), using pass1 solution",
exc,
@@ -2471,6 +2521,8 @@ def _evening_push_override_for_solve(
relaxed_neg_prep_hold_only: bool,
relaxed_neg_prep_window: bool,
neg_sell_phases_fallback: bool,
relaxed_pos_sell_ge_block: bool = False,
relaxed_solver_masks: bool = False,
) -> Optional[set[int]]:
"""Po Infeasible nesmí retry držet hysterézní push z minulého běhu."""
if evening_push_ts_override is None:
@@ -2478,8 +2530,11 @@ def _evening_push_override_for_solve(
if (
relaxed_expensive_import
or relaxed_neg_buy_charge
or relaxed_neg_prep_hold_only
or relaxed_neg_prep_window
or neg_sell_phases_fallback
or relaxed_pos_sell_ge_block
or relaxed_solver_masks
):
return None
return set(evening_push_ts_override)
@@ -2529,6 +2584,7 @@ def solve_dispatch(
relaxed_neg_prep_window: bool = False,
neg_sell_phases_fallback: bool = False,
relaxed_pos_sell_ge_block: bool = False,
relaxed_solver_masks: bool = False,
evening_push_ts_override: Optional[set[int]] = None,
) -> 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_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_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)
if T < 1:
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 = (
relaxed_expensive_import
or relaxed_neg_buy_charge
or relaxed_neg_prep_window
or neg_sell_phases_fallback
or relaxed_pos_sell_ge_block
or relaxed_solver_masks
)
prep_hold_relaxed = relaxed_neg_prep_hold_only or relaxed_neg_prep_window
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_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=relaxed_solver_masks,
)
push_override_eff = None
if push_override_raw:
@@ -3113,7 +3175,7 @@ def solve_dispatch(
for t in discharge_export_slots:
if _prague_calendar_date(slots[t]) == replan_day:
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.
charge_slots = set(range(T))
discharge_export_slots = {
@@ -3123,6 +3185,16 @@ def solve_dispatch(
}
else:
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(
slots,
first_neg_buy_idx=first_neg_buy_idx,
@@ -4620,6 +4692,39 @@ def solve_dispatch(
relaxed_pos_sell_ge_block=True,
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(
pulp.LpStatus[status],
relax_chain=_solver_relax_chain(
@@ -4629,6 +4734,7 @@ def solve_dispatch(
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=relaxed_solver_masks,
),
)
@@ -4981,6 +5087,7 @@ def solve_dispatch(
"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": relaxed_solver_masks,
"relax_chain": _solver_relax_chain(
relaxed_expensive_import=relaxed_expensive_import,
relaxed_neg_buy_charge=relaxed_neg_buy_charge,
@@ -4988,6 +5095,7 @@ def solve_dispatch(
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=relaxed_solver_masks,
),
"charge_acquisition_buy_czk_kwh": charge_acquisition_czk_kwh,
"charge_acquisition_cutoff_at": (
@@ -5092,6 +5200,13 @@ async def run_daily_plan(
)
planner_version_resolved = _planner_engine_version(planner_version)
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"
try:
@@ -5282,6 +5397,13 @@ async def run_rolling_replan(
return None, None
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
# ems.fn_load_planning_slots_full. Pro audit/debug ale chceme ukládat i RAW (bez korekcí).
correction_factor, correction_log = 1.0, {