usdata

NOAA · Multi-Radar Multi-Sensor (MRMS)

MRMS gridded radar products

Gridded CONUS products merged from every radar plus other sensors: rotation tracks, reflectivity, hail size, echo tops, precipitation rate, and lightning probability as two-minute gzipped GRIB2 files in the public noaa-mrms-pds S3 bucket laid out as CONUS/PRODUCT/YYYYMMDD/.

GRIB2 (gzipped)Since v0.15
The walkthrough's first look at mrms gridded radar products

At a glance

Spatial
0.01 degree CONUS grid (3,500 x 7,000 points); 0.005 degree for rotation tracks
Time step
Two minutes; the two-hour rotation tracks are written hourly
Updates
Data is delivered in real-time with a 2-minute update cycle
Files
GRIB2 (gzipped)
Selection
Whole two-minute CONUS grids of one product by inclusive UTC file stamp, at most one day
You provide
Product name and both timestamps
Longest request
1 day
Full description

Gridded CONUS products merged from every radar plus other sensors: rotation tracks, reflectivity, hail size, echo tops, precipitation rate, and lightning probability as two-minute gzipped GRIB2 files in the public noaa-mrms-pds S3 bucket laid out as CONUS/PRODUCT/YYYYMMDD/. Select one product from the supported list and a file-stamp window of at most one day; each asset is a complete CONUS grid with no spatial or variable subsetting, and the product is the variable. The stated extent is the grid's coverage, not an offer to crop to a bounding box.

Quick start

Terminal

python -m pip install "usdata[grib]"
usdata fetch noaa:mrms \
  --start 2024-05-07T04:30:00Z \
  --end 2024-05-07T04:50:00Z \
  -p product=RotationTrackML30min_00.50

Python

from usdata import build_query, fetch, get

items = fetch(
    get("noaa:mrms"),
    build_query(
        start="2024-05-07T04:30:00Z",
        end="2024-05-07T04:50:00Z",
        product="RotationTrackML30min_00.50",
    ),
)
data = items[0].open()

Manifest

# dataset.yaml, then: usdata pull dataset.yaml
name: oklahoma-rotation-tracks
sources:
  - dataset: noaa:mrms
    start: 2024-05-07T04:30:00Z
    end: 2024-05-07T04:50:00Z
    params:
      product: RotationTrackML30min_00.50

The same query the walkthrough below ran. The grib 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.

Multi-Radar Multi-Sensor (MRMS), run by NOAA's National Severe Storms Laboratory and the National Weather Service, merges every NEXRAD radar and other sensors onto one fixed CONUS grid every two minutes: reflectivity, rotation tracks, hail size, echo tops, precipitation rate, and more. NOAA publishes the products to the public noaa-mrms-pds S3 bucket as gzipped GRIB2, and one file is one product's whole CONUS grid at one time.

This walkthrough pulls eleven two-minute grids of the mid-level rotation track, 04:30 to 04:50 UTC on 7 May 2024, around an Oklahoma County tornado report, and follows the strongest rotation near it. It needs usdata[grib] and matplotlib. The download is 2 MB, but each grid decodes to 0.4 GB, so allow several gigabytes of memory; it runs in under a minute.

import gc
import sys
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 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__}; xarray {xr.__version__}; pandas {pd.__version__}")
Executed (UTC): 2026-09-24T06:06:31+00:00
usdata 0.26.0; xarray 2026.7.0; pandas 3.0.6

Select

The manifest names one product, RotationTrackML30min_00.50, and a window of file stamps, inclusive at both ends. Stamps fall on even minutes, so 04:30 to 04:50 selects eleven files. There is no location parameter: every file is the whole CONUS grid, and the narrowing to Oklahoma happens after download. A window may span at most one day.

This product is the largest azimuthal shear, a radar measure of rotation, seen in the 3 to 6 km layer over the thirty minutes before each stamp, in units of 0.001 s⁻¹. The window brackets Storm Events report 1184052, an EF1 tornado whose path began at 35.378 °N, 97.543 °W at 04:39 UTC (22:39 local standard time on 6 May), taken as given from NCEI.

print(manifest.read_text())
name: oklahoma-rotation-tracks
sources:
  - dataset: noaa:mrms
    start: 2024-05-07T04:30:00Z
    end: 2024-05-07T04:50:00Z
    params:
      product: RotationTrackML30min_00.50

What arrives

Eleven gzipped GRIB2 files of about 180 kB each, named for the product and the stamp. The committed dataset.lock.json pins every file's S3 URL and checksum, so a pull restores exactly these bytes.

result = pull(manifest)
items = result.fetched
files = pd.DataFrame(
    {
        "stamp_utc": item.asset.time.start.strftime("%H:%M"),
        "file": item.path.name,
        "bytes": item.provenance.size,
        "checksum": item.provenance.checksum[:19] + "…",
    }
    for item in items
)
print("Files:", len(files), "· bytes:", f"{files.bytes.sum():,}")
print("Source:", items[0].provenance.source_url.rsplit("/", 1)[0] + "/")
print("Retrieved (UTC):", items[0].provenance.retrieved_at.isoformat(timespec="seconds"))
print("Asset time of the first file:", items[0].asset.time.start, "to", items[0].asset.time.end)
files.head(3)
Files: 11 · bytes: 2,028,483
Source: s3://noaa-mrms-pds/CONUS/RotationTrackML30min_00.50/20240507/
Retrieved (UTC): 2026-09-24T06:06:40+00:00
Asset time of the first file: 2024-05-07 04:30:00+00:00 to 2024-05-07 04:30:00+00:00
stamp_utc file bytes checksum
0 04:30 MRMS_RotationTrackML30min_00.50_20240507-04300... 192425 sha256:e591e48d94ff…
1 04:32 MRMS_RotationTrackML30min_00.50_20240507-04320... 187260 sha256:cc4941537be1…
2 04:34 MRMS_RotationTrackML30min_00.50_20240507-04340... 187420 sha256:3cda44c77866…

Open

The GRIB reader decompresses in memory and returns an xarray Dataset holding one variable, named from the product without its _00.50 height suffix. The grid is 0.005° with latitude running north to south and longitude in the file's 0 to 360 convention. ecCodes has no parameter table for MRMS and the file says its units are unknown, so the reader fills the units attribute from the catalog's variable list. The stamp is the end of the thirty-minute accumulation, even though the asset's time is a single instant.

def peak_memory_gb() -> float:
    """High-water resident memory of this process, or NaN where it cannot be read."""
    try:
        import resource
    except ImportError:  # Windows
        return float("nan")
    scale = 1e9 if sys.platform == "darwin" else 1e6  # bytes on macOS, kilobytes on Linux
    return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / scale


before = peak_memory_gb()
grid = items[0].open()
(name,) = grid.data_vars
field = grid[name]
values = field.values
print("Variable:", name, "· units attribute:", field.attrs.get("units"))
print("Shape:", field.shape, "·", field.dtype, f"· {field.nbytes / 1e9:.2f} GB decoded")
print(f"Peak memory: {before:.2f} GB before the open, {peak_memory_gb():.2f} GB after")
print(f"Latitude {float(field.latitude[0]):.4f} to {float(field.latitude[-1]):.4f}")
print(f"Longitude {float(field.longitude[0]):.4f} to {float(field.longitude[-1]):.4f}")
print("NaN:", int(np.isnan(values).sum()), "· negative:", int((values < 0).sum()))
print(f"Above zero: {int((values > 0).sum()):,} of {values.size:,} points")
print("Whole numbers only:", bool(np.array_equal(values, np.round(values))))
del grid, field, values
_ = gc.collect()
Variable: RotationTrackML30min · units attribute: 0.001 s-1
Shape: (7000, 14000) · float32 · 0.39 GB decoded
Peak memory: 0.17 GB before the open, 1.35 GB after
Latitude 54.9975 to 20.0025
Longitude 230.0025 to 299.9975
NaN: 0 · negative: 0
Above zero: 186,835 of 98,000,000 points
Whole numbers only: True

Every grid is decoded whole, cropped to a box around the report, and released before the next. Slices follow the file's frame: north to south, and a negative longitude plus 360.

REPORT_LAT, REPORT_LON = 35.378, -97.543
crops = []
for item in items:
    grid = item.open()
    (name,) = grid.data_vars
    crop = grid[name].sel(
        latitude=slice(REPORT_LAT + 1, REPORT_LAT - 1),
        longitude=slice(REPORT_LON + 360 - 2, REPORT_LON + 360 + 2),
    )
    crops.append(crop.load().expand_dims(stamp=[item.asset.time.start.replace(tzinfo=None)]))
    del grid
    gc.collect()
tracks = xr.concat(crops, dim="stamp")
tracks = tracks.assign_coords(longitude=tracks.longitude - 360)
print("Cropped:", dict(tracks.sizes))
print(f"Peak memory after eleven decodes: {peak_memory_gb():.2f} GB")
Cropped: {'stamp': 11, 'latitude': 400, 'longitude': 800}
Peak memory after eleven decodes: 5.30 GB

A first look

The largest value at each point across all eleven grids, which spans the rotation from 04:00 to 04:50 UTC. Rotation tracks draw storm paths: each swath is a circulation moving across the grid.

swath = tracks.max("stamp")
fig, ax = plt.subplots(layout="constrained")
mesh = ax.pcolormesh(
    swath.longitude,
    swath.latitude,
    swath.where(swath > 0),
    cmap="YlOrRd",
    vmin=0,
    vmax=22,
    shading="nearest",
    rasterized=True,
)
ax.plot(REPORT_LON, REPORT_LAT, marker="*", markersize=13, color="#2563a6", linestyle="none")
ax.annotate(
    "tornado report 1184052, 04:39",
    (REPORT_LON, REPORT_LAT),
    xytext=(8, -14),
    textcoords="offset points",
    fontsize=9,
    color="#2563a6",
)
ax.set_aspect(1 / np.cos(np.radians(REPORT_LAT)))
ax.set(
    xlabel="Longitude (°E)",
    ylabel="Latitude (°N)",
    title="MRMS mid-level rotation tracks, 04:00 to 04:50 UTC, 7 May 2024",
)
fig.colorbar(mesh, ax=ax, label="Azimuthal shear, 3 to 6 km (0.001 s⁻¹)")
plt.show()
Saved plot from MRMS gridded radar products

Values are whole numbers, so several points often tie at a maximum; each position below is the mean of the tied points rather than whichever one argmax would pick by array order. The box is 0.25° either side of the report.

def distance_km(lat, lon):
    """Great-circle distance from the report, on a sphere of radius 6371 km."""
    lat1, lat2 = np.radians(REPORT_LAT), np.radians(lat)
    dlat, dlon = lat2 - lat1, np.radians(lon - REPORT_LON)
    h = np.sin(dlat / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2) ** 2
    return 2 * 6371 * np.arcsin(np.sqrt(h))


def peak(box: xr.DataArray) -> dict:
    """The box maximum, how many points tie at it, and their mean position."""
    rows, cols = np.nonzero(box.values >= float(box.max()))
    lat = float(box.latitude.values[rows].mean())
    lon = float(box.longitude.values[cols].mean())
    return {"max": float(box.max()), "tied": len(rows), "km": round(distance_km(lat, lon), 1)}


box = tracks.sel(
    latitude=slice(REPORT_LAT + 0.25, REPORT_LAT - 0.25),
    longitude=slice(REPORT_LON - 0.25, REPORT_LON + 0.25),
)
near = pd.DataFrame(
    [peak(box.isel(stamp=i)) for i in range(box.sizes["stamp"])],
    index=pd.DatetimeIndex(box.stamp.values).strftime("%H:%M").rename("stamp_utc"),
)
strongest = swath.where(swath == swath.max(), drop=True)
print(
    f"Strongest in the crop: {float(swath.max()):.0f} at "
    f"{distance_km(float(strongest.latitude.mean()), float(strongest.longitude.mean())):.0f} km "
    "from the report"
)
near.T
Strongest in the crop: 22 at 65 km from the report
stamp_utc 04:30 04:32 04:34 04:36 04:38 04:40 04:42 04:44 04:46 04:48 04:50
max 13.0 13.0 13.0 13.0 13.0 13.0 18.0 18.0 18.0 18.0 20.0
tied 4.0 4.0 4.0 7.0 7.0 7.0 1.0 1.0 1.0 1.0 1.0
km 19.4 19.4 19.4 16.2 16.2 16.2 0.1 0.1 0.1 0.1 10.1

The report sits at the east end of a swath running in from the west-southwest. The strongest rotation in the crop lies on a separate swath 65 km to the northeast, too far away to be the same circulation, and a third runs off the top right corner. Near the report, the box maximum rises from 13 to 20 across the window; from 04:42 to 04:48 it sits 0.1 km from the path start, and at 04:50 a stronger value appears 10 km away. Consecutive grids repeat the same value and position because each holds a thirty-minute maximum: a peak stays put until it ages out of the window. A rotation track is a shear field, not a tornado detection, and mid-level values are often high in storms that produce none.

Pin and cite

verify checks every cached file against the lockfile's checksums. 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:mrms
  NOAA Multi-Radar/Multi-Sensor System (MRMS) was accessed on 2026-09-16 from https://registry.opendata.aws/noaa-mrms-pds
  homepage: https://registry.opendata.aws/noaa-mrms-pds/
  license: US Government Work (public domain)
  terms: https://www.noaa.gov/information-technology/open-data-dissemination
  retrieved: 2026-09-16; 11 checksummed assets (2,028,483 bytes) pinned by usdata 0.18.0
  sources: 1

What was awkward

  • Decoding is far larger than downloading. A 180 kB file becomes a 0.39 GB array, --dry-run reports only the download size, and peak memory over eleven sequential opens climbs well past one grid even though each is released before the next.
  • The MRMS guide says to mask -999 and -99 sentinels. This product has none and no NaN: points without shear and points without coverage are both exactly 0.0, and a mask written from the guide does nothing.
  • The variable is named RotationTrackML30min, not the RotationTrackML30min_00.50 the manifest asks for, so the Dataset cannot be indexed by the product string; (name,) = grid.data_vars finds it.
  • The asset's time is one instant, the stamp, although every value is a thirty-minute maximum ending there. That changes the reading of every grid and appears only in prose.
  • Cropping means slicing in the file's frame: latitude north to south and longitude 0 to 360. Either mistake returns an empty selection silently and fails later at the first reduction.
  • Values are quantized to whole units, so up to a dozen points tie at a box maximum; argmax returns one of them by array order.
  • The report's position and time are constants pasted from a separate Storm Events fetch, so they are not reproducible from this manifest.

The tornado classification study joins these rotation tracks to the report archive itself.

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 NOAA Multi-Radar/Multi-Sensor System (MRMS) was accessed on [date] from https://registry.opendata.aws/noaa-mrms-pds