Figure 4 of Ryzhkov et al. (2016) — time-height QVP cross-sections of \(Z\), \(Z_{DR}\), \(\rho_{HV}\), and \(\Phi_{DP}\) for the May 20, 2011 KVNX MCS. This is the figure the notebook reproduces.
QVP Analysis: Traditional vs ARCO Workflows#
This notebook reproduces Figure 4 from Ryzhkov et al. (2016) two ways — the traditional file-based workflow and the Analysis-Ready Cloud-Optimized (ARCO) path on s3://nexrad-arco/KVNX — then asserts the results agree to floating-point precision so the speedup is honest rather than apples-to-oranges.
Scientific goal: compute Quasi-Vertical Profiles of \(Z\), \(Z_{DR}\), \(\rho_{HV}\), and \(\Phi_{DP}\) for the May 20, 2011 MCS observed by KVNX during MC3E.
TL;DR
Reproduce Ryzhkov et al. (2016) Fig. 4 on a laptop — hours of downloading and decoding 55 NEXRAD Level II files become seconds of analysis, reading a fraction of the bytes. The notebook computes the QVP both ways and asserts numerical equivalence between them, so the speedup isn’t a different result — it’s the same result, faster.
The numbers below are measured on your machine as this notebook runs; treat them as the answer, not any figure quoted elsewhere. The file-based path is network-bound, so the ratio varies substantially between runs and machines. The data comparison is of decoded bytes and assumes a nominal 4:1 Level II compression ratio; on the wire the saving is smaller.
Prerequisites
This notebook assumes familiarity with the basics covered in Notebook 1 — Open NEXRAD radar archives in 5 lines with radar-datatree:
Connecting to cloud storage with Icechunk
Opening a DataTree with
engine="rustytree"Time-based selection with
.sel()
If you’re new to radar-datatree, start there first.
What is a Quasi-Vertical Profile (QVP)?#
A Quasi-Vertical Profile (QVP) averages a polarimetric radar variable over all 360° of azimuth at a fixed high elevation (here, 19.5°), collapsing it into a time-height view of the column above the radar:
where \(r\) becomes height after the elevation correction, \(\theta\) is azimuth, and \(t\) is time.
This lets you watch the melting layer, dendritic growth zones, and aggregation signatures evolve through a storm — the killer use case Ryzhkov et al. (2016) introduce. The hero figure at the top is exactly what we’ll reproduce.
Study Parameters#
We’ll analyze an expanded time window to capture more storm evolution:
Radar: KVNX (Vance Air Force Base, Oklahoma)
Date: May 20, 2011 (MC3E field campaign)
Time Window: 08:30 - 12:30 UTC (4 hours)
Elevation: 19.5° (highest WSR-88D elevation, sweep_16)
Variables: DBZH, ZDR, RHOHV, PHIDP
import time
import tracemalloc
import warnings
from datetime import datetime, timedelta
warnings.filterwarnings("ignore", category=FutureWarning)
import cmweather # noqa: F401 — registers ChaseSpectral / Carbone11 / PD17
import fsspec
import icechunk as ic
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import xarray as xr
import xradar as xd
# Study parameters — expanded time window (4 hours)
RADAR = "KVNX"
START_TIME = "2011-05-20 08:30"
END_TIME = "2011-05-20 12:30"
VARIABLES = ["DBZH", "ZDR", "RHOHV", "PHIDP"]
# 19.5° elevation, the angle Ryzhkov et al. (2016) use for QVPs. Both
# the file-based loop below and the ARCO path target this exact sweep
# so they're guaranteed to operate on identical underlying scans.
SELECTED_SWEEP = "sweep_16"
# A VCP-12 NEXRAD Level II volume holds 17 sweeps and ~8 variables; the QVP
# uses one sweep and 4 variables, so the file path discards the rest.
N_TOTAL_SWEEPS = 17
N_TOTAL_VARS = 8
metrics = {"traditional": {}, "arco": {}}
def compute_qvp(ds, var="DBZH"):
"""Azimuthal mean of `var` as a time-height profile.
dB-scaled variables are averaged in linear units and converted back, and
range is projected onto height using the sweep's fixed elevation angle.
"""
if ds[var].attrs["units"].startswith("dB"):
qvp = 10 * np.log10((10 ** (ds[var] / 10)).mean("azimuth", skipna=True))
else:
qvp = ds[var].mean("azimuth", skipna=True)
elevation = ds.sweep_fixed_angle.mean(skipna=True).values
height = qvp.range.values * np.sin(np.deg2rad(elevation)) / 1000
return (
qvp.assign_coords(range=height).rename({"range": "height"}).rename(f"qvp_{var}")
)
Approach 1 — Traditional file-based workflow#
Discover the files, download each compressed Level II, decode it, extract the 19.5° sweep, concatenate the per-file sweeps along time, compute QVPs. Every stage blocks the next; nothing streams. This is the work the cloud-native path eliminates.
t0 = time.time()
nexrad_files = list_nexrad_files(RADAR, START_TIME, END_TIME)
metrics["traditional"]["discovery_time"] = time.time() - t0
metrics["traditional"]["total_size_mb"] = sum(f["size"] for f in nexrad_files) / 1024**2
print(
f"Found {len(nexrad_files)} files · "
f"{metrics['traditional']['total_size_mb']:.1f} MB compressed · "
f"discovered in {metrics['traditional']['discovery_time']:.2f}s"
)
print(f"First: {nexrad_files[0]['path'].split('/')[-1]}")
print(f"Last: {nexrad_files[-1]['path'].split('/')[-1]}")
Found 55 files · 808.9 MB compressed · discovered in 1.08s
First: KVNX20110520_083314_V06.gz
Last: KVNX20110520_122911_V06.gz
# Download every file, decode it, extract SELECTED_SWEEP, hold the sweep
# datasets in RAM for later concat. tracemalloc captures the peak — the
# RAM claim downstream is grounded in this measurement.
t0 = time.time()
tracemalloc.start()
sweep_datasets = []
bytes_downloaded = 0
for file_info in nexrad_files:
dtree_single, size_bytes = download_nexrad(file_info["path"])
bytes_downloaded += size_bytes
# KeyError here is intentional — silently averaging across the wrong
# elevation would corrupt the QVP without anyone noticing.
ds = dtree_single[SELECTED_SWEEP].ds.load()
sweep_datasets.append(ds.expand_dims({"vcp_time": [ds.time.values[0]]}))
_, peak_mem = tracemalloc.get_traced_memory()
tracemalloc.stop()
metrics["traditional"]["total_time"] = time.time() - t0
metrics["traditional"]["files_processed"] = len(sweep_datasets)
metrics["traditional"]["peak_memory_mb"] = peak_mem / 1e6
print(
f"Downloaded {len(sweep_datasets)} files · "
f"{bytes_downloaded / 1e6:.0f} MB compressed · "
f"peak {peak_mem / 1e6:.0f} MB RAM · "
f"{metrics['traditional']['total_time']:.1f}s total"
)
Downloaded 55 files · 848 MB compressed · peak 3083 MB RAM · 274.8s total
| Traditional workflow | |
|---|---|
| Files processed | 55.00 |
| Download + decode [s] | 274.80 |
| Concatenate [s] | 1.60 |
| QVP compute [s] | 1.88 |
| Total workflow [s] | 278.30 |
| Network transfer [MB] | 808.90 |
| Peak RAM, download [MB] | 3083.00 |
| Peak RAM, concat [MB] | 5793.00 |
Approach 2 — ARCO streaming#
Open the Icechunk repository (metadata only), navigate to the sweep we want, slice the 4-hour window on the native vcp_time dimension. No file iteration, no decoding. Chunks stream on demand only when .compute() is called below.
Connected to Icechunk repository in 0.66s
CPU times: user 48.9 ms, sys: 3.04 ms, total: 51.9 ms
Wall time: 663 ms
Open the radar datatree using xarray.
%%time
t0 = time.time()
# Open only the sweep we need across all VCPs. The `/*/sweep_16` glob
# trims the tree from "every VCP × every sweep" down to just SELECTED_SWEEP
# (the 19.5° elevation Ryzhkov et al. 2016 use for QVPs). See
# Notebook 1's "Opening only what you need" section for the same pattern.
dtree = xr.open_datatree(
session.store,
engine="rustytree",
group_filter=f"/*/{SELECTED_SWEEP}",
chunks={},
)
metrics["arco"]["open_datatree_time"] = time.time() - t0
print(f"Opened DataTree in {metrics['arco']['open_datatree_time']:.2f}s")
Opened DataTree in 1.25s
CPU times: user 168 ms, sys: 26.2 ms, total: 195 ms
Wall time: 1.25 s
dtree
<xarray.DataTree>
Group: /
├── Group: /VCP-12
│ │ Dimensions: (vcp_time: 3577)
│ │ Coordinates:
│ │ * vcp_time (vcp_time) datetime64[ns] 29kB 2011-04-08T22:31:19.547000 ...
│ │ altitude int64 8B ...
│ │ latitude float64 8B ...
│ │ longitude float64 8B ...
│ │ Data variables:
│ │ volume_number (vcp_time) float64 29kB dask.array<chunksize=(1,), meta=np.ndarray>
│ │ Attributes:
│ │ Conventions: Cf/Radial instrument_parameters radar_parameters
│ │ attribution: NOAA NEXRAD Level 2 data processed by Atmoscale from NOAA O...
│ │ dataset_id: nexrad-arco-kvnx
│ │ institution: NOAA National Weather Service
│ │ source: WSR-88D S-band weather radar
│ │ time_domain: 2026-04-27 to Present
│ │ title: NEXRAD ARCO - KVNX
│ │ version: 2.1
│ └── Group: /VCP-12/sweep_16
│ Dimensions: (vcp_time: 3577, azimuth: 360, range: 460)
│ Coordinates:
│ * azimuth (azimuth) float64 3kB 0.5 1.5 2.5 ... 357.5 358.5 359.5
│ elevation (azimuth) float64 3kB dask.array<chunksize=(360,), meta=np.ndarray>
│ time (vcp_time, azimuth) datetime64[ns] 10MB dask.array<chunksize=(1, 360), meta=np.ndarray>
│ * range (range) float32 2kB 2.125e+03 2.375e+03 ... 1.169e+05
│ Data variables:
│ CCORH (vcp_time, azimuth, range) float32 2GB dask.array<chunksize=(1, 360, 460), meta=np.ndarray>
│ DBZH (vcp_time, azimuth, range) float32 2GB dask.array<chunksize=(1, 360, 460), meta=np.ndarray>
│ PHIDP (vcp_time, azimuth, range) float32 2GB dask.array<chunksize=(1, 360, 460), meta=np.ndarray>
│ RHOHV (vcp_time, azimuth, range) float32 2GB dask.array<chunksize=(1, 360, 460), meta=np.ndarray>
│ VRADH (vcp_time, azimuth, range) float32 2GB dask.array<chunksize=(1, 360, 460), meta=np.ndarray>
│ WRADH (vcp_time, azimuth, range) float32 2GB dask.array<chunksize=(1, 360, 460), meta=np.ndarray>
│ ZDR (vcp_time, azimuth, range) float32 2GB dask.array<chunksize=(1, 360, 460), meta=np.ndarray>
│ ray_elevation_angle (vcp_time, azimuth) float64 10MB dask.array<chunksize=(1, 360), meta=np.ndarray>
│ sweep_fixed_angle (vcp_time) float32 14kB dask.array<chunksize=(1,), meta=np.ndarray>
│ sweep_number (vcp_time) float32 14kB dask.array<chunksize=(1,), meta=np.ndarray>
├── Group: /VCP-121
│ │ Dimensions: (vcp_time: 1)
│ │ Coordinates:
│ │ * vcp_time (vcp_time) datetime64[ns] 8B 2011-08-06T03:42:31.495000
│ │ altitude int64 8B ...
│ │ latitude float64 8B ...
│ │ longitude float64 8B ...
│ │ Data variables:
│ │ volume_number (vcp_time) float64 8B dask.array<chunksize=(1,), meta=np.ndarray>
│ │ Attributes:
│ │ Conventions: Cf/Radial instrument_parameters radar_parameters
│ │ attribution: NOAA NEXRAD Level 2 data processed by Atmoscale from NOAA O...
│ │ dataset_id: nexrad-arco-kvnx
│ │ institution: NOAA National Weather Service
│ │ source: WSR-88D S-band weather radar
│ │ time_domain: 2026-04-27 to Present
│ │ title: NEXRAD ARCO - KVNX
│ │ version: 2.1
│ └── Group: /VCP-121/sweep_16
│ Dimensions: (vcp_time: 1, azimuth: 360, range: 924)
│ Coordinates:
│ * azimuth (azimuth) float64 3kB 0.5 1.5 2.5 ... 357.5 358.5 359.5
│ elevation (azimuth) float64 3kB dask.array<chunksize=(360,), meta=np.ndarray>
│ time (vcp_time, azimuth) datetime64[ns] 3kB dask.array<chunksize=(1, 360), meta=np.ndarray>
│ * range (range) float32 4kB 2.125e+03 2.375e+03 ... 2.329e+05
│ Data variables:
│ DBZH (vcp_time, azimuth, range) float32 1MB dask.array<chunksize=(1, 360, 924), meta=np.ndarray>
│ PHIDP (vcp_time, azimuth, range) float32 1MB dask.array<chunksize=(1, 360, 924), meta=np.ndarray>
│ RHOHV (vcp_time, azimuth, range) float32 1MB dask.array<chunksize=(1, 360, 924), meta=np.ndarray>
│ VRADH (vcp_time, azimuth, range) float32 1MB dask.array<chunksize=(1, 360, 924), meta=np.ndarray>
│ WRADH (vcp_time, azimuth, range) float32 1MB dask.array<chunksize=(1, 360, 924), meta=np.ndarray>
│ ZDR (vcp_time, azimuth, range) float32 1MB dask.array<chunksize=(1, 360, 924), meta=np.ndarray>
│ ray_elevation_angle (vcp_time, azimuth) float64 3kB dask.array<chunksize=(1, 360), meta=np.ndarray>
│ sweep_fixed_angle (vcp_time) float32 4B dask.array<chunksize=(1,), meta=np.ndarray>
│ sweep_number (vcp_time) float32 4B dask.array<chunksize=(1,), meta=np.ndarray>
└── Group: /VCP-212
│ Dimensions: (vcp_time: 748)
│ Coordinates:
│ * vcp_time (vcp_time) datetime64[ns] 6kB 2011-05-20T19:19:02.487000 ....
│ altitude int64 8B ...
│ latitude float64 8B ...
│ longitude float64 8B ...
│ Data variables:
│ volume_number (vcp_time) float64 6kB dask.array<chunksize=(1,), meta=np.ndarray>
│ Attributes:
│ Conventions: Cf/Radial instrument_parameters radar_parameters
│ attribution: NOAA NEXRAD Level 2 data processed by Atmoscale from NOAA O...
│ dataset_id: nexrad-arco-kvnx
│ institution: NOAA National Weather Service
│ source: WSR-88D S-band weather radar
│ time_domain: 2026-04-27 to Present
│ title: NEXRAD ARCO - KVNX
│ version: 2.1
└── Group: /VCP-212/sweep_16
Dimensions: (vcp_time: 748, azimuth: 360, range: 460)
Coordinates:
* azimuth (azimuth) float64 3kB 0.5 1.5 2.5 ... 357.5 358.5 359.5
elevation (azimuth) float64 3kB dask.array<chunksize=(360,), meta=np.ndarray>
time (vcp_time, azimuth) datetime64[ns] 2MB dask.array<chunksize=(1, 360), meta=np.ndarray>
* range (range) float32 2kB 2.125e+03 2.375e+03 ... 1.169e+05
Data variables:
CCORH (vcp_time, azimuth, range) float32 495MB dask.array<chunksize=(1, 360, 460), meta=np.ndarray>
DBZH (vcp_time, azimuth, range) float32 495MB dask.array<chunksize=(1, 360, 460), meta=np.ndarray>
PHIDP (vcp_time, azimuth, range) float32 495MB dask.array<chunksize=(1, 360, 460), meta=np.ndarray>
RHOHV (vcp_time, azimuth, range) float32 495MB dask.array<chunksize=(1, 360, 460), meta=np.ndarray>
VRADH (vcp_time, azimuth, range) float32 495MB dask.array<chunksize=(1, 360, 460), meta=np.ndarray>
WRADH (vcp_time, azimuth, range) float32 495MB dask.array<chunksize=(1, 360, 460), meta=np.ndarray>
ZDR (vcp_time, azimuth, range) float32 495MB dask.array<chunksize=(1, 360, 460), meta=np.ndarray>
ray_elevation_angle (vcp_time, azimuth) float64 2MB dask.array<chunksize=(1, 360), meta=np.ndarray>
sweep_fixed_angle (vcp_time) float32 3kB dask.array<chunksize=(1,), meta=np.ndarray>
sweep_number (vcp_time) float32 3kB dask.array<chunksize=(1,), meta=np.ndarray>list(dtree.children)
['VCP-12', 'VCP-121', 'VCP-212']
Each VCP’s sweep_16 is the 19.5° elevation cut. The QVP-target VCP for the May 2011 KVNX MCS is VCP-12 (precipitation mode); pull its sweep directly:
# Select the 4-hour time window
# With ARCO format, this is a simple slice operation - no file iteration needed
ds_qvp_selected = (
dtree.sel(vcp_time=slice(START_TIME, END_TIME))
.prune(drop_size_zero_vars=True)[f"/VCP-12/{SELECTED_SWEEP}"]
.to_dataset()
)
ds_qvp_selected
<xarray.Dataset> Size: 255MB
Dimensions: (vcp_time: 55, azimuth: 360, range: 460)
Coordinates:
* vcp_time (vcp_time) datetime64[ns] 440B 2011-05-20T08:33:14.1...
* azimuth (azimuth) float64 3kB 0.5 1.5 2.5 ... 357.5 358.5 359.5
elevation (azimuth) float64 3kB dask.array<chunksize=(360,), meta=np.ndarray>
time (vcp_time, azimuth) datetime64[ns] 158kB dask.array<chunksize=(1, 360), meta=np.ndarray>
* range (range) float32 2kB 2.125e+03 2.375e+03 ... 1.169e+05
Data variables:
CCORH (vcp_time, azimuth, range) float32 36MB dask.array<chunksize=(1, 360, 460), meta=np.ndarray>
DBZH (vcp_time, azimuth, range) float32 36MB dask.array<chunksize=(1, 360, 460), meta=np.ndarray>
PHIDP (vcp_time, azimuth, range) float32 36MB dask.array<chunksize=(1, 360, 460), meta=np.ndarray>
RHOHV (vcp_time, azimuth, range) float32 36MB dask.array<chunksize=(1, 360, 460), meta=np.ndarray>
VRADH (vcp_time, azimuth, range) float32 36MB dask.array<chunksize=(1, 360, 460), meta=np.ndarray>
WRADH (vcp_time, azimuth, range) float32 36MB dask.array<chunksize=(1, 360, 460), meta=np.ndarray>
ZDR (vcp_time, azimuth, range) float32 36MB dask.array<chunksize=(1, 360, 460), meta=np.ndarray>
ray_elevation_angle (vcp_time, azimuth) float64 158kB dask.array<chunksize=(1, 360), meta=np.ndarray>
sweep_fixed_angle (vcp_time) float32 220B dask.array<chunksize=(1,), meta=np.ndarray>
sweep_number (vcp_time) float32 220B dask.array<chunksize=(1,), meta=np.ndarray>ARCO streams 220 chunks = 145.7 MB, against 809 MB downloaded / 3236 MB decompressed by the file path.
| chunks | chunk [MB] | total [MB] | |
|---|---|---|---|
| DBZH | 55.0 | 0.6624 | 36.432 |
| ZDR | 55.0 | 0.6624 | 36.432 |
| RHOHV | 55.0 | 0.6624 | 36.432 |
| PHIDP | 55.0 | 0.6624 | 36.432 |
%%time
t0 = time.time()
# Vectorized across all timesteps; chunks stream on demand here.
qvp_data = {var: compute_qvp(ds_qvp_selected, var).compute() for var in VARIABLES}
a = metrics["arco"]
a["qvp_compute_time"] = time.time() - t0
a["timesteps"] = len(ds_qvp_selected.vcp_time)
a["total_time"] = a["connect_time"] + a["open_datatree_time"] + a["qvp_compute_time"]
print(
f"Computed {len(qvp_data)} QVPs over {a['timesteps']} timesteps in "
f"{a['qvp_compute_time']:.1f}s ({a['total_time']:.1f}s including connect + open)"
)
Computed 4 QVPs over 55 timesteps in 9.2s (11.1s including connect + open)
CPU times: user 1.6 s, sys: 67.7 ms, total: 1.67 s
Wall time: 9.2 s
Sanity check: traditional and ARCO QVPs agree#
Both paths read the same sweep_16 bytes and pass them through the same compute_qvp helper, so the resulting QVPs should match within floating-point noise. This block enforces the equivalence directly — if either workflow drifted (e.g., a misaligned time slice, a chunk-decoding bug, a unit handling regression), the assertion fires.
Both workflows produce numerically equivalent QVPs.
| max |Δ| | tolerance | |
|---|---|---|
| DBZH | 0.000063 | 0.10 |
| ZDR | 0.000039 | 0.10 |
| RHOHV | 0.000003 | 0.05 |
| PHIDP | 0.000160 | 0.50 |
Performance Comparison: Traditional vs ARCO#
Now let’s compare the two approaches quantitatively. The figure below has three panels: (a) total processing time, (b) memory footprint (peak RAM vs ARCO bytes loaded), and © breakdown of the traditional workflow’s time among download/decode, concat, and QVP compute.
25.0× faster · 22× less data loaded · 25× higher throughput
| Traditional | ARCO | |
|---|---|---|
| Metric | ||
| Total time [s] | 278.3 | 11.1 |
| Timesteps | 55 | 55 |
| Network transfer [MB] | 809 (gzip) | 146 |
| Decompressed / streamed [MB] | 3236 | 146 |
| Peak RAM [MB] | 3083 | 146 |
| Throughput [MB/s] | 0.52 | 13.1 |
| Sweeps loaded | all (17/file) | 1 (sweep_16) |
| Variables loaded | all (~8/sweep) | 4 (selected) |
Key takeaway#
ARCO’s advantage isn’t compression — it’s selective access. The traditional path downloads every sweep of every variable in every file (~809 MB compressed, ~3.2 GB decompressed) to extract the 146 MB it actually needs. ARCO streams exactly those 146 MB and nothing else. Once the data model and storage layer make that selectivity declarative — one .sel(vcp_time=slice(...)) instead of fifty lines of file iteration — the toil Abernathey et al. (2021) describe simply disappears.
The same pattern scales linearly with window length. Notebook 4 picks up here and pushes it from a single 4-hour window to seasonal QPE on a Dask cluster.
← Notebook 2 — KLOT Low Sweeps · Notebook 4 — QPE Scaling Benchmark →
References#
Abernathey, R.P., T. Augspurger, A. Banihirwe, C.C. Blackmon-Luca, T.J. Crone, C.L. Gentemann, J.J. Hamman, N. Henderson, C. Lepore, T.A. McCaie, N.H. Robinson, and R.P. Signell, 2021: Cloud-Native Repositories for Big Scientific Data. Computing in Science & Engineering, 23, 26–35, https://doi.org/10.1109/MCSE.2021.3059437.
Ryzhkov, A., P. Zhang, H. Reeves, M. Kumjian, T. Tschallener, S. Trömel, and C. Simmer, 2016: Quasi-Vertical Profiles—A New Way to Look at Polarimetric Radar Data. J. Atmos. Oceanic Technol., 33, 551–562, https://doi.org/10.1175/JTECH-D-15-0020.1.
Wilkinson, M.D., et al., 2016: The FAIR Guiding Principles for scientific data management and stewardship. Scientific Data, 3, 160018, https://doi.org/10.1038/sdata.2016.18.
Ladino-Rincón, A., et al. (2026). Radar DataTree: A Cloud-Native AI-Ready Data Model for Accessible, Time-Aware Weather Radar Datasets. Submitted to IEEE Transactions on Big Data.
Earlier preprint: arXiv:2510.24943, https://doi.org/10.48550/arXiv.2510.24943
