From 9ba65ea6bb4d1f2507256d67e56865be024ea150 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Mon, 25 May 2026 00:10:58 +0200 Subject: [PATCH] a dalsi fix --- backend/services/planning_engine.py | 2 +- .../test_planning_charge_slot_selection.py | 120 ++++++++++++++++++ backend/tests/test_planning_dispatch_milp.py | 6 +- .../R__063_fn_load_planning_slots_full.sql | 33 ++++- docs/planning-changelog.md | 17 +++ 5 files changed, 170 insertions(+), 8 deletions(-) diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 4097190..8aa9a0e 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -64,7 +64,7 @@ NEG_SELL_PV_B_VENT_PENALTY_CZK_KWH = 4.0 # Výboj baterie při sell<0 jen těsně před extrémně záporným buy (round-trip arbitráž). EXTREME_BUY_DUMP_PREWINDOW_SLOTS = 12 NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH = 80.0 -PLANNER_BUILD_TAG = "2026-05-27-self-consistent-grid-mask-v12" +PLANNER_BUILD_TAG = "2026-05-27-neg-sell-soc-reservation-v13" CORRECTION_WINDOW_H = 1 # hodina zpět pro výpočet korekčního faktoru CORRECTION_MIN_CLAMP = 0.5 # spodní limit korekčního faktoru CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru diff --git a/backend/tests/test_planning_charge_slot_selection.py b/backend/tests/test_planning_charge_slot_selection.py index e82009b..7fd187b 100644 --- a/backend/tests/test_planning_charge_slot_selection.py +++ b/backend/tests/test_planning_charge_slot_selection.py @@ -1010,5 +1010,125 @@ class FixedPurchasePricingTests(unittest.TestCase): self.assertIn(2, discharge, "oba sloty sell > buy + degrad") +class NegSellReservationTests(unittest.TestCase): + """Mirror SQL `R__063` PV vrstva A — rezervace `v_pv_layer_cap_wh` pro `sell<0` okno. + + Cíl: do prvního `sell<0` slotu dorazit s SoC = soc_max − min(neg_window_pv, available_storage), + aby `sell<0` PV mohlo doplnit zbytek bez exportu / curtail pole A. + """ + + @staticmethod + def _pv_layer_cap_with_reservation( + slots, # list of dict { sell, pv_surplus_w } + energy_to_fill_wh: float, + grid_filled_wh: float, + max_charge_w: float, + charge_eff: float, + ) -> float: + cap = max(energy_to_fill_wh - grid_filled_wh, 0.0) + neg_window = sum( + min(float(s["pv_surplus_w"]), max_charge_w) * charge_eff * 0.25 + for s in slots + if float(s["sell"]) < 0 and float(s["pv_surplus_w"]) > 0 + ) + return max(cap - neg_window, 0.0) + + def test_neg_sell_window_reduces_pv_layer_cap(self) -> None: + # 4 ranní PV sloty (sell>=0), 2 odpolední neg-sell PV sloty + slots = [ + {"sell": 1.2, "pv_surplus_w": 6000}, # 10:00 cap candidate + {"sell": 1.0, "pv_surplus_w": 7000}, # 11:00 + {"sell": 0.8, "pv_surplus_w": 8000}, # 12:00 + {"sell": -0.5, "pv_surplus_w": 9000}, # 13:00 neg-sell + {"sell": -1.0, "pv_surplus_w": 9000}, # 13:30 neg-sell + ] + cap_before = 20_000.0 # 20 kWh deficit + cap_after = self._pv_layer_cap_with_reservation( + slots, + energy_to_fill_wh=cap_before, + grid_filled_wh=0.0, + max_charge_w=10_000.0, + charge_eff=0.95, + ) + # neg-window = 2 × min(9000, 10000) × 0.95 × 0.25 = 4 275 + self.assertAlmostEqual(cap_after, cap_before - 4275.0, places=1) + self.assertLess(cap_after, cap_before) + + def test_neg_sell_pv_covers_full_deficit(self) -> None: + slots = [ + {"sell": 1.0, "pv_surplus_w": 5000}, # ranní + {"sell": -0.4, "pv_surplus_w": 9000}, + {"sell": -1.1, "pv_surplus_w": 9000}, + {"sell": -0.8, "pv_surplus_w": 9000}, + ] + # neg-window = 3 × 9000 × 0.95 × 0.25 = 6 412.5 Wh + cap_after = self._pv_layer_cap_with_reservation( + slots, + energy_to_fill_wh=5_000.0, + grid_filled_wh=0.0, + max_charge_w=10_000.0, + charge_eff=0.95, + ) + # cap (5000) - neg_window (6412.5) → clamp na 0 → žádné ranní PV nabíjení + self.assertEqual(cap_after, 0.0) + + def test_no_neg_sell_window_no_change(self) -> None: + slots = [ + {"sell": 1.2, "pv_surplus_w": 6000}, + {"sell": 0.8, "pv_surplus_w": 7000}, + ] + cap_before = 8_000.0 + cap_after = self._pv_layer_cap_with_reservation( + slots, + energy_to_fill_wh=cap_before, + grid_filled_wh=0.0, + max_charge_w=10_000.0, + charge_eff=0.95, + ) + self.assertEqual(cap_after, cap_before) + + +class FallbackAcquisitionTests(unittest.TestCase): + """Mirror SQL `R__063` fallback when `v_est_grid_wh = 0` and no positive buy grid slot.""" + + @staticmethod + def _fallback_acq(slots, est_grid_wh: float, est_grid_cost: float) -> float: + if est_grid_wh > 0: + return est_grid_cost / est_grid_wh + positive_buys = [float(s["buy"]) for s in slots if float(s["buy"]) >= 0] + v = min(positive_buys) if positive_buys else 0.0 + return max(v, 0.0) + + def test_no_grid_charging_but_horizon_has_positive_buys(self) -> None: + # PM má negativní buy (13:00–14:00), ale jinde v horizontu pozitivní min + slots = [ + {"buy": 4.5, "hour": 23}, + {"buy": 0.3, "hour": 7}, + {"buy": 0.7, "hour": 10}, + {"buy": -0.36, "hour": 13}, + {"buy": -0.43, "hour": 14}, + ] + acq = self._fallback_acq(slots, est_grid_wh=0.0, est_grid_cost=0.0) + self.assertAlmostEqual(acq, 0.3, places=4) + self.assertGreaterEqual(acq, 0.0) + + def test_all_negative_buys_clamps_to_zero(self) -> None: + slots = [ + {"buy": -0.4, "hour": 13}, + {"buy": -0.5, "hour": 14}, + ] + acq = self._fallback_acq(slots, est_grid_wh=0.0, est_grid_cost=0.0) + self.assertEqual(acq, 0.0) + + def test_positive_weighted_mean_unchanged(self) -> None: + # est_grid_wh > 0 — vrátit weighted mean, ne fallback + acq = self._fallback_acq( + [{"buy": 0.7, "hour": 10}], + est_grid_wh=4000.0, + est_grid_cost=4000.0 * 0.7, + ) + self.assertAlmostEqual(acq, 0.7, places=4) + + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 9582d50..0df7645 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -1230,7 +1230,7 @@ class NegativeSellPvChargeTests(unittest.TestCase): 50.0, operating_mode="AUTO", ) - self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-self-consistent-grid-mask-v12") + self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-neg-sell-soc-reservation-v13") self.assertGreater( results[0].battery_setpoint_w, 5_500, @@ -1380,7 +1380,7 @@ class NegativeSellPvChargeTests(unittest.TestCase): 50.0, operating_mode="AUTO", ) - self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-self-consistent-grid-mask-v12") + self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-neg-sell-soc-reservation-v13") self.assertEqual(len(results), len(slots)) def test_gen_cutoff_full_soc_neg_sell_with_pv_b_feasible(self) -> None: @@ -1444,7 +1444,7 @@ class NegativeSellPvChargeTests(unittest.TestCase): 55.0, operating_mode="AUTO", ) - self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-self-consistent-grid-mask-v12") + self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-neg-sell-soc-reservation-v13") self.assertEqual(len(results), len(slots)) def test_fixed_tariff_neg_sell_no_grid_export(self) -> None: diff --git a/db/routines/R__063_fn_load_planning_slots_full.sql b/db/routines/R__063_fn_load_planning_slots_full.sql index 53d748b..9e84575 100644 --- a/db/routines/R__063_fn_load_planning_slots_full.sql +++ b/db/routines/R__063_fn_load_planning_slots_full.sql @@ -737,6 +737,26 @@ begin -- A) PV-surplus: jen zbytek kapacity po grid vrstvě B v_pv_layer_cap_wh := greatest(v_energy_to_fill - v_grid_filled_wh, 0); + + -- Rezervace SoC pro sell<0 okno: pokud v zápor. výkup. slotech máme + -- očekávaný PV přebytek X Wh (po efektivitě), snížíme PV vrstvu A o X. + -- Důsledek: do okna nedorazíme „plní" (98 % SoC), zbude prostor přijmout PV + -- z neg-sell slotů místo exportu do mínusu / curtail pole A. + -- Sample neg-sell PV sloty (sell<0 a buy<0, kde sell= buy − degrad), takže redukce je čistá. + declare + v_neg_window_pv_surplus_wh numeric := 0; + begin + select coalesce(sum(least(wk.pv_surplus_w::numeric, v_max_charge_w) * v_charge_eff * 0.25), 0) + into v_neg_window_pv_surplus_wh + from _ems_plan_slot_wk wk + where wk.sell_price < 0 + and wk.pv_surplus_w > 0; + if v_neg_window_pv_surplus_wh > 0 then + v_pv_layer_cap_wh := greatest(v_pv_layer_cap_wh - v_neg_window_pv_surplus_wh, 0); + end if; + end; + v_cum := 0; for r_slot in select wk.slot_ord, wk.pv_surplus_w @@ -999,11 +1019,16 @@ begin if v_est_grid_wh > 0 then v_charge_acquisition := v_est_grid_cost / v_est_grid_wh; elsif v_charge_acquisition is null then - v_charge_acquisition := coalesce( - (v_ref_buy_am_czk_kwh + v_ref_buy_pm_czk_kwh) / 2.0, - v_ref_buy_czk_kwh - ); + -- Fallback: nejnizsi positivni buy v horizontu (nikoli avg ref_buy_am/pm, + -- ktery muze byt < 0 kdyz PM zahrnuje zaporne OTE sloty 13-15h). + -- Cena akvizice baterie nikdy nesmi byt < 0 (jinak rozhazuje arbitraz + -- objective + two_pass divergence). + select coalesce(min(wk.buy_price), 0) + into v_charge_acquisition + from _ems_plan_slot_wk wk + where wk.buy_price >= 0; end if; + v_charge_acquisition := greatest(v_charge_acquisition, 0); -- v_charge_acquisition z min(grid) zůstane, pokud je jen jeden grid slot před exportem return query diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index 86de1be..440a700 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,23 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-05-27 (c) — rezervace SoC pro `sell<0` okno + fallback acquisition ≥ 0 (v13) + +**Problém (home-01 run 16614, tag v12):** Aktivní plán pro 2026-05-25: +- 10:30 SoC = 96,9 %, 10:45 SoC = 98,3 % (baterie plná z PV ráno) → odpoledne v `sell<0` slotech (13:00–14:45, sell až −1,08 Kč) **ge_pv export** + curtail pole A 5 kW. Ztráta 6+ Kč. +- `acquisition_pass1 = −0,035` (`R__063` fallback path: `(ref_buy_am + ref_buy_pm)/2`, ref_buy_pm < 0 protože PM zahrnuje 13:30–14:00 s buy ≈ −0,36 Kč) → `two_pass_converged = false`. + +**Oprava (tag `2026-05-27-neg-sell-soc-reservation-v13`):** + +- **`R__063` PV vrstva A — rezervace pro `sell<0` okno:** před iterátorem vrstvy A spočítat `v_neg_window_pv_surplus_wh = sum(min(pv_surplus_w, max_charge_w) * eff * 0.25) FILTER (sell<0, pv_surplus>0)`. Snížit `v_pv_layer_cap_wh` o tuto hodnotu (lower bound 0). Důsledek: před `sell<0` oknem se nabíjí jen `deficit − neg_window_pv_wh`; do okna doráží baterie nenaplněná a `sell<0` PV slot ji dorovná místo exportu / curtailu pole A. +- **`R__063` fallback acquisition:** když `v_est_grid_wh = 0` a `min(buy) FILTER (allow_grid_charge AND buy>=0)` je NULL, místo avg `ref_buy_am/pm` (může být záporný) použít `coalesce(min(buy) FILTER (buy>=0), 0)`. Navíc `v_charge_acquisition := greatest(v_charge_acquisition, 0)` jako pojistka — arbitrážní akviziční cena nesmí být < 0. + +**Ověření:** +- Replan home-01 (po redeploy R__063) → 10:45 SoC < 95 %, 13:00–14:45 SoC roste (PV charging), 13:30 `grid_setpoint` < 0 jen pole B (curtail pole A = 0), bilance: `cashflow_czk(13:00–15:00) > 0`. +- `acquisition_pass1_czk_kwh ≥ 0`, `two_pass_converged = true`. + +--- + ## 2026-05-27 (b) — acquisition: vyloučit záporný OTE buy z váženého průměru **Problém (home-01 run 16588):** `two_pass_converged=false`, `acquisition_pass1≈−0.035` (pass1 nabíjení v `buy<0` slotech), `pass2≈0.88`. Noční grid 4,8 Kč už v plánu není (maska B OK), ale two-pass a arbitrážní marže exportu baterie byly křivé.