Merge pull request 'SAFETY-118: read the peak forecast in its own unit' (#198) from SAFETY-118 into release/1.0
infra-pin / fresh (push) Successful in 1s
infra-pin / fresh (push) Successful in 1s
This commit was merged in pull request #198.
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
# SAFETY-118 - the peak forecast is read in its own unit
|
||||||
|
|
||||||
|
**Status: for review.** Tests: `test_safety118.py`.
|
||||||
|
|
||||||
|
`peak_forecast_entity` was read as a bare number, and anything under 50 was
|
||||||
|
multiplied by 1000 ("accept kW or W"). A W sensor reading 17 to 45 W, which a
|
||||||
|
real site did 430 times in one morning, would be taken as 17 to 45 kW. The
|
||||||
|
validator also called a kW sensor wrong, which was false for this entity.
|
||||||
|
|
||||||
|
Decision: the size rule is gone. The entity is read through `pv.watts()`, the
|
||||||
|
same unit table and reader as the solar entities, so W, kW, MW and the rest
|
||||||
|
convert by the sensor's own `unit_of_measurement`. No unit, or a unit that is
|
||||||
|
not power (kWh), means the forecast is not used, exactly as if the option were
|
||||||
|
empty: the loop cap and every non-forecast peak step still apply. One WARNING
|
||||||
|
per cause, and a status row and a settings note say the same.
|
||||||
|
|
||||||
|
No size rule can be right in both units: 30 W and 30 kW are both ordinary.
|
||||||
|
The read is still one `GET /api/states/<id>` per cycle, as `hass.number()` made.
|
||||||
@@ -210,7 +210,7 @@ The label and description each setting shows on the Configuration tab, in tab or
|
|||||||
| Limits | Battery cut-out level (%) | `bms_soc_floor` | The level where the battery's own management system stops giving power. Read it off your battery rather than copying a figure. |
|
| Limits | Battery cut-out level (%) | `bms_soc_floor` | The level where the battery's own management system stops giving power. Read it off your battery rather than copying a figure. |
|
||||||
| Limits | Peak limit (W) | `peak_cap_w` | Your own number: the 15-minute average power the add-on keeps the grid under. Take it from your grid contract. |
|
| Limits | Peak limit (W) | `peak_cap_w` | Your own number: the 15-minute average power the add-on keeps the grid under. Take it from your grid contract. |
|
||||||
| Limits | Margin below the peak limit (W) | `peak_margin_w` | The add-on aims this much below the peak limit, so a 15-minute period that ends a little high still stays under it. |
|
| Limits | Margin below the peak limit (W) | `peak_margin_w` | The add-on aims this much below the peak limit, so a 15-minute period that ends a little high still stays under it. |
|
||||||
| Limits | 15-minute demand forecast entity (W) | `peak_forecast_entity` | Optional. A forecast of this 15-minute period's average power. Leave it empty and there is no peak limit. |
|
| Limits | 15-minute demand forecast entity (W) | `peak_forecast_entity` | Optional. A forecast of this 15-minute period's average power, in W or kW. With no power unit it is not used. Leave it empty and there is no peak limit. |
|
||||||
| Limits | Peak reserve (%) | `peak_soc_floor` | Peak limiting will not draw the battery below this level, but ordinary use can. Derived value, leave it unchanged. |
|
| Limits | Peak reserve (%) | `peak_soc_floor` | Peak limiting will not draw the battery below this level, but ordinary use can. Derived value, leave it unchanged. |
|
||||||
| Tariff | Dynamic price source | `price_provider` | Where prices come from. Choosing a source starts the price fetch, even with trading off. |
|
| Tariff | Dynamic price source | `price_provider` | Where prices come from. Choosing a source starts the price fetch, even with trading off. |
|
||||||
| Tariff | Supplier markup (EUR/kWh) | `price_markup_eur_kwh` | Your supplier's margin. It is added only when the price source does not already include it. |
|
| Tariff | Supplier markup (EUR/kWh) | `price_markup_eur_kwh` | Your supplier's margin. It is added only when the price source does not already include it. |
|
||||||
@@ -1697,7 +1697,7 @@ DECISIONS/MAINT-13-full-pack-is-complete.md.
|
|||||||
|
|
||||||
| option | meaning |
|
| option | meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `peak_forecast_entity` | Quarter-hour demand forecast, for capacity-tariff markets. Empty = no cap |
|
| `peak_forecast_entity` | Quarter-hour demand forecast, for capacity-tariff markets. Read in its own unit (W, kW or any power unit). No unit, or a unit that is not power, means it is not used, as if empty (SAFETY-118). Empty = no cap |
|
||||||
| `peak_cap_w` | The site's capacity-tariff target |
|
| `peak_cap_w` | The site's capacity-tariff target |
|
||||||
| `peak_margin_w` (50) | Watts the peak guard aims under `peak_cap_w` (SAFETY-79). Reports and the bill use `peak_cap_w` itself |
|
| `peak_margin_w` (50) | Watts the peak guard aims under `peak_cap_w` (SAFETY-79). Reports and the bill use `peak_cap_w` itself |
|
||||||
| `peak_soc_floor` (20) | **The percent below which the capacity-peak guard will not discharge the battery.** ⚠️ It bounds **the peak guard only** — it is **not a pack-wide reserve**, and ordinary self-consumption discharges straight past it (SAFETY-25, see below). Not optional and not the same thing as `maintenance_soc_floor` |
|
| `peak_soc_floor` (20) | **The percent below which the capacity-peak guard will not discharge the battery.** ⚠️ It bounds **the peak guard only** — it is **not a pack-wide reserve**, and ordinary self-consumption discharges straight past it (SAFETY-25, see below). Not optional and not the same thing as `maintenance_soc_floor` |
|
||||||
|
|||||||
@@ -1202,6 +1202,9 @@ class Controller:
|
|||||||
# `grid` is P1, one meter, one connection, and belongs here.
|
# `grid` is P1, one meter, one connection, and belongs here.
|
||||||
self.grid = None
|
self.grid = None
|
||||||
self.peak_fc = None
|
self.peak_fc = None
|
||||||
|
# SAFETY-118: the unit fault last logged for the peak forecast (None =
|
||||||
|
# no fault), so the warning comes once and again only if it changes.
|
||||||
|
self._peak_fc_fault = None
|
||||||
self.cheap = False
|
self.cheap = False
|
||||||
self._price_gated = False # MAINT-13: is a price gate set up
|
self._price_gated = False # MAINT-13: is a price gate set up
|
||||||
# PRICE-01. Which step of the price chain the prices come from (logged
|
# PRICE-01. Which step of the price chain the prices come from (logged
|
||||||
@@ -3358,6 +3361,22 @@ class Controller:
|
|||||||
self.pv_w, self.pv_why, self.pv_parts = pv_read.total(
|
self.pv_w, self.pv_why, self.pv_parts = pv_read.total(
|
||||||
list(zip(roster, states)), pv_read.inverted(self.o))
|
list(zip(roster, states)), pv_read.inverted(self.o))
|
||||||
|
|
||||||
|
def _peak_fc_unit_log(self, fault) -> None:
|
||||||
|
"""SAFETY-118. One WARNING when the peak forecast's unit cannot be read
|
||||||
|
in watts, and again only if the unit changes; the unit is on DEBUG."""
|
||||||
|
if fault == self._peak_fc_fault:
|
||||||
|
return
|
||||||
|
self._peak_fc_fault = fault
|
||||||
|
if fault is None:
|
||||||
|
_LOG.info("The 15-minute demand forecast is used again.")
|
||||||
|
elif fault == "":
|
||||||
|
_LOG.warning("The 15-minute demand forecast has no unit, so it "
|
||||||
|
"is not used.")
|
||||||
|
else:
|
||||||
|
_LOG.warning("The 15-minute demand forecast is not in a power "
|
||||||
|
"unit, so it is not used.")
|
||||||
|
_LOG.debug("The 15-minute demand forecast reads in %r.", fault)
|
||||||
|
|
||||||
async def _read_forecast(self) -> None:
|
async def _read_forecast(self) -> None:
|
||||||
"""OPT-11 P4: the solar forecast sensors (`solarfc.read()`). A
|
"""OPT-11 P4: the solar forecast sensors (`solarfc.read()`). A
|
||||||
failed or stale read is no forecast, never a zero."""
|
failed or stale read is no forecast, never a zero."""
|
||||||
@@ -3448,11 +3467,17 @@ class Controller:
|
|||||||
_reads.append(self._read_forecast())
|
_reads.append(self._read_forecast())
|
||||||
await asyncio.gather(*_reads)
|
await asyncio.gather(*_reads)
|
||||||
if o.get("peak_forecast_entity"):
|
if o.get("peak_forecast_entity"):
|
||||||
raw = await self.hass.number(o["peak_forecast_entity"])
|
# SAFETY-118: read in its own unit, like the PV path. One state
|
||||||
# Accept kW or W - nobody's quarter-hour forecast is 50 W.
|
# read, as `hass.number()` made. No unit or a unit that is not
|
||||||
self.peak_fc = None if raw is None else (raw * 1000 if abs(raw) < 50 else raw)
|
# power: not used, as if the option were empty.
|
||||||
|
st = await self.hass.state(o["peak_forecast_entity"])
|
||||||
|
self.peak_fc, _why = pv_read.watts(st)
|
||||||
|
# A missing reading leaves the unit-fault state as it was.
|
||||||
|
if pv_read.has_reading(st):
|
||||||
|
self._peak_fc_unit_log(pv_read.unit_fault(st))
|
||||||
else:
|
else:
|
||||||
self.peak_fc = None
|
self.peak_fc = None
|
||||||
|
self._peak_fc_fault = None
|
||||||
|
|
||||||
now_e, avg_e = o.get("price_now_entity"), o.get("price_avg_entity")
|
now_e, avg_e = o.get("price_now_entity"), o.get("price_avg_entity")
|
||||||
if now_e and avg_e:
|
if now_e and avg_e:
|
||||||
@@ -7240,6 +7265,10 @@ class Controller:
|
|||||||
# is both is a check nobody reads.
|
# is both is a check nobody reads.
|
||||||
out.append({"ok": False, "warn": True, "id": "peak_forecast",
|
out.append({"ok": False, "warn": True, "id": "peak_forecast",
|
||||||
"text": messages.row_no_peak_forecast()})
|
"text": messages.row_no_peak_forecast()})
|
||||||
|
elif getattr(self, "_peak_fc_fault", None) is not None:
|
||||||
|
out.append({"ok": False, "warn": True, "id": "peak_forecast",
|
||||||
|
"text": messages.row_peak_forecast_unit(
|
||||||
|
self._peak_fc_fault)})
|
||||||
# OPT-17: a forecast summed from several entities names, by position,
|
# OPT-17: a forecast summed from several entities names, by position,
|
||||||
# each member with no usable reading. A caution: control goes on.
|
# each member with no usable reading. A caution: control goes on.
|
||||||
gaps = getattr(self, "solar_fc_gaps", ())
|
gaps = getattr(self, "solar_fc_gaps", ())
|
||||||
|
|||||||
@@ -788,6 +788,25 @@ def row_no_peak_forecast() -> str:
|
|||||||
"the peak limit")
|
"the peak limit")
|
||||||
|
|
||||||
|
|
||||||
|
def peak_forecast_unit(unit, lang="en") -> str:
|
||||||
|
"""SAFETY-118. The 15-minute demand forecast has a number but no power
|
||||||
|
unit, so it is not used. `unit` is "" for none. One sentence, for the log
|
||||||
|
line, the status row and the settings page. Log lines stay English."""
|
||||||
|
if lang == "nl":
|
||||||
|
return ("De kwartierprognose heeft geen eenheid, dus ze wordt niet "
|
||||||
|
"gebruikt." if not unit else
|
||||||
|
"De kwartierprognose heeft geen vermogenseenheid, dus ze "
|
||||||
|
"wordt niet gebruikt.")
|
||||||
|
return ("The 15-minute demand forecast has no unit, so it is not used."
|
||||||
|
if not unit else
|
||||||
|
"The 15-minute demand forecast is not in a power unit, so it is "
|
||||||
|
"not used.")
|
||||||
|
|
||||||
|
|
||||||
|
def row_peak_forecast_unit(unit) -> str:
|
||||||
|
return peak_forecast_unit(unit)
|
||||||
|
|
||||||
|
|
||||||
def row_peak_guard_waiting() -> str:
|
def row_peak_guard_waiting() -> str:
|
||||||
return "Peak guard: no decision yet, control has not run"
|
return "Peak guard: no decision yet, control has not run"
|
||||||
|
|
||||||
@@ -1229,6 +1248,8 @@ def status_samples() -> list:
|
|||||||
("row_no_estop", AMBER, row_no_estop()),
|
("row_no_estop", AMBER, row_no_estop()),
|
||||||
("row_estop_fitted", GREEN, row_estop_fitted()),
|
("row_estop_fitted", GREEN, row_estop_fitted()),
|
||||||
("row_no_peak_forecast", AMBER, row_no_peak_forecast()),
|
("row_no_peak_forecast", AMBER, row_no_peak_forecast()),
|
||||||
|
("row_peak_forecast_unit", AMBER, row_peak_forecast_unit("")),
|
||||||
|
("row_peak_forecast_unit", AMBER, row_peak_forecast_unit("kWh")),
|
||||||
("row_peak_guard_waiting", AMBER, row_peak_guard_waiting()),
|
("row_peak_guard_waiting", AMBER, row_peak_guard_waiting()),
|
||||||
("row_pv", GREEN, row_pv(3200, 2)),
|
("row_pv", GREEN, row_pv(3200, 2)),
|
||||||
("row_pv", GREEN, row_pv(1800, 1, True)),
|
("row_pv", GREEN, row_pv(1800, 1, True)),
|
||||||
|
|||||||
@@ -93,12 +93,12 @@ def watts(state: dict | None) -> tuple:
|
|||||||
unknown" are different claims and the page has to be able to make the first.
|
unknown" are different claims and the page has to be able to make the first.
|
||||||
|
|
||||||
⚠️ THE UNIT IS READ OFF THE ENTITY AND NOT ASSUMED, AND THERE IS NO
|
⚠️ THE UNIT IS READ OFF THE ENTITY AND NOT ASSUMED, AND THERE IS NO
|
||||||
MAGNITUDE HEURISTIC. `read_inputs()` guesses kW-or-W for
|
MAGNITUDE HEURISTIC. 30 W of real production at dusk is an ordinary reading,
|
||||||
`peak_forecast_entity` with `abs(raw) < 50`, which is safe there because
|
and a "small number means kW" rule would publish it as 30 kW - a fabricated
|
||||||
nobody's quarter-hour demand forecast is 50 W. It is NOT safe here: 30 W of
|
number, in the channel whose entire purpose is to be believed a week later.
|
||||||
real production at dusk is an ordinary reading, and that heuristic would
|
SAFETY-118 removed the last such rule, on `peak_forecast_entity`, for the
|
||||||
publish it as 30 kW - a fabricated number, in the channel whose entire
|
same reason (DECISIONS/SAFETY-118-peak-forecast-unit.md): the peak forecast
|
||||||
purpose is to be believed a week later. A sensor whose unit this cannot read
|
now reads through this function too. A sensor whose unit this cannot read
|
||||||
is REFUSED rather than guessed at.
|
is REFUSED rather than guessed at.
|
||||||
"""
|
"""
|
||||||
if state is None:
|
if state is None:
|
||||||
@@ -126,6 +126,31 @@ def watts(state: dict | None) -> tuple:
|
|||||||
return value * WATTS_PER[unit], unit
|
return value * WATTS_PER[unit], unit
|
||||||
|
|
||||||
|
|
||||||
|
def has_reading(state: dict | None) -> bool:
|
||||||
|
"""SAFETY-118. True when `state` holds a number, so its unit can be judged.
|
||||||
|
A missing, unavailable, unknown or non-numeric state is a missing reading."""
|
||||||
|
if state is None:
|
||||||
|
return False
|
||||||
|
raw = str(state.get("state", "")).strip()
|
||||||
|
if raw.lower() in BAD_STATES:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
float(raw)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def unit_fault(state: dict | None):
|
||||||
|
"""SAFETY-118. `None` unless `state` holds a number whose unit cannot be
|
||||||
|
read in watts, then that unit (`""` for none). `None` also means a missing
|
||||||
|
reading: use `has_reading()` to tell it from a good unit."""
|
||||||
|
if not has_reading(state):
|
||||||
|
return None
|
||||||
|
unit = str((state.get("attributes") or {}).get(UNIT_KEY) or "").strip()
|
||||||
|
return None if unit in WATTS_PER else unit
|
||||||
|
|
||||||
|
|
||||||
# --- TEL-12: the ROSTER, and the SITE figure over it --------------------------
|
# --- TEL-12: the ROSTER, and the SITE figure over it --------------------------
|
||||||
|
|
||||||
def entities(opts: dict) -> tuple:
|
def entities(opts: dict) -> tuple:
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ GROUPS = (
|
|||||||
Field("peak_cap_w", SLIDER, "kW", scale=1000, step=100,
|
Field("peak_cap_w", SLIDER, "kW", scale=1000, step=100,
|
||||||
simple=True, wizard=5, note="month_peak"),
|
simple=True, wizard=5, note="month_peak"),
|
||||||
Field("peak_margin_w", SLIDER, "W", step=10), # SAFETY-79
|
Field("peak_margin_w", SLIDER, "W", step=10), # SAFETY-79
|
||||||
Field("peak_forecast_entity", ENTITY, kind="power_w", wizard=5),
|
Field("peak_forecast_entity", ENTITY, kind="power", wizard=5),
|
||||||
Field("peak_soc_floor", LOCKED, "%"),
|
Field("peak_soc_floor", LOCKED, "%"),
|
||||||
)),
|
)),
|
||||||
Part("start", (
|
Part("start", (
|
||||||
|
|||||||
@@ -1741,11 +1741,12 @@ def _card_note(option: str, spec) -> str:
|
|||||||
|
|
||||||
|
|
||||||
# UX-13: entity options whose number the add-on takes as watts, with no unit
|
# UX-13: entity options whose number the add-on takes as watts, with no unit
|
||||||
# conversion. pv.py converts each solar entity's own unit, so those are not here.
|
# conversion. pv.py converts each solar entity's own unit, so those are not here,
|
||||||
|
# and SAFETY-118 moved the peak forecast there too.
|
||||||
WATT_ENTITY_KEYS = (
|
WATT_ENTITY_KEYS = (
|
||||||
"meter_entity", "p1_net_entity", "p1_import_entity", "p1_export_entity",
|
"meter_entity", "p1_net_entity", "p1_import_entity", "p1_export_entity",
|
||||||
"p1_phase_net_entities", "p1_phase_import_entities",
|
"p1_phase_net_entities", "p1_phase_import_entities",
|
||||||
"p1_phase_export_entities", "batt_entity", "peak_forecast_entity")
|
"p1_phase_export_entities", "batt_entity")
|
||||||
|
|
||||||
|
|
||||||
# The meter entities each `meter_source` reads through Home Assistant.
|
# The meter entities each `meter_source` reads through Home Assistant.
|
||||||
@@ -1829,6 +1830,10 @@ def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
|
|||||||
_advise(notes, "warn", (key,),
|
_advise(notes, "warn", (key,),
|
||||||
f"{entity} reads in {unit}, but {S(key, where)} is "
|
f"{entity} reads in {unit}, but {S(key, where)} is "
|
||||||
"taken as watts, so pick a sensor in W.")
|
"taken as watts, so pick a sensor in W.")
|
||||||
|
if key == "peak_forecast_entity" and unit not in pv.WATTS_PER:
|
||||||
|
_advise(notes, "warn", (key,),
|
||||||
|
messages.peak_forecast_unit(unit) + " Pick a sensor in "
|
||||||
|
"W or kW.")
|
||||||
if key in FORECAST_KEYS and unit not in solarfc.KWH_PER:
|
if key in FORECAST_KEYS and unit not in solarfc.KWH_PER:
|
||||||
_advise(notes, "warn", (key,),
|
_advise(notes, "warn", (key,),
|
||||||
f"{entity} does not read in kWh, so pick a solar "
|
f"{entity} does not read in kWh, so pick a solar "
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
"""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)
|
||||||
@@ -105,6 +105,13 @@ class _Hass:
|
|||||||
return self.power(u)
|
return self.power(u)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def state(self, e):
|
||||||
|
"""SAFETY-118: the peak forecast is read as a state object; every
|
||||||
|
number this rig serves is in watts."""
|
||||||
|
v = await self.number(e)
|
||||||
|
return None if v is None else {
|
||||||
|
"state": str(v), "attributes": {"unit_of_measurement": "W"}}
|
||||||
|
|
||||||
async def set_number(self, e, v):
|
async def set_number(self, e, v):
|
||||||
self.cmd[e[-1]] = float(v)
|
self.cmd[e[-1]] = float(v)
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ configuration:
|
|||||||
description: "The add-on aims this much below the peak limit, so a 15-minute period that ends a little high still stays under it."
|
description: "The add-on aims this much below the peak limit, so a 15-minute period that ends a little high still stays under it."
|
||||||
peak_forecast_entity:
|
peak_forecast_entity:
|
||||||
name: "Limits · 15-minute demand forecast entity (W)"
|
name: "Limits · 15-minute demand forecast entity (W)"
|
||||||
description: "Optional. A forecast of this 15-minute period's average power. Leave it empty and there is no peak limit."
|
description: "Optional. A forecast of this 15-minute period's average power, in W or kW. With no power unit it is not used. Leave it empty and there is no peak limit."
|
||||||
peak_soc_floor:
|
peak_soc_floor:
|
||||||
name: "Limits · Peak reserve (%)"
|
name: "Limits · Peak reserve (%)"
|
||||||
description: "Peak limiting will not draw the battery below this level, but ordinary use can. Derived value, leave it unchanged."
|
description: "Peak limiting will not draw the battery below this level, but ordinary use can. Derived value, leave it unchanged."
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ configuration:
|
|||||||
description: "De add-on mikt zoveel onder de piekgrens, zodat een kwartier dat iets hoger uitvalt er toch onder blijft."
|
description: "De add-on mikt zoveel onder de piekgrens, zodat een kwartier dat iets hoger uitvalt er toch onder blijft."
|
||||||
peak_forecast_entity:
|
peak_forecast_entity:
|
||||||
name: "Grenzen · Entiteit kwartierprognose (W)"
|
name: "Grenzen · Entiteit kwartierprognose (W)"
|
||||||
description: "Optioneel. Een prognose van het gemiddelde vermogen in het lopende kwartier. Laat u dit leeg, dan is er geen piekgrens."
|
description: "Optioneel. Een prognose van het gemiddelde vermogen in het lopende kwartier, in W of kW. Zonder vermogenseenheid wordt ze niet gebruikt. Laat u dit leeg, dan is er geen piekgrens."
|
||||||
peak_soc_floor:
|
peak_soc_floor:
|
||||||
name: "Grenzen · Piekreserve (%)"
|
name: "Grenzen · Piekreserve (%)"
|
||||||
description: "De piekbeperking laat de batterij niet onder dit niveau zakken, gewoon verbruik wel. Afgeleide waarde, laat ze ongewijzigd."
|
description: "De piekbeperking laat de batterij niet onder dit niveau zakken, gewoon verbruik wel. Afgeleide waarde, laat ze ongewijzigd."
|
||||||
|
|||||||
Reference in New Issue
Block a user