Merge pull request #244: SAFETY-136 a gentler loop on a slow meter until the last move shows
infra-pin / fresh (push) Successful in 2s
infra-pin / fresh (push) Successful in 2s
This commit was merged in pull request #244.
This commit is contained in:
@@ -71,7 +71,8 @@ The fits run all the time; the pull request has the counts.
|
|||||||
**What it costs.**
|
**What it costs.**
|
||||||
- Found 36-68 s after a lag starts at 1-2 s meters; at a 5 s meter 30 s late
|
- 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,
|
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
|
SAFETY-129) with the export above -200 W, and 10 s late is not found (SAFETY-136 calms
|
||||||
|
the loop at a slow meter instead). Until
|
||||||
then control is as before. In a quiet house the "readings late" row stays
|
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
|
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.
|
harm measured, and it clears as early as on base when the load changes.
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# SAFETY-136 - a slow meter gets a gentler loop until the last move shows
|
||||||
|
|
||||||
|
**Status: for review.** Code: `app/settle.py`, a call in `Controller.cycle()`
|
||||||
|
(`app/ctl_cycle.py`). Tests: `test_safety136.py`. No option, no text change.
|
||||||
|
|
||||||
|
**Problem.** On a 5 s meter, Home Assistant readings 10-15 s late are not
|
||||||
|
found (SAFETY-129): they look like a slow battery (R2-1), and a hold does not
|
||||||
|
help (a perfect one cut the export of the oven by 7 %, SAFETY-129 review).
|
||||||
|
With moving loads the loop answers the same stale error at each cycle and
|
||||||
|
overshoots: an oven, a kettle and hob, the owner's day or a step export 3-10
|
||||||
|
times what they do with no lag. The fix is a calmer loop, not a detector.
|
||||||
|
|
||||||
|
**Decision.** After a move of 150 W or more, a loop on a slow meter runs at half
|
||||||
|
its gain until the meter has fallen by half of that move, or for 2 cycles.
|
||||||
|
Slow is judged from the readings that arrive: the usual gap
|
||||||
|
between them, over the last 60 s and at least 5 readings, is over 3.75 s (the
|
||||||
|
limit of SAFETY-128's fast meter fit). It counts arrivals, not changes of
|
||||||
|
value: on the recorded day at 1-2 s the value-based test (`MeterFit.slow()`)
|
||||||
|
called 24 % of the cycles slow, because the recorded house repeats a value
|
||||||
|
for 5 s or more, and the commands then differed from base. So there is no
|
||||||
|
option. At a meter with a usual gap of 3.75 s or less (1-3 s) it is false, the tuning is never touched and the
|
||||||
|
commands are those of the tree before. A meter that Home Assistant passes on
|
||||||
|
only when its value changes looks as slow as its changes are far apart, as it
|
||||||
|
does to SAFETY-128's fits: a 1-2 s meter with a value that holds for 5 s or
|
||||||
|
more is then treated as slow, and the loop is gentler for it (the cost below).
|
||||||
|
With no lag the meter shows a move at the next reading, so the wait ends at
|
||||||
|
once.
|
||||||
|
|
||||||
|
**Candidates** (SAFETY-129 sim, real `cycle()`, 5 s meter, sum over oven,
|
||||||
|
kettle and hob, owner shape over the oven and over the heat pump, step down,
|
||||||
|
step up, flat; export Wh, lag 0 / 10 s / 15 s; base 64 / 340 / 363):
|
||||||
|
|
||||||
|
| Candidate, only at a slow meter | 0 s | 10 s | 15 s | Import Wh, 0 / 10 / 15 s |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| gain 0.4 | 97 | 201 | 299 | 97 / 224 / 301 |
|
||||||
|
| gain 0.3 | 128 | 185 | 322 | 132 / 232 / 293 |
|
||||||
|
| step limit 500 W | 118 | 285 | 293 | 110 / 325 / 324 |
|
||||||
|
| cut fast, raise slowly (0.6 / 0.3) | 55 | 140 | 226 | 140 / 332 / 345 |
|
||||||
|
| ignore 1 cycle after a move | 93 | 216 | 325 | 90 / 240 / 291 |
|
||||||
|
| ignore 2 cycles after a move, unless the meter showed half | 69 | 206 | 277 | 62 / 240 / 354 |
|
||||||
|
| **half gain for 2 cycles after a move, unless the meter showed half** | **67** | **189** | **296** | **62 / 223 / 288** |
|
||||||
|
|
||||||
|
A lower gain for good, a step limit and a fixed ignore all make a 5 s meter
|
||||||
|
with no lag worse (export +30-110 Wh, quarter peak up to 150 W): that is the
|
||||||
|
common case. Cutting fast and raising slowly moves export to import and peak.
|
||||||
|
Waiting only while the last move has not shown costs nothing with no lag.
|
||||||
|
Ignoring the meter (the 2-cycle line) was worse than half gain where the plant
|
||||||
|
is slow, not the readings late: a 12 s dead time with one inverter went from
|
||||||
|
250 to 841 Wh import and a quarter peak of 206 to 1 509 W, so the loop keeps
|
||||||
|
following at half gain.
|
||||||
|
|
||||||
|
**What it gives** (5 s meter; the pull request and the report have each
|
||||||
|
scenario). Export summed over the seven houses: 10 s late 340 -> 189 Wh
|
||||||
|
(-44 %, import 363 -> 223), 15 s late 363 -> 296 Wh (-19 %, import 281 ->
|
||||||
|
288), together -31 %. At 15 s the oven and the owner's oven are not better
|
||||||
|
(92 / 39 Wh against 91 / 37), the kettle and the steps are. No lag: export
|
||||||
|
64.4 -> 66.5 Wh (kettle +2), import and peak the same.
|
||||||
|
|
||||||
|
**What it costs.** The loop follows a real step more slowly: half gain for up
|
||||||
|
to two cycles after each move of 150 W or more. Of the 136 sim runs (every
|
||||||
|
SAFETY-129 review scenario and its 15 harm runs), 56 at 4-10 s meters differ
|
||||||
|
from base. Together their export is 10 363 -> 9 559 Wh (-8 %) and import
|
||||||
|
8 389 -> 7 835 Wh (-7 %). Only 5 export more than 1 Wh and 14 import more:
|
||||||
|
export by at most 11.5 Wh (a 10 s battery lag, +10 %), import by at most
|
||||||
|
17 Wh (a 12 s dead time, one inverter, +7 %), and the quarter average by at
|
||||||
|
most 35 W, except two import quarters: +73 W (10 s meter, 10 s dead time,
|
||||||
|
export -59 Wh) and +40 W (`dead10_p6`, -6 to 34 W, export -68 Wh). Slow
|
||||||
|
batteries with a dead time of 8-10 s export 5-39 % less; a 12 s dead time is
|
||||||
|
the same. `READINGS_LATE` holds are the same seconds in every run.
|
||||||
|
|
||||||
|
**Live, a 1-2 s meter can be judged slow.** Through Home Assistant on change,
|
||||||
|
a value that holds for 4-10 s makes a 1 s meter slow. In 20 rig scenarios the
|
||||||
|
commands were those of base. On the recorded day (1 s, N=1, 1 day, repeats
|
||||||
|
dropped; its values hold about 5 s) it was slow in 33 % of cycles, half gain
|
||||||
|
in 83 cycles, 15 422 commands in both trees, 4 016 with another value (at
|
||||||
|
most 190 W apart), cost 0.61 EUR in both (review of SAFETY-136).
|
||||||
|
|
||||||
|
**Does not change.** Meters of 1-2 s: the same commands in all 63 runs (34
|
||||||
|
at 1 s, 29 at 2 s), and the recorded-day digests (see the report). The late
|
||||||
|
verdict, the hold and the freeze tests. 15 s late is only partly helped, and
|
||||||
|
30 s late is the hold's (SAFETY-129).
|
||||||
@@ -734,15 +734,21 @@ class CycleMixin:
|
|||||||
# TEL-10. ⚠️ ONE NAME FOR THE COMMAND THE LOOP WAS MEASURED
|
# TEL-10. ⚠️ ONE NAME FOR THE COMMAND THE LOOP WAS MEASURED
|
||||||
# AGAINST. (DECISIONS/MAIN-orchestration-archive.md)
|
# AGAINST. (DECISIONS/MAIN-orchestration-archive.md)
|
||||||
loop_prev = self.target
|
loop_prev = self.target
|
||||||
decision = compute(
|
|
||||||
prev_w=loop_prev,
|
|
||||||
# SAFETY-95: a drain's tail is not the loop's to chase.
|
# SAFETY-95: a drain's tail is not the loop's to chase.
|
||||||
# OPT-15: `loop_grid` is the grid, or the target while a run
|
# OPT-15: `loop_grid` is the grid, or the target while a run
|
||||||
# edge is held.
|
# edge is held.
|
||||||
grid_w=(self._drain_grid_w(_live96) if self._drain_tail
|
grid_in = (self._drain_grid_w(_live96) if self._drain_tail
|
||||||
else loop_grid),
|
else loop_grid)
|
||||||
|
# SAFETY-136: on a slow meter, a gentler loop until the last move
|
||||||
|
# shows.
|
||||||
|
slow_meter = self.settle.slow()
|
||||||
|
if moved or mode_trade:
|
||||||
|
self.settle.reset()
|
||||||
|
decision = compute(
|
||||||
|
prev_w=loop_prev,
|
||||||
|
grid_w=grid_in,
|
||||||
actual_w=site_batt,
|
actual_w=site_batt,
|
||||||
tuning=self.tuning,
|
tuning=self.settle.tuning(self.tuning, grid_in, slow_meter),
|
||||||
sat_count=self.sat_count,
|
sat_count=self.sat_count,
|
||||||
i_w=self.i_w,
|
i_w=self.i_w,
|
||||||
# SAFETY-52 at one inverter only: with several, OPT-09's pack
|
# SAFETY-52 at one inverter only: with several, OPT-09's pack
|
||||||
@@ -772,6 +778,7 @@ class CycleMixin:
|
|||||||
self.log_event(f"saturation freeze ({self.target:.0f} W vs "
|
self.log_event(f"saturation freeze ({self.target:.0f} W vs "
|
||||||
f"{site_batt:.0f} W)",
|
f"{site_batt:.0f} W)",
|
||||||
hint=messages.hint("not_keeping_up"))
|
hint=messages.hint("not_keeping_up"))
|
||||||
|
self.settle.moved(grid_in, loop_prev, decision.target_w, slow_meter)
|
||||||
self.sat_count = decision.sat_count
|
self.sat_count = decision.sat_count
|
||||||
self.i_w = decision.i_w
|
self.i_w = decision.i_w
|
||||||
self.loop_w = decision.target_w
|
self.loop_w = decision.target_w
|
||||||
@@ -811,6 +818,7 @@ class CycleMixin:
|
|||||||
else:
|
else:
|
||||||
# Stopped or blind: no accumulation may survive the outage.
|
# Stopped or blind: no accumulation may survive the outage.
|
||||||
self.i_w = 0.0
|
self.i_w = 0.0
|
||||||
|
self.settle.reset()
|
||||||
self.loop_w = None
|
self.loop_w = None
|
||||||
self._live_was = frozenset(_live96)
|
self._live_was = frozenset(_live96)
|
||||||
# TEL-10, and it is the same zero the line above writes rather than
|
# TEL-10, and it is the same zero the line above writes rather than
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ class InputsMixin:
|
|||||||
# correlates 0.998 with battery power and 0.09 with the real meter
|
# correlates 0.998 with battery power and 0.09 with the real meter
|
||||||
# (a controller would regulate against its own output).
|
# (a controller would regulate against its own output).
|
||||||
self.grid = self.p1.net_w
|
self.grid = self.p1.net_w
|
||||||
|
self.settle.arrive(now_mono, self.p1.samples) # SAFETY-136
|
||||||
else:
|
else:
|
||||||
self.grid = await self.hass.number(o.get("meter_entity", ""),
|
self.grid = await self.hass.number(o.get("meter_entity", ""),
|
||||||
bool(o.get("meter_invert")))
|
bool(o.get("meter_invert")))
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ from .clamp import (
|
|||||||
)
|
)
|
||||||
from . import freshness
|
from . import freshness
|
||||||
from . import lateness
|
from . import lateness
|
||||||
|
from .settle import Settle
|
||||||
from . import dayview, homeview
|
from . import dayview, homeview
|
||||||
from .daycurve import SolarToday
|
from .daycurve import SolarToday
|
||||||
from . import handback
|
from . import handback
|
||||||
@@ -741,6 +742,7 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
|
|||||||
self._arb_action = None
|
self._arb_action = None
|
||||||
self.inputs_bad_since = None
|
self.inputs_bad_since = None
|
||||||
self.answer = lateness.Answer() # SAFETY-128
|
self.answer = lateness.Answer() # SAFETY-128
|
||||||
|
self.settle = Settle() # SAFETY-136
|
||||||
self._late_logged = self._late_zero = False
|
self._late_logged = self._late_zero = False
|
||||||
# SAFETY-03: what the loop's OWN bounds did last cycle, `(engaged,
|
# SAFETY-03: what the loop's OWN bounds did last cycle, `(engaged,
|
||||||
# note, over_rating)` (SAFETY-40 splits the last out). Site-level: the
|
# note, over_rating)` (SAFETY-40 splits the last out). Site-level: the
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""SAFETY-136: on a slow meter, follow the meter gently until the last move shows.
|
||||||
|
|
||||||
|
A meter whose usual gap between readings is over 3.75 s (a 4-5 s meter), read
|
||||||
|
through Home Assistant, can be 10-15 s late. The loop then answers the same error again at each cycle
|
||||||
|
and overshoots. After a move of MOVE_W or more the loop therefore runs at
|
||||||
|
WAIT_SHARE of its gain until the meter has fallen SHOWN of the move, or for at
|
||||||
|
most WAIT_CYCLES cycles. Off for a meter with a usual gap of 3.75 s or less, so those sites run as
|
||||||
|
before. DECISIONS/SAFETY-136-slow-meter-settle.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
from .lateness import M_FIT_S, M_ROWS
|
||||||
|
|
||||||
|
# A meter is slow when its readings come further apart than this (the usual
|
||||||
|
# gap, over the last 60 s and at least 5 readings): the fast meter fit's own
|
||||||
|
# limit (SAFETY-128), 3.75 s.
|
||||||
|
SLOW_GAP_S = M_FIT_S / M_ROWS
|
||||||
|
SLOW_ROWS = 5
|
||||||
|
# A move this big is waited for (above the house noise of about 80 W).
|
||||||
|
MOVE_W = 150.0
|
||||||
|
# The meter has shown a move once it fell by this share of it.
|
||||||
|
SHOWN = 0.5
|
||||||
|
# Meanwhile the loop runs at this share of its gain.
|
||||||
|
WAIT_SHARE = 0.5
|
||||||
|
# Wait at most this many cycles (10 s at a 5 s meter).
|
||||||
|
WAIT_CYCLES = 2
|
||||||
|
|
||||||
|
|
||||||
|
class Settle:
|
||||||
|
def __init__(self):
|
||||||
|
self.pend = None # (meter at the move, the move in W, cycles waited)
|
||||||
|
self._n = None # readings taken in so far
|
||||||
|
self._at = deque() # when the last ones arrived, over M_FIT_S
|
||||||
|
|
||||||
|
def arrive(self, now, n):
|
||||||
|
"""Each read: `n` is the count of meter readings taken in. A reading
|
||||||
|
counts when it arrives, not when its value changes, so a steady load on
|
||||||
|
a 1 s meter is not a slow meter."""
|
||||||
|
if n != self._n:
|
||||||
|
self._n = n
|
||||||
|
self._at.append(now)
|
||||||
|
while self._at and self._at[0] < now - M_FIT_S:
|
||||||
|
self._at.popleft()
|
||||||
|
|
||||||
|
def slow(self):
|
||||||
|
"""The usual gap between readings is over SLOW_GAP_S."""
|
||||||
|
ts = list(self._at)
|
||||||
|
if len(ts) < SLOW_ROWS:
|
||||||
|
return False
|
||||||
|
gaps = sorted(b - a for a, b in zip(ts, ts[1:]))
|
||||||
|
return gaps[len(gaps) // 2] > SLOW_GAP_S
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self.pend = None
|
||||||
|
|
||||||
|
def tuning(self, tuning, grid_w, slow):
|
||||||
|
"""The tuning for this cycle: `tuning`, or with the gain cut while the
|
||||||
|
last move has not shown on the meter (`grid_w`)."""
|
||||||
|
p = self.pend
|
||||||
|
if p is None:
|
||||||
|
return tuning
|
||||||
|
g0, move, waited = p
|
||||||
|
if not slow or (g0 - grid_w) / move >= SHOWN or waited >= WAIT_CYCLES:
|
||||||
|
self.pend = None
|
||||||
|
return tuning
|
||||||
|
self.pend = (g0, move, waited + 1)
|
||||||
|
return replace(tuning, gain=tuning.gain * WAIT_SHARE)
|
||||||
|
|
||||||
|
def moved(self, grid_w, prev_w, new_w, slow):
|
||||||
|
"""After a cycle: start waiting if the loop moved by MOVE_W or more."""
|
||||||
|
if slow and self.pend is None and abs(new_w - prev_w) >= MOVE_W:
|
||||||
|
self.pend = (float(grid_w), float(new_w - prev_w), 0)
|
||||||
@@ -38,6 +38,7 @@ logging.disable(logging.WARNING)
|
|||||||
import safety128_rig as R # noqa: E402
|
import safety128_rig as R # noqa: E402
|
||||||
import app.main as app_main # noqa: E402
|
import app.main as app_main # noqa: E402
|
||||||
from app import lateness # noqa: E402
|
from app import lateness # noqa: E402
|
||||||
|
from app import settle # noqa: E402
|
||||||
|
|
||||||
fails = []
|
fails = []
|
||||||
|
|
||||||
@@ -58,6 +59,17 @@ def no_gate(fn):
|
|||||||
app_main.Controller._readings_late = real
|
app_main.Controller._readings_late = real
|
||||||
|
|
||||||
|
|
||||||
|
def no_settle(fn):
|
||||||
|
"""`fn()` without SAFETY-136's gentler loop: the tree this card was written
|
||||||
|
against (with it the same run exports -645 W, not -1 325 W)."""
|
||||||
|
real = settle.Settle.moved
|
||||||
|
settle.Settle.moved = lambda self, *a, **k: None
|
||||||
|
try:
|
||||||
|
return fn()
|
||||||
|
finally:
|
||||||
|
settle.Settle.moved = real
|
||||||
|
|
||||||
|
|
||||||
def run_late(**kw):
|
def run_late(**kw):
|
||||||
"""Run the rig; (rig, seconds the verdict stood, first second it stood)."""
|
"""Run the rig; (rig, seconds the verdict stood, first second it stood)."""
|
||||||
dur = kw.pop("dur")
|
dur = kw.pop("dur")
|
||||||
@@ -91,7 +103,7 @@ for units, house in ((AB, 1800.0), (A, 1500.0)):
|
|||||||
if len(units) == 2:
|
if len(units) == 2:
|
||||||
old = R.Rig(house=house, lag_s=30.0, lag_on=ON, lag_off=ON + 300.0,
|
old = R.Rig(house=house, lag_s=30.0, lag_on=ON, lag_off=ON + 300.0,
|
||||||
units=units, p1_s=5.0)
|
units=units, p1_s=5.0)
|
||||||
no_gate(lambda: old.run(ON + 300.0))
|
no_settle(lambda: no_gate(lambda: old.run(ON + 300.0)))
|
||||||
check(f"{n}: without the gate the export goes below -1 kW",
|
check(f"{n}: without the gate the export goes below -1 kW",
|
||||||
grid_min(old.rows, ON, ON + 300.0) < -1000.0)
|
grid_min(old.rows, ON, ON + 300.0) < -1000.0)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
"""Runnable check for SAFETY-136: a calmer loop on a slow meter.
|
||||||
|
`python3 test_safety136.py`
|
||||||
|
|
||||||
|
With a 5 s meter and Home Assistant readings 10-15 s late the loop answers the
|
||||||
|
same error again at each cycle and overshoots: with an oven, a kettle and hob,
|
||||||
|
or a step the export was 3-10 times that of a meter with no lag. After a move
|
||||||
|
of 150 W or more `app/settle.py` now runs the loop at half its gain (at most
|
||||||
|
2 cycles) until the meter shows half of it. It is off for a meter under 4 s.
|
||||||
|
DECISIONS/SAFETY-136-slow-meter-settle.md.
|
||||||
|
|
||||||
|
The REAL Controller on TEST-23's plant (tools/safety128_rig.py). "Off" is the
|
||||||
|
same run with `Settle.moved` doing nothing: the tree before this card.
|
||||||
|
|
||||||
|
1. `Settle` alone.
|
||||||
|
2. 5 s meter, readings 10 s late: oven, kettle and hob, a step down and a step
|
||||||
|
up export clearly less than with it off.
|
||||||
|
3. 5 s meter, no lag: the same houses are no worse than a small margin.
|
||||||
|
4. 1 s and 2 s meters: the commands are those of the run with it off, and the
|
||||||
|
loop never waits.
|
||||||
|
5. The slow meter is told by its observed interval, at 4 s and 5 s.
|
||||||
|
|
||||||
|
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
|
||||||
|
from app.control import Tuning # noqa: E402
|
||||||
|
from named_absence import nth, present # noqa: E402
|
||||||
|
from app import settle # noqa: E402
|
||||||
|
from app.lateness import MeterFit # noqa: E402
|
||||||
|
|
||||||
|
fails = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, cond):
|
||||||
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||||
|
if not cond:
|
||||||
|
fails.append(name)
|
||||||
|
|
||||||
|
|
||||||
|
def off(fn):
|
||||||
|
"""`fn()` with the settle wait off: the tree before this card."""
|
||||||
|
real = settle.Settle.moved
|
||||||
|
settle.Settle.moved = lambda self, *a, **k: None
|
||||||
|
try:
|
||||||
|
return fn()
|
||||||
|
finally:
|
||||||
|
settle.Settle.moved = real
|
||||||
|
|
||||||
|
|
||||||
|
def oven(t):
|
||||||
|
return 1800.0 + (2000.0 if (t >= 300.0 and (t - 300.0) % 105 < 45) else 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def kettle(t):
|
||||||
|
w = 600.0
|
||||||
|
if t % 400 < 120:
|
||||||
|
w += 2200
|
||||||
|
if t >= 900 and t % 90 < 30:
|
||||||
|
w += 1500
|
||||||
|
return w
|
||||||
|
|
||||||
|
|
||||||
|
def step(t):
|
||||||
|
return 1800.0 if t < 320.0 else 300.0
|
||||||
|
|
||||||
|
|
||||||
|
def stepup(t):
|
||||||
|
return 300.0 if t < 320.0 else 2500.0
|
||||||
|
|
||||||
|
|
||||||
|
# name: (house, noise, run length, lag end)
|
||||||
|
HOUSES = {"oven": (oven, 80.0, 900.0, 900.0),
|
||||||
|
"kettle": (kettle, 80.0, 1500.0, 1500.0),
|
||||||
|
"step": (step, 50.0, 700.0, 600.0),
|
||||||
|
"stepup": (stepup, 50.0, 700.0, 600.0)}
|
||||||
|
ON = 300.0
|
||||||
|
|
||||||
|
|
||||||
|
def wh(rows, t0, t1, sign):
|
||||||
|
"""Export (sign -1) or import (sign +1) energy in Wh over [t0, t1)."""
|
||||||
|
return sum((-min(r[1], 0.0) if sign < 0 else max(r[1], 0.0))
|
||||||
|
for r in rows if t0 <= r[0] < t1) / 3600.0
|
||||||
|
|
||||||
|
|
||||||
|
def run(name, lag, p1_s=5.0, units=("a", "b"), dur=None):
|
||||||
|
house, noise, length, lag_off = HOUSES[name]
|
||||||
|
r = R.Rig(house=house, noise=noise, p1_s=p1_s, units=units, lag_s=lag,
|
||||||
|
lag_on=ON, lag_off=lag_off)
|
||||||
|
waits = []
|
||||||
|
r.run(dur or length + 20.0,
|
||||||
|
lambda rig, c, t: waits.append(1) if c.settle.pend else None)
|
||||||
|
return r, len(waits)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("1. Settle alone")
|
||||||
|
T = Tuning()
|
||||||
|
s = settle.Settle()
|
||||||
|
check("no move: the tuning is untouched", s.tuning(T, 500.0, True) is T)
|
||||||
|
s.moved(500.0, 0.0, 100.0, True)
|
||||||
|
check("a move under 150 W is not waited for", s.pend is None)
|
||||||
|
s.moved(500.0, 0.0, 600.0, False)
|
||||||
|
check("a meter that is not slow is not waited for", s.pend is None)
|
||||||
|
s.moved(500.0, 0.0, 600.0, True)
|
||||||
|
check("a move of 600 W is waited for: half the gain",
|
||||||
|
s.tuning(T, 500.0, True).gain == T.gain * 0.5)
|
||||||
|
check("the meter fell 200 W of 600 W: still half the gain",
|
||||||
|
s.tuning(T, 300.0, True).gain == T.gain * 0.5)
|
||||||
|
check("two cycles waited: the third has the whole gain",
|
||||||
|
s.tuning(T, 300.0, True) is T and s.pend is None)
|
||||||
|
s.moved(500.0, 0.0, 600.0, True)
|
||||||
|
check("the meter went the other way by half the move: the wait stands",
|
||||||
|
s.tuning(T, 800.0, True).gain == T.gain * 0.5)
|
||||||
|
s.reset()
|
||||||
|
s.moved(500.0, 0.0, -600.0, True)
|
||||||
|
check("a move down, the meter rose half the move: the whole gain",
|
||||||
|
s.tuning(T, 800.0, True) is T)
|
||||||
|
s.reset()
|
||||||
|
s.moved(500.0, 0.0, 600.0, True)
|
||||||
|
check("the meter fell half the move: the whole gain at once",
|
||||||
|
s.tuning(T, 200.0, True) is T and s.pend is None)
|
||||||
|
s.moved(500.0, 0.0, -600.0, True)
|
||||||
|
check("a move down is waited for too",
|
||||||
|
s.tuning(T, 500.0, True).gain == T.gain * 0.5)
|
||||||
|
s.reset()
|
||||||
|
check("reset ends the wait", s.pend is None)
|
||||||
|
s.moved(500.0, 0.0, 600.0, True)
|
||||||
|
check("a meter that turns out not to be slow ends the wait",
|
||||||
|
s.tuning(T, 500.0, False) is T and s.pend is None)
|
||||||
|
check("the numbers are pinned",
|
||||||
|
(settle.MOVE_W, settle.SHOWN, settle.WAIT_SHARE, settle.WAIT_CYCLES)
|
||||||
|
== (150.0, 0.5, 0.5, 2))
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("\n2. 5 s meter, readings 10 s late")
|
||||||
|
tot_on = tot_off = 0.0
|
||||||
|
for name, (_h, _n, length, lag_off) in HOUSES.items():
|
||||||
|
a, waited = run(name, 10.0)
|
||||||
|
b, _w = off(lambda: run(name, 10.0))
|
||||||
|
ea, eb = wh(a.rows, ON, lag_off, -1), wh(b.rows, ON, lag_off, -1)
|
||||||
|
tot_on, tot_off = tot_on + ea, tot_off + eb
|
||||||
|
print(f" {name}: export {ea:.1f} Wh, off {eb:.1f} Wh, waited {waited} s")
|
||||||
|
check(f"{name}: waits at some point", waited > 0)
|
||||||
|
check(f"{name}: exports at most 75 % of the run with it off",
|
||||||
|
ea <= 0.75 * eb)
|
||||||
|
print(f" sum: {tot_on:.1f} Wh, off {tot_off:.1f} Wh")
|
||||||
|
check("the four together export at most 65 % of the run with it off",
|
||||||
|
tot_on <= 0.65 * tot_off)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("\n2b. Another writer overrides the loop: the wait ends")
|
||||||
|
halved = []
|
||||||
|
real_tuning = settle.Settle.tuning
|
||||||
|
|
||||||
|
|
||||||
|
def watch_tuning(self, tuning, grid_w, slow):
|
||||||
|
r = real_tuning(self, tuning, grid_w, slow)
|
||||||
|
halved.append(r is not tuning)
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
settle.Settle.tuning = watch_tuning
|
||||||
|
try:
|
||||||
|
mark = []
|
||||||
|
|
||||||
|
def bump(rig, c, t):
|
||||||
|
# right after a cycle that started a wait, another writer moves the
|
||||||
|
# command, so the next cycle sees `target != loop_w`
|
||||||
|
if not mark and c.settle.pend and t > 330.0:
|
||||||
|
mark.append(len(halved))
|
||||||
|
c.target = c.target + 300.0
|
||||||
|
|
||||||
|
r = R.Rig(house=oven, noise=80.0, p1_s=5.0, lag_s=10.0, lag_on=ON,
|
||||||
|
lag_off=900.0)
|
||||||
|
r.run(500.0, bump)
|
||||||
|
finally:
|
||||||
|
settle.Settle.tuning = real_tuning
|
||||||
|
nxt = nth(halved, mark[0] if mark else 10 ** 6)
|
||||||
|
check("the cycle after an override has the whole gain",
|
||||||
|
bool(mark) and present(nxt) and nxt is False)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("\n3. 5 s meter, no lag")
|
||||||
|
for name in ("oven", "kettle", "step", "stepup"):
|
||||||
|
_h, _n, length, lag_off = HOUSES[name]
|
||||||
|
a, _w = run(name, 0.0)
|
||||||
|
b, _w = off(lambda: run(name, 0.0))
|
||||||
|
ea, eb = wh(a.rows, ON, lag_off, -1), wh(b.rows, ON, lag_off, -1)
|
||||||
|
ia, ib = wh(a.rows, ON, lag_off, 1), wh(b.rows, ON, lag_off, 1)
|
||||||
|
print(f" {name}: export {ea:.1f} Wh (off {eb:.1f}), import {ia:.1f} Wh "
|
||||||
|
f"(off {ib:.1f})")
|
||||||
|
check(f"{name}: export at most 6 Wh over the run with it off",
|
||||||
|
ea <= eb + 6.0)
|
||||||
|
check(f"{name}: import at most 1 Wh over the run with it off",
|
||||||
|
ia <= ib + 1.0)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("\n4. 1 s and 2 s meters")
|
||||||
|
for p1 in (1.0, 2.0):
|
||||||
|
for lag in (0.0, 10.0):
|
||||||
|
for units in (("a", "b"), ("a",)):
|
||||||
|
a, waited = run("oven", lag, p1_s=p1, units=units, dur=700.0)
|
||||||
|
b, _w = off(lambda: run("oven", lag, p1_s=p1, units=units,
|
||||||
|
dur=700.0))
|
||||||
|
same = ([(r[0], r[1], r[2]) for r in a.rows]
|
||||||
|
== [(r[0], r[1], r[2]) for r in b.rows])
|
||||||
|
check(f"{p1:.0f} s meter, {lag:.0f} s late, N={len(units)}: "
|
||||||
|
f"same commands, never waits", same and waited == 0)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
print("\n5. The slow meter is told by its observed interval")
|
||||||
|
s = settle.Settle()
|
||||||
|
for k in range(30):
|
||||||
|
s.arrive(float(k), k)
|
||||||
|
check("a reading each second is not a slow meter", not s.slow())
|
||||||
|
s = settle.Settle()
|
||||||
|
for k in range(30):
|
||||||
|
s.arrive(5.0 * k, k)
|
||||||
|
check("a reading every 5 s is a slow meter", s.slow())
|
||||||
|
s = settle.Settle()
|
||||||
|
for k in range(4):
|
||||||
|
s.arrive(5.0 * k, k)
|
||||||
|
check("fewer than 5 readings in a minute tell nothing", not s.slow())
|
||||||
|
s = settle.Settle()
|
||||||
|
for k in range(30):
|
||||||
|
s.arrive(5.0 * k, 0 if k else 1)
|
||||||
|
check("the same count again is no new reading", not s.slow())
|
||||||
|
for p1, expect in ((1.0, False), (2.0, False), (3.0, False), (4.0, True),
|
||||||
|
(5.0, True), (10.0, True)):
|
||||||
|
r = R.Rig(house=1800.0, p1_s=p1)
|
||||||
|
seen = []
|
||||||
|
r.run(120.0, lambda rig, c, t: seen.append(c.settle.slow()))
|
||||||
|
last = nth(seen, -1)
|
||||||
|
check(f"{p1:.0f} s meter: slow is {expect}",
|
||||||
|
present(last) and last is expect)
|
||||||
|
# A 1 s meter on a house that changes every 5 s repeats its value 4 times in 5.
|
||||||
|
# Where every reading arrives (the rig, a polled meter) it is not slow; where
|
||||||
|
# Home Assistant sends a reading only when the value changes (ha_signed) the
|
||||||
|
# same meter is slow (T3 of the review; DECISIONS).
|
||||||
|
s, m, h = settle.Settle(), MeterFit(), settle.Settle()
|
||||||
|
for k in range(60):
|
||||||
|
s.arrive(float(k), k)
|
||||||
|
h.arrive(float(k), k // 5)
|
||||||
|
m.add(float(k), 0.0, 1800.0 + 100.0 * (k // 5))
|
||||||
|
check("every reading arrives, value changes every 5 s: not slow (the value "
|
||||||
|
"fit says slow)", not s.slow() and m.slow())
|
||||||
|
check("the same meter through Home Assistant on change (a reading per "
|
||||||
|
"change): slow", h.slow())
|
||||||
|
|
||||||
|
print()
|
||||||
|
if fails:
|
||||||
|
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||||
|
sys.exit(1)
|
||||||
|
print("all checks passed")
|
||||||
Reference in New Issue
Block a user