use std::collections::VecDeque;
use crate::result::{Error, Result};
use nexrad_model::data::{GateStatus, SweepField};
use nexrad_model::geo::{GeoPoint, PolarPoint, RadarCoordinateSystem};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StormCellBounds {
min_latitude: f64,
max_latitude: f64,
min_longitude: f64,
max_longitude: f64,
}
impl StormCellBounds {
pub fn min_latitude(&self) -> f64 {
self.min_latitude
}
pub fn max_latitude(&self) -> f64 {
self.max_latitude
}
pub fn min_longitude(&self) -> f64 {
self.min_longitude
}
pub fn max_longitude(&self) -> f64 {
self.max_longitude
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct StormCell {
id: u32,
centroid: GeoPoint,
max_reflectivity_dbz: f32,
mean_reflectivity_dbz: f32,
gate_count: usize,
area_km2: f64,
bounds: StormCellBounds,
elevation_degrees: f32,
max_reflectivity_azimuth_degrees: f32,
max_reflectivity_range_km: f64,
}
impl StormCell {
pub fn id(&self) -> u32 {
self.id
}
pub fn centroid(&self) -> GeoPoint {
self.centroid
}
pub fn max_reflectivity_dbz(&self) -> f32 {
self.max_reflectivity_dbz
}
pub fn mean_reflectivity_dbz(&self) -> f32 {
self.mean_reflectivity_dbz
}
pub fn gate_count(&self) -> usize {
self.gate_count
}
pub fn area_km2(&self) -> f64 {
self.area_km2
}
pub fn bounds(&self) -> &StormCellBounds {
&self.bounds
}
pub fn elevation_degrees(&self) -> f32 {
self.elevation_degrees
}
pub fn max_reflectivity_azimuth_degrees(&self) -> f32 {
self.max_reflectivity_azimuth_degrees
}
pub fn max_reflectivity_range_km(&self) -> f64 {
self.max_reflectivity_range_km
}
}
pub struct StormCellDetector {
reflectivity_threshold_dbz: f32,
min_gate_count: usize,
}
impl StormCellDetector {
pub fn new(reflectivity_threshold_dbz: f32, min_gate_count: usize) -> Result<Self> {
if min_gate_count == 0 {
return Err(Error::InvalidParameter(
"min_gate_count must be >= 1".to_string(),
));
}
Ok(Self {
reflectivity_threshold_dbz,
min_gate_count,
})
}
pub fn detect(
&self,
field: &SweepField,
coord_system: &RadarCoordinateSystem,
) -> Result<Vec<StormCell>> {
let az_count = field.azimuth_count();
let num_gates = field.gate_count();
if az_count == 0 {
return Err(Error::InvalidGeometry(
"field has zero azimuths".to_string(),
));
}
if num_gates == 0 {
return Err(Error::InvalidGeometry("field has zero gates".to_string()));
}
let total = az_count * num_gates;
let mut above_threshold = vec![false; total];
for az_idx in 0..az_count {
for gate_idx in 0..num_gates {
let (val, status) = field.get(az_idx, gate_idx);
if status == GateStatus::Valid && val >= self.reflectivity_threshold_dbz {
above_threshold[az_idx * num_gates + gate_idx] = true;
}
}
}
let mut visited = vec![false; total];
let mut components: Vec<Vec<(usize, usize)>> = Vec::new();
for az_idx in 0..az_count {
for gate_idx in 0..num_gates {
let idx = az_idx * num_gates + gate_idx;
if !above_threshold[idx] || visited[idx] {
continue;
}
let mut component: Vec<(usize, usize)> = Vec::new();
let mut queue = VecDeque::new();
visited[idx] = true;
queue.push_back((az_idx, gate_idx));
while let Some((az, gate)) = queue.pop_front() {
component.push((az, gate));
let az_prev = if az == 0 { az_count - 1 } else { az - 1 };
let az_next = if az == az_count - 1 { 0 } else { az + 1 };
let neighbors: [(usize, usize); 2] = [(az_prev, gate), (az_next, gate)];
for &(n_az, n_gate) in &neighbors {
let n_idx = n_az * num_gates + n_gate;
if above_threshold[n_idx] && !visited[n_idx] {
visited[n_idx] = true;
queue.push_back((n_az, n_gate));
}
}
if gate > 0 {
let n_idx = az * num_gates + (gate - 1);
if above_threshold[n_idx] && !visited[n_idx] {
visited[n_idx] = true;
queue.push_back((az, gate - 1));
}
}
if gate + 1 < num_gates {
let n_idx = az * num_gates + (gate + 1);
if above_threshold[n_idx] && !visited[n_idx] {
visited[n_idx] = true;
queue.push_back((az, gate + 1));
}
}
}
components.push(component);
}
}
let mut cells: Vec<StormCell> = Vec::new();
let az_spacing_rad = (field.azimuth_spacing_degrees() as f64).to_radians();
for component in &components {
if component.len() < self.min_gate_count {
continue;
}
let cell = self.compute_cell_properties(field, coord_system, component, az_spacing_rad);
cells.push(cell);
}
cells.sort_by(|a, b| {
b.max_reflectivity_dbz
.partial_cmp(&a.max_reflectivity_dbz)
.unwrap_or(std::cmp::Ordering::Equal)
});
for (i, cell) in cells.iter_mut().enumerate() {
cell.id = i as u32;
}
Ok(cells)
}
fn compute_cell_properties(
&self,
field: &SweepField,
coord_system: &RadarCoordinateSystem,
component: &[(usize, usize)],
az_spacing_rad: f64,
) -> StormCell {
let mut max_dbz = f32::MIN;
let mut sum_dbz: f64 = 0.0;
let mut weighted_lat_sum: f64 = 0.0;
let mut weighted_lon_sum: f64 = 0.0;
let mut weight_sum: f64 = 0.0;
let mut min_lat = f64::MAX;
let mut max_lat = f64::MIN;
let mut min_lon = f64::MAX;
let mut max_lon = f64::MIN;
let mut max_dbz_az: f32 = 0.0;
let mut max_dbz_range: f64 = 0.0;
let mut total_area_km2: f64 = 0.0;
for &(az_idx, gate_idx) in component {
let (val, _) = field.get(az_idx, gate_idx);
let azimuth_deg = field.azimuths()[az_idx];
let range_km = field.first_gate_range_km() + gate_idx as f64 * field.gate_interval_km();
sum_dbz += val as f64;
if val > max_dbz {
max_dbz = val;
max_dbz_az = azimuth_deg;
max_dbz_range = range_km;
}
let geo = coord_system.polar_to_geo(PolarPoint {
azimuth_degrees: azimuth_deg,
range_km,
elevation_degrees: field.elevation_degrees(),
});
let weight = val as f64;
weighted_lat_sum += geo.latitude * weight;
weighted_lon_sum += geo.longitude * weight;
weight_sum += weight;
if geo.latitude < min_lat {
min_lat = geo.latitude;
}
if geo.latitude > max_lat {
max_lat = geo.latitude;
}
if geo.longitude < min_lon {
min_lon = geo.longitude;
}
if geo.longitude > max_lon {
max_lon = geo.longitude;
}
let gate_area = az_spacing_rad * range_km * field.gate_interval_km();
total_area_km2 += gate_area;
}
let centroid = GeoPoint {
latitude: weighted_lat_sum / weight_sum,
longitude: weighted_lon_sum / weight_sum,
};
StormCell {
id: 0, centroid,
max_reflectivity_dbz: max_dbz,
mean_reflectivity_dbz: (sum_dbz / component.len() as f64) as f32,
gate_count: component.len(),
area_km2: total_area_km2,
bounds: StormCellBounds {
min_latitude: min_lat,
max_latitude: max_lat,
min_longitude: min_lon,
max_longitude: max_lon,
},
elevation_degrees: field.elevation_degrees(),
max_reflectivity_azimuth_degrees: max_dbz_az,
max_reflectivity_range_km: max_dbz_range,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use nexrad_model::meta::Site;
fn test_site() -> Site {
Site::new(*b"KTLX", 35.3331, -97.2778, 370, 10)
}
fn test_coord_system() -> RadarCoordinateSystem {
RadarCoordinateSystem::new(&test_site())
}
fn make_empty_field(gate_count: usize) -> SweepField {
let azimuths: Vec<f32> = (0..360).map(|i| i as f32).collect();
SweepField::new_empty(
"Reflectivity",
"dBZ",
0.5,
azimuths,
1.0,
2.0,
0.25,
gate_count,
)
}
fn make_uniform_field(gate_count: usize, value: f32) -> SweepField {
let mut field = make_empty_field(gate_count);
for az in 0..360 {
for gate in 0..gate_count {
field.set(az, gate, value, GateStatus::Valid);
}
}
field
}
#[test]
fn test_no_cells_below_threshold() {
let cs = test_coord_system();
let field = make_uniform_field(100, 20.0);
let detector = StormCellDetector::new(35.0, 5).unwrap();
let cells = detector.detect(&field, &cs).unwrap();
assert!(cells.is_empty());
}
#[test]
fn test_single_cell_detected() {
let cs = test_coord_system();
let mut field = make_empty_field(100);
for az in 10..15 {
for gate in 20..25 {
field.set(az, gate, 40.0, GateStatus::Valid);
}
}
let detector = StormCellDetector::new(35.0, 5).unwrap();
let cells = detector.detect(&field, &cs).unwrap();
assert_eq!(cells.len(), 1);
assert_eq!(cells[0].gate_count(), 25);
assert_eq!(cells[0].max_reflectivity_dbz(), 40.0);
assert_eq!(cells[0].id(), 0);
}
#[test]
fn test_two_separated_cells() {
let cs = test_coord_system();
let mut field = make_empty_field(100);
for az in 10..15 {
for gate in 20..25 {
field.set(az, gate, 50.0, GateStatus::Valid);
}
}
for az in 100..105 {
for gate in 50..55 {
field.set(az, gate, 40.0, GateStatus::Valid);
}
}
let detector = StormCellDetector::new(35.0, 5).unwrap();
let cells = detector.detect(&field, &cs).unwrap();
assert_eq!(cells.len(), 2);
assert!(cells[0].max_reflectivity_dbz() >= cells[1].max_reflectivity_dbz());
assert_eq!(cells[0].max_reflectivity_dbz(), 50.0);
assert_eq!(cells[1].max_reflectivity_dbz(), 40.0);
}
#[test]
fn test_min_gate_count_filters_noise() {
let cs = test_coord_system();
let mut field = make_empty_field(100);
field.set(50, 30, 60.0, GateStatus::Valid);
let detector = StormCellDetector::new(35.0, 5).unwrap();
let cells = detector.detect(&field, &cs).unwrap();
assert!(cells.is_empty());
}
#[test]
fn test_azimuth_wrapping() {
let cs = test_coord_system();
let mut field = make_empty_field(100);
for &az in &[358, 359, 0, 1, 2] {
field.set(az, 30, 40.0, GateStatus::Valid);
}
let detector = StormCellDetector::new(35.0, 3).unwrap();
let cells = detector.detect(&field, &cs).unwrap();
assert_eq!(cells.len(), 1);
assert_eq!(cells[0].gate_count(), 5);
}
#[test]
fn test_invalid_min_gate_count_zero() {
let result = StormCellDetector::new(35.0, 0);
assert!(result.is_err());
}
#[test]
fn test_empty_field_no_error() {
let cs = test_coord_system();
let field = make_empty_field(100);
let detector = StormCellDetector::new(35.0, 5).unwrap();
let cells = detector.detect(&field, &cs).unwrap();
assert!(cells.is_empty());
}
#[test]
fn test_cell_centroid_is_geographic() {
let cs = test_coord_system();
let mut field = make_empty_field(100);
for az in 10..15 {
for gate in 5..10 {
field.set(az, gate, 45.0, GateStatus::Valid);
}
}
let detector = StormCellDetector::new(35.0, 5).unwrap();
let cells = detector.detect(&field, &cs).unwrap();
assert_eq!(cells.len(), 1);
let centroid = cells[0].centroid();
assert!(
(centroid.latitude - 35.3331).abs() < 2.0,
"centroid latitude {} too far from radar",
centroid.latitude
);
assert!(
(centroid.longitude - (-97.2778)).abs() < 2.0,
"centroid longitude {} too far from radar",
centroid.longitude
);
assert!(!centroid.latitude.is_nan());
assert!(!centroid.longitude.is_nan());
}
#[test]
fn test_cell_area_reasonable() {
let cs = test_coord_system();
let mut field = make_empty_field(100);
let gate_start = 40;
let gate_end = 45;
let az_start = 20;
let az_end = 25;
let n_gates = (gate_end - gate_start) * (az_end - az_start);
for az in az_start..az_end {
for gate in gate_start..gate_end {
field.set(az, gate, 45.0, GateStatus::Valid);
}
}
let detector = StormCellDetector::new(35.0, 5).unwrap();
let cells = detector.detect(&field, &cs).unwrap();
assert_eq!(cells.len(), 1);
assert_eq!(cells[0].gate_count(), n_gates);
let az_spacing_rad = (1.0f64).to_radians();
let gate_interval = 0.25;
let first_gate = 2.0;
let mut expected_area = 0.0;
for gate in gate_start..gate_end {
let range_km = first_gate + gate as f64 * gate_interval;
expected_area += az_spacing_rad * range_km * gate_interval * (az_end - az_start) as f64;
}
let actual = cells[0].area_km2();
let ratio = actual / expected_area;
assert!(
(0.9..=1.1).contains(&ratio),
"area {} not within 10% of expected {}",
actual,
expected_area
);
}
#[test]
fn test_nodata_gates_not_included() {
let cs = test_coord_system();
let mut field = make_empty_field(100);
for az in 10..13 {
for gate in 20..23 {
field.set(az, gate, 40.0, GateStatus::Valid);
}
}
field.set(11, 21, 0.0, GateStatus::NoData);
let detector = StormCellDetector::new(35.0, 1).unwrap();
let cells = detector.detect(&field, &cs).unwrap();
let total_gates: usize = cells.iter().map(|c| c.gate_count()).sum();
assert_eq!(total_gates, 8); }
#[test]
fn test_varying_reflectivity_weighted_centroid() {
let cs = test_coord_system();
let mut field = make_empty_field(100);
for gate in 40..43 {
field.set(90, gate, 36.0, GateStatus::Valid);
}
for gate in 43..46 {
field.set(90, gate, 60.0, GateStatus::Valid);
}
let detector = StormCellDetector::new(35.0, 3).unwrap();
let cells = detector.detect(&field, &cs).unwrap();
assert_eq!(cells.len(), 1);
let first_gate = field.first_gate_range_km();
let interval = field.gate_interval_km();
let mid_range = first_gate + 42.5 * interval;
let centroid_range_km = {
let polar = cs.geo_to_polar(cells[0].centroid(), cells[0].elevation_degrees());
polar.range_km
};
assert!(
centroid_range_km > mid_range,
"centroid range {:.3} should be beyond geometric mid {:.3} (shifted toward high-dBZ end)",
centroid_range_km,
mid_range
);
}
}