From a41b455d8a2896fcf5dacca1908d00890c1fb55f Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sat, 31 Oct 2026 18:54:27 +0100 Subject: [PATCH 1/2] =?UTF-8?q?PeakFilter:=20Vorw=C3=A4rtssprung=20begrenz?= =?UTF-8?q?en=20statt=20einen=20Zyklus=20verwerfen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/modules/common/utils/peak_filter.py | 11 +++- .../modules/common/utils/test_peak_filter.py | 65 ++++++++++++++----- 2 files changed, 59 insertions(+), 17 deletions(-) diff --git a/packages/modules/common/utils/peak_filter.py b/packages/modules/common/utils/peak_filter.py index bfb26cfbad..2a46f2f30b 100644 --- a/packages/modules/common/utils/peak_filter.py +++ b/packages/modules/common/utils/peak_filter.py @@ -91,7 +91,16 @@ def check_total_energy( 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.") + f"erlaubte Differenz: {round(allowed_deviation, 2)}Wh. Wert wird auf die erlaubte " + "Differenz begrenzt und in den folgenden Zyklen nachgezogen.") + # Manche Zähler/Wechselrichter aktualisieren ihren Stand nicht kontinuierlich, sondern in + # Sprüngen. Ein einmaliges Verwerfen würde den vollen Sprung im nächsten Zyklus ungebremst + # durchreichen (der Sprung bleibt ja bestehen und ist dann keine Abweichung mehr). Stattdessen + # hier auf die erlaubte Differenz begrenzen und als neue Vergleichsbasis merken, sodass der + # Sprung über die folgenden Zyklen gleichmäßig nachgezogen wird. + checked_energy = previous_total_energy + allowed_deviation + prev_energy = checked_energy + return checked_energy, prev_energy elif (total_energy - previous_total_energy) < 0: if previous_total_energy > max_energy: log.debug("PeakFilter: Erholung nach unplausibel hohem Zählerwert erkannt. " diff --git a/packages/modules/common/utils/test_peak_filter.py b/packages/modules/common/utils/test_peak_filter.py index 9af278ece9..659187e3a6 100644 --- a/packages/modules/common/utils/test_peak_filter.py +++ b/packages/modules/common/utils/test_peak_filter.py @@ -64,15 +64,24 @@ class Params: Params("Imp/ Exp Zähler - Werte valide", ComponentType.COUNTER, 1000, 500, 900, 1005, 505, 1005, 505), Params("Imp/ Exp Wechselrichter - Werte valide", ComponentType.INVERTER, 1000, 500, 1500, 1005, 505, 1005, 505), Params("Imp/ Exp Speicher - Werte valide", ComponentType.BAT, 1000, 500, 400, 1005, 505, 1005, 505), - Params("Imp/ Exp Zähler - Werte invalide", ComponentType.COUNTER, 1000, 500, 1000, 1300, 800, None, None), - Params("Imp/ Exp Zähler - Import invalide", ComponentType.COUNTER, 1000, 500, 1000, 1300, 505, None, 505), - Params("Imp/ Exp Zähler - Export invalide", ComponentType.COUNTER, 1000, 500, 1000, 1005, 800, 1005, None), - Params("Imp/ Exp Wechselrichter - Werte invalide", ComponentType.INVERTER, 1000, 500, 1500, 1300, 800, None, None), - Params("Imp/ Exp Wechselrichter - Import invalide", ComponentType.INVERTER, 1000, 500, 1500, 1300, 505, None, 505), - Params("Imp/ Exp Wechselrichter - Export invalide", ComponentType.INVERTER, 1000, 500, 1500, 1005, 800, 1005, None), - Params("Imp/ Exp Speicher - Werte invalide", ComponentType.BAT, 1000, 500, 400, 1300, 800, None, None), - Params("Imp/ Exp Speicher - Import invalide", ComponentType.BAT, 1000, 500, 400, 1300, 505, None, 505), - Params("Imp/ Exp Speicher - Export invalide", ComponentType.BAT, 1000, 500, 400, 1005, 800, 1005, None), + Params("Imp/ Exp Zähler - Werte invalide", ComponentType.COUNTER, 1000, 500, 1000, 1300, 800, + 1016.6666666666666, 516.6666666666666), + Params("Imp/ Exp Zähler - Import invalide", ComponentType.COUNTER, 1000, 500, 1000, 1300, 505, + 1016.6666666666666, 505), + Params("Imp/ Exp Zähler - Export invalide", ComponentType.COUNTER, 1000, 500, 1000, 1005, 800, + 1005, 516.6666666666666), + Params("Imp/ Exp Wechselrichter - Werte invalide", ComponentType.INVERTER, 1000, 500, 1500, 1300, 800, + 1011.1111111111111, 511.1111111111111), + Params("Imp/ Exp Wechselrichter - Import invalide", ComponentType.INVERTER, 1000, 500, 1500, 1300, 505, + 1011.1111111111111, 505), + Params("Imp/ Exp Wechselrichter - Export invalide", ComponentType.INVERTER, 1000, 500, 1500, 1005, 800, + 1005, 511.1111111111111), + Params("Imp/ Exp Speicher - Werte invalide", ComponentType.BAT, 1000, 500, 400, 1300, 800, + 1005.5555555555555, 505.55555555555554), + Params("Imp/ Exp Speicher - Import invalide", ComponentType.BAT, 1000, 500, 400, 1300, 505, + 1005.5555555555555, 505), + Params("Imp/ Exp Speicher - Export invalide", ComponentType.BAT, 1000, 500, 400, 1005, 800, + 1005, 505.55555555555554), Params("Imp/ Exp Speicher - Import > 50Y", ComponentType.BAT, 500000000, 500, 400, 500000000, 505, None, 505), Params("Imp/ Exp Speicher - Export > 50Y", ComponentType.BAT, 1000, 500000000, 400, 1005, 500000000, 1005, None), Params("Imp/ Exp Speicher - Export ~ 45Y", ComponentType.BAT, @@ -82,6 +91,13 @@ class Params: ] +def assert_close(actual, expected): + if expected is None: + assert actual is None + else: + assert actual == pytest.approx(expected) + + @pytest.mark.parametrize("params", cases, ids=[c.name for c in cases]) def test_check_values_valid(params): fs = DummyFaultState() @@ -93,23 +109,34 @@ def test_check_values_valid(params): 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 + assert_close(imp, params.expected_imported) + assert_close(exp, params.expected_exported) -def test_check_total_energy_forward_jump_then_accept_second_high_and_wait_after_backward_jump(): +def test_check_total_energy_forward_jump_is_capped_and_catches_up_over_cycles(): fs = DummyFaultState() pf = PeakFilter(ComponentType.COUNTER, 1, fs) allowed_deviation = 10 max_energy = 1_000_000_000 - # Vorwaertssprung: erster Wert wird verworfen, aber als Vergleichsbasis gespeichert. + # Grosser Vorwaertssprung (zB Zaehler/Wechselrichter aktualisiert nur sprunghaft): + # wird auf die erlaubte Differenz begrenzt statt verworfen, und als neue Basis gemerkt. checked, previous = pf.check_total_energy(2000, 1000, allowed_deviation, max_energy) - assert checked is None + assert checked == 1010 + assert previous == 1010 + + # Rohwert bleibt stehen - naechster Zyklus holt schrittweise weiter auf. + checked, previous = pf.check_total_energy(2000, previous, allowed_deviation, max_energy) + assert checked == 1020 + assert previous == 1020 + + for _ in range(98): + checked, previous = pf.check_total_energy(2000, previous, allowed_deviation, max_energy) + assert checked == 2000 assert previous == 2000 - # Zweiter hoher Wert mit kleiner Differenz wird uebernommen. + # Danach wird mit kleinen, plausiblen Differenzen normal weitergezaehlt. checked, previous = pf.check_total_energy(2005, previous, allowed_deviation, max_energy) assert checked == 2005 assert previous == 2005 @@ -133,10 +160,16 @@ def test_check_total_energy_forward_jump_then_accept_second_high_and_wait_after_ assert checked == 2010 assert previous == 2010 - # Der niedrigere Folgewert wird einmal verworfen und als neue Vergleichsbasis gesetzt. + +def test_check_total_energy_recovery_after_too_high_previous_value(): + fs = DummyFaultState() + pf = PeakFilter(ComponentType.COUNTER, 1, fs) + allowed_deviation = 10 max_energy = 3000 + # Vorheriger Wert lag ueber dem 50-Jahres-Limit - niedrigerer Folgewert wird einmal verworfen + # und als neue Vergleichsbasis gesetzt (unabhaengig vom Cap-Verhalten bei Vorwaertssprueingen). checked, previous = pf.check_total_energy(4000, 2900, allowed_deviation, max_energy) assert checked is None assert previous == 4000 From 3e27f5ebc129b688f43d822d175fc2b5c8470881 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sat, 31 Oct 2026 19:04:16 +0100 Subject: [PATCH 2/2] =?UTF-8?q?PeakFilter:=20moderate=20Vorw=C3=A4rtsspr?= =?UTF-8?q?=C3=BCnge=20gl=C3=A4tten=20statt=20einmalig=20verwerfen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/modules/common/utils/peak_filter.py | 31 +++-- .../modules/common/utils/test_peak_filter.py | 107 +++++++++--------- 2 files changed, 76 insertions(+), 62 deletions(-) diff --git a/packages/modules/common/utils/peak_filter.py b/packages/modules/common/utils/peak_filter.py index 2a46f2f30b..5e2edde576 100644 --- a/packages/modules/common/utils/peak_filter.py +++ b/packages/modules/common/utils/peak_filter.py @@ -7,6 +7,12 @@ log = logging.getLogger(__name__) +# Vorwärtssprünge werden nur bis zu dieser Vielfachheit von allowed_deviation geglättet (siehe +# check_total_energy). Das deckt die Größenordnung eines einzelnen groben Zähler-/WR-Registertakts mit +# Puffer ab, lässt aber deutlich größere Sprünge (zB nach einer längeren Verbindungsunterbrechung) weiter +# sofort nach einer Bestätigung im nächsten Zyklus durch, statt sie über eine ggf. sehr lange Zeit zu glätten. +MAX_SMOOTHABLE_JUMP_FACTOR = 10 + class PeakFilter: def __init__(self, component_type: ComponentType, component_id: int, fault_state: FaultState): @@ -89,18 +95,21 @@ def check_total_energy( f"aktueller Zählerwert: {total_energy / 1000 }kWh. " "Warte einen Regelintervall.") elif allowed_deviation > 0 and (total_energy - previous_total_energy) > allowed_deviation: + jump = total_energy - previous_total_energy 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. Wert wird auf die erlaubte " - "Differenz begrenzt und in den folgenden Zyklen nachgezogen.") - # Manche Zähler/Wechselrichter aktualisieren ihren Stand nicht kontinuierlich, sondern in - # Sprüngen. Ein einmaliges Verwerfen würde den vollen Sprung im nächsten Zyklus ungebremst - # durchreichen (der Sprung bleibt ja bestehen und ist dann keine Abweichung mehr). Stattdessen - # hier auf die erlaubte Differenz begrenzen und als neue Vergleichsbasis merken, sodass der - # Sprung über die folgenden Zyklen gleichmäßig nachgezogen wird. - checked_energy = previous_total_energy + allowed_deviation - prev_energy = checked_energy - return checked_energy, prev_energy + f"Differenz zum vorherigen Wert: {jump}Wh. " + f"erlaubte Differenz: {round(allowed_deviation, 2)}Wh.") + if jump <= allowed_deviation * MAX_SMOOTHABLE_JUMP_FACTOR: + # Manche Zähler/Wechselrichter aktualisieren ihren Stand nicht kontinuierlich, sondern in + # Sprüngen. Ein einmaliges Verwerfen würde den vollen Sprung im nächsten Zyklus ungebremst + # durchreichen (der Sprung bleibt ja bestehen und ist dann keine Abweichung mehr). + # Stattdessen hier auf die erlaubte Differenz begrenzen und als neue Vergleichsbasis merken, + # sodass der Sprung über die folgenden Zyklen gleichmäßig nachgezogen wird. Nur für Sprünge + # bis MAX_SMOOTHABLE_JUMP_FACTOR × allowed_deviation, damit ein Sprung nach einer längeren + # Verbindungsunterbrechung (echter, großer Nachholbedarf) nicht ebenso ausgebremst wird. + checked_energy = previous_total_energy + allowed_deviation + prev_energy = checked_energy + return checked_energy, prev_energy elif (total_energy - previous_total_energy) < 0: if previous_total_energy > max_energy: log.debug("PeakFilter: Erholung nach unplausibel hohem Zählerwert erkannt. " diff --git a/packages/modules/common/utils/test_peak_filter.py b/packages/modules/common/utils/test_peak_filter.py index 659187e3a6..e0fc5f90f7 100644 --- a/packages/modules/common/utils/test_peak_filter.py +++ b/packages/modules/common/utils/test_peak_filter.py @@ -64,24 +64,15 @@ class Params: Params("Imp/ Exp Zähler - Werte valide", ComponentType.COUNTER, 1000, 500, 900, 1005, 505, 1005, 505), Params("Imp/ Exp Wechselrichter - Werte valide", ComponentType.INVERTER, 1000, 500, 1500, 1005, 505, 1005, 505), Params("Imp/ Exp Speicher - Werte valide", ComponentType.BAT, 1000, 500, 400, 1005, 505, 1005, 505), - Params("Imp/ Exp Zähler - Werte invalide", ComponentType.COUNTER, 1000, 500, 1000, 1300, 800, - 1016.6666666666666, 516.6666666666666), - Params("Imp/ Exp Zähler - Import invalide", ComponentType.COUNTER, 1000, 500, 1000, 1300, 505, - 1016.6666666666666, 505), - Params("Imp/ Exp Zähler - Export invalide", ComponentType.COUNTER, 1000, 500, 1000, 1005, 800, - 1005, 516.6666666666666), - Params("Imp/ Exp Wechselrichter - Werte invalide", ComponentType.INVERTER, 1000, 500, 1500, 1300, 800, - 1011.1111111111111, 511.1111111111111), - Params("Imp/ Exp Wechselrichter - Import invalide", ComponentType.INVERTER, 1000, 500, 1500, 1300, 505, - 1011.1111111111111, 505), - Params("Imp/ Exp Wechselrichter - Export invalide", ComponentType.INVERTER, 1000, 500, 1500, 1005, 800, - 1005, 511.1111111111111), - Params("Imp/ Exp Speicher - Werte invalide", ComponentType.BAT, 1000, 500, 400, 1300, 800, - 1005.5555555555555, 505.55555555555554), - Params("Imp/ Exp Speicher - Import invalide", ComponentType.BAT, 1000, 500, 400, 1300, 505, - 1005.5555555555555, 505), - Params("Imp/ Exp Speicher - Export invalide", ComponentType.BAT, 1000, 500, 400, 1005, 800, - 1005, 505.55555555555554), + Params("Imp/ Exp Zähler - Werte invalide", ComponentType.COUNTER, 1000, 500, 1000, 1300, 800, None, None), + Params("Imp/ Exp Zähler - Import invalide", ComponentType.COUNTER, 1000, 500, 1000, 1300, 505, None, 505), + Params("Imp/ Exp Zähler - Export invalide", ComponentType.COUNTER, 1000, 500, 1000, 1005, 800, 1005, None), + Params("Imp/ Exp Wechselrichter - Werte invalide", ComponentType.INVERTER, 1000, 500, 1500, 1300, 800, None, None), + Params("Imp/ Exp Wechselrichter - Import invalide", ComponentType.INVERTER, 1000, 500, 1500, 1300, 505, None, 505), + Params("Imp/ Exp Wechselrichter - Export invalide", ComponentType.INVERTER, 1000, 500, 1500, 1005, 800, 1005, None), + Params("Imp/ Exp Speicher - Werte invalide", ComponentType.BAT, 1000, 500, 400, 1300, 800, None, None), + Params("Imp/ Exp Speicher - Import invalide", ComponentType.BAT, 1000, 500, 400, 1300, 505, None, 505), + Params("Imp/ Exp Speicher - Export invalide", ComponentType.BAT, 1000, 500, 400, 1005, 800, 1005, None), Params("Imp/ Exp Speicher - Import > 50Y", ComponentType.BAT, 500000000, 500, 400, 500000000, 505, None, 505), Params("Imp/ Exp Speicher - Export > 50Y", ComponentType.BAT, 1000, 500000000, 400, 1005, 500000000, 1005, None), Params("Imp/ Exp Speicher - Export ~ 45Y", ComponentType.BAT, @@ -91,13 +82,6 @@ class Params: ] -def assert_close(actual, expected): - if expected is None: - assert actual is None - else: - assert actual == pytest.approx(expected) - - @pytest.mark.parametrize("params", cases, ids=[c.name for c in cases]) def test_check_values_valid(params): fs = DummyFaultState() @@ -109,56 +93,77 @@ def test_check_values_valid(params): imp, exp = pf.check_values(params.power, params.imported, params.exported) else: imp, exp = pf.check_values(params.power, params.imported, params.exported) - assert_close(imp, params.expected_imported) - assert_close(exp, params.expected_exported) + assert imp == params.expected_imported + assert exp == params.expected_exported -def test_check_total_energy_forward_jump_is_capped_and_catches_up_over_cycles(): +def test_check_total_energy_forward_jump_within_bound_is_capped_and_catches_up_over_cycles(): fs = DummyFaultState() pf = PeakFilter(ComponentType.COUNTER, 1, fs) allowed_deviation = 10 max_energy = 1_000_000_000 - # Grosser Vorwaertssprung (zB Zaehler/Wechselrichter aktualisiert nur sprunghaft): - # wird auf die erlaubte Differenz begrenzt statt verworfen, und als neue Basis gemerkt. - checked, previous = pf.check_total_energy(2000, 1000, allowed_deviation, max_energy) + # Vorwaertssprung genau am Rand von MAX_SMOOTHABLE_JUMP_FACTOR * allowed_deviation (zB Zaehler/WR, + # der seinen Stand nur sprunghaft aktualisiert): wird auf die erlaubte Differenz begrenzt statt + # verworfen, und als neue Basis gemerkt. + checked, previous = pf.check_total_energy(1100, 1000, allowed_deviation, max_energy) assert checked == 1010 assert previous == 1010 # Rohwert bleibt stehen - naechster Zyklus holt schrittweise weiter auf. - checked, previous = pf.check_total_energy(2000, previous, allowed_deviation, max_energy) + checked, previous = pf.check_total_energy(1100, previous, allowed_deviation, max_energy) assert checked == 1020 assert previous == 1020 - for _ in range(98): - checked, previous = pf.check_total_energy(2000, previous, allowed_deviation, max_energy) - assert checked == 2000 - assert previous == 2000 + for _ in range(8): + checked, previous = pf.check_total_energy(1100, previous, allowed_deviation, max_energy) + assert checked == 1100 + assert previous == 1100 # Danach wird mit kleinen, plausiblen Differenzen normal weitergezaehlt. - checked, previous = pf.check_total_energy(2005, previous, allowed_deviation, max_energy) - assert checked == 2005 - assert previous == 2005 + checked, previous = pf.check_total_energy(1105, previous, allowed_deviation, max_energy) + assert checked == 1105 + assert previous == 1105 # Rueckwaertssprung: letzter gueltiger Hoechststand bleibt erhalten. - checked, previous = pf.check_total_energy(1900, previous, allowed_deviation, max_energy) - assert checked == 2005 - assert previous == 2005 + checked, previous = pf.check_total_energy(1000, previous, allowed_deviation, max_energy) + assert checked == 1105 + assert previous == 1105 # Unterhalb des Hoechststands wird weiterhin gewartet. - checked, previous = pf.check_total_energy(1990, previous, allowed_deviation, max_energy) - assert checked == 2005 - assert previous == 2005 + checked, previous = pf.check_total_energy(1090, previous, allowed_deviation, max_energy) + assert checked == 1105 + assert previous == 1105 # Naechster gleicher oder hoeherer Wert wird wieder zugelassen. - checked, previous = pf.check_total_energy(2005, previous, allowed_deviation, max_energy) - assert checked == 2005 - assert previous == 2005 + checked, previous = pf.check_total_energy(1105, previous, allowed_deviation, max_energy) + assert checked == 1105 + assert previous == 1105 + + checked, previous = pf.check_total_energy(1110, previous, allowed_deviation, max_energy) + assert checked == 1110 + assert previous == 1110 + + +def test_check_total_energy_forward_jump_beyond_bound_falls_back_to_reject_then_accept(): + fs = DummyFaultState() + pf = PeakFilter(ComponentType.COUNTER, 1, fs) + + allowed_deviation = 10 + max_energy = 1_000_000_000 - checked, previous = pf.check_total_energy(2010, previous, allowed_deviation, max_energy) - assert checked == 2010 - assert previous == 2010 + # Sprung deutlich groesser als MAX_SMOOTHABLE_JUMP_FACTOR * allowed_deviation (zB nach einer + # laengeren Verbindungsunterbrechung mit echtem Nachholbedarf, nicht nur einem groben Zaehlertakt): + # wird NICHT geglaettet, sondern wie vor dieser Aenderung einmal verworfen und bei Bestaetigung im + # naechsten Zyklus sofort in voller Hoehe uebernommen. + checked, previous = pf.check_total_energy(2000, 1000, allowed_deviation, max_energy) + assert checked is None + assert previous == 2000 + + checked, previous = pf.check_total_energy(2000, previous, allowed_deviation, max_energy) + assert checked == 2000 + assert previous == 2000 def test_check_total_energy_recovery_after_too_high_previous_value():