Updated apps

This commit is contained in:
2026-07-20 22:52:35 -04:00
parent 28a8cb98f6
commit a0c3271743
1164 changed files with 94781 additions and 6892 deletions
@@ -0,0 +1,320 @@
# 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))