Everything on this page runs on one real scan: the NEXRAD radar at Oklahoma City, KTLX, at 7:02 pm local time on 14 March 2024, with strong storms to its southeast. You can take it apart sweep by sweep, all the way down to the bytes in the file.
The antenna turns a full circle at one angle, tilts up, and turns again. One volume scan took this radar about six minutes. Each bar below is one full turn, drawn when it happened and at the angle it was aimed.
Low angles come back again and again. The radar returns to 0.48° five times in this one scan (look for the repeats along the bottom). That is MESO-SAILS: the lowest angle, where tornadoes and damaging winds live, gets refreshed every minute or so instead of every six.
Each low angle is scanned twice. The first turn listens for far-away echoes with long gaps between pulses. The second pulses quickly to measure wind speed without folding (chapter 4 explains why). Higher up, one turn does both.
import recast_radar
volume = recast_radar.read("KTLX20240315_000217_V06")
volume.vcp # 212
for s in volume.sweeps:
print(s["fixed_angle"], s["nrays"], s["fields"])recast-radar info KTLX20240315_000217_V06
recast-radar dump --rays --sweep 0 KTLX20240315_000217_V06 # every ray's time and angleThis is the lowest turn: 720 rays, one every half degree, each cut into gates 250 m long. Every gate holds a measurement. Move over the picture to read any one of them.
Where the picture is black, the radar listened and heard nothing above its noise: clear air. That is different from a gate with no data, and different again from a range-folded gate (purple on velocity), where an echo from beyond the radar's listening range arrived late and landed in the wrong place. The library keeps these three apart instead of calling them all NaN.
tree = recast_radar.open("KTLX20240315_000217_V06")
dbz = tree["sweep_0"]["DBZH"] # dBZ, NaN where no echo
dbz.sel(azimuth=140, range=94_000, method="nearest").item()
recast_radar.render(tree, "dbz.png", sweep=0, field="DBZH")recast-radar render KTLX20240315_000217_V06 --field DBZH -o dbz.png
recast-radar render KTLX20240315_000217_V06 --sweep 1 --field velocity -o vel.pngThe radar sees in straight-ish lines while the Earth curves away underneath. So the farther out a storm is, the higher up the radar is looking at it. Here are all eleven angles of this scan, drawn to scale.
Below the lowest beam, the radar is blind. At the storm 94 km away, the bottom of the lowest beam is already about a kilometre up. Anything lower, like the base of a tornado, is out of sight. This is why a radar network needs radars close together.
Above the highest beam is the cone of silence. Near the radar, even the top tilt passes under tall storms, so their tops go unseen.
The beam gets wide. A 0.95° beam is over a kilometre and a half across at 100 km. Small features smear out with distance.
h = √(r² + (k a)² + 2 r k a sinθ) − k a, with slant range r, elevation θ, Earth radius a = 6371 km and k = 4/3 for standard refraction (Doviak and Zrnić). Ground distance is s = k a · asin(r cosθ / (k a + h)).
In the library: beam_height_above_radar_m and beam_ground_range_m. For a real atmosphere, trace_refracted_beam takes a refractivity profile and propagation_regime flags ducting.
use recast_radar_tools::model::{beam_ground_range_m, beam_height_above_radar_m};
let h = beam_height_above_radar_m(94_000.0, 0.48); // metres above the antenna
let s = beam_ground_range_m(94_000.0, 0.48); // metres along the groundA Doppler radar measures how fast things move toward or away from it, but only up to a limit called the Nyquist velocity. Faster winds wrap around to the other end of the scale, so a strong wind blowing away can read as a wind blowing toward.
Folded gates show up as sudden jumps: bright green right next to bright red where the real wind is smooth. The library's dealiaser finds those seams and adds or subtracts twice the Nyquist velocity to put each region back where it belongs. The result is a new field, VRADDH, kept beside the raw one.
The dealiased field on the right was made by the library in the same run as everything else on this page.
result = recast_radar.process("KTLX20240315_000217_V06", ["VRADDH"], band="s")
result.report["inserted"] # [[1, "VRADDH"], [3, "VRADDH"], ...]
tree = result.volume.to_datatree()
tree["sweep_1"]["VRADDH"]recast-radar process KTLX20240315_000217_V06 --products VRADDH --band s -o vel.nc
recast-radar render KTLX20240315_000217_V06 --field velocity --dealias -o vel.pnglet raw = sweep.find(Quantity::RadialVelocity).ok_or("no velocity")?;
let dealiased = correct::dealias_velocity(sweep, raw); // VRADDH
sweep.add_field(dealiased)?;The radar sends pulses in two directions at once, horizontal and vertical, and compares what comes back. Raindrops, hailstones and bugs each return a different mix. Drag a box over part of the storm to see what the three fields say about it.
Reflectivity is how much comes back. More and bigger targets, more signal.
ZDR compares horizontal with vertical. Big raindrops flatten as they fall, so they return more horizontally: positive ZDR. Tumbling hail looks round on average: ZDR near zero.
CC (correlation coefficient) says how alike the targets in a gate are. Pure rain or pure snow is above 0.97. A mix of rain and hail drops lower. Birds, bugs and debris are far lower.
The verdict under the chart is a rule of thumb for learning, not a classifier. Operational hydrometeor classification weighs more fields, temperature and texture.
s = recast_radar.open("KTLX20240315_000217_V06")["sweep_0"]
hail_like = (s["DBZH"] > 55) & (s["ZDR"] < 1) & (s["RHOHV"] < 0.95)
rain_like = (s["RHOHV"] > 0.97) & (s["ZDR"] > 0.3)
int(hail_like.sum()), int(rain_like.sum())Stack all eleven tilts and you can slice straight down through a storm. Draw a line across it on the map and the section below shows each beam where it crossed that line, at its true height.
Every stripe in the section is one beam. Low tilts are close together. Higher up they spread apart, and the gaps between them are places the radar never looked. A section is only as good as the scan's vertical sampling.
The empty wedge along the bottom is the ground the lowest beam overshoots. It grows with distance, as chapter 3 showed.
sec = recast_radar.cross_section(
"KTLX20240315_000217_V06",
start_km=(40, -55), end_km=(85, -95), # (east, north) from the radar
field="DBZH", top_m=16_000)
sec.plot() # an xarray DataArrayrecast-radar section KTLX20240315_000217_V06 --helpColumn products look down through every tilt above each spot on the map and boil the column into one number. Hover a spot to see the column the number came from.
Composite reflectivity is the strongest value in the column. Echo top is the highest point still above 18.3 dBZ, a sign of how tall the storm is. VIL (vertically integrated liquid) adds up the water the column holds; big values often mean big hail.
result = recast_radar.process("KTLX20240315_000217_V06", ["CREF", "ET", "VIL"])
recast_radar.render(result.volume, "cref.png", field="CREF")let cref = map::composite_reflectivity(&volume).ok_or("no composite")?;
let tops = map::echo_top(&volume, map::ECHO_TOP_THRESHOLD_DBZ).ok_or("no tops")?;
let vil = map::vil(&volume).ok_or("no VIL")?;These are the real first bytes of KTLX20240315_000217_V06. Click any byte to see what it is. The file is a short header, then blocks of compressed messages; one message holds one ray.
Reflectivity is stored as one byte per gate. The moment header says how to turn a byte into dBZ: dBZ = (byte − offset) / scale. Two codes are special: 0 means below the noise threshold, and 1 means range folded.
The library never expands these to floats unless you ask. It keeps the byte and the scale and offset, which is a big part of why it reads files quickly and in little memory.
recast-radar dump KTLX20240315_000217_V06 # every header value
recast-radar dump --json --data --sweep 0 --field DBZH KTLX20240315_000217_V06raw = recast_radar.open("KTLX20240315_000217_V06", decode=False)
raw["sweep_0"]["DBZH"] # the stored bytes
raw["sweep_0"]["DBZH"].encoding # scale_factor, add_offset, _FillValue