Compare commits
8
Commits
UX-77
...
SAFETY-129
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c70eeb6916 | ||
|
|
03541a1eee | ||
|
|
85433edac6 | ||
|
|
2667cf0dda | ||
|
|
5b6cb0bf08 | ||
|
|
c66aa7f69a | ||
|
|
6d2b9b5335 | ||
|
|
47c89a13d0 |
@@ -4,6 +4,7 @@
|
||||
|
||||
- Home now draws today's charge from the grid for every hour the add-on will really charge, not only the run under way ([[UX-77]]).
|
||||
- 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]]).
|
||||
- With a 5 s meter, readings 30 s late are now noticed within 100 seconds, not 3 minutes ([[SAFETY-129]]).
|
||||
|
||||
## 0.20.1
|
||||
|
||||
|
||||
@@ -31,7 +31,12 @@ inverter limit in its last seconds (OPT-34 rig F-2).
|
||||
- **Meter** (`MeterFit`, round 1's fit): `meter(t) + request(t - d)` is the
|
||||
house alone at the right `d`. Quick, but confounded by the house. Needs
|
||||
16 new values in 60 s, so only a meter of about 3.5 s or faster: at 5 s
|
||||
it gave false delays. Late over 8 s.
|
||||
it gave false delays. Late over 8 s. At a slower meter (a 5 s P1) the
|
||||
same fit runs over 120 s and calls only a delay over 16 s late, on 2 fits
|
||||
in a row, and never ends a verdict (SAFETY-129). Alone it reads 35-90 s on healthy
|
||||
runs, a few times in a row; only the slow-start and battery-fit check
|
||||
below keeps those from a verdict. 10 s late is not found: it cannot be
|
||||
told from a 10 s slow battery, and it does not swing at 5 s.
|
||||
- A meter fit says late only with evidence from the batteries' side too:
|
||||
a request more than 100 W from the battery reading took 3 s or more to
|
||||
move a reading towards it (`SLOW_START_S`; on time they start within
|
||||
@@ -64,9 +69,12 @@ house at 1, 2 and 5 s ticks, so the commands are the same as without the gate.
|
||||
The fits run all the time; the pull request has the counts.
|
||||
|
||||
**What it costs.**
|
||||
- Found 36-68 s after a lag starts at 1-2 s meters, about 3 minutes at a 5 s
|
||||
meter (battery fit only; the meter fit needs a faster meter). Until then control is as before. At 5 s and 30 s
|
||||
late the export is cut to about a third, not removed.
|
||||
- Found 36-68 s after a lag starts at 1-2 s meters; at a 5 s meter 30 s late
|
||||
is found within 100 s after it starts (88 s measured) (the loop first moves 100 W after 60 s,
|
||||
SAFETY-129) with the export above -200 W, and 10 s late is not found. Until
|
||||
then control is as before. In a quiet house the "readings late" row stays
|
||||
about 2 minutes longer after the lag ends (126 s against 32 s on base); no
|
||||
harm measured, and it clears as early as on base when the load changes.
|
||||
- A battery that answers slowly is read as late readings and held: a dead
|
||||
time of 10 s or more (8 s reads 7-8; 10 s reads 10 at 1-2 s meters, 9 at
|
||||
5 s), or a 15-20 s lag (review round 2). The GoodWe measured settles in 6 s
|
||||
|
||||
@@ -101,8 +101,9 @@ is `SolarToday.add()` after the solar read, in a `try`, in memory.
|
||||
control will stop charging, as the tab does for tomorrow's: the cheap run
|
||||
rolls on at its end while a battery is short (`cheaproll.roll_end()`, the
|
||||
same loop as `dayview._levels()`), the level read at the charge power the
|
||||
peak limit leaves (peak aim less the house, else the rating), and each
|
||||
battery's block ends at its own stop. The curve stops charging at its level
|
||||
peak limit leaves (peak aim less the house, else the rating) and shared by
|
||||
room left to full as control shares it (`packshare.weights()`, re-read as
|
||||
the levels move), and each battery's block ends at its own stop. The curve stops charging at its level
|
||||
(`charge_to()`) and giving at the floor.
|
||||
- Today, a give run in the plan on a battery that is empty now has no block on
|
||||
Home (no energy to give). The tab equals Home, and says so: "The add-on plans
|
||||
|
||||
@@ -10,8 +10,10 @@ that goes on until the batteries are at their level or the cheap hours end.
|
||||
(`modetrade.energy()`, `priceruns.plan()`), until nothing is short or the next
|
||||
run does not follow on. `for_today()` reads the batteries at the level they
|
||||
will have by then: the charge power is the peak limit's room (peak aim minus
|
||||
the house, as `dayplan` counts it), shared by rating. Where that room is not
|
||||
known (no house profile or no aim), the rating is used.
|
||||
the house, as `dayplan` counts it), shared as control shares it: by room
|
||||
left to full (`packshare.weights()`), read again as the levels move, so a
|
||||
battery that fills drops out and its share goes to the others. Where that
|
||||
room is not known (no house profile or no aim), the rating is used.
|
||||
DECISIONS/UX-54-forecast-tab.md has the record.
|
||||
"""
|
||||
|
||||
@@ -20,7 +22,7 @@ import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from . import arbitrage, dayplan, modetrade, priceruns
|
||||
from . import arbitrage, dayplan, modetrade, packshare, priceruns
|
||||
from .dayplan import EFF
|
||||
from .price import required_spread, uncertainty_of
|
||||
|
||||
@@ -85,7 +87,7 @@ def _ceil_slot(t):
|
||||
class _Charge:
|
||||
"""The batteries' levels followed from `t0` while the chargers charge at
|
||||
the room the peak limit leaves: `min(sum of ratings, aim - house)`, shared
|
||||
by rating, each stopping at `modetrade.charge_to()`."""
|
||||
by room left to full as control does (`_shares()`), each stopping at `modetrade.charge_to()`."""
|
||||
|
||||
def __init__(self, packs, t0, ceiling_pct, targets, aim_w, house):
|
||||
self.packs = {p.name: p for p in packs}
|
||||
@@ -115,6 +117,34 @@ class _Charge:
|
||||
self._charge(s, e, hour)
|
||||
self.t = nxt
|
||||
|
||||
def _shares(self, ch, w):
|
||||
"""Watts per charger: `w` shared by `packshare.weights()` over all
|
||||
packs (room left to full, as control passes it), no charger above its
|
||||
rating, the surplus going to the others. By rating where the room left
|
||||
is not known (no capacity)."""
|
||||
wt = packshare.weights(list(self.packs.values()), -1.0)
|
||||
if wt is None:
|
||||
wt = {p.name: abs(p.rating_w) for p in ch}
|
||||
wt = {p.name: wt.get(p.name, 0.0) for p in ch}
|
||||
cap = {p.name: abs(p.rating_w) for p in ch}
|
||||
out, open_, left = {}, set(wt), float(w)
|
||||
while open_:
|
||||
tot = sum(wt[n] for n in open_)
|
||||
if tot <= 0.0:
|
||||
for n in open_:
|
||||
out[n] = 0.0
|
||||
break
|
||||
over = [n for n in open_ if left * wt[n] / tot > cap[n] + 1e-9]
|
||||
if not over:
|
||||
for n in open_:
|
||||
out[n] = left * wt[n] / tot
|
||||
break
|
||||
for n in over:
|
||||
out[n] = cap[n]
|
||||
left -= cap[n]
|
||||
open_.discard(n)
|
||||
return out
|
||||
|
||||
def _charge(self, s, e, hour):
|
||||
ch = modetrade.chargers(list(self.packs.values()), self.ceil,
|
||||
targets=self.targets)
|
||||
@@ -123,12 +153,13 @@ class _Charge:
|
||||
return
|
||||
w = self._room_w(hour, total)
|
||||
hrs = (e - s).total_seconds() / 3600.0
|
||||
share = self._shares(ch, w)
|
||||
for p in ch:
|
||||
self.stops.setdefault(p.name, None)
|
||||
if not p.capacity_kwh:
|
||||
continue
|
||||
to = modetrade.charge_to(p, self.ceil, self.targets)
|
||||
kwh = w * abs(p.rating_w) / total * hrs / 1000.0 * EFF
|
||||
kwh = share[p.name] * hrs / 1000.0 * EFF
|
||||
room = (to - p.soc) / 100.0 * float(p.capacity_kwh)
|
||||
if kwh <= 0.0:
|
||||
continue
|
||||
|
||||
@@ -10,7 +10,8 @@ already fixed. The readings show it: they answer each power request late.
|
||||
house does not move that reading, so a repeating load cannot fool it. It
|
||||
needs the request to move FIT_MOVE_W; one reading per 10 s makes it slow.
|
||||
- `MeterFit`: how long after the site request the meter shows it. Quick at a
|
||||
1-3 s meter, none at a slower one, and a house load that repeats can fool it.
|
||||
1-3 s meter, slower and for long lags only at a 5 s one (SAFETY-129), and a
|
||||
house load that repeats can fool it.
|
||||
The meter counts only when an ask was slow to start and no battery fit said
|
||||
"on time"; with hysteresis (ON_S / OFF_S) the result is `late_seen`. Then (`observe()`, each cycle):
|
||||
- a new command more than ANSWER_W from the battery reading is PENDING until
|
||||
@@ -163,13 +164,22 @@ class Delay:
|
||||
|
||||
|
||||
# The meter's delay: fitted over this much history, from this many new
|
||||
# values (a meter of about 3.5 s or faster: a slower one gives too few to
|
||||
# fit without false delays); late over M_LATE_S (the plant's 6 s settle and one 2 s meter tick),
|
||||
# but only when the batteries' own readings have not just shown them on time.
|
||||
# values (a meter of about 3.5 s or faster; a slower one see M_SLOW_FIT_S);
|
||||
# late over M_LATE_S (the plant's 6 s settle and one 2 s meter tick), but only
|
||||
# when the batteries' own readings have not just shown them on time.
|
||||
M_FIT_S = 60.0
|
||||
M_ROWS = 16
|
||||
M_LATE_S = freshness.PLANT_SETTLE_S + 2.0 # 8.0
|
||||
M_SHARE, M_BETTER = 0.4, 0.7
|
||||
# SAFETY-129: a meter slower than that (a 5 s P1) has fewer than M_ROWS values
|
||||
# in M_FIT_S, so it is fitted over M_SLOW_FIT_S instead, from the same M_ROWS
|
||||
# values. It calls a delay late only over M_SLOW_LATE_S and on ON_FITS fits in
|
||||
# a row. Alone it reads 35-90 s on healthy runs, a few times in a row; what
|
||||
# keeps those from a verdict is the slow-start and battery-fit check in
|
||||
# watch(), not the limit. A 10 s lag is not found: it cannot be told from a
|
||||
# slow battery (R2-1), nor does it swing at 5 s.
|
||||
M_SLOW_FIT_S = 120.0
|
||||
M_SLOW_LATE_S = 16.0
|
||||
# A meter fit counts only after an ask that took this long to start moving a
|
||||
# reading: on time they start within 1-2 s (the recorded house, at 1-5 s).
|
||||
SLOW_START_S = 3.0
|
||||
@@ -183,19 +193,30 @@ class MeterFit:
|
||||
up with an earlier request, so it only counts while the batteries' own
|
||||
fit is not on time (`Answer.watch`)."""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, fit_s=M_FIT_S, late_s=M_LATE_S):
|
||||
self.fit_s, self.late_s = fit_s, late_s
|
||||
self.cmds = deque() # (t, request) when it changed
|
||||
self.grid = deque() # (t, meter) new values only
|
||||
|
||||
def slow(self):
|
||||
"""The meter is too slow for M_ROWS values in `fit_s`: the usual gap
|
||||
between its new values (median, so a pause does not count) is over
|
||||
fit_s / M_ROWS."""
|
||||
ts = [t for t, _g in self.grid]
|
||||
if len(ts) < 5:
|
||||
return False
|
||||
gaps = sorted(b - a for a, b in zip(ts, ts[1:]))
|
||||
return gaps[len(gaps) // 2] * M_ROWS > self.fit_s
|
||||
|
||||
def add(self, now, cmd, grid):
|
||||
if cmd is not None and (not self.cmds or self.cmds[-1][1] != cmd):
|
||||
self.cmds.append((now, float(cmd)))
|
||||
if grid is not None and (not self.grid or self.grid[-1][1] != grid):
|
||||
self.grid.append((now, float(grid)))
|
||||
while self.grid and self.grid[0][0] < now - M_FIT_S:
|
||||
while self.grid and self.grid[0][0] < now - self.fit_s:
|
||||
self.grid.popleft()
|
||||
while (len(self.cmds) > 1
|
||||
and self.cmds[1][0] <= now - M_FIT_S - MAX_DELAY_S):
|
||||
and self.cmds[1][0] <= now - self.fit_s - MAX_DELAY_S):
|
||||
self.cmds.popleft()
|
||||
|
||||
def estimate(self):
|
||||
@@ -214,9 +235,9 @@ class MeterFit:
|
||||
best = min(range(len(pure)), key=lambda d: (pure[d], d))
|
||||
if g_var <= 0 or pure[best] > (1 - M_SHARE) * g_var:
|
||||
return None
|
||||
if best <= M_LATE_S:
|
||||
if best <= self.late_s:
|
||||
return float(best)
|
||||
if pure[best] > M_BETTER * min(pure[:int(M_LATE_S) + 1]):
|
||||
if pure[best] > M_BETTER * min(pure[:int(self.late_s) + 1]):
|
||||
return None
|
||||
return float(best)
|
||||
|
||||
@@ -236,6 +257,8 @@ class Answer:
|
||||
self._was = None # (command, reading, meter) at the last call
|
||||
self.delay = Delay()
|
||||
self.meter = MeterFit()
|
||||
self.meter_slow = MeterFit(M_SLOW_FIT_S, M_SLOW_LATE_S) # SAFETY-129
|
||||
self._ms_run = 0 # late slow-meter fits in a row
|
||||
self._batt_fit = None # (when, d): the batteries' last fit
|
||||
self._b_run = 0 # late battery fits in a row
|
||||
self._start_age = None # (when, how long the last ask took to start)
|
||||
@@ -279,14 +302,18 @@ class Answer:
|
||||
delays. Late on ON_FITS battery fits from ON_S, or on a meter fit
|
||||
over M_LATE_S when an ask was slow to start (SLOW_START_S) and no
|
||||
battery fit within FIT_S said on time: a house load that repeats can
|
||||
fool the meter fit, not these. On time again on a battery fit under
|
||||
OFF_S, or a meter fit within M_LATE_S unless a battery fit said late.
|
||||
fool the meter fit, not these. A meter too slow for M_ROWS values in
|
||||
M_FIT_S (a 5 s P1) is fitted over M_SLOW_FIT_S and counts only over
|
||||
M_SLOW_LATE_S, on ON_FITS fits in a row. On time again on a battery fit
|
||||
under OFF_S, or a meter fit within M_LATE_S unless a battery fit said
|
||||
late.
|
||||
"""
|
||||
if self._start is None:
|
||||
self._start = now
|
||||
for name, cmd, reading in units:
|
||||
self.delay.add(now, name, cmd, reading)
|
||||
self.meter.add(now, standing_w, grid_w)
|
||||
self.meter_slow.add(now, standing_w, grid_w)
|
||||
if self._fit_at is not None and now - self._fit_at < FIT_EVERY_S:
|
||||
return
|
||||
self._fit_at = now
|
||||
@@ -295,6 +322,9 @@ class Answer:
|
||||
and now - self._exempt_at <= FIT_S:
|
||||
b = None # a battery that stopped following: no late fit
|
||||
m = self.meter.estimate()
|
||||
ms = None
|
||||
if self.meter.slow():
|
||||
ms = self.meter_slow.estimate()
|
||||
if b is not None:
|
||||
self._batt_fit = (now, b)
|
||||
self.shown_s = b if b >= ON_S else None
|
||||
@@ -304,7 +334,13 @@ class Answer:
|
||||
b_late = recent and self._batt_fit[1] >= ON_S
|
||||
|
||||
self._b_run = self._b_run + 1 if (b or 0.0) >= ON_S else 0
|
||||
d = b if b is not None else m
|
||||
self._ms_run = self._ms_run + 1 if (ms or 0.0) > M_SLOW_LATE_S else 0
|
||||
# A slow-meter fit only ever adds a late verdict: it counts as a fit
|
||||
# when it is late on ON_FITS in a row, and never ends one, so a slow
|
||||
# battery (R2-1) runs exactly as before.
|
||||
if self._ms_run < ON_FITS:
|
||||
ms = None
|
||||
d = b if b is not None else m if m is not None else ms
|
||||
if d is None:
|
||||
if self._fit_ok is None or now - self._fit_ok > FIT_GONE_S:
|
||||
self.late_seen = False # nothing to tell it by
|
||||
@@ -312,12 +348,13 @@ class Answer:
|
||||
self.fits += 1
|
||||
self.delay_s, self._fit_ok = d, now
|
||||
warm = now - self._start >= MAX_DELAY_S + FIT_S
|
||||
m_late = (m or 0.0) > M_LATE_S
|
||||
m_late = ((m or 0.0) > M_LATE_S and warm) or (
|
||||
self._ms_run >= ON_FITS
|
||||
and now - self._start >= MAX_DELAY_S + M_SLOW_FIT_S)
|
||||
slow = (self._start_age is not None
|
||||
and now - self._start_age[0] <= M_FIT_S
|
||||
and self._start_age[1] >= SLOW_START_S)
|
||||
if self._b_run >= ON_FITS or (
|
||||
m_late and warm and slow and not b_fresh):
|
||||
if self._b_run >= ON_FITS or (m_late and slow and not b_fresh):
|
||||
self.late_seen = True
|
||||
elif (b is not None and b < OFF_S) or (
|
||||
m is not None and m <= M_LATE_S and not b_late):
|
||||
|
||||
@@ -229,9 +229,11 @@ late5 = R.Rig(lag_s=30.0, lag_on=ON, lag_off=ON + 300.0, p1_s=5.0)
|
||||
late5.run(ON + 300.0, lambda rig, c, t: rows5.extend(
|
||||
str(r["text"]) for r in c.checks() if r.get("id") == "late"))
|
||||
print(f" 5 s meter, 30 s late: {sorted(set(rows5))}")
|
||||
check("at a 5 s meter (the batteries' fit) the row names the delay: about "
|
||||
"20-32 s for 30 s late",
|
||||
rows5 and set(rows5) <= _ok_texts and any("about" in x for x in rows5))
|
||||
# SAFETY-129: the 5 s meter's fit finds it first (no figure); the hold then
|
||||
# leaves the batteries' fit nothing to measure, so the row names no delay.
|
||||
check("at a 5 s meter the row says the readings are late, with no figure "
|
||||
"(the meter alone found it)",
|
||||
rows5 and set(rows5) == {str(messages.row_readings_late(None))})
|
||||
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 "
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Runnable check for SAFETY-129: late Home Assistant readings found at a 5 s meter.
|
||||
`python3 test_safety129.py`
|
||||
|
||||
SAFETY-128 fitted the meter's delay only at a meter of about 3.5 s or faster
|
||||
(16 new values in 60 s). The owner's P1 reports every 5 s, so there only the
|
||||
batteries' fit ran: with readings 30 s late the loop swung to -420 W export
|
||||
and was found after about 3 minutes (-1 325 W before the hold).
|
||||
`lateness.MeterFit` now also runs at a slow meter over 120 s, and calls a
|
||||
delay late only over `M_SLOW_LATE_S` on two fits in a row, with the same
|
||||
corroboration as before (an ask slow to start, no battery fit on time).
|
||||
DECISIONS/SAFETY-128-late-readings.md.
|
||||
|
||||
The REAL Controller on TEST-23's plant (tools/safety128_rig.py).
|
||||
|
||||
1. 5 s meter, readings 30 s late (N=2 house 1 800 W, N=1 house 1 500 W): found
|
||||
within 100 s, and the export never goes below -200 W (red on c66aa7f: found
|
||||
after 190 s, export to -420 W).
|
||||
2. 5 s meter, readings 10 s late: no swing, nothing to hold; and the limit that
|
||||
goes with it: a battery that answers in 10 s (R2-1) is still not read as
|
||||
late at a 5 s meter, which a lower slow-meter threshold would break.
|
||||
3. No false holds at a slow meter: the owner's shape at 2, 5 and 10 s, HA
|
||||
restarts and meter pauses at 2 and 5 s, 8-10 s dead times: never
|
||||
late, so the commands are those of the run without the gate.
|
||||
4. The slow-meter fit at its edges.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
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 safety128_rig as R # noqa: E402
|
||||
import app.main as app_main # noqa: E402
|
||||
from app import lateness # 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 run_late(**kw):
|
||||
"""Run the rig; (rig, seconds the verdict stood, first second it stood)."""
|
||||
dur = kw.pop("dur")
|
||||
seen = []
|
||||
r = R.Rig(**kw)
|
||||
r.run(dur, lambda rig, c, t: seen.append(t) if c.answer.late_seen
|
||||
else None)
|
||||
return r, len(seen), min(seen) if seen else None
|
||||
|
||||
|
||||
def grid_min(rows, t0, t1):
|
||||
return min(r[1] for r in rows if t0 <= r[0] < t1)
|
||||
|
||||
|
||||
AB, A = ("a", "b"), ("a",)
|
||||
ON = 300.0
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. 5 s meter, readings 30 s late for 300 s")
|
||||
for units, house in ((AB, 1800.0), (A, 1500.0)):
|
||||
n = f"N={len(units)}"
|
||||
r, secs, first = run_late(house=house, lag_s=30.0, lag_on=ON,
|
||||
lag_off=ON + 300.0, units=units, p1_s=5.0,
|
||||
dur=ON + 420.0)
|
||||
low = grid_min(r.rows, ON, ON + 300.0)
|
||||
print(f" {n}: found {None if first is None else first - ON:.0f} s "
|
||||
f"after the start, export to {low:.0f} W")
|
||||
check(f"{n}: found within 100 s of the lag starting",
|
||||
first is not None and first - ON <= 100.0)
|
||||
check(f"{n}: the export never goes below -200 W", low >= -200.0)
|
||||
if len(units) == 2:
|
||||
old = R.Rig(house=house, lag_s=30.0, lag_on=ON, lag_off=ON + 300.0,
|
||||
units=units, p1_s=5.0)
|
||||
no_gate(lambda: old.run(ON + 300.0))
|
||||
check(f"{n}: without the gate the export goes below -1 kW",
|
||||
grid_min(old.rows, ON, ON + 300.0) < -1000.0)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. 5 s meter, readings 10 s late; a battery that answers in 10 s")
|
||||
r, secs, first = run_late(house=1800.0, lag_s=10.0, lag_on=ON,
|
||||
lag_off=ON + 150.0, units=AB, p1_s=5.0,
|
||||
dur=ON + 200.0)
|
||||
low = grid_min(r.rows, ON, ON + 150.0)
|
||||
print(f" 10 s late: export to {low:.0f} W, verdict stood {secs} s")
|
||||
check("10 s late: no swing, the export never goes below -200 W", low >= -200.0)
|
||||
|
||||
|
||||
def owner(t):
|
||||
w = 400.0
|
||||
if 600 <= t < 780:
|
||||
w += 2200
|
||||
if 1200 <= t < 2400 and ((t - 1200) % 105) < 45:
|
||||
w += 2000
|
||||
return w
|
||||
|
||||
|
||||
for dead in (8.0, 10.0):
|
||||
r, secs, first = run_late(house=owner, noise=80.0, units=AB, p1_s=5.0,
|
||||
dead_s=dead, dur=800.0)
|
||||
print(f" {dead:.0f} s dead time, 5 s meter: late {secs} s")
|
||||
check(f"{dead:.0f} s dead time at a 5 s meter is not read as late "
|
||||
f"(R2-1 does not get worse)", secs == 0)
|
||||
|
||||
# At a 6 s meter an 8 s dead time reads late on the slow fit alone; only the
|
||||
# slow-start and battery-fit check keeps it from a verdict (122 s late
|
||||
# without the check, from 1 084 s).
|
||||
r, secs, first = run_late(house=owner, noise=80.0, units=AB, p1_s=6.0,
|
||||
dead_s=8.0, dur=1500.0)
|
||||
print(f" 8 s dead time, 6 s meter: late {secs} s")
|
||||
check("an 8 s dead time at a 6 s meter is never late (the slow fit needs an "
|
||||
"ask slow to start and no battery fit on time)", secs == 0)
|
||||
|
||||
# A 12 s dead time is already read as late (R2-1, SAFETY-128); the slow-meter
|
||||
# fit must not make that longer. The same run with the slow fit off is the
|
||||
# tree before this card.
|
||||
_slow = lateness.MeterFit.slow
|
||||
secs = {}
|
||||
for label in ("with", "without"): # 12 s dead time: late from about 140 s
|
||||
lateness.MeterFit.slow = _slow if label == "with" else (lambda self: False)
|
||||
try:
|
||||
secs[label] = run_late(house=owner, noise=80.0, units=A, p1_s=5.0,
|
||||
dead_s=12.0, dur=900.0)[1]
|
||||
finally:
|
||||
lateness.MeterFit.slow = _slow
|
||||
print(f" 12 s dead time, N=1, 5 s meter: late {secs['with']} s, with the "
|
||||
f"slow fit off {secs['without']} s")
|
||||
check("a 12 s dead time at a 5 s meter is late no longer than before "
|
||||
"(R2-1 does not get worse)", secs["with"] <= secs["without"])
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. no false holds")
|
||||
_view = R.Plant.view
|
||||
|
||||
|
||||
def frozen(windows, meter_only):
|
||||
def view(self, t, lag_s, lag_on, lag_off):
|
||||
cur = _view(self, t, lag_s, lag_on, lag_off)
|
||||
for a, b in windows:
|
||||
if a <= t < b:
|
||||
for row in reversed(self.hist):
|
||||
if row[0] <= a + 1e-9:
|
||||
old = row[1:]
|
||||
return (old[0], old[1], cur[2], cur[3]) \
|
||||
if meter_only else old
|
||||
return cur
|
||||
return view
|
||||
|
||||
|
||||
for p1 in (2.0, 5.0, 10.0):
|
||||
kw = dict(house=owner, noise=80.0, units=AB, p1_s=p1)
|
||||
r, secs, first = run_late(dur=800.0, **kw)
|
||||
new = r
|
||||
old = R.Rig(**kw)
|
||||
no_gate(lambda: old.run(800.0))
|
||||
same = max(abs(x[2] - y[2]) for x, y in zip(new.rows, old.rows))
|
||||
print(f" owner shape, {p1:.0f} s meter: late {secs} s, command "
|
||||
f"difference {same:.0f} W")
|
||||
check(f"{p1:.0f} s meter, owner shape: never late, same commands",
|
||||
secs == 0 and same == 0)
|
||||
for label, windows, meter_only in (
|
||||
("HA restart", ((650.0, 710.0),), False),
|
||||
("meter pause", ((640.0, 700.0),), True)):
|
||||
for p1 in (2.0, 5.0):
|
||||
R.Plant.view = frozen(windows, meter_only)
|
||||
try:
|
||||
r, secs, first = run_late(house=owner, noise=80.0, units=AB,
|
||||
p1_s=p1, dur=800.0)
|
||||
finally:
|
||||
R.Plant.view = _view
|
||||
check(f"{label} at a {p1:.0f} s meter: never late", secs == 0)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. the slow-meter fit at its edges")
|
||||
|
||||
|
||||
def meter_with(period, pause_after=None, pause_s=40.0, n=24):
|
||||
f = lateness.MeterFit()
|
||||
f.add(0.0, 100.0, 0.0)
|
||||
for k in range(1, n):
|
||||
gap = pause_s if pause_after is not None and k > pause_after else 0.0
|
||||
f.add(period * k + gap, None, float(k))
|
||||
return f
|
||||
|
||||
|
||||
check("a 5 s and a 10 s meter are slow",
|
||||
meter_with(5.0).slow() and meter_with(10.0).slow())
|
||||
check("a 1 s, a 2 s and a 3 s meter are not slow",
|
||||
not any(meter_with(p, n=60).slow() for p in (1.0, 2.0, 3.0)))
|
||||
check("a pause in a 2 s meter does not make it slow",
|
||||
not meter_with(2.0, pause_after=10, n=40).slow())
|
||||
def fed(values):
|
||||
"""Answer.watch() with the slow fit reading `values`, one per fit."""
|
||||
a = lateness.Answer()
|
||||
a.meter.slow = lambda: True
|
||||
a.meter.estimate = a.delay.estimate = lambda: None
|
||||
it = iter(values)
|
||||
a.meter_slow.estimate = lambda: next(it, None)
|
||||
for k in range(len(values)):
|
||||
a.watch(2.0 * k, [], 0.0, 0.0)
|
||||
return a
|
||||
|
||||
|
||||
check("one late slow-meter fit is no fit; two in a row are one, and "
|
||||
"one on time or in between is none",
|
||||
fed([30.0]).fits == 0 and fed([30.0, 30.0]).fits == 1
|
||||
and fed([30.0, None, 30.0]).fits == 0 and fed([30.0, 12.0]).fits == 0
|
||||
and fed([3.0, 3.0]).fits == 0 and not fed([30.0, 30.0]).late_seen)
|
||||
check("the slow fit asks for a longer window and a later delay than the "
|
||||
"fast one", lateness.M_SLOW_FIT_S > lateness.M_FIT_S
|
||||
and lateness.M_SLOW_LATE_S > lateness.M_LATE_S)
|
||||
check("the slow fit's late limit is above the fast one's and above a "
|
||||
"10 s lag", lateness.M_SLOW_LATE_S >= 16.0)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -22,7 +22,7 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import ux54_scenarios as S # noqa: E402
|
||||
from app import dayplan, homeview, modetrade, packmode # noqa: E402
|
||||
from app import dayplan, homeview, modetrade, packmode, packshare # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.peakguard import PeakDecision # noqa: E402
|
||||
from app.static_tariff import static_plan # noqa: E402
|
||||
@@ -38,6 +38,7 @@ UTC = timezone.utc
|
||||
EFF = 0.95
|
||||
AIM = 3450.0
|
||||
SLOT = timedelta(hours=1) # the price slot of the day/night tariff
|
||||
QUARTER = timedelta(minutes=15) # the run's own slot
|
||||
SAMPLES = ("23:30", "00:30", "01:30", "02:30", "04:30")
|
||||
OLD = ("leftover_solar", "cheap_hours") # the owner's old shape
|
||||
NOW = ("leftover_solar", "leftover_solar") # the current one
|
||||
@@ -83,8 +84,27 @@ def hm(s, day):
|
||||
return day.replace(hour=h, minute=m)
|
||||
|
||||
|
||||
def split_by_room(w, ch, rating, wt, invs):
|
||||
"""Control's split of `w` over the chargers `ch`: by `packshare.weights()`,
|
||||
no battery over its rating, the surplus to the others."""
|
||||
p, open_, left = [0.0, 0.0], set(ch), w
|
||||
while open_:
|
||||
tot = sum(wt[invs[i].name] for i in open_)
|
||||
over = [i for i in open_
|
||||
if left * wt[invs[i].name] / tot > rating[i] + 1e-9]
|
||||
if not over:
|
||||
for i in open_:
|
||||
p[i] = left * wt[invs[i].name] / tot
|
||||
break
|
||||
for i in over:
|
||||
p[i] = rating[i]
|
||||
left -= rating[i]
|
||||
open_.discard(i)
|
||||
return p
|
||||
|
||||
|
||||
def walk(modes, soc0, house_w, night=None, dynamic=False, samples=SAMPLES,
|
||||
start_h=22, end_h=7, plain=None):
|
||||
start_h=22, end_h=7, plain=None, by_room=True, fc=(10.2, 10.0)):
|
||||
"""Walk 22:00-07:15. Returns (charging, homes, commands):
|
||||
charging[minute] = set of batteries control charges (power > 0),
|
||||
homes[sample] = Home's `/status` plan at that minute, commands = the
|
||||
@@ -116,7 +136,7 @@ def walk(modes, soc0, house_w, night=None, dynamic=False, samples=SAMPLES,
|
||||
charging, homes, cmds = {}, {}, []
|
||||
while now < end:
|
||||
lt = loc(now)
|
||||
today, tomorrow = (10.2, 10.0) if lt.hour < 12 else (10.0, 10.2)
|
||||
today, tomorrow = fc if lt.hour < 12 else fc[::-1]
|
||||
c.solar_fc = S.forecast(now, today, tomorrow, members=3)
|
||||
c.solar_fc_why = None
|
||||
if plan_at is None or (now - plan_at).total_seconds() >= 1800:
|
||||
@@ -137,9 +157,13 @@ def walk(modes, soc0, house_w, night=None, dynamic=False, samples=SAMPLES,
|
||||
if tick.roles.get(inv.name) == modetrade.CHARGE]
|
||||
p = [0.0, 0.0]
|
||||
if size < 0 and ch:
|
||||
tot = sum(rating[i] for i in ch)
|
||||
for i in ch:
|
||||
p[i] = -size * rating[i] / tot
|
||||
if by_room: # as control: room left to full, rating caps
|
||||
wt = packshare.weights(c.pack_energy, -1.0)
|
||||
p = split_by_room(-size, ch, rating, wt, c.inverters)
|
||||
else:
|
||||
tot = sum(rating[i] for i in ch)
|
||||
for i in ch:
|
||||
p[i] = -size * rating[i] / tot
|
||||
for i in range(2):
|
||||
soc[i] = min(100.0, soc[i] + p[i] / 1000.0 / 60.0 * EFF
|
||||
/ caps[i] * 100.0)
|
||||
@@ -175,7 +199,7 @@ def today_of(st, i):
|
||||
return [(t["from"], t["to"]) for t in st["batteries"][i]["today"]]
|
||||
|
||||
|
||||
def violations(charging, homes):
|
||||
def violations(charging, homes, slot=SLOT):
|
||||
"""What is wrong with Home's drawing at each sample."""
|
||||
bad = []
|
||||
for t, st in sorted(homes.items()):
|
||||
@@ -191,8 +215,10 @@ def violations(charging, homes):
|
||||
break
|
||||
if mine:
|
||||
stop = mine[-1] + timedelta(minutes=1)
|
||||
if slot == QUARTER: # the last charging quarter's end
|
||||
stop += timedelta(minutes=-stop.minute % 15)
|
||||
for a, z in bl:
|
||||
if z > stop + SLOT:
|
||||
if z > stop + slot:
|
||||
bad.append(f"{t:%H:%M} battery {i}: block to "
|
||||
f"{z:%H:%M}, stops {stop:%H:%M}")
|
||||
# the Today line says the same as the block
|
||||
@@ -215,8 +241,8 @@ def violations(charging, homes):
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Run
|
||||
# --------------------------------------------------------------------------- #
|
||||
def show(tag, ch, homes, want_bad):
|
||||
bad = violations(ch, homes)
|
||||
def show(tag, ch, homes, want_bad, slot=SLOT):
|
||||
bad = violations(ch, homes, slot)
|
||||
if want_bad:
|
||||
check(f"{tag}: red", bool(bad), "no violation found")
|
||||
else:
|
||||
@@ -240,6 +266,11 @@ def main():
|
||||
("current shape, no bind", NOW, (20.0, 20.0), 584.0, None),
|
||||
("old shape, peak-bound", OLD, (40.0, 90.0), 2850.0, 2850.0),
|
||||
("current shape, peak-bound", NOW, (40.0, 90.0), 2850.0, 2850.0))
|
||||
# the rig's checks 2 and 3 (UX-77 round 2): room left and rating disagree
|
||||
UNEQUAL = (("unequal levels, A+B Leftover solar, no bind", NOW,
|
||||
(30.0, 20.0), 584.0, None, (5.0, 5.0)),
|
||||
("unequal levels, A Leftover solar + B Cheap hours", OLD,
|
||||
(40.0, 88.0), 2850.0, 2850.0, (10.2, 10.0)))
|
||||
walked = {}
|
||||
for tag, modes, soc, hw, night in CASES:
|
||||
ch, homes, _cmds = walk(modes, soc, hw, night=night)
|
||||
@@ -251,7 +282,7 @@ def main():
|
||||
charged(ch, 0, day) or charged(ch, 1, day)))
|
||||
|
||||
print("2. a battery with nothing to charge gets no block")
|
||||
ch, homes = walked["old shape, peak-bound"]
|
||||
ch, homes, _c = walk(OLD, (40.0, 94.0), 2850.0, night=2850.0)
|
||||
st = homes[datetime(2026, 11, 2, 2, 30, tzinfo=BILLING_TZ)]
|
||||
check("B is at its level at 02:30: no block", blocks_of(st, 1) == [],
|
||||
str(blocks_of(st, 1)))
|
||||
@@ -281,7 +312,27 @@ def main():
|
||||
any(r["kind"] == "grid" for st in homes.values()
|
||||
for r in st["plan"]["runs"]))
|
||||
|
||||
print("5. the pieces")
|
||||
print("5. unequal levels: the roll shares the room as control does")
|
||||
for tag, modes, soc, hw, night, fc in UNEQUAL:
|
||||
ch, homes, _c = walk(modes, soc, hw, night=night, fc=fc)
|
||||
show(tag, ch, homes, False, QUARTER)
|
||||
day = datetime(2026, 11, 2, 0, 30, tzinfo=BILLING_TZ)
|
||||
check(f"{tag}: control charges after 00:30", bool(
|
||||
charged(ch, 0, day) or charged(ch, 1, day)))
|
||||
if hasattr(cheaproll, "_Charge") \
|
||||
and hasattr(cheaproll._Charge, "_shares"):
|
||||
real = cheaproll._Charge._shares
|
||||
cheaproll._Charge._shares = lambda self, ch, w: {
|
||||
p.name: w * abs(p.rating_w) / sum(abs(q.rating_w) for q in ch)
|
||||
for p in ch}
|
||||
try:
|
||||
for tag, modes, soc, hw, night, fc in UNEQUAL:
|
||||
ch, homes, _c = walk(modes, soc, hw, night=night, fc=fc)
|
||||
show(f"mutant by rating, {tag}", ch, homes, True, QUARTER)
|
||||
finally:
|
||||
cheaproll._Charge._shares = real
|
||||
|
||||
print("6. the pieces")
|
||||
if hasattr(cheaproll, "Roll"):
|
||||
t = datetime(2026, 11, 2, 1, 46, tzinfo=UTC)
|
||||
check("a stop is rounded up to the slot", cheaproll._ceil_slot(t)
|
||||
|
||||
Reference in New Issue
Block a user