1use super::{wind_vector, WindSock};
13use core::f64::consts::PI;
14use nalgebra::Vector3;
15use thiserror::Error;
16
17const EARTH_ROTATION_RAD_PER_S: f64 = 7.2921159e-5;
19
20pub trait WindField {
24 fn wind(&self, time_s: f64, position_m: Vector3<f64>) -> Vector3<f64>;
26}
27
28impl<F: WindField + ?Sized> WindField for &F {
29 fn wind(&self, time_s: f64, position_m: Vector3<f64>) -> Vector3<f64> {
30 (**self).wind(time_s, position_m)
31 }
32}
33
34impl<F: WindField + ?Sized> WindField for Box<F> {
35 fn wind(&self, time_s: f64, position_m: Vector3<f64>) -> Vector3<f64> {
36 (**self).wind(time_s, position_m)
37 }
38}
39
40impl WindField for WindSock {
43 fn wind(&self, _time_s: f64, position_m: Vector3<f64>) -> Vector3<f64> {
44 self.vector_for_range_stateless(position_m.x)
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct ConstantField(pub Vector3<f64>);
51
52impl WindField for ConstantField {
53 fn wind(&self, _time_s: f64, _position_m: Vector3<f64>) -> Vector3<f64> {
54 self.0
55 }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq)]
85pub struct HeightOffset<F> {
86 field: F,
87 offset_m: f64,
88}
89
90impl<F> HeightOffset<F> {
91 pub fn new(field: F, offset_m: f64) -> Result<Self, WindFieldError> {
93 if !offset_m.is_finite() {
94 return Err(WindFieldError::InvalidParameter {
95 param: WindFieldParam::OffsetM,
96 rule: WindFieldRule::Finite,
97 });
98 }
99 Ok(Self { field, offset_m })
100 }
101
102 pub fn field(&self) -> &F {
104 &self.field
105 }
106
107 pub fn offset_m(&self) -> f64 {
109 self.offset_m
110 }
111
112 pub fn into_inner(self) -> F {
114 self.field
115 }
116}
117
118impl<F: WindField> WindField for HeightOffset<F> {
119 fn wind(&self, time_s: f64, position_m: Vector3<f64>) -> Vector3<f64> {
120 self.field.wind(
121 time_s,
122 Vector3::new(position_m.x, position_m.y + self.offset_m, position_m.z),
123 )
124 }
125}
126
127#[derive(Debug, Clone, Copy, PartialEq)]
129pub struct ProfileLevel {
130 pub altitude_m: f64,
132 pub speed_mps: f64,
134 pub direction_from_deg: f64,
137}
138
139#[derive(Debug, Clone, PartialEq)]
147pub struct ProfileField {
148 levels: Vec<ProfileLevel>,
150}
151
152impl ProfileField {
153 pub fn new(levels: Vec<ProfileLevel>) -> Result<Self, WindFieldError> {
160 if levels.is_empty() {
161 return Err(WindFieldError::EmptyProfile);
162 }
163 for (index, level) in levels.iter().enumerate() {
164 let invalid = |param, rule| Err(WindFieldError::InvalidLevel { index, param, rule });
165 if !level.altitude_m.is_finite() {
166 return invalid(WindFieldParam::AltitudeM, WindFieldRule::Finite);
167 }
168 if !level.speed_mps.is_finite() || level.speed_mps < 0.0 {
169 return invalid(
170 WindFieldParam::SpeedMps,
171 WindFieldRule::FiniteAndNonNegative,
172 );
173 }
174 if !level.direction_from_deg.is_finite() {
175 return invalid(WindFieldParam::DirectionFromDeg, WindFieldRule::Finite);
176 }
177 }
178
179 let mut indexed: Vec<(usize, ProfileLevel)> = levels.into_iter().enumerate().collect();
180 indexed.sort_by(|a, b| a.1.altitude_m.total_cmp(&b.1.altitude_m));
181 for pair in indexed.windows(2) {
182 let ((ia, a), (ib, b)) = (pair[0], pair[1]);
183 if a.altitude_m == b.altitude_m {
185 return Err(WindFieldError::DuplicateAltitude {
186 first_index: ia.min(ib),
187 second_index: ia.max(ib),
188 altitude_m: a.altitude_m,
189 });
190 }
191 }
192
193 Ok(Self {
194 levels: indexed.into_iter().map(|(_, level)| level).collect(),
195 })
196 }
197
198 pub fn levels(&self) -> &[ProfileLevel] {
200 &self.levels
201 }
202
203 pub fn wind_at(&self, altitude_m: f64) -> Vector3<f64> {
206 if altitude_m.is_nan() {
207 return Vector3::zeros();
208 }
209 let levels = &self.levels;
210 let first = levels[0];
211 if altitude_m <= first.altitude_m {
212 return horizontal(first.speed_mps, first.direction_from_deg);
213 }
214 for pair in levels.windows(2) {
215 let (lo, hi) = (pair[0], pair[1]);
216 if altitude_m < hi.altitude_m {
220 if altitude_m == lo.altitude_m {
221 return horizontal(lo.speed_mps, lo.direction_from_deg);
222 }
223 let f = (altitude_m / 2.0 - lo.altitude_m / 2.0)
225 / (hi.altitude_m / 2.0 - lo.altitude_m / 2.0);
226 let speed = lo.speed_mps + f * (hi.speed_mps - lo.speed_mps);
227 let lo_dir = lo.direction_from_deg.rem_euclid(360.0);
231 let mut delta = hi.direction_from_deg.rem_euclid(360.0) - lo_dir;
232 if delta > 180.0 {
233 delta -= 360.0;
234 } else if delta <= -180.0 {
235 delta += 360.0;
236 }
237 return horizontal(speed, lo_dir + f * delta);
238 }
239 }
240 let last = levels[levels.len() - 1];
241 horizontal(last.speed_mps, last.direction_from_deg)
242 }
243}
244
245impl WindField for ProfileField {
246 fn wind(&self, _time_s: f64, position_m: Vector3<f64>) -> Vector3<f64> {
247 self.wind_at(position_m.y)
248 }
249}
250
251#[derive(Debug, Clone, Copy, PartialEq)]
257pub struct LogProfile {
258 reference_speed_mps: f64,
259 reference_height_m: f64,
260 roughness_m: f64,
261 direction_from_deg: f64,
262}
263
264impl LogProfile {
265 pub fn new(
271 reference_speed_mps: f64,
272 reference_height_m: f64,
273 roughness_m: f64,
274 direction_from_deg: f64,
275 ) -> Result<Self, WindFieldError> {
276 let invalid = |param, rule| Err(WindFieldError::InvalidParameter { param, rule });
277 if !reference_speed_mps.is_finite() || reference_speed_mps < 0.0 {
278 return invalid(
279 WindFieldParam::ReferenceSpeedMps,
280 WindFieldRule::FiniteAndNonNegative,
281 );
282 }
283 if !reference_height_m.is_finite() || reference_height_m <= 0.0 {
284 return invalid(
285 WindFieldParam::ReferenceHeightM,
286 WindFieldRule::FiniteAndPositive,
287 );
288 }
289 let log_span = reference_height_m.ln() - roughness_m.ln();
292 if !roughness_m.is_finite()
293 || roughness_m <= 0.0
294 || roughness_m >= reference_height_m
295 || !(log_span.is_finite() && log_span > 0.0)
296 {
297 return invalid(
298 WindFieldParam::RoughnessM,
299 WindFieldRule::FiniteAndPositiveBelowReferenceHeight,
300 );
301 }
302 if !direction_from_deg.is_finite() {
303 return invalid(WindFieldParam::DirectionFromDeg, WindFieldRule::Finite);
304 }
305 Ok(Self {
306 reference_speed_mps,
307 reference_height_m,
308 roughness_m,
309 direction_from_deg,
310 })
311 }
312
313 pub fn reference_speed_mps(&self) -> f64 {
315 self.reference_speed_mps
316 }
317
318 pub fn reference_height_m(&self) -> f64 {
320 self.reference_height_m
321 }
322
323 pub fn roughness_m(&self) -> f64 {
325 self.roughness_m
326 }
327
328 pub fn direction_from_deg(&self) -> f64 {
330 self.direction_from_deg
331 }
332
333 pub fn speed_at(&self, altitude_m: f64) -> f64 {
336 if !altitude_m.is_finite() || altitude_m <= self.roughness_m {
337 return 0.0;
338 }
339 let z0_ln = self.roughness_m.ln();
341 self.reference_speed_mps * (altitude_m.ln() - z0_ln)
342 / (self.reference_height_m.ln() - z0_ln)
343 }
344
345 pub fn wind_at(&self, altitude_m: f64) -> Vector3<f64> {
347 horizontal(self.speed_at(altitude_m), self.direction_from_deg)
348 }
349}
350
351impl WindField for LogProfile {
352 fn wind(&self, _time_s: f64, position_m: Vector3<f64>) -> Vector3<f64> {
353 self.wind_at(position_m.y)
354 }
355}
356
357#[derive(Debug, Clone, Copy, PartialEq)]
371pub struct EkmanProfile {
372 geostrophic_mps: f64,
373 latitude_deg: f64,
374 eddy_viscosity_m2s: f64,
375 direction_from_deg: f64,
376}
377
378impl EkmanProfile {
379 pub fn new(
388 geostrophic_mps: f64,
389 latitude_deg: f64,
390 eddy_viscosity_m2s: f64,
391 direction_from_deg: f64,
392 ) -> Result<Self, WindFieldError> {
393 let invalid = |param, rule| Err(WindFieldError::InvalidParameter { param, rule });
394 if !geostrophic_mps.is_finite() || geostrophic_mps < 0.0 {
395 return invalid(
396 WindFieldParam::GeostrophicMps,
397 WindFieldRule::FiniteAndNonNegative,
398 );
399 }
400 if !latitude_deg.is_finite() || latitude_deg == 0.0 || latitude_deg.abs() >= 90.0 {
401 return invalid(
402 WindFieldParam::LatitudeDeg,
403 WindFieldRule::FiniteNonzeroAndOffThePoles,
404 );
405 }
406 if coriolis_parameter(latitude_deg) == 0.0 {
407 return invalid(
408 WindFieldParam::LatitudeDeg,
409 WindFieldRule::FiniteNonzeroAndOffThePoles,
410 );
411 }
412 if !eddy_viscosity_m2s.is_finite()
413 || eddy_viscosity_m2s <= 0.0
414 || !decay_rate(latitude_deg, eddy_viscosity_m2s).is_finite()
415 {
416 return invalid(
417 WindFieldParam::EddyViscosityM2s,
418 WindFieldRule::FiniteAndPositive,
419 );
420 }
421 if !direction_from_deg.is_finite() {
422 return invalid(WindFieldParam::DirectionFromDeg, WindFieldRule::Finite);
423 }
424 Ok(Self {
425 geostrophic_mps,
426 latitude_deg,
427 eddy_viscosity_m2s,
428 direction_from_deg,
429 })
430 }
431
432 pub fn geostrophic_mps(&self) -> f64 {
434 self.geostrophic_mps
435 }
436
437 pub fn latitude_deg(&self) -> f64 {
439 self.latitude_deg
440 }
441
442 pub fn eddy_viscosity_m2s(&self) -> f64 {
444 self.eddy_viscosity_m2s
445 }
446
447 pub fn direction_from_deg(&self) -> f64 {
449 self.direction_from_deg
450 }
451
452 fn components_at(&self, altitude_m: f64) -> (f64, f64) {
454 if altitude_m.is_nan() || altitude_m <= 0.0 {
455 return (0.0, 0.0);
456 }
457 let g = self.geostrophic_mps;
458 let x = decay_rate(self.latitude_deg, self.eddy_viscosity_m2s) * altitude_m;
459 if x > 746.0 {
462 return (g, 0.0);
463 }
464 let decay = (-x).exp();
465 let (sin_x, cos_x) = x.sin_cos();
466 let along = if x < 1.0 {
469 let half = (0.5 * x).sin();
470 2.0 * half * half - cos_x * (-x).exp_m1()
471 } else {
472 1.0 - decay * cos_x
473 };
474 let sign = coriolis_parameter(self.latitude_deg).signum();
475 (g * along, sign * g * decay * sin_x)
476 }
477
478 pub fn wind_at(&self, altitude_m: f64) -> Vector3<f64> {
481 let (u, v) = self.components_at(altitude_m);
482 let from_rad = direction_to_rad(self.direction_from_deg);
483 let (sin, cos) = from_rad.sin_cos();
484 Vector3::new(-u * cos - v * sin, 0.0, -u * sin + v * cos)
487 }
488}
489
490impl WindField for EkmanProfile {
491 fn wind(&self, _time_s: f64, position_m: Vector3<f64>) -> Vector3<f64> {
492 self.wind_at(position_m.y)
493 }
494}
495
496#[derive(Debug, Clone, Copy, PartialEq, Eq)]
498pub enum WindFieldParam {
499 AltitudeM,
501 SpeedMps,
503 DirectionFromDeg,
505 ReferenceSpeedMps,
507 ReferenceHeightM,
509 RoughnessM,
511 GeostrophicMps,
513 LatitudeDeg,
515 EddyViscosityM2s,
517 OffsetM,
519}
520
521impl WindFieldParam {
522 pub fn name(self) -> &'static str {
524 match self {
525 WindFieldParam::AltitudeM => "altitude_m",
526 WindFieldParam::SpeedMps => "speed_mps",
527 WindFieldParam::DirectionFromDeg => "direction_from_deg",
528 WindFieldParam::ReferenceSpeedMps => "reference_speed_mps",
529 WindFieldParam::ReferenceHeightM => "reference_height_m",
530 WindFieldParam::RoughnessM => "roughness_m",
531 WindFieldParam::GeostrophicMps => "geostrophic_mps",
532 WindFieldParam::LatitudeDeg => "latitude_deg",
533 WindFieldParam::EddyViscosityM2s => "eddy_viscosity_m2s",
534 WindFieldParam::OffsetM => "offset_m",
535 }
536 }
537}
538
539impl core::fmt::Display for WindFieldParam {
540 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
541 f.write_str(self.name())
542 }
543}
544
545#[derive(Debug, Clone, Copy, PartialEq, Eq)]
547pub enum WindFieldRule {
548 Finite,
550 FiniteAndNonNegative,
552 FiniteAndPositive,
554 FiniteAndPositiveBelowReferenceHeight,
556 FiniteNonzeroAndOffThePoles,
558}
559
560impl core::fmt::Display for WindFieldRule {
561 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
562 f.write_str(match self {
563 WindFieldRule::Finite => "finite",
564 WindFieldRule::FiniteAndNonNegative => "finite and non-negative",
565 WindFieldRule::FiniteAndPositive => "finite and greater than zero",
566 WindFieldRule::FiniteAndPositiveBelowReferenceHeight => {
567 "finite, greater than zero and below the reference height"
568 }
569 WindFieldRule::FiniteNonzeroAndOffThePoles => {
570 "finite, nonzero and strictly between -90 and 90 degrees"
571 }
572 })
573 }
574}
575
576#[derive(Debug, Clone, Copy, PartialEq, Error)]
578pub enum WindFieldError {
579 #[error("wind profile needs at least one level")]
581 EmptyProfile,
582 #[error("wind profile levels[{index}].{param} must be {rule}")]
585 InvalidLevel {
586 index: usize,
587 param: WindFieldParam,
588 rule: WindFieldRule,
589 },
590 #[error(
593 "wind profile levels[{first_index}] and levels[{second_index}] share altitude_m {altitude_m}; altitudes must be strictly ascending"
594 )]
595 DuplicateAltitude {
596 first_index: usize,
597 second_index: usize,
598 altitude_m: f64,
599 },
600 #[error("{param} must be {rule}")]
602 InvalidParameter {
603 param: WindFieldParam,
604 rule: WindFieldRule,
605 },
606}
607
608fn coriolis_parameter(latitude_deg: f64) -> f64 {
610 2.0 * EARTH_ROTATION_RAD_PER_S * latitude_deg.to_radians().sin()
611}
612
613fn decay_rate(latitude_deg: f64, eddy_viscosity_m2s: f64) -> f64 {
615 (coriolis_parameter(latitude_deg).abs() / (2.0 * eddy_viscosity_m2s)).sqrt()
616}
617
618fn direction_to_rad(direction_deg: f64) -> f64 {
621 if direction_deg.abs() <= f64::MAX / PI {
622 direction_deg * PI / 180.0
623 } else {
624 direction_deg / 180.0 * PI
625 }
626}
627
628fn horizontal(speed_mps: f64, direction_from_deg: f64) -> Vector3<f64> {
630 wind_vector(speed_mps, direction_to_rad(direction_from_deg), 0.0)
631}
632
633#[cfg(test)]
634mod tests {
635 use super::super::WindSegment;
636 use super::*;
637 use core::f64::consts::FRAC_PI_2;
638
639 fn level(altitude_m: f64, speed_mps: f64, direction_from_deg: f64) -> ProfileLevel {
640 ProfileLevel {
641 altitude_m,
642 speed_mps,
643 direction_from_deg,
644 }
645 }
646
647 fn at_height(y: f64) -> Vector3<f64> {
648 Vector3::new(0.0, y, 0.0)
649 }
650
651 fn speed(w: Vector3<f64>) -> f64 {
653 w.x.hypot(w.z)
654 }
655
656 fn direction_from(w: Vector3<f64>) -> f64 {
658 (w.z.atan2(w.x).to_degrees() + 180.0).rem_euclid(360.0)
659 }
660
661 fn angle_diff(a: f64, b: f64) -> f64 {
663 let d = (a - b).rem_euclid(360.0);
664 if d > 180.0 {
665 d - 360.0
666 } else {
667 d
668 }
669 }
670
671 #[test]
674 fn wind_direction_round_trips() {
675 let headwind = horizontal(10.0, 0.0);
677 assert!((headwind.x + 10.0).abs() < 1e-12);
678 assert!(headwind.z.abs() < 1e-12);
679 assert!(direction_from(headwind).abs() < 1e-9);
680 let cross = horizontal(10.0, 90.0);
681 assert!(cross.x.abs() < 1e-12);
682 assert!((cross.z + 10.0).abs() < 1e-12);
683 for deg in [1.0, 90.0, 179.9, 181.0, 270.0, 359.9] {
684 let w = horizontal(7.0, deg);
685 assert!((speed(w) - 7.0).abs() < 1e-9);
686 let back = angle_diff(direction_from(w), deg);
687 assert!(back.abs() < 1e-9, "deg {deg} back {back}");
688 }
689 }
690
691 #[test]
692 fn constant_field_is_constant() {
693 let field = ConstantField(horizontal(5.0, 45.0));
694 assert_eq!(
695 field.wind(0.0, Vector3::zeros()),
696 field.wind(99.0, Vector3::new(1.0, 2.0, 3.0))
697 );
698 }
699
700 #[test]
701 fn profile_interpolates_speed_linearly() {
702 let profile =
703 ProfileField::new(vec![level(0.0, 2.0, 0.0), level(1000.0, 12.0, 0.0)]).unwrap();
704 assert!((speed(profile.wind_at(500.0)) - 7.0).abs() < 1e-12);
705 assert!((speed(profile.wind_at(-50.0)) - 2.0).abs() < 1e-12);
707 assert!((speed(profile.wind_at(5000.0)) - 12.0).abs() < 1e-12);
708 }
709
710 #[test]
711 fn profile_interpolates_direction_along_the_short_arc() {
712 let profile =
714 ProfileField::new(vec![level(0.0, 5.0, 350.0), level(1000.0, 5.0, 10.0)]).unwrap();
715 let mid = direction_from(profile.wind_at(500.0));
716 assert!(!(10.0..=350.0).contains(&mid), "mid {mid}");
717 let quarter = direction_from(profile.wind_at(250.0));
718 assert!(quarter > 350.0, "quarter {quarter}");
719 let reverse =
721 ProfileField::new(vec![level(0.0, 5.0, 10.0), level(1000.0, 5.0, 350.0)]).unwrap();
722 assert!(angle_diff(direction_from(reverse.wind_at(500.0)), 0.0).abs() < 1e-9);
723 }
724
725 #[test]
726 fn profile_validates_its_levels() {
727 assert!(ProfileField::new(vec![]).is_err());
728 assert!(ProfileField::new(vec![level(0.0, -1.0, 0.0)]).is_err());
729 assert!(ProfileField::new(vec![level(0.0, 1.0, 0.0), level(0.0, 2.0, 0.0)]).is_err());
730 let profile =
732 ProfileField::new(vec![level(1000.0, 12.0, 0.0), level(0.0, 2.0, 0.0)]).unwrap();
733 assert!((speed(profile.wind_at(500.0)) - 7.0).abs() < 1e-12);
734 assert_eq!(profile.levels()[0].altitude_m, 0.0);
735 assert_eq!(profile.levels()[1].altitude_m, 1000.0);
736 }
737
738 #[test]
739 fn log_profile_hits_its_reference_and_roughness() {
740 let log = LogProfile::new(8.0, 10.0, 0.03, 270.0).unwrap();
741 assert!(
742 (log.speed_at(10.0) - 8.0).abs() < 1e-12,
743 "{}",
744 log.speed_at(10.0)
745 );
746 assert_eq!(log.speed_at(0.03), 0.0);
747 assert_eq!(log.speed_at(0.01), 0.0);
748 let increment = log.speed_at(20.0) - log.speed_at(10.0);
750 assert!((increment - (log.speed_at(40.0) - log.speed_at(20.0))).abs() < 1e-12);
751 assert!(LogProfile::new(8.0, 10.0, 20.0, 0.0).is_err());
752 assert!(LogProfile::new(-1.0, 10.0, 0.03, 0.0).is_err());
753 }
754
755 #[test]
756 fn ekman_profile_has_the_right_endpoints() {
757 let ekman = EkmanProfile::new(12.0, 45.0, 5.0, 0.0).unwrap();
758 assert!(speed(ekman.wind_at(0.0)) < 1e-12);
760 let aloft = ekman.wind_at(5_000.0);
761 assert!(
762 (speed(aloft) - 12.0).abs() / 12.0 < 1e-3,
763 "{}",
764 speed(aloft)
765 );
766 let dir_err = angle_diff(direction_from(aloft), 0.0);
768 assert!(dir_err.abs() < 1.0, "dir_err {dir_err}");
769 let low = ekman.wind_at(100.0);
771 assert!(speed(low) > 0.0 && speed(low) < 12.0);
772 assert!(EkmanProfile::new(12.0, 90.0, 5.0, 0.0).is_err());
773 assert!(EkmanProfile::new(12.0, 45.0, 0.0, 0.0).is_err());
774 }
775
776 #[test]
779 fn directions_match_wind_vector_exactly() {
780 for speed_mps in [0.0, 3.0, 12.5] {
781 let at0 = ProfileField::new(vec![level(0.0, speed_mps, 0.0)]).unwrap();
782 assert_eq!(at0.wind_at(10.0), wind_vector(speed_mps, 0.0, 0.0));
783 let at90 = ProfileField::new(vec![level(0.0, speed_mps, 90.0)]).unwrap();
784 assert_eq!(at90.wind_at(10.0), wind_vector(speed_mps, FRAC_PI_2, 0.0));
785 }
786 let segment_speed_kmh = 36.0;
788 let sock = WindSock::try_new(vec![WindSegment::new(segment_speed_kmh, 33.0, 1e6)]).unwrap();
789 let from_sock = sock.vector_for_range_stateless(1.0);
790 let profile = ProfileField::new(vec![level(
791 0.0,
792 segment_speed_kmh * (1000.0 / 3600.0),
793 33.0,
794 )])
795 .unwrap();
796 assert_eq!(profile.wind_at(0.0), from_sock);
797 }
798
799 #[test]
800 fn analytic_profiles_use_the_same_direction_convention() {
801 let log = LogProfile::new(8.0, 10.0, 0.03, 90.0).unwrap();
803 let w = log.wind_at(50.0);
804 assert!(w.x.abs() < 1e-12 && w.z < 0.0 && w.y == 0.0);
805 assert!((direction_from(w) - 90.0).abs() < 1e-9);
806 for dir in [0.0, 90.0, 180.0, 270.0, 33.0] {
808 let ekman = EkmanProfile::new(10.0, 45.0, 5.0, dir).unwrap();
809 let aloft = ekman.wind_at(20_000.0);
810 assert!(angle_diff(direction_from(aloft), dir).abs() < 1e-6, "{dir}");
811 assert_eq!(aloft.y, 0.0);
812 }
813 }
814
815 #[test]
816 fn ekman_backs_near_the_surface_in_the_north_and_veers_in_the_south() {
817 let geo_dir = 200.0;
822 let north = EkmanProfile::new(10.0, 45.0, 5.0, geo_dir).unwrap();
823 let south = EkmanProfile::new(10.0, -45.0, 5.0, geo_dir).unwrap();
824 let near = 0.01; assert!((angle_diff(direction_from(north.wind_at(near)), geo_dir) + 45.0).abs() < 0.01);
826 assert!((angle_diff(direction_from(south.wind_at(near)), geo_dir) - 45.0).abs() < 0.01);
827 let mut previous = angle_diff(direction_from(north.wind_at(1.0)), geo_dir);
829 for z in [50.0, 100.0, 200.0, 400.0] {
830 let now = angle_diff(direction_from(north.wind_at(z)), geo_dir);
831 assert!(now > previous, "z {z}: {now} <= {previous}");
832 previous = now;
833 }
834 }
835
836 #[test]
839 fn wind_sock_adapter_matches_the_stateless_lookup() {
840 let sock = WindSock::try_new(vec![
841 WindSegment::new(10.0, 90.0, 100.0),
842 WindSegment {
843 speed_kmh: 20.0,
844 angle_deg: 0.0,
845 until_m: 300.0,
846 vertical_mps: 1.5,
847 },
848 WindSegment::new(5.0, 270.0, 600.0),
849 ])
850 .unwrap();
851 for x in [
852 -50.0,
853 0.0,
854 99.999,
855 100.0,
856 100.001,
857 250.0,
858 299.999,
859 300.0,
860 450.0,
861 599.0,
862 600.0,
863 1e6,
864 f64::NAN,
865 ] {
866 for (t, y, z) in [(0.0, 0.0, 0.0), (12.5, 40.0, -3.0), (-1.0, -5.0, 7.0)] {
867 let got = sock.wind(t, Vector3::new(x, y, z));
868 let want = sock.vector_for_range_stateless(x);
869 assert_eq!(got, want, "x {x} t {t} y {y} z {z}");
870 }
871 }
872 let field: &dyn WindField = &sock;
874 assert_eq!(
875 field.wind(0.0, Vector3::new(150.0, 0.0, 0.0)),
876 sock.vector_for_range_stateless(150.0)
877 );
878 }
879
880 #[test]
883 fn profile_validation_reports_index_param_and_rule() {
884 let ok = level(0.0, 1.0, 0.0);
885 let cases = [
886 (
887 level(f64::NAN, 1.0, 0.0),
888 WindFieldParam::AltitudeM,
889 WindFieldRule::Finite,
890 ),
891 (
892 level(f64::INFINITY, 1.0, 0.0),
893 WindFieldParam::AltitudeM,
894 WindFieldRule::Finite,
895 ),
896 (
897 level(0.0, -0.5, 0.0),
898 WindFieldParam::SpeedMps,
899 WindFieldRule::FiniteAndNonNegative,
900 ),
901 (
902 level(0.0, f64::INFINITY, 0.0),
903 WindFieldParam::SpeedMps,
904 WindFieldRule::FiniteAndNonNegative,
905 ),
906 (
907 level(0.0, f64::NAN, 0.0),
908 WindFieldParam::SpeedMps,
909 WindFieldRule::FiniteAndNonNegative,
910 ),
911 (
912 level(0.0, 1.0, f64::NAN),
913 WindFieldParam::DirectionFromDeg,
914 WindFieldRule::Finite,
915 ),
916 (
917 level(0.0, 1.0, f64::NEG_INFINITY),
918 WindFieldParam::DirectionFromDeg,
919 WindFieldRule::Finite,
920 ),
921 ];
922 for (bad, param, rule) in cases {
923 let mut good = ok;
925 good.altitude_m = 500.0;
926 let err = ProfileField::new(vec![good, bad]).unwrap_err();
927 assert_eq!(
928 err,
929 WindFieldError::InvalidLevel {
930 index: 1,
931 param,
932 rule
933 }
934 );
935 }
936 assert_eq!(
937 ProfileField::new(vec![]).unwrap_err(),
938 WindFieldError::EmptyProfile
939 );
940 let err = ProfileField::new(vec![
942 level(300.0, 1.0, 0.0),
943 level(100.0, 1.0, 0.0),
944 level(200.0, 1.0, 0.0),
945 level(100.0, 2.0, 0.0),
946 ])
947 .unwrap_err();
948 assert_eq!(
949 err,
950 WindFieldError::DuplicateAltitude {
951 first_index: 1,
952 second_index: 3,
953 altitude_m: 100.0
954 }
955 );
956 assert!(matches!(
958 ProfileField::new(vec![level(0.0, 1.0, 0.0), level(-0.0, 1.0, 0.0)]),
959 Err(WindFieldError::DuplicateAltitude { .. })
960 ));
961 assert_eq!(
963 WindFieldError::InvalidLevel {
964 index: 2,
965 param: WindFieldParam::SpeedMps,
966 rule: WindFieldRule::FiniteAndNonNegative
967 }
968 .to_string(),
969 "wind profile levels[2].speed_mps must be finite and non-negative"
970 );
971 }
972
973 #[test]
974 fn analytic_validation_reports_param_and_rule() {
975 use WindFieldParam as P;
976 use WindFieldRule as R;
977 let log = |s, h, z0, d| LogProfile::new(s, h, z0, d).unwrap_err();
978 let invalid = |param, rule| WindFieldError::InvalidParameter { param, rule };
979 assert_eq!(
980 log(-1.0, 10.0, 0.03, 0.0),
981 invalid(P::ReferenceSpeedMps, R::FiniteAndNonNegative)
982 );
983 assert_eq!(
984 log(f64::NAN, 10.0, 0.03, 0.0),
985 invalid(P::ReferenceSpeedMps, R::FiniteAndNonNegative)
986 );
987 assert_eq!(
988 log(8.0, 0.0, 0.03, 0.0),
989 invalid(P::ReferenceHeightM, R::FiniteAndPositive)
990 );
991 assert_eq!(
992 log(8.0, f64::INFINITY, 0.03, 0.0),
993 invalid(P::ReferenceHeightM, R::FiniteAndPositive)
994 );
995 for z0 in [0.0, -0.1, 10.0, 11.0, f64::NAN] {
996 assert_eq!(
997 log(8.0, 10.0, z0, 0.0),
998 invalid(P::RoughnessM, R::FiniteAndPositiveBelowReferenceHeight),
999 "z0 {z0}"
1000 );
1001 }
1002 assert_eq!(
1003 log(8.0, 10.0, 0.03, f64::NAN),
1004 invalid(P::DirectionFromDeg, R::Finite)
1005 );
1006
1007 let ekman = |g, lat, k, d| EkmanProfile::new(g, lat, k, d).unwrap_err();
1008 assert_eq!(
1009 ekman(-1.0, 45.0, 5.0, 0.0),
1010 invalid(P::GeostrophicMps, R::FiniteAndNonNegative)
1011 );
1012 for lat in [0.0, -0.0, 90.0, -90.0, 120.0, f64::NAN, f64::INFINITY] {
1013 assert_eq!(
1014 ekman(12.0, lat, 5.0, 0.0),
1015 invalid(P::LatitudeDeg, R::FiniteNonzeroAndOffThePoles),
1016 "lat {lat}"
1017 );
1018 }
1019 for k in [0.0, -5.0, f64::NAN] {
1020 assert_eq!(
1021 ekman(12.0, 45.0, k, 0.0),
1022 invalid(P::EddyViscosityM2s, R::FiniteAndPositive)
1023 );
1024 }
1025 assert_eq!(
1026 ekman(12.0, 45.0, 5.0, f64::INFINITY),
1027 invalid(P::DirectionFromDeg, R::Finite)
1028 );
1029 assert_eq!(
1031 LogProfile::new(0.0, 10.0, 0.03, 0.0).unwrap().wind_at(5.0),
1032 Vector3::zeros()
1033 );
1034 assert_eq!(
1035 speed(
1036 EkmanProfile::new(0.0, 45.0, 5.0, 0.0)
1037 .unwrap()
1038 .wind_at(100.0)
1039 ),
1040 0.0
1041 );
1042 }
1043
1044 #[test]
1047 fn fields_ignore_time_and_horizontal_position() {
1048 let profile =
1049 ProfileField::new(vec![level(0.0, 2.0, 30.0), level(800.0, 9.0, 80.0)]).unwrap();
1050 let log = LogProfile::new(8.0, 10.0, 0.03, 270.0).unwrap();
1051 let ekman = EkmanProfile::new(12.0, 45.0, 5.0, 120.0).unwrap();
1052 let fields: [&dyn WindField; 3] = [&profile, &log, &ekman];
1053 for field in fields {
1054 let reference = field.wind(0.0, at_height(321.0));
1055 for (t, x, z) in [(1.0, 10.0, -4.0), (1e6, -300.0, 99.0), (-7.0, 0.0, 0.0)] {
1056 assert_eq!(field.wind(t, Vector3::new(x, 321.0, z)), reference);
1057 }
1058 assert_eq!(field.wind(3.0, at_height(321.0)), reference);
1060 }
1061 }
1062
1063 #[test]
1064 fn nan_height_gives_calm_air() {
1065 let profile = ProfileField::new(vec![level(0.0, 2.0, 30.0)]).unwrap();
1066 let log = LogProfile::new(8.0, 10.0, 0.03, 270.0).unwrap();
1067 let ekman = EkmanProfile::new(12.0, 45.0, 5.0, 120.0).unwrap();
1068 assert_eq!(profile.wind_at(f64::NAN), Vector3::zeros());
1069 assert_eq!(log.wind_at(f64::NAN), Vector3::zeros());
1070 assert_eq!(log.speed_at(f64::NAN), 0.0);
1071 assert_eq!(ekman.wind_at(f64::NAN), Vector3::zeros());
1072 assert_eq!(ekman.wind_at(-10.0), Vector3::zeros());
1074 assert_eq!(log.wind_at(-10.0), Vector3::zeros());
1075 }
1076
1077 #[test]
1078 fn single_level_profile_is_uniform_with_height() {
1079 let profile = ProfileField::new(vec![level(150.0, 4.0, 45.0)]).unwrap();
1080 let w = profile.wind_at(150.0);
1081 for y in [-1e3, 0.0, 149.0, 151.0, 1e5] {
1082 assert_eq!(profile.wind_at(y), w);
1083 }
1084 }
1085
1086 #[test]
1089 fn every_level_reproduces_its_own_vector_exactly() {
1090 let levels = vec![
1092 level(0.0, 3.0, 350.0),
1093 level(100.0, 5.0, 10.0),
1094 level(250.0, 8.0, 100.0),
1095 level(330.0, 6.5, 370.0),
1096 level(400.0, 2.0, 359.0),
1097 ];
1098 let profile = ProfileField::new(levels.clone()).unwrap();
1099 for l in levels {
1100 assert_eq!(
1101 profile.wind_at(l.altitude_m),
1102 wind_vector(l.speed_mps, direction_to_rad(l.direction_from_deg), 0.0),
1103 "level at {} m",
1104 l.altitude_m
1105 );
1106 }
1107 }
1108
1109 #[test]
1110 fn opposite_directions_turn_clockwise_however_written() {
1111 for (lo, hi) in [
1113 (0.0, 180.0),
1114 (0.0, -180.0),
1115 (180.0, 0.0),
1116 (180.0, 360.0),
1117 (-180.0, 0.0),
1118 (190.0, 10.0),
1119 (190.0, 370.0),
1120 ] {
1121 let profile =
1122 ProfileField::new(vec![level(0.0, 5.0, lo), level(100.0, 5.0, hi)]).unwrap();
1123 let expected = (lo + 90.0_f64).rem_euclid(360.0);
1124 let mid = direction_from(profile.wind_at(50.0));
1125 assert!(
1126 angle_diff(mid, expected).abs() < 1e-9,
1127 "{lo} -> {hi}: {mid}"
1128 );
1129 }
1130 }
1131
1132 #[test]
1133 fn huge_level_angles_still_interpolate() {
1134 let lo_dir = 1e300_f64;
1135 let profile =
1136 ProfileField::new(vec![level(0.0, 5.0, lo_dir), level(100.0, 5.0, 10.0)]).unwrap();
1137 let lo_n = lo_dir.rem_euclid(360.0);
1138 let mut delta = 10.0 - lo_n;
1139 if delta > 180.0 {
1140 delta -= 360.0;
1141 } else if delta <= -180.0 {
1142 delta += 360.0;
1143 }
1144 for frac in [0.25, 0.5, 0.75] {
1145 let want = (lo_n + frac * delta).rem_euclid(360.0);
1146 let got = direction_from(profile.wind_at(100.0 * frac));
1147 assert!(
1148 angle_diff(got, want).abs() < 1e-6,
1149 "frac {frac}: {got} vs {want}"
1150 );
1151 }
1152 }
1153
1154 #[test]
1155 fn extreme_altitude_spans_stay_finite() {
1156 let profile =
1157 ProfileField::new(vec![level(-1e308, 2.0, 0.0), level(1e308, 6.0, 0.0)]).unwrap();
1158 assert!((speed(profile.wind_at(0.0)) - 4.0).abs() < 1e-12);
1159 assert_eq!(profile.wind_at(1e308), horizontal(6.0, 0.0));
1160 assert_eq!(profile.wind_at(f64::INFINITY), horizontal(6.0, 0.0));
1161 assert_eq!(profile.wind_at(f64::NEG_INFINITY), horizontal(2.0, 0.0));
1162 }
1163
1164 #[test]
1165 fn log_profile_is_finite_for_every_accepted_roughness() {
1166 for z0 in [1e-310, 1e-300, 1e-12, 0.03, 9.999] {
1167 let log = LogProfile::new(8.0, 10.0, z0, 30.0).unwrap();
1168 assert!((log.speed_at(10.0) - 8.0).abs() < 1e-12, "z0 {z0}");
1169 for z in [z0 * 2.0, 1.0, 1e9, f64::MAX] {
1170 let w = log.wind_at(z);
1171 assert!(w.iter().all(|c| c.is_finite()), "z0 {z0} z {z}: {w:?}");
1172 }
1173 }
1174 let z0 = f64::from_bits(10.0_f64.to_bits() - 1);
1177 match LogProfile::new(8.0, 10.0, z0, 0.0) {
1178 Err(e) => assert_eq!(
1179 e,
1180 WindFieldError::InvalidParameter {
1181 param: WindFieldParam::RoughnessM,
1182 rule: WindFieldRule::FiniteAndPositiveBelowReferenceHeight
1183 }
1184 ),
1185 Ok(log) => assert!((log.speed_at(10.0) - 8.0).abs() < 1e-9),
1186 }
1187 let log = LogProfile::new(8.0, 10.0, 0.03, 30.0).unwrap();
1189 assert_eq!(log.wind_at(f64::INFINITY), Vector3::zeros());
1190 assert_eq!(log.wind_at(f64::NEG_INFINITY), Vector3::zeros());
1191 }
1192
1193 #[test]
1194 fn ekman_rejects_underflowing_latitude_and_overflowing_decay() {
1195 let invalid = |param, rule| WindFieldError::InvalidParameter { param, rule };
1196 assert_eq!(
1197 EkmanProfile::new(12.0, 1e-322, 5.0, 0.0).unwrap_err(),
1198 invalid(
1199 WindFieldParam::LatitudeDeg,
1200 WindFieldRule::FiniteNonzeroAndOffThePoles
1201 )
1202 );
1203 assert_eq!(
1204 EkmanProfile::new(12.0, 45.0, 1e-320, 0.0).unwrap_err(),
1205 invalid(
1206 WindFieldParam::EddyViscosityM2s,
1207 WindFieldRule::FiniteAndPositive
1208 )
1209 );
1210 assert!(EkmanProfile::new(12.0, 1e-6, 5.0, 0.0).is_ok());
1212 assert!(EkmanProfile::new(12.0, 45.0, 1e-300, 0.0).is_ok());
1213 }
1214
1215 #[test]
1216 fn ekman_is_finite_at_every_height_and_geostrophic_at_infinity() {
1217 for k in [1e-300, 5.0, 1e6] {
1218 let ekman = EkmanProfile::new(12.0, -30.0, k, 75.0).unwrap();
1219 for z in [1e-300, 1e-15, 1.0, 1e4, 1e200, f64::MAX, f64::INFINITY] {
1220 let w = ekman.wind_at(z);
1221 assert!(w.iter().all(|c| c.is_finite()), "k {k} z {z}: {w:?}");
1222 }
1223 let top = ekman.wind_at(f64::INFINITY);
1224 assert!((speed(top) - 12.0).abs() < 1e-12);
1225 assert!(angle_diff(direction_from(top), 75.0).abs() < 1e-9);
1226 }
1227 }
1228
1229 #[test]
1230 fn ekman_surface_turn_holds_at_tiny_heights() {
1231 let north = EkmanProfile::new(10.0, 45.0, 5.0, 0.0).unwrap();
1232 let south = EkmanProfile::new(10.0, -45.0, 5.0, 0.0).unwrap();
1233 for z in [1e-15, 1e-13, 1e-9, 1e-5] {
1234 let n = angle_diff(direction_from(north.wind_at(z)), 0.0);
1235 let s = angle_diff(direction_from(south.wind_at(z)), 0.0);
1236 assert!((n + 45.0).abs() < 1e-3, "north z {z}: {n}");
1237 assert!((s - 45.0).abs() < 1e-3, "south z {z}: {s}");
1238 }
1239 }
1240
1241 #[test]
1244 fn height_offset_reads_the_inner_field_higher() {
1245 let profile =
1246 ProfileField::new(vec![level(0.0, 2.0, 30.0), level(800.0, 9.0, 80.0)]).unwrap();
1247 let shifted = HeightOffset::new(&profile, 250.0).unwrap();
1248 for (t, x, y, z) in [
1249 (0.0, 0.0, 0.0, 0.0),
1250 (3.5, 120.0, 40.0, -2.0),
1251 (1.0, -5.0, -300.0, 9.0),
1252 ] {
1253 assert_eq!(
1254 shifted.wind(t, Vector3::new(x, y, z)),
1255 profile.wind(t, Vector3::new(x, y + 250.0, z))
1256 );
1257 }
1258 assert_eq!(shifted.offset_m(), 250.0);
1259 assert_eq!(*shifted.field(), &profile);
1260 let same = HeightOffset::new(&profile, 0.0).unwrap();
1262 assert_eq!(same.wind(0.0, at_height(123.0)), profile.wind_at(123.0));
1263 }
1264
1265 #[test]
1266 fn a_sea_level_sounding_matches_a_ground_frame_through_the_site_elevation() {
1267 let msl = ProfileField::new(vec![
1271 level(287.0, 0.5, 120.0),
1272 level(305.0, 3.0867, 125.0),
1273 level(610.0, 3.6011, 145.0),
1274 ])
1275 .unwrap();
1276 let site = HeightOffset::new(msl.clone(), 287.0).unwrap();
1277 assert_eq!(site.wind(0.0, Vector3::zeros()), horizontal(0.5, 120.0));
1278 assert_eq!(site.wind(0.0, at_height(323.0)), horizontal(3.6011, 145.0));
1279 assert_eq!(
1281 msl.wind(0.0, at_height(287.0 - 1.0)),
1282 horizontal(0.5, 120.0)
1283 );
1284 assert_ne!(msl.wind(0.0, at_height(323.0)), horizontal(3.6011, 145.0));
1285 }
1286
1287 #[test]
1288 fn a_frame_with_y_zero_at_the_bore_reads_a_log_profile_at_the_bore_height() {
1289 let log = LogProfile::new(8.0, 10.0, 0.03, 90.0).unwrap();
1290 let from_bore = HeightOffset::new(log, 1.5).unwrap();
1291 assert_eq!(from_bore.wind(0.0, Vector3::zeros()), log.wind_at(1.5));
1292 assert!(speed(from_bore.wind(0.0, Vector3::zeros())) > 0.0);
1293 assert_eq!(from_bore.into_inner(), log);
1294 }
1295
1296 #[test]
1297 fn a_sea_level_frame_reads_a_log_profile_through_minus_the_ground_elevation() {
1298 let ground_msl = 1609.0;
1301 let log = LogProfile::new(8.0, 10.0, 0.03, 270.0).unwrap();
1302 let in_msl_frame = HeightOffset::new(log, -ground_msl).unwrap();
1303 assert_eq!(
1304 in_msl_frame.wind(0.0, at_height(ground_msl + 10.0)),
1305 log.wind_at(10.0)
1306 );
1307 assert_eq!(
1308 in_msl_frame.wind(0.0, at_height(ground_msl)),
1309 Vector3::zeros()
1310 );
1311 assert!((speed(in_msl_frame.wind(0.0, at_height(ground_msl + 10.0))) - 8.0).abs() < 1e-12);
1312
1313 let sounding_msl =
1314 ProfileField::new(vec![level(1609.0, 1.0, 200.0), level(2609.0, 9.0, 250.0)]).unwrap();
1315 assert_eq!(
1316 sounding_msl.wind(0.0, at_height(2109.0)),
1317 sounding_msl.wind_at(2109.0)
1318 );
1319 let double = HeightOffset::new(&sounding_msl, ground_msl).unwrap();
1322 assert_ne!(
1323 double.wind(0.0, at_height(2109.0)),
1324 sounding_msl.wind_at(2109.0)
1325 );
1326 }
1327
1328 #[test]
1329 fn height_offset_must_be_finite() {
1330 for offset in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1331 assert_eq!(
1332 HeightOffset::new(ConstantField(Vector3::zeros()), offset).unwrap_err(),
1333 WindFieldError::InvalidParameter {
1334 param: WindFieldParam::OffsetM,
1335 rule: WindFieldRule::Finite
1336 }
1337 );
1338 }
1339 assert_eq!(
1340 WindFieldError::InvalidParameter {
1341 param: WindFieldParam::OffsetM,
1342 rule: WindFieldRule::Finite
1343 }
1344 .to_string(),
1345 "offset_m must be finite"
1346 );
1347 }
1348
1349 #[test]
1350 fn boxed_and_borrowed_fields_are_fields() {
1351 let boxed: Vec<Box<dyn WindField>> = vec![
1352 Box::new(ConstantField(Vector3::new(1.0, 2.0, 3.0))),
1353 Box::new(LogProfile::new(8.0, 10.0, 0.03, 0.0).unwrap()),
1354 ];
1355 let p = at_height(25.0);
1356 assert_eq!(boxed[0].wind(0.0, p), Vector3::new(1.0, 2.0, 3.0));
1357 fn evaluate<F: WindField>(field: F, p: Vector3<f64>) -> Vector3<f64> {
1358 field.wind(0.0, p)
1359 }
1360 assert_eq!(evaluate(&boxed[1], p), boxed[1].wind(0.0, p));
1361 assert_eq!(
1362 evaluate(boxed.into_iter().next().unwrap(), p),
1363 Vector3::new(1.0, 2.0, 3.0)
1364 );
1365 }
1366}