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_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-infeasible-journal-granular-prep-relax-v63" PLANNER_BUILD_TAG = "2026-06-06-future-neg-buy-evening-export-v64"
SOLVER_RELAX_STEPS: tuple[str, ...] = ( SOLVER_RELAX_STEPS: tuple[str, ...] = (
"strict", "strict",
"relaxed_expensive_import", "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. # 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_MAX_SLACK_WH = 400.0
NEG_EVENING_RESERVE_SOC_SLACK_PENALTY_CZK_PER_WH = 55.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. # 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_FORECAST_MARGIN = 1.15
PRE_NEG_PV_EXPORT_MIN_NEEDED_WH = 2500.0 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( def _neg_evening_before_neg_push_indices(
slots: list[PlanningSlot], slots: list[PlanningSlot],
candidate_ts: set[int], 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_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_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_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) T = len(slots)
if T < 1: if T < 1:
@@ -2577,11 +2673,7 @@ def solve_dispatch(
if horizon_slots_h24 > 0 if horizon_slots_h24 > 0
else 4.0 else 4.0
) )
terminal_factor = float(battery.planner_terminal_soc_value_factor) terminal_factor_base = 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
)
charge_acq_raw = getattr(slots[0], "charge_acquisition_buy_czk_kwh", None) charge_acq_raw = getattr(slots[0], "charge_acquisition_buy_czk_kwh", None)
charge_acquisition_czk_kwh = ( charge_acquisition_czk_kwh = (
@@ -2676,12 +2768,38 @@ def solve_dispatch(
neg_evening_push_ts: set[int] = set() neg_evening_push_ts: set[int] = set()
neg_evening_export_budget_wh: float | None = None neg_evening_export_budget_wh: float | None = None
neg_evening_reserve_anchors: list[tuple[int, float]] = [] neg_evening_reserve_anchors: list[tuple[int, float]] = []
future_neg_buy_discharge_en = False
if ( 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" om == "AUTO"
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
): )
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( pre_neg_pv_export_ts, pre_neg_cushion_by_day = _pre_neg_pv_export_bundle(
slots, slots,
battery, battery,
@@ -2690,9 +2808,13 @@ def solve_dispatch(
neg_sell_phases_en=True, neg_sell_phases_en=True,
soc_target_by_t=neg_sell_soc_target_by_t, 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( neg_evening_before_neg_ts = _evening_discharge_before_neg_day_ts(
slots, slots,
neg_sell_day_meta, meta_for_evening,
) )
neg_evening_before_neg_ts |= _discharge_before_first_neg_sell_ts( neg_evening_before_neg_ts |= _discharge_before_first_neg_sell_ts(
slots, slots,
@@ -2700,7 +2822,7 @@ def solve_dispatch(
) )
neg_evening_reserve_anchors = _neg_evening_reserve_soc_anchors( neg_evening_reserve_anchors = _neg_evening_reserve_soc_anchors(
slots, slots,
neg_sell_day_meta, meta_for_evening,
battery, battery,
) )
reserve_wh = float( reserve_wh = float(
@@ -2856,10 +2978,8 @@ def solve_dispatch(
first_neg_sell_idx=first_neg_sell_idx, first_neg_sell_idx=first_neg_sell_idx,
kv1_evening_push=kv1_evening_push_pre, 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). # Tvrdý ge_bat push vypnout jen při neg_sell fallback (ne při prep relax — v64).
evening_push_hard_suppressed = bool( evening_push_hard_suppressed = bool(neg_sell_phases_fallback)
relaxed_neg_prep_window or neg_sell_phases_fallback
)
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(
@@ -2868,6 +2988,15 @@ def solve_dispatch(
pos_sell_pre_neg_buy_ts = _positive_sell_pre_neg_buy_indices( pos_sell_pre_neg_buy_ts = _positive_sell_pre_neg_buy_indices(
slots, first_neg_buy_idx 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( 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
) )
@@ -3980,6 +4109,7 @@ def solve_dispatch(
first_neg_buy_idx is not None first_neg_buy_idx is not None
and first_neg_buy_idx > 0 and first_neg_buy_idx > 0
and t in pos_sell_pre_neg_buy_ts 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[t] == 0
prob += ge_pv[t] == 0 prob += ge_pv[t] == 0
@@ -4332,7 +4462,7 @@ def solve_dispatch(
if not relaxed_neg_prep_window: if not relaxed_neg_prep_window:
logger.warning( logger.warning(
"solve_dispatch still Infeasible, retry with relaxed_neg_prep_window " "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( return solve_dispatch(
slots, slots,
@@ -4754,6 +4884,12 @@ def solve_dispatch(
push_override_raw and not push_override_eff push_override_raw and not push_override_eff
), ),
"evening_push_hard_suppressed": bool(evening_push_hard_suppressed), "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( "evening_push_peak_fallback_used": bool(
om == "AUTO" om == "AUTO"
and not computed_evening_push_ts and not computed_evening_push_ts

View File

@@ -3326,8 +3326,8 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
self.assertFalse(snap["inputs"].get("evening_push_hard_suppressed")) self.assertFalse(snap["inputs"].get("evening_push_hard_suppressed"))
self.assertLess(results[0].grid_setpoint_w, -1000) self.assertLess(results[0].grid_setpoint_w, -1000)
def test_relaxed_neg_prep_suppresses_hard_push_only(self) -> None: def test_relaxed_neg_prep_keeps_hard_push_v64(self) -> None:
"""v57: relaxed_neg_prep_window vypne jen tvrdý push, ne seznam slotů.""" """v64: relaxed_neg_prep_window nesmí vypnout tvrdý evening push (jen fallback)."""
prague = ZoneInfo("Europe/Prague") prague = ZoneInfo("Europe/Prague")
slots = [ slots = [
PlanningSlot( PlanningSlot(
@@ -3377,7 +3377,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
current_tuv_temp_c=55.0, current_tuv_temp_c=55.0,
relaxed_neg_prep_window=True, relaxed_neg_prep_window=True,
) )
self.assertTrue(snap["inputs"].get("evening_push_hard_suppressed")) self.assertFalse(snap["inputs"].get("evening_push_hard_suppressed"))
push_iso = snap["inputs"].get("evening_push_ts") or [] push_iso = snap["inputs"].get("evening_push_ts") or []
self.assertGreaterEqual(len(push_iso), 1) self.assertGreaterEqual(len(push_iso), 1)
@@ -3458,6 +3458,93 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase):
], ],
) )
def test_future_neg_buy_evening_export_at_high_soc_relaxed_prep(self) -> None:
"""v64: před buy<0 večerní export i při relaxed_neg_prep_window (neg-evening bundle)."""
prague = ZoneInfo("Europe/Prague")
base = datetime(2026, 6, 6, 19, 0, tzinfo=prague).astimezone(timezone.utc)
slots: list[PlanningSlot] = []
for i in range(96):
local = (base + timedelta(minutes=15 * i)).astimezone(prague)
d, h, m = local.day, local.hour, local.minute
hm = h + m / 60.0
if d == 6:
buy, sell = 3.0, 5.3
pv_a, pv_b = 0, 0
else:
sell = -0.2 if hm >= 5.75 and hm < 15 else 2.5
buy = -0.5 if 11 <= hm < 14 else 2.0
pv_a = 6000 if 8 <= h < 16 else 200
pv_b = 8000 if 8 <= h < 16 else 200
slots.append(
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=buy,
sell_price=sell,
pv_a_forecast_w=pv_a,
pv_b_forecast_w=pv_b,
load_baseline_w=500,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
charge_acquisition_buy_czk_kwh=0.8,
)
)
bat = _battery(uc_wh=64_000.0, arb_pct=20.0, terminal_soc_value_factor=0.0)
bat.max_discharge_power_w = 18_000
bat.max_charge_power_w = 18_000
bat.planner_neg_sell_prep_soc_percent = 80
bat.planner_neg_sell_full_soc_tail_slots = 4
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(
max_import_power_w=17_000,
max_export_power_w=13_500,
block_export_on_negative_sell=False,
purchase_pricing_mode="spot",
)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
relax_kw = dict(
relaxed_expensive_import=True,
relaxed_neg_buy_charge=True,
relaxed_neg_prep_hold_only=True,
relaxed_neg_prep_window=True,
)
results_hi, _ms, snap_hi = solve_dispatch(
slots,
bat,
hp,
grid,
[None, None],
vehicles,
0.81 * bat.soc_max_wh,
50.0,
operating_mode="AUTO",
**relax_kw,
)
self.assertTrue(snap_hi["inputs"].get("future_neg_buy_discharge"))
self.assertGreater(len(snap_hi["inputs"].get("neg_evening_push_slots") or []), 0)
self.assertLess(results_hi[0].grid_setpoint_w, -1000)
self.assertLess(results_hi[0].battery_soc_target, 80.0)
results_mid, _ms2, snap_mid = solve_dispatch(
slots,
bat,
hp,
grid,
[None, None],
vehicles,
0.35 * bat.soc_max_wh,
50.0,
operating_mode="AUTO",
**relax_kw,
)
self.assertFalse(snap_mid["inputs"].get("evening_push_hard_suppressed"))
self.assertFalse(snap_mid["inputs"].get("neg_sell_phases_fallback"))
self.assertLess(results_mid[0].grid_setpoint_w, -1000)
def test_kv1_evening_push_profitable_vs_morning_zone_peak(self) -> None: def test_kv1_evening_push_profitable_vs_morning_zone_peak(self) -> None:
"""v52: KV1 večer ≥ ranní max (511) degrad; pod prahem ne.""" """v52: KV1 večer ≥ ranní max (511) degrad; pod prahem ne."""
prague = ZoneInfo("Europe/Prague") prague = ZoneInfo("Europe/Prague")

View File

@@ -132,7 +132,7 @@ flowchart TD
11. **v56 — ranní tvrdý export:** `morning_pre_neg_export` / pre-neg discharge **jen strict**; pass2 Infeasible → **pass1**. Tag **`2026-05-31-morning-export-relaxed-v56`**. 11. **v56 — ranní tvrdý export:** `morning_pre_neg_export` / pre-neg discharge **jen strict**; pass2 Infeasible → **pass1**. Tag **`2026-05-31-morning-export-relaxed-v56`**.
12. **v57 — večerní push po rei:** `relaxed_expensive_import` **nesmí** vymazat `evening_push_ts`; tvrdý `ge_bat` push vypnut jen při `relaxed_neg_prep_window`. Tag **`2026-06-01-evening-push-keep-on-relaxed-import-v57`**. 12. **v57 / v64 — večerní push po rei / prep relax:** `relaxed_expensive_import` **nesmí** vymazat `evening_push_ts`; tvrdý `ge_bat` push vypnut jen při **`neg_sell_phases_fallback`** (v64: ne při `relaxed_neg_prep_window`). Tag **`2026-06-01-evening-push-keep-on-relaxed-import-v57`**, **`2026-06-06-future-neg-buy-evening-export-v64`**.
13. **v58 — fixní tarif PV vs. nabíjení (BA81/KV1):** `fixed_horizon_min_sell`; při **`sell > min + 0,20`** + PV → **`bc_pv = 0`**, export FVE; profitable noc **`sell > buy`** mimo `evening_early`. Tag **`2026-06-01-fixed-pv-export-min-sell-charge-v58`**. 13. **v58 — fixní tarif PV vs. nabíjení (BA81/KV1):** `fixed_horizon_min_sell`; při **`sell > min + 0,20`** + PV → **`bc_pv = 0`**, export FVE; profitable noc **`sell > buy`** mimo `evening_early`. Tag **`2026-06-01-fixed-pv-export-min-sell-charge-v58`**.
@@ -141,11 +141,16 @@ flowchart TD
15. **v61 — spot: grid→bat jen při buy ≤ acq:** `sell < buy` ve slotu **není** kritérium (marže); zákaz nabíjení při **`buy > charge_acquisition + degrad`**. Zrušeno v60. Tag **`2026-06-01-spot-grid-charge-at-acq-buy-v61`**. 15. **v61 — spot: grid→bat jen při buy ≤ acq:** `sell < buy` ve slotu **není** kritérium (marže); zákaz nabíjení při **`buy > charge_acquisition + degrad`**. Zrušeno v60. Tag **`2026-06-01-spot-grid-charge-at-acq-buy-v61`**.
16. **v63 — Infeasible journal + granulární prep relax (Branch 1):** 16. **v63 — Infeasible journal + granulární prep relax (Branch 1):**
- Retry řetězec: strict → `relaxed_expensive_import``relaxed_neg_buy_charge`**`relaxed_neg_prep_hold_only`** (jen prep hold / prep_soc shortfall) → **`relaxed_neg_prep_window`** (navíc vypne neg-evening bundle + tvrdý push) → `neg_sell_phases_fallback`. - Retry řetězec: strict → `relaxed_expensive_import``relaxed_neg_buy_charge`**`relaxed_neg_prep_hold_only`** (jen prep hold / prep_soc shortfall) → **`relaxed_neg_prep_window`** (vypne strict pre-neg PV export bundle) → `neg_sell_phases_fallback`.
- Snap: `relax_chain`, `relaxed_neg_prep_hold_only`; `evening_push_hard_suppressed` jen od `relaxed_neg_prep_window`. - Snap: `relax_chain`, `relaxed_neg_prep_hold_only`.
- Selhání po celém řetězci → `planning_run.status = failed`, sloupec `error_text`, `ems.fn_planning_run_fail` (aktivní plán se nemění). - Selhání po celém řetězci → `planning_run.status = failed`, sloupec `error_text`, `ems.fn_planning_run_fail` (aktivní plán se nemění).
- Diagnostika: `scripts/diagnose_home01_infeasible.py --print-export-sql --run-id <id>`. Tag **`2026-06-06-infeasible-journal-granular-prep-relax-v63`**. - Diagnostika: `scripts/diagnose_home01_infeasible.py --print-export-sql --run-id <id>`. Tag **`2026-06-06-infeasible-journal-granular-prep-relax-v63`**.
17. **v64 — future neg-buy večerní export (Branch 2, home-01):**
- **`future_neg_buy_discharge`**: před **`buy<0`** dnem s dostatečnou FVE v **`sell<0`** zůstává neg-evening push + kotvy **`reserve_soc`** i při **`relaxed_neg_prep_window`**.
- **`pos_sell_pre_neg_buy_ge_exempt_slots`**: večerní peak před **`buy<0`** — výjimka z `ge=0` při ekonomicky výhodném vývozu.
- **`terminal_soc_factor_effective`**: × **0,1** při **`future_neg_buy_discharge`**. Snap: `future_neg_buy_discharge`, `evening_push_hard_suppressed` (jen fallback). Tag **`2026-06-06-future-neg-buy-evening-export-v64`**.
**Funkce:** … home-01 **v61**; BA81/KV1 fixed **v59** (+ `R__063`). **Funkce:** … home-01 **v61**; BA81/KV1 fixed **v59** (+ `R__063`).
### Rozpočet nabíjecích slotů (plánováno, 2026-06) ### Rozpočet nabíjecích slotů (plánováno, 2026-06)

View File

@@ -5,6 +5,24 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen
--- ---
## 2026-06-06 — Future neg-buy večerní export (v64, Branch 2)
**Problém:** home-01 run 23784 při **`relaxed_neg_prep_window`**: `evening_push_hard_suppressed`, prázdné **`neg_evening_push_slots`**, **`pos_sell_pre_neg_buy_ts`** blokoval `ge_bat` ve večerní špičce, terminal SoC shadow price držel ~80 % SoC + import @ ~5 Kč.
**Změna (v64):**
- **`future_neg_buy_discharge`**: před dnem s **`buy<0`**, pokud FVE v **`sell<0`** pokryje deficit do prep rampy, zůstává neg-evening bundle (push + kotvy **`reserve_soc`**) i při **`relaxed_neg_prep_window`** (strict pre-neg PV export bundle se vypne).
- **`evening_push_hard_suppressed`** jen při **`neg_sell_phases_fallback`**, ne při **`relaxed_neg_prep_window`**.
- **`pos_sell_pre_neg_buy_ge_exempt_slots`**: večerní peak před **`buy<0`** nesmí dostat `ge=0`, pokud je vývoz ekonomicky výhodný.
- **`terminal_soc_factor_effective`**: při **`future_neg_buy_discharge`** násobit **`planner_terminal_soc_value_factor`** × **0,1**.
**Soubory:** `backend/services/planning_engine.py`, `backend/tests/test_planning_dispatch_milp.py`.
**Ověření:**
- `pytest backend/tests/test_planning_dispatch_milp.py -k "future_neg_buy or relaxed_neg_prep"`
- MCP: `solver_params->'inputs'->>'future_neg_buy_discharge' = true`, `evening_push_hard_suppressed = false` (bez fallback), večer `grid_setpoint_w < 0` k ~**`reserve_soc`**.
---
## 2026-06-06 — Infeasible journal + granulární prep relax (v63, Branch 1) ## 2026-06-06 — Infeasible journal + granulární prep relax (v63, Branch 1)
**Problém:** home-01 run 23784 prošel až **`relaxed_neg_prep_window`** (3. retry) → `evening_push_hard_suppressed`, prázdné `neg_evening_push_slots`, SoC ~80 % ve špičce + import @ ~5 Kč. Selhání **`Solver: Infeasible`** se neukládalo do DB (jen log backendu). **Problém:** home-01 run 23784 prošel až **`relaxed_neg_prep_window`** (3. retry) → `evening_push_hard_suppressed`, prázdné `neg_evening_push_slots`, SoC ~80 % ve špičce + import @ ~5 Kč. Selhání **`Solver: Infeasible`** se neukládalo do DB (jen log backendu).