1use 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
66const KMH_TO_MPS: f64 = 1000.0 / 3600.0;
68
69pub 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#[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 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
108pub(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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum WindSegmentField {
127 SpeedKmh,
128 AngleDeg,
129 UntilM,
130 VerticalMps,
131}
132
133impl WindSegmentField {
134 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum WindSegmentRule {
154 Finite,
156 FiniteAndNonNegative,
158 FiniteAndPositive,
160}
161
162impl std::fmt::Display for WindSegmentRule {
163 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164 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#[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
187pub 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#[derive(Debug, Clone)]
219pub struct WindSock {
220 winds: Vec<WindSegment>,
222 wind_vecs: Vec<Vector3<f64>>,
225 current: usize,
227 next_range: f64,
229 current_vec: Vector3<f64>,
231 validation_error: Option<WindSegmentError>,
233}
234
235impl WindSock {
236 pub fn new(segments: Vec<WindSegment>) -> Self {
247 let validation_error = validate_wind_segments(&segments).err();
250 Self::build_unchecked(segments, validation_error)
251 }
252
253 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 fn build_unchecked(
266 mut segments: Vec<WindSegment>,
267 validation_error: Option<WindSegmentError>,
268 ) -> Self {
269 sort_wind_segments_by_distance(&mut segments);
271
272 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 pub(crate) fn validate_segments(&self) -> Result<(), String> {
293 self.validation_error
294 .map_or(Ok(()), |err| Err(err.to_string()))
295 }
296
297 fn calc_vec(seg: &WindSegment) -> Vector3<f64> {
299 let speed_mps = seg.speed_kmh * KMH_TO_MPS;
301 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_vector(speed_mps, angle_rad, seg.vertical_mps)
318 }
319
320 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 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 pub fn vector_for_range(&mut self, range_m: f64) -> Vector3<f64> {
341 if range_m.is_nan() {
343 return Vector3::zeros();
344 }
345
346 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 pub fn vector_for_range_stateless(&self, range_m: f64) -> Vector3<f64> {
367 if range_m.is_nan() {
369 return Vector3::zeros();
370 }
371
372 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 Vector3::zeros()
381 }
382}
383
384impl 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
393pub 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
410pub 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 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) } else {
474 (speed * 3.6, until) };
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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
488pub enum WindReference {
489 #[default]
490 Shooter,
491 Compass,
492}
493
494impl WindReference {
495 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
507pub 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
516pub 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#[derive(Debug, Clone, Copy, PartialEq)]
528pub struct ParsedWindDirection {
529 pub degrees: f64,
530 pub was_clock: bool,
531}
532
533#[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
549pub fn parse_wind_direction(token: &str) -> Result<ParsedWindDirection, WindDirectionParseError> {
558 let raw = token.trim();
559
560 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 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
611pub 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 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); assert_eq!(segments[1].speed_kmh, 20.0); assert_eq!(segments[2].speed_kmh, 30.0);
665 assert_eq!(segments[3].speed_kmh, 40.0); assert_eq!(segments[4].speed_kmh, 10.0); 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 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 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 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 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 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 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 let sock = WindSock::new(vec![WindSegment::new(16.0934, 90.0, 100.0)]);
812
813 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 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); 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 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 let sock = WindSock::new(vec![
839 WindSegment::new(16.0934, 90.0, 50.0), WindSegment::new(24.1401, 45.0, 100.0), WindSegment::new(8.0467, 180.0, 200.0), ]);
843
844 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)"); 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()); assert!(vec_75[0] < 0.0); assert!(vec_75[2] < 0.0); 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()); assert!(
860 vec_150[2].abs() < 0.01,
861 "180° wind should have near-zero Z (lateral), got {}",
862 vec_150[2]
863 ); assert!(
865 vec_150[0] > 0.0,
866 "180° wind should have positive X (tailwind, downrange), got {}",
867 vec_150[0]
868 ); let vec_250 = sock.vector_for_range_stateless(250.0);
871 assert_eq!(vec_250, Vector3::zeros()); }
873
874 #[test]
875 fn test_wind_conversion() {
876 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 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 assert!(sock.vector_for_range_stateless(99.999)[2] < 0.0);
894 assert!(sock.vector_for_range_stateless(100.0)[2] > 0.0);
896 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 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 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 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 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 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());
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 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 let seg = parse_wind_segment_str("10:90:100:5", true).unwrap();
959 assert_eq!(seg.vertical_mps, 5.0);
960 assert!((seg.speed_kmh - 16.09344).abs() < 1e-4);
962 assert!((seg.until_m - 91.44).abs() < 1e-4);
963
964 let seg_metric = parse_wind_segment_str("5:270:200:5", false).unwrap();
967 assert_eq!(seg_metric.vertical_mps, 5.0);
968
969 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 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 assert!(parse_wind_segment_str("10:90:100:5:1", true).is_err());
980 }
981
982 #[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); 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 assert_eq!(deg("3OC").degrees, 90.0);
1004 assert_eq!(deg("10H30").degrees, 315.0);
1005
1006 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 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 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 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 #[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 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 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 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}