Skip to main content

ballistics_engine/
wind_shear.rs

1//! Altitude-dependent wind shear modeling for ballistics.
2//!
3//! Wind shear refers to the change in wind speed and/or direction with altitude.
4//! This is important for long-range ballistics where projectiles reach significant
5//! altitudes and experience different wind conditions at different heights.
6//!
7//! Boundary-layer profiles are keyed off height above the local ground.
8//!
9//! The public helpers take a height relative to the muzzle. [`boundary_layer_speed_ratio`] and
10//! [`get_wind_at_position`] add an assumed 1.5 m muzzle height to it, and so does
11//! [`WindShearWindSock::vector_for_position`] for a logarithmic or power-law profile. For an
12//! Ekman or custom-layer profile the sock samples the profile at the relative height itself,
13//! with no offset.
14//!
15//! The trajectory kernels measure the height from their own frame instead. They integrate in
16//! the shot frame, whose y is measured perpendicular to the line of sight from the level
17//! `BallisticInputs::muzzle_height` is measured from, and their ground-impact check
18//! (`ground_threshold`) compares that y too, so the ground they model runs parallel to the line
19//! of sight. Boundary-layer shear uses the same ground: the height above it is y plus the height
20//! of y = 0 above the ground, which is nothing when `muzzle_height` is set (y = 0 is then the
21//! ground it was measured from) and the assumed 1.5 m when it is left at 0 (y = 0 is then the
22//! bore). On an inclined shot that ground rises or falls with the line of sight, as it does
23//! between a shooter and a target both standing on it, so the shooting angle does not enter.
24//! Level ground at the shooter's elevation would instead read a bullet arriving at an uphill
25//! target as hundreds of metres above the ground it is about to hit, and a downhill shot as
26//! below the ground.
27
28use nalgebra::Vector3;
29use std::f64::consts::PI;
30
31/// Bore height above the ground assumed when a frame's y = 0 is the bore and its real height
32/// is unknown: the public relative-to-the-muzzle helpers, and a kernel whose `muzzle_height`
33/// is left at 0.
34const APPROX_MUZZLE_HEIGHT_AGL_M: f64 = 1.5;
35
36/// Wind shear model types
37#[derive(Debug, Clone, Copy, PartialEq)]
38pub enum WindShearModel {
39    None,
40    Logarithmic,
41    PowerLaw,
42    EkmanSpiral,
43    CustomLayers,
44}
45
46/// Wind conditions at a specific altitude
47#[derive(Debug, Clone, Copy)]
48pub struct WindLayer {
49    pub altitude_m: f64,
50    pub speed_mps: f64,
51    pub direction_deg: f64, // Direction wind is coming FROM
52}
53
54impl WindLayer {
55    /// Convert to wind vector [x, y, z] in m/s
56    /// STANDARD BALLISTICS CONVENTION: X=downrange, Y=vertical, Z=lateral
57    pub fn to_vector(&self) -> Vector3<f64> {
58        let ang = self.direction_deg.to_radians();
59        crate::wind::wind_vector(self.speed_mps, ang, 0.0)
60    }
61}
62
63/// Complete wind shear profile definition
64#[derive(Debug, Clone)]
65pub struct WindShearProfile {
66    pub model: WindShearModel,
67    pub surface_wind: WindLayer,
68    pub reference_height: f64, // Standard meteorological measurement height
69    pub roughness_length: f64, // Surface roughness (0.03 = short grass)
70    pub power_exponent: f64,   // Power law exponent (1/7 for neutral stability)
71    pub geostrophic_wind: Option<WindLayer>, // Wind above boundary layer
72    pub custom_layers: Vec<WindLayer>,
73}
74
75impl Default for WindShearProfile {
76    fn default() -> Self {
77        Self {
78            model: WindShearModel::None,
79            surface_wind: WindLayer {
80                altitude_m: 0.0,
81                speed_mps: 0.0,
82                direction_deg: 0.0,
83            },
84            reference_height: 10.0,
85            roughness_length: 0.03,
86            power_exponent: 1.0 / 7.0,
87            geostrophic_wind: None,
88            custom_layers: Vec::new(),
89        }
90    }
91}
92
93impl WindShearProfile {
94    /// Get wind vector at specified altitude
95    pub fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
96        match self.model {
97            WindShearModel::None => self.surface_wind.to_vector(),
98            WindShearModel::Logarithmic => self.logarithmic_profile(altitude_m),
99            WindShearModel::PowerLaw => self.power_law_profile(altitude_m),
100            WindShearModel::EkmanSpiral => self.ekman_spiral_profile(altitude_m),
101            WindShearModel::CustomLayers => self.interpolate_layers(altitude_m),
102        }
103    }
104
105    /// Logarithmic wind profile (boundary layer)
106    /// U(z) = U_ref * ln(z/z0) / ln(z_ref/z0)
107    fn logarithmic_profile(&self, altitude_m: f64) -> Vector3<f64> {
108        // Handle negative altitudes (bullet below sight line)
109        // Use absolute altitude, but add small offset only if very close to ground
110        let effective_altitude = if altitude_m < 0.0 {
111            // For negative altitudes, use a small positive value
112            0.001 // 1mm above ground
113        } else if altitude_m < 0.001 {
114            // Very small positive altitudes
115            0.001
116        } else {
117            altitude_m
118        };
119
120        // If very close to roughness length, return near-zero wind
121        if effective_altitude <= self.roughness_length {
122            return Vector3::zeros();
123        }
124
125        // Calculate speed ratio
126        let speed_ratio = if effective_altitude > self.roughness_length
127            && self.reference_height > self.roughness_length
128        {
129            (effective_altitude / self.roughness_length).ln()
130                / (self.reference_height / self.roughness_length).ln()
131        } else {
132            1.0
133        };
134
135        // Apply to surface wind
136        self.surface_wind.to_vector() * speed_ratio.max(0.0)
137    }
138
139    /// Power law wind profile
140    fn power_law_profile(&self, altitude_m: f64) -> Vector3<f64> {
141        if altitude_m <= 0.0 {
142            return Vector3::zeros();
143        }
144
145        // Calculate speed ratio
146        let speed_ratio = (altitude_m / self.reference_height).powf(self.power_exponent);
147
148        // Apply to surface wind
149        self.surface_wind.to_vector() * speed_ratio
150    }
151
152    /// Ekman spiral - wind direction changes with altitude.
153    ///
154    /// The implicit geostrophic layer assumes the Northern Hemisphere, where wind veers with
155    /// height. Callers can supply `geostrophic_wind` explicitly for a different turning sense.
156    fn ekman_spiral_profile(&self, altitude_m: f64) -> Vector3<f64> {
157        // Default geostrophic wind if not specified
158        let geo_wind = self.geostrophic_wind.unwrap_or({
159            WindLayer {
160                altitude_m: 1000.0,
161                speed_mps: self.surface_wind.speed_mps * 1.5,
162                direction_deg: self.surface_wind.direction_deg + 30.0, // 30° veering
163            }
164        });
165
166        // Ekman layer depth (simplified)
167        let ekman_depth = 1000.0; // meters
168
169        if altitude_m >= ekman_depth {
170            return geo_wind.to_vector();
171        }
172
173        // Interpolate between surface and geostrophic
174        let ratio = altitude_m / ekman_depth;
175
176        // Interpolate speed
177        let speed = self.surface_wind.speed_mps * (1.0 - ratio) + geo_wind.speed_mps * ratio;
178
179        // Interpolate direction (accounting for circular interpolation)
180        let dir1 = self.surface_wind.direction_deg.to_radians();
181        let mut dir2 = geo_wind.direction_deg.to_radians();
182
183        // Handle angle wrapping
184        if (dir2 - dir1).abs() > PI {
185            if dir2 > dir1 {
186                dir2 -= 2.0 * PI;
187            } else {
188                dir2 += 2.0 * PI;
189            }
190        }
191
192        let direction_rad = dir1 * (1.0 - ratio) + dir2 * ratio;
193
194        // Convert to vector (X=downrange, Y=vertical, Z=lateral)
195        crate::wind::wind_vector(speed, direction_rad, 0.0)
196    }
197
198    /// Interpolate wind from custom altitude layers
199    fn interpolate_layers(&self, altitude_m: f64) -> Vector3<f64> {
200        if self.custom_layers.is_empty() {
201            return self.surface_wind.to_vector();
202        }
203
204        // Clamp out-of-range queries to the nearest boundary layer instead of
205        // extrapolating. custom_layers is assumed sorted ascending by altitude (as the
206        // bracketing loop below already requires). The below-range case is handled by the
207        // loop (low_idx==high_idx==0); the above-range case otherwise interpolates between
208        // the TOP and FIRST layer (negative span) and extrapolates garbage.
209        let last = self.custom_layers.len() - 1;
210        if altitude_m >= self.custom_layers[last].altitude_m {
211            return self.custom_layers[last].to_vector();
212        }
213
214        // Find bracketing layers
215        let mut low_idx = 0;
216        let mut high_idx = 0;
217
218        for (i, layer) in self.custom_layers.iter().enumerate() {
219            if layer.altitude_m <= altitude_m {
220                low_idx = i;
221            }
222            if layer.altitude_m >= altitude_m {
223                high_idx = i;
224                break;
225            }
226        }
227
228        // Same layer or out of bounds
229        if low_idx == high_idx {
230            return self.custom_layers[low_idx].to_vector();
231        }
232
233        // Linear interpolation
234        let low_layer = &self.custom_layers[low_idx];
235        let high_layer = &self.custom_layers[high_idx];
236
237        // MBA-246: Guard against division by zero when layers have same altitude
238        let altitude_diff = high_layer.altitude_m - low_layer.altitude_m;
239        if altitude_diff.abs() < 1e-9 {
240            return low_layer.to_vector();
241        }
242
243        let ratio = (altitude_m - low_layer.altitude_m) / altitude_diff;
244
245        // Interpolate vectors
246        let low_vec = low_layer.to_vector();
247        let high_vec = high_layer.to_vector();
248
249        low_vec * (1.0 - ratio) + high_vec * ratio
250    }
251}
252
253/// Extended wind sock with altitude-dependent shear
254#[derive(Debug, Clone)]
255pub struct WindShearWindSock {
256    pub segments: Vec<(f64, f64, f64)>, // (speed_mps, angle_deg, until_range_m)
257    pub shear_profile: Option<WindShearProfile>,
258    /// Site elevation above sea level, retained as metadata for API compatibility. Boundary-layer
259    /// profiles use height above local ground and must not add this value.
260    pub shooter_altitude_m: f64,
261}
262
263impl WindShearWindSock {
264    pub fn new(segments: Vec<(f64, f64, f64)>, shear_profile: Option<WindShearProfile>) -> Self {
265        Self {
266            segments,
267            shear_profile,
268            shooter_altitude_m: 0.0,
269        }
270    }
271
272    pub fn with_shooter_altitude(
273        segments: Vec<(f64, f64, f64)>,
274        shear_profile: Option<WindShearProfile>,
275        shooter_altitude_m: f64,
276    ) -> Self {
277        Self {
278            segments,
279            shear_profile,
280            shooter_altitude_m,
281        }
282    }
283
284    /// Get wind vector for 3D position
285    /// Standard ballistics coordinate system: X=downrange, Y=vertical, Z=lateral
286    ///
287    /// `position.y` is the height relative to the muzzle. A logarithmic or power-law profile
288    /// adds the assumed 1.5 m muzzle height to it; an Ekman or custom-layer profile is sampled
289    /// at `position.y` as given.
290    pub fn vector_for_position(&self, position: Vector3<f64>) -> Vector3<f64> {
291        let range_m = position.x; // X is downrange (McCoy)
292        let altitude_m = position.y; // Relative to shooter
293
294        // Get base wind at this range
295        let base_wind = self.get_range_wind(range_m);
296
297        if let Some(profile) = &self.shear_profile {
298            if profile.model != WindShearModel::None {
299                if matches!(
300                    profile.model,
301                    WindShearModel::Logarithmic | WindShearModel::PowerLaw
302                ) {
303                    // McCoy Y is height relative to launch, not height above ground. Apply the
304                    // same approximate muzzle-height offset and operative-wind floor as the
305                    // high-level shear API. Site elevation above sea level is intentionally not
306                    // part of this boundary-layer height (MBA-1165, MBA-1166).
307                    let speed_ratio = profile_boundary_layer_speed_ratio(profile, altitude_m);
308                    let operative_wind = if base_wind.norm() > 0.0 {
309                        base_wind
310                    } else {
311                        profile.surface_wind.to_vector()
312                    };
313                    return operative_wind * speed_ratio;
314                }
315
316                let altitude_vec = profile.get_wind_at_altitude(altitude_m);
317
318                // Scale the base wind by altitude profile
319                if base_wind.norm() > 0.0 {
320                    let scale_factor =
321                        altitude_vec.norm() / profile.surface_wind.speed_mps.max(1e-9);
322                    return base_wind * scale_factor;
323                }
324
325                return altitude_vec;
326            }
327        }
328
329        base_wind
330    }
331
332    /// Get wind based on horizontal range
333    /// Returns wind vector in standard ballistics coordinates: X=downrange, Y=vertical, Z=lateral
334    fn get_range_wind(&self, range_m: f64) -> Vector3<f64> {
335        if range_m.is_nan() || self.segments.is_empty() {
336            return Vector3::zeros();
337        }
338
339        // Find appropriate wind segment
340        for &(speed_mps, angle_deg, until_dist) in &self.segments {
341            if range_m <= until_dist {
342                let ang = angle_deg.to_radians();
343                return crate::wind::wind_vector(speed_mps, ang, 0.0);
344            }
345        }
346
347        // Beyond all segments
348        Vector3::zeros()
349    }
350}
351
352fn profile_boundary_layer_speed_ratio(profile: &WindShearProfile, height_rel_launch_m: f64) -> f64 {
353    let minimum_height_m = profile.roughness_length.max(0.0) * 1.000_1;
354    let height_agl_m = (height_rel_launch_m + APPROX_MUZZLE_HEIGHT_AGL_M).max(minimum_height_m);
355    let sampled_speed_mps = profile.get_wind_at_altitude(height_agl_m).norm();
356    let reference_speed_mps = profile.surface_wind.speed_mps.abs().max(1e-9);
357
358    (sampled_speed_mps / reference_speed_mps).max(1.0)
359}
360
361/// Boundary-layer wind-speed multiplier for a projectile flying `height_rel_launch_m` above the
362/// muzzle (McCoy Y / height relative to the line of departure).
363///
364/// The user-supplied wind is treated as the *operative* surface/flight wind: the multiplier is
365/// floored at 1.0 so the wind is never reduced below the input value, and only increases
366/// (logarithmically, or by the 1/7 power law) for trajectories that climb well above the standard
367/// 10 m meteorological reference height. This matches the uniform-wind convention used by standard
368/// ballistic solvers for flat fire, while still adding genuine shear for high-angle / ELR shots.
369///
370/// Height above ground is approximated as the bullet's height gained plus an assumed muzzle
371/// height. Shooter altitude above *sea level* is deliberately excluded: boundary-layer shear is
372/// relative to the local ground, and air-density effects of altitude are modelled separately.
373///
374/// This replaces the previous behaviour where the height-relative-to-line-of-sight (~0 for flat
375/// fire) was treated as a true above-ground altitude and clamped to zero below the roughness
376/// length, which zeroed the crosswind for almost the whole flight (~5x too little drift).
377///
378/// The trajectory kernels do not call this: they know where their y = 0 is and use the
379/// height above ground directly (see the module docs).
380pub fn boundary_layer_speed_ratio(height_rel_launch_m: f64, model: WindShearModel) -> f64 {
381    boundary_layer_speed_ratio_agl(height_rel_launch_m + APPROX_MUZZLE_HEIGHT_AGL_M, model)
382}
383
384/// Height above the ground of a trajectory frame's y = 0: nothing when `muzzle_height_m` is
385/// set (y = 0 is the ground it was measured from), the assumed bore height when it is left at
386/// 0 (y = 0 is the bore, whose real height is unknown).
387///
388/// A nonzero `muzzle_height_m` of either sign states the frame's datum, so only exactly 0 is
389/// read as unset; `BallisticInputs::default()` leaves it there.
390pub(crate) fn frame_datum_height_agl_m(muzzle_height_m: f64) -> f64 {
391    if muzzle_height_m == 0.0 {
392        APPROX_MUZZLE_HEIGHT_AGL_M
393    } else {
394        0.0
395    }
396}
397
398/// Boundary-layer shear height above the ground of a point whose shot-frame y is `y_m` in a
399/// trajectory kernel: y, measured perpendicular to the line of sight, plus the height of y = 0
400/// above the ground ([`frame_datum_height_agl_m`]).
401///
402/// The kernels' ground runs parallel to the line of sight, the plane their `ground_threshold`
403/// check already uses, so neither the downrange distance nor the shooting angle enters (see the
404/// module docs). On a level shot y is the world height.
405pub(crate) fn shear_height_agl_m(y_m: f64, muzzle_height_m: f64) -> f64 {
406    y_m + frame_datum_height_agl_m(muzzle_height_m)
407}
408
409/// [`boundary_layer_speed_ratio`] for a height already measured above the ground, as the
410/// trajectory kernels compute it with [`shear_height_agl_m`]. Same profile, same floor at 1.0.
411pub(crate) fn boundary_layer_speed_ratio_agl(height_agl_m: f64, model: WindShearModel) -> f64 {
412    const Z0: f64 = 0.03; // surface roughness length (short grass)
413    const H_REF: f64 = 10.0; // standard meteorological reference height of the input wind
414
415    let height_agl = height_agl_m.max(Z0 * 1.000_1);
416    let ratio = match model {
417        WindShearModel::PowerLaw => (height_agl / H_REF).powf(1.0 / 7.0),
418        WindShearModel::Logarithmic => (height_agl / Z0).ln() / (H_REF / Z0).ln(),
419        // Ekman / custom / none have no closed-form near-ground scaling here -> operative wind.
420        _ => 1.0,
421    };
422    ratio.max(1.0)
423}
424
425pub(crate) fn boundary_layer_model_from_name(model: &str) -> WindShearModel {
426    match model {
427        "logarithmic" => WindShearModel::Logarithmic,
428        "power_law" | "powerlaw" => WindShearModel::PowerLaw,
429        "ekman_spiral" | "ekman" => WindShearModel::EkmanSpiral,
430        "custom_layers" | "custom" => WindShearModel::CustomLayers,
431        _ => WindShearModel::None,
432    }
433}
434
435/// [`apply_boundary_layer_shear_agl`] for a height relative to the muzzle, with the assumed
436/// muzzle height added: the public [`get_wind_at_position`] reading.
437pub(crate) fn apply_boundary_layer_shear(
438    base_wind: Vector3<f64>,
439    height_rel_launch_m: f64,
440    model: WindShearModel,
441) -> Vector3<f64> {
442    apply_boundary_layer_shear_agl(
443        base_wind,
444        height_rel_launch_m + APPROX_MUZZLE_HEIGHT_AGL_M,
445        model,
446    )
447}
448
449/// Scale `base_wind`'s horizontal components by the boundary-layer multiplier at
450/// `height_agl_m` above the ground. The fast-integrate and RK45 kernels call this with
451/// [`shear_height_agl_m`].
452pub(crate) fn apply_boundary_layer_shear_agl(
453    base_wind: Vector3<f64>,
454    height_agl_m: f64,
455    model: WindShearModel,
456) -> Vector3<f64> {
457    // MBA-728 pass-through decision: boundary-layer shear scales HORIZONTAL wind only;
458    // vertical wind (WindConditions::vertical_speed / WindSegment::vertical_mps, already
459    // carried on `base_wind.y` by the caller) passes through unscaled. Save/restore Y
460    // around the uniform scale rather than special-casing the multiply itself, since this
461    // helper is shared by the fast-integrate and RK45 shear paths and get_wind_at_position.
462    let vertical = base_wind.y;
463    let mut sheared = base_wind * boundary_layer_speed_ratio_agl(height_agl_m, model);
464    sheared.y = vertical;
465    sheared
466}
467
468/// High-level API function to get wind at arbitrary position
469///
470/// This is a convenience wrapper that handles wind segments, shear models,
471/// and altitude calculations in a single function call.
472///
473/// The shear height is `position[1]` taken as the height relative to the muzzle on a level
474/// shot, plus an assumed 1.5 m muzzle height. The trajectory kernels no longer call this: they
475/// measure the height above the ground from their own frame (see the module docs).
476///
477/// # Arguments
478/// * `position` - 3D position vector [x_downrange, y_vertical, z_lateral]
479/// * `wind_segments` - Wind segments as [`crate::wind::WindSegment`] (speed_kmh, angle_deg, until_distance_m)
480/// * `enable_wind_shear` - Whether to apply wind shear modeling
481/// * `wind_shear_model` - Model type: "none", "logarithmic", "power_law", "ekman_spiral"
482/// * `shooter_altitude_m` - Shooter's altitude above sea level
483///
484/// # Returns
485/// Wind vector in m/s [x_downrange, y_vertical, z_lateral]
486pub fn get_wind_at_position(
487    position: &Vector3<f64>,
488    wind_segments: &[crate::wind::WindSegment],
489    enable_wind_shear: bool,
490    wind_shear_model: &str,
491    shooter_altitude_m: f64,
492) -> Vector3<f64> {
493    // X IS DOWNRANGE (McCoy)
494    let range_m = position[0];
495    let altitude_m = position[1]; // Y is vertical, relative to shooter
496
497    // Find appropriate wind segment based on range. MBA-728: carry the segment's vertical_mps
498    // through alongside speed/angle so it survives into the wind vector below.
499    let base_wind = if wind_segments.is_empty() {
500        (0.0, 0.0, 0.0)
501    } else {
502        wind_segments
503            .iter()
504            .find(|seg| range_m < seg.until_m)
505            .map(|seg| (seg.speed_kmh, seg.angle_deg, seg.vertical_mps))
506            .unwrap_or((0.0, 0.0, 0.0))
507    };
508
509    // Convert base wind from km/h to m/s
510    let base_speed_mps = base_wind.0 * 0.2777778; // km/h to m/s
511    let base_direction_deg = base_wind.1;
512    let base_vertical_mps = base_wind.2;
513
514    if !enable_wind_shear || wind_shear_model == "none" {
515        // No shear - return constant wind
516        let ang = base_direction_deg.to_radians();
517        return crate::wind::wind_vector(base_speed_mps, ang, base_vertical_mps);
518    }
519
520    // Wind shear enabled: scale the operative (input) wind by a boundary-layer profile keyed off
521    // HEIGHT ABOVE GROUND. This public helper reads `altitude_m` (position[1], McCoy Y) as the
522    // bullet's height relative to the muzzle and adds the assumed muzzle height; for flat fire
523    // it stays within a few metres of the ground, so the bullet must experience ~full surface
524    // wind. The previous implementation treated this height-relative-to-line-of-sight as a true
525    // above-ground altitude and clamped it to zero below the roughness length, zeroing the
526    // crosswind for almost the whole flight.
527    // shooter_altitude_m is height above SEA LEVEL and is intentionally not used for the
528    // boundary-layer height (see boundary_layer_speed_ratio); kept in the signature for API
529    // stability and for callers that may pass it.
530    let _ = shooter_altitude_m;
531
532    let ang = base_direction_deg.to_radians();
533    let base_vector = crate::wind::wind_vector(base_speed_mps, ang, base_vertical_mps);
534    // apply_boundary_layer_shear preserves base_vector.y (vertical) unscaled (MBA-728).
535    apply_boundary_layer_shear(
536        base_vector,
537        altitude_m,
538        boundary_layer_model_from_name(wind_shear_model),
539    )
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    #[test]
547    fn test_wind_layer() {
548        // Standard ballistics coordinate system: X=downrange, Y=vertical, Z=lateral
549        // Wind direction: 0°=headwind, 90°=from right, 180°=tailwind, 270°=from left
550
551        // Test 0° wind (from north/front - headwind)
552        let layer_headwind = WindLayer {
553            altitude_m: 100.0,
554            speed_mps: 10.0,
555            direction_deg: 0.0, // Wind from front (headwind)
556        };
557
558        let vec = layer_headwind.to_vector();
559        assert!(
560            (vec.x - (-10.0)).abs() < 1e-6,
561            "0° wind should be headwind (negative X downrange)"
562        );
563        assert_eq!(vec.y, 0.0);
564        assert!(
565            (vec.z).abs() < 1e-6,
566            "0° wind (headwind) should have zero lateral (Z) component"
567        );
568
569        // Test 90° wind (from right)
570        let layer_crosswind = WindLayer {
571            altitude_m: 100.0,
572            speed_mps: 10.0,
573            direction_deg: 90.0, // Wind from right
574        };
575
576        let vec_cross = layer_crosswind.to_vector();
577        assert!(
578            (vec_cross.z - (-10.0)).abs() < 1e-6,
579            "90° wind should have negative Z lateral (from right)"
580        );
581        assert_eq!(vec_cross.y, 0.0);
582        assert!(
583            (vec_cross.x).abs() < 1e-6,
584            "90° wind (crosswind) should have zero downrange (X) component"
585        );
586    }
587
588    #[test]
589    fn test_logarithmic_profile() {
590        let profile = WindShearProfile {
591            model: WindShearModel::Logarithmic,
592            surface_wind: WindLayer {
593                altitude_m: 0.0,
594                speed_mps: 10.0,
595                direction_deg: 0.0,
596            },
597            ..Default::default()
598        };
599
600        // Wind should increase with altitude
601        let v10 = profile.get_wind_at_altitude(10.0).norm();
602        let v50 = profile.get_wind_at_altitude(50.0).norm();
603        let v100 = profile.get_wind_at_altitude(100.0).norm();
604
605        assert!(v10 > 0.0);
606        assert!(v50 > v10);
607        assert!(v100 > v50);
608    }
609
610    #[test]
611    fn test_boundary_layer_speed_ratio_flat_fire_full_wind() {
612        // Flat-fire trajectory: bullet stays within a few metres of launch height (and drops
613        // below the line of sight). The operative wind must NOT be attenuated -> ratio == 1.0.
614        for &h in &[-15.0, -11.3, -1.0, -0.2, 0.0, 0.14, 1.5, 5.0, 8.0] {
615            let r_log = boundary_layer_speed_ratio(h, WindShearModel::Logarithmic);
616            let r_pow = boundary_layer_speed_ratio(h, WindShearModel::PowerLaw);
617            assert!(
618                (r_log - 1.0).abs() < 1e-9,
619                "logarithmic ratio at h={h} should be 1.0 (full wind), got {r_log}"
620            );
621            assert!(
622                (r_pow - 1.0).abs() < 1e-9,
623                "power-law ratio at h={h} should be 1.0 (full wind), got {r_pow}"
624            );
625        }
626    }
627
628    #[test]
629    fn test_boundary_layer_speed_ratio_increases_aloft() {
630        // Well above the 10 m reference height the wind shears UP and is monotonic in altitude.
631        let r100 = boundary_layer_speed_ratio(100.0, WindShearModel::Logarithmic);
632        let r300 = boundary_layer_speed_ratio(300.0, WindShearModel::Logarithmic);
633        assert!(r100 > 1.0, "ratio at 100 m should exceed 1.0, got {r100}");
634        assert!(
635            r300 > r100,
636            "ratio should increase with altitude: {r300} !> {r100}"
637        );
638        // Magnitude sanity: ~1.4x at ~100 m above ground for the logarithmic profile.
639        assert!(
640            (r100 - 1.40).abs() < 0.10,
641            "ratio at ~100 m should be ~1.4, got {r100}"
642        );
643    }
644
645    #[test]
646    fn test_power_law_profile() {
647        let profile = WindShearProfile {
648            model: WindShearModel::PowerLaw,
649            surface_wind: WindLayer {
650                altitude_m: 0.0,
651                speed_mps: 10.0,
652                direction_deg: 0.0,
653            },
654            ..Default::default()
655        };
656
657        // Check power law relationship
658        let v100 = profile.get_wind_at_altitude(100.0).norm();
659        let expected = 10.0 * (100.0_f64 / 10.0).powf(1.0 / 7.0);
660        assert!((v100 - expected).abs() < 0.01);
661    }
662
663    #[test]
664    fn default_ekman_profile_veers_with_height() {
665        let profile = |surface_direction| WindShearProfile {
666            model: WindShearModel::EkmanSpiral,
667            surface_wind: WindLayer {
668                altitude_m: 0.0,
669                speed_mps: 10.0,
670                direction_deg: surface_direction,
671            },
672            ..Default::default()
673        };
674
675        for (surface_direction, halfway_direction, top_direction) in
676            [(0.0, 15.0, 30.0), (350.0, 365.0, 380.0)]
677        {
678            let profile = profile(surface_direction);
679            let halfway = profile.get_wind_at_altitude(500.0);
680            let expected_halfway = WindLayer {
681                altitude_m: 500.0,
682                speed_mps: 12.5,
683                direction_deg: halfway_direction,
684            }
685            .to_vector();
686            let top = profile.get_wind_at_altitude(1000.0);
687            let expected_top = WindLayer {
688                altitude_m: 1000.0,
689                speed_mps: 15.0,
690                direction_deg: top_direction,
691            }
692            .to_vector();
693
694            assert!((halfway - expected_halfway).norm() < 1e-12);
695            assert!((top - expected_top).norm() < 1e-12);
696        }
697
698        let wrapped_geostrophic = WindLayer {
699            altitude_m: 1000.0,
700            speed_mps: 8.0,
701            direction_deg: 30.0,
702        };
703        let wrapped = WindShearProfile {
704            geostrophic_wind: Some(wrapped_geostrophic),
705            ..profile(350.0)
706        };
707        let wrapped_halfway = WindLayer {
708            altitude_m: 500.0,
709            speed_mps: 9.0,
710            direction_deg: 10.0,
711        }
712        .to_vector();
713
714        assert!((wrapped.get_wind_at_altitude(500.0) - wrapped_halfway).norm() < 1e-12);
715        assert!(
716            (wrapped.get_wind_at_altitude(1000.0) - wrapped_geostrophic.to_vector()).norm() < 1e-12
717        );
718
719        let backing_geostrophic = WindLayer {
720            altitude_m: 1000.0,
721            speed_mps: 18.0,
722            direction_deg: 320.0,
723        };
724        let backing = WindShearProfile {
725            geostrophic_wind: Some(backing_geostrophic),
726            ..profile(350.0)
727        };
728        assert!(
729            (backing.get_wind_at_altitude(1000.0) - backing_geostrophic.to_vector()).norm() < 1e-12
730        );
731    }
732
733    #[test]
734    fn test_windsock_shear_is_independent_of_site_elevation() {
735        for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
736            let profile = WindShearProfile {
737                model,
738                surface_wind: WindLayer {
739                    altitude_m: 10.0,
740                    speed_mps: 10.0,
741                    direction_deg: 90.0,
742                },
743                ..Default::default()
744            };
745            let segments = vec![(10.0, 90.0, 1_000.0)];
746            let position = Vector3::new(100.0, 10.0, 0.0);
747
748            let sea_level = WindShearWindSock::with_shooter_altitude(
749                segments.clone(),
750                Some(profile.clone()),
751                0.0,
752            )
753            .vector_for_position(position);
754            let elevated =
755                WindShearWindSock::with_shooter_altitude(segments, Some(profile), 1_600.0)
756                    .vector_for_position(position);
757
758            assert!(
759                (sea_level - elevated).norm() < 1e-12,
760                "{model:?} shear must use height above local ground, not site elevation: sea={sea_level:?}, elevated={elevated:?}"
761            );
762            let expected_speed = 10.0 * boundary_layer_speed_ratio(10.0, model);
763            assert!((elevated.norm() - expected_speed).abs() < 1e-12);
764        }
765    }
766
767    #[test]
768    fn test_windsock_flat_fire_preserves_operative_wind() {
769        for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
770            let profile = WindShearProfile {
771                model,
772                surface_wind: WindLayer {
773                    altitude_m: 10.0,
774                    speed_mps: 10.0,
775                    direction_deg: 90.0,
776                },
777                ..Default::default()
778            };
779            let sock = WindShearWindSock::new(vec![(10.0, 90.0, 1_000.0)], Some(profile));
780
781            for height_rel_launch_m in [-1.0, 0.0, 1.0, 5.0] {
782                let wind = sock.vector_for_position(Vector3::new(100.0, height_rel_launch_m, 0.0));
783                assert!(
784                    (wind.norm() - 10.0).abs() < 1e-12,
785                    "{model:?} flat-fire wind at relative height {height_rel_launch_m} m must retain the operative 10 m/s input, got {wind:?}"
786                );
787            }
788
789            let aloft = sock.vector_for_position(Vector3::new(100.0, 100.0, 0.0));
790            assert!(
791                aloft.norm() > 10.0,
792                "{model:?} shear must still increase wind well above the launch height"
793            );
794        }
795    }
796
797    #[test]
798    fn apply_boundary_layer_shear_scales_horizontal_preserves_vertical() {
799        // Height comfortably above the 10 m boundary-layer reference height, so the speed
800        // ratio is meaningfully > 1.0 for both closed-form models (MBA-728 pass-through
801        // contract: shear scales horizontal wind only; vertical passes through unscaled).
802        let height_rel_launch_m = 100.0;
803        let base = Vector3::new(-3.0, 5.0, -4.0);
804
805        for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
806            let ratio = boundary_layer_speed_ratio(height_rel_launch_m, model);
807            assert!(
808                ratio > 1.02,
809                "{model:?} ratio at {height_rel_launch_m} m should be meaningfully > 1.0, got {ratio}"
810            );
811
812            let sheared = apply_boundary_layer_shear(base, height_rel_launch_m, model);
813
814            assert!(
815                sheared.x != base.x && sheared.z != base.z,
816                "{model:?}: horizontal x/z must be scaled, got sheared={sheared:?} base={base:?}"
817            );
818            assert!(
819                (sheared.x / base.x - ratio).abs() < 1e-9,
820                "{model:?}: x should scale by the boundary-layer ratio {ratio}, got {sheared:?}"
821            );
822            assert!(
823                (sheared.z / base.z - ratio).abs() < 1e-9,
824                "{model:?}: z should scale by the boundary-layer ratio {ratio}, got {sheared:?}"
825            );
826
827            // Vertical passes through EXACTLY unscaled (bit-equal), per the MBA-728 contract.
828            assert_eq!(
829                sheared.y.to_bits(),
830                base.y.to_bits(),
831                "{model:?}: vertical must pass through unscaled bit-for-bit, got {sheared:?}"
832            );
833            assert_eq!(sheared.y, 5.0);
834        }
835    }
836
837    #[test]
838    fn apply_boundary_layer_shear_zero_vertical_stays_zero() {
839        // Legacy behavior: a zero vertical component must remain exactly zero after shear.
840        let base = Vector3::new(-3.0, 0.0, -4.0);
841        for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
842            let sheared = apply_boundary_layer_shear(base, 100.0, model);
843            assert_eq!(sheared.y, 0.0);
844        }
845    }
846}
847
848#[cfg(test)]
849mod fix_validation_tests {
850    use super::*;
851    use nalgebra::Vector3;
852
853    #[test]
854    fn test_get_wind_at_position_flat_fire_full_crosswind() {
855        // Flat-fire: bullet ~1 m below line of sight, mid-range, 90deg full-value crosswind.
856        // 16.09344 km/h = 4.4704 m/s (10 mph). With the fix, lateral (Z) wind must be ~full.
857        let pos = Vector3::new(457.0, -1.0, 0.0); // [downrange, vertical(rel LOS), lateral]
858        let segs = [crate::wind::WindSegment::new(16.09344, 90.0, 1000.0)];
859        let w = get_wind_at_position(&pos, &segs, true, "logarithmic", 0.0);
860        let expected = 16.09344 * 0.2777778; // m/s
861        println!("flat-fire wind vec = {:?}, |Z| = {}", w, w.z.abs());
862        assert!(
863            (w.z.abs() - expected).abs() < 0.05,
864            "lateral wind should be ~full {expected:.3} m/s, got {:.3}",
865            w.z.abs()
866        );
867    }
868}