Skip to content

Commit a18e925

Browse files
Hutch67hutchclaude
authored
Add PID controller support for any powermeter (#315)
* Add PID controller support for any powermeter 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> * Fix PID_MODE key names in README How it works bullets Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix anti-windup gate to include d_term in saturation check 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> * Add missing docstrings to reach 80% coverage threshold 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> --------- Co-authored-by: hutch <hutch@devvm2.devms.speedport.ip> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 66a781a commit a18e925

7 files changed

Lines changed: 541 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- Added SMA Energy Meter / Sunny Home Manager support via Speedwire multicast with auto-detection and per-phase readings ([#252](https://github.com/tomquist/astrameter/pull/252))
99
- Added SML powermeter support for smart meters over a local serial port (IR head), with optional per-phase OBIS overrides ([#229](https://github.com/tomquist/astrameter/pull/229))
1010
- Added multi-phase support for Tasmota (`JSON_POWER_MQTT_LABEL`) and MQTT (`TOPICS` / `JSON_PATHS`) powermeters ([#136](https://github.com/tomquist/astrameter/issues/136), [#280](https://github.com/tomquist/astrameter/pull/280))
11+
- Added PID controller support for any powermeter via `PID_KP`, `PID_KI`, `PID_KD`, `PID_OUTPUT_MAX`, and `PID_MODE` config options (global or per-section), with built-in anti-windup
1112
- Added `POWER_OFFSET` and `POWER_MULTIPLIER` transforms for any powermeter, including per-phase calibration, sign flipping, and phase nulling ([#250](https://github.com/tomquist/astrameter/pull/250)); the Home Assistant app exposes both as optional advanced fields
1213
- Added optional Marstek cloud auto-registration for managed fake CT devices at startup ([#237](https://github.com/tomquist/astrameter/pull/237))
1314
- Switched the Home Assistant powermeter integration from REST polling to the WebSocket API ([#232](https://github.com/tomquist/astrameter/pull/232))

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,39 @@ POWER_MULTIPLIER = 1,0,1
356356

357357
**Note:** Transforms are applied when readings are taken from the powermeter, before values are passed to the emulated device (Shelly, CT002/CT003, etc.).
358358

359+
### PID Controller
360+
361+
You can optionally layer a PID (Proportional-Integral-Derivative) controller on top of any powermeter. The controller uses the grid power reading as its process variable and steers the reported value toward zero (net-zero grid exchange). This creates a second, software-level closed loop that can accelerate convergence or compensate for slow storage device response.
362+
363+
**How it works:**
364+
365+
- `PID_MODE = bias` (default) — adds the PID output to the raw meter reading. The storage device's own closed-loop controller still acts, so the effective gain is `(1 − Kp) × Kb` where `Kb` is the device's internal gain. Use `0 < Kp < 1`; `Kp = 0.5` is the recommended starting point.
366+
- `PID_MODE = replace` — uses only the PID output as the reported value, bypassing the device's own loop entirely.
367+
368+
**Anti-windup** is built in: the integral term is clamped so that the total PID output never exceeds `±PID_OUTPUT_MAX`, and accumulation pauses while the output is saturated.
369+
370+
All parameters can be set globally in `[GENERAL]` or per powermeter section (per-section values override the global ones):
371+
372+
| Parameter | Description | Default |
373+
|-----------|-------------|---------|
374+
| `PID_KP` | Proportional gain. Set > 0 to enable the PID. | `0` (disabled) |
375+
| `PID_KI` | Integral gain. Usually not needed; risks windup. | `0` |
376+
| `PID_KD` | Derivative gain. Noisy on real meters; leave at 0. | `0` |
377+
| `PID_OUTPUT_MAX` | Maximum absolute PID output in watts. | `800` |
378+
| `PID_MODE` | `bias` or `replace`. | `bias` |
379+
380+
For a small import safety buffer that prevents accidental export, combine with a negative `POWER_OFFSET` (applied before the PID):
381+
382+
```ini
383+
[SHELLY]
384+
TYPE = 1PM
385+
IP = 192.168.1.100
386+
POWER_OFFSET = -20 # 20 W safety buffer toward import
387+
PID_KP = 0.5
388+
PID_OUTPUT_MAX = 800
389+
PID_MODE = bias
390+
```
391+
359392
### Shelly
360393

361394
#### Shelly 1PM

config.ini.example

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,34 @@ THROTTLE_INTERVAL = 0
310310
#OBIS_POWER_L2 = 0100380700ff
311311
#OBIS_POWER_L3 = 01004c0700ff
312312

313+
## --- PID controller (grid-balance feedback loop) ---
314+
## When PID_KP > 0, a Proportional-Integral-Derivative controller is layered
315+
## on top of the configured powermeter. It uses the measured grid power as its
316+
## process variable and steers the reported value toward zero (net-zero grid).
317+
##
318+
## Recommended starting point (P-only, bias mode):
319+
## PID_KP = 0.5 # gain; 0.5 is a safe start for one storage device
320+
## PID_KI = 0 # integral — usually not needed; risks windup
321+
## PID_KD = 0 # derivative — noisy on real meters; leave at 0
322+
## PID_OUTPUT_MAX = 800 # cap the PID output at ± 800 W
323+
## PID_MODE = bias # add PID output to raw reading (alternative: replace)
324+
##
325+
## In bias mode the PID and the storage device's own closed-loop controller
326+
## act together; the system is stable for 0 < Kp < 1.
327+
## In replace mode the PID output replaces the raw reading entirely.
328+
##
329+
## To keep a small import safety buffer (prevent accidental export), combine
330+
## with a negative POWER_OFFSET applied before the PID:
331+
## POWER_OFFSET = -20 # bias 20 W toward import
332+
##
333+
## Parameters can be set globally in [GENERAL] or per powermeter section.
334+
## Per-section values override the global ones.
335+
#PID_KP = 0.5
336+
#PID_KI = 0
337+
#PID_KD = 0
338+
#PID_OUTPUT_MAX = 800
339+
#PID_MODE = bias
340+
313341
## --- MQTT Insights (optional) ---
314342
## Publishes internal state (grid power, targets, saturation, consumer topology)
315343
## to MQTT with Home Assistant Device Discovery support.

src/astrameter/config/config_loader.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
JsonHttpPowermeter,
2121
ModbusPowermeter,
2222
MqttPowermeter,
23+
PidPowermeter,
2324
Powermeter,
2425
Script,
2526
Shelly1PM,
@@ -149,6 +150,11 @@ def read_all_powermeter_configs(
149150
global_throttle_interval = config.getfloat(
150151
"GENERAL", "THROTTLE_INTERVAL", fallback=0.0
151152
)
153+
global_pid_kp = config.getfloat("GENERAL", "PID_KP", fallback=0.0)
154+
global_pid_ki = config.getfloat("GENERAL", "PID_KI", fallback=0.0)
155+
global_pid_kd = config.getfloat("GENERAL", "PID_KD", fallback=0.0)
156+
global_pid_output_max = config.getfloat("GENERAL", "PID_OUTPUT_MAX", fallback=800.0)
157+
global_pid_mode = config.get("GENERAL", "PID_MODE", fallback="bias").strip().lower()
152158

153159
for section in config.sections():
154160
powermeter = create_powermeter(section, config)
@@ -190,6 +196,46 @@ def read_all_powermeter_configs(
190196
)
191197
powermeter = ThrottledPowermeter(powermeter, section_throttle_interval)
192198

199+
section_pid_kp = config.getfloat(section, "PID_KP", fallback=global_pid_kp)
200+
if section_pid_kp > 0:
201+
pid_source = (
202+
"section-specific"
203+
if config.has_option(section, "PID_KP")
204+
else "global"
205+
)
206+
section_pid_ki = config.getfloat(
207+
section, "PID_KI", fallback=global_pid_ki
208+
)
209+
section_pid_kd = config.getfloat(
210+
section, "PID_KD", fallback=global_pid_kd
211+
)
212+
section_pid_output_max = config.getfloat(
213+
section, "PID_OUTPUT_MAX", fallback=global_pid_output_max
214+
)
215+
section_pid_mode = (
216+
config.get(section, "PID_MODE", fallback=global_pid_mode)
217+
.strip()
218+
.lower()
219+
)
220+
logger.info(
221+
"Applying %s PID controller (Kp=%s, Ki=%s, Kd=%s, max=%sW, mode=%s) to %s",
222+
pid_source,
223+
section_pid_kp,
224+
section_pid_ki,
225+
section_pid_kd,
226+
section_pid_output_max,
227+
section_pid_mode,
228+
section,
229+
)
230+
powermeter = PidPowermeter(
231+
powermeter,
232+
kp=section_pid_kp,
233+
ki=section_pid_ki,
234+
kd=section_pid_kd,
235+
output_max=section_pid_output_max,
236+
mode=section_pid_mode,
237+
)
238+
193239
client_filter = create_client_filter(section, config)
194240
powermeters.append((powermeter, client_filter))
195241
return powermeters

src/astrameter/powermeter/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .json_http import JsonHttpPowermeter
99
from .modbus import ModbusPowermeter
1010
from .mqtt import MqttPowermeter
11+
from .pid import PidPowermeter
1112
from .script import Script
1213
from .shelly import Shelly, Shelly1PM, Shelly3EM, Shelly3EMPro, ShellyEM, ShellyPlus1PM
1314
from .shrdzm import Shrdzm
@@ -29,6 +30,7 @@
2930
"JsonHttpPowermeter",
3031
"ModbusPowermeter",
3132
"MqttPowermeter",
33+
"PidPowermeter",
3234
"Powermeter",
3335
"Script",
3436
"Shelly",

src/astrameter/powermeter/pid.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import asyncio
2+
import time
3+
4+
from .base import Powermeter
5+
6+
7+
class PidPowermeter(Powermeter):
8+
"""
9+
A wrapper around a powermeter that applies a PID (Proportional-Integral-
10+
Derivative) controller to steer the reported power toward zero (grid balance).
11+
12+
The PID controller uses the raw power-meter reading as its *process
13+
variable* and computes an adjustment that is either **added** to the raw
14+
reading (``mode="bias"``) or **used in place of** the raw reading
15+
(``mode="replace"``).
16+
17+
Positive PID output motivates the storage device to increase feed-in power;
18+
negative output motivates it to decrease feed-in power.
19+
20+
**Gain sensitivity:** in ``mode="bias"`` the PID and the storage device's own
21+
closed-loop controller act *together*. The effective closed-loop gain is
22+
``(1 - Kp) * Kb``, where ``Kb`` is the device's internal gain.
23+
The system is stable for ``0 < Kp < 1``. Use ``Kp = 0.5`` as the
24+
recommended starting value.
25+
26+
**Anti-windup** is built in: the integral term is clamped so that the
27+
total PID output never exceeds ``[-output_max, +output_max]``, and
28+
integration is paused while the output is saturated.
29+
30+
Error convention:
31+
error = -measurement
32+
A positive grid import produces a negative error, causing the PID to
33+
reduce the reported value and motivate the storage device to cover the import.
34+
35+
To maintain a small import safety buffer (prevent export), set a small
36+
negative ``POWER_OFFSET`` (e.g. ``POWER_OFFSET = -20``) in the filter
37+
chain *before* the PID.
38+
39+
The controller runs on the **sum** of all phases (total grid power)
40+
and distributes its output equally across phases.
41+
42+
Config parameters:
43+
PID_KP Proportional gain (default 0 → PID disabled)
44+
PID_KI Integral gain (default 0)
45+
PID_KD Derivative gain (default 0)
46+
PID_OUTPUT_MAX Output clamp magnitude in watts (default 800)
47+
PID_MODE "bias" or "replace" (default "bias")
48+
"""
49+
50+
VALID_MODES = ("bias", "replace")
51+
52+
def __init__(
53+
self,
54+
wrapped_powermeter: Powermeter,
55+
kp: float = 0.0,
56+
ki: float = 0.0,
57+
kd: float = 0.0,
58+
output_max: float = 800.0,
59+
mode: str = "bias",
60+
):
61+
"""
62+
Initialise the PID powermeter wrapper.
63+
64+
Args:
65+
wrapped_powermeter: The actual powermeter instance to wrap.
66+
kp: Proportional gain.
67+
ki: Integral gain.
68+
kd: Derivative gain.
69+
output_max: Maximum absolute PID output in watts. Must be > 0.
70+
mode: ``"bias"`` — add PID output to raw reading, or
71+
``"replace"`` — use PID output as the reported value.
72+
"""
73+
if output_max <= 0:
74+
raise ValueError(f"PID output_max must be positive, got {output_max}")
75+
mode = mode.lower()
76+
if mode not in self.VALID_MODES:
77+
raise ValueError(
78+
f"PID mode must be one of {self.VALID_MODES}, got '{mode}'"
79+
)
80+
81+
self.wrapped_powermeter = wrapped_powermeter
82+
self.kp = kp
83+
self.ki = ki
84+
self.kd = kd
85+
self.output_max = output_max
86+
self.mode = mode
87+
88+
# PID state
89+
self._integral: float = 0.0
90+
self._prev_error: float | None = None
91+
self._prev_time: float | None = None
92+
self._lock = asyncio.Lock()
93+
94+
async def wait_for_message(self, timeout=5):
95+
"""Pass through to wrapped powermeter."""
96+
return await self.wrapped_powermeter.wait_for_message(timeout)
97+
98+
async def start(self):
99+
"""Pass through to wrapped powermeter."""
100+
await self.wrapped_powermeter.start()
101+
102+
async def stop(self):
103+
"""Pass through to wrapped powermeter."""
104+
await self.wrapped_powermeter.stop()
105+
106+
async def get_powermeter_watts(self) -> list[float]:
107+
"""Return PID-adjusted power readings for each phase."""
108+
async with self._lock:
109+
raw_values = await self.wrapped_powermeter.get_powermeter_watts()
110+
current_time = time.monotonic()
111+
112+
# Compute error on the total power across all phases
113+
total_power = sum(raw_values)
114+
error = -total_power
115+
if self._prev_time is None:
116+
# First call — initialise state, no derivative yet
117+
self._prev_error = error
118+
self._prev_time = current_time
119+
dt = 0.0
120+
else:
121+
dt = current_time - self._prev_time
122+
if dt <= 0:
123+
dt = 0.0
124+
125+
# --- Proportional ---
126+
p_term = self.kp * error
127+
128+
# --- Derivative ---
129+
if dt > 0 and self._prev_error is not None:
130+
d_term = self.kd * (error - self._prev_error) / dt
131+
else:
132+
d_term = 0.0
133+
134+
# --- Integral with anti-windup ---
135+
if dt > 0:
136+
# Tentatively accumulate
137+
tentative_integral = self._integral + error * dt
138+
tentative_output = p_term + self.ki * tentative_integral + d_term
139+
# Only accept the new integral if output is not saturated,
140+
# or if the integral is moving toward zero (unwinding).
141+
if abs(tentative_output) <= self.output_max or (
142+
self._integral != 0 and self._integral * error < 0
143+
):
144+
self._integral = tentative_integral
145+
i_term = self.ki * self._integral
146+
147+
self._prev_error = error
148+
self._prev_time = current_time
149+
150+
# --- Total output with clamping ---
151+
pid_output = p_term + i_term + d_term
152+
pid_output = max(-self.output_max, min(self.output_max, pid_output))
153+
154+
# --- Apply to readings ---
155+
num_phases = len(raw_values)
156+
per_phase = pid_output / num_phases if num_phases > 0 else 0.0
157+
158+
if self.mode == "bias":
159+
return [value + per_phase for value in raw_values]
160+
else:
161+
# replace mode: distribute PID output equally across phases
162+
return [per_phase] * num_phases

0 commit comments

Comments
 (0)