# 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 . """Headless cycle-replay 'Playground' backend (Group F3). Pure, executor-safe logic behind the panel's Playground tab. Nothing here touches Home Assistant, fires events, or does I/O; the WebSocket handlers in ``ws_api.py`` call these helpers inside ``hass.async_add_executor_job``. Two entry points: - :func:`run_playground_batch` - replays stored cycles through a *fresh* headless :class:`CycleDetector` (with the device's live settings, optionally overridden) and returns a structured per-cycle event log + outcome plus an aggregate summary. The detector is driven exactly as in production (``process_reading`` fed the cycle's own trace), and a synchronous profile matcher (the real Stage 1-4 pipeline via ``analysis.compute_matches_worker``) is wired in so match/ambiguous/unmatched events are captured. No live HA events are fired - transitions are collected into an in-memory buffer. - :func:`dtw_debug_payload` - the score breakdown (Stage 2 / DTW / Stage 4), the two resampled traces on a shared grid, and the DTW warping path for one cycle vs one profile (the DTW visualizer). Both top-level entry points are defensive: they never raise, returning an ``{"error": ...}`` marker instead so the WS handlers can relay it. """ from __future__ import annotations import logging from dataclasses import replace from datetime import datetime, timedelta, timezone from typing import Any, Callable import numpy as np from homeassistant.util import dt as dt_util from . import analysis from . import notification_rules as notif_rules from . import progress as progress_mod from .const import ( CONF_ABRUPT_DROP_WATTS, CONF_COMPLETION_MIN_SECONDS, CONF_END_REPEAT_COUNT, CONF_INTERRUPTED_MIN_SECONDS, CONF_MATCH_PERSISTENCE, CONF_MIN_OFF_GAP, CONF_MIN_POWER, CONF_NOTIFY_ACTIONS, CONF_NOTIFY_BEFORE_END_MINUTES, CONF_NOTIFY_FINISH_SERVICES, CONF_NOTIFY_MILESTONES, CONF_NOTIFY_START_SERVICES, CONF_OFF_DELAY, CONF_PROFILE_MATCH_MAX_DURATION_RATIO, CONF_PROFILE_MATCH_MIN_DURATION_RATIO, CONF_RUNNING_DEAD_ZONE, CONF_START_DURATION_THRESHOLD, CONF_START_THRESHOLD_W, CONF_STOP_THRESHOLD_W, CYCLE_OVERRUN_ANOMALY_RATIO, CYCLE_UNDERRUN_ANOMALY_RATIO, DEFAULT_MATCH_PERSISTENCE, DEFAULT_NOTIFY_BEFORE_END_MINUTES, DEFAULT_NOTIFY_MILESTONES, MATCH_CORR_WEIGHT, MATCH_DDTW_DIST_SCALE, MATCH_DTW_BLEND, MATCH_DTW_DIST_SCALE, MATCH_DTW_ENSEMBLE_W, MATCH_DTW_RESAMPLE_N, MATCH_DURATION_SCALE, MATCH_DURATION_WEIGHT, MATCH_ENERGY_SCALE, MATCH_ENERGY_WEIGHT, MATCH_MAE_PEAK_FLOOR, MATCH_MAE_REF_PEAK, MATCH_MAE_SCALE, STATE_ENDING, STATE_FINISHED, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_RUNNING, STATE_STARTING, STATE_UNKNOWN, TerminationReason, ) from .cycle_detector import CycleDetector, CycleDetectorConfig from .profile_store import _ambiguity_from_candidates, decompress_power_data _LOGGER = logging.getLogger(__name__) # The most recent N cycles to replay when the caller does not name any. DEFAULT_RECENT_CYCLES = 20 # Hard upper bound on cycles simulated in one batch call (defence in depth on # top of the caller-supplied ``concurrency`` cap). MAX_BATCH_CYCLES = 50 # Cap the per-cycle event log so a pathological trace cannot bloat the payload. MAX_EVENTS_PER_CYCLE = 300 # Override keys the Playground honours, mapped to CycleDetectorConfig fields. # Only detection-relevant knobs matter; everything else in settings_override is # ignored safely. _OVERRIDE_FIELD_MAP: dict[str, tuple[str, Callable[[Any], Any]]] = { CONF_MIN_POWER: ("min_power", float), CONF_OFF_DELAY: ("off_delay", int), CONF_MIN_OFF_GAP: ("min_off_gap", int), CONF_COMPLETION_MIN_SECONDS: ("completion_min_seconds", int), CONF_END_REPEAT_COUNT: ("end_repeat_count", int), CONF_START_THRESHOLD_W: ("start_threshold_w", float), CONF_STOP_THRESHOLD_W: ("stop_threshold_w", float), CONF_RUNNING_DEAD_ZONE: ("running_dead_zone", int), CONF_START_DURATION_THRESHOLD: ("start_duration_threshold", float), CONF_ABRUPT_DROP_WATTS: ("abrupt_drop_watts", float), CONF_INTERRUPTED_MIN_SECONDS: ("interrupted_min_seconds", int), } # Matching options the Playground honours, mapped to the ``match_config`` key # (Stage-1 duration gate) they drive. Only options the user can ACTUALLY set in # Settings are here, so a value found in the Playground can be applied for real - # the scoring weights (corr/duration/energy) and dtw_bandwidth are ML-tuned, not # user-settable, so exposing them would be pointless. Anything else in # ``settings_override`` is ignored. _MATCH_OVERRIDE_KEYS: dict[str, tuple[str, Callable[[Any], Any]]] = { CONF_PROFILE_MATCH_MIN_DURATION_RATIO: ("min_duration_ratio", float), CONF_PROFILE_MATCH_MAX_DURATION_RATIO: ("max_duration_ratio", float), } def apply_match_overrides( match_config: dict[str, Any], settings_override: dict[str, Any] | None ) -> dict[str, Any]: """Return a copy of ``match_config`` with the recognised matching options from ``settings_override`` overlaid onto the matcher-config keys they drive. Unknown/None/malformed values are ignored, so a detection-only override leaves matching byte-identical to the live config.""" if not settings_override: return match_config out = dict(match_config) for opt_key, (cfg_key, coerce) in _MATCH_OVERRIDE_KEYS.items(): val = settings_override.get(opt_key) if val is None: continue try: out[cfg_key] = coerce(val) except (TypeError, ValueError): pass return out def build_sim_config( base: CycleDetectorConfig, settings_override: dict[str, Any] | None ) -> CycleDetectorConfig: """Return a copy of ``base`` with the recognised override keys applied. Unknown keys and un-coercible values are ignored so a malformed override can never break a simulation. ``base`` is left untouched. """ if not isinstance(settings_override, dict) or not settings_override: return base changes: dict[str, Any] = {} for key, value in settings_override.items(): mapping = _OVERRIDE_FIELD_MAP.get(key) if mapping is None or value is None: continue field, coerce = mapping try: changes[field] = coerce(value) except (TypeError, ValueError): continue if not changes: return base try: return replace(base, **changes) except (TypeError, ValueError): # pragma: no cover - defensive return base def _cycle_base_time(cycle: dict[str, Any]) -> datetime: """Timezone-aware anchor for a cycle's offset-0 reading. Prefers the stored ISO ``start_time``; falls back to a fixed UTC epoch so offsets remain well-defined even for malformed cycles. """ raw = cycle.get("start_time") if isinstance(raw, datetime): return raw if raw.tzinfo else raw.replace(tzinfo=timezone.utc) if isinstance(raw, str) and raw: parsed = dt_util.parse_datetime(raw) if parsed is not None: return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) return datetime(2024, 1, 1, tzinfo=timezone.utc) def _cycle_label(cycle: dict[str, Any]) -> str | None: """The cycle's confirmed profile label (profile_name, else label).""" for key in ("profile_name", "label"): val = cycle.get(key) if isinstance(val, str) and val and val.lower() != "noise": return val return None def _build_match_snapshots( store: Any, ) -> tuple[list[dict[str, Any]], dict[str, Any], dict[str, list[str]], dict[str, Any]]: """Prepare the matcher snapshots + config once from the store. Mirrors :meth:`ProfileStore.match_profile`: one snapshot per profile using its sample cycle's decompressed trace, plus the store's live matching config (with any on-device tuned weight overrides merged in). Also applies Stage-5 group collapsing via :meth:`ProfileStore._grouped_snapshots`: returns the collapsed snapshot list (where each cohesive group is represented by a single ``__group__*`` aggregate candidate), plus ``group_members`` and ``member_snaps`` for the Stage-5 member-resolution step in :func:`_simulate_one`. Returns ``(grouped_snapshots, match_config, group_members, member_snaps)``. When no cohesive groups exist ``group_members`` and ``member_snaps`` are both empty dicts and behaviour is identical to before. """ snapshots: list[dict[str, Any]] = [] try: data = getattr(store, "_data", {}) or {} profiles = data.get("profiles", {}) or {} # Include imported reference cycles: an import-only profile samples from # reference_cycles, so without them it would be dropped as a candidate and # the Playground auto-detect would never match a downloaded profile. past = data.get("past_cycles", []) or [] refs = data.get("reference_cycles", []) or [] by_id = {c.get("id"): c for c in (list(past) + list(refs)) if isinstance(c, dict)} for name, profile in profiles.items(): if not isinstance(profile, dict): continue sample_cycle = by_id.get(profile.get("sample_cycle_id")) if not sample_cycle: continue sample_p = decompress_power_data(sample_cycle) if not sample_p: continue avg_dur = ( profile.get("avg_duration") or sample_cycle.get("duration") or 0.0 ) snapshots.append( { "name": name, "avg_duration": float(avg_dur), "sample_power": [p for _, p in sample_p], } ) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Playground: snapshot build failed: %s", exc) # Stage-5: collapse cohesive profile groups into aggregate candidates. group_members: dict[str, list[str]] = {} member_snaps: dict[str, Any] = {} try: grouped_snaps, group_members, member_snaps = store._grouped_snapshots( # pylint: disable=protected-access snapshots ) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Playground: _grouped_snapshots failed: %s", exc) grouped_snaps = snapshots config = _matching_config(store) return grouped_snaps, config, group_members, member_snaps def _matching_config(store: Any) -> dict[str, Any]: """Live matcher config from the store (defaults + tuned overrides).""" config: dict[str, Any] = { "min_duration_ratio": float(getattr(store, "_min_duration_ratio", 0.07)), "max_duration_ratio": float(getattr(store, "_max_duration_ratio", 1.5)), "dtw_bandwidth": float(getattr(store, "dtw_bandwidth", 0.2)), } try: overrides = store._matching_overrides() # pylint: disable=protected-access if isinstance(overrides, dict): config.update(overrides) except Exception: # pylint: disable=broad-exception-caught pass return config def decide_commit( raw_name: str | None, is_ambiguous: bool, commit_state: dict[str, Any], persistence: int, ) -> str | None: """Advance the persistence-gated match-commit state (mirror of the manager's core rule) and report the event to emit. Mutates ``commit_state`` (``candidate``/``count``/``name``): a candidate must be the non-ambiguous top-1 for ``persistence`` consecutive calls before it is committed, and the committed ``name`` is held (a one-off wobble resets the streak but never switches the commit). Returns ``"match_commit"`` on the first commit, ``"match_changed"`` on a later switch, or ``None`` otherwise. Pure + unit-testable; event emission / reporting stay in the caller. """ if not raw_name or is_ambiguous: return None if raw_name == commit_state["candidate"]: commit_state["count"] += 1 else: commit_state["candidate"] = raw_name commit_state["count"] = 1 if commit_state["count"] >= persistence and commit_state["name"] != raw_name: prev = commit_state["name"] commit_state["name"] = raw_name return "match_changed" if prev else "match_commit" return None def _readings_from_cycle( cycle: dict[str, Any], ) -> tuple[list[tuple[datetime, float]], list[tuple[float, float]], datetime]: """Reconstruct (datetime, power) readings + (offset, power) points + base time.""" points = decompress_power_data(cycle) base = _cycle_base_time(cycle) readings = [(base + timedelta(seconds=float(o)), float(p)) for o, p in points] return readings, points, base def _simulate_one( cycle: dict[str, Any], sim_config: CycleDetectorConfig, snapshots: list[dict[str, Any]], match_config: dict[str, Any], store: Any = None, group_members: dict[str, list[str]] | None = None, member_snaps: dict[str, Any] | None = None, ) -> dict[str, Any]: """Replay one stored cycle through a fresh headless detector. Returns ``{cycle_id, profile_name, events, outcome}``. Never raises. When ``group_members`` is non-empty the matcher applies Stage-5 group resolution: a winning ``__group__*`` aggregate candidate is resolved to its best-fitting member profile so ``outcome["match_profile"]`` always contains a real profile name, never a group key. """ cycle_id = cycle.get("id") label = _cycle_label(cycle) events: list[dict[str, Any]] = [] outcome: dict[str, Any] = { "detected": False, "detected_duration_s": None, "stored_duration_s": _safe_float(cycle.get("duration")), "match_profile": None, "match_correct": None, "ambiguous": False, "termination_reason": None, "status": None, "detected_count": 0, } try: readings, _points, base = _readings_from_cycle(cycle) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Playground: bad cycle %s: %s", cycle_id, exc) return { "cycle_id": cycle_id, "profile_name": label, "events": events, "outcome": outcome, } if len(readings) < 5: return { "cycle_id": cycle_id, "profile_name": label, "events": events, "outcome": outcome, } # Shared mutable state for the callbacks (offset seconds from cycle start). cursor = {"t": 0.0} captured: list[dict[str, Any]] = [] last_match: dict[str, Any] = {"name": None, "conf": 0.0, "ambiguous": False} # Dedupe consecutive identical match outcomes so the log stays readable. last_logged_match: dict[str, Any] = {"kind": None, "name": None} def _emit(etype: str, detail: str) -> None: if len(events) < MAX_EVENTS_PER_CYCLE: events.append({"t": round(cursor["t"], 1), "type": etype, "detail": detail}) def _on_state_change(old_state: str, new_state: str) -> None: _emit("state", f"{old_state}->{new_state}") def _on_cycle_end(cycle_data: dict[str, Any]) -> None: captured.append(cycle_data) _emit( "end", "reason={reason} status={status} dur={dur:.0f}s".format( reason=cycle_data.get("termination_reason"), status=cycle_data.get("status"), dur=float(cycle_data.get("duration") or 0.0), ), ) def _matcher( det_readings: list[tuple[datetime, float]], ) -> tuple[str | None, float, float, str | None, bool, bool]: if len(det_readings) < 5 or not snapshots: return (None, 0.0, 0.0, None, False, False) powers = [p for _, p in det_readings] duration = (det_readings[-1][0] - det_readings[0][0]).total_seconds() try: candidates = analysis.compute_matches_worker( powers, duration, snapshots, match_config ) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Playground match failed: %s", exc) candidates = [] if not candidates: if last_logged_match["kind"] != "unmatched": _emit("unmatched", "no candidate") last_logged_match["kind"] = "unmatched" last_logged_match["name"] = None last_match["name"] = None last_match["conf"] = 0.0 last_match["ambiguous"] = False return (None, 0.0, 0.0, None, False, False) # Stage-5: resolve a winning group aggregate to its best-fitting member. # This mirrors profile_store._stage5_pick_member used in production. # Note: dtw_debug_payload always works on an individual profile, so # Stage-5 resolution is only needed here in the batch matcher. if group_members and candidates: top = candidates[0] gkey = top.get("name", "") if gkey.startswith("__group__") and store is not None: members = group_members.get(gkey, []) if members: try: powers_list = list(powers) member_name, _, _ = store._stage5_pick_member( # pylint: disable=protected-access powers_list, duration, members, member_snaps or {} ) candidates[0] = dict(top, name=member_name) _emit("group_resolved", f"{gkey} -> {member_name}") except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug( "Playground stage5 pick_member failed: %s", exc ) best = candidates[0] margin, is_ambiguous = _ambiguity_from_candidates(candidates) name = best.get("name") conf = float(best.get("score") or 0.0) expected = float(best.get("profile_duration") or 0.0) last_match["name"] = name last_match["conf"] = conf last_match["ambiguous"] = bool(is_ambiguous) if is_ambiguous: runner = candidates[1].get("name") if len(candidates) > 1 else None if ( last_logged_match["kind"] != "ambiguous" or last_logged_match["name"] != name ): _emit("ambiguous", f"{name} vs {runner} (margin={margin:.3f})") last_logged_match["kind"] = "ambiguous" last_logged_match["name"] = name elif ( last_logged_match["kind"] != "matched" or last_logged_match["name"] != name ): _emit("matched", f"{name} (conf={conf:.2f})") last_logged_match["kind"] = "matched" last_logged_match["name"] = name return (name, conf, expected, None, False, bool(is_ambiguous)) detector = CycleDetector( sim_config, _on_state_change, _on_cycle_end, profile_matcher=_matcher, device_name="playground", ) try: for ts, power in readings: cursor["t"] = (ts - base).total_seconds() detector.process_reading(power, ts) # Feed a synthetic quiet tail so a natural end (timeout / min-off-gap) # can fire, exactly as it would in production once the appliance goes # idle. Sized to comfortably exceed both the off-delay and the # soak-bridging min_off_gap. last_ts = readings[-1][0] tail_span = max( float(sim_config.off_delay or 0.0), float(sim_config.min_off_gap or 0.0), ) * 1.5 + 300.0 step = 30.0 n_steps = min(int(tail_span / step) + 1, 400) for i in range(1, n_steps + 1): ts = last_ts + timedelta(seconds=step * i) cursor["t"] = (ts - base).total_seconds() detector.process_reading(0.0, ts) if detector.state in (STATE_OFF, STATE_FINISHED) and captured: break # If a cycle started but never finalized (unusual), flush it so the # outcome is well-defined; it lands in the log as force-stopped. if not captured and detector.state != STATE_OFF: flush_ts = last_ts + timedelta(seconds=step * (n_steps + 2)) cursor["t"] = (flush_ts - base).total_seconds() detector.force_end(flush_ts) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Playground simulate failed for %s: %s", cycle_id, exc) outcome["detected_count"] = len(captured) if captured: primary = max(captured, key=lambda c: float(c.get("duration") or 0.0)) outcome["detected"] = True outcome["detected_duration_s"] = _safe_float(primary.get("duration")) outcome["termination_reason"] = primary.get("termination_reason") outcome["status"] = primary.get("status") outcome["match_profile"] = last_match["name"] outcome["ambiguous"] = bool(last_match["ambiguous"]) if outcome["detected"] and last_match["name"] and label: outcome["match_correct"] = last_match["name"].strip() == label.strip() else: outcome["match_correct"] = None return { "cycle_id": cycle_id, "profile_name": label, "events": events, "outcome": outcome, } def run_playground_batch( store: Any, cycle_ids: list[str] | None, base_config: CycleDetectorConfig, settings_override: dict[str, Any] | None, concurrency: int, ) -> dict[str, Any]: """Replay a set of cycles headlessly; return {results, summary}. ``concurrency`` caps how many of the selected cycles are simulated in this batch (batch size), clamped 1..MAX_BATCH_CYCLES. Executor-safe; never raises. """ try: concurrency = max(1, min(MAX_BATCH_CYCLES, int(concurrency))) except (TypeError, ValueError): concurrency = 1 summary: dict[str, Any] = { "cycles": 0, "requested": 0, "concurrency": concurrency, "detected": 0, "missed": 0, "false_end": 0, "match_correct": 0, "match_wrong": 0, "unmatched": 0, "skipped_ids": [], } try: past = list(store.get_past_cycles() or []) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Playground: get_past_cycles failed: %s", exc) return {"results": [], "summary": summary} by_id = {c.get("id"): c for c in past if isinstance(c, dict)} selected: list[dict[str, Any]] = [] skipped: list[str] = [] if cycle_ids: for cid in cycle_ids: cycle = by_id.get(cid) if cycle is None: skipped.append(cid) else: selected.append(cycle) else: selected = past[-DEFAULT_RECENT_CYCLES:] summary["requested"] = len(cycle_ids) if cycle_ids else len(selected) # Batch-size cap: simulate up to ``concurrency`` of the selected cycles # (the runner is sequential). Any selected cycles beyond the cap are reported # as skipped rather than silently dropped, so ``requested`` always reconciles # with ``len(results) + len(skipped_ids)``. to_run = selected[:concurrency] if len(selected) > concurrency: # Account for every capped cycle (even one lacking an id) so that # requested == len(results) + len(skipped_ids) always reconciles. skipped.extend(str(c.get("id") or "") for c in selected[concurrency:]) summary["skipped_ids"] = skipped config = build_sim_config(base_config, settings_override) snapshots, match_config, group_members, member_snaps = _build_match_snapshots(store) match_config = apply_match_overrides(match_config, settings_override) results: list[dict[str, Any]] = [] for cycle in to_run: res = _simulate_one( cycle, config, snapshots, match_config, store=store, group_members=group_members, member_snaps=member_snaps, ) results.append(res) oc = res["outcome"] summary["cycles"] += 1 if oc.get("detected"): summary["detected"] += 1 if int(oc.get("detected_count") or 0) > 1: summary["false_end"] += 1 correct = oc.get("match_correct") if oc.get("match_profile") is None: summary["unmatched"] += 1 elif correct is True: summary["match_correct"] += 1 elif correct is False: summary["match_wrong"] += 1 else: summary["missed"] += 1 return {"results": results, "summary": summary} # ─── Single-cycle faithful simulation (Simulate mode) ─────────────────────────── _PROGRESS_STATES = (STATE_RUNNING, STATE_PAUSED, STATE_ENDING) # States in which no progress estimate is shown (mirrors _update_remaining_only). _DEAD_STATES = (STATE_OFF, STATE_UNKNOWN, STATE_IDLE) _SIM_SERIES_THROTTLE_S = 5.0 # production recomputes progress every 5s def simulate_cycle_detail( cycle: dict[str, Any], base_config: CycleDetectorConfig, settings_override: dict[str, Any] | None, store: Any, options: dict[str, Any] | None, price: float | None = None, compute_series: bool = True, prebuilt: tuple[Any, Any, Any, Any] | None = None, ) -> dict[str, Any]: """Faithful single-cycle replay for the Playground "Simulate" view. Drives the REAL :class:`CycleDetector` + real Stage 1-4 matcher over the cycle's own trace and, at production's 5s cadence, calls the SAME :mod:`progress` and :mod:`notification_rules` functions the live integration uses - so the returned timeline is byte-for-byte what would happen live. No detection/progress/notification math is implemented here; this only orchestrates the shared code. Never raises; returns ``{"error": ...}`` on failure. Read-only: nothing is persisted and no notifications are sent. Returns ``{cycle_id, label, duration_s, config_summary, series, events, alerts, outcome}`` (see the design doc for the field contract). """ options = options or {} try: return _simulate_cycle_detail_inner( cycle, base_config, settings_override, store, options, price, compute_series, prebuilt, ) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Playground detail sim failed for %s: %s", cycle.get("id"), exc) return {"error": str(exc), "cycle_id": cycle.get("id")} def _device_type_of(config: CycleDetectorConfig) -> str: return getattr(config, "device_type", "washing_machine") def simulate_cycle_detail_by_id( store: Any, cycle_id: str, base_config: CycleDetectorConfig, settings_override: dict[str, Any] | None, options: dict[str, Any] | None, price: float | None = None, ) -> dict[str, Any]: """Find a stored cycle by id and simulate it. Runs the (in-memory) store lookup and the replay together so the WS handler can offload the whole thing to an executor thread. Returns ``{"error": "not_found", ...}`` when the id is unknown. Never raises.""" try: cycle = next( (c for c in store.get_past_cycles() if c.get("id") == cycle_id), None ) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Playground detail lookup failed for %s: %s", cycle_id, exc) return {"error": str(exc), "cycle_id": cycle_id} if cycle is None: return {"error": "not_found", "cycle_id": cycle_id} return simulate_cycle_detail( cycle, base_config, settings_override, store, options, price ) def _simulate_cycle_detail_inner( cycle: dict[str, Any], base_config: CycleDetectorConfig, settings_override: dict[str, Any] | None, store: Any, options: dict[str, Any], price: float | None, compute_series: bool = True, prebuilt: tuple[Any, Any, Any, Any] | None = None, ) -> dict[str, Any]: config = build_sim_config(base_config, settings_override) device_type = _device_type_of(config) label = _cycle_label(cycle) readings, _points, base = _readings_from_cycle(cycle) stored_duration = _safe_float(cycle.get("duration")) outcome: dict[str, Any] = { "detected": False, "detected_count": 0, "termination_reason": None, "status": None, "final_duration_s": None, "matched_profile": None, "match_correct": None, "confidence": None, "expected_s": None, "overrun_ratio": None, "projected_energy_wh": None, "projected_cost": None, } if prebuilt is not None: snapshots, match_config, group_members, member_snaps = prebuilt else: snapshots, match_config, group_members, member_snaps = _build_match_snapshots(store) # Overlay any matcher-knob overrides. Because history/sweep run through this # same function, a swept matching value flows in via settings_override too; # applying to a copy keeps the shared prebuilt match_config untouched. match_config = apply_match_overrides(match_config, settings_override) empty = { "cycle_id": cycle.get("id"), "label": label, "duration_s": stored_duration, "config_summary": _sim_config_summary(config), "series": [], "events": [], "alerts": [], "outcome": outcome, } if len(readings) < 5: return empty # Per-sim end-expectation cache, threaded through the shared progress helpers # exactly like the manager threads self._ml_end_expectation_cache. endexp_cache: list[Any] = [None] def _end_exp_fn(name: str, dur: float) -> Any: exp, endexp_cache[0] = progress_mod.profile_end_expectation( store, name, dur, endexp_cache[0] ) return exp events: list[dict[str, Any]] = [] series: list[dict[str, Any]] = [] captured: list[dict[str, Any]] = [] cursor = {"t": 0.0} last_match: dict[str, Any] = { "name": None, "conf": 0.0, "ambiguous": False, "expected": 0.0, } last_logged = {"kind": None, "name": None} # Persistence-gated commit mirroring the manager: a candidate must be top-1 for # `match_persistence` consecutive matches (and not ambiguous) before it is # committed, and the committed match is HELD (a one-off wobble doesn't switch # it). The detector still receives the raw top-1 (detection unchanged); only the # reported series/events use the committed match, so the Playground shows what # the live integration would show - not raw per-interval churn. match_persistence = max(1, int( (options or {}).get(CONF_MATCH_PERSISTENCE, DEFAULT_MATCH_PERSISTENCE) )) commit_state: dict[str, Any] = {"candidate": None, "count": 0, "name": None} smoothed = {"v": 0.0} flags = {"detected": False, "pre_complete": False, "start": False} def _emit(etype: str, detail: str, severity: str = "info") -> None: if len(events) < MAX_EVENTS_PER_CYCLE: events.append( {"t": round(cursor["t"], 1), "type": etype, "detail": detail, "severity": severity} ) # --- notification config (decisions reuse notification_rules) --- start_configured = bool( options.get(CONF_NOTIFY_START_SERVICES) or options.get(CONF_NOTIFY_ACTIONS) ) finish_configured = bool( options.get(CONF_NOTIFY_FINISH_SERVICES) or options.get(CONF_NOTIFY_ACTIONS) ) before_end = float( options.get(CONF_NOTIFY_BEFORE_END_MINUTES, DEFAULT_NOTIFY_BEFORE_END_MINUTES) or 0.0 ) quiet_bounds = notif_rules.quiet_hours_bounds(options) def _held(offset: float) -> bool: return notif_rules.in_quiet_hours(quiet_bounds, base + timedelta(seconds=offset)) def _on_state_change(old_state: str, new_state: str) -> None: _emit("state", f"{old_state}->{new_state}") # A new cycle is starting after a previous one ended: clear the inherited # match-persistence streak so this cycle matches fresh (see _on_cycle_end). # PAUSED->RUNNING resumes don't arm pending_reset, so they are unaffected. if new_state == STATE_RUNNING and flags.get("pending_reset"): flags["pending_reset"] = False commit_state.update(candidate=None, count=0, name=None) last_match.update(name=None, conf=0.0, expected=0.0, ambiguous=False) last_logged.update(kind=None, name=None) if ( not flags["detected"] and new_state == STATE_RUNNING and old_state in (STATE_OFF, STATE_UNKNOWN, STATE_STARTING, STATE_IDLE) ): flags["detected"] = True _emit("detected", "cycle detected (running)") if start_configured and not flags["start"]: flags["start"] = True # Start notifications are never delayed by quiet hours (live contract), # so the sim always emits them immediately. _emit("notify_start", "start notification") def _on_cycle_end(cycle_data: dict[str, Any]) -> None: captured.append(cycle_data) reason = cycle_data.get("termination_reason") _emit("finished", f"reason={reason} status={cycle_data.get('status')}", "info") # Arm a match-state reset for the NEXT cycle. We reset at the next cycle's # start (not here) so the final cycle's committed match survives to be read # into `outcome` after the loop; a second sub-cycle then starts a fresh # match-persistence streak, mirroring the live manager (per-cycle reset). flags["pending_reset"] = True def _matcher(det_readings: list[tuple[datetime, float]]): if len(det_readings) < 5 or not snapshots: return (None, 0.0, 0.0, None, False, False) powers = [p for _, p in det_readings] duration = (det_readings[-1][0] - det_readings[0][0]).total_seconds() try: candidates = analysis.compute_matches_worker( powers, duration, snapshots, match_config ) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Playground detail match failed: %s", exc) candidates = [] if not candidates: if last_logged["kind"] != "unmatched": _emit("unmatched", "no candidate") last_logged["kind"] = "unmatched" # Hold any committed match on a transient miss (as the manager does). last_match.update(ambiguous=False) return (None, 0.0, 0.0, None, False, False) if group_members and candidates[0].get("name", "").startswith("__group__"): gkey = candidates[0]["name"] members = group_members.get(gkey, []) if members and store is not None: try: member_name, _, _ = store._stage5_pick_member( # noqa: SLF001 list(powers), duration, members, member_snaps or {} ) candidates[0] = dict(candidates[0], name=member_name) except Exception: # pylint: disable=broad-exception-caught pass best = candidates[0] margin, is_ambiguous = _ambiguity_from_candidates(candidates) raw_name = best.get("name") raw_conf = float(best.get("score") or 0.0) raw_expected = float(best.get("profile_duration") or 0.0) # Persistence-gated commit (mirror of the manager's core rule), extracted # into decide_commit() for unit-testability. commit_event = decide_commit(raw_name, is_ambiguous, commit_state, match_persistence) if commit_event: _emit(commit_event, f"{raw_name} (conf={raw_conf:.2f})") last_logged.update(kind="matched", name=raw_name) elif is_ambiguous and not commit_state["name"]: # Ambiguous before any commit: stay 'detecting', surface it once. if last_logged["kind"] != "ambiguous" or last_logged["name"] != raw_name: runner = candidates[1].get("name") if len(candidates) > 1 else None _emit("match_ambiguous", f"{raw_name} vs {runner} (margin={margin:.3f})", "warn") last_logged.update(kind="ambiguous", name=raw_name) # Reported state = the COMMITTED match (held); its confidence/expected are # that profile's own values this interval (looked up among the candidates). cname = commit_state["name"] if cname: cc = next((c for c in candidates if c.get("name") == cname), None) last_match.update( name=cname, conf=float(cc.get("score") or 0.0) if cc else (last_match.get("conf") or 0.0), expected=float(cc.get("profile_duration") or 0.0) if cc else (last_match.get("expected") or 0.0), ambiguous=False, ) else: last_match.update(name=None, conf=0.0, expected=0.0, ambiguous=bool(is_ambiguous)) # The DETECTOR still receives the RAW top-1, so detection / smart-termination # behaviour is byte-identical to before this reporting change. return (raw_name, raw_conf, raw_expected, None, False, bool(is_ambiguous)) detector = CycleDetector( config, _on_state_change, _on_cycle_end, profile_matcher=_matcher, device_name="playground-detail", ) last_sample_t = -1e9 def _sample(ts: datetime) -> None: nonlocal last_sample_t if not compute_series: return # batch/sweep rows only need the outcome, not the per-step series offset = (ts - base).total_seconds() if offset - last_sample_t < _SIM_SERIES_THROTTLE_S: return last_sample_t = offset state = detector.state power = 0.0 trace = detector.get_power_trace() if trace: power = float(trace[-1][1]) energy_wh = float(getattr(detector, "_energy_since_idle_wh", 0.0) or 0.0) pt: dict[str, Any] = { "t": round(offset, 1), "power": round(power, 1), "energy_wh": round(energy_wh, 2), "state": state, "progress": None, "remaining_s": None, "phase": None, "confidence": round(last_match["conf"], 3) if last_match["name"] else None, "matched_profile": last_match["name"], } matched_dur = float(last_match["expected"] or 0.0) program = last_match["name"] if state not in _DEAD_STATES and program and matched_dur > 0: phase_result = None if len(trace) >= 10 and program != "detecting...": phase_result = progress_mod.estimate_phase_progress( store, trace, offset, program ) ml_pct = progress_mod.ml_progress_percent( store, options, matched_dur, trace, program, _end_exp_fn ) result = progress_mod.compute_progress( device_type, matched_dur, offset, smoothed["v"], phase_result, ml_pct ) if result is not None: smoothed["v"] = result.smoothed pt["progress"] = round(result.progress, 1) pt["remaining_s"] = round(result.remaining, 0) pt["phase"] = progress_mod.current_phase( store, state, program, result.progress ) wh, cost = progress_mod.projected_energy( store, options, matched_dur, trace, program, result.progress, energy_wh, price, _end_exp_fn, ) pt["projected_energy_wh"] = round(wh, 1) if wh is not None else None pt["projected_cost"] = round(cost, 4) if cost is not None else None # One-time pre-completion marker (reuses the production predicate). if not flags["pre_complete"] and notif_rules.should_notify_pre_completion( before_end, flags["pre_complete"], result.remaining, result.progress, last_match["ambiguous"], ): flags["pre_complete"] = True held = _held(offset) _emit( "notify_held" if held else "notify_pre_complete", "pre-completion notification" + (" (held: quiet hours)" if held else ""), ) series.append(pt) try: for ts, power in readings: cursor["t"] = (ts - base).total_seconds() detector.process_reading(power, ts) _sample(ts) # Synthetic quiet tail so a natural end can fire, as in _simulate_one. last_ts = readings[-1][0] tail_span = max( float(config.off_delay or 0.0), float(config.min_off_gap or 0.0) ) * 1.5 + 300.0 step = 30.0 n_steps = min(int(tail_span / step) + 1, 400) for i in range(1, n_steps + 1): ts = last_ts + timedelta(seconds=step * i) cursor["t"] = (ts - base).total_seconds() detector.process_reading(0.0, ts) _sample(ts) if detector.state in (STATE_OFF, STATE_FINISHED) and captured: break if not captured and detector.state != STATE_OFF: flush_ts = last_ts + timedelta(seconds=step * (n_steps + 2)) cursor["t"] = (flush_ts - base).total_seconds() detector.force_end(flush_ts) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Playground detail replay failed for %s: %s", cycle.get("id"), exc) # --- outcome --- primary = None if captured: primary = max(captured, key=lambda c: float(c.get("duration") or 0.0)) outcome["detected"] = True outcome["detected_count"] = len(captured) outcome["termination_reason"] = primary.get("termination_reason") outcome["status"] = primary.get("status") outcome["final_duration_s"] = _safe_float(primary.get("duration")) outcome["matched_profile"] = last_match["name"] outcome["confidence"] = ( round(float(last_match["conf"]), 3) if last_match["name"] else None ) outcome["expected_s"] = ( round(float(last_match["expected"] or 0.0), 1) or None ) if outcome["detected"] and last_match["name"] and label: outcome["match_correct"] = last_match["name"].strip() == label.strip() # Projected energy/cost: the last LIVE estimate (the post-finish tail resets # the detector's accumulated energy, so series[-1] would read None). for pt in reversed(series): if pt.get("projected_energy_wh") is not None: outcome["projected_energy_wh"] = pt.get("projected_energy_wh") outcome["projected_cost"] = pt.get("projected_cost") break # --- finish + milestone markers (reuse production predicates) --- if captured and finish_configured: held = _held(cursor["t"]) _emit( "notify_held" if held else "notify_finish", "finish notification" + (" (held: quiet hours)" if held else ""), ) try: prev_life = int(store.get_lifetime_cycle_count()) except Exception: # pylint: disable=broad-exception-caught prev_life = 0 crossed = notif_rules.milestone_crossed( prev_life, prev_life + 1, options.get(CONF_NOTIFY_MILESTONES, DEFAULT_NOTIFY_MILESTONES), ) if crossed is not None: # Milestone notifications are held during quiet hours (live contract). m_held = _held(cursor["t"]) _emit( "notify_held" if m_held else "notify_milestone", f"milestone {crossed} cycles" + (" (held: quiet hours)" if m_held else ""), ) # --- alerts --- alerts: list[dict[str, Any]] = [] expected_dur = float(last_match["expected"] or 0.0) final_dur = outcome["final_duration_s"] or 0.0 if not outcome["detected"]: alerts.append({"code": "did_not_finish", "severity": "error", "detail": "Cycle never reached a terminal state in the replay."}) if outcome["detected"] and (outcome["detected_count"] or 0) > 1: alerts.append({"code": "false_end", "severity": "error", "detail": f"Split into {outcome['detected_count']} cycles."}) if outcome["matched_profile"] is None: alerts.append({"code": "unmatched", "severity": "warn", "detail": "No profile matched this cycle."}) if last_match["ambiguous"]: alerts.append({"code": "ambiguous", "severity": "warn", "detail": "Match was ambiguous (two programs scored close)."}) # How the cycle ended: predictive (smart / terminal-drop) vs the static # low-power fallback. Under auto-detect an unmatched cycle cannot use smart # end-prediction, so it only stops once power stays low for the off-delay - # or, if it never goes quiet, not at all. Surface which happened. term = str(outcome.get("termination_reason") or "") if outcome["detected"] and term == str(TerminationReason.FORCE_STOPPED): alerts.append({"code": "would_run_indefinitely", "severity": "error", "detail": ("The cycle never ended on its own - only the safety " "force-stop finalized it in simulation. In real use it " "would keep counting as running until power stays low.")}) elif outcome["detected"] and term == str(TerminationReason.TIMEOUT): off_min = max(1, round(float(getattr(config, "off_delay", 0) or 0) / 60)) if outcome["matched_profile"] is None: alerts.append({"code": "timeout_end", "severity": "warn", "detail": (f"Ended only by the low-power timeout: no profile matched, " f"so smart end-prediction could not run and it waited out " f"the {off_min} min off-delay after power dropped.")}) else: alerts.append({"code": "timeout_end", "severity": "info", "detail": (f"Ended by the low-power timeout, not smart prediction: it " f"waited out the {off_min} min off-delay after power dropped.")}) if expected_dur > 0 and final_dur > 0: ratio = final_dur / expected_dur outcome["overrun_ratio"] = round(ratio, 3) if ratio >= CYCLE_OVERRUN_ANOMALY_RATIO: alerts.append({"code": "overrun", "severity": "warn", "detail": f"Ran {ratio:.0%} of the profile's typical duration."}) elif ratio <= CYCLE_UNDERRUN_ANOMALY_RATIO: alerts.append({"code": "underrun", "severity": "warn", "detail": f"Finished at {ratio:.0%} of typical duration."}) return { "cycle_id": cycle.get("id"), "label": label, "duration_s": stored_duration, "config_summary": _sim_config_summary(config), "series": series, "events": events, "alerts": alerts, "outcome": outcome, } def _sim_config_summary(config: CycleDetectorConfig) -> dict[str, Any]: """Compact view of the effective detector config used for the sim.""" return { "device_type": getattr(config, "device_type", None), "min_power": getattr(config, "min_power", None), "off_delay": getattr(config, "off_delay", None), "min_off_gap": getattr(config, "min_off_gap", None), "start_threshold_w": getattr(config, "start_threshold_w", None), "stop_threshold_w": getattr(config, "stop_threshold_w", None), } # ─── Test-on-history rows + before/after diff ─────────────────────────────────── def _detail_to_row(detail: dict[str, Any]) -> dict[str, Any]: """Compact per-cycle row for the Test-on-history table from a detail sim.""" o = detail.get("outcome", {}) return { "cycle_id": detail.get("cycle_id"), "label": detail.get("label"), "detected": bool(o.get("detected")), "detected_count": int(o.get("detected_count") or 0), "matched_profile": o.get("matched_profile"), "match_correct": o.get("match_correct"), "confidence": o.get("confidence"), "termination_reason": ( str(o.get("termination_reason")) if o.get("termination_reason") else None ), "status": o.get("status"), "duration_s": o.get("final_duration_s"), "stored_duration_s": detail.get("duration_s"), "expected_s": o.get("expected_s"), "overrun_ratio": o.get("overrun_ratio"), "alerts": [a.get("code") for a in detail.get("alerts", [])], } def _rows_summary(rows: list[dict[str, Any]]) -> dict[str, Any]: total = len(rows) detected = sum(1 for r in rows if r["detected"]) labelled = [r for r in rows if r["label"]] correct = sum(1 for r in labelled if r["match_correct"] is True) wrong = sum(1 for r in labelled if r["match_correct"] is False) unmatched = sum(1 for r in rows if r["detected"] and r["matched_profile"] is None) false_end = sum(1 for r in rows if (r["detected_count"] or 0) > 1) return { "cycles": total, "detected": detected, "labelled": len(labelled), "match_correct": correct, "match_wrong": wrong, "unmatched": unmatched, "false_end": false_end, } def _run_rows( store: Any, cycles: list[dict[str, Any]], base_config: CycleDetectorConfig, settings_override: dict[str, Any] | None, options: dict[str, Any], price: float | None, ) -> list[dict[str, Any]]: # Snapshots are store-derived (independent of the cycle and the detector-level # settings_override), so build them ONCE and reuse across all cycles/values. prebuilt = _build_match_snapshots(store) rows: list[dict[str, Any]] = [] for cycle in cycles: detail = simulate_cycle_detail( cycle, base_config, settings_override, store, options, price, compute_series=False, prebuilt=prebuilt, ) if "error" in detail: continue rows.append(_detail_to_row(detail)) return rows def run_playground_history( store: Any, cycle_ids: list[str] | None, base_config: CycleDetectorConfig, settings_override: dict[str, Any] | None, options: dict[str, Any] | None, price: float | None, concurrency: int, ) -> dict[str, Any]: """Per-cycle rows for the Test-on-history table, plus a before/after diff when ``settings_override`` is set. Executor-safe; never raises.""" options = options or {} try: concurrency = max(1, min(MAX_BATCH_CYCLES, int(concurrency))) except (TypeError, ValueError): concurrency = MAX_BATCH_CYCLES try: past = list(store.get_past_cycles() or []) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Playground history: get_past_cycles failed: %s", exc) return {"rows": [], "summary": _rows_summary([])} by_id = {c.get("id"): c for c in past if isinstance(c, dict)} if cycle_ids: selected = [by_id[c] for c in cycle_ids if c in by_id] else: selected = past[-DEFAULT_RECENT_CYCLES:] selected = selected[:concurrency] override = settings_override or None rows = _run_rows(store, selected, base_config, override, options, price) payload: dict[str, Any] = {"rows": rows, "summary": _rows_summary(rows)} if override: base_rows = _run_rows(store, selected, base_config, None, options, price) payload["baseline_rows"] = base_rows payload["baseline_summary"] = _rows_summary(base_rows) payload["diff"] = _diff_rows(base_rows, rows) return payload def finalize_history( rows: list[dict[str, Any]], baseline_rows: list[dict[str, Any]], has_override: bool, ) -> dict[str, Any]: """Assemble the Test-on-history payload from rows collected across chunks (used by the server-side task runner). Reuses the same summary/diff helpers as the one-shot :func:`run_playground_history` so there is one aggregation path.""" payload: dict[str, Any] = {"rows": rows, "summary": _rows_summary(rows)} if has_override and baseline_rows: payload["baseline_rows"] = baseline_rows payload["baseline_summary"] = _rows_summary(baseline_rows) payload["diff"] = _diff_rows(baseline_rows, rows) return payload def _diff_rows( baseline: list[dict[str, Any]], override: list[dict[str, Any]] ) -> dict[str, list[str]]: """Which cycles changed between baseline and override runs (keyed by id).""" base_by = {r["cycle_id"]: r for r in baseline} newly_correct: list[str] = [] regressed: list[str] = [] end_timing_changed: list[str] = [] for r in override: b = base_by.get(r["cycle_id"]) if b is None: continue if b["match_correct"] is not True and r["match_correct"] is True: newly_correct.append(r["cycle_id"]) elif b["match_correct"] is True and r["match_correct"] is not True: regressed.append(r["cycle_id"]) bd, od = b.get("duration_s") or 0.0, r.get("duration_s") or 0.0 if b.get("termination_reason") != r.get("termination_reason") or abs(bd - od) > 60.0: end_timing_changed.append(r["cycle_id"]) return { "newly_correct": newly_correct, "regressed": regressed, "end_timing_changed": end_timing_changed, } # ─── Parameter sweep (1D curve + 2D heatmap) ──────────────────────────────────── _SWEEP_OBJECTIVES = ( "match_accuracy", "end_timing_accuracy", "false_end_rate", "median_overrun", "ambiguity_rate", ) # Objectives where a LOWER metric is better (best = minimum), so the sweep picks # the right winner and the panel colours the heatmap consistently. _SWEEP_LOWER_IS_BETTER = frozenset({ "false_end_rate", "median_overrun", "ambiguity_rate", }) def _sweep_is_better(candidate: float, best: float, objective: str) -> bool: if objective in _SWEEP_LOWER_IS_BETTER: return candidate < best return candidate > best def finalize_sweep_1d( param: str, objective: str, points: list[dict[str, Any]], current_value: Any ) -> dict[str, Any]: """Assemble a 1D sweep payload from per-value points collected across chunks, picking the best via the same direction rule as :func:`run_playground_sweep`.""" best: dict[str, Any] | None = None for p in points: m = p.get("metric") if m is None: continue if best is None or _sweep_is_better(m, best["metric"], objective): best = {"value": p["value"], "metric": m} return { "param": param, "objective": objective, "points": points, "current_value": current_value, "best_value": best["value"] if best else None, "best_metric": best["metric"] if best else None, } def finalize_sweep_2d( param_x: str, param_y: str, objective: str, x_values: list[float], y_values: list[float], grid: list[list[float | None]], current: dict[str, Any], ) -> dict[str, Any]: """Assemble a 2D sweep payload from grid cells collected across chunks.""" best: dict[str, Any] | None = None for j, row in enumerate(grid): for i, v in enumerate(row or []): if v is None: continue if best is None or _sweep_is_better(v, best["metric"], objective): best = {"x": x_values[i], "y": y_values[j], "metric": v} return { "param_x": param_x, "param_y": param_y, "objective": objective, "x_values": x_values, "y_values": y_values, "grid": grid, "best": best, "current": current, } def objective_metric(rows: list[dict[str, Any]], objective: str) -> float | None: """Reduce a set of per-cycle rows to a single objective metric (0-1, or a ratio for median_overrun). Higher is better EXCEPT false_end_rate / median_overrun deviation (the caller/panel knows the direction).""" if not rows: return None detected = [r for r in rows if r["detected"]] labelled = [r for r in detected if r["label"]] if objective == "match_accuracy": if not labelled: return None return sum(1 for r in labelled if r["match_correct"] is True) / len(labelled) if objective == "false_end_rate": if not detected: return None return sum(1 for r in detected if (r["detected_count"] or 0) > 1) / len(detected) if objective == "ambiguity_rate": if not detected: return None return sum(1 for r in detected if "ambiguous" in (r["alerts"] or [])) / len(detected) if objective == "end_timing_accuracy": # Fraction of cycles whose *detected* end lands within 10% of that cycle's own # recorded duration (its true end) - NOT the profile median. Scoring against # the median would reward ending at the typical length even for a cycle that # legitimately ran long or short, so the sweep must compare to stored_duration_s. ok = 0 n = 0 for r in detected: ref = float(r.get("stored_duration_s") or 0.0) dur = float(r.get("duration_s") or 0.0) if ref <= 0 or dur <= 0: continue n += 1 if abs(dur - ref) <= 0.10 * ref: ok += 1 return (ok / n) if n else None if objective == "median_overrun": # Score by the median duration's DEVIATION from the profile's expected # duration (|ratio - 1|), so "best" is the value that makes cycles land # closest to their typical length - not the smallest raw ratio (which # would reward a severe *underrun*, e.g. 0.5x, as if it were ideal). ratios = sorted( float(r["overrun_ratio"]) for r in detected if r.get("overrun_ratio") ) if not ratios: return None mid = len(ratios) // 2 median = ratios[mid] if len(ratios) % 2 else (ratios[mid - 1] + ratios[mid]) / 2.0 return abs(median - 1.0) return None def _coerce_param(base_config: CycleDetectorConfig, param: str, value: float) -> Any: """Coerce a sweep value to the override map's expected type.""" mapping = _OVERRIDE_FIELD_MAP.get(param) if mapping is None: return value _field, coerce = mapping try: return coerce(value) except (TypeError, ValueError): return value def run_playground_sweep( store: Any, cycle_ids: list[str] | None, base_config: CycleDetectorConfig, param: str, values: list[float], objective: str, options: dict[str, Any] | None, price: float | None, concurrency: int, param_y: str | None = None, values_y: list[float] | None = None, ) -> dict[str, Any]: """Sweep one param (1D curve) or two params (2D heatmap) and score each point by ``objective`` computed from the per-cycle rows. Executor-safe; never raises. """ options = options or {} if objective not in _SWEEP_OBJECTIVES: objective = "match_accuracy" try: concurrency = max(1, min(MAX_BATCH_CYCLES, int(concurrency))) except (TypeError, ValueError): concurrency = MAX_BATCH_CYCLES try: past = list(store.get_past_cycles() or []) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Playground sweep: get_past_cycles failed: %s", exc) return {"error": "no cycles"} by_id = {c.get("id"): c for c in past if isinstance(c, dict)} if cycle_ids: selected = [by_id[c] for c in cycle_ids if c in by_id] else: selected = past[-DEFAULT_RECENT_CYCLES:] selected = selected[:concurrency] def _metric_for(override: dict[str, Any]) -> tuple[float | None, dict[str, Any]]: rows = _run_rows(store, selected, base_config, override, options, price) return objective_metric(rows, objective), _rows_summary(rows) current_x = _sim_config_summary(base_config).get( _OVERRIDE_FIELD_MAP.get(param, (param,))[0] ) if param_y and values_y: grid: list[list[float | None]] = [] best: dict[str, Any] | None = None for vy in values_y: row_metrics: list[float | None] = [] for vx in values: override = { param: _coerce_param(base_config, param, vx), param_y: _coerce_param(base_config, param_y, vy), } metric, _ = _metric_for(override) row_metrics.append(round(metric, 4) if metric is not None else None) if metric is not None and ( best is None or _sweep_is_better(metric, best["metric"], objective) ): best = {"x": vx, "y": vy, "metric": round(metric, 4)} grid.append(row_metrics) current_y = _sim_config_summary(base_config).get( _OVERRIDE_FIELD_MAP.get(param_y, (param_y,))[0] ) return { "param_x": param, "param_y": param_y, "objective": objective, "x_values": values, "y_values": values_y, "grid": grid, "best": best, "current": {"x": current_x, "y": current_y}, } points: list[dict[str, Any]] = [] best_1d: dict[str, Any] | None = None for vx in values: override = {param: _coerce_param(base_config, param, vx)} metric, summary = _metric_for(override) points.append( {"value": vx, "metric": round(metric, 4) if metric is not None else None, "summary": summary} ) if metric is not None and ( best_1d is None or _sweep_is_better(metric, best_1d["metric"], objective) ): best_1d = {"value": vx, "metric": round(metric, 4)} return { "param": param, "objective": objective, "points": points, "current_value": current_x, "best_value": best_1d["value"] if best_1d else None, "best_metric": best_1d["metric"] if best_1d else None, } # ─── DTW debug ──────────────────────────────────────────────────────────────── def _safe_float(value: Any) -> float | None: try: if value is None: return None return round(float(value), 2) except (TypeError, ValueError): return None def _profile_trace(store: Any, profile_name: str) -> tuple[list[float], float] | None: """Return (power_values, duration_s) for a profile's average envelope. Prefers the cached envelope ``avg`` curve; falls back to the profile's sample cycle trace. Returns None when nothing usable exists. """ try: env = store.get_envelope(profile_name) except Exception: # pylint: disable=broad-exception-caught env = None if env and env.get("avg"): avg = env["avg"] powers: list[float] = [] times: list[float] = [] for pt in avg: if isinstance(pt, (list, tuple)) and len(pt) >= 2: times.append(float(pt[0])) powers.append(float(pt[1])) else: powers.append(float(pt)) if powers: duration = float(env.get("target_duration") or 0.0) if not duration and len(times) > 1: duration = times[-1] - times[0] return powers, duration # Fallback: the profile's sample cycle. try: data = getattr(store, "_data", {}) or {} profile = (data.get("profiles", {}) or {}).get(profile_name) if not isinstance(profile, dict): return None # past + imported reference cycles: an import-only profile's sample lives in # reference_cycles, so resolve against both (mirrors _build_match_snapshots). pool = (data.get("past_cycles", []) or []) + (data.get("reference_cycles", []) or []) sample = next( ( c for c in pool if isinstance(c, dict) and c.get("id") == profile.get("sample_cycle_id") ), None, ) if not sample: return None pts = decompress_power_data(sample) if not pts: return None duration = float( profile.get("avg_duration") or sample.get("duration") or (pts[-1][0] if pts else 0.0) ) return [p for _, p in pts], duration except Exception: # pylint: disable=broad-exception-caught return None def dtw_debug_payload( store: Any, cycle_id: str, profile_name: str | None ) -> dict[str, Any]: """Score breakdown + resampled traces + DTW warp path for a cycle vs profile. Returns ``{"error": }`` when the cycle or profile is unavailable. Executor-safe; never raises. """ try: cycle = next( (c for c in store.get_past_cycles() if c.get("id") == cycle_id), None ) except Exception as exc: # pylint: disable=broad-exception-caught return {"error": "store_error", "detail": str(exc)} if cycle is None: return {"error": "cycle_not_found"} target_profile = profile_name or _cycle_label(cycle) if not target_profile: return {"error": "no_profile"} cycle_pts = decompress_power_data(cycle) if not cycle_pts or len(cycle_pts) < 2: return {"error": "cycle_no_data", "profile_name": target_profile} prof = _profile_trace(store, target_profile) if prof is None: return {"error": "profile_not_found", "profile_name": target_profile} prof_powers, prof_duration = prof if len(prof_powers) < 2: return {"error": "profile_no_data", "profile_name": target_profile} try: return _compute_dtw_debug( store, cycle, cycle_pts, target_profile, prof_powers, prof_duration ) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Playground dtw_debug failed for %s: %s", cycle_id, exc) return { "error": "compute_error", "detail": str(exc), "profile_name": target_profile, } def _compute_dtw_debug( store: Any, cycle: dict[str, Any], cycle_pts: list[tuple[float, float]], profile_name: str, prof_powers: list[float], prof_duration: float, ) -> dict[str, Any]: cfg = _matching_config(store) corr_weight = float(cfg.get("corr_weight", MATCH_CORR_WEIGHT)) dur_weight = float(cfg.get("duration_weight", MATCH_DURATION_WEIGHT)) en_weight = float(cfg.get("energy_weight", MATCH_ENERGY_WEIGHT)) dur_scale = float(cfg.get("duration_scale", MATCH_DURATION_SCALE)) en_scale = float(cfg.get("energy_scale", MATCH_ENERGY_SCALE)) band = float(cfg.get("dtw_bandwidth", 0.2)) blend = float(cfg.get("dtw_blend", MATCH_DTW_BLEND)) l1_scale = float(cfg.get("dtw_l1_scale", MATCH_DTW_DIST_SCALE)) ddtw_scale = float(cfg.get("dtw_ddtw_scale", MATCH_DDTW_DIST_SCALE)) ensemble_w = float(cfg.get("dtw_ensemble_w", MATCH_DTW_ENSEMBLE_W)) cycle_powers = [p for _, p in cycle_pts] cycle_duration = float(cycle_pts[-1][0] - cycle_pts[0][0]) current_peak = float(max(cycle_powers)) if cycle_powers else 0.0 # --- Stage 2: core similarity on the raw traces (matcher-faithful) --- score, metrics, _offset = analysis.find_best_alignment( cycle_powers, prof_powers, corr_weight=corr_weight ) corr = float(metrics.get("corr", 0.0)) mae = float(metrics.get("mae", 0.0)) scaled_mae = mae * MATCH_MAE_REF_PEAK / max(current_peak, MATCH_MAE_PEAK_FLOOR) mae_score = MATCH_MAE_SCALE / (MATCH_MAE_SCALE + scaled_mae) stage2_score = float(score) # --- DTW components on a common resampled grid --- curr_arr = np.asarray(cycle_powers, dtype=float) sample_arr = np.asarray(prof_powers, dtype=float) l1_score = analysis._dtw_component_score( curr_arr, sample_arr, current_peak, band, False, l1_scale ) ddtw_score = analysis._dtw_component_score( curr_arr, sample_arr, current_peak, band, True, ddtw_scale ) ensemble_score = ensemble_w * l1_score + (1.0 - ensemble_w) * ddtw_score blended_score = blend * stage2_score + (1.0 - blend) * ensemble_score # --- Stage 4: duration + energy agreement over the DTW-blended score --- cur_mean = float(np.mean(curr_arr)) if curr_arr.size else 0.0 prof_mean = float(np.mean(sample_arr)) if sample_arr.size else 0.0 dur_ag = analysis._agreement(cycle_duration, prof_duration, dur_scale) en_ag = analysis._agreement(cur_mean, prof_mean, en_scale) shape_w = 1.0 - dur_weight - en_weight final_score = shape_w * blended_score + dur_weight * dur_ag + en_weight * en_ag # --- Resampled traces on one shared grid (progress fraction 0..1) --- n = MATCH_DTW_RESAMPLE_N a = analysis._resample_to(curr_arr, n) b = analysis._resample_to(sample_arr, n) grid = np.linspace(0.0, 1.0, n) cycle_trace = [[round(float(g), 4), round(float(p), 1)] for g, p in zip(grid, a)] profile_trace = [[round(float(g), 4), round(float(p), 1)] for g, p in zip(grid, b)] # --- DTW warping path on the same resampled arrays --- try: raw_path = analysis.compute_dtw_path(a, b, band_width_ratio=band) warp_path = [[int(i), int(j)] for i, j in raw_path] except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Playground warp path failed: %s", exc) warp_path = [] return { "cycle_id": cycle.get("id"), "profile_name": profile_name, "grid_n": n, "cycle_duration_s": round(cycle_duration, 1), "profile_duration_s": round(float(prof_duration), 1), "cycle_trace": cycle_trace, "profile_trace": profile_trace, "stage2": { "correlation": round(corr, 4), "mae_score": round(float(mae_score), 4), "score": round(stage2_score, 4), }, "dtw": { "l1_score": round(float(l1_score), 4), "ddtw_score": round(float(ddtw_score), 4), "ensemble_score": round(float(ensemble_score), 4), "blend_weight": round(blend, 4), "blended_score": round(float(blended_score), 4), }, "stage4": { "duration_agreement": round(float(dur_ag), 4), "energy_agreement": round(float(en_ag), 4), "final_score": round(float(final_score), 4), }, "warp_path": warp_path, }