2047 lines
122 KiB
Python
2047 lines
122 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."""
|
||
|
||
import math
|
||
from enum import StrEnum
|
||
from typing import Any
|
||
|
||
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"
|
||
# Bucket size (minutes) for the per-profile `power_profile` sensor attribute that
|
||
# external planners (EMHASS, tibber_prices) consume. Configurable so short sharp
|
||
# spikes are not blurred by the fixed 15-min default (#367). Read-time only.
|
||
CONF_POWER_PROFILE_INTERVAL_MIN = "power_profile_interval_min"
|
||
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" # Removed in 0.5.8: never read; key kept for old migrations
|
||
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_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" # Removed in 0.5.8: the detector never read it; stored values are ignored
|
||
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
|
||
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: quiet level in anti-wrinkle, floored at stop_threshold_w
|
||
CONF_ANTI_WRINKLE_IDLE_TIMEOUT = "anti_wrinkle_idle_timeout" # Seconds below exit power before anti-wrinkle ends
|
||
CONF_DISHWASHER_END_SPIKE_QUIET_RELEASE = "dishwasher_end_spike_quiet_release" # Dishwasher: sustained-quiet seconds after expected duration that release the end-of-cycle drain wait early (#379)
|
||
CONF_SMART_TERMINATION_DURATION_RATIO = "smart_termination_duration_ratio" # Fraction of the matched profile's expected (mean) duration that Smart Termination requires before it may fire (#393)
|
||
CONF_ANTI_CREASE_FINALIZE_RATIO = "anti_crease_finalize_ratio" # Fraction of the matched profile's expected (mean) duration the anti-crease finalise requires before it may fire (#429)
|
||
CONF_CURVE_PREROLL_SECONDS = "curve_preroll_seconds" # How far back readings from aborted start probes may be carried into a committed cycle's curve; 0 = off (#430)
|
||
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"
|
||
# Per-device accent colour (#454). One setting, three companion-app keys: `color`
|
||
# (Android notification accent), `notification_icon_color` (iOS icon glyph tint) and
|
||
# `progress_bar_color` (iOS Live Activity bar). Mobile-only, blank = platform default.
|
||
CONF_NOTIFY_ICON_COLOR = "notify_icon_color"
|
||
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"
|
||
# Opt-in data keys on the EXISTING live progress notification (#347): keep-on-tap
|
||
# (sticky) and a tap target (clickAction). Not new notification types. Mobile-only.
|
||
CONF_NOTIFY_LIVE_STICKY = "notify_live_sticky"
|
||
CONF_NOTIFY_LIVE_CLICK_ACTION = "notify_live_click_action"
|
||
# Silent recurring live updates (#417). iOS alerts on every Live Activity refresh
|
||
# unless the update is marked silent, so a 10-minute live interval buzzes the phone
|
||
# all cycle long. Mobile-only, and never applied to the update that STARTS the
|
||
# activity - that one stays audible (and `silent` has no effect there anyway).
|
||
CONF_NOTIFY_LIVE_SILENT = "notify_live_silent"
|
||
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"
|
||
# Dynamic (time-weighted) pricing (#426). With a price *entity* configured, the
|
||
# cost of a cycle is integrated against the price in force at each moment instead
|
||
# of freezing the single price that happened to be current when the cycle ended.
|
||
# Only meaningful for a price entity - a static price has no time dimension, so
|
||
# this is a no-op there and the flag is never consulted. Default on: the frozen
|
||
# end-of-cycle price is simply wrong for a tariff that moves during the cycle,
|
||
# and cost is display-only (no detection, matching or ML input depends on it).
|
||
CONF_ENERGY_PRICE_DYNAMIC = "energy_price_dynamic"
|
||
DEFAULT_ENERGY_PRICE_DYNAMIC = True
|
||
# Cap on the stored per-cycle price timeline. Entries are deduplicated (a price
|
||
# that did not change adds nothing), so an hourly tariff needs a handful and this
|
||
# only bites on a template sensor that recomputes every few seconds. Past the cap
|
||
# the timeline is coarsened by dropping the smallest price steps, which keeps the
|
||
# cost figure within a rounding error of the uncapped one while bounding what a
|
||
# cycle adds to the JSON store.
|
||
PRICE_TIMELINE_MAX_POINTS = 240
|
||
# Prices are rounded to this many decimals before dedup. Currency-per-kWh figures
|
||
# are quoted to 4-5 decimals at most; the extra digit keeps sub-cent tariffs exact
|
||
# while collapsing the float noise a template sensor emits on every recompute.
|
||
PRICE_TIMELINE_PRICE_DECIMALS = 6
|
||
# 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
|
||
# Auto-open dishwashers (AirDry etc.) pop the door at cycle end; a sustained
|
||
# door-open then means the cycle finished, not a mid-cycle pause (#342).
|
||
CONF_DOOR_OPENS_AT_END = "door_opens_at_end"
|
||
CONF_DOOR_END_DWELL_SECONDS = "door_end_dwell_seconds"
|
||
DEFAULT_DOOR_OPENS_AT_END = False
|
||
DEFAULT_DOOR_END_DWELL_SECONDS = 60 # door must stay open this long to finalize
|
||
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
|
||
# Repeat the unload reminder every delay-minutes until the door opens or the user
|
||
# taps the notification's "stop reminding" action (opt-in, #374).
|
||
CONF_NOTIFY_UNLOAD_REPEAT = "notify_unload_repeat"
|
||
|
||
# Unload confirmation without a door sensor (#451). Not every machine can have a
|
||
# contact sensor on its door (rented flat, steel door, no approval), and the Clean
|
||
# state - plus the unload reminder that hangs off it - was reachable only through
|
||
# one. Two additions give the same "the load has been taken out" signal:
|
||
# - an entity whose activation means unloaded (a Zigbee button as event.* /
|
||
# sensor.*, an input_button helper, a motion sensor, a scene), and
|
||
# - a plain opt-in for the Mark Unloaded button / ha_washdata.mark_unloaded
|
||
# service, for a setup that confirms from its own automation.
|
||
# Either one on its own also enables the Clean state on a device with no door
|
||
# sensor; with neither set, behaviour is exactly as before.
|
||
CONF_UNLOAD_CONFIRM_ENTITY = "unload_confirm_entity"
|
||
CONF_UNLOAD_TRACK_WITHOUT_DOOR = "unload_track_without_door"
|
||
DEFAULT_UNLOAD_TRACK_WITHOUT_DOOR = False
|
||
|
||
# How long after the confirmation entity is first seen - or comes back from
|
||
# unavailable/unknown - a transition out of `unknown` is still treated as a replay
|
||
# rather than a press (register items 367, 368).
|
||
#
|
||
# A fresh `event.*` / `button.*` / `input_button.*` sits at `unknown` until it is
|
||
# first pressed, so excluding `unknown -> value` outright swallowed the first ever
|
||
# press. Accepting it outright is not safe either: a z2m action sensor publishes
|
||
# its action as a RETAINED MQTT message, which the broker replays on reconnect,
|
||
# and that arrives as exactly the same transition. A restart is already covered by
|
||
# the separate `old_state is None` guard, so this window only has to cover the gap
|
||
# between our subscription and a late-arriving retained value.
|
||
#
|
||
# Measured against the reference point rather than guessed: the replay lands within
|
||
# seconds of the MQTT connection, and the only cost of the window is a genuine press
|
||
# in the first two minutes after the entity appears - which is nearly always
|
||
# harmless, because `mark_unloaded` is a no-op unless a Clean state is waiting.
|
||
#
|
||
# **The residual is real and deliberate.** An entity that sits at `unknown` for
|
||
# longer than this window and only then receives its first retained value is
|
||
# indistinguishable from a first press: a generic HA state change carries no
|
||
# "this was retained" marker, and the whole point of the option is that it accepts
|
||
# any entity the user already owns (event, button, input_button, binary_sensor,
|
||
# sensor, switch, scene, tag), most of which are not MQTT and have no reconnection
|
||
# signal to consult. The guards narrow it to that one shape; the cost of being
|
||
# wrong is a cleared unload reminder, never lost data.
|
||
UNLOAD_CONFIRM_REPLAY_GRACE_S = 120.0
|
||
|
||
# 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."
|
||
# "{duration} min", not "{duration}m": a voice assistant read "m" as metres (#93, #117).
|
||
# A template the user saved keeps its own text.
|
||
DEFAULT_NOTIFY_FINISH_MESSAGE = "{device} finished. Duration: {duration} min."
|
||
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_LIVE_STICKY = False # #347: off = today's behaviour (tap dismisses)
|
||
DEFAULT_NOTIFY_LIVE_CLICK_ACTION = "" # #347: empty = no tap target (today's behaviour)
|
||
# #417: on by default. A progress refresh is not an alert, and every other app with
|
||
# live progress updates silently; the audible per-update buzz was the complaint, not
|
||
# the feature. Turn it off to get a sound/vibration on every update again.
|
||
DEFAULT_NOTIFY_LIVE_SILENT = True
|
||
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} min ago - laundry is still inside."
|
||
DEFAULT_NOTIFY_UNLOAD_REPEAT = False # opt-in: re-send the unload reminder until dismissed (#374)
|
||
# Safety bound on repeat mode (#374). The reminder is meant to run "until the door
|
||
# opens", and the in-notification "Stop reminding" button is mobile_app-only - so a
|
||
# user whose only notify target is non-mobile has the door sensor as their sole
|
||
# escape. If that sensor never reports open (offline, or nobody home), the reminder
|
||
# would re-fire forever AND pin the entity in Clean state, blocking the power-based
|
||
# Off detection indefinitely. 48 reminders is ~2 days at the 60-minute default, far
|
||
# past any legitimate reminder window, so normal use never reaches it.
|
||
NOTIFY_UNLOAD_REPEAT_MAX_REMINDERS = 48
|
||
DEFAULT_PEAK_RATE_MESSAGE = "Running at peak rate ({price}/kWh)."
|
||
|
||
# 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_POWER_PROFILE_INTERVAL_MIN = 15 # power_profile attribute bucket (#367)
|
||
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).
|
||
# Stage-1 upper duration gate (register item 311). Raised 1.5 -> 1.8 because at
|
||
# 1.5 the gate deleted the TRUE candidate on 14 of 606 corpus folds (2.3%) - a
|
||
# cycle that legitimately overran its programme was refused the chance to match
|
||
# it at all. Measured against the item-307 kernel, which already penalises
|
||
# far-off durations smoothly so the hard gate has less to do: +0.66pp top-1,
|
||
# 4 devices better and 0 worse, cluster bootstrap [+0.15,+1.32] P(delta<=0)=0.017.
|
||
# Costs ~8% more candidates to score (4.10 -> 4.44 per match, 37 -> 38 ms).
|
||
# 2.5 measures slightly higher (+0.83pp) but regresses one device; 1.8 is the
|
||
# point at which nothing gets worse.
|
||
DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO = 1.8
|
||
# A cycle needs at least this many trace points before its ML health is scored
|
||
# (#459). It is the same floor `quality_features` uses before it falls back to a
|
||
# `has_trace = 0` row, which no model was trained on.
|
||
ML_HEALTH_MIN_TRACE_POINTS = 4
|
||
DEFAULT_WATCHDOG_INTERVAL = 30 # Floor; effective default is resolved per device
|
||
# A watchdog keepalive closing more than this many ticks was injected late (host
|
||
# suspend, loop stall, restart): the interval it closes is unobserved (item 391).
|
||
# On time it closes at most two (the first after a real reading), then one.
|
||
WATCHDOG_LATE_TICK_FACTOR = 2.5
|
||
# as max(this, 2*sampling_interval + 1) - see resolve_watchdog_interval_default (#396).
|
||
DEFAULT_MATCH_PERSISTENCE = 3
|
||
DEFAULT_END_REPEAT_COUNT = 1 # Removed setting (see CONF_END_REPEAT_COUNT)
|
||
|
||
# Share of the SHORTEST known profile that the match-interval suggestion is
|
||
# allowed to spend before a program can first be committed (#431). The
|
||
# suggestion used to be cadence-only (`median_dt * 10`), so a plug reporting
|
||
# every 60 s produced 599 s - longer than DEFAULT_PROFILE_MATCH_INTERVAL itself,
|
||
# which makes applying the suggestion strictly worse than never touching the
|
||
# setting. The budget is spent on `match_persistence` consecutive matches, so
|
||
# the cap is applied to `interval * persistence` rather than to the interval
|
||
# alone; otherwise raising persistence brings the problem straight back. At the
|
||
# default persistence of 3 this is exactly the "shortest / 20" rule the reporter
|
||
# proposed (3 / 20 = 0.15). It bounds only the *suggestion* - a hand-set
|
||
# interval is still whatever the user typed.
|
||
MATCH_INTERVAL_SUGGESTION_DECISION_FRAC = 0.15
|
||
|
||
# Absolute floor for the same suggestion, and the one case where the budget rule
|
||
# above does NOT hold: a very short program (below ~200 s at the default
|
||
# persistence) would cap the interval into a per-second poll, so the floor wins
|
||
# and the decision budget then exceeds the fraction. The suggestion says so
|
||
# rather than claiming a bound it did not apply. Matching is driven by incoming
|
||
# readings, so a 10 s interval on a 60 s-reporting plug still only matches per
|
||
# reading; the floor costs nothing there.
|
||
MATCH_INTERVAL_SUGGESTION_MIN_S = 10
|
||
|
||
# Issue #430: a cycle's curve begins at the start probe that finally COMMITS.
|
||
# Earlier probes that aborted as false starts take their readings with them, so
|
||
# on an appliance that probes repeatedly before settling (programme selection,
|
||
# door lock, first fill) the first 40-217 s of real activity - up to 17 readings
|
||
# on the reporter's dishwashers - is missing from the front of every curve. #403
|
||
# makes this more common, not less: the first high reading now earns no evidence
|
||
# toward either start gate, so a sparse change-only sensor aborts more probes.
|
||
#
|
||
# The detector keeps its own small ring buffer rather than reaching into the
|
||
# manager's diag_buffer: that one is manager-owned and records RAW readings
|
||
# before throttling, while the curve is built from throttled ones, so joining
|
||
# the two would splice two different sample populations into one curve.
|
||
#
|
||
# OFF BY DEFAULT, and it must stay that way. The stored duration is
|
||
# ``end_time - _current_cycle_start`` and matching resamples the stored curve, so
|
||
# the start pointer has to move back with the curve or the two disagree - which
|
||
# means avg_duration shifts. Cycles recorded before and after a change therefore
|
||
# carry different durations for the SAME program, widening the envelope and
|
||
# moving expected_duration (which arms Smart Termination x0.98 and the #429
|
||
# anti-crease ratio) until the old cycles age out of the retention cap.
|
||
DEFAULT_CURVE_PREROLL_SECONDS = 0.0 # 0 = off
|
||
# Upper bound on the option (the panel's number input offers the same maximum), so
|
||
# a mistyped value cannot drag minutes of unrelated standby into a curve. It is
|
||
# headroom, not a measurement: the probe runs above were 40-217 s.
|
||
CURVE_PREROLL_MAX_SECONDS = 600.0
|
||
# A quiet stretch longer than this ends the carry: it separates "the same start,
|
||
# probed twice" from "an unrelated blip earlier in the day". Deliberately a
|
||
# constant, not an option - it is a property of how appliances probe, and one
|
||
# more knob here is one more way to widen a curve by accident.
|
||
PREROLL_CHAIN_BREAK_SECONDS = 90.0
|
||
|
||
# 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
|
||
# Fraction of the matched programme's expected duration below which a confident
|
||
# match holds a fallback end (`CycleDetector._should_defer_finish`). Its OWN
|
||
# constant, not a user option: since Feb 2026 the detector was fed the matcher's
|
||
# Stage-1 `profile_match_min_duration_ratio` (0.10, or 0.05 once the suggestion was
|
||
# applied) through a field of the same name, so a confident match only held an end
|
||
# below 5-10% of expected - never in practice. Restoring 0.8 is the live fix for
|
||
# item 390's split: splits 1.37 -> 1.03% over 295 replayed cycles, early ends
|
||
# unchanged, dishwashers byte-identical (audit DETECT-02).
|
||
DEFAULT_DEFER_FINISH_RATIO = 0.8
|
||
|
||
# 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
|
||
|
||
# Cap on how far the RUNNING->PAUSED / PAUSED->ENDING gates may be stretched by
|
||
# the p95 sampling cadence (#424/#427). The gates are 3x a cadence estimate; p95
|
||
# is the 2nd-largest of the last 20 intervals, so a publish-on-change plug that
|
||
# falls silent at standby drives the estimate onto its own silence and the gates
|
||
# grow with it. ``CycleDetector._gate_cadence`` therefore clamps p95 to this
|
||
# multiple of the median interval. 5x is deliberately loose: it is above any
|
||
# plausible jitter ratio for a regularly-reporting sensor (whose median equals
|
||
# its p95, so the cap never binds and slow meters keep their wide gates) while
|
||
# still rejecting the isolated multi-minute holes that caused both reports.
|
||
# Caps the pause/end gate cadence at this multiple of the MEDIAN interval
|
||
# (items 213/215). Was 5.0; lowered to 2.0 on measurement (register item 300).
|
||
#
|
||
# The cap only ever binds when p95 >> median - a fast plug that went quiet -
|
||
# which is exactly the case it was introduced for. A uniformly slow meter has
|
||
# p95 ~= median, so its gate is set by p95 and this value is irrelevant to it:
|
||
# a 300 s-cadence meter keeps its 900 s gate at 2.0 exactly as at 5.0, so the
|
||
# original rationale is preserved intact. At 5.0 it was under-correcting the
|
||
# case it existed for: on #424's dishwasher (median 38.5 s, p95 970 s) it still
|
||
# yielded a 578 s pause gate, which was the single largest remaining component
|
||
# of that cycle's 17.9 min late finish.
|
||
#
|
||
# Measured over 246 clean cycles from the whole corpus at 5.0 vs 2.0: 16 cycles
|
||
# that never closed within their stored trace now close, ZERO went the other
|
||
# way, and exactly one stored duration changed - 302 s shorter, away from the
|
||
# un-evidenced expected-duration fallback it had been pinned to. #424 finishes
|
||
# 17.9 -> 10.8 min after the appliance; #427 is unaffected (its gate never
|
||
# reached the cap).
|
||
GATE_CADENCE_MEDIAN_FACTOR = 2.0
|
||
|
||
# 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. 5 until 0.5.8 (audit UI-26): eight programs
|
||
# meant up to 40 confirmations before the first auto-label; the label gates
|
||
# (margin, ambiguity, label_confidence) now carry what the extra prompts guarded.
|
||
CONF_PROFILE_MIN_WARMUP_CYCLES = 2 # labeled cycles before auto-labelling 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 (on for TERMINAL_DROP_DEFAULT_ON_DEVICE_TYPES, else
|
||
# gated on CONF_ENABLE_ML_MODELS; detector_config.terminal_drop_enabled decides
|
||
# for the manager and the Playground). 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
|
||
# Device types that get the terminal-drop finalize whatever the "Apply smart
|
||
# models" toggle says (audit ML-08). It is pure statistics, not a model, and only
|
||
# dishwashers gain from it: a plug pulled mid-wash closes in 3-4.5 min instead of
|
||
# waiting out 70-121 min (and being stored as completed); washers: 0 fires.
|
||
# Without the toggle it fires only on a committed, unambiguous match
|
||
# (detector_config.terminal_drop_may_fire).
|
||
TERMINAL_DROP_DEFAULT_ON_DEVICE_TYPES = frozenset({"dishwasher"})
|
||
|
||
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
|
||
# Discussion #452: a cycle halted on a flat standby-level plateau (an unbalanced
|
||
# load, a door warning). Once per stall, display/automation only, never a
|
||
# notification (CycleDetector.stalled).
|
||
EVENT_CYCLE_STALLED = "ha_washdata_cycle_stalled"
|
||
CYCLE_ANOMALY_STALLED = "stalled" # the state sensor's cycle_anomaly while stalled
|
||
|
||
# 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
|
||
# Shortest resampled current-cycle trace the matcher will score. Below this a
|
||
# correlation is noise, so both match paths decline rather than return a number
|
||
# nobody should act on. Named because the value was duplicated as a bare literal
|
||
# in async_match_profile and in the Playground's matcher, and the two drifted:
|
||
# the sim scored 5-point stretches that production had already rejected.
|
||
MATCH_MIN_RESAMPLED_POINTS = 12
|
||
# 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.
|
||
# On the shipped path 3 ties 5 and 8 is
|
||
# identical to 5; 1 costs -0.92pp mid-cycle
|
||
# (audit MR-05).
|
||
# 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.
|
||
# "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. Default.
|
||
# Measured on the shipped path (devtools/eval.py, audit MR-05), vs ensemble: DTW off
|
||
# -2.16pp mid-cycle top-1 (-3.78 at 25%) but +0.16 at cycle end (n.s.); scaled
|
||
# alone -0.32, ddtw alone -0.16. Stage 3 earns its keep mid-cycle only. (The older
|
||
# dtw_ab_eval table, off 62.4% ... ensemble 70.7%, predates item 303 and did not
|
||
# run the shipped matcher.)
|
||
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
|
||
# Envelope alignment grid cap: maximum number of time-grid points used by
|
||
# compute_envelope_worker. The DTW cost matrix is (n+1)x(m+1)x8 B float64;
|
||
# both n and m derive from this cap, so memory is bounded to roughly
|
||
# MAX_ALIGN_GRID_POINTS² x 8 B ≈ 32 MB at 2000 — regardless of cycle duration
|
||
# or recording density. Without this cap a 4 h cycle at 1 Hz asks for 1.81 GB
|
||
# in a single np.full, which OOM-kills Home Assistant (issue #388).
|
||
MAX_ALIGN_GRID_POINTS = 2000
|
||
# Ambiguity: top1-top2 score gap below this flags the match as ambiguous.
|
||
# Once a matched cycle has run past its programme's own expected length by this
|
||
# ratio, the fallback end gate stops waiting out the full `min_off_gap` and waits
|
||
# only END_GATE_LATE_SECONDS of quiet (register item 306). `min_off_gap` exists to
|
||
# bridge mid-cycle soak periods; past the end of the programme there is nothing
|
||
# left to bridge, and its default is a blind per-device prior (480 s washer /
|
||
# 600 s washer-dryer / 3600 s dishwasher) that almost nobody tunes - 12 of the 27
|
||
# real user exports carry an effective end wait of 30-60 min because of it.
|
||
# Shorten-only and bounded: the user's explicit `off_delay` stays the floor, so
|
||
# only the blind prior shrinks, and it is inert on unmatched cycles.
|
||
END_GATE_LATE_RATIO = 1.05
|
||
END_GATE_LATE_SECONDS = 300.0
|
||
|
||
# Device-resolved on a washing machine: see END_GATE_LATE_RATIO_BY_DEVICE
|
||
# and resolve_end_gate_late_ratio below, next to the device-type constants.
|
||
|
||
MATCH_AMBIGUITY_MARGIN = 0.05
|
||
# Separate, WIDER margin required before a finished cycle is auto-labelled
|
||
# (register item 310). Deliberately NOT the same constant as
|
||
# MATCH_AMBIGUITY_MARGIN: that one also gates Smart Termination via the
|
||
# detector's `_match_ambiguous`, so widening it there would defer terminations
|
||
# and undo the end-lag work of item 306. This one is consulted only where a
|
||
# label is recorded.
|
||
#
|
||
# Auto-labelling is the asymmetric decision - a wrong label silently reshapes the
|
||
# profile's avg_duration and therefore every future estimate, while a missed one
|
||
# only asks the user. Measured over 606 completed folds (post-item-307):
|
||
# margin coverage precision wrong labels
|
||
# 0.05 84.3% 86.3% 69 <- confidence-only, the old behaviour
|
||
# 0.08 77.3% 88.7% 52
|
||
# 0.10 72.2% 90.0% 43
|
||
# 0.08 is the argmax of right - 2 x wrong (a wrong label costing twice a missed
|
||
# one) and was independently selected by grouped cross-validation in all five
|
||
# held-out device groups. The absolute score is a much weaker guide here:
|
||
# AUC 0.625 against the margin's 0.792.
|
||
MATCH_LABEL_MIN_MARGIN = 0.08
|
||
# Gap between the best and second-best candidate at which a mid-cycle switch may
|
||
# skip the persistence wait (register item 305). The absolute score is close to
|
||
# useless for this mid-run (AUC 0.535, because a prefix of a long programme looks
|
||
# like a finished short one); the top1-top2 margin reaches AUC 0.773. Replayed over
|
||
# 594 cycles x 10 checkpoints: 70.4% -> 72.6% end-of-cycle correctness, 16 better /
|
||
# 3 worse, McNemar p = 0.0044, at 0.14 displayed switches per cycle against 0.07.
|
||
# The sweep is monotone (0.05 -> +6.7pp at 0.27 flips, 0.08 -> +5.1, 0.10 -> +3.0),
|
||
# so this is the conservative end of an accuracy/stability trade. Do not retune it
|
||
# in isolation: any Stage-2 scoring change rescales the margin along with it.
|
||
MATCH_DECISIVE_MARGIN = 0.12
|
||
# The first commit of a live programme (match_rules.decide_switch, Case 1). A winner
|
||
# that is AMBIGUOUS on the tick (inside MATCH_AMBIGUITY_MARGIN of the runner-up, or
|
||
# flagged by a Stage-5 safeguard) commits only once it has led for this many times
|
||
# match_persistence consecutive matches; a clear winner still commits at
|
||
# match_persistence. Until 0.5.8 an ambiguous winner committed at match_persistence
|
||
# too. Measured alone (devtools/decisive_margin_eval.py --switching --loo, 291
|
||
# labelled cycles): first programme shown right on washers 29.8 -> 34.2%,
|
||
# dishwashers 90.8 -> 93.1%, washer switches per cycle 1.32 -> 1.12, for a median
|
||
# first commit 17.8 -> 19.5 min on washers (dishwashers unchanged at 11.1 min).
|
||
# Not "never": a stable winner of an always-close pair must still get a programme
|
||
# and an ETA (offline, waiting for a clear tick left 4 washer cycles uncommitted).
|
||
MATCH_AMBIGUOUS_COMMIT_FACTOR = 2
|
||
# DISPLAY ONLY - never a gate (audit MATCH-DECIDE-15/18). The Status card's
|
||
# "Uncertain: X or Y, ~N% sure" figure while the live match is undecided:
|
||
# P(the leading guess is the right programme) as a monotone piecewise-linear map
|
||
# of the live top1-top2 margin, clamped at both ends. Being monotone, gating on it
|
||
# equals gating on the margin, so it adds nothing as a gate and must not become one.
|
||
# Fitted by devtools/margin_display_fit.py on devtools/eval.py --mode full, cuts
|
||
# 0.1-0.9 (2324 live prefix folds from 48 exports, matcher-produced labels
|
||
# excluded): 26% right at margin ~0 rising to 89% past 0.37; leave-one-source-out
|
||
# ECE 0.059 (Brier 0.191 vs base rate 0.229). A lone candidate has no runner-up
|
||
# (its margin is a 1.0 sentinel) and is right far less often than a real 1.0
|
||
# margin, so it gets its own figure.
|
||
MATCH_SURE_KNOTS: tuple[tuple[float, float], ...] = (
|
||
(0.007, 0.26), (0.036, 0.41), (0.064, 0.50), (0.099, 0.59),
|
||
(0.159, 0.73), (0.252, 0.84), (0.373, 0.89),
|
||
)
|
||
MATCH_SURE_SINGLE_CANDIDATE = 0.59
|
||
# Prefix-landscape guard (#288): 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. 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.
|
||
# Since audit LIVE-18 it guards only the dryer anti-crease finalize
|
||
# (`MatchResult.is_prefix_ambiguous_full_shape`): at the ENDING gates it was every
|
||
# false block at a genuine end.
|
||
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
|
||
|
||
# Issue #364: the landscape guard above has three structural false negatives, all
|
||
# reproduced by field reports on a multi-programme Miele washer:
|
||
# (1) it needs a longer profile to EXIST in the candidate pool, so an untrained
|
||
# longer programme is uncatchable;
|
||
# (2) it qualifies the longer candidate on its shape score against its FULL
|
||
# envelope, but a trace that is only part-way through a longer programme
|
||
# scores poorly against that programme's whole curve;
|
||
# (3) the 1.5 ratio is knife-edge - on a real 13-programme washer the observed
|
||
# neighbour ratios are 1.12-1.48, so the guard never fires at all.
|
||
#
|
||
# (a) Prefix scoring (for 2 + 3) was REMOVED in 0.5.8. It re-scored a longer
|
||
# candidate against its own curve truncated to the elapsed time and blocked the
|
||
# ENDING gates when that beat the winner's shape score by 0.15 (floor 0.40, ratio
|
||
# > 1.10, at most 3 scorings per match). #400 took its premise away: Stages 2/3
|
||
# now score a running cycle against every candidate's truncated curve, so a longer
|
||
# programme whose start explains the trace better mostly wins the match itself.
|
||
# Measured on the shipped matcher (devtools/prefix_guard_eval.py --quiet-cuts
|
||
# --sweep, leave-one-out, 71 devices): 0 of 713 genuine ends and 0 of the 7 quiet
|
||
# split positives (ENDING quiet inside a pause power later resumed from, the only
|
||
# moment Smart Termination can split a cycle) at every point of a margin 0-0.15 x
|
||
# floor 0-0.60 x ratio 1.0-1.5 grid - their best prefix margin was -0.024. What it
|
||
# caught were mid-activity cuts that never reach ENDING, which (b) blocks.
|
||
#
|
||
# (c) Pause evidence (#424), on the #288 term. It asks whether the trace LOOKS like
|
||
# the start of a longer programme, not whether that programme could be quiet right
|
||
# now. A longer candidate can only explain a below-`stop_threshold_w` moment if it
|
||
# is a programme that pauses below that threshold mid-cycle, so a candidate whose
|
||
# stored cycles never once did no longer counts. Any stored pause of this length
|
||
# keeps the guard; a programme with no traced evidence keeps it too. (Measured when
|
||
# it also fed the ENDING gates: genuine-end fires 186/689 -> 142/689 on the old
|
||
# harness, which OR-ed both terms.)
|
||
SMART_TERM_PREFIX_MIN_PAUSE_S = 60.0
|
||
|
||
# (b) Power plausibility (fixes 1, the untrained case, which no candidate-pool guard
|
||
# can reach). Both Smart-Termination paths key on `elapsed >= 0.98 * expected` and
|
||
# neither checks whether the appliance is still WORKING, so a mis-matched shorter
|
||
# profile finalises a cycle mid-wash. Compare the trailing mean power against what
|
||
# the matched profile itself draws at its own end: if we are drawing several times
|
||
# that, this is not the end of anything.
|
||
#
|
||
# The two windows must cover the same FRACTION of the run, or the comparison is not
|
||
# like-for-like. A fixed 300 s trailing window is 4% of a cotton wash but a third
|
||
# of a 15-minute "Spin & Drain", whose trailing mean is then the spin itself while
|
||
# its profile tail is the quiet moment after the pump stops - ratios of 45-310x on
|
||
# perfectly normal cycle ends. So the trailing window is
|
||
# `expected_duration * SMART_TERM_TAIL_WINDOW_FRAC`, clamped; that alone is strictly
|
||
# better at every threshold (e.g. at 4.0x: false blocks 5% -> 3%).
|
||
#
|
||
# Swept with devtools/prefix_guard_eval.py over the whole cycle_data corpus (19
|
||
# devices, 225 labelled cycles, leave-one-out): 114 folds where a shorter profile
|
||
# is winning mid-cycle (the #364 split condition) vs 165 genuine cycle ends.
|
||
# ratio caught false-blocked
|
||
# 3.0 27% 8%
|
||
# 3.5 25% 4% <- shipped, the knee
|
||
# 4.0 20% 3%
|
||
# 3.0 -> 3.5 halves the false blocks for 2pp of catch, and the reported cases sit
|
||
# at 4-8x so they stay caught. A false block only costs a later finish (the
|
||
# power-based fallback timeout still ends the cycle); a miss costs a split cycle.
|
||
# ~1 in 5 of the remaining false blocks had a wrong top-1 anyway, where blocking is right.
|
||
# Re-measured 2026-10-04 on the shipped matcher (item 483, `--quiet-cuts`): at 3.5x
|
||
# it catches 156 of 508 split positives with 0 of 675 false blocks.
|
||
SMART_TERM_TAIL_MAX_RATIO = 3.5 # block while trailing mean > this x profile tail
|
||
SMART_TERM_TAIL_WINDOW_S = 300.0 # upper clamp on the trailing window
|
||
SMART_TERM_TAIL_WINDOW_MIN_S = 60.0 # lower clamp (short programmes)
|
||
SMART_TERM_TAIL_MIN_POINTS = 3 # too few samples -> no opinion, do not block
|
||
SMART_TERM_TAIL_WINDOW_FRAC = 0.05 # both windows = last 5% of the run
|
||
|
||
# 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
|
||
# Stage-4 duration weight while the cycle is still RUNNING (live match). Mid-cycle
|
||
# the duration term compares elapsed time with each candidate's FULL duration, a
|
||
# systematic pull toward shorter programmes (80% of 50%-elapsed errors picked a
|
||
# shorter one), so it carries less weight there. Measured leave-one-out on the
|
||
# shipped path: +0.97pp mid-cycle top-1 [+0.11, +1.86], completed cycles and the
|
||
# ambiguity rate unchanged (audit MR-03). The completed-cycle weight stays.
|
||
MATCH_DURATION_WEIGHT_IN_PROGRESS = 0.15
|
||
# The Stage-1 LOWER duration-ratio gate is skipped for a live match during the first
|
||
# this-many seconds of a cycle. Matching starts as soon as the cycle runs, and at
|
||
# 5-10 min elapsed/avg_duration is below the 0.10 floor for every programme longer
|
||
# than 50-100 min, so only short programmes could compete: the gate removed the
|
||
# true programme on 69/114 user folds at 5 min and 35/243 at 10 min. Composed from
|
||
# the measured records: 5 min +83/-2, 10 min +63/-6, 15 min +17/-7, 25% of the cycle
|
||
# +0/-1 (audit MATCH-CORE-02). From 15 min on the gate stays, where it stops a much
|
||
# longer programme stealing the match.
|
||
MATCH_MIN_RATIO_GRACE_S = 900.0
|
||
|
||
# Hazard end gate (audit DETECT-16). Past an unambiguous match the ENDING fallback
|
||
# waits MARGIN x the longest below-stop pause the matched profile's traced
|
||
# evidence ever resumed from at or after this quiet's position (less SLACK of the
|
||
# run), never less than off_delay and never longer than before. Needs MIN_CYCLES
|
||
# traced cycles: a catalogue of one or two runs has not seen the programme's soaks.
|
||
END_GATE_HAZARD_MARGIN = 1.25
|
||
END_GATE_HAZARD_MIN_CYCLES = 3
|
||
END_GATE_HAZARD_POSITION_SLACK = 0.05
|
||
# Register item 498: a revoked match (divergence revert, or every candidate
|
||
# rejected) leaves an envelope-verified pause with no expected duration, which only
|
||
# high power cleared, so a finished cycle sat until the force stop. Released once the
|
||
# gap-free quiet reaches END_GATE_HAZARD_MARGIN x the longest below-stop pause the
|
||
# revoked programme's traced cycles ever resumed from, never before max(off_delay,
|
||
# min_off_gap, ENDING_HARD_FINALIZE_MIN_QUIET_S) (what the unmatched fallback waits
|
||
# anyway) and, above that floor, never after this cap: past the corpus's longest
|
||
# resumed pause x margin (a 6838 s dishwasher drying phase before its pump-out ->
|
||
# 8548 s) and 1.5 h inside the watchdog's 4.5 h silence limit under a verified pause.
|
||
ORPHANED_PAUSE_MAX_WAIT_S = 10800.0
|
||
# Stage-4 "energy" agreement. By default it compares mean power (W), not Wh:
|
||
# cur_energy=mean(curr_arr) vs profile_mean_power. Washing machines and
|
||
# washer-dryers compare integrated energy instead (analysis.stage4_energy_mode).
|
||
# While elapsed < the template span both modes reduce to the mean ratio, so the
|
||
# device choice only acts at cycle end or after an overrun (audit MR-09).
|
||
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
|
||
# At cycle end Stage 4 takes a washer's expected energy from the median of the
|
||
# profile's own cycles (analysis.member_energy_reference) once it has this many;
|
||
# below it, and mid-cycle, the template's mean power x duration as before. 3 was
|
||
# measured slightly worse than 2 (eval.py full, cut 1.0: +3/-4 folds).
|
||
MATCH_ENERGY_REF_MIN_CYCLES = 2
|
||
# Issue #400: once a RUNNING cycle has outlasted a candidate, that is hard
|
||
# evidence against it, and the penalty uses this sharper scale instead of
|
||
# MATCH_DURATION_SCALE. Only reached when the caller opts in via
|
||
# config["in_progress"]; the final match at cycle end keeps the symmetric term,
|
||
# where elapsed IS the cycle's true duration.
|
||
#
|
||
# Below a candidate's duration the term is deliberately UNCHANGED. Suppressing the
|
||
# penalty there ("we simply have not got there yet") was measured and rejected. On
|
||
# the shipped path (audit MR-02) it gains +4.65pp top-1 at 50% elapsed but costs
|
||
# -6.58pp at 98%, which is where Smart Termination reads the live match; it also
|
||
# raises the share of correct matches flagged ambiguous (11.2% -> 13.3%) and costs
|
||
# washer-dryers 18.2pp. Near the short programme's end it hands a longer sibling
|
||
# full duration agreement, so a dishwasher's 50 deg and 65 deg programmes land
|
||
# inside MATCH_AMBIGUITY_MARGIN of each other and Smart Termination is blocked
|
||
# (#393 is about finishing on time). The earlier "+0.7pp / -3.7pp at 90%" figures
|
||
# came from dtw_ab_eval, which does not run the shipped matcher.
|
||
MATCH_DURATION_SCALE_OVERRUN = 0.05
|
||
# Issue #400, shape half: while a cycle is running, Stages 2 and 3 score it against
|
||
# each candidate TRUNCATED to the elapsed time (reusing the #364 prefix machinery),
|
||
# but only while it is still clearly mid-run. Past this fraction of a candidate's
|
||
# own span the truncation starts discarding the very thing that separates a short
|
||
# programme from its longer sibling at the end - "I have already run longer than
|
||
# everything you have shown me". Measured on cycle_data: mid-cycle top-1 62.6% ->
|
||
# 71.0% at 0.7; at 0.8 the 90% checkpoint drops and a real dishwasher export loses
|
||
# Smart Termination, the same cliff the rejected duration credit fell off.
|
||
MATCH_PREFIX_SHAPE_MAX_RATIO = 0.7
|
||
# The fewest template samples a truncated prefix may keep and still be correlated
|
||
# and warped (analysis._prefix_point_count / prefix_shape_arrays), mirroring the
|
||
# matcher's >= 12-sample floor. Named SMART_TERM_PREFIX_MIN_POINTS while the
|
||
# removed #364 prefix guard shared it.
|
||
MATCH_PREFIX_MIN_POINTS = 12
|
||
|
||
|
||
# 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"
|
||
|
||
# States in which a cycle is in progress: what `binary_sensor.*_running` reports.
|
||
# Only `running` used to count, so the sensor turned off during every soak, pause
|
||
# and the end wait, and automations that treat "off" as "done" fired mid-cycle
|
||
# (audit PLATFORM-06). STARTING is excluded (not yet a confirmed cycle), as is
|
||
# ANTI_WRINKLE (the cycle has finished; the drum only tumbles the load).
|
||
CYCLE_IN_PROGRESS_STATES = frozenset(
|
||
{STATE_RUNNING, STATE_PAUSED, STATE_USER_PAUSED, STATE_ENDING}
|
||
)
|
||
|
||
STATE_UNKNOWN = "unknown"
|
||
STATE_CLEAN = "clean" # Cycle ended but door not yet opened (laundry still inside)
|
||
|
||
# A cycle start from one of these owns no update intervals yet, so the manager drops
|
||
# the cadence intervals earlier false starts left pending (#458, items 504 and 515).
|
||
CADENCE_RESET_FROM_STATES = frozenset({
|
||
STATE_OFF, STATE_UNKNOWN, STATE_DELAY_WAIT,
|
||
STATE_FINISHED, STATE_INTERRUPTED, STATE_FORCE_STOPPED,
|
||
})
|
||
|
||
# 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_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 whose Stage-4 "energy agreement" compares INTEGRATED energy
|
||
# (mean power x duration) instead of mean power. On these, same-base program
|
||
# variants differ mainly in temperature/spin at roughly equal duration, so
|
||
# integrated energy (which mean power dilutes) is the right discriminator -
|
||
# validated on real store data (washer top-1 +3.4pp, FP down). Other device
|
||
# types (dishwasher/dryer/...) keep mean power, because their programs are
|
||
# distinguished by duration and integrated energy would conflate the two axes
|
||
# (validated: it regresses there). See register item 99 / the cycle-variant
|
||
# discrimination spec. Defined from the device-type constants above (not string
|
||
# literals) so it can never drift from them.
|
||
STAGE4_INTEGRATED_ENERGY_DEVICE_TYPES = (
|
||
DEVICE_TYPE_WASHING_MACHINE,
|
||
DEVICE_TYPE_WASHER_DRYER,
|
||
)
|
||
|
||
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,
|
||
)
|
||
# Only past the programme's own expected duration (#445). Was 2.0, which on the
|
||
# reporter's 91 min Miele meant 91.5 minutes of idle before the cycle closed:
|
||
# that machine sits at 3.2-3.5 W after a programme ends, above its 2.56 W stop
|
||
# threshold, so _time_below_threshold never accumulates and this is the ONLY
|
||
# path that can end the cycle at all. Measured over 111 clean cycles from the
|
||
# whole corpus at 2.0 vs 1.0: no cycle that finished under 2.0 finished any
|
||
# differently, and two that never closed within their stored trace now close at
|
||
# 103% and 94% of expected. The three plateau conditions below are what make it
|
||
# safe - past expected AND >=10 min flat AND <=10% of the cycle's own peak is
|
||
# an appliance that has finished, not one still working.
|
||
STANDBY_BAND_MIN_RATIO = 1.0 # only past the expected duration
|
||
# ...but only for a plateau that IS the #445 shape: sitting at or just above the
|
||
# stop threshold, within max(STANDBY_BAND_NEAR_STOP_FACTOR x stop,
|
||
# stop + STANDBY_BAND_NEAR_STOP_W). 0.5.7 dropped the ratio for EVERY plateau the
|
||
# loose test below accepts - flat and under 10% of the heater peak, i.e. anything
|
||
# from a 0 W soak to a 70 W rinse on a 2 kW machine - so a run matched to a shorter
|
||
# programme was finalised mid-wash. Replaying the local corpus at 0.5.7 it fired on
|
||
# 8 washer cycles (0 at 0.5.6): 2 split, 6 lost 6-31 min of real activity, and none
|
||
# of the 8 plateaus sat within a few watts of the stop threshold. The #445 Miele
|
||
# (3.2-3.5 W idle on a 2.56 W stop), #458 (2.2 W on 1.76 W) and #427's AEG (0.7 W on
|
||
# 0.6 W) all do.
|
||
STANDBY_BAND_NEAR_STOP_FACTOR = 2.0
|
||
STANDBY_BAND_NEAR_STOP_W = 3.0
|
||
# Any other flat low plateau keeps the 0.5.6 gate: twice the expected duration.
|
||
STANDBY_BAND_LOOSE_MIN_RATIO = 2.0
|
||
|
||
# Ceiling on the measured post-activity quiet span a stored cycle may bank
|
||
# (register item 297). `profile_terminal_quiet_seconds` is a median over that profile's own
|
||
# cycles, so it is already self-limiting; this is the guard against a corrupted
|
||
# or hand-edited value licensing an unbounded tail - the one thing the field
|
||
# exists to prevent. Not a measurement of drying: 30 min did not cover it
|
||
# (register item 469). 01KGM619's Eco dries 4840-4860 s before its pump-out, so
|
||
# a run closed without one stored last activity + 1800 s (~8.0k s of an ~11.1k s
|
||
# programme). At 2 h: 12 such replays store 10.0-11.3k s, end lag unchanged on
|
||
# dishwashers but one cycle (+3 min) and no early end or split moved.
|
||
TERMINAL_QUIET_CAP_S = 7200.0
|
||
# A measured quiet span is only trusted as a tail allowance when the profile has
|
||
# actually shown it repeatedly (register item 297). Measured over 20 real profiles: the two
|
||
# dishwashers, which genuinely end in a passive drying phase, scored 20/20 and
|
||
# 17/17; every washing-machine profile that produced a value at all did so from
|
||
# 1-4 cycles out of 4-12, one of them 2400 s. Below these floors the accessor
|
||
# reports None and the caller keeps its previous behaviour.
|
||
TERMINAL_QUIET_MIN_OBSERVATIONS = 3
|
||
TERMINAL_QUIET_MIN_CONSISTENCY = 0.6
|
||
# Store key set by the v12->v13 migration and cleared once the one-time repair
|
||
# of banked cycle tails has run (register item 297). The repair itself cannot
|
||
# live in the storage migration, which sees only the store payload: deciding
|
||
# where a cycle's real activity ended needs `stop_threshold_w`, and that lives
|
||
# in entry.options. Same split as the v10->v11 marker-only bump.
|
||
BANKED_TAIL_REPAIR_KEY = "_banked_tail_repair_pending"
|
||
# Don't churn a cycle for a few seconds of tail: only rewrite one whose banked
|
||
# span is worth correcting. Measured median banking was 12.6 min, so this only
|
||
# skips noise.
|
||
BANKED_TAIL_REPAIR_MIN_S = 60.0
|
||
# A dishwasher's stored end never falls before this fraction of the shortest
|
||
# length the user has vouched for in its profile (`manual_duration`, a recorder
|
||
# capture, a golden cycle). Shared by the banked-tail repair and the live
|
||
# `_keep_tail_cap` (register item 384), so the two store the same duration.
|
||
TRUSTED_LENGTH_FLOOR_FRAC = 0.9
|
||
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).
|
||
# Issue #429: the ratio is per-appliance (CONF_ANTI_CREASE_FINALIZE_RATIO, range
|
||
# 0.50-1.00, empty = this default), for the same reason as #393 - and note that
|
||
# this is a DIFFERENT gate from CONF_SMART_TERMINATION_DURATION_RATIO, which
|
||
# gates Smart Termination rather than the finalise into STATE_ANTI_WRINKLE.
|
||
# ``_expected_duration`` is the profile's outlier-filtered arithmetic MEAN, so on
|
||
# a sensor-dry program (runtime follows the load, not the clock) a fixed 98% of
|
||
# the mean is unreachable for about half the runs by construction, and the path
|
||
# built for exactly that tail can never engage. Measured by the reporter on a
|
||
# condenser dryer: 15 of 15 matched runs landed at 0.38-0.92 of expected and all
|
||
# 15 ended by fallback timeout, 14-39 min (median 27) after the last real
|
||
# activity; at 0.75 the finalise fired on all 3 subsequent runs, 6-12 min after.
|
||
#
|
||
# Left per-appliance rather than re-defaulted, and NOT device-type-resolved,
|
||
# because the safe value is a property of the individual machine: the reporter's
|
||
# heat-pump dryer has no comparable tumble tail and closes 0.8-1.5 min after its
|
||
# last high reading, so the fixed ratio costs it nothing.
|
||
#
|
||
# LOWER IT ON DRYERS, NOT ON WASHING MACHINES. On a washer this ratio is the
|
||
# whole discriminator between the post-wash tail and a mid-wash trough (see the
|
||
# rationale above): a washer spends most of its cycle below
|
||
# ``anti_wrinkle_max_power``, so the low-power window check cannot tell the two
|
||
# apart, and neither can the #364 trailing-power check (``_smart_term_power_
|
||
# plausible`` compares against the profile's OWN tail level, which is also low).
|
||
# ``_anticrease_spin_pending`` (#399) covers the terminal-spin case but fails
|
||
# open when the profile carries no terminal high block.
|
||
DEFAULT_ANTI_CREASE_FINALIZE_RATIO = 0.98 # elapsed must reach 98% of expected duration
|
||
# The range the panel's number input enforces (min 0.5 / max 1.0), restated here so
|
||
# the detector can hold a stored value to it. Nothing else validates the range:
|
||
# `import_config` strips only nulls, a selective import writes numbers through, and
|
||
# the Playground sanitizer casts to float. A stored 0.0 would make the past-expected
|
||
# test `current_duration < expected * 0.0` pass for every non-negative duration, i.e.
|
||
# remove the discriminator this gate rests on for washing machines (see above).
|
||
ANTI_CREASE_FINALIZE_RATIO_MIN = 0.5
|
||
ANTI_CREASE_FINALIZE_RATIO_MAX = 1.0
|
||
ANTI_CREASE_CONFIRM_WINDOW_S = 180.0 # recent window that must hold no reading > max_power
|
||
|
||
# Issue #399: both conditions above look BACKWARDS, so a wash whose final spin
|
||
# lands just past 0.98 x expected - preceded by more than the confirm window below
|
||
# `anti_wrinkle_max_power` (a delicate/rinse stretch) - was finalised seconds
|
||
# before its own spin, and the spin then opened a second cycle record. The guard
|
||
# asks the matched profile whether it ends with a high-power block and, if so,
|
||
# refuses to finalise until THIS run has produced its counterpart.
|
||
#
|
||
# Deliberately event-based, not clock-based: blocking merely until elapsed passes
|
||
# the profile's own last high sample delays the reported finalise by 16 s and then
|
||
# splits the wash anyway, because a run's spin can sit hundreds of seconds later
|
||
# than the profile's (load-dependent duration). Asymmetric like the other
|
||
# anti-crease guards - it can only ever DELAY a finalise - and bounded by the cap
|
||
# below so a program that legitimately skips its spin can never hang.
|
||
ANTI_CREASE_TERMINAL_HIGH_MIN_FRAC = 0.90 # profile's last high block must START this
|
||
# late in its run to count as terminal;
|
||
# a genuinely low-power tail (the #296
|
||
# Miele tumble) never arms the guard
|
||
ANTI_CREASE_TERMINAL_MATCH_FRAC = 0.5 # live high-power seconds after that
|
||
# position, as a fraction of the
|
||
# profile's own block, that count as
|
||
# "this run has had its spin"
|
||
ANTI_CREASE_SPIN_WAIT_MAX_RATIO = 1.25 # never block past this x expected
|
||
# Register item 480: the envelope's max band arms the guard when ANY member's last
|
||
# block above the level is terminal, and washer spins straddle 400 W (held runs
|
||
# peak at 331-394 W, the members that "spin" at 404-437 W). Once the band arms,
|
||
# the guard stays armed only when at least this share of the profile's completed
|
||
# traced members end with their own terminal block; with fewer members than the
|
||
# floor the band (or sample) decides alone, as before.
|
||
ANTI_CREASE_SPIN_ARM_MIN_SHARE = 0.5
|
||
ANTI_CREASE_SPIN_ARM_MIN_MEMBERS = 2
|
||
|
||
# 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
|
||
# ...but never shorter than this multiple of the matched profile's MEASURED quiet
|
||
# before its terminal event (`profile_terminal_quiet_seconds`, match element 11):
|
||
# a release after 600 s of quiet is premature on a programme measured to wait
|
||
# 934-1810 s before its pump-out, and ended the corpus's "65° full" 12 min early
|
||
# (register item 392). Lengthen-only, and bounded by the 30 min spike wait.
|
||
# The same margin applies to the longest below-stop pause the profile's traced
|
||
# cycles ever resumed from (match element 14, register item 465): element 11 is a
|
||
# median that a cycle closed before its pump-out drags down.
|
||
DISHWASHER_QUIET_RELEASE_TERMINAL_MARGIN = 1.1
|
||
|
||
# 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
|
||
|
||
# Fraction of the matched profile's expected (mean) duration that Smart Termination
|
||
# requires before it may fire (#393). self._expected_duration is the profile's
|
||
# outlier-filtered ARITHMETIC MEAN, so a fixed 0.98 gate against a mean is
|
||
# structurally unreachable for appliances whose runtime depends on load, fill level
|
||
# or inlet temperature - about half of those cycles are shorter than their own mean
|
||
# by construction and can never take the fast path. Exposed as the per-device
|
||
# CONF_SMART_TERMINATION_DURATION_RATIO option (range 0.50-1.00; empty = default).
|
||
# The default is device-type-resolved: dishwashers keep the conservative 0.99
|
||
# (their programs are fixed, so the spread is small) while everything else keeps
|
||
# 0.98. The dishwasher pump-out relief (0.90 once the terminal pump-out spike is
|
||
# confirmed) is combined with the configured value via min(), so the option can
|
||
# only ever LOOSEN the gate, never tighten it.
|
||
DEFAULT_SMART_TERMINATION_DURATION_RATIO = 0.98
|
||
|
||
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)
|
||
}
|
||
|
||
# Ceiling for the manager's *unmatched* zombie guard (seconds), i.e. the failsafe
|
||
# that force-ends a cycle with no learned expected duration (expected == 0). This is
|
||
# only a last-resort kill for a stuck FALSE START; the detector already hard-caps any
|
||
# cycle at 8h (28800s), and the guard additionally only fires when the appliance is
|
||
# effectively idle and no external end trigger is available (issue #404). All values
|
||
# MUST stay below the detector's 28800s cap so the guard remains an *earlier* kill.
|
||
# Wet/long appliances get a longer fuse because a genuine wash+dry or long cottons
|
||
# programme with no matched profile can legitimately run well past 4h.
|
||
DEFAULT_UNMATCHED_WATCHDOG_CEILING = 14400 # 4h scalar fallback
|
||
DEFAULT_UNMATCHED_WATCHDOG_CEILING_BY_DEVICE = {
|
||
DEVICE_TYPE_WASHING_MACHINE: 21600, # 6h (long cottons + pre-wash)
|
||
DEVICE_TYPE_WASHER_DRYER: 25200, # 7h (combined wash+dry runs 6+h)
|
||
DEVICE_TYPE_DRYER: 21600, # 6h (anti-crease can extend a long dry)
|
||
DEVICE_TYPE_DISHWASHER: 18000, # 5h (long eco + silent drying pauses)
|
||
}
|
||
|
||
# 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
|
||
}
|
||
|
||
|
||
def resolve_sampling_interval_default(device_type: str) -> float:
|
||
"""Device-resolved default sampling interval (#396).
|
||
|
||
Single source of truth for the sampling default, so the manager, the panel
|
||
(via ws_get_options) and the config migration all agree. Wet appliances
|
||
sample fast (2 s) to capture the rapid 0<->150 W oscillation; everything else
|
||
keeps the coarse 30 s scalar.
|
||
"""
|
||
return DEFAULT_SAMPLING_INTERVAL_BY_DEVICE.get(device_type, DEFAULT_SAMPLING_INTERVAL)
|
||
|
||
|
||
def resolve_watchdog_interval_default(device_type: str) -> int:
|
||
"""Device-resolved watchdog tick default (#396).
|
||
|
||
The panel enforces watchdog_interval >= 2*sampling_interval (a publish-on-change
|
||
sensor can skip a sample, so the staleness tick must be coarser than the
|
||
sampling gap). Derived as max(DEFAULT_WATCHDOG_INTERVAL, 2*sampling+1): 30 for
|
||
the fast/pump types (30 already clears 2*2 / 2*10), 61 for the 30 s-sampling
|
||
types. Never smaller than the 30 s floor so a fast-sampling device does not get
|
||
an over-aggressive watchdog.
|
||
"""
|
||
sampling = resolve_sampling_interval_default(device_type)
|
||
return int(max(DEFAULT_WATCHDOG_INTERVAL, 2.0 * sampling + 1.0))
|
||
|
||
|
||
def resolve_min_off_gap_default(device_type: str) -> int:
|
||
"""Device-resolved minimum off gap (#445).
|
||
|
||
Published to the panel so the number that actually governs the end of a cycle
|
||
is visible. ``CycleDetector`` waits
|
||
``effective_off_delay = max(off_delay, min_off_gap)``, so an unset
|
||
``min_off_gap`` silently raises a hand-lowered ``off_delay`` to this blind
|
||
per-device prior - 480 s on a washing machine, 3600 s on a dishwasher. The
|
||
#445 reporter set ``off_delay`` to 180 s, waited 6 minutes, and force-stopped
|
||
three cycles because nothing told them the real wait was 480 s.
|
||
|
||
Deliberately published rather than lowered. The prior is there because a long
|
||
quiet stretch inside a cycle must not split it in two, and that is real: a
|
||
corpus sweep over 15 devices' stored cycles found dishwasher quiet-and-resumed
|
||
stretches up to **6791 s**. Only 2 of those 15 devices left ``min_off_gap``
|
||
unset at all, so honouring the lowered ``off_delay`` would be a no-op almost
|
||
everywhere and a cycle-splitting risk on exactly the device class that needs
|
||
the bridge. Showing the number lets the user make that call per appliance.
|
||
"""
|
||
return int(DEFAULT_MIN_OFF_GAP_BY_DEVICE.get(device_type, DEFAULT_MIN_OFF_GAP))
|
||
|
||
|
||
def resolve_off_delay_default(device_type: str) -> int: # noqa: ARG001
|
||
"""The off delay a device runs on when it has none set (#445).
|
||
|
||
``DEFAULT_OFF_DELAY`` for every type. ``DEFAULT_OFF_DELAY_BY_DEVICE`` is the
|
||
suggestion engine's FLOOR for a proposed value, not a runtime default: no
|
||
device has ever run on it, because the config flow does not store an off
|
||
delay and the manager falls back to the scalar. Publishing the table here
|
||
made the panel show a dishwasher's unset Off Delay as 1800 s while the
|
||
detector used 180 s - and 180 is the floor of the late ENDING shortening, so
|
||
the difference is not cosmetic. The parameter is kept for the call sites.
|
||
"""
|
||
return int(DEFAULT_OFF_DELAY)
|
||
|
||
|
||
def resolve_start_duration_default(device_type: str) -> float:
|
||
"""Device-resolved start-debounce default (#396).
|
||
|
||
The panel enforces start_duration_threshold >= sampling_interval (a debounce
|
||
shorter than one sample lets a single spike open a cycle). Derived as
|
||
max(DEFAULT_START_DURATION_THRESHOLD, sampling): 5 s for the fast types, the
|
||
sampling interval for the coarser ones.
|
||
"""
|
||
sampling = resolve_sampling_interval_default(device_type)
|
||
return max(DEFAULT_START_DURATION_THRESHOLD, sampling)
|
||
|
||
|
||
# Default Smart-Termination duration ratio by device type (#393). Dishwashers run
|
||
# fixed programs (measured spread +4%/+17% around the mean), so the conservative
|
||
# 0.99 gate is defensible there; every other type keeps the scalar
|
||
# DEFAULT_SMART_TERMINATION_DURATION_RATIO (0.98). Resolved in the config builder,
|
||
# never in the gate, so playground.effective_settings() always sees a real float.
|
||
DEFAULT_SMART_TERMINATION_DURATION_RATIO_BY_DEVICE = {
|
||
DEVICE_TYPE_DISHWASHER: 0.99,
|
||
}
|
||
|
||
|
||
# ...except on a washing machine, where 1.05 is structurally out of reach
|
||
# (register item 355). A washer's programme length is load-adaptive, so a run is
|
||
# BELOW its profile's mean duration about half the time by definition: measured
|
||
# over the `end_gate_eval.py` corpus the median washer reaches only 0.83 of the
|
||
# bar before it stops, against 1.01 for a dishwasher, and the shortening fired on
|
||
# 6.8% of washer cycles. That is what left washing machines waiting out the full
|
||
# `min_off_gap` - a median 24.74 min after the last activity, against 6.89 for a
|
||
# dishwasher. NOT an ambiguity problem, which is what the register used to say;
|
||
# item 330 had already fixed that half.
|
||
#
|
||
# Scoped to the device class rather than lowered globally, because every early
|
||
# end in the global sweep was a DISHWASHER: at 0.95 one dishwasher closed 5.58
|
||
# min before its last activity (a 30 s 73 W blip after five dead-zero minutes),
|
||
# while washing machines showed 0.00% early ends at every ratio tried. Measured
|
||
# at 0.90, washers only: median end lag 24.74 -> 16.92 min, early ends 0.00%,
|
||
# splits unchanged at 1.90%, dishwashers byte-identical. The cost is 2 cycles of
|
||
# 158 losing their auto-label (92.4% -> 91.1%) because ending ~11 min sooner can
|
||
# skip a final match tick; they are still stored and offered for confirmation,
|
||
# which this codebase already treats as much cheaper than a wrong label (#325).
|
||
END_GATE_LATE_RATIO_BY_DEVICE = {
|
||
DEVICE_TYPE_WASHING_MACHINE: 0.90,
|
||
DEVICE_TYPE_WASHER_DRYER: 0.90,
|
||
}
|
||
|
||
|
||
def resolve_end_gate_late_ratio(device_type: str) -> float:
|
||
"""Device-resolved bar for the item-306 ENDING shortening.
|
||
|
||
Single source of truth for the detector and the Playground replay, so the
|
||
sim cannot diverge from live the way it did in item 352.
|
||
"""
|
||
return END_GATE_LATE_RATIO_BY_DEVICE.get(device_type, END_GATE_LATE_RATIO)
|
||
|
||
|
||
def resolve_smart_termination_duration_ratio_default(device_type: str) -> float:
|
||
"""Device-resolved Smart-Termination duration ratio default (#393).
|
||
|
||
Single source of truth shared by the manager (config build/reload), the
|
||
Playground fallback config and the panel (via ws_get_options), so the value
|
||
the panel pre-populates always matches the one the detector actually uses:
|
||
0.99 for dishwashers (fixed programs), 0.98 for everything else.
|
||
"""
|
||
return DEFAULT_SMART_TERMINATION_DURATION_RATIO_BY_DEVICE.get(
|
||
device_type, DEFAULT_SMART_TERMINATION_DURATION_RATIO
|
||
)
|
||
|
||
# Profile groups (Stage 5): a group is mapped to its members (and so collapsed into
|
||
# one family after every member is scored on its own curve) only when the members'
|
||
# minimum pairwise shape similarity is at least this. There is no aggregate
|
||
# candidate any more (#400). 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 and are
|
||
# flagged in the UI. Calibrated on real profiles: genuine temp/spin variants score
|
||
# ~0.86-0.95, distinct programs <~0.6.
|
||
GROUP_MIN_COHESION = 0.80
|
||
|
||
# Per-profile terminal signature (`profile_store.compute_profile_terminal_signature`).
|
||
# Measured over every export in `cycle_data/` (146 unique dishwasher cycles, 10
|
||
# households): a dishwasher's terminal pump-out peaks at a median 1.3% of its own
|
||
# cycle peak (p10 0.8%, p90 3.8%) and follows a median 906 s of quiet. Both bounds
|
||
# are therefore RELATIVE - a fixed wattage does not survive the range, which is
|
||
# exactly why the #399 spin extractor (fixed at `anti_wrinkle_max_power`, 400 W)
|
||
# finds no terminal block at all on a dishwasher whose pump-out is 33 W.
|
||
# The quiet minimum separates "the event after the drying phase" from an ordinary
|
||
# gap inside the wash; 120 s is well under the measured p10 of 624 s.
|
||
TERMINAL_EVENT_PEAK_FRAC = 0.004
|
||
TERMINAL_QUIET_MIN_S = 120.0
|
||
# ...but a gap of 120 s also sits between a dishwasher's own heating blocks
|
||
# (120-160 s on 01KGM619), so a cycle closed before its pump-out offered its LAST
|
||
# HEATING BLOCK as the terminal event (register item 469). A terminal event is a
|
||
# low-power one: over every corpus dishwasher the candidates peak at <= 9.1% of
|
||
# their cycle's peak (median 1.3%) or at >= 90% (main activity), nothing between.
|
||
# Above this fraction the last run is main activity and the trace has no event.
|
||
TERMINAL_EVENT_MAX_PEAK_FRAC = 0.25
|
||
# Below this many evidence cycles the medians describe noise, not the programme.
|
||
TERMINAL_SIGNATURE_MIN_CYCLES = 3
|
||
# Cycles a profile needs before one of them can be called a duration outlier
|
||
# (register item 304). Below this there is no established 'usual length' to be
|
||
# an outlier from, and calling a program's second cycle an error is nonsense.
|
||
SELF_UNMATCHABLE_MIN_CYCLES = 3
|
||
|
||
# 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. It introduced a per-phase envelope cache for the
|
||
# phase-resolved ETA, removed with that stack in 0.5.8 (register item 411); the
|
||
# version stays because a store version can never go back down.
|
||
# v12: initialize `backfill_cycles`, the third cycle list (issue #344). Cycles
|
||
# recovered from raw power history predating the integration are auto-detected and
|
||
# unverified, so they belong in neither `past_cycles` (which feeds lifetime stats, ML
|
||
# training labels and the feedback queue, and is retention-evicted oldest-first) nor
|
||
# `reference_cycles` (curated community-store templates, golden by construction).
|
||
# Additive `setdefault`, so it is idempotent and loses nothing.
|
||
# v13: marker-only, arms the one-time banked-tail repair (register item 297).
|
||
# v14: marker-only, re-arms it (#424): the v13 pass judged the drying phase at the
|
||
# wrong threshold and skipped dishwasher timeout finishes.
|
||
# v15: label provenance repair (audit MANAGER-01): cycles the user confirmed or
|
||
# corrected in the review queue are stamped `label_source="manual"`, and an answer
|
||
# the panel's Auto-label had replaced is put back. Pure data, idempotent.
|
||
# v16: review-queue cleanup (register item 433): pending requests the new rule
|
||
# would not raise are dropped, without recording an answer. Idempotent.
|
||
# v17: drops the state of the ML parts removed in 0.5.8 (the early match commit,
|
||
# the quality gate, the matcher weight tuner, and on-device training of every head
|
||
# but total_energy): `match_ranking_history`, `matching_config`, and the
|
||
# `live_match` / `quality` / `end` / `remaining_time` records in
|
||
# `ml_model_versions` / `ml_training_history`. No cycle or label is touched.
|
||
# Idempotent.
|
||
STORAGE_VERSION = 17
|
||
STORAGE_KEY = "ha_washdata"
|
||
|
||
# Restore point for "Undo last import" (register item 195): the store as it was before
|
||
# the last replace import, in its own file `ha_washdata.<entry_id>.pre_import` so the
|
||
# main store's per-save rewrite never carries a second copy. One per device: the next
|
||
# replace import overwrites it, an undo consumes it, deleting the device removes it
|
||
# (`async_remove_entry` + the orphan sweep in `__init__.py`). The record carries the
|
||
# `STORAGE_VERSION` it was taken at, and a restore migrates it forward.
|
||
PRE_IMPORT_STORE_SUFFIX = "pre_import"
|
||
PRE_IMPORT_STORE_VERSION = 1
|
||
|
||
# Notifications held by quiet hours / presence when Home Assistant stops or the entry
|
||
# unloads, in `ha_washdata.<entry_id>.notify_queue` (audit MANAGER-16). Written at the
|
||
# stop, read and deleted once HA has started again; removed with the device.
|
||
NOTIFY_QUEUE_STORE_SUFFIX = "notify_queue"
|
||
|
||
# The last active-cycle snapshot that failed to restore, in
|
||
# `ha_washdata.<entry_id>.failed_restore` (register item 266 follow-up): kept for the
|
||
# diagnostics download instead of being deleted with the cycle it held. Written only
|
||
# on a failure, one per device (the next failure overwrites it), removed with the device.
|
||
FAILED_RESTORE_STORE_SUFFIX = "failed_restore"
|
||
|
||
# ─── Config-entry schema version (NOT the storage version above) ───────────────
|
||
# Single source for the config-entry schema: `ConfigFlow.VERSION`/`MINOR_VERSION`, every
|
||
# stepwise block in `async_migrate_entry`, and the `minor_version=` the one-pass legacy
|
||
# migration writes all read from here. They must move together - a bump that misses one
|
||
# leaves an entry a version short, which then re-migrates on every start - and repeating
|
||
# the literals in three places is what made that easy to do.
|
||
CONFIG_ENTRY_VERSION = 3
|
||
CONFIG_ENTRY_MINOR_VERSION = 11
|
||
|
||
# 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_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_TRAINING = True
|
||
# Frozen off even when a device enables ML models (audit ML-05): replayed on 292
|
||
# real cycles the end-guard prevented no premature stop and raised the washer
|
||
# median end lag 12.2 -> 17.5 min, every deferral the full 30 min cap.
|
||
ENABLE_ML_END_GUARD = False
|
||
# Removed in 0.5.8, after being frozen here: the remaining-time regressor (C4,
|
||
# audit ML-07: worse than the naive estimate on 7 of 8 installs), the early match
|
||
# commit (C2, audit ML-02: 31% of its early commits wrong vs 8.4% for
|
||
# persistence) and the quality gate (C3, audit ML-06: fired on 0 of the 12
|
||
# auto-label-eligible real cycles), with the matcher weight tuner.
|
||
|
||
# ─── 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_START_THRESHOLD_W,
|
||
CONF_STOP_THRESHOLD_W,
|
||
CONF_START_DURATION_THRESHOLD,
|
||
CONF_START_ENERGY_THRESHOLD,
|
||
CONF_COMPLETION_MIN_SECONDS,
|
||
CONF_END_ENERGY_THRESHOLD,
|
||
# (off_delay, min_off_gap, power_off_*, profile_match_interval dropped: they
|
||
# are functions of the SHARER'S plug cadence, not the model - a 94 s plug's
|
||
# 1800 s off_delay is a 30 min end lag on a 1 s plug. Audit STORE-06.)
|
||
# Matching
|
||
CONF_PROFILE_MATCH_THRESHOLD,
|
||
CONF_PROFILE_UNMATCH_THRESHOLD,
|
||
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
|
||
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
|
||
# (profile_duration_tolerance dropped: nothing reads it - audit DOCS-01.)
|
||
CONF_DURATION_TOLERANCE,
|
||
CONF_AUTO_LABEL_CONFIDENCE,
|
||
CONF_LEARNING_CONFIDENCE,
|
||
)
|
||
|
||
|
||
# Shared settings the panel bounds to 0-1 (scores and a fraction). Every shareable
|
||
# setting is also >= 0 there.
|
||
_SHARED_UNIT_INTERVAL_KEYS = frozenset({
|
||
CONF_PROFILE_MATCH_THRESHOLD,
|
||
CONF_PROFILE_UNMATCH_THRESHOLD,
|
||
CONF_DURATION_TOLERANCE,
|
||
CONF_AUTO_LABEL_CONFIDENCE,
|
||
CONF_LEARNING_CONFIDENCE,
|
||
})
|
||
|
||
|
||
def sanitize_shared_settings(settings: Any) -> dict[str, float]:
|
||
"""The allow-listed, finite, numeric subset of a shared settings map.
|
||
|
||
Every share/adopt/export site goes through this. A value outside the panel's
|
||
own range is dropped (a match threshold of 5 can never be met by a 0-1 score).
|
||
The duration ratios are also held to the shipped bounds (audit STORE-06):
|
||
25/25 store bundles carried a max ratio below 1.8 (12 at the 1.5 measured to
|
||
delete the true candidate on 2.3% of folds, register item 311), and min ratios
|
||
up to 0.81 forbid any match before 81% of a programme.
|
||
"""
|
||
if not isinstance(settings, dict):
|
||
return {}
|
||
out: dict[str, float] = {}
|
||
for key, value in settings.items():
|
||
if key not in SHAREABLE_SETTING_KEYS or isinstance(value, bool):
|
||
continue
|
||
if not isinstance(value, (int, float)):
|
||
continue
|
||
try:
|
||
number = float(value) # an oversized JSON integer raises here
|
||
except OverflowError:
|
||
continue
|
||
if not math.isfinite(number):
|
||
continue
|
||
if number < 0 or (key in _SHARED_UNIT_INTERVAL_KEYS and number > 1):
|
||
continue
|
||
if key == CONF_PROFILE_MATCH_MAX_DURATION_RATIO:
|
||
value = max(value, DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO)
|
||
elif key == CONF_PROFILE_MATCH_MIN_DURATION_RATIO:
|
||
value = min(value, DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO)
|
||
out[str(key)] = value
|
||
return out
|
||
|
||
# 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
|
||
|
||
# (The classifier promotion gate - AUC margin, balanced-accuracy margin, minimum
|
||
# positives - was removed in 0.5.8 with on-device classifier training: audit ML-11
|
||
# measured it promoting worse models on 4-7 held-out positives. Only the
|
||
# total_energy regressor is trained on-device now.)
|
||
|
||
# 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
|
||
|
||
# Total-energy regressor (standardized_linear), the one head trained on-device.
|
||
# It has no shipped baseline; it is only promoted when its held-out mean-absolute
|
||
# error on the energy-fraction target beats the naive elapsed/expected
|
||
# estimate by at least this relative margin (5% lower MAE) AND beats the model
|
||
# already in use, scored on the same held-out cycles (audit ML-12). 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
|
||
# Held-out cycles a regressor must be scored on before it can be promoted. The
|
||
# 20% holdout rested on ONE cycle for the only real promotion on record (audit
|
||
# PROGRESS-16); the split now holds out at least this many when the device has
|
||
# twice as many usable cycles, and never promotes on fewer.
|
||
ML_TRAINING_MIN_HOLDOUT_CYCLES = 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 built-in maintenance event types. bearing_service / other default off
|
||
# (absent from the default reminder dict) and are opt-in. The last four are the
|
||
# device-type presets of discussion #461 (see MAINTENANCE_PRESETS_BY_DEVICE_TYPE).
|
||
MAINTENANCE_EVENT_TYPES = (
|
||
"descale",
|
||
"filter_clean",
|
||
"drum_clean",
|
||
"bearing_service",
|
||
"other",
|
||
"salt",
|
||
"rinse_aid",
|
||
"lint_filter",
|
||
"condenser_clean",
|
||
)
|
||
# Preset defaults per device type (cycles), replacing DEFAULT_MAINTENANCE_REMINDER_CYCLES
|
||
# for that type while its reminder config was never saved. None of these can be
|
||
# measured from power: they are manufacturer ballparks set on the early side, so the
|
||
# reminder comes with headroom. Every type not listed keeps the washer default.
|
||
MAINTENANCE_PRESETS_BY_DEVICE_TYPE: dict[str, dict[str, int]] = {
|
||
DEVICE_TYPE_DISHWASHER: {
|
||
# A 1-2 kg softener reservoir lasts ~30-60 cycles at medium-hard water.
|
||
"salt": 30,
|
||
# A ~110-150 ml rinse-aid reservoir at ~3 ml per cycle lasts ~40-50 cycles.
|
||
"rinse_aid": 40,
|
||
# Same 50 as the washer default, so an existing dishwasher's reminder stays put.
|
||
"filter_clean": 50,
|
||
},
|
||
DEVICE_TYPE_DRYER: {
|
||
# Manufacturers say every load; 10 is a backstop for a forgotten filter.
|
||
"lint_filter": 10,
|
||
# Condenser / heat-pump filters: manufacturers suggest every ~20-50 loads.
|
||
"condenser_clean": 30,
|
||
},
|
||
}
|
||
# Built-in types the reminder editor offers per device type (in this order). Types
|
||
# not listed get the original five. A type with a saved positive threshold is shown
|
||
# whatever its device type, so no saved reminder ever disappears from the editor.
|
||
MAINTENANCE_TYPES_BY_DEVICE_TYPE: dict[str, tuple[str, ...]] = {
|
||
DEVICE_TYPE_DISHWASHER: ("salt", "rinse_aid", "filter_clean", "descale", "other"),
|
||
DEVICE_TYPE_DRYER: ("lint_filter", "condenser_clean", "other"),
|
||
}
|
||
# The preset types count "since last done" from the moment their reminder is first
|
||
# active (a baseline stamped in the store), not from odometer 0: an upgraded
|
||
# dishwasher with 300 cycles must not open with "salt due" (#461). The original five
|
||
# keep counting from the whole odometer when never logged, exactly as before.
|
||
MAINTENANCE_COUNT_FROM_ENABLE_TYPES = frozenset(
|
||
{"salt", "rinse_aid", "lint_filter", "condenser_clean"}
|
||
)
|
||
# User-defined maintenance tasks (#461), stored per device in the profile store
|
||
# ("maintenance_tasks") next to the log entries that reference them. Each has a
|
||
# free-text name and an interval in cycles and/or days (0 = off for that axis);
|
||
# it is due when either is reached. Ids carry this prefix so they can never collide
|
||
# with a built-in type.
|
||
MAINTENANCE_CUSTOM_TASK_PREFIX = "custom_"
|
||
MAINTENANCE_CUSTOM_TASK_MAX = 20
|
||
MAINTENANCE_TASK_NAME_MAX = 60
|
||
MAINTENANCE_INTERVAL_CYCLES_MAX = 100000
|
||
MAINTENANCE_INTERVAL_DAYS_MAX = 3650
|
||
# 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 setting presets (sandbox snapshots, per device) ────────────────
|
||
# Named snapshots of the Playground control panel's values, stored under the
|
||
# "playground_presets" store key. They never touch the live config: publishing a
|
||
# value to entry.options is always an explicit, per-setting user action.
|
||
PLAYGROUND_PRESET_MAX: int = 30 # per-device cap (keeps the store small)
|
||
PLAYGROUND_PRESET_NAME_MAX: int = 60 # preset name length cap
|
||
|
||
# ─── Which cycle categories count as evidence for a profile ────────────────────
|
||
# A profile's envelope (its average curve + duration/energy spread) and the matching
|
||
# template are built from stored cycles. By default all three categories count. Untick a
|
||
# category and it stops shaping profiles - useful when you do not trust imported data -
|
||
# without deleting anything: the cycles remain stored, listed and deletable.
|
||
#
|
||
# This gates *evidence* only (`ProfileStore.iter_evidence_cycles`), never
|
||
# `iter_stored_cycles`/`find_stored_cycle`. Profile garbage collection and sample repair
|
||
# delete or re-point a profile whose sample cycle resolves to nothing, so they must keep
|
||
# seeing every stored cycle: a cycle excluded from evidence is still a stored cycle, and
|
||
# gating those lookups would destroy a backfill-only profile the moment someone unticked
|
||
# imported history.
|
||
CONF_PROFILE_EVIDENCE_SOURCES = "profile_evidence_sources"
|
||
EVIDENCE_REAL_CYCLES = "real_cycles"
|
||
EVIDENCE_REFERENCE_CYCLES = "reference_cycles"
|
||
EVIDENCE_BACKFILL_CYCLES = "backfill_cycles"
|
||
# All three match the export taxonomy (`_EXPORT_CATEGORIES`), which the selective
|
||
# export/import wizard enumerates per category (register item 129e).
|
||
PROFILE_EVIDENCE_SOURCES = (
|
||
EVIDENCE_REAL_CYCLES,
|
||
EVIDENCE_REFERENCE_CYCLES,
|
||
EVIDENCE_BACKFILL_CYCLES,
|
||
)
|
||
# All three: the pre-setting behaviour, so an upgrade changes nothing.
|
||
DEFAULT_PROFILE_EVIDENCE_SOURCES = list(PROFILE_EVIDENCE_SOURCES)
|
||
|
||
# ─── Historical power-data import (issue #344) ─────────────────────────────────
|
||
# An HA history export (or a recorder read) is a *change-based* stream: a steady
|
||
# 0 W emits no rows at all, so it cannot be fed to the detector as-is (doing so
|
||
# produces multi-day `force_stopped` blobs). `history_import.py` pre-segments the
|
||
# stream into activity blocks first; these constants govern that pre-pass.
|
||
HISTORY_IMPORT_MAX_BYTES: int = 32 * 1024 * 1024 # staged upload cap (~32 MiB of CSV text)
|
||
HISTORY_IMPORT_MAX_ROWS: int = 500_000 # parsed-row cap (≈ a month at 5 s)
|
||
HISTORY_IMPORT_CHUNK_BYTES: int = 512 * 1024 # per-WS-message upload chunk (frame cap is 4 MiB)
|
||
# Samples replayed per executor job. 4000 was ~1.0 s of GIL per job on a desktop,
|
||
# i.e. the #311 freeze pattern on a Pi (audit PLAYGROUND-11); 1000 is ~0.25 s.
|
||
HISTORY_IMPORT_CHUNK_SAMPLES: int = 1000
|
||
HISTORY_IMPORT_MIN_BLOCK_SAMPLES: int = 20 # floor for the per-block sample gate
|
||
HISTORY_IMPORT_MAX_MEDIAN_INTERVAL_S: float = 120.0 # floor for the per-block cadence gate; the
|
||
# effective gate is
|
||
# max(this, 4 x sampling_interval) so a plug
|
||
# that legitimately reports every 60 s is not
|
||
# rejected
|
||
HISTORY_IMPORT_EDGE_GAP_S: float = 60.0 # leading samples this far from the block body
|
||
# are hourly-average debris and are trimmed
|
||
# (leading edge ONLY - trimming the trailing
|
||
# edge eats a real cycle's low-power tail)
|
||
HISTORY_IMPORT_MAX_BRIDGE_S: float = 300.0 # an `unavailable` hole up to this long inside
|
||
# a block is bridged as a plain gap (Wi-Fi
|
||
# blip, HA restart), not a cut (PLAYGROUND-06)
|
||
HISTORY_IMPORT_MAX_BLOCK_SPAN_S: float = 12 * 3600.0 # a block longer than this can only produce the
|
||
# detector's 8 h `force_stopped` blob, so it is
|
||
# reported rather than replayed
|
||
HISTORY_IMPORT_DENSIFY_STEP_S: float = 30.0 # cadence of the synthetic samples inserted into
|
||
# a carried-forward *quiet* gap, so the
|
||
# detector's gap-free quiet tally can accrue
|
||
# exactly as it does live
|
||
HISTORY_IMPORT_TAIL_STEP_S: float = 30.0 # synthetic quiet-tail cadence used to close the
|
||
# last cycle of a block
|
||
HISTORY_IMPORT_MAX_SEGMENTS: int = 60 # candidates surfaced by one scan
|
||
HISTORY_IMPORT_MAX_TOTAL_CYCLES: int = 200 # total backfilled cycles kept per device
|
||
# (`backfill_cycles` has no retention pass and
|
||
# the whole store blob is rewritten on every
|
||
# throttled active-cycle save)
|
||
HISTORY_IMPORT_RECORDER_MAX_DAYS: int = 3700 # ~10 years. HA's default `purge_keep_days`
|
||
# is 10, but a recorder configured to keep
|
||
# full-resolution states for years is a real
|
||
# setup and must not be capped out of reach.
|
||
# Reaching past what the recorder holds simply
|
||
# returns fewer rows; the real guard is
|
||
# HISTORY_IMPORT_MAX_ROWS, which stops the
|
||
# day-by-day read as soon as enough accrues.
|
||
HISTORY_IMPORT_RECORDER_EMPTY_DAY_STOP: int = 30 # consecutive empty days that end the walk.
|
||
# Within the retention window a day always
|
||
# yields at least the carried start-time state,
|
||
# so a run of truly empty days means the
|
||
# recorder has been purged past this point -
|
||
# without this, a 10-year request would issue
|
||
# thousands of pointless queries.
|
||
HISTORY_IMPORT_SOURCE: str = "history_import" # `meta.source` marker on imported cycles
|
||
|
||
|
||
def numeric_option_keys() -> dict[str, type]:
|
||
"""Option keys whose compiled default is a number -> that default's type.
|
||
|
||
Derived from the ``CONF_X`` / ``DEFAULT_X`` naming pair, so a new numeric
|
||
setting is covered without a list to maintain (audit PLATFORM-13).
|
||
"""
|
||
g = globals()
|
||
out: dict[str, type] = {}
|
||
for name, key in g.items():
|
||
if not name.startswith("CONF_") or not isinstance(key, str):
|
||
continue
|
||
default = g.get("DEFAULT_" + name[5:])
|
||
if isinstance(default, (int, float)) and not isinstance(default, bool):
|
||
out[key] = type(default)
|
||
return out
|
||
|
||
|
||
def coerce_numeric_option(value: Any, kind: type) -> float | int | None:
|
||
"""``value`` as a finite number of ``kind``'s type, or None if it is not one."""
|
||
if isinstance(value, bool):
|
||
return None
|
||
try:
|
||
number = float(value)
|
||
except (TypeError, ValueError, OverflowError):
|
||
return None
|
||
if not math.isfinite(number):
|
||
return None
|
||
if kind is int:
|
||
return int(number) if number.is_integer() else number
|
||
return number
|
||
|
||
|
||
def drop_invalid_numeric_options(options: Any) -> tuple[dict[str, Any], list[str]]:
|
||
"""``(options without non-numeric numeric settings, the keys dropped)``.
|
||
|
||
A non-numeric value for a numeric setting is dropped so its default applies;
|
||
stored, it raised in the manager's constructor and the entry never set up
|
||
again (audit PLATFORM-13, register item 279).
|
||
"""
|
||
if not isinstance(options, dict):
|
||
return {}, []
|
||
kinds = numeric_option_keys()
|
||
clean = dict(options)
|
||
dropped: list[str] = []
|
||
for key, kind in kinds.items():
|
||
if key in clean and clean[key] is not None:
|
||
number = coerce_numeric_option(clean[key], kind)
|
||
if number is None:
|
||
clean.pop(key)
|
||
dropped.append(key)
|
||
else:
|
||
clean[key] = number
|
||
return clean, dropped
|
||
|