Files
ems/docs/audits/planner-neg-buy-charge-not-executed-2026-06-13.md
Dusan Vojacek 521a3653d3 Faze 0A: battery guard carve-out — neblokovat import na nabiti pri zaporne cene
_apply_export_plan_guard / _build_setpoints: kdyz slot CHARGE / importuje na
nabiti baterie (grid_sp>0 & bat>0), guard vrati sp beze zmeny a export_ban se
nenastavi. Opravuje, ze se baterie nedobila v zapornych cenach (CHARGE+17kW
prekloplen na PASSIVE -> Deye nenabijel ze site). Diagnoza: agent a599eecc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:40:18 +02:00

225 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:0015:45
naplánoval **CHARGE + grid_setpoint = +17 000 W** (import 17 kW ze sítě) a SoC cíl
šplhal 41 % → ~96100 % 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.84.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 **1520 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:0015:45 | **0.47 … 0.95** (záporný) | 0.94 … 1.63 (záporný) |
| 16:0018:30 | +0.45 … +1.42 | 0.33 … 0.93 (stále záporný) |
| 18:4521: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:0015: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 248250 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 96100 %; `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:0016: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:0015: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.84.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`.