From 7a0d75d9db5857f182fa0eb830fd2de7baa171aa Mon Sep 17 00:00:00 2001 From: jm Date: Thu, 9 Jul 2026 22:04:16 -0400 Subject: [PATCH] Add TOU winter peak-shaving; parameterize by season (v3) Pepco MD TOU-P has different on-peak windows by season: summer (Jun-Sep) 14:00-19:00, winter (Oct-May) 06:00-09:00 and 17:00-21:00. Heat is gas, so winter electric peak logic is cooling-only. * pre-cool: bounded to summer (Jun-Sep); winter has no pre-cool. * peak-setback (hold) and peak-ends (return): unified across seasons via a season self-select (month -> season; triggers carry id: summer/winter and act only when trigger.id == season) plus a params map for setpoints. Summer forces cool + 82 / returns 74 daily; winter sets 82 / returns 74 ONLY when already in cool mode, so it never disturbs the gas furnace. * new ac_winter_morning_kill: 06:00 winter work days, if in cool mode set hvac_mode off to dodge the 6-9am winter peak. No auto-resume yet (planned). Decision logic verified as a truth table via the template engine across summer/winter x cooling x workday before commit. Deployed in place (existing ids unchanged); new id ac_winter_morning_kill added. Co-Authored-By: Claude Opus 4.8 (1M context) --- AUTOMATIONS.md | 89 +++++++++++++++---------- automations/ac_peak_ends.yaml | 49 +++++++++----- automations/ac_peak_setback.yaml | 59 ++++++++++------ automations/ac_precool_before_peak.yaml | 34 +++++----- automations/ac_winter_morning_kill.yaml | 43 ++++++++++++ 5 files changed, 190 insertions(+), 84 deletions(-) create mode 100644 automations/ac_winter_morning_kill.yaml diff --git a/AUTOMATIONS.md b/AUTOMATIONS.md index 15701d2..56980c7 100644 --- a/AUTOMATIONS.md +++ b/AUTOMATIONS.md @@ -4,6 +4,17 @@ Source of truth for the AC peak-shaving automations on the HA server (`192.168.128.3:8123`). This repo is authoritative; HA is a **deploy target** reached only through its REST config API — HA never sees these files directly. +## Utility rate context (Pepco MD, TOU-P, eff. 2026-02-01) + +| Season (rate) | Months | On-peak window(s), weekdays only | On-peak | Off-peak | +|---|---|---|---|---| +| **Summer** | Jun–Sep | **14:00–19:00** (2–7 PM) | $0.42 | $0.17 | +| **Winter** | Oct–May | **06:00–09:00** and **17:00–21:00** | $0.32 | $0.13 | + +Weekends and **federal** holidays are always off-peak — handled by +`binary_sensor.workday_sensor` (with a Mon–Fri fallback). Heating is **gas** +(not on this electric peak logic), so the winter rules are **cooling-only**. + ## Identity model (how a file becomes a live automation) ``` @@ -22,67 +33,77 @@ reached only through its REST config API — HA never sees these files directly. | **`alias`** | `AC - Peak setback to 82` | you (in YAML) | UI name; seeds `entity_id` at first create | rarely | | **`entity_id`** | `automation.ac_peak_setback_to_82` | HA, from `slugify(alias)` | dashboards, other automations, scripts | sticky once registered | -Key point: **`entity_id` derives from `alias`, not `id`.** Reference automations -elsewhere in HA by their `entity_id`. +`entity_id` derives from `alias`, not `id`. Reference automations elsewhere in +HA by their `entity_id`. ## Deploying ```bash source ~/.bashrc # provides HA_API_KEY (long-lived token) python3 deploy.py --check # parse only, no writes -python3 deploy.py # replace all three in place (same ids) +python3 deploy.py # deploy every automations/*.yaml (in place, by id) python3 deploy.py ac_peak_ends # deploy just one by id ``` -`deploy.py` POSTs each file to `/api/config/automation/config/`. Same `id` -= in-place replacement, so `entity_id`, run history, and traces stay continuous. +## Parameterization (the "params map" model) + +Rather than duplicate summer vs winter rules, each phase is **one self-selecting +automation**: + +- A `variables:` block computes `season` from the month + (`summer` = {6,7,8,9}, else `winter`). +- Time triggers carry `id: summer` / `id: winter`; the body only acts when + `trigger.id == season`, so the off-season time is a harmless no-op. +- Setpoints come from a `params` map + (`{'summer': {'hold':82,'ret':74}, 'winter': {'hold':82,'ret':74}}`) so the + two seasons can diverge later by editing one line. (Both are equal today.) + The map is repeated in `ac_peak_setback` and `ac_peak_ends`; keep them in sync. ## The rules All act on `climate.t6_pro_z_wave_programmable_thermostat_with_smartstart`. -The "peak" window is **14:00–19:00 (2–7 PM) local (America/New_York)**. -### 1. `ac_precool_before_peak` — Pre-cool before peak -- **12:00**, work days. Fetches today's daily forecast high and pre-cools: - | forecast high | setpoint | - |---|---| - | ≥ 90°F | 68 | - | ≥ 85°F | 70 | - | ≥ 80°F | 71 | - | < 80°F | *(no pre-cool)* | +### 1. `ac_precool_before_peak` — Pre-cool before peak (summer only) +- **12:00**, summer work days. Fetches today's forecast high and pre-cools: + ≥90→68, ≥85→70, ≥80→71, <80→no pre-cool. +- Winter has no pre-cool (marginal for an evening peak). - **Fallbacks:** workday sensor broken → Mon–Fri; forecast broken → assume 88°F - high → 70°F (`continue_on_error` covers a hard error, the `fc is defined and is - mapping` guard covers an empty/None response). + → 70°F (`continue_on_error` + `fc is defined and is mapping` guard). -### 2. `ac_peak_setback` — Peak setback to 82 -- **14:00**, work days. Sets cool / **82°F** to coast through the peak. -- **Fallback:** workday sensor broken → Mon–Fri. +### 2. `ac_peak_setback` — Peak setback to 82 (both seasons) +- **14:00** (summer) / **17:00** (winter), work days. +- Summer: forces `cool` and sets **82**. +- Winter: **cooling-only** — sets 82 *only if already in `cool` mode* (never + forces mode, never touches gas heat). -### 3. `ac_peak_ends` — Peak ends, return to 74 -- **19:00**, **every day** (intentionally not workday-gated). Sets **74°F**. -- No external dependencies, so nothing to fall back on. +### 3. `ac_peak_ends` — Peak ends, return to 74 (both seasons) +- **19:00** (summer) / **21:00** (winter). Sets **74**. +- Summer returns daily; winter returns only if in `cool` mode. + +### 4. `ac_winter_morning_kill` — Winter morning peak kill (new in v3) +- **06:00**, winter work days. If in `cool` mode, sets `hvac_mode: off` to dodge + the 6–9 AM winter peak. **No auto-resume yet** (add later). Cooling-only. ## Failure-mode design -Two independent dependencies, each handled so a failure keeps sensible defaults -instead of skipping: - | Failure | Symptom | Handled by | Default taken | |---|---|---|---| | Workday/holiday sensor unavailable | `states()` ≠ `on`/`off` | `{% if wd in ['on','off'] %}…{% else %} now().weekday()<5` | Mon–Fri | | Weather service raises | action would abort | `continue_on_error: true` | run continues | | Weather returns empty/None | `fc` undefined / not a mapping | `{% if fc is defined and fc is mapping %}…{% else %} 88` | regular pre-cool (88→70) | +| Wrong-season trigger fires | `trigger.id != season` | `applies = trigger.id == season` guard | no-op | +| Winter, not cooling (e.g. gas heat on) | `is_state(climate,'cool')` false | cooling-only guard | no-op (furnace safe) | -Verified empirically on 2026-07-09 by forcing each failure against the live -automations and reading their execution traces (workday → `unavailable` still -ran the Mon–Fri branch; missing weather entity → `today_high=88`, setpoint 70, -`script_execution: finished`). +Fallback and season/cooling logic verified via HA's template engine and +execution traces before each deploy. ## Changelog - **2026-07-09 — v1 baseline**: imported the three live automations as-is. -- **2026-07-09 — v2**: added workday + weather fallbacks to pre-cool and - setback (a broken sensor/forecast no longer skips the run). Peak-ends - unchanged. Removed throwaway debug automations from the server: the three - `TEST - … (spoof, every 1 min)` rules and the `ac_flap_1758_1820` - (85/90 flap) experiment. +- **2026-07-09 — v2**: added workday + weather fallbacks to pre-cool and setback; + removed throwaway debug automations (three `TEST` spoofs + `ac_flap_1758_1820`). +- **2026-07-09 — v3**: TOU winter support. Pre-cool bounded to summer (Jun–Sep). + `ac_peak_setback` and `ac_peak_ends` unified across seasons via a season + self-select + `params` map (summer 14:00/19:00, winter 17:00/21:00), winter + cooling-only. Added `ac_winter_morning_kill` (6 AM winter, cooling→off, no + resume yet). diff --git a/automations/ac_peak_ends.yaml b/automations/ac_peak_ends.yaml index bec70f4..5d80b4e 100644 --- a/automations/ac_peak_ends.yaml +++ b/automations/ac_peak_ends.yaml @@ -1,29 +1,46 @@ -# ac_peak_ends — end of the utility peak, return the house to comfort +# ac_peak_ends — end of the on-peak window, return the house to comfort (both seasons) # # id / entity : ac_peak_ends -> automation.ac_peak_ends_return_to_74 -# trigger : 19:00 local (America/New_York), EVERY day -# purpose : Return the thermostat to 74F after the peak window. +# triggers : 19:00 (id: summer) and 21:00 (id: winter), local time +# purpose : Return the AC to 74F after the on-peak window ends. # -# Runs daily on purpose (no workday gate): you always want comfort back in the -# evening, weekend or holiday included. It has no external dependencies (no -# weather, no workday sensor), so there is nothing to fall back on — it is a -# plain, robust setpoint write. +# Self-selecting by season (peak *ends* at 19:00 summer, 21:00 winter). A trigger +# only acts when trigger.id == the current season. # -# v2 (2026-07-09): no behavior change; documented + version-controlled. +# Summer returns to 74 every day (comfort), matching the original behavior. +# Winter is COOLING-ONLY: it only returns to 74 when the thermostat is already in +# `cool`, so it never disturbs the gas furnace. +# +# Setpoints come from the `params` map (shared shape with ac_peak_setback). +# +# v3 (2026-07-09): unified summer+winter, season params map, winter cooling-only. id: ac_peak_ends alias: AC - Peak ends, return to 74 description: >- - v2 (2026-07-09). At 7pm every day, returns the thermostat to 74F after the - peak window. Intentionally runs daily (no workday gate) and has no external - dependencies. Managed in git; see AUTOMATIONS.md. + v3 (2026-07-09). Returns the AC to 74F after the on-peak window: summer 7pm, + winter 9pm. Summer returns daily; winter is cooling-only (never touches gas + heat). Managed in git; see AUTOMATIONS.md. mode: single triggers: - trigger: time at: "19:00:00" + id: summer + - trigger: time + at: "21:00:00" + id: winter conditions: [] actions: - - action: climate.set_temperature - target: - entity_id: climate.t6_pro_z_wave_programmable_thermostat_with_smartstart - data: - temperature: 74 + - variables: + season: "{{ 'summer' if now().month in [6,7,8,9] else 'winter' }}" + cooling: "{{ is_state('climate.t6_pro_z_wave_programmable_thermostat_with_smartstart','cool') }}" + params: "{{ {'summer': {'hold': 82, 'ret': 74}, 'winter': {'hold': 82, 'ret': 74}} }}" + applies: "{{ trigger.id == season }}" + - if: + - condition: template + value_template: "{{ applies and (season == 'summer' or cooling) }}" + then: + - action: climate.set_temperature + target: + entity_id: climate.t6_pro_z_wave_programmable_thermostat_with_smartstart + data: + temperature: "{{ params[season]['ret'] }}" diff --git a/automations/ac_peak_setback.yaml b/automations/ac_peak_setback.yaml index 2a6e578..ae2a53b 100644 --- a/automations/ac_peak_setback.yaml +++ b/automations/ac_peak_setback.yaml @@ -1,41 +1,62 @@ -# ac_peak_setback — let the house drift during the utility peak window +# ac_peak_setback — hold the house warm during the on-peak window (both seasons) # # id / entity : ac_peak_setback -> automation.ac_peak_setback_to_82 -# trigger : 14:00 local (America/New_York) -# purpose : On work days, set cool 82F to coast through the 14:00-19:00 peak. +# triggers : 14:00 (id: summer) and 17:00 (id: winter), local time +# purpose : Coast through the utility on-peak by setting the AC to 82F. # -# Graceful degradation (v2): -# * workday sensor unavailable/unknown -> fall back to a plain Mon-Fri check -# (a broken holiday sensor must not silently cancel the setback). -# The original used a blocking `condition` on the workday sensor, which would -# skip the run whenever the sensor was unavailable; that is the bug this fixes. +# Self-selecting by season (Pepco MD TOU-P peak windows): +# * summer (Jun-Sep): on-peak 14:00-19:00 -> the 14:00 trigger applies. +# * winter (Oct-May): evening on-peak 17:00-21:00 -> the 17:00 trigger applies. +# A trigger only acts when trigger.id == the current season, so the "wrong" +# time is a no-op. # -# v2 (2026-07-09): workday fallback added. See AUTOMATIONS.md. +# Safety: winter is COOLING-ONLY. Heat is gas (not on this electric peak logic), +# so winter never forces cool mode — it only nudges the setpoint when the +# thermostat is already in `cool`. Summer forces cool mode as before. +# +# Fallbacks: workday sensor unavailable -> Mon-Fri (see is_workday). +# +# Setpoints come from the `params` map so summer/winter can diverge later. +# +# v3 (2026-07-09): unified summer+winter, season params map, winter cooling-only. id: ac_peak_setback alias: AC - Peak setback to 82 description: >- - v2 (2026-07-09). At 2pm on work days, sets the thermostat to cool/82F to coast - through the 2-7pm peak. Falls back to Mon-Fri if the workday sensor fails. - Managed in git; see AUTOMATIONS.md. + v3 (2026-07-09). On work days, sets the AC to 82F to coast through the on-peak + window: summer 2pm (14:00-19:00 peak) and winter 5pm (17:00-21:00 peak). + Winter is cooling-only (never touches gas heat). Falls back to Mon-Fri if the + workday sensor fails. Managed in git; see AUTOMATIONS.md. mode: single triggers: - trigger: time at: "14:00:00" + id: summer + - trigger: time + at: "17:00:00" + id: winter conditions: [] actions: - variables: + season: "{{ 'summer' if now().month in [6,7,8,9] else 'winter' }}" is_workday: "{% set wd = states('binary_sensor.workday_sensor') %}{% if wd in ['on','off'] %}{{ wd == 'on' }}{% else %}{{ now().weekday() < 5 }}{% endif %}" + cooling: "{{ is_state('climate.t6_pro_z_wave_programmable_thermostat_with_smartstart','cool') }}" + params: "{{ {'summer': {'hold': 82, 'ret': 74}, 'winter': {'hold': 82, 'ret': 74}} }}" + applies: "{{ trigger.id == season }}" - if: - condition: template - value_template: "{{ is_workday }}" + value_template: "{{ applies and is_workday and (season == 'summer' or cooling) }}" then: - - action: climate.set_hvac_mode - target: - entity_id: climate.t6_pro_z_wave_programmable_thermostat_with_smartstart - data: - hvac_mode: cool + - if: + - condition: template + value_template: "{{ season == 'summer' }}" + then: + - action: climate.set_hvac_mode + target: + entity_id: climate.t6_pro_z_wave_programmable_thermostat_with_smartstart + data: + hvac_mode: cool - action: climate.set_temperature target: entity_id: climate.t6_pro_z_wave_programmable_thermostat_with_smartstart data: - temperature: 82 + temperature: "{{ params[season]['hold'] }}" diff --git a/automations/ac_precool_before_peak.yaml b/automations/ac_precool_before_peak.yaml index be7110d..84ac712 100644 --- a/automations/ac_precool_before_peak.yaml +++ b/automations/ac_precool_before_peak.yaml @@ -1,28 +1,31 @@ -# ac_precool_before_peak — pre-cool the house before the utility peak window +# ac_precool_before_peak — pre-cool the house before the summer on-peak window # # id / entity : ac_precool_before_peak -> automation.ac_pre_cool_before_peak # trigger : 12:00 local (America/New_York) -# purpose : On work days, pre-cool before the 14:00-19:00 peak, harder when -# the forecast high is hotter (>=90 -> 68, >=85 -> 70, >=80 -> 71, -# below 80 -> no pre-cool). +# purpose : On summer work days, pre-cool before the 14:00-19:00 peak, +# harder when the forecast high is hotter (>=90 -> 68, >=85 -> 70, +# >=80 -> 71, below 80 -> no pre-cool). # -# Graceful degradation (v2) — a broken dependency must NOT skip pre-cooling: +# Summer only: pre-cooling for the WINTER evening peak (17:00-21:00) is marginal +# (the daily high has already passed by then), so winter is hold+return only and +# has no pre-cool. This automation self-gates on season == 'summer'. +# +# Graceful degradation (a broken dependency must NOT skip pre-cooling): # * workday sensor unavailable/unknown -> fall back to a plain Mon-Fri check. # * weather forecast fails or returns nothing -> assume a regular hot day -# (high 88F -> 70F). weather.get_forecasts runs with continue_on_error so a -# hard service error can't abort the run; the `fc is defined and is mapping` -# guard catches the soft-failure (empty/None) case. +# (88F -> 70F). continue_on_error covers a hard service error; the +# `fc is defined and is mapping` guard covers an empty/None response. # -# NOTE: templates are kept on single lines on purpose — YAML folded scalars can -# inject stray whitespace/newlines into Jinja. Readability lives in this header. +# NOTE: templates are single-line on purpose (YAML folding can corrupt Jinja). # -# v2 (2026-07-09): added workday + weather fallbacks. See AUTOMATIONS.md. +# v3 (2026-07-09): bounded to summer (Jun-Sep). v2 added the fallbacks. id: ac_precool_before_peak alias: AC - Pre-cool before peak description: >- - v2 (2026-07-09). Pre-cools before the 2-7pm peak on work days. Falls back to - Mon-Fri if the workday sensor fails, and to a regular pre-cool (assume 88F high - -> 70F) if the weather forecast fails. Managed in git; see AUTOMATIONS.md. + v3 (2026-07-09). Summer-only. Pre-cools before the 2-7pm peak on work days. + Falls back to Mon-Fri if the workday sensor fails, and to a regular pre-cool + (assume 88F high -> 70F) if the weather forecast fails. Managed in git; see + AUTOMATIONS.md. mode: single triggers: - trigger: time @@ -37,12 +40,13 @@ actions: type: daily response_variable: fc - variables: + season: "{{ 'summer' if now().month in [6,7,8,9] else 'winter' }}" is_workday: "{% set wd = states('binary_sensor.workday_sensor') %}{% if wd in ['on','off'] %}{{ wd == 'on' }}{% else %}{{ now().weekday() < 5 }}{% endif %}" today_high: "{% if fc is defined and fc is mapping and 'weather.forecast_4315aspenhill' in fc and fc['weather.forecast_4315aspenhill'].forecast | default([]) | length > 0 %}{% set today = now().date() %}{% set entries = fc['weather.forecast_4315aspenhill'].forecast | selectattr('datetime') | list %}{% set match = entries | selectattr('datetime','search', today | string) | list %}{{ (match[0].temperature if match else entries[0].temperature) | float(88) }}{% else %}88{% endif %}" setpoint: "{% set h = today_high | float(88) %}{% if h >= 90 %}68{% elif h >= 85 %}70{% elif h >= 80 %}71{% else %}none{% endif %}" - if: - condition: template - value_template: "{{ is_workday and setpoint != 'none' }}" + value_template: "{{ season == 'summer' and is_workday and setpoint != 'none' }}" then: - action: climate.set_hvac_mode target: diff --git a/automations/ac_winter_morning_kill.yaml b/automations/ac_winter_morning_kill.yaml new file mode 100644 index 0000000..0c6ab64 --- /dev/null +++ b/automations/ac_winter_morning_kill.yaml @@ -0,0 +1,43 @@ +# ac_winter_morning_kill — stop the AC at the winter morning on-peak start +# +# id / entity : ac_winter_morning_kill -> automation.ac_winter_morning_kill +# trigger : 06:00 local (America/New_York) +# purpose : Winter has a 6-9 AM on-peak. If the AC is running at 6am, stop +# it. (Rare — only the mild May/October days when AC runs.) +# +# Cooling-only and gas-heat-safe: only acts when the thermostat is in `cool` +# mode, so it never turns off the gas furnace. Turns the unit fully OFF (per the +# chosen "stop" behavior). There is intentionally NO auto-resume yet — a resume +# automation can be added later. +# +# Fallback: workday sensor unavailable -> Mon-Fri (weekends/holidays are +# off-peak, so no kill then). +# +# v3 (2026-07-09): new. +id: ac_winter_morning_kill +alias: AC - Winter morning peak kill +description: >- + v3 (2026-07-09). At 6am on winter (Oct-May) work days, turns the thermostat + OFF if it is currently cooling, to avoid the 6-9am winter on-peak. Cooling-only + (never touches gas heat). No auto-resume yet. Managed in git; see + AUTOMATIONS.md. +mode: single +triggers: + - trigger: time + at: "06:00:00" + id: winter +conditions: [] +actions: + - variables: + season: "{{ 'summer' if now().month in [6,7,8,9] else 'winter' }}" + is_workday: "{% set wd = states('binary_sensor.workday_sensor') %}{% if wd in ['on','off'] %}{{ wd == 'on' }}{% else %}{{ now().weekday() < 5 }}{% endif %}" + cooling: "{{ is_state('climate.t6_pro_z_wave_programmable_thermostat_with_smartstart','cool') }}" + - if: + - condition: template + value_template: "{{ season == 'winter' and is_workday and cooling }}" + then: + - action: climate.set_hvac_mode + target: + entity_id: climate.t6_pro_z_wave_programmable_thermostat_with_smartstart + data: + hvac_mode: "off"