"""Learning and self-tuning logic for WashData.""" from __future__ import annotations import logging from datetime import datetime from typing import Any, Optional, TYPE_CHECKING, cast 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, DEFAULT_AUTO_LABEL_CONFIDENCE, DEFAULT_DURATION_TOLERANCE, DEFAULT_LEARNING_CONFIDENCE, DEFAULT_SUPPRESS_FEEDBACK_NOTIFICATIONS, DOMAIN, SIGNAL_WASHER_UPDATE, ) from .suggestion_engine import SuggestionEngine from .log_utils import DeviceLoggerAdapter if TYPE_CHECKING: from .profile_store import ProfileStore _LOGGER = logging.getLogger(__name__) class StatisticalModel: """Helper to track running stats for a metric.""" def __init__(self, max_samples: int = 200) -> None: self._samples: list[float] = [] self._max_samples = max_samples self._last_update: datetime | None = None self._stats: dict[str, Any] = {"median": None, "p95": None, "count": 0} def add_sample(self, value: float, now: datetime) -> None: """Add a sample and update stats.""" self._samples.append(value) if len(self._samples) > self._max_samples: self._samples = self._samples[-self._max_samples:] self._last_update = now self._compute_stats() def _compute_stats(self) -> None: if not self._samples: self._stats = {"median": None, "p95": None, "count": 0} return arr = np.array(self._samples) self._stats = { "median": float(np.median(arr)), "p95": float(np.percentile(arr, 95)), "count": int(len(self._samples)), } @property def median(self) -> float | None: """Return the median of samples.""" return self._stats.get("median") @property def p95(self) -> float | None: """Return the 95th percentile of samples.""" return self._stats.get("p95") @property def count(self) -> int: """Return the number of samples.""" return self._stats.get("count", 0) class LearningManager: """Manages cycle learning, user feedback, and auto-tuning.""" def __init__( self, hass: HomeAssistant, entry_id: str, profile_store: "ProfileStore", device_type: str | None = None, device_name: str = "", ) -> None: """Initialize the learning manager.""" self._logger = DeviceLoggerAdapter(_LOGGER, device_name) self.hass = hass self.entry_id = entry_id self.profile_store = profile_store self.device_type = device_type self.suggestion_engine = SuggestionEngine( hass, entry_id, profile_store, device_type ) # Operational Stats self._sample_interval_model = StatisticalModel(max_samples=200) self._last_suggestion_update: datetime | None = None 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.""" 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. entry = self.hass.config_entries.async_get_entry(self.entry_id) current_options: dict[str, Any] = {} if entry: current_options = {**entry.data, **entry.options} filtered_suggestions: dict[str, Any] = {} for key, data in suggestions.items(): if isinstance(data, dict) and "value" in data: current_val = current_options.get(key) suggested_val = data["value"] if current_val is not None and suggested_val is not None: try: if float(current_val) == float(suggested_val): self.profile_store.delete_suggestion(key) continue # already applied, remove stale entry except (TypeError, ValueError): pass filtered_suggestions[key] = data 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: """Ingest power reading metadata for statistical analysis.""" if last_reading_time: delta = (now - last_reading_time).total_seconds() # Ignore ultra-small jitter (<0.1s) and massive gaps (>1800s - likely downtime) if 0.1 < delta < 1800: self._sample_interval_model.add_sample(delta, now) # Periodically update suggestions based on operational stats if ( self._last_suggestion_update is None or (now - self._last_suggestion_update).total_seconds() > 300 # Check every 5 mins ): self._update_operational_suggestions(now) def process_cycle_end( self, cycle_data: dict[str, Any], detected_profile: str | None = None, confidence: float = 0.0, predicted_duration: float | None = None, match_result: Any | None = None, ) -> None: """Analyze completed cycle for learning. Args: cycle_data: Completed cycle data detected_profile: Profile name detected confidence: Match confidence score (0.0-1.0) predicted_duration: Expected duration in seconds match_result: MatchResult from profile_store.async_match_profile() (optional) """ # 1. Trigger background simulation to find optimal parameters for this cycle if cycle_data.get("power_data"): # Offload to executor since simulation can be heavy self.hass.async_create_task(self._async_run_simulation(cycle_data)) # 2. Check if we should request feedback self._maybe_request_feedback( cycle_data, detected_profile, confidence, predicted_duration, match_result ) # 3. Update model-based suggestions (durations etc) self._update_model_suggestions(dt_util.now()) # 4. Run multi-cycle batch simulation when enough new labeled cycles have accumulated self._maybe_run_batch_simulation() def _maybe_run_batch_simulation(self) -> None: """Schedule a batch simulation when enough new labeled cycles have arrived.""" _BATCH_MIN = 5 _BATCH_RERUN_DELTA = 5 # Re-run every 5 new labeled cycles labeled_cycles = [ c for c in self.profile_store.get_past_cycles() if isinstance(c, dict) and c.get("profile_name") and c.get("profile_name") != "noise" and c.get("power_data") and c.get("status") in ("completed", "force_stopped") ] current_count = len(labeled_cycles) if current_count < _BATCH_MIN: return if (current_count - self._last_batch_simulation_count) < _BATCH_RERUN_DELTA: return self._last_batch_simulation_count = current_count self.hass.async_create_task(self._async_run_batch_simulation(labeled_cycles, current_count)) async def _async_run_batch_simulation(self, cycles: list[dict[str, Any]], expected_count: int) -> None: """Run multi-cycle batch simulation asynchronously.""" try: new_suggestions = await self.hass.async_add_executor_job( self.suggestion_engine.run_batch_simulation, cycles ) if new_suggestions: self._apply_suggestions_and_notify(new_suggestions) self._logger.debug( "Batch simulation (%d cycles) produced suggestions: %s", len(cycles), list(new_suggestions.keys()), ) except Exception as e: # pylint: disable=broad-exception-caught self._logger.error("Batch simulation failed: %s", e) async def _async_run_simulation(self, cycle_data: dict[str, Any]) -> None: """Run simulation asynchronously.""" try: # Simulation runner derives optimal thresholds # Offload to executor since simulation can be heavy (CPU bound) new_suggestions = await self.hass.async_add_executor_job( self.suggestion_engine.run_simulation, cycle_data ) if new_suggestions: self._apply_suggestions_and_notify(new_suggestions) self._logger.debug("Post-cycle simulation completed with suggestions: %s", new_suggestions.keys()) except Exception as e: self._logger.error("Background simulation failed: %s", e) def _update_operational_suggestions(self, now: datetime) -> None: """Generate suggestions for operational parameters (intervals, timeouts).""" if self._sample_interval_model.count < 20: return p95 = self._sample_interval_model.p95 median = self._sample_interval_model.median if p95 is None or median is None: return suggestions = self.suggestion_engine.generate_operational_suggestions(p95, median) self._apply_suggestions_and_notify(suggestions) self._last_suggestion_update = now 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) async def _async_send_suggestions_ready_notification( self, device_title: str, suggestions_count: int ) -> None: """Send a one-time persistent notification when suggestions become available.""" try: notification_id = f"ha_washdata_suggestions_ready_{self.entry_id}" translations = await translation.async_get_translations( self.hass, self.hass.config.language, "options", {DOMAIN} ) 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." ) title_template = translations.get( f"component.{DOMAIN}.options.error.suggestions_ready_notification_title", default_title, ) 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()) def _maybe_request_feedback( self, cycle_data: dict[str, Any], detected_profile: str | None, confidence: float, predicted_duration: float | None, match_result: Any | None = None, ) -> None: """Check if feedback should be requested for this completed cycle.""" if ( not predicted_duration or not detected_profile or detected_profile in ("off", "detecting...") ): # No match was made, don't request feedback return # Get the cycle ID from the cycle_data cycle_id = cycle_data.get("id") if not cycle_id: self._logger.warning("Cycle data missing ID, cannot request feedback") return # Get Configured Thresholds entry = self.hass.config_entries.async_get_entry(self.entry_id) if not entry: return auto_label_conf = entry.options.get( CONF_AUTO_LABEL_CONFIDENCE, DEFAULT_AUTO_LABEL_CONFIDENCE ) learning_conf = entry.options.get( CONF_LEARNING_CONFIDENCE, DEFAULT_LEARNING_CONFIDENCE ) duration_tol = entry.options.get( CONF_DURATION_TOLERANCE, DEFAULT_DURATION_TOLERANCE ) # Auto-label if very high 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, ) 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 if confidence < learning_conf: self._logger.debug( "Skipping feedback for low-confidence match (conf=%.2f < %.2f)", confidence, learning_conf, ) return actual_duration = cycle_data.get("duration", 0) # Request feedback via learning manager for moderate confidence self.request_cycle_verification( cycle_id=cycle_id, detected_profile=detected_profile, confidence=confidence, estimated_duration=predicted_duration, actual_duration=actual_duration, duration_tolerance=duration_tol, match_result=match_result, ) # Persist pending feedback request so it survives restart 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, detected_profile: Optional[str], confidence: float, estimated_duration: Optional[float], actual_duration: float, duration_tolerance: float = 0.10, match_result: Any | None = None, ) -> None: """Request user verification for a detected cycle.""" duration_match_pct = ( (actual_duration / estimated_duration * 100) if estimated_duration else 0 ) tolerance_pct = duration_tolerance * 100 is_close_match = ( estimated_duration and abs(duration_match_pct - 100) <= tolerance_pct ) # Extract match ranking from MatchResult if available (for UI visualization) ranking_summary: list[dict[str, Any]] = [] if match_result and hasattr(match_result, "ranking") and match_result.ranking: for cand in match_result.ranking[:5]: # Store top 5 try: ranking_summary.append({ "name": cand.get("name", "Unknown"), "score": float(cand.get("score", 0.0)), "metrics": cand.get("metrics", {}), "profile_duration": float(cand.get("profile_duration", 0.0)), }) except (TypeError, ValueError, KeyError, AttributeError): continue feedback_req: dict[str, Any] = { "cycle_id": cycle_id, "detected_profile": detected_profile, "confidence": confidence, "estimated_duration": estimated_duration, "actual_duration": actual_duration, "duration_match_pct": duration_match_pct, "is_close_match": is_close_match, "created_at": dt_util.now().isoformat(), "user_response": None, "expires_at": None, "ranking": ranking_summary, # Top candidates for UI display } self.profile_store.add_pending_feedback(cycle_id, feedback_req) est_min = int(estimated_duration / 60) if estimated_duration else 0 self._logger.info( "Feedback requested for cycle %s: profile='%s' (conf=%.2f), " "est=%smin, actual=%smin (%.0f%%)", cycle_id, detected_profile, confidence, est_min, int(actual_duration / 60), duration_match_pct, ) def auto_label_high_confidence( self, cycle_id: str, profile_name: str, confidence: float, confidence_threshold: float, ) -> bool: """Auto-label a cycle with high confidence.""" if confidence < confidence_threshold: return False # Reuse existing internal logic self._auto_label_cycle(cycle_id, profile_name) # 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) return bool(cycle and cycle.get("auto_labeled")) async def async_submit_cycle_feedback( self, cycle_id: str, user_confirmed: bool, corrected_profile: Optional[str] = None, corrected_duration: Optional[float] = None, notes: str = "", dismiss: bool = False, ) -> bool: """Submit user feedback for a cycle.""" pending = self.profile_store.get_pending_feedback().get(cycle_id) if not pending: return False # Parse corrected_duration before writing to history so a bad value # never leaves a partially-applied state. duration_sec: float | None = None if corrected_duration is not None: try: duration_sec = float(corrected_duration) except (TypeError, ValueError): self._logger.warning( "Invalid corrected_duration %r for cycle %s, ignoring", corrected_duration, cycle_id, ) feedback_record: dict[str, Any] = { "cycle_id": cycle_id, "original_detected_profile": pending["detected_profile"], "original_confidence": pending["confidence"], "user_confirmed": user_confirmed, "corrected_profile": corrected_profile, "corrected_duration": duration_sec, "notes": notes, "submitted_at": dt_util.now().isoformat(), } self.profile_store.get_feedback_history()[cycle_id] = feedback_record # Track which profiles need envelope rebuild (issue #131) profiles_to_rebuild: set[str] = set() if dismiss: # Just dismiss, no action pass elif user_confirmed: profile_name = pending.get("detected_profile") if isinstance(profile_name, str) and profile_name: 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) if confirmed_cycle: confirmed_cycle["duration"] = duration_sec profiles_to_rebuild.add(profile_name) else: # Correction path: only use corrected_profile when user_confirmed is False. # Duration-only corrections (no profile specified) are handled by the elif branch below. target_profile = corrected_profile detected_profile_name = pending.get("detected_profile") if isinstance(target_profile, str) and target_profile: self._apply_correction_learning( cycle_id, target_profile, duration_sec ) profiles_to_rebuild.add(target_profile) if ( isinstance(detected_profile_name, str) and detected_profile_name and detected_profile_name != target_profile ): profiles_to_rebuild.add(detected_profile_name) elif duration_sec is not None: # No valid profile could be determined, but a duration correction was # 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) if cycle_to_fix: cycle_to_fix["duration"] = duration_sec cycle_to_fix["manual_duration"] = duration_sec existing_profile = cycle_to_fix.get("profile_name") if isinstance(existing_profile, str) and existing_profile: profiles_to_rebuild.add(existing_profile) else: self._logger.warning( "Duration correction skipped: cycle %s not found in past_cycles", cycle_id, ) # Remove from pending (add_pending_feedback was wrapper, remove is direct) if cycle_id in self.profile_store.get_pending_feedback(): del self.profile_store.get_pending_feedback()[cycle_id] # Rebuild envelopes for all modified profiles to recalculate min/max/avg (issue #131) for profile_name in profiles_to_rebuild: try: await self.profile_store.async_rebuild_envelope(profile_name) except Exception as e: # pylint: disable=broad-exception-caught self._logger.error("Failed to rebuild envelope for profile '%s': %s", profile_name, e) # Persist changes await self.profile_store.async_save() # Trigger UI and sensor refresh (Issue #155) async_dispatcher_send(self.hass, f"ha_washdata_update_{self.entry_id}") return True 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) if cycle: cycle["profile_name"] = profile_name cycle["auto_labeled"] = True if manual_duration: cycle["manual_duration"] = manual_duration def _apply_correction_learning( self, cycle_id: str, corrected_profile: str, corrected_duration: Optional[float] = None, ) -> None: """Apply user correction to a cycle (fix for issue #131). Note: We do not update avg_duration here with EMA. Instead, the envelope rebuild in async_submit_cycle_feedback() will recalculate all statistics (min/max/avg) from labeled cycles, ensuring accuracy. """ 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) if cycle: cycle["duration"] = corrected_duration # Profile stats will be recalculated when envelope is rebuilt async def _async_rebuild_profile_envelope(self, profile_name: str) -> None: """Async helper to rebuild a profile's envelope (issue #131 fix). This wraps async_rebuild_envelope with error handling for safe task scheduling. """ try: await self.profile_store.async_rebuild_envelope(profile_name) self._logger.debug("Rebuilt envelope for profile '%s'", profile_name) except Exception as e: # pylint: disable=broad-exception-caught self._logger.error("Failed to rebuild envelope for profile '%s': %s", profile_name, e) async def _async_rebuild_and_save_profile(self, detected_profile: str) -> None: """Rebuild profile envelope then persist in deterministic order.""" await self._async_rebuild_profile_envelope(detected_profile) await self.profile_store.async_save() def get_pending_feedback(self) -> dict[str, dict[str, Any]]: """Return pending feedback requests.""" return dict(self.profile_store.get_pending_feedback()) def get_feedback_history(self, limit: int = 20) -> list[dict[str, Any]]: """Return submitted feedback history.""" items = list(self.profile_store.get_feedback_history().values()) items.sort(key=lambda x: x.get("submitted_at", ""), reverse=True) return items[:limit]