Branch 1: failed run journal + bisect Infeasible + granulární relaxace (bez vypnutí evening push)
This commit is contained in:
@@ -71,7 +71,15 @@ 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-01-kv1-fixed-night-self-consume-v62"
|
||||
PLANNER_BUILD_TAG = "2026-06-06-infeasible-journal-granular-prep-relax-v63"
|
||||
SOLVER_RELAX_STEPS: tuple[str, ...] = (
|
||||
"strict",
|
||||
"relaxed_expensive_import",
|
||||
"relaxed_neg_buy_charge",
|
||||
"relaxed_neg_prep_hold_only",
|
||||
"relaxed_neg_prep_window",
|
||||
"neg_sell_phases_fallback",
|
||||
)
|
||||
# Ranní slabá FVE: neaplikovat pv_store ge_pv=0 (jinak curtail při sell < večerní peak).
|
||||
DAWN_LOW_PV_NO_CURTAIL_W = 1500
|
||||
# BA81/KV1: PV→bat jen v těsné blízkosti nejnižšího sell v horizontu (≈ poledne), ne při ~3 Kč ráno.
|
||||
@@ -117,6 +125,42 @@ ARB_FLOOR_E_REF_FRAC = 0.5 # má scale Wh = tato frakce usable_capacity (0..
|
||||
_PRAGUE_TZ = ZoneInfo("Europe/Prague")
|
||||
|
||||
|
||||
class PlannerSolverError(RuntimeError):
|
||||
"""Solver selhal po vyčerpání retry řetězce (typicky Infeasible)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
solver_status: str,
|
||||
*,
|
||||
relax_chain: list[str] | None = None,
|
||||
) -> None:
|
||||
self.solver_status = solver_status
|
||||
self.relax_chain = list(relax_chain or [])
|
||||
super().__init__(f"Solver: {solver_status}")
|
||||
|
||||
|
||||
def _solver_relax_chain(
|
||||
*,
|
||||
relaxed_expensive_import: bool = False,
|
||||
relaxed_neg_buy_charge: bool = False,
|
||||
relaxed_neg_prep_hold_only: bool = False,
|
||||
relaxed_neg_prep_window: bool = False,
|
||||
neg_sell_phases_fallback: bool = False,
|
||||
) -> list[str]:
|
||||
flags = {
|
||||
"relaxed_expensive_import": relaxed_expensive_import,
|
||||
"relaxed_neg_buy_charge": relaxed_neg_buy_charge,
|
||||
"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,
|
||||
}
|
||||
chain = [SOLVER_RELAX_STEPS[0]]
|
||||
for step in SOLVER_RELAX_STEPS[1:]:
|
||||
if flags.get(step, False):
|
||||
chain.append(step)
|
||||
return chain
|
||||
|
||||
|
||||
def _timestamptz_from_db(val: object) -> Optional[datetime]:
|
||||
if val is None:
|
||||
return None
|
||||
@@ -2171,6 +2215,7 @@ def _solve_dispatch_relax_carryover(snap: dict[str, Any]) -> dict[str, Any]:
|
||||
for key in (
|
||||
"relaxed_expensive_import",
|
||||
"relaxed_neg_buy_charge",
|
||||
"relaxed_neg_prep_hold_only",
|
||||
"relaxed_neg_prep_window",
|
||||
"neg_sell_phases_fallback",
|
||||
):
|
||||
@@ -2273,6 +2318,7 @@ def _evening_push_override_for_solve(
|
||||
*,
|
||||
relaxed_expensive_import: bool,
|
||||
relaxed_neg_buy_charge: bool,
|
||||
relaxed_neg_prep_hold_only: bool,
|
||||
relaxed_neg_prep_window: bool,
|
||||
neg_sell_phases_fallback: bool,
|
||||
) -> Optional[set[int]]:
|
||||
@@ -2329,6 +2375,7 @@ def solve_dispatch(
|
||||
planner_version: str | None = None,
|
||||
relaxed_expensive_import: bool = False,
|
||||
relaxed_neg_buy_charge: bool = False,
|
||||
relaxed_neg_prep_hold_only: bool = False,
|
||||
relaxed_neg_prep_window: bool = False,
|
||||
neg_sell_phases_fallback: bool = False,
|
||||
evening_push_ts_override: Optional[set[int]] = None,
|
||||
@@ -2338,7 +2385,8 @@ def solve_dispatch(
|
||||
Vrátí (výsledky, solver_duration_ms, solver_debug_snapshot).
|
||||
relaxed_expensive_import: nouzový režim po Infeasible — síť smí krmit baseload v drahých slotech.
|
||||
relaxed_neg_buy_charge: druhý nouzový retry bez neg_buy charge shortfall.
|
||||
relaxed_neg_prep_window: třetí retry — bez tvrdého večerního push/kotvy a prep hold binárek (sell<0 okno).
|
||||
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 — navíc vypne neg-evening bundle a tvrdý evening push.
|
||||
"""
|
||||
T = len(slots)
|
||||
if T < 1:
|
||||
@@ -2349,6 +2397,7 @@ def solve_dispatch(
|
||||
or relaxed_neg_prep_window
|
||||
or neg_sell_phases_fallback
|
||||
)
|
||||
prep_hold_relaxed = relaxed_neg_prep_hold_only or relaxed_neg_prep_window
|
||||
EV = len(vehicles) # počet EV (typicky 2)
|
||||
planner_version_resolved = _planner_engine_version(planner_version)
|
||||
planner_v2 = planner_version_resolved == "v2"
|
||||
@@ -2779,6 +2828,7 @@ def solve_dispatch(
|
||||
evening_push_ts_override,
|
||||
relaxed_expensive_import=relaxed_expensive_import,
|
||||
relaxed_neg_buy_charge=relaxed_neg_buy_charge,
|
||||
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,
|
||||
)
|
||||
@@ -3118,7 +3168,7 @@ def solve_dispatch(
|
||||
cap_w = float(min(pv_surplus_w, battery.max_charge_power_w))
|
||||
sf_pv = pulp.LpVariable(f"post_neg_pv_shortfall_{t}", 0, cap_w)
|
||||
pv_charge_shortfall.append((t, sf_pv, cap_w))
|
||||
if neg_sell_phases_en and not relaxed_neg_prep_window:
|
||||
if neg_sell_phases_en and not prep_hold_relaxed:
|
||||
for t_ns in range(T):
|
||||
phase_ns = neg_sell_phase_by_t[t_ns]
|
||||
tgt_ns = neg_sell_soc_target_by_t[t_ns]
|
||||
@@ -3146,7 +3196,7 @@ def solve_dispatch(
|
||||
neg_sell_soc_underfill.append(
|
||||
(t_tail_last, us_tail, float(battery.soc_max_wh))
|
||||
)
|
||||
if not relaxed_neg_prep_window:
|
||||
if not prep_hold_relaxed:
|
||||
for t_ph in range(T):
|
||||
if neg_sell_phase_by_t[t_ph] != "prep":
|
||||
continue
|
||||
@@ -4256,10 +4306,10 @@ def solve_dispatch(
|
||||
relaxed_neg_buy_charge=True,
|
||||
evening_push_ts_override=evening_push_ts_override,
|
||||
)
|
||||
if not relaxed_neg_prep_window:
|
||||
if not relaxed_neg_prep_hold_only:
|
||||
logger.warning(
|
||||
"solve_dispatch still Infeasible, retry with relaxed_neg_prep_window "
|
||||
"(skip evening push/anchors and prep hold hard constraints)"
|
||||
"solve_dispatch still Infeasible, retry with relaxed_neg_prep_hold_only "
|
||||
"(skip prep_soc_shortfall and prep hold binárek; evening push unchanged)"
|
||||
)
|
||||
return solve_dispatch(
|
||||
slots,
|
||||
@@ -4276,6 +4326,30 @@ def solve_dispatch(
|
||||
planner_version=planner_version,
|
||||
relaxed_expensive_import=True,
|
||||
relaxed_neg_buy_charge=True,
|
||||
relaxed_neg_prep_hold_only=True,
|
||||
evening_push_ts_override=evening_push_ts_override,
|
||||
)
|
||||
if not relaxed_neg_prep_window:
|
||||
logger.warning(
|
||||
"solve_dispatch still Infeasible, retry with relaxed_neg_prep_window "
|
||||
"(skip neg-evening bundle and tvrdý evening push)"
|
||||
)
|
||||
return solve_dispatch(
|
||||
slots,
|
||||
battery,
|
||||
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=neg_sell_phases_fallback,
|
||||
evening_push_ts_override=evening_push_ts_override,
|
||||
@@ -4306,11 +4380,21 @@ def solve_dispatch(
|
||||
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,
|
||||
evening_push_ts_override=evening_push_ts_override,
|
||||
)
|
||||
raise RuntimeError(f"Solver: {pulp.LpStatus[status]}")
|
||||
raise PlannerSolverError(
|
||||
pulp.LpStatus[status],
|
||||
relax_chain=_solver_relax_chain(
|
||||
relaxed_expensive_import=relaxed_expensive_import,
|
||||
relaxed_neg_buy_charge=relaxed_neg_buy_charge,
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
# --- Post-processing ---
|
||||
results = []
|
||||
@@ -4638,8 +4722,16 @@ def solve_dispatch(
|
||||
"load_first_enabled": om == "AUTO",
|
||||
"relaxed_expensive_import": relaxed_expensive_import,
|
||||
"relaxed_neg_buy_charge": relaxed_neg_buy_charge,
|
||||
"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,
|
||||
"relax_chain": _solver_relax_chain(
|
||||
relaxed_expensive_import=relaxed_expensive_import,
|
||||
relaxed_neg_buy_charge=relaxed_neg_buy_charge,
|
||||
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,
|
||||
),
|
||||
"charge_acquisition_buy_czk_kwh": charge_acquisition_czk_kwh,
|
||||
"charge_acquisition_cutoff_at": (
|
||||
slots[0].charge_acquisition_cutoff_at.isoformat()
|
||||
@@ -4740,20 +4832,36 @@ async def run_daily_plan(
|
||||
slots = await _load_slots(site_id, horizon_from, horizon_to, db, soc_wh=soc_wh)
|
||||
|
||||
om = operating_mode or "AUTO"
|
||||
if om == "AUTO":
|
||||
results, duration_ms, solver_snapshot = solve_dispatch_two_pass(
|
||||
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
|
||||
tuv_delta_stats=tuv_stats,
|
||||
operating_mode=om,
|
||||
planner_version=planner_version_resolved,
|
||||
)
|
||||
else:
|
||||
results, duration_ms, solver_snapshot = solve_dispatch(
|
||||
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
|
||||
tuv_delta_stats=tuv_stats,
|
||||
operating_mode=om,
|
||||
planner_version=planner_version_resolved,
|
||||
try:
|
||||
if om == "AUTO":
|
||||
results, duration_ms, solver_snapshot = solve_dispatch_two_pass(
|
||||
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
|
||||
tuv_delta_stats=tuv_stats,
|
||||
operating_mode=om,
|
||||
planner_version=planner_version_resolved,
|
||||
)
|
||||
else:
|
||||
results, duration_ms, solver_snapshot = solve_dispatch(
|
||||
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
|
||||
tuv_delta_stats=tuv_stats,
|
||||
operating_mode=om,
|
||||
planner_version=planner_version_resolved,
|
||||
)
|
||||
except PlannerSolverError as exc:
|
||||
await _save_failed_planning_run(
|
||||
site_id,
|
||||
horizon_from,
|
||||
horizon_to,
|
||||
run_type="daily",
|
||||
triggered_by=triggered_by,
|
||||
replan_from=None,
|
||||
soc_wh=soc_wh,
|
||||
correction=1.0,
|
||||
db=db,
|
||||
error=exc,
|
||||
slot_count=len(slots),
|
||||
)
|
||||
raise
|
||||
comparison_ctx = _maybe_add_planner_comparison(
|
||||
slots=slots,
|
||||
battery=battery,
|
||||
@@ -4954,23 +5062,39 @@ async def run_rolling_replan(
|
||||
)
|
||||
|
||||
om = operating_mode or "AUTO"
|
||||
if om == "AUTO":
|
||||
results, duration_ms, solver_snapshot = solve_dispatch_two_pass(
|
||||
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
|
||||
tuv_delta_stats=tuv_stats,
|
||||
operating_mode=om,
|
||||
charge_commitment_prev_w=commitment_prev,
|
||||
planner_version=planner_version_resolved,
|
||||
evening_push_ts_override=evening_push_override,
|
||||
)
|
||||
else:
|
||||
results, duration_ms, solver_snapshot = solve_dispatch(
|
||||
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
|
||||
tuv_delta_stats=tuv_stats,
|
||||
operating_mode=om,
|
||||
charge_commitment_prev_w=commitment_prev,
|
||||
planner_version=planner_version_resolved,
|
||||
try:
|
||||
if om == "AUTO":
|
||||
results, duration_ms, solver_snapshot = solve_dispatch_two_pass(
|
||||
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
|
||||
tuv_delta_stats=tuv_stats,
|
||||
operating_mode=om,
|
||||
charge_commitment_prev_w=commitment_prev,
|
||||
planner_version=planner_version_resolved,
|
||||
evening_push_ts_override=evening_push_override,
|
||||
)
|
||||
else:
|
||||
results, duration_ms, solver_snapshot = solve_dispatch(
|
||||
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
|
||||
tuv_delta_stats=tuv_stats,
|
||||
operating_mode=om,
|
||||
charge_commitment_prev_w=commitment_prev,
|
||||
planner_version=planner_version_resolved,
|
||||
)
|
||||
except PlannerSolverError as exc:
|
||||
await _save_failed_planning_run(
|
||||
site_id,
|
||||
replan_from,
|
||||
horizon_to,
|
||||
run_type="rolling",
|
||||
triggered_by=triggered_by,
|
||||
replan_from=replan_from,
|
||||
soc_wh=soc_wh,
|
||||
correction=correction_factor,
|
||||
db=db,
|
||||
error=exc,
|
||||
slot_count=len(slots),
|
||||
)
|
||||
raise
|
||||
comparison_ctx = _maybe_add_planner_comparison(
|
||||
slots=slots,
|
||||
battery=battery,
|
||||
@@ -5523,3 +5647,57 @@ async def _save_planning_run(
|
||||
activate_run,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _save_failed_planning_run(
|
||||
site_id: int,
|
||||
horizon_from: datetime,
|
||||
horizon_to: datetime,
|
||||
*,
|
||||
run_type: str,
|
||||
triggered_by: str,
|
||||
replan_from: datetime | None,
|
||||
soc_wh: float,
|
||||
correction: float,
|
||||
db,
|
||||
error: PlannerSolverError,
|
||||
slot_count: int | None = None,
|
||||
) -> int:
|
||||
"""Uloží neúspěšný běh plánovače (status=failed); aktivní plán nemění."""
|
||||
run_meta: dict[str, Any] = {
|
||||
"run_type": run_type,
|
||||
"triggered_by": triggered_by,
|
||||
"replan_from": replan_from.isoformat() if replan_from else None,
|
||||
"soc_at_replan_wh": soc_wh,
|
||||
"solver_duration_ms": 0,
|
||||
"forecast_correction_factor": correction,
|
||||
"error_text": str(error),
|
||||
"solver_params": {
|
||||
"status": "failed",
|
||||
"planner_build_tag": PLANNER_BUILD_TAG,
|
||||
"solver_status": error.solver_status,
|
||||
"relax_chain": error.relax_chain,
|
||||
"slot_count": slot_count,
|
||||
},
|
||||
}
|
||||
run_id = int(
|
||||
await db.fetchval(
|
||||
"""
|
||||
select ems.fn_planning_run_fail(
|
||||
$1::int, $2::timestamptz, $3::timestamptz, $4::jsonb
|
||||
)
|
||||
""",
|
||||
site_id,
|
||||
horizon_from,
|
||||
horizon_to,
|
||||
json.dumps(run_meta, default=str),
|
||||
)
|
||||
)
|
||||
logger.error(
|
||||
"[site=%s] Planning solver failed run_id=%s: %s relax_chain=%s",
|
||||
site_id,
|
||||
run_id,
|
||||
error,
|
||||
error.relax_chain,
|
||||
)
|
||||
return run_id
|
||||
|
||||
Reference in New Issue
Block a user