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) => {
58 a.until_m
59 .partial_cmp(&b.until_m)
60 .expect("non-NaN distances are ordered")
61 }
62 });
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum WindSegmentField {
68 SpeedKmh,
69 AngleDeg,
70 UntilM,
71 VerticalMps,
72}
73
74impl WindSegmentField {
75 pub fn name(self) -> &'static str {
77 match self {
78 WindSegmentField::SpeedKmh => "speed_kmh",
79 WindSegmentField::AngleDeg => "angle_deg",
80 WindSegmentField::UntilM => "until_m",
81 WindSegmentField::VerticalMps => "vertical_mps",
82 }
83 }
84}
85
86impl std::fmt::Display for WindSegmentField {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 f.write_str(self.name())
89 }
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum WindSegmentRule {
95 Finite,
97 FiniteAndNonNegative,
99 FiniteAndPositive,
101}
102
103impl std::fmt::Display for WindSegmentRule {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 f.write_str(match self {
108 WindSegmentRule::Finite => "finite",
109 WindSegmentRule::FiniteAndNonNegative => "finite and non-negative",
110 WindSegmentRule::FiniteAndPositive => "finite and greater than zero",
111 })
112 }
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
121#[error("wind.segments[{index}].{field} must be {rule}")]
122pub struct WindSegmentError {
123 pub index: usize,
124 pub field: WindSegmentField,
125 pub rule: WindSegmentRule,
126}
127
128pub fn validate_wind_segments(segments: &[WindSegment]) -> Result<(), WindSegmentError> {
135 for (index, segment) in segments.iter().enumerate() {
136 let err = |field, rule| Err(WindSegmentError { index, field, rule });
137
138 if !segment.speed_kmh.is_finite() || segment.speed_kmh < 0.0 {
139 return err(
140 WindSegmentField::SpeedKmh,
141 WindSegmentRule::FiniteAndNonNegative,
142 );
143 }
144 if !segment.angle_deg.is_finite() {
145 return err(WindSegmentField::AngleDeg, WindSegmentRule::Finite);
146 }
147 if !segment.until_m.is_finite() || segment.until_m <= 0.0 {
148 return err(WindSegmentField::UntilM, WindSegmentRule::FiniteAndPositive);
149 }
150 if !segment.vertical_mps.is_finite() {
151 return err(WindSegmentField::VerticalMps, WindSegmentRule::Finite);
152 }
153 }
154
155 Ok(())
156}
157
158#[derive(Debug, Clone)]
160pub struct WindSock {
161 winds: Vec<WindSegment>,
163 wind_vecs: Vec<Vector3<f64>>,
166 current: usize,
168 next_range: f64,
170 current_vec: Vector3<f64>,
172 validation_error: Option<WindSegmentError>,
174}
175
176impl WindSock {
177 pub fn new(segments: Vec<WindSegment>) -> Self {
188 let validation_error = validate_wind_segments(&segments).err();
191 Self::build_unchecked(segments, validation_error)
192 }
193
194 pub fn try_new(segments: Vec<WindSegment>) -> Result<Self, WindSegmentError> {
201 validate_wind_segments(&segments)?;
202 Ok(Self::build_unchecked(segments, None))
203 }
204
205 fn build_unchecked(
207 mut segments: Vec<WindSegment>,
208 validation_error: Option<WindSegmentError>,
209 ) -> Self {
210 sort_wind_segments_by_distance(&mut segments);
212
213 let wind_vecs: Vec<Vector3<f64>> = segments.iter().map(Self::calc_vec).collect();
215
216 let (current, next_range, current_vec) = if segments.is_empty() {
217 (0, f64::INFINITY, Vector3::zeros())
218 } else {
219 (0, segments[0].until_m, wind_vecs[0])
220 };
221
222 WindSock {
223 winds: segments,
224 wind_vecs,
225 current,
226 next_range,
227 current_vec,
228 validation_error,
229 }
230 }
231
232 pub(crate) fn validate_segments(&self) -> Result<(), String> {
234 self.validation_error
235 .map_or(Ok(()), |err| Err(err.to_string()))
236 }
237
238 fn calc_vec(seg: &WindSegment) -> Vector3<f64> {
240 let speed_mps = seg.speed_kmh * KMH_TO_MPS;
242 let angle_rad = if seg.angle_deg.abs() <= f64::MAX / PI {
245 seg.angle_deg * PI / 180.0
246 } else {
247 seg.angle_deg / 180.0 * PI
248 };
249
250 wind_vector(speed_mps, angle_rad, seg.vertical_mps)
259 }
260
261 pub fn max_speed_mps(&self) -> f64 {
264 self.winds
265 .iter()
266 .map(|seg| seg.speed_kmh.abs() * KMH_TO_MPS + seg.vertical_mps.abs())
267 .fold(0.0, f64::max)
268 }
269
270 pub(crate) fn muzzle_crosswind_from_right_mps(&self) -> Option<f64> {
274 (!self.winds.is_empty()).then(|| -self.vector_for_range_stateless(0.0)[2])
275 }
276
277 pub fn vector_for_range(&mut self, range_m: f64) -> Vector3<f64> {
282 if range_m.is_nan() {
284 return Vector3::zeros();
285 }
286
287 while range_m >= self.next_range && self.current < self.winds.len() {
290 self.current += 1;
291 if self.current >= self.winds.len() {
292 self.current_vec = Vector3::zeros();
293 self.next_range = f64::INFINITY;
294 } else {
295 self.current_vec = self.wind_vecs[self.current];
296 self.next_range = self.winds[self.current].until_m;
297 }
298 }
299
300 self.current_vec
301 }
302
303 pub fn vector_for_range_stateless(&self, range_m: f64) -> Vector3<f64> {
308 if range_m.is_nan() {
310 return Vector3::zeros();
311 }
312
313 for (i, segment) in self.winds.iter().enumerate() {
315 if range_m < segment.until_m {
316 return self.wind_vecs[i];
317 }
318 }
319
320 Vector3::zeros()
322 }
323}
324
325impl TryFrom<Vec<WindSegment>> for WindSock {
327 type Error = WindSegmentError;
328
329 fn try_from(segments: Vec<WindSegment>) -> Result<Self, Self::Error> {
330 WindSock::try_new(segments)
331 }
332}
333
334pub fn parse_wind_segment_str(s: &str, imperial: bool) -> Result<WindSegment, String> {
348 parse_wind_segment_str_detailed(s, imperial).map(|(segment, _)| segment)
349}
350
351pub fn parse_wind_segment_str_detailed(
358 s: &str,
359 imperial: bool,
360) -> Result<(WindSegment, bool), String> {
361 let parts: Vec<&str> = s.split(':').collect();
362 if parts.len() != 3 && parts.len() != 4 {
363 return Err(format!(
364 "invalid wind segment '{s}': expected SPEED:ANGLE:UNTIL_DISTANCE[:VERTICAL] \
365 (three or four colon-separated numbers; the optional 4th field VERTICAL is always \
366 m/s, positive = updraft, regardless of --units)"
367 ));
368 }
369 let num = |i: usize, name: &str| -> Result<f64, String> {
370 parts[i].trim().parse::<f64>().map_err(|_| {
371 format!("invalid wind segment '{s}': {name} '{}' is not a number", parts[i])
372 })
373 };
374 let speed = num(0, "speed")?;
375 let (angle, angle_was_clock) = match parse_wind_direction(parts[1]) {
379 Ok(parsed) => (parsed.degrees, parsed.was_clock),
380 Err(WindDirectionParseError::Unrecognized(_)) => {
381 return Err(format!(
382 "invalid wind segment '{s}': angle '{}' is not a number or a clock \
383 position like 3oc or 10h30",
384 parts[1]
385 ))
386 }
387 Err(e) => return Err(format!("invalid wind segment '{s}': {e}")),
388 };
389 let until = num(2, "until-distance")?;
390 let vertical = if parts.len() == 4 {
391 num(3, "vertical")?
392 } else {
393 0.0
394 };
395 if !speed.is_finite() || !angle.is_finite() || !until.is_finite() || !vertical.is_finite() {
396 return Err(format!(
397 "invalid wind segment '{s}': speed, angle, until-distance, and vertical (m/s, \
398 positive = updraft) must be finite numbers"
399 ));
400 }
401 if speed < 0.0 {
402 return Err(format!("invalid wind segment '{s}': speed must be >= 0"));
403 }
404 if until <= 0.0 {
405 return Err(format!("invalid wind segment '{s}': until-distance must be > 0"));
406 }
407 let (speed_kmh, until_m) = if imperial {
408 (speed * 1.609344, until * 0.9144) } else {
410 (speed * 3.6, until) };
412 let mut segment = WindSegment::new(speed_kmh, angle, until_m);
413 segment.vertical_mps = vertical;
414 Ok((segment, angle_was_clock))
415}
416
417#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
424pub enum WindReference {
425 #[default]
426 Shooter,
427 Compass,
428}
429
430impl WindReference {
431 pub fn parse(token: &str) -> Result<Self, String> {
433 match token.trim().to_ascii_lowercase().as_str() {
434 "shooter" => Ok(Self::Shooter),
435 "compass" => Ok(Self::Compass),
436 other => Err(format!(
437 "invalid wind reference '{other}': expected 'shooter' or 'compass'"
438 )),
439 }
440 }
441}
442
443pub fn compass_bearing_to_shooter_relative_deg(bearing_deg: f64, shot_azimuth_deg: f64) -> f64 {
449 (bearing_deg - shot_azimuth_deg).rem_euclid(360.0)
450}
451
452pub fn compass_bearing_to_shooter_relative_rad(bearing_rad: f64, shot_azimuth_rad: f64) -> f64 {
455 (bearing_rad - shot_azimuth_rad).rem_euclid(2.0 * PI)
456}
457
458#[derive(Debug, Clone, Copy, PartialEq)]
464pub struct ParsedWindDirection {
465 pub degrees: f64,
466 pub was_clock: bool,
467}
468
469#[derive(Debug, Error, PartialEq, Eq)]
473pub enum WindDirectionParseError {
474 #[error(
475 "invalid wind direction '{0}': expected degrees (e.g. 90) or a clock position \
476 like 3oc or 10h30 (12oc = headwind)"
477 )]
478 Unrecognized(String),
479 #[error("invalid clock position '{0}': hour must be 1-12")]
480 HourOutOfRange(String),
481 #[error("invalid clock position '{0}': minutes must be 00-59")]
482 MinutesOutOfRange(String),
483}
484
485pub fn parse_wind_direction(token: &str) -> Result<ParsedWindDirection, WindDirectionParseError> {
494 let raw = token.trim();
495
496 if let Ok(degrees) = raw.parse::<f64>() {
499 return Ok(ParsedWindDirection {
500 degrees,
501 was_clock: false,
502 });
503 }
504
505 let lower = raw.to_ascii_lowercase();
506 let clock = |hour_str: &str, minute_str: Option<&str>| -> Result<ParsedWindDirection, WindDirectionParseError> {
507 let hour: u32 = hour_str
508 .parse()
509 .map_err(|_| WindDirectionParseError::Unrecognized(raw.to_string()))?;
510 if !(1..=12).contains(&hour) {
511 return Err(WindDirectionParseError::HourOutOfRange(raw.to_string()));
512 }
513 let minutes: u32 = match minute_str {
514 Some(m) if !m.is_empty() && m.len() <= 2 && m.bytes().all(|b| b.is_ascii_digit()) => {
515 m.parse()
516 .map_err(|_| WindDirectionParseError::Unrecognized(raw.to_string()))?
517 }
518 Some(_) => return Err(WindDirectionParseError::Unrecognized(raw.to_string())),
519 None => 0,
520 };
521 if minutes > 59 {
522 return Err(WindDirectionParseError::MinutesOutOfRange(raw.to_string()));
523 }
524 Ok(ParsedWindDirection {
525 degrees: f64::from(hour % 12) * 30.0 + f64::from(minutes) * 0.5,
526 was_clock: true,
527 })
528 };
529
530 if let Some(hour_str) = lower.strip_suffix("oc") {
531 if !hour_str.is_empty() && hour_str.bytes().all(|b| b.is_ascii_digit()) {
533 return clock(hour_str, None);
534 }
535 return Err(WindDirectionParseError::Unrecognized(raw.to_string()));
536 }
537 if let Some((hour_str, minute_str)) = lower.split_once('h') {
538 if !hour_str.is_empty() && hour_str.bytes().all(|b| b.is_ascii_digit()) {
539 return clock(hour_str, Some(minute_str));
540 }
541 }
542
543 Err(WindDirectionParseError::Unrecognized(raw.to_string()))
544}
545
546pub fn parse_wind_direction_standalone(
550 token: &str,
551) -> Result<ParsedWindDirection, WindDirectionParseError> {
552 let raw = token.trim();
553 if let Some((hour_str, minute_str)) = raw.split_once(':') {
554 if !hour_str.is_empty()
555 && hour_str.bytes().all(|b| b.is_ascii_digit())
556 && !minute_str.is_empty()
557 && minute_str.len() <= 2
558 && minute_str.bytes().all(|b| b.is_ascii_digit())
559 {
560 let with_h = format!("{hour_str}h{minute_str}");
561 return match parse_wind_direction(&with_h) {
562 Ok(parsed) => Ok(parsed),
563 Err(WindDirectionParseError::HourOutOfRange(_)) => {
565 Err(WindDirectionParseError::HourOutOfRange(raw.to_string()))
566 }
567 Err(WindDirectionParseError::MinutesOutOfRange(_)) => {
568 Err(WindDirectionParseError::MinutesOutOfRange(raw.to_string()))
569 }
570 Err(WindDirectionParseError::Unrecognized(_)) => {
571 Err(WindDirectionParseError::Unrecognized(raw.to_string()))
572 }
573 };
574 }
575 return Err(WindDirectionParseError::Unrecognized(raw.to_string()));
576 }
577 parse_wind_direction(raw)
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583
584 #[test]
585 fn segment_sort_is_stable_and_places_nan_endpoints_last() {
586 let mut segments = vec![
587 WindSegment::new(10.0, 0.0, f64::NAN),
588 WindSegment::new(20.0, 0.0, 100.0),
589 WindSegment::new(30.0, 0.0, 100.0),
590 WindSegment::new(40.0, 0.0, f64::INFINITY),
591 WindSegment::new(50.0, 0.0, f64::NEG_INFINITY),
592 WindSegment::new(60.0, 0.0, f64::NAN),
593 ];
594
595 sort_wind_segments_by_distance(&mut segments);
596
597 assert_eq!(segments[0].speed_kmh, 50.0); assert_eq!(segments[1].speed_kmh, 20.0); assert_eq!(segments[2].speed_kmh, 30.0);
600 assert_eq!(segments[3].speed_kmh, 40.0); assert_eq!(segments[4].speed_kmh, 10.0); assert_eq!(segments[5].speed_kmh, 60.0);
603 assert!(segments[4].until_m.is_nan() && segments[5].until_m.is_nan());
604 }
605
606 #[test]
607 fn test_wind_sock_empty() {
608 let sock = WindSock::new(vec![]);
609 assert_eq!(sock.vector_for_range_stateless(50.0), Vector3::zeros());
610 assert_eq!(sock.muzzle_crosswind_from_right_mps(), None);
611 }
612
613 #[test]
614 fn try_new_accepts_valid_and_empty_segments() {
615 assert!(WindSock::try_new(vec![]).is_ok());
616 let sock = WindSock::try_new(vec![WindSegment::new(16.0934, 90.0, 100.0)]).unwrap();
617 assert!(sock.vector_for_range_stateless(50.0).norm() > 0.0);
618 assert!(WindSock::try_from(vec![WindSegment::new(10.0, 0.0, 50.0)]).is_ok());
620 assert!(WindSock::try_from(vec![WindSegment::new(f64::NAN, 0.0, 50.0)]).is_err());
621 }
622
623 #[test]
624 fn try_new_rejects_each_field_with_typed_index_field_rule() {
625 let ok = WindSegment::new(10.0, 0.0, 100.0);
628 let cases: Vec<(WindSegment, WindSegmentField, WindSegmentRule)> = vec![
629 (
630 WindSegment::new(f64::NAN, 0.0, 100.0),
631 WindSegmentField::SpeedKmh,
632 WindSegmentRule::FiniteAndNonNegative,
633 ),
634 (
635 WindSegment::new(-1.0, 0.0, 100.0),
636 WindSegmentField::SpeedKmh,
637 WindSegmentRule::FiniteAndNonNegative,
638 ),
639 (
640 WindSegment::new(10.0, f64::INFINITY, 100.0),
641 WindSegmentField::AngleDeg,
642 WindSegmentRule::Finite,
643 ),
644 (
645 WindSegment::new(10.0, 0.0, 0.0),
646 WindSegmentField::UntilM,
647 WindSegmentRule::FiniteAndPositive,
648 ),
649 (
650 WindSegment::new(10.0, 0.0, f64::NEG_INFINITY),
651 WindSegmentField::UntilM,
652 WindSegmentRule::FiniteAndPositive,
653 ),
654 (
655 WindSegment {
656 vertical_mps: f64::NAN,
657 ..ok
658 },
659 WindSegmentField::VerticalMps,
660 WindSegmentRule::Finite,
661 ),
662 ];
663 for (bad, field, rule) in cases {
664 let err = WindSock::try_new(vec![ok, bad]).unwrap_err();
667 assert_eq!(err.index, 1, "index for {field}");
668 assert_eq!(err.field, field);
669 assert_eq!(err.rule, rule);
670 }
671 }
672
673 #[test]
674 fn try_new_error_index_is_the_callers_presort_index() {
675 let err = WindSock::try_new(vec![
678 WindSegment::new(10.0, 0.0, 500.0),
679 WindSegment::new(20.0, 0.0, 300.0),
680 WindSegment::new(f64::NAN, 0.0, 1.0),
681 ])
682 .unwrap_err();
683 assert_eq!(err.index, 2);
684 assert_eq!(err.field, WindSegmentField::SpeedKmh);
685 }
686
687 #[test]
688 fn wind_segment_error_display_matches_legacy_strings() {
689 let err = WindSock::try_new(vec![WindSegment::new(f64::NAN, 0.0, 100.0)]).unwrap_err();
692 assert_eq!(
693 err.to_string(),
694 "wind.segments[0].speed_kmh must be finite and non-negative"
695 );
696 let err = WindSock::try_new(vec![WindSegment::new(10.0, 0.0, -5.0)]).unwrap_err();
697 assert_eq!(
698 err.to_string(),
699 "wind.segments[0].until_m must be finite and greater than zero"
700 );
701 let err = WindSock::try_new(vec![WindSegment {
702 vertical_mps: f64::INFINITY,
703 ..WindSegment::new(10.0, 0.0, 100.0)
704 }])
705 .unwrap_err();
706 assert_eq!(
707 err.to_string(),
708 "wind.segments[0].vertical_mps must be finite"
709 );
710 }
711
712 #[test]
713 fn infallible_new_still_defers_the_same_error() {
714 let sock = WindSock::new(vec![WindSegment::new(10.0, f64::NAN, 100.0)]);
717 assert_eq!(
718 sock.validate_segments().unwrap_err(),
719 "wind.segments[0].angle_deg must be finite"
720 );
721 }
722
723 #[test]
724 fn muzzle_crosswind_distinguishes_an_explicit_zero_segment() {
725 let sock = WindSock::new(vec![WindSegment::new(0.0, 90.0, 100.0)]);
726 assert_eq!(sock.muzzle_crosswind_from_right_mps(), Some(0.0));
727 }
728
729 #[test]
730 fn muzzle_crosswind_uses_the_sorted_muzzle_segment_and_wind_from_sign() {
731 let from_right = WindSock::new(vec![
732 WindSegment::new(32.18688, 270.0, 5000.0),
733 WindSegment::new(16.09344, 90.0, 100.0),
734 ]);
735 let right_mps = from_right.muzzle_crosswind_from_right_mps().unwrap();
736 assert!((right_mps - 4.4704).abs() < 1e-12);
737
738 let from_left = WindSock::new(vec![WindSegment::new(16.09344, 270.0, 100.0)]);
739 let left_mps = from_left.muzzle_crosswind_from_right_mps().unwrap();
740 assert!((left_mps + 4.4704).abs() < 1e-12);
741 }
742
743 #[test]
744 fn test_wind_sock_single_segment() {
745 let sock = WindSock::new(vec![WindSegment::new(16.0934, 90.0, 100.0)]);
747
748 let vec_50 = sock.vector_for_range_stateless(50.0);
750 println!("vec_50 = [{}, {}, {}]", vec_50[0], vec_50[1], vec_50[2]);
751 assert!(vec_50.norm() > 0.0);
752 assert!(
754 vec_50[2] < 0.0,
755 "Z (lateral) should be negative for 90° wind, got {}",
756 vec_50[2]
757 );
758 assert_eq!(vec_50[1], 0.0); assert!(
760 vec_50[0].abs() < 0.01,
761 "X (downrange) should be nearly zero for 90° wind, got {}",
762 vec_50[0]
763 );
764
765 let vec_150 = sock.vector_for_range_stateless(150.0);
767 assert_eq!(vec_150, Vector3::zeros());
768 }
769
770 #[test]
771 fn test_wind_sock_multiple_segments() {
772 let sock = WindSock::new(vec![
774 WindSegment::new(16.0934, 90.0, 50.0), WindSegment::new(24.1401, 45.0, 100.0), WindSegment::new(8.0467, 180.0, 200.0), ]);
778
779 let vec_25 = sock.vector_for_range_stateless(25.0);
781 println!("vec_25 = [{}, {}, {}]", vec_25[0], vec_25[1], vec_25[2]);
782 assert!(vec_25.norm() > 0.0);
783 assert!(vec_25[2] < 0.0, "90° wind should have negative Z (lateral)"); let vec_75 = sock.vector_for_range_stateless(75.0);
786 println!("vec_75 = [{}, {}, {}]", vec_75[0], vec_75[1], vec_75[2]);
787 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);
792 println!("vec_150 = [{}, {}, {}]", vec_150[0], vec_150[1], vec_150[2]);
793 assert!(vec_150.norm() < vec_75.norm()); assert!(
795 vec_150[2].abs() < 0.01,
796 "180° wind should have near-zero Z (lateral), got {}",
797 vec_150[2]
798 ); assert!(
800 vec_150[0] > 0.0,
801 "180° wind should have positive X (tailwind, downrange), got {}",
802 vec_150[0]
803 ); let vec_250 = sock.vector_for_range_stateless(250.0);
806 assert_eq!(vec_250, Vector3::zeros()); }
808
809 #[test]
810 fn test_wind_conversion() {
811 let sock = WindSock::new(vec![WindSegment::new(16.0934, 0.0, 100.0)]);
813 let vec = sock.vector_for_range_stateless(50.0);
814
815 let expected_speed = 16.0934 * KMH_TO_MPS;
816 assert!((vec.norm() - expected_speed).abs() < 0.01);
817 }
818
819 #[test]
820 fn test_wind_sock_boundary_is_upper_exclusive() {
821 let sock = WindSock::new(vec![
824 WindSegment::new(16.0934, 90.0, 100.0),
825 WindSegment::new(32.1868, 270.0, 200.0),
826 ]);
827 assert!(sock.vector_for_range_stateless(99.999)[2] < 0.0);
829 assert!(sock.vector_for_range_stateless(100.0)[2] > 0.0);
831 assert_eq!(sock.vector_for_range_stateless(200.0), Vector3::zeros());
833 }
834
835 #[test]
836 fn calc_vec_passes_through_segment_vertical_unscaled() {
837 let seg = WindSegment {
840 speed_kmh: 16.0934,
841 angle_deg: 90.0,
842 until_m: 100.0,
843 vertical_mps: 3.0,
844 };
845 let vec = WindSock::calc_vec(&seg);
846 assert_eq!(vec[1], 3.0);
847 }
848
849 #[test]
850 fn calc_vec_zero_vertical_segment_keeps_zero_y() {
851 let seg = WindSegment::new(16.0934, 90.0, 100.0);
854 assert_eq!(seg.vertical_mps, 0.0);
855 let vec = WindSock::calc_vec(&seg);
856 assert_eq!(vec[1], 0.0);
857 }
858
859 #[test]
860 fn test_parse_wind_segment_str_units() {
861 let seg = parse_wind_segment_str("10:90:100", true).unwrap();
863 assert!((seg.speed_kmh - 16.09344).abs() < 1e-4);
864 assert_eq!(seg.angle_deg, 90.0);
865 assert!((seg.until_m - 91.44).abs() < 1e-4);
866
867 let seg = parse_wind_segment_str("5:270:200", false).unwrap();
869 assert!((seg.speed_kmh - 18.0).abs() < 1e-9);
870 assert_eq!(seg.angle_deg, 270.0);
871 assert!((seg.until_m - 200.0).abs() < 1e-9);
872
873 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());
880 assert!(parse_wind_segment_str("10:90:nan", true).is_err());
881 assert!(parse_wind_segment_str("inf:90:100", true).is_err());
882 }
883
884 #[test]
885 fn test_parse_wind_segment_str_vertical_field() {
886 let seg = parse_wind_segment_str("10:90:100", true).unwrap();
888 assert_eq!(seg.vertical_mps, 0.0);
889 let seg = parse_wind_segment_str("5:270:200", false).unwrap();
890 assert_eq!(seg.vertical_mps, 0.0);
891
892 let seg = parse_wind_segment_str("10:90:100:5", true).unwrap();
894 assert_eq!(seg.vertical_mps, 5.0);
895 assert!((seg.speed_kmh - 16.09344).abs() < 1e-4);
897 assert!((seg.until_m - 91.44).abs() < 1e-4);
898
899 let seg_metric = parse_wind_segment_str("5:270:200:5", false).unwrap();
902 assert_eq!(seg_metric.vertical_mps, 5.0);
903
904 let seg_neg = parse_wind_segment_str("10:90:100:-3.5", true).unwrap();
906 assert_eq!(seg_neg.vertical_mps, -3.5);
907
908 assert!(parse_wind_segment_str("10:90:100:bad", true).is_err());
910 assert!(parse_wind_segment_str("10:90:100:nan", true).is_err());
911 assert!(parse_wind_segment_str("10:90:100:inf", true).is_err());
912
913 assert!(parse_wind_segment_str("10:90:100:5:1", true).is_err());
915 }
916
917 #[test]
921 fn clock_positions_map_to_wind_from_degrees() {
922 let deg = |t: &str| parse_wind_direction(t).unwrap();
923 assert_eq!(deg("3oc"), ParsedWindDirection { degrees: 90.0, was_clock: true });
924 assert_eq!(deg("6oc").degrees, 180.0);
925 assert_eq!(deg("9oc").degrees, 270.0);
926 assert_eq!(deg("12oc").degrees, 0.0); assert_eq!(deg("10h30").degrees, 315.0);
928 assert_eq!(deg("1h00").degrees, 30.0);
929 assert_eq!(deg("12h30").degrees, 15.0);
930 assert_eq!(deg("7h05").degrees, 212.5);
931 assert_eq!(deg("3OC").degrees, 90.0);
933 assert_eq!(deg("10H30").degrees, 315.0);
934
935 assert_eq!(deg("90"), ParsedWindDirection { degrees: 90.0, was_clock: false });
937 assert_eq!(deg("-45").degrees, -45.0);
938 assert_eq!(deg("370.5").degrees, 370.5);
939
940 let sa = parse_wind_direction_standalone("10:30").unwrap();
942 assert_eq!(sa, ParsedWindDirection { degrees: 315.0, was_clock: true });
943 assert_eq!(parse_wind_direction_standalone("3oc").unwrap().degrees, 90.0);
944 assert!(!parse_wind_direction_standalone("90").unwrap().was_clock);
945 assert!(matches!(
947 parse_wind_direction("10:30"),
948 Err(WindDirectionParseError::Unrecognized(_))
949 ));
950 }
951
952 #[test]
953 fn clock_positions_reject_bad_hours_and_minutes_with_helpful_messages() {
954 assert!(matches!(
955 parse_wind_direction("13oc"),
956 Err(WindDirectionParseError::HourOutOfRange(_))
957 ));
958 assert!(matches!(
959 parse_wind_direction("0oc"),
960 Err(WindDirectionParseError::HourOutOfRange(_))
961 ));
962 assert!(matches!(
963 parse_wind_direction("3h60"),
964 Err(WindDirectionParseError::MinutesOutOfRange(_))
965 ));
966 assert!(matches!(
967 parse_wind_direction_standalone("13:00"),
968 Err(WindDirectionParseError::HourOutOfRange(_))
969 ));
970 assert!(matches!(
971 parse_wind_direction_standalone("3:60"),
972 Err(WindDirectionParseError::MinutesOutOfRange(_))
973 ));
974 for bad in ["oc", "hoc", "3.5oc", "3h", "3h123", "10h3x", "3:", ":30", "10:301", "x"] {
976 let err = parse_wind_direction_standalone(bad).unwrap_err();
977 assert!(
978 matches!(err, WindDirectionParseError::Unrecognized(_)),
979 "{bad}: {err}"
980 );
981 }
982 let msg = parse_wind_direction("bogus").unwrap_err().to_string();
983 assert!(msg.contains("3oc"), "{msg}");
984 assert!(msg.contains("degrees"), "{msg}");
985 let msg = parse_wind_direction("13oc").unwrap_err().to_string();
986 assert!(msg.contains("hour must be 1-12"), "{msg}");
987 }
988
989 #[test]
992 fn segment_angle_accepts_colon_free_clock_forms_only() {
993 let clock = parse_wind_segment_str("10:3oc:400", true).unwrap();
994 let plain = parse_wind_segment_str("10:90:400", true).unwrap();
995 assert_eq!(clock, plain);
996
997 let (seg, was_clock) = parse_wind_segment_str_detailed("10:10h30:400", true).unwrap();
998 assert_eq!(seg.angle_deg, 315.0);
999 assert!(was_clock);
1000 let (_, was_clock) = parse_wind_segment_str_detailed("10:90:400", true).unwrap();
1001 assert!(!was_clock);
1002
1003 let legacy = parse_wind_segment_str("10:30:400", true).unwrap();
1005 assert_eq!(legacy.angle_deg, 30.0);
1006 assert!((legacy.speed_kmh - 10.0 * 1.609344).abs() < 1e-12);
1007 assert!((legacy.until_m - 400.0 * 0.9144).abs() < 1e-9);
1008
1009 let err = parse_wind_segment_str("10:13oc:400", true).unwrap_err();
1012 assert!(err.contains("hour must be 1-12"), "{err}");
1013 let err = parse_wind_segment_str("10:abc:400", true).unwrap_err();
1014 assert!(
1015 err.contains("angle 'abc' is not a number or a clock position"),
1016 "{err}"
1017 );
1018 let (seg, was_clock) = parse_wind_segment_str_detailed("10:9oc:400:1.5", true).unwrap();
1020 assert_eq!(seg.angle_deg, 270.0);
1021 assert_eq!(seg.vertical_mps, 1.5);
1022 assert!(was_clock);
1023 }
1024}