nexrad_render/lib.rs
1//! Rendering functions for NEXRAD weather radar data.
2//!
3//! This crate provides functions to render radar data into visual images. It converts
4//! radar moment data (reflectivity, velocity, etc.) into color-mapped images that can
5//! be saved to common formats like PNG.
6//!
7//! # Example
8//!
9//! ```ignore
10//! use nexrad_render::{render_radials, Product, RenderOptions, get_nws_reflectivity_scale};
11//!
12//! let options = RenderOptions::new(800, 800);
13//! let image = render_radials(
14//! sweep.radials(),
15//! Product::Reflectivity,
16//! &get_nws_reflectivity_scale(),
17//! &options,
18//! ).unwrap();
19//!
20//! // Save directly to PNG
21//! image.save("radar.png").unwrap();
22//! ```
23//!
24//! # Crate Boundaries
25//!
26//! This crate provides **visualization and rendering** with the following responsibilities
27//! and constraints:
28//!
29//! ## Responsibilities
30//!
31//! - Render radar data to images ([`image::RgbaImage`])
32//! - Apply color scales to moment data
33//! - Handle geometric transformations (polar to Cartesian coordinates)
34//! - Consume `nexrad-model` types (Radial, MomentData)
35//!
36//! ## Constraints
37//!
38//! - **No data access or network operations**
39//! - **No binary parsing or decoding**
40//!
41//! This crate can be used standalone or through the `nexrad` facade crate (re-exported
42//! via the `render` feature, which is enabled by default).
43
44#![forbid(unsafe_code)]
45#![deny(clippy::unwrap_used)]
46#![deny(clippy::expect_used)]
47#![warn(clippy::correctness)]
48#![deny(missing_docs)]
49
50pub use image::RgbaImage;
51use nexrad_model::data::{MomentData, MomentValue, Radial};
52use result::{Error, Result};
53
54mod color;
55pub use crate::color::*;
56
57pub mod result;
58
59/// Radar data products that can be rendered.
60///
61/// Each product corresponds to a different type of moment data captured by the radar.
62#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
63pub enum Product {
64 /// Base reflectivity (dBZ). Measures the intensity of precipitation.
65 Reflectivity,
66 /// Radial velocity (m/s). Measures motion toward or away from the radar.
67 Velocity,
68 /// Spectrum width (m/s). Measures turbulence within the radar beam.
69 SpectrumWidth,
70 /// Differential reflectivity (dB). Compares horizontal and vertical reflectivity.
71 DifferentialReflectivity,
72 /// Differential phase (degrees). Phase difference between polarizations.
73 DifferentialPhase,
74 /// Correlation coefficient. Correlation between polarizations (0-1).
75 CorrelationCoefficient,
76 /// Specific differential phase (degrees/km). Rate of differential phase change.
77 SpecificDiffPhase,
78}
79
80/// Options for rendering radar radials.
81///
82/// Use the builder methods to configure rendering options, then pass to
83/// [`render_radials`].
84///
85/// # Example
86///
87/// ```
88/// use nexrad_render::RenderOptions;
89///
90/// // Render 800x800 with black background (default)
91/// let options = RenderOptions::new(800, 800);
92///
93/// // Render with transparent background for compositing
94/// let options = RenderOptions::new(800, 800).transparent();
95///
96/// // Render with custom background color (RGBA)
97/// let options = RenderOptions::new(800, 800).with_background([255, 255, 255, 255]);
98/// ```
99#[derive(Debug, Clone)]
100pub struct RenderOptions {
101 /// Output image dimensions (width, height) in pixels.
102 pub size: (usize, usize),
103 /// Background color as RGBA bytes. `None` means transparent (all zeros).
104 pub background: Option<[u8; 4]>,
105}
106
107impl RenderOptions {
108 /// Creates new render options with the specified dimensions and black background.
109 pub fn new(width: usize, height: usize) -> Self {
110 Self {
111 size: (width, height),
112 background: Some([0, 0, 0, 255]),
113 }
114 }
115
116 /// Sets the background to transparent for compositing.
117 ///
118 /// When rendering with a transparent background, areas without radar data
119 /// will be fully transparent, allowing multiple renders to be layered.
120 pub fn transparent(mut self) -> Self {
121 self.background = None;
122 self
123 }
124
125 /// Sets a custom background color as RGBA bytes.
126 pub fn with_background(mut self, rgba: [u8; 4]) -> Self {
127 self.background = Some(rgba);
128 self
129 }
130}
131
132/// Renders radar radials to an RGBA image.
133///
134/// Converts polar radar data into a Cartesian image representation. Each radial's
135/// moment values are mapped to colors using the provided color scale, producing
136/// a centered radar image with North at the top.
137///
138/// # Arguments
139///
140/// * `radials` - Slice of radials to render (typically from a single sweep)
141/// * `product` - The radar product (moment type) to visualize
142/// * `scale` - Color scale mapping moment values to colors
143/// * `options` - Rendering options (size, background, etc.)
144///
145/// # Errors
146///
147/// Returns an error if:
148/// - No radials are provided
149/// - The requested product is not present in the radials
150///
151/// # Example
152///
153/// ```ignore
154/// use nexrad_render::{render_radials, Product, RenderOptions, get_nws_reflectivity_scale};
155///
156/// let scale = get_nws_reflectivity_scale();
157/// let options = RenderOptions::new(800, 800);
158///
159/// let image = render_radials(
160/// sweep.radials(),
161/// Product::Reflectivity,
162/// &scale,
163/// &options,
164/// ).unwrap();
165///
166/// image.save("radar.png").unwrap();
167/// ```
168pub fn render_radials(
169 radials: &[Radial],
170 product: Product,
171 scale: &DiscreteColorScale,
172 options: &RenderOptions,
173) -> Result<RgbaImage> {
174 let (width, height) = options.size;
175 let mut buffer = vec![0u8; width * height * 4];
176
177 // Fill background
178 if let Some(bg) = options.background {
179 for pixel in buffer.chunks_exact_mut(4) {
180 pixel.copy_from_slice(&bg);
181 }
182 }
183
184 if radials.is_empty() {
185 return Err(Error::NoRadials);
186 }
187
188 // Build lookup table for fast color mapping
189 let (min_val, max_val) = get_product_value_range(product);
190 let lut = ColorLookupTable::from_scale(scale, min_val, max_val, 256);
191
192 // Get radar parameters from the first radial
193 let first_radial = &radials[0];
194 let data_moment = get_radial_moment(product, first_radial).ok_or(Error::ProductNotFound)?;
195 let first_gate_km = data_moment.first_gate_range_km();
196 let gate_interval_km = data_moment.gate_interval_km();
197 let gate_count = data_moment.gate_count() as usize;
198 let radar_range_km = first_gate_km + gate_count as f64 * gate_interval_km;
199
200 // Pre-extract all moment values indexed by azimuth for efficient lookup
201 let mut radial_data: Vec<(f32, Vec<MomentValue>)> = Vec::with_capacity(radials.len());
202 for radial in radials {
203 let azimuth = radial.azimuth_angle_degrees();
204 if let Some(moment) = get_radial_moment(product, radial) {
205 radial_data.push((azimuth, moment.values()));
206 }
207 }
208
209 // Sort by azimuth for binary search
210 radial_data.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
211
212 // Extract azimuths for binary search
213 let sorted_azimuths: Vec<f32> = radial_data.iter().map(|(az, _)| *az).collect();
214
215 // Calculate max azimuth gap threshold based on radial spacing
216 // Use 1.5x the expected spacing to allow for minor gaps while rejecting large ones
217 let azimuth_spacing = first_radial.azimuth_spacing_degrees();
218 let max_azimuth_gap = azimuth_spacing * 1.5;
219
220 let center_x = width as f64 / 2.0;
221 let center_y = height as f64 / 2.0;
222 let scale_factor = width.max(height) as f64 / 2.0 / radar_range_km;
223
224 // Render each pixel by mapping to radar coordinates
225 for y in 0..height {
226 let dy = y as f64 - center_y;
227
228 for x in 0..width {
229 let dx = x as f64 - center_x;
230
231 // Convert pixel position to distance in km
232 let distance_pixels = (dx * dx + dy * dy).sqrt();
233 let distance_km = distance_pixels / scale_factor;
234
235 // Skip pixels outside radar coverage
236 if distance_km < first_gate_km || distance_km >= radar_range_km {
237 continue;
238 }
239
240 // Calculate azimuth angle (0 = North, clockwise)
241 let azimuth_rad = dx.atan2(-dy);
242 let azimuth_deg = (azimuth_rad.to_degrees() + 360.0) % 360.0;
243
244 // Find the closest radial and check if it's within acceptable range
245 let (radial_idx, angular_distance) =
246 find_closest_radial(&sorted_azimuths, azimuth_deg as f32);
247
248 // Skip pixels where no radial is close enough (partial sweep gaps)
249 if angular_distance > max_azimuth_gap {
250 continue;
251 }
252
253 // Calculate gate index
254 let gate_index = ((distance_km - first_gate_km) / gate_interval_km) as usize;
255 if gate_index >= gate_count {
256 continue;
257 }
258
259 // Look up the value and apply color
260 let (_, ref values) = radial_data[radial_idx];
261 if let Some(MomentValue::Value(value)) = values.get(gate_index) {
262 let color = lut.get_color(*value);
263 let pixel_index = (y * width + x) * 4;
264 buffer[pixel_index..pixel_index + 4].copy_from_slice(&color);
265 }
266 }
267 }
268
269 // Convert buffer to RgbaImage
270 RgbaImage::from_raw(width as u32, height as u32, buffer).ok_or(Error::InvalidDimensions)
271}
272
273/// Renders radar radials using the default color scale for the product.
274///
275/// This is a convenience function that automatically selects an appropriate
276/// color scale based on the product type, using standard meteorological conventions.
277///
278/// # Arguments
279///
280/// * `radials` - Slice of radials to render (typically from a single sweep)
281/// * `product` - The radar product (moment type) to visualize
282/// * `options` - Rendering options (size, background, etc.)
283///
284/// # Errors
285///
286/// Returns an error if:
287/// - No radials are provided
288/// - The requested product is not present in the radials
289///
290/// # Example
291///
292/// ```ignore
293/// use nexrad_render::{render_radials_default, Product, RenderOptions};
294///
295/// let options = RenderOptions::new(800, 800);
296/// let image = render_radials_default(
297/// sweep.radials(),
298/// Product::Velocity,
299/// &options,
300/// ).unwrap();
301///
302/// image.save("velocity.png").unwrap();
303/// ```
304pub fn render_radials_default(
305 radials: &[Radial],
306 product: Product,
307 options: &RenderOptions,
308) -> Result<RgbaImage> {
309 let scale = get_default_scale(product);
310 render_radials(radials, product, &scale, options)
311}
312
313/// Returns the default color scale for a given product.
314///
315/// This function selects an appropriate color scale based on the product type,
316/// using standard meteorological conventions.
317///
318/// | Product | Scale |
319/// |---------|-------|
320/// | Reflectivity | NWS Reflectivity (dBZ) |
321/// | Velocity | Divergent Green-Red (-64 to +64 m/s) |
322/// | SpectrumWidth | Sequential (0 to 30 m/s) |
323/// | DifferentialReflectivity | Divergent (-2 to +6 dB) |
324/// | DifferentialPhase | Sequential (0 to 360 deg) |
325/// | CorrelationCoefficient | Sequential (0 to 1) |
326/// | SpecificDiffPhase | Sequential (0 to 10 deg/km) |
327pub fn get_default_scale(product: Product) -> DiscreteColorScale {
328 match product {
329 Product::Reflectivity => get_nws_reflectivity_scale(),
330 Product::Velocity => get_velocity_scale(),
331 Product::SpectrumWidth => get_spectrum_width_scale(),
332 Product::DifferentialReflectivity => get_differential_reflectivity_scale(),
333 Product::DifferentialPhase => get_differential_phase_scale(),
334 Product::CorrelationCoefficient => get_correlation_coefficient_scale(),
335 Product::SpecificDiffPhase => get_specific_diff_phase_scale(),
336 }
337}
338
339/// Returns the value range (min, max) for a given product.
340///
341/// These ranges cover the typical data values for each product type and are
342/// used internally for color mapping.
343pub fn get_product_value_range(product: Product) -> (f32, f32) {
344 match product {
345 Product::Reflectivity => (-32.0, 95.0),
346 Product::Velocity => (-64.0, 64.0),
347 Product::SpectrumWidth => (0.0, 30.0),
348 Product::DifferentialReflectivity => (-2.0, 6.0),
349 Product::DifferentialPhase => (0.0, 360.0),
350 Product::CorrelationCoefficient => (0.0, 1.0),
351 Product::SpecificDiffPhase => (0.0, 10.0),
352 }
353}
354
355/// Find the index in sorted_azimuths closest to the given azimuth and return
356/// the angular distance to that radial.
357///
358/// Returns `(index, angular_distance)` where `angular_distance` is in degrees.
359#[inline]
360fn find_closest_radial(sorted_azimuths: &[f32], azimuth: f32) -> (usize, f32) {
361 let len = sorted_azimuths.len();
362 if len == 0 {
363 return (0, f32::MAX);
364 }
365
366 // Binary search for insertion point
367 let pos = sorted_azimuths
368 .binary_search_by(|a| a.partial_cmp(&azimuth).unwrap_or(std::cmp::Ordering::Equal))
369 .unwrap_or_else(|i| i);
370
371 if pos == 0 {
372 // Check if wrapping around (360° boundary) is closer
373 let dist_to_first = (sorted_azimuths[0] - azimuth).abs();
374 let dist_to_last = 360.0 - sorted_azimuths[len - 1] + azimuth;
375 if dist_to_last < dist_to_first {
376 return (len - 1, dist_to_last);
377 }
378 return (0, dist_to_first);
379 }
380
381 if pos >= len {
382 // Check if wrapping around is closer
383 let dist_to_last = (azimuth - sorted_azimuths[len - 1]).abs();
384 let dist_to_first = 360.0 - azimuth + sorted_azimuths[0];
385 if dist_to_first < dist_to_last {
386 return (0, dist_to_first);
387 }
388 return (len - 1, dist_to_last);
389 }
390
391 // Compare distances to neighbors
392 let dist_to_prev = (azimuth - sorted_azimuths[pos - 1]).abs();
393 let dist_to_curr = (sorted_azimuths[pos] - azimuth).abs();
394
395 if dist_to_prev <= dist_to_curr {
396 (pos - 1, dist_to_prev)
397 } else {
398 (pos, dist_to_curr)
399 }
400}
401
402/// Retrieve the moment data from a radial for the given product.
403fn get_radial_moment(product: Product, radial: &Radial) -> Option<&MomentData> {
404 match product {
405 Product::Reflectivity => radial.reflectivity(),
406 Product::Velocity => radial.velocity(),
407 Product::SpectrumWidth => radial.spectrum_width(),
408 Product::DifferentialReflectivity => radial.differential_reflectivity(),
409 Product::DifferentialPhase => radial.differential_phase(),
410 Product::CorrelationCoefficient => radial.correlation_coefficient(),
411 Product::SpecificDiffPhase => radial.specific_differential_phase(),
412 }
413}