Skip to content
Draft
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
Empty file.
Empty file.
73 changes: 73 additions & 0 deletions packages/modules/devices/enecess/ecomain/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from typing import Optional

from modules.common.component_setup import ComponentSetup
from ..vendor import vendor_descriptor


class EcoMainConfiguration:
def __init__(self, ip_address: Optional[str] = None, serial_number: Optional[str] = None):
self.ip_address = ip_address
self.serial_number = serial_number


class EcoMain:
def __init__(self, name: str = "EcoMain", type: str = "ecomain", id: int = 0,
configuration: EcoMainConfiguration = None):
self.name = name
self.type = type
self.vendor = vendor_descriptor.configuration_factory().type
self.id = id
self.configuration = configuration or EcoMainConfiguration()


class EcoMainCounterConfiguration:
def __init__(self):
pass


class EcoMainCounterSetup(ComponentSetup[EcoMainCounterConfiguration]):
def __init__(self, name: str = "EcoMain EVU-Zähler", type: str = "counter", id: int = 0,
configuration: EcoMainCounterConfiguration = None, **kwargs):
super().__init__(name, type, id, configuration or EcoMainCounterConfiguration(), **kwargs)


class EcoMainChannelConfiguration:
def __init__(self, phase: int = 1, source: int = 0, channel: int = 1):
self.phase = phase
self.source = source
self.channel = channel


class EcoMainInverterConfiguration:
def __init__(self, phase_count: int = 1, invert: bool = False,
channels: Optional[list[EcoMainChannelConfiguration]] = None):
self.phase_count = phase_count
self.invert = invert
self.channels = channels if channels is not None else [EcoMainChannelConfiguration()]


class EcoMainInverterSetup(ComponentSetup[EcoMainInverterConfiguration]):
def __init__(self, name: str = "EcoMain Wechselrichter", type: str = "inverter", id: int = 0,
configuration: EcoMainInverterConfiguration = None, **kwargs):
super().__init__(name, type, id, configuration or EcoMainInverterConfiguration(), **kwargs)


def validate_inverter_configuration(
configuration: EcoMainInverterConfiguration) -> list[EcoMainChannelConfiguration]:
if configuration.phase_count not in (1, 3):
raise ValueError("Die Phasenanzahl muss 1 oder 3 sein.")
if len(configuration.channels) != configuration.phase_count:
raise ValueError("Die Anzahl der EcoMain-Kanäle stimmt nicht mit der Phasenanzahl überein.")
for item in configuration.channels:
if item.phase not in (1, 2, 3):
raise ValueError("Die Phase muss L1, L2 oder L3 sein.")
if item.source not in (0, 1, 2, 3):
raise ValueError("Die Quelle muss Hauptgerät oder Slave 1 bis 3 sein.")
if not 1 <= item.channel <= 10:
raise ValueError("Der EcoMain-Kanal muss zwischen 1 und 10 liegen.")
if configuration.phase_count == 3 and {item.phase for item in configuration.channels} != {1, 2, 3}:
raise ValueError("Bei dreiphasiger Messung müssen L1, L2 und L3 jeweils einmal konfiguriert sein.")
physical_channels = {(item.source, item.channel) for item in configuration.channels}
if len(physical_channels) != len(configuration.channels):
raise ValueError("Eine EcoMain-Quelle und ein Kanal dürfen nicht mehrfach verwendet werden.")
return sorted(configuration.channels, key=lambda item: item.phase)
71 changes: 71 additions & 0 deletions packages/modules/devices/enecess/ecomain/config_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from dataclass_utils import dataclass_from_dict
import pytest

from modules.devices.enecess.ecomain import counter, inverter
from modules.devices.enecess.ecomain.config import (
EcoMainCounterSetup,
EcoMainInverterSetup,
validate_inverter_configuration,
)
from modules.devices.enecess.vendor import vendor_descriptor


def test_nested_channels_are_deserialized():
setup = dataclass_from_dict(EcoMainInverterSetup, {
"name": "PV",
"type": "inverter",
"id": 7,
"configuration": {
"phase_count": 3,
"invert": False,
"channels": [
{"phase": 3, "source": 2, "channel": 10},
{"phase": 1, "source": 0, "channel": 1},
{"phase": 2, "source": 1, "channel": 4},
],
},
})
channels = validate_inverter_configuration(setup.configuration)
assert [(item.phase, item.source, item.channel) for item in channels] == [
(1, 0, 1), (2, 1, 4), (3, 2, 10)
]


@pytest.mark.parametrize("configuration", [
{"phase_count": 2, "channels": [{"phase": 1, "source": 0, "channel": 1}]},
{"phase_count": 1, "channels": []},
{"phase_count": 3, "channels": [
{"phase": 1, "source": 0, "channel": 1},
{"phase": 1, "source": 0, "channel": 2},
{"phase": 3, "source": 0, "channel": 3},
]},
{"phase_count": 3, "channels": [
{"phase": 1, "source": 0, "channel": 1},
{"phase": 2, "source": 0, "channel": 1},
{"phase": 3, "source": 0, "channel": 3},
]},
{"phase_count": 1, "channels": [{"phase": 1, "source": 4, "channel": 1}]},
{"phase_count": 1, "channels": [{"phase": 1, "source": 0, "channel": 11}]},
])
def test_invalid_inverter_configuration_is_rejected(configuration):
setup = dataclass_from_dict(EcoMainInverterSetup, {
"name": "PV", "type": "inverter", "id": 7, "configuration": configuration
})
with pytest.raises(ValueError):
validate_inverter_configuration(setup.configuration)


def test_vendor_is_discovered_as_enecess():
configuration = vendor_descriptor.configuration_factory()
assert configuration.type == "enecess"
assert configuration.vendor == "enecess"


@pytest.mark.parametrize(("module", "setup_class", "component_type"), [
(counter, EcoMainCounterSetup, "counter"),
(inverter, EcoMainInverterSetup, "inverter"),
])
def test_component_descriptors_are_discoverable(module, setup_class, component_type):
descriptor = module.component_descriptor
assert descriptor.configuration_factory is setup_class
assert descriptor.configuration_factory().type == component_type
59 changes: 59 additions & 0 deletions packages/modules/devices/enecess/ecomain/counter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from modules.common.abstract_device import AbstractCounter
from modules.common.component_state import CounterState
from modules.common.component_type import ComponentDescriptor, ComponentType
from modules.common.fault_state import ComponentInfo, FaultState
from modules.common.store import get_component_value_store
from modules.common.utils.peak_filter import PeakFilter
from modules.devices.enecess.ecomain.config import EcoMainCounterSetup
from modules.devices.enecess.ecomain.runtime import EcoMainRuntime


class EcoMainCounter(AbstractCounter):
def __init__(
self,
component_config: EcoMainCounterSetup,
runtime: EcoMainRuntime,
device_id: int) -> None:
self.component_config = component_config
self.runtime = runtime
self.device_id = device_id

def initialize(self) -> None:
self.store = get_component_value_store(
self.component_config.type,
self.component_config.id,
)
self.fault_state = FaultState(
ComponentInfo.from_component_config(self.component_config)
)
self.peak_filter = PeakFilter(
ComponentType.COUNTER,
self.component_config.id,
self.fault_state,
)
self.runtime.ensure_compatible()

def read_state(self) -> CounterState:
reading = self.runtime.read_counter()
imported, exported = self.peak_filter.check_values(
reading.power,
reading.imported,
reading.exported,
)
return CounterState(
power=reading.power,
powers=reading.powers,
voltages=reading.voltages,
currents=reading.currents,
power_factors=reading.power_factors,
imported=imported,
exported=exported,
frequency=50,
serial_number=f"{self.runtime.device_serial}_evu",
)

def update(self) -> None:
self.store.set(self.read_state())


component_descriptor = ComponentDescriptor(configuration_factory=EcoMainCounterSetup)
57 changes: 57 additions & 0 deletions packages/modules/devices/enecess/ecomain/counter_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from unittest.mock import MagicMock, Mock

from modules.common.component_state import CounterState
from modules.common.component_type import ComponentType
from modules.devices.enecess.ecomain import counter
from modules.devices.enecess.ecomain.config import EcoMainCounterSetup
from modules.devices.enecess.ecomain.runtime import EcoMainCounterReading, EcoMainRuntime


def test_counter_update_maps_complete_evu_reading(monkeypatch):
value_store = Mock()
monkeypatch.setattr(
counter,
"get_component_value_store",
Mock(return_value=value_store),
)
peak_filter = Mock()
peak_filter.check_values.return_value = (1234, 56)
peak_filter_factory = Mock(return_value=peak_filter)
monkeypatch.setattr(counter, "PeakFilter", peak_filter_factory)
runtime = MagicMock(spec=EcoMainRuntime)
runtime.device_serial = "099806571330"
runtime.read_counter.return_value = EcoMainCounterReading(
power=600,
powers=[100, 200, 300],
voltages=[230, 231, 232],
currents=[1, 2, 3],
power_factors=[0.98, 0.99, 1.0],
imported=1234,
exported=56,
)
component_config = EcoMainCounterSetup(id=4)
component = counter.EcoMainCounter(component_config, runtime, device_id=1)

component.initialize()
component.update()

runtime.ensure_compatible.assert_called_once_with()
peak_filter_factory.assert_called_once_with(
ComponentType.COUNTER,
component_config.id,
component.fault_state,
)
peak_filter.check_values.assert_called_once_with(600, 1234, 56)
expected = CounterState(
power=600,
powers=[100, 200, 300],
voltages=[230, 231, 232],
currents=[1, 2, 3],
power_factors=[0.98, 0.99, 1.0],
imported=1234,
exported=56,
frequency=50,
serial_number="099806571330_evu",
)
assert value_store.set.call_count == 1
assert vars(value_store.set.call_args.args[0]) == vars(expected)
70 changes: 70 additions & 0 deletions packages/modules/devices/enecess/ecomain/device.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from typing import Iterable, Optional, Union

from modules.common.abstract_device import DeviceDescriptor
from modules.common.component_context import SingleComponentUpdateContext
from modules.common.configurable_device import (
ComponentFactoryByType,
ConfigurableDevice,
MultiComponentUpdater,
)
from modules.devices.enecess.ecomain.config import (
EcoMain,
EcoMainCounterSetup,
EcoMainInverterSetup,
)
from modules.devices.enecess.ecomain.counter import EcoMainCounter
from modules.devices.enecess.ecomain.inverter import EcoMainInverter
from modules.devices.enecess.ecomain.runtime import EcoMainRuntime


EcoMainComponent = Union[EcoMainCounter, EcoMainInverter]


def create_device(device_config: EcoMain) -> ConfigurableDevice:
runtime: Optional[EcoMainRuntime] = None

def initializer() -> None:
nonlocal runtime
runtime = EcoMainRuntime(
device_config.configuration.ip_address,
device_config.configuration.serial_number,
)

def create_counter(component_config: EcoMainCounterSetup) -> EcoMainCounter:
if runtime is None:
raise RuntimeError("EcoMain-Laufzeit wurde nicht initialisiert.")
return EcoMainCounter(
component_config=component_config,
runtime=runtime,
device_id=device_config.id,
)

def create_inverter(component_config: EcoMainInverterSetup) -> EcoMainInverter:
if runtime is None:
raise RuntimeError("EcoMain-Laufzeit wurde nicht initialisiert.")
return EcoMainInverter(
component_config=component_config,
runtime=runtime,
device_id=device_config.id,
)

def update_components(components: Iterable[EcoMainComponent]) -> None:
if runtime is None:
raise RuntimeError("EcoMain-Laufzeit wurde nicht initialisiert.")
with runtime.client:
for component in components:
with SingleComponentUpdateContext(component.fault_state):
component.update()

return ConfigurableDevice(
device_config=device_config,
initializer=initializer,
component_factory=ComponentFactoryByType(
counter=create_counter,
inverter=create_inverter,
),
component_updater=MultiComponentUpdater(update_components),
)


device_descriptor = DeviceDescriptor(configuration_factory=EcoMain)
Loading