# 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 . """Phase-aware profile matching and phase-resolved ETA (Phase 0 prototype). Given a cycle segmented into phases (:mod:`phase_segmenter`), this module: * builds a per-profile :class:`PhaseProfile` (per-role duration/energy priors) from the profile's member cycles; * scores candidate profiles by **per-role** duration + energy agreement, so two profiles that differ mainly in heating length (i.e. temperature) are separated by a large per-phase signal instead of a small whole-cycle-correlation delta; * projects **time-remaining** as a per-role budget (Σ expected_role_total − consumed_role), which personalises the ETA to the matched variant. The matcher handles **partial** (observed-so-far) cycles for progressive narrowing: completed roles are compared fully; the open current role is scored one-sided (a candidate is only penalised if the observed duration already *exceeds* its expected total), so the correct larger-heating variant is not prematurely ruled out while heating is still in progress. Constraint: NumPy only, no Home Assistant imports. Pure and executor-safe; consumed live (behind the default-off ``enable_phase_matching`` per-device gate) by ``ProfileStore.phase_remaining`` and the progress blend. See `docs/superpowers/specs/2026-07-17-phase-segmented-matching-design.md`. """ from __future__ import annotations import math from dataclasses import asdict, dataclass from .phase_segmenter import ( ROLE_HEATING, ROLE_IDLE, ROLE_SPIN, ROLE_WASH, PhaseSegment, ) # Per-role weight in the match score. Heating dominates because it is the # temperature discriminator; the wash middle is load-variable; spin/idle are # weak/noisy signals. Overridable via the match config. _ROLE_WEIGHTS: dict[str, float] = { ROLE_HEATING: 0.50, ROLE_WASH: 0.25, ROLE_SPIN: 0.15, ROLE_IDLE: 0.10, } _DUR_SCALE = 0.35 # log-ratio agreement scale for per-role duration _EN_SCALE = 0.40 # ... and per-role energy # Agreement score credited when a role is present on one side but absent on the # other (structural mismatch, e.g. a heating vs no-heating cycle). 0.0 = full # penalty (structural difference is a strong, wanted discriminator); raise toward # 1.0 to soften. Config-overridable via ``occ_penalty``. _OCC_PENALTY = 0.0 @dataclass(frozen=True) class RoleStat: """Aggregated per-role prior across a profile's member cycles.""" dur_mean: float dur_std: float dur_p50: float en_mean: float occurrence: float # fraction of member cycles that contain this role @dataclass(frozen=True) class PhaseProfile: """Per-profile phase model: per-role priors + total-duration prior.""" name: str roles: dict[str, RoleStat] total_dur_mean: float total_dur_std: float n_cycles: int @dataclass(frozen=True) class PhaseMatchResult: name: str score: float def phase_profile_to_dict(profile: PhaseProfile) -> dict: """Serialize a :class:`PhaseProfile` for storage (JSON-safe).""" return { "name": profile.name, "n_cycles": profile.n_cycles, "total_dur_mean": profile.total_dur_mean, "total_dur_std": profile.total_dur_std, "roles": {role: asdict(stat) for role, stat in profile.roles.items()}, } def phase_profile_from_dict(data: dict | None) -> PhaseProfile | None: """Rebuild a :class:`PhaseProfile` from stored form. Never raises.""" if not isinstance(data, dict): return None try: roles = { str(role): RoleStat( dur_mean=float(s.get("dur_mean", 0.0)), dur_std=float(s.get("dur_std", 0.0)), dur_p50=float(s.get("dur_p50", 0.0)), en_mean=float(s.get("en_mean", 0.0)), occurrence=float(s.get("occurrence", 0.0)), ) for role, s in (data.get("roles") or {}).items() if isinstance(s, dict) } if not roles: return None return PhaseProfile( name=str(data.get("name", "")), roles=roles, total_dur_mean=float(data.get("total_dur_mean", 0.0)), total_dur_std=float(data.get("total_dur_std", 0.0)), n_cycles=int(data.get("n_cycles", 0)), ) except (TypeError, ValueError, AttributeError): return None def _agree(observed: float, expected: float, scale: float) -> float: """Log-ratio agreement in (0, 1]; 1.0 when equal, sharper for small scale.""" if observed <= 0.0 or expected <= 0.0: # Both ~zero → perfect agreement; one zero → no agreement. return 1.0 if observed <= 0.0 and expected <= 0.0 else 0.0 scale = scale if scale > 1e-9 else 1e-9 # avoid div-by-zero on a 0 config scale return 1.0 / (1.0 + abs(math.log(observed / expected)) / scale) def _role_totals(segments: list[PhaseSegment]) -> dict[str, dict[str, float]]: """Sum duration + energy per role across a cycle's segments.""" totals: dict[str, dict[str, float]] = {} for seg in segments: acc = totals.setdefault(seg.role, {"dur": 0.0, "en": 0.0}) acc["dur"] += max(0.0, seg.duration_s) acc["en"] += max(0.0, seg.energy_wh) return totals def build_phase_profile(name: str, segmented_cycles: list[list[PhaseSegment]]) -> PhaseProfile | None: """Aggregate a profile's member cycles into per-role priors. Never raises. Returns ``None`` when no usable cycles are supplied. """ cycles = [c for c in segmented_cycles if c] if not cycles: return None n = len(cycles) per_role_durs: dict[str, list[float]] = {} per_role_ens: dict[str, list[float]] = {} role_count: dict[str, int] = {} totals_dur: list[float] = [] for segs in cycles: totals = _role_totals(segs) totals_dur.append(sum(v["dur"] for v in totals.values())) for role, v in totals.items(): per_role_durs.setdefault(role, []).append(v["dur"]) per_role_ens.setdefault(role, []).append(v["en"]) role_count[role] = role_count.get(role, 0) + 1 def _mean(xs: list[float]) -> float: return float(sum(xs) / len(xs)) if xs else 0.0 def _std(xs: list[float], m: float) -> float: return float((sum((x - m) ** 2 for x in xs) / len(xs)) ** 0.5) if xs else 0.0 def _p50(xs: list[float]) -> float: if not xs: return 0.0 s = sorted(xs) mid = len(s) // 2 return float(s[mid] if len(s) % 2 else (s[mid - 1] + s[mid]) / 2.0) roles: dict[str, RoleStat] = {} for role, durs in per_role_durs.items(): m = _mean(durs) roles[role] = RoleStat( dur_mean=m, dur_std=_std(durs, m), dur_p50=_p50(durs), en_mean=_mean(per_role_ens[role]), occurrence=role_count[role] / n, ) tm = _mean(totals_dur) return PhaseProfile( name=name, roles=roles, total_dur_mean=tm, total_dur_std=_std(totals_dur, tm), n_cycles=n, ) def match_phase_profiles( observed: list[PhaseSegment], candidates: list[PhaseProfile], config: dict | None = None, ) -> list[PhaseMatchResult]: """Rank ``candidates`` for the ``observed`` (full or partial) cycle. Never raises. Score = weighted mean over roles of ``sqrt(dur_agree * energy_agree)``, with a structural (occurrence-mismatch) penalty. The observed cycle's *open* role (partial cycle) is scored one-sided so a larger-heating candidate is not ruled out mid-heating. """ if not observed or not candidates: return [] cfg = config or {} weights = {**_ROLE_WEIGHTS, **(cfg.get("role_weights") or {})} dur_scale = float(cfg.get("dur_scale", _DUR_SCALE)) en_scale = float(cfg.get("en_scale", _EN_SCALE)) occ_pen = float(cfg.get("occ_penalty", _OCC_PENALTY)) totals = _role_totals(observed) open_role = next((s.role for s in observed if s.open), None) is_partial = open_role is not None results: list[PhaseMatchResult] = [] for cand in candidates: all_roles = set(totals) | set(cand.roles) num = 0.0 den = 0.0 for role in all_roles: w = weights.get(role, 0.1) if w <= 0: continue obs = totals.get(role, {"dur": 0.0, "en": 0.0}) stat = cand.roles.get(role) if stat is None: # Observed a role the candidate never exhibits: structural miss. if obs["dur"] > 0: num += w * occ_pen den += w continue if role not in totals: # Candidate expects a role not yet observed. Future phase on a # partial cycle → neutral (skip). On a completed cycle → # structural miss (candidate does a phase this cycle never had). if not is_partial: num += w * occ_pen den += w continue if role == open_role: # One-sided: only penalise if the in-progress duration/energy # already EXCEEDS what this candidate expects for the whole role # (a larger-budget candidate must not be ruled out mid-phase). # Energy is the cleanest temperature discriminator, so score it # one-sided too rather than dropping it (weaker narrowing). da = 1.0 if obs["dur"] <= stat.dur_mean else _agree(obs["dur"], stat.dur_mean, dur_scale) ea = 1.0 if obs["en"] <= stat.en_mean else _agree(obs["en"], stat.en_mean, en_scale) agree = math.sqrt(da * ea) else: da = _agree(obs["dur"], stat.dur_mean, dur_scale) ea = _agree(obs["en"], stat.en_mean, en_scale) agree = math.sqrt(da * ea) num += w * agree den += w score = (num / den) if den > 0 else 0.0 results.append(PhaseMatchResult(name=cand.name, score=float(score))) results.sort(key=lambda r: r.score, reverse=True) return results def phase_eta( observed: list[PhaseSegment], profile: PhaseProfile, ) -> float | None: """Project remaining seconds as a per-role budget. Never raises. Classifies each profile role against the observed-so-far segments (design §8): * **current** (the open, in-progress role): contribute ``max(0, dur_mean − consumed)`` using the CONDITIONAL mean (the role is known present, so no occurrence discount); * **completed** (a role already observed but not the open one): contribute **0** — it is done, even if it ran shorter than the profile mean (fixing the "phantom remaining" over-count); * **future** (a profile role not yet observed): contribute the occurrence-weighted prior ``dur_mean × occurrence`` (we do not yet know whether it will occur). Returns ``None`` when no phase priors are available (caller falls back to the current estimator). """ if profile is None or not profile.roles: return None consumed = _role_totals(observed) if observed else {} # The open (in-progress) role. If the caller did not mark one (e.g. a # completed trace), treat the last observed role as current so completed # roles still contribute 0 rather than a spurious prior. open_role = next((s.role for s in (observed or []) if s.open), None) if open_role is None and observed: open_role = observed[-1].role remaining = 0.0 for role, stat in profile.roles.items(): done = consumed.get(role, {}).get("dur", 0.0) if role == open_role: # current: conditional mean (occurrence -> 1, role is present) remaining += max(0.0, stat.dur_mean - done) elif role in consumed: # completed: nothing more to spend on it continue else: # future: unconditional prior (may or may not occur) remaining += stat.dur_mean * max(0.0, min(1.0, stat.occurrence)) return float(max(0.0, remaining))