# 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`.
