How to Build a Thermal Comfort Layer for Outdoor Scheduling Apps Using WeatherAPI’s Hourly Fields

Most outdoor scheduling apps do one of two things: check a condition code (“is it raining?”) or threshold a single field (“is it above 30°C?”). Both miss what users actually care about — whether it’s comfortable enough to be outside. A 32°C day in Glasgow at 40% humidity is workable. The same 32°C in Singapore at 85% humidity is borderline unsafe. The raw temperature field in the API response doesn’t tell you which one you’re looking at.

This post walks through building a composite thermal comfort score using fields WeatherAPI actually returns in the hourly forecast — no external libraries, no model calls, just arithmetic on the payload you’re already getting.

What the Hourly Forecast Actually Gives You

The /forecast.json endpoint returns, for each hour block, everything you need for a reasonable comfort model:

  • temp_c — dry bulb temperature
  • humidity — relative humidity as a percentage
  • wind_kph — sustained wind speed
  • feelslike_c — the API’s own composite (wind chill or heat index depending on conditions)
  • uv — UV index
  • cloud — cloud cover percentage
  • dewpoint_c — available on most plans, and the most useful humidity-adjacent field for comfort work

You don’t get wet bulb temperature or a full WBGT reading. Those require either a physical instrument or a multi-variable iterative calculation that goes well beyond what a REST response hands you. But you can get surprisingly close to actionable comfort thresholds without them.

Why Not Just Use feelslike_c?

It switches formulas at a temperature threshold and ignores solar radiation entirely. The API applies wind chill below roughly 10°C and heat index above — the same logic NWS uses, so it’s defensible. But it treats a cloudless 28°C day identically to an overcast 28°C day, even though radiant heat load from direct sun can add 6–8°C of effective thermal stress on exposed skin. For outdoor scheduling — construction sites, sports events, agriculture fieldwork — that gap matters.

There’s a subtler problem too. Heat index uses relative humidity, but relative humidity is a function of temperature. A dew point of 20°C feels very different at 22°C ambient (roughly 90% RH, stifling) versus 35°C ambient (roughly 45% RH, warm but manageable). Using dewpoint_c directly gives a more stable picture of actual moisture load on the body, because dew point doesn’t shift as the air heats through the day the way relative humidity does.

A Practical Comfort Score: The Fields and the Weights

Here’s the approach we actually build into scheduling pipelines. It’s not a published standard — it’s a pragmatic composite. Where it draws on established research, I’ll say so.

The score runs 0–100, where higher means less comfortable (think of it as a thermal stress index). Below 25 is fine, 25–50 is moderate, 50–75 is uncomfortable, above 75 is high-risk for extended outdoor exposure.

Step 1: Adjusted Feels-Like from Dew Point

Instead of the API’s feelslike_c, compute an adjusted apparent temperature using the Steadman formula — the same underlying math as heat index, but more explicitly dew-point aware:

// AT = -1.3 + 0.92*T + 2.2*e - 0.3*WS - 0.7*SR
// where:
//   T  = temp_c
//   e  = vapour pressure in kPa (derived from dew point)
//   WS = wind speed in m/s
//   SR = solar radiation proxy (see step 2)

function vapourPressure(dewpoint_c) {
  // Magnus approximation — accurate within 0.1% between -40 and 60°C
  return 0.6105 * Math.exp((17.27 * dewpoint_c) / (dewpoint_c + 237.3));
}

function apparentTemp(temp_c, dewpoint_c, wind_kph, solarProxy) {
  const T = temp_c;
  const e = vapourPressure(dewpoint_c);
  const WS = wind_kph / 3.6; // convert to m/s
  const SR = solarProxy;     // W/m² equivalent, see below
  return -1.3 + (0.92 * T) + (2.2 * e) - (0.3 * WS) - (0.7 * SR);
}

This is the Steadman (1994) formulation used by the Australian Bureau of Meteorology for their apparent temperature outputs. It’s broader than NWS heat index — it applies across the full temperature range rather than switching formulas at a threshold, which is why it works better as a single scoring input.

Step 2: Solar Proxy from cloud and uv

The forecast endpoint doesn’t return irradiance in W/m². You can construct a rough proxy from uv and cloud:

function solarProxy(uv, cloud_pct) {
  // UV index ~= solar irradiance / 40 (approximate, varies by latitude/season)
  // Cloud attenuates linearly as a rough first approximation
  const clearSkySolar = uv * 40;                              // rough W/m² equivalent
  const cloudFactor = 1 - (cloud_pct / 100) * 0.75;          // 100% cloud ≈ 25% of clear-sky solar
  return clearSkySolar * cloudFactor;
}

The 1:40 UV-to-irradiance ratio is a known approximation — it breaks down at very low solar angles (early morning, evening, high latitudes in winter) and in aerosol-heavy conditions. Don’t use it for anything precision-dependent. For thermal comfort bucketing it’s sufficient: the meaningful distinction is “full sun” versus “overcast,” not the difference between 200 and 250 W/m².

Step 3: Map to a 0–100 Score

function thermalComfortScore(temp_c, dewpoint_c, wind_kph, uv, cloud_pct) {
  const solar = solarProxy(uv, cloud_pct);
  const AT = apparentTemp(temp_c, dewpoint_c, wind_kph, solar);
  
  // Thresholds derived from Australian BoM and occupational health guidelines
  // AT < 18: cold stress begins
  // 18–26: comfortable
  // 26–32: warm, moderate stress
  // 32–38: hot, significant stress
  // > 38: dangerous
  
  if (AT < 0)   return 95;
  if (AT < 10)  return 80;
  if (AT < 18)  return 50;
  if (AT <= 26) return Math.max(0, (AT - 18) * 3);            // 0–24 in the comfort band
  if (AT <= 32) return 25 + ((AT - 26) / 6) * 25;            // 25–50
  if (AT <= 38) return 50 + ((AT - 32) / 6) * 25;            // 50–75
  return Math.min(100, 75 + ((AT - 38) / 4) * 25);           // 75–100
}

The boundaries are intentionally asymmetric. Cold stress at 10°C apparent temperature is real — especially with wind — but it's rarely as acute for short outdoor exposures as heat stress at the same deviation above the comfort band. NIOSH and OSHA occupational health guidelines treat sustained wet-bulb temperatures above 28°C as requiring active intervention for physical workers. The scoring reflects that asymmetry rather than pretending both ends of the scale are equivalent.

Pulling the Right Hours from the API

If you're scheduling outdoor activity windows, you want a range of hours, not just the current one. The /forecast.json endpoint returns up to 14 days of hourly data. For a three-day "optimal time" picker:

GET https://api.weatherapi.com/v1/forecast.json
  ?key=YOUR_KEY
  &q=51.5074,-0.1278
  &days=3
  &aqi=no
  &alerts=no

Walk forecast.forecastday[n].hour[] and compute the score for each entry. Stripping AQI and alerts keeps the response lighter — it matters if you're running this on a mobile client or a serverless function with a tight execution budget.

One gotcha worth knowing: the hourly array uses local time in the time field, but time_epoch is UTC-based. If you're scheduling across timezones, anchor your logic to time_epoch and convert to display at render time. Comparing epochs directly is less error-prone than trying to infer the offset from location.localtime_epoch.

Where This Breaks Down

The solar proxy is the weakest link. At latitudes above roughly 55° — Scotland, Scandinavia, most of Canada — in winter, UV index values near zero don't mean no solar heating. UV specifically is low, but diffuse shortwave radiation still contributes meaningfully to radiant load. The proxy underestimates solar contribution in those conditions. For summer use cases at mid-latitudes it holds up. For a year-round app in northern Europe, incorporate the sunrise/sunset fields to zero out the solar term during nighttime hours — the UV field handles this naturally, but the cloud correction term can behave oddly near twilight.

The score also models a person standing in the open. Radiant heat from tarmac, rooftops, or synthetic sports turf can add 10–15°C to apparent temperature in dense urban environments. Nothing in the API response captures urban heat island effects at that micro-scale. If your app targets urban construction or synthetic-pitch sports events, build in a fixed additive for those surface types rather than assuming the API's temperature reflects what's happening at ground level on an asphalt-heavy block.

On the infrastructure side: if you're running this scoring server-side across more than a handful of locations, cache the raw API response at the model update frequency — roughly hourly for HRRR-sourced data in the US, roughly every six hours elsewhere. Per-user uncached requests are the fastest way to burn through quota for no benefit.

One last thing: the score gives you a number, not a policy. Whether that number means "safe," "show a warning," or "block booking" depends entirely on what your users are doing outside. The right threshold for road construction workers isn't the right threshold for a casual 5k. Build those cutoffs as configuration, not constants — the use case should drive them, not the scoring function.

Scroll to Top