393 files

This commit is contained in:
Home Assistant Version Control
2026-09-27 17:16:54 +00:00
parent b05b7a897e
commit 39d97ac2db
404 changed files with 54006 additions and 11034 deletions
@@ -29,6 +29,7 @@ import numpy as np
from homeassistant.core import HomeAssistant, callback
from .const import (
TerminationReason,
CONF_WATCHDOG_INTERVAL,
CONF_NO_UPDATE_ACTIVE_TIMEOUT,
CONF_OFF_DELAY,
@@ -221,6 +222,98 @@ def _resumed_low_runs(
return out
def _measured_quiet_span_s(
points: list[tuple[float, float]],
low_start_s: float,
resume_idx: int,
quiet_thr: float,
) -> float:
"""Longest quiet span inside a resumed low run, as the DETECTOR would time it.
``_resumed_low_runs`` locates a pause by asking when the appliance went below
``active_thr`` and when it came back - the right question for *whether* this
was a pause. It is the wrong measurement for ``off_delay``, because the run
is closed only by a *sustained* resume (``_MIN_RESUME_ACTIVE_S``, 120 s) and
any dip re-absorbs the blip. An appliance that works in bursts shorter than
that never closes a run: measured on the #445 reporter's Miele (10 s
sampling, ~110 W tumble for 20-60 s alternating with 3.4 W dips), the *first*
dip opened a run that stayed open for 2070 s until a long heating block
finally arrived - and that "pause" contains a 497.9 W peak. The p95 came out
at 1973 s, so the suggestion asked for ``off_delay`` 2033 s on a machine
whose real pauses are one sample long. Retuning ``active_thr`` does not help:
swept over ``0.02*peak`` / ``stop_threshold`` / ``start_threshold`` /
``0.25*median_active`` / ``0.5*median_active``, that p95 stays 1944-1976 s.
So measure what ``CycleDetector._time_below_threshold`` would actually have
accumulated: it resets on *any* reading at or above ``stop_threshold_w``, so
the statistic is the longest run of consecutive below-threshold samples. A
run whose samples never go below it is not a pause the end gates could ever
have seen, and returns 0.0 for the caller to drop.
Timed like the accumulator too: the interval *preceding* the first
below-threshold sample is credited (the detector adds ``dt`` on the reading
that takes it below), so a span is ``t[last_below] - t[first_below - 1]``.
"""
return _measured_quiet_span(points, low_start_s, resume_idx, quiet_thr)[0]
def _measured_quiet_span(
points: list[tuple[float, float]],
low_start_s: float,
resume_idx: int,
quiet_thr: float,
) -> tuple[float, int | None]:
""":func:`_measured_quiet_span_s`, plus the index the winning span ends on.
The caller needs both, and they can disagree. The low run is bounded by
``active_thr = max(stop_threshold_w, 0.05 * peak)``, so on any normal
appliance (peak 2 kW, stop threshold ~2.5 W) a reading anywhere between
those two levels splits the run into several below-threshold spans. This
function returns the LONGEST of them, while ``points[:resume_idx]`` ends on
the LAST one - so scoring that prefix described a different span from the
duration reported beside it, and ``_ml_off_delay``'s ``score < 0.4`` filter
admitted or rejected the wrong durations.
"""
if resume_idx <= 0 or resume_idx > len(points):
return 0.0, None
best = 0.0
best_end: int | None = None
first_below: int | None = None
last_below: int | None = None
def _close(f: int, last: int) -> None:
nonlocal best, best_end
anchor_t = points[max(0, f - 1)][0]
# `_resumed_low_runs` drops a low run that straddles a gap bigger than
# `_MAX_PAUSE_GAP_H` and opens a new one at the first reading after it.
# If that reading is already below the threshold, anchoring at `f - 1`
# reaches back across the outage and folds the whole hole into the span
# - which then flows into the p95 that sizes `off_delay` in both
# `_suggest_off_delay_from_pauses` and `_ml_off_delay`, where three
# pauses are enough for one to dominate. That is precisely the inflation
# the `max_gap_s` guard exists to prevent, arriving by the back door.
if points[f][0] - anchor_t > _MAX_PAUSE_GAP_H * 3600:
anchor_t = points[f][0]
span = points[last][0] - anchor_t
if span > best:
best, best_end = span, last
for i in range(resume_idx):
t, power = points[i]
if t < low_start_s:
continue
if power < quiet_thr:
if first_below is None:
first_below = i
last_below = i
elif first_below is not None and last_below is not None:
_close(first_below, last_below)
first_below = last_below = None
if first_below is not None and last_below is not None:
_close(first_below, last_below)
return max(0.0, best), best_end
def _cycle_readings(cycle: dict[str, Any]) -> list[tuple[float, float]]:
"""Normalise a cycle's power_data to [(offset_s, watts), ...]; [] on failure."""
raw = cycle.get("power_data")
@@ -347,6 +440,18 @@ def select_clean_cycles(
if status == "force_stopped":
_bump("force_stopped")
continue
# A cycle the user cut short with "Force cycle end" is stored
# status="completed", termination_reason="user" (CycleDetector.user_stop),
# so the force_stopped check above never sees it (#445). Its tail is
# whatever the appliance happened to be doing when the button was pressed
# - usually minutes of standby the user got tired of waiting through - so
# every statistic derived from it (sampling cadence, lowest active power,
# clean-cycle duration, intra-cycle pauses) describes the user's patience,
# not the appliance. Excluded under its own code so the UI can say which
# of the two it was.
if c.get("termination_reason") == TerminationReason.USER:
_bump("user_stopped")
continue
if status == "interrupted" or state == "interrupted":
_bump("interrupted")
continue
@@ -389,6 +494,159 @@ def select_clean_cycles(
return clean, excluded
# A final reading only counts as standby when it is at most this fraction of the
# cycle's own peak. Sized to keep every real case in the corpus (the #445
# reporter's 3.2 W idle against a ~2 kW peak is 0.16%) while rejecting a cycle
# stopped by hand while the appliance was still working.
_STANDBY_PEAK_FRACTION = 0.10
# ...and the observed levels must agree with each other, measured as the median
# absolute deviation over the median. Outlier-ROBUST on purpose: min/max spread
# was tried and rejected because #445's own Miele has one 7.5 W reading among
# [4.1, 3.4, 7.5, 3.2, 3.4] and that single sample put it at 1.26, rejecting the
# canonical true positive. On MAD the same device scores 0.059 while the corpus
# washing machine that also passes the all-above test - [8.4, 7.7, 27.0, 8.8,
# 55.0, 26.8, 6.0, 38.9], where the last stored sample is just wherever the drum
# stopped - scores 0.548. 0.25 sits with ~4x margin on both sides.
_STANDBY_MAX_MAD = 0.25
def detect_standby_above_stop(
cycles: list[dict[str, Any]],
stop_threshold_w: float,
*,
recent: int = 8,
min_hits: int = 2,
) -> dict[str, Any] | None:
"""Does this appliance idle ABOVE its stop threshold? (#445 cause 1)
``CycleDetector`` only counts a cycle as ending once power stays BELOW
``stop_threshold_w``. An appliance whose standby draw sits above it can
therefore never end a cycle on its own, however long the off delay: the #445
reporter's Miele idles at 3.2-3.5 W against a 2.56 W threshold, and they
force-stopped four cycles before reporting it.
The evidence is in the stored cycles. A cycle that ended by timeout snaps back
to the last reading above the threshold, and one the user force-stopped keeps
its tail - so in both cases the LAST stored sample is the level the appliance
was actually sitting at when the cycle closed. If that is repeatedly above
``stop_threshold_w``, the threshold is below the appliance's standby draw.
**Two requirements, and both are load-bearing** (PR #448 round 7). The first
draft asked only that ``min_hits`` of the recent cycles ended above the
threshold, and a peak-relative sanity check was added on top. Measured across
the whole corpus that still fired on **4 of 6 real devices** with plainly
wrong numbers - it told a dishwasher whose last stored samples are
``[0, 0, 0, 0, 0, 0, 62, 23]`` that it "idles at 42.5 W". The reason is
structural: every non-dishwasher end path TRIMS the trailing sub-threshold
samples, so the last stored sample is by construction the last sample *above*
the threshold - the moment the appliance was last working, not the level it
settled at. On a machine that really reaches 0 W that number is meaningless.
So:
1. **Every** recent cycle must end above the threshold, not merely
``min_hits`` of them. One cycle that reached 0 W proves the appliance
*can* go below, which is the whole question being asked.
2. The level must be **consistent**, as median-absolute-deviation over the
median, within ``_STANDBY_MAX_MAD``. A real standby draw is a level;
"wherever the drum happened to be" ranges 6 W to 55 W across eight cycles,
which is what the corpus's washing machines actually look like.
**Validated against the reporters' own exports, not just the corpus.** Of
nine real devices only one passes both tests: #445's Miele, every cycle
ending 3.2-7.5 W against a 2.56 W threshold (MAD 0.059). The corpus washing
machine that also never reaches 0 W is rejected on consistency (MAD 0.548).
**#427's AEG and #424's Beko are deliberately NOT reported**, though an
earlier version of this docstring cited them as validation at 4/8 each. Their
stored cycles end ``[0.0, 1.9, 0.1, 0.9, 0.8, 0.0, 0.0, 0.7]`` and
``[1.2, 1.3, 1.3, 0.0, 0.3, 0.4, 0.2, 1.3]`` - both reach 0 W regularly, so
neither idles above its threshold. Their late finishes had a different cause
(the keepalive cadence bug, fixed separately), and counting them here was
reading a coincidence as a diagnosis.
User-stopped cycles are still kept: on an appliance with this fault they are
often the ONLY way a cycle ever closes, and the #445 reporter force-stopped
four.
Returns None when there is no such pattern, else a summary carrying the
observed idle level so the UI can name a number rather than a symptom. Pure
statistics, never raises: this is read on the device-list path.
"""
try:
if stop_threshold_w <= 0:
return None
finals: list[float] = []
for cycle in list(cycles)[-recent:]:
if not isinstance(cycle, dict):
continue
raw = cycle.get("power_data")
if not isinstance(raw, list) or not raw:
continue
last = raw[-1]
if not isinstance(last, (list, tuple)) or len(last) < 2:
continue
try:
final_w = float(last[1])
except (TypeError, ValueError, OverflowError):
continue
# The stored signature already carries this cycle's peak, and it is
# what the rest of the integration means by one (Stage 1 rejection
# reads the same field), so scanning every sample again is pure
# waste. It is not free waste: this runs on the event loop inside the
# synchronous `ws_get_devices` callback, which the panel polls every
# 20 s per device, and measured over the worst real export in
# `cycle_data/` (11454 samples across the last 8 cycles) the scan
# costs 1.73 ms a call. Not a hazard at that cadence, which is why
# there is no cache here: keying one on cycle count, last id and
# threshold buys a millisecond and adds an invalidation path that can
# serve a stale advisory. Falling back to the scan keeps a cycle
# whose signature is missing or unusable working exactly as before.
peak = 0.0
_sig = cycle.get("signature")
_sig_peak = _sig.get("max_power") if isinstance(_sig, dict) else None
try:
_sig_peak = float(_sig_peak) if _sig_peak is not None else None
except (TypeError, ValueError, OverflowError):
_sig_peak = None
if _sig_peak is not None and math.isfinite(_sig_peak) and _sig_peak >= 0:
peak = _sig_peak
else:
for pt in raw:
if isinstance(pt, (list, tuple)) and len(pt) >= 2:
try:
peak = max(peak, float(pt[1]))
except (TypeError, ValueError, OverflowError):
continue
# Idle-like or nothing: a cycle stopped by hand mid-wash ends at
# working power and says nothing about the standby draw.
if peak > 0 and final_w > peak * _STANDBY_PEAK_FRACTION:
continue
finals.append(final_w)
if len(finals) < min_hits:
return None
above = [f for f in finals if f > stop_threshold_w]
# Requirement 1: EVERY recent cycle ended above the threshold. One that
# reached it proves the appliance can, so it does not idle above it.
if len(above) != len(finals) or len(above) < min_hits:
return None
median = float(np.median(above))
if median <= 0:
return None
# Requirement 2: the levels agree with each other. See _STANDBY_MAX_MAD.
mad = float(np.median([abs(f - median) for f in above]))
if mad / median > _STANDBY_MAX_MAD:
return None
return {
"cycles_above": len(above),
"cycles_checked": len(finals),
"idle_w": round(median, 2),
"stop_threshold_w": round(float(stop_threshold_w), 2),
}
except Exception: # noqa: BLE001 - a statistic must never break the device list
return None
def _format_exclusions(excluded: dict[str, int]) -> str:
"""English exclusion note for the suggestion ``reason`` fallback string.
@@ -1358,7 +1616,16 @@ class SuggestionEngine:
# same sustained-resume + outage-gap gate used by the off_delay
# heuristics (_suggest_off_delay_from_pauses / _scored_pauses).
for low_start_s, resume_idx in _resumed_low_runs(readings, active_thr, max_gap_s):
if readings[resume_idx][0] - low_start_s >= 60.0:
# Same measurement correction as the off_delay heuristic (#445):
# a "false end" is quiet time the END GATES would have banked, so
# it is timed against stop_threshold_w, not against the
# 2%-of-peak activity floor that only decides whether this was a
# pause at all. Without it, an appliance that works in bursts
# scores a false end in every cycle and end_repeat_count is
# driven up on evidence the detector never saw.
if _measured_quiet_span_s(
readings, low_start_s, resume_idx, stop_threshold_w
) >= 60.0:
n_false_end += 1
break
@@ -1429,7 +1696,14 @@ class SuggestionEngine:
# sustain is absorbed, and the trailing dead tail is skipped - so the
# drying phase never inflates the p95 (see _resumed_low_runs).
for low_start, resume_idx in _resumed_low_runs(readings, active_thr, max_gap_s):
run = readings[resume_idx][0] - low_start
# Measure the pause the way the end gates time it, not as the
# span between "went quiet" and "came back loud" (#445). See
# _measured_quiet_span_s: the sustained-resume rule merges an
# entire burst-driven wash phase into one multi-thousand-second
# "pause" on appliances whose working power dips between bursts.
run = _measured_quiet_span_s(
readings, low_start, resume_idx, stop_threshold_w
)
if run > 0:
pause_durations.append(run)
@@ -2121,12 +2395,24 @@ class MLSuggestionEngine:
# not sustain is not a pause). ``resume_idx`` is the first active sample of
# the resume, so the tail prefix ``points[:resume_idx]`` ends in the low run.
for low_start_s, resume_idx in _resumed_low_runs(points, active_thr, max_gap_s):
dur = points[resume_idx - 1][0] - low_start_s
# Timed against stop_threshold_w like the classic heuristic (#445), so
# the ML-calibrated off_delay is fitted to the same quantity the end
# gates measure rather than to burst-phase spans.
dur, quiet_end = _measured_quiet_span(
points, low_start_s, resume_idx, stop_threshold_w
)
if dur < 30.0: # ignore motor micro-dips
continue
score: float | None = None
try:
feat = end_feat_fn(points[:resume_idx], expectation) # tail is the low run
# Score the prefix ending on the span `dur` was measured from, not
# merely the one before the resume: a low run split by readings
# between stop_threshold_w and active_thr holds several quiet
# spans, and pairing the longest span's duration with the last
# span's score is what made the `score < 0.4` filter keep the
# wrong pauses.
tail_end = resume_idx if quiet_end is None else quiet_end + 1
feat = end_feat_fn(points[:tail_end], expectation)
if feat is not None:
score = float(end_score_fn(feat))
except Exception: # pylint: disable=broad-exception-caught