Skip to main content

nexrad_process/filter/
smoothing.rs

1use crate::result::{Error, Result};
2use crate::SweepProcessor;
3use nexrad_model::data::{GateStatus, SweepField};
4
5/// Median filter in an azimuth x range kernel.
6///
7/// Replaces each valid gate value with the median of the valid values in its
8/// neighborhood. The kernel size is specified as the number of gates in each
9/// dimension (must be odd). Invalid gates (NoData, BelowThreshold, RangeFolded)
10/// are excluded from the median computation and left unchanged.
11///
12/// The azimuth dimension wraps around 360 degrees.
13pub struct MedianFilter {
14    /// Kernel width in the azimuth dimension (must be odd, >= 1).
15    pub azimuth_kernel: usize,
16    /// Kernel width in the range dimension (must be odd, >= 1).
17    pub range_kernel: usize,
18}
19
20impl SweepProcessor for MedianFilter {
21    fn name(&self) -> &str {
22        "MedianFilter"
23    }
24
25    fn process(&self, input: &SweepField) -> Result<SweepField> {
26        if self.azimuth_kernel % 2 == 0 || self.range_kernel % 2 == 0 {
27            return Err(Error::InvalidParameter(
28                "kernel sizes must be odd".to_string(),
29            ));
30        }
31        if self.azimuth_kernel == 0 || self.range_kernel == 0 {
32            return Err(Error::InvalidParameter(
33                "kernel sizes must be >= 1".to_string(),
34            ));
35        }
36
37        // 1x1 kernel is a no-op
38        if self.azimuth_kernel == 1 && self.range_kernel == 1 {
39            return Ok(input.clone());
40        }
41
42        let az_count = input.azimuth_count();
43        let gate_count = input.gate_count();
44        let az_half = self.azimuth_kernel / 2;
45        let range_half = self.range_kernel / 2;
46
47        let mut output = input.clone();
48        let mut neighborhood = Vec::with_capacity(self.azimuth_kernel * self.range_kernel);
49
50        for az_idx in 0..az_count {
51            for gate_idx in 0..gate_count {
52                let (_, status) = input.get(az_idx, gate_idx);
53                if status != GateStatus::Valid {
54                    continue;
55                }
56
57                neighborhood.clear();
58
59                for daz in 0..self.azimuth_kernel {
60                    let az_offset = daz as isize - az_half as isize;
61                    let neighbor_az =
62                        ((az_idx as isize + az_offset).rem_euclid(az_count as isize)) as usize;
63
64                    for dr in 0..self.range_kernel {
65                        let range_offset = dr as isize - range_half as isize;
66                        let neighbor_gate = gate_idx as isize + range_offset;
67
68                        if neighbor_gate < 0 || neighbor_gate >= gate_count as isize {
69                            continue;
70                        }
71
72                        let (val, st) = input.get(neighbor_az, neighbor_gate as usize);
73                        if st == GateStatus::Valid {
74                            neighborhood.push(val);
75                        }
76                    }
77                }
78
79                if !neighborhood.is_empty() {
80                    neighborhood
81                        .sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
82                    let median = neighborhood[neighborhood.len() / 2];
83                    output.set(az_idx, gate_idx, median, GateStatus::Valid);
84                }
85            }
86        }
87
88        Ok(output)
89    }
90}
91
92/// Gaussian smoothing filter in polar coordinates.
93///
94/// Applies a 2D Gaussian kernel over the azimuth x range grid. The sigma values
95/// control the smoothing width in each dimension (in number of gates). Only valid
96/// gates contribute to the weighted average; invalid gates are left unchanged.
97///
98/// The azimuth dimension wraps around 360 degrees.
99pub struct GaussianSmooth {
100    /// Standard deviation in the azimuth dimension (in gate units).
101    pub sigma_azimuth: f32,
102    /// Standard deviation in the range dimension (in gate units).
103    pub sigma_range: f32,
104}
105
106impl SweepProcessor for GaussianSmooth {
107    fn name(&self) -> &str {
108        "GaussianSmooth"
109    }
110
111    fn process(&self, input: &SweepField) -> Result<SweepField> {
112        if self.sigma_azimuth <= 0.0 || self.sigma_range <= 0.0 {
113            return Err(Error::InvalidParameter(
114                "sigma values must be positive".to_string(),
115            ));
116        }
117
118        let az_count = input.azimuth_count();
119        let gate_count = input.gate_count();
120
121        // Kernel radius: 3 sigma, clamped to reasonable size
122        let az_radius = (self.sigma_azimuth * 3.0).ceil() as usize;
123        let range_radius = (self.sigma_range * 3.0).ceil() as usize;
124
125        let mut output = input.clone();
126
127        for az_idx in 0..az_count {
128            for gate_idx in 0..gate_count {
129                let (_, status) = input.get(az_idx, gate_idx);
130                if status != GateStatus::Valid {
131                    continue;
132                }
133
134                let mut weighted_sum = 0.0f64;
135                let mut weight_sum = 0.0f64;
136
137                for daz in 0..=(2 * az_radius) {
138                    let az_offset = daz as isize - az_radius as isize;
139                    let neighbor_az =
140                        ((az_idx as isize + az_offset).rem_euclid(az_count as isize)) as usize;
141
142                    for dr in 0..=(2 * range_radius) {
143                        let range_offset = dr as isize - range_radius as isize;
144                        let neighbor_gate = gate_idx as isize + range_offset;
145
146                        if neighbor_gate < 0 || neighbor_gate >= gate_count as isize {
147                            continue;
148                        }
149
150                        let (val, st) = input.get(neighbor_az, neighbor_gate as usize);
151                        if st != GateStatus::Valid {
152                            continue;
153                        }
154
155                        let az_dist = az_offset as f32;
156                        let r_dist = range_offset as f32;
157                        let weight = (-0.5
158                            * ((az_dist / self.sigma_azimuth).powi(2)
159                                + (r_dist / self.sigma_range).powi(2)))
160                        .exp() as f64;
161
162                        weighted_sum += val as f64 * weight;
163                        weight_sum += weight;
164                    }
165                }
166
167                if weight_sum > 0.0 {
168                    output.set(
169                        az_idx,
170                        gate_idx,
171                        (weighted_sum / weight_sum) as f32,
172                        GateStatus::Valid,
173                    );
174                }
175            }
176        }
177
178        Ok(output)
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    fn make_test_field() -> SweepField {
187        let mut field = SweepField::new_empty(
188            "Test",
189            "dBZ",
190            0.5,
191            vec![0.0, 1.0, 2.0, 3.0, 4.0],
192            1.0,
193            2.0,
194            0.25,
195            5,
196        );
197
198        // Set uniform values
199        for az in 0..5 {
200            for gate in 0..5 {
201                field.set(az, gate, 30.0, GateStatus::Valid);
202            }
203        }
204
205        field
206    }
207
208    #[test]
209    fn test_median_filter_uniform() {
210        let field = make_test_field();
211        let filter = MedianFilter {
212            azimuth_kernel: 3,
213            range_kernel: 3,
214        };
215
216        let result = filter.process(&field).unwrap();
217
218        // Uniform input should produce uniform output
219        for az in 0..5 {
220            for gate in 0..5 {
221                let (val, status) = result.get(az, gate);
222                assert_eq!(val, 30.0);
223                assert_eq!(status, GateStatus::Valid);
224            }
225        }
226    }
227
228    #[test]
229    fn test_median_filter_removes_spike() {
230        let mut field = make_test_field();
231        // Add a single spike
232        field.set(2, 2, 100.0, GateStatus::Valid);
233
234        let filter = MedianFilter {
235            azimuth_kernel: 3,
236            range_kernel: 3,
237        };
238
239        let result = filter.process(&field).unwrap();
240
241        // The spike should be removed (median of mostly 30.0 values)
242        let (val, _) = result.get(2, 2);
243        assert_eq!(val, 30.0);
244    }
245
246    #[test]
247    fn test_median_filter_even_kernel_error() {
248        let field = make_test_field();
249        let filter = MedianFilter {
250            azimuth_kernel: 2,
251            range_kernel: 3,
252        };
253
254        assert!(filter.process(&field).is_err());
255    }
256
257    #[test]
258    fn test_median_filter_1x1_noop() {
259        let mut field = make_test_field();
260        field.set(2, 2, 99.0, GateStatus::Valid);
261
262        let filter = MedianFilter {
263            azimuth_kernel: 1,
264            range_kernel: 1,
265        };
266
267        let result = filter.process(&field).unwrap();
268        let (val, _) = result.get(2, 2);
269        assert_eq!(val, 99.0);
270    }
271
272    #[test]
273    fn test_median_filter_preserves_nodata() {
274        let mut field = make_test_field();
275        field.set(2, 2, 0.0, GateStatus::NoData);
276
277        let filter = MedianFilter {
278            azimuth_kernel: 3,
279            range_kernel: 3,
280        };
281
282        let result = filter.process(&field).unwrap();
283        let (_, status) = result.get(2, 2);
284        assert_eq!(status, GateStatus::NoData);
285    }
286
287    #[test]
288    fn test_gaussian_smooth_uniform() {
289        let field = make_test_field();
290        let smoother = GaussianSmooth {
291            sigma_azimuth: 1.0,
292            sigma_range: 1.0,
293        };
294
295        let result = smoother.process(&field).unwrap();
296
297        // Uniform input should produce uniform output
298        for az in 0..5 {
299            for gate in 0..5 {
300                let (val, _) = result.get(az, gate);
301                assert!((val - 30.0).abs() < 0.01, "Expected ~30.0, got {}", val);
302            }
303        }
304    }
305
306    #[test]
307    fn test_gaussian_smooth_reduces_spike() {
308        let mut field = make_test_field();
309        field.set(2, 2, 100.0, GateStatus::Valid);
310
311        let smoother = GaussianSmooth {
312            sigma_azimuth: 1.0,
313            sigma_range: 1.0,
314        };
315
316        let result = smoother.process(&field).unwrap();
317
318        // The spike should be reduced (smoothed toward neighbors)
319        let (val, _) = result.get(2, 2);
320        assert!(val < 100.0, "Expected smoothed value < 100, got {}", val);
321        assert!(val > 30.0, "Expected smoothed value > 30, got {}", val);
322    }
323
324    #[test]
325    fn test_gaussian_smooth_invalid_sigma() {
326        let field = make_test_field();
327        let smoother = GaussianSmooth {
328            sigma_azimuth: 0.0,
329            sigma_range: 1.0,
330        };
331
332        assert!(smoother.process(&field).is_err());
333    }
334}