1use hpr_core::DVec3;
46use hpr_core::interp::Side;
47use hpr_core::random::SeededRng;
48use serde::{Deserialize, Serialize};
49
50use crate::error::{AtmosError, finite, positive};
51
52const DECORRELATED: f64 = 800.0;
55
56pub const MAX_GUST_FIELD_SAMPLES: usize = 10_000_000;
58
59const FOOT_M: f64 = 0.3048;
61
62const KNOT_M_S: f64 = 1852.0 / 3600.0;
64
65const LOW_ALTITUDE_MIN_FT: f64 = 10.0;
67
68const LOW_ALTITUDE_MAX_FT: f64 = 1000.0;
70
71const LOW_ALTITUDE_MODEL_TOP_FT: f64 = 2000.0;
73
74const MEDIUM_HIGH_ALTITUDE_SCALE_LENGTH_FT: f64 = 1750.0;
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
79#[serde(rename_all = "snake_case")]
80pub enum TurbulenceSeverity {
81 Light,
83 Moderate,
85 Severe,
87}
88
89impl TurbulenceSeverity {
90 pub fn wind_speed_20_ft_m_s(self) -> f64 {
93 let knots = match self {
94 TurbulenceSeverity::Light => 15.0,
95 TurbulenceSeverity::Moderate => 30.0,
96 TurbulenceSeverity::Severe => 45.0,
97 };
98 knots * KNOT_M_S
99 }
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
107#[serde(try_from = "DrydenParametersData", into = "DrydenParametersData")]
108pub struct DrydenParameters {
109 intensity_m_s: DVec3,
110 scale_length_m: DVec3,
111}
112
113#[derive(Serialize, Deserialize)]
114#[serde(deny_unknown_fields)]
115struct DrydenParametersData {
116 intensity_m_s: DVec3,
117 scale_length_m: DVec3,
118}
119
120impl TryFrom<DrydenParametersData> for DrydenParameters {
121 type Error = AtmosError;
122
123 fn try_from(data: DrydenParametersData) -> Result<Self, AtmosError> {
124 DrydenParameters::new(data.intensity_m_s, data.scale_length_m)
125 }
126}
127
128impl From<DrydenParameters> for DrydenParametersData {
129 fn from(p: DrydenParameters) -> Self {
130 DrydenParametersData {
131 intensity_m_s: p.intensity_m_s,
132 scale_length_m: p.scale_length_m,
133 }
134 }
135}
136
137impl DrydenParameters {
138 pub fn new(intensity_m_s: DVec3, scale_length_m: DVec3) -> Result<Self, AtmosError> {
146 for sigma in intensity_m_s.to_array() {
147 if finite("turbulence intensity (m/s)", sigma)? < 0.0 {
148 return Err(AtmosError::Domain {
149 what: "turbulence intensity (m/s)",
150 value: sigma,
151 });
152 }
153 }
154 for length in scale_length_m.to_array() {
155 positive("turbulence scale length (m)", length)?;
156 }
157 Ok(DrydenParameters {
158 intensity_m_s,
159 scale_length_m,
160 })
161 }
162
163 pub fn mil_f_8785c_low_altitude(
183 height_agl_m: f64,
184 wind_speed_20_ft_m_s: f64,
185 ) -> Result<Self, AtmosError> {
186 let height = finite("height above terrain (m)", height_agl_m)?;
187 if height > LOW_ALTITUDE_MODEL_TOP_FT * FOOT_M {
188 return Err(AtmosError::Domain {
189 what: "height above terrain for the low-altitude model (m)",
190 value: height,
191 });
192 }
193 let height_ft = (height / FOOT_M).clamp(LOW_ALTITUDE_MIN_FT, LOW_ALTITUDE_MAX_FT);
194 let u20 = finite("wind speed at 20 ft (m/s)", wind_speed_20_ft_m_s)?;
195 if u20 < 0.0 {
196 return Err(AtmosError::Domain {
197 what: "wind speed at 20 ft (m/s)",
198 value: u20,
199 });
200 }
201 let factor = 0.177 + 0.000_823 * height_ft;
202 let horizontal_length_m = height_ft / factor.powf(1.2) * FOOT_M;
203 let sigma_w = 0.1 * u20;
204 let sigma_horizontal = sigma_w / factor.powf(0.4);
205 DrydenParameters::new(
206 DVec3::new(sigma_horizontal, sigma_horizontal, sigma_w),
207 DVec3::new(horizontal_length_m, horizontal_length_m, height_ft * FOOT_M),
208 )
209 }
210
211 pub fn mil_f_8785c_medium_high_altitude(intensity_m_s: f64) -> Result<Self, AtmosError> {
221 DrydenParameters::new(
222 DVec3::splat(intensity_m_s),
223 DVec3::splat(MEDIUM_HIGH_ALTITUDE_SCALE_LENGTH_FT * FOOT_M),
224 )
225 }
226
227 pub fn intensity_m_s(&self) -> DVec3 {
229 self.intensity_m_s
230 }
231
232 pub fn scale_length_m(&self) -> DVec3 {
234 self.scale_length_m
235 }
236
237 pub fn spectra(&self, omega_rad_m: f64) -> DVec3 {
240 let s = self.intensity_m_s;
241 let l = self.scale_length_m;
242 let longitudinal = {
243 let a = l.x * omega_rad_m;
244 s.x * s.x * (2.0 * l.x / std::f64::consts::PI) / (1.0 + a * a)
245 };
246 let transverse = |sigma: f64, length: f64| {
247 let a2 = (length * omega_rad_m).powi(2);
248 sigma * sigma * (length / std::f64::consts::PI) * (1.0 + 3.0 * a2) / (1.0 + a2).powi(2)
249 };
250 DVec3::new(longitudinal, transverse(s.y, l.y), transverse(s.z, l.z))
251 }
252
253 pub fn autocorrelation(&self, separation_m: f64) -> DVec3 {
255 let s = self.intensity_m_s;
256 let l = self.scale_length_m;
257 let xi = separation_m.abs();
258 let transverse = |sigma: f64, length: f64| {
259 sigma * sigma * (-xi / length).exp() * (1.0 - xi / (2.0 * length))
260 };
261 DVec3::new(
262 s.x * s.x * (-xi / l.x).exp(),
263 transverse(s.y, l.y),
264 transverse(s.z, l.z),
265 )
266 }
267}
268
269fn regularized_gamma(n: u32, x: f64) -> f64 {
273 if x >= 1.0 {
274 if x > DECORRELATED {
275 return 1.0;
276 }
277 let mut partial = 0.0;
278 let mut term = 1.0;
279 for k in 0..n {
280 partial += term;
281 term *= x / f64::from(k + 1);
282 }
283 return 1.0 - (-x).exp() * partial;
284 }
285 let mut term = 1.0;
287 for k in 1..=n {
288 term *= x / f64::from(k);
289 }
290 let mut sum = term;
291 let mut k = n;
292 loop {
295 k += 1;
296 term *= x / f64::from(k);
297 if term <= f64::EPSILON * 0.125 * sum {
298 break;
299 }
300 sum += term;
301 }
302 (-x).exp() * sum
303}
304
305fn transverse_transition(r: f64) -> (f64, [f64; 3]) {
308 let x = 2.0 * r;
309 (
310 (-r).exp(),
311 [
312 regularized_gamma(1, x),
313 0.5 * regularized_gamma(2, x),
314 0.5 * regularized_gamma(3, x),
315 ],
316 )
317}
318
319#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
322#[serde(deny_unknown_fields)]
323struct TransverseState {
324 x1: f64,
325 x2: f64,
326}
327
328impl TransverseState {
329 fn stationary(rng: &mut SeededRng) -> Self {
330 let n1 = rng.standard_normal();
331 let n2 = rng.standard_normal();
332 TransverseState {
333 x1: n1,
334 x2: 0.5 * n1 + 0.5 * n2,
335 }
336 }
337
338 fn advance(&mut self, r: f64, rng: &mut SeededRng) {
340 if r > DECORRELATED {
341 *self = TransverseState::stationary(rng);
342 return;
343 }
344 let (decay, [q11, q12, q22]) = transverse_transition(r);
345 let (x1, x2) = (self.x1, self.x2);
346 let n1 = rng.standard_normal();
348 let n2 = rng.standard_normal();
349 let (w1, w2) = if q11 > 0.0 {
350 let l11 = q11.sqrt();
351 let l21 = q12 / l11;
352 let l22 = (q22 - l21 * l21).max(0.0).sqrt();
353 (l11 * n1, l21 * n1 + l22 * n2)
354 } else {
355 (0.0, 0.0)
356 };
357 self.x1 = decay * x1 + w1;
358 self.x2 = decay * (r * x1 + x2) + w2;
359 }
360
361 fn output(&self, sigma: f64) -> f64 {
363 let sqrt3 = 3.0_f64.sqrt();
364 sigma * std::f64::consts::FRAC_1_SQRT_2 * (sqrt3 * self.x1 + (1.0 - sqrt3) * self.x2)
365 }
366}
367
368#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
379#[serde(try_from = "DrydenGeneratorData", into = "DrydenGeneratorData")]
380pub struct DrydenGenerator {
381 rng: SeededRng,
382 u: f64,
383 v: TransverseState,
384 w: TransverseState,
385}
386
387#[derive(Serialize, Deserialize)]
388#[serde(deny_unknown_fields)]
389struct DrydenGeneratorData {
390 rng: SeededRng,
391 u: f64,
392 v: TransverseState,
393 w: TransverseState,
394}
395
396impl TryFrom<DrydenGeneratorData> for DrydenGenerator {
397 type Error = AtmosError;
398
399 fn try_from(data: DrydenGeneratorData) -> Result<Self, AtmosError> {
400 for value in [data.u, data.v.x1, data.v.x2, data.w.x1, data.w.x2] {
401 finite("turbulence generator state", value)?;
402 }
403 Ok(DrydenGenerator {
404 rng: data.rng,
405 u: data.u,
406 v: data.v,
407 w: data.w,
408 })
409 }
410}
411
412impl From<DrydenGenerator> for DrydenGeneratorData {
413 fn from(generator: DrydenGenerator) -> Self {
414 DrydenGeneratorData {
415 rng: generator.rng,
416 u: generator.u,
417 v: generator.v,
418 w: generator.w,
419 }
420 }
421}
422
423impl DrydenGenerator {
424 pub fn new(seed: u64) -> Self {
426 let mut rng = SeededRng::seed_from_u64(seed);
427 let u = rng.standard_normal();
428 let v = TransverseState::stationary(&mut rng);
429 let w = TransverseState::stationary(&mut rng);
430 DrydenGenerator { rng, u, v, w }
431 }
432
433 pub fn gust_m_s(&self, parameters: &DrydenParameters) -> DVec3 {
435 let s = parameters.intensity_m_s;
436 DVec3::new(s.x * self.u, self.v.output(s.y), self.w.output(s.z))
437 }
438
439 pub fn advance(
446 &mut self,
447 distance_m: f64,
448 parameters: &DrydenParameters,
449 ) -> Result<DVec3, AtmosError> {
450 let ds = finite("turbulence step (m)", distance_m)?;
451 if ds < 0.0 {
452 return Err(AtmosError::Domain {
453 what: "turbulence step (m)",
454 value: ds,
455 });
456 }
457 let l = parameters.scale_length_m;
458 let r = ds / l.x;
459 let n = self.rng.standard_normal();
460 self.u = if r > DECORRELATED {
461 n
462 } else {
463 (-r).exp() * self.u + regularized_gamma(1, 2.0 * r).sqrt() * n
464 };
465 self.v.advance(ds / l.y, &mut self.rng);
466 self.w.advance(ds / l.z, &mut self.rng);
467 Ok(self.gust_m_s(parameters))
468 }
469}
470
471#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
473pub struct GustSample {
474 pub gust_m_s: DVec3,
476 pub extrapolated: Option<Side>,
478}
479
480#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
499#[serde(try_from = "GustFieldData")]
500pub struct GustField {
501 spacing_m: f64,
502 samples: Vec<DVec3>,
503}
504
505#[derive(Deserialize)]
506#[serde(deny_unknown_fields)]
507struct GustFieldData {
508 spacing_m: f64,
509 samples: Vec<DVec3>,
510}
511
512impl TryFrom<GustFieldData> for GustField {
513 type Error = AtmosError;
514
515 fn try_from(data: GustFieldData) -> Result<Self, AtmosError> {
516 let spacing = positive("gust field spacing (m)", data.spacing_m)?;
517 if data.samples.is_empty() {
518 return Err(AtmosError::EmptyGustField);
519 }
520 if data.samples.len() > MAX_GUST_FIELD_SAMPLES {
521 let count = data.samples.len() as f64;
523 return Err(AtmosError::Domain {
524 what: "gust field samples",
525 value: count,
526 });
527 }
528 for sample in &data.samples {
529 for value in sample.to_array() {
530 finite("gust sample (m/s)", value)?;
531 }
532 }
533 Ok(GustField {
534 spacing_m: spacing,
535 samples: data.samples,
536 })
537 }
538}
539
540impl GustField {
541 pub fn generate(
548 seed: u64,
549 parameters: &DrydenParameters,
550 length_m: f64,
551 spacing_m: f64,
552 ) -> Result<Self, AtmosError> {
553 GustField::generate_with(seed, length_m, spacing_m, |_| Ok(*parameters))
554 }
555
556 pub fn generate_with(
566 seed: u64,
567 length_m: f64,
568 spacing_m: f64,
569 mut parameters: impl FnMut(f64) -> Result<DrydenParameters, AtmosError>,
570 ) -> Result<Self, AtmosError> {
571 let length = finite("gust field length (m)", length_m)?;
572 if length < 0.0 {
573 return Err(AtmosError::Domain {
574 what: "gust field length (m)",
575 value: length,
576 });
577 }
578 let spacing = positive("gust field spacing (m)", spacing_m)?;
579 let intervals = (length / spacing).ceil();
580 let limit = MAX_GUST_FIELD_SAMPLES as f64;
582 if intervals + 1.0 > limit {
583 return Err(AtmosError::Domain {
584 what: "gust field samples",
585 value: intervals + 1.0,
586 });
587 }
588 let intervals = intervals as usize;
590 let mut generator = DrydenGenerator::new(seed);
591 let mut samples = Vec::with_capacity(intervals + 1);
592 samples.push(generator.gust_m_s(¶meters(0.0)?));
593 for k in 1..=intervals {
594 let s = k as f64 * spacing;
596 samples.push(generator.advance(spacing, ¶meters(s)?)?);
597 }
598 Ok(GustField {
599 spacing_m: spacing,
600 samples,
601 })
602 }
603
604 pub fn spacing_m(&self) -> f64 {
606 self.spacing_m
607 }
608
609 pub fn samples(&self) -> &[DVec3] {
611 &self.samples
612 }
613
614 pub fn length_m(&self) -> f64 {
616 let intervals = self.samples.len().saturating_sub(1) as f64;
618 intervals * self.spacing_m
619 }
620
621 pub fn gust(&self, distance_m: f64) -> Result<GustSample, AtmosError> {
628 let s = finite("gust field distance (m)", distance_m)?;
629 let (Some(&first), Some(&last)) = (self.samples.first(), self.samples.last()) else {
630 return Err(AtmosError::EmptyGustField);
631 };
632 if s < 0.0 {
633 return Ok(GustSample {
634 gust_m_s: first,
635 extrapolated: Some(Side::Below),
636 });
637 }
638 if s > self.length_m() {
639 return Ok(GustSample {
640 gust_m_s: last,
641 extrapolated: Some(Side::Above),
642 });
643 }
644 let position = s / self.spacing_m;
645 let i = (position.floor() as usize).min(self.samples.len() - 1);
647 let Some(&b) = self.samples.get(i + 1) else {
648 return Ok(GustSample {
649 gust_m_s: last,
650 extrapolated: None,
651 });
652 };
653 let a = self.samples[i];
654 let t = (position - position.floor()).clamp(0.0, 1.0);
655 Ok(GustSample {
656 gust_m_s: a + t * (b - a),
657 extrapolated: None,
658 })
659 }
660}
661
662#[cfg(test)]
663mod tests {
664 use std::f64::consts::{FRAC_PI_2, PI, TAU};
665
666 use super::*;
667
668 fn parameters() -> DrydenParameters {
669 DrydenParameters::new(DVec3::new(1.5, 1.2, 0.9), DVec3::new(40.0, 40.0, 20.0)).unwrap()
670 }
671
672 fn simpson(f: impl Fn(f64) -> f64, a: f64, b: f64, n: usize) -> f64 {
674 let h = (b - a) / n as f64;
675 let mut sum = f(a) + f(b);
676 for i in 1..n {
677 let weight = if i % 2 == 1 { 4.0 } else { 2.0 };
678 sum += weight * f(a + i as f64 * h);
679 }
680 sum * h / 3.0
681 }
682
683 #[test]
684 fn spectra_integrate_to_the_variance() {
685 let p = parameters();
686 let sigma2 = p.intensity_m_s() * p.intensity_m_s();
687 for (component, length) in p.scale_length_m().to_array().into_iter().enumerate() {
688 let integrand = |theta: f64| {
690 let omega = theta.tan() / length;
691 let jacobian = 1.0 / (length * theta.cos().powi(2));
692 p.spectra(omega)[component] * jacobian
693 };
694 let integral = simpson(integrand, 0.0, FRAC_PI_2 - 1e-9, 20_000);
695 let expected = sigma2[component];
696 assert!(
697 (integral / expected - 1.0).abs() < 1e-8,
698 "component {component}: {integral} vs {expected}"
699 );
700 }
701 }
702
703 #[test]
706 fn spectra_are_the_cosine_transforms_of_the_autocorrelations() {
707 let p = parameters();
708 for omega in [0.0, 0.005, 0.02, 0.05, 0.1] {
709 for component in 0..3 {
710 let length = p.scale_length_m()[component];
711 let transform = simpson(
712 |xi| p.autocorrelation(xi)[component] * (omega * xi).cos(),
713 0.0,
714 60.0 * length,
715 200_000,
716 ) * 2.0
717 / PI;
718 let spectrum = p.spectra(omega)[component];
719 assert!(
720 (transform - spectrum).abs() < 1e-7 * spectrum.abs().max(1e-3),
721 "component {component} at Ω = {omega}: {transform} vs {spectrum}"
722 );
723 }
724 }
725 }
726
727 #[test]
731 fn regularized_gamma_matches_independent_references() {
732 let factorial = [1.0, 1.0, 2.0, 6.0];
733 for x in [
734 0.0_f64, 1e-300, 1e-12, 1e-6, 0.01, 0.3, 0.999, 1.0, 2.5, 30.0, 900.0,
735 ] {
736 for n in 1..=3_u32 {
737 let nf = f64::from(n);
738 let ours = regularized_gamma(n, x);
739 let (reference, tolerance) = if x < 1e-5 {
740 let series = (-x).exp() * x.powi(n as i32) / factorial[n as usize]
741 * (1.0 + x / (nf + 1.0) + x * x / ((nf + 1.0) * (nf + 2.0)));
742 (series, 1e-14)
743 } else if x > 60.0 {
744 (1.0, 1e-15)
745 } else {
746 let integral = simpson(|t| t.powi(n as i32 - 1) * (-t).exp(), 0.0, x, 20_000)
747 / factorial[n as usize - 1];
748 (integral, 1e-12)
749 };
750 assert!(
751 (ours - reference).abs() <= tolerance * reference,
752 "P({n}, {x}) = {ours}, reference {reference}"
753 );
754 }
755 }
756 }
757
758 type Mat2 = [[f64; 2]; 2];
759
760 fn mul(a: Mat2, b: Mat2) -> Mat2 {
761 [
762 [
763 a[0][0] * b[0][0] + a[0][1] * b[1][0],
764 a[0][0] * b[0][1] + a[0][1] * b[1][1],
765 ],
766 [
767 a[1][0] * b[0][0] + a[1][1] * b[1][0],
768 a[1][0] * b[0][1] + a[1][1] * b[1][1],
769 ],
770 ]
771 }
772
773 fn transpose(a: Mat2) -> Mat2 {
774 [[a[0][0], a[1][0]], [a[0][1], a[1][1]]]
775 }
776
777 fn add(a: Mat2, b: Mat2) -> Mat2 {
778 [
779 [a[0][0] + b[0][0], a[0][1] + b[0][1]],
780 [a[1][0] + b[1][0], a[1][1] + b[1][1]],
781 ]
782 }
783
784 fn phi(r: f64) -> Mat2 {
785 let (decay, _) = transverse_transition(r);
786 [[decay, 0.0], [decay * r, decay]]
787 }
788
789 fn q(r: f64) -> Mat2 {
790 let (_, [q11, q12, q22]) = transverse_transition(r);
791 [[q11, q12], [q12, q22]]
792 }
793
794 const STATIONARY: Mat2 = [[1.0, 0.5], [0.5, 0.5]];
795
796 fn assert_mat_close(a: Mat2, b: Mat2, tolerance: f64) {
797 for i in 0..2 {
798 for j in 0..2 {
799 assert!((a[i][j] - b[i][j]).abs() <= tolerance, "{a:?} vs {b:?}");
800 }
801 }
802 }
803
804 #[test]
807 fn transition_preserves_the_stationary_covariance() {
808 for exponent in -12..=3 {
809 for mantissa in [1.0, 3.0] {
810 let r = mantissa * 10f64.powi(exponent);
811 let propagated = add(mul(mul(phi(r), STATIONARY), transpose(phi(r))), q(r));
812 assert_mat_close(propagated, STATIONARY, 4.0 * f64::EPSILON);
813 }
814 }
815 }
816
817 #[test]
821 fn two_steps_compose_into_one() {
822 for (a, b) in [
823 (0.3, 0.7),
824 (1e-6, 2e-6),
825 (1e-9, 1e-9),
826 (5.0, 0.01),
827 (2.0, 3.0),
828 ] {
829 assert_mat_close(phi(a + b), mul(phi(b), phi(a)), 1e-15);
830 let composed = add(mul(mul(phi(b), q(a)), transpose(phi(b))), q(b));
831 let direct = q(a + b);
832 for i in 0..2 {
833 for j in 0..2 {
834 let scale = direct[i][j].abs();
835 assert!(
836 (composed[i][j] - direct[i][j]).abs() <= 1e-12 * scale,
837 "({a}, {b}) [{i}][{j}]: {composed:?} vs {direct:?}"
838 );
839 }
840 }
841 }
842 }
843
844 #[test]
847 fn transverse_output_has_the_dryden_autocorrelation() {
848 let sqrt3 = 3.0_f64.sqrt();
849 let c = [sqrt3, 1.0 - sqrt3];
850 let p = parameters();
851 for r in [0.0, 0.1, 0.5, 1.0, 2.0, 4.0, 10.0] {
852 let cov = mul(phi(r), STATIONARY);
853 let value = 0.5
854 * (c[0] * (cov[0][0] * c[0] + cov[0][1] * c[1])
855 + c[1] * (cov[1][0] * c[0] + cov[1][1] * c[1]));
856 let length = p.scale_length_m().z;
857 let expected = p.autocorrelation(r * length).z / p.intensity_m_s().z.powi(2);
858 assert!(
859 (value - expected).abs() < 1e-15,
860 "r = {r}: {value} vs {expected}"
861 );
862 }
863 }
864
865 fn fft(re: &mut [f64], im: &mut [f64]) {
867 let n = re.len();
868 assert!(n.is_power_of_two());
869 let mut j = 0;
870 for i in 1..n {
871 let mut bit = n >> 1;
872 while j & bit != 0 {
873 j ^= bit;
874 bit >>= 1;
875 }
876 j |= bit;
877 if i < j {
878 re.swap(i, j);
879 im.swap(i, j);
880 }
881 }
882 let mut len = 2;
883 while len <= n {
884 let angle = -TAU / len as f64;
885 for start in (0..n).step_by(len) {
886 for k in 0..len / 2 {
887 let (c, s) = ((angle * k as f64).cos(), (angle * k as f64).sin());
888 let (a, b) = (start + k, start + k + len / 2);
889 let tr = re[b] * c - im[b] * s;
890 let ti = re[b] * s + im[b] * c;
891 re[b] = re[a] - tr;
892 im[b] = im[a] - ti;
893 re[a] += tr;
894 im[a] += ti;
895 }
896 }
897 len <<= 1;
898 }
899 }
900
901 #[test]
902 fn test_fft_matches_the_direct_transform() {
903 let mut rng = SeededRng::seed_from_u64(5);
904 let x: Vec<f64> = (0..64).map(|_| rng.standard_normal()).collect();
905 let (mut re, mut im) = (x.clone(), vec![0.0; 64]);
906 fft(&mut re, &mut im);
907 for k in 0..64 {
908 let (mut dr, mut di) = (0.0, 0.0);
909 for (n, &value) in x.iter().enumerate() {
910 let angle = -TAU * (k * n) as f64 / 64.0;
911 dr += value * angle.cos();
912 di += value * angle.sin();
913 }
914 assert!((re[k] - dr).abs() < 1e-12 && (im[k] - di).abs() < 1e-12);
915 }
916 }
917
918 fn sampled_spectrum(sigma: f64, length: f64, transverse: bool, ds: f64, f: f64) -> f64 {
923 let rho = (-ds / length).exp();
924 let omega = TAU * f * ds;
925 let a = (1.0 - rho * rho) / (1.0 - 2.0 * rho * omega.cos() + rho * rho);
926 if !transverse {
927 return ds * sigma * sigma * a;
928 }
929 let (zr, zi) = (rho * omega.cos(), -rho * omega.sin());
930 let (wr, wi) = (1.0 - zr, -zi);
932 let (dr, di) = (wr * wr - wi * wi, 2.0 * wr * wi);
933 let denominator = dr * dr + di * di;
934 let real = (zr * dr + zi * di) / denominator;
935 let r = ds / length;
936 ds * sigma * sigma * (a - r * real)
937 }
938
939 #[test]
940 fn sampled_spectrum_closed_form_matches_the_direct_sum() {
941 for (length, transverse) in [(40.0, false), (40.0, true), (20.0, true)] {
942 for f in [0.0, 0.001, 0.01, 0.1, 0.37, 0.5] {
943 let ds = 1.0;
944 let mut sum = 1.0;
945 for k in 1..4000 {
946 let xi = k as f64 * ds;
947 let rho = (-xi / length).exp();
948 let r = if transverse {
949 rho * (1.0 - xi / (2.0 * length))
950 } else {
951 rho
952 };
953 sum += 2.0 * r * (TAU * f * xi).cos();
954 }
955 let direct = ds * sum;
956 let closed = sampled_spectrum(1.0, length, transverse, ds, f);
957 assert!(
958 (closed - direct).abs() < 1e-9 * direct.abs().max(1e-6),
959 "{f}"
960 );
961 }
962 }
963 }
964
965 #[test]
982 fn dryden_spectrum_matches_theory() {
983 let p = parameters();
984 let (segment, segments, ds) = (4096_usize, 256_usize, 1.0);
985 let field = GustField::generate(8785, &p, (segment * segments) as f64, ds).unwrap();
986 let window: Vec<f64> = (0..segment)
987 .map(|n| 0.5 - 0.5 * (TAU * n as f64 / segment as f64).cos())
988 .collect();
989 let window_power: f64 = window.iter().map(|w| w * w).sum();
990 let mut estimate = vec![[0.0_f64; 3]; segment / 2 + 1];
991 for j in 0..segments {
992 let chunk = &field.samples()[j * segment..(j + 1) * segment];
993 for component in 0..3 {
994 let mut re: Vec<f64> = chunk
995 .iter()
996 .zip(&window)
997 .map(|(g, w)| g[component] * w)
998 .collect();
999 let mut im = vec![0.0; segment];
1000 fft(&mut re, &mut im);
1001 for (k, bin) in estimate.iter_mut().enumerate() {
1002 bin[component] +=
1004 ds * (re[k] * re[k] + im[k] * im[k]) / window_power / segments as f64;
1005 }
1006 }
1007 }
1008
1009 let sigma = p.intensity_m_s();
1010 let length = p.scale_length_m();
1011 let frequency = |k: usize| k as f64 / (segment as f64 * ds);
1012 for component in 0..3 {
1013 let theory = |k: usize| {
1014 sampled_spectrum(
1015 sigma[component],
1016 length[component],
1017 component > 0,
1018 ds,
1019 frequency(k),
1020 )
1021 };
1022 let mut band_start = 1;
1023 while band_start < segment / 2 {
1024 let band_end = (2 * band_start).min(segment / 2);
1025 let n = band_end - band_start;
1026 let ratio = (band_start..band_end)
1027 .map(|k| estimate[k][component] / theory(k))
1028 .sum::<f64>()
1029 / n as f64;
1030 let nf = n as f64;
1031 let (rho1_sq, rho2_sq) = (4.0 / 9.0, 1.0 / 36.0);
1032 let bracket = 1.0
1033 + 2.0 * rho1_sq * (nf - 1.0) / nf
1034 + 2.0 * rho2_sq * (nf - 2.0).max(0.0) / nf;
1035 let standard_error = (bracket / (nf * segments as f64)).sqrt();
1036 assert!(
1037 (ratio - 1.0).abs() < 4.0 * standard_error,
1038 "component {component}, bins {band_start}..{band_end}: ratio {ratio}, \
1039 standard error {standard_error}"
1040 );
1041 band_start = band_end;
1042 }
1043 for k in 8..=segment / 20 {
1045 let continuous = PI * p.spectra(TAU * frequency(k))[component];
1046 assert!((theory(k) / continuous - 1.0).abs() < 0.01, "bin {k}");
1047 }
1048 let variance = field
1051 .samples()
1052 .iter()
1053 .map(|g| g[component].powi(2))
1054 .sum::<f64>()
1055 / field.samples().len() as f64;
1056 assert!(
1057 (variance / sigma[component].powi(2) - 1.0).abs() < 0.05,
1058 "component {component}: variance {variance}"
1059 );
1060 }
1061 }
1062
1063 #[test]
1069 fn quarter_meter_steps_give_the_one_meter_correlation() {
1070 let p = parameters();
1071 let fine = GustField::generate(11, &p, 400_000.0, 0.25).unwrap();
1072 let samples = fine.samples();
1073 for component in 0..3 {
1074 let (mut lag0, mut lag1) = (0.0, 0.0);
1075 for i in 0..samples.len() - 4 {
1076 lag0 += samples[i][component].powi(2);
1077 lag1 += samples[i][component] * samples[i + 4][component];
1078 }
1079 let correlation = lag1 / lag0;
1080 let expected = p.autocorrelation(1.0)[component] / p.intensity_m_s()[component].powi(2);
1081 assert!(
1082 (correlation - expected).abs() < 2e-3,
1083 "component {component}: {correlation} vs {expected}"
1084 );
1085 }
1086 }
1087
1088 #[test]
1089 fn same_seed_gives_bit_identical_fields() {
1090 let p = parameters();
1091 let a = GustField::generate(42, &p, 500.0, 0.5).unwrap();
1092 let b = GustField::generate(42, &p, 500.0, 0.5).unwrap();
1093 let c = GustField::generate(43, &p, 500.0, 0.5).unwrap();
1094 assert_eq!(a.samples().len(), 1001);
1095 assert!(
1096 a.samples()
1097 .iter()
1098 .zip(b.samples())
1099 .all(|(x, y)| x.to_array().map(f64::to_bits) == y.to_array().map(f64::to_bits))
1100 );
1101 assert_ne!(a.samples(), c.samples());
1102 let mut generator = DrydenGenerator::new(9);
1104 generator.advance(3.0, &p).unwrap();
1105 let json = serde_json::to_string(&generator).unwrap();
1106 let mut resumed: DrydenGenerator = serde_json::from_str(&json).unwrap();
1107 for _ in 0..10 {
1108 assert_eq!(
1109 generator.advance(0.7, &p).unwrap(),
1110 resumed.advance(0.7, &p).unwrap()
1111 );
1112 }
1113 }
1114
1115 #[test]
1116 fn gust_field_interpolates_and_flags_its_ends() {
1117 let p = parameters();
1118 let field = GustField::generate(1, &p, 10.0, 2.0).unwrap();
1119 assert_eq!(field.samples().len(), 6);
1120 assert_eq!(field.length_m(), 10.0);
1121 let s = field.samples();
1122 let mid = field.gust(3.0).unwrap();
1123 assert_eq!(mid.extrapolated, None);
1124 assert!((mid.gust_m_s - 0.5 * (s[1] + s[2])).length() < 1e-15);
1125 assert_eq!(field.gust(4.0).unwrap().gust_m_s, s[2]);
1126 assert_eq!(field.gust(10.0).unwrap().gust_m_s, s[5]);
1127 let below = field.gust(-1.0).unwrap();
1128 assert_eq!(
1129 (below.gust_m_s, below.extrapolated),
1130 (s[0], Some(Side::Below))
1131 );
1132 let above = field.gust(10.5).unwrap();
1133 assert_eq!(
1134 (above.gust_m_s, above.extrapolated),
1135 (s[5], Some(Side::Above))
1136 );
1137 assert!(field.gust(f64::NAN).is_err());
1138 let point = GustField::generate(1, &p, 0.0, 1.0).unwrap();
1140 assert_eq!(point.samples().len(), 1);
1141 assert_eq!(point.gust(0.0).unwrap().extrapolated, None);
1142 }
1143
1144 #[test]
1145 fn zero_intensity_is_calm_and_bad_inputs_are_rejected() {
1146 let calm = DrydenParameters::new(DVec3::ZERO, DVec3::splat(100.0)).unwrap();
1147 let field = GustField::generate(3, &calm, 100.0, 1.0).unwrap();
1148 assert!(field.samples().iter().all(|g| *g == DVec3::ZERO));
1149 assert!(DrydenParameters::new(DVec3::new(-1.0, 1.0, 1.0), DVec3::ONE).is_err());
1150 assert!(DrydenParameters::new(DVec3::ONE, DVec3::new(1.0, 0.0, 1.0)).is_err());
1151 assert!(DrydenParameters::new(DVec3::ONE, DVec3::new(1.0, f64::INFINITY, 1.0)).is_err());
1152 let p = parameters();
1153 assert!(GustField::generate(1, &p, -1.0, 1.0).is_err());
1154 assert!(GustField::generate(1, &p, 1.0, 0.0).is_err());
1155 assert!(GustField::generate(1, &p, 1e9, 1e-3).is_err());
1156 let mut generator = DrydenGenerator::new(1);
1157 assert!(generator.advance(-0.1, &p).is_err());
1158 let before = generator.gust_m_s(&p);
1160 assert_eq!(generator.advance(0.0, &p).unwrap(), before);
1161 assert!(generator.advance(1e300, &p).unwrap().is_finite());
1162 let json = r#"{"intensity_m_s":[1,1,1],"scale_length_m":[1,-1,1]}"#;
1163 assert!(serde_json::from_str::<DrydenParameters>(json).is_err());
1164 let field = GustField::generate(2, &p, 5.0, 1.0).unwrap();
1166 let json = serde_json::to_string(&field).unwrap();
1167 assert_eq!(serde_json::from_str::<GustField>(&json).unwrap(), field);
1168 let bad_json = r#"{"spacing_m":0.0,"samples":[[0,0,0]]}"#;
1169 assert!(serde_json::from_str::<GustField>(bad_json).is_err());
1170 let data = |spacing_m, samples| GustFieldData { spacing_m, samples };
1171 assert!(matches!(
1172 GustField::try_from(data(0.0, vec![DVec3::ZERO])),
1173 Err(AtmosError::Domain { .. })
1174 ));
1175 assert_eq!(
1176 GustField::try_from(data(1.0, vec![])),
1177 Err(AtmosError::EmptyGustField)
1178 );
1179 assert!(matches!(
1180 GustField::try_from(data(1.0, vec![DVec3::new(0.0, f64::INFINITY, 0.0)])),
1181 Err(AtmosError::Domain { .. })
1182 ));
1183 let generator = DrydenGenerator::new(4);
1184 let state = |v_x1| DrydenGeneratorData {
1185 rng: generator.rng.clone(),
1186 u: generator.u,
1187 v: TransverseState {
1188 x1: v_x1,
1189 x2: generator.v.x2,
1190 },
1191 w: generator.w,
1192 };
1193 assert_eq!(
1194 DrydenGenerator::try_from(state(generator.v.x1)).unwrap(),
1195 generator
1196 );
1197 assert!(matches!(
1198 DrydenGenerator::try_from(state(f64::NAN)),
1199 Err(AtmosError::Domain { .. })
1200 ));
1201 }
1202
1203 #[test]
1207 fn low_altitude_parameters_follow_the_specification() {
1208 let u20 = TurbulenceSeverity::Moderate.wind_speed_20_ft_m_s();
1209 assert!((u20 - 30.0 * 1852.0 / 3600.0).abs() < 1e-12);
1210 let p = DrydenParameters::mil_f_8785c_low_altitude(100.0 * FOOT_M, u20).unwrap();
1211 let factor: f64 = 0.2593;
1212 let l = p.scale_length_m();
1213 let s = p.intensity_m_s();
1214 assert!((l.x / FOOT_M - 100.0 / factor.powf(1.2)).abs() < 1e-9);
1215 assert!((l.x / FOOT_M - 505.169).abs() < 5e-4);
1216 assert_eq!(l.x, l.y);
1217 assert!((l.z - 30.48).abs() < 1e-12);
1218 assert!((s.z - 0.1 * u20).abs() < 1e-15);
1219 assert!((s.x / s.z - 1.715_849).abs() < 5e-7);
1220 assert_eq!(s.x, s.y);
1221 let top = DrydenParameters::mil_f_8785c_low_altitude(1000.0 * FOOT_M, u20).unwrap();
1223 let above = DrydenParameters::mil_f_8785c_low_altitude(1500.0 * FOOT_M, u20).unwrap();
1224 assert!((top.scale_length_m() - DVec3::splat(304.8)).length() < 1e-9);
1225 assert!((top.intensity_m_s() - DVec3::splat(0.1 * u20)).length() < 1e-12);
1226 assert_eq!(top, above);
1227 assert_eq!(
1228 DrydenParameters::mil_f_8785c_low_altitude(2000.0 * FOOT_M, u20).unwrap(),
1229 top
1230 );
1231 assert!(DrydenParameters::mil_f_8785c_low_altitude(2001.0 * FOOT_M, u20).is_err());
1232 let ground = DrydenParameters::mil_f_8785c_low_altitude(0.0, u20).unwrap();
1233 let ten_feet = DrydenParameters::mil_f_8785c_low_altitude(10.0 * FOOT_M, u20).unwrap();
1234 assert_eq!(ground, ten_feet);
1235 let high = DrydenParameters::mil_f_8785c_medium_high_altitude(2.0).unwrap();
1236 assert_eq!(high.scale_length_m(), DVec3::splat(533.4));
1237 assert!(DrydenParameters::mil_f_8785c_low_altitude(10.0, -1.0).is_err());
1238 }
1239}