348 files

This commit is contained in:
Home Assistant Version Control
2026-07-23 19:02:12 +00:00
parent 0ed1687503
commit 016af2e013
348 changed files with 12368 additions and 11689 deletions
+517 -9
View File
@@ -18,6 +18,7 @@
from __future__ import annotations
import itertools
import logging
import math
from dataclasses import dataclass
@@ -51,9 +52,21 @@ from .const import (
DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS,
DISHWASHER_END_SPIKE_WAIT_SECONDS,
DISHWASHER_SMART_TERMINATION_DEBOUNCE_SECONDS,
WASHER_SMART_TERMINATION_DEBOUNCE_MAX_SECONDS,
STARTING_PAUSED_TRUE_OFF_TIMEOUT_SECONDS,
DISHWASHER_MATCH_FREEZE_QUIET_SECONDS,
DISHWASHER_MIN_CYCLE_DURATION_S,
TERMINAL_DROP_OFF_DELAY_SECONDS,
ENDING_HARD_FINALIZE_RATIO,
ENDING_HARD_FINALIZE_MIN_QUIET_S,
STANDBY_BAND_FINALIZE_DEVICE_TYPES,
STANDBY_BAND_MIN_RATIO,
STANDBY_BAND_WINDOW_S,
STANDBY_BAND_MAX_FRACTION,
STANDBY_BAND_FLATNESS_FRACTION,
STANDBY_BAND_FLATNESS_FLOOR_W,
ANTI_CREASE_FINALIZE_RATIO,
ANTI_CREASE_CONFIRM_WINDOW_S,
)
# The dishwasher end-spike wait window is shared between two code paths
@@ -312,6 +325,11 @@ class CycleDetector:
# OFF only when the machine has clearly been switched off rather
# than briefly dipped.
self._delay_wait_true_off_seconds: float = 0.0
# _starting_paused_off_since anchors the first below-stop reading while
# a user-paused STARTING state is held (issue #306). Elapsed time is
# measured from this anchor, not accumulated per-dt, so a single large
# (but sub-outage) interval cannot prematurely credit minutes of quiet time.
self._starting_paused_off_since: datetime | None = None
# _delay_wait_high_start anchors the first high-power reading
# observed inside DELAY_WAIT. We only transition to STARTING
# when the high-power streak has lasted at least
@@ -383,6 +401,18 @@ class CycleDetector:
):
return
# Terminal-tail match freeze (anti-crease, #296): once a washer/dryer with
# anti-wrinkle enabled is past its expected duration and has settled into
# the low-power tumble tail, re-matching on the growing flat tail drifts the
# label toward a LONGER near-duplicate (its expected duration grows), which
# pushes the anti-crease finalize gate out and breaks Smart Termination -
# the field failure that merges back-to-back washes. Keep the good
# pre-tail match instead. Self-correcting: a new wash's heating burst above
# anti_wrinkle_max_power leaves the tail regime, so this stops applying and
# matching re-arms for the next cycle.
if self._matched_profile and self._in_anticrease_freeze(timestamp):
return
# Rate limiting
if not force and self._last_match_time:
elapsed = (timestamp - self._last_match_time).total_seconds()
@@ -459,6 +489,23 @@ class CycleDetector:
Can be called by the matcher callback directly or asynchronously.
"""
# Terminal-tail match freeze (anti-crease, #296). This is the single sink
# for ALL match updates - the detector's own _try_profile_match AND the
# manager's async 5-min matcher (manager.py calls update_match directly).
# Once a washer/dryer with anti-wrinkle enabled is past its expected
# duration and has settled into the low-power tumble tail, re-matching on
# the growing flat tail drifts the label toward a LONGER near-duplicate (or
# flips it ambiguous), which pushes out expected_duration and would block
# the anti-crease finalize - the field failure that merges back-to-back
# washes. Keep the good pre-tail match instead. Self-correcting: a new
# wash's heating burst above anti_wrinkle_max_power leaves the tail regime,
# so this stops applying and matching re-arms for the next cycle.
if (
self._matched_profile
and self._power_readings
and self._in_anticrease_freeze(self._power_readings[-1][0])
):
return
# Unpack 5 elements (or 4 for backward compatibility if needed, but wrapper is updated)
# wrapper returns (name, confidence, duration, phase, is_mismatch)
# Or MatchResult object if refactored, but currently wrapper returns tuple.
@@ -582,6 +629,13 @@ class CycleDetector:
self._time_below_threshold = 0.0
self._last_match_time = None
self._matched_profile = None
# Clear stale match state so the next cycle starts with clean defaults.
# _expected_duration left at 0 tells the dishwasher end-spike gate that
# no profile is matched yet; stale non-zero would mis-gate the spike check.
self._expected_duration = 0.0
self._last_match_confidence = 0.0
self._match_ambiguous = False
self._match_prefix_ambiguous = False
self._ignore_power_until_idle = False # Reset lockout
self._lockout_high_seconds = 0.0
# Clear the verified-pause flag so it can't leak into the next cycle (B6):
@@ -597,6 +651,7 @@ class CycleDetector:
self._delay_band_seconds = 0.0
self._delay_band_peak = 0.0
self._delay_wait_true_off_seconds = 0.0
self._starting_paused_off_since = None
self._delay_wait_high_start = None
@property
@@ -972,6 +1027,10 @@ class CycleDetector:
self._power_readings.append((timestamp, power))
self._cycle_max_power = max(self._cycle_max_power, power)
if is_high:
# Power back up - clear any accumulated "true off" hold time.
self._starting_paused_off_since = None
if self._time_above_threshold >= self._config.start_duration_threshold:
if self._energy_since_idle_wh >= self._config.start_energy_threshold:
self._transition_to(STATE_RUNNING, timestamp)
@@ -982,7 +1041,41 @@ class CycleDetector:
# that the low power is intentional, not a false start.
if not is_high and self._time_below_threshold > 1.0: # 1s grace period
if getattr(self, "_verified_pause", False):
pass # user pause holds; wait for Resume Cycle
# User pause holds; wait for Resume Cycle (issue #306). But a
# genuinely paused appliance keeps standby power above the stop
# threshold - sustained power *below* it means the machine was
# switched off, so fall back to OFF rather than pinning STARTING
# forever.
if power < self._config.stop_threshold_w:
# An outage-sized gap since the last reading is NOT observed
# quiet (the machine may still be paused, we just lost
# telemetry): reset anchor so only genuinely-sampled
# sustained-off time can cancel a paused STARTING.
if dt > self._outage_threshold_s():
self._starting_paused_off_since = None
elif self._starting_paused_off_since is None:
# First below-stop reading: anchor the timestamp.
# Don't credit the preceding dt interval — we only
# know the device is off *now*, not how long before
# this sample it went quiet.
self._starting_paused_off_since = timestamp
observed_off_s = (
(timestamp - self._starting_paused_off_since).total_seconds()
if self._starting_paused_off_since is not None
else 0.0
)
if observed_off_s >= STARTING_PAUSED_TRUE_OFF_TIMEOUT_SECONDS:
self._logger.info(
"Paused STARTING cancelled: power off (%.1fW) for "
"%.0fs → OFF",
power,
observed_off_s,
)
self._transition_to(STATE_OFF, timestamp)
return
else:
# Power recovered — clear the off anchor.
self._starting_paused_off_since = None
else:
# False start
self._logger.debug(
@@ -999,6 +1092,13 @@ class CycleDetector:
self._power_readings.append((timestamp, power))
self._cycle_max_power = max(self._cycle_max_power, power)
# Anti-crease finalize (#296): a matched cycle past its expected
# duration that has settled into the low-power tumble tail is done -
# finalize into anti-wrinkle now instead of letting the periodic
# bursts keep reviving RUNNING until a second wash merges in.
if self._maybe_finalize_anticrease_tail(timestamp):
return
# Use dynamic threshold
thresh = self._dynamic_pause_threshold
if self._time_below_threshold >= thresh:
@@ -1008,16 +1108,71 @@ class CycleDetector:
# Periodic profile matching
self._try_profile_match(timestamp)
# Standby-band stuck finalize (#296): an appliance holding a flat
# low standby draw ABOVE stop_threshold never accumulates
# _time_below_threshold, so it never reaches PAUSED/ENDING. Detect
# the plateau and finalize as a normal completion (so anti-wrinkle
# still engages). Cheaply gated on being well past expected before
# the window scan runs.
if self._is_standby_band_stuck(timestamp):
start_time = self._current_cycle_start or timestamp
current_duration = (timestamp - start_time).total_seconds()
# The plateau sits ABOVE stop_threshold, so it keeps advancing
# _last_active_time and the default keep_tail=False trim would NOT
# remove it - inflating the stored duration/energy with minutes of
# standby. Snap the end back to the last real activity (the last
# reading above the plateau ceiling) and drop the trailing plateau.
level_ceiling = float(self._cycle_max_power) * STANDBY_BAND_MAX_FRACTION
plateau_start_idx = None
for i in range(len(self._power_readings) - 1, -1, -1):
if float(self._power_readings[i][1]) > level_ceiling:
plateau_start_idx = i
break
if (
plateau_start_idx is not None
and plateau_start_idx < len(self._power_readings) - 1
):
self._power_readings = self._power_readings[: plateau_start_idx + 1]
self._last_active_time = self._power_readings[-1][0]
current_duration = (
self._last_active_time - start_time
).total_seconds()
self._logger.info(
"Standby-band finalize: flat plateau ~%.1fW (peak %.0fW) held "
"past expected %.0fs — appliance finished but holds a standby "
"draw above stop_threshold; finalizing (plateau trimmed, "
"duration %.0fs).",
power,
self._cycle_max_power,
self._expected_duration,
current_duration,
)
self._finish_cycle(
timestamp,
status="completed",
termination_reason=TerminationReason.TIMEOUT,
keep_tail=False,
)
return
# Max duration safety
if (
self._current_cycle_start
and (timestamp - self._current_cycle_start).total_seconds() > 28800
): # 8h safety
self._finish_cycle(timestamp, status="force_stopped")
self._finish_cycle(
timestamp,
status="force_stopped",
termination_reason=TerminationReason.FORCE_STOPPED,
)
elif self._state == STATE_PAUSED:
self._power_readings.append((timestamp, power))
# Anti-crease finalize (#296) - see the RUNNING branch.
if self._maybe_finalize_anticrease_tail(timestamp):
return
if is_high:
# Resume to RUNNING
self._transition_to(STATE_RUNNING, timestamp)
@@ -1032,6 +1187,25 @@ class CycleDetector:
elif self._state == STATE_ENDING:
self._power_readings.append((timestamp, power))
# Hard cap: ENDING must not run longer than RUNNING's 8 h safety limit.
# Without this a standby baseline can hold the state open indefinitely.
if (
self._current_cycle_start
and (timestamp - self._current_cycle_start).total_seconds() > 28800
):
self._finish_cycle(
timestamp,
status="force_stopped",
termination_reason=TerminationReason.FORCE_STOPPED,
)
return
# Anti-crease finalize (#296) - see the RUNNING branch. Fires ahead of
# the is_high end-spike handling so a sub-max_power tail burst finalizes
# into anti-wrinkle instead of reviving RUNNING.
if self._maybe_finalize_anticrease_tail(timestamp):
return
if is_high:
start_time = self._current_cycle_start or timestamp
current_duration = (timestamp - start_time).total_seconds()
@@ -1196,8 +1370,14 @@ class CycleDetector:
# the soak-bridging min_off_gap before committing
# Smart Termination, so a near-duplicate profile
# doesn't cut a long cycle short during a mid-cycle
# power trough.
smart_debounce = max(180.0, self._config.min_off_gap * 0.5)
# power trough. Bounded above so a large suggested /
# hand-set min_off_gap can't inflate the quiet-time
# requirement and starve end-detection (see
# WASHER_SMART_TERMINATION_DEBOUNCE_MAX_SECONDS).
smart_debounce = min(
WASHER_SMART_TERMINATION_DEBOUNCE_MAX_SECONDS,
max(180.0, self._config.min_off_gap * 0.5),
)
else:
smart_debounce = 120.0
@@ -1276,6 +1456,64 @@ class CycleDetector:
)
return
# --- DURATION-ANCHORED HARD FINALIZE (backstop) ---
# Separate safety net for a matched cycle whose Smart
# Termination is blocked (ambiguous / prefix-ambiguous match)
# and whose fallback energy gate is held open by a low standby
# baseline: without this it sits in ENDING until the 8 h cap /
# zombie-kill (#296/#311). Fires only well past the expected
# duration AND after a long *continuous* sub-threshold span, so
# it can never truncate a longer program mismatched to a shorter
# profile (a real longer program has high-power phases that keep
# resetting the quiet timer) — asymmetric, shorten-only. Not
# for user-paused cycles.
required_quiet = max(
ENDING_HARD_FINALIZE_MIN_QUIET_S,
float(max(self._config.off_delay, self._config.min_off_gap)),
)
# Require the required_quiet tail to be actually SAMPLED (no
# outage-sized gap): otherwise a telemetry outage that inflated
# _time_below_threshold could finalize an active cycle early.
# Walk in reverse so we can capture the boundary reading (the
# first reading outside the window) — an outage right before the
# window would be invisible if we only passed in-window timestamps.
quiet_ts: list[datetime] = []
_boundary_q: datetime | None = None
for _ts, _ in reversed(self._power_readings):
if (timestamp - _ts).total_seconds() <= required_quiet:
quiet_ts.append(_ts)
elif quiet_ts:
_boundary_q = _ts
break
if _boundary_q is not None:
quiet_ts.append(_boundary_q)
if (
self._expected_duration > 0
and current_duration
>= self._expected_duration * ENDING_HARD_FINALIZE_RATIO
and self._time_below_threshold >= required_quiet
and not self._verified_pause
and not self._window_has_outage_gap(quiet_ts)
):
self._logger.info(
"Duration-anchored finalize: cycle in ENDING at %.0fs "
"(%.1fx expected %.0fs), quiet %.0fs — Smart Termination "
"was blocked (ambiguous=%s prefix=%s); finalizing.",
current_duration,
current_duration / self._expected_duration,
self._expected_duration,
self._time_below_threshold,
self._match_ambiguous,
self._match_prefix_ambiguous,
)
self._finish_cycle(
timestamp,
status="completed",
termination_reason=TerminationReason.TIMEOUT,
keep_tail=False,
)
return
# --- FALLBACK TIMEOUT CHECK ---
# Rule: To separate cycles, we must wait at least min_off_gap.
effective_off_delay = max(self._config.off_delay, self._config.min_off_gap)
@@ -1333,11 +1571,15 @@ class CycleDetector:
if self._time_below_threshold >= effective_off_delay:
recent_window = [
r
for r in self._power_readings
if (timestamp - r[0]).total_seconds() <= gate_window
]
# Walk from the tail — readings are chronological so we can
# break as soon as we exceed the gate window (O(window) not O(n)).
recent_window = []
for r in reversed(self._power_readings):
if (timestamp - r[0]).total_seconds() <= gate_window:
recent_window.append(r)
else:
break
recent_window.reverse()
if not recent_window:
# Check deferred finish for matched profiles
@@ -1408,6 +1650,10 @@ class CycleDetector:
self._delay_wait_high_start = None
self._delay_wait_high_power = None
self._preserve_delay_band_on_off = False
# Clear the paused-STARTING true-off accumulator so a later STARTING
# cycle cannot inherit stale hold time and finalize to OFF prematurely
# (this path is also reached via the paused-STARTING cancellation).
self._starting_paused_off_since = None
# Reset end spike tracker when entering ENDING state
if new_state == STATE_ENDING:
@@ -1433,6 +1679,8 @@ class CycleDetector:
elif new_state == STATE_STARTING:
# Reset idle time if exiting ANTI_WRINKLE to STARTING (high-power burst resumed)
self._anti_wrinkle_idle_time = 0.0
# Fresh STARTING cycle: never inherit a prior cycle's true-off hold.
self._starting_paused_off_since = None
elif new_state == STATE_RUNNING:
self._delay_band_start = None
self._delay_band_seconds = 0.0
@@ -1478,6 +1726,266 @@ class CycleDetector:
self._ml_end_cache = (now_ts, exp, start, result)
return result
def _window_has_outage_gap(self, window_ts: list[datetime]) -> bool:
"""Whether a 'sustained window' contains a data-outage-sized hole.
The span + coverage checks in the standby / anti-crease window scans accept
e.g. three readings spanning the window even if a long unobserved gap sits
between them (a sensor dropout, or a sparse burst next to one old reading).
Finalizing on such a window could wrongly cut an active cycle, so reject it.
The gap ceiling is the sensor's own data-driven outage threshold
(``energy_gap_threshold_s`` over the full trace), so a change-only sensor's
legitimately-sparse stable stretches (tens of seconds between reports) are
NOT rejected while a genuine dropout is.
"""
if len(window_ts) < 2:
return True # too few points to trust as a sustained window
max_gap = self._outage_threshold_s()
ordered = sorted(t.timestamp() for t in window_ts)
return any((b - a) > max_gap for a, b in itertools.pairwise(ordered))
def _outage_threshold_s(self) -> float:
"""Sensor-adaptive gap ceiling (seconds): intervals longer than this are
treated as telemetry outages, not observed quiet. Data-driven from the
trace's own cadence (`energy_gap_threshold_s`), so a change-only sensor's
sparse-but-real stable stretches are not mistaken for a dropout.
"""
all_ts = np.array(
[r[0].timestamp() for r in self._power_readings], dtype=float
)
return energy_gap_threshold_s(all_ts)
def _is_standby_band_stuck(self, timestamp: datetime) -> bool:
"""Whether a RUNNING cycle is stuck on a flat standby plateau (#296).
Returns True only when ALL of the following hold, so this can never end
an active low-power phase:
* the device is a wet appliance where a stuck baseline is unambiguously
anomalous (``STANDBY_BAND_FINALIZE_DEVICE_TYPES``);
* a profile is matched and elapsed >= ``STANDBY_BAND_MIN_RATIO`` x the
expected duration (well past when it should have ended);
* not user-paused;
* the most recent >= ``STANDBY_BAND_WINDOW_S`` of readings are ALL at or
below ``STANDBY_BAND_MAX_FRACTION`` of the cycle's own peak power AND
span no more than ``STANDBY_BAND_FLATNESS_FRACTION`` of the peak (a flat
plateau, not fluctuating activity).
The expensive window scan runs only after the cheap duration gate passes,
so normal cycles never pay for it.
"""
if self._config.device_type not in STANDBY_BAND_FINALIZE_DEVICE_TYPES:
return False
if getattr(self, "_verified_pause", False):
return False
if not (self._matched_profile and self._expected_duration > 0):
return False
start = self._current_cycle_start
if start is None:
return False
current_duration = (timestamp - start).total_seconds()
if current_duration < self._expected_duration * STANDBY_BAND_MIN_RATIO:
return False
peak = float(self._cycle_max_power)
if peak <= 0:
return False
level_ceiling = peak * STANDBY_BAND_MAX_FRACTION
# Walk the tail; readings are chronological so we can break once outside
# the window (O(window), not O(n)).
window: list[float] = []
window_ts: list[datetime] = []
oldest_in_window: datetime | None = None
saw_older = False # a reading older than the window exists -> full coverage
_standby_boundary_ts: datetime | None = None
for ts, p in reversed(self._power_readings):
if (timestamp - ts).total_seconds() <= STANDBY_BAND_WINDOW_S:
window.append(float(p))
window_ts.append(ts)
oldest_in_window = ts
else:
saw_older = True
_standby_boundary_ts = ts # boundary: last reading before the window
break
# The plateau must actually SPAN the required window (data exists from
# before it), not just a couple of recent samples, and have enough points
# to judge. `saw_older` (rather than an exact span >= WINDOW check) is
# robust to sample phase/granularity: with e.g. 30 s sampling the oldest
# in-window reading is typically only ~570-599 s old, which an exact check
# would wrongly reject. A coverage sanity (oldest >= 90% of the window)
# plus an adjacent-gap check (``_window_has_outage_gap``) guard against a
# sparse burst of samples sitting next to one old reading across a dropout.
if (
oldest_in_window is None
or not saw_older
or len(window) < 3
or (timestamp - oldest_in_window).total_seconds()
< STANDBY_BAND_WINDOW_S * 0.9
or self._window_has_outage_gap(
[_standby_boundary_ts, *window_ts]
if _standby_boundary_ts is not None
else window_ts
)
):
return False
hi = max(window)
lo = min(window)
if hi > level_ceiling:
return False # a real active reading in the window - not standby
flatness_limit = max(
STANDBY_BAND_FLATNESS_FLOOR_W, peak * STANDBY_BAND_FLATNESS_FRACTION
)
if (hi - lo) > flatness_limit:
return False # fluctuating - still doing work
return True
def _anticrease_gate_open(self, timestamp: datetime) -> bool:
"""Core anti-crease gate (#296): everything except the current power level
and the low-power-window check. Shared by the match freeze
(``_in_anticrease_freeze``) and the finalise (``_is_anticrease_tail``).
True only when a genuinely energetic, confidently-matched cycle for an
anti-wrinkle device is PAST its expected duration - the discriminator that
separates the post-wash anti-crease tail from a mid-wash low-power trough
(a washer spends most of its cycle below ``anti_wrinkle_max_power``, but a
mid-wash trough is always BEFORE the expected duration, the tail after it).
"""
if not self._config.anti_wrinkle_enabled:
return False
if self._config.device_type not in (
DEVICE_TYPE_WASHING_MACHINE,
DEVICE_TYPE_DRYER,
DEVICE_TYPE_WASHER_DRYER,
):
return False
if getattr(self, "_verified_pause", False):
return False
if not (self._matched_profile and self._expected_duration > 0):
return False
if self._last_match_confidence < 0.4:
return False
if self._match_ambiguous or self._match_prefix_ambiguous:
return False
if self._cycle_max_power <= float(self._config.anti_wrinkle_max_power):
return False # never a hot/energetic cycle - leave low-power programs alone
start = self._current_cycle_start
if start is None:
return False
current_duration = (timestamp - start).total_seconds()
if current_duration < self._expected_duration * ANTI_CREASE_FINALIZE_RATIO:
return False
return True
def _in_anticrease_freeze(self, timestamp: datetime) -> bool:
"""Whether match updates should be frozen (#296): the anti-crease gate is
open AND the most recent reading is in the low-power regime (at or below
``anti_wrinkle_max_power``).
Deliberately lighter than ``_is_anticrease_tail`` - it does NOT wait for the
full ``ANTI_CREASE_CONFIRM_WINDOW_S``, so the confident pre-tail match is
preserved from the instant the cycle crosses its expected duration in a
low-power state, before the window accrues. Without this a match that
degrades to ambiguous within the first window's worth of tail would
deadlock both the freeze and the finalise (both require an unambiguous
match). Self-correcting: a heating burst above ``anti_wrinkle_max_power``
leaves the regime and re-arms matching.
"""
if not self._power_readings:
return False
if float(self._power_readings[-1][1]) > float(
self._config.anti_wrinkle_max_power
):
return False
return self._anticrease_gate_open(timestamp)
def _is_anticrease_tail(self, timestamp: datetime) -> bool:
"""Whether a matched, past-expected cycle has settled into the anti-crease
tumble tail (#296) - the trigger for the finalise into STATE_ANTI_WRINKLE.
Miele-style "Knitterschutz": after the wash proper ends, the machine holds
a constant baseline plus periodic sub-``anti_wrinkle_max_power`` tumble
bursts (no heating) until the door is opened. Because those bursts recur
faster than off_delay they keep reviving the cycle out of ENDING, so the
normal power-off path never finalises it and STATE_ANTI_WRINKLE - which is
built to absorb the tail and split off the next wash - never engages.
Recognising the tail lets us finalise into anti-wrinkle directly.
Requires the core gate (``_anticrease_gate_open``) AND that the most recent
>= ``ANTI_CREASE_CONFIRM_WINDOW_S`` of readings are ALL at or below
``anti_wrinkle_max_power`` (we are in the low-power tail, clear of the final
spin and not mid-heating). The expensive window scan runs only after the
cheap gate passes, so normal cycles never pay for it.
"""
if not self._anticrease_gate_open(timestamp):
return False
max_power = float(self._config.anti_wrinkle_max_power)
# Walk the tail; readings are chronological so we can break once outside the
# window (O(window), not O(n)).
window: list[float] = []
window_ts: list[datetime] = []
oldest_in_window: datetime | None = None
saw_older = False
_ac_boundary_ts: datetime | None = None
for ts, p in reversed(self._power_readings):
if (timestamp - ts).total_seconds() <= ANTI_CREASE_CONFIRM_WINDOW_S:
window.append(float(p))
window_ts.append(ts)
oldest_in_window = ts
else:
saw_older = True
_ac_boundary_ts = ts # boundary: last reading before the window
break
# The low-power tail must actually SPAN the window (data exists from before
# it) and have enough points to judge - not just a couple of recent samples.
# ``saw_older`` plus a coverage sanity and an adjacent-gap check
# (``_window_has_outage_gap``) is robust to sample phase/granularity while
# rejecting a dropout-sized hole (mirrors _is_standby_band_stuck).
if (
oldest_in_window is None
or not saw_older
or len(window) < 3
or (timestamp - oldest_in_window).total_seconds()
< ANTI_CREASE_CONFIRM_WINDOW_S * 0.9
or self._window_has_outage_gap(
[_ac_boundary_ts, *window_ts]
if _ac_boundary_ts is not None
else window_ts
)
):
return False
if max(window) > max_power:
return False # a heating / high-spin reading in the window - still washing
return True
def _maybe_finalize_anticrease_tail(self, timestamp: datetime) -> bool:
"""Finalise a cycle that has entered the anti-crease tail into
STATE_ANTI_WRINKLE (#296). Returns True if the cycle was finalised.
Shared by the RUNNING / PAUSED / ENDING branches so the finalise fires no
matter which state a burst left the detector in. Uses Smart Termination
(in ``ANTI_WRINKLE_ELIGIBLE_REASONS``) so ``_finish_cycle`` routes into
STATE_ANTI_WRINKLE, which then absorbs the tail and splits off any next
wash on its first heating burst above ``anti_wrinkle_max_power``.
"""
if not self._is_anticrease_tail(timestamp):
return False
start_time = self._current_cycle_start or timestamp
current_duration = (timestamp - start_time).total_seconds()
self._logger.info(
"Anti-crease finalize: matched '%s' past expected %.0fs (elapsed %.0fs), "
"settled into the low-power tumble tail — finalizing into anti-wrinkle.",
self._matched_profile,
self._expected_duration,
current_duration,
)
self._finish_cycle(
timestamp,
status="completed",
termination_reason=TerminationReason.SMART,
keep_tail=True,
)
return True
def _is_terminal_drop(self) -> bool:
"""Whether the current low-power event is an anomalously-early hard drop.