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 let center_x = width as f64 / 2.0;
216 let center_y = height as f64 / 2.0;
217 let scale_factor = width.max(height) as f64 / 2.0 / radar_range_km;
218
219 // Render each pixel by mapping to radar coordinates
220 for y in 0..height {
221 let dy = y as f64 - center_y;
222
223 for x in 0..width {
224 let dx = x as f64 - center_x;
225
226 // Convert pixel position to distance in km
227 let distance_pixels = (dx * dx + dy * dy).sqrt();
228 let distance_km = distance_pixels / scale_factor;
229
230 // Skip pixels outside radar coverage
231 if distance_km < first_gate_km || distance_km >= radar_range_km {
232 continue;
233 }
234
235 // Calculate azimuth angle (0 = North, clockwise)
236 let azimuth_rad = dx.atan2(-dy);
237 let azimuth_deg = (azimuth_rad.to_degrees() + 360.0) % 360.0;
238
239 // Find the closest radial
240 let radial_idx = find_closest_radial_index(&sorted_azimuths, azimuth_deg as f32);
241
242 // Calculate gate index
243 let gate_index = ((distance_km - first_gate_km) / gate_interval_km) as usize;
244 if gate_index >= gate_count {
245 continue;
246 }
247
248 // Look up the value and apply color
249 let (_, ref values) = radial_data[radial_idx];
250 if let Some(MomentValue::Value(value)) = values.get(gate_index) {
251 let color = lut.get_color(*value);
252 let pixel_index = (y * width + x) * 4;
253 buffer[pixel_index..pixel_index + 4].copy_from_slice(&color);
254 }
255 }
256 }
257
258 // Convert buffer to RgbaImage
259 RgbaImage::from_raw(width as u32, height as u32, buffer).ok_or(Error::InvalidDimensions)
260}
261
262/// Renders radar radials using the default color scale for the product.
263///
264/// This is a convenience function that automatically selects an appropriate
265/// color scale based on the product type, using standard meteorological conventions.
266///
267/// # Arguments
268///
269/// * `radials` - Slice of radials to render (typically from a single sweep)
270/// * `product` - The radar product (moment type) to visualize
271/// * `options` - Rendering options (size, background, etc.)
272///
273/// # Errors
274///
275/// Returns an error if:
276/// - No radials are provided
277/// - The requested product is not present in the radials
278///
279/// # Example
280///
281/// ```ignore
282/// use nexrad_render::{render_radials_default, Product, RenderOptions};
283///
284/// let options = RenderOptions::new(800, 800);
285/// let image = render_radials_default(
286/// sweep.radials(),
287/// Product::Velocity,
288/// &options,
289/// ).unwrap();
290///
291/// image.save("velocity.png").unwrap();
292/// ```
293pub fn render_radials_default(
294 radials: &[Radial],
295 product: Product,
296 options: &RenderOptions,
297) -> Result<RgbaImage> {
298 let scale = get_default_scale(product);
299 render_radials(radials, product, &scale, options)
300}
301
302/// Returns the default color scale for a given product.
303///
304/// This function selects an appropriate color scale based on the product type,
305/// using standard meteorological conventions.
306///
307/// | Product | Scale |
308/// |---------|-------|
309/// | Reflectivity | NWS Reflectivity (dBZ) |
310/// | Velocity | Divergent Green-Red (-64 to +64 m/s) |
311/// | SpectrumWidth | Sequential (0 to 30 m/s) |
312/// | DifferentialReflectivity | Divergent (-2 to +6 dB) |
313/// | DifferentialPhase | Sequential (0 to 360 deg) |
314/// | CorrelationCoefficient | Sequential (0 to 1) |
315/// | SpecificDiffPhase | Sequential (0 to 10 deg/km) |
316pub fn get_default_scale(product: Product) -> DiscreteColorScale {
317 match product {
318 Product::Reflectivity => get_nws_reflectivity_scale(),
319 Product::Velocity => get_velocity_scale(),
320 Product::SpectrumWidth => get_spectrum_width_scale(),
321 Product::DifferentialReflectivity => get_differential_reflectivity_scale(),
322 Product::DifferentialPhase => get_differential_phase_scale(),
323 Product::CorrelationCoefficient => get_correlation_coefficient_scale(),
324 Product::SpecificDiffPhase => get_specific_diff_phase_scale(),
325 }
326}
327
328/// Returns the value range (min, max) for a given product.
329///
330/// These ranges cover the typical data values for each product type and are
331/// used internally for color mapping.
332pub fn get_product_value_range(product: Product) -> (f32, f32) {
333 match product {
334 Product::Reflectivity => (-32.0, 95.0),
335 Product::Velocity => (-64.0, 64.0),
336 Product::SpectrumWidth => (0.0, 30.0),
337 Product::DifferentialReflectivity => (-2.0, 6.0),
338 Product::DifferentialPhase => (0.0, 360.0),
339 Product::CorrelationCoefficient => (0.0, 1.0),
340 Product::SpecificDiffPhase => (0.0, 10.0),
341 }
342}
343
344/// Find the index in sorted_azimuths closest to the given azimuth.
345#[inline]
346fn find_closest_radial_index(sorted_azimuths: &[f32], azimuth: f32) -> usize {
347 let len = sorted_azimuths.len();
348 if len == 0 {
349 return 0;
350 }
351
352 // Binary search for insertion point
353 let pos = sorted_azimuths
354 .binary_search_by(|a| a.partial_cmp(&azimuth).unwrap_or(std::cmp::Ordering::Equal))
355 .unwrap_or_else(|i| i);
356
357 if pos == 0 {
358 // Check if wrapping around (360° boundary) is closer
359 let dist_to_first = (sorted_azimuths[0] - azimuth).abs();
360 let dist_to_last = 360.0 - sorted_azimuths[len - 1] + azimuth;
361 if dist_to_last < dist_to_first {
362 return len - 1;
363 }
364 return 0;
365 }
366
367 if pos >= len {
368 // Check if wrapping around is closer
369 let dist_to_last = (azimuth - sorted_azimuths[len - 1]).abs();
370 let dist_to_first = 360.0 - azimuth + sorted_azimuths[0];
371 if dist_to_first < dist_to_last {
372 return 0;
373 }
374 return len - 1;
375 }
376
377 // Compare distances to neighbors
378 let dist_to_prev = (azimuth - sorted_azimuths[pos - 1]).abs();
379 let dist_to_curr = (sorted_azimuths[pos] - azimuth).abs();
380
381 if dist_to_prev <= dist_to_curr {
382 pos - 1
383 } else {
384 pos
385 }
386}
387
388/// Retrieve the moment data from a radial for the given product.
389fn get_radial_moment(product: Product, radial: &Radial) -> Option<&MomentData> {
390 match product {
391 Product::Reflectivity => radial.reflectivity(),
392 Product::Velocity => radial.velocity(),
393 Product::SpectrumWidth => radial.spectrum_width(),
394 Product::DifferentialReflectivity => radial.differential_reflectivity(),
395 Product::DifferentialPhase => radial.differential_phase(),
396 Product::CorrelationCoefficient => radial.correlation_coefficient(),
397 Product::SpecificDiffPhase => radial.specific_differential_phase(),
398 }
399}