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
10 changes: 10 additions & 0 deletions packages/control/bat.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@
log = logging.getLogger(__name__)


@dataclass
class Config:
max_power: float = 0


def config_factory() -> Config:
return Config()


@dataclass
class Get:
currents: List[float] = field(default_factory=currents_list_factory, metadata={
Expand Down Expand Up @@ -41,6 +50,7 @@ def set_factory() -> Set:

@dataclass
class BatData:
config: Config = field(default_factory=config_factory)
get: Get = field(default_factory=get_factory)
set: Set = field(default_factory=set_factory)

Expand Down
14 changes: 13 additions & 1 deletion packages/helpermodules/update_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@

class UpdateConfig:

DATASTORE_VERSION = 116
DATASTORE_VERSION = 117

valid_topic = [
"^openWB/bat/config/bat_control_permitted$",
Expand All @@ -71,6 +71,7 @@ class UpdateConfig:
"^openWB/bat/config/price_limit$",
"^openWB/bat/config/price_charge_activated$",
"^openWB/bat/config/charge_limit$",
"^openWB/bat/[0-9]+/config/max_power$",
"^openWB/bat/[0-9]+/get/max_charge_power$",
"^openWB/bat/[0-9]+/get/max_discharge_power$",
"^openWB/bat/[0-9]+/get/state_str$",
Expand Down Expand Up @@ -3004,3 +3005,14 @@ def upgrade(topic: str, payload) -> Optional[dict]:
return {topic: payload}
self._loop_all_received_topics(upgrade)
self._append_datastore_version(116)

def upgrade_datastore_117(self) -> None:
def upgrade(topic: str, payload) -> Optional[dict]:
if re.search("^openWB/bat/[0-9]+/get/power$", topic) is not None:
index = get_index(topic)
new_topics = {}
if f"openWB/bat/{index}/config/max_power" not in self.all_received_topics:
new_topics[f"openWB/bat/{index}/config/max_power"] = 0
return new_topics if new_topics else None
self._loop_all_received_topics(upgrade)
self._append_datastore_version(117)
10 changes: 1 addition & 9 deletions packages/modules/common/store/_inverter.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,18 +50,10 @@ def set(self, state: InverterState) -> None:
self.delegate.set(state)

def update(self) -> None:
state = self.filter_peaks(self.delegate.delegate.state)
state = self.fix_hybrid_values(state)
state = self.fix_hybrid_values(self.delegate.delegate.state)
self.delegate.set(state)
self.delegate.update()

def filter_peaks(self, state: InverterState) -> InverterState:
inverter = data.data.pv_data[f"pv{self.delegate.delegate.num}"]
max_ac_out = inverter.data.config.max_ac_out
if max_ac_out > 0 and abs(state.power) > max_ac_out:
state.power = max_ac_out if state.power > 0 else -max_ac_out
return state

def fix_hybrid_values(self, state: InverterState) -> InverterState:
children = data.data.counter_all_data.get_entry_of_element(self.delegate.delegate.num)["children"]
power = state.power
Expand Down
30 changes: 0 additions & 30 deletions packages/modules/common/store/_inverter_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,33 +50,3 @@ def test_fix_hybrid_values(params):

# evaluation
assert vars(state) == vars(params.expected_state)


FilterPeaksParams = NamedTuple("FilterPeaksParams", [(
"name", str), ("max_ac_out", int), ("input_power", float), ("expected_power", float)])
filter_peaks_cases = [
FilterPeaksParams("no_limit", 0, -5000, -5000), # max_ac_out = 0 -> keine Begrenzung
FilterPeaksParams("within_limit", 10000, -5000, -5000), # innerhalb der Grenze
FilterPeaksParams("exceeds_positive", 3000, 5000, 3000), # überschreitet positive Grenze
FilterPeaksParams("exceeds_negative", 3000, -5000, -3000), # überschreitet negative Grenze (behält Vorzeichen)
FilterPeaksParams("at_limit_positive", 5000, 5000, 5000), # genau an der positiven Grenze
FilterPeaksParams("at_limit_negative", 5000, -5000, -5000), # genau an der negativen Grenze
]


@pytest.mark.parametrize("params", filter_peaks_cases, ids=[c.name for c in filter_peaks_cases])
def test_filter_peaks(params):
# setup
mock_inverter = Mock()
mock_inverter.data.config.max_ac_out = params.max_ac_out
data.data.pv_data = {"pv1": mock_inverter}

purge = PurgeInverterState(delegate=Mock(delegate=Mock(num=1)))

# execution
input_state = InverterState(power=params.input_power, exported=1000)
result_state = purge.filter_peaks(input_state)

# evaluation
assert result_state.power == params.expected_power
assert result_state.exported == 1000 # exported sollte unverändert bleiben
93 changes: 93 additions & 0 deletions packages/modules/common/utils/peak_filter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import logging
from control import data
from typing import Optional

from modules.common.fault_state import FaultState

log = logging.getLogger(__name__)


class PeakFilter:
def __init__(self, type: str, component_id: int, fault_state: FaultState):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
def __init__(self, type: str, component_id: int, fault_state: FaultState):
def __init__(self, type: ComponentType, component_id: int, fault_state: FaultState):

Bitte das Enum für die Typen benutzen.

self.type = type
self.component_id = component_id
self.fault_state = fault_state
self.imported = None
self.exported = None

def check_values(
self,
power: float,
imported: Optional[float] = None,
exported: Optional[float] = None
) -> tuple[Optional[float], Optional[float]]:
# setze maximale Leistung je nach Komponente
if self.type == "counter":
counter = data.data.counter_data[f"counter{self.component_id}"]
max_power = counter.data.config.max_total_power
elif self.type == "inverter":
inverter = data.data.pv_data[f"pv{self.component_id}"]
max_power = inverter.data.config.max_ac_out
elif self.type == "bat":
Comment on lines +25 to +31
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
if self.type == "counter":
counter = data.data.counter_data[f"counter{self.component_id}"]
max_power = counter.data.config.max_total_power
elif self.type == "inverter":
inverter = data.data.pv_data[f"pv{self.component_id}"]
max_power = inverter.data.config.max_ac_out
elif self.type == "bat":
if self.type == ComponentType.COUNTER:
counter = data.data.counter_data[f"counter{self.component_id}"]
max_power = counter.data.config.max_total_power
elif self.type == ComponentType.INVERTER:
inverter = data.data.pv_data[f"pv{self.component_id}"]
max_power = inverter.data.config.max_ac_out
elif self.type == ComponentType.BAT:

bat = data.data.bat_data[f"bat{self.component_id}"]
max_power = bat.data.config.max_power
else:
raise ValueError(f"Unsupported component type {self.type!r} in PeakFilter")
# prüfe Leistung und importierte/exportierte Energie auf Plausibilität
self.check_power(max_power, power)
return self.check_imported_exported(max_power, imported, exported)

def check_power(self, max_power: float, power: float) -> None:
# Wenn die Leistung mehr als doppelt so hoch ist wie die
# konfigurierte maximale Leistung, ist sie unplausibel.
if max_power > 0 and abs(power) > 2 * max_power:
raise Exception(f"Peakfilter: Die Leistung von {power / 1000}kW überschreitet die konfigurierte max. "
f"Gesamtleistung von {max_power / 1000}kW um mehr als das Doppelte. "
"Werte werden noch nicht berücksichtigt.")

def check_imported_exported(
self,
max_power: float,
imported: Optional[float] = None,
exported: Optional[float] = None,
) -> tuple[Optional[float], Optional[float]]:
if max_power > 0:
# Die erlaubte Abweichung ist doppelt so groß wie die mögliche
# Energiemenge pro Intervall bei maximaler Leistung
control_interval = data.data.general_data.data.control_interval
allowed_deviation = 2 * (control_interval / 3600) * max_power

imp = self.check_total_energy(imported, self.imported, allowed_deviation)
self.imported = imported

exp = self.check_total_energy(exported, self.exported, allowed_deviation)
self.exported = exported
return imp, exp
return imported, exported

def check_total_energy(
self,
total_energy: Optional[float],
previous_total_energy: Optional[float],
allowed_deviation: float
) -> Optional[float]:
if total_energy is not None:
if previous_total_energy is None:
if allowed_deviation > 0:
log.debug(f"PeakFilter: Vorheriger Wert None, aktueller Zählerwert: {total_energy / 1000 }kWh. "
"Warte einen Regelintervall.")
self.fault_state.warning(f"Peakfilter: {total_energy / 1000}kWh. "
"Die PV-Produktion erscheint höher, als laut Anlagenkonfiguration "
"plausibel ist. Erneute Prüfung im nächsten Regelintervall.")
elif allowed_deviation > 0 and (total_energy - previous_total_energy) > allowed_deviation:
log.debug(f"PeakFilter: Unplausibler Zählerwert: {total_energy / 1000}kWh. "
f"Differenz zum vorherigen Wert: {total_energy - previous_total_energy}Wh. "
f"erlaubte Differenz: {round(allowed_deviation, 2)}Wh.")
self.fault_state.warning(f"Peakfilter: {total_energy / 1000}kWh. "
"Die PV-Produktion erscheint höher, als laut Anlagenkonfiguration plausibel "
"ist. Erneute Prüfung im nächsten Regelintervall.")
else:
log.debug(f"PeakFilter: Zählerwert: {total_energy}Wh innerhalb der zulässigen Grenzen. "
f"Differenz zum vorherigen Wert: {total_energy - previous_total_energy}Wh.")
return total_energy
return None
Comment on lines +74 to +93
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

In check_total_energy(), the else branch logs total_energy - previous_total_energy unconditionally. If previous_total_energy is still None (e.g. first run) and allowed_deviation is 0 (control_interval=0 or max_power very small), this will raise a TypeError. Consider guarding previous_total_energy is None before doing the subtraction, or handling the allowed_deviation <= 0 case explicitly (e.g. accept the value without diff logging).

Suggested change
if total_energy is not None:
if allowed_deviation > 0 and previous_total_energy is None:
log.debug(f"PeakFilter: Vorheriger Wert None, aktueller Zählerwert: {total_energy / 1000 }kWh. "
"Warte einen Regelintervall.")
self.fault_state.warning(f"Peakfilter: {total_energy / 1000}kWh. "
"Die PV-Produktion erscheint höher, als laut Anlagenkonfiguration plausibel "
"ist. Erneute Prüfung im nächsten Regelintervall.")
elif allowed_deviation > 0 and (total_energy - previous_total_energy) > allowed_deviation:
log.debug(f"PeakFilter: Unplausibler Zählerwert: {total_energy / 1000}kWh. "
f"Differenz zum vorherigen Wert: {total_energy - previous_total_energy}Wh. "
f"erlaubte Differenz: {round(allowed_deviation, 2)}Wh.")
self.fault_state.warning(f"Peakfilter: {total_energy / 1000}kWh. "
"Die PV-Produktion erscheint höher, als laut Anlagenkonfiguration plausibel "
"ist. Erneute Prüfung im nächsten Regelintervall.")
else:
log.debug(f"PeakFilter: Zählerwert: {total_energy}Wh innerhalb der zulässigen Grenzen. "
f"Differenz zum vorherigen Wert: {total_energy - previous_total_energy}Wh.")
return total_energy
return None
if total_energy is None:
return None
# Wenn keine sinnvolle erlaubte Abweichung berechnet werden konnte,
# akzeptiere den Wert ohne Plausibilitätsprüfung.
if allowed_deviation <= 0:
if previous_total_energy is not None:
log.debug(
f"PeakFilter: Zählerwert: {total_energy}Wh (keine Plausibilitätsprüfung). "
f"Differenz zum vorherigen Wert: {total_energy - previous_total_energy}Wh."
)
else:
log.debug(
f"PeakFilter: Zählerwert: {total_energy}Wh (keine Plausibilitätsprüfung, "
"kein vorheriger Wert vorhanden)."
)
return total_energy
if previous_total_energy is None:
log.debug(
f"PeakFilter: Vorheriger Wert None, aktueller Zählerwert: {total_energy / 1000}kWh. "
"Warte einen Regelintervall."
)
self.fault_state.warning(
f"Peakfilter: {total_energy / 1000}kWh. "
"Die PV-Produktion erscheint höher, als laut Anlagenkonfiguration plausibel "
"ist. Erneute Prüfung im nächsten Regelintervall."
)
return None
if (total_energy - previous_total_energy) > allowed_deviation:
log.debug(
f"PeakFilter: Unplausibler Zählerwert: {total_energy / 1000}kWh. "
f"Differenz zum vorherigen Wert: {total_energy - previous_total_energy}Wh. "
f"erlaubte Differenz: {round(allowed_deviation, 2)}Wh."
)
self.fault_state.warning(
f"Peakfilter: {total_energy / 1000}kWh. "
"Die PV-Produktion erscheint höher, als laut Anlagenkonfiguration plausibel "
"ist. Erneute Prüfung im nächsten Regelintervall."
)
return None
log.debug(
f"PeakFilter: Zählerwert: {total_energy}Wh innerhalb der zulässigen Grenzen. "
f"Differenz zum vorherigen Wert: {total_energy - previous_total_energy}Wh."
)
return total_energy

Copilot uses AI. Check for mistakes.
90 changes: 90 additions & 0 deletions packages/modules/common/utils/test_peak_filter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
from dataclasses import dataclass
import pytest
from unittest.mock import MagicMock
from modules.common.utils.peak_filter import PeakFilter
from modules.common.fault_state import FaultState


class DummyFaultState(FaultState):
def __init__(self):
self.warnings = []

def warning(self, msg):
self.warnings.append(msg)


class DummyConfig:
def __init__(self, max_power):
self.max_power = max_power
self.max_total_power = max_power
self.max_ac_out = max_power


class DummyData:
def __init__(self, max_power):
self.data = MagicMock()
self.data.config = DummyConfig(max_power)


@pytest.fixture(autouse=True)
def patch_data(monkeypatch):
import modules.common.utils.peak_filter as pf
pf.data = MagicMock()
pf.data.data = MagicMock()
pf.data.data.counter_data = {"counter1": DummyData(3000)}
pf.data.data.pv_data = {"pv1": DummyData(2000)}
pf.data.data.bat_data = {"bat1": DummyData(1000)}
pf.data.data.general_data = MagicMock()
pf.data.data.general_data.data = MagicMock()
pf.data.data.general_data.data.control_interval = 10
yield


@dataclass
class Params:
name: str
type: str
previous_imported: float
previous_exported: float
power: float
imported: float
exported: float
expected_imported: float
expected_exported: float
expect_exception: bool = False


cases = [
Params("Power Peak Zähler positiv", "counter", 1000, 500, 11000, 1300, 800, None, None, True),
Params("Power Peak Zähler negativ", "counter", 1000, 500, -11000, 1300, 800, None, None, True),
Params("Power Peak Wechselrichter", "inverter", 1000, 500, 11000, 1005, 505, 1005, 505, True),
Params("Power Peak Speicher positiv", "bat", 1000, 500, 11000, 1005, 505, 1005, 505, True),
Params("Power Peak Speicher negativ", "bat", 1000, 500, -11000, 1005, 505, 1005, 505, True),
Params("Imp/ Exp Zähler - Werte valide", "counter", 1000, 500, 900, 1005, 505, 1005, 505),
Params("Imp/ Exp Wechselrichter - Werte valide", "inverter", 1000, 500, 1500, 1005, 505, 1005, 505),
Params("Imp/ Exp Speicher - Werte valide", "bat", 1000, 500, 400, 1005, 505, 1005, 505),
Params("Imp/ Exp Zähler - Werte invalide", "counter", 1000, 500, 1000, 1300, 800, None, None),
Params("Imp/ Exp Zähler - Import invalide", "counter", 1000, 500, 1000, 1300, 505, None, 505),
Params("Imp/ Exp Zähler - Export invalide", "counter", 1000, 500, 1000, 1005, 800, 1005, None),
Params("Imp/ Exp Wechselrichter - Werte invalide", "inverter", 1000, 500, 1500, 1300, 800, None, None),
Params("Imp/ Exp Wechselrichter - Import invalide", "inverter", 1000, 500, 1500, 1300, 505, None, 505),
Params("Imp/ Exp Wechselrichter - Export invalide", "inverter", 1000, 500, 1500, 1005, 800, 1005, None),
Params("Imp/ Exp Speicher - Werte invalide", "bat", 1000, 500, 400, 1300, 800, None, None),
Params("Imp/ Exp Speicher - Import invalide", "bat", 1000, 500, 400, 1300, 505, None, 505),
Params("Imp/ Exp Speicher - Export invalide", "bat", 1000, 500, 400, 1005, 800, 1005, None),
]


@pytest.mark.parametrize("params", cases, ids=[c.name for c in cases])
def test_check_values_valid(params):
fs = DummyFaultState()
pf = PeakFilter(params.type, 1, fs)
pf.imported = params.previous_imported
pf.exported = params.previous_exported
if params.expect_exception:
with pytest.raises(Exception):
imp, exp = pf.check_values(params.power, params.imported, params.exported)
else:
imp, exp = pf.check_values(params.power, params.imported, params.exported)
assert imp == params.expected_imported
assert exp == params.expected_exported
3 changes: 3 additions & 0 deletions packages/modules/devices/algodue/algodue/bat.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from modules.common.modbus import ModbusDataType
from modules.common.simcount import SimCounter
from modules.common.store import get_bat_value_store
from modules.common.utils.peak_filter import PeakFilter


class KwargsDict(TypedDict):
Expand All @@ -30,13 +31,15 @@ def initialize(self) -> None:
self.sim_counter = SimCounter(self.__device_id, self.component_config.id, prefix="speicher")
self.store = get_bat_value_store(self.component_config.id)
self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config))
self.peak_filter = PeakFilter("bat", self.component_config.id, self.fault_state)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
self.peak_filter = PeakFilter("bat", self.component_config.id, self.fault_state)
self.peak_filter = PeakFilter(ComponentType.BAT, self.component_config.id, self.fault_state)


def update(self):
currents = self.__tcp_client.read_input_registers(
0x100E, [ModbusDataType.FLOAT_32]*3, unit=self.__modbus_id)
powers = self.__tcp_client.read_input_registers(0x1020, [ModbusDataType.FLOAT_32]*3, unit=self.__modbus_id)
power = sum(powers)

self.peak_filter.check_values(power)
imported, exported = self.sim_counter.sim_count(power)

bat_state = BatState(
Expand Down
3 changes: 3 additions & 0 deletions packages/modules/devices/algodue/algodue/counter.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from modules.common.modbus import ModbusDataType
from modules.common.simcount import SimCounter
from modules.common.store import get_counter_value_store
from modules.common.utils.peak_filter import PeakFilter


class KwargsDict(TypedDict):
Expand All @@ -30,6 +31,7 @@ def initialize(self) -> None:
self.sim_counter = SimCounter(self.__device_id, self.component_config.id, prefix="bezug")
self.store = get_counter_value_store(self.component_config.id)
self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config))
self.peak_filter = PeakFilter("counter", self.component_config.id, self.fault_state)

def update(self):
frequency = self.__tcp_client.read_input_registers(0x1038, ModbusDataType.FLOAT_32, unit=self.__modbus_id)
Expand All @@ -42,6 +44,7 @@ def update(self):
power_factors = self.__tcp_client.read_input_registers(
0x1018, [ModbusDataType.FLOAT_32]*3, unit=self.__modbus_id)

self.peak_filter.check_values(power)
imported, exported = self.sim_counter.sim_count(power)

counter_state = CounterState(
Expand Down
3 changes: 3 additions & 0 deletions packages/modules/devices/algodue/algodue/inverter.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from modules.common.modbus import ModbusDataType
from modules.common.simcount import SimCounter
from modules.common.store import get_inverter_value_store
from modules.common.utils.peak_filter import PeakFilter


class KwargsDict(TypedDict):
Expand All @@ -30,13 +31,15 @@ def initialize(self) -> None:
self.sim_counter = SimCounter(self.__device_id, self.component_config.id, prefix="pv")
self.store = get_inverter_value_store(self.component_config.id)
self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config))
self.peak_filter = PeakFilter("inverter", self.component_config.id, self.fault_state)

def update(self):
currents = self.__tcp_client.read_input_registers(
0x100E, [ModbusDataType.FLOAT_32]*3, unit=self.__modbus_id)
powers = self.__tcp_client.read_input_registers(0x1020, [ModbusDataType.FLOAT_32]*3, unit=self.__modbus_id)
power = sum(powers)

self.peak_filter.check_values(power)
_, exported = self.sim_counter.sim_count(power)

inverter_state = InverterState(
Expand Down
3 changes: 3 additions & 0 deletions packages/modules/devices/alpha_ess/alpha_ess/bat.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from modules.common.simcount import SimCounter
from modules.common.store import get_bat_value_store
from modules.devices.alpha_ess.alpha_ess.config import AlphaEssBatSetup
from modules.common.utils.peak_filter import PeakFilter

log = logging.getLogger(__name__)

Expand All @@ -30,6 +31,7 @@ def initialize(self) -> None:
self.sim_counter = SimCounter(self.kwargs['device_id'], self.component_config.id, prefix="speicher")
self.store = get_bat_value_store(self.component_config.id)
self.fault_state = FaultState(ComponentInfo.from_component_config(self.component_config))
self.peak_filter = PeakFilter("bat", self.component_config.id, self.fault_state)
self.last_mode = 'Undefined'

def update(self) -> None:
Expand All @@ -49,6 +51,7 @@ def update(self) -> None:
soc_reg = self.__tcp_client.read_holding_registers(0x0102, ModbusDataType.INT_16, unit=self.__modbus_id)
soc = int(soc_reg * 0.1)

self.peak_filter.check_values(power)
imported, exported = self.sim_counter.sim_count(power)
bat_state = BatState(
power=power,
Expand Down
Loading
Loading