Compare commits
19
Commits
deploy-0.14.0
...
v0.15.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21599938ea | ||
|
|
3ed3302d2f | ||
|
|
8c6e5bca2e | ||
|
|
f01152b113 | ||
|
|
3129daed62 | ||
|
|
64dddd64bf | ||
|
|
cf705a9058 | ||
|
|
7d06aa674d | ||
|
|
88ebf3a5a6 | ||
|
|
3db2b6392f | ||
|
|
80bfc5721e | ||
|
|
6918e154c8 | ||
|
|
dd5eb6fdc8 | ||
|
|
6759d21ea5 | ||
|
|
3a54b253cb | ||
|
|
8b9680fc35 | ||
|
|
3ce18708b6 | ||
|
|
78acb558ff | ||
|
|
0f14c2a5de |
+1
-1
@@ -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
|
||||
|
||||
@@ -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/<name>/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.**
|
||||
|
||||
@@ -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).
|
||||
@@ -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/<name>/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).
|
||||
@@ -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**
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()`
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])))
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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]}")
|
||||
Reference in New Issue
Block a user