Skip to main content

hpr_atmos/
wind.rs

1//! Mean wind models: constant, power law, logarithmic law and tabulated layers.
2//!
3//! **Conventions** (`docs/physics/wind.md`):
4//!
5//! - A wind model returns the velocity of the air in the launch frame's East-North-Up axes, m/s.
6//!   Mean wind is horizontal, so the up component is zero.
7//! - Directions are **meteorological**: the direction the wind blows *from*, clockwise from true
8//!   north, in radians. A 10 m/s wind from the west (`3π/2`) has velocity `(+10, 0, 0)`:
9//!
10//! ```text
11//! v_E = −V sin θ_from,    v_N = −V cos θ_from
12//! ```
13//!
14//! - Models are queried with the geometric height above mean sea level, like the atmosphere
15//!   ([`crate::Atmosphere`]). Laws written in height above ground carry the ground's height.
16//!
17//! Turbulence is separate: see [`crate::dryden`].
18
19use std::f64::consts::{PI, TAU};
20use std::fmt;
21
22use hpr_core::DVec3;
23use hpr_core::interp::Side;
24use serde::{Deserialize, Serialize};
25
26use crate::error::{AtmosError, finite, positive};
27
28/// A wind velocity and whether the model extrapolated to produce it.
29#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
30pub struct WindSample {
31    /// Velocity of the air in East-North-Up axes, m/s.
32    pub velocity_enu_m_s: DVec3,
33    /// `Some` when the height was below or above a tabulated wind's levels, or below ground for
34    /// a law written above ground; `None` otherwise. The power and log laws are not flagged above
35    /// the surface layer they describe.
36    pub extrapolated: Option<Side>,
37}
38
39/// A mean wind model: the wind velocity as a function of height.
40pub trait Wind: fmt::Debug + Send + Sync {
41    /// The wind at geometric height `height_msl_m` above mean sea level.
42    ///
43    /// # Errors
44    ///
45    /// [`AtmosError::Domain`] if the height is not finite.
46    fn wind(&self, height_msl_m: f64) -> Result<WindSample, AtmosError>;
47}
48
49/// East-North-Up velocity of a horizontal wind of `speed_m_s` blowing from `direction_from_rad`.
50pub fn velocity_from_speed_direction(speed_m_s: f64, direction_from_rad: f64) -> DVec3 {
51    DVec3::new(
52        -speed_m_s * direction_from_rad.sin(),
53        -speed_m_s * direction_from_rad.cos(),
54        0.0,
55    )
56}
57
58/// Wraps an angle into `[0, 2π)`.
59fn wrap_direction(angle_rad: f64) -> f64 {
60    let wrapped = angle_rad.rem_euclid(TAU);
61    // rem_euclid can round up to exactly 2π for tiny negative inputs.
62    if wrapped >= TAU { 0.0 } else { wrapped }
63}
64
65fn check_direction(direction_from_rad: f64) -> Result<f64, AtmosError> {
66    Ok(wrap_direction(finite(
67        "wind direction (rad)",
68        direction_from_rad,
69    )?))
70}
71
72fn check_speed(speed_m_s: f64) -> Result<f64, AtmosError> {
73    let speed = finite("wind speed (m/s)", speed_m_s)?;
74    if speed < 0.0 {
75        return Err(AtmosError::Domain {
76            what: "wind speed (m/s)",
77            value: speed,
78        });
79    }
80    Ok(speed)
81}
82
83/// The same wind at every height.
84#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
85#[serde(try_from = "ConstantWindData", into = "ConstantWindData")]
86pub struct ConstantWind {
87    speed_m_s: f64,
88    direction_from_rad: f64,
89}
90
91#[derive(Serialize, Deserialize)]
92#[serde(deny_unknown_fields)]
93struct ConstantWindData {
94    speed_m_s: f64,
95    direction_from_rad: f64,
96}
97
98impl TryFrom<ConstantWindData> for ConstantWind {
99    type Error = AtmosError;
100
101    fn try_from(data: ConstantWindData) -> Result<Self, AtmosError> {
102        ConstantWind::new(data.speed_m_s, data.direction_from_rad)
103    }
104}
105
106impl From<ConstantWind> for ConstantWindData {
107    fn from(wind: ConstantWind) -> Self {
108        ConstantWindData {
109            speed_m_s: wind.speed_m_s,
110            direction_from_rad: wind.direction_from_rad,
111        }
112    }
113}
114
115impl ConstantWind {
116    /// A constant wind of `speed_m_s` from `direction_from_rad` (wrapped into `[0, 2π)`).
117    ///
118    /// # Errors
119    ///
120    /// [`AtmosError::Domain`] if the speed is negative or either value is not finite.
121    pub fn new(speed_m_s: f64, direction_from_rad: f64) -> Result<Self, AtmosError> {
122        Ok(ConstantWind {
123            speed_m_s: check_speed(speed_m_s)?,
124            direction_from_rad: check_direction(direction_from_rad)?,
125        })
126    }
127
128    /// Calm air.
129    pub fn calm() -> Self {
130        ConstantWind {
131            speed_m_s: 0.0,
132            direction_from_rad: 0.0,
133        }
134    }
135
136    /// Wind speed, m/s.
137    pub fn speed_m_s(&self) -> f64 {
138        self.speed_m_s
139    }
140
141    /// Direction the wind blows from, in `[0, 2π)`, rad.
142    pub fn direction_from_rad(&self) -> f64 {
143        self.direction_from_rad
144    }
145}
146
147impl Wind for ConstantWind {
148    fn wind(&self, height_msl_m: f64) -> Result<WindSample, AtmosError> {
149        finite("height (m)", height_msl_m)?;
150        Ok(WindSample {
151            velocity_enu_m_s: velocity_from_speed_direction(
152                self.speed_m_s,
153                self.direction_from_rad,
154            ),
155            extrapolated: None,
156        })
157    }
158}
159
160/// A wind whose speed grows with height above ground as a power law, from one direction:
161///
162/// ```text
163/// V(z) = V_ref (z / z_ref)^α,   z = H − H_ground > 0;   V = 0 for z ≤ 0
164/// ```
165///
166/// Source: NASA/TM-2008-215633, *Terrestrial Environment (Climatic) Criteria Guidelines for Use
167/// in Aerospace Vehicle Development* (2008), §2.2.5.2, eq. 2.1, pinned as `nasa-tm-2008-215633`.
168/// There it describes peak winds below 150 m, with `z_ref = 18.3 m` and exponents from 0.14 to
169/// about 0.2 (Table 2-1); `docs/physics/wind.md` says how to choose one. Heights below ground are
170/// flagged. Heights above the surface layer are not, although the law keeps growing there: pair
171/// it with winds aloft.
172#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
173#[serde(try_from = "PowerLawWindData", into = "PowerLawWindData")]
174pub struct PowerLawWind {
175    reference_speed_m_s: f64,
176    reference_height_agl_m: f64,
177    exponent: f64,
178    direction_from_rad: f64,
179    ground_msl_m: f64,
180}
181
182#[derive(Serialize, Deserialize)]
183#[serde(deny_unknown_fields)]
184struct PowerLawWindData {
185    reference_speed_m_s: f64,
186    reference_height_agl_m: f64,
187    exponent: f64,
188    direction_from_rad: f64,
189    ground_msl_m: f64,
190}
191
192impl TryFrom<PowerLawWindData> for PowerLawWind {
193    type Error = AtmosError;
194
195    fn try_from(d: PowerLawWindData) -> Result<Self, AtmosError> {
196        PowerLawWind::new(
197            d.reference_speed_m_s,
198            d.reference_height_agl_m,
199            d.exponent,
200            d.direction_from_rad,
201            d.ground_msl_m,
202        )
203    }
204}
205
206impl From<PowerLawWind> for PowerLawWindData {
207    fn from(w: PowerLawWind) -> Self {
208        PowerLawWindData {
209            reference_speed_m_s: w.reference_speed_m_s,
210            reference_height_agl_m: w.reference_height_agl_m,
211            exponent: w.exponent,
212            direction_from_rad: w.direction_from_rad,
213            ground_msl_m: w.ground_msl_m,
214        }
215    }
216}
217
218impl PowerLawWind {
219    /// A power-law profile through `reference_speed_m_s` at `reference_height_agl_m` above a
220    /// ground at `ground_msl_m`, with exponent `exponent`, blowing from `direction_from_rad`.
221    ///
222    /// # Errors
223    ///
224    /// [`AtmosError::Domain`] if the speed is negative, the reference height is not positive,
225    /// the exponent is negative, or any value is not finite.
226    pub fn new(
227        reference_speed_m_s: f64,
228        reference_height_agl_m: f64,
229        exponent: f64,
230        direction_from_rad: f64,
231        ground_msl_m: f64,
232    ) -> Result<Self, AtmosError> {
233        let exponent = finite("power-law exponent", exponent)?;
234        if exponent < 0.0 {
235            return Err(AtmosError::Domain {
236                what: "power-law exponent",
237                value: exponent,
238            });
239        }
240        Ok(PowerLawWind {
241            reference_speed_m_s: check_speed(reference_speed_m_s)?,
242            reference_height_agl_m: positive("reference height (m)", reference_height_agl_m)?,
243            exponent,
244            direction_from_rad: check_direction(direction_from_rad)?,
245            ground_msl_m: finite("ground height (m)", ground_msl_m)?,
246        })
247    }
248
249    /// Wind speed at the reference height, m/s.
250    pub fn reference_speed_m_s(&self) -> f64 {
251        self.reference_speed_m_s
252    }
253
254    /// Reference height above ground, m.
255    pub fn reference_height_agl_m(&self) -> f64 {
256        self.reference_height_agl_m
257    }
258
259    /// The exponent `α`.
260    pub fn exponent(&self) -> f64 {
261        self.exponent
262    }
263
264    /// Direction the wind blows from, in `[0, 2π)`, rad.
265    pub fn direction_from_rad(&self) -> f64 {
266        self.direction_from_rad
267    }
268
269    /// Height of the ground above mean sea level, m.
270    pub fn ground_msl_m(&self) -> f64 {
271        self.ground_msl_m
272    }
273}
274
275impl Wind for PowerLawWind {
276    fn wind(&self, height_msl_m: f64) -> Result<WindSample, AtmosError> {
277        let z = finite("height (m)", height_msl_m)? - self.ground_msl_m;
278        let (speed, extrapolated) = if z > 0.0 {
279            let ratio = z / self.reference_height_agl_m;
280            (self.reference_speed_m_s * ratio.powf(self.exponent), None)
281        } else {
282            (0.0, (z < 0.0).then_some(Side::Below))
283        };
284        Ok(WindSample {
285            velocity_enu_m_s: velocity_from_speed_direction(speed, self.direction_from_rad),
286            extrapolated,
287        })
288    }
289}
290
291/// A wind whose speed follows the neutral logarithmic law above ground, from one direction:
292///
293/// ```text
294/// V(z) = V_ref ln(z / z₀) / ln(z_ref / z₀),   z = H − H_ground > z₀;   V = 0 for 0 ≤ z ≤ z₀
295/// ```
296///
297/// with roughness length `z₀`. Sources: MIL-F-8785C §3.7.3.2 (with `z_ref = 20 ft`), and the
298/// neutral surface-layer law `V = (u*/κ) ln(z/z₀)` of WMO-No. 8 (2023), Vol. I, chapter 5 annex,
299/// whose Davenport–Wieringa classes give `z₀ = 0.03 m` for open grassland. Heights below ground
300/// are flagged; see `docs/physics/wind.md`.
301#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
302#[serde(try_from = "LogLawWindData", into = "LogLawWindData")]
303pub struct LogLawWind {
304    reference_speed_m_s: f64,
305    reference_height_agl_m: f64,
306    roughness_length_m: f64,
307    direction_from_rad: f64,
308    ground_msl_m: f64,
309}
310
311#[derive(Serialize, Deserialize)]
312#[serde(deny_unknown_fields)]
313struct LogLawWindData {
314    reference_speed_m_s: f64,
315    reference_height_agl_m: f64,
316    roughness_length_m: f64,
317    direction_from_rad: f64,
318    ground_msl_m: f64,
319}
320
321impl TryFrom<LogLawWindData> for LogLawWind {
322    type Error = AtmosError;
323
324    fn try_from(d: LogLawWindData) -> Result<Self, AtmosError> {
325        LogLawWind::new(
326            d.reference_speed_m_s,
327            d.reference_height_agl_m,
328            d.roughness_length_m,
329            d.direction_from_rad,
330            d.ground_msl_m,
331        )
332    }
333}
334
335impl From<LogLawWind> for LogLawWindData {
336    fn from(w: LogLawWind) -> Self {
337        LogLawWindData {
338            reference_speed_m_s: w.reference_speed_m_s,
339            reference_height_agl_m: w.reference_height_agl_m,
340            roughness_length_m: w.roughness_length_m,
341            direction_from_rad: w.direction_from_rad,
342            ground_msl_m: w.ground_msl_m,
343        }
344    }
345}
346
347impl LogLawWind {
348    /// A logarithmic profile through `reference_speed_m_s` at `reference_height_agl_m` above a
349    /// ground at `ground_msl_m` with roughness length `roughness_length_m`, blowing from
350    /// `direction_from_rad`.
351    ///
352    /// # Errors
353    ///
354    /// [`AtmosError::Domain`] if the speed is negative, the roughness length is not positive, the
355    /// reference height is not above the roughness length, or any value is not finite.
356    pub fn new(
357        reference_speed_m_s: f64,
358        reference_height_agl_m: f64,
359        roughness_length_m: f64,
360        direction_from_rad: f64,
361        ground_msl_m: f64,
362    ) -> Result<Self, AtmosError> {
363        let z0 = positive("roughness length (m)", roughness_length_m)?;
364        let z_ref = positive("reference height (m)", reference_height_agl_m)?;
365        if z_ref <= z0 {
366            return Err(AtmosError::Domain {
367                what: "reference height above the roughness length (m)",
368                value: z_ref,
369            });
370        }
371        Ok(LogLawWind {
372            reference_speed_m_s: check_speed(reference_speed_m_s)?,
373            reference_height_agl_m: z_ref,
374            roughness_length_m: z0,
375            direction_from_rad: check_direction(direction_from_rad)?,
376            ground_msl_m: finite("ground height (m)", ground_msl_m)?,
377        })
378    }
379
380    /// Wind speed at the reference height, m/s.
381    pub fn reference_speed_m_s(&self) -> f64 {
382        self.reference_speed_m_s
383    }
384
385    /// Reference height above ground, m.
386    pub fn reference_height_agl_m(&self) -> f64 {
387        self.reference_height_agl_m
388    }
389
390    /// Roughness length `z₀`, m.
391    pub fn roughness_length_m(&self) -> f64 {
392        self.roughness_length_m
393    }
394
395    /// Direction the wind blows from, in `[0, 2π)`, rad.
396    pub fn direction_from_rad(&self) -> f64 {
397        self.direction_from_rad
398    }
399
400    /// Height of the ground above mean sea level, m.
401    pub fn ground_msl_m(&self) -> f64 {
402        self.ground_msl_m
403    }
404}
405
406impl Wind for LogLawWind {
407    fn wind(&self, height_msl_m: f64) -> Result<WindSample, AtmosError> {
408        let z = finite("height (m)", height_msl_m)? - self.ground_msl_m;
409        let z0 = self.roughness_length_m;
410        let speed = if z > z0 {
411            self.reference_speed_m_s * (z / z0).ln() / (self.reference_height_agl_m / z0).ln()
412        } else {
413            0.0
414        };
415        Ok(WindSample {
416            velocity_enu_m_s: velocity_from_speed_direction(speed, self.direction_from_rad),
417            extrapolated: (z < 0.0).then_some(Side::Below),
418        })
419    }
420}
421
422/// How a [`LayeredWind`] fills in between its levels.
423#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
424#[serde(rename_all = "snake_case")]
425#[non_exhaustive]
426pub enum WindInterpolation {
427    /// Speed linearly in height, and direction linearly along the shorter arc between the two
428    /// levels (clockwise when they are exactly opposite). A wind that veers keeps its speed. A calm
429    /// level (speed 0, whose reported direction means nothing) takes the other level's direction,
430    /// so the wind grows out of calm without turning.
431    #[default]
432    SpeedDirection,
433    /// The East and North components linearly in height, as RocketPy does. Speed dips between
434    /// levels whose directions differ.
435    Components,
436}
437
438/// One level of a [`LayeredWind`].
439#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
440#[serde(deny_unknown_fields)]
441pub struct WindLevel {
442    /// Geometric height above mean sea level, m.
443    pub height_msl_m: f64,
444    /// Wind speed, m/s.
445    pub speed_m_s: f64,
446    /// Direction the wind blows from, clockwise from true north, rad.
447    pub direction_from_rad: f64,
448}
449
450/// A wind tabulated at levels, such as a sounding or a forecast, interpolated between them.
451///
452/// Below the lowest level and above the highest, it holds the end level's wind and flags the
453/// sample. Put the surface observation (for example the 10 m wind) in the table as its lowest
454/// level, so the profile blends from the surface up instead of stepping at the first level
455/// aloft ([Loft lesson L6][l6]: Loft's forecast profiles stepped at the lowest level).
456///
457/// [l6]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l6
458#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
459#[serde(try_from = "LayeredWindData", into = "LayeredWindData")]
460pub struct LayeredWind {
461    levels: Vec<WindLevel>,
462    interpolation: WindInterpolation,
463}
464
465#[derive(Serialize, Deserialize)]
466#[serde(deny_unknown_fields)]
467struct LayeredWindData {
468    levels: Vec<WindLevel>,
469    #[serde(default)]
470    interpolation: WindInterpolation,
471}
472
473impl TryFrom<LayeredWindData> for LayeredWind {
474    type Error = AtmosError;
475
476    fn try_from(data: LayeredWindData) -> Result<Self, AtmosError> {
477        LayeredWind::new(data.levels, data.interpolation)
478    }
479}
480
481impl From<LayeredWind> for LayeredWindData {
482    fn from(wind: LayeredWind) -> Self {
483        LayeredWindData {
484            levels: wind.levels,
485            interpolation: wind.interpolation,
486        }
487    }
488}
489
490impl LayeredWind {
491    /// A tabulated wind from `levels`, with directions wrapped into `[0, 2π)`.
492    ///
493    /// # Errors
494    ///
495    /// - [`AtmosError::NoLevels`] with no levels.
496    /// - [`AtmosError::HeightsNotIncreasing`] unless heights strictly increase.
497    /// - [`AtmosError::Domain`] for a negative speed or any value that is not finite.
498    pub fn new(
499        levels: Vec<WindLevel>,
500        interpolation: WindInterpolation,
501    ) -> Result<Self, AtmosError> {
502        if levels.is_empty() {
503            return Err(AtmosError::NoLevels);
504        }
505        let mut checked = Vec::with_capacity(levels.len());
506        for (index, level) in levels.into_iter().enumerate() {
507            let height = finite("wind level height (m)", level.height_msl_m)?;
508            if let Some(previous) = checked.last().map(|l: &WindLevel| l.height_msl_m)
509                && height <= previous
510            {
511                return Err(AtmosError::HeightsNotIncreasing { index });
512            }
513            checked.push(WindLevel {
514                height_msl_m: height,
515                speed_m_s: check_speed(level.speed_m_s)?,
516                direction_from_rad: check_direction(level.direction_from_rad)?,
517            });
518        }
519        Ok(LayeredWind {
520            levels: checked,
521            interpolation,
522        })
523    }
524
525    /// The levels, lowest first.
526    pub fn levels(&self) -> &[WindLevel] {
527        &self.levels
528    }
529
530    /// How the table interpolates.
531    pub fn interpolation(&self) -> WindInterpolation {
532        self.interpolation
533    }
534}
535
536impl Wind for LayeredWind {
537    fn wind(&self, height_msl_m: f64) -> Result<WindSample, AtmosError> {
538        let z = finite("height (m)", height_msl_m)?;
539        let levels = &self.levels;
540        // `new` guarantees at least one level.
541        let (first, last) = match (levels.first(), levels.last()) {
542            (Some(first), Some(last)) => (first, last),
543            _ => return Err(AtmosError::NoLevels),
544        };
545        let hold = |level: &WindLevel, side| WindSample {
546            velocity_enu_m_s: velocity_from_speed_direction(
547                level.speed_m_s,
548                level.direction_from_rad,
549            ),
550            extrapolated: side,
551        };
552        if z < first.height_msl_m {
553            return Ok(hold(first, Some(Side::Below)));
554        }
555        if z > last.height_msl_m {
556            return Ok(hold(last, Some(Side::Above)));
557        }
558        // First level strictly above z; z ≥ first height, so upper ≥ 1.
559        let upper = levels.partition_point(|l| l.height_msl_m <= z);
560        if upper >= levels.len() {
561            return Ok(hold(last, None));
562        }
563        let (a, b) = (&levels[upper - 1], &levels[upper]);
564        let t = (z - a.height_msl_m) / (b.height_msl_m - a.height_msl_m);
565        let velocity = match self.interpolation {
566            WindInterpolation::SpeedDirection => {
567                let speed = a.speed_m_s + t * (b.speed_m_s - a.speed_m_s);
568                // A calm level's direction is meaningless (reports give 0): use the other's.
569                let (from_a, from_b) = match (a.speed_m_s == 0.0, b.speed_m_s == 0.0) {
570                    (true, false) => (b.direction_from_rad, b.direction_from_rad),
571                    (false, true) => (a.direction_from_rad, a.direction_from_rad),
572                    _ => (a.direction_from_rad, b.direction_from_rad),
573                };
574                let mut turn = from_b - from_a;
575                // Shorter arc, in (−π, π]: exactly opposite winds turn clockwise.
576                if turn > PI {
577                    turn -= TAU;
578                } else if turn <= -PI {
579                    turn += TAU;
580                }
581                velocity_from_speed_direction(speed, from_a + t * turn)
582            }
583            WindInterpolation::Components => {
584                let va = velocity_from_speed_direction(a.speed_m_s, a.direction_from_rad);
585                let vb = velocity_from_speed_direction(b.speed_m_s, b.direction_from_rad);
586                va + t * (vb - va)
587            }
588        };
589        Ok(WindSample {
590            velocity_enu_m_s: velocity,
591            extrapolated: None,
592        })
593    }
594}
595
596/// Any of the mean wind models, tagged by `model` when serialized.
597#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
598#[serde(tag = "model", rename_all = "snake_case")]
599#[non_exhaustive]
600pub enum WindModel {
601    /// [`ConstantWind`].
602    Constant(ConstantWind),
603    /// [`PowerLawWind`].
604    PowerLaw(PowerLawWind),
605    /// [`LogLawWind`].
606    LogLaw(LogLawWind),
607    /// [`LayeredWind`].
608    Layered(LayeredWind),
609}
610
611impl Wind for WindModel {
612    fn wind(&self, height_msl_m: f64) -> Result<WindSample, AtmosError> {
613        match self {
614            WindModel::Constant(w) => w.wind(height_msl_m),
615            WindModel::PowerLaw(w) => w.wind(height_msl_m),
616            WindModel::LogLaw(w) => w.wind(height_msl_m),
617            WindModel::Layered(w) => w.wind(height_msl_m),
618        }
619    }
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625
626    fn deg(degrees: f64) -> f64 {
627        degrees.to_radians()
628    }
629
630    fn assert_close(actual: DVec3, expected: DVec3, tolerance: f64) {
631        assert!(
632            (actual - expected).length() <= tolerance,
633            "{actual:?} vs {expected:?}"
634        );
635    }
636
637    fn speed_and_direction_from(velocity: DVec3) -> (f64, f64) {
638        let speed = velocity.x.hypot(velocity.y);
639        (speed, wrap_direction((-velocity.x).atan2(-velocity.y)))
640    }
641
642    /// Loft lesson L6: a forecast profile blends from the surface wind up to the first level aloft
643    /// instead of stepping there, and it interpolates speed and heading, not just one vector.
644    #[test]
645    fn layered_wind_interpolates_speed_and_heading() {
646        // A 1000 m site: the 10 m wind is 4 m/s from 350°, and 12 m/s from 30° at 2010 m.
647        let wind = LayeredWind::new(
648            vec![
649                WindLevel {
650                    height_msl_m: 1010.0,
651                    speed_m_s: 4.0,
652                    direction_from_rad: deg(350.0),
653                },
654                WindLevel {
655                    height_msl_m: 2010.0,
656                    speed_m_s: 12.0,
657                    direction_from_rad: deg(30.0),
658                },
659            ],
660            WindInterpolation::SpeedDirection,
661        )
662        .unwrap();
663
664        // Halfway: 8 m/s from 10°, turning through north along the shorter arc.
665        let mid = wind.wind(1510.0).unwrap();
666        assert_eq!(mid.extrapolated, None);
667        let (speed, direction) = speed_and_direction_from(mid.velocity_enu_m_s);
668        assert!((speed - 8.0).abs() < 1e-12);
669        assert!((direction - deg(10.0)).abs() < 1e-12);
670
671        // A quarter of the way: 6 m/s from 0°.
672        let (speed, direction) =
673            speed_and_direction_from(wind.wind(1260.0).unwrap().velocity_enu_m_s);
674        assert!((speed - 6.0).abs() < 1e-12);
675        assert!(direction.min(TAU - direction) < 1e-12);
676
677        // No step: just above the surface level the wind is still the surface wind.
678        let near_surface = wind.wind(1010.001).unwrap().velocity_enu_m_s;
679        let surface = velocity_from_speed_direction(4.0, deg(350.0));
680        assert_close(near_surface, surface, 1e-4);
681        assert_close(wind.wind(1010.0).unwrap().velocity_enu_m_s, surface, 1e-15);
682        assert_close(
683            wind.wind(2010.0).unwrap().velocity_enu_m_s,
684            velocity_from_speed_direction(12.0, deg(30.0)),
685            1e-14,
686        );
687    }
688
689    #[test]
690    fn components_interpolation_averages_the_vectors() {
691        let levels = vec![
692            WindLevel {
693                height_msl_m: 0.0,
694                speed_m_s: 10.0,
695                direction_from_rad: deg(270.0),
696            },
697            WindLevel {
698                height_msl_m: 100.0,
699                speed_m_s: 10.0,
700                direction_from_rad: deg(0.0),
701            },
702        ];
703        let components = LayeredWind::new(levels.clone(), WindInterpolation::Components).unwrap();
704        // From the west (+E) and from the north (−N): the average is (5, −5), speed 7.07.
705        assert_close(
706            components.wind(50.0).unwrap().velocity_enu_m_s,
707            DVec3::new(5.0, -5.0, 0.0),
708            1e-14,
709        );
710        // The polar form keeps 10 m/s, from 315°.
711        let polar = LayeredWind::new(levels, WindInterpolation::SpeedDirection).unwrap();
712        let (speed, direction) =
713            speed_and_direction_from(polar.wind(50.0).unwrap().velocity_enu_m_s);
714        assert!((speed - 10.0).abs() < 1e-12);
715        assert!((direction - deg(315.0)).abs() < 1e-12);
716    }
717
718    /// A calm surface reported as "0 knots from 0°" does not make the wind turn on its way up to
719    /// the first level aloft: halfway to 10 m/s from the south it is 5 m/s from the south.
720    #[test]
721    fn wind_grows_out_of_calm_without_turning() {
722        for (calm_below, calm_height) in [(true, 0.0), (false, 1000.0)] {
723            let calm = WindLevel {
724                height_msl_m: calm_height,
725                speed_m_s: 0.0,
726                direction_from_rad: 0.0,
727            };
728            let windy = WindLevel {
729                height_msl_m: 1000.0 - calm_height,
730                speed_m_s: 10.0,
731                direction_from_rad: deg(180.0),
732            };
733            let levels = if calm_below {
734                vec![calm, windy]
735            } else {
736                vec![windy, calm]
737            };
738            let wind = LayeredWind::new(levels, WindInterpolation::SpeedDirection).unwrap();
739            assert_close(
740                wind.wind(500.0).unwrap().velocity_enu_m_s,
741                DVec3::new(0.0, 5.0, 0.0),
742                1e-12,
743            );
744        }
745    }
746
747    #[test]
748    fn opposite_directions_turn_clockwise() {
749        let wind = LayeredWind::new(
750            vec![
751                WindLevel {
752                    height_msl_m: 0.0,
753                    speed_m_s: 5.0,
754                    direction_from_rad: deg(90.0),
755                },
756                WindLevel {
757                    height_msl_m: 10.0,
758                    speed_m_s: 5.0,
759                    direction_from_rad: deg(270.0),
760                },
761            ],
762            WindInterpolation::SpeedDirection,
763        )
764        .unwrap();
765        let (_, direction) = speed_and_direction_from(wind.wind(5.0).unwrap().velocity_enu_m_s);
766        assert!((direction - deg(180.0)).abs() < 1e-12);
767    }
768
769    #[test]
770    fn layered_wind_holds_and_flags_beyond_its_levels() {
771        let wind = LayeredWind::new(
772            vec![
773                WindLevel {
774                    height_msl_m: 100.0,
775                    speed_m_s: 3.0,
776                    direction_from_rad: deg(180.0),
777                },
778                WindLevel {
779                    height_msl_m: 900.0,
780                    speed_m_s: 9.0,
781                    direction_from_rad: deg(200.0),
782                },
783            ],
784            WindInterpolation::default(),
785        )
786        .unwrap();
787        let below = wind.wind(0.0).unwrap();
788        assert_eq!(below.extrapolated, Some(Side::Below));
789        assert_close(below.velocity_enu_m_s, DVec3::new(0.0, 3.0, 0.0), 1e-14);
790        let above = wind.wind(5000.0).unwrap();
791        assert_eq!(above.extrapolated, Some(Side::Above));
792        assert_close(
793            above.velocity_enu_m_s,
794            velocity_from_speed_direction(9.0, deg(200.0)),
795            1e-15,
796        );
797        // A single level is a constant wind with flags.
798        let single = LayeredWind::new(
799            vec![WindLevel {
800                height_msl_m: 10.0,
801                speed_m_s: 2.0,
802                direction_from_rad: 0.0,
803            }],
804            WindInterpolation::default(),
805        )
806        .unwrap();
807        assert_eq!(single.wind(10.0).unwrap().extrapolated, None);
808        assert_eq!(single.wind(11.0).unwrap().extrapolated, Some(Side::Above));
809    }
810
811    #[test]
812    fn meteorological_direction_convention() {
813        // From the west blows toward the east; from the north blows toward the south.
814        let west = ConstantWind::new(10.0, deg(270.0)).unwrap();
815        assert_close(
816            west.wind(0.0).unwrap().velocity_enu_m_s,
817            DVec3::new(10.0, 0.0, 0.0),
818            1e-14,
819        );
820        let north = ConstantWind::new(10.0, 0.0).unwrap();
821        assert_close(
822            north.wind(123.0).unwrap().velocity_enu_m_s,
823            DVec3::new(0.0, -10.0, 0.0),
824            1e-14,
825        );
826        assert_eq!(
827            ConstantWind::calm().wind(0.0).unwrap().velocity_enu_m_s,
828            DVec3::ZERO
829        );
830        // Directions wrap.
831        let wrapped = ConstantWind::new(1.0, deg(-90.0)).unwrap();
832        assert!((wrapped.direction_from_rad - deg(270.0)).abs() < 1e-12);
833    }
834
835    #[test]
836    fn power_law_passes_through_its_reference() {
837        let wind = PowerLawWind::new(5.0, 10.0, 1.0 / 7.0, deg(270.0), 1400.0).unwrap();
838        let at_reference = wind.wind(1410.0).unwrap();
839        assert_close(
840            at_reference.velocity_enu_m_s,
841            DVec3::new(5.0, 0.0, 0.0),
842            1e-14,
843        );
844        let at_80 = wind.wind(1480.0).unwrap().velocity_enu_m_s.x;
845        assert!((at_80 - 5.0 * 8.0_f64.powf(1.0 / 7.0)).abs() < 1e-13);
846        assert_eq!(wind.wind(1400.0).unwrap().velocity_enu_m_s, DVec3::ZERO);
847        let below = wind.wind(1390.0).unwrap();
848        assert_eq!(below.velocity_enu_m_s, DVec3::ZERO);
849        assert_eq!(below.extrapolated, Some(Side::Below));
850    }
851
852    #[test]
853    fn log_law_passes_through_its_reference_and_vanishes_at_the_roughness_length() {
854        let wind = LogLawWind::new(6.0, 10.0, 0.03, deg(180.0), 0.0).unwrap();
855        assert_close(
856            wind.wind(10.0).unwrap().velocity_enu_m_s,
857            DVec3::new(0.0, 6.0, 0.0),
858            1e-14,
859        );
860        let at_100 = wind.wind(100.0).unwrap().velocity_enu_m_s.y;
861        let expected = 6.0 * (100.0_f64 / 0.03).ln() / (10.0_f64 / 0.03).ln();
862        assert!((at_100 - expected).abs() < 1e-13);
863        assert_eq!(wind.wind(0.03).unwrap().velocity_enu_m_s, DVec3::ZERO);
864        assert_eq!(wind.wind(-1.0).unwrap().extrapolated, Some(Side::Below));
865    }
866
867    #[test]
868    fn invalid_inputs_are_rejected() {
869        assert!(ConstantWind::new(-1.0, 0.0).is_err());
870        assert!(ConstantWind::new(1.0, f64::NAN).is_err());
871        assert!(PowerLawWind::new(1.0, 0.0, 0.14, 0.0, 0.0).is_err());
872        assert!(PowerLawWind::new(1.0, 10.0, -0.1, 0.0, 0.0).is_err());
873        assert!(LogLawWind::new(1.0, 0.01, 0.03, 0.0, 0.0).is_err());
874        assert!(LogLawWind::new(1.0, 10.0, 0.0, 0.0, 0.0).is_err());
875        assert!(matches!(
876            LayeredWind::new(vec![], WindInterpolation::default()),
877            Err(AtmosError::NoLevels)
878        ));
879        let level = |h| WindLevel {
880            height_msl_m: h,
881            speed_m_s: 1.0,
882            direction_from_rad: 0.0,
883        };
884        assert!(matches!(
885            LayeredWind::new(vec![level(0.0), level(0.0)], WindInterpolation::default()),
886            Err(AtmosError::HeightsNotIncreasing { index: 1 })
887        ));
888        assert!(ConstantWind::calm().wind(f64::INFINITY).is_err());
889    }
890
891    #[test]
892    fn wind_models_round_trip_through_json() {
893        let models = vec![
894            WindModel::Constant(ConstantWind::new(3.0, 1.0).unwrap()),
895            WindModel::PowerLaw(PowerLawWind::new(5.0, 10.0, 0.14, 2.0, 100.0).unwrap()),
896            WindModel::LogLaw(LogLawWind::new(5.0, 10.0, 0.05, 3.0, 100.0).unwrap()),
897            WindModel::Layered(
898                LayeredWind::new(
899                    vec![WindLevel {
900                        height_msl_m: 5.0,
901                        speed_m_s: 1.0,
902                        direction_from_rad: 0.5,
903                    }],
904                    WindInterpolation::Components,
905                )
906                .unwrap(),
907            ),
908        ];
909        for model in models {
910            let json = serde_json::to_string(&model).unwrap();
911            let back: WindModel = serde_json::from_str(&json).unwrap();
912            assert_eq!(back, model, "{json}");
913            assert_eq!(back.wind(50.0).unwrap(), model.wind(50.0).unwrap());
914        }
915        let negative = r#"{"model":"constant","speed_m_s":-3.0,"direction_from_rad":0.0}"#;
916        assert!(serde_json::from_str::<WindModel>(negative).is_err());
917        let unknown = r#"{"model":"constant","speed_m_s":3.0,"direction_from_rad":0.0,"x":1}"#;
918        assert!(serde_json::from_str::<WindModel>(unknown).is_err());
919    }
920}