_apply_export_plan_guard / _build_setpoints: kdyz slot CHARGE / importuje na nabiti baterie (grid_sp>0 & bat>0), guard vrati sp beze zmeny a export_ban se nenastavi. Opravuje, ze se baterie nedobila v zapornych cenach (CHARGE+17kW prekloplen na PASSIVE -> Deye nenabijel ze site). Diagnoza: agent a599eecc. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
154 lines
5.0 KiB
Python
154 lines
5.0 KiB
Python
"""Exekuční pojistka exportu podle plánu (Plan 3)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
|
|
from services.control.exporter_monolith import (
|
|
ControlSetpoints,
|
|
_apply_export_plan_guard,
|
|
get_deye_mode,
|
|
)
|
|
from services.control.models import OperatingModeInfo
|
|
from services.control.setpoints import _DictRecord
|
|
|
|
|
|
def _auto_mode() -> OperatingModeInfo:
|
|
return OperatingModeInfo(
|
|
mode_code="AUTO",
|
|
battery_mode="AUTO",
|
|
grid_mode="AUTO",
|
|
ev_enabled=True,
|
|
heat_pump_enabled_def=True,
|
|
loxone_mode_value=0,
|
|
)
|
|
|
|
|
|
def _sp(**kwargs: object) -> ControlSetpoints:
|
|
base = dict(
|
|
battery_w=0,
|
|
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="SELL",
|
|
export_mode="BATTERY_SELL",
|
|
export_ban=False,
|
|
)
|
|
base.update(kwargs)
|
|
return ControlSetpoints(**base) # type: ignore[arg-type]
|
|
|
|
|
|
class ExportPlanGuardTests(unittest.TestCase):
|
|
def test_neg_sell_forces_passive_no_export(self) -> None:
|
|
sp = _sp()
|
|
pi = _DictRecord(
|
|
{
|
|
"grid_setpoint_w": -8000,
|
|
"effective_sell_price": -0.5,
|
|
"export_mode": "NONE",
|
|
}
|
|
)
|
|
out = _apply_export_plan_guard(1, _auto_mode(), pi, sp)
|
|
self.assertEqual(get_deye_mode(out), "PASSIVE")
|
|
self.assertTrue(out.export_ban)
|
|
self.assertEqual(out.grid_export_limit, 0)
|
|
self.assertGreaterEqual(out.grid_setpoint_w, 0)
|
|
self.assertEqual(out.export_mode, "NONE")
|
|
self.assertTrue(out.deye_gen_cutoff_enabled)
|
|
|
|
def test_export_mode_none_with_non_negative_grid(self) -> None:
|
|
sp = _sp(grid_setpoint_w=0, battery_w=-5000, export_mode="BATTERY_SELL")
|
|
pi = _DictRecord(
|
|
{
|
|
"grid_setpoint_w": 0,
|
|
"effective_sell_price": 2.5,
|
|
"export_mode": "NONE",
|
|
}
|
|
)
|
|
out = _apply_export_plan_guard(1, _auto_mode(), pi, sp)
|
|
self.assertEqual(get_deye_mode(out), "PASSIVE")
|
|
self.assertEqual(out.battery_w, 0)
|
|
self.assertEqual(out.grid_export_limit, 0)
|
|
# Kladná vykupní: žádný tvrdý ban — MI (pole B) se NEodstavuje, 145 zůstává 1
|
|
# (BA81 2026-06-12: cutoff při sell +1.36 zahazoval výrobu mikroinvertorů).
|
|
self.assertFalse(out.export_ban)
|
|
self.assertFalse(out.deye_gen_cutoff_enabled)
|
|
|
|
def test_export_mode_none_positive_sell_respects_plan_cutoff(self) -> None:
|
|
# Plán explicitně chce cut-off (z_gen_cutoff) -> guard ho nesmí shodit.
|
|
sp = _sp(
|
|
grid_setpoint_w=0,
|
|
battery_w=2000,
|
|
export_mode="NONE",
|
|
deye_physical_mode="PASSIVE",
|
|
deye_gen_cutoff_enabled=True,
|
|
)
|
|
pi = _DictRecord(
|
|
{
|
|
"grid_setpoint_w": 0,
|
|
"effective_sell_price": 2.5,
|
|
"export_mode": "NONE",
|
|
}
|
|
)
|
|
out = _apply_export_plan_guard(1, _auto_mode(), pi, sp)
|
|
self.assertTrue(out.deye_gen_cutoff_enabled)
|
|
|
|
def test_profitable_export_unchanged(self) -> None:
|
|
sp = _sp()
|
|
pi = _DictRecord(
|
|
{
|
|
"grid_setpoint_w": -8000,
|
|
"effective_sell_price": 9.5,
|
|
"export_mode": "BATTERY_SELL",
|
|
}
|
|
)
|
|
out = _apply_export_plan_guard(1, _auto_mode(), pi, sp)
|
|
self.assertIs(out, sp)
|
|
self.assertEqual(get_deye_mode(out), "SELL")
|
|
|
|
def test_neg_sell_grid_charge_not_blocked(self) -> None:
|
|
# Záporný sell + IMPORT na nabití baterie (CHARGE / grid>0 & bat>0):
|
|
# guard NESMÍ překlopit na PASSIVE — jinak Deye nenabije ze sítě
|
|
# v záporných cenách (bug 2026-06-13).
|
|
sp = _sp(
|
|
grid_setpoint_w=17000,
|
|
battery_w=17000,
|
|
deye_physical_mode="CHARGE",
|
|
export_mode="NONE",
|
|
)
|
|
pi = _DictRecord(
|
|
{
|
|
"grid_setpoint_w": 17000,
|
|
"battery_setpoint_w": 17000,
|
|
"deye_physical_mode": "CHARGE",
|
|
"effective_sell_price": -1.2,
|
|
"export_mode": "NONE",
|
|
}
|
|
)
|
|
out = _apply_export_plan_guard(1, _auto_mode(), pi, sp)
|
|
self.assertIs(out, sp)
|
|
self.assertEqual(get_deye_mode(out), "CHARGE")
|
|
|
|
def test_non_auto_mode_skipped(self) -> None:
|
|
sp = _sp()
|
|
pi = _DictRecord({"effective_sell_price": -1.0, "export_mode": "NONE"})
|
|
mode = OperatingModeInfo(
|
|
mode_code="SELF_SUSTAIN",
|
|
battery_mode="PASSIVE",
|
|
grid_mode="PASSIVE",
|
|
ev_enabled=False,
|
|
heat_pump_enabled_def=False,
|
|
loxone_mode_value=1,
|
|
)
|
|
out = _apply_export_plan_guard(1, mode, pi, sp)
|
|
self.assertIs(out, sp)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|