Files
Home-Assistant/custom_components/ha_washdata/phase_match.py
T
2026-07-20 22:52:35 -04:00

321 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <https://www.gnu.org/licenses/>.
"""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))