SAFETY-107: a refusal held under another writer is learned once (#183)
infra-pin / fresh (push) Successful in 1s
infra-pin / fresh (push) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #183.
This commit is contained in:
@@ -0,0 +1,96 @@
|
|||||||
|
# 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.
|
||||||
@@ -605,6 +605,10 @@ 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
|
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
|
at rest, which the add-on learns after two quiet minutes and keeps: some
|
||||||
inverters show about -100 W at rest (SAFETY-97).
|
inverters show about -100 W at rest (SAFETY-97).
|
||||||
|
While another task sets the power, such as a maintenance charge, the peak
|
||||||
|
guard or a trade, the refusal stays as long as the battery still refuses that
|
||||||
|
task's request. It is logged once, and the 200 W test waits until control sets
|
||||||
|
the power again (SAFETY-107).
|
||||||
|
|
||||||
**With two or more inverters** the same rule works per inverter. An inverter
|
**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
|
whose battery gives less than 100 W for 12 cycles, and at least 22 s, while
|
||||||
@@ -618,7 +622,7 @@ If the battery refuses to give power while its level reads above its “Battery
|
|||||||
cut-out level”, the log adds one warning ending in `REFUSES_ABOVE_CUT_OUT`: check that setting.
|
cut-out level”, the log adds one warning ending in `REFUSES_ABOVE_CUT_OUT`: check that setting.
|
||||||
A battery that refuses is not reported as *not delivering*.
|
A battery that refuses is not reported as *not delivering*.
|
||||||
|
|
||||||
*For installers:* SAFETY-52 and SAFETY-73. The event log reads
|
*For installers:* SAFETY-52, SAFETY-73 and SAFETY-107. The event log reads
|
||||||
`BATTERY REFUSES …` and `BATTERY REFUSES ENDED …` (with the inverter's name at
|
`BATTERY REFUSES …` and `BATTERY REFUSES ENDED …` (with the inverter's name at
|
||||||
N>1), and the INFO line ends in `BATTERY_REFUSES`.
|
N>1), and the INFO line ends in `BATTERY_REFUSES`.
|
||||||
`DECISIONS/SAFETY-52-a-battery-that-refuses.md` has the replay figures and
|
`DECISIONS/SAFETY-52-a-battery-that-refuses.md` has the replay figures and
|
||||||
|
|||||||
@@ -1279,9 +1279,26 @@ class Controller:
|
|||||||
_LOG.info("The battery refusal ends, control asks for power again: %s.",
|
_LOG.info("The battery refusal ends, control asks for power again: %s.",
|
||||||
why)
|
why)
|
||||||
|
|
||||||
def _refusal_update(self, decision, asked_w, batt_w, probe) -> None:
|
def _refusal_update(self, decision, asked_w, batt_w, probe,
|
||||||
"""Follow `compute()`'s carried refused direction, and say so."""
|
rejudged: int = 0) -> None:
|
||||||
|
"""Follow `compute()`'s carried refused direction, and say so.
|
||||||
|
|
||||||
|
`rejudged`: the refusal held while another writer had the wire, so
|
||||||
|
`compute()` judged it afresh against that writer's ask (SAFETY-107).
|
||||||
|
"""
|
||||||
now = decision.refused or 0
|
now = decision.refused or 0
|
||||||
|
if rejudged:
|
||||||
|
if now == rejudged:
|
||||||
|
# Still refused against the ask on the wire: the same episode.
|
||||||
|
# The probe clock restarts, as it did when this was an end and
|
||||||
|
# a new start: a probe is the loop's ask, and it is not on the
|
||||||
|
# wire while another writer is.
|
||||||
|
self.batt_refused_at = self._now_mono
|
||||||
|
return
|
||||||
|
gave = (rejudged * (batt_w - self._site_idle_w())
|
||||||
|
>= _ctl.PROBE_ANSWER_W)
|
||||||
|
self._refusal_end("the battery gives power" if gave
|
||||||
|
else "another writer")
|
||||||
if now and not self.batt_refused:
|
if now and not self.batt_refused:
|
||||||
self._refusal_start(now, f"asked {asked_w:+.0f} W, the battery "
|
self._refusal_start(now, f"asked {asked_w:+.0f} W, the battery "
|
||||||
f"gave {batt_w:+.0f} W for "
|
f"gave {batt_w:+.0f} W for "
|
||||||
@@ -4973,11 +4990,15 @@ class Controller:
|
|||||||
if trade_was and not mode_trade:
|
if trade_was and not mode_trade:
|
||||||
self._reseed_run_edge(claims)
|
self._reseed_run_edge(claims)
|
||||||
# SAFETY-52: another writer overrode the loop, so a refusal learned
|
# SAFETY-52: another writer overrode the loop, so a refusal learned
|
||||||
# against the loop's own ask no longer describes the wire. A gap
|
# against the loop's own ask may no longer describe the wire. A gap
|
||||||
# (`loop_w` None: blind or stopped) keeps it: the stale-reading stop
|
# (`loop_w` None: blind or stopped) keeps it: the stale-reading stop
|
||||||
# is what seeds it, and dropping it there restarts the ~70 s cycle.
|
# is what seeds it, and dropping it there restarts the ~70 s cycle.
|
||||||
|
# SAFETY-107: it ends only if compute() no longer finds it against
|
||||||
|
# that writer's ask (`_refusal_update()`). A held refusal makes the
|
||||||
|
# loop ask 0 W, so any claim that binds differs from it every cycle.
|
||||||
|
rejudged = 0
|
||||||
if self.loop_w is not None and self.target != self.loop_w:
|
if self.loop_w is not None and self.target != self.loop_w:
|
||||||
self._refusal_end("another writer")
|
rejudged = self.batt_refused
|
||||||
# SAFETY-96: a battery that dropped out of the split leaves the
|
# SAFETY-96: a battery that dropped out of the split leaves the
|
||||||
# loop's start point (SAFETY-93's own if its charge ended too).
|
# loop's start point (SAFETY-93's own if its charge ended too).
|
||||||
self._reseed_drop(_live96)
|
self._reseed_drop(_live96)
|
||||||
@@ -4986,7 +5007,8 @@ class Controller:
|
|||||||
# SAFETY-95: then a drain that ended, last (it reads the start
|
# SAFETY-95: then a drain that ended, last (it reads the start
|
||||||
# the others left).
|
# the others left).
|
||||||
self._reseed_drain_end(maint_results, _live96)
|
self._reseed_drain_end(maint_results, _live96)
|
||||||
probe = self._refusal_probe()
|
# No probe while it is judged afresh (the loop does not hold the wire).
|
||||||
|
probe = False if rejudged else self._refusal_probe()
|
||||||
if len(self.inverters) > 1:
|
if len(self.inverters) > 1:
|
||||||
probe = any(_probing.values())
|
probe = any(_probing.values())
|
||||||
# ⚠️ `actual_w` IS THE SITE SUM AND THAT IS DEV-02's OTHER HALF.
|
# ⚠️ `actual_w` IS THE SITE SUM AND THAT IS DEV-02's OTHER HALF.
|
||||||
@@ -5029,7 +5051,8 @@ class Controller:
|
|||||||
# limit already closes an empty battery's discharge end.
|
# limit already closes an empty battery's discharge end.
|
||||||
# SAFETY-73: at N>1 the site law holds 0 W only while every
|
# SAFETY-73: at N>1 the site law holds 0 W only while every
|
||||||
# unit refuses the same way. Otherwise None, the old law.
|
# unit refuses the same way. Otherwise None, the old law.
|
||||||
refused=self.batt_refused if len(self.inverters) == 1
|
refused=(0 if rejudged else self.batt_refused)
|
||||||
|
if len(self.inverters) == 1
|
||||||
else self._site_refused(),
|
else self._site_refused(),
|
||||||
probe=probe,
|
probe=probe,
|
||||||
# SAFETY-94: at N=1 the engage also needs the reading to have
|
# SAFETY-94: at N=1 the engage also needs the reading to have
|
||||||
@@ -5055,7 +5078,8 @@ class Controller:
|
|||||||
self.loop_w = decision.target_w
|
self.loop_w = decision.target_w
|
||||||
self._loop_decision = decision
|
self._loop_decision = decision
|
||||||
if len(self.inverters) == 1:
|
if len(self.inverters) == 1:
|
||||||
self._refusal_update(decision, loop_prev, site_batt, probe)
|
self._refusal_update(decision, loop_prev, site_batt, probe,
|
||||||
|
rejudged)
|
||||||
# --- TEL-10 ------------------------------------------------------
|
# --- TEL-10 ------------------------------------------------------
|
||||||
# ⚠️ THE INTEGRATOR HAS NEVER LEFT THIS PROCESS, AND THAT IS WHAT
|
# ⚠️ THE INTEGRATOR HAS NEVER LEFT THIS PROCESS, AND THAT IS WHAT
|
||||||
# THE CARD IS. `self.i_w` is carried between cycles and published
|
# THE CARD IS. `self.i_w` is carried between cycles and published
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
"""Runnable check for SAFETY-107: a refusal held while another writer has the
|
||||||
|
wire is learned once. `python3 test_safety107.py`
|
||||||
|
|
||||||
|
A full pack under a maintenance charge in a cheap window took no charge. At
|
||||||
|
one inverter SAFETY-52's "another writer" end then fired every cycle, and
|
||||||
|
compute() learned the refusal again in the same cycle: "BATTERY REFUSES" and
|
||||||
|
"BATTERY REFUSES ENDED: another writer" each second on the rig (MAINT-13 F5).
|
||||||
|
Now another writer ends a refusal only if compute(), judging it afresh
|
||||||
|
against that writer's ask, no longer finds it. DECISIONS/SAFETY-107.
|
||||||
|
|
||||||
|
Drives the REAL Controller (read_inputs + cycle) on opt11_identity's rig.
|
||||||
|
1. The card's case: 15 min of a full pack under a maintenance charge, N=1
|
||||||
|
and N=2, at a 1, 2 and 5 s meter: at most 2 refusal lines, held throughout.
|
||||||
|
2. Other writers: a trade and the peak guard over a battery that gives
|
||||||
|
nothing. One episode each, no flap.
|
||||||
|
3. A refusal still ends: a different writer really takes over, the battery
|
||||||
|
delivers again, a writer asks the other way, stop. The probe clock.
|
||||||
|
|
||||||
|
4. Not covered, pinned: a perfectly flat reading under a writer.
|
||||||
|
|
||||||
|
Mutations (MUTATION.md, purged copies) are listed in DECISIONS/SAFETY-107.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import functools
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
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.WARNING)
|
||||||
|
|
||||||
|
import opt11_identity as rig # noqa: E402
|
||||||
|
import safety79_identity as s79 # noqa: E402
|
||||||
|
import app.main as app_main # noqa: E402
|
||||||
|
from app import control # noqa: E402
|
||||||
|
from app.arbiter import Claim, P_ARBITRAGE # noqa: E402
|
||||||
|
from app.main import Controller # noqa: E402
|
||||||
|
from named_absence import nth, present # noqa: E402
|
||||||
|
|
||||||
|
fails = []
|
||||||
|
QUARTER = 900.0
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, cond):
|
||||||
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||||
|
if not cond:
|
||||||
|
fails.append(name)
|
||||||
|
|
||||||
|
|
||||||
|
def run(n=1, *, plant, house, dur=QUARTER, tick=5.0, maint=True,
|
||||||
|
cheap=lambda c, t: True, trade=lambda t: None, p1=False, cap=2500,
|
||||||
|
auto=lambda t: True):
|
||||||
|
"""Rows (t, commands, site target, loop reason, refused, refused_at,
|
||||||
|
unit refusals) and every action-log line as (t, line, hint).
|
||||||
|
|
||||||
|
`plant(u, t, cmd)` is battery u's reading. `cheap(c, t)` opens the cheap
|
||||||
|
window. `trade(t)` is a SET claim at P_ARBITRAGE added to the claim list
|
||||||
|
(a writer above the loop), or None."""
|
||||||
|
log, rows, now = [], [], [0.0]
|
||||||
|
real_power, real_tick = rig._Hass.power, rig.TICK
|
||||||
|
real_log, real_resolve, real_c = (Controller.log_event, app_main.resolve,
|
||||||
|
rig.Controller)
|
||||||
|
|
||||||
|
def power(self, u):
|
||||||
|
return float(plant(u, self.t, self.cmd[u]))
|
||||||
|
|
||||||
|
def log_event(self, msg, hint=None):
|
||||||
|
log.append((now[0], msg, hint))
|
||||||
|
return real_log(self, msg, hint)
|
||||||
|
|
||||||
|
def resolve(claims, **kw):
|
||||||
|
v = trade(now[0])
|
||||||
|
if v is not None:
|
||||||
|
claims.append(Claim.set("arbitrage", P_ARBITRAGE, v, "trade"))
|
||||||
|
return real_resolve(claims, **kw)
|
||||||
|
|
||||||
|
def before(c, t):
|
||||||
|
now[0] = t
|
||||||
|
if not getattr(c.hass, "_s107", False):
|
||||||
|
read = c.hass.number
|
||||||
|
|
||||||
|
async def number(e, invert=False):
|
||||||
|
if e == "sensor.pnow":
|
||||||
|
return 0.05 if cheap(c, c.hass.t) else 0.30
|
||||||
|
if e == "sensor.pavg":
|
||||||
|
return 0.20
|
||||||
|
return await read(e, invert)
|
||||||
|
c.hass.number, c.hass._s107 = number, True
|
||||||
|
if p1:
|
||||||
|
s79._feed(c, t)
|
||||||
|
c.auto = auto(t)
|
||||||
|
|
||||||
|
def on_cycle(c, t):
|
||||||
|
rows.append((t, tuple(c.hass.cmd[u] for u in "ab"[:n]), c.target,
|
||||||
|
c.loop_reason, c.batt_refused, c.batt_refused_at,
|
||||||
|
tuple(i.refused for i in c.inverters)))
|
||||||
|
|
||||||
|
extra = {"price_now_entity": "sensor.pnow",
|
||||||
|
"price_avg_entity": "sensor.pavg"}
|
||||||
|
if maint:
|
||||||
|
extra.update({"maintenance_enabled": True,
|
||||||
|
"maintenance_start_hour": 11,
|
||||||
|
"maintenance_charge_w": 2000})
|
||||||
|
if p1:
|
||||||
|
extra.update(s79.P1, peak_cap_w=cap)
|
||||||
|
db = os.path.join(tempfile.mkdtemp(prefix="s107-"), "tariff.db")
|
||||||
|
rig._Hass.power, rig.TICK = power, tick
|
||||||
|
Controller.log_event, app_main.resolve = log_event, resolve
|
||||||
|
rig.Controller = functools.partial(real_c, peak_db_path=db)
|
||||||
|
try:
|
||||||
|
_, c = rig.run(n, extra=extra, dur=dur, load=house,
|
||||||
|
soc=lambda u, t: 50.0, cut=lambda u, t: False,
|
||||||
|
before=before, on_cycle=on_cycle,
|
||||||
|
store=s79.store_charging(n) if maint else rig._Store())
|
||||||
|
finally:
|
||||||
|
rig._Hass.power, rig.TICK = real_power, real_tick
|
||||||
|
Controller.log_event, app_main.resolve = real_log, real_resolve
|
||||||
|
rig.Controller = real_c
|
||||||
|
return rows, log, c
|
||||||
|
|
||||||
|
|
||||||
|
def full_a(u, t, cmd):
|
||||||
|
"""A takes no charge (a full pack), its reading jittering by a few watts
|
||||||
|
as a real one does; B follows."""
|
||||||
|
return -1.0 - int(t) % 3 if u == "a" and cmd < 0 else round(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def flat_a(u, t, cmd):
|
||||||
|
"""`full_a` with a perfectly flat reading (the stale-reading gate trips)."""
|
||||||
|
return -1.0 if u == "a" and cmd < 0 else round(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def empty_a(u, t, cmd):
|
||||||
|
"""A gives nothing (an empty pack), its reading jittering by a few watts
|
||||||
|
(SAFETY-73's jitter shape, so the stale-reading gate stays out of it);
|
||||||
|
B follows."""
|
||||||
|
return -4.0 - int(t) % 3 if u == "a" and cmd >= -4.0 else round(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def lines(log, word="REFUSES"):
|
||||||
|
return [x for x in log if word in x[1]]
|
||||||
|
|
||||||
|
|
||||||
|
def ends(log):
|
||||||
|
return [x for x in log if "REFUSES ENDED" in x[1]]
|
||||||
|
|
||||||
|
|
||||||
|
def refused(r):
|
||||||
|
"""Row r holds a refusal (site at N=1, any unit at N>1)."""
|
||||||
|
return bool(r[4]) or any(r[6])
|
||||||
|
|
||||||
|
|
||||||
|
def held_from(rows):
|
||||||
|
"""True when, once learned, the refusal is held on every later row."""
|
||||||
|
first = next((k for k, r in enumerate(rows) if refused(r)), None)
|
||||||
|
return first is not None and all(refused(r) for r in rows[first:])
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print("1. the card: a full pack under a maintenance charge, 15 min")
|
||||||
|
# ==============================================================================
|
||||||
|
for n, tick, house in ((n, k, h) for n in (1, 2) for k in (1.0, 2.0, 5.0)
|
||||||
|
for h in (800.0, -1500.0)):
|
||||||
|
rows, log, c = run(n, plant=full_a, house=lambda t: house, tick=tick)
|
||||||
|
ref = lines(log)
|
||||||
|
tag = f"N={n}, {tick:.0f} s meter, house {house:+.0f} W"
|
||||||
|
print(f" {tag}: {len(ref)} refusal line(s): "
|
||||||
|
+ " / ".join(x[1][:60] for x in ref))
|
||||||
|
check(f"{tag}: at most 2 refusal lines in 15 min (rig: 548 + 548)",
|
||||||
|
1 <= len(ref) <= 2)
|
||||||
|
check(f"{tag}: no end line", not ends(log))
|
||||||
|
check(f"{tag}: learned once and held to the end", held_from(rows))
|
||||||
|
# N=1: the charge's ceiling stays on the wire. N=2: SAFETY-73 closes A
|
||||||
|
# in the refused direction, as before.
|
||||||
|
check(f"{tag}: still in its charge, the wire as before",
|
||||||
|
c.maints[0].phase == "charge"
|
||||||
|
and all(r[1][0] == (-2000.0 if n == 1 else 0.0)
|
||||||
|
for r in rows[-10:]))
|
||||||
|
# The hint under the buttons is set at most once for the refusal.
|
||||||
|
check(f"{tag}: the refusal hint is set once",
|
||||||
|
sum(1 for x in ref if x[2] is not None) == 1)
|
||||||
|
|
||||||
|
# The probe clock: while the charge holds the wire no probe is tried (the
|
||||||
|
# loop's ask is not on the wire), so the action log stays quiet.
|
||||||
|
rows, log, _ = run(1, plant=full_a, house=lambda t: 800.0, dur=3600.0)
|
||||||
|
check("N=1, an hour: still one line, no probe tried under the charge",
|
||||||
|
len(lines(log)) == 1
|
||||||
|
and not any(r[3] == control.PROBING for r in rows))
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print("\n2. other writers: a trade and the peak guard, no flap")
|
||||||
|
# ==============================================================================
|
||||||
|
# A trade (a SET above the loop) asks a full pack to charge for 15 min.
|
||||||
|
rows, log, _ = run(1, plant=full_a, house=lambda t: 800.0, maint=False,
|
||||||
|
trade=lambda t: -1500.0)
|
||||||
|
print(f" trade into a full pack: {len(lines(log))} line(s)")
|
||||||
|
check("a trade into a full pack: learned once, held, no end",
|
||||||
|
len(lines(log)) == 1 and not ends(log) and held_from(rows))
|
||||||
|
# The peak guard asks an empty pack to give while the house is over the limit.
|
||||||
|
rows, log, c = run(1, plant=empty_a, house=lambda t: 4500.0, maint=False,
|
||||||
|
p1=True)
|
||||||
|
print(f" peak guard on an empty pack: {len(lines(log))} line(s), "
|
||||||
|
f"guard floor {c.peak_guard.floor_w if c.peak_guard else None}")
|
||||||
|
check("the peak guard on an empty pack: the guard holds the wire",
|
||||||
|
c.peak_guard is not None and c.peak_guard.engaged)
|
||||||
|
check("the peak guard on an empty pack: at most 2 lines, held to the end",
|
||||||
|
1 <= len(lines(log)) <= 2 and held_from(rows))
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print("\n3. a refusal still ends")
|
||||||
|
# ==============================================================================
|
||||||
|
# a. Learned by the loop on an empty pack; at 300 s a trade really takes the
|
||||||
|
# wire and asks it to give 2000 W. The refusal was learned against the
|
||||||
|
# loop's ask: it ends, then is learned against the trade, once.
|
||||||
|
rows, log, _ = run(1, plant=empty_a, house=lambda t: 2000.0, maint=False,
|
||||||
|
trade=lambda t: 2000.0 if 300.0 <= t < 900.0 else None,
|
||||||
|
dur=1200.0)
|
||||||
|
e = ends(log)
|
||||||
|
print(" " + " / ".join(f"{x[0]:.0f} s {x[1][:45]}" for x in lines(log)))
|
||||||
|
check("a different writer takes over: the refusal ends, 'another writer'",
|
||||||
|
len(e) == 1 and "another writer" in e[0][1] and 300.0 < e[0][0] < 320.0)
|
||||||
|
check("...and is learned again against the trade, once, and held",
|
||||||
|
len(lines(log)) == 3 and held_from([r for r in rows if r[0] > 400.0]))
|
||||||
|
check("...and the loop keeps it when the trade leaves (no line)",
|
||||||
|
all(x[0] < 900.0 for x in lines(log)) and refused(nth(rows, -1)))
|
||||||
|
# The writer asks the other way, which the empty pack takes.
|
||||||
|
rows, log, _ = run(1, plant=empty_a, house=lambda t: 2000.0, maint=False,
|
||||||
|
trade=lambda t: -1500.0 if t >= 300.0 else None)
|
||||||
|
e = ends(log)
|
||||||
|
check("a writer that asks the other way ends it ('another writer')",
|
||||||
|
len(e) == 1 and "another writer" in e[0][1] and present(nth(rows, -1))
|
||||||
|
and not refused(nth(rows, -1)))
|
||||||
|
# b. The full pack starts taking charge again under the same writer.
|
||||||
|
rows, log, _ = run(1, plant=lambda u, t, cmd: (
|
||||||
|
round(cmd) if t >= 400.0 else full_a(u, t, cmd)), house=lambda t: 800.0)
|
||||||
|
e = ends(log)
|
||||||
|
print(" " + " / ".join(f"{x[0]:.0f} s {x[1][:45]}" for x in lines(log)))
|
||||||
|
check("the battery takes charge again: the refusal ends once, on its reading",
|
||||||
|
len(e) == 1 and "the battery gives power" in e[0][1]
|
||||||
|
and 400.0 <= e[0][0] < 430.0 and present(nth(rows, -1))
|
||||||
|
and not refused(nth(rows, -1)))
|
||||||
|
# c. Stop and start still end it.
|
||||||
|
rows, log, _ = run(1, plant=full_a, house=lambda t: 800.0,
|
||||||
|
auto=lambda t: not 500.0 <= t < 520.0)
|
||||||
|
e = ends(log)
|
||||||
|
check("stopping control still ends it", any("control stopped" in x[1]
|
||||||
|
for x in e))
|
||||||
|
# d. The probe clock. Learned under the charge; the cheap window closes when
|
||||||
|
# the refusal is 910 s old, and the loop holds the wire (a surplus, so it
|
||||||
|
# wants to charge). The first probe is a full PROBE_EVERY_S after the
|
||||||
|
# window closed, as before: never at once.
|
||||||
|
close = [None]
|
||||||
|
|
||||||
|
|
||||||
|
def window(c, t):
|
||||||
|
if close[0] is None and c.batt_refused:
|
||||||
|
close[0] = t + 910.0
|
||||||
|
return close[0] is None or t < close[0]
|
||||||
|
|
||||||
|
|
||||||
|
rows, log, _ = run(1, plant=full_a, house=lambda t: -1500.0, cheap=window,
|
||||||
|
dur=3000.0)
|
||||||
|
probes = [r[0] for r in rows if r[3] == control.PROBING]
|
||||||
|
print(" " + " / ".join(f"{x[0]:.0f} s {x[1][:45]}" for x in lines(log)))
|
||||||
|
print(f" window closed at {close[0]} s, first probe at "
|
||||||
|
f"{probes[0] if probes else None} s")
|
||||||
|
check("the refusal carries over to the loop with no line",
|
||||||
|
len(lines(log)) == 1 and held_from(rows))
|
||||||
|
check("the first probe after the writer left comes PROBE_EVERY_S later",
|
||||||
|
close[0] is not None and bool(probes)
|
||||||
|
and probes[0] >= close[0] + control.PROBE_EVERY_S - 10.0)
|
||||||
|
# e. A writer turns round on a dead pack with no gap. The evidence restarts
|
||||||
|
# on the new sign (SAFETY-94), so the old refusal ends at once and the
|
||||||
|
# other way is learned once, ~12 cycles later.
|
||||||
|
rows, log, _ = run(1, plant=lambda u, t, cmd: (
|
||||||
|
-2.0 - int(t) % 3 if u == "a" else round(cmd)), house=lambda t: 2000.0,
|
||||||
|
maint=False, trade=lambda t: 2000.0 if t < 300.0 else -2000.0)
|
||||||
|
e = ends(log)
|
||||||
|
print(" " + " / ".join(f"{x[0]:.0f} s {x[1][:45]}" for x in lines(log)))
|
||||||
|
check("a writer turns round on a dead pack: one end, then the other way",
|
||||||
|
len(lines(log)) == 3 and len(e) == 1 and 300.0 < e[0][0] < 400.0
|
||||||
|
and present(nth(rows, -1)) and nth(rows, -1)[4] == -1)
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print("\n4. not covered: a perfectly flat reading under a writer")
|
||||||
|
# ==============================================================================
|
||||||
|
# The stale-reading gate trips on a flat reading while the command moves,
|
||||||
|
# stops the site and seeds the refusal (SAFETY-52 decision 3). After the stop
|
||||||
|
# the writer's ask comes back fresh, compute() has no saturation history
|
||||||
|
# against it yet, so the refusal ends at the first cycle and the next trip
|
||||||
|
# seeds it again: one pair per gate cycle, as on release/1.0 (the commands
|
||||||
|
# are the same; DECISIONS/SAFETY-107 "Not covered"). Pinned so a change shows.
|
||||||
|
rows, log, _ = run(1, plant=flat_a, house=lambda t: -1500.0)
|
||||||
|
trips = len(lines(log, "STALE inverter"))
|
||||||
|
print(f" flat reading, house -1500 W: {trips} gate trips, "
|
||||||
|
f"{len(lines(log))} refusal lines in 15 min")
|
||||||
|
check("flat reading: no per-cycle flap, at most one pair per gate trip",
|
||||||
|
trips >= 1 and len(lines(log)) <= 2 * trips)
|
||||||
|
|
||||||
|
print()
|
||||||
|
if fails:
|
||||||
|
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||||
|
sys.exit(1)
|
||||||
|
print("all SAFETY-107 checks passed")
|
||||||
Reference in New Issue
Block a user