Skip to main content

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_model::data::Product;
11//! use nexrad_render::{render_radials, RenderOptions, nws_reflectivity_scale};
12//!
13//! let options = RenderOptions::new(800, 800);
14//! let image = render_radials(
15//!     sweep.radials(),
16//!     Product::Reflectivity,
17//!     &nws_reflectivity_scale(),
18//!     &options,
19//! ).unwrap();
20//!
21//! // Save directly to PNG
22//! image.save("radar.png").unwrap();
23//! ```
24//!
25//! # Crate Boundaries
26//!
27//! This crate provides **visualization and rendering** with the following responsibilities
28//! and constraints:
29//!
30//! ## Responsibilities
31//!
32//! - Render radar data to images ([`image::RgbaImage`])
33//! - Apply color scales to moment data
34//! - Handle geometric transformations (polar to Cartesian coordinates)
35//! - Consume `nexrad-model` types (Radial, MomentData)
36//!
37//! ## Constraints
38//!
39//! - **No data access or network operations**
40//! - **No binary parsing or decoding**
41//!
42//! This crate can be used standalone or through the `nexrad` facade crate (re-exported
43//! via the `render` feature, which is enabled by default).
44
45#![forbid(unsafe_code)]
46#![deny(clippy::unwrap_used)]
47#![deny(clippy::expect_used)]
48#![warn(clippy::correctness)]
49#![deny(missing_docs)]
50
51pub use image::RgbaImage;
52pub use nexrad_model::data::Product;
53use nexrad_model::data::{
54    CFPMomentValue, CartesianField, DataMoment, GateStatus, MomentValue, Radial, SweepField,
55    VerticalField,
56};
57use nexrad_model::geo::{GeoExtent, RadarCoordinateSystem};
58use result::{Error, Result};
59
60mod color;
61pub use crate::color::*;
62
63mod render_result;
64pub use render_result::{PointQuery, RenderMetadata, RenderResult};
65
66pub mod result;
67
68/// Interpolation method for rendering radar data.
69///
70/// Controls how pixel values are sampled from the underlying data grid.
71/// The default is [`Nearest`](Interpolation::Nearest) for backward compatibility.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
73pub enum Interpolation {
74    /// Nearest-neighbor sampling (fastest, produces blocky/aliased output).
75    ///
76    /// Each output pixel takes the value of the single closest data gate.
77    /// This is the legacy behavior and the default.
78    #[default]
79    Nearest,
80    /// Bilinear interpolation (smoother, anti-aliased output).
81    ///
82    /// Each output pixel blends the four surrounding data gates using
83    /// bilinear weights. Interpolation happens in **data value space**
84    /// (the f32 moment value), not in color space, ensuring scientifically
85    /// correct results.
86    ///
87    /// Falls back to nearest-neighbor for any pixel where one or more of
88    /// the four neighbors has a non-[`Valid`](GateStatus::Valid) status.
89    Bilinear,
90}
91
92/// Options for rendering radar radials.
93///
94/// Use the builder methods to configure rendering options, then pass to
95/// [`render_radials`].
96///
97/// # Example
98///
99/// ```
100/// use nexrad_render::RenderOptions;
101///
102/// // Render 800x800 with black background (default)
103/// let options = RenderOptions::new(800, 800);
104///
105/// // Render with transparent background for compositing
106/// let options = RenderOptions::new(800, 800).transparent();
107///
108/// // Render with custom background color (RGBA)
109/// let options = RenderOptions::new(800, 800).with_background([255, 255, 255, 255]);
110/// ```
111#[derive(Debug, Clone)]
112pub struct RenderOptions {
113    pub(crate) size: (usize, usize),
114    pub(crate) background: Option<[u8; 4]>,
115    pub(crate) extent: Option<GeoExtent>,
116    pub(crate) coord_system: Option<RadarCoordinateSystem>,
117    pub(crate) interpolation: Interpolation,
118}
119
120impl RenderOptions {
121    /// Creates new render options with the specified dimensions and black background.
122    pub fn new(width: usize, height: usize) -> Self {
123        Self {
124            size: (width, height),
125            background: Some([0, 0, 0, 255]),
126            extent: None,
127            coord_system: None,
128            interpolation: Interpolation::Nearest,
129        }
130    }
131
132    /// Sets the background to transparent for compositing.
133    ///
134    /// When rendering with a transparent background, areas without radar data
135    /// will be fully transparent, allowing multiple renders to be layered.
136    pub fn transparent(mut self) -> Self {
137        self.background = None;
138        self
139    }
140
141    /// Sets a custom background color as RGBA bytes.
142    pub fn with_background(mut self, rgba: [u8; 4]) -> Self {
143        self.background = Some(rgba);
144        self
145    }
146
147    /// Sets a geographic extent for the rendered area.
148    ///
149    /// When set, the image covers exactly this extent. This enables
150    /// consistent spatial mapping for side-by-side comparison of multiple renders.
151    pub fn with_extent(mut self, extent: GeoExtent) -> Self {
152        self.extent = Some(extent);
153        self
154    }
155
156    /// Sets a radar coordinate system for geographic projection.
157    ///
158    /// This enables geographic metadata in the [`RenderResult`], including
159    /// pixel-to-geo and geo-to-pixel coordinate conversions.
160    pub fn with_coord_system(mut self, coord_system: RadarCoordinateSystem) -> Self {
161        self.coord_system = Some(coord_system);
162        self
163    }
164
165    /// Creates render options sized for native resolution of a sweep field.
166    ///
167    /// Sets both width and height to `gate_count * 2`, which ensures approximately
168    /// one pixel per gate at the outer edge of the sweep. This produces the highest
169    /// fidelity rendering without wasting pixels.
170    pub fn native_for(field: &SweepField) -> Self {
171        let size = field.gate_count() * 2;
172        Self::new(size, size)
173    }
174
175    /// Sets the interpolation method for rendering.
176    ///
177    /// Default is [`Interpolation::Nearest`]. Use [`Interpolation::Bilinear`]
178    /// for smoother output that blends neighboring data gates.
179    pub fn with_interpolation(mut self, interpolation: Interpolation) -> Self {
180        self.interpolation = interpolation;
181        self
182    }
183
184    /// Shorthand to enable bilinear interpolation.
185    ///
186    /// Equivalent to `.with_interpolation(Interpolation::Bilinear)`.
187    pub fn bilinear(mut self) -> Self {
188        self.interpolation = Interpolation::Bilinear;
189        self
190    }
191
192    /// Output image dimensions (width, height) in pixels.
193    pub fn size(&self) -> (usize, usize) {
194        self.size
195    }
196
197    /// Background color as RGBA bytes. `None` means transparent (all zeros).
198    pub fn background(&self) -> Option<[u8; 4]> {
199        self.background
200    }
201
202    /// Geographic extent to render. If `None`, auto-computed from data range.
203    ///
204    /// When set, the image covers exactly this extent, enabling consistent
205    /// spatial mapping across multiple renders for side-by-side comparison.
206    pub fn extent(&self) -> Option<&GeoExtent> {
207        self.extent.as_ref()
208    }
209
210    /// Radar coordinate system for geographic projection.
211    ///
212    /// When provided, the [`RenderResult`] will include geographic metadata
213    /// enabling pixel-to-geo and geo-to-pixel coordinate conversions.
214    pub fn coord_system(&self) -> Option<&RadarCoordinateSystem> {
215        self.coord_system.as_ref()
216    }
217
218    /// Interpolation method for pixel sampling.
219    ///
220    /// Default is [`Interpolation::Nearest`]. Use [`Interpolation::Bilinear`]
221    /// for smoother output that blends neighboring data gates.
222    pub fn interpolation(&self) -> Interpolation {
223        self.interpolation
224    }
225}
226
227/// Renders radar radials to an RGBA image.
228///
229/// Converts polar radar data into a Cartesian image representation. Each radial's
230/// moment values are mapped to colors using the provided color scale, producing
231/// a centered radar image with North at the top.
232///
233/// # Arguments
234///
235/// * `radials` - Slice of radials to render (typically from a single sweep)
236/// * `product` - The radar product (moment type) to visualize
237/// * `scale` - Color scale mapping moment values to colors
238/// * `options` - Rendering options (size, background, etc.)
239///
240/// # Errors
241///
242/// Returns an error if:
243/// - No radials are provided
244/// - The requested product is not present in the radials
245///
246/// # Example
247///
248/// ```ignore
249/// use nexrad_render::{render_radials, Product, RenderOptions, nws_reflectivity_scale};
250///
251/// let scale = nws_reflectivity_scale();
252/// let options = RenderOptions::new(800, 800);
253///
254/// let image = render_radials(
255///     sweep.radials(),
256///     Product::Reflectivity,
257///     &scale,
258///     &options,
259/// ).unwrap();
260///
261/// image.save("radar.png").unwrap();
262/// ```
263pub fn render_radials(
264    radials: &[Radial],
265    product: Product,
266    scale: &DiscreteColorScale,
267    options: &RenderOptions,
268) -> Result<RgbaImage> {
269    let (width, height) = options.size;
270    let mut buffer = vec![0u8; width * height * 4];
271
272    // Fill background
273    if let Some(bg) = options.background {
274        for pixel in buffer.chunks_exact_mut(4) {
275            pixel.copy_from_slice(&bg);
276        }
277    }
278
279    if radials.is_empty() {
280        return Err(Error::NoRadials);
281    }
282
283    // Build lookup table for fast color mapping
284    let (min_val, max_val) = product.value_range();
285    let lut = ColorLookupTable::from_scale(scale, min_val, max_val, 256);
286
287    // Get radar parameters from the first radial
288    let first_radial = &radials[0];
289    let gate_params = get_gate_params(product, first_radial).ok_or(Error::ProductNotFound)?;
290    let first_gate_km = gate_params.first_gate_km;
291    let gate_interval_km = gate_params.gate_interval_km;
292    let gate_count = gate_params.gate_count;
293    let radar_range_km = first_gate_km + gate_count as f64 * gate_interval_km;
294
295    // Pre-extract all moment float values indexed by azimuth for efficient lookup
296    let mut radial_data: Vec<(f32, Vec<Option<f32>>)> = Vec::with_capacity(radials.len());
297    for radial in radials {
298        let azimuth = radial.azimuth_angle_degrees();
299        if let Some(values) = get_radial_float_values(product, radial) {
300            radial_data.push((azimuth, values));
301        }
302    }
303
304    // Sort by azimuth for binary search
305    radial_data.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
306
307    // Extract azimuths for binary search
308    let sorted_azimuths: Vec<f32> = radial_data.iter().map(|(az, _)| *az).collect();
309
310    // Calculate max azimuth gap threshold based on radial spacing
311    // Use 1.5x the expected spacing to allow for minor gaps while rejecting large ones
312    let azimuth_spacing = first_radial.azimuth_spacing_degrees();
313    let max_azimuth_gap = azimuth_spacing * 1.5;
314
315    let center_x = width as f64 / 2.0;
316    let center_y = height as f64 / 2.0;
317    let scale_factor = width.max(height) as f64 / 2.0 / radar_range_km;
318
319    // Render each pixel by mapping to radar coordinates
320    for y in 0..height {
321        let dy = y as f64 - center_y;
322
323        for x in 0..width {
324            let dx = x as f64 - center_x;
325
326            // Convert pixel position to distance in km
327            let distance_pixels = (dx * dx + dy * dy).sqrt();
328            let distance_km = distance_pixels / scale_factor;
329
330            // Skip pixels outside radar coverage
331            if distance_km < first_gate_km || distance_km >= radar_range_km {
332                continue;
333            }
334
335            // Calculate azimuth angle (0 = North, clockwise)
336            let azimuth_rad = dx.atan2(-dy);
337            let azimuth_deg = (azimuth_rad.to_degrees() + 360.0) % 360.0;
338
339            // Find the closest radial and check if it's within acceptable range
340            let (radial_idx, angular_distance) =
341                find_closest_radial(&sorted_azimuths, azimuth_deg as f32);
342
343            // Skip pixels where no radial is close enough (partial sweep gaps)
344            if angular_distance > max_azimuth_gap {
345                continue;
346            }
347
348            // Calculate gate index
349            let gate_index = ((distance_km - first_gate_km) / gate_interval_km) as usize;
350            if gate_index >= gate_count {
351                continue;
352            }
353
354            // Look up the value and apply color
355            let (_, ref values) = radial_data[radial_idx];
356            if let Some(Some(value)) = values.get(gate_index) {
357                let color = lut.color(*value);
358                let pixel_index = (y * width + x) * 4;
359                buffer[pixel_index..pixel_index + 4].copy_from_slice(&color);
360            }
361        }
362    }
363
364    // Convert buffer to RgbaImage
365    RgbaImage::from_raw(width as u32, height as u32, buffer).ok_or(Error::InvalidDimensions)
366}
367
368/// Renders radar radials using the default color scale for the product.
369///
370/// This is a convenience function that automatically selects an appropriate
371/// color scale based on the product type, using standard meteorological conventions.
372///
373/// # Arguments
374///
375/// * `radials` - Slice of radials to render (typically from a single sweep)
376/// * `product` - The radar product (moment type) to visualize
377/// * `options` - Rendering options (size, background, etc.)
378///
379/// # Errors
380///
381/// Returns an error if:
382/// - No radials are provided
383/// - The requested product is not present in the radials
384///
385/// # Example
386///
387/// ```ignore
388/// use nexrad_render::{render_radials_default, Product, RenderOptions};
389///
390/// let options = RenderOptions::new(800, 800);
391/// let image = render_radials_default(
392///     sweep.radials(),
393///     Product::Velocity,
394///     &options,
395/// ).unwrap();
396///
397/// image.save("velocity.png").unwrap();
398/// ```
399pub fn render_radials_default(
400    radials: &[Radial],
401    product: Product,
402    options: &RenderOptions,
403) -> Result<RgbaImage> {
404    let scale = default_scale(product);
405    render_radials(radials, product, &scale, options)
406}
407
408/// Renders a [`SweepField`] (polar grid) to an image with metadata.
409///
410/// This is the primary rendering entry point for processed data. It accepts
411/// a `SweepField` — the output of data extraction or processing — and produces
412/// a [`RenderResult`] that includes both the rendered image and metadata for
413/// geographic placement and data inspection.
414///
415/// # Arguments
416///
417/// * `field` - The sweep field to render
418/// * `color_scale` - Color scale to apply (discrete or continuous)
419/// * `options` - Rendering options (size, background, extent, coordinate system)
420///
421/// # Example
422///
423/// ```ignore
424/// use nexrad_render::{render_sweep, RenderOptions, ColorScale};
425/// use nexrad_model::data::{SweepField, Product};
426///
427/// let field = SweepField::from_radials(sweep.radials(), Product::Reflectivity).unwrap();
428/// let scale = ColorScale::from(nexrad_render::nws_reflectivity_scale());
429/// let result = render_sweep(&field, &scale, &RenderOptions::new(800, 800))?;
430///
431/// result.image().save("radar.png").unwrap();
432/// let meta = result.metadata();
433/// let ring_px = meta.km_to_pixel_distance(100.0);
434/// ```
435pub fn render_sweep(
436    field: &SweepField,
437    color_scale: &ColorScale,
438    options: &RenderOptions,
439) -> Result<RenderResult> {
440    let (width, height) = options.size;
441    let mut buffer = vec![0u8; width * height * 4];
442
443    // Fill background
444    if let Some(bg) = options.background {
445        for pixel in buffer.chunks_exact_mut(4) {
446            pixel.copy_from_slice(&bg);
447        }
448    }
449
450    if field.azimuth_count() == 0 {
451        return Err(Error::NoRadials);
452    }
453
454    let first_gate_km = field.first_gate_range_km();
455    let gate_interval_km = field.gate_interval_km();
456    let gate_count = field.gate_count();
457    let radar_range_km = field.max_range_km();
458
459    // Build LUT from the field's value range or product-typical range
460    let (min_val, max_val) = field.value_range().unwrap_or((-32.0, 95.0));
461    let lut = ColorLookupTable::from_color_scale(color_scale, min_val, max_val, 256);
462
463    // Calculate max azimuth gap for partial sweep support
464    let azimuth_spacing = field.azimuth_spacing_degrees();
465    let max_azimuth_gap = azimuth_spacing * 1.5;
466
467    let center_x = width as f64 / 2.0;
468    let center_y = height as f64 / 2.0;
469    let scale_factor = width.max(height) as f64 / 2.0 / radar_range_km;
470
471    // Render each pixel by mapping to radar coordinates
472    let use_bilinear = options.interpolation == Interpolation::Bilinear;
473
474    for y in 0..height {
475        let dy = y as f64 - center_y;
476
477        for x in 0..width {
478            let dx = x as f64 - center_x;
479
480            let distance_pixels = (dx * dx + dy * dy).sqrt();
481            let distance_km = distance_pixels / scale_factor;
482
483            if distance_km < first_gate_km || distance_km >= radar_range_km {
484                continue;
485            }
486
487            let azimuth_rad = dx.atan2(-dy);
488            let azimuth_deg = ((azimuth_rad.to_degrees() + 360.0) % 360.0) as f32;
489
490            // Try bilinear interpolation first, then fall back to nearest-neighbor
491            if use_bilinear {
492                if let Some(val) =
493                    sample_sweep_bilinear(field, azimuth_deg, distance_km, max_azimuth_gap)
494                {
495                    let color = lut.color(val);
496                    let pixel_index = (y * width + x) * 4;
497                    buffer[pixel_index..pixel_index + 4].copy_from_slice(&color);
498                    continue;
499                }
500            }
501
502            // Nearest-neighbor path (default, or bilinear fallback)
503            let (radial_idx, angular_distance) = find_closest_radial(field.azimuths(), azimuth_deg);
504
505            if angular_distance > max_azimuth_gap {
506                continue;
507            }
508
509            let gate_index = ((distance_km - first_gate_km) / gate_interval_km) as usize;
510            if gate_index >= gate_count {
511                continue;
512            }
513
514            let (val, status) = field.get(radial_idx, gate_index);
515            if status == GateStatus::Valid {
516                let color = lut.color(val);
517                let pixel_index = (y * width + x) * 4;
518                buffer[pixel_index..pixel_index + 4].copy_from_slice(&color);
519            }
520        }
521    }
522
523    let image =
524        RgbaImage::from_raw(width as u32, height as u32, buffer).ok_or(Error::InvalidDimensions)?;
525
526    let geo_extent = options.extent.or_else(|| {
527        options
528            .coord_system
529            .as_ref()
530            .map(|cs| cs.sweep_extent(radar_range_km))
531    });
532
533    let metadata = RenderMetadata {
534        width: width as u32,
535        height: height as u32,
536        center_pixel: (center_x, center_y),
537        pixels_per_km: scale_factor,
538        max_range_km: radar_range_km,
539        elevation_degrees: Some(field.elevation_degrees()),
540        geo_extent,
541        coord_system: options.coord_system.clone(),
542    };
543
544    Ok(RenderResult::new(image, metadata))
545}
546
547/// Renders a [`CartesianField`] (geographic grid) to an image with metadata.
548///
549/// This renders volume-derived products like composite reflectivity, echo tops,
550/// and VIL — data that is already projected onto a geographic grid.
551///
552/// # Arguments
553///
554/// * `field` - The Cartesian field to render
555/// * `color_scale` - Color scale to apply
556/// * `options` - Rendering options (size, background)
557pub fn render_cartesian(
558    field: &CartesianField,
559    color_scale: &ColorScale,
560    options: &RenderOptions,
561) -> Result<RenderResult> {
562    let (width, height) = options.size;
563    let mut buffer = vec![0u8; width * height * 4];
564
565    if let Some(bg) = options.background {
566        for pixel in buffer.chunks_exact_mut(4) {
567            pixel.copy_from_slice(&bg);
568        }
569    }
570
571    let field_width = field.width();
572    let field_height = field.height();
573
574    if field_width == 0 || field_height == 0 {
575        return Err(Error::NoRadials);
576    }
577
578    // Build LUT
579    let (min_val, max_val) = field.value_range().unwrap_or((-32.0, 95.0));
580    let lut = ColorLookupTable::from_color_scale(color_scale, min_val, max_val, 256);
581
582    // Map output pixels to field cells
583    let use_bilinear = options.interpolation == Interpolation::Bilinear;
584    let max_row = field_height - 1;
585    let max_col = field_width - 1;
586
587    for y in 0..height {
588        let row_f = y as f64 / height as f64 * field_height as f64 - 0.5;
589        let row_f = row_f.max(0.0);
590
591        for x in 0..width {
592            let col_f = x as f64 / width as f64 * field_width as f64 - 0.5;
593            let col_f = col_f.max(0.0);
594
595            // Try bilinear interpolation first, then fall back to nearest-neighbor
596            if use_bilinear {
597                if let Some(val) =
598                    sample_grid_bilinear(row_f, col_f, max_row, max_col, |r, c| field.get(r, c))
599                {
600                    let color = lut.color(val);
601                    let pixel_index = (y * width + x) * 4;
602                    buffer[pixel_index..pixel_index + 4].copy_from_slice(&color);
603                    continue;
604                }
605            }
606
607            // Nearest-neighbor path (default, or bilinear fallback)
608            let field_row = (row_f + 0.5) as usize;
609            let field_row = field_row.min(max_row);
610            let field_col = (col_f + 0.5) as usize;
611            let field_col = field_col.min(max_col);
612
613            let (val, status) = field.get(field_row, field_col);
614            if status == GateStatus::Valid {
615                let color = lut.color(val);
616                let pixel_index = (y * width + x) * 4;
617                buffer[pixel_index..pixel_index + 4].copy_from_slice(&color);
618            }
619        }
620    }
621
622    let image =
623        RgbaImage::from_raw(width as u32, height as u32, buffer).ok_or(Error::InvalidDimensions)?;
624
625    let metadata = RenderMetadata {
626        width: width as u32,
627        height: height as u32,
628        center_pixel: (width as f64 / 2.0, height as f64 / 2.0),
629        pixels_per_km: 0.0, // Not applicable for Cartesian fields
630        max_range_km: 0.0,
631        elevation_degrees: None,
632        geo_extent: Some(*field.extent()),
633        coord_system: options.coord_system.clone(),
634    };
635
636    Ok(RenderResult::new(image, metadata))
637}
638
639/// Renders a [`VerticalField`] (RHI / cross-section display) to an image with metadata.
640///
641/// This renders vertical cross-sections where the horizontal axis represents
642/// distance from the radar and the vertical axis represents altitude.
643///
644/// # Arguments
645///
646/// * `field` - The vertical field to render
647/// * `color_scale` - Color scale to apply
648/// * `options` - Rendering options (size, background)
649pub fn render_vertical(
650    field: &VerticalField,
651    color_scale: &ColorScale,
652    options: &RenderOptions,
653) -> Result<RenderResult> {
654    let (width, height) = options.size;
655    let mut buffer = vec![0u8; width * height * 4];
656
657    if let Some(bg) = options.background {
658        for pixel in buffer.chunks_exact_mut(4) {
659            pixel.copy_from_slice(&bg);
660        }
661    }
662
663    let field_width = field.width();
664    let field_height = field.height();
665
666    if field_width == 0 || field_height == 0 {
667        return Err(Error::NoRadials);
668    }
669
670    // Build LUT
671    let (min_val, max_val) = field.value_range().unwrap_or((-32.0, 95.0));
672    let lut = ColorLookupTable::from_color_scale(color_scale, min_val, max_val, 256);
673
674    // Map output pixels to field cells
675    let use_bilinear = options.interpolation == Interpolation::Bilinear;
676    let max_row = field_height - 1;
677    let max_col = field_width - 1;
678
679    for y in 0..height {
680        let row_f = y as f64 / height as f64 * field_height as f64 - 0.5;
681        let row_f = row_f.max(0.0);
682
683        for x in 0..width {
684            let col_f = x as f64 / width as f64 * field_width as f64 - 0.5;
685            let col_f = col_f.max(0.0);
686
687            // Try bilinear interpolation first, then fall back to nearest-neighbor
688            if use_bilinear {
689                if let Some(val) =
690                    sample_grid_bilinear(row_f, col_f, max_row, max_col, |r, c| field.get(r, c))
691                {
692                    let color = lut.color(val);
693                    let pixel_index = (y * width + x) * 4;
694                    buffer[pixel_index..pixel_index + 4].copy_from_slice(&color);
695                    continue;
696                }
697            }
698
699            // Nearest-neighbor path (default, or bilinear fallback)
700            let field_row = (row_f + 0.5) as usize;
701            let field_row = field_row.min(max_row);
702            let field_col = (col_f + 0.5) as usize;
703            let field_col = field_col.min(max_col);
704
705            let (val, status) = field.get(field_row, field_col);
706            if status == GateStatus::Valid {
707                let color = lut.color(val);
708                let pixel_index = (y * width + x) * 4;
709                buffer[pixel_index..pixel_index + 4].copy_from_slice(&color);
710            }
711        }
712    }
713
714    let image =
715        RgbaImage::from_raw(width as u32, height as u32, buffer).ok_or(Error::InvalidDimensions)?;
716
717    let (d_min, d_max) = field.distance_range_km();
718    let max_range = d_max - d_min;
719
720    let metadata = RenderMetadata {
721        width: width as u32,
722        height: height as u32,
723        center_pixel: (0.0, height as f64 / 2.0), // Left edge is range 0
724        pixels_per_km: width as f64 / max_range,
725        max_range_km: max_range,
726        elevation_degrees: None,
727        geo_extent: None,
728        coord_system: options.coord_system.clone(),
729    };
730
731    Ok(RenderResult::new(image, metadata))
732}
733
734/// Returns the default color scale for a given product.
735///
736/// This function selects an appropriate color scale based on the product type,
737/// using standard meteorological conventions.
738///
739/// | Product | Scale |
740/// |---------|-------|
741/// | Reflectivity | NWS Reflectivity (dBZ) |
742/// | Velocity | Divergent Green-Red (-64 to +64 m/s) |
743/// | SpectrumWidth | Sequential (0 to 30 m/s) |
744/// | DifferentialReflectivity | Divergent (-2 to +6 dB) |
745/// | DifferentialPhase | Sequential (0 to 360 deg) |
746/// | CorrelationCoefficient | Sequential (0 to 1) |
747/// | ClutterFilterPower | Divergent (-20 to +20 dB) |
748pub fn default_scale(product: Product) -> DiscreteColorScale {
749    match product {
750        Product::Reflectivity => nws_reflectivity_scale(),
751        Product::Velocity => velocity_scale(),
752        Product::SpectrumWidth => spectrum_width_scale(),
753        Product::DifferentialReflectivity => differential_reflectivity_scale(),
754        Product::DifferentialPhase => differential_phase_scale(),
755        Product::CorrelationCoefficient => correlation_coefficient_scale(),
756        Product::ClutterFilterPower => clutter_filter_power_scale(),
757    }
758}
759
760/// Returns the default color scale for a product, wrapped in a [`ColorScale`] enum.
761///
762/// This is a convenience wrapper around [`default_scale`] that returns the
763/// [`ColorScale`] enum directly, avoiding the need to call `.into()` at each call site.
764pub fn default_color_scale(product: Product) -> ColorScale {
765    default_scale(product).into()
766}
767
768/// Find the index in sorted_azimuths closest to the given azimuth and return
769/// the angular distance to that radial.
770///
771/// Returns `(index, angular_distance)` where `angular_distance` is in degrees.
772#[inline]
773fn find_closest_radial(sorted_azimuths: &[f32], azimuth: f32) -> (usize, f32) {
774    let len = sorted_azimuths.len();
775    if len == 0 {
776        return (0, f32::MAX);
777    }
778
779    // Binary search for insertion point
780    let pos = sorted_azimuths
781        .binary_search_by(|a| a.partial_cmp(&azimuth).unwrap_or(std::cmp::Ordering::Equal))
782        .unwrap_or_else(|i| i);
783
784    if pos == 0 {
785        // Check if wrapping around (360° boundary) is closer
786        let dist_to_first = (sorted_azimuths[0] - azimuth).abs();
787        let dist_to_last = 360.0 - sorted_azimuths[len - 1] + azimuth;
788        if dist_to_last < dist_to_first {
789            return (len - 1, dist_to_last);
790        }
791        return (0, dist_to_first);
792    }
793
794    if pos >= len {
795        // Check if wrapping around is closer
796        let dist_to_last = (azimuth - sorted_azimuths[len - 1]).abs();
797        let dist_to_first = 360.0 - azimuth + sorted_azimuths[0];
798        if dist_to_first < dist_to_last {
799            return (0, dist_to_first);
800        }
801        return (len - 1, dist_to_last);
802    }
803
804    // Compare distances to neighbors
805    let dist_to_prev = (azimuth - sorted_azimuths[pos - 1]).abs();
806    let dist_to_curr = (sorted_azimuths[pos] - azimuth).abs();
807
808    if dist_to_prev <= dist_to_curr {
809        (pos - 1, dist_to_prev)
810    } else {
811        (pos, dist_to_curr)
812    }
813}
814
815/// Find the two radials bracketing the given azimuth and the fractional position between them.
816///
817/// Returns `Some((lo_idx, hi_idx, frac))` where:
818/// - `lo_idx` is the index of the radial at or just below `azimuth`
819/// - `hi_idx` is the index of the radial just above `azimuth`
820/// - `frac` is in \[0, 1\] — the fractional distance from lo toward hi
821///
822/// Returns `None` if there are fewer than 2 radials, or if the angular gap between
823/// the two bracketing radials exceeds `max_gap` degrees (partial sweep boundary).
824#[inline]
825fn find_bracketing_radials(
826    sorted_azimuths: &[f32],
827    azimuth: f32,
828    max_gap: f32,
829) -> Option<(usize, usize, f32)> {
830    let len = sorted_azimuths.len();
831    if len < 2 {
832        return None;
833    }
834
835    let pos = sorted_azimuths
836        .binary_search_by(|a| a.partial_cmp(&azimuth).unwrap_or(std::cmp::Ordering::Equal))
837        .unwrap_or_else(|i| i);
838
839    // Determine the bracketing pair (lo, hi) such that lo_az <= azimuth <= hi_az
840    // with 360-degree wraparound at the edges.
841    let (lo_idx, hi_idx) = if pos == 0 || pos >= len {
842        // Wraps around: last azimuth → first azimuth
843        (len - 1, 0)
844    } else {
845        (pos - 1, pos)
846    };
847
848    let lo_az = sorted_azimuths[lo_idx];
849    let hi_az = sorted_azimuths[hi_idx];
850
851    // Angular span with wraparound
852    let span = if hi_az >= lo_az {
853        hi_az - lo_az
854    } else {
855        360.0 - lo_az + hi_az
856    };
857
858    if span > max_gap || span == 0.0 {
859        return None;
860    }
861
862    // Fractional position of azimuth between lo and hi
863    let offset = if azimuth >= lo_az {
864        azimuth - lo_az
865    } else {
866        360.0 - lo_az + azimuth
867    };
868
869    let frac = (offset / span).clamp(0.0, 1.0);
870    Some((lo_idx, hi_idx, frac))
871}
872
873/// Sample a sweep field using bilinear interpolation in data-value space.
874///
875/// Returns the interpolated f32 value, or `None` if any of the four neighbors
876/// is non-Valid, the pixel falls in a sweep gap, or the coordinates are at the
877/// outer edge where interpolation is not possible.
878#[inline]
879fn sample_sweep_bilinear(
880    field: &SweepField,
881    azimuth_deg: f32,
882    distance_km: f64,
883    max_azimuth_gap: f32,
884) -> Option<f32> {
885    let gate_count = field.gate_count();
886    let first_gate_km = field.first_gate_range_km();
887    let gate_interval_km = field.gate_interval_km();
888
889    // Azimuth axis: find bracketing radials
890    let (az_lo, az_hi, az_frac) =
891        find_bracketing_radials(field.azimuths(), azimuth_deg, max_azimuth_gap)?;
892
893    // Range axis: find bracketing gates
894    let gate_f = (distance_km - first_gate_km) / gate_interval_km;
895    let g_lo = gate_f as usize;
896    let g_hi = g_lo + 1;
897
898    if g_hi >= gate_count {
899        return None;
900    }
901
902    let g_frac = (gate_f - g_lo as f64) as f32;
903
904    // Fetch the four corners and require all Valid
905    let (v00, s00) = field.get(az_lo, g_lo);
906    let (v01, s01) = field.get(az_lo, g_hi);
907    let (v10, s10) = field.get(az_hi, g_lo);
908    let (v11, s11) = field.get(az_hi, g_hi);
909
910    if s00 != GateStatus::Valid
911        || s01 != GateStatus::Valid
912        || s10 != GateStatus::Valid
913        || s11 != GateStatus::Valid
914    {
915        return None;
916    }
917
918    // Bilinear blend: lerp along range for each azimuth, then across azimuths
919    let v_lo = v00 + (v01 - v00) * g_frac;
920    let v_hi = v10 + (v11 - v10) * g_frac;
921    Some(v_lo + (v_hi - v_lo) * az_frac)
922}
923
924/// Sample a row-major grid using bilinear interpolation in data-value space.
925///
926/// `row_f` and `col_f` are fractional coordinates into the grid.
927/// Returns the interpolated value, or `None` if any of the four neighbors is non-Valid.
928#[inline]
929fn sample_grid_bilinear(
930    row_f: f64,
931    col_f: f64,
932    max_row: usize,
933    max_col: usize,
934    get_fn: impl Fn(usize, usize) -> (f32, GateStatus),
935) -> Option<f32> {
936    let r0 = row_f as usize;
937    let c0 = col_f as usize;
938    let r1 = (r0 + 1).min(max_row);
939    let c1 = (c0 + 1).min(max_col);
940
941    let rf = (row_f - r0 as f64) as f32;
942    let cf = (col_f - c0 as f64) as f32;
943
944    let (v00, s00) = get_fn(r0, c0);
945    let (v01, s01) = get_fn(r0, c1);
946    let (v10, s10) = get_fn(r1, c0);
947    let (v11, s11) = get_fn(r1, c1);
948
949    if s00 != GateStatus::Valid
950        || s01 != GateStatus::Valid
951        || s10 != GateStatus::Valid
952        || s11 != GateStatus::Valid
953    {
954        return None;
955    }
956
957    let v_top = v00 + (v01 - v00) * cf;
958    let v_bot = v10 + (v11 - v10) * cf;
959    Some(v_top + (v_bot - v_top) * rf)
960}
961
962/// Gate metadata extracted from a moment data block.
963struct GateParams {
964    first_gate_km: f64,
965    gate_interval_km: f64,
966    gate_count: usize,
967}
968
969/// Retrieve gate metadata from a radial for the given product.
970fn get_gate_params(product: Product, radial: &Radial) -> Option<GateParams> {
971    fn extract(m: &impl DataMoment) -> GateParams {
972        GateParams {
973            first_gate_km: m.first_gate_range_km(),
974            gate_interval_km: m.gate_interval_km(),
975            gate_count: m.gate_count() as usize,
976        }
977    }
978    if let Some(moment) = product.moment_data(radial) {
979        return Some(extract(moment));
980    }
981    if let Some(cfp) = product.cfp_moment_data(radial) {
982        return Some(extract(cfp));
983    }
984    None
985}
986
987/// Extract decoded float values from a radial for the given product.
988///
989/// Returns `None` for below-threshold, range-folded, and CFP status gates.
990fn get_radial_float_values(product: Product, radial: &Radial) -> Option<Vec<Option<f32>>> {
991    if let Some(moment) = product.moment_data(radial) {
992        return Some(
993            moment
994                .iter()
995                .map(|v| match v {
996                    MomentValue::Value(f) => Some(f),
997                    _ => None,
998                })
999                .collect(),
1000        );
1001    }
1002
1003    if let Some(cfp) = product.cfp_moment_data(radial) {
1004        return Some(
1005            cfp.iter()
1006                .map(|v| match v {
1007                    CFPMomentValue::Value(f) => Some(f),
1008                    CFPMomentValue::Status(_) => None,
1009                })
1010                .collect(),
1011        );
1012    }
1013
1014    None
1015}