Added Alexa Music

This commit is contained in:
2026-07-17 10:12:15 -04:00
parent 92c5268dc8
commit 28a8cb98f6
757 changed files with 151171 additions and 85450 deletions
+250 -25
View File
@@ -1,3 +1,19 @@
# 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/>.
"""The WashData integration."""
from __future__ import annotations
@@ -89,6 +105,7 @@ from .const import (
DEFAULT_COMPLETION_MIN_SECONDS,
DEFAULT_NOTIFY_BEFORE_END_MINUTES,
DEFAULT_DEVICE_TYPE,
DEVICE_TYPE_OTHER,
DEFAULT_START_DURATION_THRESHOLD,
CONF_START_DURATION_THRESHOLD,
)
@@ -125,7 +142,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
return False
if version == 3 and minor_version >= 5:
if version == 3 and minor_version >= 6:
return True
data: dict[str, Any] = dict(entry.data)
@@ -239,19 +256,86 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
):
options.pop(k, None)
# 3.6: the feedback/verify-cycle and ghost-cycle persistent notifications
# were removed (suggestions and pending reviews are surfaced in the panel),
# so the now-inert "suppress feedback notifications" toggle is stripped.
options.pop("suppress_feedback_notifications", None)
# 3.6: coffee_machine / ev / heat_pump / oven device types were removed.
# Remap any entry still on one of them to DEVICE_TYPE_OTHER (Threshold Device),
# preserving all tuned options so no user data is lost.
_removed_device_types = {"coffee_machine", "ev", "heat_pump", "oven"}
if (options.get(CONF_DEVICE_TYPE) or data.get(CONF_DEVICE_TYPE)) in _removed_device_types:
_log.info(
"Device type %r is no longer supported; migrating to %r (options preserved)",
options.get(CONF_DEVICE_TYPE), DEVICE_TYPE_OTHER,
)
options[CONF_DEVICE_TYPE] = DEVICE_TYPE_OTHER
# NB: CONF_DEVICE_TYPE was already popped from ``data`` above (keys_to_remove),
# so no stale removed value can linger there; the flow/manager read it from
# options (options-first).
hass.config_entries.async_update_entry(
entry,
data=data,
options=options,
version=3,
minor_version=5,
minor_version=6,
)
_log.info(
"Migrated WashData entry from version %s.%s to 3.5", version, minor_version
"Migrated WashData entry from version %s.%s to 3.6", version, minor_version
)
return True
async def _migrate_online_to_global(hass: HomeAssistant, entry: ConfigEntry, manager: Any) -> None:
"""Hoist the (formerly per-device) online-features flag + store account to the
integration-wide store. Pre-release cleanup.
The enable flag is hoisted exactly ONCE (guarded by a marker in the global store):
the stale per-entry option is never cleared, so without the marker a user who later
turns online off would have it silently re-enabled on the next restart. The account
hoist stays idempotent (it clears the per-entry copy after moving it)."""
from . import store_account # pylint: disable=import-outside-toplevel
from .const import CONF_ENABLE_ONLINE_FEATURES # pylint: disable=import-outside-toplevel
# Best-effort, pre-release migration: a transient store write failure here must
# never propagate and abort async_setup_entry (it retries on the next restart).
try:
await store_account.async_load(hass)
if not store_account.migration_done(hass):
any_on = any(
e.options.get(CONF_ENABLE_ONLINE_FEATURES)
for e in hass.config_entries.async_entries(DOMAIN)
)
if any_on and not store_account.online_enabled(hass):
await store_account.async_set_online(hass, True)
await store_account.async_mark_migrated(hass)
except Exception: # pylint: disable=broad-exception-caught
_LOGGER.warning("Online-features migration to global store failed", exc_info=True)
try:
acct = manager.profile_store.get_store_account()
except Exception: # pylint: disable=broad-exception-caught
acct = {}
if acct:
_account_preserved = False
try:
if acct.get("refresh_token") and not store_account.get_account(hass).get("refresh_token"):
await store_account.async_set_account(hass, {
"refresh_token": acct.get("refresh_token"),
"uid": acct.get("uid"), "name": acct.get("name"),
})
_account_preserved = True
except Exception: # pylint: disable=broad-exception-caught
_LOGGER.warning("Store-account hoist to global store failed", exc_info=True)
if _account_preserved:
try:
await manager.profile_store.clear_store_account()
except Exception: # pylint: disable=broad-exception-caught
pass
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up WashData from a config entry."""
_log = DeviceLoggerAdapter(_LOGGER, entry.title)
@@ -284,6 +368,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data[DOMAIN][entry.entry_id] = manager
await manager.async_setup()
await _migrate_online_to_global(hass, entry, manager)
# Check for initial profile from onboarding
if "initial_profile" in entry.data:
@@ -336,12 +421,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
manager = hass.data[DOMAIN][entry_id]
# Assign existing profile or remove label
if profile_name:
await manager.profile_store.assign_profile_to_cycle(
cycle_id, profile_name
)
else:
await manager.profile_store.assign_profile_to_cycle(cycle_id, None)
try:
if profile_name:
await manager.profile_store.assign_profile_to_cycle(
cycle_id, profile_name
)
else:
await manager.profile_store.assign_profile_to_cycle(cycle_id, None)
except ValueError as exc:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="assign_profile_failed",
translation_placeholders={"error": str(exc)},
) from exc
manager.notify_update()
@@ -367,9 +459,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise ValueError("Integration not loaded for this device")
manager = hass.data[DOMAIN][entry_id]
await manager.profile_store.create_profile_standalone(
profile_name, reference_cycle_id
)
try:
await manager.profile_store.create_profile_standalone(
profile_name, reference_cycle_id
)
except ValueError as exc:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="create_profile_failed",
translation_placeholders={"error": str(exc)},
) from exc
manager.notify_update()
hass.services.async_register(DOMAIN, "create_profile", handle_create_profile)
@@ -466,15 +565,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Determine trim end - default to full cycle duration if not supplied
raw_end = call.data.get("trim_end_s")
# Always check cycle existence first, regardless of which trim path is taken
p_data = store.get_cycle_power_data(cycle_id)
if not p_data:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="cycle_not_found_or_no_power",
)
if raw_end is not None:
trim_end_s = max(0.0, float(raw_end))
else:
p_data = store.get_cycle_power_data(cycle_id)
if not p_data:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="cycle_not_found_or_no_power",
)
trim_end_s = max(point[0] for point in p_data)
if trim_end_s <= trim_start_s:
@@ -524,6 +624,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data["ha_washdata_card_registered"] = False
_log.warning("Card registration failed and was not deferred")
# Register full-screen sidebar panel - once per HA instance only.
# pylint: disable=import-outside-toplevel
from .frontend import async_register_panel, PANEL_REGISTERED_KEY
if not hass.data.get(PANEL_REGISTERED_KEY):
await async_register_panel(hass)
# Register WebSocket API commands for the panel. Re-run on every setup/reload:
# HA's async_register_command overwrites the handler per command type, so this
# is idempotent AND means NEW commands become available after an integration
# reload, not only after a full Home Assistant restart (previously the
# once-per-instance guard forced a full restart for any newly-added command).
from .ws_api import ( # pylint: disable=import-outside-toplevel
async_load_panel_config,
async_register_commands,
)
await async_load_panel_config(hass) # self-guards; safe to call repeatedly
from . import store_account # pylint: disable=import-outside-toplevel
await store_account.async_load(hass) # integration-wide online flag + account
async_register_commands(hass)
hass.data["ha_washdata_ws_registered"] = True
# Register conversation intents (e.g. "is my washer done?") - once per HA
# instance. Intents are domain-global, so guard against re-registration when
# more than one device is configured.
if not hass.data.get("ha_washdata_intents_registered"):
from .intents import async_setup_intents # pylint: disable=import-outside-toplevel
async_setup_intents(hass)
hass.data["ha_washdata_intents_registered"] = True
# Register feedback service
if not hass.services.has_service(
DOMAIN, SERVICE_SUBMIT_FEEDBACK.rsplit(".", maxsplit=1)[-1]
@@ -623,8 +755,48 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
target = target.resolve()
# Write export
target.write_text(json.dumps(payload, indent=2), encoding="utf-8")
# Restrict caller-supplied paths to HA-allowed dirs (path-traversal /
# arbitrary-write guard). The default (no path) lands in the config dir.
if file_path and not hass.config.is_allowed_path(str(target)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="path_not_allowed",
translation_placeholders={"path": str(target)},
)
# Write export (offloaded to executor to avoid blocking the event
# loop). A caller-supplied path must never silently overwrite an
# existing file even when is_allowed_path() accepts it; exclusive
# creation ("x") makes that no-overwrite check atomic (no TOCTOU
# window). The default generated path may be re-written freely.
def _dump_and_write():
text = json.dumps(payload, indent=2)
try:
if file_path:
# Exclusive creation ("x") makes the no-overwrite check
# atomic; the default generated path may be re-written.
with open(target, "x", encoding="utf-8") as handle:
handle.write(text)
else:
target.write_text(text, encoding="utf-8")
except FileExistsError as exc:
# Subclass of OSError -> must be caught first (no-overwrite).
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="export_path_exists",
translation_placeholders={"path": str(target)},
) from exc
except OSError as exc:
# Disk full / permission denied / bad path: surface a clean
# localized error instead of a raw OSError from the executor.
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="export_write_failed",
translation_placeholders={
"path": str(target), "error": str(exc)
},
) from exc
await hass.async_add_executor_job(_dump_and_write)
manager._logger.info("Exported ha_washdata entry %s to %s", entry_id, target)
hass.services.async_register(DOMAIN, "export_config", handle_export_config)
@@ -655,12 +827,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if entry is None:
raise ValueError(f"Config entry not found: {entry_id}")
source = Path(file_path).resolve()
if not source.exists():
# resolve()/exists() hit the filesystem; offload so the event loop is not
# blocked on I/O during the import service call.
source = await hass.async_add_executor_job(
lambda: Path(file_path).resolve()
)
# Restrict reads to HA-allowed dirs (path-traversal / arbitrary-read guard).
if not hass.config.is_allowed_path(str(source)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="path_not_allowed",
translation_placeholders={"path": str(source)},
)
if not await hass.async_add_executor_job(source.exists):
raise ValueError(f"File not found: {source}")
try:
payload = json.loads(source.read_text(encoding="utf-8"))
def _read_and_parse():
text = source.read_text(encoding="utf-8")
return json.loads(text)
payload = await hass.async_add_executor_job(_read_and_parse)
except Exception as err: # noqa: BLE001
raise ValueError(f"Failed to read import file: {err}") from err
@@ -726,6 +912,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.services.async_register(DOMAIN, "record_stop", handle_record_stop)
# Register on-device ML training trigger (Stage 4, gated by ENABLE_ML_TRAINING)
from .const import ENABLE_ML_TRAINING, SERVICE_TRIGGER_ML_TRAINING
if ENABLE_ML_TRAINING and not hass.services.has_service(
DOMAIN, SERVICE_TRIGGER_ML_TRAINING
):
async def handle_trigger_ml_training(call: ServiceCall) -> None:
device_id = _require_str(call.data.get("device_id"), "device_id")
registry = dr.async_get(hass)
device = registry.async_get(device_id)
if not device:
raise ValueError("Device not found")
entry_id = next(iter(device.config_entries), None)
if not entry_id or entry_id not in hass.data[DOMAIN]:
raise ValueError("Integration not loaded")
manager = hass.data[DOMAIN][entry_id]
summary = await manager.async_run_ml_training(force=True)
manager._logger.info("Manual ML training: %s", summary)
hass.services.async_register(
DOMAIN, SERVICE_TRIGGER_ML_TRAINING, handle_trigger_ml_training
)
# Register pause/resume services
if not hass.services.has_service(DOMAIN, "pause_cycle"):
async def handle_pause_cycle(call: ServiceCall) -> None:
@@ -753,7 +963,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
manager = hass.data[DOMAIN][entry_id]
await manager.async_pause_cycle()
success = await manager.async_pause_cycle()
if not success:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_active_cycle",
)
hass.services.async_register(DOMAIN, "pause_cycle", handle_pause_cycle)
@@ -783,7 +998,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
manager = hass.data[DOMAIN][entry_id]
await manager.async_resume_cycle()
success = await manager.async_resume_cycle()
if not success:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_active_cycle",
)
hass.services.async_register(DOMAIN, "resume_cycle", handle_resume_cycle)
@@ -834,5 +1054,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
manager = hass.data[DOMAIN].pop(entry.entry_id)
await manager.async_shutdown()
# When the last WashData entry is removed, tear down the shared panel/sidebar
# so no stale registration flags or sidebar entry linger.
if not hass.data.get(DOMAIN):
from .frontend import async_unregister_panel
await async_unregister_panel(hass)
return unload_ok
+210 -32
View File
@@ -1,3 +1,19 @@
# 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/>.
"""Analysis module for heavy CPU tasks (offloaded to executor)."""
from __future__ import annotations
@@ -6,13 +22,40 @@ from typing import Any, Optional
import numpy as np
from .const import (
DEFAULT_DTW_MODE,
MATCH_CORR_WEIGHT,
MATCH_DDTW_DIST_SCALE,
MATCH_DTW_BLEND,
MATCH_DTW_DIST_SCALE,
MATCH_DTW_ENSEMBLE_W,
MATCH_DTW_REFINE_TOP_N,
MATCH_DTW_RESAMPLE_N,
MATCH_DURATION_SCALE,
MATCH_DURATION_WEIGHT,
MATCH_ENERGY_SCALE,
MATCH_ENERGY_WEIGHT,
MATCH_KEEP_MIN_SCORE,
MATCH_MAE_PEAK_FLOOR,
MATCH_MAE_REF_PEAK,
MATCH_MAE_SCALE,
)
def _agreement(observed: float, expected: float, scale: float) -> float:
"""1.0 when observed==expected, decaying with the |log-ratio| / scale."""
if observed <= 0 or expected <= 0 or scale <= 0:
return 0.0
return 1.0 / (1.0 + abs(np.log(observed / expected)) / scale)
_LOGGER = logging.getLogger(__name__)
ALIGNMENT_CONTEXT_BUFFER = 50
def find_best_alignment(
current_power: list[float] | np.ndarray,
sample_power: list[float] | np.ndarray,
dt: float = 1.0 # pylint: disable=unused-argument
dt: float = 1.0, # pylint: disable=unused-argument
corr_weight: float = MATCH_CORR_WEIGHT,
) -> tuple[float, dict[str, float], int]:
"""Find Best Alignment using Coarse-to-Fine Search (CPU Bound)."""
@@ -103,18 +146,31 @@ def find_best_alignment(
else:
corr = 0.0
mae_score = 100.0 / (100.0 + mae)
score = (0.6 * max(0, corr)) + (0.4 * mae_score)
# Scale-invariant MAE: express the error relative to the current cycle's
# peak (common to every candidate, so ranking is unaffected) and calibrate
# to the legacy behaviour at MATCH_MAE_REF_PEAK. See const.py for rationale.
current_peak = float(np.max(np.abs(curr))) if curr.size else 0.0
scaled_mae = mae * MATCH_MAE_REF_PEAK / max(current_peak, MATCH_MAE_PEAK_FLOOR)
mae_score = MATCH_MAE_SCALE / (MATCH_MAE_SCALE + scaled_mae)
score = (corr_weight * max(0.0, corr)) + ((1.0 - corr_weight) * mae_score)
return float(score), {"mae": float(mae), "corr": float(corr)}, final_offset
def compute_dtw_lite(
x: np.ndarray, y: np.ndarray, band_width_ratio: float = 0.1
x: np.ndarray, y: np.ndarray, band_width_ratio: float = 0.1,
derivative: bool = False,
) -> float:
"""
Compute DTW distance with Sakoe-Chiba band constraint.
Optimized 1D DP implementation. O(N*W).
When ``derivative`` is True this warps on the first derivative (slope) of the
two curves (Derivative DTW): alignment is driven by shape/transitions rather
than absolute power level, which is robust to amplitude offset and scale.
"""
if derivative:
x = np.gradient(np.asarray(x, dtype=float)) if len(x) > 1 else np.asarray(x, dtype=float)
y = np.gradient(np.asarray(y, dtype=float)) if len(y) > 1 else np.asarray(y, dtype=float)
n, m = len(x), len(y)
if n == 0 or m == 0:
return float("inf")
@@ -170,6 +226,41 @@ def compute_dtw_lite(
return float(prev_row[m])
def _resample_to(arr: np.ndarray, n: int) -> np.ndarray:
"""Linearly resample a 1-D array to exactly ``n`` points over its index span.
Used to put the current cycle and a profile sample onto one common grid
before DTW so the Sakoe-Chiba band width and the distance normalisation mean
the same thing regardless of each series' native sampling cadence/length.
"""
a = np.asarray(arr, dtype=float)
length = len(a)
if length == 0:
return np.zeros(n)
if length == n:
return a
return np.interp(np.linspace(0.0, 1.0, n), np.linspace(0.0, 1.0, length), a)
def _dtw_component_score(
curr_arr: np.ndarray,
sample_arr: np.ndarray,
current_peak: float,
band: float,
derivative: bool,
scale: float,
) -> float:
"""DTW similarity in [0,1] for one candidate: resample both series to a
common grid, warp (level or derivative), and express the distance relative
to the current peak (behaviour-neutral at MATCH_MAE_REF_PEAK)."""
a = _resample_to(curr_arr, MATCH_DTW_RESAMPLE_N)
b = _resample_to(sample_arr, MATCH_DTW_RESAMPLE_N)
dtw_dist = compute_dtw_lite(a, b, band_width_ratio=band, derivative=derivative)
norm_dist = dtw_dist / MATCH_DTW_RESAMPLE_N
scaled = norm_dist * MATCH_MAE_REF_PEAK / max(current_peak, MATCH_MAE_PEAK_FLOOR)
return scale / (scale + scaled)
def compute_matches_worker(
current_power: list[float],
current_duration: float,
@@ -182,6 +273,13 @@ def compute_matches_worker(
min_duration_ratio = config.get("min_duration_ratio", 0.07)
max_duration_ratio = config.get("max_duration_ratio", 1.3)
dtw_bandwidth = config.get("dtw_bandwidth", 0.1)
dtw_mode = config.get("dtw_mode", DEFAULT_DTW_MODE)
keep_min = float(config.get("keep_min_score", MATCH_KEEP_MIN_SCORE))
corr_weight = float(config.get("corr_weight", MATCH_CORR_WEIGHT))
dur_weight = float(config.get("duration_weight", MATCH_DURATION_WEIGHT))
en_weight = float(config.get("energy_weight", MATCH_ENERGY_WEIGHT))
dur_scale = float(config.get("duration_scale", MATCH_DURATION_SCALE))
en_scale = float(config.get("energy_scale", MATCH_ENERGY_SCALE))
curr_arr = np.array(current_power)
@@ -198,10 +296,10 @@ def compute_matches_worker(
# Core Similarity
score, metrics, offset = find_best_alignment(
current_power, sample_power, 1.0
current_power, sample_power, 1.0, corr_weight=corr_weight
)
if score > 0.1:
if score > keep_min:
candidates.append({
"name": name,
"score": score,
@@ -214,33 +312,86 @@ def compute_matches_worker(
candidates.sort(key=lambda x: x["score"], reverse=True)
# Stage 3: DTW Refinement on Top 3
# Stage 3: DTW Refinement on the top N candidates
if dtw_bandwidth > 0.0 and len(candidates) > 0:
to_refine = candidates[:3]
# top-N, blend and the distance scales are config-overridable so the
# tuning harness can sweep them without editing constants; production
# uses the const defaults.
top_n = int(config.get("dtw_refine_top_n", MATCH_DTW_REFINE_TOP_N))
blend = float(config.get("dtw_blend", MATCH_DTW_BLEND))
to_refine = candidates[:top_n]
current_peak = float(np.max(curr_arr)) if curr_arr.size else 0.0
l1_scale = float(config.get("dtw_l1_scale", MATCH_DTW_DIST_SCALE))
ddtw_scale = float(config.get("dtw_ddtw_scale", MATCH_DDTW_DIST_SCALE))
ensemble_w = float(config.get("dtw_ensemble_w", MATCH_DTW_ENSEMBLE_W))
for cand in to_refine:
sample_arr = np.array(cand["sample"])
dtw_dist = compute_dtw_lite(
curr_arr,
sample_arr,
band_width_ratio=dtw_bandwidth,
)
n_points = len(curr_arr)
if n_points > 0:
norm_dist = dtw_dist / n_points
if dtw_mode == "legacy":
# Original behaviour: raw sequences, distance / len(current),
# fixed absolute-watt scale (not peak-relative).
dtw_dist = compute_dtw_lite(curr_arr, sample_arr, band_width_ratio=dtw_bandwidth)
n_points = len(curr_arr)
norm_dist = (dtw_dist / n_points) if n_points > 0 else 999.0
dtw_score = 1.0 / (1.0 + norm_dist / MATCH_DTW_DIST_SCALE)
elif dtw_mode == "ensemble":
# Blend the level-based (L1) and shape-based (derivative) DTW
# scores; they are complementary signals.
s_l1 = _dtw_component_score(curr_arr, sample_arr, current_peak, dtw_bandwidth, False, l1_scale)
s_dd = _dtw_component_score(curr_arr, sample_arr, current_peak, dtw_bandwidth, True, ddtw_scale)
dtw_score = ensemble_w * s_l1 + (1.0 - ensemble_w) * s_dd
norm_dist = 0.0 # composite; per-component distance not meaningful
else:
norm_dist = 999.0
dtw_score = 1.0 / (1.0 + norm_dist / 50.0)
# "scaled" (default) or "ddtw": resample both onto one grid so the
# band and normalisation are consistent, then express the distance
# relative to the current peak (behaviour-neutral at
# MATCH_MAE_REF_PEAK), mirroring the Stage-2 MAE treatment.
use_deriv = dtw_mode == "ddtw"
scale = ddtw_scale if use_deriv else l1_scale
dtw_score = _dtw_component_score(
curr_arr, sample_arr, current_peak, dtw_bandwidth, use_deriv, scale
)
norm_dist = 0.0
cand["original_score"] = float(cand["score"])
cand["score"] = float(0.5 * cand["score"] + 0.5 * dtw_score)
cand["score"] = float(blend * cand["score"] + (1.0 - blend) * dtw_score)
cand["dtw_dist"] = float(norm_dist)
candidates.sort(key=lambda x: x["score"], reverse=True)
# Final pass: blend in duration + energy agreement. Shape correlation alone
# cannot separate profiles that differ mainly in duration/energy (the main
# multi-program washing-machine failure mode), so nudge the score toward
# candidates whose expected duration/energy match the observed cycle.
# Sanitize the configured weights so the blended score stays a convex
# combination in [0, 1]: clamp negatives to 0 and, if duration+energy exceed
# 1.0, scale them down proportionally (shape then contributes 0) rather than
# letting shape_w go negative or the total exceed 1.
# Drop non-finite configured weights (NaN/inf) so de_sum, the normalized
# weights, and every candidate score stay finite.
dur_w = max(0.0, dur_weight) if np.isfinite(dur_weight) else 0.0
en_w = max(0.0, en_weight) if np.isfinite(en_weight) else 0.0
de_sum = dur_w + en_w
if de_sum > 1.0:
dur_w, en_w = dur_w / de_sum, en_w / de_sum
shape_w = max(0.0, 1.0 - dur_w - en_w)
if (dur_w > 0 or en_w > 0) and candidates and current_duration > 0:
cur_energy = float(np.mean(curr_arr)) # mean power (W) — no duration multiplication
for cand in candidates:
prof_dur = float(cand.get("profile_duration") or 0.0)
dur_ag = _agreement(current_duration, prof_dur, dur_scale)
sample = cand.get("sample") or []
cand_energy = float(np.mean(sample)) if sample else 0.0
en_ag = _agreement(cur_energy, cand_energy, en_scale)
cand["shape_score"] = float(cand["score"])
cand["score"] = float(
shape_w * cand["score"]
+ dur_w * dur_ag
+ en_w * en_ag
)
candidates.sort(key=lambda x: x["score"], reverse=True)
return candidates
def compute_dtw_path(
@@ -308,7 +459,8 @@ def compute_dtw_path(
def compute_envelope_worker(
raw_cycles_data: list[tuple[list[float], list[float], Optional[float]]] | list[tuple[list[float], list[float]]],
dtw_bandwidth: float
dtw_bandwidth: float,
reference_mask: list[bool] | None = None,
) -> tuple[list[float], list[float], list[float], list[float], list[float], float] | None:
"""
Compute statistical envelope.
@@ -316,16 +468,22 @@ def compute_envelope_worker(
raw_cycles_data: list of (offsets, power_values, duration) tuples.
Duration may be None and is used to compute target_duration.
dtw_bandwidth: ratio.
reference_mask: optional per-cycle flags (parallel to raw_cycles_data).
When any entry is True, the robust reference curve is built from the
median of the flagged cycles only (e.g. user-verified "golden"
cycles), so trusted cycles define the shape every other cycle is
warped onto. Min/max/avg/std bands are still built from all cycles.
Returns:
(time_grid, min_curve, max_curve, avg_curve, std_curve, target_duration) or None.
"""
if not raw_cycles_data:
return None
normalized_curves: list[tuple[np.ndarray, np.ndarray, float]] = []
golden_flags: list[bool] = []
sampling_rates: list[float] = []
# 1. Pre-process input
for curve in raw_cycles_data:
for idx, curve in enumerate(raw_cycles_data):
# Unpack curve tuple: (offsets, values) or (offsets, values, duration)
# Backward compatible with 2-tuple (offsets, values) format
try:
@@ -373,6 +531,7 @@ def compute_envelope_worker(
continue
normalized_curves.append((offsets, values, dur))
golden_flags.append(bool(reference_mask[idx]) if reference_mask and idx < len(reference_mask) else False)
if len(offsets) > 1:
intervals = np.diff(offsets)
@@ -384,8 +543,8 @@ def compute_envelope_worker(
if not normalized_curves:
return None
# 2. Reference Selection (Median Duration)
# Input is now (offsets, values, duration)
# 2. Reference Selection
# The grid is sized from the median duration. Input is (offsets, values, duration).
max_times = [float(dur) for _, _, dur in normalized_curves]
median_dur = float(np.median(max_times))
ref_idx = int(np.argmin([abs(t - median_dur) for t in max_times]))
@@ -401,16 +560,35 @@ def compute_envelope_worker(
num_points = max(50, int(target_duration / align_dt))
time_grid = np.linspace(0.0, target_duration, num_points)
ref_offsets, ref_values, _ = normalized_curves[ref_idx]
ref_array = np.interp(time_grid, ref_offsets, ref_values)
# Robust reference curve: the pointwise MEDIAN across all cycles resampled
# onto the shared grid - a synthetic "medoid" that is not distorted by a
# single atypical cycle near the median duration and handles multi-mode
# profiles far better than picking one representative curve. Falls back to
# the single closest-to-median cycle when there are too few cycles for a
# stable median.
golden_indices = [i for i, g in enumerate(golden_flags) if g]
if golden_indices:
# Trusted "golden" cycles define the reference shape.
grid_curves = np.array(
[
np.interp(time_grid, normalized_curves[i][0], normalized_curves[i][1])
for i in golden_indices
]
)
ref_array = np.median(grid_curves, axis=0)
elif len(normalized_curves) >= 3:
grid_curves = np.array(
[np.interp(time_grid, offs, vals) for offs, vals, _ in normalized_curves]
)
ref_array = np.median(grid_curves, axis=0)
else:
ref_offsets, ref_values, _ = normalized_curves[ref_idx]
ref_array = np.interp(time_grid, ref_offsets, ref_values)
# 3. Resample & DTW
# 3. Resample & DTW: warp every cycle onto the robust reference.
resampled: list[np.ndarray] = []
for i, (offsets, values, dur) in enumerate(normalized_curves):
if i == ref_idx:
resampled.append(ref_array)
continue
for offsets, values, dur in normalized_curves:
this_dur = dur
this_num_points = max(10, int(this_dur / align_dt))
this_grid = np.linspace(0.0, this_dur, this_num_points)
@@ -1,3 +1,19 @@
# 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/>.
"""Binary sensor for WashData."""
from __future__ import annotations
+106 -1
View File
@@ -1,3 +1,19 @@
# 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/>.
"""Button platform for WashData."""
from __future__ import annotations
@@ -27,6 +43,8 @@ async def async_setup_entry(
WashDataTerminateButton(manager, entry),
WashDataPauseCycleButton(manager, entry),
WashDataResumeCycleButton(manager, entry),
WashDataRecordStartButton(manager, entry),
WashDataRecordStopButton(manager, entry),
])
@@ -141,4 +159,91 @@ class WashDataResumeCycleButton(ButtonEntity):
async def async_press(self) -> None:
"""Handle the button press."""
await self._manager.async_resume_cycle()
await self._manager.async_resume_cycle()
class WashDataRecordStartButton(ButtonEntity):
"""Button to start manually recording a clean cycle."""
_attr_has_entity_name = True
_attr_translation_key = "record_start"
_attr_icon = "mdi:record-circle-outline"
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
"""Initialize the button."""
self._manager = manager
self._entry = entry
self._attr_unique_id = f"{entry.entry_id}_record_start"
self._attr_device_info = {
"identifiers": {(DOMAIN, entry.entry_id)},
"name": entry.title,
"manufacturer": "WashData",
}
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
SIGNAL_WASHER_UPDATE.format(self._entry.entry_id),
self._update_callback,
)
)
@callback
def _update_callback(self) -> None:
self.async_write_ha_state()
@property
def available(self) -> bool:
"""Only available when not already recording and no active cycle is running."""
return (
not self._manager.recorder.is_recording
and self._manager.detector.state == "off"
)
async def async_press(self) -> None:
"""Handle the button press."""
await self._manager.async_start_recording()
class WashDataRecordStopButton(ButtonEntity):
"""Button to stop manual recording."""
_attr_has_entity_name = True
_attr_translation_key = "record_stop"
_attr_icon = "mdi:stop-circle"
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
"""Initialize the button."""
self._manager = manager
self._entry = entry
self._attr_unique_id = f"{entry.entry_id}_record_stop"
self._attr_device_info = {
"identifiers": {(DOMAIN, entry.entry_id)},
"name": entry.title,
"manufacturer": "WashData",
}
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
SIGNAL_WASHER_UPDATE.format(self._entry.entry_id),
self._update_callback,
)
)
@callback
def _update_callback(self) -> None:
self.async_write_ha_state()
@property
def available(self) -> bool:
"""Only available while a recording is active."""
return self._manager.recorder.is_recording
async def async_press(self) -> None:
"""Handle the button press."""
await self._manager.async_stop_recording()
File diff suppressed because it is too large Load Diff
+499 -96
View File
@@ -1,7 +1,43 @@
# 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/>.
"""Constants for the WashData integration."""
from enum import StrEnum
DOMAIN = "ha_washdata"
class TerminationReason(StrEnum):
"""Why a cycle ended. StrEnum members equal their string value, so existing
string comparisons and JSON serialisation keep working unchanged."""
TIMEOUT = "timeout" # low-power off_delay elapsed (normal completion)
SMART = "smart" # smart-termination heuristic finished the cycle
FORCE_STOPPED = "force_stopped" # watchdog / no-update force end
USER = "user" # user manually stopped the cycle
TERMINAL_DROP = "terminal_drop" # anomalously-early hard cliff-to-0 (opt-in)
# Completed cycles stay eligible for anti-wrinkle handling only for these
# reasons (a user-stopped cycle is intentionally excluded).
ANTI_WRINKLE_ELIGIBLE_REASONS = frozenset(
{TerminationReason.TIMEOUT, TerminationReason.SMART}
)
# Configuration keys
CONF_POWER_SENSOR = "power_sensor"
CONF_NAME = "name"
@@ -16,6 +52,7 @@ CONF_NOTIFY_EVENTS = "notify_events" # Deprecated - kept for migration only
CONF_NOTIFY_START_SERVICES = "notify_start_services"
CONF_NOTIFY_FINISH_SERVICES = "notify_finish_services"
CONF_NOTIFY_LIVE_SERVICES = "notify_live_services"
CONF_NOTIFY_CYCLE_TIMERS = "notify_cycle_timers"
CONF_NO_UPDATE_ACTIVE_TIMEOUT = "no_update_active_timeout"
CONF_LOW_POWER_NO_UPDATE_TIMEOUT = "low_power_no_update_timeout"
CONF_SMOOTHING_WINDOW = "smoothing_window"
@@ -43,10 +80,8 @@ CONF_WATCHDOG_INTERVAL = "watchdog_interval" # Derived from sampling_interval
CONF_MATCH_PERSISTENCE = "match_persistence"
CONF_COMPLETION_MIN_SECONDS = "completion_min_seconds"
CONF_NOTIFY_BEFORE_END_MINUTES = "notify_before_end_minutes"
CONF_APPLY_SUGGESTIONS = "apply_suggestions"
CONF_RUNNING_DEAD_ZONE = "running_dead_zone" # Seconds after start to ignore power dips
CONF_END_REPEAT_COUNT = "end_repeat_count" # Number of times end condition must be met
CONF_SHOW_ADVANCED = "show_advanced" # Toggle advanced settings
CONF_MIN_OFF_GAP = "min_off_gap" # Minimum gap to separate cycles (seconds)
CONF_START_ENERGY_THRESHOLD = "start_energy_threshold" # Wh required to confirm start
CONF_END_ENERGY_THRESHOLD = "end_energy_threshold" # Wh allowed during end candidates
@@ -54,6 +89,12 @@ CONF_START_THRESHOLD_W = "start_threshold_w" # Custom power threshold for START
CONF_STOP_THRESHOLD_W = (
"stop_threshold_w" # Custom power threshold for ENDING (hysteresis)
)
CONF_POWER_OFF_THRESHOLD_W = (
"power_off_threshold_w" # W; 0 = disabled. Terminal Finished/Clean -> Off when
) # smoothed power stays below this (must sit below stop_threshold_w when > 0)
CONF_POWER_OFF_DELAY = (
"power_off_delay" # Seconds below the power-off threshold before Finished/Clean -> Off
)
CONF_EXPOSE_DEBUG_ENTITIES = "expose_debug_entities" # Expose detailed debug sensors
CONF_SAVE_DEBUG_TRACES = (
"save_debug_traces" # Improve historical cycle data with rich debug info
@@ -73,18 +114,15 @@ CONF_ANTI_WRINKLE_EXIT_POWER = "anti_wrinkle_exit_power" # W threshold for true
CONF_DELAY_START_DETECT_ENABLED = "delay_start_detect_enabled" # Enable delayed-start detection
CONF_DELAY_CONFIRM_SECONDS = "delay_confirm_seconds" # Seconds power must stay in standby band before DELAY_WAIT engages
CONF_DELAY_TIMEOUT_HOURS = "delay_timeout_hours" # Safety timeout (hours) while waiting to start
# Deprecated since 0.4.5: drain-spike model replaced by band-based DELAY_WAIT.
# Kept only so older Store/options blobs don't raise KeyError during migration.
CONF_DELAY_DRAIN_MIN_POWER = "delay_drain_min_power"
CONF_DELAY_DRAIN_MAX_POWER = "delay_drain_max_power"
CONF_DELAY_DRAIN_MAX_DURATION = "delay_drain_max_duration"
# Note: the deprecated 0.4.5 drain-spike keys (delay_drain_*) are stripped during
# config migration in __init__.py using raw string literals; no constants needed.
NOTIFY_EVENT_START = "cycle_start"
NOTIFY_EVENT_FINISH = "cycle_finish"
NOTIFY_EVENT_LIVE = "cycle_live"
NOTIFY_EVENT_CLEAN = "cycle_clean" # Laundry still inside after cycle ends
NOTIFY_EVENT_TIMER = "cycle_timer" # User-configured mid-cycle countdown timer
CONF_NOTIFY_TITLE = "notify_title"
CONF_NOTIFY_ICON = "notify_icon"
@@ -100,6 +138,10 @@ CONF_NOTIFY_CHANNEL = "notify_channel" # Android channel for status/live/remind
CONF_NOTIFY_FINISH_CHANNEL = "notify_finish_channel" # Distinct Android channel for finished/clean
CONF_ENERGY_PRICE_STATIC = "energy_price_static"
CONF_ENERGY_PRICE_ENTITY = "energy_price_entity"
# Peak-rate awareness: when the current price meets/exceeds this threshold, the
# start notification gets an informational tip appended (purely advisory).
CONF_PEAK_RATE_THRESHOLD = "peak_rate_threshold"
CONF_PEAK_RATE_MESSAGE = "peak_rate_message"
# Door sensor & pause
CONF_DOOR_SENSOR_ENTITY = "door_sensor_entity" # Optional binary_sensor for machine door
@@ -108,6 +150,20 @@ CONF_SWITCH_ENTITY = "switch_entity" # Optional switch entity toggled on pause/
CONF_NOTIFY_UNLOAD_DELAY_MINUTES = "notify_unload_delay_minutes" # Minutes before "laundry waiting" nag
CONF_NOTIFY_UNLOAD_MESSAGE = "notify_unload_message" # Template for the clean-laundry nag message
# Quiet hours (do-not-disturb window). Both hours 0-23; unset/None (or start==end)
# = feature off. When configured, finish-type notifications (finish, clean-laundry
# nag, pre-complete/reminder, milestone) that would fire inside the window are held
# and delivered at the end of the window. Live-progress ticks and the start
# notification are never delayed.
CONF_NOTIFY_QUIET_START_HOUR = "notify_quiet_start_hour"
CONF_NOTIFY_QUIET_END_HOUR = "notify_quiet_end_hour"
# Milestone (cycle-count achievement) notifications. A list of lifetime completed-
# cycle counts; a single milestone notification fires when the device's lifetime
# count crosses one of these values. Empty/malformed list = no-op.
CONF_NOTIFY_MILESTONES = "notify_milestones"
CONF_NOTIFY_MILESTONE_MESSAGE = "notify_milestone_message"
# Optional link to an existing HA device (e.g. the smart plug or appliance).
# When set, the WashData device is exposed as "Connected via <device>" through
# the device registry's via_device relationship. Stores a device registry id.
@@ -129,6 +185,15 @@ DEFAULT_NOTIFY_CHANNEL = "" # Empty = omit channel (companion app default)
DEFAULT_NOTIFY_FINISH_CHANNEL = "" # Empty = reuse status channel
DEFAULT_NOTIFY_UNLOAD_DELAY_MINUTES = 60 # 1 hour before "still waiting" nag notification
DEFAULT_NOTIFY_UNLOAD_MESSAGE = "{device} finished {duration}m ago - laundry is still inside."
DEFAULT_PEAK_RATE_MESSAGE = "Running at peak rate ({price}/kWh)."
# Quiet hours default: feature off (both hours unset). See CONF_NOTIFY_QUIET_*.
DEFAULT_NOTIFY_QUIET_START_HOUR = None
DEFAULT_NOTIFY_QUIET_END_HOUR = None
# Milestone notification defaults.
DEFAULT_NOTIFY_MILESTONES = [50, 100, 500, 1000]
DEFAULT_NOTIFY_MILESTONE_MESSAGE = "{device} has completed {cycle_count} cycles!"
# Defaults
DEFAULT_MIN_POWER = 2.0 # Watts
@@ -139,7 +204,6 @@ DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT = 600 # 10 minutes
DEFAULT_SMOOTHING_WINDOW = 2
DEFAULT_SAMPLING_INTERVAL = 30.0 # Seconds
DEFAULT_START_DURATION_THRESHOLD = 5.0 # Seconds (debounce)
DEFAULT_START_ENERGY_THRESHOLD = 0.2 # Wh - Require some energy accumulation before starting
DEFAULT_END_ENERGY_THRESHOLD = 0.05 # Wh - Require effectively zero energy to end
DEFAULT_DEVICE_TYPE = "washing_machine"
DEFAULT_PROFILE_DURATION_TOLERANCE = 0.25
@@ -147,6 +211,15 @@ DEFAULT_PROFILE_DURATION_TOLERANCE = 0.25
DEFAULT_INTERRUPTED_MIN_SECONDS = 150 # Internal use only, not exposed
DEFAULT_PROGRESS_RESET_DELAY = 1800 # Seconds (30 minutes state expiry/unload window)
# Power-based Off detection (issue #284; opt-in, default off). Threshold 0 = disabled
# (the enable marker); when > 0 it must sit BELOW stop_threshold_w (beneath the idle/
# standby floor) or it is ignored. The delay is a short debounce that is safe to keep
# small because it only applies in the terminal state (no soak risk there). When enabled,
# power-off owns the terminal -> Off transition and the progress-reset timer no longer
# forces Off (the terminal state persists until the machine is actually switched off).
DEFAULT_POWER_OFF_THRESHOLD_W = 0.0 # Disabled
DEFAULT_POWER_OFF_DELAY = 30 # Seconds
DEFAULT_LEARNING_CONFIDENCE = 0.6 # Minimum confidence to request user verification
DEFAULT_DURATION_TOLERANCE = 0.10 # Allow ±10% duration variance before flagging
DEFAULT_AUTO_LABEL_CONFIDENCE = 0.9 # High confidence auto-label threshold
@@ -157,9 +230,11 @@ DEFAULT_PROFILE_MATCH_INTERVAL = (
300 # Seconds between profile matching attempts (5 minutes)
)
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO = 0.10 # Allow match after 10% of expected duration
DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO = (
1.3 # Maximum duration ratio (130% of profile) - hidden default
)
# 1.5 = up to 150% of the profile's average duration. Tuned via the precision
# harness in devtools/dtw_ab_eval.py: widening 1.3->1.5 lifts commit-recall
# 71.6%->73.4% for a negligible false-positive change; 1.3 was rejecting normal
# longer-than-average runs (extended/anti-wrinkle variants).
DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO = 1.5
DEFAULT_MAX_PAST_CYCLES = 200
DEFAULT_MAX_FULL_TRACES_PER_PROFILE = 20
DEFAULT_MAX_FULL_TRACES_UNLABELED = 20
@@ -172,6 +247,82 @@ DEFAULT_END_REPEAT_COUNT = 1 # 1 = current behavior (no repeat required)
DEFAULT_MATCH_REVERT_RATIO = 0.4 # Drop from peak score to revert to detecting
DEFAULT_DEFER_FINISH_CONFIDENCE = 0.55 # Minimum confidence to defer cycle finish
# ML live-match commit gate: P(top-1 is correct) threshold to commit a match
# before the persistence counter is satisfied. Set high to avoid false-early
# commits; the model's owner-holdout precision is ~0.87 at this score.
ML_MATCH_COMMIT_THRESHOLD = 0.85
# ML quality gate: P(cycle is a problem) threshold above which even a high-
# confidence auto-label is downgraded to a feedback request. Tuned for a
# specificity of ~0.84 (few false positives) so users are not flooded.
ML_QUALITY_SUSPICIOUS_THRESHOLD = 0.65
# Match ranking history: maximum number of per-cycle snapshots retained on-device.
# Each snapshot stores pre-computed live_match feature scalars (not traces) so
# footprint is small; 500 snapshots cover ~612 months of typical usage and are
# enough to build a per-device live_match training dataset.
MATCH_RANKING_HISTORY_MAX = 500
# Runtime overrun anomaly: a *soft, visible* signal (attribute + cycle metadata,
# never a notification) flagged once a running cycle exceeds its matched
# profile's expected duration by this ratio. Distinct from the 300% zombie-kill
# hard limit: this only surfaces "running longer than usual" for the UI. Kept
# below the zombie threshold so it lights up well before any termination.
CYCLE_OVERRUN_ANOMALY_RATIO = 1.5
# Underrun anomaly: a cycle that finishes in less than this fraction of its
# matched profile's median duration is flagged "underrun" (post-cycle only,
# never a live signal — computed in _async_process_cycle_end after the cycle
# ends). Mutually exclusive with overrun: only set when no runtime anomaly fired.
CYCLE_UNDERRUN_ANOMALY_RATIO = 0.55 # below 55% of expected duration = underrun
# Energy anomaly thresholds: a cycle whose energy deviates by more than this
# many standard deviations from the profile's historical average is flagged
# "energy_spike" or "energy_low". Stored separately from the duration anomaly
# so both can coexist. Requires at least 3 labeled cycles for the reference stats.
ENERGY_ANOMALY_Z_THRESHOLD = 2.5 # |z-score| above this = energy anomaly
# Profile warm-up mode: a newly-created profile with fewer than this many
# labeled cycles skips auto-labeling and always requests manual confirmation.
# Prevents the system from confidently mis-labeling cycles before it has seen
# enough examples of the program.
CONF_PROFILE_MIN_WARMUP_CYCLES = 5 # labeled cycles before auto-matching is enabled
# Shape drift detection: compares the average power-curve envelope of the
# earliest third of a profile's cycles against the most recent third.
# A Pearson correlation below SHAPE_DRIFT_THRESHOLD signals drift.
SHAPE_DRIFT_THRESHOLD = 0.85 # envelope correlation below this = shape drifting
SHAPE_DRIFT_MIN_CYCLES = 10 # minimum labeled cycles to check drift
SHAPE_DRIFT_RESAMPLE_N = 50 # points for envelope comparison
# Unlabeled-cycle shape clustering (A3): when suggest_coverage_gaps finds
# duration-bucketed clusters of unmatched cycles, it also checks whether the
# power-curve shapes within each bucket are similar enough to suggest a new
# profile. Uses a normalized cross-correlation on resampled traces.
CLUSTER_SHAPE_SIMILARITY_THRESHOLD = 0.75 # min correlation for shape-similar cluster
CLUSTER_RESAMPLE_N = 50 # points for pairwise comparison
# Terminal-drop fast finalize (opt-in; gated on CONF_ENABLE_ML_MODELS via the
# manager provider). A hard cliff-to-~0 at an elapsed offset EARLIER than this
# device has ever legitimately gone quiet (learned from its own completed
# cycles) is an anomaly - almost certainly a real stop (plug pulled / cancelled)
# rather than a soak pause - so the cycle is finalized quickly instead of waiting
# out the full soak-bridging min_off_gap (up to 8 min for washers, 1 h for
# dishwashers). Asymmetric like the ML end-guard, but the opposite direction: it
# can only SHORTEN the end wait, and only for anomalously-early drops.
TERMINAL_DROP_OFF_DELAY_SECONDS = 90 # shortened below-threshold wait once terminal
TERMINAL_DROP_MIN_CLEAN_CYCLES = 3 # completed cycles needed before we trust the baseline
TERMINAL_DROP_MIN_QUIET_SPAN_S = 60 # sustained sub-threshold span that counts as a legit quiet period
TERMINAL_DROP_EARLINESS_RATIO = 0.8 # fire only if drop starts < ratio * earliest-ever-quiet offset
TERMINAL_DROP_MIN_PEAK_RATIO = 5.0 # cycle must have been clearly ON (peak >= ratio * stop_threshold)
# Familiarity/novelty gate: an early hard drop is only trusted as terminal when
# the cycle's power level is one this device has produced before. A very early
# drop (below the matcher's duration gate) can't be confirmed by match
# confidence, so power level is the signal available that early: a cycle peaking
# outside the device's historical peak range (widened by this tolerance) is
# treated as potentially a NEW program and DEFERRED to the proven slow path
# rather than assumed to be a stop.
TERMINAL_DROP_PEAK_FAMILIAR_TOL = 0.4
# Cycle interruption detection defaults (internal)
DEFAULT_ABRUPT_DROP_WATTS = 500.0 # Power cliff detection threshold (W)
DEFAULT_ABRUPT_DROP_RATIO = 0.6 # 60% drop considered abrupt
@@ -193,8 +344,8 @@ DEFAULT_ANTI_WRINKLE_EXIT_POWER = 0.8 # W
# ignored because they don't sustain long enough to satisfy the normal
# start-duration gate.
DEFAULT_DELAY_START_DETECT_ENABLED = False
DEFAULT_DELAY_CONFIRM_SECONDS = 60.0 # s sustained standby before DELAY_WAIT engages
DEFAULT_DELAY_TIMEOUT_HOURS = 8.0 # h give up waiting after this long
DEFAULT_DELAY_CONFIRM_SECONDS = 60.0 # s - sustained standby before DELAY_WAIT engages
DEFAULT_DELAY_TIMEOUT_HOURS = 8.0 # h - give up waiting after this long
# Pump Monitor settings (pump device type only)
CONF_PUMP_STUCK_DURATION = "pump_stuck_duration" # Seconds before a running pump is flagged as stuck
@@ -211,8 +362,82 @@ DEFAULT_PROFILE_UNMATCH_THRESHOLD = 0.35
CONF_DTW_BANDWIDTH = "dtw_bandwidth"
DEFAULT_DTW_BANDWIDTH = 0.20 # 20% Sakoe-Chiba constraint
CONF_SUPPRESS_FEEDBACK_NOTIFICATIONS = "suppress_feedback_notifications"
DEFAULT_SUPPRESS_FEEDBACK_NOTIFICATIONS = False # Show persistent notifications by default
# ─── Matching pipeline scoring constants (analysis.py) ────────────────────────
# Previously scattered as magic numbers in analysis.py / profile_store.py.
# Centralised here so the scoring formula is auditable in one place and the
# ambiguity threshold cannot drift between its two call sites.
#
# Core similarity (Stage 2): score = CORR_WEIGHT*max(0,corr) + MAE_WEIGHT*mae_score
# where mae_score = MAE_SCALE / (MAE_SCALE + scaled_mae). See MATCH_MAE_SCALE_MODE
# in analysis.py for how scaled_mae is normalised across device power scales.
# 0.45 tuned via devtools/dtw_ab_eval.py: weighting MAE more (0.6->0.45 corr)
# lifted leave-one-out top-1 74%->79.5% AND the recall/FP net 10.7%->13.7% (FP
# flat), i.e. a genuine discrimination gain, not confidence inflation. 0.35-0.45
# is a broad plateau; 0.45 is best on top-1/MRR.
MATCH_CORR_WEIGHT = 0.45 # MAE weight is (1 - MATCH_CORR_WEIGHT), computed inline
MATCH_MAE_SCALE = 100.0 # half-saturation point of the MAE score curve
# Scale-invariant MAE (5c): the raw MAE is expressed relative to the current
# cycle's peak power before scoring, so the same *proportional* error yields the
# same confidence on a 200 W dishwasher and a 2000 W dryer. Calibrated to be
# behaviour-neutral at MATCH_MAE_REF_PEAK: at that peak scaled_mae == raw mae, so
# existing thresholds keep their meaning. The current cycle's peak is common to
# every candidate in a match, so this does not change candidate ranking.
MATCH_MAE_REF_PEAK = 1000.0 # peak (W) at which scoring matches the legacy formula
MATCH_MAE_PEAK_FLOOR = 50.0 # floor so tiny/idle traces don't explode the ratio
MATCH_KEEP_MIN_SCORE = 0.1 # candidates scoring below this are discarded
# DTW refinement (Stage 3): blended = DTW_BLEND*core + (1-DTW_BLEND)*dtw_score,
# dtw_score = DIST_SCALE / (DIST_SCALE + scaled_dtw_distance).
MATCH_DTW_BLEND = 0.5
MATCH_DTW_DIST_SCALE = 50.0
MATCH_DTW_REFINE_TOP_N = 5 # DTW is applied to this many top candidates
# (5 tuned via dtw_ab_eval: rescues correct
# profiles Stage-2 ranked 4th-5th; +1.8pp)
# Stage-3 DTW modes (config key "dtw_mode"):
# "legacy" - original: raw sequences, distance / len(current), fixed 50 W scale.
# "scaled" - both sequences resampled to MATCH_DTW_RESAMPLE_N and the distance
# expressed relative to the current peak (behaviour-neutral at
# MATCH_MAE_REF_PEAK), matching the Stage-2 MAE treatment. Default.
# "ddtw" - like "scaled" but warps on the first derivative (slope) of the
# curves, so alignment is driven by shape rather than absolute level.
# "ensemble" - blend of "scaled" and "ddtw": ENSEMBLE_W*L1 + (1-W)*DDTW.
# Defaults tuned via devtools/dtw_ab_eval.py on cycle_data/ (leave-one-out top-1):
# off 62.4%, legacy 66.4%, scaled 69.9%, ddtw 69.0%, ensemble(w=0.7,dd=30) 70.7%.
DEFAULT_DTW_MODE = "ensemble"
MATCH_DTW_RESAMPLE_N = 200 # common grid length for "scaled"/"ddtw" DTW
MATCH_DDTW_DIST_SCALE = 30.0 # half-saturation for derivative-DTW distance
MATCH_DTW_ENSEMBLE_W = 0.7 # weight on L1 vs DDTW in "ensemble" mode
# Ambiguity: top1-top2 score gap below this flags the match as ambiguous.
MATCH_AMBIGUITY_MARGIN = 0.05
# Smart Termination landscape guard: when a non-winning candidate is at least this
# much longer than the matched profile AND has a decent shape score (before Stage-4
# duration penalty), the current trace may be a *prefix* of that longer program
# rather than a completed short one. Smart Termination is blocked; the power-based
# fallback timeout decides instead. Ratio chosen so that programmes within ~50% of
# each other (e.g. Quick 46 min vs Eco 60 min, ratio 1.30) do not trigger the guard
# but genuine prefix pairs like Quick 46 vs Normal 88 min (ratio 1.91) always do.
SMART_TERM_LANDSCAPE_RATIO = 1.5 # candidate must be >= 1.5× the matched duration
SMART_TERM_LANDSCAPE_MIN_SHAPE = 0.40 # minimum shape score (pre-Stage-4) to qualify
# Number of points in the compact reference-profile curve exposed on the
# `_program` sensor (`profile_store.reference_curve`). Chosen so the resulting
# `[[offset_s, watts], ...]` attribute stays comfortably under ~1 KB regardless
# of cycle length; the raw envelope can be hundreds to thousands of points.
REFERENCE_PROFILE_CURVE_POINTS = 50
# Duration + energy agreement blended into the final score. Shape correlation
# alone cannot separate profiles that differ mainly in duration/energy (a real
# weakness on multi-program washing machines), so the final score is
# (1 - dur_w - en_w)*shape + dur_w*dur_agreement + en_w*energy_agreement, where
# agreement = 1/(1 + |ln(observed/expected)| / scale) is 1.0 on a perfect match.
# Weights 0.22 and scales tuned via devtools/dtw_ab_eval.py (weight x scale grid):
# a SHARPER agreement scale (halved) plus a moderately higher weight separates
# near-duplicate profiles on the same device rather than inflating confidence.
# This lifted the recall/FP net 13.7%->17.4% with the false-positive rate
# actually DROPPING (62.7%->59.9%). Raising weight alone at the old loose scale
# inflated both recall and FP (net-negative), so both knobs move together.
MATCH_DURATION_WEIGHT = 0.22
MATCH_ENERGY_WEIGHT = 0.22
MATCH_DURATION_SCALE = 0.175 # ~ln ratio at which duration agreement halves
MATCH_ENERGY_SCALE = 0.25 # ~ln ratio at which energy agreement halves
# States
STATE_OFF = "off"
@@ -231,33 +456,46 @@ STATE_RINSE = "rinse"
STATE_UNKNOWN = "unknown"
STATE_CLEAN = "clean" # Cycle ended but door not yet opened (laundry still inside)
# Cycle Status (how the cycle ended)
CYCLE_STATUS_COMPLETED = "completed" # Natural completion (power dropped)
CYCLE_STATUS_INTERRUPTED = (
"interrupted" # Abnormal/short run or abrupt power cliff (likely user/power abort)
)
CYCLE_STATUS_FORCE_STOPPED = "force_stopped" # Watchdog forced end (sensor offline)
CYCLE_STATUS_RESUMED = "resumed" # Cycle was restored from storage after restart
# Authoritative state -> display color map. Single source of truth for the
# full-screen panel (and any other frontend), surfaced over the WebSocket
# get_constants command so colors are defined in exactly one place. Values are
# CSS colors using Home Assistant theme variables with a hex fallback, so they
# adapt to the active theme. The "recording" key covers the manual recorder state.
STATE_COLORS = {
STATE_OFF: "var(--state-inactive-color, #9e9e9e)",
STATE_IDLE: "var(--state-inactive-color, #9e9e9e)",
STATE_DELAY_WAIT: "var(--secondary-text-color, #757575)",
STATE_STARTING: "var(--warning-color, #ff9800)",
STATE_RUNNING: "var(--success-color, #4caf50)",
STATE_PAUSED: "var(--warning-color, #ff9800)",
STATE_USER_PAUSED: "var(--warning-color, #ff9800)",
STATE_ENDING: "var(--info-color, #2196f3)",
STATE_FINISHED: "var(--success-color, #4caf50)",
STATE_ANTI_WRINKLE: "var(--info-color, #2196f3)",
STATE_INTERRUPTED: "var(--error-color, #f44336)",
STATE_FORCE_STOPPED: "var(--error-color, #f44336)",
STATE_RINSE: "var(--info-color, #2196f3)",
STATE_CLEAN: "var(--teal-color, #009688)",
STATE_UNKNOWN: "var(--disabled-color, #bdbdbd)",
"recording": "var(--error-color, #f44336)",
}
# Device Types
DEVICE_TYPE_WASHING_MACHINE = "washing_machine"
DEVICE_TYPE_DRYER = "dryer"
DEVICE_TYPE_WASHER_DRYER = "washer_dryer"
DEVICE_TYPE_DISHWASHER = "dishwasher"
DEVICE_TYPE_COFFEE_MACHINE = "coffee_machine"
DEVICE_TYPE_EV = "ev"
DEVICE_TYPE_AIR_FRYER = "air_fryer"
DEVICE_TYPE_HEAT_PUMP = "heat_pump"
DEVICE_TYPE_BREAD_MAKER = "bread_maker"
DEVICE_TYPE_PUMP = "pump"
DEVICE_TYPE_OVEN = "oven"
# Generic / unsupported bucket. Ships intentionally generic defaults that are
# not tuned for any specific appliance, so the user must configure thresholds,
# timeouts, and matching parameters themselves. Also serves as the runtime
# fallback when a deprecated device type is hard-removed (see
# DEPRECATED_DEVICE_TYPE_FALLBACK below). No curated phase catalog and no
# device-type-specific branches in the runtime, so behavior is whatever the
# user dials in.
# Full-featured generic type for predictable appliances that don't fit any of the
# named categories. Participates in profile matching/learning like any other
# device type. Ships with neutral/safe defaults; the user tunes from there.
DEVICE_TYPE_GENERIC = "generic"
# Threshold-only bucket. No profile matching. Ships intentionally generic
# defaults; the user must configure thresholds and timeouts themselves.
# Config entries whose stored device_type is no longer supported are migrated
# to this bucket on load (see __init__.py), preserving their tuned options.
DEVICE_TYPE_OTHER = "other"
DEVICE_TYPES = {
@@ -265,48 +503,20 @@ DEVICE_TYPES = {
DEVICE_TYPE_DRYER: "Dryer",
DEVICE_TYPE_WASHER_DRYER: "Washer-Dryer Combo",
DEVICE_TYPE_DISHWASHER: "Dishwasher",
DEVICE_TYPE_COFFEE_MACHINE: "Coffee Machine",
DEVICE_TYPE_EV: "Electric Vehicle",
DEVICE_TYPE_AIR_FRYER: "Air Fryer",
DEVICE_TYPE_HEAT_PUMP: "Heat Pump",
DEVICE_TYPE_BREAD_MAKER: "Bread Maker",
DEVICE_TYPE_PUMP: "Pump / Sump Pump",
DEVICE_TYPE_OVEN: "Oven",
DEVICE_TYPE_OTHER: "Other (Advanced)",
DEVICE_TYPE_GENERIC: "Other (Advanced)",
DEVICE_TYPE_OTHER: "Threshold Device",
}
# Device types that ship as deprecated. They fail one of WashData's three fit
# tests (user-selected discrete program, reproducible power signature, clean
# return to OFF) so profile matching and time-remaining estimation produce
# noise rather than signal. Kept in DEVICE_TYPES so existing config entries
# load unchanged; filtered out of the new-entry picker in the config flow,
# shown with a "(deprecated)" suffix when an existing entry already uses one,
# and surfaced via a one-shot persistent_notification on integration startup.
# Planned hard removal: 0.4.6 (two release cycles after this deprecation).
DEPRECATED_DEVICE_TYPES = frozenset({
DEVICE_TYPE_COFFEE_MACHINE,
DEVICE_TYPE_EV,
DEVICE_TYPE_HEAT_PUMP,
DEVICE_TYPE_OVEN,
})
# Fallback device_type used at runtime once a deprecated type is hard-removed.
# "Other (Advanced)" intentionally ships generic defaults so the integration
# does not silently pretend an orphaned entry behaves like a washing machine.
# Stored options are preserved as-is, so a user who had hand-tuned thresholds
# on the old deprecated type keeps those values; the integration just stops
# layering device-specific defaults underneath them.
DEPRECATED_DEVICE_TYPE_FALLBACK = DEVICE_TYPE_OTHER
# Device Type Defaults
# Device Type Defaults (Maps)
DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT_BY_DEVICE = {
DEVICE_TYPE_DISHWASHER: 14400, # 4 hours (Drying can be long)
DEVICE_TYPE_HEAT_PUMP: 14400, # 4 hours (Heat pumps can run a long time with slow updates)
DEVICE_TYPE_BREAD_MAKER: 7200, # 2 hours (Proving/Rising is very low-power for extended periods)
DEVICE_TYPE_PUMP: DEFAULT_PUMP_STUCK_DURATION + 60, # Must exceed stuck-alarm threshold so the alarm fires before the watchdog
DEVICE_TYPE_OVEN: 14400, # 4 hours (Slow roasts and pyrolytic self-clean can run for hours with thermostat-driven silence)
}
DEFAULT_MAX_DEFERRAL_SECONDS = 14400 # 4 hours max safe deferral
@@ -315,7 +525,7 @@ DEFAULT_MAX_DEFERRAL_SECONDS = 14400 # 4 hours max safe deferral
#
# A dishwasher's wash→drying drain wind-down produces brief power spikes mid
# ENDING that, prior to the issue #43 fix, would set _end_spike_seen=True and
# pre-arm Smart Termination so the cycle closed at 99% of expected, BEFORE
# pre-arm Smart Termination - so the cycle closed at 99% of expected, BEFORE
# the real end-of-cycle pump-out at ~99.5% of expected. The pump-out then
# registered as a brand-new cycle.
#
@@ -337,14 +547,55 @@ DISHWASHER_END_SPIKE_MIN_PROGRESS = 0.85
# to capture even the latest pump-outs while still guaranteeing the cycle
# closes eventually for dishwashers that have no pump-out at all.
DISHWASHER_END_SPIKE_WAIT_SECONDS = 1800.0
# Minimum reasonable dishwasher cycle duration (seconds). Even the shortest
# quick programmes take at least 30 min; defer _should_defer_finish for any
# dishwasher whose cycle has not yet crossed this floor, regardless of whether
# a profile match is available yet.
DISHWASHER_MIN_CYCLE_DURATION_S = 1800.0
# Once a dishwasher is in ENDING and power has been sustained-quiet for this
# long, the active cycle is over - only the passive drain/dry tail remains.
# Live re-matching is frozen past this point: continuing to re-match on the
# ever-growing idle tail inflates the observed duration and drifts the Stage-4
# duration-agreement score toward LONGER near-duplicate profiles, which would
# flip the stored label and stall smart-termination on the ambiguity gate.
# The active-phase match is complete
# by now, so freezing it preserves the correct program identity. A real
# resume (mid-cycle soak) sends a high reading that leaves ENDING and re-arms
# matching, so this is self-correcting.
DISHWASHER_MATCH_FREEZE_QUIET_SECONDS = 300.0
# Release the end-of-cycle pump-out wait early once a dishwasher has BOTH reached
# its expected duration AND been sustained-quiet this long afterwards. This lets a
# cycle that ran slightly shorter than the profile's (drifted-up) average - and whose
# terminal pump-out landed before the drop into ENDING, so no in-ENDING end-spike ever
# armed - finalise near its expected end instead of hanging the full
# DISHWASHER_END_SPIKE_WAIT_SECONDS (30 min) past expected. Gated on reaching the
# expected duration so a long passive-drying phase that still precedes a genuinely-late
# pump-out (quiet from ~50%-99% of expected) keeps waiting and its real pump-out is
# caught by the end-spike arm first. Smaller than the 30-min window but large enough
# to confirm a terminal tail rather than an inter-phase gap.
DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS = 600.0
# Confirmation window a dishwasher must spend in ENDING before Smart Termination
# fires. This is deliberately a FIXED constant and NOT derived from off_delay:
# off_delay must be large (up to ~30 min) to bridge a dishwasher's long passive
# drying "pause" so a single cycle is not split by the fallback timeout, but that
# large value must NOT delay Smart Termination - which ends the cycle near the
# matched profile's expected duration so the finish notification is timely. A
# previous formula (max(300, off_delay*0.25)) coupled the two: a suggested
# off_delay of 1800-1999 s inflated this window to 450-500 s, and on the sparsely
# sampled near-zero drying tail the eligibility instant could fall in a gap
# between samples, slipping the cycle's end by 20+ min or leaving it to only end
# via the fallback timeout (which snaps the trace back and drops the drying tail)
# or a manual stop. 300 s (the old floor, proven on a hand-tuned production
# dishwasher running off_delay=180) settles transient dips without starving the
# end. Smart Termination is independently gated on duration >= expected*ratio, so
# a shorter window can never fire it mid-cycle.
DISHWASHER_SMART_TERMINATION_DEBOUNCE_SECONDS = 300.0
DEFAULT_OFF_DELAY_BY_DEVICE = {
DEVICE_TYPE_DISHWASHER: 1800, # 30 min (Drying)
DEVICE_TYPE_COFFEE_MACHINE: 300, # 5 min (Warming/Pause handling)
DEVICE_TYPE_HEAT_PUMP: 600, # 10 min (Defrosting pauses)
DEVICE_TYPE_BREAD_MAKER: 300, # 5 min (Keep-warm phase after baking)
DEVICE_TYPE_PUMP: 20, # 20 s (Pumps cut off sharply; no warm-down phase)
DEVICE_TYPE_OVEN: 600, # 10 min (Thermostat off-cycles can be long while holding temp)
}
# Device-specific progress smoothing thresholds (percentage points)
@@ -354,30 +605,21 @@ DEVICE_SMOOTHING_THRESHOLDS = {
DEVICE_TYPE_DRYER: 3.0, # More linear, less phase repetition
DEVICE_TYPE_WASHER_DRYER: 5.0, # Combined washer+dryer, use washer defaults
DEVICE_TYPE_DISHWASHER: 5.0, # Similar to washing machine with distinct phases
DEVICE_TYPE_COFFEE_MACHINE: 2.0, # Short cycles, rapid transitions, less tolerance
DEVICE_TYPE_AIR_FRYER: 2.0, # Constant load with sudden drop
DEVICE_TYPE_HEAT_PUMP: 5.0, # Variable load, long periods
DEVICE_TYPE_BREAD_MAKER: 5.0, # Large power swings between kneading, proving, baking
DEVICE_TYPE_PUMP: 2.0, # Binary on/off spikes; minimal smoothing needed
DEVICE_TYPE_OVEN: 5.0, # Bistable thermostat cycling between full heat and 0 W
DEVICE_TYPE_GENERIC: 3.0, # Neutral middle ground for unknown appliance types
}
CONF_VERIFICATION_POLL_INTERVAL = "verification_poll_interval" # Internal setting
DEFAULT_VERIFICATION_POLL_INTERVAL = 15 # Seconds (rapid checks after delay)
# Device specific completion thresholds (min run time to be considered a valid "completed" cycle)
DEVICE_COMPLETION_THRESHOLDS = {
DEVICE_TYPE_WASHING_MACHINE: 600, # 10 min
DEVICE_TYPE_DRYER: 600, # 10 min
DEVICE_TYPE_WASHER_DRYER: 600, # 10 min (same as washer)
DEVICE_TYPE_DISHWASHER: 900, # 15 min
DEVICE_TYPE_COFFEE_MACHINE: 60, # 1 min (Filter coffee cycle)
DEVICE_TYPE_EV: 600, # 10 min
DEVICE_TYPE_AIR_FRYER: 300, # 5 min minimum
DEVICE_TYPE_HEAT_PUMP: 900, # 15 min minimum
DEVICE_TYPE_BREAD_MAKER: 1800, # 30 min (even express bread takes 30+ min)
DEVICE_TYPE_PUMP: 5, # 5 s - pump cycles can be under 30 seconds
DEVICE_TYPE_OVEN: 600, # 10 min (covers quick reheats and ignores brief preheating tests)
}
# Default min_off_gap by device type (seconds)
@@ -391,13 +633,9 @@ DEFAULT_MIN_OFF_GAP_BY_DEVICE = {
DEVICE_TYPE_DRYER: 300, # 5 min (Cool down gaps?)
DEVICE_TYPE_WASHER_DRYER: 600, # 10 min (longer for combined cycles)
DEVICE_TYPE_DISHWASHER: 3600, # 1 hour (Drying pauses)
DEVICE_TYPE_COFFEE_MACHINE: 120, # 2 min (Session grouping)
DEVICE_TYPE_EV: 900, # 15 min (Brief unplug/replug)
DEVICE_TYPE_AIR_FRYER: 120, # 2 min (Shaking food)
DEVICE_TYPE_HEAT_PUMP: 1800, # 30 min (Defrost cycle / resting gap)
DEVICE_TYPE_BREAD_MAKER: 600, # 10 min (Resting between knead/prove keeps same cycle together)
DEVICE_TYPE_PUMP: 60, # 1 min (Pumps can cycle every 3-5 min in heavy rain)
DEVICE_TYPE_OVEN: 900, # 15 min (Bridge thermostat off-windows so one bake stays a single cycle)
}
DEFAULT_MIN_OFF_GAP = 60 # Scalar fallback
@@ -409,13 +647,9 @@ DEFAULT_START_ENERGY_THRESHOLDS_BY_DEVICE = {
DEVICE_TYPE_DRYER: 0.5, # Heater kicks in hard
DEVICE_TYPE_WASHER_DRYER: 0.3, # Mix of washer and dryer
DEVICE_TYPE_DISHWASHER: 0.2, # Pump/Heater
DEVICE_TYPE_COFFEE_MACHINE: 0.05, # Short heater burst
DEVICE_TYPE_EV: 0.5, # High power charging
DEVICE_TYPE_AIR_FRYER: 0.2, # Heater kicks in
DEVICE_TYPE_HEAT_PUMP: 0.2, # Compressor spins up
DEVICE_TYPE_BREAD_MAKER: 0.2, # Kneading motor starts (~200W for a few seconds)
DEVICE_TYPE_PUMP: 0.003, # ~100W motor for ~0.1 s is enough to confirm a pump cycle
DEVICE_TYPE_OVEN: 0.5, # Heating element kicks in hard (~2-3 kW) - high gate filters incidental light/fan draws
}
# Default sampling interval by device type
DEFAULT_SAMPLING_INTERVAL_BY_DEVICE = {
@@ -424,7 +658,6 @@ DEFAULT_SAMPLING_INTERVAL_BY_DEVICE = {
DEVICE_TYPE_WASHING_MACHINE: 2.0,
DEVICE_TYPE_WASHER_DRYER: 2.0,
DEVICE_TYPE_DISHWASHER: 2.0,
DEVICE_TYPE_COFFEE_MACHINE: 10.0, # 10s is sufficient for brew cycles
DEVICE_TYPE_PUMP: 10.0, # 10s - pump cycles can be <30 s; 30s default would miss them
}
@@ -433,8 +666,29 @@ DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO_BY_DEVICE = {
DEVICE_TYPE_DISHWASHER: 0.10,
}
# Profile groups (Stage 5): the matcher only collapses a group into one
# aggregate candidate when its members' minimum pairwise shape similarity is at
# least this. Similarity is DTW/Sakoe-Chiba on peak-normalised envelopes, so it
# tolerates the duration (longer heating/draining) and amplitude (temp/spin)
# variation between real members. Looser groups stay individual (a blurry generic
# aggregate could out-match unrelated profiles) and are flagged in the UI.
# Calibrated on real profiles: genuine temp/spin variants score ~0.86-0.95,
# distinct programs <~0.6; 0.80 leaves margin below the 0.85 suggestion bar.
GROUP_MIN_COHESION = 0.80
# Storage
STORAGE_VERSION = 5
# v6: backfill ml_review.golden=True for manually-recorded cycles (recorded ==
# golden reference; a single flag, no duplicate "recorded" field).
# v7: re-run that backfill (broadened to the meta.original_samples marker) so
# installs already at v6 that carry unflagged recorded cycles are caught too —
# the v6 step only ran for installs upgrading from below v6.
# v8: re-run again after _is_recorded_cycle gained the structural fallback
# (completed + no max_power/termination_reason) so OLD recordings that carry
# only meta:None — which the marker-only v6/v7 backfill missed — are tagged.
# v9: pre-initialize additive top-level keys (lifetime_energy_wh,
# settings_changelog, maintenance_log) so they are present from first load
# rather than only appearing lazily on first use.
STORAGE_VERSION = 10
STORAGE_KEY = "ha_washdata"
# Notification events
@@ -450,12 +704,161 @@ SERVICE_SUBMIT_FEEDBACK = (
"ha_washdata.submit_cycle_feedback" # Service to submit feedback
)
# Recorder
STATE_RECORDING = "recording"
CONF_RECORD_MODE = "record_mode"
SERVICE_RECORD_START = "record_start"
SERVICE_RECORD_STOP = "record_stop"
# ─── Feature flags (staged rollout) ───────────────────────────────────────────
# These gate preproduction / ML features so they can be shipped dark and unlocked
# in stages. When a flag is False the corresponding UI *and* logic stay hidden:
# no panel sections render and no background work runs.
#
# SHOW_ML_LAB ML Lab comparison tab in the WashData panel.
# ENABLE_ML_SUGGESTIONS ML-model-driven setting suggestions (Stage 3), shown
# side-by-side with the classic statistical suggestions.
# ENABLE_ML_TRAINING On-device model training loop (Stage 4): scheduled
# retraining on the user's own labeled cycles.
#
# Stage 1 (new statistical suggestions) and Stage 2 (fixed classic algorithms)
# are always on - they only improve the existing suggestion engine and add no
# new surfaces, so they need no flag.
SHOW_ML_LAB = True
ENABLE_ML_SUGGESTIONS = True
ENABLE_ML_TRAINING = True
# Thresholds for trim suggestions
SHORT_SILENCE_THRESHOLD_S = 600 # 10 minutes
TRIM_BUFFER_S = 60.0 # 1 minute buffer
# ─── Community store (online features) ────────────────────────────────────────
# Opt-in browsing/importing/sharing of reference cycles via the WashData Store.
# When the option is off the Store tab and all network calls stay inert.
CONF_ENABLE_ONLINE_FEATURES = "enable_online_features" # master gate, default False
CONF_STORE_BRAND = "store_brand" # declared appliance brand
CONF_STORE_MODEL = "store_model" # declared appliance model
DEFAULT_ENABLE_ONLINE_FEATURES = False
# Device-level settings that may be shared/adopted with a device bundle (Stage 3).
# These are recognition/matching thresholds intrinsic to the appliance MODEL (the
# same for everyone with that machine), never environment/plug/identity settings:
# no entity ids, notify services, energy price, sampling cadence, smoothing,
# housekeeping timers, plug-robustness (end_repeat_count) or device-behaviour
# toggles (anti-wrinkle, delay-start). Kept as one editable allow-list so share and
# adopt agree on exactly what travels. All values are plain numbers -> nothing here
# can leak PII or a user's HA topology.
SHAREABLE_SETTING_KEYS: tuple[str, ...] = (
# Detection / recognition
CONF_MIN_POWER,
CONF_OFF_DELAY,
CONF_START_THRESHOLD_W,
CONF_STOP_THRESHOLD_W,
CONF_START_DURATION_THRESHOLD,
CONF_START_ENERGY_THRESHOLD,
CONF_COMPLETION_MIN_SECONDS,
CONF_RUNNING_DEAD_ZONE,
CONF_MIN_OFF_GAP,
CONF_END_ENERGY_THRESHOLD,
CONF_POWER_OFF_THRESHOLD_W,
CONF_POWER_OFF_DELAY,
# Matching
CONF_PROFILE_MATCH_THRESHOLD,
CONF_PROFILE_UNMATCH_THRESHOLD,
CONF_PROFILE_MATCH_INTERVAL,
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
CONF_PROFILE_DURATION_TOLERANCE,
CONF_DURATION_TOLERANCE,
CONF_AUTO_LABEL_CONFIDENCE,
CONF_LEARNING_CONFIDENCE,
)
# Public Firebase web config for the community store (NOT secret - identifies the
# project; access is enforced by the store's Firestore rules).
STORE_PROJECT_ID = "washdata-store"
STORE_API_KEY = "AIzaSyDzq0MoWdU_21CSohZUhIIV7ZwfWppjcAk"
STORE_WEB_ORIGIN = "https://3dg1luk43.github.io/washdata-store"
# Reference-cycle trace format versions this integration can import.
SUPPORTED_CYCLE_SCHEMA_VERSIONS = {1}
# Obfuscated provenance codes stamped on an uploaded cycle (see store.derive_qc).
QC_RECORDING = 1 # pure recorder capture
QC_EDITED = 2 # trimmed/edited from a detected cycle
QC_MANUAL = 3 # a plain detected cycle flagged golden by hand
# ─── On-device ML training (Stage 4) ──────────────────────────────────────────
# Config keys for the scheduled, opt-in retraining loop. All gated behind
# ENABLE_ML_TRAINING; nothing runs and no options render when that flag is False.
CONF_ML_TRAINING_ENABLED = "ml_training_enabled" # per-device opt-in
CONF_ML_TRAINING_HOUR = "ml_training_hour" # local hour (0-23) to train
CONF_ML_TRAINING_MIN_CYCLES = "ml_training_min_cycles" # min labelled clean cycles before training
CONF_ML_TRAINING_INTERVAL_DAYS = "ml_training_interval_days" # min days between retrains
DEFAULT_ML_TRAINING_ENABLED = False
DEFAULT_ML_TRAINING_HOUR = 2 # 02:00 local - quiet hour
DEFAULT_ML_TRAINING_MIN_CYCLES = 30 # need a meaningful corpus first
DEFAULT_ML_TRAINING_INTERVAL_DAYS = 7 # retrain at most weekly
# A newly trained model is only promoted over the shipped baseline when its
# held-out AUC is at least (baseline AUC - this margin). Small negative slack is
# allowed so personalisation can win even at a tiny AUC cost.
ML_TRAINING_AUC_MARGIN = 0.02
# Separate tolerance for the calibration gate: a retrained classifier must not
# degrade balanced accuracy AT the live operating cutoff by more than this. Kept
# distinct from ML_TRAINING_AUC_MARGIN because it bounds a different metric (decision
# quality at a fixed threshold, not overall rank quality); same 0.02 default today.
ML_TRAINING_BACC_MARGIN = 0.02
ML_TRAINING_MIN_POSITIVES = 20 # need at least this many positive examples to trust a fit
# Per-capability held-out-score history kept across training runs, so the panel
# can show whether a model's fit is improving, steady, or declining over time
# (drift). Compact (one number per capability per run); this caps how many runs
# are retained.
ML_TRAINING_HISTORY_MAX = 30
# Remaining-time regressor (standardized_linear). Unlike the classifier heads it
# has no shipped baseline; it is only promoted when its held-out mean-absolute
# error on the completion-fraction target beats the naive elapsed/expected
# estimate by at least this relative margin (5% lower MAE). Trained from prefixes
# of the device's own clean cycles.
ML_TRAINING_REGRESSION_MARGIN = 0.05
ML_TRAINING_MIN_REGRESSION_ROWS = 30 # synthesized prefix rows needed to fit
# How strongly a promoted remaining-time regressor influences the live progress
# estimate. The ML completion-fraction is blended with the phase-aware estimate
# at this weight before the existing EMA smoothing/monotonicity guards run, so a
# bad model can never wholly override the proven phase estimator.
ML_PROGRESS_BLEND_WEIGHT = 0.5
# Service + event names for the training loop.
SERVICE_TRIGGER_ML_TRAINING = "trigger_ml_training"
EVENT_ML_TRAINING_COMPLETE = "ha_washdata_ml_training_complete"
# ─── Suggestion quality gates ──────────────────────────────────────────────────
# A suggestion is only stored / surfaced when it clears both thresholds:
# (a) relative delta >= MIN_SUGGESTION_REL_DELTA OR
# absolute delta >= per-key absolute minimum (see _suggestion_min_abs_delta)
# Suggestions that are below BOTH thresholds are deleted so they don't clutter
# the panel with noise (e.g. 0.67 → 0.68).
MIN_SUGGESTION_REL_DELTA = 0.08 # 8% minimum relative change
# After the user applies suggestions, suppress new suggestions for this many
# completed cycles. Prevents the engine from immediately re-suggesting
# slightly-different values based on a single new cycle.
MIN_SUGGESTION_COOLDOWN_CYCLES = 3
# ─── Appliance health & predictive maintenance (Group E) ───────────────────────
# Per-device maintenance-reminder thresholds: a dict {event_type: cycle_threshold}
# persisted via ws_set_options. When the number of completed cycles since the most
# recent maintenance event of a given type reaches its threshold, the event type is
# surfaced (sensor attribute + panel banner). A threshold of 0 (or an absent key)
# disables reminders for that event type.
CONF_MAINTENANCE_REMINDER_CYCLES = "maintenance_reminder_cycles"
DEFAULT_MAINTENANCE_REMINDER_CYCLES = {
"descale": 30,
"filter_clean": 50,
"drum_clean": 100,
}
# Recognised maintenance event types. bearing_service / other default off (absent
# from the default reminder dict) and are opt-in.
MAINTENANCE_EVENT_TYPES = (
"descale",
"filter_clean",
"drum_clean",
"bearing_service",
"other",
)
# A logged maintenance event of a matching type within this many days suppresses
# the "needs maintenance" nag advisory (duration-trend / shape-drift).
MAINTENANCE_RECENT_SUPPRESS_DAYS = 30
+410 -76
View File
@@ -1,3 +1,19 @@
# 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/>.
"""Cycle detection logic for WashData."""
from __future__ import annotations
@@ -13,6 +29,8 @@ from homeassistant.util import dt as dt_util
from .log_utils import DeviceLoggerAdapter
from .const import (
ANTI_WRINKLE_ELIGIBLE_REASONS,
TerminationReason,
STATE_OFF,
STATE_DELAY_WAIT,
STATE_STARTING,
@@ -30,21 +48,43 @@ from .const import (
DEFAULT_MAX_DEFERRAL_SECONDS,
DEFAULT_DEFER_FINISH_CONFIDENCE,
DISHWASHER_END_SPIKE_MIN_PROGRESS,
DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS,
DISHWASHER_END_SPIKE_WAIT_SECONDS,
DISHWASHER_SMART_TERMINATION_DEBOUNCE_SECONDS,
DISHWASHER_MATCH_FREEZE_QUIET_SECONDS,
DISHWASHER_MIN_CYCLE_DURATION_S,
TERMINAL_DROP_OFF_DELAY_SECONDS,
)
# The dishwasher end-spike wait window is shared between two code paths
# (Smart Termination's wait branch and _should_defer_finish's no-end-spike
# branch). They MUST release the cycle at the same instant sanity-check
# branch). They MUST release the cycle at the same instant - sanity-check
# that the constants module loaded a sensible value rather than allowing the
# paths to silently drift if one was changed and the other forgotten.
assert DISHWASHER_END_SPIKE_WAIT_SECONDS > 0, (
"DISHWASHER_END_SPIKE_WAIT_SECONDS must be positive"
)
assert 0 < DISHWASHER_END_SPIKE_MIN_PROGRESS < 1, (
"DISHWASHER_END_SPIKE_MIN_PROGRESS must be a fraction in (0, 1)"
)
from .signal_processing import integrate_wh
if DISHWASHER_END_SPIKE_WAIT_SECONDS <= 0:
# Runtime check (not assert: asserts are stripped under python -O).
raise ValueError("DISHWASHER_END_SPIKE_WAIT_SECONDS must be positive")
# Opt-in ML end-detection guard (Stage 6). When the manager injects an
# end-confidence provider (only when the user enabled ML models for the device),
# the cycle-end model can defer a *normal* completion if it judges the current
# low-power event to be a pause rather than the true end. This is intentionally
# asymmetric: it can only *delay* a completion, never end a cycle early, and it
# is bounded, so a wrong model can slow a finish but can neither stop one early
# nor hang the cycle. Force-stop / smart-termination / user paths never consult
# it. Overridable emphasis lives here rather than const.py to keep the guard
# self-contained (it is detector-internal policy, not user configuration).
ML_END_GUARD_MIN_CONFIDENCE = 0.5 # P(true end) below this -> treat as a likely pause
ML_END_GUARD_MAX_DEFER_SECONDS = 1800.0 # cap the extra wait the guard may add (30 min)
# The opt-in ML end-guard / terminal-drop providers rebuild the trace and run
# inference on every ENDING-phase evaluation. During a long quiet tail (e.g. a
# dishwasher's up-to-1h soak) that is wasteful, so recompute at most this often
# (data-clock seconds). Safe to cache: the guard only ever *defers* and terminal
# drop only ever *shortens*, so both tolerate a value up to this window stale.
ML_PROVIDER_THROTTLE_SECONDS = 30.0
if not 0 < DISHWASHER_END_SPIKE_MIN_PROGRESS < 1:
raise ValueError("DISHWASHER_END_SPIKE_MIN_PROGRESS must be a fraction in (0, 1)")
from .signal_processing import energy_gap_threshold_s, integrate_wh
_LOGGER = logging.getLogger(__name__)
@@ -80,6 +120,11 @@ class CycleDetectorConfig:
start_threshold_w: float = 2.0
stop_threshold_w: float = 2.0
min_duration_ratio: float = 0.8 # Default deferred finish ratio
# Power-based Off detection (issue #284). Carried on the config so the manager
# (the single owner of the terminal -> Off transition) can read them live; the
# detector itself does not act on them. 0 = disabled.
power_off_threshold_w: float = 0.0
power_off_delay: float = 30.0
match_interval: int = 300 # Default profile match interval
profile_duration_tolerance: float = 0.25 # Default tolerance (±25%)
anti_wrinkle_enabled: bool = False
@@ -95,13 +140,6 @@ class CycleDetectorConfig:
delay_timeout_seconds: float = 28800.0
@dataclass
class CycleDetectorState:
"""Internal state storage for save/restore."""
state: str = STATE_OFF
sub_state: str | None = None
accumulated_energy_wh: float = 0.0
# Add other fields as needed
@@ -178,6 +216,12 @@ class CycleDetector:
| None
) = None,
device_name: str = "",
end_confidence_provider: (
Callable[[list[tuple[float, float]], float], float | None] | None
) = None,
terminal_drop_provider: (
Callable[[list[tuple[float, float]], float], bool | None] | None
) = None,
) -> None:
"""Initialize the cycle detector."""
self._logger = DeviceLoggerAdapter(_LOGGER, device_name)
@@ -185,6 +229,24 @@ class CycleDetector:
self._on_state_change = on_state_change
self._on_cycle_end = on_cycle_end
self._profile_matcher = profile_matcher
# Opt-in ML end-guard: (points, expected_duration) -> P(true end) or None.
# Injected by the manager; None disables the guard (existing behavior).
self._end_confidence_provider = end_confidence_provider
# Opt-in terminal-drop detector: (points, expected_duration) -> bool.
# True means the current low-power event is an anomalously-early hard
# cliff-to-0 (never seen this early on this device), so the cycle may be
# finalized without waiting out the full soak-bridging min_off_gap.
# Injected by the manager; None disables it (existing behavior). Opposite
# asymmetry to the end-guard: it can only ever *shorten* the end wait.
self._terminal_drop_provider = terminal_drop_provider
# Throttle caches for the two providers, scoped to the cycle + expectation:
# (last_reading_ts, expected_duration, cycle_start, result). Reused only
# within the recompute window when expected_duration and cycle_start match.
self._ml_end_cache: tuple[datetime, float, datetime, float | None] | None = None
self._terminal_drop_cache: tuple[datetime, float, datetime, bool] | None = None
# Cycle duration (s) at which the ML guard first deferred the current
# ending episode; bounds how long the guard may keep deferring.
self._ml_defer_start_duration: float | None = None
# State
self._state = STATE_OFF
@@ -227,6 +289,9 @@ class CycleDetector:
self._expected_duration: float = 0.0
self._last_match_confidence: float = 0.0
self._end_spike_seen: bool = False
self._end_spike_duration: float = 0.0 # cycle duration (s) when _end_spike_seen was last set
self._match_ambiguous: bool = False # last live match was ambiguous (gates predictive end)
self._match_prefix_ambiguous: bool = False # longer candidate with good shape exists (prefix guard)
# Anti-wrinkle tracking (dryers only)
self._anti_wrinkle_candidate_start: datetime | None = None
@@ -242,7 +307,7 @@ class CycleDetector:
# diagnostics and tests.
self._delay_band_start: datetime | None = None
self._delay_band_seconds: float = 0.0
# _delay_band_peak is purely diagnostic surfaced in the log line
# _delay_band_peak is purely diagnostic - surfaced in the log line
# when the transition fires so users can see what their machine's
# actual standby plateau looked like.
self._delay_band_peak: float = 0.0
@@ -254,7 +319,7 @@ class CycleDetector:
# _delay_wait_high_start anchors the first high-power reading
# observed inside DELAY_WAIT. We only transition to STARTING
# when the high-power streak has lasted at least
# start_duration_threshold real seconds measured between two
# start_duration_threshold real seconds - measured between two
# consecutive high readings, not from the dt to the previous
# (low) reading. This prevents a single isolated spike from
# tripping STARTING just because the sampling interval is long.
@@ -306,6 +371,22 @@ class CycleDetector:
if not self._power_readings:
return
# Terminal-tail match freeze (dishwashers): once we are in ENDING with a
# profile already matched and power has been sustained-quiet, the active
# cycle is over - only the passive drain/dry tail remains. Re-matching on
# the growing idle tail inflates the observed duration and drifts the
# Stage-4 duration-agreement toward a LONGER near-duplicate profile,
# flipping the label and stalling smart-termination on the ambiguity gate.
# Keep the active-phase match instead. Self-correcting: a real resume sends
# a high reading that leaves ENDING, so this guard stops applying.
if (
self._state == STATE_ENDING
and self._config.device_type == "dishwasher"
and self._matched_profile
and self._time_below_threshold >= DISHWASHER_MATCH_FREEZE_QUIET_SECONDS
):
return
# Rate limiting
if not force and self._last_match_time:
elapsed = (timestamp - self._last_match_time).total_seconds()
@@ -345,7 +426,7 @@ class CycleDetector:
this helper so the gates in STATE_ENDING and ``_should_defer_finish``
can trust the value without re-validating.
Emits a DEBUG log line distinguishing the rejection reason the
Emits a DEBUG log line distinguishing the rejection reason - the
``<= 0`` and ``> 6h`` markers are part of issue #197's regression
contract and tests assert on them.
"""
@@ -391,9 +472,15 @@ class CycleDetector:
phase_name: str | None = None
confidence: float = 0.0
expected_duration: float = 0.0
ambiguous: bool = False
if isinstance(result, (list, tuple)): # type: ignore[misc]
result_seq = cast(tuple[Any, ...] | list[Any], result)
# Optional 6th element: whether the live match is ambiguous
# (top-1 vs top-2 within MATCH_AMBIGUITY_MARGIN). Used to gate the
# predictive Smart Termination below.
if len(result_seq) >= 6:
ambiguous = bool(result_seq[5])
if len(result_seq) >= 5:
(
raw_name,
@@ -442,8 +529,10 @@ class CycleDetector:
)
is_match_mismatch = False
# Store confidence for Smart Termination checks
# Store confidence + ambiguity for Smart Termination checks
self._last_match_confidence = confidence or 0.0
self._match_ambiguous = ambiguous
self._match_prefix_ambiguous = bool(result_seq[6]) if len(result_seq) >= 7 else False
else:
# Assume MatchResult object or similar (future proofing)
# But for now wrapper returns tuple
@@ -452,6 +541,8 @@ class CycleDetector:
if is_match_mismatch and self._matched_profile:
# Confident non-match - revert to detecting if previously matched
self._matched_profile = None
self._match_ambiguous = False
self._match_prefix_ambiguous = False
elif match_name:
# If sanitization rejected the expected_duration, treat the match
@@ -461,7 +552,7 @@ class CycleDetector:
# the cycle stays in detecting/unmatched mode.
if expected_duration == self._SANITIZE_INVALID_SENTINEL:
self._logger.debug(
"update_match: match %r ignored expected_duration "
"update_match: match %r ignored - expected_duration "
"sanitized to invalid sentinel; treating as unmatched",
match_name,
)
@@ -497,6 +588,10 @@ class CycleDetector:
self._matched_profile = None
self._ignore_power_until_idle = False # Reset lockout
self._lockout_high_seconds = 0.0
# Clear the verified-pause flag so it can't leak into the next cycle (B6):
# a stale True would make an early low-power dip look like a verified pause
# before the first live match of the new cycle runs.
self._verified_pause = False
self._anti_wrinkle_candidate_start = None
self._anti_wrinkle_candidate_peak = 0.0
self._anti_wrinkle_candidate_start_power = 0.0
@@ -625,7 +720,11 @@ class CycleDetector:
anti_wrinkle_active = (
self._config.anti_wrinkle_enabled
and self._config.device_type in (DEVICE_TYPE_DRYER, DEVICE_TYPE_WASHER_DRYER)
and self._config.device_type in (
DEVICE_TYPE_WASHING_MACHINE,
DEVICE_TYPE_DRYER,
DEVICE_TYPE_WASHER_DRYER,
)
)
# 3. State Machine
@@ -723,8 +822,8 @@ class CycleDetector:
#
# A machine in delayed-start mode sits in a power band between
# the off-noise floor (stop_threshold_w) and the cycle-start
# threshold (start_threshold_w) display, electronics, the
# occasional anti-damp tumble for minutes to hours. We
# threshold (start_threshold_w) - display, electronics, the
# occasional anti-damp tumble - for minutes to hours. We
# track anchored elapsed time while power is in that band; once
# it crosses delay_confirm_seconds we transition to DELAY_WAIT.
#
@@ -766,7 +865,7 @@ class CycleDetector:
)
self._transition_to(STATE_DELAY_WAIT, timestamp)
return
# Stay in OFF while we accumulate evidence do not
# Stay in OFF while we accumulate evidence - do not
# fall through to the high-power start logic, the
# reading is below threshold by definition.
return
@@ -781,7 +880,7 @@ class CycleDetector:
# STATE_STARTING will abort it as a false start and we'll
# re-enter the band check on the next sample without
# losing accumulated time (we don't reset on a high
# excursion most users' "menu navigation" peaks last
# excursion - most users' "menu navigation" peaks last
# less than a sample interval anyway).
if is_high and not started_from_anti_wrinkle:
@@ -793,13 +892,14 @@ class CycleDetector:
self._energy_since_idle_wh = power * (dt / 3600.0) if dt > 0 else 0.0
self._cycle_max_power = power
self._abrupt_drop = False
elif self._state != STATE_OFF:
# Auto-expire terminal states after 30 minutes
if (
self._state_enter_time
and (timestamp - self._state_enter_time).total_seconds() > 1800
):
self._transition_to(STATE_OFF, timestamp)
# NOTE: terminal-state expiry (Finished/Interrupted/Force-Stopped -> Off)
# is owned solely by the manager (WashDataManager._handle_state_expiry),
# which has a wall-clock timer that also fires when a change-only power
# sensor stops reporting, plus the opt-in power-based Off (issue #284).
# The detector used to auto-expire here after a hardcoded 30 min, but that
# duplicated the manager timer (a weaker, per-reading subset) and left the
# manager's bookkeeping (progress, clean overlay, notifications) dangling.
# ANTI_WRINKLE -> Off is handled by its own idle/timeout logic above.
elif self._state == STATE_DELAY_WAIT:
if power >= self._config.start_threshold_w:
@@ -843,7 +943,7 @@ class CycleDetector:
self._cycle_max_power = max(start_power, power)
self._abrupt_drop = False
else:
# Power dropped back below start threshold clear the
# Power dropped back below start threshold - clear the
# high-power streak anchor so the next high reading
# starts a fresh confirmation window.
self._delay_wait_high_start = None
@@ -883,18 +983,24 @@ class CycleDetector:
if self._energy_since_idle_wh >= self._config.start_energy_threshold:
self._transition_to(STATE_RUNNING, timestamp)
# Abort if power drops below threshold before confirmation
# Abort if power drops below threshold before confirmation.
# Skip the abort when the user has explicitly paused the cycle
# (issue #306): a user pause sets verified_pause=True, which signals
# that the low power is intentional, not a false start.
if not is_high and self._time_below_threshold > 1.0: # 1s grace period
# False start
self._logger.debug(
"False start detected: power dropped after %.2fs",
self._time_above_threshold,
)
self._delay_band_start = None
self._delay_band_seconds = 0.0
self._delay_band_peak = 0.0
self._preserve_delay_band_on_off = False
self._transition_to(STATE_OFF, timestamp)
if getattr(self, "_verified_pause", False):
pass # user pause holds; wait for Resume Cycle
else:
# False start
self._logger.debug(
"False start detected: power dropped after %.2fs",
self._time_above_threshold,
)
# Do NOT reset _delay_band_* here — _transition_to(STATE_OFF) will
# preserve the band via _preserve_delay_band_on_off if it was set
# at STARTING entry (line 838), so a brief high-power peak (menu
# navigation) doesn't restart the delayed-start accumulation from zero.
self._transition_to(STATE_OFF, timestamp)
elif self._state == STATE_RUNNING:
self._power_readings.append((timestamp, power))
@@ -955,6 +1061,7 @@ class CycleDetector:
>= self._expected_duration * DISHWASHER_END_SPIKE_MIN_PROGRESS
):
self._end_spike_seen = True
self._end_spike_duration = current_duration
self._logger.debug(
"End spike detected (power high in ENDING state, "
"%.0fs/%.0fs)",
@@ -1037,9 +1144,23 @@ class CycleDetector:
# 2. Require debounce to be measured FROM entry into ENDING state
if self._config.device_type == "dishwasher":
smart_ratio = (
0.99 # Very conservative for dishwashers to catch end spikes
)
# If the most-recent in-ENDING spike occurred at ≥90% of
# expected, it is the terminal pump-out, not a mid-cycle
# rinse drain. Once that pump-out is confirmed, we don't
# need to wait for 99% of the rolling avg — individual
# cycles can be up to ~7% shorter than avg_duration and
# still terminate cleanly. Keeping the 0.99 gate for
# spikes at <90% prevents premature closes during the
# passive Dry phase that follows the pre-final-rinse drain.
_esp_dur = getattr(self, "_end_spike_duration", 0.0)
if (
getattr(self, "_end_spike_seen", False)
and self._expected_duration > 0
and _esp_dur >= self._expected_duration * 0.90
):
smart_ratio = 0.90 # pump-out confirmed near end
else:
smart_ratio = 0.99 # conservative: wait for expected duration
else:
smart_ratio = 0.98
@@ -1047,13 +1168,43 @@ class CycleDetector:
getattr(self, "_last_match_confidence", 0.0) >= 0.4
)
# Gate the predictive end on match certainty.
# _match_ambiguous: top-1 vs top-2 score gap is too small to
# trust the matched profile's expected duration — fall through
# to the power-based fallback timeout instead.
# _match_prefix_ambiguous: a longer candidate with a similar
# shape score exists in the pool. The current trace may be a
# prefix of that longer program (e.g. Quick 46 min matched
# while the machine is actually running Normal 88 min and
# happens to be in a mid-cycle soak dip at the 46-min mark).
# Blocking Smart Termination here means a true Quick cycle
# waits for the fallback timeout instead of getting an early
# close — an acceptable trade-off against the alternative of
# splitting a Normal wash into two separate cycle records.
if (
current_duration >= (self._expected_duration * smart_ratio)
and is_confident_match
and not self._match_ambiguous
and not self._match_prefix_ambiguous
):
# Dynamic confirmation window
if self._config.device_type == "dishwasher":
smart_debounce = max(300.0, self._config.off_delay * 0.25)
# Fixed - NOT off_delay-derived. off_delay is sized to
# bridge the long drying "pause", but must not delay the
# end; see DISHWASHER_SMART_TERMINATION_DEBOUNCE_SECONDS.
smart_debounce = DISHWASHER_SMART_TERMINATION_DEBOUNCE_SECONDS
elif self._config.device_type in (
DEVICE_TYPE_WASHING_MACHINE,
DEVICE_TYPE_WASHER_DRYER,
):
# Washing machines and washer-dryers have soak and
# rinse gaps that can dip for several minutes between
# programme phases. Require quiet time equal to half
# the soak-bridging min_off_gap before committing
# Smart Termination, so a near-duplicate profile
# doesn't cut a long cycle short during a mid-cycle
# power trough.
smart_debounce = max(180.0, self._config.min_off_gap * 0.5)
else:
smart_debounce = 120.0
@@ -1061,7 +1212,7 @@ class CycleDetector:
# --- END SPIKE WAIT PERIOD (Dishwashers) ---
# Dishwashers should see the real end-of-cycle
# pump-out (which arms _end_spike_seen via the 85%
# progress gate) before Smart Termination fires
# progress gate) before Smart Termination fires -
# otherwise the pump-out arrives AFTER the cycle
# has already closed and registers as a brand-new
# "ghost" cycle. User reports (issue #43) showed
@@ -1075,9 +1226,31 @@ class CycleDetector:
# guarantees the cycle terminates eventually for
# dishwashers that have no pump-out at all.
end_spike_seen = getattr(self, "_end_spike_seen", False)
# Release the pump-out wait once EITHER the cycle has run
# DISHWASHER_END_SPIKE_WAIT_SECONDS past its expected
# duration OR it has already reached its expected duration
# AND power has since stayed sustained-quiet for
# DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS. The second arm
# closes cycles that finish shorter than the profile's
# (drifted-up) average and whose terminal pump-out lands
# *before* the drop into ENDING, so no in-ENDING end-spike
# ever arms - without it they hang to the fallback timeout
# (~30-44 min late) and their label can even drift to a longer
# near-duplicate profile. It is gated on
# ``current_duration >= expected`` so it can NOT fire during a
# long passive-drying phase that precedes a genuinely-late
# pump-out (e.g. an ECO cycle quiet from 50%-99% of expected):
# while still short of expected the cycle keeps waiting, and a
# real pump-out at ~99% arms the end-spike first. Takes the
# SOONER of the two anchors, so it can only ever shorten the
# wait, never extend it.
past_wait_period = current_duration >= (
self._expected_duration
+ DISHWASHER_END_SPIKE_WAIT_SECONDS
) or (
current_duration >= self._expected_duration
and self._time_below_threshold
>= DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS
)
if (
self._config.device_type == "dishwasher"
@@ -1105,7 +1278,7 @@ class CycleDetector:
self._finish_cycle(
timestamp,
status="completed",
termination_reason="smart",
termination_reason=TerminationReason.SMART,
keep_tail=True,
)
return
@@ -1131,6 +1304,40 @@ class CycleDetector:
effective_off_delay = min(effective_off_delay, 1800)
gate_window = effective_off_delay
# Opt-in terminal-drop fast finalize (asymmetric, shorten-only):
# a hard cliff-to-~0 sustained for TERMINAL_DROP_OFF_DELAY_SECONDS
# that began earlier than this device has ever legitimately gone
# quiet is almost certainly a real stop (plug pulled / cancelled),
# not a soak. Finalize now instead of waiting out the full
# soak-bridging min_off_gap. Only consulted when there is a longer
# wait to shorten and the provider is wired (ML/anomaly opt-in);
# the energy/defer gates are bypassed because the sustained sub-
# threshold span already proves the appliance is off, and the
# anomaly check has ruled out a legitimate early pause.
if (
self._terminal_drop_provider is not None
and not self._verified_pause
and effective_off_delay > TERMINAL_DROP_OFF_DELAY_SECONDS
and self._time_below_threshold >= TERMINAL_DROP_OFF_DELAY_SECONDS
and self._is_terminal_drop()
):
start_time = self._current_cycle_start or timestamp
current_duration = (timestamp - start_time).total_seconds()
self._logger.info(
"Terminal drop: anomalously-early power cliff after %.0fs "
"(device never quiet this early) - finalizing without the "
"full %.0fs soak wait.",
current_duration,
effective_off_delay,
)
self._finish_cycle(
timestamp,
status="interrupted",
termination_reason=TerminationReason.TERMINAL_DROP,
keep_tail=False,
)
return
if self._time_below_threshold >= effective_off_delay:
recent_window = [
@@ -1159,7 +1366,8 @@ class CycleDetector:
# Compute energy in recent window
recent_ts = np.array([r[0].timestamp() for r in recent_window])
recent_p = np.array([r[1] for r in recent_window])
recent_e = integrate_wh(recent_ts, recent_p)
max_gap_s = energy_gap_threshold_s(recent_ts)
recent_e = integrate_wh(recent_ts, recent_p, max_gap_s=max_gap_s)
if recent_e <= self.config.end_energy_threshold:
start_time = self._current_cycle_start or timestamp
@@ -1189,6 +1397,11 @@ class CycleDetector:
self._time_in_state = 0.0
self._sub_state = new_state.capitalize() # Default substate
# Bound each ENDING episode's ML-guard deferral independently: clear the
# tracker whenever we are not in ENDING (e.g. on resume back to RUNNING).
if new_state != STATE_ENDING:
self._ml_defer_start_duration = None
# Reset energy accumulator on transition to OFF
if new_state == STATE_OFF:
self._energy_since_idle_wh = 0.0
@@ -1206,6 +1419,7 @@ class CycleDetector:
# Reset end spike tracker when entering ENDING state
if new_state == STATE_ENDING:
self._end_spike_seen = False
self._end_spike_duration = 0.0
elif new_state == STATE_DELAY_WAIT:
# Band-accumulation tracker already played its role getting us
# here; reset it so a future OFF→band cycle starts fresh.
@@ -1235,14 +1449,76 @@ class CycleDetector:
self._logger.debug("Transition: %s -> %s at %s", old_state, new_state, timestamp)
self._on_state_change(old_state, new_state)
def should_defer_for_profile(self) -> bool:
"""Check if we should defer termination for profile matching (public)."""
start_time = self._current_cycle_start
if not self._matched_profile or self._expected_duration <= 0 or not start_time:
return False
def _ml_end_confidence(self) -> float | None:
"""P(the current low-power event is the true end) from the opt-in ML guard.
current_duration = (dt_util.now() - start_time).total_seconds()
return self._should_defer_finish(current_duration)
Builds the offset-second trace from the current cycle's readings and asks
the injected provider. Returns None when there is no provider, no cycle
start, or the provider declines (ML off / unmatched / model unavailable),
so the caller keeps the existing power/energy-based behavior.
"""
provider = self._end_confidence_provider
start = self._current_cycle_start
if provider is None or start is None or not self._power_readings:
return None
# Throttle: reuse the last result within the recompute window, but only when
# it was computed for THIS cycle and the same expected_duration (which can
# change under overrun) — otherwise recompute.
now_ts = self._power_readings[-1][0]
exp = float(self._expected_duration)
cache = self._ml_end_cache
if (
cache is not None
and cache[1] == exp
and cache[2] == start
and (now_ts - cache[0]).total_seconds() < ML_PROVIDER_THROTTLE_SECONDS
):
return cache[3]
points = [
((ts - start).total_seconds(), float(power))
for ts, power in self._power_readings
]
try:
result = provider(points, exp)
except Exception: # noqa: BLE001 - ML must never break detection
result = None
self._ml_end_cache = (now_ts, exp, start, result)
return result
def _is_terminal_drop(self) -> bool:
"""Whether the current low-power event is an anomalously-early hard drop.
Mirrors ``_ml_end_confidence``: builds the offset-second trace from the
current cycle's readings and asks the injected terminal-drop provider.
Returns ``False`` when there is no provider, no cycle start, or the
provider declines/raises (ML off / too little history / not anomalous),
so the caller keeps the proven soak-bridging end-detection.
"""
provider = self._terminal_drop_provider
start = self._current_cycle_start
if provider is None or start is None or not self._power_readings:
return False
# Throttle: reuse within the window, scoped to this cycle + expected_duration.
now_ts = self._power_readings[-1][0]
exp = float(self._expected_duration)
cache = self._terminal_drop_cache
if (
cache is not None
and cache[1] == exp
and cache[2] == start
and (now_ts - cache[0]).total_seconds() < ML_PROVIDER_THROTTLE_SECONDS
):
return cache[3]
points = [
((ts - start).total_seconds(), float(power))
for ts, power in self._power_readings
]
try:
result = bool(provider(points, exp))
except Exception: # noqa: BLE001 - ML must never break detection
result = False
self._terminal_drop_cache = (now_ts, exp, start, result)
return result
def _should_defer_finish(self, duration: float) -> bool:
"""Check if we should defer termination based on expected duration."""
@@ -1251,6 +1527,22 @@ class CycleDetector:
self._logger.debug("Deferring cycle finish: Verified pause active")
return True
# Dishwasher minimum-duration floor: even without a matched profile (e.g.
# first cycle of a program, or the 5-min matcher hasn't fired yet) a
# dishwasher cycle should never end before it has crossed the minimum
# reasonable programme duration. This prevents a dip during the fill or
# early wash phase from being read as the end of a complete cycle.
if (
self._config.device_type == "dishwasher"
and duration < DISHWASHER_MIN_CYCLE_DURATION_S
):
self._logger.debug(
"Deferring dishwasher cycle end: elapsed %.0fs < minimum %.0fs",
duration,
DISHWASHER_MIN_CYCLE_DURATION_S,
)
return True
if not self._matched_profile or self._expected_duration <= 0:
return False
@@ -1264,11 +1556,36 @@ class CycleDetector:
)
return False
# Opt-in ML end-guard (asymmetric anti-premature-stop, bounded). If the
# cycle-end model judges this low-power event to be more likely a pause
# than the true end, defer the normal completion - but only for a bounded
# extra window, so a wrong model can delay, never hang, the cycle. As the
# low-power run lengthens the model's confidence rises, so a genuine end
# is released once the model agrees or the cap is reached.
if (
self._end_confidence_provider is not None
and self._last_match_confidence >= DEFAULT_DEFER_FINISH_CONFIDENCE
):
confidence = self._ml_end_confidence()
if confidence is not None and confidence < ML_END_GUARD_MIN_CONFIDENCE:
if self._ml_defer_start_duration is None:
self._ml_defer_start_duration = duration
if (duration - self._ml_defer_start_duration) < ML_END_GUARD_MAX_DEFER_SECONDS:
self._logger.debug(
"Deferring cycle finish: ML end-guard (P(true end)=%.2f < %.2f)",
confidence,
ML_END_GUARD_MIN_CONFIDENCE,
)
return True
elif confidence is not None:
# Model is confident this is the true end -> stop ML-deferring.
self._ml_defer_start_duration = None
# Dishwasher passive drying protection:
# Dishwashers can have 2+ hour passive drying phases at near-0W. A terminal
# drain spike that fires early in the ENDING state (e.g. at 120 min of a
# 233-min ECO cycle) resets _time_below_threshold, and the subsequent 60-min
# silence timeout would otherwise end the cycle at ~180 min well before the
# silence timeout would otherwise end the cycle at ~180 min - well before the
# real finish. Defer until the cycle reaches the late-phase threshold (the
# same one used by the end-spike arm gate, so both move together) so that
# smart termination can catch the true end (~99% of expected) instead.
@@ -1296,13 +1613,26 @@ class CycleDetector:
# passive-drying gate above, we still keep the cycle deferred until
# the real end-of-cycle pump-out fires (sets _end_spike_seen=True via
# the 85% progress gate in STATE_ENDING) or we cross the
# smart-termination wait window (expected + 30 min) whichever comes
# smart-termination wait window (expected + 30 min) - whichever comes
# first. Shares DISHWASHER_END_SPIKE_WAIT_SECONDS with Smart
# Termination's wait branch so the two paths release the cycle at the
# same instant. Beyond the wait window, Smart Termination's
# past_wait_period kicks in and finalises; below it, the fallback
# timeout's energy gate is the safety net for cycles whose pump-out
# never arrives.
# Mirrors the STATE_ENDING pump-out wait so both paths release together.
# Keep deferring while we are still inside the wait window, UNLESS the cycle
# has already reached its expected duration and has since been sustained-quiet
# for DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS - in which case any terminal
# pump-out has already happened, so a cycle that finished slightly short of the
# profile's (drifted-up) average is released here instead of hanging to
# expected + 30 min. The ``duration >= expected`` gate keeps a long
# passive-drying phase that still precedes a late pump-out deferred.
quiet_released = (
duration >= self._expected_duration
and self._time_below_threshold
>= DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS
)
if (
self._config.device_type == "dishwasher"
and self._matched_profile
@@ -1310,13 +1640,15 @@ class CycleDetector:
and not self._end_spike_seen
and duration
< (self._expected_duration + DISHWASHER_END_SPIKE_WAIT_SECONDS)
and not quiet_released
):
self._logger.debug(
"Deferring cycle finish: dishwasher waiting for end-of-cycle "
"pump-out (%.0fs < expected %.0fs + %.0fs wait, profile: %s)",
"pump-out (%.0fs < expected %.0fs + %.0fs wait, quiet %.0fs, profile: %s)",
duration,
self._expected_duration,
DISHWASHER_END_SPIKE_WAIT_SECONDS,
self._time_below_threshold,
self._matched_profile,
)
return True
@@ -1365,7 +1697,7 @@ class CycleDetector:
self,
timestamp: datetime,
status: str = "completed",
termination_reason: str = "timeout",
termination_reason: str = TerminationReason.TIMEOUT,
keep_tail: bool = False,
) -> None:
"""Finalize cycle.
@@ -1439,9 +1771,13 @@ class CycleDetector:
target = STATE_FORCE_STOPPED
elif (
status == "completed"
and termination_reason in {"timeout", "smart"}
and termination_reason in ANTI_WRINKLE_ELIGIBLE_REASONS
and self._config.anti_wrinkle_enabled
and self._config.device_type in (DEVICE_TYPE_DRYER, DEVICE_TYPE_WASHER_DRYER)
and self._config.device_type in (
DEVICE_TYPE_WASHING_MACHINE,
DEVICE_TYPE_DRYER,
DEVICE_TYPE_WASHER_DRYER,
)
):
target = STATE_ANTI_WRINKLE
@@ -1454,7 +1790,7 @@ class CycleDetector:
self._finish_cycle(
timestamp,
status="force_stopped",
termination_reason="force_stopped",
termination_reason=TerminationReason.FORCE_STOPPED,
keep_tail=False, # Force stop usually implies snap back to reality
)
self._ignore_power_until_idle = False
@@ -1466,7 +1802,7 @@ class CycleDetector:
self._finish_cycle(
now,
status="completed",
termination_reason="user",
termination_reason=TerminationReason.USER,
keep_tail=True, # User implies "Done Now"
)
# Prevent immediate restart if power is still high
@@ -1507,6 +1843,10 @@ class CycleDetector:
self._state_enter_time.isoformat() if self._state_enter_time else None
),
"end_spike_seen": self._end_spike_seen,
"end_spike_duration": self._end_spike_duration,
"match_ambiguous": self._match_ambiguous,
"match_prefix_ambiguous": self._match_prefix_ambiguous,
"ml_defer_start_duration": self._ml_defer_start_duration,
}
def get_elapsed_seconds(self) -> float:
@@ -1522,16 +1862,6 @@ class CycleDetector:
and self._time_below_threshold > 0
)
def low_power_elapsed(self, now: datetime) -> float:
"""Return duration of current low power spell including time since last process."""
if self._time_below_threshold > 0 and self._last_process_time:
# Add time since last processing
return (
self._time_below_threshold
+ (now - self._last_process_time).total_seconds()
)
return self._time_below_threshold
def restore_state_snapshot(self, snapshot: dict[str, Any]) -> None:
"""Restore state from snapshot."""
try:
@@ -1566,6 +1896,10 @@ class CycleDetector:
self._matched_profile = restored_match
self._expected_duration = sanitized_expected
self._end_spike_seen = snapshot.get("end_spike_seen", False)
self._end_spike_duration = float(snapshot.get("end_spike_duration", 0.0))
self._match_ambiguous = snapshot.get("match_ambiguous", False)
self._match_prefix_ambiguous = snapshot.get("match_prefix_ambiguous", False)
self._ml_defer_start_duration = snapshot.get("ml_defer_start_duration")
# Restore state enter time and recompute time_in_state from it
enter_time = snapshot.get("state_enter_time")
@@ -1,3 +1,19 @@
# 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/>.
"""Diagnostic ring buffers for WashData - rolling 24-hour window.
Each WashDataManager owns one DiagBuffer instance that accumulates three
@@ -112,6 +128,12 @@ class DiagBuffer:
"""Record one raw power-sensor reading (call *before* any throttling)."""
self._power.append((ts.timestamp(), watts))
def power_samples(self, since_ts: float | None = None) -> list[tuple[float, float]]:
"""Return raw (unix_ts, watts) readings, optionally only those at/after since_ts."""
if since_ts is None:
return list(self._power)
return [(ts, w) for ts, w in self._power if ts >= since_ts]
def record_state(
self,
from_state: str,
@@ -1,3 +1,19 @@
# 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/>.
"""Diagnostics support for WashData."""
from __future__ import annotations
@@ -22,6 +38,10 @@ _SENSITIVE_KEYS = {
"title",
"unique_id",
"user_id",
# Community-store account credentials / identifiers.
"refresh_token",
"id_token",
"uid",
# HA entity / service references that reveal home topology.
"notify_service",
"notify_start_services",
+32 -165
View File
@@ -1,3 +1,19 @@
# 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/>.
"""Feature extraction logic for WashData.
Constraint: NumPy only.
@@ -7,25 +23,9 @@ Constraint: All computations must be dt-aware.
from dataclasses import dataclass
import numpy as np
@dataclass
class PowerEvent:
"""Represent a detected power change event."""
timestamp: float
magnitude: float # Absolute change in Watts
rate: float # Slope W/s
direction: str # "rising" or "falling"
from .signal_processing import energy_gap_threshold_s, integrate_wh
@dataclass
class CyclePhase:
"""Represent a distinct phase within a cycle."""
start_ts: float
end_ts: float
label: str # HEATER, MOTOR, IDLE, etc.
avg_power: float
@dataclass
@@ -35,7 +35,7 @@ class CycleSignature:
duration: float
total_energy: float
max_power: float
event_density: float # Events per minute
event_density: float # Deprecated/reserved: always 0.0 (event detector removed); kept for signature back-compat
time_to_first_high: float # Seconds to first HEATER/HIGH phase
high_phase_ratio: float # Duration of high phases / total duration
# Distributions (quantiles of power)
@@ -46,144 +46,16 @@ class CycleSignature:
p95: float
def detect_events(
timestamps: np.ndarray,
power: np.ndarray,
idle_mad: float,
min_event_watts: float = 50.0,
) -> list[PowerEvent]:
"""Detect significant power events using dp/dt.
Args:
timestamps: Time array (seconds).
power: Power array (Watts).
idle_mad: Media Absolute Deviation of idle baseline (noise floor).
min_event_watts: Absolute floor for an event to be considered.
"""
if len(power) < 2:
return []
dt = np.diff(timestamps)
dp = np.diff(power)
# Avoid div by zero
valid = dt > 0.1
rate = np.zeros_like(dp)
rate[valid] = dp[valid] / dt[valid]
# Adaptive threshold
# 3-sigma equivalent: 3 * 1.4826 * MAD ~= 4.5 * MAD
# But for dp/dt, noise scales differently.
# Let's use absolute threshold + noise factor.
noise_allowance = max(10.0, 5.0 * idle_mad)
events: list[PowerEvent] = []
for i, r in enumerate(rate):
if not valid[i]:
continue
mag = abs(dp[i])
# Criteria: Significant rate AND significant magnitude
# We want to ignore small jitter even if rate is high (dt small)
if mag > min_event_watts and abs(r) > noise_allowance: # Rate threshold W/s
# Basic check: if dt is tiny (1s) and power jump is 50W, rate is 50 W/s.
# If dt is 10s and power jump is 50W, rate is 5 W/s.
# Real heater on: 2000W in ~2s => 1000 W/s.
# Motor tumble: 200W in 1s => 200 W/s.
direction = "rising" if r > 0 else "falling"
events.append(
PowerEvent(
timestamp=timestamps[i], magnitude=mag, rate=r, direction=direction
)
)
return events
def segment_phases(timestamps: np.ndarray, power: np.ndarray) -> list[CyclePhase]:
"""Segment cycle into phases using quantile-based thresholds.
Labels:
- IDLE: < p10 (or min threshold)
- MOTOR: p25 - p75 approx
- HEATER/HIGH: > p90
Refined logic:
1. Calculate cycle quantiles.
2. Define levels: LOW, MED, HIGH.
3. Run-length encoding or simple state machine.
"""
if len(power) < 10:
return []
# Quantiles
q_low = np.percentile(power, 25)
q_high = np.percentile(power, 90)
# Enforce device minimums to avoid "High" label on a 5W phone charger cycle
min_high = 500.0
min_motor = 50.0
# Adjust thresholds
thresh_high = max(q_high, min_high)
thresh_med = max(q_low, min_motor)
labels: list[str] = []
for p in power:
if p >= thresh_high:
labels.append("HEATER")
elif p >= thresh_med:
labels.append("MOTOR")
else:
labels.append("IDLE")
# Merge consecutive
phases: list[CyclePhase] = []
if not labels:
return []
current_label: str = labels[0]
start_idx = 0
for i in range(1, len(labels)):
if labels[i] != current_label:
# End current phase
phases.append(
CyclePhase(
start_ts=timestamps[start_idx],
end_ts=timestamps[i - 1],
label=current_label,
avg_power=float(np.mean(power[start_idx:i])),
)
)
current_label = labels[i]
start_idx = i
# Last one
phases.append(
CyclePhase(
start_ts=timestamps[start_idx],
end_ts=timestamps[-1],
label=current_label,
avg_power=float(np.mean(power[start_idx:])),
)
)
return phases
def compute_signature(
timestamps: np.ndarray, power: np.ndarray, events: list[PowerEvent] | None = None
timestamps: np.ndarray, power: np.ndarray
) -> CycleSignature:
"""Compute compact signature for candidate rejection/matching.
Args:
timestamps: Timestamps (seconds)
power: Power (Watts)
events: Pre-computed events (optional)
"""
if len(power) == 0:
# Return empty/zero signature
@@ -191,15 +63,10 @@ def compute_signature(
duration = timestamps[-1] - timestamps[0]
# Energy approx
dt = np.diff(timestamps)
# Simple rectangular for speed here, or integrate_wh
if len(dt) > 0:
p_avg = (power[:-1] + power[1:]) / 2
total_energy = np.sum(p_avg * (dt / 3600.0))
else:
total_energy = 0.0
# Energy (trapezoidal Wh) via the shared integrator - single source of truth.
total_energy = integrate_wh(timestamps, power, max_gap_s=energy_gap_threshold_s(timestamps))
dt = np.diff(timestamps) # sample intervals (s), reused by the high-phase ratio
max_p = np.max(power)
# Quantiles
@@ -219,20 +86,20 @@ def compute_signature(
# Time in high / total time
# Check dt where high_mask holds
if len(dt) > 0:
# Align mask with intervals
# mask[i] corresponds to interval i? roughly
high_dur = np.sum(dt[high_mask[:-1]])
# Align mask with intervals; mask[i] corresponds to interval i.
# Exclude sensor-outage gaps: a long gap after a high-power sample is a data
# dropout, not high-phase time, so cap those intervals to 0 (mirrors the energy
# integrator's gap handling via energy_gap_threshold_s).
max_gap = energy_gap_threshold_s(timestamps)
capped_dt = np.where(dt > max_gap, 0.0, dt)
high_dur = np.sum(capped_dt[high_mask[:-1]])
high_phase_ratio = high_dur / duration if duration > 0 else 0
else:
high_phase_ratio = 0.0
# Event density
if not events:
# Compute locally if needed, but ideally passed in
pass
event_count = len(events) if events else 0
event_density = (event_count / (duration / 60.0)) if duration > 60 else 0
# Event density: always 0 now that the event detector is gone; retained as a
# signature field for backward compatibility with stored signatures.
event_density = 0.0
return CycleSignature(
duration=float(duration),
+181 -4
View File
@@ -1,4 +1,20 @@
"""Frontend card registration for WashData."""
# 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/>.
"""Frontend card and panel registration for WashData."""
import logging
import os
@@ -13,6 +29,19 @@ LOCAL_SUBDIR = "ha_washdata"
CARD_NAME = "ha-washdata-card.js"
INTEGRATION_URL = f"/{LOCAL_SUBDIR}/{CARD_NAME}"
CARD_REGISTERED = "registered"
# Full-screen panel constants
PANEL_JS_NAME = "ha-washdata-panel.js"
PANEL_JS_URL = f"/{LOCAL_SUBDIR}/{PANEL_JS_NAME}"
PANEL_ELEMENT = "ha-washdata-panel"
PANEL_URL_PATH = "ha-washdata"
PANEL_REGISTERED_KEY = "ha_washdata_panel_registered"
PANEL_STATIC_REGISTERED = "ha_washdata_panel_static_registered"
# Per-language panel translations are served straight from the integration's
# translations/panel/ directory (one {lang}.json per language). The panel fetches
# only the user's language + en fallback, instead of one monolithic bundle.
PANEL_TRANSLATIONS_DIRNAME = "panel"
PANEL_TRANSLATIONS_URL = f"/{LOCAL_SUBDIR}/panel-translations"
CARD_DEFERRED = "deferred"
CARD_FAILED = "failed"
CardRegisterResult = Literal["registered", "deferred", "failed"]
@@ -26,10 +55,10 @@ class LovelaceResourceItem(TypedDict, total=False):
res_type: str
def get_cache_buster() -> str:
"""Generate a stable cache buster based on card asset mtime."""
def get_cache_buster(filename: str = CARD_NAME) -> str:
"""Generate a stable cache buster based on a www asset's mtime."""
try:
src = Path(__file__).parent / "www" / CARD_NAME
src = Path(__file__).parent / "www" / filename
return str(int(os.path.getmtime(src)))
except OSError:
# Deterministic fallback when file is unavailable.
@@ -221,3 +250,151 @@ class WashDataCardRegistration:
_LOGGER.debug("Auto-registered lovelace resource for %s", INTEGRATION_URL)
return CARD_REGISTERED
return CARD_FAILED
async def async_register_panel(hass: HomeAssistant) -> bool:
"""Serve ha-washdata-panel.js and register a sidebar panel with Home Assistant.
Safe to call on every integration setup; subsequent calls are no-ops once
hass.data[PANEL_REGISTERED_KEY] is set. Returns True on success.
"""
if hass.data.get(PANEL_REGISTERED_KEY):
return True
src = Path(__file__).parent / "www" / PANEL_JS_NAME
# Path.exists() hits the filesystem; offload it so the event loop is not
# blocked on I/O during setup.
if not await hass.async_add_executor_job(src.exists):
_LOGGER.warning("Panel JS not found at %s — sidebar panel not registered", src)
return False
# Serve the JS module under /ha_washdata/ha-washdata-panel.js.
# Await the static path registration directly so the file is available
# before HA fires EVENT_PANELS_UPDATED and the frontend tries to import it.
# Guard with a flag set *before* the await so two concurrent setup_entry
# calls (multiple devices) don't both try to register the same route and
# log a benign "method GET is already registered" debug line.
if not hass.data.get(PANEL_STATIC_REGISTERED):
hass.data[PANEL_STATIC_REGISTERED] = True
# Outer guard: a static-path registration failure (including a raising
# fallback) must not escape and abort setup -- degrade gracefully like the
# sidebar-registration section below.
try:
try:
from homeassistant.components.http import StaticPathConfig # pylint: disable=import-outside-toplevel
if hasattr(hass.http, "async_register_static_paths"):
await hass.http.async_register_static_paths(
[StaticPathConfig(PANEL_JS_URL, str(src), True)]
)
else:
_register_static_path(hass, PANEL_JS_URL, str(src))
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.debug("Panel static path registration failed, falling back: %s", exc)
_register_static_path(hass, PANEL_JS_URL, str(src))
# Serve the translations/panel/ directory for per-user-language loading.
# The panel fetches /ha_washdata/panel-translations/{lang}.json (+ en.json
# fallback) on demand, so browsers only download the language(s) in use
# rather than a monolithic all-languages bundle.
trans_src = Path(__file__).parent / "translations" / PANEL_TRANSLATIONS_DIRNAME
# Filesystem check offloaded to the executor (see note above).
if await hass.async_add_executor_job(trans_src.is_dir):
try:
from homeassistant.components.http import StaticPathConfig # pylint: disable=import-outside-toplevel
if hasattr(hass.http, "async_register_static_paths"):
await hass.http.async_register_static_paths(
[StaticPathConfig(PANEL_TRANSLATIONS_URL, str(trans_src), True)]
)
else:
_register_static_path(hass, PANEL_TRANSLATIONS_URL, str(trans_src))
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.debug("Panel translations path registration failed: %s", exc)
_register_static_path(hass, PANEL_TRANSLATIONS_URL, str(trans_src))
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.warning("WashData panel static path registration failed: %s", exc)
hass.data.pop(PANEL_STATIC_REGISTERED, None)
return False
# Re-check after the await: with multiple WashData devices, all concurrent
# setup_entry calls pass the initial guard before any one of them sets the
# key. The first to resume after the await wins; the rest bail out here.
if hass.data.get(PANEL_REGISTERED_KEY):
return True
# Register the sidebar panel using the built-in "custom" component type.
# ha-panel-custom reads panel.config.module_url and imports it dynamically,
# then instantiates the element named in panel.config.name.
try:
from homeassistant.components import frontend # pylint: disable=import-outside-toplevel
# Cache-buster query so browsers refetch the module after each update
# while still honoring immutable cache headers between releases.
# get_cache_buster() calls os.path.getmtime(), a synchronous FS stat, so
# offload it to the executor rather than blocking the event loop.
panel_version = await hass.async_add_executor_job(
get_cache_buster, PANEL_JS_NAME
)
# HA's ha-panel-custom.ts reads panel.config._panel_custom for the
# loading parameters (name, module_url, etc.). Flat config keys at the
# top level are NOT read by the frontend — only _panel_custom is.
# This matches what panel_custom.async_register_panel() produces.
frontend.async_register_built_in_panel(
hass,
component_name="custom",
sidebar_title="WashData",
sidebar_icon="mdi:washing-machine",
frontend_url_path=PANEL_URL_PATH,
config={
"_panel_custom": {
"name": PANEL_ELEMENT,
"module_url": f"{PANEL_JS_URL}?v={panel_version}",
"embed_iframe": False,
"trust_external": False,
}
},
require_admin=False,
)
hass.data[PANEL_REGISTERED_KEY] = True
_LOGGER.debug("WashData sidebar panel registered at /%s", PANEL_URL_PATH)
return True
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.warning("Failed to register WashData panel: %s", exc)
return False
async def async_unregister_panel(hass: HomeAssistant) -> None:
"""Tear down the WashData sidebar panel and its static routes.
Integration teardown counterpart to :func:`async_register_panel`. Intended to
be called from ``async_unload_entry`` when the *final* WashData config entry
is removed, so no stale panel registration, sidebar entry, or static route is
left behind. Mirrors the register flow and is guarded by the same
once-per-boot keys (``PANEL_REGISTERED_KEY`` / ``PANEL_STATIC_REGISTERED``),
so it is a no-op when the panel was never registered and is safe to call
repeatedly. After clearing the guards a later setup revalidates the assets
and registers the panel + routes again.
"""
if not hass.data.get(PANEL_REGISTERED_KEY) and not hass.data.get(
PANEL_STATIC_REGISTERED
):
return
# Remove the sidebar panel using Home Assistant's supported API.
if hass.data.get(PANEL_REGISTERED_KEY):
try:
from homeassistant.components import frontend # pylint: disable=import-outside-toplevel
frontend.async_remove_panel(hass, PANEL_URL_PATH)
_LOGGER.debug("WashData sidebar panel removed from /%s", PANEL_URL_PATH)
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.debug("Failed to remove WashData panel: %s", exc)
# Home Assistant exposes no public API to unregister a previously registered
# static path; clearing the guards lets a later setup revalidate the assets
# and re-register the routes (a benign "already registered" debug line at
# worst) rather than leaving a stale registration flag behind.
hass.data.pop(PANEL_REGISTERED_KEY, None)
hass.data.pop(PANEL_STATIC_REGISTERED, None)
+353
View File
@@ -0,0 +1,353 @@
# 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/>.
"""Home Assistant conversation intents for WashData.
Lets users ask Assist (voice or text) natural-language questions about their
appliances and get a plain-language spoken/text answer derived from the live
manager/sensor state, for example:
"Is my washer done?"
"How long until the dryer finishes?"
The single intent registered here is :data:`INTENT_STATUS`
(``"HaWashdataStatus"``), handled by :class:`WashDataStatusIntentHandler`.
Wiring trigger sentences
------------------------
Registering the :class:`intent.IntentHandler` (via :func:`async_setup_intents`)
makes the intent handleable — it can be fired immediately from automations, the
``intent_script`` integration, developer tools, or the Assist pipeline **once a
sentence maps text to it**. Home Assistant has no public runtime API for a
*custom* integration to inject sentences into the built-in conversation agent,
so trigger sentences are wired by the user with a config-directory sentence pack.
A ready-to-use pack ships in the repo at ``docs/custom_sentences/en/ha_washdata.yaml``
- copy it to ``<config>/custom_sentences/en/ha_washdata.yaml`` and restart HA. The
minimal shape is (one file per language)::
language: en
intents:
HaWashdataStatus:
data:
- sentences:
- "is my {name} done"
- "is the {name} finished"
- "how long until the {name} finishes"
- "how long is left on the {name}"
- sentences:
- "is the laundry done"
- "how long until it finishes"
lists:
name:
wildcard: true
The optional ``{name}`` slot disambiguates which appliance when more than one is
configured. Users who prefer templated responses instead of the handler can also
declare the same intent via the ``intent_script`` integration.
This module has no import-time side effects: it only defines constants, helpers
and the handler class. Registration happens when :func:`async_setup_intents` is
called from ``async_setup_entry`` (guarded to run once per HA instance).
"""
from __future__ import annotations
import json
import logging
import os
import re
from datetime import datetime
from typing import Any
import voluptuous as vol
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, intent
from homeassistant.util import dt as dt_util
from .const import (
DOMAIN,
STATE_ANTI_WRINKLE,
STATE_CLEAN,
STATE_ENDING,
STATE_FINISHED,
STATE_PAUSED,
STATE_RINSE,
STATE_RUNNING,
STATE_STARTING,
STATE_USER_PAUSED,
)
_LOGGER = logging.getLogger(__name__)
# Unique, stable intent identifier. Referenced by sentence packs / automations.
INTENT_STATUS = "HaWashdataStatus"
# States where the appliance is actively working on a cycle.
_ACTIVE_STATES = frozenset(
{
STATE_RUNNING,
STATE_STARTING,
STATE_ENDING,
STATE_PAUSED,
STATE_USER_PAUSED,
STATE_RINSE,
STATE_ANTI_WRINKLE,
}
)
# States that mean a cycle just completed (laundry/dishes ready).
_FINISHED_STATES = frozenset({STATE_FINISHED, STATE_CLEAN})
# How far back a completed cycle still counts as "finished recently" (minutes).
_RECENT_FINISH_WINDOW_MIN = 720 # 12 hours
# English fallback response templates. The canonical translatable copies live in
# strings.json / translations/en.json under the top-level "intent" section and
# are loaded on top of these by _localized_templates(). Kept here so the handler
# always works even if the translation cache is unavailable.
DEFAULT_TEMPLATES: dict[str, str] = {
"running_with_estimate": "Your {device} is still running. About {minutes} minutes left.",
"running_no_estimate": "Your {device} is still running.",
"finished_recently": "Your {device} finished {minutes} minutes ago.",
"just_finished": "Your {device} just finished.",
"not_running": "Your {device} is not running.",
"no_devices": "I couldn't find any WashData appliances.",
"unknown_device": "I couldn't find a WashData appliance called {device}.",
"none_running": "None of your WashData appliances are running.",
"error": "Sorry, I couldn't check your appliances right now.",
}
def _minutes_from_seconds(seconds: Any) -> int | None:
"""Return whole minutes (>=1) from a seconds value, or None when unusable."""
try:
value = float(seconds)
except (TypeError, ValueError):
return None
if value <= 0:
return None
return max(1, int(round(value / 60.0)))
def _minutes_since(end: Any, now: datetime) -> int | None:
"""Return whole minutes elapsed since ``end`` (clamped at 0), or None."""
if not isinstance(end, datetime):
return None
try:
delta = (now - end).total_seconds()
except (TypeError, ValueError):
return None
if delta < 0:
return 0
return int(round(delta / 60.0))
def _iter_managers(hass: HomeAssistant) -> list[tuple[str, Any]]:
"""Return (title, manager) pairs for every loaded WashData device."""
result: list[tuple[str, Any]] = []
domain_data = hass.data.get(DOMAIN) or {}
for manager in domain_data.values():
# hass.data[DOMAIN] maps entry_id -> manager; skip anything unexpected.
if not hasattr(manager, "check_state"):
continue
entry = getattr(manager, "config_entry", None)
title = getattr(entry, "title", None)
result.append((title or "appliance", manager))
return result
def _is_running(manager: Any) -> bool:
"""Return True when the manager reports an active-cycle state."""
try:
return manager.check_state() in _ACTIVE_STATES
except Exception: # noqa: BLE001 - never let intent handling raise
return False
def _describe_device(
title: str, manager: Any, templates: dict[str, str], now: datetime
) -> str:
"""Build a one-sentence plain-language status line for a single device."""
device = title or "appliance"
try:
state = manager.check_state()
except Exception: # noqa: BLE001
state = None
if state in _ACTIVE_STATES:
minutes = _minutes_from_seconds(getattr(manager, "time_remaining", None))
if minutes:
return templates["running_with_estimate"].format(
device=device, minutes=minutes
)
return templates["running_no_estimate"].format(device=device)
# Not actively running: mention a recent finish when we can.
ago = _minutes_since(getattr(manager, "last_cycle_end_time", None), now)
if state in _FINISHED_STATES:
if ago and ago > 0:
return templates["finished_recently"].format(device=device, minutes=ago)
return templates["just_finished"].format(device=device)
# Off / idle / interrupted / unknown: only claim a finish if it was recent.
if ago is not None and 0 < ago <= _RECENT_FINISH_WINDOW_MIN:
return templates["finished_recently"].format(device=device, minutes=ago)
if ago == 0:
return templates["just_finished"].format(device=device)
return templates["not_running"].format(device=device)
def _build_speech(
hass: HomeAssistant,
name_slot: str | None,
templates: dict[str, str],
now: datetime,
) -> str:
"""Resolve the target device(s) and build the spoken answer."""
managers = _iter_managers(hass)
if not managers:
return templates["no_devices"]
needle = str(name_slot).strip().casefold() if name_slot else ""
if needle:
# Prefer an exact title, then a whole-word hit, then any substring, so
# "washer" resolves to "Laundry Washer" rather than "Dishwasher".
exact = [(t, m) for (t, m) in managers if t.casefold() == needle]
word = [(t, m) for (t, m) in managers if needle in t.casefold().split()]
sub = [(t, m) for (t, m) in managers if needle in t.casefold()]
picked = exact or word or sub
if not picked:
return templates["unknown_device"].format(device=name_slot)
title, manager = picked[0]
return _describe_device(title, manager, templates, now)
if len(managers) == 1:
title, manager = managers[0]
return _describe_device(title, manager, templates, now)
# Multiple devices, none specified: summarize the running ones.
running = [(t, m) for (t, m) in managers if _is_running(m)]
if not running:
return templates["none_running"]
return " ".join(
_describe_device(t, m, templates, now) for (t, m) in running
)
# Cache of loaded intent-response templates per language ({} when the file is
# absent/unreadable). Populated lazily by _load_intent_file.
_INTENT_TRANS_CACHE: dict[str, dict[str, str]] = {}
def _load_intent_file(language: str) -> dict[str, str]:
"""Load the ``HaWashdataStatus`` response templates for *language* (sync, cached).
These live in ``translations/intent/{lang}.json`` rather than the HA-layer
``translations/{lang}.json``: a top-level ``intent`` key is rejected by hassfest
(``extra keys not allowed``), so - exactly like the self-served panel translations
- the intent responses are kept in a sub-directory hassfest does not validate and
loaded directly. Returns ``{}`` on any failure. Never raises.
"""
if language in _INTENT_TRANS_CACHE:
return _INTENT_TRANS_CACHE[language]
# Sanitize before interpolating into a path: HA language tags are letters/digits/
# hyphen only, so reject anything else (defends against path traversal via a
# crafted language value).
if not re.fullmatch(r"[A-Za-z0-9_-]{1,20}", language or ""):
_INTENT_TRANS_CACHE[language] = {}
return {}
result: dict[str, str] = {}
try:
path = os.path.join(
os.path.dirname(__file__), "translations", "intent", f"{language}.json"
)
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
block = data.get(INTENT_STATUS) if isinstance(data, dict) else None
if isinstance(block, dict):
result = {k: v for k, v in block.items() if isinstance(v, str) and v}
except Exception: # noqa: BLE001 - missing/broken file -> English fallback
result = {}
_INTENT_TRANS_CACHE[language] = result
return result
async def _localized_templates(
hass: HomeAssistant, language: str | None
) -> dict[str, str]:
"""Return response templates, overlaying localized values onto the English base.
English base first, then the language's base subtag, then the full tag (so
``pt-BR`` overrides ``pt`` overrides ``en``). File reads are offloaded to the
executor when the hass supports it, with a synchronous fallback (minimal test
hass). Falls back to :data:`DEFAULT_TEMPLATES` on any failure.
"""
templates = dict(DEFAULT_TEMPLATES)
lang = language or "en"
order = list(dict.fromkeys(["en", lang.split("-")[0], lang]))
for lg in order:
if not lg:
continue
try:
loaded = await hass.async_add_executor_job(_load_intent_file, lg)
except Exception: # noqa: BLE001 - minimal test hass has no executor
loaded = _load_intent_file(lg)
for key, value in (loaded or {}).items():
if isinstance(value, str) and value:
templates[key] = value
return templates
class WashDataStatusIntentHandler(intent.IntentHandler):
"""Answer status questions ("is it done?", "how long left?") for a device."""
intent_type = INTENT_STATUS
description = (
"Report whether a WashData appliance is running and how long is left."
)
@property
def slot_schema(self) -> dict:
"""Optional appliance name to disambiguate which device is meant."""
return {vol.Optional("name"): cv.string}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Build a plain-language status response; never raises."""
response = intent_obj.create_response()
hass = intent_obj.hass
templates = await _localized_templates(hass, intent_obj.language)
try:
slots = intent_obj.slots or {}
name_value = slots.get("name", {}).get("value")
speech = _build_speech(hass, name_value, templates, dt_util.now())
except Exception: # noqa: BLE001 - graceful degradation, no exceptions escape
_LOGGER.exception("WashData status intent failed")
speech = templates.get("error", DEFAULT_TEMPLATES["error"])
response.async_set_speech(speech)
return response
@callback
def async_setup_intents(hass: HomeAssistant) -> None:
"""Register WashData conversation intents (domain-global, idempotent).
Call once per HA instance. ``intent.async_register`` overwrites an existing
handler of the same ``intent_type`` (with a warning), so callers guard with a
``hass.data`` flag to avoid re-registration on multi-entry setups.
"""
intent.async_register(hass, WashDataStatusIntentHandler())
+282 -239
View File
@@ -1,42 +1,45 @@
# 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/>.
"""Learning and self-tuning logic for WashData."""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime
from typing import Any, Optional, TYPE_CHECKING, cast
from collections.abc import Callable
from typing import Any, Optional, TYPE_CHECKING
import numpy as np
from homeassistant.core import HomeAssistant
from homeassistant.helpers import translation
from homeassistant.helpers.dispatcher import async_dispatcher_send
import homeassistant.util.dt as dt_util
from .const import (
CONF_AUTO_LABEL_CONFIDENCE,
CONF_DURATION_TOLERANCE,
CONF_END_ENERGY_THRESHOLD,
CONF_LEARNING_CONFIDENCE,
CONF_MIN_OFF_GAP,
CONF_MIN_POWER,
CONF_NO_UPDATE_ACTIVE_TIMEOUT,
CONF_OFF_DELAY,
CONF_PROFILE_DURATION_TOLERANCE,
CONF_PROFILE_MATCH_INTERVAL,
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
CONF_RUNNING_DEAD_ZONE,
CONF_SAMPLING_INTERVAL,
CONF_START_THRESHOLD_W,
CONF_STOP_THRESHOLD_W,
CONF_SUPPRESS_FEEDBACK_NOTIFICATIONS,
CONF_WATCHDOG_INTERVAL,
CONF_PROFILE_MIN_WARMUP_CYCLES,
DEFAULT_AUTO_LABEL_CONFIDENCE,
DEFAULT_DURATION_TOLERANCE,
DEFAULT_LEARNING_CONFIDENCE,
DEFAULT_SUPPRESS_FEEDBACK_NOTIFICATIONS,
DOMAIN,
SIGNAL_WASHER_UPDATE,
MIN_SUGGESTION_COOLDOWN_CYCLES,
MIN_SUGGESTION_REL_DELTA,
ML_QUALITY_SUSPICIOUS_THRESHOLD,
)
from .suggestion_engine import SuggestionEngine
from .log_utils import DeviceLoggerAdapter
@@ -48,6 +51,25 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
def _suggestion_min_abs_delta(key: str) -> float:
"""Return the minimum absolute change that makes a suggestion worth surfacing.
Both this threshold AND MIN_SUGGESTION_REL_DELTA must be missed for a
suggestion to be suppressed — either one passing is enough to keep it.
"""
if key.endswith(("_w", "_power")):
return 0.3 # Watts: sub-0.3 W changes are below sensor noise
if key.endswith(("_interval", "_timeout", "_delay", "_gap", "_duration", "_seconds", "_duration_threshold")):
return 5.0 # Seconds: 5 s is imperceptible to the detector
if key.endswith(("_ratio", "_tolerance")):
return 0.02 # Unitless ratio: 0.02 is the minimum meaningful step
if key.endswith(("_confidence", "_threshold")):
return 0.02 # Probability (01): 0.02 is the minimum meaningful step
if key.endswith(("_count", "_window", "_repeat")):
return 1.0 # Integer count: less than 1 is a no-op
return 0.05
class StatisticalModel:
"""Helper to track running stats for a metric."""
@@ -120,36 +142,24 @@ class LearningManager:
self._last_batch_simulation_count: int = 0 # track when to re-run batch
def _apply_suggestions_and_notify(self, suggestions: dict[str, Any]) -> None:
"""Apply suggestions and notify once when they become actionable."""
"""Apply suggestions that pass quality gates."""
if not suggestions:
return
_actionable_keys = (
CONF_MIN_POWER,
CONF_OFF_DELAY,
CONF_WATCHDOG_INTERVAL,
CONF_NO_UPDATE_ACTIVE_TIMEOUT,
CONF_SAMPLING_INTERVAL,
CONF_PROFILE_MATCH_INTERVAL,
CONF_AUTO_LABEL_CONFIDENCE,
CONF_DURATION_TOLERANCE,
CONF_PROFILE_DURATION_TOLERANCE,
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
CONF_MIN_OFF_GAP,
CONF_STOP_THRESHOLD_W,
CONF_START_THRESHOLD_W,
CONF_END_ENERGY_THRESHOLD,
CONF_RUNNING_DEAD_ZONE,
)
# Drop suggestions whose value already matches the current config - so
# that applied suggestions don't immediately reappear on the next cycle.
# Quality gate: drop or suppress suggestions that are not worth surfacing.
entry = self.hass.config_entries.async_get_entry(self.entry_id)
current_options: dict[str, Any] = {}
if entry:
current_options = {**entry.data, **entry.options}
# Cooldown: how many cycles have elapsed since the user last applied suggestions?
past_cycles = self.profile_store.get_past_cycles()
last_apply_count = self.profile_store.get_suggestion_apply_cycle_count()
cooldown_active = (
last_apply_count > 0
and (len(past_cycles) - last_apply_count) < MIN_SUGGESTION_COOLDOWN_CYCLES
)
filtered_suggestions: dict[str, Any] = {}
for key, data in suggestions.items():
if isinstance(data, dict) and "value" in data:
@@ -157,9 +167,28 @@ class LearningManager:
suggested_val = data["value"]
if current_val is not None and suggested_val is not None:
try:
if float(current_val) == float(suggested_val):
cv, sv = float(current_val), float(suggested_val)
abs_delta = abs(sv - cv)
# Gate 1: exact equality → stale, delete so it doesn't linger.
if abs_delta < 1e-9:
self.profile_store.delete_suggestion(key)
continue # already applied, remove stale entry
continue
# Gate 2: change too small to be meaningful → delete (noise).
rel_delta = abs_delta / max(abs(cv), 1e-3)
if (rel_delta < MIN_SUGGESTION_REL_DELTA
and abs_delta < _suggestion_min_abs_delta(key)):
self.profile_store.delete_suggestion(key)
continue
# Gate 3: cooldown active → skip update without deleting.
# After the user applies suggestions, wait for a few more
# cycles before surfacing new ones (avoids immediately
# re-suggesting a slightly-different value on the next cycle).
if cooldown_active:
continue
except (TypeError, ValueError):
pass
filtered_suggestions[key] = data
@@ -167,26 +196,8 @@ class LearningManager:
if not filtered_suggestions:
return
def _count_actionable(s: dict) -> int:
return sum(
1 for k in _actionable_keys
if isinstance(s.get(k), dict) and s[k].get("value") is not None
)
current = self.profile_store.get_suggestions()
before_count = _count_actionable(current) if isinstance(current, dict) else 0
self.suggestion_engine.apply_suggestions(filtered_suggestions)
updated = self.profile_store.get_suggestions()
after_count = _count_actionable(updated) if isinstance(updated, dict) else 0
if before_count == 0 and after_count > 0:
device_title = entry.title if entry else DOMAIN
self.hass.async_create_task(
self._async_send_suggestions_ready_notification(device_title, after_count)
)
def process_power_reading(
self, _power: float, now: datetime, last_reading_time: datetime | None
) -> None:
@@ -232,7 +243,10 @@ class LearningManager:
)
# 3. Update model-based suggestions (durations etc)
self._update_model_suggestions(dt_util.now())
self._update_model_suggestions()
# 3b. Update statistical detection suggestions (thresholds, gates, etc.)
self._update_detection_suggestions()
# 4. Run multi-cycle batch simulation when enough new labeled cycles have accumulated
self._maybe_run_batch_simulation()
@@ -258,9 +272,9 @@ class LearningManager:
return
self._last_batch_simulation_count = current_count
self.hass.async_create_task(self._async_run_batch_simulation(labeled_cycles, current_count))
self.hass.async_create_task(self._async_run_batch_simulation(labeled_cycles))
async def _async_run_batch_simulation(self, cycles: list[dict[str, Any]], expected_count: int) -> None:
async def _async_run_batch_simulation(self, cycles: list[dict[str, Any]]) -> None:
"""Run multi-cycle batch simulation asynchronously."""
try:
new_suggestions = await self.hass.async_add_executor_job(
@@ -291,7 +305,13 @@ class LearningManager:
self._logger.error("Background simulation failed: %s", e)
def _update_operational_suggestions(self, now: datetime) -> None:
"""Generate suggestions for operational parameters (intervals, timeouts)."""
"""Generate suggestions for operational parameters (intervals, timeouts).
The cadence stats (p95/median) are read on the event loop and captured as
immutable snapshots; the historical-trace scan inside
``generate_operational_suggestions`` is offloaded to an executor thread by
``_dispatch_scan_and_apply`` so it never runs on the loop.
"""
if self._sample_interval_model.count < 20:
return
@@ -301,76 +321,121 @@ class LearningManager:
if p95 is None or median is None:
return
suggestions = self.suggestion_engine.generate_operational_suggestions(p95, median)
self._apply_suggestions_and_notify(suggestions)
# Throttle before dispatching so repeated readings within the window do
# not schedule overlapping passes.
self._last_suggestion_update = now
self._dispatch_scan_and_apply(
lambda: self.suggestion_engine.generate_operational_suggestions(p95, median),
"Operational",
)
def _update_model_suggestions(self, now: datetime) -> None:
"""Generate suggestions for model parameters (tolerances, ratios)."""
suggestions = self.suggestion_engine.generate_model_suggestions()
self._apply_suggestions_and_notify(suggestions)
def _update_model_suggestions(self) -> None:
"""Generate suggestions for model parameters (tolerances, ratios).
async def _async_send_suggestions_ready_notification(
self, device_title: str, suggestions_count: int
The historical-cycle scan inside ``generate_model_suggestions`` is
offloaded to an executor thread by ``_dispatch_scan_and_apply``.
"""
self._dispatch_scan_and_apply(
self.suggestion_engine.generate_model_suggestions,
"Model",
)
def _dispatch_scan_and_apply(
self, generate: Callable[[], dict[str, Any]], label: str
) -> None:
"""Send a one-time persistent notification when suggestions become available."""
"""Run a heavy suggestion scan off the event loop, then apply results.
``generate`` is a pure suggestion-engine call that scans historical power
traces (up to ~100-200 cycles) and is too heavy to run on the event loop.
When a running loop is present (normal operation) the scan is offloaded to
an executor thread and the resulting suggestions are applied back on the
loop. In a synchronous context with no running loop (unit tests / direct
callers) it runs inline so results are observable immediately. ``generate``
must only read shared state and return suggestions — the state mutation
(``_apply_suggestions_and_notify``) always runs on the loop.
"""
try:
notification_id = f"ha_washdata_suggestions_ready_{self.entry_id}"
asyncio.get_running_loop()
except RuntimeError:
# No running event loop: run inline (synchronous callers / unit tests).
try:
suggestions = generate()
except Exception as e: # pylint: disable=broad-exception-caught
self._logger.error("%s suggestion pass failed: %s", label, e)
return
if suggestions:
self._apply_suggestions_and_notify(suggestions)
return
self.hass.async_create_task(self._async_scan_and_apply(generate, label))
translations = await translation.async_get_translations(
self.hass, self.hass.config.language, "options", {DOMAIN}
async def _async_scan_and_apply(
self, generate: Callable[[], dict[str, Any]], label: str
) -> None:
"""Offload ``generate`` to an executor thread, then apply on the loop."""
try:
suggestions = await self.hass.async_add_executor_job(generate)
if suggestions:
self._apply_suggestions_and_notify(suggestions)
except Exception as e: # pylint: disable=broad-exception-caught
self._logger.error("%s suggestion pass failed: %s", label, e)
def _update_detection_suggestions(self) -> None:
"""Generate statistical detection suggestions from clean cycles.
Offloaded to an executor because it scans power traces across up to 200
cycles for the clean-cycle health checks.
"""
self.hass.async_create_task(self._async_run_detection_suggestions())
async def _async_run_detection_suggestions(self) -> None:
"""Run the detection-suggestion pass off the event loop."""
try:
new_suggestions = await self.hass.async_add_executor_job(
self.suggestion_engine.generate_detection_suggestions
)
if new_suggestions:
self._apply_suggestions_and_notify(new_suggestions)
self._logger.debug(
"Detection suggestions produced: %s", list(new_suggestions.keys())
)
except Exception as e: # pylint: disable=broad-exception-caught
self._logger.error("Detection suggestion pass failed: %s", e)
default_title = "WashData: Suggested Settings Ready ({device})"
default_msg = (
"The **Suggested Settings** sensor now reports **{count}** actionable recommendations.\n\n"
"To review and apply them: **Settings > Devices & Services > WashData > Configure > "
"Advanced Settings > Apply Suggested Values**.\n\n"
"Suggestions are optional and shown for review before you save."
async def async_run_full_analysis(self) -> dict[str, int]:
"""Run every suggestion pass now (manual trigger from the panel).
Runs the operational (cadence), model, detection and batch-simulation
passes over the accumulated cycle history and reconciles the result.
Returns ``{"count": <actionable suggestions>}``.
"""
self._logger.info("Manual suggestion analysis requested")
try:
model = self._sample_interval_model
if model.count >= 20 and model.p95 is not None and model.median is not None:
p95, median = model.p95, model.median
op = await self.hass.async_add_executor_job(
self.suggestion_engine.generate_operational_suggestions, p95, median
)
if op:
self._apply_suggestions_and_notify(op)
model_sug = await self.hass.async_add_executor_job(
self.suggestion_engine.generate_model_suggestions
)
title_template = translations.get(
f"component.{DOMAIN}.options.error.suggestions_ready_notification_title",
default_title,
if model_sug:
self._apply_suggestions_and_notify(model_sug)
await self._async_run_detection_suggestions()
# Snapshot the live cycles list before handing it to the executor.
cycles = list(self.profile_store.get_past_cycles())
batch = await self.hass.async_add_executor_job(
self.suggestion_engine.run_batch_simulation, cycles
)
msg_template = translations.get(
f"component.{DOMAIN}.options.error.suggestions_ready_notification_message",
default_msg,
)
title = title_template.format(device=device_title)
message = msg_template.format(count=suggestions_count)
await self.hass.services.async_call(
"persistent_notification",
"create",
{
"message": message,
"title": title,
"notification_id": notification_id,
},
)
except Exception: # pylint: disable=broad-exception-caught
self._logger.exception("Failed to create suggestions-ready notification")
def _set_suggestion(self, key: str, value: Any, reason: str) -> None:
"""Persist a suggested setting."""
current: Any = self.profile_store.get_suggestions().get(key, {})
if isinstance(current, dict):
current_dict = cast(dict[str, Any], current)
if current_dict.get("value") == value:
return # No change
self.profile_store.set_suggestion(key, value, reason=reason)
# We fire a background save task if possible, or rely on next periodic save.
# Since learning manager doesn't hold reference to hass task creation easily,
# we can just rely on ProfileStore's periodic save or trigger one if referenced.
# Ideally ProfileStore handles dirtiness.
# But wait, Manager calls save periodically. We should just mark it dirty?
# ProfileStore.async_save() is needed.
# We'll just trigger it via hass if available.
if self.hass:
self.hass.async_create_task(self.profile_store.async_save())
if batch:
self._apply_suggestions_and_notify(batch)
except Exception as e: # pylint: disable=broad-exception-caught
self._logger.error("Manual suggestion analysis failed: %s", e)
count = len(self.profile_store.get_suggestions() or {})
self._logger.info("Manual suggestion analysis complete: %d suggestion(s)", count)
return {"count": count}
def _maybe_request_feedback(
self,
@@ -410,24 +475,93 @@ class LearningManager:
CONF_DURATION_TOLERANCE, DEFAULT_DURATION_TOLERANCE
)
# Auto-label if very high confidence
# A4: Warmup mode — profiles with fewer than CONF_PROFILE_MIN_WARMUP_CYCLES labeled
# cycles skip auto-labeling entirely and always request user confirmation.
# Only applied when confidence would otherwise trigger auto-labeling; cycles
# already below the learning threshold follow the normal skip path unchanged.
warmup_request = False
# ``route_conf`` drives the auto-label/skip routing only; ``confidence``
# remains the real match score that gets displayed and persisted, so warmup
# clamping never fabricates the value shown to the user.
route_conf = confidence
if confidence >= auto_label_conf:
labeled = self.auto_label_high_confidence(
cycle_id=cycle_id,
profile_name=detected_profile,
confidence=confidence,
confidence_threshold=auto_label_conf,
_wm_count = self.profile_store.get_profile_labeled_count(detected_profile)
# Imported reference profiles are trusted downloaded templates: the user
# expects to match immediately, so they skip the local warm-up gate.
_imported = self.profile_store.profile_has_reference_cycles(detected_profile)
_is_warmup = (
not _imported
and isinstance(_wm_count, int)
and _wm_count < CONF_PROFILE_MIN_WARMUP_CYCLES
)
if labeled:
# Rebuild envelope first, then persist (issue #131)
self.hass.async_create_task(
self._async_rebuild_and_save_profile(detected_profile)
if _is_warmup:
self._logger.info(
"Profile '%s' in warmup mode (%d/%d cycles); requiring manual confirmation.",
detected_profile, _wm_count, CONF_PROFILE_MIN_WARMUP_CYCLES,
)
self._logger.debug("Auto-labeled high-confidence cycle %s", cycle_id)
return
# A warmup cycle must always request confirmation: never auto-label,
# and never silently skip — even under a misconfigured inverted
# (learning_conf >= auto_label_conf) threshold pair.
warmup_request = True
# Clamp the ROUTING confidence just below auto_label so we fall through
# to the feedback-request path, but stay above learning_conf to request
# (not skip). Only raise toward learning_conf when there is room below
# auto_label_conf; otherwise an inverted config would push it back to/above
# auto_label_conf and silently bypass the warmup guard.
route_conf = auto_label_conf - 0.001
if learning_conf + 0.001 < auto_label_conf:
route_conf = max(route_conf, learning_conf + 0.001)
# Skip low-confidence matches below learning threshold
if confidence < learning_conf:
# Auto-label if very high confidence — but skip auto-labeling when the ML
# quality model flagged this cycle as suspicious (P(problem) >= threshold),
# even if the matcher was confident. Downgrade to a feedback request so
# the user can verify the match; this catches confident but wrong labels.
ml_quality = cycle_data.get("ml_quality_score")
ml_suspicious = (
isinstance(ml_quality, float)
and ml_quality >= ML_QUALITY_SUSPICIOUS_THRESHOLD
)
# Also downgrade when the cycle's power trace is mostly outside the
# profile envelope band (low conformance = the shape matched but the
# actual power levels are inconsistent with the profile).
_conformance = cycle_data.get("envelope_conformance")
envelope_suspicious = (
isinstance(_conformance, float)
and _conformance < 0.40
)
if route_conf >= auto_label_conf:
if ml_suspicious or envelope_suspicious:
if ml_suspicious:
self._logger.info(
"ML quality model flagged cycle %s as suspicious (score=%.3f >= %.2f); "
"downgrading auto-label to feedback request.",
cycle_id, ml_quality, ML_QUALITY_SUSPICIOUS_THRESHOLD,
)
if envelope_suspicious:
self._logger.info(
"Envelope conformance for cycle %s is low (%.2f < 0.40); "
"downgrading auto-label to feedback request.",
cycle_id, _conformance,
)
# Fall through to feedback-request path below.
else:
labeled = self.auto_label_high_confidence(
cycle_id=cycle_id,
profile_name=detected_profile,
confidence=confidence,
confidence_threshold=auto_label_conf,
)
if labeled:
# Rebuild envelope first, then persist (issue #131)
self.hass.async_create_task(
self._async_rebuild_and_save_profile(detected_profile)
)
self._logger.debug("Auto-labeled high-confidence cycle %s", cycle_id)
return
# Skip low-confidence matches below learning threshold — but a warmup cycle
# always requests confirmation, even if the thresholds are misconfigured.
if route_conf < learning_conf and not warmup_request:
self._logger.debug(
"Skipping feedback for low-confidence match (conf=%.2f < %.2f)",
confidence,
@@ -448,102 +582,11 @@ class LearningManager:
match_result=match_result,
)
# Persist pending feedback request so it survives restart
# Persist pending feedback request so it survives restart.
# The pending review is surfaced in the panel's Cycles review queue;
# WashData intentionally does not raise a persistent notification here.
self.hass.async_create_task(self.profile_store.async_save())
# Create user-visible notification (skipped when suppressed via option).
# Use `is True` so that un-configured mock objects in tests don't
# accidentally suppress notifications by being truthy.
suppress = entry.options.get(
CONF_SUPPRESS_FEEDBACK_NOTIFICATIONS,
DEFAULT_SUPPRESS_FEEDBACK_NOTIFICATIONS,
) is True
if not suppress:
self.hass.async_create_task(
self._async_send_feedback_notification(
entry.title, cycle_data, detected_profile, confidence
)
)
async def _async_send_feedback_notification(
self, device_title: str, cycle_data: dict[str, Any], profile: str, confidence: float
) -> None:
"""Send a persistent notification for feedback (Async with translation)."""
try:
cycle_id = cycle_data.get("id", "unknown")
start_ts = cycle_data.get("start_time")
end_ts = dt_util.now() # Approximate, or pass actual end time
# Format times
t_str = ""
if start_ts:
try:
s_dt = datetime.fromisoformat(str(start_ts)) if isinstance(start_ts, str) else start_ts
s_local = dt_util.as_local(s_dt)
e_local = dt_util.as_local(end_ts)
t_str = f"{s_local.strftime('%H:%M')} - {e_local.strftime('%H:%M')}"
except Exception:
t_str = "Just now"
notification_id = f"ha_washdata_feedback_{self.entry_id}_{cycle_id}"
# Load translations (from en.json / localization files)
# We use "options" category to access the error keys where we stored these strings
translations = await translation.async_get_translations(
self.hass, self.hass.config.language, "options", {DOMAIN}
)
# Default templates
default_title = "WashData: Verify Cycle ({device})"
default_msg = (
"**Device**: {device}\n"
"**Program**: {program} ({confidence}% confidence)\n"
"**Time**: {time}\n\n"
"WashData needs your help to verify this detected cycle.\n\n"
"Please go to **Settings > Devices & Services > WashData > Configure > Learning Feedbacks** to confirm or correct this result."
)
title_template = translations.get(
f"component.{DOMAIN}.options.error.feedback_notification_title", default_title
)
msg_template = translations.get(
f"component.{DOMAIN}.options.error.feedback_notification_message", default_msg
)
# Confidence as percentage
conf_pct = int(confidence * 100)
title = title_template.format(device=device_title)
message = msg_template.format(
device=device_title,
program=profile,
confidence=conf_pct,
time=t_str
)
# Use standard service call
await self.hass.services.async_call(
"persistent_notification",
"create",
{
"message": message,
"title": title,
"notification_id": notification_id,
},
)
except Exception: # pylint: disable=broad-exception-caught
self._logger.exception("Failed to create feedback notification")
def _send_feedback_notification(
self, device_title: str, cycle_data: dict[str, Any], profile: str, confidence: float
) -> None:
"""Deprecated sync wrapper."""
self.hass.async_create_task(
self._async_send_feedback_notification(
device_title, cycle_data, profile, confidence
)
)
def request_cycle_verification(
self,
cycle_id: str,
@@ -621,7 +664,7 @@ class LearningManager:
# Verify it was labeled (cycle found)
cycles = self.profile_store.get_past_cycles()
cycle = next((c for c in cycles if c["id"] == cycle_id), None)
cycle = next((c for c in cycles if c.get("id") == cycle_id), None)
return bool(cycle and cycle.get("auto_labeled"))
@@ -677,7 +720,7 @@ class LearningManager:
self._auto_label_cycle(cycle_id, profile_name, duration_sec)
if duration_sec is not None:
cycles = self.profile_store.get_past_cycles()
confirmed_cycle = next((c for c in cycles if c["id"] == cycle_id), None)
confirmed_cycle = next((c for c in cycles if c.get("id") == cycle_id), None)
if confirmed_cycle:
confirmed_cycle["duration"] = duration_sec
profiles_to_rebuild.add(profile_name)
@@ -703,7 +746,7 @@ class LearningManager:
# explicitly provided - apply it directly to the cycle so the value
# is never silently dropped.
cycles = self.profile_store.get_past_cycles()
cycle_to_fix = next((c for c in cycles if c["id"] == cycle_id), None)
cycle_to_fix = next((c for c in cycles if c.get("id") == cycle_id), None)
if cycle_to_fix:
cycle_to_fix["duration"] = duration_sec
cycle_to_fix["manual_duration"] = duration_sec
@@ -737,7 +780,7 @@ class LearningManager:
def _auto_label_cycle(self, cycle_id: str, profile_name: str, manual_duration: float | None = None) -> None:
cycles = self.profile_store.get_past_cycles()
cycle = next((c for c in cycles if c["id"] == cycle_id), None)
cycle = next((c for c in cycles if c.get("id") == cycle_id), None)
if cycle:
cycle["profile_name"] = profile_name
cycle["auto_labeled"] = True
@@ -759,7 +802,7 @@ class LearningManager:
self._auto_label_cycle(cycle_id, corrected_profile, corrected_duration)
if corrected_duration is not None:
cycles = self.profile_store.get_past_cycles()
cycle = next((c for c in cycles if c["id"] == cycle_id), None)
cycle = next((c for c in cycles if c.get("id") == cycle_id), None)
if cycle:
cycle["duration"] = corrected_duration
# Profile stats will be recalculated when envelope is rebuilt
@@ -1,3 +1,19 @@
# 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/>.
"""Logging utilities for WashData."""
from __future__ import annotations
@@ -25,4 +41,12 @@ class DeviceLoggerAdapter(logging.LoggerAdapter):
def process(self, msg: str, kwargs: dict) -> tuple[str, dict]:
device = self.extra.get("device_name") or "unknown" # type: ignore[union-attr]
# Also attach the device name as a structured field (record.wd_device) so
# the Logs page can filter by device, not just parse the "[device]" prefix.
src = kwargs.get("extra")
# Shallow-copy so we never mutate the caller's dict; the adapter owns the
# reserved wd_device field but preserves every other caller-supplied extra.
extra = dict(src) if isinstance(src, dict) else {}
extra["wd_device"] = device
kwargs["extra"] = extra
return f"[{device}] {msg}", kwargs
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -3,18 +3,23 @@
"name": "WashData",
"after_dependencies": [
"lovelace",
"http"
"http",
"frontend",
"websocket_api",
"recorder"
],
"codeowners": [
"@3dg1luk43"
],
"config_flow": true,
"dependencies": [],
"dependencies": [
"conversation"
],
"documentation": "https://github.com/3dg1luk43/ha_washdata",
"iot_class": "local_polling",
"issue_tracker": "https://github.com/3dg1luk43/ha_washdata/issues",
"requirements": [
"numpy"
],
"version": "0.4.5.1"
"version": "0.5.0"
}
@@ -0,0 +1,84 @@
# WashData ML subsystem (experimental, gated)
Compact, **NumPy-only** models plus the runtime that trains and consumes them.
No new dependencies (NumPy is already in `manifest.json`). Everything here is
gated by flags in `const.py` and is inert until enabled, so the proven
detection/matching/ETA code paths are unchanged by default.
## Feature flags (const.py)
- `SHOW_ML_LAB` - show the ML Lab panel tab (shadow-mode comparison + review).
- `ENABLE_ML_SUGGESTIONS` - surface ML-calibrated setting suggestions alongside
the classic ones (`MLSuggestionEngine`).
- `ENABLE_ML_TRAINING` - allow the scheduled/manual on-device training loop.
- `CONF_ENABLE_ML_MODELS` (per-device option) - opt-in gate (via
`ml_models_enabled(options)`) for feeding ML signals into runtime decisions;
default off so callers keep existing behavior. (No runtime consumer wires this
yet - the live ML paths below run under their own flags.)
## What ships here
- `promoted_manifest.json` + `<name>_model.py` - the embedded **baseline** models
(the broad-corpus models trained offline in `/root/ml_washdata`). Each module
is self-contained and exposes `score()`, `predict()`, `FEATURE_COLUMNS`,
`THRESHOLD`, `MODEL_METRICS`. `<name>_feature_contract.json` documents the live
data each feature comes from; `<name>_parity.json` are golden feature→score
cases the tests assert against.
- `feature_extraction.py` - NumPy-only runtime feature extractors
(`latest_end_event_features`, `live_match_features`, `quality_features`,
`profile_expectation`, energy integration) matching the models' `FEATURE_COLUMNS`.
- `engine.py` - `resolve_scorer(capability, store)`, the single bridge that
returns a **classifier** scoring callable preferring an on-device trained spec
over the embedded baseline (`"on_device"` vs `"baseline"`); `resolve_regressor(
capability, store)` is its **regression** twin for `standardized_linear` heads
that have no shipped baseline (returns `(None, None)` until one is promoted),
plus `ml_models_enabled` (opt-in gate) and `available_models` (manifest provenance).
- `trainer.py` - NumPy-only training for two spec kinds: logistic classifiers
(`fit_logistic`, `select_threshold`, `binary_metrics`, `auc`, `build_spec`/
`score_spec` - byte-compatible with the embedded `score()` math) and ridge
**regressors** (`fit_ridge`, `regression_metrics`, `build_regression_spec`/
`predict_value_spec` - standardized features + standardized target).
- `training_task.py` - on-device orchestration: derives labels from the device's
own cycles (end events from trace geometry; quality from status + ML-Lab review
labels; live_match from match-ranking-history snapshots), synthesises
completion-fraction examples for the regression capabilities, trains, and
promotes a classifier only when its held-out AUC is within margin of the
baseline (a regressor only when its held-out MAE beats the naive elapsed/
expected projection).
- `matching_tuner.py` - `tune_matching_config(cycles)`: NumPy-only, executor-safe
leave-one-out tuning of the matcher's bounded scoring weights (`corr_weight`,
`duration_weight`, `energy_weight`, `dtw_ensemble_w`) over the device's own
labelled cycles. Same promotion discipline as the models (gate on a held-out
split by a margin); it only ever changes the emphasis between shape/level/energy,
never structural matching behaviour.
Models (all standardized-logistic; only models that beat their baseline are shipped):
- `hybrid_curve_quality_model` - P(finished cycle is a problem).
- `live_match_commit_model` - P(top-1 live program match is correct).
- `cycle_end_detector_model` - P(a low-power event is the true end vs a pause).
(No regression baseline is shipped: the `remaining_time` and `total_energy`
completion-fraction regressors did not beat the `expected_duration - elapsed`
heuristic on the broad corpus, so they stay inert until on-device training
promotes a per-device spec that beats that naive projection.)
## How trained models reach inference
`resolve_scorer(capability, store)` is used by the ML Lab shadow comparison
(`ws_api._compute_ml_comparison`) and by `MLSuggestionEngine`. If the profile
store holds an on-device spec for that capability (trained by `training_task` and
persisted under `ml_model_versions`), it is used; otherwise the embedded baseline
module is used. The shipped baseline is a broad-corpus model - per-user accuracy
gains come from on-device training, not from replacing the baseline.
## Regenerating the embedded baseline (offline lab only)
```bash
cd /root/ml_washdata
./ml.sh experiment # retrain + verify the determinism gate
python promote_to_integration.py --target <this directory> # reads output/promoted/
```
`promote_to_integration.py` refuses to copy any model whose encode/decode round
trip is not deterministic. On-device training never touches these baseline files;
it writes trained specs into the profile store instead.
@@ -0,0 +1,37 @@
# 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/>.
"""Opt-in, NumPy-only ML models for WashData (experimental).
Models are trained offline in the ml_washdata lab and embedded here as base64
blobs. They are inert unless the user enables them. See engine.py and README.md.
"""
from .engine import (
CONF_ENABLE_ML_MODELS,
available_models,
ml_models_enabled,
resolve_regressor,
resolve_scorer,
)
__all__ = [
"CONF_ENABLE_ML_MODELS",
"available_models",
"ml_models_enabled",
"resolve_regressor",
"resolve_scorer",
]
@@ -0,0 +1,42 @@
[
{
"feature": "elapsed_fraction",
"group": "cycle_end",
"runtime_source": "elapsed_seconds / matched profile expected duration"
},
{
"feature": "energy_fraction",
"group": "cycle_end",
"runtime_source": "energy delivered so far (Wh) / matched profile expected energy"
},
{
"feature": "energy_remaining_expected",
"group": "cycle_end",
"runtime_source": "max(0, 1 - energy_fraction)"
},
{
"feature": "power_before_ratio",
"group": "cycle_end",
"runtime_source": "mean power just before the drop / profile expected peak"
},
{
"feature": "drop_ratio",
"group": "cycle_end",
"runtime_source": "(power_before - current_power) / profile expected peak, clipped"
},
{
"feature": "peak_seen_ratio",
"group": "cycle_end",
"runtime_source": "max power seen so far / profile expected peak"
},
{
"feature": "low_run_s_log",
"group": "cycle_end",
"runtime_source": "log1p(seconds power has stayed below the low threshold)"
},
{
"feature": "elapsed_log",
"group": "cycle_end",
"runtime_source": "log1p(elapsed seconds since cycle start)"
}
]
@@ -0,0 +1,129 @@
# 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/>.
"""Auto-generated by ml_washdata/wash_ml/promotion.py. Do not edit by hand.
Embedded WashData model: 'cycle_end_detector' (target: 'cycle_truly_ended').
Kind: standardized logistic regression. Runtime dependency: NumPy only.
Regenerate with ``./ml.sh experiment`` in the ml_washdata lab and copy the new
file. Determinism check at generation time: max_abs_score_diff=7.994e-09
over 1168 rows.
Usage in the integration::
from .cycle_end_detector_model import score, predict, FEATURE_COLUMNS
features = build_runtime_features(...) # must populate FEATURE_COLUMNS
is_positive = predict(features)
"""
from __future__ import annotations
import base64
import gzip
import json
from typing import Mapping
import numpy as np
MODEL_NAME = 'cycle_end_detector'
MODEL_TARGET = 'cycle_truly_ended'
MODEL_KIND = 'standardized_logistic'
TARGET_UNITS = ''
THRESHOLD = 0.6
FEATURE_COLUMNS = [
'elapsed_fraction',
'energy_fraction',
'energy_remaining_expected',
'power_before_ratio',
'drop_ratio',
'peak_seen_ratio',
'low_run_s_log',
'elapsed_log',
]
# Provenance (metrics at training time):
MODEL_METRICS = json.loads("""{
"owner_holdout": {
"accuracy": 0.865889,
"balanced_accuracy": 0.85704,
"f1": 0.760417,
"fn": 14,
"fp": 32,
"positive_rate": 0.306122,
"precision": 0.695238,
"problem_recall": 0.83908,
"rows": 343,
"specificity": 0.875,
"tn": 224,
"tp": 73
},
"premature_stop_rate": 0.125
}""")
_MODEL_BLOB = (
'H4sIAAAAAAACA21U227jNhD9FUIvzaK2Q5EUKXnRAkWaFgXa7GIvT8FCYKSxTUQiDZGK113sv/dQTtI+9G04M5wzc+byrXhwNhbb'
'tdhwUxqlVLUqOvKJpmJ7zzeV1JWWWqz4RpdKaM0NRNkoXkleQyyrxijN9SKWqi6lgmiaqja1rFZqU2shFTfVymwEl0YJVX8BRKAd'
'AMpNI2oOXQaoVaVEI5vVGrIEMC9zWC251kbJHJbXwjSNzB5clULoJqu5kZVskCTUUtUS7wwxkU3UtzYV20Jwoddcr0XzSfCtarZc'
'/8j5lvNiVezgN0/UdmGYRw8y7gsa7DHi726yXXLBw4s8Tfvz/2gmGq3zzu9b+nqkDpCwHcOJpvaBdgGBJ4sfUPZTOL4+jmQf20jk'
'XzVDOLXT7NvYDmGf4z8nkV+oZ+8SUhxHl+vRvLJgFl6PzvdQxGR9b6fe/X354WJyHcwjpcl1KOpbEU5IuD2EoQ9zygrbdTPKORdb'
'EK6rum5WxYMdrO8ybf81VoYrEFXmh9Gg3uDli22ZtcdiK0UuObrknpZyKTuib2gRDBN1LmbOoNRNJWSdleFhoBHsdXYYFhDZYKKK'
'KZyQrUS/iwg63c51Ll2ywBAVCVGEAGwCrJHfl/DjpYExXfhd0EtRwejtiFfRnbuBWvJ921NCj8IEanxItHT7JlvXsLIu+J3rCQRs'
'2fsrNGS9NJLRE1aCucjSgViaZmJLRJb/PEVm2dHOka53dohLoDcbxL9ZmsVSYLd3v/5x9zs7Hciz2GEk2M8/IdREMXfjLQsIO51c'
'JPZIdGQnCyL9nl1ltAsQoAf3SMP5gnQBeP9S+TpXznLl7GqxR5ZZpZ7ZmHOMb15yf6Qziozp7b/wi2n2cEamCz6SRukZ4cPskxuJ'
'jXNM+DceZ0Cgmva3218+ff5w2968+/PzX3cf2W4K4wIwYALYhbSYsIEjw1wuFqTaHYCCzu/cQD8gs+d9YZdNuu7nZRX8dd6NTZ55'
'DCog25eDhFV/1UVUiNaWWfUs3+f15zgwIl8FaUpe1qbJYlXL0pT5yIh8HqqyWkSF25QlWTdGyKZelRul6xqezUrg4GmFSTdfMsAB'
'VGOQTjYeepvsBlWMIZ+XMfQ0XJcgK9lpT+l12jAmwznP3HIQLsZ29i5h5oqseWnAshbf/wGoRgdWigUAAA=='
)
_MODEL_CACHE: dict | None = None
def _load() -> dict:
global _MODEL_CACHE
if _MODEL_CACHE is None:
payload = gzip.decompress(base64.b64decode(_MODEL_BLOB.encode("ascii")))
spec = json.loads(payload.decode("utf-8"))
_MODEL_CACHE = {
"center": np.asarray(spec["center"], dtype=float),
"scale": np.asarray(spec["scale"], dtype=float),
"coef": np.asarray(spec["coef"], dtype=float),
"bias": float(spec["bias"]),
"threshold": float(spec["threshold"]),
"output_center": float(spec.get("output_center") or 0.0),
"output_scale": float(spec.get("output_scale") if spec.get("output_scale") is not None else 1.0),
"feature_columns": list(spec["feature_columns"]),
}
return _MODEL_CACHE
def score(features: Mapping[str, float]) -> float:
"""Return the model probability in [0, 1] for one feature mapping."""
model = _load()
vector = np.array(
[float(features.get(column) or 0.0) for column in model["feature_columns"]],
dtype=float,
)
scaled = (vector - model["center"]) / model["scale"]
logit = float(scaled @ model["coef"] + model["bias"])
logit = max(-60.0, min(60.0, logit))
return 1.0 / (1.0 + np.exp(-logit))
def predict(features: Mapping[str, float]) -> bool:
"""True when the example crosses the embedded decision threshold."""
return score(features) >= _load()["threshold"]
@@ -0,0 +1,110 @@
{
"cases": [
{
"expected_score": 0.00031123,
"features": {
"drop_ratio": 0.01298192,
"elapsed_fraction": 0.02478321,
"elapsed_log": 5.83217569,
"energy_fraction": 0.00621172,
"energy_remaining_expected": 0.99378828,
"low_run_s_log": 5.70711026,
"peak_seen_ratio": 0.03683001,
"power_before_ratio": 0.03293804
}
},
{
"expected_score": 0.00046928,
"features": {
"drop_ratio": 0.0,
"elapsed_fraction": 0.00012455,
"elapsed_log": 0.69314718,
"energy_fraction": 0.0,
"energy_remaining_expected": 1.0,
"low_run_s_log": 5.49264984,
"peak_seen_ratio": 0.00356295,
"power_before_ratio": 0.00356295
}
},
{
"expected_score": 0.02292853,
"features": {
"drop_ratio": 0.03340821,
"elapsed_fraction": 0.43403046,
"elapsed_log": 8.69235585,
"energy_fraction": 0.53437329,
"energy_remaining_expected": 0.46562671,
"low_run_s_log": 5.69069724,
"peak_seen_ratio": 0.99399227,
"power_before_ratio": 0.03434855
}
},
{
"expected_score": 0.07338325,
"features": {
"drop_ratio": 0.01944167,
"elapsed_fraction": 0.60789299,
"elapsed_log": 8.57715877,
"energy_fraction": 0.59948446,
"energy_remaining_expected": 0.40051554,
"low_run_s_log": 4.79991426,
"peak_seen_ratio": 0.99750748,
"power_before_ratio": 0.03240279
}
},
{
"expected_score": 0.16173027,
"features": {
"drop_ratio": 0.03459041,
"elapsed_fraction": 0.73611397,
"elapsed_log": 8.76851206,
"energy_fraction": 0.63858395,
"energy_remaining_expected": 0.36141605,
"low_run_s_log": 4.11577984,
"peak_seen_ratio": 0.98851149,
"power_before_ratio": 0.03459041
}
},
{
"expected_score": 0.61603994,
"features": {
"drop_ratio": 0.00228805,
"elapsed_fraction": 0.8429428,
"elapsed_log": 8.89713534,
"energy_fraction": 0.98494733,
"energy_remaining_expected": 0.01505267,
"low_run_s_log": 5.15329159,
"peak_seen_ratio": 1.0,
"power_before_ratio": 0.0186657
}
},
{
"expected_score": 0.91631555,
"features": {
"drop_ratio": 0.19111446,
"elapsed_fraction": 1.05040586,
"elapsed_log": 9.13275714,
"energy_fraction": 0.97753783,
"energy_remaining_expected": 0.02246217,
"low_run_s_log": 3.7208625,
"peak_seen_ratio": 0.95060241,
"power_before_ratio": 0.20135542
}
},
{
"expected_score": 0.99973816,
"features": {
"drop_ratio": 0.18426352,
"elapsed_fraction": 1.83438035,
"elapsed_log": 8.41134367,
"energy_fraction": 1.54899093,
"energy_remaining_expected": 0.0,
"low_run_s_log": 3.71843826,
"peak_seen_ratio": 1.0,
"power_before_ratio": 0.19404488
}
}
],
"kind": "standardized_logistic",
"model": "cycle_end_detector"
}
+216
View File
@@ -0,0 +1,216 @@
# 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/>.
"""Opt-in ML scoring bridge for WashData (experimental).
This package holds compact, NumPy-only models trained offline in the
``ml_washdata`` lab and embedded here as base64 blobs (see
``promoted_manifest.json`` for provenance). The integration runtime stays
NumPy-only; no sklearn/torch/scipy are imported.
The single runtime entry point is :func:`resolve_scorer`, which returns a scoring
callable for a capability, preferring an on-device trained spec over the shipped
embedded baseline. All live ML consumers go through it (the panel's ``ml_health``
shadow comparison in ``ws_api`` and :class:`MLSuggestionEngine`), and any new
runtime consumer should too feature extraction lives in ``feature_extraction``
and gating in :func:`ml_models_enabled`, so there is no separate engine object.
Each model consumes a feature mapping whose keys are the model's
``FEATURE_COLUMNS``; the integration computes those from live data per the
``*_feature_contract.json`` files shipped alongside the model modules.
"""
from __future__ import annotations
import importlib
import json
import logging
from pathlib import Path
from typing import Mapping
_LOGGER = logging.getLogger(__name__)
CONF_ENABLE_ML_MODELS = "enable_ml_models"
# Logical capability -> generated model module name (without the _model suffix).
_MODEL_MODULES = {
"quality": "hybrid_curve_quality_model",
"live_match": "live_match_commit_model",
"end": "cycle_end_detector_model",
}
def ml_models_enabled(options: Mapping[str, object] | None) -> bool:
"""True when the user has opted into experimental ML models."""
if not options:
return False
return bool(options.get(CONF_ENABLE_ML_MODELS, False))
def resolve_scorer(capability: str, store: object | None):
"""Return ``(score_fn, source)`` for a capability, preferring an on-device
trained spec over the shipped embedded baseline.
``score_fn`` maps a feature mapping -> float in [0,1]; ``source`` is
``"on_device"`` or ``"baseline"``. Returns ``(None, None)`` when neither is
available. This is the single bridge that lets trained models (Stage 4)
actually reach inference (ML Lab shadow comparison + MLSuggestionEngine)
while transparently falling back to the baseline.
"""
def _baseline():
"""Resolve the shipped embedded baseline scorer for this capability.
Kept as a lazily-invoked helper so the baseline module is only imported
when the on-device spec is absent *or* fails at call time - preserving the
original "baseline only loaded when needed" semantics.
"""
module_name = _MODEL_MODULES.get(capability)
if module_name is None:
return (None, None)
try:
module = importlib.import_module(f"{__package__}.{module_name}")
except Exception as exc: # noqa: BLE001
_LOGGER.warning(
"Failed to load embedded baseline for capability %r: %s",
capability, exc,
)
return (None, None)
def _baseline_score(feats, _m=module):
# The embedded baseline must never raise into live inference either
# (mirrors _on_device_score's call-time guard): on any scoring error
# log and return a neutral 0.0 so a gate treats the signal as absent
# rather than letting the exception reach live detection/matching.
try:
return float(_m.score(feats))
except Exception as exc: # noqa: BLE001 - never raise into live inference
_LOGGER.warning(
"Embedded baseline scorer for capability %r failed at call "
"time, returning neutral 0.0: %s", capability, exc,
)
return 0.0
return (_baseline_score, "baseline")
# 1) On-device trained spec from the store.
if store is not None:
try:
versions = store.get_ml_model_versions() or {} # type: ignore[attr-defined]
record = versions.get(capability)
spec = record.get("spec") if isinstance(record, dict) else None
# Only treat a spec as a classifier here. A regression spec
# (standardized_linear) must never be sigmoid-squashed by score_spec;
# classifier and regression capability keys are disjoint today, but this
# guard keeps it safe if a key were ever reused.
if isinstance(spec, dict) and spec.get("kind") != "standardized_linear":
from .trainer import score_spec
def _on_device_score(feats, _s=spec):
# A malformed / dimensionally-incompatible promoted spec must
# never raise into live detection/matching: on any call-time
# error fall back to the embedded baseline (or a neutral 0.0).
try:
return float(score_spec(_s, feats))
except Exception as exc: # noqa: BLE001 - never raise into live inference
_LOGGER.warning(
"Trained scorer for capability %r failed at call time, "
"falling back to baseline: %s", capability, exc,
)
fn, _src = _baseline()
if fn is not None:
try:
return fn(feats)
except Exception: # noqa: BLE001 - baseline must not raise either
pass
return 0.0
return (_on_device_score, "on_device")
except Exception as exc: # noqa: BLE001 - never let a bad store break inference
_LOGGER.warning(
"Failed to load trained spec for capability %r, falling back to baseline: %s",
capability, exc,
)
# 2) Shipped embedded baseline module.
return _baseline()
def resolve_regressor(capability: str, store: object | None):
"""Return ``(predict_fn, source)`` for a regression capability.
Regression models (``"remaining_time"`` and ``"total_energy"``) have **no**
shipped embedded baseline - they are trained purely on-device (Stage 4) and
stored as ``standardized_linear`` specs. This returns ``(None, None)`` until
on-device training promotes one, so live behaviour is unchanged until then.
``predict_fn`` maps a feature mapping -> float in the model's target units
(a completion fraction in ~[0, 1] for both regression capabilities).
"""
if store is None:
return (None, None)
try:
versions = store.get_ml_model_versions() or {} # type: ignore[attr-defined]
record = versions.get(capability)
spec = record.get("spec") if isinstance(record, dict) else None
if isinstance(spec, dict) and spec.get("kind") == "standardized_linear":
from .trainer import predict_value_spec
def _on_device_predict(feats, _s=spec):
# A malformed / incompatible promoted regression spec must never
# raise into the live remaining-time / energy estimates: on any
# call-time error return NaN so the (isfinite-guarded) consumers
# treat this capability as inert.
try:
return float(predict_value_spec(_s, feats))
except Exception as exc: # noqa: BLE001 - never raise into live inference
_LOGGER.warning(
"Trained regressor for capability %r failed at call time, "
"returning inert value: %s", capability, exc,
)
return float("nan")
return (_on_device_predict, "on_device")
except Exception as exc: # noqa: BLE001 - never let a bad store break inference
_LOGGER.warning(
"Failed to load trained regression spec for capability %r, capability will be inert: %s",
capability, exc,
)
return (None, None)
_MANIFEST_MODELS_CACHE: list[dict[str, object]] | None = None
def available_models() -> list[dict[str, object]]:
"""Return provenance for the embedded models, or [] if none are shipped.
The manifest is a shipped baseline file that never changes at runtime
(on-device training writes specs into the store, not this file), so the parsed
result is cached module-side after the first read.
"""
global _MANIFEST_MODELS_CACHE
if _MANIFEST_MODELS_CACHE is not None:
return _MANIFEST_MODELS_CACHE
manifest = Path(__file__).resolve().parent / "promoted_manifest.json"
if not manifest.exists():
return []
try:
payload = json.loads(manifest.read_text(encoding="utf-8"))
except (OSError, ValueError):
return []
models = payload.get("models")
result = models if isinstance(models, list) else []
_MANIFEST_MODELS_CACHE = result
return result
@@ -0,0 +1,807 @@
# 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/>.
"""NumPy-only runtime feature extraction for the embedded ML models.
This is the bridge between live cycle data and the embedded models: it computes
the exact ``FEATURE_COLUMNS`` each model expects, ported faithfully from the
``ml_washdata`` lab feature definitions (see each ``*_feature_contract.json``).
Three feature extractors are implemented:
- **Cycle-end detector** (``END_FEATURE_COLUMNS``): self-contained from a live
power series + profile expectation; call ``latest_end_event_features``.
- **Live-match commit confidence** (``LIVE_MATCH_FEATURE_COLUMNS``): requires the
match ranking from ``ProfileStore`` plus the observed prefix; call
``live_match_features``.
- **Hybrid cycle quality** (``QUALITY_FEATURE_COLUMNS``): requires the complete
cycle power trace plus profile/match context; call ``quality_features``.
All inputs are plain Python/NumPy (offset-seconds, watts), so this module has no
Home Assistant dependency and is unit-tested directly. It is only invoked when
the user opts into experimental ML models (see engine.py).
"""
from __future__ import annotations
import math
from typing import Sequence
import numpy as np
# ---------------------------------------------------------------------------
# Cycle-end detector
# ---------------------------------------------------------------------------
# Mirrors ml_washdata/wash_ml/end_detection.py. The test suite asserts this list
# equals the embedded model's FEATURE_COLUMNS so the two cannot drift.
END_FEATURE_COLUMNS = [
"elapsed_fraction",
"energy_fraction",
"energy_remaining_expected",
"power_before_ratio",
"drop_ratio",
"peak_seen_ratio",
"low_run_s_log",
"elapsed_log",
]
MIN_LOW_RUN_S = 45.0
Point = tuple[float, float]
def cumulative_energy_wh(points: Sequence[Point]) -> np.ndarray:
"""Trapezoidal cumulative energy (Wh) aligned to each reading, with gap handling.
Segments spanning sensor-outage gaps (larger than ``energy_gap_threshold_s``)
are zeroed out so energy does not inflate across outages, matching the behaviour
of ``signal_processing.integrate_wh`` used for stored ``energy_wh`` fields.
"""
from ..signal_processing import energy_gap_threshold_s # noqa: PLC0415
offsets = np.asarray([float(offset) for offset, _power in points], dtype=float)
powers = np.asarray([max(0.0, float(power)) for _offset, power in points], dtype=float)
if offsets.size < 2:
return np.zeros(offsets.size, dtype=float)
max_gap = energy_gap_threshold_s(offsets)
deltas = np.diff(offsets)
segment = (powers[:-1] + powers[1:]) / 2.0 * deltas / 3600.0
# Zero out segments that span a sensor outage gap to match integrate_wh behaviour.
segment[deltas > max_gap] = 0.0
return np.concatenate([[0.0], np.cumsum(segment)])
def profile_expectation(cycles_points: Sequence[Sequence[Point]]) -> dict[str, float] | None:
"""Median expected duration (s), energy (Wh) and peak (W) over a profile's cycles."""
durations: list[float] = []
energies: list[float] = []
peaks: list[float] = []
for points in cycles_points:
if len(points) < 2:
continue
offsets = [float(offset) for offset, _power in points]
duration = offsets[-1] - offsets[0]
if duration <= 0:
continue
durations.append(duration)
energies.append(float(cumulative_energy_wh(points)[-1]))
peaks.append(max((float(power) for _offset, power in points), default=0.0))
if not durations:
return None
return {
"duration": float(np.median(durations)),
"energy": float(np.median(energies)),
"peak": float(np.median(peaks)),
}
def profile_expectations(cycles: list[dict]) -> dict[str, dict[str, float]]:
"""Median duration/energy/peak per profile from stored cycle dicts.
The dict-based counterpart of :func:`profile_expectation` (which works from
decompressed traces): reads the ``duration``/``energy_wh``/``max_power``
scalar fields already stored on each cycle. Shared by on-device training
(``training_task``) and the ML suggestion engine so the "profile expectation"
definition lives in one place. Profiles with no usable duration are skipped;
missing energy/peak default to 500.
"""
stats: dict[str, dict[str, list[float]]] = {}
for c in cycles:
name = c.get("profile_name")
if not isinstance(name, str) or not name:
continue
s = stats.setdefault(name, {"d": [], "e": [], "p": []})
for key, field in (("d", "duration"), ("e", "energy_wh"), ("p", "max_power")):
v = c.get(field)
if isinstance(v, (int, float)) and not isinstance(v, bool):
s[key].append(float(v))
out: dict[str, dict[str, float]] = {}
for name, s in stats.items():
if not s["d"]:
continue
out[name] = {
"duration": float(np.median(s["d"])),
"energy": float(np.median(s["e"])) if s["e"] else 500.0,
"peak": float(np.median(s["p"])) if s["p"] else 500.0,
}
return out
def latest_end_event_features(
points: Sequence[Point],
expectation: dict[str, float],
*,
min_low_run_s: float = MIN_LOW_RUN_S,
) -> dict[str, float] | None:
"""Features for the most recent low-power run (the "is this the end?" moment).
Returns ``None`` when there is no qualifying low-power run yet (the cycle is
still clearly active), in which case the caller keeps the existing behavior.
Mirrors ``end_detection._cycle_events`` for a single (latest) event.
"""
if len(points) < 4 or not expectation:
return None
offsets = np.asarray([float(offset) for offset, _power in points], dtype=float)
powers = np.asarray([max(0.0, float(power)) for _offset, power in points], dtype=float)
start = float(offsets[0])
peak = max(float(expectation.get("peak") or 0.0), float(np.max(powers)), 1.0)
low_threshold = max(5.0, 0.02 * peak)
profile_duration = max(float(expectation.get("duration") or 0.0), 1.0)
profile_energy = max(float(expectation.get("energy") or 0.0), 1e-6)
cumulative = cumulative_energy_wh(points)
# Find the most recent contiguous low-power run.
count = len(points)
run_start: int | None = None
index = count - 1
while index >= 0 and powers[index] < low_threshold:
run_start = index
index -= 1
if run_start is None:
return None
run_end = count - 1
run_duration = float(offsets[run_end] - offsets[run_start])
if run_duration < min_low_run_s:
return None
event_time = float(offsets[run_start] - start)
elapsed = max(event_time, 1.0)
energy_so_far = float(cumulative[run_start])
window = powers[max(0, run_start - 4):run_start]
power_before = float(np.mean(window)) if window.size else float(powers[run_start])
running_peak = float(np.max(powers[: run_start + 1]))
return {
"elapsed_fraction": float(min(elapsed / profile_duration, 2.0)),
"energy_fraction": float(min(energy_so_far / profile_energy, 2.0)),
"energy_remaining_expected": float(max(0.0, 1.0 - energy_so_far / profile_energy)),
"power_before_ratio": float(min(power_before / peak, 2.0)),
"drop_ratio": float(np.clip((power_before - float(powers[run_start])) / peak, 0.0, 1.0)),
"peak_seen_ratio": float(min(running_peak / peak, 2.0)),
"low_run_s_log": float(math.log1p(max(0.0, run_duration))),
"elapsed_log": float(math.log1p(elapsed)),
}
# ---------------------------------------------------------------------------
# Live-match commit confidence
# ---------------------------------------------------------------------------
# Mirrors ml_washdata/wash_ml/live_matching.py COMMIT_FEATURE_COLUMNS.
# The test suite asserts this equals the embedded model's FEATURE_COLUMNS.
LIVE_MATCH_FEATURE_COLUMNS = [
"match_progress_top1",
"top1_distance",
"margin",
"distance_ratio",
"candidate_count_log",
"prefix_active_fraction",
"duration_ratio_top1",
"elapsed_log",
]
def live_match_features(
points: Sequence[Point],
elapsed_s: float,
top1_distance: float,
top2_distance: float | None,
top1_median_duration_s: float,
candidate_count: int,
) -> dict[str, float]:
"""Features for the live-match commit-confidence model.
Args:
points: Observed power readings (offset_s, watts) for the current prefix.
elapsed_s: Seconds elapsed since cycle start.
top1_distance: Blended RMSE+DTW shape distance to the top-1 candidate
prefix (as returned by the profile matcher).
top2_distance: Distance to the top-2 candidate; pass ``None`` or ``0.0``
when only one candidate is available (margin defaults to 1.0).
top1_median_duration_s: Expected (median) duration of the top-1 candidate
profile in seconds.
candidate_count: Number of candidate profiles on this device.
Returns a dict with exactly ``LIVE_MATCH_FEATURE_COLUMNS`` keys.
"""
elapsed = max(0.0, float(elapsed_s))
top1 = max(0.0, float(top1_distance))
top2_raw = float(top2_distance) if top2_distance is not None else 0.0
top2 = top2_raw if top2_raw > 1e-9 else top1 + 1.0
margin = max(0.0, top2 - top1)
dur = float(top1_median_duration_s)
progress = (elapsed / dur) if dur > 0 else 1.0
# prefix_active_fraction: fraction of prefix readings clearly above idle.
# Lab uses > 0.05 on a peak-normalised trace; equivalent here is > 5% of
# peak, with a 1 W floor so a cold trace never divides by near-zero.
if points:
powers = np.asarray([max(0.0, float(p)) for _, p in points], dtype=float)
peak = float(np.max(powers)) if powers.size else 0.0
active_thr = max(1.0, 0.05 * peak)
prefix_active_fraction = float(np.mean(powers > active_thr)) if powers.size else 0.0
else:
prefix_active_fraction = 0.0
return {
"match_progress_top1": float(min(progress, 2.0)),
"top1_distance": float(top1),
"margin": float(margin),
"distance_ratio": float(top1 / top2) if top2 > 1e-9 else 1.0,
"candidate_count_log": float(math.log1p(max(0, int(candidate_count)))),
"prefix_active_fraction": float(prefix_active_fraction),
"duration_ratio_top1": float(min(progress, 2.0)),
"elapsed_log": float(math.log1p(elapsed)),
}
# ---------------------------------------------------------------------------
# Remaining-time / progress regressor
# ---------------------------------------------------------------------------
# Feature columns for the on-device remaining-time regressor. Unlike the three
# classifier heads this model is a ``standardized_linear`` regressor whose target
# is the cycle completion fraction (elapsed / total_actual). There is no shipped
# baseline: the model exists only once on-device training promotes one over the
# naive elapsed/expected estimate (``elapsed_over_expected`` is deliberately the
# first column so the naive baseline is trivially recoverable). The same
# extractor runs at training time on synthesized prefixes and at inference on the
# live trace, so the columns cannot drift.
PROGRESS_FEATURE_COLUMNS = [
"elapsed_over_expected",
"energy_over_expected",
"mean_power_over_peak",
"recent_power_over_peak",
"tail_slope_norm",
"active_fraction",
"elapsed_log",
]
def progress_features(
points: Sequence[Point],
expectation: dict[str, float],
) -> dict[str, float] | None:
"""Features for the remaining-time regressor from a running-cycle prefix.
Args:
points: Observed prefix power readings (offset_s, watts).
expectation: Matched profile's median ``duration``/``energy``/``peak``
(as produced by :func:`profile_expectation`).
Returns a dict with exactly ``PROGRESS_FEATURE_COLUMNS`` keys, or ``None``
when there is too little data to characterise progress.
"""
pts = _clean_points(points)
if len(pts) < 4 or not expectation:
return None
offsets = np.asarray([o for o, _ in pts], dtype=float)
powers = np.asarray([p for _, p in pts], dtype=float)
elapsed = max(float(offsets[-1] - offsets[0]), 1.0)
exp_dur = max(float(expectation.get("duration") or 0.0), 1.0)
exp_energy = max(float(expectation.get("energy") or 0.0), 1e-6)
exp_peak = max(float(expectation.get("peak") or 0.0), 1.0)
energy_so_far = float(cumulative_energy_wh(pts)[-1])
active_thr = max(1.0, 0.05 * exp_peak)
active_mask = powers > active_thr
active = powers[active_mask]
mean_power = float(np.mean(active)) if active.size else 0.0
# Recent power: mean of the trailing ~5% of samples (min one sample).
tail_n = max(1, len(pts) // 20)
recent_power = float(np.mean(powers[-tail_n:]))
# Tail slope over the last quarter (W per sample), normalised by peak: a
# declining tail is a strong "near the end" signal.
quarter = max(2, len(pts) // 4)
tail = powers[-quarter:]
if tail.size >= 2:
x = np.arange(tail.size, dtype=float)
xm = x - float(np.mean(x))
denom = float(np.dot(xm, xm))
slope = float(np.dot(xm, tail - float(np.mean(tail))) / denom) if denom > 1e-9 else 0.0
else:
slope = 0.0
return {
"elapsed_over_expected": float(min(elapsed / exp_dur, 3.0)),
"energy_over_expected": float(min(energy_so_far / exp_energy, 3.0)),
"mean_power_over_peak": float(min(mean_power / exp_peak, 2.0)),
"recent_power_over_peak": float(min(recent_power / exp_peak, 2.0)),
"tail_slope_norm": float(np.clip(slope / exp_peak, -2.0, 2.0)),
"active_fraction": float(np.mean(active_mask)) if powers.size else 0.0,
"elapsed_log": float(math.log1p(elapsed)),
}
# ---------------------------------------------------------------------------
# Hybrid cycle quality
# ---------------------------------------------------------------------------
# Mirrors ml_washdata/wash_ml/hybrid_curve_quality.py HYBRID_FEATURE_COLUMNS.
# Order must match the embedded model exactly; the test suite asserts this.
QUALITY_FEATURE_COLUMNS = [
# profile / context
"duration_log_ratio",
"energy_log_ratio",
"peak_log_ratio",
"profile_distance",
"label_margin_positive",
"max_gap_ratio",
"low_power_gap_ratio",
"false_end_energy_ratio",
"sample_density_log",
"peak_density_log",
"local_spike_score",
"local_spike_rate",
"local_noise_score",
"leading_idle_ratio",
"trailing_idle_ratio",
"trimmed_duration_log_ratio",
"flag_pressure",
"shape_fit_penalty",
# trace shape
"shape_active_fraction",
"shape_early_energy_fraction",
"shape_late_energy_fraction",
"shape_mid_trough_depth",
"shape_peak_density",
"shape_max_step_drop",
"shape_max_step_rise",
"shape_active_cv",
"shape_autocorr_lag1",
"shape_derivative_sign_changes",
"shape_plateau_ratio",
"shape_tail_slope",
# availability
"has_trace",
]
_QUALITY_TRACE_LENGTH = 128
_IDLE_THRESHOLD_W = 2.0
_STOP_THRESHOLD_W = 2.0
_SHAPE_COLUMNS = [
"shape_active_fraction",
"shape_early_energy_fraction",
"shape_late_energy_fraction",
"shape_mid_trough_depth",
"shape_peak_density",
"shape_max_step_drop",
"shape_max_step_rise",
"shape_active_cv",
"shape_autocorr_lag1",
"shape_derivative_sign_changes",
"shape_plateau_ratio",
"shape_tail_slope",
]
def quality_features(
points: Sequence[Point],
profile_median_duration_s: float,
profile_median_energy_wh: float,
profile_median_peak_w: float,
profile_distance: float,
label_margin: float,
profile_fit_score: float,
flag_count: int,
*,
trace_length: int = _QUALITY_TRACE_LENGTH,
) -> dict[str, float]:
"""Features for the hybrid curve-quality model (problem/bad-cycle detector).
Args:
points: Complete cycle power trace (offset_s, watts).
profile_median_duration_s: Median duration of the matched profile (s).
profile_median_energy_wh: Median energy of the matched profile (Wh).
profile_median_peak_w: Median peak power of the matched profile (W).
profile_distance: Shape distance from the MatchResult to the assigned
profile envelope (higher = worse fit).
label_margin: Score margin between the top-1 and top-2 profile candidates
(positive = confident match; 0.0 when only one candidate exists).
profile_fit_score: Profile fit score in [0, 1] from the matcher.
flag_count: Number of detection/anomaly flags raised for this cycle by
the existing detector (early_power_dip, false_end_pause_seen, etc.).
Returns a dict with exactly ``QUALITY_FEATURE_COLUMNS`` keys.
"""
pts = _clean_points(points)
if len(pts) < 4:
return _no_trace_quality_features(
profile_distance=profile_distance,
label_margin=label_margin,
profile_fit_score=profile_fit_score,
flag_count=flag_count,
)
offsets = np.asarray([float(o) for o, _ in pts], dtype=float)
powers = np.asarray([float(p) for _, p in pts], dtype=float)
duration_s = max(float(offsets[-1] - offsets[0]), 1.0)
total_energy_wh = float(cumulative_energy_wh(pts)[-1])
max_power_w = float(np.max(powers))
# -- profile context ratios --
prof_dur = max(float(profile_median_duration_s), 1.0)
prof_energy = max(float(profile_median_energy_wh), 1e-6)
prof_peak = max(float(profile_median_peak_w), 1.0)
# -- sampling gap features --
intervals = np.diff(offsets)
usable = intervals[(intervals > 0) & (intervals < 3600)]
max_gap_s = float(np.max(usable)) if usable.size else 0.0
# -- low-power / false-end features --
low_gap_s = _longest_low_power_gap_s(pts, _STOP_THRESHOLD_W)
fe_energy_wh = _false_end_energy_wh(pts, _STOP_THRESHOLD_W)
# -- density features --
sample_count = len(pts)
peak_count = _power_peak_count_arr(powers)
# -- noise features (from raw points) --
noise = _trace_noise_features(pts)
# -- idle-padding features --
padding = _trace_padding_ratios(pts, offsets, powers, duration_s, _IDLE_THRESHOLD_W)
# -- trace shape descriptors (from resampled + trimmed trace) --
trace = _resample_to_length(pts, trace_length)
shape = _trace_shape_descriptors(trace) if trace is not None else {c: 0.0 for c in _SHAPE_COLUMNS}
trimmed_ratio = float(padding["trimmed_duration_ratio"])
trimmed_log = math.log(max(1e-6, trimmed_ratio)) # always <= 0
return {
"duration_log_ratio": _log_ratio(duration_s / prof_dur),
"energy_log_ratio": _log_ratio(total_energy_wh / prof_energy),
"peak_log_ratio": _log_ratio(max_power_w / prof_peak),
"profile_distance": float(profile_distance),
"label_margin_positive": float(max(0.0, float(label_margin))),
"max_gap_ratio": _safe_div(max_gap_s, duration_s),
"low_power_gap_ratio": _safe_div(low_gap_s, duration_s),
"false_end_energy_ratio": _safe_div(fe_energy_wh, max(total_energy_wh, 1e-6)),
"sample_density_log": math.log1p(_safe_div(sample_count * 60.0, duration_s)),
"peak_density_log": math.log1p(_safe_div(peak_count * 3600.0, duration_s)),
"local_spike_score": float(noise["local_spike_score"]),
"local_spike_rate": float(noise["local_spike_rate"]),
"local_noise_score": float(noise["local_noise_score"]),
"leading_idle_ratio": float(padding["leading_idle_ratio"]),
"trailing_idle_ratio": float(padding["trailing_idle_ratio"]),
"trimmed_duration_log_ratio": float(trimmed_log),
"flag_pressure": float(max(0, int(flag_count))),
"shape_fit_penalty": float(max(0.0, 1.0 - float(profile_fit_score))),
**shape,
"has_trace": 1.0,
}
def _no_trace_quality_features(
*,
profile_distance: float,
label_margin: float,
profile_fit_score: float,
flag_count: int,
) -> dict[str, float]:
"""Zero-valued quality features for cycles with no usable power trace."""
return {
"duration_log_ratio": 0.0,
"energy_log_ratio": 0.0,
"peak_log_ratio": 0.0,
"profile_distance": float(profile_distance),
"label_margin_positive": float(max(0.0, float(label_margin))),
"max_gap_ratio": 0.0,
"low_power_gap_ratio": 0.0,
"false_end_energy_ratio": 0.0,
"sample_density_log": 0.0,
"peak_density_log": 0.0,
"local_spike_score": 0.0,
"local_spike_rate": 0.0,
"local_noise_score": 0.0,
"leading_idle_ratio": 0.0,
"trailing_idle_ratio": 0.0,
"trimmed_duration_log_ratio": 0.0,
"flag_pressure": float(max(0, int(flag_count))),
"shape_fit_penalty": float(max(0.0, 1.0 - float(profile_fit_score))),
**{c: 0.0 for c in _SHAPE_COLUMNS},
"has_trace": 0.0,
}
# ---------------------------------------------------------------------------
# Shared helpers (ported from ml_washdata/wash_ml/features.py and
# ml_washdata/wash_ml/hybrid_curve_quality.py - NumPy only)
# ---------------------------------------------------------------------------
def _clean_points(points: Sequence[Point]) -> list[Point]:
"""Filter out non-finite readings, sort by offset, and deduplicate."""
clean: list[Point] = []
for offset_raw, power_raw in points:
offset = float(offset_raw)
power = float(power_raw)
if math.isfinite(offset) and math.isfinite(power):
clean.append((offset, max(0.0, power)))
clean.sort(key=lambda pt: pt[0])
deduped: list[Point] = []
for offset, power in clean:
if deduped and offset == deduped[-1][0]:
deduped[-1] = (offset, power)
else:
deduped.append((offset, power))
return deduped
def _longest_low_power_gap_s(points: list[Point], threshold_w: float) -> float:
"""Longest contiguous span below ``threshold_w`` (seconds)."""
longest = 0.0
current = 0.0
for i in range(1, len(points)):
prev_t, prev_p = points[i - 1]
curr_t, curr_p = points[i]
dt = curr_t - prev_t
if dt <= 0 or dt > 3600:
current = 0.0
continue
avg = (prev_p + curr_p) / 2.0
if avg < threshold_w:
current += dt
longest = max(longest, current)
else:
current = 0.0
return float(longest)
def _false_end_energy_wh(points: list[Point], threshold_w: float) -> float:
"""Energy accumulated during low-power pauses that were followed by more power.
These "false ends" indicate the cycle was interrupted but resumed. Returns
the maximum such pause energy (Wh); 0.0 if no false end occurred.
"""
false_energies: list[float] = []
in_pause = False
pause_energy = 0.0
for i in range(1, len(points)):
prev_t, prev_p = points[i - 1]
curr_t, curr_p = points[i]
dt = curr_t - prev_t
if dt <= 0 or dt > 3600:
in_pause = False
pause_energy = 0.0
continue
avg = (prev_p + curr_p) / 2.0
if avg < threshold_w:
in_pause = True
pause_energy += avg * (dt / 3600.0)
elif in_pause:
false_energies.append(pause_energy)
in_pause = False
pause_energy = 0.0
return float(max(false_energies) if false_energies else 0.0)
def _power_peak_count_arr(powers: np.ndarray) -> int:
"""Number of above-p75 power peaks (rising transitions through the p75 threshold)."""
if powers.size < 3:
return 0
threshold = max(float(np.percentile(powers, 75)), 10.0)
above = powers > threshold
transitions = np.diff(above.astype(int))
return int(np.sum(transitions == 1) + (1 if above[0] else 0))
def _trace_noise_features(points: list[Point]) -> dict[str, float]:
"""Local spike and noise floor metrics over the raw power trace.
Ported from ml_washdata/wash_ml/features.py ``trace_noise_features``.
Distinguishes narrow single-sample spikes from broad appliance phases.
"""
if len(points) < 5:
return {"local_spike_score": 0.0, "local_spike_rate": 0.0, "local_noise_score": 0.0}
powers = np.asarray([float(p) for _, p in points], dtype=float)
active = powers[powers > 0.5]
scale = float(np.percentile(active, 95)) if active.size else float(np.max(powers))
if not math.isfinite(scale) or scale <= 1e-6:
return {"local_spike_score": 0.0, "local_spike_rate": 0.0, "local_noise_score": 0.0}
normalized = np.clip(powers / scale, 0.0, 8.0)
spike_scores: list[float] = []
residuals: list[float] = []
n = normalized.size
for i, value in enumerate(normalized):
left = max(0, i - 2)
right = min(n, i + 3)
neighbors = np.concatenate([normalized[left:i], normalized[i + 1:right]])
if neighbors.size < 2:
continue
local_median = float(np.median(neighbors))
residual = abs(float(value - local_median))
residuals.append(residual)
left_nbr = float(normalized[i - 1]) if i > 0 else local_median
right_nbr = float(normalized[i + 1]) if i + 1 < n else local_median
shoulder = max(left_nbr, right_nbr)
narrow_jump = float(value - shoulder)
if value > 0.15 and (value - local_median) > 0.28 and narrow_jump > 0.18:
spike_scores.append(min(3.0, max(float(value - local_median), narrow_jump)))
spike_count = len(spike_scores)
return {
"local_spike_score": round(float(max(spike_scores, default=0.0)), 6),
"local_spike_rate": round(float(spike_count / max(1, n)), 6),
"local_noise_score": round(float(np.percentile(np.asarray(residuals, dtype=float), 95)) if residuals else 0.0, 6),
}
def _trace_padding_ratios(
pts: list[Point],
offsets: np.ndarray,
powers: np.ndarray,
duration_s: float,
idle_threshold_w: float,
) -> dict[str, float]:
"""Leading/trailing idle fractions and trimmed-duration ratio."""
active_mask = powers > idle_threshold_w
active_indexes = np.where(active_mask)[0]
if active_indexes.size == 0 or duration_s <= 0:
return {"leading_idle_ratio": 0.0, "trailing_idle_ratio": 0.0, "trimmed_duration_ratio": 1.0}
first_active_t = float(offsets[active_indexes[0]])
last_active_t = float(offsets[active_indexes[-1]])
start_t = float(offsets[0])
end_t = float(offsets[-1])
leading = max(0.0, first_active_t - start_t)
trailing = max(0.0, end_t - last_active_t)
trimmed = max(0.0, last_active_t - first_active_t)
return {
"leading_idle_ratio": float(leading / duration_s),
"trailing_idle_ratio": float(trailing / duration_s),
"trimmed_duration_ratio": float(trimmed / duration_s) if duration_s > 0 else 1.0,
}
def _resample_to_length(points: list[Point], length: int) -> np.ndarray | None:
"""Trim idle padding, resample to ``length`` points, and peak-normalise.
Ported from ml_washdata/wash_ml/hybrid_curve_quality.py ``_resample_trace``.
Returns ``None`` when the trace is too short to be useful.
"""
# Trim leading/trailing idle.
active_indexes = [i for i, (_, p) in enumerate(points) if p > _IDLE_THRESHOLD_W]
if not active_indexes:
return None
pad_s = 60.0
start_off = max(points[0][0], points[active_indexes[0]][0] - pad_s)
end_off = points[active_indexes[-1]][0] + pad_s
trimmed = [(o, p) for o, p in points if start_off <= o <= end_off]
if len(trimmed) < 2:
return None
offsets = np.asarray([float(o) for o, _ in trimmed], dtype=float)
powers = np.asarray([max(0.0, float(p)) for _, p in trimmed], dtype=float)
valid = np.isfinite(offsets) & np.isfinite(powers)
offsets = offsets[valid]
powers = powers[valid]
if offsets.size < 2 or offsets[-1] <= offsets[0]:
return None
grid = np.linspace(offsets[0], offsets[-1], length)
trace = np.interp(grid, offsets, powers)
active = trace[trace > 0.5]
scale = float(np.percentile(active, 95)) if active.size else float(np.max(trace))
if not math.isfinite(scale) or scale <= 1e-6:
scale = 1.0
return np.clip(trace / scale, 0.0, 5.0)
def _trace_shape_descriptors(trace: np.ndarray) -> dict[str, float]:
"""Deterministic, scale-robust shape descriptors over a normalised trace.
Ported from ml_washdata/wash_ml/hybrid_curve_quality.py
``_trace_shape_descriptors``. Every value is NumPy-computable at runtime.
"""
if trace is None or trace.size < 4:
return {c: 0.0 for c in _SHAPE_COLUMNS}
trace = np.asarray(trace, dtype=float)
length = trace.size
total = float(np.sum(trace))
active_mask = trace > 0.5
active = trace[active_mask]
quarter = max(1, length // 4)
early_energy = float(np.sum(trace[:quarter]))
late_energy = float(np.sum(trace[-quarter:]))
mid = trace[quarter: length - quarter]
active_level = float(np.median(active)) if active.size else 0.0
mid_trough_depth = 0.0
if mid.size and active_level > 1e-6:
mid_trough_depth = float(np.clip(1.0 - float(np.min(mid)) / active_level, 0.0, 1.0))
diffs = np.diff(trace)
return {
"shape_active_fraction": float(np.mean(active_mask)),
"shape_early_energy_fraction": _safe_div(early_energy, total),
"shape_late_energy_fraction": _safe_div(late_energy, total),
"shape_mid_trough_depth": float(mid_trough_depth),
"shape_peak_density": _shape_peak_density(trace),
"shape_max_step_drop": float(max(0.0, -float(np.min(diffs)))) if diffs.size else 0.0,
"shape_max_step_rise": float(max(0.0, float(np.max(diffs)))) if diffs.size else 0.0,
"shape_active_cv": _safe_div(float(np.std(active)), float(np.mean(active))) if active.size else 0.0,
"shape_autocorr_lag1": _autocorr_lag1(trace),
"shape_derivative_sign_changes": _safe_div(
float(np.sum(np.abs(np.diff(np.sign(diffs))) > 0)), float(diffs.size)
) if diffs.size else 0.0,
"shape_plateau_ratio": _plateau_ratio(trace, active_level),
"shape_tail_slope": _safe_div(float(trace[-1] - trace[-quarter]), float(quarter)),
}
def _shape_peak_density(trace: np.ndarray, prominence: float = 0.2) -> float:
"""Prominent local maxima per sample."""
if trace.size < 3:
return 0.0
peaks = sum(
1
for i in range(1, trace.size - 1)
if trace[i] > trace[i - 1] and trace[i] >= trace[i + 1] and trace[i] >= prominence
)
return float(peaks) / float(trace.size)
def _autocorr_lag1(trace: np.ndarray) -> float:
"""Lag-1 autocorrelation (smoothness indicator)."""
centered = trace - float(np.mean(trace))
denom = float(np.dot(centered, centered))
if denom <= 1e-9:
return 0.0
return float(np.dot(centered[:-1], centered[1:]) / denom)
def _plateau_ratio(trace: np.ndarray, active_level: float, band: float = 0.12) -> float:
"""Fraction of trace within ``band`` of the running active level."""
if active_level <= 1e-6:
return 0.0
within = np.abs(trace - active_level) <= band
return float(np.mean(within & (trace > 0.5)))
def _log_ratio(ratio: float) -> float:
"""log(ratio) clamped to finite; 0.0 for non-positive or non-finite inputs."""
if not math.isfinite(ratio) or ratio <= 0:
return 0.0
return float(math.log(max(1e-6, ratio)))
def _safe_div(numerator: float, denominator: float) -> float:
if not math.isfinite(numerator) or not math.isfinite(denominator) or abs(denominator) <= 1e-9:
return 0.0
return float(numerator / denominator)
@@ -0,0 +1,157 @@
[
{
"feature": "duration_log_ratio",
"group": "profile_context",
"runtime_source": "log(cycle_duration / profile_target_duration)"
},
{
"feature": "energy_log_ratio",
"group": "profile_context",
"runtime_source": "log(cycle_energy_wh / profile_median_energy_wh)"
},
{
"feature": "peak_log_ratio",
"group": "profile_context",
"runtime_source": "log(max_power_w / profile_median_peak_w)"
},
{
"feature": "profile_distance",
"group": "match",
"runtime_source": "MatchResult distance to assigned profile envelope"
},
{
"feature": "label_margin_positive",
"group": "match",
"runtime_source": "max(0, score margin between top-1 and top-2 profile candidates)"
},
{
"feature": "max_gap_ratio",
"group": "sampling",
"runtime_source": "max sample gap seconds / cycle duration seconds"
},
{
"feature": "low_power_gap_ratio",
"group": "sampling",
"runtime_source": "longest low-power gap seconds / cycle duration seconds"
},
{
"feature": "false_end_energy_ratio",
"group": "tail",
"runtime_source": "energy after first apparent end / total energy"
},
{
"feature": "sample_density_log",
"group": "sampling",
"runtime_source": "log1p(sample_count * 60 / duration_s)"
},
{
"feature": "peak_density_log",
"group": "sampling",
"runtime_source": "log1p(power_peak_count * 3600 / duration_s)"
},
{
"feature": "local_spike_score",
"group": "noise",
"runtime_source": "trace-local spike magnitude score"
},
{
"feature": "local_spike_rate",
"group": "noise",
"runtime_source": "trace-local spike rate"
},
{
"feature": "local_noise_score",
"group": "noise",
"runtime_source": "trace-local noise floor score"
},
{
"feature": "leading_idle_ratio",
"group": "trim",
"runtime_source": "leading 0W padding fraction before true start"
},
{
"feature": "trailing_idle_ratio",
"group": "trim",
"runtime_source": "trailing 0W padding fraction after true end"
},
{
"feature": "trimmed_duration_log_ratio",
"group": "trim",
"runtime_source": "log(trimmed duration / raw duration)"
},
{
"feature": "flag_pressure",
"group": "flags",
"runtime_source": "count of detection/anomaly flags raised for the cycle"
},
{
"feature": "shape_fit_penalty",
"group": "match",
"runtime_source": "max(0, 1 - profile_fit_score)"
},
{
"feature": "shape_active_fraction",
"group": "trace_shape",
"runtime_source": "fraction of normalized trace above 0.5"
},
{
"feature": "shape_early_energy_fraction",
"group": "trace_shape",
"runtime_source": "energy share in first 25% of trace"
},
{
"feature": "shape_late_energy_fraction",
"group": "trace_shape",
"runtime_source": "energy share in last 25% of trace"
},
{
"feature": "shape_mid_trough_depth",
"group": "trace_shape",
"runtime_source": "1 - min(mid trace)/median active (split/pause depth)"
},
{
"feature": "shape_peak_density",
"group": "trace_shape",
"runtime_source": "prominent local maxima per sample"
},
{
"feature": "shape_max_step_drop",
"group": "trace_shape",
"runtime_source": "largest single-step drop (false-end signature)"
},
{
"feature": "shape_max_step_rise",
"group": "trace_shape",
"runtime_source": "largest single-step rise"
},
{
"feature": "shape_active_cv",
"group": "trace_shape",
"runtime_source": "std/mean over active region"
},
{
"feature": "shape_autocorr_lag1",
"group": "trace_shape",
"runtime_source": "lag-1 autocorrelation (smoothness)"
},
{
"feature": "shape_derivative_sign_changes",
"group": "trace_shape",
"runtime_source": "derivative sign-change rate (oscillation)"
},
{
"feature": "shape_plateau_ratio",
"group": "trace_shape",
"runtime_source": "fraction within band of running active level"
},
{
"feature": "shape_tail_slope",
"group": "trace_shape",
"runtime_source": "slope across the last 25% of trace"
},
{
"feature": "has_trace",
"group": "availability",
"runtime_source": "1.0 when a usable power trace exists, else 0.0"
}
]
@@ -0,0 +1,172 @@
# 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/>.
"""Auto-generated by ml_washdata/wash_ml/promotion.py. Do not edit by hand.
Embedded WashData model: 'hybrid_curve_quality' (target: 'problem_cycle').
Kind: standardized logistic regression. Runtime dependency: NumPy only.
Regenerate with ``./ml.sh experiment`` in the ml_washdata lab and copy the new
file. Determinism check at generation time: max_abs_score_diff=6.8331e-08
over 373 rows.
Usage in the integration::
from .hybrid_curve_quality_model import score, predict, FEATURE_COLUMNS
features = build_runtime_features(...) # must populate FEATURE_COLUMNS
is_positive = predict(features)
"""
from __future__ import annotations
import base64
import gzip
import json
from typing import Mapping
import numpy as np
MODEL_NAME = 'hybrid_curve_quality'
MODEL_TARGET = 'problem_cycle'
MODEL_KIND = 'standardized_logistic'
TARGET_UNITS = ''
THRESHOLD = 0.19
FEATURE_COLUMNS = [
'duration_log_ratio',
'energy_log_ratio',
'peak_log_ratio',
'profile_distance',
'label_margin_positive',
'max_gap_ratio',
'low_power_gap_ratio',
'false_end_energy_ratio',
'sample_density_log',
'peak_density_log',
'local_spike_score',
'local_spike_rate',
'local_noise_score',
'leading_idle_ratio',
'trailing_idle_ratio',
'trimmed_duration_log_ratio',
'flag_pressure',
'shape_fit_penalty',
'shape_active_fraction',
'shape_early_energy_fraction',
'shape_late_energy_fraction',
'shape_mid_trough_depth',
'shape_peak_density',
'shape_max_step_drop',
'shape_max_step_rise',
'shape_active_cv',
'shape_autocorr_lag1',
'shape_derivative_sign_changes',
'shape_plateau_ratio',
'shape_tail_slope',
'has_trace',
]
# Provenance (metrics at training time):
MODEL_METRICS = json.loads("""{
"owner_holdout": {
"accuracy": 0.872,
"balanced_accuracy": 0.89997,
"f1": 0.898734,
"fn": 15,
"fp": 1,
"positive_rate": 0.576,
"precision": 0.986111,
"problem_recall": 0.825581,
"rows": 125,
"specificity": 0.974359,
"tn": 38,
"tp": 71
},
"synthetic_all": {
"accuracy": 1.0,
"balanced_accuracy": 0.5,
"f1": 1.0,
"fn": 0,
"fp": 0,
"positive_rate": 1.0,
"precision": 1.0,
"problem_recall": 1.0,
"rows": 1255,
"specificity": 0.0,
"tn": 0,
"tp": 1255
}
}""")
_MODEL_BLOB = (
'H4sIAAAAAAACA3VW247bOAz9FcOvzaS6X7JPi6L7tBeg230qCkNjK4lR2/L60uls0X/fQzmTmem0yENkiiIPDymRX8vbNszlQe+V'
'1txoo3dlHYclTuXhww3bM2GMsZbvaO0151Zua6mtMMLt2N5aJ6URBkshmJLcqh0pC6ad87SU0mqns5QLeIE5vudeSyNNPmaMwo+W'
'UljpLfebrpQqGxPeCuW1yFKulJN5mSFYTXg4Z5o5pXZib5y2XAkyYTkThI5MKCaga7FUWnLvLWHnkjvpHCl4ox3XnqTMCuGV4CTV'
'QltpCZqHKas84eFcGkjJhZMGMq/JLnBLZjJgphwXVmSqaO2yMY99ydVHcJziEQwTT84J5mmbawk4hkwxnJYe9NJ5w5UGYGKHG4Ms'
'2Cx2nkGZkHEBsreAmBZcARwRLBzywTI9nkllPMvKiAO8kRfhPNg22YuwzIktSVwII9nmhHEH6s2WcoUAlATF0nBmrCLQSkoHpxek'
'xiuWQ2UMgGymUCKLcG22oiACsmkkCV5UzgKVhMxpktbCpdrKzXkOg7SWYFttYu457NqNL2etyim1wiKTlzIFPKcFsTzFsMSmCkt5'
'KAVq+YaZG+HfC3ZQ7qD4K8YOjJW78gi9dYpVnbq1H3AfPpTNOoWlTUPVpVOVl9CLQ5xO989EYwyfngumdGy7WDXtvIShjhB14TZ2'
'VR+mUztUY5rbpf1M8j58qU5hvB7t0h227+L0THoM3RyrODTVxf3Dxhz6kRzFARYzqgc8z0VdqkNXzWP7KVZznab4nQz2HkVDaucn'
'ajE07XCq2qaLV7/LFNruB9K270H2D4k7duFUjVOc5zXbnc9hjNWxXaoxDqFb7q+yUBM51XGiRRqu8him7v6BgRe7HUL46WbfNtUy'
'pfV0BjHjcr5uPOXqURtJmZc4Vs2UxpfSCfR8D7b+/ChZlwTuJiA68au0iVP7OWTduT0NVX0OwynOj0AIf1gfM5ulC2iu5i6N5PAc'
'ZgQRUE8o7BOIq1Pft1TYhungjIDOp3ZoIKC6a8LUtP8hHcgCCrGtqdwiclSjur+W6Q5cVefUNWldSBDqGnmr78sD3jQ8XOVt6Kh6'
'cXme7uAFsMgm3z6clQpfQ3ng6BvHEf8owEt9b2V1oJtu6FbEup0pK5B4Zzjn+arcdrGvsBe6LtsUGs/wrpzSHXCiW4CLESePbU05'
'orNWSe1RbrAkHf7h1fJv0LsflnNEpFW29TQkvmc/CUhvwWQFioNtYbAXYWSNJ0Fcvp/jz8Ir9JfY2QabbahJ5RuAD6GHg/J8fzuh'
'UIENTv9dQ7cV5ZCWmB+kN6m/bYfYFLkKbnKJFK+Ky3Pzuk7o2l+W4oLppr6vu1g0cYn1kqY9LL1bh6XtY9GvM9TSuFLRFb+9/fX9'
'P+/eVm/++v2fP/78uzhOqS86BF5kC68v9osmLKG4jUe8CwW9Drj/ZPQ93gIClYbiDrepoPq/ye/dXIzdOhfXtBSEcErd/EuxtKfz'
'EnGiXc7Fee3DcDPFz228g6HL2YundY7TzTHU8EaR9Yku9p6uAOp2XHELLsNKJvcim5GNh5Rd1ui0Gi3G0uzB9gb/uW9YJZzFLIN2'
'KRyXCk2StiVnaBa5J6NdcbHNI2jeArWXJxoMCyjVTdc6oahvOpzSioTU29CPbFbFWki3zTYavSkb0IxhmtC5+aEJW0vNDxJcKSGA'
'RqFlWp/nK8EdBpNsjNoaV1vvRjBOcDqGhu8xKG1LWKCeTy4waUlBUsOkMyoPMRqtmsamTRfDyzZ5YBPjk89LULKNTByTj9xGG8ZB'
'HQaAXZ4IMTF595G4Pcc+oHTvwnym+thvKcId61MTu9f0AC7oepGeqYfLksvqulGtQ7ugvkuSnNEg6E2idHL/7X9yriqGnQoAAA=='
)
_MODEL_CACHE: dict | None = None
def _load() -> dict:
global _MODEL_CACHE
if _MODEL_CACHE is None:
payload = gzip.decompress(base64.b64decode(_MODEL_BLOB.encode("ascii")))
spec = json.loads(payload.decode("utf-8"))
_MODEL_CACHE = {
"center": np.asarray(spec["center"], dtype=float),
"scale": np.asarray(spec["scale"], dtype=float),
"coef": np.asarray(spec["coef"], dtype=float),
"bias": float(spec["bias"]),
"threshold": float(spec["threshold"]),
"output_center": float(spec.get("output_center") or 0.0),
"output_scale": float(spec.get("output_scale") if spec.get("output_scale") is not None else 1.0),
"feature_columns": list(spec["feature_columns"]),
}
return _MODEL_CACHE
def score(features: Mapping[str, float]) -> float:
"""Return the model probability in [0, 1] for one feature mapping."""
model = _load()
vector = np.array(
[float(features.get(column) or 0.0) for column in model["feature_columns"]],
dtype=float,
)
scaled = (vector - model["center"]) / model["scale"]
logit = float(scaled @ model["coef"] + model["bias"])
logit = max(-60.0, min(60.0, logit))
return 1.0 / (1.0 + np.exp(-logit))
def predict(features: Mapping[str, float]) -> bool:
"""True when the example crosses the embedded decision threshold."""
return score(features) >= _load()["threshold"]
@@ -0,0 +1,294 @@
{
"cases": [
{
"expected_score": 0.0058697,
"features": {
"duration_log_ratio": 0.0,
"energy_log_ratio": 0.0,
"false_end_energy_ratio": 0.0,
"flag_pressure": 0.0,
"has_trace": 1.0,
"label_margin_positive": 0.0,
"leading_idle_ratio": 0.0,
"local_noise_score": 0.918434,
"local_spike_rate": 0.0,
"local_spike_score": 0.0,
"low_power_gap_ratio": 0.0462132,
"max_gap_ratio": 0.0462132,
"peak_density_log": 2.28328714,
"peak_log_ratio": -0.34046848,
"profile_distance": 0.0,
"sample_density_log": 0.81000683,
"shape_active_cv": 0.14447535,
"shape_active_fraction": 0.46875,
"shape_autocorr_lag1": 0.30744328,
"shape_derivative_sign_changes": 0.52755906,
"shape_early_energy_fraction": 0.33872822,
"shape_fit_penalty": 0.0,
"shape_late_energy_fraction": 0.11182152,
"shape_max_step_drop": 0.94082077,
"shape_max_step_rise": 0.94068383,
"shape_mid_trough_depth": 0.93907254,
"shape_peak_density": 0.2109375,
"shape_plateau_ratio": 0.3828125,
"shape_tail_slope": -0.03142463,
"trailing_idle_ratio": 0.052709,
"trimmed_duration_log_ratio": -0.05414895
}
},
{
"expected_score": 0.05607764,
"features": {
"duration_log_ratio": 0.0,
"energy_log_ratio": 0.0,
"false_end_energy_ratio": 0.0,
"flag_pressure": 0.0,
"has_trace": 1.0,
"label_margin_positive": 0.0,
"leading_idle_ratio": 0.0,
"local_noise_score": 0.142423,
"local_spike_rate": 0.0,
"local_spike_score": 0.0,
"low_power_gap_ratio": 0.0,
"max_gap_ratio": 0.00865005,
"peak_density_log": 1.65845988,
"peak_log_ratio": 0.0,
"profile_distance": 0.0,
"sample_density_log": 0.72835069,
"shape_active_cv": 0.08213481,
"shape_active_fraction": 0.078125,
"shape_autocorr_lag1": 0.93041708,
"shape_derivative_sign_changes": 0.46456693,
"shape_early_energy_fraction": 0.71529561,
"shape_fit_penalty": 0.0,
"shape_late_energy_fraction": 0.14814543,
"shape_max_step_drop": 0.77920376,
"shape_max_step_rise": 0.5558909,
"shape_mid_trough_depth": 0.99420388,
"shape_peak_density": 0.03125,
"shape_plateau_ratio": 0.0625,
"shape_tail_slope": -0.00093727,
"trailing_idle_ratio": 0.0,
"trimmed_duration_log_ratio": 0.0
}
},
{
"expected_score": 0.1196706,
"features": {
"duration_log_ratio": 0.0,
"energy_log_ratio": 0.0,
"false_end_energy_ratio": 1.508e-05,
"flag_pressure": 1.0,
"has_trace": 1.0,
"label_margin_positive": 0.0,
"leading_idle_ratio": 0.012663,
"local_noise_score": 0.055005,
"local_spike_rate": 0.0,
"local_spike_score": 0.0,
"low_power_gap_ratio": 0.01154096,
"max_gap_ratio": 0.01163994,
"peak_density_log": 2.17918982,
"peak_log_ratio": 0.0,
"profile_distance": 0.0,
"sample_density_log": 0.88104527,
"shape_active_cv": 0.01007372,
"shape_active_fraction": 0.375,
"shape_autocorr_lag1": 0.96196064,
"shape_derivative_sign_changes": 0.52755906,
"shape_early_energy_fraction": 0.45151166,
"shape_fit_penalty": 0.0,
"shape_late_energy_fraction": 0.01292759,
"shape_max_step_drop": 0.95083153,
"shape_max_step_rise": 0.98360456,
"shape_mid_trough_depth": 0.99823463,
"shape_peak_density": 0.1015625,
"shape_plateau_ratio": 0.375,
"shape_tail_slope": -0.00030196,
"trailing_idle_ratio": 0.00013,
"trimmed_duration_log_ratio": -0.01287655
}
},
{
"expected_score": 0.34367089,
"features": {
"duration_log_ratio": 0.0,
"energy_log_ratio": 0.0,
"false_end_energy_ratio": 0.0,
"flag_pressure": 2.0,
"has_trace": 1.0,
"label_margin_positive": 0.0,
"leading_idle_ratio": 0.0,
"local_noise_score": 0.914775,
"local_spike_rate": 0.005714,
"local_spike_score": 0.697412,
"low_power_gap_ratio": 0.14300259,
"max_gap_ratio": 0.08871048,
"peak_density_log": 2.33230715,
"peak_log_ratio": 0.0,
"profile_distance": 0.0,
"sample_density_log": 0.75631571,
"shape_active_cv": 0.12076531,
"shape_active_fraction": 0.453125,
"shape_autocorr_lag1": 0.37598497,
"shape_derivative_sign_changes": 0.48031496,
"shape_early_energy_fraction": 0.33717205,
"shape_fit_penalty": 0.0,
"shape_late_energy_fraction": 0.09040023,
"shape_max_step_drop": 0.93456211,
"shape_max_step_rise": 0.94494752,
"shape_mid_trough_depth": 0.9376202,
"shape_peak_density": 0.203125,
"shape_plateau_ratio": 0.3828125,
"shape_tail_slope": -0.00193552,
"trailing_idle_ratio": 0.14928,
"trimmed_duration_log_ratio": -0.16167223
}
},
{
"expected_score": 0.53702273,
"features": {
"duration_log_ratio": 0.0,
"energy_log_ratio": 0.0,
"false_end_energy_ratio": 0.0,
"flag_pressure": 2.0,
"has_trace": 1.0,
"label_margin_positive": 0.0,
"leading_idle_ratio": 0.0,
"local_noise_score": 0.757831,
"local_spike_rate": 0.241379,
"local_spike_score": 0.742169,
"low_power_gap_ratio": 0.0,
"max_gap_ratio": 0.04654033,
"peak_density_log": 3.01770482,
"peak_log_ratio": 0.0,
"profile_distance": 0.0,
"sample_density_log": 0.9424837,
"shape_active_cv": 0.26732401,
"shape_active_fraction": 0.4296875,
"shape_autocorr_lag1": 0.9008239,
"shape_derivative_sign_changes": 0.1496063,
"shape_early_energy_fraction": 0.17508041,
"shape_fit_penalty": 0.0,
"shape_late_energy_fraction": 0.23482531,
"shape_max_step_drop": 0.34637187,
"shape_max_step_rise": 0.19347146,
"shape_mid_trough_depth": 0.93833705,
"shape_peak_density": 0.0703125,
"shape_plateau_ratio": 0.15625,
"shape_tail_slope": -0.01567805,
"trailing_idle_ratio": 0.0,
"trimmed_duration_log_ratio": 0.0
}
},
{
"expected_score": 0.8497107,
"features": {
"duration_log_ratio": 0.0,
"energy_log_ratio": 0.0,
"false_end_energy_ratio": 7.272e-05,
"flag_pressure": 2.0,
"has_trace": 1.0,
"label_margin_positive": 0.0,
"leading_idle_ratio": 0.0,
"local_noise_score": 0.515134,
"local_spike_rate": 0.114894,
"local_spike_score": 0.905233,
"low_power_gap_ratio": 0.05700163,
"max_gap_ratio": 0.01734271,
"peak_density_log": 2.66830551,
"peak_log_ratio": 0.01278344,
"profile_distance": 0.0,
"sample_density_log": 1.10556404,
"shape_active_cv": 0.01663372,
"shape_active_fraction": 0.0546875,
"shape_autocorr_lag1": 0.858075,
"shape_derivative_sign_changes": 0.51181102,
"shape_early_energy_fraction": 0.57687186,
"shape_fit_penalty": 0.0,
"shape_late_energy_fraction": 0.18846404,
"shape_max_step_drop": 1.22193942,
"shape_max_step_rise": 1.21758141,
"shape_mid_trough_depth": 0.99936713,
"shape_peak_density": 0.03125,
"shape_plateau_ratio": 0.0546875,
"shape_tail_slope": -0.00140962,
"trailing_idle_ratio": 0.062792,
"trimmed_duration_log_ratio": -0.06485004
}
},
{
"expected_score": 0.93456044,
"features": {
"duration_log_ratio": 0.0,
"energy_log_ratio": 0.0,
"false_end_energy_ratio": 0.0,
"flag_pressure": 2.0,
"has_trace": 1.0,
"label_margin_positive": 0.0,
"leading_idle_ratio": 0.0,
"local_noise_score": 0.044825,
"local_spike_rate": 0.001105,
"local_spike_score": 0.38183,
"low_power_gap_ratio": 0.0,
"max_gap_ratio": 0.00883896,
"peak_density_log": 2.27572511,
"peak_log_ratio": 0.0,
"profile_distance": 0.0,
"sample_density_log": 2.34285295,
"shape_active_cv": 0.03491006,
"shape_active_fraction": 0.1796875,
"shape_autocorr_lag1": 0.83801221,
"shape_derivative_sign_changes": 0.5511811,
"shape_early_energy_fraction": 0.86309364,
"shape_fit_penalty": 0.0,
"shape_late_energy_fraction": 0.02448348,
"shape_max_step_drop": 0.95857301,
"shape_max_step_rise": 0.95800353,
"shape_mid_trough_depth": 0.99645606,
"shape_peak_density": 0.0625,
"shape_plateau_ratio": 0.171875,
"shape_tail_slope": -0.00415246,
"trailing_idle_ratio": 0.006029,
"trimmed_duration_log_ratio": -0.00604725
}
},
{
"expected_score": 0.9999956,
"features": {
"duration_log_ratio": 0.0,
"energy_log_ratio": 0.0,
"false_end_energy_ratio": 0.0,
"flag_pressure": 4.0,
"has_trace": 1.0,
"label_margin_positive": 0.0,
"leading_idle_ratio": 0.0,
"local_noise_score": 0.0,
"local_spike_rate": 0.0,
"local_spike_score": 0.0,
"low_power_gap_ratio": 0.0,
"max_gap_ratio": 0.99441282,
"peak_density_log": 0.0,
"peak_log_ratio": 0.0,
"profile_distance": 0.0,
"sample_density_log": 3.23289577,
"shape_active_cv": 0.0,
"shape_active_fraction": 1.0,
"shape_autocorr_lag1": 0.0,
"shape_derivative_sign_changes": 0.0,
"shape_early_energy_fraction": 0.25,
"shape_fit_penalty": 0.0,
"shape_late_energy_fraction": 0.25,
"shape_max_step_drop": 0.0,
"shape_max_step_rise": 0.0,
"shape_mid_trough_depth": 0.0,
"shape_peak_density": 0.0,
"shape_plateau_ratio": 1.0,
"shape_tail_slope": 0.0,
"trailing_idle_ratio": 0.0,
"trimmed_duration_log_ratio": 0.0
}
}
],
"kind": "standardized_logistic",
"model": "hybrid_curve_quality"
}
@@ -0,0 +1,42 @@
[
{
"feature": "match_progress_top1",
"group": "live_match",
"runtime_source": "elapsed_seconds / top-1 candidate expected duration"
},
{
"feature": "top1_distance",
"group": "live_match",
"runtime_source": "blended RMSE+DTW shape distance to the top-1 candidate prefix"
},
{
"feature": "margin",
"group": "live_match",
"runtime_source": "top-2 distance minus top-1 distance (decision confidence)"
},
{
"feature": "distance_ratio",
"group": "live_match",
"runtime_source": "top-1 distance / top-2 distance"
},
{
"feature": "candidate_count_log",
"group": "live_match",
"runtime_source": "log1p(number of candidate profiles on the device)"
},
{
"feature": "prefix_active_fraction",
"group": "live_match",
"runtime_source": "fraction of the observed prefix above the active threshold"
},
{
"feature": "duration_ratio_top1",
"group": "live_match",
"runtime_source": "elapsed / top-1 expected duration (clipped)"
},
{
"feature": "elapsed_log",
"group": "live_match",
"runtime_source": "log1p(elapsed seconds since cycle start)"
}
]
@@ -0,0 +1,127 @@
# 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/>.
"""Auto-generated by ml_washdata/wash_ml/promotion.py. Do not edit by hand.
Embedded WashData model: 'live_match_commit' (target: 'match_top1_correct').
Kind: standardized logistic regression. Runtime dependency: NumPy only.
Regenerate with ``./ml.sh experiment`` in the ml_washdata lab and copy the new
file. Determinism check at generation time: max_abs_score_diff=1.6467e-08
over 2392 rows.
Usage in the integration::
from .live_match_commit_model import score, predict, FEATURE_COLUMNS
features = build_runtime_features(...) # must populate FEATURE_COLUMNS
is_positive = predict(features)
"""
from __future__ import annotations
import base64
import gzip
import json
from typing import Mapping
import numpy as np
MODEL_NAME = 'live_match_commit'
MODEL_TARGET = 'match_top1_correct'
MODEL_KIND = 'standardized_logistic'
TARGET_UNITS = ''
THRESHOLD = 0.371786
FEATURE_COLUMNS = [
'match_progress_top1',
'top1_distance',
'margin',
'distance_ratio',
'candidate_count_log',
'prefix_active_fraction',
'duration_ratio_top1',
'elapsed_log',
]
# Provenance (metrics at training time):
MODEL_METRICS = json.loads("""{
"owner_holdout": {
"accuracy": 0.771789,
"balanced_accuracy": 0.741539,
"f1": 0.836751,
"fn": 120,
"fp": 79,
"positive_rate": 0.675459,
"precision": 0.865874,
"problem_recall": 0.809524,
"rows": 872,
"specificity": 0.673554,
"tn": 163,
"tp": 510
}
}""")
_MODEL_BLOB = (
'H4sIAAAAAAACA1VU246kNhD9FYunRKGJDb7BKpGi0UaKtJONktmn1Qq5wXRbAxgZdzqb1fx7jmG6NXmiqMupOnXxt+zozJo1tNCM'
'aVELnWednaMNWfOZFoJyJcqS57Rglaoha4gVo5UQsoQoGOe6ZiJnRc2kkoolX64oE7pi+RsEVWheUa7FF2TwdtjwS0UlFzo5Ms2o'
'oila8lJQIWV+YEXJeF1zKfIDUJlAjUwmWai6qsSW7I6R1JwBRaqUI1gTbd+amDVZSUt5oPJQ1k8lbXjdUPEDpQ2lWZ4N8LsE23Z+'
'vEwzevE5m0zszu0S/CnYdW2jXxgc06ft3RrN3Fn8Tyac3AzhpmuDic5D0Zm5dz3SA/Qyx3b0J2iXYAf3T2u66P627RCS4Lf4yxY4'
'7/G3dHY0ywoCKRh8Ti4CbZpc4iOpMFqW8Hp2cw9FKqA3oXf/7hGoyHWpRhuD60DqW+avsw3t2Y+9v8SkMF2HxN3XNH2lmNJ1nh3N'
'mJigbW+N6HwF48C2RamkEgx/c9awkkJYskbBvPjVbdRAwyZP+HFRb8Q7tyauKVwKrXhS+uNopxY2M46bhdYCi5IFf0XBWpV5ti6I'
'HFzn4tcdEHsHj5hSywoCUgtGX17ybDYTkmZjKmCf32u3YPLRbnP9AONhM5LdeOj8PLjegnJD/vgOnT8wcp8ecSuJZwvfgCoj2RbC'
'TN8XwHzY4kn0xJCU82YkO/z1bGeyItCSn38CCPYodf4d8QAMV7da8mztsnu7+USQk6xXd481pHfDYANu8U1BR4u67UpuZcdUylMw'
'brY98TO5WvNMzCX6w2iOdlzfkYD9c5Ml02WNifRyAczDx8fH357aX9//8vTpz/ftw8cPnx5//4sMwU8b4Y3QTiSYGSt2KtIKYm8Q'
'3t7eB1rQu27FENF+llSvcjpvXWnNucCVUq1Lzfj2JJSqqvFspJeE0ppj99KbwSvFZbVdNB4UhcD8fwh4B7A5FfuSMpztZDDtq1nP'
'aIwp0P3Jp3OffG/HH7dzxXnadCz7NmzX+zrJu7W9zC5iNbKkuQ0pMavSPciX/wDk5KXIHwUAAA=='
)
_MODEL_CACHE: dict | None = None
def _load() -> dict:
global _MODEL_CACHE
if _MODEL_CACHE is None:
payload = gzip.decompress(base64.b64decode(_MODEL_BLOB.encode("ascii")))
spec = json.loads(payload.decode("utf-8"))
_MODEL_CACHE = {
"center": np.asarray(spec["center"], dtype=float),
"scale": np.asarray(spec["scale"], dtype=float),
"coef": np.asarray(spec["coef"], dtype=float),
"bias": float(spec["bias"]),
"threshold": float(spec["threshold"]),
"output_center": float(spec.get("output_center") or 0.0),
"output_scale": float(spec.get("output_scale") if spec.get("output_scale") is not None else 1.0),
"feature_columns": list(spec["feature_columns"]),
}
return _MODEL_CACHE
def score(features: Mapping[str, float]) -> float:
"""Return the model probability in [0, 1] for one feature mapping."""
model = _load()
vector = np.array(
[float(features.get(column) or 0.0) for column in model["feature_columns"]],
dtype=float,
)
scaled = (vector - model["center"]) / model["scale"]
logit = float(scaled @ model["coef"] + model["bias"])
logit = max(-60.0, min(60.0, logit))
return 1.0 / (1.0 + np.exp(-logit))
def predict(features: Mapping[str, float]) -> bool:
"""True when the example crosses the embedded decision threshold."""
return score(features) >= _load()["threshold"]
@@ -0,0 +1,110 @@
{
"cases": [
{
"expected_score": 0.0409139,
"features": {
"candidate_count_log": 2.30258509,
"distance_ratio": 0.99324714,
"duration_ratio_top1": 0.0713484,
"elapsed_log": 6.43615037,
"margin": 0.0003374,
"match_progress_top1": 0.0713484,
"prefix_active_fraction": 1.0,
"top1_distance": 0.04962666
}
},
{
"expected_score": 0.23638102,
"features": {
"candidate_count_log": 2.30258509,
"distance_ratio": 0.9767654,
"duration_ratio_top1": 0.77657169,
"elapsed_log": 9.10509096,
"margin": 0.00796904,
"match_progress_top1": 0.77657169,
"prefix_active_fraction": 0.28125,
"top1_distance": 0.33501266
}
},
{
"expected_score": 0.36610898,
"features": {
"candidate_count_log": 1.79175947,
"distance_ratio": 0.98383705,
"duration_ratio_top1": 0.67719326,
"elapsed_log": 8.00670085,
"margin": 0.00441228,
"match_progress_top1": 0.67719326,
"prefix_active_fraction": 0.328125,
"top1_distance": 0.26857515
}
},
{
"expected_score": 0.50799834,
"features": {
"candidate_count_log": 2.30258509,
"distance_ratio": 0.56963091,
"duration_ratio_top1": 0.56899238,
"elapsed_log": 8.35501068,
"margin": 0.12556275,
"match_progress_top1": 0.56899238,
"prefix_active_fraction": 0.40625,
"top1_distance": 0.16619322
}
},
{
"expected_score": 0.67345544,
"features": {
"candidate_count_log": 2.30258509,
"distance_ratio": 0.61349302,
"duration_ratio_top1": 0.99052133,
"elapsed_log": 8.74369111,
"margin": 0.12364826,
"match_progress_top1": 0.99052133,
"prefix_active_fraction": 0.296875,
"top1_distance": 0.19626385
}
},
{
"expected_score": 0.88134857,
"features": {
"candidate_count_log": 1.94591015,
"distance_ratio": 0.37757139,
"duration_ratio_top1": 1.01608929,
"elapsed_log": 6.5539334,
"margin": 0.1729941,
"match_progress_top1": 1.01608929,
"prefix_active_fraction": 0.859375,
"top1_distance": 0.10493994
}
},
{
"expected_score": 0.95653616,
"features": {
"candidate_count_log": 1.09861229,
"distance_ratio": 0.21720322,
"duration_ratio_top1": 0.30021136,
"elapsed_log": 7.89561849,
"margin": 0.51761286,
"match_progress_top1": 0.30021136,
"prefix_active_fraction": 0.234375,
"top1_distance": 0.14362244
}
},
{
"expected_score": 0.99762013,
"features": {
"candidate_count_log": 1.09861229,
"distance_ratio": 0.02465625,
"duration_ratio_top1": 1.00014576,
"elapsed_log": 9.5269014,
"margin": 1.86460354,
"match_progress_top1": 1.00014576,
"prefix_active_fraction": 0.09375,
"top1_distance": 0.04713633
}
}
],
"kind": "standardized_logistic",
"model": "live_match_commit"
}
@@ -0,0 +1,261 @@
# 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/>.
"""On-device tuning of the matcher's scoring weights (Stage 4/5, opt-in).
Mirrors the offline ``devtools/dtw_ab_eval.py`` methodology but as a shippable,
NumPy-only, executor-safe pure function: it does leave-one-out matching over the
device's own labelled cycles, sweeps a small grid of the highest-impact scoring
weights (corr/MAE split, duration agreement weight, energy agreement weight, and
DTW ensemble weight independently), and - only if a candidate beats the shipped
defaults on a HELD-OUT split by a margin - returns a per-device config override. The caller persists it; the matcher reads it live
and falls back to the const defaults otherwise.
Discipline (same as model promotion): tune on a train split, gate on a held-out
split, require a margin, cap the grid to bounded scoring weights (never
structural behaviour). This guards against over-fitting the small, partly
manually-labelled per-user cycle set.
"""
from __future__ import annotations
from typing import Any
import numpy as np
from .. import analysis
_RESAMPLE_L = 150
def _powers(cycle: dict[str, Any]) -> list[float]:
pd = cycle.get("power_data") or []
out: list[float] = []
for p in pd:
try:
out.append(float(p[1]))
except (TypeError, ValueError, IndexError):
pass
return out
def _resample(vals: list[float], n: int) -> np.ndarray:
a = np.asarray(vals, dtype=float)
if a.size == 0:
return np.zeros(n)
if a.size == n:
return a
return np.interp(np.linspace(0, 1, n), np.linspace(0, 1, a.size), a)
def _prep(cycles: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
"""Group labelled cycles by profile, caching powers/duration/resampled curve."""
by_profile: dict[str, list[dict[str, Any]]] = {}
for c in cycles:
name = c.get("profile_name")
pw = _powers(c)
if not name or len(pw) < 4:
continue
try:
dur = float(c.get("duration"))
except (TypeError, ValueError):
dur = 0.0
if dur <= 0:
# No reliable wall-clock duration: skip rather than fabricate one from the
# sample count (len(pw)), which distorts duration scoring on devices that
# sample every 30-60 s. Real cycles always carry a 'duration', so this only
# drops degenerate entries.
continue
by_profile.setdefault(name, []).append(
{"pw": pw, "dur": dur, "rs": _resample(pw, _RESAMPLE_L)}
)
return by_profile
def _snaps(by_profile: dict[str, list[dict]], exclude: tuple[str, int] | None) -> list[dict[str, Any]]:
snaps = []
for name, items in by_profile.items():
curves, durs = [], []
for idx, it in enumerate(items):
if exclude is not None and (name, idx) == exclude:
continue
curves.append(it["rs"])
durs.append(it["dur"])
if curves:
snaps.append({
"name": name,
"avg_duration": float(np.mean(durs)),
"sample_power": np.mean(np.array(curves), axis=0).tolist(),
})
return snaps
def _top1(by_profile: dict[str, list[dict]], targets: list[tuple[str, int]], cfg: dict[str, Any]) -> float:
"""Fraction of the given (profile, idx) targets whose true profile ranks #1
under leave-one-out matching with the given config."""
if not targets:
return 0.0
correct = 0
total = 0
for name, idx in targets:
it = by_profile[name][idx]
snaps = _snaps(by_profile, exclude=(name, idx))
if len(snaps) < 2:
continue
cands = analysis.compute_matches_worker(it["pw"], it["dur"], snaps, cfg)
total += 1
if cands and cands[0]["name"] == name:
correct += 1
return correct / total if total else 0.0
_BASE_CFG = {"min_duration_ratio": 0.10, "max_duration_ratio": 1.5}
#: Bounded scoring weights the tuner may promote. All live in [0, 1], so a tuned
#: config can only shift emphasis (shape vs level vs energy, and how much the DTW
#: ensemble leans on the derivative/DDTW component) - never structural behaviour.
OVERRIDE_KEYS = ("corr_weight", "duration_weight", "energy_weight", "dtw_ensemble_w")
def _grid() -> list[dict[str, Any]]:
"""Small, high-impact grid over four bounded scoring weights.
Axes: corr/MAE split × duration agreement weight × energy agreement weight
× DTW ensemble weight. The duration and energy axes are now independent so
the tuner can find asymmetric configurations (e.g. a device with highly
variable energy but stable duration benefits from a low energy_weight and a
high duration_weight). All values are bounded scoring weights (see
OVERRIDE_KEYS) so a promoted config can never change structural behaviour.
Grid size: 4 × 2 × 2 × 3 = 48 configurations (was 4 × 2 × 3 = 24).
"""
out = []
for cw in (0.40, 0.45, 0.50, 0.60):
for dur_w in (0.15, 0.22):
for en_w in (0.15, 0.22):
for ew in (0.55, 0.70, 0.85):
out.append({
"corr_weight": cw,
"duration_weight": dur_w,
"energy_weight": en_w,
"dtw_ensemble_w": ew,
})
return out
def tune_matching_config(
cycles: list[dict[str, Any]],
*,
min_cycles: int = 25,
# Kept intentionally low so per-device tuning becomes useful early; the noise
# a small sample would introduce is controlled by the multi-split majority gate
# below (a lucky single split can't promote), not by a large ``min_targets``.
min_targets: int = 12,
margin: float = 0.03,
seed: int = 0,
) -> dict[str, Any]:
"""Leave-one-out per-device tuning of matcher scoring weights.
Methodology (no target leakage between selection and gating):
1. Partition the device's labelled cycles ONCE into a *search* pool and an
untouched *holdout* pool; no target is ever used for both.
2. **Select** the candidate config as the grid entry with the best
leave-one-out top-1 on the SEARCH pool only. (Reference snapshots are
built from all cycles as in production, where a query is matched
against aggregates of the full profile library; only the *query* targets
are partitioned.)
3. **Gate** the fixed candidate on the HOLDOUT pool: it must beat the
shipped defaults by at least ``margin`` on a MAJORITY of reshuffled
holdout subsamples (a variance check that rejects a lucky single split)
AND on the holdout mean. ``min_targets`` is kept intentionally low so
per-device tuning becomes useful early; the majority gate not a large
sample controls the noise.
Returns a status dict; ``promoted`` is True only when both holdout gates pass.
When promoted, ``config`` holds the override to persist (bounded scoring
weights only never structural matching behaviour). Never raises for data
reasons; returns {"promoted": False, "reason": ...}.
"""
by_profile = _prep(cycles)
multi = {n: items for n, items in by_profile.items() if len(items) >= 2}
n_cycles = sum(len(v) for v in by_profile.values())
if len(multi) < 2 or n_cycles < min_cycles:
return {"promoted": False, "reason": "insufficient data", "n_cycles": n_cycles, "n_profiles": len(by_profile)}
# Partition targets ONCE, up front, into a search pool (used to pick the
# candidate config) and an untouched holdout pool (used only to gate it). No
# target is ever used for both selection and gating -> no target leakage.
rng = np.random.default_rng(seed)
targets = [(n, i) for n, items in multi.items() for i in range(len(items))]
rng.shuffle(targets)
if len(targets) < min_targets:
return {"promoted": False, "reason": "too few targets", "n_targets": len(targets)}
cut = max(1, len(targets) // 2)
search_pool, holdout_pool = targets[:cut], targets[cut:]
if not holdout_pool:
return {"promoted": False, "reason": "too few targets", "n_targets": len(targets)}
base = {**_BASE_CFG}
# Candidate: the grid config with the best top-1 on the SEARCH pool only.
best_search = _top1(by_profile, search_pool, base)
best_cfg = base
for extra in _grid():
acc = _top1(by_profile, search_pool, {**base, **extra})
if acc > best_search:
best_search, best_cfg = acc, {**base, **extra}
override = {k: best_cfg[k] for k in OVERRIDE_KEYS if k in best_cfg}
# Gate the FIXED candidate on the held-out pool: require it to beat the defaults
# by ``margin`` on a MAJORITY of reshuffled subsamples of the holdout (variance
# check), rejecting a lucky single split while keeping min_targets low.
n_splits, min_wins = 5, 4
base_tests: list[float] = []
tuned_tests: list[float] = []
wins = 0
for k in range(n_splits):
r = np.random.default_rng(seed + 1 + k)
pool = list(holdout_pool)
r.shuffle(pool)
held = pool[: max(1, len(pool) // 2)]
bt = _top1(by_profile, held, base)
tt = _top1(by_profile, held, best_cfg)
base_tests.append(bt)
tuned_tests.append(tt)
if tt - bt >= margin:
wins += 1
mean_base = float(np.mean(base_tests)) if base_tests else 0.0
mean_tuned = float(np.mean(tuned_tests)) if tuned_tests else 0.0
has_override = bool(override)
enough_wins = wins >= min_wins
enough_margin = (mean_tuned - mean_base) >= margin
promoted = has_override and enough_wins and enough_margin
if promoted:
reason = f"beat baseline on {wins}/{n_splits} held-out subsamples"
elif not has_override:
reason = "defaults already optimal (no override)"
elif not enough_wins:
reason = f"only {wins}/{n_splits} held-out subsamples beat baseline by margin"
else:
reason = f"mean held-out gain {mean_tuned - mean_base:+.3f} below margin {margin}"
return {
"promoted": promoted,
"config": override if promoted else None,
"baseline_test_top1": round(mean_base, 3),
"tuned_test_top1": round(mean_tuned, 3),
"train_top1": round(best_search, 3),
"holdout_wins": wins,
"holdout_splits": n_splits,
"n_targets": len(targets),
"reason": reason,
}
@@ -0,0 +1,96 @@
{
"generated_at": "2026-07-01T06:27:13+00:00",
"models": [
{
"created_at": "2026-06-29T20:49:06+00:00",
"git_commit": "605a862",
"kind": "standardized_logistic",
"metrics": {
"owner_holdout": {
"accuracy": 0.865889,
"balanced_accuracy": 0.85704,
"f1": 0.760417,
"fn": 14,
"fp": 32,
"positive_rate": 0.306122,
"precision": 0.695238,
"problem_recall": 0.83908,
"rows": 343,
"specificity": 0.875,
"tn": 224,
"tp": 73
},
"premature_stop_rate": 0.125
},
"module": "cycle_end_detector_model.py",
"name": "cycle_end_detector",
"target": "cycle_truly_ended",
"target_units": ""
},
{
"created_at": "2026-06-29T20:48:41+00:00",
"git_commit": "605a862",
"kind": "standardized_logistic",
"metrics": {
"owner_holdout": {
"accuracy": 0.872,
"balanced_accuracy": 0.89997,
"f1": 0.898734,
"fn": 15,
"fp": 1,
"positive_rate": 0.576,
"precision": 0.986111,
"problem_recall": 0.825581,
"rows": 125,
"specificity": 0.974359,
"tn": 38,
"tp": 71
},
"synthetic_all": {
"accuracy": 1.0,
"balanced_accuracy": 0.5,
"f1": 1.0,
"fn": 0,
"fp": 0,
"positive_rate": 1.0,
"precision": 1.0,
"problem_recall": 1.0,
"rows": 1255,
"specificity": 0.0,
"tn": 0,
"tp": 1255
}
},
"module": "hybrid_curve_quality_model.py",
"name": "hybrid_curve_quality",
"target": "problem_cycle",
"target_units": ""
},
{
"created_at": "2026-06-29T20:49:05+00:00",
"git_commit": "605a862",
"kind": "standardized_logistic",
"metrics": {
"owner_holdout": {
"accuracy": 0.771789,
"balanced_accuracy": 0.741539,
"f1": 0.836751,
"fn": 120,
"fp": 79,
"positive_rate": 0.675459,
"precision": 0.865874,
"problem_recall": 0.809524,
"rows": 872,
"specificity": 0.673554,
"tn": 163,
"tp": 510
}
},
"module": "live_match_commit_model.py",
"name": "live_match_commit",
"target": "match_top1_correct",
"target_units": ""
}
],
"source": "ml_washdata/output/promoted"
}
+440
View File
@@ -0,0 +1,440 @@
# 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/>.
"""On-device, NumPy-only model training for WashData (Stage 4).
This is the runtime counterpart of the offline lab's promotion pipeline. All
three embedded models are ``standardized_logistic`` heads - a mean/std scaler
plus a weight vector, bias, and decision threshold - which the lab fits with a
short pure-NumPy gradient descent (``wash_ml/end_detection.py::_fit_logistic``).
This module reproduces that fit and the exact scoring math the embedded
``*_model.py`` modules use, so a model trained here on the user's own cycles is
byte-compatible with the shipped baseline and can be scored identically.
No new dependencies: NumPy only. Nothing here runs unless the caller (behind the
``ENABLE_ML_TRAINING`` flag) invokes it.
"""
from __future__ import annotations
from typing import Any, Mapping, Sequence
import numpy as np
# Matches wash_ml/promotion.py so a trained spec is interchangeable with the
# shipped bundles and could be rendered into a *_model.py if ever needed.
PROMOTION_SCHEMA = "washdata.promoted_model/1"
def _sigmoid(values: np.ndarray) -> np.ndarray:
clipped = np.clip(values, -60.0, 60.0)
return 1.0 / (1.0 + np.exp(-clipped))
def fit_logistic(
matrix: np.ndarray,
labels: np.ndarray,
*,
l2: float = 0.01,
learning_rate: float = 0.2,
iterations: int = 4000,
) -> dict[str, np.ndarray | float]:
"""Fit a class-balanced, L2-regularised logistic head with NumPy GD.
Identical in shape to the lab's ``_fit_logistic``: mean/std standardisation,
inverse-frequency class weights, and a fixed-step gradient descent. Returns
``{center, scale, coef, bias}``.
"""
matrix = np.asarray(matrix, dtype=float)
labels = np.asarray(labels, dtype=float)
if matrix.ndim != 2 or matrix.shape[0] == 0:
raise ValueError("matrix must be a non-empty 2D array")
if labels.shape[0] != matrix.shape[0]:
raise ValueError("labels/matrix row mismatch")
if len(np.unique(labels)) < 2:
raise ValueError(
f"fit_logistic requires both positive and negative examples; "
f"got labels: {np.unique(labels)}"
)
center = np.mean(matrix, axis=0)
scale = np.std(matrix, axis=0)
scale = np.where(scale <= 1e-8, 1.0, scale)
scaled = (matrix - center) / scale
weight = np.ones(labels.size, dtype=float)
for label_value in (0.0, 1.0):
mask = labels == label_value
count = float(np.sum(mask))
if count > 0:
weight[mask] = labels.size / (2.0 * count)
normalized = weight / (float(np.sum(weight)) or 1.0)
coef = np.zeros(matrix.shape[1], dtype=float)
bias = 0.0
for _ in range(iterations):
predictions = _sigmoid(scaled @ coef + bias)
residual = (predictions - labels) * normalized
coef -= learning_rate * (scaled.T @ residual + l2 * coef)
bias -= learning_rate * float(np.sum(residual))
return {"center": center, "scale": scale, "coef": coef, "bias": float(bias)}
def _safe_ratio(numerator: float, denominator: float) -> float:
return float(numerator) / float(denominator) if denominator else 0.0
def binary_metrics(labels: np.ndarray, scores: np.ndarray, threshold: float) -> dict[str, Any]:
"""Confusion-matrix metrics at a threshold (pure NumPy)."""
labels = np.asarray(labels, dtype=float)
scores = np.asarray(scores, dtype=float)
if labels.size == 0:
return {}
predictions = (scores >= threshold).astype(int)
tp = int(np.sum((labels == 1) & (predictions == 1)))
fp = int(np.sum((labels == 0) & (predictions == 1)))
tn = int(np.sum((labels == 0) & (predictions == 0)))
fn = int(np.sum((labels == 1) & (predictions == 0)))
precision = _safe_ratio(tp, tp + fp)
recall = _safe_ratio(tp, tp + fn)
specificity = _safe_ratio(tn, tn + fp)
f1 = _safe_ratio(2.0 * precision * recall, precision + recall)
accuracy = _safe_ratio(tp + tn, labels.size)
positive_rate = _safe_ratio(int(np.sum(labels == 1)), labels.size)
# Key names mirror the shipped MODEL_METRICS schema (see *_model.py):
# ``problem_recall`` (recall of the positive/"problem" class) and
# ``positive_rate`` (base rate of positives), so on-device-trained metrics
# are schema-identical to the embedded baselines they are compared against.
return {
"rows": int(labels.size),
"tp": tp, "fp": fp, "tn": tn, "fn": fn,
"precision": round(precision, 6),
"problem_recall": round(recall, 6),
"positive_rate": round(positive_rate, 6),
"specificity": round(specificity, 6),
"balanced_accuracy": round((recall + specificity) / 2.0, 6),
"f1": round(f1, 6),
"accuracy": round(accuracy, 6),
}
def auc(labels: np.ndarray, scores: np.ndarray) -> float:
"""Rank-based ROC AUC (Mann-Whitney U). 0.5 when one class is absent."""
labels = np.asarray(labels, dtype=float)
scores = np.asarray(scores, dtype=float)
finite_mask = np.isfinite(scores)
scores = scores[finite_mask]
labels = labels[finite_mask]
if len(scores) == 0:
return 0.5
pos = scores[labels == 1]
neg = scores[labels == 0]
if pos.size == 0 or neg.size == 0:
return 0.5
order = np.argsort(scores, kind="mergesort")
ranks = np.empty(scores.size, dtype=float)
ranks[order] = np.arange(1, scores.size + 1, dtype=float)
# Average ranks over ties so AUC is exact for discrete scores.
_assign_tie_ranks(scores, ranks, order)
rank_sum_pos = float(np.sum(ranks[labels == 1]))
n_pos = float(pos.size)
n_neg = float(neg.size)
u = rank_sum_pos - n_pos * (n_pos + 1.0) / 2.0
return float(u / (n_pos * n_neg))
def _assign_tie_ranks(scores: np.ndarray, ranks: np.ndarray, order: np.ndarray) -> None:
sorted_scores = scores[order]
i = 0
n = scores.size
while i < n:
j = i
while j + 1 < n and sorted_scores[j + 1] == sorted_scores[i]:
j += 1
if j > i:
avg = (ranks[order[i]] + ranks[order[j]]) / 2.0
for k in range(i, j + 1):
ranks[order[k]] = avg
i = j + 1
def select_threshold(
labels: np.ndarray,
scores: np.ndarray,
*,
default: float = 0.5,
) -> float:
"""Pick the threshold maximising balanced accuracy (model-agnostic).
Ties break toward the ``default`` so the operating point stays stable when
the data does not clearly prefer one cut.
"""
labels = np.asarray(labels, dtype=float)
scores = np.asarray(scores, dtype=float)
if scores.size == 0:
return default
candidates = np.unique(
np.concatenate([
np.quantile(scores, np.linspace(0.05, 0.95, 37)),
np.linspace(0.1, 0.95, 86),
])
)
candidates = candidates[(candidates >= 0.05) & (candidates <= 0.999)]
if candidates.size == 0:
pos_scores = scores[labels == 1]
return float(np.min(pos_scores)) if len(pos_scores) > 0 else default
best_key: tuple[float, float] | None = None
best_threshold = default
best_ba = 0.5
for threshold in candidates:
m = binary_metrics(labels, scores, float(threshold))
bal = float(m.get("balanced_accuracy") or 0.0)
key = (bal, -abs(float(threshold) - default))
if best_key is None or key > best_key:
best_key = key
best_threshold = float(threshold)
best_ba = bal
if best_ba == 0.5:
pos_scores = scores[labels == 1]
return float(np.min(pos_scores)) if len(pos_scores) > 0 else default
return round(best_threshold, 6)
def build_spec(
*,
name: str,
target: str,
feature_columns: Sequence[str],
fit: Mapping[str, Any],
threshold: float,
metrics: Mapping[str, Any] | None = None,
trained_at: str = "",
cycle_count: int = 0,
) -> dict[str, Any]:
"""Assemble a runtime model spec (same schema as the shipped bundles).
The returned dict is JSON-serialisable and can be scored by
:func:`score_spec` with math identical to the embedded ``*_model.py``.
"""
return {
"schema": PROMOTION_SCHEMA,
"name": name,
"kind": "standardized_logistic",
"target": target,
"target_units": "",
"feature_columns": list(feature_columns),
"center": [round(float(v), 8) for v in np.asarray(fit["center"], dtype=float)],
"scale": [round(float(v), 8) for v in np.asarray(fit["scale"], dtype=float)],
"coef": [round(float(v), 8) for v in np.asarray(fit["coef"], dtype=float)],
"bias": round(float(fit["bias"]), 8),
"threshold": round(float(threshold), 8),
"output_center": 0.0,
"output_scale": 1.0,
"metrics": dict(metrics or {}),
"notes": ["Trained on-device from the user's own labelled cycles."],
"created_at": trained_at,
"cycle_count": int(cycle_count),
"source": "on_device",
}
def score_matrix_spec(spec: Mapping[str, Any], matrix: np.ndarray) -> np.ndarray:
"""Pure-NumPy probabilities for a (rows, features) matrix from a spec."""
matrix = np.asarray(matrix, dtype=float)
if matrix.size == 0:
return np.empty(0, dtype=float)
center = np.asarray(spec["center"], dtype=float)
scale = np.asarray(spec["scale"], dtype=float)
coef = np.asarray(spec["coef"], dtype=float)
raw = ((matrix - center) / scale) @ coef + float(spec["bias"])
return _sigmoid(raw)
def score_spec(spec: Mapping[str, Any], features: Mapping[str, float]) -> float:
"""Pure-NumPy probability for one feature mapping.
Byte-identical to the embedded ``score()`` in ``*_model.py`` for *complete*
feature mappings (the normal case: the extractors in ``feature_extraction``
always populate every ``FEATURE_COLUMNS`` key). The two intentionally differ
only on the defensive missing-key fallback: this fills a missing feature with
the training center (standardises to 0.0 = neutral, avoiding 8+ SD corruption
of inference), whereas the embedded ``score()`` fills raw 0.0. That path is
not exercised by the parity fixtures and is not reachable in practice.
"""
columns = spec["feature_columns"]
center = np.asarray(spec["center"], dtype=float)
row = []
for i, col in enumerate(columns):
val = features.get(col)
row.append(float(center[i]) if val is None else float(val))
vector = np.array(row, dtype=float)
return float(score_matrix_spec(spec, vector.reshape(1, -1))[0])
# ---------------------------------------------------------------------------
# Regression head (standardized_linear) - remaining-time / progress regressor.
#
# The three classifier heads above are logistic. The remaining-time model is a
# ridge-regularised linear regressor over standardised features with a
# standardised target; prediction un-standardises back to target units using the
# spec's ``output_center``/``output_scale``. Same NumPy-only, JSON-serialisable
# spec schema as :func:`build_spec` so it is stored/loaded identically, but it is
# scored with :func:`predict_matrix_spec` (no sigmoid) rather than ``score_spec``.
# ---------------------------------------------------------------------------
def fit_ridge(
matrix: np.ndarray,
labels: np.ndarray,
*,
alpha: float = 1.0,
) -> dict[str, np.ndarray | float]:
"""Fit a standardised ridge-regression head via NumPy normal equations.
Standardises features (mean/std) and the target, solves
``(ZᵀZ + αI) w = Zᵀ y_std`` in closed form, and returns
``{center, scale, coef, bias, y_center, y_scale}``. Because both the
standardised features and the centred target are zero-mean, the intercept in
standardised space is 0. Prediction is
``((x - center)/scale) @ coef * y_scale + y_center``.
"""
matrix = np.asarray(matrix, dtype=float)
labels = np.asarray(labels, dtype=float)
if matrix.ndim != 2 or matrix.shape[0] == 0:
raise ValueError("matrix must be a non-empty 2D array")
if labels.shape[0] != matrix.shape[0]:
raise ValueError("labels/matrix row mismatch")
if np.std(labels) < 1e-8:
raise ValueError(
f"fit_ridge requires non-constant targets; "
f"all labels are approximately {labels[0]:.4f}"
)
center = np.mean(matrix, axis=0)
scale = np.std(matrix, axis=0)
scale = np.where(scale <= 1e-8, 1.0, scale)
scaled = (matrix - center) / scale
y_center = float(np.mean(labels))
y_scale = float(np.std(labels))
if y_scale <= 1e-9:
y_scale = 1.0
y_std = (labels - y_center) / y_scale
n_features = scaled.shape[1]
gram = scaled.T @ scaled + float(alpha) * np.eye(n_features)
rhs = scaled.T @ y_std
try:
coef = np.linalg.solve(gram, rhs)
except np.linalg.LinAlgError:
coef = np.linalg.lstsq(gram, rhs, rcond=None)[0]
return {
"center": center,
"scale": scale,
"coef": coef,
"bias": 0.0,
"y_center": y_center,
"y_scale": y_scale,
}
def regression_metrics(labels: np.ndarray, predictions: np.ndarray) -> dict[str, Any]:
"""MAE / RMSE / R² for a regression fit (pure NumPy)."""
labels = np.asarray(labels, dtype=float)
predictions = np.asarray(predictions, dtype=float)
if labels.size == 0:
return {}
err = predictions - labels
mae = float(np.mean(np.abs(err)))
rmse = float(np.sqrt(np.mean(err ** 2)))
ss_res = float(np.sum(err ** 2))
ss_tot = float(np.sum((labels - float(np.mean(labels))) ** 2))
r2 = float(1.0 - ss_res / ss_tot) if ss_tot > 1e-12 else 0.0
return {
"rows": int(labels.size),
"mae": round(mae, 6),
"rmse": round(rmse, 6),
"r2": round(r2, 6),
}
def predict_matrix_spec(spec: Mapping[str, Any], matrix: np.ndarray) -> np.ndarray:
"""Regression predictions (target units) for a (rows, features) matrix."""
matrix = np.asarray(matrix, dtype=float)
if matrix.size == 0:
return np.empty(0, dtype=float)
center = np.asarray(spec["center"], dtype=float)
scale = np.asarray(spec["scale"], dtype=float)
coef = np.asarray(spec["coef"], dtype=float)
y_std = ((matrix - center) / scale) @ coef + float(spec.get("bias", 0.0))
y_center = float(spec.get("output_center", 0.0))
y_scale = float(spec.get("output_scale", 1.0))
return y_std * y_scale + y_center
def predict_value_spec(spec: Mapping[str, Any], features: Mapping[str, float]) -> float:
"""Un-standardised regression output for one feature mapping.
Missing feature keys are filled with the training center (which standardises
to 0.0 = neutral), not raw 0.0, to avoid 8+ SD corruption of inference.
"""
columns = spec["feature_columns"]
center = np.asarray(spec["center"], dtype=float)
row = []
for i, col in enumerate(columns):
val = features.get(col)
row.append(float(center[i]) if val is None else float(val))
vector = np.array(row, dtype=float)
return float(predict_matrix_spec(spec, vector.reshape(1, -1))[0])
def build_regression_spec(
*,
name: str,
target: str,
feature_columns: Sequence[str],
fit: Mapping[str, Any],
target_units: str = "",
metrics: Mapping[str, Any] | None = None,
trained_at: str = "",
cycle_count: int = 0,
) -> dict[str, Any]:
"""Assemble a ``standardized_linear`` regression spec (JSON-serialisable).
Scored by :func:`predict_matrix_spec` / :func:`predict_value_spec` with math
that mirrors :func:`fit_ridge`. ``threshold`` is retained (0.0) only so the
spec shape stays uniform with the classifier bundles.
"""
return {
"schema": PROMOTION_SCHEMA,
"name": name,
"kind": "standardized_linear",
"target": target,
"target_units": target_units,
"feature_columns": list(feature_columns),
"center": [round(float(v), 8) for v in np.asarray(fit["center"], dtype=float)],
"scale": [round(float(v), 8) for v in np.asarray(fit["scale"], dtype=float)],
"coef": [round(float(v), 8) for v in np.asarray(fit["coef"], dtype=float)],
"bias": round(float(fit.get("bias", 0.0)), 8),
"threshold": 0.0,
"output_center": round(float(fit["y_center"]), 8),
"output_scale": round(float(fit["y_scale"]), 8),
"metrics": dict(metrics or {}),
"notes": ["Trained on-device from the user's own labelled cycles."],
"created_at": trained_at,
"cycle_count": int(cycle_count),
"source": "on_device",
}
@@ -0,0 +1,814 @@
# 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/>.
"""On-device training orchestration (Stage 4, gated by ENABLE_ML_TRAINING).
Gathers the user's own labelled cycles, derives training labels from data the
integration already has, fits NumPy-only logistic heads with :mod:`.trainer`,
and promotes a retrained model over the shipped baseline only when it is at
least as good on a held-out split. Nothing here runs unless the training loop
(behind the feature flag + per-device opt-in) invokes it.
Label sources (no manual labelling required to start):
* end detector - from trace geometry: a completed cycle's final low-power
event is a true end (positive); earlier pauses that resumed are non-ends.
* quality model - from cycle status + optional ML-Lab review labels: clean
completed / "good" / "golden" -> not a problem; force_stopped / interrupted
/ "bad" / "unusable" -> a problem.
* live_match - from match-ranking-history snapshots: :func:`_live_match_dataset`
labels each snapshot 1/0 by comparing its top-1 candidate to the confirmed
profile (wired into :func:`train_from_cycles` via ``ranking_history``).
"""
from __future__ import annotations
import importlib
import logging
from typing import Any
import numpy as np
_LOGGER = logging.getLogger(__name__)
from ..const import (
DEFAULT_DEFER_FINISH_CONFIDENCE,
ML_MATCH_COMMIT_THRESHOLD,
ML_QUALITY_SUSPICIOUS_THRESHOLD,
ML_TRAINING_AUC_MARGIN,
ML_TRAINING_BACC_MARGIN,
ML_TRAINING_MIN_POSITIVES,
ML_TRAINING_MIN_REGRESSION_ROWS,
ML_TRAINING_REGRESSION_MARGIN,
)
from . import trainer as T
# Capability -> (embedded module name, target label). Mirrors engine._MODEL_MODULES.
# The target label MUST match each baseline module's MODEL_TARGET (and
# promoted_manifest.json) so a promoted on-device spec records the same target as the
# shipped baseline it replaces.
_CAPABILITIES = {
"end": ("cycle_end_detector_model", "cycle_truly_ended"),
"quality": ("hybrid_curve_quality_model", "problem_cycle"),
"live_match": ("live_match_commit_model", "match_top1_correct"),
}
# The FIXED probability cutoff each live consumer applies to this capability's
# score. AUC alone is calibration-blind, so on-device retraining must also not
# degrade balanced accuracy AT the operating point the model is actually used at
# (else a "better AUC" model can silently shift decision rates). See _train_capability.
_OPERATING_THRESHOLD = {
"end": DEFAULT_DEFER_FINISH_CONFIDENCE,
"quality": ML_QUALITY_SUSPICIOUS_THRESHOLD,
"live_match": ML_MATCH_COMMIT_THRESHOLD,
}
# Regression capabilities have no embedded baseline module - they are promoted
# only when they beat a naive analytic estimate on held-out data. capability ->
# (target label, target units).
_REGRESSION_CAPABILITIES = {
"remaining_time": ("progress_fraction", "fraction"),
"total_energy": ("energy_fraction", "fraction"),
}
# Elapsed fractions at which each clean cycle is cut to synthesize a training row.
_PROGRESS_CUT_FRACTIONS = (0.15, 0.30, 0.45, 0.60, 0.75, 0.90)
_ACTIVE_FLOOR_RATIO = 0.02
_MIN_ROWS = 40
def _read_points(cycle: dict[str, Any]) -> list[tuple[float, float]]:
"""Return power data as offset-seconds/watts pairs, handling str and datetime start_time."""
from ..profile_store import decompress_power_data # noqa: PLC0415
try:
return decompress_power_data(cycle)
except Exception: # noqa: BLE001
return []
def _matrix(rows: list[dict[str, float]], columns: list[str]) -> np.ndarray:
if not rows:
return np.empty((0, len(columns)), dtype=float)
return np.array(
[[float(r.get(col) or 0.0) for col in columns] for r in rows], dtype=float
)
def _end_dataset(
clean: list[dict[str, Any]],
expectations: dict[str, dict[str, float]],
stop_thr: float,
) -> tuple[np.ndarray, np.ndarray, list[str], np.ndarray]:
"""Positives = each completed clean cycle's final end; negatives = pauses that resumed.
Also returns a per-row ``groups`` array (source-cycle index) so the holdout split
keeps every row from a given cycle on the same side (this dataset emits 1+N rows
per cycle; row-level splitting would leak siblings across train/test B5)."""
from .feature_extraction import END_FEATURE_COLUMNS, latest_end_event_features
rows: list[dict[str, float]] = []
labels: list[float] = []
groups: list[int] = []
for ci, c in enumerate(clean):
exp = expectations.get(c.get("profile_name"))
if not exp:
continue
points = _read_points(c)
if len(points) < 6:
continue
peak = max((p for _, p in points), default=0.0)
if peak <= 0:
continue
active_thr = max(stop_thr, _ACTIVE_FLOOR_RATIO * peak)
in_low = False
low_start = 0.0
for i, (t, p) in enumerate(points):
if not in_low and p < active_thr:
in_low = True
low_start = t
elif in_low and p >= active_thr:
if (points[i - 1][0] - low_start) >= 30.0:
feat = latest_end_event_features(points[:i], exp)
if feat is not None:
rows.append(feat)
labels.append(0.0) # resumed -> not the end
groups.append(ci)
in_low = False
feat_end = latest_end_event_features(points, exp)
if feat_end is not None:
rows.append(feat_end)
labels.append(1.0) # trace ends here -> true end
groups.append(ci)
return (_matrix(rows, list(END_FEATURE_COLUMNS)), np.array(labels, dtype=float),
list(END_FEATURE_COLUMNS), np.array(groups, dtype=int))
def _quality_label(cycle: dict[str, Any]) -> float | None:
"""1 = problem, 0 = clean, None = unknown (skip)."""
review = cycle.get("ml_review")
if isinstance(review, dict):
if review.get("golden"):
return 0.0 # pinned reference cycle -> definitely clean
q = review.get("quality")
if q in ("good", "golden"):
return 0.0
if q in ("bad", "unusable"):
return 1.0
status = cycle.get("status")
if status in ("force_stopped", "interrupted"):
return 1.0
if status == "completed":
return 0.0
return None
def _quality_dataset(
cycles: list[dict[str, Any]],
expectations: dict[str, dict[str, float]],
) -> tuple[np.ndarray, np.ndarray, list[str], np.ndarray]:
"""Uses ALL cycles (not clean-filtered) so mis-detected cycles are the positives.
Emits at most one row per cycle, so ``groups`` is unique-per-row (splitting by
group is equivalent to row-level here) returned for a uniform split API."""
from .feature_extraction import QUALITY_FEATURE_COLUMNS, quality_features
rows: list[dict[str, float]] = []
labels: list[float] = []
groups: list[int] = []
for ci, c in enumerate(cycles):
exp = expectations.get(c.get("profile_name"))
if not exp:
continue
label = _quality_label(c)
if label is None:
continue
points = _read_points(c)
if len(points) < 6:
continue
raw_conf = c.get("match_confidence")
if isinstance(raw_conf, (int, float)) and not isinstance(raw_conf, bool) and raw_conf > 0:
conf = float(raw_conf)
proxy_dist, proxy_margin, proxy_fit = max(0.0, 1.0 - conf), conf, conf
else:
proxy_dist, proxy_margin, proxy_fit = 0.25, 0.30, 0.75
# Use the cycle's real detected-artifact count so the flag_pressure feature
# is not train-time-constant (which would zero its learned coefficient and
# blind the AUC gate to it). Mirrors inference in manager._compute_cycle_quality_score.
arts = c.get("artifacts")
flag_count = len(arts) if isinstance(arts, list) else 0
try:
feat = quality_features(
points, exp["duration"], exp["energy"], exp["peak"],
proxy_dist, proxy_margin, proxy_fit, flag_count,
)
except Exception: # pylint: disable=broad-exception-caught
continue
rows.append(feat)
labels.append(label)
groups.append(ci)
return (_matrix(rows, list(QUALITY_FEATURE_COLUMNS)), np.array(labels, dtype=float),
list(QUALITY_FEATURE_COLUMNS), np.array(groups, dtype=int))
def _live_match_dataset(
snapshots: list[dict[str, Any]],
) -> tuple[np.ndarray, np.ndarray, list[str], np.ndarray]:
"""Build a training matrix from accumulated match ranking snapshots.
Each snapshot was captured mid-cycle; at cycle end the confirmed profile
was back-filled as ``confirmed_label``. We label by whether the model's
top-1 candidate at recording time matched the final confirmed label:
1.0 = top-1 was correct (should commit), 0.0 = wrong (should not commit).
Snapshots without a confirmed label are skipped.
One cycle produces several snapshots (matching re-runs every ~5 min), all
back-filled with the same label, so ``groups`` keys rows by source cycle
(``cycle_id`` ``start_time_iso`` unique) to stop the holdout split leaking
correlated same-cycle snapshots across train/test (B5).
"""
from .feature_extraction import LIVE_MATCH_FEATURE_COLUMNS
columns = list(LIVE_MATCH_FEATURE_COLUMNS)
rows: list[dict[str, float]] = []
labels: list[float] = []
group_keys: list[str] = []
for i, snap in enumerate(snapshots):
if not isinstance(snap, dict):
continue
confirmed = snap.get("confirmed_label")
if not isinstance(confirmed, str) or not confirmed:
continue
top1 = snap.get("top1_profile")
if not isinstance(top1, str):
continue
feat = snap.get("features")
if not isinstance(feat, dict):
continue
label = 1.0 if confirmed == top1 else 0.0
rows.append({col: float(feat.get(col) or 0.0) for col in columns})
labels.append(label)
group_keys.append(str(snap.get("cycle_id") or snap.get("start_time_iso") or f"_row{i}"))
return (_matrix(rows, columns), np.array(labels, dtype=float),
columns, _group_ids(group_keys))
def _group_ids(keys: list[Any]) -> np.ndarray:
"""Map an ordered list of group keys to stable integer ids (first-seen order)."""
seen: dict[Any, int] = {}
out: list[int] = []
for k in keys:
if k not in seen:
seen[k] = len(seen)
out.append(seen[k])
return np.array(out, dtype=int)
def _progress_dataset(
clean: list[dict[str, Any]],
expectations: dict[str, dict[str, float]],
) -> tuple[np.ndarray, np.ndarray, list[str], np.ndarray]:
"""Synthesize (features, completion_fraction) rows for the remaining-time model.
Each clean completed cycle is cut at several elapsed fractions; the target is
the true completion fraction of the prefix (``prefix_elapsed / total``). This
turns every stored trace into a handful of supervised progress examples, so
the regressor learns the device's own progress curve (e.g. a program that
reliably runs longer than its labelled duration) rather than the naive
elapsed/expected assumption.
"""
from .feature_extraction import PROGRESS_FEATURE_COLUMNS, progress_features
columns = list(PROGRESS_FEATURE_COLUMNS)
rows: list[dict[str, float]] = []
labels: list[float] = []
groups: list[int] = []
for ci, c in enumerate(clean):
exp = expectations.get(c.get("profile_name"))
if not exp:
continue
points = _read_points(c)
if len(points) < 12:
continue
t0 = points[0][0]
total = points[-1][0] - t0
if total <= 60.0:
continue
for frac in _PROGRESS_CUT_FRACTIONS:
cut_t = t0 + frac * total
prefix = [(o, p) for o, p in points if o <= cut_t]
if len(prefix) < 4:
continue
feat = progress_features(prefix, exp)
if feat is None:
continue
actual_elapsed = prefix[-1][0] - t0
label = actual_elapsed / total
rows.append(feat)
labels.append(float(min(max(label, 0.0), 1.0)))
groups.append(ci)
return (_matrix(rows, columns), np.array(labels, dtype=float),
columns, np.array(groups, dtype=int))
def _energy_dataset(
clean: list[dict[str, Any]],
expectations: dict[str, dict[str, float]],
) -> tuple[np.ndarray, np.ndarray, list[str], np.ndarray]:
"""Synthesize (features, energy_completion_fraction) rows for the total-energy
model. Same feature vector as the remaining-time model; the label is
``energy_so_far / total_energy`` at each cut, so the regressor learns how
energy accumulates *non-linearly* over the cycle (heating front-loads it)
rather than assuming it tracks elapsed time. The naive baseline in
``_train_regression_capability`` is ``elapsed_over_expected`` (time progress),
which is exactly the current ``energy_so_far / progress`` projection so a
model is only promoted when it beats that.
"""
from .feature_extraction import (
PROGRESS_FEATURE_COLUMNS,
progress_features,
cumulative_energy_wh,
)
columns = list(PROGRESS_FEATURE_COLUMNS)
rows: list[dict[str, float]] = []
labels: list[float] = []
groups: list[int] = []
for ci, c in enumerate(clean):
exp = expectations.get(c.get("profile_name"))
if not exp:
continue
points = _read_points(c)
if len(points) < 12:
continue
t0 = points[0][0]
total_dur = points[-1][0] - t0
if total_dur <= 60.0:
continue
total_energy = float(cumulative_energy_wh(points)[-1])
if total_energy <= 1e-6:
continue
for frac in _PROGRESS_CUT_FRACTIONS:
cut_t = t0 + frac * total_dur
prefix = [(o, p) for o, p in points if o <= cut_t]
if len(prefix) < 4:
continue
feat = progress_features(prefix, exp)
if feat is None:
continue
energy_so_far = float(cumulative_energy_wh(prefix)[-1])
label = energy_so_far / total_energy
rows.append(feat)
labels.append(float(min(max(label, 0.0), 1.0)))
groups.append(ci)
return (_matrix(rows, columns), np.array(labels, dtype=float),
columns, np.array(groups, dtype=int))
def _group_holdout_indices(
groups: np.ndarray, frac: float, seed: int
) -> tuple[np.ndarray, np.ndarray] | None:
"""Assign whole groups to train/test so no group straddles the split (B5).
Returns (train_idx, test_idx) row-index arrays, or None if there are too few
distinct groups to hold any out while leaving 1 training group.
"""
uniq = np.unique(groups)
if uniq.size < 2:
return None
rng = np.random.default_rng(seed)
perm = rng.permutation(uniq)
n_test_groups = max(1, int(round(uniq.size * frac)))
if uniq.size - n_test_groups < 1:
n_test_groups = uniq.size - 1
test_groups = set(perm[:n_test_groups].tolist())
test_mask = np.array([g in test_groups for g in groups])
train_idx = np.where(~test_mask)[0]
test_idx = np.where(test_mask)[0]
if train_idx.size == 0 or test_idx.size == 0:
return None
return train_idx, test_idx
def _regression_split(
X: np.ndarray, y: np.ndarray, groups: np.ndarray | None = None,
*, frac: float = 0.2, seed: int = 0
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Seeded train/test split for regression (no class balancing).
When ``groups`` is given, splits by group so correlated same-cycle rows never
span train and test; falls back to in-sample eval if it cannot.
"""
n = X.shape[0]
if groups is not None and getattr(groups, "size", 0) == n:
split = _group_holdout_indices(groups, frac, seed)
if split is not None and split[0].size >= 2:
train_idx, test_idx = split
return X[train_idx], y[train_idx], X[test_idx], y[test_idx]
return X, y, X, y
rng = np.random.default_rng(seed)
idx = rng.permutation(n)
n_test = max(1, int(round(n * frac)))
if n - n_test < 2: # keep at least a couple of training rows
return X, y, X, y
test_idx, train_idx = idx[:n_test], idx[n_test:]
return X[train_idx], y[train_idx], X[test_idx], y[test_idx]
def _train_regression_capability(
capability: str,
target: str,
target_units: str,
X: np.ndarray,
y: np.ndarray,
columns: list[str],
trained_at: str,
groups: np.ndarray | None = None,
) -> dict[str, Any]:
"""Fit + gate one regression capability against a naive analytic baseline.
The naive baseline for the completion-fraction target is
``elapsed_over_expected`` (the first feature column) clamped to [0, 1] - i.e.
the current profile-duration assumption. A trained regressor is only promoted
when its held-out MAE is at least ``ML_TRAINING_REGRESSION_MARGIN`` lower.
"""
n = X.shape[0]
if n < ML_TRAINING_MIN_REGRESSION_ROWS:
return {"capability": capability, "promoted": False,
"reason": f"insufficient data (rows={n})"}
X_tr, y_tr, X_te, y_te = _regression_split(X, y, groups)
# Detect in-sample fallback (too few rows to split).
in_sample = X_tr is X and X_te is X
if in_sample:
_LOGGER.warning(
"ML training '%s': too few rows (%d) to split for regression — "
"evaluating in-sample; NOT promoting. Add more cycles for a reliable holdout.",
capability, n,
)
try:
fit = T.fit_ridge(X_tr, y_tr, alpha=1.0)
except ValueError as err:
return {"capability": capability, "promoted": False, "reason": str(err)}
spec_probe = {
"center": fit["center"], "scale": fit["scale"], "coef": fit["coef"],
"bias": fit["bias"], "output_center": fit["y_center"], "output_scale": fit["y_scale"],
"feature_columns": columns,
}
preds = np.clip(T.predict_matrix_spec(spec_probe, X_te), 0.0, 1.0)
metrics = T.regression_metrics(y_te, preds)
model_mae = float(metrics.get("mae") or 1.0)
naive_col = columns.index("elapsed_over_expected") if "elapsed_over_expected" in columns else 0
naive = np.clip(X_te[:, naive_col], 0.0, 1.0)
naive_mae = float(np.mean(np.abs(naive - y_te))) if y_te.size else 1.0
# Distinct source cycles: each clean cycle contributes several prefix rows via
# `groups`, so ``n`` (rows) overstates how many real cycles trained the model.
n_cycles = (
int(np.unique(groups).size)
if groups is not None and getattr(groups, "size", 0) == n
else n
)
# Never promote on an in-sample (non-held-out) evaluation.
promote = (model_mae <= naive_mae * (1.0 - ML_TRAINING_REGRESSION_MARGIN)) and not in_sample
record: dict[str, Any] = {
"capability": capability,
"promoted": bool(promote),
"rows": n,
"cycle_count": n_cycles,
"model_mae": round(model_mae, 5),
"naive_mae": round(naive_mae, 5),
"metrics": metrics,
}
if promote:
record["spec"] = T.build_regression_spec(
name=capability, target=target, feature_columns=columns, fit=fit,
target_units=target_units,
metrics={"holdout": metrics, "model_mae": round(model_mae, 5),
"naive_mae": round(naive_mae, 5)},
trained_at=trained_at, cycle_count=n_cycles,
)
record["trained_at"] = trained_at
elif in_sample:
record["reason"] = "no held-out split (in-sample eval); not promoted"
else:
record["reason"] = f"MAE {model_mae:.4f} not below naive {naive_mae:.4f} - margin"
return record
def _holdout_split(
X: np.ndarray, y: np.ndarray, groups: np.ndarray | None = None,
*, frac: float = 0.2, seed: int = 0
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Seeded split that keeps both classes in the test set when possible.
When ``groups`` is given, splits by group (no same-cycle row spans the split, B5);
if the resulting split loses a class from either side it retries a few seeds, then
falls back to in-sample eval.
"""
n = X.shape[0]
if groups is not None and getattr(groups, "size", 0) == n:
for s in range(seed, seed + 8):
split = _group_holdout_indices(groups, frac, s)
if split is None:
break
train_idx, test_idx = split
if (len(np.unique(y[test_idx])) >= 2 and len(np.unique(y[train_idx])) >= 2):
return X[train_idx], y[train_idx], X[test_idx], y[test_idx]
return X, y, X, y
rng = np.random.default_rng(seed)
idx = rng.permutation(n)
n_test = max(1, int(round(n * frac)))
test_idx, train_idx = idx[:n_test], idx[n_test:]
# Guarantee both classes present in test; otherwise fall back to all-data eval.
if len(np.unique(y[test_idx])) < 2 or len(np.unique(y[train_idx])) < 2:
return X, y, X, y
return X[train_idx], y[train_idx], X[test_idx], y[test_idx]
def _embedded_module(capability: str):
module_name = _CAPABILITIES.get(capability, (None, None))[0]
if module_name is None:
return None
try:
return importlib.import_module(f"{__package__}.{module_name}")
except Exception: # pylint: disable=broad-exception-caught
return None
def _baseline_scores(capability: str, X_test: np.ndarray, columns: list[str]) -> np.ndarray | None:
"""Embedded-baseline probabilities on X_test, or None if it can't load/score."""
module = _embedded_module(capability)
if module is None:
return None
try:
return np.array(
[float(module.score(dict(zip(columns, row)))) for row in X_test], dtype=float
)
except Exception: # pylint: disable=broad-exception-caught
return None
def _baseline_threshold(capability: str, default: float) -> float:
module = _embedded_module(capability)
thr = getattr(module, "THRESHOLD", None) if module is not None else None
return float(thr) if isinstance(thr, (int, float)) else default
def _train_capability(
capability: str,
target: str,
X: np.ndarray,
y: np.ndarray,
columns: list[str],
trained_at: str,
groups: np.ndarray | None = None,
) -> dict[str, Any]:
"""Fit + gate one capability. Returns a status record (promoted or not)."""
n = X.shape[0]
n_pos = int(np.sum(y == 1))
n_neg = int(np.sum(y == 0))
if n < _MIN_ROWS or n_pos < ML_TRAINING_MIN_POSITIVES or n_neg < 5:
return {"capability": capability, "promoted": False,
"reason": f"insufficient data (rows={n}, pos={n_pos}, neg={n_neg})"}
X_tr, y_tr, X_te, y_te = _holdout_split(X, y, groups)
# Detect in-sample fallback (holdout returned full dataset for both splits).
in_sample = X_tr is X and X_te is X
if in_sample:
_LOGGER.warning(
"ML training '%s': dataset too small or imbalanced to split "
"(n=%d, pos=%d, neg=%d) — AUC evaluated in-sample; NOT promoting "
"(an in-sample AUC is optimistic). Add more labeled cycles.",
capability, n, n_pos, n_neg,
)
fit = T.fit_logistic(X_tr, y_tr)
default_thr = _baseline_threshold(capability, 0.5)
spec_probe = {"center": fit["center"], "scale": fit["scale"], "coef": fit["coef"],
"bias": fit["bias"], "feature_columns": columns}
train_scores = T.score_matrix_spec(spec_probe, X_tr)
threshold = T.select_threshold(y_tr, train_scores, default=default_thr)
test_scores = T.score_matrix_spec(spec_probe, X_te)
new_auc = T.auc(y_te, test_scores)
metrics = T.binary_metrics(y_te, test_scores, threshold)
# Distinct source cycles (some capabilities emit >1 row per cycle, e.g. an
# end classifier with several candidate events); mirror the regression path.
n_cycles = (
int(np.unique(groups).size)
if groups is not None and getattr(groups, "size", 0) == n
else n
)
base_scores = _baseline_scores(capability, X_te, columns)
if base_scores is None:
# Every classifier capability ships an embedded baseline; None here means
# it failed to load/score, NOT that it is legitimately absent. Don't promote
# against a fabricated 0.5 bar (that would let a near-random model win).
return {"capability": capability, "promoted": False,
"rows": n, "positives": n_pos, "negatives": n_neg,
"cycle_count": n_cycles, "new_auc": round(new_auc, 4),
"threshold": threshold, "metrics": metrics,
"reason": "embedded baseline unavailable; cannot gate promotion"}
baseline = T.auc(y_te, base_scores)
# Calibration-aware gate: the live consumer applies a FIXED probability cutoff to
# this capability, so AUC (rank quality) alone isn't enough — a retrained model
# must also not degrade balanced accuracy AT that operating cutoff, else a
# differently-calibrated on-device model silently shifts decision rates.
op_thr = _OPERATING_THRESHOLD.get(capability)
trained_op_bacc: float | None = None
base_op_bacc: float | None = None
calib_ok = True
if op_thr is not None:
trained_op_bacc = float(
T.binary_metrics(y_te, test_scores, op_thr).get("balanced_accuracy") or 0.0
)
base_op_bacc = float(
T.binary_metrics(y_te, base_scores, op_thr).get("balanced_accuracy") or 0.0
)
calib_ok = trained_op_bacc >= (base_op_bacc - ML_TRAINING_BACC_MARGIN)
# Never promote on an in-sample (non-held-out) evaluation: the AUC is optimistic.
promote = (
(new_auc >= (baseline - ML_TRAINING_AUC_MARGIN)) and not in_sample and calib_ok
)
record: dict[str, Any] = {
"capability": capability,
"promoted": bool(promote),
"rows": n, "positives": n_pos, "negatives": n_neg,
"cycle_count": n_cycles,
"new_auc": round(new_auc, 4),
"baseline_auc": round(baseline, 4),
"threshold": threshold,
"metrics": metrics,
}
if op_thr is not None:
record["operating_threshold"] = op_thr
record["op_balanced_accuracy"] = round(trained_op_bacc or 0.0, 4)
record["baseline_op_balanced_accuracy"] = round(base_op_bacc or 0.0, 4)
if promote:
record["spec"] = T.build_spec(
name=capability, target=target, feature_columns=columns,
fit=fit, threshold=threshold,
metrics={"holdout": metrics, "auc": round(new_auc, 4), "baseline_auc": round(baseline, 4)},
trained_at=trained_at, cycle_count=n_cycles,
)
record["trained_at"] = trained_at
elif in_sample:
record["reason"] = "no held-out split (in-sample eval); not promoted"
elif not calib_ok:
record["reason"] = (
f"balanced accuracy at operating threshold {op_thr} "
f"({trained_op_bacc:.3f}) below baseline ({base_op_bacc:.3f}) - margin"
)
else:
record["reason"] = f"AUC {new_auc:.3f} below baseline {baseline:.3f} - margin"
return record
def train_from_cycles(
cycles: list[dict[str, Any]],
device_type: str | None,
stop_threshold_w: float = 2.0,
trained_at: str = "",
ranking_history: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Pure function (executor-safe): build datasets, train, gate all capabilities.
Returns ``{"results": [record, ...], "promoted": {capability: record}}``.
Caller persists the promoted records via ``profile_store.set_ml_model_version``.
``ranking_history`` is the accumulated match ranking snapshots from the store
(see :meth:`.ProfileStore.get_match_ranking_history`). When provided it
unlocks on-device training for the ``live_match`` capability.
"""
from ..suggestion_engine import select_clean_cycles
from .feature_extraction import profile_expectations
clean, _excluded = select_clean_cycles(cycles, stop_threshold_w=stop_threshold_w)
expectations = profile_expectations(cycles)
datasets: dict[str, tuple[np.ndarray, np.ndarray, list[str], np.ndarray]] = {
"end": _end_dataset(clean, expectations, stop_threshold_w),
"quality": _quality_dataset(cycles, expectations),
"live_match": _live_match_dataset(ranking_history or []),
}
results: list[dict[str, Any]] = []
promoted: dict[str, Any] = {}
for capability, (module_name, target) in _CAPABILITIES.items():
X, y, columns, groups = datasets[capability]
try:
record = _train_capability(capability, target, X, y, columns, trained_at, groups)
except ValueError as exc:
_LOGGER.debug("Skipping %s training: %s", capability, exc)
results.append({"capability": capability, "promoted": False, "reason": str(exc)})
continue
results.append(record)
if record.get("promoted") and "spec" in record:
promoted[capability] = {
"spec": record["spec"],
"trained_at": trained_at,
"cycle_count": record["cycle_count"],
"metrics": record["metrics"],
"new_auc": record["new_auc"],
"baseline_auc": record["baseline_auc"],
}
# Regression capabilities (no embedded baseline; gated against a naive estimate).
reg_datasets: dict[str, tuple[np.ndarray, np.ndarray, list[str], np.ndarray]] = {
"remaining_time": _progress_dataset(clean, expectations),
"total_energy": _energy_dataset(clean, expectations),
}
for capability, (target, target_units) in _REGRESSION_CAPABILITIES.items():
X, y, columns, groups = reg_datasets[capability]
record = _train_regression_capability(
capability, target, target_units, X, y, columns, trained_at, groups
)
results.append(record)
if record.get("promoted") and "spec" in record:
promoted[capability] = {
"spec": record["spec"],
"trained_at": trained_at,
"cycle_count": record["cycle_count"],
"metrics": record["metrics"],
"model_mae": record["model_mae"],
"naive_mae": record["naive_mae"],
}
return {"results": results, "promoted": promoted}
async def async_run_training(hass: Any, manager: Any) -> dict[str, Any]:
"""Public entry point: train on this device's cycles and persist winners.
Offloads the CPU work to an executor thread and persists any promoted model
specs into the profile store. Returns a summary for logging / the event.
"""
from ..const import CONF_MIN_POWER, CONF_STOP_THRESHOLD_W
store = manager.profile_store
entry = hass.config_entries.async_get_entry(manager.entry_id)
merged = {**(entry.data if entry else {}), **(entry.options if entry else {})}
stop_thr = 2.0
for key in (CONF_STOP_THRESHOLD_W, CONF_MIN_POWER):
try:
v = float(merged.get(key))
except (TypeError, ValueError):
continue
if v > 0:
stop_thr = v
break
from homeassistant.util import dt as dt_util
trained_at = dt_util.now().isoformat()
cycles = list(store.get_past_cycles()) # snapshot before executor to avoid data race
# get_match_ranking_history() already returns a shallow copy of the top-level
# list, but wrap it in list(...) too so the executor never iterates a list that
# the event loop could mutate mid-training - matching the get_past_cycles()
# snapshot above.
ranking_history = list(store.get_match_ranking_history())
_LOGGER.info(
"On-device ML training starting: %d cycles, %d ranking snapshots, "
"device_type=%s, stop_threshold=%.1fW",
len(cycles), len(ranking_history), manager.device_type, stop_thr,
)
summary = await hass.async_add_executor_job(
train_from_cycles, cycles, manager.device_type, stop_thr, trained_at, ranking_history
)
for record in summary.get("results", []):
is_regression = "model_mae" in record
if record.get("promoted") and is_regression:
_LOGGER.info(
"ML training PROMOTED %s: MAE %.4f vs naive %.4f (rows=%s)",
record["capability"], record.get("model_mae", 0), record.get("naive_mae", 0),
record.get("rows"),
)
elif record.get("promoted"):
_LOGGER.info(
"ML training PROMOTED %s: AUC %.3f vs baseline %.3f (rows=%s, pos=%s)",
record["capability"], record.get("new_auc", 0), record.get("baseline_auc", 0),
record.get("rows"), record.get("positives"),
)
else:
_LOGGER.info(
"ML training kept baseline for %s: %s",
record["capability"], record.get("reason", "not promoted"),
)
for capability, record in summary.get("promoted", {}).items():
await store.set_ml_model_version(capability, record)
return summary
@@ -0,0 +1,156 @@
# 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/>.
"""Pure notification DECISION predicates.
Single source of truth for the "should this notification fire now?" logic that
carries real thresholds (quiet hours, milestone crossings, the pre-completion
window). Both the live integration (``manager.WashDataManager`` - which keeps all
DELIVERY: hass services, notify entities, quiet-hours queueing, presence) and the
Playground simulation (which surfaces "a notification would fire here" markers)
call these, so the panel's what-if timeline matches the running integration.
Nothing here touches Home Assistant; every function is pure given plain config
values + a timestamp, so it is executor-safe. Trivial "is a start/finish service
configured" checks stay inline in the manager (they are config presence checks,
not duplicated logic).
Extracted verbatim from ``manager.py``; guarded by the notification test suite.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any
from .const import CONF_NOTIFY_QUIET_END_HOUR, CONF_NOTIFY_QUIET_START_HOUR
def quiet_hours_bounds(options: Any) -> tuple[int, int] | None:
"""Return validated ``(start_hour, end_hour)`` or ``None`` when off.
Off when either hour is unset/None/non-int/out-of-range, or ``start == end``.
"""
raw_start = options.get(CONF_NOTIFY_QUIET_START_HOUR)
raw_end = options.get(CONF_NOTIFY_QUIET_END_HOUR)
if raw_start is None or raw_end is None:
return None
try:
if isinstance(raw_start, bool) or isinstance(raw_end, bool):
return None
start = int(raw_start)
end = int(raw_end)
if start != raw_start or end != raw_end:
return None
except (TypeError, ValueError):
return None
if not (0 <= start <= 23) or not (0 <= end <= 23):
return None
if start == end:
# Zero-length window -> feature off (avoids "always quiet" ambiguity).
return None
return start, end
def in_quiet_hours(bounds: tuple[int, int] | None, when: datetime) -> bool:
"""Return True when ``when`` falls inside the quiet window ``bounds``.
Supports windows that wrap midnight (start > end, e.g. 22 -> 7 means
22:00-06:59). The end hour is exclusive at hour granularity. ``when`` is
supplied by the caller (the manager passes ``dt_util.now()``; the Playground
passes the replay timestamp), so this never reads the wall clock.
"""
if bounds is None:
return False
start, end = bounds
hour = when.hour
if start < end:
# Same-day window, e.g. 1 -> 6 covers hours 1..5.
return start <= hour < end
# Wrap-around window, e.g. 22 -> 7 covers 22,23,0..6.
return hour >= start or hour < end
def seconds_until_quiet_end(
bounds: tuple[int, int] | None, when: datetime
) -> float:
"""Seconds from ``when`` until the next end-of-quiet boundary (end:00).
Returns 0.0 when the feature is off or ``when`` is not in quiet hours.
"""
if bounds is None:
return 0.0
if not in_quiet_hours(bounds, when):
return 0.0
_start, end = bounds
target = when.replace(hour=end, minute=0, second=0, microsecond=0)
if target <= when:
# End hour is earlier today (wrap-around window) -> it lands tomorrow.
target = target + timedelta(days=1)
return max(0.0, (target - when).total_seconds())
def milestone_crossed(prev_count: int, cur_count: int, milestones: Any) -> int | None:
"""Return the milestone just crossed, or None.
A milestone ``m`` is crossed when ``prev_count < m <= cur_count``. Empty or
malformed ``milestones`` is a no-op. If several are crossed in one step the
largest is returned so a single, most-significant notification fires.
"""
if not milestones or isinstance(milestones, (str, bytes)):
return None
try:
iterator = list(milestones)
except TypeError:
return None
crossed: int | None = None
for raw in iterator:
# Accept genuine positive integers only: reject bool (True/False), fractional
# floats (50.5 -> 50), and int-like strings ("50") so a milestone the user
# never configured can't fire.
if isinstance(raw, bool):
continue
try:
m = int(raw)
except (TypeError, ValueError):
continue
if m != raw or m <= 0:
continue
if prev_count < m <= cur_count and (crossed is None or m > crossed):
crossed = m
return crossed
def should_notify_pre_completion(
notify_before_end_minutes: float,
already_notified: bool,
time_remaining: float | None,
cycle_progress: float,
match_ambiguous: bool,
) -> bool:
"""The one-time "almost done" pre-completion gate.
Fires when the configured lead time is set, we have not already fired, the
model-estimated remaining time has dropped within the lead window, the cycle
is not yet complete, and the match is not ambiguous.
"""
return (
notify_before_end_minutes > 0
and not already_notified
and time_remaining is not None
and time_remaining <= (notify_before_end_minutes * 60)
and cycle_progress < 100
and not match_ambiguous
)
@@ -1,50 +0,0 @@
"""Helpers for phase range assignment and timestamp conversion."""
from __future__ import annotations
from datetime import datetime
from homeassistant.util import dt as dt_util
def parse_phase_timestamp(value: str, cycle_start_dt: datetime) -> datetime | None:
"""Parse timestamp text used in phase assignment.
Supported formats:
- Full parseable datetime (via Home Assistant parser)
- YYYY-MM-DD HH:MM
- YYYY-MM-DD HH:MM:SS
- HH:MM (on cycle start date)
- HH:MM:SS (on cycle start date)
"""
text = str(value or "").strip()
if not text:
return None
parsed = dt_util.parse_datetime(text)
if parsed is not None:
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=cycle_start_dt.tzinfo)
return parsed
for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%d %H:%M:%S"):
try:
dt_val = datetime.strptime(text, fmt)
return dt_val.replace(tzinfo=cycle_start_dt.tzinfo)
except ValueError:
continue
for fmt in ("%H:%M", "%H:%M:%S"):
try:
t_val = datetime.strptime(text, fmt)
base = dt_util.as_local(cycle_start_dt)
return base.replace(
hour=t_val.hour,
minute=t_val.minute,
second=t_val.second,
microsecond=0,
)
except ValueError:
continue
return None
+61 -199
View File
@@ -1,3 +1,19 @@
# 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/>.
"""Phase catalog defaults and helpers for WashData."""
from __future__ import annotations
@@ -7,14 +23,8 @@ from copy import deepcopy
from typing import Any
from .const import (
DEVICE_TYPE_AIR_FRYER,
DEVICE_TYPE_BREAD_MAKER,
DEVICE_TYPE_COFFEE_MACHINE,
DEVICE_TYPE_DISHWASHER,
DEVICE_TYPE_DRYER,
DEVICE_TYPE_EV,
DEVICE_TYPE_HEAT_PUMP,
DEVICE_TYPE_OVEN,
DEVICE_TYPE_WASHER_DRYER,
DEVICE_TYPE_WASHING_MACHINE,
)
@@ -32,31 +42,37 @@ DEFAULT_PHASES_BY_DEVICE: dict[str, list[PhaseItem]] = {
{
"name": "Pre-Wash",
"description": "Initial soak or pre-treatment before the main wash.",
"translation_key": "phase_desc.pre_wash",
"is_default": True,
},
{
"name": "Wash",
"description": "Main washing cycle with drum movement and optional heating.",
"translation_key": "phase_desc.wash",
"is_default": True,
},
{
"name": "Rinse",
"description": "Clean-water rinse stage. This phase may repeat multiple times.",
"translation_key": "phase_desc.rinse",
"is_default": True,
},
{
"name": "Spin",
"description": "High-speed extraction to remove water from the load.",
"translation_key": "phase_desc.spin",
"is_default": True,
},
{
"name": "Soak",
"description": "Low-activity soaking period between active wash stages.",
"translation_key": "phase_desc.soak",
"is_default": True,
},
{
"name": "Anti-Crease",
"description": "Occasional short tumbles after completion to reduce wrinkles.",
"translation_key": "phase_desc.anti_crease",
"is_default": True,
},
],
@@ -64,26 +80,31 @@ DEFAULT_PHASES_BY_DEVICE: dict[str, list[PhaseItem]] = {
{
"name": "Heat Up",
"description": "Initial heater warm-up before full drying begins.",
"translation_key": "phase_desc.heat_up",
"is_default": True,
},
{
"name": "Drying",
"description": "Main heated tumbling period.",
"translation_key": "phase_desc.drying",
"is_default": True,
},
{
"name": "Cool Down",
"description": "Tumbling without heat near cycle end.",
"translation_key": "phase_desc.cool_down",
"is_default": True,
},
{
"name": "Anti-Wrinkle",
"description": "Periodic post-cycle tumbling to reduce wrinkles.",
"translation_key": "phase_desc.anti_wrinkle",
"is_default": True,
},
{
"name": "Sensor Check",
"description": "Short low-power pause while dryness is measured.",
"translation_key": "phase_desc.sensor_check",
"is_default": True,
},
],
@@ -91,46 +112,55 @@ DEFAULT_PHASES_BY_DEVICE: dict[str, list[PhaseItem]] = {
{
"name": "Pre-Wash",
"description": "Initial soak or pre-treatment before the main wash.",
"translation_key": "phase_desc.pre_wash",
"is_default": True,
},
{
"name": "Wash",
"description": "Main washing cycle with drum movement and optional heating.",
"translation_key": "phase_desc.wash",
"is_default": True,
},
{
"name": "Rinse",
"description": "Clean-water rinse stage. This phase may repeat multiple times.",
"translation_key": "phase_desc.rinse",
"is_default": True,
},
{
"name": "Spin",
"description": "High-speed extraction before drying transition.",
"translation_key": "phase_desc.spin_wd",
"is_default": True,
},
{
"name": "Drain & Switch",
"description": "Transition period from washing to drying mode.",
"translation_key": "phase_desc.drain_and_switch",
"is_default": True,
},
{
"name": "Heat Up",
"description": "Initial heater warm-up before full drying begins.",
"translation_key": "phase_desc.heat_up",
"is_default": True,
},
{
"name": "Drying",
"description": "Main heated tumbling period.",
"translation_key": "phase_desc.drying",
"is_default": True,
},
{
"name": "Cool Down",
"description": "Tumbling without heat near cycle end.",
"translation_key": "phase_desc.cool_down",
"is_default": True,
},
{
"name": "Anti-Wrinkle",
"description": "Periodic post-cycle tumbling to reduce wrinkles.",
"translation_key": "phase_desc.anti_wrinkle",
"is_default": True,
},
],
@@ -138,213 +168,37 @@ DEFAULT_PHASES_BY_DEVICE: dict[str, list[PhaseItem]] = {
{
"name": "Pre-Rinse",
"description": "Initial spray-down before detergent wash.",
"translation_key": "phase_desc.pre_rinse",
"is_default": True,
},
{
"name": "Wash",
"description": "Main detergent wash with heating.",
"translation_key": "phase_desc.wash_dw",
"is_default": True,
},
{
"name": "Rinse",
"description": "Clean-water rinse stage. This phase may repeat multiple times.",
"translation_key": "phase_desc.rinse",
"is_default": True,
},
{
"name": "Dry",
"description": "Drying stage using heater and/or residual heat.",
"translation_key": "phase_desc.dry",
"is_default": True,
},
{
"name": "Sanitize",
"description": "High-temperature cleaning stage for sanitization programs.",
"translation_key": "phase_desc.sanitize",
"is_default": True,
},
{
"name": "Soak",
"description": "Extended soak period for heavy soil.",
"is_default": True,
},
],
DEVICE_TYPE_COFFEE_MACHINE: [
{
"name": "Heat Up",
"description": "Boiler heating to reach operating temperature.",
"is_default": True,
},
{
"name": "Brewing",
"description": "Water pumping through coffee grounds.",
"is_default": True,
},
{
"name": "Keep Warm",
"description": "Maintaining temperature after brew completion.",
"is_default": True,
},
{
"name": "Grinding",
"description": "Bean grinding stage on machines with integrated grinder.",
"is_default": True,
},
{
"name": "Steaming",
"description": "Steam generation for milk frothing.",
"is_default": True,
},
{
"name": "Idle",
"description": "Ready/standby period with low power use.",
"is_default": True,
},
],
DEVICE_TYPE_EV: [
{
"name": "Initialization",
"description": "Vehicle and charger handshake before power transfer.",
"is_default": True,
},
{
"name": "Charging",
"description": "Main charging period at available power.",
"is_default": True,
},
{
"name": "Taper",
"description": "Reduced charging rate near high state of charge.",
"is_default": True,
},
{
"name": "Maintenance",
"description": "Battery balancing or conditioning activity.",
"is_default": True,
},
{
"name": "Complete",
"description": "Charge complete with minimal top-up activity.",
"is_default": True,
},
{
"name": "Pre-Conditioning",
"description": "Battery temperature conditioning before or during charge.",
"is_default": True,
},
],
DEVICE_TYPE_AIR_FRYER: [
{
"name": "Pre-Heat",
"description": "Initial chamber heating before full cooking.",
"is_default": True,
},
{
"name": "Cooking",
"description": "Main cooking phase with active heater and fan.",
"is_default": True,
},
{
"name": "Pause",
"description": "Short pause for shaking or inspection.",
"is_default": True,
},
{
"name": "Cool Down",
"description": "Fan-only cool-down stage after heating.",
"is_default": True,
},
{
"name": "Keep Warm",
"description": "Low-heat holding stage to keep food warm.",
"is_default": True,
},
],
DEVICE_TYPE_HEAT_PUMP: [
{
"name": "Start-Up",
"description": "Compressor and system stabilization at cycle start.",
"is_default": True,
},
{
"name": "Heating",
"description": "Active heating operation.",
"is_default": True,
},
{
"name": "Cooling",
"description": "Active cooling operation.",
"is_default": True,
},
{
"name": "Defrost",
"description": "Defrost routine to clear outdoor coil ice.",
"is_default": True,
},
{
"name": "Standby",
"description": "Low-activity temperature holding period.",
"is_default": True,
},
{
"name": "Fan Only",
"description": "Air circulation without compressor heating/cooling.",
"is_default": True,
},
{
"name": "Boost",
"description": "High-output operation for rapid temperature change.",
"is_default": True,
},
],
DEVICE_TYPE_BREAD_MAKER: [
{
"name": "Kneading",
"description": "Motor-driven dough mixing and development. High power draw.",
"is_default": True,
},
{
"name": "Resting",
"description": "Short low-power pause between kneading stages for gluten relaxation.",
"is_default": True,
},
{
"name": "Proving",
"description": "Low-heat rising period to allow yeast fermentation and dough expansion.",
"is_default": True,
},
{
"name": "Baking",
"description": "High-temperature heating element active for crust and crumb formation.",
"is_default": True,
},
{
"name": "Keep Warm",
"description": "Low-heat holding stage to keep the loaf warm after baking.",
"is_default": True,
},
],
DEVICE_TYPE_OVEN: [
{
"name": "Pre-Heat",
"description": "Heating element runs continuously to bring the cavity up to the target temperature.",
"is_default": True,
},
{
"name": "Heating",
"description": "Active heater bursts during cooking when the thermostat calls for heat.",
"is_default": True,
},
{
"name": "Maintaining Temp",
"description": "Thermostat-regulated holding period: heater cycles on and off to keep the set temperature.",
"is_default": True,
},
{
"name": "Cool Down",
"description": "Heater off after the cycle ends; residual heat dissipates and the cooling fan may continue to run.",
"is_default": True,
},
{
"name": "Pyrolytic Clean",
"description": "High-temperature self-clean phase that burns off residue. Optional and only active during pyrolytic programs.",
"translation_key": "phase_desc.soak_dw",
"is_default": True,
},
],
@@ -371,28 +225,36 @@ def get_default_phase_catalog(device_type: str) -> list[PhaseItem]:
def get_shared_default_phase_catalog() -> list[PhaseItem]:
"""Return a shared default catalog deduplicated across all device types."""
merged: list[PhaseItem] = []
seen: set[str] = set()
"""Return a shared default catalog deduplicated by name across all device types.
One entry per phase name (first occurrence wins), but a later same-named phase
can back-fill a ``translation_key`` the first occurrence lacked, so a localized
label from any device-specific definition is not silently discarded.
"""
by_name: dict[str, PhaseItem] = {}
order: list[str] = []
for device_type, device_phases in DEFAULT_PHASES_BY_DEVICE.items():
for item in device_phases:
name = str(item.get("name", "")).strip()
if not name:
continue
key = name.casefold()
if key in seen:
continue
seen.add(key)
merged.append(
{
existing = by_name.get(key)
if existing is None:
entry: PhaseItem = {
"id": _builtin_phase_id(device_type, name),
"device_type": device_type,
"device_type": "",
"name": name,
"description": str(item.get("description", "")).strip(),
"is_default": True,
}
)
return merged
if "translation_key" in item:
entry["translation_key"] = item["translation_key"]
by_name[key] = entry
order.append(key)
elif "translation_key" not in existing and "translation_key" in item:
existing["translation_key"] = item["translation_key"]
return [by_name[k] for k in order]
def get_builtin_phase_by_id(phase_id: str) -> PhaseItem | None:
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+639
View File
@@ -0,0 +1,639 @@
# 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/>.
"""Progress / remaining-time / phase / projected-energy estimation.
Single source of truth for the cycle-progress math. Both the live integration
(``manager.WashDataManager`` - thin wrappers over these functions) and the
Playground's headless simulation (``playground.SimRunner``) call the SAME
functions here, so the panel's what-if replay is byte-for-byte what the running
integration computes. Nothing here touches Home Assistant; every function is
pure given a ``ProfileStore`` (read-only), the entry options mapping, and a
replayed ``(timestamp, power)`` trace, so it is executor-safe.
Extracted verbatim from ``manager.py`` (``self.profile_store`` -> ``store``,
``self._logger`` -> ``logger``); the arithmetic is unchanged and guarded by the
existing progress/phase/ML/energy test suite plus a golden before/after snapshot.
"""
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from datetime import datetime
from typing import Any, cast
import numpy as np
from .const import (
CYCLE_OVERRUN_ANOMALY_RATIO,
DEVICE_SMOOTHING_THRESHOLDS,
ML_PROGRESS_BLEND_WEIGHT,
STATE_ENDING,
STATE_PAUSED,
STATE_RUNNING,
)
from .profile_store import decompress_power_data
from .time_utils import power_data_to_offsets
_LOGGER = logging.getLogger(__name__)
# Minimum progress before an energy projection is trusted (mirrors the manager
# class constant of the same purpose).
PROJECTION_MIN_PROGRESS = 3.0
# Cache type for profile_end_expectation: (profile_name, base_expectation_dict).
EndExpCache = tuple[str, dict[str, float]] | None
@dataclass
class ProgressResult:
"""Output of :func:`compute_progress`."""
progress: float
smoothed: float
remaining: float
total: float
phase_progress: float | None # raw pre-smoothing estimate (diagnostic)
source: str # "phase" | "linear"
def profile_end_expectation(
store: Any,
profile_name: str,
expected_duration: float,
cache: EndExpCache = None,
) -> tuple[dict[str, float] | None, EndExpCache]:
"""Median duration/energy/peak for a matched profile, for end features.
Cached per profile (caller threads ``cache``) so the guard does not
re-decompress history on every low-power reading during ENDING. The
authoritative expected duration overrides the median when available.
Returns ``(expectation, cache)``.
"""
if cache is not None and cache[0] == profile_name:
expectation = dict(cache[1])
else:
from .ml.feature_extraction import profile_expectation
points_list: list[list[tuple[float, float]]] = []
for cycle in store.get_past_cycles():
if cycle.get("profile_name") != profile_name:
continue
pts = decompress_power_data(cycle)
if pts:
points_list.append(pts)
base = profile_expectation(points_list[-20:])
if base is None:
return None, cache
cache = (profile_name, dict(base))
expectation = dict(base)
if expected_duration and expected_duration > 0:
expectation["duration"] = float(expected_duration)
return expectation, cache
EndExpFn = Any # Callable[[str, float], dict[str, float] | None]
def ml_progress_percent(
store: Any,
options: Any,
matched_duration: float,
trace: list[tuple[datetime, float]],
profile_name: str,
end_expectation_fn: EndExpFn,
logger: logging.Logger | None = None,
) -> float | None:
"""ML completion-fraction estimate (0-100) for the running cycle, or None.
Uses the on-device ``remaining_time`` regressor; gated on the ML opt-in and
inert until training promotes a regressor. ``end_expectation_fn(name, dur)``
supplies the profile expectation (the manager passes its cached
``_profile_end_expectation``; the Playground wraps :func:`profile_end_expectation`)
so history is only decompressed after the cheap gates pass. Never raises.
"""
logger = logger or _LOGGER
try:
from .ml.engine import ml_models_enabled, resolve_regressor
if not ml_models_enabled(options):
return None
if (
not profile_name
or profile_name in ("off", "detecting...", "restored...")
or profile_name not in store.get_profiles()
):
return None
predict_fn, _src = resolve_regressor("remaining_time", store)
if predict_fn is None:
return None
if not trace or len(trace) < 4:
return None
expectation = end_expectation_fn(
profile_name, float(matched_duration or 0.0)
)
if expectation is None:
return None
t0 = trace[0][0]
pts = [(float((t - t0).total_seconds()), float(p)) for t, p in trace]
from .ml.feature_extraction import progress_features
feat = progress_features(pts, expectation)
if feat is None:
return None
frac = float(predict_fn(feat))
if not math.isfinite(frac):
return None
return float(min(max(frac, 0.0), 0.99)) * 100.0
except Exception as err: # noqa: BLE001 - ML must never break estimates
logger.debug("ML progress estimate skipped: %s", err)
return None
def ml_energy_total(
store: Any,
options: Any,
matched_duration: float,
trace: list[tuple[datetime, float]],
profile_name: str,
end_expectation_fn: EndExpFn,
logger: logging.Logger | None = None,
) -> float | None:
"""Predicted total cycle energy (Wh) from the on-device ``total_energy``
regressor, or None. ``end_expectation_fn`` as in :func:`ml_progress_percent`.
Never raises.
"""
logger = logger or _LOGGER
try:
from .ml.engine import ml_models_enabled, resolve_regressor
if not ml_models_enabled(options):
return None
if (
not profile_name
or profile_name in ("off", "detecting...", "restored...")
or profile_name not in store.get_profiles()
):
return None
predict_fn, _src = resolve_regressor("total_energy", store)
if predict_fn is None:
return None
if not trace or len(trace) < 4:
return None
expectation = end_expectation_fn(
profile_name, float(matched_duration or 0.0)
)
if expectation is None:
return None
t0 = trace[0][0]
pts = [(float((t - t0).total_seconds()), float(p)) for t, p in trace]
from .ml.feature_extraction import cumulative_energy_wh, progress_features
feat = progress_features(pts, expectation)
if feat is None:
return None
frac = float(predict_fn(feat))
# Floor the fraction so an under-confident prediction can't blow the
# projection up; below the floor, defer to the time-based fallback.
if not math.isfinite(frac) or frac < 0.05:
return None
energy_so_far = float(cumulative_energy_wh(pts)[-1])
if energy_so_far <= 0.0:
return None
total = energy_so_far / min(max(frac, 0.05), 1.0)
return max(total, energy_so_far) # never below what's already consumed
except Exception as err: # noqa: BLE001 - ML must never break estimates
logger.debug("ML energy projection skipped: %s", err)
return None
def estimate_phase_progress(
store: Any,
current_power_data: list[tuple[datetime, float]] | list[tuple[str, float]],
current_duration: float,
profile_name: str,
logger: logging.Logger | None = None,
) -> tuple[float, float] | None:
"""Estimate cycle progress by analyzing which phase we're in.
Uses cached statistical envelope built from ALL cycles labeled with this
profile, normalized by TIME to account for different sampling rates. Returns
``(progress_pct, variance_watts)`` or ``None`` if estimation fails.
"""
logger = logger or _LOGGER
# Get cached envelope (fast - already computed and stored)
envelope = store.get_envelope(profile_name)
if envelope is None:
logger.debug("No envelope cached for profile %s", profile_name)
return None
# Convert cached lists back to numpy arrays
try:
env_min = envelope.get("min", [])
env_max = envelope.get("max", [])
env_avg = envelope.get("avg", [])
env_std = envelope.get("std", [])
def extract_y_values(data: list[Any]) -> np.ndarray[Any, np.dtype[np.float64]]:
if not data:
return np.array([], dtype=float)
first = data[0]
if isinstance(first, (list, tuple)):
first_seq = cast(list[Any] | tuple[Any, ...], first)
if len(first_seq) < 2:
return np.array([], dtype=float)
# New format: [[t, y], ...]
points = cast(list[list[Any] | tuple[Any, ...]], data)
return np.array([float(pt[1]) for pt in points], dtype=float)
# Legacy format: [y, ...]
scalars = cast(list[float | int], data)
return np.array(scalars, dtype=float)
envelope_arrays: dict[str, np.ndarray[Any, np.dtype[np.float64]]] = {
"min": extract_y_values(env_min),
"max": extract_y_values(env_max),
"avg": extract_y_values(env_avg),
"std": extract_y_values(env_std),
}
time_grid: np.ndarray[Any, np.dtype[np.float64]] = np.array(
envelope.get("time_grid", []), dtype=float
)
target_duration = float(envelope.get("target_duration", 0.0) or 0.0)
except (KeyError, ValueError, TypeError, IndexError) as e:
logger.warning("Invalid envelope format for %s: %s", profile_name, e)
return None
if len(time_grid) == 0 or target_duration <= 0:
if target_duration > 0 and len(envelope_arrays["avg"]) > 0:
# Reconstruct time_grid if missing (Legacy envelope support)
count = len(envelope_arrays["avg"])
time_grid = np.linspace(0, target_duration, count)
logger.debug(
"Reconstructed missing time_grid for %s (n=%d)",
profile_name,
count,
)
else:
logger.debug("Envelope missing time grid/duration, cannot estimate phase")
return None
# Extract power offsets from current cycle (any format -> [offset, power])
current_offsets_list = power_data_to_offsets(
cast(list[list[Any] | tuple[Any, ...]], current_power_data)
)
current_offsets = np.array([o for o, _ in current_offsets_list])
current_values = np.array([p for _, p in current_offsets_list])
if current_offsets.size == 0:
logger.debug("No valid current power offsets, cannot estimate phase")
return None
# Use sliding window on TIME, not sample count
window_duration = min(60.0, target_duration * 0.25)
current_time = current_offsets[-1]
window_start_time = max(0, current_time - window_duration)
window_mask = current_offsets >= window_start_time
current_window_values = current_values[window_mask]
if len(current_window_values) < 3:
logger.debug("Insufficient data in current window for phase estimation")
return None
best_progress: float | None = None
best_score = -1.0
in_bounds = False
best_time_window_start: float | None = None
for i in range(len(time_grid) - 1):
time_window_start = float(time_grid[i])
envelope_window_start = i
envelope_window_end = min(
i + len(current_window_values), len(envelope_arrays["avg"])
)
if envelope_window_end <= envelope_window_start:
continue
avg_window = envelope_arrays["avg"][
envelope_window_start:envelope_window_end
]
min_window = envelope_arrays["min"][
envelope_window_start:envelope_window_end
]
max_window = envelope_arrays["max"][
envelope_window_start:envelope_window_end
]
if len(avg_window) != len(current_window_values):
x_old = np.linspace(0, 1, len(avg_window))
x_new = np.linspace(0, 1, len(current_window_values))
avg_window = np.interp(x_new, x_old, avg_window)
min_window = np.interp(x_new, x_old, min_window)
max_window = np.interp(x_new, x_old, max_window)
within_bounds = np.all(
(current_window_values >= min_window * 0.8)
& (current_window_values <= max_window * 1.2)
)
bounds_score = np.mean(
(current_window_values >= min_window)
& (current_window_values <= max_window)
)
try:
if np.std(current_window_values) > 0 and np.std(avg_window) > 0:
correlation = np.corrcoef(current_window_values, avg_window)[0, 1]
else:
correlation = 0.0
mae = np.mean(np.abs(current_window_values - avg_window))
max_power = max(np.max(avg_window), np.max(current_window_values), 1.0)
mae_normalized = 1.0 - min(mae / max_power, 1.0)
score = (
0.4 * max(correlation, 0.0)
+ 0.3 * mae_normalized
+ 0.3 * bounds_score
)
time_diff = abs(time_window_start - current_duration)
time_penalty = min(1.0, time_diff / (target_duration * 0.3))
score = score * (1.0 - 0.4 * time_penalty)
if score > best_score:
best_score = score
best_progress = (time_window_start / target_duration) * 100.0
in_bounds = within_bounds
best_time_window_start = float(time_window_start)
except Exception: # pylint: disable=broad-exception-caught
continue
if best_progress is None or best_score < 0.4:
logger.debug("Phase detection failed: best_score=%.3f", best_score)
return None
best_variance = 0.0
if best_time_window_start is not None:
idx_start = int((best_time_window_start / target_duration) * len(time_grid))
idx_end = min(
idx_start + len(current_window_values), len(envelope_arrays["std"])
)
if idx_end > idx_start:
window_std = envelope_arrays["std"][idx_start:idx_end]
if len(window_std) > 0:
best_variance = float(np.mean(window_std))
best_progress = max(0.0, min(best_progress, 99.0))
cycle_count = envelope.get("cycle_count", 0)
avg_sample_rates_raw = envelope.get("sampling_rates", [1.0])
avg_sample_rates = (
cast(list[float | int], avg_sample_rates_raw)
if isinstance(avg_sample_rates_raw, list)
else [1.0]
)
avg_sample_rate = (
float(np.median(np.array(avg_sample_rates, dtype=float)))
if avg_sample_rates
else 1.0
)
tws = (
best_time_window_start
if best_time_window_start is not None
else float(current_duration)
)
if not in_bounds:
logger.debug(
"Phase detection: progress=%.1f%%, score=%.3f, var=%.1fW, "
"time=%.0f/%.0fs [OUT OF BOUNDS, %s cycles, avg_sample_rate=%.1fs]",
best_progress,
best_score,
best_variance,
tws,
target_duration,
cycle_count,
avg_sample_rate,
)
else:
logger.debug(
"Phase detection: progress=%.1f%%, score=%.3f, var=%.1fW, "
"time=%.0f/%.0fs [IN BOUNDS, %s cycles, avg_sample_rate=%.1fs]",
best_progress,
best_score,
best_variance,
tws,
target_duration,
cycle_count,
avg_sample_rate,
)
return (best_progress, best_variance)
def compute_progress(
device_type: str,
matched_duration: float,
duration_so_far: float,
prev_smoothed: float,
phase_result: tuple[float, float] | None,
ml_pct: float | None,
logger: logging.Logger | None = None,
) -> ProgressResult | None:
"""The blend + EMA + monotonicity + back-calculation body of the estimate loop.
Pure arithmetic: the caller supplies ``phase_result`` (from
:func:`estimate_phase_progress`, or ``None`` to force the linear fallback) and
``ml_pct`` (from :func:`ml_progress_percent`, or ``None``); both the live
manager and the Playground compute those via the same functions, so this is
the single implementation of the smoothing/back-calc. Returns ``None`` when no
profile duration is known (caller clears the estimate). Behavior-identical to
the matched-duration branch of ``manager._update_remaining_only``.
"""
logger = logger or _LOGGER
if not (matched_duration and matched_duration > 0):
return None
# --- PHASE-AWARE ESTIMATION ---
if phase_result is not None:
phase_progress, phase_variance = phase_result
if ml_pct is not None:
w = ML_PROGRESS_BLEND_WEIGHT
phase_progress = (1.0 - w) * phase_progress + w * ml_pct
if prev_smoothed == 0.0:
smoothed = phase_progress
else:
current_smoothed = prev_smoothed
alpha = 0.2 # Default
if phase_variance > 100.0:
alpha = 0.05
logger.debug(
"High variance phase (std=%.1fW), "
"locking time estimate (alpha=0.05)",
phase_variance,
)
elif phase_variance > 50.0:
alpha = 0.1
smoothing_threshold = DEVICE_SMOOTHING_THRESHOLDS.get(device_type, 5.0)
if phase_progress < current_smoothed - smoothing_threshold:
smoothed = (current_smoothed * 0.95) + (phase_progress * 0.05)
logger.debug(
"Progress drop detected (%.1f%% < %.1f%% - %.1f%%), "
"applying heavy damping for %s",
phase_progress,
current_smoothed,
smoothing_threshold,
device_type,
)
else:
smoothed = (prev_smoothed * (1.0 - alpha)) + (phase_progress * alpha)
smoothed = min(99.0, smoothed)
progress = smoothed
remaining = matched_duration * (1.0 - (progress / 100.0))
remaining = max(0.0, remaining)
total = duration_so_far + remaining
logger.debug(
"Phase-aware estimate: raw=%.1f%%, smoothed=%.1f%%, remaining=%smin",
phase_progress,
progress,
int(remaining / 60),
)
return ProgressResult(progress, smoothed, remaining, total, phase_progress, "phase")
# --- LINEAR FALLBACK (if phase analysis unavailable) ---
matched_dur = float(matched_duration)
remaining = max(matched_dur - duration_so_far, 0.0)
progress = (duration_so_far / matched_dur) * 100.0
if ml_pct is not None:
w = ML_PROGRESS_BLEND_WEIGHT
progress = (1.0 - w) * progress + w * ml_pct
remaining = max(matched_dur * (1.0 - progress / 100.0), 0.0)
if prev_smoothed > 0:
smoothed = (prev_smoothed * 0.9) + (progress * 0.1)
else:
smoothed = progress
progress = max(0.0, min(smoothed, 100.0))
remaining = max(matched_dur * (1.0 - progress / 100.0), 0.0)
total = duration_so_far + remaining
logger.debug(
"Linear estimate: remaining=%smin, progress=%.1f%%",
int(remaining / 60),
progress,
)
return ProgressResult(progress, smoothed, remaining, total, None, "linear")
def current_phase(
store: Any,
state: str,
current_program: str | None,
cycle_progress: float,
) -> str | None:
"""Live phase from the profile's configured ranges + ML-blended progress.
Indexed by the smoothed progress fraction rather than raw elapsed seconds, so
overrun/underrun cycles still name the phase correctly. Returns ``None`` when
not running, no profile is matched, or the profile has no configured phase
ranges. Never raises.
"""
try:
if state not in (STATE_RUNNING, STATE_PAUSED, STATE_ENDING):
return None
profile = current_program
if not profile or profile in ("off", "detecting...", "restored...", "none", "unknown"):
return None
ranges = store.get_profile_phase_ranges(profile)
if not ranges:
return None
nominal = max((float(r.get("end") or 0.0) for r in ranges), default=0.0)
if nominal <= 0.0:
return None
frac = max(0.0, min(1.0, float(cycle_progress) / 100.0))
return store.check_phase_match(profile, frac * nominal)
except Exception: # noqa: BLE001 - phase readout must never break
return None
def projected_energy(
store: Any,
options: Any,
matched_duration: float,
trace: list[tuple[datetime, float]],
current_program: str | None,
cycle_progress: float,
energy_so_far: float,
price: float | None,
end_expectation_fn: EndExpFn,
logger: logging.Logger | None = None,
) -> tuple[float | None, float | None]:
"""Project total energy (Wh) and cost for the running cycle.
Prefers the on-device ``total_energy`` regressor; otherwise falls back to
``energy_so_far / progress_fraction``. Returns ``(wh, cost)``; both values are
``None`` when progress is too low or there is no energy yet. Never raises.
"""
logger = logger or _LOGGER
try:
progress = float(cycle_progress or 0.0)
energy_so_far = float(energy_so_far or 0.0)
if progress < PROJECTION_MIN_PROGRESS or energy_so_far <= 0.0:
return None, None
projected_wh = ml_energy_total(
store, options, matched_duration, trace, current_program,
end_expectation_fn, logger,
)
if projected_wh is None:
projected_wh = energy_so_far / (progress / 100.0)
projected_wh = max(projected_wh, energy_so_far)
# A valid price of 0 (free/zero tariff) must yield cost 0.0, not None; only an
# absent or non-numeric price is "unknown".
try:
price_val = float(price)
except (TypeError, ValueError):
price_val = None
cost = (projected_wh / 1000.0) * price_val if price_val is not None else None
return projected_wh, cost
except Exception: # noqa: BLE001 - projection must never break estimates
return None, None
def cycle_anomaly(matched_duration: float, duration_so_far: float) -> tuple[float, str]:
"""Return ``(overrun_ratio, anomaly)`` - the soft runtime overrun signal.
``anomaly`` is ``"overrun"`` once elapsed/expected crosses
``CYCLE_OVERRUN_ANOMALY_RATIO``, else ``"none"``. Never raises.
"""
try:
expected = float(matched_duration or 0.0)
if expected <= 0.0 or duration_so_far <= 0.0:
return 0.0, "none"
ratio = duration_so_far / expected
return ratio, ("overrun" if ratio >= CYCLE_OVERRUN_ANOMALY_RATIO else "none")
except Exception: # noqa: BLE001 - anomaly signal must never break estimates
return 0.0, "none"
+16 -111
View File
@@ -1,3 +1,19 @@
# 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/>.
"""Recorder for raw cycle data in WashData."""
from __future__ import annotations
@@ -13,8 +29,6 @@ from homeassistant.util import dt as dt_util
from .const import (
STORAGE_VERSION,
STORAGE_KEY,
SHORT_SILENCE_THRESHOLD_S,
TRIM_BUFFER_S,
)
from .log_utils import DeviceLoggerAdapter
@@ -206,112 +220,3 @@ class CycleRecorder:
elif not self._last_save:
self.hass.add_job(self._async_save)
def get_trim_suggestions(
self,
data: list[tuple[str, float]],
recording_start: datetime | None = None,
recording_end: datetime | None = None,
) -> tuple[float, float, float]:
"""Analyze data to propose trims.
Args:
data: List of (iso_timestamp, power)
recording_start: Actual start time of recording (for head trim relative to start)
recording_end: Actual end time of recording (for tail trim relative to end)
Returns: (head_trim_seconds, tail_trim_seconds, median_dt)
"""
if not data:
# No data found - return full recording duration as trim
if recording_start and recording_end:
dur = (recording_end - recording_start).total_seconds()
return 0.0, dur, 0.0
return 0.0, 0.0, 0.0
# Parse timestamps and powers
parsed: list[tuple[float, float]] = []
for t_str, p in data:
t = dt_util.parse_datetime(t_str)
if t:
parsed.append((t.timestamp(), p))
if not parsed:
return 0.0, 0.0, 0.0
data_start_ts = parsed[0][0]
data_end_ts = parsed[-1][0]
# Use provided bounds or fallback to data bounds
rec_start_ts = recording_start.timestamp() if recording_start else data_start_ts
rec_end_ts = recording_end.timestamp() if recording_end else data_end_ts
# Ensure bounds cover data
rec_start_ts = min(rec_start_ts, data_start_ts)
rec_end_ts = max(rec_end_ts, data_end_ts)
threshold = 1.0 # W
first_active_idx = -1
last_active_idx = -1
for i, (_, p) in enumerate(parsed):
if p > threshold:
if first_active_idx == -1:
first_active_idx = i
last_active_idx = i
if first_active_idx == -1:
# No activity found
total_dur = rec_end_ts - rec_start_ts
return 0.0, round(total_dur, 1), 0.0
head_ts = parsed[first_active_idx][0]
tail_ts = parsed[last_active_idx][0]
if len(parsed) > 1:
dts = [t - s for (t, _), (s, _) in zip(parsed[1:], parsed[:-1])]
# Median calculation without numpy
dts.sort()
mid = len(dts) // 2
if len(dts) % 2 == 0:
median_dt = (dts[mid - 1] + dts[mid]) / 2.0
else:
median_dt = dts[mid]
if median_dt <= 0:
median_dt = 1.0 # Fallback
else:
median_dt = 1.0
# 1. Head Trim
# Time from recording start to first active sample
raw_head_trim = max(0.0, head_ts - rec_start_ts)
# Align to sampling rate (floor to keep buffer)
# Example: raw=19s, dt=10s -> trim 10s. Buffer=9s.
# Example: raw=21s, dt=10s -> trim 20s. Buffer=1s.
# To ensure we don't cut active sample if jitter:
# We start at rec_start_ts. We want start_time + trim <= head_ts
# floor ensures this.
steps_head = int(raw_head_trim / median_dt)
# Align trim to sampling rate (floor to keep buffer before active sample)
# However, if using the "floor" logic makes it 0, that's fine.
head_trim = steps_head * median_dt
# 2. Tail Trim
# Time from last active sample to recording end
# For manual recordings, we want to be conservative because of drying phases.
raw_tail_trim = max(0.0, rec_end_ts - tail_ts)
# If tail silence is less than SHORT_SILENCE_THRESHOLD_S, suggest 0 trim to be safe.
# Dishwashers often have 5-10 min silent periods that are NOT the end.
if raw_tail_trim < SHORT_SILENCE_THRESHOLD_S:
tail_trim = 0.0
else:
# If it's very long, suggest trimming but keep a TRIM_BUFFER_S buffer
tail_trim = max(0.0, raw_tail_trim - TRIM_BUFFER_S)
steps_tail = int(tail_trim / median_dt)
tail_trim = steps_tail * median_dt
return round(head_trim, 1), round(tail_trim, 1), round(median_dt, 1)
+16 -8
View File
@@ -1,3 +1,19 @@
# 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/>.
"""Select entity for WashData."""
from __future__ import annotations
@@ -54,16 +70,8 @@ class WashDataProgramSelect(SelectEntity):
self._attr_icon = "mdi:tumble-dryer"
elif dtype == "dishwasher":
self._attr_icon = "mdi:dishwasher"
elif dtype == "ev":
self._attr_icon = "mdi:car-electric"
elif dtype == "coffee_machine":
self._attr_icon = "mdi:coffee"
elif dtype == "air_fryer":
self._attr_icon = "mdi:pot-steam"
elif dtype == "heat_pump":
self._attr_icon = "mdi:heat-pump"
elif dtype == "oven":
self._attr_icon = "mdi:stove"
else:
self._attr_icon = "mdi:washing-machine" # Default and washing_machine
+135 -12
View File
@@ -1,3 +1,19 @@
# 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/>.
"""Sensors for WashData."""
from __future__ import annotations
@@ -7,11 +23,15 @@ import hashlib
import logging
from typing import Any
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription, SensorDeviceClass, SensorStateClass
from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
SensorDeviceClass,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.const import EntityCategory
from homeassistant.const import EntityCategory, UnitOfEnergy
from homeassistant.helpers import entity_registry
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -161,6 +181,7 @@ async def async_setup_entry(
WasherDebugSensor(manager, entry),
WasherSuggestionsSensor(manager, entry),
WasherCycleCountSensor(manager, entry),
WasherEnergyTotalSensor(manager, entry),
]
# Add pump-specific sensors
@@ -173,6 +194,7 @@ async def async_setup_entry(
[
WasherMatchConfidenceSensor(manager, entry),
WasherTopCandidatesSensor(manager, entry),
WasherAmbiguitySensor(manager, entry),
]
)
@@ -256,18 +278,10 @@ class WasherStateSensor(WasherBaseSensor):
return "mdi:tumble-dryer"
if dtype == "dishwasher":
return "mdi:dishwasher"
if dtype == "ev":
return "mdi:car-electric"
if dtype == "coffee_machine":
return "mdi:coffee-maker"
if dtype == "air_fryer":
return "mdi:pot-steam"
if dtype == "heat_pump":
return "mdi:heat-pump"
if dtype == "pump":
return "mdi:water-pump"
if dtype == "oven":
return "mdi:stove"
return "mdi:washing-machine"
@property
@@ -283,12 +297,45 @@ class WasherStateSensor(WasherBaseSensor):
}
if self._manager.device_type == DEVICE_TYPE_PUMP:
attrs["pump_stuck"] = self._manager.pump_stuck
# Runtime anomaly signal (visible only; never a notification). Present the
# overrun ratio while a cycle is overrunning its usual duration so users /
# automations can react without a push.
anomaly = self._manager.cycle_anomaly
if anomaly and anomaly != "none":
attrs["cycle_anomaly"] = anomaly
attrs["overrun_ratio"] = round(self._manager.overrun_ratio, 2)
# Post-cycle anomaly data (underrun, energy spike/low) from last completed cycle.
last_post = self._manager.last_cycle_post_anomaly
if isinstance(last_post, dict):
if last_post.get("anomaly") == "underrun":
attrs["last_cycle_anomaly"] = "underrun"
if "underrun_ratio" in last_post:
attrs["last_cycle_underrun_ratio"] = last_post["underrun_ratio"]
if "energy_anomaly" in last_post:
attrs["last_cycle_energy_anomaly"] = last_post["energy_anomaly"]
if "energy_z_score" in last_post:
attrs["last_cycle_energy_z_score"] = last_post["energy_z_score"]
# Surface HA restart gaps recorded during the current cycle so automations
# can see that the power trace has holes (pure metadata, never a notification).
gaps = self._manager.restart_gaps
if gaps:
attrs["ha_restart_gaps"] = len(gaps)
# Predictive-maintenance reminders (E2): event types whose cycle threshold
# has been reached. Automatable by users; never a notification.
maintenance_due = self._manager.maintenance_due
if maintenance_due:
attrs["maintenance_due"] = maintenance_due
return attrs
class WasherProgramSensor(WasherBaseSensor):
"""Sensor for the current program."""
# The reference-profile curve is a live forecast for energy managers; it is
# static per profile and has no historical value, so keep it out of the
# recorder database (still available live via state/templates/WebSocket).
_unrecorded_attributes = frozenset({"reference_profile"})
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
"""Initialize the program sensor."""
self.entity_description = SensorEntityDescription(
@@ -348,6 +395,14 @@ class WasherProgramSensor(WasherBaseSensor):
"phase_catalog": catalog_view,
"phase_ranges": assigned,
}
# Forward-looking reference power curve of the matched profile (issue
# #304): a compact `[[offset_s, watts], ...]` shape energy managers can
# slice by the live progress position to anticipate later load (e.g. a
# heating spike). Only present once a real profile is matched - the
# guard above already returns None while detecting/unmatched/off.
reference = self._manager.profile_store.reference_curve(profile_name)
if reference:
attrs["reference_profile"] = reference
return attrs
@@ -428,6 +483,23 @@ class WasherProgressSensor(WasherBaseSensor):
def native_value(self): # type: ignore[override]
return self._manager.cycle_progress
@property
def extra_state_attributes(self): # type: ignore[override]
"""Expose the live projected total energy/cost for the running cycle.
Derived from accumulated energy and the (ML-blended) progress estimate.
Keys are present only while a projection is available, so the attributes
stay clean when idle or early in a cycle.
"""
attrs: dict[str, float] = {}
projected_wh = self._manager.projected_energy_wh
if projected_wh is not None:
attrs["projected_energy_kwh"] = round(float(projected_wh) / 1000.0, 3)
projected_cost = self._manager.projected_cost
if projected_cost is not None:
attrs["projected_cost"] = round(float(projected_cost), 2)
return attrs or None
class WasherPowerSensor(WasherBaseSensor):
"""Sensor for current power usage."""
@@ -557,6 +629,37 @@ class WasherTopCandidatesSensor(WasherBaseSensor):
return {"candidates": self._manager.top_candidates}
class WasherAmbiguitySensor(WasherBaseSensor):
"""Diagnostic sensor for how ambiguous the last profile match was.
Reports the score margin between the top-1 and top-2 candidates as a
percentage: a small margin means the matcher could not confidently
distinguish the best profile from the runner-up.
"""
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
self.entity_description = SensorEntityDescription(
key="ambiguity",
translation_key="ambiguity",
icon="mdi:help-rhombus-outline",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement="%",
entity_category=EntityCategory.DIAGNOSTIC,
)
super().__init__(manager, entry)
@property
def native_value(self): # type: ignore[override]
margin = self._manager.last_ambiguity_margin
if margin is None:
return None
return round(float(margin) * 100, 1)
@property
def extra_state_attributes(self): # type: ignore[override]
return {"is_ambiguous": self._manager.match_ambiguity}
class WasherCurrentPhaseSensor(WasherBaseSensor):
"""Sensor for the current detected phase."""
@@ -861,4 +964,24 @@ class WasherCycleCountSensor(WasherBaseSensor):
@property
def native_value(self) -> int: # type: ignore[override]
return self._manager.cycle_count
return self._manager.cycle_count
class WasherEnergyTotalSensor(WasherBaseSensor):
"""Lifetime-accumulating energy meter for the HA Energy dashboard."""
def __init__(self, manager: WashDataManager, entry: ConfigEntry) -> None:
self.entity_description = SensorEntityDescription(
key="energy_total",
translation_key="energy_total",
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=3,
icon="mdi:lightning-bolt",
)
super().__init__(manager, entry)
@property
def native_value(self) -> float: # type: ignore[override]
return self._manager.lifetime_energy_kwh
@@ -247,3 +247,39 @@ trim_cycle:
max: 86400
step: 1
mode: box
pause_cycle:
name: Pause Cycle
description: Pause the active cycle so a power drop won't finalize it (optionally cuts switch power).
fields:
device_id:
name: Device
description: The WashData device whose active cycle to pause.
required: true
selector:
device:
integration: ha_washdata
resume_cycle:
name: Resume Cycle
description: Resume a previously paused cycle.
fields:
device_id:
name: Device
description: The WashData device whose cycle to resume.
required: true
selector:
device:
integration: ha_washdata
trigger_ml_training:
name: Trigger ML Training
description: Manually retrain the on-device ML models from this device's own labelled cycles (experimental; requires ML training enabled).
fields:
device_id:
name: Device
description: The WashData device to retrain models for.
required: true
selector:
device:
integration: ha_washdata
@@ -1,3 +1,19 @@
# 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/>.
"""Signal processing primitives for WashData.
Constraint: NumPy only.
@@ -28,12 +44,37 @@ class Segment:
# Future extensibility: might add other channels here
def integrate_wh(timestamps: np.ndarray, power: np.ndarray) -> float:
def energy_gap_threshold_s(timestamps: np.ndarray) -> float:
"""Data-driven gap threshold (seconds) for energy integration.
Ten times the median sample interval, clamped to ``[60, 3600]``. Segments
longer than this are treated as sensor outages and excluded from the energy
sum, without masking valid slow-sampling configurations. Single source for
both persistence paths (``manager._on_cycle_end`` / ``ProfileStore.add_cycle``).
"""
ts = np.asarray(timestamps, dtype=float)
if ts.size < 2:
return 3600.0
intervals = np.diff(np.sort(ts))
positive = intervals[intervals > 0]
median_interval = float(np.median(positive)) if positive.size > 0 else 0.0
return float(np.clip(10.0 * median_interval, 60.0, 3600.0))
def integrate_wh(
timestamps: np.ndarray,
power: np.ndarray,
*,
max_gap_s: float | None = None,
) -> float:
"""Compute energy in Wh using trapezoidal integration.
Args:
timestamps: Array of timestamps in seconds.
timestamps: Array of timestamps in seconds (must be ascending).
power: Array of power values in Watts.
max_gap_s: When set, segments whose ``dt`` exceeds this (or is non-positive)
are excluded, so sensor-outage gaps don't inflate the total. When
``None`` (default) every segment is integrated - the original behaviour.
Returns:
Energy in Watt-hours.
@@ -41,95 +82,19 @@ def integrate_wh(timestamps: np.ndarray, power: np.ndarray) -> float:
if len(timestamps) < 2:
return 0.0
# Calculate dt in hours
# np.diff(timestamps) is in seconds, divide by 3600 for hours
dt_hours = np.diff(timestamps) / 3600.0
# np.diff(timestamps) is in seconds; divide by 3600 for hours.
dt_hours = np.diff(np.asarray(timestamps, dtype=float)) / 3600.0
power = np.asarray(power, dtype=float)
# Trapezoidal rule: (p[i] + p[i+1]) / 2 * dt
avg_power = (power[:-1] + power[1:]) * 0.5
return float(np.sum(avg_power * dt_hours))
if max_gap_s is None:
return float(np.sum(avg_power * dt_hours))
mask = (dt_hours > 0) & (dt_hours <= float(max_gap_s) / 3600.0)
return float(np.sum(avg_power[mask] * dt_hours[mask]))
def robust_smooth(
power: np.ndarray, timestamps: np.ndarray, time_constant_s: float = 30.0
) -> np.ndarray:
"""Apply robust smoothing to power data.
Combines a median filter (spike rejection) with an Exponential Moving Average (EMA).
EMA is calculated using time-weighted alpha to handle irregular jitter.
Args:
power: Array of power values.
timestamps: Array of timestamps in seconds.
time_constant_s: EMA time constant in seconds.
alpha = 1 - exp(-dt / time_constant)
Returns:
Smoothed power array.
"""
if len(power) == 0:
return np.array([])
if len(power) < 3:
return power.copy()
# 1. Median filter (3-point) using pure NumPy
p_med = power.copy()
# Vectorized 3-point median: y[i] = median(x[i-1], x[i], x[i+1])
# Edge handling: repeat values (first and last)
if len(power) >= 3:
# Pad with edge values
p_padded = np.empty(len(power) + 2)
p_padded[0] = power[0]
p_padded[-1] = power[-1]
p_padded[1:-1] = power
# Stack shifted views
# Left neighbor: p_padded[0:-2] -> indices 0..N
# Center: p_padded[1:-1] -> indices 1..N+1 (original)
# Right neighbor: p_padded[2:] -> indices 2..N+2
stack = np.vstack(
[p_padded[0 : len(power)], p_padded[1 : len(power) + 1], p_padded[2:]]
)
# Compute median down columns
p_med = np.median(stack, axis=0)
# 2. Time-aware EMA
# y[i] = alpha * x[i] + (1-alpha) * y[i-1]
# alpha = 1 - exp(-dt / tau)
smoothed = np.zeros_like(p_med, dtype=float)
smoothed[0] = p_med[0]
# We Iterate because alpha changes with dt.
# Vectorization is possible but complex for IIR filter with variable coefs.
# Python loop is fine for typical cycle lengths (points < 10k).
prev_y = p_med[0]
prev_t = timestamps[0]
for i in range(1, len(p_med)):
dt = timestamps[i] - prev_t
if dt <= 0:
# Duplicate or disorderly timestamp, just carry forward
smoothed[i] = prev_y
continue
current_val = p_med[i]
# Adaptive alpha based on dt
alpha = 1.0 - np.exp(-dt / time_constant_s)
# Apply EMA
y = alpha * current_val + (1.0 - alpha) * prev_y
smoothed[i] = y
prev_y = y
prev_t = timestamps[i]
return smoothed
def resample_uniform(
@@ -197,6 +162,44 @@ def resample_uniform(
return segments
def resample_to_n(power: list[float], n: int) -> list[float]:
"""Resample a power trace to exactly *n* evenly-spaced points via linear interpolation.
Works on raw power-value lists (no timestamp required assumes uniform
original spacing). Returns a plain Python list so callers can convert to
NumPy as needed.
Args:
power: Input power values. 2+ points are interpolated; fewer are handled
explicitly (see Returns).
n: Desired number of output points.
Returns:
List of *n* float values, except:
- returns the input unchanged when it already has exactly *n* points;
- returns ``[]`` for non-positive *n* or an empty input (no data to
resample a "missing" marker, not fabricated zeros);
- returns *n* copies of the sole value for a single-sample input.
"""
if len(power) == n:
return list(power)
if n < 1:
return []
src = np.asarray(power, dtype=float)
# An empty trace has no data to resample: return empty (a "missing" marker)
# rather than fabricating n zeros that read as real zero-power samples.
# Callers already guard empty/short input before calling.
if src.size == 0:
return []
# A single sample can only be replicated: return n copies of that value.
if src.size == 1:
return [float(src[0])] * n
src_x = np.linspace(0.0, 1.0, src.size)
dst_x = np.linspace(0.0, 1.0, n)
# Return native Python floats (not np.float64) so callers/JSON get plain floats.
return [float(v) for v in np.interp(dst_x, src_x, src)]
def resample_adaptive(
timestamps: np.ndarray,
power: np.ndarray,
@@ -246,21 +249,3 @@ def resample_adaptive(
return segments, target_dt
def estimate_idle_baseline(power: np.ndarray) -> Tuple[float, float]:
"""Estimate idle baseline level using robust statistics.
Args:
power: Power samples (ideally from a period known or suspected to be IDLE/lower).
If mixed data is passed, the median might be biased if active time > idle time.
Returns:
(baseline_median, baseline_mad)
"""
if len(power) == 0:
return 0.0, 0.0
median = float(np.median(power))
# Median Absolute Deviation
mad = float(np.median(np.abs(power - median)))
return median, mad
+455
View File
@@ -0,0 +1,455 @@
# 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/>.
"""Community-store bridge: gating + provenance + import/share/catalog orchestration.
Pure/near-pure glue between ``store_client`` (network) and ``profile_store`` (local),
plus the integration-wide account/online flag in ``store_account``. The GitHub
connection and the online-features switch are device-agnostic (one per HA install);
brand/model stay per-device. Nothing here runs unless online features are enabled.
"""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.core import HomeAssistant
from . import store_account
from .const import QC_EDITED, QC_MANUAL, QC_RECORDING
from .store_client import StoreClient, device_id, profile_id, trace_hash
_LOGGER = logging.getLogger(__name__)
def online_features_enabled(hass: HomeAssistant) -> bool:
"""True when online store features are enabled integration-wide (default off)."""
return store_account.online_enabled(hass)
# The community catalog only knows washer/dryer/dishwasher/washer_dryer; HA's
# washing_machine device type maps to washer. Keep this in sync with the panel's
# _storeApplianceType() so search, create and share all resolve the same deviceId.
_STORE_APPLIANCE_TYPE = {"washing_machine": "washer"}
def store_appliance_type(device_type: str) -> str:
return _STORE_APPLIANCE_TYPE.get(device_type, device_type)
def derive_qc(cycle: dict[str, Any]) -> int:
"""Derive the obfuscated provenance code for a cycle being uploaded.
QC_RECORDING - a recorder capture. Deliberately takes precedence over ``edited``
(see test_store_provenance.test_recorder_precedence_over_edited): a
trimmed recording is still classed as a recording, since it began as
a clean manual capture.
QC_EDITED - trimmed/edited from a detected cycle.
QC_MANUAL - a plain detected cycle the user flagged golden by hand.
Never raises.
"""
meta = cycle.get("meta") if isinstance(cycle.get("meta"), dict) else {}
if meta.get("source") == "recorder" or "original_samples" in meta:
return QC_RECORDING
if meta.get("edited"):
return QC_EDITED
return QC_MANUAL
def _downsample(points: list[list[float]], max_n: int = 10000) -> list[list[float]]:
"""Downsample a power trace to at most max_n points using LTTB.
LTTB (Largest Triangle Three Buckets) selects the sample in each bucket that
maximises the triangle area formed by the previously-selected point and the
centroid of the next bucket. This preserves peaks and troughs (heater pulses,
pump-out spikes, spin transients) that nearest-index selection can silently drop
when the step size straddles a narrow transient.
"""
n = len(points)
if n <= max_n:
return [[float(p[0]), float(p[1])] for p in points]
if max_n <= 2:
return [[float(points[0][0]), float(points[0][1])],
[float(points[-1][0]), float(points[-1][1])]]
sampled: list[list[float]] = [[float(points[0][0]), float(points[0][1])]]
bucket_count = max_n - 2
bucket_size = (n - 2) / bucket_count
prev_idx = 0
for i in range(bucket_count):
# Current bucket [a, b)
a = int(i * bucket_size) + 1
b = min(int((i + 1) * bucket_size) + 1, n - 1)
# Next bucket centroid (triangle's third vertex)
c = b
d = min(int((i + 2) * bucket_size) + 1, n - 1)
cnt = d - c
if cnt > 0:
avg_x = sum(points[j][0] for j in range(c, d)) / cnt
avg_y = sum(points[j][1] for j in range(c, d)) / cnt
else:
avg_x, avg_y = float(points[-1][0]), float(points[-1][1])
# Select point in [a, b) with the largest triangle area
prev = points[prev_idx]
max_area = -1.0
max_idx = a
for j in range(a, b):
area = abs(
(prev[0] - avg_x) * (points[j][1] - prev[1]) -
(prev[0] - points[j][0]) * (avg_y - prev[1])
) * 0.5
if area > max_area:
max_area = area
max_idx = j
sampled.append([float(points[max_idx][0]), float(points[max_idx][1])])
prev_idx = max_idx
sampled.append([float(points[-1][0]), float(points[-1][1])])
return sampled
def _cycle_upload_stats(cyc: dict[str, Any], pts: list[list[float]]) -> dict[str, Any]:
"""Build the community-upload stats for a cycle from its stored metadata + trace.
``energy_wh`` is emitted only when it is a known positive value: an older cycle
or a recording without energy data has no meaningful figure, and sending 0 would
drag the store's per-program energy average downward. Absent-when-unknown lets the
aggregate ignore it instead. Shared by share_cycle and share_device so both paths
serialize a cycle identically.
"""
vals = [float(p[1]) for p in pts]
stats: dict[str, Any] = {
"duration": float(cyc.get("duration") or (pts[-1][0] - pts[0][0])),
"peak_w": max(vals) if vals else 0.0,
"mean_w": (sum(vals) / len(vals)) if vals else 0.0,
"signature": cyc.get("signature") if isinstance(cyc.get("signature"), dict) else {},
}
try:
energy = float(cyc.get("energy_wh"))
except (TypeError, ValueError):
energy = 0.0
if energy > 0:
stats["energy_wh"] = energy
return stats
class StoreBridge:
"""Orchestrates store browse/import/share/catalog against a ProfileStore.
All methods no-op-safe: they return an ``{"error": ...}`` marker rather than raising.
Callers must gate on ``online_features_enabled`` first. The account/online flag are
global (via ``store_account``); import/share target this bridge's ProfileStore.
"""
def __init__(self, hass: Any, profile_store: Any) -> None:
self._hass = hass
self._ps = profile_store
self._client = StoreClient(hass)
# ── account / status (global) ───────────────────────────────────────────────
def status(self) -> dict[str, Any]:
return {"enabled": store_account.online_enabled(self._hass), **store_account.get_identity(self._hass)}
async def connect(self, refresh_token: str, uid: str, name: str | None) -> dict[str, Any]:
# Validate the refresh token by exchanging it once before persisting.
if not await self._client.ensure_id_token(refresh_token):
return {"error": "token_invalid"}
await store_account.async_set_account(self._hass, {"refresh_token": refresh_token, "uid": uid, "name": name})
return store_account.get_identity(self._hass)
async def disconnect(self) -> dict[str, Any]:
await store_account.async_clear_account(self._hass)
return {"connected": False}
# ── catalog browse (reads) ───────────────────────────────────────────────────
async def list_brands(self, query: str | None = None, include_pending: bool = True) -> list[dict[str, Any]]:
return await self._client.list_brands(query, include_pending=include_pending)
async def search_devices(
self, brand: str | None, appliance_type: str | None,
model_query: str | None = None, include_pending: bool = False,
) -> list[dict[str, Any]]:
return await self._client.search_devices(
brand, appliance_type, model_query=model_query, include_pending=include_pending,
)
async def get_profiles(self, device_id: str) -> list[dict[str, Any]]:
return await self._client.get_profiles(device_id)
async def device_profiles(self, brand: str, model: str, appliance_type: str) -> dict[str, Any]:
"""Profiles for the appliance identified by brand/model/type (for the Share
dialog's profile picker). Maps the HA device type to the catalog type first."""
return await self._client.device_profiles(brand, model, store_appliance_type(appliance_type))
async def get_cycles(self, profile_id: str) -> list[dict[str, Any]]:
return await self._client.get_cycles(profile_id)
async def get_device_quality(self, device_id: str) -> dict[str, Any]:
return await self._client.get_device_quality(device_id)
# ── community actions (authed writes) ────────────────────────────────────────
async def confirm_device(self, device_id: str) -> dict[str, Any]:
acct = store_account.get_account(self._hass)
if not acct.get("refresh_token"):
return {"error": "not_connected"}
res = await self._client.confirm_device(acct["refresh_token"], acct.get("uid", ""), device_id)
return res if res else {"error": "confirm_failed"}
async def rate_device(self, device_id: str, rating: int) -> dict[str, Any]:
acct = store_account.get_account(self._hass)
if not acct.get("refresh_token"):
return {"error": "not_connected"}
ok = await self._client.rate_device(acct["refresh_token"], acct.get("uid", ""), device_id, rating)
return {"ok": True} if ok else {"error": "rate_failed"}
# ── import / share (target this device's ProfileStore) ───────────────────────
async def import_cycle(
self, cycle_id: str, target_profile: str | None = None, new_profile_name: str | None = None
) -> dict[str, Any]:
cyc = await self._client.get_cycle(cycle_id)
if not cyc:
return {"error": "not_found"}
pts = cyc.get("importable")
if not pts:
return {"error": "unsupported_schema"}
# The name comes from the caller (localized) or the store's program label;
# never fall back to an inline English string. Require a non-empty name.
raw_profile = new_profile_name or target_profile or cyc.get("program_lc")
profile = raw_profile.strip() if isinstance(raw_profile, str) else ""
if not profile:
return {"error": "profile_name_required"}
local_id = await self._ps.add_reference_cycle(profile, pts, {
"store_cycle_id": cyc.get("id"),
"store_uploaded_at": cyc.get("createdAt"),
"sampling_interval": (cyc.get("trace") or {}).get("sampleIntervalSec"),
})
if not local_id: # trace failed validation in add_reference_cycle
return {"error": "invalid_trace"}
return {"profile": profile, "cycle_id": local_id}
async def share_cycle(
self, local_cycle_id: str, program: str, brand: str, model: str, appliance_type: str,
sample_interval_sec: float = 0.0, description: str = "",
) -> dict[str, Any]:
acct = store_account.get_account(self._hass)
if not acct.get("refresh_token"):
return {"error": "not_connected"}
pts = self._ps.get_cycle_power_data(local_cycle_id)
if not pts:
return {"error": "cycle_not_found"}
# Look up metadata in BOTH real past_cycles and imported reference_cycles
# (same by_id behavior as share_device) so an imported/reference cycle keeps
# its stored duration/energy/signature instead of falling back to trace-derived.
by_id = {
c.get("id"): c
for c in (list(self._ps.get_reference_cycles()) + list(self._ps.get_past_cycles()))
}
cyc = by_id.get(local_cycle_id, {})
stats = _cycle_upload_stats(cyc, pts)
meta = {
"applianceType": store_appliance_type(appliance_type), "brand": brand, "model": model, "program": program,
"sampleIntervalSec": float(sample_interval_sec or cyc.get("sampling_interval") or 0.0),
"description": description,
}
# LTTB downsampling is O(N) pure Python; offload it so a long trace never
# blocks the event loop while a user shares a cycle.
downsampled = await self._hass.async_add_executor_job(
_downsample, [[p[0], p[1]] for p in pts]
)
new_id = await self._client.upload_reference_cycle(
acct["refresh_token"], acct.get("uid", ""), acct.get("name"),
meta, downsampled, stats, derive_qc(cyc),
)
if not new_id:
return {"error": "upload_failed", "detail": self._client.last_error()}
return {"store_cycle_id": new_id}
async def share_device(
self, brand: str, model: str, appliance_type: str, items: list[dict[str, Any]],
include_phases: list[str] | None = None, settings: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Share a device bundle. ``items`` = ``[{local_cycle_id, program}]`` (the
panel's tree selection). Resolves each local cycle's trace + stats and uploads
the whole set via ``upload_device_bundle``. For each program named in
``include_phases`` that has local phase ranges, the phase map (seconds) is
attached to that program's bundle items so it lands on the store profile doc.
Returns ``{ok, cycle_ids, created, duplicates, errors}`` or ``{error}``.
"""
acct = store_account.get_account(self._hass)
if not acct.get("refresh_token"):
return {"error": "not_connected"}
store_type = store_appliance_type(appliance_type)
want_phases = {str(p).strip() for p in (include_phases or []) if str(p).strip()}
by_id = {
c.get("id"): c
for c in (list(self._ps.get_past_cycles()) + list(self._ps.get_reference_cycles()))
}
bundle_items: list[dict[str, Any]] = []
for it in items or []:
cid = it.get("local_cycle_id")
program = str(it.get("program") or "").strip()
if not cid or not program:
continue
pts = self._ps.get_cycle_power_data(cid)
if not pts:
continue
cyc = by_id.get(cid, {})
# Offload the O(N) LTTB pass per cycle so a large bundle never stalls
# the event loop while sharing.
downsampled = await self._hass.async_add_executor_job(
_downsample, [[p[0], p[1]] for p in pts]
)
bundle_items.append({
"program": program,
"points": downsampled,
"stats": _cycle_upload_stats(cyc, pts),
"qc": derive_qc(cyc),
"sampleIntervalSec": float(cyc.get("sampling_interval") or 0.0),
})
if not bundle_items:
return {"error": "nothing_to_share"}
# Stage 2: attach each requested program's phase map to its items. The store
# cycle id is deterministic (trace_hash), so phaseSourceCycleId can point at
# the program's first shared cycle for the Stage-4 web editor.
d_id = device_id(store_type, brand, model)
for program in want_phases:
ranges = self._ps.get_profile_phase_ranges(program)
if not ranges:
continue
prog_items = [b for b in bundle_items if b["program"] == program]
if not prog_items:
continue
phases = [{"name": r["name"], "start": r["start"], "end": r["end"]} for r in ranges]
source_cid = trace_hash(profile_id(d_id, program), prog_items[0]["points"])
for b in prog_items:
b["phases"] = phases
b["phaseSourceCycleId"] = source_cid
device_meta: dict[str, Any] = {"applianceType": store_type, "brand": brand, "model": model}
# Stage 3: attach the device's recognition/matching settings (already filtered
# to the allow-list by the WS layer, which owns entry.options).
if isinstance(settings, dict) and settings:
device_meta["settings"] = dict(settings)
res = await self._client.upload_device_bundle(
acct["refresh_token"], acct.get("uid", ""), acct.get("name"), device_meta, bundle_items,
)
# Return the raw bundle result ({ok, cycle_ids, errors}) so the caller can
# tell a partial upload (some cycle_ids present) from a total failure.
# Only a pre-flight gate short-circuits with an {"error": ...} marker above.
if not res.get("ok") and not res.get("cycle_ids"):
res = {**res, "detail": self._client.last_error()}
return res
async def download_device(self, device_id_: str, device_type: str = "") -> dict[str, Any]:
"""Adopt a whole-device bundle: for each downloaded profile, import its
reference cycles into ``reference_cycles`` (merge/upsert; real past_cycles are
never touched) and, when the profile carries a phase map, replace the local
profile's phase ranges + reconcile any unknown phase labels into the catalog.
Returns ``{profiles_adopted, cycles_imported, phases_applied, settings}`` where
``settings`` is the bundle's device settings map (the WS layer applies it to
entry.options only when the user opts in; the bridge never touches options).
Idempotent: a store cycle already imported locally (``meta.source ==
"store:<id>"``) is skipped, so re-downloading the same device does not
accumulate duplicate reference cycles.
"""
bundle = await self._client.get_device_bundle(device_id_)
already = {
str((c.get("meta") or {}).get("source") or "")
for c in self._ps.get_reference_cycles()
}
profiles_adopted = 0
cycles_imported = 0
phases_applied = 0
for prof in bundle.get("profiles", []) or []:
program = str(prof.get("program") or prof.get("program_lc") or "").strip()
if not program:
continue
adopted_any = False
for cyc in prof.get("cycles", []) or []:
pts = cyc.get("importable")
if not pts:
continue
store_cid = cyc.get("id")
if store_cid and f"store:{store_cid}" in already:
continue # already imported on a previous download
local_id = await self._ps.add_reference_cycle(program, pts, {
"store_cycle_id": store_cid,
"store_uploaded_at": cyc.get("createdAt"),
"sampling_interval": (cyc.get("trace") or {}).get("sampleIntervalSec"),
})
if local_id:
cycles_imported += 1
adopted_any = True
if adopted_any:
profiles_adopted += 1
# Stage 2: apply the bundled phase map (replace) + reconcile labels. Never
# raises; a bad/overlapping range set is skipped rather than failing adopt.
# Run whenever the profile carries phases -- not gated on new cycles -- so a
# re-download with no new cycles still reconciles updated phase ranges.
if prof.get("phases") and await self._apply_phases(program, prof.get("phases"), device_type):
phases_applied += 1
settings = bundle.get("settings") if isinstance(bundle.get("settings"), dict) else {}
return {
"profiles_adopted": profiles_adopted,
"cycles_imported": cycles_imported,
"phases_applied": phases_applied,
"settings": settings,
}
async def _apply_phases(
self, program: str, phases: Any, device_type: str
) -> bool:
"""Replace ``program``'s local phase ranges with the bundled set and merge any
unknown phase labels into the custom-phase catalog. Returns True when a
non-empty phase map was applied. Never raises."""
if not isinstance(phases, list) or not phases:
return False
ranges: list[dict[str, Any]] = []
for p in phases:
if not isinstance(p, dict):
continue
name = str(p.get("name", "")).strip()
try:
start, end = float(p.get("start", 0)), float(p.get("end", 0))
except (TypeError, ValueError):
continue
if name and end > start:
ranges.append({"name": name, "start": start, "end": end})
if not ranges:
return False
try:
await self._ps.async_set_profile_phase_ranges(program, ranges)
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.debug("download_device: could not apply phases for %s: %s", program, exc)
return False
# Reconcile labels into the catalog so they carry a name/description in the UI.
try:
known = {str(p.get("name", "")).casefold() for p in self._ps.list_phase_catalog(device_type)}
for r in ranges:
if r["name"].casefold() not in known:
try:
await self._ps.async_create_custom_phase(device_type, r["name"])
known.add(r["name"].casefold())
except Exception: # pylint: disable=broad-exception-caught
pass # duplicate / invalid label -> skip
except Exception: # pylint: disable=broad-exception-caught
pass
return True
@@ -0,0 +1,187 @@
# 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/>.
"""Integration-wide (device-agnostic) community-store state.
One GitHub connection and one online-features on/off for the whole HA install,
held in a single domain-scoped Store rather than in any per-device config entry.
The refresh token is a credential: never logged, never put in events, and redacted
in diagnostics (see ``diagnostics._SENSITIVE_KEYS``).
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
from .const import DEFAULT_ENABLE_ONLINE_FEATURES, DOMAIN
_LOGGER = logging.getLogger(__name__)
_STORE_VERSION = 1
_STORE_FILE = f"{DOMAIN}_online"
_DATA_KEY = f"{DOMAIN}_online_cfg"
_LOAD_LOCK_KEY = f"{DOMAIN}_online_load_lock"
# Integration-wide community-store display/behaviour preferences. To add a new
# online setting, add one entry here (key -> default) and one declarative row in the
# panel's _STORE_PREFS list; the generic get_prefs / async_set_prefs / store_set_prefs
# plumbing carries it end-to-end with no further wiring.
_DEFAULT_PREFS: dict[str, Any] = {
"show_contributor": True, # show "by <contributor>" attribution in the pickers
}
def _default() -> dict[str, Any]:
return {
"online_enabled": DEFAULT_ENABLE_ONLINE_FEATURES,
"account": {},
"migrated": False,
"prefs": dict(_DEFAULT_PREFS),
}
async def async_load(hass: HomeAssistant) -> None:
"""Load (once) the global online config + account into hass.data.
Several device config entries set up concurrently at HA startup and each
calls this, so the load is serialized under a lock with a second check
inside it -- otherwise two callers could both pass the guard across the
``await`` and the later one would clobber the first's bucket.
"""
if _DATA_KEY in hass.data:
return
lock = hass.data.setdefault(_LOAD_LOCK_KEY, asyncio.Lock())
async with lock:
if _DATA_KEY in hass.data:
return
store = Store(hass, _STORE_VERSION, _STORE_FILE)
data = _default()
try:
loaded = await store.async_load()
if isinstance(loaded, dict):
data["online_enabled"] = bool(loaded.get("online_enabled", DEFAULT_ENABLE_ONLINE_FEATURES))
data["migrated"] = bool(loaded.get("migrated", False))
if isinstance(loaded.get("account"), dict):
data["account"] = dict(loaded["account"])
# Merge persisted prefs over the defaults, keeping only known keys so
# a stale/removed pref can never linger.
if isinstance(loaded.get("prefs"), dict):
data["prefs"] = {
k: loaded["prefs"].get(k, _DEFAULT_PREFS[k]) for k in _DEFAULT_PREFS
}
except Exception as exc: # noqa: BLE001 - never fail setup over this
_LOGGER.warning("Failed to load online config, using defaults: %s", exc)
hass.data[_DATA_KEY] = {"store": store, "data": data}
def _data(hass: HomeAssistant) -> dict[str, Any]:
bucket = hass.data.get(_DATA_KEY)
return bucket["data"] if bucket else _default()
async def _save(hass: HomeAssistant) -> None:
bucket = hass.data.get(_DATA_KEY)
if bucket and bucket.get("store"):
await bucket["store"].async_save(bucket["data"])
def online_enabled(hass: HomeAssistant) -> bool:
"""True when online features are enabled integration-wide (default off)."""
return bool(_data(hass).get("online_enabled", DEFAULT_ENABLE_ONLINE_FEATURES))
async def async_set_online(hass: HomeAssistant, on: bool) -> None:
await async_load(hass)
_data(hass)["online_enabled"] = bool(on)
# Turning online features off is a full opt-out: drop the stored refresh token
# so a disabled install never leaves a live credential on disk (a later re-enable
# simply reconnects). Explicit disconnect clears it the same way.
if not on:
_data(hass)["account"] = {}
await _save(hass)
def get_prefs(hass: HomeAssistant) -> dict[str, Any]:
"""Integration-wide community-store preferences, defaults filled in."""
stored = _data(hass).get("prefs")
stored = stored if isinstance(stored, dict) else {}
return {k: stored.get(k, _DEFAULT_PREFS[k]) for k in _DEFAULT_PREFS}
def get_pref(hass: HomeAssistant, key: str) -> Any:
"""A single store preference (default if unknown/unset)."""
return get_prefs(hass).get(key, _DEFAULT_PREFS.get(key))
async def async_set_prefs(hass: HomeAssistant, patch: dict[str, Any]) -> dict[str, Any]:
"""Merge a subset of store preferences (only known keys) and persist."""
await async_load(hass)
data = _data(hass)
prefs = data.get("prefs")
prefs = dict(prefs) if isinstance(prefs, dict) else dict(_DEFAULT_PREFS)
for k, v in (patch or {}).items():
if k in _DEFAULT_PREFS:
prefs[k] = bool(v) if isinstance(_DEFAULT_PREFS[k], bool) else v
data["prefs"] = prefs
await _save(hass)
return get_prefs(hass)
def migration_done(hass: HomeAssistant) -> bool:
"""True once the one-time per-device -> global online migration has run."""
return bool(_data(hass).get("migrated", False))
async def async_mark_migrated(hass: HomeAssistant) -> None:
await async_load(hass)
_data(hass)["migrated"] = True
await _save(hass)
def get_account(hass: HomeAssistant) -> dict[str, Any]:
"""Full account incl. the refresh token (credential; internal use only)."""
acct = _data(hass).get("account")
return dict(acct) if isinstance(acct, dict) else {}
def get_identity(hass: HomeAssistant) -> dict[str, Any]:
"""Safe account view for status/UI - never includes the refresh token."""
acct = get_account(hass)
return {"connected": bool(acct.get("refresh_token")), "uid": acct.get("uid"), "name": acct.get("name")}
async def async_set_account(hass: HomeAssistant, account: dict[str, Any]) -> None:
"""Persist the account, replacing any previously stored one.
Both callers (connect / migration hoist) pass a complete account dict, so a
fresh login fully supersedes the old one -- no field from a previous account
(e.g. a stale ``uid``) can survive an account switch. ``None`` values are
dropped so an unset optional (``name``) doesn't overwrite with null.
"""
await async_load(hass)
_data(hass)["account"] = {k: v for k, v in account.items() if v is not None}
await _save(hass)
async def async_clear_account(hass: HomeAssistant) -> None:
await async_load(hass)
_data(hass)["account"] = {}
await _save(hass)
@@ -0,0 +1,802 @@
# 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/>.
"""Async Firestore-REST client for the WashData community store (v2 hierarchy).
Reads (approved brands/devices/profiles/cycles) are public and need no token. Writes
(upload a reference cycle) use the signed-in user's Firebase ID token, obtained by
exchanging the refresh token handed over by the store's connect page.
No Firebase SDK, no new dependency: plain aiohttp via Home Assistant's shared session.
Never raises into the event loop - failures return ``None``/empty and are logged.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import re
import time
import unicodedata
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import (
SHAREABLE_SETTING_KEYS,
STORE_API_KEY,
STORE_PROJECT_ID,
SUPPORTED_CYCLE_SCHEMA_VERSIONS,
)
_LOGGER = logging.getLogger(__name__)
_APPLIANCE_TYPES = {"washer", "dryer", "dishwasher", "washer_dryer"}
# Max concurrent per-cycle rating aggregations when listing a profile's cycles.
_RATING_FANOUT_LIMIT = 8
# Max profiles hydrated concurrently when downloading a whole-device bundle. Each
# profile's get_cycles adds its own (rating) fan-out, so the effective ceiling is
# roughly this x (1 + _RATING_FANOUT_LIMIT); kept small to stay well under the
# store's rate limiter on devices that carry many profiles.
_BUNDLE_HYDRATE_LIMIT = 4
# ── deterministic ids (must match the store's lib/ids.js exactly) ──────────────
def normalize_token(s: Any) -> str:
"""lowercase -> NFKD -> collapse non-alphanumerics to '-' -> trim '-'."""
text = unicodedata.normalize("NFKD", str(s if s is not None else "").lower())
text = re.sub(r"[^a-z0-9]+", "-", text)
return text.strip("-")
def device_id(appliance_type: str, brand: str, model: str) -> str:
return "__".join((normalize_token(appliance_type), normalize_token(brand), normalize_token(model)))
def profile_id(dev_id: str, program: str) -> str:
return f"{dev_id}__{normalize_token(program)}"
def brand_id(brand: str) -> str:
return str(brand or "").lower()
# ── typed-value encode/decode (Firestore REST) ─────────────────────────────────
def _encode(v: Any) -> dict[str, Any]:
if v is None:
return {"nullValue": None}
if isinstance(v, bool):
return {"booleanValue": v}
if isinstance(v, int):
return {"integerValue": str(v)}
if isinstance(v, float):
return {"doubleValue": v}
if isinstance(v, str):
return {"stringValue": v}
if isinstance(v, (list, tuple)):
return {"arrayValue": {"values": [_encode(x) for x in v]}}
if isinstance(v, dict):
return {"mapValue": {"fields": {k: _encode(x) for k, x in v.items()}}}
return {"stringValue": str(v)}
def _decode(v: dict[str, Any]) -> Any:
if "stringValue" in v:
return v["stringValue"]
if "integerValue" in v:
return int(v["integerValue"])
if "doubleValue" in v:
return float(v["doubleValue"])
if "booleanValue" in v:
return v["booleanValue"]
if "nullValue" in v:
return None
if "timestampValue" in v:
return v["timestampValue"]
if "arrayValue" in v:
return [_decode(x) for x in v["arrayValue"].get("values", [])]
if "mapValue" in v:
return {k: _decode(x) for k, x in v["mapValue"].get("fields", {}).items()}
return None
def _decode_doc(doc: dict[str, Any]) -> dict[str, Any]:
out = {k: _decode(x) for k, x in doc.get("fields", {}).items()}
name = doc.get("name", "")
out["id"] = name.rsplit("/", 1)[-1] if "/" in name else name
return out
# Firestore forbids directly-nested arrays, so a trace can't be stored as
# [[offset, watts], ...]. On the wire we store an array of {o, w} maps and convert
# to/from [[offset, watts], ...] pairs at the boundary (matches lib/trace.js).
def pack_points(pairs: list[list[float]]) -> list[dict[str, float]]:
return [{"o": float(p[0]), "w": float(p[1])} for p in pairs if len(p) >= 2]
def unpack_points(points: Any) -> list[list[float]]:
out: list[list[float]] = []
if not isinstance(points, list):
return out
for p in points:
if isinstance(p, dict):
out.append([p.get("o", 0), p.get("w", 0)])
elif isinstance(p, (list, tuple)) and len(p) >= 2:
out.append([p[0], p[1]])
return out
def trace_hash(profile_id_: str, pts: list[list[float]]) -> str:
"""Deterministic content hash for a reference-cycle trace, scoped to its profile.
Used as the store cycle's document id so an identical trace re-uploaded to the
same program collides on the same id and is refused server-side (the create
precondition), making share idempotent. Two DIFFERENT recordings of the same
program hash differently, so genuine multi-instance contributions are preserved.
Offsets are rounded to whole seconds and watts to 1 decimal so trivial float
formatting differences do not change the hash.
"""
norm = [[int(round(float(p[0]))), round(float(p[1]), 1)] for p in pts if len(p) >= 2]
payload = f"{profile_id_}|{json.dumps(norm, separators=(',', ':'))}"
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
class StoreClient:
"""Read/write client for the store. One per manager; safe to keep for the entry."""
_FS = "https://firestore.googleapis.com/v1"
_TOKEN = "https://securetoken.googleapis.com/v1/token"
def __init__(
self,
hass: HomeAssistant,
project_id: str = STORE_PROJECT_ID,
api_key: str = STORE_API_KEY,
session: Any | None = None,
) -> None:
self._hass = hass
self._pid = project_id
self._key = api_key
self._session = session
self._id_token: str | None = None
self._id_token_exp: float = 0.0
self._id_token_rt: str | None = None # refresh token that produced the cached id_token
self._last_error: str | None = None # short reason for the last failed write, for the UI
self._base = f"{self._FS}/projects/{project_id}/databases/(default)/documents"
def last_error(self) -> str | None:
return self._last_error
def _sess(self) -> Any:
if self._session is None:
self._session = async_get_clientsession(self._hass)
return self._session
# ── auth ──────────────────────────────────────────────────────────────────
async def ensure_id_token(self, refresh_token: str) -> str | None:
"""Exchange the refresh token for a (cached) Firebase ID token."""
now = time.time()
# The cache is only valid for the same refresh token that produced it -- after
# a disconnect/reconnect (or a different global account) the previous account's
# token must not be returned even if it is still unexpired.
if (
self._id_token
and self._id_token_rt == refresh_token
and now < self._id_token_exp - 60
):
return self._id_token
try:
async with self._sess().post(
f"{self._TOKEN}?key={self._key}",
data={"grant_type": "refresh_token", "refresh_token": refresh_token},
timeout=15,
) as resp:
if resp.status != 200:
_LOGGER.warning("Store token exchange failed: HTTP %s", resp.status)
self._last_error = f"sign-in expired (HTTP {resp.status}) - reconnect GitHub in the gear"
return None
body = await resp.json()
except Exception as exc: # noqa: BLE001 - never raise into the loop
_LOGGER.warning("Store token exchange error: %s", exc)
self._last_error = "could not reach the sign-in service"
return None
self._id_token = body.get("id_token")
self._id_token_rt = refresh_token
try:
self._id_token_exp = now + float(body.get("expires_in", 3600))
except (TypeError, ValueError):
self._id_token_exp = now + 3600
return self._id_token
# ── reads (public, no token) ────────────────────────────────────────────────
async def _run_query(self, sq: dict[str, Any], parent: str = "") -> list[dict[str, Any]]:
url = f"{self._base}/{parent}:runQuery" if parent else f"{self._base}:runQuery"
try:
async with self._sess().post(url, json={"structuredQuery": sq}, timeout=15) as resp:
if resp.status != 200:
try:
body = await resp.json()
_LOGGER.warning("Store query HTTP %s: %s", resp.status, body)
except Exception:
_LOGGER.warning("Store query HTTP %s (no body)", resp.status)
return []
rows = await resp.json()
except Exception as exc: # noqa: BLE001
_LOGGER.warning("Store query error: %s", exc)
return []
return [_decode_doc(r["document"]) for r in rows if isinstance(r, dict) and "document" in r]
@staticmethod
def _field_filter(field: str, op: str, value: Any) -> dict[str, Any]:
return {"fieldFilter": {"field": {"fieldPath": field}, "op": op, "value": _encode(value)}}
def _where(self, filters: list[dict[str, Any]]) -> dict[str, Any]:
if len(filters) == 1:
return filters[0]
return {"compositeFilter": {"op": "AND", "filters": filters}}
def _status_filter(self, include_pending: bool) -> dict[str, Any]:
"""status == approved, or status IN [approved, pending] when browsing the
community catalog (pending entries are publicly readable, shown with a tag)."""
if include_pending:
return {"fieldFilter": {
"field": {"fieldPath": "status"}, "op": "IN",
"value": _encode(["approved", "pending"]),
}}
return self._field_filter("status", "EQUAL", "approved")
async def search_devices(
self, brand: str | None = None, appliance_type: str | None = None,
model_query: str | None = None, include_pending: bool = False, page_size: int = 60,
) -> list[dict[str, Any]]:
filters = [self._status_filter(include_pending)]
if appliance_type:
filters.append(self._field_filter("applianceType", "EQUAL", appliance_type))
if brand:
filters.append(self._field_filter("brand_lc", "EQUAL", brand.lower()))
sq = {
"from": [{"collectionId": "devices"}],
"where": self._where(filters),
"orderBy": [{"field": {"fieldPath": "favoriteCount"}, "direction": "DESCENDING"}],
"limit": page_size,
}
rows = await self._run_query(sq)
if model_query:
p = model_query.lower()
rows = [r for r in rows if str(r.get("model_lc", "")).startswith(p)]
return rows
async def list_brands(self, q: str | None = None, include_pending: bool = True, page_size: int = 60) -> list[dict[str, Any]]:
sq = {
"from": [{"collectionId": "brands"}],
"where": self._where([self._status_filter(include_pending)]),
"orderBy": [{"field": {"fieldPath": "brand_lc"}, "direction": "ASCENDING"}],
"limit": page_size,
}
rows = await self._run_query(sq)
if q:
p = q.lower()
rows = [r for r in rows if str(r.get("brand_lc", "")).startswith(p)]
return rows
async def get_device(self, device_id: str) -> dict[str, Any] | None:
try:
async with self._sess().get(f"{self._base}/devices/{device_id}", timeout=15) as resp:
if resp.status in (403, 404):
return None
if resp.status != 200:
return None
doc = await resp.json()
except Exception as exc: # noqa: BLE001
_LOGGER.debug("Store get_device error: %s", exc)
return None
return _decode_doc(doc)
async def get_config(self) -> dict[str, Any]:
"""Public config/site (maintenance flag + confirmThreshold). {} on failure."""
try:
async with self._sess().get(f"{self._base}/config/site", timeout=15) as resp:
if resp.status != 200:
return {}
return _decode_doc(await resp.json())
except Exception as exc: # noqa: BLE001
_LOGGER.debug("Store get_config error: %s", exc)
return {}
async def _rating_agg(self, parent_path: str) -> dict[str, Any]:
"""count + average over the `ratings` subcollection under ``parent_path``.
Public (unauthenticated) aggregation -- ratings are world-readable. Returns
``{"avg": float|None, "count": int}`` and never raises.
"""
body = {"structuredAggregationQuery": {
"structuredQuery": {"from": [{"collectionId": "ratings"}]},
"aggregations": [
{"alias": "cnt", "count": {}},
{"alias": "avg", "average": {"field": {"fieldPath": "rating"}}},
],
}}
try:
async with self._sess().post(
f"{self._base}/{parent_path}:runAggregationQuery",
json=body, timeout=15,
) as resp:
if resp.status != 200:
return {"avg": None, "count": 0}
rows = await resp.json()
except Exception as exc: # noqa: BLE001
_LOGGER.debug("Store rating aggregation error (%s): %s", parent_path, exc)
return {"avg": None, "count": 0}
agg = next((r["result"]["aggregateFields"] for r in rows if isinstance(r, dict) and "result" in r), None)
if not agg:
return {"avg": None, "count": 0}
cnt = _decode(agg["cnt"]) if "cnt" in agg else 0
avg = _decode(agg["avg"]) if ("avg" in agg and "nullValue" not in agg["avg"]) else None
return {"avg": avg if (cnt and avg is not None) else None, "count": cnt or 0}
async def get_device_quality(self, device_id: str) -> dict[str, Any]:
"""count + average of the device's 5-star quality ratings (info only)."""
return await self._rating_agg(f"devices/{device_id}")
async def cycle_rating(self, cycle_id: str) -> dict[str, Any]:
"""count + average of a reference cycle's 5-star ratings (info only)."""
return await self._rating_agg(f"cycles/{cycle_id}")
async def get_profiles(self, dev_id: str, include_pending: bool = False, page_size: int = 100) -> list[dict[str, Any]]:
sq = {
"from": [{"collectionId": "profiles"}],
"where": self._where([
self._field_filter("deviceId", "EQUAL", dev_id),
self._status_filter(include_pending),
]),
"orderBy": [{"field": {"fieldPath": "createdAt"}, "direction": "DESCENDING"}],
"limit": page_size,
}
return await self._run_query(sq)
async def device_profiles(self, brand: str, model: str, appliance_type: str) -> dict[str, Any]:
"""Resolve the store deviceId from brand/model/type and return its profiles
(approved + the caller's own pending), for the Share dialog's profile picker."""
dev_id = device_id(appliance_type, brand, model)
items = await self.get_profiles(dev_id, include_pending=True)
return {"device_id": dev_id, "items": items}
async def get_device_bundle(self, dev_id: str, include_pending: bool = True) -> dict[str, Any]:
"""Whole-device package for download: the device's shareable ``settings`` (from
the device doc) + its profiles, each with its reference cycles nested under
``cycles`` (hydrated + rating-summarised by get_cycles). One device GET + one
profiles query + one cycles query per profile. Never raises.
"""
device = await self.get_device(dev_id) or {}
settings = device.get("settings") if isinstance(device.get("settings"), dict) else {}
profiles = await self.get_profiles(dev_id, include_pending=include_pending)
# Bound the per-profile fan-out: each get_cycles issues one query plus a
# rating fan-out, so an unbounded gather over a device with many profiles
# could burst hundreds of concurrent requests and trip the store's rate
# limiter. A shared semaphore caps how many profiles hydrate at once.
sem = asyncio.Semaphore(_BUNDLE_HYDRATE_LIMIT)
async def _cycles_for(p: dict[str, Any]) -> list[dict[str, Any]]:
pid = p.get("id")
if not pid:
return []
async with sem:
return await self.get_cycles(pid, include_pending=include_pending)
# Fetch profiles' cycles concurrently (bounded) rather than one at a time.
cycle_lists = await asyncio.gather(*(_cycles_for(p) for p in profiles))
for p, cycles in zip(profiles, cycle_lists):
p["cycles"] = cycles
return {"device_id": dev_id, "settings": settings, "profiles": profiles}
async def get_cycles(
self, prof_id: str, include_pending: bool = True, page_size: int = 50
) -> list[dict[str, Any]]:
"""Reference cycles for a profile, most-recent-first.
``include_pending`` (default True) also returns still-awaiting-approval
recordings so they can be browsed/imported before the community votes them
in (they are publicly readable, shown with an "awaiting approval" tag).
Each cycle gets a ``rating`` = ``{"avg", "count"}`` summary attached.
"""
sq = {
"from": [{"collectionId": "cycles"}],
"where": self._where([
self._field_filter("profileId", "EQUAL", prof_id),
self._status_filter(include_pending),
]),
"orderBy": [{"field": {"fieldPath": "createdAt"}, "direction": "DESCENDING"}],
"limit": page_size,
}
cycles = [self._with_decoded_trace(c) for c in await self._run_query(sq)]
# Attach each cycle's 5-star rating summary (info-only; the aggregation lives
# in a subcollection so it can't ride the list query). Bound concurrency with
# a semaphore so a large page can't fan out into dozens of simultaneous
# aggregation requests.
sem = asyncio.Semaphore(_RATING_FANOUT_LIMIT)
async def _rate(cyc: dict[str, Any]) -> dict[str, Any]:
cid = cyc.get("id")
if not cid:
return {"avg": None, "count": 0}
async with sem:
return await self.cycle_rating(cid)
summaries = await asyncio.gather(*(_rate(c) for c in cycles), return_exceptions=True)
for cyc, summary in zip(cycles, summaries):
cyc["rating"] = summary if isinstance(summary, dict) else {"avg": None, "count": 0}
return cycles
async def get_cycle(self, cycle_id: str) -> dict[str, Any] | None:
try:
async with self._sess().get(f"{self._base}/cycles/{cycle_id}", timeout=15) as resp:
if resp.status in (403, 404):
return None
if resp.status != 200:
_LOGGER.debug("Store get_cycle HTTP %s", resp.status)
return None
doc = await resp.json()
except Exception as exc: # noqa: BLE001
_LOGGER.debug("Store get_cycle error: %s", exc)
return None
return self._with_decoded_trace(_decode_doc(doc))
@staticmethod
def _with_decoded_trace(cycle: dict[str, Any]) -> dict[str, Any]:
"""Attach ``importable`` = trace points when the cycleSchemaVersion is supported."""
ver = cycle.get("cycleSchemaVersion", 1)
trace = cycle.get("trace")
if ver in SUPPORTED_CYCLE_SCHEMA_VERSIONS and isinstance(trace, dict) and isinstance(trace.get("points"), list):
pairs = unpack_points(trace["points"])
trace["points"] = pairs # hydrate to [[offset, watts]] for the panel sparkline
cycle["importable"] = pairs
else:
cycle["importable"] = None
return cycle
# ── write: upload a reference cycle (authed) ────────────────────────────────
async def _commit_create(self, id_token: str, path: str, fields: dict[str, Any], server_ts_field: str = "createdAt") -> bool:
"""Create-if-missing. Returns True on create OR if it already exists; False on
real failure. Thin wrapper over :meth:`_commit_create_ex` (drops the created flag).
"""
ok, _created = await self._commit_create_ex(id_token, path, fields, server_ts_field)
return ok
async def _commit_create_ex(
self, id_token: str, path: str, fields: dict[str, Any], server_ts_field: str = "createdAt"
) -> tuple[bool, bool]:
"""Create a document if it does not already exist, stamping ``server_ts_field``
with the server request time (so the store rules' ``createdAt == request.time``
holds). Returns ``(ok, created)``: ``created=False`` means the doc already
existed (a benign no-op that supports idempotent re-upload); ``ok=False`` is a
real failure.
"""
write: dict[str, Any] = {
"update": {
"name": f"projects/{self._pid}/databases/(default)/documents/{path}",
"fields": {k: _encode(v) for k, v in fields.items()},
},
"currentDocument": {"exists": False},
"updateTransforms": [
{"fieldPath": server_ts_field, "setToServerValue": "REQUEST_TIME"}
],
}
try:
async with self._sess().post(
f"{self._base}:commit",
json={"writes": [write]},
headers={"Authorization": f"Bearer {id_token}"},
timeout=15,
) as resp:
if resp.status == 200:
return (True, True)
body = await resp.text()
# Precondition failure => the doc already exists; that is fine (no-op).
if resp.status == 409 or "ALREADY_EXISTS" in body or "FAILED_PRECONDITION" in body:
return (True, False)
_LOGGER.warning("Store create %s failed: HTTP %s %s", path, resp.status, body[:300])
coll = path.split("/", 1)[0]
if resp.status == 403 or "PERMISSION_DENIED" in body:
self._last_error = f"{coll} rejected by the store rules (HTTP 403) - the community catalog rules may be out of date"
else:
self._last_error = f"{coll} create failed (HTTP {resp.status})"
return (False, False)
except Exception as exc: # noqa: BLE001
_LOGGER.warning("Store create %s error: %s", path, exc)
self._last_error = f"{path.split('/', 1)[0]} create error: {exc}"
return (False, False)
async def upload_reference_cycle(
self, refresh_token: str, uid: str, uploader_name: str | None, meta: dict[str, Any],
points: list[list[float]], stats: dict[str, Any], qc: int, return_status: bool = False,
) -> str | None | dict[str, Any]:
"""Ensure brand/device/profile docs exist, then create the reference cycle.
The cycle's document id is a deterministic content hash of its trace (scoped
to the profile), so re-uploading an identical trace collides on the same id
and the create is refused server-side -- share is idempotent. Returns the
cycle id (on create OR already-exists), or None on real failure. With
``return_status=True`` returns ``{"id": str|None, "created": bool}`` where
``created=False`` means the trace was already in the store. All writes authed.
"""
def _out(cid: str | None, created: bool) -> str | None | dict[str, Any]:
return {"id": cid, "created": created} if return_status else cid
self._last_error = None
token = await self.ensure_id_token(refresh_token)
if not token:
return _out(None, False)
# Preserve the documented never-raise contract: malformed metadata/points must
# return a failure marker (with _last_error set), not propagate an exception to
# the no-raise StoreBridge caller.
if not isinstance(meta, dict):
self._last_error = "invalid upload metadata"
return _out(None, False)
# Reject (don't coerce) missing/blank required metadata: str(None) -> "None"
# would otherwise pollute the catalog with a literal "None" brand/model/etc.
required: dict[str, str] = {}
for _key in ("applianceType", "brand", "model", "program"):
_val = meta.get(_key)
if not isinstance(_val, str) or not _val.strip():
self._last_error = f"invalid or missing upload metadata: {_key}"
return _out(None, False)
required[_key] = _val.strip()
appliance = required["applianceType"]
brand = required["brand"]
model = required["model"]
program = required["program"]
try:
interval = float(meta.get("sampleIntervalSec") or 0)
except (TypeError, ValueError):
interval = 0.0
if appliance not in _APPLIANCE_TYPES:
_LOGGER.warning("Store upload: invalid applianceType %r", appliance)
self._last_error = f"unsupported appliance type {appliance!r} (only washer/dryer/dishwasher/washer_dryer)"
return _out(None, False)
b_id = brand_id(brand)
d_id = device_id(appliance, brand, model)
p_id = profile_id(d_id, program)
qc_code = qc if qc in (1, 2, 3) else 3
try:
pts = [[float(p[0]), float(p[1])] for p in (points or [])[:10000] if len(p) >= 2]
except (TypeError, ValueError):
self._last_error = "malformed trace points"
return _out(None, False)
if len(pts) < 2:
self._last_error = "empty or too-short trace"
return _out(None, False)
# 1-3: brand/device/profile (create-if-missing; rules deny updating existing).
ok = await self._commit_create(token, f"brands/{b_id}", {
"brand": brand, "brand_lc": b_id, "status": "pending", "createdByUid": uid,
})
device_fields: dict[str, Any] = {
"applianceType": appliance, "brand": brand, "brand_lc": b_id,
"model": model, "model_lc": model.lower(), "status": "pending",
"createdByUid": uid, "createdByName": None, "manualUrl": None,
"favoriteCount": 0, "confirmCount": 0,
}
# Stage 3: bundle the device's recognition/matching settings (allow-listed,
# numeric only) onto the device doc when supplied. Create rule allows extra
# fields, so no rules change; settings attach at create time (owner update is
# Stage 5).
settings = meta.get("settings")
if isinstance(settings, dict) and settings:
# Defense in depth at the store boundary: keep only allow-listed, numeric
# settings (never trust the caller to have filtered) so nothing arbitrary is
# ever written to the shared device doc.
filtered = {
str(k): v for k, v in settings.items()
if k in SHAREABLE_SETTING_KEYS
and isinstance(v, (int, float)) and not isinstance(v, bool)
}
if filtered:
device_fields["settings"] = filtered
ok = ok and await self._commit_create(token, f"devices/{d_id}", device_fields)
profile_fields: dict[str, Any] = {
"deviceId": d_id, "applianceType": appliance, "program": program,
"program_lc": program.lower(), "description": meta.get("description", ""),
"status": "pending", "createdByUid": uid,
}
# Stage 2: bundle the program's phase map onto the profile doc when the caller
# supplies it. The profile create rule allows extra fields, so this needs no
# rules change; phases attach at create time (updating an existing profile's
# phases is an owner action -> Stage 5).
phases = meta.get("phases")
if isinstance(phases, list) and phases:
def _valid_phase(p: Any) -> dict[str, Any] | None:
# Drop a phase with non-numeric start/end rather than coercing it to
# 0.0 (which would ship a bogus zero-length phase to the catalog).
try:
return {"name": str(p.get("name", "")), "start": float(p["start"]), "end": float(p["end"])}
except (KeyError, TypeError, ValueError):
return None
valid_phases = [
vp for vp in (_valid_phase(p) for p in phases if isinstance(p, dict)) if vp is not None
]
if valid_phases:
profile_fields["phases"] = valid_phases
profile_fields["phaseSourceCycleId"] = str(meta.get("phaseSourceCycleId") or "")
profile_fields["phasesSchemaVersion"] = 1
ok = ok and await self._commit_create(token, f"profiles/{p_id}", profile_fields)
if not ok:
return _out(None, False)
# 4: the reference cycle. Its id is a deterministic content hash of the trace
# (scoped to the profile), so an identical re-upload collides on the same id
# and the create precondition refuses it -> idempotent share (no duplicate).
cyc_id = trace_hash(p_id, pts)
cycle_fields = {
"profileId": p_id, "deviceId": d_id, "brand_lc": b_id,
"program_lc": program.lower(), "applianceType": appliance,
"uploaderUid": uid, "uploaderName": uploader_name,
"status": "pending", "rejectionReason": None,
"traceHash": cyc_id,
# Firestore rejects nested arrays -> store points as {o,w} maps.
"trace": {"points": pack_points(pts), "sampleIntervalSec": interval},
"stats": stats if isinstance(stats, dict) else {},
"cycleSchemaVersion": 1, "downloads": 0, "commentCount": 0, "confirmCount": 0, "qc": qc_code,
}
cyc_ok, created = await self._commit_create_ex(token, f"cycles/{cyc_id}", cycle_fields)
if not cyc_ok:
return _out(None, False)
# NB: cycle/profile counts are CALCULATED on the store (COUNT aggregation over
# approved+pending), not maintained as a running total here -- a best-effort
# increment that a rule denied is what left the browse counters stuck at 0.
return _out(cyc_id, created)
async def upload_device_bundle(
self, refresh_token: str, uid: str, uploader_name: str | None,
device_meta: dict[str, Any], items: list[dict[str, Any]],
) -> dict[str, Any]:
"""Upload a whole-device bundle: one item per selected reference cycle.
``device_meta`` = ``{applianceType, brand, model}``; each ``item`` =
``{program, points, stats, qc, sampleIntervalSec}``. Reuses
``upload_reference_cycle`` per item, which idempotently upserts the
brand/device/profile chain (existing ancestors are treated as success) and
creates the cycle. Returns ``{ok, cycle_ids, created, duplicates, errors}``:
``created`` counts newly-uploaded cycles, ``duplicates`` counts ones whose
identical trace was already in the store (both still land in ``cycle_ids``).
Never raises.
"""
cycle_ids: list[str] = []
errors: list[str] = []
created = 0
duplicates = 0
token = await self.ensure_id_token(refresh_token)
if not token:
return {"ok": False, "cycle_ids": [], "created": 0, "duplicates": 0,
"errors": [self._last_error or "not_connected"]}
for it in items or []:
meta = {
"applianceType": device_meta.get("applianceType"),
"brand": device_meta.get("brand"),
"model": device_meta.get("model"),
"program": it.get("program"),
"sampleIntervalSec": it.get("sampleIntervalSec"),
# Stage 2: optional phase map for the profile doc (create-time).
"phases": it.get("phases"),
"phaseSourceCycleId": it.get("phaseSourceCycleId"),
# Stage 3: optional device-level settings (attach to the device doc).
"settings": device_meta.get("settings"),
}
res = await self.upload_reference_cycle(
refresh_token, uid, uploader_name, meta,
it.get("points") or [], it.get("stats") or {}, int(it.get("qc") or 3),
return_status=True,
)
cid = res.get("id") if isinstance(res, dict) else res
if cid:
cycle_ids.append(cid)
if isinstance(res, dict) and res.get("created"):
created += 1
else:
duplicates += 1
else:
errors.append(self._last_error or f"failed to upload {it.get('program')!r}")
return {"ok": not errors, "cycle_ids": cycle_ids,
"created": created, "duplicates": duplicates, "errors": errors}
# ── community catalog: confirm + rate a device (authed) ──────────────────────
async def _commit(self, id_token: str, writes: list[dict[str, Any]]) -> tuple[bool, str]:
"""Post a batched :commit. Returns (ok, response_body_text)."""
try:
async with self._sess().post(
f"{self._base}:commit",
json={"writes": writes},
headers={"Authorization": f"Bearer {id_token}"},
timeout=15,
) as resp:
return (resp.status == 200, await resp.text())
except Exception as exc: # noqa: BLE001
_LOGGER.warning("Store commit error: %s", exc)
return (False, str(exc))
def _doc_path(self, rel: str) -> str:
return f"projects/{self._pid}/databases/(default)/documents/{rel}"
async def confirm_device(self, refresh_token: str, uid: str, device_id: str) -> dict[str, Any] | None:
"""Confirm a device (one per user). Bumps the honest confirmCount in the same
batch that creates confirmations/{uid}, then best-effort promotes to approved
once the threshold is reached (the rule is the real guard). Returns state."""
token = await self.ensure_id_token(refresh_token)
if not token:
return None
dev_path = self._doc_path(f"devices/{device_id}")
conf_path = self._doc_path(f"devices/{device_id}/confirmations/{uid}")
writes = [
{
"update": {"name": conf_path, "fields": {"uid": _encode(uid)}},
"currentDocument": {"exists": False},
"updateTransforms": [{"fieldPath": "createdAt", "setToServerValue": "REQUEST_TIME"}],
},
{
"transform": {
"document": dev_path,
"fieldTransforms": [{"fieldPath": "confirmCount", "increment": _encode(1)}],
},
},
]
ok, body = await self._commit(token, writes)
# A precondition failure means this user already confirmed - not an error.
if not ok and "ALREADY_EXISTS" not in body and "FAILED_PRECONDITION" not in body:
_LOGGER.warning("Store confirm_device failed: %s", body[:200])
return None
dev = await self.get_device(device_id) or {}
count = int(dev.get("confirmCount") or 0)
status = dev.get("status")
try:
threshold = int((await self.get_config()).get("confirmThreshold") or 5)
except (TypeError, ValueError):
threshold = 5
if status == "pending" and count >= threshold:
promote = [{
"update": {"name": dev_path, "fields": {"status": _encode("approved")}},
"updateMask": {"fieldPaths": ["status"]},
"currentDocument": {"exists": True},
}]
if (await self._commit(token, promote))[0]:
status = "approved"
return {"confirmed": True, "confirmCount": count, "status": status}
async def rate_device(self, refresh_token: str, uid: str, device_id: str, rating: int) -> bool:
"""Set this user's 5-star quality rating for a device (info only)."""
if rating not in (1, 2, 3, 4, 5):
return False
token = await self.ensure_id_token(refresh_token)
if not token:
return False
path = self._doc_path(f"devices/{device_id}/ratings/{uid}")
writes = [{
"update": {"name": path, "fields": {"uid": _encode(uid), "rating": _encode(rating)}},
"updateTransforms": [{"fieldPath": "updatedAt", "setToServerValue": "REQUEST_TIME"}],
}]
ok, body = await self._commit(token, writes)
if not ok:
_LOGGER.warning("Store rate_device failed: %s", body[:200])
return ok
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,244 @@
# 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/>.
"""In-memory registry of long-running background tasks (reprocess, ML training,
Playground history/optimize).
Purpose: keep a task's progress, cancel handle and result on the *server* so they
survive a dropped WebSocket (backgrounded tab), can be cancelled, and can be
re-fetched on reconnect. One registry per ``hass``; each task is tagged with the
``entry_id`` it belongs to. No persistence - results live for the session and the
last few finished tasks are retained for reload.
Pure asyncio + synchronous listener callbacks; the WebSocket layer registers a
listener to push updates and calls :func:`get_registry` to read/kick/cancel.
"""
from __future__ import annotations
import logging
import uuid
from collections import OrderedDict
from dataclasses import dataclass, field
from typing import Any, Callable
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from .const import DOMAIN
# Keep this many *finished* tasks (with their results) around for reload; older
# ones are evicted. Running tasks are never evicted.
_MAX_FINISHED = 30
_REGISTRY_KEY = f"{DOMAIN}_task_registry"
# Task lifecycle states.
STATE_RUNNING = "running"
STATE_DONE = "done"
STATE_ERROR = "error"
STATE_CANCELLED = "cancelled"
@dataclass
class Task:
"""A single tracked background operation."""
id: str
entry_id: str
kind: str # 'reprocess' | 'ml_training' | 'pg_history' | 'pg_sweep'
label: str # English fallback shown only if no label_key resolves
# Panel-localizable label: the pill renders _t(label_key, label_params, label)
# so per-step progress text is translated. When label_key is None the pill
# falls back to a per-kind translated action label.
label_key: str | None = None
label_params: dict[str, Any] = field(default_factory=dict)
total: int = 0
done: int = 0
state: str = STATE_RUNNING
error: str | None = None
started_at: float = field(default_factory=lambda: dt_util.now().timestamp())
updated_at: float = field(default_factory=lambda: dt_util.now().timestamp())
finished_at: float | None = None
result: Any = None
_cancelled: bool = False
@property
def cancel_requested(self) -> bool:
return self._cancelled
def progress(self) -> float | None:
"""Fraction complete in [0, 1], or None when total is unknown."""
if self.total <= 0:
return None
return max(0.0, min(1.0, self.done / self.total))
def eta_s(self) -> float | None:
"""Rough seconds-to-completion from elapsed time and progress."""
p = self.progress()
if not p or p <= 0 or self.state != STATE_RUNNING:
return None
elapsed = self.updated_at - self.started_at
if elapsed <= 0:
return None
return max(0.0, elapsed * (1.0 - p) / p)
def snapshot(self, include_result: bool = False) -> dict[str, Any]:
"""JSON-safe view for the WS layer. ``include_result`` embeds the payload."""
data: dict[str, Any] = {
"id": self.id,
"entry_id": self.entry_id,
"kind": self.kind,
"label": self.label,
"label_key": self.label_key,
"label_params": self.label_params,
"state": self.state,
"done": self.done,
"total": self.total,
"progress": self.progress(),
"eta_s": self.eta_s(),
"started_at": self.started_at,
"updated_at": self.updated_at,
"finished_at": self.finished_at,
"error": self.error,
"has_result": self.result is not None,
}
if include_result:
data["result"] = self.result
return data
class TaskRegistry:
"""Holds active + recently-finished tasks and notifies listeners on change."""
def __init__(self) -> None:
self._tasks: OrderedDict[str, Task] = OrderedDict()
self._listeners: set[Callable[[dict[str, Any]], None]] = set()
# -- listeners -----------------------------------------------------------
def add_listener(self, cb: Callable[[dict[str, Any]], None]) -> Callable[[], None]:
"""Register a change callback; returns an unsubscribe function."""
self._listeners.add(cb)
return lambda: self._listeners.discard(cb)
def _notify(self, task: Task) -> None:
snap = task.snapshot()
for cb in list(self._listeners):
try:
cb(snap)
except Exception: # pylint: disable=broad-exception-caught
logging.getLogger(__name__).debug("Task registry listener error", exc_info=True)
# -- lifecycle -----------------------------------------------------------
def create(
self,
entry_id: str,
kind: str,
label: str,
total: int = 0,
*,
label_key: str | None = None,
label_params: dict[str, Any] | None = None,
) -> Task:
task = Task(
id=uuid.uuid4().hex[:12],
entry_id=entry_id,
kind=kind,
label=label,
label_key=label_key,
label_params=dict(label_params) if label_params else {},
total=max(0, int(total or 0)),
)
self._tasks[task.id] = task
self._notify(task)
self._evict()
return task
def update(
self,
task: Task,
*,
done: int | None = None,
total: int | None = None,
label: str | None = None,
label_key: str | None = None,
label_params: dict[str, Any] | None = None,
) -> None:
if done is not None:
task.done = done
if total is not None:
task.total = total
if label is not None:
task.label = label
# A supplied label_key replaces the localized label; passing label without
# label_key (legacy callers) clears any stale key so the fallback shows.
if label_key is not None or label is not None:
task.label_key = label_key
task.label_params = dict(label_params) if label_params else {}
task.updated_at = dt_util.now().timestamp()
self._notify(task)
def finish(
self,
task: Task,
*,
state: str = STATE_DONE,
result: Any = None,
error: str | None = None,
) -> None:
task.state = state
task.error = error
if result is not None:
task.result = result
task.finished_at = task.updated_at = dt_util.now().timestamp()
self._notify(task)
self._evict()
def cancel(self, task_id: str) -> bool:
"""Request cancellation of a running task. Consumers poll
:attr:`Task.cancel_requested` between chunks. Returns True if a running
task was flagged."""
task = self._tasks.get(task_id)
if task is not None and task.state == STATE_RUNNING:
task._cancelled = True # noqa: SLF001 - registry owns the flag
return True
return False
# -- reads ---------------------------------------------------------------
def get(self, task_id: str) -> Task | None:
return self._tasks.get(task_id)
def snapshot(self, entry_id: str | None = None) -> list[dict[str, Any]]:
return [
t.snapshot()
for t in self._tasks.values()
if entry_id is None or t.entry_id == entry_id
]
def _evict(self) -> None:
finished = sorted(
[t for t in self._tasks.values() if t.state != STATE_RUNNING],
key=lambda t: t.finished_at or 0.0,
)
while len(finished) > _MAX_FINISHED:
self._tasks.pop(finished.pop(0).id, None)
def get_registry(hass: HomeAssistant) -> TaskRegistry:
"""Get (or lazily create) the per-hass task registry."""
reg = hass.data.get(_REGISTRY_KEY)
if not isinstance(reg, TaskRegistry):
reg = TaskRegistry()
hass.data[_REGISTRY_KEY] = reg
return reg
@@ -1,3 +1,19 @@
# 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/>.
"""Unified time/power-data utilities for WashData.
Canonical storage format for power_data: ``[[offset_seconds, power], ...]``
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More