From 230351b38abc3323d9f808f439fbcd939ff4046c Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Fri, 29 May 2026 22:26:52 +0200 Subject: [PATCH] oprava --- backend/services/planning_engine.py | 123 ++++++++++++++++++++++++---- docs/planning-changelog.md | 10 +++ 2 files changed, 116 insertions(+), 17 deletions(-) diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 0399084..b23141a 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -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-05-29-neg-prep-observed-soc-v40" +PLANNER_BUILD_TAG = "2026-05-29-neg-prep-infeasible-relax-v40b" # Po t_detach v prep: necpát PV do bat (měkké; tvrdý hold přes soc_target z rampy). NEG_SELL_POST_DETACH_BCPV_DISCOURAGE_CZK_KWH = 250.0 # Večer před neg dnem: výboj do sítě (měkký shortfall na ge_bat). @@ -1300,12 +1300,20 @@ def _neg_evening_before_neg_push_indices( *, export_budget_wh: float, per_slot_discharge_wh: float, + discharge_export_ok: set[int] | None = None, ) -> set[int]: """Nejdražší kladné-sell sloty v kandidátech, dokud budget z pozorovaného SoC.""" if export_budget_wh < per_slot_discharge_wh * 0.5 or not candidate_ts: return set() + eligible = { + t + for t in candidate_ts + if discharge_export_ok is None or t in discharge_export_ok + } + if not eligible: + return set() ranked = sorted( - candidate_ts, + eligible, key=lambda t: (float(slots[t].sell_price), -t), reverse=True, ) @@ -1974,6 +1982,8 @@ def solve_dispatch( planner_version: str | None = None, relaxed_expensive_import: bool = False, relaxed_neg_buy_charge: bool = False, + relaxed_neg_prep_window: bool = False, + neg_sell_phases_fallback: bool = False, evening_push_ts_override: Optional[set[int]] = None, ) -> tuple[list[DispatchResult], int, dict[str, Any]]: """ @@ -1981,6 +1991,7 @@ 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). """ T = len(slots) if T < 1: @@ -2256,7 +2267,12 @@ def solve_dispatch( neg_evening_push_ts: set[int] = set() neg_evening_export_budget_wh: float | None = None neg_evening_reserve_anchors: list[tuple[int, float]] = [] - if om == "AUTO" and not purchase_fixed_pre and neg_sell_phases_en: + if ( + om == "AUTO" + and not purchase_fixed_pre + and neg_sell_phases_en + and not relaxed_neg_prep_window + ): pre_neg_pv_export_ts, pre_neg_cushion_by_day = _pre_neg_pv_export_bundle( slots, battery, @@ -2298,6 +2314,7 @@ def solve_dispatch( neg_evening_before_neg_ts, export_budget_wh=float(neg_evening_export_budget_wh), per_slot_discharge_wh=per_slot_neg_eve_wh, + discharge_export_ok=discharge_export_slots, ) elif om == "AUTO" and not purchase_fixed_pre: legacy_ok = bool( @@ -2678,19 +2695,20 @@ def solve_dispatch( neg_sell_soc_underfill.append( (t_tail_last, us_tail, float(battery.soc_max_wh)) ) - for t_ph in range(T): - if neg_sell_phase_by_t[t_ph] != "prep": - continue - cap_bc = float(battery.max_charge_power_w) - prep_hold_met_binary[t_ph] = pulp.LpVariable( - f"prep_hold_met_{t_ph}", - cat=pulp.LpBinary, - ) - sf_hold = pulp.LpVariable(f"prep_hold_bcpv_{t_ph}", 0, cap_bc) - prep_hold_bcpv_shortfall.append((t_ph, sf_hold, cap_bc)) - cap_ca = float(max(0, slots[t_ph].pv_a_forecast_w)) - sf_ca = pulp.LpVariable(f"prep_hold_curtail_{t_ph}", 0, cap_ca) - prep_hold_curtail_shortfall.append((t_ph, sf_ca, cap_ca)) + if not relaxed_neg_prep_window: + for t_ph in range(T): + if neg_sell_phase_by_t[t_ph] != "prep": + continue + cap_bc = float(battery.max_charge_power_w) + prep_hold_met_binary[t_ph] = pulp.LpVariable( + f"prep_hold_met_{t_ph}", + cat=pulp.LpBinary, + ) + sf_hold = pulp.LpVariable(f"prep_hold_bcpv_{t_ph}", 0, cap_bc) + prep_hold_bcpv_shortfall.append((t_ph, sf_hold, cap_bc)) + cap_ca = float(max(0, slots[t_ph].pv_a_forecast_w)) + sf_ca = pulp.LpVariable(f"prep_hold_curtail_{t_ph}", 0, cap_ca) + prep_hold_curtail_shortfall.append((t_ph, sf_ca, cap_ca)) elif len(neg_buy_slot_indices_pre) >= 2: t_nb_last = max(neg_buy_slot_indices_pre) if t_nb_last in charge_slots or relaxed_neg_buy_charge: @@ -3643,6 +3661,7 @@ def solve_dispatch( charge_commitment_prev_w=charge_commitment_prev_w, planner_version=planner_version, relaxed_expensive_import=True, + evening_push_ts_override=evening_push_ts_override, ) if not relaxed_neg_buy_charge: logger.warning( @@ -3664,6 +3683,60 @@ def solve_dispatch( relaxed_expensive_import=True, relaxed_neg_buy_charge=True, ) + if not relaxed_neg_prep_window: + logger.warning( + "solve_dispatch still Infeasible, retry with relaxed_neg_prep_window " + "(skip evening push/anchors and prep hold hard constraints)" + ) + 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_window=True, + neg_sell_phases_fallback=neg_sell_phases_fallback, + evening_push_ts_override=evening_push_ts_override, + ) + if not neg_sell_phases_fallback: + logger.warning( + "solve_dispatch still Infeasible, retry with neg_sell phases disabled " + "(prep_soc_percent=100)" + ) + 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_window=True, + neg_sell_phases_fallback=True, + evening_push_ts_override=evening_push_ts_override, + ) raise RuntimeError(f"Solver: {pulp.LpStatus[status]}") # --- Post-processing --- @@ -3988,6 +4061,9 @@ 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_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() @@ -4208,11 +4284,24 @@ async def run_rolling_replan( planner_version=planner_version_resolved, ) - logger.info(f"[site={site_id}] Rolling replan from {replan_from} → {horizon_to}") + logger.info( + "[site=%s] Rolling replan from %s → %s (tag=%s)", + site_id, + replan_from, + horizon_to, + PLANNER_BUILD_TAG, + ) battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode, tuv_stats = ( await _load_site_context(site_id, db) ) + if operating_mode != "AUTO": + logger.info( + "[site=%s] Rolling replan skipped: operating_mode=%s (not AUTO)", + site_id, + operating_mode, + ) + return None, None slots = await _load_slots(site_id, replan_from, horizon_to, db, soc_wh=soc_wh) # PV forecast korekce je kanonicky v DB (delta + rolling faktor + decay) a do LP vstupuje přes diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index c8462f3..25b9ac3 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,16 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-05-29 — Infeasible rolling: relax neg-prep okno (v40b) + +**Problém:** Po načtení OTE na **30. 5.** (neg sell) rolling/home-01 končil `Solver: Infeasible` od ~13:15; ruční replan stejně. Plán zůstal na runu z 13:00 (horizont jen do 22:00). Log často prázdný — výjimka se loguje na `WARNING`, scheduler ji polyká. + +**Změna (v40b):** Třetí retry `relaxed_neg_prep_window` (bez večerního push/kotvy + prep hold binárek); čtvrtý retry s `planner_neg_sell_prep_soc_percent=100` (fáze sell<0 vypnuté). Večerní push jen sloty s `allow_discharge_export`. Rolling v **MANUAL** se přeskočí (log INFO). Tag **`2026-05-29-neg-prep-infeasible-relax-v40b`**. + +**Ověření:** po deployi `POST …/plan/run?type=rolling` v AUTO; `solver_params.inputs.relaxed_neg_prep_window` nebo `neg_sell_phases_fallback`; log: `docker compose -f deploy/docker-compose.yml logs backend --since 2h 2>&1 | rg -i infeasible`. + +--- + ## 2026-05-29 — Neg-prep z pozorovaného SoC (Plan 5, v40) **Problém:** Strategie „místo na zítřejší FVE + sell<0“ a večerní výboj před neg dnem počítaly z **modelového** SoC (řetězení `soc_target` mezi dny v `_pre_neg_pv_export_bundle`). BMS měl často **~15 %** více → předčasné zastavení výboje, „mrtvé“ kWh přes noc, méně ranního pre-neg exportu.