Branch 1: failed run journal + bisect Infeasible + granulární relaxace (bez vypnutí evening push)
Some checks failed
CI and deploy / migration-check (push) Failing after 14s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-06-06 22:23:59 +02:00
parent 1429d402e5
commit 2a963c9793
7 changed files with 593 additions and 72 deletions

View File

@@ -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