[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!

1 Like
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…