prej final final v2 verze
Some checks failed
CI and deploy / migration-check (push) Failing after 15s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-06-07 00:05:46 +02:00
parent 50ac40868d
commit edc8ae9774
3 changed files with 143 additions and 11 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-degraded-night-guard-v4" PLANNER_BUILD_TAG = "2026-06-06-home01-strict-late-replan-v5"
SOLVER_RELAX_STEPS: tuple[str, ...] = ( SOLVER_RELAX_STEPS: tuple[str, ...] = (
"strict", "strict",
"relaxed_expensive_import", "relaxed_expensive_import",
@@ -1940,6 +1940,56 @@ def _evening_push_segment_candidates(
return out return out
def _strict_late_replan_evening_slot_indices(
slots: list[PlanningSlot],
*,
first_neg_buy_idx: int | None,
observed_soc_wh: float,
reserve_soc_wh: float,
) -> set[int]:
"""
Strict solve: večer D0 (1722h) před dnem s buy<0 — vývoz k reserve, výjimka z pos_sell ge=0.
"""
if first_neg_buy_idx is None or first_neg_buy_idx <= 0:
return set()
if not any(float(s.buy_price) < 0.0 for s in slots):
return set()
if observed_soc_wh <= float(reserve_soc_wh) + 500.0:
return set()
replan_day = _prague_calendar_date(slots[0])
out: set[int] = set()
for t, s in enumerate(slots):
if _prague_calendar_date(s) != replan_day:
continue
h = _prague_hour(s)
if h < NIGHT_EXPORT_EVENING_START_HOUR or h > 22:
continue
if float(s.sell_price) < 0.0:
continue
out.add(t)
return out
def _strict_late_replan_night_self_consume_indices(
slots: list[PlanningSlot],
*,
evening_export_ts: set[int],
) -> set[int]:
"""Strict: noc po 22h — dům z baterie, ne drahý import (mimo večerní export sloty)."""
out: set[int] = set()
for t, s in enumerate(slots):
if t in evening_export_ts:
continue
if not _in_night_battery_export_window(s):
continue
if float(s.load_baseline_w) <= 0:
continue
if float(s.buy_price) < 0.0:
continue
out.add(t)
return out
def _degraded_relaxed_night_self_consume_indices( def _degraded_relaxed_night_self_consume_indices(
slots: list[PlanningSlot], slots: list[PlanningSlot],
) -> set[int]: ) -> set[int]:
@@ -2658,6 +2708,9 @@ def solve_dispatch(
or relaxed_solver_masks 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
late_replan_strict_active = False
strict_late_replan_evening_ts: set[int] = set()
strict_late_replan_night_ts: set[int] = set()
EV = len(vehicles) # počet EV (typicky 2) EV = len(vehicles) # počet EV (typicky 2)
planner_version_resolved = _planner_engine_version(planner_version) planner_version_resolved = _planner_engine_version(planner_version)
planner_v2 = planner_version_resolved == "v2" planner_v2 = planner_version_resolved == "v2"
@@ -2902,10 +2955,60 @@ def solve_dispatch(
) )
min_spread_pre = float(degradation_cost_effective) min_spread_pre = float(degradation_cost_effective)
fixed_tariff_like_pre = purchase_fixed_pre or _horizon_fixed_tariff_like(slots) fixed_tariff_like_pre = purchase_fixed_pre or _horizon_fixed_tariff_like(slots)
reserve_soc_wh_solver = float(
getattr(battery, "reserve_soc_wh", getattr(battery, "min_soc_wh", 0.0))
)
if om == "AUTO" and not purchase_fixed_pre and not relaxed_solver_masks:
strict_late_replan_evening_ts = _strict_late_replan_evening_slot_indices(
slots,
first_neg_buy_idx=first_neg_buy_idx,
observed_soc_wh=observed_soc_wh,
reserve_soc_wh=reserve_soc_wh_solver,
)
late_replan_strict_active = bool(strict_late_replan_evening_ts)
if late_replan_strict_active:
slots = [
replace(
s,
allow_charge=True,
allow_discharge_export=True,
)
if i in strict_late_replan_evening_ts
else s
for i, s in enumerate(slots)
]
charge_slots |= strict_late_replan_evening_ts
discharge_export_slots |= strict_late_replan_evening_ts
if not relaxed_pos_sell_ge_block and not relaxed_solver_masks:
slots = _relax_solver_slot_masks(slots)
charge_slots = {t for t, s in enumerate(slots) if s.allow_charge}
charge_slots |= {
t for t, s in enumerate(slots) if float(s.buy_price) < 0.0
}
charge_slots |= {
t
for t, s in enumerate(slots)
if float(s.sell_price) < 0.0
and max(
0,
int(s.pv_a_forecast_w)
+ int(s.pv_b_forecast_w)
- int(s.load_baseline_w),
)
> 500
}
discharge_export_slots = {
t for t, s in enumerate(slots) if s.allow_discharge_export
}
late_replan_solver_relax = (
late_replan_strict_active
and not relaxed_solver_masks
)
neg_sell_phases_en = ( neg_sell_phases_en = (
om == "AUTO" om == "AUTO"
and not purchase_fixed_pre and not purchase_fixed_pre
and _neg_sell_phases_enabled(battery) and _neg_sell_phases_enabled(battery)
and not late_replan_strict_active
) )
neg_sell_phase_by_t: list[str] = ["none"] * T neg_sell_phase_by_t: list[str] = ["none"] * T
neg_sell_soc_target_by_t: list[Optional[float]] = [None] * T neg_sell_soc_target_by_t: list[Optional[float]] = [None] * T
@@ -2963,6 +3066,7 @@ def solve_dispatch(
and not purchase_fixed_pre and not purchase_fixed_pre
and neg_sell_phases_en and neg_sell_phases_en
and not relaxed_neg_prep_window and not relaxed_neg_prep_window
and not late_replan_strict_active
) )
neg_evening_discharge_active = neg_evening_bundle_strict or future_neg_buy_discharge_en neg_evening_discharge_active = neg_evening_bundle_strict or future_neg_buy_discharge_en
if neg_evening_bundle_strict: if neg_evening_bundle_strict:
@@ -3151,7 +3255,9 @@ def solve_dispatch(
purchase_fixed=purchase_fixed_pre, purchase_fixed=purchase_fixed_pre,
) )
# Tvrdý ge_bat push vypnout jen při neg_sell fallback (ne při prep relax — v64). # Tvrdý ge_bat push vypnout jen při neg_sell fallback (ne při prep relax — v64).
evening_push_hard_suppressed = bool(neg_sell_phases_fallback) evening_push_hard_suppressed = bool(
neg_sell_phases_fallback or late_replan_strict_active
)
else: else:
evening_push_hard_suppressed = False evening_push_hard_suppressed = False
last_pos_sell_pre_neg_buy = _last_non_negative_sell_before_neg_buy( last_pos_sell_pre_neg_buy = _last_non_negative_sell_before_neg_buy(
@@ -3169,6 +3275,8 @@ def solve_dispatch(
fixed_tariff=fixed_tariff_like_pre, fixed_tariff=fixed_tariff_like_pre,
future_neg_buy_discharge_en=future_neg_buy_discharge_en, future_neg_buy_discharge_en=future_neg_buy_discharge_en,
) )
if strict_late_replan_evening_ts:
pos_sell_pre_neg_buy_ge_exempt_ts |= strict_late_replan_evening_ts
pre_neg_buy_empty_ts = _pre_neg_buy_empty_discharge_indices( pre_neg_buy_empty_ts = _pre_neg_buy_empty_discharge_indices(
slots, first_neg_buy_idx, last_pos_sell_pre_neg_buy slots, first_neg_buy_idx, last_pos_sell_pre_neg_buy
) )
@@ -3199,6 +3307,13 @@ def solve_dispatch(
slots, evening_push_ts slots, evening_push_ts
) )
night_self_consume_discourage_ts |= post_evening_push_night_ts night_self_consume_discourage_ts |= post_evening_push_night_ts
if not relaxed_solver_masks and strict_late_replan_evening_ts:
strict_late_replan_night_ts = _strict_late_replan_night_self_consume_indices(
slots,
evening_export_ts=strict_late_replan_evening_ts,
)
night_self_consume_discourage_ts |= strict_late_replan_night_ts
post_evening_push_night_ts |= strict_late_replan_night_ts
battery_export_defer_pv_ts = { battery_export_defer_pv_ts = {
t for t in range(T) if _battery_export_push_defer_to_pv(slots[t]) t for t in range(T) if _battery_export_push_defer_to_pv(slots[t])
} }
@@ -3343,6 +3458,7 @@ def solve_dispatch(
relaxed_neg_buy_charge 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 late_replan_solver_relax
): ):
commitment_for_solve = None commitment_for_solve = None
if commitment_for_solve is not None and len(commitment_for_solve) == T: if commitment_for_solve is not None and len(commitment_for_solve) == T:
@@ -3422,7 +3538,7 @@ def solve_dispatch(
deg_cap, deg_cap,
) )
degraded_evening_export_shortfall.append((t_deg, sf_deg, deg_cap)) degraded_evening_export_shortfall.append((t_deg, sf_deg, deg_cap))
if not relaxed_neg_buy_charge: if not (relaxed_neg_buy_charge or late_replan_solver_relax):
neg_buy_slot_indices = [ neg_buy_slot_indices = [
t for t, s in enumerate(slots) if float(s.buy_price) < 0.0 t for t, s in enumerate(slots) if float(s.buy_price) < 0.0
] ]
@@ -3533,7 +3649,7 @@ def solve_dispatch(
cap_w = float(min(pv_surplus_w, battery.max_charge_power_w)) 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) sf_pv = pulp.LpVariable(f"post_neg_pv_shortfall_{t}", 0, cap_w)
pv_charge_shortfall.append((t, sf_pv, cap_w)) pv_charge_shortfall.append((t, sf_pv, cap_w))
if neg_sell_phases_en and not prep_hold_relaxed: if neg_sell_phases_en and not (prep_hold_relaxed or late_replan_strict_active):
for t_ns in range(T): for t_ns in range(T):
phase_ns = neg_sell_phase_by_t[t_ns] phase_ns = neg_sell_phase_by_t[t_ns]
tgt_ns = neg_sell_soc_target_by_t[t_ns] tgt_ns = neg_sell_soc_target_by_t[t_ns]
@@ -3552,7 +3668,7 @@ def solve_dispatch(
continue continue
tail_last_by_day[_prague_calendar_date(st_ln)] = t_ln tail_last_by_day[_prague_calendar_date(st_ln)] = t_ln
for t_tail_last in tail_last_by_day.values(): for t_tail_last in tail_last_by_day.values():
if t_tail_last in charge_slots or relaxed_neg_buy_charge: if t_tail_last in charge_slots or relaxed_neg_buy_charge or late_replan_solver_relax:
us_tail = pulp.LpVariable( us_tail = pulp.LpVariable(
f"neg_sell_tail_soc_{t_tail_last}", f"neg_sell_tail_soc_{t_tail_last}",
0, 0,
@@ -3561,7 +3677,7 @@ def solve_dispatch(
neg_sell_soc_underfill.append( neg_sell_soc_underfill.append(
(t_tail_last, us_tail, float(battery.soc_max_wh)) (t_tail_last, us_tail, float(battery.soc_max_wh))
) )
if not prep_hold_relaxed: if not (prep_hold_relaxed or late_replan_strict_active):
for t_ph in range(T): for t_ph in range(T):
if neg_sell_phase_by_t[t_ph] != "prep": if neg_sell_phase_by_t[t_ph] != "prep":
continue continue
@@ -3577,7 +3693,7 @@ def solve_dispatch(
prep_hold_curtail_shortfall.append((t_ph, sf_ca, cap_ca)) prep_hold_curtail_shortfall.append((t_ph, sf_ca, cap_ca))
elif len(neg_buy_slot_indices_pre) >= 2: elif len(neg_buy_slot_indices_pre) >= 2:
t_nb_last = max(neg_buy_slot_indices_pre) t_nb_last = max(neg_buy_slot_indices_pre)
if t_nb_last in charge_slots or relaxed_neg_buy_charge: if t_nb_last in charge_slots or relaxed_neg_buy_charge or late_replan_solver_relax:
us = pulp.LpVariable( us = pulp.LpVariable(
f"neg_buy_soc_under_{t_nb_last}", f"neg_buy_soc_under_{t_nb_last}",
0, 0,
@@ -4584,13 +4700,13 @@ def solve_dispatch(
or force_night_self_consume or force_night_self_consume
) )
) )
if relaxed_expensive_import and not night_self_consume_slot: if (relaxed_expensive_import or late_replan_solver_relax) and not night_self_consume_slot:
prob += gi[t] <= ev_cap_t + hp[t] + float(s.load_baseline_w) prob += gi[t] <= ev_cap_t + hp[t] + float(s.load_baseline_w)
else: else:
prob += gi[t] <= ev_cap_t + hp[t] prob += gi[t] <= ev_cap_t + hp[t]
if ( if (
force_night_self_consume force_night_self_consume
or (not relaxed_expensive_import or night_self_consume_slot) or (not (relaxed_expensive_import or late_replan_solver_relax) or night_self_consume_slot)
) and om == "AUTO": ) and om == "AUTO":
prob += ( prob += (
bd[t] + pv_ld[t] bd[t] + pv_ld[t]
@@ -5272,6 +5388,16 @@ def solve_dispatch(
slots[i].interval_start.isoformat() slots[i].interval_start.isoformat()
for i in sorted(degraded_evening_export_ts) for i in sorted(degraded_evening_export_ts)
], ],
"strict_late_replan_evening_ts": [
slots[i].interval_start.isoformat()
for i in sorted(strict_late_replan_evening_ts)
],
"strict_late_replan_night_ts": [
slots[i].interval_start.isoformat()
for i in sorted(strict_late_replan_night_ts)
],
"late_replan_strict_active": bool(late_replan_strict_active),
"late_replan_solver_relax": bool(late_replan_solver_relax),
}, },
"masks": masks_snap, "masks": masks_snap,
"soc_bounds": soc_bounds_snap, "soc_bounds": soc_bounds_snap,

View File

@@ -3695,6 +3695,11 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
50.0, 50.0,
operating_mode="AUTO", operating_mode="AUTO",
) )
self.assertEqual(snap["inputs"].get("relax_chain"), ["strict"])
self.assertNotIn(
"relaxed_solver_masks",
snap["inputs"].get("relax_chain") or [],
)
self.assertLess(results[0].grid_setpoint_w, -500) self.assertLess(results[0].grid_setpoint_w, -500)
self.assertLess(results[0].battery_soc_target, 70.0) self.assertLess(results[0].battery_soc_target, 70.0)

View File

@@ -17,14 +17,15 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen
- Nový retry **`relaxed_pos_sell_ge_block`** (+ **`relaxed_solver_masks`** nouzový) v `SOLVER_RELAX_STEPS`. - 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`. - **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`.
- **v3 (`degraded-night-guard-v3`):** v **`relaxed_solver_masks`** — spot **bez nočního/večerního `ge_bat` exportu**; **`_degraded_relaxed_night_self_consume_indices`** + tvrdý expensive-import guard (dům z baterie až **`min_soc`**, ne import za ~4 Kč); exportní podlaha SoC ≥ **`reserve_soc`**. - **v3 (`degraded-night-guard-v3`):** v **`relaxed_solver_masks`** — spot **bez nočního/večerního `ge_bat` exportu**; **`_degraded_relaxed_night_self_consume_indices`** + tvrdý expensive-import guard (dům z baterie až **`min_soc`**, ne import za ~4 Kč); exportní podlaha SoC ≥ **`reserve_soc`**.
- **v4 (`degraded-night-guard-v4`):** oprava v3 — `charge_slots=all` obcházela expensive-import guard → import za ~4 Kč. **Večer D0 (1722h):** vývoz k **`reserve_soc`** (`degraded_evening_export_ts` + shortfall). **Po 22h / půlnoc:** tvrdý **`bd ≥ load`** (`force_night_self_consume`) i když `t ∈ charge_slots`. Večerní export sloty **ne** sahají do 23h+ (jinak blokují noc). - **v4 (`degraded-night-guard-v4`):** oprava v3 — nouzový režim pro `relaxed_solver_masks` (viz výše).
- **v5 (`strict-late-replan-v5`):** **strict solve** bez relax chainu při pozdním replanu večer před `buy<0` dnem — `late_replan_strict_active`: večer 1722h vývoz k **reserve**, noc self-consume (discourage import), vypnutí neg-evening bundle + prep fází + tvrdého evening push; snapshot `strict_late_replan_*_ts`, `late_replan_solver_relax`.
**Soubory:** `planning_engine.py`, `scripts/repro_home01_23840.py`, testy `test_home01_late_replan_high_soc_realistic_masks`, `test_degraded_relaxed_solver_evening_to_reserve_and_night_self_consume`. **Soubory:** `planning_engine.py`, `scripts/repro_home01_23840.py`, testy `test_home01_late_replan_high_soc_realistic_masks`, `test_degraded_relaxed_solver_evening_to_reserve_and_night_self_consume`.
**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 or degraded_relaxed"` - `pytest backend/tests/test_planning_dispatch_milp.py -k "home01_late_replan or degraded_relaxed"`
- Po deployi: aktivní run `planner_build_tag` končí **`degraded-night-guard-v4`**; při `relax_chain` obsahujícím `relaxed_solver_masks`: večer vývoz k ~**20 %**, noc **bez** importu pro baseload, ráno headroom na FVE + **`buy<0`**. - Po deployi v5: `relax_chain = ["strict"]`, `late_replan_strict_active = true`**bez** `relaxed_solver_masks`; večer export, noc bez drahého importu baseloadu.
--- ---