# 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 . """Profile storage and matching logic for WashData.""" from __future__ import annotations import dataclasses import hashlib import logging import math import os import re import threading import uuid from datetime import datetime, timedelta from typing import Any, TypeAlias, cast import numpy as np from homeassistant.core import HomeAssistant from homeassistant.helpers.storage import Store from homeassistant.util import dt as dt_util from .const import ( CLUSTER_RESAMPLE_N, CLUSTER_SHAPE_SIMILARITY_THRESHOLD, GROUP_MIN_COHESION, MAINTENANCE_EVENT_TYPES, MAINTENANCE_RECENT_SUPPRESS_DAYS, MATCH_AMBIGUITY_MARGIN, PHASE_CONSISTENCY_MIN_CYCLES, PHASE_PROFILE_MIN_CYCLES, PHASE_HEAT_CV_WARN, PHASE_HEAT_OCC_MIXED_LO, PHASE_HEAT_OCC_MIXED_HI, REFERENCE_PROFILE_CURVE_POINTS, SHAPE_DRIFT_MIN_CYCLES, SHAPE_DRIFT_RESAMPLE_N, SHAPE_DRIFT_THRESHOLD, SMART_TERM_LANDSCAPE_RATIO, SMART_TERM_LANDSCAPE_MIN_SHAPE, STORAGE_KEY, STORAGE_VERSION, DEFAULT_MAX_PAST_CYCLES, DEFAULT_MAX_FULL_TRACES_PER_PROFILE, DEFAULT_MAX_FULL_TRACES_UNLABELED, DEFAULT_DTW_BANDWIDTH, ) from .features import compute_signature from .signal_processing import resample_uniform, resample_adaptive, Segment, integrate_wh, energy_gap_threshold_s from . import analysis from .time_utils import ( migrate_power_data_to_offsets, power_data_to_offsets, ) from .phase_catalog import ( DEFAULT_PHASES_BY_DEVICE, _builtin_phase_id, get_builtin_phase_by_id, merge_phase_catalog, normalize_phase_name, ) from .phase_segmenter import phase_matching_live_supported, phase_model_for, segment_cycle from .phase_match import ( build_phase_profile, match_phase_profiles, phase_eta, phase_profile_from_dict, phase_profile_to_dict, ) from .log_utils import DeviceLoggerAdapter _LOGGER = logging.getLogger(__name__) # Absolute peak-power floor below which a cycle trace is considered a # mis-capture (e.g. the power sensor wasn't reporting), regardless of profile. # Used together with a relative (10% of median peak) test in envelope rebuild # and reference selection so degenerate cycles never become the matching template. _DEGENERATE_POWER_FLOOR = 15.0 # watts JSONDict: TypeAlias = dict[str, Any] CycleDict: TypeAlias = dict[str, Any] def _is_recorded_cycle(cycle: dict[str, Any]) -> bool: """True when a cycle was produced by the manual recorder. Two independent signals, because the explicit marker did not always exist: 1. **Explicit meta marker** (recent recorder builds): ``meta.source == "recorder"`` and, equivalently, ``meta.original_samples`` (the pre-trim sample count). (In exported diagnostics ``meta.source`` is redacted, but the real stored value is ``"recorder"`` — the migration runs against the real store, not the export.) 2. **Structural signature** (old recordings, ``meta`` is ``None``): the recorder builds the cycle dict directly (``ws_process_recording``) and never sets ``max_power`` or ``termination_reason``, whereas *every* auto-detected cycle goes through ``CycleDetector._finish_cycle`` which stamps ``max_power`` unconditionally (in every version — ``termination_reason`` was added later, so old *auto* cycles can lack it but never lack ``max_power``). So a **completed** cycle missing *both* fields is a recording that predates the meta marker. Verified against real exports: this recovers old recordings that carry only ``meta: None`` — which the marker-only check silently missed. """ meta = cycle.get("meta") if isinstance(meta, dict) and ( meta.get("source") == "recorder" or "original_samples" in meta ): return True return ( cycle.get("status") == "completed" and "max_power" not in cycle and "termination_reason" not in cycle ) def _flag_recorded_cycles_golden(cycles: list[dict[str, Any]]) -> int: """Mark manually-recorded cycles as golden references (recorded == golden). A recorded cycle (see :func:`_is_recorded_cycle`) is, by definition, a hand-picked clean example, so it becomes the profile's golden reference. The flag lives in the single ``ml_review`` dict (no duplicate ``recorded`` field). Idempotent: already-golden cycles are skipped, so it is safe to re-run from the migration and the diagnostics processing trigger. Returns the number of cycles newly flagged. """ flagged = 0 for cycle in cycles: if not isinstance(cycle, dict): continue review = cycle.get("ml_review") if isinstance(review, dict) and review.get("golden"): continue if not _is_recorded_cycle(cycle): continue review = dict(review) if isinstance(review, dict) else {} review["golden"] = True if not review.get("quality"): review["quality"] = "good" # recorded cycles are clean by definition if not review.get("reviewed_at"): review["reviewed_at"] = dt_util.now().isoformat() cycle["ml_review"] = review flagged += 1 return flagged def _empty_ranking() -> list[dict[str, Any]]: """Typed default factory for ranking entries.""" return [] def _parse_start_dt(value: Any) -> datetime | None: """Parse a start_time value (ISO string or numeric timestamp) into a datetime. Handles the case where legacy cycles stored numeric unix timestamps instead of ISO-formatted strings, which dt_util.parse_datetime cannot handle. """ if isinstance(value, datetime): return value if isinstance(value, (int, float)) and not isinstance(value, bool): try: return datetime.fromtimestamp(float(value), tz=dt_util.UTC) except (OSError, OverflowError, ValueError): return None if isinstance(value, str) and value: parsed = dt_util.parse_datetime(value) if parsed is not None: return parsed try: return datetime.fromtimestamp(float(value), tz=dt_util.UTC) except (TypeError, ValueError): return None return None def _parse_maintenance_dt(value: Any) -> datetime | None: """Parse a maintenance-log date (ISO date or datetime) into an aware datetime. Accepts both full ISO datetimes (as written by ``dt_util.now().isoformat()``) and bare ISO dates (``"2026-07-01"``) that the user may enter. Naive results are localised so recency/comparison math stays timezone-consistent. Never raises. """ dt = _parse_start_dt(value) if dt is None and isinstance(value, str) and value: try: dt = datetime.fromisoformat(value) except ValueError: dt = None if dt is not None and dt.tzinfo is None: dt = dt.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE or dt_util.UTC) return dt def _empty_debug_details() -> dict[str, Any]: """Typed default factory for debug details.""" return {} def _value_to_timestamp(value: Any) -> float | None: """Parse supported datetime-like values into unix seconds.""" if isinstance(value, datetime): return value.timestamp() if isinstance(value, (int, float)): return float(value) if isinstance(value, str) and value: parsed = dt_util.parse_datetime(value) if parsed is not None: return parsed.timestamp() try: return float(value) except (TypeError, ValueError): return None return None def profile_sort_key(name: str) -> tuple[int, int, str]: """Sort key for profile names: numeric-prefixed first (by number), then alphabetically.""" match = re.match(r'^(\d+)', name) if match: return (0, int(match.group(1)), name) return (1, 0, name) def trim_zero_power_data( data: list[list[float]], threshold: float = 0.5 ) -> list[list[float]]: """Trim leading/trailing zero/near-zero power readings from stored data. Args: data: List of [offset, power] pairs threshold: Power values <= this are considered "zero" Returns: Trimmed list with leading/trailing zeros removed """ if not data: return data # Find first non-zero reading start_idx = 0 for i, point in enumerate(data): if point[1] > threshold: start_idx = i break else: # All readings are zero - keep at least one return data[:1] if data else [] # Find last non-zero reading end_idx = len(data) - 1 for i in range(len(data) - 1, -1, -1): if data[i][1] > threshold: end_idx = i break # Return trimmed slice (inclusive of end) return data[start_idx:end_idx + 1] def filter_duration_outliers(durations: list[float]) -> list[float]: """Return a robust duration set with extreme outliers removed. Uses Tukey IQR fences for normal spread and falls back to a MAD-based filter when IQR collapses (common when most cycles are identical). """ if len(durations) < 4: return durations arr = np.array(durations, dtype=float) q1 = float(np.percentile(arr, 25)) q3 = float(np.percentile(arr, 75)) iqr = q3 - q1 if iqr > 0: lower = max(60.0, q1 - 1.5 * iqr) upper = q3 + 1.5 * iqr filtered = arr[(arr >= lower) & (arr <= upper)] else: # Degenerate spread (e.g. many identical durations); keep values # close to median and drop only extreme anomalies. median = float(np.median(arr)) abs_dev = np.abs(arr - median) mad = float(np.median(abs_dev)) if mad == 0: tol = max(300.0, median * 0.15) filtered = arr[np.abs(arr - median) <= tol] else: robust_z = abs_dev / (1.4826 * mad) filtered = arr[robust_z <= 3.5] # Guardrail: do not over-filter sparse datasets. if len(filtered) >= max(3, int(len(arr) * 0.6)): return filtered.astype(float).tolist() return durations @dataclasses.dataclass class MatchResult: """Result of a profile matching attempt.""" best_profile: str | None confidence: float expected_duration: float matched_phase: str | None candidates: list[dict[str, Any]] is_ambiguous: bool ambiguity_margin: float ranking: list[dict[str, Any]] = dataclasses.field(default_factory=_empty_ranking) debug_details: dict[str, Any] = dataclasses.field(default_factory=_empty_debug_details) is_confident_mismatch: bool = False mismatch_reason: str | None = None is_prefix_ambiguous: bool = False def to_dict(self) -> dict[str, Any]: """Convert to dictionary with JSON-serializable types, excluding heavy arrays.""" def _convert(obj: Any) -> Any: if isinstance(obj, np.generic): return cast(np.generic, obj).item() if isinstance(obj, np.ndarray): # Fallback for unexpected arrays: just describe shape arr = cast(np.ndarray[Any, Any], obj) return f"" if isinstance(obj, dict): # Exclude huge raw data arrays from cycle candidates obj_dict = cast(dict[str, Any], obj) return { k: _convert(v) for k, v in obj_dict.items() if k not in ("current", "sample", "metrics", "warping_path") } if isinstance(obj, list): obj_list = cast(list[Any], obj) return [_convert(v) for v in obj_list] if dataclasses.is_dataclass(obj): return text_type_safe_asdict(obj) return obj def text_type_safe_asdict(d_obj: Any) -> dict[str, Any]: return {f.name: _convert(getattr(d_obj, f.name)) for f in dataclasses.fields(d_obj)} return text_type_safe_asdict(self) def _safe_file_size_kb(path: str) -> float: """Return the size of ``path`` in KiB, or 0.0 if it cannot be read. Runs blocking os.path calls; intended to be offloaded to the executor. """ try: return os.path.getsize(path) / 1024 if os.path.exists(path) else 0.0 except OSError: return 0.0 def decompress_power_data(cycle: CycleDict) -> list[tuple[float, float]]: """Return power data as ``[(offset_seconds, power), ...]`` for a cycle. Handles both the current canonical ``[offset_float, power]`` format and the legacy ``(iso_str, power)`` format transparently. Returns an empty list if data is missing or malformed. """ raw = cycle.get("power_data", []) if not isinstance(raw, list) or not raw: return [] start_time_raw = cycle.get("start_time") start_time_iso: str | None = start_time_raw if isinstance(start_time_raw, str) and start_time_raw else None if isinstance(start_time_raw, datetime): start_time_iso = start_time_raw.isoformat() offsets = power_data_to_offsets(cast(list[list[Any] | tuple[Any, ...]], raw), start_time_iso) return [(float(o), float(p)) for o, p in offsets] def earliest_sustained_quiet_offset( cycles: list[CycleDict], stop_threshold_w: float, min_quiet_span_s: float, min_clean_cycles: int, ) -> float | None: """Smallest elapsed offset (s) at which any *completed* cycle first shows a sustained (>= ``min_quiet_span_s``) near-zero (< ``stop_threshold_w``) span. This is the device's learned "we have never legitimately been quiet before this many seconds into a cycle" baseline, used by the opt-in terminal-drop detector: a hard cliff-to-~0 that begins *earlier* than this is anomalous and almost certainly a real stop rather than a soak pause. Only ``completed`` cycles seed the baseline - interrupted / force-stopped / terminal-drop cycles are exactly the anomalies we are trying to catch, so including them would poison the baseline and suppress detection. Returns ``None`` when there are fewer than ``min_clean_cycles`` completed cycles with usable traces, so the caller keeps the proven slow end-detection. Using the strict *minimum* (rather than a higher percentile) is deliberately conservative: one cycle that happened to go quiet early only lowers the baseline, making the detector fire *less* often - never more. """ if not cycles: return None earliest: float | None = None clean = 0 for cycle in cycles: if cycle.get("status") != "completed": continue points = decompress_power_data(cycle) if not points: continue clean += 1 span_start: float | None = None for offset, power in points: if power < stop_threshold_w: if span_start is None: span_start = offset elif (offset - span_start) >= min_quiet_span_s: if earliest is None or span_start < earliest: earliest = span_start break else: span_start = None if clean < min_clean_cycles: return None return earliest def device_active_peak_range( cycles: list[CycleDict], min_clean_cycles: int ) -> tuple[float, float] | None: """(min_peak, max_peak) across the device's **completed** cycles. The peak of a cycle is its maximum power. This is the device's learned "power levels we have produced before" band, used by the terminal-drop familiarity gate: a running cycle peaking outside this range (widened by a tolerance) is drawing power unlike anything in its history and may be a new program, so an early drop on it is deferred rather than assumed terminal. Returns ``None`` below ``min_clean_cycles`` completed cycles with usable traces (same guard as the quiet-offset baseline).""" peaks: list[float] = [] for cycle in cycles: if cycle.get("status") != "completed": continue points = decompress_power_data(cycle) if not points: continue peaks.append(max(p for _, p in points)) if len(peaks) < min_clean_cycles: return None return (min(peaks), max(peaks)) def terminal_drop_baseline( cycles: list[CycleDict], stop_threshold_w: float, min_quiet_span_s: float, min_clean_cycles: int, ) -> tuple[float | None, tuple[float, float] | None]: """Combined ``(earliest_quiet_offset, peak_range)`` baseline. Pure (no store / no I/O beyond decompressing the passed-in cycle traces), so the manager can offload the whole per-cycle scan to an executor thread in one hop instead of decompressing every trace on the event loop (issue #311).""" earliest = earliest_sustained_quiet_offset( cycles, stop_threshold_w, min_quiet_span_s, min_clean_cycles ) peak_range = device_active_peak_range(cycles, min_clean_cycles) return earliest, peak_range def is_terminal_drop( points: list[tuple[float, float]], earliest_quiet: float | None, peak_range: tuple[float, float] | None, stop_threshold_w: float, earliness_ratio: float, min_peak_ratio: float, peak_familiar_tol: float, ) -> bool: """Whether a running cycle's current low-power event is an anomalously-early terminal drop (plug pulled / cancelled) rather than a soak pause. Pure decision logic (no store / no I/O) so it is unit-testable in isolation; the manager wires the learned ``earliest_quiet`` / ``peak_range`` baselines and gates on the ML opt-in. All three checks must hold: * **Clearly ON** - the cycle peaked >= ``min_peak_ratio`` x stop-threshold (a low-power idling device can't trip it). * **Familiar** - the peak is within the device's historical peak range widened by ``peak_familiar_tol``. A peak outside that band means the cycle is drawing power unlike anything seen before (possibly a new program), so we DEFER (return ``False``) rather than assume a stop. * **Anomalous** - the trailing sub-threshold span began at an offset ``< earliest_quiet * earliness_ratio`` - earlier than this device has ever legitimately gone quiet. Returns ``False`` (keep the proven slow path) whenever a baseline is missing or any check fails.""" if not points or earliest_quiet is None or peak_range is None: return False peak = max(p for _, p in points) if peak < min_peak_ratio * max(stop_threshold_w, 1.0): return False lo, hi = peak_range if not (lo * (1.0 - peak_familiar_tol) <= peak <= hi * (1.0 + peak_familiar_tol)): return False drop_start: float | None = None for offset, power in points: if power < stop_threshold_w: if drop_start is None: drop_start = offset else: drop_start = None if drop_start is None: return False return drop_start < earliest_quiet * earliness_ratio def compress_power_data(cycle: CycleDict) -> list[Any] | None: """Compress cycle power data to [offset, power] format (Module-level helper). Returns the compressed list structure or None if compression failed/not needed. """ raw_data_raw = cycle.get("power_data") if not isinstance(raw_data_raw, list) or not raw_data_raw: return None raw_data = cast(list[Any], raw_data_raw) # Check if already compressed (first element is number or mixed format) first = raw_data[0] if isinstance(first, (int, float)): # Already flat list (very old format?) or specific compression return None if isinstance(first, (list, tuple)): first_seq = cast(list[Any] | tuple[Any, ...], first) if len(first_seq) == 2 and isinstance(first_seq[0], (int, float)): # Already compressed [offset, power] return None # Proceed with compression from [iso_string, power] if "start_time" not in cycle: return None try: start_ts = _value_to_timestamp(cycle.get("start_time")) if start_ts is None: return None compressed: list[list[float]] = [] last_saved_p = -999.0 last_saved_t = -999.0 for i, entry in enumerate(raw_data): if isinstance(entry, (list, tuple)): entry_seq = cast(list[Any] | tuple[Any, ...], entry) if len(entry_seq) != 2: continue t_str, p_val_raw = entry_seq try: # Handle both ISO string and potential timestamp float t = _value_to_timestamp(t_str) if t is None: continue p_val = float(p_val_raw) offset = round(t - start_ts, 1) if offset < 0: offset = 0.0 # Save first and last is_endpoint = i == 0 or i == len(raw_data) - 1 # Downsample: change > 1W or gap > 60s if ( is_endpoint or abs(p_val - last_saved_p) > 1.0 or (offset - last_saved_t) > 60 ): compressed.append([offset, round(p_val, 1)]) last_saved_p = p_val last_saved_t = offset except (ValueError, TypeError): continue return compressed except Exception: return None class WashDataStore(Store[JSONDict]): """Store implementation with migration support.""" async def _async_migrate_func( self, old_major_version: int, old_minor_version: int, # pylint: disable=unused-argument old_data: JSONDict, ) -> JSONDict: """Migrate data to the new version.""" if old_major_version < 2: _LOGGER.info("Migrating storage from v%s to v2", old_major_version) # Logic moved from ProfileStore._migrate_v1_to_v2 cycles_raw = old_data.get("past_cycles", []) cycles = cast(list[dict[str, Any]], cycles_raw) if isinstance(cycles_raw, list) else [] migrated_cycles = 0 for cycle in cycles: if "signature" not in cycle and cycle.get("power_data"): try: # decompress_power_data now returns [(offset_seconds, power), ...] tuples = decompress_power_data(cycle) if tuples and len(tuples) > 10: ts_arr = np.array([t for t, _ in tuples]) p_arr = np.array([p for _, p in tuples]) sig = compute_signature(ts_arr, p_arr) cycle["signature"] = dataclasses.asdict(sig) migrated_cycles += 1 except Exception as e: # pylint: disable=broad-exception-caught _LOGGER.warning( "Failed to migrate signature for cycle %s: %s", cycle.get("id"), e ) _LOGGER.info( "Migration v1->v2: Computed signatures for %s cycles", migrated_cycles ) if old_major_version < 3: _LOGGER.info("Migrating storage from v%s to v3", old_major_version) cycles_raw = old_data.get("past_cycles", []) profiles_raw = old_data.get("profiles", {}) cycles = cast(list[dict[str, Any]], cycles_raw) if isinstance(cycles_raw, list) else [] profiles = cast(dict[str, dict[str, Any]], profiles_raw) if isinstance(profiles_raw, dict) else {} migrated_count = 0 # 1. Migrate Power Data to canonical offset format & ensure status for cycle in cycles: if "status" not in cycle: cycle["status"] = "completed" if cycle.get("power_data") and isinstance(cycle["power_data"], list): try: if migrate_power_data_to_offsets(cycle): migrated_count += 1 except Exception as e: _LOGGER.warning( "Failed to migrate power data for cycle %s: %s", cycle.get("id"), e, ) # 2. Ensure Device Type in Profiles for profile in profiles.values(): if "device_type" not in profile: profile["device_type"] = "washing_machine" _LOGGER.info( "Migration v2->v3: Migrated power data for %s cycles", migrated_count ) if old_major_version < 4: _LOGGER.info("Migrating storage from v%s to v4", old_major_version) profiles = old_data.get("profiles", {}) if isinstance(profiles, dict): for profile in cast(dict[str, dict[str, Any]], profiles).values(): phases = profile.get("phases") if not isinstance(phases, list): profile["phases"] = [] custom = old_data.get("custom_phases") if not isinstance(custom, dict): old_data["custom_phases"] = {} if old_major_version < 5: _LOGGER.info("Migrating storage from v%s to v5", old_major_version) custom = old_data.get("custom_phases") if isinstance(custom, list): normalized: list[dict[str, Any]] = [] seen: set[tuple[str, str]] = set() for item in cast(list[Any], custom): if not isinstance(item, dict): continue item_dict = cast(dict[str, Any], item) name = str(item_dict.get("name", "")).strip() if not name: continue device_type = str(item_dict.get("device_type", "")).strip() key = (name.casefold(), device_type.casefold()) if key in seen: continue seen.add(key) normalized.append( { "name": name, "description": str(item_dict.get("description", "")).strip(), "device_type": device_type, "created_at": item_dict.get("created_at") or dt_util.now().isoformat(), } ) old_data["custom_phases"] = normalized elif isinstance(custom, dict): normalized: list[dict[str, Any]] = [] seen: set[tuple[str, str]] = set() custom_dict = cast(dict[str, Any], custom) for legacy_device_type, phase_list in custom_dict.items(): if not isinstance(phase_list, list): continue for item in cast(list[Any], phase_list): if not isinstance(item, dict): continue item_dict = cast(dict[str, Any], item) name = str(item_dict.get("name", "")).strip() if not name: continue device_type = str(legacy_device_type or "").strip() key = (name.casefold(), device_type.casefold()) if key in seen: continue seen.add(key) normalized.append( { "name": name, "description": str(item_dict.get("description", "")).strip(), "device_type": device_type, "created_at": item_dict.get("created_at") or dt_util.now().isoformat(), } ) old_data["custom_phases"] = normalized else: old_data["custom_phases"] = [] if old_major_version < 6: _LOGGER.info("Migrating storage from v%s to v6", old_major_version) cycles_raw = old_data.get("past_cycles", []) cycles = cast(list[dict[str, Any]], cycles_raw) if isinstance(cycles_raw, list) else [] flagged = _flag_recorded_cycles_golden(cycles) _LOGGER.info( "Migration v5->v6: flagged %s recorded cycle(s) as golden references", flagged ) if old_major_version < 7: # The v6 step only ran when upgrading from below v6, so recorded # cycles on installs already at v6 (e.g. recorded by an older build # that didn't set the golden flag) were never tagged. Re-run the now # broadened backfill once so every historical recorded cycle is # recognised. Idempotent: already-golden cycles are skipped. _LOGGER.info("Migrating storage from v%s to v7", old_major_version) cycles_raw = old_data.get("past_cycles", []) cycles = cast(list[dict[str, Any]], cycles_raw) if isinstance(cycles_raw, list) else [] flagged = _flag_recorded_cycles_golden(cycles) _LOGGER.info( "Migration v6->v7: tagged %s previously-unmarked recorded cycle(s) as golden", flagged, ) if old_major_version < 8: # The v6->v7 backfill identified recordings only by the explicit # ``meta.source``/``original_samples`` marker, which OLD recordings # (made before that marker existed, ``meta: None``) do not carry — so # they stayed untagged. _is_recorded_cycle now also recognises them by # their structural signature (completed + no max_power/termination_reason). # Re-run the backfill once more so those old recordings are finally # tagged. Idempotent: already-golden cycles are skipped. _LOGGER.info("Migrating storage from v%s to v8", old_major_version) cycles_raw = old_data.get("past_cycles", []) cycles = cast(list[dict[str, Any]], cycles_raw) if isinstance(cycles_raw, list) else [] flagged = _flag_recorded_cycles_golden(cycles) _LOGGER.info( "Migration v7->v8: tagged %s old recorded cycle(s) (no meta marker) as golden", flagged, ) if old_major_version < 9: # Pre-initialize additive top-level keys so they are present from the # first load rather than appearing lazily on first use. Idempotent: # setdefault leaves existing values untouched. _LOGGER.info("Migrating storage from v%s to v9", old_major_version) old_data.setdefault("lifetime_energy_wh", 0.0) # Seed the monotonic lifetime cycle counter from the retained history so an # existing install does not start at 0 and re-fire past milestones (50/100/...) # on cycles it already completed. Matches the cycle_count fallback semantics. old_data.setdefault( "lifetime_cycle_count", len(old_data.get("past_cycles") or []) ) old_data.setdefault("settings_changelog", []) old_data.setdefault("maintenance_log", []) if old_major_version < 10: # Reference cycles imported from the community store live in their own list, # never in past_cycles, so they can feed the envelope/matcher but can never # touch usage/energy stats. Additive + idempotent. _LOGGER.info("Migrating storage from v%s to v10", old_major_version) old_data.setdefault("reference_cycles", []) if old_major_version < 11: # Marker-only bump. Per-phase profiles (envelope["phase_profile"], used # by phase-segmented matching / phase-resolved ETA) are DERIVED CACHE # built by async_rebuild_envelope, not stored data - so there is nothing # to migrate. They self-populate on the next envelope rebuild (which # runs on every cycle end / label change); until then consumers fall # back to the existing estimator via lazy absent-key handling. No data # is added, removed, or altered here. _LOGGER.info("Migrating storage from v%s to v11 (phase-profile cache marker)", old_major_version) return old_data def _ambiguity_from_candidates(candidates: list[dict]) -> tuple[float, bool]: """Top1-vs-top2 score margin and whether the match is ambiguous. Single source for the ambiguity rule shared by both match paths: ``is_ambiguous = margin < MATCH_AMBIGUITY_MARGIN``. ``margin`` defaults to 1.0 when there is only one candidate. """ margin = 1.0 if len(candidates) > 1: margin = candidates[0]["score"] - candidates[1]["score"] return margin, margin < MATCH_AMBIGUITY_MARGIN class ProfileStore: """Manages storage of washer profiles and past cycles.""" def __init__( self, hass: HomeAssistant, entry_id: str, min_duration_ratio: float = 0.50, max_duration_ratio: float = 1.50, save_debug_traces: bool = False, match_threshold: float = 0.4, unmatch_threshold: float = 0.35, device_name: str = "", ) -> None: """Initialize the profile store.""" self.hass = hass self.entry_id = entry_id self._logger = DeviceLoggerAdapter(_LOGGER, device_name) self._min_duration_ratio = min_duration_ratio self._max_duration_ratio = max_duration_ratio self._match_threshold = match_threshold self._unmatch_threshold = unmatch_threshold self.dtw_bandwidth: float = DEFAULT_DTW_BANDWIDTH self._save_debug_traces = save_debug_traces # Cache for resampled sample segments: key=(cycle_id, dt) self._cached_sample_segments: dict[tuple[str, float], Segment] = {} # Cache for group cohesion scores to avoid re-running DTW on the event loop # every 5 minutes. Keyed by sorted-members tuple; invalidated when profile_groups # content changes (tracked by a simple generation counter). self._cohesion_cache: dict[tuple[str, ...], float] = {} self._cohesion_cache_generation: int = 0 self._cohesion_cache_generation_checked: int = -1 # group_cohesion runs in executor threads (live matching + Playground can # touch the same store concurrently); serialize the read-clear-compute-store # sequence so a concurrent call can't clear a half-populated cache or # duplicate the DTW work. A plain thread lock, never held across an await. self._cohesion_cache_lock = threading.Lock() # Profile duration tolerance (set by manager; reserved for duration-based heuristics) self._duration_tolerance: float = 0.25 # Retention policy: cap total cycles and number of full-resolution traces per profile self._max_past_cycles = DEFAULT_MAX_PAST_CYCLES self._max_full_traces_per_profile = DEFAULT_MAX_FULL_TRACES_PER_PROFILE self._max_full_traces_unlabeled = DEFAULT_MAX_FULL_TRACES_UNLABELED # Separate store for each entry to avoid giant files # Use WashDataStore to handle migration self._store: Store[JSONDict] = WashDataStore( hass, STORAGE_VERSION, f"{STORAGE_KEY}.{entry_id}" ) self._data: JSONDict = { "profiles": {}, "past_cycles": [], "reference_cycles": [], # Imported store cycles: envelope/matcher only, never usage stats "envelopes": {}, # Cached statistical envelopes per profile "auto_adjustments": [], # Log of automatic setting changes "suggestions": {}, # Suggested settings (do NOT change user options) "feedback_history": {}, # Persisted user feedback (cycle_id -> record) "pending_feedback": {}, # Persisted pending feedback requests "custom_phases": [], # Shared custom phase catalog "ml_model_versions": {}, # On-device trained model specs (Stage 4) "profile_groups": {}, # Named groups of near-duplicate profiles (Stage 5) "maintenance_log": [], # User-logged maintenance events (Group E) } def set_suggestion( self, key: str, value: Any, reason: str | None = None, reason_key: str | None = None, reason_params: dict[str, Any] | None = None, ) -> None: """Store a suggested setting value without changing config entry options. ``reason`` is the English fallback text; ``reason_key`` + ``reason_params`` are the localization key + interpolation values the panel resolves via ``_t(reason_key, reason_params, reason)``. Both localization fields are optional so old callers / persisted entries keep working. """ suggestions: JSONDict = self._data.setdefault("suggestions", {}) entry: dict[str, Any] = { "value": value, "reason": reason, "updated": dt_util.now().isoformat(), } # Store localization sidecars only when present so we never overwrite an # existing key with a stale ``None`` (keeps the shape lean for old data). if reason_key is not None: entry["reason_key"] = reason_key if reason_params is not None: entry["reason_params"] = reason_params suggestions[key] = entry def get_suggestions(self) -> dict[str, Any]: """Return current suggestion map.""" raw = self._data.get("suggestions") if isinstance(raw, dict): suggestions = cast(JSONDict, raw) return suggestions.copy() return {} def delete_suggestion(self, key: str) -> None: """Remove a single suggestion entry by key.""" suggestions: JSONDict = self._data.setdefault("suggestions", {}) suggestions.pop(key, None) async def clear_suggestions(self) -> None: """Clear all pending suggestions and persist.""" self._data["suggestions"] = {} await self.async_save() def get_suggestion_apply_cycle_count(self) -> int: """Return the total cycle count recorded at the last user suggestion apply.""" return int(self._data.get("suggestion_apply_cycle_count", 0)) def set_suggestion_apply_cycle_count(self, count: int) -> None: """Record the total cycle count at the moment the user applies suggestions.""" self._data["suggestion_apply_cycle_count"] = count # ─── Settings changelog (Group D7) ───────────────────────────────────────── #: Maximum settings-changelog entries retained per device (newest kept). SETTINGS_CHANGELOG_MAX = 50 def get_settings_changelog(self) -> list[dict[str, Any]]: """Return the recorded settings-change history (most-recent-first). Never raises: returns an empty list when no changelog exists yet. """ try: raw = self._data.get("settings_changelog", []) if isinstance(raw, list): return raw except Exception: # pylint: disable=broad-exception-caught pass return [] async def async_record_settings_changes(self, changes: list[dict]) -> None: """Append settings-change entries and persist (capped at the newest 50). Each entry is normalized to ``{"key", "old", "new", "timestamp"}`` and prepended so the list stays most-recent-first. No-op on empty/invalid input. """ if not changes: return log = self._data.setdefault("settings_changelog", []) if not isinstance(log, list): log = [] self._data["settings_changelog"] = log now_iso = dt_util.now().isoformat() added = False for ch in changes: if not isinstance(ch, dict) or "key" not in ch: continue log.insert( 0, { "key": str(ch.get("key")), "old": ch.get("old"), "new": ch.get("new"), "timestamp": ch.get("timestamp") or now_iso, }, ) added = True if not added: return # Keep only the newest entries (list is most-recent-first). if len(log) > self.SETTINGS_CHANGELOG_MAX: del log[self.SETTINGS_CHANGELOG_MAX:] self._data["settings_changelog"] = log await self.async_save() # ─── On-device ML model versions (Stage 4) ──────────────────────────────── def get_ml_model_versions(self) -> dict[str, Any]: """Return the map of on-device trained model specs (capability -> record). Each record is ``{spec, trained_at, cycle_count, metrics, baseline_auc}`` where ``spec`` is a NumPy-only standardized-logistic model produced by :mod:`.ml.trainer`. Empty when nothing has been trained on-device. """ raw = self._data.get("ml_model_versions") if isinstance(raw, dict): return cast(JSONDict, raw).copy() return {} async def set_ml_model_version(self, capability: str, record: dict[str, Any]) -> None: """Persist a trained model record for a capability (quality/live_match/end).""" versions: JSONDict = self._data.setdefault("ml_model_versions", {}) versions[capability] = record await self.async_save() async def clear_ml_model_versions(self) -> None: """Drop all on-device trained models (reverts to embedded baselines).""" self._data["ml_model_versions"] = {} await self.async_save() def get_ml_last_training_run(self) -> str | None: """ISO timestamp of the last *completed* on-device training run, or None. Distinct from each model's ``trained_at`` (the promotion time): this advances on every run even when no model beat the baseline, so the panel's "Last trained" reflects that training actually ran. """ ts = self._data.get("ml_last_training_run") return ts if isinstance(ts, str) else None async def set_ml_last_training_run(self, iso: str) -> None: """Record when on-device training last completed (regardless of promotion).""" self._data["ml_last_training_run"] = iso await self.async_save() def get_ml_training_history(self) -> dict[str, list[dict[str, Any]]]: """Per-capability held-out-score history across training runs. ``{capability: [{"ts": iso, "score": float, "higher_better": bool}, ...]}`` oldest-first. Lets the panel show whether a model's fit is improving, steady, or declining over time. Empty until training has run. """ raw = self._data.get("ml_training_history") return cast(dict[str, list[dict[str, Any]]], raw) if isinstance(raw, dict) else {} async def append_ml_training_history(self, run_iso: str, results: list[dict[str, Any]]) -> None: """Append each capability's held-out score from a training run. ``results`` is ``train_from_cycles``'s per-capability records; classifiers report ``new_auc`` (higher is better), regressors ``model_mae`` (lower is better). Capabilities without a held-out metric this run (insufficient data) are skipped. Retains at most ``ML_TRAINING_HISTORY_MAX`` runs each. """ from .const import ML_TRAINING_HISTORY_MAX # noqa: PLC0415 hist: JSONDict = self._data.setdefault("ml_training_history", {}) changed = False for rec in results or []: if not isinstance(rec, dict): continue cap = rec.get("capability") if not isinstance(cap, str) or not cap: continue if rec.get("new_auc") is not None: score, higher_better = float(rec["new_auc"]), True elif rec.get("model_mae") is not None: score, higher_better = float(rec["model_mae"]), False else: continue series = hist.setdefault(cap, []) if not isinstance(series, list): series = hist[cap] = [] series.append({"ts": run_iso, "score": round(score, 5), "higher_better": higher_better}) del series[:-ML_TRAINING_HISTORY_MAX] changed = True if changed: await self.async_save() # ─── On-device matching-config tuning (Stage 4/5, opt-in) ────────────────── #: Only these bounded scoring weights (all in [0, 1]) may be overridden #: on-device, so a stored record can never change structural matching #: behaviour. Mirrors ml.matching_tuner.OVERRIDE_KEYS. _MATCHING_OVERRIDE_KEYS = ("corr_weight", "duration_weight", "energy_weight", "dtw_ensemble_w") def get_matching_config(self) -> dict[str, Any]: """Return the on-device tuned matcher scoring-weight record, if any. Record shape: ``{config, trained_at, cycle_count, baseline_test_top1, tuned_test_top1}`` where ``config`` holds the bounded scoring weights (``corr_weight``/``duration_weight``/``energy_weight``). Empty when the matcher is using the shipped defaults. """ raw = self._data.get("matching_config") if isinstance(raw, dict): return cast(JSONDict, raw).copy() return {} async def set_matching_config(self, record: dict[str, Any]) -> None: """Persist the tuned matcher scoring-weight override + metadata.""" self._data["matching_config"] = record await self.async_save() async def clear_matching_config(self) -> None: """Revert the matcher to the shipped default scoring weights.""" self._data.pop("matching_config", None) await self.async_save() def _matching_overrides(self) -> dict[str, float]: """Bounded scoring-weight overrides to merge into the matcher config. Only the whitelisted keys are honoured, each clamped to ``[0, 1]``, so a stored record can never alter structural matching behaviour - only the emphasis between shape, level and energy agreement. """ rec = self._data.get("matching_config") cfg = rec.get("config") if isinstance(rec, dict) else None out: dict[str, float] = {} if isinstance(cfg, dict): for k in self._MATCHING_OVERRIDE_KEYS: try: out[k] = min(1.0, max(0.0, float(cfg[k]))) except (KeyError, TypeError, ValueError): continue return out async def set_cycle_review( self, cycle_id: str, *, quality: str | None = None, golden: bool | None = None, tags: list[str] | None = None, notes: str | None = None, ) -> bool: """Attach an ML-Lab review to a cycle (Stage 4b). The review (``quality``/``golden``/``tags``/``notes``) is stored under the cycle's ``ml_review`` key and becomes a strong training label for the on-device quality model. Only the provided fields are updated. Returns True when the cycle was found and updated. """ cycle = next( (c for c in self._data.get("past_cycles", []) if c.get("id") == cycle_id), None, ) if not cycle: raise ValueError(f"Cycle {cycle_id} not found") prev_review = cycle.get("ml_review") prev_golden = bool(prev_review.get("golden")) if isinstance(prev_review, dict) else False review = dict(prev_review or {}) if quality is not None: review["quality"] = quality if golden is not None: review["golden"] = bool(golden) if tags is not None: review["tags"] = list(tags) if notes is not None: review["notes"] = notes review["reviewed_at"] = dt_util.now().isoformat() cycle["ml_review"] = review # Golden cycles seed the profile's matching reference/envelope. When the # golden flag actually flips (either direction), rebuild the affected # profile so its envelope + reference cycle + cohesion cache reflect the # new golden set. Only when the value changed — an unchanged save stays # idempotent and skips the (executor-bound) rebuild. golden_changed = golden is not None and bool(golden) != prev_golden profile_name = cycle.get("profile_name") if golden_changed and isinstance(profile_name, str) and profile_name: await self.async_rebuild_envelope(profile_name) await self.async_save() # Don't log the full review (notes/tags are user-entered) — only field names. _changed = [ n for n, v in (("quality", quality), ("golden", golden), ("tags", tags), ("notes", notes)) if v is not None ] self._logger.info( "Recorded ML review for cycle %s (updated: %s)", cycle_id, ", ".join(_changed) or "none" ) return True async def async_backfill_recorded_golden(self) -> int: """Flag manually-recorded cycles (meta.source == 'recorder') as golden references. Recorded == golden: a single stored flag, no duplicate field. Idempotent; saves only when something changed. Returns the count flagged. """ cycles = self._data.get("past_cycles", []) if not isinstance(cycles, list): return 0 # Snapshot each cycle's golden state (aligned by list position, which # _flag_recorded_cycles_golden never reorders) so we can rebuild exactly # the profiles whose cycles get newly flagged — golden cycles seed the # matching reference/envelope. before_golden = [ bool(c.get("ml_review", {}).get("golden")) if isinstance(c, dict) and isinstance(c.get("ml_review"), dict) else False for c in cycles ] flagged = _flag_recorded_cycles_golden(cycles) if flagged: affected: set[str] = set() for was_golden, cycle in zip(before_golden, cycles): if was_golden or not isinstance(cycle, dict): continue review = cycle.get("ml_review") if isinstance(review, dict) and review.get("golden"): pname = cycle.get("profile_name") if isinstance(pname, str) and pname: affected.add(pname) for pname in affected: await self.async_rebuild_envelope(pname) await self.async_save() self._logger.info("Backfilled golden flag on %s recorded cycle(s)", flagged) return flagged def get_feedback_history(self) -> dict[str, dict[str, Any]]: """Return mutable feedback history mapping (cycle_id -> record).""" raw = self._data.setdefault("feedback_history", {}) if isinstance(raw, dict): return cast(dict[str, dict[str, Any]], raw) return {} def get_pending_feedback(self) -> dict[str, dict[str, Any]]: """Return mutable pending feedback mapping (cycle_id -> request).""" raw = self._data.setdefault("pending_feedback", {}) if isinstance(raw, dict): return cast(dict[str, dict[str, Any]], raw) return {} def add_pending_feedback(self, cycle_id: str, request_data: dict[str, Any]) -> None: """Add a pending feedback request (sync wrapper, does not save immediately).""" feedbacks = self.get_pending_feedback() feedbacks[cycle_id] = request_data # Caller must ensure save is called eventually def prune_orphaned_feedback(self) -> int: """Drop pending-feedback entries whose cycle no longer exists. A cycle that was deleted/merged/trimmed can leave its pending feedback behind, so the device's "needs review" count stays >0 while the Cycles review filter (which matches feedback ids against real cycles) shows nothing. Returns the number pruned; never raises. Caller saves. """ try: pending = self._data.get("pending_feedback") if not isinstance(pending, dict) or not pending: return 0 live_ids = {c.get("id") for c in self.get_past_cycles()} orphans = [cid for cid in list(pending) if cid not in live_ids] for cid in orphans: pending.pop(cid, None) return len(orphans) except Exception: # noqa: BLE001 return 0 def get_profile(self, name: str) -> JSONDict | None: """Return a single profile by name with calculated stats (via list_profiles).""" # Reuse list_profiles logic to ensure consistency and avoid duplication all_profiles = self.list_profiles() return next((p for p in all_profiles if p["name"] == name), None) def get_profiles(self) -> dict[str, JSONDict]: """Return mutable profiles mapping (profile_name -> profile data).""" raw = self._data.setdefault("profiles", {}) if isinstance(raw, dict): return cast(dict[str, JSONDict], raw) return {} def get_past_cycles(self) -> list[CycleDict]: """Return mutable list of stored cycles.""" raw = self._data.setdefault("past_cycles", []) if isinstance(raw, list): return cast(list[CycleDict], raw) return [] # ── Community-store account (connect handoff) ───────────────────────────── def get_store_account(self) -> dict[str, Any]: """Full persisted store account incl. the refresh token (credential).""" raw = self._data.get("store_account") return dict(raw) if isinstance(raw, dict) else {} def get_store_identity(self) -> dict[str, Any]: """Safe account view for status/UI - never includes the refresh token.""" a = self.get_store_account() return { "connected": bool(a.get("refresh_token")), "uid": a.get("uid"), "name": a.get("name"), "brand": a.get("brand"), "model": a.get("model"), } async def set_store_account(self, account: dict[str, Any]) -> None: """Persist/merge the store account (refresh_token, uid, name, brand, model).""" cur = self.get_store_account() cur.update({k: v for k, v in account.items() if v is not None}) self._data["store_account"] = cur await self.async_save() async def clear_store_account(self) -> None: self._data.pop("store_account", None) await self.async_save() def get_reference_cycles(self) -> list[CycleDict]: """Return the imported-store reference cycles. These are NOT in ``past_cycles``: they feed only the envelope shape and the matcher template, never usage/energy/count/trend stats. """ raw = self._data.setdefault("reference_cycles", []) if isinstance(raw, list): return cast(list[CycleDict], raw) return [] def get_shareable_cycles(self) -> list[dict[str, Any]]: """Recorded/golden reference cycles eligible to share to the community store. A cycle qualifies when it is hand-flagged golden (``ml_review.golden``) or a manual recording (``meta.source == "recorder"``) and carries a program label. Imported ``reference_cycles`` are intentionally excluded (you do not re-share what you downloaded). Returns light summaries (no traces), most-recent-first; the whole set (not a page) so the panel's share tree can offer all of them. Never raises. """ out: list[dict[str, Any]] = [] for c in self.get_past_cycles(): if not isinstance(c, dict): continue program = str(c.get("profile_name") or "").strip() if not program: continue review = c.get("ml_review") if isinstance(c.get("ml_review"), dict) else {} meta = c.get("meta") if isinstance(c.get("meta"), dict) else {} if not (review.get("golden") or meta.get("source") == "recorder"): continue out.append({ "id": c.get("id"), "profile_name": program, "start_time": c.get("start_time"), "duration": c.get("duration"), "source": meta.get("source"), }) out.sort(key=lambda r: r.get("start_time") or "", reverse=True) return out async def add_reference_cycle( self, profile_name: str, points: list[list[float]], meta: dict[str, Any] ) -> str: """Import a reference cycle downloaded from the store into ``reference_cycles``. ``points`` is a raw trace of ``[offset_seconds, watts]`` pairs. ``meta`` may carry ``store_cycle_id`` (-> ``meta.source = "store:"``), ``store_uploaded_at`` and ``sampling_interval``. The cycle is stamped with import-time timestamps (its real run time is meaningless locally), forced ``status="completed"`` and ``ml_review.golden=True`` so it seeds the envelope shape, then the envelope is rebuilt. Never accumulates lifetime energy or touches ``past_cycles``. """ # Validate the trace BEFORE creating any persistent state (profile entry / # reference cycle): drop non-finite/non-numeric samples, require >= 2 points # and a positive time span. A garbage trace returns "" and mutates nothing. pairs: list[list[float]] = [] for p in (points or []): if len(p) < 2: continue try: x, y = float(p[0]), float(p[1]) except (TypeError, ValueError): continue if math.isfinite(x) and math.isfinite(y): pairs.append([x, y]) if len(pairs) < 2: return "" pairs.sort(key=lambda q: q[0]) # defensive: normalize any out-of-order offsets duration = float(pairs[-1][0] - pairs[0][0]) if duration <= 0: return "" # Re-base to offset 0 so envelope reconstruction and DTW work correctly. if pairs[0][0] != 0.0: origin = pairs[0][0] pairs = [[p[0] - origin, p[1]] for p in pairs] now = dt_util.now() store_id = str(meta.get("store_cycle_id") or "") cycle: CycleDict = { "profile_name": profile_name, "power_data": pairs, "start_time": now.isoformat(), # Keep the timestamp interval consistent with the imported duration # (both were previously "now", giving a zero-length interval). "end_time": (now + timedelta(seconds=duration)).isoformat(), "duration": duration, "status": "completed", "ml_review": {"golden": True}, "meta": { "source": f"store:{store_id}" if store_id else "store", "store_uploaded_at": meta.get("store_uploaded_at"), }, } if meta.get("sampling_interval"): cycle["sampling_interval"] = float(meta["sampling_interval"]) # A reference cycle implies its program exists locally; create a minimal profile # entry if absent so the matcher iterates it and the rebuild can set its template. profiles = self._data.setdefault("profiles", {}) if profile_name not in profiles: profiles[profile_name] = {"avg_duration": duration} self._add_cycle_data(cycle, target=self._data.setdefault("reference_cycles", [])) await self.async_rebuild_envelope(profile_name) await self.async_save() return str(cycle.get("id", "")) # ── Profile groups (Stage 5: near-duplicate variants) ────────────────────── def get_profile_groups(self) -> dict[str, JSONDict]: """Return mutable profile-groups mapping (group_name -> {members, created_at}). A group bundles near-duplicate profiles (e.g. the same program at different temperature/spin). The matcher treats the group as one aggregate candidate and only picks the exact member once the group wins. Groups with fewer than 2 present members are ignored by the matcher. """ raw = self._data.setdefault("profile_groups", {}) if not isinstance(raw, dict): self._data["profile_groups"] = {} return self._data["profile_groups"] # Drop members that no longer exist as profiles (kept consistent lazily). profiles = self.get_profiles() for g in raw.values(): if isinstance(g, dict) and isinstance(g.get("members"), list): g["members"] = [m for m in g["members"] if m in profiles] return cast(dict[str, JSONDict], raw) def _members_in_other_groups(self, members: list[str], exclude: str | None) -> dict[str, str]: """Map any member already assigned to a DIFFERENT group -> that group name.""" owner: dict[str, str] = {} for gname, g in self.get_profile_groups().items(): if gname == exclude or not isinstance(g, dict): continue for m in (g.get("members") or []): owner.setdefault(m, gname) return {m: owner[m] for m in members if m in owner} async def create_profile_group(self, name: str, members: list[str]) -> bool: """Create (or overwrite) a group with the given member profile names.""" name = (name or "").strip() if not name: raise ValueError("Group name is required") profiles = self.get_profiles() members = [m for m in dict.fromkeys(members or []) if m in profiles] # A profile may belong to at most one group (else _grouped_snapshots would # collapse it inconsistently). Reject up front — no partial mutation. conflicts = self._members_in_other_groups(members, exclude=name) if conflicts: raise ValueError( "Already in another group: " + ", ".join(f"{m} ({g})" for m, g in conflicts.items()) ) groups = self.get_profile_groups() groups[name] = {"members": members, "created_at": dt_util.now().isoformat()} self._cohesion_cache_generation += 1 await self.async_save() self._logger.info("Created profile group %r with %d members", name, len(members)) return True async def set_profile_group_members(self, name: str, members: list[str]) -> bool: """Replace a group's member list. Deletes the group if left empty.""" groups = self.get_profile_groups() if name not in groups: raise ValueError(f"Group {name} not found") profiles = self.get_profiles() members = [m for m in dict.fromkeys(members or []) if m in profiles] conflicts = self._members_in_other_groups(members, exclude=name) if conflicts: raise ValueError( "Already in another group: " + ", ".join(f"{m} ({g})" for m, g in conflicts.items()) ) if members: groups[name]["members"] = members else: groups.pop(name, None) self._cohesion_cache_generation += 1 await self.async_save() return True async def rename_profile_group(self, name: str, new_name: str) -> bool: groups = self.get_profile_groups() new_name = (new_name or "").strip() if name not in groups or not new_name: raise ValueError("Group not found or new name empty") if new_name != name: if new_name in groups: raise ValueError(f"A group named {new_name!r} already exists") groups[new_name] = groups.pop(name) self._cohesion_cache_generation += 1 await self.async_save() return True async def delete_profile_group(self, name: str) -> bool: groups = self.get_profile_groups() if groups.pop(name, None) is None: return False self._cohesion_cache_generation += 1 await self.async_save() self._logger.info("Deleted profile group %r", name) return True def _profile_curve(self, name: str, n: int = 150) -> np.ndarray | None: """A profile's envelope average resampled to n points, or None.""" env = self._data.get("envelopes", {}).get(name) if isinstance(self._data.get("envelopes"), dict) else None avg = env.get("avg") if isinstance(env, dict) else None if not avg or not isinstance(avg[0], (list, tuple)): return None ys = np.asarray([float(p[1]) for p in avg], dtype=float) if ys.size < 4: return None return np.interp(np.linspace(0, 1, n), np.linspace(0, 1, ys.size), ys) @staticmethod def _shape_similarity(a: np.ndarray, b: np.ndarray) -> float: """Duration- and amplitude-tolerant shape similarity in (0,1]. Peak-normalises both curves (so temperature/spin amplitude differences don't count against membership) then aligns them with a Sakoe-Chiba-band DTW (so a longer heating or draining phase at a different setting warps into alignment instead of being penalised as a different shape). 1.0 = identical shape; genuinely different programs score low even after warping. """ na = a / (float(a.max()) or 1.0) nb = b / (float(b.max()) or 1.0) dist = analysis.compute_dtw_lite(na, nb, band_width_ratio=0.2) norm = dist / max(len(na), 1) # mean per-sample distance on [0,1] curves return 1.0 / (1.0 + norm / 0.15) def group_cohesion(self, members: list[str]) -> float: """Minimum pairwise shape similarity among a group's members (1.0 = identical shapes; see _shape_similarity for the DTW/peak-normalised metric). Low cohesion means the members are not really the same program, so their averaged aggregate would be too generic and could out-match unrelated profiles - the matcher refuses to aggregate below GROUP_MIN_COHESION and the UI warns the user. Results are cached per member-set and invalidated via ``_cohesion_cache_generation`` (incremented by group mutation methods) so the DTW pairwise comparison does not run on the event loop every 5 minutes. """ key = tuple(sorted(members)) with self._cohesion_cache_lock: if self._cohesion_cache_generation != self._cohesion_cache_generation_checked: self._cohesion_cache.clear() self._cohesion_cache_generation_checked = self._cohesion_cache_generation if key in self._cohesion_cache: return self._cohesion_cache[key] curves = [c for c in (self._profile_curve(m) for m in members) if c is not None] if len(members) < 2: # A genuinely single-member group is trivially cohesive (and is never # collapsed anyway — nothing to aggregate). result = 1.0 elif len(curves) < 2: # Multi-member but too few built curves -> insufficient evidence, treat as # NOT cohesive so the group isn't collapsed into a blurry aggregate yet. result = 0.0 else: result = 1.0 for i in range(len(curves)): for j in range(i + 1, len(curves)): result = min(result, self._shape_similarity(curves[i], curves[j])) self._cohesion_cache[key] = result return result def _grouped_snapshots( self, snapshots: list[dict[str, Any]] ) -> tuple[list[dict[str, Any]], dict[str, list[str]], dict[str, dict[str, Any]]]: """Collapse each *cohesive* group into one aggregate candidate snapshot. Returns (snapshots, group_members, member_snaps). Groups with <2 present members, or cohesion below GROUP_MIN_COHESION (too generic an aggregate), are left as individual member snapshots. Aggregate name is prefixed ``__group__`` and mapped to its member profile names in group_members. """ groups = self.get_profile_groups() if not groups: return snapshots, {}, {} by_name = {s["name"]: s for s in snapshots} member_to_group: dict[str, str] = {} for gname, g in groups.items(): present = [m for m in (g.get("members") or []) if m in by_name] if len(present) < 2 or self.group_cohesion(present) < GROUP_MIN_COHESION: continue # keep members individual for m in present: member_to_group[m] = gname if not member_to_group: return snapshots, {}, {} n = 200 agg_curves: dict[str, list[np.ndarray]] = {} agg_durs: dict[str, list[float]] = {} member_snaps: dict[str, dict[str, Any]] = {} out: list[dict[str, Any]] = [] for s in snapshots: g = member_to_group.get(s["name"]) if g is None: out.append(s) continue member_snaps[s["name"]] = s sp = s.get("sample_power") or [] if len(sp) >= 2: arr = np.asarray(sp, dtype=float) agg_curves.setdefault(g, []).append( np.interp(np.linspace(0, 1, n), np.linspace(0, 1, arr.size), arr) ) agg_durs.setdefault(g, []).append(float(s.get("avg_duration") or 0.0)) group_members: dict[str, list[str]] = {} for g, curves in agg_curves.items(): key = f"__group__{g}" durs = [d for d in agg_durs[g] if d > 0] out.append({ "name": key, "avg_duration": float(np.mean(durs)) if durs else 0.0, "sample_power": np.mean(np.array(curves), axis=0).tolist(), }) group_members[key] = [m for m in member_to_group if member_to_group[m] == g and m in member_snaps] return out, group_members, member_snaps def _stage5_pick_member( self, current_power: list[float], current_duration: float, members: list[str], member_snaps: dict[str, dict[str, Any]], ) -> tuple[str, float | None, float | None]: """Within a winning group, pick the member whose duration + mean power + peak best match the cycle (temperature -> mean power, spin -> peak). Returns (member_name, individual_fit_score, member_avg_duration). The fit score is the chosen member's own alignment score, used as a sanity check.""" cur = np.asarray(current_power, dtype=float) if cur.size == 0 or not members: return (members[0] if members else ""), None, None cur_mp = float(cur.mean()); cur_pk = float(cur.max()) def agree(a: float, b: float, scale: float) -> float: if a <= 0 or b <= 0: return 0.0 return 1.0 / (1.0 + abs(math.log(a / b)) / scale) best_m, best_sc, best_dur = members[0], -1.0, None for m in members: snap = member_snaps.get(m) if not snap: continue sp = np.asarray(snap.get("sample_power") or [], dtype=float) if sp.size == 0: continue md = float(snap.get("avg_duration") or 0.0) sc = (agree(current_duration, md, 0.15) * agree(cur_mp, float(sp.mean()), 0.20) * agree(cur_pk, float(sp.max()), 0.20)) if sc > best_sc: best_sc, best_m, best_dur = sc, m, md fit = None snap = member_snaps.get(best_m) if snap and snap.get("sample_power"): try: fit = float(analysis.find_best_alignment(current_power, snap["sample_power"], 1.0)[0]) except Exception: # pylint: disable=broad-exception-caught fit = None return best_m, fit, best_dur def suggest_profile_groups(self, dur_tol: float = 0.60, sim_min: float = 0.85) -> list[dict[str, Any]]: """Detect clusters of near-duplicate profiles not already fully grouped. Two profiles cluster when their durations are within a (loose) ``dur_tol`` sanity bound AND their DTW/peak-normalised shape similarity exceeds ``sim_min`` (same program shape; they may differ in temperature/spin and in phase length, which the DTW alignment tolerates). The duration bound is loose because higher-temp/lower-rpm variants legitimately run longer - it only rules out grouping, say, a 20-min rinse with a 3-hour cotton. Returns {"members": [...], "existing_group": name|None} suggestions the user confirms. Never mutates state. """ profiles = self.get_profiles() envelopes = self._data.get("envelopes", {}) # Build per-profile (avg curve resampled to N, avg duration). N = 150 reps: dict[str, tuple[np.ndarray, float]] = {} for name, prof in profiles.items(): env = envelopes.get(name) if isinstance(envelopes, dict) else None avg = env.get("avg") if isinstance(env, dict) else None dur = float(prof.get("avg_duration") or 0.0) if not avg or dur <= 0 or not isinstance(avg[0], (list, tuple)): continue ys = np.asarray([float(p[1]) for p in avg], dtype=float) if ys.size < 4: continue curve = np.interp(np.linspace(0, 1, N), np.linspace(0, 1, ys.size), ys) reps[name] = (curve, dur) names = list(reps) # Union-find near-duplicates. parent = {n: n for n in names} def find(x: str) -> str: while parent[x] != x: parent[x] = parent[parent[x]]; x = parent[x] return x lim = math.log(1.0 + dur_tol) for i in range(len(names)): for j in range(i + 1, len(names)): a, b = names[i], names[j] (ca, da), (cb, db) = reps[a], reps[b] if da <= 0 or db <= 0 or abs(math.log(da / db)) > lim: continue if self._shape_similarity(ca, cb) > sim_min: parent[find(a)] = find(b) clusters: dict[str, list[str]] = {} for n in names: clusters.setdefault(find(n), []).append(n) # Only clusters of >=2; annotate with any existing group overlap; skip # clusters already fully contained in one group. out: list[dict[str, Any]] = [] groups = self.get_profile_groups() for members in clusters.values(): if len(members) < 2: continue existing = None for gname, g in groups.items(): gmembers = set(g.get("members") or []) if gmembers & set(members): existing = gname if set(members) <= gmembers: members = [] # already grouped break if members: out.append({"members": sorted(members), "existing_group": existing}) return out # ------------------------------------------------------------------ # A1: Underrun anomaly helpers # ------------------------------------------------------------------ def get_profile_median_duration(self, profile_name: str) -> float | None: """Median cycle duration (s) for this profile across all labeled cycles. Never raises.""" try: durations = [ float(c["duration"]) for c in self.get_past_cycles() if c.get("profile_name") == profile_name and c.get("duration") ] return float(np.median(durations)) if len(durations) >= 2 else None except Exception: # noqa: BLE001 return None # ------------------------------------------------------------------ # A2: Energy anomaly helpers # ------------------------------------------------------------------ def get_profile_energy_stats(self, profile_name: str) -> dict[str, float] | None: """Energy stats {avg_wh, std_wh, n} for this profile. None if fewer than 3 cycles. Never raises.""" try: energies = [ float(c["energy_wh"]) for c in self.get_past_cycles() if c.get("profile_name") == profile_name and c.get("energy_wh") ] if len(energies) < 3: return None arr = np.asarray(energies, dtype=float) return {"avg_wh": float(np.mean(arr)), "std_wh": float(np.std(arr)), "n": len(energies)} except Exception: # noqa: BLE001 return None # ------------------------------------------------------------------ # A4: Profile warm-up mode helper # ------------------------------------------------------------------ def get_profile_labeled_count(self, profile_name: str) -> int: """Number of labeled cycles for this profile. Never raises.""" try: return sum(1 for c in self.get_past_cycles() if c.get("profile_name") == profile_name) except Exception: # noqa: BLE001 return 0 def profile_has_reference_cycles(self, profile_name: str) -> bool: """True if this profile was seeded from imported store reference cycle(s). Such a profile is a trusted, community-shared template that the user downloaded to match immediately, so it is exempt from the local warm-up gate (which exists to stabilise profiles built from a few local cycles). """ try: return any( c.get("profile_name") == profile_name for c in self.get_reference_cycles() ) except Exception: # noqa: BLE001 return False # ------------------------------------------------------------------ # B1: Lifetime energy accumulator (HA Energy dashboard) # ------------------------------------------------------------------ def get_lifetime_energy_wh(self) -> float: """Total accumulated lifetime energy (Wh). Pure getter — never persists. Never raises.""" try: return float(self._data.get("lifetime_energy_wh", 0.0)) except Exception: # noqa: BLE001 return 0.0 async def async_add_lifetime_energy_wh(self, wh: float) -> None: """Add *wh* (clamped >= 0) to the lifetime energy total and persist. Called exactly once per completed cycle so it never double-counts. Does not backfill from history — the meter starts at 0 and accumulates forward. Ignores non-numeric input. """ try: add = max(0.0, float(wh)) except (ValueError, TypeError): return base = self.get_lifetime_energy_wh() self._data["lifetime_energy_wh"] = round(base + add, 3) await self.async_save() def get_lifetime_cycle_count(self) -> int: """Persisted monotonic lifetime completed-cycle count. Only ever increments (never regresses when history is trimmed/merged), so it is the correct basis for milestone crossings. Pure getter - never persists. Never raises; returns 0 when unset. """ try: return int(self._data.get("lifetime_cycle_count", 0) or 0) except Exception: # noqa: BLE001 return 0 def set_lifetime_cycle_count(self, count: int) -> None: """Set the lifetime cycle count in memory WITHOUT persisting. The cycle-end path batches this with the immediately-following lifetime energy save (:meth:`async_add_lifetime_energy_wh`) so the store is written once per cycle. Encapsulates the storage key so callers need not touch ``_data`` directly. Ignores non-integer input. """ try: self._data["lifetime_cycle_count"] = int(count) except (TypeError, ValueError): pass # ------------------------------------------------------------------ # E1: Maintenance log & predictive-maintenance reminders (Group E) # ------------------------------------------------------------------ def get_maintenance_log(self) -> list[dict[str, Any]]: """Return logged maintenance events, most-recent-first. Never raises. Each entry is ``{"id", "date", "event_type", "notes"}``. Entries with an unparseable date sort last. """ try: raw = self._data.get("maintenance_log", []) if not isinstance(raw, list): return [] entries = [dict(e) for e in raw if isinstance(e, dict)] _floor = datetime.min.replace(tzinfo=dt_util.UTC) entries.sort( key=lambda e: _parse_maintenance_dt(e.get("date")) or _floor, reverse=True, ) return entries except Exception: # noqa: BLE001 return [] async def async_add_maintenance_event( self, event_type: str, date: str | None = None, notes: str = "" ) -> dict[str, Any]: """Append a maintenance event and persist. Returns the created entry. ``event_type`` must be one of :data:`MAINTENANCE_EVENT_TYPES` (else raises ``ValueError``). ``date`` defaults to the current timestamp; a short unique id is generated for the entry. """ if event_type not in MAINTENANCE_EVENT_TYPES: raise ValueError(f"Unknown maintenance event_type: {event_type!r}") entry: dict[str, Any] = { "id": uuid.uuid4().hex[:12], "date": date if isinstance(date, str) and date else dt_util.now().isoformat(), "event_type": event_type, "notes": str(notes or ""), } log = self._data.setdefault("maintenance_log", []) if not isinstance(log, list): log = [] self._data["maintenance_log"] = log log.append(entry) await self.async_save() return entry async def async_delete_maintenance_event(self, event_id: str) -> bool: """Remove a maintenance event by id, persist, and report whether removed.""" log = self._data.get("maintenance_log") if not isinstance(log, list): return False remaining = [e for e in log if not (isinstance(e, dict) and e.get("id") == event_id)] if len(remaining) == len(log): return False self._data["maintenance_log"] = remaining await self.async_save() return True def cycles_since_maintenance(self, event_type: str) -> int: """Count completed cycles since the most recent maintenance event of a type. Counts completed cycles (``status == "completed"``) whose ``start_time`` is after the most recent maintenance event of ``event_type``. If no such event was ever logged, returns the total completed-cycle count. Never raises. """ try: completed = [ c for c in self.get_past_cycles() if isinstance(c, dict) and c.get("status") == "completed" ] latest_dt: datetime | None = None for e in self._data.get("maintenance_log", []) or []: if not isinstance(e, dict) or e.get("event_type") != event_type: continue dt = _parse_maintenance_dt(e.get("date")) if dt is not None and (latest_dt is None or dt > latest_dt): latest_dt = dt if latest_dt is None: return len(completed) count = 0 for c in completed: start = _parse_start_dt(c.get("start_time")) if start is not None and start > latest_dt: count += 1 return count except Exception: # noqa: BLE001 return 0 def get_maintenance_due(self, reminder_cfg: dict[str, Any] | None) -> list[str]: """Return event types whose cycles-since threshold has been reached. For each event type with a positive integer threshold in ``reminder_cfg``, includes it when ``cycles_since_maintenance(event_type) >= threshold``. Never raises. """ try: if not isinstance(reminder_cfg, dict): return [] due: list[str] = [] for event_type, threshold in reminder_cfg.items(): try: thr = int(threshold) except (TypeError, ValueError): continue if thr <= 0: continue if self.cycles_since_maintenance(str(event_type)) >= thr: due.append(str(event_type)) return due except Exception: # noqa: BLE001 return [] def has_recent_maintenance( self, event_type: str, days: int = MAINTENANCE_RECENT_SUPPRESS_DAYS ) -> bool: """True if a matching maintenance event was logged within ``days``. Never raises.""" try: cutoff = dt_util.now() - timedelta(days=max(0, int(days))) for e in self._data.get("maintenance_log", []) or []: if not isinstance(e, dict) or e.get("event_type") != event_type: continue dt = _parse_maintenance_dt(e.get("date")) if dt is not None and dt >= cutoff: return True return False except Exception: # noqa: BLE001 return False def compute_profile_health(self) -> dict[str, dict[str, Any]]: """Compute per-profile health indicators from labeled cycle history. For each profile that has at least 3 labeled cycles, returns a dict with: ``cycle_count`` – number of labeled cycles used in the calculation ``confidence_mean`` – mean match_confidence across those cycles (0–1) ``duration_cv`` – coefficient of variation of cycle durations (lower = consistent) ``health_score`` – composite 0–1 health score (1 = healthy, 0 = needs attention) ``health_status`` – "healthy" / "fair" / "poor" / "unknown" ``shape_drift`` – True when the early/recent envelope correlation is below SHAPE_DRIFT_THRESHOLD (only present when >= SHAPE_DRIFT_MIN_CYCLES labeled cycles with power_data exist) ``shape_drift_correlation`` – Pearson r between early and recent average envelopes Profiles with fewer than 3 labeled cycles return ``health_status="unknown"``. Never raises — returns an empty dict on any error. """ try: cycles = self.get_past_cycles() from collections import defaultdict # pylint: disable=import-outside-toplevel by_profile: dict[str, list[dict[str, Any]]] = defaultdict(list) for c in cycles: name = c.get("profile_name") if name: by_profile[str(name)].append(c) result: dict[str, dict[str, Any]] = {} for name, pcy in by_profile.items(): count = len(pcy) durations = [float(c["duration"]) for c in pcy if c.get("duration")] confidences = [ float(c["match_confidence"]) for c in pcy if c.get("match_confidence") is not None # keep genuine 0.0, drop only absent ] if count < 3 or not durations: result[name] = {"cycle_count": count, "health_status": "unknown"} continue dur_arr = np.asarray(durations, dtype=float) dur_mean = float(np.mean(dur_arr)) dur_cv = float(np.std(dur_arr) / dur_mean) if dur_mean > 0 else 0.0 conf_mean = float(np.mean(confidences)) if confidences else 0.5 # consistency: 1 at CV=0, linearly decays to 0 at CV=0.5+ consistency = max(0.0, 1.0 - dur_cv / 0.5) health_score = round(0.5 * consistency + 0.5 * conf_mean, 3) if health_score >= 0.65: status = "healthy" elif health_score >= 0.40: status = "fair" else: status = "poor" # A5: Shape drift — compare early vs recent power curve envelopes. _sd: dict[str, Any] = {} _traced = [c for c in pcy if c.get("power_data")] if len(_traced) >= SHAPE_DRIFT_MIN_CYCLES: _third = len(_traced) // 3 _early = _traced[:_third] _recent = _traced[-_third:] try: from .signal_processing import resample_to_n as _resamp # noqa: PLC0415 def _avg_env(cycles: list[dict[str, Any]]) -> "np.ndarray | None": _traces = [] for _c in cycles: _raw = decompress_power_data(_c) if not _raw: continue _pwr = [float(_p) for _, _p in _raw] if len(_pwr) < 5: continue _t = np.asarray(_resamp(_pwr, SHAPE_DRIFT_RESAMPLE_N), dtype=float) _mx = _t.max() if _mx > 0: _t = _t / _mx _traces.append(_t) if len(_traces) < 2: return None return np.mean(np.stack(_traces), axis=0) _early_env = _avg_env(_early) _rec_env = _avg_env(_recent) if _early_env is not None and _rec_env is not None: _corr = float(np.corrcoef(_early_env, _rec_env)[0, 1]) if not np.isfinite(_corr): _corr = 1.0 _sd = { "shape_drift": _corr < SHAPE_DRIFT_THRESHOLD, "shape_drift_correlation": round(_corr, 3), } except Exception: # noqa: BLE001 pass result[name] = { "cycle_count": count, "confidence_mean": round(conf_mean, 3), "duration_cv": round(dur_cv, 3), "health_score": health_score, "health_status": status, **_sd, # merges shape_drift and shape_drift_correlation when available } return result except Exception: # noqa: BLE001 return {} # ------------------------------------------------------------------ # Coverage gap detection (unmatched cycle clustering) # ------------------------------------------------------------------ def suggest_coverage_gaps( self, recent_window: int = 30, min_unmatched: int = 5, min_unmatched_rate: float = 0.20, low_confidence_threshold: float = 0.40, duration_bucket_s: float = 900.0, ) -> dict[str, Any]: """Detect gaps in profile coverage from recent unlabelled cycles. Looks at the ``recent_window`` most recent cycles. When there are enough unmatched (no ``profile_name``) or low-confidence cycles, returns a record with: ``unmatched_count`` – number of cycles with no profile label ``low_confidence_count`` – number of labelled cycles below the confidence threshold (potential mis-matches) ``unmatched_rate`` – fraction of recent cycles that are unmatched ``suggest_create`` – True when unmatched_count >= min_unmatched AND unmatched_rate >= min_unmatched_rate ``duration_clusters`` – list of duration-cluster dicts for unmatched cycles: ``{"duration_bucket_min": int, "count": int, "avg_duration_s": float}`` sorted descending by count. Only clusters with ≥ 2 members are returned. Returns an empty dict when there are fewer than ``min_unmatched`` unmatched cycles or on any error (never raises). """ try: cycles = self.get_past_cycles() recent = cycles[-recent_window:] if len(cycles) > recent_window else cycles if not recent: return {} unmatched: list[dict[str, Any]] = [] low_conf: list[dict[str, Any]] = [] for c in recent: name = c.get("profile_name") if not name: unmatched.append(c) else: conf = c.get("match_confidence") if ( isinstance(conf, (int, float)) and not isinstance(conf, bool) and conf < low_confidence_threshold ): low_conf.append(c) n_unmatched = len(unmatched) n_total = len(recent) rate = n_unmatched / n_total if n_total > 0 else 0.0 if n_unmatched < min_unmatched: return {} # Cluster unmatched cycles by duration bucket (simple histogram) bucket_dur: dict[int, list[float]] = {} for c in unmatched: dur = c.get("duration") if isinstance(dur, (int, float)) and not isinstance(dur, bool) and dur > 0: bucket = int(dur // duration_bucket_s) bucket_dur.setdefault(bucket, []).append(float(dur)) clusters = [] for bucket, durs in sorted(bucket_dur.items(), key=lambda kv: -len(kv[1])): if len(durs) >= 2: clusters.append({ "duration_bucket_min": int(bucket * duration_bucket_s / 60), "count": len(durs), "avg_duration_s": round(float(np.mean(durs)), 1), }) # A3: Shape-similarity clustering within duration buckets profile_suggestions: list[dict[str, Any]] = [] for bucket, durs in sorted(bucket_dur.items(), key=lambda kv: -len(kv[1])): if len(durs) < 2: continue bucket_cycles = [ c for c in unmatched if c.get("power_data") and c.get("duration") and int(float(c["duration"]) // duration_bucket_s) == bucket ] if len(bucket_cycles) < 2: continue try: from .signal_processing import resample_to_n # noqa: PLC0415 traces: list[np.ndarray] = [] ids: list[str] = [] for c in bucket_cycles[:5]: # cap at 5 per bucket for performance raw = decompress_power_data(c) if not raw: continue pwr = [float(p) for _, p in raw] if len(pwr) < 5: continue t = np.asarray(resample_to_n(pwr, CLUSTER_RESAMPLE_N), dtype=float) mx = t.max() if mx > 0: t = t / mx traces.append(t) ids.append(str(c.get("id", ""))) if len(traces) < 2: continue corrs = [ float(np.corrcoef(traces[i], traces[j])[0, 1]) for i in range(len(traces)) for j in range(i + 1, len(traces)) ] valid_corrs = [r for r in corrs if np.isfinite(r)] if not valid_corrs: continue avg_corr = float(np.mean(valid_corrs)) if avg_corr >= CLUSTER_SHAPE_SIMILARITY_THRESHOLD: avg_dur_s = float(np.mean(durs)) profile_suggestions.append({ "suggested_name": f"~{int(avg_dur_s // 60)} min program", "cycle_ids": [cid for cid in ids if cid], "avg_duration_s": round(avg_dur_s, 1), "count": len(ids), "similarity": round(avg_corr, 3), }) except Exception: # noqa: BLE001 continue # Most-recent unmatched cycle id, so the setup advisor's phase-2 # "unmatched" nudge can deep-link straight to it (open_cycle:) # instead of always falling back to the whole unlabelled list. last_unmatched_cycle_id = None for c in reversed(unmatched): cid = c.get("id") if cid: last_unmatched_cycle_id = cid break return { "unmatched_count": n_unmatched, "low_confidence_count": len(low_conf), "unmatched_rate": round(rate, 3), "suggest_create": bool(n_unmatched >= min_unmatched and rate >= min_unmatched_rate), "duration_clusters": clusters, "profile_suggestions": profile_suggestions, # NEW (A3) "last_unmatched_cycle_id": last_unmatched_cycle_id, } except Exception: # noqa: BLE001 return {} # ------------------------------------------------------------------ # Profile trend analysis (pure stats, appliance performance drift) # ------------------------------------------------------------------ def compute_profile_trends( self, min_cycles: int = 12, recent_window: int = 8, slope_threshold_pct: float = 0.08, ) -> dict[str, dict[str, Any]]: """Detect per-profile duration / energy drift using ordinary least-squares. Uses all labeled cycles in chronological order. For each profile with at least ``min_cycles`` labeled cycles: * Fits a linear trend (OLS via NumPy) to the most-recent N=``len(cycles)`` values (not just the window — the window is used only for the *recent mean* display; the slope is computed on all data so short histories don't give noisy slopes). * Expresses the slope as percent-change-per-cycle relative to the mean, so the magnitude is comparable across low- and high-duration profiles. * Classifies as ``"up"`` / ``"down"`` / ``"stable"`` per metric when the normalized slope exceeds ``slope_threshold_pct`` (default 8%/cycle). Returns a dict mapping profile_name → trend record: ``duration_trend`` – "up" / "down" / "stable" ``duration_slope_pct`` – normalized slope (%/cycle) ``duration_recent_mean_s`` – mean duration of the last N cycles (s) ``energy_trend`` – "up" / "down" / "stable" (if available) ``energy_slope_pct`` – normalized slope (%/cycle) ``energy_recent_mean_wh`` – mean energy of the last N cycles (Wh) ``cycle_count`` – number of labeled cycles used ``recent_window`` – how many cycles the recent means cover Profiles with fewer than ``min_cycles`` labeled cycles are omitted. Never raises — returns ``{}`` on any error. """ try: cycles = self.get_past_cycles() from collections import defaultdict # pylint: disable=import-outside-toplevel by_profile: dict[str, list[dict[str, Any]]] = defaultdict(list) for c in cycles: name = c.get("profile_name") if name: by_profile[str(name)].append(c) result: dict[str, dict[str, Any]] = {} for name, pcy in by_profile.items(): if len(pcy) < min_cycles: continue # Preserve insertion order (chronological from get_past_cycles) durations = [float(c["duration"]) for c in pcy if c.get("duration")] energies = [float(c["energy_wh"]) for c in pcy if c.get("energy_wh")] if len(durations) < min_cycles: continue def _slope_pct(values: list[float]) -> float: """OLS slope as % of mean per cycle.""" arr = np.asarray(values, dtype=float) n = len(arr) x = np.arange(n, dtype=float) x -= x.mean() denom = float(np.dot(x, x)) if denom < 1e-9: return 0.0 slope = float(np.dot(x, arr)) / denom mean_val = float(np.mean(arr)) return (slope / mean_val) if abs(mean_val) > 1e-9 else 0.0 def _classify(slope_pct: float) -> str: if slope_pct > slope_threshold_pct: return "up" if slope_pct < -slope_threshold_pct: return "down" return "stable" dur_slope = _slope_pct(durations) dur_recent = float(np.mean(durations[-recent_window:])) rec: dict[str, Any] = { "cycle_count": len(pcy), "recent_window": min(recent_window, len(durations)), "duration_trend": _classify(dur_slope), "duration_slope_pct": round(dur_slope * 100, 2), "duration_recent_mean_s": round(dur_recent, 1), } if len(energies) >= min_cycles: en_slope = _slope_pct(energies) en_recent = float(np.mean(energies[-recent_window:])) rec["energy_trend"] = _classify(en_slope) rec["energy_slope_pct"] = round(en_slope * 100, 2) rec["energy_recent_mean_wh"] = round(en_recent, 1) result[name] = rec return result except Exception: # noqa: BLE001 return {} def compute_profile_advisories(self) -> list[dict[str, Any]]: """Actionable per-profile maintenance advisories from existing signals. Pure statistics (no ML): consolidates :meth:`compute_profile_health` and :meth:`compute_profile_trends` into a small ranked list of human-readable, actionable recommendations (e.g. "durations trending longer -> re-record"). These surface as recommendations in the Profiles tab - never a notification. Each item is ``{profile, severity, code, message}``; severity is ``"warning"`` or ``"info"``. Returns ``[]`` on error (never raises). """ try: advisories: list[dict[str, Any]] = [] health = self.compute_profile_health() or {} trends = self.compute_profile_trends() or {} for name, h in health.items(): if h.get("health_status") == "poor": advisories.append({ "profile": name, "severity": "warning", "code": "poor_health", "message": ( f"'{name}' has a low fit score - its recent cycles vary " "a lot or match weakly. Review its cycles or re-record " "the profile so matching and time estimates stay accurate." ), # Localization: panel renders _t(message_key, message_params, # message). The English `message` above is the fallback. "message_key": "msg.advisory_poor_health", "message_params": {"name": name}, }) elif h.get("shape_drift"): corr = h.get("shape_drift_correlation") corr_str = f" (correlation {corr:.2f})" if corr is not None else "" advisories.append({ "profile": name, "severity": "info", "code": "shape_drift", "message": ( f"'{name}' has drifted significantly from its original " f"power shape{corr_str}. The appliance may have changed " "behaviour over time (e.g. limescale, wear). Consider " "re-recording this profile with recent cycles." ), "message_key": ( "msg.advisory_shape_drift_corr" if corr is not None else "msg.advisory_shape_drift" ), "message_params": ( {"name": name, "corr": f"{corr:.2f}"} if corr is not None else {"name": name} ), }) for name, t in trends.items(): # Skip profiles already flagged poor to avoid double advice. if health.get(name, {}).get("health_status") == "poor": continue if t.get("duration_trend") == "up": # duration_slope_pct is already expressed as %/cycle by # compute_profile_trends (dur_slope * 100); do NOT re-scale. pct = abs(float(t.get("duration_slope_pct") or 0.0)) advisories.append({ "profile": name, "severity": "info", "code": "duration_trend_up", "message": ( f"'{name}' cycles are running progressively longer " f"(about +{pct:.0f}% per cycle). If the appliance's " "behaviour changed, re-record or rebuild this profile." ), "message_key": "msg.advisory_duration_trend_up", "message_params": {"name": name, "pct": f"{pct:.0f}"}, }) elif t.get("energy_trend") == "up": # energy_slope_pct is already %/cycle (en_slope * 100); no re-scale. pct = abs(float(t.get("energy_slope_pct") or 0.0)) advisories.append({ "profile": name, "severity": "info", "code": "energy_trend_up", "message": ( f"'{name}' is drawing progressively more energy " f"(about +{pct:.0f}% per cycle) - worth checking the " "appliance if that is unexpected." ), "message_key": "msg.advisory_energy_trend_up", "message_params": {"name": name, "pct": f"{pct:.0f}"}, }) # Phase-structure consistency (phase-matching device types only). Uses # the cached per-role phase profile: a profile whose member cycles heat # for wildly different times, or where only some cycles heat at all, # most likely mixes different programs/temperatures under one label - # which hurts both matching and the phase-resolved ETA. Advisory only # (Profiles tab); no relabeling (phase matching does not label better). _sd = getattr(self, "_data", None) _envs = _sd.get("envelopes") if isinstance(_sd, dict) else None for pname, penv in (_envs if isinstance(_envs, dict) else {}).items(): if health.get(pname, {}).get("health_status") == "poor": continue # avoid double advice pp = penv.get("phase_profile") if isinstance(penv, dict) else None if not isinstance(pp, dict): continue try: if int(pp.get("n_cycles") or 0) < PHASE_CONSISTENCY_MIN_CYCLES: continue heat = (pp.get("roles") or {}).get("heating") or {} heat_mean = float(heat.get("dur_mean") or 0.0) heat_std = float(heat.get("dur_std") or 0.0) heat_occ = float(heat.get("occurrence") or 0.0) heat_cv = (heat_std / heat_mean) if heat_mean > 60.0 else 0.0 mixed_temp = heat_cv > PHASE_HEAT_CV_WARN mixed_prog = PHASE_HEAT_OCC_MIXED_LO <= heat_occ <= PHASE_HEAT_OCC_MIXED_HI if not (mixed_temp or mixed_prog): continue advisories.append({ "profile": pname, "severity": "warning", "code": "phase_inconsistent", "message": ( f"'{pname}' looks like it mixes different programs or " "temperatures - its cycles heat for very different lengths " "of time. Splitting it into separate profiles (e.g. per " "temperature) will improve matching and time estimates." ), "message_key": "msg.advisory_phase_inconsistent", "message_params": {"name": pname}, }) except (TypeError, ValueError): continue # E1: suppress the "needs maintenance" nag (duration-trending-longer / # shape-drift/poor-fit) when the user recently logged a descale, filter # clean, or drum clean — any recent maintenance clears the reminder. try: # ``is True`` (not truthiness) so a mocked store whose method # returns a MagicMock does not accidentally suppress advisories. if any( self.has_recent_maintenance(evt) is True for evt in ("descale", "filter_clean", "drum_clean") ): _nag_codes = {"duration_trend_up", "poor_health", "shape_drift"} advisories = [a for a in advisories if a.get("code") not in _nag_codes] except Exception: # noqa: BLE001 pass order = {"warning": 0, "info": 1} advisories.sort(key=lambda a: order.get(a["severity"], 2)) return advisories except Exception: # noqa: BLE001 return [] # ------------------------------------------------------------------ # Match ranking history (per-cycle ML commit snapshots) # ------------------------------------------------------------------ def record_match_ranking_snapshot( self, start_time_iso: str, features: dict[str, float], top1_profile: str, top1_score: float, top2_score: float | None, candidate_count: int, cycle_id: str = "", ) -> None: """Append a live-match ranking snapshot for the active cycle. Snapshots are keyed by ``start_time_iso`` (and optionally ``cycle_id``) so that ``confirm_match_ranking_snapshots`` can back-fill the confirmed label when the cycle ends. Pre-computed feature scalars are stored (not raw traces) to keep footprint small. The store is NOT persisted here — the caller must schedule ``async_save``. """ from .const import MATCH_RANKING_HISTORY_MAX # pylint: disable=import-outside-toplevel history: list[dict[str, Any]] = self._data.setdefault("match_ranking_history", []) history.append({ "start_time_iso": start_time_iso, "cycle_id": str(cycle_id) if cycle_id else "", "features": dict(features), "top1_profile": str(top1_profile), "top1_score": round(float(top1_score), 4), "top2_score": round(float(top2_score), 4) if top2_score is not None else None, "candidate_count": int(candidate_count), "confirmed_label": None, }) # Trim to the most recent N snapshots. if len(history) > MATCH_RANKING_HISTORY_MAX: del history[: len(history) - MATCH_RANKING_HISTORY_MAX] def confirm_match_ranking_snapshots( self, start_time_iso: str, confirmed_label: str, cycle_id: str = "", ) -> int: """Back-fill the confirmed label for all snapshots belonging to a cycle. When ``cycle_id`` is provided (and the snapshot was recorded with one), matches by ``cycle_id`` to avoid cross-contamination between cycles that share the same second-resolution ``start_time_iso``. Falls back to ``start_time_iso`` matching for snapshots recorded without a cycle_id (backward compatibility). Returns the number of snapshots updated. """ history = self._data.get("match_ranking_history") if not isinstance(history, list): return 0 updated = 0 for snap in history: if not isinstance(snap, dict): continue snap_cid = snap.get("cycle_id") or "" if cycle_id and snap_cid: # Both sides have an ID — match by ID for precision. if snap_cid == cycle_id: snap["confirmed_label"] = str(confirmed_label) updated += 1 else: # Legacy path: match by timestamp (no cycle_id on one or both sides). if snap.get("start_time_iso") == start_time_iso: snap["confirmed_label"] = str(confirmed_label) updated += 1 return updated def get_match_ranking_history(self) -> list[dict[str, Any]]: """Return all ranking snapshots (confirmed and pending). Callers that build a training dataset should filter to snapshots where ``confirmed_label`` is not None. """ raw = self._data.get("match_ranking_history") return list(raw) if isinstance(raw, list) else [] def _get_shared_custom_phases(self) -> list[dict[str, Any]]: """Return mutable shared custom phase list with legacy flattening.""" raw = self._data.setdefault("custom_phases", []) if isinstance(raw, list): return cast(list[dict[str, Any]], raw) # Legacy format: {device_type: [phase, ...]}. Flatten to shared list. flattened: list[dict[str, Any]] = [] seen: set[tuple[str, str]] = set() if isinstance(raw, dict): raw_dict = cast(dict[str, Any], raw) for legacy_device_type, phase_list in raw_dict.items(): if not isinstance(phase_list, list): continue for item in cast(list[Any], phase_list): if not isinstance(item, dict): continue item_dict = cast(dict[str, Any], item) name = str(item_dict.get("name", "")).strip() if not name: continue device_type = str(legacy_device_type or "").strip() key = (name.casefold(), device_type.casefold()) if key in seen: continue seen.add(key) flattened.append( { "name": name, "description": str(item_dict.get("description", "")).strip(), "device_type": device_type, "created_at": item_dict.get("created_at") or dt_util.now().isoformat(), } ) self._data["custom_phases"] = flattened return self._data["custom_phases"] def list_custom_phases(self, device_type: str) -> list[dict[str, Any]]: """Return shared custom phases relevant to the requested device type.""" def applies_to_device(item_device_type: str, target_device_type: str) -> bool: if not item_device_type: return True return item_device_type == target_device_type target = str(device_type or "").strip() phases = self._get_shared_custom_phases() return [ { "id": str(p.get("id", "")), "name": str(p.get("name", "")).strip(), "description": str(p.get("description", "")).strip(), "device_type": str(p.get("device_type", "")).strip(), "is_default": False, } for p in phases if p.get("name") and applies_to_device(str(p.get("device_type", "")).strip(), target) ] def list_phase_catalog(self, device_type: str) -> list[dict[str, Any]]: """Return merged shared default + custom phase catalog.""" return merge_phase_catalog(device_type, self.list_custom_phases(device_type)) async def async_create_custom_phase( self, device_type: str, phase_name: str, description: str = "", ) -> None: """Create a custom phase in the shared catalog.""" target_device_type = str(device_type or "").strip() name = normalize_phase_name(phase_name) desc = str(description or "").strip() catalog = self.list_phase_catalog(target_device_type) if any(str(p.get("name", "")).casefold() == name.casefold() for p in catalog): raise ValueError("duplicate_phase") self._get_shared_custom_phases().append( { "id": str(uuid.uuid4()), "name": name, "description": desc, "device_type": target_device_type, "created_at": dt_util.now().isoformat(), } ) await self.async_save() async def async_update_custom_phase( self, phase_id: str, new_name: str, description: str = "", ) -> None: """Update a phase by id, propagating rename to profile assignments. If phase_id matches a built-in, a custom override is created using the built-in's id so the merge can replace it in-place. """ target_name = normalize_phase_name(new_name) desc = str(description or "").strip() phases = self._get_shared_custom_phases() # Look for an existing custom entry with this id. found: dict[str, Any] | None = next( (p for p in phases if str(p.get("id", "")) == phase_id), None ) creating_new = found is None if creating_new: builtin = get_builtin_phase_by_id(phase_id) if builtin is None: raise ValueError("phase_not_found") candidate: dict[str, Any] = { "id": phase_id, "name": str(builtin.get("name", "")), "description": "", "device_type": str(builtin.get("device_type", "")), "created_at": dt_util.now().isoformat(), } else: candidate = found # type: ignore[assignment] old_name = str(candidate.get("name", "")) target_device_type = str(candidate.get("device_type", "")).strip() # Duplicate-name check before any mutation. for p in self.list_phase_catalog(target_device_type): pname = str(p.get("name", "")) if pname.casefold() == target_name.casefold() and pname.casefold() != old_name.casefold(): raise ValueError("duplicate_phase") if creating_new: phases.append(candidate) found = candidate found["name"] = target_name # type: ignore[index] found["description"] = desc # type: ignore[index] # Propagate rename to profile assignments. for profile in self.get_profiles().values(): profile_device_type = str(profile.get("device_type", "")).strip() if target_device_type and profile_device_type != target_device_type: continue phases_assigned = profile.get("phases", []) if not isinstance(phases_assigned, list): continue for assigned in cast(list[dict[str, Any]], phases_assigned): if str(assigned.get("name", "")).casefold() == old_name.casefold(): assigned["name"] = target_name await self.async_save() async def async_delete_custom_phase(self, phase_id: str) -> int: """Delete a custom phase by id and remove matching profile assignments. Returns number of removed assignments. Raises ValueError('phase_not_found') if no custom phase has this id. Raises ValueError('cannot_delete_builtin') if the id is a built-in phase. """ phases = self._get_shared_custom_phases() found = next((p for p in phases if str(p.get("id", "")) == phase_id), None) if found is None: raise ValueError("phase_not_found") if get_builtin_phase_by_id(phase_id) is not None: raise ValueError("cannot_delete_builtin") phase_name = str(found.get("name", "")) phase_scope = str(found.get("device_type", "")).strip() self._data["custom_phases"] = [p for p in phases if str(p.get("id", "")) != phase_id] removed_assignments = 0 for profile in self.get_profiles().values(): profile_device_type = str(profile.get("device_type", "")).strip() if phase_scope and profile_device_type != phase_scope: continue assigned = profile.get("phases", []) if not isinstance(assigned, list): continue assigned_list = cast(list[dict[str, Any]], assigned) before = len(assigned_list) profile["phases"] = [ p for p in assigned_list if str(p.get("name", "")).casefold() != phase_name.casefold() ] removed_assignments += before - len(profile["phases"]) await self.async_save() return removed_assignments def get_profile_phase_ranges(self, profile_name: str) -> list[dict[str, Any]]: """Return assigned phase ranges for a profile.""" profiles = self._data.get("profiles", {}) if not isinstance(profiles, dict): return [] profile_raw = cast(dict[str, Any], profiles).get(profile_name) profile = cast(dict[str, Any], profile_raw) if isinstance(profile_raw, dict) else None if not isinstance(profile, dict): return [] phases = profile.get("phases", []) if not isinstance(phases, list): return [] phases_list = cast(list[dict[str, Any]], phases) cleaned: list[dict[str, Any]] = [] for phase in phases_list: try: start = float(phase.get("start", 0.0)) end = float(phase.get("end", 0.0)) except (TypeError, ValueError): continue if end <= start: continue cleaned.append( { "name": str(phase.get("name", "")).strip(), "start": start, "end": end, "description": str(phase.get("description", "")).strip(), } ) return sorted(cleaned, key=lambda x: (x["start"], x["end"], x["name"])) def get_profile_phase_ranges_for_device( self, profile_name: str, device_type: str ) -> list[dict[str, Any]]: """Return assigned ranges enriched with catalog descriptions.""" ranges = self.get_profile_phase_ranges(profile_name) catalog = self.list_phase_catalog(device_type) desc_map = { str(p.get("name", "")).casefold(): str(p.get("description", "")).strip() for p in catalog } enriched: list[dict[str, Any]] = [] for row in ranges: name = str(row.get("name", "")).strip() enriched.append( { "name": name, "start": float(row.get("start", 0.0)), "end": float(row.get("end", 0.0)), "description": desc_map.get(name.casefold(), ""), } ) return enriched async def async_set_profile_phase_ranges( self, profile_name: str, ranges: list[dict[str, Any]] ) -> None: """Replace assigned phase ranges for a profile.""" profile = self._data.get("profiles", {}).get(profile_name) if not isinstance(profile, dict): raise ValueError("profile_not_found") normalized: list[dict[str, Any]] = [] for item in ranges: name = normalize_phase_name(str(item.get("name", ""))) try: start = float(item.get("start", 0.0)) end = float(item.get("end", 0.0)) except (TypeError, ValueError) as e: raise ValueError("invalid_phase_range") from e if end <= start: raise ValueError("invalid_phase_range") normalized.append({"name": name, "start": start, "end": end}) normalized.sort(key=lambda x: (x["start"], x["end"])) prev_end = None for row in normalized: if prev_end is not None and row["start"] < prev_end: raise ValueError("overlapping_phase_ranges") prev_end = row["end"] profile["phases"] = normalized await self.async_save() def set_duration_tolerance(self, tolerance: float) -> None: """Set the profile duration tolerance used by matching heuristics.""" try: self._duration_tolerance = float(tolerance) except (TypeError, ValueError): pass def set_retention_limits( self, *, max_past_cycles: int, max_full_traces_per_profile: int, max_full_traces_unlabeled: int, ) -> None: """Set retention caps for stored cycles and full-resolution traces.""" try: self._max_past_cycles = int(max_past_cycles) self._max_full_traces_per_profile = int(max_full_traces_per_profile) self._max_full_traces_unlabeled = int(max_full_traces_unlabeled) except (TypeError, ValueError): pass def get_duration_ratio_limits(self) -> tuple[float, float]: """Return (min_duration_ratio, max_duration_ratio) used for duration matching.""" return (float(self._min_duration_ratio), float(self._max_duration_ratio)) def set_duration_ratio_limits(self, *, min_ratio: float, max_ratio: float) -> None: """Update duration ratio bounds used for duration matching.""" try: self._min_duration_ratio = float(min_ratio) self._max_duration_ratio = float(max_ratio) except (TypeError, ValueError): pass def _migrate_phase_ids(self) -> bool: """Assign ids to any custom phase missing one. Returns True if anything changed.""" phases = self._data.get("custom_phases", []) if not isinstance(phases, list): return False changed = False for phase in cast(list[dict[str, Any]], phases): if phase.get("id"): continue dt = str(phase.get("device_type", "")).strip() name = str(phase.get("name", "")).strip() matched_id: str | None = None for bdt, bphases in DEFAULT_PHASES_BY_DEVICE.items(): if dt and bdt != dt: continue for bp in bphases: if str(bp.get("name", "")).strip().casefold() == name.casefold(): matched_id = _builtin_phase_id(bdt, str(bp.get("name", ""))) break if matched_id: break phase["id"] = matched_id if matched_id else str(uuid.uuid4()) changed = True return changed async def async_load(self) -> None: """Load data from storage with migration.""" # WashDataStore handles migration internally via _async_migrate_func data = await self._store.async_load() if data: self._data = data # Ensure legacy custom phase formats are normalized in-memory. self._get_shared_custom_phases() # Assign ids to any custom phase missing one. if self._migrate_phase_ids(): await self.async_save() # Repair cycles whose power_data was corrupted by the double-subtract bug. if self.repair_corrupted_power_data(): await self.async_save() await self.async_rebuild_all_envelopes() await self.async_save() # Prune pending feedback whose cycle no longer exists, so the device's # "needs review" count can't disagree with the Cycles review filter. if self.prune_orphaned_feedback(): await self.async_save() # _migrate_v1_to_v2 and _decompress_power_from_raw removed; logic moved to WashDataStore async def async_repair_profile_samples(self) -> dict[str, int]: """Repair profile sample references after retention or migrations. Ensures each profile's sample_cycle_id points to an existing cycle that still has full-resolution power_data. If missing, picks the newest available cycle with power_data and assigns it as the sample (and labels that cycle to the profile if it was unlabeled). Returns stats dict. """ stats = { "profiles_checked": 0, "profiles_repaired": 0, "cycles_labeled_as_sample": 0, } profiles: dict[str, dict[str, Any]] = self._data.get("profiles", {}) or {} cycles: list[dict[str, Any]] = self._data.get("past_cycles", []) or [] ref_cycles: list[dict[str, Any]] = self._data.get("reference_cycles", []) or [] if not profiles or not cycles: return stats # Sample validity must recognise imported reference cycles: an import-only # profile legitimately points its sample at a reference cycle. Without this, # such a sample looks "missing" and the repair below would steal an unrelated # unlabeled real cycle into the imported profile. by_id: dict[str, dict[str, Any]] = { c["id"]: c for c in list(cycles) + list(ref_cycles) if c.get("id") } def newest_unlabeled_with_power_data() -> dict[str, Any] | None: candidates: list[dict[str, Any]] = [ c for c in cycles if c.get("power_data") and not c.get("profile_name") ] if not candidates: return None try: return max(candidates, key=lambda c: c.get("start_time", "")) except Exception: # pylint: disable=broad-exception-caught return candidates[-1] for profile_name, profile in profiles.items(): stats["profiles_checked"] += 1 sample_id = profile.get("sample_cycle_id") sample = by_id.get(sample_id) if sample_id else None # Sample is valid only if it exists and still has power_data if sample and sample.get("power_data"): continue # Prefer newest already-labeled cycle for this profile that still has power_data labeled_candidates = [ c for c in cycles if c.get("profile_name") == profile_name and c.get("power_data") ] if labeled_candidates: try: chosen = max( labeled_candidates, key=lambda c: c.get("start_time", "") ) except Exception: # pylint: disable=broad-exception-caught chosen = labeled_candidates[-1] else: # Fallback: pick newest UNLABELED cycle with power_data chosen = newest_unlabeled_with_power_data() if not chosen: continue profile["sample_cycle_id"] = chosen.get("id") if chosen.get("duration"): profile["avg_duration"] = chosen["duration"] # If chosen cycle is unlabeled, label it to this profile to bootstrap matching if not chosen.get("profile_name"): chosen["profile_name"] = profile_name stats["cycles_labeled_as_sample"] += 1 stats["profiles_repaired"] += 1 try: await self.async_rebuild_envelope(profile_name) except Exception: # pylint: disable=broad-exception-caught pass return stats async def async_save(self) -> None: """Save data to storage.""" await self._store.async_save(self._data) async def async_save_active_cycle(self, detector_snapshot: JSONDict) -> None: """Save the active cycle state to storage (throttled by Manager).""" self._data["active_cycle"] = detector_snapshot self._data["last_active_save"] = dt_util.now().isoformat() await self._store.async_save(self._data) def get_active_cycle(self) -> JSONDict | None: """Get the saved active cycle.""" raw = self._data.get("active_cycle") if isinstance(raw, dict): return cast(JSONDict, raw) return None def get_last_active_save(self) -> datetime | None: """Return the last time the active cycle snapshot was persisted.""" raw = self._data.get("last_active_save") if not isinstance(raw, str) or not raw: return None try: return dt_util.parse_datetime(raw) except ValueError: return None async def async_clear_active_cycle(self) -> None: """Clear the active cycle snapshot from storage.""" if "active_cycle" in self._data: del self._data["active_cycle"] await self._store.async_save(self._data) def add_cycle(self, cycle_data: CycleDict) -> None: """Add a completed cycle to history (sync wrapper, schedules async tasks).""" self._add_cycle_data(cycle_data) self.hass.async_create_task(self.async_enforce_retention()) async def async_add_cycle(self, cycle_data: CycleDict) -> None: """Add a completed cycle to history asynchronously.""" self._add_cycle_data(cycle_data) await self.async_enforce_retention() def _add_cycle_data(self, cycle_data: CycleDict, target: list[CycleDict] | None = None) -> None: """Internal logic to add cycle data to storage. ``target`` defaults to ``past_cycles``; ``add_reference_cycle`` passes the separate ``reference_cycles`` list so imported cycles never enter usage stats. """ dest = self._data["past_cycles"] if target is None else target # Generate SHA256 ID unique_str = f"{cycle_data['start_time']}_{cycle_data['duration']}" cycle_data["id"] = hashlib.sha256(unique_str.encode()).hexdigest()[:12] # Preserve profile_name if already set by manager; default to None otherwise if "profile_name" not in cycle_data: cycle_data["profile_name"] = None # Initially unknown # Store power data at native sampling resolution # Format: [seconds_offset, power] preserves actual sample rate from device # (e.g., 3s intervals from test socket, 60s intervals from real socket) raw_data: list[Any] = cycle_data.get("power_data", []) or [] self._logger.debug("add_cycle: raw_data has %s points", len(raw_data)) if raw_data: start_time_raw = cycle_data.get("start_time") start_time_iso: str | None = None if start_time_raw is not None: parsed_dt = _parse_start_dt(start_time_raw) if parsed_dt is not None: start_time_iso = parsed_dt.isoformat() # Keep original ISO string as-is if it was already a valid ISO string if isinstance(start_time_raw, str) and dt_util.parse_datetime(start_time_raw) is not None: start_time_iso = start_time_raw else: try: ts = float(start_time_raw) start_time_iso = dt_util.utc_from_timestamp(ts).isoformat() except (ValueError, OSError): self._logger.debug( "add_cycle: unparseable string start_time %r, falling back", start_time_raw, ) elif isinstance(start_time_raw, datetime): start_time_iso = start_time_raw.isoformat() elif isinstance(start_time_raw, (int, float)): try: start_time_iso = dt_util.utc_from_timestamp(float(start_time_raw)).isoformat() except (ValueError, OSError): pass if start_time_iso is not None: cycle_data["start_time"] = start_time_iso if start_time_iso is None and _value_to_timestamp(start_time_raw) is None: self._logger.debug("add_cycle: invalid start_time %r, skipping power_data normalization", start_time_raw) if hasattr(self, "_save_debug_traces") and not self._save_debug_traces: cycle_data.pop("debug_data", None) dest.append(cycle_data) return # Use unified normalizer: handles offset, ISO-string, and datetime formats pairs = power_data_to_offsets( cast(list[list[Any] | tuple[Any, ...]], raw_data), start_time_iso ) stored: list[list[float]] = [[round(p[0], 1), round(p[1], 1)] for p in pairs] offsets: list[float] = [p[0] for p in stored] # Calculate average sampling interval (in seconds) if len(offsets) > 1: intervals = np.diff(offsets) positive_intervals = intervals[intervals > 0] sampling_interval = float(np.median(positive_intervals)) if positive_intervals.size > 0 else 0.0 else: sampling_interval = 1.0 # Default fallback # Trim leading/trailing zero readings for cleaner data # SKIP for completed cycles to preserve end spike data if cycle_data.get("status") in ("completed", "force_stopped"): # Only trim leading zeros for completed cycles, keep trailing data start_idx = 0 for i, point in enumerate(stored): if point[1] > 1.0: start_idx = i break stored = stored[start_idx:] self._logger.debug("add_cycle: Skipping trailing trim for completed cycle") else: stored = trim_zero_power_data(stored, threshold=1.0) cycle_data["power_data"] = stored cycle_data["sampling_interval"] = round(sampling_interval, 1) # Helper to get arrays for signature (use stored data for consistency) ts_arr = np.array([t for t, _ in stored]) p_arr = np.array([p for _, p in stored]) # Compute and store signature if len(ts_arr) > 1 and len(ts_arr) == len(p_arr): sig = compute_signature(ts_arr, p_arr) cycle_data["signature"] = dataclasses.asdict(sig) # Compute and store energy (Wh) if not already set (e.g. by manager). # Shared trapezoidal integrator + data-driven outage gap (single source # with manager._on_cycle_end). if "energy_wh" not in cycle_data and len(ts_arr) > 1: sort_idx = np.argsort(ts_arr) ts_s = ts_arr[sort_idx] p_s = p_arr[sort_idx] cycle_data["energy_wh"] = round( integrate_wh(ts_s, p_s, max_gap_s=energy_gap_threshold_s(ts_s)), 3 ) self._logger.debug( "add_cycle: stored %s samples at %.1fs intervals", len(stored), sampling_interval, ) # 4. Handle Debug Data (Strip if not enabled) if hasattr(self, "_save_debug_traces") and not self._save_debug_traces: if "debug_data" in cycle_data: del cycle_data["debug_data"] dest.append(cycle_data) # Apply retention after adding async def async_enforce_retention(self) -> None: """Apply retention policy asynchronously.""" affected = self._enforce_retention_data() for p in affected: try: # Use async rebuild task self.hass.async_create_task(self.async_rebuild_envelope(p)) except Exception as e: # pylint: disable=broad-exception-caught self._logger.warning("Failed to schedule envelope rebuild for %s: %s", p, e) def _enforce_retention_data(self) -> set[str]: """Internal retention logic (data operations only). Returns set of affected profile names.""" raw_cycles = self._data.get("past_cycles", []) cycles: list[CycleDict] = ( cast(list[CycleDict], raw_cycles) if isinstance(raw_cycles, list) else [] ) if not cycles: return set() def _start_time(cycle: CycleDict) -> str: return str(cycle.get("start_time", "")) affected_profiles: set[str] = set() # 1) Cap total cycles if len(cycles) > self._max_past_cycles: # Sort by start_time and drop oldest beyond cap try: cycles.sort(key=_start_time) except Exception: # pylint: disable=broad-exception-caught pass drop_count = len(cycles) - self._max_past_cycles to_drop = cycles[:drop_count] # Maintain profile sample references when dropping sample_refs = { name: p.get("sample_cycle_id") for name, p in self._data.get("profiles", {}).items() } for cy in to_drop: # Track affected profile p_name = cy.get("profile_name") if p_name: affected_profiles.add(p_name) cy_id = cy.get("id") # If a profile sample points here, try to move to most recent cycle of that profile for name, ref_id in list(sample_refs.items()): if ref_id == cy_id: # find newest cycle for that profile newest = next( ( c for c in reversed(cycles) if c.get("profile_name") == name and c not in to_drop ), None, ) if newest: self._data["profiles"][name]["sample_cycle_id"] = ( newest.get("id") ) else: # No replacement available self._data["profiles"][name].pop("sample_cycle_id", None) # Actually drop del cycles[:drop_count] # 2) Strip older full traces per profile by_profile: dict[str | None, list[CycleDict]] = {} for cy in cycles: key_any = cy.get("profile_name") # None for unlabeled key: str | None = key_any if isinstance(key_any, str) and key_any else None by_profile.setdefault(key, []).append(cy) # Collect cycle IDs that have pending feedback - never strip their power_data pending_feedback_ids: set[str] = set(self._data.get("pending_feedback", {}).keys()) for key, group in by_profile.items(): # newest first based on start_time try: group.sort(key=_start_time) except Exception: # pylint: disable=broad-exception-caught pass # determine cap cap = ( self._max_full_traces_unlabeled if key in ( None, "", ) else self._max_full_traces_per_profile ) # count existing full traces full_indices = [i for i, c in enumerate(group) if c.get("power_data")] if len(full_indices) > cap: # preserve last 'cap' full traces (newest at end after sort), strip older ones keep_set = set(full_indices[-cap:]) # Get sample cycle ID for this profile sample_id: str | None = None if key and key in self._data.get("profiles", {}): sample_id = self._data["profiles"][key].get("sample_cycle_id") for i, c in enumerate(group): if i in keep_set: continue # EXEMPTION: Never strip power data from the profile's sample cycle! if sample_id and c.get("id") == sample_id: continue # EXEMPTION: Never strip power data from cycles awaiting feedback review if c.get("id") in pending_feedback_ids: continue # EXEMPTION: Never strip power data from user-pinned "golden" cycles. # The matcher uses a golden trace as the sharp single-cycle template # (has_golden in the snapshot builder), and golden_profiles membership # is gated on the trace still being present — trimming it would flip # has_golden false and silently drop the profile back to the smeared # envelope average. rev = c.get("ml_review") if isinstance(rev, dict) and rev.get("golden"): continue if c.get("power_data"): c.pop("power_data", None) c.pop("sampling_interval", None) if key: affected_profiles.add(key) return affected_profiles def cleanup_orphaned_profiles(self) -> int: """Remove profiles that reference non-existent cycles. Returns number of profiles removed.""" # Imported reference cycles are valid sample targets too (an import-only # profile points its sample there), so include them or such profiles would # be wrongly deleted as orphans. cycle_ids = {c["id"] for c in self._data.get("past_cycles", [])} cycle_ids |= { c["id"] for c in self._data.get("reference_cycles", []) if c.get("id") } orphaned: list[str] = [] for name, profile in self._data["profiles"].items(): ref = profile.get("sample_cycle_id") # Only delete if it references a non-existent cycle ID (Broken Link) # Creating a profile without a sample (None) is allowed (Pending State) if ref and ref not in cycle_ids: orphaned.append(name) for name in orphaned: del self._data["profiles"][name] self._logger.info( "Cleaned up orphaned profile '%s' (cycle no longer exists)", name ) return len(orphaned) async def async_run_maintenance(self) -> dict[str, int]: """Run full maintenance: cleanup orphans, merge fragments, trim old cycles. Also rebuilds envelopes. Returns stats dict with counts of actions taken. """ stats = { "orphaned_profiles": 0, "merged_cycles": 0, "split_cycles": 0, "rebuilt_envelopes": 0, } # 1. Clean up orphaned profiles stats["orphaned_profiles"] = self.cleanup_orphaned_profiles() # 2. Auto-Label missed cycles (retroactive matching) # Use overwrite=False to respect existing manual/confident labels label_stats = await self.auto_label_cycles(confidence_threshold=0.75, overwrite=False) stats["labeled_cycles"] = label_stats.get("labeled", 0) # 3. Smart Process History (Merge/Split/Rebuild) proc_stats = await self.async_smart_process_history() stats["merged_cycles"] = proc_stats.get("merged", 0) stats["split_cycles"] = proc_stats.get("split", 0) # 4. Rebuild every profile's envelope so bands stay fresh, and report the # real count of successful rebuilds (not an approximation). rebuilt = 0 for profile_name in list(self._data.get("profiles", {}).keys()): try: # Count only real rebuilds; a no-op/failed rebuild returns False. if await self.async_rebuild_envelope(profile_name): rebuilt += 1 except Exception: # pylint: disable=broad-exception-caught self._logger.debug( "Envelope rebuild failed for %s during maintenance", profile_name, exc_info=True ) stats["rebuilt_envelopes"] = rebuilt # 4. Save if any changes made (smart process saves internally if needed, but explicit save safe) if any(stats.values()): await self.async_save() self._logger.info("Maintenance completed: %s", stats) return stats def _reprocess_all_data_sync(self) -> int: """Synchronous implementation of reprocessing logic (run in executor).""" cycles_raw = self._data.get("past_cycles", []) cycles = cast(list[CycleDict], cycles_raw) if isinstance(cycles_raw, list) else [] if not cycles: return 0 processed_count = 0 # 1. Update Signatures & Optimize Data for cycle in cycles: # Data Optimization: Trim leading/trailing zeros (0W) # Only apply to compressed data to avoid breaking legacy format p_data = cycle.get("power_data") if ( p_data and isinstance(p_data, list) and p_data and isinstance(p_data[0], (list, tuple)) ): first_point = cast(list[Any] | tuple[Any, ...], p_data[0]) # Only trim offset-format data (numeric offsets). Legacy ISO-format # cycles skip trimming but still reach the signature block below. if len(first_point) == 2 and isinstance(first_point[0], (int, float)): p_data_list = cast(list[list[float]], p_data) # Apply trim helper original_len = len(p_data_list) # Logic: For completed cycles, only trim leading zeros. # For others, trim both ends. if cycle.get("status") in ("completed", "force_stopped"): # Only trim leading start_idx = 0 for i, point in enumerate(p_data_list): if point[1] > 1.0: # Match threshold below start_idx = i break trimmed: list[list[float]] = p_data_list[start_idx:] else: trimmed = trim_zero_power_data(p_data_list, threshold=1.0) # Conservative 1W threshold if trimmed and len(trimmed) < original_len: # Data was trimmed - check for start time shift first_offset = trimmed[0][0] if first_offset > 0: # Leading zeros removed - Must shift start_time forward try: start_dt = datetime.fromisoformat(cycle["start_time"]) new_start = start_dt + timedelta(seconds=first_offset) cycle["start_time"] = new_start.isoformat() # Re-normalize offsets to 0 shifted_data: list[list[float]] = [] for row in trimmed: # row is [offset, power] shifted_data.append([round(row[0] - first_offset, 1), row[1]]) cycle["power_data"] = shifted_data processed_count += 1 except (ValueError, TypeError) as e: self._logger.warning("Failed to shift start_time for trimmed cycle: %s", e) else: # Only trailing trimmed or no shift needed cycle["power_data"] = trimmed processed_count += 1 # Update duration to match new data length # If we only trimmed the head, the new duration is old_duration - first_offset # This preserves trailing silence. if cycle.get("power_data"): old_dur = float(cycle.get("duration", 0.0) or 0.0) # If we shifted (first_offset > 0), new duration is old_dur - first_offset # Otherwise if we only trimmed tail, we might want to snap, # but for completed cycles we don't trim tail in this loop. if first_offset > 0: cycle["duration"] = max(0.0, old_dur - first_offset) else: # Only trailing was trimmed (not expected for completed cycles here) # or no trim happened. # If trailing was trimmed, we SHOULD snap. if len(trimmed) < original_len: cycle["duration"] = cycle["power_data"][-1][0] # Self-heal duration/end_time drift. A freshly-finalized cycle # always has last_offset == duration (the finalizer appends a # terminal point at end_time, and add_cycle keeps the tail for # completed cycles), so this is a no-op for healthy records. It # repairs older / manually-edited cycles whose trace was trimmed # without updating duration + end_time - e.g. a legacy record # whose drying tail was dropped while duration kept the pre-trim # value (duration says 8820s but the trace and end_time end at # 6845s). Snap both to the trace so the three always agree. pd_now = cycle.get("power_data") if ( isinstance(pd_now, list) and pd_now and isinstance(pd_now[-1], (list, tuple)) and len(pd_now[-1]) == 2 ): trace_end = float(pd_now[-1][0]) stored_dur = float(cycle.get("duration", 0.0) or 0.0) si = float(cycle.get("sampling_interval", 0.0) or 0.0) # Tolerance above rounding + one sampling gap; the drifts we # repair are large (minutes), so this never churns healthy data. tol = max(5.0, 2.0 * si) if trace_end > 0 and abs(stored_dur - trace_end) > tol: cycle["duration"] = round(trace_end, 1) start_ts = _value_to_timestamp(cycle.get("start_time")) if start_ts is not None: cycle["end_time"] = dt_util.utc_from_timestamp( start_ts + trace_end ).isoformat() processed_count += 1 self._logger.info( "Reprocess self-heal: cycle %s duration %.0fs -> %.0fs " "(snapped to trace; end_time realigned)", cycle.get("id"), stored_dur, trace_end, ) if cycle.get("power_data"): try: tuples = decompress_power_data(cycle) if tuples and len(tuples) > 10: ts_arr: list[float] = [] p_arr: list[float] = [] for offset_sec, p in tuples: ts_arr.append(float(offset_sec)) p_arr.append(float(p)) sig = compute_signature(np.array(ts_arr, dtype=float), np.array(p_arr, dtype=float)) cycle["signature"] = dataclasses.asdict(sig) processed_count += 1 except Exception as e: # pylint: disable=broad-exception-caught self._logger.warning("Failed to reprocess signature: %s", e) # 2. Rebuild Envelopes return processed_count async def async_reprocess_all_data(self) -> int: """Reprocess all historical data to update signatures and rebuild envelopes. This is a non-destructive operation for raw cycle data. It: 1. Recalculates signatures for ALL past cycles using current logic. 2. Rebuilds all profile envelopes from scratch. 3. Updates global stats. Returns total number of cycles processed. """ self._logger.info("Starting reprocessing (offloaded)...") # Offload heavy synchronous work processed_count = await self.hass.async_add_executor_job( self._reprocess_all_data_sync ) # 2. Rebuild Envelopes (Using new async infrastructure) await self.async_rebuild_all_envelopes() await self.async_save() return processed_count async def get_storage_stats(self) -> dict[str, Any]: """Get storage usage stats. The on-disk size lookup uses blocking os.path calls, so it is offloaded to the executor. We deliberately never json.dumps(self._data) as a fallback: with power_data included the dataset can be many megabytes and serialising it on the event loop previously stalled the loop long enough that the diagnostics view appeared to hang on a spinner. """ cycles = self._data.get("past_cycles", []) profiles = self._data.get("profiles", {}) debug_traces_count = sum(1 for c in cycles if c.get("debug_data")) file_size_kb = 0.0 path = getattr(self._store, "path", None) if path: file_size_kb = await self.hass.async_add_executor_job( _safe_file_size_kb, path ) return { "file_size_kb": round(file_size_kb, 1), "total_cycles": len(cycles), "total_profiles": len(profiles), "debug_traces_count": debug_traces_count, } async def async_clear_debug_data(self) -> int: """Clear debug data from all cycles.""" cycles = self._data.get("past_cycles", []) count = 0 for cycle in cycles: if "debug_data" in cycle: del cycle["debug_data"] count += 1 if count > 0: await self.async_save() self._logger.info("Cleared debug data from %s cycles", count) return count def _rebuild_envelope_sync( self, labeled_cycles: list[CycleDict] ) -> tuple[Any, list[float]] | None: """Sync worker to parse data and build envelope (run in executor). Degenerate cycles (a near-flat trace whose peak is a tiny fraction of the profile's typical peak - e.g. a run where the power sensor wasn't reporting) are excluded so they cannot pollute the envelope average that the live matcher scores against, or drag ``avg_duration`` around. User-pinned golden cycles are always kept. """ # First pass: decompress everything and record each cycle's peak so we # can judge degeneracy relative to the profile (works for both a 2000W # dishwasher and a low-power pump). parsed: list[tuple[list[float], list[float], float, bool, float]] = [] for cycle in labeled_cycles: pairs = decompress_power_data(cycle) if len(pairs) < 3: continue offsets = [p[0] for p in pairs] values = [p[1] for p in pairs] stored_dur = float(cycle.get("duration", 0.0) or 0.0) authoritative_dur = float(max(offsets[-1], stored_dur)) man_dur = cycle.get("manual_duration") final_dur = float(man_dur) if man_dur else authoritative_dur review = cycle.get("ml_review") is_golden = bool(review.get("golden")) if isinstance(review, dict) else False peak = max(values) if values else 0.0 parsed.append((offsets, values, final_dur, is_golden, peak)) if not parsed: return None # Degeneracy floor: below max(_DEGENERATE_POWER_FLOOR, 10% of the median # peak) a cycle is treated as a mis-capture and dropped (unless golden). peaks = sorted(p[4] for p in parsed) median_peak = peaks[len(peaks) // 2] if peaks else 0.0 degen_floor = max(_DEGENERATE_POWER_FLOOR, 0.10 * median_peak) raw_cycles_data: list[tuple[list[float], list[float], float]] = [] durations: list[float] = [] golden_mask: list[bool] = [] dropped = 0 for offsets, values, final_dur, is_golden, peak in parsed: if peak < degen_floor and not is_golden: dropped += 1 continue raw_cycles_data.append((offsets, values, final_dur)) durations.append(final_dur) golden_mask.append(is_golden) # Never drop everything: if the filter removed all cycles (e.g. a truly # low-power profile misjudged), fall back to using them all. if not raw_cycles_data: for offsets, values, final_dur, is_golden, _peak in parsed: raw_cycles_data.append((offsets, values, final_dur)) durations.append(final_dur) golden_mask.append(is_golden) elif dropped: self._logger.debug( "Envelope rebuild: excluded %d degenerate cycle(s) below %.0fW " "(median peak %.0fW)", dropped, degen_floor, median_peak, ) if not raw_cycles_data: return None # Run Heavy Computation. When the profile has user-verified "golden" # cycles, they define the reference shape (see compute_envelope_worker). result = analysis.compute_envelope_worker( cast(Any, raw_cycles_data), self.dtw_bandwidth, reference_mask=golden_mask if any(golden_mask) else None, ) if not result: return None return result, durations def _cycle_peak(self, cycle: CycleDict) -> float: """Peak power of a cycle's trace (0.0 if it has none).""" pairs = decompress_power_data(cycle) return max((p[1] for p in pairs), default=0.0) if pairs else 0.0 def _select_reference_cycle_id( self, profile_name: str, target_duration: float | None = None ) -> str | None: """Choose the best reference cycle for a profile's matching template. Preference order: user-pinned golden cycles first, then non-degenerate cycles (peak not a tiny fraction of the profile's median peak - this rejects mis-captured near-flat traces), and among those the one closest to the profile's representative duration (so a truncated half-cycle is not chosen). Returns a cycle id, or None if there are no usable cycles. """ cands = [ c for c in list(self._data.get("past_cycles", [])) + list(self._data.get("reference_cycles", [])) if c.get("profile_name") == profile_name and c.get("status") in ("completed", "force_stopped") and isinstance(c.get("power_data"), list) and len(c["power_data"]) >= 3 ] if not cands: return None peaks = {c["id"]: self._cycle_peak(c) for c in cands} med_peak = sorted(peaks.values())[len(peaks) // 2] if peaks else 0.0 degen_floor = max(_DEGENERATE_POWER_FLOOR, 0.10 * med_peak) golden = [ c for c in cands if isinstance(c.get("ml_review"), dict) and c["ml_review"].get("golden") ] if golden: pool = golden else: pool = [c for c in cands if peaks.get(c["id"], 0.0) >= degen_floor] or cands if target_duration and target_duration > 0: best = min(pool, key=lambda c: abs(float(c.get("duration") or 0.0) - target_duration)) else: # No duration hint: prefer the longest (avoids truncated half-cycles). best = max(pool, key=lambda c: float(c.get("duration") or 0.0)) return best.get("id") async def async_rebuild_all_envelopes(self) -> int: """Rebuild envelopes for all profiles. Returns count of envelopes rebuilt.""" count = 0 for profile_name in list(self._data["profiles"].keys()): if await self.async_rebuild_envelope(profile_name): count += 1 return count def repair_corrupted_power_data(self) -> int: """Fix cycles whose power_data offsets were corrupted by the double-subtract bug. The bug caused ``offset = small_float - unix_timestamp`` to be stored instead of just ``small_float``. Corrupted cycles have a first-offset < -1e8 (a value that can never occur for a real appliance cycle offset). Recovery: add ``start_ts`` back to every offset in the affected cycle. Returns the number of cycles repaired. """ repaired = 0 for cycle in self._data.get("past_cycles", []): power_data = cycle.get("power_data") if not isinstance(power_data, list) or not power_data: continue first = power_data[0] if not isinstance(first, (list, tuple)) or len(first) < 2: continue first_offset = first[0] if not isinstance(first_offset, (int, float)) or first_offset > -1e8: continue # Not corrupted start_ts = _value_to_timestamp(cycle.get("start_time")) if start_ts is None: continue repaired_rows: list[list[float]] = [] for pt in power_data: if not isinstance(pt, (list, tuple)) or len(pt) < 2: continue try: repaired_rows.append([round(float(pt[0]) + start_ts, 1), round(float(pt[1]), 1)]) except (TypeError, ValueError): continue if not repaired_rows: continue # all rows malformed - leave original trace untouched cycle["power_data"] = repaired_rows repaired += 1 repaired_data = cycle["power_data"] if len(repaired_data) > 1: r_offsets = [pt[0] for pt in repaired_data] r_intervals = np.diff(r_offsets) r_pos = r_intervals[r_intervals > 0] r_si = float(np.median(r_pos)) if len(r_pos) > 0 else 1.0 cycle["sampling_interval"] = round(r_si, 1) # duration = last sample offset from cycle start (not span between # first and last sample, which would be wrong when leading zeros # were trimmed before storage) r_duration = round(r_offsets[-1], 1) cycle["duration"] = r_duration cycle["end_time"] = dt_util.utc_from_timestamp( start_ts + r_duration ).isoformat() r_ts = np.array(r_offsets, dtype=float) r_p = np.array([pt[1] for pt in repaired_data], dtype=float) r_sig = compute_signature(r_ts, r_p) cycle["signature"] = dataclasses.asdict(r_sig) elif len(repaired_data) == 1: cycle["sampling_interval"] = 1.0 cycle["duration"] = 0.0 cycle["end_time"] = dt_util.utc_from_timestamp(start_ts).isoformat() cycle["signature"] = None if repaired: self._logger.warning( "Repaired corrupted power_data offsets in %d cycle(s)", repaired ) return repaired async def async_rebuild_envelope(self, profile_name: str) -> bool: """ Build/rebuild statistical envelope for a profile asynchronously. Offloads heavy DTW/normalization to executor. """ # A rebuild changes this profile's curve, which feeds group cohesion, so # invalidate the cohesion cache (not only on group mutations) to avoid stale # cohesion approving/rejecting a collapse against outdated shapes. self._cohesion_cache_generation += 1 # 1. Gather Data (Main Thread) def _eligible(seq: list[CycleDict]) -> list[CycleDict]: return [ c for c in seq if c.get("profile_name") == profile_name and c.get("status") in ("completed", "force_stopped") and c.get("duration", 0) > 60 ] # Real cycles drive usage stats (energy/count). Imported reference cycles # additionally shape the curves + matching duration, but never usage stats. real_cycles = _eligible(self._data["past_cycles"]) ref_cycles = _eligible(self._data.get("reference_cycles", [])) shape_cycles = real_cycles + ref_cycles if not shape_cycles: if profile_name in self._data.get("envelopes", {}): del self._data["envelopes"][profile_name] return False # Kept for the fallback path + duration-stat code below (behaviour is # byte-identical to before when there are no reference cycles). labeled_cycles = shape_cycles # 2. Run Heavy Computation in Executor (Parsing + DTW) result_pkg = await self.hass.async_add_executor_job( self._rebuild_envelope_sync, shape_cycles ) if not result_pkg: # Envelope shape couldn't be built (no power data / too few points). # Still update profile min/max/avg from raw cycle durations so that # a duration correction via feedback is immediately reflected in stats. if labeled_cycles and profile_name in self._data.get("profiles", {}): raw_durs = [ float(c.get("manual_duration") or c.get("duration", 0)) for c in labeled_cycles ] raw_durs = [d for d in raw_durs if d > 60] if raw_durs: raw_arr_fallback = np.array(raw_durs, dtype=float) self._data["profiles"][profile_name]["min_duration"] = float(np.min(raw_arr_fallback)) self._data["profiles"][profile_name]["max_duration"] = float(np.max(raw_arr_fallback)) self._data["profiles"][profile_name]["avg_duration"] = float(np.mean(raw_arr_fallback)) if profile_name in self._data.get("envelopes", {}): del self._data["envelopes"][profile_name] return False result, durations = result_pkg # Update profile stats in storage (Fast metadata update) if durations and profile_name in self._data.get("profiles", {}): stats_durations = filter_duration_outliers(durations) raw_arr = np.array(durations, dtype=float) # min/max reflect the actual observed range (including outliers) # avg uses the outlier-filtered set for a robust representative value min_duration = float(np.min(raw_arr)) max_duration = float(np.max(raw_arr)) avg_duration = float(np.mean(stats_durations)) self._data["profiles"][profile_name]["min_duration"] = min_duration self._data["profiles"][profile_name]["max_duration"] = max_duration self._data["profiles"][profile_name]["avg_duration"] = avg_duration # Re-point the matching reference to a golden / non-degenerate cycle # near the representative duration, so a mis-captured (flat) or # truncated cycle can never remain the profile's template. ref_id = self._select_reference_cycle_id(profile_name, avg_duration) if ref_id and self._data["profiles"][profile_name].get("sample_cycle_id") != ref_id: old = self._data["profiles"][profile_name].get("sample_cycle_id") self._data["profiles"][profile_name]["sample_cycle_id"] = ref_id self._logger.debug( "Re-selected reference cycle for %s: %s -> %s", profile_name, old, ref_id, ) if not result: if profile_name in self._data.get("envelopes", {}): del self._data["envelopes"][profile_name] return False time_grid, min_curve, max_curve, avg_curve, std_curve, target_duration = result # 3. Update Storage # Convert to list of points [[x, y], ...] def to_points(y_vals: list[float]) -> list[list[float]]: return [[round(t, 1), round(y, 1)] for t, y in zip(time_grid, y_vals)] # Calculate scalar stats duration_std_dev = float(np.std(durations)) if durations else 0.0 # Usage stats (avg_energy, cycle_count) come from REAL cycles only, so imported # reference cycles can shape the curve without ever inflating energy/count. # When there are no reference cycles this is byte-identical to the prior behaviour # (avg_energy from the avg curve, cycle_count = len(durations)). if ref_cycles: real_energies = [ float(c["energy_wh"]) for c in real_cycles if isinstance(c.get("energy_wh"), (int, float)) ] avg_energy = (sum(real_energies) / len(real_energies) / 1000.0) if real_energies else 0.0 cycle_count = len(real_cycles) else: # Average-curve energy (kWh) via the shared trapezoidal integrator. # avg_curve is in Watts, time_grid in seconds; integrate_wh returns Wh. avg_energy = integrate_wh(time_grid, avg_curve) / 1000.0 cycle_count = len(durations) envelope_data: dict[str, Any] = { "time_grid": time_grid, # Time grid used by manager for phase estimation "target_duration": target_duration, # Target duration for phase estimation "min": to_points(min_curve), "max": to_points(max_curve), "avg": to_points(avg_curve), "std": to_points(std_curve), "cycle_count": cycle_count, "avg_energy": avg_energy, "duration_std_dev": duration_std_dev, "updated": dt_util.now().isoformat(), } # Derived cache: per-phase profile (per-role duration/energy priors) used by # phase-segmented matching / phase-resolved ETA. Built only for device types # phase matching is live-supported for; absent otherwise (consumers fall back # to the whole-cycle pipeline). Pure/cheap - segmentation is O(samples). device_type = str( self._data.get("profiles", {}).get(profile_name, {}).get("device_type") or "" ) # Offload the per-cycle segmentation to the executor (it can be tens of ms # for very long traces; keep it off the event loop, like the envelope DTW). phase_profile = await self.hass.async_add_executor_job( self._compute_phase_profile, profile_name, shape_cycles, device_type ) if phase_profile is not None: envelope_data["phase_profile"] = phase_profile if "envelopes" not in self._data: self._data["envelopes"] = {} self._data["envelopes"][profile_name] = envelope_data return True def _compute_phase_profile( self, profile_name: str, cycles: list[CycleDict], device_type: str ) -> dict[str, Any] | None: """Segment each member cycle and aggregate a per-role phase profile. Returns a JSON-safe dict for ``envelope["phase_profile"]`` or ``None`` when phase matching is not live-supported for this device type or no cycle could be segmented. Never raises (phase support must never break envelope rebuild). """ try: if not phase_matching_live_supported(device_type): return None model = phase_model_for(device_type) if model is None: return None segmented: list = [] for cycle in cycles: offsets = power_data_to_offsets(cycle.get("power_data") or []) if len(offsets) < 4: continue t = [float(o) for o, _ in offsets] w = [float(p) for _, p in offsets] segs = segment_cycle(t, w, model) if segs: segmented.append(segs) if not segmented: return None profile = build_phase_profile(profile_name, segmented) return phase_profile_to_dict(profile) if profile is not None else None except Exception: # noqa: BLE001 - phase caching must never break rebuild self._logger.debug("phase-profile build failed for %s", profile_name, exc_info=True) return None def _group_scope(self, program: str) -> set[str] | None: """Phase-narrowing scope for the matched ``program``: * If ``program`` is in a group with >= 2 members, return that group's members - narrow WITHIN the family (design §9). This is both coherent (same program family as the displayed program) and accurate (picks the right temperature/spin variant among siblings). * Otherwise return ``None`` = no scope filter (consider ALL of the device's phase profiles). The Phase-0 gate showed that constraining an UNGROUPED cycle to only the whole-cycle-matched program regresses the ETA whenever that match is wrong (common on mislabeled data): the best ETA comes from letting the phase matcher pick the best-fitting profile, bounded by the ambiguity gate + cold-start floor. Grouping variants is the recommended workflow and restores full coherence. """ try: for grp in self.get_profile_groups().values(): members = grp.get("members") if isinstance(grp, dict) else None if isinstance(members, list) and program in members: sib = {m for m in members if isinstance(m, str)} if len(sib) >= 2: return sib except Exception: # noqa: BLE001 self._logger.debug("_group_scope failed for %r", program, exc_info=True) return None def _candidate_phase_profiles(self, scope: set[str] | None = None) -> list: """Cached per-profile PhaseProfiles (from envelope['phase_profile']). Restricted to ``scope`` (profile names) when given, and always filtered to profiles with >= ``PHASE_PROFILE_MIN_CYCLES`` member cycles so a noisy single-cycle prior can't drive the ETA (cold-start floor). """ out = [] for name, env in (self._data.get("envelopes") or {}).items(): if scope is not None and name not in scope: continue if isinstance(env, dict): pp = phase_profile_from_dict(env.get("phase_profile")) if pp is not None and pp.n_cycles >= PHASE_PROFILE_MIN_CYCLES: out.append(pp) return out def phase_remaining( self, power_data: list, device_type: str, program: str | None = None, ) -> dict[str, Any] | None: """Phase-resolved remaining-time for a running cycle. Never raises. Segments the observed-so-far trace and matches it against the matched ``program``'s phase profile (and its group siblings, design §9), returning the winning member's per-role budget remaining. Returns ``None`` (caller keeps the current estimate) when: phase matching is not live-supported for the device type; ``program`` is unknown / has no cached phase profile (or too few cycles - cold-start floor); segmentation is degenerate; or the top two candidates are within ``MATCH_AMBIGUITY_MARGIN`` (ambiguous - do not commit a variant, design §7). This is the *phase* half of the blended ETA; the blend with the current estimator lives in ``progress.compute_progress`` (single source of truth). Pure and cheap (segmentation + per-role agreement, no DTW) - safe to call inline from the async matching path. """ try: if not phase_matching_live_supported(device_type): return None model = phase_model_for(device_type) if model is None or not program: return None candidates = self._candidate_phase_profiles(self._group_scope(program)) if not candidates: return None offsets = power_data_to_offsets(power_data or []) if len(offsets) < 4: return None t = [float(o) for o, _ in offsets] w = [float(p) for _, p in offsets] segs = segment_cycle(t, w, model, partial=True) if not segs: return None ranked = match_phase_profiles(segs, candidates, {}) if not ranked: return None # Ambiguity gate: a near-tie among group members is not a confident # variant call - fall back rather than swing the ETA between budgets. if (len(ranked) >= 2 and (ranked[0].score - ranked[1].score) < MATCH_AMBIGUITY_MARGIN): return None best = next((c for c in candidates if c.name == ranked[0].name), None) remaining = phase_eta(segs, best) if best is not None else None if remaining is None: return None return { "remaining_s": float(remaining), "matched": ranked[0].name, "score": float(ranked[0].score), } except Exception: # noqa: BLE001 - phase ETA must never break the estimate self._logger.debug("phase_remaining failed", exc_info=True) return None def get_envelope(self, profile_name: str) -> JSONDict | None: """Get cached envelope for a profile, or None if not available.""" envelopes = self._data.get("envelopes", {}) if isinstance(envelopes, dict): envelopes_map = cast(dict[str, Any], envelopes) env = envelopes_map.get(profile_name) return cast(JSONDict, env) if isinstance(env, dict) else None return None def reference_curve( self, profile_name: str, n: int = REFERENCE_PROFILE_CURVE_POINTS ) -> JSONDict | None: """Compact, read-only reference power curve for a matched profile. Downsamples the profile envelope's average power-over-time shape to at most ``n`` points so it can be exposed as an entity attribute for consumers (e.g. home energy managers) that want the *forward-looking load shape* rather than a scalar ETA - to anticipate, say, a heating spike later in the cycle. Shape:: { "points": [[offset_s, watts], ...], # <= n samples "duration_s": float, # profile target duration "cycle_count": int, # cycles behind the average } Offsets are absolute seconds from cycle start (0 .. ``duration_s``); a consumer derives the *remaining* curve by slicing from the live progress position (already exposed as the progress sensor). The curve is static per profile - it only changes when the profile is re-learned - so it can be surfaced as an attribute without recorder churn. Pure statistics (no ML); never raises - returns ``None`` when the envelope is missing or too short to be meaningful. """ try: env = self.get_envelope(profile_name) if not isinstance(env, dict): return None avg = env.get("avg") if ( not isinstance(avg, list) or len(avg) < 2 or not isinstance(avg[0], (list, tuple)) or len(avg[0]) < 2 ): return None ts = np.asarray([float(p[0]) for p in avg], dtype=float) ws = np.asarray([float(p[1]) for p in avg], dtype=float) if ts.size < 2 or not (np.all(np.isfinite(ts)) and np.all(np.isfinite(ws))): return None if ts[-1] <= ts[0]: return None duration = float(env.get("target_duration") or 0.0) if duration <= 0: duration = float(ts[-1]) count = max(2, min(int(n), ts.size)) grid = np.linspace(float(ts[0]), float(ts[-1]), count) vals = np.interp(grid, ts, ws) points = [ [int(round(float(g))), round(float(v), 1)] for g, v in zip(grid, vals) ] return { "points": points, "duration_s": round(duration, 1), "cycle_count": int(env.get("cycle_count") or 0), } except Exception: # pragma: no cover - defensive; never break the sensor return None def get_profile_power_profile( self, profile_name: str, interval_s: float = 900.0 ) -> list[float]: """Average power (W) per fixed interval across a profile's learned shape. Resamples the profile envelope's average power-over-time curve into consecutive ``interval_s`` buckets (default 15 min) and returns the mean watts in each, e.g. ``[2200, 2200, 800, 800, 1500, 500, 400, 200]`` - the flat per-slot array external planners such as tibber_prices' ``power_profile`` consume to pick the cheapest window to run the appliance (issue #272). Unlike :meth:`reference_curve` (a downsampled time/watt shape surfaced on the running-program sensor), this is a fixed-interval average exposed per profile so it can be read for planning before a cycle starts. The final bucket is averaged only over the part of the cycle that actually falls inside it. Pure statistics; never raises. Returns an empty list when the profile has no learned envelope yet. """ try: if interval_s <= 0: return [] env = self.get_envelope(profile_name) if not isinstance(env, dict): return [] avg = env.get("avg") if ( not isinstance(avg, list) or len(avg) < 2 or not isinstance(avg[0], (list, tuple)) or len(avg[0]) < 2 ): return [] ts = np.asarray([float(p[0]) for p in avg], dtype=float) ws = np.asarray([float(p[1]) for p in avg], dtype=float) if ts.size < 2 or not (np.all(np.isfinite(ts)) and np.all(np.isfinite(ws))): return [] if ts[-1] <= ts[0]: return [] total = float(env.get("target_duration") or 0.0) if total <= 0: total = float(ts[-1]) if total <= 0: return [] n_buckets = int(math.ceil(total / interval_s)) out: list[float] = [] for k in range(n_buckets): lo = k * interval_s hi = min(lo + interval_s, total) if hi <= lo: break # Time-average the curve over the half-open slot [lo, hi) on a fine # interpolated grid, so the result is independent of the envelope's # grid spacing. endpoint=False keeps a slot boundary from being # double-counted into the adjacent slot. fine = np.linspace(lo, hi, 16, endpoint=False) out.append(round(float(np.mean(np.interp(fine, ts, ws))), 1)) return out except Exception: # pragma: no cover - defensive; never break the sensor return [] def compute_envelope_conformance( self, profile_name: str, points: list[tuple[float, float]], ) -> dict[str, Any] | None: """Score how well the current power trace conforms to the profile envelope band. Resamples ``points`` to the envelope's time grid (scaling by the ratio of the current elapsed time to the envelope duration) and computes the fraction of samples that land within the [lower, upper] band. Returns a dict: ``conformance`` – fraction of samples inside the envelope band (0–1) ``outside_frac`` – fraction outside the band (1 - conformance) ``samples`` – number of samples compared ``envelope_name`` – profile_name used Returns ``None`` if the envelope or points are unavailable / too short. Never raises. This is a complementary signal to match confidence: confidence measures shape correlation, while conformance measures absolute level/spread. A high-confidence but low-conformance score may indicate the cycle was mis-detected (merged cycles, offset start) or the appliance is running anomalously. """ try: if not points or len(points) < 4: return None env = self.get_envelope(profile_name) if not env: return None time_grid = env.get("time_grid") lower_raw = env.get("min") upper_raw = env.get("max") if not time_grid or not lower_raw or not upper_raw: return None tg = np.asarray(time_grid, dtype=float) # Envelope curves are stored as [[t, y], ...]; extract the y column. lo = np.asarray( [p[1] if isinstance(p, (list, tuple)) else p for p in lower_raw], dtype=float, ) hi = np.asarray( [p[1] if isinstance(p, (list, tuple)) else p for p in upper_raw], dtype=float, ) env_duration = float(tg[-1]) if len(tg) > 1 else 1.0 # Normalise observed trace to [0, env_duration] time range t_obs = np.asarray([t for t, _ in points], dtype=float) p_obs = np.asarray([p for _, p in points], dtype=float) obs_duration = float(t_obs[-1]) if len(t_obs) > 1 else 1.0 t_scaled = t_obs * (env_duration / obs_duration) if obs_duration > 0 else t_obs # Interpolate envelope bounds at scaled observed time points (clamp ends) t_clamped = np.clip(t_scaled, tg[0], tg[-1]) lo_interp = np.interp(t_clamped, tg, lo) hi_interp = np.interp(t_clamped, tg, hi) inside = np.sum((p_obs >= lo_interp) & (p_obs <= hi_interp)) n = len(p_obs) conformance = float(inside) / n if n > 0 else 0.0 return { "conformance": round(conformance, 3), "outside_frac": round(1.0 - conformance, 3), "samples": n, "envelope_name": profile_name, } except Exception: # noqa: BLE001 return None def detect_cycle_artifacts( self, profile_name: str, points: list[tuple[float, float]], ) -> list[dict[str, Any]]: """Detect transient artifacts in a cycle by comparing it to the profile band. Walks the trace against the matched profile's envelope and flags contiguous segments that deviate from what the program normally does: * ``pause`` – power fell to ~0 where the profile expects activity and then resumed (e.g. the door was opened mid-cycle to add an item, or a manual pause); excluded at the very end (that's just the cycle finishing). * ``dip`` – ran sustainedly below the usual power band. * ``spike`` – ran sustainedly above the usual power band. Returns a chronological list of ``{type, start_s, end_s, detail, severity}`` in the trace's own time offsets (so the panel can mark them on the graph), capped to the most significant few. ``[]`` when unavailable. Pure statistics (no ML), never raises — and the events double as candidate labels for a future supervised anomaly model. """ try: if not points or len(points) < 6: return [] env = self.get_envelope(profile_name) if not env: return [] tg = np.asarray(env.get("time_grid") or [], dtype=float) # Envelope curves are stored as [[t, y], ...]; extract the y column. def _extract_y(raw: list) -> np.ndarray: if not raw: return np.array([], dtype=float) return np.asarray( [p[1] if isinstance(p, (list, tuple)) else p for p in raw], dtype=float, ) lo = _extract_y(env.get("min") or []) hi = _extract_y(env.get("max") or []) avg = _extract_y(env.get("avg") or []) if tg.size < 2 or lo.size != tg.size or hi.size != tg.size: return [] t_obs = np.asarray([t for t, _ in points], dtype=float) p_obs = np.asarray([max(0.0, float(p)) for _, p in points], dtype=float) obs_dur = float(t_obs[-1]) if t_obs.size > 1 else 1.0 env_dur = float(tg[-1]) if tg.size > 1 else 1.0 if obs_dur <= 0 or env_dur <= 0: return [] # Map each observed time to the envelope grid (same scaling as # compute_envelope_conformance) so the band is aligned to progress. t_scaled = np.clip(t_obs * (env_dur / obs_dur), tg[0], tg[-1]) lo_i = np.interp(t_scaled, tg, lo) hi_i = np.interp(t_scaled, tg, hi) avg_i = np.interp(t_scaled, tg, avg) if avg.size == tg.size else (lo_i + hi_i) / 2.0 peak = max(float(np.max(hi)), 1.0) active_thr = max(5.0, 0.05 * peak) # profile expects real power here pause_thr = max(2.0, 0.03 * peak) # observed effectively off margin = max(10.0, 0.12 * peak) # band slack to ignore edge noise # Pre-check: if the cycle's linear-resampled alignment is too poor # (e.g. duration or phase structure doesn't match the profile), the # envelope comparison produces unreliable artifacts. Compute tight # conformance (no margin) over the active region; bail out when more # than 45 % of expected-active samples are outside the raw band. active_mask = avg_i > active_thr n_active = int(np.sum(active_mask)) if n_active > 10: outside_tight = int(np.sum( (p_obs[active_mask] < lo_i[active_mask]) | (p_obs[active_mask] > hi_i[active_mask]) )) if outside_tight / n_active > 0.45: return [] states: list[str] = [] for i in range(len(p_obs)): expects_power = avg_i[i] > active_thr if expects_power and p_obs[i] <= pause_thr: states.append("pause") elif expects_power and p_obs[i] < lo_i[i] - margin: states.append("dip") elif p_obs[i] > hi_i[i] + margin: states.append("spike") else: states.append("ok") min_dur = {"pause": 25.0, "dip": 45.0, "spike": 30.0} sev_scale = {"pause": 300.0, "dip": 600.0, "spike": 300.0} events: list[dict[str, Any]] = [] n = len(states) i = 0 while i < n: s = states[i] if s == "ok": i += 1 continue j = i while j + 1 < n and states[j + 1] == s: j += 1 start_s, end_s = float(t_obs[i]), float(t_obs[j]) dur = end_s - start_s resumes = (j + 1 < n) and float(np.max(p_obs[j + 1:])) > active_thr if dur >= min_dur[s] and (s != "pause" or resumes): if s == "pause": detail = (f"Power dropped to near zero for ~{int(dur)}s then resumed — " "likely the door was opened mid-cycle or the cycle was paused.") detail_key = "msg.artifact_pause_detail" elif s == "dip": detail = f"Drew below the usual power band for ~{int(dur)}s." detail_key = "msg.artifact_dip_detail" else: detail = f"Drew above the usual power band for ~{int(dur)}s." detail_key = "msg.artifact_spike_detail" events.append({ "type": s, "start_s": round(start_s, 1), "end_s": round(end_s, 1), "detail": detail, "detail_key": detail_key, "detail_params": {"n": int(dur)}, "severity": round(min(1.0, dur / sev_scale[s]), 3), }) i = j + 1 events.sort(key=lambda e: -e["severity"]) events = events[:6] events.sort(key=lambda e: e["start_s"]) return events except Exception: # noqa: BLE001 return [] def get_match_candidates_summary( self, match_result: MatchResult, limit: int = 3 ) -> list[dict[str, Any]]: """Extract top candidates from query result for UI display. Args: match_result: MatchResult from profile matching limit: Number of top candidates to return Returns: List of dicts with keys: profile_name, confidence_pct, mae, correlation, duration_ratio """ candidates: list[dict[str, Any]] = [] for candidate in match_result.ranking[:limit]: try: confidence_pct = round(candidate.get("score", 0.0) * 100, 1) metrics = candidate.get("metrics", {}) mae = round(metrics.get("mae", 0.0), 2) corr = round(metrics.get("corr", 0.0), 3) profile_duration = candidate.get("profile_duration", 0.0) actual_duration = match_result.expected_duration duration_ratio = ( round((actual_duration / profile_duration - 1.0) * 100, 1) if profile_duration > 0 else 0.0 ) candidates.append({ "profile_name": candidate.get("name", "Unknown"), "confidence_pct": confidence_pct, "mae": mae, "correlation": corr, "duration_ratio": duration_ratio, # ±% from expected }) except (TypeError, ValueError, KeyError): continue return candidates def _get_cached_sample_segment( self, sample_cycle: dict[str, Any], dt: float ) -> Segment | None: """Get or compute resampled segment for a sample cycle, using cache.""" cycle_id = sample_cycle.get("id") if not cycle_id: return None # Round dt to avoid float cache misses dt_key = float(round(dt, 2)) key = (cycle_id, dt_key) if key in self._cached_sample_segments: return self._cached_sample_segments[key] # Miss: Compute sample_data = sample_cycle.get("power_data") if not sample_data: return None try: if len(sample_data) > 0 and isinstance(sample_data[0], (list, tuple)): s_ts = np.array([x[0] for x in sample_data]) s_p = np.array([x[1] for x in sample_data]) else: return None s_segments = resample_uniform(s_ts, s_p, dt_s=dt, gap_s=21600.0) if not s_segments: return None sample_seg = max(s_segments, key=lambda s: len(s.power)) # Store self._cached_sample_segments[key] = sample_seg return sample_seg except Exception as e: # pylint: disable=broad-exception-caught self._logger.warning("Error caching sample segment %s: %s", cycle_id, e) return None async def async_match_profile( self, current_power_data: list[tuple[str, float]] | list[tuple[datetime, float]] | list[tuple[float, float]] | list[list[float]], current_duration: float, ) -> MatchResult: """Run profile matching asynchronously in executor.""" # 1. Prepare data in main thread (Access ProfileStore state safely) group_members: dict[str, list[str]] = {} member_snaps: dict[str, dict[str, Any]] = {} # Convert to list of floats for current power (uniform resampling) if not current_power_data: return MatchResult(None, 0.0, 0.0, None, [], False, 0.0) # Pre-process current data try: # Normalize input format first_elem = current_power_data[0][0] if isinstance(first_elem, datetime): # datetime objects: compute relative timestamps t_start = first_elem.timestamp() ts_arr = np.array([(x[0].timestamp() - t_start) for x in cast(list[tuple[datetime, float]], current_power_data)]) elif isinstance(first_elem, (int, float)): # Already offset timestamps (from compressed format) ts_arr = np.array([float(x[0]) for x in cast(list[tuple[float, float]], current_power_data)]) else: # ISO format strings t_start = datetime.fromisoformat(first_elem).timestamp() ts_arr = np.array( [ (datetime.fromisoformat(x[0]).timestamp() - t_start) for x in cast(list[tuple[str, float]], current_power_data) ] ) p_arr = np.array([float(x[1]) for x in current_power_data]) # Resample current segments, used_dt = resample_adaptive(ts_arr, p_arr, min_dt=5.0, gap_s=21600.0) if not segments: return MatchResult(None, 0.0, 0.0, None, [], False, 0.0) current_seg = max(segments, key=lambda s: len(s.power)) if len(current_seg.power) < 12: return MatchResult(None, 0.0, 0.0, None, [], False, 0.0) current_power_list = current_seg.power.tolist() # Prepare Snapshots. Imported reference cycles are eligible as matching # templates alongside real cycles (so an import-only profile can match). all_cycles = list(self._data["past_cycles"]) + list(self._data.get("reference_cycles", [])) # Precompute per-profile lookups ONCE so the loop below is O(profiles), # not O(profiles x cycles). Rescanning all_cycles with next()/any() for # every profile made matching quadratic and stalled low-power hosts on # auto-label (many matches x many cycles) - issue #311. Selections are # byte-identical: cycles_by_id keeps the FIRST occurrence (== next()), # labeled_by_profile keeps the first eligible cycle in all_cycles order, # and golden_profiles mirrors the any(...) golden test. cycles_by_id: dict[str, CycleDict] = {} labeled_by_profile: dict[str, CycleDict] = {} golden_profiles: set[str] = set() for c in all_cycles: cid = c.get("id") if cid is not None and cid not in cycles_by_id: cycles_by_id[cid] = c pname = c.get("profile_name") if not pname or not c.get("power_data"): continue if ( pname not in labeled_by_profile and c.get("status") in ("completed", "force_stopped") ): labeled_by_profile[pname] = c rev = c.get("ml_review") if isinstance(rev, dict) and rev.get("golden"): golden_profiles.add(pname) snapshots: list[dict[str, Any]] = [] skipped_profiles: list[str] = [] for name, profile in self._data["profiles"].items(): # Try sample_cycle_id first, fall back to any labeled cycle sample_id = profile.get("sample_cycle_id") sample_cycle = cycles_by_id.get(sample_id) if sample_id else None # Fallback: find ANY completed cycle labeled with this profile if not sample_cycle: sample_cycle = labeled_by_profile.get(name) # Boost user-pinned "golden" cycles: when a profile has one, use # its sharp single-cycle trace as the matching template instead # of the envelope average. The envelope average smears the # wash-phase peaks (each cycle's spikes land at slightly # different times), which hurts correlation for sharply-shaped # programs; a trusted golden cycle preserves that shape. has_golden = name in golden_profiles # Prefer envelope avg curve when ≥2 labeled cycles have been # confirmed - it gives a more representative reference signal # than the original sample alone, so confidence improves over # time as the user keeps confirming correct detections. Skipped # when a golden cycle is pinned (see above). envelope = self._data.get("envelopes", {}).get(name) _env_avg = envelope.get("avg") if envelope else None if ( not has_golden and envelope and envelope.get("cycle_count", 0) >= 2 and _env_avg and isinstance(_env_avg[0], (list, tuple)) and len(_env_avg[0]) >= 2 ): avg_y = [float(p[1]) for p in _env_avg] _env_ts_duration = ( float(_env_avg[-1][0]) - float(_env_avg[0][0]) if len(_env_avg) > 1 else 0.0 ) avg_duration = ( envelope.get("target_duration") or profile.get("avg_duration") or _env_ts_duration or None ) if not avg_duration: skipped_profiles.append( f"{name}: no valid duration (envelope has no target_duration, avg_duration, or timestamp span)" ) continue snapshots.append({ "name": name, "avg_duration": float(avg_duration), "sample_power": avg_y, }) continue if not sample_cycle: skipped_profiles.append( f"{name}: no sample cycle (sample_id={sample_id})" ) continue # Prepare sample segment (using cache) sample_seg = self._get_cached_sample_segment(sample_cycle, used_dt) if not sample_seg: skipped_profiles.append( f"{name}: failed to resample cycle {sample_cycle.get('id')}" ) continue # avg_duration preference order: # 1. profile["avg_duration"] (rolling average, most accurate) # 2. sample_cycle["duration"] (raw cycle field) # 3. timestamp span of sample_seg (estimate from the resampled data) # Profiles created before avg_duration tracking was added may have # 0 or a missing value; falling back to the segment estimate prevents # update_match() from always seeing expected_duration=0, which # silences time-remaining estimates and logs a misleading warning. _seg_ts_duration = ( float(sample_seg.timestamps[-1]) - float(sample_seg.timestamps[0]) if len(sample_seg.timestamps) > 1 else 0.0 ) avg_dur = ( profile.get("avg_duration") or sample_cycle.get("duration") or _seg_ts_duration ) if not avg_dur: skipped_profiles.append( f"{name}: no valid duration (avg_duration, cycle duration, and timestamp span all zero/missing)" ) continue snapshots.append({ "name": name, "avg_duration": float(avg_dur), "sample_power": sample_seg.power.tolist(), "sample_dt": used_dt }) if skipped_profiles: self._logger.debug( "Profile matching skipped %d profiles: %s", len(skipped_profiles), "; ".join(skipped_profiles) ) # Stage 5: collapse cohesive near-duplicate groups into one aggregate # candidate each (loose groups stay individual). No-op without groups. snapshots, group_members, member_snaps = self._grouped_snapshots(snapshots) config = { "min_duration_ratio": self._min_duration_ratio, "max_duration_ratio": self._max_duration_ratio, "dtw_bandwidth": self.dtw_bandwidth, # On-device tuned scoring weights (opt-in); empty = shipped defaults. **self._matching_overrides(), } except Exception as e: # pylint: disable=broad-exception-caught self._logger.error("Preparation for async match failed: %s", e) return MatchResult(None, 0.0, 0.0, None, [], False, 0.0) # 2. Run Heavy Logic in Executor candidates = await self.hass.async_add_executor_job( analysis.compute_matches_worker, current_power_list, current_duration, cast(Any, snapshots), config ) # 3. Process Result (Main Thread) if not candidates: profiles_count = len(self._data.get("profiles", {})) snapshots_count = len(snapshots) if 'snapshots' in dir() else 0 self._logger.debug( "No profile match candidates: profiles=%d, snapshots=%d, " "duration=%.0fs. Possible reasons: duration ratio filter, " "no labeled cycles, or no profiles defined.", profiles_count, snapshots_count, current_duration ) return MatchResult(None, 0.0, 0.0, None, [], False, 0.0, [], {}, is_confident_mismatch=True, mismatch_reason="all_rejected") best = candidates[0] # Reconstruct MatchResult. Top-level ambiguity (safeguard #1): a close # group-vs-runner-up call is flagged so it goes to the uncertain/feedback # path, not a confident commit. margin, is_ambiguous = _ambiguity_from_candidates(candidates) best_name = best["name"] best_duration = best["profile_duration"] # Stage 5: if a group won, pick the best-fitting member. if best_name in group_members: chosen, member_fit, member_dur = self._stage5_pick_member( current_power_list, current_duration, group_members[best_name], member_snaps ) best_name = chosen if member_dur: best_duration = member_dur # Safeguard #2: the group aggregate matched but if the chosen member # does not individually fit reasonably (vs the group score), the real # program may be a different single profile -> treat as uncertain. # member_fit is a Stage-2-only score; best["score"] includes DTW-blend + # duration/energy agreement (typically 25-30% higher). Use 0.55× to avoid # the threshold being effectively too strict for DTW-boosted groups. if member_fit is not None and best["score"] > 0 and member_fit < 0.55 * best["score"]: is_ambiguous = True # Overrun guard: if the cycle has already outlasted the chosen member's # expected duration we may be running the longer group member — downgrade # to ambiguous so Smart Termination falls back to the power timeout. if best_duration and current_duration > best_duration * 1.05: is_ambiguous = True # Relabel the winning candidate for the ranking / diagnostics. candidates = [{**best, "name": best_name, "profile_duration": best_duration}, *candidates[1:]] matched_phase = None if best_name: # Always resolve phase for the matched profile so phase sensors can # show user-assigned phase names even when confidence is moderate. matched_phase = self.check_phase_match(best_name, current_duration) # Detect "prefix ambiguity": a non-winning candidate whose duration is # significantly longer than the matched profile AND whose shape matched # well before Stage-4 penalised its duration. When this is true the # current trace may be a prefix of that longer program, not a complete # short cycle. Signal cycle_detector to block Smart Termination; the # power-based fallback timeout will decide instead. best_dur = best_duration or 0.0 is_prefix_ambiguous = best_dur > 0 and any( float(c.get("profile_duration") or 0) > best_dur * SMART_TERM_LANDSCAPE_RATIO and float(c.get("shape_score", c.get("score", 0))) >= SMART_TERM_LANDSCAPE_MIN_SHAPE for c in candidates[1:] ) return MatchResult( best_name, best["score"], best_duration, matched_phase, candidates[:5], is_ambiguous, margin, ranking=candidates[:5], # populate ranking (consumed for training snapshots) is_prefix_ambiguous=is_prefix_ambiguous, ) def match_profile( self, power_data: list[tuple[str, float]], duration: float ) -> MatchResult: """Synchronous wrapper for matching (for use in executor tasks).""" # Convert to list for worker p_list = [p[1] for p in power_data] # Prepare snapshots safely snapshots: list[dict[str, Any]] = [] # Accessing self._data in thread is generally safe for reads if not modifying for name, profile in self._data["profiles"].items(): sample_id = profile.get("sample_cycle_id") sample_cycle = next((c for c in self._data["past_cycles"] if c["id"] == sample_id), None) if not sample_cycle: continue # Decompress sample data sample_p_data = decompress_power_data(sample_cycle) if not sample_p_data: continue snapshots.append({ "name": name, "avg_duration": profile.get("avg_duration", sample_cycle.get("duration", 0)), "sample_power": [x[1] for x in sample_p_data], }) config = { "min_duration_ratio": self._min_duration_ratio, "max_duration_ratio": self._max_duration_ratio, "dtw_bandwidth": self.dtw_bandwidth, # On-device tuned scoring weights (opt-in); empty = shipped defaults. **self._matching_overrides(), } candidates = analysis.compute_matches_worker( p_list, duration, cast(Any, snapshots), config ) if not candidates: return MatchResult(None, 0.0, 0.0, None, [], False, 0.0) best = candidates[0] margin, is_ambiguous = _ambiguity_from_candidates(candidates) return MatchResult( best["name"], best["score"], best["profile_duration"], None, candidates, is_ambiguous, margin, ranking=candidates, ) async def async_verify_alignment( self, profile_name: str, current_power_data: list[list[float]] | list[tuple[Any, ...]], ) -> tuple[bool, float, float]: """ Verify if the current power trace aligns with an expected low-power region in the envelope. Returns: (is_confirmed_low_power, mapped_envelope_time, mapped_envelope_power) """ envelope = self.get_envelope(profile_name) if not envelope or not envelope.get("avg") or not current_power_data: return False, 0.0, 9999.0 # Extract envelope curves # "avg" can be list of [t, p] (new) or [p, ...] (legacy) env_avg_raw = envelope.get("avg", []) if not env_avg_raw: return False, 0.0, 9999.0 try: # Handle both formats: [[t, y], ...] (new) or [y, ...] (legacy) if isinstance(env_avg_raw[0], (list, tuple)) and len(env_avg_raw[0]) >= 2: # New format: [[t, y], ...] env_points = cast(list[list[Any] | tuple[Any, ...]], env_avg_raw) env_time = [float(p[0]) for p in env_points] env_power = [float(p[1]) for p in env_points] else: # Legacy format: [y, ...] env_values = cast(list[float | int], env_avg_raw) env_power = [float(p) for p in env_values] # Reconstruct time grid from envelope if available, or assume 60s intervals env_time_raw = envelope.get("time_grid") env_time = cast(list[float], env_time_raw) if isinstance(env_time_raw, list) else None if not env_time or len(env_time) != len(env_power): target_dur = float(envelope.get("target_duration", 0.0) or 0.0) if target_dur > 0: env_time = cast(list[float], np.linspace(0, target_dur, len(env_power)).tolist()) else: env_time = [float(i * 60) for i in range(len(env_power))] except (TypeError, ValueError, IndexError) as e: first_type_name = type(env_avg_raw[0]).__name__ if env_avg_raw else "None" self._logger.error( "Malformed envelope 'avg' data for %s. Type: %s, Length: %d, Error: %s", profile_name, first_type_name, len(env_avg_raw), e ) return False, 0.0, 9999.0 try: current_power_list = [float(x[1]) for x in current_power_data] except Exception: # pylint: disable=broad-exception-caught return False, 0.0, 9999.0 # Offload to worker mapped_time, mapped_power, score = await self.hass.async_add_executor_job( analysis.verify_profile_alignment_worker, current_power_list, env_power, env_time, self.dtw_bandwidth ) # Verify if mapped power and alignment score indicate an expected low-power region. # Thresholds: Expected power < 15W, Alignment score > 0.4 is_confirmed = (mapped_power < 15.0) and (score > 0.4) return is_confirmed, mapped_time, mapped_power def check_phase_match(self, profile_name: str, duration: float) -> str | None: """ Check if the current duration aligns with a known phase in the profile. Returns the phase name (e.g., 'Rinse', 'Spin') or None. """ profile = self._data["profiles"].get(profile_name) if not profile: return None phases = profile.get("phases", []) if not phases: return None phases_sorted = sorted( phases, key=lambda p: float(p.get("start", 0)), ) for phase in phases_sorted: p_start = phase.get("start", 0) p_end = phase.get("end", 0) if p_start <= duration <= p_end: return str(phase.get("name", "Unknown")) # If duration is outside explicit bounds, keep a phase label anyway so # entities avoid falling back to generic running/starting states. if phases_sorted: if duration < float(phases_sorted[0].get("start", 0)): return str(phases_sorted[0].get("name", "Unknown")) return str(phases_sorted[-1].get("name", "Unknown")) return None async def create_profile(self, name: str, source_cycle_id: str) -> None: """Create a new profile from a past cycle.""" cycle = next( (c for c in self._data["past_cycles"] if c["id"] == source_cycle_id), None ) if not cycle: raise ValueError("Cycle not found") cycle["profile_name"] = name self._data.setdefault("profiles", {})[name] = { "avg_duration": cycle["duration"], "sample_cycle_id": source_cycle_id, } # Save to persist the label await self.async_save() @property def has_real_profiles(self) -> bool: """True if at least one stored profile is backed by a real cycle. A profile counts as "real" when it has a labelled cycle in ``past_cycles`` OR an imported ``reference_cycle`` (store-adopted templates that the matcher treats as eligible snapshots — see the snapshot builder in async_match). An import-only install has zero past_cycles but is fully matchable, so it must pass this gate too, otherwise matching and the setup notifications are skipped for it entirely. """ profile_names = self._data.get("profiles", {}).keys() if not profile_names: return False assigned = { c.get("profile_name") for c in self._data.get("past_cycles", []) if c.get("profile_name") } if assigned.intersection(profile_names): return True ref_assigned = { c.get("profile_name") for c in self._data.get("reference_cycles", []) if c.get("profile_name") } return bool(ref_assigned.intersection(profile_names)) def list_profiles(self) -> list[dict[str, Any]]: """List all profiles with metadata.""" profiles: list[JSONDict] = [] raw_profiles = self._data.get("profiles", {}) profiles_map = ( cast(dict[str, Any], raw_profiles) if isinstance(raw_profiles, dict) else {} ) for name, data in profiles_map.items(): profile_meta = cast(JSONDict, data) if isinstance(data, dict) else {} # Calculate count and last_run p_cycles = [ c for c in self._data.get("past_cycles", []) if c.get("profile_name") == name ] cycle_count = len(p_cycles) last_run = None if p_cycles: last_c = max(p_cycles, key=lambda x: x.get("start_time", "")) last_run = last_c.get("start_time") # Fetch envelope stats envelope = self.get_envelope(name) avg_energy = envelope.get("avg_energy") if envelope else None duration_std_dev = envelope.get("duration_std_dev") if envelope else None # Per-profile cost aggregates from frozen per-cycle costs. Never raises; # both default to None when no cycle carries a cost. avg_cost: float | None = None total_cost: float | None = None try: costs = [float(c["cost"]) for c in p_cycles if c.get("cost") is not None] if costs: total_cost = round(sum(costs), 4) avg_cost = round(sum(costs) / len(costs), 4) except Exception: # noqa: BLE001 avg_cost = None total_cost = None # Downsampled power signature (the profile's real average power curve), # for the profile-card mini graph. Small (<=40 pts), power values only. sig_curve: list[float] = [] try: avg_pts = (envelope or {}).get("avg") or [] ps = [ float(p[1]) for p in avg_pts if isinstance(p, (list, tuple)) and len(p) >= 2 ] if not ps: # legacy flat [p, ...] envelopes ps = [float(x) for x in avg_pts if isinstance(x, (int, float))] if len(ps) > 40: step = (len(ps) - 1) / 39.0 sig_curve = [ps[round(i * step)] for i in range(40)] else: sig_curve = ps except Exception: # noqa: BLE001 sig_curve = [] profiles.append( { "name": name, "avg_duration": profile_meta.get("avg_duration", 0), "min_duration": profile_meta.get("min_duration", 0), "max_duration": profile_meta.get("max_duration", 0), "sample_cycle_id": profile_meta.get("sample_cycle_id"), "cycle_count": cycle_count, "last_run": last_run, "avg_energy": avg_energy, "duration_std_dev": duration_std_dev, "avg_cost": avg_cost, "total_cost": total_cost, "signature_curve": sig_curve, "is_imported": self.profile_has_reference_cycles(name), } ) return sorted(profiles, key=lambda p: profile_sort_key(p.get("name", ""))) async def create_profile_standalone( self, name: str, reference_cycle_id: str | None = None, avg_duration: float | None = None, ) -> None: """Create a profile without immediately labeling a cycle. If reference_cycle_id is provided, use that cycle's characteristics. If avg_duration is provided (and no reference cycle), use it as baseline.""" if name in self._data.get("profiles", {}): raise ValueError(f"Profile '{name}' already exists") profile_data: JSONDict = {} if reference_cycle_id: cycle = next( (c for c in self._data["past_cycles"] if c["id"] == reference_cycle_id), None, ) if cycle: profile_data = { "avg_duration": cycle["duration"], "sample_cycle_id": reference_cycle_id, } # Label the reference cycle with the new profile so that # statistics are immediately populated after creation. if not cycle.get("profile_name"): cycle["profile_name"] = name elif avg_duration is not None and avg_duration > 0: profile_data = { "avg_duration": float(avg_duration), } # Create profile with minimal data (will be updated when cycles are labeled) profile_data.setdefault("phases", []) self._data.setdefault("profiles", {})[name] = profile_data # Build the envelope from any already-labeled cycles (e.g. reference cycle above) await self.async_rebuild_envelope(name) await self.async_save() self._logger.info("Created standalone profile '%s'", name) async def update_profile( self, old_name: str, new_name: str, avg_duration: float | None = None ) -> int: """Update a profile's name and/or average duration. Returns number of cycles updated (if renamed).""" profiles = self._data.get("profiles", {}) if old_name not in profiles: raise ValueError(f"Profile '{old_name}' not found") # Handle Rename renamed = False if new_name != old_name: if new_name in profiles: raise ValueError(f"Profile '{new_name}' already exists") # Rename in profiles dict profiles[new_name] = profiles.pop(old_name) # Rename in envelopes if "envelopes" in self._data and old_name in self._data["envelopes"]: self._data["envelopes"][new_name] = self._data["envelopes"].pop( old_name ) renamed = True target_name = new_name if renamed else old_name # Handle Duration Update if avg_duration is not None and avg_duration > 0: profiles[target_name]["avg_duration"] = float(avg_duration) # If there's an envelope, we ideally update its target_duration too, # but envelope is usually rebuilt from data. # However, for manual profiles, envelope might be empty or theoretical. # Let's log it. self._logger.info( "Updated baseline duration for '%s' to %ss", target_name, avg_duration, ) # Update cycles and feedback if renamed count = 0 if renamed: # 1. Update past + imported reference cycles (imports carry profile_name # too; leaving them under the old name orphans them from the matcher). for cycle in ( list(self._data.get("past_cycles", [])) + list(self._data.get("reference_cycles", [])) ): if cycle.get("profile_name") == old_name: cycle["profile_name"] = new_name count += 1 # 2. Update pending feedback pending = self.get_pending_feedback() for req in pending.values(): if req.get("detected_profile") == old_name: req["detected_profile"] = new_name # 3. Update feedback history history = self.get_feedback_history() for record in history.values(): if record.get("original_detected_profile") == old_name: record["original_detected_profile"] = new_name if record.get("corrected_profile") == old_name: record["corrected_profile"] = new_name # 4. Update group membership (rename the member in any group) for g in self._data.get("profile_groups", {}).values(): mems = g.get("members") if isinstance(g, dict) else None if isinstance(mems, list) and old_name in mems: g["members"] = [new_name if m == old_name else m for m in mems] self._logger.info( "Renamed profile '%s' to '%s', updated %s cycles and associated feedback", old_name, new_name, count, ) await self.async_save() return count async def delete_profile(self, name: str, unlabel_cycles: bool = True) -> int: """Delete a profile. If unlabel_cycles=True, removes profile label from cycles. If unlabel_cycles=False, cycles keep the label (orphaned). Returns number of cycles affected.""" if name not in self._data.get("profiles", {}): raise ValueError(f"Profile '{name}' not found") # Delete profile del self._data["profiles"][name] # Handle cycles (past + imported reference; both carry profile_name, so an # imported cycle would otherwise keep a dangling label for a deleted profile). count = 0 for cycle in ( list(self._data.get("past_cycles", [])) + list(self._data.get("reference_cycles", [])) ): if cycle.get("profile_name") == name: if unlabel_cycles: cycle["profile_name"] = None count += 1 await self.async_save() action = "unlabeled" if unlabel_cycles else "orphaned" self._logger.info("Deleted profile '%s', %s %s cycles", name, action, count) return count async def clear_all_data(self) -> None: """Clear all profiles, cycle data, and derived state.""" self._data["past_cycles"] = [] self._data["reference_cycles"] = [] self._data["profiles"] = {} self._data["envelopes"] = {} self._data["suggestions"] = {} self._data["feedback_history"] = {} self._data["pending_feedback"] = {} self._data["auto_adjustments"] = [] self._data["active_cycle"] = None self._data["last_active_save"] = None # Newer persisted state must also be wiped, else a "wipe all" leaves trained # models, groups, matcher tuning, histories, and counters behind. self._data["custom_phases"] = [] self._data["ml_model_versions"] = {} self._data["profile_groups"] = {} self._data["maintenance_log"] = [] self._data["matching_config"] = {} self._data["match_ranking_history"] = [] self._data["ml_last_training_run"] = None self._data["ml_training_history"] = {} self._data["lifetime_energy_wh"] = 0.0 self._data["lifetime_cycle_count"] = 0 self._data["settings_changelog"] = [] self._data["suggestion_apply_cycle_count"] = 0 self._cached_sample_segments = {} self._cohesion_cache = {} self._cohesion_cache_generation += 1 await self.async_save() self._logger.info("Cleared all WashData storage") async def assign_profile_to_cycle( self, cycle_id: str, profile_name: str | None ) -> None: """Assign an existing profile to a cycle. Rebuilds envelope.""" old_profile = None cycle = next( (c for c in self._data["past_cycles"] if c["id"] == cycle_id), None ) if not cycle: # Imported reference recording (separate list, never in usage stats): # relabelling just moves which profile's template it seeds. ref = next( (c for c in self.get_reference_cycles() if c.get("id") == cycle_id), None, ) if ref is not None: await self._assign_reference_cycle_profile(ref, profile_name) return raise ValueError(f"Cycle {cycle_id} not found") # Track old profile for envelope rebuild old_profile = cycle.get("profile_name") if profile_name and profile_name not in self._data.get("profiles", {}): raise ValueError(f"Profile '{profile_name}' not found. Create it first.") # Preserve original auto-assigned label before first manual relabeling if profile_name and old_profile and not cycle.get("original_auto_label"): orig_src = cycle.get("label_source", "") if orig_src in ("auto_match", "auto_label_post", "auto_label_service"): cycle["original_auto_label"] = old_profile # Update cycle cycle["profile_name"] = profile_name if profile_name else None cycle["label_source"] = "manual" if profile_name else None # Update profile metadata if this is the first cycle if profile_name: profile = self._data["profiles"][profile_name] if not profile.get("sample_cycle_id"): profile["sample_cycle_id"] = cycle_id profile["avg_duration"] = cycle["duration"] # Rebuild envelopes for affected profiles if old_profile and old_profile != profile_name: await self.async_rebuild_envelope(old_profile) # Old profile lost a cycle if profile_name: await self.async_rebuild_envelope(profile_name) # New profile gained a cycle # Apply retention after labeling, in case profile now exceeds cap await self.async_enforce_retention() await self.async_save() self._logger.info("Assigned profile '%s' to cycle %s", profile_name, cycle_id) # Trigger smart processing to potentially merge now-labeled cycle await self.async_smart_process_history() async def _assign_reference_cycle_profile( self, ref: CycleDict, profile_name: str | None ) -> None: """Reassign an imported reference recording to a different profile. The cycle stays in ``reference_cycles`` (out of usage stats); only which profile envelope it seeds changes. Rebuilds the old and new envelopes. ``profile_name=None`` clears the label (the recording then seeds nothing). """ if profile_name and profile_name not in self._data.get("profiles", {}): raise ValueError(f"Profile '{profile_name}' not found. Create it first.") old_profile = ref.get("profile_name") ref_id = ref.get("id") ref["profile_name"] = profile_name if profile_name else None if old_profile and old_profile != profile_name: # The moved cycle may have been the old profile's sample. Clear that # stale pointer so the old profile can't resolve the moved trace (now # another profile's) by id, and drop the old profile if it is now empty # (mirrors _delete_reference_cycle; prevents repair adopting a real cycle). op = self._data.get("profiles", {}).get(old_profile) if op is not None and op.get("sample_cycle_id") == ref_id: op["sample_cycle_id"] = None old_has_cycles = any( c.get("profile_name") == old_profile for c in list(self._data.get("past_cycles", [])) + list(self._data.get("reference_cycles", [])) ) if old_has_cycles: await self.async_rebuild_envelope(old_profile) else: self._data.get("profiles", {}).pop(old_profile, None) self._data.get("envelopes", {}).pop(old_profile, None) if profile_name: await self.async_rebuild_envelope(profile_name) await self.async_save() self._logger.info( "Reassigned imported reference cycle %s to profile '%s'", ref.get("id"), profile_name, ) async def auto_label_cycles( self, confidence_threshold: float = 0.75, overwrite: bool = False ) -> dict[str, int]: """Auto-label cycles retroactively using profile matching. Args: confidence_threshold: Min confidence to apply a label. overwrite: If True, re-evaluates already labeled cycles. Returns stats: {labeled: int, relabeled: int, skipped: int, total: int} """ stats = {"labeled": 0, "relabeled": 0, "skipped": 0, "total": 0} cycles = self._data.get("past_cycles", []) # Filter down if not overwriting if not overwrite: target_cycles = [c for c in cycles if not c.get("profile_name")] else: target_cycles = cycles stats["total"] = len(target_cycles) for cycle in target_cycles: # Reconstruct power data for matching power_data = decompress_power_data(cycle) if not power_data or len(power_data) < 10: stats["skipped"] += 1 continue # Try to match result = await self.async_match_profile(power_data, cycle["duration"]) # Honor the ambiguity safeguard: never auto-label a close/ambiguous match. if result.best_profile and result.confidence >= confidence_threshold and not result.is_ambiguous: current_label = cycle.get("profile_name") # Sanitize: strip heavy current/sample arrays before persisting. ranking_top5 = [ { "name": c.get("name"), "score": round(float(c.get("score", 0.0)), 3), "profile_duration": c.get("profile_duration"), } for c in (getattr(result, "ranking", []) or [])[:5] ] # If overwriting, check if new match is different and better/valid if current_label: if current_label != result.best_profile: # Preserve original label before first auto-service relabeling if not cycle.get("original_auto_label"): orig_src = cycle.get("label_source", "") if orig_src in ("auto_match", "auto_label_post", "auto_label_service"): cycle["original_auto_label"] = current_label cycle["profile_name"] = result.best_profile cycle["match_confidence"] = float(result.confidence) cycle["label_source"] = "auto_label_service" if ranking_top5: cycle["match_ranking_top5"] = ranking_top5 stats["relabeled"] += 1 self._logger.info( "Relabeled cycle %s: '%s' -> '%s' (confidence: %.2f)", cycle["id"], current_label, result.best_profile, result.confidence, ) else: cycle["profile_name"] = result.best_profile cycle["match_confidence"] = float(result.confidence) cycle["label_source"] = "auto_label_service" if ranking_top5: cycle["match_ranking_top5"] = ranking_top5 stats["labeled"] += 1 self._logger.info( "Auto-labeled cycle %s as '%s' (confidence: %.2f)", cycle["id"], result.best_profile, result.confidence, ) else: stats["skipped"] += 1 if stats["labeled"] > 0 or stats["relabeled"] > 0: await self.async_save() # Trigger smart processing after bulk labeling await self.async_smart_process_history() self._logger.info( "Auto-labeling complete: %s labeled, %s relabeled, %s skipped", stats["labeled"], stats["relabeled"], stats["skipped"], ) return stats async def async_backfill_match_confidence(self) -> int: """Populate match_confidence for labeled cycles that predate the field. Runs the matcher once per cycle with profile_name set but no match_confidence, and persists the resulting confidence if the same profile is returned. Returns the number of cycles updated. Safe to call repeatedly - already-backfilled cycles are skipped. """ cycles = self._data.get("past_cycles", []) or [] updated = 0 for cycle in cycles: if cycle.get("match_confidence") is not None: continue profile_name = cycle.get("profile_name") if not profile_name: continue power_data = decompress_power_data(cycle) if not power_data or len(power_data) < 10: continue try: result = await self.async_match_profile( power_data, cycle.get("duration", 0) ) except Exception: # pylint: disable=broad-exception-caught continue if result.best_profile == profile_name and result.confidence > 0: cycle["match_confidence"] = float(result.confidence) updated += 1 if updated: await self.async_save() self._logger.info("Backfilled match_confidence on %d cycles", updated) return updated async def async_migrate_cycles_to_compressed(self) -> int: """ Migrate all cycles to the compressed format. Ensures all cycles use [offset_seconds, power] format. Returns number of cycles migrated. """ raw_cycles = self._data.get("past_cycles", []) cycles: list[CycleDict] = ( cast(list[CycleDict], raw_cycles) if isinstance(raw_cycles, list) else [] ) migrated = 0 for cycle in cycles: raw_data: list[Any] = cycle.get("power_data", []) or [] if not raw_data: continue # Check if already compressed (first element is number or mixed format) first_elem = raw_data[0][0] if isinstance(first_elem, (int, float)): # Already in offset format continue # Old format: ISO timestamp strings. Convert to compressed offsets. try: compressed = compress_power_data(cycle) if compressed: cycle["power_data"] = compressed migrated += 1 except Exception as e: # pylint: disable=broad-exception-caught self._logger.warning("Failed to migrate cycle %s: %s", cycle.get("id"), e) continue if migrated > 0: self._logger.info("Migrated %s cycles to compressed format", migrated) await self.async_save() return migrated async def async_smart_process_history( self ) -> dict[str, int]: # Orchestrate smart history processing: Cleanup, Retention. # Split/Merge is now manual via Interactive Editor. stats = {"cleaned_profiles": 0} # 1. Cleanup self._logger.debug("Running maintenance: cleanup_orphaned_profiles") stats["cleaned_profiles"] = self.cleanup_orphaned_profiles() # 2. Retention self._logger.debug("Running maintenance: async_enforce_retention") await self.async_enforce_retention() # 3. Save self._logger.debug("Maintenance complete, saving") await self.async_save() return stats def export_data( self, entry_data: JSONDict | None = None, entry_options: JSONDict | None = None ) -> JSONDict: # Return a serializable snapshot of the store for backup/export. # Includes config entry data/options so users can transfer fine-tuned settings. opts = entry_options or {} data = entry_data or {} _FINGERPRINT_KEYS = ( "off_delay", "min_power", "sampling_interval", "start_power_threshold", "idle_power_threshold", "min_cycle_duration", "max_cycle_duration", "delay_start_detect_enabled", ) device_fingerprint: JSONDict = { "device_type": data.get("device_type") or opts.get("device_type", "unknown"), } for key in _FINGERPRINT_KEYS: if key in opts: device_fingerprint[key] = opts[key] # Never let a backup/diagnostics export carry the GitHub refresh token: the # (legacy, now-global) per-device store account may still hold it. Shallow-copy # so we can drop the credential without mutating live state, and so a caller # can't alias-mutate self._data through the returned snapshot. export_store = dict(self._data) export_store.pop("store_account", None) return { "version": STORAGE_VERSION, "entry_id": self.entry_id, "exported_at": dt_util.now().isoformat(), "device_fingerprint": device_fingerprint, "data": export_store, "entry_data": data, "entry_options": opts, } async def async_import_data(self, payload: dict[str, Any]) -> dict[str, Any]: # Import data from JSON payload (migration aware). # Unwrap HA diagnostics download file (outer HA wrapper: {home_assistant, data, ...}) if "home_assistant" in payload and "data" in payload: payload = payload["data"] self._logger.info("Detected HA diagnostics file wrapper, unwrapping 'data'") # Unwrap our integration's diagnostics format ({entry, manager_state, store_export, ...}) if "store_export" in payload: payload = payload["store_export"] self._logger.info("Detected diagnostics store_export format, unwrapping") version = payload.get("version", 1) # Handle v1 format (flat structure) - convert to v2 if version == 1 or "data" not in payload: # V1 format had profiles/past_cycles at top level data_dict = { "profiles": payload.get("profiles", {}), "past_cycles": payload.get("past_cycles", []), "envelopes": payload.get("envelopes", {}), } self._logger.info( "Importing v1 format: %s cycles", len(data_dict.get("past_cycles", [])) ) else: # V2 format with nested "data" key data = payload.get("data") if not isinstance(data, dict): raise ValueError( "Invalid export payload (missing or invalid 'data' key)" ) data_dict = cast(JSONDict, data) self._logger.info( "Importing v2 format: %s cycles", len(data_dict.get("past_cycles", [])) ) # Validate and repair structure if not isinstance(data_dict.get("profiles"), dict): data_dict["profiles"] = {} if not isinstance(data_dict.get("past_cycles"), list): data_dict["past_cycles"] = [] if not isinstance(data_dict.get("reference_cycles"), list): data_dict["reference_cycles"] = [] data_dict.setdefault("envelopes", {}) if not data_dict.get("profiles") and not data_dict.get("past_cycles"): raise ValueError( "Import payload contains no profiles or cycles — aborting to prevent data loss" ) self._data = data_dict self._cached_sample_segments = {} await self.async_save() # Strip diagnostic redaction sentinels so they don't overwrite real settings def _strip_redacted(d: dict) -> dict: if not isinstance(d, dict): return {} return {k: v for k, v in d.items() if v != "**REDACTED**"} return { "entry_data": _strip_redacted(payload.get("entry_data", {})), "entry_options": _strip_redacted(payload.get("entry_options", {})), } async def delete_cycle(self, cycle_id: str) -> bool: """Delete a cycle by ID.""" cycles = cast(list[CycleDict], self._data.get("past_cycles", [])) initial_len = len(cycles) cycle_to_delete = next((c for c in cycles if c.get("id") == cycle_id), None) if not cycle_to_delete: # Not a real cycle -- it may be an imported store recording, which # lives in the separate reference_cycles list (never in usage stats). return await self._delete_reference_cycle(cycle_id) profile_name = cycle_to_delete.get("profile_name") self._data["past_cycles"] = [c for c in cycles if c.get("id") != cycle_id] if len(self._data["past_cycles"]) < initial_len: # Check profile references for _p_name, p_data in self.get_profiles().items(): if p_data.get("sample_cycle_id") == cycle_id: p_data["sample_cycle_id"] = None # Drop any pending feedback for this cycle so it can't orphan the # "needs review" count (the count would say 1 while the cycle is gone). self._data.get("pending_feedback", {}).pop(cycle_id, None) # Rebuild envelope if cycle belonged to a profile if profile_name: await self.async_rebuild_envelope(profile_name) await self.async_save() return True return False async def _delete_reference_cycle(self, cycle_id: str) -> bool: """Delete a single imported store recording from ``reference_cycles``. Rebuilds the affected profile's envelope so removing a bad import immediately stops influencing the matcher template. Returns False when no reference cycle carries that id. """ refs = cast(list[CycleDict], self._data.get("reference_cycles", [])) cycle = next((c for c in refs if c.get("id") == cycle_id), None) if cycle is None: return False profile_name = cycle.get("profile_name") self._data["reference_cycles"] = [c for c in refs if c.get("id") != cycle_id] # Clear any profile that sampled this now-deleted reference cycle, mirroring # the real-cycle path in delete_cycle, so no sample id is left dangling. for _p_name, p_data in self.get_profiles().items(): if p_data.get("sample_cycle_id") == cycle_id: p_data["sample_cycle_id"] = None if profile_name: # If removing this recording leaves the profile with no cycles at all # (no real, no imported), drop the now-empty profile. Otherwise a # sampleless import-only profile would be re-populated by # async_repair_profile_samples stealing an unlabeled real cycle into it. remaining = any( c.get("profile_name") == profile_name for c in list(self._data.get("past_cycles", [])) + list(self._data.get("reference_cycles", [])) ) if remaining: await self.async_rebuild_envelope(profile_name) else: self._data.get("profiles", {}).pop(profile_name, None) self._data.get("envelopes", {}).pop(profile_name, None) await self.async_save() return True def get_cycle_power_data(self, cycle_id: str) -> list[tuple[float, float]]: """Return decompressed power data for a cycle as [(offset_s, watts), ...]. Returns an empty list if the cycle is not found or has no power data. """ cycle = next( (c for c in self.get_past_cycles() if c.get("id") == cycle_id), None ) if cycle is None: # Imported store recordings live in a separate list; the panel opens # them from the same Cycles table, so look them up here too. cycle = next( (c for c in self.get_reference_cycles() if c.get("id") == cycle_id), None, ) if cycle is None: return [] return decompress_power_data(cycle) async def trim_cycle_power_data( self, cycle_id: str, new_start_s: float, new_end_s: float, ) -> bool: """Trim a cycle's power_data to the window [new_start_s, new_end_s]. Offsets are renormalized so the kept segment starts at 0.0. The cycle's ``duration``, ``signature``, and ``sampling_interval`` are recomputed from the trimmed data. Returns True if successful, False if the cycle was not found or the resulting data is empty. """ cycles = cast(list[CycleDict], self._data.get("past_cycles", [])) cycle = next((c for c in cycles if c.get("id") == cycle_id), None) if cycle is None: return False p_data = decompress_power_data(cycle) if not p_data: return False new_start_s = max(0.0, float(new_start_s)) new_end_s = float(new_end_s) kept = sorted( ( (offset, power) for offset, power in p_data if new_start_s <= offset <= new_end_s ), key=lambda x: x[0], ) if not kept: return False # Re-normalize offsets so the trimmed segment starts at 0.0 base = kept[0][0] renorm: list[list[float]] = [ [round(offset - base, 2), power] for offset, power in kept ] # Advance start_time when trimming from the front if base > 0: start_ts = _value_to_timestamp(cycle.get("start_time")) if start_ts is not None: cycle["start_time"] = dt_util.utc_from_timestamp( start_ts + base ).isoformat() # Recompute sampling interval if len(renorm) > 1: offsets_arr = np.array([r[0] for r in renorm], dtype=float) intervals = np.diff(offsets_arr) pos = intervals[intervals > 0] sampling_interval = float(np.median(pos)) if len(pos) > 0 else 1.0 else: sampling_interval = 1.0 # Recompute signature ts_arr = np.array([r[0] for r in renorm], dtype=float) p_arr = np.array([r[1] for r in renorm], dtype=float) if len(ts_arr) > 1: sig = compute_signature(ts_arr, p_arr) cycle["signature"] = dataclasses.asdict(sig) else: cycle["signature"] = None new_duration = round(renorm[-1][0], 1) if renorm else 0.0 cycle["power_data"] = renorm cycle["sampling_interval"] = round(sampling_interval, 1) cycle["duration"] = new_duration # Keep end_time consistent with the updated start_time and duration new_start_ts = _value_to_timestamp(cycle.get("start_time")) if new_start_ts is not None: cycle["end_time"] = dt_util.utc_from_timestamp( new_start_ts + new_duration ).isoformat() # Clear manual_duration override - trimmed duration is now authoritative cycle.pop("manual_duration", None) # Mark the cycle as edited so store-upload provenance (qc) can distinguish a # trimmed cycle from a plain detected one. meta = cycle.setdefault("meta", {}) if isinstance(meta, dict): meta["edited"] = True meta["trim"] = [round(new_start_s, 1), round(new_end_s, 1)] # Invalidate cached sample segments for this cycle so future lookups # are recomputed from the trimmed data stale_keys = [k for k in self._cached_sample_segments if k[0] == cycle_id] for k in stale_keys: del self._cached_sample_segments[k] # Rebuild envelope for the associated profile profile_name = cycle.get("profile_name") if profile_name: await self.async_rebuild_envelope(profile_name) await self.async_save() return True def analyze_split_sync( self, cycle: CycleDict, min_gap_s: int = 900, idle_power: float = 2.0 ) -> list[tuple[float, float]]: """Analyze cycle for potential splits (sync for executor).""" p_data = decompress_power_data(cycle) if not p_data: return [] # Parse all points to (rel_t, power) points: list[tuple[float, float]] = [] for offset_seconds, val in p_data: points.append((float(offset_seconds), float(val))) if not points: return [] valid_segments: list[tuple[float, float]] = [] seg_start = 0.0 for i in range(1, len(points)): t, _ = points[i] prev_t, prev_p = points[i-1] # Detect idle gap gap = t - prev_t if prev_p < idle_power and gap > min_gap_s: # Segment ending at prev_t if (prev_t - seg_start) > 60: valid_segments.append((seg_start, prev_t)) seg_start = t # Last segment last_t = points[-1][0] if (last_t - seg_start) > 60: valid_segments.append((seg_start, last_t)) if len(valid_segments) < 2: return [] self._logger.debug( "Analyzed split for %s: found %d segments", cycle.get("id"), len(valid_segments) ) return valid_segments def build_split_segments_from_offsets( self, cycle: CycleDict, split_offsets_s: list[float], min_segment_s: float = 60.0, ) -> list[tuple[float, float]]: """Build segments for a manual split from explicit offsets (seconds from cycle start). Returns adjacent [(start, end)] segments covering the cycle window, split at the given offsets. Offsets are sorted and deduplicated; offsets outside the cycle window or producing a sub-`min_segment_s` slice are dropped. Returns [] if fewer than two segments would result. """ p_data = decompress_power_data(cycle) if not p_data: return [] last_t = float(p_data[-1][0]) if last_t <= 0: return [] unique_offsets = sorted( {round(float(o), 3) for o in split_offsets_s if 0.0 < float(o) < last_t} ) if not unique_offsets: return [] filtered_offsets: list[float] = [] for offset in unique_offsets: if offset <= min_segment_s: continue if offset >= (last_t - min_segment_s): continue if filtered_offsets and (offset - filtered_offsets[-1]) < min_segment_s: continue filtered_offsets.append(offset) if not filtered_offsets: return [] boundaries = [0.0, *filtered_offsets, last_t] segments: list[tuple[float, float]] = [] for i in range(len(boundaries) - 1): seg_start = boundaries[i] seg_end = boundaries[i + 1] if (seg_end - seg_start) >= min_segment_s: segments.append((seg_start, seg_end)) if len(segments) < 2: return [] self._logger.debug( "Built manual split for %s: %d segments at offsets %s", cycle.get("id"), len(segments), filtered_offsets, ) return segments async def apply_split_interactive( self, cycle_id: str, segments: list[dict[str, Any]] ) -> list[str]: """Apply a manual split config. segments format: [{"start": float, "end": float, "profile": str|None}] Returns list of new cycle IDs. """ cycles = cast(list[CycleDict], self._data.get("past_cycles", [])) idx = next((i for i, c in enumerate(cycles) if c.get("id") == cycle_id), -1) if idx == -1: return [] cycle = cycles[idx] cycles.pop(idx) # Remove original new_ids: list[str] = [] original_profile = cycle.get("profile_name") start_dt_base_parsed = _parse_start_dt(cycle["start_time"]) if not start_dt_base_parsed: return [] start_ts = start_dt_base_parsed.timestamp() # Decompress original data p_data_tuples = decompress_power_data(cycle) if not p_data_tuples: return [] # Prepare points (relative seconds) points: list[tuple[float, float]] = [] for offset_seconds, val in p_data_tuples: points.append((float(offset_seconds), float(val))) # Create new cycles for seg in segments: if isinstance(seg, (list, tuple)): seg_tuple = cast(tuple[Any, ...] | list[Any], seg) seg_start = float(seg_tuple[0]) seg_end = float(seg_tuple[1]) seg_profile = None else: seg_start = float(seg["start"]) seg_end = float(seg["end"]) seg_profile = seg.get("profile") seg_dur = seg_end - seg_start new_cycle_start = start_dt_base_parsed + timedelta(seconds=seg_start) new_cycle_start_ts = new_cycle_start.timestamp() # Extract points for this segment p_data_abs: list[list[float]] = [] # Find closest state before/at start to ensure continuity? # Or just take points strictly inside? # Generally better to capture the state at start 0. state_val = 0.0 for t, p in points: if t <= seg_start: state_val = p else: break # Start point (t=0 relative to new cycle) p_data_abs.append([round(new_cycle_start_ts, 1), state_val]) for t, p in points: if seg_start < t <= seg_end: p_data_abs.append([round(start_ts + t, 1), p]) # Create Cycle Record new_cycle: dict[str, Any] = { "start_time": new_cycle_start.isoformat(), "end_time": (new_cycle_start + timedelta(seconds=seg_dur)).isoformat(), "duration": round(seg_dur, 1), "status": "completed", "power_data": p_data_abs, "profile_name": seg_profile } self.add_cycle(new_cycle) new_ids.append(new_cycle["id"]) # Fix profile refs (handle original sample cycle logic) original_sample_id = cycle.get("id") best_replacement_id = None longest_dur = 0 new_cycles_objs = [c for c in cycles if c["id"] in new_ids] # 'cycles' is mutated by add_cycle for c in new_cycles_objs: d = c.get("duration", 0) if d > longest_dur: longest_dur = d best_replacement_id = c["id"] if best_replacement_id and original_profile: p_data = self._data["profiles"].get(original_profile) if p_data and p_data.get("sample_cycle_id") == original_sample_id: p_data["sample_cycle_id"] = best_replacement_id # Rebuild envelopes ONLY for the profiles whose dataset actually changed: # the original profile (it lost the parent cycle) plus any profile a labeled # segment was assigned to. This replaces a blanket rebuild-all-envelopes in # the caller, which re-scanned every profile serially and stalled low-power # hosts on a split (issue #311 follow-up). touched: set[str] = set() if original_profile: touched.add(original_profile) for seg in segments: if isinstance(seg, dict): seg_prof = seg.get("profile") if seg_prof: touched.add(seg_prof) for name in touched: await self.async_rebuild_envelope(name) await self.async_save() self._logger.info("Interactive Split Applied to %s -> %s", cycle_id, new_ids) return new_ids async def apply_merge_interactive( self, cycle_ids: list[str], target_profile: str | None ) -> str | None: """ Merge multiple past cycles into a single cycle record, filling gaps between traces with short zero-power segments. Parameters: cycle_ids (list[str]): Unordered set of past-cycle IDs to merge; at least two IDs are required. The function internally sorts cycles by start_time and mutates the chronologically earliest cycle. target_profile (str | None): Profile name to assign to the merged cycle, or `None` to leave unlabeled. Description: When successful, this sorts the provided cycles by start_time and replaces the earliest cycle with the merged cycle, removing the other consumed cycles and updating related metadata. Side effects: - Updates the store's past_cycles (removes consumed cycles and replaces the first cycle with the merged record). - Clears any `manual_duration` override on the resulting cycle. - Updates `sample_cycle_id` references in profiles that pointed to removed cycles. - Attempts to recompute and store the merged cycle's signature. - Persists changes to storage and triggers envelope rebuilds for affected profiles. Returns: merged_id (str | None): The new merged cycle's ID if the merge was applied, `None` if the merge could not be performed. """ if len(cycle_ids) < 2: return None cycles = self.get_past_cycles() target_cycles = [c for c in cycles if c.get("id") in cycle_ids] if len(target_cycles) != len(cycle_ids): return None # Sort by time - use timestamp comparison to handle mixed timezone offsets correctly def _cycle_start_ts(c: CycleDict) -> float: ts = _value_to_timestamp(c.get("start_time")) return ts if ts is not None else float("inf") target_cycles.sort(key=_cycle_start_ts) # Collect affected profiles for envelope rebuild affected_profiles: set[str] = set() for c in target_cycles: if c.get("profile_name"): affected_profiles.add(c["profile_name"]) if target_profile: affected_profiles.add(target_profile) # We modify the first cycle (c1) to become the merged one c1 = target_cycles[0] ids_to_remove: list[str] = [] # Base setup c1_start_dt = _parse_start_dt(c1["start_time"]) if not c1_start_dt: return None # Helper to get parsed points from a cycle def get_points(cy: CycleDict) -> list[tuple[float, float, float]]: # content: [(timestamp, offset, power)] raw = decompress_power_data(cy) res: list[tuple[float, float, float]] = [] if not raw: return [] base_dt = _parse_start_dt(cy["start_time"]) if base_dt is None: return [] base_t = base_dt.timestamp() for offset_seconds, val in raw: t_abs = base_t + float(offset_seconds) res.append((t_abs, float(offset_seconds), float(val))) return res # Start with C1 points merged_points_abs: list[list[float]] = [] # [timestamp, power] # Add C1 points c1_pts = get_points(c1) for t_abs, _, p in c1_pts: merged_points_abs.append([t_abs, p]) # Use the maximum t_abs seen so far (guards against out-of-order or corrupted points) last_t_abs = max((pt[0] for pt in c1_pts), default=c1_start_dt.timestamp()) # Iterate others max_power = c1.get("max_power", 0) for next_c in target_cycles[1:]: c_start_dt = _parse_start_dt(next_c.get("start_time")) if not c_start_dt: continue c_pts = get_points(next_c) if not c_pts: continue current_start_ts = c_pts[0][0] # --- GAP FILLING --- gap = current_start_ts - last_t_abs # If gap > 1s, inject 0W points to ensure graph drops to 0 if gap > 1.0: merged_points_abs.append([last_t_abs + 0.1, 0.0]) merged_points_abs.append([current_start_ts - 0.1, 0.0]) # Append points; track the running maximum to guard against reversed/corrupt data for t_abs, _, p in c_pts: merged_points_abs.append([t_abs, p]) if t_abs > last_t_abs: last_t_abs = t_abs max_power = max(max_power, next_c.get("max_power", 0)) ids_to_remove.append(next_c["id"]) # Derive merged end time from power data when available; otherwise fall back to # the end_time field of the last cycle (handles cycles without recorded power data). if merged_points_abs: # Use the maximum absolute timestamp from all collected data points last_t_abs = max(pt[0] for pt in merged_points_abs) final_end_dt = dt_util.utc_from_timestamp(last_t_abs) else: last_cycle = target_cycles[-1] fallback_end_dt = _parse_start_dt(last_cycle.get("end_time")) if fallback_end_dt is not None: final_end_dt = fallback_end_dt else: final_end_dt = c1_start_dt new_dur = (final_end_dt - c1_start_dt).total_seconds() c1["end_time"] = final_end_dt.isoformat() c1["duration"] = round(new_dur, 1) c1["max_power"] = max_power c1["profile_name"] = target_profile # Remove manual_duration override so the freshly computed duration is shown c1.pop("manual_duration", None) # Generate new compressed power_data [offset, power] new_power_data: list[list[float]] = [] c1_start_ts = c1_start_dt.timestamp() for t_abs, p in merged_points_abs: offset = round(t_abs - c1_start_ts, 1) new_power_data.append([offset, float(p)]) c1["power_data"] = new_power_data # New Hash ID new_id = hashlib.sha256(f"{c1['start_time']}_{c1['duration']}".encode()).hexdigest()[:12] old_c1_id = c1["id"] c1["id"] = new_id # Update references in profiles all_removed_ids = ids_to_remove + [old_c1_id] for p_data in self.get_profiles().values(): if p_data.get("sample_cycle_id") in all_removed_ids: p_data["sample_cycle_id"] = new_id # Remove consumed cycles self._data["past_cycles"] = [ c for c in cycles if c.get("id") not in ids_to_remove ] # Update signature try: ts_arr = np.array([pt[0] for pt in new_power_data], dtype=float) p_arr = np.array([pt[1] for pt in new_power_data], dtype=float) if len(ts_arr) > 1: sig = compute_signature(ts_arr, p_arr) c1["signature"] = dataclasses.asdict(sig) except Exception as e: # pylint: disable=broad-exception-caught self._logger.warning("Failed to update signature for merged cycle %s: %s", new_id, e) await self.async_save() self._logger.info("Interactive Merge Applied: %s -> %s", cycle_ids, new_id) # Rebuild envelopes for all affected profiles for p_name in affected_profiles: await self.async_rebuild_envelope(p_name) return new_id