Files
ems/backend/services/control/setpoints.py
Dusan Vojacek a03b45d4a9
Some checks failed
CI and deploy / migration-check (push) Failing after 10s
CI and deploy / deploy (push) Has been skipped
oprava zbytecneho curtailu A
2026-05-30 23:23:17 +02:00

485 lines
16 KiB
Python

"""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 plan_skips_deye_reg340_write(
*,
battery_setpoint_w: int,
grid_setpoint_w: int,
export_mode: str | None,
export_limit_w: int,
pv_a_curtailed_w: int,
) -> bool:
"""
Nezapisovat reg 340: plán neexportuje, nenabíjí baterii a neškrtí pole A.
Deye sám řídí PV A přes 108/109/142 (zero export + 0 A nabíjení).
"""
em = (export_mode or "").strip().upper()
if em == "NONE":
no_export = True
elif int(grid_setpoint_w) < 0 or int(export_limit_w) > 0:
no_export = False
else:
no_export = True
return (
no_export
and int(battery_setpoint_w) <= 0
and int(pv_a_curtailed_w) <= 0
)
def _build_setpoints(
mode: OperatingModeInfo,
pi: Any | None,
*,
pv_a_cap_w: int = 0,
pv_a_reg340_min_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)
export_limit_raw = pi.get("export_limit_w")
export_limit = int(export_limit_raw) if export_limit_raw is not None else abs(min(grid_sp, 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
export_mode_raw = pi.get("export_mode")
export_mode = str(export_mode_raw).strip().upper() if export_mode_raw is not None else None
if export_mode == "NONE":
export_limit = 0
elif export_limit <= 0 and grid_sp < 0:
export_limit = abs(grid_sp)
# 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
bat_w = int(pi["battery_setpoint_w"] or 0)
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)
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)
# Slabý úsvit: neposílat reg 340 — forecast nepřesný, Deye řídí sám (108/109/142).
_low_pv_no_reg340_w = 1500
if (
forecast < _low_pv_no_reg340_w
and curtail <= 0
and pv_b > 0
):
pv_a_allowed = None
elif (
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
elif plan_skips_deye_reg340_write(
battery_setpoint_w=bat_w,
grid_setpoint_w=grid_sp,
export_mode=export_mode,
export_limit_w=max(0, export_limit),
pv_a_curtailed_w=curtail,
):
pv_a_allowed = None
else:
pv_a_allowed = compute_pv_a_reg340_max_solar_w(
int(pv_a_cap_w),
forecast,
curtail,
min_w=int(pv_a_reg340_min_w),
)
return ControlSetpoints(
battery_w=bat_w,
grid_export_limit=max(0, export_limit),
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_mode=export_mode,
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 _passive_no_export_guard(sp: ControlSetpoints) -> ControlSetpoints:
"""PASSIVE, žádný vývoz do sítě; vybíjení baterie do sítě vynulováno (reg 109 přes export_ban)."""
bat = int(sp.battery_w or 0)
if bat < 0:
bat = 0
return ControlSetpoints(
battery_w=bat,
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,
deye_physical_mode="PASSIVE",
export_mode="NONE",
export_ban=True,
deye_gen_cutoff_enabled=sp.deye_gen_cutoff_enabled,
effective_sell_price_czk_kwh=sp.effective_sell_price_czk_kwh,
pv_a_allowed_w=sp.pv_a_allowed_w,
lock_battery=sp.lock_battery,
self_sustain_local_use=sp.self_sustain_local_use,
)
def _apply_export_plan_guard(
site_id: int,
mode: OperatingModeInfo,
pi: Any | None,
sp: ControlSetpoints,
) -> ControlSetpoints:
"""
Exekuční pojistka: plán zakazuje vývoz (záporná vykupní nebo export_mode NONE),
ale Deye může zůstat v SELL — vynutit PASSIVE a export_ban před zápisem Modbus.
"""
if mode.mode_code != "AUTO" or pi is None:
return sp
sell_raw = pi.get("effective_sell_price")
sell_f: float | None = (
float(sell_raw) if sell_raw is not None else sp.effective_sell_price_czk_kwh
)
export_mode_raw = pi.get("export_mode")
export_mode = (
str(export_mode_raw).strip().upper()
if export_mode_raw is not None
else (sp.export_mode or "")
)
grid_sp = int(pi.get("grid_setpoint_w") or sp.grid_setpoint_w or 0)
neg_sell = sell_f is not None and float(sell_f) < 0
plan_no_export = export_mode == "NONE" and grid_sp >= 0
if not neg_sell and not plan_no_export:
return sp
reason = "neg_sell" if neg_sell else "export_mode_none"
logger.warning(
"control export site=%s: AUTO export plan guard (%s) -> PASSIVE no-export",
site_id,
reason,
)
return _passive_no_export_guard(sp)
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 _is_passive_pv_surplus_export(
*,
deye_mode: str,
export_mode: str | None,
export_ban: bool,
grid_w: int,
) -> bool:
"""
Přetok FVE do sítě v PASSIVE (ne SELL z baterie): reg 142 zůstane zero-export (1/2),
nabíjení se blokuje přes **108 = 0** — baterie nemá kam brát přebytek → jde do sítě (145).
"""
if deye_mode != "PASSIVE" or export_ban:
return False
em = (export_mode or "").strip().upper()
if em == "PV_SURPLUS":
return True
if em in {"NONE", "BATTERY_SELL"}:
return False
return grid_w < 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): asymetrie jen tam, kde dává smysl pro import.
Přetok FVE do sítě řeší větev ``_is_passive_pv_surplus_export`` (**108 = 0**). Zde jen import
bez nabíjení → vypnout vybíjení (**109 = 0**).
"""
if grid_w > 0 and bat_w <= 0:
return max_charge_a, 0
return int(max_charge_a), int(max_discharge_a)
def deye_battery_charge_discharge_amps(
*,
lock_battery: bool,
deye_mode: str,
self_sustain_local_use: bool,
bat_w: int,
grid_w: int,
max_charge_a: int,
max_discharge_a: int,
export_mode: str | None = None,
export_ban: bool = False,
) -> tuple[int | None, int]:
"""
Proud nabíjení / vybíjení (reg 108 / 109) pro zápis Deye.
**PV_SURPLUS** (PASSIVE, export FVE): **108 = 0**, **109 = max** — baterie se přes limit
nabíjení neplní, přebytek jde do sítě (142 = zero-export dle instalace, 145 = 1).
PASSIVE + nabíjení bez exportního záměru (`battery_w > 0`, export_mode NONE): **108 = max**.
**CHARGE** ze sítě: 108 z `battery_w`.
**SELL** (selling first, reg 142 = 0): vrací ``(None, max_discharge)`` — reg **108 se nezapisuje**
(export řídí 142/178; nulování 108 a obnova po návratu jsou zbytečné zápisy do paměti).
"""
if lock_battery:
return 0, 0
if deye_mode == "CHARGE":
return battery_watts_to_amps(bat_w, max_charge_a), 0
if deye_mode == "SELL":
return None, int(max_discharge_a)
if self_sustain_local_use:
return int(max_charge_a), int(max_discharge_a)
if _is_passive_pv_surplus_export(
deye_mode=deye_mode,
export_mode=export_mode,
export_ban=export_ban,
grid_w=grid_w,
):
return 0, int(max_discharge_a)
if bat_w > 0:
return int(max_charge_a), int(max_discharge_a)
return _deye_zero_export_amps_for_passive(
grid_w, bat_w, int(max_charge_a), int(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