Files
goodwe-addon/goodwe_controller/DECISIONS/SAFETY-39-slew-against-the-rail.md
T
glenn schrooyenandClaude Opus 5 a6af332048
checks / unit (pull_request) Successful in 7m55s
SAFETY-48: what the lead cap actually does to a frozen integrator, and what the peak figure depends on
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
2026-09-19 02:37:01 +02:00

24 KiB
Raw Blame History

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

  1. 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.
  2. The deliverable is the relationship, not the number. slew_w: 0 now means follow the rail and resolves to SLEW_RAIL_FRAC * max_w; validate.py rule 10 refuses an explicit value faster than that against the site rail. Verified by breaking it. §4, §5.
  3. ⚠️ slew_w does not own the over-absorption. gain does, and gain was 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: 1000 is 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_w on 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: 400 starts, 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: 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 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_w bounds the OUTPUT.
  • 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.

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:

  • gain is a live control-law constant on a running house. Two of them moving in one release makes the next measurement uninterpretable — and the interim slew_w: 400 has 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 gain with a rail would be inventing a relationship nobody has measured. The bound would have to be gain × error_max ≤ SLEW_RAIL_FRAC × max_w, and error_max is the disturbance, which is not config and is not bounded.
  • integrator_max_w is 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:

  1. 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. Recommend max_w at 4500–4750 W, i.e. 90–95 % of the 5000 VA nameplate, leaving 250–500 W for the overshoot the loop still has. This is freeze.py's RAIL_FRAC argument applied to the cap itself.
  2. The −5154 W may not be an AC figure at all. batt_entity is the pack sensor and max_w is graded against 0x0518, 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.
  3. It is the owner's number. translations/en.yaml marks max_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's min(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_w and 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.
  • gain was not changed. §6.
  • max_w was 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) in validate.py's docstring.
  • Nothing in admin/goodwe was modified, and nothing reached the inverter.