225 lines
7.4 KiB
Python
225 lines
7.4 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_tou_params,
|
||
_deye_tou_power_verify_match,
|
||
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_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: plánovaný výdej baterie alesvěň tak velký jako plánovaný export (|bat| ≥ |grid|)."""
|
||
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_pv_led_export_with_small_battery_is_passive(self) -> None:
|
||
"""Regrese site 25A 17:30: |bat| < |grid| → PASSIVE (FVE přetok, ne „vylít baterku“)."""
|
||
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), "PASSIVE")
|
||
|
||
def test_large_export_small_battery_is_passive(self) -> None:
|
||
"""Export v plánu větší než výdej z baterie → PASSIVE."""
|
||
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), "PASSIVE")
|
||
|
||
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_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()
|