97 lines
5.6 KiB
Markdown
97 lines
5.6 KiB
Markdown
# SAFETY-107 - a refusal held under another writer is learned once
|
|
|
|
**Status: for review.** Tests: `test_safety107.py`. One inverter only; nothing
|
|
changes at N>1 (SAFETY-73's unit refusal has no "another writer" end).
|
|
|
|
**The defect** (MAINT-13 F5, MAINT-14 "seen again"). A full pack under a
|
|
maintenance charge in a cheap window takes no charge. At N=1 the action log then
|
|
showed "BATTERY REFUSES asked -2500 W ..." and "BATTERY REFUSES ENDED: another
|
|
writer" every cycle: 548 + 548 lines in 15 min on the MAINT-13 rig head,
|
|
652 + 651 in 12 min on its base, 33 + 32 in 60 s in MAINT-14's c2b. The 40-line
|
|
log flooded and the hint under the buttons changed every second, so the
|
|
MAINT-13 notice was gone at once.
|
|
|
|
**Cause** (`main.py`, the SAFETY-52 block before `compute()`). The end tested
|
|
`self.target != self.loop_w`: the wire differs from the loop's ask. A held
|
|
refusal makes the loop ask 0 W, so any claim that binds (the maintenance
|
|
charge's `hi`, the peak guard's `lo`, a trade's SET) differs from it on every
|
|
cycle. The refusal was ended against the same writer it had been learned
|
|
against. `sat_count` is not reset by that end, so `compute()` learned the same
|
|
refusal again in the same cycle. Nothing about the battery or the wire had
|
|
changed; only the log and the hint did.
|
|
|
|
**Other writers.** The same flap comes from any writer that holds the wire
|
|
while the loop is refused, as long as the evidence lasts: a trade (a SET at
|
|
P_ARBITRAGE) into a full pack, and the peak guard over an empty pack: 335
|
|
refusal lines in 15 min each at a 5 s meter (`test_safety107.py` part 2, run on
|
|
release/1.0).
|
|
A maintenance drain or hold does not flap: its SET resets `sat_count` each cycle.
|
|
|
|
**The rule** (`_refusal_update()`, `rejudged`). When another writer held the
|
|
wire, the held refusal is judged afresh: `compute()` gets `refused=0` and no
|
|
probe, exactly as before the change. Then:
|
|
|
|
1. it finds the same refusal against that writer's ask: the same episode
|
|
continues, with no line and no hint. The probe clock restarts, as it did on
|
|
the old end and start: a probe is the loop's ask, and it is not on the wire
|
|
while another writer is;
|
|
2. it does not: the refusal ends, logged "another writer", or "the battery
|
|
gives power" when the reading shows it (it was "another writer" before);
|
|
3. it finds the other direction: end, then a new start, as before. In
|
|
practice 2 comes first: SAFETY-94's evidence restarts when the ask changes
|
|
sign, so a writer that turns round ends the refusal at once and the other
|
|
way is learned ~12 cycles later (`test_safety107.py` part 3e).
|
|
|
|
**Why this rule.** The end now means what SAFETY-52 meant by it: the refusal no
|
|
longer describes what is on the wire, judged by the same test that learns it.
|
|
`compute()` sees the same inputs as before and the state after each cycle is
|
|
the same (`batt_refused`, `batt_refused_at`), so every command is unchanged.
|
|
Keeping the held refusal into `compute()` instead (mutation M5) would also
|
|
stop the lines, but it holds the loop at 0 W where the old law let it ask past
|
|
a writer's limit, which changes commands; it also removes SAFETY-52's
|
|
owner-decided "another writer" exit.
|
|
|
|
**Evidence.**
|
|
- Differential trace (`goodwe/scratch/SAFETY-107/probe/trace.py`, not in this
|
|
repo; the real Controller in the CI image, release/1.0 bf3ac79 against this
|
|
tree): 300 seeds, N=1 (153) and
|
|
N=2 (147), 1/2/5 s meter, full/empty/dead/healthy battery windows, a stepping
|
|
house, maintenance charge with the cheap window opening and closing, P1 and
|
|
the peak guard, trade windows, stop/start; 427 680 cycles, 294 seeds with a
|
|
refusal held. Every command, site target, integrator, `sat_count`, refusal
|
|
state and reason is identical. Refusal lines: 100 748 before, 2 188 after.
|
|
- The card's case: 1 line in 15 min at N=1 and N=2, 1/2/5 s meter, house
|
|
importing or exporting (release/1.0: 335-1 753 at N=1; N=2 was already 1).
|
|
|
|
**Mutations** (`tools/mutation_run.sh` on purged copies; reds in
|
|
test_safety107 / test_refusal / test_refusal_units; trace = seeds of 300 whose
|
|
commands differ from release/1.0):
|
|
|
|
| | mutation | reds | trace |
|
|
|---|---|---|---|
|
|
| M1 | `now == rejudged` -> `False` (every such cycle ends: the old flap) | 26 / 0 / 0 | 0 |
|
|
| M2 | `rejudged` always 0 (another writer never ends it) | 5 / 1 / 2 | 29 |
|
|
| M3 | the probe clock is not restarted on a continued episode | 1 / 0 / 0 | 2 |
|
|
| M4 | a probe may open while rejudged | 0 / 0 / 0 | 0 |
|
|
| M5 | the held refusal goes into `compute()` while rejudged | 2 / 1 / 2 | 27 |
|
|
| M6 | an end while rejudged always says "another writer" | 1 / 0 / 0 | 0 |
|
|
|
|
M4 is equivalent: a probe matters only if `compute()` engages in that call,
|
|
which needs `SETTLE_CYCLES` saturated against the writer, and each of those
|
|
cycles restarted the clock. The line stays because it states the rule.
|
|
|
|
**Cost.** None in power. A takeover by a different writer while the battery
|
|
still refuses is logged as an end and, ~12 cycles later, a new start against
|
|
that writer (3 lines for the episode, `test_safety107.py` part 3a).
|
|
|
|
**Not covered.**
|
|
- A perfectly flat reading under a writer. The stale-reading gate trips, stops
|
|
the site and seeds the refusal (SAFETY-52 decision 3). After the stop the
|
|
writer's ask returns fresh, `compute()` has no saturation history against it,
|
|
so the refusal ends at the first cycle and the next trip seeds it again: 2
|
|
lines per gate cycle (29 in 15 min, house exporting), the same as release/1.0.
|
|
The gate cycle itself (0 W, then the writer's ask) is the cause; stopping it
|
|
changes commands. None of the rig runs showed it: real readings move.
|
|
- At N=1 a refusal shapes only the loop. A writer above it still asks the
|
|
battery; at N>1 SAFETY-73's P_SAFETY claim binds every writer.
|