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

13 KiB
Raw Blame History

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 >= 0True (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) < 0True. 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 PASSIVEdeye_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ě:

    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:

    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:81get_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.