Add TOU SOC handling for battery priority in passive mode
- 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:
@@ -34,6 +34,9 @@ BATT_VOLTAGE_V = 51.2
|
|||||||
# Reg 178 – pevné hodnoty (bit4–5); bez read-modify-write (kolize s Loxone / transaction ID)
|
# Reg 178 – pevné hodnoty (bit4–5); bez read-modify-write (kolize s Loxone / transaction ID)
|
||||||
REG178_SELL = 0b00100000 # 32, grid peak shaving disable
|
REG178_SELL = 0b00100000 # 32, grid peak shaving disable
|
||||||
REG178_PASSIVE = 0b00110000 # 48, grid peak shaving enable (PASSIVE i CHARGE)
|
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 4–5 (horní byte layout v dokumentaci); ostatní bity mohou mít firmware / Loxone
|
# Verify: jen bity 4–5 (horní byte layout v dokumentaci); ostatní bity mohou mít firmware / Loxone
|
||||||
REG178_VERIFY_MASK = 0x0030
|
REG178_VERIFY_MASK = 0x0030
|
||||||
|
|
||||||
@@ -301,6 +304,8 @@ class ControlSetpoints:
|
|||||||
ev1_power_w: int
|
ev1_power_w: int
|
||||||
ev2_power_w: int
|
ev2_power_w: int
|
||||||
target_soc_pct: int | None = None
|
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á)
|
#: True = reg 108/109 na 0 (PRESERVE – Deye baterii nepoužívá)
|
||||||
lock_battery: bool = False
|
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"])
|
hp_en = bool(pi["heat_pump_enabled"])
|
||||||
tgt = pi["battery_soc_target_pct"]
|
tgt = pi["battery_soc_target_pct"]
|
||||||
target_soc = int(round(float(tgt))) if tgt is not None else None
|
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(
|
return ControlSetpoints(
|
||||||
battery_w=int(pi["battery_setpoint_w"] or 0),
|
battery_w=int(pi["battery_setpoint_w"] or 0),
|
||||||
grid_export_limit=abs(min(grid_sp, 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,
|
ev1_power_w=ev1_w,
|
||||||
ev2_power_w=ev2_w,
|
ev2_power_w=ev2_w,
|
||||||
target_soc_pct=target_soc,
|
target_soc_pct=target_soc,
|
||||||
|
effective_sell_price_czk_kwh=sell_f,
|
||||||
)
|
)
|
||||||
|
|
||||||
if code == "SELF_SUSTAIN":
|
if code == "SELF_SUSTAIN":
|
||||||
@@ -1234,6 +1242,7 @@ def _apply_price_failsafe_guard(
|
|||||||
ev1_power_w=sp.ev1_power_w,
|
ev1_power_w=sp.ev1_power_w,
|
||||||
ev2_power_w=sp.ev2_power_w,
|
ev2_power_w=sp.ev2_power_w,
|
||||||
target_soc_pct=sp.target_soc_pct,
|
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))
|
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:
|
def _deye_tou_min_soc_pct(inv: InverterConfig) -> int:
|
||||||
if inv.min_soc_percent is not None:
|
if inv.min_soc_percent is not None:
|
||||||
return _clamp_deye_tou_soc_pct(int(inv.min_soc_percent))
|
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
|
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:
|
def get_deye_mode(setpoints: ControlSetpoints) -> str:
|
||||||
"""
|
"""
|
||||||
Fyzický režim Deye: SELL | CHARGE | PASSIVE.
|
Fyzický režim Deye: SELL | CHARGE | PASSIVE.
|
||||||
@@ -1283,8 +1327,8 @@ def _deye_tou_params(
|
|||||||
inv: InverterConfig,
|
inv: InverterConfig,
|
||||||
) -> tuple[int, int, bool]:
|
) -> tuple[int, int, bool]:
|
||||||
"""
|
"""
|
||||||
Parametry jednoho Deye time pointu: výkon W, SOC min %, grid_charge.
|
Parametry jednoho Deye time pointu: výkon W, SOC % (TOU reg 166+), grid_charge.
|
||||||
Musí odpovídat logice get_deye_mode / lock_battery v write_inverter_setpoints.
|
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)
|
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
|
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
|
return tp_charge_w, target_soc, True
|
||||||
if deye_mode == "SELL":
|
if deye_mode == "SELL":
|
||||||
return tp_discharge_w, tou_reserve, False
|
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(
|
async def write_inverter_setpoints(
|
||||||
@@ -1746,6 +1791,7 @@ async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None:
|
|||||||
ev1_power_w=0,
|
ev1_power_w=0,
|
||||||
ev2_power_w=0,
|
ev2_power_w=0,
|
||||||
target_soc_pct=None,
|
target_soc_pct=None,
|
||||||
|
effective_sell_price_czk_kwh=None,
|
||||||
)
|
)
|
||||||
sp_next = sp_now
|
sp_next = sp_now
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -62,12 +62,50 @@ class DeyeTouParamsTests(unittest.TestCase):
|
|||||||
ev1_power_w=0,
|
ev1_power_w=0,
|
||||||
ev2_power_w=0,
|
ev2_power_w=0,
|
||||||
target_soc_pct=None,
|
target_soc_pct=None,
|
||||||
|
effective_sell_price_czk_kwh=None,
|
||||||
)
|
)
|
||||||
self.assertEqual(get_deye_mode(sp), "PASSIVE")
|
self.assertEqual(get_deye_mode(sp), "PASSIVE")
|
||||||
p, s, g = _deye_tou_params(sp, _inv(min_soc=12, reserve_soc=20))
|
p, s, g = _deye_tou_params(sp, _inv(min_soc=12, reserve_soc=20))
|
||||||
self.assertFalse(g)
|
self.assertFalse(g)
|
||||||
self.assertEqual(s, 12)
|
self.assertEqual(s, 12)
|
||||||
|
|
||||||
|
def test_passive_negative_sell_steers_tou_above_current_soc(self) -> None:
|
||||||
|
"""Záporná vykupní → TOU SOC = 100 % (priorita akumulace vs. přetok)."""
|
||||||
|
sp = ControlSetpoints(
|
||||||
|
battery_w=-400,
|
||||||
|
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=14,
|
||||||
|
effective_sell_price_czk_kwh=-0.25,
|
||||||
|
)
|
||||||
|
self.assertEqual(get_deye_mode(sp), "PASSIVE")
|
||||||
|
_p, s, g = _deye_tou_params(sp, _inv(min_soc=12, reserve_soc=20))
|
||||||
|
self.assertFalse(g)
|
||||||
|
self.assertEqual(s, 100)
|
||||||
|
|
||||||
|
def test_passive_planned_charge_steers_tou(self) -> None:
|
||||||
|
sp = ControlSetpoints(
|
||||||
|
battery_w=800,
|
||||||
|
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=60,
|
||||||
|
effective_sell_price_czk_kwh=1.0,
|
||||||
|
)
|
||||||
|
self.assertEqual(get_deye_mode(sp), "PASSIVE")
|
||||||
|
_p, s, g = _deye_tou_params(sp, _inv(min_soc=12, reserve_soc=20))
|
||||||
|
self.assertFalse(g)
|
||||||
|
self.assertEqual(s, 100)
|
||||||
|
|
||||||
def test_charge_unchanged_grid_charge(self) -> None:
|
def test_charge_unchanged_grid_charge(self) -> None:
|
||||||
sp = ControlSetpoints(
|
sp = ControlSetpoints(
|
||||||
battery_w=5000,
|
battery_w=5000,
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ Solver předvybírá sloty pro nabíjení a export-vybíjení (`_select_charge_s
|
|||||||
| **178** peak shaving | 48 (PASSIVE) | 48 (PASSIVE) | **32** (SELL) | 48 (PASSIVE) |
|
| **178** peak shaving | 48 (PASSIVE) | 48 (PASSIVE) | **32** (SELL) | 48 (PASSIVE) |
|
||||||
| **143** export limit | max export W z DB | max export W z DB | max export W z DB | max export W z DB |
|
| **143** export limit | max export W z DB | max export W z DB | max export W z DB | max export W z DB |
|
||||||
| **141** energy mode | 0 | 0 | 0 | 0 |
|
| **141** energy mode | 0 | 0 | 0 | 0 |
|
||||||
| **TOU SOC** | min_soc_pct | min_soc_pct | reserve_soc_pct | min_soc_pct |
|
| **TOU SOC** (reg 166+) | viz níže | min_soc_pct | reserve_soc_pct | min_soc_pct |
|
||||||
|
|
||||||
|
**PASSIVE – TOU SOC % (home-01 / Deye):** EMS ukládá do řádku time pointu procento, které na zařízení řídí **prioritu baterie vs. přetok FVE do sítě** (viz firmware / instalace). Je-li zapsané procento **níž než skutečný SoC**, přebytek tíhne do sítě; při **záporné efektivní vykupní** (`effective_sell_price` ze slotu) nebo při **kladném `battery_setpoint_w`** (plánované nabíjení) EMS nastaví **100 %** (signál „využij baterii naplno“). **`asset_battery.max_soc_percent`** (typicky 95) je **jiný účel**: horní limit pro **plánovač / denní provoz v % SoC** (komfort, degradace, rezerva výrobce), **ne** časové „do kdy“ ani hodnota zapisovaná do tohoto TOU při této priorité. Jinak zůstane **`min_soc_percent`**.
|
||||||
|
|
||||||
**Jak funguje pass-through fyzicky:**
|
**Jak funguje pass-through fyzicky:**
|
||||||
|
|
||||||
@@ -116,7 +118,7 @@ Příklad v 14:18: blok 1 má čas **1415**, blok 2 čas **1430** – mezi 14:15
|
|||||||
|
|
||||||
| Režim | Výkon (W) | SOC min (reg 166+) | Grid charge |
|
| Režim | Výkon (W) | SOC min (reg 166+) | Grid charge |
|
||||||
|-------|-----------|---------------------|-------------|
|
|-------|-----------|---------------------|-------------|
|
||||||
| **PASSIVE** | `max_discharge_a × 51,2` | **`min_soc_percent`** z DB | NE |
|
| **PASSIVE** | `max_discharge_a × 51,2` | `_deye_passive_tou_battery_soc_pct`: při neg. vykupní / plánovaném nabíjení = **100 %**, jinak **`min_soc_percent`** | NE |
|
||||||
| **SELL** | `max_discharge_a × 51,2` | **`reserve_soc_percent`** z DB | NE |
|
| **SELL** | `max_discharge_a × 51,2` | **`reserve_soc_percent`** z DB | NE |
|
||||||
| **CHARGE** | `battery_watts_to_amps(battery_w, max_charge_a) × 51,2` | min(95, cíl SoC z plánu nebo 80) | ANO |
|
| **CHARGE** | `battery_watts_to_amps(battery_w, max_charge_a) × 51,2` | min(95, cíl SoC z plánu nebo 80) | ANO |
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user