checks / unit (pull_request) Successful in 5m16s
`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
439 lines
23 KiB
Markdown
439 lines
23 KiB
Markdown
# 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.
|
||
|
||
⚠️ **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.
|