Branch 2: home-01 neg-večer — export k reserve_soc, fix pos_sell_pre_neg_buy + oddělit evening_push od prep relax
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-06-06 22:28:48 +02:00
parent 2a963c9793
commit 09bca0a903
4 changed files with 267 additions and 21 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-infeasible-journal-granular-prep-relax-v63"
PLANNER_BUILD_TAG = "2026-06-06-future-neg-buy-evening-export-v64"
SOLVER_RELAX_STEPS: tuple[str, ...] = (
"strict",
"relaxed_expensive_import",
@@ -93,6 +93,8 @@ NEG_EVENING_PREP_DISCHARGE_SHORTFALL_PENALTY_CZK_KWH = 120.0
# Kotva: SoC na konci večera D1 a těsně před 1. sell<0 ráno D ≤ reserve_soc.
NEG_EVENING_RESERVE_SOC_MAX_SLACK_WH = 400.0
NEG_EVENING_RESERVE_SOC_SLACK_PENALTY_CZK_PER_WH = 55.0
# Terminal SoC shadow price: při blízkém buy<0 nesmí LP „šetřit“ baterii ve večerní špičce.
FUTURE_NEG_BUY_TERMINAL_SOC_FACTOR_MULT = 0.1
# Před prvním sell<0: export FVE jen pokud predikce v sell<0 okně pokryje dobítí na prep cíl.
PRE_NEG_PV_EXPORT_FORECAST_MARGIN = 1.15
PRE_NEG_PV_EXPORT_MIN_NEEDED_WH = 2500.0
@@ -1369,6 +1371,100 @@ def _neg_evening_discharge_budget_wh(
)
def _first_neg_sell_idx_on_prague_day(
slots: list[PlanningSlot],
prague_day: object,
) -> int | None:
for t, st in enumerate(slots):
if _prague_calendar_date(st) != prague_day:
continue
if float(st.sell_price) < 0.0:
return t
return None
def _future_neg_buy_discharge_enabled(
slots: list[PlanningSlot],
battery: Any,
*,
first_neg_buy_idx: int,
first_neg_sell_idx: int | None,
observed_soc_wh: float,
neg_sell_phases_en: bool,
neg_sell_soc_target_by_t: list[Optional[float]] | None = None,
) -> bool:
"""
Večerní vývoz k reserve_soc před dnem s buy<0: aktivní i při relaxed_neg_prep_window,
pokud FVE v sell<0 okně pokryje deficit do prep rampy (× PRE_NEG_PV_EXPORT_FORECAST_MARGIN).
"""
if first_neg_buy_idx <= 0:
return False
neg_buy_day = _prague_calendar_date(slots[first_neg_buy_idx])
neg_sell_t = first_neg_sell_idx
if (
neg_sell_t is None
or _prague_calendar_date(slots[neg_sell_t]) != neg_buy_day
):
neg_sell_t = _first_neg_sell_idx_on_prague_day(slots, neg_buy_day)
if neg_sell_t is None:
return False
if neg_sell_phases_en and neg_sell_soc_target_by_t is not None:
tgt = neg_sell_soc_target_by_t[neg_sell_t]
target_wh = float(tgt) if tgt is not None else float(battery.soc_max_wh)
else:
target_wh = float(battery.soc_max_wh)
reserve_wh = float(
getattr(battery, "reserve_soc_wh", getattr(battery, "min_soc_wh", 0.0))
)
soc_obs = max(
float(battery.min_soc_wh),
min(float(observed_soc_wh), float(battery.soc_max_wh)),
)
if soc_obs <= reserve_wh + 1e-3:
return False
if soc_obs >= target_wh - 1e-3:
return True
usable_wh = _neg_sell_day_pv_usable_wh(
slots,
neg_sell_t,
max_charge_power_w=float(battery.max_charge_power_w),
charge_efficiency=float(battery.charge_efficiency),
)
needed_wh = max(0.0, target_wh - soc_obs)
if needed_wh < PRE_NEG_PV_EXPORT_MIN_NEEDED_WH:
return True
return usable_wh >= needed_wh * PRE_NEG_PV_EXPORT_FORECAST_MARGIN
def _pos_sell_pre_neg_buy_evening_export_exempt_ts(
slots: list[PlanningSlot],
pos_sell_pre_neg_buy_ts: list[int],
evening_peak_export_ts: list[int],
*,
charge_acquisition_czk_kwh: float,
min_spread: float,
fixed_tariff: bool,
future_neg_buy_discharge_en: bool,
) -> set[int]:
"""Večerní peak před buy<0: neaplikovat ge=0, pokud je vývoz ekonomicky výhodný."""
if not future_neg_buy_discharge_en:
return set()
evening_peak_set = set(evening_peak_export_ts)
out: set[int] = set()
for t in pos_sell_pre_neg_buy_ts:
if t not in evening_peak_set and not _in_evening_push_hour_window(slots[t]):
continue
if not _slot_profitable_battery_export(
slots[t],
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
min_spread=min_spread,
fixed_tariff=fixed_tariff,
):
continue
out.add(t)
return out
def _neg_evening_before_neg_push_indices(
slots: list[PlanningSlot],
candidate_ts: set[int],
@@ -2386,7 +2482,7 @@ def solve_dispatch(
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_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.
relaxed_neg_prep_window: čtvrtý retry — vypne strict pre-neg bundle; future_neg_buy večerní export zůstává.
"""
T = len(slots)
if T < 1:
@@ -2577,11 +2673,7 @@ def solve_dispatch(
if horizon_slots_h24 > 0
else 4.0
)
terminal_factor = float(battery.planner_terminal_soc_value_factor)
# Kč/Wh: ocenění energie ponechané v baterii na konci horizontu (receding horizon kotva).
terminal_soc_kcz_per_wh = (
avg_buy_terminal * terminal_factor / 1000.0
)
terminal_factor_base = float(battery.planner_terminal_soc_value_factor)
charge_acq_raw = getattr(slots[0], "charge_acquisition_buy_czk_kwh", None)
charge_acquisition_czk_kwh = (
@@ -2676,12 +2768,38 @@ 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]] = []
future_neg_buy_discharge_en = False
if (
om == "AUTO"
and not purchase_fixed_pre
and first_neg_buy_idx is not None
and first_neg_buy_idx > 0
):
future_neg_buy_discharge_en = _future_neg_buy_discharge_enabled(
slots,
battery,
first_neg_buy_idx=first_neg_buy_idx,
first_neg_sell_idx=first_neg_sell_idx,
observed_soc_wh=observed_soc_wh,
neg_sell_phases_en=neg_sell_phases_en,
neg_sell_soc_target_by_t=(
neg_sell_soc_target_by_t if neg_sell_phases_en else None
),
)
terminal_factor = terminal_factor_base
if future_neg_buy_discharge_en:
terminal_factor *= FUTURE_NEG_BUY_TERMINAL_SOC_FACTOR_MULT
# Kč/Wh: ocenění energie ponechané v baterii na konci horizontu (receding horizon kotva).
terminal_soc_kcz_per_wh = avg_buy_terminal * terminal_factor / 1000.0
neg_evening_bundle_strict = (
om == "AUTO"
and not purchase_fixed_pre
and neg_sell_phases_en
and not relaxed_neg_prep_window
):
)
neg_evening_discharge_active = neg_evening_bundle_strict or future_neg_buy_discharge_en
if neg_evening_bundle_strict:
pre_neg_pv_export_ts, pre_neg_cushion_by_day = _pre_neg_pv_export_bundle(
slots,
battery,
@@ -2690,9 +2808,13 @@ def solve_dispatch(
neg_sell_phases_en=True,
soc_target_by_t=neg_sell_soc_target_by_t,
)
if neg_evening_discharge_active:
meta_for_evening = neg_sell_day_meta
if not (meta_for_evening.get("days")) and first_neg_sell_idx is not None:
meta_for_evening = {"days": [{"first_neg_idx": first_neg_sell_idx}]}
neg_evening_before_neg_ts = _evening_discharge_before_neg_day_ts(
slots,
neg_sell_day_meta,
meta_for_evening,
)
neg_evening_before_neg_ts |= _discharge_before_first_neg_sell_ts(
slots,
@@ -2700,7 +2822,7 @@ def solve_dispatch(
)
neg_evening_reserve_anchors = _neg_evening_reserve_soc_anchors(
slots,
neg_sell_day_meta,
meta_for_evening,
battery,
)
reserve_wh = float(
@@ -2856,10 +2978,8 @@ def solve_dispatch(
first_neg_sell_idx=first_neg_sell_idx,
kv1_evening_push=kv1_evening_push_pre,
)
# Tvrdý ge_bat push vypnout jen v prep/fallback retry (ne při rei — jinak zmizí vývoz v špičce).
evening_push_hard_suppressed = bool(
relaxed_neg_prep_window or neg_sell_phases_fallback
)
# 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)
else:
evening_push_hard_suppressed = False
last_pos_sell_pre_neg_buy = _last_non_negative_sell_before_neg_buy(
@@ -2868,6 +2988,15 @@ def solve_dispatch(
pos_sell_pre_neg_buy_ts = _positive_sell_pre_neg_buy_indices(
slots, first_neg_buy_idx
)
pos_sell_pre_neg_buy_ge_exempt_ts = _pos_sell_pre_neg_buy_evening_export_exempt_ts(
slots,
pos_sell_pre_neg_buy_ts,
evening_peak_export_ts,
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
min_spread=float(degradation_cost_effective),
fixed_tariff=fixed_tariff_like_pre,
future_neg_buy_discharge_en=future_neg_buy_discharge_en,
)
pre_neg_buy_empty_ts = _pre_neg_buy_empty_discharge_indices(
slots, first_neg_buy_idx, last_pos_sell_pre_neg_buy
)
@@ -3980,6 +4109,7 @@ def solve_dispatch(
first_neg_buy_idx is not None
and first_neg_buy_idx > 0
and t in pos_sell_pre_neg_buy_ts
and t not in pos_sell_pre_neg_buy_ge_exempt_ts
):
prob += ge[t] == 0
prob += ge_pv[t] == 0
@@ -4332,7 +4462,7 @@ def solve_dispatch(
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)"
"(skip strict pre-neg bundle; future_neg_buy evening export kept)"
)
return solve_dispatch(
slots,
@@ -4754,6 +4884,12 @@ def solve_dispatch(
push_override_raw and not push_override_eff
),
"evening_push_hard_suppressed": bool(evening_push_hard_suppressed),
"future_neg_buy_discharge": bool(future_neg_buy_discharge_en),
"terminal_soc_factor_effective": float(terminal_factor),
"pos_sell_pre_neg_buy_ge_exempt_slots": [
slots[i].interval_start.isoformat()
for i in sorted(pos_sell_pre_neg_buy_ge_exempt_ts)
],
"evening_push_peak_fallback_used": bool(
om == "AUTO"
and not computed_evening_push_ts