"""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, ) from services.control.models import OperatingModeInfo from services.control.setpoints import ( _build_setpoints, _deye_zero_export_amps_for_passive, ) 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: 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_build_setpoints_uses_explicit_export_limit(self) -> None: mode = OperatingModeInfo( mode_code="AUTO", battery_mode="AUTO", grid_mode="AUTO", ev_enabled=False, heat_pump_enabled_def=False, loxone_mode_value=1, ) pi = { "battery_setpoint_w": 0, "grid_setpoint_w": -3000, "export_limit_w": 13_500, "export_mode": "PV_SURPLUS", "ev1_setpoint_w": 0, "ev2_setpoint_w": 0, "heat_pump_enabled": False, "battery_soc_target_pct": 50, "effective_sell_price": 1.0, } sp = _build_setpoints(mode, pi) self.assertIsNotNone(sp) self.assertEqual(sp.grid_export_limit, 13_500) 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_tou_stays_min_soc(self) -> None: """PASSIVE: záporná vykupní nenastavuje TOU na 100 — zůstává min_soc (145/export_ban řeší síť).""" 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, 12) def test_passive_planned_pv_charge_tou_stays_min_soc(self) -> None: """PASSIVE s kladným battery_w bez grid importu: CHARGE to není — TOU je stále min_soc.""" 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, 12) 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_target_soc_respects_max_soc_100(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") inv = replace(_inv(), max_soc_percent=100) _p, s, g = _deye_tou_params(sp, inv) self.assertTrue(g) self.assertEqual(s, 100) 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, 100) 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()