Skip to content

Add Hampel outlier filter for noisy powermeter sources#334

Merged
tomquist merged 1 commit intodevelopfrom
claude/evaluate-kalman-filter-7RNnp
Apr 19, 2026
Merged

Add Hampel outlier filter for noisy powermeter sources#334
tomquist merged 1 commit intodevelopfrom
claude/evaluate-kalman-filter-7RNnp

Conversation

@tomquist
Copy link
Copy Markdown
Owner

@tomquist tomquist commented Apr 19, 2026

Summary

Introduces an optional Hampel identifier-based outlier rejection filter for powermeter readings. This wrapper detects and replaces wild samples (e.g., from MQTT/HTTP/WiFi glitches) with the rolling-window median while preserving normal readings unchanged.

Key Changes

  • New HampelPowermeter wrapper (src/astrameter/powermeter/wrappers/hampel.py):

    • Implements rolling-median outlier detection using Median Absolute Deviation (MAD)
    • Rejects samples deviating more than n_sigma * 1.4826 * MAD from the window median
    • Includes min_threshold floor to handle constant-signal (MAD=0) degenerate case
    • Mutates rejected samples to the median in the window to prevent single spikes from poisoning future detections
    • Redistributes per-phase values proportionally when total is replaced; equal-splits when raw total is near zero
    • Operates on sum-of-phases, consistent with downstream wrappers (EMA, deadband, PID)
  • Comprehensive test suite (src/astrameter/powermeter/wrappers/hampel_test.py):

    • 268 lines covering warmup behavior, spike detection/replacement, window management, edge cases (constant signals, even windows, zero n_sigma), and lifecycle delegation
    • Tests confirm phase-ratio preservation and recovery from warmup poisoning
  • Configuration integration:

    • Added global config options in [GENERAL]: HAMPEL_WINDOW, HAMPEL_N_SIGMA, HAMPEL_MIN_THRESHOLD
    • Per-powermeter section overrides supported
    • Inserted in wrapper chain after throttling and before EMA smoothing
    • Disabled by default (HAMPEL_WINDOW=0)
  • Documentation:

    • Updated config.ini.example with recommended settings and parameter descriptions
    • Updated README.md with per-powermeter option documentation
    • Updated CHANGELOG.md
  • Module exports: Added HampelPowermeter to __init__.py files for public API

Implementation Details

  • Uses collections.deque with maxlen for efficient rolling window management
  • Leverages statistics.median() for robust central tendency estimation
  • Threshold calculation: max(n_sigma * 1.4826 * MAD, min_threshold) ensures both statistical and absolute bounds
  • Window entry mutation (not original value replacement) prevents outlier re-entry after eviction
  • Handles edge cases: empty inputs, window=1 (degenerate passthrough), even window sizes, near-zero totals

https://claude.ai/code/session_01Kesh7c5j8kcpEBJPLyEHik

Summary by CodeRabbit

Release Notes

  • New Features

    • Added optional Hampel outlier filter to reject extreme power readings from noisy sources. Configurable with window size, sigma threshold, and minimum threshold parameters. Disabled by default and can be set globally or per-powermeter.
  • Documentation

    • Updated configuration examples and documentation with Hampel filter setup and parameter details.

Introduce HampelPowermeter — stateful rolling-median rejection of wild
samples — as a new wrapper in the filter chain (after throttling, before
EMA smoothing). Useful for flaky MQTT/HTTP/WiFi sources that occasionally
emit impulse noise; EMA alone smooths these into the signal, while Hampel
replaces them with the window median before they reach downstream stages.

Configurable via HAMPEL_WINDOW, HAMPEL_N_SIGMA, HAMPEL_MIN_THRESHOLD
globally or per powermeter section. Disabled by default.

https://claude.ai/code/session_01Kesh7c5j8kcpEBJPLyEHik
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 19, 2026

Walkthrough

This pull request adds an optional Hampel outlier filter for power meter readings to reject extreme samples. The filter is implemented as a configurable wrapper that uses rolling-window median and MAD (median absolute deviation) statistics to detect and suppress spikes while preserving phase ratios.

Changes

Cohort / File(s) Summary
Documentation & Configuration
CHANGELOG.md, README.md, config.ini.example
Added documentation for new Hampel filter parameters (HAMPEL_WINDOW, HAMPEL_N_SIGMA, HAMPEL_MIN_THRESHOLD), including operation timing in the processing pipeline and default behavior.
Implementation Core
src/astrameter/powermeter/wrappers/hampel.py
New HampelPowermeter wrapper class implementing rolling Hampel outlier rejection with window-based median/MAD computation, threshold-based spike detection, and proportional phase redistribution for outlier samples.
Configuration Integration
src/astrameter/config/config_loader.py
Updated config loader to read Hampel parameters from [GENERAL] and per-powermeter sections, then conditionally wrap powermeters with HampelPowermeter when HAMPEL_WINDOW > 0.
Module Exports
src/astrameter/powermeter/__init__.py, src/astrameter/powermeter/wrappers/__init__.py
Added HampelPowermeter to public exports in the powermeter package hierarchy.
Test Suite
src/astrameter/powermeter/wrappers/hampel_test.py
Comprehensive async test module covering warmup behavior, spike rejection, window rolling, MAD edge cases, phase ratio preservation, zero-total handling, validation, lifecycle delegation, and state reset scenarios.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.24% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change—adding a Hampel outlier filter for noisy powermeter sources—which is the primary feature introduced across all modified files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/evaluate-kalman-filter-7RNnp

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@tomquist tomquist marked this pull request as ready for review April 19, 2026 22:24
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
src/astrameter/powermeter/wrappers/hampel_test.py (1)

258-268: Optional: assert wait_for_message was actually delegated.

hp.wait_for_message(timeout=1) is invoked but the call isn't verified because FakePowermeter.wait_for_message is a no-op with no side-effect. As-is this only proves the method doesn't raise. Adding a counter to the fake would make the delegation assertion complete.

♻️ Suggested enhancement
     def __init__(self, values: list[float] | None = None):
         self._values: list[float] = values if values is not None else [0.0]
         self.started = False
         self.stopped = False
         self.reset_count = 0
+        self.wait_for_message_count = 0

     async def wait_for_message(self, timeout=5):
-        pass
+        self.wait_for_message_count += 1

and in the test:

         await hp.wait_for_message(timeout=1)
+        assert fake.wait_for_message_count == 1
         hp.reset()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/astrameter/powermeter/wrappers/hampel_test.py` around lines 258 - 268,
The test currently calls hp.wait_for_message(timeout=1) but doesn't verify
delegation because FakePowermeter.wait_for_message is a no-op; modify
FakePowermeter to track calls (e.g., add an attribute like
wait_for_message_count and increment it inside FakePowermeter.wait_for_message)
and then in test_lifecycle_delegation assert that fake.wait_for_message_count
increased (or equals 1) after calling hp.wait_for_message(timeout=1) to prove
HampelPowermeter.wait_for_message delegates to the underlying fake.
src/astrameter/powermeter/wrappers/hampel.py (1)

85-88: Minor: ratio-based redistribution can flip per-phase signs when median and raw_total have opposite sign.

When an outlier raw_total has opposite sign to median (e.g. brief export spike while steady state is import), ratio = median / raw_total is negative and every per-phase value is sign-flipped. The sum still equals median (correct by construction), but the per-phase direction no longer matches the raw sample's direction.

This is acceptable given the documented "operates on the sum of phases" contract and the fact that an outlier sample's per-phase distribution isn't necessarily trustworthy anyway. Worth noting in the docstring for future readers, but not a blocker.

📝 Optional docstring clarification
     redistributed proportionally (equal split when ``|raw_total|`` is near
-    zero). The window entry itself is mutated to the median so a single spike
+    zero; note that when the outlier ``raw_total`` has opposite sign to the
+    median, the per-phase values are sign-flipped so their sum equals the
+    median). The window entry itself is mutated to the median so a single spike
     does not poison future detections — this is the canonical Hampel
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/astrameter/powermeter/wrappers/hampel.py` around lines 85 - 88, The
ratio-based redistribution code using variables raw_total, raw_values, median
and ratio can flip per-phase signs when median and raw_total have opposite sign;
update the function's docstring (the function that contains this block) to
explicitly note that redistribution preserves the sum but may invert per-phase
signs in such opposite-sign cases and that this behavior is intentional given
the "operates on the sum of phases" contract; keep the implementation unchanged
but add the explanatory note so future readers understand the sign-flip
tradeoff.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/astrameter/powermeter/wrappers/hampel_test.py`:
- Around line 258-268: The test currently calls hp.wait_for_message(timeout=1)
but doesn't verify delegation because FakePowermeter.wait_for_message is a
no-op; modify FakePowermeter to track calls (e.g., add an attribute like
wait_for_message_count and increment it inside FakePowermeter.wait_for_message)
and then in test_lifecycle_delegation assert that fake.wait_for_message_count
increased (or equals 1) after calling hp.wait_for_message(timeout=1) to prove
HampelPowermeter.wait_for_message delegates to the underlying fake.

In `@src/astrameter/powermeter/wrappers/hampel.py`:
- Around line 85-88: The ratio-based redistribution code using variables
raw_total, raw_values, median and ratio can flip per-phase signs when median and
raw_total have opposite sign; update the function's docstring (the function that
contains this block) to explicitly note that redistribution preserves the sum
but may invert per-phase signs in such opposite-sign cases and that this
behavior is intentional given the "operates on the sum of phases" contract; keep
the implementation unchanged but add the explanatory note so future readers
understand the sign-flip tradeoff.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e7610b16-5c68-49e6-8601-a8cfc6cb71c1

📥 Commits

Reviewing files that changed from the base of the PR and between ea5b628 and b2c1186.

📒 Files selected for processing (8)
  • CHANGELOG.md
  • README.md
  • config.ini.example
  • src/astrameter/config/config_loader.py
  • src/astrameter/powermeter/__init__.py
  • src/astrameter/powermeter/wrappers/__init__.py
  • src/astrameter/powermeter/wrappers/hampel.py
  • src/astrameter/powermeter/wrappers/hampel_test.py

@tomquist tomquist merged commit e0c7e0e into develop Apr 19, 2026
13 checks passed
@tomquist tomquist deleted the claude/evaluate-kalman-filter-7RNnp branch April 19, 2026 22:31
tomquist pushed a commit that referenced this pull request Apr 19, 2026
Regrouped the flat bullet list into Breaking / Added / Changed / Fixed
subsections so readers can scan by change type. Each bullet is now a
standalone diff to main (no cross-references) and cites every PR that
contributed.

Added Breaking entries that were missing:
- CT002/CT003 ACTIVE_CONTROL default (smoothing + 15 W BALANCE_DEADBAND
  + saturation detection on by default)
- WAIT_FOR_NEXT_MESSAGE default True (affects Shelly emulation too, not
  just CT002/CT003)
- Async Powermeter base (out-of-tree subclasses must implement
  async get_powermeter_watts())

Added missing feature bullets: per-powermeter EMA smoothing/deadband
wrappers (#331), Hampel outlier filter (#334), MQTT BROKER_URI (#309),
exc_info on warnings (#307). Filled in previously-missing PR refs on
the rebrand, CT002/CT003, MQTT Insights, web config editor, PID
controller, and GIT_COMMIT_SHA bullets.

https://claude.ai/code/session_01BCVmemteVXNfoTQE4De2CU
tomquist added a commit that referenced this pull request Apr 19, 2026
* Restructure CHANGELOG Next section as Keep-a-Changelog diff to main

Regrouped the flat bullet list into Breaking / Added / Changed / Fixed
subsections so readers can scan by change type. Each bullet is now a
standalone diff to main (no cross-references) and cites every PR that
contributed.

Added Breaking entries that were missing:
- CT002/CT003 ACTIVE_CONTROL default (smoothing + 15 W BALANCE_DEADBAND
  + saturation detection on by default)
- WAIT_FOR_NEXT_MESSAGE default True (affects Shelly emulation too, not
  just CT002/CT003)
- Async Powermeter base (out-of-tree subclasses must implement
  async get_powermeter_watts())

Added missing feature bullets: per-powermeter EMA smoothing/deadband
wrappers (#331), Hampel outlier filter (#334), MQTT BROKER_URI (#309),
exc_info on warnings (#307). Filled in previously-missing PR refs on
the rebrand, CT002/CT003, MQTT Insights, web config editor, PID
controller, and GIT_COMMIT_SHA bullets.

https://claude.ai/code/session_01BCVmemteVXNfoTQE4De2CU

* Fold same-cycle fixes into their parent Added bullets

The Fixed / Changed bullets for CT002/CT003 saturation, efficiency-
rotation lockup, and the MQTT_INSIGHTS empty-config crash / HA mosquitto
availability check referenced features introduced in this same release
cycle, so they weren't a standalone diff against main. Merged those PR
refs into the CT002/CT003 and MQTT Insights Added bullets (the end
state, which is what a main-viewer cares about). Modbus UNIT_ID fix
stays in Fixed — Modbus existed on main.

https://claude.ai/code/session_01BCVmemteVXNfoTQE4De2CU

* Fold CT002/CT003 active-control defaults into Added bullet

The CT002/CT003 ACTIVE_CONTROL default is not a 'changed default' vs
main — CT002/CT003 don't exist on main, so the default is just part of
the new feature description. Moved the default-on behavior and
BALANCE_DEADBAND details into the CT002/CT003 Added bullet.

Also narrowed the WAIT_FOR_NEXT_MESSAGE Breaking bullet to just the
Shelly emulator (the real diff against main); the CT002/CT003 aspect
is implicit in the CT002/CT003 Added bullet.

Fixed a minor verb mismatch in Changed: 'Added battery activity
info logs' → 'Expanded Shelly emulation logs'.

https://claude.ai/code/session_01BCVmemteVXNfoTQE4De2CU

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants