Skip to content

Instantly share code, notes, and snippets.

@Komzpa
Created December 24, 2025 10:43
Show Gist options
  • Select an option

  • Save Komzpa/f2da05c0a0ba1706cbd1deaab68c98ff to your computer and use it in GitHub Desktop.

Select an option

Save Komzpa/f2da05c0a0ba1706cbd1deaab68c98ff to your computer and use it in GitHub Desktop.
{# ============================
SENSOR INPUTS (edit entity_ids if needed)
============================ #}
{% set s_occ_air = 'sensor.living_room_occupied_temperature_10_min' %}
{% set s_work_air = 'sensor.workplace_temperature' %}
{% set s_floor_min = 'sensor.living_room_floor_min_temperature' %}
{% set s_floor_surface = 'sensor.living_room_floor_temperature' %}
{% set s_floor_max = 'sensor.living_room_floor_max_temperature' %}
{% set s_bookshelf = 'sensor.bookshelf_temperature' %}
{% set s_work_floor = 'sensor.workplace_floor_temperature' %}
{# Optional trend sensors (°C per hour). If you do not have them, leave as-is (they will behave as 0). #}
{% set s_floor_surface_slope = 'sensor.living_room_floor_temperature_slope_c_per_h' %}
{% set s_work_floor_slope = 'sensor.workplace_floor_temperature_slope_c_per_h' %}
{% set t_occ_air = states(s_occ_air) | float(none) %}
{% set t_work_air = states(s_work_air) | float(none) %}
{% set t_floor_min = states(s_floor_min) | float(none) %}
{% set t_floor_surface = states(s_floor_surface) | float(none) %}
{% set t_floor_max = states(s_floor_max) | float(none) %}
{% set t_bookshelf = states(s_bookshelf) | float(none) %}
{% set t_work_floor = states(s_work_floor) | float(none) %}
{% set slope_floor_surface = states(s_floor_surface_slope) | float(0) %}
{% set slope_work_floor = states(s_work_floor_slope) | float(0) %}
{# If we have no useful base sensors, return unknown #}
{% if (t_occ_air is none) and (t_work_air is none) and (t_floor_min is none) %}
unknown
{% else %}
{# ============================
TUNABLES (all knobs are here)
============================ #}
{# Base behavior:
This is your "normal comfort temp" when nothing violates boundaries.
We compute it as a weighted average of two air sensors. #}
{% set base_w_occ_air = 1.0 %} {# weight of occupied air sensor in normal mode #}
{% set base_w_work_air = 1.0 %} {# weight of workplace air sensor in normal mode #}
{# Cold corridor:
low_target is the boundary you care about (corner should not go below it).
low_band is softness: smaller reacts more aggressively around the boundary. #}
{% set low_target = 19.0 %} {# °C, cold boundary you want to protect (anti-freeze) #}
{% set low_band = 2.0 %} {# °C, how wide the "ramp" is around low_target #}
{# Cold weighting shape:
cold_priority controls how hard it pulls exactly at the boundary (when cold == low_target).
alpha_cold controls how sharp the pull grows when going below the boundary. #}
{% set cold_priority = 8.0 %} {# multiplier at boundary; 5..20 typical. Higher = more jump to cold #}
{% set alpha_cold = 2.0 %} {# exponent slope; higher = more aggressive once below boundary #}
{# Which sensors participate in the cold sentinel (min()):
include_floor_min = true means floor_min participates as "cold spot".
If floor_min causes unwanted behavior, set it to false. #}
{% set include_floor_min = true %}
{# Hot corridor thresholds (failsafe-like):
These are the temperatures where you want the system to back off.
Each has its own band (softness) so they can ramp in smoothly. #}
{% set high_floor_max = 28.0 %} {# °C, floor max threshold (general "too hot" indicator) #}
{% set high_floor_max_band = 2.0 %} {# °C, softness around high_floor_max #}
{% set high_floor_surface = 26.0 %} {# °C, floor surface threshold (feet comfort limit) #}
{% set high_floor_surface_band = 1.0 %} {# °C, softness around high_floor_surface #}
{% set high_bookshelf = 30.0 %} {# °C, air hotspot threshold (bookshelf corner overheat) #}
{% set high_bookshelf_band = 2.0 %} {# °C, softness around high_bookshelf #}
{% set high_work_floor = 31.0 %} {# °C, workplace in-floor threshold (feet melting sensor) #}
{% set high_work_floor_band = 0.7 %} {# °C, small band = very strict near this threshold #}
{# Hot weighting shape:
hot_priority controls pull when exactly at threshold (score = 0).
alpha_hot controls how fast it dominates once above threshold. #}
{% set hot_priority = 1.0 %} {# multiplier at threshold; raise if you want earlier domination #}
{% set alpha_hot = 4.0 %} {# exponent slope; higher = stronger safety clamp on overshoot #}
{# Trend anticipation:
lead_time_hours predicts future hot temps: T_pred = T_now + max(0, slope)*lead_time_hours
If you do not have slope sensors, this still works but behaves as lead_time=0. #}
{% set lead_time_hours = 0.5 %} {# hours, 0.5 means anticipate 30 minutes ahead #}
{# General softmax stability:
base_priority is the "always on" weight of the base temperature.
clamp_score limits exponent blowups (bigger = more extreme weights possible). #}
{% set base_priority = 1.0 %} {# baseline weight; smaller = boundaries win more often #}
{% set clamp_score = 6.0 %} {# clamp for scores before exponent; 4..8 typical #}
{# Math constant for exp approximation: exp(x) = e**x #}
{% set e = 2.718281828 %}
{# ============================
BASE TEMPERATURE (normal regime)
============================ #}
{% set base_sum = 0.0 %}
{% set base_wsum = 0.0 %}
{% if t_occ_air is not none %}
{% set base_sum = base_sum + base_w_occ_air * t_occ_air %}
{% set base_wsum = base_wsum + base_w_occ_air %}
{% endif %}
{% if t_work_air is not none %}
{% set base_sum = base_sum + base_w_work_air * t_work_air %}
{% set base_wsum = base_wsum + base_w_work_air %}
{% endif %}
{% if base_wsum > 0 %}
{% set t_base = base_sum / base_wsum %}
{% else %}
{# fallback to something available #}
{% set t_base = (t_floor_min if t_floor_min is not none else 0) %}
{% endif %}
{# ============================
COLD SENTINEL (min temperature you care about)
============================ #}
{% set cold_list = [t_occ_air, t_work_air] %}
{% if include_floor_min %}
{% set cold_list = cold_list + [t_floor_min] %}
{% endif %}
{% set cold_list = cold_list | reject('eq', none) | list %}
{% set t_cold = (cold_list | min) if (cold_list | length > 0) else t_base %}
{# cold score:
positive when t_cold is below low_target
0 at boundary
negative when safely above #}
{% set s_cold = (low_target - t_cold) / low_band %}
{# clamp s_cold to avoid huge exponents #}
{% set s_cold_c = [[s_cold, clamp_score] | min, -clamp_score] | max %}
{# ============================
HOT SENTINEL (worst offender)
============================ #}
{# Predict future hot temps using positive slope only #}
{% set slope_floor_surface_pos = (slope_floor_surface if slope_floor_surface > 0 else 0) %}
{% set slope_work_floor_pos = (slope_work_floor if slope_work_floor > 0 else 0) %}
{% set t_floor_surface_pred = (t_floor_surface if t_floor_surface is not none else t_base) + slope_floor_surface_pos * lead_time_hours %}
{% set t_work_floor_pred = (t_work_floor if t_work_floor is not none else t_base) + slope_work_floor_pos * lead_time_hours %}
{# Hot score per sensor:
positive when above its threshold
0 at threshold
negative when below threshold #}
{% set s_hot_floor_max = ((t_floor_max if t_floor_max is not none else t_base) - high_floor_max) / high_floor_max_band %}
{% set s_hot_floor_surface = (t_floor_surface_pred - high_floor_surface) / high_floor_surface_band %}
{% set s_hot_bookshelf = ((t_bookshelf if t_bookshelf is not none else t_base) - high_bookshelf) / high_bookshelf_band %}
{% set s_hot_work_floor = (t_work_floor_pred - high_work_floor) / high_work_floor_band %}
{# One hot score to rule them all: take the maximum normalized overshoot #}
{% set s_hot = [s_hot_floor_max, s_hot_floor_surface, s_hot_bookshelf, s_hot_work_floor] | max %}
{# clamp s_hot #}
{% set s_hot_c = [[s_hot, clamp_score] | min, -clamp_score] | max %}
{# Hot sentinel temperature (used for blending, not only scoring) #}
{% set hot_list = [t_floor_max, t_floor_surface_pred, t_bookshelf, t_work_floor_pred] | reject('eq', none) | list %}
{% set t_hot = (hot_list | max) if (hot_list | length > 0) else t_base %}
{# ============================
SOFTMAX-LIKE BLEND
============================ #}
{# Weights:
At boundary (score=0):
w_base = base_priority
w_cold = cold_priority
w_hot = hot_priority
So cold_priority directly controls how close we jump toward cold at low_target. #}
{% set w_base = base_priority %}
{% set w_cold = cold_priority * (e ** (alpha_cold * s_cold_c)) %}
{% set w_hot = hot_priority * (e ** (alpha_hot * s_hot_c)) %}
{% set denom = w_base + w_cold + w_hot %}
{% set t_virtual = (w_base*t_base + w_cold*t_cold + w_hot*t_hot) / denom %}
{{ t_virtual | round(2) }}
{% endif %}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment