"""Suggestion engine for WashData.""" from __future__ import annotations import logging from datetime import datetime from typing import Any, TYPE_CHECKING, cast import numpy as np from homeassistant.core import HomeAssistant from .const import ( CONF_WATCHDOG_INTERVAL, CONF_NO_UPDATE_ACTIVE_TIMEOUT, CONF_OFF_DELAY, CONF_PROFILE_MATCH_INTERVAL, CONF_PROFILE_MATCH_MAX_DURATION_RATIO, CONF_PROFILE_MATCH_MIN_DURATION_RATIO, CONF_DURATION_TOLERANCE, CONF_PROFILE_DURATION_TOLERANCE, CONF_START_THRESHOLD_W, CONF_STOP_THRESHOLD_W, CONF_END_ENERGY_THRESHOLD, CONF_RUNNING_DEAD_ZONE, CONF_MIN_OFF_GAP, DEFAULT_OFF_DELAY_BY_DEVICE, DEFAULT_OFF_DELAY, DEFAULT_MIN_OFF_GAP_BY_DEVICE, DEFAULT_MIN_OFF_GAP, ) from .time_utils import power_data_to_offsets if TYPE_CHECKING: from .profile_store import ProfileStore _LOGGER = logging.getLogger(__name__) def _parse_ts(v: Any) -> float | None: """Parse a value into a unix timestamp float, supporting ISO strings.""" if isinstance(v, str): try: return datetime.fromisoformat(v.replace("Z", "+00:00")).timestamp() except ValueError: return None return None class SuggestionEngine: """Refined engine for generating data-driven parameter suggestions.""" def __init__( self, hass: HomeAssistant, entry_id: str, profile_store: "ProfileStore", device_type: str | None = None, ) -> None: """Initialize the suggestion engine.""" self.hass = hass self.entry_id = entry_id self.profile_store = profile_store self.device_type = device_type def generate_operational_suggestions(self, p95_dt: float, median_dt: float) -> dict[str, Any]: """Generate suggestions for operational parameters based on cadence.""" suggestions: dict[str, dict[str, Any]] = {} # 1. Watchdog Interval suggested_watchdog = int(max(30, p95_dt * 10)) suggestions[CONF_WATCHDOG_INTERVAL] = { "value": suggested_watchdog, "reason": f"Based on observed update cadence (p95={p95_dt:.1f}s) * 10 (min 30s buffer)." } # 2. No Update Timeout suggested_timeout = int(max(60, p95_dt * 20)) suggestions[CONF_NO_UPDATE_ACTIVE_TIMEOUT] = { "value": suggested_timeout, "reason": f"Based on observed update cadence (p95={p95_dt:.1f}s) * 20 (min 60s)." } # 3. Off Delay # Use device-specific default as floor to prevent splitting cycles with long pauses device_floor = ( DEFAULT_OFF_DELAY_BY_DEVICE.get(self.device_type, DEFAULT_OFF_DELAY) if self.device_type is not None else DEFAULT_OFF_DELAY ) suggested_off_delay = int(max(device_floor, p95_dt * 5)) reason_off = f"Based on observed update cadence (p95={p95_dt:.1f}s) * 5" if suggested_off_delay == device_floor: if self.device_type and self.device_type in DEFAULT_OFF_DELAY_BY_DEVICE: reason_off = ( f"Used device-specific safe minimum for {self.device_type} ({device_floor}s)." ) else: reason_off = f"Used generic safe minimum ({DEFAULT_OFF_DELAY}s)." suggestions[CONF_OFF_DELAY] = { "value": suggested_off_delay, "reason": reason_off } # 4. Profile Match Interval suggested_match = int(max(10, median_dt * 10)) suggestions[CONF_PROFILE_MATCH_INTERVAL] = { "value": suggested_match, "reason": f"Based on observed update cadence (median={median_dt:.1f}s) * 10." } return suggestions def generate_model_suggestions(self) -> dict[str, Any]: """Generate suggestions for model parameters based on past cycles.""" suggestions: dict[str, dict[str, Any]] = {} cycles = self.profile_store.get_past_cycles()[-100:] profiles = self.profile_store.get_profiles() ratios: list[float] = [] for c in cycles: if not isinstance(c, dict): continue profile_name = c.get("profile_name") if not isinstance(profile_name, str) or c.get("status") == "interrupted": continue prof = profiles.get(profile_name) if not isinstance(prof, dict): continue try: avg = float(prof.get("avg_duration") or 0.0) dur = float(c.get("duration") or 0.0) except (TypeError, ValueError): continue if avg > 60 and dur > 60: ratios.append(dur / avg) if len(ratios) >= 10: arr: np.ndarray[Any, np.dtype[np.float64]] = np.array(ratios, dtype=float) deviations = np.abs(arr - 1.0) p95_dev = float(np.percentile(deviations, 95)) suggested_tol = min(0.50, max(0.10, round(p95_dev + 0.05, 2))) reason_tol = f"Based on duration variance of {len(ratios)} recent labeled cycles (p95 dev={p95_dev:.2f})." suggestions[CONF_DURATION_TOLERANCE] = {"value": suggested_tol, "reason": reason_tol} suggestions[CONF_PROFILE_DURATION_TOLERANCE] = {"value": suggested_tol, "reason": reason_tol} p05_ratio = float(np.percentile(arr, 5)) p95_ratio = float(np.percentile(arr, 95)) min_r = max(0.1, round(p05_ratio - 0.1, 2)) max_r = min(3.0, round(p95_ratio + 0.1, 2)) if min_r < max_r - 0.2: suggestions[CONF_PROFILE_MATCH_MIN_DURATION_RATIO] = { "value": min_r, "reason": f"Based on labeled cycle durations (p05={p05_ratio:.2f})." } suggestions[CONF_PROFILE_MATCH_MAX_DURATION_RATIO] = { "value": max_r, "reason": f"Based on labeled cycle durations (p95={p95_ratio:.2f})." } # Min-off-gap: derived from observed inter-cycle gaps min_off_gap = self._suggest_min_off_gap(cycles) if min_off_gap is not None: suggestions[CONF_MIN_OFF_GAP] = min_off_gap return suggestions def _suggest_min_off_gap( self, cycles: list[dict[str, Any]] ) -> dict[str, Any] | None: """Derive a min_off_gap suggestion from observed inter-cycle gaps.""" # Only consider completed, labeled cycles with valid timestamps timed_cycles: list[tuple[float, float]] = [] for c in cycles: if not isinstance(c, dict): continue if c.get("status") not in ("completed", "force_stopped"): continue label = c.get("profile_name") or c.get("label") if not label or label == "noise": continue try: start = float(c["start_time"]) if isinstance(c.get("start_time"), (int, float)) and not isinstance(c.get("start_time"), bool) else None end = float(c["end_time"]) if isinstance(c.get("end_time"), (int, float)) and not isinstance(c.get("end_time"), bool) else None if start is None or end is None: # Try ISO string parsing start = _parse_ts(c.get("start_time")) end = _parse_ts(c.get("end_time")) if start is None or end is None or end <= start: continue timed_cycles.append((start, end)) except (TypeError, ValueError, KeyError): continue if len(timed_cycles) < 3: return None timed_cycles.sort(key=lambda x: x[0]) gaps: list[float] = [] for i in range(1, len(timed_cycles)): gap = timed_cycles[i][0] - timed_cycles[i - 1][1] if 30 <= gap <= 86400: # Only gaps between 30s and 1 day gaps.append(gap) if len(gaps) < 3: return None gaps_arr = np.array(gaps) # Use the 5th-percentile gap as the safe minimum, with device-type floor p05_gap = float(np.percentile(gaps_arr, 5)) device_floor = ( DEFAULT_MIN_OFF_GAP_BY_DEVICE.get(self.device_type, DEFAULT_MIN_OFF_GAP) if self.device_type is not None else DEFAULT_MIN_OFF_GAP ) # Add a 20% safety margin so we never split a real gap into two cycles suggested = int(max(device_floor, min(p05_gap * 0.8, 3600))) # When the data-derived value is equal to the device floor, we have no # useful signal to surface — return None to suppress a misleading suggestion. if suggested == device_floor: return None reason = ( f"Based on {len(gaps)} observed inter-cycle gaps " f"(p05={p05_gap:.0f}s). Device floor: {device_floor}s." ) return {"value": suggested, "reason": reason} def run_simulation(self, cycle_data: dict[str, Any]) -> dict[str, Any]: """Replay a single cycle with varied parameters to find optimal settings. For richer, multi-cycle suggestions use :meth:`run_batch_simulation`. """ power_data_raw: Any = cycle_data.get("power_data", []) if not isinstance(power_data_raw, list): return {} power_data = cast(list[list[float] | tuple[Any, float]], power_data_raw) if len(power_data) < 10: return {} start_time_raw = cycle_data.get("start_time") start_time_iso = ( start_time_raw if isinstance(start_time_raw, str) and start_time_raw else None ) # Normalise power_data to [[offset_sec, power], ...] regardless of source format. readings_list = power_data_to_offsets(power_data, start_time_iso) readings: list[tuple[float, float]] = [ (float(offset), float(power)) for offset, power in readings_list ] if not readings: return {} powers = np.array([p[1] for p in readings]) active_powers = powers[powers > 0.5] if len(active_powers) < 5: return {} min_active = float(np.min(active_powers)) suggested_stop = round(min_active * 0.8, 2) suggested_start = round(min_active * 1.2, 2) # Energy suggestions suggested_end_energy = 0.05 # Dead zone: look for early dips in the first 5 minutes dead_zone = 0 for ts_offset, p in readings: elapsed = ts_offset if elapsed > 300: break if p < 5.0 and elapsed > 5.0: dead_zone = int(elapsed) suggested_dead_zone = min(300, dead_zone) if dead_zone > 0 else 60 return { CONF_STOP_THRESHOLD_W: { "value": suggested_stop, "reason": f"Based on minimum active power ({min_active:.1f}W) observed in last cycle." }, CONF_START_THRESHOLD_W: { "value": suggested_start, "reason": f"Based on minimum active power ({min_active:.1f}W) observed in last cycle." }, CONF_END_ENERGY_THRESHOLD: { "value": suggested_end_energy, "reason": "Default recommended baseline for end-of-cycle noise gate." }, CONF_RUNNING_DEAD_ZONE: { "value": suggested_dead_zone, "reason": f"Based on early power dip detected at {suggested_dead_zone}s." }, } def run_batch_simulation(self, cycles: list[dict[str, Any]]) -> dict[str, Any]: """Derive parameter suggestions from a collection of labeled cycles. Unlike :meth:`run_simulation` (single-cycle heuristics), this method aggregates statistics across *multiple* cycles for robustness: - Power thresholds from the 5th-percentile minimum active power. - Dead zone from the 75th-percentile of early dips across cycles. - End-energy threshold from the maximum false-end energy seen. - Min-off-gap from the 5th-percentile inter-cycle gap. Returns an empty dict when fewer than ``_BATCH_MIN_CYCLES`` valid cycles are provided. """ _BATCH_MIN_CYCLES = 5 valid_cycles: list[list[tuple[float, float]]] = [] for c in cycles: if not isinstance(c, dict): continue label = c.get("label") or c.get("profile_name") if not isinstance(label, str) or not label: continue if label.lower() == "noise": continue if not ( c.get("state") == "completed" or c.get("status") in ("completed", "force_stopped") ): continue raw = c.get("power_data") if not isinstance(raw, list) or len(raw) < 5: continue start_iso = c.get("start_time") if isinstance(c.get("start_time"), str) else None readings_list = power_data_to_offsets( cast(list[list[float] | tuple[Any, float]], raw), start_iso ) readings = [(float(o), float(p)) for o, p in readings_list] if len(readings) >= 5: valid_cycles.append(readings) if len(valid_cycles) < _BATCH_MIN_CYCLES: return {} # --- Power thresholds --- lowest_active: list[float] = [] false_end_energies: list[float] = [] dead_zone_candidates: list[int] = [] _MAX_PAUSE_GAP_H = 1.0 max_gap_s = _MAX_PAUSE_GAP_H * 3600 for readings in valid_cycles: powers = np.array([p for _, p in readings]) active = powers[powers > 0.5] if len(active) > 0: lowest_active.append(float(np.min(active))) # Dead zone: first dip below 5 W within the first 5 minutes for ts_offset, p in readings: if ts_offset > 300: break if p < 5.0 and ts_offset > 5.0: dead_zone_candidates.append(int(ts_offset)) break # False-end energies: low-power segments that resumed in_pause = False pause_energy = 0.0 stop_w = 2.0 for i in range(1, len(readings)): t0, p0 = readings[i - 1] t1, p1 = readings[i] dt_s = t1 - t0 # Guard against non-positive or excessively large time gaps if dt_s <= 0 or dt_s > max_gap_s: # Skip this interval and reset pause state in_pause = False pause_energy = 0.0 continue avg_p = (p0 + p1) / 2.0 dt_h = dt_s / 3600.0 if avg_p < stop_w: if not in_pause: in_pause = True pause_energy = 0.0 pause_energy += avg_p * dt_h elif in_pause: false_end_energies.append(pause_energy) in_pause = False suggestions: dict[str, dict[str, Any]] = {} if lowest_active: p05_min = float(np.percentile(lowest_active, 5)) suggested_stop = round(p05_min * 0.8, 2) suggested_start = round(max(suggested_stop + 0.1, p05_min * 1.2), 2) n = len(lowest_active) suggestions[CONF_STOP_THRESHOLD_W] = { "value": suggested_stop, "reason": ( f"Based on p05 of minimum active power across {n} cycles " f"({p05_min:.1f}W)." ), } suggestions[CONF_START_THRESHOLD_W] = { "value": suggested_start, "reason": ( f"Based on p05 of minimum active power across {n} cycles " f"({p05_min:.1f}W)." ), } if false_end_energies: max_false = float(np.max(false_end_energies)) suggested_end = round(max(0.05, max_false * 1.2), 4) else: suggested_end = 0.05 suggestions[CONF_END_ENERGY_THRESHOLD] = { "value": suggested_end, "reason": ( f"Based on maximum false-end energy " f"({float(np.max(false_end_energies)) if false_end_energies else 0:.4f}Wh) " f"across {len(valid_cycles)} cycles." ), } if dead_zone_candidates: # Use the 75th percentile to cover most cycles without being overly generous p75_dz = int(np.percentile(dead_zone_candidates, 75)) suggested_dz = min(300, p75_dz) suggestions[CONF_RUNNING_DEAD_ZONE] = { "value": suggested_dz, "reason": ( f"Based on p75 of early power dips across " f"{len(dead_zone_candidates)} cycles ({suggested_dz}s)." ), } min_off_gap = self._suggest_min_off_gap(cycles) if min_off_gap is not None: suggestions[CONF_MIN_OFF_GAP] = min_off_gap return suggestions def apply_suggestions(self, suggestions: dict[str, Any]) -> None: """Persist suggestions to the profile store.""" for key, data in suggestions.items(): self.profile_store.set_suggestion(key, data["value"], reason=data["reason"]) if self.hass and suggestions: self.hass.async_create_task(self.profile_store.async_save())