Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/control/algorithm/additional_current.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ def set_additional_current(self) -> None:
available_currents, limit = Loadmanagement().get_available_currents(missing_currents, counter, cp)
log.debug(f"cp {cp.num} available currents {available_currents} missing currents "
f"{missing_currents} limit {limit.message}")
cp.data.control_parameter.limit = limit
if limit.limiting_value is not None:
cp.data.control_parameter.limit = limit
Comment on lines +33 to +34

Copilot AI Mar 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as in min_current.py: skipping the assignment when limit.limiting_value is None means cp.data.control_parameter.limit can remain set to an old limiting reason even after the constraint is gone. That stale state can propagate into phase-switch logic and status messaging. Consider clearing/resetting the limit explicitly when there is no active limitation.

Suggested change
if limit.limiting_value is not None:
cp.data.control_parameter.limit = limit
cp.data.control_parameter.limit = limit

Copilot uses AI. Check for mistakes.
available_for_cp = common.available_current_for_cp(cp, counts, available_currents, missing_currents)
current = common.get_current_to_set(
cp.data.set.current, available_for_cp, cp.data.set.target_current)
Expand Down
2 changes: 2 additions & 0 deletions packages/control/algorithm/algorithm.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ def calc_current(self) -> None:
self._check_auto_phase_switch_delay()
self.surplus_controlled.check_submode_pv_charging()
common.reset_current()
for cp in data.data.cp_data.values():
cp.reset_values_before_algorithm()
log.info("**Mindestrom setzen**")
self.min_current.set_min_current()
log.info("**Soll-Strom setzen**")
Expand Down
3 changes: 2 additions & 1 deletion packages/control/algorithm/min_current.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ def set_min_current(self) -> None:
if max(missing_currents) > 0:
available_currents, limit = Loadmanagement().get_available_currents(
missing_currents, counter, cp)
cp.data.control_parameter.limit = limit
if limit.limiting_value is not None:
cp.data.control_parameter.limit = limit

Copilot AI Mar 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this change, when Loadmanagement().get_available_currents(...) returns a limit with limiting_value is None, the chargepoint's control_parameter.limit is no longer updated/cleared and will retain whatever value was set in a previous iteration. That can lead to stale limit state being displayed and can affect downstream logic that reads control_parameter.limit. If you only want to skip publishing limit messages, consider still assigning a neutral/empty LoadmanagementLimit here to clear the previous limit.

Suggested change
cp.data.control_parameter.limit = limit
cp.data.control_parameter.limit = limit
else:
cp.data.control_parameter.limit = None

Copilot uses AI. Check for mistakes.
available_for_cp = common.available_current_for_cp(
cp, counts, available_currents, missing_currents)
current = common.get_current_to_set(
Expand Down
4 changes: 3 additions & 1 deletion packages/control/algorithm/surplus_controlled.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ def _set(self,
cp,
feed_in=feed_in_yield
)
cp.data.control_parameter.limit = limit
# im PV-Laden wird der Strom immer durch die Leistung begrenzt
if limit.limiting_value is not None and limit.limiting_value != LimitingValue.POWER:
cp.data.control_parameter.limit = limit

Copilot AI Mar 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This conditional assignment can leave cp.data.control_parameter.limit stale when (a) there is no limiting (limit.limiting_value is None) or (b) limiting is by POWER (which is skipped). Since cp.data.control_parameter.limit is later consumed by algorithm.py for auto_phase_switch, an old CURRENT/UNBALANCED limit may incorrectly influence phase-switch decisions and UI state. If the intention is to ignore POWER limits in PV mode, consider explicitly clearing/resetting control_parameter.limit when the current limit is POWER or None, rather than leaving the previous value in place.

Suggested change
cp.data.control_parameter.limit = limit
cp.data.control_parameter.limit = limit
else:
# Clear any stale CURRENT/UNBALANCED limit when there is no limiting
# or when limiting is by POWER (ignored in PV mode).
cp.data.control_parameter.limit = None

Copilot uses AI. Check for mistakes.
available_for_cp = common.available_current_for_cp(cp, counts, available_currents, missing_currents)
if counter.get_control_range_state(feed_in_yield) == ControlRangeState.MIDDLE:
pv_charging = data.data.general_data.data.chargemode_config.pv_charging
Expand Down
29 changes: 20 additions & 9 deletions packages/control/auto_phase_switch_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from control.chargepoint.control_parameter import ControlParameter
from control.counter import Counter, CounterData, Set

from control.limiting_value import LoadmanagementLimit
from control.limiting_value import LimitingValue, LoadmanagementLimit
from control.ev.charge_template import ChargeTemplate
from control.pv_all import PvAll
from control.bat_all import BatAll
Expand Down Expand Up @@ -153,21 +153,32 @@ def test_auto_phase_switch(monkeypatch, vehicle: Ev, params: Params):


@pytest.mark.parametrize(
"evse_current, get_currents, all_surplus, expected",
"evse_current, get_currents, all_surplus, limit, expected",
[
pytest.param(8, [7.7]*3, 100, (False, Ev.ENOUGH_POWER), id="kein 1p3p, genug Leistung für mehrphasige Ladung"),
pytest.param(10, [9.8, 0, 0], 50, (False, Ev.NOT_ENOUGH_POWER),
pytest.param(8, [7.7]*3, 100, LoadmanagementLimit(None, None), (False, Ev.ENOUGH_POWER),
id="kein 1p3p, genug Leistung für mehrphasige Ladung"),
pytest.param(10, [9.8, 0, 0], 50, LoadmanagementLimit(None, None), (False, Ev.NOT_ENOUGH_POWER),
id="kein 1p3p, nicht genug Leistung, um auf 3p zu schalten"),
pytest.param(16, [14, 0, 0], 5000, (False, Ev.CURRENT_OUT_OF_NOMINAL_DIFFERENCE),
pytest.param(16, [14, 0, 0], 5000, LoadmanagementLimit(None, None),
(False, Ev.CURRENT_OUT_OF_NOMINAL_DIFFERENCE),
id="kein 1p3p, Auto lädt nicht mit vorgegebener Maximalstromstärke"),
pytest.param(6, [7.5]*3, -20, (False, Ev.CURRENT_OUT_OF_NOMINAL_DIFFERENCE),
pytest.param(6, [7.5]*3, -20, LoadmanagementLimit(None, None), (False, Ev.CURRENT_OUT_OF_NOMINAL_DIFFERENCE),
id="kein 1p3p, Auto lädt nicht mit vorgegebener Minimalstromstärke"),
pytest.param(16, [15.8, 0, 0], 5000, (True, None), id="1p3p"),
pytest.param(6, [5.8]*3, -10, (True, None), id="3p1p"),
pytest.param(16, [15.8, 0, 0], 5000, LoadmanagementLimit(None, None), (True, None), id="1p3p"),
pytest.param(6, [5.8]*3, -10, LoadmanagementLimit(None, None), (True, None), id="3p1p"),
pytest.param(10, [9.8, 0, 0], 5000,
LoadmanagementLimit(message=", da der Maximal-Strom an Zähler Test erreicht ist.",
limiting_value=LimitingValue.CURRENT), (True, None),
id="1p3p, da durch die Begrenzung des LM nicht mit maximalem Strom geladen wird"),
pytest.param(10, [9.8, 0, 0], 5000,
LoadmanagementLimit(message=", da die maximale Schieflast an Zähler Test erreicht ist.",
limiting_value=LimitingValue.UNBALANCED_LOAD), (True, None),
id="1p3p, da durch die Begrenzung der Schieflast nicht mit maximalem Strom geladen wird"),
])
def test_check_phase_switch_conditions(evse_current: int,
get_currents: List[float],
all_surplus: int,
limit: LoadmanagementLimit,
expected: Tuple[bool, Optional[str]],
monkeypatch):
# setup
Expand All @@ -183,7 +194,7 @@ def test_check_phase_switch_conditions(evse_current: int,
get_currents,
sum(get_currents)*230,
16,
LoadmanagementLimit(None, None))
limit)

# evaluation
assert (phase_switch, condition_msg) == expected
4 changes: 4 additions & 0 deletions packages/control/chargepoint/chargepoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from control.ev.ev import Ev
from control import phase_switch
from control.chargepoint.chargepoint_state import CHARGING_STATES, ChargepointState
from control.limiting_value import loadmanagement_limit_factory
from control.text import BidiState
from helpermodules.phase_handling import convert_single_evu_phase_to_cp_phase
from helpermodules.pub import Pub
Expand Down Expand Up @@ -266,6 +267,9 @@ def reset_control_parameter_at_charge_stop(self) -> None:
control_parameter = control_parameter_factory()
self.data.control_parameter = control_parameter

def reset_values_before_algorithm(self) -> None:
self.data.control_parameter.limit = loadmanagement_limit_factory()

def initiate_control_pilot_interruption(self):
""" prüft, ob eine Control Pilot- Unterbrechung erforderlich ist und führt diese durch.
"""
Expand Down
5 changes: 3 additions & 2 deletions packages/control/ev/ev.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,8 +277,9 @@ def _check_phase_switch_conditions(self,
all_surplus = data.data.counter_all_data.get_evu_counter().get_usable_surplus(feed_in_yield)
required_surplus = control_parameter.min_current * max_phases_ev * 230 - get_power
unbalanced_load_limit_reached = limit.limiting_value == LimitingValue.UNBALANCED_LOAD
condition_1_to_3 = (((get_medium_charging_current(get_currents) > max_current_range and
all_surplus > required_surplus) or unbalanced_load_limit_reached) and
current_limit_reached = limit.limiting_value == LimitingValue.CURRENT
condition_1_to_3 = ((((get_medium_charging_current(get_currents) > max_current_range or current_limit_reached)
and all_surplus > required_surplus) or unbalanced_load_limit_reached) and
phases_in_use == 1)
condition_3_to_1 = get_medium_charging_current(
get_currents) < min_current_range and all_surplus <= 0 and phases_in_use > 1
Expand Down
75 changes: 75 additions & 0 deletions packages/dataclass_utils/_dataclass_asdict_test.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import pytest

from dataclass_utils import asdict
from dataclass_utils.conftest import MyDataclass


class SingleValue:
Expand Down Expand Up @@ -37,3 +38,77 @@ def test_asdict(object, expected_dict: dict):

# evaluation
assert actual == expected_dict


MY_DATACLASS_AS_DICT = {
"str_value": "string_value",
"float_value": 5.2,
"int_value": 6,
"enum_value": "value1",
"nested_dataclass": {
"nested_str": "nested string",
"nested_int": 42
},
"nested_dataclass_enum_value": {
"D1": "value1",
"D2": "value2"
},
"dict_of_dataclass_value": {"a": {"nested_int": 42,
"nested_str": "nested string"},
"b": {"nested_int": 42,
"nested_str": "nested string"}},
"dict_value": {"a": "a", "b": 2},
"dict2_value": {"a": 1, "b": 2},
"list_value": ["a", 2, None],
"list2_value": ["a", 2, None],
# JSON kennt keine Tupel
"tuple_value": [None, "a", 2],
"tuple2_value": [None, "a", 2],

"optional_str_value": "string_value",
"optional_float_value": 5.2,
"optional_int_value": 6,
"optional_enum_value": "value1",
"optional_nested_dataclass": {
"nested_str": "nested string",
"nested_int": 42
},
"optional_nested_dataclass_enum_value": {
"D1": "value1",
"D2": "value2"
},
"optional_dict_of_dataclass_value": {"a": {"nested_int": 42,
"nested_str": "nested string"},
"b": {"nested_int": 42,
"nested_str": "nested string"}},
"optional_dict_value": {"a": "a", "b": 2},
"optional_dict2_value": {"a": 1, "b": 2},
"optional_list_value": ["a", 2, None],
"optional_list2_value": ["a", 2, None],
# JSON kennt keine Tupel
"optional_tuple_value": [None, "a", 2],
"optional_tuple2_value": [None, "a", 2],

"none_str_value": None,
"none_float_value": None,
"none_int_value": None,
"none_enum_value": None,
"none_nested_dataclass": None,
"none_nested_dataclass_enum_value": None,
"none_dict_of_dataclass_value": None,
"none_dict_value": None,
"none_dict2_value": None,
"none_list_value": None,
"none_list2_value": None,
"none_tuple_value": None,
"none_tuple2_value": None,

}


def test_dataclass_as_dict():
# execution
actual_dict = asdict(MyDataclass())

# evaluation
assert actual_dict == MY_DATACLASS_AS_DICT
43 changes: 22 additions & 21 deletions packages/dataclass_utils/_dataclass_from_dict.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from enum import Enum
import inspect
from inspect import FullArgSpec, isclass
import typing
from typing import TypeVar, Type, Union, get_args, get_origin

T = TypeVar('T')
Expand All @@ -20,8 +19,9 @@ def dataclass_from_dict(cls: Type[T], args: Union[dict, T]) -> T:
if isinstance(args, cls):
return args
elif get_origin(cls):
# Generische Typen wie Dict[int, float]
if isinstance(args, get_origin(cls)):
# Generische Typen wie Dict[int, float] - aber nicht Union, da isinstance mit Union fehlschlägt
origin = get_origin(cls)
if origin != Union and isinstance(args, origin):
return args
elif isinstance(args, type(cls)):
return args
Expand All @@ -46,26 +46,27 @@ def _get_argument_value(arg_spec: FullArgSpec, index: int, parameters: dict):


def _dataclass_from_dict_recurse(value, requested_type: Type[T]):
if get_origin(requested_type) == list:
# Handle Optional types (Union[X, None]) - extract the actual type
actual_type = requested_type
if get_origin(requested_type) == Union:
args = get_args(requested_type)
if len(args) == 2 and args[1].__name__ == 'NoneType':
if value is None:
return None
actual_type = args[0] # Extract X from Optional[X]
Comment on lines 21 to +56

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description is focused on automatic phase switching and loadmanagement limits, but this file also changes dataclass_from_dict Optional/Union handling and adds new dataclass_utils tests/fixtures. Please update the PR description to cover these changes or split them into a separate PR so the review scope matches the intent.

Copilot uses AI. Check for mistakes.

if get_origin(actual_type) == list:
# Extrahiere den generischen Typ der Liste
if get_args(requested_type):
generic_type = get_args(requested_type)[0]
if get_args(actual_type):
generic_type = get_args(actual_type)[0]
# Konvertiere jedes Element der Liste in den generischen Typ
return [_dataclass_from_dict_recurse(item, generic_type) for item in value]

if isinstance(value, dict) and not (
_is_optional_of_dict(requested_type) or
issubclass(requested_type if isclass(requested_type) else type(bool), dict)):
return dataclass_from_dict(requested_type, value)
if isinstance(requested_type, type) and issubclass(requested_type, Enum):
return requested_type(value)
return value

# Handle dict types (both direct and Optional[dict])
if isinstance(value, dict) and isclass(actual_type) and not issubclass(actual_type, dict):
return dataclass_from_dict(actual_type, value)

def _is_optional_of_dict(requested_type):
# Optional[dict] is an alias for Union[dict, None]
if typing.get_origin(requested_type) == Union:
args = typing.get_args(requested_type)
if len(args) == 2:
return issubclass(args[0], dict) and issubclass(args[1], type(None))
return False
# Handle Enum types (both direct and Optional[Enum])
if isinstance(actual_type, type) and issubclass(actual_type, Enum):
return actual_type(value)
return value
65 changes: 65 additions & 0 deletions packages/dataclass_utils/_dataclass_from_dict_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import pytest

from dataclass_utils import dataclass_from_dict
from dataclass_utils.conftest import MyDataclass

T = TypeVar('T')

Expand Down Expand Up @@ -125,3 +126,67 @@ def test_from_dict_without_optional():
# evaluation
assert actual.a == "aValue"
assert actual.o is None


MY_DATACLASS_AS_DICT = {
"str_value": "string_value",
"float_value": 5.2,
"int_value": 6,
"enum_value": "value1",
"nested_dataclass": {
"nested_str": "nested string",
"nested_int": 42
},
"nested_dataclass_enum_value": {
"D1": "value1",
"D2": "value2"
},
"dict_value": {"a": "a", "b": 2},
"dict2_value": {"a": 1, "b": 2},
"list_value": ["a", 2, None],
"list2_value": ["a", 2, None],
"tuple_value": (None, "a", 2),
"tuple2_value": (None, "a", 2),

"optional_str_value": "string_value",
"optional_float_value": 5.2,
"optional_int_value": 6,
"optional_enum_value": "value1",
"optional_nested_dataclass": {
"nested_str": "nested string",
"nested_int": 42
},
"optional_nested_dataclass_enum_value": {
"D1": "value1",
"D2": "value2"
},
"optional_dict_value": {"a": "a", "b": 2},
"optional_dict2_value": {"a": 1, "b": 2},
"optional_list_value": ["a", 2, None],
"optional_list2_value": ["a", 2, None],
"optional_tuple_value": (None, "a", 2),
"optional_tuple2_value": (None, "a", 2),

"none_str_value": None,
"none_float_value": None,
"none_int_value": None,
"none_enum_value": None,
"none_nested_dataclass": None,
"none_nested_dataclass_enum_value": None,
"none_dict_of_dataclass_value": None,
"none_dict_value": None,
"none_dict2_value": None,
"none_list_value": None,
"none_list2_value": None,
"none_tuple_value": None,
"none_tuple2_value": None,

}


def test_dataclass_from_dict():
# execution
actual_dict = dataclass_from_dict(MyDataclass, MY_DATACLASS_AS_DICT)

# evaluation
assert vars(actual_dict) == vars(MyDataclass())
Loading
Loading