Skip to main content

ballistics_engine/wind/
field.rs

1//! Deterministic wind fields: the [`WindField`] trait and its implementations.
2//!
3//! Every field here is stateless: the wind depends only on the time and position passed in, so
4//! integrator substeps, snapshots and seeded reproduction give identical results. Nothing reads
5//! a clock, the network or a random source.
6//!
7//! Conventions match the rest of [`crate::wind`]: world-frame McCoy coordinates (x downrange,
8//! y up, z right), speeds in m/s, and wind-FROM directions in degrees with 0° a headwind and 90°
9//! wind from the right. A field built from a direction gives the same vector as
10//! [`wind_vector`] for that speed and direction.
11
12use super::{wind_vector, WindSock};
13use core::f64::consts::PI;
14use nalgebra::Vector3;
15use thiserror::Error;
16
17/// Earth's rotation rate, rad/s (the value the crate's Coriolis paths use).
18const EARTH_ROTATION_RAD_PER_S: f64 = 7.2921159e-5;
19
20/// A deterministic, stateless wind field: same time and position always
21/// give the same wind, so integrator substeps, snapshots and seeded
22/// reproduction are exact.
23pub trait WindField {
24    /// World-frame air velocity (m/s) at a time and position.
25    fn wind(&self, time_s: f64, position_m: Vector3<f64>) -> Vector3<f64>;
26}
27
28impl<F: WindField + ?Sized> WindField for &F {
29    fn wind(&self, time_s: f64, position_m: Vector3<f64>) -> Vector3<f64> {
30        (**self).wind(time_s, position_m)
31    }
32}
33
34impl<F: WindField + ?Sized> WindField for Box<F> {
35    fn wind(&self, time_s: f64, position_m: Vector3<f64>) -> Vector3<f64> {
36        (**self).wind(time_s, position_m)
37    }
38}
39
40/// The existing segmented wind as a field: the wind at downrange position `x`, exactly
41/// [`WindSock::vector_for_range_stateless`]. Time, height and cross-range position are ignored.
42impl WindField for WindSock {
43    fn wind(&self, _time_s: f64, position_m: Vector3<f64>) -> Vector3<f64> {
44        self.vector_for_range_stateless(position_m.x)
45    }
46}
47
48/// Uniform wind everywhere.
49#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct ConstantField(pub Vector3<f64>);
51
52impl WindField for ConstantField {
53    fn wind(&self, _time_s: f64, _position_m: Vector3<f64>) -> Vector3<f64> {
54        self.0
55    }
56}
57
58/// Reads a field at a shifted height, so a field and its caller can use different height
59/// datums: `wind(t, p)` is the inner field's wind at `p.y + offset_m`, with time and the
60/// horizontal position unchanged.
61///
62/// `offset_m` is the height of the caller's y = 0 in the field's datum. Convert in one place
63/// only: wrapping twice, or also shifting a profile's levels, moves every wind by the offset
64/// again. The usual cases:
65/// - y is height above sea level, as in rockets-engine: a [`LogProfile`] or [`EkmanProfile`],
66///   which measure height from the ground, takes `-` the ground elevation. A sounding
67///   [`ProfileField`] in metres above sea level needs no wrapper there.
68/// - y = 0 is the ground at elevation `E`: a sea-level sounding [`ProfileField`] takes `+E`.
69/// - this crate's [`TrajectorySolver`](crate::TrajectorySolver), whose y = 0 is the level
70///   `muzzle_height` is measured from and whose ground runs along the line of sight: its
71///   `ground_threshold` check and its built-in boundary-layer shear (see
72///   [`crate::wind_shear`]) both take the shot-frame y as the height. So a [`LogProfile`] or
73///   [`EkmanProfile`] takes the bore's real height above the ground minus `muzzle_height` on a
74///   level or an inclined shot alike: 0 when `muzzle_height` is that real height, the real
75///   height when `muzzle_height` is left at 0, as `BallisticInputs::default()` leaves it. Sea
76///   level stays level, so a sea-level sounding takes `+` the
77///   `AtmosphericConditions::altitude` on a level shot, where the solver's own air puts y = 0.
78///   On an inclined shot (shooting angle θ) no constant offset fits: read the sounding at the
79///   altitude the solver's air uses, `altitude + x·sin θ + y·cos θ`. Fields return winds in
80///   level axes; rotate them into the shot frame before comparing them with solver output.
81///
82/// Elevations are those of the place the positions are, not of the station that measured a
83/// sounding.
84#[derive(Debug, Clone, Copy, PartialEq)]
85pub struct HeightOffset<F> {
86    field: F,
87    offset_m: f64,
88}
89
90impl<F> HeightOffset<F> {
91    /// Wrap `field` so it is read `offset_m` metres higher. The offset must be finite.
92    pub fn new(field: F, offset_m: f64) -> Result<Self, WindFieldError> {
93        if !offset_m.is_finite() {
94            return Err(WindFieldError::InvalidParameter {
95                param: WindFieldParam::OffsetM,
96                rule: WindFieldRule::Finite,
97            });
98        }
99        Ok(Self { field, offset_m })
100    }
101
102    /// The wrapped field.
103    pub fn field(&self) -> &F {
104        &self.field
105    }
106
107    /// The height added to every query, metres.
108    pub fn offset_m(&self) -> f64 {
109        self.offset_m
110    }
111
112    /// Unwrap the field.
113    pub fn into_inner(self) -> F {
114        self.field
115    }
116}
117
118impl<F: WindField> WindField for HeightOffset<F> {
119    fn wind(&self, time_s: f64, position_m: Vector3<f64>) -> Vector3<f64> {
120        self.field.wind(
121            time_s,
122            Vector3::new(position_m.x, position_m.y + self.offset_m, position_m.z),
123        )
124    }
125}
126
127/// One level of a vertical profile.
128#[derive(Debug, Clone, Copy, PartialEq)]
129pub struct ProfileLevel {
130    /// Height of the level, metres (the y coordinate of the field's positions).
131    pub altitude_m: f64,
132    /// Horizontal wind speed, m/s (nonnegative).
133    pub speed_mps: f64,
134    /// Wind-FROM direction, degrees: 0° = headwind, 90° = from the right, matching
135    /// [`WindSegment`](super::WindSegment).
136    pub direction_from_deg: f64,
137}
138
139/// Piecewise-linear vertical profile: speed interpolates linearly in
140/// altitude, direction along the shortest circular arc; nearest level holds
141/// outside the range.
142///
143/// The field reads only the height (`position_m.y`), on the same datum as the levels'
144/// `altitude_m`. A query exactly at a level gives that level's own vector, and exactly opposite
145/// neighbouring directions turn clockwise (increasing FROM angle). Vertical wind is zero.
146#[derive(Debug, Clone, PartialEq)]
147pub struct ProfileField {
148    /// Levels sorted by strictly ascending altitude.
149    levels: Vec<ProfileLevel>,
150}
151
152impl ProfileField {
153    /// Build a profile from levels in any order.
154    ///
155    /// Every value must be finite and every speed `>= 0`; these checks run before sorting, so a
156    /// [`WindFieldError::InvalidLevel`] index refers to the caller's own ordering. After
157    /// sorting, two levels at the same altitude are rejected with
158    /// [`WindFieldError::DuplicateAltitude`].
159    pub fn new(levels: Vec<ProfileLevel>) -> Result<Self, WindFieldError> {
160        if levels.is_empty() {
161            return Err(WindFieldError::EmptyProfile);
162        }
163        for (index, level) in levels.iter().enumerate() {
164            let invalid = |param, rule| Err(WindFieldError::InvalidLevel { index, param, rule });
165            if !level.altitude_m.is_finite() {
166                return invalid(WindFieldParam::AltitudeM, WindFieldRule::Finite);
167            }
168            if !level.speed_mps.is_finite() || level.speed_mps < 0.0 {
169                return invalid(
170                    WindFieldParam::SpeedMps,
171                    WindFieldRule::FiniteAndNonNegative,
172                );
173            }
174            if !level.direction_from_deg.is_finite() {
175                return invalid(WindFieldParam::DirectionFromDeg, WindFieldRule::Finite);
176            }
177        }
178
179        let mut indexed: Vec<(usize, ProfileLevel)> = levels.into_iter().enumerate().collect();
180        indexed.sort_by(|a, b| a.1.altitude_m.total_cmp(&b.1.altitude_m));
181        for pair in indexed.windows(2) {
182            let ((ia, a), (ib, b)) = (pair[0], pair[1]);
183            // `==`, not total ordering: -0.0 and 0.0 are the same altitude.
184            if a.altitude_m == b.altitude_m {
185                return Err(WindFieldError::DuplicateAltitude {
186                    first_index: ia.min(ib),
187                    second_index: ia.max(ib),
188                    altitude_m: a.altitude_m,
189                });
190            }
191        }
192
193        Ok(Self {
194            levels: indexed.into_iter().map(|(_, level)| level).collect(),
195        })
196    }
197
198    /// The levels, sorted by ascending altitude.
199    pub fn levels(&self) -> &[ProfileLevel] {
200        &self.levels
201    }
202
203    /// Wind (m/s) at an altitude. A NaN altitude gives zero wind, as
204    /// [`WindSock::vector_for_range_stateless`] does for a NaN range.
205    pub fn wind_at(&self, altitude_m: f64) -> Vector3<f64> {
206        if altitude_m.is_nan() {
207            return Vector3::zeros();
208        }
209        let levels = &self.levels;
210        let first = levels[0];
211        if altitude_m <= first.altitude_m {
212            return horizontal(first.speed_mps, first.direction_from_deg);
213        }
214        for pair in levels.windows(2) {
215            let (lo, hi) = (pair[0], pair[1]);
216            // Strictly below the upper level: a query exactly at a level is answered by that
217            // level itself (the next segment's lower end, or the top level below), so every
218            // level reproduces its own speed and direction exactly.
219            if altitude_m < hi.altitude_m {
220                if altitude_m == lo.altitude_m {
221                    return horizontal(lo.speed_mps, lo.direction_from_deg);
222                }
223                // Halving keeps the span finite for levels near ±f64::MAX and is exact otherwise.
224                let f = (altitude_m / 2.0 - lo.altitude_m / 2.0)
225                    / (hi.altitude_m / 2.0 - lo.altitude_m / 2.0);
226                let speed = lo.speed_mps + f * (hi.speed_mps - lo.speed_mps);
227                // Interpolate between directions reduced to [0, 360), so a huge angle cannot
228                // absorb the turn. Exactly opposite directions turn clockwise (the FROM angle
229                // increases), however the angles are written.
230                let lo_dir = lo.direction_from_deg.rem_euclid(360.0);
231                let mut delta = hi.direction_from_deg.rem_euclid(360.0) - lo_dir;
232                if delta > 180.0 {
233                    delta -= 360.0;
234                } else if delta <= -180.0 {
235                    delta += 360.0;
236                }
237                return horizontal(speed, lo_dir + f * delta);
238            }
239        }
240        let last = levels[levels.len() - 1];
241        horizontal(last.speed_mps, last.direction_from_deg)
242    }
243}
244
245impl WindField for ProfileField {
246    fn wind(&self, _time_s: f64, position_m: Vector3<f64>) -> Vector3<f64> {
247        self.wind_at(position_m.y)
248    }
249}
250
251/// Log-law boundary layer: u(z) = u_ref · ln(z/z0) / ln(z_ref/z0), z > z0;
252/// zero below z0. Constant direction with height.
253///
254/// The field reads only the height (`position_m.y`), which must be the height above the ground;
255/// [`HeightOffset`] converts another frame's heights. Vertical wind is zero.
256#[derive(Debug, Clone, Copy, PartialEq)]
257pub struct LogProfile {
258    reference_speed_mps: f64,
259    reference_height_m: f64,
260    roughness_m: f64,
261    direction_from_deg: f64,
262}
263
264impl LogProfile {
265    /// Build a log-law profile.
266    ///
267    /// `reference_speed_mps` must be finite and `>= 0`, `reference_height_m` finite and `> 0`,
268    /// `roughness_m` (z0) finite, `> 0` and below the reference height, and
269    /// `direction_from_deg` finite.
270    pub fn new(
271        reference_speed_mps: f64,
272        reference_height_m: f64,
273        roughness_m: f64,
274        direction_from_deg: f64,
275    ) -> Result<Self, WindFieldError> {
276        let invalid = |param, rule| Err(WindFieldError::InvalidParameter { param, rule });
277        if !reference_speed_mps.is_finite() || reference_speed_mps < 0.0 {
278            return invalid(
279                WindFieldParam::ReferenceSpeedMps,
280                WindFieldRule::FiniteAndNonNegative,
281            );
282        }
283        if !reference_height_m.is_finite() || reference_height_m <= 0.0 {
284            return invalid(
285                WindFieldParam::ReferenceHeightM,
286                WindFieldRule::FiniteAndPositive,
287            );
288        }
289        // The log difference is the profile's denominator; it must be a usable positive number,
290        // which also rejects a roughness indistinguishable from the reference height.
291        let log_span = reference_height_m.ln() - roughness_m.ln();
292        if !roughness_m.is_finite()
293            || roughness_m <= 0.0
294            || roughness_m >= reference_height_m
295            || !(log_span.is_finite() && log_span > 0.0)
296        {
297            return invalid(
298                WindFieldParam::RoughnessM,
299                WindFieldRule::FiniteAndPositiveBelowReferenceHeight,
300            );
301        }
302        if !direction_from_deg.is_finite() {
303            return invalid(WindFieldParam::DirectionFromDeg, WindFieldRule::Finite);
304        }
305        Ok(Self {
306            reference_speed_mps,
307            reference_height_m,
308            roughness_m,
309            direction_from_deg,
310        })
311    }
312
313    /// Wind speed at the reference height, m/s.
314    pub fn reference_speed_mps(&self) -> f64 {
315        self.reference_speed_mps
316    }
317
318    /// Reference height, metres.
319    pub fn reference_height_m(&self) -> f64 {
320        self.reference_height_m
321    }
322
323    /// Aerodynamic roughness length z0, metres.
324    pub fn roughness_m(&self) -> f64 {
325        self.roughness_m
326    }
327
328    /// Wind-FROM direction, degrees (constant with height).
329    pub fn direction_from_deg(&self) -> f64 {
330        self.direction_from_deg
331    }
332
333    /// Wind speed (m/s) at an altitude: zero at or below the roughness length, and for a NaN or
334    /// infinite altitude (the log law has no finite value there).
335    pub fn speed_at(&self, altitude_m: f64) -> f64 {
336        if !altitude_m.is_finite() || altitude_m <= self.roughness_m {
337            return 0.0;
338        }
339        // Differences of logs rather than logs of ratios: z / z0 overflows for a tiny z0.
340        let z0_ln = self.roughness_m.ln();
341        self.reference_speed_mps * (altitude_m.ln() - z0_ln)
342            / (self.reference_height_m.ln() - z0_ln)
343    }
344
345    /// Wind (m/s) at an altitude.
346    pub fn wind_at(&self, altitude_m: f64) -> Vector3<f64> {
347        horizontal(self.speed_at(altitude_m), self.direction_from_deg)
348    }
349}
350
351impl WindField for LogProfile {
352    fn wind(&self, _time_s: f64, position_m: Vector3<f64>) -> Vector3<f64> {
353        self.wind_at(position_m.y)
354    }
355}
356
357/// Ekman spiral (constant eddy viscosity K; Holton's classic solution):
358/// u = G(1 − e^(−γz)·cos(γz)), v = sign(f)·G·e^(−γz)·sin(γz),
359/// γ = sqrt(|f|/2K), f = 2Ω·sin(latitude). Zero at the surface, approaches
360/// the geostrophic wind aloft, rotated to `direction_from_deg`.
361///
362/// `u` runs along the geostrophic flow and `v` to its left, so in the northern hemisphere the
363/// wind near the surface is backed up to 45° from the geostrophic direction and veers with
364/// height; the southern hemisphere mirrors this. This constant-K solution is linear near the
365/// ground, not logarithmic; [`LogProfile`] is the surface-layer model.
366///
367/// The field reads only the height (`position_m.y`), which must be the height above the ground;
368/// [`HeightOffset`] converts another frame's heights. An infinite height is the geostrophic
369/// limit. Vertical wind is zero.
370#[derive(Debug, Clone, Copy, PartialEq)]
371pub struct EkmanProfile {
372    geostrophic_mps: f64,
373    latitude_deg: f64,
374    eddy_viscosity_m2s: f64,
375    direction_from_deg: f64,
376}
377
378impl EkmanProfile {
379    /// Build an Ekman profile.
380    ///
381    /// `geostrophic_mps` must be finite and `>= 0`, `latitude_deg` finite, nonzero and strictly
382    /// between -90 and 90 (the solution has no boundary layer at the equator), and
383    /// `eddy_viscosity_m2s` finite and `> 0`. `direction_from_deg`, the wind-FROM direction of
384    /// the geostrophic wind aloft, must be finite. A latitude so close to zero that the Coriolis
385    /// parameter underflows counts as the equator, and a viscosity so small that the decay rate
386    /// overflows is rejected.
387    pub fn new(
388        geostrophic_mps: f64,
389        latitude_deg: f64,
390        eddy_viscosity_m2s: f64,
391        direction_from_deg: f64,
392    ) -> Result<Self, WindFieldError> {
393        let invalid = |param, rule| Err(WindFieldError::InvalidParameter { param, rule });
394        if !geostrophic_mps.is_finite() || geostrophic_mps < 0.0 {
395            return invalid(
396                WindFieldParam::GeostrophicMps,
397                WindFieldRule::FiniteAndNonNegative,
398            );
399        }
400        if !latitude_deg.is_finite() || latitude_deg == 0.0 || latitude_deg.abs() >= 90.0 {
401            return invalid(
402                WindFieldParam::LatitudeDeg,
403                WindFieldRule::FiniteNonzeroAndOffThePoles,
404            );
405        }
406        if coriolis_parameter(latitude_deg) == 0.0 {
407            return invalid(
408                WindFieldParam::LatitudeDeg,
409                WindFieldRule::FiniteNonzeroAndOffThePoles,
410            );
411        }
412        if !eddy_viscosity_m2s.is_finite()
413            || eddy_viscosity_m2s <= 0.0
414            || !decay_rate(latitude_deg, eddy_viscosity_m2s).is_finite()
415        {
416            return invalid(
417                WindFieldParam::EddyViscosityM2s,
418                WindFieldRule::FiniteAndPositive,
419            );
420        }
421        if !direction_from_deg.is_finite() {
422            return invalid(WindFieldParam::DirectionFromDeg, WindFieldRule::Finite);
423        }
424        Ok(Self {
425            geostrophic_mps,
426            latitude_deg,
427            eddy_viscosity_m2s,
428            direction_from_deg,
429        })
430    }
431
432    /// Geostrophic wind speed aloft, m/s.
433    pub fn geostrophic_mps(&self) -> f64 {
434        self.geostrophic_mps
435    }
436
437    /// Latitude, degrees (its sign sets the turning sense).
438    pub fn latitude_deg(&self) -> f64 {
439        self.latitude_deg
440    }
441
442    /// Constant eddy viscosity K, m²/s.
443    pub fn eddy_viscosity_m2s(&self) -> f64 {
444        self.eddy_viscosity_m2s
445    }
446
447    /// Wind-FROM direction of the geostrophic wind aloft, degrees.
448    pub fn direction_from_deg(&self) -> f64 {
449        self.direction_from_deg
450    }
451
452    /// The along-flow and left-of-flow components `(u, v)`, m/s.
453    fn components_at(&self, altitude_m: f64) -> (f64, f64) {
454        if altitude_m.is_nan() || altitude_m <= 0.0 {
455            return (0.0, 0.0);
456        }
457        let g = self.geostrophic_mps;
458        let x = decay_rate(self.latitude_deg, self.eddy_viscosity_m2s) * altitude_m;
459        // Past this, e^-x is exactly zero: the geostrophic limit, also for an infinite height
460        // (where cos(x) would be NaN).
461        if x > 746.0 {
462            return (g, 0.0);
463        }
464        let decay = (-x).exp();
465        let (sin_x, cos_x) = x.sin_cos();
466        // 1 - e^-x cos x. Near the surface write it as 2 sin^2(x/2) - cos x * expm1(-x), which
467        // has no cancellation, so the 45° surface turn survives at sub-millimetre heights.
468        let along = if x < 1.0 {
469            let half = (0.5 * x).sin();
470            2.0 * half * half - cos_x * (-x).exp_m1()
471        } else {
472            1.0 - decay * cos_x
473        };
474        let sign = coriolis_parameter(self.latitude_deg).signum();
475        (g * along, sign * g * decay * sin_x)
476    }
477
478    /// Wind (m/s) at an altitude: zero at or below the surface and for a NaN altitude, and the
479    /// geostrophic wind for an infinite one.
480    pub fn wind_at(&self, altitude_m: f64) -> Vector3<f64> {
481        let (u, v) = self.components_at(altitude_m);
482        let from_rad = direction_to_rad(self.direction_from_deg);
483        let (sin, cos) = from_rad.sin_cos();
484        // Along-flow unit vector: wind_vector(1, from_rad, 0) = (-cos, 0, -sin). Its left
485        // (counterclockwise seen from above, with z to the right) is (-sin, 0, cos).
486        Vector3::new(-u * cos - v * sin, 0.0, -u * sin + v * cos)
487    }
488}
489
490impl WindField for EkmanProfile {
491    fn wind(&self, _time_s: f64, position_m: Vector3<f64>) -> Vector3<f64> {
492        self.wind_at(position_m.y)
493    }
494}
495
496/// Which constructor input failed validation.
497#[derive(Debug, Clone, Copy, PartialEq, Eq)]
498pub enum WindFieldParam {
499    /// [`ProfileLevel::altitude_m`].
500    AltitudeM,
501    /// [`ProfileLevel::speed_mps`].
502    SpeedMps,
503    /// A wind-FROM direction ([`ProfileLevel`], [`LogProfile`] or [`EkmanProfile`]).
504    DirectionFromDeg,
505    /// [`LogProfile`] reference speed.
506    ReferenceSpeedMps,
507    /// [`LogProfile`] reference height.
508    ReferenceHeightM,
509    /// [`LogProfile`] roughness length.
510    RoughnessM,
511    /// [`EkmanProfile`] geostrophic speed.
512    GeostrophicMps,
513    /// [`EkmanProfile`] latitude.
514    LatitudeDeg,
515    /// [`EkmanProfile`] eddy viscosity.
516    EddyViscosityM2s,
517    /// [`HeightOffset`] offset.
518    OffsetM,
519}
520
521impl WindFieldParam {
522    /// The parameter name, as it appears in error messages.
523    pub fn name(self) -> &'static str {
524        match self {
525            WindFieldParam::AltitudeM => "altitude_m",
526            WindFieldParam::SpeedMps => "speed_mps",
527            WindFieldParam::DirectionFromDeg => "direction_from_deg",
528            WindFieldParam::ReferenceSpeedMps => "reference_speed_mps",
529            WindFieldParam::ReferenceHeightM => "reference_height_m",
530            WindFieldParam::RoughnessM => "roughness_m",
531            WindFieldParam::GeostrophicMps => "geostrophic_mps",
532            WindFieldParam::LatitudeDeg => "latitude_deg",
533            WindFieldParam::EddyViscosityM2s => "eddy_viscosity_m2s",
534            WindFieldParam::OffsetM => "offset_m",
535        }
536    }
537}
538
539impl core::fmt::Display for WindFieldParam {
540    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
541        f.write_str(self.name())
542    }
543}
544
545/// The rule a constructor input violated.
546#[derive(Debug, Clone, Copy, PartialEq, Eq)]
547pub enum WindFieldRule {
548    /// The value must be finite.
549    Finite,
550    /// The value must be finite and `>= 0`.
551    FiniteAndNonNegative,
552    /// The value must be finite and `> 0`.
553    FiniteAndPositive,
554    /// The roughness length must be finite, `> 0` and below the reference height.
555    FiniteAndPositiveBelowReferenceHeight,
556    /// The latitude must be finite, nonzero and strictly between -90 and 90 degrees.
557    FiniteNonzeroAndOffThePoles,
558}
559
560impl core::fmt::Display for WindFieldRule {
561    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
562        f.write_str(match self {
563            WindFieldRule::Finite => "finite",
564            WindFieldRule::FiniteAndNonNegative => "finite and non-negative",
565            WindFieldRule::FiniteAndPositive => "finite and greater than zero",
566            WindFieldRule::FiniteAndPositiveBelowReferenceHeight => {
567                "finite, greater than zero and below the reference height"
568            }
569            WindFieldRule::FiniteNonzeroAndOffThePoles => {
570                "finite, nonzero and strictly between -90 and 90 degrees"
571            }
572        })
573    }
574}
575
576/// A wind-field constructor input that failed validation.
577#[derive(Debug, Clone, Copy, PartialEq, Error)]
578pub enum WindFieldError {
579    /// A [`ProfileField`] needs at least one level.
580    #[error("wind profile needs at least one level")]
581    EmptyProfile,
582    /// A [`ProfileLevel`] value is invalid. `index` is the level's position in the vector as
583    /// supplied, before sorting.
584    #[error("wind profile levels[{index}].{param} must be {rule}")]
585    InvalidLevel {
586        index: usize,
587        param: WindFieldParam,
588        rule: WindFieldRule,
589    },
590    /// Two [`ProfileLevel`]s share an altitude; altitudes must be strictly ascending once
591    /// sorted. The indices are positions in the vector as supplied.
592    #[error(
593        "wind profile levels[{first_index}] and levels[{second_index}] share altitude_m {altitude_m}; altitudes must be strictly ascending"
594    )]
595    DuplicateAltitude {
596        first_index: usize,
597        second_index: usize,
598        altitude_m: f64,
599    },
600    /// A [`LogProfile`], [`EkmanProfile`] or [`HeightOffset`] parameter is invalid.
601    #[error("{param} must be {rule}")]
602    InvalidParameter {
603        param: WindFieldParam,
604        rule: WindFieldRule,
605    },
606}
607
608/// The Coriolis parameter f = 2Ω·sin(latitude), 1/s.
609fn coriolis_parameter(latitude_deg: f64) -> f64 {
610    2.0 * EARTH_ROTATION_RAD_PER_S * latitude_deg.to_radians().sin()
611}
612
613/// The Ekman decay rate γ = sqrt(|f| / 2K), 1/m.
614fn decay_rate(latitude_deg: f64, eddy_viscosity_m2s: f64) -> f64 {
615    (coriolis_parameter(latitude_deg).abs() / (2.0 * eddy_viscosity_m2s)).sqrt()
616}
617
618/// Degrees to radians with the same arithmetic as [`WindSegment`](super::WindSegment)'s
619/// conversion, so equal angles give bit-identical vectors.
620fn direction_to_rad(direction_deg: f64) -> f64 {
621    if direction_deg.abs() <= f64::MAX / PI {
622        direction_deg * PI / 180.0
623    } else {
624        direction_deg / 180.0 * PI
625    }
626}
627
628/// Horizontal wind from a speed and a wind-FROM direction in degrees.
629fn horizontal(speed_mps: f64, direction_from_deg: f64) -> Vector3<f64> {
630    wind_vector(speed_mps, direction_to_rad(direction_from_deg), 0.0)
631}
632
633#[cfg(test)]
634mod tests {
635    use super::super::WindSegment;
636    use super::*;
637    use core::f64::consts::FRAC_PI_2;
638
639    fn level(altitude_m: f64, speed_mps: f64, direction_from_deg: f64) -> ProfileLevel {
640        ProfileLevel {
641            altitude_m,
642            speed_mps,
643            direction_from_deg,
644        }
645    }
646
647    fn at_height(y: f64) -> Vector3<f64> {
648        Vector3::new(0.0, y, 0.0)
649    }
650
651    /// Horizontal speed of a wind vector.
652    fn speed(w: Vector3<f64>) -> f64 {
653        w.x.hypot(w.z)
654    }
655
656    /// Wind-FROM direction of a wind vector, degrees in [0, 360).
657    fn direction_from(w: Vector3<f64>) -> f64 {
658        (w.z.atan2(w.x).to_degrees() + 180.0).rem_euclid(360.0)
659    }
660
661    /// Signed difference a - b, wrapped to (-180, 180].
662    fn angle_diff(a: f64, b: f64) -> f64 {
663        let d = (a - b).rem_euclid(360.0);
664        if d > 180.0 {
665            d - 360.0
666        } else {
667            d
668        }
669    }
670
671    // ---- Ported from winds-engine tests/profile.rs ----
672
673    #[test]
674    fn wind_direction_round_trips() {
675        // FROM 0° blows toward −X (a headwind); FROM 90° toward −Z.
676        let headwind = horizontal(10.0, 0.0);
677        assert!((headwind.x + 10.0).abs() < 1e-12);
678        assert!(headwind.z.abs() < 1e-12);
679        assert!(direction_from(headwind).abs() < 1e-9);
680        let cross = horizontal(10.0, 90.0);
681        assert!(cross.x.abs() < 1e-12);
682        assert!((cross.z + 10.0).abs() < 1e-12);
683        for deg in [1.0, 90.0, 179.9, 181.0, 270.0, 359.9] {
684            let w = horizontal(7.0, deg);
685            assert!((speed(w) - 7.0).abs() < 1e-9);
686            let back = angle_diff(direction_from(w), deg);
687            assert!(back.abs() < 1e-9, "deg {deg} back {back}");
688        }
689    }
690
691    #[test]
692    fn constant_field_is_constant() {
693        let field = ConstantField(horizontal(5.0, 45.0));
694        assert_eq!(
695            field.wind(0.0, Vector3::zeros()),
696            field.wind(99.0, Vector3::new(1.0, 2.0, 3.0))
697        );
698    }
699
700    #[test]
701    fn profile_interpolates_speed_linearly() {
702        let profile =
703            ProfileField::new(vec![level(0.0, 2.0, 0.0), level(1000.0, 12.0, 0.0)]).unwrap();
704        assert!((speed(profile.wind_at(500.0)) - 7.0).abs() < 1e-12);
705        // Below and above the levels, the nearest holds.
706        assert!((speed(profile.wind_at(-50.0)) - 2.0).abs() < 1e-12);
707        assert!((speed(profile.wind_at(5000.0)) - 12.0).abs() < 1e-12);
708    }
709
710    #[test]
711    fn profile_interpolates_direction_along_the_short_arc() {
712        // 350° to 10°: through 0°, never through 180°.
713        let profile =
714            ProfileField::new(vec![level(0.0, 5.0, 350.0), level(1000.0, 5.0, 10.0)]).unwrap();
715        let mid = direction_from(profile.wind_at(500.0));
716        assert!(!(10.0..=350.0).contains(&mid), "mid {mid}");
717        let quarter = direction_from(profile.wind_at(250.0));
718        assert!(quarter > 350.0, "quarter {quarter}");
719        // And the reverse arc, 10° down to 350°.
720        let reverse =
721            ProfileField::new(vec![level(0.0, 5.0, 10.0), level(1000.0, 5.0, 350.0)]).unwrap();
722        assert!(angle_diff(direction_from(reverse.wind_at(500.0)), 0.0).abs() < 1e-9);
723    }
724
725    #[test]
726    fn profile_validates_its_levels() {
727        assert!(ProfileField::new(vec![]).is_err());
728        assert!(ProfileField::new(vec![level(0.0, -1.0, 0.0)]).is_err());
729        assert!(ProfileField::new(vec![level(0.0, 1.0, 0.0), level(0.0, 2.0, 0.0)]).is_err());
730        // Unsorted input is accepted and sorted.
731        let profile =
732            ProfileField::new(vec![level(1000.0, 12.0, 0.0), level(0.0, 2.0, 0.0)]).unwrap();
733        assert!((speed(profile.wind_at(500.0)) - 7.0).abs() < 1e-12);
734        assert_eq!(profile.levels()[0].altitude_m, 0.0);
735        assert_eq!(profile.levels()[1].altitude_m, 1000.0);
736    }
737
738    #[test]
739    fn log_profile_hits_its_reference_and_roughness() {
740        let log = LogProfile::new(8.0, 10.0, 0.03, 270.0).unwrap();
741        assert!(
742            (log.speed_at(10.0) - 8.0).abs() < 1e-12,
743            "{}",
744            log.speed_at(10.0)
745        );
746        assert_eq!(log.speed_at(0.03), 0.0);
747        assert_eq!(log.speed_at(0.01), 0.0);
748        // Doubling height adds a constant increment in a log law.
749        let increment = log.speed_at(20.0) - log.speed_at(10.0);
750        assert!((increment - (log.speed_at(40.0) - log.speed_at(20.0))).abs() < 1e-12);
751        assert!(LogProfile::new(8.0, 10.0, 20.0, 0.0).is_err());
752        assert!(LogProfile::new(-1.0, 10.0, 0.03, 0.0).is_err());
753    }
754
755    #[test]
756    fn ekman_profile_has_the_right_endpoints() {
757        let ekman = EkmanProfile::new(12.0, 45.0, 5.0, 0.0).unwrap();
758        // Calm at the surface, geostrophic aloft.
759        assert!(speed(ekman.wind_at(0.0)) < 1e-12);
760        let aloft = ekman.wind_at(5_000.0);
761        assert!(
762            (speed(aloft) - 12.0).abs() / 12.0 < 1e-3,
763            "{}",
764            speed(aloft)
765        );
766        // Aloft it comes FROM the requested geostrophic direction.
767        let dir_err = angle_diff(direction_from(aloft), 0.0);
768        assert!(dir_err.abs() < 1.0, "dir_err {dir_err}");
769        // Near the ground the speed is between zero and geostrophic.
770        let low = ekman.wind_at(100.0);
771        assert!(speed(low) > 0.0 && speed(low) < 12.0);
772        assert!(EkmanProfile::new(12.0, 90.0, 5.0, 0.0).is_err());
773        assert!(EkmanProfile::new(12.0, 45.0, 0.0, 0.0).is_err());
774    }
775
776    // ---- Conventions ----
777
778    #[test]
779    fn directions_match_wind_vector_exactly() {
780        for speed_mps in [0.0, 3.0, 12.5] {
781            let at0 = ProfileField::new(vec![level(0.0, speed_mps, 0.0)]).unwrap();
782            assert_eq!(at0.wind_at(10.0), wind_vector(speed_mps, 0.0, 0.0));
783            let at90 = ProfileField::new(vec![level(0.0, speed_mps, 90.0)]).unwrap();
784            assert_eq!(at90.wind_at(10.0), wind_vector(speed_mps, FRAC_PI_2, 0.0));
785        }
786        // A level and a WindSegment with the same speed and angle give the same vector.
787        let segment_speed_kmh = 36.0;
788        let sock = WindSock::try_new(vec![WindSegment::new(segment_speed_kmh, 33.0, 1e6)]).unwrap();
789        let from_sock = sock.vector_for_range_stateless(1.0);
790        let profile = ProfileField::new(vec![level(
791            0.0,
792            segment_speed_kmh * (1000.0 / 3600.0),
793            33.0,
794        )])
795        .unwrap();
796        assert_eq!(profile.wind_at(0.0), from_sock);
797    }
798
799    #[test]
800    fn analytic_profiles_use_the_same_direction_convention() {
801        // Log: constant direction, so a 90° profile blows toward -z at every height.
802        let log = LogProfile::new(8.0, 10.0, 0.03, 90.0).unwrap();
803        let w = log.wind_at(50.0);
804        assert!(w.x.abs() < 1e-12 && w.z < 0.0 && w.y == 0.0);
805        assert!((direction_from(w) - 90.0).abs() < 1e-9);
806        // Ekman aloft: FROM the requested direction, for several directions.
807        for dir in [0.0, 90.0, 180.0, 270.0, 33.0] {
808            let ekman = EkmanProfile::new(10.0, 45.0, 5.0, dir).unwrap();
809            let aloft = ekman.wind_at(20_000.0);
810            assert!(angle_diff(direction_from(aloft), dir).abs() < 1e-6, "{dir}");
811            assert_eq!(aloft.y, 0.0);
812        }
813    }
814
815    #[test]
816    fn ekman_backs_near_the_surface_in_the_north_and_veers_in_the_south() {
817        // Holton: in the northern hemisphere the surface wind is turned toward low pressure,
818        // 45° to the LEFT of (backed from) the geostrophic wind, and the wind veers with
819        // height. "Left" here is counterclockwise seen from above with z to the right, which
820        // lowers the wind-FROM angle. The southern hemisphere mirrors it.
821        let geo_dir = 200.0;
822        let north = EkmanProfile::new(10.0, 45.0, 5.0, geo_dir).unwrap();
823        let south = EkmanProfile::new(10.0, -45.0, 5.0, geo_dir).unwrap();
824        let near = 0.01; // metres: gamma * z << 1, so u ≈ v
825        assert!((angle_diff(direction_from(north.wind_at(near)), geo_dir) + 45.0).abs() < 0.01);
826        assert!((angle_diff(direction_from(south.wind_at(near)), geo_dir) - 45.0).abs() < 0.01);
827        // Veering with height in the north: the FROM angle rises from about geo - 45°.
828        let mut previous = angle_diff(direction_from(north.wind_at(1.0)), geo_dir);
829        for z in [50.0, 100.0, 200.0, 400.0] {
830            let now = angle_diff(direction_from(north.wind_at(z)), geo_dir);
831            assert!(now > previous, "z {z}: {now} <= {previous}");
832            previous = now;
833        }
834    }
835
836    // ---- Adapter ----
837
838    #[test]
839    fn wind_sock_adapter_matches_the_stateless_lookup() {
840        let sock = WindSock::try_new(vec![
841            WindSegment::new(10.0, 90.0, 100.0),
842            WindSegment {
843                speed_kmh: 20.0,
844                angle_deg: 0.0,
845                until_m: 300.0,
846                vertical_mps: 1.5,
847            },
848            WindSegment::new(5.0, 270.0, 600.0),
849        ])
850        .unwrap();
851        for x in [
852            -50.0,
853            0.0,
854            99.999,
855            100.0,
856            100.001,
857            250.0,
858            299.999,
859            300.0,
860            450.0,
861            599.0,
862            600.0,
863            1e6,
864            f64::NAN,
865        ] {
866            for (t, y, z) in [(0.0, 0.0, 0.0), (12.5, 40.0, -3.0), (-1.0, -5.0, 7.0)] {
867                let got = sock.wind(t, Vector3::new(x, y, z));
868                let want = sock.vector_for_range_stateless(x);
869                assert_eq!(got, want, "x {x} t {t} y {y} z {z}");
870            }
871        }
872        // Through a trait object too.
873        let field: &dyn WindField = &sock;
874        assert_eq!(
875            field.wind(0.0, Vector3::new(150.0, 0.0, 0.0)),
876            sock.vector_for_range_stateless(150.0)
877        );
878    }
879
880    // ---- Validation ----
881
882    #[test]
883    fn profile_validation_reports_index_param_and_rule() {
884        let ok = level(0.0, 1.0, 0.0);
885        let cases = [
886            (
887                level(f64::NAN, 1.0, 0.0),
888                WindFieldParam::AltitudeM,
889                WindFieldRule::Finite,
890            ),
891            (
892                level(f64::INFINITY, 1.0, 0.0),
893                WindFieldParam::AltitudeM,
894                WindFieldRule::Finite,
895            ),
896            (
897                level(0.0, -0.5, 0.0),
898                WindFieldParam::SpeedMps,
899                WindFieldRule::FiniteAndNonNegative,
900            ),
901            (
902                level(0.0, f64::INFINITY, 0.0),
903                WindFieldParam::SpeedMps,
904                WindFieldRule::FiniteAndNonNegative,
905            ),
906            (
907                level(0.0, f64::NAN, 0.0),
908                WindFieldParam::SpeedMps,
909                WindFieldRule::FiniteAndNonNegative,
910            ),
911            (
912                level(0.0, 1.0, f64::NAN),
913                WindFieldParam::DirectionFromDeg,
914                WindFieldRule::Finite,
915            ),
916            (
917                level(0.0, 1.0, f64::NEG_INFINITY),
918                WindFieldParam::DirectionFromDeg,
919                WindFieldRule::Finite,
920            ),
921        ];
922        for (bad, param, rule) in cases {
923            // The bad level is second in the caller's order but would sort first.
924            let mut good = ok;
925            good.altitude_m = 500.0;
926            let err = ProfileField::new(vec![good, bad]).unwrap_err();
927            assert_eq!(
928                err,
929                WindFieldError::InvalidLevel {
930                    index: 1,
931                    param,
932                    rule
933                }
934            );
935        }
936        assert_eq!(
937            ProfileField::new(vec![]).unwrap_err(),
938            WindFieldError::EmptyProfile
939        );
940        // Unsorted duplicates are found after sorting and reported in caller order.
941        let err = ProfileField::new(vec![
942            level(300.0, 1.0, 0.0),
943            level(100.0, 1.0, 0.0),
944            level(200.0, 1.0, 0.0),
945            level(100.0, 2.0, 0.0),
946        ])
947        .unwrap_err();
948        assert_eq!(
949            err,
950            WindFieldError::DuplicateAltitude {
951                first_index: 1,
952                second_index: 3,
953                altitude_m: 100.0
954            }
955        );
956        // -0.0 and 0.0 are the same altitude.
957        assert!(matches!(
958            ProfileField::new(vec![level(0.0, 1.0, 0.0), level(-0.0, 1.0, 0.0)]),
959            Err(WindFieldError::DuplicateAltitude { .. })
960        ));
961        // Messages follow the module's style.
962        assert_eq!(
963            WindFieldError::InvalidLevel {
964                index: 2,
965                param: WindFieldParam::SpeedMps,
966                rule: WindFieldRule::FiniteAndNonNegative
967            }
968            .to_string(),
969            "wind profile levels[2].speed_mps must be finite and non-negative"
970        );
971    }
972
973    #[test]
974    fn analytic_validation_reports_param_and_rule() {
975        use WindFieldParam as P;
976        use WindFieldRule as R;
977        let log = |s, h, z0, d| LogProfile::new(s, h, z0, d).unwrap_err();
978        let invalid = |param, rule| WindFieldError::InvalidParameter { param, rule };
979        assert_eq!(
980            log(-1.0, 10.0, 0.03, 0.0),
981            invalid(P::ReferenceSpeedMps, R::FiniteAndNonNegative)
982        );
983        assert_eq!(
984            log(f64::NAN, 10.0, 0.03, 0.0),
985            invalid(P::ReferenceSpeedMps, R::FiniteAndNonNegative)
986        );
987        assert_eq!(
988            log(8.0, 0.0, 0.03, 0.0),
989            invalid(P::ReferenceHeightM, R::FiniteAndPositive)
990        );
991        assert_eq!(
992            log(8.0, f64::INFINITY, 0.03, 0.0),
993            invalid(P::ReferenceHeightM, R::FiniteAndPositive)
994        );
995        for z0 in [0.0, -0.1, 10.0, 11.0, f64::NAN] {
996            assert_eq!(
997                log(8.0, 10.0, z0, 0.0),
998                invalid(P::RoughnessM, R::FiniteAndPositiveBelowReferenceHeight),
999                "z0 {z0}"
1000            );
1001        }
1002        assert_eq!(
1003            log(8.0, 10.0, 0.03, f64::NAN),
1004            invalid(P::DirectionFromDeg, R::Finite)
1005        );
1006
1007        let ekman = |g, lat, k, d| EkmanProfile::new(g, lat, k, d).unwrap_err();
1008        assert_eq!(
1009            ekman(-1.0, 45.0, 5.0, 0.0),
1010            invalid(P::GeostrophicMps, R::FiniteAndNonNegative)
1011        );
1012        for lat in [0.0, -0.0, 90.0, -90.0, 120.0, f64::NAN, f64::INFINITY] {
1013            assert_eq!(
1014                ekman(12.0, lat, 5.0, 0.0),
1015                invalid(P::LatitudeDeg, R::FiniteNonzeroAndOffThePoles),
1016                "lat {lat}"
1017            );
1018        }
1019        for k in [0.0, -5.0, f64::NAN] {
1020            assert_eq!(
1021                ekman(12.0, 45.0, k, 0.0),
1022                invalid(P::EddyViscosityM2s, R::FiniteAndPositive)
1023            );
1024        }
1025        assert_eq!(
1026            ekman(12.0, 45.0, 5.0, f64::INFINITY),
1027            invalid(P::DirectionFromDeg, R::Finite)
1028        );
1029        // Zero speeds are valid and give calm air.
1030        assert_eq!(
1031            LogProfile::new(0.0, 10.0, 0.03, 0.0).unwrap().wind_at(5.0),
1032            Vector3::zeros()
1033        );
1034        assert_eq!(
1035            speed(
1036                EkmanProfile::new(0.0, 45.0, 5.0, 0.0)
1037                    .unwrap()
1038                    .wind_at(100.0)
1039            ),
1040            0.0
1041        );
1042    }
1043
1044    // ---- Determinism and edge positions ----
1045
1046    #[test]
1047    fn fields_ignore_time_and_horizontal_position() {
1048        let profile =
1049            ProfileField::new(vec![level(0.0, 2.0, 30.0), level(800.0, 9.0, 80.0)]).unwrap();
1050        let log = LogProfile::new(8.0, 10.0, 0.03, 270.0).unwrap();
1051        let ekman = EkmanProfile::new(12.0, 45.0, 5.0, 120.0).unwrap();
1052        let fields: [&dyn WindField; 3] = [&profile, &log, &ekman];
1053        for field in fields {
1054            let reference = field.wind(0.0, at_height(321.0));
1055            for (t, x, z) in [(1.0, 10.0, -4.0), (1e6, -300.0, 99.0), (-7.0, 0.0, 0.0)] {
1056                assert_eq!(field.wind(t, Vector3::new(x, 321.0, z)), reference);
1057            }
1058            // Repeated calls are bit-identical.
1059            assert_eq!(field.wind(3.0, at_height(321.0)), reference);
1060        }
1061    }
1062
1063    #[test]
1064    fn nan_height_gives_calm_air() {
1065        let profile = ProfileField::new(vec![level(0.0, 2.0, 30.0)]).unwrap();
1066        let log = LogProfile::new(8.0, 10.0, 0.03, 270.0).unwrap();
1067        let ekman = EkmanProfile::new(12.0, 45.0, 5.0, 120.0).unwrap();
1068        assert_eq!(profile.wind_at(f64::NAN), Vector3::zeros());
1069        assert_eq!(log.wind_at(f64::NAN), Vector3::zeros());
1070        assert_eq!(log.speed_at(f64::NAN), 0.0);
1071        assert_eq!(ekman.wind_at(f64::NAN), Vector3::zeros());
1072        // Below the surface the analytic profiles are calm too.
1073        assert_eq!(ekman.wind_at(-10.0), Vector3::zeros());
1074        assert_eq!(log.wind_at(-10.0), Vector3::zeros());
1075    }
1076
1077    #[test]
1078    fn single_level_profile_is_uniform_with_height() {
1079        let profile = ProfileField::new(vec![level(150.0, 4.0, 45.0)]).unwrap();
1080        let w = profile.wind_at(150.0);
1081        for y in [-1e3, 0.0, 149.0, 151.0, 1e5] {
1082            assert_eq!(profile.wind_at(y), w);
1083        }
1084    }
1085
1086    // ---- Review fixes: exact levels, extremes, numerics ----
1087
1088    #[test]
1089    fn every_level_reproduces_its_own_vector_exactly() {
1090        // Bottom, interior (after a 350 -> 10 wrap) and top levels, plus a raw angle >= 360.
1091        let levels = vec![
1092            level(0.0, 3.0, 350.0),
1093            level(100.0, 5.0, 10.0),
1094            level(250.0, 8.0, 100.0),
1095            level(330.0, 6.5, 370.0),
1096            level(400.0, 2.0, 359.0),
1097        ];
1098        let profile = ProfileField::new(levels.clone()).unwrap();
1099        for l in levels {
1100            assert_eq!(
1101                profile.wind_at(l.altitude_m),
1102                wind_vector(l.speed_mps, direction_to_rad(l.direction_from_deg), 0.0),
1103                "level at {} m",
1104                l.altitude_m
1105            );
1106        }
1107    }
1108
1109    #[test]
1110    fn opposite_directions_turn_clockwise_however_written() {
1111        // Exactly opposite neighbours: the FROM angle increases by 90° at the midpoint.
1112        for (lo, hi) in [
1113            (0.0, 180.0),
1114            (0.0, -180.0),
1115            (180.0, 0.0),
1116            (180.0, 360.0),
1117            (-180.0, 0.0),
1118            (190.0, 10.0),
1119            (190.0, 370.0),
1120        ] {
1121            let profile =
1122                ProfileField::new(vec![level(0.0, 5.0, lo), level(100.0, 5.0, hi)]).unwrap();
1123            let expected = (lo + 90.0_f64).rem_euclid(360.0);
1124            let mid = direction_from(profile.wind_at(50.0));
1125            assert!(
1126                angle_diff(mid, expected).abs() < 1e-9,
1127                "{lo} -> {hi}: {mid}"
1128            );
1129        }
1130    }
1131
1132    #[test]
1133    fn huge_level_angles_still_interpolate() {
1134        let lo_dir = 1e300_f64;
1135        let profile =
1136            ProfileField::new(vec![level(0.0, 5.0, lo_dir), level(100.0, 5.0, 10.0)]).unwrap();
1137        let lo_n = lo_dir.rem_euclid(360.0);
1138        let mut delta = 10.0 - lo_n;
1139        if delta > 180.0 {
1140            delta -= 360.0;
1141        } else if delta <= -180.0 {
1142            delta += 360.0;
1143        }
1144        for frac in [0.25, 0.5, 0.75] {
1145            let want = (lo_n + frac * delta).rem_euclid(360.0);
1146            let got = direction_from(profile.wind_at(100.0 * frac));
1147            assert!(
1148                angle_diff(got, want).abs() < 1e-6,
1149                "frac {frac}: {got} vs {want}"
1150            );
1151        }
1152    }
1153
1154    #[test]
1155    fn extreme_altitude_spans_stay_finite() {
1156        let profile =
1157            ProfileField::new(vec![level(-1e308, 2.0, 0.0), level(1e308, 6.0, 0.0)]).unwrap();
1158        assert!((speed(profile.wind_at(0.0)) - 4.0).abs() < 1e-12);
1159        assert_eq!(profile.wind_at(1e308), horizontal(6.0, 0.0));
1160        assert_eq!(profile.wind_at(f64::INFINITY), horizontal(6.0, 0.0));
1161        assert_eq!(profile.wind_at(f64::NEG_INFINITY), horizontal(2.0, 0.0));
1162    }
1163
1164    #[test]
1165    fn log_profile_is_finite_for_every_accepted_roughness() {
1166        for z0 in [1e-310, 1e-300, 1e-12, 0.03, 9.999] {
1167            let log = LogProfile::new(8.0, 10.0, z0, 30.0).unwrap();
1168            assert!((log.speed_at(10.0) - 8.0).abs() < 1e-12, "z0 {z0}");
1169            for z in [z0 * 2.0, 1.0, 1e9, f64::MAX] {
1170                let w = log.wind_at(z);
1171                assert!(w.iter().all(|c| c.is_finite()), "z0 {z0} z {z}: {w:?}");
1172            }
1173        }
1174        // A roughness indistinguishable from the reference height on a log scale is refused;
1175        // anything accepted must still hit its reference speed.
1176        let z0 = f64::from_bits(10.0_f64.to_bits() - 1);
1177        match LogProfile::new(8.0, 10.0, z0, 0.0) {
1178            Err(e) => assert_eq!(
1179                e,
1180                WindFieldError::InvalidParameter {
1181                    param: WindFieldParam::RoughnessM,
1182                    rule: WindFieldRule::FiniteAndPositiveBelowReferenceHeight
1183                }
1184            ),
1185            Ok(log) => assert!((log.speed_at(10.0) - 8.0).abs() < 1e-9),
1186        }
1187        // No finite value at an infinite height: calm, like NaN.
1188        let log = LogProfile::new(8.0, 10.0, 0.03, 30.0).unwrap();
1189        assert_eq!(log.wind_at(f64::INFINITY), Vector3::zeros());
1190        assert_eq!(log.wind_at(f64::NEG_INFINITY), Vector3::zeros());
1191    }
1192
1193    #[test]
1194    fn ekman_rejects_underflowing_latitude_and_overflowing_decay() {
1195        let invalid = |param, rule| WindFieldError::InvalidParameter { param, rule };
1196        assert_eq!(
1197            EkmanProfile::new(12.0, 1e-322, 5.0, 0.0).unwrap_err(),
1198            invalid(
1199                WindFieldParam::LatitudeDeg,
1200                WindFieldRule::FiniteNonzeroAndOffThePoles
1201            )
1202        );
1203        assert_eq!(
1204            EkmanProfile::new(12.0, 45.0, 1e-320, 0.0).unwrap_err(),
1205            invalid(
1206                WindFieldParam::EddyViscosityM2s,
1207                WindFieldRule::FiniteAndPositive
1208            )
1209        );
1210        // Small but representable values are still accepted.
1211        assert!(EkmanProfile::new(12.0, 1e-6, 5.0, 0.0).is_ok());
1212        assert!(EkmanProfile::new(12.0, 45.0, 1e-300, 0.0).is_ok());
1213    }
1214
1215    #[test]
1216    fn ekman_is_finite_at_every_height_and_geostrophic_at_infinity() {
1217        for k in [1e-300, 5.0, 1e6] {
1218            let ekman = EkmanProfile::new(12.0, -30.0, k, 75.0).unwrap();
1219            for z in [1e-300, 1e-15, 1.0, 1e4, 1e200, f64::MAX, f64::INFINITY] {
1220                let w = ekman.wind_at(z);
1221                assert!(w.iter().all(|c| c.is_finite()), "k {k} z {z}: {w:?}");
1222            }
1223            let top = ekman.wind_at(f64::INFINITY);
1224            assert!((speed(top) - 12.0).abs() < 1e-12);
1225            assert!(angle_diff(direction_from(top), 75.0).abs() < 1e-9);
1226        }
1227    }
1228
1229    #[test]
1230    fn ekman_surface_turn_holds_at_tiny_heights() {
1231        let north = EkmanProfile::new(10.0, 45.0, 5.0, 0.0).unwrap();
1232        let south = EkmanProfile::new(10.0, -45.0, 5.0, 0.0).unwrap();
1233        for z in [1e-15, 1e-13, 1e-9, 1e-5] {
1234            let n = angle_diff(direction_from(north.wind_at(z)), 0.0);
1235            let s = angle_diff(direction_from(south.wind_at(z)), 0.0);
1236            assert!((n + 45.0).abs() < 1e-3, "north z {z}: {n}");
1237            assert!((s - 45.0).abs() < 1e-3, "south z {z}: {s}");
1238        }
1239    }
1240
1241    // ---- Height datum ----
1242
1243    #[test]
1244    fn height_offset_reads_the_inner_field_higher() {
1245        let profile =
1246            ProfileField::new(vec![level(0.0, 2.0, 30.0), level(800.0, 9.0, 80.0)]).unwrap();
1247        let shifted = HeightOffset::new(&profile, 250.0).unwrap();
1248        for (t, x, y, z) in [
1249            (0.0, 0.0, 0.0, 0.0),
1250            (3.5, 120.0, 40.0, -2.0),
1251            (1.0, -5.0, -300.0, 9.0),
1252        ] {
1253            assert_eq!(
1254                shifted.wind(t, Vector3::new(x, y, z)),
1255                profile.wind(t, Vector3::new(x, y + 250.0, z))
1256            );
1257        }
1258        assert_eq!(shifted.offset_m(), 250.0);
1259        assert_eq!(*shifted.field(), &profile);
1260        // A zero offset is the field itself.
1261        let same = HeightOffset::new(&profile, 0.0).unwrap();
1262        assert_eq!(same.wind(0.0, at_height(123.0)), profile.wind_at(123.0));
1263    }
1264
1265    #[test]
1266    fn a_sea_level_sounding_matches_a_ground_frame_through_the_site_elevation() {
1267        // Levels in metres above sea level, the first at the station's 287 m surface. A launch
1268        // frame with y = 0 on the ground at that elevation reads the surface level at y = 0 and
1269        // the 610 m level 323 m up.
1270        let msl = ProfileField::new(vec![
1271            level(287.0, 0.5, 120.0),
1272            level(305.0, 3.0867, 125.0),
1273            level(610.0, 3.6011, 145.0),
1274        ])
1275        .unwrap();
1276        let site = HeightOffset::new(msl.clone(), 287.0).unwrap();
1277        assert_eq!(site.wind(0.0, Vector3::zeros()), horizontal(0.5, 120.0));
1278        assert_eq!(site.wind(0.0, at_height(323.0)), horizontal(3.6011, 145.0));
1279        // Without the offset the ground frame would read the surface value up to 287 m.
1280        assert_eq!(
1281            msl.wind(0.0, at_height(287.0 - 1.0)),
1282            horizontal(0.5, 120.0)
1283        );
1284        assert_ne!(msl.wind(0.0, at_height(323.0)), horizontal(3.6011, 145.0));
1285    }
1286
1287    #[test]
1288    fn a_frame_with_y_zero_at_the_bore_reads_a_log_profile_at_the_bore_height() {
1289        let log = LogProfile::new(8.0, 10.0, 0.03, 90.0).unwrap();
1290        let from_bore = HeightOffset::new(log, 1.5).unwrap();
1291        assert_eq!(from_bore.wind(0.0, Vector3::zeros()), log.wind_at(1.5));
1292        assert!(speed(from_bore.wind(0.0, Vector3::zeros())) > 0.0);
1293        assert_eq!(from_bore.into_inner(), log);
1294    }
1295
1296    #[test]
1297    fn a_sea_level_frame_reads_a_log_profile_through_minus_the_ground_elevation() {
1298        // y is height above sea level and the ground sits 1609 m up: the log profile, measured
1299        // from the ground, takes -1609; a sea-level sounding profile is read as it is.
1300        let ground_msl = 1609.0;
1301        let log = LogProfile::new(8.0, 10.0, 0.03, 270.0).unwrap();
1302        let in_msl_frame = HeightOffset::new(log, -ground_msl).unwrap();
1303        assert_eq!(
1304            in_msl_frame.wind(0.0, at_height(ground_msl + 10.0)),
1305            log.wind_at(10.0)
1306        );
1307        assert_eq!(
1308            in_msl_frame.wind(0.0, at_height(ground_msl)),
1309            Vector3::zeros()
1310        );
1311        assert!((speed(in_msl_frame.wind(0.0, at_height(ground_msl + 10.0))) - 8.0).abs() < 1e-12);
1312
1313        let sounding_msl =
1314            ProfileField::new(vec![level(1609.0, 1.0, 200.0), level(2609.0, 9.0, 250.0)]).unwrap();
1315        assert_eq!(
1316            sounding_msl.wind(0.0, at_height(2109.0)),
1317            sounding_msl.wind_at(2109.0)
1318        );
1319        // The double-shift trap: wrapping a profile that is already in the frame's datum moves
1320        // every wind by the offset.
1321        let double = HeightOffset::new(&sounding_msl, ground_msl).unwrap();
1322        assert_ne!(
1323            double.wind(0.0, at_height(2109.0)),
1324            sounding_msl.wind_at(2109.0)
1325        );
1326    }
1327
1328    #[test]
1329    fn height_offset_must_be_finite() {
1330        for offset in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1331            assert_eq!(
1332                HeightOffset::new(ConstantField(Vector3::zeros()), offset).unwrap_err(),
1333                WindFieldError::InvalidParameter {
1334                    param: WindFieldParam::OffsetM,
1335                    rule: WindFieldRule::Finite
1336                }
1337            );
1338        }
1339        assert_eq!(
1340            WindFieldError::InvalidParameter {
1341                param: WindFieldParam::OffsetM,
1342                rule: WindFieldRule::Finite
1343            }
1344            .to_string(),
1345            "offset_m must be finite"
1346        );
1347    }
1348
1349    #[test]
1350    fn boxed_and_borrowed_fields_are_fields() {
1351        let boxed: Vec<Box<dyn WindField>> = vec![
1352            Box::new(ConstantField(Vector3::new(1.0, 2.0, 3.0))),
1353            Box::new(LogProfile::new(8.0, 10.0, 0.03, 0.0).unwrap()),
1354        ];
1355        let p = at_height(25.0);
1356        assert_eq!(boxed[0].wind(0.0, p), Vector3::new(1.0, 2.0, 3.0));
1357        fn evaluate<F: WindField>(field: F, p: Vector3<f64>) -> Vector3<f64> {
1358            field.wind(0.0, p)
1359        }
1360        assert_eq!(evaluate(&boxed[1], p), boxed[1].wind(0.0, p));
1361        assert_eq!(
1362            evaluate(boxed.into_iter().next().unwrap(), p),
1363            Vector3::new(1.0, 2.0, 3.0)
1364        );
1365    }
1366}