Branch 2: home-01 neg-večer — export k reserve_soc, fix pos_sell_pre_neg_buy + oddělit evening_push od prep relax
This commit is contained in:
@@ -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 D−1 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
|
||||
|
||||
Reference in New Issue
Block a user