From 9d31b19ec6c5dd015fa9fbc65576b194f4e59532 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Sun, 24 May 2026 16:36:30 +0200 Subject: [PATCH] ladime --- backend/services/planning_engine.py | 16 ++++- backend/tests/test_planning_dispatch_milp.py | 66 +++++++++++++++++++- docs/planning-changelog.md | 15 +++++ 3 files changed, 91 insertions(+), 6 deletions(-) diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 6e47a16..a0a92c9 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -59,7 +59,7 @@ NEG_SELL_CURTAIL_PENALTY_CZK_KWH = 1.0 NEG_SELL_PV_CHARGE_REWARD_CZK_KWH = 0.8 # Měkký tlak: v okně sell<0 dobít na soc_max (ne zastavit na ~94 % kvůli curtail). NEG_SELL_SOC_UNDERFILL_PENALTY_CZK_PER_WH = 0.35 -PLANNER_BUILD_TAG = "2026-05-24-ba81-soc-headroom-v7" +PLANNER_BUILD_TAG = "2026-05-25-neg-sell-no-export-fixed-v8" 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 @@ -1641,6 +1641,11 @@ def solve_dispatch( prob += ge[t] == 0 prob += ge_pv[t] == 0 prob += ge_bat[t] == 0 + elif fixed_tariff_like_pre: + # BA81: při sell<0 neexportovat (záporná cena výkupu = platíš za export). + # Přebytek FVE → baterie / curtail A; B přes z_gen_cutoff nebo bc_pv. + prob += ge[t] == 0 + prob += ge_pv[t] == 0 soc_prev_expr = current_soc_wh if t == 0 else soc[t - 1] arb_t = arb_floor_series[t] @@ -1829,13 +1834,18 @@ def solve_dispatch( and not getattr(grid, "block_export_on_negative_sell", False) and sell_t < 0 and not fixed_tariff_like_pre + ) or ( + # KV1: plná baterie + kladný sell — neblokovat ge_pv==0 (jinak masivní curtail). + getattr(grid, "block_export_on_negative_sell", False) + and sell_t >= 0 + and pv_surplus_w > 500 ) - # BA81 (fixní tarif + pole B): ge_pv==0 z pv_store by znemožnilo odvést nelimitovatelný - # výkon B (plná baterie po sell<0). Místo toho jen strop na pv_b. + # BA81: export pole B jen při kladném sell (po sell<0 jinak ge==0 výše). fixed_pv_b_export_cap = ( fixed_tariff_like_pre and float(s.pv_b_forecast_w) > 0 and not getattr(grid, "block_export_on_negative_sell", False) + and sell_t >= 0 ) if fixed_pv_b_export_cap: if z_gen_cutoff is not None: diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 4853b58..2c7a91c 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -1222,7 +1222,7 @@ class NegativeSellPvChargeTests(unittest.TestCase): 50.0, operating_mode="AUTO", ) - self.assertEqual(snap.get("planner_build_tag"), "2026-05-24-ba81-soc-headroom-v7") + self.assertEqual(snap.get("planner_build_tag"), "2026-05-25-neg-sell-no-export-fixed-v8") self.assertGreater( results[0].battery_setpoint_w, 5_500, @@ -1366,7 +1366,7 @@ class NegativeSellPvChargeTests(unittest.TestCase): 50.0, operating_mode="AUTO", ) - self.assertEqual(snap.get("planner_build_tag"), "2026-05-24-ba81-soc-headroom-v7") + self.assertEqual(snap.get("planner_build_tag"), "2026-05-25-neg-sell-no-export-fixed-v8") self.assertEqual(len(results), len(slots)) def test_gen_cutoff_full_soc_neg_sell_with_pv_b_feasible(self) -> None: @@ -1430,9 +1430,69 @@ class NegativeSellPvChargeTests(unittest.TestCase): 55.0, operating_mode="AUTO", ) - self.assertEqual(snap.get("planner_build_tag"), "2026-05-24-ba81-soc-headroom-v7") + self.assertEqual(snap.get("planner_build_tag"), "2026-05-25-neg-sell-no-export-fixed-v8") self.assertEqual(len(results), len(slots)) + def test_fixed_tariff_neg_sell_no_grid_export(self) -> None: + """BA81: sell<0 nesmí vést do sítě (záporná výkupní cena) — jen nabíjení/curtail.""" + slots = [ + PlanningSlot( + interval_start=datetime(2026, 5, 25, 7, 0, tzinfo=timezone.utc) + + timedelta(minutes=15 * i), + buy_price=3.088, + sell_price=-0.5, + pv_a_forecast_w=10_000, + pv_b_forecast_w=2_500, + load_baseline_w=300, + ev1_connected=False, + ev2_connected=False, + allow_charge=True, + allow_discharge_export=False, + charge_acquisition_buy_czk_kwh=3.61, + ) + for i in range(4) + ] + battery = _battery(uc_wh=12_500.0, terminal_soc_value_factor=0.0) + battery.soc_max_wh = 12_500.0 + battery.max_charge_power_w = 6_250 + grid = SimpleNamespace( + max_import_power_w=17_000, + max_export_power_w=16_000, + block_export_on_negative_sell=False, + deye_gen_microinverter_cutoff_enabled=True, + ) + hp = SimpleNamespace( + rated_heating_power_w=0, + tuv_min_temp_c=45.0, + tuv_target_temp_c=55.0, + ) + 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, + ), + ] + results, _ms, _ = solve_dispatch( + slots, + battery, + hp, + grid, + [None, None], + vehicles, + 8_000.0, + 50.0, + operating_mode="AUTO", + ) + for r in results: + self.assertGreaterEqual(r.battery_setpoint_w, 0, "neg sell má nabíjet") + self.assertGreaterEqual(r.grid_setpoint_w, 0, "neg sell bez exportu do sítě") + class AutoPvSurplusExportTests(unittest.TestCase): """Plná baterie + vysoká FVE: export přebytku (ge_pv), ne curtailment, bez SELL.""" diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index d5a6afc..8d62c54 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,21 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-05-25 (l) — Plán 25. 5.: BA81 neg. výkup bez exportu, KV1 ranní curtail + +**Problém (MCP plán run 16346–16350, tag v7):** KV1 06–08 h masivní **curtail** FVE (plná baterie, `ge_pv=0` z pv_store). BA81 při `sell<0` **export ~10 kW** místo nabíjení. Večer slabý export u KV1/home-01 (spot: `sell < buy`). + +**Oprava (tag `2026-05-25-neg-sell-no-export-fixed-v8`):** + +- Fixní tarif (BA81): při **`sell < 0`** tvrdě **`ge = 0`** (jako KV1 s block_export) — přebytek jen baterie/curtail. +- **`fixed_pv_b_export_cap`** jen při **`sell ≥ 0`** (po neg. okně export B). +- KV1: **`skip_pv_store_block`** při kladném `sell` + PV přebytek — méně curtailu před neg. oknem. + +**Deploy:** služba v compose je **`backend`**, ne `ems-api`. Ověření: +`docker compose -f /opt/ems-deploy/docker-compose.yml exec backend grep PLANNER_BUILD_TAG /app/services/planning_engine.py` + +--- + ## 2026-05-24 (k) — BA81: Infeasible při SoC = 100 % (telemetrie = soc_max) **Problém:** Po v6 stále `Solver: Infeasible` při replanu, když `fn_planning_site_context` vrátí `soc_wh = soc_max_wh` (12 500).