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:
co-authored by
Claude Opus 5
parent
1b85eb41ad
commit
842d8c77a3
@@ -1,5 +1,16 @@
|
||||
# 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
|
||||
|
||||
Precedence between strategies is now a first-class object instead of an if/else
|
||||
|
||||
@@ -56,6 +56,7 @@ phase having charged nothing.
|
||||
| `gain` | 0.6 | Correction per cycle. **At the limit — do not raise** |
|
||||
| `slew_w` | 1000 | Maximum change per cycle |
|
||||
| `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 |
|
||||
| `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** |
|
||||
@@ -63,6 +64,30 @@ phase having charged nothing.
|
||||
| `stale_input_s` | 15 | How long inputs may be missing before commanding 0 W |
|
||||
| `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
|
||||
|
||||
| option | default | meaning |
|
||||
|
||||
@@ -28,6 +28,14 @@ class Tuning:
|
||||
step_w: int = 10
|
||||
saturation_w: float = 500.0
|
||||
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)
|
||||
@@ -74,11 +82,12 @@ def compute(
|
||||
# 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
|
||||
# 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
|
||||
reason = "deadband"
|
||||
else:
|
||||
want = prev_w + tuning.gain * grid_w
|
||||
want = prev_w + tuning.gain * error
|
||||
|
||||
# ⚠️ Maintenance shaping (charge-only, cheap-window floor) used to live
|
||||
# here. It now belongs to arbiter.py as limit claims, so that precedence
|
||||
|
||||
@@ -66,6 +66,7 @@ class Controller:
|
||||
max_w=float(opts.get("max_w", 2000)),
|
||||
slew_w=float(opts.get("slew_w", 1000)),
|
||||
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)),
|
||||
saturation_w=float(opts.get("saturation_w", 500)),
|
||||
saturation_cycles=int(opts.get("saturation_cycles", 3)),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.2.0"
|
||||
version: "0.2.1"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||
@@ -44,6 +44,7 @@ options:
|
||||
gain: 0.6
|
||||
slew_w: 1000
|
||||
deadband_w: 15
|
||||
target_grid_w: -10
|
||||
step_w: 10
|
||||
saturation_w: 500
|
||||
saturation_cycles: 3
|
||||
@@ -83,6 +84,7 @@ schema:
|
||||
gain: float(0.05,1.0)
|
||||
slew_w: int(50,5000)
|
||||
deadband_w: int(0,500)
|
||||
target_grid_w: float(-200,200)
|
||||
step_w: int(1,100)
|
||||
saturation_w: int(100,2000)
|
||||
saturation_cycles: int(1,10)
|
||||
|
||||
@@ -97,6 +97,26 @@ for i in range(12):
|
||||
check(f"converges within deadband in {cycles} cycles (<=6)", cycles <= 6)
|
||||
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()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
|
||||
Reference in New Issue
Block a user