Skip to main content

hpr_atmos/
dryden.rs

1//! Seeded Dryden turbulence: the spectra, MIL-F-8785C parameters, a generator that is exact for
2//! any step length, and a precomputed gust field.
3//!
4//! Source: MIL-F-8785C, *Military Specification: Flying Qualities of Piloted Airplanes*
5//! (5 November 1980), section 3.7, pinned as `mil-f-8785c` (see `docs/physics/turbulence.md`).
6//!
7//! **Spectra** (§3.7.1.2 and the definitions in §6.2.7). Turbulence is a frozen random field that
8//! the vehicle flies through, so the spectra are functions of spatial frequency `Ω` (rad/m). They
9//! are one-sided, with `∫₀^∞ Φ(Ω) dΩ = σ²`:
10//!
11//! ```text
12//! Φ_u(Ω) = σ_u² (2 L_u / π) / (1 + (L_u Ω)²)
13//! Φ_v(Ω) = σ_v² (L_v / π) (1 + 3 (L_v Ω)²) / (1 + (L_v Ω)²)²
14//! Φ_w(Ω) = σ_w² (L_w / π) (1 + 3 (L_w Ω)²) / (1 + (L_w Ω)²)²
15//! ```
16//!
17//! These are MIL-F-8785C's scale lengths. MIL-HDBK-1797 writes the transverse spectra with
18//! `2 L_v` and `2 L_w` and halves those lengths, which gives the same spectra; mixing one
19//! document's lengths with the other's formula is off by a factor of two.
20//!
21//! Their autocorrelations along the path, at separation `ξ`, are
22//!
23//! ```text
24//! R_u(ξ) = σ_u² e^(−|ξ|/L_u)
25//! R_v(ξ) = σ_v² e^(−|ξ|/L_v) (1 − |ξ| / (2 L_v))
26//! ```
27//!
28//! **Generator.** The longitudinal component is a first-order Gauss–Markov process. Each
29//! transverse component is the output `y = (σ/√2)(√3 x₁ + (1 − √3) x₂)` of two states driven by
30//! white noise through `dx₁/ds = (−x₁ + η)/L`, `dx₂/ds = (x₁ − x₂)/L`. Its stationary state
31//! covariance is `P = [[1, ½], [½, ½]]` for every `L`, and the output's autocorrelation is exactly
32//! `R_v` above. A step of length `Δs` (with `r = Δs/L`, `x = 2r`) maps the state through
33//!
34//! ```text
35//! Φ(r) = e^(−r) [[1, 0], [r, 1]]
36//! Q(r) = [[P(1, x), ½ P(2, x)], [½ P(2, x), ½ P(3, x)]],   P(n, x) = γ(n, x)/Γ(n)
37//! ```
38//!
39//! and adds Gaussian noise of covariance `Q = P − Φ P Φᵀ`, written with regularized incomplete
40//! gamma functions so it has no cancellation for tiny steps. The longitudinal state uses
41//! `ρ = e^(−r)` and noise variance `P(1, x) = 1 − ρ²`. Because this is the exact transition of the
42//! continuous process, any sequence of step lengths samples the same field statistics, and
43//! parameters that change between steps keep the state stationary.
44
45use 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
52/// Largest `r = Δs/L` stepped exactly; beyond it the state is redrawn from the stationary
53/// distribution, which differs from the exact transition by less than `e^(−800)`.
54const DECORRELATED: f64 = 800.0;
55
56/// The most samples a [`GustField`] holds (240 MB of `f64`s).
57pub const MAX_GUST_FIELD_SAMPLES: usize = 10_000_000;
58
59/// The international foot, m.
60const FOOT_M: f64 = 0.3048;
61
62/// The international knot, m/s (1852 m per hour).
63const KNOT_M_S: f64 = 1852.0 / 3600.0;
64
65/// Lower end of the low-altitude formulas, ft.
66const LOW_ALTITUDE_MIN_FT: f64 = 10.0;
67
68/// Upper end of the low-altitude formulas, ft.
69const LOW_ALTITUDE_MAX_FT: f64 = 1000.0;
70
71/// Top of the low-altitude model, ft (MIL-F-8785C §3.8.1: "approximately 2,000 feet AGL").
72const LOW_ALTITUDE_MODEL_TOP_FT: f64 = 2000.0;
73
74/// Dryden scale length above about 2000 ft, ft (MIL-F-8785C §3.7.2).
75const MEDIUM_HIGH_ALTITUDE_SCALE_LENGTH_FT: f64 = 1750.0;
76
77/// Turbulence severity, which sets the low-altitude reference wind.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
79#[serde(rename_all = "snake_case")]
80pub enum TurbulenceSeverity {
81    /// 15 kt at 20 ft.
82    Light,
83    /// 30 kt at 20 ft.
84    Moderate,
85    /// 45 kt at 20 ft.
86    Severe,
87}
88
89impl TurbulenceSeverity {
90    /// The wind speed at 20 ft that MIL-F-8785C Figure 9 marks for this severity (15, 30 and
91    /// 45 kt), m/s.
92    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/// Intensities and scale lengths of Dryden turbulence.
103///
104/// Components are `u` (longitudinal), `v` (lateral) and `w` (vertical); see [`GustField`] for the
105/// axes they apply along.
106#[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    /// Turbulence with RMS intensities `(σ_u, σ_v, σ_w)` in m/s and scale lengths
139    /// `(L_u, L_v, L_w)` in m.
140    ///
141    /// # Errors
142    ///
143    /// [`AtmosError::Domain`] if an intensity is negative or a scale length is not positive, or
144    /// any value is not finite.
145    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    /// MIL-F-8785C low-altitude turbulence (§3.7.3.4, Figures 10 and 11) at `height_agl_m`
164    /// above the terrain, for a mean wind of `wind_speed_20_ft_m_s` at 20 ft (6.096 m). With `h`
165    /// in feet:
166    ///
167    /// ```text
168    /// L_u = L_v = h / (0.177 + 0.000823 h)^1.2,   L_w = h                      (ft)
169    /// σ_w = 0.1 u₂₀,   σ_u = σ_v = σ_w / (0.177 + 0.000823 h)^0.4
170    /// ```
171    ///
172    /// The formulas hold from 10 to 1000 ft. Below 10 ft this uses the 10 ft values; from 1000 to
173    /// 2000 ft, the specification's figures' values at 1000 ft (`L = 1000 ft`, `σ_u = σ_v = σ_w`).
174    /// The specification applies the low-altitude model only up to about 2000 ft (§3.8.1) and
175    /// gives no blend into the medium/high-altitude model
176    /// ([`DrydenParameters::mil_f_8785c_medium_high_altitude`]), so above 2000 ft this refuses.
177    ///
178    /// # Errors
179    ///
180    /// [`AtmosError::Domain`] if the height is not finite or above 2000 ft (609.6 m), or the wind
181    /// speed is negative or not finite.
182    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    /// MIL-F-8785C medium/high-altitude turbulence (§3.7.2, above about 2000 ft): isotropic, with
212    /// `σ_u = σ_v = σ_w = intensity_m_s` and `L_u = L_v = L_w = 1750 ft` (533.4 m).
213    ///
214    /// The specification gives the intensity against altitude and probability of exceedance only
215    /// as a graph (Figure 7), so the caller supplies it.
216    ///
217    /// # Errors
218    ///
219    /// [`AtmosError::Domain`] if the intensity is negative or not finite.
220    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    /// RMS intensities `(σ_u, σ_v, σ_w)`, m/s.
228    pub fn intensity_m_s(&self) -> DVec3 {
229        self.intensity_m_s
230    }
231
232    /// Scale lengths `(L_u, L_v, L_w)`, m.
233    pub fn scale_length_m(&self) -> DVec3 {
234        self.scale_length_m
235    }
236
237    /// The one-sided spectra `(Φ_u, Φ_v, Φ_w)` at spatial frequency `omega_rad_m` (rad/m), in
238    /// (m/s)² per rad/m.
239    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    /// The autocorrelations `(R_u, R_v, R_w)` at path separation `separation_m`, (m/s)².
254    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
269/// The regularized lower incomplete gamma function `P(n, x) = γ(n, x)/Γ(n)` for `n = 1, 2, 3`
270/// and `x ≥ 0`: `1 − e^(−x) Σ_{k<n} x^k/k!`, summed as `e^(−x) Σ_{k≥n} x^k/k!` for `x < 1` so tiny
271/// arguments keep every digit.
272fn 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    // term = x^k / k!, starting at k = n.
286    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    // For x < 1 each term is less than the last divided by k + 1, so this stops within about 20
293    // terms. At x = 0 every term is zero and it stops at once.
294    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
305/// The exact transition of a normalized transverse state over `r = Δs/L`: the decay `e^(−r)` of
306/// `Φ(r)` and the noise covariance entries `(Q₁₁, Q₁₂, Q₂₂)`.
307fn 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/// The normalized state of a transverse component, with stationary covariance
320/// `[[1, ½], [½, ½]]`.
321#[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    /// Advances by `r = Δs/L ≥ 0`.
339    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        // Cholesky factor of Q. `q11 > 0` for r > 0; at r = 0, Q = 0 and the state is unchanged.
347        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    /// The output for intensity `sigma`: `(σ/√2)(√3 x₁ + (1 − √3) x₂)`.
362    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/// A seeded Dryden turbulence generator that advances along the flight path.
369///
370/// The state is normalized, so the intensities and scale lengths may change from one step to the
371/// next (for example with altitude); within a step they are held constant. Draws come from a
372/// [`SeededRng`] in a fixed order (`u`, then two for `v`, then two for `w`), so the same seed
373/// and the same steps give bit-identical gusts on one platform. (Across platforms the math
374/// library's `exp` and `ln` may differ in the last bit.)
375///
376/// It serializes as its generator and state, so a run can be checkpointed and resumed; a
377/// non-finite state is rejected when deserialized.
378#[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    /// A generator whose initial state is drawn from the stationary distribution using `seed`.
425    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    /// The current gust `(u, v, w)` for `parameters`, m/s.
434    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    /// Moves `distance_m` along the path through turbulence described by `parameters`, and
440    /// returns the gust there.
441    ///
442    /// # Errors
443    ///
444    /// [`AtmosError::Domain`] if the distance is negative or not finite.
445    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/// A gust and whether it was looked up beyond the end of its field.
472#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
473pub struct GustSample {
474    /// Gust velocity `(u, v, w)`, m/s.
475    pub gust_m_s: DVec3,
476    /// `Some` when the distance was outside `[0, length]` and the end sample was held.
477    pub extrapolated: Option<Side>,
478}
479
480/// A precomputed Dryden turbulence realization along a path coordinate, sampled every `spacing`
481/// meters and interpolated linearly, so it is a pure function an adaptive integrator can call
482/// repeatedly.
483///
484/// **Axes.** `u` is the longitudinal component: the Dryden spectrum it carries belongs to the
485/// velocity component along the path through the frozen field, and `v` and `w` are the two
486/// transverse ones. MIL-F-8785C (p. 60) puts `u` along the horizontal *relative* mean wind for
487/// aircraft at low altitude, but for a climbing rocket the path is nearly vertical, so the caller
488/// must align `u` with the path. Getting that wrong does change the statistics: a horizontal gust
489/// given the longitudinal spectrum has twice the transverse power at low frequency and 2/3 of it at
490/// high frequency. The signs of the axes do not matter.
491///
492/// **Path coordinate.** The field does not decide what `s` is: distance flown through the air is
493/// Taylor's hypothesis, but a caller may key it on altitude or on time at a reference speed.
494/// Linear interpolation removes variance at wavelengths near the spacing, so keep the spacing
495/// well below the smallest scale length (a tenth or less).
496///
497/// It serializes as its spacing and samples, and re-checks them when deserialized.
498#[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            // Cast: only reported in the error.
522            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    /// A field covering at least `length_m` (rounded up to a whole number of spacings), with
542    /// samples every `spacing_m` and constant `parameters`.
543    ///
544    /// # Errors
545    ///
546    /// See [`GustField::generate_with`].
547    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    /// A field whose parameters vary along the path: `parameters(s)` is evaluated at each sample
557    /// point `s = k·spacing` and held over the step that ends there (the first sample uses
558    /// `parameters(0)`).
559    ///
560    /// # Errors
561    ///
562    /// - [`AtmosError::Domain`] if the length is negative or not finite, the spacing is not
563    ///   finite and positive, or the field would need more than [`MAX_GUST_FIELD_SAMPLES`].
564    /// - Any error `parameters` returns.
565    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        // Cast: the sample limit is far below 2⁵³.
581        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        // Cast: intervals is a non-negative integer below the sample limit.
589        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(&parameters(0.0)?));
593        for k in 1..=intervals {
594            // Cast: k is below the sample limit, far below 2⁵³.
595            let s = k as f64 * spacing;
596            samples.push(generator.advance(spacing, &parameters(s)?)?);
597        }
598        Ok(GustField {
599            spacing_m: spacing,
600            samples,
601        })
602    }
603
604    /// The spacing between samples, m.
605    pub fn spacing_m(&self) -> f64 {
606        self.spacing_m
607    }
608
609    /// The samples, at `s = 0, spacing, 2·spacing, …`.
610    pub fn samples(&self) -> &[DVec3] {
611        &self.samples
612    }
613
614    /// The path length the field covers, m.
615    pub fn length_m(&self) -> f64 {
616        // Cast: the sample count is below the limit, far below 2⁵³.
617        let intervals = self.samples.len().saturating_sub(1) as f64;
618        intervals * self.spacing_m
619    }
620
621    /// The gust at path coordinate `distance_m`, linearly interpolated; beyond either end the end
622    /// sample is held and flagged.
623    ///
624    /// # Errors
625    ///
626    /// [`AtmosError::Domain`] if the distance is not finite.
627    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        // Cast: 0 ≤ position ≤ the sample count, which fits a usize.
646        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    /// Composite Simpson's rule on `[a, b]` with `n` (even) intervals.
673    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            // Ω = tan(θ)/L maps [0, ∞) onto [0, π/2), where the integrand stays bounded.
689            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    /// The one-sided spectrum is `(2/π) ∫₀^∞ R(ξ) cos(Ωξ) dξ`: the stated spectra and
704    /// autocorrelations are a Fourier pair.
705    #[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    /// `P(n, x)` against `∫₀^x t^(n−1) e^(−t) dt / (n−1)!` by Simpson's rule, and for tiny `x`
728    /// against its series `e^(−x) x^n/n! (1 + x/(n+1) + x²/((n+1)(n+2)))`, whose truncation error
729    /// is below 10⁻¹⁵ there.
730    #[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    /// `Φ P Φᵀ + Q = P` for steps from 10⁻¹² to 10³ scale lengths: every step keeps the state
805    /// stationary.
806    #[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    /// Two steps `a` then `b` equal one step `a + b` in distribution: `Φ(a+b) = Φ(b)Φ(a)` and
818    /// `Q(a+b) = Φ(b) Q(a) Φ(b)ᵀ + Q(b)`. Small steps keep their noise covariance to relative
819    /// precision, which the cancelling closed form would not.
820    #[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    /// The output `(σ/√2)(√3 x₁ + (1 − √3) x₂)` of the stationary state has autocorrelation
845    /// `σ² e^(−r)(1 − r/2)`, which is `R_v` of MIL-F-8785C's Dryden spectrum.
846    #[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    /// In-place iterative radix-2 FFT, `X_k = Σ x_n e^(−2πikn/N)`.
866    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    /// Two-sided spectral density (per cycle/m) of the process sampled every `ds` at frequency
919    /// `f`: `ds Σ_k R(k ds) e^(−2πifk ds)`, summed in closed form. With `ρ = e^(−ds/L)`,
920    /// `ω = 2πf ds` and `z = ρ e^(−iω)`, `Σ ρ^|k| e^(−iωk) = (1 − ρ²)/(1 − 2ρ cos ω + ρ²)` and
921    /// `Σ |k| ρ^|k| e^(−iωk) = 2 Re[z/(1 − z)²]`.
922    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        // (1 − z)² = (1 − zr)² − zi² − 2i(1 − zr)zi... as (dr + i di).
931        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    /// The M1.2 *done when*: a Dryden spectrum test.
966    ///
967    /// A seeded field of 2²⁰ samples one meter apart is cut into 256 segments of 4096. Each is
968    /// Hann-windowed and transformed, and the periodograms are averaged (Bartlett's method).
969    /// Over octave bands of frequency bins from the first above zero to Nyquist, the mean ratio of
970    /// the estimate to theory must fall within 4 standard errors. For a band of `n` bins averaged
971    /// over `K` segments the variance of the mean ratio is
972    /// `[1 + 2ρ₁²(n−1)/n + 2ρ₂²(n−2)/n]/(nK)`, where `ρ₁ = 2/3` and `ρ₂ = 1/6` are the Hann
973    /// window's correlations between the transforms at neighbouring bins and bins two apart
974    /// (the bracket tends to 1.94 for wide bands and is 1 for a single bin). The single-bin and
975    /// two-bin bands at the bottom are loose (±25%, ±21%); the wide bands above (±1–3%) are what
976    /// pin the spectrum.
977    ///
978    /// Theory is the spectrum of the continuous process sampled every meter (its aliased
979    /// spectrum), which the exact discretization produces. Below a tenth of the Nyquist
980    /// frequency it is also checked against MIL-F-8785C's continuous formula itself, to 1%.
981    #[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                    // Two-sided density per cycle/m.
1003                    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            // The sampled spectrum is MIL-F-8785C's: two-sided per cycle/m is π Φ(2πf).
1044            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            // And the variance of the whole record is σ² to 5%. One standard error of it is about
1049            // 1% here (√(2L/N) for the longitudinal component), so this is about 5 of them.
1050            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    /// A field stepped 0.25 m at a time has the 1 m correlation `R(1 m)/σ²` of the process. The
1064    /// sampling error is about 4e-4 (five seeds all within 9e-4), so the 2e-3 bound catches a
1065    /// scale length 10% off in `u` and `v` and 3% off in `w`. It is a check of the stepping loop,
1066    /// not of exactness: an Euler step would differ by only 8e-5 here. Exactness is pinned by
1067    /// `transition_preserves_the_stationary_covariance` and `two_steps_compose_into_one`.
1068    #[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        // A checkpointed generator resumes the same stream.
1103        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        // A zero-length field is one sample.
1139        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        // A step of zero changes nothing; a huge step still gives finite gusts.
1159        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        // Fields and generators re-check what they deserialize.
1165        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    /// MIL-F-8785C Figures 10 and 11 at h = 100 ft for a moderate 30 kt wind at 20 ft, evaluated
1204    /// separately: 0.177 + 0.0823 = 0.2593; L_u = 100/0.2593^1.2 = 505.169 ft; σ_w = 0.1·15.433
1205    /// m/s; σ_u = σ_w/0.2593^0.4 = 1.715849 σ_w.
1206    #[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        // Continuous into the constant values above 1000 ft; held below 10 ft.
1222        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}