Added Alexa Music
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user