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

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