Skip to main content

ballistics_engine/
wind.rs

1use nalgebra::Vector3;
2use std::cmp::Ordering;
3use std::f64::consts::PI;
4
5/// Conversion constant from KMH to MPS
6const KMH_TO_MPS: f64 = 1000.0 / 3600.0;
7
8/// THE wind-vector builder (McCoy frame: x downrange, y up, z right).
9/// Horizontal wind uses the wind-FROM convention (0 = headwind, PI/2 = from
10/// the right); `vertical_mps` is positive-updraft and lands on y UNSCALED —
11/// boundary-layer shear models scale horizontal flow only (MBA-728 decision).
12pub fn wind_vector(speed_mps: f64, direction_rad: f64, vertical_mps: f64) -> Vector3<f64> {
13    Vector3::new(
14        -speed_mps * direction_rad.cos(),
15        vertical_mps,
16        -speed_mps * direction_rad.sin(),
17    )
18}
19
20/// One downrange wind segment. `vertical_mps` (m/s, positive = updraft) feeds
21/// straight into the segment's wind vector via [`wind_vector`] (MBA-728);
22/// boundary-layer shear scales horizontal wind only, so vertical passes
23/// through unscaled wherever shear is applied on top of a segment.
24///
25/// This matches the Python WindSock interface.
26#[derive(Debug, Clone, Copy, PartialEq, Default)]
27pub struct WindSegment {
28    pub speed_kmh: f64,
29    pub angle_deg: f64,
30    pub until_m: f64,
31    pub vertical_mps: f64,
32}
33
34impl WindSegment {
35    /// The historical 3-field constructor (vertical 0.0) — used by every
36    /// pre-existing call site.
37    pub fn new(speed_kmh: f64, angle_deg: f64, until_m: f64) -> Self {
38        Self {
39            speed_kmh,
40            angle_deg,
41            until_m,
42            vertical_mps: 0.0,
43        }
44    }
45}
46
47/// Sort wind segments by their `until_distance_m` threshold.
48///
49/// Shared by [`WindSock`] and the low-level trajectory integrator so every segmented-wind path
50/// applies the same interval ordering.
51pub(crate) fn sort_wind_segments_by_distance(segments: &mut [WindSegment]) {
52    segments.sort_by(|a, b| match (a.until_m.is_nan(), b.until_m.is_nan()) {
53        (true, true) => Ordering::Equal,
54        (true, false) => Ordering::Greater,
55        (false, true) => Ordering::Less,
56        (false, false) => {
57            a.until_m
58                .partial_cmp(&b.until_m)
59                .expect("non-NaN distances are ordered")
60        }
61    });
62}
63
64pub(crate) fn validate_wind_segments(segments: &[WindSegment]) -> Result<(), String> {
65    for (index, segment) in segments.iter().enumerate() {
66        let field = |name: &str| format!("wind.segments[{index}].{name}");
67
68        if !segment.speed_kmh.is_finite() || segment.speed_kmh < 0.0 {
69            return Err(format!(
70                "{} must be finite and non-negative",
71                field("speed_kmh")
72            ));
73        }
74        if !segment.angle_deg.is_finite() {
75            return Err(format!("{} must be finite", field("angle_deg")));
76        }
77        if !segment.until_m.is_finite() || segment.until_m <= 0.0 {
78            return Err(format!(
79                "{} must be finite and greater than zero",
80                field("until_m")
81            ));
82        }
83        if !segment.vertical_mps.is_finite() {
84            return Err(format!("{} must be finite", field("vertical_mps")));
85        }
86    }
87
88    Ok(())
89}
90
91/// Wind condition handler for trajectory calculations
92#[derive(Debug, Clone)]
93pub struct WindSock {
94    /// Sorted wind segments by distance
95    winds: Vec<WindSegment>,
96    /// Precomputed wind vector for each segment (parallel to `winds`). The Monte-Carlo RK4
97    /// kernel queries wind 4x per step, so caching avoids recomputing sin/cos every call.
98    wind_vecs: Vec<Vector3<f64>>,
99    /// Current segment index
100    current: usize,
101    /// Distance where next segment starts
102    next_range: f64,
103    /// Current wind vector
104    current_vec: Vector3<f64>,
105    /// Validation result captured before sorting, preserving the caller's segment index.
106    validation_error: Option<String>,
107}
108
109impl WindSock {
110    /// Create a new WindSock from wind segments
111    ///
112    /// Args:
113    ///     segments: List of (speed_kmh, angle_deg, until_distance_m) tuples
114    pub fn new(mut segments: Vec<WindSegment>) -> Self {
115        // Keep this infallible for API compatibility, but validate before sorting so an error can
116        // identify the segment index supplied by the caller. The solver consumes this deferred
117        // error at its common pre-integration validation boundary.
118        let validation_error = validate_wind_segments(&segments).err();
119
120        // Sort segments by distance, handling NaN safely by treating it as greater than any value
121        sort_wind_segments_by_distance(&mut segments);
122
123        // Precompute each segment's wind vector once (depends only on its speed/angle).
124        let wind_vecs: Vec<Vector3<f64>> = segments.iter().map(Self::calc_vec).collect();
125
126        let (current, next_range, current_vec) = if segments.is_empty() {
127            (0, f64::INFINITY, Vector3::zeros())
128        } else {
129            (0, segments[0].until_m, wind_vecs[0])
130        };
131
132        WindSock {
133            winds: segments,
134            wind_vecs,
135            current,
136            next_range,
137            current_vec,
138            validation_error,
139        }
140    }
141
142    /// Return any malformed-segment error captured before normalization.
143    pub(crate) fn validate_segments(&self) -> Result<(), String> {
144        self.validation_error.clone().map_or(Ok(()), Err)
145    }
146
147    /// Calculate wind vector from wind segment
148    fn calc_vec(seg: &WindSegment) -> Vector3<f64> {
149        // Convert kmh to m/s
150        let speed_mps = seg.speed_kmh * KMH_TO_MPS;
151        // Preserve the historical multiply-then-divide result for ordinary angles, while
152        // avoiding intermediate overflow for otherwise-valid finite values near f64::MAX.
153        let angle_rad = if seg.angle_deg.abs() <= f64::MAX / PI {
154            seg.angle_deg * PI / 180.0
155        } else {
156            seg.angle_deg / 180.0 * PI
157        };
158
159        // Wind convention (matching trajectory coordinates):
160        // 0° = headwind (from front, affects -x downrange)
161        // 90° = wind from right (affects -z lateral)
162        // 180° = tailwind (from back, affects +x downrange)
163        // 270° = wind from left (affects +z lateral)
164        //
165        // McCoy convention: x=downrange, y=vertical, z=lateral. Vertical (MBA-728) passes
166        // straight through per-segment; it is not derived from speed_kmh/angle_deg.
167        wind_vector(speed_mps, angle_rad, seg.vertical_mps)
168    }
169
170    /// Upper bound (m/s) on the wind speed any segment can contribute, horizontal
171    /// plus vertical. Feeds the solver's integration divergence guard (MBA-1293).
172    pub fn max_speed_mps(&self) -> f64 {
173        self.winds
174            .iter()
175            .map(|seg| seg.speed_kmh.abs() * KMH_TO_MPS + seg.vertical_mps.abs())
176            .fold(0.0, f64::max)
177    }
178
179    /// Crosswind at the muzzle in the aerodynamic-jump convention: positive means wind from
180    /// the right. `None` means there are no segmented winds, while a real zero-crosswind
181    /// segment returns `Some(0.0)` so callers do not incorrectly fall back to scalar wind.
182    pub(crate) fn muzzle_crosswind_from_right_mps(&self) -> Option<f64> {
183        (!self.winds.is_empty()).then(|| -self.vector_for_range_stateless(0.0)[2])
184    }
185
186    /// Get wind vector for a given range
187    ///
188    /// Note: This modifies internal state and expects monotonically increasing ranges
189    /// For trajectory integration, we need a stateless version
190    pub fn vector_for_range(&mut self, range_m: f64) -> Vector3<f64> {
191        // Handle NaN
192        if range_m.is_nan() {
193            return Vector3::zeros();
194        }
195
196        // Advance the cursor across however many segments the query skipped (a single `if`
197        // returned a stale vector when a monotonic query jumped past a whole short segment).
198        while range_m >= self.next_range && self.current < self.winds.len() {
199            self.current += 1;
200            if self.current >= self.winds.len() {
201                self.current_vec = Vector3::zeros();
202                self.next_range = f64::INFINITY;
203            } else {
204                self.current_vec = self.wind_vecs[self.current];
205                self.next_range = self.winds[self.current].until_m;
206            }
207        }
208
209        self.current_vec
210    }
211
212    /// Get wind vector for a given range (stateless version)
213    ///
214    /// This version doesn't modify internal state and is safe for numerical integration
215    /// where the same range might be queried multiple times or out of order
216    pub fn vector_for_range_stateless(&self, range_m: f64) -> Vector3<f64> {
217        // Handle NaN
218        if range_m.is_nan() {
219            return Vector3::zeros();
220        }
221
222        // Find the appropriate segment (precomputed vector — no per-call trig).
223        for (i, segment) in self.winds.iter().enumerate() {
224            if range_m < segment.until_m {
225                return self.wind_vecs[i];
226            }
227        }
228
229        // Beyond all segments
230        Vector3::zeros()
231    }
232}
233
234/// Parse a `"SPEED:ANGLE:UNTIL_DISTANCE[:VERTICAL]"` string into a [`WindSegment`]
235/// `(speed_kmh, angle_deg, until_distance_m, vertical_mps)`.
236///
237/// `imperial`: when true, SPEED is mph and UNTIL_DISTANCE is yards; otherwise
238/// SPEED is m/s and UNTIL_DISTANCE is meters. ANGLE is always degrees in the
239/// wind-FROM convention (0 = headwind, 90 = from the right). The optional 4th
240/// field, VERTICAL, is ALWAYS m/s (positive = updraft, raises POI) regardless of
241/// `imperial` — it does not follow --units, matching how [`WindSegment::vertical_mps`]
242/// stores it. This speed-in-display-units-but-vertical-always-m/s asymmetry is
243/// unit-honest (it mirrors the struct field name) even though it reads oddly next
244/// to SPEED. Omitting the 4th field keeps the historical 3-field behavior
245/// (vertical wind 0.0). Shared by the CLI (`--wind-segment`) and the WASM
246/// front-ends so they parse identically.
247pub fn parse_wind_segment_str(s: &str, imperial: bool) -> Result<WindSegment, String> {
248    let parts: Vec<&str> = s.split(':').collect();
249    if parts.len() != 3 && parts.len() != 4 {
250        return Err(format!(
251            "invalid wind segment '{s}': expected SPEED:ANGLE:UNTIL_DISTANCE[:VERTICAL] \
252             (three or four colon-separated numbers; the optional 4th field VERTICAL is always \
253             m/s, positive = updraft, regardless of --units)"
254        ));
255    }
256    let num = |i: usize, name: &str| -> Result<f64, String> {
257        parts[i].trim().parse::<f64>().map_err(|_| {
258            format!("invalid wind segment '{s}': {name} '{}' is not a number", parts[i])
259        })
260    };
261    let speed = num(0, "speed")?;
262    let angle = num(1, "angle")?;
263    let until = num(2, "until-distance")?;
264    let vertical = if parts.len() == 4 {
265        num(3, "vertical")?
266    } else {
267        0.0
268    };
269    if !speed.is_finite() || !angle.is_finite() || !until.is_finite() || !vertical.is_finite() {
270        return Err(format!(
271            "invalid wind segment '{s}': speed, angle, until-distance, and vertical (m/s, \
272             positive = updraft) must be finite numbers"
273        ));
274    }
275    if speed < 0.0 {
276        return Err(format!("invalid wind segment '{s}': speed must be >= 0"));
277    }
278    if until <= 0.0 {
279        return Err(format!("invalid wind segment '{s}': until-distance must be > 0"));
280    }
281    let (speed_kmh, until_m) = if imperial {
282        (speed * 1.609344, until * 0.9144) // mph -> km/h, yards -> meters
283    } else {
284        (speed * 3.6, until) // m/s -> km/h, meters -> meters
285    };
286    let mut segment = WindSegment::new(speed_kmh, angle, until_m);
287    segment.vertical_mps = vertical;
288    Ok(segment)
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn segment_sort_is_stable_and_places_nan_endpoints_last() {
297        let mut segments = vec![
298            WindSegment::new(10.0, 0.0, f64::NAN),
299            WindSegment::new(20.0, 0.0, 100.0),
300            WindSegment::new(30.0, 0.0, 100.0),
301            WindSegment::new(40.0, 0.0, f64::INFINITY),
302            WindSegment::new(50.0, 0.0, f64::NEG_INFINITY),
303            WindSegment::new(60.0, 0.0, f64::NAN),
304        ];
305
306        sort_wind_segments_by_distance(&mut segments);
307
308        assert_eq!(segments[0].speed_kmh, 50.0); // -inf first
309        assert_eq!(segments[1].speed_kmh, 20.0); // equal endpoints retain input order
310        assert_eq!(segments[2].speed_kmh, 30.0);
311        assert_eq!(segments[3].speed_kmh, 40.0); // +inf after finite endpoints
312        assert_eq!(segments[4].speed_kmh, 10.0); // NaNs last and stable
313        assert_eq!(segments[5].speed_kmh, 60.0);
314        assert!(segments[4].until_m.is_nan() && segments[5].until_m.is_nan());
315    }
316
317    #[test]
318    fn test_wind_sock_empty() {
319        let sock = WindSock::new(vec![]);
320        assert_eq!(sock.vector_for_range_stateless(50.0), Vector3::zeros());
321        assert_eq!(sock.muzzle_crosswind_from_right_mps(), None);
322    }
323
324    #[test]
325    fn muzzle_crosswind_distinguishes_an_explicit_zero_segment() {
326        let sock = WindSock::new(vec![WindSegment::new(0.0, 90.0, 100.0)]);
327        assert_eq!(sock.muzzle_crosswind_from_right_mps(), Some(0.0));
328    }
329
330    #[test]
331    fn muzzle_crosswind_uses_the_sorted_muzzle_segment_and_wind_from_sign() {
332        let from_right = WindSock::new(vec![
333            WindSegment::new(32.18688, 270.0, 5000.0),
334            WindSegment::new(16.09344, 90.0, 100.0),
335        ]);
336        let right_mps = from_right.muzzle_crosswind_from_right_mps().unwrap();
337        assert!((right_mps - 4.4704).abs() < 1e-12);
338
339        let from_left = WindSock::new(vec![WindSegment::new(16.09344, 270.0, 100.0)]);
340        let left_mps = from_left.muzzle_crosswind_from_right_mps().unwrap();
341        assert!((left_mps + 4.4704).abs() < 1e-12);
342    }
343
344    #[test]
345    fn test_wind_sock_single_segment() {
346        // 16.0934 kmh (10 mph) @ 90° until 100m
347        let sock = WindSock::new(vec![WindSegment::new(16.0934, 90.0, 100.0)]);
348
349        // Should have wind before 100m
350        let vec_50 = sock.vector_for_range_stateless(50.0);
351        println!("vec_50 = [{}, {}, {}]", vec_50[0], vec_50[1], vec_50[2]);
352        assert!(vec_50.norm() > 0.0);
353        // 90° wind from right (crosswind, McCoy): negative Z (lateral), zero Y, near-zero X (downrange)
354        assert!(
355            vec_50[2] < 0.0,
356            "Z (lateral) should be negative for 90° wind, got {}",
357            vec_50[2]
358        );
359        assert_eq!(vec_50[1], 0.0); // Zero Y component (vertical_mps defaults to 0.0, MBA-728)
360        assert!(
361            vec_50[0].abs() < 0.01,
362            "X (downrange) should be nearly zero for 90° wind, got {}",
363            vec_50[0]
364        );
365
366        // No wind after 100m
367        let vec_150 = sock.vector_for_range_stateless(150.0);
368        assert_eq!(vec_150, Vector3::zeros());
369    }
370
371    #[test]
372    fn test_wind_sock_multiple_segments() {
373        // Multiple wind segments (in kmh)
374        let sock = WindSock::new(vec![
375            WindSegment::new(16.0934, 90.0, 50.0),  // 10 mph @ 90° until 50m
376            WindSegment::new(24.1401, 45.0, 100.0), // 15 mph @ 45° until 100m
377            WindSegment::new(8.0467, 180.0, 200.0), // 5 mph @ 180° until 200m
378        ]);
379
380        // Test each segment
381        let vec_25 = sock.vector_for_range_stateless(25.0);
382        println!("vec_25 = [{}, {}, {}]", vec_25[0], vec_25[1], vec_25[2]);
383        assert!(vec_25.norm() > 0.0);
384        assert!(vec_25[2] < 0.0, "90° wind should have negative Z (lateral)"); // 90° wind from right
385
386        let vec_75 = sock.vector_for_range_stateless(75.0);
387        println!("vec_75 = [{}, {}, {}]", vec_75[0], vec_75[1], vec_75[2]);
388        assert!(vec_75.norm() > vec_25.norm()); // 15 mph > 10 mph
389        assert!(vec_75[0] < 0.0); // 45° wind has negative X component
390        assert!(vec_75[2] < 0.0); // 45° wind has negative Z component
391
392        let vec_150 = sock.vector_for_range_stateless(150.0);
393        println!("vec_150 = [{}, {}, {}]", vec_150[0], vec_150[1], vec_150[2]);
394        assert!(vec_150.norm() < vec_75.norm()); // 5 mph < 15 mph
395        assert!(
396            vec_150[2].abs() < 0.01,
397            "180° wind should have near-zero Z (lateral), got {}",
398            vec_150[2]
399        ); // 180° wind (from behind)
400        assert!(
401            vec_150[0] > 0.0,
402            "180° wind should have positive X (tailwind, downrange), got {}",
403            vec_150[0]
404        ); // Tailwind
405
406        let vec_250 = sock.vector_for_range_stateless(250.0);
407        assert_eq!(vec_250, Vector3::zeros()); // Beyond all segments
408    }
409
410    #[test]
411    fn test_wind_conversion() {
412        // Test conversion: 16.0934 km/h = 4.47 m/s
413        let sock = WindSock::new(vec![WindSegment::new(16.0934, 0.0, 100.0)]);
414        let vec = sock.vector_for_range_stateless(50.0);
415
416        let expected_speed = 16.0934 * KMH_TO_MPS;
417        assert!((vec.norm() - expected_speed).abs() < 0.01);
418    }
419
420    #[test]
421    fn test_wind_sock_boundary_is_upper_exclusive() {
422        // A segment's `until_distance_m` is exclusive: a query exactly at the
423        // boundary rolls to the next segment.
424        let sock = WindSock::new(vec![
425            WindSegment::new(16.0934, 90.0, 100.0),
426            WindSegment::new(32.1868, 270.0, 200.0),
427        ]);
428        // Just below 100 m -> first segment (90deg, negative Z).
429        assert!(sock.vector_for_range_stateless(99.999)[2] < 0.0);
430        // Exactly 100 m -> second segment (270deg, positive Z).
431        assert!(sock.vector_for_range_stateless(100.0)[2] > 0.0);
432        // Beyond the last boundary -> zero.
433        assert_eq!(sock.vector_for_range_stateless(200.0), Vector3::zeros());
434    }
435
436    #[test]
437    fn calc_vec_passes_through_segment_vertical_unscaled() {
438        // MBA-728: a segment's vertical_mps must land unchanged on the wind vector's Y
439        // component, independent of speed/angle.
440        let seg = WindSegment {
441            speed_kmh: 16.0934,
442            angle_deg: 90.0,
443            until_m: 100.0,
444            vertical_mps: 3.0,
445        };
446        let vec = WindSock::calc_vec(&seg);
447        assert_eq!(vec[1], 3.0);
448    }
449
450    #[test]
451    fn calc_vec_zero_vertical_segment_keeps_zero_y() {
452        // Upgrades (does not replace) the zero-Y assertions above: the historical
453        // 3-field constructor still yields vertical_mps == 0.0 -> Y == 0.0.
454        let seg = WindSegment::new(16.0934, 90.0, 100.0);
455        assert_eq!(seg.vertical_mps, 0.0);
456        let vec = WindSock::calc_vec(&seg);
457        assert_eq!(vec[1], 0.0);
458    }
459
460    #[test]
461    fn test_parse_wind_segment_str_units() {
462        // Imperial: 10 mph -> 16.0934 km/h, 100 yd -> 91.44 m.
463        let seg = parse_wind_segment_str("10:90:100", true).unwrap();
464        assert!((seg.speed_kmh - 16.09344).abs() < 1e-4);
465        assert_eq!(seg.angle_deg, 90.0);
466        assert!((seg.until_m - 91.44).abs() < 1e-4);
467
468        // Metric: 5 m/s -> 18 km/h, 200 m stays 200 m.
469        let seg = parse_wind_segment_str("5:270:200", false).unwrap();
470        assert!((seg.speed_kmh - 18.0).abs() < 1e-9);
471        assert_eq!(seg.angle_deg, 270.0);
472        assert!((seg.until_m - 200.0).abs() < 1e-9);
473
474        // Malformed inputs are rejected.
475        assert!(parse_wind_segment_str("10:90", true).is_err()); // too few fields
476        assert!(parse_wind_segment_str("10:bad:100", true).is_err()); // non-numeric
477        assert!(parse_wind_segment_str("10:90:0", true).is_err()); // zero until-distance
478        assert!(parse_wind_segment_str("-3:90:100", true).is_err()); // negative speed
479        // Non-finite values must be rejected (NaN comparisons would slip past < / <=).
480        assert!(parse_wind_segment_str("10:nan:5000", true).is_err());
481        assert!(parse_wind_segment_str("10:90:nan", true).is_err());
482        assert!(parse_wind_segment_str("inf:90:100", true).is_err());
483    }
484
485    #[test]
486    fn test_parse_wind_segment_str_vertical_field() {
487        // MBA-728: 3-field input is unchanged (backward compat) -> vertical_mps == 0.0.
488        let seg = parse_wind_segment_str("10:90:100", true).unwrap();
489        assert_eq!(seg.vertical_mps, 0.0);
490        let seg = parse_wind_segment_str("5:270:200", false).unwrap();
491        assert_eq!(seg.vertical_mps, 0.0);
492
493        // 4-field input parses the vertical component, m/s, regardless of --units.
494        let seg = parse_wind_segment_str("10:90:100:5", true).unwrap();
495        assert_eq!(seg.vertical_mps, 5.0);
496        // The rest of the fields still go through the imperial conversion.
497        assert!((seg.speed_kmh - 16.09344).abs() < 1e-4);
498        assert!((seg.until_m - 91.44).abs() < 1e-4);
499
500        // Vertical is NOT converted by --units (always m/s): metric and imperial parses of the
501        // same "...:5" 4th field land on the identical vertical_mps.
502        let seg_metric = parse_wind_segment_str("5:270:200:5", false).unwrap();
503        assert_eq!(seg_metric.vertical_mps, 5.0);
504
505        // Negative vertical (downdraft) is valid.
506        let seg_neg = parse_wind_segment_str("10:90:100:-3.5", true).unwrap();
507        assert_eq!(seg_neg.vertical_mps, -3.5);
508
509        // A non-numeric or non-finite 4th field is rejected.
510        assert!(parse_wind_segment_str("10:90:100:bad", true).is_err());
511        assert!(parse_wind_segment_str("10:90:100:nan", true).is_err());
512        assert!(parse_wind_segment_str("10:90:100:inf", true).is_err());
513
514        // Too many fields is still rejected.
515        assert!(parse_wind_segment_str("10:90:100:5:1", true).is_err());
516    }
517}