From 1e0300dd7e29806915624d319786571263261a4f Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Fri, 1 May 2026 12:51:28 +0200 Subject: [PATCH] register 340 -omezovani vyrkonu pv pole (home-01) --- CLAUDE.md | 4 +- backend/services/control/exporter_monolith.py | 58 +++++++++- backend/tests/test_control_exporter_reg340.py | 104 ++++++++++++++++++ db/routines/R__083_fn_inverter_pv_a_max_w.sql | 14 +++ docs/03-data-model.md | 2 + docs/04-modules/control.md | 15 ++- docs/04-modules/modbus-registers.md | 12 +- docs/04-modules/planning.md | 2 + 8 files changed, 200 insertions(+), 11 deletions(-) create mode 100644 backend/tests/test_control_exporter_reg340.py create mode 100644 db/routines/R__083_fn_inverter_pv_a_max_w.sql diff --git a/CLAUDE.md b/CLAUDE.md index a66b55a..b2c5d14 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):** **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`. **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):** **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 **Deye** + `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 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). @@ -199,7 +199,7 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan | Modbus, telemetrie, agregace | `docs/04-modules/telemetry.md` | | Dashboard přehled – 15min grafy slotů, SoC vs. živá telemetrie | `docs/04-modules/telemetry.md` (CA `telemetry_inverter_15m`, view `vw_telemetry_15m_7d`), `frontend/src/hooks/useDashboardData.ts`, `frontend/src/components/charts/SocTuvChart.tsx` | | Modbus journal, verifikace, Discord | `docs/04-modules/modbus-command-journal.md` | -| Deye registry (FC 0x10, 108/109/141/142/178/143) | `docs/04-modules/modbus-registers.md` | +| Deye registry (FC 0x10, 108/109/141/142/178/143/145/340) | `docs/04-modules/modbus-registers.md` | | Export setpointů, Loxone HTTP | `docs/04-modules/control.md`, `docs/loxone-integration.md` | | LP solver, rolling replan, korekce FVE, dynamický OTE horizont | `docs/04-modules/planning.md`, `docs/04-modules/planning-extended-horizon.md`, `planning_engine.py` | | Provozní režimy AUTO / SELF_SUSTAIN / … | `docs/04-modules/operating-modes.md`, `db/migration/V004__operating_modes.sql`, `db/routines/R__044_fn_set_mode.sql` | diff --git a/backend/services/control/exporter_monolith.py b/backend/services/control/exporter_monolith.py index 51a9298..b4e24a3 100644 --- a/backend/services/control/exporter_monolith.py +++ b/backend/services/control/exporter_monolith.py @@ -121,6 +121,7 @@ DEYE_REGISTER_NAMES: dict[int, str] = { 142: "limit_control (0=selling first, 1=zero export to load, 2=zero export to CT)", 143: "export_limit_w (max export do sítě)", 145: "solar_sell (0=disabled, 1=enabled)", + 340: "max_solar_power_w (strop DC PV A v W; součet nominal_power_wp řiditelných polí)", 178: "control_board_special_1 (bits0-1: MI export cutoff disable=2 enable=3; bits4-5 peak shaving 32/48)", 148: "time_point_1_time", 149: "time_point_2_time", @@ -202,6 +203,16 @@ class InverterConfig: deye_tou_inactive_signature: str | None = None deye_zero_export_mode: int = 1 deye_gen_microinverter_cutoff_enabled: bool = False + manufacturer: str = "" + #: Součet nominal_power_wp controllable PV na invertoru; 0 = EMS nezapisuje reg 340. + pv_a_cap_w: int = 0 + + +def compute_pv_a_reg340_max_solar_w(cap_w: int, forecast_w: int, curtail_w: int) -> int: + """Hodnota pro Deye reg 340 (max solar power, W) z capu a plánovaného curtailmentu pole A.""" + if curtail_w <= 0: + return int(cap_w) + return max(0, min(int(cap_w), int(forecast_w) - int(curtail_w))) def _prague_minute_start_utc() -> datetime: @@ -368,6 +379,8 @@ class ControlSetpoints: lock_battery: bool = False #: Režim SELF_SUSTAIN: plný rozsah nabíjení/vybíjení na invertoru + zero-export (reg 142) a nízké TOU %. self_sustain_local_use: bool = False + #: Deye reg 340 (max solar power, W). None = EMS reg 340 v tomto ticku neřeší (PRESERVE/SELF_SUSTAIN/CHARGE_CHEAP/…). + pv_a_allowed_w: int | None = None @dataclass @@ -1062,6 +1075,8 @@ async def _load_inverter_config( """ SELECT ai.id, ai.code, + coalesce(ai.manufacturer, '') AS manufacturer, + coalesce(ems.fn_inverter_pv_a_max_w(ai.id), 0) AS pv_a_cap_w, se.host, se.port, se.unit_id, sgc.max_export_power_w, sgc.max_import_power_w, @@ -1162,6 +1177,8 @@ async def _load_inverter_config( deye_tou_inactive_signature=row["deye_tou_inactive_signature"], deye_zero_export_mode=int(row["deye_zero_export_mode"]), deye_gen_microinverter_cutoff_enabled=bool(row["deye_gen_microinverter_cutoff_enabled"] or False), + manufacturer=str(row["manufacturer"] or ""), + pv_a_cap_w=int(row["pv_a_cap_w"] or 0), ) @@ -1240,7 +1257,13 @@ class _DictRecord: return k in self._d -def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> ControlSetpoints | None: +def _build_setpoints( + mode: OperatingModeInfo, + pi: asyncpg.Record | None, + *, + pv_a_cap_w: int = 0, + inverter_manufacturer: str = "", +) -> ControlSetpoints | None: code = mode.mode_code if code == "MANUAL": return None @@ -1262,6 +1285,11 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont export_ban = sell_f is not None and float(sell_f) < 0 and grid_sp >= 0 gen_cutoff_raw = pi.get("deye_gen_cutoff_enabled") gen_cutoff = bool(gen_cutoff_raw) if gen_cutoff_raw is not None else False + pv_a_allowed: int | None = None + if (inverter_manufacturer or "").strip().lower() == "deye" and int(pv_a_cap_w) > 0: + forecast = int(pi.get("pv_a_forecast_solver_w") or 0) + curtail = int(pi.get("pv_a_curtailed_w") or 0) + pv_a_allowed = compute_pv_a_reg340_max_solar_w(int(pv_a_cap_w), forecast, curtail) return ControlSetpoints( battery_w=int(pi["battery_setpoint_w"] or 0), grid_export_limit=abs(min(grid_sp, 0)), @@ -1276,6 +1304,7 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont export_ban=bool(export_ban), deye_gen_cutoff_enabled=bool(gen_cutoff), effective_sell_price_czk_kwh=sell_f, + pv_a_allowed_w=pv_a_allowed, ) if code == "SELF_SUSTAIN": @@ -1349,6 +1378,7 @@ def _apply_price_failsafe_guard( ev2_power_w=sp.ev2_power_w, target_soc_pct=sp.target_soc_pct, effective_sell_price_czk_kwh=sp.effective_sell_price_czk_kwh, + pv_a_allowed_w=sp.pv_a_allowed_w, ) @@ -1633,6 +1663,14 @@ async def write_inverter_setpoints( ] ) + mfr = (inv.manufacturer or "").strip().lower() + if ( + mfr == "deye" + and int(inv.pv_a_cap_w) > 0 + and setpoints_now.pv_a_allowed_w is not None + ): + registers.append((340, "max_solar_power_w", int(setpoints_now.pv_a_allowed_w))) + # Reg 178: bitové pole. Nastavujeme bits4–5 (peak shaving) vždy; bits0–1 (MI export cutoff) jen pokud feature. # Ostatní bity musí zůstat zachované → read-modify-write. try: @@ -1768,13 +1806,13 @@ async def write_inverter_setpoints( return ( f"OK inverter: batt_w={raw_bat!r} " - f"(time points + FC 0x10: 108/109/141/142/178/143)" + f"(time points + FC 0x10: 108/109/141/142/178/143/145/340 dle plánu)" ) async def read_deye_registers_live(site_id: int, db: asyncpg.Connection) -> dict[str, Any]: """ - Živé čtení holding registrů Deye 108, 109, 141–145, 178, 191 (stejné TCP spojení jako telemetrie/export). + Živé čtení holding registrů Deye 108, 109, 141–145, 178, 191, 340 (stejné TCP spojení jako telemetrie/export). Vše pod jedním mutexem + sdružené FC3 bloky — mezi jednotlivými read_register dřív telemetrie střídavě brala lock a RS485 brány házely cizí transaction_id / I/O timeouty. """ @@ -1791,11 +1829,13 @@ async def read_deye_registers_live(site_id: int, db: asyncpg.Connection) -> dict b141 = await mb.read_holding_registers(141, 5) r178 = await mb.read_holding_registers(178, 1) r191 = await mb.read_holding_registers(191, 1) + r340 = await mb.read_holding_registers(340, 1) r108, r109 = b108[0], b108[1] r141, r142, r143 = b141[0], b141[1], b141[2] r145 = b141[4] r178 = r178[0] r191 = r191[0] + r340v = int(r340[0]) if r340 and len(r340) >= 1 else 0 except Exception: logger.exception("read_deye_registers_live site=%s failed", site_id) raise @@ -1812,6 +1852,7 @@ async def read_deye_registers_live(site_id: int, db: asyncpg.Connection) -> dict "reg178_mi_export_cutoff_bits": int(r178) & int(REG178_MI_EXPORT_MASK), "reg178_mi_export_cutoff_is_on": (int(r178) & int(REG178_MI_EXPORT_MASK)) == int(REG178_MI_EXPORT_ENABLE), "reg191_peak_shaving_w": int(r191), + "reg340_max_solar_power_w": int(r340v), "read_at": read_at.isoformat(), } @@ -1955,10 +1996,17 @@ async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None: return try: + inv_for_pv = await _load_inverter_config(site_id, db) + cap_pv = int(inv_for_pv.pv_a_cap_w) if inv_for_pv is not None else 0 + mfr_pv = (inv_for_pv.manufacturer or "") if inv_for_pv is not None else "" pi_now = await _fetch_plan_row_for_slot_offset(site_id, db, 0) pi_next = await _fetch_plan_row_for_slot_offset(site_id, db, 1) - sp_now = _build_setpoints(mode, pi_now) - sp_next = _build_setpoints(mode, pi_next) + sp_now = _build_setpoints( + mode, pi_now, pv_a_cap_w=cap_pv, inverter_manufacturer=mfr_pv + ) + sp_next = _build_setpoints( + mode, pi_next, pv_a_cap_w=cap_pv, inverter_manufacturer=mfr_pv + ) if mode.mode_code == "AUTO" and sp_now is None: if pi_now is None: diff --git a/backend/tests/test_control_exporter_reg340.py b/backend/tests/test_control_exporter_reg340.py new file mode 100644 index 0000000..d89fc1c --- /dev/null +++ b/backend/tests/test_control_exporter_reg340.py @@ -0,0 +1,104 @@ +"""Deye reg 340 (max solar power) z plánu a capu z DB.""" + +from __future__ import annotations + +import unittest + +from services.control.exporter_monolith import ( + OperatingModeInfo, + _DictRecord, + _build_setpoints, + compute_pv_a_reg340_max_solar_w, + deye_reg_triggers_self_sustain_after_verify_exhaust, +) + + +def _auto_mode() -> OperatingModeInfo: + return OperatingModeInfo( + mode_code="AUTO", + battery_mode="auto", + grid_mode="auto", + ev_enabled=True, + heat_pump_enabled_def=True, + loxone_mode_value=0, + ) + + +def _pi_base(**kwargs: object) -> _DictRecord: + d: dict[str, object] = { + "grid_setpoint_w": 0, + "battery_setpoint_w": 0, + "battery_soc_target_pct": None, + "heat_pump_enabled": False, + "effective_sell_price": 1.0, + "pv_a_forecast_solver_w": 8000, + "pv_a_curtailed_w": 0, + } + d.update(kwargs) + return _DictRecord(d) + + +class ComputePvAReg340Tests(unittest.TestCase): + def test_full_cap_when_no_curtail(self) -> None: + self.assertEqual(compute_pv_a_reg340_max_solar_w(10_000, 8000, 0), 10_000) + + def test_curtailed_value(self) -> None: + self.assertEqual(compute_pv_a_reg340_max_solar_w(10_000, 8000, 2000), 6000) + + def test_clamped_to_cap_when_forecast_high(self) -> None: + self.assertEqual(compute_pv_a_reg340_max_solar_w(10_000, 12_000, 0), 10_000) + + def test_curtail_floor_zero(self) -> None: + self.assertEqual(compute_pv_a_reg340_max_solar_w(10_000, 1000, 5000), 0) + + +class BuildSetpointsReg340Tests(unittest.TestCase): + def test_deye_with_cap_sets_pv_a_allowed(self) -> None: + sp = _build_setpoints( + _auto_mode(), + _pi_base(pv_a_forecast_solver_w=8000, pv_a_curtailed_w=2000), + pv_a_cap_w=10_000, + inverter_manufacturer="Deye", + ) + assert sp is not None + self.assertEqual(sp.pv_a_allowed_w, 6000) + + def test_skipped_when_cap_zero(self) -> None: + sp = _build_setpoints( + _auto_mode(), + _pi_base(), + pv_a_cap_w=0, + inverter_manufacturer="Deye", + ) + assert sp is not None + self.assertIsNone(sp.pv_a_allowed_w) + + def test_skipped_for_non_deye(self) -> None: + sp = _build_setpoints( + _auto_mode(), + _pi_base(), + pv_a_cap_w=10_000, + inverter_manufacturer="Foo", + ) + assert sp is not None + self.assertIsNone(sp.pv_a_allowed_w) + + def test_self_sustain_no_pv_a_allowed(self) -> None: + mode = OperatingModeInfo( + mode_code="SELF_SUSTAIN", + battery_mode="x", + grid_mode="x", + ev_enabled=False, + heat_pump_enabled_def=False, + loxone_mode_value=0, + ) + sp = _build_setpoints( + mode, None, pv_a_cap_w=10_000, inverter_manufacturer="Deye" + ) + assert sp is not None + self.assertIsNone(sp.pv_a_allowed_w) + + +class Reg340VerifyPolicyTests(unittest.TestCase): + def test_reg340_not_critical_for_self_sustain(self) -> None: + self.assertFalse(deye_reg_triggers_self_sustain_after_verify_exhaust(340)) diff --git a/db/routines/R__083_fn_inverter_pv_a_max_w.sql b/db/routines/R__083_fn_inverter_pv_a_max_w.sql new file mode 100644 index 0000000..b41450b --- /dev/null +++ b/db/routines/R__083_fn_inverter_pv_a_max_w.sql @@ -0,0 +1,14 @@ +-- Cap pro Deye reg 340 (max solar power, W): součet nominal_power_wp řiditelných PV polí na invertoru. +create or replace function ems.fn_inverter_pv_a_max_w(p_inverter_id int) +returns int +language sql +stable +as $$ + select coalesce(sum(nominal_power_wp), 0)::int + from ems.asset_pv_array + where inverter_id = p_inverter_id + and controllable = true +$$; + +comment on function ems.fn_inverter_pv_a_max_w(int) is + 'Cap pro Deye reg 340 (max solar power, W) = součet nominal_power_wp řiditelných PV polí na daném invertoru. 0 = EMS reg 340 neaktivní (skip zápisu).'; diff --git a/docs/03-data-model.md b/docs/03-data-model.md index 374e0ae..635c1b1 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -125,6 +125,8 @@ CREATE TABLE asset_battery ( ### `asset_pv_array` Každé FVE pole zvlášť – důležité pro predikci (azimut, sklon). **Zelený bonus** (dotace za vyrobenou elektřinu) se váže na **úroveň pole**, ne na celou lokalitu: které pole má bonus, jaká je sazba Kč/kWh a platnost, určují sloupce `green_bonus_*`. Výpočet příjmu za interval probíhá funkcí `ems.fn_green_bonus_revenue` z výroby v Wh; není součástí efektivní prodejní ceny ze sítě. +**Deye reg 340 (max solar power, W):** strop pro řiditelné DC pole A na hybridu počítá `ems.fn_inverter_pv_a_max_w(inverter_id)` jako **součet `nominal_power_wp`** řádků s `controllable = true` vázaných na daný invertor. Exportér (`exporter_monolith.write_inverter_setpoints`) tento cap použije jen u `asset_inverter.manufacturer` = Deye a **pouze pokud součet > 0**; při součtu 0 se reg 340 z EMS nezapisuje (nezasahuje do ručního nastavení v invertoru). + ```sql CREATE TABLE asset_pv_array ( id SERIAL PRIMARY KEY, diff --git a/docs/04-modules/control.md b/docs/04-modules/control.md index 7e427f9..5b7948e 100644 --- a/docs/04-modules/control.md +++ b/docs/04-modules/control.md @@ -130,9 +130,20 @@ registru **178** (v některých manuálech/UI uváděno jako “register 179” - `deye_gen_cutoff_enabled = false` → reg **178** bits **0–1** = **2** (`10b`, disable = cut-off **OFF** / export povolen) Zápisy se ukládají do `ems.modbus_command` a ověřují v `verify_modbus_commands` (porovnává se pouze maska -bits 0–1). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg 179). bits 0–1). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg 178). +### PV A curtailment — zápis reg 340 (max solar power) + +- **Implementace:** `backend/services/control/exporter_monolith.py` — `export_setpoints` načte cap v `_load_inverter_config` (`ems.fn_inverter_pv_a_max_w(ai.id)`), `_build_setpoints` v režimu **AUTO** dopočítá `ControlSetpoints.pv_a_allowed_w`, `write_inverter_setpoints` zařadí **reg 340**, pokud je výrobce invertoru Deye, cap > 0 a `pv_a_allowed_w` je vyplněné. +- **Data:** `pv_a_forecast_solver_w` / `pv_a_curtailed_w` z aktivního `planning_interval` (json z `ems.fn_planning_interval_at_offset`); cap = součet `nominal_power_wp` řiditelných polí na invertoru (bez nového sloupce v DB). +- **Verify:** reg **340** není kritický → po 3× mismatch verify **bez** přepnutí do SELF_SUSTAIN (stejně jako reg 178); viz [`modbus-command-journal.md`](modbus-command-journal.md). + +#### Ověření po nasazení (smoke) + +1. `select ems.fn_inverter_pv_a_max_w();` — při **0** na PV A (např. odpojené pole, `nominal_power_wp = 0`) EMS reg 340 **nezapisuje**. +2. Dočasně zvýšit `nominal_power_wp` na controllable PV A → po dalším běhu exportu řádek v `ems.modbus_command` pro register **340** → po verify jobu stav **`verified`**. +3. Živé čtení: `read_deye_registers_live` vrací **`reg340_max_solar_power_w`**. + ### Fyzický režim (`get_deye_mode` v `exporter_monolith.py`) | Fyzický režim | Podmínka z `ControlSetpoints` | @@ -167,7 +178,7 @@ Hodnota `deye_zero_export_mode` (1 = zero export to load, 2 = zero export to CT) Po zápisu na Modbus se hodnoty ověřují v `verify_modbus_commands` (`control_exporter.py`). Po **3 neúspěšných** cyklech zápis+verify: - **Kritické registry** (**108, 109, 142, 143, 145**) → přepnutí lokality do **SELF_SUSTAIN** (`system:mismatch`). -- **Ostatní** (včetně **178** a **TOU power W 154–159** po vyčerpání soft pravidel) → zůstane **AUTO** (nebo aktuální režim), řádek journalu **`mismatch`**, Discord upozornění. +- **Ostatní** (včetně **178**, **340** a **TOU power W 154–159** po vyčerpání soft pravidel) → zůstane **AUTO** (nebo aktuální režim), řádek journalu **`mismatch`**, Discord upozornění. Při přechodu **SELF_SUSTAIN → AUTO** (`run_fn_set_mode_with_discord`) se na pozadí spustí **rolling replan**, aby aktivní plán odpovídal plné optimalizaci. Viz [`modbus-command-journal.md`](modbus-command-journal.md). diff --git a/docs/04-modules/modbus-registers.md b/docs/04-modules/modbus-registers.md index 547d651..be66e0e 100644 --- a/docs/04-modules/modbus-registers.md +++ b/docs/04-modules/modbus-registers.md @@ -19,12 +19,20 @@ EMS zapisuje řídící hodnoty přes journal (`modbus_command`) a **`write_regi | 141 | Energy mgmt mode | bitmask | — | EMS vždy **0** (neměnit jinak) | | 142 | Limit control (System work mode) | 0/1/2 | — | **0** = selling first, **1** = zero export to load, **2** = zero export to CT. Hodnota v non-SELL režimech pochází z `asset_inverter.deye_zero_export_mode` (závisí na instalaci – viz tabulka níže). V režimu SELL vždy **0**. | | 145 | Solar sell | 0/1 | — | **0** = disabled (přebytek FVE na **straně měniče** se nesmí vést do sítě — curtailment vůči síti), **1** = enabled. Platí jen pro **FVE pod kontrolou Deye** (`controllable = true`); druhá pole (např. **pv-b** u home-01) EMS tímto registerem neřídí. EMS dnes **vždy zapisuje 1**; při 108 = 0 a 145 = 1 přebytky z řiditelného stringu typicky tečou do sítě (viz pass-through níže). | +| 340 | Max solar power | 0 … cap (W) | 1 W | Strop výkonu DC PV řízeného střídačem (pole A). EMS zapisuje jen pokud `manufacturer` = Deye a `ems.fn_inverter_pv_a_max_w(inverter_id) > 0` (součet `nominal_power_wp` controllable polí). Hodnota z aktivního `planning_interval`: bez curtailmentu = cap; s `pv_a_curtailed_w > 0` = `clamp(0, cap, pv_a_forecast_solver_w − pv_a_curtailed_w)`. **Není** v `DEYE_CRITICAL_REGS_SELF_SUSTAIN` — verify mismatch nečeká přepnutí do SELF_SUSTAIN. | | 143 | Export limit W | závisí na typu (SUN-20K až ~13 500) | 1 W | Max export do sítě; hodnota z `site_grid_connection.max_export_power_w` | | 178 | Control board special 1 | bitmask | — | Bitové pole pro více funkcí. **EMS používá:** (a) bits **4–5** pro peak shaving switch: **32** (`0b00100000`, bit4–5 = **10**) v režimu **SELL**; **48** (`0b00110000`, bit4–5 = **11**) v **PASSIVE/CHARGE**. (b) **BA81:** bits **0–1** pro „MI export to Grid cutoff“ (AC coupling / GEN): **2** = disable (cutoff OFF), **3** = enable (cutoff ON). EMS zapisuje jako **read-modify-write** (zachová ostatní bity). V některých manuálech/UI je to označené jako „register 179“ (1-based). | | 190 | GEN peak shaving | 0–16000 | 1 W | Peak shaving na GEN portu | | 191 | Grid peak shaving power | 0–16000 | 1 W | **EMS NEZAPISUJE** – nastavit **manuálně v SolarmanApp**. Hodnota určuje výkon peak shavingu v **W**. | -`exporter_monolith.write_inverter_setpoints` zapisuje přes **`modbus_command`** (journal; jeden řádek na registr) a **`execute_modbus_commands`** odesílá **souvislé bloky jedním FC 0x10** (např. 62–64, 148–159, 166–177, 108–109, 141–143, 145 podle toho, co je ve frontě). Pořadí v journalu: **62–64** (čas, viz níže), **time points 148–177** (jen řádky zařazené do daného běhu), **108, 109, 141, 142, 143, 145, 178**. Popisné názvy v DB bere `DEYE_REGISTER_NAMES`. **Reg 191** EMS nezapisuje. +`exporter_monolith.write_inverter_setpoints` zapisuje přes **`modbus_command`** (journal; jeden řádek na registr) a **`execute_modbus_commands`** odesílá **souvislé bloky jedním FC 0x10** (např. 62–64, 148–159, 166–177, 108–109, 141–143, 145, 340 podle toho, co je ve frontě). Pořadí v journalu: **62–64** (čas, viz níže), **time points 148–177** (jen řádky zařazené do daného běhu), **108, 109, 141, 142, 143, 145, 340 (podmíněně), 178**. Popisné názvy v DB bere `DEYE_REGISTER_NAMES`. **Reg 191** EMS nezapisuje. + +### Reg 340 (max solar power) + +- **FC 0x10**, jednotka **W**; firmware omezuje strop výroby z řiditelných stringů (pole A na hybridu). +- **Kdy EMS zapisuje:** `asset_inverter.manufacturer` odpovídá Deye **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0` (součet `nominal_power_wp` z `asset_pv_array` kde `controllable = true`). Při součtu **0** EMS reg 340 **nezapisuje** (např. odpojené pole A s `nominal_power_wp = 0` v DB — ruční hodnota v invertoru zůstane). +- **Hodnota:** z `ControlSetpoints.pv_a_allowed_w` (AUTO): bez curtailmentu = plný cap; při `pv_a_curtailed_w > 0` viz tabulka výše. Režimy **SELF_SUSTAIN / PRESERVE / CHARGE_CHEAP** mají `pv_a_allowed_w = None` → žádný zápis 340 z EMS v daném ticku. +- **Živé čtení:** `read_deye_registers_live` vrací **`reg340_max_solar_power_w`**. ### Reg 191 (výkon grid peak shaving) @@ -131,7 +139,7 @@ Deye má 6 časových bloků. EMS přepisuje **bloky 1–2** (TOU index 0–1) p | 2 | **`next_slot_hhmm()`** – začátek **následujícího** 15min slotu | `planning_interval` pro **další** slot (`_fetch_plan_row_for_slot_offset(..., 1)`) | Přechod na další čtvrthodinu | viz tabulka níže | viz tabulka níže | | 3–6 | **23:55** (2355) | — | Neaktivní (pasivní profil); ne 23:59 — firmware Deye často 2359 neuloží → verify mismatch | **`min_soc_percent`** (DB) | NE | -**Registry 108 / 109 / 141 / 142 / 143 / 145 / 178** odpovídají **aktuálnímu** plánu (okamžitý výstup; `setpoints_now` v `write_inverter_setpoints`). TOU řádky 1–2 doplňují stejnou logiku pro časové segmenty (`_deye_tou_params`). +**Registry 108 / 109 / 141 / 142 / 143 / 145 / 340 (podmíněně) / 178** odpovídají **aktuálnímu** plánu (okamžitý výstup; `setpoints_now` v `write_inverter_setpoints`). TOU řádky 1–2 doplňují stejnou logiku pro časové segmenty (`_deye_tou_params`). Příklad v 14:18: blok 1 má čas **1415**, blok 2 čas **1430** – mezi 14:15 a 14:29 je aktivní segment z bloku 1 (sladěný s plánem pro 14:15–14:30), po 14:30 blok 2 (plán 14:30–14:45). Po dalším exportu se oba časy posunou (např. 14:30 / 14:45). diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 7dba4f7..d7cff54 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -487,6 +487,8 @@ COMMENT ON COLUMN ems.planning_interval.pv_a_curtailed_w IS 'Hodnota > 0 znamená že solver rozhodl omezit výrobu pole A přes Modbus.'; ``` +**Fyzická realizace na Deye (bez změny solveru):** u hybridu s `manufacturer = Deye` a nenulovým součtem `nominal_power_wp` controllable polí na invertoru exportér mapuje `pv_a_forecast_solver_w` / `pv_a_curtailed_w` na zápis **holding registru 340** (max solar power, W) — viz [`control.md`](control.md) sekce *PV A curtailment* a [`modbus-registers.md`](modbus-registers.md) reg 340. + --- ## Tuning pro malé baterie (např. BA81)