DEV Community

Stalefish Labs
Stalefish Labs

Posted on • Originally published at stalefishlabs.com

Turning Raw Weather Into a Drying Model

In the first article about my foray into using weather data to assess surface conditions for riding activities, I introduced the Groundwise engine, a software decision system that turns weather data into a yes/maybe/no verdict for outdoor conditions. This article goes deeper into the core moisture model: how the engine takes raw weather observations and calculates a wetness score that drives the verdict. The hope is to show how a simple question such as "can I ride my mountain bike today?" spiraled into a fairly complex yet intriguing design challenge. Totally fair if you aren't entranced by weather math, I get it, but I feel like there's some value in pulling back the curtain to reveal how much goes into solving a seemingly simple problem.

The Groundwise model is relatively restrainted in scope, it isn't trying to simulate soil physics. It's trying to approximate what an experienced local would know instinctively — "it rained pretty hard yesterday, but it's been warm and windy all morning, so the trails are probably fine." The goal is to encode that intuition into something repeatable and surface-aware.

Weighted Precipitation: Not All Rain Is Equal

The engine tracks precipitation across three time windows: the last 24 hours, 24-48 hours ago, and 48-72 hours ago. But it doesn't treat them equally. Here's how they are weighted differently by the weather precipitation equation:

weatherPrecip = rain24h × 0.7 + (rain48h - rain24h) × 0.2 + (rain72h - rain48h) × 0.1
Enter fullscreen mode Exit fullscreen mode

Recent rain dominates. An inch in the last 24 hours contributes seven times more to the score than an inch from two days ago. This matches real-world observation, where rain from three days ago has had significant time to drain and evaporate, while yesterday's rain is still actively affecting conditions.

The subtraction matters too. rain48h is a cumulative total (it includes the last 24 hours), so the formula isolates each window's unique contribution. A half an inch total over 48 hours where all of it fell in the last 24 tells a very different story than half an inch spread evenly.

Rain Score Normalization

A recent rain results in a raw precipitation value that gets normalized to a 0-1 scale, so effectively a percentage:

rainScore = min(1.0, totalPrecip / 0.75)
Enter fullscreen mode Exit fullscreen mode

Three-quarters of an inch saturates the score (maximum value of 1, or 100%). Beyond that, more rain doesn't make things more wet from the engine's perspective — you're already at maximum precipitation/saturation, which matches reality in that a trail, yard, or any other outdoor surface has a limit to how wet it can get. This prevents a 3-inch deluge from producing a wildly different result than a 1-inch soaking. Both are "very wet" — the distinction that matters is how long ago it happened and how fast things are drying.

Why Intensity Matters

The engine tracks rain pattern: light, steady, or downpour. It then applies a multiplier to the base wetness given one of those patterns:

Pattern Multiplier Why
Light 0.7x Gentle rain soaks in gradually, less runoff, less surface pooling
Steady 1.1x Sustained saturation, ground can't keep up
Downpour 1.5x Overwhelms drainage, causes pooling and surface flooding

A quarter inch of light rain over several hours is genuinely different from a quarter inch dumped in 20 minutes. The light rain absorbs more evenly. The downpour creates surface water, overwhelms drainage on absorbent surfaces, and can cause erosion on trails.

Drying Strength: The Recovery Side

Wetness is only half the picture. The other half is how aggressively conditions are drying things out. The engine calculates a composite drying strength from four weather factors:

drying = (tempFactor × 0.30) + (windFactor × 0.30) + (humidityFactor × 0.25) + (skyFactor × 0.15)
Enter fullscreen mode Exit fullscreen mode

Temperature (30% weight): Warmer air holds more moisture and drives evaporation. The factor scales linearly from 0 at 50°F to 1.0 at 90°F. Below 50°F, evaporation slows dramatically. Above 90°F, you're drying as fast as conditions allow.

tempFactor = clamp((tempF - 50) / 40, 0, 1)
Enter fullscreen mode Exit fullscreen mode

Wind (30% weight): Moving air carries moisture away from surfaces. Scales linearly up to 20 mph, where the effect plateaus - going from 20 to 40 mph doesn't double the drying rate.

windFactor = clamp(sustainedWindMph / 20, 0, 1)
Enter fullscreen mode Exit fullscreen mode

Humidity (25% weight): Dry air absorbs moisture more readily. This is the inverse, where low humidity means strong drying.

humidityFactor = clamp(1.0 - effectiveHumidity, 0, 1)
Enter fullscreen mode Exit fullscreen mode

When the weather API provides a dew point instead of relative humidity, the engine converts it using the Magnus formula. Dew point is actually the more reliable measurement — relative humidity fluctuates throughout the day even when actual moisture content stays constant.

Sky cover (15% weight): Clear skies mean solar radiation hitting surfaces directly, which drives evaporation. Clouds block that. This gets the lowest weight because its effect is real but smaller than the others.

skyFactor = clamp(1.0 - cloudCover, 0, 1)
Enter fullscreen mode Exit fullscreen mode

Exposure Matters

All of this gets modified by where the surface actually sits. A trail under heavy tree canopy dries differently than an open field fully exposed to sunlight:

Exposure Rain Multiplier Drying Multiplier
Exposed 1.0x 1.0x
Shaded 0.7x 0.6x
Covered 0.0x 0.8x

Shaded spots get less rain (canopy intercepts 30%) but also dry 40% slower (less sun, less wind). Covered spots (under a roof or overhang) get no rain at all but still have air circulation for moderate drying.

This creates an interesting dynamic: a shaded trail might actually be drier than an exposed one after light rain (less water reached it) but wetter after heavy rain (the rain that got through takes longer to leave).

Drying Classification

The composite score maps to three tiers, each returning a fixed drying effectiveness value:

Score Range Classification Effectiveness
≥ 0.7 Strong 0.8
0.4 – 0.7 Moderate 0.5
< 0.4 Weak 0.2

I deliberately chose discrete tiers over a continuous curve. The difference between a 0.41 and a 0.69 drying score isn't meaningful in practice — both represent "conditions are helping somewhat." The tiers prevent false precision while still capturing the real distinction between "sunny and breezy" (strong), "overcast and mild" (moderate), and "cold, humid, and still" (weak).

Combining It All: The Wetness Score

I warned you this problem has more nuance that it would seem. But hang in there, we're getting closer to arriving at some meaningful mathematical conclusions. For example, the wetness score blends precipitation and drying into a single 0-1 value:

baseWetness = rainScore × 0.5 + (rainScore × timingScore) × 0.6
Enter fullscreen mode Exit fullscreen mode

This formula does something subtle. The first term (rainScore × 0.5) ensures that heavy rain always contributes some wetness regardless of timing. The second term (rainScore × timingScore) captures the interaction — recent heavy rain is much worse than old heavy rain. The timing score decays linearly from 1.0 (just stopped) to 0.0 (24+ hours ago).

After the base calculation, the rain pattern multiplier is applied (0.7x for light, 1.1x for steady, 1.5x for downpour), then drying reduces it:

dryingEffect = dryingScore × 0.3 × dryingEffectiveness
wetness = baseWetness × (1.0 - dryingEffect)
Enter fullscreen mode Exit fullscreen mode

The dryingEffectiveness factor prevents drying from being too aggressive when rain just ended. If rain stopped 30 minutes ago, even strong drying conditions haven't had time to do much yet. The factor scales up as time passes, so drying's impact grows the longer it's been since rain.

A Worked Example

To put all these equations into perspective, let's trace through a real scenario to arrive at a Groundwise verdict: 0.4 inches of steady rain ended 8 hours ago. It's 72°F, 12 mph wind, 45% humidity, 20% cloud cover. Exposed dirt trail.

Rain score:

weatherPrecip = 0.4 × 0.7 = 0.28  (all in last 24h)
rainScore = min(1.0, 0.28 / 0.75) = 0.37
Enter fullscreen mode Exit fullscreen mode

Timing score:

hours = 480 min / 60 = 8
timingScore = max(0, 1.0 - 8/24) = 0.67
Enter fullscreen mode Exit fullscreen mode

Drying strength:

temp = (72-50)/40 × 0.30 = 0.165
wind = 12/20 × 0.30 = 0.18
humidity = 0.55 × 0.25 = 0.1375
sky = 0.80 × 0.15 = 0.12
total = 0.60 → Moderate (effectiveness = 0.5)
Enter fullscreen mode Exit fullscreen mode

Wetness:

base = 0.37 × 0.5 + (0.37 × 0.67) × 0.6 = 0.185 + 0.149 = 0.334
× steady pattern (1.1) = 0.367
dryingEffectiveness = max(0.2, 1.0 - 0.67 × 0.5) = 0.665
dryingEffect = 0.5 × 0.3 × 0.665 = 0.10
wetness = 0.367 × (1.0 - 0.10) = 0.33
Enter fullscreen mode Exit fullscreen mode

Verdict: Maybe (0.33 is just above the 0.3 threshold). The trail is borderline — rideable but still damp. On a dirt trail (damage sensitivity 0.5), the sensitivity veto wouldn't trigger. A reasonable call either way, and the engine goes with Maybe, giving the rider the opportunity to then weigh locale-specific details such as trail sensitivity to damage.

Now change one variable — make it 85°F instead of 72°F:

temp = (85-50)/40 × 0.30 = 0.2625
total drying = 0.70 → Strong (effectiveness = 0.8)
dryingEffect = 0.8 × 0.3 × 0.665 = 0.16
wetness = 0.367 × (1.0 - 0.16) = 0.308
Enter fullscreen mode Exit fullscreen mode

Still Maybe, but barely. A bit more wind or another hour of drying and it flips to Yes. That matches intuition — a hot afternoon pulls moisture out fast.

Residual Wetness: The Ground Remembers

The timing-based model handles most scenarios well, but it has a blind spot: ground saturation after heavy rain on absorbent surfaces.

If it rained an inch on Monday and you're checking the trail on Wednesday, the timing score says "it's been 48 hours, moisture contribution is minimal." But anyone who's walked a clay trail two days after heavy rain knows that's wrong. The ground is still holding water.

The engine adds a residual wetness component for absorbent surfaces (dirt, grass, clay, amended soil) when precipitation exceeded 0.25 inches:

volumeFactor = clamp((rain - 0.25) / 0.75, 0, 1)
decayFactor = based on hours since rain and drying strength
surfaceRetention = inverse of surface drying multiplier
dryingReduction = strength-based reduction

residualWetness = 0.55 × volumeFactor × decayFactor × surfaceRetention × (1.0 - dryingReduction)
Enter fullscreen mode Exit fullscreen mode

The peak residual value of ~0.55 deliberately sits in the middle of the Maybe range. It's not enough to trigger a hard No, but it keeps the engine from prematurely saying Yes on saturated ground.

Clay surfaces (drying multiplier 0.7x) retain the most residual moisture. Concrete and metal (2.0x+) don't get residual wetness at all, they're draining surfaces that hold little (concrete) to no (metal) water.

What This Model Doesn't Do

A few things I intentionally left out:

Soil type modeling. Real evapotranspiration models account for soil composition, drainage rates, water table depth. The engine uses surface type as a proxy instead. A dirt trail in sandy Arizona soil dries differently than one in Georgia red clay, and the engine can't distinguish them. The tradeoff is simplicity — asking users to classify their soil composition seemed like a bridge too far. If you disagree, let us know, but I decided to err on the side of simplicity at least in terms of user inputs.

Microclimate effects. Two spots a mile apart can have meaningfully different conditions. The engine uses a single weather observation for each saved location, which might come from a station several miles away. Elevation, valley effects, and urban heat islands aren't modeled.

Drainage infrastructure. A well-designed trail with proper drainage handles rain better than a flat trail in a depression. An athletic field with subsurface drainage is ready for play sooner than one without. The engine doesn't know about this. Surface type captures some of it (artificial turf implies drainage engineering), but it's imperfect.

These are all real limitations, and I'd rather be transparent about them than pretend the model is more precise than it is. The engine targets "better than guessing" — not "better than walking over and checking yourself."

Next in the Series

The next article covers how this engine is packaged as a shared Swift framework consumed by three different apps — each with its own surface library, threshold calibration, and verdict interpretation. Same core math, three different products. It's a lesson in code reuse, and the benefits of solving a problem once in just a general enough way to apply that solution multiple times.


The drying model powers Ridewise, Fieldwise, and Yardwise — all coming soon for iOS from Stalefish Labs.

Top comments (0)