Skip to content

Add PID controller support for any powermeter#315

Merged
tomquist merged 4 commits into
tomquist:developfrom
Hutch67:feature/pid-controller
Apr 12, 2026
Merged

Add PID controller support for any powermeter#315
tomquist merged 4 commits into
tomquist:developfrom
Hutch67:feature/pid-controller

Conversation

@Hutch67
Copy link
Copy Markdown
Contributor

@Hutch67 Hutch67 commented Apr 8, 2026

Summary

  • Adds PidPowermeter, an async wrapper that layers a PID (Proportional-Integral-Derivative) controller on top of any powermeter to steer the reported grid power toward zero (net-zero grid exchange)
  • Supports bias mode (adds PID output to raw reading, works alongside the storage device's own loop) and replace mode (PID output replaces raw reading entirely)
  • Built-in anti-windup: integral is clamped to PID_OUTPUT_MAX and pauses accumulation while output is saturated
  • All five parameters (PID_KP, PID_KI, PID_KD, PID_OUTPUT_MAX, PID_MODE) can be set globally in [GENERAL] or overridden per powermeter section
  • Ported from b2500-meter and adapted for AstraMeter's async architecture (asyncio.Lock instead of threading.Lock, all methods async)

Test plan

  • 17 new unit tests in pid_test.py cover: P/I/D terms individually, output clamping, anti-windup, multi-phase bias and replace modes, zero-gains passthrough, first-call no-derivative-spike, wait_for_message pass-through, and list immutability
  • All 447 tests pass (uv run pytest)
  • ruff format, ruff check, and mypy all clean

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Optional PID controller for powermeters; configurable via PID_KP, PID_KI, PID_KD, PID_OUTPUT_MAX, and PID_MODE (bias or replace). Settings may be global or overridden per powermeter; integral anti-windup and per-phase output distribution included.
  • Documentation
    • Added README section, changelog entry, and commented config example illustrating configuration and usage notes.
  • Tests
    • Added unit tests covering construction, validation, PID terms, anti-windup, modes, and multi-phase behavior.

Ports the PID feature from b2500-meter, adapted for AstraMeter's async
architecture. Adds PidPowermeter wrapper with built-in anti-windup, bias
and replace modes, and global/per-section config via PID_KP, PID_KI,
PID_KD, PID_OUTPUT_MAX, and PID_MODE.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Hutch67 Hutch67 changed the base branch from main to develop April 8, 2026 14:22
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 8, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 889a950a-1672-4da7-a5d5-66cd6691375e

📥 Commits

Reviewing files that changed from the base of the PR and between 6ac689b and 2d38a37.

📒 Files selected for processing (2)
  • src/astrameter/powermeter/pid.py
  • src/astrameter/powermeter/pid_test.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/astrameter/powermeter/pid_test.py
  • src/astrameter/powermeter/pid.py

Walkthrough

Adds a configurable PID controller wrapper for powermeters (configurable globally or per-section), implements anti-windup and output clamping, exposes PidPowermeter in the powermeter package, updates the config loader to apply the wrapper, and includes tests and documentation.

Changes

Cohort / File(s) Summary
Documentation
CHANGELOG.md, README.md, config.ini.example
Added PID controller docs and example config keys: PID_KP, PID_KI, PID_KD, PID_OUTPUT_MAX, PID_MODE; described bias/replace modes and anti-windup behavior.
Public API
src/astrameter/powermeter/__init__.py
Exported PidPowermeter from the powermeter package.
Config Loading
src/astrameter/config/config_loader.py
Extended powermeter config loader to read global and per-section PID settings and wrap powermeters with PidPowermeter when enabled (PID_KP > 0), normalizing PID_MODE.
PID Implementation
src/astrameter/powermeter/pid.py
Added PidPowermeter wrapper implementing P/I/D with anti-windup (integral clamping and pause while saturated), derivative handling with dt, output clamping, per-phase distribution, bias/replace modes, and input validation.
Tests
src/astrameter/powermeter/pid_test.py
Added async pytest suite covering constructor validation, P/I/D behavior, anti-windup, clamping, multi-phase distribution, modes, delegation passthrough, and no-gains passthrough behavior.

Sequence Diagram

sequenceDiagram
    participant Loader as Configuration Loader
    participant Config as Config File
    participant Factory as PidPowermeter
    participant Wrapped as Wrapped Powermeter
    participant Device as Physical Device

    Loader->>Config: Read [GENERAL] PID settings
    Config-->>Loader: Return global PID params
    Loader->>Config: Read per-section PID overrides
    Config-->>Loader: Return section-specific values
    Loader->>Wrapped: Create base Powermeter instance
    Wrapped-->>Loader: Powermeter ready

    alt PID_KP > 0
        Loader->>Factory: Wrap with PidPowermeter(kp,ki,kd,output_max,mode)
        Factory-->>Loader: PidPowermeter wrapper ready
    end

    Note over Factory,Device: Runtime: get_powermeter_watts()
    Factory->>Wrapped: get_powermeter_watts()
    Wrapped->>Device: Query phase readings
    Device-->>Wrapped: Return [P1, P2, P3]
    Wrapped-->>Factory: Return raw readings

    Factory->>Factory: Compute error = -(P1+P2+P3)
    Factory->>Factory: Calculate P, I (with anti-windup), D
    Factory->>Factory: Clamp output to ±output_max
    Factory->>Factory: Distribute PID across phases

    alt mode == "bias"
        Factory->>Factory: adjusted = [P1+pid, P2+pid, P3+pid]
    else mode == "replace"
        Factory->>Factory: adjusted = [pid, pid, pid]
    end

    Factory-->>Loader: Return adjusted readings
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 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 PID controller support for any powermeter, which is the primary feature introduced across all modified files.
Docstring Coverage ✅ Passed Docstring coverage is 95.83% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

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.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/astrameter/powermeter/pid_test.py (1)

118-134: Consider adding a regression test for D-term-driven saturation.

Current anti-windup coverage is good for integral saturation, but a case where derivative spikes saturate output would protect against integral accumulation while saturated.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/astrameter/powermeter/pid_test.py` around lines 118 - 134, Add a new
regression test (e.g., test_derivative_driven_saturation) that uses the same
mocking pattern as test_anti_windup_stops_integration but forces a large
derivative kick: create a PidPowermeter instance with nonzero ki, a high kp and
a tight output_max, call PidPowermeter.get_powermeter_watts once to initialize,
then advance time and change mock_powermeter.get_powermeter_watts to produce a
sharp step so the D-term saturates the output; assert that after the saturation
the integrator does not grow (call get_powermeter_watts again later and verify
result is not biased by accumulated integral), referencing PidPowermeter,
get_powermeter_watts, kp, ki, and output_max to find and implement the test.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@README.md`:
- Around line 365-366: Replace the generic "mode = ..." wording with the actual
configuration key name `PID_MODE` in both bullets so users can copy/paste
correctly; update the two lines to read `PID_MODE = bias` (with the same
explanatory text and recommended Kp guidance) and `PID_MODE = replace` (with the
same explanation about bypassing the device loop) and ensure the backticks use
`PID_MODE` exactly.

In `@src/astrameter/powermeter/pid.py`:
- Around line 125-135: The anti-windup gate currently computes tentative_output
from p_term + self.ki * tentative_integral but clamping later uses P+I+D;
include the derivative term when deciding whether to accept the tentative
integral: compute the d_term (same expression used in output clamping, e.g.,
self.kd * derivative or variable name used in compute) and use tentative_output
= p_term + self.ki * tentative_integral + d_term in the saturation/unwinding
check (and ensure the same d_term variable is used in the final output clamping
logic), so the integral is paused when the full P+I+D output would be saturated.
Ensure you reference and reuse the existing derivative variable name (e.g.,
d_term or self._derivative) and output_max/output_min variables when updating
self._integral.

---

Nitpick comments:
In `@src/astrameter/powermeter/pid_test.py`:
- Around line 118-134: Add a new regression test (e.g.,
test_derivative_driven_saturation) that uses the same mocking pattern as
test_anti_windup_stops_integration but forces a large derivative kick: create a
PidPowermeter instance with nonzero ki, a high kp and a tight output_max, call
PidPowermeter.get_powermeter_watts once to initialize, then advance time and
change mock_powermeter.get_powermeter_watts to produce a sharp step so the
D-term saturates the output; assert that after the saturation the integrator
does not grow (call get_powermeter_watts again later and verify result is not
biased by accumulated integral), referencing PidPowermeter,
get_powermeter_watts, kp, ki, and output_max to find and implement the test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 46dd2a49-e2e0-4ad7-9662-b10374dc6e16

📥 Commits

Reviewing files that changed from the base of the PR and between 9fdaa0e and 83c8106.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • README.md
  • config.ini.example
  • src/astrameter/config/config_loader.py
  • src/astrameter/powermeter/__init__.py
  • src/astrameter/powermeter/pid.py
  • src/astrameter/powermeter/pid_test.py

Comment thread README.md Outdated
Comment thread src/astrameter/powermeter/pid.py
hutch and others added 3 commits April 8, 2026 16:30
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The tentative_output used to decide whether to accept the integral
accumulation was missing the derivative term, so the gate checked P+I
while the final output clamp used P+I+D. Moving d_term computation
before the integral block and adding it to tentative_output means the
integral is correctly paused when the full P+I+D output is saturated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
start, stop, get_powermeter_watts in PidPowermeter and the
mock_powermeter fixture were undocumented; adding them brings the
new files to full docstring coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Hutch67
Copy link
Copy Markdown
Contributor Author

Hutch67 commented Apr 8, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 8, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Owner

@tomquist tomquist left a comment

Choose a reason for hiding this comment

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

LGTM. Thanks for the contribution!

@tomquist tomquist merged commit a18e925 into tomquist:develop Apr 12, 2026
13 checks passed
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