blueprint:
name: "Virtual MRT v0.1.10"
description: |
### What it does
Computes mean radiant temperature (MRT, °C) from room air temperature and weather-driven influences (outdoor air, wind/gusts, cloud cover, UV, precipitation/cooling, solar position). Also computes operative temperature (T_op = (T_in + MRT)/2).
### Outputs
- **MRT** → input_number helper (optional)
- **Operative temperature (T_op)** → input_number helper (optional)
- **KNX exposure (DPT 9.001)** only if the respective helper **and** KNX group address are set
- If no helpers are set, nothing is written or sent. Calculations still run and are logged.
### Data sources (priority, with plausibility checks)
1. Dedicated sensors (e.g., PirateWeather: `sensor.<weather_name>_*`)
2. Weather entity attributes
3. Heuristics (sun/cloud/condition) or defaults
### Global radiation
- Optional global radiation sensor (W/m²) is preferred if available and plausible (0–1300 W/m²).
- Otherwise, a heuristic estimate is used: UV × cloud cover × rain × daylight with a **cap of 1000 W/m²** to stay realistic across typical climates.
- For high-irradiance regions or cloud-enhancement events, we **recommend adding an external global radiation sensor** for best results.
### Room profiles
- “Top floor …” denotes the top dwelling level (avoids confusion with an unconditioned attic).
- Additional profiles cover vertical windows and a second exterior wall/cavity.
domain: automation
author: "Tobias Paul"
input:
air_temp:
name: "Room air temperature"
description: "Sensor entity for room air temperature (°C)."
selector:
entity:
domain: sensor
device_class: temperature
weather_entity:
name: "Weather entity"
description: "Weather entity providing base attributes."
selector:
entity:
domain: weather
room_profile:
name: "Room profile (building fabric)"
description: "Profile for exterior contact, window share, and typical losses/gains."
default: one_wall_large_window
selector:
select:
options:
- label: "1 exterior wall, large window"
value: one_wall_large_window
- label: "2 exterior walls, large window"
value: two_wall_large_window
- label: "Top floor (tilted/high solar gains)"
value: attic
- label: "Top floor (vertical windows, small window)"
value: topfloor_vert_small_window
- label: "Top floor (vertical windows, medium window)"
value: topfloor_vert_medium_window
- label: "Top floor (2 exterior walls/cavity)"
value: topfloor_two_walls_cavity
- label: "Top floor (cold adjacent room)"
value: topfloor_cold_adjacent
- label: "2 exterior walls, small window"
value: two_wall_small_window
- label: "1 exterior wall, small window"
value: one_wall_small_window
- label: "Basement / semi-basement"
value: basement
- label: "1 exterior wall, cold adjacent room"
value: one_wall_cold_adjacent
- label: "Corner room, cold adjacent room"
value: corner_cold_adjacent
- label: "Interior room"
value: interior
- label: "Interior room, cold adjacent room"
value: interior_cold_adjacent
sort: false
orientation:
name: "Main window orientation"
description: "Used to weight solar gains."
default: S
selector:
select:
options:
- N
- NE
- E
- SE
- S
- SW
- W
- NW
sort: false
sensor_solar_radiation:
name: "Global radiation sensor (optional)"
description: "W/m². Preferred if available and plausible (0–1300 W/m²)."
default: ""
selector:
entity:
domain: sensor
target_mrt_helper:
name: "Target helper for MRT (optional)"
description: "input_number entity receiving smoothed MRT_out."
default: ""
selector:
entity:
domain: input_number
target_oper_temp_helper:
name: "Target helper for operative temperature (optional)"
description: "input_number entity receiving T_op_out."
default: ""
selector:
entity:
domain: input_number
knx_ga_mrt:
name: "KNX group address for MRT (optional)"
description: "DPT 9.001 (°C). Only possible if an MRT helper is set."
default: ""
selector:
text: {}
knx_ga_oper_temp:
name: "KNX group address for operative temperature (optional)"
description: "DPT 9.001 (°C). Only possible if a T_op helper is set."
default: ""
selector:
text: {}
mode: restart
max_exceeded: silent
trigger:
- platform: time_pattern
minutes: "/5"
id: cycle
- platform: homeassistant
event: start
id: ha_start
- platform: event
event_type: automation_reloaded
id: ha_reload
variables:
# Inputs
air: !input air_temp
out_mrt: !input target_mrt_helper
out_op: !input target_oper_temp_helper
W: !input weather_entity
profile: !input room_profile
orient: !input orientation
knx_ga_mrt: !input knx_ga_mrt
knx_ga_op: !input knx_ga_oper_temp
ext_rad_sensor: !input sensor_solar_radiation
# Derive weather name: weather.NAME → NAME
wx_entity_id: "{{ W }}"
wx_prefix: "{{ wx_entity_id.split('.', 1)[1] if wx_entity_id is not none else '' }}"
# Possible dedicated sensors (e.g., PirateWeather)
pw_temp_entity: "{{ 'sensor.' ~ wx_prefix ~ '_temperature' }}"
pw_app_entity: "{{ 'sensor.' ~ wx_prefix ~ '_apparent_temperature' }}"
pw_wind_entity: "{{ 'sensor.' ~ wx_prefix ~ '_wind_speed' }}"
pw_gust_entity: "{{ 'sensor.' ~ wx_prefix ~ '_wind_gust' }}"
pw_uv_entity: "{{ 'sensor.' ~ wx_prefix ~ '_uv_index' }}"
pw_cloud_entity_1: "{{ 'sensor.' ~ wx_prefix ~ '_cloud_cover' }}"
pw_cloud_entity_2: "{{ 'sensor.' ~ wx_prefix ~ '_cloud_coverage' }}"
pw_precip_entity_1: "{{ 'sensor.' ~ wx_prefix ~ '_precipitation_intensity' }}"
pw_precip_entity_2: "{{ 'sensor.' ~ wx_prefix ~ '_precip_intensity' }}"
# Heuristic constants
k_uv: 90
cloud_weight: 0.90
base_rain_penalty: 0.60
wind_factor: 0.02
# Room profile parameters (incl. top-floor profiles)
f_out: >
{% set m = {
'one_wall_large_window': 0.5,
'two_wall_large_window': 0.8,
'attic': 0.9,
'topfloor_vert_small_window': 0.90,
'topfloor_vert_medium_window': 0.90,
'topfloor_two_walls_cavity': 0.95,
'topfloor_cold_adjacent': 0.95,
'two_wall_small_window': 0.7,
'one_wall_small_window': 0.5,
'basement': 0.4,
'one_wall_cold_adjacent': 0.6,
'corner_cold_adjacent': 0.8,
'interior': 0.0,
'interior_cold_adjacent': 0.3
} %}
{{ m.get(profile, 0.5) }}
f_win: >
{% set m = {
'one_wall_large_window': 0.4,
'two_wall_large_window': 0.5,
'attic': 0.4,
'topfloor_vert_small_window': 0.15,
'topfloor_vert_medium_window': 0.30,
'topfloor_two_walls_cavity': 0.25,
'topfloor_cold_adjacent': 0.35,
'two_wall_small_window': 0.3,
'one_wall_small_window': 0.2,
'basement': 0.2,
'one_wall_cold_adjacent': 0.3,
'corner_cold_adjacent': 0.4,
'interior': 0.0,
'interior_cold_adjacent': 0.0
} %}
{{ m.get(profile, 0.2) }}
k_loss: >
{% set m = {
'one_wall_large_window': 0.14,
'two_wall_large_window': 0.16,
'attic': 0.20,
'topfloor_vert_small_window': 0.23,
'topfloor_vert_medium_window': 0.22,
'topfloor_two_walls_cavity': 0.24,
'topfloor_cold_adjacent': 0.23,
'two_wall_small_window': 0.16,
'one_wall_small_window': 0.12,
'basement': 0.10,
'one_wall_cold_adjacent': 0.18,
'corner_cold_adjacent': 0.20,
'interior': 0.08,
'interior_cold_adjacent': 0.12
} %}
{{ m.get(profile, 0.14) }}
k_solar: >
{% set m = {
'one_wall_large_window': 1.20,
'two_wall_large_window': 1.40,
'attic': 1.50,
'topfloor_vert_small_window': 0.75,
'topfloor_vert_medium_window': 1.00,
'topfloor_two_walls_cavity': 0.95,
'topfloor_cold_adjacent': 1.15,
'two_wall_small_window': 1.00,
'one_wall_small_window': 0.80,
'basement': 0.60,
'one_wall_cold_adjacent': 0.80,
'corner_cold_adjacent': 1.00,
'interior': 0.40,
'interior_cold_adjacent': 0.40
} %}
{{ m.get(profile, 1.20) }}
south_factor: >
{% set m = {'N':0.2,'NE':0.4,'E':0.6,'SE':0.8,'S':1.0,'SW':0.8,'W':0.6,'NW':0.4} %}
{{ m.get(orient, 1.0) }}
# Weather and solar data
# --- Indoor air (normalize to °C for all calculations) ---
T_air_raw: "{{ states(air) }}"
T_air_unit: "{{ (state_attr(air, 'unit_of_measurement') | string | lower) }}"
T_air: >
{% set v = T_air_raw %}
{% if v not in ['unknown','unavailable','none','None',''] %}
{% set t = v | float(default=21) %}
{% set u = T_air_unit %}
{% if 'f' in u %}
{% set t = (t - 32) * 5 / 9 %}
{% elif u in ['k','kelvin'] %}
{% set t = t - 273.15 %}
{% endif %}
{{ t }}
{% else %}
21
{% endif %}
# --- Outdoor temperature / apparent temperature (already normalized to °C in v0.1.9) ---
pw_temp_raw: "{{ states(pw_temp_entity) }}"
pw_temp_unit: "{{ (state_attr(pw_temp_entity, 'unit_of_measurement') | string | lower) if pw_temp_entity is not none else '' }}"
pw_temp_ok: >
{% set v = pw_temp_raw %}
{% if v not in ['unknown','unavailable','none','None',''] %}
{% set t = v | float(default=none) %}
{% if t is not none %}
{% set u = pw_temp_unit %}
{% if 'f' in u %}{% set t = (t - 32) * 5 / 9 %}{% elif u in ['k','kelvin'] %}{% set t = t - 273.15 %}{% endif %}
{% if -50 <= t <= 60 %}{{ t }}{% else %}{{ none }}{% endif %}
{% else %}{{ none }}{% endif %}
{% else %}{{ none }}{% endif %}
pw_app_raw: "{{ states(pw_app_entity) }}"
pw_app_unit: "{{ (state_attr(pw_app_entity, 'unit_of_measurement') | string | lower) if pw_app_entity is not none else '' }}"
pw_app_ok: >
{% set v = pw_app_raw %}
{% if v not in ['unknown','unavailable','none','None',''] %}
{% set t = v | float(default=none) %}
{% if t is not none %}
{% set u = pw_app_unit %}
{% if 'f' in u %}{% set t = (t - 32) * 5 / 9 %}{% elif u in ['k','kelvin'] %}{% set t = t - 273.15 %}{% endif %}
{% if -60 <= t <= 60 %}{{ t }}{% else %}{{ none }}{% endif %}
{% else %}{{ none }}{% endif %}
{% else %}{{ none }}{% endif %}
w_temp_raw: "{{ state_attr(W, 'temperature') }}"
w_temp_ok: >
{% set t = w_temp_raw | float(default=none) %}
{% if t is not none %}
{% set u = (state_attr(W, 'temperature_unit') | string | lower) %}
{% if 'f' in u %}{% set t = (t - 32) * 5 / 9 %}{% elif u in ['k','kelvin'] %}{% set t = t - 273.15 %}{% endif %}
{% if -50 <= t <= 60 %}{{ t }}{% else %}{{ none }}{% endif %}
{% else %}{{ none }}{% endif %}
w_app_raw: "{{ state_attr(W, 'apparent_temperature') }}"
w_app_ok: >
{% set t = w_app_raw | float(default=none) %}
{% if t is not none %}
{% set u = (state_attr(W, 'temperature_unit') | string | lower) %}
{% if 'f' in u %}{% set t = (t - 32) * 5 / 9 %}{% elif u in ['k','kelvin'] %}{% set t = t - 273.15 %}{% endif %}
{% if -60 <= t <= 60 %}{{ t }}{% else %}{{ none }}{% endif %}
{% else %}{{ none }}{% endif %}
T_out_source_temp: >
{% if pw_temp_ok is not none %}sensor
{% elif w_temp_ok is not none %}weather_attr
{% else %}fallback{% endif %}
T_out_val: >
{% if pw_temp_ok is not none %}
{{ pw_temp_ok }}
{% elif w_temp_ok is not none %}
{{ w_temp_ok }}
{% else %}
10.0
{% endif %}
T_app_source_temp: >
{% if pw_app_ok is not none %}sensor
{% elif w_app_ok is not none %}weather_attr
{% else %}none{% endif %}
T_app_val: >
{% if pw_app_ok is not none %}
{{ pw_app_ok }}
{% elif w_app_ok is not none %}
{{ w_app_ok }}
{% else %}
{{ none }}
{% endif %}
T_out_eff: >
{% if T_app_val is not none %}
{% if (T_out_val | float) <= (T_app_val | float) %}
{{ T_out_val }}
{% else %}
{{ T_app_val }}
{% endif %}
{% else %}
{{ T_out_val }}
{% endif %}
T_out_eff_source: >
{% if T_app_val is not none %}
eff(min(temp={{T_out_source_temp}},app={{T_app_source_temp}}))
{% else %}
temp={{T_out_source_temp}}
{% endif %}
# --- Wind / gusts (auto-units to km/h) ---
pw_wind_raw: "{{ states(pw_wind_entity) }}"
pw_wind_unit: "{{ (state_attr(pw_wind_entity, 'unit_of_measurement') | string | lower) if pw_wind_entity is not none else '' }}"
pw_wind_ok: >
{% set v = pw_wind_raw %}
{% if v not in ['unknown','unavailable','none','None',''] %}
{% set s = v | float(default=none) %}
{% if s is not none %}
{% set u = pw_wind_unit %}
{% if 'm/s' in u or 'mps' in u %}{% set s = s * 3.6 %}{% elif 'mph' in u %}{% set s = s * 1.60934 %}{% endif %}
{% if s >= 0 %}{{ s }}{% else %}{{ none }}{% endif %}
{% else %}{{ none }}{% endif %}
{% else %}{{ none }}{% endif %}
pw_gust_raw: "{{ states(pw_gust_entity) }}"
pw_gust_unit: "{{ (state_attr(pw_gust_entity, 'unit_of_measurement') | string | lower) if pw_gust_entity is not none else '' }}"
pw_gust_ok: >
{% set v = pw_gust_raw %}
{% if v not in ['unknown','unavailable','none','None',''] %}
{% set s = v | float(default=none) %}
{% if s is not none %}
{% set u = pw_gust_unit %}
{% if 'm/s' in u or 'mps' in u %}{% set s = s * 3.6 %}{% elif 'mph' in u %}{% set s = s * 1.60934 %}{% endif %}
{% if s >= 0 %}{{ s }}{% else %}{{ none }}{% endif %}
{% else %}{{ none }}{% endif %}
{% else %}{{ none }}{% endif %}
w_wind_raw: "{{ state_attr(W, 'wind_speed') }}"
w_wind_ok: >
{% set s = w_wind_raw | float(default=none) %}
{% if s is not none %}
{% set u = (state_attr(W, 'wind_speed_unit') | string | lower) %}
{% if 'mph' in u %}{% set s = s * 1.60934 %}{% elif 'm/s' in u or 'mps' in u %}{% set s = s * 3.6 %}{% endif %}
{% if s >= 0 %}{{ s }}{% else %}{{ none }}{% endif %}
{% else %}{{ none }}{% endif %}
w_gust_raw: "{{ state_attr(W, 'wind_gust_speed') }}"
w_gust_ok: >
{% set s = w_gust_raw | float(default=none) %}
{% if s is not none %}
{% set u = (state_attr(W, 'wind_speed_unit') | string | lower) %}
{% if 'mph' in u %}{% set s = s * 1.60934 %}{% elif 'm/s' in u or 'mps' in u %}{% set s = s * 3.6 %}{% endif %}
{% if s >= 0 %}{{ s }}{% else %}{{ none }}{% endif %}
{% else %}{{ none }}{% endif %}
wind_kmh_effective: >
{% set base_sens = pw_wind_ok if pw_wind_ok is not none else w_wind_ok %}
{% set gust_sens = pw_gust_ok if pw_gust_ok is not none else w_gust_ok %}
{% if base_sens is not none and gust_sens is not none %}
{% if (base_sens | float) >= (gust_sens | float) %}
{{ base_sens }}
{% else %}
{{ gust_sens }}
{% endif %}
{% elif base_sens is not none %}
{{ base_sens }}
{% elif gust_sens is not none %}
{{ gust_sens }}
{% else %}
0.0
{% endif %}
wind_source: >
{% if pw_wind_ok is not none or pw_gust_ok is not none %}
sensor
{% elif w_wind_ok is not none or w_gust_ok is not none %}
weather_attr
{% else %}
fallback
{% endif %}
wind_ms: "{{ (wind_kmh_effective | float(0)) / 3.6 | round(2) }}"
# Cloud cover
pw_cloud_raw: >
{% set v1 = states(pw_cloud_entity_1) %}
{% set v2 = states(pw_cloud_entity_2) %}
{% if v1 not in ['unknown','unavailable','none','None',''] %}
{{ v1 }}
{% else %}
{{ v2 }}
{% endif %}
pw_cloud_ok: >
{% set v = pw_cloud_raw %}
{% if v not in ['unknown','unavailable','none','None',''] %}
{% set f = v | float(default=none) %}
{% if f is not none and 0 <= f <= 100 %}{{ f }}{% else %}{{ none }}{% endif %}
{% else %}{{ none }}{% endif %}
w_cloud_raw: >
{% set a = state_attr(W, 'cloud_coverage') %}
{% if a is none %}
{% set a = state_attr(W, 'cloudiness') %}
{% endif %}
{{ a }}
w_cloud_ok: >
{% if w_cloud_raw is not none %}
{% set f = w_cloud_raw | float(default=none) %}
{% if f is not none and 0 <= f <= 100 %}{{ f }}{% else %}{{ none }}{% endif %}
{% else %}{{ none }}{% endif %}
cond_raw: "{{ states(W) | string | lower }}"
cond_ok: >
{% set c = cond_raw %}
{% if c in ['unknown','unavailable','none',''] %}{{ none }}{% else %}{{ c }}{% endif %}
clouds_cov_final: >
{% if pw_cloud_ok is not none %}
{{ pw_cloud_ok | float }}
{% elif w_cloud_ok is not none %}
{{ w_cloud_ok | float }}
{% else %}
{% if cond_ok in ['sunny','clear','clear-night'] %}
0
{% elif cond_ok in ['partlycloudy','partly-cloudy','mostly-sunny'] %}
50
{% elif cond_ok is not none %}
100
{% else %}
100
{% endif %}
{% endif %}
clouds_source: >
{% if pw_cloud_ok is not none %}sensor
{% elif w_cloud_ok is not none %}weather_attr
{% elif cond_ok is not none %}heuristic(cond)
{% else %}default(100)
{% endif %}
# UV
pw_uv_raw: "{{ states(pw_uv_entity) }}"
pw_uv_ok: >
{% set v = pw_uv_raw %}
{% if v not in ['unknown','unavailable','none','None',''] %}
{% set f = v | float(default=none) %}
{% if f is not none and f >= 0 %}{{ f }}{% else %}{{ none }}{% endif %}
{% else %}{{ none }}{% endif %}
w_uv_raw: "{{ state_attr(W, 'uv_index') }}"
w_uv_ok: >
{% if w_uv_raw is not none %}
{% set f = w_uv_raw | float(default=none) %}
{% if f is not none and f >= 0 %}{{ f }}{% else %}{{ none }}{% endif %}
{% else %}{{ none }}{% endif %}
sun_elev_raw: "{{ state_attr('sun.sun','elevation') }}"
sun_elev_ok: >
{% if sun_elev_raw is not none %}
{% set e = sun_elev_raw | float(default=none) %}
{% if e is not none and -90 <= e <= 90 %}{{ e }}{% else %}{{ none }}{% endif %}
{% else %}{{ none }}{% endif %}
sun_up: "{{ is_state('sun.sun','above_horizon') }}"
UV_final: >
{% if pw_uv_ok is not none %}
{{ pw_uv_ok | float }}
{% elif w_uv_ok is not none %}
{{ w_uv_ok | float }}
{% else %}
{% if sun_up and (clouds_cov_final | float(100)) < 60 and (sun_elev_ok | float(0) > 10) %}
1.0
{% else %}
0.0
{% endif %}
{% endif %}
UV_source: >
{% if pw_uv_ok is not none %}sensor
{% elif w_uv_ok is not none %}weather_attr
{% else %}heuristic(sun/cloud){% endif %}
# --- Precipitation intensity (auto-units to mm/h) ---
pw_precip_raw: >
{% set v1 = states(pw_precip_entity_1) %}
{% set v2 = states(pw_precip_entity_2) %}
{% if v1 not in ['unknown','unavailable','none','None',''] %}
{{ v1 }}
{% else %}
{{ v2 }}
{% endif %}
pw_precip_unit: >
{% set u1 = (state_attr(pw_precip_entity_1, 'unit_of_measurement') | string | lower) %}
{% set u2 = (state_attr(pw_precip_entity_2, 'unit_of_measurement') | string | lower) %}
{% if states(pw_precip_entity_1) not in ['unknown','unavailable','none','None',''] %}
{{ u1 }}
{% else %}
{{ u2 }}
{% endif %}
pw_precip_ok: >
{% set v = pw_precip_raw %}
{% if v not in ['unknown','unavailable','none','None',''] %}
{% set p = v | float(default=0) %}
{% set u = pw_precip_unit %}
{% if 'in' in u and '/h' in u %}{% set p = p * 25.4 %}{% endif %}
{% if p < 0 %}0{% else %}{{ p }}{% endif %}
{% else %}0{% endif %}
rainy_bool: >
{% set c = cond_ok if cond_ok is not none else '' %}
{{ 'rain' in c or 'pour' in c or 'snow' in c or 'hail' in c }}
rain_mul: >
{% if pw_precip_ok | float(0) > 0 %}
{% set penalty = (pw_precip_ok | float(0)) / 5.0 %}
{% if penalty > 1 %}{% set penalty = 1 %}{% endif %}
{{ 1 - (penalty * base_rain_penalty) }}
{% else %}
{% if rainy_bool %}
{{ 1 - base_rain_penalty }}
{% else %}
1
{% endif %}
{% endif %}
rain_source: >
{% if (pw_precip_ok | float(0)) > 0 %}sensor
{% elif rainy_bool %}condition_string
{% else %}dry
{% endif %}
# Solar elevation / daylight factor
sun_elev_clip: >
{% set e = sun_elev_ok if sun_elev_ok is not none else -90 %}
{{ [ -6.0, [ e, 60.0 ] | min ] | max }}
day_fac: >
{% set clip = sun_elev_clip | float(-6) %}
{{ ((clip + 6.0) / 66.0) | float | round(3) }}
# Global radiation (prefer external sensor)
ext_rad_raw: "{{ states(ext_rad_sensor) if ext_rad_sensor | length > 0 else none }}"
ext_rad_ok: >
{% set v = ext_rad_raw %}
{% if v not in [none, 'unknown','unavailable','none','None',''] %}
{% set f = v | float(default=none) %}
{% if f is not none and 0 <= f <= 1300 %}{{ f }}{% else %}{{ none }}{% endif %}
{% else %}{{ none }}{% endif %}
rad_est: >
{% if ext_rad_ok is not none %}
{{ (ext_rad_ok | float) | round(1) }}
{% else %}
{% set base = k_uv * (UV_final | float(0)) %}
{% set cloudy = [0, 1 - (cloud_weight * (clouds_cov_final | float(100) / 100.0))] | max %}
{% set daylight = day_fac | float(0) %}
{% set rm = rain_mul | float(1) %}
{% if sun_up %}
{{ [0, [ base * cloudy * rm * daylight, 1000 ] | min ] | max | round(1) }}
{% else %}
0
{% endif %}
{% endif %}
rad_source: >
{% if ext_rad_ok is not none %}ext_sensor
{% else %}heuristic(UV/cloud/rain/sun)
{% endif %}
# Loss and solar terms
loss_term: >
{{ (k_loss | float)
* ( (T_air | float) - (T_out_eff | float) )
* ( (f_out | float) + 1.5 * (f_win | float) )
* (1 + wind_factor * (wind_ms | float)) }}
solar_term: >
{{ (k_solar | float)
* ((rad_est | float) / 400)
* (south_factor | float)
* (f_win | float) }}
# MRT with bounds and smoothing
MRT_calc: "{{ (T_air | float) - (loss_term | float) + (solar_term | float) }}"
lower_dyn: "{{ [ (T_out_eff | float + 2.0), (T_air | float - 3.0) ] | max }}"
upper_dyn: "{{ (T_air | float + 4.0) }}"
MRT: "{{ [ lower_dyn, [ MRT_calc, upper_dyn ] | min ] | max | round(1) }}"
MRT_plaus_floor: >
{% set floor1 = (T_out_eff | float) - 5 %}
{% set floor2 = (T_air | float) - 15 %}
{{ [floor1, floor2, -30] | max }}
MRT_plaus_ceil: >
{% set ceil1 = (T_air | float) + 10 %}
{% set ceil2 = (T_out_eff | float) + 30 %}
{{ [ceil1, ceil2, 60] | min }}
MRT_prev_raw: "{{ states(out_mrt) if out_mrt | length > 0 else none }}"
MRT_prev_candidate: "{{ MRT_prev_raw | float(none) if MRT_prev_raw is not none else none }}"
MRT_prev_sane: >
{% set current = MRT | float %}
{% set pv = MRT_prev_candidate %}
{% if pv is none %}
{{ current }}
{% elif pv < 5 %}
{{ current }}
{% elif (pv - current) > 2 %}
{{ current }}
{% else %}
{% set lo = MRT_plaus_floor | float(-30) %}
{% set hi = MRT_plaus_ceil | float(60) %}
{% if pv < lo %}
{{ lo }}
{% elif pv > hi %}
{{ hi }}
{% else %}
{{ pv }}
{% endif %}
{% endif %}
MRT_out: "{{ (0.7 * (MRT_prev_sane | float) + 0.3 * (MRT | float)) | round(1) }}"
# Operative temperature (no second smoothing)
T_op_out: "{{ ((T_air | float) + (MRT_out | float)) / 2 | round(1) }}"
action:
# KNX exposure (idempotent registration)
- choose:
- conditions:
- condition: template
value_template: "{{ trigger.id in ['ha_start', 'ha_reload'] and knx_ga_mrt != '' and out_mrt != '' }}"
sequence:
- service: knx.exposure_register
data:
address: "{{ knx_ga_mrt }}"
type: temperature
entity_id: "{{ out_mrt }}"
- choose:
- conditions:
- condition: template
value_template: "{{ trigger.id in ['ha_start', 'ha_reload'] and knx_ga_op != '' and out_op != '' }}"
sequence:
- service: knx.exposure_register
data:
address: "{{ knx_ga_op }}"
type: temperature
entity_id: "{{ out_op }}"
# Write values (cycle only) – with Δ ≥ 0.1 K
- choose:
- conditions:
- condition: template
value_template: "{{ trigger.id == 'cycle' and out_mrt != '' }}"
- condition: template
value_template: "{{ (states(out_mrt) | float(0) - MRT_out | float(0)) | abs >= 0.1 }}"
sequence:
- service: input_number.set_value
target:
entity_id: "{{ out_mrt }}"
data:
value: >
{% set u = (state_attr(out_mrt, 'unit_of_measurement') | string | lower) %}
{% set knx_active = (knx_ga_mrt | length > 0) %}
{# If KNX is active for MRT, keep helper in °C (DPT 9.001). #}
{% if knx_active %}
{{ MRT_out }}
{% else %}
{% if 'f' in u %}
{{ ((MRT_out | float) * 9 / 5 + 32) | round(1) }}
{% else %}
{{ MRT_out }}
{% endif %}
{% endif %}
- choose:
- conditions:
- condition: template
value_template: "{{ trigger.id == 'cycle' and out_op != '' }}"
- condition: template
value_template: "{{ (states(out_op) | float(0) - T_op_out | float(0)) | abs >= 0.1 }}"
sequence:
- service: input_number.set_value
target:
entity_id: "{{ out_op }}"
data:
value: >
{% set u = (state_attr(out_op, 'unit_of_measurement') | string | lower) %}
{% set knx_active = (knx_ga_op | length > 0) %}
{# If KNX is active for T_op, keep helper in °C (DPT 9.001). #}
{% if knx_active %}
{{ T_op_out }}
{% else %}
{% if 'f' in u %}
{{ ((T_op_out | float) * 9 / 5 + 32) | round(1) }}
{% else %}
{{ T_op_out }}
{% endif %}
{% endif %}
# Logging (cycle only)
- choose:
- conditions:
- condition: template
value_template: "{{ trigger.id == 'cycle' and (out_mrt != '' or out_op != '') }}"
sequence:
- service: logbook.log
data:
name: "Virtual MRT v0.1.10"
message: >
Profile={{ profile }}, Orientation={{ orient }},
T_in={{ T_air }}°C,
T_out_eff={{ T_out_eff }}°C [{{ T_out_eff_source }}],
Wind_eff(km/h)={{ wind_kmh_effective }} via {{ wind_source }},
Wind_ms={{ wind_ms }},
Clouds={{ clouds_cov_final }}% [{{ clouds_source }}],
UV={{ UV_final }} [{{ UV_source }}],
rain_mul={{ rain_mul }} [{{ rain_source }}],
day_fac={{ day_fac }},
rad≈{{ rad_est }} W/m² [{{ rad_source }}],
loss_term={{ loss_term | round(2) }},
solar_term={{ solar_term | round(2) }},
MRT_calc={{ MRT_calc | round(1) }}°C,
MRT_out={{ MRT_out }}°C{{ ', T_op=' ~ (T_op_out | string) ~ '°C' if out_op != '' else '' }}
entity_id: "{{ out_mrt if out_mrt != '' else out_op }}"
- choose:
- conditions:
- condition: template
value_template: "{{ trigger.id == 'cycle' and (out_mrt == '' and out_op == '') }}"
sequence:
- service: logbook.log
data:
name: "Virtual MRT v0.1.10"
message: >
(No helper/GA) T_in={{ T_air }}°C,
T_out_eff={{ T_out_eff }}°C [{{ T_out_eff_source }}],
Wind={{ wind_ms }} m/s, Clouds={{ clouds_cov_final }}% [{{ clouds_source }}],
UV={{ UV_final }} [{{ UV_source }}], rad≈{{ rad_est }} W/m² [{{ rad_source }}],
MRT={{ MRT_out }}°C, T_op={{ T_op_out }}°C