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 profile = WindShearProfile {
514            model: WindShearModel::Logarithmic,
515            surface_wind: WindLayer {
516                altitude_m: 0.0,
517                speed_mps: 10.0,
518                direction_deg: 0.0,
519            },
520            ..Default::default()
521        };
522
523        // Wind should increase with altitude
524        let v10 = profile.get_wind_at_altitude(10.0).norm();
525        let v50 = profile.get_wind_at_altitude(50.0).norm();
526        let v100 = profile.get_wind_at_altitude(100.0).norm();
527
528        assert!(v10 > 0.0);
529        assert!(v50 > v10);
530        assert!(v100 > v50);
531    }
532
533    #[test]
534    fn test_boundary_layer_speed_ratio_flat_fire_full_wind() {
535        // Flat-fire trajectory: bullet stays within a few metres of launch height (and drops
536        // below the line of sight). The operative wind must NOT be attenuated -> ratio == 1.0.
537        for &h in &[-15.0, -11.3, -1.0, -0.2, 0.0, 0.14, 1.5, 5.0, 8.0] {
538            let r_log = boundary_layer_speed_ratio(h, WindShearModel::Logarithmic);
539            let r_pow = boundary_layer_speed_ratio(h, WindShearModel::PowerLaw);
540            assert!(
541                (r_log - 1.0).abs() < 1e-9,
542                "logarithmic ratio at h={h} should be 1.0 (full wind), got {r_log}"
543            );
544            assert!(
545                (r_pow - 1.0).abs() < 1e-9,
546                "power-law ratio at h={h} should be 1.0 (full wind), got {r_pow}"
547            );
548        }
549    }
550
551    #[test]
552    fn test_boundary_layer_speed_ratio_increases_aloft() {
553        // Well above the 10 m reference height the wind shears UP and is monotonic in altitude.
554        let r100 = boundary_layer_speed_ratio(100.0, WindShearModel::Logarithmic);
555        let r300 = boundary_layer_speed_ratio(300.0, WindShearModel::Logarithmic);
556        assert!(r100 > 1.0, "ratio at 100 m should exceed 1.0, got {r100}");
557        assert!(
558            r300 > r100,
559            "ratio should increase with altitude: {r300} !> {r100}"
560        );
561        // Magnitude sanity: ~1.4x at ~100 m above ground for the logarithmic profile.
562        assert!(
563            (r100 - 1.40).abs() < 0.10,
564            "ratio at ~100 m should be ~1.4, got {r100}"
565        );
566    }
567
568    #[test]
569    fn test_power_law_profile() {
570        let profile = WindShearProfile {
571            model: WindShearModel::PowerLaw,
572            surface_wind: WindLayer {
573                altitude_m: 0.0,
574                speed_mps: 10.0,
575                direction_deg: 0.0,
576            },
577            ..Default::default()
578        };
579
580        // Check power law relationship
581        let v100 = profile.get_wind_at_altitude(100.0).norm();
582        let expected = 10.0 * (100.0_f64 / 10.0).powf(1.0 / 7.0);
583        assert!((v100 - expected).abs() < 0.01);
584    }
585
586    #[test]
587    fn default_ekman_profile_veers_with_height() {
588        let profile = |surface_direction| WindShearProfile {
589            model: WindShearModel::EkmanSpiral,
590            surface_wind: WindLayer {
591                altitude_m: 0.0,
592                speed_mps: 10.0,
593                direction_deg: surface_direction,
594            },
595            ..Default::default()
596        };
597
598        for (surface_direction, halfway_direction, top_direction) in
599            [(0.0, 15.0, 30.0), (350.0, 365.0, 380.0)]
600        {
601            let profile = profile(surface_direction);
602            let halfway = profile.get_wind_at_altitude(500.0);
603            let expected_halfway = WindLayer {
604                altitude_m: 500.0,
605                speed_mps: 12.5,
606                direction_deg: halfway_direction,
607            }
608            .to_vector();
609            let top = profile.get_wind_at_altitude(1000.0);
610            let expected_top = WindLayer {
611                altitude_m: 1000.0,
612                speed_mps: 15.0,
613                direction_deg: top_direction,
614            }
615            .to_vector();
616
617            assert!((halfway - expected_halfway).norm() < 1e-12);
618            assert!((top - expected_top).norm() < 1e-12);
619        }
620
621        let wrapped_geostrophic = WindLayer {
622            altitude_m: 1000.0,
623            speed_mps: 8.0,
624            direction_deg: 30.0,
625        };
626        let wrapped = WindShearProfile {
627            geostrophic_wind: Some(wrapped_geostrophic),
628            ..profile(350.0)
629        };
630        let wrapped_halfway = WindLayer {
631            altitude_m: 500.0,
632            speed_mps: 9.0,
633            direction_deg: 10.0,
634        }
635        .to_vector();
636
637        assert!((wrapped.get_wind_at_altitude(500.0) - wrapped_halfway).norm() < 1e-12);
638        assert!(
639            (wrapped.get_wind_at_altitude(1000.0) - wrapped_geostrophic.to_vector()).norm() < 1e-12
640        );
641
642        let backing_geostrophic = WindLayer {
643            altitude_m: 1000.0,
644            speed_mps: 18.0,
645            direction_deg: 320.0,
646        };
647        let backing = WindShearProfile {
648            geostrophic_wind: Some(backing_geostrophic),
649            ..profile(350.0)
650        };
651        assert!(
652            (backing.get_wind_at_altitude(1000.0) - backing_geostrophic.to_vector()).norm() < 1e-12
653        );
654    }
655
656    #[test]
657    fn test_windsock_shear_is_independent_of_site_elevation() {
658        for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
659            let profile = WindShearProfile {
660                model,
661                surface_wind: WindLayer {
662                    altitude_m: 10.0,
663                    speed_mps: 10.0,
664                    direction_deg: 90.0,
665                },
666                ..Default::default()
667            };
668            let segments = vec![(10.0, 90.0, 1_000.0)];
669            let position = Vector3::new(100.0, 10.0, 0.0);
670
671            let sea_level = WindShearWindSock::with_shooter_altitude(
672                segments.clone(),
673                Some(profile.clone()),
674                0.0,
675            )
676            .vector_for_position(position);
677            let elevated =
678                WindShearWindSock::with_shooter_altitude(segments, Some(profile), 1_600.0)
679                    .vector_for_position(position);
680
681            assert!(
682                (sea_level - elevated).norm() < 1e-12,
683                "{model:?} shear must use height above local ground, not site elevation: sea={sea_level:?}, elevated={elevated:?}"
684            );
685            let expected_speed = 10.0 * boundary_layer_speed_ratio(10.0, model);
686            assert!((elevated.norm() - expected_speed).abs() < 1e-12);
687        }
688    }
689
690    #[test]
691    fn test_windsock_flat_fire_preserves_operative_wind() {
692        for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
693            let profile = WindShearProfile {
694                model,
695                surface_wind: WindLayer {
696                    altitude_m: 10.0,
697                    speed_mps: 10.0,
698                    direction_deg: 90.0,
699                },
700                ..Default::default()
701            };
702            let sock = WindShearWindSock::new(
703                vec![(10.0, 90.0, 1_000.0)],
704                Some(profile),
705            );
706
707            for height_rel_launch_m in [-1.0, 0.0, 1.0, 5.0] {
708                let wind = sock.vector_for_position(Vector3::new(
709                    100.0,
710                    height_rel_launch_m,
711                    0.0,
712                ));
713                assert!(
714                    (wind.norm() - 10.0).abs() < 1e-12,
715                    "{model:?} flat-fire wind at relative height {height_rel_launch_m} m must retain the operative 10 m/s input, got {wind:?}"
716                );
717            }
718
719            let aloft = sock.vector_for_position(Vector3::new(100.0, 100.0, 0.0));
720            assert!(
721                aloft.norm() > 10.0,
722                "{model:?} shear must still increase wind well above the launch height"
723            );
724        }
725    }
726
727    #[test]
728    fn apply_boundary_layer_shear_scales_horizontal_preserves_vertical() {
729        // Height comfortably above the 10 m boundary-layer reference height, so the speed
730        // ratio is meaningfully > 1.0 for both closed-form models (MBA-728 pass-through
731        // contract: shear scales horizontal wind only; vertical passes through unscaled).
732        let height_rel_launch_m = 100.0;
733        let base = Vector3::new(-3.0, 5.0, -4.0);
734
735        for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
736            let ratio = boundary_layer_speed_ratio(height_rel_launch_m, model);
737            assert!(
738                ratio > 1.02,
739                "{model:?} ratio at {height_rel_launch_m} m should be meaningfully > 1.0, got {ratio}"
740            );
741
742            let sheared = apply_boundary_layer_shear(base, height_rel_launch_m, model);
743
744            assert!(
745                sheared.x != base.x && sheared.z != base.z,
746                "{model:?}: horizontal x/z must be scaled, got sheared={sheared:?} base={base:?}"
747            );
748            assert!(
749                (sheared.x / base.x - ratio).abs() < 1e-9,
750                "{model:?}: x should scale by the boundary-layer ratio {ratio}, got {sheared:?}"
751            );
752            assert!(
753                (sheared.z / base.z - ratio).abs() < 1e-9,
754                "{model:?}: z should scale by the boundary-layer ratio {ratio}, got {sheared:?}"
755            );
756
757            // Vertical passes through EXACTLY unscaled (bit-equal), per the MBA-728 contract.
758            assert_eq!(
759                sheared.y.to_bits(),
760                base.y.to_bits(),
761                "{model:?}: vertical must pass through unscaled bit-for-bit, got {sheared:?}"
762            );
763            assert_eq!(sheared.y, 5.0);
764        }
765    }
766
767    #[test]
768    fn apply_boundary_layer_shear_zero_vertical_stays_zero() {
769        // Legacy behavior: a zero vertical component must remain exactly zero after shear.
770        let base = Vector3::new(-3.0, 0.0, -4.0);
771        for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
772            let sheared = apply_boundary_layer_shear(base, 100.0, model);
773            assert_eq!(sheared.y, 0.0);
774        }
775    }
776}
777
778#[cfg(test)]
779mod fix_validation_tests {
780    use super::*;
781    use nalgebra::Vector3;
782
783    #[test]
784    fn test_get_wind_at_position_flat_fire_full_crosswind() {
785        // Flat-fire: bullet ~1 m below line of sight, mid-range, 90deg full-value crosswind.
786        // 16.09344 km/h = 4.4704 m/s (10 mph). With the fix, lateral (Z) wind must be ~full.
787        let pos = Vector3::new(457.0, -1.0, 0.0); // [downrange, vertical(rel LOS), lateral]
788        let segs = [crate::wind::WindSegment::new(16.09344, 90.0, 1000.0)];
789        let w = get_wind_at_position(&pos, &segs, true, "logarithmic", 0.0);
790        let expected = 16.09344 * 0.2777778; // m/s
791        println!("flat-fire wind vec = {:?}, |Z| = {}", w, w.z.abs());
792        assert!(
793            (w.z.abs() - expected).abs() < 0.05,
794            "lateral wind should be ~full {expected:.3} m/s, got {:.3}",
795            w.z.abs()
796        );
797    }
798}