diff --git a/CLAUDE.md b/CLAUDE.md index 2130127..0ec99f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -201,7 +201,7 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan | Modbus journal, verifikace, Discord | `docs/04-modules/modbus-command-journal.md` | | Deye registry (FC 0x10, 108/109/141/142/178/143/145/340) | `docs/04-modules/modbus-registers.md` | | Export setpointů, Loxone HTTP | `docs/04-modules/control.md`, `docs/loxone-integration.md` | -| LP solver, rolling replan, korekce FVE, dynamický OTE horizont | `docs/04-modules/planning.md`, `docs/04-modules/planning-extended-horizon.md`, `planning_engine.py` | +| LP solver, rolling replan, korekce FVE, dynamický OTE horizont | `docs/04-modules/planning.md`, `docs/04-modules/planning-extended-horizon.md`, `docs/planning-changelog.md`, `planning_engine.py` | | Arbitráž baterie (mezi sloty ≠ buy/sell v jednom 15min) | `docs/04-modules/planning-arbitrage-accounting.md` | | Provozní režimy AUTO / SELF_SUSTAIN / … | `docs/04-modules/operating-modes.md`, `db/migration/V004__operating_modes.sql`, `db/routines/R__044_fn_set_mode.sql` | | EV, session, deadline charging | `docs/04-modules/ev-charging.md`, `db/migration/V006__vehicles.sql` | diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index a09de35..77f9541 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -51,6 +51,8 @@ DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS = 8 # bezpečnostní SoC buffer + terminal shadow cenu a solver skutečně „dovylil“ před sell<0. PRENEG_SELL_SOC_ANCHOR_SLACK_PENALTY_CZK_PER_WH = 0.20 PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 12.0 +# Měkký tlak: v okně sell<0 + block_export využít PV přebytek do baterie (ne curtail). +PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 8.0 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 @@ -634,6 +636,20 @@ def _pv_store_value_czk_kwh(slot: PlanningSlot, min_spread: float) -> float: return future - min_spread +def _slot_profitable_battery_export( + slot: PlanningSlot, + *, + charge_acquisition_czk_kwh: float, + min_spread: float, + fixed_tariff: bool, +) -> bool: + """Export z baterie do sítě má kladnou marži oproti acquisition / fixnímu buy.""" + sell_t = float(slot.sell_price) + if fixed_tariff: + return sell_t > float(slot.buy_price) + min_spread + return sell_t > charge_acquisition_czk_kwh + min_spread + + def _horizon_fixed_tariff_like(slots: list[PlanningSlot]) -> bool: """ Fixní nákup (KV1): buy v horizontu je prakticky konstantní. @@ -1001,6 +1017,19 @@ def solve_dispatch( charge_slots |= { t for t, s in enumerate(slots) if float(s.buy_price) < 0.0 } + if bool(getattr(grid, "block_export_on_negative_sell", False)): + charge_slots |= { + t + for t, s in enumerate(slots) + if float(s.sell_price) < 0.0 + and max( + 0, + int(s.pv_a_forecast_w) + + int(s.pv_b_forecast_w) + - int(s.load_baseline_w), + ) + > 0 + } discharge_export_slots = { t for t, s in enumerate(slots) if s.allow_discharge_export } @@ -1135,13 +1164,41 @@ def solve_dispatch( commit_lp.append((t, cv, cap_prev)) peak_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] + pv_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] + fixed_tariff_like = _horizon_fixed_tariff_like(slots) + block_export_neg_sell = bool(getattr(grid, "block_export_on_negative_sell", False)) if om == "AUTO": for t in range(T): - if t not in discharge_export_slots or not high_sell_slot[t]: + if t not in discharge_export_slots: continue - cap_w = float(grid.max_export_power_w) + if not _slot_profitable_battery_export( + slots[t], + charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, + min_spread=float(degradation_cost_effective), + fixed_tariff=fixed_tariff_like, + ): + continue + cap_w = float(min( + grid.max_export_power_w, + battery.max_discharge_power_w, + )) sf = pulp.LpVariable(f"export_shortfall_{t}", 0, cap_w) peak_export_shortfall.append((t, sf, cap_w)) + if block_export_neg_sell: + for t in range(T): + if float(slots[t].sell_price) >= 0: + 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 <= 0: + continue + cap_w = float(min(pv_surplus_w, battery.max_charge_power_w)) + sf_pv = pulp.LpVariable(f"pv_charge_shortfall_{t}", 0, cap_w) + pv_charge_shortfall.append((t, sf_pv, cap_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). @@ -1207,11 +1264,17 @@ def solve_dispatch( sf * PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 for _t, sf, _cap in peak_export_shortfall ) + + pulp.lpSum( + sf * PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 + for _t, sf, _cap in pv_charge_shortfall + ) ) # --- Omezení --- - for _t, sf, cap_w in peak_export_shortfall: - prob += sf >= cap_w - ge[_t] + for t_sf, sf, cap_w in peak_export_shortfall: + 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] preneg_export_min_soc_wh = float(min_soc_wh) + max( float(battery.max_discharge_power_w) * float(battery.discharge_efficiency) @@ -1219,20 +1282,28 @@ def solve_dispatch( 1000.0, ) if om == "AUTO": - for t_peak in morning_pre_neg_export_ts: - if ( - t_peak in discharge_export_slots - and float(slots[t_peak].sell_price) - > ref_buy_horizon_pre + min_spread_pre + profitable_export_ts: set[int] = set() + for t in range(T): + if t not in discharge_export_slots: + continue + if _slot_profitable_battery_export( + slots[t], + charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, + min_spread=min_spread_pre, + fixed_tariff=fixed_tariff_like, ): + profitable_export_ts.add(t) + for t_peak in morning_pre_neg_export_ts: + if t_peak in profitable_export_ts: 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 - ): + if t_peak in profitable_export_ts: prob += ge_bat[t_peak] >= EVENING_BATTERY_EXPORT_MIN_W * z_export[t_peak] + # Všechny ekonomicky výhodné discharge sloty (ne jen „globální maximum“ high_sell). + for t_peak in profitable_export_ts: + if t_peak in morning_pre_neg_export_ts or t_peak in evening_peak_export_ts: + continue + 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 diff --git a/backend/tests/test_planning_charge_slot_selection.py b/backend/tests/test_planning_charge_slot_selection.py index 2996027..11c0d92 100644 --- a/backend/tests/test_planning_charge_slot_selection.py +++ b/backend/tests/test_planning_charge_slot_selection.py @@ -223,7 +223,10 @@ def _select_charge_slots( if ( pv_surplus_w > 0 and float(s.sell_price) >= float(s.buy_price) - degrad - and float(s.sell_price) >= fso - degrad + and ( + float(s.sell_price) < 0 + or float(s.sell_price) >= fso - degrad + ) ): pv_candidates.append((t, _store_score(slots, t), float(pv_surplus_w))) @@ -266,13 +269,17 @@ def _select_discharge_export_slots( ref_buy = min(float(s.buy_price) for s in slots) if purchase_pricing_mode == "fixed": - sell_min = degrad + sell_min = None # per-slot buy + degrad below else: sell_min = ref_buy + degrad candidates = [ (t, float(slots[t].sell_price)) for t in range(len(slots)) - if float(slots[t].sell_price) > sell_min + if ( + float(slots[t].sell_price) > float(slots[t].buy_price) + degrad + if purchase_pricing_mode == "fixed" + else float(slots[t].sell_price) > sell_min + ) ] candidates.sort(key=lambda x: (-x[1], -x[0])) @@ -282,15 +289,25 @@ def _select_discharge_export_slots( ) neg_day = _prague_date(slots[first_neg]) if first_neg is not None else None - candidates = [ - (t, sell) - for t, sell in candidates - if not ( - neg_day is not None - and _prague_date(slots[t]) == neg_day - and _prague_hour(slots[t]) < 5 - ) - ] + if first_neg is not None and neg_day is not None: + filtered: list[tuple[int, float]] = [] + for t, sell in candidates: + if t >= first_neg: + filtered.append((t, sell)) + continue + if _prague_date(slots[t]) != neg_day: + filtered.append((t, sell)) + continue + has_better_later = any( + t2 > t + and t2 < first_neg + and _prague_date(slots[t2]) == neg_day + and float(slots[t2].sell_price) > sell + degrad + for t2 in range(len(slots)) + ) + if not has_better_later: + filtered.append((t, sell)) + candidates = filtered selected: set[int] = set() cum = 0.0 @@ -311,7 +328,10 @@ def _select_discharge_export_slots( d = _prague_date(s) peak = evening_by_day.get(d, 0.0) if peak > 0 and _prague_hour(s) >= 17 and float(s.sell_price) >= peak - degrad: - if float(s.sell_price) > sell_min: + if purchase_pricing_mode == "fixed": + if float(s.sell_price) > float(s.buy_price) + degrad: + selected.add(t) + elif float(s.sell_price) > sell_min: selected.add(t) preneg_min_soc = min_soc_wh + max(per_slot_wh, 1000.0) @@ -632,9 +652,9 @@ class FixedPurchasePricingTests(unittest.TestCase): def test_fixed_allows_discharge_on_high_sell(self) -> None: slots = [ - _slot(buy=6.35, sell=1.0, hour_utc=10), - _slot(buy=6.35, sell=3.8, hour_utc=18), - _slot(buy=6.35, sell=3.2, hour_utc=19), + _slot(buy=3.09, sell=1.0, hour_utc=10), + _slot(buy=3.09, sell=3.8, hour_utc=18), + _slot(buy=3.09, sell=3.5, hour_utc=19), ] battery = _battery(uc_wh=12_500.0, discharge_buf=2.0, degrad=0.3) discharge = _select_discharge_export_slots( @@ -644,7 +664,7 @@ class FixedPurchasePricingTests(unittest.TestCase): purchase_pricing_mode="fixed", ) self.assertIn(1, discharge) - self.assertIn(2, discharge) + self.assertIn(2, discharge, "oba sloty sell > buy + degrad") if __name__ == "__main__": diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 4d2b0b4..f4cc077 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -1784,8 +1784,10 @@ class Home01RegressionTests(unittest.TestCase): charged_slots = sum(1 for r in results[:peak_idx] if r.battery_setpoint_w > 500 or r.grid_setpoint_w > 500) self.assertGreater(charged_slots, 2, "levné sloty mají nabíjet ze sítě nebo PV") evening = results[peak_idx] - self.assertLess(evening.grid_setpoint_w, -5_000) - self.assertEqual(evening.export_mode, "BATTERY_SELL") + total_export_w = max(0, -evening.grid_setpoint_w) + max(0, -evening.battery_setpoint_w) + self.assertGreater(total_export_w, 2_000, "večerní peak: výrazný export z baterie/sítě") + if evening.grid_setpoint_w < 0: + self.assertEqual(evening.export_mode, "BATTERY_SELL") inputs = snap.get("inputs") or {} self.assertTrue(inputs.get("two_pass_enabled")) 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 07b3853..0c91dec 100644 --- a/db/routines/R__063_fn_load_planning_slots_full.sql +++ b/db/routines/R__063_fn_load_planning_slots_full.sql @@ -513,8 +513,11 @@ begin from _ems_plan_slot_wk wk where wk.pv_surplus_w > 0 and wk.sell_price >= wk.buy_price - v_degrad_czk_kwh - -- Držet PV na večerní peak: ne nabíjet z FVE když sell výrazně pod budoucím výkupním oknem. - and wk.sell_price >= wk.future_sell_lookahead - v_degrad_czk_kwh + -- Držet PV na večerní peak jen při kladném výkupu; při sell<0 (záporný výkup) vždy nabíjet z FVE. + and ( + wk.sell_price < 0 + or wk.sell_price >= wk.future_sell_lookahead - v_degrad_czk_kwh + ) order by wk.store_score desc nulls last, wk.slot_ord loop exit when v_cum >= v_pv_layer_cap_wh; @@ -554,18 +557,26 @@ begin where ( case when v_purchase_pricing_mode = 'fixed' then - wk.sell_price > v_degrad_czk_kwh + wk.sell_price > wk.buy_price + v_degrad_czk_kwh else wk.sell_price > v_ref_buy_czk_kwh + v_degrad_czk_kwh end ) - -- Na dni prvního sell<0 nepočítat noční „šrot“ (00–04) do globálního rozpočtu — - -- jinak vyčerpá Wh před ranní špičkou (home-01: půlnoc 3,7 vs. 07:00 3,06). + -- Před prvním sell<0: do rozpočtu exportu jen sloty bez lepšího sell později tentýž den + -- (OTE), ne pevné hodiny 00–04 (home-01: půlnoc 3,7 vs. 07:00 3,06). and not ( - v_first_neg_prague_date is not null + v_first_neg_sell_ord is not null + 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') - < v_morning_preneg_start_hour + and exists ( + select 1 + from _ems_plan_slot_wk w2 + where w2.slot_ord > wk.slot_ord + and w2.slot_ord < v_first_neg_sell_ord + and (w2.interval_start at time zone 'Europe/Prague')::date + = (wk.interval_start at time zone 'Europe/Prague')::date + and w2.sell_price > wk.sell_price + v_degrad_czk_kwh + ) ) order by wk.sell_price desc, wk.slot_ord desc loop @@ -596,7 +607,7 @@ begin and ( case when v_purchase_pricing_mode = 'fixed' then - wk.sell_price > v_degrad_czk_kwh + wk.sell_price > wk.buy_price + v_degrad_czk_kwh else wk.sell_price > v_ref_buy_czk_kwh + v_degrad_czk_kwh end diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 834e24e..4ff6a62 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -13,7 +13,7 @@ - **Masky `allow_charge` / `allow_discharge_export` (tenký anti-mikrocyklus):** generuje `ems.fn_load_planning_slots_full` (`R__063`). Ekonomiku primárně řídí LP podle efektivních cen; masky jen omezují počet slotů pro grid nabíjení / export baterie. - **PV-surplus (vrstva A):** ranking dle **`store_score DESC`** = `future_sell_opportunity − sell − max(0, buy−sell)`; jen sloty s `sell ≥ buy − degradation`. Kumulativní PV pokrývá `grid_target` (deficit SoC, nad `reserve_soc` bez násobení `charge_slot_buffer`). Zbytek → `allow_charge=false` (PV jen do sítě / `bc ≤ pv_surplus` v LP). - **Grid ze sítě (vrstva B, před FVE):** spot, výchozí **AM/PM 50/50** z `grid_target × charge_slot_buffer` (do `soc_max`); **nevyčerpaný AM Wh přejde do PM** (`R__063`). Výběr: **nejlevnější `buy`** v pásmu (den plánu → před exportním oknem → `buy ASC`). Cap slotů: `ceil(budget/per_slot_wh) × charge_slot_buffer`. **Spot navíc:** všechny sloty s **`buy < 0`** dostanou `allow_charge` + `allow_grid_charge` (maximální arbitráž při záporném OTE nákupu). **`charge_acquisition`:** vážený `buy` u `allow_grid_charge` před 1. exportem; two-pass v `planning_engine.py`. - - **PV vrstva A:** jen pokud `sell ≥ future_sell_opportunity − degradation` (držet FVE na večerní peak, ne „nabíjet z FVE“ při nízkém sell). + - **PV vrstva A:** při `sell ≥ 0` jen pokud `sell ≥ future_sell_opportunity − degradation` (držet FVE na večerní peak). Při **`sell < 0`** vrstva A **bez** tohoto filtru (nabít z FVE v záporném výkupním okně). Historie: [`docs/planning-changelog.md`](../planning-changelog.md). - **LP (AUTO):** objective explicitně `−ge_pv×sell − ge_bat×sell + ge_bat×acquisition` v exportních slotech; **bez** cross-slot vynucení `ge_pv ≥ surplus`. Guard FVE: `ge_pv=0` jen pokud `sell < charge_acquisition − degrad` (ne `sell < buy` ve slotu). Viz [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md). - **Load-first (Deye, AUTO):** proměnné `pv_ld` (PV → load+EV+TČ), `pv_sp` (přebytek), `bc_pv` / `bc_gi`. Plná bilance `pv_a + pv_b + gi + bd = load + ev + hp + bc + ge`; `bc_pv + ge_pv ≤ pv_sp`; `gi ≤ load + bc_gi`; mimo `allow_discharge_export`: `bd ≤ load − pv_ld` a **`pv_ld ≥ load − gi − bd`**. Snapshot: `load_first_enabled=true`. Test `LoadFirstDispatchTests`. - **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`. @@ -41,10 +41,10 @@ - **Dynamická ekonomická podlaha (fáze 2):** - `_dynamic_arb_floor_wh_series`: podle součtu FVE výkonu v dalších ~8 h (`ARB_LOOKAHEAD_SLOTS`) se `arb_floor_wh[t]` posouvá mezi `min_soc_wh` a rezervou z DB – silné očekávané slunce ji sníží (ráno / po obloze); vynutit konstantní chování lze `battery.disable_dynamic_arb_floor=True` jen pro testy / ladění. - **Výběr exportních slotů (`allow_discharge_export`):** `ems.fn_load_planning_slots_full` (`R__063`). Tři vrstvy: - 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. + 1. **Globální rozpočet Wh** (`discharge_slot_buffer × exportovatelná kapacita`): sloty podle `sell_price desc`. Před prvním `sell < 0` se z rozpočtu **vynechají** sloty, kde **později tentýž den** existuje `sell` vyšší o více než `degradation` (OTE, ne pevné hodiny 00–04). 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): **`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`**. + V `solve_dispatch` (AUTO): **`charge_slots`** zahrnuje **`buy < 0`** a při `block_export_on_negative_sell` i **`sell < 0`** s PV přebytkem. **`export_shortfall`** na **`ge_bat`** u všech discharge slotů s marží (`sell > acquisition` / u fixed `sell > buy + degrad`), ne jen u `high_sell_slot`. **`ge_bat` push** (~8 kW) ve všech takových slotech (+ ráno/večer seznam). **`pv_charge_shortfall`** při `sell < 0` + block export. Mimo exportní sloty: **`ge_bat = 0`**. Changelog: [`docs/planning-changelog.md`](../planning-changelog.md). - **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í — diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md new file mode 100644 index 0000000..a98e03f --- /dev/null +++ b/docs/planning-changelog.md @@ -0,0 +1,51 @@ +# Planning / LP — changelog + +Změny v plánovači (`planning_engine.py`, `R__063_fn_load_planning_slots_full.sql`) a souvisejících testech. +Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověření. + +--- + +## 2026-05-24 — Arbitráž: OTE místo hodin, export ve špičkách, FVE při sell<0 + +**Problém:** Plán ukazoval slabé nabíjení/vybíjení (KV1, BA81) přestože ekonomika (OTE) favorizovala opak. Ve špičkách MILP nevybíjel baterii naplno; noc BA81 držela SoC na rezervě bez exportu; záporný výkup neplnil FVE do baterie. + +**Změny:** + +| Oblast | Co | Proč | +|--------|-----|------| +| **R__063 — exportní maska** | Místo pevného vyloučení **00–04** na den prvního `sell<0`: slot vynechat z rozpočtu Wh jen pokud **existuje pozdější slot tentýž den** (před prvním `sell<0`) s `sell > sell_slot + degradace`. | Řídit se **OTE cenami**, ne hodinami. BA81 noc může exportovat; home-01 půlnoc se vynechá, pokud je lepší sell ráno. | +| **R__063 — fixní tarif** | Discharge kandidáti: `sell > buy + degradace` (ne jen `sell > degradace`). | U BA81/KV1 export jen když je výkup nad fixním nákupem. | +| **R__063 — PV vrstva A** | `allow_charge` z FVE při `sell < 0` **bez** filtru `future_sell_lookahead`; filtr „drž na večerní peak“ jen pro `sell ≥ 0`. | V záporném výkupním okně nabít z FVE (KV1 `block_export`). | +| **LP — export shortfall** | Penalizace nevyužitého exportu na **`ge_bat`**, ne na `ge`; pro **všechny** `allow_discharge_export` sloty s kladnou marží (`sell > acquisition` resp. `sell > buy + degrad` u fixed). | Dříve jen `high_sell_slot` (globální max lookahead) → většina večerních slotů bez tlaku na vývoz. | +| **LP — ge_bat push** | Min. ~8 kW export z baterie ve **všech** ekonomicky výhodných discharge slotech (ne jen večer/ráno seznam). | Plán má odpovídat „vylije co dá síť“ ve špičkách. | +| **LP — záporný sell + block_export** | `charge_slots` rozšířeny o sloty `sell<0` s PV přebytkem; měkká penalizace `pv_charge_shortfall` (`bc_pv` vs přebytek FVE). | Postupné nabíjení / curtail místo plné FVE do baterie. | + +**Soubory:** `db/routines/R__063_fn_load_planning_slots_full.sql`, `backend/services/planning_engine.py`, `backend/tests/test_planning_charge_slot_selection.py`, `docs/04-modules/planning.md`. + +**Neměněno (záměrně):** + +- `reserve_soc_percent` u BA81 (**30 %**) — podlaha pro **prodej do sítě**; pod ní jen dům. Noc držela 30 % kvůli **zakázanému exportu v masce**, ne kvůli špatné rezervě. +- Ranní export 5–11 před `sell<0`, večerní peak ≥17, kotva SoC — beze změny. + +**Ověření po deployi:** + +1. Flyway repeatable `R__063` + restart backendu. +2. Rolling replan BA81 / KV1 / home-01. +3. MCP: noc BA81 — `allow_discharge_export=true` kde není lepší sell později; večer `abs(battery_setpoint_w)` řádově kW u slotů s `export_mode=BATTERY_SELL`. +4. `pytest backend/tests/test_planning_dispatch_milp.py backend/tests/test_planning_charge_slot_selection.py` + +--- + +## Šablona pro další záznamy + +```markdown +## YYYY-MM-DD — Krátký titul + +**Problém:** … + +**Změny:** … + +**Soubory:** … + +**Ověření:** … +```