diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index d4c7748..a889cb1 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -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-05-31-ba81-dawn-no-micro-curtail-v51" +PLANNER_BUILD_TAG = "2026-05-31-kv1-evening-push-morning-peak-v52" # Ranní slabá FVE: neaplikovat pv_store ge_pv=0 (jinak curtail při sell < večerní peak). DAWN_LOW_PV_NO_CURTAIL_W = 1500 # Mimo evening_push: preferovat bd pro dům místo gi, když buy >> acq (účinná cena importu). @@ -1666,17 +1666,42 @@ def _evening_push_discharge_budget_wh( return min(available_wh, exportable_full_wh * buf) +def _kv1_block_export_fixed_evening_push( + grid: Any, + *, + purchase_fixed: bool, +) -> bool: + """KV1: fixní buy + block_export — večerní push jiná profitabilita než acq+spread.""" + return purchase_fixed and bool( + getattr(grid, "block_export_on_negative_sell", False) + ) + + def _slot_evening_push_profitable( slot: PlanningSlot, *, charge_acquisition_czk_kwh: float, min_spread: float, + slots: list[PlanningSlot] | None = None, + first_neg_sell_idx: int | None = None, + kv1_evening_push: bool = False, ) -> bool: """ - Push večerní špičky: sell > acq+spread (zásoba z levného nabití). - Večer sell acq+spread (zásoba z levného nabití). + KV1 (fixed + block_export, v52): sell ≥ max sell v pásmu 5–11 před 1. sell<0 − spread + — neprodávat večer levněji než plánované ranní maximum; bez neg dne v horizontu sell ≥ 1 Kč. """ - return float(slot.sell_price) > float(charge_acquisition_czk_kwh) + float(min_spread) + sell_t = float(slot.sell_price) + if kv1_evening_push: + if sell_t < PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH: + return False + if slots is not None: + zone_peak = _morning_pre_neg_zone_peak_sell(slots, first_neg_sell_idx) + if zone_peak is not None: + return sell_t >= float(zone_peak) - float(min_spread) + return True + return sell_t > float(charge_acquisition_czk_kwh) + float(min_spread) def _evening_push_segment_candidates( @@ -1686,6 +1711,8 @@ def _evening_push_segment_candidates( charge_acquisition_czk_kwh: float, min_spread: float, discharge_export_ok: set[int] | None = None, + first_neg_sell_idx: int | None = None, + kv1_evening_push: bool = False, ) -> list[int]: """Profitable sloty v nočním úseku — výběr pořadí a strop dělá rozpočet Wh (sell desc).""" if not seg: @@ -1700,6 +1727,9 @@ def _evening_push_segment_candidates( slots[t], charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, min_spread=min_spread, + slots=slots, + first_neg_sell_idx=first_neg_sell_idx, + kv1_evening_push=kv1_evening_push, ): continue out.append(t) @@ -1824,6 +1854,8 @@ def _evening_battery_export_push_indices( discharge_slot_buffer: float, discharge_export_ok: set[int] | None = None, evening_start_hour: int = 17, + first_neg_sell_idx: int | None = None, + kv1_evening_push: bool = False, ) -> list[int]: """ Večerní push (≥17h): plný ge_bat v nejdražších slotách (sell desc), rozpočet Wh @@ -1858,6 +1890,8 @@ def _evening_battery_export_push_indices( charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, min_spread=degrad_czk_kwh, discharge_export_ok=discharge_export_ok, + first_neg_sell_idx=first_neg_sell_idx, + kv1_evening_push=kv1_evening_push, ): if t not in seen: seen.add(t) @@ -2580,6 +2614,10 @@ def solve_dispatch( ) discharge_buf_pre = float(getattr(battery, "discharge_slot_buffer", 0) or 0) discharge_floor_wh = _planner_discharge_floor_wh(battery) + kv1_evening_push_pre = _kv1_block_export_fixed_evening_push( + grid, + purchase_fixed=purchase_fixed_pre, + ) computed_evening_push_ts = set( _evening_battery_export_push_indices( slots, @@ -2591,6 +2629,8 @@ def solve_dispatch( per_slot_discharge_wh=per_slot_push_wh_pre, discharge_slot_buffer=discharge_buf_pre, discharge_export_ok=discharge_export_slots, + first_neg_sell_idx=first_neg_sell_idx, + kv1_evening_push=kv1_evening_push_pre, ) ) if evening_push_ts_override is not None: @@ -4371,6 +4411,10 @@ def solve_dispatch( else _evening_night_peak_sell_czk(slots) ), "evening_push_hysteresis_retained": bool(evening_push_hysteresis_retained), + "kv1_evening_push_morning_peak_rule": _kv1_block_export_fixed_evening_push( + grid, + purchase_fixed=purchase_fixed_pre, + ), "night_self_consume_discourage_ts": [ slots[i].interval_start.isoformat() for i in sorted(night_self_consume_discourage_ts) diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 7bce326..6204e64 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -30,6 +30,7 @@ from services.planning_engine import ( _pre_neg_pv_export_bundle, _pre_neg_pv_export_forecast_cushion_ok_for_day, _prague_calendar_date, + _prague_hour, _pre_neg_buy_soc_ceiling_wh, _pre_neg_peak_sell_idx, _pre_neg_pv_export_forecast_cushion_ok, @@ -2836,6 +2837,73 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): self.assertLessEqual(len(push), 4) self.assertEqual(push, [0, 1, 2, 3][: len(push)]) + def test_kv1_evening_push_profitable_vs_morning_zone_peak(self) -> None: + """v52: KV1 večer ≥ ranní max (5–11) − degrad; pod prahem ne.""" + prague = ZoneInfo("Europe/Prague") + neg = datetime(2026, 5, 31, 8, 15, tzinfo=prague).astimezone(timezone.utc) + slots = [ + PlanningSlot( + interval_start=datetime(2026, 5, 31, 6, 0, tzinfo=prague).astimezone(timezone.utc), + buy_price=6.35, + sell_price=2.55, + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=400, + ev1_connected=False, + ev2_connected=False, + ), + PlanningSlot( + interval_start=neg, + buy_price=6.35, + sell_price=-0.3, + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=400, + ev1_connected=False, + ev2_connected=False, + ), + ] + eve = PlanningSlot( + interval_start=datetime(2026, 5, 30, 22, 0, tzinfo=prague).astimezone(timezone.utc), + buy_price=6.35, + sell_price=3.30, + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=400, + ev1_connected=False, + ev2_connected=False, + ) + self.assertTrue( + _slot_evening_push_profitable( + eve, + charge_acquisition_czk_kwh=6.35, + min_spread=0.3, + slots=slots, + first_neg_sell_idx=1, + kv1_evening_push=True, + ) + ) + eve_low = PlanningSlot( + interval_start=eve.interval_start, + buy_price=6.35, + sell_price=2.20, + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=400, + ev1_connected=False, + ev2_connected=False, + ) + self.assertFalse( + _slot_evening_push_profitable( + eve_low, + charge_acquisition_czk_kwh=6.35, + min_spread=0.3, + slots=slots, + first_neg_sell_idx=1, + kv1_evening_push=True, + ) + ) + def test_evening_push_ok_when_sell_below_buy_vs_acq(self) -> None: """v47: večer sellacq — push pro vyprázdnění před neg dnem.""" slot = PlanningSlot( @@ -3817,6 +3885,106 @@ class PreNegativeSellExportTests(unittest.TestCase): self.assertGreater(abs(res[1].grid_setpoint_w), 500) self.assertLess(abs(res[1].battery_setpoint_w), 500) + def test_kv1_evening_push_when_sell_above_morning_peak_not_at_dawn(self) -> None: + """v52: večer sell ≥ ranní max (5–11) před sell<0 — push, ne až úsvit za horší cenu.""" + prague = ZoneInfo("Europe/Prague") + base = datetime(2026, 5, 30, 23, 0, tzinfo=prague) + specs: list[tuple[int, float, float]] = [] + for i in range(40): + local = base + timedelta(minutes=15 * i) + h = local.hour + if h >= 23 or h <= 2: + sell = 3.25 if i % 2 == 0 else 3.10 + pv_a = 0 + elif h == 4: + sell = 2.80 + pv_a = 900 + elif 5 <= h <= 7: + sell = 2.55 + pv_a = 1200 + elif h == 8 and local.minute == 15: + sell = -0.20 + pv_a = 4000 + elif h >= 8: + sell = -0.30 + pv_a = 4500 + else: + sell = 3.00 + pv_a = 0 + specs.append((pv_a, sell)) + slots: list[PlanningSlot] = [] + for i, (pv_a, sell) in enumerate(specs): + local = base + timedelta(minutes=15 * i) + slots.append( + PlanningSlot( + interval_start=local.astimezone(timezone.utc), + buy_price=6.3525, + sell_price=sell, + pv_a_forecast_w=pv_a, + pv_b_forecast_w=0, + load_baseline_w=400, + ev1_connected=False, + ev2_connected=False, + allow_charge=sell < 0, + allow_discharge_export=sell >= 0, + charge_acquisition_buy_czk_kwh=6.3525, + ) + ) + battery = _battery(uc_wh=12_500.0, min_pct=10.0, arb_pct=30.0) + battery.reserve_soc_percent = 30.0 + battery.max_discharge_power_w = 6250 + battery.discharge_slot_buffer = 1.5 + 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=8000, + block_export_on_negative_sell=True, + purchase_pricing_mode="fixed", + ) + 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), + ] + res, _, snap = solve_dispatch( + slots, + battery, + hp, + grid, + [None, None], + vehicles, + 0.62 * battery.soc_max_wh, + 50.0, + operating_mode="AUTO", + ) + self.assertTrue(snap["inputs"].get("kv1_evening_push_morning_peak_rule")) + push_iso = set(snap["inputs"].get("evening_push_ts") or []) + self.assertTrue( + any( + slots[i].interval_start.isoformat() in push_iso + for i in range(4) + if _in_evening_push_hour_window(slots[i]) + ), + "večerní push musí zahrnout slot ≥17h", + ) + eve_idx = next( + i + for i in range(len(slots)) + if _in_evening_push_hour_window(slots[i]) + and float(slots[i].sell_price) >= 3.0 + ) + self.assertLess( + res[eve_idx].grid_setpoint_w, + -2000, + "večer ~3,3 Kč: vývoz do sítě, ne jen úsvit", + ) + dawn_idx = next(i for i, s in enumerate(slots) if _prague_hour(s) == 4) + if slots[dawn_idx].interval_start.isoformat() not in push_iso: + self.assertLess( + abs(res[dawn_idx].battery_setpoint_w), + 4000, + "úsvit není jediný velký bat export pokud večer push proběhl", + ) + class Home01PvStoreValueTests(unittest.TestCase): """FVE: spot sell<0 → nabít/vent B; sell>=0 → LP volí export vs bc (ne tvrdý curtail).""" diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 950e032..2251cdf 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -118,7 +118,12 @@ flowchart TD - večerní push zůstává **sell > acq+spread** (sell<buy je záměr před neg dnem); - **`post_evening_push_night_ts`:** po pushu **bd ≥ load**, ne import ~5 Kč i při relaxed solve. -**Funkce:** … Tag: **`2026-05-30-post-push-night-battery-v47`**. +7. **v52 — KV1 večerní push (fixed + block_export):** + - push profitabilita: **`sell ≥ max(sell 5–11 před 1. sell<0) − degrad`**, ne `sell > fixní buy + spread`; + - **`evening_early`** beze změny — export jen v `evening_push_ts` (ne rozprostřeně po celé noci). + - Snap: `kv1_evening_push_morning_peak_rule`. Tag **`2026-05-31-kv1-evening-push-morning-peak-v52`**. + +**Funkce:** … Tag: **`2026-05-30-post-push-night-battery-v47`** (spot); KV1 v52 viz výše. ### Arbitráž baterie — účtování mezi sloty (povinné čtení) diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index 6adfb58..f1a10ce 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,18 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-05-31 — KV1: večerní push vs ranní max sell (v52) + +**Problém:** KV1 večer **~3,3 Kč** neprodával do sítě (`evening_push` prázdný: `sell < acq+spread` ≈ 6,65), vývoz až **úsvit ~2,8 Kč** před `sell<0` (08:15). Příčina: pravidla **v41 `evening_early`** + **v47 push profitabilita** z home-01 na fixní acquisition. + +**Změna (v52):** `_kv1_block_export_fixed_evening_push` — u **fixed + `block_export_on_negative_sell`** večerní push kandidát když `sell ≥ max(sell 5–11 před 1. sell<0) − degrad` (ne `sell > 6,35+spread`). Bez neg dne v horizontu: `sell ≥ 1 Kč`. Snap: `kv1_evening_push_morning_peak_rule`. + +Tag **`2026-05-31-kv1-evening-push-morning-peak-v52`**. + +**Ověření:** `pytest … -k kv1_evening_push_when_sell_above_morning`; MCP KV1 večer `BATTERY_SELL`, `evening_push_ts` neprázdný. + +--- + ## 2026-05-31 — BA81 úsvit: žádný plný curtail A / zápis reg 340 (v51) **Problém:** Při malém ranním PV (např. **405 W** A, **49 W** B) LP kvůli `fixed_pv_b_export_cap` (`ge_pv ≤ pv_b`) **usekl celé pole A** (`curt_a = pv_a`) a exporter posílal **reg 340** z nepřesného forecastu — zbytečný HW zápis, baterie prázdná.