diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index a5d2fa0..1b14451 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -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 diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 6aafd43..c6f91fb 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -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 | diff --git a/goodwe_controller/app/control.py b/goodwe_controller/app/control.py index cdb3614..0830b60 100644 --- a/goodwe_controller/app/control.py +++ b/goodwe_controller/app/control.py @@ -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 diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index baba4fd..1e2750e 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -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)), diff --git a/goodwe_controller/config.yaml b/goodwe_controller/config.yaml index 44565dd..30892ec 100644 --- a/goodwe_controller/config.yaml +++ b/goodwe_controller/config.yaml @@ -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) diff --git a/goodwe_controller/test_control.py b/goodwe_controller/test_control.py index daaa848..478629b 100644 --- a/goodwe_controller/test_control.py +++ b/goodwe_controller/test_control.py @@ -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)}")