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