Rest the meter just below zero, not at zero

The deadband is a one-way ratchet: any resting point inside it holds
indefinitely. Import and export are separate registers on the meter, so a
loop resting at +14 W bills 0.34 kWh/day while behaving perfectly.

target_grid_w (default -10 W) moves that residue onto the export register.
Worst billed rest point drops from 15 W to under 5 W. Behaviour is unchanged
at target_grid_w: 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-08-24 04:20:07 +02:00
co-authored by Claude Opus 5
parent 1b85eb41ad
commit 842d8c77a3
6 changed files with 71 additions and 3 deletions
+11
View File
@@ -1,5 +1,16 @@
# Changelog # Changelog
## 0.2.1
`target_grid_w` (default -10 W): what the meter should rest at. The deadband
holds any resting point inside it indefinitely, and the meter bills import and
export on separate registers, so resting at +14 W import costs 0.34 kWh/day
with the loop behaving perfectly. Biasing the target slightly negative moves
that residue onto the export register. Configurable in the Configuration tab;
see DOCS.md for the trade-off table.
Behaviour is unchanged at `target_grid_w: 0`.
## 0.2.0 ## 0.2.0
Precedence between strategies is now a first-class object instead of an if/else Precedence between strategies is now a first-class object instead of an if/else
+25
View File
@@ -56,6 +56,7 @@ phase having charged nothing.
| `gain` | 0.6 | Correction per cycle. **At the limit — do not raise** | | `gain` | 0.6 | Correction per cycle. **At the limit — do not raise** |
| `slew_w` | 1000 | Maximum change per cycle | | `slew_w` | 1000 | Maximum change per cycle |
| `deadband_w` | 15 | Ignore errors smaller than this | | `deadband_w` | 15 | Ignore errors smaller than this |
| `target_grid_w` | -10 | What the meter should rest at. Negative = a slight export |
| `step_w` | 10 | Quantisation | | `step_w` | 10 | Quantisation |
| `saturation_w` | 500 | Divergence that counts as "the inverter is at a limit" | | `saturation_w` | 500 | Divergence that counts as "the inverter is at a limit" |
| `saturation_cycles` | 3 | How many consecutive cycles before freezing. **Do not set to 1** | | `saturation_cycles` | 3 | How many consecutive cycles before freezing. **Do not set to 1** |
@@ -63,6 +64,30 @@ phase having charged nothing.
| `stale_input_s` | 15 | How long inputs may be missing before commanding 0 W | | `stale_input_s` | 15 | How long inputs may be missing before commanding 0 W |
| `auto_start` | false | Start controlling on boot (only after commissioning) | | `auto_start` | false | Start controlling on boot (only after commissioning) |
#### Why `target_grid_w` is not zero
The deadband is a one-way ratchet: any resting point inside it holds until
something disturbs it. Import and export are **separate registers on the
meter**, so a rest point of +14 W is billed for every second it holds and no
amount of export cancels it - 14 W all day is 0.34 kWh.
Biasing the target below zero moves that residue into the export register,
which is not billed. The resting band becomes `target ± deadband`, so:
| `target_grid_w` | resting band | worst billed leak | export given away |
|---|---|---|---|
| 0 | -15 … +15 W | ~15 W (0.35 kWh/day) | none |
| **-10** | -25 … +5 W | ~5 W (0.12 kWh/day) | ~10 W |
| -15 | -30 … 0 W | none | ~15 W (0.36 kWh/day) |
Set it to `-deadband_w` if injection is worth nothing to you and you would
rather give the energy away than buy it back. Set it to `0` if you are paid
properly for export, or if you are debugging and want the loop centred.
⚠️ This is a **billing** knob, not a speed knob. If import is arriving in
bursts rather than as a trickle, the cause is tracking lag, and this will not
help - see "Why the tuning is what it is".
### Maintenance ### Maintenance
| option | default | meaning | | option | default | meaning |
+11 -2
View File
@@ -28,6 +28,14 @@ class Tuning:
step_w: int = 10 step_w: int = 10
saturation_w: float = 500.0 saturation_w: float = 500.0
saturation_cycles: int = 3 saturation_cycles: int = 3
# What the meter should rest at, in W. Negative = a slight export.
# ⚠️ The deadband is a one-way ratchet: any resting point inside it holds
# forever, and the meter's IMPORT register counts every positive one with
# no export to cancel it. Resting at 0 W therefore leaks ~deadband/2 W of
# billed import all day (15 W deadband ≈ 0.2-0.35 kWh). Biasing the rest
# point below zero moves that leak into the export register, which is not
# billed. Cost is ~|bias| W of given-away export; keep it small.
target_grid_w: float = 0.0
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -74,11 +82,12 @@ def compute(
# recognisable resting state, which is worth more than it looks - "flat for # recognisable resting state, which is worth more than it looks - "flat for
# 70 s" is how a healthy loop is recognised at a glance, and a command # 70 s" is how a healthy loop is recognised at a glance, and a command
# frozen where it should not be is how two real bugs were caught. # frozen where it should not be is how two real bugs were caught.
if abs(grid_w) < tuning.deadband_w: error = grid_w - tuning.target_grid_w
if abs(error) < tuning.deadband_w:
want = prev_w want = prev_w
reason = "deadband" reason = "deadband"
else: else:
want = prev_w + tuning.gain * grid_w want = prev_w + tuning.gain * error
# ⚠️ Maintenance shaping (charge-only, cheap-window floor) used to live # ⚠️ Maintenance shaping (charge-only, cheap-window floor) used to live
# here. It now belongs to arbiter.py as limit claims, so that precedence # here. It now belongs to arbiter.py as limit claims, so that precedence
+1
View File
@@ -66,6 +66,7 @@ class Controller:
max_w=float(opts.get("max_w", 2000)), max_w=float(opts.get("max_w", 2000)),
slew_w=float(opts.get("slew_w", 1000)), slew_w=float(opts.get("slew_w", 1000)),
deadband_w=float(opts.get("deadband_w", 15)), deadband_w=float(opts.get("deadband_w", 15)),
target_grid_w=float(opts.get("target_grid_w", 0)),
step_w=int(opts.get("step_w", 10)), step_w=int(opts.get("step_w", 10)),
saturation_w=float(opts.get("saturation_w", 500)), saturation_w=float(opts.get("saturation_w", 500)),
saturation_cycles=int(opts.get("saturation_cycles", 3)), saturation_cycles=int(opts.get("saturation_cycles", 3)),
+3 -1
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller name: GoodWe RS485 Controller
version: "0.2.0" version: "0.2.1"
slug: goodwe_controller slug: goodwe_controller
description: >- description: >-
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
@@ -44,6 +44,7 @@ options:
gain: 0.6 gain: 0.6
slew_w: 1000 slew_w: 1000
deadband_w: 15 deadband_w: 15
target_grid_w: -10
step_w: 10 step_w: 10
saturation_w: 500 saturation_w: 500
saturation_cycles: 3 saturation_cycles: 3
@@ -83,6 +84,7 @@ schema:
gain: float(0.05,1.0) gain: float(0.05,1.0)
slew_w: int(50,5000) slew_w: int(50,5000)
deadband_w: int(0,500) deadband_w: int(0,500)
target_grid_w: float(-200,200)
step_w: int(1,100) step_w: int(1,100)
saturation_w: int(100,2000) saturation_w: int(100,2000)
saturation_cycles: int(1,10) saturation_cycles: int(1,10)
+20
View File
@@ -97,6 +97,26 @@ for i in range(12):
check(f"converges within deadband in {cycles} cycles (<=6)", cycles <= 6) check(f"converges within deadband in {cycles} cycles (<=6)", cycles <= 6)
check("no overshoot past the load", actual <= load + T.deadband_w) check("no overshoot past the load", actual <= load + T.deadband_w)
print("grid bias: the deadband must not rest on the import register")
# The billed asymmetry: import and export are separate registers, so a resting
# point inside the deadband on the import side is paid for every second it
# holds. 14 W held all day is 0.34 kWh.
T0 = Tuning(target_grid_w=0.0)
TB = Tuning(target_grid_w=-10.0)
check("unbiased, +14 W import rests forever",
compute(500.0, 14.0, 500.0, T0).reason == "deadband")
d = compute(500.0, 14.0, 500.0, TB)
check("biased, the same +14 W is corrected", d.reason != "deadband" and d.target_w > 500.0)
check("biased, a small export rests", compute(500.0, -10.0, 500.0, TB).reason == "deadband")
check("biased, the band still ends before -25 W",
compute(500.0, -30.0, 500.0, TB).reason != "deadband")
# Worst-case billed leak: the band is [bias - deadband, bias + deadband], so it
# drops from 15 W to 5 W. Set target_grid_w to -deadband_w to remove it entirely,
# at the cost of giving that much away as export.
check("worst billed rest point falls from 15 W to under 5 W",
compute(500.0, 4.9, 500.0, TB).reason == "deadband"
and compute(500.0, 5.0, 500.0, TB).reason != "deadband")
print() print()
if fails: if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}") print(f"{len(fails)} FAILED: {', '.join(fails)}")