slew_w: 1000 was derived on 2026-08-22 against a clamp of 1500 W
(goodwe/NOTES.md, "Simulated 2 kW load step (gain 0.6, clamp 1500)"), on the
grounds that it caps "how far the loop can jump ahead of the inverter". At
1500 W that was true, and the reason was the rail rather than the number:
1000 W/cycle reaches the rail in two cycles, so the excursion is bounded by
1500 W whatever the loop does.
The live install then went to max_w: 5000 and the constant did not move. Nothing in the code is broken. The rail moved and the derivation did not.
derived at
shipped default
live install
max_w
1500
2000
5000
slew_w
1000
1000
1000
Measured on the owner's system 2026-09-05 11:43–11:46: a 3.4 kW export drove the
setpoint 380 → 5000 W in five 2 s ticks, the battery to −5154 W against a
5000 VA inverter, and the grid from 3.4 kW of export to 2.8 kW of import.
≥ 900 W setpoint steps per 30 min that morning: 2 → 1 → 13 → 43.
Full record: goodwe_controller/DECISIONS/SAFETY-39-slew-against-the-rail.md.
⭐ 1. Reproduced first — and one half of it is not reproducible at all
tools/slew_sim.py --repro, against admin/goodwe's plantsim.Plant, the real compute(), and the firmware's own encoder on the wire. Gain 0.6, max_w 5000, slew_w 1000, 2 s tick, one 3385 W export step:
Over 51 export steps (1.0–4.2 kW × 3 phases), 48 of 51 runs contain a ≥ 900 W
setpoint step and the loop over-absorbs ~1 kW, driving the meter to +958 W.
⚠️ The −5154 W figure is NOT reproducible in simulation, and no criterion is written on it
plantsim.Plant.limit() clamps the plant at p_max, and so does the
independent model in test_control.py. The modelled battery cannot exceed its
rating by one watt, at any tuning, on any step, ever. So the card's AC "battery power no longer exceeds max_w on a step of the size observed" is unfalsifiable in simulation — it passes identically on the configuration
that produced the excursion.
Per this board's own rule that a third review round means the AC is wrong, that
criterion was rewritten rather than met. What replaced it, and is graded in
CI: the ≥ 900 W step is gone from every member of the family, and the
over-absorption is measured and reported rather than claimed to be fixed.
Two independent plant models agree (in-repo first-order lag vs plantsim's
ramp-plus-lag):
in-repo
plantsim
≥ 900 W steps at slew_w 1000
48/51
48/51
≥ 900 W steps at the derived value
0/51
0/51
over-absorption, slew 1000 → 250
1005 → 1330 W
958 → 1081 W
over-absorption, gain 0.6 → 0.2
1109 → 393 W
987 → 435 W
2. The derivation
The loop must not traverse its full output range faster than the plant takes
to settle.
value
measured where
plant settling
18 s
goodwe/NOTES.md §5.10, 2026-08-22, a 1.78 kW kettle step off a plug that meters its own draw: ~18 s up, ~23 s down. 18 is taken because it is the shorter and therefore the looser bound — this is a refusal boundary, and rules 5/6 refuse the impossible case only.
control tick
2 s
the live install, 2026-09-05 — the setpoint stepped +1000 W every 2 s.
output range
max_w
main.py builds Tuning(max_w=self.site_max_w), the roster sum.
SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = 2 s / 18 s = 0.1111 (9 ticks)
slew_w ≤ SLEW_RAIL_FRAC × max_w
rail
derived slew_w
ticks
seconds
1500 (where the original was derived)
167 W
9
18 s
2000 (shipped default)
222 W
9
18 s
5000 (live)
556 W
9
18 s
9600 (with inverter B — datasheet, not measured)
1067 W
9
18 s
And the pair that was measured failing: slew_w 1000 at max_w 5000 crosses in 5.0 ticks = 10 s against an 18 s plant.
⚠️ The rig cannot pick 556 out of the table, and is not asked to
slew_w 1000 ← 2026-09-05 pair step 1000 W 48/51 ≥900 W over-absorbed 958 W
slew_w 800 step 800 W 0/51 ≥900 W over-absorbed 1107 W
slew_w 600 step 600 W 0/51 ≥900 W over-absorbed 1058 W
slew_w 555 ← the derived value step 560 W 0/51 ≥900 W over-absorbed 987 W
slew_w 400 ← the live interim step 400 W 0/51 ≥900 W over-absorbed 1150 W
slew_w 250 step 250 W 0/51 ≥900 W over-absorbed 1081 W
The ≥ 900 W step disappears at 800 and stays gone; below that the column is flat
and slightly non-monotone, and 800 is better than 556 on over-absorption. The
constraint chooses the number; the rig says what the constraint does and does not
buy — the same relationship the 2026-08-22 table had to its own three columns.
⚠️A single step is not evidence here. The deadband, the 10 W quantisation and
the step's phase move the answer by hundreds of watts non-monotonically. The
first cut of this derivation was one step and ranked gain 0.3 as worse than
0.6, which the family shows is backwards. Every number is a worst case over a
family, as config.yaml's own "1890 runs" / "924 runs" campaigns are.
⚠️ The tick is the weak term, and it is not wrong in the safe direction
A slower meter makes this conservative — visible immediately, one edit. A meter faster than 2 s makes it loose by that ratio and nothing would say so. run_control()'s await asyncio.sleep(1) bounds the tick at 1 s, so the worst
case is a factor of two — but a faster meter means LOOP_TICK_S must be re-derived, not re-read. That is why both numbers are written out in control.py rather than their quotient.
⭐ 3. What ships — the relationship, not the number
slew_w: 0 means FOLLOW THE RAIL.control.slew_limit_w() resolves it to SLEW_RAIL_FRAC * max_w. It is the shipped default and the only value that
cannot be left behind when the rail moves. This is the idiom integrator_max_w: 0 has carried in the same options block since it was
added — not a new concept, the same one, applied to the key that needed it.
An explicit number is still accepted and never silently reduced (the live
install runs 400). Silently reducing an operator's stated bound is case (a) in validate.py's own docstring.
validate.py rule 10 refuses an explicit slew_w above SLEW_RAIL_FRAC × site rail, graded against sum(unit_caps(opts)) — the
roster sum, i.e. Controller.site_max_w — not the flat max_w and not the
weakest unit. Rules 5/6/8 take min() because each grades one watchdog, claim
or pack; slew_w is not per-unit at all, since compute() emits the site
target and the allocator divides it afterwards.
⚠️config.yaml's site_sign_disagree_w note says there is deliberately no rule
pairing a site quantity with max_w because "validate_startup_config() is
handed options rather than a roster". That objection is to grading against the flat key, and it is answered rather than ignored: unit_caps() already reads
the roster the add-on will build.
⭐ Verified by breaking it
restoring slew_w 1000 at max_w 5000 -> 1 problem(s)
slew_w is 1000 W against a site rail of 5000 W, so the loop crosses its
whole output range in 5.0 cycles - 10 s at the 2 s tick this site was
measured running - while the plant takes 18 s to settle …
Graded separately: which rail it used, how long the crossing takes, which
measurement it cites, and what to do (including 0). Plus equality starts
(the constraint met, not violated — rule 6's case, not rule 5's), one watt of
slack starts, the live interim 400 starts, and slew_w: 0 starts at
every rating from 100 W to 15 kW. test_control.py grades the same boundary
from the law's side so the rule and the law cannot drift apart.
⚠️ 4. gain: 0.6 — measured, and deliberately NOT changed
The card asks whether gain still holds at this clamp. It does not.
slew_w at the derived 556 W, same 51-step family:
gain 0.6 ← shipped over-absorbed 987 W
gain 0.5 over-absorbed 941 W
gain 0.4 over-absorbed 977 W
gain 0.3 over-absorbed 876 W
gain 0.2 over-absorbed 435 W
gain 0.1 over-absorbed 66 W
against a slew column that is flat. The mechanism is structural: slew_w
bounds the OUTPUT, while the integrator accumulates gain × error per cycle
and is bounded only by integrator_max_w — at gain 0.6 into a 3.4 kW error it
moves ~2040 W per tick and reaches a 5000 W rail in under three ticks,
whatever slew_w is. The 2026-08-22 sentence "the gain, not the slew, is the
limit by then" is now literally true.
Not changed, for three reasons: it is a live control-law constant on a
running house and the interim slew_w: 400 is a clean baseline that two
simultaneous changes would destroy; a rule pairing gain with a rail would
invent a relationship nobody has measured (error_max is not config); and integrator_max_w is not the lever it looks like — it bounds the output too,
which is why 2000 capped the inverter to 2000 W of its 5000 on 2026-09-05 and
was reverted the same day. It wants its own ticket, and it is named in validate.py's "deliberately not refused" list and asserted in test_control.py so nobody reads this card as having fixed it.
⚠️ 5. N = 1 → N = 2: the rail is what moved
Inverter B does not exist on site (SPIKE-04, ≈ 2026-09-12). Its figures are from its datasheet and serial: GW5048D-ES, 55048ESU22AW0992, 4600 W, pack
4 × Dyness DL5.0C = 20.48 kWh, floor 10 % (goodwe/NOTES.md §1.1b) — and 4600 W
is printed under battery input data, so it may be DC (AC ceiling ~4324 W).
sim/plantsim.py's INVERTER_B is inverter A copied, measured=False. Every two-inverter figure below is a result about two copies of A on a 10000 W
rail, not about the ~9600 W site B implies.
Site rail becomes sum(max_w) ≈ 9600 W while slew_w is per-site. The
derived value there is 1067 W/tick (533 W per unit at an even split —
ARCH-01 split-invariance, as two_master_gain_sweep shows).
Does the enforced relationship produce it automatically? Yes. Rule 10 reads sum(unit_caps(opts)) and slew_limit_w() reads Tuning.max_w, which main.py sets from Controller.site_max_w. Declaring B moves both, no operator
action, no second derivation. test_validate.py asserts the 9600 W arithmetic.
Two things must be said because they are not what the ticket assumed:
A rail that grows LOOSENS this bound.slew_w: 1000 crosses 9600 W in
19.2 s and passes, while the same 1000 at 5000 W is the configuration
measured failing. That is what the constraint says and it is correct — the
bound is on how long the crossing takes. What the rule protects against is
the constant being left behind when the rail moves, in either direction; it
is not a bound on how much power the site may command, and it does not
bound the over-absorption (§4).
The ≥ 900 W symptom goes stale at N = 2 the same way the constant did. On
the two-copies-of-A rows, 48/51 runs contain a ≥ 900 W step at the derived
value, because at a 10000 W rail the derived step is 1111 W. A check on
the absolute 900 would report the correct tuning as broken. Anything grading
this symptom at N ≥ 2 must grade it as a fraction of the rail — this card,
one layer out.
6. max_w: 5000 — recommended, not changed
The battery reached 5154 W against a 5000 VA inverter: the cap is the
rating, so the overshoot the loop still produces has nowhere to go but past it.
Recommend max_w at 4500–4750 W (90–95 % of nameplate), leaving
250–500 W of headroom. This is freeze.py's RAIL_FRAC argument applied to
the cap itself.
⚠️The −5154 W may not be an AC figure.batt_entity is the pack sensor; max_w is graded against 0x0518, the inverter's AC power — config.yaml's roster comment already records that ambiguity for B. If DC,
5154 W DC ≈ 4900 W AC and the inverter was never over nameplate. Nobody has
measured which. The recommendation stands either way.
It is the owner's number — translations/en.yaml marks it "THIS IS YOUR
NUMBER" precisely so it is not confused with a derived constant.
7. What the change found in this repository's own tests
Five files moved, and none because a number rounded differently.
Two fixtures encoded the defect.test_inverter.py's BASE_OPTS paired slew_w: 1000 with max_w: 5000 — the 2026-09-05 configuration exactly — and test_loop.py's BASE paired 1000 with 2000. Rule 10 refused both on its
first run: those fixtures would not start.
Two reproductions depended on a default they never named. test_alloc.py's close_loop and test_freeze.py's proof-of-capability case
reached 1000 through Tuning's default; ARCH-01's 4000 W limit cycle and
SAFETY-18's hot replay would have gone quiet, reporting measured findings as absent. Both now write the number beside its measurement.
One harness had a latent off-by-one only a ramping loop could expose. test_watchdog.py paired c.grid with last_write sampled after cycle(), while the detector runs insidecycle() against the standing
command. At slew_w: 1000 the loop railed in two cycles and the wrong pair and
the right pair were the same numbers. _drive() now has a pre hook and the
check reads the latching cycle. The assertion is strictly stronger.
One tripwire had two assertions that were exact by coincidence. test_peakguard.py 10c read limited_discharging == limited (49/49) and min_soc <= bms_floor + 0.05. At the slower slew the loop drains that pack
~82 Wh less over the same two hours: 48/49, low-water 10.57 %, 0 cycles at the
BMS limit instead of 190 — while the measure the finding rests on gets sharper (162 cycles of granted discharge below the floor, up from 138).
Old and new numbers are tabulated in the file beside the checks. ⚠️"An
assertion was loosened to get a branch green" is the first thing to suspect
here; the table is there so it can be settled rather than argued.
Deliberately not done
saturation_w and the deadband untouched — widening either hides the
symptom. The loop follows its command correctly; the command is wrong.
gain not changed (§4). max_w not changed (§6).
The slew limit is not clamped to the bound — an explicit value above it is
refused at startup, not quietly reduced.
Nothing in admin/goodwe modified. Nothing reached the inverter. The live
install still runs the interim slew_w: 400, which rule 10 accepts unchanged.
## What this is
`slew_w: 1000` was derived on 2026-08-22 against a **clamp of 1500 W**
(goodwe/`NOTES.md`, *"Simulated 2 kW load step (gain 0.6, clamp 1500)"*), on the
grounds that it caps *"how far the loop can jump ahead of the inverter"*. At
1500 W that was true, and the reason was **the rail rather than the number**:
1000 W/cycle reaches the rail in two cycles, so the excursion is bounded by
1500 W whatever the loop does.
The live install then went to `max_w: 5000` and the constant did not move.
**Nothing in the code is broken. The rail moved and the derivation did not.**
| | derived at | shipped default | live install |
|---|---|---|---|
| `max_w` | **1500** | 2000 | **5000** |
| `slew_w` | 1000 | 1000 | **1000** |
Measured on the owner's system 2026-09-05 11:43–11:46: a 3.4 kW export drove the
setpoint **380 → 5000 W in five 2 s ticks**, the battery to **−5154 W against a
5000 VA inverter**, and the grid from **3.4 kW of export to 2.8 kW of import**.
≥ 900 W setpoint steps per 30 min that morning: `2 → 1 → 13 → 43`.
Full record: **`goodwe_controller/DECISIONS/SAFETY-39-slew-against-the-rail.md`**.
---
## ⭐ 1. Reproduced first — and one half of it is not reproducible at all
`tools/slew_sim.py --repro`, against admin/goodwe's `plantsim.Plant`, the real
`compute()`, and the firmware's own encoder on the wire. Gain 0.6, `max_w` 5000,
`slew_w` 1000, 2 s tick, one 3385 W export step:
```
t grid in setpoint battery grid out reason
10 -3739 -280 290 -3675 slew-limited
12 -3675 -1280 57 -3442 slew-limited
14 -3442 -2280 -316 -3069 slew-limited
...
34 -1012 -5000 -2823 -562 i-clamped ← the rail
38 -167 -5000 -3541 156 saturated-freeze ← EXPORT → IMPORT
44 562 -4310 -4018 633 saturated-freeze
```
Over 51 export steps (1.0–4.2 kW × 3 phases), **48 of 51 runs contain a ≥ 900 W
setpoint step** and the loop over-absorbs ~1 kW, driving the meter to +958 W.
### ⚠️ The −5154 W figure is NOT reproducible in simulation, and no criterion is written on it
`plantsim.Plant.limit()` clamps the plant at `p_max`, and so does the
independent model in `test_control.py`. **The modelled battery cannot exceed its
rating by one watt, at any tuning, on any step, ever.** So the card's AC
*"battery power no longer exceeds `max_w` on a step of the size observed"* is
**unfalsifiable in simulation** — it passes identically on the configuration
that produced the excursion.
Per this board's own rule that a third review round means the AC is wrong, that
criterion was **rewritten rather than met**. What replaced it, and is graded in
CI: the ≥ 900 W step is gone from every member of the family, and the
over-absorption is **measured and reported rather than claimed to be fixed**.
**Two independent plant models agree** (in-repo first-order lag vs `plantsim`'s
ramp-plus-lag):
| | in-repo | `plantsim` |
|---|---|---|
| ≥ 900 W steps at `slew_w` 1000 | 48/51 | 48/51 |
| ≥ 900 W steps at the derived value | 0/51 | 0/51 |
| over-absorption, slew 1000 → 250 | 1005 → 1330 W | 958 → 1081 W |
| over-absorption, gain 0.6 → 0.2 | 1109 → 393 W | 987 → 435 W |
---
## 2. The derivation
> **The loop must not traverse its full output range faster than the plant takes
> to settle.**
| | value | measured where |
|---|---|---|
| plant settling | **18 s** | goodwe/`NOTES.md` §5.10, 2026-08-22, a 1.78 kW kettle step off a plug that meters its own draw: ~18 s up, ~23 s down. **18 is taken because it is the shorter and therefore the looser bound** — this is a refusal boundary, and rules 5/6 refuse the impossible case only. |
| control tick | **2 s** | the live install, 2026-09-05 — the setpoint stepped +1000 W every 2 s. |
| output range | `max_w` | `main.py` builds `Tuning(max_w=self.site_max_w)`, the roster sum. |
```
SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = 2 s / 18 s = 0.1111 (9 ticks)
slew_w ≤ SLEW_RAIL_FRAC × max_w
```
| rail | derived `slew_w` | ticks | seconds |
|---|---|---|---|
| 1500 (where the original was derived) | 167 W | 9 | 18 s |
| 2000 (shipped default) | 222 W | 9 | 18 s |
| **5000 (live)** | **556 W** | 9 | 18 s |
| 9600 (with inverter B — **datasheet, not measured**) | 1067 W | 9 | 18 s |
And the pair that was measured failing: `slew_w` 1000 at `max_w` 5000 crosses in
**5.0 ticks = 10 s** against an 18 s plant.
### ⚠️ The rig cannot pick 556 out of the table, and is not asked to
```
slew_w 1000 ← 2026-09-05 pair step 1000 W 48/51 ≥900 W over-absorbed 958 W
slew_w 800 step 800 W 0/51 ≥900 W over-absorbed 1107 W
slew_w 600 step 600 W 0/51 ≥900 W over-absorbed 1058 W
slew_w 555 ← the derived value step 560 W 0/51 ≥900 W over-absorbed 987 W
slew_w 400 ← the live interim step 400 W 0/51 ≥900 W over-absorbed 1150 W
slew_w 250 step 250 W 0/51 ≥900 W over-absorbed 1081 W
```
The ≥ 900 W step disappears at 800 and stays gone; below that the column is flat
and slightly non-monotone, and 800 is *better* than 556 on over-absorption. **The
constraint chooses the number; the rig says what the constraint does and does not
buy** — the same relationship the 2026-08-22 table had to its own three columns.
⚠️ *A single step is not evidence here.* The deadband, the 10 W quantisation and
the step's phase move the answer by hundreds of watts non-monotonically. The
first cut of this derivation was one step and ranked gain 0.3 as **worse** than
0.6, which the family shows is backwards. Every number is a worst case over a
family, as `config.yaml`'s own "1890 runs" / "924 runs" campaigns are.
### ⚠️ The tick is the weak term, and it is not wrong in the safe direction
A slower meter makes this conservative — visible immediately, one edit. A meter
**faster** than 2 s makes it loose by that ratio and nothing would say so.
`run_control()`'s `await asyncio.sleep(1)` bounds the tick at 1 s, so the worst
case is a factor of two — but a faster meter means `LOOP_TICK_S` must be
**re-derived, not re-read**. That is why both numbers are written out in
`control.py` rather than their quotient.
---
## ⭐ 3. What ships — the relationship, not the number
**`slew_w: 0` means FOLLOW THE RAIL.** `control.slew_limit_w()` resolves it to
`SLEW_RAIL_FRAC * max_w`. It is the shipped default and the only value that
cannot be left behind when the rail moves. This is the idiom
`integrator_max_w: 0` has carried **in the same options block** since it was
added — not a new concept, the same one, applied to the key that needed it.
An explicit number is still accepted **and never silently reduced** (the live
install runs 400). Silently reducing an operator's stated bound is case (a) in
`validate.py`'s own docstring.
**`validate.py` rule 10** refuses an explicit `slew_w` above
`SLEW_RAIL_FRAC × site rail`, graded against **`sum(unit_caps(opts))`** — the
roster sum, i.e. `Controller.site_max_w` — not the flat `max_w` and not the
weakest unit. Rules 5/6/8 take `min()` because each grades *one* watchdog, claim
or pack; `slew_w` is not per-unit at all, since `compute()` emits the **site**
target and the allocator divides it afterwards.
⚠️ `config.yaml`'s `site_sign_disagree_w` note says there is deliberately no rule
pairing a site quantity with `max_w` because "`validate_startup_config()` is
handed options rather than a roster". That objection is to grading against the
**flat key**, and it is answered rather than ignored: `unit_caps()` already reads
the roster the add-on will build.
### ⭐ Verified by breaking it
```
restoring slew_w 1000 at max_w 5000 -> 1 problem(s)
slew_w is 1000 W against a site rail of 5000 W, so the loop crosses its
whole output range in 5.0 cycles - 10 s at the 2 s tick this site was
measured running - while the plant takes 18 s to settle …
```
Graded separately: which rail it used, how long the crossing takes, which
measurement it cites, and what to do (including `0`). Plus **equality starts**
(the constraint met, not violated — rule 6's case, not rule 5's), **one watt of
slack starts**, **the live interim 400 starts**, and **`slew_w: 0` starts at
every rating from 100 W to 15 kW**. `test_control.py` grades the same boundary
from the law's side so the rule and the law cannot drift apart.
---
## ⚠️ 4. `gain: 0.6` — measured, and deliberately NOT changed
The card asks whether `gain` still holds at this clamp. **It does not.**
```
slew_w at the derived 556 W, same 51-step family:
gain 0.6 ← shipped over-absorbed 987 W
gain 0.5 over-absorbed 941 W
gain 0.4 over-absorbed 977 W
gain 0.3 over-absorbed 876 W
gain 0.2 over-absorbed 435 W
gain 0.1 over-absorbed 66 W
```
against a slew column that is flat. The mechanism is structural: **`slew_w`
bounds the OUTPUT**, while the integrator accumulates `gain × error` per cycle
and is bounded only by `integrator_max_w` — at gain 0.6 into a 3.4 kW error it
moves **~2040 W per tick** and reaches a 5000 W rail in **under three ticks,
whatever `slew_w` is**. The 2026-08-22 sentence *"the gain, not the slew, is the
limit by then"* is now literally true.
**Not changed, for three reasons:** it is a live control-law constant on a
running house and the interim `slew_w: 400` is a clean baseline that two
simultaneous changes would destroy; a rule pairing `gain` with a rail would
invent a relationship nobody has measured (`error_max` is not config); and
`integrator_max_w` is not the lever it looks like — it bounds the *output* too,
which is why 2000 capped the inverter to 2000 W of its 5000 on 2026-09-05 and
was reverted the same day. **It wants its own ticket**, and it is named in
`validate.py`'s "deliberately not refused" list and asserted in
`test_control.py` so nobody reads this card as having fixed it.
---
## ⚠️ 5. N = 1 → N = 2: the rail is what moved
**Inverter B does not exist on site** (SPIKE-04, ≈ 2026-09-12). Its figures are
**from its datasheet and serial**: GW5048D-ES, `55048ESU22AW0992`, 4600 W, pack
4 × Dyness DL5.0C = 20.48 kWh, floor 10 % (goodwe/`NOTES.md` §1.1b) — and 4600 W
is printed under *battery input data*, so it may be DC (AC ceiling ~4324 W).
**`sim/plantsim.py`'s `INVERTER_B` is inverter A copied**, `measured=False`.
**Every two-inverter figure below is a result about two copies of A on a 10000 W
rail**, not about the ~9600 W site B implies.
Site rail becomes `sum(max_w)` ≈ **9600 W** while `slew_w` is per-site. The
derived value there is **1067 W/tick** (533 W per unit at an even split —
ARCH-01 split-invariance, as `two_master_gain_sweep` shows).
**Does the enforced relationship produce it automatically? Yes.** Rule 10 reads
`sum(unit_caps(opts))` and `slew_limit_w()` reads `Tuning.max_w`, which
`main.py` sets from `Controller.site_max_w`. Declaring B moves both, no operator
action, no second derivation. `test_validate.py` asserts the 9600 W arithmetic.
**Two things must be said because they are not what the ticket assumed:**
1. **A rail that grows LOOSENS this bound.** `slew_w: 1000` crosses 9600 W in
19.2 s and **passes**, while the same 1000 at 5000 W is the configuration
measured failing. That is what the constraint says and it is correct — the
bound is on how long the crossing *takes*. What the rule protects against is
the constant being left behind when the rail moves, in either direction; it
is **not** a bound on how much power the site may command, and it does **not**
bound the over-absorption (§4).
2. **The ≥ 900 W symptom goes stale at N = 2 the same way the constant did.** On
the two-copies-of-A rows, 48/51 runs contain a ≥ 900 W step *at the derived
value*, because at a 10000 W rail the derived step **is** 1111 W. A check on
the absolute 900 would report the correct tuning as broken. Anything grading
this symptom at N ≥ 2 must grade it as a **fraction of the rail** — this card,
one layer out.
---
## 6. `max_w: 5000` — recommended, not changed
The battery reached **5154 W against a 5000 VA inverter**: the cap is the
rating, so the overshoot the loop still produces has nowhere to go but past it.
* **Recommend `max_w` at 4500–4750 W** (90–95 % of nameplate), leaving
250–500 W of headroom. This is `freeze.py`'s `RAIL_FRAC` argument applied to
the cap itself.
* ⚠️ **The −5154 W may not be an AC figure.** `batt_entity` is the pack sensor;
`max_w` is graded against `0x0518`, the inverter's **AC** power —
`config.yaml`'s roster comment already records that ambiguity for B. If DC,
5154 W DC ≈ 4900 W AC and the inverter was never over nameplate. **Nobody has
measured which.** The recommendation stands either way.
* **It is the owner's number** — `translations/en.yaml` marks it "THIS IS YOUR
NUMBER" precisely so it is not confused with a derived constant.
---
## 7. What the change found in this repository's own tests
Five files moved, and none because a number rounded differently.
* **Two fixtures encoded the defect.** `test_inverter.py`'s `BASE_OPTS` paired
`slew_w: 1000` with `max_w: 5000` — the 2026-09-05 configuration exactly — and
`test_loop.py`'s `BASE` paired 1000 with 2000. Rule 10 refused both on its
first run: **those fixtures would not start.**
* **Two reproductions depended on a default they never named.**
`test_alloc.py`'s `close_loop` and `test_freeze.py`'s proof-of-capability case
reached 1000 through `Tuning`'s default; ARCH-01's 4000 W limit cycle and
SAFETY-18's hot replay would have gone quiet, reporting measured findings as
*absent*. Both now write the number beside its measurement.
* **One harness had a latent off-by-one only a ramping loop could expose.**
`test_watchdog.py` paired `c.grid` with `last_write` sampled **after**
`cycle()`, while the detector runs **inside** `cycle()` against the *standing*
command. At `slew_w: 1000` the loop railed in two cycles and the wrong pair and
the right pair were the same numbers. `_drive()` now has a `pre` hook and the
check reads the **latching** cycle. The assertion is strictly stronger.
* **One tripwire had two assertions that were exact by coincidence.**
`test_peakguard.py` 10c read `limited_discharging == limited` (49/49) and
`min_soc <= bms_floor + 0.05`. At the slower slew the loop drains that pack
~82 Wh less over the same two hours: 48/49, low-water 10.57 %, 0 cycles at the
BMS limit instead of 190 — while the measure the finding rests on gets
**sharper** (162 cycles of granted discharge below the floor, up from 138).
Old and new numbers are tabulated in the file beside the checks. ⚠️ *"An
assertion was loosened to get a branch green" is the first thing to suspect
here; the table is there so it can be settled rather than argued.*
---
## Deliberately not done
* **`saturation_w` and the deadband untouched** — widening either hides the
symptom. The loop follows its command correctly; the command is wrong.
* **`gain` not changed** (§4). **`max_w` not changed** (§6).
* **The slew limit is not clamped to the bound** — an explicit value above it is
refused at startup, not quietly reduced.
* **Nothing in admin/goodwe modified. Nothing reached the inverter.** The live
install still runs the interim `slew_w: 400`, which rule 10 accepts unchanged.
---
## Verification
`goodwe-addon-ci`, checkout mounted `:ro`, `__pycache__` purged first (0 dirs,
0 `.pyc` remaining), `python3 -B`:
```
21 test file(s) to run — 21 PASS — SUITE EXIT=0
```
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
`slew_w: 1000` was derived on 2026-08-22 against a clamp of 1500 W, where
"capping how far the loop can jump ahead of the inverter" was true because the
rail itself bounded the excursion. The live install went to max_w 5000 and the
constant did not move, so the loop crosses its whole output range in 10 s
against a plant NOTES.md §5.10 measured at ~18 s. Measured on the owner's system
2026-09-05 11:43-11:46: a 3.4 kW export drove the setpoint 380 -> 5000 W in five
2 s ticks, the battery to -5154 W past a 5000 VA rating, and the grid from
3.4 kW of EXPORT to 2.8 kW of IMPORT. Nothing was broken; the rail moved.
REPRODUCED FIRST, and one half of it is not reproducible at all. On
plantsim.Plant and on an independent in-repo model: the setpoint stepping a full
1000 W per tick to the rail (48 of 51 export steps), and the meter crossing from
export into import. The -5154 W figure is NOT reproducible in either model,
because both clamp the plant at its rating - so the card's AC "battery no longer
exceeds max_w in simulation" is unfalsifiable and was rewritten rather than met.
THE DELIVERABLE IS THE RELATIONSHIP.
SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = 2 s / 18 s = 1/9
slew_w = 0 now means FOLLOW THE RAIL - SLEW_RAIL_FRAC * max_w - exactly as
integrator_max_w = 0 already means follow max_w. It is the shipped default:
222 W at max_w 2000, 556 W at 5000, 1067 W at a ~9600 W two-inverter site.
validate.py rule 10 refuses an explicit value faster than that, graded against
sum(unit_caps(opts)) - the roster sum, i.e. Controller.site_max_w - so a
second inverter moves the bound with no operator action.
VERIFIED BY BREAKING IT: restoring slew_w 1000 at max_w 5000 is refused, and the
refusal names the rail, the crossing time, the measurement and what to do.
Equality, one watt of slack, the live interim 400 and slew_w 0 at every rating
all still start.
GAIN IS THE CONSTANT THAT IS STILL WRONG, and it is NOT changed here. Over 51
export steps the over-absorption is FLAT in slew (958 W at 1000, 1081 W at 250)
and monotone in gain (987 W at 0.6, 876 at 0.3, 435 at 0.2): slew_w bounds the
OUTPUT, while the integrator moves gain*error per cycle and reaches a 5000 W
rail in under three ticks whatever the slew is. Two control-law constants moving
in one release on a running house makes the next measurement uninterpretable.
⚠️ N=2: the site rail becomes sum(max_w) ~ 9600 W (inverter B FROM DATASHEET AND
SERIAL, not measured, and sim/plantsim.py's INVERTER_B is A copied), and the
enforced ratio produces 1067 W there automatically. But a growing rail LOOSENS
this bound, and the >= 900 W symptom goes stale at N=2 the same way the constant
did - both stated in the record rather than left to be discovered.
Nothing reached the inverter. The live install still runs the interim slew_w 400
applied by hand on 2026-09-05, which rule 10 accepts unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
Review found validate.py:288-289 carrying numbers that appear nowhere
else in the PR's evidence and could not be reproduced: over-absorption
"1233 W at slew 1000 and 1344 W at slew 250 ... 1495 W at 0.6, 961 W at
0.3, ~0 W at 0.2". A grep for 1233/1344/1495 found them ONLY in that
comment - the DECISIONS record, the PR body, test_control.py's audit
table and slew_sim's own output all carried the correct ones.
The measured values, from re-running tools/slew_sim.py on the real plant
model at the stated conditions (max_w 5000, 2 s tick, 51 export steps
1.0-4.2 kW): slew column 958 (slew 1000) and 1081 (slew 250); gain column
987 (0.6), 876 (0.3), 435 (0.2).
⚠️ "~0 W at 0.2" WAS WRONG IN THE UNSAFE DIRECTION. It invited a future
reader to conclude that dropping gain to 0.2 removes over-absorption. It
does not: 435 W remains, which is 44 % of the figure at 0.6, not zero.
The corrected text says so at the line rather than leaving a round number
to be trusted.
The conclusion the paragraph draws is unchanged and still holds on the
measured figures: over-absorption is flat in slew_w and falls in gain, so
`gain` is the constant that would have to move, and SAFETY-39
deliberately does not move it.
No behaviour change; comment only. Full suite: 21 passed, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin
merged commit a4290d39ac into release/1.02026-09-06 10:41:05 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What this is
slew_w: 1000was derived on 2026-08-22 against a clamp of 1500 W(goodwe/
NOTES.md, "Simulated 2 kW load step (gain 0.6, clamp 1500)"), on thegrounds that it caps "how far the loop can jump ahead of the inverter". At
1500 W that was true, and the reason was the rail rather than the number:
1000 W/cycle reaches the rail in two cycles, so the excursion is bounded by
1500 W whatever the loop does.
The live install then went to
max_w: 5000and the constant did not move.Nothing in the code is broken. The rail moved and the derivation did not.
max_wslew_wMeasured on the owner's system 2026-09-05 11:43–11:46: a 3.4 kW export drove the
setpoint 380 → 5000 W in five 2 s ticks, the battery to −5154 W against a
5000 VA inverter, and the grid from 3.4 kW of export to 2.8 kW of import.
≥ 900 W setpoint steps per 30 min that morning:
2 → 1 → 13 → 43.Full record:
goodwe_controller/DECISIONS/SAFETY-39-slew-against-the-rail.md.⭐ 1. Reproduced first — and one half of it is not reproducible at all
tools/slew_sim.py --repro, against admin/goodwe'splantsim.Plant, the realcompute(), and the firmware's own encoder on the wire. Gain 0.6,max_w5000,slew_w1000, 2 s tick, one 3385 W export step:Over 51 export steps (1.0–4.2 kW × 3 phases), 48 of 51 runs contain a ≥ 900 W
setpoint step and the loop over-absorbs ~1 kW, driving the meter to +958 W.
⚠️ The −5154 W figure is NOT reproducible in simulation, and no criterion is written on it
plantsim.Plant.limit()clamps the plant atp_max, and so does theindependent model in
test_control.py. The modelled battery cannot exceed itsrating by one watt, at any tuning, on any step, ever. So the card's AC
"battery power no longer exceeds
max_won a step of the size observed" isunfalsifiable in simulation — it passes identically on the configuration
that produced the excursion.
Per this board's own rule that a third review round means the AC is wrong, that
criterion was rewritten rather than met. What replaced it, and is graded in
CI: the ≥ 900 W step is gone from every member of the family, and the
over-absorption is measured and reported rather than claimed to be fixed.
Two independent plant models agree (in-repo first-order lag vs
plantsim'sramp-plus-lag):
plantsimslew_w10002. The derivation
NOTES.md§5.10, 2026-08-22, a 1.78 kW kettle step off a plug that meters its own draw: ~18 s up, ~23 s down. 18 is taken because it is the shorter and therefore the looser bound — this is a refusal boundary, and rules 5/6 refuse the impossible case only.max_wmain.pybuildsTuning(max_w=self.site_max_w), the roster sum.slew_wAnd the pair that was measured failing:
slew_w1000 atmax_w5000 crosses in5.0 ticks = 10 s against an 18 s plant.
⚠️ The rig cannot pick 556 out of the table, and is not asked to
The ≥ 900 W step disappears at 800 and stays gone; below that the column is flat
and slightly non-monotone, and 800 is better than 556 on over-absorption. The
constraint chooses the number; the rig says what the constraint does and does not
buy — the same relationship the 2026-08-22 table had to its own three columns.
⚠️ A single step is not evidence here. The deadband, the 10 W quantisation and
the step's phase move the answer by hundreds of watts non-monotonically. The
first cut of this derivation was one step and ranked gain 0.3 as worse than
0.6, which the family shows is backwards. Every number is a worst case over a
family, as
config.yaml's own "1890 runs" / "924 runs" campaigns are.⚠️ The tick is the weak term, and it is not wrong in the safe direction
A slower meter makes this conservative — visible immediately, one edit. A meter
faster than 2 s makes it loose by that ratio and nothing would say so.
run_control()'sawait asyncio.sleep(1)bounds the tick at 1 s, so the worstcase is a factor of two — but a faster meter means
LOOP_TICK_Smust bere-derived, not re-read. That is why both numbers are written out in
control.pyrather than their quotient.⭐ 3. What ships — the relationship, not the number
slew_w: 0means FOLLOW THE RAIL.control.slew_limit_w()resolves it toSLEW_RAIL_FRAC * max_w. It is the shipped default and the only value thatcannot be left behind when the rail moves. This is the idiom
integrator_max_w: 0has carried in the same options block since it wasadded — not a new concept, the same one, applied to the key that needed it.
An explicit number is still accepted and never silently reduced (the live
install runs 400). Silently reducing an operator's stated bound is case (a) in
validate.py's own docstring.validate.pyrule 10 refuses an explicitslew_waboveSLEW_RAIL_FRAC × site rail, graded againstsum(unit_caps(opts))— theroster sum, i.e.
Controller.site_max_w— not the flatmax_wand not theweakest unit. Rules 5/6/8 take
min()because each grades one watchdog, claimor pack;
slew_wis not per-unit at all, sincecompute()emits the sitetarget and the allocator divides it afterwards.
⚠️
config.yaml'ssite_sign_disagree_wnote says there is deliberately no rulepairing a site quantity with
max_wbecause "validate_startup_config()ishanded options rather than a roster". That objection is to grading against the
flat key, and it is answered rather than ignored:
unit_caps()already readsthe roster the add-on will build.
⭐ Verified by breaking it
Graded separately: which rail it used, how long the crossing takes, which
measurement it cites, and what to do (including
0). Plus equality starts(the constraint met, not violated — rule 6's case, not rule 5's), one watt of
slack starts, the live interim 400 starts, and
slew_w: 0starts atevery rating from 100 W to 15 kW.
test_control.pygrades the same boundaryfrom the law's side so the rule and the law cannot drift apart.
⚠️ 4.
gain: 0.6— measured, and deliberately NOT changedThe card asks whether
gainstill holds at this clamp. It does not.against a slew column that is flat. The mechanism is structural:
slew_wbounds the OUTPUT, while the integrator accumulates
gain × errorper cycleand is bounded only by
integrator_max_w— at gain 0.6 into a 3.4 kW error itmoves ~2040 W per tick and reaches a 5000 W rail in under three ticks,
whatever
slew_wis. The 2026-08-22 sentence "the gain, not the slew, is thelimit by then" is now literally true.
Not changed, for three reasons: it is a live control-law constant on a
running house and the interim
slew_w: 400is a clean baseline that twosimultaneous changes would destroy; a rule pairing
gainwith a rail wouldinvent a relationship nobody has measured (
error_maxis not config); andintegrator_max_wis not the lever it looks like — it bounds the output too,which is why 2000 capped the inverter to 2000 W of its 5000 on 2026-09-05 and
was reverted the same day. It wants its own ticket, and it is named in
validate.py's "deliberately not refused" list and asserted intest_control.pyso nobody reads this card as having fixed it.⚠️ 5. N = 1 → N = 2: the rail is what moved
Inverter B does not exist on site (SPIKE-04, ≈ 2026-09-12). Its figures are
from its datasheet and serial: GW5048D-ES,
55048ESU22AW0992, 4600 W, pack4 × Dyness DL5.0C = 20.48 kWh, floor 10 % (goodwe/
NOTES.md§1.1b) — and 4600 Wis printed under battery input data, so it may be DC (AC ceiling ~4324 W).
sim/plantsim.py'sINVERTER_Bis inverter A copied,measured=False.Every two-inverter figure below is a result about two copies of A on a 10000 W
rail, not about the ~9600 W site B implies.
Site rail becomes
sum(max_w)≈ 9600 W whileslew_wis per-site. Thederived value there is 1067 W/tick (533 W per unit at an even split —
ARCH-01 split-invariance, as
two_master_gain_sweepshows).Does the enforced relationship produce it automatically? Yes. Rule 10 reads
sum(unit_caps(opts))andslew_limit_w()readsTuning.max_w, whichmain.pysets fromController.site_max_w. Declaring B moves both, no operatoraction, no second derivation.
test_validate.pyasserts the 9600 W arithmetic.Two things must be said because they are not what the ticket assumed:
slew_w: 1000crosses 9600 W in19.2 s and passes, while the same 1000 at 5000 W is the configuration
measured failing. That is what the constraint says and it is correct — the
bound is on how long the crossing takes. What the rule protects against is
the constant being left behind when the rail moves, in either direction; it
is not a bound on how much power the site may command, and it does not
bound the over-absorption (§4).
the two-copies-of-A rows, 48/51 runs contain a ≥ 900 W step at the derived
value, because at a 10000 W rail the derived step is 1111 W. A check on
the absolute 900 would report the correct tuning as broken. Anything grading
this symptom at N ≥ 2 must grade it as a fraction of the rail — this card,
one layer out.
6.
max_w: 5000— recommended, not changedThe battery reached 5154 W against a 5000 VA inverter: the cap is the
rating, so the overshoot the loop still produces has nowhere to go but past it.
max_wat 4500–4750 W (90–95 % of nameplate), leaving250–500 W of headroom. This is
freeze.py'sRAIL_FRACargument applied tothe cap itself.
batt_entityis the pack sensor;max_wis graded against0x0518, the inverter's AC power —config.yaml's roster comment already records that ambiguity for B. If DC,5154 W DC ≈ 4900 W AC and the inverter was never over nameplate. Nobody has
measured which. The recommendation stands either way.
translations/en.yamlmarks it "THIS IS YOURNUMBER" precisely so it is not confused with a derived constant.
7. What the change found in this repository's own tests
Five files moved, and none because a number rounded differently.
test_inverter.py'sBASE_OPTSpairedslew_w: 1000withmax_w: 5000— the 2026-09-05 configuration exactly — andtest_loop.py'sBASEpaired 1000 with 2000. Rule 10 refused both on itsfirst run: those fixtures would not start.
test_alloc.py'sclose_loopandtest_freeze.py's proof-of-capability casereached 1000 through
Tuning's default; ARCH-01's 4000 W limit cycle andSAFETY-18's hot replay would have gone quiet, reporting measured findings as
absent. Both now write the number beside its measurement.
test_watchdog.pypairedc.gridwithlast_writesampled aftercycle(), while the detector runs insidecycle()against the standingcommand. At
slew_w: 1000the loop railed in two cycles and the wrong pair andthe right pair were the same numbers.
_drive()now has aprehook and thecheck reads the latching cycle. The assertion is strictly stronger.
test_peakguard.py10c readlimited_discharging == limited(49/49) andmin_soc <= bms_floor + 0.05. At the slower slew the loop drains that pack~82 Wh less over the same two hours: 48/49, low-water 10.57 %, 0 cycles at the
BMS limit instead of 190 — while the measure the finding rests on gets
sharper (162 cycles of granted discharge below the floor, up from 138).
Old and new numbers are tabulated in the file beside the checks. ⚠️ "An
assertion was loosened to get a branch green" is the first thing to suspect
here; the table is there so it can be settled rather than argued.
Deliberately not done
saturation_wand the deadband untouched — widening either hides thesymptom. The loop follows its command correctly; the command is wrong.
gainnot changed (§4).max_wnot changed (§6).refused at startup, not quietly reduced.
install still runs the interim
slew_w: 400, which rule 10 accepts unchanged.Verification
goodwe-addon-ci, checkout mounted:ro,__pycache__purged first (0 dirs,0
.pycremaining),python3 -B:🤖 Generated with Claude Code
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU