From 6d6341cde8d44d7ec6e4bb23a75a2522673c140e Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Sat, 2 May 2026 19:35:41 +0200 Subject: [PATCH] refactor control exporter helpers --- backend/services/control/deye_helpers.py | 236 ++++++++++++ backend/services/control/exporter_monolith.py | 338 ++---------------- backend/services/control/models.py | 73 ++++ 3 files changed, 345 insertions(+), 302 deletions(-) create mode 100644 backend/services/control/deye_helpers.py create mode 100644 backend/services/control/models.py diff --git a/backend/services/control/deye_helpers.py b/backend/services/control/deye_helpers.py new file mode 100644 index 0000000..b1463fa --- /dev/null +++ b/backend/services/control/deye_helpers.py @@ -0,0 +1,236 @@ +"""Čisté Deye konstanty a helpery pro control export.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo + +from services.control.models import InverterConfig + +PRAGUE_TZ = ZoneInfo("Europe/Prague") + +# Hodiny Deye 62-64: po zápisu sekundy na zařízení dál běží, verify musí být toleranční. +DEYE_CLOCK_VERIFY_MAX_DELTA_SEC = 120 +# Řidší zápis: bez zápisu, pokud čas na invertoru neodbočí od Prahy víc než o tolik sekund. +DEYE_CLOCK_DRIFT_OK_SEC = 60 +# A zároveň neuplynul tento interval od posledního syncu / potvrzení driftu. +DEYE_CLOCK_RESYNC_INTERVAL_HOURS = 24 + +# Deye LV baterie: převod výkon -> proud pro registry 108/109. +BATT_VOLTAGE_V = 51.2 + +# Reg 143 ve SELL: min(|grid_setpoint_w|, ...) nesmí klesnout pod tuto podlahu (W). +REG143_SELL_CAP_MIN_W = 200 + +# Reg 178 - bitové pole: bity 4-5 (peak shaving switch) a bity 0-1 (MI export cutoff). +REG178_SELL = 0b00100000 +REG178_PASSIVE = 0b00110000 +REG178_VERIFY_MASK = 0x0030 +REG178_MI_EXPORT_MASK = 0x0003 +REG178_MI_EXPORT_DISABLE = 0b10 +REG178_MI_EXPORT_ENABLE = 0b11 +REG178_VERIFY_MASK_COMBINED = REG178_VERIFY_MASK | REG178_MI_EXPORT_MASK + +DEYE_CRITICAL_REGS_SELF_SUSTAIN = frozenset({108, 109, 142, 143, 145}) +DEYE_TOU_POWER_REGS = frozenset(range(154, 160)) +DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A = 350 + +# Neaktivní TOU bloky (3-6): Deye často 23:59 (2359) neuloží, 23:55 je stabilní. +DEYE_TOU_INACTIVE_HHMM = 2355 + +_DEYE_INACTIVE_TOU_REGISTERS: frozenset[int] = frozenset( + [ + 150, + 151, + 152, + 153, + 156, + 157, + 158, + 159, + 168, + 169, + 170, + 171, + 174, + 175, + 176, + 177, + ] +) + +DEYE_CLOCK_REGS: frozenset[int] = frozenset({62, 63, 64}) + +DEYE_REGISTER_NAMES: dict[int, str] = { + 108: "max_charge_a (max nabíjecí proud baterie)", + 109: "max_discharge_a (max vybíjecí proud baterie)", + 141: "energy_mode (0, EMS nemění)", + 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", + 154: "time_point_1_power_w", + 155: "time_point_2_power_w", + 166: "time_point_1_soc_min_pct", + 167: "time_point_2_soc_min_pct", + 172: "time_point_1_grid_charge", + 173: "time_point_2_grid_charge", + 62: "system_time_year_month", + 63: "system_time_day_hour", + 64: "system_time_min_sec", +} +for _tp_i in range(6): + _n = _tp_i + 1 + DEYE_REGISTER_NAMES.setdefault(148 + _tp_i, f"time_point_{_n}_time") + DEYE_REGISTER_NAMES.setdefault(154 + _tp_i, f"time_point_{_n}_power_w") + DEYE_REGISTER_NAMES.setdefault(166 + _tp_i, f"time_point_{_n}_soc_min_pct") + DEYE_REGISTER_NAMES.setdefault(172 + _tp_i, f"time_point_{_n}_grid_charge") + + +def _deye_reg178_verify_match(expected_i: int, actual_i: int) -> bool: + return (int(expected_i) & REG178_VERIFY_MASK_COMBINED) == ( + int(actual_i) & REG178_VERIFY_MASK_COMBINED + ) + + +def deye_reg_triggers_self_sustain_after_verify_exhaust(reg: int) -> bool: + """True = po 3x mismatch přepnout lokalitu do SELF_SUSTAIN (kritický registr).""" + return int(reg) in DEYE_CRITICAL_REGS_SELF_SUSTAIN + + +def _deye_tou_power_verify_match( + expected_i: int, actual_i: int, inv: InverterConfig +) -> bool: + """Firmware často clampne TOU power W na max z reg. 108/109 x 51.2 V.""" + if int(actual_i) == int(expected_i): + return True + max_w_charge = int(inv.max_charge_a * BATT_VOLTAGE_V) + max_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V) + a = int(actual_i) + return a == max_w_charge or a == max_w_discharge + + +def _deye_reg178_verify_with_double_read( + expected_i: int, actual_first: int, actual_second: int | None +) -> tuple[bool, int]: + """ + Vrátí (shoda, hodnota_pro_journal). + Druhé čtení použít jen když první neprojde maskou (RS485 / glitch). + """ + if _deye_reg178_verify_match(expected_i, actual_first): + return True, actual_first + if actual_second is not None and _deye_reg178_verify_match(expected_i, actual_second): + return True, int(actual_second) + return False, actual_first + + +def watts_to_amps(power_w: int | None, phases: int = 3, voltage: int = 230) -> int: + if not power_w or power_w <= 0: + return 0 + return min(32, max(0, int(power_w / (phases * voltage)))) + + +def battery_watts_to_amps(power_w: int, max_amps: int) -> int: + """Proud z |výkonu| baterie; max_amps z DB.""" + derived = int(abs(power_w) / BATT_VOLTAGE_V) + return min(max(0, max_amps), max(0, derived)) + + +def current_slot_hhmm() -> int: + """Začátek probíhajícího 15min slotu v Europe/Prague, formát HHMM.""" + now = datetime.now(PRAGUE_TZ) + slot_min = (now.minute // 15) * 15 + return now.hour * 100 + slot_min + + +def next_slot_hhmm() -> int: + """Začátek příštího 15min slotu v Europe/Prague, formát HHMM.""" + now = datetime.now(PRAGUE_TZ) + minutes = now.minute + slot_minutes = ((minutes // 15) + 1) * 15 + if slot_minutes >= 60: + next_hour = (now.hour + 1) % 24 + next_min = 0 + else: + next_hour = now.hour + next_min = slot_minutes + return next_hour * 100 + next_min + + +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: + """UTC okamžik odpovídající začátku aktuální kalendářní minuty v Europe/Prague.""" + p = datetime.now(PRAGUE_TZ).replace(second=0, microsecond=0) + return p.astimezone(timezone.utc) + + +def _deye_registers_to_prague_datetime(r62: int, r63: int, r64: int) -> datetime | None: + """Dekódování reg 62-64 (Deye system time v Europe/Prague).""" + try: + year = (int(r62) >> 8) + 2000 + month = int(r62) & 0xFF + day = int(r63) >> 8 + hour = int(r63) & 0xFF + minute = int(r64) >> 8 + second = int(r64) & 0xFF + if not (1 <= month <= 12 and 1 <= day <= 31 and 0 <= hour <= 23): + return None + if not (0 <= minute <= 59 and 0 <= second <= 59): + return None + return datetime(year, month, day, hour, minute, second, tzinfo=PRAGUE_TZ) + except (ValueError, OverflowError): + return None + + +def _deye_clock_registers_verify_match( + w62: int, + w63: int, + w64: int, + a62: int, + a63: int, + a64: int, +) -> bool: + w_dt = _deye_registers_to_prague_datetime(w62, w63, w64) + a_dt = _deye_registers_to_prague_datetime(a62, a63, a64) + if w_dt is None or a_dt is None: + return False + return abs((a_dt - w_dt).total_seconds()) <= DEYE_CLOCK_VERIFY_MAX_DELTA_SEC + + +def _deye_should_skip_time_sync_after_read( + inv: InverterConfig, + r62: int, + r63: int, + r64: int, +) -> bool: + """ + True = nezařazovat zápis 62-64: drift je malý a od posledního úspěšného zápisu + nebo tolerančního ověření neuplynulo 24h. + """ + dev = _deye_registers_to_prague_datetime(r62, r63, r64) + if dev is None: + return False + wall = datetime.now(PRAGUE_TZ) + drift = abs((wall - dev).total_seconds()) + if drift > DEYE_CLOCK_DRIFT_OK_SEC: + return False + last_write = inv.deye_last_system_time_sync_at + if last_write is None: + return False + if last_write.tzinfo is None: + last_write = last_write.replace(tzinfo=timezone.utc) + else: + last_write = last_write.astimezone(timezone.utc) + age = datetime.now(timezone.utc) - last_write + if age >= timedelta(hours=DEYE_CLOCK_RESYNC_INTERVAL_HOURS): + return False + return True diff --git a/backend/services/control/exporter_monolith.py b/backend/services/control/exporter_monolith.py index 0c8b159..e4d1521 100644 --- a/backend/services/control/exporter_monolith.py +++ b/backend/services/control/exporter_monolith.py @@ -7,281 +7,53 @@ import json import logging import os from collections import defaultdict -from dataclasses import dataclass from typing import Any -from datetime import date, datetime, timedelta, timezone -from zoneinfo import ZoneInfo +from datetime import datetime, timezone import asyncpg import httpx from app.config import get_settings +from services.control.deye_helpers import ( + BATT_VOLTAGE_V, + DEYE_CLOCK_DRIFT_OK_SEC, + DEYE_CLOCK_REGS, + DEYE_CLOCK_RESYNC_INTERVAL_HOURS, + DEYE_CLOCK_VERIFY_MAX_DELTA_SEC, # noqa: F401 - re-export for compatibility + DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A, + DEYE_REGISTER_NAMES, + DEYE_TOU_INACTIVE_HHMM, + DEYE_TOU_POWER_REGS, + PRAGUE_TZ, + REG143_SELL_CAP_MIN_W, + REG178_MI_EXPORT_DISABLE, + REG178_MI_EXPORT_ENABLE, + REG178_MI_EXPORT_MASK, + REG178_PASSIVE, + REG178_SELL, + REG178_VERIFY_MASK, + REG178_VERIFY_MASK_COMBINED, + _DEYE_INACTIVE_TOU_REGISTERS, + _deye_clock_registers_verify_match, + _deye_reg178_verify_match, + _deye_reg178_verify_with_double_read, + _deye_registers_to_prague_datetime, # noqa: F401 - re-export for compatibility + _deye_should_skip_time_sync_after_read, + _deye_tou_power_verify_match, + _prague_minute_start_utc, + battery_watts_to_amps, + compute_pv_a_reg340_max_solar_w, + current_slot_hhmm, + deye_reg_triggers_self_sustain_after_verify_exhaust, # noqa: F401 - re-export + next_slot_hhmm, + watts_to_amps, +) +from services.control.models import ControlSetpoints, InverterConfig, OperatingModeInfo from services.modbus_client import get_modbus_client from services.signal_service import enqueue_site_signals logger = logging.getLogger(__name__) -PRAGUE_TZ = ZoneInfo("Europe/Prague") - -# Hodiny Deye 62–64: po zápisu sekundy na zařízení dál běží → verify musí být toleranční. -DEYE_CLOCK_VERIFY_MAX_DELTA_SEC = 120 -# Řidší zápis: bez zápisu, pokud čas na invertoru neodbočí od Prahy víc než o tolik sekund… -DEYE_CLOCK_DRIFT_OK_SEC = 60 -# …a zároveň neuplynul tento interval od posledního syncu / potvrzení driftu. -DEYE_CLOCK_RESYNC_INTERVAL_HOURS = 24 - -# Deye LV baterie: převod výkon → proud pro registry 108/109 (viz docs/04-modules/modbus-registers.md) -BATT_VOLTAGE_V = 51.2 - -# Reg 143 ve SELL: min(|grid_setpoint_w|, …) nesmí klesnout pod tuto podlahu (W) — kvůli chování firmware, ne mapování režimu. -REG143_SELL_CAP_MIN_W = 200 - -# Reg 178 – bitové pole: používáme bity 4–5 (peak shaving switch) a bity 0–1 (MI export cutoff). -# 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) -# Verify: jen bity 4–5 (horní byte layout v dokumentaci); ostatní bity mohou mít firmware / Loxone -REG178_VERIFY_MASK = 0x0030 -# Reg 178 bits 0–1: MI export cutoff (AC coupling / GEN). -REG178_MI_EXPORT_MASK = 0x0003 -REG178_MI_EXPORT_DISABLE = 0b10 -REG178_MI_EXPORT_ENABLE = 0b11 -REG178_VERIFY_MASK_COMBINED = REG178_VERIFY_MASK | REG178_MI_EXPORT_MASK - -# Po 3 neúspěšných verify pokusech → SELF_SUSTAIN jen u těchto registrech (bezpečnost / export). -# 62–64 řeší toleranční bundle (nemění režim). 178 a TOU power W jsou „soft“ — jen log + Discord. -DEYE_CRITICAL_REGS_SELF_SUSTAIN = frozenset({108, 109, 142, 143, 145}) -# Výkonové řádky TOU (154 + slot_index 0…5) — firmware často přepíše na max W z max_charge/max_discharge A. -DEYE_TOU_POWER_REGS = frozenset(range(154, 160)) -# Deye LV: firmware často odmítne 351 A a drží 350 — horní strop pro zápis z DB. -DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A = 350 - - -def _deye_reg178_verify_match(expected_i: int, actual_i: int) -> bool: - return (int(expected_i) & REG178_VERIFY_MASK_COMBINED) == ( - int(actual_i) & REG178_VERIFY_MASK_COMBINED - ) - - -def deye_reg_triggers_self_sustain_after_verify_exhaust(reg: int) -> bool: - """True = po 3× mismatch přepnout lokalitu do SELF_SUSTAIN (kritický registr).""" - return int(reg) in DEYE_CRITICAL_REGS_SELF_SUSTAIN - - -def _deye_tou_power_verify_match( - expected_i: int, actual_i: int, inv: InverterConfig -) -> bool: - """Firmware často clampne TOU power W na max z reg. 108/109 × 51.2 V — akceptovat jako OK.""" - if int(actual_i) == int(expected_i): - return True - # 51.2 V — nesmí int(BATT_VOLTAGE_V)==51 (off-by-one vs. firmware 17920 W @ 350 A) - max_w_charge = int(inv.max_charge_a * BATT_VOLTAGE_V) - max_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V) - a = int(actual_i) - return a == max_w_charge or a == max_w_discharge - - -def _deye_reg178_verify_with_double_read( - expected_i: int, actual_first: int, actual_second: int | None -) -> tuple[bool, int]: - """ - Vrátí (shoda, hodnota_pro_journal). - Druhé čtení použít jen když první neprojde maskou (RS485 / glitch). - """ - if _deye_reg178_verify_match(expected_i, actual_first): - return True, actual_first - if actual_second is not None and _deye_reg178_verify_match(expected_i, actual_second): - return True, int(actual_second) - return False, actual_first - -# Neaktivní TOU bloky (3–6): „konec dne“ — Deye často 23:59 (2359) neuloží a vrátí např. 2355, -# verify pak hlásí mismatch. 23:55 je na zařízeních stabilní (viz HHMM jako desítkové číslo). -DEYE_TOU_INACTIVE_HHMM = 2355 - -# Registry TOU řádků 3–6 (slot index 2…5): 150–153, 156–159, … — pro detekci skutečného zápisu po filtru „unchanged“. -_DEYE_INACTIVE_TOU_REGISTERS: frozenset[int] = frozenset( - [ - 150, 151, 152, 153, - 156, 157, 158, 159, - 168, 169, 170, 171, - 174, 175, 176, 177, - ] -) - -# Systémový čas Deye — vždy toleranční verify jako celek 62–64 (reg 64 sám nesmí do striktní větve). -DEYE_CLOCK_REGS: frozenset[int] = frozenset({62, 63, 64}) - -DEYE_REGISTER_NAMES: dict[int, str] = { - 108: "max_charge_a (max nabíjecí proud baterie)", - 109: "max_discharge_a (max vybíjecí proud baterie)", - 141: "energy_mode (0, EMS nemění)", - 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", - 154: "time_point_1_power_w", - 155: "time_point_2_power_w", - 166: "time_point_1_soc_min_pct", - 167: "time_point_2_soc_min_pct", - 172: "time_point_1_grid_charge", - 173: "time_point_2_grid_charge", - 62: "system_time_year_month", - 63: "system_time_day_hour", - 64: "system_time_min_sec", -} -for _tp_i in range(6): - _n = _tp_i + 1 - DEYE_REGISTER_NAMES.setdefault(148 + _tp_i, f"time_point_{_n}_time") - DEYE_REGISTER_NAMES.setdefault(154 + _tp_i, f"time_point_{_n}_power_w") - DEYE_REGISTER_NAMES.setdefault(166 + _tp_i, f"time_point_{_n}_soc_min_pct") - DEYE_REGISTER_NAMES.setdefault(172 + _tp_i, f"time_point_{_n}_grid_charge") - - -def watts_to_amps(power_w: int | None, phases: int = 3, voltage: int = 230) -> int: - if not power_w or power_w <= 0: - return 0 - return min(32, max(0, int(power_w / (phases * voltage)))) - - -def battery_watts_to_amps(power_w: int, max_amps: int) -> int: - """Proud z |výkonu| baterie; max_amps z DB (už COALESCE se stropy v SQL). - - int(|W|/51.2) — u kladných hodnot stejné jako floor bez importu math. - """ - derived = int(abs(power_w) / BATT_VOLTAGE_V) - return min(max(0, max_amps), max(0, derived)) - - -def current_slot_hhmm() -> int: - """Začátek probíhajícího 15min slotu v Europe/Prague, formát HHMM (např. 1415).""" - now = datetime.now(ZoneInfo("Europe/Prague")) - slot_min = (now.minute // 15) * 15 - return now.hour * 100 + slot_min - - -def next_slot_hhmm() -> int: - """Začátek příštího 15min slotu v Europe/Prague, formát HHMM (např. 1430).""" - now = datetime.now(ZoneInfo("Europe/Prague")) - minutes = now.minute - slot_minutes = ((minutes // 15) + 1) * 15 - if slot_minutes >= 60: - next_hour = (now.hour + 1) % 24 - next_min = 0 - else: - next_hour = now.hour - next_min = slot_minutes - return next_hour * 100 + next_min - - -@dataclass -class InverterConfig: - id: int - code: str - host: str - port: int - unit_id: int - max_export_power_w: int | None - max_import_power_w: int | None - no_export: bool - max_battery_charge_w: int | None - max_battery_discharge_w: int | None - min_soc_percent: int | None - reserve_soc_percent: int | None - max_soc_percent: int | None - usable_capacity_wh: int | None - max_charge_a: int - max_discharge_a: int - deye_last_system_time_sync_minute: datetime | None = None - deye_last_system_time_sync_at: datetime | None = None - deye_last_tou_inactive_write_prague_date: date | None = None - deye_tou_inactive_signature: str | None = None - deye_zero_export_mode: int = 1 - deye_gen_microinverter_cutoff_enabled: bool = False - #: Součet nominal_power_wp controllable PV na invertoru; 0 = EMS nezapisuje reg 340. - pv_a_cap_w: int = 0 - #: True = EMS smí řídit Deye reg 340 (max solar power); z SQL `fn_site_has_active_green_bonus_pv(site_id)` — není DB sloupec na invertoru. - deye_reg340_pv_a_control_enabled: bool = False - - -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: - """UTC okamžik odpovídající začátku aktuální kalendářní minuty v Europe/Prague.""" - p = datetime.now(PRAGUE_TZ).replace(second=0, microsecond=0) - return p.astimezone(timezone.utc) - - -def _deye_registers_to_prague_datetime(r62: int, r63: int, r64: int) -> datetime | None: - """Dekódování reg 62–64 (Deye system time v Europe/Prague).""" - try: - year = (int(r62) >> 8) + 2000 - month = int(r62) & 0xFF - day = int(r63) >> 8 - hour = int(r63) & 0xFF - minute = int(r64) >> 8 - second = int(r64) & 0xFF - if not (1 <= month <= 12 and 1 <= day <= 31 and 0 <= hour <= 23): - return None - if not (0 <= minute <= 59 and 0 <= second <= 59): - return None - return datetime(year, month, day, hour, minute, second, tzinfo=PRAGUE_TZ) - except (ValueError, OverflowError): - return None - - -def _deye_clock_registers_verify_match( - w62: int, - w63: int, - w64: int, - a62: int, - a63: int, - a64: int, -) -> bool: - w_dt = _deye_registers_to_prague_datetime(w62, w63, w64) - a_dt = _deye_registers_to_prague_datetime(a62, a63, a64) - if w_dt is None or a_dt is None: - return False - return abs((a_dt - w_dt).total_seconds()) <= DEYE_CLOCK_VERIFY_MAX_DELTA_SEC - - -def _deye_should_skip_time_sync_after_read( - inv: InverterConfig, - r62: int, - r63: int, - r64: int, -) -> bool: - """ - True = nezařazovat zápis 62–64: drift je malý a od posledního úspěšného zápisu (FC 0x10 ACK) - nebo tolerančního ověření neuplynulo 24h — sloupec deye_last_system_time_sync_at doplňuje - write_inverter_setpoints po úspěšném zápisu batche obsahujícího 62–64 a znovu po úspěšném verify. - """ - dev = _deye_registers_to_prague_datetime(r62, r63, r64) - if dev is None: - return False - wall = datetime.now(PRAGUE_TZ) - drift = abs((wall - dev).total_seconds()) - if drift > DEYE_CLOCK_DRIFT_OK_SEC: - return False - last_write = inv.deye_last_system_time_sync_at - if last_write is None: - return False - if last_write.tzinfo is None: - last_write = last_write.replace(tzinfo=timezone.utc) - else: - last_write = last_write.astimezone(timezone.utc) - age = datetime.now(timezone.utc) - last_write - if age >= timedelta(hours=DEYE_CLOCK_RESYNC_INTERVAL_HOURS): - return False - return True - async def _fetch_written_deye_clock_commands( site_id: int, @@ -353,44 +125,6 @@ def _drop_registers_matching_last_verified( return out, skipped -@dataclass -class ControlSetpoints: - battery_w: int | None - grid_export_limit: int - ev1_current_a: int - ev2_current_a: int - heat_pump_enable: bool - grid_setpoint_w: int - ev1_power_w: int - ev2_power_w: int - target_soc_pct: int | None = None - #: Explicitní fyzický režim z plánu (PASSIVE/SELL/CHARGE). Pokud je vyplněn, má přednost před detekcí ze znamének. - deye_physical_mode: str | None = None - #: True = zákaz exportu (BLOCK_EXPORT) pro daný slot: např. při efektivní vykupní ceně < 0. - export_ban: bool = False - #: True = odpojit GEN port (MI export cutoff) v tomto slotu dle plánu (reg 178 bits0-1, 0-based). - #: None/False = neodpojovat. - deye_gen_cutoff_enabled: bool = False - #: Efektivní vykupní cena slotu (Kč/kWh z plánu); pro TOU řízení priorit baterie vs. přetok - effective_sell_price_czk_kwh: float | None = None - #: True = reg 108/109 na 0 (PRESERVE – Deye baterii nepoužívá) - 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 -class OperatingModeInfo: - mode_code: str - battery_mode: str - grid_mode: str - ev_enabled: bool - heat_pump_enabled_def: bool - loxone_mode_value: int - - async def create_modbus_commands( site_id: int, planning_run_id: int | None, diff --git a/backend/services/control/models.py b/backend/services/control/models.py new file mode 100644 index 0000000..7b57276 --- /dev/null +++ b/backend/services/control/models.py @@ -0,0 +1,73 @@ +"""Datové modely pro control export.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime + + +@dataclass +class InverterConfig: + id: int + code: str + host: str + port: int + unit_id: int + max_export_power_w: int | None + max_import_power_w: int | None + no_export: bool + max_battery_charge_w: int | None + max_battery_discharge_w: int | None + min_soc_percent: int | None + reserve_soc_percent: int | None + max_soc_percent: int | None + usable_capacity_wh: int | None + max_charge_a: int + max_discharge_a: int + deye_last_system_time_sync_minute: datetime | None = None + deye_last_system_time_sync_at: datetime | None = None + deye_last_tou_inactive_write_prague_date: date | None = None + deye_tou_inactive_signature: str | None = None + deye_zero_export_mode: int = 1 + deye_gen_microinverter_cutoff_enabled: bool = False + #: Součet nominal_power_wp controllable PV na invertoru; 0 = EMS nezapisuje reg 340. + pv_a_cap_w: int = 0 + #: True = EMS smí řídit Deye reg 340 (max solar power); z SQL `fn_site_has_active_green_bonus_pv(site_id)`. + deye_reg340_pv_a_control_enabled: bool = False + + +@dataclass +class ControlSetpoints: + battery_w: int | None + grid_export_limit: int + ev1_current_a: int + ev2_current_a: int + heat_pump_enable: bool + grid_setpoint_w: int + ev1_power_w: int + ev2_power_w: int + target_soc_pct: int | None = None + #: Explicitní fyzický režim z plánu (PASSIVE/SELL/CHARGE). + deye_physical_mode: str | None = None + #: True = zákaz exportu (BLOCK_EXPORT) pro daný slot. + export_ban: bool = False + #: True = odpojit GEN port (MI export cutoff) v tomto slotu dle plánu (reg 178 bits0-1). + deye_gen_cutoff_enabled: bool = False + #: Efektivní vykupní cena slotu (Kč/kWh z plánu). + effective_sell_price_czk_kwh: float | None = None + #: True = reg 108/109 na 0 (PRESERVE - Deye baterii nepoužívá). + lock_battery: bool = False + #: Režim SELF_SUSTAIN. + self_sustain_local_use: bool = False + #: Deye reg 340 (max solar power, W). None = EMS reg 340 v tomto ticku neřeší. + pv_a_allowed_w: int | None = None + + +@dataclass +class OperatingModeInfo: + mode_code: str + battery_mode: str + grid_mode: str + ev_enabled: bool + heat_pump_enabled_def: bool + loxone_mode_value: int