checks / unit (pull_request) Successful in 8m0s
Text only. No behaviour change - same conjunction, same limits, same MISSING, same 0 W, same NOT READY. `tools/safety56_behaviour_trace.py` says so: 2390 capture reads, a 66-cell swept grid, and six driven controller worlds produce the byte-identical document `6030175e...` on origin/release/1.0 and on this branch, while the sentences differ. THE DEFECT. SAFETY-37's conjunction is satisfied by two disjoint worlds - the register has stopped reporting reality, or the SOURCE cannot comply with the command - and nothing `freshness.py` is handed separates them. Three sentences asserted the first. On the live 2026-09-21 episode it was the second: the pack read 11 % for the whole 850 s hold, every command in that hold was a discharge, and the register answered the first CHARGE command three seconds later. The readiness row was worse still, because it asserted a false fact about a NAMED entity that was present, numeric and updating - so the operator opened Developer Tools, found it working, and had every reason to conclude the diagnostics were broken. THE FIX. Each message states what was OBSERVED and names BOTH explanations. Three wordings, no shared helper: two are a log line about a behaviour, the third is a banner about an entity, and a helper serving both would drop whichever half the other caller does not need. SoC stays STATED, NOT WIRED - part 11b drives the two worlds at 50 % and 11 % and the sentence does not move one byte. The row does not branch on `batt_fresh.stale` either: `stale_note()` already rides `liveness_notes()` a few rows below, so the discriminator is "is that row there?", and part 11d asserts it is present in one world and absent in the other. THE CHECKS. test_freshness.py part 11 builds the pair of worlds with ground truth written down rather than derived from the published stream, asserts they are really two (40 of 40 reads differ) while the published stream is bit-identical, and only then grades the sentences. Eleven mutations, all red: each of the three sentences reverted (3, 2 and 2 named reds), the two explanations transposed at ZERO bytes (1), the second explanation deleted (1), the admission of ambiguity replaced by a ranking (1), all three reverted at once (5), and two aimed at this card's own check - collapsing the world pair (2) and emptying the accusation list, which a positive control against the three sentences as 0.10.1 shipped them catches (1). AND TWO OF THE ELEVEN FOUND DEFECTS IN THE CHECK RATHER THAN IN THE CODE. Transposing `label` and `entity` in the row made it unfindable and the run died on an IndexError - a red with no name and every later check ungraded, FCST-06's shape exactly; `_s56_row()` returns a named absence now, 0 named reds before, 3 after. And the first invariance run compared a tree with itself, because it archived a branch whose edits were not committed yet; the tool now REFUSES to report "behaviour unchanged" unless the sentences halves differ, and both of its refusals are driven. SAFETY-57's part 10h check was the handoff and is inverted rather than deleted: it required the accusation to still be present so that removing it had to be a deliberate act. This is that act. Its escape clause is untouched. Suite: 30/30 in the CI image, checks.yml's exact command, purged tree, :ro. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
389 lines
20 KiB
Markdown
389 lines
20 KiB
Markdown
# SAFETY-57 — the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested
|
||
|
||
**Status:** decided and implemented on branch `SAFETY-57`, off `origin/master` @ `6f5e8e3`
|
||
(content-identical to `release/1.0` @ `8a57361`).
|
||
**Card:** SAFETY-57 — *"the freshness gate has no exit reachable from inside the state it creates"*.
|
||
**Nothing here reached the inverter.** No contact was made with 192.168.2.158 or
|
||
192.168.2.221. The one live system touched is `sniffer@192.168.2.167`, a
|
||
**passive** RS-485 listener with no address on the bus, and it was read only.
|
||
|
||
---
|
||
|
||
## 0. The five things a reader should take away
|
||
|
||
1. **The live episode is reproduced from the wire, not modelled.** The capture
|
||
is committed, the decoder is committed, and the box that produced it was
|
||
running the defect — so the `0 W` in the trace *is* the add-on's own
|
||
`inputs missing → command 0 W`. §2.
|
||
2. **Option 2 from the card — "do not arm the gate when the command is 0 W" —
|
||
already holds, and it is not the bug.** `_grade()` has required
|
||
`|standing| >= MOVE_W` since SAFETY-37. The trip happened at **+1060 W** and
|
||
the stuck state is at **0 W**; they are different moments. §3.
|
||
3. **The decision is not "how long", it is "when".** The verdict is withdrawn
|
||
when the **obligation that produced it is no longer standing** — SAFETY-13's
|
||
shape one module over. The 17.0 s is the obligation window that was already
|
||
derived in the file, taken at the **floor** of its band, and which half is
|
||
safe is the **opposite** of SAFETY-13's. §4.
|
||
4. **It costs 52 % exposure on a permanently dead register, and that number is
|
||
in the suite so it cannot be forgotten.** It is payable only because one
|
||
re-grading window (35 s) is shorter than both consumer holds (75 s, 85 s).
|
||
**No detection SAFETY-37 had is given back.** §5.
|
||
5. **The battery had been rescuing this code by accident.** 93 trips in the
|
||
committed 16-day capture; median wait for the BMS keep-alive pulse that
|
||
cleared them **143 s**, worst **5 h 57 m**. §6.
|
||
|
||
---
|
||
|
||
## 1. What was there, in the terms the code uses
|
||
|
||
`freshness.py`, `Freshness.submit()` and `_grade()`:
|
||
|
||
```python
|
||
if self.changed_mono is None or value != self.held:
|
||
...
|
||
self.stale = False # the only place the latch is cleared
|
||
def _grade(self, standing_w, now_mono):
|
||
if self.stale or self.changed_mono is None:
|
||
return # sets the latch; never clears it
|
||
```
|
||
|
||
and `main.py:1777`, `inv.batt = inv.batt_fresh.reading()`, where `reading()`
|
||
answers `None` while `stale`. `inputs_ok` sees the `None`, the site holds for
|
||
`stale_input_s`, then files `Claim.set("safety", P_SAFETY, 0.0, "inputs
|
||
missing")` and keeps filing it.
|
||
|
||
The reason the latch exists is written in the class docstring and it is a good
|
||
one: without it the verdict oscillates at about 1 Hz, because declaring the
|
||
reading stale drops the command below `MOVE_W`, which un-arms the conjunction,
|
||
which hands the held number straight back.
|
||
|
||
**The defect is that the only thing that ends it is an arrival carrying a
|
||
different number — and the state's own output is what stops the register being
|
||
asked for one.** The log line promised the opposite, in the same sentence that
|
||
announced the 0 W:
|
||
|
||
> *Treating it as MISSING, which commands 0 W. It clears itself the moment the
|
||
> register reports a different value.*
|
||
|
||
---
|
||
|
||
## 2. Reproducing it, from the wire
|
||
|
||
`scenarios/safety57-2026-09-21/bus-a-2026-09-21-1030-1110.log.gz` is 3840
|
||
verbatim frames off the RS-485 pair, 10:30–11:10 Europe/Brussels, decoded by
|
||
`tools/decode_bus_capture.py`. The decoder reports **0 unparsed, 0 bad CRC,
|
||
0 unknown shapes**, and its mode→sign convention is **graded off the capture**
|
||
rather than trusted: 298 commands at or under −1000 W are each answered one
|
||
obligation window later by a negative register reading, and not one positive.
|
||
|
||
| capture time | on the wire |
|
||
|---|---|
|
||
| 10:51:14 | `0x0518` = **−447 W**; the loop is charging |
|
||
| 10:51:19 | the command reverses: EMS **mode 3**, 260 W of discharge |
|
||
| 10:51:24.7 | `0x0518` = **−3 W**. The pack is at **11 %**, the BMS refuses the discharge, the inverter idles. **The register is healthy and truthful.** |
|
||
| 10:51:29.7 | the command reaches **+1060 W**. 800 W of travel |
|
||
| 10:52:21.7 | the setpoint is written **0 W** — this is the add-on's own §11.1 rule 1, recorded on the wire |
|
||
| …850.0 s | `0x0518` does not change value |
|
||
| …790.0 s | the setpoint does not change value |
|
||
| ~11:04 | **a person restarts the add-on** — not visible in this file, and the only thing that ends the episode |
|
||
| 11:05:31.7 | the restarted loop commands **−400 W of charge**, which the pack accepts at 11 % |
|
||
| 11:05:34.7 | `0x0518` = **−137 W** |
|
||
|
||
⚠️ **THE CAPTURE IS CLOSED-LOOP FOR THE WHOLE PARKED INTERVAL**, which is why
|
||
this is a reproduction and not a simulation: the box was running 0.10.0, so
|
||
every command in the trace is the shipped code's own. The only event inside the
|
||
window the file cannot account for is the human restart.
|
||
|
||
Replaying the shipped `_read_unit` loop at 1 Hz against those two series
|
||
(`test_freshness.py` part 10b):
|
||
|
||
| | stale at | clear at | parked |
|
||
|---|---|---|---|
|
||
| **before** (`release_s = inf`) | 10:52:00 | 11:05:35 | **815 s**, and `reading()` is `None` on all **812** reads from the trip to the first command a human put on the wire, with the register at −3.0 W in every one of them |
|
||
| **after** | 10:52:00 | **10:52:39** | **39 s**, with `0x0518` still answering the same **−3 W** |
|
||
|
||
⚠️ **THE COUNTERFACTUAL IS `release_s = inf`, AND IT IS GRADED RATHER THAN
|
||
CLAIMED.** With an infinite bound `_release()` never fires, `_open_window()` is
|
||
only ever called where the register moves, and the window pair is the
|
||
change-stamp pair — byte-for-byte the pre-SAFETY-57 conjunction. Part 10b
|
||
asserts that by driving part 1's own fresh/stale pair through both bounds and
|
||
comparing the verdict streams.
|
||
|
||
The replay also found a **one-read trip at 10:48:14** that clears one second
|
||
later. That is a pre-existing false positive and it is now recorded as
|
||
survivor 5 — §7.
|
||
|
||
---
|
||
|
||
## 3. What the card's option 2 actually buys, which is nothing here
|
||
|
||
The card offered *"do not arm the gate when the command being graded is 0 W"*
|
||
and warned that it might hold already. **It does, and it has since SAFETY-37**:
|
||
`_grade()` returns at `abs(standing_w) < self.bound.move_w`, and
|
||
`test_freshness.py` part 4 has a named check on that term, added because a
|
||
mutation removing it survived.
|
||
|
||
The warning in the card is the right one and it is worth writing down: **the
|
||
trip and the stuck state are different moments.** The gate armed at **+1060 W**,
|
||
where the conjunction is entirely correct — a command travelled 800 W and the
|
||
register did not move for 35 s. The site then went to **0 W** *because of the
|
||
verdict*. An entry-side rule cannot reach a state that is entered legitimately
|
||
and then persists. **The defect is the exit, and nothing else in the conjunction
|
||
is touched by this card.**
|
||
|
||
Option 3 — probing with a small non-zero command — was not taken and does not
|
||
need to be. It writes to a real actuator to test an assumption, and §4's rule
|
||
does not need a probe: the site's *normal* control resuming is the test, and it
|
||
is gated behind the same `inputs_ok` everything else is.
|
||
|
||
---
|
||
|
||
## 4. The decision: which verdicts get an exit, and what the bound is
|
||
|
||
> The stale verdict holds as before, **unless** the obligation that produced it
|
||
> is no longer standing — `|standing| < MOVE_W` — **and** it has been gone
|
||
> longer than the register would have needed to answer it. On a release the
|
||
> latch clears and a **new grading window** opens at that instant.
|
||
|
||
This is [[SAFETY-13]]'s §4 in a different module, and deliberately so: that card
|
||
established the house answer to "a latch with no exit" and its answer was *not*
|
||
a plain time bound but a **predicate plus a time bound**, where the predicate
|
||
says whether the state can end on its own. Here the predicate is that **the
|
||
question is no longer being asked.** A register under a 0 W command is obliged
|
||
to be nowhere, so a verdict about whether it is following cannot be tested, and
|
||
a verdict that cannot be tested may not stand for ever.
|
||
|
||
### 4.1 Why the window has to re-open, and what happens if it does not
|
||
|
||
`_grade()` compares the register's unchanged time against `unchanged_s` and the
|
||
command's travel against the command standing when the window opened. If a
|
||
release cleared the latch **without** re-opening the window, the first read at
|
||
which the resumed command travels 250 W would re-trip immediately — the
|
||
register would never get the 17 s in which it could answer, and the live
|
||
episode's recovery (a charge the pack **accepts**) would be killed inside two
|
||
seconds. Mutating exactly that (`graded_from` re-anchored to `changed_mono`,
|
||
+82 B) takes four named checks red.
|
||
|
||
The new window is `BATT_UNCHANGED_S` = **35 s**, the same bound as entry,
|
||
because it is the same question — *does this register follow the commands issued
|
||
from now on?* — and there is no principled shorter answer to it. It is also the
|
||
number the exposure argument in §5 rests on, so it is not free to move.
|
||
|
||
### 4.2 `RELEASE_S` = `BATT_OBLIGED_S` = 17.0 s, and which half is safe
|
||
|
||
The quantity needed is *has the register had its chance to answer the command
|
||
standing now?*, and `freshness.py` already derives it as the floor of
|
||
`BATT_UNCHANGED_S`'s band:
|
||
|
||
```
|
||
6.0 s plant goodwe/NOTES.md §5.9, measured 2026-08-22
|
||
10.0 s publisher firmware/goodwe-master.yaml:181, update_interval: 10s
|
||
1.0 s this add-on run_control()'s await asyncio.sleep(1)
|
||
```
|
||
|
||
⚠️ **IT IS THE FLOOR OF ITS BAND, NOT A SENSITIVITY PREFERENCE** — the mirror of
|
||
`MOVE_W` taking the **ceiling** of its own for the mirrored reason.
|
||
|
||
| | the question | the safe half | why |
|
||
|---|---|---|---|
|
||
| SAFETY-13's `SETTLE_CYCLES` | may the plant still be the **explanation**? | the **longer** half | a shorter bound stops believing the plant too early, and then it accuses |
|
||
| this one | may the verdict still be **tested**? | the **shorter** half | being wrong *long* is the failure being fixed; being wrong *short* costs one re-grading window, and §5 bounds that |
|
||
|
||
Below 17.0 s the release pre-empts the **cheaper exit** — the register answering
|
||
the new 0 W command by itself, which clears the latch on evidence rather than on
|
||
a timer. That is graded behaviourally rather than asserted: a register that
|
||
answers at +16 s clears by **movement** at `release_s = 17.0` with `releases`
|
||
still 0, and by **timer** at `release_s = 5.0`.
|
||
|
||
The family is computed and printed (habit 10): `5, 16, 17, 18, 35, inf` give
|
||
six distinct release instants, so no check in the file can confuse two of them.
|
||
|
||
### 4.3 Two clocks, and the operator reads the register's
|
||
|
||
`changed_mono` / `unchanged_s()` remain facts about the **register**: after a
|
||
release they go on reporting that it has not moved for 850 s, because it has
|
||
not. `graded_from` / `graded_s()` are the **verdict's** window. They coincide
|
||
until the first release. Collapsing them either lies to the operator or defeats
|
||
the re-grading, and both directions are mutated red.
|
||
|
||
The stale sentence's `unchanged for N s` slot carries the **register's** number;
|
||
a re-trip appends a clause naming the window and the grade count, so the two
|
||
durations cannot be read as one number that moved (habit 14).
|
||
|
||
---
|
||
|
||
## 5. What it costs, measured
|
||
|
||
Driven against a register frozen for good, 1201 reads, shipped defaults
|
||
(`test_freshness.py` part 10c):
|
||
|
||
| | parked | the held number reaches a consumer | releases |
|
||
|---|---|---|---|
|
||
| before | 1166 s | **35 reads (2.9 %)** | 0 |
|
||
| after | 571 s | **630 reads (52.5 %)** | 17 |
|
||
|
||
⚠️ **52 % IS PAID EVERY CYCLE, NOT OCCASIONALLY.** The period is
|
||
`stale_input_s` (15 s) + `RELEASE_S` (17 s) parked, then one 35 s window.
|
||
|
||
It is payable because of one inequality, and it is the inequality
|
||
`test_freshness.py` part 6 has graded against the shipped `config.yaml` since
|
||
SAFETY-37:
|
||
|
||
```
|
||
35 s (the re-grading window) < 75 s (circulation_hold_s) < 85 s (unit_sign_disagree_s)
|
||
```
|
||
|
||
Every unbroken run of exposure is **exactly one window**, so neither consumer
|
||
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.
|
||
|
||
**And nothing SAFETY-37 detects is given back**, which is the half that makes
|
||
this a trade rather than a regression: a permanent `None` clears those same two
|
||
windows for ever as well, so on the shipped-before module those detectors are
|
||
*equally* unable to speak about the unit. What the held number does reach for
|
||
35 s is `site_batt_w`, the anti-windup input of `control.compute()` — which
|
||
answers a large unexplained divergence by **freezing the output**, the
|
||
conservative direction and SAFETY-13's business — and the command itself is
|
||
derived from the **P1 meter**, never from `batt`.
|
||
|
||
### 5.1 The narrowing this does NOT take, named so the next card can
|
||
|
||
The live episode's register refused a **discharge** into an 11 % pack and then
|
||
followed a **charge** three seconds later. A refusal in one direction is not
|
||
evidence about the other, and a gate that knew the pack's floor could re-grade
|
||
on the first command that crosses it instead of on a timer — which would shrink
|
||
the 52 % above. That needs SoC, which this module deliberately does not take
|
||
(see the `soc` row and TEL-08), and the pack floor, which is SAFETY-32's number.
|
||
It is **not needed to give the state an exit**, and folding it in here would
|
||
make the exit depend on a second measurement being right.
|
||
|
||
---
|
||
|
||
## 6. The measurement that says the old exit was luck
|
||
|
||
`scenarios/loop-2026-09-02_2026-09-17/` replayed at 1 Hz through the shipped
|
||
conjunction (`test_freshness.py` part 8b): **93 trips in 16 days.** The only
|
||
thing that could clear any of them was the register carrying a different number,
|
||
and that took:
|
||
|
||
| | |
|
||
|---|---|
|
||
| minimum | 0.6 s |
|
||
| **median** | **143.4 s** |
|
||
| maximum | **21440.1 s** (5 h 57 m) |
|
||
| at or over the live episode's 710 s | **17 of 93** |
|
||
|
||
The short waits are the BMS keep-alive pulse — SAFETY-50 measured its ceiling at
|
||
508 W — twitching `0x0518` and satisfying the clearing rule **by accident**.
|
||
Over a fifth of the trips are cleared in under 20 s by something nothing in this
|
||
add-on asked for.
|
||
|
||
⚠️ **WHAT THAT REPLAY IS AND IS NOT.** The capture runs 2026-09-02…17 and
|
||
0.10.0 — the first version carrying this gate — was cut on 2026-09-16, so the
|
||
setpoint column is an **ungated** controller's. That makes each trip an honest
|
||
register-versus-command divergence off the real bus, and it makes the post-trip
|
||
command column something a gated box would **not** have written. The figure
|
||
taken from it is therefore the only one that does not depend on the command at
|
||
all — how long this register goes without changing value — and it **under**-states
|
||
the wait, because a site parked at 0 W gives the register even less reason to
|
||
move than the one recorded here.
|
||
|
||
---
|
||
|
||
## 7. Survivors, and the two things the battery found
|
||
|
||
The battery is 22 mutations over `app/freshness.py`, `app/main.py` and
|
||
`tools/decode_bus_capture.py`, plus one over the committed capture itself. All
|
||
run through `ci_run.sh`, a mirror of `checks.yml`'s own step: a purged copy of
|
||
the tree, bind-mounted `:ro`, one file at a time, `PYTHONDONTWRITEBYTECODE=1`.
|
||
Per MUTATION.md habit 18 the harness's scratch file is `mktemp`ed per
|
||
invocation and the tree is fingerprinted **after the revert** as well as before;
|
||
every revert restored the recorded sha256 and byte count.
|
||
|
||
**21 red, 1 argued equivalent, 0 unexplained survivors.** The table is in the
|
||
PR body. Two of them are worth keeping here.
|
||
|
||
### 7.1 The equivalent mutant, argued rather than rounded off
|
||
|
||
Swapping `_release()` and `_grade()` in `submit()` is a **zero-byte** edit and
|
||
survived the whole battery. **It is not a missing fixture and no fixture can
|
||
close it**: `_grade()` acts only when `|standing| >= move_w` and `_release()`
|
||
only when `|standing| < move_w`, so at most one of them can do anything on any
|
||
read, whichever runs first.
|
||
|
||
⚠️ **THE FIRST VERSION OF THE CODE CLAIMED THE ORDER WAS LOAD-BEARING**, in a
|
||
`⚠️` comment, with a reason that sounded right. It was false, and the battery is
|
||
what said so — **MUTATION.md habit 22 exactly, one card later: an explanation in
|
||
a comment is a claim, and this one had not been run.** The comment now states
|
||
the disjointness, and part 10 pins **that** — at 0, 100, 249, 250, 251 and
|
||
1300 W — instead of pinning an order that is not observable.
|
||
|
||
### 7.2 The sibling that survived, which is habit 11 earning its place
|
||
|
||
`_release()`'s obligation test is `abs(standing_w) >= self.bound.move_w`.
|
||
Swapping `move_w` for `unchanged_s` — comparing **watts against 35** — survived
|
||
the file at **+5 B**, because **every fixture rested at exactly 0 W**, and 0 is
|
||
below both 35 and 250. They separate only in the band between them, and a site
|
||
can sit there: a peak-guard floor, a shadow substitution, any claim that wins
|
||
with a small non-zero number. Closed by a three-point fixture at 0 / 100 / 300 W
|
||
that asserts the 100 W case releases and the 300 W case does not.
|
||
|
||
### 7.3 ⚠️ And the instrument lied once, which is habit 16's second half
|
||
|
||
The first form of the `graded_from` mutation was
|
||
`float(self.changed_mono or now_mono)`. It reported a **survivor at +21 B**, and
|
||
the tree fingerprint showed the edit really was on disk and really did run.
|
||
`changed_mono` is **0.0** in the dead-register fixture, and `0.0 or x` is `x` —
|
||
so the mutant was a *no-op on the fixture*, for a Python truthiness reason that
|
||
has nothing to do with the property under test. Rewritten as an explicit
|
||
`is None` test (+82 B) it is red on four named checks.
|
||
|
||
**A mutation is subject to every habit in MUTATION.md, including habit 4.** A
|
||
purge, a fingerprint and a byte delta all say the edit is present; none of them
|
||
says the edit *changed the answer on this stimulus*. MUTATION.md gains habit 19
|
||
for it.
|
||
|
||
---
|
||
|
||
## 8. What this does NOT fix, said plainly
|
||
|
||
**It does not tell a BMS refusal apart from a dead register**, and it does not
|
||
try to. Both are re-graded on the same timer. §5.1 names what telling them apart
|
||
would need.
|
||
|
||
**It does not make the diagnosis right.** The line the operator reads still says
|
||
*"battery-power register is not following its own command"*, which is wrong in a
|
||
different way when the pack is simply empty. That wording is **SAFETY-56's**,
|
||
together with `main.py`'s NOT READY row, and it is deliberately untouched here —
|
||
two cards editing one sentence is how one of them silently loses. Only the
|
||
**escape clause** was rewritten, because this card is what made the old one
|
||
false, and part 10h has a named check asserting the diagnosis half is still
|
||
there so that rewrite has to be a deliberate act.
|
||
|
||
> ⚠️ **SINCE SUPERSEDED, WHICH IS WHAT THAT CHECK WAS FOR.** [[SAFETY-56]] is
|
||
> the deliberate act: all three sentences now name **both** explanations and
|
||
> accuse neither, and part 10h's check is inverted — it requires the accusation
|
||
> to be **gone**. Nothing else in this section moved; the escape clause above is
|
||
> still this card's and is untouched by it. See
|
||
> `DECISIONS/SAFETY-56-what-the-gate-cannot-tell-apart.md`, and
|
||
> `test_freshness.py` part 11 for the graded pair of worlds.
|
||
|
||
**Survivor 5 is older than this card and is not closed by it.** After a long
|
||
rest the unchanged clock is already past 35 s, so the **first** read carrying a
|
||
travelled command trips the gate — before the 17.0 s in which a healthy register
|
||
could have answered it. The live capture contains one (10:48:14, cleared one
|
||
second later). Its size is one `stale_input_s` hold, during which `cycle()`
|
||
returns at `inputs missing {n}s` and commands nothing differently. 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.
|
||
|
||
**Nothing was measured on two inverters.** Inverter B does not physically exist
|
||
(`goodwe/NOTES.md` §1.1b). Every two-unit figure in `test_freshness.py` is two
|
||
copies of A's behaviour with different numbers passed in.
|
||
|
||
**No deploy is proposed.** 0.10.0 is on the reference install and this branch's
|
||
CHANGELOG row is filed under it only because `test_version_gate.py` part 11b
|
||
requires the newest heading to be a version. Moving it, and deciding whether a
|
||
version is cut, is a PKG card's.
|