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#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct StormCellBounds {
10 min_latitude: f64,
12 max_latitude: f64,
14 min_longitude: f64,
16 max_longitude: f64,
18}
19
20impl StormCellBounds {
21 pub fn min_latitude(&self) -> f64 {
23 self.min_latitude
24 }
25
26 pub fn max_latitude(&self) -> f64 {
28 self.max_latitude
29 }
30
31 pub fn min_longitude(&self) -> f64 {
33 self.min_longitude
34 }
35
36 pub fn max_longitude(&self) -> f64 {
38 self.max_longitude
39 }
40}
41
42#[derive(Debug, Clone, PartialEq)]
49pub struct StormCell {
50 id: u32,
52 centroid: GeoPoint,
55 max_reflectivity_dbz: f32,
57 mean_reflectivity_dbz: f32,
59 gate_count: usize,
61 area_km2: f64,
63 bounds: StormCellBounds,
65 elevation_degrees: f32,
67 max_reflectivity_azimuth_degrees: f32,
69 max_reflectivity_range_km: f64,
71}
72
73impl StormCell {
74 pub fn id(&self) -> u32 {
79 self.id
80 }
81
82 pub fn centroid(&self) -> GeoPoint {
84 self.centroid
85 }
86
87 pub fn max_reflectivity_dbz(&self) -> f32 {
89 self.max_reflectivity_dbz
90 }
91
92 pub fn mean_reflectivity_dbz(&self) -> f32 {
94 self.mean_reflectivity_dbz
95 }
96
97 pub fn gate_count(&self) -> usize {
99 self.gate_count
100 }
101
102 pub fn area_km2(&self) -> f64 {
104 self.area_km2
105 }
106
107 pub fn bounds(&self) -> &StormCellBounds {
109 &self.bounds
110 }
111
112 pub fn elevation_degrees(&self) -> f32 {
114 self.elevation_degrees
115 }
116
117 pub fn max_reflectivity_azimuth_degrees(&self) -> f32 {
119 self.max_reflectivity_azimuth_degrees
120 }
121
122 pub fn max_reflectivity_range_km(&self) -> f64 {
124 self.max_reflectivity_range_km
125 }
126}
127
128pub struct StormCellDetector {
166 reflectivity_threshold_dbz: f32,
168 min_gate_count: usize,
170}
171
172impl StormCellDetector {
173 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 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 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 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 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 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 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 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 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 for (i, cell) in cells.iter_mut().enumerate() {
324 cell.id = i as u32;
325 }
326
327 Ok(cells)
328 }
329
330 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 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 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 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 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 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, 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 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 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 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 for az in 10..15 {
500 for gate in 20..25 {
501 field.set(az, gate, 50.0, GateStatus::Valid);
502 }
503 }
504
505 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 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 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 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 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 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 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 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 for az in 10..13 {
654 for gate in 20..23 {
655 field.set(az, gate, 40.0, GateStatus::Valid);
656 }
657 }
658 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 let total_gates: usize = cells.iter().map(|c| c.gate_count()).sum();
666 assert_eq!(total_gates, 8); }
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 for gate in 40..43 {
677 field.set(90, gate, 36.0, GateStatus::Valid);
678 }
679 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 let first_gate = field.first_gate_range_km();
691 let interval = field.gate_interval_km();
692 let mid_range = first_gate + 42.5 * interval; 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}