Skip to main content

nexrad_process/derived/
srvel.rs

1use crate::result::{Error, Result};
2use crate::SweepProcessor;
3use nexrad_model::data::{GateStatus, SweepField};
4
5/// Storm-relative velocity — subtracts a storm motion vector from radial velocity.
6///
7/// This makes it easier to identify rotation signatures (mesocyclones) in storm
8/// systems by removing the bulk storm motion. The input field should contain
9/// radial velocity data.
10///
11/// The storm motion is specified as a direction (degrees from north) and speed (m/s).
12/// The component of the storm motion along each gate's radial direction is subtracted
13/// from the measured velocity.
14///
15/// # Example
16///
17/// ```ignore
18/// use nexrad_process::derived::StormRelativeVelocity;
19///
20/// // Storm moving from 240° at 15 m/s
21/// let srv = StormRelativeVelocity::new(240.0, 15.0)?;
22/// let sr_velocity = srv.process(&velocity_field)?;
23/// ```
24pub struct StormRelativeVelocity {
25    /// Storm motion direction in degrees clockwise from north (0-360).
26    storm_direction_degrees: f32,
27    /// Storm motion speed in m/s.
28    storm_speed_mps: f32,
29}
30
31impl StormRelativeVelocity {
32    /// Create a new storm-relative velocity processor.
33    ///
34    /// # Parameters
35    ///
36    /// - `storm_direction_degrees` — Direction the storm is moving FROM, in degrees
37    ///   clockwise from north (0-360).
38    /// - `storm_speed_mps` — Storm movement speed in m/s.
39    ///
40    /// # Errors
41    ///
42    /// Returns an error if speed is negative.
43    pub fn new(storm_direction_degrees: f32, storm_speed_mps: f32) -> Result<Self> {
44        if storm_speed_mps < 0.0 {
45            return Err(Error::InvalidParameter(
46                "storm speed must be non-negative".to_string(),
47            ));
48        }
49        Ok(Self {
50            storm_direction_degrees,
51            storm_speed_mps,
52        })
53    }
54}
55
56impl SweepProcessor for StormRelativeVelocity {
57    fn name(&self) -> &str {
58        "StormRelativeVelocity"
59    }
60
61    fn process(&self, input: &SweepField) -> Result<SweepField> {
62        let mut output = input.clone();
63
64        // Convert storm direction to radians (meteorological convention: FROM direction)
65        // The storm motion vector points in the direction the storm is moving TO.
66        let storm_to_rad = (self.storm_direction_degrees + 180.0).to_radians();
67
68        // Storm motion components (u = east, v = north)
69        let storm_u = self.storm_speed_mps * storm_to_rad.sin();
70        let storm_v = self.storm_speed_mps * storm_to_rad.cos();
71
72        for az_idx in 0..input.azimuth_count() {
73            let azimuth_rad = input.azimuths()[az_idx].to_radians();
74
75            // Unit vector along the radial direction (from radar toward target)
76            let radial_u = azimuth_rad.sin();
77            let radial_v = azimuth_rad.cos();
78
79            // Component of storm motion along this radial
80            let storm_radial_component = storm_u * radial_u + storm_v * radial_v;
81
82            for gate_idx in 0..input.gate_count() {
83                let (val, status) = input.get(az_idx, gate_idx);
84                if status != GateStatus::Valid {
85                    continue;
86                }
87
88                // Subtract storm motion component
89                let sr_velocity = val - storm_radial_component;
90                output.set(az_idx, gate_idx, sr_velocity, GateStatus::Valid);
91            }
92        }
93
94        Ok(output)
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    fn make_velocity_field() -> SweepField {
103        let azimuths = vec![0.0, 90.0, 180.0, 270.0];
104        let gate_count = 5;
105
106        let mut field =
107            SweepField::new_empty("Velocity", "m/s", 0.5, azimuths, 1.0, 2.0, 0.25, gate_count);
108
109        // Set uniform velocity of 10 m/s at all azimuths
110        for az in 0..4 {
111            for gate in 0..gate_count {
112                field.set(az, gate, 10.0, GateStatus::Valid);
113            }
114        }
115
116        field
117    }
118
119    #[test]
120    fn test_srv_zero_storm_motion() {
121        let field = make_velocity_field();
122        let srv = StormRelativeVelocity::new(0.0, 0.0).unwrap();
123        let result = srv.process(&field).unwrap();
124
125        // No storm motion — output should match input
126        for az in 0..4 {
127            for gate in 0..5 {
128                let (val, _) = result.get(az, gate);
129                assert!((val - 10.0).abs() < 0.01);
130            }
131        }
132    }
133
134    #[test]
135    fn test_srv_northward_storm() {
136        let field = make_velocity_field();
137        // Storm moving FROM south (180°), so moving TO north
138        let srv = StormRelativeVelocity::new(180.0, 10.0).unwrap();
139        let result = srv.process(&field).unwrap();
140
141        // At azimuth 0° (north): radial is along storm direction
142        // Storm component along north radial = +10 m/s
143        // SR velocity = 10 - 10 = 0
144        let (val, _) = result.get(0, 2);
145        assert!(val.abs() < 0.1, "Expected ~0 at north azimuth, got {}", val);
146
147        // At azimuth 180° (south): radial is opposite storm direction
148        // Storm component along south radial = -10 m/s
149        // SR velocity = 10 - (-10) = 20
150        let (val, _) = result.get(2, 2);
151        assert!(
152            (val - 20.0).abs() < 0.1,
153            "Expected ~20 at south azimuth, got {}",
154            val
155        );
156
157        // At azimuth 90° (east): radial is perpendicular to storm direction
158        // Storm component along east radial = 0
159        // SR velocity = 10 - 0 = 10
160        let (val, _) = result.get(1, 2);
161        assert!(
162            (val - 10.0).abs() < 0.1,
163            "Expected ~10 at east azimuth, got {}",
164            val
165        );
166    }
167
168    #[test]
169    fn test_srv_preserves_nodata() {
170        let mut field = make_velocity_field();
171        field.set(1, 2, 0.0, GateStatus::NoData);
172
173        let srv = StormRelativeVelocity::new(180.0, 10.0).unwrap();
174        let result = srv.process(&field).unwrap();
175
176        let (_, status) = result.get(1, 2);
177        assert_eq!(status, GateStatus::NoData);
178    }
179
180    #[test]
181    fn test_srv_negative_speed_error() {
182        assert!(StormRelativeVelocity::new(0.0, -5.0).is_err());
183    }
184}