# 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 . """Unsupervised power-trace phase segmentation (Phase 0 prototype). Turns a cycle's power trace into an ordered list of :class:`PhaseSegment` (role, start/end offset, duration, energy, mean/peak power) via a hysteresis regime classifier (idle / active / high) + minimum-run merge + role assignment driven by a per-device-type :class:`PhaseModel`. Constraint: NumPy only, no Home Assistant imports. Pure and executor-safe; ``segment_cycle`` never raises - it returns ``[]`` on malformed input. Consumed live (behind the default-off ``enable_phase_matching`` per-device gate) by `profile_store.ProfileStore` (phase-profile cache + `phase_remaining`) and, offline, by the side-by-side harness (`devtools/eta_phase_eval.py`). See `docs/superpowers/specs/2026-07-17-phase-segmented-matching-design.md`. """ from __future__ import annotations from dataclasses import dataclass, field from typing import Mapping, Optional import numpy as np from .const import CONF_ENABLE_PHASE_MATCHING from .signal_processing import energy_gap_threshold_s, integrate_wh # Regime tokens (internal, not user-facing). _IDLE = 0 _ACTIVE = 1 _HIGH = 2 # Role tokens. Physically detectable roles; mapping to display phase names # (phase_catalog.DEFAULT_PHASES_BY_DEVICE) is a later (Phase 6) concern. ROLE_HEATING = "heating" ROLE_WASH = "wash" ROLE_SPIN = "spin" ROLE_IDLE = "idle" @dataclass(frozen=True) class PhaseSegment: """One contiguous phase of a cycle.""" role: str t_start: float # seconds from cycle start t_end: float duration_s: float energy_wh: float mean_w: float peak_w: float open: bool = False # True when this is the final segment of a partial cycle @dataclass(frozen=True) class PhaseModel: """Per-device-type segmentation parameters and role vocabulary. ``high`` threshold = ``max(high_w_floor, high_frac * robust_peak)`` where the robust peak is the 95th percentile of the trace, so a heating element is detected relative to the device's own scale without a single spike inflating it. ``active_w`` separates motor/fill activity from idle. A terminal ``active`` block whose mean power >= ``spin_min_w`` and which begins in the last ``spin_tail_frac`` of the (completed) cycle is labelled ``spin``. """ device_type: str high_w_floor: float high_frac: float active_w: float spin_min_w: float spin_tail_frac: float min_run_s: float roles: tuple[str, ...] = field(default=(ROLE_HEATING, ROLE_WASH, ROLE_SPIN, ROLE_IDLE)) # Only washing_machine is intended for live rollout first; dishwasher and # washer-dryer models exist for OFFLINE evaluation in the Phase-0 harness. _MODELS: dict[str, PhaseModel] = { "washing_machine": PhaseModel( device_type="washing_machine", high_w_floor=800.0, high_frac=0.5, active_w=30.0, spin_min_w=200.0, spin_tail_frac=0.30, min_run_s=90.0, ), "washer_dryer": PhaseModel( device_type="washer_dryer", high_w_floor=800.0, high_frac=0.5, active_w=30.0, spin_min_w=200.0, spin_tail_frac=0.30, min_run_s=90.0, ), # Dishwasher: heating element is the dominant HIGH regime; the long passive # dry tail lands in IDLE. Deferred for LIVE rollout (delicate end-of-cycle # logic) - present here only for offline measurement. "dishwasher": PhaseModel( device_type="dishwasher", high_w_floor=800.0, high_frac=0.5, active_w=25.0, spin_min_w=250.0, spin_tail_frac=0.25, min_run_s=120.0, ), } # Device types phase matching is rolled out for LIVE (validated by the Phase-0 # ETA gate). Dishwasher has a model for OFFLINE evaluation only (its ETA gate was # a no-go and its end-of-cycle logic is delicate), so it is deliberately excluded # here even though ``phase_model_for("dishwasher")`` returns a model. LIVE_PHASE_DEVICE_TYPES: tuple[str, ...] = ("washing_machine", "washer_dryer") def phase_model_for(device_type: str | None) -> PhaseModel | None: """Return the :class:`PhaseModel` for a device type, or ``None`` to fall back. ``None`` means the device type has no validated phase model and must use the existing whole-cycle pipeline (zero-regression fallback). Returns a model for every type with one defined (incl. dishwasher, for the offline harness). """ if not device_type: return None return _MODELS.get(str(device_type)) def phase_matching_live_supported(device_type: str | None) -> bool: """True when phase matching is enabled for LIVE use on this device type. Stricter than ``phase_model_for`` - only the Phase-0-validated rollout types (washing machine, washer-dryer). Used to gate phase-profile caching and the live ETA path; the offline harness uses ``phase_model_for`` directly. """ return ( device_type in LIVE_PHASE_DEVICE_TYPES and phase_model_for(device_type) is not None ) def phase_matching_enabled(options: "Mapping[str, object] | None", device_type: str | None) -> bool: """True when the user opted in AND the device type is live-supported. The opt-in gate for the live phase-resolved ETA blend, mirroring ``ml.engine.ml_models_enabled``. Both conditions are required: the per-device ``enable_phase_matching`` option and a validated live phase model. """ if not options: return False if not bool(options.get(CONF_ENABLE_PHASE_MATCHING, False)): return False return phase_matching_live_supported(device_type) def _classify(power: np.ndarray, model: PhaseModel) -> tuple[np.ndarray, float]: """Per-sample regime tokens + the robust peak used for the HIGH threshold.""" robust_peak = float(np.percentile(power, 95)) if power.size else 0.0 high_thr = max(model.high_w_floor, model.high_frac * robust_peak) reg = np.full(power.shape, _IDLE, dtype=int) reg[power >= model.active_w] = _ACTIVE reg[power >= high_thr] = _HIGH return reg, robust_peak def _runs(reg: np.ndarray) -> list[list[int]]: """Contiguous same-regime runs as ``[regime, start_idx, end_idx]`` (inclusive). Replaces the nested while-loop (O(n) Python iterations) with np.diff + np.flatnonzero (C-level), then a short list comprehension over only the run-boundary indices (~10-30 for a real cycle vs 1 400+ element visits). """ if len(reg) == 0: return [] changes = np.flatnonzero(np.diff(reg)) # indices where value changes starts = np.empty(len(changes) + 1, dtype=np.intp) ends = np.empty(len(changes) + 1, dtype=np.intp) starts[0] = 0 starts[1:] = changes + 1 ends[:-1] = changes ends[-1] = len(reg) - 1 return [[int(reg[s]), int(s), int(e)] for s, e in zip(starts, ends)] def _merge_short(runs: list[list[int]], t: np.ndarray, min_run_s: float) -> list[list[int]]: """Absorb runs shorter than ``min_run_s`` into their neighbour. A short leading run merges forward; every other short run merges into the preceding (already-accepted) run. This prevents brief motor spikes during tumbling from fragmenting a wash phase, and brief dips from splitting a heating block, without erasing genuine phases. """ if not runs: return runs merged: list[list[int]] = [] for reg, a, b in runs: dur = float(t[b] - t[a]) if merged and dur < min_run_s: merged[-1][2] = b # extend previous run to absorb this short one else: merged.append([reg, a, b]) # A short LEADING run has no preceding run to absorb it, so it would survive # as its own segment (e.g. a startup/inrush HIGH spike becoming a phantom # heating block that pollutes the temperature prior). Fold it FORWARD into # the following run, adopting that run's regime. while len(merged) > 1 and float(t[merged[0][2]] - t[merged[0][1]]) < min_run_s: merged[1][1] = merged[0][1] # extend 2nd run's start back over the lead merged.pop(0) return merged def segment_cycle( timestamps: "np.ndarray | list[float]", power: "np.ndarray | list[float]", model: PhaseModel, *, partial: bool = False, ) -> list[PhaseSegment]: """Segment a power trace into ordered phases. Never raises. Args: timestamps: seconds from cycle start (ascending). power: watts, same length as ``timestamps``. model: the device-type :class:`PhaseModel`. partial: when True the trace is an observed-so-far prefix of a running cycle; the final segment is marked ``open=True``. Returns: List of :class:`PhaseSegment` in time order, or ``[]`` when the input is too short/degenerate to segment. Any internal error also yields ``[]``. """ try: return _segment_impl(timestamps, power, model, partial=partial) except Exception: # noqa: BLE001 - segmentation must never break a live cycle return [] def _segment_impl( timestamps: "np.ndarray | list[float]", power: "np.ndarray | list[float]", model: PhaseModel, *, partial: bool = False, ) -> list[PhaseSegment]: t = np.asarray(timestamps, dtype=float) w = np.asarray(power, dtype=float) if t.size < 4 or w.size != t.size: return [] if not (np.all(np.isfinite(t)) and np.all(np.isfinite(w))): finite = np.isfinite(t) & np.isfinite(w) t, w = t[finite], w[finite] if t.size < 4: return [] # Enforce ascending time. order = np.argsort(t, kind="stable") t, w = t[order], w[order] reg, _peak = _classify(w, model) runs = _merge_short(_runs(reg), t, model.min_run_s) if not runs: return [] gap_s = energy_gap_threshold_s(t) total = float(t[-1] - t[0]) spin_zone_start = t[0] + (1.0 - model.spin_tail_frac) * total if total > 0 else t[-1] # First pass: build raw segments with stats. raw: list[dict] = [] for reg_tok, a, b in runs: seg_t = t[a:b + 1] seg_w = w[a:b + 1] dur = float(seg_t[-1] - seg_t[0]) if seg_t.size > 1 else 0.0 energy = integrate_wh(seg_t, seg_w, max_gap_s=gap_s) if seg_t.size > 1 else 0.0 raw.append({ "reg": reg_tok, "a": a, "b": b, "t0": float(seg_t[0]), "t1": float(seg_t[-1]), "dur": dur, "energy": float(energy), "mean": float(np.mean(seg_w)), "peak": float(np.max(seg_w)), }) # Determine which ACTIVE run (if any) is the spin: the last non-idle run, # elevated and starting in the terminal zone. Only on a completed cycle - # a partial cycle can't know its terminal segment yet. spin_idx: Optional[int] = None if not partial: for idx in range(len(raw) - 1, -1, -1): seg = raw[idx] if seg["reg"] == _IDLE: continue if (seg["reg"] == _ACTIVE and seg["mean"] >= model.spin_min_w and seg["t0"] >= spin_zone_start): spin_idx = idx break # only inspect the last non-idle run out: list[PhaseSegment] = [] for idx, seg in enumerate(raw): if seg["reg"] == _HIGH: role = ROLE_HEATING elif seg["reg"] == _IDLE: role = ROLE_IDLE else: # _ACTIVE role = ROLE_SPIN if idx == spin_idx else ROLE_WASH out.append(PhaseSegment( role=role, t_start=seg["t0"], t_end=seg["t1"], duration_s=seg["dur"], energy_wh=seg["energy"], mean_w=seg["mean"], peak_w=seg["peak"], open=(partial and idx == len(raw) - 1), )) return out