Skip to main content

ballistics_engine/
wind.rs

1use nalgebra::Vector3;
2use std::f64::consts::PI;
3
4/// Conversion constant from KMH to MPS
5const KMH_TO_MPS: f64 = 1000.0 / 3600.0;
6
7/// Wind segment: (speed_kmh, angle_deg, until_distance_m)
8/// This matches the Python WindSock interface
9pub type WindSegment = (f64, f64, f64);
10
11/// Wind condition handler for trajectory calculations
12#[derive(Debug, Clone)]
13pub struct WindSock {
14    /// Sorted wind segments by distance
15    winds: Vec<WindSegment>,
16    /// Precomputed wind vector for each segment (parallel to `winds`). The Monte-Carlo RK4
17    /// kernel queries wind 4x per step, so caching avoids recomputing sin/cos every call.
18    wind_vecs: Vec<Vector3<f64>>,
19    /// Current segment index
20    current: usize,
21    /// Distance where next segment starts
22    next_range: f64,
23    /// Current wind vector
24    current_vec: Vector3<f64>,
25}
26
27impl WindSock {
28    /// Create a new WindSock from wind segments
29    ///
30    /// Args:
31    ///     segments: List of (speed_kmh, angle_deg, until_distance_m) tuples
32    pub fn new(mut segments: Vec<WindSegment>) -> Self {
33        // Sort segments by distance, handling NaN safely by treating it as greater than any value
34        segments.sort_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Greater));
35
36        // Precompute each segment's wind vector once (depends only on its speed/angle).
37        let wind_vecs: Vec<Vector3<f64>> = segments.iter().map(Self::calc_vec).collect();
38
39        let (current, next_range, current_vec) = if segments.is_empty() {
40            (0, f64::INFINITY, Vector3::zeros())
41        } else {
42            (0, segments[0].2, wind_vecs[0])
43        };
44
45        WindSock {
46            winds: segments,
47            wind_vecs,
48            current,
49            next_range,
50            current_vec,
51        }
52    }
53
54    /// Calculate wind vector from wind segment
55    fn calc_vec(seg: &WindSegment) -> Vector3<f64> {
56        let (speed_kmh, angle_deg, _) = *seg;
57
58        // Convert kmh to m/s
59        let speed_mps = speed_kmh * KMH_TO_MPS;
60        let angle_rad = angle_deg * PI / 180.0;
61
62        // Wind convention (matching trajectory coordinates):
63        // 0° = headwind (from front, affects -x downrange)
64        // 90° = wind from right (affects -z lateral)
65        // 180° = tailwind (from back, affects +x downrange)
66        // 270° = wind from left (affects +z lateral)
67        //
68        // McCoy convention: x=downrange, y=vertical, z=lateral
69        Vector3::new(
70            -speed_mps * angle_rad.cos(), // x (downrange - head/tail component)
71            0.0,                          // y (vertical)
72            -speed_mps * angle_rad.sin(), // z (lateral - crosswind component)
73        )
74    }
75
76    /// Get wind vector for a given range
77    ///
78    /// Note: This modifies internal state and expects monotonically increasing ranges
79    /// For trajectory integration, we need a stateless version
80    pub fn vector_for_range(&mut self, range_m: f64) -> Vector3<f64> {
81        // Handle NaN
82        if range_m.is_nan() {
83            return Vector3::zeros();
84        }
85
86        // Advance the cursor across however many segments the query skipped (a single `if`
87        // returned a stale vector when a monotonic query jumped past a whole short segment).
88        while range_m >= self.next_range && self.current < self.winds.len() {
89            self.current += 1;
90            if self.current >= self.winds.len() {
91                self.current_vec = Vector3::zeros();
92                self.next_range = f64::INFINITY;
93            } else {
94                self.current_vec = self.wind_vecs[self.current];
95                self.next_range = self.winds[self.current].2;
96            }
97        }
98
99        self.current_vec
100    }
101
102    /// Get wind vector for a given range (stateless version)
103    ///
104    /// This version doesn't modify internal state and is safe for numerical integration
105    /// where the same range might be queried multiple times or out of order
106    pub fn vector_for_range_stateless(&self, range_m: f64) -> Vector3<f64> {
107        // Handle NaN
108        if range_m.is_nan() {
109            return Vector3::zeros();
110        }
111
112        // Find the appropriate segment (precomputed vector — no per-call trig).
113        for (i, segment) in self.winds.iter().enumerate() {
114            if range_m < segment.2 {
115                return self.wind_vecs[i];
116            }
117        }
118
119        // Beyond all segments
120        Vector3::zeros()
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn test_wind_sock_empty() {
130        let sock = WindSock::new(vec![]);
131        assert_eq!(sock.vector_for_range_stateless(50.0), Vector3::zeros());
132    }
133
134    #[test]
135    fn test_wind_sock_single_segment() {
136        // 16.0934 kmh (10 mph) @ 90° until 100m
137        let sock = WindSock::new(vec![(16.0934, 90.0, 100.0)]);
138
139        // Should have wind before 100m
140        let vec_50 = sock.vector_for_range_stateless(50.0);
141        println!("vec_50 = [{}, {}, {}]", vec_50[0], vec_50[1], vec_50[2]);
142        assert!(vec_50.norm() > 0.0);
143        // 90° wind from right (crosswind, McCoy): negative Z (lateral), zero Y, near-zero X (downrange)
144        assert!(
145            vec_50[2] < 0.0,
146            "Z (lateral) should be negative for 90° wind, got {}",
147            vec_50[2]
148        );
149        assert_eq!(vec_50[1], 0.0); // Zero Y component
150        assert!(
151            vec_50[0].abs() < 0.01,
152            "X (downrange) should be nearly zero for 90° wind, got {}",
153            vec_50[0]
154        );
155
156        // No wind after 100m
157        let vec_150 = sock.vector_for_range_stateless(150.0);
158        assert_eq!(vec_150, Vector3::zeros());
159    }
160
161    #[test]
162    fn test_wind_sock_multiple_segments() {
163        // Multiple wind segments (in kmh)
164        let sock = WindSock::new(vec![
165            (16.0934, 90.0, 50.0),  // 10 mph @ 90° until 50m
166            (24.1401, 45.0, 100.0), // 15 mph @ 45° until 100m
167            (8.0467, 180.0, 200.0), // 5 mph @ 180° until 200m
168        ]);
169
170        // Test each segment
171        let vec_25 = sock.vector_for_range_stateless(25.0);
172        println!("vec_25 = [{}, {}, {}]", vec_25[0], vec_25[1], vec_25[2]);
173        assert!(vec_25.norm() > 0.0);
174        assert!(vec_25[2] < 0.0, "90° wind should have negative Z (lateral)"); // 90° wind from right
175
176        let vec_75 = sock.vector_for_range_stateless(75.0);
177        println!("vec_75 = [{}, {}, {}]", vec_75[0], vec_75[1], vec_75[2]);
178        assert!(vec_75.norm() > vec_25.norm()); // 15 mph > 10 mph
179        assert!(vec_75[0] < 0.0); // 45° wind has negative X component
180        assert!(vec_75[2] < 0.0); // 45° wind has negative Z component
181
182        let vec_150 = sock.vector_for_range_stateless(150.0);
183        println!("vec_150 = [{}, {}, {}]", vec_150[0], vec_150[1], vec_150[2]);
184        assert!(vec_150.norm() < vec_75.norm()); // 5 mph < 15 mph
185        assert!(
186            vec_150[2].abs() < 0.01,
187            "180° wind should have near-zero Z (lateral), got {}",
188            vec_150[2]
189        ); // 180° wind (from behind)
190        assert!(
191            vec_150[0] > 0.0,
192            "180° wind should have positive X (tailwind, downrange), got {}",
193            vec_150[0]
194        ); // Tailwind
195
196        let vec_250 = sock.vector_for_range_stateless(250.0);
197        assert_eq!(vec_250, Vector3::zeros()); // Beyond all segments
198    }
199
200    #[test]
201    fn test_wind_conversion() {
202        // Test conversion: 16.0934 km/h = 4.47 m/s
203        let sock = WindSock::new(vec![(16.0934, 0.0, 100.0)]);
204        let vec = sock.vector_for_range_stateless(50.0);
205
206        let expected_speed = 16.0934 * KMH_TO_MPS;
207        assert!((vec.norm() - expected_speed).abs() < 0.01);
208    }
209}