Every weather app happily tells me it will be 14°C and cloudy at 3pm. Not one of them answers the thing I actually care about: when should I go for a walk today?

That question is why I built Walk When. It takes the weather forecast, gives every hour of the day a score from 0 to 100 for how pleasant it would be outside, and surfaces the best three to five windows. You don’t need to do the mental math yourself. The app does it for you.

Here is the app’s scoring engine, running live in your browser. It’s the same code that ships on your phone, rewritten in JavaScript.

your settings
72
Good

Take it to the extremes. Push rain past 50%, drop the temperature to freezing, drag your favorite temperature around and watch everything shift. The rest of this post is an explanation of what each slider does.


Each factor gets its own curve

A walk score has to combine things that don’t behave alike. Temperature has a sweet spot in the middle. Rain only ever makes things worse. Wind bothers you a little at first and then a lot. So each input gets scored on its own 0-to-100 curve first, and once they all speak the same language, it’s easy to blend them together.

Temperature: a bell curve around what you like

Temperature does most of the work because it’s the thing you feel most. It’s scored as a bell curve centered on your favorite temperature, falling away as it gets colder or hotter.

let temp = hour.apparentTemperature      // feels-like, never the raw number
let fav  = settings.favTempC

let sigma = temp < fav ? 9.0 : 7.0
let tempScore = max(0, 100 * exp(-0.5 * pow((temp - fav) / sigma, 2)))

Two things worth pointing out.

The input is apparentTemperature, the feels-like number, not the thermometer reading. 8°C in still sunshine and 8°C in a cutting wind are two different walks, and feels-like already folds in wind chill and humidity. The plain temperature shows up on screen, but it never touches a score.

And the curve isn’t the same shape on both sides. It falls off gently when it’s colder than your favorite and more sharply when it’s warmer (that’s the sigma value in the code, 9 versus 7). There’s a real reason for that: you can put on a jacket against the cold, but there’s no fix for 32°C, so being too hot should cost you faster than being too cold. Slide the feels-like control past your favorite in both directions and you can watch the warm side drop off quicker.

Rain: a penalty that only grows

let precipScore = max(0, 100 * pow(1 - hour.precipitationProbability / 60.0, 2))

Zero rain scores 100, and the score falls as the chance climbs. It drops slowly at first and then faster, reaching zero by around 60%. So 20% rain barely registers, but 45% takes a real hit. Past 60% the raw math actually ticks back upward, but you never see it: anything over a 50% chance is blocked outright, so the graph just holds flat at zero. Rain ruins more walks than anything else. It’s our most dramatic factor.

Wind: scored on the gusts

let windScore = min(100, max(0, 100 / (1 + exp(0.08 * (hour.windGusts - 60)))))

This scores the gust, not the steady wind speed. A calm breeze is easy to ignore, but hard gusts push you around, and that’s the part of the wind you actually deal with on a walk. So low gusts cost almost nothing, and the score only really drops once they get strong. It’s halfway down by about 60 km/h and near zero by gale force. Really strong gusts get their own, much harsher rule further down.

Cloud: as plain as it looks

let cloudScore = max(0, 100 - cloud)

Clear sky is 100, full overcast is 0, straight line between. It’s a mild preference that only carries real weight when you specifically ask for “Sunniest.”

Here is what the temperature, rain, wind, and cloud curves actually look like:

Temperature
Rain
Wind
Cloud

Priorities: same curves, different weights

Now the personal part. Those factor scores don’t all count equally, and you get to decide how they count. Each priority is basically a recipe: how much each factor counts, adding up to 100%:

func weights(for priority: WeatherPriority) -> [ScoreFactor: Double] {
    switch priority {
    case .balanced: return [.temperature: 0.50, .rain: 0.40, .wind: 0.10]
    case .sunniest: return [.cloudCover: 0.50, .temperature: 0.30, .rain: 0.20]
    case .warmest:  return [.warmth: 0.70, .rain: 0.20, .wind: 0.10]
    case .coolest:  return [.coolness: 0.70, .rain: 0.20, .wind: 0.10]
    case .driest:   return [.temperature: 0.20, .rain: 0.70, .wind: 0.10]
    case .calmest:  return [.temperature: 0.20, .rain: 0.30, .wind: 0.50]
    }
}

“Balanced” leans on temperature and rain, with a little wind. “Driest” pushes rain up to 70% of the decision. “Calmest” hands wind half the vote. To get the final score, each factor’s score is multiplied by how much it counts, and those are added up:

let rawScore = contributions.reduce(0) { $0 + $1.weightedScore }
// weightedScore == rawScore * weight, per factor

That is exactly the breakdown in the lab: each factor gets its own little curve with a dot marking where your current conditions land on it, and the combined total sits at the bottom. Flip between the priority chips and watch the set of curves change as different factors take over. Drag your favorite temperature and the temperature curve slides with it.

“Warmest” and “Coolest” work differently

Notice that Warmest and Coolest don’t use the temperature bell curve at all. Here’s the problem with it. The bell curve gives its best scores when the temperature is close to your favorite, and it drops the score by the same amount whether you’re a few degrees too cold or a few degrees too warm. That’s perfect most of the time. But when you ask for the warmest hour, being close to your favorite isn’t the point anymore. You want it as warm as possible, and a curve that punishes “too warm” exactly like “too cold” has no way to say “warmer is better.”

So Warmest and Coolest use a curve that leans one way instead of peaking in the middle. It’s called a sigmoid, an S-shaped curve:

let comfortOffset = 3.0
let warmthCenter = fav - comfortOffset
let coolnessCenter = fav + comfortOffset
let warmthScore = min(100, max(0, 100 / (1 + exp(-0.2 * (temp - warmthCenter)))))
let coolnessScore = min(100, max(0, 100 / (1 + exp(0.2 * (temp - coolnessCenter)))))
Warmth & Coolness sigmoids
warmth & coolness

Warmth (the rising curve) starts low when it’s cold and climbs as the temperature goes up, leveling off near 100 once it’s plenty warm. Coolness does the reverse. The two lines cross right around your favorite temperature. That 0.2 in the code just sets how gradual the climb is: here it spreads over about ten degrees instead of flipping all at once.

The last piece is comfortOffset. It shifts each curve so that landing exactly on your favorite temperature scores about 65, not a full 100. On a warmest day, that means your usual favorite is still a good walk, just not the top pick, and anything a few degrees warmer will beat it.


Some things aren’t a matter of degree

Blending scores together has a weakness. Enough decent-but-unremarkable factors can outvote one real dealbreaker. A score of 78 tells you nothing if the missing 22 points are all “it’s about to pour.”

So a few conditions skip the average entirely and force the hour to Not recommended, no matter how lovely everything else looks:

if effectivelyNight
    || effectivelyHazardousAir
    || hour.windGusts >= 90
    || hour.precipitationProbability > 50
    || finalScore < minScore {
    status = .notRecommended
}

Gale-force gusts of 90 km/h or more (Environment Canada’s wind-warning line), rain chance over 50%, nighttime, or hazardous air. A nice temperature can’t overcome any of these. Below that, the gust curve already pulls the score down as the wind builds, so the hard block only kicks in when the wind gets dangerous. In the lab, set gusts to 90 with everything else perfect and watch a 95 go grey. I would rather have the app say “not today” once in a while than recommend a walk you bail on 10 minutes in.

The last line, finalScore < minScore, is the Pickiness slider: your own baseline. Turn it up and only great windows reach you. Turn it down and it will offer the merely-fine ones too.

One extra bar for the top

Earning the Optimal label takes more than a big number:

} else if finalScore >= 80 && hour.precipitationProbability <= 20 {
    status = .optimal
} else if finalScore >= 65 {
    status = .good
} else {
    status = .acceptable
}

You need 80 or better and rain at 20% or less. A lovely 88 with a 30% rain chance stops at Good, because Optimal is a promise, and I won’t make that promise under a sky that might open up. Get the score into the 80s in the lab, then walk rain from 15% up to 25% and watch Optimal drop to Good while the number itself sits still.


Two things the lab skips

The widget above is the core engine. The shipping app layers two more real-world inputs on top:

  • Air quality and pollen. Air quality is a health thing, not a preference, so it works as a straight penalty tied to the standard US air-quality levels: a small ding when it’s moderate, a hard veto when it turns hazardous. Pollen works the same way for anyone who opts in. You wouldn’t want “nice weather” to outvote either of them.
  • Golden hour. If you love a sunset walk, twilight becomes a bonus instead of a penalty. The same closeness to sunset that normally counts against an hour counts for it instead, depending on one setting.

I left both out of the lab to keep it small and the curves easy to read. The ones you’re dragging are the real ones.


What makes a good score

Most of the work in a good score is unglamorous. It comes down to a handful of small choices:

  • Give each factor the curve its shape deserves. A bell for temperature. A cliff for rain. A gentle S-curve for wind gusts.
  • Let people weight what they care about, instead of pretending there’s a single right answer.
  • Keep the hard vetoes outside the average, where a real dealbreaker can’t get outvoted.
  • Make it predictable, so the same forecast always produces the same answer on every device

There’s no advanced math in any of it. The hard part is not hand-waving the things that are easy to hand-wave. Scroll back up and poke at the lab one more time. Now you know what every slider is doing.