Skip to main content

ballistics_engine/
wind.rs

1use nalgebra::Vector3;
2use std::cmp::Ordering;
3use std::f64::consts::PI;
4use thiserror::Error;
5
6/// Conversion constant from KMH to MPS
7const KMH_TO_MPS: f64 = 1000.0 / 3600.0;
8
9/// THE wind-vector builder (McCoy frame: x downrange, y up, z right).
10/// Horizontal wind uses the wind-FROM convention (0 = headwind, PI/2 = from
11/// the right); `vertical_mps` is positive-updraft and lands on y UNSCALED —
12/// boundary-layer shear models scale horizontal flow only (MBA-728 decision).
13pub fn wind_vector(speed_mps: f64, direction_rad: f64, vertical_mps: f64) -> Vector3<f64> {
14    Vector3::new(
15        -speed_mps * direction_rad.cos(),
16        vertical_mps,
17        -speed_mps * direction_rad.sin(),
18    )
19}
20
21/// One downrange wind segment. `vertical_mps` (m/s, positive = updraft) feeds
22/// straight into the segment's wind vector via [`wind_vector`] (MBA-728);
23/// boundary-layer shear scales horizontal wind only, so vertical passes
24/// through unscaled wherever shear is applied on top of a segment.
25///
26/// This matches the Python WindSock interface.
27#[derive(Debug, Clone, Copy, PartialEq, Default)]
28pub struct WindSegment {
29    pub speed_kmh: f64,
30    pub angle_deg: f64,
31    pub until_m: f64,
32    pub vertical_mps: f64,
33}
34
35impl WindSegment {
36    /// The historical 3-field constructor (vertical 0.0) — used by every
37    /// pre-existing call site.
38    pub fn new(speed_kmh: f64, angle_deg: f64, until_m: f64) -> Self {
39        Self {
40            speed_kmh,
41            angle_deg,
42            until_m,
43            vertical_mps: 0.0,
44        }
45    }
46}
47
48/// Sort wind segments by their `until_distance_m` threshold.
49///
50/// Shared by [`WindSock`] and the low-level trajectory integrator so every segmented-wind path
51/// applies the same interval ordering.
52pub(crate) fn sort_wind_segments_by_distance(segments: &mut [WindSegment]) {
53    segments.sort_by(|a, b| match (a.until_m.is_nan(), b.until_m.is_nan()) {
54        (true, true) => Ordering::Equal,
55        (true, false) => Ordering::Greater,
56        (false, true) => Ordering::Less,
57        (false, false) => {
58            a.until_m
59                .partial_cmp(&b.until_m)
60                .expect("non-NaN distances are ordered")
61        }
62    });
63}
64
65/// Which [`WindSegment`] field failed validation (MBA-1338).
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum WindSegmentField {
68    SpeedKmh,
69    AngleDeg,
70    UntilM,
71    VerticalMps,
72}
73
74impl WindSegmentField {
75    /// The struct field name, as it appears in error messages and the public API.
76    pub fn name(self) -> &'static str {
77        match self {
78            WindSegmentField::SpeedKmh => "speed_kmh",
79            WindSegmentField::AngleDeg => "angle_deg",
80            WindSegmentField::UntilM => "until_m",
81            WindSegmentField::VerticalMps => "vertical_mps",
82        }
83    }
84}
85
86impl std::fmt::Display for WindSegmentField {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        f.write_str(self.name())
89    }
90}
91
92/// The validation rule a [`WindSegment`] field violated (MBA-1338).
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum WindSegmentRule {
95    /// The value must be finite (`angle_deg`, `vertical_mps`).
96    Finite,
97    /// The value must be finite and `>= 0` (`speed_kmh`).
98    FiniteAndNonNegative,
99    /// The value must be finite and `> 0` (`until_m`).
100    FiniteAndPositive,
101}
102
103impl std::fmt::Display for WindSegmentRule {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        // Phrasing matches the historical String errors exactly so consumers that surface
106        // Display output (FastSolution, solver boundaries, bindings) see unchanged messages.
107        f.write_str(match self {
108            WindSegmentRule::Finite => "finite",
109            WindSegmentRule::FiniteAndNonNegative => "finite and non-negative",
110            WindSegmentRule::FiniteAndPositive => "finite and greater than zero",
111        })
112    }
113}
114
115/// A malformed [`WindSegment`] rejected at a checked construction/solve boundary (MBA-1338).
116///
117/// `index` is the segment's position in the vector **as supplied by the caller** —
118/// validation runs before any sorting or normalization, so the index always refers to
119/// the caller's own ordering.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
121#[error("wind.segments[{index}].{field} must be {rule}")]
122pub struct WindSegmentError {
123    pub index: usize,
124    pub field: WindSegmentField,
125    pub rule: WindSegmentRule,
126}
127
128/// Validate every segment, reporting the first violation as a typed error carrying the
129/// caller's segment index, the offending field, and the violated rule (MBA-1338).
130///
131/// Checked APIs ([`WindSock::try_new`], `try_integrate_trajectory`,
132/// `try_solve_trajectory_rust`) call this before sorting, vector precomputation, or
133/// producing any trajectory points.
134pub fn validate_wind_segments(segments: &[WindSegment]) -> Result<(), WindSegmentError> {
135    for (index, segment) in segments.iter().enumerate() {
136        let err = |field, rule| Err(WindSegmentError { index, field, rule });
137
138        if !segment.speed_kmh.is_finite() || segment.speed_kmh < 0.0 {
139            return err(
140                WindSegmentField::SpeedKmh,
141                WindSegmentRule::FiniteAndNonNegative,
142            );
143        }
144        if !segment.angle_deg.is_finite() {
145            return err(WindSegmentField::AngleDeg, WindSegmentRule::Finite);
146        }
147        if !segment.until_m.is_finite() || segment.until_m <= 0.0 {
148            return err(WindSegmentField::UntilM, WindSegmentRule::FiniteAndPositive);
149        }
150        if !segment.vertical_mps.is_finite() {
151            return err(WindSegmentField::VerticalMps, WindSegmentRule::Finite);
152        }
153    }
154
155    Ok(())
156}
157
158/// Wind condition handler for trajectory calculations
159#[derive(Debug, Clone)]
160pub struct WindSock {
161    /// Sorted wind segments by distance
162    winds: Vec<WindSegment>,
163    /// Precomputed wind vector for each segment (parallel to `winds`). The Monte-Carlo RK4
164    /// kernel queries wind 4x per step, so caching avoids recomputing sin/cos every call.
165    wind_vecs: Vec<Vector3<f64>>,
166    /// Current segment index
167    current: usize,
168    /// Distance where next segment starts
169    next_range: f64,
170    /// Current wind vector
171    current_vec: Vector3<f64>,
172    /// Validation result captured before sorting, preserving the caller's segment index.
173    validation_error: Option<WindSegmentError>,
174}
175
176impl WindSock {
177    /// Create a new WindSock from wind segments.
178    ///
179    /// **Legacy/unchecked entry point** (MBA-1338): this constructor is infallible for API
180    /// compatibility — a malformed segment is captured as a deferred error that the solver
181    /// consumes at its pre-integration validation boundary, but a direct library consumer
182    /// gets a constructed sock with no structured signal. Prefer [`WindSock::try_new`]
183    /// (or `TryFrom<Vec<WindSegment>>`), which rejects malformed segments up front.
184    ///
185    /// Args:
186    ///     segments: List of (speed_kmh, angle_deg, until_distance_m) tuples
187    pub fn new(segments: Vec<WindSegment>) -> Self {
188        // Validate before sorting so an error can identify the segment index supplied by
189        // the caller; defer it rather than fail (see doc above).
190        let validation_error = validate_wind_segments(&segments).err();
191        Self::build_unchecked(segments, validation_error)
192    }
193
194    /// Create a new WindSock, rejecting malformed segments with a typed error (MBA-1338).
195    ///
196    /// Validation runs **before** sorting and wind-vector precomputation, and the error's
197    /// `index` refers to the caller's own segment ordering. Rejects non-finite or negative
198    /// `speed_kmh`, non-finite `angle_deg`, non-finite or non-positive `until_m`, and
199    /// non-finite `vertical_mps`.
200    pub fn try_new(segments: Vec<WindSegment>) -> Result<Self, WindSegmentError> {
201        validate_wind_segments(&segments)?;
202        Ok(Self::build_unchecked(segments, None))
203    }
204
205    /// Shared constructor body: sort, precompute vectors, seed the cursor.
206    fn build_unchecked(
207        mut segments: Vec<WindSegment>,
208        validation_error: Option<WindSegmentError>,
209    ) -> Self {
210        // Sort segments by distance, handling NaN safely by treating it as greater than any value
211        sort_wind_segments_by_distance(&mut segments);
212
213        // Precompute each segment's wind vector once (depends only on its speed/angle).
214        let wind_vecs: Vec<Vector3<f64>> = segments.iter().map(Self::calc_vec).collect();
215
216        let (current, next_range, current_vec) = if segments.is_empty() {
217            (0, f64::INFINITY, Vector3::zeros())
218        } else {
219            (0, segments[0].until_m, wind_vecs[0])
220        };
221
222        WindSock {
223            winds: segments,
224            wind_vecs,
225            current,
226            next_range,
227            current_vec,
228            validation_error,
229        }
230    }
231
232    /// Return any malformed-segment error captured before normalization.
233    pub(crate) fn validate_segments(&self) -> Result<(), String> {
234        self.validation_error
235            .map_or(Ok(()), |err| Err(err.to_string()))
236    }
237
238    /// Calculate wind vector from wind segment
239    fn calc_vec(seg: &WindSegment) -> Vector3<f64> {
240        // Convert kmh to m/s
241        let speed_mps = seg.speed_kmh * KMH_TO_MPS;
242        // Preserve the historical multiply-then-divide result for ordinary angles, while
243        // avoiding intermediate overflow for otherwise-valid finite values near f64::MAX.
244        let angle_rad = if seg.angle_deg.abs() <= f64::MAX / PI {
245            seg.angle_deg * PI / 180.0
246        } else {
247            seg.angle_deg / 180.0 * PI
248        };
249
250        // Wind convention (matching trajectory coordinates):
251        // 0° = headwind (from front, affects -x downrange)
252        // 90° = wind from right (affects -z lateral)
253        // 180° = tailwind (from back, affects +x downrange)
254        // 270° = wind from left (affects +z lateral)
255        //
256        // McCoy convention: x=downrange, y=vertical, z=lateral. Vertical (MBA-728) passes
257        // straight through per-segment; it is not derived from speed_kmh/angle_deg.
258        wind_vector(speed_mps, angle_rad, seg.vertical_mps)
259    }
260
261    /// Upper bound (m/s) on the wind speed any segment can contribute, horizontal
262    /// plus vertical. Feeds the solver's integration divergence guard (MBA-1293).
263    pub fn max_speed_mps(&self) -> f64 {
264        self.winds
265            .iter()
266            .map(|seg| seg.speed_kmh.abs() * KMH_TO_MPS + seg.vertical_mps.abs())
267            .fold(0.0, f64::max)
268    }
269
270    /// Crosswind at the muzzle in the aerodynamic-jump convention: positive means wind from
271    /// the right. `None` means there are no segmented winds, while a real zero-crosswind
272    /// segment returns `Some(0.0)` so callers do not incorrectly fall back to scalar wind.
273    pub(crate) fn muzzle_crosswind_from_right_mps(&self) -> Option<f64> {
274        (!self.winds.is_empty()).then(|| -self.vector_for_range_stateless(0.0)[2])
275    }
276
277    /// Get wind vector for a given range
278    ///
279    /// Note: This modifies internal state and expects monotonically increasing ranges
280    /// For trajectory integration, we need a stateless version
281    pub fn vector_for_range(&mut self, range_m: f64) -> Vector3<f64> {
282        // Handle NaN
283        if range_m.is_nan() {
284            return Vector3::zeros();
285        }
286
287        // Advance the cursor across however many segments the query skipped (a single `if`
288        // returned a stale vector when a monotonic query jumped past a whole short segment).
289        while range_m >= self.next_range && self.current < self.winds.len() {
290            self.current += 1;
291            if self.current >= self.winds.len() {
292                self.current_vec = Vector3::zeros();
293                self.next_range = f64::INFINITY;
294            } else {
295                self.current_vec = self.wind_vecs[self.current];
296                self.next_range = self.winds[self.current].until_m;
297            }
298        }
299
300        self.current_vec
301    }
302
303    /// Get wind vector for a given range (stateless version)
304    ///
305    /// This version doesn't modify internal state and is safe for numerical integration
306    /// where the same range might be queried multiple times or out of order
307    pub fn vector_for_range_stateless(&self, range_m: f64) -> Vector3<f64> {
308        // Handle NaN
309        if range_m.is_nan() {
310            return Vector3::zeros();
311        }
312
313        // Find the appropriate segment (precomputed vector — no per-call trig).
314        for (i, segment) in self.winds.iter().enumerate() {
315            if range_m < segment.until_m {
316                return self.wind_vecs[i];
317            }
318        }
319
320        // Beyond all segments
321        Vector3::zeros()
322    }
323}
324
325/// Checked conversion mirroring [`WindSock::try_new`] (MBA-1338).
326impl TryFrom<Vec<WindSegment>> for WindSock {
327    type Error = WindSegmentError;
328
329    fn try_from(segments: Vec<WindSegment>) -> Result<Self, Self::Error> {
330        WindSock::try_new(segments)
331    }
332}
333
334/// Parse a `"SPEED:ANGLE:UNTIL_DISTANCE[:VERTICAL]"` string into a [`WindSegment`]
335/// `(speed_kmh, angle_deg, until_distance_m, vertical_mps)`.
336///
337/// `imperial`: when true, SPEED is mph and UNTIL_DISTANCE is yards; otherwise
338/// SPEED is m/s and UNTIL_DISTANCE is meters. ANGLE is always degrees in the
339/// wind-FROM convention (0 = headwind, 90 = from the right). The optional 4th
340/// field, VERTICAL, is ALWAYS m/s (positive = updraft, raises POI) regardless of
341/// `imperial` — it does not follow --units, matching how [`WindSegment::vertical_mps`]
342/// stores it. This speed-in-display-units-but-vertical-always-m/s asymmetry is
343/// unit-honest (it mirrors the struct field name) even though it reads oddly next
344/// to SPEED. Omitting the 4th field keeps the historical 3-field behavior
345/// (vertical wind 0.0). Shared by the CLI (`--wind-segment`) and the WASM
346/// front-ends so they parse identically.
347pub fn parse_wind_segment_str(s: &str, imperial: bool) -> Result<WindSegment, String> {
348    let parts: Vec<&str> = s.split(':').collect();
349    if parts.len() != 3 && parts.len() != 4 {
350        return Err(format!(
351            "invalid wind segment '{s}': expected SPEED:ANGLE:UNTIL_DISTANCE[:VERTICAL] \
352             (three or four colon-separated numbers; the optional 4th field VERTICAL is always \
353             m/s, positive = updraft, regardless of --units)"
354        ));
355    }
356    let num = |i: usize, name: &str| -> Result<f64, String> {
357        parts[i].trim().parse::<f64>().map_err(|_| {
358            format!("invalid wind segment '{s}': {name} '{}' is not a number", parts[i])
359        })
360    };
361    let speed = num(0, "speed")?;
362    let angle = num(1, "angle")?;
363    let until = num(2, "until-distance")?;
364    let vertical = if parts.len() == 4 {
365        num(3, "vertical")?
366    } else {
367        0.0
368    };
369    if !speed.is_finite() || !angle.is_finite() || !until.is_finite() || !vertical.is_finite() {
370        return Err(format!(
371            "invalid wind segment '{s}': speed, angle, until-distance, and vertical (m/s, \
372             positive = updraft) must be finite numbers"
373        ));
374    }
375    if speed < 0.0 {
376        return Err(format!("invalid wind segment '{s}': speed must be >= 0"));
377    }
378    if until <= 0.0 {
379        return Err(format!("invalid wind segment '{s}': until-distance must be > 0"));
380    }
381    let (speed_kmh, until_m) = if imperial {
382        (speed * 1.609344, until * 0.9144) // mph -> km/h, yards -> meters
383    } else {
384        (speed * 3.6, until) // m/s -> km/h, meters -> meters
385    };
386    let mut segment = WindSegment::new(speed_kmh, angle, until_m);
387    segment.vertical_mps = vertical;
388    Ok(segment)
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn segment_sort_is_stable_and_places_nan_endpoints_last() {
397        let mut segments = vec![
398            WindSegment::new(10.0, 0.0, f64::NAN),
399            WindSegment::new(20.0, 0.0, 100.0),
400            WindSegment::new(30.0, 0.0, 100.0),
401            WindSegment::new(40.0, 0.0, f64::INFINITY),
402            WindSegment::new(50.0, 0.0, f64::NEG_INFINITY),
403            WindSegment::new(60.0, 0.0, f64::NAN),
404        ];
405
406        sort_wind_segments_by_distance(&mut segments);
407
408        assert_eq!(segments[0].speed_kmh, 50.0); // -inf first
409        assert_eq!(segments[1].speed_kmh, 20.0); // equal endpoints retain input order
410        assert_eq!(segments[2].speed_kmh, 30.0);
411        assert_eq!(segments[3].speed_kmh, 40.0); // +inf after finite endpoints
412        assert_eq!(segments[4].speed_kmh, 10.0); // NaNs last and stable
413        assert_eq!(segments[5].speed_kmh, 60.0);
414        assert!(segments[4].until_m.is_nan() && segments[5].until_m.is_nan());
415    }
416
417    #[test]
418    fn test_wind_sock_empty() {
419        let sock = WindSock::new(vec![]);
420        assert_eq!(sock.vector_for_range_stateless(50.0), Vector3::zeros());
421        assert_eq!(sock.muzzle_crosswind_from_right_mps(), None);
422    }
423
424    #[test]
425    fn try_new_accepts_valid_and_empty_segments() {
426        assert!(WindSock::try_new(vec![]).is_ok());
427        let sock = WindSock::try_new(vec![WindSegment::new(16.0934, 90.0, 100.0)]).unwrap();
428        assert!(sock.vector_for_range_stateless(50.0).norm() > 0.0);
429        // TryFrom mirrors try_new.
430        assert!(WindSock::try_from(vec![WindSegment::new(10.0, 0.0, 50.0)]).is_ok());
431        assert!(WindSock::try_from(vec![WindSegment::new(f64::NAN, 0.0, 50.0)]).is_err());
432    }
433
434    #[test]
435    fn try_new_rejects_each_field_with_typed_index_field_rule() {
436        // MBA-1338 acceptance criteria: non-finite or negative speed_kmh, non-finite
437        // angle_deg, non-finite or non-positive until_m, non-finite vertical_mps.
438        let ok = WindSegment::new(10.0, 0.0, 100.0);
439        let cases: Vec<(WindSegment, WindSegmentField, WindSegmentRule)> = vec![
440            (
441                WindSegment::new(f64::NAN, 0.0, 100.0),
442                WindSegmentField::SpeedKmh,
443                WindSegmentRule::FiniteAndNonNegative,
444            ),
445            (
446                WindSegment::new(-1.0, 0.0, 100.0),
447                WindSegmentField::SpeedKmh,
448                WindSegmentRule::FiniteAndNonNegative,
449            ),
450            (
451                WindSegment::new(10.0, f64::INFINITY, 100.0),
452                WindSegmentField::AngleDeg,
453                WindSegmentRule::Finite,
454            ),
455            (
456                WindSegment::new(10.0, 0.0, 0.0),
457                WindSegmentField::UntilM,
458                WindSegmentRule::FiniteAndPositive,
459            ),
460            (
461                WindSegment::new(10.0, 0.0, f64::NEG_INFINITY),
462                WindSegmentField::UntilM,
463                WindSegmentRule::FiniteAndPositive,
464            ),
465            (
466                WindSegment {
467                    vertical_mps: f64::NAN,
468                    ..ok
469                },
470                WindSegmentField::VerticalMps,
471                WindSegmentRule::Finite,
472            ),
473        ];
474        for (bad, field, rule) in cases {
475            // Put the bad segment at index 1 behind a valid one so the reported index is
476            // meaningful (and would be perturbed if validation ran after sorting).
477            let err = WindSock::try_new(vec![ok, bad]).unwrap_err();
478            assert_eq!(err.index, 1, "index for {field}");
479            assert_eq!(err.field, field);
480            assert_eq!(err.rule, rule);
481        }
482    }
483
484    #[test]
485    fn try_new_error_index_is_the_callers_presort_index() {
486        // The bad segment sorts FIRST by until_m; the reported index must still be the
487        // caller's (2), proving validation runs before sorting.
488        let err = WindSock::try_new(vec![
489            WindSegment::new(10.0, 0.0, 500.0),
490            WindSegment::new(20.0, 0.0, 300.0),
491            WindSegment::new(f64::NAN, 0.0, 1.0),
492        ])
493        .unwrap_err();
494        assert_eq!(err.index, 2);
495        assert_eq!(err.field, WindSegmentField::SpeedKmh);
496    }
497
498    #[test]
499    fn wind_segment_error_display_matches_legacy_strings() {
500        // The deferred WindSock::new path and FastSolution surface this Display output;
501        // it must stay byte-identical to the historical String errors.
502        let err = WindSock::try_new(vec![WindSegment::new(f64::NAN, 0.0, 100.0)]).unwrap_err();
503        assert_eq!(
504            err.to_string(),
505            "wind.segments[0].speed_kmh must be finite and non-negative"
506        );
507        let err = WindSock::try_new(vec![WindSegment::new(10.0, 0.0, -5.0)]).unwrap_err();
508        assert_eq!(
509            err.to_string(),
510            "wind.segments[0].until_m must be finite and greater than zero"
511        );
512        let err = WindSock::try_new(vec![WindSegment {
513            vertical_mps: f64::INFINITY,
514            ..WindSegment::new(10.0, 0.0, 100.0)
515        }])
516        .unwrap_err();
517        assert_eq!(
518            err.to_string(),
519            "wind.segments[0].vertical_mps must be finite"
520        );
521    }
522
523    #[test]
524    fn infallible_new_still_defers_the_same_error() {
525        // Legacy behavior preserved: WindSock::new constructs, and the solver-boundary
526        // validate_segments() reports the identical message try_new would have raised.
527        let sock = WindSock::new(vec![WindSegment::new(10.0, f64::NAN, 100.0)]);
528        assert_eq!(
529            sock.validate_segments().unwrap_err(),
530            "wind.segments[0].angle_deg must be finite"
531        );
532    }
533
534    #[test]
535    fn muzzle_crosswind_distinguishes_an_explicit_zero_segment() {
536        let sock = WindSock::new(vec![WindSegment::new(0.0, 90.0, 100.0)]);
537        assert_eq!(sock.muzzle_crosswind_from_right_mps(), Some(0.0));
538    }
539
540    #[test]
541    fn muzzle_crosswind_uses_the_sorted_muzzle_segment_and_wind_from_sign() {
542        let from_right = WindSock::new(vec![
543            WindSegment::new(32.18688, 270.0, 5000.0),
544            WindSegment::new(16.09344, 90.0, 100.0),
545        ]);
546        let right_mps = from_right.muzzle_crosswind_from_right_mps().unwrap();
547        assert!((right_mps - 4.4704).abs() < 1e-12);
548
549        let from_left = WindSock::new(vec![WindSegment::new(16.09344, 270.0, 100.0)]);
550        let left_mps = from_left.muzzle_crosswind_from_right_mps().unwrap();
551        assert!((left_mps + 4.4704).abs() < 1e-12);
552    }
553
554    #[test]
555    fn test_wind_sock_single_segment() {
556        // 16.0934 kmh (10 mph) @ 90° until 100m
557        let sock = WindSock::new(vec![WindSegment::new(16.0934, 90.0, 100.0)]);
558
559        // Should have wind before 100m
560        let vec_50 = sock.vector_for_range_stateless(50.0);
561        println!("vec_50 = [{}, {}, {}]", vec_50[0], vec_50[1], vec_50[2]);
562        assert!(vec_50.norm() > 0.0);
563        // 90° wind from right (crosswind, McCoy): negative Z (lateral), zero Y, near-zero X (downrange)
564        assert!(
565            vec_50[2] < 0.0,
566            "Z (lateral) should be negative for 90° wind, got {}",
567            vec_50[2]
568        );
569        assert_eq!(vec_50[1], 0.0); // Zero Y component (vertical_mps defaults to 0.0, MBA-728)
570        assert!(
571            vec_50[0].abs() < 0.01,
572            "X (downrange) should be nearly zero for 90° wind, got {}",
573            vec_50[0]
574        );
575
576        // No wind after 100m
577        let vec_150 = sock.vector_for_range_stateless(150.0);
578        assert_eq!(vec_150, Vector3::zeros());
579    }
580
581    #[test]
582    fn test_wind_sock_multiple_segments() {
583        // Multiple wind segments (in kmh)
584        let sock = WindSock::new(vec![
585            WindSegment::new(16.0934, 90.0, 50.0),  // 10 mph @ 90° until 50m
586            WindSegment::new(24.1401, 45.0, 100.0), // 15 mph @ 45° until 100m
587            WindSegment::new(8.0467, 180.0, 200.0), // 5 mph @ 180° until 200m
588        ]);
589
590        // Test each segment
591        let vec_25 = sock.vector_for_range_stateless(25.0);
592        println!("vec_25 = [{}, {}, {}]", vec_25[0], vec_25[1], vec_25[2]);
593        assert!(vec_25.norm() > 0.0);
594        assert!(vec_25[2] < 0.0, "90° wind should have negative Z (lateral)"); // 90° wind from right
595
596        let vec_75 = sock.vector_for_range_stateless(75.0);
597        println!("vec_75 = [{}, {}, {}]", vec_75[0], vec_75[1], vec_75[2]);
598        assert!(vec_75.norm() > vec_25.norm()); // 15 mph > 10 mph
599        assert!(vec_75[0] < 0.0); // 45° wind has negative X component
600        assert!(vec_75[2] < 0.0); // 45° wind has negative Z component
601
602        let vec_150 = sock.vector_for_range_stateless(150.0);
603        println!("vec_150 = [{}, {}, {}]", vec_150[0], vec_150[1], vec_150[2]);
604        assert!(vec_150.norm() < vec_75.norm()); // 5 mph < 15 mph
605        assert!(
606            vec_150[2].abs() < 0.01,
607            "180° wind should have near-zero Z (lateral), got {}",
608            vec_150[2]
609        ); // 180° wind (from behind)
610        assert!(
611            vec_150[0] > 0.0,
612            "180° wind should have positive X (tailwind, downrange), got {}",
613            vec_150[0]
614        ); // Tailwind
615
616        let vec_250 = sock.vector_for_range_stateless(250.0);
617        assert_eq!(vec_250, Vector3::zeros()); // Beyond all segments
618    }
619
620    #[test]
621    fn test_wind_conversion() {
622        // Test conversion: 16.0934 km/h = 4.47 m/s
623        let sock = WindSock::new(vec![WindSegment::new(16.0934, 0.0, 100.0)]);
624        let vec = sock.vector_for_range_stateless(50.0);
625
626        let expected_speed = 16.0934 * KMH_TO_MPS;
627        assert!((vec.norm() - expected_speed).abs() < 0.01);
628    }
629
630    #[test]
631    fn test_wind_sock_boundary_is_upper_exclusive() {
632        // A segment's `until_distance_m` is exclusive: a query exactly at the
633        // boundary rolls to the next segment.
634        let sock = WindSock::new(vec![
635            WindSegment::new(16.0934, 90.0, 100.0),
636            WindSegment::new(32.1868, 270.0, 200.0),
637        ]);
638        // Just below 100 m -> first segment (90deg, negative Z).
639        assert!(sock.vector_for_range_stateless(99.999)[2] < 0.0);
640        // Exactly 100 m -> second segment (270deg, positive Z).
641        assert!(sock.vector_for_range_stateless(100.0)[2] > 0.0);
642        // Beyond the last boundary -> zero.
643        assert_eq!(sock.vector_for_range_stateless(200.0), Vector3::zeros());
644    }
645
646    #[test]
647    fn calc_vec_passes_through_segment_vertical_unscaled() {
648        // MBA-728: a segment's vertical_mps must land unchanged on the wind vector's Y
649        // component, independent of speed/angle.
650        let seg = WindSegment {
651            speed_kmh: 16.0934,
652            angle_deg: 90.0,
653            until_m: 100.0,
654            vertical_mps: 3.0,
655        };
656        let vec = WindSock::calc_vec(&seg);
657        assert_eq!(vec[1], 3.0);
658    }
659
660    #[test]
661    fn calc_vec_zero_vertical_segment_keeps_zero_y() {
662        // Upgrades (does not replace) the zero-Y assertions above: the historical
663        // 3-field constructor still yields vertical_mps == 0.0 -> Y == 0.0.
664        let seg = WindSegment::new(16.0934, 90.0, 100.0);
665        assert_eq!(seg.vertical_mps, 0.0);
666        let vec = WindSock::calc_vec(&seg);
667        assert_eq!(vec[1], 0.0);
668    }
669
670    #[test]
671    fn test_parse_wind_segment_str_units() {
672        // Imperial: 10 mph -> 16.0934 km/h, 100 yd -> 91.44 m.
673        let seg = parse_wind_segment_str("10:90:100", true).unwrap();
674        assert!((seg.speed_kmh - 16.09344).abs() < 1e-4);
675        assert_eq!(seg.angle_deg, 90.0);
676        assert!((seg.until_m - 91.44).abs() < 1e-4);
677
678        // Metric: 5 m/s -> 18 km/h, 200 m stays 200 m.
679        let seg = parse_wind_segment_str("5:270:200", false).unwrap();
680        assert!((seg.speed_kmh - 18.0).abs() < 1e-9);
681        assert_eq!(seg.angle_deg, 270.0);
682        assert!((seg.until_m - 200.0).abs() < 1e-9);
683
684        // Malformed inputs are rejected.
685        assert!(parse_wind_segment_str("10:90", true).is_err()); // too few fields
686        assert!(parse_wind_segment_str("10:bad:100", true).is_err()); // non-numeric
687        assert!(parse_wind_segment_str("10:90:0", true).is_err()); // zero until-distance
688        assert!(parse_wind_segment_str("-3:90:100", true).is_err()); // negative speed
689        // Non-finite values must be rejected (NaN comparisons would slip past < / <=).
690        assert!(parse_wind_segment_str("10:nan:5000", true).is_err());
691        assert!(parse_wind_segment_str("10:90:nan", true).is_err());
692        assert!(parse_wind_segment_str("inf:90:100", true).is_err());
693    }
694
695    #[test]
696    fn test_parse_wind_segment_str_vertical_field() {
697        // MBA-728: 3-field input is unchanged (backward compat) -> vertical_mps == 0.0.
698        let seg = parse_wind_segment_str("10:90:100", true).unwrap();
699        assert_eq!(seg.vertical_mps, 0.0);
700        let seg = parse_wind_segment_str("5:270:200", false).unwrap();
701        assert_eq!(seg.vertical_mps, 0.0);
702
703        // 4-field input parses the vertical component, m/s, regardless of --units.
704        let seg = parse_wind_segment_str("10:90:100:5", true).unwrap();
705        assert_eq!(seg.vertical_mps, 5.0);
706        // The rest of the fields still go through the imperial conversion.
707        assert!((seg.speed_kmh - 16.09344).abs() < 1e-4);
708        assert!((seg.until_m - 91.44).abs() < 1e-4);
709
710        // Vertical is NOT converted by --units (always m/s): metric and imperial parses of the
711        // same "...:5" 4th field land on the identical vertical_mps.
712        let seg_metric = parse_wind_segment_str("5:270:200:5", false).unwrap();
713        assert_eq!(seg_metric.vertical_mps, 5.0);
714
715        // Negative vertical (downdraft) is valid.
716        let seg_neg = parse_wind_segment_str("10:90:100:-3.5", true).unwrap();
717        assert_eq!(seg_neg.vertical_mps, -3.5);
718
719        // A non-numeric or non-finite 4th field is rejected.
720        assert!(parse_wind_segment_str("10:90:100:bad", true).is_err());
721        assert!(parse_wind_segment_str("10:90:100:nan", true).is_err());
722        assert!(parse_wind_segment_str("10:90:100:inf", true).is_err());
723
724        // Too many fields is still rejected.
725        assert!(parse_wind_segment_str("10:90:100:5:1", true).is_err());
726    }
727}