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}