SAFETY-118: read the peak forecast in its own unit
peak_forecast_entity is read through pv.watts() by its unit_of_measurement; the abs(raw) < 50 size rule is gone. No unit or a non-power unit means the forecast is not used, with one warning per cause. The validator no longer claims a kW sensor is taken as watts for this entity. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
780c1f67bb
commit
f0b4e68e49
@@ -0,0 +1,303 @@
|
||||
"""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)
|
||||
h.fc = st("unavailable")
|
||||
reads(c)
|
||||
check("unavailable is no unit fault and adds no warning", len(warns()) == 3)
|
||||
h.fc = st(820, "W")
|
||||
reads(c)
|
||||
check("a good unit is read again and logs INFO, not a warning",
|
||||
c.peak_fc == 820.0 and len(warns()) == 3
|
||||
and rec.rows[-1] == (logging.INFO,
|
||||
"The 15-minute demand forecast is used again."))
|
||||
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)
|
||||
check("pv.unit_fault: None for a good unit and for a missing reading",
|
||||
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)
|
||||
Reference in New Issue
Block a user