Compare commits
1
Commits
master
..
SAFETY-128
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09e1fbb898 |
@@ -2,6 +2,10 @@
|
||||
|
||||
## 0.20.0
|
||||
|
||||
### Not in a release yet
|
||||
|
||||
- When Home Assistant passes readings on late, control no longer swings on them: it waits until the battery shows its last power request, and the peak limit no longer asks for full power on them. The page says so, and if the readings stay too late every inverter goes to 0 W ([[SAFETY-128]]).
|
||||
|
||||
**A minor bump: a Forecast tab, a night grid charge sized to what the sun will not give, a kept evening forecast, and no option changes.**
|
||||
No option was added, removed or renamed, and no default changed (86 options). It is a minor bump because Leftover solar now buys different amounts from the grid and the night charge keeps its level when the forecast goes quiet; every saved setting still starts.
|
||||
**Existing installs need no option change:** options that start 0.19.2 also
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# SAFETY-128 - a reading that does not answer the last command is not current
|
||||
|
||||
**Status: FIXED on release/1.0 (after 0.20.0).** Code: `app/lateness.py`,
|
||||
`control.compute(hold=, hold_w=)`, `peakguard.cap_floor()`,
|
||||
`Controller._readings_late()`. Tests: `test_safety128.py`
|
||||
(`tools/safety128_rig.py`, TEST-23's plant).
|
||||
|
||||
**Cause** (SAFETY-127 report). Home Assistant can deliver readings late but
|
||||
still changing. Every gate judges arrival (SAFETY-01) or a value that stops
|
||||
changing (SAFETY-18, SAFETY-37), so a late reading passes all of them. The loop
|
||||
then corrects, each cycle, an error its last command has already fixed: a loop
|
||||
with a dead time far longer than its cycle, which swings and grows. The peak
|
||||
guard projects the quarter from the same late figures and pushes to the
|
||||
inverter limit in its last seconds (OPT-34 rig F-2).
|
||||
|
||||
**Rule.**
|
||||
1. *Find late readings.* `Delay` fits, every 4 s over the last 40 s of new
|
||||
meter values, how long after a power request the meter shows it: the meter
|
||||
is the house less the batteries, so `meter(t) + request(t - d)` is flattest
|
||||
at the right `d`. Over `DELAY_S` the readings are late (`late_seen`). The
|
||||
fit counts only if the requests explain half the meter's swing; a late `d`
|
||||
only if it fits 30 % better than any fresh one and better than a slow
|
||||
battery answering at once (10 or 20 s lag, the SAFETY-85/94 rigs). A
|
||||
silent meter adds no values (SAFETY-99/125). A battery that gives nothing
|
||||
under its ask, or refuses, spoils the fit and is left out for 40 s. With no
|
||||
fit for 120 s (a steady site shows nothing) the verdict is dropped.
|
||||
Nothing below acts unless `late_seen`, so with readings on time control is
|
||||
unchanged.
|
||||
2. *Hold.* While `late_seen`, a request more than `ANSWER_W` from the battery
|
||||
reading is pending until that reading comes within `ANSWER_W` of it, or half
|
||||
way. Meanwhile the loop may not lead the reading by more than `ANSWER_W`
|
||||
that way (`compute(hold=, hold_w=)`; it may come back), and the peak
|
||||
guard's floor is capped at the request that stands (`cap_floor()`). The
|
||||
loop is not held while a battery gives nothing under its ask or refuses:
|
||||
SAFETY-52/94 need it to go on asking.
|
||||
3. *Late.* Pending for `LATE_S` with no reading moving `ANSWER_W` towards it is
|
||||
the late verdict: control holds, then after `stale_input_s` goes to 0 W
|
||||
("readings late"), as for a missing input. It ends when a reading starts
|
||||
towards the request.
|
||||
4. *Seen.* While `late_seen` an amber row says "Home Assistant readings arrive
|
||||
about N seconds late, so control holds the batteries." (NL too), and
|
||||
"every inverter is at 0 W" past the hold. One WARNING (`READINGS_LATE`)
|
||||
and one action-log line per episode, and one when it ends.
|
||||
|
||||
**Constants** (`app/lateness.py`).
|
||||
|
||||
| | Value | Why |
|
||||
|---|---|---|
|
||||
| `ANSWER_W` | 100 W | `control.PROBE_ANSWER_W`: a reading this far towards the ask has answered it (SAFETY-52's probe). |
|
||||
| `DELAY_S` | 8 s | `freshness.PLANT_SETTLE_S` 6 s + one `LOOP_TICK_S` 2 s: a fresh meter shows a request within the plant's settle (SAFETY-75's 6 s dead time) and one meter tick. The fit reads 0 s at 1/2/5 s meters, 5-7 s with a 6 s dead time, 9 s at 10 s late. |
|
||||
| `LATE_S` | 23 s | 2 x `PLANT_SETTLE_S` + `PUBLISH_PERIOD_S` 10 s + `READ_PERIOD_S` 1 s: SAFETY-75's 6 s dead time, the 6 s settle, one publish and one read, the slowest healthy battery answer. |
|
||||
| `FIT_S`, `FIT_EVERY_S` | 40 s, 4 s | Short enough to find 10 s and 30 s late within 50-60 s of the start (the swing has not grown yet); one 2 s cycle in two. |
|
||||
| `MAX_DELAY_S` | 90 s | F-2 measured 17-78 s. |
|
||||
| `FIT_SHARE`, `FIT_BETTER` | 0.5, 0.7 | Below these a battery that stops following (a refusal at N=2) was read as 23 s late. |
|
||||
| `SLOW_TAUS_S` | 10 s, 20 s | The SAFETY-85/94 lagging battery: without this check 16 of 36 of its reversals read as 11-56 s late. |
|
||||
| `FIT_GONE_S` | 120 s | The verdict outlives the lag by at most `FIT_S` + this when the site goes steady. |
|
||||
|
||||
**What it costs.**
|
||||
- Late readings are found only after the requests have moved 100 W and the
|
||||
meter has shown it late: 48-68 s after the lag starts in the tests. Until
|
||||
then control is as before.
|
||||
- The row can stay up to 160 s after the readings are on time again, while
|
||||
the site is steady.
|
||||
- A battery that cannot follow while readings are late (at a limit, not
|
||||
reading 0 W) is held, then set to 0 W after 38 s, until it moves.
|
||||
- The fit costs about 93 x 20 sums every 4 s.
|
||||
- With readings late, a large step is followed in steps of the lag: the loop
|
||||
waits for each request to show before the next.
|
||||
|
||||
**Left out.** The round trip setpoint -> Home Assistant: the fitted delay gives
|
||||
the same figure from readings the add-on already has. The arbitrage charge's
|
||||
own sizing on late readings (`charge_room_w()`) is not capped here; the test
|
||||
shows it swinging while the peak guard holds (see the pull request).
|
||||
@@ -298,6 +298,9 @@ PROBE_S = 34.0
|
||||
# command: every battery that may move this way is at its mode's limit.
|
||||
MODE_BOUND = "mode-bound"
|
||||
|
||||
# SAFETY-128. The loop's reason while it waits for the readings to answer.
|
||||
WAITING = "waiting for readings"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tuning:
|
||||
@@ -486,6 +489,8 @@ def compute(
|
||||
bound: tuple | None = None,
|
||||
gave_nothing: bool | None = None,
|
||||
idle_w: float = 0.0,
|
||||
hold: int = 0,
|
||||
hold_w: float | None = None,
|
||||
) -> Decision:
|
||||
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
|
||||
|
||||
@@ -506,6 +511,9 @@ def compute(
|
||||
`idle_w` SAFETY-97: what `actual_w` reads while the batteries give
|
||||
nothing (app/idle.py). The refusal's band and its end are
|
||||
centred on it. 0 is the law as it was.
|
||||
`hold` SAFETY-128: +1 / -1, the batteries have not answered the last
|
||||
command that way (app/lateness.py), so the loop may not go
|
||||
past `hold_w` (None: `prev_w`) that way. 0 is the law as it was.
|
||||
"""
|
||||
reason = "tracking"
|
||||
# OPT-11: a bound that leaves out 0 W would FORCE a direction, which no
|
||||
@@ -622,6 +630,11 @@ def compute(
|
||||
i_w = min(moved, i_w)
|
||||
else:
|
||||
i_w = max(moved, i_w)
|
||||
# SAFETY-128: no further than `hold_w` until the readings answer the ask.
|
||||
held_at = float(prev_w if hold_w is None else hold_w)
|
||||
if hold and hold * (i_w - held_at) > 0:
|
||||
i_w = held_at
|
||||
reason = WAITING
|
||||
|
||||
# OPT-11: the integrator does not wind past what the modes let the
|
||||
# batteries place. Only a side tighter than the rail is left in `bound`
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"""SAFETY-128: a reading that does not answer the last command is not current.
|
||||
|
||||
When Home Assistant delivers readings late but still changing, every gate that
|
||||
judges arrival (SAFETY-01) or an unchanged value (SAFETY-18, SAFETY-37) passes
|
||||
them, and the loop corrects again, each cycle, an error its last command has
|
||||
already fixed. The readings themselves show it: after a command the meter moves
|
||||
the other way and the battery reading follows (r = 0.998, freshness.py), and
|
||||
late readings do both late.
|
||||
|
||||
`Answer` watches the site command on the wire, the meter and the site battery
|
||||
reading (less the batteries' idle reading, SAFETY-97):
|
||||
- `Delay` fits how long after a command the meter shows it. Over DELAY_S the
|
||||
readings run late (`late_seen`); within it they do not. A battery that does
|
||||
not follow gives no fit, so no evidence either way;
|
||||
- a new command more than ANSWER_W from the battery reading is PENDING until
|
||||
that reading comes within ANSWER_W of it, or half way. The first move of
|
||||
either reading towards it (ANSWER_W) is its START;
|
||||
- while `late_seen` and pending, the loop may not lead the battery reading by
|
||||
more than ANSWER_W that way (`hold`, `hold_w`; not while `exempt`: a
|
||||
battery that gives nothing under its ask or refuses is SAFETY-52/94's case)
|
||||
and the peak guard may not push past what stands (`peak_cap`). Pending for LATE_S with no start is LATE: control
|
||||
holds, then 0 W after `stale_input_s`, as for a missing input.
|
||||
|
||||
With readings on time nothing here acts. DECISIONS/SAFETY-128-late-readings.md.
|
||||
"""
|
||||
|
||||
import bisect
|
||||
import math
|
||||
from collections import deque
|
||||
|
||||
from . import freshness
|
||||
from .control import LOOP_TICK_S, PROBE_ANSWER_W
|
||||
|
||||
# Within this of the command the reading has answered it (a probe's answer).
|
||||
ANSWER_W = PROBE_ANSWER_W # 100
|
||||
# A fresh meter shows a command within the plant's settle (SAFETY-75's 6 s dead
|
||||
# time) and one meter tick; later than that the readings run late.
|
||||
DELAY_S = freshness.PLANT_SETTLE_S + LOOP_TICK_S # 8.0
|
||||
# The slowest healthy battery answer: SAFETY-75's 6 s dead time, the 6 s plant
|
||||
# settle, one 10 s publish and one 1 s read.
|
||||
LATE_S = (2 * freshness.PLANT_SETTLE_S + freshness.PUBLISH_PERIOD_S
|
||||
+ freshness.READ_PERIOD_S) # 23.0
|
||||
|
||||
|
||||
# The meter's delay is fitted over this much history, up to this delay.
|
||||
FIT_S = 40.0
|
||||
MAX_DELAY_S = 90
|
||||
FIT_EVERY_S = 4.0
|
||||
# A slow battery answers at once but takes this long (SAFETY-85/94 rigs).
|
||||
SLOW_TAUS_S = (10.0, 20.0)
|
||||
# With no fit for this long the evidence is dropped (a steady site shows none).
|
||||
FIT_GONE_S = 120.0
|
||||
# A fit counts only if the commands explain this share of the meter's swing.
|
||||
FIT_SHARE = 0.5
|
||||
# ...and a late delay only if it fits this much better than any fresh one.
|
||||
FIT_BETTER = 0.7
|
||||
|
||||
|
||||
class Delay:
|
||||
"""How long after a command the meter shows it, fitted from history.
|
||||
|
||||
The meter is the house less the batteries, and the batteries follow the
|
||||
commands, so `grid(t) + command(t - d)` is the house alone at the right
|
||||
`d`: the `d` that leaves it flattest is the delay. Only new meter values
|
||||
count, so a silent meter (SAFETY-99) is not a late one. None while the
|
||||
commands have not moved enough to tell, or explain too little of the meter
|
||||
(a battery that does not follow); not late if a slow battery that answers
|
||||
at once (SLOW_TAUS_S) fits as well."""
|
||||
|
||||
def __init__(self):
|
||||
self.cmds = deque() # (t, command) when it changed
|
||||
self.grid = deque() # (t, meter)
|
||||
|
||||
def add(self, now, cmd, grid):
|
||||
if not self.cmds or self.cmds[-1][1] != cmd:
|
||||
self.cmds.append((now, cmd))
|
||||
if grid is not None and (not self.grid or self.grid[-1][1] != grid):
|
||||
self.grid.append((now, grid)) # new meter values only
|
||||
while self.grid and self.grid[0][0] < now - FIT_S:
|
||||
self.grid.popleft()
|
||||
while (len(self.cmds) > 1
|
||||
and self.cmds[1][0] <= now - FIT_S - MAX_DELAY_S):
|
||||
self.cmds.popleft()
|
||||
|
||||
def estimate(self):
|
||||
if len(self.grid) < 10 or len(self.cmds) < 2:
|
||||
return None
|
||||
ts = [t for t, _c in self.cmds]
|
||||
t0 = self.cmds[0][0]
|
||||
rows = [(t, g) for t, g in self.grid if t >= t0 + MAX_DELAY_S]
|
||||
if len(rows) < 10:
|
||||
return None
|
||||
cs = [c for _t, c in self.cmds]
|
||||
if max(cs) - min(cs) < ANSWER_W:
|
||||
return None
|
||||
g_mean = sum(g for _t, g in rows) / len(rows)
|
||||
g_var = sum((g - g_mean) ** 2 for _t, g in rows) / len(rows)
|
||||
|
||||
def var(f, d):
|
||||
e = [g + f(t - d) for t, g in rows]
|
||||
m = sum(e) / len(e)
|
||||
return sum((x - m) ** 2 for x in e) / len(e)
|
||||
step = lambda t: cs[bisect.bisect_right(ts, t) - 1] # noqa: E731
|
||||
pure = [var(step, d) for d in range(0, MAX_DELAY_S + 1)]
|
||||
best = min(range(len(pure)), key=lambda d: (pure[d], d))
|
||||
if g_var <= 0 or pure[best] > (1 - FIT_SHARE) * g_var:
|
||||
return None
|
||||
if best <= DELAY_S:
|
||||
return float(best)
|
||||
# Late only if it fits clearly better than any fresh delay, and better
|
||||
# than a slow battery answering at once (SAFETY-85/94's 10-20 s lag).
|
||||
if pure[best] > FIT_BETTER * min(pure[:int(DELAY_S) + 1]):
|
||||
return None
|
||||
first, last = int(t0), int(rows[-1][0])
|
||||
for tau in SLOW_TAUS_S:
|
||||
k, f, slow = 1 - math.exp(-1.0 / tau), None, []
|
||||
for sec in range(first, last + 1): # the battery, each second
|
||||
f = step(sec) if f is None else f + (step(sec) - f) * k
|
||||
slow.append(f)
|
||||
if var(lambda t: slow[min(int(t), last) - first], 0) <= pure[best]:
|
||||
return None
|
||||
return float(best)
|
||||
|
||||
|
||||
class Answer:
|
||||
"""The answer gate. `observe()` once per cycle, before the loop."""
|
||||
|
||||
def __init__(self):
|
||||
self.ask = None # the pending command, W (site, + = give)
|
||||
self.from_w = None # the battery reading when it was asked
|
||||
self.from_grid = None # the meter when it was asked
|
||||
self.since = None # when it was asked
|
||||
self.started = False # a reading has moved towards it
|
||||
self.late_seen = False # the last fitted delay was over DELAY_S
|
||||
self.late_at = None # when the late verdict started
|
||||
self.exempt = False # a battery that cannot follow explains it
|
||||
self._was = None # (command, reading, meter) at the last call
|
||||
self.delay = Delay()
|
||||
self.delay_s = None # the meter's last fitted delay, s
|
||||
self._fit_at = self._fit_ok = self._exempt_at = None
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self.ask is not None and self.late_seen
|
||||
|
||||
@property
|
||||
def hold(self) -> int:
|
||||
"""+1 / -1: the loop may not push further that way. 0: no hold."""
|
||||
if not self.active or self.exempt:
|
||||
return 0
|
||||
return 1 if self.ask > self.from_w else -1
|
||||
|
||||
@property
|
||||
def hold_w(self):
|
||||
"""How far the loop may go while held: ANSWER_W past the reading."""
|
||||
if not self.hold:
|
||||
return None
|
||||
return self._was[1] + self.hold * ANSWER_W
|
||||
|
||||
def peak_cap(self, standing_w):
|
||||
"""The most the peak guard may ask, or None."""
|
||||
return max(float(standing_w), 0.0) if self.active else None
|
||||
|
||||
def late_s(self, now):
|
||||
"""Seconds the late verdict has stood, or None."""
|
||||
return None if self.late_at is None else max(0.0, now - self.late_at)
|
||||
|
||||
def observe(self, now, standing_w, batt_w, *, grid_w=None, exempt=False,
|
||||
idle_w=0.0):
|
||||
"""`standing_w` the site command on the wire, `batt_w` the site
|
||||
battery reading (None: unknown, nothing changes), `grid_w` the meter
|
||||
(+ = import), `idle_w` what the batteries read while giving nothing.
|
||||
`exempt`: a battery gives nothing under its ask, or refuses."""
|
||||
if batt_w is None or standing_w is None:
|
||||
return
|
||||
cmd, r = float(standing_w), float(batt_w) - float(idle_w)
|
||||
g = None if grid_w is None else float(grid_w)
|
||||
self.exempt = bool(exempt)
|
||||
if self.exempt:
|
||||
self._exempt_at = now
|
||||
was, self._was = self._was, (cmd, r, g)
|
||||
self.delay.add(now, cmd, g)
|
||||
if self._fit_at is None or now - self._fit_at >= FIT_EVERY_S:
|
||||
self._fit_at = now
|
||||
d = self.delay.estimate()
|
||||
if self._exempt_at is not None and now - self._exempt_at < FIT_S:
|
||||
d = None # a battery that does not follow spoils it
|
||||
if d is not None:
|
||||
self.delay_s, self.late_seen, self._fit_ok = d, d > DELAY_S, now
|
||||
elif self._fit_ok is None or now - self._fit_ok > FIT_GONE_S:
|
||||
self.late_seen = False # nothing to tell it by
|
||||
if self.ask is None:
|
||||
if was is None or cmd == was[0] or abs(cmd - was[1]) < ANSWER_W:
|
||||
return
|
||||
self.ask, self.from_w, self.from_grid = cmd, was[1], was[2]
|
||||
self.since, self.started = now, False
|
||||
self.ask = cmd
|
||||
age = now - self.since
|
||||
gap = self.ask - self.from_w
|
||||
way = 1.0 if gap > 0 else -1.0
|
||||
batt_to = (r - self.from_w) * way
|
||||
grid_to = (-1e9 if g is None or self.from_grid is None
|
||||
else (self.from_grid - g) * way)
|
||||
if not self.started and max(batt_to, grid_to) >= ANSWER_W:
|
||||
self.started, self.late_at = True, None
|
||||
if (self.ask - r) * way < ANSWER_W or batt_to >= abs(gap) / 2:
|
||||
self._clear() # answered
|
||||
elif abs(gap) < ANSWER_W:
|
||||
self._clear() # withdrawn
|
||||
elif exempt:
|
||||
self.late_at = None
|
||||
elif (self.late_at is None and self.late_seen and not self.started
|
||||
and age >= LATE_S):
|
||||
self.late_at = now
|
||||
|
||||
def _clear(self):
|
||||
self.ask = self.from_w = self.from_grid = self.since = None
|
||||
self.started, self.late_at = False, None
|
||||
@@ -64,6 +64,7 @@ from .clamp import (
|
||||
shortfall_note, write_clamp_note,
|
||||
)
|
||||
from . import freshness
|
||||
from . import lateness
|
||||
from . import dayview, homeview
|
||||
from .daycurve import SolarToday
|
||||
from . import messages
|
||||
@@ -1281,6 +1282,8 @@ class Controller:
|
||||
self.cycle_at = None
|
||||
self._arb_action = None
|
||||
self.inputs_bad_since = None
|
||||
self.answer = lateness.Answer() # SAFETY-128
|
||||
self._late_logged = self._late_zero = False
|
||||
# SAFETY-03. What the loop's OWN bounds did last cycle - a
|
||||
# (engaged, note) pair so the alarm can say WHICH bound bit without
|
||||
# re-deriving it from a string. ⚠️ THIS ONE STAYS SITE-LEVEL, and it is
|
||||
@@ -3116,6 +3119,36 @@ class Controller:
|
||||
return (decision if self._peak_needed
|
||||
else dataclasses.replace(decision, within_aim=True))
|
||||
|
||||
def _readings_late(self, standing_w):
|
||||
"""SAFETY-128: update the answer gate; seconds the late verdict has
|
||||
stood, or None. Readings found late are logged once per episode.
|
||||
DECISIONS/SAFETY-128-late-readings.md."""
|
||||
a = self.answer
|
||||
if self._now_mono is None:
|
||||
return None
|
||||
a.observe(self._now_mono, standing_w, self.site_batt_w,
|
||||
grid_w=self.grid, exempt=bool(self.batt_refused) or any(
|
||||
i.refused or (
|
||||
i.batt is not None
|
||||
and abs(i.standing_w()) >= lateness.ANSWER_W
|
||||
and abs(i.batt - i.idle_w) < lateness.ANSWER_W)
|
||||
for i in self.inverters),
|
||||
idle_w=self._site_idle_w())
|
||||
if a.late_seen != self._late_logged:
|
||||
self._late_logged = a.late_seen
|
||||
if a.late_seen:
|
||||
self.log_event(f"readings late ({a.delay_s:.0f} s) - holding",
|
||||
hint=messages.hint("readings_late"))
|
||||
_LOG.warning("Home Assistant readings arrive late, so control "
|
||||
"holds the battery: READINGS_LATE.")
|
||||
_LOG.debug("READINGS_LATE: the meter shows a power request "
|
||||
"%.0f s after it is sent.", a.delay_s)
|
||||
else:
|
||||
self.log_event("readings on time", hint=messages.hint(
|
||||
"readings_back"))
|
||||
_LOG.info("Home Assistant readings are on time again.")
|
||||
return a.late_s(self._now_mono)
|
||||
|
||||
def _peak_reserve(self) -> frozenset:
|
||||
"""SAFETY-87: the batteries that give nothing for the peak this
|
||||
cycle: under their reserve while the others carry a peak floor."""
|
||||
@@ -4647,6 +4680,8 @@ class Controller:
|
||||
"away a partly delivering unit's output.")
|
||||
|
||||
stale_after = float(self.o.get("stale_input_s", 15))
|
||||
late_s = self._readings_late(site_standing) # SAFETY-128
|
||||
self._late_zero = late_s is not None and late_s >= stale_after
|
||||
# ⚠️ `any`, NOT the sole unit's. The hold exists so a single missed poll
|
||||
# does not become a fault; a LATCHED inverter must still reach the claim
|
||||
# list below, because its `failsafe` SET is what parks it. One tripped
|
||||
@@ -4672,6 +4707,13 @@ class Controller:
|
||||
self.why_words = messages.why_waiting(bad_for)
|
||||
self._why_waiting_since = now - timedelta(seconds=bad_for)
|
||||
return
|
||||
if late_s is not None and late_s < stale_after and self.inputs_ok \
|
||||
and (self.auto or self.shadow) \
|
||||
and not any(i.sign_wd.tripped for i in self.inverters) \
|
||||
and not (n_units > 1 and self.site_sign_wd.tripped):
|
||||
self.reason = f"readings late {late_s:.0f}s" # SAFETY-128: hold
|
||||
self.why_words = messages.why_late()
|
||||
return
|
||||
|
||||
claims = []
|
||||
|
||||
@@ -4850,6 +4892,9 @@ class Controller:
|
||||
hint=messages.hint("inputs_missing"))
|
||||
claims.append(Claim.set("safety", P_SAFETY, 0.0, "inputs missing",
|
||||
actuator=self.actuator_of("safety")))
|
||||
elif late_s is not None:
|
||||
claims.append(Claim.set("safety", P_SAFETY, 0.0, "readings late",
|
||||
actuator=self.actuator_of("safety")))
|
||||
|
||||
# --- SAFETY-07: the quarter-peak projection guard --------------------
|
||||
# ONE `lo` limit at P_PEAK, and peakguard.py carries the whole argument.
|
||||
@@ -4973,6 +5018,8 @@ class Controller:
|
||||
self.peak_guard = self._peak_on_able(self.peak_guard,
|
||||
maint_results)
|
||||
self.peak_guard = self._peak_landing(self.peak_guard)
|
||||
self.peak_guard = peakguard.cap_floor( # SAFETY-128
|
||||
self.peak_guard, self.answer.peak_cap(site_standing))
|
||||
# SITE, and peakguard.claim() says so on the object it builds. The
|
||||
# quantity is the 15-minute average OFFTAKE AT THE GRID CONNECTION,
|
||||
# which is what the capacity tariff bills; there is one quarter-hour
|
||||
@@ -5437,6 +5484,7 @@ class Controller:
|
||||
if len(self.inverters) == 1 else None),
|
||||
# SAFETY-97: "gives nothing" is centred on the idle reading.
|
||||
idle_w=self._site_idle_w(),
|
||||
hold=self.answer.hold, hold_w=self.answer.hold_w, # SAFETY-128
|
||||
# OPT-11: None unless battery modes are on. SAFETY-85: or while
|
||||
# maintenance holds a battery at N>=2, so the loop does not
|
||||
# wind past what the others can give and jump when it ends.
|
||||
@@ -7333,6 +7381,10 @@ class Controller:
|
||||
(inv.refusal_note(), False)):
|
||||
if note:
|
||||
out.append({"ok": False, "warn": not red, "text": note})
|
||||
if self.answer.late_seen: # SAFETY-128
|
||||
out.append({"ok": False, "warn": True, "id": "late",
|
||||
"text": messages.row_readings_late(
|
||||
self.answer.delay_s, self._late_zero)})
|
||||
|
||||
# SAFETY-03. Red while an over-rating clamp is engaged (amber for the
|
||||
# integrator bound, SAFETY-40), and the engagement COUNT is shown even
|
||||
|
||||
@@ -644,6 +644,19 @@ def row_controller_stalled(age_s, veto_s) -> str:
|
||||
f"stuurmodule zet de omvormer na {seconden(veto_s)} op 0 W.")
|
||||
|
||||
|
||||
def row_readings_late(delay_s, zero=False) -> str:
|
||||
"""SAFETY-128: Home Assistant readings arrive late; past the hold, 0 W."""
|
||||
if zero:
|
||||
return Bi(f"Home Assistant readings arrive about {seconds(delay_s)} "
|
||||
f"late, so every inverter is at 0 W.",
|
||||
f"Metingen uit Home Assistant komen zo'n "
|
||||
f"{seconden(delay_s)} te laat, omvormers op 0 W.")
|
||||
return Bi(f"Home Assistant readings arrive about {seconds(delay_s)} late, "
|
||||
f"so control holds the batteries.",
|
||||
f"Metingen uit Home Assistant komen zo'n {seconden(delay_s)} te "
|
||||
f"laat, dus de batterijen wachten.")
|
||||
|
||||
|
||||
def row_meter_waiting(limit_s) -> str:
|
||||
"""UX-10: no reading yet since the start, and still inside the limit."""
|
||||
return Bi(f"Meter: waiting for the first reading, limit "
|
||||
@@ -1270,6 +1283,7 @@ WHY_WINNER = {
|
||||
"circulation": "Safety stop, inverters worked against each other",
|
||||
"stopped": "Control is stopped",
|
||||
"inputs missing": "A reading is missing",
|
||||
"readings late": "Home Assistant readings arrive late",
|
||||
"no claim was made": "Nothing asked for power",
|
||||
# UX-10: the claim reasons main.why_key() can return for a safety or
|
||||
# failsafe winner. test_messages.py part 7 finds every one in the code.
|
||||
@@ -1320,6 +1334,11 @@ def why_missing(what, limit_s) -> str:
|
||||
return f"No usable {what} reading for over {seconds(limit_s)}: 0 W"
|
||||
|
||||
|
||||
def why_late() -> str:
|
||||
"""SAFETY-128: readings arrive late, so control holds the batteries."""
|
||||
return "Home Assistant readings arrive late, control holds"
|
||||
|
||||
|
||||
def why_waiting(missing_s) -> str:
|
||||
"""A reading went missing a moment ago: control holds and waits."""
|
||||
return f"A reading is missing for {seconds(missing_s)}, control waits"
|
||||
@@ -1337,6 +1356,8 @@ HINTS = {
|
||||
"roster": "Inverter list read: {n} inverters",
|
||||
"maint_history": "Maintenance history did not move over cleanly",
|
||||
"stop_restored": "{unit}: an earlier safety stop is back, it stays at 0 W",
|
||||
"readings_late": "Home Assistant readings arrive late, control holds",
|
||||
"readings_back": "Home Assistant readings are on time again",
|
||||
"battery_stuck": "{unit}: battery power reading stopped changing",
|
||||
"battery_back": "{unit}: battery power reading is used again",
|
||||
"battery_refuses": "The battery refuses to give power, the add-on asks 0 W",
|
||||
@@ -1488,6 +1509,8 @@ def status_samples() -> list:
|
||||
("row_controller_stalled", RED, row_controller_stalled(45, 30)),
|
||||
("row_meter_stale", RED, row_meter_stale(40, 10)),
|
||||
("row_meter_waiting", AMBER, row_meter_waiting(30)),
|
||||
("row_readings_late", AMBER, row_readings_late(30)),
|
||||
("row_readings_late", AMBER, row_readings_late(30, True)),
|
||||
("row_meter", GREEN, row_meter(-350, 2, 3, 340)),
|
||||
("row_meter_frozen", RED, row_meter_frozen()),
|
||||
*(("row_freeze_off", AMBER, row_freeze_off(k)) for k in FREEZE_OFF),
|
||||
|
||||
@@ -446,6 +446,19 @@ def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool:
|
||||
return decision is not None and decision.engaged
|
||||
|
||||
|
||||
def cap_floor(decision, cap_w):
|
||||
"""SAFETY-128: the floor no higher than `cap_w` (the command standing while
|
||||
the readings have not answered it), or `decision` unchanged when None.
|
||||
DECISIONS/SAFETY-128-late-readings.md."""
|
||||
if (cap_w is None or decision is None or decision.floor_w is None
|
||||
or decision.floor_w <= cap_w):
|
||||
return decision
|
||||
return replace(
|
||||
decision, floor_w=float(cap_w), saturated=False,
|
||||
reason=decision.reason + f", held at {float(cap_w):+.0f} W until the "
|
||||
"readings answer the last power request")
|
||||
|
||||
|
||||
def claim(decision: PeakDecision) -> Claim | None:
|
||||
"""The decision as one arbiter claim (`lo` only, `hi` stays `+inf`), or None."""
|
||||
if not decision.engaged or decision.floor_w is None:
|
||||
|
||||
@@ -476,6 +476,7 @@
|
||||
"stopped": "Control is stopped. Every inverter is at 0 W."
|
||||
},
|
||||
"attn": {
|
||||
"late": "Readings arrive late",
|
||||
"home_assistant": "Home Assistant is not answering",
|
||||
"controller": "Control is not running",
|
||||
"meter": "Meter reading",
|
||||
|
||||
@@ -397,6 +397,7 @@
|
||||
"peak_not_covered": "Geen piekgrens: deze meter meet geen kwartieren.",
|
||||
"control": {"shadow": "Schaduwmodus: elke omvormer blijft op 0 W.", "running": "De regeling is actief.", "stopped": "De regeling is gestopt. Elke omvormer staat op 0 W."},
|
||||
"attn": {
|
||||
"late": "Metingen komen te laat",
|
||||
"home_assistant": "Home Assistant antwoordt niet",
|
||||
"controller": "Regeling is niet actief",
|
||||
"meter": "Meting van de meter",
|
||||
|
||||
@@ -3080,13 +3080,13 @@ _unit_sources_unrouted = [
|
||||
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT and "," not in r[1]]
|
||||
print(f" {len(_direct)} direct appends, each read at its own line: "
|
||||
+ ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing))
|
||||
check("all SEVENTEEN of `Controller.cycle()`'s DIRECT appends route through "
|
||||
check("all EIGHTEEN of `Controller.cycle()`'s DIRECT appends route through "
|
||||
"`self.actuator_of(<string literal>)`, so this scan can read the routing "
|
||||
"of EVERY one of them. An append that computed its actuator, or omitted "
|
||||
"the argument, or hand-wrote a string, fails HERE rather than being "
|
||||
"silently skipped - which is how a scan like this goes quietly vacuous, "
|
||||
"and 10f is in this file because that already happened once",
|
||||
len(_direct) == 17
|
||||
len(_direct) == 18
|
||||
and all(a is not None and a.startswith("self.actuator_of('")
|
||||
for _s, a, _ln in _routing))
|
||||
check("⚠️ AND AT EVERY ONE OF THE ELEVEN THE LITERAL IT ROUTES THROUGH IS THE "
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
"""Runnable check for SAFETY-128: control does not act on late readings.
|
||||
`python3 test_safety128.py`
|
||||
|
||||
When Home Assistant runs behind, its readings arrive late but still change, so
|
||||
every staleness gate passes them. The loop corrected again, each cycle, an
|
||||
error its last command had already fixed, and the evening give run swung with
|
||||
growing export (SAFETY-127 rig: 10 s late, commands -450..+3 790 W, grid to
|
||||
-1 947 W). The peak guard pushed to the inverter limit on the same late figures
|
||||
(OPT-34 rig F-2). Now a command the battery reading has not answered holds the
|
||||
loop and caps the peak guard (app/lateness.py), and readings found late hold,
|
||||
then go to 0 W. DECISIONS/SAFETY-128-late-readings.md.
|
||||
|
||||
The REAL Controller on TEST-23's plant (tools/safety128_rig.py). "Without the
|
||||
gate" is the same run with `Controller._readings_late` returning None, which
|
||||
is the tree before this card (the rig runs unchanged on 77fe31b).
|
||||
|
||||
1. The give run (two batteries, house 1 800 W), readings 10 s late for 150 s
|
||||
and 30 s late for 300 s: without the gate the swing grows to export below
|
||||
-1 kW; with it the command stays near the last good one, no export below
|
||||
-200 W, and it settles once the lag stops. N=1 too.
|
||||
2. The peak guard in a quarter's last 30 s (a charge at the peak aim, readings
|
||||
30 s late, the house steps): it does not go to the inverter limit.
|
||||
3. Seen: the late hold says so on the page (EN and NL) and logs once per
|
||||
episode.
|
||||
4. Nothing else moves: no-lag runs at 1, 2 and 5 s meters, a load step on a
|
||||
battery with a 6 s dead time (SAFETY-75), start-up, a battery at its
|
||||
cut-out level: within the stated tolerance of the run without the gate.
|
||||
5. `Answer` and `compute(hold=)` at their edges.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import timedelta
|
||||
|
||||
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 opt11p3_replay as rp # noqa: E402
|
||||
import safety128_rig as R # noqa: E402
|
||||
import app.main as app_main # noqa: E402
|
||||
from app import control, lateness, messages # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def no_gate(fn):
|
||||
"""`fn()` with the answer gate off: the tree before SAFETY-128."""
|
||||
real = app_main.Controller._readings_late
|
||||
app_main.Controller._readings_late = lambda self, standing_w: None
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
app_main.Controller._readings_late = real
|
||||
|
||||
|
||||
def window(rows, t0, t1):
|
||||
return [r for r in rows if t0 <= r[0] < t1]
|
||||
|
||||
|
||||
def last_good(rows, t):
|
||||
"""The site command standing just before `t`."""
|
||||
return [r for r in rows if r[0] < t][-1][2]
|
||||
|
||||
|
||||
def settled_at(rows, t0, band=150.0):
|
||||
"""First second from `t0` after which the grid stays within `band` W."""
|
||||
out = None
|
||||
for r in rows:
|
||||
if r[0] < t0:
|
||||
continue
|
||||
if abs(r[1]) > band:
|
||||
out = None
|
||||
elif out is None:
|
||||
out = r[0]
|
||||
return out
|
||||
|
||||
|
||||
def swing(rows, t0, t1):
|
||||
w = window(rows, t0, t1)
|
||||
return max(r[1] for r in w) - min(r[1] for r in w)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. the give run with readings late")
|
||||
ON = 300.0
|
||||
CASES = ((10.0, 150.0), (30.0, 300.0))
|
||||
for units in (("a", "b"), ("a",)):
|
||||
house = 1800.0 if len(units) == 2 else 1500.0
|
||||
for lag, span in CASES:
|
||||
off = ON + span
|
||||
new = R.Rig(house=house, lag_s=lag, lag_on=ON, lag_off=off,
|
||||
units=units)
|
||||
new.run(off + 120.0)
|
||||
old = R.Rig(house=house, lag_s=lag, lag_on=ON, lag_off=off,
|
||||
units=units)
|
||||
no_gate(lambda: old.run(off + 120.0))
|
||||
n = f"N={len(units)}, {lag:.0f} s late for {span:.0f} s"
|
||||
wo, wn = window(old.rows, ON, off), window(new.rows, ON, off)
|
||||
good = last_good(new.rows, ON)
|
||||
half = ON + span / 2
|
||||
print(f" {n}: without the gate cmd {min(r[2] for r in wo):.0f}.."
|
||||
f"{max(r[2] for r in wo):.0f} W, grid {min(r[1] for r in wo):.0f}"
|
||||
f"..{max(r[1] for r in wo):.0f} W (swing {swing(old.rows, ON, half):.0f}"
|
||||
f" then {swing(old.rows, half, off):.0f}); with it cmd "
|
||||
f"{min(r[2] for r in wn):.0f}..{max(r[2] for r in wn):.0f} W "
|
||||
f"(last good {good:.0f}), grid {min(r[1] for r in wn):.0f}.."
|
||||
f"{max(r[1] for r in wn):.0f} W, settled "
|
||||
f"{settled_at(new.rows, off) - off:.0f} s after")
|
||||
if len(units) == 2:
|
||||
check(f"{n}: without the gate the export goes below -1 kW and the "
|
||||
f"swing grows (red on 77fe31b)",
|
||||
min(r[1] for r in wo) < -1000.0
|
||||
and swing(old.rows, half, off) > swing(old.rows, ON, half))
|
||||
check(f"{n}: with it the command stays within 250 W of the last good "
|
||||
f"one and no export below -200 W",
|
||||
all(abs(r[2] - good) <= 250.0 for r in wn)
|
||||
and min(r[1] for r in wn) >= -200.0)
|
||||
check(f"{n}: settled within 30 s after the lag stops",
|
||||
settled_at(new.rows, off) is not None
|
||||
and settled_at(new.rows, off) - off <= 30.0)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. the peak guard in a quarter's last 30 s")
|
||||
# A charge sized to the peak aim (arbitrage on a cheap hour, the shape of the
|
||||
# owner's night charge), readings 30 s late from 300 s; the house steps
|
||||
# +2 000 W 100 s before the quarter ends (900 s).
|
||||
PTS = [(R.BASE + timedelta(hours=h), 3600, 0.02 if h < 2 else 0.40)
|
||||
for h in range(-3, 30)]
|
||||
|
||||
|
||||
def peak_run(lag, gate=True, step_at=800.0):
|
||||
seen = []
|
||||
|
||||
def sample(rig, c, t):
|
||||
now = rig.start + timedelta(seconds=t)
|
||||
if int(t) % 300 == 0:
|
||||
c.tariff_plan = rp.plan_at(now, PTS)
|
||||
c.tariff_at = now
|
||||
g = c.peak_guard
|
||||
if g is not None and 870.0 <= t < 900.0 and g.engaged:
|
||||
seen.append((t, g.saturated, float(g.floor_w or 0.0),
|
||||
float(c.target)))
|
||||
r = R.Rig(house=lambda t: 1000.0 + (2000.0 if t >= step_at else 0.0),
|
||||
lag_s=lag, lag_on=ON, lag_off=2000.0, soc=30.0,
|
||||
extra={"arbitrage_enabled": True,
|
||||
"cycle_cost_threshold_eur": 0.03})
|
||||
(lambda f: f() if gate else no_gate(f))(lambda: r.run(900.0, sample))
|
||||
return seen, r
|
||||
|
||||
|
||||
RATING = sum(R.MAXW.values())
|
||||
for lag in (10.0, 30.0):
|
||||
pg_new, _r = peak_run(lag)
|
||||
pg_old, _r = peak_run(lag, gate=False)
|
||||
top_new = max([s[2] for s in pg_new] or [0.0])
|
||||
top_old = max([s[2] for s in pg_old] or [0.0])
|
||||
print(f" {lag:.0f} s late: the guard's largest ask in the last 30 s "
|
||||
f"without the gate {top_old:.0f} W ({sum(s[1] for s in pg_old)} "
|
||||
f"cycles at the limit), with it {top_new:.0f} W "
|
||||
f"({sum(s[1] for s in pg_new)})")
|
||||
if lag == 30.0:
|
||||
check("without the gate the guard goes to the inverter limit on the "
|
||||
"late projection (red on 77fe31b)",
|
||||
top_old >= RATING and any(s[1] for s in pg_old))
|
||||
check(f"{lag:.0f} s late: with it the guard never goes to the limit and "
|
||||
f"never says it is saturated",
|
||||
top_new < RATING and not any(s[1] for s in pg_new))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. the page and the log say so")
|
||||
rows_seen = []
|
||||
|
||||
|
||||
def watch(rig, c, t):
|
||||
rows_seen.extend((t, r) for r in c.checks() if r.get("id") == "late")
|
||||
|
||||
|
||||
late = R.Rig(lag_s=30.0, lag_on=ON, lag_off=ON + 300.0)
|
||||
c = late.run(ON + 300.0 + lateness.FIT_GONE_S + 60.0, watch)
|
||||
log = [e for e in c.events if "readings late" in e or "readings on time" in e]
|
||||
n_late = sum(1 for e in log if "readings late" in e)
|
||||
n_back = sum(1 for e in log if "readings on time" in e)
|
||||
first = min([t for t, _r in rows_seen] or [None])
|
||||
lastr = max([t for t, _r in rows_seen] or [None])
|
||||
print(f" 30 s late for 300 s: {n_late} late episode(s), {n_back} end(s), "
|
||||
f"the row from {first} to {lastr} s ({len(rows_seen)} s); "
|
||||
f"{rows_seen[0][1]['text'] if rows_seen else ''}")
|
||||
_d = c.answer.delay_s
|
||||
check("found late within a minute of the lag starting, and an amber row says "
|
||||
"so with the delay it measured, in plain words, EN and NL",
|
||||
first is not None and first - ON <= 60.0
|
||||
and all(r["warn"] and not r["ok"] for _t, r in rows_seen)
|
||||
and str(rows_seen[-1][1]["text"]) in {
|
||||
str(messages.row_readings_late(d)) for d in range(20, 33)}
|
||||
and rows_seen[-1][1]["text"].nl.startswith(
|
||||
"Metingen uit Home Assistant komen zo'n "))
|
||||
check("logged once for the episode, and once when it ends, not per cycle",
|
||||
n_late == 1 and n_back == 1)
|
||||
check("the row goes once a fit shows the readings on time, or 2 minutes "
|
||||
"after the last fit, at most 160 s after the lag (a steady site shows "
|
||||
"nothing to fit)",
|
||||
lastr is not None
|
||||
and lastr - (ON + 300.0) <= lateness.FIT_S + lateness.FIT_GONE_S
|
||||
and not [r for r in c.checks() if r.get("id") == "late"])
|
||||
# The late verdict through the real cycle(): readings found late (30 s), then
|
||||
# the gate is frozen with its verdict 5 s old (held) or 20 s old (past
|
||||
# stale_input_s, 15 s).
|
||||
seen_zero = {}
|
||||
|
||||
|
||||
def verdict(after_s):
|
||||
def at(rig, c, t):
|
||||
if t == 500.0:
|
||||
c.answer.observe = lambda *a, **k: None
|
||||
c.answer.late_seen, c.answer.late_at = True, c._now_mono - after_s
|
||||
seen_zero[after_s] = [c.target]
|
||||
if t == 506.0:
|
||||
seen_zero[after_s] += [
|
||||
c.target, c.reason, [r["text"] for r in c.checks()
|
||||
if r.get("id") == "late"], c.why_words]
|
||||
v = R.Rig(lag_s=30.0, lag_on=ON, lag_off=2000.0)
|
||||
v.run(507.0, at)
|
||||
|
||||
|
||||
verdict(5.0)
|
||||
verdict(20.0)
|
||||
held, zero = seen_zero[5.0], seen_zero[20.0]
|
||||
print(f" late verdict 5 s old: {held[:3]}; 20 s old: {zero[:3]}")
|
||||
check("the late verdict holds the request until stale_input_s, then every "
|
||||
"inverter goes to 0 W, and the row says so (EN and NL)",
|
||||
held[1] == held[0] and held[2].startswith("readings late")
|
||||
and zero[1] == 0.0 and zero[4].startswith(
|
||||
"Home Assistant readings arrive late: 0 W")
|
||||
and len(zero[3]) == 1 and "every inverter is at 0 W" in zero[3][0]
|
||||
and "omvormers op 0 W" in zero[3][0].nl)
|
||||
on_time = R.Rig(house=lambda t: 600.0 + (2500.0 if t >= 200.0 else 0.0))
|
||||
c = on_time.run(600.0)
|
||||
check("readings on time, a load step: no late episode, no row",
|
||||
not [e for e in c.events if "readings late" in e]
|
||||
and not c.answer.late_seen)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. nothing else moves")
|
||||
STEP = dict(house=lambda t: 600.0 + (2500.0 if t >= 200.0 else 0.0))
|
||||
|
||||
|
||||
def compare(name, tol_w, **kw):
|
||||
a = R.Rig(**kw)
|
||||
a.run(500.0)
|
||||
b = R.Rig(**kw)
|
||||
no_gate(lambda: b.run(500.0))
|
||||
diff = max(abs(x[2] - y[2]) for x, y in zip(a.rows, b.rows))
|
||||
sa, sb = settled_at(a.rows, 200.0), settled_at(b.rows, 200.0)
|
||||
print(f" {name}: largest command difference {diff:.0f} W, settled "
|
||||
f"{sa - 200.0 if sa else None} s after the step (without the gate "
|
||||
f"{sb - 200.0 if sb else None} s)")
|
||||
return diff, sa, sb, a
|
||||
|
||||
|
||||
for p1 in (1.0, 2.0, 5.0):
|
||||
for units in (("a", "b"), ("a",)):
|
||||
d, sa, sb, _a = compare(f"no lag, {p1:.0f} s meter, N={len(units)}, "
|
||||
f"house 600 -> 3 100 W", 0.0, p1_s=p1,
|
||||
units=units, **STEP)
|
||||
check(f"{p1:.0f} s meter, N={len(units)}: settles within 10 s of the "
|
||||
f"run without the gate",
|
||||
sa is not None and sb is not None and sa - sb <= 10.0)
|
||||
d, sa, sb, a75 = compare("SAFETY-75 shape, 6 s dead time", 0.0, dead_s=6.0,
|
||||
**STEP)
|
||||
check("6 s dead time (SAFETY-75): the same commands as without the gate, and "
|
||||
"no late episode (the fit says 5-7 s, under 8 s)",
|
||||
d == 0.0 and not any("readings late" in e for e in a75.c.events))
|
||||
d, sa, sb, a0 = compare("start-up, the house at 2 000 W from the first second",
|
||||
0.0, house=2000.0)
|
||||
check("start-up: the first 120 s reach the house within 30 s, as without the "
|
||||
"gate, and no late episode",
|
||||
settled_at(a0.rows, 0.0) is not None and settled_at(a0.rows, 0.0) <= 30.0
|
||||
and not any("readings late" in e for e in a0.c.events))
|
||||
import safety94_rig as s94 # noqa: E402
|
||||
slow = [(n, tau, tick) for n in (1, 2) for tau in (10.0, 20.0)
|
||||
for tick in (1.0, 2.0)
|
||||
if any("readings late" in e for e in s94.run(
|
||||
n, s94.reversal(300.0, -2500.0, 800.0), 600.0, tau=tau,
|
||||
tick=tick)["c"].events)]
|
||||
check("a slow battery (10 or 20 s lag, SAFETY-85/94's rig) reversing out of a "
|
||||
"charge, readings on time: never taken for late readings, N=1 and N=2, "
|
||||
f"1 and 2 s meters (taken: {slow})", not slow)
|
||||
for name, kw in (("at their cut-out level", dict(soc=10.5)),
|
||||
("A refusing from 200 s",
|
||||
dict(cut=lambda u, t: u == "a" and t >= 200.0))):
|
||||
x = R.Rig(house=1800.0, **kw)
|
||||
x.run(600.0)
|
||||
y = R.Rig(house=1800.0, **kw)
|
||||
no_gate(lambda: y.run(600.0))
|
||||
check(f"readings on time, batteries {name}: not taken for late "
|
||||
f"readings, the same commands as without the gate",
|
||||
not any("readings late" in e for e in x.c.events)
|
||||
and [r[2] for r in x.rows] == [r[2] for r in y.rows])
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n5. the rules at their edges")
|
||||
check("LATE_S is SAFETY-75's slowest healthy answer: 6 + 6 + 10 + 1 = 23 s",
|
||||
lateness.LATE_S == 23.0)
|
||||
check("ANSWER_W is a probe's answer (100 W)",
|
||||
lateness.ANSWER_W == control.PROBE_ANSWER_W == 100.0)
|
||||
|
||||
|
||||
check("DELAY_S is the plant's 6 s settle and one 2 s meter tick",
|
||||
lateness.DELAY_S == 8.0)
|
||||
|
||||
|
||||
def fit(delay, *, follows=True, step_every=20, noise=40.0, dur=300):
|
||||
"""`Delay` on a meter that shows each command `delay` s later (a house
|
||||
of 2 000 W with +-`noise`), or never (`follows` False)."""
|
||||
import random
|
||||
rng, d = random.Random(7), lateness.Delay()
|
||||
edges = [0]
|
||||
while edges[-1] < dur:
|
||||
edges.append(edges[-1] + step_every * rng.uniform(0.5, 1.5))
|
||||
cmd = lambda t: 1500.0 + (400.0 if sum(e <= t for e in edges) % 2 else 0.0)
|
||||
for t in range(0, dur, 2):
|
||||
shown = cmd(t - delay) if follows and t >= delay else 1500.0
|
||||
d.add(float(t), cmd(t), 2000.0 - shown + rng.uniform(-noise, noise))
|
||||
return d.estimate()
|
||||
|
||||
|
||||
got = {k: fit(k) for k in (0, 6, 10, 30, 60)}
|
||||
print(f" fitted delays: {got}")
|
||||
check("the fit finds the meter's delay (0, 6, 10, 30, 60 s) to within 1 s",
|
||||
all(v is not None and abs(v - k) <= 1.0 for k, v in got.items()))
|
||||
check("a battery that does not follow gives no fit, and so does a command "
|
||||
"that does not move",
|
||||
fit(10, follows=False) is None and fit(10, step_every=10_000) is None)
|
||||
|
||||
|
||||
def gate(steps, exempt=(), late_seen=True):
|
||||
"""[(t, command, battery reading, meter)] through one Answer, its fit
|
||||
replaced by `late_seen`; after each: (pending, hold, late s, peak cap)."""
|
||||
a = lateness.Answer()
|
||||
a.delay.estimate = lambda: (30.0 if late_seen else 0.0)
|
||||
|
||||
def one(t, cmd, batt, grid):
|
||||
a.observe(float(t), cmd, batt, grid_w=grid, exempt=t in exempt)
|
||||
return (a.ask is not None, a.hold, a.late_s(float(t)),
|
||||
a.peak_cap(cmd))
|
||||
return a, [one(*x) for x in steps]
|
||||
|
||||
|
||||
STEP = [(0, 1000, 1000, 500), (2, 1500, 1000, 500)]
|
||||
HELD = STEP + [(t, 1500, 1000, 500) for t in range(4, 40, 2)]
|
||||
a, out = gate(HELD, late_seen=False)
|
||||
check("readings on time: an unanswered ask holds nothing, caps nothing and "
|
||||
"is never late (a battery at its limit)",
|
||||
out[-1][0] and all(o[1] == 0 and o[2] is None and o[3] is None
|
||||
for o in out))
|
||||
a, out = gate(STEP + [(4, 1500, 1000, 500), (6, 1500, 1450, 60)])
|
||||
check("readings late: the ask holds the loop up and caps the peak guard at "
|
||||
"what stands until the battery reading answers",
|
||||
out[1][1:] == (1, None, 1500.0) and out[2][1:] == (1, None, 1500.0)
|
||||
and out[3] == (False, 0, None, None))
|
||||
a, out = gate(HELD)
|
||||
check("...and with no move towards it for 23 s it is late (control holds, "
|
||||
"then 0 W)",
|
||||
[o[2] for o in out].index(0.0) == len([t for t, *_ in HELD
|
||||
if t - 2 < 23]))
|
||||
a, out = gate(HELD + [(40, 1500, 1000, 380)])
|
||||
check("...which ends when a reading starts towards the ask",
|
||||
out[-2][2] is not None and out[-1][2] is None and out[-1][0])
|
||||
a, out = gate(HELD, exempt=set(range(2, 40, 2)))
|
||||
check("exempt (a battery gives nothing under its ask, or refuses): no hold "
|
||||
"and no late verdict, the cap stays",
|
||||
all(o[1] == 0 and o[2] is None for o in out) and out[-1][3] == 1500.0)
|
||||
a, out = gate([(0, 1000, 1000, 500), (2, 1090, 1000, 500),
|
||||
(4, 1000, 1000, 500)])
|
||||
check("a move under 100 W is not an ask", not any(o[0] for o in out))
|
||||
a, out = gate(STEP + [(4, 1050, 1000, 500)])
|
||||
check("an ask taken back within 100 W of the reading is withdrawn",
|
||||
out[2][0] is False)
|
||||
T = control.Tuning(max_w=5000.0)
|
||||
base = dict(prev_w=1500.0, grid_w=800.0, actual_w=1000.0, tuning=T,
|
||||
i_w=1500.0)
|
||||
check("compute(hold=+1): the loop may not push past the standing command, "
|
||||
"but may come back",
|
||||
control.compute(**base).target_w > 1500.0
|
||||
and control.compute(**base, hold=1).target_w == 1500.0
|
||||
and control.compute(**base, hold=1).reason == control.WAITING
|
||||
and control.compute(**dict(base, grid_w=-800.0), hold=1).target_w
|
||||
< 1500.0)
|
||||
check("compute(hold=-1) mirrors it, and hold=0 is the law as it was",
|
||||
control.compute(**dict(base, grid_w=-800.0), hold=-1).target_w == 1500.0
|
||||
and control.compute(**base, hold=0) == control.compute(**base))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -61,6 +61,9 @@ def without_fix(fn):
|
||||
"""Run `fn` with the gate's old clock and the old refusal evidence."""
|
||||
real_f, real_c = app_inverter.Freshness, app_main.compute
|
||||
real_p = app_main.REFUSED_PROGRESS_W
|
||||
real_l = app_main.Controller._readings_late
|
||||
# SAFETY-128: that tree had no late-readings gate either.
|
||||
app_main.Controller._readings_late = lambda self, standing_w: None
|
||||
app_inverter.Freshness = Before94
|
||||
app_main.compute = lambda *a, **k: real_c(*a, **dict(k, gave_nothing=None))
|
||||
app_main.REFUSED_PROGRESS_W = float("inf")
|
||||
@@ -69,6 +72,7 @@ def without_fix(fn):
|
||||
finally:
|
||||
app_inverter.Freshness, app_main.compute = real_f, real_c
|
||||
app_main.REFUSED_PROGRESS_W = real_p
|
||||
app_main.Controller._readings_late = real_l
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
|
||||
@@ -68,10 +68,14 @@ def without(fn):
|
||||
Controller._held_out_w = lambda self, live: 0.0
|
||||
Controller._reseed_drop = lambda self, live: setattr(
|
||||
self, "_live_was", frozenset(live))
|
||||
real_late = Controller._readings_late
|
||||
# SAFETY-128: that tree had no late-readings gate either.
|
||||
Controller._readings_late = lambda self, standing_w: None
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
Controller._held_out_w, Controller._reseed_drop = fixed, real_drop
|
||||
Controller._readings_late = real_late
|
||||
|
||||
|
||||
def run(n=2, *, house=lambda t: 2400.0, dur=600.0, store=None, opt=None,
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SAFETY-128: the REAL Controller with Home Assistant readings arriving late.
|
||||
|
||||
python3 tools/safety128_rig.py # prints a few runs' summaries
|
||||
|
||||
The plant is TEST-23's (SAFETY-127 rig, t127_plant.py): two batteries
|
||||
(A 5 000 W, B 4 600 W) that ramp at 5 000 W/s and then follow a 1 s lag; each
|
||||
battery reading is published every 10 s (A at :x6, B at :x1.6); the meter every
|
||||
2 s; the house is `house` W plus seeded +-`noise` W, new each second.
|
||||
|
||||
`p1_s` sets the meter's period, `dead_s` a command dead time (SAFETY-75's
|
||||
laggy battery), `units` one battery ("a") or two.
|
||||
|
||||
Home Assistant runs `lag_s` seconds behind (a number, or a function of t) between `lag_on` and `lag_off`:
|
||||
every reading the add-on gets (meter, battery power, battery level) is the
|
||||
one published `lag_s` seconds earlier. When the lag starts, nothing new comes
|
||||
for `lag_s` seconds, then the backlog; when it stops, the readings jump to
|
||||
now. Commands reach the plant at once (t127_shim.py's lag mode).
|
||||
|
||||
The add-on reads every 1 s and runs a cycle on a new meter value or after
|
||||
`cycle_floor_s`, as `run_control()` does. Uses only the Controller API that
|
||||
release/1.0 77fe31b has, so the same file runs on the tree before SAFETY-128.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import app.main as m # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
|
||||
# 18:00 local (CET) on a weekday: the evening give run.
|
||||
BASE = datetime(2026, 11, 4, 17, 0, tzinfo=timezone.utc)
|
||||
UNITS = ("a", "b")
|
||||
MAXW = {"a": 5000.0, "b": 4600.0}
|
||||
CAPWH = {"a": 15360.0, "b": 20480.0}
|
||||
PHASE = {"a": 6.0, "b": 1.6}
|
||||
BPER = 10.0
|
||||
P1PER = 2.0
|
||||
RAMP = 5000.0
|
||||
LAG = 1.0
|
||||
|
||||
def options(units=UNITS):
|
||||
o = dict(OPTIONS)
|
||||
o["inverters"] = [
|
||||
{"name": f"inverter-{u}", "setpoint_entity": f"number.sp_{u}",
|
||||
"soc_entity": f"sensor.soc_{u}", "batt_entity": f"sensor.batt_{u}",
|
||||
"max_w": int(MAXW[u]), "bms_soc_floor": 10,
|
||||
"capacity_kwh": CAPWH[u] / 1000.0} for u in units]
|
||||
return o
|
||||
|
||||
|
||||
OPTIONS = {
|
||||
"meter_entity": "sensor.grid", "meter_source": "ha_signed",
|
||||
"p1_net_entity": "sensor.grid", "meter_phases": 1, "meter_max_age_s": 30,
|
||||
"peak_cap_w": 3500, "peak_margin_w": 50, "peak_soc_floor": 20,
|
||||
"gain": 0.6, "slew_w": 0, "target_grid_w": -10, "deadband_w": 15,
|
||||
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15,
|
||||
"cycle_floor_s": 15, "saturation_w": 500, "saturation_cycles": 3,
|
||||
"auto_start": True, "battery_modes_enabled": False,
|
||||
"arbitrage_enabled": False, "bms_soc_floor": 10,
|
||||
}
|
||||
|
||||
|
||||
class _Store:
|
||||
def __init__(self):
|
||||
from app.store import DEFAULTS
|
||||
self.data = dict(DEFAULTS)
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get_time(self, k):
|
||||
v = self.data.get(k)
|
||||
return datetime.fromisoformat(v) if v else None
|
||||
|
||||
def set_time(self, k, when):
|
||||
self.data[k] = when.isoformat() if when else None
|
||||
|
||||
|
||||
class _Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *a, **k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class Plant:
|
||||
"""The batteries, their registers and the meter, in 0.1 s steps."""
|
||||
|
||||
def __init__(self, house, noise, seed, soc, cut=None, units=UNITS,
|
||||
p1_s=P1PER, dead_s=0.0):
|
||||
self.house_fn, self.noise, self.seed = house, noise, seed
|
||||
self.units, self.p1_s, self.dead_s = units, p1_s, dead_s
|
||||
self.cut = cut or (lambda u, t: False)
|
||||
self.cmd = {u: 0.0 for u in units}
|
||||
self.queue = [] # (t, unit, command), dead time
|
||||
self.tgt = {u: 0.0 for u in units}
|
||||
self.act = {u: 0.0 for u in units}
|
||||
self.soc = {u: float(soc) for u in units}
|
||||
self.reg = {u: 0.0 for u in units}
|
||||
self.slot = {u: None for u in units}
|
||||
self.p1 = None
|
||||
self.p1_n = 0
|
||||
self.t = 0.0
|
||||
self.house = float(house(0.0))
|
||||
self.hist = [] # (t, p1 value, p1 index, {reg}, {soc})
|
||||
|
||||
def grid(self):
|
||||
return self.house - sum(self.act.values())
|
||||
|
||||
def advance(self, t):
|
||||
while self.t < t - 1e-9:
|
||||
self.t = round(self.t + 0.1, 6)
|
||||
sec = int(math.floor(self.t + 1e-9))
|
||||
rs = random.Random(self.seed * 1000003 + sec)
|
||||
self.house = (float(self.house_fn(self.t))
|
||||
+ rs.uniform(-self.noise, self.noise))
|
||||
while self.queue and self.queue[0][0] <= self.t + 1e-9:
|
||||
_t, u, v = self.queue.pop(0)
|
||||
self.cmd[u] = v
|
||||
for u in self.units:
|
||||
want = max(-MAXW[u], min(MAXW[u], self.cmd[u]))
|
||||
if self.cut(u, self.t):
|
||||
want = 0.0
|
||||
if self.soc[u] <= 10.0 and want > 0:
|
||||
want = 0.0
|
||||
if self.soc[u] >= 100.0 and want < 0:
|
||||
want = 0.0
|
||||
d = want - self.tgt[u]
|
||||
self.tgt[u] += math.copysign(min(abs(d), RAMP * 0.1), d)
|
||||
self.act[u] += ((self.tgt[u] - self.act[u])
|
||||
* (1 - math.exp(-0.1 / LAG)))
|
||||
self.soc[u] = max(0.0, min(100.0, self.soc[u] - self.act[u]
|
||||
* 0.1 / 3600.0 / CAPWH[u] * 100.0))
|
||||
slot = math.floor((self.t - PHASE[u]) / BPER + 1e-9)
|
||||
if slot != self.slot[u]:
|
||||
self.slot[u] = slot
|
||||
self.reg[u] = float(round(self.act[u]))
|
||||
if abs(self.t / self.p1_s - round(self.t / self.p1_s)) < 1e-6:
|
||||
self.p1 = round(self.grid(), 1)
|
||||
self.p1_n += 1
|
||||
self.hist.append((self.t, self.p1, self.p1_n, dict(self.reg),
|
||||
{u: round(v, 2) for u, v in self.soc.items()}))
|
||||
del self.hist[:-3000]
|
||||
|
||||
def view(self, t, lag_s, lag_on, lag_off):
|
||||
"""What Home Assistant shows at `t`: (p1, p1 index, regs, socs)."""
|
||||
lag_s = lag_s(t) if callable(lag_s) else lag_s
|
||||
if lag_s and lag_on <= t < lag_off:
|
||||
want = max(lag_on, t - lag_s)
|
||||
for row in reversed(self.hist):
|
||||
if row[0] <= want + 1e-9:
|
||||
return row[1:]
|
||||
return self.hist[0][1:]
|
||||
return (self.p1, self.p1_n, dict(self.reg),
|
||||
{u: round(v, 2) for u, v in self.soc.items()})
|
||||
|
||||
|
||||
class _Hass:
|
||||
def __init__(self, rig):
|
||||
self.rig = rig
|
||||
|
||||
async def limits(self, e):
|
||||
return (-MAXW[e[-1]], MAXW[e[-1]], 1.0)
|
||||
|
||||
async def call(self, *a):
|
||||
return True
|
||||
|
||||
async def state(self, e):
|
||||
return None
|
||||
|
||||
async def number(self, e, invert=False):
|
||||
_p1, _n, regs, socs = self.rig.seen
|
||||
u = e[-1]
|
||||
if e.startswith("sensor.soc"):
|
||||
return socs[u]
|
||||
if e.startswith("sensor.batt"):
|
||||
return regs[u]
|
||||
return None
|
||||
|
||||
async def set_number(self, e, v):
|
||||
p = self.rig.plant
|
||||
p.queue.append((p.t + p.dead_s, e[-1], float(v)))
|
||||
self.rig.writes.append((self.rig.plant.t, e[-1], float(v)))
|
||||
return True
|
||||
|
||||
|
||||
class Rig:
|
||||
def __init__(self, house=1800.0, noise=50.0, seed=90, soc=60.0,
|
||||
lag_s=0.0, lag_on=0.0, lag_off=0.0, extra=None, cut=None,
|
||||
start=BASE, units=UNITS, p1_s=P1PER, dead_s=0.0):
|
||||
house_fn = house if callable(house) else (lambda t, h=house: h)
|
||||
self.plant = Plant(house_fn, noise, seed, soc, cut, units, p1_s,
|
||||
dead_s)
|
||||
self.lag = (lag_s, lag_on, lag_off)
|
||||
self.o = dict(options(units), **(extra or {}))
|
||||
self.start = start
|
||||
self.writes = []
|
||||
self.rows = []
|
||||
self.seen = None
|
||||
|
||||
def run(self, dur, sample=None):
|
||||
"""Run `dur` s. Rows: (t, true grid, site command, site actual,
|
||||
loop reason, controller reason). `sample(rig, c, t)` per second."""
|
||||
async def go():
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="s128-"), "tariff.db")
|
||||
c = Controller(self.o, _Hass(self), _Store(), _Pub(),
|
||||
stop=asyncio.Event(), peak_db_path=db)
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -MAXW[i.name[-1]], MAXW[i.name[-1]]
|
||||
c.auto = True
|
||||
self.c = c
|
||||
real_dt = m.datetime
|
||||
clock = [self.start]
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
|
||||
m.datetime = _DT
|
||||
floor = float(self.o.get("cycle_floor_s", 15))
|
||||
last_grid, last_cycle, last_n = object(), None, None
|
||||
try:
|
||||
t = 0.0
|
||||
while t < dur:
|
||||
self.plant.advance(t)
|
||||
now = self.start + timedelta(seconds=t)
|
||||
clock[0] = now
|
||||
self.seen = self.plant.view(t, *self.lag)
|
||||
p1, n = self.seen[0], self.seen[1]
|
||||
if p1 is not None and n != last_n:
|
||||
last_n = n
|
||||
imp, exp = split_signed(p1)
|
||||
c.p1.submit(make_sample(
|
||||
"ha_signed", imp, exp, phases=1, ingest_ts=now,
|
||||
ingest_mono=time.monotonic()))
|
||||
await c.read_inputs(now_mono=t)
|
||||
if (c.grid != last_grid or last_cycle is None
|
||||
or t - last_cycle >= floor):
|
||||
last_grid, last_cycle = c.grid, t
|
||||
await c.cycle()
|
||||
self.rows.append((
|
||||
t, self.plant.grid(), sum(self.plant.cmd.values()),
|
||||
sum(self.plant.act.values()), c.loop_reason,
|
||||
c.reason))
|
||||
if sample is not None:
|
||||
sample(self, c, t)
|
||||
t += 1.0
|
||||
finally:
|
||||
m.datetime = real_dt
|
||||
return c
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
def summary(rows, t0, t1):
|
||||
"""Over [t0, t1): (min cmd, max cmd, min grid, max grid, s of export
|
||||
below -200 W, s below -1 000 W)."""
|
||||
sel = [r for r in rows if t0 <= r[0] < t1]
|
||||
return (min(r[2] for r in sel), max(r[2] for r in sel),
|
||||
min(r[1] for r in sel), max(r[1] for r in sel),
|
||||
sum(1 for r in sel if r[1] < -200), sum(1 for r in sel
|
||||
if r[1] < -1000))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for lag in (0.0, 10.0, 30.0):
|
||||
r = Rig(lag_s=lag, lag_on=300.0, lag_off=450.0)
|
||||
r.run(600.0)
|
||||
print(f"lag {lag:4.0f} s: before {summary(r.rows, 200, 300)}")
|
||||
print(f" during {summary(r.rows, 300, 450)}")
|
||||
print(f" after {summary(r.rows, 480, 600)}")
|
||||
Reference in New Issue
Block a user