Rule 2 graded each battery against last cycle's command and graded charges too. On plantsim's battery (10 s lag) at the 2 s meter it warned at every drain or charge start and 15 times an hour under the peak guard at N=2; a charge tapering near full warned 11 times in 50 min (SAFETY-42 section 7). Now a discharge is graded against the least it stood at over SHORT_CYCLES / SHORT_SPAN_S. DOCS lists `mode-bound` under Loop reason. test_safety85 part 7: peak guard with a dead battery, lagging plant, taper; part 3: the bound ends with maintenance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
451 lines
19 KiB
Python
451 lines
19 KiB
Python
"""Runnable check for SAFETY-85: no false 'not keeping up' while another owner
|
|
sets the battery. `python3 test_safety85.py`
|
|
|
|
The loop's integrator bound fired every ~2.5 min during maintenance and under
|
|
the peak guard, though the battery gave what it was told: the loop's own ask,
|
|
re-seeded and pushed by a grid error it did not own, crossed its rail. Now the
|
|
loop's clamp counts only on a cycle the loop decided, a battery another owner
|
|
set alarms only when it gives less than told, and at N>=2 the loop is bounded
|
|
while maintenance holds a battery. Drives the REAL Controller
|
|
(tools/safety84_identity.py's harness). DECISIONS/SAFETY-85-no-false-not-keeping-up.md.
|
|
|
|
1. N=1 and N=2: a drain, a charge and a HOLD with the house above what the
|
|
battery is set to, an hour each: no warning; without the fix they warn.
|
|
2. A battery that gives less than told during a drain does alarm (N=1, N=2);
|
|
a two-cycle dip does not.
|
|
3. Control coming back from maintenance does not jump (N=2 hold end, N=2
|
|
charge pin lifting, N=1 hold end).
|
|
4. The loop's own alarm stays (house over the rating); a safety limit at N=2
|
|
(a battery at its cut-out) does not warn.
|
|
5. The status row counts episodes, not cycles.
|
|
6. The rules at their boundaries.
|
|
7. Review: a battery the peak guard sets that gives nothing warns; a battery
|
|
that lags (10 s) and a charge that tapers do not.
|
|
|
|
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import sys
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
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 safety84_identity as s84 # noqa: E402
|
|
import app.main as app_main # noqa: E402
|
|
from app import clamp, messages # noqa: E402
|
|
from app.main import Controller # noqa: E402
|
|
from app.store import unit_key # noqa: E402
|
|
|
|
fails = []
|
|
A = "inverter-a"
|
|
HOUR = 3600.0
|
|
SLEW = 400.0 # the rig's slew_w
|
|
OPT0 = dict(s84.OPT)
|
|
|
|
|
|
def check(name, cond):
|
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
|
if not cond:
|
|
fails.append(name)
|
|
|
|
|
|
async def _call(self, *a):
|
|
"""The rig's Home Assistant, recording each notification id it is sent."""
|
|
if len(a) >= 3 and a[1] == "create" and isinstance(a[2], dict):
|
|
self.__dict__.setdefault("notes", []).append(
|
|
a[2].get("notification_id"))
|
|
return True
|
|
|
|
|
|
rig._Hass.call = _call
|
|
|
|
|
|
def store(n, phase):
|
|
"""Battery A in `phase` from the first cycle ('drain': due at 11:00;
|
|
'idle': only with `maint=False`)."""
|
|
if phase == "drain":
|
|
return rig._Store()
|
|
if phase == "charge":
|
|
return s79.store_charging(n)
|
|
s = rig._Store()
|
|
if phase == "hold":
|
|
for k, v in (("phase", "hold"), ("phase_started",
|
|
rig.BASE.isoformat())):
|
|
s.data[k if n == 1 else unit_key(A, k)] = v
|
|
return s
|
|
|
|
|
|
def run(n, phase, house, cap, *, dur=HOUR, soc=None, cut=None, extra=None,
|
|
maint=True):
|
|
"""Rows and controller; `cap` is the peak limit (20000: never binds);
|
|
`house` is watts or a function of time."""
|
|
s84.OPT = dict(OPT0, peak_cap_w=cap, maintenance_enabled=maint)
|
|
if soc is None:
|
|
def soc(u, t):
|
|
return 99.0 if phase == "hold" and u == "a" else 60.0
|
|
try:
|
|
load = house if callable(house) else (lambda t: house)
|
|
return s84.run(n, load=load, cheap=(phase == "charge"),
|
|
store=store(n, phase), soc=soc, dur=dur, cut=cut,
|
|
extra=dict({"maintenance_hold_min": 600,
|
|
"maintenance_discharge_w": 2000},
|
|
**(extra or {})))
|
|
finally:
|
|
s84.OPT = OPT0
|
|
|
|
|
|
def warned(c):
|
|
"""'Battery is not keeping up': notifications, cycles, episodes."""
|
|
return (getattr(c.hass, "notes", []).count(messages.ID_LOOP_BOUND),
|
|
c.clamp_wd.bound, c.clamp_wd.bound_episodes)
|
|
|
|
|
|
def patched(obj, name, value):
|
|
"""Run `fn` with `obj.name` replaced (the 'without the fix' runs)."""
|
|
def wrap(fn):
|
|
real = getattr(obj, name)
|
|
setattr(obj, name, value)
|
|
try:
|
|
return fn()
|
|
finally:
|
|
setattr(obj, name, real)
|
|
return wrap
|
|
|
|
|
|
no_gate = patched(Controller, "_loop_alarm_input", lambda self, r: None)
|
|
no_bound = patched(Controller, "loop_bound", lambda self, claims: None)
|
|
|
|
|
|
def cmd(r):
|
|
return dict(r[2])
|
|
|
|
|
|
def steps(rows, lo, hi):
|
|
"""The largest change in the total command between cycles in [lo, hi]."""
|
|
tot = [(r[0], sum(cmd(r).values())) for r in rows]
|
|
return max(abs(b - a) for (t0, a), (t1, b) in zip(tot, tot[1:])
|
|
if lo <= t1 <= hi)
|
|
|
|
|
|
# ============================================================================ #
|
|
print("1. the house above what the battery is set to, an hour each")
|
|
CASES = [
|
|
# (n, phase, house, cap): the peak guard raises the drain / the HOLD at a
|
|
# 3500 W limit; the rest are the phase alone under a house that pushes the
|
|
# loop's re-seeded ask past its rail.
|
|
(1, "drain", 6500.0, 3500), (1, "drain", 7500.0, 20000),
|
|
(1, "charge", 10000.0, 20000),
|
|
(1, "hold", 6500.0, 3500), (1, "hold", 9000.0, 20000),
|
|
(2, "drain", 6500.0, 3500), (2, "charge", 10000.0, 20000),
|
|
(2, "hold", 4500.0, 20000), (2, "hold", 6500.0, 3500),
|
|
]
|
|
NEW, OLD = {}, {}
|
|
for k in CASES:
|
|
NEW[k] = run(*k)
|
|
OLD[k] = no_gate(lambda: no_bound(lambda: run(*k)))
|
|
print(f" N={k[0]} {k[1]:6} house {k[2]:5.0f} limit {k[3]:5}: phases "
|
|
f"{sorted({p for r in NEW[k][0] for p in r[-1][3]})}, warnings "
|
|
f"(notes, cycles, episodes) {warned(NEW[k][1])}, without the fix "
|
|
f"{warned(OLD[k][1])}")
|
|
check("⭐ no 'Battery is not keeping up' notification, cycle or episode in any "
|
|
"of the nine hours, at N=1 and N=2",
|
|
all(warned(c) == (0, 0, 0) for _r, c in NEW.values()))
|
|
check("...the phase held all hour (the battery was set by maintenance, or by "
|
|
"the guard over it, throughout)",
|
|
all(all(r[-1][3][0] == k[1] for r in NEW[k][0]) for k in CASES))
|
|
check("non-vacuous: without the fix every one of the nine warns, the "
|
|
"notification re-created on the re-assert cadence",
|
|
all(warned(c)[0] >= 1 and warned(c)[1] > 0 for _r, c in OLD.values())
|
|
and sum(warned(c)[0] for _r, c in OLD.values()) >= 9 * 5)
|
|
|
|
# ============================================================================ #
|
|
print("\n2. a battery that really gives less than told still alarms")
|
|
|
|
|
|
def gone(t0, t1):
|
|
"""Battery A gives nothing from t0 to t1."""
|
|
return lambda u, t: u == "a" and t0 <= t < t1
|
|
|
|
|
|
SHORT = {n: run(n, "drain", 1000.0, 20000, dur=1200.0, cut=gone(600, 1200))
|
|
for n in (1, 2)}
|
|
for n, (rows, c) in SHORT.items():
|
|
print(f" N={n}: {warned(c)}, log "
|
|
f"{[e for e in c.events if 'OUTPUT_CLAMP' in e][:1]}")
|
|
_r1, _c1 = SHORT[1]
|
|
check("⭐ N=1, a drain of 2000 W that gives nothing: it warns, and the log "
|
|
"says what was asked and what came",
|
|
warned(_c1)[0] >= 1 and any(
|
|
"the battery gives -4 W of the +2000 W asked" in e
|
|
for e in _c1.events))
|
|
_r2, _c2 = SHORT[2]
|
|
check("⭐ N=2, the same on battery A while B follows the house: it warns and "
|
|
"names inverter-a",
|
|
warned(_c2)[0] >= 1 and any(
|
|
"inverter-a gives -4 W of the +2000 W asked" in e
|
|
for e in _c2.events))
|
|
_hb = run(2, "hold", 4500.0, 20000, dur=1200.0,
|
|
cut=lambda u, t: u == "b" and t >= 600)
|
|
print(f" N=2 HOLD on A, B gives nothing: {warned(_hb[1])}")
|
|
check("⭐ N=2, A in its HOLD and B, which the loop drives, gives nothing: the "
|
|
"loop stands at what B may give, and it warns naming inverter-b",
|
|
warned(_hb[1])[0] >= 1 and any(
|
|
"inverter-b gives -4 W of the +2500 W asked" in e
|
|
for e in _hb[1].events))
|
|
check("...it starts no earlier than the saturation cycles allow (3 x 5 s)",
|
|
all(e.split(" ", 1)[0] >= "11:10:10" for c in (_c1, _c2)
|
|
for e in c.events if "OUTPUT_CLAMP" in e))
|
|
_quiet = patched(app_main, "delivery_short", lambda *a: False)(
|
|
lambda: run(1, "drain", 1000.0, 20000, dur=1200.0, cut=gone(600, 1200)))
|
|
check("non-vacuous: without the delivery rule the N=1 drain that gives "
|
|
"nothing is silent (the loop's clamp never reaches its rail here)",
|
|
warned(_quiet[1]) == (0, 0, 0))
|
|
_dip = run(1, "drain", 1000.0, 20000, dur=900.0, cut=gone(600, 610))
|
|
check("a dip of two cycles is not an alarm", warned(_dip[1]) == (0, 0, 0))
|
|
|
|
# ============================================================================ #
|
|
print("\n3. control coming back from maintenance does not jump")
|
|
END = {"maintenance_hold_min": 10}
|
|
_bounds = []
|
|
_real_compute = app_main.compute
|
|
|
|
|
|
def _spy_compute(*a, **kw):
|
|
_bounds.append(kw.get("bound"))
|
|
return _real_compute(*a, **kw)
|
|
|
|
|
|
app_main.compute = _spy_compute
|
|
try:
|
|
_h2 = run(2, "hold", 4500.0, 20000, dur=900.0, extra=END)
|
|
finally:
|
|
app_main.compute = _real_compute
|
|
_h2_old = no_bound(lambda: run(2, "hold", 4500.0, 20000, dur=900.0,
|
|
extra=END))
|
|
_h1 = run(1, "hold", 4500.0, 20000, dur=900.0, extra=END)
|
|
s84.OPT = dict(OPT0)
|
|
_cd = s84.run(2, cheap=False)
|
|
_cd_old = no_bound(lambda: s84.run(2, cheap=False))
|
|
print(f" N=2 hold end: largest step {steps(_h2[0], 560, 700):.0f} W "
|
|
f"(without the bound {steps(_h2_old[0], 560, 700):.0f} W); N=1 "
|
|
f"{steps(_h1[0], 560, 700):.0f} W; N=2 charge pin lifts at 1800 s: "
|
|
f"{steps(_cd[0], 1780, 1840):.0f} W (without "
|
|
f"{steps(_cd_old[0], 1780, 1840):.0f} W)")
|
|
check("⭐ N=2, the HOLD ends under a 4500 W house: the batteries move at most "
|
|
"one slew step a cycle",
|
|
_h2[1].maints[0].phase != "hold" and steps(_h2[0], 560, 700) <= SLEW)
|
|
_held = [any(p != "idle" for p in r[-1][3]) for r in _h2[0]]
|
|
print(f" bound per cycle: {len(_bounds)} calls, {len(_held)} rows, "
|
|
f"{sum(_held)} in maintenance, "
|
|
f"{sum(b is not None for b in _bounds)} bounded")
|
|
check("...and the bound is on while A holds and off from the cycle the HOLD "
|
|
"ends (review)",
|
|
len(_bounds) == len(_held) and any(not h for h in _held)
|
|
and all((b is not None) == h for b, h in zip(_bounds, _held)))
|
|
check("⭐ N=2, A's charge pin lifts at a quarter's start: at most one slew "
|
|
"step a cycle", steps(_cd[0], 1780, 1840) <= SLEW)
|
|
check("...and N=1 already moved one slew step at a time (re-seeded each "
|
|
"cycle), unchanged", steps(_h1[0], 560, 700) <= SLEW)
|
|
check("non-vacuous: without the bound the N=2 loop stands wound at its rail "
|
|
"and both jump (2500 W at the hold's end)",
|
|
steps(_h2_old[0], 560, 700) >= 2000.0
|
|
and steps(_cd_old[0], 1780, 1840) >= 1000.0)
|
|
|
|
# ============================================================================ #
|
|
print("\n4. what still warns, and what a safety limit does")
|
|
_rail = run(1, "drain", 6500.0, 20000, dur=600.0, maint=False)
|
|
print(f" N=1 no maintenance, house 6500 over a 5000 W rating: "
|
|
f"{warned(_rail[1])}")
|
|
check("⭐ the loop in charge at its rail (house over the rating) still warns, "
|
|
"as SAFETY-40 decided", warned(_rail[1])[0] >= 1)
|
|
|
|
|
|
def at_cutout(u, t):
|
|
return 9.0 if u == "a" else 60.0
|
|
|
|
|
|
_cut = run(2, "idle", 6000.0, 20000, dur=900.0, soc=at_cutout, maint=False)
|
|
_cut_old = no_gate(lambda: run(2, "idle", 6000.0, 20000, dur=900.0,
|
|
soc=at_cutout, maint=False))
|
|
print(f" N=2, A at its cut-out, house 6000: {warned(_cut[1])}, without "
|
|
f"the fix {warned(_cut_old[1])}")
|
|
check("⭐ N=2, A at its cut-out level (a safety limit sets it to 0 W) and B "
|
|
"at its rating: no warning, each battery gives what it is told",
|
|
warned(_cut[1]) == (0, 0, 0) and cmd(_cut[0][-1]) == {"a": 0.0,
|
|
"b": 2500.0})
|
|
check("non-vacuous: without the fix it warns",
|
|
warned(_cut_old[1])[0] >= 1)
|
|
|
|
# ============================================================================ #
|
|
print("\n5. the status row counts episodes")
|
|
_wd = clamp.ClampWatchdog()
|
|
_t = datetime(2026, 9, 20, 11, 0, tzinfo=timezone.utc)
|
|
for ep in range(3):
|
|
for i in range(10):
|
|
_wd.note(True, "x", _t, bug=False)
|
|
_t += timedelta(seconds=5)
|
|
for i in range(clamp.CLEAR_CYCLES):
|
|
_wd.note(False, "", _t)
|
|
_t += timedelta(seconds=5)
|
|
_wd.note(True, "y", _t, bug=True)
|
|
check("three bound episodes of ten cycles and one over-rating: 3 episodes, "
|
|
"30 cycles", (_wd.bound_episodes, _wd.bound, _wd.episodes) == (3, 30, 4))
|
|
_rows = [x for x in _c1.checks() if x.get("id") == "not_keeping_up"]
|
|
check("⭐ the page row reads the episodes, not the cycles "
|
|
f"({_c1.clamp_wd.bound_episodes} vs {_c1.clamp_wd.bound})",
|
|
_rows and _c1.clamp_wd.bound > _c1.clamp_wd.bound_episodes >= 1
|
|
and _rows[0]["text"].startswith(messages.T_LOOP_BOUND + ": "
|
|
+ messages.times(
|
|
_c1.clamp_wd.bound_episodes)))
|
|
|
|
# ============================================================================ #
|
|
print("\n6. the rules at their boundaries")
|
|
DS = [((2000, 1499, 500), True), ((2000, 1500, 500), False),
|
|
((2000, 2600, 500), False), ((2000, -4, 500), True),
|
|
((0, -900, 500), False), ((0, 900, 500), False),
|
|
((-2000, -1499, 500), False), ((-2000, 600, 500), False)]
|
|
check("delivery_short: a discharge more than the margin short; more than "
|
|
"asked, 0 W asked, or a charge (a full pack tapers) is never short",
|
|
all(clamp.delivery_short(*a) is want for a, want in DS))
|
|
check("the note names each battery, asked before given",
|
|
clamp.shortfall_note([("inverter-a", 2000.0, -4.0)])
|
|
== "OUTPUT_CLAMP: inverter-a gives -4 W of the +2000 W asked"
|
|
and clamp.shortfall_note([(None, -2000.0, 0.0)])
|
|
== "OUTPUT_CLAMP: the battery gives +0 W of the -2000 W asked")
|
|
|
|
# ============================================================================ #
|
|
print("\n7. review: the peak guard, a battery that lags, a charge that tapers")
|
|
import math # noqa: E402
|
|
|
|
|
|
def under_guard(n, cut):
|
|
"""A HOLD the peak guard raises (house 6500, limit 3500), 20 min."""
|
|
return run(n, "hold", 6500.0, 3500, dur=1200.0, cut=cut)
|
|
|
|
|
|
_g1 = under_guard(1, lambda u, t: t >= 600)
|
|
_g2 = under_guard(2, lambda u, t: u == "a" and t >= 600)
|
|
print(f" under the guard, the battery gives nothing: N=1 {warned(_g1[1])}, "
|
|
f"N=2 {warned(_g2[1])}")
|
|
check("⭐ the peak guard sets the battery and it gives nothing: it warns at "
|
|
"N=1, and at N=2 (A, raised for the peak, gives nothing; B gives all "
|
|
"it is told)",
|
|
warned(_g1[1])[0] >= 1 and warned(_g2[1])[0] >= 1
|
|
and all("peak" in r[6] for rows, _c in (_g1, _g2) for r in rows
|
|
if 600 <= r[0] <= 620))
|
|
check("...and it gives what it is told until then (no warning before 600 s)",
|
|
all(e.split(" ", 1)[0] >= "11:10:00" for c in (_g1[1], _g2[1])
|
|
for e in c.events if "OUTPUT_CLAMP" in e))
|
|
|
|
|
|
def lagging(fn, lag=10.0):
|
|
"""Run `fn` on plantsim's battery (800 W/s ramp, `lag` s first-order lag)
|
|
at the live 2 s meter tick."""
|
|
real_p, real_t = rig._Hass.power, rig.TICK
|
|
|
|
def power(self, u):
|
|
st = self.__dict__.setdefault("_plant", {})
|
|
a, tgt, tl = st.get(u, (0.0, 0.0, self.t))
|
|
for _ in range(int(round((self.t - tl) / 0.1))):
|
|
d = self.cmd[u] - tgt
|
|
tgt += math.copysign(min(abs(d), 80.0), d)
|
|
a += (tgt - a) * (1 - math.exp(-0.1 / lag))
|
|
st[u] = (a, tgt, self.t)
|
|
if self.cut(u, self.t) and self.cmd[u] >= -4.0:
|
|
return -4.0
|
|
return float(round(a))
|
|
rig._Hass.power, rig.TICK = power, 2.0
|
|
try:
|
|
return fn()
|
|
finally:
|
|
rig._Hass.power, rig.TICK = real_p, real_t
|
|
|
|
|
|
def kettle(t):
|
|
"""800 W, plus 2 kW 3 min in 10, plus 3 kW 1 min in 4."""
|
|
return (800.0 + (2000.0 if t % 600 < 180 else 0.0)
|
|
+ (3000.0 if t % 240 < 60 else 0.0))
|
|
|
|
|
|
def lag_runs():
|
|
return [run(n, ph, kettle, 3500, dur=1800.0)
|
|
for n, ph in ((1, "drain"), (2, "drain"), (2, "hold"))]
|
|
|
|
|
|
_lag = lagging(lag_runs)
|
|
_lag_old = patched(app_main, "SHORT_CYCLES", 1)(
|
|
lambda: patched(app_main, "SHORT_SPAN_S", 0.0)(
|
|
lambda: lagging(lag_runs)))
|
|
_lag_dead = lagging(lambda: run(1, "drain", 1000.0, 20000, dur=900.0,
|
|
cut=gone(600, 900)))
|
|
print(f" lagging battery, house steps under the guard: "
|
|
f"{[warned(c) for _r, c in _lag]}, graded on last cycle's command "
|
|
f"{[warned(c) for _r, c in _lag_old]}; gives nothing "
|
|
f"{warned(_lag_dead[1])}")
|
|
check("⭐ a battery that lags (10 s) behind maintenance and peak-guard steps "
|
|
"at a 2 s meter does not warn (N=1 drain, N=2 drain and HOLD, 30 min)",
|
|
all(warned(c) == (0, 0, 0) for _r, c in _lag))
|
|
check("non-vacuous: graded on last cycle's command alone, it warns",
|
|
sum(warned(c)[0] for _r, c in _lag_old) >= 3)
|
|
check("...and a lagging battery that gives nothing still warns",
|
|
warned(_lag_dead[1])[0] >= 1)
|
|
|
|
|
|
def taper(fn):
|
|
"""Battery A takes the full charge until 600 s, then its acceptance falls
|
|
to 150 W over 30 min (constant-voltage taper)."""
|
|
real_p = rig._Hass.power
|
|
|
|
def power(self, u):
|
|
c = self.cmd[u]
|
|
if c < 0 and u == "a":
|
|
c = max(c, -max(150.0, 2500.0 - max(0.0, self.t - 600) / 1800
|
|
* 2350.0))
|
|
return float(round(c))
|
|
rig._Hass.power = power
|
|
try:
|
|
return fn()
|
|
finally:
|
|
rig._Hass.power = real_p
|
|
|
|
|
|
def charge_runs():
|
|
s84.OPT = dict(OPT0, peak_cap_w=20000)
|
|
try:
|
|
return [s84.run(n, load=lambda t: 800.0, cheap=True,
|
|
store=s79.store_charging(n), soc=lambda u, t: 60.0,
|
|
dur=2400.0) for n in (1, 2)]
|
|
finally:
|
|
s84.OPT = OPT0
|
|
|
|
|
|
def both_ways(asked, given, margin):
|
|
asked, given = float(asked), float(given)
|
|
return asked != 0.0 and (asked - given) * math.copysign(1.0, asked) > margin
|
|
|
|
|
|
_tp = taper(charge_runs)
|
|
_tp_old = patched(app_main, "delivery_short", both_ways)(
|
|
lambda: taper(charge_runs))
|
|
print(f" a charge that tapers: {[warned(c) for _r, c in _tp]}, grading "
|
|
f"charges too {[warned(c) for _r, c in _tp_old]}")
|
|
check("⭐ a maintenance charge that tapers near full does not warn (N=1, N=2)",
|
|
all(warned(c) == (0, 0, 0) for _r, c in _tp)
|
|
and all("charge" in {p for r in rows for p in r[-1][3]}
|
|
for rows, _c in _tp))
|
|
check("non-vacuous: grading charges too, the taper warns",
|
|
all(warned(c)[0] >= 3 for _r, c in _tp_old))
|
|
|
|
print()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
|
sys.exit(1)
|
|
print("all SAFETY-85 checks passed")
|