refactor-control-monolith #2
236
backend/services/control/deye_helpers.py
Normal file
236
backend/services/control/deye_helpers.py
Normal file
@@ -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
|
||||||
@@ -7,281 +7,53 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import datetime, timezone
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.config import get_settings
|
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.modbus_client import get_modbus_client
|
||||||
from services.signal_service import enqueue_site_signals
|
from services.signal_service import enqueue_site_signals
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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(
|
async def _fetch_written_deye_clock_commands(
|
||||||
site_id: int,
|
site_id: int,
|
||||||
@@ -353,44 +125,6 @@ def _drop_registers_matching_last_verified(
|
|||||||
return out, skipped
|
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(
|
async def create_modbus_commands(
|
||||||
site_id: int,
|
site_id: int,
|
||||||
planning_run_id: int | None,
|
planning_run_id: int | None,
|
||||||
|
|||||||
73
backend/services/control/models.py
Normal file
73
backend/services/control/models.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user