diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 1ad5041..4b0e426 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -57,7 +57,9 @@ PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 120.0 NEG_SELL_CURTAIL_PENALTY_CZK_KWH = 1.0 # Odměna v objective za FVE→baterie při sell<0 (doplňuje shortfall; BA81 fixed tarif). NEG_SELL_PV_CHARGE_REWARD_CZK_KWH = 0.8 -PLANNER_BUILD_TAG = "2026-05-24-neg-sell-v3" +# 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-evening-export-v4" 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 @@ -649,11 +651,17 @@ def _slot_profitable_battery_export( fixed_tariff: bool, ) -> bool: """ - Export z baterie do sítě má kladnou marži vs. cena zásoby (acquisition). - U fixed tarifu nepoužívat buy v slotu (může být predikovaný OTE jiný den) — jen acquisition. + Export z baterie do sítě má kladnou marži. + Spot: sell > charge_acquisition + spread (energie ze sítě / vážený nákup). + Fixní tarif (BA81/KV1): stejně jako R__063 discharge maska — sell > buy + spread; + acquisition může být nafouknutá grid nabíjením a blokovat večerní špičku (3,7 < 3,9). """ sell_t = float(slot.sell_price) acq = float(charge_acquisition_czk_kwh) + if fixed_tariff: + buy_t = float(slot.buy_price) + if buy_t >= 0.0: + return sell_t > buy_t + min_spread return sell_t > acq + min_spread @@ -1130,6 +1138,9 @@ def solve_dispatch( fixed_tariff=fixed_tariff_like_pre, ): profitable_export_ts_pre.add(_t) + elif slots[_t].allow_discharge_export: + # SQL maska (R__063) už vybrala slot — neblokovat push/shortfall kvůli acq. + profitable_export_ts_pre.add(_t) 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 ( @@ -1204,6 +1215,7 @@ def solve_dispatch( peak_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] pv_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] + neg_sell_soc_underfill: list[tuple[int, pulp.LpVariable]] = [] fixed_tariff_like = fixed_tariff_like_pre block_export_neg_sell = bool(getattr(grid, "block_export_on_negative_sell", False)) if om == "AUTO": @@ -1255,6 +1267,25 @@ def solve_dispatch( cap_w = float(min(pv_surplus_w, battery.max_charge_power_w)) sf_pv = pulp.LpVariable(f"post_neg_pv_shortfall_{t}", 0, cap_w) pv_charge_shortfall.append((t, sf_pv, cap_w)) + for t in range(T): + if float(slots[t].sell_price) >= 0: + continue + if t not in charge_slots: + continue + pv_surplus_w = max( + 0.0, + float(slots[t].pv_a_forecast_w) + + float(slots[t].pv_b_forecast_w) + - float(slots[t].load_baseline_w), + ) + if pv_surplus_w <= 500: + continue + us = pulp.LpVariable( + f"neg_soc_under_{t}", + 0, + float(battery.usable_capacity_wh), + ) + neg_sell_soc_underfill.append((t, us)) # --- Účelová funkce (jen OTE sloty; terminal SoC shadow price na konci horizontu) --- # Kanály: gi×buy, −ge_pv×sell, −ge_bat×sell, +ge_bat×acquisition (export bat. jen v discharge slotách). @@ -1344,6 +1375,15 @@ def solve_dispatch( sf * PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 for _t, sf, _cap in pv_charge_shortfall ) + + pulp.lpSum( + us * NEG_SELL_SOC_UNDERFILL_PENALTY_CZK_PER_WH + for _t, us in neg_sell_soc_underfill + ) + + pulp.lpSum( + -25.0 * z_export[t] + for t in range(T) + if t in discharge_export_slots and t in profitable_export_ts_pre + ) ) # --- Omezení --- @@ -1351,6 +1391,8 @@ def solve_dispatch( prob += sf >= cap_w - ge_bat[t_sf] for t_sf, sf, cap_w in pv_charge_shortfall: prob += sf >= cap_w - bc_pv[t_sf] + for t_us, us in neg_sell_soc_underfill: + prob += us >= float(battery.soc_max_wh) - soc[t_us] preneg_export_min_soc_wh = float(min_soc_wh) + max( float(battery.max_discharge_power_w) * float(battery.discharge_efficiency) @@ -1367,9 +1409,14 @@ def solve_dispatch( for t_peak in morning_pre_neg_export_ts: if t_peak in profitable_export_ts: prob += ge_bat[t_peak] >= float(PRENEG_MORNING_EXPORT_MIN_W) * z_export[t_peak] + evening_export_push_w = min( + export_push_w, + float(battery.max_discharge_power_w) * 0.5, + ) for t_peak in evening_peak_export_ts: - if t_peak in profitable_export_ts: - prob += ge_bat[t_peak] >= export_push_w * z_export[t_peak] + if t_peak not in discharge_export_slots: + continue + prob += ge_bat[t_peak] >= evening_export_push_w * z_export[t_peak] # Ostatní profitable sloty: jen shortfall penalizace (ne tvrdý push na celý horizont). if t_anchor is not None and soc_anchor_slack is not None: target_floor_wh = float(planner_floor_effective_wh) @@ -1599,6 +1646,13 @@ def solve_dispatch( export_soc_floor_t = float(soc_panel_min[t]) else: export_soc_floor_t = float(arb_base_wh) + # Večerní exportní slot: podlaha jen min_soc (ne safety ramp), aby šlo vybít při z_export=1. + if ( + om == "AUTO" + and t in discharge_export_slots + and t in evening_peak_export_ts + ): + export_soc_floor_t = float(min_soc_wh) # Safety export floor: v běžných (ne high-sell) slotech nevybít exportem energii potřebnou pro # robustnost/noční baseload. Použije se pouze pokud je safety target v SQL vyplněný. tgt_s = slots[t].safety_soc_target_wh if daytime_en else None @@ -1606,6 +1660,11 @@ def solve_dispatch( tgt_s is not None and not high_sell_slot[t] and t not in profitable_export_ts_pre + and not ( + om == "AUTO" + and t in discharge_export_slots + and t in evening_peak_export_ts + ) ): export_soc_floor_t = max( export_soc_floor_t, diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 0742469..379a0b8 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-neg-sell-v3") + self.assertEqual(snap.get("planner_build_tag"), "2026-05-24-evening-export-v4") self.assertGreater( results[0].battery_setpoint_w, 5_500, @@ -1234,6 +1234,64 @@ class NegativeSellPvChargeTests(unittest.TestCase): "neg okno má dobít na planner soc_max, ne ~92 %", ) + def test_fixed_tariff_evening_export_when_sell_above_buy(self) -> None: + """BA81: sell 3,7 > buy 3,088 musí exportovat (acq 3,61 + 0,3 by dříve blokovalo).""" + slots = [ + PlanningSlot( + interval_start=datetime(2026, 5, 24, 17, 0, tzinfo=timezone.utc), + buy_price=3.088, + sell_price=3.75, + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=400, + ev1_connected=False, + ev2_connected=False, + allow_charge=False, + allow_discharge_export=True, + charge_acquisition_buy_czk_kwh=3.613, + ) + ] + battery = _battery(uc_wh=12_500.0, terminal_soc_value_factor=0.0) + battery.max_discharge_power_w = 6_250 + battery.planner_daytime_charge_target_enabled = False + 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=16_000, + block_export_on_negative_sell=False, + ) + 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.95 * battery.usable_capacity_wh + results, _ms, _ = solve_dispatch( + slots, + battery, + hp, + grid, + [None, None], + vehicles, + soc0, + 50.0, + operating_mode="AUTO", + ) + r0 = results[0] + self.assertLess(r0.grid_setpoint_w, 0, "očekáván export do sítě") + self.assertEqual(r0.export_mode, "BATTERY_SELL") + class AutoPvSurplusExportTests(unittest.TestCase): """Plná baterie + vysoká FVE: export přebytku (ge_pv), ne curtailment, bez SELL.""" 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 15235d3..008a026 100644 --- a/db/routines/R__063_fn_load_planning_slots_full.sql +++ b/db/routines/R__063_fn_load_planning_slots_full.sql @@ -698,7 +698,8 @@ begin and ( case when v_purchase_pricing_mode = 'fixed' then - wk.sell_price > wk.buy_price + v_degrad_czk_kwh + -- Večerní peak: vyvést i když sell < fixní buy (KV1), pokud je to denní maximum výkupu. + true else wk.sell_price > v_ref_buy_czk_kwh + v_degrad_czk_kwh end diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index 3ae7902..78ae85b 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -187,6 +187,22 @@ Očekáváno: `tag = 2026-05-24-neg-sell-v2`, v ranním okně `sell<0` více slo --- +## 2026-05-24 (i) — Večerní export BA81/KV1 + BA81 dobít na 100 % + +**Problém:** Po v3 KV1 nabíjení OK, BA81 stále plateau ~94 % v neg. okně. **Večer žádný prodej** z baterie ani při sell ~3,7 Kč (BA81 i KV1). + +**Příčiny:** + +1. **`_slot_profitable_battery_export`:** u fixního tarifu porovnával `sell > acquisition + degrad` (BA81 acq ~3,61 → potřeba sell > ~3,91). Správně **`sell > buy + degrad`** jako v R__063. +2. **KV1 večer:** SQL večerní maska vyžadovala `sell > buy` (6,35 vs 3,7) → **`allow_discharge_export = false`**. +3. **LP:** `ge_bat >= export_push * z_export` — solver nechal **`z_export = 0`** (export „zdarma“ bez nutnosti). + +**Oprava:** `planning_engine.py` tag **`2026-05-24-evening-export-v4`**; `R__063` večerní peak u fixed tarifu bez podmínky sell>buy. Měkký push `ge_bat`, odměna `z_export`, `neg_sell_soc_underfill`, večerní export floor = min_soc. + +**Deploy:** `flyway migrate` (R__063) + rebuild `ems-api` + replan. MCP: `planner_build_tag = 2026-05-24-evening-export-v4`, večer `export_mode = BATTERY_SELL` nebo `grid_setpoint_w < -1000` v špičce. + +--- + ## 2026-05-24 (h) — BA81: neg okno na plné soc_max (ne 92 %) **Problém:** Po (g) plán lépe nabíjí v okně `sell<0`, ale SoC plán končí ~**92 %** a drží se do přechodu na kladný výkup; až pak dobíjí na 100 %.