diff --git a/CLAUDE.md b/CLAUDE.md index 7af12f0..8343c43 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 **62–64** (č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ů 60–499:** 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):** **`export_mode=PV_SURPLUS`** → **108=0**, **109=max**, **142**=`deye_zero_export_mode` (ne selling first); jinak **108/109** dle `deye_battery_charge_discharge_amps` / `_deye_zero_export_amps_for_passive` (import bez vybíjení → **109=0**); **TOU SOC** (reg 166+): **PASSIVE** = **`min_soc_percent`**, **CHARGE** = **`max_soc_percent`** (clamp 10–100 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)` **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0` (strop z `deye_reg340_max_solar_w`, typ. 32k home-01 / 65k jinde, ne součet Wp; min `deye_reg340_min_solar_w`, home-01 400); hodnota z plánu / curtailu (AUTO). **Není** v `DEYE_CRITICAL_REGS_SELF_SUSTAIN` — verify mismatch nečeká přepnutí do SELF_SUSTAIN. **PRESERVE:** `lock_battery` → 108/109=0. **Čas 62–64**, bloky TOU **1–2** vs **3–6**, 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ů 60–499:** 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):** **`export_mode=PV_SURPLUS`** → **108=0**, **109=max**, **142**=`deye_zero_export_mode` (ne selling first); jinak **108/109** dle `deye_battery_charge_discharge_amps` / `_deye_zero_export_amps_for_passive` (import bez vybíjení → **109=0**); **TOU SOC** (reg 166+): **PASSIVE** = **`min_soc_percent`**, **CHARGE** = **`max_soc_percent`** (clamp 10–100 z DB), **SELL** = **`reserve_soc_percent`** (`_deye_passive_tou_battery_soc_pct`, `_deye_tou_params`). **SELL:** reg **108** EMS **nezapisuje** (selling first = **142**), **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)` **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0` (strop z `deye_reg340_max_solar_w`, typ. 32k home-01 / 65k jinde, ne součet Wp; min `deye_reg340_min_solar_w`, home-01 400); hodnota z plánu / curtailu (AUTO). **Není** v `DEYE_CRITICAL_REGS_SELF_SUSTAIN` — verify mismatch nečeká přepnutí do SELF_SUSTAIN. **PRESERVE:** `lock_battery` → 108/109=0. **Čas 62–64**, bloky TOU **1–2** vs **3–6**, 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 11–12 %, migrace V029 + komentář sloupce). diff --git a/backend/services/control/inverter.py b/backend/services/control/inverter.py index a9f5f42..424b534 100644 --- a/backend/services/control/inverter.py +++ b/backend/services/control/inverter.py @@ -98,10 +98,11 @@ async def write_inverter_setpoints( export_limit = export_lim reg178_val = REG178_SELL if deye_mode == "SELL" else REG178_PASSIVE + charge_a_log = charge_a if charge_a is not None else "skip" logger.info( f"[control] site={site_id} fyzický režim Deye: {deye_mode} | " f"battery_w={raw_bat!r} grid_w={grid_w} | " - f"charge_a={charge_a} discharge_a={discharge_a} | " + f"charge_a={charge_a_log} discharge_a={discharge_a} | " f"reg142={selling_mode} reg145={solar_sell} reg143={export_limit}W reg178={reg178_val}" ) @@ -164,10 +165,13 @@ async def write_inverter_setpoints( "Deye TOU rows 3-6 skipped (already written today, signature unchanged)" ) + amp_regs: list[tuple[int, str, int]] = [] + if charge_a is not None: + amp_regs.append((108, "", charge_a)) + amp_regs.append((109, "", discharge_a)) registers.extend( - [ - (108, "", charge_a), - (109, "", discharge_a), + amp_regs + + [ (141, "energy_mode (0)", 0), (142, "limit_control", selling_mode), (143, "", export_limit), diff --git a/backend/services/control/setpoints.py b/backend/services/control/setpoints.py index 22d3c5e..f285aed 100644 --- a/backend/services/control/setpoints.py +++ b/backend/services/control/setpoints.py @@ -331,7 +331,7 @@ def deye_battery_charge_discharge_amps( max_discharge_a: int, export_mode: str | None = None, export_ban: bool = False, -) -> tuple[int, int]: +) -> tuple[int | None, int]: """ Proud nabíjení / vybíjení (reg 108 / 109) pro zápis Deye. @@ -339,14 +339,17 @@ def deye_battery_charge_discharge_amps( nabíjení neplní, přebytek jde do sítě (142 = zero-export dle instalace, 145 = 1). PASSIVE + nabíjení bez exportního záměru (`battery_w > 0`, export_mode NONE): **108 = max**. - **CHARGE** ze sítě: 108 z `battery_w`. **SELL**: 108 = 0, 109 = max. + **CHARGE** ze sítě: 108 z `battery_w`. + + **SELL** (selling first, reg 142 = 0): vrací ``(None, max_discharge)`` — reg **108 se nezapisuje** + (export řídí 142/178; nulování 108 a obnova po návratu jsou zbytečné zápisy do paměti). """ if lock_battery: return 0, 0 if deye_mode == "CHARGE": return battery_watts_to_amps(bat_w, max_charge_a), 0 if deye_mode == "SELL": - return 0, int(max_discharge_a) + return None, int(max_discharge_a) if self_sustain_local_use: return int(max_charge_a), int(max_discharge_a) if _is_passive_pv_surplus_export( diff --git a/backend/tests/test_control_deye_passive_pv_charge.py b/backend/tests/test_control_deye_passive_pv_charge.py index 733d768..bb77bf2 100644 --- a/backend/tests/test_control_deye_passive_pv_charge.py +++ b/backend/tests/test_control_deye_passive_pv_charge.py @@ -83,7 +83,7 @@ class PassivePvSurplusChargeAmpsTests(unittest.TestCase): self.assertGreater(ch, 0) self.assertEqual(dis, 0) - def test_sell_unchanged(self) -> None: + def test_sell_skips_charge_amp_write(self) -> None: ch, dis = deye_battery_charge_discharge_amps( lock_battery=False, deye_mode="SELL", @@ -93,7 +93,7 @@ class PassivePvSurplusChargeAmpsTests(unittest.TestCase): max_charge_a=100, max_discharge_a=80, ) - self.assertEqual(ch, 0) + self.assertIsNone(ch) self.assertEqual(dis, 80) diff --git a/docs/04-modules/control.md b/docs/04-modules/control.md index a83fc11..7fc6047 100644 --- a/docs/04-modules/control.md +++ b/docs/04-modules/control.md @@ -171,7 +171,7 @@ bits 0–1). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg | Registr | Charge | PASSIVE (ZERO) | SELL | Self-consumption | |---|---|---|---|---| -| **108** (charge A) | škálo dle `battery_w` | max / **0** (export bez nabíjení) / **max** při PASSIVE + `battery_w>0` (FVE do baterie až po strop) | **0** | dle varianty | +| **108** (charge A) | škálo dle `battery_w` | max / **0** (export bez nabíjení) / **max** při PASSIVE + `battery_w>0` (FVE do baterie až po strop) | **nezapisuje EMS** | dle varianty | | **109** (discharge A) | **0** | max / **0** (import, držet bat.) / **max** při PASSIVE + `battery_w>0` | **max z DB** | dle varianty | | **142** (limit control) | `deye_zero_export_mode` | `deye_zero_export_mode` | **0** (selling first) | `deye_zero_export_mode` | | **143** (export cap) | max z DB | max z DB | max z DB (tvrdý limit, bez forecast heuristiky) | max z DB | diff --git a/docs/04-modules/modbus-registers.md b/docs/04-modules/modbus-registers.md index 227684b..845e5ca 100644 --- a/docs/04-modules/modbus-registers.md +++ b/docs/04-modules/modbus-registers.md @@ -12,7 +12,7 @@ EMS zapisuje řídící hodnoty přes journal (`modbus_command`) a **`write_regi | Reg | Název | Rozsah | Jednotka | Použití v EMS | |-----|-------|--------|----------|---------------| -| 108 | Max charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Strop **A** z DB (`COALESCE(deye_register_max_charge_a, odvod z kW)` + clamp **350 A**). **PASSIVE** + plán chce nabíjet (`battery_w>0`): **108 = max** (špička FVE nesmí být omezená průměrem slotu). **PASSIVE** + export bez nabíjení: **0**. **CHARGE:** z `battery_w` přes `battery_watts_to_amps`. **SELL:** **0**. | +| 108 | Max charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Strop **A** z DB (`COALESCE(deye_register_max_charge_a, odvod z kW)` + clamp **350 A**). **PASSIVE** + plán chce nabíjet (`battery_w>0`): **108 = max** (špička FVE nesmí být omezená průměrem slotu). **PASSIVE** + export bez nabíjení: **0**. **CHARGE:** z `battery_w` přes `battery_watts_to_amps`. **SELL:** EMS **nezapisuje** (selling first = reg **142**; zbytečné nulování/obnova). | | 109 | Max discharge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Ve **PASSIVE** (AUTO): výchozí **max**, u importu bez nabíjení **0**; při **PASSIVE + `battery_w>0` + export** zůstává **max** (domácnost z baterie při výpadku PV). **SELL** max vybíjení; **CHARGE** typicky **0**. | | 128 | Grid charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Nabíjení ze sítě. Firmware automaticky sníží reálný proud tak, aby `load + battery_charge` nepřekročil velikost jističe — proto LP v `planning_engine.py` plánuje `gi[t]` až **do `max_import_power_w + BMS_max_charge`**, aby uměl využít cenově nejlepší 15min okna pro nabíjení na plný BMS strop (viz `planning.md` sekce „Plánovací strop gi[t] vs. fyzický jistič"). Fyzické dodržení jističe drží reg 128 + firmware. | | 130 | Grid charge enable | 0/1 | — | 1 = povolit nabíjení ze sítě | @@ -97,7 +97,7 @@ Solver předvybírá sloty pro nabíjení a export-vybíjení (`_select_charge_s |---|---|---|---|---| | **Kdy** | `bat_w > 0`, `grid_w > 0` | typicky `grid_w < 0`, `bat_w ≥ 0` | `grid_w < 0`, `bat_w < 0` | import, `bat_w ≤ 0` či mix | | **Deye mode** | CHARGE | PASSIVE | SELL | PASSIVE | -| **108** charge A | dle `bat_w` | **0** při exportu bez vybíjení | **0** | max nebo **0** dle varianty | +| **108** charge A | dle `bat_w` | **0** při exportu bez vybíjení | **nezapisuje EMS** | max nebo **0** dle varianty | | **109** discharge A | **0** | max | **max** | **0** při importu bez nabíjení, jinak max | | **142** limit control | `deye_zero_export_mode` (1 nebo 2) | **`deye_zero_export_mode`** (1/2 = zero export k load/CT; **ne** „blokace do sítě“). Přetok FVE do sítě: **108=0**, **145=1** | **0** (selling first) | `deye_zero_export_mode` (1 nebo 2) | | **145** solar sell | **1** (enabled) | **1** (enabled) | **1** (enabled) | **1** (enabled) | diff --git a/docs/04-modules/operating-modes.md b/docs/04-modules/operating-modes.md index da7a9f9..9c0f9bb 100644 --- a/docs/04-modules/operating-modes.md +++ b/docs/04-modules/operating-modes.md @@ -5,7 +5,7 @@ - **Žádné wattové prahy pro výběr SELL / CHARGE** — mapování z MILP setpointů je čistě ze **znamének** `battery_setpoint_w` a `grid_setpoint_w` (viz `get_deye_mode` v `exporter_monolith.py`). - **Přetok FVE do sítě** se neodvozuje z forecastového capu: plán nese explicitní `export_limit_w` jako tvrdý limit lokality / invertoru, ne jako tipované maximum z předpovědi. - **ZERO (PASSIVE)** = **142** = `deye_zero_export_mode` (1/2, ne selling first). **PV_SURPLUS:** **108 = 0**, **109 = max** — přebytek FVE do sítě (**145 = 1**), ne do baterie. Jinak **108/109** typicky max; výjimka import bez vybíjení → **109 = 0**. -- **SELL** = plánovaný export **i** plánované vybíjení (oba záporné) → **142** = selling first, **178** = vypnutý grid peak shaving (32); po návratu do ZERO/CHARGE zase **178** = 48. +- **SELL** = plánovaný export **i** plánované vybíjení (oba záporné) → **142** = selling first, **178** = vypnutý grid peak shaving (32); reg **108** EMS **nemění** (export řídí 142, ne vynucené 0 A). Po návratu do ZERO/CHARGE zase **178** = 48. - Novou logiku vždy ověřit proti **reálnému řádku plánu** (audit / `planning_interval`). ## Přehled @@ -42,7 +42,7 @@ Značení: `battery_w` = `battery_setpoint_w` (kladné = nabíjení, záporné = | Režim | Podmínka z plánu | 108 / 109 (zkráceně) | 142 | 178 | |--------|------------------|----------------------|-----|-----| | **CHARGE** | `battery_w` > 0 **a** `grid_setpoint_w` > 0 | dle plánu nabíjení / 0 vybíjení | větev CHARGE | 48 | -| **SELL** | `battery_w` < 0 **a** `grid_setpoint_w` < 0 | 0 nabíjení / max vybíjení | 0 (selling first) | **32** (peak shaving off) | +| **SELL** | `battery_w` < 0 **a** `grid_setpoint_w` < 0 | **108 nezapisuje EMS** / max vybíjení (109) | 0 (selling first) | **32** (peak shaving off) | | **PASSIVE (ZERO)** | vše ostatní | viz tabulka ZERO níže | `deye_zero_export_mode` | 48 | ### ZERO: výchozí a dvě varianty proudu (reg. 108 / 109) diff --git a/docs/04-modules/provozni-rezimy-checklist.md b/docs/04-modules/provozni-rezimy-checklist.md index 155e96c..cbbc1b6 100644 --- a/docs/04-modules/provozni-rezimy-checklist.md +++ b/docs/04-modules/provozni-rezimy-checklist.md @@ -157,7 +157,7 @@ Jak to je v implementaci: - `deye_physical_mode = SELL` - `reg 142 = 0` - `reg 178 = 32` -- `reg 109` na max, `reg 108 = 0` (jen ve fyzickém **SELL** — aktivní výdej baterie do sítě; u **PASSIVE** + přetoku FVE už **108** typicky **max**) +- `reg 109` na max; **`reg 108` EMS ve SELL nemění** (selling first = **142**; u **PASSIVE** + přetoku FVE se **108** zapisuje **0**) ## 4. Další režimy, které v praxi existují