refactor control setpoint calculations

This commit is contained in:
Dusan Vojacek
2026-05-02 19:40:16 +02:00
parent 6d6341cde8
commit 0ca1bed0fd
2 changed files with 310 additions and 312 deletions

View File

@@ -20,6 +20,7 @@ from services.control.deye_helpers import (
DEYE_CLOCK_REGS,
DEYE_CLOCK_RESYNC_INTERVAL_HOURS,
DEYE_CLOCK_VERIFY_MAX_DELTA_SEC, # noqa: F401 - re-export for compatibility
DEYE_CRITICAL_REGS_SELF_SUSTAIN, # noqa: F401 - re-export for compatibility
DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A,
DEYE_REGISTER_NAMES,
DEYE_TOU_INACTIVE_HHMM,
@@ -49,6 +50,21 @@ from services.control.deye_helpers import (
watts_to_amps,
)
from services.control.models import ControlSetpoints, InverterConfig, OperatingModeInfo
from services.control.setpoints import (
_DictRecord,
_apply_price_failsafe_guard,
_build_setpoints,
_clamp_deye_tou_soc_pct,
_deye_passive_tou_battery_soc_pct,
_deye_reg143_export_w,
_deye_system_time_register_rows,
_deye_time_point_rows,
_deye_tou_min_soc_pct,
_deye_tou_params,
_deye_tou_reserve_soc_pct,
_deye_zero_export_amps_for_passive,
get_deye_mode,
)
from services.modbus_client import get_modbus_client
from services.signal_service import enqueue_site_signals
@@ -917,36 +933,6 @@ async def _load_inverter_config(
)
def _deye_system_time_register_rows() -> tuple[datetime, list[tuple[int, str, int]]]:
"""Hodnoty pro reg 6264 (Europe/Prague); sekundy v reg 64 = 0 (stabilnější zápis)."""
now = datetime.now(PRAGUE_TZ).replace(second=0, microsecond=0)
reg62 = ((now.year - 2000) << 8) | now.month
reg63 = (now.day << 8) | now.hour
reg64 = (now.minute << 8) | 0
rows = [
(62, "", reg62),
(63, "", reg63),
(64, "", reg64),
]
return now, rows
def _deye_time_point_rows(
slot_index: int,
time_hhmm: int,
power_w: int,
soc_pct: int,
grid_charge: bool,
) -> list[tuple[int, str, int]]:
g = 1 if grid_charge else 0
return [
(148 + slot_index, "", time_hhmm),
(154 + slot_index, "", power_w),
(166 + slot_index, "", soc_pct),
(172 + slot_index, "", g),
]
async def _fetch_plan_row_for_slot_offset(
site_id: int, db: asyncpg.Connection, slot_offset: int
) -> asyncpg.Record | None:
@@ -974,288 +960,6 @@ async def _fetch_max_charge_power_w(site_id: int, db: asyncpg.Connection) -> int
return int(v or 0)
class _DictRecord:
"""Minimální asyncpg Record kompatibilita pro dict z jsonb."""
__slots__ = ("_d",)
def __init__(self, d: dict[str, Any]) -> None:
self._d = d
def __getitem__(self, k: str) -> Any:
return self._d[k]
def get(self, k: str, default: Any = None) -> Any:
return self._d.get(k, default)
def __contains__(self, k: str) -> bool:
return k in self._d
def _build_setpoints(
mode: OperatingModeInfo,
pi: asyncpg.Record | None,
*,
pv_a_cap_w: int = 0,
reg340_pv_a_control_enabled: bool = False,
) -> ControlSetpoints | None:
code = mode.mode_code
if code == "MANUAL":
return None
if code == "AUTO":
if pi is None:
return None
grid_sp = int(pi["grid_setpoint_w"] or 0)
ev1_w = int(pi["ev1_setpoint_w"] or 0) if "ev1_setpoint_w" in pi else 0
ev2_w = int(pi["ev2_setpoint_w"] or 0) if "ev2_setpoint_w" in pi else 0
hp_en = bool(pi["heat_pump_enabled"])
tgt = pi["battery_soc_target_pct"]
target_soc = int(round(float(tgt))) if tgt is not None else None
pm_raw = pi.get("deye_physical_mode")
pm: str | None = str(pm_raw).strip().upper() if pm_raw is not None else None
sell_raw = pi.get("effective_sell_price")
sell_f: float | None = float(sell_raw) if sell_raw is not None else None
# Záporný výkup sám o sobě neblokuje export, pokud plán export explicitně žádá (soulad s LP).
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 bool(reg340_pv_a_control_enabled) 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)
# Home-01 strategie: pokud jsou zároveň buy<0 i sell<0 a PV B vyrábí (necurtailable),
# chceme držet baterii „prázdnější“ pro PV B / další záporný nákup a PV A raději odstavit
# i když forecast PV A je nulový (predikce/telemetrie může být odpojená).
buy_raw = pi.get("effective_buy_price")
buy_f: float | None = float(buy_raw) if buy_raw is not None else None
pv_b = int(pi.get("pv_b_forecast_solver_w") or 0)
if (
buy_f is not None
and sell_f is not None
and float(buy_f) < 0.0
and float(sell_f) < 0.0
and pv_b > 0
):
pv_a_allowed = 0
return ControlSetpoints(
battery_w=int(pi["battery_setpoint_w"] or 0),
grid_export_limit=abs(min(grid_sp, 0)),
ev1_current_a=watts_to_amps(ev1_w, phases=3),
ev2_current_a=watts_to_amps(ev2_w, phases=1),
heat_pump_enable=hp_en,
grid_setpoint_w=grid_sp,
ev1_power_w=ev1_w,
ev2_power_w=ev2_w,
target_soc_pct=target_soc,
deye_physical_mode=pm,
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":
return ControlSetpoints(
battery_w=None,
grid_export_limit=0,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=0,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=None,
self_sustain_local_use=True,
)
if code == "CHARGE_CHEAP":
# max_charge doplníme v export_setpoints z DB
return ControlSetpoints(
battery_w=0,
grid_export_limit=0,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=0,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=None,
)
if code == "PRESERVE":
return ControlSetpoints(
battery_w=0,
grid_export_limit=0,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=0,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=None,
lock_battery=True,
)
logger.warning("Unknown mode_code %s for site export, skipping", code)
return None
def _apply_price_failsafe_guard(
site_id: int,
mode: OperatingModeInfo,
pi: asyncpg.Record | None,
sp: ControlSetpoints,
) -> ControlSetpoints:
if mode.mode_code != "AUTO" or pi is None:
return sp
if "is_predicted_price" not in pi or not bool(pi["is_predicted_price"]):
return sp
logger.warning(
"control export site=%s: AUTO slot uses predicted price -> forcing PASSIVE no-export guard",
site_id,
)
return ControlSetpoints(
battery_w=0,
grid_export_limit=0,
ev1_current_a=sp.ev1_current_a,
ev2_current_a=sp.ev2_current_a,
heat_pump_enable=sp.heat_pump_enable,
grid_setpoint_w=max(0, int(sp.grid_setpoint_w or 0)),
ev1_power_w=sp.ev1_power_w,
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,
)
def _deye_reg143_export_w(no_export: bool, max_export_power_w: int | None) -> int:
"""Reg 143 max export W z DB (např. SUN-20K / home-01 = 13 500 W)."""
if no_export:
return 0
return max(0, int(max_export_power_w or 0))
def _clamp_deye_tou_soc_pct(pct: int) -> int:
return max(5, min(95, pct))
def _deye_tou_min_soc_pct(inv: InverterConfig) -> int:
if inv.min_soc_percent is not None:
return _clamp_deye_tou_soc_pct(int(inv.min_soc_percent))
return 10
def _deye_tou_reserve_soc_pct(inv: InverterConfig) -> int:
if inv.reserve_soc_percent is not None:
return _clamp_deye_tou_soc_pct(int(inv.reserve_soc_percent))
return 20
def _deye_passive_tou_battery_soc_pct(
inv: InverterConfig, _setpoints: ControlSetpoints
) -> int:
"""
Hodnota SOC u Deye TOU řádku (reg 166+) ve fyzickém PASSIVE.
Vždy provozní minimum z DB (**``min_soc_percent``**, clamp 595 jako u všech TOU SOC)
— signál „spodní pásmo“ pro firmware, aby baterii šlo použít pro překrytí zátěže bez
snahy o vysoký cílový SoC jen přes TOU.
Riziko spojené v minulosti s nízkým TOU („přebytek FVE tíhne do sítě“ při nízkém %
oproti skutečnému SoC) řeší **LP**, **145** (**``export_ban``** při záporné vykupní),
řez GEN (**178**) a další páky — ne zvyšování TOU nad **min_soc**. Přímé dobíjení ze
sítě a cílové horní pásmo: větev **CHARGE** v ``_deye_tou_params`` (**``max_soc_percent``**).
Argument ``_setpoints`` zůstává kvůli volajícím API; hodnoty z něj PASSIVE SOC nebere.
"""
return _deye_tou_min_soc_pct(inv)
def _deye_zero_export_amps_for_passive(
grid_w: int,
bat_w: int,
max_charge_a: int,
max_discharge_a: int,
) -> tuple[int, int]:
"""
PASSIVE (zero export k CT/zátěži, reg. 142 dle DB): výchozí plné 108/109.
- Export v plánu (grid_w < 0) a žádné plánované vybíjení (bat_w >= 0): **108 = 0** — nepřebírat
přebytek FVE do baterie, ať může jít přetok do sítě.
- Import v plánu (grid_w > 0) a žádné plánované nabíjení (bat_w <= 0): **109 = 0** — nevybíjet
baterii, odběr ze sítě.
"""
if grid_w < 0 and bat_w >= 0:
return 0, max_discharge_a
if grid_w > 0 and bat_w <= 0:
return max_charge_a, 0
return max_charge_a, max_discharge_a
def get_deye_mode(setpoints: ControlSetpoints) -> str:
"""
Fyzický režim Deye: SELL | CHARGE | PASSIVE.
Primárně explicitně z plánu (`setpoints.deye_physical_mode`), fallback jen ze znamének (viz
``docs/04-modules/operating-modes.md``):
- **CHARGE** — ``battery_w`` > 0 **a** ``grid_setpoint_w`` > 0 (nabíjení ze sítě + import v plánu).
- **SELL** — ``grid_setpoint_w`` < 0 **a** ``battery_w`` < 0 (export + vybíjení baterie v plánu).
- **PASSIVE** (ZERO) — vše ostatní; reg. **108/109** dle ``_deye_zero_export_amps_for_passive``.
``battery_w=None`` (SELF_SUSTAIN) → bat_w 0 → typicky PASSIVE; v ``write_inverter_setpoints`` má
SELF_SUSTAIN vlastní větev (108/109 max).
"""
pm = (setpoints.deye_physical_mode or "").strip().upper()
if pm in {"PASSIVE", "SELL", "CHARGE"}:
return pm
grid_w = int(setpoints.grid_setpoint_w or 0)
bat_w = 0 if setpoints.battery_w is None else int(setpoints.battery_w)
if bat_w > 0 and grid_w > 0:
return "CHARGE"
if grid_w < 0 and bat_w < 0:
return "SELL"
return "PASSIVE"
def _deye_tou_params(
setpoints: ControlSetpoints,
inv: InverterConfig,
) -> tuple[int, int, bool]:
"""
Parametry jednoho Deye time pointu: výkon W, SOC % (TOU reg 166+), grid_charge.
Ve PASSIVE: TOU SOC = ``min_soc_percent`` z DB; v CHARGE: horní hraniční SoC =
``asset_battery.max_soc_percent`` (clamp 10100).
"""
max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V)
tp_discharge_w = 0 if setpoints.lock_battery else max_batt_w_discharge
tou_min = _deye_tou_min_soc_pct(inv)
tou_reserve = _deye_tou_reserve_soc_pct(inv)
if setpoints.lock_battery:
return tp_discharge_w, tou_min, False
deye_mode = get_deye_mode(setpoints)
if deye_mode == "CHARGE":
raw_bat = setpoints.battery_w
battery_w = int(raw_bat) if raw_bat is not None else 0
cap = int(inv.max_soc_percent) if inv.max_soc_percent is not None else 95
target_soc = max(10, min(100, cap))
tp_charge_w = (
battery_watts_to_amps(battery_w, int(inv.max_charge_a)) * int(BATT_VOLTAGE_V)
)
return tp_charge_w, target_soc, True
if deye_mode == "SELL":
return tp_discharge_w, tou_reserve, False
tou_soc = _deye_passive_tou_battery_soc_pct(inv, setpoints)
return tp_discharge_w, tou_soc, False
async def write_inverter_setpoints(
site_id: int,
setpoints_now: ControlSetpoints,

View File

@@ -0,0 +1,294 @@
"""Výpočet control setpointů a Deye TOU parametrů."""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from services.control.deye_helpers import (
BATT_VOLTAGE_V,
PRAGUE_TZ,
battery_watts_to_amps,
compute_pv_a_reg340_max_solar_w,
watts_to_amps,
)
from services.control.models import ControlSetpoints, InverterConfig, OperatingModeInfo
logger = logging.getLogger(__name__)
def _deye_system_time_register_rows() -> tuple[datetime, list[tuple[int, str, int]]]:
"""Hodnoty pro reg 62-64 (Europe/Prague); sekundy v reg 64 = 0 (stabilnější zápis)."""
now = datetime.now(PRAGUE_TZ).replace(second=0, microsecond=0)
reg62 = ((now.year - 2000) << 8) | now.month
reg63 = (now.day << 8) | now.hour
reg64 = (now.minute << 8) | 0
rows = [
(62, "", reg62),
(63, "", reg63),
(64, "", reg64),
]
return now, rows
def _deye_time_point_rows(
slot_index: int,
time_hhmm: int,
power_w: int,
soc_pct: int,
grid_charge: bool,
) -> list[tuple[int, str, int]]:
g = 1 if grid_charge else 0
return [
(148 + slot_index, "", time_hhmm),
(154 + slot_index, "", power_w),
(166 + slot_index, "", soc_pct),
(172 + slot_index, "", g),
]
class _DictRecord:
"""Minimální asyncpg Record kompatibilita pro dict z jsonb."""
__slots__ = ("_d",)
def __init__(self, d: dict[str, Any]) -> None:
self._d = d
def __getitem__(self, k: str) -> Any:
return self._d[k]
def get(self, k: str, default: Any = None) -> Any:
return self._d.get(k, default)
def __contains__(self, k: str) -> bool:
return k in self._d
def _build_setpoints(
mode: OperatingModeInfo,
pi: Any | None,
*,
pv_a_cap_w: int = 0,
reg340_pv_a_control_enabled: bool = False,
) -> ControlSetpoints | None:
code = mode.mode_code
if code == "MANUAL":
return None
if code == "AUTO":
if pi is None:
return None
grid_sp = int(pi["grid_setpoint_w"] or 0)
ev1_w = int(pi["ev1_setpoint_w"] or 0) if "ev1_setpoint_w" in pi else 0
ev2_w = int(pi["ev2_setpoint_w"] or 0) if "ev2_setpoint_w" in pi else 0
hp_en = bool(pi["heat_pump_enabled"])
tgt = pi["battery_soc_target_pct"]
target_soc = int(round(float(tgt))) if tgt is not None else None
pm_raw = pi.get("deye_physical_mode")
pm: str | None = str(pm_raw).strip().upper() if pm_raw is not None else None
sell_raw = pi.get("effective_sell_price")
sell_f: float | None = float(sell_raw) if sell_raw is not None else None
# Záporný výkup sám o sobě neblokuje export, pokud plán export explicitně žádá.
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 bool(reg340_pv_a_control_enabled) 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)
buy_raw = pi.get("effective_buy_price")
buy_f: float | None = float(buy_raw) if buy_raw is not None else None
pv_b = int(pi.get("pv_b_forecast_solver_w") or 0)
if (
buy_f is not None
and sell_f is not None
and float(buy_f) < 0.0
and float(sell_f) < 0.0
and pv_b > 0
):
pv_a_allowed = 0
return ControlSetpoints(
battery_w=int(pi["battery_setpoint_w"] or 0),
grid_export_limit=abs(min(grid_sp, 0)),
ev1_current_a=watts_to_amps(ev1_w, phases=3),
ev2_current_a=watts_to_amps(ev2_w, phases=1),
heat_pump_enable=hp_en,
grid_setpoint_w=grid_sp,
ev1_power_w=ev1_w,
ev2_power_w=ev2_w,
target_soc_pct=target_soc,
deye_physical_mode=pm,
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":
return ControlSetpoints(
battery_w=None,
grid_export_limit=0,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=0,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=None,
self_sustain_local_use=True,
)
if code == "CHARGE_CHEAP":
return ControlSetpoints(
battery_w=0,
grid_export_limit=0,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=0,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=None,
)
if code == "PRESERVE":
return ControlSetpoints(
battery_w=0,
grid_export_limit=0,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=0,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=None,
lock_battery=True,
)
logger.warning("Unknown mode_code %s for site export, skipping", code)
return None
def _apply_price_failsafe_guard(
site_id: int,
mode: OperatingModeInfo,
pi: Any | None,
sp: ControlSetpoints,
) -> ControlSetpoints:
if mode.mode_code != "AUTO" or pi is None:
return sp
if "is_predicted_price" not in pi or not bool(pi["is_predicted_price"]):
return sp
logger.warning(
"control export site=%s: AUTO slot uses predicted price -> forcing PASSIVE no-export guard",
site_id,
)
return ControlSetpoints(
battery_w=0,
grid_export_limit=0,
ev1_current_a=sp.ev1_current_a,
ev2_current_a=sp.ev2_current_a,
heat_pump_enable=sp.heat_pump_enable,
grid_setpoint_w=max(0, int(sp.grid_setpoint_w or 0)),
ev1_power_w=sp.ev1_power_w,
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,
)
def _deye_reg143_export_w(no_export: bool, max_export_power_w: int | None) -> int:
"""Reg 143 - max export W z DB (např. SUN-20K / home-01 = 13 500 W)."""
if no_export:
return 0
return max(0, int(max_export_power_w or 0))
def _clamp_deye_tou_soc_pct(pct: int) -> int:
return max(5, min(95, pct))
def _deye_tou_min_soc_pct(inv: InverterConfig) -> int:
if inv.min_soc_percent is not None:
return _clamp_deye_tou_soc_pct(int(inv.min_soc_percent))
return 10
def _deye_tou_reserve_soc_pct(inv: InverterConfig) -> int:
if inv.reserve_soc_percent is not None:
return _clamp_deye_tou_soc_pct(int(inv.reserve_soc_percent))
return 20
def _deye_passive_tou_battery_soc_pct(
inv: InverterConfig, _setpoints: ControlSetpoints
) -> int:
"""Hodnota SOC u Deye TOU řádku (reg 166+) ve fyzickém PASSIVE."""
return _deye_tou_min_soc_pct(inv)
def _deye_zero_export_amps_for_passive(
grid_w: int,
bat_w: int,
max_charge_a: int,
max_discharge_a: int,
) -> tuple[int, int]:
"""
PASSIVE (zero export k CT/zátěži): výchozí plné 108/109.
Export v plánu bez vybíjení baterie vypne charge A; import bez nabíjení vypne discharge A.
"""
if grid_w < 0 and bat_w >= 0:
return 0, max_discharge_a
if grid_w > 0 and bat_w <= 0:
return max_charge_a, 0
return max_charge_a, max_discharge_a
def get_deye_mode(setpoints: ControlSetpoints) -> str:
"""Fyzický režim Deye: SELL | CHARGE | PASSIVE."""
pm = (setpoints.deye_physical_mode or "").strip().upper()
if pm in {"PASSIVE", "SELL", "CHARGE"}:
return pm
grid_w = int(setpoints.grid_setpoint_w or 0)
bat_w = 0 if setpoints.battery_w is None else int(setpoints.battery_w)
if bat_w > 0 and grid_w > 0:
return "CHARGE"
if grid_w < 0 and bat_w < 0:
return "SELL"
return "PASSIVE"
def _deye_tou_params(
setpoints: ControlSetpoints,
inv: InverterConfig,
) -> tuple[int, int, bool]:
"""Parametry jednoho Deye time pointu: výkon W, SOC % (TOU reg 166+), grid_charge."""
max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V)
tp_discharge_w = 0 if setpoints.lock_battery else max_batt_w_discharge
tou_min = _deye_tou_min_soc_pct(inv)
tou_reserve = _deye_tou_reserve_soc_pct(inv)
if setpoints.lock_battery:
return tp_discharge_w, tou_min, False
deye_mode = get_deye_mode(setpoints)
if deye_mode == "CHARGE":
raw_bat = setpoints.battery_w
battery_w = int(raw_bat) if raw_bat is not None else 0
cap = int(inv.max_soc_percent) if inv.max_soc_percent is not None else 95
target_soc = max(10, min(100, cap))
tp_charge_w = (
battery_watts_to_amps(battery_w, int(inv.max_charge_a)) * int(BATT_VOLTAGE_V)
)
return tp_charge_w, target_soc, True
if deye_mode == "SELL":
return tp_discharge_w, tou_reserve, False
tou_soc = _deye_passive_tou_battery_soc_pct(inv, setpoints)
return tp_discharge_w, tou_soc, False