diff --git a/.gitea/infra-pin b/.gitea/infra-pin index 225b483..f01d2b5 100644 --- a/.gitea/infra-pin +++ b/.gitea/infra-pin @@ -1,2 +1,2 @@ # The admin/goodwe commit CI tests against. Bump: set to the full SHA of admin/goodwe release/1.0 HEAD (README.md, "CI and the infra pin"). -0c04488a9194039c2bf851b680a8ffc53beff671 +3b4ff84757f9c2c5b63a4b4a53e593ccaeafb847 diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 679b372..b9316a5 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +## 0.15.0 + +**A minor bump: changed behaviour, and no option changes.** No option was +added, removed or renamed, and no default changed (83 options). Options that +start 0.14.0 also start 0.15.0. The changes below apply with one inverter and +with two. + +The add-on now learns and saves, per battery, what its power reading shows +at rest (`unit//idle_w`). It learns it after about two quiet minutes and +uses 0 W until then, as before. There is nothing to set. + +Contents of this release, in merge order since 0.14.0 (`02ed120`): + +| | | +|---|---| +| `cf705a9` | No more false "battery power reading stopped changing" errors at a new command after a rest, or at a change of phase. Each one paused control for a few seconds (several a day on a live site). A healthy battery that lags or turns around is no longer held as refusing for up to 15 min. With two inverters, a refusing battery whose reading at rest is about −100 W is now caught too. The rest value used is limited to ±75 W ([[SAFETY-94]], [[SAFETY-97]], also fixes [[SAFETY-58]]) | +| `3129dae` | CI tests against a newer `admin/goodwe` (CI only) ([[CI-14]]) | + +### Known issues + +- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]). +- After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]). +- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]). +- A battery whose reading at rest is beyond about ±170 W is still not caught when it refuses, as in 0.14.0 ([[SAFETY-97]]). +- Two inverters: if one battery's drain stops early (e.g. switched Off), the other battery can jump to about 1.6 kW at once, and the surplus goes to the grid ([[SAFETY-95]]). +- Two inverters: after one battery's link drops, control can stall until a safety stop ([[SAFETY-96]]). +- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]). +- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]). + +### If you are crossing from a version older than 0.10.0 + +`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the +withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and +`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the +bare options (`jq '.data.options'`) first. + ## 0.14.0 **A minor bump: new behaviour with two inverters, and no option changes.** diff --git a/goodwe_controller/DECISIONS/SAFETY-94-lagging-battery.md b/goodwe_controller/DECISIONS/SAFETY-94-lagging-battery.md new file mode 100644 index 0000000..49a043d --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-94-lagging-battery.md @@ -0,0 +1,102 @@ +# SAFETY-94 - a battery that lags or reverses is not frozen or refusing + +**Status: implemented 2026-10-03, for review.** Tests: `test_safety94.py` +(rig: `tools/safety94_rig.py`). Also closes SAFETY-58. SAFETY-74 stays open. + +## The defects + +1. **Stale-reading gate (SAFETY-37).** Its 35 s ran from the register's last + change. After a rest that clock was already past 35 s, so the first read + after a new command tripped it, before the register could answer. Live + 2026-10-03 (0.14.0, N=2): 17:39:16 B and 17:39:25 A (load ramp after a + rest), 19:00:04 A (drain start), 21:21:22 B (drain -> charge). Each was + 1 s after its command; each register answered within 11 s. This is + SAFETY-58's shape. +2. **Refusal at N=1 (SAFETY-52).** `compute()` engaged after `SETTLE_CYCLES` + frozen cycles if the reading was under 100 W at that moment. The frozen + cycles include a reversal through the battery's lag. At a 1 s meter the + 12th cycle comes as a battery reversing out of a charge passes 0 W + (SAFETY-85 rig, O1: "gave -20 W for 13 cycles"). The refusal then held + 0 W, the battery settled at 0 W, never gave 100 W, and waited for the + 15-minute probe. N>1 already required 22 s in the band, so it was safe + at a 10 s lag but not at 20 s. + +## What changed + +- **The gate counts only time the register was asked to move** + (`freshness.Freshness._oblige`). A read counts the time since the last + read when, at that read, the command is 250 W or more and has moved 250 W + from where it stood at the register's last change. A register change + (or a SAFETY-57 release) restarts the count. A command flapping in and + out of the ask still adds up (frozen register, 0/1500 W every 5 s: trip + at 69 s). The conjunction, `MOVE_W` and `RELEASE_S` are unchanged. +- **The bound is unchanged at 35 s and now means what its derivation + says.** The floor was always "how long a healthy register takes to answer + a command": 6 s plant + 10 s publish + 1 s read = 17 s. The band + `[2 x 17, 0.5 x 75] = [34, 37.5]` holds. Live answers measured: 1.4, 3, + 6 and 11 s after the command. +- **Refusal evidence is the same at every roster size** + (`main.Controller._gave_nothing`, SAFETY-73's count moved into a method). + The reading must stay under 100 W, one way, while asked over 200 W, for 12 + cycles spanning 22 s. At N=1 `compute()` gets this as `gave_nothing` and + needs it on top of its own freeze test. `None` (compute alone) keeps the + old law. +- **The evidence restarts when the reading moves 50 W towards the ask** + (`REFUSED_PROGRESS_W`). A refusing battery reads flat (-3/-4 W live). Live + readings wander by under 25 W at rest. A first-order lag crossing the band + gains 50 W in at most 0.4 tau, so it cannot fill 22 s unless tau > 54 s. + This also covers B, whose reading is 100 W below its power. + +## Bounds and worst-case catch + +| fault | before | after | budget | +|---|---|---|---| +| register frozen, then asked to move | 0-35 s after the ask (often 1 s) | 35 s after the ask (+1 read) | SAFETY-37: 35 s, under the 75/85 s consumer holds | +| register frozen at rest reading ~0 W | stale 1 s after the ask | refused or stale, 0 W within 23-35 s | the same | +| refusing battery, 1 s meter, N=1 | 11-16 s | 22-28 s | SAFETY-73's 22 s span (+ one 10 s publish) | +| refusing battery, 2 s meter | 22-28 s | 22-28 s | unchanged | +| refusing battery, 5 s meter | N=1 35-41 s, N=2 41-55 s | N=1 40-45 s, N=2 50-55 s | 12 cycles = 55-60 s | + +Rig: `test_safety94.py` part 3. A release after SAFETY-57 still exposes +the held number for one 35 s window of asking, so 35 < 75 < 85 still holds. + +## Before -> after + +- Live replay (part 1): 4 trips -> 0. +- Refusing B (its -100 W idle reading): see SAFETY-97. +- Rig, 10 s lag, N=1 and N=2, 1/2/5 s meters, two register shapes, 48 runs + (part 2): before 27 stale trips and 1 refusal, after 0 and 0. +- Reversals, lag 10 and 20 s, 1 s meter (part 2): before N=1 6 stale / 3 + refused, N=2 6 / 1; after 0 / 0. +- 16-day loop capture (`test_freshness.py` part 8): 93 trips -> 59. Trips + answered within 20 s: 21 -> 1. Two of the removed trips were not quick: + a register held 351 s under a command hovering at the 250 W travel edge + (under 35 s of asking before it moved; survivor 2's shape), and a + refusing battery the gate had caught only on a passing ramp (SAFETY-52's + refusal covers it). + +## SAFETY-58 and SAFETY-74 + +- **SAFETY-58 is fixed** by this change: its card asks for the clock to + start with the obligation and for the bound to be re-derived. Both are + done above. `test_freshness.py` survivor 5 is now graded as closed. +- **SAFETY-74 is not fixed.** A battery capped at 300 W with a perfectly + flat reading, 5 s meter, house stepping: 50 trips/h at N=1 and 80 at N=2 + before, 37 and 60 after. With +-1 W dither: 0. A flat reading under a + command that moved is exactly what the gate is for; telling "limited" + from "frozen" needs a second signal (the meter, or SoC). + +## Known gaps + +- ~~B's standby offset (-95 to -118 W at idle) puts a refusing B outside the + 100 W band, so a refusing B never latched (review F1: at N=1 new, at N=2 + as before).~~ **Fixed by SAFETY-97 in the same PR:** the band is centred + on each unit's learned idle reading. `SAFETY-97-idle-offset.md`. +- A command that asks only part of the time (a fraction d) is caught after + 35 s of asking, which is 35/d s of wall time (0/1500 W every 5 s: 69 s). + SAFETY-30's per-unit check restarts when its command stops opposing the + held reading, which a lapsing ask normally does, so 35 < 85 holds there. + SAFETY-31's circulation check (75 s) ignores the command, so its margin + holds only while d >= 0.47. +- A register frozen under a command that stays near the 250 W travel edge + is caught late or not at all (survivor 2). diff --git a/goodwe_controller/DECISIONS/SAFETY-97-idle-offset.md b/goodwe_controller/DECISIONS/SAFETY-97-idle-offset.md new file mode 100644 index 0000000..e572314 --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-97-idle-offset.md @@ -0,0 +1,118 @@ +# SAFETY-97 - "gives nothing" is graded against each battery's idle reading + +**Status: implemented 2026-10-04, folded into PR #171 (SAFETY-94), for +review.** Owner decision 2026-10-04: the band's centre is clamped to ++-75 W (see Bounds). Tests: `test_safety97.py` (rig: `tools/safety94_rig.py`). +Closes SAFETY-94 review finding F1. + +## The defect + +B's power reading (board B's `inverter_ac_power`, 0x0518) shows -95 to -118 W +at idle: its standby draw (the GoodWe app shows 0 W). A's shows -2 to -4 W. +Every "the battery gives nothing" test was `|reading| < 100 W`, so a refusing +B sat on the band's edge and never latched: + +- N=2: never caught, before or after SAFETY-94. +- N=1: the old one-instant check caught it in 17-105 s; SAFETY-94's shared + rule (12 cycles in the band in a row) never did (review probe, 1200 s). + +## Design + +- **Offset source: learned per unit** (`app/idle.py`, `Inverter.idle`). No + new option: a required one is a setup step every client gets wrong, and an + optional one is a second source to reconcile. An override can be added + later if a site needs one. +- **When it learns:** control runs; the unit is not refused (unit or site), + stale or sign-tripped; its command has been within 50 W (`IDLE_ASK_W`) for + 120 s (`IDLE_QUIET_S`, 6 lags of 20 s). A refusing battery is asked more + than 50 W, and while refused it does not learn, so its refusal cannot be + learned as idle. A capped battery under a big ask is not sampled either. +- **How:** the first value is the mean of 120 s of samples; then it follows + drift with a 30 min time constant. A sample beyond 250 W (`IDLE_SAMPLE_W`) + is dropped (a register held at a giving value). The band is centred on + the value clamped to 75 W (`IDLE_MAX_W`; owner decision below). +- **Restart:** saved per unit (`unit//idle_w`, on first learn and each + 5 W move) and restored, clamped to 250 W. Without it a battery refusing from the + start is never quiet, so never learned, so never caught. +- **Until learned the idle reading is 0 W**: the rule as it was. + +## Not chosen: the GoodWe LAN integration's DC battery power + +Both inverters also appear through HA's GoodWe integration (B: +`sensor.kelder_dyness_battery_power`, 0 W at idle; found read-only on the +live HA 2026-10-04). Pointing `batt_entity` at it removes B's offset by +configuration, but: + +- the band must be safe without it: other installs may not have it; +- it is polled over UDP on the inverter's Wi-Fi, a second channel that can + drop while the board keeps reading (HW-01 lost both once); a missing + reading stops the site; +- SAFETY-37's 35 s bound is derived from the board's 10 s publish; the + integration's poll interval (believed 10 s, not checked here) and latency + are not measured; +- DC power is not 0x0518, which SAFETY-30/31/42 were measured against. + +It stays an install choice to test on the rig, not a code change here. With +it, the learned idle reading is simply ~0 W. + +## Where it applies + +Every test of "gives nothing / gives / takes" now uses `reading - idle`: + +| check | change | +|---|---| +| shared refusal evidence `_gave_nothing` (N=1 and N>=2) | centred | +| N=1 engage and end in `compute()` (`idle_w`, the site sum) | centred | +| N=1 end in `_refusal_update` | centred | +| N>1 unit end (probe answered, takes the other way) | centred; uncentred, B's idle -100 W read as "takes 100 W" and ended a charge refusal at once | +| stale-path refusal (`_refusal_from_stale`, N=1 and N>1) | centred | +| SAFETY-42 "parked" (small ask, gives nothing) | centred | +| SAFETY-37 stale gate | unchanged: it grades register changes against command travel, not a level | +| SAFETY-57 release | unchanged: it reads the command only | +| SAFETY-30 per-unit sign | unchanged: opposition needs both over 250 W; B's 100 W idle cannot make or hide one alone | +| SAFETY-42 shortfall | unchanged: B reads ~100 W short at every power, which errs towards reporting (report only) | + +## Bounds: owner decision 2026-10-04 (review R1) + +Band: `|reading - centre| < 100 W`, centre = idle clamped to +-75 W. + +- **Why a clamp under the band.** A wrong idle reading (taught during a + rest while the reading shows something else, e.g. a grid top-up of a unit + held at 0 W, or restored onto a swapped inverter) more than about 100 W off + hid every refusal on that battery, at N=1 and N=2; with the first +-250 W + clamp the rig missed a refusing A whenever the error was 110-250 W. At + +-75 W the band always keeps 0 W +-25 W, so a battery reading about 0 W + while refusing is caught whatever was learned or restored, and a wrong idle + cannot refuse a healthy 0 W battery (its ask is over 200 W). +- **The trade-off.** Options: keep +-250 W (covers idle readings to about + +-350 W, any 100 W mis-learn hides refusals); clamp to +-75 W (chosen); + "old band or centred band" (rejected: a correct B would need to give 200 W + to answer its 200 W probe). +- **Cost.** A battery whose idle reading is beyond about +-170 W is centred + at most 75 W out, so its refusal falls outside the band: it behaves as in + 0.14.0 (not caught by this rule). B (-95 to -118 W) reads 20-43 W from its + clamped centre. B is caught across -150..+90 W of error in its idle + reading; learned 110 W or more too high it is missed, as in 0.14.0 at N=2. +- A frozen register is still caught by the stale gate at 35 s of asking. +- Recovery from a wrong value needs quiet time: tau 30 min. + +## Catch times (rig, refusing from 200 s after a 400 s rest, +-4 W) + +| refusing | meter 1 s | 2 s | 5 s | +|---|---|---|---| +| A, N=1 | 28 s (before 28) | 28 (28) | 65 (65) | +| A, N=2 | 28 (28) | 28 (28) | 110 (110) | +| B, N=1 | 28 (never) | 28 (never) | 65 (never) | +| B, N=2 | 24 (never) | 24 (never) | 60 (never) | + +A at N=2 on a 5 s meter is 110 s here because the rig's grid goes flat once +A is cut, so the loop cycles on the 15 s floor; the same run on the PR before +SAFETY-97 gives 110 s. B restored after a restart, refusing from the start: +24 s; with nothing saved: never. + +## Before -> after + +- Healthy B with its offset, SAFETY-94's four scenarios, N=1 and N=2, 1/2/5 s + meters, both register shapes, idle learned or restored (96 runs): 0 stale, + 0 refused. Reversals, lag 10/20 s, idle restored: 0. +- Live replay (SAFETY-94 part 1): 0 trips (the gate is untouched). diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index a5b659f..7ea88f6 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -590,7 +590,7 @@ accumulator is doing: | `freeze-released` | the freeze had run out of plant-shaped explanations and the command was let move again | | `mode-bound` | the batteries' limits set the command, not the meter: their battery modes, or, with two or more inverters, a battery in its monthly maintenance | | `not-running` | control is stopped **and** shadow mode is off, so the loop did not run | -| `battery refuses` | the battery gave less than 100 W for 12 cycles (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) | +| `battery refuses` | the battery gave less than 100 W for 12 cycles and at least 22 s (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) | | `battery probe` | the add-on asks 200 W to test whether a refusing battery gives power again | **When the battery refuses.** At its cut-out level the battery gives nothing, @@ -600,7 +600,11 @@ request, and the `Why` row reads *The battery refuses to give power: 0 W*. Every the battery gives 100 W or more, when the grid needs charging instead, and when control is stopped or started. 200 W is below the 250 W the stale-reading check needs, so a test never trips it. A battery that gives some power, even 100 W, -is not refusing and keeps its request. +is not refusing and keeps its request. Nor is a battery whose reading is +still moving towards the request, such as one turning from charging to +giving (SAFETY-94). "Gives" is counted from what the battery's reading shows +at rest, which the add-on learns after two quiet minutes and keeps: some +inverters show about -100 W at rest (SAFETY-97). **With two or more inverters** the same rule works per inverter. An inverter whose battery gives less than 100 W for 12 cycles, and at least 22 s, while @@ -3457,7 +3461,8 @@ reason code, and its full figures are on the DEBUG line right after it and in …`, and the peak guard's full reason is on the DEBUG line after its ERROR. - ***name*: battery power reading stopped changing** is SAFETY-37/56/57. The ERROR line says when it clears and names `STALE_READING`. The full reason, - with both possible causes, is on the DEBUG line after it. + with both possible causes, is on the DEBUG line after it. The 35 s counts + only while the command asks the reading to move (SAFETY-94). - **Meter: no reading for …** (red): the add-on logs one WARNING when the meter goes quiet, "No meter reading for …", and an INFO line when readings return, at most once every 10 minutes. Past `stale_input_s` the **Why** diff --git a/goodwe_controller/app/control.py b/goodwe_controller/app/control.py index 1f2713a..3484292 100644 --- a/goodwe_controller/app/control.py +++ b/goodwe_controller/app/control.py @@ -484,6 +484,8 @@ def compute( refused: int | None = None, probe: bool = False, bound: tuple | None = None, + gave_nothing: bool | None = None, + idle_w: float = 0.0, ) -> Decision: """One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1). @@ -497,6 +499,13 @@ def compute( `bound` OPT-11: `(lo, hi)`, what the batteries may place this tick with their modes applied (`allocator.loop_bound()`), or None. See MODE_BOUND below. None is the law as it was. + `gave_nothing` SAFETY-94: the caller's span check - the reading stayed + under REFUSED_BELOW_W, one way, for SETTLE_CYCLES spanning + REFUSED_SPAN_S. A refusal engages only with it. None leaves it + out (compute() alone, as before). + `idle_w` SAFETY-97: what `actual_w` reads while the batteries give + nothing (app/idle.py). The refusal's band and its end are + centred on it. 0 is the law as it was. """ reason = "tracking" # OPT-11: a bound that leaves out 0 W would FORCE a direction, which no @@ -817,13 +826,18 @@ def compute( # `saturation_w` is a refusal, not a slow plant. The drop to 0 skips the slew # limit because it only removes an ask the battery is not meeting. if refused is not None: + # SAFETY-94: and only when the reading has given nothing for the + # whole span, not just now. `sat_count` also counts the cycles a + # healthy battery spends reversing through its lag (-2000 W towards + # +800 W): at a 1 s meter its 12 cycles end as the reading passes 0. if (refused == 0 and frozen and sat_count >= SETTLE_CYCLES and abs(prev_w) > tuning.saturation_w - and abs(actual_w) < REFUSED_BELOW_W): + and abs(actual_w - idle_w) < REFUSED_BELOW_W + and gave_nothing is not False): refused = 1 if prev_w > 0 else -1 if refused and refused * i_w < 0: refused = 0 # the loop wants the other way - elif refused and refused * actual_w >= PROBE_ANSWER_W: + elif refused and refused * (actual_w - idle_w) >= PROBE_ANSWER_W: refused = 0 # it delivers: a probe answered, # or a slow reading arriving elif refused: diff --git a/goodwe_controller/app/freshness.py b/goodwe_controller/app/freshness.py index 7281d7a..ba577c8 100644 --- a/goodwe_controller/app/freshness.py +++ b/goodwe_controller/app/freshness.py @@ -87,6 +87,10 @@ when it has not moved THROUGH A CHANGE IT WAS OBLIGED TO FOLLOW: AND |standing command now - standing command when the reading last changed| >= MOVE_W +SAFETY-94: "unchanged for" counts only the reads at which both command terms +held, so a register that rested is not judged before it could answer. +DECISIONS/SAFETY-94-lagging-battery.md. + 0x0518 is the inverter reporting ITSELF and it tracks its own command: r = 0.998 with battery power against r = 0.09 with the real meter (goodwe/NOTES.md §3.3), command and actual agreeing within 4 W once settled (§5.10, 2026-08-22), and the @@ -216,6 +220,8 @@ change - which was the ONLY exit - has a median of 143 s, 17 episodes at or over the live one's 710 s, and a worst case of 21440 s. That distribution is a property of this battery's firmware. It is not a property of this code, and the add-on must not depend on it. +SAFETY-94: many were the gate judging a rested register on its first read +(SAFETY-58). Counted from the ask, the same days give 59 trips. THE RULE, and it is SAFETY-13's shape one module over - "which latches get an exit, not how long the block lasts": @@ -372,7 +378,9 @@ BOUNDS = { "SAFETY-57: the verdict is withdrawn after 17.0 s of the " "standing command below 250 W, because at 0 W the register " "is obliged to nothing and the verdict cannot be tested " - "again from inside the state it creates"), + "again from inside the state it creates. SAFETY-94: the " + "35 s counts only reads at which the command asks the " + "register to move"), "soc": Bound( signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)", unchanged_s=float("inf"), @@ -416,11 +424,9 @@ class Freshness: new grading window at that instant. The 1 Hz oscillation the latch exists to prevent is still prevented: the new window is `bound.unchanged_s` wide, so no re-trip can land sooner than 35 s after a release, rather than on the - next read. ⚠️ MEASURED FROM THE RELEASE, NOT FROM THE COMMAND RESUMING - if - the site rests for longer than the window before it asks for anything, the - window is already old and the re-trip lands on the first read at which the - command travels 250 W again. That is survivor 5's shape, it predates this - card, and its cost is one `stale_input_s` hold. + next read. SAFETY-94: the 35 s counts only while the command asks the + register to move, so a site that rests after a release is not re-tripped + on its first ask. ⚠️ TWO CLOCKS, AND THE DISTINCTION IS THE WHOLE OF SAFETY-57'S HONESTY. `changed_mono` / `held` are facts about the REGISTER: when it last carried a @@ -451,6 +457,11 @@ class Freshness: # the change-stamps; `_release()` is where they diverge. self.graded_from = None self.standing_at_grade = None + # SAFETY-94. Seconds of this window during which the register was + # obliged to move (the command at least `move_w` and `move_w` away + # from `standing_at_grade`), and the instant of the last read. + self.obliged_acc = 0.0 + self.last_read = None # The instant the standing command last FELL below `move_w`, or None # while an obligation is standing. The release timer runs off this. self.rested_since = None @@ -499,6 +510,7 @@ class Freshness: # it reads in the order the state machine moves - withdraw, then # re-grade - not because the code depends on it. self._release(standing_w, now_mono) + self._oblige(standing_w, now_mono) self._grade(standing_w, now_mono) def _open_window(self, standing_w, now_mono) -> None: @@ -510,6 +522,35 @@ class Freshness: self.graded_from = float(now_mono) self.standing_at_grade = (None if standing_w is None else float(standing_w)) + # The read that opens a window is never an ask (no travel yet). + self.obliged_acc = 0.0 + + def _obliged(self, standing_w) -> bool: + """Is the register asked to move: the command is at least `move_w` and + has travelled `move_w` since the window opened?""" + if standing_w is None or self.standing_at_grade is None: + return False + standing_w = float(standing_w) + return (abs(standing_w) >= self.bound.move_w + and abs(standing_w - self.standing_at_grade) + >= self.bound.move_w) + + def _oblige(self, standing_w, now_mono) -> None: + """SAFETY-94. Count only the time the register was asked to move. + + A register that held still while nothing asked it to move has not + failed, so time before the command moved is not evidence. The time + since the previous read counts when the obligation stands at this + read; a command flapping in and out of the obligation still adds up. + """ + now_mono = float(now_mono) + if self.last_read is not None and self._obliged(standing_w): + self.obliged_acc += max(0.0, now_mono - self.last_read) + self.last_read = now_mono + + def obliged_s(self) -> float: + """Seconds of the current window the register was obliged to move.""" + return self.obliged_acc def _release(self, standing_w, now_mono) -> None: """SAFETY-57. Withdraw a verdict whose obligation is no longer standing. @@ -543,7 +584,12 @@ class Freshness: if self.stale or self.graded_from is None: return held_s = self.graded_s(now_mono) - if held_s < self.bound.unchanged_s: + # SAFETY-94 (closes SAFETY-58): the bound counts only the time the + # command asked the register to move, not the time since it last + # changed. A register that rested for minutes was otherwise judged on + # the first read after a new command, before it could answer (live, + # 2026-10-03: 1 s after the command). + if self.obliged_s() < self.bound.unchanged_s: return # ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN # OBLIGATION YET, WHICH IS NOT `_read_unit`. `Inverter.standing_w()` diff --git a/goodwe_controller/app/idle.py b/goodwe_controller/app/idle.py new file mode 100644 index 0000000..513d210 --- /dev/null +++ b/goodwe_controller/app/idle.py @@ -0,0 +1,87 @@ +"""SAFETY-97: what a battery's power reading shows while it gives nothing. + +B's reading (0x0518) shows -95 to -118 W at idle (standby draw, live +2026-10-03); A's shows -2 to -4 W. "The battery gives nothing" is graded +against this idle reading, not against 0 W, or a refusing B never latches. +DECISIONS/SAFETY-97-idle-offset.md. + +Learned per unit, never configured: +- only while control runs, the unit is not refused, stale or tripped, and + its command has been within IDLE_ASK_W for IDLE_QUIET_S (a refusing battery + is asked more than that, so it cannot teach its refusal as idle); +- a reading beyond IDLE_SAMPLE_W is not a sample; +- first value: the mean of IDLE_FIRST_S of samples; then a slow follow + (IDLE_TAU_S); +- persisted per unit and restored, clamped to IDLE_SAMPLE_W, at start. +The band is centred on it clamped to IDLE_MAX_W (owner, 2026-10-04). Until +learned the idle reading is 0 W, the rule as it was. +""" + +import math + +IDLE_MAX_W = 75.0 # |idle| used: under the 100 W band, so it keeps 0 W +-25 W +IDLE_SAMPLE_W = 250.0 # a reading beyond this is not a sample +IDLE_ASK_W = 50.0 # a command this small counts as "asked ~0" +IDLE_QUIET_S = 120.0 # 6 lags of 20 s: a 2500 W step has decayed to 6 W +IDLE_FIRST_S = 120.0 # seconds of samples averaged for the first value +IDLE_TAU_S = 1800.0 # then follow drift this slowly +IDLE_SAVE_W = 5.0 # persist when the value moved this far +IDLE_GAP_S = 10.0 # a longer gap between reads counts as this + + +def _num(v): + try: + v = float(v) + except (TypeError, ValueError): + return None + return v if math.isfinite(v) else None + + +class IdleReading: + """One unit's idle reading. `w` is what "gives nothing" is centred on.""" + + def __init__(self, saved=None): + v = _num(saved) + self.value = (None if v is None + else max(-IDLE_SAMPLE_W, min(IDLE_SAMPLE_W, v))) + self.saved = self.value + self.quiet_since = None + self.last = None + self.acc_w = 0.0 + self.acc_s = 0.0 + + @property + def w(self) -> float: + """The centre used: the learned value clamped to IDLE_MAX_W.""" + if self.value is None: + return 0.0 + return max(-IDLE_MAX_W, min(IDLE_MAX_W, self.value)) + + def observe(self, reading, standing_w, now_mono, may_learn) -> bool: + """One read. Returns True when the value should be persisted.""" + now = float(now_mono) + dt = (0.0 if self.last is None + else max(0.0, min(IDLE_GAP_S, now - self.last))) + self.last = now + standing = _num(standing_w) + if not may_learn or standing is None or abs(standing) > IDLE_ASK_W: + self.quiet_since = None + return False + if self.quiet_since is None: + self.quiet_since = now + b = _num(reading) + if (now - self.quiet_since < IDLE_QUIET_S or b is None + or abs(b) > IDLE_SAMPLE_W or dt <= 0.0): + return False + if self.value is None: + self.acc_w += b * dt + self.acc_s += dt + if self.acc_s < IDLE_FIRST_S: + return False + self.value = self.acc_w / self.acc_s + else: + self.value += (b - self.value) * min(1.0, dt / IDLE_TAU_S) + if self.saved is None or abs(self.value - self.saved) >= IDLE_SAVE_W: + self.saved = self.value + return True + return False diff --git a/goodwe_controller/app/inverter.py b/goodwe_controller/app/inverter.py index 328c9fa..1816811 100644 --- a/goodwe_controller/app/inverter.py +++ b/goodwe_controller/app/inverter.py @@ -127,6 +127,7 @@ from . import messages # SAFETY-37. The freshness policy, and the ONE place its bounds are written. # freshness.py imports nothing from this package, so the direction is one-way. from .freshness import BOUNDS, Freshness +from .idle import IdleReading # CFG-03's ONE in-code mirror of config.yaml's SAFETY-24 defaults. Imported # rather than retyped: the flat-keys path below needs `bms_soc_floor`'s # shipped default and a literal here would be the fourth defaults site that @@ -321,13 +322,21 @@ class Inverter: # take, 0 none), when that refusal or its last probe started, the # current probe's start, and the cycles of evidence so far and when # they began. Memory only, like SAFETY-52's: stop and start end it. - # Used at N>1 only. + # The evidence count is also used at N=1 (SAFETY-94); the rest at N>1. self.refused = 0 self.refused_at = None self.probe_from = None self.ref_count = 0 self.ref_since = None self.ref_sign = 0 + self.ref_from = None + # SAFETY-97: what this unit's reading shows at idle; "gives nothing" + # is centred on it. Learned, persisted, restored (app/idle.py). + self.idle = IdleReading(data.get(self.key("idle_w"))) + + @property + def idle_w(self) -> float: + return self.idle.w # -- store --------------------------------------------------------------- def key(self, name: str) -> str: diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 6aeb8fb..b2e7ebf 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -281,6 +281,14 @@ def pack_energy_of(inv): # ordinary load steps. At 2 s and slower this changes nothing. REFUSED_SPAN_S = (_ctl.SETTLE_CYCLES - 1) * _ctl.LOOP_TICK_S +# SAFETY-94: the evidence restarts when the reading has moved this far towards +# the ask since the evidence began. A refusing battery reads flat (-4 W live, +# 2026-09-06); live readings wander by under 25 W at rest (B's standby, -95 to +# -118 W, 2026-10-03). A battery crossing the 100 W band on a first-order lag +# gains 50 W in at most tau x ln(151/101) = 0.4 tau, so the evidence cannot +# span REFUSED_SPAN_S unless the lag is over 54 s. +REFUSED_PROGRESS_W = _ctl.PROBE_ANSWER_W / 2 + # SAFETY-85 review: a battery another owner set is graded against the least it # was told over the same span as above (the plant's measured settle time), so # a healthy battery still settling on a new command is not short. @@ -1241,7 +1249,8 @@ class Controller: f"gave {batt_w:+.0f} W for " f"{decision.sat_count} cycles") elif self.batt_refused and not now: - gave = self.batt_refused * batt_w >= _ctl.PROBE_ANSWER_W + gave = (self.batt_refused * (batt_w - self._site_idle_w()) + >= _ctl.PROBE_ANSWER_W) self._refusal_end("the probe was answered" if gave and probe else "the battery gives power" if gave else "the grid now asks the other direction") @@ -1259,7 +1268,8 @@ class Controller: if ((self.auto or self.shadow) and not inv.refused and inv.ref_count >= t.saturation_cycles and inv.batt_fresh.held is not None - and abs(inv.batt_fresh.held) < _ctl.REFUSED_BELOW_W + and abs(inv.batt_fresh.held - inv.idle_w) + < _ctl.REFUSED_BELOW_W and abs(standing) > _ctl.PROBE_W): self._now_mono = now_mono self._unit_refusal_start( @@ -1271,7 +1281,8 @@ class Controller: and not self.batt_refused and self.sat_count >= t.saturation_cycles and inv.batt_fresh.held is not None - and abs(inv.batt_fresh.held) < _ctl.REFUSED_BELOW_W + and abs(inv.batt_fresh.held - inv.idle_w) + < _ctl.REFUSED_BELOW_W and abs(standing) > t.saturation_w): self._now_mono = now_mono self._refusal_start(1 if standing > 0 else -1, @@ -1344,7 +1355,9 @@ class Controller: for inv in self.inverters: b = inv.batt if inv.refused: - if b is not None and inv.refused * b >= _ctl.PROBE_ANSWER_W: + # SAFETY-97: given and taken are counted from the idle reading. + if (b is not None and inv.refused * (b - inv.idle_w) + >= _ctl.PROBE_ANSWER_W): self._unit_refusal_end( inv, "the probe was answered" if inv.probe_from is not None @@ -1355,32 +1368,54 @@ class Controller: # on its own reading, so one cycle of the loop overshooting # through zero does not hand it its share back. inv.ref_count = (inv.ref_count + 1 if b is not None and - -inv.refused * b >= _ctl.PROBE_ANSWER_W else 0) + -inv.refused * (b - inv.idle_w) + >= _ctl.PROBE_ANSWER_W else 0) if inv.ref_count >= _ctl.SETTLE_CYCLES: self._unit_refusal_end( inv, "the battery takes power the other way") continue - if b is None: - continue # a gap keeps the count: the gate's trip seeds - standing = inv.standing_w() - sign = 1 if standing > 0 else -1 - # One direction throughout: a command that swaps sign every cycle - # (a flapping load) reads ~0 through the plant's own lag, and that - # is a battery following, not refusing (TEST-19 flapping family). - if abs(standing) > _ctl.PROBE_W and abs(b) < _ctl.REFUSED_BELOW_W: - if sign != inv.ref_sign or not inv.ref_count: - inv.ref_count, inv.ref_since = 0, self._now_mono - inv.ref_count = min(inv.ref_count + 1, _ctl.SETTLE_CYCLES) - else: - inv.ref_count = 0 - inv.ref_sign = sign - if inv.ref_count >= _ctl.SETTLE_CYCLES and ( - inv.ref_since is None or self._now_mono is None - or self._now_mono - inv.ref_since >= REFUSED_SPAN_S): + sign = self._gave_nothing(inv) + if sign: self._unit_refusal_start( inv, sign, - f"asked {standing:+.0f} W, the battery gave {b:+.0f} W " - f"for {_ctl.SETTLE_CYCLES} cycles") + f"asked {inv.standing_w():+.0f} W, the battery gave " + f"{b:+.0f} W for {_ctl.SETTLE_CYCLES} cycles") + + def _gave_nothing(self, inv) -> int: + """The refusal evidence, one cycle of it: the sign `inv` has given + under REFUSED_BELOW_W while asked over PROBE_W, one way, for + SETTLE_CYCLES spanning REFUSED_SPAN_S, else 0. SAFETY-73 at N>1; + SAFETY-94 adds it to the N=1 engage in compute(). SAFETY-97: the band + is centred on the unit's idle reading.""" + b = inv.batt + if b is None: + return 0 # a gap keeps the count: the gate's trip seeds + standing = inv.standing_w() + sign = 1 if standing > 0 else -1 + # One direction throughout: a command that swaps sign every cycle + # (a flapping load) reads ~0 through the plant's own lag, and that + # is a battery following, not refusing (TEST-19 flapping family). + if (abs(standing) > _ctl.PROBE_W + and abs(b - inv.idle_w) < _ctl.REFUSED_BELOW_W): + # SAFETY-94: a reading still moving towards the ask is a battery + # catching up through its lag, not one refusing: start again. + if (sign != inv.ref_sign or not inv.ref_count + or sign * (b - inv.ref_from) >= REFUSED_PROGRESS_W): + inv.ref_count, inv.ref_since, inv.ref_from = ( + 0, self._now_mono, b) + inv.ref_count = min(inv.ref_count + 1, _ctl.SETTLE_CYCLES) + else: + inv.ref_count = 0 + inv.ref_sign = sign + if inv.ref_count >= _ctl.SETTLE_CYCLES and ( + inv.ref_since is None or self._now_mono is None + or self._now_mono - inv.ref_since >= REFUSED_SPAN_S): + return sign + return 0 + + def _site_idle_w(self) -> float: + """SAFETY-97: the site reading at idle, the sum of the units'.""" + return float(sum(inv.idle_w for inv in self.inverters)) def _site_refused(self): """Owner decision 4: every unit refuses the same way, so the site law @@ -3120,6 +3155,14 @@ class Controller: # `stale_input_s` and then commands 0 W and keeps commanding, which is # §11.1 rule 1 and is already what a missing reading does here. inv.batt = inv.batt_fresh.reading() + # SAFETY-97: learn this unit's idle reading, only while control runs + # and nothing says the unit is refusing, stale or faulty. + if inv.idle.observe( + inv.batt, inv.standing_w(), now_mono, + bool(self.auto) and inv.batt is not None + and not inv.batt_fresh.stale and not inv.refused + and not self.batt_refused and not inv.sign_wd.tripped): + inv.store_set("idle_w", round(inv.idle.value, 1)) if inv.batt_fresh.stale and not was_stale: self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason, hint=messages.hint("battery_stuck", unit=inv.name)) @@ -3162,8 +3205,9 @@ class Controller: inv.batt_fresh.bound.move_w, messages.reason_code(inv.batt_fresh.reason)) _LOG.debug("Evidence for %s: %s.", inv.name, inv.batt_fresh.reason) - _LOG.debug("%s: the limits are unchanged over %.0f s, a command " - "over %.0f W and a move by over %.0f W.", inv.name, + _LOG.debug("%s: the limits are unchanged for %.0f s while asked " + "to move: a command over %.0f W that moved over %.0f W.", + inv.name, inv.batt_fresh.bound.unchanged_s, inv.batt_fresh.bound.move_w, inv.batt_fresh.bound.move_w) @@ -3880,7 +3924,8 @@ class Controller: parked=inv.sign_wd.tripped or bool(inv.refused) or ( n_units > 1 and inv.batt is not None and abs(float(standing[inv.name])) <= _ctl.PROBE_W - and abs(float(inv.batt)) < _ctl.REFUSED_BELOW_W), + and abs(float(inv.batt) - inv.idle_w) + < _ctl.REFUSED_BELOW_W), # ⚠️ THIS UNIT'S OWN CUT-OUT. Not the shipped 10 %, and not # the site's `peak_soc_floor` either - that one bounds a # money-saving discharge only, and grading a pack against it @@ -4660,6 +4705,12 @@ class Controller: refused=self.batt_refused if len(self.inverters) == 1 else self._site_refused(), probe=probe, + # SAFETY-94: at N=1 the engage also needs the reading to have + # given nothing for the whole span, as at N>1. + gave_nothing=(bool(self._gave_nothing(self.inverters[0])) + if len(self.inverters) == 1 else None), + # SAFETY-97: "gives nothing" is centred on the idle reading. + idle_w=self._site_idle_w(), # OPT-11: None unless battery modes are on. SAFETY-85: or while # maintenance holds a battery at N>=2, so the loop does not # wind past what the others can give and jump when it ends. diff --git a/goodwe_controller/config.yaml b/goodwe_controller/config.yaml index 3c3104f..3faf4dc 100644 --- a/goodwe_controller/config.yaml +++ b/goodwe_controller/config.yaml @@ -1,5 +1,5 @@ name: GoodWe RS485 Controller -version: "0.14.0" +version: "0.15.0" slug: goodwe_controller description: >- Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart diff --git a/goodwe_controller/test_clamp.py b/goodwe_controller/test_clamp.py index b3cc1b2..f651a2d 100644 --- a/goodwe_controller/test_clamp.py +++ b/goodwe_controller/test_clamp.py @@ -439,7 +439,7 @@ class _Hass: return True -def wired(grid, cycles=40, batt=0.0, track=True, **over): +def wired(grid, cycles=40, batt=0.0, track=True, tick=None, **over): async def go(): hass = _Hass(grid, batt=batt, track=track) c = Controller(dict( @@ -455,8 +455,11 @@ def wired(grid, cycles=40, batt=0.0, track=True, **over): # hysteresis clears it a few cycles later - so it is captured live # rather than looked for after the run, when it is correctly gone. c.red_snapshot = [] - for _ in range(cycles): - await c.read_inputs() + for i in range(cycles): + # `tick`: simulated seconds per cycle. SAFETY-94: a refusal needs + # its evidence to span REFUSED_SPAN_S, which back-to-back cycles + # on the real clock never do. + await c.read_inputs(now_mono=None if tick is None else i * tick) await c.cycle() if c.clamp_wd.active and not c.red_snapshot: c.red_snapshot = c.checks() @@ -474,7 +477,7 @@ def clamp_notes(hass): # unclamped integrator climbed ~130 W per 4 s to over 10 kW and, pinned at the # 5 kW rail, reported 14 768 W." `track=False` is the inverter switched off: it # reports 0 W however hard it is commanded, so the error never closes. -hass_r, ctl_r = wired(6000.0, cycles=60, track=False) +hass_r, ctl_r = wired(6000.0, cycles=60, track=False, tick=2.0) emitted = [abs(w) for w in hass_r.writes] # What the SAME error would have done to an accumulator with no bound - the diff --git a/goodwe_controller/test_freshness.py b/goodwe_controller/test_freshness.py index 80a8e3c..620da86 100644 --- a/goodwe_controller/test_freshness.py +++ b/goodwe_controller/test_freshness.py @@ -900,34 +900,51 @@ def _loop_series(day, sig): return out +class _Before94(Freshness): + """The gate before SAFETY-94: the clock ran from the register's last + change, not from when the command asked it to move. The history below is + about that gate, so it is replayed on it.""" + + def obliged_s(self): + return self.graded_s(self.last_read) + + +def _replay16(cls): + """Every committed day at 1 Hz. Returns (trips, waits for the next move).""" + waits, trips = [], 0 + for _day in _loop_days: + _b, _s = _loop_series(_day, "batt"), _loop_series(_day, "setpoint") + if not _b or not _s: + continue + _moves = [r[0] for i, r in enumerate(_b) + if i == 0 or r[1] != _b[i - 1][1]] + _f8b, _prev8b = cls(BOUNDS["batt"]), False + _ib = _is = 0 + _t = max(_b[0][0], _s[0][0]) + _end = min(_b[-1][0], _s[-1][0]) + while _t <= _end: + while _ib + 1 < len(_b) and _b[_ib + 1][0] <= _t: + _ib += 1 + while _is + 1 < len(_s) and _s[_is + 1][0] <= _t: + _is += 1 + _f8b.submit(_b[_ib][1], _s[_is][1], _t) + if _f8b.stale and not _prev8b: + trips += 1 + _nxt = next((m for m in _moves if m > _t), None) + if _nxt is not None: + waits.append(_nxt - _t) + _prev8b = _f8b.stale + _t += 1.0 + return trips, sorted(waits) + + _t8b = time.monotonic() -_waits, _trips8b = [], 0 -for _day in _loop_days: - _b, _s = _loop_series(_day, "batt"), _loop_series(_day, "setpoint") - if not _b or not _s: - continue - _moves = [r[0] for i, r in enumerate(_b) if i == 0 or r[1] != _b[i - 1][1]] - _f8b, _prev8b = Freshness(BOUNDS["batt"]), False - _ib = _is = 0 - _t = max(_b[0][0], _s[0][0]) - _end = min(_b[-1][0], _s[-1][0]) - while _t <= _end: - while _ib + 1 < len(_b) and _b[_ib + 1][0] <= _t: - _ib += 1 - while _is + 1 < len(_s) and _s[_is + 1][0] <= _t: - _is += 1 - _f8b.submit(_b[_ib][1], _s[_is][1], _t) - if _f8b.stale and not _prev8b: - _trips8b += 1 - _nxt = next((m for m in _moves if m > _t), None) - if _nxt is not None: - _waits.append(_nxt - _t) - _prev8b = _f8b.stale - _t += 1.0 -_waits.sort() +_trips8b, _waits = _replay16(_Before94) +_trips94, _waits94 = _replay16(Freshness) _p50 = nth(_waits, len(_waits) // 2) print(f" {len(_loop_days)} days replayed at 1 Hz in " - f"{time.monotonic() - _t8b:.1f}s: {_trips8b} trips") + f"{time.monotonic() - _t8b:.1f}s (twice): {_trips8b} trips before " + f"SAFETY-94, {_trips94} after") print(f" wait for the register's next change after a trip: " f"min {nth(_waits, 0):.1f} s median {_p50:.1f} s max {nth(_waits, -1):.1f} s; " f"{sum(1 for w in _waits if w >= 710.0)} at or over the live episode's " @@ -953,6 +970,17 @@ check("⚠️ AND THAT DISTRIBUTION IS A PROPERTY OF THIS BMS, NOT OF THIS CODE "is `stale_input_s + release_s` = 32 s whatever the register does", sum(1 for w in _waits if w < 20.0) >= 20 and float(option_default("stale_input_s")) + BOUNDS["batt"].release_s == 32.0) +print(f" SAFETY-94: trips answered within 20 s: {sum(1 for w in _waits if w < 20.0)}" + f" before, {sum(1 for w in _waits94 if w < 20.0)} after; quickest " + f"answer after a trip {nth(_waits94, 0):.1f} s") +check("⚠️ SAFETY-94 (SAFETY-58): MOST OF THOSE SHORT WAITS WERE FALSE TRIPS, " + "NOT KEEP-ALIVE PULSES. The clock now counts only the time the command " + "asked the register to move. The same sixteen days give 59 trips, not " + "93, and only one of the 59 is answered within 20 s (21 before): the " + "gate no longer judges a register on the first read after a rest", + _trips94 == 59 and len(_waits94) == _trips94 + and sum(1 for w in _waits94 if w < 20.0) == 1 + and sum(1 for w in _waits if w < 20.0) == 21) # ============================================================================== @@ -1017,32 +1045,30 @@ check("NOT A SURVIVOR, recorded because it looks like one: a publisher failure " "each unit's own command travel and each unit's own number", _s4a.stale and _s4b.stale and _s4a.held != _s4b.held) -# S5. SAFETY-57's own, and it is a FALSE POSITIVE that predates this card. -# The unchanged clock keeps running while the site rests, so the FIRST read -# at which a command travels 250 W after a long quiet already satisfies all -# three terms - the register is given no time at all to answer the new -# command. Present on the shipped tree; the live capture in part 10 contains -# one (10:48:14, cleared one second later). +# S5. SAFETY-57's own FALSE POSITIVE, CLOSED BY SAFETY-94 (it was SAFETY-58). +# The unchanged clock used to keep running while the site rested, so the +# FIRST read at which a command travelled 250 W after a long quiet tripped +# the gate. The clock now counts only time the register was asked to move. +# Live 2026-10-03: four such trips, each 1 s after the command. _s5 = Freshness(BOUNDS["batt"]) for t in range(0, 300): # five minutes of a resting site _s5.submit(-3.0, 0.0, float(t)) _s5_quiet = _s5.stale _s5.submit(-3.0, 1500.0, 300.0) # the loop asks for 1500 W -check("⚠️ SURVIVOR 5, FALSE POSITIVE, AND IT IS OLDER THAN SAFETY-57: after a " - "long rest the unchanged clock is already past 35 s, so the very FIRST " - "read carrying a travelled command trips the gate - before the 17.0 s in " - "which a healthy register could have answered it. The gate accuses on " - "read one and the register answers on read eleven", - not _s5_quiet and _s5.stale and _s5.graded_s(300.0) == 300.0) -check("...and its SIZE is one `stale_input_s` hold, which is why it is recorded " - "here rather than fixed here: `cycle()` returns at `inputs missing " - "{n}s` for the whole of it, so nothing is commanded differently, and the " - "register's next publish clears it. Fixing it means measuring the " - "unchanged clock from when the OBLIGATION appeared rather than from the " - "register's last move, which re-derives the 35.0 s bound and is a " - "different card", - (lambda f: (f.submit(-4.0, 1500.0, 311.0), not f.stale)[-1])(_s5) - and float(option_default("stale_input_s")) >= 5.0) +_s5_first = _s5.stale +for t in range(301, 334): + _s5.submit(-3.0, 1500.0, float(t)) +_s5_before = _s5.stale +_s5.submit(-3.0, 1500.0, 334.0) +check("⚠️ SURVIVOR 5 IS CLOSED (SAFETY-94): after five minutes of rest the " + "first read carrying a travelled command is NOT a trip, nor is any read " + "before 35 s of standing command - and a register that still has not " + "moved after 35 s of it IS", + not _s5_quiet and not _s5_first and not _s5_before and _s5.stale + and _s5.graded_s(334.0) == 334.0 and _s5.obliged_s() == 35.0) +check("...and the evidence sentence still gives the register's own unchanged " + "time (334 s), which an operator compares with a history graph", + "unchanged for 334 s" in _s5.reason) # ============================================================================== @@ -1353,20 +1379,23 @@ print(f" after : held number reached a consumer on {sum(_exp57)} reads " check("⚠️ THE EXIT IS NOT FREE AND THE PRICE IS STATED AS A NUMBER. A register " "that is dead for good is re-graded for ever: over 1201 reads the held " - "number reaches a consumer on 630 of them against 35 before, and 17 " + "number reaches a consumer on 631 of them against 36 before, and 17 " "releases are taken. This check exists so that cost cannot be quietly " "forgotten, and so a change that makes it worse has to move a number " "somebody reads", - sum(_expb) == 35 and sum(_exp57) == 630 and _dead.releases == 17 + sum(_expb) == 36 and sum(_exp57) == 631 and _dead.releases == 17 and _deadb.releases == 0) check("⚠️ AND IT IS PAYABLE ONLY BECAUSE ONE RUN IS SHORTER THAN EITHER " - "CONSUMER'S HOLD. Every unbroken run of exposure is exactly one 35.0 s " - "grading window, and 35 < 75 (circulation_hold_s) < 85 " + "CONSUMER'S HOLD. Every unbroken run of exposure after a release is " + "exactly one 35.0 s window of standing command (the first is 36 s: the " + "fixture's command starts one read late), and 36 < 75 " + "(circulation_hold_s) < 85 " "(unit_sign_disagree_s) - so NEITHER detector can reach a verdict inside " "one, and the re-trip that ends each run hands them a None, which CLEARS " "their windows rather than accumulating into them", - set(_runs57) == {BOUNDS["batt"].unchanged_s} - and BOUNDS["batt"].unchanged_s < _circ_hold < _unit_hold) + set(_runs57[1:]) == {BOUNDS["batt"].unchanged_s} + and nth(_runs57, 0) == BOUNDS["batt"].unchanged_s + 1.0 + and max(_runs57) < _circ_hold < _unit_hold) check("...and nothing SAFETY-37 detects is given back by that, which is the " "half that makes the trade a trade. A permanent None clears both windows " "for ever too, so on the shipped-before module the same two detectors are " @@ -1643,8 +1672,10 @@ check("...and a re-trip's reason names the TWO durations separately, each " [f.submit(A_FROZEN_W, 0.0, float(t)) for t in range(40, 80)], [f.submit(A_FROZEN_W, 1300.0, float(t)) for t in range(80, 130)], f.stale and f.releases == 1 - and "unchanged for 92 s" in f.reason - and "checked 2 times over a 35 s window since the last stop was " + # SAFETY-94: the re-trip comes 35 s after the command returns at + # 80 s (114 s), not 35 s after the release at 57 s. + and "unchanged for 114 s" in f.reason + and "checked 2 times over a 57 s window since the last stop was " "withdrawn" in f.reason)[-1])( Freshness(BOUNDS["batt"]))) diff --git a/goodwe_controller/test_modes.py b/goodwe_controller/test_modes.py index ab9b229..40eb5b6 100644 --- a/goodwe_controller/test_modes.py +++ b/goodwe_controller/test_modes.py @@ -153,10 +153,16 @@ check("⭐ at N=1 and N=2, with the option absent and with it false, every " "is the arbiter on 6000 claim lists", {k: v for k, v in _now.items() if k not in _S91} == {k: v for k, v in A5DDDAD.items() if k not in _S91}) +# SAFETY-94: with SAFETY-87's held rule, A (cut out at 1600 s) now trips the +# stale gate at 1640 s, not 1635 s - 35 s after its command moved rather than +# after its reading last changed - so that run is no longer a5dddad's. It was +# a5dddad's (a60003b1...) before SAFETY-94. The shipped n2 is unchanged. +SAFETY94_N2_S87 = \ + "4ddea7ef9dc1efa437af899675db442cd531b084ae4cec35951d7c7bb3d6d8b5" check("...n2 and n2_off are the SAFETY-91 digest (A at its reserve gives " - "last), and SAFETY-87's held rule gives a5dddad's back", + "last), and SAFETY-87's held rule gives SAFETY-94's", _now["n2"] == _now["n2_off"] == SAFETY91_N2 != A5DDDAD["n2"] - and _n2_s87 == A5DDDAD["n2"]) + and _n2_s87 == SAFETY94_N2_S87) check("...and n2_maint is the SAFETY-78 digest: the N=2 drain that a5dddad " "refused on 392 of 540 cycles now runs (test_safety78.py), bounded by " "SAFETY-85", diff --git a/goodwe_controller/test_safety94.py b/goodwe_controller/test_safety94.py new file mode 100644 index 0000000..c7dfc51 --- /dev/null +++ b/goodwe_controller/test_safety94.py @@ -0,0 +1,361 @@ +"""Runnable check for SAFETY-94: a healthy battery that lags or reverses is +not flagged as frozen or refusing. `python3 test_safety94.py` + +Two false alarms, both pre-existing: +- The stale-reading gate (SAFETY-37) counted its 35 s from the register's last + change. After a rest that clock was already past 35 s, so the first read + after a new command tripped it, before the register could answer (SAFETY-58; + live 2026-10-03, four trips, each 1 s after a command). +- At N=1 the refusal (SAFETY-52) engaged after 12 frozen cycles if the reading + was under 100 W at that moment. At a 1 s meter, a battery reversing out of a + charge through its lag is passing 0 W just then (SAFETY-85 rig, O1). It was + held at 0 W until the next probe, up to 15 min. + +Now the gate counts only the time the command asked the register to move, and +a refusal needs the reading to have stayed under 100 W, not moving towards the +ask, for 12 cycles spanning 22 s, at every roster size. +DECISIONS/SAFETY-94-lagging-battery.md. + +1. The four live trips, replayed through the gate. +2. The REAL Controller on a lagging battery (tools/safety94_rig.py), N=1 and + N=2: rest then a step, maintenance drain start, drain -> charge, a charge + reversing into a discharge. No stale trip and no refusal. +3. A frozen register and a refusing battery are still caught, and how fast. +4. The rules at their edges. + +Mutations (MUTATION.md, purged copies) are listed in the pull request. +""" + +import logging +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +sys.path.insert(0, os.path.join(HERE, "tools")) +logging.disable(logging.CRITICAL) + +import safety94_rig as rig # noqa: E402 +import app.inverter as app_inverter # noqa: E402 +import app.main as app_main # noqa: E402 +from app import control # noqa: E402 +from app.freshness import BOUNDS, Freshness # noqa: E402 + +fails = [] + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +class Before94(Freshness): + """The gate before SAFETY-94: the clock ran from the last change.""" + + def obliged_s(self): + return self.graded_s(self.last_read) + + +def without_fix(fn): + """Run `fn` with the gate's old clock and the old refusal evidence.""" + real_f, real_c = app_inverter.Freshness, app_main.compute + real_p = app_main.REFUSED_PROGRESS_W + app_inverter.Freshness = Before94 + app_main.compute = lambda *a, **k: real_c(*a, **dict(k, gave_nothing=None)) + app_main.REFUSED_PROGRESS_W = float("inf") + try: + return fn() + finally: + app_inverter.Freshness, app_main.compute = real_f, real_c + app_main.REFUSED_PROGRESS_W = real_p + + +# ============================================================================ # +print("1. the four live trips of 2026-10-03, replayed") +# HA history (only changes are recorded), local time: the unit's setpoint +# (what stands on its wire) and its battery power register (0x0518). +LIVE = { + "17:39:16 inverter-b": ( + [("17:38:00", -24), ("17:38:14.7", 0), ("17:38:25.4", 5), + ("17:38:40.5", 16), ("17:38:44.7", 26), ("17:38:55.7", 37), + ("17:39:05.1", 42), ("17:39:09.8", 58), ("17:39:15.6", 268), + ("17:39:25.0", 479), ("17:39:30.7", 689), ("17:39:34.8", 900)], + [("17:38:00", -118), ("17:38:21.6", -99), ("17:39:21.6", 179), + ("17:39:31.6", 399)]), + "17:39:25 inverter-a": ( + [("17:38:00", -16), ("17:38:14.9", 0), ("17:38:25.0", 5), + ("17:38:40.0", 14), ("17:38:44.7", 24), ("17:38:55.2", 33), + ("17:39:04.7", 38), ("17:39:09.3", 52), ("17:39:15.0", 242), + ("17:39:24.6", 431), ("17:39:30.3", 621), ("17:39:34.8", 810)], + [("17:38:00", -3), ("17:38:25.9", 0), ("17:39:25.9", 318), + ("17:39:36.0", 654)]), + "19:00:04 inverter-a": ( + [("18:58:30.0", 290), ("18:58:43.3", 305), ("18:58:48.0", 314), + ("18:58:52.5", 300), ("19:00:03.1", 2500)], + [("18:58:30.0", 292), ("19:00:06.0", 951), ("19:00:16.0", 2360), + ("19:00:26.0", 2376)]), + "21:21:22 inverter-b": ( + [("21:19:30.0", 0), ("21:21:21.9", 2100), ("21:21:26.6", 1700), + ("21:21:31.1", 1300), ("21:21:36.9", 900), ("21:21:41.0", 500), + ("21:21:47.3", 200)], + [("21:19:30.0", -98), ("21:21:22.8", -97), ("21:21:32.8", 1411), + ("21:21:42.8", 798)]), +} + + +def _sec(hms): + h, m, s = hms.split(":") + return int(h) * 3600 + int(m) * 60 + float(s) + + +def replay(cls, setp, reg): + """1 Hz reads, as `run_control()` does. Returns the trip times.""" + f, trips = cls(BOUNDS["batt"]), [] + t, end = _sec(setp[0][0]), _sec(reg[-1][0]) + + def at(series, t): + return [float(v) for ts, v in series if _sec(ts) <= t][-1] + while t <= end: + was = f.stale + f.submit(at(reg, t), at(setp, t), t) + if f.stale and not was: + trips.append(t) + t += 1.0 + return trips + + +for name, (setp, reg) in LIVE.items(): + old, new = replay(Before94, setp, reg), replay(Freshness, setp, reg) + print(f" {name}: before {[f'{int(t // 3600):02d}:{int(t % 3600 // 60):02d}:{t % 60:04.1f}' for t in old]}" + f", now {len(new)} trips") + check(f"{name}: the shipped trip reproduces 1-2 s after the command, and " + f"now there is none (the register answered within 11 s)", + len(old) == 1 and abs(old[0] - _sec(name.split()[0])) < 1.0 + and new == []) + +# ============================================================================ # +print("\n2. the REAL Controller on a lagging battery (10 s lag)") +# Two register shapes: published every 10 s and repeating its value (the +# boards, live), and every 1 s with +-1 W of dither (the SAFETY-85 rig). +SHAPES = ((10.0, False), (1.0, True)) +NAMES = ("rest_step", "drain_start", "drain_charge", "reversal") + + +def sweep(fn=lambda f: f()): + out = {} + for n in (1, 2): + for name in NAMES: + for tick in (1.0, 2.0, 5.0): + for pub, dith in SHAPES: + s, r, _ = fn(lambda: rig.counts(n, name, tick=tick, + publish_s=pub, + dither=dith)) + out[(n, name, tick, pub)] = (s, r) + return out + + +NOW = sweep() +OLD = sweep(without_fix) +for n in (1, 2): + for name in NAMES: + now = [NOW[k] for k in NOW if k[:2] == (n, name)] + old = [OLD[k] for k in OLD if k[:2] == (n, name)] + print(f" N={n} {name:12s} stale/refused now " + f"{sum(a for a, _ in now)}/{sum(b for _, b in now)}, before " + f"{sum(a for a, _ in old)}/{sum(b for _, b in old)}") +check("⭐ no stale trip and no refusal, at N=1 and N=2, at 1, 2 and 5 s meters, " + "on both register shapes: a step after a rest, a drain start, drain -> " + "charge and a charge reversing into a discharge", + all(v == (0, 0) for v in NOW.values()) and len(NOW) == 48) +check("non-vacuous: before, the same runs trip the gate (drain start and " + "drain -> charge at both N, as live) and refuse at N=1 on a 1 s meter", + sum(OLD[(n, nm, t, 10.0)][0] for n in (1, 2) + for nm in ("drain_start", "drain_charge") for t in (1.0, 2.0, 5.0)) + >= 12 and OLD[(1, "drain_charge", 1.0, 1.0)][1] >= 1) + + +def reversals(n, tick, taus=(10.0, 20.0)): + """A charge reversing into a discharge: (stale trips, refusals) summed.""" + s = r = 0 + for tau in taus: + for charge in (-1500.0, -2500.0): + for house in (300.0, 800.0, 2000.0): + for pub, dith in SHAPES: + x = rig.run(n, rig.reversal(300.0, charge, house), 600.0, + tau=tau, tick=tick, publish_s=pub, + dither=dith) + s, r = s + len(x["stale"]), r + len(x["refused"]) + return s, r + + +REV = {(n, t): reversals(n, t) for n in (1, 2) for t in (1.0, 2.0)} +REV_OLD = {(n, 1.0): without_fix(lambda n=n: reversals(n, 1.0)) for n in (1, 2)} +print(f" reversals (lag 10 and 20 s): now {REV}, before {REV_OLD}") +check("⭐ SAFETY-94's case: a battery reversing out of a charge (1500 or 2500 " + "W) into a 300-2000 W house, lag 10 or 20 s, 1 or 2 s meter, is never " + "refused or flagged, at N=1 or N=2", + all(v == (0, 0) for v in REV.values())) +check("non-vacuous: before, the 1 s meter refuses at N=1 (lag 20 s) and at " + "N=2 (B, lag 20 s, its reading 100 W below its power)", + REV_OLD[(1, 1.0)][1] >= 1 and REV_OLD[(2, 1.0)][1] >= 1) + +# ============================================================================ # +print("\n3. a frozen register and a refusing battery are still caught") + + +def obliged_at(rows, t_f): + """First cycle from `t_f` where A's command is 250 W or more and has moved + 250 W from where it stood at `t_f`.""" + c0 = None + for row in rows: + if row[0] >= t_f and c0 is None: + c0 = row[2]["a"] + if c0 is not None and abs(row[2]["a"]) >= 250 \ + and abs(row[2]["a"] - c0) >= 250: + return row[0] + return None + + +CATCH = {} +for n in (1, 2): + for tick in (1.0, 5.0): + # frozen while giving 1000 W, then the house steps + r1 = rig.run(n, lambda t: 3000.0 if t >= 400 else 1000.0, 520.0, + tick=tick, publish_s=1.0, dither=True, + freeze_at=lambda u: 300.0 if u == "a" else None) + ob = obliged_at(r1["rows"], 300.0) + st = [t for t, u in r1["stale"] if u == "inverter-a"] + # frozen at rest (HW-01's shape), the house steps 500 s later + r2 = rig.run(n, lambda t: 2500.0 if t >= 600 else 0.0, 700.0, + tick=tick, publish_s=1.0, dither=True, + freeze_at=lambda u: 100.0 if u == "a" else None) + ob2 = obliged_at(r2["rows"], 100.0) + park = min([t for t, u in r2["stale"] if u == "inverter-a"] + + [t for t, _u in r2["refused"]] or [None]) + # a refusing battery from 300 s + r3 = rig.run(n, lambda t: 3000.0, 420.0, tick=tick, publish_s=10.0, + cut=lambda u, t: u == "a" and t >= 300.0) + ref = [t for t, _u in r3["refused"]] + CATCH[(n, tick)] = (st[0] - ob if st and ob is not None else None, + park - ob2 if park is not None and ob2 is not None + else None, ref[0] - 300.0 if ref else None) +print(" (N, meter): (frozen giving: trip after the command moved, frozen " + "at rest: 0 W after, refusing: refused after the cut), in s") +print(f" {CATCH}") +check("⭐ a register frozen while giving is caught 35 s after the command asks " + "it to move (the SAFETY-37 bound, now counted from the ask)", + all(v[0] == BOUNDS["batt"].unchanged_s for v in CATCH.values())) +check("⭐ a register frozen at rest reads like a refusing battery: the " + "battery is at 0 W, refused or stale, within 36 s of the ask", + all(v[1] is not None and v[1] <= 36.0 for v in CATCH.values())) +check("⭐ a refusing battery is still refused: within 22 s plus one 10 s " + "publish at a 1 s meter, and within 12 cycles plus a publish at 5 s", + all(CATCH[(n, 1.0)][2] is not None and CATCH[(n, 1.0)][2] <= 32.0 + for n in (1, 2)) + and all(CATCH[(n, 5.0)][2] is not None + and CATCH[(n, 5.0)][2] <= 12 * 5.0 + 10.0 for n in (1, 2))) + +# ============================================================================ # +print("\n4. the rules at their edges") +# The gate counts time the register was asked to move, across a flapping +# command too. +f = Freshness(BOUNDS["batt"]) +trip = None +for t in range(0, 200): + f.submit(-3.0, 1500.0 if (t // 5) % 2 else 0.0, float(t)) + if f.stale and trip is None: + trip = t +print(f" command flapping 0 / 1500 W every 5 s, register frozen: trip at " + f"{trip} s") +check("a frozen register under a command that flaps in and out of the ask is " + "still caught (after 35 s of asking, about 70 s here)", + trip is not None and 65 <= trip <= 75) +f = Freshness(BOUNDS["batt"]) +for t in range(0, 30): + f.submit(-3.0, 1500.0, float(t)) +f.submit(400.0, 1500.0, 30.0) # it answers +for t in range(31, 60): + f.submit(400.0, 1500.0, float(t)) +check("a register that answers starts a fresh count: 30 s asked, then it " + "moves, then 29 s more is not stale", + not f.stale and f.obliged_s() < 35.0) +f = Freshness(BOUNDS["batt"]) +for t in range(0, 600): + f.submit(-3.0, 200.0 if t % 60 < 3 else 0.0, float(t)) +check("a command that never asks 250 W is never an ask (SAFETY-52's 200 W " + "probe cannot arm the gate)", + not f.stale and f.obliged_s() == 0.0) +# Review: time under 250 W is not counted, even after the command travelled. +f = Freshness(BOUNDS["batt"]) +for t in range(0, 61): + f.submit(-300.0, -300.0 if t == 0 else 200.0, float(t)) +for t in range(61, 131): + f.submit(-300.0, 260.0, float(t)) + if t == 90: + early = f.stale +check("a command that has travelled 500 W but stands at 200 W for a minute " + "adds nothing: 30 s after it reaches 260 W the register is not stale, " + "and by 70 s it is", + not early and f.stale) + +# compute(): `gave_nothing` gates the N=1 engage; None keeps the old law. +T = control.Tuning(max_w=4000.0, slew_w=400.0) +args = dict(prev_w=1200.0, grid_w=1200.0, actual_w=-20.0, tuning=T, + sat_count=control.SETTLE_CYCLES, i_w=1200.0, refused=0) +check("compute(): a frozen loop at SETTLE_CYCLES reading -20 W engages with " + "gave_nothing None (compute alone, as before) or True, and not with " + "False", + control.compute(**args).refused == 1 + and control.compute(**args, gave_nothing=True).refused == 1 + and control.compute(**args, gave_nothing=False).refused == 0) + +# The evidence restarts on progress towards the ask, not on jitter. +print(f" REFUSED_PROGRESS_W {app_main.REFUSED_PROGRESS_W:.0f} W, span " + f"{app_main.REFUSED_SPAN_S:.0f} s") + + +class _Inv: + def __init__(self): + self.batt, self.ref_count, self.ref_since, self.ref_sign = 0.0, 0, None, 0 + self.ref_from = None + self.idle_w = 0.0 # SAFETY-97: A, ~0 W at idle + self.ask = 800.0 + + def standing_w(self): + return self.ask + + +class _C: + _now_mono = 0.0 + + +def evidence(readings, tick=1.0, ask=lambda i: 800.0): + c, inv = _C(), _Inv() + got = [] + for i, b in enumerate(readings): + c._now_mono, inv.batt, inv.ask = i * tick, b, ask(i) + got.append(app_main.Controller._gave_nothing(c, inv)) + return got + + +flat = evidence([-4.0 + (i % 3) for i in range(30)]) +climb = evidence([-95.0 + 4.0 * i for i in range(48)]) +check("a flat reading (+-1 W) under an 800 W ask is evidence after 22 s; one " + "climbing 4 W/s through the band is never", + 1 in flat and flat.index(1) == 22 and not any(climb)) +flap = evidence([-4.0] * 60, ask=lambda i: 800.0 if i % 2 else -800.0) +away = evidence([40.0] * 5 + [-40.0] * 25) +check("a command that swaps direction every cycle is never evidence, and a " + "reading moving 80 W away from the ask does not restart it (latched at " + "22 s, counted from the first reading)", + not any(flap) and 1 in away and away.index(1) == 22) +check("REFUSED_PROGRESS_W is under the band's width and over the live " + "readings' rest wander (25 W)", + 25.0 < app_main.REFUSED_PROGRESS_W < 2 * control.REFUSED_BELOW_W) + +print() +if fails: + print(f"{len(fails)} FAILED: " + ", ".join(fails)) + sys.exit(1) +print("all checks passed") diff --git a/goodwe_controller/test_safety97.py b/goodwe_controller/test_safety97.py new file mode 100644 index 0000000..084c6b3 --- /dev/null +++ b/goodwe_controller/test_safety97.py @@ -0,0 +1,441 @@ +"""Runnable check for SAFETY-97: "the battery gives nothing" is graded against +each battery's own idle reading. `python3 test_safety97.py` + +B's power reading shows -95 to -118 W at idle (standby draw; live +2026-10-03). The refusal evidence (SAFETY-52/73, shared since SAFETY-94) +needed the reading under 100 W in magnitude, so a refusing B never latched, +at N=2 ever, and at N=1 since SAFETY-94 (review F1). + +Now each unit learns its idle reading (app/idle.py) and the band, the +refusal's end and the stale-path refusal are centred on it. +DECISIONS/SAFETY-97-idle-offset.md. + +1. A refusing B is caught at N=1 and N=2, 1/2/5 s meters; A unchanged. +2. A healthy B with its offset is never refused or flagged (SAFETY-94's + scenarios), learned or restored. +3. Learning: not while refusing or asked, bounded, restored, follows drift. +4. The rules at their edges. + +Mutations (MUTATION.md, purged copies) are listed in the pull request. +""" + +import logging +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +sys.path.insert(0, os.path.join(HERE, "tools")) +logging.disable(logging.CRITICAL) + +import safety94_rig as rig # noqa: E402 +import app.idle as idle # noqa: E402 +import app.main as app_main # noqa: E402 +from app import control # noqa: E402 +from app.store import unit_key # noqa: E402 + +fails = [] + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +def without_fix(fn): + """Run `fn` with every idle reading at 0 W (the rule before SAFETY-97).""" + real = idle.IdleReading.w + idle.IdleReading.w = property(lambda self: 0.0) + try: + return fn() + finally: + idle.IdleReading.w = real + + +def with_offset(n, fn): + """At N=1 the rig's only unit is "a": give it B's offset.""" + saved = dict(rig.OFFSET) + if n == 1: + rig.OFFSET["a"] = rig.OFFSET["b"] + try: + return fn() + finally: + rig.OFFSET.clear() + rig.OFFSET.update(saved) + + +REST, CUT = 400.0, 600.0 # rest (it learns), then ask, then cut +REST_LOAD = (lambda t: 0.0 if t < REST else 3000.0) + + +def caught(n, unit, tick, *, offset=True, dur=None, store=None, load=None, + cut_at=CUT, **kw): + """Seconds from the cut to the refusal, or None, and the run.""" + cu = "a" if n == 1 else unit + + def go(): + return rig.run(n, load or REST_LOAD, dur or cut_at + 300.0, + tick=tick, noise=4.0, store=store, + cut=lambda u, t: u == cu and t >= cut_at, **kw) + r = with_offset(n, go) if offset and unit == "b" else go() + ref = [t - cut_at for t, _u in r["refused"]] + return (ref[0] if ref else None), r + + +# ============================================================================ # +print("1. a refusing battery, B with its -100 W idle reading (+-4 W noise)") +TIMES, OLD = {}, {} +for unit in ("a", "b"): + for n in (1, 2): + for tick in (1.0, 2.0, 5.0): + TIMES[(unit, n, tick)] = caught(n, unit, tick)[0] + OLD[(unit, n, tick)] = without_fix( + lambda: caught(n, unit, tick)[0]) +for k in TIMES: + print(f" refusing {k[0].upper()} N={k[1]} meter {k[2]:.0f} s: refused " + f"{TIMES[k]} s after the cut (before {OLD[k]})") +check("⭐ a refusing B is caught at N=1 and N=2: within 22 s plus one 10 s " + "publish at 1 and 2 s meters, 12 cycles plus a publish at 5 s", + all(TIMES[("b", n, t)] is not None + and TIMES[("b", n, t)] <= (32.0 if t < 5 else 12 * 5.0 + 10.0) + for n in (1, 2) for t in (1.0, 2.0, 5.0))) +check("non-vacuous: before, a refusing B is never caught (300 s), at N=1 " + "or N=2", + all(OLD[("b", n, t)] is None for n in (1, 2) for t in (1.0, 2.0, 5.0))) +check("⭐ a refusing A is caught exactly as before", + all(TIMES[("a", n, t)] == OLD[("a", n, t)] is not None + for n in (1, 2) for t in (1.0, 2.0, 5.0))) + +# B refusing for 800 s: one refusal, held, not ended by its own idle reading. +_t, r = caught(2, "b", 1.0, dur=CUT + 800.0) +starts = [x for x in r["refused"] if x[1] == "inverter-b"] +check("a refusing B stays refused until its probe: its idle reading is not " + "read as taking power the other way (one refusal in 800 s)", + len(starts) == 1 and r["c"].inverters[1].refused == 1) + +# A register frozen at rest on B: refused or stale within 36 s of the ask. +r = rig.run(2, lambda t: 2500.0 if t >= 600 else 0.0, 700.0, tick=1.0, + publish_s=1.0, dither=True, + freeze_at=lambda u: 300.0 if u == "b" else None) +park = min([t for t, u in r["stale"] if u == "inverter-b"] + + [t for t, u in r["refused"] if u == "inverter-b"] or [None]) +print(f" B's register frozen at rest from 300 s, asked at 600 s: 0 W at " + f"{park}") +check("a register frozen at rest on B is caught as on A (within 36 s of " + "the ask)", park is not None and park - 600.0 <= 36.0) +# At a 5 s meter the stale trip comes first: its refusal is centred too. +saved = dict(rig.OFFSET) +rig.OFFSET["b"] = -110.0 +try: + r = rig.run(2, lambda t: 2500.0 if t >= 600 else 0.0, 760.0, tick=5.0, + publish_s=1.0, dither=True, + freeze_at=lambda u: 300.0 if u == "b" else None) +finally: + rig.OFFSET.clear() + rig.OFFSET.update(saved) +st = [t for t, u in r["stale"] if u == "inverter-b"] +ref = [t for t, u in r["refused"] if u == "inverter-b"] +print(f" the same at a 5 s meter, B idle -110 W: stale {st}, refused {ref}") +check("...and at a 5 s meter the stale trip on B's held -110 W is a refusal " + "of B (one trip), not a repeating site stop", + len(st) == 1 and ref == st) + +# Review: the two centrings with no check of their own. +# N=1, 5 s meter: the stale trip on a held -110 W idle reading is a refusal. +saved = dict(rig.OFFSET) +rig.OFFSET["a"] = -110.0 +try: + r = rig.run(1, lambda t: 2500.0 if t >= 600 else 0.0, 800.0, tick=5.0, + publish_s=1.0, dither=True, freeze_at=lambda u: 300.0) +finally: + rig.OFFSET.clear() + rig.OFFSET.update(saved) +st = [t for t, _u in r["stale"]] +ref = [t for t, _u in r["refused"]] +print(f" N=1, 5 s meter, register frozen on -110 W: stale {st}, refused " + f"{ref}") +check("...and at N=1 the same stale trip is a refusal (one trip), not a " + "repeating site stop", len(st) == 1 and ref == st) + +# SAFETY-42: B asked under 200 W while giving nothing (its idle reading) is +# parked, not named as not delivering; A carries the house. +def small_ask(): + return rig.run(2, lambda t: 150.0, 700.0, tick=1.0, noise=4.0, + store={unit_key("inverter-b", "idle_w"): -100.0}, + cut=lambda u, t: u == "b")["c"].inverters[1] + + +b, b_old = small_ask(), without_fix(small_ask) +check("SAFETY-42: B giving nothing at a small ask, idle -100 W, is parked " + "(not latched as not delivering in 700 s; with idle 0 W it is)", + not b.delivery_wd.tripped and b.idle.value == -100.0 + and b_old.delivery_wd.tripped) + +# ============================================================================ # +print("\n2. a healthy B with its offset is never refused or flagged") +SHAPES = ((10.0, False), (1.0, True)) +NAMES = ("rest_step", "drain_start", "drain_charge", "reversal") +SAVED = {"b": {unit_key("inverter-b", "idle_w"): -100.0}, + "a": {unit_key("inverter-a", "idle_w"): -100.0}} +bad = [] +runs = 0 +for n in (1, 2): + for name in NAMES: + for tick in (1.0, 2.0, 5.0): + for pub, dith in SHAPES: + for store in (None, SAVED["a" if n == 1 else "b"]): + s, f, _r = with_offset(n, lambda: rig.counts( + n, name, tick=tick, publish_s=pub, dither=dith, + store=store)) + runs += 1 + if s or f: + bad.append((n, name, tick, pub, bool(store), s, f)) +print(f" {runs} runs, stale or refused: {bad}") +check("⭐ SAFETY-94's scenarios with B's offset (N=1 and N=2, 1/2/5 s " + "meters, both register shapes, idle learned or restored): no stale " + "trip and no refusal", not bad and runs == 96) +rev = [] +for n in (1, 2): + for tau in (10.0, 20.0): + for charge in (-1500.0, -2500.0): + for house in (300.0, 800.0, 2000.0): + x = with_offset(n, lambda: rig.run( + n, rig.reversal(300.0, charge, house), 600.0, tau=tau, + tick=1.0, store=SAVED["a" if n == 1 else "b"])) + if x["stale"] or x["refused"]: + rev.append((n, tau, charge, house)) +check("⭐ a charge reversing into a discharge (lag 10 and 20 s, 1 s meter) " + "with B's idle reading restored is never refused or flagged", + not rev) + +# ============================================================================ # +print("\n3. learning the idle reading") +_t, r = caught(2, "b", 1.0) +c = r["c"] +check("learned during the rest: A about 0 W, B about -100 W (used: -75 W), " + "and saved", + abs(c.inverters[0].idle_w) < 5 + and abs(c.inverters[1].idle.value + 100) < 5 + and c.inverters[1].idle_w == -idle.IDLE_MAX_W + and abs(c.store.data.get(unit_key("inverter-b", "idle_w"), 0) + 100) + < 5) + +# A refusing B from the start, no rest, nothing saved: nothing to learn from. +_t, r = caught(2, "b", 1.0, load=lambda t: 3000.0, cut_at=60.0, dur=1200.0) +check("a battery refusing from the start teaches nothing: asked the whole " + "time, then held at 0 W by its refusal (none learned, none saved)", + r["c"].inverters[1].idle.value is None + and unit_key("inverter-b", "idle_w") not in r["c"].store.data) +# ... which is why the value is saved: restored, the same battery is caught. +t_saved, _r = caught(2, "b", 1.0, load=lambda t: 3000.0, cut_at=60.0, + store=SAVED["b"]) +t_none, _r = caught(2, "b", 1.0, load=lambda t: 3000.0, cut_at=60.0) +print(f" refusing from the start: saved idle {t_saved} s, none {t_none}") +check("⭐ after a restart the saved idle reading is used: a B refusing from " + "the start is caught in 32 s; with nothing saved it is not", + t_saved is not None and t_saved <= 32.0 and t_none is None) + +# Not learned while refused: a refusing B reading -160 W (in the band) does +# not pull its idle reading during the 0 W its refusal holds. +class _P(rig.Plant): + def advance(self, t): + super().advance(t) + for u in self.units: + if self.cut(u, self.t): + self.reg[u] = -160.0 + + +real_plant = rig.Plant +rig.Plant = _P +try: + _t, r = caught(2, "b", 1.0, dur=CUT + 800.0) +finally: + rig.Plant = real_plant +b = r["c"].inverters[1] +print(f" refusing B reading -160 W: refused at {_t} s, idle " + f"{b.idle.value:.1f}") +check("⭐ not learned while refused: 800 s held at 0 W reading -160 W leaves " + "B's idle reading at -100 W", + _t is not None and b.refused and abs(b.idle.value + 100.0) < 1.0) + +# Not learned while control is stopped (another owner may drive the battery) +# or while the unit's sign watchdog has latched. +class _Stopped(app_main.Controller): + auto = property(lambda self: False, lambda self, v: None) + + +rig.Controller = _Stopped +try: + r = rig.run(2, lambda t: 0.0, 600.0, tick=1.0) +finally: + rig.Controller = app_main.Controller +r2 = rig.run(2, lambda t: 0.0, 600.0, tick=1.0, + store={unit_key("inverter-b", "sign_tripped"): True}) +check("not learned while control is stopped, nor on a unit whose sign " + "watchdog has latched (600 s at 0 W); its sibling learns", + all(i.idle.value is None for i in r["c"].inverters) + and r2["c"].inverters[1].idle.value is None + and r2["c"].inverters[0].idle.value is not None) + +# A battery capped at 150 W under a big ask is not idle. +x = idle.IdleReading() +for t in range(0, 3600): + x.observe(150.0, 2000.0, float(t), True) +check("a reading under a command over 50 W is never a sample (a capped " + "battery is not idle)", x.value is None and x.w == 0.0) + +x = idle.IdleReading() +for t in range(0, 239): + x.observe(-100.0, 10.0, float(t), True) +first = x.value +x.observe(-100.0, 10.0, 239.0, True) +x.observe(-100.0, 10.0, 240.0, True) +check("the first value needs 120 s asked ~0 and then 120 s of samples", + first is None and abs(x.value + 100.0) < 1e-6) +y = idle.IdleReading() +for t in range(0, 400): + y.observe(-100.0, 60.0 if t % 100 == 0 else 0.0, float(t), True) +check("a command over 50 W restarts the quiet time (a 60 W blip every " + "100 s: never learned)", y.value is None) +z = idle.IdleReading() +for t in range(0, 400): + z.observe(-100.0, 0.0, float(t), False) +check("nothing is learned while learning is not allowed (refused, stale, " + "tripped or stopped)", z.value is None) + +for t in range(240, 300): + x.observe(-130.0, 0.0, float(t), True) +after_min = x.value +for t in range(300, 240 + 3600): + x.observe(-130.0, 0.0, float(t), True) +print(f" drift -100 -> -130 W: {after_min:.1f} after 60 s, {x.value:.1f} " + f"after an hour") +check("drift is followed slowly: under 2 W in the first minute, most of it " + "in an hour", -102.0 < after_min < -100.0 and -130 < x.value < -124) + +o = idle.IdleReading() +for t in range(0, 600): + o.observe(-300.0, 0.0, float(t), True) +p = idle.IdleReading() +for t in range(0, 600): + p.observe(2000.0 if t < 300 else -240.0, 0.0, float(t), True) +check("a reading beyond 250 W is not a sample (-300 W: never learned; a " + "register held at 2000 W then -240 W: -240 W)", + o.value is None and p.value is not None and abs(p.value + 240.0) < 1e-6) +check("a saved value is clamped to 250 W, the band's centre to 75 W, and " + "garbage is ignored", + idle.IdleReading(900).value == idle.IDLE_SAMPLE_W + and idle.IdleReading(900).w == idle.IDLE_MAX_W + and idle.IdleReading(-900.0).w == -idle.IDLE_MAX_W + and idle.IdleReading(-60.0).w == -60.0 + and idle.IdleReading("x").value is None + and idle.IdleReading(float("nan")).value is None) + +# ============================================================================ # +print("\n3b. a wrong idle reading (owner, 2026-10-04: centre clamped to 75 W)") + + +def wrong_idle(n, unit, err, restore): + """Seconds from the cut to the refusal when the idle reading is `err` W + off: taught wrong during the rest, or restored wrong.""" + cu = "a" if n == 1 else unit + true = -100.0 if unit == "b" else 0.0 + if restore: + return caught(n, unit, 1.0, store={ + unit_key(f"inverter-{cu}", "idle_w"): true + err})[0] + + class _W(rig.Plant): + def advance(self, t): + rig.OFFSET[cu] = true + (err if t < REST else 0.0) + super().advance(t) + saved, real = dict(rig.OFFSET), rig.Plant + rig.Plant = _W + try: + return caught(n, unit, 1.0)[0] + finally: + rig.Plant = real + rig.OFFSET.clear() + rig.OFFSET.update(saved) + + +A_WRONG = {(n, e, rs): wrong_idle(n, "a", e, rs) for n in (1, 2) + for e, rs in ((-250.0, False), (-150.0, False), (150.0, False), + (250.0, False), (-250.0, True), (200.0, True), + (250.0, True))} +print(f" A (reads about 0 W) refusing, idle off by E: {A_WRONG}") +check("⭐ a battery reading about 0 W while refusing is caught whatever idle " + "reading was learned or restored (A, E -250..+250 W, N=1 and N=2)", + all(v is not None and v <= 32.0 for v in A_WRONG.values())) +check("⭐ ...including a wrong saved idle_w of +200 W on A", + all(A_WRONG[(n, 200.0, True)] is not None for n in (1, 2))) +B_WRONG = {(n, e): wrong_idle(n, "b", e, False) for n in (1, 2) + for e in (-150.0, -50.0, 90.0)} +print(f" B refusing, idle taught wrong by E: {B_WRONG}") +check("B is caught across -150..+90 W of error in its idle reading", + all(v is not None and v <= 32.0 for v in B_WRONG.values())) + +# ============================================================================ # +print("\n4. the rules at their edges") + + +class _Inv: + def __init__(self, idle_w): + self.batt, self.ref_count, self.ref_since, self.ref_sign = 0.0, 0, None, 0 + self.ref_from, self.idle_w = None, idle_w + + def standing_w(self): + return 800.0 + + +class _C: + _now_mono = 0.0 + + +def evidence(readings, idle_w): + c, inv = _C(), _Inv(idle_w) + got = [] + for i, b in enumerate(readings): + c._now_mono, inv.batt = float(i), b + got.append(app_main.Controller._gave_nothing(c, inv)) + return got + + +flat_b = evidence([-104.0 + (i % 3) for i in range(30)], -100.0) +flat_b0 = evidence([-104.0 + (i % 3) for i in range(30)], 0.0) +gives = evidence([0.0] * 30, -100.0) +takes = evidence([-200.0] * 30, -100.0) +check("the band is centred on the idle reading: B flat at -104 W is evidence " + "at 22 s with idle -100 W and never with idle 0 W; reading 0 W (giving " + "100 W) or -200 W (taking 100 W) is not", + 1 in flat_b and flat_b.index(1) == 22 and not any(flat_b0) + and not any(gives) and not any(takes)) + +T = control.Tuning(max_w=4000.0, slew_w=400.0) +args = dict(prev_w=1200.0, grid_w=1200.0, tuning=T, + sat_count=control.SETTLE_CYCLES, i_w=1200.0, refused=0, + gave_nothing=True) +check("compute(): the N=1 engage is centred on idle_w (-104 W refuses with " + "idle -100, not with 0; -4 W refuses with idle 0, not with -150)", + control.compute(actual_w=-104.0, idle_w=-100.0, **args).refused == 1 + and control.compute(actual_w=-104.0, **args).refused == 0 + and control.compute(actual_w=-4.0, **args).refused == 1 + and control.compute(actual_w=-4.0, idle_w=-150.0, **args).refused == 0) +args.update(refused=-1, i_w=-1200.0, prev_w=0.0, grid_w=-1200.0) +check("compute(): a charge refusal ends when the battery takes 100 W more " + "than its idle reading, not at its idle reading", + control.compute(actual_w=-110.0, idle_w=-100.0, **args).refused == -1 + and control.compute(actual_w=-205.0, idle_w=-100.0, **args).refused + == 0 + and control.compute(actual_w=-110.0, **args).refused == 0) +check("the band's centre stays within 75 W of 0 W, so the band keeps 0 W " + "+-25 W; samples to 250 W", + idle.IDLE_MAX_W == 75.0 and idle.IDLE_SAMPLE_W == 250.0 + and control.REFUSED_BELOW_W - idle.IDLE_MAX_W >= 25.0 + and idle.IDLE_ASK_W < control.PROBE_W) + +print() +if fails: + print(f"{len(fails)} FAILED: " + ", ".join(fails)) + sys.exit(1) +print("all checks passed") diff --git a/goodwe_controller/tools/safety94_rig.py b/goodwe_controller/tools/safety94_rig.py new file mode 100644 index 0000000..f832c5e --- /dev/null +++ b/goodwe_controller/tools/safety94_rig.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +"""SAFETY-94: the REAL Controller on a lagging battery whose reading is late. + + python3 tools/safety94_rig.py # prints every scenario's counts + +Each battery ramps at 800 W/s, then follows a first-order lag (`tau`, 10 s +as on the SAFETY-85 rig). Its reading (register 0x0518) is published every +10 s, at a different phase per unit, and repeats its value between publishes, +as the live boards do. B reads 100 W below what it gives (its standby draw: +-100 W at idle, live 2026-10-03). The meter updates every `tick` s; the add-on +reads every 1 s and cycles on a new meter value, as `run_control()` does. + +A frozen register stops publishing at `freeze_at`; a refusing battery (`cut`) +gives 0 W and reads -4 W plus its offset (B: -104 W), with `noise` W of +seeded jitter (SAFETY-97). Uses only the Controller API release/1.0 02ed120 +has, so the same file runs on the tree before SAFETY-94. +""" + +import asyncio +import math +import os +import random +import sys +from datetime import datetime, timedelta, timezone + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import app.main as m # noqa: E402 +from app.main import Controller # noqa: E402 + +BASE = datetime(2026, 10, 3, 10, 50, tzinfo=timezone.utc) # drain from 600 s +PUBLISH_S = 10.0 +PHASE = {"a": 6.0, "b": 1.6} # live: A publishes at :x6, B at :x1.6 +OFFSET = {"a": 0.0, "b": -100.0} # B's standby draw, live 2026-10-03 +CUT_W = -4.0 +LIVE = {"gain": 0.6, "slew_w": 400, "target_grid_w": -10, "deadband_w": 15, + "step_w": 10, "heartbeat_s": 10, "stale_input_s": 15, + "auto_start": True, "meter_entity": "sensor.grid", + "meter_source": "off"} +MAINT = {"maintenance_enabled": True, "maintenance_start_hour": 11, + "maintenance_discharge_w": 2500, "maintenance_hold_min": 30} + + +class _Store: + def __init__(self, data=None): + from app.store import DEFAULTS + self.data = dict(DEFAULTS, **(data or {})) + + def set(self, k, v): + self.data[k] = v + + def get_time(self, k): + v = self.data.get(k) + return datetime.fromisoformat(v) if v else None + + def set_time(self, k, when): + self.data[k] = when.isoformat() if when else None + + +class _Pub: + enabled = False + + def publish(self, *a, **k): + pass + + def close(self): + pass + + +class Plant: + """The batteries, their registers and the meter, in simulated seconds.""" + + def __init__(self, units, load, tau, tick, soc, cut, freeze_at, + publish_s=PUBLISH_S, dither=False, noise=0.0, seed=0): + self.units, self.load, self.tau, self.tick = units, load, tau, tick + self.noise, self.rng = noise, random.Random(seed) + self.publish_s, self.dither, self.n_pub = publish_s, dither, 0 + self.soc_fn, self.cut, self.freeze_at = soc, cut, freeze_at + self.cmd = {u: 0.0 for u in units} + self.ramp = {u: 0.0 for u in units} + self.real = {u: 0.0 for u in units} + self.reg = {u: round(OFFSET[u]) for u in units} + self.grid_v = float(load(0.0)) + self.t = 0.0 + + def advance(self, t): + """Integrate to `t` in 0.1 s steps; publish and sample on schedule.""" + while self.t < t - 1e-9: + self.t = round(self.t + 0.1, 6) + for u in self.units: + want = 0.0 if self.cut(u, self.t) else self.cmd[u] + d = want - self.ramp[u] + self.ramp[u] += math.copysign(min(abs(d), 80.0), d) + self.real[u] += ((self.ramp[u] - self.real[u]) + * (1 - math.exp(-0.1 / self.tau))) + frozen = (self.freeze_at is not None + and self.freeze_at(u) is not None + and self.t >= self.freeze_at(u)) + ph = (self.t - PHASE[u] % self.publish_s) / self.publish_s + if not frozen and abs(ph - round(ph)) < 1e-6: + # `dither`: the +-1 W a settled register shows (live). + self.n_pub += 1 + jit = (self.n_pub * 7 % 3 - 1) if self.dither else 0 + self.reg[u] = ( + float(round(CUT_W + OFFSET[u] + self.rng.uniform( + -self.noise, self.noise))) + if self.cut(u, self.t) + else float(round(self.real[u] + OFFSET[u]) + jit)) + if abs(self.t / self.tick - round(self.t / self.tick)) < 1e-6: + self.grid_v = float(round(self.load(self.t) + - sum(self.real.values()))) + + +class _Hass: + def __init__(self, plant): + self.p = plant + + async def limits(self, e): + return (-5000.0, 5000.0, 1.0) + + async def call(self, *a): + return True + + async def number(self, e, invert=False): + if e == "sensor.grid": + return self.p.grid_v + u = e[-1] + if e.startswith("sensor.soc"): + return self.p.soc_fn(u, self.p.t) + if e.startswith("sensor.batt"): + return self.p.reg[u] + return None + + async def set_number(self, e, v): + self.p.cmd[e[-1]] = float(v) + return True + + +def options(n, extra=None): + o = dict(LIVE) + if n == 1: + o.update({"setpoint_entity": "number.sp_a", + "soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a", + "max_w": 4000, "bms_soc_floor": 10}) + else: + o["inverters"] = [ + {"name": f"inverter-{u}", "setpoint_entity": f"number.sp_{u}", + "soc_entity": f"sensor.soc_{u}", "batt_entity": f"sensor.batt_{u}", + "max_w": 2500, "bms_soc_floor": 10} for u in "ab"[:n]] + o.update(extra or {}) + return o + + +def run(n, load, dur, *, tau=10.0, tick=1.0, soc=None, cut=None, + freeze_at=None, extra=None, floor_s=15.0, publish_s=PUBLISH_S, + dither=False, noise=0.0, seed=0, store=None): + """Returns a dict: per-unit stale trips [(t, unit)], refusals + [(t, unit or 'site')], rows [(t, grid, {u: cmd}, {u: real}, {u: reg}, + phases)] and the controller. `store`: saved state to start from.""" + units = tuple("ab"[:n]) + soc = soc or (lambda u, t: 50.0) + cut = cut or (lambda u, t: False) + plant = Plant(units, load, tau, tick, soc, cut, freeze_at, publish_s, + dither, noise, seed) + + async def go(): + c = Controller(options(n, extra), _Hass(plant), _Store(store), _Pub(), + stop=asyncio.Event()) + for i in c.inverters: + i.dev_min, i.dev_max = -5000.0, 5000.0 + c.auto = True + real_dt = m.datetime + clock = [0.0] + + class _DT(real_dt): + @classmethod + def now(cls, tz=None): + return BASE + timedelta(seconds=clock[0]) + + m.datetime = _DT + out = {"stale": [], "refused": [], "rows": [], "c": c} + was = {i.name: False for i in c.inverters} + was_ref = {i.name: 0 for i in c.inverters} + was_site = 0 + last_grid, last_cycle = None, None + try: + t = 0.0 + while t < dur: + plant.advance(t) + clock[0] = t + await c.read_inputs(now_mono=t) + for i in c.inverters: + if i.batt_fresh.stale and not was[i.name]: + out["stale"].append((t, i.name)) + was[i.name] = i.batt_fresh.stale + cycled = (c.grid != last_grid or last_cycle is None + or t - last_cycle >= floor_s) + if cycled: + last_grid, last_cycle = c.grid, t + await c.cycle() + # A refusal can start on a read (a gate trip) or in a cycle. + for i in c.inverters: + if i.refused and not was_ref[i.name]: + out["refused"].append((t, i.name)) + was_ref[i.name] = i.refused + if c.batt_refused and not was_site: + out["refused"].append((t, "site")) + was_site = c.batt_refused + if cycled: + out["rows"].append(( + t, plant.grid_v, dict(plant.cmd), + {u: round(v) for u, v in plant.real.items()}, + dict(plant.reg), [mm.phase for mm in c.maints])) + t += 1.0 + finally: + m.datetime = real_dt + return out + return asyncio.run(go()) + + +# --- the scenarios --------------------------------------------------------- +def rest_then(step_at, w): + """A house at 0 W (the batteries rest) until `step_at`, then `w`.""" + return lambda t: w if t >= step_at else 0.0 + + +def reversal(at_s, charge_w, house_w): + """Solar surplus (the batteries charge), then a house load from `at_s`.""" + return lambda t: house_w if t >= at_s else charge_w + + +def drain_soc(floor_at): + """A drains (maintenance) and reaches its floor at `floor_at`; B at 50 %.""" + def soc(u, t): + if u != "a": + return 50.0 + return 50.0 if t < floor_at else 10.0 + return soc + + +SCENARIOS = { + # first command after a long rest (the live 17:39 shape) + "rest_step": dict(load=rest_then(600.0, 2400.0), dur=900.0), + # maintenance drain starts after a rest (the live 19:00 shape) + "drain_start": dict(load=lambda t: 300.0, dur=900.0, + extra=MAINT, soc=drain_soc(1e9)), + # A's drain ends and its charge starts (the live 21:21 shape) + "drain_charge": dict(load=lambda t: 300.0, dur=1200.0, extra=MAINT, + soc=drain_soc(900.0)), + # a charge reverses into a discharge through the lag (SAFETY-94) + "reversal": dict(load=reversal(300.0, -2500.0, 800.0), dur=600.0), +} + + +def counts(n, name, **kw): + sc = dict(SCENARIOS[name], **kw) + r = run(n, **sc) + return len(r["stale"]), len(r["refused"]), r + + +if __name__ == "__main__": + for n in (1, 2): + for name in SCENARIOS: + for tick in (1.0, 2.0, 5.0): + for pub, dith in ((10.0, False), (1.0, True)): + s, f, r = counts(n, name, tick=tick, publish_s=pub, + dither=dith) + print(f"N={n} {name:13s} tick {tick:.0f} s publish " + f"{pub:2.0f} s{' dither' if dith else ' '}: " + f"stale {s} {r['stale'][:2]}, refusals {f} " + f"{r['refused'][:2]}")