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 5184a2cfc2
6 changed files with 71 additions and 3 deletions
+11 -2
View File
@@ -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