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