DroneInfo
Engineering notes

Seven radar networks, three kinds of nothing

30 July 2026 · 8 min read

Most consumer drones should not be flown in rain unless the manufacturer explicitly permits it. Answering "is it raining at this coordinate, right now" for a pilot anywhere in the world took seven adapters across eight countries, four file formats and two command-line binaries in the runtime image.

The formats turned out to be the easy part. You read a spec, install a decoder, write a test with a fixture, and it is done and stays done. The part that shaped the API is smaller and much easier to get wrong: every one of these networks can report an absence, an absence means three different things, and flattening them into a zero tells a pilot the sky is clear when nothing looked at it.

Radar is national infrastructure. Each country publishes its composite in whatever format suits its own agency, and we could not find a global point-query radar API whose pricing, licence, resolution and reliability fit the product. Commercial global aggregators do exist. So: seven adapters, and one lesson repeated seven times.

What follows is four traps these formats set for any decoder, the return type that determines whether you can even describe the problem, and the judgement calls that are ours rather than the agencies'.

Three kinds of nothing

Every one of these networks can report an absence, and an absence means three different things.

Undetect. A radar looked at this cell and there is no precipitation in it. That is a measurement, worth as much as any other. It becomes 0 mm/h.

Nodata. No radar looked at this cell. It is masked, beam-blocked by terrain, or the contributing station is offline. Nothing is known. It becomes null.

Out of coverage. There is no radar over this point at all. This network cannot answer and the right move is to ask the next one.

Collapsing the three into 0 is the characteristic bug of this software category, and it fails in the dangerous direction: 0 mm/h renders as clear skies. A masked cell behind a mountain and a genuinely dry sky produce the same byte in a naive pipeline, and the pilot sees "no rain" either way.

Nothing unifies how they are spelled. Not even a shared standard: two of our sources both publish ODIM HDF5, both use the words nodata and undetect with identical meanings, and still encode them differently because one packs floats and the other packs unsigned bytes.

NetworkNo rain (measured)Nobody lookedNo radar here
MET Norwayrate 0 in JSONradar_coverage markerHTTP 422
NOAA MRMScell value 0-1 Missing-3 No Coverage, or out-of-grid
UK Met Officeundetect = 0nodata = -1decoder prints nothing, still exits 0
DWD via Bright Skygrid cell 0not distinguishedHTTP 404

MET Norway is the only one that answers all three explicitly, and it is worth dwelling on how it does it, because the two mechanisms are not interchangeable. Measured live against nowcast/2.0/complete on 30 July 2026:

PointResponse
Copenhagenradar_coverage: "ok"
Helsinkiradar_coverage: "ok"
Aarhusradar_coverage: "no coverage"
BerlinHTTP 422, outside the geographic area supported

So 422 means the point is outside the product's domain entirely, while radar_coverage discriminates inside it. Aarhus is in Denmark, inside the domain, and has no radar over it; Copenhagen is in Denmark and does. A bounding box would get both wrong.

The consequence for the API is that a single number is not a sufficient answer. The response says which source answered and what kind of answer it is:

GET /v1/weather/point (abridged)json
{
  "nowcastSource": "metoffice-radar",
  "radarCoverage": "ok",
  "radarStatus":   "available"
}

radarStatus has three values and none of them is decorative. available means a national radar answered. out-of-coverage means no radar covers the point, which is permanent and expected. temporarily-unavailable means a radar plausibly covers it but produced nothing right now, which means ask again shortly. In the last two cases a model nowcast may still be supplied, labelled model-fallback rather than passed off as an observation.

Trap 1: the sentinel that reads as clear skies

NOAA's MRMS PrecipRate is the best free radar product of the seven: roughly 150 NEXRAD stations fused with satellite and model data into a quality-controlled instantaneous rate on a 1 km grid, published to a public S3 bucket every two minutes, no auth and no key.

Its values are mm/hr, and two of them are not measurements. NOAA NSSL's operational GRIB2 table defines Missing = -1 and No Coverage = -3. Neither means "no precipitation", and they do not mean the same thing as each other: -3 puts the point outside the domain, -1 leaves it inside with a masked cell.

The obvious way to handle a negative is to clamp it, and it is the one thing you must not do. A decoder that writes Math.max(0, value)reports a beam-blocked cell over a mountain as 0 mm/h, and 0 mm/h renders to a pilot as a clear sky. The sentinels have to survive as distinct states all the way up:

providers/mrms/grib-extract.tsjavascript
if (value === NO_COVERAGE_SENTINEL) return OUT_OF_GRID;             // -3
if (value === MISSING_SENTINEL || value < 0) return NO_MEASUREMENT; // -1
return gridValue(value);                          // 0 is a real measurement

Note the third line. A measured zero is as real as a measured 12 mm/h and must be preserved; it is only the sentinels that are unknowns. And note that any unrecognised negative goes to no-measurement rather than to zero, so a sentinel the table gains later fails safe instead of arriving as a confident dry reading.

Nearest neighbour, not bilinear

Worth stating because the instinct runs the other way. Sample the nearest of the four surrounding grid points. Interpolating across a boundary between 0 and 40 mm/h invents a drizzle no instrument measured. For "what is happening over this point", nearest neighbour is not an approximation, it is the honest answer.

Trap 2: a projection you should not implement

The Met Office publishes a UK composite surface rain rate as ODIM HDF5, 1 km cells on a 15-minute cadence, in a public AWS open-data bucket in eu-west-2, licensed CC BY-SA 4.0 so attribution is a requirement rather than a courtesy.

The grid is not lat/lon. It is British National Grid, a Transverse Mercator projection on the Airy 1830 ellipsoid, a datum from the nineteenth century. You can implement the forward transform. You should not: GDAL reads the projection out of the file's own metadata and reprojects a WGS84 input internally, so the whole projection problem collapses into one flag.

Then two traps that have nothing to do with projections. The first is argument order: GDAL takes lon lat, while the eccodes call for the US product takes lat,lon. Two decoders in one codebase with opposite conventions, both of which accept swapped arguments without complaint and return a plausible number. In the UK a swap lands you in the Atlantic and you get nothing back, which is luck; the same mistake in a country that straddles its own mirrored coordinate returns a confident reading for the wrong place.

The second is worth measuring for yourself before you trust any assumption about it. Asked for a point outside the grid, gdallocationinfo prints nothing and still exits 0. Inside the grid it prints a value, including a bare 0 for no rain. Run against a live UK composite:
London      (inside grid)   exit=0  stdout=[0]
Sahara      (outside grid)  exit=0  stdout=[]
Mid-Pacific (outside grid)  exit=0  stdout=[]
So empty output is the out-of-coverage signal and the exit code carries no information at all. Branch on the exit code and every point outside the UK looks like a decoder failure rather than an uncovered location. Empty output with a zero exit looks like a success case. It is not.

This is also where the ODIM distinction bites. nodata = -1 is a masked or offline cell and has to become null. undetect = 0 is a radar measuring no rain and has to stay zero. One line apart in the same parser, opposite meanings, and only one of them may be reported as a number.

Trap 3: a unit with no name

Germany's DWD publishes a radar composite as a two-hour nowcast in five-minute steps on 1 km cells. We read it through Bright Sky, a community-maintained JSON proxy over DWD's open data.

The precipitation field carries integers in units of 0.01 mm per 5 minutes. Nothing in the field name says either of those things. Read it as millimetres and you are out by a factor of 100. Read it as mm/h, which is what almost every other network in this list publishes, and you are out by 12.

providers/dwd/normalizer.tsjavascript
// 0.01 mm / 5 min  ->  x 0.01 = mm/5min  ->  x 12 = mm/h
const UNITS_TO_MM_PER_HOUR = 0.12;

There is a second, smaller lesson here. The endpoint refuses one of its response formats unless the request explicitly accepts a compressed encoding. Node's HTTP client usually negotiates that for you, which is exactly why the header is worth setting by hand: an implicit dependency on your client library's defaults is a bug waiting for a runtime upgrade.

Bright Sky returns a small grid window rather than a single cell, and we take the maximum across it rather than the centre or a mean. For a flight decision the conservative reading is the correct one. That is a choice we make, not something the data tells us, and it belongs in the same list as the others further down.

Trap 4: rendered output cannot express "no data"

One of the seven agencies does not publish its nowcast as data at all. It exists only as rendered imagery for the agency's own web viewer: there is no point-query endpoint, no gridded file, no service call. If you want a number you have to read the picture.

The fragility of that is obvious. The part that is not obvious is that a rendered image has no way to say "no data here". Sampling live output and histogramming every pixel, one sample per location:

  • the densest radar-covered metropolitan area in the country, on a dry day: entirely blank output, 334 bytes;
  • open ocean roughly 600 km offshore, far outside any radar range: entirely blank output, 334 bytes;
  • a remote island and a stretch of sea toward a neighbouring country, both well outside coverage: the same 334 bytes again.

Four geographically unrelated points, one of them a major city and three of them outside radar range, producing byte-identical output. Blank cannot be read as "no rain", because blank is also exactly what "there is no radar here" looks like. Only the pixels the renderer actually paints carry information, and one of those paints is the in-domain "a radar looked and found no echo" colour, which is a genuine measured zero.

A national bounding box does not rescue this. Two of the ocean points above sit inside any bounding box generous enough to include the country's outlying islands. Coverage has to be decided per sample, not per region, and where the imagery cannot express it the honest answer is to decline and let a model nowcast answer instead, labelled as a model.

The cost is real and worth stating plainly: on a completely dry day this network declines and the country gets a model reading rather than radar. That is the accurate description of the situation, because on a dry day its output genuinely does not distinguish the two cases. Availability is the thing you trade away.

Reading rendered output has one more consequence. The intensity scale is a small number of buckets rather than values, and the lowest non-zero bucket spans 0.1 to 1 mm/h, which straddles the 0.3 mm/h no-fly threshold in our flight score. A true 0.15 mm/h drizzle and a true 0.8 mm/h shower are indistinguishable in that bucket. The arithmetic does not allow a neutral reading, so the whole bucket is classified as no-fly: a deliberate false positive in the safe direction. Carrying a range instead of a scalar is the only way to do better.

The contract that makes it hold

Seven adapters, one interface, and the interesting part is the return type. The obvious signature is Promise<Result | null>, and it cannot carry the argument above. Null would have to mean an out-of-coverage point, and also a timeout, a 5xx, a missing decoder binary and a parse failure. The router then cannot tell them apart, and neither can the response: a caller sees the same fallback whether no radar exists over the point or a national composite was unreachable for thirty seconds.

Those are different facts. One is permanent, the other means retry. So an attempt is three-valued:

services/nowcast-router.tsjavascript
type NowcastAttempt =
  | { status: "ok"; snapshot: WeatherSnapshot }
  | { status: "out-of-coverage" }
  | { status: "unavailable" };

And the routing loop stays small while carrying the distinction outward:

services/nowcast-router.tsjavascript
let sawUnavailable = false;

for (const provider of this.providers) {
  const attempt = await provider.tryFetch(point);
  if (attempt.status === "ok") {
    return { result: { ...attempt, source: provider.source, coverage: "ok" },
             radarStatus: "available" };
  }
  if (attempt.status === "unavailable") sawUnavailable = true;
}

// One failure is enough. We cannot prove a point is uncovered when a
// provider that might cover it never answered.
const radarStatus = sawUnavailable
  ? "temporarily-unavailable"
  : "out-of-coverage";

That last line is the part worth copying. A single adapter reporting a failure is enough to downgrade the whole answer, because a point can only be called uncovered when every provider that might cover it actually said so. It also means one flaky adapter degrades the reported status for uncovered points everywhere, which is the correct trade in this direction: overstating uncertainty is safe, understating it is not.

The same three-way split runs one level down, inside the adapters that read gridded files. A cell sample is ok, out-of-grid or no-measurement, so a masked cell and a point outside the domain never become the same value on the way up. All the complexity lives in the adapters, individually testable against a fixture, and adding an eighth country means one new adapter and one new line in a registration array.

The general principle: a two-state return type cannot model a three-state problem, and every consumer downstream inherits whatever the type cannot say. If your decoder returns number | null for data that has measurements, masked cells and missing coverage, the collapse has already happened by the time anything else sees it.

The choices we make, stated

Not every number in a pipeline like this comes from a meteorological agency. Some of them are ours, and the useful discipline is to know which:

  • Maximum across a grid window where an upstream returns a window rather than a cell, instead of the centre or a mean.
  • Nearest-neighbour sampling rather than interpolation, because interpolating across a radar edge invents precipitation.
  • Bucket handling for the one network that publishes intensity classes rather than values, resolved toward the conservative end.
  • A reflectivity-to-rain-rate conversion for the one network that publishes reflectivity and no rate product. That conversion assumes a rain type, which makes it a model rather than a measurement, and the response names the source so a caller can weight it accordingly.
  • The flight-score thresholds themselves, which are advisory and deliberately conservative.

Some sources also cannot express the full three-way distinction at all. The per-network table above says so where it applies: that is a property of those products, and the only honest response is to name which source answered so a caller can decide how much a zero from it is worth.

The general lesson, if there is one: in this domain the interesting engineering is not the parsing. It is refusing to turn an unknown into a zero, and noticing the places where your own types make that refusal impossible to express.

You can check any of this without signing up for anything. The demo endpoint takes no key and is capped per IP:
curl "https://api.droneinfo.app/v1/demo/weather/point?lat=59.3293&lon=18.0686"
Move the coordinate across a border and watch nowcastSource and radarStatus change. Full reference in the API docs. Corrections are welcome at [email protected].

Sources, with the attribution each licence asks for: NOAA NSSL MRMS (public domain), UK Met Office radar composite (CC BY-SA 4.0, British Crown copyright), DWD via Bright Sky, MET Norway (NLOD 2.0), DMI, ECCC GeoMet (Open Government Licence Canada), JMA (出典: 気象庁), and Open-Meteo for the model nowcast.

DroneInfo is a pre-flight weather API for drone software, and a pilot app built on it. Radar, wind aloft and airspace link-outs are free in the app. Advisory information only: it does not grant permission to fly, and an airspace check with the relevant national authority is always required. Reach us at [email protected].