Added Alexa Music
This commit is contained in:
@@ -0,0 +1,807 @@
|
||||
# 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/>.
|
||||
"""NumPy-only runtime feature extraction for the embedded ML models.
|
||||
|
||||
This is the bridge between live cycle data and the embedded models: it computes
|
||||
the exact ``FEATURE_COLUMNS`` each model expects, ported faithfully from the
|
||||
``ml_washdata`` lab feature definitions (see each ``*_feature_contract.json``).
|
||||
|
||||
Three feature extractors are implemented:
|
||||
|
||||
- **Cycle-end detector** (``END_FEATURE_COLUMNS``): self-contained from a live
|
||||
power series + profile expectation; call ``latest_end_event_features``.
|
||||
- **Live-match commit confidence** (``LIVE_MATCH_FEATURE_COLUMNS``): requires the
|
||||
match ranking from ``ProfileStore`` plus the observed prefix; call
|
||||
``live_match_features``.
|
||||
- **Hybrid cycle quality** (``QUALITY_FEATURE_COLUMNS``): requires the complete
|
||||
cycle power trace plus profile/match context; call ``quality_features``.
|
||||
|
||||
All inputs are plain Python/NumPy (offset-seconds, watts), so this module has no
|
||||
Home Assistant dependency and is unit-tested directly. It is only invoked when
|
||||
the user opts into experimental ML models (see engine.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cycle-end detector
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Mirrors ml_washdata/wash_ml/end_detection.py. The test suite asserts this list
|
||||
# equals the embedded model's FEATURE_COLUMNS so the two cannot drift.
|
||||
END_FEATURE_COLUMNS = [
|
||||
"elapsed_fraction",
|
||||
"energy_fraction",
|
||||
"energy_remaining_expected",
|
||||
"power_before_ratio",
|
||||
"drop_ratio",
|
||||
"peak_seen_ratio",
|
||||
"low_run_s_log",
|
||||
"elapsed_log",
|
||||
]
|
||||
|
||||
MIN_LOW_RUN_S = 45.0
|
||||
|
||||
Point = tuple[float, float]
|
||||
|
||||
|
||||
def cumulative_energy_wh(points: Sequence[Point]) -> np.ndarray:
|
||||
"""Trapezoidal cumulative energy (Wh) aligned to each reading, with gap handling.
|
||||
|
||||
Segments spanning sensor-outage gaps (larger than ``energy_gap_threshold_s``)
|
||||
are zeroed out so energy does not inflate across outages, matching the behaviour
|
||||
of ``signal_processing.integrate_wh`` used for stored ``energy_wh`` fields.
|
||||
"""
|
||||
from ..signal_processing import energy_gap_threshold_s # noqa: PLC0415
|
||||
offsets = np.asarray([float(offset) for offset, _power in points], dtype=float)
|
||||
powers = np.asarray([max(0.0, float(power)) for _offset, power in points], dtype=float)
|
||||
if offsets.size < 2:
|
||||
return np.zeros(offsets.size, dtype=float)
|
||||
max_gap = energy_gap_threshold_s(offsets)
|
||||
deltas = np.diff(offsets)
|
||||
segment = (powers[:-1] + powers[1:]) / 2.0 * deltas / 3600.0
|
||||
# Zero out segments that span a sensor outage gap to match integrate_wh behaviour.
|
||||
segment[deltas > max_gap] = 0.0
|
||||
return np.concatenate([[0.0], np.cumsum(segment)])
|
||||
|
||||
|
||||
def profile_expectation(cycles_points: Sequence[Sequence[Point]]) -> dict[str, float] | None:
|
||||
"""Median expected duration (s), energy (Wh) and peak (W) over a profile's cycles."""
|
||||
durations: list[float] = []
|
||||
energies: list[float] = []
|
||||
peaks: list[float] = []
|
||||
for points in cycles_points:
|
||||
if len(points) < 2:
|
||||
continue
|
||||
offsets = [float(offset) for offset, _power in points]
|
||||
duration = offsets[-1] - offsets[0]
|
||||
if duration <= 0:
|
||||
continue
|
||||
durations.append(duration)
|
||||
energies.append(float(cumulative_energy_wh(points)[-1]))
|
||||
peaks.append(max((float(power) for _offset, power in points), default=0.0))
|
||||
if not durations:
|
||||
return None
|
||||
return {
|
||||
"duration": float(np.median(durations)),
|
||||
"energy": float(np.median(energies)),
|
||||
"peak": float(np.median(peaks)),
|
||||
}
|
||||
|
||||
|
||||
def profile_expectations(cycles: list[dict]) -> dict[str, dict[str, float]]:
|
||||
"""Median duration/energy/peak per profile from stored cycle dicts.
|
||||
|
||||
The dict-based counterpart of :func:`profile_expectation` (which works from
|
||||
decompressed traces): reads the ``duration``/``energy_wh``/``max_power``
|
||||
scalar fields already stored on each cycle. Shared by on-device training
|
||||
(``training_task``) and the ML suggestion engine so the "profile expectation"
|
||||
definition lives in one place. Profiles with no usable duration are skipped;
|
||||
missing energy/peak default to 500.
|
||||
"""
|
||||
stats: dict[str, dict[str, list[float]]] = {}
|
||||
for c in cycles:
|
||||
name = c.get("profile_name")
|
||||
if not isinstance(name, str) or not name:
|
||||
continue
|
||||
s = stats.setdefault(name, {"d": [], "e": [], "p": []})
|
||||
for key, field in (("d", "duration"), ("e", "energy_wh"), ("p", "max_power")):
|
||||
v = c.get(field)
|
||||
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
||||
s[key].append(float(v))
|
||||
out: dict[str, dict[str, float]] = {}
|
||||
for name, s in stats.items():
|
||||
if not s["d"]:
|
||||
continue
|
||||
out[name] = {
|
||||
"duration": float(np.median(s["d"])),
|
||||
"energy": float(np.median(s["e"])) if s["e"] else 500.0,
|
||||
"peak": float(np.median(s["p"])) if s["p"] else 500.0,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def latest_end_event_features(
|
||||
points: Sequence[Point],
|
||||
expectation: dict[str, float],
|
||||
*,
|
||||
min_low_run_s: float = MIN_LOW_RUN_S,
|
||||
) -> dict[str, float] | None:
|
||||
"""Features for the most recent low-power run (the "is this the end?" moment).
|
||||
|
||||
Returns ``None`` when there is no qualifying low-power run yet (the cycle is
|
||||
still clearly active), in which case the caller keeps the existing behavior.
|
||||
Mirrors ``end_detection._cycle_events`` for a single (latest) event.
|
||||
"""
|
||||
if len(points) < 4 or not expectation:
|
||||
return None
|
||||
offsets = np.asarray([float(offset) for offset, _power in points], dtype=float)
|
||||
powers = np.asarray([max(0.0, float(power)) for _offset, power in points], dtype=float)
|
||||
start = float(offsets[0])
|
||||
peak = max(float(expectation.get("peak") or 0.0), float(np.max(powers)), 1.0)
|
||||
low_threshold = max(5.0, 0.02 * peak)
|
||||
profile_duration = max(float(expectation.get("duration") or 0.0), 1.0)
|
||||
profile_energy = max(float(expectation.get("energy") or 0.0), 1e-6)
|
||||
cumulative = cumulative_energy_wh(points)
|
||||
|
||||
# Find the most recent contiguous low-power run.
|
||||
count = len(points)
|
||||
run_start: int | None = None
|
||||
index = count - 1
|
||||
while index >= 0 and powers[index] < low_threshold:
|
||||
run_start = index
|
||||
index -= 1
|
||||
if run_start is None:
|
||||
return None
|
||||
run_end = count - 1
|
||||
run_duration = float(offsets[run_end] - offsets[run_start])
|
||||
if run_duration < min_low_run_s:
|
||||
return None
|
||||
|
||||
event_time = float(offsets[run_start] - start)
|
||||
elapsed = max(event_time, 1.0)
|
||||
energy_so_far = float(cumulative[run_start])
|
||||
window = powers[max(0, run_start - 4):run_start]
|
||||
power_before = float(np.mean(window)) if window.size else float(powers[run_start])
|
||||
running_peak = float(np.max(powers[: run_start + 1]))
|
||||
return {
|
||||
"elapsed_fraction": float(min(elapsed / profile_duration, 2.0)),
|
||||
"energy_fraction": float(min(energy_so_far / profile_energy, 2.0)),
|
||||
"energy_remaining_expected": float(max(0.0, 1.0 - energy_so_far / profile_energy)),
|
||||
"power_before_ratio": float(min(power_before / peak, 2.0)),
|
||||
"drop_ratio": float(np.clip((power_before - float(powers[run_start])) / peak, 0.0, 1.0)),
|
||||
"peak_seen_ratio": float(min(running_peak / peak, 2.0)),
|
||||
"low_run_s_log": float(math.log1p(max(0.0, run_duration))),
|
||||
"elapsed_log": float(math.log1p(elapsed)),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Live-match commit confidence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Mirrors ml_washdata/wash_ml/live_matching.py COMMIT_FEATURE_COLUMNS.
|
||||
# The test suite asserts this equals the embedded model's FEATURE_COLUMNS.
|
||||
LIVE_MATCH_FEATURE_COLUMNS = [
|
||||
"match_progress_top1",
|
||||
"top1_distance",
|
||||
"margin",
|
||||
"distance_ratio",
|
||||
"candidate_count_log",
|
||||
"prefix_active_fraction",
|
||||
"duration_ratio_top1",
|
||||
"elapsed_log",
|
||||
]
|
||||
|
||||
|
||||
def live_match_features(
|
||||
points: Sequence[Point],
|
||||
elapsed_s: float,
|
||||
top1_distance: float,
|
||||
top2_distance: float | None,
|
||||
top1_median_duration_s: float,
|
||||
candidate_count: int,
|
||||
) -> dict[str, float]:
|
||||
"""Features for the live-match commit-confidence model.
|
||||
|
||||
Args:
|
||||
points: Observed power readings (offset_s, watts) for the current prefix.
|
||||
elapsed_s: Seconds elapsed since cycle start.
|
||||
top1_distance: Blended RMSE+DTW shape distance to the top-1 candidate
|
||||
prefix (as returned by the profile matcher).
|
||||
top2_distance: Distance to the top-2 candidate; pass ``None`` or ``0.0``
|
||||
when only one candidate is available (margin defaults to 1.0).
|
||||
top1_median_duration_s: Expected (median) duration of the top-1 candidate
|
||||
profile in seconds.
|
||||
candidate_count: Number of candidate profiles on this device.
|
||||
|
||||
Returns a dict with exactly ``LIVE_MATCH_FEATURE_COLUMNS`` keys.
|
||||
"""
|
||||
elapsed = max(0.0, float(elapsed_s))
|
||||
top1 = max(0.0, float(top1_distance))
|
||||
top2_raw = float(top2_distance) if top2_distance is not None else 0.0
|
||||
top2 = top2_raw if top2_raw > 1e-9 else top1 + 1.0
|
||||
margin = max(0.0, top2 - top1)
|
||||
dur = float(top1_median_duration_s)
|
||||
progress = (elapsed / dur) if dur > 0 else 1.0
|
||||
|
||||
# prefix_active_fraction: fraction of prefix readings clearly above idle.
|
||||
# Lab uses > 0.05 on a peak-normalised trace; equivalent here is > 5% of
|
||||
# peak, with a 1 W floor so a cold trace never divides by near-zero.
|
||||
if points:
|
||||
powers = np.asarray([max(0.0, float(p)) for _, p in points], dtype=float)
|
||||
peak = float(np.max(powers)) if powers.size else 0.0
|
||||
active_thr = max(1.0, 0.05 * peak)
|
||||
prefix_active_fraction = float(np.mean(powers > active_thr)) if powers.size else 0.0
|
||||
else:
|
||||
prefix_active_fraction = 0.0
|
||||
|
||||
return {
|
||||
"match_progress_top1": float(min(progress, 2.0)),
|
||||
"top1_distance": float(top1),
|
||||
"margin": float(margin),
|
||||
"distance_ratio": float(top1 / top2) if top2 > 1e-9 else 1.0,
|
||||
"candidate_count_log": float(math.log1p(max(0, int(candidate_count)))),
|
||||
"prefix_active_fraction": float(prefix_active_fraction),
|
||||
"duration_ratio_top1": float(min(progress, 2.0)),
|
||||
"elapsed_log": float(math.log1p(elapsed)),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Remaining-time / progress regressor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Feature columns for the on-device remaining-time regressor. Unlike the three
|
||||
# classifier heads this model is a ``standardized_linear`` regressor whose target
|
||||
# is the cycle completion fraction (elapsed / total_actual). There is no shipped
|
||||
# baseline: the model exists only once on-device training promotes one over the
|
||||
# naive elapsed/expected estimate (``elapsed_over_expected`` is deliberately the
|
||||
# first column so the naive baseline is trivially recoverable). The same
|
||||
# extractor runs at training time on synthesized prefixes and at inference on the
|
||||
# live trace, so the columns cannot drift.
|
||||
PROGRESS_FEATURE_COLUMNS = [
|
||||
"elapsed_over_expected",
|
||||
"energy_over_expected",
|
||||
"mean_power_over_peak",
|
||||
"recent_power_over_peak",
|
||||
"tail_slope_norm",
|
||||
"active_fraction",
|
||||
"elapsed_log",
|
||||
]
|
||||
|
||||
|
||||
def progress_features(
|
||||
points: Sequence[Point],
|
||||
expectation: dict[str, float],
|
||||
) -> dict[str, float] | None:
|
||||
"""Features for the remaining-time regressor from a running-cycle prefix.
|
||||
|
||||
Args:
|
||||
points: Observed prefix power readings (offset_s, watts).
|
||||
expectation: Matched profile's median ``duration``/``energy``/``peak``
|
||||
(as produced by :func:`profile_expectation`).
|
||||
|
||||
Returns a dict with exactly ``PROGRESS_FEATURE_COLUMNS`` keys, or ``None``
|
||||
when there is too little data to characterise progress.
|
||||
"""
|
||||
pts = _clean_points(points)
|
||||
if len(pts) < 4 or not expectation:
|
||||
return None
|
||||
offsets = np.asarray([o for o, _ in pts], dtype=float)
|
||||
powers = np.asarray([p for _, p in pts], dtype=float)
|
||||
elapsed = max(float(offsets[-1] - offsets[0]), 1.0)
|
||||
exp_dur = max(float(expectation.get("duration") or 0.0), 1.0)
|
||||
exp_energy = max(float(expectation.get("energy") or 0.0), 1e-6)
|
||||
exp_peak = max(float(expectation.get("peak") or 0.0), 1.0)
|
||||
|
||||
energy_so_far = float(cumulative_energy_wh(pts)[-1])
|
||||
active_thr = max(1.0, 0.05 * exp_peak)
|
||||
active_mask = powers > active_thr
|
||||
active = powers[active_mask]
|
||||
mean_power = float(np.mean(active)) if active.size else 0.0
|
||||
# Recent power: mean of the trailing ~5% of samples (min one sample).
|
||||
tail_n = max(1, len(pts) // 20)
|
||||
recent_power = float(np.mean(powers[-tail_n:]))
|
||||
# Tail slope over the last quarter (W per sample), normalised by peak: a
|
||||
# declining tail is a strong "near the end" signal.
|
||||
quarter = max(2, len(pts) // 4)
|
||||
tail = powers[-quarter:]
|
||||
if tail.size >= 2:
|
||||
x = np.arange(tail.size, dtype=float)
|
||||
xm = x - float(np.mean(x))
|
||||
denom = float(np.dot(xm, xm))
|
||||
slope = float(np.dot(xm, tail - float(np.mean(tail))) / denom) if denom > 1e-9 else 0.0
|
||||
else:
|
||||
slope = 0.0
|
||||
|
||||
return {
|
||||
"elapsed_over_expected": float(min(elapsed / exp_dur, 3.0)),
|
||||
"energy_over_expected": float(min(energy_so_far / exp_energy, 3.0)),
|
||||
"mean_power_over_peak": float(min(mean_power / exp_peak, 2.0)),
|
||||
"recent_power_over_peak": float(min(recent_power / exp_peak, 2.0)),
|
||||
"tail_slope_norm": float(np.clip(slope / exp_peak, -2.0, 2.0)),
|
||||
"active_fraction": float(np.mean(active_mask)) if powers.size else 0.0,
|
||||
"elapsed_log": float(math.log1p(elapsed)),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hybrid cycle quality
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Mirrors ml_washdata/wash_ml/hybrid_curve_quality.py HYBRID_FEATURE_COLUMNS.
|
||||
# Order must match the embedded model exactly; the test suite asserts this.
|
||||
QUALITY_FEATURE_COLUMNS = [
|
||||
# profile / context
|
||||
"duration_log_ratio",
|
||||
"energy_log_ratio",
|
||||
"peak_log_ratio",
|
||||
"profile_distance",
|
||||
"label_margin_positive",
|
||||
"max_gap_ratio",
|
||||
"low_power_gap_ratio",
|
||||
"false_end_energy_ratio",
|
||||
"sample_density_log",
|
||||
"peak_density_log",
|
||||
"local_spike_score",
|
||||
"local_spike_rate",
|
||||
"local_noise_score",
|
||||
"leading_idle_ratio",
|
||||
"trailing_idle_ratio",
|
||||
"trimmed_duration_log_ratio",
|
||||
"flag_pressure",
|
||||
"shape_fit_penalty",
|
||||
# trace shape
|
||||
"shape_active_fraction",
|
||||
"shape_early_energy_fraction",
|
||||
"shape_late_energy_fraction",
|
||||
"shape_mid_trough_depth",
|
||||
"shape_peak_density",
|
||||
"shape_max_step_drop",
|
||||
"shape_max_step_rise",
|
||||
"shape_active_cv",
|
||||
"shape_autocorr_lag1",
|
||||
"shape_derivative_sign_changes",
|
||||
"shape_plateau_ratio",
|
||||
"shape_tail_slope",
|
||||
# availability
|
||||
"has_trace",
|
||||
]
|
||||
|
||||
_QUALITY_TRACE_LENGTH = 128
|
||||
_IDLE_THRESHOLD_W = 2.0
|
||||
_STOP_THRESHOLD_W = 2.0
|
||||
|
||||
_SHAPE_COLUMNS = [
|
||||
"shape_active_fraction",
|
||||
"shape_early_energy_fraction",
|
||||
"shape_late_energy_fraction",
|
||||
"shape_mid_trough_depth",
|
||||
"shape_peak_density",
|
||||
"shape_max_step_drop",
|
||||
"shape_max_step_rise",
|
||||
"shape_active_cv",
|
||||
"shape_autocorr_lag1",
|
||||
"shape_derivative_sign_changes",
|
||||
"shape_plateau_ratio",
|
||||
"shape_tail_slope",
|
||||
]
|
||||
|
||||
|
||||
def quality_features(
|
||||
points: Sequence[Point],
|
||||
profile_median_duration_s: float,
|
||||
profile_median_energy_wh: float,
|
||||
profile_median_peak_w: float,
|
||||
profile_distance: float,
|
||||
label_margin: float,
|
||||
profile_fit_score: float,
|
||||
flag_count: int,
|
||||
*,
|
||||
trace_length: int = _QUALITY_TRACE_LENGTH,
|
||||
) -> dict[str, float]:
|
||||
"""Features for the hybrid curve-quality model (problem/bad-cycle detector).
|
||||
|
||||
Args:
|
||||
points: Complete cycle power trace (offset_s, watts).
|
||||
profile_median_duration_s: Median duration of the matched profile (s).
|
||||
profile_median_energy_wh: Median energy of the matched profile (Wh).
|
||||
profile_median_peak_w: Median peak power of the matched profile (W).
|
||||
profile_distance: Shape distance from the MatchResult to the assigned
|
||||
profile envelope (higher = worse fit).
|
||||
label_margin: Score margin between the top-1 and top-2 profile candidates
|
||||
(positive = confident match; 0.0 when only one candidate exists).
|
||||
profile_fit_score: Profile fit score in [0, 1] from the matcher.
|
||||
flag_count: Number of detection/anomaly flags raised for this cycle by
|
||||
the existing detector (early_power_dip, false_end_pause_seen, etc.).
|
||||
|
||||
Returns a dict with exactly ``QUALITY_FEATURE_COLUMNS`` keys.
|
||||
"""
|
||||
pts = _clean_points(points)
|
||||
if len(pts) < 4:
|
||||
return _no_trace_quality_features(
|
||||
profile_distance=profile_distance,
|
||||
label_margin=label_margin,
|
||||
profile_fit_score=profile_fit_score,
|
||||
flag_count=flag_count,
|
||||
)
|
||||
|
||||
offsets = np.asarray([float(o) for o, _ in pts], dtype=float)
|
||||
powers = np.asarray([float(p) for _, p in pts], dtype=float)
|
||||
duration_s = max(float(offsets[-1] - offsets[0]), 1.0)
|
||||
total_energy_wh = float(cumulative_energy_wh(pts)[-1])
|
||||
max_power_w = float(np.max(powers))
|
||||
|
||||
# -- profile context ratios --
|
||||
prof_dur = max(float(profile_median_duration_s), 1.0)
|
||||
prof_energy = max(float(profile_median_energy_wh), 1e-6)
|
||||
prof_peak = max(float(profile_median_peak_w), 1.0)
|
||||
|
||||
# -- sampling gap features --
|
||||
intervals = np.diff(offsets)
|
||||
usable = intervals[(intervals > 0) & (intervals < 3600)]
|
||||
max_gap_s = float(np.max(usable)) if usable.size else 0.0
|
||||
|
||||
# -- low-power / false-end features --
|
||||
low_gap_s = _longest_low_power_gap_s(pts, _STOP_THRESHOLD_W)
|
||||
fe_energy_wh = _false_end_energy_wh(pts, _STOP_THRESHOLD_W)
|
||||
|
||||
# -- density features --
|
||||
sample_count = len(pts)
|
||||
peak_count = _power_peak_count_arr(powers)
|
||||
|
||||
# -- noise features (from raw points) --
|
||||
noise = _trace_noise_features(pts)
|
||||
|
||||
# -- idle-padding features --
|
||||
padding = _trace_padding_ratios(pts, offsets, powers, duration_s, _IDLE_THRESHOLD_W)
|
||||
|
||||
# -- trace shape descriptors (from resampled + trimmed trace) --
|
||||
trace = _resample_to_length(pts, trace_length)
|
||||
shape = _trace_shape_descriptors(trace) if trace is not None else {c: 0.0 for c in _SHAPE_COLUMNS}
|
||||
|
||||
trimmed_ratio = float(padding["trimmed_duration_ratio"])
|
||||
trimmed_log = math.log(max(1e-6, trimmed_ratio)) # always <= 0
|
||||
|
||||
return {
|
||||
"duration_log_ratio": _log_ratio(duration_s / prof_dur),
|
||||
"energy_log_ratio": _log_ratio(total_energy_wh / prof_energy),
|
||||
"peak_log_ratio": _log_ratio(max_power_w / prof_peak),
|
||||
"profile_distance": float(profile_distance),
|
||||
"label_margin_positive": float(max(0.0, float(label_margin))),
|
||||
"max_gap_ratio": _safe_div(max_gap_s, duration_s),
|
||||
"low_power_gap_ratio": _safe_div(low_gap_s, duration_s),
|
||||
"false_end_energy_ratio": _safe_div(fe_energy_wh, max(total_energy_wh, 1e-6)),
|
||||
"sample_density_log": math.log1p(_safe_div(sample_count * 60.0, duration_s)),
|
||||
"peak_density_log": math.log1p(_safe_div(peak_count * 3600.0, duration_s)),
|
||||
"local_spike_score": float(noise["local_spike_score"]),
|
||||
"local_spike_rate": float(noise["local_spike_rate"]),
|
||||
"local_noise_score": float(noise["local_noise_score"]),
|
||||
"leading_idle_ratio": float(padding["leading_idle_ratio"]),
|
||||
"trailing_idle_ratio": float(padding["trailing_idle_ratio"]),
|
||||
"trimmed_duration_log_ratio": float(trimmed_log),
|
||||
"flag_pressure": float(max(0, int(flag_count))),
|
||||
"shape_fit_penalty": float(max(0.0, 1.0 - float(profile_fit_score))),
|
||||
**shape,
|
||||
"has_trace": 1.0,
|
||||
}
|
||||
|
||||
|
||||
def _no_trace_quality_features(
|
||||
*,
|
||||
profile_distance: float,
|
||||
label_margin: float,
|
||||
profile_fit_score: float,
|
||||
flag_count: int,
|
||||
) -> dict[str, float]:
|
||||
"""Zero-valued quality features for cycles with no usable power trace."""
|
||||
return {
|
||||
"duration_log_ratio": 0.0,
|
||||
"energy_log_ratio": 0.0,
|
||||
"peak_log_ratio": 0.0,
|
||||
"profile_distance": float(profile_distance),
|
||||
"label_margin_positive": float(max(0.0, float(label_margin))),
|
||||
"max_gap_ratio": 0.0,
|
||||
"low_power_gap_ratio": 0.0,
|
||||
"false_end_energy_ratio": 0.0,
|
||||
"sample_density_log": 0.0,
|
||||
"peak_density_log": 0.0,
|
||||
"local_spike_score": 0.0,
|
||||
"local_spike_rate": 0.0,
|
||||
"local_noise_score": 0.0,
|
||||
"leading_idle_ratio": 0.0,
|
||||
"trailing_idle_ratio": 0.0,
|
||||
"trimmed_duration_log_ratio": 0.0,
|
||||
"flag_pressure": float(max(0, int(flag_count))),
|
||||
"shape_fit_penalty": float(max(0.0, 1.0 - float(profile_fit_score))),
|
||||
**{c: 0.0 for c in _SHAPE_COLUMNS},
|
||||
"has_trace": 0.0,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers (ported from ml_washdata/wash_ml/features.py and
|
||||
# ml_washdata/wash_ml/hybrid_curve_quality.py - NumPy only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _clean_points(points: Sequence[Point]) -> list[Point]:
|
||||
"""Filter out non-finite readings, sort by offset, and deduplicate."""
|
||||
clean: list[Point] = []
|
||||
for offset_raw, power_raw in points:
|
||||
offset = float(offset_raw)
|
||||
power = float(power_raw)
|
||||
if math.isfinite(offset) and math.isfinite(power):
|
||||
clean.append((offset, max(0.0, power)))
|
||||
clean.sort(key=lambda pt: pt[0])
|
||||
deduped: list[Point] = []
|
||||
for offset, power in clean:
|
||||
if deduped and offset == deduped[-1][0]:
|
||||
deduped[-1] = (offset, power)
|
||||
else:
|
||||
deduped.append((offset, power))
|
||||
return deduped
|
||||
|
||||
|
||||
def _longest_low_power_gap_s(points: list[Point], threshold_w: float) -> float:
|
||||
"""Longest contiguous span below ``threshold_w`` (seconds)."""
|
||||
longest = 0.0
|
||||
current = 0.0
|
||||
for i in range(1, len(points)):
|
||||
prev_t, prev_p = points[i - 1]
|
||||
curr_t, curr_p = points[i]
|
||||
dt = curr_t - prev_t
|
||||
if dt <= 0 or dt > 3600:
|
||||
current = 0.0
|
||||
continue
|
||||
avg = (prev_p + curr_p) / 2.0
|
||||
if avg < threshold_w:
|
||||
current += dt
|
||||
longest = max(longest, current)
|
||||
else:
|
||||
current = 0.0
|
||||
return float(longest)
|
||||
|
||||
|
||||
def _false_end_energy_wh(points: list[Point], threshold_w: float) -> float:
|
||||
"""Energy accumulated during low-power pauses that were followed by more power.
|
||||
|
||||
These "false ends" indicate the cycle was interrupted but resumed. Returns
|
||||
the maximum such pause energy (Wh); 0.0 if no false end occurred.
|
||||
"""
|
||||
false_energies: list[float] = []
|
||||
in_pause = False
|
||||
pause_energy = 0.0
|
||||
for i in range(1, len(points)):
|
||||
prev_t, prev_p = points[i - 1]
|
||||
curr_t, curr_p = points[i]
|
||||
dt = curr_t - prev_t
|
||||
if dt <= 0 or dt > 3600:
|
||||
in_pause = False
|
||||
pause_energy = 0.0
|
||||
continue
|
||||
avg = (prev_p + curr_p) / 2.0
|
||||
if avg < threshold_w:
|
||||
in_pause = True
|
||||
pause_energy += avg * (dt / 3600.0)
|
||||
elif in_pause:
|
||||
false_energies.append(pause_energy)
|
||||
in_pause = False
|
||||
pause_energy = 0.0
|
||||
return float(max(false_energies) if false_energies else 0.0)
|
||||
|
||||
|
||||
def _power_peak_count_arr(powers: np.ndarray) -> int:
|
||||
"""Number of above-p75 power peaks (rising transitions through the p75 threshold)."""
|
||||
if powers.size < 3:
|
||||
return 0
|
||||
threshold = max(float(np.percentile(powers, 75)), 10.0)
|
||||
above = powers > threshold
|
||||
transitions = np.diff(above.astype(int))
|
||||
return int(np.sum(transitions == 1) + (1 if above[0] else 0))
|
||||
|
||||
|
||||
def _trace_noise_features(points: list[Point]) -> dict[str, float]:
|
||||
"""Local spike and noise floor metrics over the raw power trace.
|
||||
|
||||
Ported from ml_washdata/wash_ml/features.py ``trace_noise_features``.
|
||||
Distinguishes narrow single-sample spikes from broad appliance phases.
|
||||
"""
|
||||
if len(points) < 5:
|
||||
return {"local_spike_score": 0.0, "local_spike_rate": 0.0, "local_noise_score": 0.0}
|
||||
powers = np.asarray([float(p) for _, p in points], dtype=float)
|
||||
active = powers[powers > 0.5]
|
||||
scale = float(np.percentile(active, 95)) if active.size else float(np.max(powers))
|
||||
if not math.isfinite(scale) or scale <= 1e-6:
|
||||
return {"local_spike_score": 0.0, "local_spike_rate": 0.0, "local_noise_score": 0.0}
|
||||
|
||||
normalized = np.clip(powers / scale, 0.0, 8.0)
|
||||
spike_scores: list[float] = []
|
||||
residuals: list[float] = []
|
||||
n = normalized.size
|
||||
for i, value in enumerate(normalized):
|
||||
left = max(0, i - 2)
|
||||
right = min(n, i + 3)
|
||||
neighbors = np.concatenate([normalized[left:i], normalized[i + 1:right]])
|
||||
if neighbors.size < 2:
|
||||
continue
|
||||
local_median = float(np.median(neighbors))
|
||||
residual = abs(float(value - local_median))
|
||||
residuals.append(residual)
|
||||
left_nbr = float(normalized[i - 1]) if i > 0 else local_median
|
||||
right_nbr = float(normalized[i + 1]) if i + 1 < n else local_median
|
||||
shoulder = max(left_nbr, right_nbr)
|
||||
narrow_jump = float(value - shoulder)
|
||||
if value > 0.15 and (value - local_median) > 0.28 and narrow_jump > 0.18:
|
||||
spike_scores.append(min(3.0, max(float(value - local_median), narrow_jump)))
|
||||
|
||||
spike_count = len(spike_scores)
|
||||
return {
|
||||
"local_spike_score": round(float(max(spike_scores, default=0.0)), 6),
|
||||
"local_spike_rate": round(float(spike_count / max(1, n)), 6),
|
||||
"local_noise_score": round(float(np.percentile(np.asarray(residuals, dtype=float), 95)) if residuals else 0.0, 6),
|
||||
}
|
||||
|
||||
|
||||
def _trace_padding_ratios(
|
||||
pts: list[Point],
|
||||
offsets: np.ndarray,
|
||||
powers: np.ndarray,
|
||||
duration_s: float,
|
||||
idle_threshold_w: float,
|
||||
) -> dict[str, float]:
|
||||
"""Leading/trailing idle fractions and trimmed-duration ratio."""
|
||||
active_mask = powers > idle_threshold_w
|
||||
active_indexes = np.where(active_mask)[0]
|
||||
if active_indexes.size == 0 or duration_s <= 0:
|
||||
return {"leading_idle_ratio": 0.0, "trailing_idle_ratio": 0.0, "trimmed_duration_ratio": 1.0}
|
||||
first_active_t = float(offsets[active_indexes[0]])
|
||||
last_active_t = float(offsets[active_indexes[-1]])
|
||||
start_t = float(offsets[0])
|
||||
end_t = float(offsets[-1])
|
||||
leading = max(0.0, first_active_t - start_t)
|
||||
trailing = max(0.0, end_t - last_active_t)
|
||||
trimmed = max(0.0, last_active_t - first_active_t)
|
||||
return {
|
||||
"leading_idle_ratio": float(leading / duration_s),
|
||||
"trailing_idle_ratio": float(trailing / duration_s),
|
||||
"trimmed_duration_ratio": float(trimmed / duration_s) if duration_s > 0 else 1.0,
|
||||
}
|
||||
|
||||
|
||||
def _resample_to_length(points: list[Point], length: int) -> np.ndarray | None:
|
||||
"""Trim idle padding, resample to ``length`` points, and peak-normalise.
|
||||
|
||||
Ported from ml_washdata/wash_ml/hybrid_curve_quality.py ``_resample_trace``.
|
||||
Returns ``None`` when the trace is too short to be useful.
|
||||
"""
|
||||
# Trim leading/trailing idle.
|
||||
active_indexes = [i for i, (_, p) in enumerate(points) if p > _IDLE_THRESHOLD_W]
|
||||
if not active_indexes:
|
||||
return None
|
||||
pad_s = 60.0
|
||||
start_off = max(points[0][0], points[active_indexes[0]][0] - pad_s)
|
||||
end_off = points[active_indexes[-1]][0] + pad_s
|
||||
trimmed = [(o, p) for o, p in points if start_off <= o <= end_off]
|
||||
if len(trimmed) < 2:
|
||||
return None
|
||||
|
||||
offsets = np.asarray([float(o) for o, _ in trimmed], dtype=float)
|
||||
powers = np.asarray([max(0.0, float(p)) for _, p in trimmed], dtype=float)
|
||||
valid = np.isfinite(offsets) & np.isfinite(powers)
|
||||
offsets = offsets[valid]
|
||||
powers = powers[valid]
|
||||
if offsets.size < 2 or offsets[-1] <= offsets[0]:
|
||||
return None
|
||||
|
||||
grid = np.linspace(offsets[0], offsets[-1], length)
|
||||
trace = np.interp(grid, offsets, powers)
|
||||
active = trace[trace > 0.5]
|
||||
scale = float(np.percentile(active, 95)) if active.size else float(np.max(trace))
|
||||
if not math.isfinite(scale) or scale <= 1e-6:
|
||||
scale = 1.0
|
||||
return np.clip(trace / scale, 0.0, 5.0)
|
||||
|
||||
|
||||
def _trace_shape_descriptors(trace: np.ndarray) -> dict[str, float]:
|
||||
"""Deterministic, scale-robust shape descriptors over a normalised trace.
|
||||
|
||||
Ported from ml_washdata/wash_ml/hybrid_curve_quality.py
|
||||
``_trace_shape_descriptors``. Every value is NumPy-computable at runtime.
|
||||
"""
|
||||
if trace is None or trace.size < 4:
|
||||
return {c: 0.0 for c in _SHAPE_COLUMNS}
|
||||
trace = np.asarray(trace, dtype=float)
|
||||
length = trace.size
|
||||
total = float(np.sum(trace))
|
||||
active_mask = trace > 0.5
|
||||
active = trace[active_mask]
|
||||
quarter = max(1, length // 4)
|
||||
|
||||
early_energy = float(np.sum(trace[:quarter]))
|
||||
late_energy = float(np.sum(trace[-quarter:]))
|
||||
mid = trace[quarter: length - quarter]
|
||||
active_level = float(np.median(active)) if active.size else 0.0
|
||||
mid_trough_depth = 0.0
|
||||
if mid.size and active_level > 1e-6:
|
||||
mid_trough_depth = float(np.clip(1.0 - float(np.min(mid)) / active_level, 0.0, 1.0))
|
||||
|
||||
diffs = np.diff(trace)
|
||||
return {
|
||||
"shape_active_fraction": float(np.mean(active_mask)),
|
||||
"shape_early_energy_fraction": _safe_div(early_energy, total),
|
||||
"shape_late_energy_fraction": _safe_div(late_energy, total),
|
||||
"shape_mid_trough_depth": float(mid_trough_depth),
|
||||
"shape_peak_density": _shape_peak_density(trace),
|
||||
"shape_max_step_drop": float(max(0.0, -float(np.min(diffs)))) if diffs.size else 0.0,
|
||||
"shape_max_step_rise": float(max(0.0, float(np.max(diffs)))) if diffs.size else 0.0,
|
||||
"shape_active_cv": _safe_div(float(np.std(active)), float(np.mean(active))) if active.size else 0.0,
|
||||
"shape_autocorr_lag1": _autocorr_lag1(trace),
|
||||
"shape_derivative_sign_changes": _safe_div(
|
||||
float(np.sum(np.abs(np.diff(np.sign(diffs))) > 0)), float(diffs.size)
|
||||
) if diffs.size else 0.0,
|
||||
"shape_plateau_ratio": _plateau_ratio(trace, active_level),
|
||||
"shape_tail_slope": _safe_div(float(trace[-1] - trace[-quarter]), float(quarter)),
|
||||
}
|
||||
|
||||
|
||||
def _shape_peak_density(trace: np.ndarray, prominence: float = 0.2) -> float:
|
||||
"""Prominent local maxima per sample."""
|
||||
if trace.size < 3:
|
||||
return 0.0
|
||||
peaks = sum(
|
||||
1
|
||||
for i in range(1, trace.size - 1)
|
||||
if trace[i] > trace[i - 1] and trace[i] >= trace[i + 1] and trace[i] >= prominence
|
||||
)
|
||||
return float(peaks) / float(trace.size)
|
||||
|
||||
|
||||
def _autocorr_lag1(trace: np.ndarray) -> float:
|
||||
"""Lag-1 autocorrelation (smoothness indicator)."""
|
||||
centered = trace - float(np.mean(trace))
|
||||
denom = float(np.dot(centered, centered))
|
||||
if denom <= 1e-9:
|
||||
return 0.0
|
||||
return float(np.dot(centered[:-1], centered[1:]) / denom)
|
||||
|
||||
|
||||
def _plateau_ratio(trace: np.ndarray, active_level: float, band: float = 0.12) -> float:
|
||||
"""Fraction of trace within ``band`` of the running active level."""
|
||||
if active_level <= 1e-6:
|
||||
return 0.0
|
||||
within = np.abs(trace - active_level) <= band
|
||||
return float(np.mean(within & (trace > 0.5)))
|
||||
|
||||
|
||||
def _log_ratio(ratio: float) -> float:
|
||||
"""log(ratio) clamped to finite; 0.0 for non-positive or non-finite inputs."""
|
||||
if not math.isfinite(ratio) or ratio <= 0:
|
||||
return 0.0
|
||||
return float(math.log(max(1e-6, ratio)))
|
||||
|
||||
|
||||
def _safe_div(numerator: float, denominator: float) -> float:
|
||||
if not math.isfinite(numerator) or not math.isfinite(denominator) or abs(denominator) <= 1e-9:
|
||||
return 0.0
|
||||
return float(numerator / denominator)
|
||||
Reference in New Issue
Block a user