From 2d021b15c3d5dea0deaee92facffd507cc535b68 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Sun, 24 May 2026 20:22:11 +0200 Subject: [PATCH] dalsi fix zapornoeho sellu u home-01 --- backend/services/planning_engine.py | 151 ++++++++++++++++--- backend/tests/test_planning_dispatch_milp.py | 103 ++++++++++++- docs/04-modules/planning.md | 2 +- docs/planning-changelog.md | 14 ++ 4 files changed, 243 insertions(+), 27 deletions(-) diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index bbbced5..fac6ab6 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -59,7 +59,12 @@ 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-25-home01-neg-sell-evening-v10" +# Jen ventil nekontrolovatelného pole B při plné baterii a sell<0 (spot); ne celý PV přebytek. +NEG_SELL_PV_B_VENT_PENALTY_CZK_KWH = 4.0 +# Výboj baterie při sell<0 jen těsně před extrémně záporným buy (round-trip arbitráž). +EXTREME_BUY_DUMP_PREWINDOW_SLOTS = 12 +NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH = 80.0 +PLANNER_BUILD_TAG = "2026-05-26-neg-sell-bat-dump-extreme-buy-v11" 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 @@ -688,6 +693,70 @@ def _horizon_fixed_tariff_like(slots: list[PlanningSlot]) -> bool: return max(buys) - min(buys) < 0.25 +def _future_extreme_buy_from( + slots: list[PlanningSlot], + buy_thr: float, +) -> list[bool]: + """True v t, pokud v některém budoucím slotu buy <= buy_thr.""" + t_len = len(slots) + out = [False] * t_len + seen = False + for i in range(t_len - 1, -1, -1): + if float(slots[i].buy_price) <= buy_thr: + seen = True + out[i] = seen + return out + + +def _neg_sell_bat_dump_slots( + slots: list[PlanningSlot], + *, + operating_mode: str, + purchase_fixed: bool, + grid: Any, + buy_extreme_thr: float, + degrad_czk_kwh: float, +) -> set[int]: + """Sloty, kde smí ge_bat>0 při sell<0 (výboj před extrémně záporným buy).""" + if operating_mode != "AUTO" or purchase_fixed: + return set() + if bool(getattr(grid, "block_export_on_negative_sell", False)): + return set() + t_len = len(slots) + future_extreme = _future_extreme_buy_from(slots, buy_extreme_thr) + dist = _slots_until_buy_le(slots, buy_extreme_thr) + out: set[int] = set() + for t, s in enumerate(slots): + if float(s.sell_price) >= 0.0: + continue + future_min = min( + (float(slots[j].buy_price) for j in range(t + 1, t_len)), + default=float(s.buy_price), + ) + if ( + future_extreme[t] + and 0 < dist[t] <= EXTREME_BUY_DUMP_PREWINDOW_SLOTS + and future_min < float(s.sell_price) - degrad_czk_kwh + ): + out.add(t) + return out + + +def _slots_until_buy_le( + slots: list[PlanningSlot], + buy_thr: float, +) -> list[int]: + """Počet slotů do nejbližšího buy <= thr (0 = v tomto slotu, T = nikdy).""" + t_len = len(slots) + dist = [t_len] * t_len + next_idx = t_len + for i in range(t_len - 1, -1, -1): + if float(slots[i].buy_price) <= buy_thr: + next_idx = i + dist[i] = (next_idx - i) if next_idx < t_len else t_len + return dist + + def _pre_negative_sell_export_window( slots: list[PlanningSlot], ) -> tuple[int | None, int | None]: @@ -1138,6 +1207,8 @@ def solve_dispatch( if float(slots[i].buy_price) < 0.0: seen_neg_buy = True future_neg_buy_from[i] = seen_neg_buy + future_extreme_buy_from = _future_extreme_buy_from(slots, buy_extreme_thr) + dist_to_extreme_buy = _slots_until_buy_le(slots, buy_extreme_thr) # EV proměnné per vozidlo ev_direct = [[pulp.LpVariable(f"evd_{e}_{t}", 0, @@ -1195,6 +1266,14 @@ def solve_dispatch( 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_bat_dump_slots = _neg_sell_bat_dump_slots( + slots, + operating_mode=om, + purchase_fixed=purchase_fixed_pre, + grid=grid, + buy_extreme_thr=buy_extreme_thr, + degrad_czk_kwh=float(degradation_cost_effective), + ) profitable_export_ts_pre: set[int] = set() if om == "AUTO": for _t in range(T): @@ -1281,6 +1360,7 @@ def solve_dispatch( peak_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] pv_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] + neg_sell_bat_dump_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)) @@ -1352,6 +1432,14 @@ def solve_dispatch( float(battery.usable_capacity_wh), ) neg_sell_soc_underfill.append((t, us)) + for t in neg_sell_bat_dump_slots: + dump_target_w = min( + float(EVENING_BATTERY_EXPORT_MIN_W), + float(battery.max_discharge_power_w), + float(grid.max_export_power_w), + ) + sf_dump = pulp.LpVariable(f"neg_bat_dump_shortfall_{t}", 0, dump_target_w) + neg_sell_bat_dump_shortfall.append((t, sf_dump, dump_target_w)) # --- Úč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). @@ -1395,6 +1483,18 @@ def solve_dispatch( ) else 0 ) + + ( + ge_pv[t] + * NEG_SELL_PV_B_VENT_PENALTY_CZK_KWH + * INTERVAL_H + / 1000 + if ( + om == "AUTO" + and float(slots[t].sell_price) < 0.0 + and not purchase_fixed_pre + ) + else 0 + ) + pulp.lpSum( ev_direct[e][t] * slots[t].buy_price * INTERVAL_H / 1000 + ev_via_bat[e][t] * slots[t].buy_price * EV_ROUNDTRIP_FACTOR * INTERVAL_H / 1000 @@ -1445,6 +1545,10 @@ def solve_dispatch( us * NEG_SELL_SOC_UNDERFILL_PENALTY_CZK_PER_WH for _t, us in neg_sell_soc_underfill ) + + pulp.lpSum( + sf * NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 + for _t, sf, _cap in neg_sell_bat_dump_shortfall + ) + pulp.lpSum( -25.0 * z_export[t] for t in range(T) @@ -1457,6 +1561,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_sf, sf, cap_w in neg_sell_bat_dump_shortfall: + prob += sf >= cap_w - ge_bat[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( @@ -1602,13 +1708,16 @@ def solve_dispatch( + heat_pump.rated_heating_power_w, ) - # Záporný prodej (sell < 0): baterii v tomhle okně nevybíjíme (dump má proběhnout předtím). - # Export v okně sell<0 může vzniknout jen z přebytku FVE (pv_a/pv_b). Výjimka: EV-via-battery - # (pokud by bylo připojené a požadovalo výkon) – to kryjeme přes bd >= ev_via_bat. + # Záporný prodej (sell < 0): výboj baterie jen před extrémně záporným buy (v11). + # Export FVE při sell<0: spot = nabíjení/curtail A; ventil jen pole B při plné baterii. if s.sell_price < 0: prob += w_arb[t] == 0 prob += bd[t] <= pulp.lpSum(ev_via_bat[e][t] for e in range(EV)) - prob += ge_bat[t] == 0 + block_neg_sell_export_t = bool( + getattr(grid, "block_export_on_negative_sell", False) + ) + if t not in neg_sell_bat_dump_slots: + prob += ge_bat[t] == 0 ev_cap_neg = sum( float(vehicles[e].max_charge_power_w) for e in range(EV) @@ -1640,32 +1749,25 @@ def solve_dispatch( if block_pv_export_neg_sell: prob += ge_pv[t] == 0 # Tvrdý zákaz vývozu jen při block_export_on_negative_sell (KV1). - # GEN cut-off (z_gen_cutoff) nesmí vynutit ge==0 — jinak nelze odvést pole B při plné baterii (BA81). - block_neg_sell_export = bool( - getattr(grid, "block_export_on_negative_sell", False) - ) - if block_neg_sell_export: + if block_neg_sell_export_t: prob += ge[t] == 0 prob += ge_pv[t] == 0 prob += ge_bat[t] == 0 elif purchase_fixed_pre: # Fixní nákup + spot výkup (BA81, KV1 bez block_export): sell<0 = platíš za vývoz. - # Nesouvisí s NT/VT skokem buy — řídí se výkupní cenou, ne rozptylem buy v horizontu. - # Přebytek FVE → baterie / curtail A; B přes z_gen_cutoff nebo bc_pv. prob += ge[t] == 0 prob += ge_pv[t] == 0 - elif not purchase_fixed_pre and pv_surplus_neg_w > 500: - # Spot (home-01): při sell<0 neexportovat, dokud není baterie plná (curtailable A). - # Dříve skip_pv_store_block + pv_b vynucoval export i při prázdné baterii. + elif not purchase_fixed_pre: + # Spot (home-01): ge_pv=0 dokud není plná baterie; pak jen ventil pole B (ne celý surplus). soc_prev_neg = current_soc_wh if t == 0 else soc[t - 1] - w_pv_full = pulp.LpVariable(f"w_pv_full_neg_{t}", cat=pulp.LpBinary) + w_pv_b_vent = pulp.LpVariable(f"w_pv_b_vent_neg_{t}", cat=pulp.LpBinary) + m_soc_neg = float(battery.soc_max_wh) prob += soc_prev_neg >= ( - float(battery.soc_max_wh) + m_soc_neg - soc_headroom_wh - - float(battery.soc_max_wh) * (1 - w_pv_full) + - m_soc_neg * (1 - w_pv_b_vent) ) - prob += ge_pv[t] <= float(pv_surplus_neg_w) * w_pv_full - prob += ge[t] <= float(grid.max_export_power_w) * w_pv_full + prob += ge_pv[t] <= float(s.pv_b_forecast_w) * w_pv_b_vent soc_prev_expr = current_soc_wh if t == 0 else soc[t - 1] arb_t = arb_floor_series[t] @@ -1712,8 +1814,11 @@ def solve_dispatch( # Významný export z baterie ⇒ koncové SoC ≥ podlaha (FVE export ge_pv bez této podlahy). m_ge = float(grid.max_export_power_w) m_soc_bigm = float(battery.usable_capacity_wh) - prob += ge_bat[t] <= m_ge * z_export[t] - prob += ge_bat[t] >= GE_MIN_EXPORT_W * z_export[t] + if t in neg_sell_bat_dump_slots: + prob += ge_bat[t] <= m_ge + else: + prob += ge_bat[t] <= m_ge * z_export[t] + prob += ge_bat[t] >= GE_MIN_EXPORT_W * z_export[t] # Bez hluboké relaxace: export končí ≥ rezerva. Při hluboké relaxaci (soc_panel_min pod min_soc) # sladit s LP spodkem — jinak z_export vynutil arb_base a blokoval vývoz k planner floor. if ( @@ -1810,7 +1915,7 @@ def solve_dispatch( prob += bc_pv[t] == 0 else: prob += bc_pv[t] <= float(pv_surplus_w) - if t not in discharge_export_slots: + if t not in discharge_export_slots and t not in neg_sell_bat_dump_slots: prob += ge_bat[t] == 0 prob += z_export[t] == 0 diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index ba34941..6035f47 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-25-home01-neg-sell-evening-v10") + self.assertEqual(snap.get("planner_build_tag"), "2026-05-26-neg-sell-bat-dump-extreme-buy-v11") self.assertGreater( results[0].battery_setpoint_w, 5_500, @@ -1372,7 +1372,7 @@ class NegativeSellPvChargeTests(unittest.TestCase): 50.0, operating_mode="AUTO", ) - self.assertEqual(snap.get("planner_build_tag"), "2026-05-25-home01-neg-sell-evening-v10") + self.assertEqual(snap.get("planner_build_tag"), "2026-05-26-neg-sell-bat-dump-extreme-buy-v11") self.assertEqual(len(results), len(slots)) def test_gen_cutoff_full_soc_neg_sell_with_pv_b_feasible(self) -> None: @@ -1436,7 +1436,7 @@ class NegativeSellPvChargeTests(unittest.TestCase): 55.0, operating_mode="AUTO", ) - self.assertEqual(snap.get("planner_build_tag"), "2026-05-25-home01-neg-sell-evening-v10") + self.assertEqual(snap.get("planner_build_tag"), "2026-05-26-neg-sell-bat-dump-extreme-buy-v11") self.assertEqual(len(results), len(slots)) def test_fixed_tariff_neg_sell_no_grid_export(self) -> None: @@ -2376,6 +2376,103 @@ class Home01RegressionTests(unittest.TestCase): self.assertGreaterEqual(r.grid_setpoint_w, 0, "neg sell bez exportu při volné kapacitě baterie") self.assertGreater(r.battery_setpoint_w, 0, "neg sell má nabíjet z FVE") + def test_neg_sell_full_battery_exports_at_most_pv_b_not_full_surplus(self) -> None: + """Plná baterie + sell<0: max export jen pole B (~5 kW), ne pv_a+pv_b (~9 kW).""" + slots = [ + PlanningSlot( + interval_start=datetime(2026, 5, 25, 7, 30, tzinfo=timezone.utc) + + timedelta(minutes=15 * i), + buy_price=0.5, + sell_price=-0.4, + pv_a_forecast_w=4700, + pv_b_forecast_w=5100, + load_baseline_w=400, + ev1_connected=False, + ev2_connected=False, + allow_charge=True, + allow_discharge_export=False, + ) + for i in range(3) + ] + battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.2) + battery.max_charge_power_w = 18_000 + battery.soc_max_wh = 64_000.0 + grid = SimpleNamespace( + max_import_power_w=17_000, + max_export_power_w=13_500, + block_export_on_negative_sell=False, + purchase_pricing_mode="spot", + ) + 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), + ] + soc0 = float(battery.soc_max_wh) - 500.0 + results, _ms, _ = solve_dispatch( + slots, + battery, + hp, + grid, + [None, None], + vehicles, + soc0, + 50.0, + operating_mode="AUTO", + ) + for r in results: + export_w = max(0, -int(r.grid_setpoint_w or 0)) + if export_w > 0: + self.assertLessEqual( + export_w, + 5_500, + "při plné baterii jen ventil pole B, ne celý PV přebytek", + ) + + def test_neg_sell_bat_dump_slot_selection(self) -> None: + """sell<0 těsně před buy<=-2: slot je v neg_sell_bat_dump_slots (ge_bat povolen).""" + from services.planning_engine import _neg_sell_bat_dump_slots + + slots = [ + PlanningSlot( + interval_start=datetime(2026, 4, 4, 5, 0, tzinfo=timezone.utc), + buy_price=0.3, + sell_price=-0.35, + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=800, + ev1_connected=False, + ev2_connected=False, + allow_charge=False, + allow_discharge_export=False, + ), + PlanningSlot( + interval_start=datetime(2026, 4, 4, 5, 15, tzinfo=timezone.utc), + buy_price=-10.0, + sell_price=-0.2, + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=800, + ev1_connected=False, + ev2_connected=False, + allow_charge=True, + allow_discharge_export=False, + ), + ] + grid = SimpleNamespace( + block_export_on_negative_sell=False, + purchase_pricing_mode="spot", + ) + dump = _neg_sell_bat_dump_slots( + slots, + operating_mode="AUTO", + purchase_fixed=False, + grid=grid, + buy_extreme_thr=-2.0, + degrad_czk_kwh=0.15, + ) + self.assertEqual(dump, {0}) + def test_no_fve_dump_at_low_sell_with_evening_peak(self) -> None: """Odpolední sell ~1,4 vs večer ~5,5 — žádný PV_SURPLUS export, nabíjení z FVE.""" base = datetime(2026, 5, 21, 14, 0, tzinfo=timezone.utc) diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 7ad4311..2e230dd 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -19,7 +19,7 @@ - **Tvrdé výkonové limity site/baterie:** `gi ≤ site_grid_connection.max_import_power_w` (breaker); **`bc_pv + bc_gi ≤ asset_battery.max_charge_power_w`**; **`ge ≤ max_export_power_w`** (proměnná `ge`, platí `ge = ge_pv + ge_bat`); **`bd + ge_bat ≤ asset_battery.max_discharge_power_w`** (vybíjení do domu + export z baterie nesmí současně překročit BMS). Dříve LP dovoloval import+nabíjení a dvojnásobné nabíjení; u prodeje hrozilo současné `bd` a `ge_bat` až 2× max discharge — viz `SitePowerCapTests`. - **Hodnota FVE (PV store value):** `ge_pv = 0`, pokud `sell < future_sell_opportunity − degradation` (ne `charge_acquisition` — u fixního KV1 by jinak blokoval export při sell 2 Kč). **Před prvním `sell < 0` v horizontu:** při `sell ≥ 0` smí `ge_pv` až do `pv_sp` (strategie BA81: vyvézt přes poledne, pak nabít z FVE v záporném okně). Výjimka **nucený vent** jen plná baterie. Testy `Home01PvStoreValueTests`, `PreNegativeSellExportTests`. - **Drahý nákup → vlastní spotřeba z baterie:** mimo `allow_charge` platí `bd + pv_ld ≥ load_baseline + hp[t]` a `gi ≤ EV + hp[t]` (ne `hp_rated`). **Spot:** drahý slot = `buy > min(buy≥0) + degradace`. **Fixní nákup (DB `purchase_pricing_mode=fixed` nebo heuristika rozptylu buy < 0,25):** navíc `buy > charge_acquisition + degradace`. Na spotu **nesmí** `charge_acquisition` (~0,9 Kč) označit všechny sloty jako drahé → Infeasible (home-01). Při **Infeasible** solver jednou opakuje s `relaxed_expensive_import` (síť smí krmit baseload v drahých slotech; v `solver_params.inputs.relaxed_expensive_import=true`). Testy `AutoPassiveSelfConsumptionTests`, `test_spot_low_acquisition_does_not_mark_all_slots_expensive`, `test_negative_buy_in_horizon_does_not_block_all_grid_import`. - - **Záporný výkup (`sell < 0`) bez exportu:** `block_export_on_negative_sell` (KV1) **nebo** `purchase_pricing_mode=fixed` (BA81). **Spot (home-01):** export FVE jen při plné baterii (`w_pv_full_neg`); jinak nabíjení/curtail A — tag `2026-05-25-home01-neg-sell-evening-v10`. Večerní discharge maska u spotu: denní peak ≥17:00 (ne `sell > ref_buy` v slotu). + - **Záporný výkup (`sell < 0`) bez exportu:** `block_export_on_negative_sell` (KV1) **nebo** `purchase_pricing_mode=fixed` (BA81). **Spot (home-01):** `ge_pv=0` dokud není plná baterie; při plné jen ventil pole B (`ge_pv ≤ pv_b`, `w_pv_b_vent_neg`); výboj baterie při `sell<0` jen **12 slotů** před `buy ≤ planner_extreme_buy_threshold` (default −2), pokud spread do budoucna dává smysl — tag `2026-05-26-neg-sell-bat-dump-extreme-buy-v11`. Večerní discharge maska u spotu: denní peak ≥17:00 (ne `sell > ref_buy` v slotu). - **Pole B při sell<0 (home-01):** pokud `block_export_on_negative_sell = false`, LP nesmí vynutit `ge_pv = 0` (přebytek neriťitelného PV B). KV1 s `block_export = true` jen curtail A / nabíjení. - **`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. diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index 32f4bde..083e8b2 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,20 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-05-26 (o) — home-01: neg. výkup bez placeného exportu FVE + dump baterie před extrémním buy + +**Problém (run 16480, tag v10):** Po ranním nabití na `soc_max` solver při `sell<0` exportoval **celý PV přebytek** (~9 kW, `PV_SURPLUS`) — binárka `w_pv_full_neg` povolila `ge_pv ≤ pv_surplus` místo jen ventilu pole B. Zároveň `ge_bat=0` blokoval výboj baterie před oknem `buy ≤ −2` (round-trip arbitráž). + +**Oprava (tag `2026-05-26-neg-sell-bat-dump-extreme-buy-v11`):** + +- Spot `sell<0`: `ge_pv=0` dokud není plná baterie; při plné jen `ge_pv ≤ pv_b` (`w_pv_b_vent_neg`) + penalizace `NEG_SELL_PV_B_VENT_PENALTY` (4 Kč/kWh). +- Před extrémním buy (`buy ≤ planner_extreme_buy_threshold`, default −2): v okně **12 slotů** smí `ge_bat>0` při `sell<0`, pokud `min_buy_future < sell − degrad`. +- Odstraněn `w_pv_full_neg` (export celého surplusu). + +**Ověření:** `test_neg_sell_full_battery_exports_at_most_pv_b_not_full_surplus`, `test_neg_sell_bat_dump_before_extreme_buy`, `test_neg_sell_pv_to_battery_not_grid_when_soc_has_room`; po deploy replan home-01 — neg sell bez ~9 kW exportu. + +--- + ## 2026-05-25 (n) — home-01 AUTO: záporný výkup bez exportu, večerní špička **Problém (run 16412, AUTO):** Dnes večer téměř bez exportu (terminal SoC drží energii na zítřek); zítra 07:30+ masivní **PV_SURPLUS** při `sell<0` místo nabíjení; zítra večer export OK.