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) => a
58            .until_m
59            .partial_cmp(&b.until_m)
60            .expect("non-NaN distances are ordered"),
61    });
62}
63
64/// Which [`WindSegment`] field failed validation (MBA-1338).
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum WindSegmentField {
67    SpeedKmh,
68    AngleDeg,
69    UntilM,
70    VerticalMps,
71}
72
73impl WindSegmentField {
74    /// The struct field name, as it appears in error messages and the public API.
75    pub fn name(self) -> &'static str {
76        match self {
77            WindSegmentField::SpeedKmh => "speed_kmh",
78            WindSegmentField::AngleDeg => "angle_deg",
79            WindSegmentField::UntilM => "until_m",
80            WindSegmentField::VerticalMps => "vertical_mps",
81        }
82    }
83}
84
85impl std::fmt::Display for WindSegmentField {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        f.write_str(self.name())
88    }
89}
90
91/// The validation rule a [`WindSegment`] field violated (MBA-1338).
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum WindSegmentRule {
94    /// The value must be finite (`angle_deg`, `vertical_mps`).
95    Finite,
96    /// The value must be finite and `>= 0` (`speed_kmh`).
97    FiniteAndNonNegative,
98    /// The value must be finite and `> 0` (`until_m`).
99    FiniteAndPositive,
100}
101
102impl std::fmt::Display for WindSegmentRule {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        // Phrasing matches the historical String errors exactly so consumers that surface
105        // Display output (FastSolution, solver boundaries, bindings) see unchanged messages.
106        f.write_str(match self {
107            WindSegmentRule::Finite => "finite",
108            WindSegmentRule::FiniteAndNonNegative => "finite and non-negative",
109            WindSegmentRule::FiniteAndPositive => "finite and greater than zero",
110        })
111    }
112}
113
114/// A malformed [`WindSegment`] rejected at a checked construction/solve boundary (MBA-1338).
115///
116/// `index` is the segment's position in the vector **as supplied by the caller** —
117/// validation runs before any sorting or normalization, so the index always refers to
118/// the caller's own ordering.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
120#[error("wind.segments[{index}].{field} must be {rule}")]
121pub struct WindSegmentError {
122    pub index: usize,
123    pub field: WindSegmentField,
124    pub rule: WindSegmentRule,
125}
126
127/// Validate every segment, reporting the first violation as a typed error carrying the
128/// caller's segment index, the offending field, and the violated rule (MBA-1338).
129///
130/// Checked APIs ([`WindSock::try_new`], `try_integrate_trajectory`,
131/// `try_solve_trajectory_rust`) call this before sorting, vector precomputation, or
132/// producing any trajectory points.
133pub fn validate_wind_segments(segments: &[WindSegment]) -> Result<(), WindSegmentError> {
134    for (index, segment) in segments.iter().enumerate() {
135        let err = |field, rule| Err(WindSegmentError { index, field, rule });
136
137        if !segment.speed_kmh.is_finite() || segment.speed_kmh < 0.0 {
138            return err(
139                WindSegmentField::SpeedKmh,
140                WindSegmentRule::FiniteAndNonNegative,
141            );
142        }
143        if !segment.angle_deg.is_finite() {
144            return err(WindSegmentField::AngleDeg, WindSegmentRule::Finite);
145        }
146        if !segment.until_m.is_finite() || segment.until_m <= 0.0 {
147            return err(WindSegmentField::UntilM, WindSegmentRule::FiniteAndPositive);
148        }
149        if !segment.vertical_mps.is_finite() {
150            return err(WindSegmentField::VerticalMps, WindSegmentRule::Finite);
151        }
152    }
153
154    Ok(())
155}
156
157/// Wind condition handler for trajectory calculations
158#[derive(Debug, Clone)]
159pub struct WindSock {
160    /// Sorted wind segments by distance
161    winds: Vec<WindSegment>,
162    /// Precomputed wind vector for each segment (parallel to `winds`). The Monte-Carlo RK4
163    /// kernel queries wind 4x per step, so caching avoids recomputing sin/cos every call.
164    wind_vecs: Vec<Vector3<f64>>,
165    /// Current segment index
166    current: usize,
167    /// Distance where next segment starts
168    next_range: f64,
169    /// Current wind vector
170    current_vec: Vector3<f64>,
171    /// Validation result captured before sorting, preserving the caller's segment index.
172    validation_error: Option<WindSegmentError>,
173}
174
175impl WindSock {
176    /// Create a new WindSock from wind segments.
177    ///
178    /// **Legacy/unchecked entry point** (MBA-1338): this constructor is infallible for API
179    /// compatibility — a malformed segment is captured as a deferred error that the solver
180    /// consumes at its pre-integration validation boundary, but a direct library consumer
181    /// gets a constructed sock with no structured signal. Prefer [`WindSock::try_new`]
182    /// (or `TryFrom<Vec<WindSegment>>`), which rejects malformed segments up front.
183    ///
184    /// Args:
185    ///     segments: List of (speed_kmh, angle_deg, until_distance_m) tuples
186    pub fn new(segments: Vec<WindSegment>) -> Self {
187        // Validate before sorting so an error can identify the segment index supplied by
188        // the caller; defer it rather than fail (see doc above).
189        let validation_error = validate_wind_segments(&segments).err();
190        Self::build_unchecked(segments, validation_error)
191    }
192
193    /// Create a new WindSock, rejecting malformed segments with a typed error (MBA-1338).
194    ///
195    /// Validation runs **before** sorting and wind-vector precomputation, and the error's
196    /// `index` refers to the caller's own segment ordering. Rejects non-finite or negative
197    /// `speed_kmh`, non-finite `angle_deg`, non-finite or non-positive `until_m`, and
198    /// non-finite `vertical_mps`.
199    pub fn try_new(segments: Vec<WindSegment>) -> Result<Self, WindSegmentError> {
200        validate_wind_segments(&segments)?;
201        Ok(Self::build_unchecked(segments, None))
202    }
203
204    /// Shared constructor body: sort, precompute vectors, seed the cursor.
205    fn build_unchecked(
206        mut segments: Vec<WindSegment>,
207        validation_error: Option<WindSegmentError>,
208    ) -> Self {
209        // Sort segments by distance, handling NaN safely by treating it as greater than any value
210        sort_wind_segments_by_distance(&mut segments);
211
212        // Precompute each segment's wind vector once (depends only on its speed/angle).
213        let wind_vecs: Vec<Vector3<f64>> = segments.iter().map(Self::calc_vec).collect();
214
215        let (current, next_range, current_vec) = if segments.is_empty() {
216            (0, f64::INFINITY, Vector3::zeros())
217        } else {
218            (0, segments[0].until_m, wind_vecs[0])
219        };
220
221        WindSock {
222            winds: segments,
223            wind_vecs,
224            current,
225            next_range,
226            current_vec,
227            validation_error,
228        }
229    }
230
231    /// Return any malformed-segment error captured before normalization.
232    pub(crate) fn validate_segments(&self) -> Result<(), String> {
233        self.validation_error
234            .map_or(Ok(()), |err| Err(err.to_string()))
235    }
236
237    /// Calculate wind vector from wind segment
238    fn calc_vec(seg: &WindSegment) -> Vector3<f64> {
239        // Convert kmh to m/s
240        let speed_mps = seg.speed_kmh * KMH_TO_MPS;
241        // Preserve the historical multiply-then-divide result for ordinary angles, while
242        // avoiding intermediate overflow for otherwise-valid finite values near f64::MAX.
243        let angle_rad = if seg.angle_deg.abs() <= f64::MAX / PI {
244            seg.angle_deg * PI / 180.0
245        } else {
246            seg.angle_deg / 180.0 * PI
247        };
248
249        // Wind convention (matching trajectory coordinates):
250        // 0° = headwind (from front, affects -x downrange)
251        // 90° = wind from right (affects -z lateral)
252        // 180° = tailwind (from back, affects +x downrange)
253        // 270° = wind from left (affects +z lateral)
254        //
255        // McCoy convention: x=downrange, y=vertical, z=lateral. Vertical (MBA-728) passes
256        // straight through per-segment; it is not derived from speed_kmh/angle_deg.
257        wind_vector(speed_mps, angle_rad, seg.vertical_mps)
258    }
259
260    /// Upper bound (m/s) on the wind speed any segment can contribute, horizontal
261    /// plus vertical. Feeds the solver's integration divergence guard (MBA-1293).
262    pub fn max_speed_mps(&self) -> f64 {
263        self.winds
264            .iter()
265            .map(|seg| seg.speed_kmh.abs() * KMH_TO_MPS + seg.vertical_mps.abs())
266            .fold(0.0, f64::max)
267    }
268
269    /// Crosswind at the muzzle in the aerodynamic-jump convention: positive means wind from
270    /// the right. `None` means there are no segmented winds, while a real zero-crosswind
271    /// segment returns `Some(0.0)` so callers do not incorrectly fall back to scalar wind.
272    pub(crate) fn muzzle_crosswind_from_right_mps(&self) -> Option<f64> {
273        (!self.winds.is_empty()).then(|| -self.vector_for_range_stateless(0.0)[2])
274    }
275
276    /// Get wind vector for a given range
277    ///
278    /// Note: This modifies internal state and expects monotonically increasing ranges
279    /// For trajectory integration, we need a stateless version
280    pub fn vector_for_range(&mut self, range_m: f64) -> Vector3<f64> {
281        // Handle NaN
282        if range_m.is_nan() {
283            return Vector3::zeros();
284        }
285
286        // Advance the cursor across however many segments the query skipped (a single `if`
287        // returned a stale vector when a monotonic query jumped past a whole short segment).
288        while range_m >= self.next_range && self.current < self.winds.len() {
289            self.current += 1;
290            if self.current >= self.winds.len() {
291                self.current_vec = Vector3::zeros();
292                self.next_range = f64::INFINITY;
293            } else {
294                self.current_vec = self.wind_vecs[self.current];
295                self.next_range = self.winds[self.current].until_m;
296            }
297        }
298
299        self.current_vec
300    }
301
302    /// Get wind vector for a given range (stateless version)
303    ///
304    /// This version doesn't modify internal state and is safe for numerical integration
305    /// where the same range might be queried multiple times or out of order
306    pub fn vector_for_range_stateless(&self, range_m: f64) -> Vector3<f64> {
307        // Handle NaN
308        if range_m.is_nan() {
309            return Vector3::zeros();
310        }
311
312        // Find the appropriate segment (precomputed vector — no per-call trig).
313        for (i, segment) in self.winds.iter().enumerate() {
314            if range_m < segment.until_m {
315                return self.wind_vecs[i];
316            }
317        }
318
319        // Beyond all segments
320        Vector3::zeros()
321    }
322}
323
324/// Checked conversion mirroring [`WindSock::try_new`] (MBA-1338).
325impl TryFrom<Vec<WindSegment>> for WindSock {
326    type Error = WindSegmentError;
327
328    fn try_from(segments: Vec<WindSegment>) -> Result<Self, Self::Error> {
329        WindSock::try_new(segments)
330    }
331}
332
333/// Parse a `"SPEED:ANGLE:UNTIL_DISTANCE[:VERTICAL]"` string into a [`WindSegment`]
334/// `(speed_kmh, angle_deg, until_distance_m, vertical_mps)`.
335///
336/// `imperial`: when true, SPEED is mph and UNTIL_DISTANCE is yards; otherwise
337/// SPEED is m/s and UNTIL_DISTANCE is meters. ANGLE is always degrees in the
338/// wind-FROM convention (0 = headwind, 90 = from the right). The optional 4th
339/// field, VERTICAL, is ALWAYS m/s (positive = updraft, raises POI) regardless of
340/// `imperial` — it does not follow --units, matching how [`WindSegment::vertical_mps`]
341/// stores it. This speed-in-display-units-but-vertical-always-m/s asymmetry is
342/// unit-honest (it mirrors the struct field name) even though it reads oddly next
343/// to SPEED. Omitting the 4th field keeps the historical 3-field behavior
344/// (vertical wind 0.0). Shared by the CLI (`--wind-segment`) and the WASM
345/// front-ends so they parse identically.
346pub fn parse_wind_segment_str(s: &str, imperial: bool) -> Result<WindSegment, String> {
347    parse_wind_segment_str_detailed(s, imperial).map(|(segment, _)| segment)
348}
349
350/// [`parse_wind_segment_str`] plus clock-form provenance (MBA-1367/MBA-1368): the
351/// second tuple element is true when the ANGLE token used a marked clock form
352/// (`3oc`, `10h30`) rather than plain degrees — earth-fixed compass mode must reject
353/// clock positions, which are shooter-relative by definition. Error strings stay in
354/// the legacy `String` family (pre-MBA-1338 boundary): the typed
355/// [`WindDirectionParseError`] converts to a plain message here.
356pub fn parse_wind_segment_str_detailed(
357    s: &str,
358    imperial: bool,
359) -> Result<(WindSegment, bool), String> {
360    let parts: Vec<&str> = s.split(':').collect();
361    if parts.len() != 3 && parts.len() != 4 {
362        return Err(format!(
363            "invalid wind segment '{s}': expected SPEED:ANGLE:UNTIL_DISTANCE[:VERTICAL] \
364             (three or four colon-separated numbers; the optional 4th field VERTICAL is always \
365             m/s, positive = updraft, regardless of --units)"
366        ));
367    }
368    let num = |i: usize, name: &str| -> Result<f64, String> {
369        parts[i].trim().parse::<f64>().map_err(|_| {
370            format!(
371                "invalid wind segment '{s}': {name} '{}' is not a number",
372                parts[i]
373            )
374        })
375    };
376    let speed = num(0, "speed")?;
377    // ANGLE additionally accepts the colon-FREE marked clock forms (`3oc`, `10h30`) —
378    // the colon form is impossible here because this grammar is colon-delimited
379    // (`10:30` inside a segment stays SPEED=10, ANGLE=30, exactly as it always has).
380    let (angle, angle_was_clock) = match parse_wind_direction(parts[1]) {
381        Ok(parsed) => (parsed.degrees, parsed.was_clock),
382        Err(WindDirectionParseError::Unrecognized(_)) => {
383            return Err(format!(
384                "invalid wind segment '{s}': angle '{}' is not a number or a clock \
385                 position like 3oc or 10h30",
386                parts[1]
387            ))
388        }
389        Err(e) => return Err(format!("invalid wind segment '{s}': {e}")),
390    };
391    let until = num(2, "until-distance")?;
392    let vertical = if parts.len() == 4 {
393        num(3, "vertical")?
394    } else {
395        0.0
396    };
397    if !speed.is_finite() || !angle.is_finite() || !until.is_finite() || !vertical.is_finite() {
398        return Err(format!(
399            "invalid wind segment '{s}': speed, angle, until-distance, and vertical (m/s, \
400             positive = updraft) must be finite numbers"
401        ));
402    }
403    if speed < 0.0 {
404        return Err(format!("invalid wind segment '{s}': speed must be >= 0"));
405    }
406    if until <= 0.0 {
407        return Err(format!(
408            "invalid wind segment '{s}': until-distance must be > 0"
409        ));
410    }
411    let (speed_kmh, until_m) = if imperial {
412        (speed * 1.609344, until * 0.9144) // mph -> km/h, yards -> meters
413    } else {
414        (speed * 3.6, until) // m/s -> km/h, meters -> meters
415    };
416    let mut segment = WindSegment::new(speed_kmh, angle, until_m);
417    segment.vertical_mps = vertical;
418    Ok((segment, angle_was_clock))
419}
420
421/// Which frame wind directions are ENTERED in (MBA-1368). `Shooter` (the default) is
422/// today's behavior: wind-FROM angles relative to the line of fire (0 = headwind).
423/// `Compass` treats every entered wind direction — the single direction AND every
424/// segment — as an absolute earth-fixed bearing (0 = north), derived shooter-relative
425/// once at the input boundary as `bearing - shot_azimuth` (wind-FROM both sides), so
426/// the physics downstream of [`wind_vector`] never sees a bearing.
427#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
428pub enum WindReference {
429    #[default]
430    Shooter,
431    Compass,
432}
433
434impl WindReference {
435    /// Parses the CLI/WASM token (`shooter` | `compass`, case-insensitive).
436    pub fn parse(token: &str) -> Result<Self, String> {
437        match token.trim().to_ascii_lowercase().as_str() {
438            "shooter" => Ok(Self::Shooter),
439            "compass" => Ok(Self::Compass),
440            other => Err(format!(
441                "invalid wind reference '{other}': expected 'shooter' or 'compass'"
442            )),
443        }
444    }
445}
446
447/// Earth-fixed compass bearing (degrees, 0 = north) -> shooter-relative wind-FROM
448/// degrees against the shot azimuth (MBA-1368), normalized to [0, 360). A wind FROM
449/// north (bearing 0) with the shot fired due north (azimuth 0) is a pure headwind
450/// (relative 0 — the 0.19.0 sign convention); a bearing 90 (from east) on a northbound
451/// shot is wind from the shooter's RIGHT (relative 90).
452pub fn compass_bearing_to_shooter_relative_deg(bearing_deg: f64, shot_azimuth_deg: f64) -> f64 {
453    (bearing_deg - shot_azimuth_deg).rem_euclid(360.0)
454}
455
456/// [`compass_bearing_to_shooter_relative_deg`] in radians (the solve-json v1 surface
457/// is SI/radians end to end).
458pub fn compass_bearing_to_shooter_relative_rad(bearing_rad: f64, shot_azimuth_rad: f64) -> f64 {
459    (bearing_rad - shot_azimuth_rad).rem_euclid(2.0 * PI)
460}
461
462/// A wind direction as entered at an input boundary (MBA-1367): the resolved degrees
463/// in the wind-FROM convention (0 = headwind, 90 = from the right — post-0.19.0), plus
464/// whether it was entered as a clock position. Clock positions are shooter-relative
465/// BY DEFINITION (12 o'clock = dead ahead), so `was_clock` is what lets the
466/// earth-fixed compass mode (MBA-1368) reject them.
467#[derive(Debug, Clone, Copy, PartialEq)]
468pub struct ParsedWindDirection {
469    pub degrees: f64,
470    pub was_clock: bool,
471}
472
473/// Typed parse errors for [`parse_wind_direction`] (internal family; legacy `String`
474/// boundaries like [`parse_wind_segment_str`] convert at the boundary per the
475/// MBA-1338 layering).
476#[derive(Debug, Error, PartialEq, Eq)]
477pub enum WindDirectionParseError {
478    #[error(
479        "invalid wind direction '{0}': expected degrees (e.g. 90) or a clock position \
480         like 3oc or 10h30 (12oc = headwind)"
481    )]
482    Unrecognized(String),
483    #[error("invalid clock position '{0}': hour must be 1-12")]
484    HourOutOfRange(String),
485    #[error("invalid clock position '{0}': minutes must be 00-59")]
486    MinutesOutOfRange(String),
487}
488
489/// Parses one wind-direction token (MBA-1367): a bare number is DEGREES exactly as it
490/// always was; marked clock forms are `<H>oc` (e.g. `3oc`) and `<H>h<MM>` (e.g.
491/// `10h30`), case-insensitive, mapping to `(H % 12) * 30 + MM * 0.5` degrees in the
492/// wind-FROM convention (12 o'clock = headwind = 0°, post-0.19.0). H must be 1-12 and
493/// MM 0-59. The colon form `<H>:<MM>` is deliberately NOT accepted here — it is legal
494/// only where a token cannot be colon-delimited grammar, i.e. standalone flags
495/// ([`parse_wind_direction_standalone`]); inside `--wind-segment`, `10:30` keeps its
496/// historical SPEED:ANGLE meaning.
497pub fn parse_wind_direction(token: &str) -> Result<ParsedWindDirection, WindDirectionParseError> {
498    let raw = token.trim();
499
500    // Bare number = degrees, byte-for-byte the pre-MBA-1367 acceptance (f64::from_str,
501    // exactly what the clap f64 args and the segment parser used).
502    if let Ok(degrees) = raw.parse::<f64>() {
503        return Ok(ParsedWindDirection {
504            degrees,
505            was_clock: false,
506        });
507    }
508
509    let lower = raw.to_ascii_lowercase();
510    let clock = |hour_str: &str,
511                 minute_str: Option<&str>|
512     -> Result<ParsedWindDirection, WindDirectionParseError> {
513        let hour: u32 = hour_str
514            .parse()
515            .map_err(|_| WindDirectionParseError::Unrecognized(raw.to_string()))?;
516        if !(1..=12).contains(&hour) {
517            return Err(WindDirectionParseError::HourOutOfRange(raw.to_string()));
518        }
519        let minutes: u32 = match minute_str {
520            Some(m) if !m.is_empty() && m.len() <= 2 && m.bytes().all(|b| b.is_ascii_digit()) => m
521                .parse()
522                .map_err(|_| WindDirectionParseError::Unrecognized(raw.to_string()))?,
523            Some(_) => return Err(WindDirectionParseError::Unrecognized(raw.to_string())),
524            None => 0,
525        };
526        if minutes > 59 {
527            return Err(WindDirectionParseError::MinutesOutOfRange(raw.to_string()));
528        }
529        Ok(ParsedWindDirection {
530            degrees: f64::from(hour % 12) * 30.0 + f64::from(minutes) * 0.5,
531            was_clock: true,
532        })
533    };
534
535    if let Some(hour_str) = lower.strip_suffix("oc") {
536        // Reject a nested separator ("10h30oc" is nonsense, not hour "10h30").
537        if !hour_str.is_empty() && hour_str.bytes().all(|b| b.is_ascii_digit()) {
538            return clock(hour_str, None);
539        }
540        return Err(WindDirectionParseError::Unrecognized(raw.to_string()));
541    }
542    if let Some((hour_str, minute_str)) = lower.split_once('h') {
543        if !hour_str.is_empty() && hour_str.bytes().all(|b| b.is_ascii_digit()) {
544            return clock(hour_str, Some(minute_str));
545        }
546    }
547
548    Err(WindDirectionParseError::Unrecognized(raw.to_string()))
549}
550
551/// [`parse_wind_direction`] for STANDALONE flag contexts (`--wind-direction`), which
552/// additionally accept the `<H>:<MM>` colon clock form (`10:30` = 315°) — legal there
553/// because a flag value is never colon-delimited grammar (MBA-1367).
554pub fn parse_wind_direction_standalone(
555    token: &str,
556) -> Result<ParsedWindDirection, WindDirectionParseError> {
557    let raw = token.trim();
558    if let Some((hour_str, minute_str)) = raw.split_once(':') {
559        if !hour_str.is_empty()
560            && hour_str.bytes().all(|b| b.is_ascii_digit())
561            && !minute_str.is_empty()
562            && minute_str.len() <= 2
563            && minute_str.bytes().all(|b| b.is_ascii_digit())
564        {
565            let with_h = format!("{hour_str}h{minute_str}");
566            return match parse_wind_direction(&with_h) {
567                Ok(parsed) => Ok(parsed),
568                // Re-report range errors against the token as typed, not the rewrite.
569                Err(WindDirectionParseError::HourOutOfRange(_)) => {
570                    Err(WindDirectionParseError::HourOutOfRange(raw.to_string()))
571                }
572                Err(WindDirectionParseError::MinutesOutOfRange(_)) => {
573                    Err(WindDirectionParseError::MinutesOutOfRange(raw.to_string()))
574                }
575                Err(WindDirectionParseError::Unrecognized(_)) => {
576                    Err(WindDirectionParseError::Unrecognized(raw.to_string()))
577                }
578            };
579        }
580        return Err(WindDirectionParseError::Unrecognized(raw.to_string()));
581    }
582    parse_wind_direction(raw)
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    #[test]
590    fn segment_sort_is_stable_and_places_nan_endpoints_last() {
591        let mut segments = vec![
592            WindSegment::new(10.0, 0.0, f64::NAN),
593            WindSegment::new(20.0, 0.0, 100.0),
594            WindSegment::new(30.0, 0.0, 100.0),
595            WindSegment::new(40.0, 0.0, f64::INFINITY),
596            WindSegment::new(50.0, 0.0, f64::NEG_INFINITY),
597            WindSegment::new(60.0, 0.0, f64::NAN),
598        ];
599
600        sort_wind_segments_by_distance(&mut segments);
601
602        assert_eq!(segments[0].speed_kmh, 50.0); // -inf first
603        assert_eq!(segments[1].speed_kmh, 20.0); // equal endpoints retain input order
604        assert_eq!(segments[2].speed_kmh, 30.0);
605        assert_eq!(segments[3].speed_kmh, 40.0); // +inf after finite endpoints
606        assert_eq!(segments[4].speed_kmh, 10.0); // NaNs last and stable
607        assert_eq!(segments[5].speed_kmh, 60.0);
608        assert!(segments[4].until_m.is_nan() && segments[5].until_m.is_nan());
609    }
610
611    #[test]
612    fn test_wind_sock_empty() {
613        let sock = WindSock::new(vec![]);
614        assert_eq!(sock.vector_for_range_stateless(50.0), Vector3::zeros());
615        assert_eq!(sock.muzzle_crosswind_from_right_mps(), None);
616    }
617
618    #[test]
619    fn try_new_accepts_valid_and_empty_segments() {
620        assert!(WindSock::try_new(vec![]).is_ok());
621        let sock = WindSock::try_new(vec![WindSegment::new(16.0934, 90.0, 100.0)]).unwrap();
622        assert!(sock.vector_for_range_stateless(50.0).norm() > 0.0);
623        // TryFrom mirrors try_new.
624        assert!(WindSock::try_from(vec![WindSegment::new(10.0, 0.0, 50.0)]).is_ok());
625        assert!(WindSock::try_from(vec![WindSegment::new(f64::NAN, 0.0, 50.0)]).is_err());
626    }
627
628    #[test]
629    fn try_new_rejects_each_field_with_typed_index_field_rule() {
630        // MBA-1338 acceptance criteria: non-finite or negative speed_kmh, non-finite
631        // angle_deg, non-finite or non-positive until_m, non-finite vertical_mps.
632        let ok = WindSegment::new(10.0, 0.0, 100.0);
633        let cases: Vec<(WindSegment, WindSegmentField, WindSegmentRule)> = vec![
634            (
635                WindSegment::new(f64::NAN, 0.0, 100.0),
636                WindSegmentField::SpeedKmh,
637                WindSegmentRule::FiniteAndNonNegative,
638            ),
639            (
640                WindSegment::new(-1.0, 0.0, 100.0),
641                WindSegmentField::SpeedKmh,
642                WindSegmentRule::FiniteAndNonNegative,
643            ),
644            (
645                WindSegment::new(10.0, f64::INFINITY, 100.0),
646                WindSegmentField::AngleDeg,
647                WindSegmentRule::Finite,
648            ),
649            (
650                WindSegment::new(10.0, 0.0, 0.0),
651                WindSegmentField::UntilM,
652                WindSegmentRule::FiniteAndPositive,
653            ),
654            (
655                WindSegment::new(10.0, 0.0, f64::NEG_INFINITY),
656                WindSegmentField::UntilM,
657                WindSegmentRule::FiniteAndPositive,
658            ),
659            (
660                WindSegment {
661                    vertical_mps: f64::NAN,
662                    ..ok
663                },
664                WindSegmentField::VerticalMps,
665                WindSegmentRule::Finite,
666            ),
667        ];
668        for (bad, field, rule) in cases {
669            // Put the bad segment at index 1 behind a valid one so the reported index is
670            // meaningful (and would be perturbed if validation ran after sorting).
671            let err = WindSock::try_new(vec![ok, bad]).unwrap_err();
672            assert_eq!(err.index, 1, "index for {field}");
673            assert_eq!(err.field, field);
674            assert_eq!(err.rule, rule);
675        }
676    }
677
678    #[test]
679    fn try_new_error_index_is_the_callers_presort_index() {
680        // The bad segment sorts FIRST by until_m; the reported index must still be the
681        // caller's (2), proving validation runs before sorting.
682        let err = WindSock::try_new(vec![
683            WindSegment::new(10.0, 0.0, 500.0),
684            WindSegment::new(20.0, 0.0, 300.0),
685            WindSegment::new(f64::NAN, 0.0, 1.0),
686        ])
687        .unwrap_err();
688        assert_eq!(err.index, 2);
689        assert_eq!(err.field, WindSegmentField::SpeedKmh);
690    }
691
692    #[test]
693    fn wind_segment_error_display_matches_legacy_strings() {
694        // The deferred WindSock::new path and FastSolution surface this Display output;
695        // it must stay byte-identical to the historical String errors.
696        let err = WindSock::try_new(vec![WindSegment::new(f64::NAN, 0.0, 100.0)]).unwrap_err();
697        assert_eq!(
698            err.to_string(),
699            "wind.segments[0].speed_kmh must be finite and non-negative"
700        );
701        let err = WindSock::try_new(vec![WindSegment::new(10.0, 0.0, -5.0)]).unwrap_err();
702        assert_eq!(
703            err.to_string(),
704            "wind.segments[0].until_m must be finite and greater than zero"
705        );
706        let err = WindSock::try_new(vec![WindSegment {
707            vertical_mps: f64::INFINITY,
708            ..WindSegment::new(10.0, 0.0, 100.0)
709        }])
710        .unwrap_err();
711        assert_eq!(
712            err.to_string(),
713            "wind.segments[0].vertical_mps must be finite"
714        );
715    }
716
717    #[test]
718    fn infallible_new_still_defers_the_same_error() {
719        // Legacy behavior preserved: WindSock::new constructs, and the solver-boundary
720        // validate_segments() reports the identical message try_new would have raised.
721        let sock = WindSock::new(vec![WindSegment::new(10.0, f64::NAN, 100.0)]);
722        assert_eq!(
723            sock.validate_segments().unwrap_err(),
724            "wind.segments[0].angle_deg must be finite"
725        );
726    }
727
728    #[test]
729    fn muzzle_crosswind_distinguishes_an_explicit_zero_segment() {
730        let sock = WindSock::new(vec![WindSegment::new(0.0, 90.0, 100.0)]);
731        assert_eq!(sock.muzzle_crosswind_from_right_mps(), Some(0.0));
732    }
733
734    #[test]
735    fn muzzle_crosswind_uses_the_sorted_muzzle_segment_and_wind_from_sign() {
736        let from_right = WindSock::new(vec![
737            WindSegment::new(32.18688, 270.0, 5000.0),
738            WindSegment::new(16.09344, 90.0, 100.0),
739        ]);
740        let right_mps = from_right.muzzle_crosswind_from_right_mps().unwrap();
741        assert!((right_mps - 4.4704).abs() < 1e-12);
742
743        let from_left = WindSock::new(vec![WindSegment::new(16.09344, 270.0, 100.0)]);
744        let left_mps = from_left.muzzle_crosswind_from_right_mps().unwrap();
745        assert!((left_mps + 4.4704).abs() < 1e-12);
746    }
747
748    #[test]
749    fn test_wind_sock_single_segment() {
750        // 16.0934 kmh (10 mph) @ 90° until 100m
751        let sock = WindSock::new(vec![WindSegment::new(16.0934, 90.0, 100.0)]);
752
753        // Should have wind before 100m
754        let vec_50 = sock.vector_for_range_stateless(50.0);
755        println!("vec_50 = [{}, {}, {}]", vec_50[0], vec_50[1], vec_50[2]);
756        assert!(vec_50.norm() > 0.0);
757        // 90° wind from right (crosswind, McCoy): negative Z (lateral), zero Y, near-zero X (downrange)
758        assert!(
759            vec_50[2] < 0.0,
760            "Z (lateral) should be negative for 90° wind, got {}",
761            vec_50[2]
762        );
763        assert_eq!(vec_50[1], 0.0); // Zero Y component (vertical_mps defaults to 0.0, MBA-728)
764        assert!(
765            vec_50[0].abs() < 0.01,
766            "X (downrange) should be nearly zero for 90° wind, got {}",
767            vec_50[0]
768        );
769
770        // No wind after 100m
771        let vec_150 = sock.vector_for_range_stateless(150.0);
772        assert_eq!(vec_150, Vector3::zeros());
773    }
774
775    #[test]
776    fn test_wind_sock_multiple_segments() {
777        // Multiple wind segments (in kmh)
778        let sock = WindSock::new(vec![
779            WindSegment::new(16.0934, 90.0, 50.0), // 10 mph @ 90° until 50m
780            WindSegment::new(24.1401, 45.0, 100.0), // 15 mph @ 45° until 100m
781            WindSegment::new(8.0467, 180.0, 200.0), // 5 mph @ 180° until 200m
782        ]);
783
784        // Test each segment
785        let vec_25 = sock.vector_for_range_stateless(25.0);
786        println!("vec_25 = [{}, {}, {}]", vec_25[0], vec_25[1], vec_25[2]);
787        assert!(vec_25.norm() > 0.0);
788        assert!(vec_25[2] < 0.0, "90° wind should have negative Z (lateral)"); // 90° wind from right
789
790        let vec_75 = sock.vector_for_range_stateless(75.0);
791        println!("vec_75 = [{}, {}, {}]", vec_75[0], vec_75[1], vec_75[2]);
792        assert!(vec_75.norm() > vec_25.norm()); // 15 mph > 10 mph
793        assert!(vec_75[0] < 0.0); // 45° wind has negative X component
794        assert!(vec_75[2] < 0.0); // 45° wind has negative Z component
795
796        let vec_150 = sock.vector_for_range_stateless(150.0);
797        println!("vec_150 = [{}, {}, {}]", vec_150[0], vec_150[1], vec_150[2]);
798        assert!(vec_150.norm() < vec_75.norm()); // 5 mph < 15 mph
799        assert!(
800            vec_150[2].abs() < 0.01,
801            "180° wind should have near-zero Z (lateral), got {}",
802            vec_150[2]
803        ); // 180° wind (from behind)
804        assert!(
805            vec_150[0] > 0.0,
806            "180° wind should have positive X (tailwind, downrange), got {}",
807            vec_150[0]
808        ); // Tailwind
809
810        let vec_250 = sock.vector_for_range_stateless(250.0);
811        assert_eq!(vec_250, Vector3::zeros()); // Beyond all segments
812    }
813
814    #[test]
815    fn test_wind_conversion() {
816        // Test conversion: 16.0934 km/h = 4.47 m/s
817        let sock = WindSock::new(vec![WindSegment::new(16.0934, 0.0, 100.0)]);
818        let vec = sock.vector_for_range_stateless(50.0);
819
820        let expected_speed = 16.0934 * KMH_TO_MPS;
821        assert!((vec.norm() - expected_speed).abs() < 0.01);
822    }
823
824    #[test]
825    fn test_wind_sock_boundary_is_upper_exclusive() {
826        // A segment's `until_distance_m` is exclusive: a query exactly at the
827        // boundary rolls to the next segment.
828        let sock = WindSock::new(vec![
829            WindSegment::new(16.0934, 90.0, 100.0),
830            WindSegment::new(32.1868, 270.0, 200.0),
831        ]);
832        // Just below 100 m -> first segment (90deg, negative Z).
833        assert!(sock.vector_for_range_stateless(99.999)[2] < 0.0);
834        // Exactly 100 m -> second segment (270deg, positive Z).
835        assert!(sock.vector_for_range_stateless(100.0)[2] > 0.0);
836        // Beyond the last boundary -> zero.
837        assert_eq!(sock.vector_for_range_stateless(200.0), Vector3::zeros());
838    }
839
840    #[test]
841    fn calc_vec_passes_through_segment_vertical_unscaled() {
842        // MBA-728: a segment's vertical_mps must land unchanged on the wind vector's Y
843        // component, independent of speed/angle.
844        let seg = WindSegment {
845            speed_kmh: 16.0934,
846            angle_deg: 90.0,
847            until_m: 100.0,
848            vertical_mps: 3.0,
849        };
850        let vec = WindSock::calc_vec(&seg);
851        assert_eq!(vec[1], 3.0);
852    }
853
854    #[test]
855    fn calc_vec_zero_vertical_segment_keeps_zero_y() {
856        // Upgrades (does not replace) the zero-Y assertions above: the historical
857        // 3-field constructor still yields vertical_mps == 0.0 -> Y == 0.0.
858        let seg = WindSegment::new(16.0934, 90.0, 100.0);
859        assert_eq!(seg.vertical_mps, 0.0);
860        let vec = WindSock::calc_vec(&seg);
861        assert_eq!(vec[1], 0.0);
862    }
863
864    #[test]
865    fn test_parse_wind_segment_str_units() {
866        // Imperial: 10 mph -> 16.0934 km/h, 100 yd -> 91.44 m.
867        let seg = parse_wind_segment_str("10:90:100", true).unwrap();
868        assert!((seg.speed_kmh - 16.09344).abs() < 1e-4);
869        assert_eq!(seg.angle_deg, 90.0);
870        assert!((seg.until_m - 91.44).abs() < 1e-4);
871
872        // Metric: 5 m/s -> 18 km/h, 200 m stays 200 m.
873        let seg = parse_wind_segment_str("5:270:200", false).unwrap();
874        assert!((seg.speed_kmh - 18.0).abs() < 1e-9);
875        assert_eq!(seg.angle_deg, 270.0);
876        assert!((seg.until_m - 200.0).abs() < 1e-9);
877
878        // Malformed inputs are rejected.
879        assert!(parse_wind_segment_str("10:90", true).is_err()); // too few fields
880        assert!(parse_wind_segment_str("10:bad:100", true).is_err()); // non-numeric
881        assert!(parse_wind_segment_str("10:90:0", true).is_err()); // zero until-distance
882        assert!(parse_wind_segment_str("-3:90:100", true).is_err()); // negative speed
883                                                                     // Non-finite values must be rejected (NaN comparisons would slip past < / <=).
884        assert!(parse_wind_segment_str("10:nan:5000", true).is_err());
885        assert!(parse_wind_segment_str("10:90:nan", true).is_err());
886        assert!(parse_wind_segment_str("inf:90:100", true).is_err());
887    }
888
889    #[test]
890    fn test_parse_wind_segment_str_vertical_field() {
891        // MBA-728: 3-field input is unchanged (backward compat) -> vertical_mps == 0.0.
892        let seg = parse_wind_segment_str("10:90:100", true).unwrap();
893        assert_eq!(seg.vertical_mps, 0.0);
894        let seg = parse_wind_segment_str("5:270:200", false).unwrap();
895        assert_eq!(seg.vertical_mps, 0.0);
896
897        // 4-field input parses the vertical component, m/s, regardless of --units.
898        let seg = parse_wind_segment_str("10:90:100:5", true).unwrap();
899        assert_eq!(seg.vertical_mps, 5.0);
900        // The rest of the fields still go through the imperial conversion.
901        assert!((seg.speed_kmh - 16.09344).abs() < 1e-4);
902        assert!((seg.until_m - 91.44).abs() < 1e-4);
903
904        // Vertical is NOT converted by --units (always m/s): metric and imperial parses of the
905        // same "...:5" 4th field land on the identical vertical_mps.
906        let seg_metric = parse_wind_segment_str("5:270:200:5", false).unwrap();
907        assert_eq!(seg_metric.vertical_mps, 5.0);
908
909        // Negative vertical (downdraft) is valid.
910        let seg_neg = parse_wind_segment_str("10:90:100:-3.5", true).unwrap();
911        assert_eq!(seg_neg.vertical_mps, -3.5);
912
913        // A non-numeric or non-finite 4th field is rejected.
914        assert!(parse_wind_segment_str("10:90:100:bad", true).is_err());
915        assert!(parse_wind_segment_str("10:90:100:nan", true).is_err());
916        assert!(parse_wind_segment_str("10:90:100:inf", true).is_err());
917
918        // Too many fields is still rejected.
919        assert!(parse_wind_segment_str("10:90:100:5:1", true).is_err());
920    }
921
922    /// MBA-1367: clock-position wind direction entry — the shared helper both the CLI
923    /// value parsers and the WASM hand-rolled arg loops call, so these host-run pins
924    /// ARE the WASM parity pins.
925    #[test]
926    fn clock_positions_map_to_wind_from_degrees() {
927        let deg = |t: &str| parse_wind_direction(t).unwrap();
928        assert_eq!(
929            deg("3oc"),
930            ParsedWindDirection {
931                degrees: 90.0,
932                was_clock: true
933            }
934        );
935        assert_eq!(deg("6oc").degrees, 180.0);
936        assert_eq!(deg("9oc").degrees, 270.0);
937        assert_eq!(deg("12oc").degrees, 0.0); // 12 o'clock = headwind = 0° (post-0.19.0)
938        assert_eq!(deg("10h30").degrees, 315.0);
939        assert_eq!(deg("1h00").degrees, 30.0);
940        assert_eq!(deg("12h30").degrees, 15.0);
941        assert_eq!(deg("7h05").degrees, 212.5);
942        // Case-insensitive markers.
943        assert_eq!(deg("3OC").degrees, 90.0);
944        assert_eq!(deg("10H30").degrees, 315.0);
945
946        // Bare numbers stay degrees, byte-for-byte the old f64 acceptance.
947        assert_eq!(
948            deg("90"),
949            ParsedWindDirection {
950                degrees: 90.0,
951                was_clock: false
952            }
953        );
954        assert_eq!(deg("-45").degrees, -45.0);
955        assert_eq!(deg("370.5").degrees, 370.5);
956
957        // Standalone flags additionally accept the colon form.
958        let sa = parse_wind_direction_standalone("10:30").unwrap();
959        assert_eq!(
960            sa,
961            ParsedWindDirection {
962                degrees: 315.0,
963                was_clock: true
964            }
965        );
966        assert_eq!(
967            parse_wind_direction_standalone("3oc").unwrap().degrees,
968            90.0
969        );
970        assert!(!parse_wind_direction_standalone("90").unwrap().was_clock);
971        // ...but the non-standalone parser rejects the colon form (segment grammar).
972        assert!(matches!(
973            parse_wind_direction("10:30"),
974            Err(WindDirectionParseError::Unrecognized(_))
975        ));
976    }
977
978    #[test]
979    fn clock_positions_reject_bad_hours_and_minutes_with_helpful_messages() {
980        assert!(matches!(
981            parse_wind_direction("13oc"),
982            Err(WindDirectionParseError::HourOutOfRange(_))
983        ));
984        assert!(matches!(
985            parse_wind_direction("0oc"),
986            Err(WindDirectionParseError::HourOutOfRange(_))
987        ));
988        assert!(matches!(
989            parse_wind_direction("3h60"),
990            Err(WindDirectionParseError::MinutesOutOfRange(_))
991        ));
992        assert!(matches!(
993            parse_wind_direction_standalone("13:00"),
994            Err(WindDirectionParseError::HourOutOfRange(_))
995        ));
996        assert!(matches!(
997            parse_wind_direction_standalone("3:60"),
998            Err(WindDirectionParseError::MinutesOutOfRange(_))
999        ));
1000        // Malformed shapes are Unrecognized, with a message naming the accepted forms.
1001        for bad in [
1002            "oc", "hoc", "3.5oc", "3h", "3h123", "10h3x", "3:", ":30", "10:301", "x",
1003        ] {
1004            let err = parse_wind_direction_standalone(bad).unwrap_err();
1005            assert!(
1006                matches!(err, WindDirectionParseError::Unrecognized(_)),
1007                "{bad}: {err}"
1008            );
1009        }
1010        let msg = parse_wind_direction("bogus").unwrap_err().to_string();
1011        assert!(msg.contains("3oc"), "{msg}");
1012        assert!(msg.contains("degrees"), "{msg}");
1013        let msg = parse_wind_direction("13oc").unwrap_err().to_string();
1014        assert!(msg.contains("hour must be 1-12"), "{msg}");
1015    }
1016
1017    /// MBA-1367 in the segment grammar: colon-free marked forms only; `10:30:400`
1018    /// keeps its historical SPEED=10, ANGLE=30, DIST=400 meaning.
1019    #[test]
1020    fn segment_angle_accepts_colon_free_clock_forms_only() {
1021        let clock = parse_wind_segment_str("10:3oc:400", true).unwrap();
1022        let plain = parse_wind_segment_str("10:90:400", true).unwrap();
1023        assert_eq!(clock, plain);
1024
1025        let (seg, was_clock) = parse_wind_segment_str_detailed("10:10h30:400", true).unwrap();
1026        assert_eq!(seg.angle_deg, 315.0);
1027        assert!(was_clock);
1028        let (_, was_clock) = parse_wind_segment_str_detailed("10:90:400", true).unwrap();
1029        assert!(!was_clock);
1030
1031        // The historical 3-numeric form is untouched (10:30 stays SPEED:ANGLE).
1032        let legacy = parse_wind_segment_str("10:30:400", true).unwrap();
1033        assert_eq!(legacy.angle_deg, 30.0);
1034        assert!((legacy.speed_kmh - 10.0 * 1.609344).abs() < 1e-12);
1035        assert!((legacy.until_m - 400.0 * 0.9144).abs() < 1e-9);
1036
1037        // Clock range errors surface through the legacy String boundary, typed text
1038        // preserved; unrecognized angles get the extended legacy message.
1039        let err = parse_wind_segment_str("10:13oc:400", true).unwrap_err();
1040        assert!(err.contains("hour must be 1-12"), "{err}");
1041        let err = parse_wind_segment_str("10:abc:400", true).unwrap_err();
1042        assert!(
1043            err.contains("angle 'abc' is not a number or a clock position"),
1044            "{err}"
1045        );
1046        // A 4-field segment with a clock angle still parses (vertical rides along).
1047        let (seg, was_clock) = parse_wind_segment_str_detailed("10:9oc:400:1.5", true).unwrap();
1048        assert_eq!(seg.angle_deg, 270.0);
1049        assert_eq!(seg.vertical_mps, 1.5);
1050        assert!(was_clock);
1051    }
1052}