SAFETY-52 review: a refusal is under 100 W, and any delivery ends it
- Engage (and seed from a stale stop) below REFUSED_BELOW_W, the probe's own 100 W answer, not below saturation_w: a battery derated to 100-499 W was held at 0 W ~93 % of the time. - A reading of 100 W or more in the held direction ends the refusal, probe or not: at a 1 s meter SETTLE_CYCLES is 12 s, shorter than a healthy reading's lag, and that held a healthy step at 0 W until the next probe. - test_refusal part 5b and a seed test; DOCS states the N>1 limit; the DECISIONS record carries the traced cause of the replay's extra trips. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,12 +19,13 @@ instead and ran SAFETY-59's ~70 s trip cycle.
|
||||
|
||||
1. **A refused direction is held at 0 W**, reason `battery refuses`. It engages
|
||||
in `compute()` when the freeze has held `SETTLE_CYCLES` and the reading is
|
||||
within `saturation_w` of 0 against an ask beyond it. The drop to 0 skips
|
||||
below `REFUSED_BELOW_W` (100 W, the probe's own answer) against an ask beyond
|
||||
`saturation_w`. The drop to 0 skips
|
||||
the slew limit (it only removes an ask nobody meets). The direction is
|
||||
carried like `sat_count` (`refused=`, `Decision.refused`).
|
||||
2. **A 200 W probe every 15 minutes, 34 s long** (`PROBE_W`, `PROBE_EVERY_S`,
|
||||
`PROBE_S`; twice `BATT_OBLIGED_S`). An answer of at least 100 W ends the
|
||||
refusal. 200 W is below `freshness.MOVE_W` (250 W), so a probe cannot arm
|
||||
`PROBE_S`; twice `BATT_OBLIGED_S`). A reading of at least 100 W in the held
|
||||
direction ends the refusal, during a probe or not. 200 W is below `freshness.MOVE_W` (250 W), so a probe cannot arm
|
||||
the gate. The clock is main.py's `now_mono`; `compute()` stays clockless.
|
||||
3. **A gate trip while the loop is frozen on a ~0 W reading is a refusal** for
|
||||
control (`Controller._refusal_from_stale`). This is what stops the ~70 s
|
||||
@@ -52,18 +53,43 @@ shapes, recovery with no sign change, the probe cadence, every exit).
|
||||
| plant | law | gate trips | request >1 kW above delivery | longest held | import / export kWh | worst 15-min |
|
||||
|---|---|---|---|---|---|---|
|
||||
| fitted | old | 85 | 113.3 h | 16.57 h | 112.0 / 35.2 | 3434 W |
|
||||
| fitted | new | 130 | 11.4 h | 0.21 h | 112.3 / 35.4 | 3456 W |
|
||||
| fitted | new | 130 (133 after review) | 11.4 h | 0.21 h | 112.3 / 35.4 | 3456 W |
|
||||
| offset 0 W | old | 1010 | 102.2 h | 16.58 h | 112.0 / 35.2 | 3434 W |
|
||||
| offset 0 W | new | 85 | 6.1 h | 0.33 h | 111.9 / 35.1 | 3434 W |
|
||||
|
||||
The +22 W worst quarter and the +45 trips on the fitted plant follow the fit's
|
||||
-22 W offset, which slowly charges the modelled pack above its floor while the
|
||||
hold stands. With the offset at 0 both vanish. That is inferred from these two
|
||||
rows and was not traced further. Rejected alternatives and their figures are in
|
||||
**Traced in review (#141).** The +22 W worst quarter is the old law's worst
|
||||
quarter too. There the old law stands at +2060 W on a pack giving 0 W, and the
|
||||
new law asks 0 W, which the fitted plant answers with its -22 W offset: 22 W
|
||||
charged from the grid. The fit above the floor reads `batt = cmd - 28 W`, so a
|
||||
real inverter asked 0 W may do the same. Of the 71 new-law trips with no
|
||||
old-law trip within 2 min, 59 come 85-97 s after an answered probe. The offset has lifted the modelled pack
|
||||
just above its floor, the probe is answered, control ramps, the pack empties
|
||||
again mid-ramp, and the gate trips, which re-seeds the hold. The other 12
|
||||
follow an other-direction exit the same way. On a site this is one
|
||||
`STALE_READING` ERROR line per answered probe at most, and only on a battery
|
||||
that answers a probe and then cuts out again. Catching the re-cut before the
|
||||
gate needs a faster engage than `SETTLE_CYCLES`, which accuses a healthy slow
|
||||
battery, so it is left to SAFETY-58/59. After the review changes below the
|
||||
fitted-plant row reads 133 trips (the delivery exit now also catches answers
|
||||
that arrive after the probe window); the other figures are unchanged. Rejected alternatives and their figures are in
|
||||
the phase A plan: bounding the integrator by demand made the worst quarter
|
||||
+541 W worse or cycled the gate 3368 times, and an SoC envelope needs the
|
||||
configured floor to be right and needs SoC.
|
||||
|
||||
## Review changes (#141)
|
||||
|
||||
- **"Gives nothing" is below 100 W, not below 500 W.** At `saturation_w` a
|
||||
battery derated to 100-499 W (cold, a tapering battery) was held at 0 W for
|
||||
~93 % of the time: 300 W derated gave 86 Wh in 4 h against 1 200 Wh on the
|
||||
old law. The capture has 0.11 h in that band, so the replay does not move.
|
||||
- **Any delivery ends the hold, not only an answered probe.** With
|
||||
`meter_poll_s: 1` the 12 cycles last 12 s, less than a healthy battery's
|
||||
reading lag (up to `BATT_OBLIGED_S`, 17 s). That engaged on a healthy step and
|
||||
held it for 15 min. Now the late reading ends it within seconds (2 s held in
|
||||
`test_refusal.py` part 5b).
|
||||
- **N>1 is stated as a limit in DOCS.** The pack limit there covers an empty
|
||||
battery only when its cut-out level is configured right.
|
||||
|
||||
## Not covered
|
||||
|
||||
A battery that gives a steady part of the ask (e.g. a 500 W keep-alive) is a
|
||||
|
||||
@@ -538,17 +538,23 @@ accumulator is doing:
|
||||
| `saturated-freeze` | the inverter is not following, so the command's magnitude may fall but not rise |
|
||||
| `freeze-released` | the freeze had run out of plant-shaped explanations and the command was let move again |
|
||||
| `not-running` | control is stopped **and** shadow mode is off, so the loop did not run |
|
||||
| `battery refuses` | the battery gave nothing for a minute while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) |
|
||||
| `battery refuses` | the battery gave less than 100 W for 12 cycles (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) |
|
||||
| `battery probe` | the add-on asks 200 W to test whether a refusing battery gives power again |
|
||||
|
||||
**When the battery refuses.** At its cut-out level the battery gives nothing,
|
||||
whatever it is asked. The add-on then asks 0 W instead of repeating its last
|
||||
request, and the `Why` row reads *The battery refuses to give power: 0 W*. Every
|
||||
15 minutes it asks 200 W for about half a minute; if the battery answers,
|
||||
control resumes. It also resumes when the grid needs charging instead, and when
|
||||
15 minutes it asks 200 W for about half a minute. Control resumes as soon as
|
||||
the battery gives 100 W or more, when the grid needs charging instead, and when
|
||||
control is stopped or started. 200 W is below the 250 W the stale-reading check
|
||||
needs, so a test never trips it. One inverter only: with several, the battery
|
||||
level limit already moves an empty battery's share to the others.
|
||||
needs, so a test never trips it. A battery that gives some power, even 100 W,
|
||||
is not refusing and keeps its request.
|
||||
|
||||
⚠️ **One inverter only.** With several inverters this hold is off. There, the
|
||||
battery level limit moves an empty battery's share to the others, but only if
|
||||
its “Battery cut-out level” matches where the battery really stops. A battery
|
||||
that refuses at another level, or for another reason (cold, a battery fault),
|
||||
can still hold its last request until the grid starts to export.
|
||||
|
||||
*For installers:* SAFETY-52. The event log reads `BATTERY REFUSES …` and
|
||||
`BATTERY REFUSES ENDED: …`, and the INFO line ends in `BATTERY_REFUSES`.
|
||||
|
||||
@@ -286,6 +286,10 @@ REFUSED = "battery refuses"
|
||||
PROBING = "battery probe"
|
||||
PROBE_W = 200.0
|
||||
PROBE_ANSWER_W = PROBE_W / 2
|
||||
# "Gives nothing" is below the probe's own answer, not below `saturation_w`: a
|
||||
# pack derated to 100-499 W is a limit, and at 500 W it kept ~7 % of its power
|
||||
# (review of #141). The capture's refusals read -4 W.
|
||||
REFUSED_BELOW_W = PROBE_ANSWER_W
|
||||
PROBE_EVERY_S = 900.0
|
||||
PROBE_S = 34.0
|
||||
|
||||
@@ -773,18 +777,19 @@ def compute(
|
||||
|
||||
# --- SAFETY-52: a refused direction is held at 0 W ----------------------
|
||||
# Engages only where the freeze has held for SETTLE_CYCLES and the unit
|
||||
# delivers ~nothing: a reading within `saturation_w` of 0 against an ask
|
||||
# beyond it is a refusal, not a slow plant. The drop to 0 skips the slew
|
||||
# delivers ~nothing: a reading below REFUSED_BELOW_W against an ask beyond
|
||||
# `saturation_w` is a refusal, not a slow plant. The drop to 0 skips the slew
|
||||
# limit because it only removes an ask the battery is not meeting.
|
||||
if refused is not None:
|
||||
if (refused == 0 and frozen and sat_count >= SETTLE_CYCLES
|
||||
and abs(prev_w) > tuning.saturation_w
|
||||
and abs(actual_w) < tuning.saturation_w):
|
||||
and abs(actual_w) < REFUSED_BELOW_W):
|
||||
refused = 1 if prev_w > 0 else -1
|
||||
if refused and refused * i_w < 0:
|
||||
refused = 0 # the loop wants the other way
|
||||
elif refused and probe and refused * actual_w >= PROBE_ANSWER_W:
|
||||
refused = 0 # the probe was answered
|
||||
elif refused and refused * actual_w >= PROBE_ANSWER_W:
|
||||
refused = 0 # it delivers: a probe answered,
|
||||
# or a slow reading arriving
|
||||
elif refused:
|
||||
wants = refused * i_w > 0
|
||||
i_w = 0.0
|
||||
|
||||
@@ -1108,8 +1108,9 @@ class Controller:
|
||||
f"gave {batt_w:+.0f} W for "
|
||||
f"{decision.sat_count} cycles")
|
||||
elif self.batt_refused and not now:
|
||||
answered = probe and self.batt_refused * batt_w >= _ctl.PROBE_ANSWER_W
|
||||
self._refusal_end("the probe was answered" if answered
|
||||
gave = self.batt_refused * batt_w >= _ctl.PROBE_ANSWER_W
|
||||
self._refusal_end("the probe was answered" if gave and probe
|
||||
else "the battery gives power" if gave
|
||||
else "the grid now asks the other direction")
|
||||
|
||||
def _refusal_from_stale(self, inv, now_mono) -> None:
|
||||
@@ -1124,7 +1125,7 @@ class Controller:
|
||||
and not self.batt_refused
|
||||
and self.sat_count >= t.saturation_cycles
|
||||
and inv.batt_fresh.held is not None
|
||||
and abs(inv.batt_fresh.held) < t.saturation_w
|
||||
and abs(inv.batt_fresh.held) < _ctl.REFUSED_BELOW_W
|
||||
and abs(standing) > t.saturation_w):
|
||||
self._now_mono = now_mono
|
||||
self._refusal_start(1 if standing > 0 else -1,
|
||||
|
||||
@@ -1147,8 +1147,10 @@ async def _one():
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": 2000, "gain": 0.6,
|
||||
# ⚠️ THE SAME 1 s AS THE TWO-INVERTER FIXTURE, AND THAT IS WHAT
|
||||
# MAKES THIS SECTION NON-VACUOUS. The run below is 2 s of wall clock
|
||||
# against a 1 s window, so a hold is not what stops this firing -
|
||||
# MAKES THIS SECTION NON-VACUOUS. The run below is 1.1 s of wall
|
||||
# clock against a 1 s window (SAFETY-52 shortened it from 2 s, and
|
||||
# "the window was never even opened" below is what still makes it
|
||||
# non-vacuous), so a hold is not what stops this firing -
|
||||
# the reduction is. With a shipped-length hold "nothing was
|
||||
# reported" would be true of a fixture that simply ran out of time.
|
||||
"bms_soc_floor": _FLOOR_PCT, "delivery_hold_s": 1,
|
||||
|
||||
@@ -34,6 +34,12 @@ test_messages (MUTATION.md). Reds are failing checks, on this tree:
|
||||
R7 main.py: another writer no longer ends the refusal 1
|
||||
R8 main.py: stop/start no longer ends the refusal 1
|
||||
R9 main.py: the probe window never opens 4
|
||||
R10 main.py: seed from a stale stop below `saturation_w` again, 1
|
||||
not below REFUSED_BELOW_W (review of #141). The same mutation
|
||||
in control.py's engage clause SURVIVES, and that is expected:
|
||||
the delivery exit (R11) ends such a refusal in the same cycle.
|
||||
R11 control.py: only an answered PROBE ends the refusal 2
|
||||
(`and probe` back in the delivery exit; review of #141)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -366,6 +372,89 @@ rows, _, c = run(load906, cut906, 900, cap906, events={600.0: other_writer})
|
||||
check("another writer on the wire ends the refusal (the arbiter reseed)",
|
||||
at(rows, 590)[6] == 1 and at(rows, 605)[6] == 0)
|
||||
|
||||
# ==============================================================================
|
||||
print("5b. review of #141: a pack that gives SOME power, and a reading that is "
|
||||
"only late, are not a refusal")
|
||||
# ==============================================================================
|
||||
# A pack derated to 100-499 W (cold, BMS taper) gives what it can against a
|
||||
# larger ask. Engaging below `saturation_w` (500 W) held it at 0 W for ~93 % of
|
||||
# the time; "gives nothing" is below the probe's own answer.
|
||||
# `compute()` alone: through the Controller the stale-reading gate trips on the
|
||||
# flat 300 W first, and that fixture then passes whatever this threshold is.
|
||||
def derated(carry, cap_w=300.0, n=1800):
|
||||
prev, i_w, sat, ref, gave, held = 0.0, 0.0, 0, carry, 0.0, 0
|
||||
b = 0.0
|
||||
for _ in range(n): # 2.5 h at 5 s
|
||||
d = control.compute(prev, 2000.0 - b, b, T, sat, i_w, refused=ref)
|
||||
prev, i_w, sat, ref = d.target_w, d.i_w, d.sat_count, d.refused
|
||||
held += bool(ref)
|
||||
b = max(0.0, min(prev, cap_w)) # one tick late
|
||||
gave += b
|
||||
return gave / n, held
|
||||
|
||||
|
||||
T = control.Tuning(**LIVE)
|
||||
gave_old, _ = derated(None)
|
||||
gave_new, held_new = derated(0)
|
||||
print(f" pack derated to 300 W: old law {gave_old:.0f} W average, new "
|
||||
f"{gave_new:.0f} W, held as refusing on {held_new} cycles")
|
||||
check("a pack that gives a steady 300 W against a 2000 W ask is never held at "
|
||||
"0 W as refusing, and keeps giving what the old law got from it",
|
||||
held_new == 0 and gave_new == gave_old and gave_old > 290.0)
|
||||
|
||||
|
||||
def lagging(tick, dead_s=6.0, publish_s=10.0, phase_s=5.0, dur=600.0):
|
||||
"""`compute()` on a HEALTHY pack whose reading lags: it follows the command
|
||||
of `dead_s` ago, published every `publish_s`. A 1 s meter (`meter_poll_s`
|
||||
1) makes SETTLE_CYCLES only 12 s, shorter than that lag."""
|
||||
prev, i_w, sat, ref, hist, reg, pub = 0.0, 0.0, 0, 0, [], 0.0, -phase_s
|
||||
held = engaged = 0
|
||||
t = 0.0
|
||||
while t < dur:
|
||||
then = [c for tc, c in hist if tc <= t - dead_s]
|
||||
b = then[-1] if then else 0.0
|
||||
if t - pub >= publish_s:
|
||||
reg, pub = b, t
|
||||
d = control.compute(prev, (3000.0 if t >= 10 else 0.0) - b, reg, T,
|
||||
sat, i_w, refused=ref)
|
||||
engaged += bool(d.refused) and not ref
|
||||
prev, i_w, sat, ref = d.target_w, d.i_w, d.sat_count, d.refused
|
||||
hist.append((t, prev))
|
||||
held += tick * bool(ref)
|
||||
t += tick
|
||||
return engaged, held
|
||||
|
||||
|
||||
lag1 = lagging(1.0)
|
||||
print(f" 1 s meter, healthy pack with a late reading: engaged {lag1[0]} "
|
||||
f"time(s), held {lag1[1]:.0f} s")
|
||||
check("a healthy pack whose reading is merely late is let go as soon as the "
|
||||
"reading shows it delivering - not held until the next probe",
|
||||
lag1[1] <= 10.0)
|
||||
|
||||
|
||||
async def _seed(held_w):
|
||||
"""`_refusal_from_stale()` on a unit standing at +2000 W, frozen."""
|
||||
c = Controller(dict(
|
||||
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off"}, **LIVE),
|
||||
_Hass(lambda t: 0.0, lambda t: False), _Store(), _Pub(),
|
||||
stop=asyncio.Event())
|
||||
c.auto, c.sat_count = True, c.tuning.saturation_cycles
|
||||
inv = c.inverters[0]
|
||||
inv.last_write, inv.batt_fresh.held = 2000.0, held_w
|
||||
c._refusal_from_stale(inv, 100.0)
|
||||
return c.batt_refused
|
||||
|
||||
|
||||
check("a stale-reading stop on a register standing at 300 W is not a refusal "
|
||||
"(it gives something), and one at -4 W is",
|
||||
asyncio.run(_seed(300.0)) == 0 and asyncio.run(_seed(CUT_W)) == 1)
|
||||
d = control.compute(0.0, 1500.0, 1400.0, T, 0, 0.0, refused=1, probe=False)
|
||||
check("...i.e. a held direction that reads 100 W or more ends the refusal "
|
||||
"without waiting for a probe", d.refused == 0)
|
||||
|
||||
# ==============================================================================
|
||||
print("6. words")
|
||||
# ==============================================================================
|
||||
|
||||
@@ -43,7 +43,7 @@ def go(which, rows, fit, publish_s=10.0, stale_input_s=15.0):
|
||||
if fr.stale and not was:
|
||||
trips += 1
|
||||
if (which == "new" and not st["ref"] and st["sat"] >= tun.saturation_cycles
|
||||
and abs(reg) < tun.saturation_w and abs(st["target"]) > tun.saturation_w):
|
||||
and abs(reg) < NEW.REFUSED_BELOW_W and abs(st["target"]) > tun.saturation_w):
|
||||
st["ref"], st["anchor"] = (1 if st["target"] > 0 else -1), t
|
||||
was = fr.stale
|
||||
if batt is None:
|
||||
|
||||
Reference in New Issue
Block a user