7062 lines
313 KiB
Python
7062 lines
313 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/>.
|
||
"""Profile storage and matching logic for WashData."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import contextlib
|
||
import dataclasses
|
||
import hashlib
|
||
import json
|
||
import logging
|
||
import math
|
||
import os
|
||
import re
|
||
import threading
|
||
import uuid
|
||
from datetime import datetime, timedelta
|
||
from typing import Any, TypeAlias, cast
|
||
|
||
import numpy as np
|
||
|
||
from homeassistant.core import HomeAssistant
|
||
from homeassistant.helpers.storage import Store
|
||
from homeassistant.util import dt as dt_util
|
||
|
||
from .const import (
|
||
CLUSTER_RESAMPLE_N,
|
||
CLUSTER_SHAPE_SIMILARITY_THRESHOLD,
|
||
GROUP_MIN_COHESION,
|
||
MAINTENANCE_EVENT_TYPES,
|
||
MAINTENANCE_RECENT_SUPPRESS_DAYS,
|
||
MATCH_AMBIGUITY_MARGIN,
|
||
PHASE_CONSISTENCY_MIN_CYCLES,
|
||
PHASE_PROFILE_MIN_CYCLES,
|
||
PHASE_HEAT_CV_WARN,
|
||
PHASE_HEAT_OCC_MIXED_LO,
|
||
PHASE_HEAT_OCC_MIXED_HI,
|
||
REFERENCE_PROFILE_CURVE_POINTS,
|
||
SHAPE_DRIFT_MIN_CYCLES,
|
||
SHAPE_DRIFT_RESAMPLE_N,
|
||
SHAPE_DRIFT_THRESHOLD,
|
||
SHAREABLE_SETTING_KEYS,
|
||
SMART_TERM_LANDSCAPE_RATIO,
|
||
SMART_TERM_LANDSCAPE_MIN_SHAPE,
|
||
STORAGE_KEY,
|
||
STORAGE_VERSION,
|
||
DEFAULT_MAX_PAST_CYCLES,
|
||
DEFAULT_MAX_FULL_TRACES_PER_PROFILE,
|
||
DEFAULT_MAX_FULL_TRACES_UNLABELED,
|
||
DEFAULT_DTW_BANDWIDTH,
|
||
)
|
||
from .features import compute_signature
|
||
from .signal_processing import resample_uniform, resample_adaptive, Segment, integrate_wh, energy_gap_threshold_s
|
||
from . import analysis
|
||
from .time_utils import (
|
||
migrate_power_data_to_offsets,
|
||
power_data_to_offsets,
|
||
)
|
||
from .phase_catalog import (
|
||
DEFAULT_PHASES_BY_DEVICE,
|
||
_builtin_phase_id,
|
||
get_builtin_phase_by_id,
|
||
merge_phase_catalog,
|
||
normalize_phase_name,
|
||
)
|
||
from .phase_segmenter import phase_matching_live_supported, phase_model_for, segment_cycle
|
||
from .phase_match import (
|
||
build_phase_profile,
|
||
match_phase_profiles,
|
||
phase_eta,
|
||
phase_profile_from_dict,
|
||
phase_profile_to_dict,
|
||
)
|
||
from .log_utils import DeviceLoggerAdapter
|
||
|
||
_LOGGER = logging.getLogger(__name__)
|
||
|
||
# Absolute peak-power floor below which a cycle trace is considered a
|
||
# mis-capture (e.g. the power sensor wasn't reporting), regardless of profile.
|
||
# Used together with a relative (10% of median peak) test in envelope rebuild
|
||
# and reference selection so degenerate cycles never become the matching template.
|
||
_DEGENERATE_POWER_FLOOR = 15.0 # watts
|
||
|
||
JSONDict: TypeAlias = dict[str, Any]
|
||
CycleDict: TypeAlias = dict[str, Any]
|
||
|
||
|
||
def _is_recorded_cycle(cycle: dict[str, Any]) -> bool:
|
||
"""True when a cycle was produced by the manual recorder.
|
||
|
||
Two independent signals, because the explicit marker did not always exist:
|
||
|
||
1. **Explicit meta marker** (recent recorder builds): ``meta.source ==
|
||
"recorder"`` and, equivalently, ``meta.original_samples`` (the pre-trim
|
||
sample count). (In exported diagnostics ``meta.source`` is redacted, but
|
||
the real stored value is ``"recorder"`` — the migration runs against the
|
||
real store, not the export.)
|
||
|
||
2. **Structural signature** (old recordings, ``meta`` is ``None``): the
|
||
recorder builds the cycle dict directly (``ws_process_recording``) and
|
||
never sets ``max_power`` or ``termination_reason``, whereas *every*
|
||
auto-detected cycle goes through ``CycleDetector._finish_cycle`` which
|
||
stamps ``max_power`` unconditionally (in every version — ``termination_reason``
|
||
was added later, so old *auto* cycles can lack it but never lack
|
||
``max_power``). So a **completed** cycle missing *both* fields is a
|
||
recording that predates the meta marker. Verified against real exports:
|
||
this recovers old recordings that carry only ``meta: None`` — which the
|
||
marker-only check silently missed.
|
||
"""
|
||
meta = cycle.get("meta")
|
||
if isinstance(meta, dict) and (
|
||
meta.get("source") == "recorder" or "original_samples" in meta
|
||
):
|
||
return True
|
||
return (
|
||
cycle.get("status") == "completed"
|
||
and "max_power" not in cycle
|
||
and "termination_reason" not in cycle
|
||
)
|
||
|
||
|
||
def _flag_recorded_cycles_golden(cycles: list[dict[str, Any]]) -> int:
|
||
"""Mark manually-recorded cycles as golden references (recorded == golden).
|
||
|
||
A recorded cycle (see :func:`_is_recorded_cycle`) is, by definition, a
|
||
hand-picked clean example, so it becomes the profile's golden reference. The
|
||
flag lives in the single ``ml_review`` dict (no duplicate ``recorded`` field).
|
||
Idempotent: already-golden cycles are skipped, so it is safe to re-run from the
|
||
migration and the diagnostics processing trigger.
|
||
|
||
Returns the number of cycles newly flagged.
|
||
"""
|
||
flagged = 0
|
||
for cycle in cycles:
|
||
if not isinstance(cycle, dict):
|
||
continue
|
||
review = cycle.get("ml_review")
|
||
if isinstance(review, dict) and review.get("golden"):
|
||
continue
|
||
if not _is_recorded_cycle(cycle):
|
||
continue
|
||
review = dict(review) if isinstance(review, dict) else {}
|
||
review["golden"] = True
|
||
if not review.get("quality"):
|
||
review["quality"] = "good" # recorded cycles are clean by definition
|
||
if not review.get("reviewed_at"):
|
||
review["reviewed_at"] = dt_util.now().isoformat()
|
||
cycle["ml_review"] = review
|
||
flagged += 1
|
||
return flagged
|
||
|
||
|
||
def _empty_ranking() -> list[dict[str, Any]]:
|
||
"""Typed default factory for ranking entries."""
|
||
return []
|
||
|
||
|
||
def _parse_start_dt(value: Any) -> datetime | None:
|
||
"""Parse a start_time value (ISO string or numeric timestamp) into a datetime.
|
||
|
||
Handles the case where legacy cycles stored numeric unix timestamps instead
|
||
of ISO-formatted strings, which dt_util.parse_datetime cannot handle.
|
||
"""
|
||
if isinstance(value, datetime):
|
||
return value
|
||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||
try:
|
||
return datetime.fromtimestamp(float(value), tz=dt_util.UTC)
|
||
except (OSError, OverflowError, ValueError):
|
||
return None
|
||
if isinstance(value, str) and value:
|
||
parsed = dt_util.parse_datetime(value)
|
||
if parsed is not None:
|
||
return parsed
|
||
try:
|
||
return datetime.fromtimestamp(float(value), tz=dt_util.UTC)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
return None
|
||
|
||
|
||
def _parse_maintenance_dt(value: Any) -> datetime | None:
|
||
"""Parse a maintenance-log date (ISO date or datetime) into an aware datetime.
|
||
|
||
Accepts both full ISO datetimes (as written by ``dt_util.now().isoformat()``)
|
||
and bare ISO dates (``"2026-07-01"``) that the user may enter. Naive results are
|
||
localised so recency/comparison math stays timezone-consistent. Never raises.
|
||
"""
|
||
dt = _parse_start_dt(value)
|
||
if dt is None and isinstance(value, str) and value:
|
||
try:
|
||
dt = datetime.fromisoformat(value)
|
||
except ValueError:
|
||
dt = None
|
||
if dt is not None and dt.tzinfo is None:
|
||
dt = dt.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE or dt_util.UTC)
|
||
return dt
|
||
|
||
|
||
def _empty_debug_details() -> dict[str, Any]:
|
||
"""Typed default factory for debug details."""
|
||
return {}
|
||
|
||
|
||
def _value_to_timestamp(value: Any) -> float | None:
|
||
"""Parse supported datetime-like values into unix seconds."""
|
||
if isinstance(value, datetime):
|
||
return value.timestamp()
|
||
if isinstance(value, (int, float)):
|
||
return float(value)
|
||
if isinstance(value, str) and value:
|
||
parsed = dt_util.parse_datetime(value)
|
||
if parsed is not None:
|
||
return parsed.timestamp()
|
||
try:
|
||
return float(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
return None
|
||
|
||
|
||
def profile_sort_key(name: str) -> tuple[int, int, str]:
|
||
"""Sort key for profile names: numeric-prefixed first (by number), then alphabetically."""
|
||
match = re.match(r'^(\d+)', name)
|
||
if match:
|
||
return (0, int(match.group(1)), name)
|
||
return (1, 0, name)
|
||
|
||
|
||
|
||
|
||
def trim_zero_power_data(
|
||
data: list[list[float]],
|
||
threshold: float = 0.5
|
||
) -> list[list[float]]:
|
||
"""Trim leading/trailing zero/near-zero power readings from stored data.
|
||
|
||
Args:
|
||
data: List of [offset, power] pairs
|
||
threshold: Power values <= this are considered "zero"
|
||
|
||
Returns:
|
||
Trimmed list with leading/trailing zeros removed
|
||
"""
|
||
if not data:
|
||
return data
|
||
|
||
# Find first non-zero reading
|
||
start_idx = 0
|
||
for i, point in enumerate(data):
|
||
if point[1] > threshold:
|
||
start_idx = i
|
||
break
|
||
else:
|
||
# All readings are zero - keep at least one
|
||
return data[:1] if data else []
|
||
|
||
# Find last non-zero reading
|
||
end_idx = len(data) - 1
|
||
for i in range(len(data) - 1, -1, -1):
|
||
if data[i][1] > threshold:
|
||
end_idx = i
|
||
break
|
||
|
||
# Return trimmed slice (inclusive of end)
|
||
return data[start_idx:end_idx + 1]
|
||
|
||
|
||
def filter_duration_outliers(durations: list[float]) -> list[float]:
|
||
"""Return a robust duration set with extreme outliers removed.
|
||
|
||
Uses Tukey IQR fences for normal spread and falls back to a MAD-based
|
||
filter when IQR collapses (common when most cycles are identical).
|
||
"""
|
||
if len(durations) < 4:
|
||
return durations
|
||
|
||
arr = np.array(durations, dtype=float)
|
||
|
||
q1 = float(np.percentile(arr, 25))
|
||
q3 = float(np.percentile(arr, 75))
|
||
iqr = q3 - q1
|
||
|
||
if iqr > 0:
|
||
lower = max(60.0, q1 - 1.5 * iqr)
|
||
upper = q3 + 1.5 * iqr
|
||
filtered = arr[(arr >= lower) & (arr <= upper)]
|
||
else:
|
||
# Degenerate spread (e.g. many identical durations); keep values
|
||
# close to median and drop only extreme anomalies.
|
||
median = float(np.median(arr))
|
||
abs_dev = np.abs(arr - median)
|
||
mad = float(np.median(abs_dev))
|
||
if mad == 0:
|
||
tol = max(300.0, median * 0.15)
|
||
filtered = arr[np.abs(arr - median) <= tol]
|
||
else:
|
||
robust_z = abs_dev / (1.4826 * mad)
|
||
filtered = arr[robust_z <= 3.5]
|
||
|
||
# Guardrail: do not over-filter sparse datasets.
|
||
if len(filtered) >= max(3, int(len(arr) * 0.6)):
|
||
return filtered.astype(float).tolist()
|
||
|
||
return durations
|
||
|
||
|
||
|
||
|
||
|
||
@dataclasses.dataclass
|
||
class MatchResult:
|
||
"""Result of a profile matching attempt."""
|
||
|
||
best_profile: str | None
|
||
confidence: float
|
||
expected_duration: float
|
||
matched_phase: str | None
|
||
candidates: list[dict[str, Any]]
|
||
is_ambiguous: bool
|
||
ambiguity_margin: float
|
||
ranking: list[dict[str, Any]] = dataclasses.field(default_factory=_empty_ranking)
|
||
debug_details: dict[str, Any] = dataclasses.field(default_factory=_empty_debug_details)
|
||
is_confident_mismatch: bool = False
|
||
mismatch_reason: str | None = None
|
||
is_prefix_ambiguous: bool = False
|
||
|
||
def to_dict(self) -> dict[str, Any]:
|
||
"""Convert to dictionary with JSON-serializable types, excluding heavy arrays."""
|
||
def _convert(obj: Any) -> Any:
|
||
if isinstance(obj, np.generic):
|
||
return cast(np.generic, obj).item()
|
||
if isinstance(obj, np.ndarray):
|
||
# Fallback for unexpected arrays: just describe shape
|
||
arr = cast(np.ndarray[Any, Any], obj)
|
||
return f"<array shape={arr.shape}>"
|
||
if isinstance(obj, dict):
|
||
# Exclude huge raw data arrays from cycle candidates
|
||
obj_dict = cast(dict[str, Any], obj)
|
||
return {
|
||
k: _convert(v)
|
||
for k, v in obj_dict.items()
|
||
if k not in ("current", "sample", "metrics", "warping_path")
|
||
}
|
||
if isinstance(obj, list):
|
||
obj_list = cast(list[Any], obj)
|
||
return [_convert(v) for v in obj_list]
|
||
if dataclasses.is_dataclass(obj):
|
||
return text_type_safe_asdict(obj)
|
||
return obj
|
||
|
||
def text_type_safe_asdict(d_obj: Any) -> dict[str, Any]:
|
||
return {f.name: _convert(getattr(d_obj, f.name)) for f in dataclasses.fields(d_obj)}
|
||
|
||
return text_type_safe_asdict(self)
|
||
|
||
|
||
|
||
def _safe_file_size_kb(path: str) -> float:
|
||
"""Return the size of ``path`` in KiB, or 0.0 if it cannot be read.
|
||
|
||
Runs blocking os.path calls; intended to be offloaded to the executor.
|
||
"""
|
||
try:
|
||
return os.path.getsize(path) / 1024 if os.path.exists(path) else 0.0
|
||
except OSError:
|
||
return 0.0
|
||
|
||
|
||
def decompress_power_data(cycle: CycleDict) -> list[tuple[float, float]]:
|
||
"""Return power data as ``[(offset_seconds, power), ...]`` for a cycle.
|
||
|
||
Handles both the current canonical ``[offset_float, power]`` format and the
|
||
legacy ``(iso_str, power)`` format transparently. Returns an empty list if
|
||
data is missing or malformed.
|
||
"""
|
||
raw = cycle.get("power_data", [])
|
||
if not isinstance(raw, list) or not raw:
|
||
return []
|
||
|
||
start_time_raw = cycle.get("start_time")
|
||
start_time_iso: str | None = start_time_raw if isinstance(start_time_raw, str) and start_time_raw else None
|
||
if isinstance(start_time_raw, datetime):
|
||
start_time_iso = start_time_raw.isoformat()
|
||
offsets = power_data_to_offsets(cast(list[list[Any] | tuple[Any, ...]], raw), start_time_iso)
|
||
return [(float(o), float(p)) for o, p in offsets]
|
||
|
||
|
||
def earliest_sustained_quiet_offset(
|
||
cycles: list[CycleDict],
|
||
stop_threshold_w: float,
|
||
min_quiet_span_s: float,
|
||
min_clean_cycles: int,
|
||
) -> float | None:
|
||
"""Smallest elapsed offset (s) at which any *completed* cycle first shows a
|
||
sustained (>= ``min_quiet_span_s``) near-zero (< ``stop_threshold_w``) span.
|
||
|
||
This is the device's learned "we have never legitimately been quiet before
|
||
this many seconds into a cycle" baseline, used by the opt-in terminal-drop
|
||
detector: a hard cliff-to-~0 that begins *earlier* than this is anomalous and
|
||
almost certainly a real stop rather than a soak pause.
|
||
|
||
Only ``completed`` cycles seed the baseline - interrupted / force-stopped /
|
||
terminal-drop cycles are exactly the anomalies we are trying to catch, so
|
||
including them would poison the baseline and suppress detection. Returns
|
||
``None`` when there are fewer than ``min_clean_cycles`` completed cycles with
|
||
usable traces, so the caller keeps the proven slow end-detection.
|
||
|
||
Using the strict *minimum* (rather than a higher percentile) is deliberately
|
||
conservative: one cycle that happened to go quiet early only lowers the
|
||
baseline, making the detector fire *less* often - never more.
|
||
"""
|
||
if not cycles:
|
||
return None
|
||
earliest: float | None = None
|
||
clean = 0
|
||
for cycle in cycles:
|
||
if cycle.get("status") != "completed":
|
||
continue
|
||
points = decompress_power_data(cycle)
|
||
if not points:
|
||
continue
|
||
clean += 1
|
||
span_start: float | None = None
|
||
for offset, power in points:
|
||
if power < stop_threshold_w:
|
||
if span_start is None:
|
||
span_start = offset
|
||
elif (offset - span_start) >= min_quiet_span_s:
|
||
if earliest is None or span_start < earliest:
|
||
earliest = span_start
|
||
break
|
||
else:
|
||
span_start = None
|
||
if clean < min_clean_cycles:
|
||
return None
|
||
return earliest
|
||
|
||
|
||
def device_active_peak_range(
|
||
cycles: list[CycleDict], min_clean_cycles: int
|
||
) -> tuple[float, float] | None:
|
||
"""(min_peak, max_peak) across the device's **completed** cycles.
|
||
|
||
The peak of a cycle is its maximum power. This is the device's learned
|
||
"power levels we have produced before" band, used by the terminal-drop
|
||
familiarity gate: a running cycle peaking outside this range (widened by a
|
||
tolerance) is drawing power unlike anything in its history and may be a new
|
||
program, so an early drop on it is deferred rather than assumed terminal.
|
||
|
||
Returns ``None`` below ``min_clean_cycles`` completed cycles with usable
|
||
traces (same guard as the quiet-offset baseline)."""
|
||
peaks: list[float] = []
|
||
for cycle in cycles:
|
||
if cycle.get("status") != "completed":
|
||
continue
|
||
points = decompress_power_data(cycle)
|
||
if not points:
|
||
continue
|
||
peaks.append(max(p for _, p in points))
|
||
if len(peaks) < min_clean_cycles:
|
||
return None
|
||
return (min(peaks), max(peaks))
|
||
|
||
|
||
def terminal_drop_baseline(
|
||
cycles: list[CycleDict],
|
||
stop_threshold_w: float,
|
||
min_quiet_span_s: float,
|
||
min_clean_cycles: int,
|
||
) -> tuple[float | None, tuple[float, float] | None]:
|
||
"""Combined ``(earliest_quiet_offset, peak_range)`` baseline.
|
||
|
||
Pure (no store / no I/O beyond decompressing the passed-in cycle traces), so
|
||
the manager can offload the whole per-cycle scan to an executor thread in one
|
||
hop instead of decompressing every trace on the event loop (issue #311)."""
|
||
earliest = earliest_sustained_quiet_offset(
|
||
cycles, stop_threshold_w, min_quiet_span_s, min_clean_cycles
|
||
)
|
||
peak_range = device_active_peak_range(cycles, min_clean_cycles)
|
||
return earliest, peak_range
|
||
|
||
|
||
def is_terminal_drop(
|
||
points: list[tuple[float, float]],
|
||
earliest_quiet: float | None,
|
||
peak_range: tuple[float, float] | None,
|
||
stop_threshold_w: float,
|
||
earliness_ratio: float,
|
||
min_peak_ratio: float,
|
||
peak_familiar_tol: float,
|
||
) -> bool:
|
||
"""Whether a running cycle's current low-power event is an anomalously-early
|
||
terminal drop (plug pulled / cancelled) rather than a soak pause.
|
||
|
||
Pure decision logic (no store / no I/O) so it is unit-testable in isolation;
|
||
the manager wires the learned ``earliest_quiet`` / ``peak_range`` baselines
|
||
and gates on the ML opt-in. All three checks must hold:
|
||
|
||
* **Clearly ON** - the cycle peaked >= ``min_peak_ratio`` x stop-threshold
|
||
(a low-power idling device can't trip it).
|
||
* **Familiar** - the peak is within the device's historical peak range
|
||
widened by ``peak_familiar_tol``. A peak outside that band means the cycle
|
||
is drawing power unlike anything seen before (possibly a new program), so
|
||
we DEFER (return ``False``) rather than assume a stop.
|
||
* **Anomalous** - the trailing sub-threshold span began at an offset
|
||
``< earliest_quiet * earliness_ratio`` - earlier than this device has ever
|
||
legitimately gone quiet.
|
||
|
||
Returns ``False`` (keep the proven slow path) whenever a baseline is missing
|
||
or any check fails."""
|
||
if not points or earliest_quiet is None or peak_range is None:
|
||
return False
|
||
peak = max(p for _, p in points)
|
||
if peak < min_peak_ratio * max(stop_threshold_w, 1.0):
|
||
return False
|
||
lo, hi = peak_range
|
||
if not (lo * (1.0 - peak_familiar_tol) <= peak <= hi * (1.0 + peak_familiar_tol)):
|
||
return False
|
||
drop_start: float | None = None
|
||
for offset, power in points:
|
||
if power < stop_threshold_w:
|
||
if drop_start is None:
|
||
drop_start = offset
|
||
else:
|
||
drop_start = None
|
||
if drop_start is None:
|
||
return False
|
||
return drop_start < earliest_quiet * earliness_ratio
|
||
|
||
|
||
def compress_power_data(cycle: CycleDict) -> list[Any] | None:
|
||
"""Compress cycle power data to [offset, power] format (Module-level helper).
|
||
|
||
Returns the compressed list structure or None if compression failed/not needed.
|
||
"""
|
||
raw_data_raw = cycle.get("power_data")
|
||
if not isinstance(raw_data_raw, list) or not raw_data_raw:
|
||
return None
|
||
raw_data = cast(list[Any], raw_data_raw)
|
||
|
||
# Check if already compressed (first element is number or mixed format)
|
||
first = raw_data[0]
|
||
if isinstance(first, (int, float)):
|
||
# Already flat list (very old format?) or specific compression
|
||
return None
|
||
if isinstance(first, (list, tuple)):
|
||
first_seq = cast(list[Any] | tuple[Any, ...], first)
|
||
if len(first_seq) == 2 and isinstance(first_seq[0], (int, float)):
|
||
# Already compressed [offset, power]
|
||
return None
|
||
|
||
# Proceed with compression from [iso_string, power]
|
||
if "start_time" not in cycle:
|
||
return None
|
||
|
||
try:
|
||
start_ts = _value_to_timestamp(cycle.get("start_time"))
|
||
if start_ts is None:
|
||
return None
|
||
compressed: list[list[float]] = []
|
||
|
||
last_saved_p = -999.0
|
||
last_saved_t = -999.0
|
||
|
||
for i, entry in enumerate(raw_data):
|
||
if isinstance(entry, (list, tuple)):
|
||
entry_seq = cast(list[Any] | tuple[Any, ...], entry)
|
||
if len(entry_seq) != 2:
|
||
continue
|
||
t_str, p_val_raw = entry_seq
|
||
try:
|
||
# Handle both ISO string and potential timestamp float
|
||
t = _value_to_timestamp(t_str)
|
||
if t is None:
|
||
continue
|
||
|
||
p_val = float(p_val_raw)
|
||
offset = round(t - start_ts, 1)
|
||
if offset < 0:
|
||
offset = 0.0
|
||
|
||
# Save first and last
|
||
is_endpoint = i == 0 or i == len(raw_data) - 1
|
||
|
||
# Downsample: change > 1W or gap > 60s
|
||
if (
|
||
is_endpoint
|
||
or abs(p_val - last_saved_p) > 1.0
|
||
or (offset - last_saved_t) > 60
|
||
):
|
||
compressed.append([offset, round(p_val, 1)])
|
||
last_saved_p = p_val
|
||
last_saved_t = offset
|
||
|
||
except (ValueError, TypeError):
|
||
continue
|
||
return compressed
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
class WashDataStore(Store[JSONDict]):
|
||
"""Store implementation with migration support."""
|
||
|
||
async def _async_migrate_func(
|
||
self,
|
||
old_major_version: int,
|
||
old_minor_version: int, # pylint: disable=unused-argument
|
||
old_data: JSONDict,
|
||
) -> JSONDict:
|
||
"""Migrate data to the new version."""
|
||
if old_major_version < 2:
|
||
_LOGGER.info("Migrating storage from v%s to v2", old_major_version)
|
||
# Logic moved from ProfileStore._migrate_v1_to_v2
|
||
cycles_raw = old_data.get("past_cycles", [])
|
||
cycles = cast(list[dict[str, Any]], cycles_raw) if isinstance(cycles_raw, list) else []
|
||
migrated_cycles = 0
|
||
for cycle in cycles:
|
||
if "signature" not in cycle and cycle.get("power_data"):
|
||
try:
|
||
# decompress_power_data now returns [(offset_seconds, power), ...]
|
||
tuples = decompress_power_data(cycle)
|
||
if tuples and len(tuples) > 10:
|
||
ts_arr = np.array([t for t, _ in tuples])
|
||
p_arr = np.array([p for _, p in tuples])
|
||
sig = compute_signature(ts_arr, p_arr)
|
||
cycle["signature"] = dataclasses.asdict(sig)
|
||
migrated_cycles += 1
|
||
except Exception as e: # pylint: disable=broad-exception-caught
|
||
_LOGGER.warning(
|
||
"Failed to migrate signature for cycle %s: %s", cycle.get("id"), e
|
||
)
|
||
|
||
_LOGGER.info(
|
||
"Migration v1->v2: Computed signatures for %s cycles", migrated_cycles
|
||
)
|
||
|
||
if old_major_version < 3:
|
||
_LOGGER.info("Migrating storage from v%s to v3", old_major_version)
|
||
cycles_raw = old_data.get("past_cycles", [])
|
||
profiles_raw = old_data.get("profiles", {})
|
||
cycles = cast(list[dict[str, Any]], cycles_raw) if isinstance(cycles_raw, list) else []
|
||
profiles = cast(dict[str, dict[str, Any]], profiles_raw) if isinstance(profiles_raw, dict) else {}
|
||
migrated_count = 0
|
||
|
||
# 1. Migrate Power Data to canonical offset format & ensure status
|
||
for cycle in cycles:
|
||
if "status" not in cycle:
|
||
cycle["status"] = "completed"
|
||
|
||
if cycle.get("power_data") and isinstance(cycle["power_data"], list):
|
||
try:
|
||
if migrate_power_data_to_offsets(cycle):
|
||
migrated_count += 1
|
||
except Exception as e:
|
||
_LOGGER.warning(
|
||
"Failed to migrate power data for cycle %s: %s",
|
||
cycle.get("id"),
|
||
e,
|
||
)
|
||
|
||
# 2. Ensure Device Type in Profiles
|
||
for profile in profiles.values():
|
||
if "device_type" not in profile:
|
||
profile["device_type"] = "washing_machine"
|
||
|
||
_LOGGER.info(
|
||
"Migration v2->v3: Migrated power data for %s cycles", migrated_count
|
||
)
|
||
|
||
if old_major_version < 4:
|
||
_LOGGER.info("Migrating storage from v%s to v4", old_major_version)
|
||
profiles = old_data.get("profiles", {})
|
||
if isinstance(profiles, dict):
|
||
for profile in cast(dict[str, dict[str, Any]], profiles).values():
|
||
phases = profile.get("phases")
|
||
if not isinstance(phases, list):
|
||
profile["phases"] = []
|
||
|
||
custom = old_data.get("custom_phases")
|
||
if not isinstance(custom, dict):
|
||
old_data["custom_phases"] = {}
|
||
|
||
if old_major_version < 5:
|
||
_LOGGER.info("Migrating storage from v%s to v5", old_major_version)
|
||
custom = old_data.get("custom_phases")
|
||
if isinstance(custom, list):
|
||
normalized: list[dict[str, Any]] = []
|
||
seen: set[tuple[str, str]] = set()
|
||
for item in cast(list[Any], custom):
|
||
if not isinstance(item, dict):
|
||
continue
|
||
item_dict = cast(dict[str, Any], item)
|
||
name = str(item_dict.get("name", "")).strip()
|
||
if not name:
|
||
continue
|
||
device_type = str(item_dict.get("device_type", "")).strip()
|
||
key = (name.casefold(), device_type.casefold())
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
normalized.append(
|
||
{
|
||
"name": name,
|
||
"description": str(item_dict.get("description", "")).strip(),
|
||
"device_type": device_type,
|
||
"created_at": item_dict.get("created_at") or dt_util.now().isoformat(),
|
||
}
|
||
)
|
||
old_data["custom_phases"] = normalized
|
||
elif isinstance(custom, dict):
|
||
normalized: list[dict[str, Any]] = []
|
||
seen: set[tuple[str, str]] = set()
|
||
custom_dict = cast(dict[str, Any], custom)
|
||
for legacy_device_type, phase_list in custom_dict.items():
|
||
if not isinstance(phase_list, list):
|
||
continue
|
||
for item in cast(list[Any], phase_list):
|
||
if not isinstance(item, dict):
|
||
continue
|
||
item_dict = cast(dict[str, Any], item)
|
||
name = str(item_dict.get("name", "")).strip()
|
||
if not name:
|
||
continue
|
||
device_type = str(legacy_device_type or "").strip()
|
||
key = (name.casefold(), device_type.casefold())
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
normalized.append(
|
||
{
|
||
"name": name,
|
||
"description": str(item_dict.get("description", "")).strip(),
|
||
"device_type": device_type,
|
||
"created_at": item_dict.get("created_at") or dt_util.now().isoformat(),
|
||
}
|
||
)
|
||
old_data["custom_phases"] = normalized
|
||
else:
|
||
old_data["custom_phases"] = []
|
||
|
||
if old_major_version < 6:
|
||
_LOGGER.info("Migrating storage from v%s to v6", old_major_version)
|
||
cycles_raw = old_data.get("past_cycles", [])
|
||
cycles = cast(list[dict[str, Any]], cycles_raw) if isinstance(cycles_raw, list) else []
|
||
flagged = _flag_recorded_cycles_golden(cycles)
|
||
_LOGGER.info(
|
||
"Migration v5->v6: flagged %s recorded cycle(s) as golden references", flagged
|
||
)
|
||
|
||
if old_major_version < 7:
|
||
# The v6 step only ran when upgrading from below v6, so recorded
|
||
# cycles on installs already at v6 (e.g. recorded by an older build
|
||
# that didn't set the golden flag) were never tagged. Re-run the now
|
||
# broadened backfill once so every historical recorded cycle is
|
||
# recognised. Idempotent: already-golden cycles are skipped.
|
||
_LOGGER.info("Migrating storage from v%s to v7", old_major_version)
|
||
cycles_raw = old_data.get("past_cycles", [])
|
||
cycles = cast(list[dict[str, Any]], cycles_raw) if isinstance(cycles_raw, list) else []
|
||
flagged = _flag_recorded_cycles_golden(cycles)
|
||
_LOGGER.info(
|
||
"Migration v6->v7: tagged %s previously-unmarked recorded cycle(s) as golden",
|
||
flagged,
|
||
)
|
||
|
||
if old_major_version < 8:
|
||
# The v6->v7 backfill identified recordings only by the explicit
|
||
# ``meta.source``/``original_samples`` marker, which OLD recordings
|
||
# (made before that marker existed, ``meta: None``) do not carry — so
|
||
# they stayed untagged. _is_recorded_cycle now also recognises them by
|
||
# their structural signature (completed + no max_power/termination_reason).
|
||
# Re-run the backfill once more so those old recordings are finally
|
||
# tagged. Idempotent: already-golden cycles are skipped.
|
||
_LOGGER.info("Migrating storage from v%s to v8", old_major_version)
|
||
cycles_raw = old_data.get("past_cycles", [])
|
||
cycles = cast(list[dict[str, Any]], cycles_raw) if isinstance(cycles_raw, list) else []
|
||
flagged = _flag_recorded_cycles_golden(cycles)
|
||
_LOGGER.info(
|
||
"Migration v7->v8: tagged %s old recorded cycle(s) (no meta marker) as golden",
|
||
flagged,
|
||
)
|
||
|
||
if old_major_version < 9:
|
||
# Pre-initialize additive top-level keys so they are present from the
|
||
# first load rather than appearing lazily on first use. Idempotent:
|
||
# setdefault leaves existing values untouched.
|
||
_LOGGER.info("Migrating storage from v%s to v9", old_major_version)
|
||
old_data.setdefault("lifetime_energy_wh", 0.0)
|
||
# Seed the monotonic lifetime cycle counter from the retained history so an
|
||
# existing install does not start at 0 and re-fire past milestones (50/100/...)
|
||
# on cycles it already completed. Matches the cycle_count fallback semantics.
|
||
old_data.setdefault(
|
||
"lifetime_cycle_count", len(old_data.get("past_cycles") or [])
|
||
)
|
||
old_data.setdefault("settings_changelog", [])
|
||
old_data.setdefault("maintenance_log", [])
|
||
|
||
if old_major_version < 10:
|
||
# Reference cycles imported from the community store live in their own list,
|
||
# never in past_cycles, so they can feed the envelope/matcher but can never
|
||
# touch usage/energy stats. Additive + idempotent.
|
||
_LOGGER.info("Migrating storage from v%s to v10", old_major_version)
|
||
old_data.setdefault("reference_cycles", [])
|
||
|
||
if old_major_version < 11:
|
||
# Marker-only bump. Per-phase profiles (envelope["phase_profile"], used
|
||
# by phase-segmented matching / phase-resolved ETA) are DERIVED CACHE
|
||
# built by async_rebuild_envelope, not stored data - so there is nothing
|
||
# to migrate. They self-populate on the next envelope rebuild (which
|
||
# runs on every cycle end / label change); until then consumers fall
|
||
# back to the existing estimator via lazy absent-key handling. No data
|
||
# is added, removed, or altered here.
|
||
_LOGGER.info("Migrating storage from v%s to v11 (phase-profile cache marker)",
|
||
old_major_version)
|
||
|
||
return old_data
|
||
|
||
def _ambiguity_from_candidates(candidates: list[dict]) -> tuple[float, bool]:
|
||
"""Top1-vs-top2 score margin and whether the match is ambiguous.
|
||
|
||
Single source for the ambiguity rule shared by both match paths:
|
||
``is_ambiguous = margin < MATCH_AMBIGUITY_MARGIN``. ``margin`` defaults to
|
||
1.0 when there is only one candidate.
|
||
"""
|
||
margin = 1.0
|
||
if len(candidates) > 1:
|
||
margin = candidates[0]["score"] - candidates[1]["score"]
|
||
return margin, margin < MATCH_AMBIGUITY_MARGIN
|
||
|
||
|
||
# ── Selective export/import taxonomy ────────────────────────────────────────────
|
||
# One canonical description of the store's top-level data kinds, grouped into the
|
||
# user-facing categories offered by the export/import wizard. A single walker
|
||
# (``_scan_data``) drives BOTH the export inventory and the import manifest so the
|
||
# tree UI is identical on both sides, and the export filter + selective-import
|
||
# apply agree on what each category contains.
|
||
#
|
||
# Keys NOT listed here are handled specially and are never a selectable category:
|
||
# - "envelopes": derived cache -- rebuilt on import for cycle-receiving profiles,
|
||
# or carried verbatim for a profiles-only export so definition-only profiles
|
||
# stay matchable without their raw traces.
|
||
# - "active_cycle" / "last_active_save": transient in-flight state -- never exported.
|
||
# - "store_account": credential (GitHub refresh token) -- never exported.
|
||
_EXPORT_CATEGORIES: dict[str, dict[str, Any]] = {
|
||
"profiles": {"keys": ["profiles"], "kind": "dict", "enumerable": True},
|
||
"real_cycles": {"keys": ["past_cycles"], "kind": "list", "enumerable": True,
|
||
"group_by": "profile_name"},
|
||
"reference_cycles": {"keys": ["reference_cycles"], "kind": "list", "enumerable": True,
|
||
"group_by": "profile_name"},
|
||
"custom_phases": {"keys": ["custom_phases"], "kind": "list"},
|
||
"profile_groups": {"keys": ["profile_groups"], "kind": "dict"},
|
||
"matching_config": {"keys": ["matching_config"], "kind": "dict", "device_specific": True},
|
||
"suggestions": {"keys": ["suggestions", "suggestion_apply_cycle_count"], "kind": "mixed"},
|
||
"feedback": {"keys": ["feedback_history", "pending_feedback"], "kind": "dict"},
|
||
"maintenance_log": {"keys": ["maintenance_log"], "kind": "list"},
|
||
"ml_models": {"keys": ["ml_model_versions", "ml_training_history", "ml_last_training_run"],
|
||
"kind": "mixed", "device_specific": True},
|
||
"history_logs": {"keys": ["auto_adjustments", "match_ranking_history", "settings_changelog"],
|
||
"kind": "list"},
|
||
"lifetime_stats": {"keys": ["lifetime_energy_wh", "lifetime_cycle_count"], "kind": "scalar"},
|
||
"settings": {"keys": [], "kind": "options", "device_specific": True},
|
||
}
|
||
|
||
# Categories whose payload rides at the envelope level (entry_options), not inside "data".
|
||
_ENVELOPE_LEVEL_CATEGORIES = frozenset({"settings"})
|
||
# Enumerable/structural categories handled explicitly (not as plain leaf keys).
|
||
_STRUCTURAL_CATEGORIES = frozenset({"profiles", "real_cycles", "reference_cycles", "settings"})
|
||
|
||
|
||
def _strip_redacted_dict(d: Any) -> dict[str, Any]:
|
||
"""Drop diagnostic ``**REDACTED**`` sentinels so they never overwrite real settings."""
|
||
if not isinstance(d, dict):
|
||
return {}
|
||
return {k: v for k, v in d.items() if v != "**REDACTED**"}
|
||
|
||
|
||
def _shareable_settings(opts: Any) -> dict[str, Any]:
|
||
"""Filter options to the SHAREABLE allow-list, numeric non-bool only.
|
||
|
||
Mirrors the community-bundle settings filter (``store.py`` / ``ws_api.py``): the
|
||
only options that may travel with an export are recognition/matching thresholds,
|
||
never identity keys or the power-sensor binding.
|
||
"""
|
||
if not isinstance(opts, dict):
|
||
return {}
|
||
return {
|
||
k: opts[k]
|
||
for k in SHAREABLE_SETTING_KEYS
|
||
if k in opts and isinstance(opts[k], (int, float)) and not isinstance(opts[k], bool)
|
||
}
|
||
|
||
|
||
def _selected_categories(selection: Any) -> set[str]:
|
||
"""Normalize a wizard ``selection`` into the set of enabled category ids.
|
||
|
||
Accepts either ``{"categories": [ids...]}`` or ``{"categories": {id: bool}}``.
|
||
Unknown category ids are ignored so a stale client can't inject junk keys.
|
||
"""
|
||
if not isinstance(selection, dict):
|
||
return set()
|
||
cats = selection.get("categories")
|
||
out: set[str] = set()
|
||
if isinstance(cats, dict):
|
||
for cid, on in cats.items():
|
||
if on and cid in _EXPORT_CATEGORIES:
|
||
out.add(str(cid))
|
||
elif isinstance(cats, list):
|
||
for cid in cats:
|
||
if cid in _EXPORT_CATEGORIES:
|
||
out.add(str(cid))
|
||
return out
|
||
|
||
|
||
def _cycles_by_profile(seq: Any) -> dict[str, list[dict[str, Any]]]:
|
||
"""Group a cycle list by ``profile_name`` (unlabeled cycles under ``""``)."""
|
||
groups: dict[str, list[dict[str, Any]]] = {}
|
||
if not isinstance(seq, list):
|
||
return groups
|
||
for c in seq:
|
||
if not isinstance(c, dict):
|
||
continue
|
||
name = str(c.get("profile_name") or "")
|
||
groups.setdefault(name, []).append(c)
|
||
return groups
|
||
|
||
|
||
def _scan_data(data_dict: Any, entry_options: Any = None) -> dict[str, dict[str, Any]]:
|
||
"""Walk a store ``data`` blob and return a per-category inventory.
|
||
|
||
Pure and never raises. Drives both the export inventory (over ``self._data``)
|
||
and the import manifest (over an unwrapped payload). Enumerable categories
|
||
carry item/group detail so the panel can render a hierarchical tri-state tree.
|
||
"""
|
||
out: dict[str, dict[str, Any]] = {}
|
||
dd = data_dict if isinstance(data_dict, dict) else {}
|
||
profiles = dd.get("profiles") if isinstance(dd.get("profiles"), dict) else {}
|
||
past = dd.get("past_cycles") if isinstance(dd.get("past_cycles"), list) else []
|
||
refs = dd.get("reference_cycles") if isinstance(dd.get("reference_cycles"), list) else []
|
||
|
||
past_by = _cycles_by_profile(past)
|
||
ref_by = _cycles_by_profile(refs)
|
||
|
||
# profiles: each profile with its local real/reference cycle counts
|
||
prof_items = [
|
||
{
|
||
"name": name,
|
||
"real_cycles": len(past_by.get(name, [])),
|
||
"reference_cycles": len(ref_by.get(name, [])),
|
||
}
|
||
for name in profiles
|
||
]
|
||
out["profiles"] = {"present": bool(profiles), "count": len(profiles), "items": prof_items}
|
||
|
||
def _groups(groups: dict[str, list[dict[str, Any]]]) -> list[dict[str, Any]]:
|
||
result: list[dict[str, Any]] = []
|
||
for name in sorted(groups):
|
||
cs = groups[name]
|
||
result.append(
|
||
{
|
||
"profile": name,
|
||
"count": len(cs),
|
||
"cycles": [
|
||
{
|
||
"id": c.get("id"),
|
||
"date": c.get("start_time"),
|
||
"duration": c.get("duration"),
|
||
}
|
||
for c in cs
|
||
if isinstance(c, dict)
|
||
],
|
||
}
|
||
)
|
||
return result
|
||
|
||
out["real_cycles"] = {"present": bool(past), "count": len(past), "groups": _groups(past_by)}
|
||
out["reference_cycles"] = {"present": bool(refs), "count": len(refs), "groups": _groups(ref_by)}
|
||
|
||
# leaf categories (everything except the structural ones above + settings)
|
||
for cat_id, spec in _EXPORT_CATEGORIES.items():
|
||
if cat_id in _STRUCTURAL_CATEGORIES:
|
||
continue
|
||
present = False
|
||
count = 0
|
||
for key in spec.get("keys", []):
|
||
if key not in dd:
|
||
continue
|
||
val = dd.get(key)
|
||
if isinstance(val, (dict, list)):
|
||
if val:
|
||
present = True
|
||
count += len(val)
|
||
elif val not in (None, 0, 0.0, ""):
|
||
present = True
|
||
count += 1
|
||
out[cat_id] = {"present": present, "count": count}
|
||
|
||
# settings: the shareable numeric subset of entry_options
|
||
shareable = _shareable_settings(entry_options)
|
||
out["settings"] = {
|
||
"present": bool(shareable),
|
||
"count": len(shareable),
|
||
"keys": sorted(shareable.keys()),
|
||
}
|
||
return out
|
||
|
||
|
||
def unwrap_import_payload(payload: Any) -> tuple[dict[str, Any], dict[str, Any]]:
|
||
"""Normalize any supported export/import wrapper into ``(data_dict, meta)``.
|
||
|
||
Handles the four shapes the integration can be handed:
|
||
* HA diagnostics download -> ``{"home_assistant": ..., "data": {...}}``
|
||
* integration diagnostics -> ``{..., "store_export": {...}}``
|
||
* v1 flat export -> ``{"profiles": ..., "past_cycles": ...}``
|
||
* v2+ nested export -> ``{"version": N, "data": {...}, ...}``
|
||
|
||
Returns a shallow-copied, shape-repaired ``data_dict`` (so the caller can mutate
|
||
or assign it without aliasing the input) plus a ``meta`` dict carrying format,
|
||
version, redaction-stripped ``entry_data``/``entry_options``, the device
|
||
fingerprint and the export timestamp. Raises ``ValueError`` on a payload that
|
||
is not a usable object.
|
||
"""
|
||
if isinstance(payload, dict) and "home_assistant" in payload and "data" in payload:
|
||
payload = payload["data"]
|
||
if isinstance(payload, dict) and "store_export" in payload:
|
||
payload = payload["store_export"]
|
||
if not isinstance(payload, dict):
|
||
raise ValueError("Invalid export payload (not a JSON object)")
|
||
|
||
version = payload.get("version", 1)
|
||
if version == 1 or "data" not in payload:
|
||
data_dict: dict[str, Any] = {
|
||
"profiles": payload.get("profiles", {}),
|
||
"past_cycles": payload.get("past_cycles", []),
|
||
"envelopes": payload.get("envelopes", {}),
|
||
}
|
||
fmt = "v1"
|
||
else:
|
||
data = payload.get("data")
|
||
if not isinstance(data, dict):
|
||
raise ValueError("Invalid export payload (missing or invalid 'data' key)")
|
||
data_dict = dict(data) # shallow copy: caller may assign/mutate freely
|
||
fmt = "v2"
|
||
|
||
# Repair top-level shape so downstream code can assume the core keys.
|
||
if not isinstance(data_dict.get("profiles"), dict):
|
||
data_dict["profiles"] = {}
|
||
if not isinstance(data_dict.get("past_cycles"), list):
|
||
data_dict["past_cycles"] = []
|
||
if not isinstance(data_dict.get("reference_cycles"), list):
|
||
data_dict["reference_cycles"] = []
|
||
data_dict.setdefault("envelopes", {})
|
||
|
||
fingerprint = payload.get("device_fingerprint")
|
||
meta: dict[str, Any] = {
|
||
"format": fmt,
|
||
"version": version,
|
||
"entry_data": _strip_redacted_dict(payload.get("entry_data", {})),
|
||
"entry_options": _strip_redacted_dict(payload.get("entry_options", {})),
|
||
"device_fingerprint": fingerprint if isinstance(fingerprint, dict) else {},
|
||
"exported_at": payload.get("exported_at"),
|
||
}
|
||
return data_dict, meta
|
||
|
||
|
||
def build_import_manifest(
|
||
payload: Any, *, local_device_type: str, local_profile_names: Any
|
||
) -> dict[str, Any]:
|
||
"""Analyze an import payload and describe what it contains + what can be imported.
|
||
|
||
Pure (no store mutation). Returns ``{"error": msg}`` on an unparseable payload so
|
||
the WS layer / panel can render a friendly message instead of failing. Otherwise
|
||
returns the format/version, source-vs-local device type compatibility, per-category
|
||
inventory (with per-profile ``conflict`` flags and ``importable`` gating), and any
|
||
warnings the panel should surface.
|
||
"""
|
||
try:
|
||
data_dict, meta = unwrap_import_payload(payload)
|
||
except ValueError as exc:
|
||
return {"error": str(exc)}
|
||
|
||
scan = _scan_data(data_dict, entry_options=meta.get("entry_options"))
|
||
source_dt = str((meta.get("device_fingerprint") or {}).get("device_type") or "") or None
|
||
device_type_match = (not source_dt) or (source_dt == local_device_type)
|
||
|
||
local_names = {str(n).casefold() for n in (local_profile_names or [])}
|
||
for item in scan.get("profiles", {}).get("items", []):
|
||
item["conflict"] = str(item.get("name", "")).casefold() in local_names
|
||
|
||
for cat_id, info in scan.items():
|
||
spec = _EXPORT_CATEGORIES.get(cat_id, {})
|
||
importable = bool(info.get("present"))
|
||
if spec.get("device_specific") and not device_type_match:
|
||
importable = False
|
||
info["importable"] = importable
|
||
|
||
warnings: list[str] = []
|
||
if not device_type_match:
|
||
warnings.append("device_type_mismatch")
|
||
|
||
return {
|
||
"format": meta.get("format"),
|
||
"version": meta.get("version"),
|
||
"exported_at": meta.get("exported_at"),
|
||
"source_device_type": source_dt,
|
||
"local_device_type": local_device_type,
|
||
"device_type_match": device_type_match,
|
||
"real_history_allowed": device_type_match,
|
||
"categories": scan,
|
||
"warnings": warnings,
|
||
}
|
||
|
||
|
||
def _unique_profile_name(existing: dict[str, Any], base: str) -> str:
|
||
"""Return ``base`` or ``"base 2"``/``"base 3"``… so it doesn't collide in ``existing``."""
|
||
if base not in existing:
|
||
return base
|
||
i = 2
|
||
while f"{base} {i}" in existing:
|
||
i += 1
|
||
return f"{base} {i}"
|
||
|
||
|
||
def _usable_reference_pairs(points: Any) -> list[list[float]] | None:
|
||
"""Validate a raw ``[[offset, watts], ...]`` trace for reference-cycle import: drop
|
||
non-finite/non-numeric samples, require >= 2 points and a positive time span. Returns the
|
||
sorted ``[offset, watts]`` pairs, or ``None`` if the trace is unusable (would no-op).
|
||
|
||
Single source of the "is this trace importable" rule, shared by ``_add_reference_cycle_nosave``
|
||
(which mutates) and the selective-import replace guard (which must not wipe a destination
|
||
when every selected cycle would silently no-op)."""
|
||
pairs: list[list[float]] = []
|
||
for p in (points if isinstance(points, (list, tuple)) else []):
|
||
if not isinstance(p, (list, tuple)) or len(p) < 2:
|
||
continue
|
||
try:
|
||
x, y = float(p[0]), float(p[1])
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if np.isfinite(x) and np.isfinite(y):
|
||
pairs.append([x, y])
|
||
if len(pairs) < 2:
|
||
return None
|
||
pairs.sort(key=lambda q: q[0]) # defensive: normalize any out-of-order offsets
|
||
if float(pairs[-1][0] - pairs[0][0]) <= 0:
|
||
return None
|
||
return pairs
|
||
|
||
|
||
def _merge_list_dedup(base: list[Any], incoming: list[Any]) -> None:
|
||
"""Append items from ``incoming`` to ``base`` in place, skipping duplicates.
|
||
|
||
Dedupes by ``id`` when items carry one, else by a stable JSON signature so
|
||
re-importing the same log file is idempotent for id-less list entries.
|
||
"""
|
||
seen_ids = {str(x.get("id")) for x in base if isinstance(x, dict) and x.get("id") is not None}
|
||
seen_sigs: set[str] = set()
|
||
for x in base:
|
||
try:
|
||
seen_sigs.add(json.dumps(x, sort_keys=True, default=str))
|
||
except (TypeError, ValueError):
|
||
pass
|
||
for item in incoming:
|
||
if isinstance(item, dict) and item.get("id") is not None:
|
||
key = str(item.get("id"))
|
||
if key in seen_ids:
|
||
continue
|
||
seen_ids.add(key)
|
||
base.append(item)
|
||
continue
|
||
try:
|
||
sig = json.dumps(item, sort_keys=True, default=str)
|
||
except (TypeError, ValueError):
|
||
sig = None
|
||
if sig is not None:
|
||
if sig in seen_sigs:
|
||
continue
|
||
seen_sigs.add(sig)
|
||
base.append(item)
|
||
|
||
|
||
class ProfileStore:
|
||
"""Manages storage of washer profiles and past cycles."""
|
||
|
||
def __init__(
|
||
self,
|
||
hass: HomeAssistant,
|
||
entry_id: str,
|
||
min_duration_ratio: float = 0.50,
|
||
max_duration_ratio: float = 1.50,
|
||
save_debug_traces: bool = False,
|
||
match_threshold: float = 0.4,
|
||
unmatch_threshold: float = 0.35,
|
||
device_name: str = "",
|
||
) -> None:
|
||
"""Initialize the profile store."""
|
||
self.hass = hass
|
||
self.entry_id = entry_id
|
||
self._logger = DeviceLoggerAdapter(_LOGGER, device_name)
|
||
self._min_duration_ratio = min_duration_ratio
|
||
self._max_duration_ratio = max_duration_ratio
|
||
self._match_threshold = match_threshold
|
||
self._unmatch_threshold = unmatch_threshold
|
||
self.dtw_bandwidth: float = DEFAULT_DTW_BANDWIDTH
|
||
self._save_debug_traces = save_debug_traces
|
||
|
||
# Cache for resampled sample segments: key=(cycle_id, dt)
|
||
self._cached_sample_segments: dict[tuple[str, float], Segment] = {}
|
||
# Cache for group cohesion scores to avoid re-running DTW on the event loop
|
||
# every 5 minutes. Keyed by sorted-members tuple; invalidated when profile_groups
|
||
# content changes (tracked by a simple generation counter).
|
||
self._cohesion_cache: dict[tuple[str, ...], float] = {}
|
||
self._cohesion_cache_generation: int = 0
|
||
self._cohesion_cache_generation_checked: int = -1
|
||
# group_cohesion runs in executor threads (live matching + Playground can
|
||
# touch the same store concurrently); serialize the read-clear-compute-store
|
||
# sequence so a concurrent call can't clear a half-populated cache or
|
||
# duplicate the DTW work. A plain thread lock, never held across an await.
|
||
self._cohesion_cache_lock = threading.Lock()
|
||
# Profile duration tolerance (set by manager; reserved for duration-based heuristics)
|
||
self._duration_tolerance: float = 0.25
|
||
# Retention policy: cap total cycles and number of full-resolution traces per profile
|
||
self._max_past_cycles = DEFAULT_MAX_PAST_CYCLES
|
||
self._max_full_traces_per_profile = DEFAULT_MAX_FULL_TRACES_PER_PROFILE
|
||
self._max_full_traces_unlabeled = DEFAULT_MAX_FULL_TRACES_UNLABELED
|
||
# Separate store for each entry to avoid giant files
|
||
# Use WashDataStore to handle migration
|
||
self._store: Store[JSONDict] = WashDataStore(
|
||
hass, STORAGE_VERSION, f"{STORAGE_KEY}.{entry_id}"
|
||
)
|
||
self._data: JSONDict = {
|
||
"profiles": {},
|
||
"past_cycles": [],
|
||
"reference_cycles": [], # Imported store cycles: envelope/matcher only, never usage stats
|
||
"envelopes": {}, # Cached statistical envelopes per profile
|
||
"auto_adjustments": [], # Log of automatic setting changes
|
||
"suggestions": {}, # Suggested settings (do NOT change user options)
|
||
"feedback_history": {}, # Persisted user feedback (cycle_id -> record)
|
||
"pending_feedback": {}, # Persisted pending feedback requests
|
||
"custom_phases": [], # Shared custom phase catalog
|
||
"ml_model_versions": {}, # On-device trained model specs (Stage 4)
|
||
"profile_groups": {}, # Named groups of near-duplicate profiles (Stage 5)
|
||
"maintenance_log": [], # User-logged maintenance events (Group E)
|
||
}
|
||
|
||
|
||
|
||
|
||
def set_suggestion(
|
||
self,
|
||
key: str,
|
||
value: Any,
|
||
reason: str | None = None,
|
||
reason_key: str | None = None,
|
||
reason_params: dict[str, Any] | None = None,
|
||
) -> None:
|
||
"""Store a suggested setting value without changing config entry options.
|
||
|
||
``reason`` is the English fallback text; ``reason_key`` + ``reason_params``
|
||
are the localization key + interpolation values the panel resolves via
|
||
``_t(reason_key, reason_params, reason)``. Both localization fields are
|
||
optional so old callers / persisted entries keep working.
|
||
"""
|
||
suggestions: JSONDict = self._data.setdefault("suggestions", {})
|
||
entry: dict[str, Any] = {
|
||
"value": value,
|
||
"reason": reason,
|
||
"updated": dt_util.now().isoformat(),
|
||
}
|
||
# Store localization sidecars only when present so we never overwrite an
|
||
# existing key with a stale ``None`` (keeps the shape lean for old data).
|
||
if reason_key is not None:
|
||
entry["reason_key"] = reason_key
|
||
if reason_params is not None:
|
||
entry["reason_params"] = reason_params
|
||
suggestions[key] = entry
|
||
|
||
def get_suggestions(self) -> dict[str, Any]:
|
||
"""Return current suggestion map."""
|
||
raw = self._data.get("suggestions")
|
||
if isinstance(raw, dict):
|
||
suggestions = cast(JSONDict, raw)
|
||
return suggestions.copy()
|
||
return {}
|
||
|
||
def delete_suggestion(self, key: str) -> None:
|
||
"""Remove a single suggestion entry by key."""
|
||
suggestions: JSONDict = self._data.setdefault("suggestions", {})
|
||
suggestions.pop(key, None)
|
||
|
||
async def clear_suggestions(self) -> None:
|
||
"""Clear all pending suggestions and persist."""
|
||
self._data["suggestions"] = {}
|
||
await self.async_save()
|
||
|
||
def get_suggestion_apply_cycle_count(self) -> int:
|
||
"""Return the total cycle count recorded at the last user suggestion apply."""
|
||
return int(self._data.get("suggestion_apply_cycle_count", 0))
|
||
|
||
def set_suggestion_apply_cycle_count(self, count: int) -> None:
|
||
"""Record the total cycle count at the moment the user applies suggestions."""
|
||
self._data["suggestion_apply_cycle_count"] = count
|
||
|
||
# ─── Settings changelog (Group D7) ─────────────────────────────────────────
|
||
|
||
#: Maximum settings-changelog entries retained per device (newest kept).
|
||
SETTINGS_CHANGELOG_MAX = 50
|
||
|
||
def get_settings_changelog(self) -> list[dict[str, Any]]:
|
||
"""Return the recorded settings-change history (most-recent-first).
|
||
|
||
Never raises: returns an empty list when no changelog exists yet.
|
||
"""
|
||
try:
|
||
raw = self._data.get("settings_changelog", [])
|
||
if isinstance(raw, list):
|
||
return raw
|
||
except Exception: # pylint: disable=broad-exception-caught
|
||
pass
|
||
return []
|
||
|
||
async def async_record_settings_changes(self, changes: list[dict]) -> None:
|
||
"""Append settings-change entries and persist (capped at the newest 50).
|
||
|
||
Each entry is normalized to ``{"key", "old", "new", "timestamp"}`` and
|
||
prepended so the list stays most-recent-first. No-op on empty/invalid
|
||
input.
|
||
"""
|
||
if not changes:
|
||
return
|
||
log = self._data.setdefault("settings_changelog", [])
|
||
if not isinstance(log, list):
|
||
log = []
|
||
self._data["settings_changelog"] = log
|
||
|
||
now_iso = dt_util.now().isoformat()
|
||
added = False
|
||
for ch in changes:
|
||
if not isinstance(ch, dict) or "key" not in ch:
|
||
continue
|
||
log.insert(
|
||
0,
|
||
{
|
||
"key": str(ch.get("key")),
|
||
"old": ch.get("old"),
|
||
"new": ch.get("new"),
|
||
"timestamp": ch.get("timestamp") or now_iso,
|
||
},
|
||
)
|
||
added = True
|
||
|
||
if not added:
|
||
return
|
||
|
||
# Keep only the newest entries (list is most-recent-first).
|
||
if len(log) > self.SETTINGS_CHANGELOG_MAX:
|
||
del log[self.SETTINGS_CHANGELOG_MAX:]
|
||
self._data["settings_changelog"] = log
|
||
await self.async_save()
|
||
|
||
# ─── On-device ML model versions (Stage 4) ────────────────────────────────
|
||
|
||
def get_ml_model_versions(self) -> dict[str, Any]:
|
||
"""Return the map of on-device trained model specs (capability -> record).
|
||
|
||
Each record is ``{spec, trained_at, cycle_count, metrics, baseline_auc}``
|
||
where ``spec`` is a NumPy-only standardized-logistic model produced by
|
||
:mod:`.ml.trainer`. Empty when nothing has been trained on-device.
|
||
"""
|
||
raw = self._data.get("ml_model_versions")
|
||
if isinstance(raw, dict):
|
||
return cast(JSONDict, raw).copy()
|
||
return {}
|
||
|
||
async def set_ml_model_version(self, capability: str, record: dict[str, Any]) -> None:
|
||
"""Persist a trained model record for a capability (quality/live_match/end)."""
|
||
versions: JSONDict = self._data.setdefault("ml_model_versions", {})
|
||
versions[capability] = record
|
||
await self.async_save()
|
||
|
||
async def clear_ml_model_versions(self) -> None:
|
||
"""Drop all on-device trained models (reverts to embedded baselines)."""
|
||
self._data["ml_model_versions"] = {}
|
||
await self.async_save()
|
||
|
||
def get_ml_last_training_run(self) -> str | None:
|
||
"""ISO timestamp of the last *completed* on-device training run, or None.
|
||
|
||
Distinct from each model's ``trained_at`` (the promotion time): this
|
||
advances on every run even when no model beat the baseline, so the panel's
|
||
"Last trained" reflects that training actually ran.
|
||
"""
|
||
ts = self._data.get("ml_last_training_run")
|
||
return ts if isinstance(ts, str) else None
|
||
|
||
async def set_ml_last_training_run(self, iso: str) -> None:
|
||
"""Record when on-device training last completed (regardless of promotion)."""
|
||
self._data["ml_last_training_run"] = iso
|
||
await self.async_save()
|
||
|
||
def get_ml_training_history(self) -> dict[str, list[dict[str, Any]]]:
|
||
"""Per-capability held-out-score history across training runs.
|
||
|
||
``{capability: [{"ts": iso, "score": float, "higher_better": bool}, ...]}``
|
||
oldest-first. Lets the panel show whether a model's fit is improving,
|
||
steady, or declining over time. Empty until training has run.
|
||
"""
|
||
raw = self._data.get("ml_training_history")
|
||
return cast(dict[str, list[dict[str, Any]]], raw) if isinstance(raw, dict) else {}
|
||
|
||
async def append_ml_training_history(self, run_iso: str, results: list[dict[str, Any]]) -> None:
|
||
"""Append each capability's held-out score from a training run.
|
||
|
||
``results`` is ``train_from_cycles``'s per-capability records; classifiers
|
||
report ``new_auc`` (higher is better), regressors ``model_mae`` (lower is
|
||
better). Capabilities without a held-out metric this run (insufficient
|
||
data) are skipped. Retains at most ``ML_TRAINING_HISTORY_MAX`` runs each.
|
||
"""
|
||
from .const import ML_TRAINING_HISTORY_MAX # noqa: PLC0415
|
||
|
||
hist: JSONDict = self._data.setdefault("ml_training_history", {})
|
||
changed = False
|
||
for rec in results or []:
|
||
if not isinstance(rec, dict):
|
||
continue
|
||
cap = rec.get("capability")
|
||
if not isinstance(cap, str) or not cap:
|
||
continue
|
||
if rec.get("new_auc") is not None:
|
||
score, higher_better = float(rec["new_auc"]), True
|
||
elif rec.get("model_mae") is not None:
|
||
score, higher_better = float(rec["model_mae"]), False
|
||
else:
|
||
continue
|
||
series = hist.setdefault(cap, [])
|
||
if not isinstance(series, list):
|
||
series = hist[cap] = []
|
||
series.append({"ts": run_iso, "score": round(score, 5), "higher_better": higher_better})
|
||
del series[:-ML_TRAINING_HISTORY_MAX]
|
||
changed = True
|
||
if changed:
|
||
await self.async_save()
|
||
|
||
# ─── On-device matching-config tuning (Stage 4/5, opt-in) ──────────────────
|
||
|
||
#: Only these bounded scoring weights (all in [0, 1]) may be overridden
|
||
#: on-device, so a stored record can never change structural matching
|
||
#: behaviour. Mirrors ml.matching_tuner.OVERRIDE_KEYS.
|
||
_MATCHING_OVERRIDE_KEYS = ("corr_weight", "duration_weight", "energy_weight", "dtw_ensemble_w")
|
||
|
||
def get_matching_config(self) -> dict[str, Any]:
|
||
"""Return the on-device tuned matcher scoring-weight record, if any.
|
||
|
||
Record shape: ``{config, trained_at, cycle_count, baseline_test_top1,
|
||
tuned_test_top1}`` where ``config`` holds the bounded scoring weights
|
||
(``corr_weight``/``duration_weight``/``energy_weight``). Empty when the
|
||
matcher is using the shipped defaults.
|
||
"""
|
||
raw = self._data.get("matching_config")
|
||
if isinstance(raw, dict):
|
||
return cast(JSONDict, raw).copy()
|
||
return {}
|
||
|
||
async def set_matching_config(self, record: dict[str, Any]) -> None:
|
||
"""Persist the tuned matcher scoring-weight override + metadata."""
|
||
self._data["matching_config"] = record
|
||
await self.async_save()
|
||
|
||
async def clear_matching_config(self) -> None:
|
||
"""Revert the matcher to the shipped default scoring weights."""
|
||
self._data.pop("matching_config", None)
|
||
await self.async_save()
|
||
|
||
def _matching_overrides(self) -> dict[str, float]:
|
||
"""Bounded scoring-weight overrides to merge into the matcher config.
|
||
|
||
Only the whitelisted keys are honoured, each clamped to ``[0, 1]``, so a
|
||
stored record can never alter structural matching behaviour - only the
|
||
emphasis between shape, level and energy agreement.
|
||
"""
|
||
rec = self._data.get("matching_config")
|
||
cfg = rec.get("config") if isinstance(rec, dict) else None
|
||
out: dict[str, float] = {}
|
||
if isinstance(cfg, dict):
|
||
for k in self._MATCHING_OVERRIDE_KEYS:
|
||
try:
|
||
out[k] = min(1.0, max(0.0, float(cfg[k])))
|
||
except (KeyError, TypeError, ValueError):
|
||
continue
|
||
return out
|
||
|
||
async def set_cycle_review(
|
||
self,
|
||
cycle_id: str,
|
||
*,
|
||
quality: str | None = None,
|
||
golden: bool | None = None,
|
||
tags: list[str] | None = None,
|
||
notes: str | None = None,
|
||
) -> bool:
|
||
"""Attach an ML-Lab review to a cycle (Stage 4b).
|
||
|
||
The review (``quality``/``golden``/``tags``/``notes``) is stored under the
|
||
cycle's ``ml_review`` key and becomes a strong training label for the
|
||
on-device quality model. Only the provided fields are updated. Returns
|
||
True when the cycle was found and updated.
|
||
"""
|
||
cycle = next(
|
||
(c for c in self._data.get("past_cycles", []) if c.get("id") == cycle_id),
|
||
None,
|
||
)
|
||
if not cycle:
|
||
raise ValueError(f"Cycle {cycle_id} not found")
|
||
prev_review = cycle.get("ml_review")
|
||
prev_golden = bool(prev_review.get("golden")) if isinstance(prev_review, dict) else False
|
||
review = dict(prev_review or {})
|
||
if quality is not None:
|
||
review["quality"] = quality
|
||
if golden is not None:
|
||
review["golden"] = bool(golden)
|
||
if tags is not None:
|
||
review["tags"] = list(tags)
|
||
if notes is not None:
|
||
review["notes"] = notes
|
||
review["reviewed_at"] = dt_util.now().isoformat()
|
||
cycle["ml_review"] = review
|
||
# Golden cycles seed the profile's matching reference/envelope. When the
|
||
# golden flag actually flips (either direction), rebuild the affected
|
||
# profile so its envelope + reference cycle + cohesion cache reflect the
|
||
# new golden set. Only when the value changed — an unchanged save stays
|
||
# idempotent and skips the (executor-bound) rebuild.
|
||
golden_changed = golden is not None and bool(golden) != prev_golden
|
||
profile_name = cycle.get("profile_name")
|
||
if golden_changed and isinstance(profile_name, str) and profile_name:
|
||
await self.async_rebuild_envelope(profile_name)
|
||
await self.async_save()
|
||
# Don't log the full review (notes/tags are user-entered) — only field names.
|
||
_changed = [
|
||
n for n, v in (("quality", quality), ("golden", golden), ("tags", tags), ("notes", notes))
|
||
if v is not None
|
||
]
|
||
self._logger.info(
|
||
"Recorded ML review for cycle %s (updated: %s)", cycle_id, ", ".join(_changed) or "none"
|
||
)
|
||
return True
|
||
|
||
async def async_backfill_recorded_golden(self) -> int:
|
||
"""Flag manually-recorded cycles (meta.source == 'recorder') as golden
|
||
references. Recorded == golden: a single stored flag, no duplicate field.
|
||
Idempotent; saves only when something changed. Returns the count flagged.
|
||
"""
|
||
cycles = self._data.get("past_cycles", [])
|
||
if not isinstance(cycles, list):
|
||
return 0
|
||
# Snapshot each cycle's golden state (aligned by list position, which
|
||
# _flag_recorded_cycles_golden never reorders) so we can rebuild exactly
|
||
# the profiles whose cycles get newly flagged — golden cycles seed the
|
||
# matching reference/envelope.
|
||
before_golden = [
|
||
bool(c.get("ml_review", {}).get("golden"))
|
||
if isinstance(c, dict) and isinstance(c.get("ml_review"), dict)
|
||
else False
|
||
for c in cycles
|
||
]
|
||
flagged = _flag_recorded_cycles_golden(cycles)
|
||
if flagged:
|
||
affected: set[str] = set()
|
||
for was_golden, cycle in zip(before_golden, cycles):
|
||
if was_golden or not isinstance(cycle, dict):
|
||
continue
|
||
review = cycle.get("ml_review")
|
||
if isinstance(review, dict) and review.get("golden"):
|
||
pname = cycle.get("profile_name")
|
||
if isinstance(pname, str) and pname:
|
||
affected.add(pname)
|
||
for pname in affected:
|
||
await self.async_rebuild_envelope(pname)
|
||
await self.async_save()
|
||
self._logger.info("Backfilled golden flag on %s recorded cycle(s)", flagged)
|
||
return flagged
|
||
|
||
def get_feedback_history(self) -> dict[str, dict[str, Any]]:
|
||
"""Return mutable feedback history mapping (cycle_id -> record)."""
|
||
raw = self._data.setdefault("feedback_history", {})
|
||
if isinstance(raw, dict):
|
||
return cast(dict[str, dict[str, Any]], raw)
|
||
return {}
|
||
|
||
def get_pending_feedback(self) -> dict[str, dict[str, Any]]:
|
||
"""Return mutable pending feedback mapping (cycle_id -> request)."""
|
||
raw = self._data.setdefault("pending_feedback", {})
|
||
if isinstance(raw, dict):
|
||
return cast(dict[str, dict[str, Any]], raw)
|
||
return {}
|
||
|
||
def add_pending_feedback(self, cycle_id: str, request_data: dict[str, Any]) -> None:
|
||
"""Add a pending feedback request (sync wrapper, does not save immediately)."""
|
||
feedbacks = self.get_pending_feedback()
|
||
feedbacks[cycle_id] = request_data
|
||
# Caller must ensure save is called eventually
|
||
|
||
def prune_orphaned_feedback(self) -> int:
|
||
"""Drop pending-feedback entries whose cycle no longer exists.
|
||
|
||
A cycle that was deleted/merged/trimmed can leave its pending feedback
|
||
behind, so the device's "needs review" count stays >0 while the Cycles
|
||
review filter (which matches feedback ids against real cycles) shows
|
||
nothing. Returns the number pruned; never raises. Caller saves.
|
||
"""
|
||
try:
|
||
pending = self._data.get("pending_feedback")
|
||
if not isinstance(pending, dict) or not pending:
|
||
return 0
|
||
live_ids = {c.get("id") for c in self.get_past_cycles()}
|
||
orphans = [cid for cid in list(pending) if cid not in live_ids]
|
||
for cid in orphans:
|
||
pending.pop(cid, None)
|
||
return len(orphans)
|
||
except Exception: # noqa: BLE001
|
||
return 0
|
||
|
||
|
||
def get_profile(self, name: str) -> JSONDict | None:
|
||
"""Return a single profile by name with calculated stats (via list_profiles)."""
|
||
# Reuse list_profiles logic to ensure consistency and avoid duplication
|
||
all_profiles = self.list_profiles()
|
||
return next((p for p in all_profiles if p["name"] == name), None)
|
||
|
||
def get_profiles(self) -> dict[str, JSONDict]:
|
||
"""Return mutable profiles mapping (profile_name -> profile data)."""
|
||
raw = self._data.setdefault("profiles", {})
|
||
if isinstance(raw, dict):
|
||
return cast(dict[str, JSONDict], raw)
|
||
return {}
|
||
|
||
def get_past_cycles(self) -> list[CycleDict]:
|
||
"""Return mutable list of stored cycles."""
|
||
raw = self._data.setdefault("past_cycles", [])
|
||
if isinstance(raw, list):
|
||
return cast(list[CycleDict], raw)
|
||
return []
|
||
|
||
# ── Community-store account (connect handoff) ─────────────────────────────
|
||
def get_store_account(self) -> dict[str, Any]:
|
||
"""Full persisted store account incl. the refresh token (credential)."""
|
||
raw = self._data.get("store_account")
|
||
return dict(raw) if isinstance(raw, dict) else {}
|
||
|
||
def get_store_identity(self) -> dict[str, Any]:
|
||
"""Safe account view for status/UI - never includes the refresh token."""
|
||
a = self.get_store_account()
|
||
return {
|
||
"connected": bool(a.get("refresh_token")),
|
||
"uid": a.get("uid"),
|
||
"name": a.get("name"),
|
||
"brand": a.get("brand"),
|
||
"model": a.get("model"),
|
||
}
|
||
|
||
async def set_store_account(self, account: dict[str, Any]) -> None:
|
||
"""Persist/merge the store account (refresh_token, uid, name, brand, model)."""
|
||
cur = self.get_store_account()
|
||
cur.update({k: v for k, v in account.items() if v is not None})
|
||
self._data["store_account"] = cur
|
||
await self.async_save()
|
||
|
||
async def clear_store_account(self) -> None:
|
||
self._data.pop("store_account", None)
|
||
await self.async_save()
|
||
|
||
def get_reference_cycles(self) -> list[CycleDict]:
|
||
"""Return the imported-store reference cycles.
|
||
|
||
These are NOT in ``past_cycles``: they feed only the envelope shape and the
|
||
matcher template, never usage/energy/count/trend stats.
|
||
"""
|
||
raw = self._data.setdefault("reference_cycles", [])
|
||
if isinstance(raw, list):
|
||
return cast(list[CycleDict], raw)
|
||
return []
|
||
|
||
def get_shareable_cycles(self) -> list[dict[str, Any]]:
|
||
"""Recorded/golden reference cycles eligible to share to the community store.
|
||
|
||
A cycle qualifies when it is hand-flagged golden (``ml_review.golden``) or a
|
||
manual recording (``meta.source == "recorder"``) and carries a program label.
|
||
Imported ``reference_cycles`` are intentionally excluded (you do not re-share
|
||
what you downloaded). Returns light summaries (no traces), most-recent-first;
|
||
the whole set (not a page) so the panel's share tree can offer all of them.
|
||
Never raises.
|
||
"""
|
||
out: list[dict[str, Any]] = []
|
||
for c in self.get_past_cycles():
|
||
if not isinstance(c, dict):
|
||
continue
|
||
program = str(c.get("profile_name") or "").strip()
|
||
if not program:
|
||
continue
|
||
review = c.get("ml_review") if isinstance(c.get("ml_review"), dict) else {}
|
||
meta = c.get("meta") if isinstance(c.get("meta"), dict) else {}
|
||
if not (review.get("golden") or meta.get("source") == "recorder"):
|
||
continue
|
||
out.append({
|
||
"id": c.get("id"),
|
||
"profile_name": program,
|
||
"start_time": c.get("start_time"),
|
||
"duration": c.get("duration"),
|
||
"source": meta.get("source"),
|
||
})
|
||
out.sort(key=lambda r: r.get("start_time") or "", reverse=True)
|
||
return out
|
||
|
||
def _add_reference_cycle_nosave(
|
||
self, profile_name: str, points: list[list[float]], meta: dict[str, Any],
|
||
*, id_pool: set[Any] | None = None,
|
||
) -> str:
|
||
"""Validate + append one reference cycle. NO envelope rebuild, NO save.
|
||
|
||
The mutation core shared by ``add_reference_cycle`` (single-cycle: rebuild +
|
||
save after) and ``async_import_data_selective`` (bulk: rebuild each touched
|
||
profile once + a single save at the end). Returns the new cycle id, or ``""``
|
||
when the trace is unusable (mutating nothing).
|
||
"""
|
||
# Validate the trace BEFORE creating any persistent state (profile entry / reference
|
||
# cycle): a garbage trace returns "" and mutates nothing. Same rule the import guard
|
||
# uses to decide whether a replace has anything usable to refill a destination with.
|
||
pairs = _usable_reference_pairs(points)
|
||
if pairs is None:
|
||
return ""
|
||
duration = float(pairs[-1][0] - pairs[0][0])
|
||
# Re-base to offset 0 so envelope reconstruction and DTW work correctly.
|
||
if pairs[0][0] != 0.0:
|
||
origin = pairs[0][0]
|
||
pairs = [[p[0] - origin, p[1]] for p in pairs]
|
||
now = dt_util.now()
|
||
store_id = str(meta.get("store_cycle_id") or "")
|
||
cycle: CycleDict = {
|
||
"profile_name": profile_name,
|
||
"power_data": pairs,
|
||
"start_time": now.isoformat(),
|
||
# Keep the timestamp interval consistent with the imported duration
|
||
# (both were previously "now", giving a zero-length interval).
|
||
"end_time": (now + timedelta(seconds=duration)).isoformat(),
|
||
"duration": duration,
|
||
"status": "completed",
|
||
"ml_review": {"golden": True},
|
||
"meta": {
|
||
"source": f"store:{store_id}" if store_id else "store",
|
||
"store_uploaded_at": meta.get("store_uploaded_at"),
|
||
},
|
||
}
|
||
if meta.get("sampling_interval"):
|
||
# ignore a malformed sampling_interval rather than raise mid-import
|
||
with contextlib.suppress(TypeError, ValueError):
|
||
cycle["sampling_interval"] = float(meta["sampling_interval"])
|
||
# A reference cycle implies its program exists locally; create a minimal profile
|
||
# entry if absent so the matcher iterates it and the rebuild can set its template.
|
||
profiles = self._data.setdefault("profiles", {})
|
||
if profile_name not in profiles:
|
||
profiles[profile_name] = {"avg_duration": duration}
|
||
self._add_cycle_data(cycle, target=self._data.setdefault("reference_cycles", []), id_pool=id_pool)
|
||
return str(cycle.get("id", ""))
|
||
|
||
async def add_reference_cycle(
|
||
self, profile_name: str, points: list[list[float]], meta: dict[str, Any]
|
||
) -> str:
|
||
"""Import a reference cycle downloaded from the store into ``reference_cycles``.
|
||
|
||
``points`` is a raw trace of ``[offset_seconds, watts]`` pairs. ``meta`` may carry
|
||
``store_cycle_id`` (-> ``meta.source = "store:<id>"``), ``store_uploaded_at`` and
|
||
``sampling_interval``. The cycle is stamped with import-time timestamps (its real
|
||
run time is meaningless locally), forced ``status="completed"`` and
|
||
``ml_review.golden=True`` so it seeds the envelope shape, then the envelope is
|
||
rebuilt. Never accumulates lifetime energy or touches ``past_cycles``.
|
||
"""
|
||
cid = self._add_reference_cycle_nosave(profile_name, points, meta)
|
||
if not cid:
|
||
return ""
|
||
await self.async_rebuild_envelope(profile_name)
|
||
await self.async_save()
|
||
return cid
|
||
|
||
# ── Profile groups (Stage 5: near-duplicate variants) ──────────────────────
|
||
|
||
def get_profile_groups(self) -> dict[str, JSONDict]:
|
||
"""Return mutable profile-groups mapping (group_name -> {members, created_at}).
|
||
|
||
A group bundles near-duplicate profiles (e.g. the same program at
|
||
different temperature/spin). The matcher treats the group as one
|
||
aggregate candidate and only picks the exact member once the group wins.
|
||
Groups with fewer than 2 present members are ignored by the matcher.
|
||
"""
|
||
raw = self._data.setdefault("profile_groups", {})
|
||
if not isinstance(raw, dict):
|
||
self._data["profile_groups"] = {}
|
||
return self._data["profile_groups"]
|
||
# Drop members that no longer exist as profiles (kept consistent lazily).
|
||
profiles = self.get_profiles()
|
||
for g in raw.values():
|
||
if isinstance(g, dict) and isinstance(g.get("members"), list):
|
||
g["members"] = [m for m in g["members"] if m in profiles]
|
||
return cast(dict[str, JSONDict], raw)
|
||
|
||
def _members_in_other_groups(self, members: list[str], exclude: str | None) -> dict[str, str]:
|
||
"""Map any member already assigned to a DIFFERENT group -> that group name."""
|
||
owner: dict[str, str] = {}
|
||
for gname, g in self.get_profile_groups().items():
|
||
if gname == exclude or not isinstance(g, dict):
|
||
continue
|
||
for m in (g.get("members") or []):
|
||
owner.setdefault(m, gname)
|
||
return {m: owner[m] for m in members if m in owner}
|
||
|
||
async def create_profile_group(self, name: str, members: list[str]) -> bool:
|
||
"""Create (or overwrite) a group with the given member profile names."""
|
||
name = (name or "").strip()
|
||
if not name:
|
||
raise ValueError("Group name is required")
|
||
profiles = self.get_profiles()
|
||
members = [m for m in dict.fromkeys(members or []) if m in profiles]
|
||
# A profile may belong to at most one group (else _grouped_snapshots would
|
||
# collapse it inconsistently). Reject up front — no partial mutation.
|
||
conflicts = self._members_in_other_groups(members, exclude=name)
|
||
if conflicts:
|
||
raise ValueError(
|
||
"Already in another group: "
|
||
+ ", ".join(f"{m} ({g})" for m, g in conflicts.items())
|
||
)
|
||
groups = self.get_profile_groups()
|
||
groups[name] = {"members": members, "created_at": dt_util.now().isoformat()}
|
||
self._cohesion_cache_generation += 1
|
||
await self.async_save()
|
||
self._logger.info("Created profile group %r with %d members", name, len(members))
|
||
return True
|
||
|
||
async def set_profile_group_members(self, name: str, members: list[str]) -> bool:
|
||
"""Replace a group's member list. Deletes the group if left empty."""
|
||
groups = self.get_profile_groups()
|
||
if name not in groups:
|
||
raise ValueError(f"Group {name} not found")
|
||
profiles = self.get_profiles()
|
||
members = [m for m in dict.fromkeys(members or []) if m in profiles]
|
||
conflicts = self._members_in_other_groups(members, exclude=name)
|
||
if conflicts:
|
||
raise ValueError(
|
||
"Already in another group: "
|
||
+ ", ".join(f"{m} ({g})" for m, g in conflicts.items())
|
||
)
|
||
if members:
|
||
groups[name]["members"] = members
|
||
else:
|
||
groups.pop(name, None)
|
||
self._cohesion_cache_generation += 1
|
||
await self.async_save()
|
||
return True
|
||
|
||
async def rename_profile_group(self, name: str, new_name: str) -> bool:
|
||
groups = self.get_profile_groups()
|
||
new_name = (new_name or "").strip()
|
||
if name not in groups or not new_name:
|
||
raise ValueError("Group not found or new name empty")
|
||
if new_name != name:
|
||
if new_name in groups:
|
||
raise ValueError(f"A group named {new_name!r} already exists")
|
||
groups[new_name] = groups.pop(name)
|
||
self._cohesion_cache_generation += 1
|
||
await self.async_save()
|
||
return True
|
||
|
||
async def delete_profile_group(self, name: str) -> bool:
|
||
groups = self.get_profile_groups()
|
||
if groups.pop(name, None) is None:
|
||
return False
|
||
self._cohesion_cache_generation += 1
|
||
await self.async_save()
|
||
self._logger.info("Deleted profile group %r", name)
|
||
return True
|
||
|
||
def _profile_curve(self, name: str, n: int = 150) -> np.ndarray | None:
|
||
"""A profile's envelope average resampled to n points, or None."""
|
||
env = self._data.get("envelopes", {}).get(name) if isinstance(self._data.get("envelopes"), dict) else None
|
||
avg = env.get("avg") if isinstance(env, dict) else None
|
||
if not avg or not isinstance(avg[0], (list, tuple)):
|
||
return None
|
||
ys = np.asarray([float(p[1]) for p in avg], dtype=float)
|
||
if ys.size < 4:
|
||
return None
|
||
return np.interp(np.linspace(0, 1, n), np.linspace(0, 1, ys.size), ys)
|
||
|
||
@staticmethod
|
||
def _shape_similarity(a: np.ndarray, b: np.ndarray) -> float:
|
||
"""Duration- and amplitude-tolerant shape similarity in (0,1].
|
||
|
||
Peak-normalises both curves (so temperature/spin amplitude differences
|
||
don't count against membership) then aligns them with a Sakoe-Chiba-band
|
||
DTW (so a longer heating or draining phase at a different setting warps
|
||
into alignment instead of being penalised as a different shape). 1.0 =
|
||
identical shape; genuinely different programs score low even after warping.
|
||
"""
|
||
na = a / (float(a.max()) or 1.0)
|
||
nb = b / (float(b.max()) or 1.0)
|
||
dist = analysis.compute_dtw_lite(na, nb, band_width_ratio=0.2)
|
||
norm = dist / max(len(na), 1) # mean per-sample distance on [0,1] curves
|
||
return 1.0 / (1.0 + norm / 0.15)
|
||
|
||
def group_cohesion(self, members: list[str]) -> float:
|
||
"""Minimum pairwise shape similarity among a group's members (1.0 =
|
||
identical shapes; see _shape_similarity for the DTW/peak-normalised
|
||
metric). Low cohesion means the members are not really the same program,
|
||
so their averaged aggregate would be too generic and could out-match
|
||
unrelated profiles - the matcher refuses to aggregate below
|
||
GROUP_MIN_COHESION and the UI warns the user.
|
||
|
||
Results are cached per member-set and invalidated via
|
||
``_cohesion_cache_generation`` (incremented by group mutation methods) so
|
||
the DTW pairwise comparison does not run on the event loop every 5 minutes.
|
||
"""
|
||
key = tuple(sorted(members))
|
||
with self._cohesion_cache_lock:
|
||
if self._cohesion_cache_generation != self._cohesion_cache_generation_checked:
|
||
self._cohesion_cache.clear()
|
||
self._cohesion_cache_generation_checked = self._cohesion_cache_generation
|
||
if key in self._cohesion_cache:
|
||
return self._cohesion_cache[key]
|
||
curves = [c for c in (self._profile_curve(m) for m in members) if c is not None]
|
||
if len(members) < 2:
|
||
# A genuinely single-member group is trivially cohesive (and is never
|
||
# collapsed anyway — nothing to aggregate).
|
||
result = 1.0
|
||
elif len(curves) < 2:
|
||
# Multi-member but too few built curves -> insufficient evidence, treat as
|
||
# NOT cohesive so the group isn't collapsed into a blurry aggregate yet.
|
||
result = 0.0
|
||
else:
|
||
result = 1.0
|
||
for i in range(len(curves)):
|
||
for j in range(i + 1, len(curves)):
|
||
result = min(result, self._shape_similarity(curves[i], curves[j]))
|
||
self._cohesion_cache[key] = result
|
||
return result
|
||
|
||
def _grouped_snapshots(
|
||
self, snapshots: list[dict[str, Any]]
|
||
) -> tuple[list[dict[str, Any]], dict[str, list[str]], dict[str, dict[str, Any]]]:
|
||
"""Collapse each *cohesive* group into one aggregate candidate snapshot.
|
||
|
||
Returns (snapshots, group_members, member_snaps). Groups with <2 present
|
||
members, or cohesion below GROUP_MIN_COHESION (too generic an aggregate),
|
||
are left as individual member snapshots. Aggregate name is prefixed
|
||
``__group__`` and mapped to its member profile names in group_members.
|
||
"""
|
||
groups = self.get_profile_groups()
|
||
if not groups:
|
||
return snapshots, {}, {}
|
||
by_name = {s["name"]: s for s in snapshots}
|
||
member_to_group: dict[str, str] = {}
|
||
for gname, g in groups.items():
|
||
present = [m for m in (g.get("members") or []) if m in by_name]
|
||
if len(present) < 2 or self.group_cohesion(present) < GROUP_MIN_COHESION:
|
||
continue # keep members individual
|
||
for m in present:
|
||
member_to_group[m] = gname
|
||
if not member_to_group:
|
||
return snapshots, {}, {}
|
||
n = 200
|
||
agg_curves: dict[str, list[np.ndarray]] = {}
|
||
agg_durs: dict[str, list[float]] = {}
|
||
member_snaps: dict[str, dict[str, Any]] = {}
|
||
out: list[dict[str, Any]] = []
|
||
for s in snapshots:
|
||
g = member_to_group.get(s["name"])
|
||
if g is None:
|
||
out.append(s)
|
||
continue
|
||
member_snaps[s["name"]] = s
|
||
sp = s.get("sample_power") or []
|
||
if len(sp) >= 2:
|
||
arr = np.asarray(sp, dtype=float)
|
||
agg_curves.setdefault(g, []).append(
|
||
np.interp(np.linspace(0, 1, n), np.linspace(0, 1, arr.size), arr)
|
||
)
|
||
agg_durs.setdefault(g, []).append(float(s.get("avg_duration") or 0.0))
|
||
group_members: dict[str, list[str]] = {}
|
||
for g, curves in agg_curves.items():
|
||
key = f"__group__{g}"
|
||
durs = [d for d in agg_durs[g] if d > 0]
|
||
out.append({
|
||
"name": key,
|
||
"avg_duration": float(np.mean(durs)) if durs else 0.0,
|
||
"sample_power": np.mean(np.array(curves), axis=0).tolist(),
|
||
})
|
||
group_members[key] = [m for m in member_to_group if member_to_group[m] == g and m in member_snaps]
|
||
return out, group_members, member_snaps
|
||
|
||
def _stage5_pick_member(
|
||
self, current_power: list[float], current_duration: float,
|
||
members: list[str], member_snaps: dict[str, dict[str, Any]],
|
||
) -> tuple[str, float | None, float | None]:
|
||
"""Within a winning group, pick the member whose duration + mean power +
|
||
peak best match the cycle (temperature -> mean power, spin -> peak).
|
||
Returns (member_name, individual_fit_score, member_avg_duration). The fit
|
||
score is the chosen member's own alignment score, used as a sanity check."""
|
||
cur = np.asarray(current_power, dtype=float)
|
||
if cur.size == 0 or not members:
|
||
return (members[0] if members else ""), None, None
|
||
cur_mp = float(cur.mean()); cur_pk = float(cur.max())
|
||
|
||
def agree(a: float, b: float, scale: float) -> float:
|
||
if a <= 0 or b <= 0:
|
||
return 0.0
|
||
return 1.0 / (1.0 + abs(math.log(a / b)) / scale)
|
||
|
||
best_m, best_sc, best_dur = members[0], -1.0, None
|
||
for m in members:
|
||
snap = member_snaps.get(m)
|
||
if not snap:
|
||
continue
|
||
sp = np.asarray(snap.get("sample_power") or [], dtype=float)
|
||
if sp.size == 0:
|
||
continue
|
||
md = float(snap.get("avg_duration") or 0.0)
|
||
sc = (agree(current_duration, md, 0.15)
|
||
* agree(cur_mp, float(sp.mean()), 0.20)
|
||
* agree(cur_pk, float(sp.max()), 0.20))
|
||
if sc > best_sc:
|
||
best_sc, best_m, best_dur = sc, m, md
|
||
fit = None
|
||
snap = member_snaps.get(best_m)
|
||
if snap and snap.get("sample_power"):
|
||
try:
|
||
fit = float(analysis.find_best_alignment(current_power, snap["sample_power"], 1.0)[0])
|
||
except Exception: # pylint: disable=broad-exception-caught
|
||
fit = None
|
||
return best_m, fit, best_dur
|
||
|
||
def suggest_profile_groups(self, dur_tol: float = 0.60, sim_min: float = 0.85) -> list[dict[str, Any]]:
|
||
"""Detect clusters of near-duplicate profiles not already fully grouped.
|
||
|
||
Two profiles cluster when their durations are within a (loose) ``dur_tol``
|
||
sanity bound AND their DTW/peak-normalised shape similarity exceeds
|
||
``sim_min`` (same program shape; they may differ in temperature/spin and in
|
||
phase length, which the DTW alignment tolerates). The duration bound is
|
||
loose because higher-temp/lower-rpm variants legitimately run longer - it
|
||
only rules out grouping, say, a 20-min rinse with a 3-hour cotton.
|
||
Returns {"members": [...], "existing_group": name|None} suggestions the
|
||
user confirms. Never mutates state.
|
||
"""
|
||
profiles = self.get_profiles()
|
||
envelopes = self._data.get("envelopes", {})
|
||
# Build per-profile (avg curve resampled to N, avg duration).
|
||
N = 150
|
||
reps: dict[str, tuple[np.ndarray, float]] = {}
|
||
for name, prof in profiles.items():
|
||
env = envelopes.get(name) if isinstance(envelopes, dict) else None
|
||
avg = env.get("avg") if isinstance(env, dict) else None
|
||
dur = float(prof.get("avg_duration") or 0.0)
|
||
if not avg or dur <= 0 or not isinstance(avg[0], (list, tuple)):
|
||
continue
|
||
ys = np.asarray([float(p[1]) for p in avg], dtype=float)
|
||
if ys.size < 4:
|
||
continue
|
||
curve = np.interp(np.linspace(0, 1, N), np.linspace(0, 1, ys.size), ys)
|
||
reps[name] = (curve, dur)
|
||
names = list(reps)
|
||
# Union-find near-duplicates.
|
||
parent = {n: n for n in names}
|
||
def find(x: str) -> str:
|
||
while parent[x] != x:
|
||
parent[x] = parent[parent[x]]; x = parent[x]
|
||
return x
|
||
lim = math.log(1.0 + dur_tol)
|
||
for i in range(len(names)):
|
||
for j in range(i + 1, len(names)):
|
||
a, b = names[i], names[j]
|
||
(ca, da), (cb, db) = reps[a], reps[b]
|
||
if da <= 0 or db <= 0 or abs(math.log(da / db)) > lim:
|
||
continue
|
||
if self._shape_similarity(ca, cb) > sim_min:
|
||
parent[find(a)] = find(b)
|
||
clusters: dict[str, list[str]] = {}
|
||
for n in names:
|
||
clusters.setdefault(find(n), []).append(n)
|
||
# Only clusters of >=2; annotate with any existing group overlap; skip
|
||
# clusters already fully contained in one group.
|
||
out: list[dict[str, Any]] = []
|
||
groups = self.get_profile_groups()
|
||
for members in clusters.values():
|
||
if len(members) < 2:
|
||
continue
|
||
existing = None
|
||
for gname, g in groups.items():
|
||
gmembers = set(g.get("members") or [])
|
||
if gmembers & set(members):
|
||
existing = gname
|
||
if set(members) <= gmembers:
|
||
members = [] # already grouped
|
||
break
|
||
if members:
|
||
out.append({"members": sorted(members), "existing_group": existing})
|
||
return out
|
||
|
||
# ------------------------------------------------------------------
|
||
# A1: Underrun anomaly helpers
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_profile_median_duration(self, profile_name: str) -> float | None:
|
||
"""Median cycle duration (s) for this profile across all labeled cycles. Never raises."""
|
||
try:
|
||
durations = [
|
||
float(c["duration"])
|
||
for c in self.get_past_cycles()
|
||
if c.get("profile_name") == profile_name and c.get("duration")
|
||
]
|
||
return float(np.median(durations)) if len(durations) >= 2 else None
|
||
except Exception: # noqa: BLE001
|
||
return None
|
||
|
||
# ------------------------------------------------------------------
|
||
# A2: Energy anomaly helpers
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_profile_energy_stats(self, profile_name: str) -> dict[str, float] | None:
|
||
"""Energy stats {avg_wh, std_wh, n} for this profile. None if fewer than 3 cycles. Never raises."""
|
||
try:
|
||
energies = [
|
||
float(c["energy_wh"])
|
||
for c in self.get_past_cycles()
|
||
if c.get("profile_name") == profile_name and c.get("energy_wh")
|
||
]
|
||
if len(energies) < 3:
|
||
return None
|
||
arr = np.asarray(energies, dtype=float)
|
||
return {"avg_wh": float(np.mean(arr)), "std_wh": float(np.std(arr)), "n": len(energies)}
|
||
except Exception: # noqa: BLE001
|
||
return None
|
||
|
||
# ------------------------------------------------------------------
|
||
# A4: Profile warm-up mode helper
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_profile_labeled_count(self, profile_name: str) -> int:
|
||
"""Number of labeled cycles for this profile. Never raises."""
|
||
try:
|
||
return sum(1 for c in self.get_past_cycles() if c.get("profile_name") == profile_name)
|
||
except Exception: # noqa: BLE001
|
||
return 0
|
||
|
||
def profile_has_reference_cycles(self, profile_name: str) -> bool:
|
||
"""True if this profile was seeded from imported store reference cycle(s).
|
||
|
||
Such a profile is a trusted, community-shared template that the user
|
||
downloaded to match immediately, so it is exempt from the local warm-up
|
||
gate (which exists to stabilise profiles built from a few local cycles).
|
||
"""
|
||
try:
|
||
return any(
|
||
c.get("profile_name") == profile_name
|
||
for c in self.get_reference_cycles()
|
||
)
|
||
except Exception: # noqa: BLE001
|
||
return False
|
||
|
||
# ------------------------------------------------------------------
|
||
# B1: Lifetime energy accumulator (HA Energy dashboard)
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_lifetime_energy_wh(self) -> float:
|
||
"""Total accumulated lifetime energy (Wh). Pure getter — never persists. Never raises."""
|
||
try:
|
||
return float(self._data.get("lifetime_energy_wh", 0.0))
|
||
except Exception: # noqa: BLE001
|
||
return 0.0
|
||
|
||
async def async_add_lifetime_energy_wh(self, wh: float) -> None:
|
||
"""Add *wh* (clamped >= 0) to the lifetime energy total and persist.
|
||
|
||
Called exactly once per completed cycle so it never double-counts. Does
|
||
not backfill from history — the meter starts at 0 and accumulates forward.
|
||
Ignores non-numeric input.
|
||
"""
|
||
try:
|
||
add = max(0.0, float(wh))
|
||
except (ValueError, TypeError):
|
||
return
|
||
base = self.get_lifetime_energy_wh()
|
||
self._data["lifetime_energy_wh"] = round(base + add, 3)
|
||
await self.async_save()
|
||
|
||
def get_lifetime_cycle_count(self) -> int:
|
||
"""Persisted monotonic lifetime completed-cycle count.
|
||
|
||
Only ever increments (never regresses when history is trimmed/merged), so it
|
||
is the correct basis for milestone crossings. Pure getter - never persists.
|
||
Never raises; returns 0 when unset.
|
||
"""
|
||
try:
|
||
return int(self._data.get("lifetime_cycle_count", 0) or 0)
|
||
except Exception: # noqa: BLE001
|
||
return 0
|
||
|
||
def set_lifetime_cycle_count(self, count: int) -> None:
|
||
"""Set the lifetime cycle count in memory WITHOUT persisting.
|
||
|
||
The cycle-end path batches this with the immediately-following lifetime
|
||
energy save (:meth:`async_add_lifetime_energy_wh`) so the store is written
|
||
once per cycle. Encapsulates the storage key so callers need not touch
|
||
``_data`` directly. Ignores non-integer input.
|
||
"""
|
||
try:
|
||
self._data["lifetime_cycle_count"] = int(count)
|
||
except (TypeError, ValueError):
|
||
pass
|
||
|
||
# ------------------------------------------------------------------
|
||
# E1: Maintenance log & predictive-maintenance reminders (Group E)
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_maintenance_log(self) -> list[dict[str, Any]]:
|
||
"""Return logged maintenance events, most-recent-first. Never raises.
|
||
|
||
Each entry is ``{"id", "date", "event_type", "notes"}``. Entries with an
|
||
unparseable date sort last.
|
||
"""
|
||
try:
|
||
raw = self._data.get("maintenance_log", [])
|
||
if not isinstance(raw, list):
|
||
return []
|
||
entries = [dict(e) for e in raw if isinstance(e, dict)]
|
||
_floor = datetime.min.replace(tzinfo=dt_util.UTC)
|
||
entries.sort(
|
||
key=lambda e: _parse_maintenance_dt(e.get("date")) or _floor,
|
||
reverse=True,
|
||
)
|
||
return entries
|
||
except Exception: # noqa: BLE001
|
||
return []
|
||
|
||
async def async_add_maintenance_event(
|
||
self, event_type: str, date: str | None = None, notes: str = ""
|
||
) -> dict[str, Any]:
|
||
"""Append a maintenance event and persist. Returns the created entry.
|
||
|
||
``event_type`` must be one of :data:`MAINTENANCE_EVENT_TYPES` (else raises
|
||
``ValueError``). ``date`` defaults to the current timestamp; a short unique
|
||
id is generated for the entry.
|
||
"""
|
||
if event_type not in MAINTENANCE_EVENT_TYPES:
|
||
raise ValueError(f"Unknown maintenance event_type: {event_type!r}")
|
||
entry: dict[str, Any] = {
|
||
"id": uuid.uuid4().hex[:12],
|
||
"date": date if isinstance(date, str) and date else dt_util.now().isoformat(),
|
||
"event_type": event_type,
|
||
"notes": str(notes or ""),
|
||
}
|
||
log = self._data.setdefault("maintenance_log", [])
|
||
if not isinstance(log, list):
|
||
log = []
|
||
self._data["maintenance_log"] = log
|
||
log.append(entry)
|
||
await self.async_save()
|
||
return entry
|
||
|
||
async def async_delete_maintenance_event(self, event_id: str) -> bool:
|
||
"""Remove a maintenance event by id, persist, and report whether removed."""
|
||
log = self._data.get("maintenance_log")
|
||
if not isinstance(log, list):
|
||
return False
|
||
remaining = [e for e in log if not (isinstance(e, dict) and e.get("id") == event_id)]
|
||
if len(remaining) == len(log):
|
||
return False
|
||
self._data["maintenance_log"] = remaining
|
||
await self.async_save()
|
||
return True
|
||
|
||
def cycles_since_maintenance(self, event_type: str) -> int:
|
||
"""Count completed cycles since the most recent maintenance event of a type.
|
||
|
||
Counts completed cycles (``status == "completed"``) whose ``start_time`` is
|
||
after the most recent maintenance event of ``event_type``. If no such event
|
||
was ever logged, returns the total completed-cycle count. Never raises.
|
||
"""
|
||
try:
|
||
completed = [
|
||
c for c in self.get_past_cycles()
|
||
if isinstance(c, dict) and c.get("status") == "completed"
|
||
]
|
||
latest_dt: datetime | None = None
|
||
for e in self._data.get("maintenance_log", []) or []:
|
||
if not isinstance(e, dict) or e.get("event_type") != event_type:
|
||
continue
|
||
dt = _parse_maintenance_dt(e.get("date"))
|
||
if dt is not None and (latest_dt is None or dt > latest_dt):
|
||
latest_dt = dt
|
||
if latest_dt is None:
|
||
return len(completed)
|
||
count = 0
|
||
for c in completed:
|
||
start = _parse_start_dt(c.get("start_time"))
|
||
if start is not None and start > latest_dt:
|
||
count += 1
|
||
return count
|
||
except Exception: # noqa: BLE001
|
||
return 0
|
||
|
||
def get_maintenance_due(self, reminder_cfg: dict[str, Any] | None) -> list[str]:
|
||
"""Return event types whose cycles-since threshold has been reached.
|
||
|
||
For each event type with a positive integer threshold in ``reminder_cfg``,
|
||
includes it when ``cycles_since_maintenance(event_type) >= threshold``.
|
||
Never raises.
|
||
"""
|
||
try:
|
||
if not isinstance(reminder_cfg, dict):
|
||
return []
|
||
due: list[str] = []
|
||
for event_type, threshold in reminder_cfg.items():
|
||
try:
|
||
thr = int(threshold)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if thr <= 0:
|
||
continue
|
||
if self.cycles_since_maintenance(str(event_type)) >= thr:
|
||
due.append(str(event_type))
|
||
return due
|
||
except Exception: # noqa: BLE001
|
||
return []
|
||
|
||
def has_recent_maintenance(
|
||
self, event_type: str, days: int = MAINTENANCE_RECENT_SUPPRESS_DAYS
|
||
) -> bool:
|
||
"""True if a matching maintenance event was logged within ``days``. Never raises."""
|
||
try:
|
||
cutoff = dt_util.now() - timedelta(days=max(0, int(days)))
|
||
for e in self._data.get("maintenance_log", []) or []:
|
||
if not isinstance(e, dict) or e.get("event_type") != event_type:
|
||
continue
|
||
dt = _parse_maintenance_dt(e.get("date"))
|
||
if dt is not None and dt >= cutoff:
|
||
return True
|
||
return False
|
||
except Exception: # noqa: BLE001
|
||
return False
|
||
|
||
def compute_profile_health(self) -> dict[str, dict[str, Any]]:
|
||
"""Compute per-profile health indicators from labeled cycle history.
|
||
|
||
For each profile that has at least 3 labeled cycles, returns a dict with:
|
||
``cycle_count`` – number of labeled cycles used in the calculation
|
||
``confidence_mean`` – mean match_confidence across those cycles (0–1)
|
||
``duration_cv`` – coefficient of variation of cycle durations (lower = consistent)
|
||
``health_score`` – composite 0–1 health score (1 = healthy, 0 = needs attention)
|
||
``health_status`` – "healthy" / "fair" / "poor" / "unknown"
|
||
``shape_drift`` – True when the early/recent envelope correlation is below
|
||
SHAPE_DRIFT_THRESHOLD (only present when >= SHAPE_DRIFT_MIN_CYCLES
|
||
labeled cycles with power_data exist)
|
||
``shape_drift_correlation`` – Pearson r between early and recent average envelopes
|
||
|
||
Profiles with fewer than 3 labeled cycles return ``health_status="unknown"``.
|
||
Never raises — returns an empty dict on any error.
|
||
"""
|
||
try:
|
||
cycles = self.get_past_cycles()
|
||
from collections import defaultdict # pylint: disable=import-outside-toplevel
|
||
by_profile: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||
for c in cycles:
|
||
name = c.get("profile_name")
|
||
if name:
|
||
by_profile[str(name)].append(c)
|
||
|
||
result: dict[str, dict[str, Any]] = {}
|
||
for name, pcy in by_profile.items():
|
||
count = len(pcy)
|
||
durations = [float(c["duration"]) for c in pcy if c.get("duration")]
|
||
confidences = [
|
||
float(c["match_confidence"])
|
||
for c in pcy
|
||
if c.get("match_confidence") is not None # keep genuine 0.0, drop only absent
|
||
]
|
||
|
||
if count < 3 or not durations:
|
||
result[name] = {"cycle_count": count, "health_status": "unknown"}
|
||
continue
|
||
|
||
dur_arr = np.asarray(durations, dtype=float)
|
||
dur_mean = float(np.mean(dur_arr))
|
||
dur_cv = float(np.std(dur_arr) / dur_mean) if dur_mean > 0 else 0.0
|
||
|
||
conf_mean = float(np.mean(confidences)) if confidences else 0.5
|
||
|
||
# consistency: 1 at CV=0, linearly decays to 0 at CV=0.5+
|
||
consistency = max(0.0, 1.0 - dur_cv / 0.5)
|
||
health_score = round(0.5 * consistency + 0.5 * conf_mean, 3)
|
||
|
||
if health_score >= 0.65:
|
||
status = "healthy"
|
||
elif health_score >= 0.40:
|
||
status = "fair"
|
||
else:
|
||
status = "poor"
|
||
|
||
# A5: Shape drift — compare early vs recent power curve envelopes.
|
||
_sd: dict[str, Any] = {}
|
||
_traced = [c for c in pcy if c.get("power_data")]
|
||
if len(_traced) >= SHAPE_DRIFT_MIN_CYCLES:
|
||
_third = len(_traced) // 3
|
||
_early = _traced[:_third]
|
||
_recent = _traced[-_third:]
|
||
try:
|
||
from .signal_processing import resample_to_n as _resamp # noqa: PLC0415
|
||
|
||
def _avg_env(cycles: list[dict[str, Any]]) -> "np.ndarray | None":
|
||
_traces = []
|
||
for _c in cycles:
|
||
_raw = decompress_power_data(_c)
|
||
if not _raw:
|
||
continue
|
||
_pwr = [float(_p) for _, _p in _raw]
|
||
if len(_pwr) < 5:
|
||
continue
|
||
_t = np.asarray(_resamp(_pwr, SHAPE_DRIFT_RESAMPLE_N), dtype=float)
|
||
_mx = _t.max()
|
||
if _mx > 0:
|
||
_t = _t / _mx
|
||
_traces.append(_t)
|
||
if len(_traces) < 2:
|
||
return None
|
||
return np.mean(np.stack(_traces), axis=0)
|
||
|
||
_early_env = _avg_env(_early)
|
||
_rec_env = _avg_env(_recent)
|
||
if _early_env is not None and _rec_env is not None:
|
||
_corr = float(np.corrcoef(_early_env, _rec_env)[0, 1])
|
||
if not np.isfinite(_corr):
|
||
_corr = 1.0
|
||
_sd = {
|
||
"shape_drift": _corr < SHAPE_DRIFT_THRESHOLD,
|
||
"shape_drift_correlation": round(_corr, 3),
|
||
}
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
result[name] = {
|
||
"cycle_count": count,
|
||
"confidence_mean": round(conf_mean, 3),
|
||
"duration_cv": round(dur_cv, 3),
|
||
"health_score": health_score,
|
||
"health_status": status,
|
||
**_sd, # merges shape_drift and shape_drift_correlation when available
|
||
}
|
||
return result
|
||
except Exception: # noqa: BLE001
|
||
return {}
|
||
|
||
# ------------------------------------------------------------------
|
||
# Coverage gap detection (unmatched cycle clustering)
|
||
# ------------------------------------------------------------------
|
||
|
||
def suggest_coverage_gaps(
|
||
self,
|
||
recent_window: int = 30,
|
||
min_unmatched: int = 5,
|
||
min_unmatched_rate: float = 0.20,
|
||
low_confidence_threshold: float = 0.40,
|
||
duration_bucket_s: float = 900.0,
|
||
) -> dict[str, Any]:
|
||
"""Detect gaps in profile coverage from recent unlabelled cycles.
|
||
|
||
Looks at the ``recent_window`` most recent cycles. When there are
|
||
enough unmatched (no ``profile_name``) or low-confidence cycles,
|
||
returns a record with:
|
||
|
||
``unmatched_count`` – number of cycles with no profile label
|
||
``low_confidence_count`` – number of labelled cycles below the confidence
|
||
threshold (potential mis-matches)
|
||
``unmatched_rate`` – fraction of recent cycles that are unmatched
|
||
``suggest_create`` – True when unmatched_count >= min_unmatched
|
||
AND unmatched_rate >= min_unmatched_rate
|
||
``duration_clusters`` – list of duration-cluster dicts for unmatched
|
||
cycles: ``{"duration_bucket_min": int,
|
||
"count": int, "avg_duration_s": float}``
|
||
sorted descending by count. Only clusters
|
||
with ≥ 2 members are returned.
|
||
|
||
Returns an empty dict when there are fewer than ``min_unmatched``
|
||
unmatched cycles or on any error (never raises).
|
||
"""
|
||
try:
|
||
cycles = self.get_past_cycles()
|
||
recent = cycles[-recent_window:] if len(cycles) > recent_window else cycles
|
||
if not recent:
|
||
return {}
|
||
|
||
unmatched: list[dict[str, Any]] = []
|
||
low_conf: list[dict[str, Any]] = []
|
||
for c in recent:
|
||
name = c.get("profile_name")
|
||
if not name:
|
||
unmatched.append(c)
|
||
else:
|
||
conf = c.get("match_confidence")
|
||
if (
|
||
isinstance(conf, (int, float))
|
||
and not isinstance(conf, bool)
|
||
and conf < low_confidence_threshold
|
||
):
|
||
low_conf.append(c)
|
||
|
||
n_unmatched = len(unmatched)
|
||
n_total = len(recent)
|
||
rate = n_unmatched / n_total if n_total > 0 else 0.0
|
||
|
||
if n_unmatched < min_unmatched:
|
||
return {}
|
||
|
||
# Cluster unmatched cycles by duration bucket (simple histogram)
|
||
bucket_dur: dict[int, list[float]] = {}
|
||
for c in unmatched:
|
||
dur = c.get("duration")
|
||
if isinstance(dur, (int, float)) and not isinstance(dur, bool) and dur > 0:
|
||
bucket = int(dur // duration_bucket_s)
|
||
bucket_dur.setdefault(bucket, []).append(float(dur))
|
||
|
||
clusters = []
|
||
for bucket, durs in sorted(bucket_dur.items(), key=lambda kv: -len(kv[1])):
|
||
if len(durs) >= 2:
|
||
clusters.append({
|
||
"duration_bucket_min": int(bucket * duration_bucket_s / 60),
|
||
"count": len(durs),
|
||
"avg_duration_s": round(float(np.mean(durs)), 1),
|
||
})
|
||
|
||
# A3: Shape-similarity clustering within duration buckets
|
||
profile_suggestions: list[dict[str, Any]] = []
|
||
for bucket, durs in sorted(bucket_dur.items(), key=lambda kv: -len(kv[1])):
|
||
if len(durs) < 2:
|
||
continue
|
||
bucket_cycles = [
|
||
c for c in unmatched
|
||
if c.get("power_data")
|
||
and c.get("duration")
|
||
and int(float(c["duration"]) // duration_bucket_s) == bucket
|
||
]
|
||
if len(bucket_cycles) < 2:
|
||
continue
|
||
try:
|
||
from .signal_processing import resample_to_n # noqa: PLC0415
|
||
traces: list[np.ndarray] = []
|
||
ids: list[str] = []
|
||
for c in bucket_cycles[:5]: # cap at 5 per bucket for performance
|
||
raw = decompress_power_data(c)
|
||
if not raw:
|
||
continue
|
||
pwr = [float(p) for _, p in raw]
|
||
if len(pwr) < 5:
|
||
continue
|
||
t = np.asarray(resample_to_n(pwr, CLUSTER_RESAMPLE_N), dtype=float)
|
||
mx = t.max()
|
||
if mx > 0:
|
||
t = t / mx
|
||
traces.append(t)
|
||
ids.append(str(c.get("id", "")))
|
||
if len(traces) < 2:
|
||
continue
|
||
corrs = [
|
||
float(np.corrcoef(traces[i], traces[j])[0, 1])
|
||
for i in range(len(traces))
|
||
for j in range(i + 1, len(traces))
|
||
]
|
||
valid_corrs = [r for r in corrs if np.isfinite(r)]
|
||
if not valid_corrs:
|
||
continue
|
||
avg_corr = float(np.mean(valid_corrs))
|
||
if avg_corr >= CLUSTER_SHAPE_SIMILARITY_THRESHOLD:
|
||
avg_dur_s = float(np.mean(durs))
|
||
profile_suggestions.append({
|
||
"suggested_name": f"~{int(avg_dur_s // 60)} min program",
|
||
"cycle_ids": [cid for cid in ids if cid],
|
||
"avg_duration_s": round(avg_dur_s, 1),
|
||
"count": len(ids),
|
||
"similarity": round(avg_corr, 3),
|
||
})
|
||
except Exception: # noqa: BLE001
|
||
continue
|
||
|
||
# Most-recent unmatched cycle id, so the setup advisor's phase-2
|
||
# "unmatched" nudge can deep-link straight to it (open_cycle:<id>)
|
||
# instead of always falling back to the whole unlabelled list.
|
||
last_unmatched_cycle_id = None
|
||
for c in reversed(unmatched):
|
||
cid = c.get("id")
|
||
if cid:
|
||
last_unmatched_cycle_id = cid
|
||
break
|
||
|
||
return {
|
||
"unmatched_count": n_unmatched,
|
||
"low_confidence_count": len(low_conf),
|
||
"unmatched_rate": round(rate, 3),
|
||
"suggest_create": bool(n_unmatched >= min_unmatched and rate >= min_unmatched_rate),
|
||
"duration_clusters": clusters,
|
||
"profile_suggestions": profile_suggestions, # NEW (A3)
|
||
"last_unmatched_cycle_id": last_unmatched_cycle_id,
|
||
}
|
||
except Exception: # noqa: BLE001
|
||
return {}
|
||
|
||
# ------------------------------------------------------------------
|
||
# Profile trend analysis (pure stats, appliance performance drift)
|
||
# ------------------------------------------------------------------
|
||
|
||
def compute_profile_trends(
|
||
self,
|
||
min_cycles: int = 12,
|
||
recent_window: int = 8,
|
||
slope_threshold_pct: float = 0.08,
|
||
) -> dict[str, dict[str, Any]]:
|
||
"""Detect per-profile duration / energy drift using ordinary least-squares.
|
||
|
||
Uses all labeled cycles in chronological order. For each profile with at
|
||
least ``min_cycles`` labeled cycles:
|
||
|
||
* Fits a linear trend (OLS via NumPy) to the most-recent N=``len(cycles)``
|
||
values (not just the window — the window is used only for the *recent
|
||
mean* display; the slope is computed on all data so short histories don't
|
||
give noisy slopes).
|
||
* Expresses the slope as percent-change-per-cycle relative to the mean,
|
||
so the magnitude is comparable across low- and high-duration profiles.
|
||
* Classifies as ``"up"`` / ``"down"`` / ``"stable"`` per metric when the
|
||
normalized slope exceeds ``slope_threshold_pct`` (default 8%/cycle).
|
||
|
||
Returns a dict mapping profile_name → trend record:
|
||
``duration_trend`` – "up" / "down" / "stable"
|
||
``duration_slope_pct`` – normalized slope (%/cycle)
|
||
``duration_recent_mean_s`` – mean duration of the last N cycles (s)
|
||
``energy_trend`` – "up" / "down" / "stable" (if available)
|
||
``energy_slope_pct`` – normalized slope (%/cycle)
|
||
``energy_recent_mean_wh`` – mean energy of the last N cycles (Wh)
|
||
``cycle_count`` – number of labeled cycles used
|
||
``recent_window`` – how many cycles the recent means cover
|
||
|
||
Profiles with fewer than ``min_cycles`` labeled cycles are omitted.
|
||
Never raises — returns ``{}`` on any error.
|
||
"""
|
||
try:
|
||
cycles = self.get_past_cycles()
|
||
from collections import defaultdict # pylint: disable=import-outside-toplevel
|
||
by_profile: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||
for c in cycles:
|
||
name = c.get("profile_name")
|
||
if name:
|
||
by_profile[str(name)].append(c)
|
||
|
||
result: dict[str, dict[str, Any]] = {}
|
||
for name, pcy in by_profile.items():
|
||
if len(pcy) < min_cycles:
|
||
continue
|
||
# Preserve insertion order (chronological from get_past_cycles)
|
||
durations = [float(c["duration"]) for c in pcy if c.get("duration")]
|
||
energies = [float(c["energy_wh"]) for c in pcy if c.get("energy_wh")]
|
||
|
||
if len(durations) < min_cycles:
|
||
continue
|
||
|
||
def _slope_pct(values: list[float]) -> float:
|
||
"""OLS slope as % of mean per cycle."""
|
||
arr = np.asarray(values, dtype=float)
|
||
n = len(arr)
|
||
x = np.arange(n, dtype=float)
|
||
x -= x.mean()
|
||
denom = float(np.dot(x, x))
|
||
if denom < 1e-9:
|
||
return 0.0
|
||
slope = float(np.dot(x, arr)) / denom
|
||
mean_val = float(np.mean(arr))
|
||
return (slope / mean_val) if abs(mean_val) > 1e-9 else 0.0
|
||
|
||
def _classify(slope_pct: float) -> str:
|
||
if slope_pct > slope_threshold_pct:
|
||
return "up"
|
||
if slope_pct < -slope_threshold_pct:
|
||
return "down"
|
||
return "stable"
|
||
|
||
dur_slope = _slope_pct(durations)
|
||
dur_recent = float(np.mean(durations[-recent_window:]))
|
||
|
||
rec: dict[str, Any] = {
|
||
"cycle_count": len(pcy),
|
||
"recent_window": min(recent_window, len(durations)),
|
||
"duration_trend": _classify(dur_slope),
|
||
"duration_slope_pct": round(dur_slope * 100, 2),
|
||
"duration_recent_mean_s": round(dur_recent, 1),
|
||
}
|
||
if len(energies) >= min_cycles:
|
||
en_slope = _slope_pct(energies)
|
||
en_recent = float(np.mean(energies[-recent_window:]))
|
||
rec["energy_trend"] = _classify(en_slope)
|
||
rec["energy_slope_pct"] = round(en_slope * 100, 2)
|
||
rec["energy_recent_mean_wh"] = round(en_recent, 1)
|
||
result[name] = rec
|
||
return result
|
||
except Exception: # noqa: BLE001
|
||
return {}
|
||
|
||
def compute_profile_advisories(self) -> list[dict[str, Any]]:
|
||
"""Actionable per-profile maintenance advisories from existing signals.
|
||
|
||
Pure statistics (no ML): consolidates :meth:`compute_profile_health` and
|
||
:meth:`compute_profile_trends` into a small ranked list of human-readable,
|
||
actionable recommendations (e.g. "durations trending longer -> re-record").
|
||
These surface as recommendations in the Profiles tab - never a
|
||
notification. Each item is ``{profile, severity, code, message}``; severity
|
||
is ``"warning"`` or ``"info"``. Returns ``[]`` on error (never raises).
|
||
"""
|
||
try:
|
||
advisories: list[dict[str, Any]] = []
|
||
health = self.compute_profile_health() or {}
|
||
trends = self.compute_profile_trends() or {}
|
||
|
||
for name, h in health.items():
|
||
if h.get("health_status") == "poor":
|
||
advisories.append({
|
||
"profile": name, "severity": "warning", "code": "poor_health",
|
||
"message": (
|
||
f"'{name}' has a low fit score - its recent cycles vary "
|
||
"a lot or match weakly. Review its cycles or re-record "
|
||
"the profile so matching and time estimates stay accurate."
|
||
),
|
||
# Localization: panel renders _t(message_key, message_params,
|
||
# message). The English `message` above is the fallback.
|
||
"message_key": "msg.advisory_poor_health",
|
||
"message_params": {"name": name},
|
||
})
|
||
elif h.get("shape_drift"):
|
||
corr = h.get("shape_drift_correlation")
|
||
corr_str = f" (correlation {corr:.2f})" if corr is not None else ""
|
||
advisories.append({
|
||
"profile": name, "severity": "info", "code": "shape_drift",
|
||
"message": (
|
||
f"'{name}' has drifted significantly from its original "
|
||
f"power shape{corr_str}. The appliance may have changed "
|
||
"behaviour over time (e.g. limescale, wear). Consider "
|
||
"re-recording this profile with recent cycles."
|
||
),
|
||
"message_key": (
|
||
"msg.advisory_shape_drift_corr"
|
||
if corr is not None
|
||
else "msg.advisory_shape_drift"
|
||
),
|
||
"message_params": (
|
||
{"name": name, "corr": f"{corr:.2f}"}
|
||
if corr is not None
|
||
else {"name": name}
|
||
),
|
||
})
|
||
|
||
for name, t in trends.items():
|
||
# Skip profiles already flagged poor to avoid double advice.
|
||
if health.get(name, {}).get("health_status") == "poor":
|
||
continue
|
||
if t.get("duration_trend") == "up":
|
||
# duration_slope_pct is already expressed as %/cycle by
|
||
# compute_profile_trends (dur_slope * 100); do NOT re-scale.
|
||
pct = abs(float(t.get("duration_slope_pct") or 0.0))
|
||
advisories.append({
|
||
"profile": name, "severity": "info", "code": "duration_trend_up",
|
||
"message": (
|
||
f"'{name}' cycles are running progressively longer "
|
||
f"(about +{pct:.0f}% per cycle). If the appliance's "
|
||
"behaviour changed, re-record or rebuild this profile."
|
||
),
|
||
"message_key": "msg.advisory_duration_trend_up",
|
||
"message_params": {"name": name, "pct": f"{pct:.0f}"},
|
||
})
|
||
elif t.get("energy_trend") == "up":
|
||
# energy_slope_pct is already %/cycle (en_slope * 100); no re-scale.
|
||
pct = abs(float(t.get("energy_slope_pct") or 0.0))
|
||
advisories.append({
|
||
"profile": name, "severity": "info", "code": "energy_trend_up",
|
||
"message": (
|
||
f"'{name}' is drawing progressively more energy "
|
||
f"(about +{pct:.0f}% per cycle) - worth checking the "
|
||
"appliance if that is unexpected."
|
||
),
|
||
"message_key": "msg.advisory_energy_trend_up",
|
||
"message_params": {"name": name, "pct": f"{pct:.0f}"},
|
||
})
|
||
|
||
# Phase-structure consistency (phase-matching device types only). Uses
|
||
# the cached per-role phase profile: a profile whose member cycles heat
|
||
# for wildly different times, or where only some cycles heat at all,
|
||
# most likely mixes different programs/temperatures under one label -
|
||
# which hurts both matching and the phase-resolved ETA. Advisory only
|
||
# (Profiles tab); no relabeling (phase matching does not label better).
|
||
_sd = getattr(self, "_data", None)
|
||
_envs = _sd.get("envelopes") if isinstance(_sd, dict) else None
|
||
for pname, penv in (_envs if isinstance(_envs, dict) else {}).items():
|
||
if health.get(pname, {}).get("health_status") == "poor":
|
||
continue # avoid double advice
|
||
pp = penv.get("phase_profile") if isinstance(penv, dict) else None
|
||
if not isinstance(pp, dict):
|
||
continue
|
||
try:
|
||
if int(pp.get("n_cycles") or 0) < PHASE_CONSISTENCY_MIN_CYCLES:
|
||
continue
|
||
heat = (pp.get("roles") or {}).get("heating") or {}
|
||
heat_mean = float(heat.get("dur_mean") or 0.0)
|
||
heat_std = float(heat.get("dur_std") or 0.0)
|
||
heat_occ = float(heat.get("occurrence") or 0.0)
|
||
heat_cv = (heat_std / heat_mean) if heat_mean > 60.0 else 0.0
|
||
mixed_temp = heat_cv > PHASE_HEAT_CV_WARN
|
||
mixed_prog = PHASE_HEAT_OCC_MIXED_LO <= heat_occ <= PHASE_HEAT_OCC_MIXED_HI
|
||
if not (mixed_temp or mixed_prog):
|
||
continue
|
||
advisories.append({
|
||
"profile": pname, "severity": "warning",
|
||
"code": "phase_inconsistent",
|
||
"message": (
|
||
f"'{pname}' looks like it mixes different programs or "
|
||
"temperatures - its cycles heat for very different lengths "
|
||
"of time. Splitting it into separate profiles (e.g. per "
|
||
"temperature) will improve matching and time estimates."
|
||
),
|
||
"message_key": "msg.advisory_phase_inconsistent",
|
||
"message_params": {"name": pname},
|
||
})
|
||
except (TypeError, ValueError):
|
||
continue
|
||
|
||
# E1: suppress the "needs maintenance" nag (duration-trending-longer /
|
||
# shape-drift/poor-fit) when the user recently logged a descale, filter
|
||
# clean, or drum clean — any recent maintenance clears the reminder.
|
||
try:
|
||
# ``is True`` (not truthiness) so a mocked store whose method
|
||
# returns a MagicMock does not accidentally suppress advisories.
|
||
if any(
|
||
self.has_recent_maintenance(evt) is True
|
||
for evt in ("descale", "filter_clean", "drum_clean")
|
||
):
|
||
_nag_codes = {"duration_trend_up", "poor_health", "shape_drift"}
|
||
advisories = [a for a in advisories if a.get("code") not in _nag_codes]
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
order = {"warning": 0, "info": 1}
|
||
advisories.sort(key=lambda a: order.get(a["severity"], 2))
|
||
return advisories
|
||
except Exception: # noqa: BLE001
|
||
return []
|
||
|
||
# ------------------------------------------------------------------
|
||
# Match ranking history (per-cycle ML commit snapshots)
|
||
# ------------------------------------------------------------------
|
||
|
||
def record_match_ranking_snapshot(
|
||
self,
|
||
start_time_iso: str,
|
||
features: dict[str, float],
|
||
top1_profile: str,
|
||
top1_score: float,
|
||
top2_score: float | None,
|
||
candidate_count: int,
|
||
cycle_id: str = "",
|
||
) -> None:
|
||
"""Append a live-match ranking snapshot for the active cycle.
|
||
|
||
Snapshots are keyed by ``start_time_iso`` (and optionally ``cycle_id``) so
|
||
that ``confirm_match_ranking_snapshots`` can back-fill the confirmed label
|
||
when the cycle ends. Pre-computed feature scalars are stored (not raw traces)
|
||
to keep footprint small. The store is NOT persisted here — the caller must
|
||
schedule ``async_save``.
|
||
"""
|
||
from .const import MATCH_RANKING_HISTORY_MAX # pylint: disable=import-outside-toplevel
|
||
history: list[dict[str, Any]] = self._data.setdefault("match_ranking_history", [])
|
||
history.append({
|
||
"start_time_iso": start_time_iso,
|
||
"cycle_id": str(cycle_id) if cycle_id else "",
|
||
"features": dict(features),
|
||
"top1_profile": str(top1_profile),
|
||
"top1_score": round(float(top1_score), 4),
|
||
"top2_score": round(float(top2_score), 4) if top2_score is not None else None,
|
||
"candidate_count": int(candidate_count),
|
||
"confirmed_label": None,
|
||
})
|
||
# Trim to the most recent N snapshots.
|
||
if len(history) > MATCH_RANKING_HISTORY_MAX:
|
||
del history[: len(history) - MATCH_RANKING_HISTORY_MAX]
|
||
|
||
def confirm_match_ranking_snapshots(
|
||
self,
|
||
start_time_iso: str,
|
||
confirmed_label: str,
|
||
cycle_id: str = "",
|
||
) -> int:
|
||
"""Back-fill the confirmed label for all snapshots belonging to a cycle.
|
||
|
||
When ``cycle_id`` is provided (and the snapshot was recorded with one), matches
|
||
by ``cycle_id`` to avoid cross-contamination between cycles that share the same
|
||
second-resolution ``start_time_iso``. Falls back to ``start_time_iso`` matching
|
||
for snapshots recorded without a cycle_id (backward compatibility).
|
||
Returns the number of snapshots updated.
|
||
"""
|
||
history = self._data.get("match_ranking_history")
|
||
if not isinstance(history, list):
|
||
return 0
|
||
updated = 0
|
||
for snap in history:
|
||
if not isinstance(snap, dict):
|
||
continue
|
||
snap_cid = snap.get("cycle_id") or ""
|
||
if cycle_id and snap_cid:
|
||
# Both sides have an ID — match by ID for precision.
|
||
if snap_cid == cycle_id:
|
||
snap["confirmed_label"] = str(confirmed_label)
|
||
updated += 1
|
||
else:
|
||
# Legacy path: match by timestamp (no cycle_id on one or both sides).
|
||
if snap.get("start_time_iso") == start_time_iso:
|
||
snap["confirmed_label"] = str(confirmed_label)
|
||
updated += 1
|
||
return updated
|
||
|
||
def get_match_ranking_history(self) -> list[dict[str, Any]]:
|
||
"""Return all ranking snapshots (confirmed and pending).
|
||
|
||
Callers that build a training dataset should filter to snapshots where
|
||
``confirmed_label`` is not None.
|
||
"""
|
||
raw = self._data.get("match_ranking_history")
|
||
return list(raw) if isinstance(raw, list) else []
|
||
|
||
def _get_shared_custom_phases(self) -> list[dict[str, Any]]:
|
||
"""Return mutable shared custom phase list with legacy flattening."""
|
||
raw = self._data.setdefault("custom_phases", [])
|
||
if isinstance(raw, list):
|
||
return cast(list[dict[str, Any]], raw)
|
||
|
||
# Legacy format: {device_type: [phase, ...]}. Flatten to shared list.
|
||
flattened: list[dict[str, Any]] = []
|
||
seen: set[tuple[str, str]] = set()
|
||
if isinstance(raw, dict):
|
||
raw_dict = cast(dict[str, Any], raw)
|
||
for legacy_device_type, phase_list in raw_dict.items():
|
||
if not isinstance(phase_list, list):
|
||
continue
|
||
for item in cast(list[Any], phase_list):
|
||
if not isinstance(item, dict):
|
||
continue
|
||
item_dict = cast(dict[str, Any], item)
|
||
name = str(item_dict.get("name", "")).strip()
|
||
if not name:
|
||
continue
|
||
device_type = str(legacy_device_type or "").strip()
|
||
key = (name.casefold(), device_type.casefold())
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
flattened.append(
|
||
{
|
||
"name": name,
|
||
"description": str(item_dict.get("description", "")).strip(),
|
||
"device_type": device_type,
|
||
"created_at": item_dict.get("created_at") or dt_util.now().isoformat(),
|
||
}
|
||
)
|
||
|
||
self._data["custom_phases"] = flattened
|
||
return self._data["custom_phases"]
|
||
|
||
def list_custom_phases(self, device_type: str) -> list[dict[str, Any]]:
|
||
"""Return shared custom phases relevant to the requested device type."""
|
||
|
||
def applies_to_device(item_device_type: str, target_device_type: str) -> bool:
|
||
if not item_device_type:
|
||
return True
|
||
return item_device_type == target_device_type
|
||
|
||
target = str(device_type or "").strip()
|
||
phases = self._get_shared_custom_phases()
|
||
return [
|
||
{
|
||
"id": str(p.get("id", "")),
|
||
"name": str(p.get("name", "")).strip(),
|
||
"description": str(p.get("description", "")).strip(),
|
||
"device_type": str(p.get("device_type", "")).strip(),
|
||
"is_default": False,
|
||
}
|
||
for p in phases
|
||
if p.get("name")
|
||
and applies_to_device(str(p.get("device_type", "")).strip(), target)
|
||
]
|
||
|
||
def list_phase_catalog(self, device_type: str) -> list[dict[str, Any]]:
|
||
"""Return merged shared default + custom phase catalog."""
|
||
return merge_phase_catalog(device_type, self.list_custom_phases(device_type))
|
||
|
||
async def async_create_custom_phase(
|
||
self,
|
||
device_type: str,
|
||
phase_name: str,
|
||
description: str = "",
|
||
) -> None:
|
||
"""Create a custom phase in the shared catalog."""
|
||
target_device_type = str(device_type or "").strip()
|
||
name = normalize_phase_name(phase_name)
|
||
desc = str(description or "").strip()
|
||
catalog = self.list_phase_catalog(target_device_type)
|
||
if any(str(p.get("name", "")).casefold() == name.casefold() for p in catalog):
|
||
raise ValueError("duplicate_phase")
|
||
|
||
self._get_shared_custom_phases().append(
|
||
{
|
||
"id": str(uuid.uuid4()),
|
||
"name": name,
|
||
"description": desc,
|
||
"device_type": target_device_type,
|
||
"created_at": dt_util.now().isoformat(),
|
||
}
|
||
)
|
||
await self.async_save()
|
||
|
||
async def async_update_custom_phase(
|
||
self,
|
||
phase_id: str,
|
||
new_name: str,
|
||
description: str = "",
|
||
) -> None:
|
||
"""Update a phase by id, propagating rename to profile assignments.
|
||
|
||
If phase_id matches a built-in, a custom override is created using the
|
||
built-in's id so the merge can replace it in-place.
|
||
"""
|
||
target_name = normalize_phase_name(new_name)
|
||
desc = str(description or "").strip()
|
||
phases = self._get_shared_custom_phases()
|
||
|
||
# Look for an existing custom entry with this id.
|
||
found: dict[str, Any] | None = next(
|
||
(p for p in phases if str(p.get("id", "")) == phase_id), None
|
||
)
|
||
creating_new = found is None
|
||
|
||
if creating_new:
|
||
builtin = get_builtin_phase_by_id(phase_id)
|
||
if builtin is None:
|
||
raise ValueError("phase_not_found")
|
||
candidate: dict[str, Any] = {
|
||
"id": phase_id,
|
||
"name": str(builtin.get("name", "")),
|
||
"description": "",
|
||
"device_type": str(builtin.get("device_type", "")),
|
||
"created_at": dt_util.now().isoformat(),
|
||
}
|
||
else:
|
||
candidate = found # type: ignore[assignment]
|
||
|
||
old_name = str(candidate.get("name", ""))
|
||
target_device_type = str(candidate.get("device_type", "")).strip()
|
||
|
||
# Duplicate-name check before any mutation.
|
||
for p in self.list_phase_catalog(target_device_type):
|
||
pname = str(p.get("name", ""))
|
||
if pname.casefold() == target_name.casefold() and pname.casefold() != old_name.casefold():
|
||
raise ValueError("duplicate_phase")
|
||
|
||
if creating_new:
|
||
phases.append(candidate)
|
||
found = candidate
|
||
|
||
found["name"] = target_name # type: ignore[index]
|
||
found["description"] = desc # type: ignore[index]
|
||
|
||
# Propagate rename to profile assignments.
|
||
for profile in self.get_profiles().values():
|
||
profile_device_type = str(profile.get("device_type", "")).strip()
|
||
if target_device_type and profile_device_type != target_device_type:
|
||
continue
|
||
phases_assigned = profile.get("phases", [])
|
||
if not isinstance(phases_assigned, list):
|
||
continue
|
||
for assigned in cast(list[dict[str, Any]], phases_assigned):
|
||
if str(assigned.get("name", "")).casefold() == old_name.casefold():
|
||
assigned["name"] = target_name
|
||
|
||
await self.async_save()
|
||
|
||
async def async_delete_custom_phase(self, phase_id: str) -> int:
|
||
"""Delete a custom phase by id and remove matching profile assignments.
|
||
|
||
Returns number of removed assignments.
|
||
Raises ValueError('phase_not_found') if no custom phase has this id.
|
||
Raises ValueError('cannot_delete_builtin') if the id is a built-in phase.
|
||
"""
|
||
phases = self._get_shared_custom_phases()
|
||
found = next((p for p in phases if str(p.get("id", "")) == phase_id), None)
|
||
if found is None:
|
||
raise ValueError("phase_not_found")
|
||
if get_builtin_phase_by_id(phase_id) is not None:
|
||
raise ValueError("cannot_delete_builtin")
|
||
|
||
phase_name = str(found.get("name", ""))
|
||
phase_scope = str(found.get("device_type", "")).strip()
|
||
self._data["custom_phases"] = [p for p in phases if str(p.get("id", "")) != phase_id]
|
||
|
||
removed_assignments = 0
|
||
for profile in self.get_profiles().values():
|
||
profile_device_type = str(profile.get("device_type", "")).strip()
|
||
if phase_scope and profile_device_type != phase_scope:
|
||
continue
|
||
assigned = profile.get("phases", [])
|
||
if not isinstance(assigned, list):
|
||
continue
|
||
assigned_list = cast(list[dict[str, Any]], assigned)
|
||
before = len(assigned_list)
|
||
profile["phases"] = [
|
||
p for p in assigned_list
|
||
if str(p.get("name", "")).casefold() != phase_name.casefold()
|
||
]
|
||
removed_assignments += before - len(profile["phases"])
|
||
|
||
await self.async_save()
|
||
return removed_assignments
|
||
|
||
def get_profile_phase_ranges(self, profile_name: str) -> list[dict[str, Any]]:
|
||
"""Return assigned phase ranges for a profile."""
|
||
profiles = self._data.get("profiles", {})
|
||
if not isinstance(profiles, dict):
|
||
return []
|
||
profile_raw = cast(dict[str, Any], profiles).get(profile_name)
|
||
profile = cast(dict[str, Any], profile_raw) if isinstance(profile_raw, dict) else None
|
||
if not isinstance(profile, dict):
|
||
return []
|
||
phases = profile.get("phases", [])
|
||
if not isinstance(phases, list):
|
||
return []
|
||
phases_list = cast(list[dict[str, Any]], phases)
|
||
cleaned: list[dict[str, Any]] = []
|
||
for phase in phases_list:
|
||
try:
|
||
start = float(phase.get("start", 0.0))
|
||
end = float(phase.get("end", 0.0))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if end <= start:
|
||
continue
|
||
cleaned.append(
|
||
{
|
||
"name": str(phase.get("name", "")).strip(),
|
||
"start": start,
|
||
"end": end,
|
||
"description": str(phase.get("description", "")).strip(),
|
||
}
|
||
)
|
||
return sorted(cleaned, key=lambda x: (x["start"], x["end"], x["name"]))
|
||
|
||
def get_profile_phase_ranges_for_device(
|
||
self, profile_name: str, device_type: str
|
||
) -> list[dict[str, Any]]:
|
||
"""Return assigned ranges enriched with catalog descriptions."""
|
||
ranges = self.get_profile_phase_ranges(profile_name)
|
||
catalog = self.list_phase_catalog(device_type)
|
||
desc_map = {
|
||
str(p.get("name", "")).casefold(): str(p.get("description", "")).strip()
|
||
for p in catalog
|
||
}
|
||
enriched: list[dict[str, Any]] = []
|
||
for row in ranges:
|
||
name = str(row.get("name", "")).strip()
|
||
enriched.append(
|
||
{
|
||
"name": name,
|
||
"start": float(row.get("start", 0.0)),
|
||
"end": float(row.get("end", 0.0)),
|
||
"description": desc_map.get(name.casefold(), ""),
|
||
}
|
||
)
|
||
return enriched
|
||
|
||
async def async_set_profile_phase_ranges(
|
||
self, profile_name: str, ranges: list[dict[str, Any]]
|
||
) -> None:
|
||
"""Replace assigned phase ranges for a profile."""
|
||
profile = self._data.get("profiles", {}).get(profile_name)
|
||
if not isinstance(profile, dict):
|
||
raise ValueError("profile_not_found")
|
||
|
||
normalized: list[dict[str, Any]] = []
|
||
for item in ranges:
|
||
name = normalize_phase_name(str(item.get("name", "")))
|
||
try:
|
||
start = float(item.get("start", 0.0))
|
||
end = float(item.get("end", 0.0))
|
||
except (TypeError, ValueError) as e:
|
||
raise ValueError("invalid_phase_range") from e
|
||
if end <= start:
|
||
raise ValueError("invalid_phase_range")
|
||
normalized.append({"name": name, "start": start, "end": end})
|
||
|
||
normalized.sort(key=lambda x: (x["start"], x["end"]))
|
||
prev_end = None
|
||
for row in normalized:
|
||
if prev_end is not None and row["start"] < prev_end:
|
||
raise ValueError("overlapping_phase_ranges")
|
||
prev_end = row["end"]
|
||
|
||
profile["phases"] = normalized
|
||
await self.async_save()
|
||
|
||
def set_duration_tolerance(self, tolerance: float) -> None:
|
||
"""Set the profile duration tolerance used by matching heuristics."""
|
||
try:
|
||
self._duration_tolerance = float(tolerance)
|
||
except (TypeError, ValueError):
|
||
pass
|
||
|
||
def set_retention_limits(
|
||
self,
|
||
*,
|
||
max_past_cycles: int,
|
||
max_full_traces_per_profile: int,
|
||
max_full_traces_unlabeled: int,
|
||
) -> None:
|
||
"""Set retention caps for stored cycles and full-resolution traces."""
|
||
try:
|
||
self._max_past_cycles = int(max_past_cycles)
|
||
self._max_full_traces_per_profile = int(max_full_traces_per_profile)
|
||
self._max_full_traces_unlabeled = int(max_full_traces_unlabeled)
|
||
except (TypeError, ValueError):
|
||
pass
|
||
|
||
def get_duration_ratio_limits(self) -> tuple[float, float]:
|
||
"""Return (min_duration_ratio, max_duration_ratio) used for duration matching."""
|
||
return (float(self._min_duration_ratio), float(self._max_duration_ratio))
|
||
|
||
def set_duration_ratio_limits(self, *, min_ratio: float, max_ratio: float) -> None:
|
||
"""Update duration ratio bounds used for duration matching."""
|
||
try:
|
||
self._min_duration_ratio = float(min_ratio)
|
||
self._max_duration_ratio = float(max_ratio)
|
||
except (TypeError, ValueError):
|
||
pass
|
||
|
||
def _migrate_phase_ids(self) -> bool:
|
||
"""Assign ids to any custom phase missing one. Returns True if anything changed."""
|
||
phases = self._data.get("custom_phases", [])
|
||
if not isinstance(phases, list):
|
||
return False
|
||
changed = False
|
||
for phase in cast(list[dict[str, Any]], phases):
|
||
if phase.get("id"):
|
||
continue
|
||
dt = str(phase.get("device_type", "")).strip()
|
||
name = str(phase.get("name", "")).strip()
|
||
matched_id: str | None = None
|
||
for bdt, bphases in DEFAULT_PHASES_BY_DEVICE.items():
|
||
if dt and bdt != dt:
|
||
continue
|
||
for bp in bphases:
|
||
if str(bp.get("name", "")).strip().casefold() == name.casefold():
|
||
matched_id = _builtin_phase_id(bdt, str(bp.get("name", "")))
|
||
break
|
||
if matched_id:
|
||
break
|
||
phase["id"] = matched_id if matched_id else str(uuid.uuid4())
|
||
changed = True
|
||
return changed
|
||
|
||
async def async_load(self) -> None:
|
||
"""Load data from storage with migration."""
|
||
# WashDataStore handles migration internally via _async_migrate_func
|
||
data = await self._store.async_load()
|
||
if data:
|
||
self._data = data
|
||
# Ensure legacy custom phase formats are normalized in-memory.
|
||
self._get_shared_custom_phases()
|
||
# Assign ids to any custom phase missing one.
|
||
if self._migrate_phase_ids():
|
||
await self.async_save()
|
||
# Repair cycles whose power_data was corrupted by the double-subtract bug.
|
||
if self.repair_corrupted_power_data():
|
||
await self.async_save()
|
||
await self.async_rebuild_all_envelopes()
|
||
await self.async_save()
|
||
# Prune pending feedback whose cycle no longer exists, so the device's
|
||
# "needs review" count can't disagree with the Cycles review filter.
|
||
if self.prune_orphaned_feedback():
|
||
await self.async_save()
|
||
|
||
# _migrate_v1_to_v2 and _decompress_power_from_raw removed; logic moved to WashDataStore
|
||
|
||
async def async_repair_profile_samples(self) -> dict[str, int]:
|
||
"""Repair profile sample references after retention or migrations.
|
||
|
||
Ensures each profile's sample_cycle_id points to an existing cycle that still
|
||
has full-resolution power_data. If missing, picks the newest available cycle
|
||
with power_data and assigns it as the sample (and labels that cycle to the
|
||
profile if it was unlabeled).
|
||
|
||
Returns stats dict.
|
||
"""
|
||
stats = {
|
||
"profiles_checked": 0,
|
||
"profiles_repaired": 0,
|
||
"cycles_labeled_as_sample": 0,
|
||
}
|
||
|
||
profiles: dict[str, dict[str, Any]] = self._data.get("profiles", {}) or {}
|
||
cycles: list[dict[str, Any]] = self._data.get("past_cycles", []) or []
|
||
ref_cycles: list[dict[str, Any]] = self._data.get("reference_cycles", []) or []
|
||
if not profiles or not cycles:
|
||
return stats
|
||
|
||
# Sample validity must recognise imported reference cycles: an import-only
|
||
# profile legitimately points its sample at a reference cycle. Without this,
|
||
# such a sample looks "missing" and the repair below would steal an unrelated
|
||
# unlabeled real cycle into the imported profile.
|
||
by_id: dict[str, dict[str, Any]] = {
|
||
c["id"]: c for c in list(cycles) + list(ref_cycles) if c.get("id")
|
||
}
|
||
|
||
def newest_unlabeled_with_power_data() -> dict[str, Any] | None:
|
||
candidates: list[dict[str, Any]] = [
|
||
c for c in cycles if c.get("power_data") and not c.get("profile_name")
|
||
]
|
||
if not candidates:
|
||
return None
|
||
try:
|
||
return max(candidates, key=lambda c: c.get("start_time", ""))
|
||
except Exception: # pylint: disable=broad-exception-caught
|
||
return candidates[-1]
|
||
|
||
for profile_name, profile in profiles.items():
|
||
stats["profiles_checked"] += 1
|
||
sample_id = profile.get("sample_cycle_id")
|
||
sample = by_id.get(sample_id) if sample_id else None
|
||
|
||
# Sample is valid only if it exists and still has power_data
|
||
if sample and sample.get("power_data"):
|
||
continue
|
||
|
||
# Prefer newest already-labeled cycle for this profile that still has power_data
|
||
labeled_candidates = [
|
||
c
|
||
for c in cycles
|
||
if c.get("profile_name") == profile_name and c.get("power_data")
|
||
]
|
||
if labeled_candidates:
|
||
try:
|
||
chosen = max(
|
||
labeled_candidates, key=lambda c: c.get("start_time", "")
|
||
)
|
||
except Exception: # pylint: disable=broad-exception-caught
|
||
chosen = labeled_candidates[-1]
|
||
else:
|
||
# Fallback: pick newest UNLABELED cycle with power_data
|
||
chosen = newest_unlabeled_with_power_data()
|
||
|
||
if not chosen:
|
||
continue
|
||
|
||
profile["sample_cycle_id"] = chosen.get("id")
|
||
if chosen.get("duration"):
|
||
profile["avg_duration"] = chosen["duration"]
|
||
|
||
# If chosen cycle is unlabeled, label it to this profile to bootstrap matching
|
||
if not chosen.get("profile_name"):
|
||
chosen["profile_name"] = profile_name
|
||
stats["cycles_labeled_as_sample"] += 1
|
||
|
||
stats["profiles_repaired"] += 1
|
||
try:
|
||
await self.async_rebuild_envelope(profile_name)
|
||
except Exception: # pylint: disable=broad-exception-caught
|
||
pass
|
||
|
||
return stats
|
||
|
||
async def async_save(self) -> None:
|
||
"""Save data to storage."""
|
||
await self._store.async_save(self._data)
|
||
|
||
async def async_save_active_cycle(self, detector_snapshot: JSONDict) -> None:
|
||
"""Save the active cycle state to storage (throttled by Manager)."""
|
||
self._data["active_cycle"] = detector_snapshot
|
||
self._data["last_active_save"] = dt_util.now().isoformat()
|
||
await self._store.async_save(self._data)
|
||
|
||
def get_active_cycle(self) -> JSONDict | None:
|
||
"""Get the saved active cycle."""
|
||
raw = self._data.get("active_cycle")
|
||
if isinstance(raw, dict):
|
||
return cast(JSONDict, raw)
|
||
return None
|
||
|
||
def get_last_active_save(self) -> datetime | None:
|
||
"""Return the last time the active cycle snapshot was persisted."""
|
||
raw = self._data.get("last_active_save")
|
||
if not isinstance(raw, str) or not raw:
|
||
return None
|
||
try:
|
||
return dt_util.parse_datetime(raw)
|
||
except ValueError:
|
||
return None
|
||
|
||
async def async_clear_active_cycle(self) -> None:
|
||
"""Clear the active cycle snapshot from storage."""
|
||
if "active_cycle" in self._data:
|
||
del self._data["active_cycle"]
|
||
await self._store.async_save(self._data)
|
||
|
||
def add_cycle(self, cycle_data: CycleDict) -> None:
|
||
"""Add a completed cycle to history (sync wrapper, schedules async tasks)."""
|
||
self._add_cycle_data(cycle_data)
|
||
self.hass.async_create_task(self.async_enforce_retention())
|
||
|
||
async def async_add_cycle(self, cycle_data: CycleDict) -> None:
|
||
"""Add a completed cycle to history asynchronously."""
|
||
self._add_cycle_data(cycle_data)
|
||
await self.async_enforce_retention()
|
||
|
||
def _add_cycle_data(
|
||
self,
|
||
cycle_data: CycleDict,
|
||
target: list[CycleDict] | None = None,
|
||
*,
|
||
id_pool: set[Any] | None = None,
|
||
) -> None:
|
||
"""Internal logic to add cycle data to storage.
|
||
|
||
``target`` defaults to ``past_cycles``; ``add_reference_cycle`` passes the
|
||
separate ``reference_cycles`` list so imported cycles never enter usage stats.
|
||
|
||
``id_pool`` lets a bulk caller (e.g. selective import) hand in a reusable set of
|
||
already-assigned ids so the SHA-id uniqueness check stays O(1) amortized instead
|
||
of rebuilding the id set from the growing destination list on every call (which is
|
||
O(N^2) over a large import and would block the event loop). When supplied it is
|
||
mutated in place with the newly-minted id.
|
||
"""
|
||
dest = self._data["past_cycles"] if target is None else target
|
||
# Generate SHA256 ID — dedup suffix avoids collisions when two cycles share
|
||
# an identical raw start_time + duration (e.g. bulk reference-cycle imports).
|
||
existing_ids = id_pool if id_pool is not None else {c.get("id") for c in dest if isinstance(c, dict)}
|
||
unique_str = f"{cycle_data['start_time']}_{cycle_data['duration']}"
|
||
candidate = hashlib.sha256(unique_str.encode()).hexdigest()[:12]
|
||
suffix = 0
|
||
while candidate in existing_ids:
|
||
suffix += 1
|
||
candidate = hashlib.sha256(f"{unique_str}_{suffix}".encode()).hexdigest()[:12]
|
||
cycle_data["id"] = candidate
|
||
if id_pool is not None:
|
||
id_pool.add(candidate)
|
||
|
||
# Preserve profile_name if already set by manager; default to None otherwise
|
||
if "profile_name" not in cycle_data:
|
||
cycle_data["profile_name"] = None # Initially unknown
|
||
|
||
# Store power data at native sampling resolution
|
||
# Format: [seconds_offset, power] preserves actual sample rate from device
|
||
# (e.g., 3s intervals from test socket, 60s intervals from real socket)
|
||
raw_data: list[Any] = cycle_data.get("power_data", []) or []
|
||
self._logger.debug("add_cycle: raw_data has %s points", len(raw_data))
|
||
|
||
if raw_data:
|
||
start_time_raw = cycle_data.get("start_time")
|
||
start_time_iso: str | None = None
|
||
if start_time_raw is not None:
|
||
parsed_dt = _parse_start_dt(start_time_raw)
|
||
if parsed_dt is not None:
|
||
start_time_iso = parsed_dt.isoformat()
|
||
# Keep original ISO string as-is if it was already a valid ISO string
|
||
if isinstance(start_time_raw, str) and dt_util.parse_datetime(start_time_raw) is not None:
|
||
start_time_iso = start_time_raw
|
||
else:
|
||
try:
|
||
ts = float(start_time_raw)
|
||
start_time_iso = dt_util.utc_from_timestamp(ts).isoformat()
|
||
except (ValueError, OSError):
|
||
self._logger.debug(
|
||
"add_cycle: unparseable string start_time %r, falling back",
|
||
start_time_raw,
|
||
)
|
||
elif isinstance(start_time_raw, datetime):
|
||
start_time_iso = start_time_raw.isoformat()
|
||
elif isinstance(start_time_raw, (int, float)):
|
||
try:
|
||
start_time_iso = dt_util.utc_from_timestamp(float(start_time_raw)).isoformat()
|
||
except (ValueError, OSError):
|
||
pass
|
||
if start_time_iso is not None:
|
||
cycle_data["start_time"] = start_time_iso
|
||
if start_time_iso is None and _value_to_timestamp(start_time_raw) is None:
|
||
self._logger.debug("add_cycle: invalid start_time %r, skipping power_data normalization", start_time_raw)
|
||
if hasattr(self, "_save_debug_traces") and not self._save_debug_traces:
|
||
cycle_data.pop("debug_data", None)
|
||
dest.append(cycle_data)
|
||
return
|
||
|
||
# Use unified normalizer: handles offset, ISO-string, and datetime formats
|
||
pairs = power_data_to_offsets(
|
||
cast(list[list[Any] | tuple[Any, ...]], raw_data), start_time_iso
|
||
)
|
||
stored: list[list[float]] = [[round(p[0], 1), round(p[1], 1)] for p in pairs]
|
||
offsets: list[float] = [p[0] for p in stored]
|
||
|
||
# Calculate average sampling interval (in seconds)
|
||
if len(offsets) > 1:
|
||
intervals = np.diff(offsets)
|
||
positive_intervals = intervals[intervals > 0]
|
||
sampling_interval = float(np.median(positive_intervals)) if positive_intervals.size > 0 else 0.0
|
||
else:
|
||
sampling_interval = 1.0 # Default fallback
|
||
|
||
# Trim leading/trailing zero readings for cleaner data
|
||
# SKIP for completed cycles to preserve end spike data
|
||
if cycle_data.get("status") in ("completed", "force_stopped"):
|
||
# Only trim leading zeros for completed cycles, keep trailing data
|
||
start_idx = 0
|
||
for i, point in enumerate(stored):
|
||
if point[1] > 1.0:
|
||
start_idx = i
|
||
break
|
||
stored = stored[start_idx:]
|
||
self._logger.debug("add_cycle: Skipping trailing trim for completed cycle")
|
||
else:
|
||
stored = trim_zero_power_data(stored, threshold=1.0)
|
||
|
||
cycle_data["power_data"] = stored
|
||
cycle_data["sampling_interval"] = round(sampling_interval, 1)
|
||
|
||
# Helper to get arrays for signature (use stored data for consistency)
|
||
ts_arr = np.array([t for t, _ in stored])
|
||
p_arr = np.array([p for _, p in stored])
|
||
|
||
# Compute and store signature
|
||
if len(ts_arr) > 1 and len(ts_arr) == len(p_arr):
|
||
sig = compute_signature(ts_arr, p_arr)
|
||
cycle_data["signature"] = dataclasses.asdict(sig)
|
||
|
||
# Compute and store energy (Wh) if not already set (e.g. by manager).
|
||
# Shared trapezoidal integrator + data-driven outage gap (single source
|
||
# with manager._on_cycle_end).
|
||
if "energy_wh" not in cycle_data and len(ts_arr) > 1:
|
||
sort_idx = np.argsort(ts_arr)
|
||
ts_s = ts_arr[sort_idx]
|
||
p_s = p_arr[sort_idx]
|
||
cycle_data["energy_wh"] = round(
|
||
integrate_wh(ts_s, p_s, max_gap_s=energy_gap_threshold_s(ts_s)), 3
|
||
)
|
||
|
||
self._logger.debug(
|
||
"add_cycle: stored %s samples at %.1fs intervals",
|
||
len(stored),
|
||
sampling_interval,
|
||
)
|
||
|
||
# 4. Handle Debug Data (Strip if not enabled)
|
||
if hasattr(self, "_save_debug_traces") and not self._save_debug_traces:
|
||
if "debug_data" in cycle_data:
|
||
del cycle_data["debug_data"]
|
||
|
||
dest.append(cycle_data)
|
||
# Apply retention after adding
|
||
|
||
|
||
async def async_enforce_retention(self) -> None:
|
||
"""Apply retention policy asynchronously."""
|
||
affected = self._enforce_retention_data()
|
||
for p in affected:
|
||
try:
|
||
# Use async rebuild task
|
||
self.hass.async_create_task(self.async_rebuild_envelope(p))
|
||
except Exception as e: # pylint: disable=broad-exception-caught
|
||
self._logger.warning("Failed to schedule envelope rebuild for %s: %s", p, e)
|
||
|
||
def _enforce_retention_data(self) -> set[str]:
|
||
"""Internal retention logic (data operations only).
|
||
Returns set of affected profile names."""
|
||
raw_cycles = self._data.get("past_cycles", [])
|
||
cycles: list[CycleDict] = (
|
||
cast(list[CycleDict], raw_cycles) if isinstance(raw_cycles, list) else []
|
||
)
|
||
if not cycles:
|
||
return set()
|
||
|
||
def _start_time(cycle: CycleDict) -> str:
|
||
return str(cycle.get("start_time", ""))
|
||
|
||
affected_profiles: set[str] = set()
|
||
|
||
# 1) Cap total cycles
|
||
if len(cycles) > self._max_past_cycles:
|
||
# Sort by start_time and drop oldest beyond cap
|
||
try:
|
||
cycles.sort(key=_start_time)
|
||
except Exception: # pylint: disable=broad-exception-caught
|
||
pass
|
||
drop_count = len(cycles) - self._max_past_cycles
|
||
to_drop = cycles[:drop_count]
|
||
|
||
# Maintain profile sample references when dropping
|
||
sample_refs = {
|
||
name: p.get("sample_cycle_id")
|
||
for name, p in self._data.get("profiles", {}).items()
|
||
}
|
||
for cy in to_drop:
|
||
# Track affected profile
|
||
p_name = cy.get("profile_name")
|
||
if p_name:
|
||
affected_profiles.add(p_name)
|
||
|
||
cy_id = cy.get("id")
|
||
# If a profile sample points here, try to move to most recent cycle of that profile
|
||
for name, ref_id in list(sample_refs.items()):
|
||
if ref_id == cy_id:
|
||
# find newest cycle for that profile
|
||
newest = next(
|
||
(
|
||
c
|
||
for c in reversed(cycles)
|
||
if c.get("profile_name") == name and c not in to_drop
|
||
),
|
||
None,
|
||
)
|
||
if newest:
|
||
self._data["profiles"][name]["sample_cycle_id"] = (
|
||
newest.get("id")
|
||
)
|
||
else:
|
||
# No replacement available
|
||
self._data["profiles"][name].pop("sample_cycle_id", None)
|
||
# Actually drop
|
||
del cycles[:drop_count]
|
||
|
||
# 2) Strip older full traces per profile
|
||
by_profile: dict[str | None, list[CycleDict]] = {}
|
||
for cy in cycles:
|
||
key_any = cy.get("profile_name") # None for unlabeled
|
||
key: str | None = key_any if isinstance(key_any, str) and key_any else None
|
||
by_profile.setdefault(key, []).append(cy)
|
||
|
||
# Collect cycle IDs that have pending feedback - never strip their power_data
|
||
pending_feedback_ids: set[str] = set(self._data.get("pending_feedback", {}).keys())
|
||
|
||
for key, group in by_profile.items():
|
||
# newest first based on start_time
|
||
try:
|
||
group.sort(key=_start_time)
|
||
except Exception: # pylint: disable=broad-exception-caught
|
||
pass
|
||
# determine cap
|
||
cap = (
|
||
self._max_full_traces_unlabeled
|
||
if key
|
||
in (
|
||
None,
|
||
"",
|
||
)
|
||
else self._max_full_traces_per_profile
|
||
)
|
||
# count existing full traces
|
||
full_indices = [i for i, c in enumerate(group) if c.get("power_data")]
|
||
if len(full_indices) > cap:
|
||
# preserve last 'cap' full traces (newest at end after sort), strip older ones
|
||
keep_set = set(full_indices[-cap:])
|
||
|
||
# Get sample cycle ID for this profile
|
||
sample_id: str | None = None
|
||
if key and key in self._data.get("profiles", {}):
|
||
sample_id = self._data["profiles"][key].get("sample_cycle_id")
|
||
|
||
for i, c in enumerate(group):
|
||
if i in keep_set:
|
||
continue
|
||
|
||
# EXEMPTION: Never strip power data from the profile's sample cycle!
|
||
if sample_id and c.get("id") == sample_id:
|
||
continue
|
||
|
||
# EXEMPTION: Never strip power data from cycles awaiting feedback review
|
||
if c.get("id") in pending_feedback_ids:
|
||
continue
|
||
|
||
# EXEMPTION: Never strip power data from user-pinned "golden" cycles.
|
||
# The matcher uses a golden trace as the sharp single-cycle template
|
||
# (has_golden in the snapshot builder), and golden_profiles membership
|
||
# is gated on the trace still being present — trimming it would flip
|
||
# has_golden false and silently drop the profile back to the smeared
|
||
# envelope average.
|
||
rev = c.get("ml_review")
|
||
if isinstance(rev, dict) and rev.get("golden"):
|
||
continue
|
||
|
||
if c.get("power_data"):
|
||
c.pop("power_data", None)
|
||
c.pop("sampling_interval", None)
|
||
if key:
|
||
affected_profiles.add(key)
|
||
|
||
return affected_profiles
|
||
|
||
|
||
|
||
def cleanup_orphaned_profiles(self) -> int:
|
||
"""Remove profiles that reference non-existent cycles.
|
||
Returns number of profiles removed."""
|
||
# Imported reference cycles are valid sample targets too (an import-only
|
||
# profile points its sample there), so include them or such profiles would
|
||
# be wrongly deleted as orphans.
|
||
cycle_ids = {c["id"] for c in self._data.get("past_cycles", [])}
|
||
cycle_ids |= {
|
||
c["id"] for c in self._data.get("reference_cycles", []) if c.get("id")
|
||
}
|
||
orphaned: list[str] = []
|
||
for name, profile in self._data["profiles"].items():
|
||
ref = profile.get("sample_cycle_id")
|
||
# Only delete if it references a non-existent cycle ID (Broken Link)
|
||
# Creating a profile without a sample (None) is allowed (Pending State)
|
||
if ref and ref not in cycle_ids:
|
||
orphaned.append(name)
|
||
|
||
for name in orphaned:
|
||
del self._data["profiles"][name]
|
||
self._logger.info(
|
||
"Cleaned up orphaned profile '%s' (cycle no longer exists)", name
|
||
)
|
||
|
||
return len(orphaned)
|
||
|
||
async def async_run_maintenance(self) -> dict[str, int]:
|
||
"""Run full maintenance: cleanup orphans, merge fragments, trim old cycles.
|
||
|
||
Also rebuilds envelopes. Returns stats dict with counts of actions taken.
|
||
"""
|
||
stats = {
|
||
"orphaned_profiles": 0,
|
||
"merged_cycles": 0,
|
||
"split_cycles": 0,
|
||
"rebuilt_envelopes": 0,
|
||
}
|
||
|
||
# 1. Clean up orphaned profiles
|
||
stats["orphaned_profiles"] = self.cleanup_orphaned_profiles()
|
||
|
||
# 2. Auto-Label missed cycles (retroactive matching)
|
||
# Use overwrite=False to respect existing manual/confident labels
|
||
label_stats = await self.auto_label_cycles(confidence_threshold=0.75, overwrite=False)
|
||
stats["labeled_cycles"] = label_stats.get("labeled", 0)
|
||
|
||
# 3. Smart Process History (Merge/Split/Rebuild)
|
||
proc_stats = await self.async_smart_process_history()
|
||
stats["merged_cycles"] = proc_stats.get("merged", 0)
|
||
stats["split_cycles"] = proc_stats.get("split", 0)
|
||
|
||
# 4. Rebuild every profile's envelope so bands stay fresh, and report the
|
||
# real count of successful rebuilds (not an approximation).
|
||
rebuilt = 0
|
||
for profile_name in list(self._data.get("profiles", {}).keys()):
|
||
try:
|
||
# Count only real rebuilds; a no-op/failed rebuild returns False.
|
||
if await self.async_rebuild_envelope(profile_name):
|
||
rebuilt += 1
|
||
except Exception: # pylint: disable=broad-exception-caught
|
||
self._logger.debug(
|
||
"Envelope rebuild failed for %s during maintenance", profile_name, exc_info=True
|
||
)
|
||
stats["rebuilt_envelopes"] = rebuilt
|
||
|
||
# 4. Save if any changes made (smart process saves internally if needed, but explicit save safe)
|
||
if any(stats.values()):
|
||
await self.async_save()
|
||
self._logger.info("Maintenance completed: %s", stats)
|
||
|
||
return stats
|
||
|
||
def _reprocess_all_data_sync(self) -> int:
|
||
"""Synchronous implementation of reprocessing logic (run in executor)."""
|
||
cycles_raw = self._data.get("past_cycles", [])
|
||
cycles = cast(list[CycleDict], cycles_raw) if isinstance(cycles_raw, list) else []
|
||
if not cycles:
|
||
return 0
|
||
|
||
processed_count = 0
|
||
|
||
# 1. Update Signatures & Optimize Data
|
||
for cycle in cycles:
|
||
# Data Optimization: Trim leading/trailing zeros (0W)
|
||
# Only apply to compressed data to avoid breaking legacy format
|
||
p_data = cycle.get("power_data")
|
||
if (
|
||
p_data
|
||
and isinstance(p_data, list)
|
||
and p_data
|
||
and isinstance(p_data[0], (list, tuple))
|
||
):
|
||
first_point = cast(list[Any] | tuple[Any, ...], p_data[0])
|
||
# Only trim offset-format data (numeric offsets). Legacy ISO-format
|
||
# cycles skip trimming but still reach the signature block below.
|
||
if len(first_point) == 2 and isinstance(first_point[0], (int, float)):
|
||
p_data_list = cast(list[list[float]], p_data)
|
||
# Apply trim helper
|
||
original_len = len(p_data_list)
|
||
|
||
# Logic: For completed cycles, only trim leading zeros.
|
||
# For others, trim both ends.
|
||
if cycle.get("status") in ("completed", "force_stopped"):
|
||
# Only trim leading
|
||
start_idx = 0
|
||
for i, point in enumerate(p_data_list):
|
||
if point[1] > 1.0: # Match threshold below
|
||
start_idx = i
|
||
break
|
||
trimmed: list[list[float]] = p_data_list[start_idx:]
|
||
else:
|
||
trimmed = trim_zero_power_data(p_data_list, threshold=1.0) # Conservative 1W threshold
|
||
|
||
if trimmed and len(trimmed) < original_len:
|
||
# Data was trimmed - check for start time shift
|
||
first_offset = trimmed[0][0]
|
||
|
||
if first_offset > 0:
|
||
# Leading zeros removed - Must shift start_time forward
|
||
try:
|
||
start_dt = datetime.fromisoformat(cycle["start_time"])
|
||
new_start = start_dt + timedelta(seconds=first_offset)
|
||
cycle["start_time"] = new_start.isoformat()
|
||
|
||
# Re-normalize offsets to 0
|
||
shifted_data: list[list[float]] = []
|
||
for row in trimmed:
|
||
# row is [offset, power]
|
||
shifted_data.append([round(row[0] - first_offset, 1), row[1]])
|
||
cycle["power_data"] = shifted_data
|
||
processed_count += 1
|
||
except (ValueError, TypeError) as e:
|
||
self._logger.warning("Failed to shift start_time for trimmed cycle: %s", e)
|
||
else:
|
||
# Only trailing trimmed or no shift needed
|
||
cycle["power_data"] = trimmed
|
||
processed_count += 1
|
||
|
||
# Update duration to match new data length
|
||
# If we only trimmed the head, the new duration is old_duration - first_offset
|
||
# This preserves trailing silence.
|
||
if cycle.get("power_data"):
|
||
old_dur = float(cycle.get("duration", 0.0) or 0.0)
|
||
# If we shifted (first_offset > 0), new duration is old_dur - first_offset
|
||
# Otherwise if we only trimmed tail, we might want to snap,
|
||
# but for completed cycles we don't trim tail in this loop.
|
||
if first_offset > 0:
|
||
cycle["duration"] = max(0.0, old_dur - first_offset)
|
||
else:
|
||
# Only trailing was trimmed (not expected for completed cycles here)
|
||
# or no trim happened.
|
||
# If trailing was trimmed, we SHOULD snap.
|
||
if len(trimmed) < original_len:
|
||
cycle["duration"] = cycle["power_data"][-1][0]
|
||
|
||
# Self-heal duration/end_time drift. A freshly-finalized cycle
|
||
# always has last_offset == duration (the finalizer appends a
|
||
# terminal point at end_time, and add_cycle keeps the tail for
|
||
# completed cycles), so this is a no-op for healthy records. It
|
||
# repairs older / manually-edited cycles whose trace was trimmed
|
||
# without updating duration + end_time - e.g. a legacy record
|
||
# whose drying tail was dropped while duration kept the pre-trim
|
||
# value (duration says 8820s but the trace and end_time end at
|
||
# 6845s). Snap both to the trace so the three always agree.
|
||
pd_now = cycle.get("power_data")
|
||
if (
|
||
isinstance(pd_now, list)
|
||
and pd_now
|
||
and isinstance(pd_now[-1], (list, tuple))
|
||
and len(pd_now[-1]) == 2
|
||
):
|
||
trace_end = float(pd_now[-1][0])
|
||
stored_dur = float(cycle.get("duration", 0.0) or 0.0)
|
||
si = float(cycle.get("sampling_interval", 0.0) or 0.0)
|
||
# Tolerance above rounding + one sampling gap; the drifts we
|
||
# repair are large (minutes), so this never churns healthy data.
|
||
tol = max(5.0, 2.0 * si)
|
||
if trace_end > 0 and abs(stored_dur - trace_end) > tol:
|
||
cycle["duration"] = round(trace_end, 1)
|
||
start_ts = _value_to_timestamp(cycle.get("start_time"))
|
||
if start_ts is not None:
|
||
cycle["end_time"] = dt_util.utc_from_timestamp(
|
||
start_ts + trace_end
|
||
).isoformat()
|
||
processed_count += 1
|
||
self._logger.info(
|
||
"Reprocess self-heal: cycle %s duration %.0fs -> %.0fs "
|
||
"(snapped to trace; end_time realigned)",
|
||
cycle.get("id"),
|
||
stored_dur,
|
||
trace_end,
|
||
)
|
||
|
||
if cycle.get("power_data"):
|
||
try:
|
||
tuples = decompress_power_data(cycle)
|
||
if tuples and len(tuples) > 10:
|
||
ts_arr: list[float] = []
|
||
p_arr: list[float] = []
|
||
for offset_sec, p in tuples:
|
||
ts_arr.append(float(offset_sec))
|
||
p_arr.append(float(p))
|
||
|
||
sig = compute_signature(np.array(ts_arr, dtype=float), np.array(p_arr, dtype=float))
|
||
cycle["signature"] = dataclasses.asdict(sig)
|
||
processed_count += 1
|
||
except Exception as e: # pylint: disable=broad-exception-caught
|
||
self._logger.warning("Failed to reprocess signature: %s", e)
|
||
|
||
# 2. Rebuild Envelopes
|
||
|
||
|
||
return processed_count
|
||
|
||
async def async_reprocess_all_data(self) -> int:
|
||
"""Reprocess all historical data to update signatures and rebuild envelopes.
|
||
|
||
This is a non-destructive operation for raw cycle data. It:
|
||
1. Recalculates signatures for ALL past cycles using current logic.
|
||
2. Rebuilds all profile envelopes from scratch.
|
||
3. Updates global stats.
|
||
|
||
Returns total number of cycles processed.
|
||
"""
|
||
self._logger.info("Starting reprocessing (offloaded)...")
|
||
|
||
# Offload heavy synchronous work
|
||
processed_count = await self.hass.async_add_executor_job(
|
||
self._reprocess_all_data_sync
|
||
)
|
||
|
||
# 2. Rebuild Envelopes (Using new async infrastructure)
|
||
await self.async_rebuild_all_envelopes()
|
||
|
||
await self.async_save()
|
||
|
||
return processed_count
|
||
|
||
async def get_storage_stats(self) -> dict[str, Any]:
|
||
"""Get storage usage stats.
|
||
|
||
The on-disk size lookup uses blocking os.path calls, so it is offloaded
|
||
to the executor. We deliberately never json.dumps(self._data) as a
|
||
fallback: with power_data included the dataset can be many megabytes and
|
||
serialising it on the event loop previously stalled the loop long enough
|
||
that the diagnostics view appeared to hang on a spinner.
|
||
"""
|
||
cycles = self._data.get("past_cycles", [])
|
||
profiles = self._data.get("profiles", {})
|
||
debug_traces_count = sum(1 for c in cycles if c.get("debug_data"))
|
||
|
||
file_size_kb = 0.0
|
||
path = getattr(self._store, "path", None)
|
||
if path:
|
||
file_size_kb = await self.hass.async_add_executor_job(
|
||
_safe_file_size_kb, path
|
||
)
|
||
|
||
return {
|
||
"file_size_kb": round(file_size_kb, 1),
|
||
"total_cycles": len(cycles),
|
||
"total_profiles": len(profiles),
|
||
"debug_traces_count": debug_traces_count,
|
||
}
|
||
|
||
async def async_clear_debug_data(self) -> int:
|
||
"""Clear debug data from all cycles."""
|
||
cycles = self._data.get("past_cycles", [])
|
||
count = 0
|
||
for cycle in cycles:
|
||
if "debug_data" in cycle:
|
||
del cycle["debug_data"]
|
||
count += 1
|
||
|
||
if count > 0:
|
||
await self.async_save()
|
||
self._logger.info("Cleared debug data from %s cycles", count)
|
||
|
||
return count
|
||
|
||
|
||
|
||
def _rebuild_envelope_sync(
|
||
self, labeled_cycles: list[CycleDict]
|
||
) -> tuple[Any, list[float]] | None:
|
||
"""Sync worker to parse data and build envelope (run in executor).
|
||
|
||
Degenerate cycles (a near-flat trace whose peak is a tiny fraction of the
|
||
profile's typical peak - e.g. a run where the power sensor wasn't
|
||
reporting) are excluded so they cannot pollute the envelope average that
|
||
the live matcher scores against, or drag ``avg_duration`` around.
|
||
User-pinned golden cycles are always kept.
|
||
"""
|
||
# First pass: decompress everything and record each cycle's peak so we
|
||
# can judge degeneracy relative to the profile (works for both a 2000W
|
||
# dishwasher and a low-power pump).
|
||
parsed: list[tuple[list[float], list[float], float, bool, float]] = []
|
||
for cycle in labeled_cycles:
|
||
pairs = decompress_power_data(cycle)
|
||
if len(pairs) < 3:
|
||
continue
|
||
offsets = [p[0] for p in pairs]
|
||
values = [p[1] for p in pairs]
|
||
stored_dur = float(cycle.get("duration", 0.0) or 0.0)
|
||
authoritative_dur = float(max(offsets[-1], stored_dur))
|
||
man_dur = cycle.get("manual_duration")
|
||
final_dur = float(man_dur) if man_dur else authoritative_dur
|
||
review = cycle.get("ml_review")
|
||
is_golden = bool(review.get("golden")) if isinstance(review, dict) else False
|
||
peak = max(values) if values else 0.0
|
||
parsed.append((offsets, values, final_dur, is_golden, peak))
|
||
|
||
if not parsed:
|
||
return None
|
||
|
||
# Degeneracy floor: below max(_DEGENERATE_POWER_FLOOR, 10% of the median
|
||
# peak) a cycle is treated as a mis-capture and dropped (unless golden).
|
||
peaks = sorted(p[4] for p in parsed)
|
||
median_peak = peaks[len(peaks) // 2] if peaks else 0.0
|
||
degen_floor = max(_DEGENERATE_POWER_FLOOR, 0.10 * median_peak)
|
||
|
||
raw_cycles_data: list[tuple[list[float], list[float], float]] = []
|
||
durations: list[float] = []
|
||
golden_mask: list[bool] = []
|
||
dropped = 0
|
||
for offsets, values, final_dur, is_golden, peak in parsed:
|
||
if peak < degen_floor and not is_golden:
|
||
dropped += 1
|
||
continue
|
||
raw_cycles_data.append((offsets, values, final_dur))
|
||
durations.append(final_dur)
|
||
golden_mask.append(is_golden)
|
||
|
||
# Never drop everything: if the filter removed all cycles (e.g. a truly
|
||
# low-power profile misjudged), fall back to using them all.
|
||
if not raw_cycles_data:
|
||
for offsets, values, final_dur, is_golden, _peak in parsed:
|
||
raw_cycles_data.append((offsets, values, final_dur))
|
||
durations.append(final_dur)
|
||
golden_mask.append(is_golden)
|
||
elif dropped:
|
||
self._logger.debug(
|
||
"Envelope rebuild: excluded %d degenerate cycle(s) below %.0fW "
|
||
"(median peak %.0fW)", dropped, degen_floor, median_peak,
|
||
)
|
||
|
||
if not raw_cycles_data:
|
||
return None
|
||
|
||
# Run Heavy Computation. When the profile has user-verified "golden"
|
||
# cycles, they define the reference shape (see compute_envelope_worker).
|
||
result = analysis.compute_envelope_worker(
|
||
cast(Any, raw_cycles_data),
|
||
self.dtw_bandwidth,
|
||
reference_mask=golden_mask if any(golden_mask) else None,
|
||
)
|
||
|
||
if not result:
|
||
return None
|
||
|
||
return result, durations
|
||
|
||
def _cycle_peak(self, cycle: CycleDict) -> float:
|
||
"""Peak power of a cycle's trace (0.0 if it has none)."""
|
||
pairs = decompress_power_data(cycle)
|
||
return max((p[1] for p in pairs), default=0.0) if pairs else 0.0
|
||
|
||
def _select_reference_cycle_id(
|
||
self, profile_name: str, target_duration: float | None = None
|
||
) -> str | None:
|
||
"""Choose the best reference cycle for a profile's matching template.
|
||
|
||
Preference order: user-pinned golden cycles first, then non-degenerate
|
||
cycles (peak not a tiny fraction of the profile's median peak - this
|
||
rejects mis-captured near-flat traces), and among those the one closest
|
||
to the profile's representative duration (so a truncated half-cycle is
|
||
not chosen). Returns a cycle id, or None if there are no usable cycles.
|
||
"""
|
||
cands = [
|
||
c for c in list(self._data.get("past_cycles", [])) + list(self._data.get("reference_cycles", []))
|
||
if c.get("profile_name") == profile_name
|
||
and c.get("status") in ("completed", "force_stopped")
|
||
and isinstance(c.get("power_data"), list) and len(c["power_data"]) >= 3
|
||
]
|
||
if not cands:
|
||
return None
|
||
peaks = {c["id"]: self._cycle_peak(c) for c in cands}
|
||
med_peak = sorted(peaks.values())[len(peaks) // 2] if peaks else 0.0
|
||
degen_floor = max(_DEGENERATE_POWER_FLOOR, 0.10 * med_peak)
|
||
|
||
golden = [
|
||
c for c in cands
|
||
if isinstance(c.get("ml_review"), dict) and c["ml_review"].get("golden")
|
||
]
|
||
if golden:
|
||
pool = golden
|
||
else:
|
||
pool = [c for c in cands if peaks.get(c["id"], 0.0) >= degen_floor] or cands
|
||
|
||
if target_duration and target_duration > 0:
|
||
best = min(pool, key=lambda c: abs(float(c.get("duration") or 0.0) - target_duration))
|
||
else:
|
||
# No duration hint: prefer the longest (avoids truncated half-cycles).
|
||
best = max(pool, key=lambda c: float(c.get("duration") or 0.0))
|
||
return best.get("id")
|
||
|
||
async def async_rebuild_all_envelopes(self) -> int:
|
||
"""Rebuild envelopes for all profiles. Returns count of envelopes rebuilt."""
|
||
count = 0
|
||
for profile_name in list(self._data["profiles"].keys()):
|
||
if await self.async_rebuild_envelope(profile_name):
|
||
count += 1
|
||
return count
|
||
|
||
def repair_corrupted_power_data(self) -> int:
|
||
"""Fix cycles whose power_data offsets were corrupted by the double-subtract bug.
|
||
|
||
The bug caused ``offset = small_float - unix_timestamp`` to be stored instead of
|
||
just ``small_float``. Corrupted cycles have a first-offset < -1e8 (a value that
|
||
can never occur for a real appliance cycle offset). Recovery: add ``start_ts``
|
||
back to every offset in the affected cycle.
|
||
|
||
Returns the number of cycles repaired.
|
||
"""
|
||
repaired = 0
|
||
for cycle in self._data.get("past_cycles", []):
|
||
power_data = cycle.get("power_data")
|
||
if not isinstance(power_data, list) or not power_data:
|
||
continue
|
||
first = power_data[0]
|
||
if not isinstance(first, (list, tuple)) or len(first) < 2:
|
||
continue
|
||
first_offset = first[0]
|
||
if not isinstance(first_offset, (int, float)) or first_offset > -1e8:
|
||
continue # Not corrupted
|
||
|
||
start_ts = _value_to_timestamp(cycle.get("start_time"))
|
||
if start_ts is None:
|
||
continue
|
||
|
||
repaired_rows: list[list[float]] = []
|
||
for pt in power_data:
|
||
if not isinstance(pt, (list, tuple)) or len(pt) < 2:
|
||
continue
|
||
try:
|
||
repaired_rows.append([round(float(pt[0]) + start_ts, 1), round(float(pt[1]), 1)])
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if not repaired_rows:
|
||
continue # all rows malformed - leave original trace untouched
|
||
cycle["power_data"] = repaired_rows
|
||
repaired += 1
|
||
repaired_data = cycle["power_data"]
|
||
if len(repaired_data) > 1:
|
||
r_offsets = [pt[0] for pt in repaired_data]
|
||
r_intervals = np.diff(r_offsets)
|
||
r_pos = r_intervals[r_intervals > 0]
|
||
r_si = float(np.median(r_pos)) if len(r_pos) > 0 else 1.0
|
||
cycle["sampling_interval"] = round(r_si, 1)
|
||
# duration = last sample offset from cycle start (not span between
|
||
# first and last sample, which would be wrong when leading zeros
|
||
# were trimmed before storage)
|
||
r_duration = round(r_offsets[-1], 1)
|
||
cycle["duration"] = r_duration
|
||
cycle["end_time"] = dt_util.utc_from_timestamp(
|
||
start_ts + r_duration
|
||
).isoformat()
|
||
r_ts = np.array(r_offsets, dtype=float)
|
||
r_p = np.array([pt[1] for pt in repaired_data], dtype=float)
|
||
r_sig = compute_signature(r_ts, r_p)
|
||
cycle["signature"] = dataclasses.asdict(r_sig)
|
||
elif len(repaired_data) == 1:
|
||
cycle["sampling_interval"] = 1.0
|
||
cycle["duration"] = 0.0
|
||
cycle["end_time"] = dt_util.utc_from_timestamp(start_ts).isoformat()
|
||
cycle["signature"] = None
|
||
|
||
if repaired:
|
||
self._logger.warning(
|
||
"Repaired corrupted power_data offsets in %d cycle(s)", repaired
|
||
)
|
||
return repaired
|
||
|
||
async def async_rebuild_envelope(self, profile_name: str) -> bool:
|
||
"""
|
||
Build/rebuild statistical envelope for a profile asynchronously.
|
||
Offloads heavy DTW/normalization to executor.
|
||
"""
|
||
# A rebuild changes this profile's curve, which feeds group cohesion, so
|
||
# invalidate the cohesion cache (not only on group mutations) to avoid stale
|
||
# cohesion approving/rejecting a collapse against outdated shapes.
|
||
self._cohesion_cache_generation += 1
|
||
# 1. Gather Data (Main Thread)
|
||
def _eligible(seq: list[CycleDict]) -> list[CycleDict]:
|
||
return [
|
||
c
|
||
for c in seq
|
||
if c.get("profile_name") == profile_name
|
||
and c.get("status") in ("completed", "force_stopped")
|
||
and c.get("duration", 0) > 60
|
||
]
|
||
|
||
# Real cycles drive usage stats (energy/count). Imported reference cycles
|
||
# additionally shape the curves + matching duration, but never usage stats.
|
||
real_cycles = _eligible(self._data["past_cycles"])
|
||
ref_cycles = _eligible(self._data.get("reference_cycles", []))
|
||
shape_cycles = real_cycles + ref_cycles
|
||
|
||
if not shape_cycles:
|
||
if profile_name in self._data.get("envelopes", {}):
|
||
del self._data["envelopes"][profile_name]
|
||
return False
|
||
|
||
# Kept for the fallback path + duration-stat code below (behaviour is
|
||
# byte-identical to before when there are no reference cycles).
|
||
labeled_cycles = shape_cycles
|
||
|
||
# 2. Run Heavy Computation in Executor (Parsing + DTW)
|
||
result_pkg = await self.hass.async_add_executor_job(
|
||
self._rebuild_envelope_sync,
|
||
shape_cycles
|
||
)
|
||
|
||
if not result_pkg:
|
||
# Envelope shape couldn't be built (no power data / too few points).
|
||
# Still update profile min/max/avg from raw cycle durations so that
|
||
# a duration correction via feedback is immediately reflected in stats.
|
||
if labeled_cycles and profile_name in self._data.get("profiles", {}):
|
||
raw_durs = [
|
||
float(c.get("manual_duration") or c.get("duration", 0))
|
||
for c in labeled_cycles
|
||
]
|
||
raw_durs = [d for d in raw_durs if d > 60]
|
||
if raw_durs:
|
||
raw_arr_fallback = np.array(raw_durs, dtype=float)
|
||
self._data["profiles"][profile_name]["min_duration"] = float(np.min(raw_arr_fallback))
|
||
self._data["profiles"][profile_name]["max_duration"] = float(np.max(raw_arr_fallback))
|
||
self._data["profiles"][profile_name]["avg_duration"] = float(np.mean(raw_arr_fallback))
|
||
if profile_name in self._data.get("envelopes", {}):
|
||
del self._data["envelopes"][profile_name]
|
||
return False
|
||
|
||
result, durations = result_pkg
|
||
|
||
# Update profile stats in storage (Fast metadata update)
|
||
if durations and profile_name in self._data.get("profiles", {}):
|
||
stats_durations = filter_duration_outliers(durations)
|
||
raw_arr = np.array(durations, dtype=float)
|
||
# min/max reflect the actual observed range (including outliers)
|
||
# avg uses the outlier-filtered set for a robust representative value
|
||
min_duration = float(np.min(raw_arr))
|
||
max_duration = float(np.max(raw_arr))
|
||
avg_duration = float(np.mean(stats_durations))
|
||
self._data["profiles"][profile_name]["min_duration"] = min_duration
|
||
self._data["profiles"][profile_name]["max_duration"] = max_duration
|
||
self._data["profiles"][profile_name]["avg_duration"] = avg_duration
|
||
|
||
# Re-point the matching reference to a golden / non-degenerate cycle
|
||
# near the representative duration, so a mis-captured (flat) or
|
||
# truncated cycle can never remain the profile's template.
|
||
ref_id = self._select_reference_cycle_id(profile_name, avg_duration)
|
||
if ref_id and self._data["profiles"][profile_name].get("sample_cycle_id") != ref_id:
|
||
old = self._data["profiles"][profile_name].get("sample_cycle_id")
|
||
self._data["profiles"][profile_name]["sample_cycle_id"] = ref_id
|
||
self._logger.debug(
|
||
"Re-selected reference cycle for %s: %s -> %s",
|
||
profile_name, old, ref_id,
|
||
)
|
||
|
||
if not result:
|
||
if profile_name in self._data.get("envelopes", {}):
|
||
del self._data["envelopes"][profile_name]
|
||
return False
|
||
|
||
time_grid, min_curve, max_curve, avg_curve, std_curve, target_duration = result
|
||
|
||
# 3. Update Storage
|
||
# Convert to list of points [[x, y], ...]
|
||
def to_points(y_vals: list[float]) -> list[list[float]]:
|
||
return [[round(t, 1), round(y, 1)] for t, y in zip(time_grid, y_vals)]
|
||
|
||
# Calculate scalar stats
|
||
duration_std_dev = float(np.std(durations)) if durations else 0.0
|
||
|
||
# Usage stats (avg_energy, cycle_count) come from REAL cycles only, so imported
|
||
# reference cycles can shape the curve without ever inflating energy/count.
|
||
# When there are no reference cycles this is byte-identical to the prior behaviour
|
||
# (avg_energy from the avg curve, cycle_count = len(durations)).
|
||
if ref_cycles:
|
||
real_energies = [
|
||
float(c["energy_wh"])
|
||
for c in real_cycles
|
||
if isinstance(c.get("energy_wh"), (int, float))
|
||
]
|
||
avg_energy = (sum(real_energies) / len(real_energies) / 1000.0) if real_energies else 0.0
|
||
cycle_count = len(real_cycles)
|
||
else:
|
||
# Average-curve energy (kWh) via the shared trapezoidal integrator.
|
||
# avg_curve is in Watts, time_grid in seconds; integrate_wh returns Wh.
|
||
avg_energy = integrate_wh(time_grid, avg_curve) / 1000.0
|
||
cycle_count = len(durations)
|
||
|
||
envelope_data: dict[str, Any] = {
|
||
"time_grid": time_grid, # Time grid used by manager for phase estimation
|
||
"target_duration": target_duration, # Target duration for phase estimation
|
||
"min": to_points(min_curve),
|
||
"max": to_points(max_curve),
|
||
"avg": to_points(avg_curve),
|
||
"std": to_points(std_curve),
|
||
"cycle_count": cycle_count,
|
||
"avg_energy": avg_energy,
|
||
"duration_std_dev": duration_std_dev,
|
||
"updated": dt_util.now().isoformat(),
|
||
}
|
||
|
||
# Derived cache: per-phase profile (per-role duration/energy priors) used by
|
||
# phase-segmented matching / phase-resolved ETA. Built only for device types
|
||
# phase matching is live-supported for; absent otherwise (consumers fall back
|
||
# to the whole-cycle pipeline). Pure/cheap - segmentation is O(samples).
|
||
device_type = str(
|
||
self._data.get("profiles", {}).get(profile_name, {}).get("device_type") or ""
|
||
)
|
||
# Offload the per-cycle segmentation to the executor (it can be tens of ms
|
||
# for very long traces; keep it off the event loop, like the envelope DTW).
|
||
phase_profile = await self.hass.async_add_executor_job(
|
||
self._compute_phase_profile, profile_name, shape_cycles, device_type
|
||
)
|
||
if phase_profile is not None:
|
||
envelope_data["phase_profile"] = phase_profile
|
||
|
||
if "envelopes" not in self._data:
|
||
self._data["envelopes"] = {}
|
||
self._data["envelopes"][profile_name] = envelope_data
|
||
|
||
return True
|
||
|
||
def _compute_phase_profile(
|
||
self, profile_name: str, cycles: list[CycleDict], device_type: str
|
||
) -> dict[str, Any] | None:
|
||
"""Segment each member cycle and aggregate a per-role phase profile.
|
||
|
||
Returns a JSON-safe dict for ``envelope["phase_profile"]`` or ``None`` when
|
||
phase matching is not live-supported for this device type or no cycle could
|
||
be segmented. Never raises (phase support must never break envelope rebuild).
|
||
"""
|
||
try:
|
||
if not phase_matching_live_supported(device_type):
|
||
return None
|
||
model = phase_model_for(device_type)
|
||
if model is None:
|
||
return None
|
||
segmented: list = []
|
||
for cycle in cycles:
|
||
offsets = power_data_to_offsets(cycle.get("power_data") or [])
|
||
if len(offsets) < 4:
|
||
continue
|
||
t = [float(o) for o, _ in offsets]
|
||
w = [float(p) for _, p in offsets]
|
||
segs = segment_cycle(t, w, model)
|
||
if segs:
|
||
segmented.append(segs)
|
||
if not segmented:
|
||
return None
|
||
profile = build_phase_profile(profile_name, segmented)
|
||
return phase_profile_to_dict(profile) if profile is not None else None
|
||
except Exception: # noqa: BLE001 - phase caching must never break rebuild
|
||
self._logger.debug("phase-profile build failed for %s", profile_name, exc_info=True)
|
||
return None
|
||
|
||
def _group_scope(self, program: str) -> set[str] | None:
|
||
"""Phase-narrowing scope for the matched ``program``:
|
||
|
||
* If ``program`` is in a group with >= 2 members, return that group's
|
||
members - narrow WITHIN the family (design §9). This is both coherent
|
||
(same program family as the displayed program) and accurate (picks the
|
||
right temperature/spin variant among siblings).
|
||
* Otherwise return ``None`` = no scope filter (consider ALL of the
|
||
device's phase profiles). The Phase-0 gate showed that constraining an
|
||
UNGROUPED cycle to only the whole-cycle-matched program regresses the
|
||
ETA whenever that match is wrong (common on mislabeled data): the best
|
||
ETA comes from letting the phase matcher pick the best-fitting profile,
|
||
bounded by the ambiguity gate + cold-start floor. Grouping variants is
|
||
the recommended workflow and restores full coherence.
|
||
"""
|
||
try:
|
||
for grp in self.get_profile_groups().values():
|
||
members = grp.get("members") if isinstance(grp, dict) else None
|
||
if isinstance(members, list) and program in members:
|
||
sib = {m for m in members if isinstance(m, str)}
|
||
if len(sib) >= 2:
|
||
return sib
|
||
except Exception: # noqa: BLE001
|
||
self._logger.debug("_group_scope failed for %r", program, exc_info=True)
|
||
return None
|
||
|
||
def _candidate_phase_profiles(self, scope: set[str] | None = None) -> list:
|
||
"""Cached per-profile PhaseProfiles (from envelope['phase_profile']).
|
||
|
||
Restricted to ``scope`` (profile names) when given, and always filtered to
|
||
profiles with >= ``PHASE_PROFILE_MIN_CYCLES`` member cycles so a noisy
|
||
single-cycle prior can't drive the ETA (cold-start floor).
|
||
"""
|
||
out = []
|
||
for name, env in (self._data.get("envelopes") or {}).items():
|
||
if scope is not None and name not in scope:
|
||
continue
|
||
if isinstance(env, dict):
|
||
pp = phase_profile_from_dict(env.get("phase_profile"))
|
||
if pp is not None and pp.n_cycles >= PHASE_PROFILE_MIN_CYCLES:
|
||
out.append(pp)
|
||
return out
|
||
|
||
def phase_remaining(
|
||
self,
|
||
power_data: list,
|
||
device_type: str,
|
||
program: str | None = None,
|
||
) -> dict[str, Any] | None:
|
||
"""Phase-resolved remaining-time for a running cycle. Never raises.
|
||
|
||
Segments the observed-so-far trace and matches it against the matched
|
||
``program``'s phase profile (and its group siblings, design §9), returning
|
||
the winning member's per-role budget remaining. Returns ``None`` (caller
|
||
keeps the current estimate) when: phase matching is not live-supported for
|
||
the device type; ``program`` is unknown / has no cached phase profile
|
||
(or too few cycles - cold-start floor); segmentation is degenerate; or the
|
||
top two candidates are within ``MATCH_AMBIGUITY_MARGIN`` (ambiguous - do
|
||
not commit a variant, design §7).
|
||
|
||
This is the *phase* half of the blended ETA; the blend with the current
|
||
estimator lives in ``progress.compute_progress`` (single source of truth).
|
||
Pure and cheap (segmentation + per-role agreement, no DTW) - safe to call
|
||
inline from the async matching path.
|
||
"""
|
||
try:
|
||
if not phase_matching_live_supported(device_type):
|
||
return None
|
||
model = phase_model_for(device_type)
|
||
if model is None or not program:
|
||
return None
|
||
candidates = self._candidate_phase_profiles(self._group_scope(program))
|
||
if not candidates:
|
||
return None
|
||
offsets = power_data_to_offsets(power_data or [])
|
||
if len(offsets) < 4:
|
||
return None
|
||
t = [float(o) for o, _ in offsets]
|
||
w = [float(p) for _, p in offsets]
|
||
segs = segment_cycle(t, w, model, partial=True)
|
||
if not segs:
|
||
return None
|
||
ranked = match_phase_profiles(segs, candidates, {})
|
||
if not ranked:
|
||
return None
|
||
# Ambiguity gate: a near-tie among group members is not a confident
|
||
# variant call - fall back rather than swing the ETA between budgets.
|
||
if (len(ranked) >= 2
|
||
and (ranked[0].score - ranked[1].score) < MATCH_AMBIGUITY_MARGIN):
|
||
return None
|
||
best = next((c for c in candidates if c.name == ranked[0].name), None)
|
||
remaining = phase_eta(segs, best) if best is not None else None
|
||
if remaining is None:
|
||
return None
|
||
return {
|
||
"remaining_s": float(remaining),
|
||
"matched": ranked[0].name,
|
||
"score": float(ranked[0].score),
|
||
}
|
||
except Exception: # noqa: BLE001 - phase ETA must never break the estimate
|
||
self._logger.debug("phase_remaining failed", exc_info=True)
|
||
return None
|
||
|
||
|
||
|
||
|
||
|
||
|
||
def get_envelope(self, profile_name: str) -> JSONDict | None:
|
||
"""Get cached envelope for a profile, or None if not available."""
|
||
envelopes = self._data.get("envelopes", {})
|
||
if isinstance(envelopes, dict):
|
||
envelopes_map = cast(dict[str, Any], envelopes)
|
||
env = envelopes_map.get(profile_name)
|
||
return cast(JSONDict, env) if isinstance(env, dict) else None
|
||
return None
|
||
|
||
def reference_curve(
|
||
self, profile_name: str, n: int = REFERENCE_PROFILE_CURVE_POINTS
|
||
) -> JSONDict | None:
|
||
"""Compact, read-only reference power curve for a matched profile.
|
||
|
||
Downsamples the profile envelope's average power-over-time shape to at
|
||
most ``n`` points so it can be exposed as an entity attribute for
|
||
consumers (e.g. home energy managers) that want the *forward-looking
|
||
load shape* rather than a scalar ETA - to anticipate, say, a heating
|
||
spike later in the cycle. Shape::
|
||
|
||
{
|
||
"points": [[offset_s, watts], ...], # <= n samples
|
||
"duration_s": float, # profile target duration
|
||
"cycle_count": int, # cycles behind the average
|
||
}
|
||
|
||
Offsets are absolute seconds from cycle start (0 .. ``duration_s``); a
|
||
consumer derives the *remaining* curve by slicing from the live progress
|
||
position (already exposed as the progress sensor). The curve is static
|
||
per profile - it only changes when the profile is re-learned - so it can
|
||
be surfaced as an attribute without recorder churn.
|
||
|
||
Pure statistics (no ML); never raises - returns ``None`` when the
|
||
envelope is missing or too short to be meaningful.
|
||
"""
|
||
try:
|
||
env = self.get_envelope(profile_name)
|
||
if not isinstance(env, dict):
|
||
return None
|
||
avg = env.get("avg")
|
||
if (
|
||
not isinstance(avg, list)
|
||
or len(avg) < 2
|
||
or not isinstance(avg[0], (list, tuple))
|
||
or len(avg[0]) < 2
|
||
):
|
||
return None
|
||
ts = np.asarray([float(p[0]) for p in avg], dtype=float)
|
||
ws = np.asarray([float(p[1]) for p in avg], dtype=float)
|
||
if ts.size < 2 or not (np.all(np.isfinite(ts)) and np.all(np.isfinite(ws))):
|
||
return None
|
||
if ts[-1] <= ts[0]:
|
||
return None
|
||
duration = float(env.get("target_duration") or 0.0)
|
||
if duration <= 0:
|
||
duration = float(ts[-1])
|
||
count = max(2, min(int(n), ts.size))
|
||
grid = np.linspace(float(ts[0]), float(ts[-1]), count)
|
||
vals = np.interp(grid, ts, ws)
|
||
points = [
|
||
[int(round(float(g))), round(float(v), 1)]
|
||
for g, v in zip(grid, vals)
|
||
]
|
||
return {
|
||
"points": points,
|
||
"duration_s": round(duration, 1),
|
||
"cycle_count": int(env.get("cycle_count") or 0),
|
||
}
|
||
except Exception: # pragma: no cover - defensive; never break the sensor
|
||
return None
|
||
|
||
def get_profile_power_profile(
|
||
self, profile_name: str, interval_s: float = 900.0
|
||
) -> list[float]:
|
||
"""Average power (W) per fixed interval across a profile's learned shape.
|
||
|
||
Resamples the profile envelope's average power-over-time curve into
|
||
consecutive ``interval_s`` buckets (default 15 min) and returns the mean
|
||
watts in each, e.g. ``[2200, 2200, 800, 800, 1500, 500, 400, 200]`` - the
|
||
flat per-slot array external planners such as tibber_prices'
|
||
``power_profile`` consume to pick the cheapest window to run the appliance
|
||
(issue #272). Unlike :meth:`reference_curve` (a downsampled time/watt shape
|
||
surfaced on the running-program sensor), this is a fixed-interval average
|
||
exposed per profile so it can be read for planning before a cycle starts.
|
||
|
||
The final bucket is averaged only over the part of the cycle that actually
|
||
falls inside it. Pure statistics; never raises. Returns an empty list when
|
||
the profile has no learned envelope yet.
|
||
"""
|
||
try:
|
||
if interval_s <= 0:
|
||
return []
|
||
env = self.get_envelope(profile_name)
|
||
if not isinstance(env, dict):
|
||
return []
|
||
avg = env.get("avg")
|
||
if (
|
||
not isinstance(avg, list)
|
||
or len(avg) < 2
|
||
or not isinstance(avg[0], (list, tuple))
|
||
or len(avg[0]) < 2
|
||
):
|
||
return []
|
||
ts = np.asarray([float(p[0]) for p in avg], dtype=float)
|
||
ws = np.asarray([float(p[1]) for p in avg], dtype=float)
|
||
if ts.size < 2 or not (np.all(np.isfinite(ts)) and np.all(np.isfinite(ws))):
|
||
return []
|
||
if ts[-1] <= ts[0]:
|
||
return []
|
||
total = float(env.get("target_duration") or 0.0)
|
||
if total <= 0:
|
||
total = float(ts[-1])
|
||
if total <= 0:
|
||
return []
|
||
n_buckets = int(math.ceil(total / interval_s))
|
||
out: list[float] = []
|
||
for k in range(n_buckets):
|
||
lo = k * interval_s
|
||
hi = min(lo + interval_s, total)
|
||
if hi <= lo:
|
||
break
|
||
# Time-average the curve over the half-open slot [lo, hi) on a fine
|
||
# interpolated grid, so the result is independent of the envelope's
|
||
# grid spacing. endpoint=False keeps a slot boundary from being
|
||
# double-counted into the adjacent slot.
|
||
fine = np.linspace(lo, hi, 16, endpoint=False)
|
||
out.append(round(float(np.mean(np.interp(fine, ts, ws))), 1))
|
||
return out
|
||
except Exception: # pragma: no cover - defensive; never break the sensor
|
||
return []
|
||
|
||
def compute_envelope_conformance(
|
||
self,
|
||
profile_name: str,
|
||
points: list[tuple[float, float]],
|
||
) -> dict[str, Any] | None:
|
||
"""Score how well the current power trace conforms to the profile envelope band.
|
||
|
||
Resamples ``points`` to the envelope's time grid (scaling by the ratio of
|
||
the current elapsed time to the envelope duration) and computes the fraction
|
||
of samples that land within the [lower, upper] band. Returns a dict:
|
||
|
||
``conformance`` – fraction of samples inside the envelope band (0–1)
|
||
``outside_frac`` – fraction outside the band (1 - conformance)
|
||
``samples`` – number of samples compared
|
||
``envelope_name`` – profile_name used
|
||
|
||
Returns ``None`` if the envelope or points are unavailable / too short.
|
||
Never raises.
|
||
|
||
This is a complementary signal to match confidence: confidence measures
|
||
shape correlation, while conformance measures absolute level/spread. A
|
||
high-confidence but low-conformance score may indicate the cycle was
|
||
mis-detected (merged cycles, offset start) or the appliance is running
|
||
anomalously.
|
||
"""
|
||
try:
|
||
if not points or len(points) < 4:
|
||
return None
|
||
env = self.get_envelope(profile_name)
|
||
if not env:
|
||
return None
|
||
time_grid = env.get("time_grid")
|
||
lower_raw = env.get("min")
|
||
upper_raw = env.get("max")
|
||
if not time_grid or not lower_raw or not upper_raw:
|
||
return None
|
||
|
||
tg = np.asarray(time_grid, dtype=float)
|
||
# Envelope curves are stored as [[t, y], ...]; extract the y column.
|
||
lo = np.asarray(
|
||
[p[1] if isinstance(p, (list, tuple)) else p for p in lower_raw],
|
||
dtype=float,
|
||
)
|
||
hi = np.asarray(
|
||
[p[1] if isinstance(p, (list, tuple)) else p for p in upper_raw],
|
||
dtype=float,
|
||
)
|
||
env_duration = float(tg[-1]) if len(tg) > 1 else 1.0
|
||
|
||
# Normalise observed trace to [0, env_duration] time range
|
||
t_obs = np.asarray([t for t, _ in points], dtype=float)
|
||
p_obs = np.asarray([p for _, p in points], dtype=float)
|
||
obs_duration = float(t_obs[-1]) if len(t_obs) > 1 else 1.0
|
||
t_scaled = t_obs * (env_duration / obs_duration) if obs_duration > 0 else t_obs
|
||
|
||
# Interpolate envelope bounds at scaled observed time points (clamp ends)
|
||
t_clamped = np.clip(t_scaled, tg[0], tg[-1])
|
||
lo_interp = np.interp(t_clamped, tg, lo)
|
||
hi_interp = np.interp(t_clamped, tg, hi)
|
||
|
||
inside = np.sum((p_obs >= lo_interp) & (p_obs <= hi_interp))
|
||
n = len(p_obs)
|
||
conformance = float(inside) / n if n > 0 else 0.0
|
||
|
||
return {
|
||
"conformance": round(conformance, 3),
|
||
"outside_frac": round(1.0 - conformance, 3),
|
||
"samples": n,
|
||
"envelope_name": profile_name,
|
||
}
|
||
except Exception: # noqa: BLE001
|
||
return None
|
||
|
||
def detect_cycle_artifacts(
|
||
self,
|
||
profile_name: str,
|
||
points: list[tuple[float, float]],
|
||
) -> list[dict[str, Any]]:
|
||
"""Detect transient artifacts in a cycle by comparing it to the profile band.
|
||
|
||
Walks the trace against the matched profile's envelope and flags contiguous
|
||
segments that deviate from what the program normally does:
|
||
|
||
* ``pause`` – power fell to ~0 where the profile expects activity and then
|
||
resumed (e.g. the door was opened mid-cycle to add an item, or a manual
|
||
pause); excluded at the very end (that's just the cycle finishing).
|
||
* ``dip`` – ran sustainedly below the usual power band.
|
||
* ``spike`` – ran sustainedly above the usual power band.
|
||
|
||
Returns a chronological list of ``{type, start_s, end_s, detail, severity}``
|
||
in the trace's own time offsets (so the panel can mark them on the graph),
|
||
capped to the most significant few. ``[]`` when unavailable. Pure statistics
|
||
(no ML), never raises — and the events double as candidate labels for a
|
||
future supervised anomaly model.
|
||
"""
|
||
try:
|
||
if not points or len(points) < 6:
|
||
return []
|
||
env = self.get_envelope(profile_name)
|
||
if not env:
|
||
return []
|
||
tg = np.asarray(env.get("time_grid") or [], dtype=float)
|
||
# Envelope curves are stored as [[t, y], ...]; extract the y column.
|
||
def _extract_y(raw: list) -> np.ndarray:
|
||
if not raw:
|
||
return np.array([], dtype=float)
|
||
return np.asarray(
|
||
[p[1] if isinstance(p, (list, tuple)) else p for p in raw],
|
||
dtype=float,
|
||
)
|
||
lo = _extract_y(env.get("min") or [])
|
||
hi = _extract_y(env.get("max") or [])
|
||
avg = _extract_y(env.get("avg") or [])
|
||
if tg.size < 2 or lo.size != tg.size or hi.size != tg.size:
|
||
return []
|
||
|
||
t_obs = np.asarray([t for t, _ in points], dtype=float)
|
||
p_obs = np.asarray([max(0.0, float(p)) for _, p in points], dtype=float)
|
||
obs_dur = float(t_obs[-1]) if t_obs.size > 1 else 1.0
|
||
env_dur = float(tg[-1]) if tg.size > 1 else 1.0
|
||
if obs_dur <= 0 or env_dur <= 0:
|
||
return []
|
||
|
||
# Map each observed time to the envelope grid (same scaling as
|
||
# compute_envelope_conformance) so the band is aligned to progress.
|
||
t_scaled = np.clip(t_obs * (env_dur / obs_dur), tg[0], tg[-1])
|
||
lo_i = np.interp(t_scaled, tg, lo)
|
||
hi_i = np.interp(t_scaled, tg, hi)
|
||
avg_i = np.interp(t_scaled, tg, avg) if avg.size == tg.size else (lo_i + hi_i) / 2.0
|
||
|
||
peak = max(float(np.max(hi)), 1.0)
|
||
active_thr = max(5.0, 0.05 * peak) # profile expects real power here
|
||
pause_thr = max(2.0, 0.03 * peak) # observed effectively off
|
||
margin = max(10.0, 0.12 * peak) # band slack to ignore edge noise
|
||
|
||
# Pre-check: if the cycle's linear-resampled alignment is too poor
|
||
# (e.g. duration or phase structure doesn't match the profile), the
|
||
# envelope comparison produces unreliable artifacts. Compute tight
|
||
# conformance (no margin) over the active region; bail out when more
|
||
# than 45 % of expected-active samples are outside the raw band.
|
||
active_mask = avg_i > active_thr
|
||
n_active = int(np.sum(active_mask))
|
||
if n_active > 10:
|
||
outside_tight = int(np.sum(
|
||
(p_obs[active_mask] < lo_i[active_mask]) |
|
||
(p_obs[active_mask] > hi_i[active_mask])
|
||
))
|
||
if outside_tight / n_active > 0.45:
|
||
return []
|
||
|
||
states: list[str] = []
|
||
for i in range(len(p_obs)):
|
||
expects_power = avg_i[i] > active_thr
|
||
if expects_power and p_obs[i] <= pause_thr:
|
||
states.append("pause")
|
||
elif expects_power and p_obs[i] < lo_i[i] - margin:
|
||
states.append("dip")
|
||
elif p_obs[i] > hi_i[i] + margin:
|
||
states.append("spike")
|
||
else:
|
||
states.append("ok")
|
||
|
||
min_dur = {"pause": 25.0, "dip": 45.0, "spike": 30.0}
|
||
sev_scale = {"pause": 300.0, "dip": 600.0, "spike": 300.0}
|
||
events: list[dict[str, Any]] = []
|
||
n = len(states)
|
||
i = 0
|
||
while i < n:
|
||
s = states[i]
|
||
if s == "ok":
|
||
i += 1
|
||
continue
|
||
j = i
|
||
while j + 1 < n and states[j + 1] == s:
|
||
j += 1
|
||
start_s, end_s = float(t_obs[i]), float(t_obs[j])
|
||
dur = end_s - start_s
|
||
resumes = (j + 1 < n) and float(np.max(p_obs[j + 1:])) > active_thr
|
||
if dur >= min_dur[s] and (s != "pause" or resumes):
|
||
if s == "pause":
|
||
detail = (f"Power dropped to near zero for ~{int(dur)}s then resumed — "
|
||
"likely the door was opened mid-cycle or the cycle was paused.")
|
||
detail_key = "msg.artifact_pause_detail"
|
||
elif s == "dip":
|
||
detail = f"Drew below the usual power band for ~{int(dur)}s."
|
||
detail_key = "msg.artifact_dip_detail"
|
||
else:
|
||
detail = f"Drew above the usual power band for ~{int(dur)}s."
|
||
detail_key = "msg.artifact_spike_detail"
|
||
events.append({
|
||
"type": s,
|
||
"start_s": round(start_s, 1),
|
||
"end_s": round(end_s, 1),
|
||
"detail": detail,
|
||
"detail_key": detail_key,
|
||
"detail_params": {"n": int(dur)},
|
||
"severity": round(min(1.0, dur / sev_scale[s]), 3),
|
||
})
|
||
i = j + 1
|
||
|
||
events.sort(key=lambda e: -e["severity"])
|
||
events = events[:6]
|
||
events.sort(key=lambda e: e["start_s"])
|
||
return events
|
||
except Exception: # noqa: BLE001
|
||
return []
|
||
|
||
def get_match_candidates_summary(
|
||
self, match_result: MatchResult, limit: int = 3
|
||
) -> list[dict[str, Any]]:
|
||
"""Extract top candidates from query result for UI display.
|
||
|
||
Args:
|
||
match_result: MatchResult from profile matching
|
||
limit: Number of top candidates to return
|
||
|
||
Returns:
|
||
List of dicts with keys: profile_name, confidence_pct, mae, correlation, duration_ratio
|
||
"""
|
||
candidates: list[dict[str, Any]] = []
|
||
|
||
for candidate in match_result.ranking[:limit]:
|
||
try:
|
||
confidence_pct = round(candidate.get("score", 0.0) * 100, 1)
|
||
metrics = candidate.get("metrics", {})
|
||
mae = round(metrics.get("mae", 0.0), 2)
|
||
corr = round(metrics.get("corr", 0.0), 3)
|
||
|
||
profile_duration = candidate.get("profile_duration", 0.0)
|
||
actual_duration = match_result.expected_duration
|
||
duration_ratio = (
|
||
round((actual_duration / profile_duration - 1.0) * 100, 1)
|
||
if profile_duration > 0
|
||
else 0.0
|
||
)
|
||
|
||
candidates.append({
|
||
"profile_name": candidate.get("name", "Unknown"),
|
||
"confidence_pct": confidence_pct,
|
||
"mae": mae,
|
||
"correlation": corr,
|
||
"duration_ratio": duration_ratio, # ±% from expected
|
||
})
|
||
except (TypeError, ValueError, KeyError):
|
||
continue
|
||
|
||
return candidates
|
||
|
||
def _get_cached_sample_segment(
|
||
self, sample_cycle: dict[str, Any], dt: float
|
||
) -> Segment | None:
|
||
"""Get or compute resampled segment for a sample cycle, using cache."""
|
||
cycle_id = sample_cycle.get("id")
|
||
if not cycle_id:
|
||
return None
|
||
|
||
# Round dt to avoid float cache misses
|
||
dt_key = float(round(dt, 2))
|
||
key = (cycle_id, dt_key)
|
||
|
||
if key in self._cached_sample_segments:
|
||
return self._cached_sample_segments[key]
|
||
|
||
# Miss: Compute
|
||
sample_data = sample_cycle.get("power_data")
|
||
if not sample_data:
|
||
return None
|
||
|
||
try:
|
||
if len(sample_data) > 0 and isinstance(sample_data[0], (list, tuple)):
|
||
s_ts = np.array([x[0] for x in sample_data])
|
||
s_p = np.array([x[1] for x in sample_data])
|
||
else:
|
||
return None
|
||
|
||
s_segments = resample_uniform(s_ts, s_p, dt_s=dt, gap_s=21600.0)
|
||
if not s_segments:
|
||
return None
|
||
|
||
sample_seg = max(s_segments, key=lambda s: len(s.power))
|
||
|
||
# Store
|
||
self._cached_sample_segments[key] = sample_seg
|
||
return sample_seg
|
||
except Exception as e: # pylint: disable=broad-exception-caught
|
||
self._logger.warning("Error caching sample segment %s: %s", cycle_id, e)
|
||
return None
|
||
|
||
async def async_match_profile(
|
||
self,
|
||
current_power_data: list[tuple[str, float]] | list[tuple[datetime, float]] | list[tuple[float, float]] | list[list[float]],
|
||
current_duration: float,
|
||
) -> MatchResult:
|
||
"""Run profile matching asynchronously in executor."""
|
||
# 1. Prepare data in main thread (Access ProfileStore state safely)
|
||
group_members: dict[str, list[str]] = {}
|
||
member_snaps: dict[str, dict[str, Any]] = {}
|
||
|
||
# Convert to list of floats for current power (uniform resampling)
|
||
if not current_power_data:
|
||
return MatchResult(None, 0.0, 0.0, None, [], False, 0.0)
|
||
|
||
# Pre-process current data
|
||
try:
|
||
# Normalize input format
|
||
first_elem = current_power_data[0][0]
|
||
if isinstance(first_elem, datetime):
|
||
# datetime objects: compute relative timestamps
|
||
t_start = first_elem.timestamp()
|
||
ts_arr = np.array([(x[0].timestamp() - t_start) for x in cast(list[tuple[datetime, float]], current_power_data)])
|
||
elif isinstance(first_elem, (int, float)):
|
||
# Already offset timestamps (from compressed format)
|
||
ts_arr = np.array([float(x[0]) for x in cast(list[tuple[float, float]], current_power_data)])
|
||
else:
|
||
# ISO format strings
|
||
t_start = datetime.fromisoformat(first_elem).timestamp()
|
||
ts_arr = np.array(
|
||
[
|
||
(datetime.fromisoformat(x[0]).timestamp() - t_start)
|
||
for x in cast(list[tuple[str, float]], current_power_data)
|
||
]
|
||
)
|
||
|
||
p_arr = np.array([float(x[1]) for x in current_power_data])
|
||
|
||
# Resample current
|
||
segments, used_dt = resample_adaptive(ts_arr, p_arr, min_dt=5.0, gap_s=21600.0)
|
||
if not segments:
|
||
return MatchResult(None, 0.0, 0.0, None, [], False, 0.0)
|
||
current_seg = max(segments, key=lambda s: len(s.power))
|
||
if len(current_seg.power) < 12:
|
||
return MatchResult(None, 0.0, 0.0, None, [], False, 0.0)
|
||
|
||
current_power_list = current_seg.power.tolist()
|
||
|
||
# Prepare Snapshots. Imported reference cycles are eligible as matching
|
||
# templates alongside real cycles (so an import-only profile can match).
|
||
all_cycles = list(self._data["past_cycles"]) + list(self._data.get("reference_cycles", []))
|
||
# Precompute per-profile lookups ONCE so the loop below is O(profiles),
|
||
# not O(profiles x cycles). Rescanning all_cycles with next()/any() for
|
||
# every profile made matching quadratic and stalled low-power hosts on
|
||
# auto-label (many matches x many cycles) - issue #311. Selections are
|
||
# byte-identical: cycles_by_id keeps the FIRST occurrence (== next()),
|
||
# labeled_by_profile keeps the first eligible cycle in all_cycles order,
|
||
# and golden_profiles mirrors the any(...) golden test.
|
||
cycles_by_id: dict[str, CycleDict] = {}
|
||
labeled_by_profile: dict[str, CycleDict] = {}
|
||
golden_profiles: set[str] = set()
|
||
for c in all_cycles:
|
||
cid = c.get("id")
|
||
if cid is not None and cid not in cycles_by_id:
|
||
cycles_by_id[cid] = c
|
||
pname = c.get("profile_name")
|
||
if not pname or not c.get("power_data"):
|
||
continue
|
||
if (
|
||
pname not in labeled_by_profile
|
||
and c.get("status") in ("completed", "force_stopped")
|
||
):
|
||
labeled_by_profile[pname] = c
|
||
rev = c.get("ml_review")
|
||
if isinstance(rev, dict) and rev.get("golden"):
|
||
golden_profiles.add(pname)
|
||
|
||
snapshots: list[dict[str, Any]] = []
|
||
skipped_profiles: list[str] = []
|
||
for name, profile in self._data["profiles"].items():
|
||
# Try sample_cycle_id first, fall back to any labeled cycle
|
||
sample_id = profile.get("sample_cycle_id")
|
||
sample_cycle = cycles_by_id.get(sample_id) if sample_id else None
|
||
# Fallback: find ANY completed cycle labeled with this profile
|
||
if not sample_cycle:
|
||
sample_cycle = labeled_by_profile.get(name)
|
||
# Boost user-pinned "golden" cycles: when a profile has one, use
|
||
# its sharp single-cycle trace as the matching template instead
|
||
# of the envelope average. The envelope average smears the
|
||
# wash-phase peaks (each cycle's spikes land at slightly
|
||
# different times), which hurts correlation for sharply-shaped
|
||
# programs; a trusted golden cycle preserves that shape.
|
||
has_golden = name in golden_profiles
|
||
|
||
# Prefer envelope avg curve when ≥2 labeled cycles have been
|
||
# confirmed - it gives a more representative reference signal
|
||
# than the original sample alone, so confidence improves over
|
||
# time as the user keeps confirming correct detections. Skipped
|
||
# when a golden cycle is pinned (see above).
|
||
envelope = self._data.get("envelopes", {}).get(name)
|
||
_env_avg = envelope.get("avg") if envelope else None
|
||
if (
|
||
not has_golden
|
||
and envelope
|
||
and envelope.get("cycle_count", 0) >= 2
|
||
and _env_avg
|
||
and isinstance(_env_avg[0], (list, tuple))
|
||
and len(_env_avg[0]) >= 2
|
||
):
|
||
avg_y = [float(p[1]) for p in _env_avg]
|
||
_env_ts_duration = (
|
||
float(_env_avg[-1][0]) - float(_env_avg[0][0])
|
||
if len(_env_avg) > 1 else 0.0
|
||
)
|
||
avg_duration = (
|
||
envelope.get("target_duration") or
|
||
profile.get("avg_duration") or
|
||
_env_ts_duration or
|
||
None
|
||
)
|
||
if not avg_duration:
|
||
skipped_profiles.append(
|
||
f"{name}: no valid duration (envelope has no target_duration, avg_duration, or timestamp span)"
|
||
)
|
||
continue
|
||
snapshots.append({
|
||
"name": name,
|
||
"avg_duration": float(avg_duration),
|
||
"sample_power": avg_y,
|
||
})
|
||
continue
|
||
|
||
if not sample_cycle:
|
||
skipped_profiles.append(
|
||
f"{name}: no sample cycle (sample_id={sample_id})"
|
||
)
|
||
continue
|
||
|
||
# Prepare sample segment (using cache)
|
||
sample_seg = self._get_cached_sample_segment(sample_cycle, used_dt)
|
||
if not sample_seg:
|
||
skipped_profiles.append(
|
||
f"{name}: failed to resample cycle {sample_cycle.get('id')}"
|
||
)
|
||
continue
|
||
# avg_duration preference order:
|
||
# 1. profile["avg_duration"] (rolling average, most accurate)
|
||
# 2. sample_cycle["duration"] (raw cycle field)
|
||
# 3. timestamp span of sample_seg (estimate from the resampled data)
|
||
# Profiles created before avg_duration tracking was added may have
|
||
# 0 or a missing value; falling back to the segment estimate prevents
|
||
# update_match() from always seeing expected_duration=0, which
|
||
# silences time-remaining estimates and logs a misleading warning.
|
||
_seg_ts_duration = (
|
||
float(sample_seg.timestamps[-1]) - float(sample_seg.timestamps[0])
|
||
if len(sample_seg.timestamps) > 1 else 0.0
|
||
)
|
||
avg_dur = (
|
||
profile.get("avg_duration") or
|
||
sample_cycle.get("duration") or
|
||
_seg_ts_duration
|
||
)
|
||
if not avg_dur:
|
||
skipped_profiles.append(
|
||
f"{name}: no valid duration (avg_duration, cycle duration, and timestamp span all zero/missing)"
|
||
)
|
||
continue
|
||
snapshots.append({
|
||
"name": name,
|
||
"avg_duration": float(avg_dur),
|
||
"sample_power": sample_seg.power.tolist(),
|
||
"sample_dt": used_dt
|
||
})
|
||
|
||
if skipped_profiles:
|
||
self._logger.debug(
|
||
"Profile matching skipped %d profiles: %s",
|
||
len(skipped_profiles),
|
||
"; ".join(skipped_profiles)
|
||
)
|
||
|
||
# Stage 5: collapse cohesive near-duplicate groups into one aggregate
|
||
# candidate each (loose groups stay individual). No-op without groups.
|
||
snapshots, group_members, member_snaps = self._grouped_snapshots(snapshots)
|
||
|
||
config = {
|
||
"min_duration_ratio": self._min_duration_ratio,
|
||
"max_duration_ratio": self._max_duration_ratio,
|
||
"dtw_bandwidth": self.dtw_bandwidth,
|
||
# On-device tuned scoring weights (opt-in); empty = shipped defaults.
|
||
**self._matching_overrides(),
|
||
}
|
||
|
||
except Exception as e: # pylint: disable=broad-exception-caught
|
||
self._logger.error("Preparation for async match failed: %s", e)
|
||
return MatchResult(None, 0.0, 0.0, None, [], False, 0.0)
|
||
|
||
# 2. Run Heavy Logic in Executor
|
||
candidates = await self.hass.async_add_executor_job(
|
||
analysis.compute_matches_worker,
|
||
current_power_list,
|
||
current_duration,
|
||
cast(Any, snapshots),
|
||
config
|
||
)
|
||
|
||
# 3. Process Result (Main Thread)
|
||
if not candidates:
|
||
profiles_count = len(self._data.get("profiles", {}))
|
||
snapshots_count = len(snapshots) if 'snapshots' in dir() else 0
|
||
self._logger.debug(
|
||
"No profile match candidates: profiles=%d, snapshots=%d, "
|
||
"duration=%.0fs. Possible reasons: duration ratio filter, "
|
||
"no labeled cycles, or no profiles defined.",
|
||
profiles_count,
|
||
snapshots_count,
|
||
current_duration
|
||
)
|
||
return MatchResult(None, 0.0, 0.0, None, [], False, 0.0, [], {}, is_confident_mismatch=True, mismatch_reason="all_rejected")
|
||
|
||
best = candidates[0]
|
||
|
||
# Reconstruct MatchResult. Top-level ambiguity (safeguard #1): a close
|
||
# group-vs-runner-up call is flagged so it goes to the uncertain/feedback
|
||
# path, not a confident commit.
|
||
margin, is_ambiguous = _ambiguity_from_candidates(candidates)
|
||
|
||
best_name = best["name"]
|
||
best_duration = best["profile_duration"]
|
||
# Stage 5: if a group won, pick the best-fitting member.
|
||
if best_name in group_members:
|
||
chosen, member_fit, member_dur = self._stage5_pick_member(
|
||
current_power_list, current_duration, group_members[best_name], member_snaps
|
||
)
|
||
best_name = chosen
|
||
if member_dur:
|
||
best_duration = member_dur
|
||
# Safeguard #2: the group aggregate matched but if the chosen member
|
||
# does not individually fit reasonably (vs the group score), the real
|
||
# program may be a different single profile -> treat as uncertain.
|
||
# member_fit is a Stage-2-only score; best["score"] includes DTW-blend +
|
||
# duration/energy agreement (typically 25-30% higher). Use 0.55× to avoid
|
||
# the threshold being effectively too strict for DTW-boosted groups.
|
||
if member_fit is not None and best["score"] > 0 and member_fit < 0.55 * best["score"]:
|
||
is_ambiguous = True
|
||
# Overrun guard: if the cycle has already outlasted the chosen member's
|
||
# expected duration we may be running the longer group member — downgrade
|
||
# to ambiguous so Smart Termination falls back to the power timeout.
|
||
if best_duration and current_duration > best_duration * 1.05:
|
||
is_ambiguous = True
|
||
# Relabel the winning candidate for the ranking / diagnostics.
|
||
candidates = [{**best, "name": best_name, "profile_duration": best_duration}, *candidates[1:]]
|
||
|
||
matched_phase = None
|
||
if best_name:
|
||
# Always resolve phase for the matched profile so phase sensors can
|
||
# show user-assigned phase names even when confidence is moderate.
|
||
matched_phase = self.check_phase_match(best_name, current_duration)
|
||
|
||
# Detect "prefix ambiguity": a non-winning candidate whose duration is
|
||
# significantly longer than the matched profile AND whose shape matched
|
||
# well before Stage-4 penalised its duration. When this is true the
|
||
# current trace may be a prefix of that longer program, not a complete
|
||
# short cycle. Signal cycle_detector to block Smart Termination; the
|
||
# power-based fallback timeout will decide instead.
|
||
best_dur = best_duration or 0.0
|
||
is_prefix_ambiguous = best_dur > 0 and any(
|
||
float(c.get("profile_duration") or 0) > best_dur * SMART_TERM_LANDSCAPE_RATIO
|
||
and float(c.get("shape_score", c.get("score", 0))) >= SMART_TERM_LANDSCAPE_MIN_SHAPE
|
||
for c in candidates[1:]
|
||
)
|
||
|
||
return MatchResult(
|
||
best_name,
|
||
best["score"],
|
||
best_duration,
|
||
matched_phase,
|
||
candidates[:5],
|
||
is_ambiguous,
|
||
margin,
|
||
ranking=candidates[:5], # populate ranking (consumed for training snapshots)
|
||
is_prefix_ambiguous=is_prefix_ambiguous,
|
||
)
|
||
|
||
async def async_verify_alignment(
|
||
self,
|
||
profile_name: str,
|
||
current_power_data: list[list[float]] | list[tuple[Any, ...]],
|
||
) -> tuple[bool, float, float]:
|
||
"""
|
||
Verify if the current power trace aligns with an expected low-power region in the envelope.
|
||
Returns: (is_confirmed_low_power, mapped_envelope_time, mapped_envelope_power)
|
||
"""
|
||
envelope = self.get_envelope(profile_name)
|
||
if not envelope or not envelope.get("avg") or not current_power_data:
|
||
return False, 0.0, 9999.0
|
||
|
||
# Extract envelope curves
|
||
# "avg" can be list of [t, p] (new) or [p, ...] (legacy)
|
||
env_avg_raw = envelope.get("avg", [])
|
||
if not env_avg_raw:
|
||
return False, 0.0, 9999.0
|
||
|
||
try:
|
||
# Handle both formats: [[t, y], ...] (new) or [y, ...] (legacy)
|
||
if isinstance(env_avg_raw[0], (list, tuple)) and len(env_avg_raw[0]) >= 2:
|
||
# New format: [[t, y], ...]
|
||
env_points = cast(list[list[Any] | tuple[Any, ...]], env_avg_raw)
|
||
env_time = [float(p[0]) for p in env_points]
|
||
env_power = [float(p[1]) for p in env_points]
|
||
else:
|
||
# Legacy format: [y, ...]
|
||
env_values = cast(list[float | int], env_avg_raw)
|
||
env_power = [float(p) for p in env_values]
|
||
# Reconstruct time grid from envelope if available, or assume 60s intervals
|
||
env_time_raw = envelope.get("time_grid")
|
||
env_time = cast(list[float], env_time_raw) if isinstance(env_time_raw, list) else None
|
||
if not env_time or len(env_time) != len(env_power):
|
||
target_dur = float(envelope.get("target_duration", 0.0) or 0.0)
|
||
if target_dur > 0:
|
||
env_time = cast(list[float], np.linspace(0, target_dur, len(env_power)).tolist())
|
||
else:
|
||
env_time = [float(i * 60) for i in range(len(env_power))]
|
||
except (TypeError, ValueError, IndexError) as e:
|
||
first_type_name = type(env_avg_raw[0]).__name__ if env_avg_raw else "None"
|
||
self._logger.error(
|
||
"Malformed envelope 'avg' data for %s. Type: %s, Length: %d, Error: %s",
|
||
profile_name, first_type_name, len(env_avg_raw), e
|
||
)
|
||
return False, 0.0, 9999.0
|
||
|
||
try:
|
||
current_power_list = [float(x[1]) for x in current_power_data]
|
||
except Exception: # pylint: disable=broad-exception-caught
|
||
return False, 0.0, 9999.0
|
||
|
||
# Offload to worker
|
||
mapped_time, mapped_power, score = await self.hass.async_add_executor_job(
|
||
analysis.verify_profile_alignment_worker,
|
||
current_power_list,
|
||
env_power,
|
||
env_time,
|
||
self.dtw_bandwidth
|
||
)
|
||
|
||
# Verify if mapped power and alignment score indicate an expected low-power region.
|
||
# Thresholds: Expected power < 15W, Alignment score > 0.4
|
||
is_confirmed = (mapped_power < 15.0) and (score > 0.4)
|
||
|
||
return is_confirmed, mapped_time, mapped_power
|
||
|
||
|
||
def check_phase_match(self, profile_name: str, duration: float) -> str | None:
|
||
"""
|
||
Check if the current duration aligns with a known phase in the profile.
|
||
Returns the phase name (e.g., 'Rinse', 'Spin') or None.
|
||
"""
|
||
profile = self._data["profiles"].get(profile_name)
|
||
if not profile:
|
||
return None
|
||
|
||
phases = profile.get("phases", [])
|
||
if not phases:
|
||
return None
|
||
|
||
# Guard against non-dict entries (legacy/malformed storage) and
|
||
# None/non-numeric start values before sorting.
|
||
phases_sorted = sorted(
|
||
[p for p in phases if isinstance(p, dict)],
|
||
key=lambda p: float(p.get("start") or 0),
|
||
)
|
||
|
||
last_matched: str | None = None
|
||
for phase in phases_sorted:
|
||
p_start = float(phase.get("start") or 0)
|
||
p_end = float(phase.get("end") or 0)
|
||
if p_end <= p_start:
|
||
# Missing or zero end — phase range is invalid; skip silently.
|
||
continue
|
||
if p_start <= duration <= p_end:
|
||
return str(phase.get("name", "Unknown"))
|
||
if p_start <= duration:
|
||
# Track the last phase whose start we have passed; used for gap fallback.
|
||
last_matched = str(phase.get("name", "Unknown"))
|
||
|
||
# Duration is outside explicit bounds — keep a label so entities avoid
|
||
# falling back to generic running/starting states.
|
||
if phases_sorted:
|
||
if duration < float(phases_sorted[0].get("start") or 0):
|
||
return str(phases_sorted[0].get("name", "Unknown"))
|
||
# Return the phase that just ended (last_matched) when in a gap, or the
|
||
# final phase when past all ranges, rather than always the absolute last.
|
||
return last_matched or str(phases_sorted[-1].get("name", "Unknown"))
|
||
|
||
return None
|
||
|
||
|
||
|
||
async def create_profile(self, name: str, source_cycle_id: str) -> None:
|
||
"""Create a new profile from a past cycle."""
|
||
cycle = next(
|
||
(c for c in self._data["past_cycles"] if c["id"] == source_cycle_id), None
|
||
)
|
||
if not cycle:
|
||
raise ValueError("Cycle not found")
|
||
|
||
cycle["profile_name"] = name
|
||
|
||
self._data.setdefault("profiles", {})[name] = {
|
||
"avg_duration": cycle["duration"],
|
||
"sample_cycle_id": source_cycle_id,
|
||
}
|
||
|
||
# Save to persist the label
|
||
await self.async_save()
|
||
|
||
@property
|
||
def has_real_profiles(self) -> bool:
|
||
"""True if at least one stored profile is backed by a real cycle.
|
||
|
||
A profile counts as "real" when it has a labelled cycle in ``past_cycles``
|
||
OR an imported ``reference_cycle`` (store-adopted templates that the matcher
|
||
treats as eligible snapshots — see the snapshot builder in async_match). An
|
||
import-only install has zero past_cycles but is fully matchable, so it must
|
||
pass this gate too, otherwise matching and the setup notifications are
|
||
skipped for it entirely.
|
||
"""
|
||
profile_names = self._data.get("profiles", {}).keys()
|
||
if not profile_names:
|
||
return False
|
||
assigned = {
|
||
c.get("profile_name")
|
||
for c in self._data.get("past_cycles", [])
|
||
if c.get("profile_name")
|
||
}
|
||
if assigned.intersection(profile_names):
|
||
return True
|
||
ref_assigned = {
|
||
c.get("profile_name")
|
||
for c in self._data.get("reference_cycles", [])
|
||
if c.get("profile_name")
|
||
}
|
||
return bool(ref_assigned.intersection(profile_names))
|
||
|
||
def list_profiles(self) -> list[dict[str, Any]]:
|
||
"""List all profiles with metadata."""
|
||
profiles: list[JSONDict] = []
|
||
raw_profiles = self._data.get("profiles", {})
|
||
profiles_map = (
|
||
cast(dict[str, Any], raw_profiles) if isinstance(raw_profiles, dict) else {}
|
||
)
|
||
for name, data in profiles_map.items():
|
||
profile_meta = cast(JSONDict, data) if isinstance(data, dict) else {}
|
||
|
||
# Calculate count and last_run
|
||
p_cycles = [
|
||
c for c in self._data.get("past_cycles", [])
|
||
if c.get("profile_name") == name
|
||
]
|
||
cycle_count = len(p_cycles)
|
||
|
||
last_run = None
|
||
if p_cycles:
|
||
last_c = max(p_cycles, key=lambda x: x.get("start_time", ""))
|
||
last_run = last_c.get("start_time")
|
||
|
||
# Fetch envelope stats
|
||
envelope = self.get_envelope(name)
|
||
avg_energy = envelope.get("avg_energy") if envelope else None
|
||
duration_std_dev = envelope.get("duration_std_dev") if envelope else None
|
||
|
||
# Per-profile cost aggregates from frozen per-cycle costs. Never raises;
|
||
# both default to None when no cycle carries a cost.
|
||
avg_cost: float | None = None
|
||
total_cost: float | None = None
|
||
try:
|
||
costs = [float(c["cost"]) for c in p_cycles if c.get("cost") is not None]
|
||
if costs:
|
||
total_cost = round(sum(costs), 4)
|
||
avg_cost = round(sum(costs) / len(costs), 4)
|
||
except Exception: # noqa: BLE001
|
||
avg_cost = None
|
||
total_cost = None
|
||
|
||
# Downsampled power signature (the profile's real average power curve),
|
||
# for the profile-card mini graph. Small (<=40 pts), power values only.
|
||
sig_curve: list[float] = []
|
||
try:
|
||
avg_pts = (envelope or {}).get("avg") or []
|
||
ps = [
|
||
float(p[1]) for p in avg_pts
|
||
if isinstance(p, (list, tuple)) and len(p) >= 2
|
||
]
|
||
if not ps: # legacy flat [p, ...] envelopes
|
||
ps = [float(x) for x in avg_pts if isinstance(x, (int, float))]
|
||
if len(ps) > 40:
|
||
step = (len(ps) - 1) / 39.0
|
||
sig_curve = [ps[round(i * step)] for i in range(40)]
|
||
else:
|
||
sig_curve = ps
|
||
except Exception: # noqa: BLE001
|
||
sig_curve = []
|
||
|
||
profiles.append(
|
||
{
|
||
"name": name,
|
||
"avg_duration": profile_meta.get("avg_duration", 0),
|
||
"min_duration": profile_meta.get("min_duration", 0),
|
||
"max_duration": profile_meta.get("max_duration", 0),
|
||
"sample_cycle_id": profile_meta.get("sample_cycle_id"),
|
||
"cycle_count": cycle_count,
|
||
"last_run": last_run,
|
||
"avg_energy": avg_energy,
|
||
"duration_std_dev": duration_std_dev,
|
||
"avg_cost": avg_cost,
|
||
"total_cost": total_cost,
|
||
"signature_curve": sig_curve,
|
||
"is_imported": self.profile_has_reference_cycles(name),
|
||
}
|
||
)
|
||
return sorted(profiles, key=lambda p: profile_sort_key(p.get("name", "")))
|
||
|
||
async def create_profile_standalone(
|
||
self,
|
||
name: str,
|
||
reference_cycle_id: str | None = None,
|
||
avg_duration: float | None = None,
|
||
) -> None:
|
||
"""Create a profile without immediately labeling a cycle.
|
||
If reference_cycle_id is provided, use that cycle's characteristics.
|
||
If avg_duration is provided (and no reference cycle), use it as baseline."""
|
||
if name in self._data.get("profiles", {}):
|
||
raise ValueError(f"Profile '{name}' already exists")
|
||
|
||
profile_data: JSONDict = {}
|
||
if reference_cycle_id:
|
||
cycle = next(
|
||
(c for c in self._data["past_cycles"] if c["id"] == reference_cycle_id),
|
||
None,
|
||
)
|
||
if cycle:
|
||
profile_data = {
|
||
"avg_duration": cycle["duration"],
|
||
"sample_cycle_id": reference_cycle_id,
|
||
}
|
||
# Label the reference cycle with the new profile so that
|
||
# statistics are immediately populated after creation.
|
||
if not cycle.get("profile_name"):
|
||
cycle["profile_name"] = name
|
||
elif avg_duration is not None and avg_duration > 0:
|
||
profile_data = {
|
||
"avg_duration": float(avg_duration),
|
||
}
|
||
|
||
# Create profile with minimal data (will be updated when cycles are labeled)
|
||
profile_data.setdefault("phases", [])
|
||
self._data.setdefault("profiles", {})[name] = profile_data
|
||
|
||
# Build the envelope from any already-labeled cycles (e.g. reference cycle above)
|
||
await self.async_rebuild_envelope(name)
|
||
|
||
await self.async_save()
|
||
self._logger.info("Created standalone profile '%s'", name)
|
||
|
||
async def update_profile(
|
||
self, old_name: str, new_name: str, avg_duration: float | None = None
|
||
) -> int:
|
||
"""Update a profile's name and/or average duration.
|
||
Returns number of cycles updated (if renamed)."""
|
||
profiles = self._data.get("profiles", {})
|
||
if old_name not in profiles:
|
||
raise ValueError(f"Profile '{old_name}' not found")
|
||
|
||
# Handle Rename
|
||
renamed = False
|
||
if new_name != old_name:
|
||
if new_name in profiles:
|
||
raise ValueError(f"Profile '{new_name}' already exists")
|
||
|
||
# Rename in profiles dict
|
||
profiles[new_name] = profiles.pop(old_name)
|
||
|
||
# Rename in envelopes
|
||
if "envelopes" in self._data and old_name in self._data["envelopes"]:
|
||
self._data["envelopes"][new_name] = self._data["envelopes"].pop(
|
||
old_name
|
||
)
|
||
|
||
renamed = True
|
||
|
||
target_name = new_name if renamed else old_name
|
||
|
||
# Handle Duration Update
|
||
if avg_duration is not None and avg_duration > 0:
|
||
profiles[target_name]["avg_duration"] = float(avg_duration)
|
||
# If there's an envelope, we ideally update its target_duration too,
|
||
# but envelope is usually rebuilt from data.
|
||
# However, for manual profiles, envelope might be empty or theoretical.
|
||
# Let's log it.
|
||
self._logger.info(
|
||
"Updated baseline duration for '%s' to %ss",
|
||
target_name,
|
||
avg_duration,
|
||
)
|
||
|
||
# Update cycles and feedback if renamed
|
||
count = 0
|
||
if renamed:
|
||
# 1. Update past + imported reference cycles (imports carry profile_name
|
||
# too; leaving them under the old name orphans them from the matcher).
|
||
for cycle in (
|
||
list(self._data.get("past_cycles", []))
|
||
+ list(self._data.get("reference_cycles", []))
|
||
):
|
||
if cycle.get("profile_name") == old_name:
|
||
cycle["profile_name"] = new_name
|
||
count += 1
|
||
|
||
# 2. Update pending feedback
|
||
pending = self.get_pending_feedback()
|
||
for req in pending.values():
|
||
if req.get("detected_profile") == old_name:
|
||
req["detected_profile"] = new_name
|
||
|
||
# 3. Update feedback history
|
||
history = self.get_feedback_history()
|
||
for record in history.values():
|
||
if record.get("original_detected_profile") == old_name:
|
||
record["original_detected_profile"] = new_name
|
||
if record.get("corrected_profile") == old_name:
|
||
record["corrected_profile"] = new_name
|
||
|
||
# 4. Update group membership (rename the member in any group)
|
||
for g in self._data.get("profile_groups", {}).values():
|
||
mems = g.get("members") if isinstance(g, dict) else None
|
||
if isinstance(mems, list) and old_name in mems:
|
||
g["members"] = [new_name if m == old_name else m for m in mems]
|
||
|
||
self._logger.info(
|
||
"Renamed profile '%s' to '%s', updated %s cycles and associated feedback",
|
||
old_name,
|
||
new_name,
|
||
count,
|
||
)
|
||
|
||
await self.async_save()
|
||
return count
|
||
|
||
async def delete_profile(self, name: str, unlabel_cycles: bool = True) -> int:
|
||
"""Delete a profile.
|
||
If unlabel_cycles=True, removes profile label from cycles.
|
||
If unlabel_cycles=False, cycles keep the label (orphaned).
|
||
Returns number of cycles affected."""
|
||
if name not in self._data.get("profiles", {}):
|
||
raise ValueError(f"Profile '{name}' not found")
|
||
|
||
# Delete profile
|
||
del self._data["profiles"][name]
|
||
|
||
# Handle cycles (past + imported reference; both carry profile_name, so an
|
||
# imported cycle would otherwise keep a dangling label for a deleted profile).
|
||
count = 0
|
||
for cycle in (
|
||
list(self._data.get("past_cycles", []))
|
||
+ list(self._data.get("reference_cycles", []))
|
||
):
|
||
if cycle.get("profile_name") == name:
|
||
if unlabel_cycles:
|
||
cycle["profile_name"] = None
|
||
count += 1
|
||
|
||
await self.async_save()
|
||
action = "unlabeled" if unlabel_cycles else "orphaned"
|
||
self._logger.info("Deleted profile '%s', %s %s cycles", name, action, count)
|
||
return count
|
||
|
||
async def clear_all_data(self) -> None:
|
||
"""Clear all profiles, cycle data, and derived state."""
|
||
self._data["past_cycles"] = []
|
||
self._data["reference_cycles"] = []
|
||
self._data["profiles"] = {}
|
||
self._data["envelopes"] = {}
|
||
self._data["suggestions"] = {}
|
||
self._data["feedback_history"] = {}
|
||
self._data["pending_feedback"] = {}
|
||
self._data["auto_adjustments"] = []
|
||
self._data["active_cycle"] = None
|
||
self._data["last_active_save"] = None
|
||
# Newer persisted state must also be wiped, else a "wipe all" leaves trained
|
||
# models, groups, matcher tuning, histories, and counters behind.
|
||
self._data["custom_phases"] = []
|
||
self._data["ml_model_versions"] = {}
|
||
self._data["profile_groups"] = {}
|
||
self._data["maintenance_log"] = []
|
||
self._data["matching_config"] = {}
|
||
self._data["match_ranking_history"] = []
|
||
self._data["ml_last_training_run"] = None
|
||
self._data["ml_training_history"] = {}
|
||
self._data["lifetime_energy_wh"] = 0.0
|
||
self._data["lifetime_cycle_count"] = 0
|
||
self._data["settings_changelog"] = []
|
||
self._data["suggestion_apply_cycle_count"] = 0
|
||
self._cached_sample_segments = {}
|
||
self._cohesion_cache = {}
|
||
self._cohesion_cache_generation += 1
|
||
await self.async_save()
|
||
self._logger.info("Cleared all WashData storage")
|
||
|
||
async def assign_profile_to_cycle(
|
||
self, cycle_id: str, profile_name: str | None
|
||
) -> None:
|
||
"""Assign an existing profile to a cycle. Rebuilds envelope."""
|
||
old_profile = None
|
||
cycle = next(
|
||
(c for c in self._data["past_cycles"] if c["id"] == cycle_id), None
|
||
)
|
||
if not cycle:
|
||
# Imported reference recording (separate list, never in usage stats):
|
||
# relabelling just moves which profile's template it seeds.
|
||
ref = next(
|
||
(c for c in self.get_reference_cycles() if c.get("id") == cycle_id),
|
||
None,
|
||
)
|
||
if ref is not None:
|
||
await self._assign_reference_cycle_profile(ref, profile_name)
|
||
return
|
||
raise ValueError(f"Cycle {cycle_id} not found")
|
||
|
||
# Track old profile for envelope rebuild
|
||
old_profile = cycle.get("profile_name")
|
||
|
||
if profile_name and profile_name not in self._data.get("profiles", {}):
|
||
raise ValueError(f"Profile '{profile_name}' not found. Create it first.")
|
||
|
||
# Preserve original auto-assigned label before first manual relabeling
|
||
if profile_name and old_profile and not cycle.get("original_auto_label"):
|
||
orig_src = cycle.get("label_source", "")
|
||
if orig_src in ("auto_match", "auto_label_post", "auto_label_service"):
|
||
cycle["original_auto_label"] = old_profile
|
||
|
||
# Update cycle
|
||
cycle["profile_name"] = profile_name if profile_name else None
|
||
cycle["label_source"] = "manual" if profile_name else None
|
||
|
||
# Update profile metadata if this is the first cycle
|
||
if profile_name:
|
||
profile = self._data["profiles"][profile_name]
|
||
if not profile.get("sample_cycle_id"):
|
||
profile["sample_cycle_id"] = cycle_id
|
||
profile["avg_duration"] = cycle["duration"]
|
||
|
||
# Rebuild envelopes for affected profiles
|
||
if old_profile and old_profile != profile_name:
|
||
await self.async_rebuild_envelope(old_profile) # Old profile lost a cycle
|
||
if profile_name:
|
||
await self.async_rebuild_envelope(profile_name) # New profile gained a cycle
|
||
# Apply retention after labeling, in case profile now exceeds cap
|
||
await self.async_enforce_retention()
|
||
|
||
await self.async_save()
|
||
self._logger.info("Assigned profile '%s' to cycle %s", profile_name, cycle_id)
|
||
# Trigger smart processing to potentially merge now-labeled cycle
|
||
await self.async_smart_process_history()
|
||
|
||
async def _assign_reference_cycle_profile(
|
||
self, ref: CycleDict, profile_name: str | None
|
||
) -> None:
|
||
"""Reassign an imported reference recording to a different profile.
|
||
|
||
The cycle stays in ``reference_cycles`` (out of usage stats); only which
|
||
profile envelope it seeds changes. Rebuilds the old and new envelopes.
|
||
``profile_name=None`` clears the label (the recording then seeds nothing).
|
||
"""
|
||
if profile_name and profile_name not in self._data.get("profiles", {}):
|
||
raise ValueError(f"Profile '{profile_name}' not found. Create it first.")
|
||
old_profile = ref.get("profile_name")
|
||
ref_id = ref.get("id")
|
||
ref["profile_name"] = profile_name if profile_name else None
|
||
if old_profile and old_profile != profile_name:
|
||
# The moved cycle may have been the old profile's sample. Clear that
|
||
# stale pointer so the old profile can't resolve the moved trace (now
|
||
# another profile's) by id, and drop the old profile if it is now empty
|
||
# (mirrors _delete_reference_cycle; prevents repair adopting a real cycle).
|
||
op = self._data.get("profiles", {}).get(old_profile)
|
||
if op is not None and op.get("sample_cycle_id") == ref_id:
|
||
op["sample_cycle_id"] = None
|
||
old_has_cycles = any(
|
||
c.get("profile_name") == old_profile
|
||
for c in list(self._data.get("past_cycles", []))
|
||
+ list(self._data.get("reference_cycles", []))
|
||
)
|
||
if old_has_cycles:
|
||
await self.async_rebuild_envelope(old_profile)
|
||
else:
|
||
self._data.get("profiles", {}).pop(old_profile, None)
|
||
self._data.get("envelopes", {}).pop(old_profile, None)
|
||
if profile_name:
|
||
await self.async_rebuild_envelope(profile_name)
|
||
await self.async_save()
|
||
self._logger.info(
|
||
"Reassigned imported reference cycle %s to profile '%s'",
|
||
ref.get("id"), profile_name,
|
||
)
|
||
|
||
async def auto_label_cycles(
|
||
self, confidence_threshold: float = 0.75, overwrite: bool = False
|
||
) -> dict[str, int]:
|
||
"""Auto-label cycles retroactively using profile matching.
|
||
|
||
Args:
|
||
confidence_threshold: Min confidence to apply a label.
|
||
overwrite: If True, re-evaluates already labeled cycles.
|
||
|
||
Returns stats: {labeled: int, relabeled: int, skipped: int, total: int}
|
||
"""
|
||
stats = {"labeled": 0, "relabeled": 0, "skipped": 0, "total": 0}
|
||
|
||
cycles = self._data.get("past_cycles", [])
|
||
|
||
# Filter down if not overwriting
|
||
if not overwrite:
|
||
target_cycles = [c for c in cycles if not c.get("profile_name")]
|
||
else:
|
||
target_cycles = cycles
|
||
|
||
stats["total"] = len(target_cycles)
|
||
|
||
for cycle in target_cycles:
|
||
# Reconstruct power data for matching
|
||
power_data = decompress_power_data(cycle)
|
||
if not power_data or len(power_data) < 10:
|
||
stats["skipped"] += 1
|
||
continue
|
||
|
||
# Try to match
|
||
result = await self.async_match_profile(power_data, cycle["duration"])
|
||
|
||
# Honor the ambiguity safeguard: never auto-label a close/ambiguous match.
|
||
if result.best_profile and result.confidence >= confidence_threshold and not result.is_ambiguous:
|
||
current_label = cycle.get("profile_name")
|
||
# Sanitize: strip heavy current/sample arrays before persisting.
|
||
ranking_top5 = [
|
||
{
|
||
"name": c.get("name"),
|
||
"score": round(float(c.get("score", 0.0)), 3),
|
||
"profile_duration": c.get("profile_duration"),
|
||
}
|
||
for c in (getattr(result, "ranking", []) or [])[:5]
|
||
]
|
||
|
||
# If overwriting, check if new match is different and better/valid
|
||
if current_label:
|
||
if current_label != result.best_profile:
|
||
# Preserve original label before first auto-service relabeling
|
||
if not cycle.get("original_auto_label"):
|
||
orig_src = cycle.get("label_source", "")
|
||
if orig_src in ("auto_match", "auto_label_post", "auto_label_service"):
|
||
cycle["original_auto_label"] = current_label
|
||
cycle["profile_name"] = result.best_profile
|
||
cycle["match_confidence"] = float(result.confidence)
|
||
cycle["label_source"] = "auto_label_service"
|
||
if ranking_top5:
|
||
cycle["match_ranking_top5"] = ranking_top5
|
||
stats["relabeled"] += 1
|
||
self._logger.info(
|
||
"Relabeled cycle %s: '%s' -> '%s' (confidence: %.2f)",
|
||
cycle["id"],
|
||
current_label,
|
||
result.best_profile,
|
||
result.confidence,
|
||
)
|
||
else:
|
||
cycle["profile_name"] = result.best_profile
|
||
cycle["match_confidence"] = float(result.confidence)
|
||
cycle["label_source"] = "auto_label_service"
|
||
if ranking_top5:
|
||
cycle["match_ranking_top5"] = ranking_top5
|
||
stats["labeled"] += 1
|
||
self._logger.info(
|
||
"Auto-labeled cycle %s as '%s' (confidence: %.2f)",
|
||
cycle["id"],
|
||
result.best_profile,
|
||
result.confidence,
|
||
)
|
||
else:
|
||
stats["skipped"] += 1
|
||
|
||
if stats["labeled"] > 0 or stats["relabeled"] > 0:
|
||
await self.async_save()
|
||
# Trigger smart processing after bulk labeling
|
||
await self.async_smart_process_history()
|
||
|
||
self._logger.info(
|
||
"Auto-labeling complete: %s labeled, %s relabeled, %s skipped",
|
||
stats["labeled"],
|
||
stats["relabeled"],
|
||
stats["skipped"],
|
||
)
|
||
return stats
|
||
|
||
async def async_backfill_match_confidence(self) -> int:
|
||
"""Populate match_confidence for labeled cycles that predate the field.
|
||
|
||
Runs the matcher once per cycle with profile_name set but no
|
||
match_confidence, and persists the resulting confidence if the same
|
||
profile is returned. Returns the number of cycles updated. Safe to
|
||
call repeatedly - already-backfilled cycles are skipped.
|
||
"""
|
||
cycles = self._data.get("past_cycles", []) or []
|
||
updated = 0
|
||
for cycle in cycles:
|
||
if cycle.get("match_confidence") is not None:
|
||
continue
|
||
profile_name = cycle.get("profile_name")
|
||
if not profile_name:
|
||
continue
|
||
power_data = decompress_power_data(cycle)
|
||
if not power_data or len(power_data) < 10:
|
||
continue
|
||
try:
|
||
result = await self.async_match_profile(
|
||
power_data, cycle.get("duration", 0)
|
||
)
|
||
except Exception: # pylint: disable=broad-exception-caught
|
||
continue
|
||
if result.best_profile == profile_name and result.confidence > 0:
|
||
cycle["match_confidence"] = float(result.confidence)
|
||
updated += 1
|
||
if updated:
|
||
await self.async_save()
|
||
self._logger.info("Backfilled match_confidence on %d cycles", updated)
|
||
return updated
|
||
|
||
async def async_migrate_cycles_to_compressed(self) -> int:
|
||
"""
|
||
Migrate all cycles to the compressed format.
|
||
Ensures all cycles use [offset_seconds, power] format.
|
||
Returns number of cycles migrated.
|
||
"""
|
||
raw_cycles = self._data.get("past_cycles", [])
|
||
cycles: list[CycleDict] = (
|
||
cast(list[CycleDict], raw_cycles) if isinstance(raw_cycles, list) else []
|
||
)
|
||
migrated = 0
|
||
|
||
for cycle in cycles:
|
||
raw_data: list[Any] = cycle.get("power_data", []) or []
|
||
if not raw_data:
|
||
continue
|
||
|
||
# Check if already compressed (first element is number or mixed format)
|
||
first_elem = raw_data[0][0]
|
||
if isinstance(first_elem, (int, float)):
|
||
# Already in offset format
|
||
continue
|
||
|
||
# Old format: ISO timestamp strings. Convert to compressed offsets.
|
||
try:
|
||
compressed = compress_power_data(cycle)
|
||
if compressed:
|
||
cycle["power_data"] = compressed
|
||
migrated += 1
|
||
except Exception as e: # pylint: disable=broad-exception-caught
|
||
self._logger.warning("Failed to migrate cycle %s: %s", cycle.get("id"), e)
|
||
continue
|
||
|
||
if migrated > 0:
|
||
self._logger.info("Migrated %s cycles to compressed format", migrated)
|
||
await self.async_save()
|
||
|
||
return migrated
|
||
|
||
|
||
|
||
async def async_smart_process_history(
|
||
self
|
||
) -> dict[str, int]:
|
||
# Orchestrate smart history processing: Cleanup, Retention.
|
||
# Split/Merge is now manual via Interactive Editor.
|
||
stats = {"cleaned_profiles": 0}
|
||
|
||
# 1. Cleanup
|
||
self._logger.debug("Running maintenance: cleanup_orphaned_profiles")
|
||
stats["cleaned_profiles"] = self.cleanup_orphaned_profiles()
|
||
|
||
# 2. Retention
|
||
self._logger.debug("Running maintenance: async_enforce_retention")
|
||
await self.async_enforce_retention()
|
||
|
||
# 3. Save
|
||
self._logger.debug("Maintenance complete, saving")
|
||
await self.async_save()
|
||
|
||
return stats
|
||
|
||
|
||
|
||
def export_data(
|
||
self,
|
||
entry_data: JSONDict | None = None,
|
||
entry_options: JSONDict | None = None,
|
||
selection: dict[str, Any] | None = None,
|
||
) -> JSONDict:
|
||
# Return a serializable snapshot of the store for backup/export.
|
||
# Includes config entry data/options so users can transfer fine-tuned settings.
|
||
#
|
||
# ``selection`` (wizard) narrows the payload to chosen categories/items; when
|
||
# ``None`` this is the whole-store dump the diagnostics + quick-export callers
|
||
# rely on. The envelope shape ({version, data, entry_data, entry_options, ...})
|
||
# is identical either way so old importers accept a selective export too.
|
||
opts = entry_options or {}
|
||
data = entry_data or {}
|
||
_FINGERPRINT_KEYS = (
|
||
"off_delay", "min_power", "sampling_interval",
|
||
"start_power_threshold", "idle_power_threshold",
|
||
"min_cycle_duration", "max_cycle_duration",
|
||
"delay_start_detect_enabled",
|
||
)
|
||
device_fingerprint: JSONDict = {
|
||
"device_type": data.get("device_type") or opts.get("device_type", "unknown"),
|
||
}
|
||
for key in _FINGERPRINT_KEYS:
|
||
if key in opts:
|
||
device_fingerprint[key] = opts[key]
|
||
if selection is None:
|
||
# Never let a backup/diagnostics export carry the GitHub refresh token: the
|
||
# (legacy, now-global) per-device store account may still hold it. Shallow-copy
|
||
# so we can drop the credential without mutating live state, and so a caller
|
||
# can't alias-mutate self._data through the returned snapshot.
|
||
export_store = dict(self._data)
|
||
export_store.pop("store_account", None)
|
||
out_entry_data = data
|
||
out_entry_options = opts
|
||
else:
|
||
# Selective export: start from an empty blob and copy only chosen categories
|
||
# (store_account can never leak because it is not a category). Raw entry_data
|
||
# is never shipped (it carries the source power_sensor binding); settings
|
||
# travel only as the shareable numeric subset, and only when selected.
|
||
export_store = self._filter_export_data(dict(self._data), selection, opts)
|
||
cats = _selected_categories(selection)
|
||
out_entry_data = {}
|
||
out_entry_options = _shareable_settings(opts) if "settings" in cats else {}
|
||
return {
|
||
"version": STORAGE_VERSION,
|
||
"entry_id": self.entry_id,
|
||
"exported_at": dt_util.now().isoformat(),
|
||
"device_fingerprint": device_fingerprint,
|
||
"data": export_store,
|
||
"entry_data": out_entry_data,
|
||
"entry_options": out_entry_options,
|
||
}
|
||
|
||
def get_export_inventory(self, entry_options: JSONDict | None = None) -> dict[str, Any]:
|
||
"""Per-category inventory of this device's store, for the export wizard tree."""
|
||
return _scan_data(self._data, entry_options=entry_options or {})
|
||
|
||
def _filter_export_data(
|
||
self, data: dict[str, Any], selection: dict[str, Any], entry_options: JSONDict
|
||
) -> dict[str, Any]:
|
||
"""Build a filtered ``data`` blob containing only the selected categories/items.
|
||
|
||
Enumerable categories (profiles, real/reference cycles) honour an optional
|
||
per-category id/name subset from ``selection``; when the subset is absent the
|
||
whole category is included. The "profiles empty" rule: when profiles are
|
||
selected but neither cycle category is, the profiles' cached envelopes are
|
||
carried so the target can still match without the raw traces.
|
||
"""
|
||
cats = _selected_categories(selection)
|
||
out: dict[str, Any] = {}
|
||
profiles_src = data.get("profiles") if isinstance(data.get("profiles"), dict) else {}
|
||
|
||
selected_profiles: set[str] | None = None
|
||
if "profiles" in cats:
|
||
names = selection.get("profiles")
|
||
if isinstance(names, list):
|
||
selected_profiles = {str(n) for n in names}
|
||
out["profiles"] = {
|
||
n: profiles_src[n] for n in profiles_src if n in selected_profiles
|
||
}
|
||
else:
|
||
selected_profiles = set(profiles_src.keys())
|
||
out["profiles"] = dict(profiles_src)
|
||
|
||
def _filter_cycles(key: str, id_sel_key: str) -> list[Any]:
|
||
src = data.get(key) if isinstance(data.get(key), list) else []
|
||
ids = selection.get(id_sel_key)
|
||
if isinstance(ids, list):
|
||
idset = {str(i) for i in ids}
|
||
return [c for c in src if isinstance(c, dict) and str(c.get("id")) in idset]
|
||
return [c for c in src if isinstance(c, dict)]
|
||
|
||
include_real = "real_cycles" in cats
|
||
include_ref = "reference_cycles" in cats
|
||
if include_real:
|
||
out["past_cycles"] = _filter_cycles("past_cycles", "real_cycle_ids")
|
||
if include_ref:
|
||
out["reference_cycles"] = _filter_cycles("reference_cycles", "reference_cycle_ids")
|
||
|
||
# "profiles empty" rule: carry cached envelopes when profiles ship without cycles.
|
||
if "profiles" in cats and not include_real and not include_ref:
|
||
envs = data.get("envelopes") if isinstance(data.get("envelopes"), dict) else {}
|
||
carried = {n: envs[n] for n in (selected_profiles or set()) if n in envs}
|
||
if carried:
|
||
out["envelopes"] = carried
|
||
|
||
# Leaf categories: copy their raw store keys verbatim.
|
||
for cat_id in cats:
|
||
if cat_id in _STRUCTURAL_CATEGORIES:
|
||
continue
|
||
for key in _EXPORT_CATEGORIES.get(cat_id, {}).get("keys", []):
|
||
if key in data:
|
||
out[key] = data[key]
|
||
return out
|
||
|
||
async def async_import_data(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||
"""Wholesale import: replace the entire store from a JSON payload.
|
||
|
||
Migration/wrapper aware via ``unwrap_import_payload`` (HA diagnostics,
|
||
integration ``store_export``, v1 flat, v2 nested). This is the destructive
|
||
"replace everything" path used by the legacy service / raw-JSON fallback;
|
||
selective merge lives in ``async_import_data_selective``.
|
||
"""
|
||
data_dict, meta = unwrap_import_payload(payload)
|
||
self._logger.info(
|
||
"Importing %s format: %s cycles",
|
||
meta.get("format"),
|
||
len(data_dict.get("past_cycles", [])),
|
||
)
|
||
|
||
if not data_dict.get("profiles") and not data_dict.get("past_cycles"):
|
||
raise ValueError(
|
||
"Import payload contains no profiles or cycles — aborting to prevent data loss"
|
||
)
|
||
self._data = data_dict
|
||
self._cached_sample_segments = {}
|
||
await self.async_save()
|
||
|
||
return {
|
||
"entry_data": meta.get("entry_data", {}),
|
||
"entry_options": meta.get("entry_options", {}),
|
||
}
|
||
|
||
async def async_import_data_selective(
|
||
self,
|
||
payload: dict[str, Any],
|
||
*,
|
||
selection: dict[str, Any],
|
||
mode: str = "merge",
|
||
conflict_resolutions: dict[str, str] | None = None,
|
||
cycle_destination: str = "reference",
|
||
apply_settings: bool = True,
|
||
local_device_type: str = "",
|
||
) -> dict[str, Any]:
|
||
"""Selectively import chosen categories/items, merging into existing data.
|
||
|
||
``mode`` = ``"merge"`` (additive, nothing local is lost) or ``"replace"``
|
||
(each ticked category is wiped and replaced from the file). ``cycle_destination``
|
||
routes imported real cycles to ``"reference"`` (shape-only, never touches usage/
|
||
energy/count/lifetime stats — the hard isolation invariant) or ``"real_history"``
|
||
(``past_cycles``, feeds stats; only when the source device type matches).
|
||
``conflict_resolutions`` maps a source profile name to
|
||
``keep_mine`` / ``overwrite`` / ``import_as_copy``.
|
||
|
||
Reuses the community-adopt primitives (``_add_reference_cycle_nosave``,
|
||
``_add_cycle_data``) and rebuilds each cycle-receiving profile's envelope
|
||
exactly once. Never routes through the wholesale replace path.
|
||
"""
|
||
data_dict, meta = unwrap_import_payload(payload)
|
||
conflict_resolutions = conflict_resolutions or {}
|
||
mode = "replace" if str(mode) == "replace" else "merge"
|
||
cycle_destination = (
|
||
"real_history" if str(cycle_destination) == "real_history" else "reference"
|
||
)
|
||
cats = _selected_categories(selection)
|
||
if not cats:
|
||
raise ValueError("Import selection is empty — nothing to import")
|
||
|
||
# Device-type gating: block device-specific categories and force reference-only
|
||
# cycle destination when the source device type does not match this device.
|
||
source_dt = str((meta.get("device_fingerprint") or {}).get("device_type") or "") or None
|
||
# Keep this identical to build_import_manifest so the analysis shown to the user
|
||
# (which disables device-specific categories / real-history on a mismatch) cannot
|
||
# diverge from what the apply actually does. An unknown local device type is treated
|
||
# as a mismatch (conservative), matching the manifest.
|
||
device_type_match = (not source_dt) or (source_dt == local_device_type)
|
||
if not device_type_match:
|
||
cats = {c for c in cats if not _EXPORT_CATEGORIES.get(c, {}).get("device_specific")}
|
||
cycle_destination = "reference"
|
||
|
||
src_profiles = data_dict.get("profiles") if isinstance(data_dict.get("profiles"), dict) else {}
|
||
src_past = [c for c in (data_dict.get("past_cycles") or []) if isinstance(c, dict)]
|
||
src_refs = [c for c in (data_dict.get("reference_cycles") or []) if isinstance(c, dict)]
|
||
|
||
prof_filter = selection.get("profiles") if isinstance(selection.get("profiles"), list) else None
|
||
real_id_filter = (
|
||
selection.get("real_cycle_ids") if isinstance(selection.get("real_cycle_ids"), list) else None
|
||
)
|
||
ref_id_filter = (
|
||
selection.get("reference_cycle_ids")
|
||
if isinstance(selection.get("reference_cycle_ids"), list)
|
||
else None
|
||
)
|
||
|
||
def _apply_prof_filter(names: list[str]) -> list[str]:
|
||
if prof_filter is None:
|
||
return names
|
||
keep = {str(n) for n in prof_filter}
|
||
return [n for n in names if n in keep]
|
||
|
||
def _apply_id_filter(cycles: list[dict[str, Any]], idf: list[Any] | None) -> list[dict[str, Any]]:
|
||
if idf is None:
|
||
return cycles
|
||
keep = {str(i) for i in idf}
|
||
return [c for c in cycles if str(c.get("id")) in keep]
|
||
|
||
# The cycles that will actually be imported once the per-item id selection is applied.
|
||
selected_refs = _apply_id_filter(src_refs, ref_id_filter)
|
||
selected_past = _apply_id_filter(src_past, real_id_filter)
|
||
|
||
def _any_usable(cs: list[dict[str, Any]]) -> bool:
|
||
# A cycle bound for reference storage only refills the destination if its trace is
|
||
# usable (_add_reference_cycle_nosave silently no-ops on a degenerate trace), so
|
||
# the wipe guard checks usability, not mere presence, for reference-bound cycles.
|
||
return any(_usable_reference_pairs(c.get("power_data")) is not None for c in cs)
|
||
|
||
def _any_real_importable(cs: list[dict[str, Any]]) -> bool:
|
||
# A real-history record is skipped in Step 3 if it lacks start_time/duration (the
|
||
# fields _add_cycle_data requires), so the wipe guard requires at least one record
|
||
# that carries both -- otherwise an all-malformed selection wipes past_cycles and
|
||
# refills nothing.
|
||
return any(
|
||
c.get("start_time") is not None and c.get("duration") is not None for c in cs
|
||
)
|
||
|
||
# Replace-mode anti-data-loss guard: never wipe a destination unless the file has a
|
||
# SELECTED set that will actually refill it. Guarding on the post-filter sets (not raw
|
||
# src_*) means a stale/malformed id selection that matches nothing aborts here instead
|
||
# of emptying the local list; for reference-bound cycles we further require at least
|
||
# one usable trace so an all-degenerate selection can't wipe-then-add-nothing. Profiles
|
||
# are never wiped (per-name overwrite/copy), so a profiles-only replace needs no guard.
|
||
if mode == "replace":
|
||
if "reference_cycles" in cats and not _any_usable(selected_refs):
|
||
raise ValueError(
|
||
"Import payload has no usable reference cycles — aborting to prevent data loss"
|
||
)
|
||
if "real_cycles" in cats:
|
||
if cycle_destination == "reference" and not _any_usable(selected_past):
|
||
raise ValueError(
|
||
"Import payload has no usable cycles — aborting to prevent data loss"
|
||
)
|
||
if cycle_destination == "real_history" and not _any_real_importable(selected_past):
|
||
raise ValueError(
|
||
"Import payload contains no real cycles — aborting to prevent data loss"
|
||
)
|
||
|
||
local_profiles = self._data.setdefault("profiles", {})
|
||
name_remap: dict[str, str] = {}
|
||
touched: set[str] = set()
|
||
overwritten: set[str] = set() # profiles whose definition the file replaced in place
|
||
created_profiles = 0
|
||
conflicts_resolved = 0
|
||
real_imported = 0
|
||
ref_imported = 0
|
||
malformed_skipped = 0 # records dropped mid-import (surfaced in the summary)
|
||
|
||
# ── Step 1: profiles + conflict resolution ──────────────────────────────
|
||
if "profiles" in cats:
|
||
for name in _apply_prof_filter(list(src_profiles.keys())):
|
||
src_def = src_profiles.get(name)
|
||
if not isinstance(src_def, dict):
|
||
continue
|
||
if name in local_profiles:
|
||
# In replace mode the file wins for every ticked category, so a name
|
||
# clash always overwrites the local profile in place. This is enforced
|
||
# here (not just as a default) because the panel still transmits its
|
||
# analyze-time per-conflict resolutions even though it hides the resolver
|
||
# in replace mode — honouring an "import_as_copy" there would leave a
|
||
# stale original with an un-rebuilt envelope and, when reference_cycles
|
||
# is also replaced, a dangling sample_cycle_id. Merge mode keeps the safe
|
||
# copy default and shows the resolver.
|
||
res = "overwrite" if mode == "replace" else str(
|
||
conflict_resolutions.get(name, "import_as_copy")
|
||
)
|
||
conflicts_resolved += 1
|
||
if res == "keep_mine":
|
||
name_remap[name] = name # cycles route to the existing local profile
|
||
elif res == "overwrite":
|
||
local_profiles[name] = dict(src_def)
|
||
name_remap[name] = name
|
||
overwritten.add(name) # let Step 5 replace any stale local envelope
|
||
else: # import_as_copy
|
||
new_name = _unique_profile_name(local_profiles, f"{name} (imported)")
|
||
local_profiles[new_name] = dict(src_def)
|
||
name_remap[name] = new_name
|
||
created_profiles += 1
|
||
else:
|
||
local_profiles[name] = dict(src_def)
|
||
name_remap[name] = name
|
||
created_profiles += 1
|
||
|
||
def _ensure_profile(name: str, duration: float) -> None:
|
||
nonlocal created_profiles
|
||
if name and name not in local_profiles:
|
||
local_profiles[name] = {"avg_duration": float(duration or 0)}
|
||
created_profiles += 1
|
||
|
||
# Replace-mode wipes (hoisted so the id pools + dedup set below reflect the
|
||
# post-wipe lists). Each ticked cycle category clears the list it actually writes to
|
||
# (per the docstring's "each ticked category is wiped and replaced"), so real_cycles
|
||
# routed to the default reference destination wipes reference_cycles too -- not just
|
||
# when the reference_cycles category itself is ticked. The empty-payload guard above
|
||
# already refused to run if the file had nothing to replace it with.
|
||
if mode == "replace":
|
||
wipe_reference = "reference_cycles" in cats or (
|
||
"real_cycles" in cats and cycle_destination == "reference"
|
||
)
|
||
wipe_past = "real_cycles" in cats and cycle_destination == "real_history"
|
||
if wipe_reference:
|
||
self._data["reference_cycles"] = []
|
||
if wipe_past:
|
||
self._data["past_cycles"] = []
|
||
|
||
# O(N) id pools threaded through the bulk cycle adds so SHA-id uniqueness stays
|
||
# O(1) amortized instead of rebuilding the id set from the growing destination on
|
||
# every insert (previously O(N^2) for a large import, all on the event loop).
|
||
ref_id_pool: set[Any] = {c.get("id") for c in self._data.get("reference_cycles", []) if isinstance(c, dict)}
|
||
past_id_pool: set[Any] = {c.get("id") for c in self._data.get("past_cycles", []) if isinstance(c, dict)}
|
||
def _bare_store_id(raw_id: Any) -> str:
|
||
# A reference cycle exported after a prior import already carries
|
||
# meta.source="store:<id>". Strip EVERY leading "store:" so re-prefixing does not
|
||
# accumulate "store:store:..." across round-trips and, crucially, so historical
|
||
# double-prefixed values already persisted collapse to the same canonical id --
|
||
# otherwise a later import of "store:<id>" would miss a stored "store:store:<id>"
|
||
# and add a duplicate.
|
||
sid = str(raw_id or "")
|
||
while sid.startswith("store:"):
|
||
sid = sid[len("store:"):]
|
||
return sid
|
||
|
||
def _ref_dedup_key(raw_id: Any) -> str:
|
||
bare = _bare_store_id(raw_id)
|
||
return f"store:{bare}" if bare else ""
|
||
|
||
# Reference-cycle dedup: skip re-importing a reference cycle already held so a
|
||
# repeated import stays idempotent (otherwise envelopes get double-weighted and
|
||
# cycle_count inflates). Keyed on the CANONICAL source id, so a legacy double-prefixed
|
||
# persisted value and a fresh single-prefixed import map to the same key.
|
||
existing_ref_sources: set[str] = set()
|
||
for c in self._data.get("reference_cycles", []):
|
||
if not isinstance(c, dict):
|
||
continue
|
||
meta = c.get("meta")
|
||
src = meta.get("source") if isinstance(meta, dict) else None
|
||
if src:
|
||
existing_ref_sources.add(_ref_dedup_key(src))
|
||
|
||
# ── Step 2: reference cycles (shape-only) ───────────────────────────────
|
||
# Each record is processed defensively: a single malformed record is skipped (not
|
||
# fatal) so a replace-mode import can never abort part-way and leave a wiped
|
||
# destination in an inconsistent state (the wipe above already happened).
|
||
if "reference_cycles" in cats:
|
||
for c in selected_refs:
|
||
try:
|
||
orig = str(c.get("profile_name") or "")
|
||
target = name_remap.get(orig, orig)
|
||
if not target:
|
||
continue
|
||
src_meta = c.get("meta") if isinstance(c.get("meta"), dict) else {}
|
||
raw_sid = src_meta.get("source") or c.get("id")
|
||
dkey = _ref_dedup_key(raw_sid)
|
||
if dkey and dkey in existing_ref_sources:
|
||
continue # already imported -> keep re-import idempotent
|
||
_ensure_profile(target, c.get("duration") or 0)
|
||
cid = self._add_reference_cycle_nosave(
|
||
target,
|
||
c.get("power_data") or [],
|
||
{
|
||
"store_cycle_id": _bare_store_id(raw_sid),
|
||
"store_uploaded_at": src_meta.get("store_uploaded_at"),
|
||
"sampling_interval": c.get("sampling_interval"),
|
||
},
|
||
id_pool=ref_id_pool,
|
||
)
|
||
if cid:
|
||
if dkey:
|
||
existing_ref_sources.add(dkey)
|
||
touched.add(target)
|
||
ref_imported += 1
|
||
except Exception: # noqa: BLE001 - skip a malformed record, never abort the import
|
||
malformed_skipped += 1
|
||
self._logger.debug("selective import: skipped a malformed reference cycle", exc_info=True)
|
||
|
||
# ── Step 3: real cycles (reference-shape or real-history) ───────────────
|
||
if "real_cycles" in cats:
|
||
# Identity set (native ids + prior import provenance) for real-history dedup.
|
||
known_ids: set[str] = set()
|
||
for c in self._data.get("past_cycles", []):
|
||
if isinstance(c, dict):
|
||
if c.get("id"):
|
||
known_ids.add(str(c.get("id")))
|
||
imp = (c.get("meta") or {}).get("imported_from")
|
||
if imp:
|
||
known_ids.add(str(imp))
|
||
for c in selected_past:
|
||
try:
|
||
orig = str(c.get("profile_name") or "")
|
||
target = name_remap.get(orig, orig)
|
||
if cycle_destination == "reference":
|
||
raw_sid = c.get("id")
|
||
dkey = _ref_dedup_key(raw_sid)
|
||
if dkey and dkey in existing_ref_sources:
|
||
continue # already imported as a reference cycle
|
||
_ensure_profile(target or "(imported)", c.get("duration") or 0)
|
||
cid = self._add_reference_cycle_nosave(
|
||
target or "(imported)", c.get("power_data") or [],
|
||
{"store_cycle_id": _bare_store_id(raw_sid)}, id_pool=ref_id_pool,
|
||
)
|
||
if cid:
|
||
if dkey:
|
||
existing_ref_sources.add(dkey)
|
||
touched.add(target or "(imported)")
|
||
ref_imported += 1
|
||
else: # real_history
|
||
orig_id = str(c.get("id") or "")
|
||
if orig_id and orig_id in known_ids:
|
||
continue # already imported / round-trip -> skip duplicate
|
||
if c.get("start_time") is None or c.get("duration") is None:
|
||
continue # malformed real record (add_cycle needs both) -> skip, don't raise
|
||
_ensure_profile(target, c.get("duration") or 0)
|
||
cyc = dict(c)
|
||
cyc["profile_name"] = target or None
|
||
meta_c = dict(cyc.get("meta") or {})
|
||
if orig_id:
|
||
meta_c["imported_from"] = orig_id
|
||
cyc["meta"] = meta_c
|
||
cyc.pop("id", None) # let _add_cycle_data assign a fresh, collision-safe id
|
||
self._add_cycle_data(
|
||
cyc, target=self._data.setdefault("past_cycles", []), id_pool=past_id_pool
|
||
)
|
||
if orig_id:
|
||
known_ids.add(orig_id)
|
||
if target:
|
||
touched.add(target)
|
||
real_imported += 1
|
||
except Exception: # noqa: BLE001 - skip a malformed record, never abort the import
|
||
malformed_skipped += 1
|
||
self._logger.debug("selective import: skipped a malformed cycle", exc_info=True)
|
||
|
||
# ── Step 4: leaf categories (additive merge / replace) ──────────────────
|
||
for cat_id in cats:
|
||
if cat_id in _STRUCTURAL_CATEGORIES:
|
||
continue
|
||
if cat_id == "lifetime_stats" and mode == "merge":
|
||
continue # never sum lifetime totals -- would double-count
|
||
for key in _EXPORT_CATEGORIES.get(cat_id, {}).get("keys", []):
|
||
if key not in data_dict:
|
||
continue
|
||
src_val = data_dict.get(key)
|
||
if mode == "replace":
|
||
self._data[key] = src_val
|
||
continue
|
||
cur = self._data.get(key)
|
||
if isinstance(src_val, dict):
|
||
base = cur if isinstance(cur, dict) else {}
|
||
for k, v in src_val.items():
|
||
base.setdefault(k, v) # keep-mine on key collision
|
||
self._data[key] = base
|
||
elif isinstance(src_val, list):
|
||
base = cur if isinstance(cur, list) else []
|
||
_merge_list_dedup(base, src_val)
|
||
self._data[key] = base
|
||
elif cur is None:
|
||
self._data[key] = src_val # scalar: fill only when absent locally
|
||
|
||
# ── Step 5: envelopes -- carry for definition-only profiles, rebuild the rest ─
|
||
src_envs = data_dict.get("envelopes") if isinstance(data_dict.get("envelopes"), dict) else {}
|
||
local_envs = self._data.setdefault("envelopes", {})
|
||
for orig, new_name in name_remap.items():
|
||
if new_name in touched:
|
||
continue # will be rebuilt from imported cycles
|
||
# Carry the file's envelope when the target has none, OR when the file
|
||
# overwrote the definition in place (replace/overwrite: the file wins, so its
|
||
# envelope must supersede the now-mismatched stale local one instead of being
|
||
# discarded — otherwise matching keeps using the old shape).
|
||
if orig in src_envs and (new_name not in local_envs or new_name in overwritten):
|
||
local_envs[new_name] = src_envs[orig] # keep it matchable without raw traces
|
||
for p in sorted(touched):
|
||
await self.async_rebuild_envelope(p)
|
||
|
||
self._cached_sample_segments = {}
|
||
await self.async_save()
|
||
|
||
settings_out: dict[str, Any] = {}
|
||
if apply_settings and "settings" in cats and device_type_match:
|
||
settings_out = _shareable_settings(meta.get("entry_options") or {})
|
||
|
||
return {
|
||
"profiles_imported": created_profiles,
|
||
"real_cycles_imported": real_imported,
|
||
"reference_cycles_imported": ref_imported,
|
||
"conflicts_resolved": conflicts_resolved,
|
||
"skipped_cycles": malformed_skipped,
|
||
"categories_applied": sorted(cats),
|
||
"device_type_match": device_type_match,
|
||
"settings": settings_out,
|
||
}
|
||
|
||
async def delete_cycle(self, cycle_id: str) -> bool:
|
||
"""Delete a cycle by ID."""
|
||
cycles = cast(list[CycleDict], self._data.get("past_cycles", []))
|
||
initial_len = len(cycles)
|
||
cycle_to_delete = next((c for c in cycles if c.get("id") == cycle_id), None)
|
||
if not cycle_to_delete:
|
||
# Not a real cycle -- it may be an imported store recording, which
|
||
# lives in the separate reference_cycles list (never in usage stats).
|
||
return await self._delete_reference_cycle(cycle_id)
|
||
|
||
profile_name = cycle_to_delete.get("profile_name")
|
||
self._data["past_cycles"] = [c for c in cycles if c.get("id") != cycle_id]
|
||
|
||
if len(self._data["past_cycles"]) < initial_len:
|
||
# Check profile references
|
||
for _p_name, p_data in self.get_profiles().items():
|
||
if p_data.get("sample_cycle_id") == cycle_id:
|
||
p_data["sample_cycle_id"] = None
|
||
|
||
# Drop any pending feedback for this cycle so it can't orphan the
|
||
# "needs review" count (the count would say 1 while the cycle is gone).
|
||
self._data.get("pending_feedback", {}).pop(cycle_id, None)
|
||
|
||
# Rebuild envelope if cycle belonged to a profile
|
||
if profile_name:
|
||
await self.async_rebuild_envelope(profile_name)
|
||
|
||
await self.async_save()
|
||
return True
|
||
return False
|
||
|
||
async def _delete_reference_cycle(self, cycle_id: str) -> bool:
|
||
"""Delete a single imported store recording from ``reference_cycles``.
|
||
|
||
Rebuilds the affected profile's envelope so removing a bad import
|
||
immediately stops influencing the matcher template. Returns False when
|
||
no reference cycle carries that id.
|
||
"""
|
||
refs = cast(list[CycleDict], self._data.get("reference_cycles", []))
|
||
cycle = next((c for c in refs if c.get("id") == cycle_id), None)
|
||
if cycle is None:
|
||
return False
|
||
profile_name = cycle.get("profile_name")
|
||
self._data["reference_cycles"] = [c for c in refs if c.get("id") != cycle_id]
|
||
# Clear any profile that sampled this now-deleted reference cycle, mirroring
|
||
# the real-cycle path in delete_cycle, so no sample id is left dangling.
|
||
for _p_name, p_data in self.get_profiles().items():
|
||
if p_data.get("sample_cycle_id") == cycle_id:
|
||
p_data["sample_cycle_id"] = None
|
||
if profile_name:
|
||
# If removing this recording leaves the profile with no cycles at all
|
||
# (no real, no imported), drop the now-empty profile. Otherwise a
|
||
# sampleless import-only profile would be re-populated by
|
||
# async_repair_profile_samples stealing an unlabeled real cycle into it.
|
||
remaining = any(
|
||
c.get("profile_name") == profile_name
|
||
for c in list(self._data.get("past_cycles", []))
|
||
+ list(self._data.get("reference_cycles", []))
|
||
)
|
||
if remaining:
|
||
await self.async_rebuild_envelope(profile_name)
|
||
else:
|
||
self._data.get("profiles", {}).pop(profile_name, None)
|
||
self._data.get("envelopes", {}).pop(profile_name, None)
|
||
await self.async_save()
|
||
return True
|
||
|
||
def get_cycle_power_data(self, cycle_id: str) -> list[tuple[float, float]]:
|
||
"""Return decompressed power data for a cycle as [(offset_s, watts), ...].
|
||
|
||
Returns an empty list if the cycle is not found or has no power data.
|
||
"""
|
||
cycle = next(
|
||
(c for c in self.get_past_cycles() if c.get("id") == cycle_id), None
|
||
)
|
||
if cycle is None:
|
||
# Imported store recordings live in a separate list; the panel opens
|
||
# them from the same Cycles table, so look them up here too.
|
||
cycle = next(
|
||
(c for c in self.get_reference_cycles() if c.get("id") == cycle_id),
|
||
None,
|
||
)
|
||
if cycle is None:
|
||
return []
|
||
return decompress_power_data(cycle)
|
||
|
||
async def trim_cycle_power_data(
|
||
self,
|
||
cycle_id: str,
|
||
new_start_s: float,
|
||
new_end_s: float,
|
||
) -> bool:
|
||
"""Trim a cycle's power_data to the window [new_start_s, new_end_s].
|
||
|
||
Offsets are renormalized so the kept segment starts at 0.0.
|
||
The cycle's ``duration``, ``signature``, and ``sampling_interval`` are
|
||
recomputed from the trimmed data.
|
||
|
||
Returns True if successful, False if the cycle was not found or the
|
||
resulting data is empty.
|
||
"""
|
||
cycles = cast(list[CycleDict], self._data.get("past_cycles", []))
|
||
cycle = next((c for c in cycles if c.get("id") == cycle_id), None)
|
||
if cycle is None:
|
||
return False
|
||
|
||
p_data = decompress_power_data(cycle)
|
||
if not p_data:
|
||
return False
|
||
|
||
new_start_s = max(0.0, float(new_start_s))
|
||
new_end_s = float(new_end_s)
|
||
|
||
kept = sorted(
|
||
(
|
||
(offset, power)
|
||
for offset, power in p_data
|
||
if new_start_s <= offset <= new_end_s
|
||
),
|
||
key=lambda x: x[0],
|
||
)
|
||
if not kept:
|
||
return False
|
||
|
||
# Re-normalize offsets so the trimmed segment starts at 0.0
|
||
base = kept[0][0]
|
||
renorm: list[list[float]] = [
|
||
[round(offset - base, 2), power] for offset, power in kept
|
||
]
|
||
|
||
# Advance start_time when trimming from the front
|
||
if base > 0:
|
||
start_ts = _value_to_timestamp(cycle.get("start_time"))
|
||
if start_ts is not None:
|
||
cycle["start_time"] = dt_util.utc_from_timestamp(
|
||
start_ts + base
|
||
).isoformat()
|
||
|
||
# Recompute sampling interval
|
||
if len(renorm) > 1:
|
||
offsets_arr = np.array([r[0] for r in renorm], dtype=float)
|
||
intervals = np.diff(offsets_arr)
|
||
pos = intervals[intervals > 0]
|
||
sampling_interval = float(np.median(pos)) if len(pos) > 0 else 1.0
|
||
else:
|
||
sampling_interval = 1.0
|
||
|
||
# Recompute signature
|
||
ts_arr = np.array([r[0] for r in renorm], dtype=float)
|
||
p_arr = np.array([r[1] for r in renorm], dtype=float)
|
||
if len(ts_arr) > 1:
|
||
sig = compute_signature(ts_arr, p_arr)
|
||
cycle["signature"] = dataclasses.asdict(sig)
|
||
else:
|
||
cycle["signature"] = None
|
||
|
||
new_duration = round(renorm[-1][0], 1) if renorm else 0.0
|
||
cycle["power_data"] = renorm
|
||
cycle["sampling_interval"] = round(sampling_interval, 1)
|
||
cycle["duration"] = new_duration
|
||
|
||
# Recompute energy and max_power from the trimmed trace so stored stats
|
||
# reflect the kept window (not the pre-trim full cycle).
|
||
ts_s = np.array([r[0] for r in renorm], dtype=float)
|
||
p_s = np.array([r[1] for r in renorm], dtype=float)
|
||
if len(ts_s) > 1:
|
||
cycle["energy_wh"] = round(
|
||
integrate_wh(ts_s, p_s, max_gap_s=energy_gap_threshold_s(ts_s)), 3
|
||
)
|
||
cycle["max_power"] = round(float(np.max(p_s)), 1)
|
||
elif len(ts_s) == 1:
|
||
cycle["energy_wh"] = 0.0
|
||
cycle["max_power"] = round(float(p_s[0]), 1)
|
||
|
||
# Keep end_time consistent with the updated start_time and duration
|
||
new_start_ts = _value_to_timestamp(cycle.get("start_time"))
|
||
if new_start_ts is not None:
|
||
cycle["end_time"] = dt_util.utc_from_timestamp(
|
||
new_start_ts + new_duration
|
||
).isoformat()
|
||
|
||
# Clear manual_duration override - trimmed duration is now authoritative
|
||
cycle.pop("manual_duration", None)
|
||
|
||
# Mark the cycle as edited so store-upload provenance (qc) can distinguish a
|
||
# trimmed cycle from a plain detected one.
|
||
meta = cycle.setdefault("meta", {})
|
||
if isinstance(meta, dict):
|
||
meta["edited"] = True
|
||
meta["trim"] = [round(new_start_s, 1), round(new_end_s, 1)]
|
||
|
||
# Invalidate cached sample segments for this cycle so future lookups
|
||
# are recomputed from the trimmed data
|
||
stale_keys = [k for k in self._cached_sample_segments if k[0] == cycle_id]
|
||
for k in stale_keys:
|
||
del self._cached_sample_segments[k]
|
||
|
||
# Rebuild envelope for the associated profile
|
||
profile_name = cycle.get("profile_name")
|
||
if profile_name:
|
||
await self.async_rebuild_envelope(profile_name)
|
||
|
||
await self.async_save()
|
||
return True
|
||
|
||
def analyze_split_sync(
|
||
self, cycle: CycleDict, min_gap_s: int = 900, idle_power: float = 2.0
|
||
) -> list[tuple[float, float]]:
|
||
"""Analyze cycle for potential splits (sync for executor)."""
|
||
p_data = decompress_power_data(cycle)
|
||
if not p_data:
|
||
return []
|
||
|
||
# Parse all points to (rel_t, power)
|
||
points: list[tuple[float, float]] = []
|
||
for offset_seconds, val in p_data:
|
||
points.append((float(offset_seconds), float(val)))
|
||
|
||
if not points:
|
||
return []
|
||
|
||
valid_segments: list[tuple[float, float]] = []
|
||
seg_start = 0.0
|
||
|
||
for i in range(1, len(points)):
|
||
t, _ = points[i]
|
||
prev_t, prev_p = points[i-1]
|
||
|
||
# Detect idle gap
|
||
gap = t - prev_t
|
||
if prev_p < idle_power and gap > min_gap_s:
|
||
# Segment ending at prev_t
|
||
if (prev_t - seg_start) > 60:
|
||
valid_segments.append((seg_start, prev_t))
|
||
seg_start = t
|
||
|
||
# Last segment
|
||
last_t = points[-1][0]
|
||
if (last_t - seg_start) > 60:
|
||
valid_segments.append((seg_start, last_t))
|
||
|
||
if len(valid_segments) < 2:
|
||
return []
|
||
|
||
self._logger.debug(
|
||
"Analyzed split for %s: found %d segments",
|
||
cycle.get("id"),
|
||
len(valid_segments)
|
||
)
|
||
return valid_segments
|
||
|
||
def build_split_segments_from_offsets(
|
||
self,
|
||
cycle: CycleDict,
|
||
split_offsets_s: list[float],
|
||
min_segment_s: float = 60.0,
|
||
) -> list[tuple[float, float]]:
|
||
"""Build segments for a manual split from explicit offsets (seconds from cycle start).
|
||
|
||
Returns adjacent [(start, end)] segments covering the cycle window, split at the
|
||
given offsets. Offsets are sorted and deduplicated; offsets outside the cycle window
|
||
or producing a sub-`min_segment_s` slice are dropped. Returns [] if fewer than two
|
||
segments would result.
|
||
"""
|
||
p_data = decompress_power_data(cycle)
|
||
if not p_data:
|
||
return []
|
||
|
||
last_t = float(p_data[-1][0])
|
||
if last_t <= 0:
|
||
return []
|
||
|
||
unique_offsets = sorted(
|
||
{round(float(o), 3) for o in split_offsets_s if 0.0 < float(o) < last_t}
|
||
)
|
||
if not unique_offsets:
|
||
return []
|
||
|
||
filtered_offsets: list[float] = []
|
||
for offset in unique_offsets:
|
||
if offset <= min_segment_s:
|
||
continue
|
||
if offset >= (last_t - min_segment_s):
|
||
continue
|
||
if filtered_offsets and (offset - filtered_offsets[-1]) < min_segment_s:
|
||
continue
|
||
filtered_offsets.append(offset)
|
||
|
||
if not filtered_offsets:
|
||
return []
|
||
|
||
boundaries = [0.0, *filtered_offsets, last_t]
|
||
segments: list[tuple[float, float]] = []
|
||
for i in range(len(boundaries) - 1):
|
||
seg_start = boundaries[i]
|
||
seg_end = boundaries[i + 1]
|
||
if (seg_end - seg_start) >= min_segment_s:
|
||
segments.append((seg_start, seg_end))
|
||
|
||
if len(segments) < 2:
|
||
return []
|
||
|
||
self._logger.debug(
|
||
"Built manual split for %s: %d segments at offsets %s",
|
||
cycle.get("id"),
|
||
len(segments),
|
||
filtered_offsets,
|
||
)
|
||
return segments
|
||
|
||
async def apply_split_interactive(
|
||
self, cycle_id: str, segments: list[dict[str, Any]]
|
||
) -> list[str]:
|
||
"""Apply a manual split config.
|
||
|
||
segments format: [{"start": float, "end": float, "profile": str|None}]
|
||
Returns list of new cycle IDs.
|
||
"""
|
||
cycles = cast(list[CycleDict], self._data.get("past_cycles", []))
|
||
idx = next((i for i, c in enumerate(cycles) if c.get("id") == cycle_id), -1)
|
||
|
||
if idx == -1:
|
||
return []
|
||
|
||
cycle = cycles[idx]
|
||
|
||
# Validate before mutating — early returns below this point would otherwise
|
||
# permanently delete the source cycle from the live list.
|
||
start_dt_base_parsed = _parse_start_dt(cycle["start_time"])
|
||
if not start_dt_base_parsed:
|
||
return []
|
||
|
||
p_data_tuples = decompress_power_data(cycle)
|
||
if not p_data_tuples:
|
||
return []
|
||
|
||
# Pre-materialise all segment bounds before touching history; a malformed
|
||
# segment that raises after the pop would leave the source cycle permanently
|
||
# deleted with only partial children created.
|
||
try:
|
||
materialized_segs: list[tuple[float, float, str | None]] = []
|
||
for seg in segments:
|
||
if isinstance(seg, (list, tuple)):
|
||
seg_s = float(seg[0])
|
||
seg_e = float(seg[1])
|
||
seg_p: str | None = None
|
||
else:
|
||
seg_s = float(seg["start"])
|
||
seg_e = float(seg["end"])
|
||
seg_p = seg.get("profile")
|
||
if seg_e <= seg_s:
|
||
return []
|
||
materialized_segs.append((seg_s, seg_e, seg_p))
|
||
except (TypeError, ValueError, KeyError, IndexError):
|
||
return []
|
||
# A split into fewer than two pieces is not a real split; guard here so
|
||
# an empty or single-element segments list can't pop the source cycle and
|
||
# leave zero or one orphaned children.
|
||
if len(materialized_segs) < 2:
|
||
return []
|
||
|
||
cycles.pop(idx) # Remove original — all segments validated, safe to mutate
|
||
|
||
new_ids: list[str] = []
|
||
original_profile = cycle.get("profile_name")
|
||
start_ts = start_dt_base_parsed.timestamp()
|
||
|
||
# Prepare points (relative seconds)
|
||
points: list[tuple[float, float]] = []
|
||
for offset_seconds, val in p_data_tuples:
|
||
points.append((float(offset_seconds), float(val)))
|
||
|
||
# Create new cycles
|
||
for seg_start, seg_end, seg_profile in materialized_segs:
|
||
seg_dur = seg_end - seg_start
|
||
new_cycle_start = start_dt_base_parsed + timedelta(seconds=seg_start)
|
||
new_cycle_start_ts = new_cycle_start.timestamp()
|
||
|
||
# Extract points for this segment
|
||
p_data_abs: list[list[float]] = []
|
||
|
||
# Find closest state before/at start to ensure continuity?
|
||
# Or just take points strictly inside?
|
||
# Generally better to capture the state at start 0.
|
||
state_val = 0.0
|
||
for t, p in points:
|
||
if t <= seg_start:
|
||
state_val = p
|
||
else:
|
||
break
|
||
|
||
# Start point (t=0 relative to new cycle)
|
||
p_data_abs.append([round(new_cycle_start_ts, 1), state_val])
|
||
|
||
for t, p in points:
|
||
if seg_start < t <= seg_end:
|
||
p_data_abs.append([round(start_ts + t, 1), p])
|
||
|
||
# Create Cycle Record
|
||
new_cycle: dict[str, Any] = {
|
||
"start_time": new_cycle_start.isoformat(),
|
||
"end_time": (new_cycle_start + timedelta(seconds=seg_dur)).isoformat(),
|
||
"duration": round(seg_dur, 1),
|
||
"status": "completed",
|
||
"power_data": p_data_abs,
|
||
"profile_name": seg_profile
|
||
}
|
||
await self.async_add_cycle(new_cycle)
|
||
new_ids.append(new_cycle["id"])
|
||
|
||
# Fix profile refs (handle original sample cycle logic)
|
||
original_sample_id = cycle.get("id")
|
||
best_replacement_id = None
|
||
longest_dur = 0
|
||
new_cycles_objs = [c for c in cycles if c["id"] in new_ids]
|
||
|
||
for c in new_cycles_objs:
|
||
d = c.get("duration", 0)
|
||
if d > longest_dur:
|
||
longest_dur = d
|
||
best_replacement_id = c["id"]
|
||
|
||
if best_replacement_id and original_profile:
|
||
p_data = self._data["profiles"].get(original_profile)
|
||
if p_data and p_data.get("sample_cycle_id") == original_sample_id:
|
||
p_data["sample_cycle_id"] = best_replacement_id
|
||
|
||
# Rebuild envelopes ONLY for the profiles whose dataset actually changed:
|
||
# the original profile (it lost the parent cycle) plus any profile a labeled
|
||
# segment was assigned to. This replaces a blanket rebuild-all-envelopes in
|
||
# the caller, which re-scanned every profile serially and stalled low-power
|
||
# hosts on a split (issue #311 follow-up).
|
||
touched: set[str] = set()
|
||
if original_profile:
|
||
touched.add(original_profile)
|
||
for seg in segments:
|
||
if isinstance(seg, dict):
|
||
seg_prof = seg.get("profile")
|
||
if seg_prof:
|
||
touched.add(seg_prof)
|
||
for name in touched:
|
||
await self.async_rebuild_envelope(name)
|
||
|
||
await self.async_save()
|
||
self._logger.info("Interactive Split Applied to %s -> %s", cycle_id, new_ids)
|
||
return new_ids
|
||
|
||
async def apply_merge_interactive(
|
||
self, cycle_ids: list[str], target_profile: str | None
|
||
) -> str | None:
|
||
"""
|
||
Merge multiple past cycles into a single cycle record, filling gaps between traces with short zero-power segments.
|
||
|
||
Parameters:
|
||
cycle_ids (list[str]): Unordered set of past-cycle IDs to merge; at least two IDs are required.
|
||
The function internally sorts cycles by start_time and mutates the chronologically earliest cycle.
|
||
target_profile (str | None): Profile name to assign to the merged cycle, or `None` to leave unlabeled.
|
||
|
||
Description:
|
||
When successful, this sorts the provided cycles by start_time and replaces the earliest cycle with the merged cycle,
|
||
removing the other consumed cycles and updating related metadata.
|
||
|
||
Side effects:
|
||
- Updates the store's past_cycles (removes consumed cycles and replaces the first cycle with the merged record).
|
||
- Clears any `manual_duration` override on the resulting cycle.
|
||
- Updates `sample_cycle_id` references in profiles that pointed to removed cycles.
|
||
- Attempts to recompute and store the merged cycle's signature.
|
||
- Persists changes to storage and triggers envelope rebuilds for affected profiles.
|
||
|
||
Returns:
|
||
merged_id (str | None): The new merged cycle's ID if the merge was applied, `None` if the merge could not be performed.
|
||
"""
|
||
if len(cycle_ids) < 2:
|
||
return None
|
||
|
||
cycles = self.get_past_cycles()
|
||
target_cycles = [c for c in cycles if c.get("id") in cycle_ids]
|
||
|
||
if len(target_cycles) != len(cycle_ids):
|
||
return None
|
||
|
||
# Sort by time - use timestamp comparison to handle mixed timezone offsets correctly
|
||
def _cycle_start_ts(c: CycleDict) -> float:
|
||
ts = _value_to_timestamp(c.get("start_time"))
|
||
return ts if ts is not None else float("inf")
|
||
|
||
target_cycles.sort(key=_cycle_start_ts)
|
||
|
||
# Collect affected profiles for envelope rebuild
|
||
affected_profiles: set[str] = set()
|
||
for c in target_cycles:
|
||
if c.get("profile_name"):
|
||
affected_profiles.add(c["profile_name"])
|
||
if target_profile:
|
||
affected_profiles.add(target_profile)
|
||
|
||
# We modify the first cycle (c1) to become the merged one
|
||
c1 = target_cycles[0]
|
||
ids_to_remove: list[str] = []
|
||
|
||
# Base setup
|
||
c1_start_dt = _parse_start_dt(c1["start_time"])
|
||
if not c1_start_dt:
|
||
return None
|
||
|
||
# Helper to get parsed points from a cycle
|
||
def get_points(cy: CycleDict) -> list[tuple[float, float, float]]:
|
||
# content: [(timestamp, offset, power)]
|
||
raw = decompress_power_data(cy)
|
||
res: list[tuple[float, float, float]] = []
|
||
if not raw:
|
||
return []
|
||
base_dt = _parse_start_dt(cy["start_time"])
|
||
if base_dt is None:
|
||
return []
|
||
base_t = base_dt.timestamp()
|
||
for offset_seconds, val in raw:
|
||
t_abs = base_t + float(offset_seconds)
|
||
res.append((t_abs, float(offset_seconds), float(val)))
|
||
return res
|
||
|
||
# Start with C1 points
|
||
merged_points_abs: list[list[float]] = [] # [timestamp, power]
|
||
|
||
# Add C1 points
|
||
c1_pts = get_points(c1)
|
||
for t_abs, _, p in c1_pts:
|
||
merged_points_abs.append([t_abs, p])
|
||
|
||
# Use the maximum t_abs seen so far (guards against out-of-order or corrupted points)
|
||
last_t_abs = max((pt[0] for pt in c1_pts), default=c1_start_dt.timestamp())
|
||
|
||
# Iterate others
|
||
max_power = c1.get("max_power", 0)
|
||
|
||
for next_c in target_cycles[1:]:
|
||
c_start_dt = _parse_start_dt(next_c.get("start_time"))
|
||
if not c_start_dt:
|
||
continue
|
||
|
||
c_pts = get_points(next_c)
|
||
if not c_pts:
|
||
continue
|
||
|
||
current_start_ts = c_pts[0][0]
|
||
|
||
# --- GAP FILLING ---
|
||
gap = current_start_ts - last_t_abs
|
||
# If gap > 1s, inject 0W points to ensure graph drops to 0
|
||
if gap > 1.0:
|
||
merged_points_abs.append([last_t_abs + 0.1, 0.0])
|
||
merged_points_abs.append([current_start_ts - 0.1, 0.0])
|
||
|
||
# Append points; track the running maximum to guard against reversed/corrupt data
|
||
for t_abs, _, p in c_pts:
|
||
merged_points_abs.append([t_abs, p])
|
||
if t_abs > last_t_abs:
|
||
last_t_abs = t_abs
|
||
|
||
max_power = max(max_power, next_c.get("max_power", 0))
|
||
ids_to_remove.append(next_c["id"])
|
||
|
||
# Derive merged end time from power data when available; otherwise fall back to
|
||
# the end_time field of the last cycle (handles cycles without recorded power data).
|
||
if merged_points_abs:
|
||
# Use the maximum absolute timestamp from all collected data points
|
||
last_t_abs = max(pt[0] for pt in merged_points_abs)
|
||
final_end_dt = dt_util.utc_from_timestamp(last_t_abs)
|
||
else:
|
||
last_cycle = target_cycles[-1]
|
||
fallback_end_dt = _parse_start_dt(last_cycle.get("end_time"))
|
||
if fallback_end_dt is not None:
|
||
final_end_dt = fallback_end_dt
|
||
else:
|
||
final_end_dt = c1_start_dt
|
||
|
||
new_dur = (final_end_dt - c1_start_dt).total_seconds()
|
||
|
||
c1["end_time"] = final_end_dt.isoformat()
|
||
c1["duration"] = round(new_dur, 1)
|
||
c1["max_power"] = max_power
|
||
c1["profile_name"] = target_profile
|
||
# Remove manual_duration override so the freshly computed duration is shown
|
||
c1.pop("manual_duration", None)
|
||
|
||
# Generate new compressed power_data [offset, power]
|
||
new_power_data: list[list[float]] = []
|
||
c1_start_ts = c1_start_dt.timestamp()
|
||
|
||
for t_abs, p in merged_points_abs:
|
||
offset = round(t_abs - c1_start_ts, 1)
|
||
new_power_data.append([offset, float(p)])
|
||
|
||
c1["power_data"] = new_power_data
|
||
|
||
# Recompute energy from the merged trace (merge keeps only c1's original
|
||
# energy_wh which under-represents the combined cycle).
|
||
try:
|
||
ts_s = np.array([pt[0] for pt in new_power_data], dtype=float)
|
||
p_s = np.array([pt[1] for pt in new_power_data], dtype=float)
|
||
if len(ts_s) > 1:
|
||
c1["energy_wh"] = round(
|
||
integrate_wh(ts_s, p_s, max_gap_s=energy_gap_threshold_s(ts_s)), 3
|
||
)
|
||
except Exception: # pylint: disable=broad-exception-caught
|
||
self._logger.warning("Energy recompute failed after cycle merge", exc_info=True)
|
||
|
||
# New Hash ID
|
||
new_id = hashlib.sha256(f"{c1['start_time']}_{c1['duration']}".encode()).hexdigest()[:12]
|
||
old_c1_id = c1["id"]
|
||
c1["id"] = new_id
|
||
|
||
# Update references in profiles
|
||
all_removed_ids = ids_to_remove + [old_c1_id]
|
||
for p_data in self.get_profiles().values():
|
||
if p_data.get("sample_cycle_id") in all_removed_ids:
|
||
p_data["sample_cycle_id"] = new_id
|
||
|
||
# Remove consumed cycles
|
||
self._data["past_cycles"] = [
|
||
c for c in cycles if c.get("id") not in ids_to_remove
|
||
]
|
||
|
||
# Update signature
|
||
try:
|
||
ts_arr = np.array([pt[0] for pt in new_power_data], dtype=float)
|
||
p_arr = np.array([pt[1] for pt in new_power_data], dtype=float)
|
||
if len(ts_arr) > 1:
|
||
sig = compute_signature(ts_arr, p_arr)
|
||
c1["signature"] = dataclasses.asdict(sig)
|
||
except Exception as e: # pylint: disable=broad-exception-caught
|
||
self._logger.warning("Failed to update signature for merged cycle %s: %s", new_id, e)
|
||
|
||
await self.async_save()
|
||
self._logger.info("Interactive Merge Applied: %s -> %s", cycle_ids, new_id)
|
||
|
||
# Rebuild envelopes for all affected profiles
|
||
for p_name in affected_profiles:
|
||
await self.async_rebuild_envelope(p_name)
|
||
|
||
return new_id
|
||
|