Files

1833 lines
80 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/>.
"""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``.
Main entry points:
- :func:`simulate_cycle_detail` - faithful single-cycle replay with per-step
progress/remaining-time/phase/energy series and typed event log.
- :func:`run_playground_history` - per-cycle rows + optional before/after diff.
- :func:`run_playground_sweep` - objective 1D/2D grid sweep.
- :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).
All 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
import math
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 .phase_segmenter import phase_matching_enabled
from .const import (
CONF_ANTI_WRINKLE_ENABLED,
CONF_ANTI_WRINKLE_EXIT_POWER,
CONF_ANTI_WRINKLE_IDLE_TIMEOUT,
CONF_ANTI_WRINKLE_MAX_DURATION,
CONF_ANTI_WRINKLE_MAX_POWER,
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_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,
PLAYGROUND_STRESS_DENSE_DURATION_S,
PLAYGROUND_STRESS_DENSE_STEP_S,
PLAYGROUND_STRESS_FLOOR_PERCENTILE,
PLAYGROUND_STRESS_FLUCT_FALLBACK_FRAC,
PLAYGROUND_STRESS_MAX_IDLE_W,
PLAYGROUND_STRESS_MAX_SPARSE_STEPS,
PLAYGROUND_STRESS_SPARSE_STEP_S,
PLAYGROUND_STRESS_TRAILING_WINDOW_S,
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
# Cap the per-cycle timeline series so a very long cycle (4h dishwasher = ~2800 pts)
# does not bloat the task result. Points are thinned at finalize time — evenly-spaced,
# so the shape is preserved rather than truncated.
MAX_SERIES_PER_CYCLE = 600
def _coerce_bool(value: Any) -> bool:
"""Strict bool coercion for override values.
Plain ``bool()`` would read the string ``"false"`` as True, so a toggle sent
as a string could switch a mode *on* when the user asked for it off. Unknown
values raise, which ``build_sim_config`` turns into "ignore this override".
"""
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
# Only the two values that actually mean a toggle. Anything else (2, -1,
# NaN, inf) is a malformed override, not an intent to switch a mode on.
if value == 0:
return False
if value == 1:
return True
raise ValueError(f"not a boolean: {value!r}")
if isinstance(value, str):
low = value.strip().lower()
if low in ("true", "1", "yes", "on"):
return True
if low in ("false", "0", "no", "off"):
return False
raise ValueError(f"not a boolean: {value!r}")
# 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_ANTI_WRINKLE_ENABLED: ("anti_wrinkle_enabled", _coerce_bool),
CONF_ANTI_WRINKLE_MAX_POWER: ("anti_wrinkle_max_power", float),
CONF_ANTI_WRINKLE_MAX_DURATION: ("anti_wrinkle_max_duration", float),
CONF_ANTI_WRINKLE_EXIT_POWER: ("anti_wrinkle_exit_power", float),
CONF_ANTI_WRINKLE_IDLE_TIMEOUT: ("anti_wrinkle_idle_timeout", 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_START_DURATION_THRESHOLD: ("start_duration_threshold", float),
CONF_INTERRUPTED_MIN_SECONDS: ("interrupted_min_seconds", int),
}
# Matching options the Playground honours, mapped to the ``match_config`` key
# they drive. The two duration ratios are real user settings (a good value found
# here can be applied for real). The remaining keys are the Stage 2-4 scoring
# weights / DTW knobs: they are NOT persistent settings (they are ML-tuned
# defaults), but they ARE exposed here as SANDBOX-ONLY overrides so power users
# can experiment with how each stage of the matcher scores their own cycles in
# the Playground. They never persist - a match config built from them lives only
# for the simulation. Anything else in ``settings_override`` is ignored.
_MATCH_OVERRIDE_KEYS: dict[str, tuple[str, Callable[[Any], Any]]] = {
# Stage 1 - duration gate (real settings)
CONF_PROFILE_MATCH_MIN_DURATION_RATIO: ("min_duration_ratio", float),
CONF_PROFILE_MATCH_MAX_DURATION_RATIO: ("max_duration_ratio", float),
# Stage 2 - core similarity (sandbox-only)
"corr_weight": ("corr_weight", float),
"keep_min_score": ("keep_min_score", float),
# Stage 3 - DTW refinement (sandbox-only)
"dtw_bandwidth": ("dtw_bandwidth", float),
"dtw_blend": ("dtw_blend", float),
"dtw_ensemble_w": ("dtw_ensemble_w", float),
"dtw_ddtw_scale": ("dtw_ddtw_scale", float),
"dtw_refine_top_n": ("dtw_refine_top_n", int),
# Stage 4 - duration/energy agreement (sandbox-only)
"duration_weight": ("duration_weight", float),
"energy_weight": ("energy_weight", float),
"duration_scale": ("duration_scale", float),
"energy_scale": ("energy_scale", 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 the store's async matching path: 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 the faithful history runner.
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
# ─── 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 = 30.0 # cap estimator calls; 5s matched cadence made this a no-op
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,
stress_tail: bool = False,
stress_idle_w: float | 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, stress_tail, stress_idle_w,
)
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 build_cycle_detail_sim_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,
stress_tail: bool = False,
stress_idle_w: float | None = None,
) -> "_DetailSim | dict[str, Any]":
"""Look up a stored cycle by id and build a resumable :class:`_DetailSim`.
Used by the chunked background-task driver in ``ws_api`` so the heavy replay
can be stepped across many small executor jobs (issue #311). Returns a
``{"error": ...}`` marker (not a sim) when the id is unknown or setup fails,
so the caller can surface it. The store lookup + build run together so the WS
handler can offload the whole thing to an executor thread. Never raises."""
options = options or {}
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}
try:
return _DetailSim(
cycle, base_config, settings_override, store, options, price,
stress_tail=stress_tail, stress_idle_w=stress_idle_w,
)
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.debug("Playground detail sim build failed for %s: %s", cycle_id, exc)
return {"error": str(exc), "cycle_id": cycle_id}
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,
stress_tail: bool = False,
stress_idle_w: float | None = None,
) -> dict[str, Any]:
"""One-shot faithful replay: build the resumable sim and run it to completion.
The chunked (background-task) driver in ``ws_api`` builds the same
:class:`_DetailSim` and calls ``step``/``run_tail``/``finalize`` across many
small executor jobs so the event loop breathes on very long cycles (issue
#311). Because both paths drive the identical object in the identical order,
the timeline is byte-for-byte the same (golden test in test_playground_detail).
"""
sim = _DetailSim(
cycle, base_config, settings_override, store, options, price,
compute_series, prebuilt,
stress_tail=stress_tail, stress_idle_w=stress_idle_w,
)
if not sim.ready:
return sim.empty_payload()
sim.step(0, sim.n_readings)
if sim.stress_tail:
sim.run_stress_tail()
else:
sim.run_tail()
return sim.finalize()
class _DetailSim:
"""Resumable single-cycle Playground "Simulate" replay.
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. Read-only: nothing is persisted and no
notifications are sent.
The replay is split into :meth:`step` (a slice of the real readings),
:meth:`run_tail` (the synthetic quiet tail + flush) and :meth:`finalize`
(outcome + alerts) so a long cycle can be replayed chunk-by-chunk across
executor jobs without holding the GIL for the whole run.
"""
def __init__(
self,
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,
stress_tail: bool = False,
stress_idle_w: float | None = None,
) -> None:
self.cycle = cycle
self.store = store
self.options = options or {}
self.price = price
self.compute_series = compute_series
self.config = build_sim_config(base_config, settings_override)
self.device_type = _device_type_of(self.config)
self.label = _cycle_label(cycle)
self.readings, _points, self.base = _readings_from_cycle(cycle)
self.stored_duration = _safe_float(cycle.get("duration"))
self.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,
"stress": None,
}
self.stress_tail = stress_tail
self.stress_idle_override = stress_idle_w
self._stress_outcome: dict[str, Any] | None = 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 class, a swept matching value flows in via settings_override too;
# applying to a copy keeps the shared prebuilt match_config untouched.
self.snapshots = snapshots
self.match_config = apply_match_overrides(match_config, settings_override)
self.group_members = group_members
self.member_snaps = member_snaps
self.ready = len(self.readings) >= 5
# Per-sim end-expectation cache, threaded through the shared progress helpers
# exactly like the manager threads self._ml_end_expectation_cache.
self.endexp_cache: list[Any] = [None]
self.events: list[dict[str, Any]] = []
self.series: list[dict[str, Any]] = []
self.captured: list[dict[str, Any]] = []
self.cursor = {"t": 0.0}
self.last_match: dict[str, Any] = {
"name": None, "conf": 0.0, "ambiguous": False, "expected": 0.0,
}
self.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 churn.
self.match_persistence = max(1, int(
self.options.get(CONF_MATCH_PERSISTENCE, DEFAULT_MATCH_PERSISTENCE)
))
self.commit_state: dict[str, Any] = {"candidate": None, "count": 0, "name": None}
self.smoothed = {"v": 0.0}
self.flags = {"detected": False, "pre_complete": False, "start": False}
# --- notification config (decisions reuse notification_rules) ---
self.start_configured = bool(
self.options.get(CONF_NOTIFY_START_SERVICES) or self.options.get(CONF_NOTIFY_ACTIONS)
)
self.finish_configured = bool(
self.options.get(CONF_NOTIFY_FINISH_SERVICES) or self.options.get(CONF_NOTIFY_ACTIONS)
)
self.before_end = float(
self.options.get(CONF_NOTIFY_BEFORE_END_MINUTES, DEFAULT_NOTIFY_BEFORE_END_MINUTES)
or 0.0
)
self.quiet_bounds = notif_rules.quiet_hours_bounds(self.options)
self.last_sample_t = -1e9
self._aborted = False
if self.ready:
self.detector = CycleDetector(
self.config, self._on_state_change, self._on_cycle_end,
profile_matcher=self._matcher, device_name="playground-detail",
)
@property
def n_readings(self) -> int:
return len(self.readings)
def empty_payload(self) -> dict[str, Any]:
return {
"cycle_id": self.cycle.get("id"),
"label": self.label,
"duration_s": self.stored_duration,
"config_summary": _sim_config_summary(self.config),
"series": [],
"events": [],
"alerts": [],
"outcome": self.outcome,
}
def _end_exp_fn(self, name: str, dur: float) -> Any:
exp, self.endexp_cache[0] = progress_mod.profile_end_expectation(
self.store, name, dur, self.endexp_cache[0]
)
return exp
def _emit(self, etype: str, detail: str, severity: str = "info") -> None:
if len(self.events) < MAX_EVENTS_PER_CYCLE:
self.events.append(
{"t": round(self.cursor["t"], 1), "type": etype, "detail": detail,
"severity": severity}
)
def _held(self, offset: float) -> bool:
return notif_rules.in_quiet_hours(
self.quiet_bounds, self.base + timedelta(seconds=offset)
)
def _on_state_change(self, old_state: str, new_state: str) -> None:
self._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 self.flags.get("pending_reset"):
self.flags["pending_reset"] = False
self.commit_state.update(candidate=None, count=0, name=None)
self.last_match.update(name=None, conf=0.0, expected=0.0, ambiguous=False)
self.last_logged.update(kind=None, name=None)
if (
not self.flags["detected"]
and new_state == STATE_RUNNING
and old_state in (STATE_OFF, STATE_UNKNOWN, STATE_STARTING, STATE_IDLE)
):
self.flags["detected"] = True
self._emit("detected", "cycle detected (running)")
if self.start_configured and not self.flags["start"]:
self.flags["start"] = True
# Start notifications are never delayed by quiet hours (live
# contract), so the sim always emits them immediately.
self._emit("notify_start", "start notification")
def _on_cycle_end(self, cycle_data: dict[str, Any]) -> None:
self.captured.append(cycle_data)
reason = cycle_data.get("termination_reason")
self._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).
self.flags["pending_reset"] = True
def _matcher(self, det_readings: list[tuple[datetime, float]]):
if len(det_readings) < 5 or not self.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, self.snapshots, self.match_config
)
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.debug("Playground detail match failed: %s", exc)
candidates = []
if not candidates:
if self.last_logged["kind"] != "unmatched":
self._emit("unmatched", "no candidate")
self.last_logged["kind"] = "unmatched"
# Hold any committed match on a transient miss (as the manager does).
self.last_match.update(ambiguous=False)
return (None, 0.0, 0.0, None, False, False)
if self.group_members and candidates[0].get("name", "").startswith("__group__"):
gkey = candidates[0]["name"]
members = self.group_members.get(gkey, [])
if members and self.store is not None:
try:
member_name, _, _ = self.store._stage5_pick_member( # noqa: SLF001
list(powers), duration, members, self.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, self.commit_state, self.match_persistence
)
if commit_event:
self._emit(commit_event, f"{raw_name} (conf={raw_conf:.2f})")
self.last_logged.update(kind="matched", name=raw_name)
elif is_ambiguous and not self.commit_state["name"]:
# Ambiguous before any commit: stay 'detecting', surface it once.
if self.last_logged["kind"] != "ambiguous" or self.last_logged["name"] != raw_name:
runner = candidates[1].get("name") if len(candidates) > 1 else None
self._emit("match_ambiguous", f"{raw_name} vs {runner} (margin={margin:.3f})", "warn")
self.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 = self.commit_state["name"]
if cname:
cc = next((c for c in candidates if c.get("name") == cname), None)
self.last_match.update(
name=cname,
conf=float(cc.get("score") or 0.0) if cc else (self.last_match.get("conf") or 0.0),
expected=float(cc.get("profile_duration") or 0.0) if cc else (self.last_match.get("expected") or 0.0),
ambiguous=False,
)
else:
self.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))
def _sample(self, ts: datetime) -> None:
if not self.compute_series:
return # batch/sweep rows only need the outcome, not the per-step series
offset = (ts - self.base).total_seconds()
if offset - self.last_sample_t < _SIM_SERIES_THROTTLE_S:
return
self.last_sample_t = offset
state = self.detector.state
power = 0.0
trace = self.detector.get_power_trace()
if trace:
power = float(trace[-1][1])
energy_wh = float(getattr(self.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(self.last_match["conf"], 3) if self.last_match["name"] else None,
"matched_profile": self.last_match["name"],
}
matched_dur = float(self.last_match["expected"] or 0.0)
program = self.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(
self.store, trace, offset, program
)
ml_pct = progress_mod.ml_progress_percent(
self.store, self.options, matched_dur, trace, program, self._end_exp_fn
)
# Opt-in phase-resolved ETA blend - identical gating + call as the live
# manager, so the Playground stays a faithful mirror of the estimator.
phase_remaining_s = None
if (
self.store is not None
and len(trace) >= 10
and program not in ("detecting...", "off", None)
and phase_matching_enabled(self.options, self.device_type)
):
pr = self.store.phase_remaining(trace, self.device_type, program)
if pr is not None:
phase_remaining_s = pr.get("remaining_s")
result = progress_mod.compute_progress(
self.device_type, matched_dur, offset, self.smoothed["v"], phase_result, ml_pct,
phase_remaining_s=phase_remaining_s,
)
if result is not None:
self.smoothed["v"] = result.smoothed
pt["progress"] = round(result.progress, 1)
pt["remaining_s"] = round(result.remaining, 0)
pt["phase"] = progress_mod.current_phase(
self.store, state, program, result.progress
)
wh, cost = progress_mod.projected_energy(
self.store, self.options, matched_dur, trace, program, result.progress,
energy_wh, self.price, self._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 self.flags["pre_complete"] and notif_rules.should_notify_pre_completion(
self.before_end, self.flags["pre_complete"], result.remaining,
result.progress, self.last_match["ambiguous"],
):
self.flags["pre_complete"] = True
held = self._held(offset)
self._emit(
"notify_held" if held else "notify_pre_complete",
"pre-completion notification"
+ (" (held: quiet hours)" if held else ""),
)
self.series.append(pt)
def step(self, i0: int, i1: int) -> None:
"""Replay readings[i0:i1] through the detector (a chunk of the cycle)."""
if self._aborted or not self.ready:
return
try:
for ts, power in self.readings[i0:i1]:
self.cursor["t"] = (ts - self.base).total_seconds()
self.detector.process_reading(power, ts)
self._sample(ts)
except Exception as exc: # pylint: disable=broad-exception-caught
self._aborted = True
_LOGGER.debug(
"Playground detail replay failed for %s: %s", self.cycle.get("id"), exc
)
def _derive_idle_level(self) -> tuple[float, float]:
"""Derive (idle_w, fluct_w) from the standby floor of the real cycle tail.
idle_w = p7 of readings in the last PLAYGROUND_STRESS_TRAILING_WINDOW_S;
this is the between-burst standby floor, not a contaminated mean.
fluct_w = std-dev of the low band (readings ≤ idle_w × 1.5); falls back
to ±PLAYGROUND_STRESS_FLUCT_FALLBACK_FRAC when the tail is flat.
"""
if not self.readings:
return 3.0, 0.36
cutoff = self.readings[-1][0] - timedelta(seconds=PLAYGROUND_STRESS_TRAILING_WINDOW_S)
window = [p for ts, p in self.readings if ts >= cutoff]
if not window:
window = [self.readings[-1][1]]
sorted_w = sorted(window)
p7_idx = max(0, int(len(sorted_w) * PLAYGROUND_STRESS_FLOOR_PERCENTILE))
idle_w = float(sorted_w[p7_idx])
low_band = [p for p in window if p <= idle_w * 1.5]
if len(low_band) >= 2:
mean_lb = sum(low_band) / len(low_band)
variance = sum((p - mean_lb) ** 2 for p in low_band) / len(low_band)
fluct_w = variance ** 0.5
if fluct_w < 0.01:
fluct_w = idle_w * PLAYGROUND_STRESS_FLUCT_FALLBACK_FRAC
else:
fluct_w = idle_w * PLAYGROUND_STRESS_FLUCT_FALLBACK_FRAC
return max(0.0, idle_w), max(0.01, fluct_w)
def run_tail(self) -> None:
"""Synthetic quiet tail so a natural end can fire."""
if self._aborted or not self.ready:
return
try:
last_ts = self.readings[-1][0]
tail_span = max(
float(self.config.off_delay or 0.0), float(self.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)
self.cursor["t"] = (ts - self.base).total_seconds()
self.detector.process_reading(0.0, ts)
self._sample(ts)
if self.detector.state in (STATE_OFF, STATE_FINISHED) and self.captured:
break
if not self.captured and self.detector.state != STATE_OFF:
flush_ts = last_ts + timedelta(seconds=step * (n_steps + 2))
self.cursor["t"] = (flush_ts - self.base).total_seconds()
self.detector.force_end(flush_ts)
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.debug(
"Playground detail replay failed for %s: %s", self.cycle.get("id"), exc
)
def run_stress_tail(self) -> None:
"""Synthetic idle continuation for the idle termination test.
Replaces the quiet tail when ``stress_tail=True``. Never raises.
Two phases keep CPU near zero while preserving detector fidelity:
* **Dense pre-fill** (DENSE_DURATION_S at DENSE_STEP_S): populates
``_power_readings`` so ``_is_standby_band_stuck`` (10-min plateau check)
and ``_window_has_outage_gap`` can fire correctly.
* **Sparse main** (SPARSE_STEP_S steps): ``_time_below_threshold += dt``
accumulates correctly for large dt; the 8h hard cap fires normally.
``_sample()`` is called on every step so the series carries progress /
remaining_s / phase through the overrun, identical to the live estimator.
"""
if self._aborted or not self.ready:
return
try:
idle_w, fluct_w = self._derive_idle_level()
override_applied = False
if self.stress_idle_override is not None:
# The schema accepts any float. Reject non-finite (nan/inf) by keeping the
# auto-derived floor, and clamp to [0, MAX] so a negative can't surface as a
# nonsensical "-Xw" draw and a huge/inf value can't corrupt the synthetic
# power samples (NaN/inf detector math). fluct_w stays from the call above.
ov = float(self.stress_idle_override)
if math.isfinite(ov):
idle_w = max(0.0, min(ov, PLAYGROUND_STRESS_MAX_IDLE_W))
override_applied = True
synthetic_from_s = self.cursor["t"]
stop_thresh = float(getattr(self.config, "stop_threshold_w", 2.0))
idle_above = idle_w >= stop_thresh
seed = sum(ord(c) for c in (self.cycle.get("id") or "pg")) & 0xFFFF
self._emit(
"stress_tail_start",
f"idle ~{idle_w:.1f}W fluct={fluct_w:.2f}W above_stop_thresh={idle_above}",
)
last_ts = self.readings[-1][0]
captured_before = len(self.captured)
n_dense = int(PLAYGROUND_STRESS_DENSE_DURATION_S / PLAYGROUND_STRESS_DENSE_STEP_S)
dense_end_ts = last_ts
for i in range(1, n_dense + 1):
ts = last_ts + timedelta(seconds=PLAYGROUND_STRESS_DENSE_STEP_S * i)
power = max(0.0, idle_w + fluct_w * math.sin(seed + i * 1.1))
self.cursor["t"] = (ts - self.base).total_seconds()
self.detector.process_reading(power, ts)
self._sample(ts)
dense_end_ts = ts
if len(self.captured) > captured_before:
break
if len(self.captured) == captured_before:
for i in range(1, PLAYGROUND_STRESS_MAX_SPARSE_STEPS + 1):
ts = dense_end_ts + timedelta(seconds=PLAYGROUND_STRESS_SPARSE_STEP_S * i)
power = max(0.0, idle_w + fluct_w * math.sin(seed + n_dense + i * 1.1))
self.cursor["t"] = (ts - self.base).total_seconds()
self.detector.process_reading(power, ts)
self._sample(ts)
if len(self.captured) > captured_before:
break
if len(self.captured) == captured_before:
flush_ts = dense_end_ts + timedelta(
seconds=PLAYGROUND_STRESS_SPARSE_STEP_S * (PLAYGROUND_STRESS_MAX_SPARSE_STEPS + 2)
)
self.cursor["t"] = (flush_ts - self.base).total_seconds()
self.detector.force_end(flush_ts)
# Record a series sample at the forced-stop timestamp so the plotted tail
# reaches the reported elapsed time (otherwise the last point sits up to two
# sparse steps short of terminated_after_s).
self._sample(flush_ts)
terminated = len(self.captured) > captured_before
terminated_after_s: float | None = None
term_reason: Any = None
hit_cap = False
if terminated:
primary = max(
self.captured[captured_before:],
key=lambda c: float(c.get("duration") or 0.0),
)
term_reason = primary.get("termination_reason")
hit_cap = str(term_reason) == str(TerminationReason.FORCE_STOPPED)
terminated_after_s = self.cursor["t"] - synthetic_from_s
self._stress_outcome = {
"enabled": True,
"idle_w": round(idle_w, 2),
"fluct_w": round(fluct_w, 3),
"manual_override": override_applied,
"synthetic_from_s": round(synthetic_from_s, 1),
"stop_threshold_w": round(stop_thresh, 2),
"idle_above_threshold": idle_above,
"terminated": terminated,
"terminated_after_s": (
round(terminated_after_s, 1) if terminated_after_s is not None else None
),
"termination_reason": str(term_reason) if term_reason is not None else None,
"hit_cap": hit_cap,
}
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.debug(
"Playground stress tail failed for %s: %s", self.cycle.get("id"), exc
)
def finalize(self) -> dict[str, Any]:
outcome = self.outcome
last_match = self.last_match
# --- outcome ---
if self.captured:
primary = max(self.captured, key=lambda c: float(c.get("duration") or 0.0))
outcome["detected"] = True
outcome["detected_count"] = len(self.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 self.label:
outcome["match_correct"] = last_match["name"].strip() == self.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(self.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 self.captured and self.finish_configured:
held = self._held(self.cursor["t"])
self._emit(
"notify_held" if held else "notify_finish",
"finish notification" + (" (held: quiet hours)" if held else ""),
)
try:
prev_life = int(self.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,
self.options.get(CONF_NOTIFY_MILESTONES, DEFAULT_NOTIFY_MILESTONES),
)
if crossed is not None:
# Milestone notifications are held during quiet hours (live contract).
m_held = self._held(self.cursor["t"])
self._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(self.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."})
# --- stress-tail verdict ---
st = self._stress_outcome
if st and st.get("enabled"):
outcome["stress"] = st
idle_w = st["idle_w"]
stop_thresh = st["stop_threshold_w"]
if st.get("terminated") and not st.get("hit_cap"):
after_s = float(st.get("terminated_after_s") or 0.0)
h, rem = divmod(int(after_s), 3600)
m = rem // 60
reason = st.get("termination_reason") or "?"
alerts.append({
"code": "stress_terminated", "severity": "info",
"detail_key": "msg.pg_stress_terminated_detail",
"detail_params": {"idle": f"{idle_w:.1f}", "h": h, "m": m, "reason": reason},
"detail": (
f"Settled to ~{idle_w:.1f}W idle -- cycle ended "
f"{h}h {m}m later via {reason}."
),
})
elif st.get("hit_cap"):
if st.get("idle_above_threshold"):
alerts.append({
"code": "stress_above_threshold", "severity": "warn",
"detail_key": "msg.pg_stress_above_threshold_detail",
"detail_params": {"idle": f"{idle_w:.1f}", "stop": f"{stop_thresh:.1f}"},
"detail": (
f"Idle draw ~{idle_w:.1f}W is at or above the effective stop "
f"threshold ({stop_thresh:.1f}W) -- the cycle never registered "
f"as quiet. Raise stop_threshold_w to fix."
),
})
# Report the actual elapsed synthetic time (the cap is ~7h50m of synthetic
# tail plus the pre-tail cycle length, not a flat 8 h).
after_s = float(st.get("terminated_after_s") or 0.0)
h, rem = divmod(int(after_s), 3600)
m = rem // 60
alerts.append({
"code": "stress_hit_cap", "severity": "error",
"detail_key": "msg.pg_stress_hit_cap_detail",
"detail_params": {"h": h, "m": m, "idle": f"{idle_w:.1f}", "stop": f"{stop_thresh:.1f}"},
"detail": (
f"Cycle ran {h}h {m}m without stopping -- force-stopped by the safety cap. "
f"Idle draw {idle_w:.1f}W vs stop threshold {stop_thresh:.1f}W."
),
})
series = self.series
if len(series) > MAX_SERIES_PER_CYCLE:
# Thin evenly so the shape is preserved (first + last always kept).
# Span (len-1)/(N-1) so the final index lands on the true last point
# (a plain len/N tops out below it and drops the terminal sample).
step = (len(series) - 1) / (MAX_SERIES_PER_CYCLE - 1)
series = [series[round(i * step)] for i in range(MAX_SERIES_PER_CYCLE)]
return {
"cycle_id": self.cycle.get("id"),
"label": self.label,
"duration_s": self.stored_duration,
"config_summary": _sim_config_summary(self.config),
"series": series,
"events": self.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),
"anti_wrinkle_enabled": getattr(config, "anti_wrinkle_enabled", None),
"anti_wrinkle_max_power": getattr(config, "anti_wrinkle_max_power", None),
"anti_wrinkle_max_duration": getattr(config, "anti_wrinkle_max_duration", None),
"anti_wrinkle_exit_power": getattr(config, "anti_wrinkle_exit_power", None),
"anti_wrinkle_idle_timeout": getattr(config, "anti_wrinkle_idle_timeout", 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,
prebuilt: tuple[Any, Any, Any, Any] | None = 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.
# Callers that drive many chunks should pass prebuilt= to avoid rebuilding per chunk.
if prebuilt is None:
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,
prebuilt: tuple[Any, Any, Any, Any] | None = None,
) -> 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, prebuilt)
payload: dict[str, Any] = {"rows": rows, "summary": _rows_summary(rows)}
if override:
base_rows = _run_rows(store, selected, base_config, None, options, price, prebuilt)
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,
"lower_is_better": objective in _SWEEP_LOWER_IS_BETTER,
}
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,
"lower_is_better": objective in _SWEEP_LOWER_IS_BETTER,
}
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,
prebuilt: tuple[Any, Any, Any, Any] | 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, prebuilt)
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": <code>}`` 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,
}