usdata

NOAA · NEXRAD Level II Radar

NEXRAD radar scans

Raw volume scans from the WSR-88D weather radar network, archived in the public unidata-nexrad-level2 S3 bucket (NOAA Open Data Dissemination).

NEXRAD Level IISince v0.2
The walkthrough's first look at nexrad radar scans

At a glance

Time step
One volume scan every 4, 5, 6, or 10 minutes, set by the volume coverage pattern
Updates
New Level II data is added as soon as it is available
Files
NEXRAD Level II
Selection
Whole radar scans by site and inclusive UTC scan-start time
You provide
Both timestamps; radar IDs or a geographic query
Longest request
31 days
Full description

Raw volume scans from the WSR-88D weather radar network, archived in the public unidata-nexrad-level2 S3 bucket (NOAA Open Data Dissemination). One object per radar site per volume scan. No server-side subsetting; whole files are fetched. Select radars by site id, bbox, or nearest to a point. Moments vary by sweep and volume coverage pattern; the catalog lists the ones the radar example's lowest sweep carries.

Quick start

Terminal

python -m pip install "usdata[radar]"
usdata fetch noaa:nexrad-level2 \
  --start 2024-05-06T20:00:00Z \
  --end 2024-05-06T20:05:00Z \
  -p site=KTLX

Python

from usdata import build_query, fetch, get

items = fetch(
    get("noaa:nexrad-level2"),
    build_query(
        start="2024-05-06T20:00:00Z",
        end="2024-05-06T20:05:00Z",
        site="KTLX",
    ),
)
data = items[0].open()

Manifest

# dataset.yaml, then: usdata pull dataset.yaml
name: ktlx-one-volume
sources:
  - dataset: noaa:nexrad-level2
    start: 2024-05-06T20:00:00Z
    end: 2024-05-06T20:05:00Z
    params:
      site: KTLX

The same query the walkthrough below ran. The radar extra opens the files.

Saved results from a run against the live service; the notebook records when it ran and the checksums of what it read. Run it yourself.

The WSR-88D network of about 160 NEXRAD radars scans the atmosphere continuously, each radar completing a volume, a stack of conical sweeps at rising elevations, every four to ten minutes. The raw Level II data, with reflectivity, radial velocity, and the dual-polarization moments for every gate, are archived by NOAA and Unidata in the public unidata-nexrad-level2 S3 bucket from 1991 on. One file is one radar's one volume.

This walkthrough pulls the KTLX (Oklahoma City) volume that began just after 20:00 UTC on 6 May 2024, hours before that evening's tornadoes, opens its lowest sweep, and uses the correlation coefficient to tell weather from everything else the radar sees. It needs usdata[radar] and matplotlib, downloads one file of about 9.6 MB, and runs in a few seconds.

from datetime import UTC, datetime
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import xarray as xr
import xradar

import usdata
from usdata import cite_lockfile, pull, verify

# One figure style for every usdata notebook, so previews look alike.
plt.rcParams.update(
    {
        "figure.figsize": (8, 4.5),
        "figure.dpi": 120,
        "axes.spines.top": False,
        "axes.spines.right": False,
        "axes.grid": True,
        "grid.alpha": 0.3,
        "font.size": 10,
    }
)
manifest = Path("dataset.yaml")
print("Executed (UTC):", datetime.now(UTC).isoformat(timespec="seconds"))
print(f"usdata {usdata.__version__}; xradar {xradar.__version__}; xarray {xr.__version__}")
Executed (UTC): 2026-09-24T06:16:21+00:00
usdata 0.26.0; xradar 0.12.0; xarray 2026.7.0

Select

The manifest names one radar by site id, KTLX, and a window of volume start times, inclusive at both ends: 20:00 to 20:05 UTC on 6 May 2024, which here holds one volume. A shorter window around a known start selects one volume exactly; a geographic query or nearest picks radars by position instead of by id. Windows span at most 31 days, and every file is fetched whole.

print(manifest.read_text())
name: ktlx-one-volume
sources:
  - dataset: noaa:nexrad-level2
    start: 2024-05-06T20:00:00Z
    end: 2024-05-06T20:05:00Z
    params:
      site: KTLX

What arrives

One Level II archive file, named for the radar and the volume's start time. The first pull writes dataset.lock.json beside the manifest, pinning the file's checksum; later pulls reuse the pin and the cache.

result = pull(manifest)
(item,) = result.fetched
print("File:", item.path.name)
print("Bytes:", item.provenance.size)
print("Source:", item.provenance.source_url)
print("Retrieved (UTC):", item.provenance.retrieved_at.isoformat(timespec="seconds"))
print("Checksum:", item.provenance.checksum)
print("Volume start:", item.asset.time.start.isoformat())
File: KTLX20240506_200116_V06
Bytes: 9622867
Source: s3://unidata-nexrad-level2/2024/05/06/KTLX/KTLX20240506_200116_V06
Retrieved (UTC): 2026-09-24T06:16:22+00:00
Checksum: sha256:de5bd64281fc57fcb3c4c4a071722221bc3d1515c5e766462178434f2c065b2c
Volume start: 2024-05-06T20:01:16+00:00

Open

The radar reader decodes with xradar and returns an xarray DataTree: the root holds the radar's position and the volume's metadata, and each sweep is a node. sweep=0 decodes only the lowest sweep, which keeps memory to tens of megabytes rather than the whole volume's hundreds. Each moment is an (azimuth, range) array: azimuth in degrees clockwise from north, range in metres along the beam. Below-threshold and range-folded gates are NaN, and every ray carries its own UTC time.

radar = item.open_nexrad(sweep=0)
sweep = radar["/sweep_0"].to_dataset()
moments = [name for name, data in sweep.data_vars.items() if "range" in data.dims]
print(
    "Radar:",
    radar.attrs["instrument_name"],
    f"at {float(radar['latitude']):.3f} °N,",
    f"{float(radar['longitude']):.3f} °E",
)
print("Elevation cuts in the volume:", radar.attrs["actual_elevation_cuts"])
print(f"Sweep 0: {float(sweep.sweep_fixed_angle):.2f}° elevation,", dict(sweep.sizes))
print(f"Range: {float(sweep.range.min()) / 1000:.1f} to {float(sweep.range.max()) / 1000:.1f} km")
print("Ray times:", str(sweep.time.min().values)[:19], "to", str(sweep.time.max().values)[:19])
pd.DataFrame(
    {
        "long_name": [sweep[name].attrs.get("long_name") for name in moments],
        "units": [sweep[name].attrs.get("units") for name in moments],
        "dtype": [str(sweep[name].dtype) for name in moments],
        "valid_gates": [int(sweep[name].count()) for name in moments],
    },
    index=pd.Index(moments, name="moment"),
)
Radar: KTLX at 35.333 °N, -97.278 °E
Elevation cuts in the volume: 14
Sweep 0: 0.48° elevation, {'azimuth': 720, 'range': 1832}
Range: 2.1 to 459.9 km
Ray times: 2024-05-06T20:01:16 to 2024-05-06T20:02:27
long_name units dtype valid_gates
moment
DBZH Equivalent reflectivity factor H dBZ float64 351051
ZDR Log differential reflectivity H/V dB float64 343998
PHIDP Differential phase HV degrees float64 343998
RHOHV Correlation coefficient HV unitless float64 343998
CCORH Clutter Correction H unitless float64 70843

A first look

Reflectivity and correlation coefficient on the lowest sweep, placed east and north of the radar by azimuth and range. Correlation coefficient (RHOHV) measures how alike the horizontal and vertical pulses return: near 1 for rain and hail, lower for insects, birds, and ground clutter. It is drawn only where there is reflectivity.

def edges(centres: np.ndarray) -> np.ndarray:
    """Cell edges halfway between centres, extended half a step at each end."""
    middle = (centres[:-1] + centres[1:]) / 2
    return np.concatenate([[2 * centres[0] - middle[0]], middle, [2 * centres[-1] - middle[-1]]])


ordered = sweep.sortby("azimuth")
azimuth = np.deg2rad(edges(ordered.azimuth.values))[:, None]
ground = edges(ordered.range.values)[None, :] * np.cos(np.deg2rad(float(sweep.sweep_fixed_angle)))
east, north = np.sin(azimuth) * ground / 1000, np.cos(azimuth) * ground / 1000
reflectivity = ordered["DBZH"]
correlation = ordered["RHOHV"].where(reflectivity.notnull())

fig, (left, right) = plt.subplots(1, 2, sharey=True, layout="constrained")
panels = [
    (left, reflectivity, "turbo", (-10, 60), "Reflectivity (dBZ)"),
    (right, correlation, "viridis", (0.5, 1.0), "Correlation coefficient"),
]
for ax, values, cmap, (low, high), label in panels:
    mesh = ax.pcolormesh(east, north, values, cmap=cmap, vmin=low, vmax=high, rasterized=True)
    fig.colorbar(mesh, ax=ax, label=label, orientation="horizontal", shrink=0.9)
    ax.plot(0, 0, marker="+", color="black")
    ax.set_aspect("equal")
    ax.set(xlim=(-350, 150), ylim=(-200, 300), xlabel="East of radar (km)")
left.set_ylabel("North of radar (km)")
fig.suptitle(
    f"KTLX lowest sweep, {item.asset.time.start:%H:%M} UTC 6 May 2024: storms far to the "
    "northwest, clear-air echo overhead"
)
plt.show()
Saved plot from NEXRAD radar scans
near = ordered.range <= 150_000
far = ordered.range >= 200_000
near_echo = reflectivity.where(near)
near_correlation = correlation.where(near)
strong_far = correlation.where(far & (reflectivity >= 40))
print(f"Echo within 150 km: {int(near_echo.count()):,} gates, max {float(near_echo.max()):.1f} dBZ")
print(f"  below 15 dBZ: {float((near_echo < 15).sum() / near_echo.count()):.0%}")
low = float((near_correlation < 0.9).sum() / near_correlation.count())
print(f"  correlation below 0.9: {low:.0%}")
high = float((strong_far >= 0.95).sum() / strong_far.count())
print(f"Echo of 40 dBZ or more beyond 200 km: {int(strong_far.count()):,} gates")
print(f"  correlation of 0.95 or more: {high:.0%}")
print(f"Strongest echo: {float(reflectivity.max()):.1f} dBZ")
Echo within 150 km: 338,736 gates, max 34.0 dBZ
  below 15 dBZ: 98%
  correlation below 0.9: 76%
Echo of 40 dBZ or more beyond 200 km: 221 gates
  correlation of 0.95 or more: 100%
Strongest echo: 65.0 dBZ

At 20:01 UTC the only storms KTLX could see were a line of cells about 250 to 360 km to the northwest, with correlation near 1. The echo filling the first 150 km around the radar is weak, 98% of it below 15 dBZ, and three quarters of it has a correlation coefficient below 0.9: insects and birds in the afternoon boundary layer, not precipitation. Reflectivity alone would not separate the two. At 300 km the lowest beam is several kilometres above the ground, so the distant storms are seen only in their upper parts.

Pin and cite

verify checks the cached file against the lockfile's checksum. Keep the manifest and lockfile with your analysis; the citation below is what a methods section needs, and usdata cite dataset.yaml prints the same.

assert verify(manifest) == []
for citation in cite_lockfile(manifest):
    print(citation.as_text())
noaa:nexrad-level2
  NEXRAD on AWS was accessed on 2026-09-24 from https://registry.opendata.aws/noaa-nexrad
  homepage: https://registry.opendata.aws/noaa-nexrad/
  license: US Government Work (public domain)
  terms: https://www.noaa.gov/information-technology/open-data-dissemination
  retrieved: 2026-09-24; 1 checksummed asset (9,622,867 bytes) pinned by usdata 0.26.0
  sources: 1

What was awkward

  • The sweep node does not carry the radar's position. radar["/sweep_0"].to_dataset() has no latitude or longitude, so xradar's own georeference() on it fails with AttributeError: 'Dataset' object has no attribute 'longitude', and the map above places gates by hand on a flat plane, without beam height or earth curvature.
  • The whole volume is downloaded even when one sweep is wanted; sweep=0 saves memory, not bytes.
  • Moments decode as float64, 11 MB per moment for one sweep, from data stored as one or two bytes per gate.
  • Selecting one volume by window depends on the volume coverage pattern: five minutes holds one clear-air volume but can hold two in precipitation mode, and nothing in the manifest says which the radar was in.
  • A volume's time is its start, but each ray has its own time and the lowest sweep alone spans more than a minute; matching a sweep to an event needs the ray times, not the file name.

See the NEXRAD guide and the radar and satellite guide for choosing volumes by time, and the event-context study for a volume matched to a tornado report later that evening.

Used in these studies

Which severe reports came with rotation and lightning?

Convert one Oklahoma tornado report to UTC, join it to the nearest NEXRAD volume, MRMS rotation track, and GLM flashes, then build a small labeled table of rotation and lightning features for tornado, hail, and wind reports from the same evening.

Storm Events details, fatalities, and locations · NEXRAD radar scans · MRMS gridded radar products · GOES lightning detections

For one Oklahoma tornado, do the two report archives agree on when and where it was, and what did radar, lightning, and the model analysis show at that place and time?

Take one tornado report's UTC time and position out of the 2024 Storm Events archive, check it against the SPC tornado file, and measure what the nearest NEXRAD volume, five MRMS rotation grids, twenty minutes of GOES-16 lightning, and the last HRRR analysis held within 25 km of it.

Storm Events details, fatalities, and locations · SPC tornado, hail, and wind databases · NEXRAD radar scans · MRMS gridded radar products · GOES lightning detections · HRRR model output

Reference

Cite as NEXRAD on AWS was accessed on [date] from https://registry.opendata.aws/noaa-nexrad