fix soc v TOU (ne 100) pri ne-grid-charge
Some checks failed
CI and deploy / migration-check (push) Failing after 15s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-02 12:15:40 +02:00
parent fffe6c7185
commit b20cb6e0f9
5 changed files with 47 additions and 49 deletions

View File

@@ -108,7 +108,7 @@ Projekt je **SQL-first**: doménová logika, agregace, joiny mezi tabulkami a st
17. **Modbus zápis = journal.** Každý zápis do zařízení přes control exporter se loguje do `ems.modbus_command`. **Verifikační job** běží každé **2 minuty** a ověřuje nedávno zápis (`written` → čtení registru). Při **mismatch** po max. **3** pokusech o zápis → u běžných registrů přepnutí na **SELF_SUSTAIN** (`run_fn_set_mode_with_discord``fn_set_mode`, `activated_by` = `system:mismatch`) + **Discord** při skutečné změně režimu. **Výjimka:** souvislý blok Deye **6264** (čas) → po 3 neúspěšných ověřeních **bez** změny režimu, kritický **Discord** (`notify_modbus_clock_verify_exhausted`). **Obecně:** při jakékoli změně `mode_code` z Pythonu (`POST /api/v1/sites/{id}/mode`, mismatch → SELF_SUSTAIN, `fn_expire_modes`) lze Discord zapnout přes `DISCORD_WEBHOOK_URL`. Detail: `docs/04-modules/modbus-command-journal.md`.
18. **Deye zápis registrů 60499:** pouze **FC 0x10** (`write_registers`), **nikdy** FC 0x06 pro tento rozsah; **`execute_modbus_commands`** slučuje souvislé adresy do jednoho FC 0x10. **Fyzický režim Deye** (`PASSIVE` / `CHARGE` / `SELL`): **výhradně** `get_deye_mode` v `exporter_monolith.py` (bez wattových prahů: **SELL** při `battery_w` < 0 a `grid_setpoint_w` < 0; **CHARGE** při obou > 0; jinak **PASSIVE**). **PASSIVE (ZERO, AUTO):** **108/109** dle `_deye_zero_export_amps_for_passive`; **TOU** z plánu. **SELL:** 108=0, 109=max, **178**=32 (peak shaving off), **143** omezeno podle `|grid_setpoint_w|`; **142/145/TOU** jako v `write_inverter_setpoints`. **Reg 340** (*max solar power*, W): jen pokud `ems.fn_site_has_active_green_bonus_pv(site_id)` (lokalita se zeleným bonusem na PV poli) **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0`; hodnota z `pv_a_forecast_solver_w` / `pv_a_curtailed_w` (AUTO). **Není** v `DEYE_CRITICAL_REGS_SELF_SUSTAIN` — verify mismatch nečeká přepnutí do SELF_SUSTAIN. **PRESERVE:** `lock_battery` → 108/109=0. **Čas 6264**, bloky TOU **12** vs **36**, verify, Discord: beze změny oproti dřívějšímu chování — plný popis **`docs/04-modules/modbus-registers.md`** a **`docs/04-modules/operating-modes.md`**.
18. **Deye zápis registrů 60499:** pouze **FC 0x10** (`write_registers`), **nikdy** FC 0x06 pro tento rozsah; **`execute_modbus_commands`** slučuje souvislé adresy do jednoho FC 0x10. **Fyzický režim Deye** (`PASSIVE` / `CHARGE` / `SELL`): **výhradně** `get_deye_mode` v `exporter_monolith.py` (bez wattových prahů: **SELL** při `battery_w` < 0 a `grid_setpoint_w` < 0; **CHARGE** při obou > 0; jinak **PASSIVE**). **PASSIVE (ZERO, AUTO):** **108/109** dle `_deye_zero_export_amps_for_passive`; **TOU SOC** (reg 166+): **PASSIVE** = **`min_soc_percent`**, **CHARGE** = **`max_soc_percent`** (clamp 10100 z DB), **SELL** = **`reserve_soc_percent`** (`_deye_passive_tou_battery_soc_pct`, `_deye_tou_params`). **SELL:** 108=0, 109=max, **178**=32 (peak shaving off), **143** omezeno podle `|grid_setpoint_w|`; **142/145/TOU** jako v `write_inverter_setpoints`. **Reg 340** (*max solar power*, W): jen pokud `ems.fn_site_has_active_green_bonus_pv(site_id)` (lokalita se zeleným bonusem na PV poli) **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0`; hodnota z `pv_a_forecast_solver_w` / `pv_a_curtailed_w` (AUTO). **Není** v `DEYE_CRITICAL_REGS_SELF_SUSTAIN` — verify mismatch nečeká přepnutí do SELF_SUSTAIN. **PRESERVE:** `lock_battery` → 108/109=0. **Čas 6264**, bloky TOU **12** vs **36**, verify, Discord: beze změny oproti dřívějšímu chování — plný popis **`docs/04-modules/modbus-registers.md`** a **`docs/04-modules/operating-modes.md`**.
19. **Baterie export v LP:** V `solve_dispatch` binárka `z_export[t]`: pokud `grid_export` v daném slotu **≥ 1** W, platí koncové `soc[t] ≥ arb_base_wh` (ekonomická rezerva z DB, ne časová řada `arb_floor_series`). Bez exportu může plán jít k `min_soc_percent` (provozní podlaha; u paralelních packů často 1112 %, migrace V029 + komentář sloupce).

View File

@@ -40,9 +40,6 @@ REG143_SELL_CAP_MIN_W = 200
# Ostatní bity zachovat → read-modify-write.
REG178_SELL = 0b00100000 # 32, grid peak shaving disable
REG178_PASSIVE = 0b00110000 # 48, grid peak shaving enable (PASSIVE i CHARGE)
# TOU reg 166+ ve PASSIVE při prioritě baterie: signál střídači „využij celý dostupný rozsah“,
# ne provozní strop z DB (ten je pro LP / Wh viz asset_battery.max_soc_percent).
DEYE_TOU_SOC_PASSIVE_BATTERY_PRIORITY_PCT = 100
# Verify: jen bity 45 (horní byte layout v dokumentaci); ostatní bity mohou mít firmware / Loxone
REG178_VERIFY_MASK = 0x0030
# Reg 178 bits 01: MI export cutoff (AC coupling / GEN).
@@ -1411,11 +1408,6 @@ def _clamp_deye_tou_soc_pct(pct: int) -> int:
return max(5, min(95, pct))
def _clamp_deye_tou_soc_pct_hi(pct: int, hi: int) -> int:
"""Stejné dolní omezení 5 % jako u TOU; horní mez z parametru (např. 100 u priority baterie)."""
return max(5, min(int(hi), int(pct)))
def _deye_tou_min_soc_pct(inv: InverterConfig) -> int:
if inv.min_soc_percent is not None:
return _clamp_deye_tou_soc_pct(int(inv.min_soc_percent))
@@ -1429,39 +1421,23 @@ def _deye_tou_reserve_soc_pct(inv: InverterConfig) -> int:
def _deye_passive_tou_battery_soc_pct(
inv: InverterConfig,
setpoints: ControlSetpoints,
inv: InverterConfig, _setpoints: ControlSetpoints
) -> int:
"""
Hodnota SOC u Deye TOU řádku (reg 166+) ve fyzickém PASSIVE.
Na home-01 Deye interpretuje TOU % jako „kam má směřovat využití baterie“:
je-li zapsané procento **nižší než skutečný SoC**, přebytek FVE míří spíš do sítě.
Vždy provozní minimum z DB (**``min_soc_percent``**, clamp 595 jako u všech TOU SOC)
— signál „spodní pásmo“ pro firmware, aby baterii šlo použít pro překrytí zátěže bez
snahy o vysoký cílový SoC jen přes TOU.
Při **záporné vykupní** nebo **plánovaném nabíjení** (kladný ``battery_w``) EMS
zapíše **100 %** do TOU (signál střídači „ber přebytek do baterie v celém rozsahu“).
**``max_soc_percent`` v DB** je odděleně: horní limit pro **plánovač / Wh bilance**
(denní provoz, viz komentář sloupce), **nikoli** časové „do kdy“.
Riziko spojené v minulosti s nízkým TOU („přebytek FVE tíhne do sítě“ při nízkém %
oproti skutečnému SoC) řeší **LP**, **145** (**``export_ban``** při záporné vykupní),
řez GEN (**178**) a další páky — ne zvyšování TOU nad **min_soc**. Přímé dobíjení ze
sítě a cílové horní pásmo: větev **CHARGE** v ``_deye_tou_params`` (**``max_soc_percent``**).
Jinak zůstane provozní podlaha ``min_soc_percent`` (typicky nízká % → přetok do sítě
možný dle chování Deye).
Režim **SELF_SUSTAIN** (``self_sustain_local_use``): vždy ``min_soc_percent`` — nízké
TOU drží prioritu „baterie jako buffer“ při plném reg. 108/109 a reg. 142 zero-export;
neaplikuje se sem logika 100 % podle ceny (LP se v SELF_SUSTAIN nepoužívá).
Argument ``_setpoints`` zůstává kvůli volajícím API; hodnoty z něj PASSIVE SOC nebere.
"""
mn = _deye_tou_min_soc_pct(inv)
if setpoints.self_sustain_local_use:
return mn
bat_w = 0 if setpoints.battery_w is None else int(setpoints.battery_w)
sell = setpoints.effective_sell_price_czk_kwh
want_battery_priority = bat_w > 0 or (sell is not None and float(sell) < 0)
if not want_battery_priority:
return mn
return _clamp_deye_tou_soc_pct_hi(DEYE_TOU_SOC_PASSIVE_BATTERY_PRIORITY_PCT, hi=100)
return _deye_tou_min_soc_pct(inv)
def _deye_zero_export_amps_for_passive(
@@ -1521,7 +1497,8 @@ def _deye_tou_params(
) -> tuple[int, int, bool]:
"""
Parametry jednoho Deye time pointu: výkon W, SOC % (TOU reg 166+), grid_charge.
Ve PASSIVE viz _deye_passive_tou_battery_soc_pct (min vs. plný max z DB).
Ve PASSIVE: TOU SOC = ``min_soc_percent`` z DB; v CHARGE: horní hraniční SoC =
``asset_battery.max_soc_percent`` (clamp 10100).
"""
max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V)
tp_discharge_w = 0 if setpoints.lock_battery else max_batt_w_discharge
@@ -1534,7 +1511,7 @@ def _deye_tou_params(
raw_bat = setpoints.battery_w
battery_w = int(raw_bat) if raw_bat is not None else 0
cap = int(inv.max_soc_percent) if inv.max_soc_percent is not None else 95
target_soc = max(10, min(95, cap))
target_soc = max(10, min(100, cap))
tp_charge_w = (
battery_watts_to_amps(battery_w, int(inv.max_charge_a)) * int(BATT_VOLTAGE_V)
)

View File

@@ -158,8 +158,8 @@ class DeyeTouParamsTests(unittest.TestCase):
self.assertFalse(g)
self.assertEqual(s, 12)
def test_passive_negative_sell_steers_tou_above_current_soc(self) -> None:
"""Záporná vykupní → TOU SOC = 100 % (priorita akumulace vs. přetok)."""
def test_passive_negative_sell_tou_stays_min_soc(self) -> None:
"""PASSIVE: záporná vykupní nenastavuje TOU na 100 — zůstává min_soc (145/export_ban řeší síť)."""
sp = ControlSetpoints(
battery_w=-400,
grid_export_limit=0,
@@ -175,9 +175,10 @@ class DeyeTouParamsTests(unittest.TestCase):
self.assertEqual(get_deye_mode(sp), "PASSIVE")
_p, s, g = _deye_tou_params(sp, _inv(min_soc=12, reserve_soc=20))
self.assertFalse(g)
self.assertEqual(s, 100)
self.assertEqual(s, 12)
def test_passive_planned_charge_steers_tou(self) -> None:
def test_passive_planned_pv_charge_tou_stays_min_soc(self) -> None:
"""PASSIVE s kladným battery_w bez grid importu: CHARGE to není — TOU je stále min_soc."""
sp = ControlSetpoints(
battery_w=800,
grid_export_limit=0,
@@ -193,7 +194,7 @@ class DeyeTouParamsTests(unittest.TestCase):
self.assertEqual(get_deye_mode(sp), "PASSIVE")
_p, s, g = _deye_tou_params(sp, _inv(min_soc=12, reserve_soc=20))
self.assertFalse(g)
self.assertEqual(s, 100)
self.assertEqual(s, 12)
def test_charge_unchanged_grid_charge(self) -> None:
sp = ControlSetpoints(
@@ -212,6 +213,24 @@ class DeyeTouParamsTests(unittest.TestCase):
self.assertTrue(g)
self.assertEqual(s, 95)
def test_charge_target_soc_respects_max_soc_100(self) -> None:
sp = ControlSetpoints(
battery_w=5000,
grid_export_limit=0,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=5000,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=80,
)
self.assertEqual(get_deye_mode(sp), "CHARGE")
inv = replace(_inv(), max_soc_percent=100)
_p, s, g = _deye_tou_params(sp, inv)
self.assertTrue(g)
self.assertEqual(s, 100)
def test_charge_any_positive_pair_without_w_threshold(self) -> None:
sp = ControlSetpoints(
battery_w=50,

View File

@@ -168,11 +168,11 @@ bits 01). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg
| **145** (solar sell) | 1 | 1 | 1 | 1 |
| **178** (peak shaving) | 48 | 48 | **32** | 48 |
U **AUTO PASSIVE** závisí **108/109** na znaménkách plánu (viz `operating-modes.md`). **SELF_SUSTAIN** drží oba **max z DB**; liší se **TOU SOC** a `battery_w`.
U **AUTO PASSIVE** závisí **108/109** na znaménkách plánu (viz `operating-modes.md`). **SELF_SUSTAIN** drží oba **max z DB**; **TOU SOC** ve všech PASSIVE větvích je **`min_soc_percent`** (viz `_deye_passive_tou_battery_soc_pct`). Liší se především **`battery_w`** a mapování **108/109**.
Hodnota `deye_zero_export_mode` (1 = zero export to load, 2 = zero export to CT) pochází z `asset_inverter.deye_zero_export_mode` a závisí na fyzické instalaci (přítomnost CT). Detail v [`modbus-registers.md`](modbus-registers.md).
**TOU (time points, reg. 166+):** SOC závisí na fyzickém režimu z `get_deye_mode`**SELL** zapisuje ekonomickou rezervu (`reserve_soc_percent`), **PASSIVE** a neaktivní řádky **36** provozní minimum (`min_soc_percent`). Viz [`modbus-registers.md`](modbus-registers.md).
**TOU (time points, reg. 166+):** SOC podle `get_deye_mode`**CHARGE**: `max_soc_percent` z DB (clamp 10100), **SELL**: `reserve_soc_percent`, **PASSIVE** + neaktivní řádky **36**: **`min_soc_percent`**. Viz [`modbus-registers.md`](modbus-registers.md).
### Verifikace zápisů (journal) a SELF_SUSTAIN

View File

@@ -85,7 +85,7 @@ Z hlediska `get_deye_mode` je **SELF_SUSTAIN** stále **PASSIVE** (`battery_w` z
- **108 / 109** = **max** z invertoru (DB) — plný rozsah nabíjení i vybíjení, aby přebytek FVE mohl do baterie.
- **142** = `asset_inverter.deye_zero_export_mode` (**1** = zero export to load, **2** = zero export to CT), stejně jako u ostatního PASSIVE mimo SELL.
- **TOU SOC** (reg 166+) = vždy **`min_soc_percent`** (typicky 12 %) — `_deye_passive_tou_battery_soc_pct` při tomto příznaku **ne** přepíná na 100 % podle vykupní ceny, protože LP se v SELF_SUSTAIN nepoužívá.
- **TOU SOC** (reg 166+) = vždy **`min_soc_percent`** (typicky 12 %) — stejně jako u běžného **AUTO PASSIVE**: akumulace vs. síť řeší plán a **145** / **178**, ne výška TOU %.
### Čtyři typy slotů a mapování na registry
@@ -102,9 +102,11 @@ Solver předvybírá sloty pro nabíjení a export-vybíjení (`_select_charge_s
| **178** peak shaving | 48 (PASSIVE) | 48 (PASSIVE) | **32** (SELL) | 48 (PASSIVE) |
| **143** export limit | max export W z DB | max export W z DB | max export W z DB | max export W z DB |
| **141** energy mode | 0 | 0 | 0 | 0 |
| **TOU SOC** (reg 166+) | viz níže | min_soc_pct | reserve_soc_pct | min_soc_pct |
| **TOU SOC** (reg 166+) | **`max_soc_percent`** (clamp 10100), grid charge ON | **`min_soc_percent`** z DB | reserve_soc_pct | min_soc_pct |
**PASSIVE TOU SOC % (home-01 / Deye):** EMS ukládá do řádku time pointu procento, které na zařízení řídí **prioritu baterie vs. přetok FVE do sítě** (viz firmware / instalace). Je-li zapsané procento **níž než skutečný SoC**, přebytek tíhne do sítě; při **záporné efektivní vykupní** (`effective_sell_price` ze slotu) nebo při **kladném `battery_setpoint_w`** (plánované nabíjení) EMS nastaví **100 %** (signál „využij baterii naplno“) — **ne** v režimu **SELF_SUSTAIN** (`self_sustain_local_use`), tam je vždy **`min_soc_percent`**. **`asset_battery.max_soc_percent`** (typicky 95) je **jiný účel**: horní limit pro **plánovač / denní provoz v % SoC** (komfort, degradace, rezerva výrobce), **ne** časové „do kdy“ ani hodnota zapisovaná do tohoto TOU při této priorité. Jinak zůstane **`min_soc_percent`**.
**PASSIVE TOU SOC % (Deye):** EMS zapisuje vždy **`min_soc_percent`** z ``asset_battery`` (clamp jako u všech TOU SOC 595). Slouží jako spodní pásmový signál pro firmware; výšku nepoužíváme k řízení „honit akumulaci na 100 %“ ve PASSIVE — to u levného importu řeší **108/109** (viz ``operating-modes.md``), u záporné vykupní **BLOCK_EXPORT** přes **`export_ban`** → **145**, případně **178** na GEN.
**CHARGE:** TOU řádek nese **`max_soc_percent`** z DB (**clamp 10100**) jako cíl při **grid charge** (spolu s příznakem grid charge v time pointu). **Energy pattern** („load first“ / „battery first“) v UI střídače zůstává v kompetenci instalace — EMS ho přes Modbus nenastavuje.
**Jak funguje pass-through fyzicky:**
@@ -147,9 +149,9 @@ Příklad v 14:18: blok 1 má čas **1415**, blok 2 čas **1430** mezi 14:15
| Režim | Výkon (W) | SOC min (reg 166+) | Grid charge |
|-------|-----------|---------------------|-------------|
| **PASSIVE** | `max_discharge_a × 51,2` | `_deye_passive_tou_battery_soc_pct`: při neg. vykupní / plánovaném nabíjení = **100 %**, jinak **`min_soc_percent`** | NE |
| **PASSIVE** | `max_discharge_a × 51,2` | **`min_soc_percent`** z DB (**`_deye_passive_tou_battery_soc_pct`**) | NE |
| **SELL** | `max_discharge_a × 51,2` | **`reserve_soc_percent`** z DB | NE |
| **CHARGE** | `battery_watts_to_amps(battery_w, max_charge_a) × 51,2` | min(95, cíl SoC z plánu nebo 80) | ANO |
| **CHARGE** | `battery_watts_to_amps(battery_w, max_charge_a) × 51,2` | **clamp**(10 … **100**, **`asset_battery.max_soc_percent`**) | ANO |
Bloky 36 používají čas **2355** a stejnou **SOC** hodnotu jako PASSIVE (`min_soc_percent`, grid charge = NE).