292 lines
9.7 KiB
Python
292 lines
9.7 KiB
Python
"""Deye TOU SOC % podle fyzického režimu (SELL vs PASSIVE)."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import unittest
|
||
from dataclasses import replace
|
||
|
||
from services.control.exporter_monolith import (
|
||
ControlSetpoints,
|
||
InverterConfig,
|
||
_deye_reg178_verify_with_double_read,
|
||
_deye_reg179_verify_match,
|
||
_deye_tou_params,
|
||
_deye_tou_power_verify_match,
|
||
_deye_zero_export_amps_for_passive,
|
||
deye_reg_triggers_self_sustain_after_verify_exhaust,
|
||
get_deye_mode,
|
||
)
|
||
|
||
|
||
def _inv(*, min_soc: int | None = 12, reserve_soc: int | None = 20) -> InverterConfig:
|
||
return InverterConfig(
|
||
id=1,
|
||
code="deye-main",
|
||
host="127.0.0.1",
|
||
port=502,
|
||
unit_id=1,
|
||
max_export_power_w=13_500,
|
||
max_import_power_w=13_500,
|
||
no_export=False,
|
||
max_battery_charge_w=10_000,
|
||
max_battery_discharge_w=10_000,
|
||
min_soc_percent=min_soc,
|
||
reserve_soc_percent=reserve_soc,
|
||
max_soc_percent=95,
|
||
usable_capacity_wh=64_000,
|
||
max_charge_a=100,
|
||
max_discharge_a=100,
|
||
)
|
||
|
||
|
||
def _inv_350a() -> InverterConfig:
|
||
"""350 A × 51.2 V = 17920 W — typický firmware clamp pro TOU power."""
|
||
return replace(_inv(), max_charge_a=350, max_discharge_a=350)
|
||
|
||
|
||
class ModbusVerifyPolicyTests(unittest.TestCase):
|
||
def test_tou_power_accepts_firmware_max_w_clamp(self) -> None:
|
||
inv = _inv_350a()
|
||
self.assertTrue(_deye_tou_power_verify_match(7752, 17920, inv))
|
||
self.assertTrue(_deye_tou_power_verify_match(16728, 17920, inv))
|
||
|
||
def test_reg178_double_read_recovers_from_glitch(self) -> None:
|
||
ok, v = _deye_reg178_verify_with_double_read(48, 12014, 48)
|
||
self.assertTrue(ok)
|
||
self.assertEqual(v, 48)
|
||
|
||
def test_reg179_verify_match_only_bits_0_1(self) -> None:
|
||
# expected=3 (enable), actual can have other bits set but bits0-1 must match
|
||
self.assertTrue(_deye_reg179_verify_match(3, 0xFFFB))
|
||
self.assertFalse(_deye_reg179_verify_match(3, 0xFFFA)) # bits0-1=2
|
||
|
||
def test_reg178_not_critical_for_self_sustain(self) -> None:
|
||
self.assertFalse(deye_reg_triggers_self_sustain_after_verify_exhaust(178))
|
||
|
||
def test_reg108_critical_for_self_sustain(self) -> None:
|
||
self.assertTrue(deye_reg_triggers_self_sustain_after_verify_exhaust(108))
|
||
|
||
|
||
class DeyeTouParamsTests(unittest.TestCase):
|
||
def test_sell_uses_reserve_soc(self) -> None:
|
||
"""SELL: záporný grid_setpoint_w i battery_w → selling first; TOU SOC = reserve."""
|
||
sp = ControlSetpoints(
|
||
battery_w=-8000,
|
||
grid_export_limit=8000,
|
||
ev1_current_a=0,
|
||
ev2_current_a=0,
|
||
heat_pump_enable=False,
|
||
grid_setpoint_w=-8000,
|
||
ev1_power_w=0,
|
||
ev2_power_w=0,
|
||
target_soc_pct=50,
|
||
)
|
||
self.assertEqual(get_deye_mode(sp), "SELL")
|
||
p, s, g = _deye_tou_params(sp, _inv())
|
||
self.assertFalse(g)
|
||
self.assertEqual(s, 20)
|
||
|
||
def test_explicit_deye_physical_mode_from_plan_overrides_detection(self) -> None:
|
||
sp = ControlSetpoints(
|
||
battery_w=-8000,
|
||
grid_export_limit=8000,
|
||
ev1_current_a=0,
|
||
ev2_current_a=0,
|
||
heat_pump_enable=False,
|
||
grid_setpoint_w=-8000,
|
||
ev1_power_w=0,
|
||
ev2_power_w=0,
|
||
target_soc_pct=50,
|
||
deye_physical_mode="PASSIVE",
|
||
)
|
||
self.assertEqual(get_deye_mode(sp), "PASSIVE")
|
||
|
||
def test_export_ban_does_not_change_deye_mode(self) -> None:
|
||
sp = 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=50,
|
||
export_ban=True,
|
||
)
|
||
self.assertEqual(get_deye_mode(sp), "PASSIVE")
|
||
|
||
def test_pv_led_export_with_small_battery_is_sell(self) -> None:
|
||
"""Obě záporné → SELL (bez porovnání |bat| vs |grid|)."""
|
||
sp = ControlSetpoints(
|
||
battery_w=-733,
|
||
grid_export_limit=1294,
|
||
ev1_current_a=0,
|
||
ev2_current_a=0,
|
||
heat_pump_enable=False,
|
||
grid_setpoint_w=-1294,
|
||
ev1_power_w=0,
|
||
ev2_power_w=0,
|
||
target_soc_pct=50,
|
||
)
|
||
self.assertEqual(get_deye_mode(sp), "SELL")
|
||
|
||
def test_large_export_small_battery_is_sell(self) -> None:
|
||
"""I když |bat| < |grid| — stále SELL při obou záporných setpointech."""
|
||
sp = ControlSetpoints(
|
||
battery_w=-1500,
|
||
grid_export_limit=8000,
|
||
ev1_current_a=0,
|
||
ev2_current_a=0,
|
||
heat_pump_enable=False,
|
||
grid_setpoint_w=-8000,
|
||
ev1_power_w=0,
|
||
ev2_power_w=0,
|
||
target_soc_pct=50,
|
||
)
|
||
self.assertEqual(get_deye_mode(sp), "SELL")
|
||
|
||
def test_passive_uses_min_soc(self) -> None:
|
||
sp = 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,
|
||
effective_sell_price_czk_kwh=None,
|
||
)
|
||
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, 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:
|
||
sp = ControlSetpoints(
|
||
battery_w=5000,
|
||
grid_export_limit=0,
|
||
ev1_current_a=0,
|
||
ev2_current_a=0,
|
||
heat_pump_enable=False,
|
||
grid_setpoint_w=5000,
|
||
ev1_power_w=0,
|
||
ev2_power_w=0,
|
||
target_soc_pct=80,
|
||
)
|
||
self.assertEqual(get_deye_mode(sp), "CHARGE")
|
||
_p, s, g = _deye_tou_params(sp, _inv())
|
||
self.assertTrue(g)
|
||
self.assertEqual(s, 95)
|
||
|
||
def test_charge_any_positive_pair_without_w_threshold(self) -> None:
|
||
sp = ControlSetpoints(
|
||
battery_w=50,
|
||
grid_export_limit=0,
|
||
ev1_current_a=0,
|
||
ev2_current_a=0,
|
||
heat_pump_enable=False,
|
||
grid_setpoint_w=80,
|
||
ev1_power_w=0,
|
||
ev2_power_w=0,
|
||
target_soc_pct=50,
|
||
)
|
||
self.assertEqual(get_deye_mode(sp), "CHARGE")
|
||
|
||
def test_zero_export_amps_fve_overflow(self) -> None:
|
||
c, d = _deye_zero_export_amps_for_passive(-1000, 0, 100, 90)
|
||
self.assertEqual(c, 0)
|
||
self.assertEqual(d, 90)
|
||
|
||
def test_zero_export_amps_import_hold_discharge(self) -> None:
|
||
c, d = _deye_zero_export_amps_for_passive(500, 0, 100, 90)
|
||
self.assertEqual(c, 100)
|
||
self.assertEqual(d, 0)
|
||
|
||
def test_zero_export_amps_full_when_discharge_with_export(self) -> None:
|
||
"""Export + plánované vybíjení → plné proudy (SELL řeší režim 142 zvlášť)."""
|
||
c, d = _deye_zero_export_amps_for_passive(-2000, -500, 100, 90)
|
||
self.assertEqual(c, 100)
|
||
self.assertEqual(d, 90)
|
||
|
||
def test_self_sustain_tou_stays_min_soc_even_if_sell_negative(self) -> None:
|
||
"""SELF_SUSTAIN: nízké TOU (min_soc), ne 100 % z negativní vykupní — LP se nepoužívá."""
|
||
sp = 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,
|
||
effective_sell_price_czk_kwh=-0.48,
|
||
self_sustain_local_use=True,
|
||
)
|
||
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, 12)
|
||
|
||
def test_lock_battery_uses_min_soc(self) -> None:
|
||
sp = ControlSetpoints(
|
||
battery_w=0,
|
||
grid_export_limit=0,
|
||
ev1_current_a=0,
|
||
ev2_current_a=0,
|
||
heat_pump_enable=False,
|
||
grid_setpoint_w=-500,
|
||
ev1_power_w=0,
|
||
ev2_power_w=0,
|
||
target_soc_pct=None,
|
||
lock_battery=True,
|
||
)
|
||
p, s, g = _deye_tou_params(sp, _inv(min_soc=12))
|
||
self.assertEqual(p, 0)
|
||
self.assertFalse(g)
|
||
self.assertEqual(s, 12)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|