How to Detect Fog Programmatically: Using Visibility, Dew Point Spread, and Condition Codes Together

Fog is one of those conditions that sounds simple to detect from an API response and turns out to be genuinely annoying in practice. A single field check — vis_km < 1, say — will miss more cases than it catches, and checking condition codes alone will misfire in drizzle, smoke, and heavy rain. The reliable approach uses three fields together, and understanding why each one fails alone is the only way to get the combination right.

Why Each Field Fails on Its Own

Visibility alone is ambiguous. Low visibility can mean fog, but it can also mean heavy precipitation, blowing snow, smoke, or dust. A visibility reading of 0.4 km during a thunderstorm cell is not fog. If you’re routing trucks or flagging airport ground conditions, treating those two situations identically will cause problems. The visibility field tells you that something is reducing horizontal range — not what.

Condition codes alone are coarse. WeatherAPI’s numeric condition codes are mapped from METAR weather phenomena observations — a pipeline we handle ourselves rather than relying on a third-party translation layer, which means we’ve seen exactly where the ambiguity lives. Codes for fog (248 and 260 in our set) come from the METAR FG and BCFG identifiers in the raw observation. But METAR fog reporting is time-lagged: a station may not have updated its observation in 40 minutes, and patchy radiation fog that forms and dissipates in 20-minute cycles at 03:00 will often not appear in the condition code at all.

Dew point spread alone is a forecast signal, not a detection signal. A small gap between temperature and dew point — typically 2°C or less — means the air is close to saturation, which is a necessary condition for fog. But it’s not sufficient. Saturated air over a moving airmass with decent wind doesn’t produce fog; it produces low stratus. And the dew point data in a forecast comes from model output (GFS, NAM, HRRR depending on location and horizon), which smooths out the local surface inversions that actually drive radiation fog formation.

The Three-Signal Approach

Combine all three, and the false-positive rate drops substantially. Here’s the logic in plain terms before the code:

  • Visibility below a threshold — 1 km is the standard aviation VMC/IMC boundary; 0.2 km is the dense fog advisory threshold NWS uses for surface warnings
  • Dew point spread ≤ 2°C (temperature minus dew point, using temp_c and dewpoint_c from the hourly block)
  • Condition code in the fog family — or, as a fallback, condition code NOT in the precipitation family

That last point matters. If visibility is low and the spread is tight but the condition code is 1189 (moderate rain), you’re in rain, not fog. Excluding known-precipitation codes is often more reliable than requiring a positive fog code match, because fog codes depend on METAR stations having reported it, which they may not have yet.

Implementation: Python

This example uses the /forecast.json endpoint with aqi=no&alerts=no and pulls the hourly block. It assumes you’re working with current or near-current hours — fog detection from a 5-day forecast is a different problem (you’d flip to model-derived relative humidity and abandon the condition code signal entirely).

import requests

API_KEY = "your_key_here"
LOCATION = "Glasgow"

# Condition codes that indicate precipitation rather than obscuration
PRECIP_CODES = {
    1063, 1069, 1072, 1150, 1153, 1168, 1171,
    1180, 1183, 1186, 1189, 1192, 1195, 1198,
    1201, 1204, 1207, 1210, 1213, 1216, 1219,
    1222, 1225, 1237, 1240, 1243, 1246, 1249,
    1252, 1255, 1258, 1261, 1264, 1273, 1276,
    1279, 1282
}

# WeatherAPI condition codes that specifically indicate fog
FOG_CODES = {248, 260}

def is_fog(hour: dict) -> bool:
    vis_km = hour.get("vis_km", 999)
    temp_c = hour.get("temp_c", 20)
    dewpoint_c = hour.get("dewpoint_c", 0)
    condition_code = hour["condition"]["code"]

    spread = temp_c - dewpoint_c
    low_vis = vis_km < 1.0
    saturated = spread <= 2.0
    not_precip = condition_code not in PRECIP_CODES
    positive_fog_signal = condition_code in FOG_CODES

    # Positive fog code match is strongest signal
    if positive_fog_signal and low_vis:
        return True

    # Fallback: low vis + saturated air + not a precip event
    if low_vis and saturated and not_precip:
        return True

    return False

response = requests.get(
    "https://api.weatherapi.com/v1/forecast.json",
    params={
        "key": API_KEY,
        "q": LOCATION,
        "days": 1,
        "aqi": "no",
        "alerts": "no"
    }
)
data = response.json()

for hour in data["forecast"]["forecastday"][0]["hour"]:
    if is_fog(hour):
        print(f"{hour['time']} — fog detected (vis={hour['vis_km']}km, "
              f"spread={round(hour['temp_c'] - hour['dewpoint_c'], 1)}°C, "
              f"code={hour['condition']['code']})")

Two things worth flagging before you run this: dewpoint_c is available in the hourly forecast block but is not in the current conditions block by default — you need the forecast endpoint, not /current.json. And vis_km in the hourly forecast is a modeled estimate, not a METAR observation, so treat it as a coarse signal rather than a precise measurement.

Where This Still Goes Wrong

Radiation fog — the kind that forms overnight in valleys on calm, clear nights — is the hardest case. It forms fast, it’s patchy, and it dissipates within an hour of sunrise. Model output at even 3km grid resolution (HRRR’s finest) struggles to resolve valley-scale fog pockets, so the modeled visibility and dew point fields will often show clear conditions while the actual road surface is at 50m visibility. For that use case, the condition code from a nearby METAR station is actually your best signal, because surface observers do catch radiation fog once it’s established — but there’s an inherent time lag you can’t engineer around.

Smoke is the other common false-positive trigger. Wildfire smoke can push visibility below 1 km with a near-saturated dew point spread when the smoke is dense and damp. The condition code should differentiate — smoke has its own METAR identifier, FU — but station coverage in rural fire-prone areas is thin enough that the code sometimes defaults to a generic low-visibility reading rather than a specific one.

The most practical mitigation for both edge cases is a wind speed gate. Radiation fog almost never forms with surface winds above 5 km/h, so if wind_kph is elevated, a positive fog detection should be downgraded to uncertain. Smoke is harder to screen for programmatically without crossing into a fire/smoke layer API, at which point a simple heuristic starts to fall apart anyway.

Threshold Choices Worth Questioning

The 1 km visibility threshold is standard for aviation IMC and common in surface transportation alerts, but your application might want something different. NWS dense fog advisories use 0.4 km (400m) as the criterion. If you’re building something for road safety alerts rather than general weather awareness, the 0.4 km threshold with a positive fog code requirement is more defensible than the looser 1 km fallback logic.

I’d also push back on treating dew point spread as a soft signal. Running this logic across a range of climates, the spread check is often the most reliable discriminator between fog and light precipitation — precipitation tends to push the spread to zero or negative through evaporative cooling, while fog tends to sit at exactly 1–2°C. It’s not a rule, but if you’re calibrating thresholds, it’s worth logging the spread alongside your detections and checking whether tightening to ≤1.5°C reduces false positives in the specific geography your users are in.

If you’re already storing API responses, add vis_km, dewpoint_c, and condition.code to your observation log. Even a week of data from a location that gets regular fog will tell you whether your threshold is too loose — and that’s a faster feedback loop than any amount of upfront tuning.

Scroll to Top