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
|
# 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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)}")
|
||||||
|
|||||||
Reference in New Issue
Block a user