register 340 -omezovani vyrkonu pv pole (home-01)
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-01 12:51:28 +02:00
parent e686bc1d2c
commit 1e0300dd7e
8 changed files with 200 additions and 11 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`. **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** 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 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).
@@ -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` |

View File

@@ -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 bits45 (peak shaving) vždy; bits01 (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, 141145, 178, 191 (stejné TCP spojení jako telemetrie/export).
Živé čtení holding registrů Deye 108, 109, 141145, 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:

View File

@@ -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))

View File

@@ -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).';

View File

@@ -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,

View File

@@ -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 **01** = **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 01). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg 179).
bits 01). 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(<id kontrolovatelného deye-main invertoru>);` — 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 154159** 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 154159** 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).

View File

@@ -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 **45** pro peak shaving switch: **32** (`0b00100000`, bit45 = **10**) v režimu **SELL**; **48** (`0b00110000`, bit45 = **11**) v **PASSIVE/CHARGE**. (b) **BA81:** bits **01** 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 | 016000 | 1 W | Peak shaving na GEN portu |
| 191 | Grid peak shaving power | 016000 | 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ř. 6264, 148159, 166177, 108109, 141143, 145 podle toho, co je ve frontě). Pořadí v journalu: **6264** (čas, viz níže), **time points 148177** (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ř. 6264, 148159, 166177, 108109, 141143, 145, 340 podle toho, co je ve frontě). Pořadí v journalu: **6264** (čas, viz níže), **time points 148177** (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 12** (TOU index 01) 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 |
| 36 | **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 12 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 12 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:1514:30), po 14:30 blok 2 (plán 14:3014:45). Po dalším exportu se oba časy posunou (např. 14:30 / 14:45).

View File

@@ -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)