Added Alexa Music
This commit is contained in:
@@ -1,3 +1,19 @@
|
||||
# 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/>.
|
||||
"""Analysis module for heavy CPU tasks (offloaded to executor)."""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -6,13 +22,40 @@ from typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .const import (
|
||||
DEFAULT_DTW_MODE,
|
||||
MATCH_CORR_WEIGHT,
|
||||
MATCH_DDTW_DIST_SCALE,
|
||||
MATCH_DTW_BLEND,
|
||||
MATCH_DTW_DIST_SCALE,
|
||||
MATCH_DTW_ENSEMBLE_W,
|
||||
MATCH_DTW_REFINE_TOP_N,
|
||||
MATCH_DTW_RESAMPLE_N,
|
||||
MATCH_DURATION_SCALE,
|
||||
MATCH_DURATION_WEIGHT,
|
||||
MATCH_ENERGY_SCALE,
|
||||
MATCH_ENERGY_WEIGHT,
|
||||
MATCH_KEEP_MIN_SCORE,
|
||||
MATCH_MAE_PEAK_FLOOR,
|
||||
MATCH_MAE_REF_PEAK,
|
||||
MATCH_MAE_SCALE,
|
||||
)
|
||||
|
||||
|
||||
def _agreement(observed: float, expected: float, scale: float) -> float:
|
||||
"""1.0 when observed==expected, decaying with the |log-ratio| / scale."""
|
||||
if observed <= 0 or expected <= 0 or scale <= 0:
|
||||
return 0.0
|
||||
return 1.0 / (1.0 + abs(np.log(observed / expected)) / scale)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
ALIGNMENT_CONTEXT_BUFFER = 50
|
||||
|
||||
def find_best_alignment(
|
||||
current_power: list[float] | np.ndarray,
|
||||
sample_power: list[float] | np.ndarray,
|
||||
dt: float = 1.0 # pylint: disable=unused-argument
|
||||
dt: float = 1.0, # pylint: disable=unused-argument
|
||||
corr_weight: float = MATCH_CORR_WEIGHT,
|
||||
) -> tuple[float, dict[str, float], int]:
|
||||
"""Find Best Alignment using Coarse-to-Fine Search (CPU Bound)."""
|
||||
|
||||
@@ -103,18 +146,31 @@ def find_best_alignment(
|
||||
else:
|
||||
corr = 0.0
|
||||
|
||||
mae_score = 100.0 / (100.0 + mae)
|
||||
score = (0.6 * max(0, corr)) + (0.4 * mae_score)
|
||||
# Scale-invariant MAE: express the error relative to the current cycle's
|
||||
# peak (common to every candidate, so ranking is unaffected) and calibrate
|
||||
# to the legacy behaviour at MATCH_MAE_REF_PEAK. See const.py for rationale.
|
||||
current_peak = float(np.max(np.abs(curr))) if curr.size else 0.0
|
||||
scaled_mae = mae * MATCH_MAE_REF_PEAK / max(current_peak, MATCH_MAE_PEAK_FLOOR)
|
||||
mae_score = MATCH_MAE_SCALE / (MATCH_MAE_SCALE + scaled_mae)
|
||||
score = (corr_weight * max(0.0, corr)) + ((1.0 - corr_weight) * mae_score)
|
||||
|
||||
return float(score), {"mae": float(mae), "corr": float(corr)}, final_offset
|
||||
|
||||
def compute_dtw_lite(
|
||||
x: np.ndarray, y: np.ndarray, band_width_ratio: float = 0.1
|
||||
x: np.ndarray, y: np.ndarray, band_width_ratio: float = 0.1,
|
||||
derivative: bool = False,
|
||||
) -> float:
|
||||
"""
|
||||
Compute DTW distance with Sakoe-Chiba band constraint.
|
||||
Optimized 1D DP implementation. O(N*W).
|
||||
|
||||
When ``derivative`` is True this warps on the first derivative (slope) of the
|
||||
two curves (Derivative DTW): alignment is driven by shape/transitions rather
|
||||
than absolute power level, which is robust to amplitude offset and scale.
|
||||
"""
|
||||
if derivative:
|
||||
x = np.gradient(np.asarray(x, dtype=float)) if len(x) > 1 else np.asarray(x, dtype=float)
|
||||
y = np.gradient(np.asarray(y, dtype=float)) if len(y) > 1 else np.asarray(y, dtype=float)
|
||||
n, m = len(x), len(y)
|
||||
if n == 0 or m == 0:
|
||||
return float("inf")
|
||||
@@ -170,6 +226,41 @@ def compute_dtw_lite(
|
||||
|
||||
return float(prev_row[m])
|
||||
|
||||
def _resample_to(arr: np.ndarray, n: int) -> np.ndarray:
|
||||
"""Linearly resample a 1-D array to exactly ``n`` points over its index span.
|
||||
|
||||
Used to put the current cycle and a profile sample onto one common grid
|
||||
before DTW so the Sakoe-Chiba band width and the distance normalisation mean
|
||||
the same thing regardless of each series' native sampling cadence/length.
|
||||
"""
|
||||
a = np.asarray(arr, dtype=float)
|
||||
length = len(a)
|
||||
if length == 0:
|
||||
return np.zeros(n)
|
||||
if length == n:
|
||||
return a
|
||||
return np.interp(np.linspace(0.0, 1.0, n), np.linspace(0.0, 1.0, length), a)
|
||||
|
||||
|
||||
def _dtw_component_score(
|
||||
curr_arr: np.ndarray,
|
||||
sample_arr: np.ndarray,
|
||||
current_peak: float,
|
||||
band: float,
|
||||
derivative: bool,
|
||||
scale: float,
|
||||
) -> float:
|
||||
"""DTW similarity in [0,1] for one candidate: resample both series to a
|
||||
common grid, warp (level or derivative), and express the distance relative
|
||||
to the current peak (behaviour-neutral at MATCH_MAE_REF_PEAK)."""
|
||||
a = _resample_to(curr_arr, MATCH_DTW_RESAMPLE_N)
|
||||
b = _resample_to(sample_arr, MATCH_DTW_RESAMPLE_N)
|
||||
dtw_dist = compute_dtw_lite(a, b, band_width_ratio=band, derivative=derivative)
|
||||
norm_dist = dtw_dist / MATCH_DTW_RESAMPLE_N
|
||||
scaled = norm_dist * MATCH_MAE_REF_PEAK / max(current_peak, MATCH_MAE_PEAK_FLOOR)
|
||||
return scale / (scale + scaled)
|
||||
|
||||
|
||||
def compute_matches_worker(
|
||||
current_power: list[float],
|
||||
current_duration: float,
|
||||
@@ -182,6 +273,13 @@ def compute_matches_worker(
|
||||
min_duration_ratio = config.get("min_duration_ratio", 0.07)
|
||||
max_duration_ratio = config.get("max_duration_ratio", 1.3)
|
||||
dtw_bandwidth = config.get("dtw_bandwidth", 0.1)
|
||||
dtw_mode = config.get("dtw_mode", DEFAULT_DTW_MODE)
|
||||
keep_min = float(config.get("keep_min_score", MATCH_KEEP_MIN_SCORE))
|
||||
corr_weight = float(config.get("corr_weight", MATCH_CORR_WEIGHT))
|
||||
dur_weight = float(config.get("duration_weight", MATCH_DURATION_WEIGHT))
|
||||
en_weight = float(config.get("energy_weight", MATCH_ENERGY_WEIGHT))
|
||||
dur_scale = float(config.get("duration_scale", MATCH_DURATION_SCALE))
|
||||
en_scale = float(config.get("energy_scale", MATCH_ENERGY_SCALE))
|
||||
|
||||
curr_arr = np.array(current_power)
|
||||
|
||||
@@ -198,10 +296,10 @@ def compute_matches_worker(
|
||||
|
||||
# Core Similarity
|
||||
score, metrics, offset = find_best_alignment(
|
||||
current_power, sample_power, 1.0
|
||||
current_power, sample_power, 1.0, corr_weight=corr_weight
|
||||
)
|
||||
|
||||
if score > 0.1:
|
||||
if score > keep_min:
|
||||
candidates.append({
|
||||
"name": name,
|
||||
"score": score,
|
||||
@@ -214,33 +312,86 @@ def compute_matches_worker(
|
||||
|
||||
candidates.sort(key=lambda x: x["score"], reverse=True)
|
||||
|
||||
# Stage 3: DTW Refinement on Top 3
|
||||
# Stage 3: DTW Refinement on the top N candidates
|
||||
if dtw_bandwidth > 0.0 and len(candidates) > 0:
|
||||
to_refine = candidates[:3]
|
||||
# top-N, blend and the distance scales are config-overridable so the
|
||||
# tuning harness can sweep them without editing constants; production
|
||||
# uses the const defaults.
|
||||
top_n = int(config.get("dtw_refine_top_n", MATCH_DTW_REFINE_TOP_N))
|
||||
blend = float(config.get("dtw_blend", MATCH_DTW_BLEND))
|
||||
to_refine = candidates[:top_n]
|
||||
current_peak = float(np.max(curr_arr)) if curr_arr.size else 0.0
|
||||
l1_scale = float(config.get("dtw_l1_scale", MATCH_DTW_DIST_SCALE))
|
||||
ddtw_scale = float(config.get("dtw_ddtw_scale", MATCH_DDTW_DIST_SCALE))
|
||||
ensemble_w = float(config.get("dtw_ensemble_w", MATCH_DTW_ENSEMBLE_W))
|
||||
|
||||
for cand in to_refine:
|
||||
sample_arr = np.array(cand["sample"])
|
||||
|
||||
dtw_dist = compute_dtw_lite(
|
||||
curr_arr,
|
||||
sample_arr,
|
||||
band_width_ratio=dtw_bandwidth,
|
||||
)
|
||||
|
||||
n_points = len(curr_arr)
|
||||
if n_points > 0:
|
||||
norm_dist = dtw_dist / n_points
|
||||
if dtw_mode == "legacy":
|
||||
# Original behaviour: raw sequences, distance / len(current),
|
||||
# fixed absolute-watt scale (not peak-relative).
|
||||
dtw_dist = compute_dtw_lite(curr_arr, sample_arr, band_width_ratio=dtw_bandwidth)
|
||||
n_points = len(curr_arr)
|
||||
norm_dist = (dtw_dist / n_points) if n_points > 0 else 999.0
|
||||
dtw_score = 1.0 / (1.0 + norm_dist / MATCH_DTW_DIST_SCALE)
|
||||
elif dtw_mode == "ensemble":
|
||||
# Blend the level-based (L1) and shape-based (derivative) DTW
|
||||
# scores; they are complementary signals.
|
||||
s_l1 = _dtw_component_score(curr_arr, sample_arr, current_peak, dtw_bandwidth, False, l1_scale)
|
||||
s_dd = _dtw_component_score(curr_arr, sample_arr, current_peak, dtw_bandwidth, True, ddtw_scale)
|
||||
dtw_score = ensemble_w * s_l1 + (1.0 - ensemble_w) * s_dd
|
||||
norm_dist = 0.0 # composite; per-component distance not meaningful
|
||||
else:
|
||||
norm_dist = 999.0
|
||||
|
||||
dtw_score = 1.0 / (1.0 + norm_dist / 50.0)
|
||||
# "scaled" (default) or "ddtw": resample both onto one grid so the
|
||||
# band and normalisation are consistent, then express the distance
|
||||
# relative to the current peak (behaviour-neutral at
|
||||
# MATCH_MAE_REF_PEAK), mirroring the Stage-2 MAE treatment.
|
||||
use_deriv = dtw_mode == "ddtw"
|
||||
scale = ddtw_scale if use_deriv else l1_scale
|
||||
dtw_score = _dtw_component_score(
|
||||
curr_arr, sample_arr, current_peak, dtw_bandwidth, use_deriv, scale
|
||||
)
|
||||
norm_dist = 0.0
|
||||
|
||||
cand["original_score"] = float(cand["score"])
|
||||
cand["score"] = float(0.5 * cand["score"] + 0.5 * dtw_score)
|
||||
cand["score"] = float(blend * cand["score"] + (1.0 - blend) * dtw_score)
|
||||
cand["dtw_dist"] = float(norm_dist)
|
||||
|
||||
candidates.sort(key=lambda x: x["score"], reverse=True)
|
||||
|
||||
# Final pass: blend in duration + energy agreement. Shape correlation alone
|
||||
# cannot separate profiles that differ mainly in duration/energy (the main
|
||||
# multi-program washing-machine failure mode), so nudge the score toward
|
||||
# candidates whose expected duration/energy match the observed cycle.
|
||||
# Sanitize the configured weights so the blended score stays a convex
|
||||
# combination in [0, 1]: clamp negatives to 0 and, if duration+energy exceed
|
||||
# 1.0, scale them down proportionally (shape then contributes 0) rather than
|
||||
# letting shape_w go negative or the total exceed 1.
|
||||
# Drop non-finite configured weights (NaN/inf) so de_sum, the normalized
|
||||
# weights, and every candidate score stay finite.
|
||||
dur_w = max(0.0, dur_weight) if np.isfinite(dur_weight) else 0.0
|
||||
en_w = max(0.0, en_weight) if np.isfinite(en_weight) else 0.0
|
||||
de_sum = dur_w + en_w
|
||||
if de_sum > 1.0:
|
||||
dur_w, en_w = dur_w / de_sum, en_w / de_sum
|
||||
shape_w = max(0.0, 1.0 - dur_w - en_w)
|
||||
if (dur_w > 0 or en_w > 0) and candidates and current_duration > 0:
|
||||
cur_energy = float(np.mean(curr_arr)) # mean power (W) — no duration multiplication
|
||||
for cand in candidates:
|
||||
prof_dur = float(cand.get("profile_duration") or 0.0)
|
||||
dur_ag = _agreement(current_duration, prof_dur, dur_scale)
|
||||
sample = cand.get("sample") or []
|
||||
cand_energy = float(np.mean(sample)) if sample else 0.0
|
||||
en_ag = _agreement(cur_energy, cand_energy, en_scale)
|
||||
cand["shape_score"] = float(cand["score"])
|
||||
cand["score"] = float(
|
||||
shape_w * cand["score"]
|
||||
+ dur_w * dur_ag
|
||||
+ en_w * en_ag
|
||||
)
|
||||
candidates.sort(key=lambda x: x["score"], reverse=True)
|
||||
|
||||
return candidates
|
||||
|
||||
def compute_dtw_path(
|
||||
@@ -308,7 +459,8 @@ def compute_dtw_path(
|
||||
|
||||
def compute_envelope_worker(
|
||||
raw_cycles_data: list[tuple[list[float], list[float], Optional[float]]] | list[tuple[list[float], list[float]]],
|
||||
dtw_bandwidth: float
|
||||
dtw_bandwidth: float,
|
||||
reference_mask: list[bool] | None = None,
|
||||
) -> tuple[list[float], list[float], list[float], list[float], list[float], float] | None:
|
||||
"""
|
||||
Compute statistical envelope.
|
||||
@@ -316,16 +468,22 @@ def compute_envelope_worker(
|
||||
raw_cycles_data: list of (offsets, power_values, duration) tuples.
|
||||
Duration may be None and is used to compute target_duration.
|
||||
dtw_bandwidth: ratio.
|
||||
reference_mask: optional per-cycle flags (parallel to raw_cycles_data).
|
||||
When any entry is True, the robust reference curve is built from the
|
||||
median of the flagged cycles only (e.g. user-verified "golden"
|
||||
cycles), so trusted cycles define the shape every other cycle is
|
||||
warped onto. Min/max/avg/std bands are still built from all cycles.
|
||||
Returns:
|
||||
(time_grid, min_curve, max_curve, avg_curve, std_curve, target_duration) or None.
|
||||
"""
|
||||
if not raw_cycles_data:
|
||||
return None
|
||||
normalized_curves: list[tuple[np.ndarray, np.ndarray, float]] = []
|
||||
golden_flags: list[bool] = []
|
||||
sampling_rates: list[float] = []
|
||||
|
||||
# 1. Pre-process input
|
||||
for curve in raw_cycles_data:
|
||||
for idx, curve in enumerate(raw_cycles_data):
|
||||
# Unpack curve tuple: (offsets, values) or (offsets, values, duration)
|
||||
# Backward compatible with 2-tuple (offsets, values) format
|
||||
try:
|
||||
@@ -373,6 +531,7 @@ def compute_envelope_worker(
|
||||
continue
|
||||
|
||||
normalized_curves.append((offsets, values, dur))
|
||||
golden_flags.append(bool(reference_mask[idx]) if reference_mask and idx < len(reference_mask) else False)
|
||||
|
||||
if len(offsets) > 1:
|
||||
intervals = np.diff(offsets)
|
||||
@@ -384,8 +543,8 @@ def compute_envelope_worker(
|
||||
if not normalized_curves:
|
||||
return None
|
||||
|
||||
# 2. Reference Selection (Median Duration)
|
||||
# Input is now (offsets, values, duration)
|
||||
# 2. Reference Selection
|
||||
# The grid is sized from the median duration. Input is (offsets, values, duration).
|
||||
max_times = [float(dur) for _, _, dur in normalized_curves]
|
||||
median_dur = float(np.median(max_times))
|
||||
ref_idx = int(np.argmin([abs(t - median_dur) for t in max_times]))
|
||||
@@ -401,16 +560,35 @@ def compute_envelope_worker(
|
||||
num_points = max(50, int(target_duration / align_dt))
|
||||
time_grid = np.linspace(0.0, target_duration, num_points)
|
||||
|
||||
ref_offsets, ref_values, _ = normalized_curves[ref_idx]
|
||||
ref_array = np.interp(time_grid, ref_offsets, ref_values)
|
||||
# Robust reference curve: the pointwise MEDIAN across all cycles resampled
|
||||
# onto the shared grid - a synthetic "medoid" that is not distorted by a
|
||||
# single atypical cycle near the median duration and handles multi-mode
|
||||
# profiles far better than picking one representative curve. Falls back to
|
||||
# the single closest-to-median cycle when there are too few cycles for a
|
||||
# stable median.
|
||||
golden_indices = [i for i, g in enumerate(golden_flags) if g]
|
||||
if golden_indices:
|
||||
# Trusted "golden" cycles define the reference shape.
|
||||
grid_curves = np.array(
|
||||
[
|
||||
np.interp(time_grid, normalized_curves[i][0], normalized_curves[i][1])
|
||||
for i in golden_indices
|
||||
]
|
||||
)
|
||||
ref_array = np.median(grid_curves, axis=0)
|
||||
elif len(normalized_curves) >= 3:
|
||||
grid_curves = np.array(
|
||||
[np.interp(time_grid, offs, vals) for offs, vals, _ in normalized_curves]
|
||||
)
|
||||
ref_array = np.median(grid_curves, axis=0)
|
||||
else:
|
||||
ref_offsets, ref_values, _ = normalized_curves[ref_idx]
|
||||
ref_array = np.interp(time_grid, ref_offsets, ref_values)
|
||||
|
||||
# 3. Resample & DTW
|
||||
# 3. Resample & DTW: warp every cycle onto the robust reference.
|
||||
resampled: list[np.ndarray] = []
|
||||
|
||||
for i, (offsets, values, dur) in enumerate(normalized_curves):
|
||||
if i == ref_idx:
|
||||
resampled.append(ref_array)
|
||||
continue
|
||||
for offsets, values, dur in normalized_curves:
|
||||
this_dur = dur
|
||||
this_num_points = max(10, int(this_dur / align_dt))
|
||||
this_grid = np.linspace(0.0, this_dur, this_num_points)
|
||||
|
||||
Reference in New Issue
Block a user