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(profile: &WindShearProfile, height_rel_launch_m: f64) -> f64 {
327    let minimum_height_m = profile.roughness_length.max(0.0) * 1.000_1;
328    let height_agl_m = (height_rel_launch_m + APPROX_MUZZLE_HEIGHT_AGL_M).max(minimum_height_m);
329    let sampled_speed_mps = profile.get_wind_at_altitude(height_agl_m).norm();
330    let reference_speed_mps = profile.surface_wind.speed_mps.abs().max(1e-9);
331
332    (sampled_speed_mps / reference_speed_mps).max(1.0)
333}
334
335/// Boundary-layer wind-speed multiplier for a projectile flying `height_rel_launch_m` above the
336/// muzzle (McCoy Y / height relative to the line of departure).
337///
338/// The user-supplied wind is treated as the *operative* surface/flight wind: the multiplier is
339/// floored at 1.0 so the wind is never reduced below the input value, and only increases
340/// (logarithmically, or by the 1/7 power law) for trajectories that climb well above the standard
341/// 10 m meteorological reference height. This matches the uniform-wind convention used by standard
342/// ballistic solvers for flat fire, while still adding genuine shear for high-angle / ELR shots.
343///
344/// Height above ground is approximated as the bullet's height gained plus an assumed muzzle
345/// height. Shooter altitude above *sea level* is deliberately excluded: boundary-layer shear is
346/// relative to the local ground, and air-density effects of altitude are modelled separately.
347///
348/// This replaces the previous behaviour where the height-relative-to-line-of-sight (~0 for flat
349/// fire) was treated as a true above-ground altitude and clamped to zero below the roughness
350/// length, which zeroed the crosswind for almost the whole flight (~5x too little drift).
351pub fn boundary_layer_speed_ratio(height_rel_launch_m: f64, model: WindShearModel) -> f64 {
352    const Z0: f64 = 0.03; // surface roughness length (short grass)
353    const H_REF: f64 = 10.0; // standard meteorological reference height of the input wind
354
355    let height_agl = (height_rel_launch_m + APPROX_MUZZLE_HEIGHT_AGL_M).max(Z0 * 1.000_1);
356    let ratio = match model {
357        WindShearModel::PowerLaw => (height_agl / H_REF).powf(1.0 / 7.0),
358        WindShearModel::Logarithmic => (height_agl / Z0).ln() / (H_REF / Z0).ln(),
359        // Ekman / custom / none have no closed-form near-ground scaling here -> operative wind.
360        _ => 1.0,
361    };
362    ratio.max(1.0)
363}
364
365pub(crate) fn boundary_layer_model_from_name(model: &str) -> WindShearModel {
366    match model {
367        "logarithmic" => WindShearModel::Logarithmic,
368        "power_law" | "powerlaw" => WindShearModel::PowerLaw,
369        "ekman_spiral" | "ekman" => WindShearModel::EkmanSpiral,
370        "custom_layers" | "custom" => WindShearModel::CustomLayers,
371        _ => WindShearModel::None,
372    }
373}
374
375pub(crate) fn apply_boundary_layer_shear(
376    base_wind: Vector3<f64>,
377    height_rel_launch_m: f64,
378    model: WindShearModel,
379) -> Vector3<f64> {
380    // MBA-728 pass-through decision: boundary-layer shear scales HORIZONTAL wind only;
381    // vertical wind (WindConditions::vertical_speed / WindSegment::vertical_mps, already
382    // carried on `base_wind.y` by the caller) passes through unscaled. Save/restore Y
383    // around the uniform scale rather than special-casing the multiply itself, since this
384    // helper is shared by both the cli_api and fast-integrate shear paths.
385    let vertical = base_wind.y;
386    let mut sheared = base_wind * boundary_layer_speed_ratio(height_rel_launch_m, model);
387    sheared.y = vertical;
388    sheared
389}
390
391/// High-level API function to get wind at arbitrary position
392///
393/// This is a convenience wrapper that handles wind segments, shear models,
394/// and altitude calculations in a single function call.
395///
396/// # Arguments
397/// * `position` - 3D position vector [x_downrange, y_vertical, z_lateral]
398/// * `wind_segments` - Wind segments as [`crate::wind::WindSegment`] (speed_kmh, angle_deg, until_distance_m)
399/// * `enable_wind_shear` - Whether to apply wind shear modeling
400/// * `wind_shear_model` - Model type: "none", "logarithmic", "power_law", "ekman_spiral"
401/// * `shooter_altitude_m` - Shooter's altitude above sea level
402///
403/// # Returns
404/// Wind vector in m/s [x_downrange, y_vertical, z_lateral]
405pub fn get_wind_at_position(
406    position: &Vector3<f64>,
407    wind_segments: &[crate::wind::WindSegment],
408    enable_wind_shear: bool,
409    wind_shear_model: &str,
410    shooter_altitude_m: f64,
411) -> Vector3<f64> {
412    // X IS DOWNRANGE (McCoy)
413    let range_m = position[0];
414    let altitude_m = position[1]; // Y is vertical, relative to shooter
415
416    // Find appropriate wind segment based on range. MBA-728: carry the segment's vertical_mps
417    // through alongside speed/angle so it survives into the wind vector below.
418    let base_wind = if wind_segments.is_empty() {
419        (0.0, 0.0, 0.0)
420    } else {
421        wind_segments
422            .iter()
423            .find(|seg| range_m < seg.until_m)
424            .map(|seg| (seg.speed_kmh, seg.angle_deg, seg.vertical_mps))
425            .unwrap_or((0.0, 0.0, 0.0))
426    };
427
428    // Convert base wind from km/h to m/s
429    let base_speed_mps = base_wind.0 * 0.2777778; // km/h to m/s
430    let base_direction_deg = base_wind.1;
431    let base_vertical_mps = base_wind.2;
432
433    if !enable_wind_shear || wind_shear_model == "none" {
434        // No shear - return constant wind
435        let ang = base_direction_deg.to_radians();
436        return crate::wind::wind_vector(base_speed_mps, ang, base_vertical_mps);
437    }
438
439    // Wind shear enabled: scale the operative (input) wind by a boundary-layer profile keyed off
440    // HEIGHT ABOVE GROUND. `altitude_m` (position[1], McCoy Y) is the bullet's height gained
441    // relative to the muzzle; for flat fire it stays within a few metres of the ground, so the
442    // bullet must experience ~full surface wind. The previous implementation treated this
443    // height-relative-to-line-of-sight as a true above-ground altitude and clamped it to zero
444    // below the roughness length, zeroing the crosswind for almost the whole flight.
445    // shooter_altitude_m is height above SEA LEVEL and is intentionally not used for the
446    // boundary-layer height (see boundary_layer_speed_ratio); kept in the signature for API
447    // stability and for callers that may pass it.
448    let _ = shooter_altitude_m;
449
450    let ang = base_direction_deg.to_radians();
451    let base_vector = crate::wind::wind_vector(base_speed_mps, ang, base_vertical_mps);
452    // apply_boundary_layer_shear preserves base_vector.y (vertical) unscaled (MBA-728).
453    apply_boundary_layer_shear(
454        base_vector,
455        altitude_m,
456        boundary_layer_model_from_name(wind_shear_model),
457    )
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    #[test]
465    fn test_wind_layer() {
466        // Standard ballistics coordinate system: X=downrange, Y=vertical, Z=lateral
467        // Wind direction: 0°=headwind, 90°=from right, 180°=tailwind, 270°=from left
468
469        // Test 0° wind (from north/front - headwind)
470        let layer_headwind = WindLayer {
471            altitude_m: 100.0,
472            speed_mps: 10.0,
473            direction_deg: 0.0, // Wind from front (headwind)
474        };
475
476        let vec = layer_headwind.to_vector();
477        assert!(
478            (vec.x - (-10.0)).abs() < 1e-6,
479            "0° wind should be headwind (negative X downrange)"
480        );
481        assert_eq!(vec.y, 0.0);
482        assert!(
483            (vec.z).abs() < 1e-6,
484            "0° wind (headwind) should have zero lateral (Z) component"
485        );
486
487        // Test 90° wind (from right)
488        let layer_crosswind = WindLayer {
489            altitude_m: 100.0,
490            speed_mps: 10.0,
491            direction_deg: 90.0, // Wind from right
492        };
493
494        let vec_cross = layer_crosswind.to_vector();
495        assert!(
496            (vec_cross.z - (-10.0)).abs() < 1e-6,
497            "90° wind should have negative Z lateral (from right)"
498        );
499        assert_eq!(vec_cross.y, 0.0);
500        assert!(
501            (vec_cross.x).abs() < 1e-6,
502            "90° wind (crosswind) should have zero downrange (X) component"
503        );
504    }
505
506    #[test]
507    fn test_logarithmic_profile() {
508        let profile = WindShearProfile {
509            model: WindShearModel::Logarithmic,
510            surface_wind: WindLayer {
511                altitude_m: 0.0,
512                speed_mps: 10.0,
513                direction_deg: 0.0,
514            },
515            ..Default::default()
516        };
517
518        // Wind should increase with altitude
519        let v10 = profile.get_wind_at_altitude(10.0).norm();
520        let v50 = profile.get_wind_at_altitude(50.0).norm();
521        let v100 = profile.get_wind_at_altitude(100.0).norm();
522
523        assert!(v10 > 0.0);
524        assert!(v50 > v10);
525        assert!(v100 > v50);
526    }
527
528    #[test]
529    fn test_boundary_layer_speed_ratio_flat_fire_full_wind() {
530        // Flat-fire trajectory: bullet stays within a few metres of launch height (and drops
531        // below the line of sight). The operative wind must NOT be attenuated -> ratio == 1.0.
532        for &h in &[-15.0, -11.3, -1.0, -0.2, 0.0, 0.14, 1.5, 5.0, 8.0] {
533            let r_log = boundary_layer_speed_ratio(h, WindShearModel::Logarithmic);
534            let r_pow = boundary_layer_speed_ratio(h, WindShearModel::PowerLaw);
535            assert!(
536                (r_log - 1.0).abs() < 1e-9,
537                "logarithmic ratio at h={h} should be 1.0 (full wind), got {r_log}"
538            );
539            assert!(
540                (r_pow - 1.0).abs() < 1e-9,
541                "power-law ratio at h={h} should be 1.0 (full wind), got {r_pow}"
542            );
543        }
544    }
545
546    #[test]
547    fn test_boundary_layer_speed_ratio_increases_aloft() {
548        // Well above the 10 m reference height the wind shears UP and is monotonic in altitude.
549        let r100 = boundary_layer_speed_ratio(100.0, WindShearModel::Logarithmic);
550        let r300 = boundary_layer_speed_ratio(300.0, WindShearModel::Logarithmic);
551        assert!(r100 > 1.0, "ratio at 100 m should exceed 1.0, got {r100}");
552        assert!(
553            r300 > r100,
554            "ratio should increase with altitude: {r300} !> {r100}"
555        );
556        // Magnitude sanity: ~1.4x at ~100 m above ground for the logarithmic profile.
557        assert!(
558            (r100 - 1.40).abs() < 0.10,
559            "ratio at ~100 m should be ~1.4, got {r100}"
560        );
561    }
562
563    #[test]
564    fn test_power_law_profile() {
565        let profile = WindShearProfile {
566            model: WindShearModel::PowerLaw,
567            surface_wind: WindLayer {
568                altitude_m: 0.0,
569                speed_mps: 10.0,
570                direction_deg: 0.0,
571            },
572            ..Default::default()
573        };
574
575        // Check power law relationship
576        let v100 = profile.get_wind_at_altitude(100.0).norm();
577        let expected = 10.0 * (100.0_f64 / 10.0).powf(1.0 / 7.0);
578        assert!((v100 - expected).abs() < 0.01);
579    }
580
581    #[test]
582    fn default_ekman_profile_veers_with_height() {
583        let profile = |surface_direction| WindShearProfile {
584            model: WindShearModel::EkmanSpiral,
585            surface_wind: WindLayer {
586                altitude_m: 0.0,
587                speed_mps: 10.0,
588                direction_deg: surface_direction,
589            },
590            ..Default::default()
591        };
592
593        for (surface_direction, halfway_direction, top_direction) in
594            [(0.0, 15.0, 30.0), (350.0, 365.0, 380.0)]
595        {
596            let profile = profile(surface_direction);
597            let halfway = profile.get_wind_at_altitude(500.0);
598            let expected_halfway = WindLayer {
599                altitude_m: 500.0,
600                speed_mps: 12.5,
601                direction_deg: halfway_direction,
602            }
603            .to_vector();
604            let top = profile.get_wind_at_altitude(1000.0);
605            let expected_top = WindLayer {
606                altitude_m: 1000.0,
607                speed_mps: 15.0,
608                direction_deg: top_direction,
609            }
610            .to_vector();
611
612            assert!((halfway - expected_halfway).norm() < 1e-12);
613            assert!((top - expected_top).norm() < 1e-12);
614        }
615
616        let wrapped_geostrophic = WindLayer {
617            altitude_m: 1000.0,
618            speed_mps: 8.0,
619            direction_deg: 30.0,
620        };
621        let wrapped = WindShearProfile {
622            geostrophic_wind: Some(wrapped_geostrophic),
623            ..profile(350.0)
624        };
625        let wrapped_halfway = WindLayer {
626            altitude_m: 500.0,
627            speed_mps: 9.0,
628            direction_deg: 10.0,
629        }
630        .to_vector();
631
632        assert!((wrapped.get_wind_at_altitude(500.0) - wrapped_halfway).norm() < 1e-12);
633        assert!(
634            (wrapped.get_wind_at_altitude(1000.0) - wrapped_geostrophic.to_vector()).norm() < 1e-12
635        );
636
637        let backing_geostrophic = WindLayer {
638            altitude_m: 1000.0,
639            speed_mps: 18.0,
640            direction_deg: 320.0,
641        };
642        let backing = WindShearProfile {
643            geostrophic_wind: Some(backing_geostrophic),
644            ..profile(350.0)
645        };
646        assert!(
647            (backing.get_wind_at_altitude(1000.0) - backing_geostrophic.to_vector()).norm() < 1e-12
648        );
649    }
650
651    #[test]
652    fn test_windsock_shear_is_independent_of_site_elevation() {
653        for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
654            let profile = WindShearProfile {
655                model,
656                surface_wind: WindLayer {
657                    altitude_m: 10.0,
658                    speed_mps: 10.0,
659                    direction_deg: 90.0,
660                },
661                ..Default::default()
662            };
663            let segments = vec![(10.0, 90.0, 1_000.0)];
664            let position = Vector3::new(100.0, 10.0, 0.0);
665
666            let sea_level = WindShearWindSock::with_shooter_altitude(
667                segments.clone(),
668                Some(profile.clone()),
669                0.0,
670            )
671            .vector_for_position(position);
672            let elevated =
673                WindShearWindSock::with_shooter_altitude(segments, Some(profile), 1_600.0)
674                    .vector_for_position(position);
675
676            assert!(
677                (sea_level - elevated).norm() < 1e-12,
678                "{model:?} shear must use height above local ground, not site elevation: sea={sea_level:?}, elevated={elevated:?}"
679            );
680            let expected_speed = 10.0 * boundary_layer_speed_ratio(10.0, model);
681            assert!((elevated.norm() - expected_speed).abs() < 1e-12);
682        }
683    }
684
685    #[test]
686    fn test_windsock_flat_fire_preserves_operative_wind() {
687        for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
688            let profile = WindShearProfile {
689                model,
690                surface_wind: WindLayer {
691                    altitude_m: 10.0,
692                    speed_mps: 10.0,
693                    direction_deg: 90.0,
694                },
695                ..Default::default()
696            };
697            let sock = WindShearWindSock::new(vec![(10.0, 90.0, 1_000.0)], Some(profile));
698
699            for height_rel_launch_m in [-1.0, 0.0, 1.0, 5.0] {
700                let wind = sock.vector_for_position(Vector3::new(100.0, height_rel_launch_m, 0.0));
701                assert!(
702                    (wind.norm() - 10.0).abs() < 1e-12,
703                    "{model:?} flat-fire wind at relative height {height_rel_launch_m} m must retain the operative 10 m/s input, got {wind:?}"
704                );
705            }
706
707            let aloft = sock.vector_for_position(Vector3::new(100.0, 100.0, 0.0));
708            assert!(
709                aloft.norm() > 10.0,
710                "{model:?} shear must still increase wind well above the launch height"
711            );
712        }
713    }
714
715    #[test]
716    fn apply_boundary_layer_shear_scales_horizontal_preserves_vertical() {
717        // Height comfortably above the 10 m boundary-layer reference height, so the speed
718        // ratio is meaningfully > 1.0 for both closed-form models (MBA-728 pass-through
719        // contract: shear scales horizontal wind only; vertical passes through unscaled).
720        let height_rel_launch_m = 100.0;
721        let base = Vector3::new(-3.0, 5.0, -4.0);
722
723        for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
724            let ratio = boundary_layer_speed_ratio(height_rel_launch_m, model);
725            assert!(
726                ratio > 1.02,
727                "{model:?} ratio at {height_rel_launch_m} m should be meaningfully > 1.0, got {ratio}"
728            );
729
730            let sheared = apply_boundary_layer_shear(base, height_rel_launch_m, model);
731
732            assert!(
733                sheared.x != base.x && sheared.z != base.z,
734                "{model:?}: horizontal x/z must be scaled, got sheared={sheared:?} base={base:?}"
735            );
736            assert!(
737                (sheared.x / base.x - ratio).abs() < 1e-9,
738                "{model:?}: x should scale by the boundary-layer ratio {ratio}, got {sheared:?}"
739            );
740            assert!(
741                (sheared.z / base.z - ratio).abs() < 1e-9,
742                "{model:?}: z should scale by the boundary-layer ratio {ratio}, got {sheared:?}"
743            );
744
745            // Vertical passes through EXACTLY unscaled (bit-equal), per the MBA-728 contract.
746            assert_eq!(
747                sheared.y.to_bits(),
748                base.y.to_bits(),
749                "{model:?}: vertical must pass through unscaled bit-for-bit, got {sheared:?}"
750            );
751            assert_eq!(sheared.y, 5.0);
752        }
753    }
754
755    #[test]
756    fn apply_boundary_layer_shear_zero_vertical_stays_zero() {
757        // Legacy behavior: a zero vertical component must remain exactly zero after shear.
758        let base = Vector3::new(-3.0, 0.0, -4.0);
759        for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
760            let sheared = apply_boundary_layer_shear(base, 100.0, model);
761            assert_eq!(sheared.y, 0.0);
762        }
763    }
764}
765
766#[cfg(test)]
767mod fix_validation_tests {
768    use super::*;
769    use nalgebra::Vector3;
770
771    #[test]
772    fn test_get_wind_at_position_flat_fire_full_crosswind() {
773        // Flat-fire: bullet ~1 m below line of sight, mid-range, 90deg full-value crosswind.
774        // 16.09344 km/h = 4.4704 m/s (10 mph). With the fix, lateral (Z) wind must be ~full.
775        let pos = Vector3::new(457.0, -1.0, 0.0); // [downrange, vertical(rel LOS), lateral]
776        let segs = [crate::wind::WindSegment::new(16.09344, 90.0, 1000.0)];
777        let w = get_wind_at_position(&pos, &segs, true, "logarithmic", 0.0);
778        let expected = 16.09344 * 0.2777778; // m/s
779        println!("flat-fire wind vec = {:?}, |Z| = {}", w, w.z.abs());
780        assert!(
781            (w.z.abs() - expected).abs() < 0.05,
782            "lateral wind should be ~full {expected:.3} m/s, got {:.3}",
783            w.z.abs()
784        );
785    }
786}