This commit is contained in:
Home Assistant Version Control committed 2026-08-18 14:36:46 +00:00
1 parent 13dc64b70f
commit 88e26358aa
89 files changed
+26068 -18351

No files matched your search

+322 -51
View File
@@ -49,6 +49,8 @@ from .const import (
PHASE_HEAT_CV_WARN,
PHASE_HEAT_OCC_MIXED_LO,
PHASE_HEAT_OCC_MIXED_HI,
PLAYGROUND_PRESET_MAX,
PLAYGROUND_PRESET_NAME_MAX,
REFERENCE_PROFILE_CURVE_POINTS,
SHAPE_DRIFT_MIN_CYCLES,
SHAPE_DRIFT_RESAMPLE_N,
@@ -95,6 +97,12 @@ _LOGGER = logging.getLogger(__name__)
# and reference selection so degenerate cycles never become the matching template.
_DEGENERATE_POWER_FLOOR = 15.0 # watts
# How far outside the requested trim window a stored sample may still be snapped
# to. Trim boundaries arrive quantized to whole seconds (panel number input, the
# trim_cycle service), so one second is exactly the input's own resolution -- it
# absorbs that rounding without letting the kept window grow measurably.
_TRIM_SNAP_TOLERANCE_S = 1.0
JSONDict: TypeAlias = dict[str, Any]
CycleDict: TypeAlias = dict[str, Any]
@@ -1216,6 +1224,10 @@ class ProfileStore:
self._match_threshold = match_threshold
self._unmatch_threshold = unmatch_threshold
self.dtw_bandwidth: float = DEFAULT_DTW_BANDWIDTH
# Stage-4 energy-agreement mode ("mean"|"integrated"); the manager sets it
# from the device type via analysis.stage4_energy_mode. Default "mean"
# keeps behaviour byte-identical until wired.
self.energy_mode: str = "mean"
self._save_debug_traces = save_debug_traces
# Cache for resampled sample segments: key=(cycle_id, dt)
@@ -1255,6 +1267,7 @@ class ProfileStore:
"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)
"playground_presets": {}, # Named Playground setting snapshots (sandbox only)
}
@@ -1302,6 +1315,31 @@ class ProfileStore:
suggestions: JSONDict = self._data.setdefault("suggestions", {})
suggestions.pop(key, None)
def get_locked_suggestions(self) -> list[str]:
"""Return the setting keys the user has locked against auto-tuning (#343).
A locked key is never re-surfaced as a suggestion, so the auto-tuner stops
repeatedly proposing a value the user has rejected (e.g. a stop/start
threshold that breaks an anti-crease-tuned device). Never raises.
"""
raw = self._data.get("locked_suggestions")
return [str(k) for k in raw] if isinstance(raw, list) else []
async def set_suggestion_locked(self, key: str, locked: bool) -> None:
"""Lock or unlock a suggestion key and persist (#343).
Locking also drops any currently-pending suggestion for the key so it
disappears immediately; unlocking lets the auto-tuner surface it again.
"""
cur = set(self.get_locked_suggestions())
if locked:
cur.add(key)
self.delete_suggestion(key)
else:
cur.discard(key)
self._data["locked_suggestions"] = sorted(cur)
await self.async_save()
async def clear_suggestions(self) -> None:
"""Clear all pending suggestions and persist."""
self._data["suggestions"] = {}
@@ -1746,7 +1784,7 @@ class ProfileStore:
if pairs[0][0] != 0.0:
origin = pairs[0][0]
pairs = [[p[0] - origin, p[1]] for p in pairs]
now = dt_util.now()
now = dt_util.as_utc(dt_util.now())
store_id = str(meta.get("store_cycle_id") or "")
cycle: CycleDict = {
"profile_name": profile_name,
@@ -2010,14 +2048,31 @@ class ProfileStore:
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).
"""Within a winning group, pick the member whose integrated ENERGY best
matches the cycle.
Group members already share shape and duration (that is what the cohesion
gate collapses them for), so the within-group discriminator is temperature
/ spin, and the clean signal for that is integrated energy (Sum P*dt) --
NOT whole-cycle mean power (diluted, because hotter cycles also run longer)
nor peak (dominated by the ~constant heating-element draw, so it is flat
across variants). Validated on real store data (leave-one-cycle-out member
pick, 196 cycles): energy-only 73.3% vs the old duration*mean*peak product
63.3%; adding any duration term back regressed it (grouped members are
duration-cohesive, so duration only adds noise). Duration is still returned
(for ETA and the overrun guard) but is intentionally not part of selection.
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())
# Energy proxy = mean power * duration; the 1/3600 Wh factor cancels in the
# log-ratio, so this is exact up to that constant. This proxy equals the
# true integral only because `current_power` is already resampled onto a
# uniform grid by the matcher; raw or irregular traces must use
# signal_processing.integrate_wh instead.
cur_energy = float(cur.mean()) * float(current_duration)
def agree(a: float, b: float, scale: float) -> float:
if a <= 0 or b <= 0:
@@ -2033,9 +2088,9 @@ class ProfileStore:
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))
mem_energy = float(sp.mean()) * md
# Scale 0.30 is validated as insensitive over 0.25-0.40 on real data.
sc = agree(cur_energy, mem_energy, 0.30)
if sc > best_sc:
best_sc, best_m, best_dur = sc, m, md
fit = None
@@ -2286,6 +2341,83 @@ class ProfileStore:
await self.async_save()
return True
# ─── Playground presets ────────────────────────────────────────────────────
# Named snapshots of the Playground's settings control panel. Sandbox data:
# nothing here ever reaches the live detector or matcher - the user publishes
# individual values to entry.options explicitly (ws_set_options) if they want
# them live. Stored per device because the values are device-scale (watts,
# seconds tuned for THIS appliance).
def get_playground_presets(self) -> dict[str, JSONDict]:
"""Return the mutable playground-presets mapping (name -> record).
Each record is ``{"values": {...}, "created_at": iso, "updated_at": iso}``.
Self-heals a corrupt/missing key to an empty mapping. Never raises.
"""
raw = self._data.setdefault("playground_presets", {})
if not isinstance(raw, dict):
self._data["playground_presets"] = {}
return cast(dict[str, JSONDict], self._data["playground_presets"])
return cast(dict[str, JSONDict], raw)
@staticmethod
def _playground_preset_key(name: str) -> str:
"""Canonical storage key for a Playground preset name.
Save and delete MUST derive the key the same way: truncating only on save
meant a name longer than the cap was stored truncated but looked up in
full, leaving a preset that could never be deleted. The trailing strip()
runs after the clamp so a cut landing mid-space cannot bake a trailing
space into the key.
"""
return (name or "").strip()[:PLAYGROUND_PRESET_NAME_MAX].strip()
async def async_save_playground_preset(
self, name: str, values: dict[str, Any]
) -> JSONDict:
"""Create or overwrite a named Playground preset and persist it.
``values`` must already be sanitized by ``playground.sanitize_setting_values``
(the store deliberately does not import the playground module - that would
be a cycle). Raises ``ValueError`` for an empty name, an empty value map, or
when the per-device preset cap is reached by a NEW name.
"""
name = self._playground_preset_key(name)
if not name:
raise ValueError("Preset name is required")
if not isinstance(values, dict) or not values:
raise ValueError("Preset has no settings to save")
presets = self.get_playground_presets()
existing = presets.get(name)
if existing is None and len(presets) >= PLAYGROUND_PRESET_MAX:
raise ValueError(
f"Preset limit reached ({PLAYGROUND_PRESET_MAX}); delete one first"
)
now = dt_util.now().isoformat()
created = now
if isinstance(existing, dict) and isinstance(existing.get("created_at"), str):
created = existing["created_at"]
record: JSONDict = {
"values": dict(values),
"created_at": created,
"updated_at": now,
}
presets[name] = record
await self.async_save()
self._logger.info(
"Saved playground preset %r with %d values", name, len(values)
)
return record
async def async_delete_playground_preset(self, name: str) -> bool:
"""Remove a Playground preset by name; report whether one was removed."""
presets = self.get_playground_presets()
if presets.pop(self._playground_preset_key(name), None) is None:
return False
await self.async_save()
self._logger.info("Deleted playground preset %r", name)
return True
def cycles_since_maintenance(self, event_type: str) -> int:
"""Count completed cycles since the most recent maintenance event of a type.
@@ -3835,9 +3967,11 @@ class ProfileStore:
if first_offset > 0:
# Leading zeros removed - Must shift start_time forward
try:
start_dt = datetime.fromisoformat(cycle["start_time"])
start_dt = _parse_start_dt(cycle["start_time"])
if start_dt is None:
raise ValueError("unparseable start_time")
new_start = start_dt + timedelta(seconds=first_offset)
cycle["start_time"] = new_start.isoformat()
cycle["start_time"] = dt_util.as_utc(new_start).isoformat()
# Re-normalize offsets to 0
shifted_data: list[list[float]] = []
@@ -4501,6 +4635,92 @@ class ProfileStore:
return cast(JSONDict, env) if isinstance(env, dict) else None
return None
@staticmethod
def _envelope_time_power(
envelope: JSONDict | None,
) -> tuple[list[float], list[float]] | None:
"""Extract (time_grid, power) from an envelope's ``avg`` curve, or None.
Handles both the new ``[[t, p], ...]`` and legacy ``[p, ...]`` formats
(reconstructing the grid from ``time_grid`` / ``target_duration`` / a 60 s
fallback). Single source of the envelope time axis for both the alignment
worker and the Smart-Termination release span (#348), so the mapped position
and the span it is compared against always live on the same grid.
"""
if not envelope:
return None
env_avg_raw = envelope.get("avg", [])
if not env_avg_raw:
return None
try:
if isinstance(env_avg_raw[0], (list, tuple)) and len(env_avg_raw[0]) >= 2:
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:
env_values = cast(list[float | int], env_avg_raw)
env_power = [float(p) for p in env_values]
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))]
# Inside the try: a stored time_grid with non-numeric entries must
# yield the safe None fallback, not propagate to async_verify_alignment.
return [float(t) for t in env_time], env_power
except (TypeError, ValueError, IndexError):
return None
@staticmethod
def _resample_trace_to_grid(
offsets: list[float], powers: list[float], env_time: list[float]
) -> list[float]:
"""Resample a live ``(offsets, powers)`` trace onto the envelope's uniform
time step, so its sample index maps to elapsed seconds on the envelope grid
(#350). Linear interpolation (matching the envelope build side), bounded at
2x the envelope length. Falls back to the raw powers when a grid cannot be
formed (too few points / non-positive step), leaving behavior unchanged.
"""
if len(offsets) < 2 or len(env_time) < 2:
return list(powers)
diffs = np.diff(np.asarray(env_time, dtype=float))
pos = diffs[diffs > 0]
if pos.size == 0:
return list(powers)
step = float(np.median(pos))
span = float(env_time[-1])
# Sort and deduplicate before computing t_max so the grid span reflects the
# true maximum offset even when the raw trace is non-monotonic.
off_arr = np.asarray(offsets, dtype=float)
pow_arr = np.asarray(powers, dtype=float)
order = np.argsort(off_arr, kind="stable")
off_arr, pow_arr = off_arr[order], pow_arr[order]
# Remove duplicate offsets; np.unique keeps the first occurrence after sorting.
off_arr, unique_idx = np.unique(off_arr, return_index=True)
pow_arr = pow_arr[unique_idx]
t_max = min(float(off_arr[-1]), 2.0 * span) if span > 0 else float(off_arr[-1])
if step <= 0 or t_max <= 0:
return list(powers)
n = max(2, round(t_max / step) + 1)
grid = np.arange(n, dtype=float) * step
return np.interp(grid, off_arr, pow_arr).tolist()
def envelope_time_span(self, profile_name: str) -> float:
"""Total time span (seconds) of a profile's envelope grid, 0 if unavailable.
This is exactly the maximum value ``async_verify_alignment`` maps onto, so
dividing a mapped position by it yields a true 0..1 fraction through the
cycle - unlike ``avg_duration`` (a differently-derived trimmed mean) which
could push the 0.95 Smart-Termination release threshold out of reach (#348).
"""
curves = self._envelope_time_power(self.get_envelope(profile_name))
if not curves or not curves[0]:
return 0.0
return float(curves[0][-1])
def reference_curve(
self, profile_name: str, n: int = REFERENCE_PROFILE_CURVE_POINTS
) -> JSONDict | None:
@@ -5099,6 +5319,7 @@ class ProfileStore:
"min_duration_ratio": self._min_duration_ratio,
"max_duration_ratio": self._max_duration_ratio,
"dtw_bandwidth": self.dtw_bandwidth,
"energy_mode": self.energy_mode,
# On-device tuned scoring weights (opt-in); empty = shipped defaults.
**self._matching_overrides(),
}
@@ -5203,48 +5424,50 @@ class ProfileStore:
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)
"""
if not current_power_data:
return False, 0.0, 9999.0
# "avg" can be a list of [t, p] (new) or [p, ...] (legacy); the shared helper
# normalizes both to (time_grid, power) - identical to envelope_time_span so
# the mapped position and the release span live on the same grid (#348).
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:
curves = self._envelope_time_power(envelope)
if curves is None:
if envelope and envelope.get("avg"):
self._logger.error(
"Malformed envelope 'avg' data for %s", profile_name
)
return False, 0.0, 9999.0
env_time, env_power = curves
# Resample the live trace onto the envelope's own time step BEFORE alignment,
# so its sample index corresponds to elapsed SECONDS, not sample count (#350).
# The worker warps power-vs-power; with no shared time axis an irregularly or
# sparsely sampled tail (e.g. 0 W keepalives every off_delay) maps one grid
# step per sample regardless of wall-clock, so the position crawls - and a
# dense short prefix instead races to the end. Linear interpolation matches
# the envelope build side so the two curves stay comparable.
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
offsets = [float(x[0]) for x in current_power_data]
powers = [float(x[1]) for x in current_power_data]
except (TypeError, ValueError, IndexError):
# A legacy ISO-timestamped trace (x[0] is a string) lands here. The
# signature admits ``list[tuple[Any, ...]]``, so normalize through the
# shared helper instead of bailing out: returning early skipped
# alignment entirely, which silently reduced the legacy-envelope guard
# in tests/repro/test_issue_112.py to a no-op. Production callers pass
# offsets and never reach this branch, so the fast path is unchanged.
normalized = power_data_to_offsets(
cast(list[list[Any] | tuple[Any, ...]], current_power_data)
)
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
if not normalized:
return False, 0.0, 9999.0
try:
offsets = [float(x[0]) for x in normalized]
powers = [float(x[1]) for x in normalized]
except (TypeError, ValueError, IndexError):
return False, 0.0, 9999.0
current_power_list = self._resample_trace_to_grid(offsets, powers, env_time)
# Offload to worker
mapped_time, mapped_power, score = await self.hass.async_add_executor_job(
@@ -5593,6 +5816,7 @@ class ProfileStore:
self._data["profiles"] = {}
self._data["envelopes"] = {}
self._data["suggestions"] = {}
self._data["locked_suggestions"] = []
self._data["feedback_history"] = {}
self._data["pending_feedback"] = {}
self._data["auto_adjustments"] = []
@@ -5604,6 +5828,7 @@ class ProfileStore:
self._data["ml_model_versions"] = {}
self._data["profile_groups"] = {}
self._data["maintenance_log"] = []
self._data["playground_presets"] = {}
self._data["matching_config"] = {}
self._data["match_ranking_history"] = []
self._data["ml_last_training_run"] = None
@@ -6261,8 +6486,8 @@ class ProfileStore:
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
cyc_meta = c.get("meta")
src = cyc_meta.get("source") if isinstance(cyc_meta, dict) else None
if src:
existing_ref_sources.add(_ref_dedup_key(src))
@@ -6530,6 +6755,36 @@ class ProfileStore:
new_start_s = max(0.0, float(new_start_s))
new_end_s = float(new_end_s)
# Guard against a destructive trim (#366): an inverted/empty window would
# produce a zero- or single-sample segment that collapses the cycle to
# duration 0 / energy 0. Reject before touching the cycle.
if new_end_s <= new_start_s:
return False
# Snap both boundaries to a real sample offset (#373). Trim inputs round
# to whole seconds, but sample offsets are frequently fractional -- a
# nominal 10 s cadence drifts, so ~3132.3 is the norm -- and the
# [start, end] filter below is inclusive, so a whole-second entry lands
# just below the sample the user aimed at and silently drops it. Snapping
# against the full-resolution stored trace (independent of any client-side
# curve decimation) makes the kept window land on the samples the
# boundaries name, on both ends.
#
# Snap *inward*, tolerating only the whole-second input quantization
# (_TRIM_SNAP_TOLERANCE_S). Nearest-in-either-direction snapping could
# widen the window by up to half a sample interval per side -- on a
# coarse trace (samples at 0 s and 100 s, request 40-60 s) that silently
# kept the entire cycle while meta["trim"] claimed a narrow window.
offsets = [float(offset) for offset, _ in p_data]
starts = [o for o in offsets if o >= new_start_s - _TRIM_SNAP_TOLERANCE_S]
ends = [o for o in offsets if o <= new_end_s + _TRIM_SNAP_TOLERANCE_S]
if not starts or not ends:
return False
new_start_s = min(starts)
new_end_s = max(ends)
if new_end_s <= new_start_s:
return False
kept = sorted(
(
(offset, power)
@@ -6547,6 +6802,13 @@ class ProfileStore:
[round(offset - base, 2), power] for offset, power in kept
]
# A window that keeps fewer than two samples (or whose kept span is zero)
# would collapse the cycle to duration 0 with no recovery. Reject here,
# BEFORE the start_time mutation below, so the stored cycle is untouched
# and the caller reports a failure instead of destroying data (#366).
if len(renorm) < 2 or round(renorm[-1][0], 1) <= 0.0:
return False
# Advance start_time when trimming from the front
if base > 0:
start_ts = _value_to_timestamp(cycle.get("start_time"))
@@ -6817,8 +7079,8 @@ class ProfileStore:
# Create Cycle Record
new_cycle: dict[str, Any] = {
"start_time": new_cycle_start.isoformat(),
"end_time": (new_cycle_start + timedelta(seconds=seg_dur)).isoformat(),
"start_time": dt_util.as_utc(new_cycle_start).isoformat(),
"end_time": dt_util.as_utc(new_cycle_start + timedelta(seconds=seg_dur)).isoformat(),
"duration": round(seg_dur, 1),
"status": "completed",
"power_data": p_data_abs,
@@ -6860,6 +7122,10 @@ class ProfileStore:
for name in touched:
await self.async_rebuild_envelope(name)
# The original cycle is gone; drop its now-orphaned pending feedback so the
# "needs review" badge stays consistent with the review list (#362).
self.prune_orphaned_feedback()
await self.async_save()
self._logger.info("Interactive Split Applied to %s -> %s", cycle_id, new_ids)
return new_ids
@@ -6995,7 +7261,8 @@ class ProfileStore:
new_dur = (final_end_dt - c1_start_dt).total_seconds()
c1["end_time"] = final_end_dt.isoformat()
c1["start_time"] = dt_util.as_utc(c1_start_dt).isoformat()
c1["end_time"] = dt_util.as_utc(final_end_dt).isoformat()
c1["duration"] = round(new_dur, 1)
c1["max_power"] = max_power
c1["profile_name"] = target_profile
@@ -7050,6 +7317,10 @@ class ProfileStore:
except Exception as e: # pylint: disable=broad-exception-caught
self._logger.warning("Failed to update signature for merged cycle %s: %s", new_id, e)
# Consumed cycles are gone; drop any pending feedback that pointed at them so
# the "needs review" badge does not keep counting non-existent cycles (#362).
self.prune_orphaned_feedback()
await self.async_save()
self._logger.info("Interactive Merge Applied: %s -> %s", cycle_ids, new_id)