Skip to main content

nexrad_process/detection/
cell.rs

1use std::collections::VecDeque;
2
3use crate::result::{Error, Result};
4use nexrad_model::data::{GateStatus, SweepField};
5use nexrad_model::geo::{GeoPoint, PolarPoint, RadarCoordinateSystem};
6
7/// Geographic bounding box of a storm cell.
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct StormCellBounds {
10    /// Minimum latitude (southern edge).
11    min_latitude: f64,
12    /// Maximum latitude (northern edge).
13    max_latitude: f64,
14    /// Minimum longitude (western edge).
15    min_longitude: f64,
16    /// Maximum longitude (eastern edge).
17    max_longitude: f64,
18}
19
20impl StormCellBounds {
21    /// Minimum latitude (southern edge).
22    pub fn min_latitude(&self) -> f64 {
23        self.min_latitude
24    }
25
26    /// Maximum latitude (northern edge).
27    pub fn max_latitude(&self) -> f64 {
28        self.max_latitude
29    }
30
31    /// Minimum longitude (western edge).
32    pub fn min_longitude(&self) -> f64 {
33        self.min_longitude
34    }
35
36    /// Maximum longitude (eastern edge).
37    pub fn max_longitude(&self) -> f64 {
38        self.max_longitude
39    }
40}
41
42/// A detected storm cell identified by connected-component analysis on a
43/// reflectivity sweep field.
44///
45/// Each cell represents a contiguous region of radar gates above a configured
46/// reflectivity threshold. Geographic properties are computed using the radar
47/// coordinate system.
48#[derive(Debug, Clone, PartialEq)]
49pub struct StormCell {
50    /// Unique identifier for this cell within the detection run (0-based).
51    id: u32,
52    /// Geographic centroid of the cell, computed as the reflectivity-weighted
53    /// mean of all gate positions.
54    centroid: GeoPoint,
55    /// Maximum reflectivity value (dBZ) observed in this cell.
56    max_reflectivity_dbz: f32,
57    /// Mean reflectivity value (dBZ) across all gates in this cell.
58    mean_reflectivity_dbz: f32,
59    /// Number of polar gates comprising this cell.
60    gate_count: usize,
61    /// Approximate area of the cell in square kilometers.
62    area_km2: f64,
63    /// Geographic bounding box of the cell.
64    bounds: StormCellBounds,
65    /// Elevation angle of the sweep this cell was detected on (degrees).
66    elevation_degrees: f32,
67    /// Azimuth of the gate with maximum reflectivity (degrees).
68    max_reflectivity_azimuth_degrees: f32,
69    /// Range of the gate with maximum reflectivity (km).
70    max_reflectivity_range_km: f64,
71}
72
73impl StormCell {
74    /// Unique identifier for this cell within the detection run.
75    ///
76    /// Cells are sorted by descending maximum reflectivity, so ID 0 is the
77    /// strongest cell.
78    pub fn id(&self) -> u32 {
79        self.id
80    }
81
82    /// Geographic centroid of the cell (reflectivity-weighted).
83    pub fn centroid(&self) -> GeoPoint {
84        self.centroid
85    }
86
87    /// Maximum reflectivity value in dBZ.
88    pub fn max_reflectivity_dbz(&self) -> f32 {
89        self.max_reflectivity_dbz
90    }
91
92    /// Mean reflectivity value in dBZ.
93    pub fn mean_reflectivity_dbz(&self) -> f32 {
94        self.mean_reflectivity_dbz
95    }
96
97    /// Number of polar gates in this cell.
98    pub fn gate_count(&self) -> usize {
99        self.gate_count
100    }
101
102    /// Approximate area in square kilometers.
103    pub fn area_km2(&self) -> f64 {
104        self.area_km2
105    }
106
107    /// Geographic bounding box.
108    pub fn bounds(&self) -> &StormCellBounds {
109        &self.bounds
110    }
111
112    /// Elevation angle of the source sweep in degrees.
113    pub fn elevation_degrees(&self) -> f32 {
114        self.elevation_degrees
115    }
116
117    /// Azimuth of the maximum reflectivity gate (degrees).
118    pub fn max_reflectivity_azimuth_degrees(&self) -> f32 {
119        self.max_reflectivity_azimuth_degrees
120    }
121
122    /// Range of the maximum reflectivity gate (km).
123    pub fn max_reflectivity_range_km(&self) -> f64 {
124        self.max_reflectivity_range_km
125    }
126}
127
128/// Detects storm cells in a reflectivity sweep field using connected-component
129/// labeling.
130///
131/// The algorithm identifies contiguous regions of gates with reflectivity at or
132/// above a configurable threshold, then computes geographic properties for each
133/// region using the radar coordinate system.
134///
135/// # Algorithm
136///
137/// 1. Scan all gates in the polar grid. Mark each gate as "above threshold" if
138///    its status is `Valid` and its value >= `reflectivity_threshold_dbz`.
139/// 2. Perform flood-fill connected-component labeling on the above-threshold
140///    gates. Two gates are connected if they are adjacent in azimuth or range
141///    (4-connectivity). Azimuth wraps: the last azimuth index is adjacent to
142///    the first.
143/// 3. Filter out components with fewer than `min_gate_count` gates.
144/// 4. For each remaining component, compute cell properties.
145///
146/// # Example
147///
148/// ```ignore
149/// use nexrad_process::detection::StormCellDetector;
150/// use nexrad_model::geo::RadarCoordinateSystem;
151///
152/// let detector = StormCellDetector::new(35.0, 10)?;
153/// let cells = detector.detect(&reflectivity_field, &coord_system)?;
154///
155/// for cell in &cells {
156///     println!("Cell {} at ({:.2}, {:.2}): max {:.1} dBZ, area {:.1} km²",
157///         cell.id(),
158///         cell.centroid().latitude,
159///         cell.centroid().longitude,
160///         cell.max_reflectivity_dbz(),
161///         cell.area_km2(),
162///     );
163/// }
164/// ```
165pub struct StormCellDetector {
166    /// Minimum reflectivity (dBZ) for a gate to be included in a cell.
167    reflectivity_threshold_dbz: f32,
168    /// Minimum number of gates for a component to be retained as a cell.
169    min_gate_count: usize,
170}
171
172impl StormCellDetector {
173    /// Create a new storm cell detector.
174    ///
175    /// # Parameters
176    ///
177    /// - `reflectivity_threshold_dbz` — Minimum reflectivity in dBZ. Gates
178    ///   at or above this value are candidates for cell membership.
179    /// - `min_gate_count` — Minimum number of contiguous gates for a region
180    ///   to be considered a storm cell. Regions smaller than this are
181    ///   discarded as noise.
182    ///
183    /// # Errors
184    ///
185    /// Returns [`Error::InvalidParameter`] if `min_gate_count` is zero.
186    pub fn new(reflectivity_threshold_dbz: f32, min_gate_count: usize) -> Result<Self> {
187        if min_gate_count == 0 {
188            return Err(Error::InvalidParameter(
189                "min_gate_count must be >= 1".to_string(),
190            ));
191        }
192        Ok(Self {
193            reflectivity_threshold_dbz,
194            min_gate_count,
195        })
196    }
197
198    /// Detect storm cells in the given reflectivity sweep field.
199    ///
200    /// # Parameters
201    ///
202    /// - `field` — A reflectivity sweep field (dBZ values).
203    /// - `coord_system` — The radar coordinate system for computing geographic
204    ///   positions.
205    ///
206    /// # Returns
207    ///
208    /// A vector of detected storm cells, sorted by descending maximum
209    /// reflectivity. Returns an empty vector if no cells are found.
210    ///
211    /// # Errors
212    ///
213    /// Returns [`Error::InvalidGeometry`] if the field has zero azimuths or
214    /// zero gates.
215    pub fn detect(
216        &self,
217        field: &SweepField,
218        coord_system: &RadarCoordinateSystem,
219    ) -> Result<Vec<StormCell>> {
220        let az_count = field.azimuth_count();
221        let num_gates = field.gate_count();
222
223        if az_count == 0 {
224            return Err(Error::InvalidGeometry(
225                "field has zero azimuths".to_string(),
226            ));
227        }
228        if num_gates == 0 {
229            return Err(Error::InvalidGeometry("field has zero gates".to_string()));
230        }
231
232        let total = az_count * num_gates;
233
234        // Phase 1: Build threshold mask
235        let mut above_threshold = vec![false; total];
236        for az_idx in 0..az_count {
237            for gate_idx in 0..num_gates {
238                let (val, status) = field.get(az_idx, gate_idx);
239                if status == GateStatus::Valid && val >= self.reflectivity_threshold_dbz {
240                    above_threshold[az_idx * num_gates + gate_idx] = true;
241                }
242            }
243        }
244
245        // Phase 2: Flood-fill connected-component labeling
246        let mut visited = vec![false; total];
247        let mut components: Vec<Vec<(usize, usize)>> = Vec::new();
248
249        for az_idx in 0..az_count {
250            for gate_idx in 0..num_gates {
251                let idx = az_idx * num_gates + gate_idx;
252                if !above_threshold[idx] || visited[idx] {
253                    continue;
254                }
255
256                // BFS flood fill for new component
257                let mut component: Vec<(usize, usize)> = Vec::new();
258                let mut queue = VecDeque::new();
259
260                visited[idx] = true;
261                queue.push_back((az_idx, gate_idx));
262
263                while let Some((az, gate)) = queue.pop_front() {
264                    component.push((az, gate));
265
266                    // 4-connected neighbors: azimuth ± 1 (wrapping), range ± 1 (clamped)
267                    let az_prev = if az == 0 { az_count - 1 } else { az - 1 };
268                    let az_next = if az == az_count - 1 { 0 } else { az + 1 };
269
270                    let neighbors: [(usize, usize); 2] = [(az_prev, gate), (az_next, gate)];
271
272                    for &(n_az, n_gate) in &neighbors {
273                        let n_idx = n_az * num_gates + n_gate;
274                        if above_threshold[n_idx] && !visited[n_idx] {
275                            visited[n_idx] = true;
276                            queue.push_back((n_az, n_gate));
277                        }
278                    }
279
280                    // Range neighbors (no wrapping)
281                    if gate > 0 {
282                        let n_idx = az * num_gates + (gate - 1);
283                        if above_threshold[n_idx] && !visited[n_idx] {
284                            visited[n_idx] = true;
285                            queue.push_back((az, gate - 1));
286                        }
287                    }
288                    if gate + 1 < num_gates {
289                        let n_idx = az * num_gates + (gate + 1);
290                        if above_threshold[n_idx] && !visited[n_idx] {
291                            visited[n_idx] = true;
292                            queue.push_back((az, gate + 1));
293                        }
294                    }
295                }
296
297                components.push(component);
298            }
299        }
300
301        // Phase 3 & 4: Filter by size and compute properties
302        let mut cells: Vec<StormCell> = Vec::new();
303
304        let az_spacing_rad = (field.azimuth_spacing_degrees() as f64).to_radians();
305
306        for component in &components {
307            if component.len() < self.min_gate_count {
308                continue;
309            }
310
311            let cell = self.compute_cell_properties(field, coord_system, component, az_spacing_rad);
312            cells.push(cell);
313        }
314
315        // Sort by descending max reflectivity
316        cells.sort_by(|a, b| {
317            b.max_reflectivity_dbz
318                .partial_cmp(&a.max_reflectivity_dbz)
319                .unwrap_or(std::cmp::Ordering::Equal)
320        });
321
322        // Re-assign IDs after sorting so ID 0 = strongest cell
323        for (i, cell) in cells.iter_mut().enumerate() {
324            cell.id = i as u32;
325        }
326
327        Ok(cells)
328    }
329
330    /// Compute properties for a single storm cell from its component gates.
331    fn compute_cell_properties(
332        &self,
333        field: &SweepField,
334        coord_system: &RadarCoordinateSystem,
335        component: &[(usize, usize)],
336        az_spacing_rad: f64,
337    ) -> StormCell {
338        let mut max_dbz = f32::MIN;
339        let mut sum_dbz: f64 = 0.0;
340        let mut weighted_lat_sum: f64 = 0.0;
341        let mut weighted_lon_sum: f64 = 0.0;
342        let mut weight_sum: f64 = 0.0;
343        let mut min_lat = f64::MAX;
344        let mut max_lat = f64::MIN;
345        let mut min_lon = f64::MAX;
346        let mut max_lon = f64::MIN;
347        let mut max_dbz_az: f32 = 0.0;
348        let mut max_dbz_range: f64 = 0.0;
349        let mut total_area_km2: f64 = 0.0;
350
351        for &(az_idx, gate_idx) in component {
352            let (val, _) = field.get(az_idx, gate_idx);
353
354            let azimuth_deg = field.azimuths()[az_idx];
355            let range_km = field.first_gate_range_km() + gate_idx as f64 * field.gate_interval_km();
356
357            // Reflectivity statistics
358            sum_dbz += val as f64;
359            if val > max_dbz {
360                max_dbz = val;
361                max_dbz_az = azimuth_deg;
362                max_dbz_range = range_km;
363            }
364
365            // Geographic position of this gate
366            let geo = coord_system.polar_to_geo(PolarPoint {
367                azimuth_degrees: azimuth_deg,
368                range_km,
369                elevation_degrees: field.elevation_degrees(),
370            });
371
372            // Reflectivity-weighted centroid
373            let weight = val as f64;
374            weighted_lat_sum += geo.latitude * weight;
375            weighted_lon_sum += geo.longitude * weight;
376            weight_sum += weight;
377
378            // Bounding box
379            if geo.latitude < min_lat {
380                min_lat = geo.latitude;
381            }
382            if geo.latitude > max_lat {
383                max_lat = geo.latitude;
384            }
385            if geo.longitude < min_lon {
386                min_lon = geo.longitude;
387            }
388            if geo.longitude > max_lon {
389                max_lon = geo.longitude;
390            }
391
392            // Gate area: annular sector approximation
393            let gate_area = az_spacing_rad * range_km * field.gate_interval_km();
394            total_area_km2 += gate_area;
395        }
396
397        let centroid = GeoPoint {
398            latitude: weighted_lat_sum / weight_sum,
399            longitude: weighted_lon_sum / weight_sum,
400        };
401
402        StormCell {
403            id: 0, // re-assigned after sorting
404            centroid,
405            max_reflectivity_dbz: max_dbz,
406            mean_reflectivity_dbz: (sum_dbz / component.len() as f64) as f32,
407            gate_count: component.len(),
408            area_km2: total_area_km2,
409            bounds: StormCellBounds {
410                min_latitude: min_lat,
411                max_latitude: max_lat,
412                min_longitude: min_lon,
413                max_longitude: max_lon,
414            },
415            elevation_degrees: field.elevation_degrees(),
416            max_reflectivity_azimuth_degrees: max_dbz_az,
417            max_reflectivity_range_km: max_dbz_range,
418        }
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use nexrad_model::meta::Site;
426
427    fn test_site() -> Site {
428        Site::new(*b"KTLX", 35.3331, -97.2778, 370, 10)
429    }
430
431    fn test_coord_system() -> RadarCoordinateSystem {
432        RadarCoordinateSystem::new(&test_site())
433    }
434
435    /// Create a 360-azimuth, `gate_count`-gate field with all gates set to NoData.
436    fn make_empty_field(gate_count: usize) -> SweepField {
437        let azimuths: Vec<f32> = (0..360).map(|i| i as f32).collect();
438        SweepField::new_empty(
439            "Reflectivity",
440            "dBZ",
441            0.5,
442            azimuths,
443            1.0,
444            2.0,
445            0.25,
446            gate_count,
447        )
448    }
449
450    /// Create a uniform field where all gates have the given value.
451    fn make_uniform_field(gate_count: usize, value: f32) -> SweepField {
452        let mut field = make_empty_field(gate_count);
453        for az in 0..360 {
454            for gate in 0..gate_count {
455                field.set(az, gate, value, GateStatus::Valid);
456            }
457        }
458        field
459    }
460
461    #[test]
462    fn test_no_cells_below_threshold() {
463        let cs = test_coord_system();
464        let field = make_uniform_field(100, 20.0);
465
466        let detector = StormCellDetector::new(35.0, 5).unwrap();
467        let cells = detector.detect(&field, &cs).unwrap();
468
469        assert!(cells.is_empty());
470    }
471
472    #[test]
473    fn test_single_cell_detected() {
474        let cs = test_coord_system();
475        let mut field = make_empty_field(100);
476
477        // Paint a 5x5 block at 40 dBZ
478        for az in 10..15 {
479            for gate in 20..25 {
480                field.set(az, gate, 40.0, GateStatus::Valid);
481            }
482        }
483
484        let detector = StormCellDetector::new(35.0, 5).unwrap();
485        let cells = detector.detect(&field, &cs).unwrap();
486
487        assert_eq!(cells.len(), 1);
488        assert_eq!(cells[0].gate_count(), 25);
489        assert_eq!(cells[0].max_reflectivity_dbz(), 40.0);
490        assert_eq!(cells[0].id(), 0);
491    }
492
493    #[test]
494    fn test_two_separated_cells() {
495        let cs = test_coord_system();
496        let mut field = make_empty_field(100);
497
498        // Block A: strong
499        for az in 10..15 {
500            for gate in 20..25 {
501                field.set(az, gate, 50.0, GateStatus::Valid);
502            }
503        }
504
505        // Block B: weaker, well-separated
506        for az in 100..105 {
507            for gate in 50..55 {
508                field.set(az, gate, 40.0, GateStatus::Valid);
509            }
510        }
511
512        let detector = StormCellDetector::new(35.0, 5).unwrap();
513        let cells = detector.detect(&field, &cs).unwrap();
514
515        assert_eq!(cells.len(), 2);
516        // Sorted by descending max reflectivity
517        assert!(cells[0].max_reflectivity_dbz() >= cells[1].max_reflectivity_dbz());
518        assert_eq!(cells[0].max_reflectivity_dbz(), 50.0);
519        assert_eq!(cells[1].max_reflectivity_dbz(), 40.0);
520    }
521
522    #[test]
523    fn test_min_gate_count_filters_noise() {
524        let cs = test_coord_system();
525        let mut field = make_empty_field(100);
526
527        // Single gate at 60 dBZ — below the min_gate_count threshold
528        field.set(50, 30, 60.0, GateStatus::Valid);
529
530        let detector = StormCellDetector::new(35.0, 5).unwrap();
531        let cells = detector.detect(&field, &cs).unwrap();
532
533        assert!(cells.is_empty());
534    }
535
536    #[test]
537    fn test_azimuth_wrapping() {
538        let cs = test_coord_system();
539        let mut field = make_empty_field(100);
540
541        // Gates spanning the azimuth wrap: 358, 359, 0, 1, 2
542        for &az in &[358, 359, 0, 1, 2] {
543            field.set(az, 30, 40.0, GateStatus::Valid);
544        }
545
546        let detector = StormCellDetector::new(35.0, 3).unwrap();
547        let cells = detector.detect(&field, &cs).unwrap();
548
549        // Should be a single cell, not two
550        assert_eq!(cells.len(), 1);
551        assert_eq!(cells[0].gate_count(), 5);
552    }
553
554    #[test]
555    fn test_invalid_min_gate_count_zero() {
556        let result = StormCellDetector::new(35.0, 0);
557        assert!(result.is_err());
558    }
559
560    #[test]
561    fn test_empty_field_no_error() {
562        let cs = test_coord_system();
563        let field = make_empty_field(100);
564
565        let detector = StormCellDetector::new(35.0, 5).unwrap();
566        let cells = detector.detect(&field, &cs).unwrap();
567
568        assert!(cells.is_empty());
569    }
570
571    #[test]
572    fn test_cell_centroid_is_geographic() {
573        let cs = test_coord_system();
574        let mut field = make_empty_field(100);
575
576        // Cell near the radar
577        for az in 10..15 {
578            for gate in 5..10 {
579                field.set(az, gate, 45.0, GateStatus::Valid);
580            }
581        }
582
583        let detector = StormCellDetector::new(35.0, 5).unwrap();
584        let cells = detector.detect(&field, &cs).unwrap();
585
586        assert_eq!(cells.len(), 1);
587        let centroid = cells[0].centroid();
588
589        // Centroid should be near the radar site (within a few degrees)
590        assert!(
591            (centroid.latitude - 35.3331).abs() < 2.0,
592            "centroid latitude {} too far from radar",
593            centroid.latitude
594        );
595        assert!(
596            (centroid.longitude - (-97.2778)).abs() < 2.0,
597            "centroid longitude {} too far from radar",
598            centroid.longitude
599        );
600        assert!(!centroid.latitude.is_nan());
601        assert!(!centroid.longitude.is_nan());
602    }
603
604    #[test]
605    fn test_cell_area_reasonable() {
606        let cs = test_coord_system();
607        let mut field = make_empty_field(100);
608
609        let gate_start = 40;
610        let gate_end = 45;
611        let az_start = 20;
612        let az_end = 25;
613        let n_gates = (gate_end - gate_start) * (az_end - az_start);
614
615        for az in az_start..az_end {
616            for gate in gate_start..gate_end {
617                field.set(az, gate, 45.0, GateStatus::Valid);
618            }
619        }
620
621        let detector = StormCellDetector::new(35.0, 5).unwrap();
622        let cells = detector.detect(&field, &cs).unwrap();
623
624        assert_eq!(cells.len(), 1);
625        assert_eq!(cells[0].gate_count(), n_gates);
626
627        // Expected area: sum of annular sector areas
628        let az_spacing_rad = (1.0f64).to_radians();
629        let gate_interval = 0.25;
630        let first_gate = 2.0;
631        let mut expected_area = 0.0;
632        for gate in gate_start..gate_end {
633            let range_km = first_gate + gate as f64 * gate_interval;
634            expected_area += az_spacing_rad * range_km * gate_interval * (az_end - az_start) as f64;
635        }
636
637        let actual = cells[0].area_km2();
638        let ratio = actual / expected_area;
639        assert!(
640            (0.9..=1.1).contains(&ratio),
641            "area {} not within 10% of expected {}",
642            actual,
643            expected_area
644        );
645    }
646
647    #[test]
648    fn test_nodata_gates_not_included() {
649        let cs = test_coord_system();
650        let mut field = make_empty_field(100);
651
652        // Create a 3x3 block with a NoData hole in the center
653        for az in 10..13 {
654            for gate in 20..23 {
655                field.set(az, gate, 40.0, GateStatus::Valid);
656            }
657        }
658        // Punch a hole — this gate stays NoData so it's not part of any cell
659        field.set(11, 21, 0.0, GateStatus::NoData);
660
661        let detector = StormCellDetector::new(35.0, 1).unwrap();
662        let cells = detector.detect(&field, &cs).unwrap();
663
664        // The NoData gate should not be counted
665        let total_gates: usize = cells.iter().map(|c| c.gate_count()).sum();
666        assert_eq!(total_gates, 8); // 9 - 1 NoData
667    }
668
669    #[test]
670    fn test_varying_reflectivity_weighted_centroid() {
671        let cs = test_coord_system();
672        let mut field = make_empty_field(100);
673
674        // Create a cell where one end is much stronger
675        // Low end: gates 40-42 at 36 dBZ
676        for gate in 40..43 {
677            field.set(90, gate, 36.0, GateStatus::Valid);
678        }
679        // High end: gates 43-45 at 60 dBZ
680        for gate in 43..46 {
681            field.set(90, gate, 60.0, GateStatus::Valid);
682        }
683
684        let detector = StormCellDetector::new(35.0, 3).unwrap();
685        let cells = detector.detect(&field, &cs).unwrap();
686
687        assert_eq!(cells.len(), 1);
688
689        // Compute the unweighted geometric midpoint range
690        let first_gate = field.first_gate_range_km();
691        let interval = field.gate_interval_km();
692        let mid_range = first_gate + 42.5 * interval; // middle gate of 40-45
693
694        // The weighted centroid should be shifted toward the high-end (gate 43-45)
695        // meaning further from the radar than the geometric midpoint
696        let centroid_range_km = {
697            let polar = cs.geo_to_polar(cells[0].centroid(), cells[0].elevation_degrees());
698            polar.range_km
699        };
700
701        assert!(
702            centroid_range_km > mid_range,
703            "centroid range {:.3} should be beyond geometric mid {:.3} (shifted toward high-dBZ end)",
704            centroid_range_km,
705            mid_range
706        );
707    }
708}