Skip to content

incomplete_sweep="pad" reindexes NEXRAD sweeps onto off-nominal azimuth grids (e.g. 735 rays instead of 720) #396

Description

@aladinor

Summary

open_nexradlevel2_datatree(..., incomplete_sweep="pad") (added in #332) auto-detects the azimuth grid for an incomplete sweep with util.extract_angle_parameters, which infers angle_res from the median ray-to-ray azimuth difference of the observed rays. WSR-88D azimuths carry oversampling jitter — a truncated super-res cut's measured median spacing can be ~0.486° instead of the nominal 0.5° — and the rounding step (np.round(median_diff, decimals=2)) turns that into angle_res = 0.49. reindex_angle then builds np.arange(0, 360, 0.49) → a 735-ray grid, a rotation no WSR-88D produces (super-res cuts are 720 rays, legacy cuts 360).

Consequences:

  • the padded sweep's azimuth dimension is physically impossible and unstable across polls of the same live volume (the inferred median shifts as more rays arrive) — while shape stability is a main reason to use "pad" in streaming pipelines;
  • grid centers are misaligned relative to the nominal 0.25°+k·0.5° slots, so rays land in wrong bins near the arc edges.

Reproducer (real data)

Uses a real truncated volume captured from the live feed: the first 18 chunk objects (1 S + 17 I) of KLOT volume 2026-07-27 19:01:44 UTC, packaged as an open-radar-data fixture (nexrad_level2_chunks_KLOT_998.tar.gz, PR to openradar/open-radar-data in flight — I can link it here once opened). The truncated sweep_2 has 600 observed rays with median measured spacing 0.4861°.

import tarfile
from pathlib import Path

import xradar as xd
from open_radar_data import DATASETS

archive = DATASETS.fetch("nexrad_level2_chunks_KLOT_998.tar.gz")
extract_dir = Path("chunks_998")
with tarfile.open(archive) as tar:
    tar.extractall(extract_dir, filter="data")
chunks = [p.read_bytes() for p in sorted((extract_dir / "nexrad_chunks_KLOT_998").iterdir())]

dt = xd.io.open_nexradlevel2_datatree(chunks, incomplete_sweep="pad")
print(dt["sweep_2"].ds.sizes["azimuth"])          # 735 — expected 720
print(float(dt["sweep_2"].ds.azimuth[1] - dt["sweep_2"].ds.azimuth[0]))  # 0.49 — expected 0.5

Output:

735
0.49

Until the fixture lands, the same 18 objects can be fetched directly (volume directories in the live bucket persist for a few days):

import fsspec
fs = fsspec.filesystem("s3", anon=True)
paths = sorted(fs.ls("unidata-nexrad-level2-chunks/KLOT/998"))[:18]  # 20260727-190144-001-S ...
chunks = [fs.open(p, "rb").read() for p in paths]

Note: the existing full-volume chunks fixture (nexrad_level2_chunks_KLOT.tar.gz) does not reproduce this at any truncation point — its truncated sweeps happen to infer the nominal 0.5° — which is why the new jittery capture is needed to pin the bug with real data.

Mechanism in isolation (no data download)

The same failure through the public util functions with a synthetic sweep at the real-world measured spacing:

import numpy as np
import xarray as xr
from xradar import util

n = 600  # truncated super-res sweep, median measured spacing 0.4861°
ds = xr.Dataset(
    {"DBZH": (("azimuth", "range"), np.zeros((n, 4)))},
    coords={
        "azimuth": ("azimuth", 0.25 + np.arange(n) * 0.4861),
        "elevation": ("azimuth", np.full(n, 0.5)),
        "time": ("azimuth", np.datetime64("2026-07-28T00:00:00")
                 + (np.arange(n) * np.timedelta64(30, "ms"))),
        "range": ("range", np.arange(4) * 250.0),
        "sweep_mode": "azimuth_surveillance",
    },
)
params = util.extract_angle_parameters(ds)
print(params["angle_res"])   # 0.49
reindexed = util.reindex_angle(
    ds, start_angle=params["start_angle"], stop_angle=params["stop_angle"],
    angle_res=float(params["angle_res"]), direction=params["direction"],
)
print(reindexed.sizes["azimuth"])   # 735

Root cause

  • xradar/util.py, extract_angle_parameters step 8: angle_res = np.round(median_diff, decimals=2) — the std-filtered median of observed diffs, rounded to 2 decimals, with no snap to the radar's nominal resolutions.
  • xradar/io/backends/nexrad_level2.py (pad path from ADD: Support reading NEXRAD Level 2 chunks from real-time S3 bucket #332): feeds that inferred value into reindex_angle for incomplete sweeps.

Suggested fix

For the NEXRAD pad path, the exact resolution is available on the wire and already parsed: the MSG 31 data header's azimuth_resolution field (1 → 0.5°, 2 → 1.0°, ICD 2620002 Table XVII). Using it instead of inference makes the grid deterministic (720/360) regardless of jitter. A more general belt-and-braces option: in extract_angle_parameters, snap the inferred angle_res to the nearest member of a small set of plausible resolutions when sweep_mode == "azimuth_surveillance" (0.25/0.5/1.0), keeping free inference for RHI and exotic radars. Happy to submit a PR for either approach.

Environment

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions