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
12/// Wind shear model types
13#[derive(Debug, Clone, Copy, PartialEq)]
14pub enum WindShearModel {
15    None,
16    Logarithmic,
17    PowerLaw,
18    EkmanSpiral,
19    CustomLayers,
20}
21
22/// Wind conditions at a specific altitude
23#[derive(Debug, Clone, Copy)]
24pub struct WindLayer {
25    pub altitude_m: f64,
26    pub speed_mps: f64,
27    pub direction_deg: f64, // Direction wind is coming FROM
28}
29
30impl WindLayer {
31    /// Convert to wind vector [x, y, z] in m/s
32    /// STANDARD BALLISTICS CONVENTION: X=downrange, Y=vertical, Z=lateral
33    pub fn to_vector(&self) -> Vector3<f64> {
34        let ang = self.direction_deg.to_radians();
35        Vector3::new(
36            -self.speed_mps * ang.cos(), // X (downrange - head/tail component)
37            0.0,                         // Y (vertical)
38            -self.speed_mps * ang.sin(), // Z (lateral - crosswind component)
39        )
40    }
41}
42
43/// Complete wind shear profile definition
44#[derive(Debug, Clone)]
45pub struct WindShearProfile {
46    pub model: WindShearModel,
47    pub surface_wind: WindLayer,
48    pub reference_height: f64, // Standard meteorological measurement height
49    pub roughness_length: f64, // Surface roughness (0.03 = short grass)
50    pub power_exponent: f64,   // Power law exponent (1/7 for neutral stability)
51    pub geostrophic_wind: Option<WindLayer>, // Wind above boundary layer
52    pub custom_layers: Vec<WindLayer>,
53}
54
55impl Default for WindShearProfile {
56    fn default() -> Self {
57        Self {
58            model: WindShearModel::None,
59            surface_wind: WindLayer {
60                altitude_m: 0.0,
61                speed_mps: 0.0,
62                direction_deg: 0.0,
63            },
64            reference_height: 10.0,
65            roughness_length: 0.03,
66            power_exponent: 1.0 / 7.0,
67            geostrophic_wind: None,
68            custom_layers: Vec::new(),
69        }
70    }
71}
72
73impl WindShearProfile {
74    /// Get wind vector at specified altitude
75    pub fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
76        match self.model {
77            WindShearModel::None => self.surface_wind.to_vector(),
78            WindShearModel::Logarithmic => self.logarithmic_profile(altitude_m),
79            WindShearModel::PowerLaw => self.power_law_profile(altitude_m),
80            WindShearModel::EkmanSpiral => self.ekman_spiral_profile(altitude_m),
81            WindShearModel::CustomLayers => self.interpolate_layers(altitude_m),
82        }
83    }
84
85    /// Logarithmic wind profile (boundary layer)
86    /// U(z) = U_ref * ln(z/z0) / ln(z_ref/z0)
87    fn logarithmic_profile(&self, altitude_m: f64) -> Vector3<f64> {
88        // Handle negative altitudes (bullet below sight line)
89        // Use absolute altitude, but add small offset only if very close to ground
90        let effective_altitude = if altitude_m < 0.0 {
91            // For negative altitudes, use a small positive value
92            0.001 // 1mm above ground
93        } else if altitude_m < 0.001 {
94            // Very small positive altitudes
95            0.001
96        } else {
97            altitude_m
98        };
99
100        // If very close to roughness length, return near-zero wind
101        if effective_altitude <= self.roughness_length {
102            return Vector3::zeros();
103        }
104
105        // Calculate speed ratio
106        let speed_ratio = if effective_altitude > self.roughness_length
107            && self.reference_height > self.roughness_length
108        {
109            (effective_altitude / self.roughness_length).ln()
110                / (self.reference_height / self.roughness_length).ln()
111        } else {
112            1.0
113        };
114
115        // Apply to surface wind
116        self.surface_wind.to_vector() * speed_ratio.max(0.0)
117    }
118
119    /// Power law wind profile
120    fn power_law_profile(&self, altitude_m: f64) -> Vector3<f64> {
121        if altitude_m <= 0.0 {
122            return Vector3::zeros();
123        }
124
125        // Calculate speed ratio
126        let speed_ratio = (altitude_m / self.reference_height).powf(self.power_exponent);
127
128        // Apply to surface wind
129        self.surface_wind.to_vector() * speed_ratio
130    }
131
132    /// Ekman spiral - wind direction changes with altitude
133    fn ekman_spiral_profile(&self, altitude_m: f64) -> Vector3<f64> {
134        // Default geostrophic wind if not specified
135        let geo_wind = self.geostrophic_wind.unwrap_or({
136            WindLayer {
137                altitude_m: 1000.0,
138                speed_mps: self.surface_wind.speed_mps * 1.5,
139                direction_deg: self.surface_wind.direction_deg - 30.0, // 30° backing
140            }
141        });
142
143        // Ekman layer depth (simplified)
144        let ekman_depth = 1000.0; // meters
145
146        if altitude_m >= ekman_depth {
147            return geo_wind.to_vector();
148        }
149
150        // Interpolate between surface and geostrophic
151        let ratio = altitude_m / ekman_depth;
152
153        // Interpolate speed
154        let speed = self.surface_wind.speed_mps * (1.0 - ratio) + geo_wind.speed_mps * ratio;
155
156        // Interpolate direction (accounting for circular interpolation)
157        let dir1 = self.surface_wind.direction_deg.to_radians();
158        let mut dir2 = geo_wind.direction_deg.to_radians();
159
160        // Handle angle wrapping
161        if (dir2 - dir1).abs() > PI {
162            if dir2 > dir1 {
163                dir2 -= 2.0 * PI;
164            } else {
165                dir2 += 2.0 * PI;
166            }
167        }
168
169        let direction_rad = dir1 * (1.0 - ratio) + dir2 * ratio;
170
171        // Convert to vector (X=downrange, Y=vertical, Z=lateral)
172        Vector3::new(
173            -speed * direction_rad.cos(), // X (downrange - head/tail)
174            0.0,
175            -speed * direction_rad.sin(), // Z (lateral - crosswind)
176        )
177    }
178
179    /// Interpolate wind from custom altitude layers
180    fn interpolate_layers(&self, altitude_m: f64) -> Vector3<f64> {
181        if self.custom_layers.is_empty() {
182            return self.surface_wind.to_vector();
183        }
184
185        // Clamp out-of-range queries to the nearest boundary layer instead of
186        // extrapolating. custom_layers is assumed sorted ascending by altitude (as the
187        // bracketing loop below already requires). The below-range case is handled by the
188        // loop (low_idx==high_idx==0); the above-range case otherwise interpolates between
189        // the TOP and FIRST layer (negative span) and extrapolates garbage.
190        let last = self.custom_layers.len() - 1;
191        if altitude_m >= self.custom_layers[last].altitude_m {
192            return self.custom_layers[last].to_vector();
193        }
194
195        // Find bracketing layers
196        let mut low_idx = 0;
197        let mut high_idx = 0;
198
199        for (i, layer) in self.custom_layers.iter().enumerate() {
200            if layer.altitude_m <= altitude_m {
201                low_idx = i;
202            }
203            if layer.altitude_m >= altitude_m {
204                high_idx = i;
205                break;
206            }
207        }
208
209        // Same layer or out of bounds
210        if low_idx == high_idx {
211            return self.custom_layers[low_idx].to_vector();
212        }
213
214        // Linear interpolation
215        let low_layer = &self.custom_layers[low_idx];
216        let high_layer = &self.custom_layers[high_idx];
217
218        // MBA-246: Guard against division by zero when layers have same altitude
219        let altitude_diff = high_layer.altitude_m - low_layer.altitude_m;
220        if altitude_diff.abs() < 1e-9 {
221            return low_layer.to_vector();
222        }
223
224        let ratio = (altitude_m - low_layer.altitude_m) / altitude_diff;
225
226        // Interpolate vectors
227        let low_vec = low_layer.to_vector();
228        let high_vec = high_layer.to_vector();
229
230        low_vec * (1.0 - ratio) + high_vec * ratio
231    }
232}
233
234/// Extended wind sock with altitude-dependent shear
235#[derive(Debug, Clone)]
236pub struct WindShearWindSock {
237    pub segments: Vec<(f64, f64, f64)>, // (speed_mps, angle_deg, until_range_m)
238    pub shear_profile: Option<WindShearProfile>,
239    pub shooter_altitude_m: f64,
240}
241
242impl WindShearWindSock {
243    pub fn new(segments: Vec<(f64, f64, f64)>, shear_profile: Option<WindShearProfile>) -> Self {
244        Self {
245            segments,
246            shear_profile,
247            shooter_altitude_m: 0.0,
248        }
249    }
250
251    pub fn with_shooter_altitude(
252        segments: Vec<(f64, f64, f64)>,
253        shear_profile: Option<WindShearProfile>,
254        shooter_altitude_m: f64,
255    ) -> Self {
256        Self {
257            segments,
258            shear_profile,
259            shooter_altitude_m,
260        }
261    }
262
263    /// Get wind vector for 3D position
264    /// Standard ballistics coordinate system: X=downrange, Y=vertical, Z=lateral
265    pub fn vector_for_position(&self, position: Vector3<f64>) -> Vector3<f64> {
266        let range_m = position.x; // X is downrange (McCoy)
267        let altitude_m = position.y; // Relative to shooter
268
269        // Get base wind at this range
270        let base_wind = self.get_range_wind(range_m);
271
272        if let Some(profile) = &self.shear_profile {
273            if profile.model != WindShearModel::None {
274                // Apply altitude-dependent shear
275                // Calculate absolute altitude by adding shooter's altitude
276                let absolute_altitude_m = altitude_m + self.shooter_altitude_m;
277                let altitude_vec = profile.get_wind_at_altitude(absolute_altitude_m);
278
279                // Scale the base wind by altitude profile
280                if base_wind.norm() > 0.0 {
281                    let scale_factor =
282                        altitude_vec.norm() / profile.surface_wind.speed_mps.max(1e-9);
283                    return base_wind * scale_factor;
284                }
285
286                return altitude_vec;
287            }
288        }
289
290        base_wind
291    }
292
293    /// Get wind based on horizontal range
294    /// Returns wind vector in standard ballistics coordinates: X=downrange, Y=vertical, Z=lateral
295    fn get_range_wind(&self, range_m: f64) -> Vector3<f64> {
296        if range_m.is_nan() || self.segments.is_empty() {
297            return Vector3::zeros();
298        }
299
300        // Find appropriate wind segment
301        for &(speed_mps, angle_deg, until_dist) in &self.segments {
302            if range_m <= until_dist {
303                let ang = angle_deg.to_radians();
304                return Vector3::new(
305                    -speed_mps * ang.cos(), // X (downrange - head/tail)
306                    0.0,
307                    -speed_mps * ang.sin(), // Z (lateral - crosswind)
308                );
309            }
310        }
311
312        // Beyond all segments
313        Vector3::zeros()
314    }
315}
316
317/// Boundary-layer wind-speed multiplier for a projectile flying `height_rel_launch_m` above the
318/// muzzle (McCoy Y / height relative to the line of departure).
319///
320/// The user-supplied wind is treated as the *operative* surface/flight wind: the multiplier is
321/// floored at 1.0 so the wind is never reduced below the input value, and only increases
322/// (logarithmically, or by the 1/7 power law) for trajectories that climb well above the standard
323/// 10 m meteorological reference height. This matches the uniform-wind convention used by standard
324/// ballistic solvers for flat fire, while still adding genuine shear for high-angle / ELR shots.
325///
326/// Height above ground is approximated as the bullet's height gained plus an assumed muzzle
327/// height. Shooter altitude above *sea level* is deliberately excluded: boundary-layer shear is
328/// relative to the local ground, and air-density effects of altitude are modelled separately.
329///
330/// This replaces the previous behaviour where the height-relative-to-line-of-sight (~0 for flat
331/// fire) was treated as a true above-ground altitude and clamped to zero below the roughness
332/// length, which zeroed the crosswind for almost the whole flight (~5x too little drift).
333pub fn boundary_layer_speed_ratio(height_rel_launch_m: f64, model: WindShearModel) -> f64 {
334    const Z0: f64 = 0.03; // surface roughness length (short grass)
335    const H_REF: f64 = 10.0; // standard meteorological reference height of the input wind
336    const MUZZLE_HEIGHT_M: f64 = 1.5; // approximate height of the bore above ground
337
338    let height_agl = (height_rel_launch_m + MUZZLE_HEIGHT_M).max(Z0 * 1.000_1);
339    let ratio = match model {
340        WindShearModel::PowerLaw => (height_agl / H_REF).powf(1.0 / 7.0),
341        WindShearModel::Logarithmic => (height_agl / Z0).ln() / (H_REF / Z0).ln(),
342        // Ekman / custom / none have no closed-form near-ground scaling here -> operative wind.
343        _ => 1.0,
344    };
345    ratio.max(1.0)
346}
347
348/// High-level API function to get wind at arbitrary position
349///
350/// This is a convenience wrapper that handles wind segments, shear models,
351/// and altitude calculations in a single function call.
352///
353/// # Arguments
354/// * `position` - 3D position vector [x_downrange, y_vertical, z_lateral]
355/// * `wind_segments` - Wind segments as (speed_kmh, angle_deg, until_distance_m)
356/// * `enable_wind_shear` - Whether to apply wind shear modeling
357/// * `wind_shear_model` - Model type: "none", "logarithmic", "power_law", "ekman_spiral"
358/// * `shooter_altitude_m` - Shooter's altitude above sea level
359///
360/// # Returns
361/// Wind vector in m/s [x_downrange, y_vertical, z_lateral]
362pub fn get_wind_at_position(
363    position: &Vector3<f64>,
364    wind_segments: &[(f64, f64, f64)], // (speed_kmh, angle_deg, until_distance_m)
365    enable_wind_shear: bool,
366    wind_shear_model: &str,
367    shooter_altitude_m: f64,
368) -> Vector3<f64> {
369    // X IS DOWNRANGE (McCoy)
370    let range_m = position[0];
371    let altitude_m = position[1]; // Y is vertical, relative to shooter
372
373    // Find appropriate wind segment based on range
374    let base_wind = if wind_segments.is_empty() {
375        (0.0, 0.0)
376    } else {
377        // Find the segment that covers this range
378        let mut found_wind = (wind_segments[0].0, wind_segments[0].1);
379        for seg in wind_segments {
380            if range_m <= seg.2 {
381                found_wind = (seg.0, seg.1);
382                break;
383            }
384        }
385        found_wind
386    };
387
388    // Convert base wind from km/h to m/s
389    let base_speed_mps = base_wind.0 * 0.2777778; // km/h to m/s
390    let base_direction_deg = base_wind.1;
391
392    if !enable_wind_shear || wind_shear_model == "none" {
393        // No shear - return constant wind
394        let ang = base_direction_deg.to_radians();
395        return Vector3::new(
396            -base_speed_mps * ang.cos(), // x (downrange)
397            0.0,                         // y (vertical)
398            -base_speed_mps * ang.sin(), // z (lateral)
399        );
400    }
401
402    // Wind shear enabled: scale the operative (input) wind by a boundary-layer profile keyed off
403    // HEIGHT ABOVE GROUND. `altitude_m` (position[1], McCoy Y) is the bullet's height gained
404    // relative to the muzzle; for flat fire it stays within a few metres of the ground, so the
405    // bullet must experience ~full surface wind. The previous implementation treated this
406    // height-relative-to-line-of-sight as a true above-ground altitude and clamped it to zero
407    // below the roughness length, zeroing the crosswind for almost the whole flight.
408    let model = match wind_shear_model {
409        "logarithmic" => WindShearModel::Logarithmic,
410        "power_law" | "powerlaw" => WindShearModel::PowerLaw,
411        "ekman_spiral" | "ekman" => WindShearModel::EkmanSpiral,
412        "custom_layers" | "custom" => WindShearModel::CustomLayers,
413        _ => WindShearModel::None,
414    };
415
416    // shooter_altitude_m is height above SEA LEVEL and is intentionally not used for the
417    // boundary-layer height (see boundary_layer_speed_ratio); kept in the signature for API
418    // stability and for callers that may pass it.
419    let _ = shooter_altitude_m;
420
421    let speed_ratio = boundary_layer_speed_ratio(altitude_m, model);
422    let ang = base_direction_deg.to_radians();
423    Vector3::new(
424        -base_speed_mps * ang.cos() * speed_ratio, // x (downrange head/tail)
425        0.0,                                       // y (vertical)
426        -base_speed_mps * ang.sin() * speed_ratio, // z (lateral crosswind)
427    )
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn test_wind_layer() {
436        // Standard ballistics coordinate system: X=downrange, Y=vertical, Z=lateral
437        // Wind direction: 0°=headwind, 90°=from right, 180°=tailwind, 270°=from left
438
439        // Test 0° wind (from north/front - headwind)
440        let layer_headwind = WindLayer {
441            altitude_m: 100.0,
442            speed_mps: 10.0,
443            direction_deg: 0.0, // Wind from front (headwind)
444        };
445
446        let vec = layer_headwind.to_vector();
447        assert!(
448            (vec.x - (-10.0)).abs() < 1e-6,
449            "0° wind should be headwind (negative X downrange)"
450        );
451        assert_eq!(vec.y, 0.0);
452        assert!(
453            (vec.z).abs() < 1e-6,
454            "0° wind (headwind) should have zero lateral (Z) component"
455        );
456
457        // Test 90° wind (from right)
458        let layer_crosswind = WindLayer {
459            altitude_m: 100.0,
460            speed_mps: 10.0,
461            direction_deg: 90.0, // Wind from right
462        };
463
464        let vec_cross = layer_crosswind.to_vector();
465        assert!(
466            (vec_cross.z - (-10.0)).abs() < 1e-6,
467            "90° wind should have negative Z lateral (from right)"
468        );
469        assert_eq!(vec_cross.y, 0.0);
470        assert!(
471            (vec_cross.x).abs() < 1e-6,
472            "90° wind (crosswind) should have zero downrange (X) component"
473        );
474    }
475
476    #[test]
477    fn test_logarithmic_profile() {
478        let mut profile = WindShearProfile::default();
479        profile.model = WindShearModel::Logarithmic;
480        profile.surface_wind = WindLayer {
481            altitude_m: 0.0,
482            speed_mps: 10.0,
483            direction_deg: 0.0,
484        };
485
486        // Wind should increase with altitude
487        let v10 = profile.get_wind_at_altitude(10.0).norm();
488        let v50 = profile.get_wind_at_altitude(50.0).norm();
489        let v100 = profile.get_wind_at_altitude(100.0).norm();
490
491        assert!(v10 > 0.0);
492        assert!(v50 > v10);
493        assert!(v100 > v50);
494    }
495
496    #[test]
497    fn test_boundary_layer_speed_ratio_flat_fire_full_wind() {
498        // Flat-fire trajectory: bullet stays within a few metres of launch height (and drops
499        // below the line of sight). The operative wind must NOT be attenuated -> ratio == 1.0.
500        for &h in &[-15.0, -11.3, -1.0, -0.2, 0.0, 0.14, 1.5, 5.0, 8.0] {
501            let r_log = boundary_layer_speed_ratio(h, WindShearModel::Logarithmic);
502            let r_pow = boundary_layer_speed_ratio(h, WindShearModel::PowerLaw);
503            assert!(
504                (r_log - 1.0).abs() < 1e-9,
505                "logarithmic ratio at h={h} should be 1.0 (full wind), got {r_log}"
506            );
507            assert!(
508                (r_pow - 1.0).abs() < 1e-9,
509                "power-law ratio at h={h} should be 1.0 (full wind), got {r_pow}"
510            );
511        }
512    }
513
514    #[test]
515    fn test_boundary_layer_speed_ratio_increases_aloft() {
516        // Well above the 10 m reference height the wind shears UP and is monotonic in altitude.
517        let r100 = boundary_layer_speed_ratio(100.0, WindShearModel::Logarithmic);
518        let r300 = boundary_layer_speed_ratio(300.0, WindShearModel::Logarithmic);
519        assert!(r100 > 1.0, "ratio at 100 m should exceed 1.0, got {r100}");
520        assert!(r300 > r100, "ratio should increase with altitude: {r300} !> {r100}");
521        // Magnitude sanity: ~1.4x at ~100 m above ground for the logarithmic profile.
522        assert!(
523            (r100 - 1.40).abs() < 0.10,
524            "ratio at ~100 m should be ~1.4, got {r100}"
525        );
526    }
527
528    #[test]
529    fn test_power_law_profile() {
530        let mut profile = WindShearProfile::default();
531        profile.model = WindShearModel::PowerLaw;
532        profile.surface_wind = WindLayer {
533            altitude_m: 0.0,
534            speed_mps: 10.0,
535            direction_deg: 0.0,
536        };
537
538        // Check power law relationship
539        let v100 = profile.get_wind_at_altitude(100.0).norm();
540        let expected = 10.0 * (100.0_f64 / 10.0).powf(1.0 / 7.0);
541        assert!((v100 - expected).abs() < 0.01);
542    }
543}
544
545#[cfg(test)]
546mod fix_validation_tests {
547    use super::*;
548    use nalgebra::Vector3;
549
550    #[test]
551    fn test_get_wind_at_position_flat_fire_full_crosswind() {
552        // Flat-fire: bullet ~1 m below line of sight, mid-range, 90deg full-value crosswind.
553        // 16.09344 km/h = 4.4704 m/s (10 mph). With the fix, lateral (Z) wind must be ~full.
554        let pos = Vector3::new(457.0, -1.0, 0.0); // [downrange, vertical(rel LOS), lateral]
555        let segs = [(16.09344_f64, 90.0_f64, 1000.0_f64)];
556        let w = get_wind_at_position(&pos, &segs, true, "logarithmic", 0.0);
557        let expected = 16.09344 * 0.2777778; // m/s
558        println!("flat-fire wind vec = {:?}, |Z| = {}", w, w.z.abs());
559        assert!(
560            (w.z.abs() - expected).abs() < 0.05,
561            "lateral wind should be ~full {expected:.3} m/s, got {:.3}",
562            w.z.abs()
563        );
564    }
565}