deye_battery_charge_discharge_amps: v PASSIVE+PV_SURPLUS reg 108 = max když plán chce nabíjet (bat_w>0) místo tvrdé 0; baterka nabere co zvládne, přebytek nad nabíjecí rychlost do sítě. + kalibrace: SoC u maxima → dojet na 100% (BMS). Sell beze změny. Vědomě přepsán test starého chování. 365 passed. Všechny Deye lokality. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
540 lines
19 KiB
Python
540 lines
19 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__)
|
||
|
||
#: Tolerance pod max SoC, v rámci níž se v PV přebytku nechá baterka dojet na max
|
||
#: (reg 108 = max) kvůli BMS rekalibraci SoC (LiFePO4 potřebuje občas na 100 %).
|
||
BATTERY_CALIB_TOPOFF_MARGIN_PCT = 3.0
|
||
|
||
|
||
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)
|
||
bat_w = int(pi["battery_setpoint_w"] or 0)
|
||
# Záporný výkup sám o sobě neblokuje export, pokud plán export explicitně žádá.
|
||
# A nesmí blokovat ani IMPORT na nabití baterie (CHARGE / grid>0 & bat>0) —
|
||
# jinak MI cut-off (178) / 145=0 zbytečně odstaví pole B a Deye nenabije
|
||
# ze sítě v záporných cenách (bug 2026-06-13). §6 blokuje jen export.
|
||
is_grid_charge = pm == "CHARGE" or (grid_sp > 0 and bat_w > 0)
|
||
export_ban = (
|
||
sell_f is not None
|
||
and float(sell_f) < 0
|
||
and grid_sp >= 0
|
||
and not is_grid_charge
|
||
)
|
||
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)
|
||
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)
|
||
# Záporný buy i sell + pole B: pole A = 0 MÁ PŘEDNOST před úsvitovou
|
||
# výjimkou (při hluboce záporných cenách se reg 340 posílá vždy).
|
||
_low_pv_no_reg340_w = 1500
|
||
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
|
||
elif (
|
||
# Slabý úsvit: neposílat reg 340 — forecast nepřesný, Deye řídí sám (108/109/142).
|
||
forecast < _low_pv_no_reg340_w
|
||
and curtail <= 0
|
||
and pv_b > 0
|
||
):
|
||
pv_a_allowed = None
|
||
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, *, hard_ban: bool = True
|
||
) -> ControlSetpoints:
|
||
"""
|
||
PASSIVE, žádný vývoz do sítě z plánu (143=0, grid_setpoint>=0, baterie nevybíjí do sítě).
|
||
|
||
``hard_ban=True`` (záporná vykupní): navíc export_ban (145=0) a MI cut-off na GEN
|
||
portu (reg 178) — přebytek pole B NESMÍ do sítě.
|
||
``hard_ban=False`` (kladná vykupní, plán jen nechce exportovat baterii/stringy):
|
||
mikroinvertory NEodstavovat — jejich výroba se absorbuje do baterie/zátěže a
|
||
případný fyzický přetok se při kladné ceně prodá (cut-off by výrobu zahodil).
|
||
"""
|
||
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=bool(sp.export_ban) or hard_ban,
|
||
deye_gen_cutoff_enabled=bool(sp.deye_gen_cutoff_enabled) or hard_ban,
|
||
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)
|
||
|
||
# Carve-out: nabíjecí / importní slot NENÍ export. Guard řeší jen zákaz
|
||
# exportu při sell<0 — když plán importuje na nabití baterie (CHARGE, nebo
|
||
# grid_sp>0 & bat_sp>0), překlopení na PASSIVE by zařízlo grid charge
|
||
# (bug 2026-06-13: baterie se nedobila v záporných cenách). §6 zakazuje
|
||
# jen export, ne import (§7).
|
||
pm = str(pi.get("deye_physical_mode") or "").strip().upper()
|
||
bat_sp = int(pi.get("battery_setpoint_w") or 0)
|
||
if pm == "CHARGE" or (grid_sp > 0 and bat_sp > 0):
|
||
return sp
|
||
|
||
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,
|
||
)
|
||
# MI cut-off / 145=0 jen při záporné vykupní; export_mode NONE s kladnou cenou
|
||
# nesmí odstavit pole B (BA81 2026-06-12: cutoff při sell +1.36 → výroba MI zahozena).
|
||
return _passive_no_export_guard(sp, hard_ban=neg_sell)
|
||
|
||
|
||
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,
|
||
current_soc_pct: float | None = None,
|
||
max_soc_pct: int | None = None,
|
||
) -> tuple[int | None, int]:
|
||
"""
|
||
Proud nabíjení / vybíjení (reg 108 / 109) pro zápis Deye.
|
||
|
||
**PV_SURPLUS** (PASSIVE, export FVE) — reg 108 SLEDUJE charge intent plánu (fix 2026-06-16):
|
||
- `bat_w > 0` (plán chce nabíjet z přebytku) → **108 = max**: baterie nabere kolik fyzicky
|
||
zvládne (nabíjecí rychlost), přebytek NAD ni jde do sítě (BA81: výroba 12 kW > rychlost
|
||
6 kW → 6 do baterky, 6 ven). Dřív tvrdě 108=0 i při bat_w>0 → baterka nenabíjela ani
|
||
levné ranní PV (control bug).
|
||
- kalibrace: SoC u maxima (`>= max_soc − margin`) + přebytek → **108 = max**, ať dojede na
|
||
100 % (BMS rekalibrace SoC). Strop drží Deye max_soc.
|
||
- jen „prodej PV a drž baterku" daleko od maxima (`bat_w <= 0`) → **108 = 0**, přebytek ven.
|
||
|
||
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,
|
||
):
|
||
# reg 108 sleduje charge intent: nabíjet z přebytku (bat_w>0) nebo dojet na max
|
||
# kvůli BMS kalibraci (SoC u maxima + přebytek) → 108 = max; jinak 108 = 0 (přebytek
|
||
# ven). Strop SoC drží Deye max_soc, takže 108=max nepřebije nad povolené.
|
||
near_full_calib = (
|
||
current_soc_pct is not None
|
||
and max_soc_pct is not None
|
||
and float(current_soc_pct) >= float(max_soc_pct) - BATTERY_CALIB_TOPOFF_MARGIN_PCT
|
||
)
|
||
if bat_w > 0 or near_full_calib:
|
||
return int(max_charge_a), int(max_discharge_a)
|
||
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
|