318 lines
12 KiB
Python
318 lines
12 KiB
Python
# 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/>.
|
|
"""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
|