Skip to main content

ballistics_engine/
wind.rs

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