diff --git a/backend/services/control/setpoints.py b/backend/services/control/setpoints.py index 5fa66ab..7851300 100644 --- a/backend/services/control/setpoints.py +++ b/backend/services/control/setpoints.py @@ -125,11 +125,20 @@ def _build_setpoints( export_limit = 0 elif export_limit <= 0 and grid_sp < 0: export_limit = abs(grid_sp) + bat_w = int(pi["battery_setpoint_w"] or 0) # Záporný výkup sám o sobě neblokuje export, pokud plán export explicitně žádá. - export_ban = sell_f is not None and float(sell_f) < 0 and grid_sp >= 0 + # A nesmí blokovat ani IMPORT na nabití baterie (CHARGE / grid>0 & bat>0) — + # jinak MI cut-off (178) / 145=0 zbytečně odstaví pole B a Deye nenabije + # ze sítě v záporných cenách (bug 2026-06-13). §6 blokuje jen export. + is_grid_charge = pm == "CHARGE" or (grid_sp > 0 and bat_w > 0) + export_ban = ( + sell_f is not None + and float(sell_f) < 0 + and grid_sp >= 0 + and not is_grid_charge + ) gen_cutoff_raw = pi.get("deye_gen_cutoff_enabled") gen_cutoff = bool(gen_cutoff_raw) if gen_cutoff_raw is not None else False - bat_w = int(pi["battery_setpoint_w"] or 0) pv_a_allowed: int | None = None if bool(reg340_pv_a_control_enabled) and int(pv_a_cap_w) > 0: forecast = int(pi.get("pv_a_forecast_solver_w") or 0) @@ -294,6 +303,16 @@ def _apply_export_plan_guard( ) grid_sp = int(pi.get("grid_setpoint_w") or sp.grid_setpoint_w or 0) + # Carve-out: nabíjecí / importní slot NENÍ export. Guard řeší jen zákaz + # exportu při sell<0 — když plán importuje na nabití baterie (CHARGE, nebo + # grid_sp>0 & bat_sp>0), překlopení na PASSIVE by zařízlo grid charge + # (bug 2026-06-13: baterie se nedobila v záporných cenách). §6 zakazuje + # jen export, ne import (§7). + pm = str(pi.get("deye_physical_mode") or "").strip().upper() + bat_sp = int(pi.get("battery_setpoint_w") or 0) + if pm == "CHARGE" or (grid_sp > 0 and bat_sp > 0): + return sp + neg_sell = sell_f is not None and float(sell_f) < 0 plan_no_export = export_mode == "NONE" and grid_sp >= 0 if not neg_sell and not plan_no_export: diff --git a/backend/tests/test_control_export_plan_guard.py b/backend/tests/test_control_export_plan_guard.py index 5b3de60..a1f1e07 100644 --- a/backend/tests/test_control_export_plan_guard.py +++ b/backend/tests/test_control_export_plan_guard.py @@ -111,6 +111,29 @@ class ExportPlanGuardTests(unittest.TestCase): self.assertIs(out, sp) self.assertEqual(get_deye_mode(out), "SELL") + def test_neg_sell_grid_charge_not_blocked(self) -> None: + # Záporný sell + IMPORT na nabití baterie (CHARGE / grid>0 & bat>0): + # guard NESMÍ překlopit na PASSIVE — jinak Deye nenabije ze sítě + # v záporných cenách (bug 2026-06-13). + sp = _sp( + grid_setpoint_w=17000, + battery_w=17000, + deye_physical_mode="CHARGE", + export_mode="NONE", + ) + pi = _DictRecord( + { + "grid_setpoint_w": 17000, + "battery_setpoint_w": 17000, + "deye_physical_mode": "CHARGE", + "effective_sell_price": -1.2, + "export_mode": "NONE", + } + ) + out = _apply_export_plan_guard(1, _auto_mode(), pi, sp) + self.assertIs(out, sp) + self.assertEqual(get_deye_mode(out), "CHARGE") + def test_non_auto_mode_skipped(self) -> None: sp = _sp() pi = _DictRecord({"effective_sell_price": -1.0, "export_mode": "NONE"}) diff --git a/docs/audits/planner-neg-buy-charge-not-executed-2026-06-13.md b/docs/audits/planner-neg-buy-charge-not-executed-2026-06-13.md new file mode 100644 index 0000000..1bc54c5 --- /dev/null +++ b/docs/audits/planner-neg-buy-charge-not-executed-2026-06-13.md @@ -0,0 +1,224 @@ +# 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`. diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index cd48a4b..61b9783 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,14 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-06-13 — exekuce: baterie se nedobila v záporných cenách (guard carve-out) + +- **Problém:** buy záporný 13:00–15:45 (−0.47…−0.95 Kč), plán ordinoval CHARGE +17 kW import, SoC cíl ~96 %, ale realita SoC 71 % (nabíjení jen z PV, grid≈0). Večer se dokupovalo ze sítě místo z plné baterie. +- **Příčina:** `_apply_export_plan_guard` / `_build_setpoints` (setpoints.py) — false positive: `sell<0` & `grid_sp>=0` splete IMPORT na nabití s exportní situací a překlopí CHARGE→PASSIVE / nastaví export_ban. V PASSIVE Deye nenabíjí ze sítě. +- **Mechanismus (fix):** carve-out — když slot je CHARGE (`pm=='CHARGE'`) nebo importuje na nabití (`grid_sp>0 & bat>0`), guard vrátí sp beze změny a export_ban se nenastaví. §6 zakazuje jen export, ne import (§7). +- **Soubory:** backend/services/control/setpoints.py, test_control_export_plan_guard.py (test_neg_sell_grid_charge_not_blocked), docs/audits/planner-neg-buy-charge-not-executed-2026-06-13.md. +- **Ověření:** guard testy 47 passed; živě — záporný den → grid_w roste, SoC k cíli. Mimo solver → golden gate / solver_v2_eval beze změny. + ## 2026-06-13 — degradační cena dle skutečných cen packů (V103) - **Problém:** seedy nesly default 0.50 Kč/kWh u KV1/BA81/HU1 — u malých packů zabíjel mělké arbitráže, u HU1 zkresloval studii spotové smlouvy.