diff --git a/goodwe_controller/DECISIONS/SAFETY-107-refusal-under-another-writer.md b/goodwe_controller/DECISIONS/SAFETY-107-refusal-under-another-writer.md new file mode 100644 index 0000000..a3c1062 --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-107-refusal-under-another-writer.md @@ -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. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 7c46df8..0d00d11 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -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 at rest, which the add-on learns after two quiet minutes and keeps: some 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 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. 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 N>1), and the INFO line ends in `BATTERY_REFUSES`. `DECISIONS/SAFETY-52-a-battery-that-refuses.md` has the replay figures and diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index b7a3fef..a85adce 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -1279,9 +1279,26 @@ class Controller: _LOG.info("The battery refusal ends, control asks for power again: %s.", why) - def _refusal_update(self, decision, asked_w, batt_w, probe) -> None: - """Follow `compute()`'s carried refused direction, and say so.""" + def _refusal_update(self, decision, asked_w, batt_w, probe, + 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 + 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: self._refusal_start(now, f"asked {asked_w:+.0f} W, the battery " f"gave {batt_w:+.0f} W for " @@ -4973,11 +4990,15 @@ class Controller: if trade_was and not mode_trade: self._reseed_run_edge(claims) # 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 # 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: - self._refusal_end("another writer") + rejudged = self.batt_refused # 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). self._reseed_drop(_live96) @@ -4986,7 +5007,8 @@ class Controller: # SAFETY-95: then a drain that ended, last (it reads the start # the others left). 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: probe = any(_probing.values()) # ⚠️ `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. # SAFETY-73: at N>1 the site law holds 0 W only while every # 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(), probe=probe, # 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_decision = decision 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 ------------------------------------------------------ # ⚠️ THE INTEGRATOR HAS NEVER LEFT THIS PROCESS, AND THAT IS WHAT # THE CARD IS. `self.i_w` is carried between cycles and published diff --git a/goodwe_controller/test_safety107.py b/goodwe_controller/test_safety107.py new file mode 100644 index 0000000..e3f7516 --- /dev/null +++ b/goodwe_controller/test_safety107.py @@ -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")