From 649c9e9510ec84f21a68a1a18b257c1f604bbe1d Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Thu, 21 May 2026 15:17:09 +0200 Subject: [PATCH] uz me to nebavi --- backend/services/planning_engine.py | 30 +++++++--- backend/tests/test_planning_dispatch_milp.py | 60 ++++++++++--------- .../planning-arbitrage-accounting.md | 2 +- docs/04-modules/planning.md | 2 +- 4 files changed, 56 insertions(+), 38 deletions(-) diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 414d251..cf3ad67 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -1095,13 +1095,30 @@ def solve_dispatch( 0.0, float(s.pv_a_forecast_w) + float(s.pv_b_forecast_w) - load_t, ) - # Mezi-slotová arbitráž: ráno prodat FVE (sell > acquisition), odpoledne levně - # nabít ze sítě — necpát přebytek do baterie jen protože sell < buy ve stejném slotu. + # Mezi-slotová FVE arbitráž: export jen když (prodat teď − levný nákup později) + # ≥ (večerní špička − acquisition). Jinak drž PV v baterii na peak sell. + fso_t = float( + s.future_sell_opportunity_czk_kwh + if s.future_sell_opportunity_czk_kwh is not None + else sell_t + ) + future_chg_buys = [ + float(slots[ts].buy_price) + for ts in range(t + 1, T) + if ts in charge_slots + ] + min_future_chg_buy = ( + min(future_chg_buys) + if future_chg_buys + else charge_acquisition_czk_kwh + ) + export_refill_net = sell_t - min_future_chg_buy + store_peak_net = fso_t - charge_acquisition_czk_kwh cross_slot_pv_export = ( t not in charge_slots and pv_surplus_w > 0 - and sell_t >= charge_acquisition_czk_kwh + min_spread - and any(ts in charge_slots for ts in range(t + 1, T)) + and future_chg_buys + and export_refill_net >= store_peak_net + min_spread ) # Ztrátový export FVE (sell ≪ buy): zakázat jen pokud jde energii do baterie. # Výjimky: plná baterie (ventil), neriťitelné pv_b s přebytkem, cross-slot výše. @@ -1115,11 +1132,6 @@ def solve_dispatch( block_loss_pv_export = False if block_loss_pv_export: prob += ge_pv[t] == 0 - if cross_slot_pv_export: - prob += bc[t] == 0 - prob += ge_pv[t] >= min( - pv_surplus_w, float(grid.max_export_power_w) - ) # Drahý nákup oproti horizontu: import jen na load + EV + TČ, ne na grid-nabíjení. if buy_t >= 0 and buy_t > ref_buy_horizon + min_spread: prob += gi[t] <= load_t + ev_cap_t + hp_rated_w diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 8a8f684..0fedac2 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -1420,39 +1420,41 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): ) self.assertLess(evening.battery_setpoint_w, -500) - def test_morning_pv_export_when_sell_above_acquisition_and_later_grid_charge(self) -> None: - """Ráno sell>acquisition a sell None: + """Odpolední sell ~1,4 a večer ~5,5 — PV do baterie, ne FVE→síť za haléř.""" + base = datetime(2026, 5, 21, 12, 0, tzinfo=timezone.utc) + afternoon = PlanningSlot( interval_start=base, - buy_price=5.2, - sell_price=3.4, - pv_a_forecast_w=2000, + buy_price=4.5, + sell_price=1.4, + pv_a_forecast_w=8000, pv_b_forecast_w=0, - load_baseline_w=600, + load_baseline_w=2500, ev1_connected=False, ev2_connected=False, allow_charge=False, allow_discharge_export=False, - charge_acquisition_buy_czk_kwh=0.55, + charge_acquisition_buy_czk_kwh=0.82, + future_sell_opportunity_czk_kwh=5.5, ) cheap = PlanningSlot( - interval_start=base + timedelta(minutes=15), - buy_price=0.55, + interval_start=base + timedelta(hours=20), + buy_price=0.5, sell_price=-0.2, - pv_a_forecast_w=5000, + pv_a_forecast_w=0, pv_b_forecast_w=0, - load_baseline_w=1800, + load_baseline_w=2000, ev1_connected=False, ev2_connected=False, allow_charge=True, allow_discharge_export=False, - charge_acquisition_buy_czk_kwh=0.55, + charge_acquisition_buy_czk_kwh=0.82, + future_sell_opportunity_czk_kwh=5.5, ) - evening = PlanningSlot( - interval_start=base + timedelta(minutes=30), + peak = PlanningSlot( + interval_start=base + timedelta(hours=7), buy_price=7.0, - sell_price=5.0, + sell_price=5.5, pv_a_forecast_w=0, pv_b_forecast_w=0, load_baseline_w=2500, @@ -1460,9 +1462,10 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): ev2_connected=False, allow_charge=False, allow_discharge_export=True, - charge_acquisition_buy_czk_kwh=0.55, + charge_acquisition_buy_czk_kwh=0.82, + future_sell_opportunity_czk_kwh=5.5, ) - slots = [morning, cheap, evening] + slots = [afternoon, peak, cheap] battery = _battery(uc_wh=64_000.0, min_pct=12.0, arb_pct=20.0) battery.max_charge_power_w = 18_000 battery.max_discharge_power_w = 18_000 @@ -1472,7 +1475,7 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): 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), ] - soc0 = 0.25 * battery.usable_capacity_wh + soc0 = 0.5 * battery.usable_capacity_wh results, _ms, _ = solve_dispatch( slots, battery, @@ -1484,14 +1487,17 @@ class ChargeAcquisitionArbitrageTests(unittest.TestCase): 50.0, operating_mode="AUTO", ) - am = results[0] - self.assertLess( - am.grid_setpoint_w, - -100, - "morning PV surplus should export when later cheap grid charge exists", + pm = results[0] + self.assertGreaterEqual( + pm.grid_setpoint_w, + -50, + "low sell with high evening peak: keep PV for battery, not grid dump", + ) + self.assertGreater( + pm.battery_setpoint_w, + 500, + "PV surplus should charge battery ahead of evening export", ) - self.assertIn(am.export_mode, ("PV_SURPLUS", "BATTERY_SELL")) - self.assertLessEqual(am.battery_setpoint_w, 100) if __name__ == "__main__": diff --git a/docs/04-modules/planning-arbitrage-accounting.md b/docs/04-modules/planning-arbitrage-accounting.md index 280b17a..a0ecd20 100644 --- a/docs/04-modules/planning-arbitrage-accounting.md +++ b/docs/04-modules/planning-arbitrage-accounting.md @@ -49,7 +49,7 @@ Energetická bilance je také **per slot** (15 min). Když solver v evening slot Tvrdé zákazy typu `ge_pv = 0` když `sell[t] < buy[t]` brání **ztrátovému** exportu FVE v tomže slotu (prodat za 3 Kč při VT nákupu 5 Kč). -**Výjimka (AUTO, od 2026-05):** pokud je v budoucnu slot s `allow_charge` (levné grid nabíjení) a `sell[t] ≥ charge_acquisition + degrad`, solver **vyžaduje export PV přebytku** (`ge_pv`, `bc=0`) — typicky ranní prodej FVE nad ~3 Kč/kWh a NT nabíjení odpoledne. Implementace: `solve_dispatch()` v `planning_engine.py`. +**Výjimka (AUTO, od 2026-05):** pokud je v budoucnu `allow_charge` (levný nákup), solver **povolí** FVE export i při `sell[t] < buy[t]`, ale **jen když** `(sell[t] − min_buy_charge) ≥ (future_sell_opportunity − charge_acquisition) + degrad` — tj. prodat teď a později levně dobít překoná uložení PV na večerní špičku. Při odpoledním sell ~1,4 Kč a večer ~5,5 Kč **export se nevnucuje** (energie do baterie). Implementace: `solve_dispatch()` v `planning_engine.py`. Pro **baterii** stejný test v **exportním** slotu **nesmí** být jediná logika arbitráže — večer téměř vždy `sell[t] < buy[t]` (VT/NT vs výkupní marže), přesto má smysl **vybíjet do sítě** energii nabitou v levném okně. diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 576fe18..e2e8254 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -12,7 +12,7 @@ - **Masky `allow_charge` / `allow_discharge_export` (tenký anti-mikrocyklus):** generuje `ems.fn_load_planning_slots_full` (`R__063`). Ekonomiku primárně řídí LP podle efektivních cen; masky jen omezují počet slotů pro grid nabíjení / export baterie. - **PV-surplus (vrstva A):** ranking dle **`store_score DESC`** = `future_sell_opportunity − sell − max(0, buy−sell)`; jen sloty s `sell ≥ buy − degradation`. Kumulativní PV pokrývá `grid_target` (deficit SoC, nad `reserve_soc` bez násobení `charge_slot_buffer`). Zbytek → `allow_charge=false` (PV jen do sítě / `bc ≤ pv_surplus` v LP). - **Grid ze sítě (vrstva B, před FVE):** spot, **AM/PM rozpočet Wh 50/50** z `grid_target`. Výběr: nejdřív **kalendářní den plánu** (`p_from` Prague), pak sloty před **výkupním oknem daného dne** (`sell > min(buy téhož dne)+degrad` — ne globální min zítra). Lookahead VT→NT jen před tímto oknem. **`charge_acquisition`:** vážený `buy` jen u `allow_grid_charge` (ne FVE vrstva A). - - **LP (AUTO):** pokud `sell ≥ charge_acquisition + degrad` a později existuje `allow_charge`, přebytek FVE jde **do sítě** (`ge_pv`), ne do baterie — i když ve stejném slotu `sell < buy` (ranní VT výkup vs odpolední NT nákup). Viz [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md) §2.2. + - **LP (AUTO):** FVE export při `sell < buy` jen pokud `(sell − min_buy_v_charge) ≥ (future_sell − acquisition) + degrad` — jinak PV do baterie na večerní peak. Viz [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md) §2.2. - **`ref_buy_min` (brána exportu):** `min(buy_price)` horizontu — jen „existuje levný nákup?“, **ne** průměrná cena nabití přes hodiny. Export sloty: `sell > ref_buy_min + degradation` (spot). Viz [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md). - Pokud `energy_to_fill <= 0` nebo `charge_slot_buffer = 0`: všechny sloty povoleny. - **LP ekonomické guardy** (`solve_dispatch`, AUTO): pokud `sell < buy − degradation` → `ge_pv=0` (výjimka: plná baterie, přebytek **pv_b**). Pokud `buy > min(buy)+degradation` → `gi` jen na load+EV+TČ. Viz `planning_engine.py` sekce po slot pre-selection.