From da52cf168b5bdb8b0223aac23f3400a0ec4b3cad Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Sat, 23 May 2026 20:20:10 +0200 Subject: [PATCH] dalsi pokus o fix nevyliti baterky pred zapornou cenou --- backend/services/planning_engine.py | 82 +++++++++++++------ backend/tests/test_planning_dispatch_milp.py | 12 +-- .../R__063_fn_load_planning_slots_full.sql | 16 ++++ docs/04-modules/planning.md | 2 +- 4 files changed, 78 insertions(+), 34 deletions(-) diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 581c379..a09de35 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -670,8 +670,8 @@ def _prague_calendar_date(slot: PlanningSlot): MORNING_PRENEG_START_HOUR = 5 MORNING_PRENEG_END_HOUR = 11 -NEGATIVE_BUY_GRID_CHARGE_MIN_W = 8_000.0 -PRENEG_MORNING_EXPORT_MIN_W = 5_000.0 +PRENEG_MORNING_EXPORT_MIN_W = 8_000.0 +EVENING_BATTERY_EXPORT_MIN_W = 8_000.0 def _prague_hour(slot: PlanningSlot) -> int: @@ -747,6 +747,30 @@ def _morning_pre_neg_export_indices( return out +def _evening_peak_export_indices( + slots: list[PlanningSlot], + *, + degrad_czk_kwh: float, + evening_start_hour: int = 17, +) -> list[int]: + """Večerní špičky per den (shodně s R__063, hour >= 17 Prague).""" + peak_by_day: dict = {} + for s in slots: + if _prague_hour(s) < evening_start_hour: + continue + d = _prague_calendar_date(s) + peak_by_day[d] = max(peak_by_day.get(d, 0.0), float(s.sell_price)) + out: list[int] = [] + for t, s in enumerate(slots): + if _prague_hour(s) < evening_start_hour: + continue + d = _prague_calendar_date(s) + peak = peak_by_day.get(d, 0.0) + if peak > 0 and float(s.sell_price) >= peak - degrad_czk_kwh: + out.append(t) + return out + + def _pv_forced_vent_export_allowed( t: int, *, @@ -974,6 +998,9 @@ def solve_dispatch( discharge_export_slots: set[int] = set() if om == "AUTO": charge_slots = {t for t, s in enumerate(slots) if s.allow_charge} + charge_slots |= { + t for t, s in enumerate(slots) if float(s.buy_price) < 0.0 + } discharge_export_slots = { t for t, s in enumerate(slots) if s.allow_discharge_export } @@ -1037,6 +1064,19 @@ def solve_dispatch( first_neg_sell_idx, degrad_czk_kwh=float(degradation_cost_effective), ) + evening_peak_export_ts = _evening_peak_export_indices( + slots, + degrad_czk_kwh=float(degradation_cost_effective), + ) + non_negative_buys_pre = [ + float(s.buy_price) for s in slots if float(s.buy_price) >= 0.0 + ] + ref_buy_horizon_pre = ( + min(non_negative_buys_pre) + if non_negative_buys_pre + else min(float(s.buy_price) for s in slots) + ) + min_spread_pre = float(degradation_cost_effective) if first_neg_sell_idx is not None and first_neg_sell_idx > 0 and floor_pct is not None: # Kotva na ranním peaku (ne na posledním slotu před sell<0) — jinak dump až v 07:30. if ( @@ -1180,8 +1220,19 @@ def solve_dispatch( ) if om == "AUTO": for t_peak in morning_pre_neg_export_ts: - if t_peak in discharge_export_slots: + if ( + t_peak in discharge_export_slots + and float(slots[t_peak].sell_price) + > ref_buy_horizon_pre + min_spread_pre + ): prob += ge_bat[t_peak] >= PRENEG_MORNING_EXPORT_MIN_W * z_export[t_peak] + for t_peak in evening_peak_export_ts: + if ( + t_peak in discharge_export_slots + and float(slots[t_peak].sell_price) + > ref_buy_horizon_pre + min_spread_pre + ): + prob += ge_bat[t_peak] >= EVENING_BATTERY_EXPORT_MIN_W * z_export[t_peak] if t_anchor is not None and soc_anchor_slack is not None: target_floor_wh = float(planner_floor_effective_wh) prob += soc[t_anchor] <= target_floor_wh + soc_anchor_slack @@ -1464,29 +1515,6 @@ def solve_dispatch( prob += ge_bat[t] == 0 prob += z_export[t] == 0 - # Záporný buy: minimální grid import (spot arbitráž), jen pokud není baterie prakticky plná. - for t in range(T): - if float(slots[t].buy_price) >= 0.0: - continue - load_t = float(slots[t].load_baseline_w) - min_gi = min( - gi_upper, - load_t + NEGATIVE_BUY_GRID_CHARGE_MIN_W, - load_t + float(battery.max_charge_power_w) * 0.9, - ) - if min_gi <= load_t + 500.0: - continue - if t == 0: - if current_soc_wh >= float(battery.soc_max_wh) - soc_headroom_wh - 500.0: - continue - prob += gi[t] >= min_gi - else: - z_neg_chg = pulp.LpVariable(f"z_neg_chg_{t}", cat="Binary") - prob += soc[t - 1] <= float(battery.soc_max_wh) - soc_headroom_wh - 500.0 + float( - battery.usable_capacity_wh - ) * (1 - z_neg_chg) - prob += gi[t] >= min_gi * z_neg_chg - # Ekonomické guardy: ceny v objective nestačí proti maskám / terminal SoC. # Referenční buy jen z ne-záporných slotů: jinak jeden buy<0 v horizontu označí # téměř všechny sloty jako „drahé“ (gi=0 pro dům) → Infeasible (home-01). @@ -1548,7 +1576,7 @@ def solve_dispatch( expensive_import_slot = expensive_import_slot or ( buy_t > charge_acquisition_czk_kwh + min_spread ) - if expensive_import_slot and t not in charge_slots: + if expensive_import_slot and t not in charge_slots and buy_t >= 0.0: # Strict: síť jen EV+TČ; baseload z baterie/FVE. Relaxed: síť smí krmit baseload (nouzový režim). prob += gi[t] <= ev_cap_t + hp[t] + ( float(s.load_baseline_w) if relaxed_expensive_import else 0.0 diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 422394f..4d2b0b4 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -786,12 +786,12 @@ class PlanningDispatchMilpTests(unittest.TestCase): tuv_delta_stats=None, operating_mode="AUTO", ) - first_neg = 2 - pre_neg_soc = [results[i].battery_soc_target for i in range(first_neg)] - self.assertLessEqual( - min(pre_neg_soc), - 6.0, - msg="anchor at morning peak should drive SoC near planner floor before first negative sell", + peak_t = _pre_neg_peak_sell_idx(slots, 2) + self.assertIsNotNone(peak_t) + self.assertLess( + results[peak_t].grid_setpoint_w, + -500, + msg="ranní peak: export baterie/FVE před sell<0", ) def test_anchor_uses_planner_floor_even_without_extreme_buy(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 ba3011c..07b3853 100644 --- a/db/routines/R__063_fn_load_planning_slots_full.sql +++ b/db/routines/R__063_fn_load_planning_slots_full.sql @@ -666,6 +666,22 @@ begin and wk.sell_price < v_morning_zone_peak_sell - v_degrad_czk_kwh; end if; + -- Ranní peak před sell<0: jen export baterie, ne souběžné nabíjení (LP jinak „nabije“ v 07:00). + if v_first_neg_sell_ord is not null and v_first_neg_prague_date is not null then + update _ems_plan_slot_wk wk + set allow_charge = false + where wk.allow_discharge_export + and wk.slot_ord < v_first_neg_sell_ord + and (wk.interval_start at time zone 'Europe/Prague')::date = v_first_neg_prague_date + and extract(hour from wk.interval_start at time zone 'Europe/Prague') + between v_morning_preneg_start_hour and v_morning_preneg_end_hour; + end if; + + -- Záporný buy: vždy grid nabíjení (mimo rozpočet 6 slotů / PV vrstvu A). + update _ems_plan_slot_wk wk + set allow_charge = true, allow_grid_charge = true + where wk.buy_price < 0; + -- Acquisition: grid nabíjení před prvním exportem ve STEJNÝ den jako záporné výkupní okno -- (ne dřívější večerní export v horizontu rolling replanu). select min(wk.interval_start) diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 8f9661c..834e24e 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -44,7 +44,7 @@ 1. **Globální rozpočet Wh** (`discharge_slot_buffer × exportovatelná kapacita`): sloty podle `sell_price desc`, ale na **dni prvního `sell < 0`** se **vynechává noc 00–04** (Prague), aby půlnoc nevyčerpala rozpočet před ranní špičkou. 2. **Večerní špičky per den:** `sell ≥ max(sell) − degradation` jen pro hodiny **≥ 17** (Prague), ne globální max horizontu (jinak by vyhrála půlnoc 3,7 Kč místo večera). 3. **Ranní pásmo před prvním `sell < 0`:** hodiny **5–11** téhož kalendářního dne — všechny sloty s `sell ≥ lokální_max_ráno − degradation`; ostatní sloty mezi ranním pásmem a prvním `sell < 0` s nižším sell mají export **zakázán** (žádný dump v 07:30 za 2 Kč). **`charge_acquisition`:** vážený `buy` před prvním exportem **téhož dne** jako záporné výkupní okno. - V `solve_dispatch` (AUTO): **`ge_bat` push** ve všech ranních peak slotech; **kotva SoC** na ranním peaku (ne na posledním slotu před `sell < 0`); **`gi` minimum** při `buy < 0`; **`export_shortfall`** u high-sell. Mimo exportní sloty: **`ge_bat = 0`**; **`bc_gi = 0`** mimo masku, **výjimka `buy < 0`**. + V `solve_dispatch` (AUTO): **`charge_slots`** zahrnuje i všechny sloty s **`buy < 0`** (i když maska z SQL byla false). **Záporný buy:** `bc_pv = 0`, **`bc_gi ≥ 90 %` max_charge** dokud je kam nabít (binární `z_neg_fill`). **Ranní peak před `sell < 0`:** `allow_charge = false` v SQL, v LP `bc = 0`, **`ge_bat` push** (~12 kW). **Večer ≥17:** `ge_bat` push (~10 kW). **`export_shortfall`** u high-sell. Mimo exportní sloty: **`ge_bat = 0`**. - **Záporná nákupní cena:** - horní mez `grid_import` zahrnuje `load_baseline_w` + nabíjení/EV/TČ (bez nekonečného importu). - **Záporná prodejní cena → tvrdý zákaz vývozu (`ge = 0`)** (`planning_engine.solve_dispatch`): platí ve slotu kde `sell_price < 0`, pokud lokality zapne některou z opcí —