Four review findings, none of them in executable code. The control law, the tests and the instruments' arithmetic are untouched; every number below was re-measured against the committed 16-day capture before it was written down. R1 - app/control.py carried a justification the decision record and MUTATION.md habit 22 had already retracted, and it was false about the code it sits in. It said an unconditional placement "would also pull the integrator toward a command the SATURATION freeze is pinning", priced at 118 of 256 250 ticks. The 118 was measured against a different pair; on the pair the sentence names the two forms are bit-identical - 0 of 256 246 ticks, re-measured. And the narrow form that shipped DOES move a saturation-frozen integrator: it runs before the freeze block and is not gated on `frozen`. Of 79 160 frozen ticks the cap changes `i_w` on 1095, and on 679 of those it moves it further out on the side it was already on - putting back magnitude the freeze had just let fall, which the freeze's own rule forbids. Worst case 2424 W, at prev_w -3400 W, grid +4697 W, actual -4144 W, sat_count 2 -> 3: the freeze leaves the integrator at -176 W and the cap drags it to -2600 W. It never overrides a same-cycle refusal (the freeze clipped on 75 512 ticks, the slew bound on none of them), and that is stated too. It is also where SAFETY-13's settle values went: gating the cap on `not frozen` puts test_control.py part 5 back at -1190 W exactly, while part 4(b) does not come back - that one moved for the winding reason. R3 - the record read its own sensitivity table backwards. `fit_plant()` picks lag 7.5 s on battery-power RMS, which separates it from 2.5 s by 0.3 %, while the tool's own printed fidelity measure (export against the recorded 29.43 kWh) prefers 2.5 s by the whole 35 % gap. Swept properly: at every lag <= 5.0 s release/1.0's worst quarter is ALREADY 3434 W and this card changes it by exactly zero. The 3739 -> 3434 W headline exists at one plant. New section 3a carries the table; the export reduction now leads every summary and the peak is presented as lag-dependent. The form-A rejection is strengthened rather than weakened: form A is worse on the billed quarter at every lag where release/1.0 produces a peak at all, and at every top-N cut of the quarters. R2 - DOCS.md stated modelled watts to the owner as measurement. It now leads with the export reduction, says the figures are simulated, and says plainly that no capacity-tariff improvement is promised. R4 - tools/slew_sim.py --slew narrated "the OVER-ABSORPTION does not move, and at the slowest slews it is slightly WORSE" while printing a monotonically falling column. That sentence is TRUE on release/1.0 (reproduced: 958/1107/1133/1058/987/1029/1150/1143/1081) and false under this branch's law (777/734/663/563/553/507/435/358/321), so SAFETY-39 is not retracted: its record gets a forward reference, and the script now reads SLEW_LEAD_STEPS out of the law it imported and narrates from the column it just printed. Both branches of that narration were driven. R5 - three counts in the L table were decoration and are re-driven in the CI image on a `cp -a`, purged, `:ro`, one file at a time: L5 is 5+5 with the function names exchanged and 5+4 with the branch bodies exchanged (two mutations wearing one name, now listed separately), L6 is 6+3. The separable- state count is grid-dependent and three sweeps disagree, so all three are quoted with their grids and the load-bearing claim is WHERE they separate. R7 - "a large import answered too weakly" is not what the aggregate shows. Form A cuts import by 5.45 kWh and export by 5.18 kWh, and paired per tick delivers 8.56 kWh less offtake energy on the ticks release/1.0 was importing. The harm is a tail redistribution inside quarters that already net to offtake. The conclusion stands; the sentence is narrowed to it. Verified: the full suite in the CI image, purged tree, `:ro` mount, one file at a time, exactly the command in .gitea/workflows/checks.yml - 30/30, exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
24 KiB
SAFETY-39 — slew_w is a ratio to the rail, and gain is the constant that is still wrong
Status: decided and implemented on branch SAFETY-39.
Card: SAFETY-39 — "slew_w was derived at a 1500 W clamp and runs at 5000,
so the loop outruns the plant".
Nothing here reached the inverter. The live install still runs the interim
slew_w: 400 applied by hand on 2026-09-05 12:00, which this branch's new rule
accepts unchanged.
0. The three things a reader should take away
- The overshoot is reproduced in simulation, and one half of it is not reproducible at all. The setpoint stepping to the rail and the meter crossing from export into import are both reproduced, on two independent plant models. The −5154 W battery figure is not reproducible in either, because both clamp the plant at its rating. An acceptance criterion written on it would pass on the broken configuration too, so none is. §2.
- The deliverable is the relationship, not the number.
slew_w: 0now means follow the rail and resolves toSLEW_RAIL_FRAC * max_w;validate.pyrule 10 refuses an explicit value faster than that against the site rail. Verified by breaking it. §4, §5. - ⚠️
slew_wdoes not own the over-absorption.gaindoes, andgainwas not changed. Re-deriving the slew removes the ≥ 900 W step — the symptom that escalated 2 → 1 → 13 → 43 per 30 min on the morning of the excursion — and removes nothing else. Anyone reading this card as "the overshoot is fixed" is reading more than was delivered. §6.
1. The defect, stated in the terms the code uses
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)"). Its
stated justification:
slew: 1000is the default because it puts most of the correction in the first cycle while still capping how far the loop can jump ahead of the inverter.
At 1500 W that sentence is true, and the reason is 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.
DRIVER-07 ("raise actuator power cap to full inverter rating") is still in To
Do; the cap was raised ahead of it and slew_w was never revisited. 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:
11:43:55 grid -3385 W a real 3.4 kW export
11:43:56 SP 380 → 1380 → 2380 → 3380 → 4380 → 5000 +1000 per 2 s tick
11:44:47 batt -5154 W ABOVE the 5000 VA rating
11:45:55 grid +2831 W flipped to 2.8 kW IMPORT
SP 5000 → 2930 → 3840 → 3750 hunting
Large (≥ 900 W) setpoint steps per ~30 min that morning: 2 → 1 → 13 → 43.
2. ⭐ Reproduced first — and what could not be
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 at t = 10 s:
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
50 544 -3210 -3804 419 tracking
Reproduced: the setpoint stepping a full 1000 W per tick to the rail, the loop absorbing ~640 W more than the site needed, and the meter crossing from 3.7 kW of export to +633 W of import — the loop manufacturing the error it then chases. Over a family of 51 export steps (1.0–4.2 kW × 3 phases), 48 of 51 runs contain a ≥ 900 W setpoint step.
⚠️ What is NOT reproducible, and why no criterion is written on it
plantsim.Plant.limit() clamps the plant at p_max. The modelled battery
cannot exceed its rating by one watt, at any tuning, on any step, ever.
Neither can test_control.py's independent model. So:
- the −5154 W excursion is not reproducible in simulation at all, and this is a property of the models rather than of the tuning;
- the card's AC "battery power no longer exceeds
max_won a step of the size observed" is unfalsifiable in simulation — it passes identically on the configuration that produced the excursion. It is the one acceptance criterion this card had to rewrite rather than meet, per the board's own rule that a third review round means the AC is wrong.
What replaced it, and what is graded in CI: the ≥ 900 W step is gone from every member of the family, and the meter excursion and the over-absorption are measured and reported rather than claimed to be fixed.
The two models agree
test_control.py's in-repo first-order lag and admin/goodwe's ramp-plus-lag
plantsim.Plant are independent, and they agree on every sign and rank:
| in-repo model | plantsim.Plant |
|
|---|---|---|
≥ 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 |
3. The derivation
The constraint, in words, and it is the 2026-08-22 sentence made checkable:
The loop must not traverse its full output range faster than the plant takes to settle.
Three measured quantities, and each is named where it came from:
| value | where it was measured | |
|---|---|---|
| plant settling time | 18 s | goodwe/NOTES.md §5.10, 2026-08-22 — a 1.78 kW kettle step driven from HA off a plug that meters its own draw. ~18 s up to the deadband, ~23 s down. 18 is taken because it is the shorter and therefore the looser bound; this is a refusal boundary, and rules 5 and 6 refuse the impossible case only, never "close to the rail". |
| control tick | 2 s | the live install, measured 2026-09-05 — the setpoint stepped +1000 W every 2 s. run_control() runs a cycle on a changed meter value or on cycle_floor_s, whichever comes first, so the tick is the meter's rate. |
| 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 to cross | seconds to cross |
|---|---|---|---|
| 1500 (where the original was derived) | 167 W | 9 | 18 s |
| 2000 (shipped default) | 222 W | 9 | 18 s |
| 5000 (the live install) | 556 W | 9 | 18 s |
| 9600 (with inverter B — datasheet, not measured) | 1067 W | 9 | 18 s |
And the configuration that was measured failing: slew_w 1000 at max_w 5000
crosses the rail in 5.0 ticks = 10 s, against a plant that takes 18 s.
⚠️ The tick is the weak term and it is not wrong in the safe direction
A slower meter makes this bound conservative — it refuses configurations that
would have been fine, which is visible immediately and costs one edit. A meter
faster than 2 s makes it loose by exactly that ratio and nothing in this
add-on would say so. run_control()'s await asyncio.sleep(1) bounds the tick
below at 1 s, so the worst case is a factor of two — but if a faster meter is
ever fitted, LOOP_TICK_S has to be re-derived, not merely re-read. Compare
validate.FIRMWARE_INGEST_VETO_S, which carries the same kind of copy and can
only ever be wrong the other way; that difference is why both numbers are
written out in control.py rather than their quotient.
⚠️ The rig cannot pick 556 out of the table, and is not asked to
tools/slew_sim.py --slew, worst case over 51 export steps:
slew_w 1000 ← 2026-09-05 live 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 700 step 700 W 0/51 ≥900 W over-absorbed 1133 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 500 step 500 W 0/51 ≥900 W over-absorbed 1029 W
slew_w 400 ← the live interim step 400 W 0/51 ≥900 W over-absorbed 1150 W
slew_w 300 step 300 W 0/51 ≥900 W over-absorbed 1143 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 table is flat and slightly non-monotone, and 800 is better than 556 on over-absorption. So the rig does not choose the number — the constraint chooses it, and the rig says what the constraint does and does not buy. That is the same relationship the 2026-08-22 table had to its own three columns.
⚠️ FORWARD REFERENCE, ADDED BY SAFETY-48 — THIS TABLE AND THIS SENTENCE ARE
TRUE OF release/1.0's LAW AND FALSE OF THE ONE THAT REPLACED IT. Nothing above
is retracted: re-run on release/1.0 today the same rig prints the same column
(958 / 1107 / 1133 / 1058 / 987 / 1029 / 1150 / 1143 / 1081 W), flat,
non-monotone, 800 better than 556. Under SAFETY-48's lead cap the same script
on the same plant prints 777 / 734 / 663 / 563 / 553 / 507 / 435 / 358 / 321 W
— falling monotonically, so a slower slew is now better and the sentence above
inverts. The mechanism SAFETY-39 identified is exactly why: slew_w bounds the
OUTPUT while the integrator goes on accumulating gain · error, so slowing the
slew used to buy nothing; bound the integrator's lead and it starts to.
tools/slew_sim.py --slew now names which of the two laws it is running under
before it narrates the column, and
DECISIONS/SAFETY-48-bounding-the-integrator-lead.md §6 carries both tables and
the independent confirmation from test_control.py's own plant model. ⚠️ This
does NOT re-open the choice of 556: the constraint picks the number, the rig
does not, and that is unchanged in both laws.
⚠️ A single step is not evidence here. The deadband, the 10 W quantisation
and the phase of the step against the tick move the result by hundreds of watts,
non-monotonically. The first cut of this derivation was a single step and ranked
gain 0.3 as worse than 0.6, which the family shows is backwards. Every number
above is a worst case over a family, which is how admin/goodwe grades the same
kind of quantity (config.yaml's "1890 closed-loop runs", "924 runs").
4. 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 it is 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 is never silently reduced: the live
install runs 400, and silently reducing an operator's stated bound is case (a)
in validate.py's own docstring — the defect that whole module exists to
prevent.
validate.py rule 10 refuses an explicit slew_w above
SLEW_RAIL_FRAC × site rail. Graded against sum(unit_caps(opts)), the
roster sum — the same number Controller.site_max_w builds — and not against
the flat max_w and not against the weakest unit. Rules 5, 6 and 8 take
min() because each grades one watchdog, one shaping claim, one pack. slew_w
is not a per-unit quantity at all: compute() emits the site target and the
allocator divides it afterwards.
⚠️ config.yaml's note on site_sign_disagree_w 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 a site
quantity against the flat key, and it is answered rather than ignored:
unit_caps() already reads the roster the add-on will build, so the roster sum
is available at startup, and site_rail_w() is that number.
5. ⭐ Verified by breaking it
test_validate.py, section 10 — the acceptance criterion written as a test:
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 …
and the four things the refusal has to say, each graded separately: which rail
it used, how long the crossing takes, which measurement it is graded against,
and what to do about it (including 0, which cannot go stale). Plus:
- equality starts — at exactly the derived value the crossing takes exactly the settling time, which is the constraint met, not violated (rule 6's case, not rule 5's; getting it backwards would refuse the recommended value);
- one watt of slack starts — a boundary that is not meaningful to one watt must not refuse on one watt;
- the live interim
slew_w: 400starts, and so does the schema floor — there is no such thing as too slow here, and refusing it would refuse the mitigation that is holding; slew_w: 0starts at every rating from 100 W to 15 kW.
test_control.py grades the same boundary from the law's side, so the rule can
only ever refuse what the ratio says is too fast, and the two cannot drift apart.
5b. What the change found in this repository's own tests
Five files moved, and none of them moved because a number was rounded differently. Each is worth reading as a finding.
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. Both were typed when those rails
were new; nothing in either file had noticed. Rule 10 refused them on its first
run, so those fixtures would not start. That is what a rule about a
relationship is for, and it is the clearest evidence the rule is not a
formality.
Two reproductions depended on a default they never named.
test_alloc.py's close_loop and test_freeze.py's "make both terms
reachable" case reached slew_w: 1000 through Tuning's default. ARCH-01's
4000 W broadcast limit cycle and SAFETY-18's proof-of-capability alarm would
both have gone quiet — reporting a measured finding as absent because a
default moved underneath them. Both now write the number beside the measurement
it belongs to.
One harness had a latent off-by-one that only a ramping loop could expose.
test_watchdog.py graded SAFETY-02's banner by pairing c.grid with
last_write sampled after cycle(), while the detector is evaluated
inside cycle() against the standing command from the previous one. At
slew_w: 1000 the loop reached its rail in two cycles and consecutive commands
were identical, so the wrong pair and the right pair were the same numbers. At a
rail-following slew they differ by one step and the check went red. _drive()
now has a pre hook that reads the operands the detector will actually use, and
the check reads the latching cycle rather than the one before it. The
assertion is strictly stronger than it was.
One tripwire had two assertions that were exact by coincidence.
test_peakguard.py part 10c read limited_discharging == limited (49/49) and
min_soc <= bms_soc_floor + 0.05. At the slower slew the loop drains that pack
~82 Wh less over the same two hours and no longer touches the cut-out inside
that window: 48/49, low-water 10.57 % against a 10 % cut-out, 0 cycles at the
BMS limit instead of 190. The finding is unchanged and is sharper on the
measure that carries it — 162 cycles of granted discharge below the EMS floor,
up from 138. The two exact equalities were artefacts of one tuning, the old and
new numbers are tabulated in the file beside the checks, and the assertions now
grade the substance. ⚠️ Loosening an assertion to get a branch green is the
first thing a reviewer should suspect here; the table is there so the suspicion
can be settled rather than argued.
6. ⚠️ gain: 0.6 — measured, and deliberately NOT changed
The card asks whether gain still holds at this clamp. It does not, and here
is the measurement.
tools/slew_sim.py --gain, slew_w at the derived 556 W, same 51-step family:
gain 0.6 ← shipped over-absorbed 987 W meter driven to +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 the slew column in §3, which is flat. The mechanism is visible in
control.py and is structural rather than empirical:
slew_wbounds the OUTPUT.- The integrator accumulates
gain × errorper cycle and is bounded only byintegrator_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, whateverslew_wis.
So the 2026-08-22 derivation's own sentence — "the gain, not the slew, is the
limit by then" — has become literally true, and gain is the term that owns
the over-absorption at this rail. It is written about a 1500 W rail, where
gain × 1500 = 900 W and the clamp caught it in two cycles; at 5000 there is
3.3× more room to wind before the clamp binds.
Why nothing was changed:
gainis a live control-law constant on a running house. Two of them moving in one release makes the next measurement uninterpretable — and the interimslew_w: 400has been in service since 2026-09-05 12:00, so there is a clean baseline to measure the next change against and it would be destroyed.- A validator rule pairing
gainwith a rail would be inventing a relationship nobody has measured. The bound would have to begain × error_max ≤ SLEW_RAIL_FRAC × max_w, anderror_maxis the disturbance, which is not config and is not bounded. integrator_max_wis not the lever it looks like: it bounds the output as well as the accumulator (target = clamp(i_w, ±max_w)), which is why setting it to 2000 on the live system on 2026-09-05 capped the inverter to 2000 W of its 5000 and was reverted the same day.
This is named in validate.py's "deliberately not refused" list so that it is
a known open item rather than an omission, and it is asserted in
test_control.py so that nobody can quietly read this card as having fixed it.
It wants its own ticket.
7. 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, not a measurement: GW5048D-ES, serial
55048ESU22AW0992, 4600 W, pack 4 × Dyness DL5.0C = 20.48 kWh, floor 10 %
(goodwe/NOTES.md §1.1b) — and the 4600 W is printed under battery input data,
so it may be DC, in which case the AC ceiling is nearer 4324 W.
⚠️ And sim/plantsim.py's INVERTER_B is inverter A copied, carrying
measured=False. Every two-inverter row below is a result about two copies of
A on a 10000 W rail, not about the ~9600 W site B implies.
The site rail becomes sum(max_w) ≈ 9600 W while slew_w is per-site. The
derived value there is 0.1111 × 9600 = 1067 W per tick, which at an even split
is 533 W per unit against each unit's own 5000/4600 W rating — ARCH-01's
split-invariance holding, as its two_master_gain_sweep already 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 inverter B moves both,
with no operator action and no second derivation. That is the property that
makes this a fix rather than a re-tune, and test_validate.py asserts the
9600 W arithmetic directly.
⚠️ But the direction of the rule is not the one the ticket assumed, and it
must be said. 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 (that is
each unit's own max_w through the arbiter's supervised claim) and it does
not bound the over-absorption (§6).
⚠️ 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 written on the absolute 900 W would report the correct tuning as broken. Anything grading this symptom at N ≥ 2 has to grade it as a fraction of the rail too — which is this whole card, one layer out.
8. max_w: 5000 — a recommendation, not a change
The battery reached 5154 W against a 5000 VA inverter, so the configured cap left no headroom for the overshoot that the loop was going to produce anyway. Three things are worth writing down and none of them is changed here:
- The cap is the rating, so the overshoot has nowhere to go but past it.
compute()bounds the command to±max_w; nothing bounds the plant's response to a command at the rail, and §6 shows the loop asks for the rail on any large step at gain 0.6. Recommendmax_wat 4500–4750 W, i.e. 90–95 % of the 5000 VA nameplate, leaving 250–500 W for the overshoot the loop still has. This isfreeze.py'sRAIL_FRACargument applied to the cap itself. - The −5154 W may not be an AC figure at all.
batt_entityis the pack sensor andmax_wis graded against0x0518, the inverter's AC power —config.yaml's roster comment already records exactly this ambiguity for inverter B. If the battery reading is DC, 5154 W DC is ~4900 W AC and the inverter was never over its nameplate. Nobody has measured which, and the recommendation above is worth making either way. - It is the owner's number.
translations/en.yamlmarksmax_w"THIS IS YOUR NUMBER" precisely so it is not confused with a derived constant, and changing it unilaterally in a pull request would undo that distinction.SAFETY-20'smin(max_w, dev_max)settles the device side once ESPHome has answered; the headroom question is a policy choice and belongs to the owner.
9. What was deliberately not done
saturation_wand the deadband were not touched. The card forbids it and it is right to: widening either hides the symptom. The loop is following its command correctly; the command is wrong.gainwas not changed. §6.max_wwas not changed. §8.control.py's slew limit was not clamped to the bound. An explicit value above it is refused at startup, not quietly reduced — silently reducing an operator's stated bound is case (a) invalidate.py's docstring.- Nothing in admin/goodwe was modified, and nothing reached the inverter.