# 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 . """Learning and self-tuning logic for WashData.""" from __future__ import annotations import asyncio import logging from datetime import datetime from collections.abc import Callable from typing import Any, Optional, TYPE_CHECKING import numpy as np from homeassistant.core import HomeAssistant 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_LEARNING_CONFIDENCE, CONF_PROFILE_MIN_WARMUP_CYCLES, DEFAULT_AUTO_LABEL_CONFIDENCE, DEFAULT_DURATION_TOLERANCE, DEFAULT_LEARNING_CONFIDENCE, MIN_SUGGESTION_COOLDOWN_CYCLES, MIN_SUGGESTION_REL_DELTA, ML_QUALITY_SUSPICIOUS_THRESHOLD, ) from .suggestion_engine import SuggestionEngine from .log_utils import DeviceLoggerAdapter if TYPE_CHECKING: from .profile_store import ProfileStore _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.""" 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 self._last_suggestions_labeled_count: int = 0 # gate model/detection passes def _apply_suggestions_and_notify(self, suggestions: dict[str, Any]) -> None: """Apply suggestions that pass quality gates.""" if not suggestions: return # 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: current_val = current_options.get(key) suggested_val = data["value"] if current_val is not None and suggested_val is not None: try: 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 # 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 if not filtered_suggestions: return self.suggestion_engine.apply_suggestions(filtered_suggestions) 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 single-cycle simulation — only for cleanly-completed, labeled, # non-noise cycles. Skipping force_stopped/unlabeled/noise avoids deriving # start/stop thresholds from mis-detected or truncated cycles. _profile = detected_profile or cycle_data.get("profile_name") _is_clean = ( cycle_data.get("power_data") and _profile and _profile != "noise" and cycle_data.get("status") == "completed" ) if _is_clean: 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+3b. Heavy per-profile suggestion passes — only run when the labeled # cycle count has grown since the last update (skips passes for unlabeled / # noise / duplicate ends with no new data). labeled_count = sum( 1 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("status") in ("completed", "force_stopped") ) if labeled_count > self._last_suggestions_labeled_count: self._last_suggestions_labeled_count = labeled_count self._update_model_suggestions() self._update_detection_suggestions() # 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)) 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( 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). 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 p95 = self._sample_interval_model.p95 median = self._sample_interval_model.median if p95 is None or median is None: return # 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) -> None: """Generate suggestions for model parameters (tolerances, ratios). 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: """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: 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)) 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) 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": }``. """ 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 ) 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 ) 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, 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 ) # 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: _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 _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, ) # 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) # 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") # Use float() so numpy scalars (float32/float64) returned by resolve_scorer # are accepted — isinstance(numpy_float, float) is False in NumPy ≥ 2.0. # Wrap in try/except so non-numeric sentinel values are silently ignored. try: ml_suspicious = ( ml_quality is not None and float(ml_quality) >= ML_QUALITY_SUSPICIOUS_THRESHOLD ) except (TypeError, ValueError): ml_suspicious = False # 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") try: envelope_suspicious = ( _conformance is not None and float(_conformance) < 0.40 ) except (TypeError, ValueError): envelope_suspicious = False 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, 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. # 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()) 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.get("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.get("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.get("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.get("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.get("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]