Fix SoC balance on battery export and improve evening push (v39).
Some checks failed
CI and deploy / migration-check (push) Failing after 38s
CI and deploy / deploy (push) Has been skipped

SoC continuity now deducts only bd (ge_bat was double-counted via energy
balance), which stopped the plan from draining ~2× faster than BMS during
evening BATTERY_SELL. Also ships dynamic evening push budget + rolling
hysteresis (v38), drops unused fn_soc_tracking_bundle, and adds tests/docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Dusan Vojacek
2026-05-29 00:04:48 +02:00
parent 52e4b68789
commit ba0b55bf10
5 changed files with 432 additions and 58 deletions

View File

@@ -9,7 +9,7 @@
- **SQL-first:** horizont a sloty z DB funkcí (`fn_planning_horizon_end`, `fn_load_planning_slots_full`, …); viz **`CLAUDE.md`** → sekce *SQL-first a read-model*.
- **Dynamický horizont (jen OTE):** konec plánu z **`ems.fn_planning_horizon_end(site_id, horizon_start)`** (výchozí strop **36 h**, minimum pro rolling **1 h** obojí jako defaultní argumenty v SQL, úprava přes repeatable migraci). Pomocná `ems.fn_last_effective_ote` vrací konec posledního OTE intervalu. Rolling replan při `NULL` přeskočí; denní plán použije krátký (1 h) fallback v Pythonu. Sloty v solveru jsou bez predikovaných cen v rámci tohoto horizontu.
- **Terminal SoC shadow price:** v objective je člen `(avg_buy_prvních_24h × planner_terminal_soc_value_factor / 1000) × soc[T1]` (Kč), kde faktor je **`ems.asset_battery.planner_terminal_soc_value_factor`** přes **`ems.fn_planning_site_context`** (default v DB **0.9**); viz sekci *Tuning pro malé baterie* níže. Účel: konec horizontu nemusí končit zbytečně vyprázdněnou baterií (receding horizon).
- **SoC kontinuita a export z baterie:** `soc[t]` klesá při **`bd[t]` i `ge_bat[t]`** (vybíjení do domu i do sítě). Bez `ge_bat` v bilanci SoC LP „exportovalo“ bez vybití — arbitrážní dump v pozdních slotech místo ranního peaku.
- **SoC kontinuita a export z baterie:** `soc[t]` klesá při **`bd[t]`** — výkon vybíjení na AC sběrnici z energetické bilance `pv + gi + bd = load + bc + ge`. Při exportu z baterie je v `bd` už započten i tok do sítě (`ge_bat` je součást `ge`); **`ge_bat` se v SoC znovu neodečítá** (dříve double-count → plán klesal ~2× rychleji než BMS ve večerním exportu). Tag `2026-05-28-evening-export-soc-balance-v39`.
- **Masky `allow_charge` / `allow_discharge_export` (tenký anti-mikrocyklus):** generuje `ems.fn_load_planning_slots_full` (`R__063`). Ekonomiku primárně řídí LP podle efektivních cen; masky jen omezují počet slotů pro grid nabíjení / export baterie.
- **PV-surplus (vrstva A):** ranking dle **`store_score DESC`** = `future_sell_opportunity sell max(0, buysell)`; jen sloty s `sell ≥ buy degradation`. Kumulativní PV pokrývá `grid_target` (deficit SoC, nad `reserve_soc` bez násobení `charge_slot_buffer`). Zbytek → `allow_charge=false` (PV jen do sítě / `bc ≤ pv_surplus` v LP).
- **Grid ze sítě (vrstva B, před FVE):** výchozí **AM/PM 50/50** z `grid_target × charge_slot_buffer` (do `soc_max`); **nevyčerpaný AM Wh přejde do PM** (`R__063`). **Spot:** výběr **nejlevnější `buy`** (den plánu → před exportním oknem → `buy ASC`); navíc všechny sloty s **`buy < 0`** → `allow_grid_charge`. Po výběru AM/PM běží **iterativní self-konzistentní filtr** (vyloučí drahé grid sloty, pokud `pv_charge_wh_ahead + neg_buy_wh_ahead >= 60 %` deficitu SoC; failsafe unlock). Debug: `grid_charge_suppressed_reason`. **Fixní tarif (BA81):** stejný AM/PM rozpočet, ale pořadí podle **`slot_ord`** (buy konstantní), jen pokud v horizontu existuje **`sell > buy + degradation`**; jinak jen PV vrstva A. Cap slotů: `ceil(budget/per_slot_wh) × charge_slot_buffer`. **`charge_acquisition`:** vážený `buy` u `allow_grid_charge` před 1. exportem; two-pass v `planning_engine.py`.
@@ -85,24 +85,24 @@ Cíl zůstává **maximální ekonomický užitek v celém horizontu**: prodat (
flowchart TD
A[LP: globální optimum v horizontu] --> B{slot >= 17h a profitable export?}
B -->|sell pod nocnim max - 0.05| C[ge_bat = 0: baterie ne pred spickou]
B -->|sell v top pasme max - 0.05| D[evening_push kandidat]
D --> E[Seradit sell desc, pridat sloty az do Wh rozpoctu]
E --> F[ge_bat >= plny vykon na cap v kazdem push slotu]
B -->|profitable + nocni okno| D[push: sell desc az do Wh rozpoctu]
D --> F[ge_bat >= plny vykon na cap v kazdem push slotu]
C --> G[Vysledek: energie zustane na nejdrazsi vecer]
F --> G
```
1. **SQL masky (R__063, vrstva 2)** — které večerní sloty *smí* export z baterie vůbec (`allow_discharge_export`): mimo jiné sloty v pásmu „denní večerní max degrad“ (SQL), plus globální Wh rozpočet (vrstva 1).
2. **v27 — zákaz předčasného večerního vývozu** (`evening_early_export_penalty_ts` → tvrdé `ge_bat[t] = 0`):
- jen v **nočním okně** (`_in_night_battery_export_window`) a **časově před** prvním slotem v `evening_push_ts`;
- jen pokud `sell < max_sell_v_nočním_úseku 0,05` (v30: max přes půlnoc, ne per kalendářní den);
2. **v38 — zákaz předčasného / levného večerního vývozu** (`evening_early_export_penalty_ts` → tvrdé `ge_bat[t] = 0`):
- v **nočním okně** pro profitable sloty **mimo** `evening_push_ts` (včetně slotů **po** prvním push — v27 je omezoval jen na čas před prvním push);
- pokud `sell < max_sell_v_nočním_úseku 0,05` (v30: max přes půlnoc);
- **nezakazuje** přebytek FVE do sítě (`ge_pv`).
3. **v24 + v27 — plný výkon v top večerních slotech** (`evening_push_ts`):
- kandidáti: profitable ∩ večer`sell ≥ max_večer 0,05` (úzké pásmo u **absolutní** večerní špičky, ne široké „peakdegrad“ pro push);
- řazení podle **`sell` sestupně**;
- přidávat sloty, dokud `kumulované_Wh ≤` rozpočet (`discharge_slot_buffer`, SoC nad `min_soc`);
3. **v38 — plný výkon v top večerních slotech** (`evening_push_ts`):
- kandidáti: profitable ∩ noční okno`sell ≥ 0`;
- push = nejdražší sloty **seřazené `sell` desc**, dokud `kumulované_Wh ≤ push_budget` (`min(available_soc, exportable_full × discharge_slot_buffer)`; `per_slot` ≈ max_discharge × účinnost × 0,25 h) — **počet slotů dynamický** (ne pevné top-3);
- při vysokém SoC může být push slotů víc než 3 (např. 40+ kWh rozpočet → ~912 slotů podle `per_slot`);
- **rolling hysteresis:** při `|Δ peak sell| < 0,5` Kč a `|Δ SoC| < 5 %` držet `evening_push_ts` z předchozího aktivního runu (`_rolling_evening_push_override`);
- **v28 push fyzika:** cap `ge_bat ≈ min(export_cap, max_discharge load)` a v push slotech BMS `load + ge_bat ≤ max_discharge` (ne `bd+ge_bat`, které dvojí započítávalo export); odpovídá Deye SELL — load z baterie, zbytek do sítě až po site cap;
- **výsledek:** jeden nejdražší slot → export řádově kW; další drahé sloty **po** prvním push mohou exportovat dle ekonomiky LP.
@@ -116,7 +116,7 @@ flowchart TD
| Měkká `peak_export_shortfall` → často ~50 % výkonu v mnoha slotech | Na `evening_push` slotech tvrdý push na cap; shortfall na push vypnutý |
| `grid_setpoint = gi ge` → Deye vidí ~0 W při velkém `ge_bat` | `_dispatch_grid_setpoint_w` z reálného exportu |
**Funkce:** `_evening_battery_export_push_indices`, `_evening_push_discharge_budget_wh`, `_evening_push_battery_export_w`, `_dispatch_grid_setpoint_w` v `planning_engine.py`. Tag: `2026-05-28-evening-peak-full-export-v28`.
**Funkce:** `_evening_battery_export_push_indices`, `_evening_early_export_penalty_indices`, `_rolling_evening_push_override`, `_evening_push_discharge_budget_wh`, `_evening_push_battery_export_w`, `_dispatch_grid_setpoint_w` v `planning_engine.py`. Tag: `2026-05-28-evening-export-dynamic-v38`.
### Arbitráž baterie — účtování mezi sloty (povinné čtení)
@@ -301,9 +301,11 @@ if ev_session[e].target_deadline and ev_session[e].soc_at_connect_pct is not Non
### SoC kontinuita
```python
# battery_discharge = bd (W z baterie na AC sběrnici z bilance pv+gi+bd = load+bc+ge).
# ge_bat je součást ge — v SoC znovu neodečítat (v39).
soc[t] == soc[t-1]
+ battery_charge[t] * charge_efficiency * interval_h
- battery_discharge[t] / discharge_efficiency * interval_h
+ (bc_pv[t] + bc_gi[t]) * charge_efficiency * interval_h
- bd[t] / discharge_efficiency * interval_h
soc[0] == current_soc_wh # počáteční podmínka z telemetrie
```