# Diagnóza: baterie se nenabila během záporných cen (home-01, 2026-06-13) **Datum analýzy:** 2026-06-13 · **Site:** 2 (home-01) · **Autor:** agent (read-only MCP/psql) **Verdikt:** **Bug v EXEKUCI (control exporter), NE v plánovači ani forecastu.** --- ## 1. Shrnutí (TL;DR) Plánovač (v1 i shadow v2) **udělal vše správně**: během záporného nákupu 13:00–15:45 naplánoval **CHARGE + grid_setpoint = +17 000 W** (import 17 kW ze sítě) a SoC cíl šplhal 41 % → ~96–100 % do 16:00. Realita: SoC se zastavil na **~71 %** v 15:00 a tam zůstal, protože **baterie se nabíjela JEN z FVE** (grid_w ≈ 0 celé odpoledne). Když PV po 15:00 spadlo (mraky: pv 14 kW → 3 kW), nabíjení skončilo. **Příčina:** exekuční pojistka `_apply_export_plan_guard` (`backend/services/control/setpoints.py:272`) má **false positive**. Spustí se při **záporné výkupní ceně** (`sell < 0`) a `grid_setpoint_w >= 0`, což má detekovat „plán nechce exportovat". Jenže slot, kde plán **importuje 17 kW kvůli nabití baterie**, má také `grid_setpoint_w >= 0` (import je kladný) a `sell < 0`. Guard to splete s exportní situací a přepne `deye_physical_mode` z **CHARGE na PASSIVE** (`_passive_no_export_guard`, `setpoints.py:261`). V PASSIVE Deye nenabíjí ze sítě → 17 kW grid charge se nikdy nevykoná. **Ekonomický dopad:** Baterie skončila ~71 % místo ~96 %. Večer (buy 3.8–4.7 Kč/kWh) se load nedokryl z baterie a **importoval ze sítě** (19:15: import **5.8 kW** při buy 2.17 Kč/kWh) a v peaku exportoval jen z ~59 % SoC místo ~96 %. Cca **15–20 kWh** levné (záporné = placené ZA odběr) energie se nenakoupilo; místo toho se draho dokupovalo a méně prodávalo ve špičce. --- ## 2. Důkazy z DB (historické běhy + telemetrie) ### 2.1 Efektivní ceny (`vw_site_effective_price`, site 2, Praha) | Okno | buy [Kč/kWh] | sell [Kč/kWh] | |------|-------------|---------------| | 13:00–15:45 | **−0.47 … −0.95** (záporný) | −0.94 … −1.63 (záporný) | | 16:00–18:30 | +0.45 … +1.42 | −0.33 … −0.93 (stále záporný) | | 18:45–21:00 | **+1.42 … +4.70** (večerní špička) | +0.40 … +2.40 | ### 2.2 Plán (executed first-slot, v1 superseded runs) — CO PLÁN CHTĚL Plánovač každý rolling běh ordinoval CHARGE 17 kW po celou dobu záporného nákupu: | run_t | run_id | slot | mode | grid_w | predicted | buy | |-------|--------|------|------|--------|-----------|-----| | 13:00 | 27081 | 13:00 | **CHARGE** | **17000** | f | −0.47 | | 13:30 | 27092 | 13:30 | **CHARGE** | **17000** | f | −0.87 | | 14:00 | 27102 | 14:00 | **CHARGE** | **17000** | f | −0.78 | | 15:00 | 27122 | 15:00 | **CHARGE** | **17000** | f | −0.51 | | 15:45 | 27144 | 15:45 | **CHARGE** | **17000** | f | −0.19 | | 16:00 | 27149 | 16:00 | PASSIVE | 1182 | f | +0.45 | `is_predicted_price = f` všude → **cena NENÍ predikovaná**, takže `_apply_price_failsafe_guard` nehraje roli. Plán je reálný a chce nabíjet ze sítě. Plán z 13:00 (run 27081) ukazoval kompletní trajektorii: SoC 41.7 % (13:00) → **100 %** (15:45) → večer export. v2 shadow (27082) totéž (~98 %). **Oba enginy chtěly plnou baterii.** ### 2.3 Realita (`telemetry_inverter`, 15min průměr) | slot | SoC % | grid_w | batt_w | pv_w | |------|-------|--------|--------|------| | 13:30 | 45.3 | **2** | −13436 | 14846 | | 14:00 | 55.1 | **13** | −13129 | 14317 | | 14:30 | 64.3 | **−15** | −12700 | 13954 | | 15:00 | **71.0** | 63 | −1201 | 8058 | | 15:30 | 68.9 | 23 | +2236 | 6569 | | 16:00 | 70.2 | 2191 | −1320 | 7497 | | 19:15 | 59.0 | **+5825 (import!)** | −72 | 758 | | 20:45 | 49.7 | −13466 (export) | +15080 | — | **Klíč:** `grid_w ≈ 0` po celé okno záporných cen. Plán chtěl import +17 000 W, reálně se neimportovalo nic. Baterie šla nahoru jen z PV (batt_w ≈ −13 kW = nabíjení z FVE). Jakmile PV spadlo (mraky kolem 15:00), SoC zamrzl na ~71 %. ### 2.4 Modbus journal (`modbus_command`, site 2 inverter, 13:00–15:45) Zapsané registry: **pouze 148/149** (TOU časy), **109** (max discharge 350 A), **340** (solar cap). **Žádný zápis 108 (charge proud) / 142 / 143 / TOU power 154 / TOU grid-charge flag 172.** Sloupec `deye_physical_mode` = **PASSIVE** u všech. → Exekuce psala Deye v PASSIVE, ne CHARGE, navzdory plánu CHARGE. To je přesně otisk guardu, který stáhl CHARGE→PASSIVE: `get_deye_mode(setpoints_now)` v `write_inverter_setpoints` (inverter.py:81) vrátil PASSIVE, protože guard přepsal `deye_physical_mode` na "PASSIVE" ještě před zápisem. --- ## 3. Přesný mechanismus chyby (řetězec kódu) 1. `orchestrator.export_setpoints` (`orchestrator.py:101`) volá `_apply_export_plan_guard(site_id, mode, pi_now, sp_now)`. 2. `_build_setpoints` (`setpoints.py:129`) už pro odpolední slot nastaví `export_ban = sell_f < 0 and grid_sp >= 0` → **True** (sell −0.8, grid +17000). Plán importuje (grid_sp kladný), ale `grid_sp >= 0` platí stejně jako u „neexportuji". 3. `_apply_export_plan_guard` (`setpoints.py:297`): `neg_sell = sell_f is not None and float(sell_f) < 0` → **True**. Podmínka `if not neg_sell and not plan_no_export: return sp` **neprojde** → guard pokračuje a volá `_passive_no_export_guard(sp, hard_ban=True)`. 4. `_passive_no_export_guard` (`setpoints.py:261`): vrací setpoints s **`deye_physical_mode="PASSIVE"`**, `export_mode="NONE"`, `export_ban=True`, `deye_gen_cutoff_enabled=True`. `battery_w` zůstane (line 248–250 jen ořízne záporné), ale **fyzický režim je PASSIVE** → `deye_battery_charge_discharge_amps` jde do PASSIVE větve, ne CHARGE; TOU grid-charge flag (reg 172) = 0. Baterie se neplní ze sítě. **Kořen:** guard rozlišuje jen `sell < 0` a `grid_sp >= 0`, ale **nerozlišuje import (nabíjení) od „neexportuji"**. Záporný **prodej** legitimně zakazuje **export**, ale nesmí zakázat **import na nabití baterie** — to jsou dvě nezávislé věci na jednom elektroměru. Plán, který při `sell < 0` importuje kvůli levnému/zápornému **nákupu**, je zcela správný (pravidlo 6 zakazuje jen *export* při sell<0, ne import). --- ## 4. Vyloučení ostatních hypotéz | Hypotéza | Verdikt | Důkaz | |----------|---------|-------| | Plánovač byl konzervativní (cílil 71 %) | **NE** | Plán cílil 96–100 %; `max_soc_percent=100`, `planner_max_soc_percent=100` — žádný strop pod 100 %. | | HW strop nabíjení bránil naplnění | **NE** | `max_charge_c_rate=0.28 → 17.9 kW`, `bms_max_charge_w=18000`. PV samo dávalo −13 kW; ze sítě bylo dost hlavy (breaker 17 kW). | | `is_predicted_price` failsafe guard | **NE** | `is_predicted_price = f` na všech executed slotech → `_apply_price_failsafe_guard` se nespustil. | | §7 import cap při buy<0 v solver_v2 stropoval import | **NE** | solver_v2 stropuje `gi[t] ≤ max_imp` (17 kW) — řádek 146; plán reálně ordinoval celých 17 kW. §7 řeší jen „nekonečný" import, ne tento případ. v1 (aktivní engine) navíc psal grid +17000. | | Terminal SoC shadow price podhodnocená záporným průměrem buy | **NE (není to příčina dnešního selhání)** | viz §6 — potenciální *sekundární* slabina, ale dnes plán plnou baterii CHTĚL, takže terminal value byla dost vysoká. Není to páka pro tento bug. | | Forecast PV přestřelil → plán nehedgoval | **NE** | Plán hedgoval importem 17 kW (právě proto, že se nespoléhal jen na PV). Forecast PV v 15:00–16:00 sice nadhodnotil (plán pv_a_fc ~3.4 kW, reálně PV spadlo), ale to by řešil ten import, který guard zařízl. | --- ## 5. Páka a návrh fixu (po krocích) ### Páka Jedna funkce, jedna podmínka: `_apply_export_plan_guard` v `backend/services/control/setpoints.py` (a symetricky `export_ban` v `_build_setpoints`). Guard se nesmí spouštět, když plán **importuje / nabíjí baterii** (fyzický režim CHARGE, resp. `grid_setpoint_w > 0` / `battery_setpoint_w > 0`). ### Návrh (krok po kroku) 1. **`_apply_export_plan_guard` (setpoints.py:297):** přidat carve-out pro CHARGE/import. Guard má řešit jen *export*; pokud plán slot je CHARGE (`pi.deye_physical_mode == 'CHARGE'`) nebo importuje na nabití (`grid_setpoint_w > 0` a `battery_setpoint_w > 0`), **vrátit `sp` beze změny**. Konkrétně: ```python pm = str(pi.get("deye_physical_mode") or "").strip().upper() grid_sp = int(pi.get("grid_setpoint_w") or sp.grid_setpoint_w or 0) bat_sp = int(pi.get("battery_setpoint_w") or 0) is_grid_charge = pm == "CHARGE" or (grid_sp > 0 and bat_sp > 0) if is_grid_charge: return sp # import na nabití baterie není export; sell<0 zakazuje jen export ``` (umístit hned po `if mode.mode_code != "AUTO" or pi is None: return sp`). 2. **`_build_setpoints` `export_ban` (setpoints.py:129):** zpřesnit, aby se nenastavil u nabíjecího slotu: ```python export_ban = ( sell_f is not None and float(sell_f) < 0 and grid_sp >= 0 and not (pm == "CHARGE" or (grid_sp > 0 and bat_w > 0)) ) ``` Tím se neaktivuje MI cut-off (reg 178) / 145=0 jen kvůli tomu, že slot importuje při záporném sell. Pole B přebytek při importu jde do baterie/zátěže, nikoli do sítě — `gen_cutoff` netřeba. (Pozor: zkontrolovat interakci s pravidlem 6 / BA81 — viz §7.) 3. **Bez tvrdé změny chování pro skutečný export:** Když plán slot je PASSIVE/SELL s `grid_setpoint_w >= 0` a `sell < 0` (tj. opravdu by mohl nechtěně exportovat), guard se chová jako dosud. Fix mění chování **jen** pro nabíjecí (CHARGE/import) sloty. ### Alternativa (defensivnější) Místo `deye_physical_mode`/setpoints rozhodovat čistě na `grid_setpoint_w > 0` (import) → guard přeskočit. Plus: nezávisí na tom, zda solver vyplnil `deye_physical_mode`. Minus: PASSIVE import bez nabíjení (`bat=0, grid>0`) by guard přeskočil i tam, kde to nevadí (žádný export beztak nehrozí, protože importujeme). Doporučuji kombinaci: `grid_sp > 0` (import) → return sp; tím je carve-out robustní i bez `battery_setpoint_w`. --- ## 6. Sekundární zjištění (NE příčina dnešního bugu, ale stojí za sledování) **Terminal SoC shadow price při dlouhém záporném okně.** `solver_v2._terminal_value_czk_per_wh` (solver_v2.py:90) = `max(0, avg_buy_24h) × factor / 1000`. Když je průměr buy prvních 24 h stažený dolů zápornými cenami (dnes 13:00–15:45 záporné), `avg_buy` klesá → terminal hodnota zbytkové energie na večer je nižší. Dnes to **nezpůsobilo podnabití** (plán plnou baterii chtěl, protože večerní špička 3.8–4.7 Kč/kWh přebila vše), ale na hraně (mělčí večerní špička) by podhodnocená terminal value mohla vést k tomu, že plán nebude motivován plně nabít. **Doporučení:** zvážit, zda terminal value nepočítat z *kladné* části budoucích buy (např. percentil/medián kladných buy ve zbytku horizontu), ne z prostého průměru, který záporné ceny umělaá stahují. **Toto je samostatná otázka, ne fix dnešního incidentu** — viz `docs/06-open-questions.md`. --- ## 7. Co ověřit (před nasazením fixu) 1. **Regrese pravidla 6 / BA81 (2026-06-12):** carve-out se NESMÍ rozšířit na sloty, kde plán EXPORTuje při kladném sell, ani povolit export baterie/pole B při `sell < 0`. Fix se týká jen import/charge slotů (`grid_setpoint_w > 0`). Ověřit, že u BA81 (cutoff při sell +1.36) a KV1 (`block_export_on_negative_sell=true`) se chování nemění — tam guard pro export běží dál. 2. **Golden gate (`backend/tests/golden/test_golden_replay.py`):** Fix je v control exporteru, **ne** v plánovači → plánovací výstupy (planning_interval) se nemění → golden replay by měl zůstat zelený. Spustit pro jistotu (`pytest backend/tests/golden`). 3. **solver_v2_eval (`scripts/harness/solver_v2_eval.py`):** rovněž bez dopadu (control exporter mimo solver). Ověřit, že se eval čísla nezmění. 4. **Unit test guardu:** přidat test `_apply_export_plan_guard`, že u `deye_physical_mode='CHARGE', grid_setpoint_w=17000, sell<0` **vrátí sp beze změny** (mode zůstane CHARGE), a u `PASSIVE, grid>=0, sell<0` dál vynutí PASSIVE no-export. 5. **Běžný letní den bez záporných cen:** žádný slot nemá `sell < 0` → guard se beztak nespouští → fix nemá dopad. (Ověřeno logikou; carve-out aktivní jen při sell<0 + import.) 6. **Po nasazení živě (MCP):** příští den se záporným nákupem zkontrolovat `modbus_command` — že u CHARGE slotů jsou zapsané reg **108** (charge proud) + TOU **154/172** (grid-charge flag=1) a `deye_physical_mode='CHARGE'`, a že `telemetry_inverter.grid_power_w` ukazuje reálný import během záporných cen. --- ## 8. Soubory (relevantní) - `backend/services/control/setpoints.py` — `_apply_export_plan_guard` (272), `_passive_no_export_guard` (236), `_build_setpoints` export_ban (95/129). **Místo fixu.** - `backend/services/control/orchestrator.py:101` — volání guardu před zápisem. - `backend/services/control/inverter.py:81` — `get_deye_mode(setpoints_now)`; `:243` drop unchanged regs; `:290` journal `deye_physical_mode`. - `backend/services/planning/solver_v2.py:90` — terminal value (sekundární zjištění). - DB: `ems.planning_interval`, `ems.modbus_command`, `ems.telemetry_inverter`, `ems.vw_site_effective_price`.