1use nalgebra::Vector3;
2use std::cmp::Ordering;
3use std::f64::consts::PI;
4use thiserror::Error;
5
6const KMH_TO_MPS: f64 = 1000.0 / 3600.0;
8
9pub fn wind_vector(speed_mps: f64, direction_rad: f64, vertical_mps: f64) -> Vector3<f64> {
14 Vector3::new(
15 -speed_mps * direction_rad.cos(),
16 vertical_mps,
17 -speed_mps * direction_rad.sin(),
18 )
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Default)]
28pub struct WindSegment {
29 pub speed_kmh: f64,
30 pub angle_deg: f64,
31 pub until_m: f64,
32 pub vertical_mps: f64,
33}
34
35impl WindSegment {
36 pub fn new(speed_kmh: f64, angle_deg: f64, until_m: f64) -> Self {
39 Self {
40 speed_kmh,
41 angle_deg,
42 until_m,
43 vertical_mps: 0.0,
44 }
45 }
46}
47
48pub(crate) fn sort_wind_segments_by_distance(segments: &mut [WindSegment]) {
53 segments.sort_by(|a, b| match (a.until_m.is_nan(), b.until_m.is_nan()) {
54 (true, true) => Ordering::Equal,
55 (true, false) => Ordering::Greater,
56 (false, true) => Ordering::Less,
57 (false, false) => a
58 .until_m
59 .partial_cmp(&b.until_m)
60 .expect("non-NaN distances are ordered"),
61 });
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum WindSegmentField {
67 SpeedKmh,
68 AngleDeg,
69 UntilM,
70 VerticalMps,
71}
72
73impl WindSegmentField {
74 pub fn name(self) -> &'static str {
76 match self {
77 WindSegmentField::SpeedKmh => "speed_kmh",
78 WindSegmentField::AngleDeg => "angle_deg",
79 WindSegmentField::UntilM => "until_m",
80 WindSegmentField::VerticalMps => "vertical_mps",
81 }
82 }
83}
84
85impl std::fmt::Display for WindSegmentField {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 f.write_str(self.name())
88 }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum WindSegmentRule {
94 Finite,
96 FiniteAndNonNegative,
98 FiniteAndPositive,
100}
101
102impl std::fmt::Display for WindSegmentRule {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 f.write_str(match self {
107 WindSegmentRule::Finite => "finite",
108 WindSegmentRule::FiniteAndNonNegative => "finite and non-negative",
109 WindSegmentRule::FiniteAndPositive => "finite and greater than zero",
110 })
111 }
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
120#[error("wind.segments[{index}].{field} must be {rule}")]
121pub struct WindSegmentError {
122 pub index: usize,
123 pub field: WindSegmentField,
124 pub rule: WindSegmentRule,
125}
126
127pub fn validate_wind_segments(segments: &[WindSegment]) -> Result<(), WindSegmentError> {
134 for (index, segment) in segments.iter().enumerate() {
135 let err = |field, rule| Err(WindSegmentError { index, field, rule });
136
137 if !segment.speed_kmh.is_finite() || segment.speed_kmh < 0.0 {
138 return err(
139 WindSegmentField::SpeedKmh,
140 WindSegmentRule::FiniteAndNonNegative,
141 );
142 }
143 if !segment.angle_deg.is_finite() {
144 return err(WindSegmentField::AngleDeg, WindSegmentRule::Finite);
145 }
146 if !segment.until_m.is_finite() || segment.until_m <= 0.0 {
147 return err(WindSegmentField::UntilM, WindSegmentRule::FiniteAndPositive);
148 }
149 if !segment.vertical_mps.is_finite() {
150 return err(WindSegmentField::VerticalMps, WindSegmentRule::Finite);
151 }
152 }
153
154 Ok(())
155}
156
157#[derive(Debug, Clone)]
159pub struct WindSock {
160 winds: Vec<WindSegment>,
162 wind_vecs: Vec<Vector3<f64>>,
165 current: usize,
167 next_range: f64,
169 current_vec: Vector3<f64>,
171 validation_error: Option<WindSegmentError>,
173}
174
175impl WindSock {
176 pub fn new(segments: Vec<WindSegment>) -> Self {
187 let validation_error = validate_wind_segments(&segments).err();
190 Self::build_unchecked(segments, validation_error)
191 }
192
193 pub fn try_new(segments: Vec<WindSegment>) -> Result<Self, WindSegmentError> {
200 validate_wind_segments(&segments)?;
201 Ok(Self::build_unchecked(segments, None))
202 }
203
204 fn build_unchecked(
206 mut segments: Vec<WindSegment>,
207 validation_error: Option<WindSegmentError>,
208 ) -> Self {
209 sort_wind_segments_by_distance(&mut segments);
211
212 let wind_vecs: Vec<Vector3<f64>> = segments.iter().map(Self::calc_vec).collect();
214
215 let (current, next_range, current_vec) = if segments.is_empty() {
216 (0, f64::INFINITY, Vector3::zeros())
217 } else {
218 (0, segments[0].until_m, wind_vecs[0])
219 };
220
221 WindSock {
222 winds: segments,
223 wind_vecs,
224 current,
225 next_range,
226 current_vec,
227 validation_error,
228 }
229 }
230
231 pub(crate) fn validate_segments(&self) -> Result<(), String> {
233 self.validation_error
234 .map_or(Ok(()), |err| Err(err.to_string()))
235 }
236
237 fn calc_vec(seg: &WindSegment) -> Vector3<f64> {
239 let speed_mps = seg.speed_kmh * KMH_TO_MPS;
241 let angle_rad = if seg.angle_deg.abs() <= f64::MAX / PI {
244 seg.angle_deg * PI / 180.0
245 } else {
246 seg.angle_deg / 180.0 * PI
247 };
248
249 wind_vector(speed_mps, angle_rad, seg.vertical_mps)
258 }
259
260 pub fn max_speed_mps(&self) -> f64 {
263 self.winds
264 .iter()
265 .map(|seg| seg.speed_kmh.abs() * KMH_TO_MPS + seg.vertical_mps.abs())
266 .fold(0.0, f64::max)
267 }
268
269 pub(crate) fn muzzle_crosswind_from_right_mps(&self) -> Option<f64> {
273 (!self.winds.is_empty()).then(|| -self.vector_for_range_stateless(0.0)[2])
274 }
275
276 pub fn vector_for_range(&mut self, range_m: f64) -> Vector3<f64> {
281 if range_m.is_nan() {
283 return Vector3::zeros();
284 }
285
286 while range_m >= self.next_range && self.current < self.winds.len() {
289 self.current += 1;
290 if self.current >= self.winds.len() {
291 self.current_vec = Vector3::zeros();
292 self.next_range = f64::INFINITY;
293 } else {
294 self.current_vec = self.wind_vecs[self.current];
295 self.next_range = self.winds[self.current].until_m;
296 }
297 }
298
299 self.current_vec
300 }
301
302 pub fn vector_for_range_stateless(&self, range_m: f64) -> Vector3<f64> {
307 if range_m.is_nan() {
309 return Vector3::zeros();
310 }
311
312 for (i, segment) in self.winds.iter().enumerate() {
314 if range_m < segment.until_m {
315 return self.wind_vecs[i];
316 }
317 }
318
319 Vector3::zeros()
321 }
322}
323
324impl TryFrom<Vec<WindSegment>> for WindSock {
326 type Error = WindSegmentError;
327
328 fn try_from(segments: Vec<WindSegment>) -> Result<Self, Self::Error> {
329 WindSock::try_new(segments)
330 }
331}
332
333pub fn parse_wind_segment_str(s: &str, imperial: bool) -> Result<WindSegment, String> {
347 parse_wind_segment_str_detailed(s, imperial).map(|(segment, _)| segment)
348}
349
350pub fn parse_wind_segment_str_detailed(
357 s: &str,
358 imperial: bool,
359) -> Result<(WindSegment, bool), String> {
360 let parts: Vec<&str> = s.split(':').collect();
361 if parts.len() != 3 && parts.len() != 4 {
362 return Err(format!(
363 "invalid wind segment '{s}': expected SPEED:ANGLE:UNTIL_DISTANCE[:VERTICAL] \
364 (three or four colon-separated numbers; the optional 4th field VERTICAL is always \
365 m/s, positive = updraft, regardless of --units)"
366 ));
367 }
368 let num = |i: usize, name: &str| -> Result<f64, String> {
369 parts[i].trim().parse::<f64>().map_err(|_| {
370 format!(
371 "invalid wind segment '{s}': {name} '{}' is not a number",
372 parts[i]
373 )
374 })
375 };
376 let speed = num(0, "speed")?;
377 let (angle, angle_was_clock) = match parse_wind_direction(parts[1]) {
381 Ok(parsed) => (parsed.degrees, parsed.was_clock),
382 Err(WindDirectionParseError::Unrecognized(_)) => {
383 return Err(format!(
384 "invalid wind segment '{s}': angle '{}' is not a number or a clock \
385 position like 3oc or 10h30",
386 parts[1]
387 ))
388 }
389 Err(e) => return Err(format!("invalid wind segment '{s}': {e}")),
390 };
391 let until = num(2, "until-distance")?;
392 let vertical = if parts.len() == 4 {
393 num(3, "vertical")?
394 } else {
395 0.0
396 };
397 if !speed.is_finite() || !angle.is_finite() || !until.is_finite() || !vertical.is_finite() {
398 return Err(format!(
399 "invalid wind segment '{s}': speed, angle, until-distance, and vertical (m/s, \
400 positive = updraft) must be finite numbers"
401 ));
402 }
403 if speed < 0.0 {
404 return Err(format!("invalid wind segment '{s}': speed must be >= 0"));
405 }
406 if until <= 0.0 {
407 return Err(format!(
408 "invalid wind segment '{s}': until-distance must be > 0"
409 ));
410 }
411 let (speed_kmh, until_m) = if imperial {
412 (speed * 1.609344, until * 0.9144) } else {
414 (speed * 3.6, until) };
416 let mut segment = WindSegment::new(speed_kmh, angle, until_m);
417 segment.vertical_mps = vertical;
418 Ok((segment, angle_was_clock))
419}
420
421#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
428pub enum WindReference {
429 #[default]
430 Shooter,
431 Compass,
432}
433
434impl WindReference {
435 pub fn parse(token: &str) -> Result<Self, String> {
437 match token.trim().to_ascii_lowercase().as_str() {
438 "shooter" => Ok(Self::Shooter),
439 "compass" => Ok(Self::Compass),
440 other => Err(format!(
441 "invalid wind reference '{other}': expected 'shooter' or 'compass'"
442 )),
443 }
444 }
445}
446
447pub fn compass_bearing_to_shooter_relative_deg(bearing_deg: f64, shot_azimuth_deg: f64) -> f64 {
453 (bearing_deg - shot_azimuth_deg).rem_euclid(360.0)
454}
455
456pub fn compass_bearing_to_shooter_relative_rad(bearing_rad: f64, shot_azimuth_rad: f64) -> f64 {
459 (bearing_rad - shot_azimuth_rad).rem_euclid(2.0 * PI)
460}
461
462#[derive(Debug, Clone, Copy, PartialEq)]
468pub struct ParsedWindDirection {
469 pub degrees: f64,
470 pub was_clock: bool,
471}
472
473#[derive(Debug, Error, PartialEq, Eq)]
477pub enum WindDirectionParseError {
478 #[error(
479 "invalid wind direction '{0}': expected degrees (e.g. 90) or a clock position \
480 like 3oc or 10h30 (12oc = headwind)"
481 )]
482 Unrecognized(String),
483 #[error("invalid clock position '{0}': hour must be 1-12")]
484 HourOutOfRange(String),
485 #[error("invalid clock position '{0}': minutes must be 00-59")]
486 MinutesOutOfRange(String),
487}
488
489pub fn parse_wind_direction(token: &str) -> Result<ParsedWindDirection, WindDirectionParseError> {
498 let raw = token.trim();
499
500 if let Ok(degrees) = raw.parse::<f64>() {
503 return Ok(ParsedWindDirection {
504 degrees,
505 was_clock: false,
506 });
507 }
508
509 let lower = raw.to_ascii_lowercase();
510 let clock = |hour_str: &str,
511 minute_str: Option<&str>|
512 -> Result<ParsedWindDirection, WindDirectionParseError> {
513 let hour: u32 = hour_str
514 .parse()
515 .map_err(|_| WindDirectionParseError::Unrecognized(raw.to_string()))?;
516 if !(1..=12).contains(&hour) {
517 return Err(WindDirectionParseError::HourOutOfRange(raw.to_string()));
518 }
519 let minutes: u32 = match minute_str {
520 Some(m) if !m.is_empty() && m.len() <= 2 && m.bytes().all(|b| b.is_ascii_digit()) => m
521 .parse()
522 .map_err(|_| WindDirectionParseError::Unrecognized(raw.to_string()))?,
523 Some(_) => return Err(WindDirectionParseError::Unrecognized(raw.to_string())),
524 None => 0,
525 };
526 if minutes > 59 {
527 return Err(WindDirectionParseError::MinutesOutOfRange(raw.to_string()));
528 }
529 Ok(ParsedWindDirection {
530 degrees: f64::from(hour % 12) * 30.0 + f64::from(minutes) * 0.5,
531 was_clock: true,
532 })
533 };
534
535 if let Some(hour_str) = lower.strip_suffix("oc") {
536 if !hour_str.is_empty() && hour_str.bytes().all(|b| b.is_ascii_digit()) {
538 return clock(hour_str, None);
539 }
540 return Err(WindDirectionParseError::Unrecognized(raw.to_string()));
541 }
542 if let Some((hour_str, minute_str)) = lower.split_once('h') {
543 if !hour_str.is_empty() && hour_str.bytes().all(|b| b.is_ascii_digit()) {
544 return clock(hour_str, Some(minute_str));
545 }
546 }
547
548 Err(WindDirectionParseError::Unrecognized(raw.to_string()))
549}
550
551pub fn parse_wind_direction_standalone(
555 token: &str,
556) -> Result<ParsedWindDirection, WindDirectionParseError> {
557 let raw = token.trim();
558 if let Some((hour_str, minute_str)) = raw.split_once(':') {
559 if !hour_str.is_empty()
560 && hour_str.bytes().all(|b| b.is_ascii_digit())
561 && !minute_str.is_empty()
562 && minute_str.len() <= 2
563 && minute_str.bytes().all(|b| b.is_ascii_digit())
564 {
565 let with_h = format!("{hour_str}h{minute_str}");
566 return match parse_wind_direction(&with_h) {
567 Ok(parsed) => Ok(parsed),
568 Err(WindDirectionParseError::HourOutOfRange(_)) => {
570 Err(WindDirectionParseError::HourOutOfRange(raw.to_string()))
571 }
572 Err(WindDirectionParseError::MinutesOutOfRange(_)) => {
573 Err(WindDirectionParseError::MinutesOutOfRange(raw.to_string()))
574 }
575 Err(WindDirectionParseError::Unrecognized(_)) => {
576 Err(WindDirectionParseError::Unrecognized(raw.to_string()))
577 }
578 };
579 }
580 return Err(WindDirectionParseError::Unrecognized(raw.to_string()));
581 }
582 parse_wind_direction(raw)
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588
589 #[test]
590 fn segment_sort_is_stable_and_places_nan_endpoints_last() {
591 let mut segments = vec![
592 WindSegment::new(10.0, 0.0, f64::NAN),
593 WindSegment::new(20.0, 0.0, 100.0),
594 WindSegment::new(30.0, 0.0, 100.0),
595 WindSegment::new(40.0, 0.0, f64::INFINITY),
596 WindSegment::new(50.0, 0.0, f64::NEG_INFINITY),
597 WindSegment::new(60.0, 0.0, f64::NAN),
598 ];
599
600 sort_wind_segments_by_distance(&mut segments);
601
602 assert_eq!(segments[0].speed_kmh, 50.0); assert_eq!(segments[1].speed_kmh, 20.0); assert_eq!(segments[2].speed_kmh, 30.0);
605 assert_eq!(segments[3].speed_kmh, 40.0); assert_eq!(segments[4].speed_kmh, 10.0); assert_eq!(segments[5].speed_kmh, 60.0);
608 assert!(segments[4].until_m.is_nan() && segments[5].until_m.is_nan());
609 }
610
611 #[test]
612 fn test_wind_sock_empty() {
613 let sock = WindSock::new(vec![]);
614 assert_eq!(sock.vector_for_range_stateless(50.0), Vector3::zeros());
615 assert_eq!(sock.muzzle_crosswind_from_right_mps(), None);
616 }
617
618 #[test]
619 fn try_new_accepts_valid_and_empty_segments() {
620 assert!(WindSock::try_new(vec![]).is_ok());
621 let sock = WindSock::try_new(vec![WindSegment::new(16.0934, 90.0, 100.0)]).unwrap();
622 assert!(sock.vector_for_range_stateless(50.0).norm() > 0.0);
623 assert!(WindSock::try_from(vec![WindSegment::new(10.0, 0.0, 50.0)]).is_ok());
625 assert!(WindSock::try_from(vec![WindSegment::new(f64::NAN, 0.0, 50.0)]).is_err());
626 }
627
628 #[test]
629 fn try_new_rejects_each_field_with_typed_index_field_rule() {
630 let ok = WindSegment::new(10.0, 0.0, 100.0);
633 let cases: Vec<(WindSegment, WindSegmentField, WindSegmentRule)> = vec![
634 (
635 WindSegment::new(f64::NAN, 0.0, 100.0),
636 WindSegmentField::SpeedKmh,
637 WindSegmentRule::FiniteAndNonNegative,
638 ),
639 (
640 WindSegment::new(-1.0, 0.0, 100.0),
641 WindSegmentField::SpeedKmh,
642 WindSegmentRule::FiniteAndNonNegative,
643 ),
644 (
645 WindSegment::new(10.0, f64::INFINITY, 100.0),
646 WindSegmentField::AngleDeg,
647 WindSegmentRule::Finite,
648 ),
649 (
650 WindSegment::new(10.0, 0.0, 0.0),
651 WindSegmentField::UntilM,
652 WindSegmentRule::FiniteAndPositive,
653 ),
654 (
655 WindSegment::new(10.0, 0.0, f64::NEG_INFINITY),
656 WindSegmentField::UntilM,
657 WindSegmentRule::FiniteAndPositive,
658 ),
659 (
660 WindSegment {
661 vertical_mps: f64::NAN,
662 ..ok
663 },
664 WindSegmentField::VerticalMps,
665 WindSegmentRule::Finite,
666 ),
667 ];
668 for (bad, field, rule) in cases {
669 let err = WindSock::try_new(vec![ok, bad]).unwrap_err();
672 assert_eq!(err.index, 1, "index for {field}");
673 assert_eq!(err.field, field);
674 assert_eq!(err.rule, rule);
675 }
676 }
677
678 #[test]
679 fn try_new_error_index_is_the_callers_presort_index() {
680 let err = WindSock::try_new(vec![
683 WindSegment::new(10.0, 0.0, 500.0),
684 WindSegment::new(20.0, 0.0, 300.0),
685 WindSegment::new(f64::NAN, 0.0, 1.0),
686 ])
687 .unwrap_err();
688 assert_eq!(err.index, 2);
689 assert_eq!(err.field, WindSegmentField::SpeedKmh);
690 }
691
692 #[test]
693 fn wind_segment_error_display_matches_legacy_strings() {
694 let err = WindSock::try_new(vec![WindSegment::new(f64::NAN, 0.0, 100.0)]).unwrap_err();
697 assert_eq!(
698 err.to_string(),
699 "wind.segments[0].speed_kmh must be finite and non-negative"
700 );
701 let err = WindSock::try_new(vec![WindSegment::new(10.0, 0.0, -5.0)]).unwrap_err();
702 assert_eq!(
703 err.to_string(),
704 "wind.segments[0].until_m must be finite and greater than zero"
705 );
706 let err = WindSock::try_new(vec![WindSegment {
707 vertical_mps: f64::INFINITY,
708 ..WindSegment::new(10.0, 0.0, 100.0)
709 }])
710 .unwrap_err();
711 assert_eq!(
712 err.to_string(),
713 "wind.segments[0].vertical_mps must be finite"
714 );
715 }
716
717 #[test]
718 fn infallible_new_still_defers_the_same_error() {
719 let sock = WindSock::new(vec![WindSegment::new(10.0, f64::NAN, 100.0)]);
722 assert_eq!(
723 sock.validate_segments().unwrap_err(),
724 "wind.segments[0].angle_deg must be finite"
725 );
726 }
727
728 #[test]
729 fn muzzle_crosswind_distinguishes_an_explicit_zero_segment() {
730 let sock = WindSock::new(vec![WindSegment::new(0.0, 90.0, 100.0)]);
731 assert_eq!(sock.muzzle_crosswind_from_right_mps(), Some(0.0));
732 }
733
734 #[test]
735 fn muzzle_crosswind_uses_the_sorted_muzzle_segment_and_wind_from_sign() {
736 let from_right = WindSock::new(vec![
737 WindSegment::new(32.18688, 270.0, 5000.0),
738 WindSegment::new(16.09344, 90.0, 100.0),
739 ]);
740 let right_mps = from_right.muzzle_crosswind_from_right_mps().unwrap();
741 assert!((right_mps - 4.4704).abs() < 1e-12);
742
743 let from_left = WindSock::new(vec![WindSegment::new(16.09344, 270.0, 100.0)]);
744 let left_mps = from_left.muzzle_crosswind_from_right_mps().unwrap();
745 assert!((left_mps + 4.4704).abs() < 1e-12);
746 }
747
748 #[test]
749 fn test_wind_sock_single_segment() {
750 let sock = WindSock::new(vec![WindSegment::new(16.0934, 90.0, 100.0)]);
752
753 let vec_50 = sock.vector_for_range_stateless(50.0);
755 println!("vec_50 = [{}, {}, {}]", vec_50[0], vec_50[1], vec_50[2]);
756 assert!(vec_50.norm() > 0.0);
757 assert!(
759 vec_50[2] < 0.0,
760 "Z (lateral) should be negative for 90° wind, got {}",
761 vec_50[2]
762 );
763 assert_eq!(vec_50[1], 0.0); assert!(
765 vec_50[0].abs() < 0.01,
766 "X (downrange) should be nearly zero for 90° wind, got {}",
767 vec_50[0]
768 );
769
770 let vec_150 = sock.vector_for_range_stateless(150.0);
772 assert_eq!(vec_150, Vector3::zeros());
773 }
774
775 #[test]
776 fn test_wind_sock_multiple_segments() {
777 let sock = WindSock::new(vec![
779 WindSegment::new(16.0934, 90.0, 50.0), WindSegment::new(24.1401, 45.0, 100.0), WindSegment::new(8.0467, 180.0, 200.0), ]);
783
784 let vec_25 = sock.vector_for_range_stateless(25.0);
786 println!("vec_25 = [{}, {}, {}]", vec_25[0], vec_25[1], vec_25[2]);
787 assert!(vec_25.norm() > 0.0);
788 assert!(vec_25[2] < 0.0, "90° wind should have negative Z (lateral)"); let vec_75 = sock.vector_for_range_stateless(75.0);
791 println!("vec_75 = [{}, {}, {}]", vec_75[0], vec_75[1], vec_75[2]);
792 assert!(vec_75.norm() > vec_25.norm()); assert!(vec_75[0] < 0.0); assert!(vec_75[2] < 0.0); let vec_150 = sock.vector_for_range_stateless(150.0);
797 println!("vec_150 = [{}, {}, {}]", vec_150[0], vec_150[1], vec_150[2]);
798 assert!(vec_150.norm() < vec_75.norm()); assert!(
800 vec_150[2].abs() < 0.01,
801 "180° wind should have near-zero Z (lateral), got {}",
802 vec_150[2]
803 ); assert!(
805 vec_150[0] > 0.0,
806 "180° wind should have positive X (tailwind, downrange), got {}",
807 vec_150[0]
808 ); let vec_250 = sock.vector_for_range_stateless(250.0);
811 assert_eq!(vec_250, Vector3::zeros()); }
813
814 #[test]
815 fn test_wind_conversion() {
816 let sock = WindSock::new(vec![WindSegment::new(16.0934, 0.0, 100.0)]);
818 let vec = sock.vector_for_range_stateless(50.0);
819
820 let expected_speed = 16.0934 * KMH_TO_MPS;
821 assert!((vec.norm() - expected_speed).abs() < 0.01);
822 }
823
824 #[test]
825 fn test_wind_sock_boundary_is_upper_exclusive() {
826 let sock = WindSock::new(vec![
829 WindSegment::new(16.0934, 90.0, 100.0),
830 WindSegment::new(32.1868, 270.0, 200.0),
831 ]);
832 assert!(sock.vector_for_range_stateless(99.999)[2] < 0.0);
834 assert!(sock.vector_for_range_stateless(100.0)[2] > 0.0);
836 assert_eq!(sock.vector_for_range_stateless(200.0), Vector3::zeros());
838 }
839
840 #[test]
841 fn calc_vec_passes_through_segment_vertical_unscaled() {
842 let seg = WindSegment {
845 speed_kmh: 16.0934,
846 angle_deg: 90.0,
847 until_m: 100.0,
848 vertical_mps: 3.0,
849 };
850 let vec = WindSock::calc_vec(&seg);
851 assert_eq!(vec[1], 3.0);
852 }
853
854 #[test]
855 fn calc_vec_zero_vertical_segment_keeps_zero_y() {
856 let seg = WindSegment::new(16.0934, 90.0, 100.0);
859 assert_eq!(seg.vertical_mps, 0.0);
860 let vec = WindSock::calc_vec(&seg);
861 assert_eq!(vec[1], 0.0);
862 }
863
864 #[test]
865 fn test_parse_wind_segment_str_units() {
866 let seg = parse_wind_segment_str("10:90:100", true).unwrap();
868 assert!((seg.speed_kmh - 16.09344).abs() < 1e-4);
869 assert_eq!(seg.angle_deg, 90.0);
870 assert!((seg.until_m - 91.44).abs() < 1e-4);
871
872 let seg = parse_wind_segment_str("5:270:200", false).unwrap();
874 assert!((seg.speed_kmh - 18.0).abs() < 1e-9);
875 assert_eq!(seg.angle_deg, 270.0);
876 assert!((seg.until_m - 200.0).abs() < 1e-9);
877
878 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());
885 assert!(parse_wind_segment_str("10:90:nan", true).is_err());
886 assert!(parse_wind_segment_str("inf:90:100", true).is_err());
887 }
888
889 #[test]
890 fn test_parse_wind_segment_str_vertical_field() {
891 let seg = parse_wind_segment_str("10:90:100", true).unwrap();
893 assert_eq!(seg.vertical_mps, 0.0);
894 let seg = parse_wind_segment_str("5:270:200", false).unwrap();
895 assert_eq!(seg.vertical_mps, 0.0);
896
897 let seg = parse_wind_segment_str("10:90:100:5", true).unwrap();
899 assert_eq!(seg.vertical_mps, 5.0);
900 assert!((seg.speed_kmh - 16.09344).abs() < 1e-4);
902 assert!((seg.until_m - 91.44).abs() < 1e-4);
903
904 let seg_metric = parse_wind_segment_str("5:270:200:5", false).unwrap();
907 assert_eq!(seg_metric.vertical_mps, 5.0);
908
909 let seg_neg = parse_wind_segment_str("10:90:100:-3.5", true).unwrap();
911 assert_eq!(seg_neg.vertical_mps, -3.5);
912
913 assert!(parse_wind_segment_str("10:90:100:bad", true).is_err());
915 assert!(parse_wind_segment_str("10:90:100:nan", true).is_err());
916 assert!(parse_wind_segment_str("10:90:100:inf", true).is_err());
917
918 assert!(parse_wind_segment_str("10:90:100:5:1", true).is_err());
920 }
921
922 #[test]
926 fn clock_positions_map_to_wind_from_degrees() {
927 let deg = |t: &str| parse_wind_direction(t).unwrap();
928 assert_eq!(
929 deg("3oc"),
930 ParsedWindDirection {
931 degrees: 90.0,
932 was_clock: true
933 }
934 );
935 assert_eq!(deg("6oc").degrees, 180.0);
936 assert_eq!(deg("9oc").degrees, 270.0);
937 assert_eq!(deg("12oc").degrees, 0.0); assert_eq!(deg("10h30").degrees, 315.0);
939 assert_eq!(deg("1h00").degrees, 30.0);
940 assert_eq!(deg("12h30").degrees, 15.0);
941 assert_eq!(deg("7h05").degrees, 212.5);
942 assert_eq!(deg("3OC").degrees, 90.0);
944 assert_eq!(deg("10H30").degrees, 315.0);
945
946 assert_eq!(
948 deg("90"),
949 ParsedWindDirection {
950 degrees: 90.0,
951 was_clock: false
952 }
953 );
954 assert_eq!(deg("-45").degrees, -45.0);
955 assert_eq!(deg("370.5").degrees, 370.5);
956
957 let sa = parse_wind_direction_standalone("10:30").unwrap();
959 assert_eq!(
960 sa,
961 ParsedWindDirection {
962 degrees: 315.0,
963 was_clock: true
964 }
965 );
966 assert_eq!(
967 parse_wind_direction_standalone("3oc").unwrap().degrees,
968 90.0
969 );
970 assert!(!parse_wind_direction_standalone("90").unwrap().was_clock);
971 assert!(matches!(
973 parse_wind_direction("10:30"),
974 Err(WindDirectionParseError::Unrecognized(_))
975 ));
976 }
977
978 #[test]
979 fn clock_positions_reject_bad_hours_and_minutes_with_helpful_messages() {
980 assert!(matches!(
981 parse_wind_direction("13oc"),
982 Err(WindDirectionParseError::HourOutOfRange(_))
983 ));
984 assert!(matches!(
985 parse_wind_direction("0oc"),
986 Err(WindDirectionParseError::HourOutOfRange(_))
987 ));
988 assert!(matches!(
989 parse_wind_direction("3h60"),
990 Err(WindDirectionParseError::MinutesOutOfRange(_))
991 ));
992 assert!(matches!(
993 parse_wind_direction_standalone("13:00"),
994 Err(WindDirectionParseError::HourOutOfRange(_))
995 ));
996 assert!(matches!(
997 parse_wind_direction_standalone("3:60"),
998 Err(WindDirectionParseError::MinutesOutOfRange(_))
999 ));
1000 for bad in [
1002 "oc", "hoc", "3.5oc", "3h", "3h123", "10h3x", "3:", ":30", "10:301", "x",
1003 ] {
1004 let err = parse_wind_direction_standalone(bad).unwrap_err();
1005 assert!(
1006 matches!(err, WindDirectionParseError::Unrecognized(_)),
1007 "{bad}: {err}"
1008 );
1009 }
1010 let msg = parse_wind_direction("bogus").unwrap_err().to_string();
1011 assert!(msg.contains("3oc"), "{msg}");
1012 assert!(msg.contains("degrees"), "{msg}");
1013 let msg = parse_wind_direction("13oc").unwrap_err().to_string();
1014 assert!(msg.contains("hour must be 1-12"), "{msg}");
1015 }
1016
1017 #[test]
1020 fn segment_angle_accepts_colon_free_clock_forms_only() {
1021 let clock = parse_wind_segment_str("10:3oc:400", true).unwrap();
1022 let plain = parse_wind_segment_str("10:90:400", true).unwrap();
1023 assert_eq!(clock, plain);
1024
1025 let (seg, was_clock) = parse_wind_segment_str_detailed("10:10h30:400", true).unwrap();
1026 assert_eq!(seg.angle_deg, 315.0);
1027 assert!(was_clock);
1028 let (_, was_clock) = parse_wind_segment_str_detailed("10:90:400", true).unwrap();
1029 assert!(!was_clock);
1030
1031 let legacy = parse_wind_segment_str("10:30:400", true).unwrap();
1033 assert_eq!(legacy.angle_deg, 30.0);
1034 assert!((legacy.speed_kmh - 10.0 * 1.609344).abs() < 1e-12);
1035 assert!((legacy.until_m - 400.0 * 0.9144).abs() < 1e-9);
1036
1037 let err = parse_wind_segment_str("10:13oc:400", true).unwrap_err();
1040 assert!(err.contains("hour must be 1-12"), "{err}");
1041 let err = parse_wind_segment_str("10:abc:400", true).unwrap_err();
1042 assert!(
1043 err.contains("angle 'abc' is not a number or a clock position"),
1044 "{err}"
1045 );
1046 let (seg, was_clock) = parse_wind_segment_str_detailed("10:9oc:400:1.5", true).unwrap();
1048 assert_eq!(seg.angle_deg, 270.0);
1049 assert_eq!(seg.vertical_mps, 1.5);
1050 assert!(was_clock);
1051 }
1052}