_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>
13 KiB
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)
orchestrator.export_setpoints(orchestrator.py:101) volá_apply_export_plan_guard(site_id, mode, pi_now, sp_now)._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ý), alegrid_sp >= 0platí stejně jako u „neexportuji"._apply_export_plan_guard(setpoints.py:297):neg_sell = sell_f is not None and float(sell_f) < 0→ True. Podmínkaif not neg_sell and not plan_no_export: return spneprojde → guard pokračuje a volá_passive_no_export_guard(sp, hard_ban=True)._passive_no_export_guard(setpoints.py:261): vrací setpoints sdeye_physical_mode="PASSIVE",export_mode="NONE",export_ban=True,deye_gen_cutoff_enabled=True.battery_wzůstane (line 248–250 jen ořízne záporné), ale fyzický režim je PASSIVE →deye_battery_charge_discharge_ampsjde 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)
-
_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 > 0abattery_setpoint_w > 0), vrátitspbeze 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). -
_build_setpointsexport_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_cutoffnetřeba. (Pozor: zkontrolovat interakci s pravidlem 6 / BA81 — viz §7.) -
Bez tvrdé změny chování pro skutečný export: Když plán slot je PASSIVE/SELL s
grid_setpoint_w >= 0asell < 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)
- 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. - 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). - solver_v2_eval (
scripts/harness/solver_v2_eval.py): rovněž bez dopadu (control exporter mimo solver). Ověřit, že se eval čísla nezmění. - Unit test guardu: přidat test
_apply_export_plan_guard, že udeye_physical_mode='CHARGE', grid_setpoint_w=17000, sell<0vrátí sp beze změny (mode zůstane CHARGE), a uPASSIVE, grid>=0, sell<0dál vynutí PASSIVE no-export. - 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.) - 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) adeye_physical_mode='CHARGE', a žetelemetry_inverter.grid_power_wukazuje 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_setpointsexport_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);:243drop unchanged regs;:290journaldeye_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.