[Blueprint] Virtual MRT v0.1.10 — Mean Radiant Temperature & Operative Temperature

This blueprint computes Mean Radiant Temperature (MRT) from room air temperature, weather, and sun position, and optionally derives operative temperature T_op = (T_in + MRT) / 2.
It can write to input_number helpers and (optionally) expose to KNX (DPT 9.001). It includes strong fallbacks, plausibility checks, and automatic unit detection/conversion (°C/°F/K for temps, km/h/mph/m/s for wind, mm/h/in/h for precipitation).

Note: I don’t run an °F setup myself. Feedback from °F users is highly appreciated to confirm the conversions behave as expected.


What this blueprint does

  • Calculates MRT (°C) from:
    • Indoor air temperature
    • Weather-driven losses/gains (outdoor & “apparent” temp, wind/gusts, cloud cover, UV, precipitation)
    • Global radiation (preferred if an external W/m² sensor is available; otherwise a heuristic)
    • Solar elevation (via the sun integration)
  • Calculates operative temperature (T_op) as the simple average of indoor air and MRT.
  • Runs every 5 minutes with light MRT smoothing (0.7 previous / 0.3 new) and dynamic, physically reasonable bounds.

Highlights

  • Automatic unit handling
    • Internally, all temps are normalized to °C; wind to km/h (also converted to m/s for the formula); precipitation to mm/h.
    • Helper output respects the helper’s unit (°C/°F) unless KNX is enabled for that value.
    • KNX exposure (DPT 9.001) is always °C by definition.
  • Data source priority (per signal)
    1. Dedicated sensors (e.g., PirateWeather: sensor.<weather_name>_*)
    2. Weather-entity attributes
    3. Heuristics/defaults
      Each value is range-checked for plausibility before use.
  • Global radiation
    • If an external W/m² sensor is available, its reading is used as-is (sanity check 0–1300 W/m², no capping).
    • Otherwise a heuristic is used (UV × clouds × rain × daylight), capped at 1000 W/m² to reflect realistic clear-sky peaks.
    • For very sunny regions or cloud-enhancement, an external sensor is recommended.
  • KNX exposure (optional, per quantity)
    • Registers exposure only if both a helper and a group address are set; idempotent and clean on HA start/reload.
  • Verbose logbook entries
    • Logs all key terms and their source (sensor / weather_attr / heuristic) to help compare weather integrations and diagnose issues.

Requirements

  • Sun integration (built-in)
  • A weather entity (e.g., met.no, PirateWeather, Tomorrow.io, …)
  • Optional:
    • input_number helpers for MRT and/or T_op
    • KNX group addresses for MRT and/or T_op (DPT 9.001)
    • Global radiation sensor (W/m²)

Installation

  1. Save the YAML as a blueprint (e.g., config/blueprints/automation/<your_namespace>/virtual_mrt.yaml) or import via UI if hosted.
  2. Restart Home Assistant or reload automations.
  3. Create an automation from this blueprint and configure the inputs (see below).

Configuration (inputs)

  • Room air temperature — your indoor temperature sensor. Unit is auto-detected and normalized internally.
  • Weather entity — your weather provider (weather.xxxx).
  • Room profile — choose the envelope/window profile closest to the room (e.g., “Top floor …” for the top dwelling level).
  • Main window orientation — N/NE/E/SE/S/SW/W/NW to weight solar gains.
  • Global radiation sensor (optional) — W/m²; preferred when available and plausible.
  • Target helper for MRT (optional)input_number for MRT output.
  • Target helper for operative temperature (optional)input_number for T_op output.
  • KNX group address for MRT / T_op (optional) — DPT 9.001. Requires the respective helper to be set.

Units & KNX note

  • Internal math uses °C.
  • Helper output: writes in the helper’s unit (°C or °F) unless KNX is enabled for that quantity.
  • If KNX is enabled, the helper is written in °C (DPT 9.001). To avoid confusion, configure the helper to show °C in that case.

Supported weather data / sensors (examples)

  • PirateWeather: dedicated sensors such as _temperature, _apparent_temperature, _wind_speed, _wind_gust, _uv_index, _cloud_cover or _cloud_coverage, and _precipitation_intensity or _precip_intensity. Enable these if you want higher-quality inputs.
  • met.no: uses attributes from the weather entity when separate sensors are not present.

Per-signal priority example (clouds):

  1. sensor.<wx>_cloud_cover or sensor.<wx>_cloud_coverage
  2. state_attr(weather.<wx>, 'cloud_coverage' | 'cloudiness')
  3. Heuristic from the condition string (clear/partlycloudy/…).

Logging (to verify data & sources)

A logbook entry is created every 5 minutes, including the values and their source, e.g.:

Virtual MRT v0.1.10
T_in=21.0°C, T_out_eff=2.4°C [eff(min(temp=sensor,app=sensor))],
Wind_eff(km/h)=28.8 via sensor, Wind_ms=8.0,
Clouds=100% [sensor], UV=0.0 [weather_attr],
rain_mul=0.76 [sensor], day_fac=0.12,
rad≈52 W/m² [heuristic], loss_term=3.42, solar_term=0.08,
MRT_calc=17.7°C, MRT_out=18.0°C, T_op=19.5°C

Room profiles (quick guide)

  • Top floor … = top dwelling level (not an unconditioned attic), typically higher losses and higher solar gains.
  • Corner / cold adjacent = more exterior/cold-space exposure → stronger losses.
  • Window small/large = simplified window share.
    Choose the closest match; advanced users can fine-tune by editing the YAML profile tables.

Known limits & recommendations

  • Heuristic global radiation is capped at 1000 W/m²; for high-irradiance regions and cloud-enhancement, use an external W/m² sensor.
  • Units: automatic detection/conversion is implemented, but I do not run an °F setup—please help validate the behavior in °F systems (especially helper-unit output vs. KNX in °C).
  • KNX: DPT 9.001 is °C. If you enable KNX exposure, keep the corresponding helper in °C to avoid confusion.

Troubleshooting

  • No exposure at startup: Exposures are registered only when helper + GA are set—startup should be clean.
  • “Sensor not found”: Remember that dedicated sensor IDs are derived from your weather.NAME; ensure your PirateWeather sensors are enabled and named as expected.
  • Odd values: Check the logbook entry to see which source (sensor / weather_attr / heuristic) was used and whether units look correct.

Changelog (short)

  • v0.1.10 — Normalize indoor air to °C; helper output in helper’s unit unless KNX is enabled (then °C).
  • v0.1.9 — Auto-units for temps, wind, precipitation; improved conversions.
  • v0.1.8 — External global radiation sensor unmodified; heuristic cap at 1000 W/m²; description cleanup.
  • Earlier — sensor prioritization, robust fallbacks/validation, KNX registration hardening.

Code

I’ve attached the full YAML of v0.1.10 in this thread. Copy → save as a blueprint → create an automation.


Feedback welcome (especially °F users)

  • Does helper output in °F behave as expected when KNX is not enabled?
  • With KNX enabled, do you see correct °C values on the bus?
  • Do wind/precip values look right after auto-conversion?
  • Which global radiation sources (W/m²) are you using, and how do they compare to the heuristic?

Thanks & happy tuning!

2 Likes
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

Hello Ecronika,

Thanks for contributing to the community with a new Blueprint.
I have a suggestion for you. Many people who are not familiar with directory structures will have problems installing this without the Home Assistant MY tools.
Adding a MY link for this Blueprint to your top post would help them a lot.
Here is the link to make that.
Create a link – My Home Assistant
Note: if the original is in the forums here, it has to be in the top post in the topic and has to be the only code block there or the link will not work.

@Ecronika if you change this from a code block to just text this should do the mylink thing. There can be only 1 code block in the top post if you are sharing from there for the mylink to work.

Virtual MRT v0.1.10
T_in=21.0°C, T_out_eff=2.4°C [eff(min(temp=sensor,app=sensor))],
Wind_eff(km/h)=28.8 via sensor, Wind_ms=8.0,
Clouds=100% [sensor], UV=0.0 [weather_attr],
rain_mul=0.76 [sensor], day_fac=0.12,
rad≈52 W/m² [heuristic], loss_term=3.42, solar_term=0.08,
MRT_calc=17.7°C, MRT_out=18.0°C, T_op=19.5°C

Change that block to lead with a > like this…

Then post the blueprint back into the top post as a </> code block.

Sharing from any post but the top post will not work.

Unfortunately, I can no longer edit my first post, otherwise I would have already implemented your recommendation. The code has now been published on GitHub…

Open your Home Assistant instance and show the blueprint import dialog with a specific blueprint pre-filled.

Click the pencil…
image

Copy that my-link to the top post, that works as well.


There is no pencil …

1 Like

You might need to do a few things to jump from basic user to member…

Hi everyone,

I’ve just pushed a significant update to the Gist. Version 0.1.15 is a major overhaul focusing on stability, safety, and physical accuracy. I essentially re-engineered the logic to make this “production-ready” for controlling actual heating systems.

:rocket: What’s New in v0.1.15?

1. :shield: Safe Mode (Sensor Guard)
This is the most critical change for heating control.

  • Previously: If a sensor went unavailable, the calculation might have fallen back to hardcoded values (e.g., 21°C), potentially causing the heating to spike or turn off unexpectedly.

  • Now: The blueprint strictly monitors input validity. If a critical sensor (Indoor/Outdoor) drops out, the calculation pauses and retains the last valid MRT value. No more freezing or overheating due to empty batteries or Zigbee dropouts!

2. :brick: Configurable Thermal Mass
Added a smoothing_factor slider to the configuration.

  • Buildings react differently to temperature changes. You can now define if your room is a heavy stone/concrete structure (slow response, low smoothing factor) or a light construction/attic (fast response, high smoothing factor).

  • Default is set to 0.3 (Standard Brick/Mixed).

3. :shushing_face: Quiet Operation

  • Debug logging is now disabled by default . Your Logbook stays clean.

  • You can enable detailed logging via a toggle if you need to troubleshoot.

  • Note: Critical sensor failures (“Skipped Update”) are still logged to warn you.

4. :wrench: Technical Polish

  • Refactored the entire variable logic for better performance.

  • Improved heuristics for Solar/Cloud detection (fixed list-matching logic).

  • Corrected edge-cases where sunny weather attributes weren’t recognized correctly.


How to update?

If you are using at least V0.1.11 the Gist URL is the same, you can simply re-import the Blueprint.

  1. Go to Settings > Automations & Scenes > Blueprints .

  2. Find the Virtual MRT blueprint.

  3. Click the three dots > Re-import .

  4. Recommendation: Check your automation after updating to ensure the new smoothing_factor is set to your liking.

For new Users / Installations:

Open your Home Assistant instance and show the blueprint import dialog with a specific blueprint pre-filled.

:point_right: Link to Gist

@Ecronika I’m really interested in this blueprint. I have a blueprint that attempts to do something similar but only using a number of different outdoor temperature sensors (real feel etc…) to trigger heating boosts. This looks way better.

Do you have a weather integration that you would say gives the best results?

Cheers
Spence

Hi @spencerwebb189,

I want to give you a detailed answer.

First things first:
I use Pirate Weather for the standard atmospheric data (Temp, Wind, Clouds) because it provides all the needed data nicely.
However, for the solar part, I use a virtual sensor based on OpenMeteo to feed the Global Radiation Sensor input. This is much more accurate than calculating it from UV/Cloud cover and makes the heating curve extremely precise on sunny winter days.

How it gathers data (Sensors vs. Attributes):
The blueprint uses a “waterfall” logic:

  1. Auto-Discovery: It first checks if separate sensors exist that match your weather entity’s name (e.g., if you use weather.pirateweather, it looks for sensor.pirateweather_uv_index).

  2. Attributes: If no sensor is found, it looks inside the weather entity attributes.

  3. Fallback: Uses heuristics (e.g., calculating solar based on cloud cover).

My specific setup (Pirate Weather):
This logic is crucial for me because in my region/configuration, Pirate Weather does not provide uv_index or precip_intensity inside the main weather entity attributes. However, it exposes them as separate sensors. The blueprint picks these up automatically, which is great because data availability often varies by region or integration settings.

Data used:

  • Temperature: (temperature) - Base value.

  • Apparent Temperature: (apparent_temperature) - Important for you: The blueprint logic automatically uses the lower of the two values to account for wind chill (heating logic).

  • Wind: Uses the maximum of wind_speed and wind_gust_speed to calculate heat loss (drafts) on walls.

  • Sky Conditions: cloud_coverage and uv_index are used to estimate solar gain if no sensor is present.

  • Precipitation: Used to reduce the estimated solar gain (wet surfaces reflect less/evaporation cooling).

Pro Tip:
While I use Pirate Weather for the atmospheric data above, I highly recommend using the “Global Radiation Sensor” input if you can. As mentioned I feed this with a virtual sensor from OpenMeteo , which is much more accurate than the UV/Cloud calculation for solar gain.

Cheers!

1 Like

@Ecronika thanks so much, I’ve moved over to pirate weather.

Could you please elaborate on how you setup the global radiation sensor? I installed the OpenMeteo integration but it’s not obvious which value to use here.

Thanks

Hi @spencerwebb189,

You are absolutely right. I found that the official integration unfortunately doesn’t provide the “Shortwave Solar Radiation” value which is needed here.

To solve this, I used a REST sensor in my configuration.yaml.
This setup fetches the data directly from the OpenMeteo API. I wrote a specific template that tries to get 15-minute precision data first (which is better for solar tracking) and falls back to hourly data if needed.

Here is the code. You just need to add this to your configuration.yaml (or sensors.yaml) and replace the Latitude/Longitude with your own coordinates:

codeYaml

sensor:
  - platform: rest
    name: "Global Radiation OpenMeteo"
    # REPLACE latitude and longitude with your location!
    resource: "https://api.open-meteo.com/v1/forecast?latitude=YOUR_LAT_HERE&longitude=YOUR_LONG_HERE&hourly=shortwave_radiation&minutely_15=shortwave_radiation&forecast_days=1&timezone=auto"
    scan_interval: 450
    value_template: >
      {# 1) Try to use minutely_15 data first for better precision #}
      {% set has_min15 = value_json.get('minutely_15') is not none %}
      {% if has_min15 %}
        {% set times = value_json.minutely_15.time %}
        {% set vals = value_json.minutely_15.shortwave_radiation %}
        {# Round current time to nearest 15 minutes (0,15,30,45) #}
        {% set now = now() %}
        {% set quarter = (now.minute // 15) * 15 %}
        {% set now_q = now.replace(minute=quarter, second=0, microsecond=0) %}
        {% set now_iso = now_q.strftime('%Y-%m-%dT%H:%M') %}
        {% if now_iso in times %}
          {% set idx = times.index(now_iso) %}
          {{ vals[idx] }}
        {% else %}
          0
        {% endif %}
      {% else %}
        {# 2) Fallback: use hourly data #}
        {% set times = value_json.hourly.time %}
        {% set vals = value_json.hourly.shortwave_radiation %}
        {% set now_iso = now().strftime('%Y-%m-%dT%H:00') %}
        {% if now_iso in times %}
          {% set idx = times.index(now_iso) %}
          {{ vals[idx] }}
        {% else %}
          0
        {% endif %}
      {% endif %}
    unit_of_measurement: "W/m²"
    device_class: irradiance
    state_class: measurement

After a restart, you can select sensor.global_radiation_openmeteo in the blueprint input.

Cheers!

1 Like

Hi, @Ecronika .

I created an account just to make this post.

I found your blueprint and turned it into a custom integration installable via HACS. I added you as a code owner in the integration, hope that’s ok.

If you would like, just fork it and use it as a base to add more features. My plan was to add logic to prioritize dedicated sensors vs weather entity.

I added custom profiles data store and a soft cap of 100 custom profiles per room. And each input is now a number entity attached to the device and I also implemented the thermal smoothing factor.

Hope you don’t mind!

Edit: just to be clear, I’ll relinquish the ownership of the repo and transfer it to you if you’d like. I just wanted to get this into an integration so it can create its own devices and entities and be easily installable by everyone. Not trying to step on toes or anything like that.

1 Like

Hi @Some-dude,

Wow, that is amazing! Thank you so much for the effort and for creating an account just to share this. I am really honored.

To be honest: Python is not really my world (yet). I am comfortable with YAML and Jinja templating, but a full custom component is a bit out of my depth right now. So I wouldn’t be able to contribute much to the codebase or maintain it properly at this moment.

I am very happy for you to keep the ownership and maintain the repo! I definitely don’t mind—on the contrary, it is great to see this becoming easily installable for everyone.

One question regarding the future:
I actually have a local development version running here that already includes some interesting improvements and new features which are not released yet. :wink:

If I push these updates to the blueprint logic, do you plan to adopt those changes into the integration?

Cheers!

1 Like

Awesome! Glad to help.

Yes, I can pull in new changes from the blueprint as the blueprint is updated. You can also open issues in the repo to grab my attention if something needs looking at right away.

I’ll take a look at the blueprint this evening and update the integration if needed.

Thanks for your work!

:fire: Virtual MRT Sensor – v2.1.0 Update (Solar Azimuth, Heating Model, Presence/People & Adaptive Comfort)

Hey everyone :wave:
I’ve been iterating on my Virtual MRT (Mean Radiant Temperature) Sensor Blueprint for Home Assistant.
If you’re coming from v0.1.15, this is a major upgrade: it’s not just cleanup—there are new (optional) models that make the results feel much more “real-world” in daily heating control.

Quick reminder: what does it do?

It calculates MRT and Operative Temperature (instead of using air temperature alone). This is especially useful in rooms with:

  • cold exterior walls
  • large windows
  • strong sun exposure
  • radiators / underfloor heating where “radiant feel” matters

:white_check_mark: What’s better in v2.1.0 (vs. v0.1.15)

:sunny: 1) Advanced Solar Gain: Azimuth correction (sun angle aware)

In v2, solar gain can be scaled using the actual sun azimuth relative to your window orientation (when enabled).
:arrow_right: Benefit: East/West windows and shoulder-season sun behave much more realistically, with fewer “solar spikes at the wrong time.”

(Optional as before: you can still use an external irradiance sensor in W/m² for maximum accuracy.)


:fire: 2) Optional Heating Model: Radiator / Underfloor / IR

v2 adds an optional heating model that can apply a radiant contribution when heating is active (e.g., via climate.*, valve position, switch.*, binary_sensor.*, etc.).
It’s tunable via an intensity slider.
:arrow_right: Benefit: MRT rises in a way that better matches how rooms actually feel when heat sources radiate.


:busts_in_silhouette: 3) Optional Presence / People load

You can optionally add a small MRT uplift based on occupancy (roughly ~0.1 K per person).

  • Priority: room presence sensor (mmWave/occupancy)
  • Fallback: list of person.* entities (counts who is home)
    :arrow_right: Benefit: offices, meeting rooms, and living spaces feel more consistent under real occupancy.

:wind_face: 4) Optional Adaptive Operative Temperature weighting

Previously, operative temperature used a fixed 50/50 blend of air temp and MRT.
v2 introduces an optional “simple adaptive” weighting:

  • window open → air temperature gets more weight
  • heating active → slightly more air temperature weight
    :arrow_right: Benefit: fewer weird operative values during ventilation, and smoother control behavior.

:jigsaw: 5) Better Blueprint UX / clearer configuration

Inputs are grouped more cleanly (Essentials / Outputs / Heating / People / Advanced).
:arrow_right: Benefit: faster setup, easier troubleshooting.


:hammer_and_wrench: 6) Quality improvements & robustness

General guard checks, more informative debugging (especially when Solar/Heating/People features are enabled), and some consistency improvements.


:gear: Requirements / Upgrade notes

  • Requires Home Assistant ≥ 2024.6.0
  • Weather integrations like Pirate Weather / Met.no work well (same general approach as before)
2 Likes

@Ecronika I’ll be upgrading to this latest version this afternoon, thanks for all of your work on this :pray:

I’ve been using it to control my heading and the results have been positive so far. I have a room with 3 external walls a large window and a large french window. Any recommendations on which profile to use?

Also, do the 2 walls profiles mean cavity walls? Or that the room has 2 externally facing walls?

Hey @spencerwebb189 ! Thanks a lot — really happy to hear it’s been working well for heating control :pray:

Which profile for 3 external walls + big window + french window?

In v2.1.0 there isn’t a dedicated “3 exterior walls” option, so I’d start with “2 Exterior Walls, Large Window” as the closest match (it’s the most “exposed” of the non-top-floor profiles, and it keeps a strong window influence).

A couple of quick tips for big glazing rooms:

  • Set Primary Window Orientation to the dominant glazing direction (the big window / french window side).
  • Keep Azimuth Correction enabled (and if you have a real W/m² radiation sensor, select it — it makes solar behavior much more realistic).

If you find the room still “feels colder than the air temp suggests” on windy / cold nights, you can experiment with a slightly more exposed profile (e.g., Attic) — it bumps the exterior-loss weighting — but I’d still recommend starting with 2 Exterior Walls, Large Window first because your windows are a major comfort driver.

Do the “2 walls” profiles mean cavity walls?

No — in this blueprint, “1/2 Exterior Walls …” literally means the number of outside-facing walls (room exposure).

The word “cavity” only appears in “Top Floor (2 Ext Walls/Cavity)”, and that one is meant for top-floor/loft scenarios where part of the envelope borders a cold roof void / knee-wall / cavity space — it’s not saying “cavity wall construction” (brick cavity vs solid wall).

Thanks for the explanation and apologies that I’ve confused things. I wasn’t saying I’m using a 3 wall profile, I was saying that my room has 3 external walls and I’m using the 2 wall profile. Thanks for confirming the cavity situation also I presumed that this was the case but wasn’t sure.

I don’t currently have an external global radiation sensor. I had a quick look and might look to invest :slight_smile: I’m currently using the integration you recommended.

With the latest update, the rooms feel warmer overall so thanks again for your hard work.

Hey @Ecronika and @Some-dude - great blueprint and great HACS integration! I’m keen to install the blueprint v2.1.0 or should I be using the HACS integration? Is the HACS integration updated to account for the updates from v2.1.0?