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_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-degraded-night-guard-v4"
PLANNER_BUILD_TAG = "2026-06-06-home01-strict-late-replan-v5"
SOLVER_RELAX_STEPS: tuple[str, ...] = (
"strict",
"relaxed_expensive_import",
@@ -1940,6 +1940,56 @@ def _evening_push_segment_candidates(
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(
slots: list[PlanningSlot],
) -> set[int]:
@@ -2658,6 +2708,9 @@ def solve_dispatch(
or relaxed_solver_masks
)
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)
planner_version_resolved = _planner_engine_version(planner_version)
planner_v2 = planner_version_resolved == "v2"
@@ -2902,10 +2955,60 @@ def solve_dispatch(
)
min_spread_pre = float(degradation_cost_effective)
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 = (
om == "AUTO"
and not purchase_fixed_pre
and _neg_sell_phases_enabled(battery)
and not late_replan_strict_active
)
neg_sell_phase_by_t: list[str] = ["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 neg_sell_phases_en
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
if neg_evening_bundle_strict:
@@ -3151,7 +3255,9 @@ def solve_dispatch(
purchase_fixed=purchase_fixed_pre,
)
# 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:
evening_push_hard_suppressed = False
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,
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(
slots, first_neg_buy_idx, last_pos_sell_pre_neg_buy
)
@@ -3199,6 +3307,13 @@ def solve_dispatch(
slots, evening_push_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 = {
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
or relaxed_neg_prep_window
or neg_sell_phases_fallback
or late_replan_solver_relax
):
commitment_for_solve = None
if commitment_for_solve is not None and len(commitment_for_solve) == T:
@@ -3422,7 +3538,7 @@ def solve_dispatch(
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 = [
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))
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 prep_hold_relaxed:
if neg_sell_phases_en and not (prep_hold_relaxed or late_replan_strict_active):
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]
@@ -3552,7 +3668,7 @@ def solve_dispatch(
continue
tail_last_by_day[_prague_calendar_date(st_ln)] = t_ln
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(
f"neg_sell_tail_soc_{t_tail_last}",
0,
@@ -3561,7 +3677,7 @@ def solve_dispatch(
neg_sell_soc_underfill.append(
(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):
if neg_sell_phase_by_t[t_ph] != "prep":
continue
@@ -3577,7 +3693,7 @@ def solve_dispatch(
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:
if t_nb_last in charge_slots or relaxed_neg_buy_charge or late_replan_solver_relax:
us = pulp.LpVariable(
f"neg_buy_soc_under_{t_nb_last}",
0,
@@ -4584,13 +4700,13 @@ def solve_dispatch(
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)
else:
prob += gi[t] <= ev_cap_t + hp[t]
if (
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":
prob += (
bd[t] + pv_ld[t]
@@ -5272,6 +5388,16 @@ def solve_dispatch(
slots[i].interval_start.isoformat()
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,
"soc_bounds": soc_bounds_snap,

View File

@@ -3695,6 +3695,11 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
50.0,
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].battery_soc_target, 70.0)