diff --git a/goodwe_controller/DECISIONS/SAFETY-118-peak-forecast-unit.md b/goodwe_controller/DECISIONS/SAFETY-118-peak-forecast-unit.md new file mode 100644 index 0000000..37422d7 --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-118-peak-forecast-unit.md @@ -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/` per cycle, as `hass.number()` made. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 56548e1..cd9051c 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -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 | 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 | 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. | | 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. | @@ -1697,7 +1697,7 @@ DECISIONS/MAINT-13-full-pack-is-complete.md. | 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_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` | diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 3677b35..9e98549 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -1202,6 +1202,9 @@ class Controller: # `grid` is P1, one meter, one connection, and belongs here. self.grid = 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._price_gated = False # MAINT-13: is a price gate set up # 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( 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: """OPT-11 P4: the solar forecast sensors (`solarfc.read()`). A failed or stale read is no forecast, never a zero.""" @@ -3448,11 +3467,17 @@ class Controller: _reads.append(self._read_forecast()) await asyncio.gather(*_reads) if o.get("peak_forecast_entity"): - raw = await self.hass.number(o["peak_forecast_entity"]) - # Accept kW or W - nobody's quarter-hour forecast is 50 W. - self.peak_fc = None if raw is None else (raw * 1000 if abs(raw) < 50 else raw) + # SAFETY-118: read in its own unit, like the PV path. One state + # read, as `hass.number()` made. No unit or a unit that is not + # 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: self.peak_fc = None + self._peak_fc_fault = None now_e, avg_e = o.get("price_now_entity"), o.get("price_avg_entity") if now_e and avg_e: @@ -7240,6 +7265,10 @@ class Controller: # is both is a check nobody reads. out.append({"ok": False, "warn": True, "id": "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, # each member with no usable reading. A caution: control goes on. gaps = getattr(self, "solar_fc_gaps", ()) diff --git a/goodwe_controller/app/messages.py b/goodwe_controller/app/messages.py index 48646f4..0c1344f 100644 --- a/goodwe_controller/app/messages.py +++ b/goodwe_controller/app/messages.py @@ -788,6 +788,25 @@ def row_no_peak_forecast() -> str: "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: 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_estop_fitted", GREEN, row_estop_fitted()), ("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_pv", GREEN, row_pv(3200, 2)), ("row_pv", GREEN, row_pv(1800, 1, True)), diff --git a/goodwe_controller/app/pv.py b/goodwe_controller/app/pv.py index 71c3691..901e0c9 100644 --- a/goodwe_controller/app/pv.py +++ b/goodwe_controller/app/pv.py @@ -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. ⚠️ THE UNIT IS READ OFF THE ENTITY AND NOT ASSUMED, AND THERE IS NO - MAGNITUDE HEURISTIC. `read_inputs()` guesses kW-or-W for - `peak_forecast_entity` with `abs(raw) < 50`, which is safe there because - nobody's quarter-hour demand forecast is 50 W. It is NOT safe here: 30 W of - real production at dusk is an ordinary reading, and that heuristic would - publish it as 30 kW - a fabricated number, in the channel whose entire - purpose is to be believed a week later. A sensor whose unit this cannot read + MAGNITUDE HEURISTIC. 30 W of real production at dusk is an ordinary reading, + and a "small number means kW" rule would publish it as 30 kW - a fabricated + number, in the channel whose entire purpose is to be believed a week later. + SAFETY-118 removed the last such rule, on `peak_forecast_entity`, for the + same reason (DECISIONS/SAFETY-118-peak-forecast-unit.md): the peak forecast + now reads through this function too. A sensor whose unit this cannot read is REFUSED rather than guessed at. """ if state is None: @@ -126,6 +126,31 @@ def watts(state: dict | None) -> tuple: 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 -------------------------- def entities(opts: dict) -> tuple: diff --git a/goodwe_controller/app/uilayout.py b/goodwe_controller/app/uilayout.py index fbe9535..421dac7 100644 --- a/goodwe_controller/app/uilayout.py +++ b/goodwe_controller/app/uilayout.py @@ -194,7 +194,7 @@ GROUPS = ( Field("peak_cap_w", SLIDER, "kW", scale=1000, step=100, simple=True, wizard=5, note="month_peak"), 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, "%"), )), Part("start", ( diff --git a/goodwe_controller/app/validate.py b/goodwe_controller/app/validate.py index e554c80..3314706 100644 --- a/goodwe_controller/app/validate.py +++ b/goodwe_controller/app/validate.py @@ -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 -# 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 = ( "meter_entity", "p1_net_entity", "p1_import_entity", "p1_export_entity", "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. @@ -1829,6 +1830,10 @@ def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None: _advise(notes, "warn", (key,), f"{entity} reads in {unit}, but {S(key, where)} is " "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: _advise(notes, "warn", (key,), f"{entity} does not read in kWh, so pick a solar " diff --git a/goodwe_controller/test_safety118.py b/goodwe_controller/test_safety118.py new file mode 100644 index 0000000..30058a6 --- /dev/null +++ b/goodwe_controller/test_safety118.py @@ -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) diff --git a/goodwe_controller/tools/opt11_identity.py b/goodwe_controller/tools/opt11_identity.py index 3339a97..f775816 100644 --- a/goodwe_controller/tools/opt11_identity.py +++ b/goodwe_controller/tools/opt11_identity.py @@ -105,6 +105,13 @@ class _Hass: return self.power(u) 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): self.cmd[e[-1]] = float(v) return True diff --git a/goodwe_controller/translations/en.yaml b/goodwe_controller/translations/en.yaml index 881a027..cdaced8 100644 --- a/goodwe_controller/translations/en.yaml +++ b/goodwe_controller/translations/en.yaml @@ -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." peak_forecast_entity: 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: name: "Limits · Peak reserve (%)" description: "Peak limiting will not draw the battery below this level, but ordinary use can. Derived value, leave it unchanged." diff --git a/goodwe_controller/translations/nl.yaml b/goodwe_controller/translations/nl.yaml index 12cfaf8..0e939d5 100644 --- a/goodwe_controller/translations/nl.yaml +++ b/goodwe_controller/translations/nl.yaml @@ -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." peak_forecast_entity: 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: name: "Grenzen · Piekreserve (%)" description: "De piekbeperking laat de batterij niet onder dit niveau zakken, gewoon verbruik wel. Afgeleide waarde, laat ze ongewijzigd."