Why vis_km Collapses at Night: Visibility Fields, Sensor Limits, and Building Fog Detection That Actually Works After Dark

The vis_km field has a daytime bias nobody documents

Pull vis_km from a current conditions response at 2am and you’ll often get a reading that looks plausible — 8km, 10km, maybe 16km — even when ground-level fog is sitting at 50 meters. This isn’t a data pipeline bug. It’s a consequence of how visibility is actually measured and reported, and it trips up more integrations than people admit.

Automated surface observation systems (ASOS/AWOS in the US, equivalents elsewhere) measure visibility using forward scatter sensors: they fire a beam of light and measure how much gets scattered back by particles in the nearby air column. That works reasonably well when the sensor is calibrated and sited correctly. The catch is that these sensors sample a small volume of air immediately around the station. A radiation fog event — the classic still-night-cooling type — can be patchy enough that the station sensor reads 8km while a valley 2km away is completely socked in. METAR visibility is a point measurement, not an area one.

It gets worse at night because human observers — where they still exist — report visibility differently after dark. During daylight, observers judge visibility by identifying landmarks at known distances. At night, they switch to estimating by light sources: runway approach lights, distant streetlights. Illuminated targets at distance are easier to see through thin fog than unlit landmarks, so nighttime human-reported visibility tends to run slightly optimistic compared to equivalent daytime conditions. ICAO Annex 3 and WMO No. 49 both acknowledge this asymmetry in their observer guidance. The numbers aren’t wrong exactly — they’re measuring something subtly different.

What the API actually gives you

WeatherAPI’s vis_km in current conditions flows from the METAR observation for the selected station — whatever the METAR visibility group reports, converted from statute miles, is what surfaces. If the station reports M1/4SM (less than a quarter mile), the field floors out and reflects it. But if the METAR reports 10SM, the standard ceiling for “visibility not a limiting factor,” you get a number that is genuinely uninformative. It means “we didn’t observe restricted visibility,” not that visibility is exactly 16km.

The 10SM/16km value is a sentinel, not a measurement. A METAR 10SM could mean crystal-clear air or a wide band of thin haze. Treating it as a precise reading in application logic is a mistake. Anything at or above roughly 14km in vis_km should be read as “visibility unrestricted” — not as a number worth doing arithmetic on.

Why fog detection fails if you rely on vis_km alone

The most common implementation I see is something like: if vis_km < 1, flag as fog. Clean, obvious, and wrong about half the time in practice.

Fog forms before visibility collapses. By the time a METAR station reports sub-1km visibility, the fog has typically been developing for 30 to 90 minutes. If your application is issuing warnings — for logistics routing, flight planning, or early-morning construction scheduling — you’re already behind. The METAR catches the result, not the onset.

A better approach combines three fields available in WeatherAPI’s hourly forecast and current response:

  • Dew point spread (temp_c minus dewpoint_c): When this drops below about 2°C, the air is close to saturation. Below 1°C, radiation fog is likely if winds are calm. This is the leading indicator.
  • Wind speed (wind_kph): Radiation fog needs calm conditions — roughly under 8–10 km/h — to form and persist. Turbulent mixing destroys it. If wind_kph is above 15, even near-saturated air rarely produces surface fog.
  • Condition code: Code 248 (freezing fog) and codes 143/248 (mist/fog family) in WeatherAPI’s scheme confirm the observation side. Use these as confirmation, not as triggers — they lag the onset.

Putting it together: flag elevated fog risk when dew point spread is under 2°C and wind_kph is under 10 and it’s between roughly 22:00 and 09:00 local time. Then use vis_km and condition codes as confirmation. That combination catches likely fog events earlier than vis_km alone — the lag on vis_km-only detection is real enough that it affects operational decisions.

The night-hours window matters more than people expect

Radiation fog peaks around local sunrise, not at midnight. The ground cools steadily through the night, and by 05:00–07:00 local time you’ve hit peak cooling — that’s when dew point spread collapses fastest. Any fog detection logic running on a fixed UTC schedule without local-time awareness will miss this window consistently for users outside UTC.

This is one of the practical reasons we expose both epoch timestamps and local-time strings in responses. A UTC-only integration is actively working against you for time-of-night logic like this.

A minimal implementation sketch

Using the /current.json and /forecast.json endpoints, you’d want something like this in pseudocode:

current = weatherapi.get_current(location)
hourly = weatherapi.get_forecast(location, days=1).forecastday[0].hour

def fog_risk(obs, hour_data):
    spread = obs.temp_c - obs.dewpoint_c
    wind = obs.wind_kph
    local_hour = parse_local_hour(obs.last_updated_epoch, obs.tz_id)
    night_window = local_hour >= 22 or local_hour <= 9

    early_warning = spread < 2.0 and wind < 10 and night_window
    confirmed = obs.vis_km < 2.0 or obs.condition_code in FOG_CODES

    return {"risk": early_warning, "confirmed": confirmed}

The FOG_CODES set should include WeatherAPI condition codes for mist (143), fog (248), and freezing fog (260). Those map to specific METAR present-weather groups in our internal METAR-to-condition-code mapping — they’re not arbitrary numbers, they reflect actual observed conditions from the station network.

Station distance compounds all of this

There’s another layer that doesn’t show up in the response payload: how far the underlying METAR station is from the location you actually queried. A request for coordinates in a valley might be served by a station on flatter terrain several kilometers away. Fog can exist in one location and not the other simultaneously — that’s not a bug in the data, it’s just what patchy radiation fog does. Our station selection logic weights for representativeness (elevation difference, reading consistency) but there’s no scoring system that bridges a real gap in station density.

If your use case is genuinely sensitive to fog — agricultural frost risk, drone corridor management, early-morning logistics — you probably want to layer in whatever ground truth you have locally: a road sensor feed, a webcam pipeline, your own anemometer. API data is a prior, not ground truth, and visibility is where that distinction matters most.

One thing worth doing before you ship

Filter out the 10SM sentinel before you run any statistics or thresholds on vis_km. Anything reporting at or above roughly 14–16km is almost certainly an observation shorthand for “no restriction noted,” not a real measurement — and leaving it in will quietly skew averages and break threshold logic. It’s the single change that most improves fog detection in integrations I’ve looked at, and it’s almost never done by default.

Scroll to Top