Files
ems/backend/tests/test_control_exporter_tou.py
Dusan Vojacek eb425a26f2
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
narovnani spravneho rezimu - nastavenim charge A
2026-05-21 10:23:53 +02:00

333 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()