How to Calculate the Heat Index and Feels-Like Temperature in Python
On this page
Problem statement
On a humid summer afternoon 33 °C feels much hotter than on a dry one, and on a windy winter day 0 °C feels colder than on a calm one. Forecasts report this as the "feels-like" or apparent temperature, but there is no single formula behind it. The US National Weather Service uses the heat index in summer and the wind chill in winter, Canada uses the humidex, and Open-Meteo returns an apparent temperature based on Australia's Steadman formula. Each gives a different number for the same weather.
This page computes the NWS heat index, with its adjustments, from hourly temperature and humidity at Washington National Airport for summer 2024. It computes the wind chill for winter, compares the results with the humidex and with Open-Meteo's apparent temperature, and counts the hours that reached NWS heat-advisory levels.
Quick answer
The NWS heat index uses the Rothfusz regression in °F, with a simpler formula below about 80 °F:
def heat_index_c(t_c, rh):
"""NWS heat index from air temperature (°C) and relative humidity (%), in °C."""
T = np.asarray(t_c, dtype=float) * 9 / 5 + 32
R = np.asarray(rh, dtype=float)
simple = 0.5 * (T + 61.0 + (T - 68.0) * 1.2 + R * 0.094)
hi = (-42.379 + 2.04901523 * T + 10.14333127 * R - 0.22475541 * T * R - 6.83783e-3 * T**2
- 5.481717e-2 * R**2 + 1.22874e-3 * T**2 * R + 8.5282e-4 * T * R**2 - 1.99e-6 * T**2 * R**2)
adj_dry = np.where((R < 13) & (T >= 80) & (T <= 112), -((13 - R) / 4) * np.sqrt(np.clip((17 - np.abs(T - 95)) / 17, 0, None)), 0)
adj_wet = np.where((R > 85) & (T >= 80) & (T <= 87), ((R - 85) / 10) * ((87 - T) / 5), 0)
hi = np.where((simple + T) / 2 >= 80, hi + adj_dry + adj_wet, simple)
return (hi - 32) * 5 / 9
S["heat_index"] = heat_index_c(S.temp, S.rhum)
print(f"summer 2024 hours: {len(S):,}; highest heat index {S.heat_index.max():.1f} °C at {S.heat_index.idxmax():%d %b %H:%M} UTC, when air temperature was {S.loc[S.heat_index.idxmax(), 'temp']:.1f} °C and humidity {S.loc[S.heat_index.idxmax(), 'rhum']:.0f}%")
summer 2024 hours: 2,208; highest heat index 44.0 °C at 09 Jul 18:00 UTC, when air temperature was 36.7 °C and humidity 48%
The highest heat index of summer 2024 was 44.0 °C (111 °F), at 18:00 UTC on 9 July, when the air was 36.7 °C at 48% humidity.
How much humidity adds, advisory hours, wind chill
1. How much humidity adds
hot = S[S.temp >= 30]
print(f"hours at or above 30 °C: {len(hot)}; heat index minus air temperature: median {(hot.heat_index - hot.temp).median():+.1f} °C, largest {(hot.heat_index - hot.temp).max():+.1f} °C")
for lo, hi_ in ((30, 33), (33, 36), (36, 40)):
sub = S[(S.temp >= lo) & (S.temp < hi_)]
print(f"air {lo}-{hi_} °C: {len(sub):>4} hours, mean heat index {sub.heat_index.mean():.1f} °C")
hours at or above 30 °C: 519; heat index minus air temperature: median +2.6 °C, largest +7.7 °C
air 30-33 °C: 337 hours, mean heat index 33.3 °C
air 33-36 °C: 124 hours, mean heat index 37.5 °C
air 36-40 °C: 58 hours, mean heat index 40.1 °C
In the 519 hours at or above 30 °C, humidity added a median of 2.6 °C to the felt temperature, and up to 7.7 °C. The hotter the air, the more it adds: 33.3 °C on average between 30 and 33 °C, and 40.1 °C between 36 and 40 °C.
2. Advisory hours
The NWS issues heat advisories at local thresholds; 105 °F (40.6 °C) heat index is a common one. Count the hours and days that reached it:
adv = S.heat_index >= (105 - 32) * 5 / 9
print(f"hours with heat index at or above 105 °F: {adv.sum()}; days with at least one: {S[adv].index.tz_localize('UTC').tz_convert('America/New_York').normalize().nunique()}")
hours with heat index at or above 105 °F: 30; days with at least one: 7
The heat index reached 105 °F (40.6 °C) in 30 hours, on 7 days.
3. Wind chill in winter
Below 50 °F and with wind above 3 mph, the NWS wind chill applies:
def wind_chill_c(t_c, wind_kmh):
T = np.asarray(t_c) * 9 / 5 + 32; V = np.asarray(wind_kmh) / 1.609344
wc = 35.74 + 0.6215 * T - 35.75 * V**0.16 + 0.4275 * T * V**0.16
return np.where((T <= 50) & (V > 3), (wc - 32) * 5 / 9, np.asarray(t_c))
Wn["wind_chill"] = wind_chill_c(Wn.temp, Wn.wspd)
print(f"winter hours: {len(Wn):,}; lowest wind chill {Wn.wind_chill.min():.1f} °C at {Wn.wind_chill.idxmin():%d %b %H:%M} UTC (air {Wn.loc[Wn.wind_chill.idxmin(), 'temp']:.1f} °C, wind {Wn.loc[Wn.wind_chill.idxmin(), 'wspd']:.0f} km/h)")
winter hours: 1,440; lowest wind chill -14.4 °C at 17 Jan 08:00 UTC (air -7.8 °C, wind 17 km/h)
The lowest wind chill of the winter was −14.4 °C at 08:00 UTC on 17 January, 3 am local, with the air at −7.8 °C and a 17 km/h wind.
Four feels-like numbers for the same hour
The data used on this page
Hourly temperature, relative humidity and wind speed for June to August 2024, with dew point derived from them, and January to February 2024 from Meteostat station 72405, Washington National Airport (CC BY 4.0); and Open-Meteo's archived apparent_temperature for the same point. The heat index follows the NWS formula.
import hashlib
import json
import time
from datetime import datetime
from pathlib import Path
import meteostat as ms
import numpy as np
import pandas as pd
import requests
Path("data").mkdir(exist_ok=True)
S = ms.hourly("72405", datetime(2024, 6, 1), datetime(2024, 8, 31, 23)).fetch()[["temp", "rhum", "wspd"]].dropna(subset=["temp", "rhum"])
g = np.log(S.rhum / 100) + 17.625 * S.temp / (243.04 + S.temp)
S["dwpt"] = 243.04 * g / (17.625 - g) # dew point from temperature and humidity (Magnus)
Wn = ms.hourly("72405", datetime(2024, 1, 1), datetime(2024, 2, 29, 23)).fetch()[["temp", "wspd"]].dropna()
print(f"summer hours {len(S):,}; winter hours {len(Wn):,}")
summer hours 2,208; winter hours 1,440
There are 2,208 summer hours and 1,440 winter hours of data.
Example 1: four feels-like numbers for the same hour
Compare the heat index with the humidex and with Open-Meteo's apparent temperature at the hottest hour:
def humidex(t_c, dew_c):
e = 6.11 * np.exp(5417.7530 * (1 / 273.16 - 1 / (np.asarray(dew_c) + 273.15)))
return np.asarray(t_c) + 0.5555 * (e - 10.0)
S["humidex"] = humidex(S.temp, S.dwpt)
params = {"latitude": 38.8472, "longitude": -77.0345, "start_date": "2024-06-01", "end_date": "2024-08-31", "hourly": "apparent_temperature", "timezone": "UTC"}
f = Path("data/om_app_" + hashlib.md5(json.dumps(params, sort_keys=True).encode()).hexdigest() + ".json")
if not f.exists():
r = requests.get("https://archive-api.open-meteo.com/v1/archive", params=params, timeout=300); r.raise_for_status(); f.write_text(r.text)
app = pd.DataFrame(json.loads(f.read_text())["hourly"]).assign(time=lambda d: pd.to_datetime(d.time)).set_index("time").apparent_temperature
t = S.heat_index.idxmax()
print(f"at {t:%d %b %H:%M} UTC: air {S.loc[t, 'temp']:.1f} °C, heat index {S.loc[t, 'heat_index']:.1f}, humidex {S.loc[t, 'humidex']:.1f}, Open-Meteo apparent {app.get(t, np.nan):.1f} °C")
at 09 Jul 18:00 UTC: air 36.7 °C, heat index 44.0, humidex 47.9, Open-Meteo apparent 39.9 °C
The same hour gives four different numbers: 36.7 °C air temperature, a heat index of 44.0, a humidex of 47.9 and Open-Meteo's apparent temperature of 39.9 °C. The last comes from a grid cell and a different formula.
Feels-like temperatures model the body's heat balance
Feels-like temperatures estimate how hot or cold the air feels to a person, given the body's heat balance. In heat, humidity reduces the cooling from evaporating sweat. In cold, wind strips heat from the skin. The NWS heat index is a regression, fitted by Rothfusz in 1990, to Steadman's tables of apparent temperature for a person in shade with light wind. It is defined in °F, needs adjustments at very low and very high humidity, and falls back to a simpler formula when the result would be below about 80 °F. The wind chill, revised in 2001, models heat loss from the face in wind. Canada's humidex uses the dew point instead of relative humidity. Open-Meteo's apparent temperature follows Steadman's formulation and also includes wind and, in some versions, radiation.
These indices answer slightly different questions, so the same hour gets different numbers, and none of them is the air temperature. For public health work, the index should match the warnings it relates to: in the US, the heat index against NWS advisory thresholds. Station data is a better input than reanalysis for extremes, because humidity and temperature peaks are local and cell averages damp them.
Sun, units and local thresholds
- Sun: the heat index assumes shade; full sun can add up to about 8 °C, which is what wet-bulb globe temperature accounts for.
- Units: compute in °F as defined and convert, or the regression coefficients do not apply.
- Dew point vs humidity: humidity derived from dew point and temperature is more reliable than reported humidity at some stations.
- Thresholds are local: NWS offices set advisory criteria for their own areas.
Internal links
- How to count heatwave days in Python — heat as events
- Heat exposure explained — who is exposed
- How to get weather station data with Meteostat in Python — the hourly input
FAQ
How do I calculate the heat index in Python?
Use the NWS Rothfusz regression in °F with its low- and high-humidity adjustments, and the simple formula when the result is below about 80 °F; convert the result to °C if needed.
What is the difference between heat index and humidex?
The heat index (US) uses relative humidity and approximates Steadman's apparent temperature; the humidex (Canada) uses dew point and a simpler vapour-pressure formula, and gives higher values.
How do I calculate wind chill?
Use the 2001 NWS formula in °F with wind in mph, valid for temperatures at or below 50 °F and wind above 3 mph.
What does feels like temperature mean?
An estimate of how the air feels to a person, combining temperature with humidity in heat or wind in cold.