SAFETY-39: slew_w is a ratio to the rail, and gain is the constant that is still wrong #56

Merged
admin merged 2 commits from SAFETY-39 into release/1.0 2026-09-06 10:41:05 +02:00
Owner

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.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

## 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
admin added 1 commit 2026-09-06 01:11:38 +02:00
`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
admin added 1 commit 2026-09-06 01:48:53 +02:00
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.0 2026-09-06 10:41:05 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#56