A missing reading (unavailable, unknown, failed read, non-numeric) no longer clears the fault: no false 'used again' INFO, no repeated WARNING, the status row stays. Adds pv.has_reading(). DECISIONS wording fixed; tests added. Co-Authored-By: Claude <noreply@anthropic.com>
376 lines
13 KiB
Python
376 lines
13 KiB
Python
"""SAFETY-118: the peak forecast is read in its own unit. `python3 -B test_safety118.py`
|
|
|
|
`peak_forecast_entity` used to be read as a bare number and anything under 50
|
|
was multiplied by 1000, so a W sensor reading 17 W was taken as 17 kW. It is
|
|
now read through `pv.watts()` by its own `unit_of_measurement`. No unit, or a
|
|
unit that is not power: the forecast is not used, as if the option were empty.
|
|
|
|
1. The reader through the real `read_inputs()`: W, kW, MW, no unit, kWh, bad state.
|
|
2. The warning: once, again only when the cause changes, never every cycle.
|
|
3. One state read per cycle, as before.
|
|
4. The maintenance charge headroom and the charge-only clamp act on the
|
|
converted value (real `cycle()`).
|
|
5. The validator, the status row and the strings.
|
|
|
|
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from app import main as _main
|
|
from app import messages, pv, validate
|
|
from app.main import Controller
|
|
|
|
fails = []
|
|
|
|
|
|
def check(name, cond):
|
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
|
if not cond:
|
|
fails.append(name)
|
|
|
|
|
|
def st(value, unit=None):
|
|
attrs = {} if unit is None else {"unit_of_measurement": unit}
|
|
return {"state": str(value), "attributes": attrs}
|
|
|
|
|
|
class _Store:
|
|
def __init__(self, data=None):
|
|
self.data = dict(data or {})
|
|
|
|
def set(self, k, v):
|
|
self.data[k] = v
|
|
|
|
def get_time(self, *a):
|
|
return None
|
|
|
|
def set_time(self, *a):
|
|
pass
|
|
|
|
|
|
class _Pub:
|
|
enabled = False
|
|
|
|
def publish(self, *a, **k):
|
|
pass
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
class _Hass:
|
|
def __init__(self, fc=None, prices=None):
|
|
self.fc = fc
|
|
self.prices = prices or {}
|
|
self.reads = []
|
|
self.writes = []
|
|
|
|
async def limits(self, entity):
|
|
return (-5000.0, 5000.0, 1.0)
|
|
|
|
async def state(self, entity):
|
|
self.reads.append(entity)
|
|
if entity == "sensor.fc":
|
|
return self.fc
|
|
v = {"sensor.grid": 0.0, "sensor.soc": 50.0, "sensor.batt": 0.0,
|
|
**self.prices}.get(entity)
|
|
return None if v is None else st(v, "W")
|
|
|
|
async def number(self, entity, invert=False):
|
|
obj = await self.state(entity)
|
|
try:
|
|
return float(obj["state"]) if obj else None
|
|
except ValueError:
|
|
return None
|
|
|
|
async def set_number(self, entity, value):
|
|
self.writes.append(float(value))
|
|
return True
|
|
|
|
async def call(self, domain, service, data):
|
|
return True
|
|
|
|
|
|
OPTS = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
|
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
|
"meter_source": "off", "heartbeat_s": 10, "stale_input_s": 15,
|
|
"max_w": 5000.0, "gain": 0.6, "slew_w": 400.0, "deadband_w": 15,
|
|
"step_w": 10, "target_grid_w": -10.0, "stale_input_s": 15,
|
|
"peak_cap_w": 5000, "peak_margin_w": 0,
|
|
"peak_forecast_entity": "sensor.fc"}
|
|
|
|
|
|
class _Rec(logging.Handler):
|
|
def __init__(self):
|
|
super().__init__(logging.DEBUG)
|
|
self.rows = []
|
|
|
|
def emit(self, r):
|
|
self.rows.append((r.levelno, r.getMessage()))
|
|
|
|
|
|
def make(fc, store=None, prices=None, **over):
|
|
hass = _Hass(fc, prices)
|
|
c = Controller(dict(OPTS, **over), hass, _Store(store), _Pub(),
|
|
stop=asyncio.Event())
|
|
c.auto, c.dev_min, c.dev_max = True, -5000.0, 5000.0
|
|
return c, hass
|
|
|
|
|
|
def reads(c):
|
|
asyncio.run(c.read_inputs())
|
|
return c.peak_fc
|
|
|
|
|
|
def peak_of(state):
|
|
c, _h = make(state)
|
|
return reads(c)
|
|
|
|
|
|
print("1. the reader")
|
|
CASES = [("W 17", st(17, "W"), 17.0), ("W 820", st(820, "W"), 820.0),
|
|
("kW 0.82", st(0.82, "kW"), 820.0), ("kW 0.017", st(0.017, "kW"), 17.0),
|
|
("MW 0.0008", st(0.0008, "MW"), 800.0), ("W 49", st(49, "W"), 49.0),
|
|
("W 50", st(50, "W"), 50.0), ("kW 30", st(30, "kW"), 30000.0),
|
|
("W 0", st(0, "W"), 0.0)]
|
|
for label, s, want in CASES:
|
|
got = peak_of(s)
|
|
check(f"{label} -> {want} W (got {got})",
|
|
got is not None and abs(got - want) < 1e-6)
|
|
for label, s in [("no unit", st(820)), ("empty unit", st(820, "")),
|
|
("kWh", st(820, "kWh")), ("percent", st(20, "%")),
|
|
("unavailable", st("unavailable", "W")),
|
|
("unknown", st("unknown", "W")), ("missing entity", None),
|
|
("not a number", st("sunny", "W"))]:
|
|
check(f"{label} -> None (got {peak_of(s)})", peak_of(s) is None)
|
|
c, _h = make(st(820, "W"), peak_forecast_entity="")
|
|
check("option empty -> None and the entity is never read",
|
|
reads(c) is None and "sensor.fc" not in _h.reads)
|
|
|
|
print("2. the warning")
|
|
rec = _Rec()
|
|
lg = logging.getLogger("goodwe")
|
|
lg.addHandler(rec)
|
|
lg.setLevel(logging.DEBUG)
|
|
|
|
|
|
def warns():
|
|
return [m for lv, m in rec.rows if lv >= logging.WARNING]
|
|
|
|
|
|
c, h = make(st(820, "kWh"))
|
|
for _ in range(5):
|
|
reads(c)
|
|
check(f"kWh: five cycles, one WARNING ({warns()})", len(warns()) == 1)
|
|
check("...short, plain, and says it is not used",
|
|
warns()[0] == "The 15-minute demand forecast is not in a power unit, so "
|
|
"it is not used." and len(warns()[0].split()) <= 25)
|
|
h.fc = st(820, "MWh")
|
|
reads(c)
|
|
check("the same cause with another unit warns again (the cause changed)",
|
|
len(warns()) == 2)
|
|
h.fc = st(820)
|
|
reads(c)
|
|
check("no unit is a different cause and warns", len(warns()) == 3
|
|
and "has no unit" in warns()[2])
|
|
reads(c)
|
|
check("...and only once", len(warns()) == 3)
|
|
USED = (logging.INFO, "The 15-minute demand forecast is used again.")
|
|
|
|
|
|
def used():
|
|
return [r for r in rec.rows if r == USED]
|
|
|
|
|
|
h.fc = st("unavailable")
|
|
reads(c)
|
|
check("unavailable adds no warning and no 'used again'",
|
|
len(warns()) == 3 and not used())
|
|
h.fc = st(820, "W")
|
|
reads(c)
|
|
check("a good unit is read again: one INFO, no new warning",
|
|
c.peak_fc == 820.0 and len(warns()) == 3 and len(used()) == 1
|
|
and rec.rows[-1] == USED)
|
|
n = len(warns())
|
|
for s in (st(820, "W"), st(0.8, "kW")):
|
|
h.fc = s
|
|
reads(c)
|
|
check("W and kW never warn", len(warns()) == n)
|
|
rows = [r for r in c.checks() if r.get("id") == "peak_forecast"]
|
|
check("a good forecast has no peak_forecast row", not rows)
|
|
h.fc = st(820, "kWh")
|
|
reads(c)
|
|
rows = [r for r in c.checks() if r.get("id") == "peak_forecast"]
|
|
check("the status page carries a caution row for the unit fault",
|
|
len(rows) == 1 and rows[0]["warn"] and not rows[0]["ok"]
|
|
and rows[0]["text"] == messages.row_peak_forecast_unit("kWh"))
|
|
lg.removeHandler(rec)
|
|
|
|
print("3. one state read per cycle")
|
|
c, h = make(st(0.82, "kW"))
|
|
reads(c)
|
|
check(f"one read of the forecast entity per read_inputs() ({h.reads})",
|
|
h.reads.count("sensor.fc") == 1)
|
|
|
|
print("4. maintenance headroom and the charge-only clamp use watts")
|
|
seen = []
|
|
_real = _main.maintenance_charge_floor
|
|
|
|
|
|
def spy(charge_w, fc, cap):
|
|
out = _real(charge_w, fc, cap)
|
|
seen.append((charge_w, fc, cap, out))
|
|
return out
|
|
|
|
|
|
_main.maintenance_charge_floor = spy
|
|
try:
|
|
def cycle(fc, **kw):
|
|
c, h = make(fc, store={"phase": "charge"},
|
|
prices={"sensor.pnow": 1.0, "sensor.pavg": 2.0},
|
|
price_now_entity="sensor.pnow",
|
|
price_avg_entity="sensor.pavg", **kw)
|
|
seen.clear()
|
|
asyncio.run(c.read_inputs())
|
|
asyncio.run(c.cycle())
|
|
return c, h
|
|
|
|
# cap 2500 W, charge rate 2000 W: 820 W of forecast leaves 1680 W, 17 W
|
|
# leaves the whole 2000 W (the size rule read 17 as 17 kW and left 0).
|
|
c, h = cycle(st(0.82, "kW"), peak_cap_w=2500)
|
|
check(f"cheap-window headroom is cap - 820 W = 1680 W ({seen})",
|
|
len(seen) == 1 and seen[0][1] == 820.0 and seen[0][0] == 2000.0
|
|
and abs(seen[0][3] - 1680.0) < 1e-6)
|
|
c, h = cycle(st(17, "W"), peak_cap_w=2500)
|
|
check(f"a 17 W forecast leaves the full 2000 W charge rate ({seen})",
|
|
len(seen) == 1 and seen[0][1] == 17.0 and abs(seen[0][3] - 2000.0) < 1e-6)
|
|
# charge-only clamp: not cheap, shaping suspended only if the forecast is
|
|
# over the aim. 6 kW is over 5000 W; 17 W is not (the old rule read it
|
|
# as 17 kW and suspended).
|
|
def clamp(fc):
|
|
c, h = make(fc, store={"phase": "charge"})
|
|
asyncio.run(c.read_inputs())
|
|
asyncio.run(c.cycle())
|
|
return c._maint_shape_suspended
|
|
check("kW 6.0 (6000 W, over the 5000 W aim) suspends the charge-only clamp",
|
|
clamp(st(6.0, "kW")) is True)
|
|
check("W 17 leaves the charge-only clamp in place",
|
|
clamp(st(17, "W")) is False)
|
|
check("kW 0.017 leaves the charge-only clamp in place",
|
|
clamp(st(0.017, "kW")) is False)
|
|
check("no unit: forecast unused, clamp in place, as with the option empty",
|
|
clamp(st(6000)) is False)
|
|
finally:
|
|
_main.maintenance_charge_floor = _real
|
|
|
|
print("5. validator and strings")
|
|
def notes(unit, key="peak_forecast_entity"):
|
|
ent = {"sensor.fc": {"entity_id": "sensor.fc", "unit": unit,
|
|
"state": "1"}}
|
|
return [n for n in validate.config_notes(
|
|
{key: "sensor.fc", "meter_source": "off"}, entities=ent)
|
|
if key in n.keys]
|
|
|
|
|
|
check("validator: W no note", not notes("W"))
|
|
check("validator: kW no note", not notes("kW"))
|
|
check("validator: MW no note", not notes("MW"))
|
|
nu, kw = notes(""), notes("kWh")
|
|
check(f"validator: no unit warns ({nu})", len(nu) == 1 and nu[0].tier == "warn"
|
|
and "has no unit" in nu[0].text)
|
|
check(f"validator: kWh warns ({kw})", len(kw) == 1 and kw[0].tier == "warn"
|
|
and "not in a power unit" in kw[0].text)
|
|
check("validator: the false 'taken as watts' claim is gone for this entity",
|
|
not any("taken as watts" in n.text for n in nu + kw + notes("kW")))
|
|
check("validator: another watt key keeps its claim",
|
|
len(notes("kW", "batt_entity")) == 1
|
|
and "taken as watts" in notes("kW", "batt_entity")[0].text)
|
|
check("WATT_ENTITY_KEYS no longer holds the peak forecast",
|
|
"peak_forecast_entity" not in validate.WATT_ENTITY_KEYS
|
|
and "batt_entity" in validate.WATT_ENTITY_KEYS)
|
|
print(" EN no unit :", messages.peak_forecast_unit(""))
|
|
print(" EN bad unit:", messages.peak_forecast_unit("kWh"))
|
|
print(" NL no unit :", messages.peak_forecast_unit("", "nl"))
|
|
print(" NL bad unit:", messages.peak_forecast_unit("kWh", "nl"))
|
|
check("the strings differ by cause and by language",
|
|
len({messages.peak_forecast_unit(u, l) for u in ("", "kWh")
|
|
for l in ("en", "nl")}) == 4)
|
|
print("6. a missing reading leaves the unit fault as it was (F1)")
|
|
|
|
|
|
lg.addHandler(rec)
|
|
|
|
|
|
def prow(c):
|
|
return [r for r in c.checks() if r.get("id") == "peak_forecast"]
|
|
|
|
|
|
def flap(first, gap, last):
|
|
"""first, gap, first again (or last): the log rows and the status row
|
|
after each cycle."""
|
|
rec.rows.clear()
|
|
c, h = make(first)
|
|
out = []
|
|
for s in (first, gap, last):
|
|
h.fc = s
|
|
reads(c)
|
|
out.append((len(warns()), len(used()), len(prow(c)), c.peak_fc))
|
|
return out
|
|
|
|
|
|
for label, first, gap in [
|
|
("kWh -> unavailable -> kWh", st(820, "kWh"), st("unavailable", "kWh")),
|
|
("kWh -> unknown -> kWh", st(820, "kWh"), st("unknown", "kWh")),
|
|
("kWh -> failed read -> kWh", st(820, "kWh"), None),
|
|
("kWh -> non-numeric -> kWh", st(820, "kWh"), st("sunny", "kWh")),
|
|
("no unit -> failed read -> no unit", st(820), None),
|
|
("no unit -> unavailable -> no unit", st(820), st("unavailable"))]:
|
|
r = flap(first, gap, first)
|
|
check(f"{label}: one WARNING, no 'used again', row on all 3 ({r})",
|
|
[x[0] for x in r] == [1, 1, 1] and [x[1] for x in r] == [0, 0, 0]
|
|
and [x[2] for x in r] == [1, 1, 1]
|
|
and [x[3] for x in r] == [None, None, None])
|
|
r = flap(st(820, "kWh"), st("unavailable", "kWh"), st(820, "W"))
|
|
check(f"kWh -> unavailable -> W: the fault clears only on the W reading ({r})",
|
|
[x[1] for x in r] == [0, 0, 1] and [x[2] for x in r] == [1, 1, 0]
|
|
and [x[0] for x in r] == [1, 1, 1] and r[2][3] == 820.0)
|
|
rec.rows.clear()
|
|
c, h = make(st(820, "W"))
|
|
for s in (st(820, "W"), st("unavailable", "W"), None, st("sunny", "W")):
|
|
h.fc = s
|
|
reads(c)
|
|
check(f"good unit, reading {s and s['state']!r}: no row, no log",
|
|
not prow(c) and not warns() and not used())
|
|
rec.rows.clear()
|
|
c, h = make(st(820, "kWh"))
|
|
reads(c)
|
|
for s in (st("unavailable", "kWh"), None):
|
|
h.fc = s
|
|
reads(c)
|
|
check("fault row stays while the reading is missing",
|
|
len(prow(c)) == 1 and prow(c)[0]["text"]
|
|
== messages.row_peak_forecast_unit("kWh"))
|
|
c, h = make(st(820, "W"))
|
|
h.fc = st("unavailable", "W")
|
|
reads(c)
|
|
check("good-unit sensor only unavailable: the fault row stays away",
|
|
not prow(c))
|
|
lg.removeHandler(rec)
|
|
check("has_reading: numbers only",
|
|
pv.has_reading(st(0, "kWh")) and pv.has_reading(st("1.5"))
|
|
and not pv.has_reading(None) and not pv.has_reading(st("unavailable"))
|
|
and not pv.has_reading(st("unknown")) and not pv.has_reading(st("x")))
|
|
check("pv.unit_fault: None for a good unit and for a missing reading (has_reading tells them apart)",
|
|
pv.unit_fault(st(1, "kW")) is None and pv.unit_fault(None) is None
|
|
and pv.unit_fault(st("unavailable")) is None
|
|
and pv.unit_fault(st(1)) == "" and pv.unit_fault(st(1, "kWh")) == "kWh")
|
|
src = (Path(__file__).parent / "app" / "main.py").read_text(encoding="utf-8")
|
|
check("the size rule is gone from main.py", "abs(raw) < 50" not in src)
|
|
|
|
print()
|
|
print("FAILED: " + "; ".join(fails) if fails else "all checks passed")
|
|
raise SystemExit(1 if fails else 0)
|