use nalgebra::Vector3;
use std::cmp::Ordering;
use std::f64::consts::PI;
use thiserror::Error;
const KMH_TO_MPS: f64 = 1000.0 / 3600.0;
pub fn wind_vector(speed_mps: f64, direction_rad: f64, vertical_mps: f64) -> Vector3<f64> {
Vector3::new(
-speed_mps * direction_rad.cos(),
vertical_mps,
-speed_mps * direction_rad.sin(),
)
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct WindSegment {
pub speed_kmh: f64,
pub angle_deg: f64,
pub until_m: f64,
pub vertical_mps: f64,
}
impl WindSegment {
pub fn new(speed_kmh: f64, angle_deg: f64, until_m: f64) -> Self {
Self {
speed_kmh,
angle_deg,
until_m,
vertical_mps: 0.0,
}
}
}
pub(crate) fn sort_wind_segments_by_distance(segments: &mut [WindSegment]) {
segments.sort_by(|a, b| match (a.until_m.is_nan(), b.until_m.is_nan()) {
(true, true) => Ordering::Equal,
(true, false) => Ordering::Greater,
(false, true) => Ordering::Less,
(false, false) => {
a.until_m
.partial_cmp(&b.until_m)
.expect("non-NaN distances are ordered")
}
});
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WindSegmentField {
SpeedKmh,
AngleDeg,
UntilM,
VerticalMps,
}
impl WindSegmentField {
pub fn name(self) -> &'static str {
match self {
WindSegmentField::SpeedKmh => "speed_kmh",
WindSegmentField::AngleDeg => "angle_deg",
WindSegmentField::UntilM => "until_m",
WindSegmentField::VerticalMps => "vertical_mps",
}
}
}
impl std::fmt::Display for WindSegmentField {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.name())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WindSegmentRule {
Finite,
FiniteAndNonNegative,
FiniteAndPositive,
}
impl std::fmt::Display for WindSegmentRule {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
WindSegmentRule::Finite => "finite",
WindSegmentRule::FiniteAndNonNegative => "finite and non-negative",
WindSegmentRule::FiniteAndPositive => "finite and greater than zero",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("wind.segments[{index}].{field} must be {rule}")]
pub struct WindSegmentError {
pub index: usize,
pub field: WindSegmentField,
pub rule: WindSegmentRule,
}
pub fn validate_wind_segments(segments: &[WindSegment]) -> Result<(), WindSegmentError> {
for (index, segment) in segments.iter().enumerate() {
let err = |field, rule| Err(WindSegmentError { index, field, rule });
if !segment.speed_kmh.is_finite() || segment.speed_kmh < 0.0 {
return err(
WindSegmentField::SpeedKmh,
WindSegmentRule::FiniteAndNonNegative,
);
}
if !segment.angle_deg.is_finite() {
return err(WindSegmentField::AngleDeg, WindSegmentRule::Finite);
}
if !segment.until_m.is_finite() || segment.until_m <= 0.0 {
return err(WindSegmentField::UntilM, WindSegmentRule::FiniteAndPositive);
}
if !segment.vertical_mps.is_finite() {
return err(WindSegmentField::VerticalMps, WindSegmentRule::Finite);
}
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct WindSock {
winds: Vec<WindSegment>,
wind_vecs: Vec<Vector3<f64>>,
current: usize,
next_range: f64,
current_vec: Vector3<f64>,
validation_error: Option<WindSegmentError>,
}
impl WindSock {
pub fn new(segments: Vec<WindSegment>) -> Self {
let validation_error = validate_wind_segments(&segments).err();
Self::build_unchecked(segments, validation_error)
}
pub fn try_new(segments: Vec<WindSegment>) -> Result<Self, WindSegmentError> {
validate_wind_segments(&segments)?;
Ok(Self::build_unchecked(segments, None))
}
fn build_unchecked(
mut segments: Vec<WindSegment>,
validation_error: Option<WindSegmentError>,
) -> Self {
sort_wind_segments_by_distance(&mut segments);
let wind_vecs: Vec<Vector3<f64>> = segments.iter().map(Self::calc_vec).collect();
let (current, next_range, current_vec) = if segments.is_empty() {
(0, f64::INFINITY, Vector3::zeros())
} else {
(0, segments[0].until_m, wind_vecs[0])
};
WindSock {
winds: segments,
wind_vecs,
current,
next_range,
current_vec,
validation_error,
}
}
pub(crate) fn validate_segments(&self) -> Result<(), String> {
self.validation_error
.map_or(Ok(()), |err| Err(err.to_string()))
}
fn calc_vec(seg: &WindSegment) -> Vector3<f64> {
let speed_mps = seg.speed_kmh * KMH_TO_MPS;
let angle_rad = if seg.angle_deg.abs() <= f64::MAX / PI {
seg.angle_deg * PI / 180.0
} else {
seg.angle_deg / 180.0 * PI
};
wind_vector(speed_mps, angle_rad, seg.vertical_mps)
}
pub fn max_speed_mps(&self) -> f64 {
self.winds
.iter()
.map(|seg| seg.speed_kmh.abs() * KMH_TO_MPS + seg.vertical_mps.abs())
.fold(0.0, f64::max)
}
pub(crate) fn muzzle_crosswind_from_right_mps(&self) -> Option<f64> {
(!self.winds.is_empty()).then(|| -self.vector_for_range_stateless(0.0)[2])
}
pub fn vector_for_range(&mut self, range_m: f64) -> Vector3<f64> {
if range_m.is_nan() {
return Vector3::zeros();
}
while range_m >= self.next_range && self.current < self.winds.len() {
self.current += 1;
if self.current >= self.winds.len() {
self.current_vec = Vector3::zeros();
self.next_range = f64::INFINITY;
} else {
self.current_vec = self.wind_vecs[self.current];
self.next_range = self.winds[self.current].until_m;
}
}
self.current_vec
}
pub fn vector_for_range_stateless(&self, range_m: f64) -> Vector3<f64> {
if range_m.is_nan() {
return Vector3::zeros();
}
for (i, segment) in self.winds.iter().enumerate() {
if range_m < segment.until_m {
return self.wind_vecs[i];
}
}
Vector3::zeros()
}
}
impl TryFrom<Vec<WindSegment>> for WindSock {
type Error = WindSegmentError;
fn try_from(segments: Vec<WindSegment>) -> Result<Self, Self::Error> {
WindSock::try_new(segments)
}
}
pub fn parse_wind_segment_str(s: &str, imperial: bool) -> Result<WindSegment, String> {
parse_wind_segment_str_detailed(s, imperial).map(|(segment, _)| segment)
}
pub fn parse_wind_segment_str_detailed(
s: &str,
imperial: bool,
) -> Result<(WindSegment, bool), String> {
let parts: Vec<&str> = s.split(':').collect();
if parts.len() != 3 && parts.len() != 4 {
return Err(format!(
"invalid wind segment '{s}': expected SPEED:ANGLE:UNTIL_DISTANCE[:VERTICAL] \
(three or four colon-separated numbers; the optional 4th field VERTICAL is always \
m/s, positive = updraft, regardless of --units)"
));
}
let num = |i: usize, name: &str| -> Result<f64, String> {
parts[i].trim().parse::<f64>().map_err(|_| {
format!("invalid wind segment '{s}': {name} '{}' is not a number", parts[i])
})
};
let speed = num(0, "speed")?;
let (angle, angle_was_clock) = match parse_wind_direction(parts[1]) {
Ok(parsed) => (parsed.degrees, parsed.was_clock),
Err(WindDirectionParseError::Unrecognized(_)) => {
return Err(format!(
"invalid wind segment '{s}': angle '{}' is not a number or a clock \
position like 3oc or 10h30",
parts[1]
))
}
Err(e) => return Err(format!("invalid wind segment '{s}': {e}")),
};
let until = num(2, "until-distance")?;
let vertical = if parts.len() == 4 {
num(3, "vertical")?
} else {
0.0
};
if !speed.is_finite() || !angle.is_finite() || !until.is_finite() || !vertical.is_finite() {
return Err(format!(
"invalid wind segment '{s}': speed, angle, until-distance, and vertical (m/s, \
positive = updraft) must be finite numbers"
));
}
if speed < 0.0 {
return Err(format!("invalid wind segment '{s}': speed must be >= 0"));
}
if until <= 0.0 {
return Err(format!("invalid wind segment '{s}': until-distance must be > 0"));
}
let (speed_kmh, until_m) = if imperial {
(speed * 1.609344, until * 0.9144) } else {
(speed * 3.6, until) };
let mut segment = WindSegment::new(speed_kmh, angle, until_m);
segment.vertical_mps = vertical;
Ok((segment, angle_was_clock))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WindReference {
#[default]
Shooter,
Compass,
}
impl WindReference {
pub fn parse(token: &str) -> Result<Self, String> {
match token.trim().to_ascii_lowercase().as_str() {
"shooter" => Ok(Self::Shooter),
"compass" => Ok(Self::Compass),
other => Err(format!(
"invalid wind reference '{other}': expected 'shooter' or 'compass'"
)),
}
}
}
pub fn compass_bearing_to_shooter_relative_deg(bearing_deg: f64, shot_azimuth_deg: f64) -> f64 {
(bearing_deg - shot_azimuth_deg).rem_euclid(360.0)
}
pub fn compass_bearing_to_shooter_relative_rad(bearing_rad: f64, shot_azimuth_rad: f64) -> f64 {
(bearing_rad - shot_azimuth_rad).rem_euclid(2.0 * PI)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ParsedWindDirection {
pub degrees: f64,
pub was_clock: bool,
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum WindDirectionParseError {
#[error(
"invalid wind direction '{0}': expected degrees (e.g. 90) or a clock position \
like 3oc or 10h30 (12oc = headwind)"
)]
Unrecognized(String),
#[error("invalid clock position '{0}': hour must be 1-12")]
HourOutOfRange(String),
#[error("invalid clock position '{0}': minutes must be 00-59")]
MinutesOutOfRange(String),
}
pub fn parse_wind_direction(token: &str) -> Result<ParsedWindDirection, WindDirectionParseError> {
let raw = token.trim();
if let Ok(degrees) = raw.parse::<f64>() {
return Ok(ParsedWindDirection {
degrees,
was_clock: false,
});
}
let lower = raw.to_ascii_lowercase();
let clock = |hour_str: &str, minute_str: Option<&str>| -> Result<ParsedWindDirection, WindDirectionParseError> {
let hour: u32 = hour_str
.parse()
.map_err(|_| WindDirectionParseError::Unrecognized(raw.to_string()))?;
if !(1..=12).contains(&hour) {
return Err(WindDirectionParseError::HourOutOfRange(raw.to_string()));
}
let minutes: u32 = match minute_str {
Some(m) if !m.is_empty() && m.len() <= 2 && m.bytes().all(|b| b.is_ascii_digit()) => {
m.parse()
.map_err(|_| WindDirectionParseError::Unrecognized(raw.to_string()))?
}
Some(_) => return Err(WindDirectionParseError::Unrecognized(raw.to_string())),
None => 0,
};
if minutes > 59 {
return Err(WindDirectionParseError::MinutesOutOfRange(raw.to_string()));
}
Ok(ParsedWindDirection {
degrees: f64::from(hour % 12) * 30.0 + f64::from(minutes) * 0.5,
was_clock: true,
})
};
if let Some(hour_str) = lower.strip_suffix("oc") {
if !hour_str.is_empty() && hour_str.bytes().all(|b| b.is_ascii_digit()) {
return clock(hour_str, None);
}
return Err(WindDirectionParseError::Unrecognized(raw.to_string()));
}
if let Some((hour_str, minute_str)) = lower.split_once('h') {
if !hour_str.is_empty() && hour_str.bytes().all(|b| b.is_ascii_digit()) {
return clock(hour_str, Some(minute_str));
}
}
Err(WindDirectionParseError::Unrecognized(raw.to_string()))
}
pub fn parse_wind_direction_standalone(
token: &str,
) -> Result<ParsedWindDirection, WindDirectionParseError> {
let raw = token.trim();
if let Some((hour_str, minute_str)) = raw.split_once(':') {
if !hour_str.is_empty()
&& hour_str.bytes().all(|b| b.is_ascii_digit())
&& !minute_str.is_empty()
&& minute_str.len() <= 2
&& minute_str.bytes().all(|b| b.is_ascii_digit())
{
let with_h = format!("{hour_str}h{minute_str}");
return match parse_wind_direction(&with_h) {
Ok(parsed) => Ok(parsed),
Err(WindDirectionParseError::HourOutOfRange(_)) => {
Err(WindDirectionParseError::HourOutOfRange(raw.to_string()))
}
Err(WindDirectionParseError::MinutesOutOfRange(_)) => {
Err(WindDirectionParseError::MinutesOutOfRange(raw.to_string()))
}
Err(WindDirectionParseError::Unrecognized(_)) => {
Err(WindDirectionParseError::Unrecognized(raw.to_string()))
}
};
}
return Err(WindDirectionParseError::Unrecognized(raw.to_string()));
}
parse_wind_direction(raw)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn segment_sort_is_stable_and_places_nan_endpoints_last() {
let mut segments = vec![
WindSegment::new(10.0, 0.0, f64::NAN),
WindSegment::new(20.0, 0.0, 100.0),
WindSegment::new(30.0, 0.0, 100.0),
WindSegment::new(40.0, 0.0, f64::INFINITY),
WindSegment::new(50.0, 0.0, f64::NEG_INFINITY),
WindSegment::new(60.0, 0.0, f64::NAN),
];
sort_wind_segments_by_distance(&mut segments);
assert_eq!(segments[0].speed_kmh, 50.0); assert_eq!(segments[1].speed_kmh, 20.0); assert_eq!(segments[2].speed_kmh, 30.0);
assert_eq!(segments[3].speed_kmh, 40.0); assert_eq!(segments[4].speed_kmh, 10.0); assert_eq!(segments[5].speed_kmh, 60.0);
assert!(segments[4].until_m.is_nan() && segments[5].until_m.is_nan());
}
#[test]
fn test_wind_sock_empty() {
let sock = WindSock::new(vec![]);
assert_eq!(sock.vector_for_range_stateless(50.0), Vector3::zeros());
assert_eq!(sock.muzzle_crosswind_from_right_mps(), None);
}
#[test]
fn try_new_accepts_valid_and_empty_segments() {
assert!(WindSock::try_new(vec![]).is_ok());
let sock = WindSock::try_new(vec![WindSegment::new(16.0934, 90.0, 100.0)]).unwrap();
assert!(sock.vector_for_range_stateless(50.0).norm() > 0.0);
assert!(WindSock::try_from(vec![WindSegment::new(10.0, 0.0, 50.0)]).is_ok());
assert!(WindSock::try_from(vec![WindSegment::new(f64::NAN, 0.0, 50.0)]).is_err());
}
#[test]
fn try_new_rejects_each_field_with_typed_index_field_rule() {
let ok = WindSegment::new(10.0, 0.0, 100.0);
let cases: Vec<(WindSegment, WindSegmentField, WindSegmentRule)> = vec![
(
WindSegment::new(f64::NAN, 0.0, 100.0),
WindSegmentField::SpeedKmh,
WindSegmentRule::FiniteAndNonNegative,
),
(
WindSegment::new(-1.0, 0.0, 100.0),
WindSegmentField::SpeedKmh,
WindSegmentRule::FiniteAndNonNegative,
),
(
WindSegment::new(10.0, f64::INFINITY, 100.0),
WindSegmentField::AngleDeg,
WindSegmentRule::Finite,
),
(
WindSegment::new(10.0, 0.0, 0.0),
WindSegmentField::UntilM,
WindSegmentRule::FiniteAndPositive,
),
(
WindSegment::new(10.0, 0.0, f64::NEG_INFINITY),
WindSegmentField::UntilM,
WindSegmentRule::FiniteAndPositive,
),
(
WindSegment {
vertical_mps: f64::NAN,
..ok
},
WindSegmentField::VerticalMps,
WindSegmentRule::Finite,
),
];
for (bad, field, rule) in cases {
let err = WindSock::try_new(vec![ok, bad]).unwrap_err();
assert_eq!(err.index, 1, "index for {field}");
assert_eq!(err.field, field);
assert_eq!(err.rule, rule);
}
}
#[test]
fn try_new_error_index_is_the_callers_presort_index() {
let err = WindSock::try_new(vec![
WindSegment::new(10.0, 0.0, 500.0),
WindSegment::new(20.0, 0.0, 300.0),
WindSegment::new(f64::NAN, 0.0, 1.0),
])
.unwrap_err();
assert_eq!(err.index, 2);
assert_eq!(err.field, WindSegmentField::SpeedKmh);
}
#[test]
fn wind_segment_error_display_matches_legacy_strings() {
let err = WindSock::try_new(vec![WindSegment::new(f64::NAN, 0.0, 100.0)]).unwrap_err();
assert_eq!(
err.to_string(),
"wind.segments[0].speed_kmh must be finite and non-negative"
);
let err = WindSock::try_new(vec![WindSegment::new(10.0, 0.0, -5.0)]).unwrap_err();
assert_eq!(
err.to_string(),
"wind.segments[0].until_m must be finite and greater than zero"
);
let err = WindSock::try_new(vec![WindSegment {
vertical_mps: f64::INFINITY,
..WindSegment::new(10.0, 0.0, 100.0)
}])
.unwrap_err();
assert_eq!(
err.to_string(),
"wind.segments[0].vertical_mps must be finite"
);
}
#[test]
fn infallible_new_still_defers_the_same_error() {
let sock = WindSock::new(vec![WindSegment::new(10.0, f64::NAN, 100.0)]);
assert_eq!(
sock.validate_segments().unwrap_err(),
"wind.segments[0].angle_deg must be finite"
);
}
#[test]
fn muzzle_crosswind_distinguishes_an_explicit_zero_segment() {
let sock = WindSock::new(vec![WindSegment::new(0.0, 90.0, 100.0)]);
assert_eq!(sock.muzzle_crosswind_from_right_mps(), Some(0.0));
}
#[test]
fn muzzle_crosswind_uses_the_sorted_muzzle_segment_and_wind_from_sign() {
let from_right = WindSock::new(vec![
WindSegment::new(32.18688, 270.0, 5000.0),
WindSegment::new(16.09344, 90.0, 100.0),
]);
let right_mps = from_right.muzzle_crosswind_from_right_mps().unwrap();
assert!((right_mps - 4.4704).abs() < 1e-12);
let from_left = WindSock::new(vec![WindSegment::new(16.09344, 270.0, 100.0)]);
let left_mps = from_left.muzzle_crosswind_from_right_mps().unwrap();
assert!((left_mps + 4.4704).abs() < 1e-12);
}
#[test]
fn test_wind_sock_single_segment() {
let sock = WindSock::new(vec![WindSegment::new(16.0934, 90.0, 100.0)]);
let vec_50 = sock.vector_for_range_stateless(50.0);
println!("vec_50 = [{}, {}, {}]", vec_50[0], vec_50[1], vec_50[2]);
assert!(vec_50.norm() > 0.0);
assert!(
vec_50[2] < 0.0,
"Z (lateral) should be negative for 90° wind, got {}",
vec_50[2]
);
assert_eq!(vec_50[1], 0.0); assert!(
vec_50[0].abs() < 0.01,
"X (downrange) should be nearly zero for 90° wind, got {}",
vec_50[0]
);
let vec_150 = sock.vector_for_range_stateless(150.0);
assert_eq!(vec_150, Vector3::zeros());
}
#[test]
fn test_wind_sock_multiple_segments() {
let sock = WindSock::new(vec![
WindSegment::new(16.0934, 90.0, 50.0), WindSegment::new(24.1401, 45.0, 100.0), WindSegment::new(8.0467, 180.0, 200.0), ]);
let vec_25 = sock.vector_for_range_stateless(25.0);
println!("vec_25 = [{}, {}, {}]", vec_25[0], vec_25[1], vec_25[2]);
assert!(vec_25.norm() > 0.0);
assert!(vec_25[2] < 0.0, "90° wind should have negative Z (lateral)");
let vec_75 = sock.vector_for_range_stateless(75.0);
println!("vec_75 = [{}, {}, {}]", vec_75[0], vec_75[1], vec_75[2]);
assert!(vec_75.norm() > vec_25.norm()); assert!(vec_75[0] < 0.0); assert!(vec_75[2] < 0.0);
let vec_150 = sock.vector_for_range_stateless(150.0);
println!("vec_150 = [{}, {}, {}]", vec_150[0], vec_150[1], vec_150[2]);
assert!(vec_150.norm() < vec_75.norm()); assert!(
vec_150[2].abs() < 0.01,
"180° wind should have near-zero Z (lateral), got {}",
vec_150[2]
); assert!(
vec_150[0] > 0.0,
"180° wind should have positive X (tailwind, downrange), got {}",
vec_150[0]
);
let vec_250 = sock.vector_for_range_stateless(250.0);
assert_eq!(vec_250, Vector3::zeros()); }
#[test]
fn test_wind_conversion() {
let sock = WindSock::new(vec![WindSegment::new(16.0934, 0.0, 100.0)]);
let vec = sock.vector_for_range_stateless(50.0);
let expected_speed = 16.0934 * KMH_TO_MPS;
assert!((vec.norm() - expected_speed).abs() < 0.01);
}
#[test]
fn test_wind_sock_boundary_is_upper_exclusive() {
let sock = WindSock::new(vec![
WindSegment::new(16.0934, 90.0, 100.0),
WindSegment::new(32.1868, 270.0, 200.0),
]);
assert!(sock.vector_for_range_stateless(99.999)[2] < 0.0);
assert!(sock.vector_for_range_stateless(100.0)[2] > 0.0);
assert_eq!(sock.vector_for_range_stateless(200.0), Vector3::zeros());
}
#[test]
fn calc_vec_passes_through_segment_vertical_unscaled() {
let seg = WindSegment {
speed_kmh: 16.0934,
angle_deg: 90.0,
until_m: 100.0,
vertical_mps: 3.0,
};
let vec = WindSock::calc_vec(&seg);
assert_eq!(vec[1], 3.0);
}
#[test]
fn calc_vec_zero_vertical_segment_keeps_zero_y() {
let seg = WindSegment::new(16.0934, 90.0, 100.0);
assert_eq!(seg.vertical_mps, 0.0);
let vec = WindSock::calc_vec(&seg);
assert_eq!(vec[1], 0.0);
}
#[test]
fn test_parse_wind_segment_str_units() {
let seg = parse_wind_segment_str("10:90:100", true).unwrap();
assert!((seg.speed_kmh - 16.09344).abs() < 1e-4);
assert_eq!(seg.angle_deg, 90.0);
assert!((seg.until_m - 91.44).abs() < 1e-4);
let seg = parse_wind_segment_str("5:270:200", false).unwrap();
assert!((seg.speed_kmh - 18.0).abs() < 1e-9);
assert_eq!(seg.angle_deg, 270.0);
assert!((seg.until_m - 200.0).abs() < 1e-9);
assert!(parse_wind_segment_str("10:90", true).is_err()); assert!(parse_wind_segment_str("10:bad:100", true).is_err()); assert!(parse_wind_segment_str("10:90:0", true).is_err()); assert!(parse_wind_segment_str("-3:90:100", true).is_err()); assert!(parse_wind_segment_str("10:nan:5000", true).is_err());
assert!(parse_wind_segment_str("10:90:nan", true).is_err());
assert!(parse_wind_segment_str("inf:90:100", true).is_err());
}
#[test]
fn test_parse_wind_segment_str_vertical_field() {
let seg = parse_wind_segment_str("10:90:100", true).unwrap();
assert_eq!(seg.vertical_mps, 0.0);
let seg = parse_wind_segment_str("5:270:200", false).unwrap();
assert_eq!(seg.vertical_mps, 0.0);
let seg = parse_wind_segment_str("10:90:100:5", true).unwrap();
assert_eq!(seg.vertical_mps, 5.0);
assert!((seg.speed_kmh - 16.09344).abs() < 1e-4);
assert!((seg.until_m - 91.44).abs() < 1e-4);
let seg_metric = parse_wind_segment_str("5:270:200:5", false).unwrap();
assert_eq!(seg_metric.vertical_mps, 5.0);
let seg_neg = parse_wind_segment_str("10:90:100:-3.5", true).unwrap();
assert_eq!(seg_neg.vertical_mps, -3.5);
assert!(parse_wind_segment_str("10:90:100:bad", true).is_err());
assert!(parse_wind_segment_str("10:90:100:nan", true).is_err());
assert!(parse_wind_segment_str("10:90:100:inf", true).is_err());
assert!(parse_wind_segment_str("10:90:100:5:1", true).is_err());
}
#[test]
fn clock_positions_map_to_wind_from_degrees() {
let deg = |t: &str| parse_wind_direction(t).unwrap();
assert_eq!(deg("3oc"), ParsedWindDirection { degrees: 90.0, was_clock: true });
assert_eq!(deg("6oc").degrees, 180.0);
assert_eq!(deg("9oc").degrees, 270.0);
assert_eq!(deg("12oc").degrees, 0.0); assert_eq!(deg("10h30").degrees, 315.0);
assert_eq!(deg("1h00").degrees, 30.0);
assert_eq!(deg("12h30").degrees, 15.0);
assert_eq!(deg("7h05").degrees, 212.5);
assert_eq!(deg("3OC").degrees, 90.0);
assert_eq!(deg("10H30").degrees, 315.0);
assert_eq!(deg("90"), ParsedWindDirection { degrees: 90.0, was_clock: false });
assert_eq!(deg("-45").degrees, -45.0);
assert_eq!(deg("370.5").degrees, 370.5);
let sa = parse_wind_direction_standalone("10:30").unwrap();
assert_eq!(sa, ParsedWindDirection { degrees: 315.0, was_clock: true });
assert_eq!(parse_wind_direction_standalone("3oc").unwrap().degrees, 90.0);
assert!(!parse_wind_direction_standalone("90").unwrap().was_clock);
assert!(matches!(
parse_wind_direction("10:30"),
Err(WindDirectionParseError::Unrecognized(_))
));
}
#[test]
fn clock_positions_reject_bad_hours_and_minutes_with_helpful_messages() {
assert!(matches!(
parse_wind_direction("13oc"),
Err(WindDirectionParseError::HourOutOfRange(_))
));
assert!(matches!(
parse_wind_direction("0oc"),
Err(WindDirectionParseError::HourOutOfRange(_))
));
assert!(matches!(
parse_wind_direction("3h60"),
Err(WindDirectionParseError::MinutesOutOfRange(_))
));
assert!(matches!(
parse_wind_direction_standalone("13:00"),
Err(WindDirectionParseError::HourOutOfRange(_))
));
assert!(matches!(
parse_wind_direction_standalone("3:60"),
Err(WindDirectionParseError::MinutesOutOfRange(_))
));
for bad in ["oc", "hoc", "3.5oc", "3h", "3h123", "10h3x", "3:", ":30", "10:301", "x"] {
let err = parse_wind_direction_standalone(bad).unwrap_err();
assert!(
matches!(err, WindDirectionParseError::Unrecognized(_)),
"{bad}: {err}"
);
}
let msg = parse_wind_direction("bogus").unwrap_err().to_string();
assert!(msg.contains("3oc"), "{msg}");
assert!(msg.contains("degrees"), "{msg}");
let msg = parse_wind_direction("13oc").unwrap_err().to_string();
assert!(msg.contains("hour must be 1-12"), "{msg}");
}
#[test]
fn segment_angle_accepts_colon_free_clock_forms_only() {
let clock = parse_wind_segment_str("10:3oc:400", true).unwrap();
let plain = parse_wind_segment_str("10:90:400", true).unwrap();
assert_eq!(clock, plain);
let (seg, was_clock) = parse_wind_segment_str_detailed("10:10h30:400", true).unwrap();
assert_eq!(seg.angle_deg, 315.0);
assert!(was_clock);
let (_, was_clock) = parse_wind_segment_str_detailed("10:90:400", true).unwrap();
assert!(!was_clock);
let legacy = parse_wind_segment_str("10:30:400", true).unwrap();
assert_eq!(legacy.angle_deg, 30.0);
assert!((legacy.speed_kmh - 10.0 * 1.609344).abs() < 1e-12);
assert!((legacy.until_m - 400.0 * 0.9144).abs() < 1e-9);
let err = parse_wind_segment_str("10:13oc:400", true).unwrap_err();
assert!(err.contains("hour must be 1-12"), "{err}");
let err = parse_wind_segment_str("10:abc:400", true).unwrap_err();
assert!(
err.contains("angle 'abc' is not a number or a clock position"),
"{err}"
);
let (seg, was_clock) = parse_wind_segment_str_detailed("10:9oc:400:1.5", true).unwrap();
assert_eq!(seg.angle_deg, 270.0);
assert_eq!(seg.vertical_mps, 1.5);
assert!(was_clock);
}
}