From 19108002ca01e75a5fffb5a1a012c101343d095e Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Tue, 26 May 2026 14:57:52 +0200 Subject: [PATCH] oprava KV 1 --- backend/services/planning_engine.py | 36 ++++++++++-- backend/tests/test_planning_dispatch_milp.py | 57 ++++++++++++++++++- .../R__063_fn_load_planning_slots_full.sql | 19 +++++-- docs/planning-changelog.md | 10 ++++ 4 files changed, 110 insertions(+), 12 deletions(-) diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 03961ae..08bea9d 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-28-neg-prep-window-v36d" +PLANNER_BUILD_TAG = "2026-05-28-neg-prep-window-v36e" # Po t_detach v prep: necpát PV do bat (měkké; tvrdý hold přes soc_target z rampy). NEG_SELL_POST_DETACH_BCPV_DISCOURAGE_CZK_KWH = 250.0 # Večer před neg dnem: výboj do sítě (měkký shortfall na ge_bat). @@ -2047,6 +2047,20 @@ def solve_dispatch( slots, degrad_czk_kwh=float(degradation_cost_effective), ) + purchase_fixed_pre = _purchase_pricing_fixed(grid) + block_export_neg_sell_pre = bool( + getattr(grid, "block_export_on_negative_sell", False) + ) + if purchase_fixed_pre and block_export_neg_sell_pre: + evening_peak_export_ts = sorted( + set(evening_peak_export_ts) + | { + t + for t, st in enumerate(slots) + if _in_night_battery_export_window(st) + and float(st.sell_price) > 0.0 + } + ) non_negative_buys_pre = [ float(s.buy_price) for s in slots if float(s.buy_price) >= 0.0 ] @@ -2056,7 +2070,6 @@ def solve_dispatch( else min(float(s.buy_price) for s in slots) ) min_spread_pre = float(degradation_cost_effective) - purchase_fixed_pre = _purchase_pricing_fixed(grid) fixed_tariff_like_pre = purchase_fixed_pre or _horizon_fixed_tariff_like(slots) neg_sell_phases_en = ( om == "AUTO" @@ -2153,6 +2166,14 @@ def solve_dispatch( fixed_tariff=fixed_tariff_like_pre, ): profitable_export_ts_pre.add(_t) + elif ( + purchase_fixed_pre + and block_export_neg_sell_pre + and _t in evening_peak_export_ts + and float(slots[_t].sell_price) > 0.0 + ): + # KV1: večerní sell může být < fixní buy; peak sloty stejně vývoz bat. + profitable_export_ts_pre.add(_t) evening_push_ts: set[int] = set() evening_early_export_penalty_ts: set[int] = set() if om == "AUTO": @@ -3287,11 +3308,18 @@ def solve_dispatch( or t < first_neg_buy_idx ) ) or ( - # Spot (home-01, KV1): při sell>=0 neblokovat ge_pv — solver export vs bc_pv; - # šetření na večerní peak = ge_bat, ne curtail FVE (pv_store jen sell<0 / fixed). + # Spot: při sell>=0 neblokovat ge_pv (export vs bc_pv; večerní peak = ge_bat). not purchase_fixed_pre and sell_t >= 0 and pv_surplus_w > 500 + ) or ( + # KV1 (fixed + block_export, jen PV A): bez pole B neplatí fixed_pv_b_export_cap; + # jinak ge_pv==0 → plný curtail při plné baterii místo prodeje do site. + purchase_fixed_pre + and bool(getattr(grid, "block_export_on_negative_sell", False)) + and float(s.pv_b_forecast_w) <= 0.0 + and sell_t >= 0.0 + and pv_surplus_w > 500 ) # BA81: export pole B jen při kladném sell (po sell<0 jinak ge==0 výše). fixed_pv_b_export_cap = ( diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 9a7a3f7..00747d7 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -3190,20 +3190,73 @@ class PreNegativeSellExportTests(unittest.TestCase): 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), ] soc0 = 0.85 * battery.soc_max_wh - results, _, _ = solve_dispatch( + results, _, snap = solve_dispatch( slots, battery, hp, grid, [None, None], vehicles, soc0, 50.0, operating_mode="AUTO" ) self.assertLess(results[0].grid_setpoint_w, -500, "ráno: přebytek FVE do sítě před sell<0") + self.assertLess(results[0].pv_a_curtailed_w, 500, "fixed KV1: ne plný curtail při kladném sell") neg = results[8] self.assertGreater(neg.battery_setpoint_w, 500, "záporný sell: PV do baterie") self.assertEqual(neg.export_mode, "NONE") + def test_kv1_evening_battery_push_when_sell_below_fixed_buy(self) -> None: + """KV1: večerní sell < fixní buy — přesto vývoz bat (ne jen jeden peak slot).""" + prague = ZoneInfo("Europe/Prague") + base = datetime(2026, 5, 26, 17, 0, tzinfo=prague) + sells = [1.9, 3.0, 3.7, 2.0, 2.8, 3.3, 4.0, 2.9, 3.5, 4.4, 6.57, 5.4, 5.5, 5.1, 5.2, 4.3] + slots: list[PlanningSlot] = [] + for i, sell in enumerate(sells): + slots.append( + PlanningSlot( + interval_start=(base + timedelta(minutes=15 * i)).astimezone(timezone.utc), + buy_price=6.35, + sell_price=sell, + pv_a_forecast_w=800 if sell < 4 else 200, + pv_b_forecast_w=0, + load_baseline_w=400, + ev1_connected=False, + ev2_connected=False, + allow_charge=False, + allow_discharge_export=sell > 0, + charge_acquisition_buy_czk_kwh=6.35, + future_sell_opportunity_czk_kwh=6.57, + ) + ) + battery = _battery(uc_wh=12_500.0, min_pct=10.0, arb_pct=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, _, _ = solve_dispatch( + slots, + battery, + hp, + grid, + [None, None], + vehicles, + 0.95 * battery.soc_max_wh, + 50.0, + operating_mode="AUTO", + ) + self.assertLess(res[10].grid_setpoint_w, -500, "20:15 sell=0 → LP volí export vs bc (ne tvrdý curtail).""" @@ -4195,7 +4248,7 @@ class NegSellPrepWindowV36Tests(unittest.TestCase): 50.0, operating_mode="AUTO", ) - self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-neg-prep-window-v36d") + self.assertEqual(snap.get("planner_build_tag"), "2026-05-28-neg-prep-window-v36e") anchors = snap["inputs"].get("neg_evening_reserve_soc_anchors") or [] self.assertGreaterEqual(len(anchors), 1) anchor_iso = anchors[-1]["slot"] 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 9e84575..a4c2f7b 100644 --- a/db/routines/R__063_fn_load_planning_slots_full.sql +++ b/db/routines/R__063_fn_load_planning_slots_full.sql @@ -71,6 +71,7 @@ declare v_ref_buy_am_czk_kwh numeric; v_ref_buy_pm_czk_kwh numeric; v_purchase_pricing_mode text; + v_block_export_neg_sell boolean; v_lookahead_slots int := 4; v_grid_charge_cap_am int; v_grid_charge_cap_pm int; @@ -281,6 +282,13 @@ begin v_purchase_pricing_mode := coalesce(v_purchase_pricing_mode, 'spot'); + select coalesce(sgc.block_export_on_negative_sell, false) + into v_block_export_neg_sell + from ems.site_grid_connection sgc + where sgc.site_id = p_site_id; + + v_block_export_neg_sell := coalesce(v_block_export_neg_sell, false); + v_per_slot_charge_wh := v_max_charge_w * v_charge_eff * 0.25; v_per_slot_discharge_wh := v_max_discharge_w * v_discharge_eff * 0.25; v_energy_to_fill := v_soc_max_wh - p_current_soc_wh; @@ -853,15 +861,14 @@ begin where (wk.interval_start at time zone 'Europe/Prague')::date = r_slot.plan_date and extract(hour from wk.interval_start at time zone 'Europe/Prague') >= v_evening_peak_start_hour - and wk.sell_price >= r_slot.evening_peak_sell - v_degrad_czk_kwh and ( case - when v_purchase_pricing_mode = 'fixed' then - -- Večerní peak: vyvést i když sell < fixní buy (KV1), pokud je to denní maximum výkupu. - true + when v_purchase_pricing_mode = 'fixed' + and v_block_export_neg_sell then + -- KV1: fixní buy ~6,3; večerní sell často < buy — vývoz ve všech kladných sell slotech ≥17h. + wk.sell_price > 0 else - -- Spot (home-01): denní večerní maximum výkupu; sell často < buy v tomže slotu. - true + wk.sell_price >= r_slot.evening_peak_sell - v_degrad_czk_kwh end ); end if; diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index 34c6cdc..ba63643 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -11,6 +11,16 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen **Rozhodnutí home-01** (souhrn v [`docs/06-open-questions.md`](06-open-questions.md)): rampa/**T** odvozené z PV B (bez fixních 80 % v LP); TČ ne v pre-neg exportu; bazén min 4 h/den + Shelly; spirála Loxone; **workshop UI flex zátěží před v37** (§ 9.1 strategie). +## 2026-05-28 — KV1 fixed + block_export (v36e) + +**Kód:** `planning_engine.py` tag `2026-05-28-neg-prep-window-v36e`; `R__063_fn_load_planning_slots_full.sql`. + +**Problém:** KV1 (fixní buy ~6,35, jen PV A, `block_export_on_negative_sell`) — od v34/v36 logiky pro spot/home-01: ráno **curtail** místo exportu do site; večer jen **jeden** discharge slot (sell peak 6,57 vs buy 6,35). BA81 má pole B (`fixed_pv_b_export_cap`) a nižší buy → chová se správně. + +**Změna:** `skip_pv_store_block` pro fixed+block_export bez PV B při `sell≥0`; večerní `evening_peak_export_ts` + profitable export pro všechny kladné sell sloty v nočním okně; SQL maska `allow_discharge_export` stejně pro KV1 večer. + +**Ověření:** `PreNegativeSellExportTests` (s `purchase_pricing_mode=fixed`); po deployi KV1 plán: odpoledne `PV_SURPLUS` / export, večer více `BATTERY_SELL` slotů. + ## 2026-05-28 — Přípravné okno neg dne (v36 / v36b / v36d) **Kód:** `backend/services/planning_engine.py` — tag `2026-05-28-neg-prep-window-v36d`.