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