Add TOU SOC handling for battery priority in passive mode
All checks were successful
deploy / deploy (push) Successful in 28s
test / smoke-test (push) Successful in 6s

- Introduced `effective_sell_price_czk_kwh` to `ControlSetpoints` for managing battery usage based on sell price.
- Implemented logic in `_deye_passive_tou_battery_soc_pct` to set TOU SOC to 100% when conditions favor battery usage.
- Updated tests to validate new behavior for negative sell prices and planned charging scenarios.
- Enhanced documentation to clarify TOU SOC behavior in passive mode.
This commit is contained in:
Dusan Vojacek
2026-04-19 12:49:04 +02:00
parent d5dcf33e13
commit d3fd8b139a
3 changed files with 91 additions and 5 deletions

View File

@@ -34,6 +34,9 @@ BATT_VOLTAGE_V = 51.2
# Reg 178 pevné hodnoty (bit45); bez read-modify-write (kolize s Loxone / transaction ID)
REG178_SELL = 0b00100000 # 32, grid peak shaving disable
REG178_PASSIVE = 0b00110000 # 48, grid peak shaving enable (PASSIVE i CHARGE)
# TOU reg 166+ ve PASSIVE při prioritě baterie: signál střídači „využij celý dostupný rozsah“,
# ne provozní strop z DB (ten je pro LP / Wh viz asset_battery.max_soc_percent).
DEYE_TOU_SOC_PASSIVE_BATTERY_PRIORITY_PCT = 100
# Verify: jen bity 45 (horní byte layout v dokumentaci); ostatní bity mohou mít firmware / Loxone
REG178_VERIFY_MASK = 0x0030
@@ -301,6 +304,8 @@ class ControlSetpoints:
ev1_power_w: int
ev2_power_w: int
target_soc_pct: int | None = None
#: 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
@@ -1153,6 +1158,8 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
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
sell_raw = pi.get("effective_sell_price")
sell_f: float | None = float(sell_raw) if sell_raw is not None else None
return ControlSetpoints(
battery_w=int(pi["battery_setpoint_w"] or 0),
grid_export_limit=abs(min(grid_sp, 0)),
@@ -1163,6 +1170,7 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
ev1_power_w=ev1_w,
ev2_power_w=ev2_w,
target_soc_pct=target_soc,
effective_sell_price_czk_kwh=sell_f,
)
if code == "SELF_SUSTAIN":
@@ -1234,6 +1242,7 @@ def _apply_price_failsafe_guard(
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,
)
@@ -1248,6 +1257,11 @@ def _clamp_deye_tou_soc_pct(pct: int) -> int:
return max(5, min(95, pct))
def _clamp_deye_tou_soc_pct_hi(pct: int, hi: int) -> int:
"""Stejné dolní omezení 5 % jako u TOU; horní mez z parametru (např. 100 u priority baterie)."""
return max(5, min(int(hi), int(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))
@@ -1260,6 +1274,36 @@ def _deye_tou_reserve_soc_pct(inv: InverterConfig) -> int:
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.
Na home-01 Deye interpretuje TOU % jako „kam má směřovat využití baterie“:
je-li zapsané procento **nižší než skutečný SoC**, přebytek FVE míří spíš do sítě.
Při **záporné vykupní** nebo **plánovaném nabíjení** (kladný ``battery_w``) EMS
zapíše **100 %** do TOU (signál střídači „ber přebytek do baterie v celém rozsahu“).
**``max_soc_percent`` v DB** je odděleně: horní limit pro **plánovač / Wh bilance**
(denní provoz, viz komentář sloupce), **nikoli** časové „do kdy“.
Jinak zůstane provozní podlaha ``min_soc_percent`` (typicky nízká % → přetok do sítě
možný dle chování Deye).
"""
mn = _deye_tou_min_soc_pct(inv)
bat_w = 0 if setpoints.battery_w is None else int(setpoints.battery_w)
sell = setpoints.effective_sell_price_czk_kwh
want_battery_priority = bat_w > 0 or (sell is not None and float(sell) < 0)
if not want_battery_priority:
return mn
return _clamp_deye_tou_soc_pct_hi(DEYE_TOU_SOC_PASSIVE_BATTERY_PRIORITY_PCT, hi=100)
def get_deye_mode(setpoints: ControlSetpoints) -> str:
"""
Fyzický režim Deye: SELL | CHARGE | PASSIVE.
@@ -1283,8 +1327,8 @@ def _deye_tou_params(
inv: InverterConfig,
) -> tuple[int, int, bool]:
"""
Parametry jednoho Deye time pointu: výkon W, SOC min %, grid_charge.
Musí odpovídat logice get_deye_mode / lock_battery v write_inverter_setpoints.
Parametry jednoho Deye time pointu: výkon W, SOC % (TOU reg 166+), grid_charge.
Ve PASSIVE viz _deye_passive_tou_battery_soc_pct (min vs. plný max z DB).
"""
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
@@ -1304,7 +1348,8 @@ def _deye_tou_params(
return tp_charge_w, target_soc, True
if deye_mode == "SELL":
return tp_discharge_w, tou_reserve, False
return tp_discharge_w, tou_min, False
tou_soc = _deye_passive_tou_battery_soc_pct(inv, setpoints)
return tp_discharge_w, tou_soc, False
async def write_inverter_setpoints(
@@ -1746,6 +1791,7 @@ async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None:
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=None,
effective_sell_price_czk_kwh=None,
)
sp_next = sp_now
else: