A Single Year-Round "Feels Like" Temperature Sensor for Home Assistant
Scope: This article builds the shaded (non-solar) apparent temperature. All formulas and values assume no direct sun load on the body. For a sun-exposed location, the radiation-inclusive Steadman variant or UTCI is the correct model family; see Limitations.
The problem
Home Assistant has no built-in way to answer a simple question: what does it actually feel like outside right now, in one number, on any day of the year?
The indices most people reach for are seasonal by design. Wind chill is defined only for cold, windy conditions and ignores humidity. The US heat index and Canadian humidex are defined only for warm, humid conditions and ignore wind. Weather providers handle this by switching formulas: the US National Weather Service uses wind chill at or below 50 °F, heat index above 80 °F, and plain air temperature in between. The result is two seasonal sensors and a dead band in the middle, which is exactly what a dashboard should not have.
The popular HACS Thermal Comfort integration does not solve this either, for a specific reason. It is actively maintained, MIT licensed, and its humidity-derived indices (dew point, heat index, simmer index, frost risk and others) are correctly implemented. But it accepts only temperature and humidity inputs. It has no wind input at all, which is acknowledged in the project's own issue tracker (issue #161). Without wind, no index it produces can be valid on the cold half of the year. This is a core capability gap for any "feels like" use case, not a defect in what it does implement.
This article documents a single template sensor that covers the full annual range, computes locally, and handles unit conversion automatically.
Model selection
The requirement: one continuous scale from deep winter to peak summer, valid for a shaded location, computable from temperature, relative humidity, and wind speed.
The model that fits is the Australian Bureau of Meteorology Apparent Temperature, from Steadman (1994). The non-radiation (shaded) form:
AT = Ta + 0.33·e − 0.70·ws − 4.00
where:
Tais air temperature in °Cwsis wind speed in m/s, measured at the standard 10 m heighteis water vapour pressure in hPa, derived from temperature and relative humidity:
e = (RH / 100) · 6.105 · exp(17.27·Ta / (237.7 + Ta))
Why this model and not the alternatives:
| Candidate | Verdict |
|---|---|
| BoM Apparent Temperature | One continuous formula. Humidity term dominates in heat, wind term in cold. Explicitly modeled for an adult walking outdoors in the shade. Inputs match what a home deployment has. |
| Wind chill (JAG/TI 2001) | Cold only. Undefined above roughly 10 °C. No humidity term. |
| Heat index / humidex | Heat only. No wind term. Undefined in cool conditions. |
| UTCI | Scientifically the strongest, but requires mean radiant temperature, is implemented as a 6th-order polynomial, and is undefined below 0.5 m/s wind. Reasonable via the pythermalcomfort Python library, overkill for a dashboard number. |
| WBGT / wet bulb | Heat stress indices, not perceived temperature scales. Undefined as cold indices. |
Limits of the chosen model, with computed examples:
- Extreme cold: at −20 °C with 30 km/h wind, the BoM AT reads about −29.5 °C where the dedicated NWS/Environment Canada wind chill reads −32.6 °C. The AT understates strong wind bite by roughly 3 °C in this regime. If frostbite alerting matters, run the wind chill formula as a separate threshold helper; do not display two competing headline numbers.
- Extreme heat: at 35 °C and 60% RH, the AT reads about 41 °C where humidex reads 48.5 °C. These are different scales with different reference states; the AT is the more conservative.
- Mild conditions: the −4.00 constant means the AT reads a few degrees below air temperature even in calm, mild weather. This is inherent to the index's reference state, not an error. If "feels like equals air temperature on a calm spring day" is a requirement, this model will not satisfy it.
Input sources
Temperature and humidity
Local sensors, ideally a shaded outdoor probe. Local measurement beats any provider's grid cell for your microclimate.
Wind speed
Wind is the input most installations lack. Three workable sources, in order of preference:
- A local anemometer, if reasonably exposed. A sheltered ground-level reading will understate wind and is a worse input than a model value.
- A forecast provider's current wind. Pirate Weather, Open-Meteo, and the NWS integration all serve wind from the NOAA model chain (NBM, HRRR, GFS and related). Surface wind in these models is the standard 10 m above-ground value, which is exactly what the formula's coefficients were fit against.
- A nearby personal weather station or airport METAR, if one is genuinely closer to your conditions than a ~3 km model grid cell.
Two rules for the wind input:
- Do not apply a height-reduction factor. Steadman's regression already embeds the assumption that wind felt at body height is roughly half the 10 m value. Feeding 10 m wind directly is correct; multiplying by 2/3 or 0.7 first double-counts the reduction.
- Verify units on the actual entity, not the provider's documentation. Pirate Weather, for example, returns m/s, km/h, or mph depending on the requested unit system, and Home Assistant may convert again on top of that. The template below removes this class of error by reading units at evaluation time.
If you use Pirate Weather: its own apparentTemperature field is also a Steadman formulation, but the radiation-inclusive variant with a solar term and slightly different coefficients (0.348 humidity coefficient, −4.25 constant). It is a sun-exposed value computed entirely from grid data. It makes a useful sanity-check comparison against the sensor built here, but it is not the shaded value and it does not use your local sensors. Expect it to read warmer on clear days.
Implementation
One template sensor. Three design points beyond the formula itself:
- Unit-aware inputs. The template reads each source's
unit_of_measurementattribute at evaluation time and converts to the formula's native units. Swap a mph wind source for a m/s anemometer later and the math self-corrects with no edit. - Single-place configuration. The three source entity IDs live in one
variablesblock on the template entity, referenced by both theavailabilityandstatetemplates. For state-based template entities, variables resolve at configuration load or reload, which is the correct behavior for static entity IDs. This requires a recent HA Core release; on older versions that reject thevariableskey, fall back to repeating{% set %}lines at the top of each template. - Optional wind input. Setting
w_ent: nullin thevariablesblock switches the sensor to a calm-air mode for fully sheltered or indoor locations. See the dedicated section below. - Fail closed. If any input is non-numeric, or a unit appears that the template does not recognize, the sensor goes
unavailableinstead of computing a wrong number. Beaufort is deliberately excluded from the wind whitelist: it is a nonlinear, lossy scale, and refusing it is better than approximating it. - Native-unit output. The template outputs °C, the formula's native unit, and never converts. The output unit is selected once, in the entity's unit setting. See the output unit section for why conversion is kept out of the template.
TL;DR:
- All three source entity IDs are configured in one place: the
variablesblock at the top.- For a fully sheltered or indoor location, set
w_ent: nullin thevariablesblock; the sensor switches to calm-air mode.- Nothing else requires editing; input units are detected automatically.
- The sensor outputs the formula's native unit, °C. The output unit is selected in exactly one place: the entity's "Unit of measurement" setting (gear icon on the entity, °C/°F/K). HA converts the state itself, so dashboards, automations, and statistics all see the selected unit. Nothing in the YAML changes.
template:
- variables:
t_ent: sensor.outdoor_temperature
w_ent: sensor.outdoor_wind_speed # set to null for sheltered or indoor locations
h_ent: sensor.outdoor_humidity
sensor:
- name: "Apparent Temperature"
unique_id: apparent_temperature_bom
unit_of_measurement: "°C"
device_class: temperature
state_class: measurement
availability: >
{{ states(t_ent) | is_number
and states(h_ent) | is_number
and state_attr(t_ent, 'unit_of_measurement') in ['°F', '°C', 'K']
and (w_ent is none
or (states(w_ent) | is_number
and state_attr(w_ent, 'unit_of_measurement') in ['mph', 'km/h', 'm/s', 'kn', 'ft/s'])) }}
state: >
{# temperature to °C #}
{% set t_raw = states(t_ent) | float %}
{% set t_unit = state_attr(t_ent, 'unit_of_measurement') %}
{% if t_unit == '°F' %}
{% set ta = (t_raw - 32) / 1.8 %}
{% elif t_unit == 'K' %}
{% set ta = t_raw - 273.15 %}
{% else %}
{% set ta = t_raw %}
{% endif %}
{# wind to m/s; calm-air mode when no wind source is configured #}
{% if w_ent is none %}
{% set ws = 0 %}
{% else %}
{% set w_raw = states(w_ent) | float %}
{% set w_unit = state_attr(w_ent, 'unit_of_measurement') %}
{% set w_factor = {'mph': 0.44704, 'km/h': 0.2777778,
'm/s': 1.0, 'kn': 0.514444,
'ft/s': 0.3048}.get(w_unit) %}
{% set ws = w_raw * w_factor %}
{% endif %}
{# relative humidity, 0-100 % #}
{% set rh = states(h_ent) | float %}
{# BoM apparent temperature, non-radiation (shaded) #}
{% set e = (rh / 100) * 6.105 * 2.718281828 ** (17.27 * ta / (237.7 + ta)) %}
{% set at_c = ta + 0.33 * e - 0.70 * ws - 4.00 %}
{# native output in °C; output unit is selected once,
in the entity's unit setting (see Output unit selection) #}
{{ at_c | round(1) }}
Output unit selection
The template outputs the formula's native unit, °C, and never converts. Select the output unit in one place:
- Per entity: open the entity, gear icon, "Unit of measurement" dropdown (°C, °F, K). This converts the entity state itself, so dashboards, automations, and recorded statistics all operate in the selected unit.
- Per user: each user's profile unit system additionally applies to their own dashboard views.
Changing the setting later is non-destructive; the native value is unchanged and HA offers to convert existing statistics. Keep conversion out of the template: the declared unit_of_measurement key cannot read a template variable, so any in-template conversion duplicates configuration across two places that must match, and a mismatch produces mislabeled data.
Sheltered and indoor locations: omitting wind
For a fully wind-sheltered location (an enclosed porch, a greenhouse, an indoor room), set the wind entity to null in the variables block:
variables:
w_ent: null
The template then sets ws = 0 and the formula reduces to the calm-air case:
AT = Ta + 0.33·e − 4.00
This is the same Steadman scale with the wind term at its floor, not a different model, so readings from a sheltered instance remain directly comparable to readings from an exposed instance. That comparability is the reason to omit wind this way rather than switching the sheltered location to a humidity-only index such as heat index: one scale across every location, one set of thresholds.
Two qualifications:
- Setting wind to zero is correct for enclosed spaces and a reasonable floor for heavily sheltered outdoor spots. It is not a substitute for a real wind value at a location that does receive wind; an exposed location with
w_ent = nonewill read warm in winter. - The index was calibrated for outdoor clothing and activity norms. Applied indoors, the arithmetic is internally consistent and useful for relative comparison and trending, but the absolute values should not be read as an indoor comfort standard. For dedicated indoor comfort assessment, PMV/PPD-class models are the conventional instruments.
Verification
Test incrementally before committing configuration:
- Paste the
state:block contents into Developer Tools, Template, prefixed with three{% set %}lines standing in for thevariablesblock (for example{% set t_ent = 'sensor.outdoor_temperature' %}and so on), since entity variables are not defined in the Developer Tools sandbox. Confirm a sane number against a hand calculation. Worked check value: at 86.2 °F, 50% RH, and 5.4 mph wind, the native result should be approximately 31.4 °C (vapour pressure ≈ 21.3 hPa), which displays as 88.6 °F once the entity's unit setting is switched to °F. - Temporarily change one source entity ID to a sensor with an unexpected unit and confirm the sensor goes
unavailablerather than emitting a number. - If deploying a sheltered or indoor instance, repeat the Developer Tools test with
{% set w_ent = none %}as the stand-in and confirm the result rises by 0.70 °C per m/s of wind removed relative to the windy result, which confirms the branch is taken. - Add the block to
configuration.yaml, then reload under Developer Tools, YAML, Template Entities. No restart required. - Confirm the display-unit dropdown behaves as expected on the live entity before placing it on a dashboard.
Failure behavior
The availability guard marks the sensor unavailable whenever any input drops out, including a cloud wind source. If you prefer degraded output over gaps during a provider outage, substitute a climatological default wind (around 3 m/s, converted to your source's unit) via the float() default and remove the wind clause from availability. Choose one behavior deliberately; mixing a default value with an availability guard on the same input is incoherent.
Limitations
- The BoM AT is a regression approximation of Steadman's full heat-balance model. Perceived temperature additionally varies with clothing, activity, acclimatization, and hydration, which no weather-input index captures.
- The shaded (non-radiation) form is used throughout. For a sun-exposed location, the radiation-inclusive Steadman variant or UTCI with a computed mean radiant temperature would be the upgrade path, at the cost of a solar input and significant implementation weight.
- "Feels like" is model-dependent. The same conditions yield −29.5 °C on this scale and −32.6 °C on the wind chill scale. Publish one model, label it, and do not mix scales on a dashboard.
References
- Steadman, R.G. (1994). Norms of apparent temperature in Australia. Australian Meteorological Magazine, 43, 1-16.
- Australian Bureau of Meteorology, thermal comfort observations documentation (apparent temperature formula).
- US National Weather Service, National Digital Forecast Database apparent temperature definition (piecewise wind chill / heat index thresholds).
- Environment and Climate Change Canada, wind chill and humidex inclusion criteria.
- dolezsa/thermal_comfort, GitHub: provided sensors and issue #161 (no wind input).
- Pirate Weather API documentation:
apparentTemperature(Steadman 1994, radiation-inclusive variant) andwindSpeedunit behavior. - pythermalcomfort (MIT) and ECMWF thermofeel (Apache-2.0) libraries, UTCI implementations.