224 files
This commit is contained in:
@@ -36,6 +36,7 @@ All top-level entry points are defensive: they never raise, returning an
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Callable
|
||||
@@ -49,6 +50,11 @@ 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,
|
||||
@@ -63,7 +69,6 @@ from .const import (
|
||||
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,
|
||||
@@ -85,6 +90,14 @@ from .const import (
|
||||
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,
|
||||
@@ -112,18 +125,48 @@ MAX_EVENTS_PER_CYCLE = 300
|
||||
# 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_RUNNING_DEAD_ZONE: ("running_dead_zone", int),
|
||||
CONF_START_DURATION_THRESHOLD: ("start_duration_threshold", float),
|
||||
CONF_INTERRUPTED_MIN_SECONDS: ("interrupted_min_seconds", int),
|
||||
}
|
||||
@@ -373,6 +416,8 @@ def simulate_cycle_detail(
|
||||
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.
|
||||
|
||||
@@ -391,7 +436,7 @@ def simulate_cycle_detail(
|
||||
try:
|
||||
return _simulate_cycle_detail_inner(
|
||||
cycle, base_config, settings_override, store, options, price,
|
||||
compute_series, prebuilt,
|
||||
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)
|
||||
@@ -435,6 +480,8 @@ def build_cycle_detail_sim_by_id(
|
||||
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`.
|
||||
|
||||
@@ -456,6 +503,7 @@ def build_cycle_detail_sim_by_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)
|
||||
@@ -471,6 +519,8 @@ def _simulate_cycle_detail_inner(
|
||||
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.
|
||||
|
||||
@@ -483,11 +533,15 @@ def _simulate_cycle_detail_inner(
|
||||
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)
|
||||
sim.run_tail()
|
||||
if sim.stress_tail:
|
||||
sim.run_stress_tail()
|
||||
else:
|
||||
sim.run_tail()
|
||||
return sim.finalize()
|
||||
|
||||
|
||||
@@ -518,6 +572,8 @@ class _DetailSim:
|
||||
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
|
||||
@@ -543,7 +599,11 @@ class _DetailSim:
|
||||
"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:
|
||||
@@ -833,6 +893,34 @@ class _DetailSim:
|
||||
"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:
|
||||
@@ -860,6 +948,117 @@ class _DetailSim:
|
||||
"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
|
||||
@@ -958,6 +1157,53 @@ class _DetailSim:
|
||||
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).
|
||||
@@ -987,6 +1233,11 @@ def _sim_config_summary(config: CycleDetectorConfig) -> dict[str, Any]:
|
||||
"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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user