1016 lines
57 KiB
Python
1016 lines
57 KiB
Python
# 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/>.
|
||
"""Constants for the WashData integration."""
|
||
|
||
from enum import StrEnum
|
||
|
||
DOMAIN = "ha_washdata"
|
||
|
||
|
||
class TerminationReason(StrEnum):
|
||
"""Why a cycle ended. StrEnum members equal their string value, so existing
|
||
string comparisons and JSON serialisation keep working unchanged."""
|
||
|
||
TIMEOUT = "timeout" # low-power off_delay elapsed (normal completion)
|
||
SMART = "smart" # smart-termination heuristic finished the cycle
|
||
FORCE_STOPPED = "force_stopped" # watchdog / no-update force end
|
||
USER = "user" # user manually stopped the cycle
|
||
TERMINAL_DROP = "terminal_drop" # anomalously-early hard cliff-to-0 (opt-in)
|
||
|
||
|
||
# Completed cycles stay eligible for anti-wrinkle handling only for these
|
||
# reasons (a user-stopped cycle is intentionally excluded).
|
||
ANTI_WRINKLE_ELIGIBLE_REASONS = frozenset(
|
||
{TerminationReason.TIMEOUT, TerminationReason.SMART}
|
||
)
|
||
|
||
# Configuration keys
|
||
CONF_POWER_SENSOR = "power_sensor"
|
||
CONF_NAME = "name"
|
||
CONF_MIN_POWER = "min_power"
|
||
CONF_OFF_DELAY = "off_delay"
|
||
CONF_NOTIFY_SERVICE = "notify_service" # Deprecated - kept for migration only
|
||
CONF_NOTIFY_ACTIONS = "notify_actions"
|
||
CONF_NOTIFY_PEOPLE = "notify_people"
|
||
CONF_NOTIFY_ONLY_WHEN_HOME = "notify_only_when_home"
|
||
CONF_NOTIFY_FIRE_EVENTS = "notify_fire_events"
|
||
CONF_NOTIFY_EVENTS = "notify_events" # Deprecated - kept for migration only
|
||
CONF_NOTIFY_START_SERVICES = "notify_start_services"
|
||
CONF_NOTIFY_FINISH_SERVICES = "notify_finish_services"
|
||
CONF_NOTIFY_LIVE_SERVICES = "notify_live_services"
|
||
CONF_NOTIFY_CYCLE_TIMERS = "notify_cycle_timers"
|
||
CONF_NO_UPDATE_ACTIVE_TIMEOUT = "no_update_active_timeout"
|
||
CONF_LOW_POWER_NO_UPDATE_TIMEOUT = "low_power_no_update_timeout"
|
||
CONF_SMOOTHING_WINDOW = "smoothing_window"
|
||
CONF_SAMPLING_INTERVAL = "sampling_interval"
|
||
CONF_START_DURATION_THRESHOLD = (
|
||
"start_duration_threshold" # Debounce for start detection
|
||
)
|
||
CONF_DEVICE_TYPE = "device_type"
|
||
CONF_PROFILE_DURATION_TOLERANCE = "profile_duration_tolerance"
|
||
|
||
|
||
CONF_INTERRUPTED_MIN_SECONDS = "interrupted_min_seconds" # Internal use only
|
||
CONF_PROGRESS_RESET_DELAY = "progress_reset_delay"
|
||
CONF_LEARNING_CONFIDENCE = "learning_confidence"
|
||
CONF_DURATION_TOLERANCE = "duration_tolerance"
|
||
CONF_AUTO_LABEL_CONFIDENCE = "auto_label_confidence"
|
||
CONF_AUTO_MAINTENANCE = "auto_maintenance"
|
||
CONF_PROFILE_MATCH_INTERVAL = "profile_match_interval"
|
||
CONF_PROFILE_MATCH_MIN_DURATION_RATIO = "profile_match_min_duration_ratio"
|
||
CONF_PROFILE_MATCH_MAX_DURATION_RATIO = "profile_match_max_duration_ratio"
|
||
CONF_MAX_PAST_CYCLES = "max_past_cycles"
|
||
CONF_MAX_FULL_TRACES_PER_PROFILE = "max_full_traces_per_profile"
|
||
CONF_MAX_FULL_TRACES_UNLABELED = "max_full_traces_unlabeled"
|
||
CONF_WATCHDOG_INTERVAL = "watchdog_interval" # Derived from sampling_interval
|
||
CONF_MATCH_PERSISTENCE = "match_persistence"
|
||
CONF_COMPLETION_MIN_SECONDS = "completion_min_seconds"
|
||
CONF_NOTIFY_BEFORE_END_MINUTES = "notify_before_end_minutes"
|
||
CONF_RUNNING_DEAD_ZONE = "running_dead_zone" # REMOVED in 0.5.3 — was never wired to detection
|
||
CONF_END_REPEAT_COUNT = "end_repeat_count" # Number of times end condition must be met
|
||
CONF_MIN_OFF_GAP = "min_off_gap" # Minimum gap to separate cycles (seconds)
|
||
CONF_START_ENERGY_THRESHOLD = "start_energy_threshold" # Wh required to confirm start
|
||
CONF_END_ENERGY_THRESHOLD = "end_energy_threshold" # Wh allowed during end candidates
|
||
CONF_START_THRESHOLD_W = "start_threshold_w" # Custom power threshold for STARTING
|
||
CONF_STOP_THRESHOLD_W = (
|
||
"stop_threshold_w" # Custom power threshold for ENDING (hysteresis)
|
||
)
|
||
CONF_POWER_OFF_THRESHOLD_W = (
|
||
"power_off_threshold_w" # W; 0 = disabled. Terminal Finished/Clean -> Off when
|
||
) # smoothed power stays below this (must sit below stop_threshold_w when > 0)
|
||
CONF_POWER_OFF_DELAY = (
|
||
"power_off_delay" # Seconds below the power-off threshold before Finished/Clean -> Off
|
||
)
|
||
CONF_EXPOSE_DEBUG_ENTITIES = "expose_debug_entities" # Expose detailed debug sensors
|
||
# Per-device opt-in: blend the phase-resolved (per-role budget) ETA into the
|
||
# time-remaining estimate for phase-matching-supported device types (washing
|
||
# machine, washer-dryer). Default off. Validated by the Phase-0 ETA gate; see
|
||
# docs/superpowers/specs/2026-07-17-phase-segmented-matching-design.md.
|
||
CONF_ENABLE_PHASE_MATCHING = "enable_phase_matching"
|
||
# Phase-structure consistency advisory (Profiles tab, never a notification).
|
||
# A single-program/temperature profile should have a fairly consistent heating
|
||
# block; wildly varying heating time or heating present in only some cycles
|
||
# usually means different programs/temperatures were labelled under one profile
|
||
# (the "mixed labels" data-hygiene problem). Pure statistics from the cached
|
||
# phase profile - no relabeling (phase matching does not label better than the
|
||
# whole-cycle matcher; see the Phase-0 gate).
|
||
# Minimum member cycles before a profile's cached phase profile is trusted to
|
||
# drive the live phase-resolved ETA (mirrors the envelope's cycle_count>=2 gate);
|
||
# below this the priors are too noisy (single-cycle -> zero variance) and the
|
||
# estimate falls back to the classic one. Design §6 cold-start floor.
|
||
PHASE_PROFILE_MIN_CYCLES = 2
|
||
PHASE_CONSISTENCY_MIN_CYCLES = 4
|
||
# Heating-time std/mean above this -> likely mixed temperatures under one label.
|
||
# A clean single-temperature profile sits ~0.2 (load variation only); a profile
|
||
# mixing 30/40/90C sits ~0.45-0.6, so 0.45 catches genuine mixing with margin.
|
||
PHASE_HEAT_CV_WARN = 0.45
|
||
PHASE_HEAT_OCC_MIXED_LO = 0.25 # heating present in only 25%-75% of cycles ->
|
||
PHASE_HEAT_OCC_MIXED_HI = 0.75 # mixed with a non-heating program
|
||
CONF_SAVE_DEBUG_TRACES = (
|
||
"save_debug_traces" # Improve historical cycle data with rich debug info
|
||
)
|
||
CONF_AUTO_TUNE_NOISE_EVENTS_THRESHOLD = "auto_tune_noise_events_threshold" # Noise events before auto-tune
|
||
CONF_EXTERNAL_END_TRIGGER_ENABLED = "external_end_trigger_enabled" # Enable external cycle end trigger
|
||
CONF_EXTERNAL_END_TRIGGER = "external_end_trigger" # Binary sensor entity for external cycle end
|
||
CONF_EXTERNAL_END_TRIGGER_INVERTED = "external_end_trigger_inverted" # Invert external trigger logic (trigger on OFF)
|
||
CONF_ANTI_WRINKLE_ENABLED = "anti_wrinkle_enabled" # Dryer anti-wrinkle shielding
|
||
CONF_ANTI_WRINKLE_MAX_POWER = "anti_wrinkle_max_power" # W threshold for anti-wrinkle spikes
|
||
CONF_ANTI_WRINKLE_MAX_DURATION = "anti_wrinkle_max_duration" # Seconds to treat as anti-wrinkle
|
||
CONF_ANTI_WRINKLE_EXIT_POWER = "anti_wrinkle_exit_power" # W threshold for true-off exit
|
||
CONF_ANTI_WRINKLE_IDLE_TIMEOUT = "anti_wrinkle_idle_timeout" # Seconds below exit power before anti-wrinkle ends
|
||
CONF_DELAY_START_DETECT_ENABLED = "delay_start_detect_enabled" # Enable delayed-start detection
|
||
CONF_DELAY_CONFIRM_SECONDS = "delay_confirm_seconds" # Seconds power must stay in standby band before DELAY_WAIT engages
|
||
CONF_DELAY_TIMEOUT_HOURS = "delay_timeout_hours" # Safety timeout (hours) while waiting to start
|
||
# Note: the deprecated 0.4.5 drain-spike keys (delay_drain_*) are stripped during
|
||
# config migration in __init__.py using raw string literals; no constants needed.
|
||
|
||
|
||
NOTIFY_EVENT_START = "cycle_start"
|
||
NOTIFY_EVENT_FINISH = "cycle_finish"
|
||
NOTIFY_EVENT_LIVE = "cycle_live"
|
||
NOTIFY_EVENT_CLEAN = "cycle_clean" # Laundry still inside after cycle ends
|
||
NOTIFY_EVENT_TIMER = "cycle_timer" # User-configured mid-cycle countdown timer
|
||
|
||
CONF_NOTIFY_TITLE = "notify_title"
|
||
CONF_NOTIFY_ICON = "notify_icon"
|
||
CONF_NOTIFY_START_MESSAGE = "notify_start_message"
|
||
CONF_NOTIFY_FINISH_MESSAGE = "notify_finish_message"
|
||
CONF_NOTIFY_PRE_COMPLETE_MESSAGE = "notify_pre_complete_message"
|
||
CONF_NOTIFY_LIVE_INTERVAL_SECONDS = "notify_live_interval_seconds"
|
||
CONF_NOTIFY_LIVE_OVERRUN_PERCENT = "notify_live_overrun_percent"
|
||
CONF_NOTIFY_LIVE_CHRONOMETER = "notify_live_chronometer"
|
||
CONF_NOTIFY_REMINDER_MESSAGE = "notify_reminder_message" # Distinct one-time pre-end alert
|
||
CONF_NOTIFY_TIMEOUT_SECONDS = "notify_timeout_seconds" # Auto-dismiss after N seconds (0 = never)
|
||
CONF_NOTIFY_CHANNEL = "notify_channel" # Android channel for status/live/reminder
|
||
CONF_NOTIFY_FINISH_CHANNEL = "notify_finish_channel" # Distinct Android channel for finished/clean
|
||
CONF_ENERGY_PRICE_STATIC = "energy_price_static"
|
||
CONF_ENERGY_PRICE_ENTITY = "energy_price_entity"
|
||
# Optional external cumulative energy meter (issue #316). When set, each cycle's
|
||
# reported energy is taken from this counter's start->end delta instead of the
|
||
# integrated power trace, which systematically under-counts on report-on-change
|
||
# plugs. Strictly opt-in: with no entity configured, behaviour is unchanged. The
|
||
# integrated value is always still computed and stored (energy_wh) so matching,
|
||
# ML and anomaly stats stay internally consistent; the meter only supplies the
|
||
# user-facing reported figure (cost, lifetime total, notifications, panel).
|
||
CONF_ENERGY_SENSOR = "energy_sensor"
|
||
# Peak-rate awareness: when the current price meets/exceeds this threshold, the
|
||
# start notification gets an informational tip appended (purely advisory).
|
||
CONF_PEAK_RATE_THRESHOLD = "peak_rate_threshold"
|
||
CONF_PEAK_RATE_MESSAGE = "peak_rate_message"
|
||
|
||
# Door sensor & pause
|
||
CONF_DOOR_SENSOR_ENTITY = "door_sensor_entity" # Optional binary_sensor for machine door
|
||
CONF_PAUSE_CUTS_POWER = "pause_cuts_power" # Also turn off switch entity when pausing
|
||
CONF_SWITCH_ENTITY = "switch_entity" # Optional switch entity toggled on pause/resume
|
||
CONF_NOTIFY_UNLOAD_DELAY_MINUTES = "notify_unload_delay_minutes" # Minutes before "laundry waiting" nag
|
||
CONF_NOTIFY_UNLOAD_MESSAGE = "notify_unload_message" # Template for the clean-laundry nag message
|
||
|
||
# Quiet hours (do-not-disturb window). Both hours 0-23; unset/None (or start==end)
|
||
# = feature off. When configured, finish-type notifications (finish, clean-laundry
|
||
# nag, pre-complete/reminder, milestone) that would fire inside the window are held
|
||
# and delivered at the end of the window. Live-progress ticks and the start
|
||
# notification are never delayed.
|
||
CONF_NOTIFY_QUIET_START_HOUR = "notify_quiet_start_hour"
|
||
CONF_NOTIFY_QUIET_END_HOUR = "notify_quiet_end_hour"
|
||
|
||
# Milestone (cycle-count achievement) notifications. A list of lifetime completed-
|
||
# cycle counts; a single milestone notification fires when the device's lifetime
|
||
# count crosses one of these values. Empty/malformed list = no-op.
|
||
CONF_NOTIFY_MILESTONES = "notify_milestones"
|
||
CONF_NOTIFY_MILESTONE_MESSAGE = "notify_milestone_message"
|
||
|
||
# Optional link to an existing HA device (e.g. the smart plug or appliance).
|
||
# When set, the WashData device is exposed as "Connected via <device>" through
|
||
# the device registry's via_device relationship. Stores a device registry id.
|
||
CONF_LINKED_DEVICE = "linked_device"
|
||
|
||
DEFAULT_NOTIFY_TITLE = "WashData: {device}"
|
||
DEFAULT_NOTIFY_START_MESSAGE = "{device} started."
|
||
DEFAULT_NOTIFY_FINISH_MESSAGE = "{device} finished. Duration: {duration}m."
|
||
DEFAULT_NOTIFY_PRE_COMPLETE_MESSAGE = "{device}: Less than {minutes} minutes remaining."
|
||
DEFAULT_NOTIFY_REMINDER_MESSAGE = "{device}: about {minutes} minutes left."
|
||
DEFAULT_NOTIFY_LIVE_WAITING_MESSAGE = "{device}: No profile matched yet."
|
||
DEFAULT_NOTIFY_ONLY_WHEN_HOME = False
|
||
DEFAULT_NOTIFY_FIRE_EVENTS = True
|
||
DEFAULT_NOTIFY_LIVE_INTERVAL_SECONDS = 300
|
||
DEFAULT_NOTIFY_LIVE_OVERRUN_PERCENT = 20
|
||
DEFAULT_NOTIFY_LIVE_CHRONOMETER = False
|
||
DEFAULT_NOTIFY_TIMEOUT_SECONDS = 0 # 0 = notifications never auto-dismiss
|
||
DEFAULT_NOTIFY_CHANNEL = "" # Empty = omit channel (companion app default)
|
||
DEFAULT_NOTIFY_FINISH_CHANNEL = "" # Empty = reuse status channel
|
||
DEFAULT_NOTIFY_UNLOAD_DELAY_MINUTES = 60 # 1 hour before "still waiting" nag notification
|
||
DEFAULT_NOTIFY_UNLOAD_MESSAGE = "{device} finished {duration}m ago - laundry is still inside."
|
||
DEFAULT_PEAK_RATE_MESSAGE = "Running at peak rate ({price}/kWh)."
|
||
|
||
# Quiet hours default: feature off (both hours unset). See CONF_NOTIFY_QUIET_*.
|
||
DEFAULT_NOTIFY_QUIET_START_HOUR = None
|
||
DEFAULT_NOTIFY_QUIET_END_HOUR = None
|
||
|
||
# Milestone notification defaults.
|
||
DEFAULT_NOTIFY_MILESTONES = [50, 100, 500, 1000]
|
||
DEFAULT_NOTIFY_MILESTONE_MESSAGE = "{device} has completed {cycle_count} cycles!"
|
||
|
||
# Defaults
|
||
DEFAULT_MIN_POWER = 2.0 # Watts
|
||
DEFAULT_OFF_DELAY = 180 # Seconds (3 minutes, safer for 60s polling)
|
||
DEFAULT_NAME = "Washing Machine"
|
||
# Seconds without updates while active before forced stop (publish-on-change sockets)
|
||
DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT = 600 # 10 minutes
|
||
DEFAULT_SMOOTHING_WINDOW = 2
|
||
DEFAULT_SAMPLING_INTERVAL = 30.0 # Seconds
|
||
DEFAULT_START_DURATION_THRESHOLD = 5.0 # Seconds (debounce)
|
||
DEFAULT_END_ENERGY_THRESHOLD = 0.05 # Wh - Require effectively zero energy to end
|
||
DEFAULT_DEVICE_TYPE = "washing_machine"
|
||
DEFAULT_PROFILE_DURATION_TOLERANCE = 0.25
|
||
|
||
DEFAULT_INTERRUPTED_MIN_SECONDS = 150 # Internal use only, not exposed
|
||
|
||
DEFAULT_PROGRESS_RESET_DELAY = 1800 # Seconds (30 minutes state expiry/unload window)
|
||
|
||
# Power-based Off detection (issue #284; opt-in, default off). Threshold 0 = disabled
|
||
# (the enable marker); when > 0 it must sit BELOW stop_threshold_w (beneath the idle/
|
||
# standby floor) or it is ignored. The delay is a short debounce that is safe to keep
|
||
# small because it only applies in the terminal state (no soak risk there). When enabled,
|
||
# power-off owns the terminal -> Off transition and the progress-reset timer no longer
|
||
# forces Off (the terminal state persists until the machine is actually switched off).
|
||
DEFAULT_POWER_OFF_THRESHOLD_W = 0.0 # Disabled
|
||
DEFAULT_POWER_OFF_DELAY = 30 # Seconds
|
||
DEFAULT_LEARNING_CONFIDENCE = 0.6 # Minimum confidence to request user verification
|
||
DEFAULT_DURATION_TOLERANCE = 0.10 # Allow ±10% duration variance before flagging
|
||
DEFAULT_AUTO_LABEL_CONFIDENCE = 0.9 # High confidence auto-label threshold
|
||
DEFAULT_AUTO_MAINTENANCE = True # Enable nightly cleanup by default
|
||
DEFAULT_COMPLETION_MIN_SECONDS = 600 # 10 minutes
|
||
DEFAULT_NOTIFY_BEFORE_END_MINUTES = 0 # Disabled
|
||
DEFAULT_PROFILE_MATCH_INTERVAL = (
|
||
300 # Seconds between profile matching attempts (5 minutes)
|
||
)
|
||
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO = 0.10 # Allow match after 10% of expected duration
|
||
# 1.5 = up to 150% of the profile's average duration. Tuned via the precision
|
||
# harness in devtools/dtw_ab_eval.py: widening 1.3->1.5 lifts commit-recall
|
||
# 71.6%->73.4% for a negligible false-positive change; 1.3 was rejecting normal
|
||
# longer-than-average runs (extended/anti-wrinkle variants).
|
||
DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO = 1.5
|
||
DEFAULT_MAX_PAST_CYCLES = 200
|
||
DEFAULT_MAX_FULL_TRACES_PER_PROFILE = 20
|
||
DEFAULT_MAX_FULL_TRACES_UNLABELED = 20
|
||
DEFAULT_WATCHDOG_INTERVAL = 30 # Derived: 2 * sampling_interval + 1
|
||
DEFAULT_MATCH_PERSISTENCE = 3
|
||
DEFAULT_END_REPEAT_COUNT = 1 # 1 = current behavior (no repeat required)
|
||
|
||
# Matching & Termination Stability
|
||
DEFAULT_MATCH_REVERT_RATIO = 0.4 # Drop from peak score to revert to detecting
|
||
DEFAULT_DEFER_FINISH_CONFIDENCE = 0.55 # Minimum confidence to defer cycle finish
|
||
|
||
# ML live-match commit gate: P(top-1 is correct) threshold to commit a match
|
||
# before the persistence counter is satisfied. Set high to avoid false-early
|
||
# commits; the model's owner-holdout precision is ~0.87 at this score.
|
||
ML_MATCH_COMMIT_THRESHOLD = 0.85
|
||
|
||
# ML quality gate: P(cycle is a problem) threshold above which even a high-
|
||
# confidence auto-label is downgraded to a feedback request. Tuned for a
|
||
# specificity of ~0.84 (few false positives) so users are not flooded.
|
||
ML_QUALITY_SUSPICIOUS_THRESHOLD = 0.65
|
||
|
||
# Match ranking history: maximum number of per-cycle snapshots retained on-device.
|
||
# Each snapshot stores pre-computed live_match feature scalars (not traces) so
|
||
# footprint is small; 500 snapshots cover ~6–12 months of typical usage and are
|
||
# enough to build a per-device live_match training dataset.
|
||
MATCH_RANKING_HISTORY_MAX = 500
|
||
|
||
# Runtime overrun anomaly: a *soft, visible* signal (attribute + cycle metadata,
|
||
# never a notification) flagged once a running cycle exceeds its matched
|
||
# profile's expected duration by this ratio. Distinct from the 300% zombie-kill
|
||
# hard limit: this only surfaces "running longer than usual" for the UI. Kept
|
||
# below the zombie threshold so it lights up well before any termination.
|
||
CYCLE_OVERRUN_ANOMALY_RATIO = 1.5
|
||
|
||
# Duration-anchored hard-finalize backstop in the ENDING state. Smart Termination
|
||
# is (deliberately) gated on a confident, non-ambiguous match; an ambiguous /
|
||
# prefix-ambiguous match therefore skips it and relies on the power+energy fallback
|
||
# timeout, which a low standby baseline (below stop_threshold but energetic enough
|
||
# to trip the energy gate) can hold open until the 8 h cap / zombie-kill — the
|
||
# #296/#311 "finishes hours/1000+ min late" reports. This SEPARATE backstop
|
||
# finalizes a matched cycle that has sat in ENDING well past its expected duration
|
||
# AND been genuinely quiet (below stop_threshold) for a sustained span. It is
|
||
# asymmetric (can only ever SHORTEN a stuck wait, never end a cycle early) and the
|
||
# sustained-quiet guard is what keeps it from truncating a longer program that was
|
||
# mismatched to a shorter profile — a real longer program has high-power phases
|
||
# that keep resetting the below-threshold timer, so it never accumulates the
|
||
# required continuous quiet. Sits between CYCLE_OVERRUN_ANOMALY_RATIO (1.5, soft
|
||
# visible signal) and the manager's 3x zombie-kill, so it fires well after any
|
||
# legitimate overrun but well before the hard kill.
|
||
ENDING_HARD_FINALIZE_RATIO = 2.0
|
||
ENDING_HARD_FINALIZE_MIN_QUIET_S = 600.0 # continuous sub-threshold span floor
|
||
|
||
# NOTE: STANDBY_BAND_* constants live further down, after the DEVICE_TYPE_*
|
||
# definitions they reference (search "Standby-band stuck-in-RUNNING finalize").
|
||
# Underrun anomaly: a cycle that finishes in less than this fraction of its
|
||
# matched profile's median duration is flagged "underrun" (post-cycle only,
|
||
# never a live signal — computed in _async_process_cycle_end after the cycle
|
||
# ends). Mutually exclusive with overrun: only set when no runtime anomaly fired.
|
||
CYCLE_UNDERRUN_ANOMALY_RATIO = 0.55 # below 55% of expected duration = underrun
|
||
|
||
# Energy anomaly thresholds: a cycle whose energy deviates by more than this
|
||
# many standard deviations from the profile's historical average is flagged
|
||
# "energy_spike" or "energy_low". Stored separately from the duration anomaly
|
||
# so both can coexist. Requires at least 3 labeled cycles for the reference stats.
|
||
ENERGY_ANOMALY_Z_THRESHOLD = 2.5 # |z-score| above this = energy anomaly
|
||
|
||
# Profile warm-up mode: a newly-created profile with fewer than this many
|
||
# labeled cycles skips auto-labeling and always requests manual confirmation.
|
||
# Prevents the system from confidently mis-labeling cycles before it has seen
|
||
# enough examples of the program.
|
||
CONF_PROFILE_MIN_WARMUP_CYCLES = 5 # labeled cycles before auto-matching is enabled
|
||
|
||
# Shape drift detection: compares the average power-curve envelope of the
|
||
# earliest third of a profile's cycles against the most recent third.
|
||
# A Pearson correlation below SHAPE_DRIFT_THRESHOLD signals drift.
|
||
SHAPE_DRIFT_THRESHOLD = 0.85 # envelope correlation below this = shape drifting
|
||
SHAPE_DRIFT_MIN_CYCLES = 10 # minimum labeled cycles to check drift
|
||
SHAPE_DRIFT_RESAMPLE_N = 50 # points for envelope comparison
|
||
|
||
# Unlabeled-cycle shape clustering (A3): when suggest_coverage_gaps finds
|
||
# duration-bucketed clusters of unmatched cycles, it also checks whether the
|
||
# power-curve shapes within each bucket are similar enough to suggest a new
|
||
# profile. Uses a normalized cross-correlation on resampled traces.
|
||
CLUSTER_SHAPE_SIMILARITY_THRESHOLD = 0.75 # min correlation for shape-similar cluster
|
||
CLUSTER_RESAMPLE_N = 50 # points for pairwise comparison
|
||
|
||
# Terminal-drop fast finalize (opt-in; gated on CONF_ENABLE_ML_MODELS via the
|
||
# manager provider). A hard cliff-to-~0 at an elapsed offset EARLIER than this
|
||
# device has ever legitimately gone quiet (learned from its own completed
|
||
# cycles) is an anomaly - almost certainly a real stop (plug pulled / cancelled)
|
||
# rather than a soak pause - so the cycle is finalized quickly instead of waiting
|
||
# out the full soak-bridging min_off_gap (up to 8 min for washers, 1 h for
|
||
# dishwashers). Asymmetric like the ML end-guard, but the opposite direction: it
|
||
# can only SHORTEN the end wait, and only for anomalously-early drops.
|
||
TERMINAL_DROP_OFF_DELAY_SECONDS = 90 # shortened below-threshold wait once terminal
|
||
TERMINAL_DROP_MIN_CLEAN_CYCLES = 3 # completed cycles needed before we trust the baseline
|
||
TERMINAL_DROP_MIN_QUIET_SPAN_S = 60 # sustained sub-threshold span that counts as a legit quiet period
|
||
TERMINAL_DROP_EARLINESS_RATIO = 0.8 # fire only if drop starts < ratio * earliest-ever-quiet offset
|
||
TERMINAL_DROP_MIN_PEAK_RATIO = 5.0 # cycle must have been clearly ON (peak >= ratio * stop_threshold)
|
||
# Familiarity/novelty gate: an early hard drop is only trusted as terminal when
|
||
# the cycle's power level is one this device has produced before. A very early
|
||
# drop (below the matcher's duration gate) can't be confirmed by match
|
||
# confidence, so power level is the signal available that early: a cycle peaking
|
||
# outside the device's historical peak range (widened by this tolerance) is
|
||
# treated as potentially a NEW program and DEFERRED to the proven slow path
|
||
# rather than assumed to be a stop.
|
||
TERMINAL_DROP_PEAK_FAMILIAR_TOL = 0.4
|
||
|
||
DEFAULT_AUTO_TUNE_NOISE_EVENTS_THRESHOLD = 3 # Ghost cycles before threshold adjustment
|
||
|
||
# Anti-wrinkle defaults (advanced; disabled by default)
|
||
DEFAULT_ANTI_WRINKLE_ENABLED = False
|
||
DEFAULT_ANTI_WRINKLE_MAX_POWER = 400.0 # W
|
||
DEFAULT_ANTI_WRINKLE_MAX_DURATION = 60.0 # s
|
||
DEFAULT_ANTI_WRINKLE_EXIT_POWER = 0.8 # W
|
||
# Quiet gap a machine may leave between two anti-wrinkle pulses before the mode
|
||
# ends. Keeps the previous hardcoded behaviour as the default; dryers whose
|
||
# pulses sit further apart (a heat-pump dryer measured 130-660 s) need a higher
|
||
# value, otherwise the mode drops out after the first pulse and every later one
|
||
# surfaces as an aborted false start.
|
||
DEFAULT_ANTI_WRINKLE_IDLE_TIMEOUT = 120.0 # s
|
||
|
||
# Delayed-start detection defaults (disabled by default).
|
||
#
|
||
# The detector watches for sustained power between stop_threshold_w and
|
||
# start_threshold_w (the "standby band"): a machine sitting in that band
|
||
# for at least DEFAULT_DELAY_CONFIRM_SECONDS is in delayed-start mode, not
|
||
# off and not running. Short menu-navigation peaks above the band are
|
||
# ignored because they don't sustain long enough to satisfy the normal
|
||
# start-duration gate.
|
||
DEFAULT_DELAY_START_DETECT_ENABLED = False
|
||
DEFAULT_DELAY_CONFIRM_SECONDS = 60.0 # s - sustained standby before DELAY_WAIT engages
|
||
DEFAULT_DELAY_TIMEOUT_HOURS = 8.0 # h - give up waiting after this long
|
||
|
||
# Pump Monitor settings (pump device type only)
|
||
CONF_PUMP_STUCK_DURATION = "pump_stuck_duration" # Seconds before a running pump is flagged as stuck
|
||
DEFAULT_PUMP_STUCK_DURATION = 1800 # 30 min - typical sump pump runs <60 s; 30 min implies motor is jammed
|
||
EVENT_PUMP_STUCK = "ha_washdata_pump_stuck" # Fired when stuck-pump threshold is exceeded
|
||
|
||
# Profile Matching Thresholds
|
||
CONF_PROFILE_MATCH_THRESHOLD = "profile_match_threshold"
|
||
CONF_PROFILE_UNMATCH_THRESHOLD = "profile_unmatch_threshold"
|
||
|
||
DEFAULT_PROFILE_MATCH_THRESHOLD = 0.4
|
||
DEFAULT_PROFILE_UNMATCH_THRESHOLD = 0.35
|
||
|
||
CONF_DTW_BANDWIDTH = "dtw_bandwidth"
|
||
DEFAULT_DTW_BANDWIDTH = 0.20 # 20% Sakoe-Chiba constraint
|
||
|
||
# ─── Matching pipeline scoring constants (analysis.py) ────────────────────────
|
||
# Previously scattered as magic numbers in analysis.py / profile_store.py.
|
||
# Centralised here so the scoring formula is auditable in one place and the
|
||
# ambiguity threshold cannot drift between its two call sites.
|
||
#
|
||
# Core similarity (Stage 2): score = CORR_WEIGHT*max(0,corr) + MAE_WEIGHT*mae_score
|
||
# where mae_score = MAE_SCALE / (MAE_SCALE + scaled_mae). See MATCH_MAE_SCALE_MODE
|
||
# in analysis.py for how scaled_mae is normalised across device power scales.
|
||
# 0.45 tuned via devtools/dtw_ab_eval.py: weighting MAE more (0.6->0.45 corr)
|
||
# lifted leave-one-out top-1 74%->79.5% AND the recall/FP net 10.7%->13.7% (FP
|
||
# flat), i.e. a genuine discrimination gain, not confidence inflation. 0.35-0.45
|
||
# is a broad plateau; 0.45 is best on top-1/MRR.
|
||
MATCH_CORR_WEIGHT = 0.45 # MAE weight is (1 - MATCH_CORR_WEIGHT), computed inline
|
||
MATCH_MAE_SCALE = 100.0 # half-saturation point of the MAE score curve
|
||
# Scale-invariant MAE (5c): the raw MAE is expressed relative to the current
|
||
# cycle's peak power before scoring, so the same *proportional* error yields the
|
||
# same confidence on a 200 W dishwasher and a 2000 W dryer. Calibrated to be
|
||
# behaviour-neutral at MATCH_MAE_REF_PEAK: at that peak scaled_mae == raw mae, so
|
||
# existing thresholds keep their meaning. The current cycle's peak is common to
|
||
# every candidate in a match, so this does not change candidate ranking.
|
||
MATCH_MAE_REF_PEAK = 1000.0 # peak (W) at which scoring matches the legacy formula
|
||
MATCH_MAE_PEAK_FLOOR = 50.0 # floor so tiny/idle traces don't explode the ratio
|
||
MATCH_KEEP_MIN_SCORE = 0.1 # candidates scoring below this are discarded
|
||
# DTW refinement (Stage 3): blended = DTW_BLEND*core + (1-DTW_BLEND)*dtw_score,
|
||
# dtw_score = DIST_SCALE / (DIST_SCALE + scaled_dtw_distance).
|
||
MATCH_DTW_BLEND = 0.5
|
||
MATCH_DTW_DIST_SCALE = 50.0
|
||
MATCH_DTW_REFINE_TOP_N = 5 # DTW is applied to this many top candidates
|
||
# (5 tuned via dtw_ab_eval: rescues correct
|
||
# profiles Stage-2 ranked 4th-5th; +1.8pp)
|
||
# Stage-3 DTW modes (config key "dtw_mode"):
|
||
# "legacy" - original: raw sequences, distance / len(current), fixed 50 W scale.
|
||
# "scaled" - both sequences resampled to MATCH_DTW_RESAMPLE_N and the distance
|
||
# expressed relative to the current peak (behaviour-neutral at
|
||
# MATCH_MAE_REF_PEAK), matching the Stage-2 MAE treatment. Default.
|
||
# "ddtw" - like "scaled" but warps on the first derivative (slope) of the
|
||
# curves, so alignment is driven by shape rather than absolute level.
|
||
# "ensemble" - blend of "scaled" and "ddtw": ENSEMBLE_W*L1 + (1-W)*DDTW.
|
||
# Defaults tuned via devtools/dtw_ab_eval.py on cycle_data/ (leave-one-out top-1):
|
||
# off 62.4%, legacy 66.4%, scaled 69.9%, ddtw 69.0%, ensemble(w=0.7,dd=30) 70.7%.
|
||
DEFAULT_DTW_MODE = "ensemble"
|
||
MATCH_DTW_RESAMPLE_N = 200 # common grid length for "scaled"/"ddtw" DTW
|
||
MATCH_DDTW_DIST_SCALE = 30.0 # half-saturation for derivative-DTW distance
|
||
MATCH_DTW_ENSEMBLE_W = 0.7 # weight on L1 vs DDTW in "ensemble" mode
|
||
# Ambiguity: top1-top2 score gap below this flags the match as ambiguous.
|
||
MATCH_AMBIGUITY_MARGIN = 0.05
|
||
# Smart Termination landscape guard: when a non-winning candidate is at least this
|
||
# much longer than the matched profile AND has a decent shape score (before Stage-4
|
||
# duration penalty), the current trace may be a *prefix* of that longer program
|
||
# rather than a completed short one. Smart Termination is blocked; the power-based
|
||
# fallback timeout decides instead. Ratio chosen so that programmes within ~50% of
|
||
# each other (e.g. Quick 46 min vs Eco 60 min, ratio 1.30) do not trigger the guard
|
||
# but genuine prefix pairs like Quick 46 vs Normal 88 min (ratio 1.91) always do.
|
||
SMART_TERM_LANDSCAPE_RATIO = 1.5 # candidate must be >= 1.5× the matched duration
|
||
SMART_TERM_LANDSCAPE_MIN_SHAPE = 0.40 # minimum shape score (pre-Stage-4) to qualify
|
||
|
||
# Number of points in the compact reference-profile curve exposed on the
|
||
# `_program` sensor (`profile_store.reference_curve`). Chosen so the resulting
|
||
# `[[offset_s, watts], ...]` attribute stays comfortably under ~1 KB regardless
|
||
# of cycle length; the raw envelope can be hundreds to thousands of points.
|
||
REFERENCE_PROFILE_CURVE_POINTS = 50
|
||
# Duration + energy agreement blended into the final score. Shape correlation
|
||
# alone cannot separate profiles that differ mainly in duration/energy (a real
|
||
# weakness on multi-program washing machines), so the final score is
|
||
# (1 - dur_w - en_w)*shape + dur_w*dur_agreement + en_w*energy_agreement, where
|
||
# agreement = 1/(1 + |ln(observed/expected)| / scale) is 1.0 on a perfect match.
|
||
# Weights 0.22 and scales tuned via devtools/dtw_ab_eval.py (weight x scale grid):
|
||
# a SHARPER agreement scale (halved) plus a moderately higher weight separates
|
||
# near-duplicate profiles on the same device rather than inflating confidence.
|
||
# This lifted the recall/FP net 13.7%->17.4% with the false-positive rate
|
||
# actually DROPPING (62.7%->59.9%). Raising weight alone at the old loose scale
|
||
# inflated both recall and FP (net-negative), so both knobs move together.
|
||
MATCH_DURATION_WEIGHT = 0.22
|
||
# Despite the name, "energy" here means mean power (W), not Wh — the Stage-4
|
||
# agreement term compares cur_energy=mean(curr_arr) vs profile_mean_power.
|
||
MATCH_ENERGY_WEIGHT = 0.22
|
||
MATCH_DURATION_SCALE = 0.175 # ~ln ratio at which duration agreement halves
|
||
MATCH_ENERGY_SCALE = 0.25 # ~ln ratio at which energy agreement halves
|
||
|
||
# States
|
||
STATE_OFF = "off"
|
||
STATE_DELAY_WAIT = "delay_wait"
|
||
STATE_IDLE = "idle"
|
||
STATE_STARTING = "starting"
|
||
STATE_RUNNING = "running"
|
||
STATE_PAUSED = "paused"
|
||
STATE_USER_PAUSED = "user_paused"
|
||
STATE_ENDING = "ending"
|
||
STATE_FINISHED = "finished"
|
||
STATE_ANTI_WRINKLE = "anti_wrinkle"
|
||
STATE_INTERRUPTED = "interrupted"
|
||
STATE_FORCE_STOPPED = "force_stopped"
|
||
STATE_RINSE = "rinse"
|
||
STATE_UNKNOWN = "unknown"
|
||
STATE_CLEAN = "clean" # Cycle ended but door not yet opened (laundry still inside)
|
||
|
||
# Authoritative state -> display color map. Single source of truth for the
|
||
# full-screen panel (and any other frontend), surfaced over the WebSocket
|
||
# get_constants command so colors are defined in exactly one place. Values are
|
||
# CSS colors using Home Assistant theme variables with a hex fallback, so they
|
||
# adapt to the active theme. The "recording" key covers the manual recorder state.
|
||
STATE_COLORS = {
|
||
STATE_OFF: "var(--state-inactive-color, #9e9e9e)",
|
||
STATE_IDLE: "var(--state-inactive-color, #9e9e9e)",
|
||
STATE_DELAY_WAIT: "var(--secondary-text-color, #757575)",
|
||
STATE_STARTING: "var(--warning-color, #ff9800)",
|
||
STATE_RUNNING: "var(--success-color, #4caf50)",
|
||
STATE_PAUSED: "var(--warning-color, #ff9800)",
|
||
STATE_USER_PAUSED: "var(--warning-color, #ff9800)",
|
||
STATE_ENDING: "var(--info-color, #2196f3)",
|
||
STATE_FINISHED: "var(--success-color, #4caf50)",
|
||
STATE_ANTI_WRINKLE: "var(--info-color, #2196f3)",
|
||
STATE_INTERRUPTED: "var(--error-color, #f44336)",
|
||
STATE_FORCE_STOPPED: "var(--error-color, #f44336)",
|
||
STATE_RINSE: "var(--info-color, #2196f3)",
|
||
STATE_CLEAN: "var(--teal-color, #009688)",
|
||
STATE_UNKNOWN: "var(--disabled-color, #bdbdbd)",
|
||
"recording": "var(--error-color, #f44336)",
|
||
}
|
||
|
||
# Device Types
|
||
DEVICE_TYPE_WASHING_MACHINE = "washing_machine"
|
||
DEVICE_TYPE_DRYER = "dryer"
|
||
DEVICE_TYPE_WASHER_DRYER = "washer_dryer"
|
||
DEVICE_TYPE_DISHWASHER = "dishwasher"
|
||
DEVICE_TYPE_AIR_FRYER = "air_fryer"
|
||
DEVICE_TYPE_BREAD_MAKER = "bread_maker"
|
||
DEVICE_TYPE_PUMP = "pump"
|
||
# Full-featured generic type for predictable appliances that don't fit any of the
|
||
# named categories. Participates in profile matching/learning like any other
|
||
# device type. Ships with neutral/safe defaults; the user tunes from there.
|
||
DEVICE_TYPE_GENERIC = "generic"
|
||
# Threshold-only bucket. No profile matching. Ships intentionally generic
|
||
# defaults; the user must configure thresholds and timeouts themselves.
|
||
# Config entries whose stored device_type is no longer supported are migrated
|
||
# to this bucket on load (see __init__.py), preserving their tuned options.
|
||
DEVICE_TYPE_OTHER = "other"
|
||
|
||
DEVICE_TYPES = {
|
||
DEVICE_TYPE_WASHING_MACHINE: "Washing Machine",
|
||
DEVICE_TYPE_DRYER: "Dryer",
|
||
DEVICE_TYPE_WASHER_DRYER: "Washer-Dryer Combo",
|
||
DEVICE_TYPE_DISHWASHER: "Dishwasher",
|
||
DEVICE_TYPE_AIR_FRYER: "Air Fryer",
|
||
DEVICE_TYPE_BREAD_MAKER: "Bread Maker",
|
||
DEVICE_TYPE_PUMP: "Pump / Sump Pump",
|
||
DEVICE_TYPE_GENERIC: "Other (Advanced)",
|
||
DEVICE_TYPE_OTHER: "Threshold Device",
|
||
}
|
||
|
||
# Standby-band stuck-in-RUNNING finalize (#296). Some appliances finish but hold
|
||
# a small, flat "anti-crease" / display standby draw that sits ABOVE stop_threshold_w
|
||
# (e.g. a ~2.5-3.2 W baseline while stop_threshold is ~1.2 W). Because power never
|
||
# drops below the stop threshold, _time_below_threshold never accumulates, so the
|
||
# cycle never reaches PAUSED/ENDING and runs until the 8 h RUNNING cap — and the
|
||
# anti-wrinkle handler can't help because entering it requires a completed
|
||
# TIMEOUT/SMART finish that never happens (chicken-and-egg). This detector spots a
|
||
# sustained, FLAT, tiny-fraction-of-peak plateau *past* the expected duration and
|
||
# finalizes the cycle (as a normal TIMEOUT completion, so an anti-wrinkle-enabled
|
||
# washer/dryer still routes into ANTI_WRINKLE afterwards). Heavily gated so it can
|
||
# never end an active low-power phase: it needs a matched profile, elapsed >= 2x
|
||
# expected, and the whole recent window flat + below a small fraction of the cycle's
|
||
# own peak. Restricted to wet appliances where a stuck baseline is unambiguously
|
||
# anomalous (bread-maker keep-warm, pump, air-fryer etc. have legitimate holds and
|
||
# are excluded).
|
||
STANDBY_BAND_FINALIZE_DEVICE_TYPES = (
|
||
DEVICE_TYPE_WASHING_MACHINE,
|
||
DEVICE_TYPE_WASHER_DRYER,
|
||
DEVICE_TYPE_DRYER,
|
||
)
|
||
STANDBY_BAND_MIN_RATIO = 2.0 # only past 2x the expected duration
|
||
STANDBY_BAND_WINDOW_S = 600.0 # require a >=10 min flat plateau
|
||
STANDBY_BAND_MAX_FRACTION = 0.10 # plateau level <= 10% of the cycle's peak
|
||
STANDBY_BAND_FLATNESS_FRACTION = 0.03 # window (max-min) <= 3% of the cycle's peak
|
||
STANDBY_BAND_FLATNESS_FLOOR_W = 2.0 # absolute flatness floor for low-peak devices
|
||
|
||
# Issue #296 follow-up: anti-crease ("Knitterschutz") back-to-back handling.
|
||
#
|
||
# After a wash finishes, some machines (e.g. Miele) hold a low-power tumble tail -
|
||
# a constant baseline plus periodic sub-``anti_wrinkle_max_power`` bursts, NO
|
||
# heating - until the door is opened. To the power-off detector this looks like
|
||
# continued RUNNING (the bursts recur faster than off_delay and keep reviving the
|
||
# cycle out of ENDING), so the cycle never finalises into STATE_ANTI_WRINKLE - the
|
||
# state that is designed to absorb the tail and split off the next wash. If a
|
||
# second load is started before the door is opened, the whole sequence
|
||
# (wash -> tail -> wash -> tail) merges into one multi-hour "cycle".
|
||
#
|
||
# Two coordinated mechanisms fix it, BOTH opt-in via ``anti_wrinkle_enabled`` and
|
||
# gated to the anti-wrinkle device types (see ``anti_wrinkle_active`` in
|
||
# cycle_detector):
|
||
#
|
||
# * a proactive finalise (``_is_anticrease_tail`` -> Smart Termination into
|
||
# ANTI_WRINKLE): once a *matched* cycle is past its expected duration AND the
|
||
# recent window shows only the low-power tail, finalise without waiting to reach
|
||
# ENDING through the burst-defeated off_delay path;
|
||
# * a match freeze (``_try_profile_match`` guard) under the SAME condition, so
|
||
# re-matching on the growing flat tail cannot drift the label to a longer
|
||
# near-duplicate profile (which would push expected_duration out and break the
|
||
# finalise gate) - the field failure that breaks Smart Termination.
|
||
#
|
||
# Gated on ``elapsed >= expected * ratio`` so a legitimate mid-wash low-power phase
|
||
# can NEVER trigger it: a washer spends most of its cycle below
|
||
# ``anti_wrinkle_max_power`` (only brief heating spikes exceed it), but every
|
||
# observed clean cycle's mid-cycle sub-max_power gap ENDS well before its expected
|
||
# duration, while the anti-crease tail BEGINS after it. Also requires a genuinely
|
||
# energetic cycle (peak above ``anti_wrinkle_max_power``) so a low-power program
|
||
# that never heats is left alone. Asymmetric (finalise-only, can only shorten the
|
||
# wait) and self-correcting (a new wash's heating burst leaves the regime and
|
||
# re-arms matching).
|
||
ANTI_CREASE_FINALIZE_RATIO = 0.98 # elapsed must reach 98% of expected duration
|
||
ANTI_CREASE_CONFIRM_WINDOW_S = 180.0 # recent window that must hold no reading > max_power
|
||
|
||
# Device Type Defaults
|
||
# Device Type Defaults (Maps)
|
||
|
||
DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT_BY_DEVICE = {
|
||
DEVICE_TYPE_DISHWASHER: 14400, # 4 hours (Drying can be long)
|
||
DEVICE_TYPE_BREAD_MAKER: 7200, # 2 hours (Proving/Rising is very low-power for extended periods)
|
||
DEVICE_TYPE_PUMP: DEFAULT_PUMP_STUCK_DURATION + 60, # Must exceed stuck-alarm threshold so the alarm fires before the watchdog
|
||
}
|
||
|
||
DEFAULT_MAX_DEFERRAL_SECONDS = 14400 # 4 hours max safe deferral
|
||
|
||
# Issue #43: dishwasher end-of-cycle pump-out handling.
|
||
#
|
||
# A dishwasher's wash→drying drain wind-down produces brief power spikes mid
|
||
# ENDING that, prior to the issue #43 fix, would set _end_spike_seen=True and
|
||
# pre-arm Smart Termination - so the cycle closed at 99% of expected, BEFORE
|
||
# the real end-of-cycle pump-out at ~99.5% of expected. The pump-out then
|
||
# registered as a brand-new cycle.
|
||
#
|
||
# Two coordinated thresholds gate the fix. They MUST agree: the wait window
|
||
# in _should_defer_finish (DISHWASHER_END_SPIKE_WAIT_SECONDS) is the upper
|
||
# bound for keeping the cycle open without an end spike, and Smart
|
||
# Termination's own wait branch in STATE_ENDING uses the SAME constant so the
|
||
# two paths release the cycle at the same moment.
|
||
#
|
||
# A spike at < DISHWASHER_END_SPIKE_MIN_PROGRESS of expected duration is
|
||
# ignored for end-spike tracking (the cycle still stays in ENDING via the
|
||
# existing long_ending_tail path - this only governs the smart-termination
|
||
# pre-arming).
|
||
DISHWASHER_END_SPIKE_MIN_PROGRESS = 0.85
|
||
# Widened from 300s to 1800s after issue #43 follow-up:
|
||
# real-world user reports showed Smart Termination misfiring ~4 min before the
|
||
# end-of-cycle pump-out, and the original 5-min escape hatch wasn't generous
|
||
# enough to cover that gap before the next reading arrived. 30 min is plenty
|
||
# to capture even the latest pump-outs while still guaranteeing the cycle
|
||
# closes eventually for dishwashers that have no pump-out at all.
|
||
DISHWASHER_END_SPIKE_WAIT_SECONDS = 1800.0
|
||
# Minimum reasonable dishwasher cycle duration (seconds). Even the shortest
|
||
# quick programmes take at least 30 min; defer _should_defer_finish for any
|
||
# dishwasher whose cycle has not yet crossed this floor, regardless of whether
|
||
# a profile match is available yet.
|
||
DISHWASHER_MIN_CYCLE_DURATION_S = 1800.0
|
||
# Once a dishwasher is in ENDING and power has been sustained-quiet for this
|
||
# long, the active cycle is over - only the passive drain/dry tail remains.
|
||
# Live re-matching is frozen past this point: continuing to re-match on the
|
||
# ever-growing idle tail inflates the observed duration and drifts the Stage-4
|
||
# duration-agreement score toward LONGER near-duplicate profiles, which would
|
||
# flip the stored label and stall smart-termination on the ambiguity gate.
|
||
# The active-phase match is complete
|
||
# by now, so freezing it preserves the correct program identity. A real
|
||
# resume (mid-cycle soak) sends a high reading that leaves ENDING and re-arms
|
||
# matching, so this is self-correcting.
|
||
DISHWASHER_MATCH_FREEZE_QUIET_SECONDS = 300.0
|
||
# Release the end-of-cycle pump-out wait early once a dishwasher has BOTH reached
|
||
# its expected duration AND been sustained-quiet this long afterwards. This lets a
|
||
# cycle that ran slightly shorter than the profile's (drifted-up) average - and whose
|
||
# terminal pump-out landed before the drop into ENDING, so no in-ENDING end-spike ever
|
||
# armed - finalise near its expected end instead of hanging the full
|
||
# DISHWASHER_END_SPIKE_WAIT_SECONDS (30 min) past expected. Gated on reaching the
|
||
# expected duration so a long passive-drying phase that still precedes a genuinely-late
|
||
# pump-out (quiet from ~50%-99% of expected) keeps waiting and its real pump-out is
|
||
# caught by the end-spike arm first. Smaller than the 30-min window but large enough
|
||
# to confirm a terminal tail rather than an inter-phase gap.
|
||
DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS = 600.0
|
||
|
||
# Confirmation window a dishwasher must spend in ENDING before Smart Termination
|
||
# fires. This is deliberately a FIXED constant and NOT derived from off_delay:
|
||
# off_delay must be large (up to ~30 min) to bridge a dishwasher's long passive
|
||
# drying "pause" so a single cycle is not split by the fallback timeout, but that
|
||
# large value must NOT delay Smart Termination - which ends the cycle near the
|
||
# matched profile's expected duration so the finish notification is timely. A
|
||
# previous formula (max(300, off_delay*0.25)) coupled the two: a suggested
|
||
# off_delay of 1800-1999 s inflated this window to 450-500 s, and on the sparsely
|
||
# sampled near-zero drying tail the eligibility instant could fall in a gap
|
||
# between samples, slipping the cycle's end by 20+ min or leaving it to only end
|
||
# via the fallback timeout (which snaps the trace back and drops the drying tail)
|
||
# or a manual stop. 300 s (the old floor, proven on a hand-tuned production
|
||
# dishwasher running off_delay=180) settles transient dips without starving the
|
||
# end. Smart Termination is independently gated on duration >= expected*ratio, so
|
||
# a shorter window can never fire it mid-cycle.
|
||
DISHWASHER_SMART_TERMINATION_DEBOUNCE_SECONDS = 300.0
|
||
|
||
# Sustained "true off" (power below stop_threshold_w) window that cancels a
|
||
# user-paused STARTING state back to OFF. A user pause during STARTING is held
|
||
# indefinitely (issue #306) waiting for Resume Cycle, but a genuinely paused
|
||
# appliance keeps standby power above the stop threshold; sustained power below it
|
||
# means the machine was switched off, so without this the detector could stay
|
||
# pinned in STARTING forever. Generous enough not to abort a real pause whose
|
||
# standby briefly dips (and well above the issue-#306 test's 10 s hold), while
|
||
# bounding the pinned-forever case.
|
||
STARTING_PAUSED_TRUE_OFF_TIMEOUT_SECONDS = 300.0
|
||
|
||
# Upper bound on the washer / washer-dryer Smart-Termination debounce, which is
|
||
# otherwise derived as max(180, min_off_gap * 0.5). At the shipped defaults this
|
||
# is 240 s (washing machine, min_off_gap 480) / 300 s (washer-dryer, min_off_gap
|
||
# 600) and the cap never bites. It only bounds the case where a suggested or
|
||
# hand-set min_off_gap (e.g. 1800 s) would inflate the quiet-time requirement to
|
||
# 15 min, starving end-detection for confident non-ambiguous matches the same way
|
||
# the old dishwasher off_delay*0.25 coupling did. 600 s leaves ample headroom for
|
||
# a washer's longest legitimate mid-cycle soak trough while capping the pathology.
|
||
WASHER_SMART_TERMINATION_DEBOUNCE_MAX_SECONDS = 600.0
|
||
|
||
DEFAULT_OFF_DELAY_BY_DEVICE = {
|
||
DEVICE_TYPE_DISHWASHER: 1800, # 30 min (Drying)
|
||
DEVICE_TYPE_BREAD_MAKER: 300, # 5 min (Keep-warm phase after baking)
|
||
DEVICE_TYPE_PUMP: 20, # 20 s (Pumps cut off sharply; no warm-down phase)
|
||
}
|
||
|
||
# Device-specific progress smoothing thresholds (percentage points)
|
||
# These control how much backward progress is allowed before heavy damping kicks in
|
||
DEVICE_SMOOTHING_THRESHOLDS = {
|
||
DEVICE_TYPE_WASHING_MACHINE: 5.0, # Can have repeating phases (rinse cycles)
|
||
DEVICE_TYPE_DRYER: 3.0, # More linear, less phase repetition
|
||
DEVICE_TYPE_WASHER_DRYER: 5.0, # Combined washer+dryer, use washer defaults
|
||
DEVICE_TYPE_DISHWASHER: 5.0, # Similar to washing machine with distinct phases
|
||
DEVICE_TYPE_AIR_FRYER: 2.0, # Constant load with sudden drop
|
||
DEVICE_TYPE_BREAD_MAKER: 5.0, # Large power swings between kneading, proving, baking
|
||
DEVICE_TYPE_PUMP: 2.0, # Binary on/off spikes; minimal smoothing needed
|
||
DEVICE_TYPE_GENERIC: 3.0, # Neutral middle ground for unknown appliance types
|
||
}
|
||
|
||
# Device specific completion thresholds (min run time to be considered a valid "completed" cycle)
|
||
DEVICE_COMPLETION_THRESHOLDS = {
|
||
DEVICE_TYPE_WASHING_MACHINE: 600, # 10 min
|
||
DEVICE_TYPE_DRYER: 600, # 10 min
|
||
DEVICE_TYPE_WASHER_DRYER: 600, # 10 min (same as washer)
|
||
DEVICE_TYPE_DISHWASHER: 900, # 15 min
|
||
DEVICE_TYPE_AIR_FRYER: 300, # 5 min minimum
|
||
DEVICE_TYPE_BREAD_MAKER: 1800, # 30 min (even express bread takes 30+ min)
|
||
DEVICE_TYPE_PUMP: 5, # 5 s - pump cycles can be under 30 seconds
|
||
}
|
||
|
||
# Default min_off_gap by device type (seconds)
|
||
# If gap between cycles is larger than this, force new cycle.
|
||
# If smaller, and we deemed previous as 'ended' but technically could be same,
|
||
# we might want to handle that (though strict state machine usually suffices if tuned well).
|
||
# Default min_off_gap by device type (seconds)
|
||
# Default min_off_gap by device type (seconds)
|
||
DEFAULT_MIN_OFF_GAP_BY_DEVICE = {
|
||
DEVICE_TYPE_WASHING_MACHINE: 480, # 8 min (Soak handling)
|
||
DEVICE_TYPE_DRYER: 300, # 5 min (Cool down gaps?)
|
||
DEVICE_TYPE_WASHER_DRYER: 600, # 10 min (longer for combined cycles)
|
||
DEVICE_TYPE_DISHWASHER: 3600, # 1 hour (Drying pauses)
|
||
DEVICE_TYPE_AIR_FRYER: 120, # 2 min (Shaking food)
|
||
DEVICE_TYPE_BREAD_MAKER: 600, # 10 min (Resting between knead/prove keeps same cycle together)
|
||
DEVICE_TYPE_PUMP: 60, # 1 min (Pumps can cycle every 3-5 min in heavy rain)
|
||
}
|
||
DEFAULT_MIN_OFF_GAP = 60 # Scalar fallback
|
||
|
||
# Default start energy threshold by device type (Wh)
|
||
# Filter noise spikes (1000W * 0.01s = 0.002Wh).
|
||
# Must be significant enough to imply mechanical work.
|
||
DEFAULT_START_ENERGY_THRESHOLDS_BY_DEVICE = {
|
||
DEVICE_TYPE_WASHING_MACHINE: 0.2, # ~50W for 15s or 200W for 3s
|
||
DEVICE_TYPE_DRYER: 0.5, # Heater kicks in hard
|
||
DEVICE_TYPE_WASHER_DRYER: 0.3, # Mix of washer and dryer
|
||
DEVICE_TYPE_DISHWASHER: 0.2, # Pump/Heater
|
||
DEVICE_TYPE_AIR_FRYER: 0.2, # Heater kicks in
|
||
DEVICE_TYPE_BREAD_MAKER: 0.2, # Kneading motor starts (~200W for a few seconds)
|
||
DEVICE_TYPE_PUMP: 0.003, # ~100W motor for ~0.1 s is enough to confirm a pump cycle
|
||
}
|
||
# Default sampling interval by device type
|
||
DEFAULT_SAMPLING_INTERVAL_BY_DEVICE = {
|
||
# 2s captures the rapid 0<->150W motor/heater oscillation in wet appliances;
|
||
# the 30s global default discards those spikes and undersamples the cycle.
|
||
DEVICE_TYPE_WASHING_MACHINE: 2.0,
|
||
DEVICE_TYPE_WASHER_DRYER: 2.0,
|
||
DEVICE_TYPE_DISHWASHER: 2.0,
|
||
DEVICE_TYPE_PUMP: 10.0, # 10s - pump cycles can be <30 s; 30s default would miss them
|
||
}
|
||
|
||
# Default profile match min duration ratio by device type
|
||
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO_BY_DEVICE = {
|
||
DEVICE_TYPE_DISHWASHER: 0.10,
|
||
}
|
||
|
||
# Profile groups (Stage 5): the matcher only collapses a group into one
|
||
# aggregate candidate when its members' minimum pairwise shape similarity is at
|
||
# least this. Similarity is DTW/Sakoe-Chiba on peak-normalised envelopes, so it
|
||
# tolerates the duration (longer heating/draining) and amplitude (temp/spin)
|
||
# variation between real members. Looser groups stay individual (a blurry generic
|
||
# aggregate could out-match unrelated profiles) and are flagged in the UI.
|
||
# Calibrated on real profiles: genuine temp/spin variants score ~0.86-0.95,
|
||
# distinct programs <~0.6; 0.80 leaves margin below the 0.85 suggestion bar.
|
||
GROUP_MIN_COHESION = 0.80
|
||
|
||
# Storage
|
||
# v6: backfill ml_review.golden=True for manually-recorded cycles (recorded ==
|
||
# golden reference; a single flag, no duplicate "recorded" field).
|
||
# v7: re-run that backfill (broadened to the meta.original_samples marker) so
|
||
# installs already at v6 that carry unflagged recorded cycles are caught too —
|
||
# the v6 step only ran for installs upgrading from below v6.
|
||
# v8: re-run again after _is_recorded_cycle gained the structural fallback
|
||
# (completed + no max_power/termination_reason) so OLD recordings that carry
|
||
# only meta:None — which the marker-only v6/v7 backfill missed — are tagged.
|
||
# v9: pre-initialize additive top-level keys (lifetime_energy_wh,
|
||
# settings_changelog, maintenance_log) so they are present from first load
|
||
# rather than only appearing lazily on first use.
|
||
# v11 is a marker-only bump: per-phase profiles (envelope["phase_profile"]) are
|
||
# derived cache populated by async_rebuild_envelope, so no data migration is
|
||
# needed - they self-populate on the next envelope rebuild.
|
||
STORAGE_VERSION = 11
|
||
STORAGE_KEY = "ha_washdata"
|
||
|
||
# Notification events
|
||
EVENT_CYCLE_STARTED = "ha_washdata_cycle_started"
|
||
EVENT_CYCLE_ENDED = "ha_washdata_cycle_ended"
|
||
|
||
# Signals
|
||
SIGNAL_WASHER_UPDATE = "ha_washdata_update_{}"
|
||
|
||
# Learning & Feedback
|
||
|
||
SERVICE_SUBMIT_FEEDBACK = (
|
||
"ha_washdata.submit_cycle_feedback" # Service to submit feedback
|
||
)
|
||
|
||
# ─── Feature flags (staged rollout) ───────────────────────────────────────────
|
||
# These gate preproduction / ML features so they can be shipped dark and unlocked
|
||
# in stages. When a flag is False the corresponding UI *and* logic stay hidden:
|
||
# no panel sections render and no background work runs.
|
||
#
|
||
# SHOW_ML_LAB ML Lab comparison tab in the WashData panel.
|
||
# ENABLE_ML_SUGGESTIONS ML-model-driven setting suggestions (Stage 3), shown
|
||
# side-by-side with the classic statistical suggestions.
|
||
# ENABLE_ML_TRAINING On-device model training loop (Stage 4): scheduled
|
||
# retraining on the user's own labeled cycles.
|
||
#
|
||
# Stage 1 (new statistical suggestions) and Stage 2 (fixed classic algorithms)
|
||
# are always on - they only improve the existing suggestion engine and add no
|
||
# new surfaces, so they need no flag.
|
||
SHOW_ML_LAB = True
|
||
ENABLE_ML_SUGGESTIONS = True
|
||
ENABLE_ML_TRAINING = True
|
||
|
||
# ─── Community store (online features) ────────────────────────────────────────
|
||
# Opt-in browsing/importing/sharing of reference cycles via the WashData Store.
|
||
# When the option is off the Store tab and all network calls stay inert.
|
||
CONF_ENABLE_ONLINE_FEATURES = "enable_online_features" # master gate, default False
|
||
CONF_STORE_BRAND = "store_brand" # declared appliance brand
|
||
CONF_STORE_MODEL = "store_model" # declared appliance model
|
||
DEFAULT_ENABLE_ONLINE_FEATURES = False
|
||
|
||
# Device-level settings that may be shared/adopted with a device bundle (Stage 3).
|
||
# These are recognition/matching thresholds intrinsic to the appliance MODEL (the
|
||
# same for everyone with that machine), never environment/plug/identity settings:
|
||
# no entity ids, notify services, energy price, sampling cadence, smoothing,
|
||
# housekeeping timers, plug-robustness (end_repeat_count) or device-behaviour
|
||
# toggles (anti-wrinkle, delay-start). Kept as one editable allow-list so share and
|
||
# adopt agree on exactly what travels. All values are plain numbers -> nothing here
|
||
# can leak PII or a user's HA topology.
|
||
SHAREABLE_SETTING_KEYS: tuple[str, ...] = (
|
||
# Detection / recognition
|
||
CONF_MIN_POWER,
|
||
CONF_OFF_DELAY,
|
||
CONF_START_THRESHOLD_W,
|
||
CONF_STOP_THRESHOLD_W,
|
||
CONF_START_DURATION_THRESHOLD,
|
||
CONF_START_ENERGY_THRESHOLD,
|
||
CONF_COMPLETION_MIN_SECONDS,
|
||
CONF_MIN_OFF_GAP,
|
||
CONF_END_ENERGY_THRESHOLD,
|
||
CONF_POWER_OFF_THRESHOLD_W,
|
||
CONF_POWER_OFF_DELAY,
|
||
# Matching
|
||
CONF_PROFILE_MATCH_THRESHOLD,
|
||
CONF_PROFILE_UNMATCH_THRESHOLD,
|
||
CONF_PROFILE_MATCH_INTERVAL,
|
||
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
|
||
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
|
||
CONF_PROFILE_DURATION_TOLERANCE,
|
||
CONF_DURATION_TOLERANCE,
|
||
CONF_AUTO_LABEL_CONFIDENCE,
|
||
CONF_LEARNING_CONFIDENCE,
|
||
)
|
||
|
||
# Public Firebase web config for the community store (NOT secret - identifies the
|
||
# project; access is enforced by the store's Firestore rules).
|
||
STORE_PROJECT_ID = "washdata-store"
|
||
STORE_API_KEY = "AIzaSyDzq0MoWdU_21CSohZUhIIV7ZwfWppjcAk"
|
||
STORE_WEB_ORIGIN = "https://3dg1luk43.github.io/washdata-store"
|
||
|
||
# Reference-cycle trace format versions this integration can import.
|
||
SUPPORTED_CYCLE_SCHEMA_VERSIONS = {1}
|
||
|
||
# Obfuscated provenance codes stamped on an uploaded cycle (see store.derive_qc).
|
||
QC_RECORDING = 1 # pure recorder capture
|
||
QC_EDITED = 2 # trimmed/edited from a detected cycle
|
||
QC_MANUAL = 3 # a plain detected cycle flagged golden by hand
|
||
|
||
# ─── On-device ML training (Stage 4) ──────────────────────────────────────────
|
||
# Config keys for the scheduled, opt-in retraining loop. All gated behind
|
||
# ENABLE_ML_TRAINING; nothing runs and no options render when that flag is False.
|
||
CONF_ML_TRAINING_ENABLED = "ml_training_enabled" # per-device opt-in
|
||
CONF_ML_TRAINING_HOUR = "ml_training_hour" # local hour (0-23) to train
|
||
CONF_ML_TRAINING_MIN_CYCLES = "ml_training_min_cycles" # min labelled clean cycles before training
|
||
CONF_ML_TRAINING_INTERVAL_DAYS = "ml_training_interval_days" # min days between retrains
|
||
|
||
DEFAULT_ML_TRAINING_ENABLED = False
|
||
DEFAULT_ML_TRAINING_HOUR = 2 # 02:00 local - quiet hour
|
||
DEFAULT_ML_TRAINING_MIN_CYCLES = 30 # need a meaningful corpus first
|
||
DEFAULT_ML_TRAINING_INTERVAL_DAYS = 7 # retrain at most weekly
|
||
|
||
# A newly trained model is only promoted over the shipped baseline when its
|
||
# held-out AUC is at least (baseline AUC - this margin). Small negative slack is
|
||
# allowed so personalisation can win even at a tiny AUC cost.
|
||
ML_TRAINING_AUC_MARGIN = 0.02
|
||
# Separate tolerance for the calibration gate: a retrained classifier must not
|
||
# degrade balanced accuracy AT the live operating cutoff by more than this. Kept
|
||
# distinct from ML_TRAINING_AUC_MARGIN because it bounds a different metric (decision
|
||
# quality at a fixed threshold, not overall rank quality); same 0.02 default today.
|
||
ML_TRAINING_BACC_MARGIN = 0.02
|
||
ML_TRAINING_MIN_POSITIVES = 20 # need at least this many positive examples to trust a fit
|
||
|
||
# Per-capability held-out-score history kept across training runs, so the panel
|
||
# can show whether a model's fit is improving, steady, or declining over time
|
||
# (drift). Compact (one number per capability per run); this caps how many runs
|
||
# are retained.
|
||
ML_TRAINING_HISTORY_MAX = 30
|
||
|
||
# Remaining-time regressor (standardized_linear). Unlike the classifier heads it
|
||
# has no shipped baseline; it is only promoted when its held-out mean-absolute
|
||
# error on the completion-fraction target beats the naive elapsed/expected
|
||
# estimate by at least this relative margin (5% lower MAE). Trained from prefixes
|
||
# of the device's own clean cycles.
|
||
ML_TRAINING_REGRESSION_MARGIN = 0.05
|
||
ML_TRAINING_MIN_REGRESSION_ROWS = 30 # synthesized prefix rows needed to fit
|
||
# How strongly a promoted remaining-time regressor influences the live progress
|
||
# estimate. The ML completion-fraction is blended with the phase-aware estimate
|
||
# at this weight before the existing EMA smoothing/monotonicity guards run, so a
|
||
# bad model can never wholly override the proven phase estimator.
|
||
ML_PROGRESS_BLEND_WEIGHT = 0.5
|
||
|
||
# Service + event names for the training loop.
|
||
SERVICE_TRIGGER_ML_TRAINING = "trigger_ml_training"
|
||
EVENT_ML_TRAINING_COMPLETE = "ha_washdata_ml_training_complete"
|
||
|
||
# ─── Suggestion quality gates ──────────────────────────────────────────────────
|
||
# A suggestion is only stored / surfaced when it clears both thresholds:
|
||
# (a) relative delta >= MIN_SUGGESTION_REL_DELTA OR
|
||
# absolute delta >= per-key absolute minimum (see _suggestion_min_abs_delta)
|
||
# Suggestions that are below BOTH thresholds are deleted so they don't clutter
|
||
# the panel with noise (e.g. 0.67 → 0.68).
|
||
MIN_SUGGESTION_REL_DELTA = 0.08 # 8% minimum relative change
|
||
|
||
# After the user applies suggestions, suppress new suggestions for this many
|
||
# completed cycles. Prevents the engine from immediately re-suggesting
|
||
# slightly-different values based on a single new cycle.
|
||
MIN_SUGGESTION_COOLDOWN_CYCLES = 3
|
||
|
||
# ─── Appliance health & predictive maintenance (Group E) ───────────────────────
|
||
# Per-device maintenance-reminder thresholds: a dict {event_type: cycle_threshold}
|
||
# persisted via ws_set_options. When the number of completed cycles since the most
|
||
# recent maintenance event of a given type reaches its threshold, the event type is
|
||
# surfaced (sensor attribute + panel banner). A threshold of 0 (or an absent key)
|
||
# disables reminders for that event type.
|
||
CONF_MAINTENANCE_REMINDER_CYCLES = "maintenance_reminder_cycles"
|
||
DEFAULT_MAINTENANCE_REMINDER_CYCLES = {
|
||
"descale": 30,
|
||
"filter_clean": 50,
|
||
"drum_clean": 100,
|
||
}
|
||
# Recognised maintenance event types. bearing_service / other default off (absent
|
||
# from the default reminder dict) and are opt-in.
|
||
MAINTENANCE_EVENT_TYPES = (
|
||
"descale",
|
||
"filter_clean",
|
||
"drum_clean",
|
||
"bearing_service",
|
||
"other",
|
||
)
|
||
# A logged maintenance event of a matching type within this many days suppresses
|
||
# the "needs maintenance" nag advisory (duration-trend / shape-drift).
|
||
MAINTENANCE_RECENT_SUPPRESS_DAYS = 30
|
||
|
||
# ─── Playground stress-tail constants (never used by the live integration) ─────
|
||
# These govern the synthetic idle continuation in the "Test idle termination"
|
||
# Playground toggle. All times are in seconds.
|
||
PLAYGROUND_STRESS_TRAILING_WINDOW_S: float = 60.0 # window for idle-floor derivation
|
||
PLAYGROUND_STRESS_FLOOR_PERCENTILE: float = 0.07 # p7 of window readings = standby floor
|
||
PLAYGROUND_STRESS_FLUCT_FALLBACK_FRAC: float = 0.12 # ±12% fallback when window is flat
|
||
PLAYGROUND_STRESS_DENSE_STEP_S: float = 30.0 # dense pre-fill cadence
|
||
PLAYGROUND_STRESS_DENSE_DURATION_S: float = 1200.0 # dense pre-fill length (20 min)
|
||
PLAYGROUND_STRESS_SPARSE_STEP_S: float = 1800.0 # sparse main step (30 min)
|
||
PLAYGROUND_STRESS_MAX_SPARSE_STEPS: int = 15 # max sparse steps → max 7.5 h extra
|
||
PLAYGROUND_STRESS_MAX_IDLE_W: float = 100000.0 # upper bound for a manual idle override
|
||
# (far beyond any appliance; guards against
|
||
# inf/absurd values corrupting synthesis)
|