1757 lines
78 KiB
Python
1757 lines
78 KiB
Python
# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs.
|
||
# Copyright (C) 2026 Lukas Bandura
|
||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||
#
|
||
# This program is free software: you can redistribute it and/or modify
|
||
# it under the terms of the GNU Affero General Public License as published
|
||
# by the Free Software Foundation, either version 3 of the License, or
|
||
# (at your option) any later version.
|
||
#
|
||
# This program is distributed in the hope that it will be useful,
|
||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
# GNU Affero General Public License for more details.
|
||
#
|
||
# You should have received a copy of the GNU Affero General Public License
|
||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||
"""Suggestion engine for WashData."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from datetime import datetime
|
||
from typing import Any, TYPE_CHECKING, cast
|
||
|
||
import numpy as np
|
||
from homeassistant.core import HomeAssistant
|
||
|
||
from .const import (
|
||
CONF_WATCHDOG_INTERVAL,
|
||
CONF_NO_UPDATE_ACTIVE_TIMEOUT,
|
||
CONF_OFF_DELAY,
|
||
CONF_PROFILE_MATCH_INTERVAL,
|
||
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
|
||
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
|
||
CONF_DURATION_TOLERANCE,
|
||
CONF_PROFILE_DURATION_TOLERANCE,
|
||
CONF_START_THRESHOLD_W,
|
||
CONF_STOP_THRESHOLD_W,
|
||
CONF_POWER_OFF_THRESHOLD_W,
|
||
CONF_END_ENERGY_THRESHOLD,
|
||
CONF_MIN_OFF_GAP,
|
||
CONF_MIN_POWER,
|
||
CONF_SAMPLING_INTERVAL,
|
||
CONF_SMOOTHING_WINDOW,
|
||
CONF_COMPLETION_MIN_SECONDS,
|
||
CONF_AUTO_LABEL_CONFIDENCE,
|
||
CONF_LEARNING_CONFIDENCE,
|
||
CONF_PROFILE_MATCH_THRESHOLD,
|
||
CONF_PROFILE_UNMATCH_THRESHOLD,
|
||
CONF_END_REPEAT_COUNT,
|
||
CONF_START_DURATION_THRESHOLD,
|
||
CONF_ANTI_WRINKLE_MAX_POWER,
|
||
CONF_ANTI_WRINKLE_EXIT_POWER,
|
||
CONF_DEVICE_TYPE,
|
||
CONF_PUMP_STUCK_DURATION,
|
||
DEVICE_TYPE_DRYER,
|
||
DEVICE_TYPE_PUMP,
|
||
DEVICE_TYPE_WASHING_MACHINE,
|
||
DEVICE_TYPE_WASHER_DRYER,
|
||
DEFAULT_OFF_DELAY_BY_DEVICE,
|
||
DEFAULT_OFF_DELAY,
|
||
DEFAULT_MIN_OFF_GAP_BY_DEVICE,
|
||
DEFAULT_MIN_OFF_GAP,
|
||
DEFAULT_SAMPLING_INTERVAL,
|
||
)
|
||
from .time_utils import power_data_to_offsets
|
||
|
||
# ─── Clean-cycle selection ────────────────────────────────────────────────────
|
||
# Suggestions must learn only from cycles that were detected correctly. A cycle
|
||
# whose power trace shows a mis-detection (started mid-stream, cut off abruptly,
|
||
# or fragmented by a mid-cycle restart) would poison the statistics, so it is
|
||
# excluded before any suggestion is derived.
|
||
_CLEAN_MIN_DURATION_S = 120.0 # shorter completed cycles are treated as noise
|
||
_CLEAN_HIGH_START_RATIO = 0.5 # first *sample* already >= this*peak (no lead-in) => started mid-cycle
|
||
_CLEAN_ABRUPT_END_RATIO = 0.30 # mean tail power >= this*peak => cut off mid-operation
|
||
_CLEAN_MID_RESTART_MIN_S = 600.0 # internal near-zero run >= this => merged/restarted
|
||
_CLEAN_MID_RESTART_END_GUARD = 0.90 # ... and ending before this fraction (not the tail)
|
||
_CLEAN_ACTIVE_FLOOR_RATIO = 0.02 # "active" means power above max(stop_thr, this*peak)
|
||
_MAX_PAUSE_GAP_H = 1.0 # a gap > this (hours) between samples is a data outage, not a pause
|
||
# A low run only counts as a genuine intra-cycle pause if activity RESUMES and is
|
||
# then sustained for at least this long. A dishwasher's terminal pump-out / vent
|
||
# tick (tens of watts, a sample or two) at the very end of the cycle would
|
||
# otherwise convert the whole trailing drying tail into a huge "resumed pause",
|
||
# inflating the p95 that sizes off_delay (observed: a lone 64 W blip at 99.7 % of
|
||
# a 50 degC cycle turned the ~35 min drying tail into a 2078 s "pause", driving
|
||
# off_delay to 1999 s). A genuine mid-cycle pause is followed by minutes of real
|
||
# washing; a terminal blip is followed by the cycle ending.
|
||
_MIN_RESUME_ACTIVE_S = 120.0
|
||
|
||
|
||
def _resumed_low_runs(
|
||
points: list[tuple[float, float]],
|
||
active_thr: float,
|
||
max_gap_s: float,
|
||
min_resume_active_s: float = _MIN_RESUME_ACTIVE_S,
|
||
) -> list[tuple[float, int]]:
|
||
"""Locate genuine intra-cycle pauses in a power trace.
|
||
|
||
Returns ``(low_start_s, resume_idx)`` for each low run (power below
|
||
``active_thr``) that *resumed into sustained activity* - i.e. after the run
|
||
ends, the appliance draws active power for at least ``min_resume_active_s``
|
||
contiguous seconds. ``resume_idx`` indexes the first active sample of that
|
||
sustained resume.
|
||
|
||
A low run that is only followed by a brief blip (e.g. a terminal drying /
|
||
pump-out tick) and then the cycle's end is NOT a pause: the blip is absorbed
|
||
back into the quiet run so the trailing dead tail is never mis-counted as a
|
||
resumed pause. Leading below-active idle (before the cycle first became
|
||
active) is excluded, and a low run straddling a data-outage-sized sampling
|
||
gap is abandoned (its span is a dropout, not a pause).
|
||
|
||
Shared by both the classic (:meth:`_suggest_off_delay_from_pauses`) and the
|
||
ML-calibrated (:meth:`_scored_pauses`) off-delay heuristics so they detect
|
||
the same pauses.
|
||
"""
|
||
out: list[tuple[float, int]] = []
|
||
low_start: float | None = None
|
||
cand_idx: int | None = None # first active sample of an unconfirmed resume
|
||
active_accum = 0.0 # contiguous active seconds since cand_idx
|
||
seen_active = False
|
||
prev_t: float | None = None
|
||
for i, (t, p) in enumerate(points):
|
||
# A gap larger than the outage ceiling is a sensor dropout / restart, not
|
||
# a pause: abandon any in-progress low run and pending resume.
|
||
if prev_t is not None and (t - prev_t) > max_gap_s:
|
||
low_start = None
|
||
cand_idx = None
|
||
active_accum = 0.0
|
||
if p >= active_thr:
|
||
if not seen_active:
|
||
# First activity begins here: any preceding low run is the cycle's
|
||
# leading lead-in (below-active idle before it truly started), never
|
||
# an intra-cycle pause -> discard it so it can't be mis-counted as a
|
||
# resumed pause (the docstring invariant; matches the pre-refactor
|
||
# unconditional clear on the first low->active transition).
|
||
low_start = None
|
||
cand_idx = None
|
||
active_accum = 0.0
|
||
elif low_start is not None:
|
||
if cand_idx is None:
|
||
cand_idx = i
|
||
active_accum = 0.0
|
||
elif prev_t is not None:
|
||
active_accum += t - prev_t
|
||
if active_accum >= min_resume_active_s:
|
||
out.append((low_start, cand_idx))
|
||
low_start = None
|
||
cand_idx = None
|
||
active_accum = 0.0
|
||
seen_active = True
|
||
else:
|
||
if cand_idx is not None:
|
||
# The candidate resume did not sustain - it was a blip. Absorb it
|
||
# back into the ongoing quiet run (keep the original low_start).
|
||
cand_idx = None
|
||
active_accum = 0.0
|
||
elif low_start is None:
|
||
low_start = t
|
||
prev_t = t
|
||
return out
|
||
|
||
|
||
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")
|
||
if not isinstance(raw, list) or len(raw) < 2:
|
||
return []
|
||
start_iso = cycle.get("start_time") if isinstance(cycle.get("start_time"), str) else None
|
||
try:
|
||
pairs = power_data_to_offsets(
|
||
cast(list[list[float] | tuple[Any, float]], raw), start_iso
|
||
)
|
||
return [(float(o), float(p)) for o, p in pairs]
|
||
except (TypeError, ValueError):
|
||
return []
|
||
|
||
|
||
def _classify_cycle_health(
|
||
readings: list[tuple[float, float]],
|
||
duration: float,
|
||
stop_threshold_w: float,
|
||
) -> str | None:
|
||
"""Return an exclusion reason if the trace looks mis-detected, else None."""
|
||
if not readings:
|
||
return "no_trace_short"
|
||
if duration < _CLEAN_MIN_DURATION_S:
|
||
return "too_short"
|
||
powers = [p for _, p in readings]
|
||
peak = max(powers)
|
||
if peak <= 0:
|
||
return "no_power"
|
||
active_thr = max(stop_threshold_w, _CLEAN_ACTIVE_FLOOR_RATIO * peak)
|
||
|
||
# First active reading (and its index within the trace)
|
||
first_active_p: float | None = None
|
||
first_active_i: int | None = None
|
||
for i, (_t, p) in enumerate(readings):
|
||
if p >= active_thr:
|
||
first_active_p, first_active_i = p, i
|
||
break
|
||
if first_active_p is None or first_active_i is None:
|
||
return "no_active_power"
|
||
|
||
t0 = readings[0][0]
|
||
|
||
# High start: the trace's very first sample is already at/near peak, with no
|
||
# captured low-power lead-in. When detection begins mid-cycle (e.g. restored
|
||
# state) the first recorded reading is already at operating power because the
|
||
# OFF->ON edge was never observed. A cycle that legitimately begins at high
|
||
# power (pump, resistive heater) is still preceded by at least one
|
||
# below-active reading whenever the sensor captured that OFF->ON transition,
|
||
# so requiring the first active reading to be the very first sample
|
||
# (first_active_i == 0) avoids flagging those valid immediate-start cycles.
|
||
if first_active_i == 0 and first_active_p >= _CLEAN_HIGH_START_RATIO * peak:
|
||
return "high_start"
|
||
|
||
# Abrupt end: the tail is still drawing significant power, so the cycle was
|
||
# cut off rather than winding down naturally. Guard with the last sample's
|
||
# power level: if the trace ends at near-zero, the device shut down cleanly
|
||
# (resistive devices like pumps and bread makers hold near-peak until the
|
||
# very last moment then drop to 0, so their correctly-detected cycles must
|
||
# not be excluded by this check).
|
||
tail = powers[-3:] if len(powers) >= 3 else powers
|
||
last_power = readings[-1][1] if readings else 0.0
|
||
if (
|
||
(sum(tail) / len(tail)) >= _CLEAN_ABRUPT_END_RATIO * peak
|
||
and last_power >= _CLEAN_ABRUPT_END_RATIO * peak
|
||
):
|
||
return "abrupt_end"
|
||
|
||
# Mid-cycle restart / fragmentation: a long internal near-zero run that
|
||
# resumes before the tail indicates two cycles merged into one. A sampling
|
||
# gap larger than the outage ceiling is a sensor dropout, not a genuine dead
|
||
# run, so the in-progress low run is abandoned across it -- otherwise a valid
|
||
# cycle that merely lost its plug for a while gets mis-flagged as a merged
|
||
# restart (mirrors the outage guard in _suggest_end_repeat_count).
|
||
max_gap_s = _MAX_PAUSE_GAP_H * 3600
|
||
dead_start: float | None = None
|
||
prev_t: float | None = None
|
||
for t, p in readings:
|
||
if prev_t is not None and (t - prev_t) > max_gap_s:
|
||
dead_start = None
|
||
prev_t = t
|
||
if p < active_thr:
|
||
if dead_start is None:
|
||
dead_start = t
|
||
else:
|
||
if dead_start is not None:
|
||
run = t - dead_start
|
||
end_frac = (t - t0) / duration if duration > 0 else 1.0
|
||
if run >= _CLEAN_MID_RESTART_MIN_S and end_frac <= _CLEAN_MID_RESTART_END_GUARD:
|
||
return "mid_restart"
|
||
dead_start = None
|
||
return None
|
||
|
||
|
||
def select_clean_cycles(
|
||
cycles: list[dict[str, Any]],
|
||
*,
|
||
stop_threshold_w: float = 2.0,
|
||
require_label: bool = False,
|
||
) -> tuple[list[dict[str, Any]], dict[str, int]]:
|
||
"""Keep only correctly-detected cycles for suggestion learning.
|
||
|
||
Systematically drops cycles we can tell are wrong: force-stopped or
|
||
interrupted runs, noise, and traces that show a high start, an abrupt end,
|
||
or a mid-cycle restart. Cycles without a power trace are kept when their
|
||
duration is plausible (we cannot inspect them, but they are not *known* bad).
|
||
|
||
Returns ``(clean_cycles, exclusion_counts)`` where the counts map an
|
||
exclusion reason to how many cycles it removed (for transparent reason
|
||
strings in the suggestions).
|
||
"""
|
||
clean: list[dict[str, Any]] = []
|
||
excluded: dict[str, int] = {}
|
||
|
||
def _bump(reason: str) -> None:
|
||
excluded[reason] = excluded.get(reason, 0) + 1
|
||
|
||
for c in cycles:
|
||
if not isinstance(c, dict):
|
||
continue
|
||
|
||
status = c.get("status")
|
||
state = c.get("state")
|
||
if status == "force_stopped":
|
||
_bump("force_stopped")
|
||
continue
|
||
if status == "interrupted" or state == "interrupted":
|
||
_bump("interrupted")
|
||
continue
|
||
if not (status == "completed" or state == "completed"):
|
||
_bump("incomplete")
|
||
continue
|
||
|
||
label = c.get("profile_name") or c.get("label")
|
||
if isinstance(label, str) and label.lower() == "noise":
|
||
_bump("noise")
|
||
continue
|
||
if require_label and not (isinstance(label, str) and label):
|
||
_bump("unlabeled")
|
||
continue
|
||
|
||
try:
|
||
duration = float(c.get("duration") or 0.0)
|
||
except (TypeError, ValueError):
|
||
duration = 0.0
|
||
|
||
readings = _cycle_readings(c)
|
||
if not readings:
|
||
# No usable trace: cannot inspect health. Keep if the duration is
|
||
# plausible, otherwise it is almost certainly a ghost/noise entry.
|
||
if duration >= _CLEAN_MIN_DURATION_S:
|
||
clean.append(c)
|
||
else:
|
||
_bump("no_trace_short")
|
||
continue
|
||
|
||
if duration <= 0:
|
||
duration = readings[-1][0] - readings[0][0]
|
||
|
||
reason = _classify_cycle_health(readings, duration, stop_threshold_w)
|
||
if reason is not None:
|
||
_bump(reason)
|
||
continue
|
||
clean.append(c)
|
||
|
||
return clean, excluded
|
||
|
||
|
||
def _format_exclusions(excluded: dict[str, int]) -> str:
|
||
"""English exclusion note for the suggestion ``reason`` fallback string.
|
||
|
||
The localized rendering is done client-side from :func:`_exclusion_summary`
|
||
(the reason *codes* are translated in the panel); this English text is only the
|
||
fallback shown when a translation is unavailable.
|
||
"""
|
||
total = sum(excluded.values())
|
||
if not total:
|
||
return ""
|
||
top = sorted(excluded.items(), key=lambda kv: -kv[1])[:3]
|
||
parts = ", ".join(f"{n} {reason.replace('_', ' ')}" for reason, n in top)
|
||
return f" Excluded {total} mis-detected cycle(s): {parts}."
|
||
|
||
|
||
def _exclusion_summary(excluded: dict[str, int]) -> dict[str, Any]:
|
||
"""Structured counterpart of :func:`_format_exclusions` for client localization.
|
||
|
||
Returns ``{"total": int, "items": [[reason_code, count], ...]}`` (top 3 reasons,
|
||
most-frequent first) so the panel can translate each reason code and assemble a
|
||
localized note. Empty dict when nothing was excluded.
|
||
"""
|
||
total = sum(excluded.values())
|
||
if not total:
|
||
return {}
|
||
top = sorted(excluded.items(), key=lambda kv: -kv[1])[:3]
|
||
return {"total": total, "items": [[reason, int(n)] for reason, n in top]}
|
||
|
||
|
||
# ─── Parameter interdependency reconciliation (Stage 5g) ──────────────────────
|
||
# Suggestions are produced by several independent passes, so a value for one
|
||
# parameter can silently contradict another (e.g. a start threshold below the
|
||
# stop threshold, or an off_delay longer than the cycle-separation gap). This
|
||
# pass takes the full suggestion set plus the current option values and nudges
|
||
# any *suggested* value so the coupled invariants hold, recording why.
|
||
|
||
|
||
def _num(value: Any) -> float | None:
|
||
# Reject bool first: bool is a subclass of int, so the old combined guard
|
||
# let True/False fall through to float() and coerce to 1.0/0.0.
|
||
if isinstance(value, bool):
|
||
return None
|
||
if not isinstance(value, (int, float, str)):
|
||
return None
|
||
try:
|
||
result = float(value)
|
||
except (TypeError, ValueError, OverflowError):
|
||
# OverflowError: huge integer strings like "1e100000" / 10**10000.
|
||
return None
|
||
# Reject NaN/inf (e.g. from a malformed "nan"/"inf" string or bad option)
|
||
# so they can't poison the invariant arithmetic downstream.
|
||
return result if np.isfinite(result) else None
|
||
|
||
|
||
def reconcile_suggestions(
|
||
suggestions: dict[str, Any],
|
||
current: dict[str, Any],
|
||
) -> tuple[dict[str, Any], set[str]]:
|
||
"""Enforce cross-parameter invariants over a suggestion map.
|
||
|
||
Runs a direction-aware fixpoint loop with cascade-create. When fixing a
|
||
conflict requires adjusting a key that was not originally proposed by the
|
||
engine, a *cascade* entry is created (``"cascade": True``) so the returned
|
||
map is a *coherent, jointly-valid* set of suggested values.
|
||
|
||
Direction follows the dependency hierarchy: the more-fundamental setting
|
||
anchors; the derived setting yields.
|
||
- ``start_threshold_w`` is the detection trigger (primary).
|
||
- ``stop_threshold_w`` is derived from start (must stay below it).
|
||
- ``min_power`` is a display floor (derived from stop).
|
||
Rules that straddle two original suggestions prefer adjusting the derived
|
||
(lower-priority) side. Rules that affect only one original suggestion
|
||
cascade-create an entry for the other so the full set is self-consistent.
|
||
|
||
A cascade-created entry is NOT written if neither key in the constraint is
|
||
in ``out`` (live-vs-live conflicts are the frontend's responsibility).
|
||
"""
|
||
out: dict[str, Any] = {k: dict(v) if isinstance(v, dict) else v for k, v in suggestions.items()}
|
||
# Track which keys the engine originally proposed — used for direction logic.
|
||
original_keys: frozenset[str] = frozenset(
|
||
k for k, v in out.items() if isinstance(v, dict) and v.get("value") is not None
|
||
)
|
||
all_changed: set[str] = set()
|
||
# Counts EVERY actual value change (not just distinct keys) so the fixpoint loop
|
||
# below keeps iterating when an already-changed key is adjusted again -- len(all_changed)
|
||
# alone would stall and break early on a repeated change to an existing key.
|
||
change_count = [0]
|
||
|
||
def eff(key: str) -> float | None:
|
||
entry = out.get(key)
|
||
if isinstance(entry, dict) and entry.get("value") is not None:
|
||
return _num(entry.get("value"))
|
||
return _num(current.get(key))
|
||
|
||
def is_original(key: str) -> bool:
|
||
return key in original_keys
|
||
|
||
def in_out(*keys: str) -> bool:
|
||
"""True if at least one key is already in the suggestion map (original or cascade)."""
|
||
return any(isinstance(out.get(k), dict) for k in keys)
|
||
|
||
def adjust(key: str, new_value: float, why: str) -> None:
|
||
"""Set a suggestion value; cascade-creates an entry when the key is absent."""
|
||
rounded = round(new_value, 2)
|
||
entry = out.get(key)
|
||
if isinstance(entry, dict):
|
||
if _num(entry.get("value")) == rounded:
|
||
return
|
||
entry["value"] = rounded
|
||
base = entry.get("reason", "")
|
||
entry["reason"] = f"{base} Adjusted to {rounded} for consistency with {why}.".strip()
|
||
# The composed English reason now differs from the base suggestion's
|
||
# localization key, so drop the sidecars — the panel falls back to the
|
||
# (updated) English ``reason``. Reconcile-composed reasons embed both a
|
||
# nested base reason and a "why" fragment, which the flat single-key
|
||
# _t() mechanism cannot recompose; leaving English here is correct.
|
||
entry.pop("reason_key", None)
|
||
entry.pop("reason_params", None)
|
||
elif entry is None:
|
||
out[key] = {
|
||
"value": rounded,
|
||
"reason": f"Adjusted to {rounded} for consistency with {why}.",
|
||
"cascade": True,
|
||
}
|
||
else:
|
||
return
|
||
all_changed.add(key)
|
||
change_count[0] += 1
|
||
|
||
for _iteration in range(8):
|
||
prev_count = change_count[0]
|
||
|
||
# ── Rule 1a: stop_threshold_w < start_threshold_w ─────────────────────
|
||
# start is more fundamental (the detection trigger); stop is derived.
|
||
# When start is the original suggestion → cascade stop downward.
|
||
# When start was not originally suggested → cascade start upward.
|
||
start = eff(CONF_START_THRESHOLD_W)
|
||
stop = eff(CONF_STOP_THRESHOLD_W)
|
||
if start is not None and stop is not None and start <= stop and in_out(CONF_START_THRESHOLD_W, CONF_STOP_THRESHOLD_W):
|
||
if is_original(CONF_START_THRESHOLD_W):
|
||
adjust(CONF_STOP_THRESHOLD_W, round(start * 0.8, 1), "the start threshold")
|
||
else:
|
||
adjust(CONF_START_THRESHOLD_W, round(max(stop + 0.5, stop * 1.25), 1), "the stop threshold")
|
||
stop = eff(CONF_STOP_THRESHOLD_W)
|
||
|
||
# ── Rule 1b: min_power <= stop_threshold_w ────────────────────────────
|
||
# min_power is a display floor; always yields to the stop threshold.
|
||
mp = eff(CONF_MIN_POWER)
|
||
if stop is not None and mp is not None and mp > stop and in_out(CONF_STOP_THRESHOLD_W, CONF_MIN_POWER):
|
||
adjust(CONF_MIN_POWER, round(stop * 0.8, 1), "the stop threshold")
|
||
|
||
# ── Rule 2: min_off_gap >= off_delay ──────────────────────────────────
|
||
# Always cascade-RAISE the gap to the off delay; never lower off_delay.
|
||
# Lowering off_delay makes end/pause detection more aggressive and can
|
||
# split a genuine multi-minute soak pause into two separate cycles. The
|
||
# detector already takes max(off_delay, min_off_gap) at runtime, so
|
||
# raising the gap is the safe (and no-op-at-runtime) direction; the
|
||
# smart_debounce coupling that a larger gap would otherwise inflate is
|
||
# bounded in cycle_detector.py.
|
||
min_gap = eff(CONF_MIN_OFF_GAP)
|
||
off_delay = eff(CONF_OFF_DELAY)
|
||
if off_delay is not None and min_gap is not None and min_gap < off_delay and in_out(CONF_MIN_OFF_GAP, CONF_OFF_DELAY):
|
||
adjust(CONF_MIN_OFF_GAP, off_delay, "the off delay")
|
||
|
||
# ── Rule 3a: watchdog_interval >= 2 × sampling_interval ───────────────
|
||
sampling = eff(CONF_SAMPLING_INTERVAL)
|
||
watchdog = eff(CONF_WATCHDOG_INTERVAL)
|
||
if sampling is not None and watchdog is not None and watchdog < 2.0 * sampling and in_out(CONF_SAMPLING_INTERVAL, CONF_WATCHDOG_INTERVAL):
|
||
adjust(CONF_WATCHDOG_INTERVAL, 2.0 * sampling + 1.0, "the sampling interval")
|
||
watchdog = eff(CONF_WATCHDOG_INTERVAL)
|
||
|
||
# ── Rule 3b: no_update_active_timeout > watchdog_interval ─────────────
|
||
timeout = eff(CONF_NO_UPDATE_ACTIVE_TIMEOUT)
|
||
if watchdog is not None and timeout is not None and timeout <= watchdog and in_out(CONF_WATCHDOG_INTERVAL, CONF_NO_UPDATE_ACTIVE_TIMEOUT):
|
||
adjust(CONF_NO_UPDATE_ACTIVE_TIMEOUT, round(watchdog * 2.0, 1), "the watchdog interval")
|
||
|
||
# ── Rule 4: start_duration_threshold >= sampling_interval ─────────────
|
||
start_dur = eff(CONF_START_DURATION_THRESHOLD)
|
||
if sampling is not None and start_dur is not None and start_dur < sampling and in_out(CONF_SAMPLING_INTERVAL, CONF_START_DURATION_THRESHOLD):
|
||
adjust(CONF_START_DURATION_THRESHOLD, sampling, "the sampling interval")
|
||
|
||
# ── Rule 5: learning_confidence <= match_threshold <= auto_label ───────
|
||
# Reconcile top-down (match<=auto first) so a lower fix cannot re-break
|
||
# the ordering already set above.
|
||
match_thr = eff(CONF_PROFILE_MATCH_THRESHOLD)
|
||
auto = eff(CONF_AUTO_LABEL_CONFIDENCE)
|
||
if match_thr is not None and auto is not None and match_thr > auto and in_out(CONF_PROFILE_MATCH_THRESHOLD, CONF_AUTO_LABEL_CONFIDENCE):
|
||
adjust(CONF_PROFILE_MATCH_THRESHOLD, auto, "the auto-label confidence")
|
||
match_thr = eff(CONF_PROFILE_MATCH_THRESHOLD)
|
||
learn = eff(CONF_LEARNING_CONFIDENCE)
|
||
if learn is not None and match_thr is not None and learn > match_thr and in_out(CONF_LEARNING_CONFIDENCE, CONF_PROFILE_MATCH_THRESHOLD):
|
||
adjust(CONF_LEARNING_CONFIDENCE, match_thr, "the profile match threshold")
|
||
|
||
# ── Rule 6: profile_unmatch_threshold < profile_match_threshold ────────
|
||
unmatch = eff(CONF_PROFILE_UNMATCH_THRESHOLD)
|
||
match_thr2 = eff(CONF_PROFILE_MATCH_THRESHOLD)
|
||
if unmatch is not None and match_thr2 is not None and unmatch >= match_thr2 and in_out(CONF_PROFILE_UNMATCH_THRESHOLD, CONF_PROFILE_MATCH_THRESHOLD):
|
||
adjust(CONF_PROFILE_UNMATCH_THRESHOLD, round(match_thr2 - 0.05, 2), "the profile match threshold")
|
||
|
||
# ── Rule 7: power_off_threshold_w < stop_threshold_w (when > 0) ───────
|
||
pot = eff(CONF_POWER_OFF_THRESHOLD_W)
|
||
stop_eff = eff(CONF_STOP_THRESHOLD_W)
|
||
if pot is not None and pot > 0.0 and stop_eff is not None and pot >= stop_eff and in_out(CONF_POWER_OFF_THRESHOLD_W, CONF_STOP_THRESHOLD_W):
|
||
adjust(CONF_POWER_OFF_THRESHOLD_W, round(stop_eff * 0.6, 1), "the stop threshold")
|
||
|
||
# ── Rule 8: anti_wrinkle_exit_power < stop_threshold_w ────────────────
|
||
# Anti-wrinkle only applies to washing machines, dryers, and washer-dryer
|
||
# combos; skip the constraint for all other device types.
|
||
_dt = current.get(CONF_DEVICE_TYPE)
|
||
_aw_eligible = _dt is None or _dt in {DEVICE_TYPE_WASHING_MACHINE, DEVICE_TYPE_DRYER, DEVICE_TYPE_WASHER_DRYER}
|
||
if _aw_eligible:
|
||
aw_exit = eff(CONF_ANTI_WRINKLE_EXIT_POWER)
|
||
if stop_eff is not None and aw_exit is not None and aw_exit >= stop_eff and in_out(CONF_ANTI_WRINKLE_EXIT_POWER, CONF_STOP_THRESHOLD_W):
|
||
adjust(CONF_ANTI_WRINKLE_EXIT_POWER, round(stop_eff * 0.4, 1), "the stop threshold")
|
||
|
||
# ── Rule 9: anti_wrinkle_max_power > start_threshold_w ────────────────
|
||
if _aw_eligible:
|
||
aw_max = eff(CONF_ANTI_WRINKLE_MAX_POWER)
|
||
start_eff = eff(CONF_START_THRESHOLD_W)
|
||
if aw_max is not None and start_eff is not None and aw_max <= start_eff and in_out(CONF_ANTI_WRINKLE_MAX_POWER, CONF_START_THRESHOLD_W):
|
||
adjust(CONF_ANTI_WRINKLE_MAX_POWER, round(start_eff * 2.0, 1), "the start threshold")
|
||
|
||
# ── Rule 10: pump_stuck_duration < no_update_active_timeout ───────────
|
||
# Pump stuck detection is only relevant for pump/sump-pump device types.
|
||
if _dt is None or _dt == DEVICE_TYPE_PUMP:
|
||
pump_stuck = eff(CONF_PUMP_STUCK_DURATION)
|
||
no_upd = eff(CONF_NO_UPDATE_ACTIVE_TIMEOUT)
|
||
if pump_stuck is not None and no_upd is not None and no_upd <= pump_stuck and in_out(CONF_PUMP_STUCK_DURATION, CONF_NO_UPDATE_ACTIVE_TIMEOUT):
|
||
adjust(CONF_NO_UPDATE_ACTIVE_TIMEOUT, round(pump_stuck + 60.0), "the pump stuck duration")
|
||
|
||
# ── Rule 11: min_duration_ratio < max_duration_ratio ──────────────────
|
||
# Direction: when min_ratio is the original anchor (raised), max must
|
||
# rise to stay above it. Otherwise lower min to stay below max.
|
||
min_r = eff(CONF_PROFILE_MATCH_MIN_DURATION_RATIO)
|
||
max_r = eff(CONF_PROFILE_MATCH_MAX_DURATION_RATIO)
|
||
if min_r is not None and max_r is not None and min_r >= max_r and in_out(CONF_PROFILE_MATCH_MIN_DURATION_RATIO, CONF_PROFILE_MATCH_MAX_DURATION_RATIO):
|
||
if is_original(CONF_PROFILE_MATCH_MIN_DURATION_RATIO):
|
||
adjust(CONF_PROFILE_MATCH_MAX_DURATION_RATIO, round(min_r * 2.0, 2), "the min duration ratio")
|
||
else:
|
||
adjust(CONF_PROFILE_MATCH_MIN_DURATION_RATIO, round(max_r * 0.5, 2), "the max duration ratio")
|
||
|
||
if change_count[0] == prev_count:
|
||
break
|
||
|
||
return out, all_changed
|
||
|
||
if TYPE_CHECKING:
|
||
from .profile_store import ProfileStore
|
||
|
||
_LOGGER = logging.getLogger(__name__)
|
||
|
||
|
||
def _parse_ts(v: Any) -> float | None:
|
||
"""Parse a value into a unix timestamp float, supporting ISO strings."""
|
||
if isinstance(v, str):
|
||
try:
|
||
return datetime.fromisoformat(v.replace("Z", "+00:00")).timestamp()
|
||
except ValueError:
|
||
return None
|
||
return None
|
||
|
||
|
||
class SuggestionEngine:
|
||
"""Refined engine for generating data-driven parameter suggestions."""
|
||
|
||
def __init__(
|
||
self,
|
||
hass: HomeAssistant,
|
||
entry_id: str,
|
||
profile_store: "ProfileStore",
|
||
device_type: str | None = None,
|
||
) -> None:
|
||
"""Initialize the suggestion engine."""
|
||
self.hass = hass
|
||
self.entry_id = entry_id
|
||
self.profile_store = profile_store
|
||
self.device_type = device_type
|
||
|
||
def generate_operational_suggestions(self, p95_dt: float, median_dt: float) -> dict[str, Any]:
|
||
"""Generate suggestions for operational parameters based on cadence."""
|
||
suggestions: dict[str, dict[str, Any]] = {}
|
||
|
||
# 1. Watchdog Interval
|
||
# Goal: as LOW (responsive) as safely possible so a stalled cycle is
|
||
# caught quickly. The only hard constraint is that it must sit above the
|
||
# normal update cadence to avoid false stops, so target just above p95
|
||
# (3x) rather than a very conservative multiple.
|
||
suggested_watchdog = int(max(30, round(p95_dt * 3)))
|
||
suggestions[CONF_WATCHDOG_INTERVAL] = {
|
||
"value": suggested_watchdog,
|
||
"reason": f"Kept as low as safe (3x the p95 update gap of {p95_dt:.1f}s, min 30s) so stalls are caught quickly without false stops.",
|
||
"reason_key": "suggestion.reason.watchdog",
|
||
"reason_params": {"p95": f"{p95_dt:.1f}"},
|
||
}
|
||
|
||
# 2. No Update Timeout
|
||
suggested_timeout = int(max(60, p95_dt * 20))
|
||
suggestions[CONF_NO_UPDATE_ACTIVE_TIMEOUT] = {
|
||
"value": suggested_timeout,
|
||
"reason": f"Based on observed update cadence (p95={p95_dt:.1f}s) * 20 (min 60s).",
|
||
"reason_key": "suggestion.reason.no_update_timeout",
|
||
"reason_params": {"p95": f"{p95_dt:.1f}"},
|
||
}
|
||
|
||
# 3. Off Delay
|
||
# Use device-specific default as floor to prevent splitting cycles with long pauses
|
||
device_floor = (
|
||
DEFAULT_OFF_DELAY_BY_DEVICE.get(self.device_type, DEFAULT_OFF_DELAY)
|
||
if self.device_type is not None
|
||
else DEFAULT_OFF_DELAY
|
||
)
|
||
|
||
# Prefer real intra-cycle pause analysis: off_delay must outlast the
|
||
# longest genuine pause or a single cycle gets split in two. The update
|
||
# cadence only sets a lower sanity bound, so fall back to it when we do
|
||
# not yet have enough traces to measure pauses.
|
||
raw_cycles = self.profile_store.get_past_cycles()[-100:]
|
||
stop_thr = self._current_stop_threshold(self._entry_options())
|
||
clean, _excl = select_clean_cycles(raw_cycles, stop_threshold_w=stop_thr)
|
||
pause_based = self._suggest_off_delay_from_pauses(clean, stop_thr, device_floor)
|
||
|
||
reason_off_key: str | None = None
|
||
reason_off_params: dict[str, Any] | None = None
|
||
if pause_based is not None:
|
||
suggested_off_delay, reason_off, reason_off_key, reason_off_params = pause_based
|
||
else:
|
||
suggested_off_delay = int(max(device_floor, p95_dt * 5))
|
||
reason_off = f"Based on observed update cadence (p95={p95_dt:.1f}s) * 5"
|
||
reason_off_key = "suggestion.reason.off_delay_cadence"
|
||
reason_off_params = {"p95": f"{p95_dt:.1f}"}
|
||
if suggested_off_delay == device_floor:
|
||
if self.device_type and self.device_type in DEFAULT_OFF_DELAY_BY_DEVICE:
|
||
reason_off = (
|
||
f"Used device-specific safe minimum for {self.device_type} ({device_floor}s)."
|
||
)
|
||
reason_off_key = "suggestion.reason.off_delay_device_floor"
|
||
reason_off_params = {"device": self.device_type, "floor": device_floor}
|
||
else:
|
||
reason_off = f"Used generic safe minimum ({DEFAULT_OFF_DELAY}s)."
|
||
reason_off_key = "suggestion.reason.off_delay_generic_floor"
|
||
reason_off_params = {"floor": DEFAULT_OFF_DELAY}
|
||
|
||
suggestions[CONF_OFF_DELAY] = {
|
||
"value": suggested_off_delay,
|
||
"reason": reason_off,
|
||
"reason_key": reason_off_key,
|
||
"reason_params": reason_off_params,
|
||
}
|
||
|
||
# 4. Profile Match Interval
|
||
suggested_match = int(max(10, median_dt * 10))
|
||
suggestions[CONF_PROFILE_MATCH_INTERVAL] = {
|
||
"value": suggested_match,
|
||
"reason": f"Based on observed update cadence (median={median_dt:.1f}s) * 10.",
|
||
"reason_key": "suggestion.reason.match_interval",
|
||
"reason_params": {"median": f"{median_dt:.1f}"},
|
||
}
|
||
|
||
return suggestions
|
||
|
||
def generate_model_suggestions(self) -> dict[str, Any]:
|
||
"""Generate suggestions for model parameters based on past cycles."""
|
||
suggestions: dict[str, dict[str, Any]] = {}
|
||
|
||
raw_cycles = self.profile_store.get_past_cycles()[-100:]
|
||
stop_thr = self._current_stop_threshold(self._entry_options())
|
||
cycles, _excluded = select_clean_cycles(raw_cycles, stop_threshold_w=stop_thr)
|
||
profiles = self.profile_store.get_profiles()
|
||
|
||
ratios: list[float] = []
|
||
ratios_by_profile: dict[str, list[float]] = {}
|
||
for c in cycles:
|
||
if not isinstance(c, dict):
|
||
continue
|
||
profile_name = c.get("profile_name")
|
||
if not isinstance(profile_name, str) or c.get("status") == "interrupted":
|
||
continue
|
||
prof = profiles.get(profile_name)
|
||
if not isinstance(prof, dict):
|
||
continue
|
||
try:
|
||
avg = float(prof.get("avg_duration") or 0.0)
|
||
dur = float(c.get("duration") or 0.0)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if avg > 60 and dur > 60:
|
||
r = dur / avg
|
||
ratios.append(r)
|
||
ratios_by_profile.setdefault(profile_name, []).append(r)
|
||
|
||
if len(ratios) >= 10:
|
||
arr: np.ndarray[Any, np.dtype[np.float64]] = np.array(ratios, dtype=float)
|
||
|
||
# Per-profile tolerance: each profile contributes its own p95
|
||
# duration deviation, so a tight profile is not penalised by a loose
|
||
# one. The global suggestion is the p75 across profiles (covers most
|
||
# without over-widening). Falls back to the pooled p95 when no
|
||
# profile has enough cycles for its own estimate.
|
||
per_profile_p95: list[float] = []
|
||
for _pname, prs in ratios_by_profile.items():
|
||
if len(prs) >= 2:
|
||
devs = np.abs(np.array(prs, dtype=float) - 1.0)
|
||
per_profile_p95.append(float(np.percentile(devs, 95)))
|
||
if per_profile_p95:
|
||
agg_dev = float(np.percentile(per_profile_p95, 75))
|
||
reason_tol = (
|
||
f"p75 of per-profile duration variance across "
|
||
f"{len(per_profile_p95)} profiles ({len(ratios)} cycles); "
|
||
f"tight profiles not penalised."
|
||
)
|
||
reason_tol_key = "suggestion.reason.tol_per_profile"
|
||
reason_tol_params: dict[str, Any] = {
|
||
"profiles": len(per_profile_p95),
|
||
"cycles": len(ratios),
|
||
}
|
||
else:
|
||
agg_dev = float(np.percentile(np.abs(arr - 1.0), 95))
|
||
reason_tol = (
|
||
f"Based on pooled duration variance of {len(ratios)} recent "
|
||
f"labeled cycles (p95 dev={agg_dev:.2f})."
|
||
)
|
||
reason_tol_key = "suggestion.reason.tol_pooled"
|
||
reason_tol_params = {"cycles": len(ratios), "dev": f"{agg_dev:.2f}"}
|
||
|
||
suggested_tol = min(0.50, max(0.10, round(agg_dev + 0.05, 2)))
|
||
|
||
suggestions[CONF_DURATION_TOLERANCE] = {
|
||
"value": suggested_tol,
|
||
"reason": reason_tol,
|
||
"reason_key": reason_tol_key,
|
||
"reason_params": reason_tol_params,
|
||
}
|
||
suggestions[CONF_PROFILE_DURATION_TOLERANCE] = {
|
||
"value": suggested_tol,
|
||
"reason": reason_tol,
|
||
"reason_key": reason_tol_key,
|
||
"reason_params": reason_tol_params,
|
||
}
|
||
|
||
p95_ratio = float(np.percentile(arr, 95))
|
||
|
||
# min_duration_ratio governs how EARLY a running cycle may match a
|
||
# profile. Goal: as low as possible so a program is recognised ASAP.
|
||
# It is not bounded by full-cycle duration variance - the confidence
|
||
# and ambiguity gates already prevent premature commits - so keep it
|
||
# aggressively low rather than tied to p05 of observed durations.
|
||
min_r = 0.05
|
||
max_r = min(3.0, round(p95_ratio + 0.1, 2))
|
||
|
||
if min_r < max_r - 0.2:
|
||
suggestions[CONF_PROFILE_MATCH_MIN_DURATION_RATIO] = {
|
||
"value": min_r,
|
||
"reason": "Kept as low as possible so a program is recognised early in the cycle; the confidence and ambiguity gates prevent premature commits.",
|
||
"reason_key": "suggestion.reason.min_duration_ratio",
|
||
"reason_params": {},
|
||
}
|
||
suggestions[CONF_PROFILE_MATCH_MAX_DURATION_RATIO] = {
|
||
"value": max_r,
|
||
"reason": f"Based on labeled cycle durations (p95={p95_ratio:.2f}).",
|
||
"reason_key": "suggestion.reason.max_duration_ratio",
|
||
"reason_params": {"p95": f"{p95_ratio:.2f}"},
|
||
}
|
||
|
||
# Min-off-gap: derived from observed inter-cycle gaps
|
||
min_off_gap = self._suggest_min_off_gap(cycles)
|
||
if min_off_gap is not None:
|
||
suggestions[CONF_MIN_OFF_GAP] = min_off_gap
|
||
|
||
return suggestions
|
||
|
||
def _entry_options(self) -> dict[str, Any]:
|
||
"""Best-effort read of the current config entry options."""
|
||
try:
|
||
entry = self.hass.config_entries.async_get_entry(self.entry_id)
|
||
except Exception: # pylint: disable=broad-exception-caught
|
||
return {}
|
||
if entry is None:
|
||
return {}
|
||
return {**entry.data, **entry.options}
|
||
|
||
def _current_stop_threshold(self, options: dict[str, Any]) -> float:
|
||
"""Resolve the effective stop/off power threshold for clean-cycle checks."""
|
||
for key in (CONF_STOP_THRESHOLD_W, CONF_MIN_POWER):
|
||
raw = options.get(key)
|
||
try:
|
||
val = float(raw)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if val > 0:
|
||
return val
|
||
return 2.0
|
||
|
||
def generate_detection_suggestions(self) -> dict[str, Any]:
|
||
"""Statistical suggestions for detection/model settings not covered by
|
||
the operational or model passes.
|
||
|
||
Learns exclusively from *clean* cycles (see :func:`select_clean_cycles`)
|
||
so that mis-detected runs never skew the recommendations. Every block is
|
||
independently gated on a minimum sample size, so early on the method
|
||
simply returns whatever it can compute confidently.
|
||
"""
|
||
options = self._entry_options()
|
||
stop_thr = self._current_stop_threshold(options)
|
||
|
||
all_cycles = self.profile_store.get_past_cycles()[-200:]
|
||
clean, excluded = select_clean_cycles(all_cycles, stop_threshold_w=stop_thr)
|
||
if len(clean) < 5:
|
||
return {}
|
||
excl_note = _format_exclusions(excluded)
|
||
excl_summary = _exclusion_summary(excluded)
|
||
|
||
suggestions: dict[str, dict[str, Any]] = {}
|
||
|
||
# --- Observed sampling interval (drives smoothing + start debounce) ---
|
||
sampling_vals: list[float] = []
|
||
for c in clean:
|
||
try:
|
||
si = float(c.get("sampling_interval") or 0.0)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if si > 0:
|
||
sampling_vals.append(si)
|
||
observed_si: float | None = None
|
||
if len(sampling_vals) >= 5:
|
||
observed_si = float(np.median(sampling_vals))
|
||
suggestions[CONF_SAMPLING_INTERVAL] = {
|
||
"value": round(observed_si, 1),
|
||
"reason": (
|
||
f"Median update interval observed across {len(sampling_vals)} "
|
||
f"clean cycles ({observed_si:.1f}s).{excl_note}"
|
||
),
|
||
"reason_key": "suggestion.reason.sampling_interval",
|
||
"reason_params": {
|
||
"cycles": len(sampling_vals),
|
||
"si": f"{observed_si:.1f}",
|
||
"excl": excl_note,
|
||
},
|
||
"exclusions": excl_summary,
|
||
}
|
||
|
||
si_for_calc = observed_si if observed_si else DEFAULT_SAMPLING_INTERVAL
|
||
|
||
# --- Smoothing window: ~30 s of readings ---
|
||
suggested_smooth = int(min(15, max(2, round(30.0 / max(si_for_calc, 1.0)))))
|
||
suggestions[CONF_SMOOTHING_WINDOW] = {
|
||
"value": suggested_smooth,
|
||
"reason": (
|
||
f"Sized to smooth ~30s of readings at {si_for_calc:.0f}s sampling "
|
||
f"({suggested_smooth} samples)."
|
||
),
|
||
"reason_key": "suggestion.reason.smoothing_window",
|
||
"reason_params": {"si": f"{si_for_calc:.0f}", "samples": suggested_smooth},
|
||
}
|
||
|
||
# --- Start debounce ---
|
||
# Goal: begin capturing a cycle as soon as possible. Set to one sampling
|
||
# interval - the minimum that still needs a sustained (not single-sample
|
||
# transient) reading to confirm a start.
|
||
suggested_start_dur = round(max(2.0, si_for_calc), 1)
|
||
suggestions[CONF_START_DURATION_THRESHOLD] = {
|
||
"value": suggested_start_dur,
|
||
"reason": (
|
||
f"Kept short (~one {si_for_calc:.0f}s sample interval) so detection "
|
||
f"starts as early as possible while still ignoring single-sample spikes."
|
||
),
|
||
"reason_key": "suggestion.reason.start_duration",
|
||
"reason_params": {"si": f"{si_for_calc:.0f}"},
|
||
}
|
||
|
||
# --- min_power: keep the noise gate below the lowest genuine draw ---
|
||
lowest_active: list[float] = []
|
||
for c in clean:
|
||
readings = _cycle_readings(c)
|
||
if len(readings) < 5:
|
||
continue
|
||
active = np.array([p for _, p in readings if p > 0.5])
|
||
if active.size:
|
||
lowest_active.append(float(np.min(active)))
|
||
if len(lowest_active) >= 5:
|
||
p05 = float(np.percentile(lowest_active, 5))
|
||
suggested_mp = round(min(max(p05 * 0.4, 1.0), 10.0), 1)
|
||
suggestions[CONF_MIN_POWER] = {
|
||
"value": suggested_mp,
|
||
"reason": (
|
||
f"40% of the p05 lowest active power ({p05:.1f}W) across "
|
||
f"{len(lowest_active)} clean cycles, keeping the off-gate below "
|
||
f"real draw.{excl_note}"
|
||
),
|
||
"reason_key": "suggestion.reason.min_power",
|
||
"reason_params": {
|
||
"p05": f"{p05:.1f}",
|
||
"cycles": len(lowest_active),
|
||
"excl": excl_note,
|
||
},
|
||
"exclusions": excl_summary,
|
||
}
|
||
|
||
# --- completion_min_seconds: filter ghosts below half the shortest run ---
|
||
durations = [
|
||
float(c["duration"])
|
||
for c in clean
|
||
if isinstance(c.get("duration"), (int, float))
|
||
and not isinstance(c.get("duration"), bool)
|
||
and float(c["duration"]) > 0
|
||
]
|
||
if len(durations) >= 10:
|
||
p05d = float(np.percentile(durations, 5))
|
||
suggested_cms = int(max(120, round(p05d * 0.5)))
|
||
suggestions[CONF_COMPLETION_MIN_SECONDS] = {
|
||
"value": suggested_cms,
|
||
"reason": (
|
||
f"Half the p05 clean-cycle duration ({p05d / 60:.0f} min) across "
|
||
f"{len(durations)} cycles; filters ghost cycles.{excl_note}"
|
||
),
|
||
"reason_key": "suggestion.reason.completion_min_seconds",
|
||
"reason_params": {
|
||
"minutes": f"{p05d / 60:.0f}",
|
||
"cycles": len(durations),
|
||
"excl": excl_note,
|
||
},
|
||
"exclusions": excl_summary,
|
||
}
|
||
|
||
# --- Confidence-calibrated thresholds (labeled clean cycles only) ---
|
||
self._add_confidence_suggestions(clean, suggestions)
|
||
|
||
# --- end_repeat_count: false-end pressure ---
|
||
erc = self._suggest_end_repeat_count(clean, stop_thr)
|
||
if erc is not None:
|
||
suggestions[CONF_END_REPEAT_COUNT] = erc
|
||
|
||
return suggestions
|
||
|
||
def _add_confidence_suggestions(
|
||
self, clean: list[dict[str, Any]], suggestions: dict[str, dict[str, Any]]
|
||
) -> None:
|
||
"""Derive confidence thresholds from the match_confidence distribution.
|
||
|
||
Uses the ``label_source`` provenance (auto vs manual) so we can tell
|
||
which cycles the user trusted. Auto-labels the user never corrected are
|
||
the ground truth for "matching was reliable at this confidence".
|
||
"""
|
||
manual_conf: list[float] = []
|
||
auto_ok_conf: list[float] = []
|
||
for c in clean:
|
||
raw_conf = c.get("match_confidence")
|
||
if (
|
||
not isinstance(raw_conf, (int, float))
|
||
or isinstance(raw_conf, bool)
|
||
or raw_conf <= 0
|
||
):
|
||
continue
|
||
conf = float(raw_conf)
|
||
src = c.get("label_source")
|
||
if src == "manual":
|
||
manual_conf.append(conf)
|
||
elif src in ("auto_match", "auto_label_post", "auto_label_service") and not c.get(
|
||
"original_auto_label"
|
||
):
|
||
auto_ok_conf.append(conf)
|
||
|
||
if len(manual_conf) >= 10:
|
||
p05c = float(np.percentile(manual_conf, 5))
|
||
suggestions[CONF_LEARNING_CONFIDENCE] = {
|
||
"value": round(min(max(p05c, 0.3), 0.9), 2),
|
||
"reason": (
|
||
f"p05 confidence of {len(manual_conf)} user-labeled cycles "
|
||
f"({p05c:.2f}); below this, request verification."
|
||
),
|
||
"reason_key": "suggestion.reason.learning_confidence",
|
||
"reason_params": {"cycles": len(manual_conf), "p05": f"{p05c:.2f}"},
|
||
}
|
||
|
||
if len(auto_ok_conf) >= 15:
|
||
p15 = float(np.percentile(auto_ok_conf, 15))
|
||
suggestions[CONF_AUTO_LABEL_CONFIDENCE] = {
|
||
"value": round(min(max(p15, 0.5), 0.98), 2),
|
||
"reason": (
|
||
f"15th-percentile confidence of {len(auto_ok_conf)} auto-labels "
|
||
f"the user never corrected ({p15:.2f})."
|
||
),
|
||
"reason_key": "suggestion.reason.auto_label_confidence",
|
||
"reason_params": {"cycles": len(auto_ok_conf), "p15": f"{p15:.2f}"},
|
||
}
|
||
p10 = float(np.percentile(auto_ok_conf, 10))
|
||
suggestions[CONF_PROFILE_MATCH_THRESHOLD] = {
|
||
"value": round(min(max(p10, 0.3), 0.9), 2),
|
||
"reason": (
|
||
f"p10 confidence of {len(auto_ok_conf)} correct auto-labels "
|
||
f"({p10:.2f}); safe live-commit floor."
|
||
),
|
||
"reason_key": "suggestion.reason.profile_match_threshold",
|
||
"reason_params": {"cycles": len(auto_ok_conf), "p10": f"{p10:.2f}"},
|
||
}
|
||
|
||
def _suggest_end_repeat_count(
|
||
self, clean: list[dict[str, Any]], stop_threshold_w: float
|
||
) -> dict[str, Any] | None:
|
||
"""Recommend how many end confirmations to require, from false-end rate.
|
||
|
||
A "false end" is an internal low-power run (>= 60 s) that resumed - the
|
||
kind of pause that can trip a premature cycle end. If many clean cycles
|
||
contain one, requiring extra end confirmations avoids splitting cycles.
|
||
"""
|
||
n_total = 0
|
||
n_false_end = 0
|
||
for c in clean:
|
||
readings = _cycle_readings(c)
|
||
if len(readings) < 10:
|
||
continue
|
||
n_total += 1
|
||
powers = [p for _, p in readings]
|
||
peak = max(powers) if powers else 0.0
|
||
if peak <= 0:
|
||
continue
|
||
active_thr = max(stop_threshold_w, _CLEAN_ACTIVE_FLOOR_RATIO * peak)
|
||
max_gap_s = _MAX_PAUSE_GAP_H * 3600
|
||
# A "false end" is a >=60 s internal quiet run that resumed into
|
||
# *sustained* activity. Reuse the shared pause locator so a brief
|
||
# terminal blip (a pump-out / drying tick after a soak) is absorbed
|
||
# back into the quiet tail rather than mis-counted as a resume -- the
|
||
# 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:
|
||
n_false_end += 1
|
||
break
|
||
|
||
if n_total < 15:
|
||
return None
|
||
frac = n_false_end / n_total
|
||
if frac >= 0.55:
|
||
val = 3
|
||
elif frac >= 0.30:
|
||
val = 2
|
||
else:
|
||
val = 1
|
||
return {
|
||
"value": val,
|
||
"reason": (
|
||
f"{n_false_end}/{n_total} clean cycles ({frac * 100:.0f}%) had a "
|
||
f">60s internal pause that resumed; require {val} end confirmation(s)."
|
||
),
|
||
"reason_key": "suggestion.reason.end_repeat_count",
|
||
"reason_params": {
|
||
"false": n_false_end,
|
||
"total": n_total,
|
||
"pct": f"{frac * 100:.0f}",
|
||
"val": val,
|
||
},
|
||
}
|
||
|
||
def _suggest_off_delay_from_pauses(
|
||
self,
|
||
cycles: list[dict[str, Any]],
|
||
stop_threshold_w: float,
|
||
device_floor: int,
|
||
) -> tuple[int, str, str, dict[str, Any]] | None:
|
||
"""Off-delay sized to outlast the longest genuine intra-cycle pause.
|
||
|
||
Collects every low-power segment that *resumed* (a proven pause, not the
|
||
trailing wind-down) across clean cycles and sets off_delay to the p95
|
||
pause length plus a 60 s buffer, floored by the device minimum. Returns
|
||
``None`` when too few traces exist, so the caller falls back to the
|
||
update-cadence heuristic.
|
||
"""
|
||
pause_durations: list[float] = []
|
||
n_traced = 0
|
||
max_gap_s = _MAX_PAUSE_GAP_H * 3600
|
||
for c in cycles:
|
||
readings = _cycle_readings(c)
|
||
if len(readings) < 10:
|
||
continue
|
||
n_traced += 1
|
||
powers = [p for _, p in readings]
|
||
peak = max(powers) if powers else 0.0
|
||
if peak <= 0:
|
||
continue
|
||
active_thr = max(stop_threshold_w, _CLEAN_ACTIVE_FLOOR_RATIO * peak)
|
||
# Genuine intra-cycle pauses only: a low run that resumed into
|
||
# sustained activity. A terminal drying/pump-out blip that does not
|
||
# 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
|
||
if run > 0:
|
||
pause_durations.append(run)
|
||
|
||
if n_traced < 5 or len(pause_durations) < 3:
|
||
return None
|
||
|
||
p95_pause = float(np.percentile(pause_durations, 95))
|
||
value = int(max(device_floor, round(p95_pause + 60.0)))
|
||
reason = (
|
||
f"Sized to outlast real pauses: p95 intra-cycle pause {p95_pause:.0f}s "
|
||
f"+ 60s buffer, from {len(pause_durations)} pauses across {n_traced} "
|
||
f"clean cycles (floor {device_floor}s)."
|
||
)
|
||
return (
|
||
value,
|
||
reason,
|
||
"suggestion.reason.off_delay_pauses",
|
||
{
|
||
"p95": f"{p95_pause:.0f}",
|
||
"pauses": len(pause_durations),
|
||
"cycles": n_traced,
|
||
"floor": device_floor,
|
||
},
|
||
)
|
||
|
||
def _suggest_min_off_gap(
|
||
self, cycles: list[dict[str, Any]]
|
||
) -> dict[str, Any] | None:
|
||
"""Derive a min_off_gap suggestion from observed inter-cycle gaps."""
|
||
# Only consider completed, labeled cycles with valid timestamps
|
||
timed_cycles: list[tuple[float, float]] = []
|
||
for c in cycles:
|
||
if not isinstance(c, dict):
|
||
continue
|
||
if c.get("status") not in ("completed", "force_stopped"):
|
||
continue
|
||
label = c.get("profile_name") or c.get("label")
|
||
if not label or label == "noise":
|
||
continue
|
||
try:
|
||
start = float(c["start_time"]) if isinstance(c.get("start_time"), (int, float)) and not isinstance(c.get("start_time"), bool) else None
|
||
end = float(c["end_time"]) if isinstance(c.get("end_time"), (int, float)) and not isinstance(c.get("end_time"), bool) else None
|
||
if start is None or end is None:
|
||
# Try ISO string parsing
|
||
start = _parse_ts(c.get("start_time"))
|
||
end = _parse_ts(c.get("end_time"))
|
||
if start is None or end is None or end <= start:
|
||
continue
|
||
timed_cycles.append((start, end))
|
||
except (TypeError, ValueError, KeyError):
|
||
continue
|
||
|
||
if len(timed_cycles) < 3:
|
||
return None
|
||
|
||
timed_cycles.sort(key=lambda x: x[0])
|
||
gaps: list[float] = []
|
||
for i in range(1, len(timed_cycles)):
|
||
gap = timed_cycles[i][0] - timed_cycles[i - 1][1]
|
||
if 30 <= gap <= 86400: # Only gaps between 30s and 1 day
|
||
gaps.append(gap)
|
||
|
||
if len(gaps) < 3:
|
||
return None
|
||
|
||
gaps_arr = np.array(gaps)
|
||
# Use the 5th-percentile gap as the safe minimum, with device-type floor
|
||
p05_gap = float(np.percentile(gaps_arr, 5))
|
||
device_floor = (
|
||
DEFAULT_MIN_OFF_GAP_BY_DEVICE.get(self.device_type, DEFAULT_MIN_OFF_GAP)
|
||
if self.device_type is not None
|
||
else DEFAULT_MIN_OFF_GAP
|
||
)
|
||
# Add a 20% safety margin so we never split a real gap into two cycles
|
||
suggested = int(max(device_floor, min(p05_gap * 0.8, 3600)))
|
||
# When the data-derived value is equal to the device floor, we have no
|
||
# useful signal to surface - return None to suppress a misleading suggestion.
|
||
if suggested == device_floor:
|
||
return None
|
||
reason = (
|
||
f"Based on {len(gaps)} observed inter-cycle gaps "
|
||
f"(p05={p05_gap:.0f}s). Device floor: {device_floor}s."
|
||
)
|
||
return {
|
||
"value": suggested,
|
||
"reason": reason,
|
||
"reason_key": "suggestion.reason.min_off_gap",
|
||
"reason_params": {
|
||
"gaps": len(gaps),
|
||
"p05": f"{p05_gap:.0f}",
|
||
"floor": device_floor,
|
||
},
|
||
}
|
||
|
||
def run_simulation(self, cycle_data: dict[str, Any]) -> dict[str, Any]:
|
||
"""Replay a single cycle with varied parameters to find optimal settings.
|
||
|
||
For richer, multi-cycle suggestions use :meth:`run_batch_simulation`.
|
||
"""
|
||
power_data_raw: Any = cycle_data.get("power_data", [])
|
||
if not isinstance(power_data_raw, list):
|
||
return {}
|
||
power_data = cast(list[list[float] | tuple[Any, float]], power_data_raw)
|
||
if len(power_data) < 10:
|
||
return {}
|
||
|
||
start_time_raw = cycle_data.get("start_time")
|
||
start_time_iso = (
|
||
start_time_raw if isinstance(start_time_raw, str) and start_time_raw else None
|
||
)
|
||
|
||
# Normalise power_data to [[offset_sec, power], ...] regardless of source format.
|
||
readings_list = power_data_to_offsets(power_data, start_time_iso)
|
||
|
||
readings: list[tuple[float, float]] = [
|
||
(float(offset), float(power)) for offset, power in readings_list
|
||
]
|
||
|
||
if not readings:
|
||
return {}
|
||
|
||
powers = np.array([p[1] for p in readings])
|
||
active_powers = powers[powers > 0.5]
|
||
|
||
if len(active_powers) < 5:
|
||
return {}
|
||
|
||
min_active = float(np.min(active_powers))
|
||
|
||
suggested_stop = round(min_active * 0.8, 2)
|
||
suggested_start = round(min_active * 1.2, 2)
|
||
|
||
# Energy suggestions
|
||
suggested_end_energy = 0.05
|
||
|
||
return {
|
||
CONF_STOP_THRESHOLD_W: {
|
||
"value": suggested_stop,
|
||
"reason": f"Based on minimum active power ({min_active:.1f}W) observed in last cycle.",
|
||
"reason_key": "suggestion.reason.min_active",
|
||
"reason_params": {"min": f"{min_active:.1f}"},
|
||
},
|
||
CONF_START_THRESHOLD_W: {
|
||
"value": suggested_start,
|
||
"reason": f"Based on minimum active power ({min_active:.1f}W) observed in last cycle.",
|
||
"reason_key": "suggestion.reason.min_active",
|
||
"reason_params": {"min": f"{min_active:.1f}"},
|
||
},
|
||
CONF_END_ENERGY_THRESHOLD: {
|
||
"value": suggested_end_energy,
|
||
"reason": "Default recommended baseline for end-of-cycle noise gate.",
|
||
"reason_key": "suggestion.reason.end_energy_default",
|
||
"reason_params": {},
|
||
},
|
||
}
|
||
|
||
def run_batch_simulation(self, cycles: list[dict[str, Any]]) -> dict[str, Any]:
|
||
"""Derive parameter suggestions from a collection of labeled cycles.
|
||
|
||
Unlike :meth:`run_simulation` (single-cycle heuristics), this method
|
||
aggregates statistics across *multiple* cycles for robustness:
|
||
|
||
- Power thresholds from the 5th-percentile minimum active power.
|
||
- Dead zone from the 75th-percentile of early dips across cycles.
|
||
- End-energy threshold from the maximum false-end energy seen.
|
||
- Min-off-gap from the 5th-percentile inter-cycle gap.
|
||
|
||
Returns an empty dict when fewer than ``_BATCH_MIN_CYCLES`` valid
|
||
cycles are provided.
|
||
|
||
Mis-detected cycles (force-stopped, high start, abrupt end, mid-cycle
|
||
restart) are dropped up front via :func:`select_clean_cycles` so they
|
||
cannot skew the derived thresholds.
|
||
"""
|
||
_BATCH_MIN_CYCLES = 5
|
||
|
||
stop_thr = self._current_stop_threshold(self._entry_options())
|
||
cycles, _excluded = select_clean_cycles(cycles, stop_threshold_w=stop_thr)
|
||
|
||
valid_cycles: list[list[tuple[float, float]]] = []
|
||
for c in cycles:
|
||
if not isinstance(c, dict):
|
||
continue
|
||
label = c.get("label") or c.get("profile_name")
|
||
if not isinstance(label, str) or not label:
|
||
continue
|
||
if label.lower() == "noise":
|
||
continue
|
||
if not (
|
||
c.get("state") == "completed"
|
||
or c.get("status") in ("completed", "force_stopped")
|
||
):
|
||
continue
|
||
raw = c.get("power_data")
|
||
if not isinstance(raw, list) or len(raw) < 5:
|
||
continue
|
||
start_iso = c.get("start_time") if isinstance(c.get("start_time"), str) else None
|
||
readings_list = power_data_to_offsets(
|
||
cast(list[list[float] | tuple[Any, float]], raw), start_iso
|
||
)
|
||
readings = [(float(o), float(p)) for o, p in readings_list]
|
||
if len(readings) >= 5:
|
||
valid_cycles.append(readings)
|
||
|
||
if len(valid_cycles) < _BATCH_MIN_CYCLES:
|
||
return {}
|
||
|
||
# --- Power thresholds ---
|
||
lowest_active: list[float] = []
|
||
cycle_energies: list[float] = [] # per-cycle total energy (Wh) for proportional floor
|
||
false_end_energies: list[float] = []
|
||
max_gap_s = _MAX_PAUSE_GAP_H * 3600
|
||
for readings in valid_cycles:
|
||
powers = np.array([p for _, p in readings])
|
||
active = powers[powers > 0.5]
|
||
peak = float(np.max(powers)) if powers.size else 0.0
|
||
active_thr = max(stop_thr, _CLEAN_ACTIVE_FLOOR_RATIO * peak)
|
||
if active.size > 0:
|
||
lowest_active.append(float(np.min(active)))
|
||
|
||
# Per-cycle total energy (trapezoidal, gap-guarded) for the
|
||
# proportional end-energy floor.
|
||
cycle_wh = 0.0
|
||
in_pause = False
|
||
pause_energy = 0.0
|
||
stop_w = stop_thr
|
||
for i in range(1, len(readings)):
|
||
t0, p0 = readings[i - 1]
|
||
t1, p1 = readings[i]
|
||
dt_s = t1 - t0
|
||
# Guard against non-positive or excessively large time gaps
|
||
if dt_s <= 0 or dt_s > max_gap_s:
|
||
# Skip this interval and reset pause state
|
||
in_pause = False
|
||
pause_energy = 0.0
|
||
continue
|
||
avg_p = (p0 + p1) / 2.0
|
||
dt_h = dt_s / 3600.0
|
||
cycle_wh += avg_p * dt_h
|
||
# False-end energies: low-power segments that resumed
|
||
if avg_p < stop_w:
|
||
if not in_pause:
|
||
in_pause = True
|
||
pause_energy = 0.0
|
||
pause_energy += avg_p * dt_h
|
||
elif in_pause:
|
||
false_end_energies.append(pause_energy)
|
||
in_pause = False
|
||
if cycle_wh > 0:
|
||
cycle_energies.append(cycle_wh)
|
||
|
||
suggestions: dict[str, dict[str, Any]] = {}
|
||
|
||
if lowest_active:
|
||
p05_min = float(np.percentile(lowest_active, 5))
|
||
n = len(lowest_active)
|
||
# Anchor the detection thresholds to the LOWEST active power (p05 of
|
||
# per-cycle minima) - the true standby->active boundary. The stop
|
||
# threshold MUST sit below the lowest active power, otherwise the
|
||
# machine reads as "off" during its low-power phases (premature end),
|
||
# and the start threshold just above it catches a real start early.
|
||
#
|
||
# NB: we deliberately do NOT anchor to a bimodal "valley" of pooled
|
||
# active readings - for multi-phase appliances that valley is the
|
||
# wash<->heat/spin boundary (hundreds of W), which produced absurdly
|
||
# high thresholds (stop ~400 W). The lowest-active floor adapts
|
||
# correctly per appliance (a few W for washers, ~steady load for pumps).
|
||
suggested_stop = round(p05_min * 0.8, 2)
|
||
suggested_start = round(max(suggested_stop + 0.1, p05_min * 1.05), 2)
|
||
reason_thr = (
|
||
f"Kept just above the p05 lowest active power across {n} cycles "
|
||
f"({p05_min:.1f}W) so a start is caught as early as possible and the "
|
||
f"stop threshold stays below the machine's lowest running power."
|
||
)
|
||
reason_thr_params = {"cycles": n, "p05": f"{p05_min:.1f}"}
|
||
suggestions[CONF_STOP_THRESHOLD_W] = {
|
||
"value": suggested_stop,
|
||
"reason": reason_thr,
|
||
"reason_key": "suggestion.reason.thr_batch",
|
||
"reason_params": reason_thr_params,
|
||
}
|
||
suggestions[CONF_START_THRESHOLD_W] = {
|
||
"value": suggested_start,
|
||
"reason": reason_thr,
|
||
"reason_key": "suggestion.reason.thr_batch",
|
||
"reason_params": reason_thr_params,
|
||
}
|
||
|
||
# End-energy: p95 of resuming-pause energies (outlier-robust) with a
|
||
# floor proportional to the cycle's own energy, not a fixed Wh value.
|
||
median_energy = float(np.median(cycle_energies)) if cycle_energies else 0.0
|
||
prop_floor = 0.002 * median_energy # 0.2% of a typical cycle
|
||
if false_end_energies:
|
||
p95_false = float(np.percentile(false_end_energies, 95))
|
||
suggested_end = round(max(0.01, prop_floor, p95_false * 1.1), 4)
|
||
reason_end = (
|
||
f"p95 false-end energy ({p95_false:.4f}Wh) across {len(valid_cycles)} "
|
||
f"cycles, floored at 0.2% of median cycle energy ({prop_floor:.4f}Wh)."
|
||
)
|
||
reason_end_key = "suggestion.reason.end_energy_false"
|
||
reason_end_params: dict[str, Any] = {
|
||
"p95": f"{p95_false:.4f}",
|
||
"cycles": len(valid_cycles),
|
||
"floor": f"{prop_floor:.4f}",
|
||
}
|
||
else:
|
||
suggested_end = round(max(0.01, prop_floor), 4)
|
||
reason_end = (
|
||
f"No false ends across {len(valid_cycles)} cycles; floored at 0.2% "
|
||
f"of median cycle energy ({prop_floor:.4f}Wh)."
|
||
)
|
||
reason_end_key = "suggestion.reason.end_energy_no_false"
|
||
reason_end_params = {
|
||
"cycles": len(valid_cycles),
|
||
"floor": f"{prop_floor:.4f}",
|
||
}
|
||
suggestions[CONF_END_ENERGY_THRESHOLD] = {
|
||
"value": suggested_end,
|
||
"reason": reason_end,
|
||
"reason_key": reason_end_key,
|
||
"reason_params": reason_end_params,
|
||
}
|
||
|
||
min_off_gap = self._suggest_min_off_gap(cycles)
|
||
if min_off_gap is not None:
|
||
suggestions[CONF_MIN_OFF_GAP] = min_off_gap
|
||
|
||
return suggestions
|
||
|
||
def apply_suggestions(self, suggestions: dict[str, Any]) -> None:
|
||
"""Persist suggestions to the profile store, then reconcile the full set.
|
||
|
||
After storing the new values, cross-parameter invariants are enforced
|
||
over the *entire* accumulated suggestion set so that suggesting one
|
||
parameter never leaves it inconsistent with another (see
|
||
:func:`reconcile_suggestions`).
|
||
"""
|
||
for key, data in suggestions.items():
|
||
self.profile_store.set_suggestion(
|
||
key,
|
||
data["value"],
|
||
reason=data["reason"],
|
||
reason_key=data.get("reason_key"),
|
||
reason_params=data.get("reason_params"),
|
||
)
|
||
if suggestions:
|
||
_LOGGER.info("Applied %d setting suggestion(s): %s", len(suggestions), ", ".join(sorted(suggestions)))
|
||
|
||
self._reconcile_stored_suggestions()
|
||
|
||
if self.hass and suggestions:
|
||
self.hass.async_create_task(self.profile_store.async_save())
|
||
|
||
def _reconcile_stored_suggestions(self) -> None:
|
||
"""Reconcile the accumulated stored suggestions against current options."""
|
||
stored = self.profile_store.get_suggestions()
|
||
if not stored:
|
||
return
|
||
adjusted, changed = reconcile_suggestions(stored, self._entry_options())
|
||
for key in changed:
|
||
entry = adjusted[key]
|
||
self.profile_store.set_suggestion(
|
||
key,
|
||
entry["value"],
|
||
reason=entry.get("reason"),
|
||
reason_key=entry.get("reason_key"),
|
||
reason_params=entry.get("reason_params"),
|
||
)
|
||
if changed:
|
||
_LOGGER.info("Reconciled coupled parameters for consistency: %s", ", ".join(sorted(changed)))
|
||
|
||
|
||
# ─── ML-calibrated suggestions (Stage 3, gated by ENABLE_ML_SUGGESTIONS) ──────
|
||
|
||
|
||
class MLSuggestionEngine:
|
||
"""Setting suggestions calibrated with the embedded ML models.
|
||
|
||
Runs *alongside* :class:`SuggestionEngine` and never mutates it. It produces
|
||
a parallel set of recommendations for the ML Lab side-by-side comparison,
|
||
using the end-detector and quality models to judge cycle behaviour rather
|
||
than fixed statistical heuristics. All work is NumPy-only and safe to run in
|
||
an executor thread.
|
||
|
||
The models are loaded lazily; if the ML package is unavailable the engine
|
||
simply yields no suggestions.
|
||
"""
|
||
|
||
def __init__(self, classic: SuggestionEngine) -> None:
|
||
self._classic = classic
|
||
self.profile_store = classic.profile_store
|
||
self.device_type = classic.device_type
|
||
|
||
def _load_models(self) -> tuple[Any, Any, Any, Any] | None:
|
||
"""Resolve (end_score_fn, quality_score_fn, end_feat_fn, quality_feat_fn).
|
||
|
||
Score fns prefer an on-device trained spec over the embedded baseline
|
||
(via :func:`ml.engine.resolve_scorer`), so ML-calibrated suggestions use
|
||
the user's personalised model once one has been trained.
|
||
"""
|
||
try:
|
||
from .ml.engine import resolve_scorer
|
||
from .ml.feature_extraction import (
|
||
latest_end_event_features,
|
||
quality_features,
|
||
)
|
||
except Exception: # pylint: disable=broad-exception-caught
|
||
return None
|
||
end_fn, _ = resolve_scorer("end", self.profile_store)
|
||
quality_fn, _ = resolve_scorer("quality", self.profile_store)
|
||
if end_fn is None and quality_fn is None:
|
||
return None
|
||
return (end_fn, quality_fn, latest_end_event_features, quality_features)
|
||
|
||
def _profile_expectations(
|
||
self, clean: list[dict[str, Any]]
|
||
) -> dict[str, dict[str, float]]:
|
||
"""Median duration / energy / peak per profile (shared helper)."""
|
||
from .ml.feature_extraction import profile_expectations
|
||
|
||
return profile_expectations(clean)
|
||
|
||
def _scored_pauses(
|
||
self,
|
||
points: list[tuple[float, float]],
|
||
expectation: dict[str, float],
|
||
stop_threshold_w: float,
|
||
end_score_fn: Any,
|
||
end_feat_fn: Any,
|
||
) -> list[tuple[float, float | None]]:
|
||
"""Return (duration_s, P(end)) for each internal pause (>=30s) that
|
||
resumed. P(end) is the end-detector's score for a prefix ending in that
|
||
pause; ``None`` if scoring failed."""
|
||
if not points or len(points) < 6:
|
||
return []
|
||
powers = [p for _, p in points]
|
||
peak = max(powers) if powers else 0.0
|
||
if peak <= 0:
|
||
return []
|
||
active_thr = max(stop_threshold_w, _CLEAN_ACTIVE_FLOOR_RATIO * peak)
|
||
# Data-outage ceiling: the same pause-gap bound used elsewhere in this file
|
||
# (a real intra-cycle pause never exceeds ~1h). A gap larger than this
|
||
# between consecutive samples is a sensor dropout / restart, not a genuine
|
||
# pause; its span must not be counted as pause duration (it would inflate
|
||
# dur and, downstream, the p95 that sizes _ml_off_delay / off_delay_pauses).
|
||
max_gap_s = _MAX_PAUSE_GAP_H * 3600
|
||
out: list[tuple[float, float | None]] = []
|
||
# Same pause detector as the classic off_delay heuristic: a low run that
|
||
# resumed into sustained activity (a terminal drying/pump-out blip that does
|
||
# 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
|
||
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
|
||
if feat is not None:
|
||
score = float(end_score_fn(feat))
|
||
except Exception: # pylint: disable=broad-exception-caught
|
||
pass
|
||
out.append((dur, score))
|
||
return out
|
||
|
||
def generate_ml_suggestions(self) -> dict[str, Any]:
|
||
"""Produce ML-calibrated suggestions from clean cycle history."""
|
||
models = self._load_models()
|
||
if models is None:
|
||
return {}
|
||
end_score_fn, quality_score_fn, end_feat_fn, quality_feat_fn = models
|
||
|
||
raw_cycles = self.profile_store.get_past_cycles()[-200:]
|
||
stop_thr = self._classic._current_stop_threshold(self._classic._entry_options())
|
||
clean, _excluded = select_clean_cycles(raw_cycles, stop_threshold_w=stop_thr)
|
||
if len(clean) < 5:
|
||
return {}
|
||
|
||
expectations = self._profile_expectations(clean)
|
||
device_floor = (
|
||
DEFAULT_OFF_DELAY_BY_DEVICE.get(self.device_type, DEFAULT_OFF_DELAY)
|
||
if self.device_type is not None
|
||
else DEFAULT_OFF_DELAY
|
||
)
|
||
|
||
out: dict[str, dict[str, Any]] = {}
|
||
off_delay = self._ml_off_delay(
|
||
clean, expectations, stop_thr, end_score_fn, end_feat_fn, device_floor
|
||
)
|
||
if off_delay is not None:
|
||
out[CONF_OFF_DELAY] = off_delay
|
||
|
||
erc = self._ml_end_repeat_count(clean, expectations, stop_thr, end_score_fn, end_feat_fn)
|
||
if erc is not None:
|
||
out[CONF_END_REPEAT_COUNT] = erc
|
||
|
||
alc = self._ml_auto_label_confidence(clean, expectations, quality_score_fn, quality_feat_fn)
|
||
if alc is not None:
|
||
out[CONF_AUTO_LABEL_CONFIDENCE] = alc
|
||
|
||
if out:
|
||
_LOGGER.info("ML-calibrated suggestions from %d clean cycles: %s", len(clean), ", ".join(sorted(out)))
|
||
return out
|
||
|
||
def _ml_off_delay(
|
||
self,
|
||
clean: list[dict[str, Any]],
|
||
expectations: dict[str, dict[str, float]],
|
||
stop_thr: float,
|
||
end_score_fn: Any,
|
||
end_feat_fn: Any,
|
||
device_floor: int,
|
||
) -> dict[str, Any] | None:
|
||
"""Off-delay from end-detector-confirmed pauses (P(end) < 0.4)."""
|
||
confirmed: list[float] = []
|
||
n_cycles = 0
|
||
for c in clean:
|
||
exp = expectations.get(c.get("profile_name"))
|
||
if not exp:
|
||
continue
|
||
points = _cycle_readings(c)
|
||
if len(points) < 6:
|
||
continue
|
||
n_cycles += 1
|
||
for dur, score in self._scored_pauses(points, exp, stop_thr, end_score_fn, end_feat_fn):
|
||
if score is not None and score < 0.4:
|
||
confirmed.append(dur)
|
||
if n_cycles < 5 or len(confirmed) < 3:
|
||
return None
|
||
p95 = float(np.percentile(confirmed, 95))
|
||
value = int(max(device_floor, round(p95 + 60.0)))
|
||
return {
|
||
"value": value,
|
||
"reason": (
|
||
f"End-detector-confirmed pauses: p95 {p95:.0f}s + 60s buffer, from "
|
||
f"{len(confirmed)} model-verified pauses across {n_cycles} cycles "
|
||
f"(floor {device_floor}s)."
|
||
),
|
||
"reason_key": "suggestion.reason.ml_off_delay",
|
||
"reason_params": {
|
||
"p95": f"{p95:.0f}",
|
||
"pauses": len(confirmed),
|
||
"cycles": n_cycles,
|
||
"floor": device_floor,
|
||
},
|
||
}
|
||
|
||
def _ml_end_repeat_count(
|
||
self,
|
||
clean: list[dict[str, Any]],
|
||
expectations: dict[str, dict[str, float]],
|
||
stop_thr: float,
|
||
end_score_fn: Any,
|
||
end_feat_fn: Any,
|
||
) -> dict[str, Any] | None:
|
||
"""Require extra end confirmations when the end-detector is fooled by
|
||
pauses (scores a resuming pause > 0.5)."""
|
||
n_total = 0
|
||
n_false = 0
|
||
for c in clean:
|
||
exp = expectations.get(c.get("profile_name"))
|
||
if not exp:
|
||
continue
|
||
points = _cycle_readings(c)
|
||
if len(points) < 6:
|
||
continue
|
||
n_total += 1
|
||
for _dur, score in self._scored_pauses(points, exp, stop_thr, end_score_fn, end_feat_fn):
|
||
if score is not None and score > 0.5:
|
||
n_false += 1
|
||
break
|
||
if n_total < 15:
|
||
return None
|
||
frac = n_false / n_total
|
||
val = 3 if frac >= 0.5 else 2 if frac >= 0.25 else 1
|
||
return {
|
||
"value": val,
|
||
"reason": (
|
||
f"{n_false}/{n_total} cycles ({frac * 100:.0f}%) had a pause the "
|
||
f"end-detector scored >50%; require {val} end confirmation(s)."
|
||
),
|
||
"reason_key": "suggestion.reason.ml_end_repeat",
|
||
"reason_params": {
|
||
"false": n_false,
|
||
"total": n_total,
|
||
"pct": f"{frac * 100:.0f}",
|
||
"val": val,
|
||
},
|
||
}
|
||
|
||
def _ml_auto_label_confidence(
|
||
self,
|
||
clean: list[dict[str, Any]],
|
||
expectations: dict[str, dict[str, float]],
|
||
quality_score_fn: Any,
|
||
quality_feat_fn: Any,
|
||
) -> dict[str, Any] | None:
|
||
"""Lowest match-confidence band the quality model still rates as clean."""
|
||
clean_confs: list[float] = []
|
||
for c in clean:
|
||
raw_conf = c.get("match_confidence")
|
||
if (
|
||
not isinstance(raw_conf, (int, float))
|
||
or isinstance(raw_conf, bool)
|
||
or raw_conf <= 0
|
||
):
|
||
continue
|
||
exp = expectations.get(c.get("profile_name"))
|
||
if not exp:
|
||
continue
|
||
points = _cycle_readings(c)
|
||
if len(points) < 6:
|
||
continue
|
||
conf = float(raw_conf)
|
||
try:
|
||
feat = quality_feat_fn(
|
||
points=points,
|
||
profile_median_duration_s=exp["duration"],
|
||
profile_median_energy_wh=exp["energy"],
|
||
profile_median_peak_w=exp["peak"],
|
||
profile_distance=max(0.0, 1.0 - conf),
|
||
label_margin=conf,
|
||
profile_fit_score=conf,
|
||
flag_count=0,
|
||
)
|
||
q = float(quality_score_fn(feat))
|
||
except Exception: # pylint: disable=broad-exception-caught
|
||
continue
|
||
if q < 0.15:
|
||
clean_confs.append(conf)
|
||
if len(clean_confs) < 10:
|
||
return None
|
||
p10 = float(np.percentile(clean_confs, 10))
|
||
return {
|
||
"value": round(min(max(p10, 0.5), 0.98), 2),
|
||
"reason": (
|
||
f"Lowest confidence the quality model still rated clean "
|
||
f"(p10 of {len(clean_confs)} clean cycles = {p10:.2f})."
|
||
),
|
||
"reason_key": "suggestion.reason.ml_auto_label",
|
||
"reason_params": {"cycles": len(clean_confs), "p10": f"{p10:.2f}"},
|
||
} |