Add Hampel outlier filter for noisy powermeter sources#334
Conversation
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
WalkthroughThis 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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes 🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (2)
src/astrameter/powermeter/wrappers/hampel_test.py (1)
258-268: Optional: assertwait_for_messagewas actually delegated.
hp.wait_for_message(timeout=1)is invoked but the call isn't verified becauseFakePowermeter.wait_for_messageis 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 += 1and 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 whenmedianandraw_totalhave opposite sign.When an outlier
raw_totalhas opposite sign tomedian(e.g. brief export spike while steady state is import),ratio = median / raw_totalis negative and every per-phase value is sign-flipped. The sum still equalsmedian(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
📒 Files selected for processing (8)
CHANGELOG.mdREADME.mdconfig.ini.examplesrc/astrameter/config/config_loader.pysrc/astrameter/powermeter/__init__.pysrc/astrameter/powermeter/wrappers/__init__.pysrc/astrameter/powermeter/wrappers/hampel.pysrc/astrameter/powermeter/wrappers/hampel_test.py
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
* 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>
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
HampelPowermeterwrapper (src/astrameter/powermeter/wrappers/hampel.py):n_sigma * 1.4826 * MADfrom the window medianmin_thresholdfloor to handle constant-signal (MAD=0) degenerate caseComprehensive test suite (
src/astrameter/powermeter/wrappers/hampel_test.py):Configuration integration:
[GENERAL]:HAMPEL_WINDOW,HAMPEL_N_SIGMA,HAMPEL_MIN_THRESHOLDHAMPEL_WINDOW=0)Documentation:
config.ini.examplewith recommended settings and parameter descriptionsREADME.mdwith per-powermeter option documentationCHANGELOG.mdModule exports: Added
HampelPowermeterto__init__.pyfiles for public APIImplementation Details
collections.dequewithmaxlenfor efficient rolling window managementstatistics.median()for robust central tendency estimationmax(n_sigma * 1.4826 * MAD, min_threshold)ensures both statistical and absolute boundshttps://claude.ai/code/session_01Kesh7c5j8kcpEBJPLyEHik
Summary by CodeRabbit
Release Notes
New Features
Documentation