Added Alexa Music
This commit is contained in:
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
@@ -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 ~6–12 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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
@@ -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 (0–1): 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
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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], ...]``
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
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
Reference in New Issue
Block a user