Add stove activity detector blueprint using split-phase power balance heuristic
Experimental first draft: infers electric range activity (off/active/probably_on) from whole-panel split-phase power alone, since there's no dedicated CT clamp on the stove circuit. Uses phase-balance (240V loads both legs symmetrically) as the primary discriminator against 120V kitchen appliances in the same wattage range.
This commit is contained in:
@@ -0,0 +1,433 @@
|
|||||||
|
blueprint:
|
||||||
|
name: Stove Activity Detector (Split-Phase Power Heuristic)
|
||||||
|
description: >
|
||||||
|
Infers electric range / stove activity from whole-panel split-phase power alone, for
|
||||||
|
setups where a North American Aeotec Home Energy Monitor (or similar HEM) reports only
|
||||||
|
the two aggregate mains legs and there is NO dedicated CT clamp on the range circuit.
|
||||||
|
|
||||||
|
|
||||||
|
How it works: a North American electric range is a 240V load, so it draws current from
|
||||||
|
BOTH legs of split-phase power symmetrically. Almost every other appliance in the same
|
||||||
|
wattage band (microwave, kettle, toaster, coffee maker, air fryer) is a 120V load and
|
||||||
|
only draws from ONE leg. The primary discriminator here is therefore phase BALANCE - does
|
||||||
|
a power step appear roughly equally on both legs? - which is far more reliable than
|
||||||
|
wattage magnitude alone, since many 120V kitchen appliances overlap the same 900-4000W
|
||||||
|
range as stove elements. A secondary discriminator checks that the step magnitude falls
|
||||||
|
inside a plausible single-element / oven-element window. Because stove elements and oven
|
||||||
|
thermostats duty-cycle on and off, when a qualifying rise is seen its magnitude is
|
||||||
|
remembered, and a later matching fall is treated as that same element cycling off
|
||||||
|
(probably_on) rather than an unrelated event or a confirmed shutdown.
|
||||||
|
|
||||||
|
|
||||||
|
SETUP - before using this blueprint you MUST pre-create two helper entities:
|
||||||
|
|
||||||
|
* An input_select helper for the stove state, with EXACTLY these three options
|
||||||
|
(spelled and cased exactly like this): off, active, probably_on
|
||||||
|
|
||||||
|
* An input_number helper used as internal scratch space for the last detected
|
||||||
|
signature wattage. Give it a wide range (e.g. min 0, max 10000). This is internal
|
||||||
|
scratch space and is not meant for dashboards.
|
||||||
|
|
||||||
|
|
||||||
|
OUTPUT STATES (written to the input_select helper):
|
||||||
|
|
||||||
|
* off - confidently no stove activity.
|
||||||
|
|
||||||
|
* active - a balanced, in-window power step matching a plausible element
|
||||||
|
signature is currently being drawn.
|
||||||
|
|
||||||
|
* probably_on - was recently active and a matching element drop was seen; likely
|
||||||
|
between duty-cycle pulses (elements/ovens cycle on and off via
|
||||||
|
internal thermostats/simmerstats), not yet confirmed off. Reverts to
|
||||||
|
off automatically if no matching activity resumes within the cycle
|
||||||
|
timeout.
|
||||||
|
|
||||||
|
|
||||||
|
KNOWN LIMITATIONS:
|
||||||
|
|
||||||
|
* Other 240V appliances - electric dryer, electric water heater, EV charger, HVAC
|
||||||
|
electric heat strips, well pump - can FALSE-POSITIVE if their wattage lands in the
|
||||||
|
configured element window and they are also balanced across both legs.
|
||||||
|
|
||||||
|
* Near-simultaneous multi-burner, or burner + oven, startup within the settle window
|
||||||
|
may appear as one larger combined edge, so element_max_watts may need to be raised to
|
||||||
|
catch those - at the cost of more false positives from large 240V loads.
|
||||||
|
|
||||||
|
* This is a first-draft heuristic. Run it with verbose_diagnostics ON and compare the
|
||||||
|
logged edge events against manually-observed real stove usage to calibrate the numeric
|
||||||
|
thresholds BEFORE relying on it for anything automated or critical.
|
||||||
|
domain: automation
|
||||||
|
input:
|
||||||
|
phase1_power:
|
||||||
|
name: Phase 1 Power Sensor
|
||||||
|
description: One leg of the split-phase mains power (whole-panel aggregate, watts).
|
||||||
|
selector:
|
||||||
|
entity:
|
||||||
|
domain: sensor
|
||||||
|
|
||||||
|
phase2_power:
|
||||||
|
name: Phase 2 Power Sensor
|
||||||
|
description: The other leg of the split-phase mains power (whole-panel aggregate, watts).
|
||||||
|
selector:
|
||||||
|
entity:
|
||||||
|
domain: sensor
|
||||||
|
|
||||||
|
state_helper:
|
||||||
|
name: Stove State Helper
|
||||||
|
description: >
|
||||||
|
Pre-created input_select helper that this blueprint writes the inferred stove state
|
||||||
|
into. It MUST have exactly three options: off, active, probably_on.
|
||||||
|
selector:
|
||||||
|
entity:
|
||||||
|
domain: input_select
|
||||||
|
|
||||||
|
signature_helper:
|
||||||
|
name: Signature Scratch Helper
|
||||||
|
description: >
|
||||||
|
Pre-created input_number helper used as internal scratch space to remember the last
|
||||||
|
detected element signature wattage. Not meant for dashboards. Give it a wide range,
|
||||||
|
e.g. min 0 / max 10000.
|
||||||
|
selector:
|
||||||
|
entity:
|
||||||
|
domain: input_number
|
||||||
|
|
||||||
|
element_min_watts:
|
||||||
|
name: Element Minimum Watts
|
||||||
|
description: Lower bound of a plausible single stove/oven element power step.
|
||||||
|
default: 900
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
min: 200
|
||||||
|
max: 3000
|
||||||
|
unit_of_measurement: W
|
||||||
|
mode: box
|
||||||
|
|
||||||
|
element_max_watts:
|
||||||
|
name: Element Maximum Watts
|
||||||
|
description: >
|
||||||
|
Upper bound of a plausible element power step. Raise this if simultaneous
|
||||||
|
multi-burner startups are being missed (at the cost of more false positives).
|
||||||
|
default: 4000
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
min: 1000
|
||||||
|
max: 8000
|
||||||
|
unit_of_measurement: W
|
||||||
|
mode: box
|
||||||
|
|
||||||
|
balance_tolerance_pct:
|
||||||
|
name: Phase Balance Tolerance (%)
|
||||||
|
description: >
|
||||||
|
How closely the two legs must move together for a step to count as a 240V load.
|
||||||
|
Smaller is stricter (fewer false positives, more false negatives).
|
||||||
|
default: 20
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
min: 5
|
||||||
|
max: 50
|
||||||
|
unit_of_measurement: "%"
|
||||||
|
|
||||||
|
signature_match_tolerance_pct:
|
||||||
|
name: Signature Match Tolerance (%)
|
||||||
|
description: >
|
||||||
|
How closely a falling step must match the remembered rising signature to be treated
|
||||||
|
as the same element cycling off.
|
||||||
|
default: 25
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
min: 5
|
||||||
|
max: 60
|
||||||
|
unit_of_measurement: "%"
|
||||||
|
|
||||||
|
settle_delay_seconds:
|
||||||
|
name: Settle Delay (seconds)
|
||||||
|
description: >
|
||||||
|
Time to wait after one leg reports so the paired leg's report can land before
|
||||||
|
resampling both. Handles the fact that HA reports the two legs as separate,
|
||||||
|
non-atomic state-changed events even for one physical 240V event.
|
||||||
|
default: 3
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
min: 1
|
||||||
|
max: 15
|
||||||
|
unit_of_measurement: seconds
|
||||||
|
|
||||||
|
cycle_timeout_minutes:
|
||||||
|
name: Cycle Timeout (minutes)
|
||||||
|
description: >
|
||||||
|
How long to remain in probably_on with no matching activity before reverting to off.
|
||||||
|
Should comfortably exceed a normal element/oven off-pulse duration.
|
||||||
|
default: 12
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
min: 1
|
||||||
|
max: 60
|
||||||
|
unit_of_measurement: minutes
|
||||||
|
|
||||||
|
verbose_diagnostics:
|
||||||
|
name: Verbose Diagnostics
|
||||||
|
description: >
|
||||||
|
Emits a notification for every considered power edge (not just state transitions),
|
||||||
|
intended for calibrating thresholds against real observed stove usage. Turn off once
|
||||||
|
trusted.
|
||||||
|
default: true
|
||||||
|
selector:
|
||||||
|
boolean:
|
||||||
|
|
||||||
|
notify_target:
|
||||||
|
name: Notification Target
|
||||||
|
description: >
|
||||||
|
Where to send diagnostic and transition notifications (e.g. a mobile app notify
|
||||||
|
service). If left empty, a persistent notification is created instead.
|
||||||
|
default: []
|
||||||
|
selector:
|
||||||
|
target:
|
||||||
|
entity:
|
||||||
|
domain: notify
|
||||||
|
|
||||||
|
# Serialized queued execution (not restart) is what makes the snapshot-then-settle-delay
|
||||||
|
# resample technique safe: near-simultaneous phase1/phase2 trigger firings are processed one
|
||||||
|
# after another instead of restarting each other mid-delay or being dropped.
|
||||||
|
mode: queued
|
||||||
|
max: 10
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
- platform: state
|
||||||
|
id: power_change
|
||||||
|
entity_id:
|
||||||
|
- !input phase1_power
|
||||||
|
- !input phase2_power
|
||||||
|
# Fires only if the state helper has stayed in probably_on continuously for the timeout.
|
||||||
|
# This self-cancels if the state leaves probably_on (e.g. back to active) before the timer
|
||||||
|
# elapses - no extra bookkeeping needed.
|
||||||
|
- platform: state
|
||||||
|
id: cycle_timeout
|
||||||
|
entity_id: !input state_helper
|
||||||
|
to: "probably_on"
|
||||||
|
for:
|
||||||
|
minutes: !input cycle_timeout_minutes
|
||||||
|
|
||||||
|
variables:
|
||||||
|
phase1_power: !input phase1_power
|
||||||
|
phase2_power: !input phase2_power
|
||||||
|
state_helper: !input state_helper
|
||||||
|
signature_helper: !input signature_helper
|
||||||
|
element_min_watts: !input element_min_watts
|
||||||
|
element_max_watts: !input element_max_watts
|
||||||
|
balance_tolerance_pct: !input balance_tolerance_pct
|
||||||
|
signature_match_tolerance_pct: !input signature_match_tolerance_pct
|
||||||
|
cycle_timeout_minutes: !input cycle_timeout_minutes
|
||||||
|
verbose_diagnostics: !input verbose_diagnostics
|
||||||
|
notify_target: !input notify_target
|
||||||
|
|
||||||
|
action:
|
||||||
|
- choose:
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POWER CHANGE: settle-delay resample, then state-machine evaluation.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- conditions: "{{ trigger.id == 'power_change' }}"
|
||||||
|
sequence:
|
||||||
|
# Availability guard at snapshot point 1 - abort the run rather than compute garbage.
|
||||||
|
- condition: template
|
||||||
|
value_template: >
|
||||||
|
{{ states(phase1_power) not in ['unavailable', 'unknown']
|
||||||
|
and states(phase2_power) not in ['unavailable', 'unknown'] }}
|
||||||
|
|
||||||
|
# Snapshot both legs immediately, before the paired leg's report lands.
|
||||||
|
- variables:
|
||||||
|
phase1_before: "{{ states(phase1_power) | float(0) }}"
|
||||||
|
phase2_before: "{{ states(phase2_power) | float(0) }}"
|
||||||
|
|
||||||
|
# Let the paired leg's separate state-changed event arrive.
|
||||||
|
- delay:
|
||||||
|
seconds: !input settle_delay_seconds
|
||||||
|
|
||||||
|
# Availability guard at snapshot point 2.
|
||||||
|
- condition: template
|
||||||
|
value_template: >
|
||||||
|
{{ states(phase1_power) not in ['unavailable', 'unknown']
|
||||||
|
and states(phase2_power) not in ['unavailable', 'unknown'] }}
|
||||||
|
|
||||||
|
# Resample and compute the combined split-phase edge.
|
||||||
|
- variables:
|
||||||
|
phase1_after: "{{ states(phase1_power) | float(0) }}"
|
||||||
|
phase2_after: "{{ states(phase2_power) | float(0) }}"
|
||||||
|
delta1: "{{ phase1_after - phase1_before }}"
|
||||||
|
delta2: "{{ phase2_after - phase2_before }}"
|
||||||
|
combined_delta: "{{ delta1 + delta2 }}"
|
||||||
|
magnitude: "{{ abs(combined_delta) }}"
|
||||||
|
balance_pct: >
|
||||||
|
{{ abs(delta1 - delta2) / max(abs(delta1), abs(delta2), 1) * 100 }}
|
||||||
|
is_balanced: "{{ balance_pct <= balance_tolerance_pct }}"
|
||||||
|
in_window: >
|
||||||
|
{{ magnitude >= element_min_watts and magnitude <= element_max_watts }}
|
||||||
|
current_state: "{{ states(state_helper) }}"
|
||||||
|
|
||||||
|
- choose:
|
||||||
|
# ---- RISE: balanced, in-window step up -> ACTIVE ----
|
||||||
|
- conditions: "{{ combined_delta > 0 and in_window and is_balanced }}"
|
||||||
|
sequence:
|
||||||
|
# Remember this element's signature magnitude for later fall-matching.
|
||||||
|
- service: input_number.set_value
|
||||||
|
target:
|
||||||
|
entity_id: "{{ signature_helper }}"
|
||||||
|
data:
|
||||||
|
value: "{{ magnitude | round(1) }}"
|
||||||
|
- choose:
|
||||||
|
- conditions: "{{ current_state != 'active' }}"
|
||||||
|
sequence:
|
||||||
|
- service: input_select.select_option
|
||||||
|
target:
|
||||||
|
entity_id: "{{ state_helper }}"
|
||||||
|
data:
|
||||||
|
option: active
|
||||||
|
- choose:
|
||||||
|
- conditions: "{{ verbose_diagnostics }}"
|
||||||
|
sequence:
|
||||||
|
- choose:
|
||||||
|
- conditions: "{{ notify_target | length > 0 }}"
|
||||||
|
sequence:
|
||||||
|
- service: notify.{{ notify_target }}
|
||||||
|
data:
|
||||||
|
message: >
|
||||||
|
Stove signature detected: +{{ magnitude | round(0) }}W
|
||||||
|
(phase balance {{ balance_pct | round(1) }}%, delta1
|
||||||
|
{{ delta1 | round(0) }}W / delta2 {{ delta2 | round(0) }}W)
|
||||||
|
-> ACTIVE
|
||||||
|
default:
|
||||||
|
- service: persistent_notification.create
|
||||||
|
data:
|
||||||
|
title: Stove Activity Detector
|
||||||
|
message: >
|
||||||
|
Stove signature detected: +{{ magnitude | round(0) }}W
|
||||||
|
(phase balance {{ balance_pct | round(1) }}%, delta1
|
||||||
|
{{ delta1 | round(0) }}W / delta2 {{ delta2 | round(0) }}W)
|
||||||
|
-> ACTIVE
|
||||||
|
|
||||||
|
# ---- FALL: balanced, in-window step down -> possible cycle-off ----
|
||||||
|
- conditions: "{{ combined_delta < 0 and in_window and is_balanced }}"
|
||||||
|
sequence:
|
||||||
|
- variables:
|
||||||
|
signature: "{{ states(signature_helper) | float(0) }}"
|
||||||
|
match_pct: >
|
||||||
|
{{ abs(magnitude - signature) / max(signature, 1) * 100 }}
|
||||||
|
- choose:
|
||||||
|
# Fall matches the remembered rising signature: same element cycling off.
|
||||||
|
- conditions: >
|
||||||
|
{{ signature > 0 and match_pct <= signature_match_tolerance_pct }}
|
||||||
|
sequence:
|
||||||
|
- choose:
|
||||||
|
- conditions: "{{ current_state == 'active' }}"
|
||||||
|
sequence:
|
||||||
|
- service: input_select.select_option
|
||||||
|
target:
|
||||||
|
entity_id: "{{ state_helper }}"
|
||||||
|
data:
|
||||||
|
option: probably_on
|
||||||
|
- choose:
|
||||||
|
- conditions: "{{ verbose_diagnostics }}"
|
||||||
|
sequence:
|
||||||
|
- choose:
|
||||||
|
- conditions: "{{ notify_target | length > 0 }}"
|
||||||
|
sequence:
|
||||||
|
- service: notify.{{ notify_target }}
|
||||||
|
data:
|
||||||
|
message: >
|
||||||
|
Matching element drop: -{{ magnitude | round(0) }}W
|
||||||
|
vs remembered {{ signature | round(0) }}W (match
|
||||||
|
{{ match_pct | round(1) }}%). {{ 'ACTIVE ->
|
||||||
|
PROBABLY_ON' if current_state == 'active' else
|
||||||
|
'state unchanged (' ~ current_state ~ ')' }}
|
||||||
|
default:
|
||||||
|
- service: persistent_notification.create
|
||||||
|
data:
|
||||||
|
title: Stove Activity Detector
|
||||||
|
message: >
|
||||||
|
Matching element drop: -{{ magnitude | round(0) }}W
|
||||||
|
vs remembered {{ signature | round(0) }}W (match
|
||||||
|
{{ match_pct | round(1) }}%). {{ 'ACTIVE ->
|
||||||
|
PROBABLY_ON' if current_state == 'active' else
|
||||||
|
'state unchanged (' ~ current_state ~ ')' }}
|
||||||
|
default:
|
||||||
|
# Ambiguous / unrelated drop - do NOT change state. Log only if it is
|
||||||
|
# a meaningful edge (skip sub-50W noise to avoid spam).
|
||||||
|
- choose:
|
||||||
|
- conditions: "{{ verbose_diagnostics and magnitude >= 50 }}"
|
||||||
|
sequence:
|
||||||
|
- choose:
|
||||||
|
- conditions: "{{ notify_target | length > 0 }}"
|
||||||
|
sequence:
|
||||||
|
- service: notify.{{ notify_target }}
|
||||||
|
data:
|
||||||
|
message: >
|
||||||
|
Ignored non-matching drop: -{{ magnitude | round(0) }}W
|
||||||
|
vs remembered {{ signature | round(0) }}W (match
|
||||||
|
{{ match_pct | round(1) }}%) - no state change.
|
||||||
|
default:
|
||||||
|
- service: persistent_notification.create
|
||||||
|
data:
|
||||||
|
title: Stove Activity Detector
|
||||||
|
message: >
|
||||||
|
Ignored non-matching drop: -{{ magnitude | round(0) }}W
|
||||||
|
vs remembered {{ signature | round(0) }}W (match
|
||||||
|
{{ match_pct | round(1) }}%) - no state change.
|
||||||
|
|
||||||
|
# ---- ELSE: imbalanced or out-of-window edge -> no state change ----
|
||||||
|
default:
|
||||||
|
- variables:
|
||||||
|
reason: >
|
||||||
|
{% if not in_window and not is_balanced %}out-of-window and imbalanced
|
||||||
|
{% elif not in_window %}out-of-window magnitude
|
||||||
|
{% elif not is_balanced %}imbalanced across legs
|
||||||
|
{% else %}no net change{% endif %}
|
||||||
|
- choose:
|
||||||
|
- conditions: "{{ verbose_diagnostics and magnitude >= 50 }}"
|
||||||
|
sequence:
|
||||||
|
- choose:
|
||||||
|
- conditions: "{{ notify_target | length > 0 }}"
|
||||||
|
sequence:
|
||||||
|
- service: notify.{{ notify_target }}
|
||||||
|
data:
|
||||||
|
message: >
|
||||||
|
Ignored power edge: delta1 {{ delta1 | round(0) }}W / delta2
|
||||||
|
{{ delta2 | round(0) }}W, combined {{ combined_delta | round(0) }}W,
|
||||||
|
magnitude {{ magnitude | round(0) }}W, balance
|
||||||
|
{{ balance_pct | round(1) }}% - {{ reason | trim }}; no state
|
||||||
|
change.
|
||||||
|
default:
|
||||||
|
- service: persistent_notification.create
|
||||||
|
data:
|
||||||
|
title: Stove Activity Detector
|
||||||
|
message: >
|
||||||
|
Ignored power edge: delta1 {{ delta1 | round(0) }}W / delta2
|
||||||
|
{{ delta2 | round(0) }}W, combined {{ combined_delta | round(0) }}W,
|
||||||
|
magnitude {{ magnitude | round(0) }}W, balance
|
||||||
|
{{ balance_pct | round(1) }}% - {{ reason | trim }}; no state
|
||||||
|
change.
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# CYCLE TIMEOUT: stayed in probably_on with no matching activity -> OFF.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- conditions: "{{ trigger.id == 'cycle_timeout' }}"
|
||||||
|
sequence:
|
||||||
|
- service: input_select.select_option
|
||||||
|
target:
|
||||||
|
entity_id: "{{ state_helper }}"
|
||||||
|
data:
|
||||||
|
option: "off"
|
||||||
|
- choose:
|
||||||
|
- conditions: "{{ verbose_diagnostics }}"
|
||||||
|
sequence:
|
||||||
|
- choose:
|
||||||
|
- conditions: "{{ notify_target | length > 0 }}"
|
||||||
|
sequence:
|
||||||
|
- service: notify.{{ notify_target }}
|
||||||
|
data:
|
||||||
|
message: >
|
||||||
|
No matching stove activity for {{ cycle_timeout_minutes }}m -> OFF
|
||||||
|
default:
|
||||||
|
- service: persistent_notification.create
|
||||||
|
data:
|
||||||
|
title: Stove Activity Detector
|
||||||
|
message: >
|
||||||
|
No matching stove activity for {{ cycle_timeout_minutes }}m -> OFF
|
||||||
Reference in New Issue
Block a user