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=lateral, Y=vertical, Z=downrange
33    pub fn to_vector(&self) -> Vector3<f64> {
34        let ang = self.direction_deg.to_radians();
35        Vector3::new(
36            -self.speed_mps * ang.sin(), // X (lateral - crosswind component)
37            0.0,                         // Y (vertical)
38            -self.speed_mps * ang.cos(), // Z (downrange - head/tail 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=lateral, Y=vertical, Z=downrange)
172        Vector3::new(
173            -speed * direction_rad.sin(), // X (lateral - crosswind)
174            0.0,
175            -speed * direction_rad.cos(), // Z (downrange - head/tail)
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        // Find bracketing layers
186        let mut low_idx = 0;
187        let mut high_idx = 0;
188
189        for (i, layer) in self.custom_layers.iter().enumerate() {
190            if layer.altitude_m <= altitude_m {
191                low_idx = i;
192            }
193            if layer.altitude_m >= altitude_m {
194                high_idx = i;
195                break;
196            }
197        }
198
199        // Same layer or out of bounds
200        if low_idx == high_idx {
201            return self.custom_layers[low_idx].to_vector();
202        }
203
204        // Linear interpolation
205        let low_layer = &self.custom_layers[low_idx];
206        let high_layer = &self.custom_layers[high_idx];
207
208        // MBA-246: Guard against division by zero when layers have same altitude
209        let altitude_diff = high_layer.altitude_m - low_layer.altitude_m;
210        if altitude_diff.abs() < 1e-9 {
211            return low_layer.to_vector();
212        }
213
214        let ratio = (altitude_m - low_layer.altitude_m) / altitude_diff;
215
216        // Interpolate vectors
217        let low_vec = low_layer.to_vector();
218        let high_vec = high_layer.to_vector();
219
220        low_vec * (1.0 - ratio) + high_vec * ratio
221    }
222}
223
224/// Extended wind sock with altitude-dependent shear
225#[derive(Debug, Clone)]
226pub struct WindShearWindSock {
227    pub segments: Vec<(f64, f64, f64)>, // (speed_mps, angle_deg, until_range_m)
228    pub shear_profile: Option<WindShearProfile>,
229    pub shooter_altitude_m: f64,
230}
231
232impl WindShearWindSock {
233    pub fn new(segments: Vec<(f64, f64, f64)>, shear_profile: Option<WindShearProfile>) -> Self {
234        Self {
235            segments,
236            shear_profile,
237            shooter_altitude_m: 0.0,
238        }
239    }
240
241    pub fn with_shooter_altitude(
242        segments: Vec<(f64, f64, f64)>,
243        shear_profile: Option<WindShearProfile>,
244        shooter_altitude_m: f64,
245    ) -> Self {
246        Self {
247            segments,
248            shear_profile,
249            shooter_altitude_m,
250        }
251    }
252
253    /// Get wind vector for 3D position
254    /// Standard ballistics coordinate system: X=lateral, Y=vertical, Z=downrange
255    pub fn vector_for_position(&self, position: Vector3<f64>) -> Vector3<f64> {
256        let range_m = position.z; // Z is downrange
257        let altitude_m = position.y; // Relative to shooter
258
259        // Get base wind at this range
260        let base_wind = self.get_range_wind(range_m);
261
262        if let Some(profile) = &self.shear_profile {
263            if profile.model != WindShearModel::None {
264                // Apply altitude-dependent shear
265                // Calculate absolute altitude by adding shooter's altitude
266                let absolute_altitude_m = altitude_m + self.shooter_altitude_m;
267                let altitude_vec = profile.get_wind_at_altitude(absolute_altitude_m);
268
269                // Scale the base wind by altitude profile
270                if base_wind.norm() > 0.0 {
271                    let scale_factor =
272                        altitude_vec.norm() / profile.surface_wind.speed_mps.max(1e-9);
273                    return base_wind * scale_factor;
274                }
275
276                return altitude_vec;
277            }
278        }
279
280        base_wind
281    }
282
283    /// Get wind based on horizontal range
284    /// Returns wind vector in standard ballistics coordinates: X=lateral, Y=vertical, Z=downrange
285    fn get_range_wind(&self, range_m: f64) -> Vector3<f64> {
286        if range_m.is_nan() || self.segments.is_empty() {
287            return Vector3::zeros();
288        }
289
290        // Find appropriate wind segment
291        for &(speed_mps, angle_deg, until_dist) in &self.segments {
292            if range_m <= until_dist {
293                let ang = angle_deg.to_radians();
294                return Vector3::new(
295                    -speed_mps * ang.sin(), // X (lateral - crosswind)
296                    0.0,
297                    -speed_mps * ang.cos(), // Z (downrange - head/tail)
298                );
299            }
300        }
301
302        // Beyond all segments
303        Vector3::zeros()
304    }
305}
306
307/// High-level API function to get wind at arbitrary position
308///
309/// This is a convenience wrapper that handles wind segments, shear models,
310/// and altitude calculations in a single function call.
311///
312/// # Arguments
313/// * `position` - 3D position vector [x_downrange, y_vertical, z_lateral]
314/// * `wind_segments` - Wind segments as (speed_kmh, angle_deg, until_distance_m)
315/// * `enable_wind_shear` - Whether to apply wind shear modeling
316/// * `wind_shear_model` - Model type: "none", "logarithmic", "power_law", "ekman_spiral"
317/// * `shooter_altitude_m` - Shooter's altitude above sea level
318///
319/// # Returns
320/// Wind vector in m/s [x_downrange, y_vertical, z_lateral]
321pub fn get_wind_at_position(
322    position: &Vector3<f64>,
323    wind_segments: &[(f64, f64, f64)], // (speed_kmh, angle_deg, until_distance_m)
324    enable_wind_shear: bool,
325    wind_shear_model: &str,
326    shooter_altitude_m: f64,
327) -> Vector3<f64> {
328    // Z IS DOWNRANGE
329    let range_m = position[2];
330    let altitude_m = position[1]; // Y is vertical, relative to shooter
331
332    // Find appropriate wind segment based on range
333    let base_wind = if wind_segments.is_empty() {
334        (0.0, 0.0)
335    } else {
336        // Find the segment that covers this range
337        let mut found_wind = (wind_segments[0].0, wind_segments[0].1);
338        for seg in wind_segments {
339            if range_m <= seg.2 {
340                found_wind = (seg.0, seg.1);
341                break;
342            }
343        }
344        found_wind
345    };
346
347    // Convert base wind from km/h to m/s
348    let base_speed_mps = base_wind.0 * 0.2777778; // km/h to m/s
349    let base_direction_deg = base_wind.1;
350
351    if !enable_wind_shear || wind_shear_model == "none" {
352        // No shear - return constant wind
353        let ang = base_direction_deg.to_radians();
354        return Vector3::new(
355            -base_speed_mps * ang.sin(), // x (lateral)
356            0.0,                         // y (vertical)
357            -base_speed_mps * ang.cos(), // z (downrange)
358        );
359    }
360
361    // Create wind shear profile
362    let mut profile = WindShearProfile::default();
363    profile.model = match wind_shear_model {
364        "logarithmic" => WindShearModel::Logarithmic,
365        "power_law" | "powerlaw" => WindShearModel::PowerLaw,
366        "ekman_spiral" | "ekman" => WindShearModel::EkmanSpiral,
367        "custom_layers" | "custom" => WindShearModel::CustomLayers,
368        _ => WindShearModel::None,
369    };
370    profile.surface_wind = WindLayer {
371        altitude_m: 0.0,
372        speed_mps: base_speed_mps,
373        direction_deg: base_direction_deg,
374    };
375
376    // Calculate absolute altitude by adding shooter's altitude
377    let absolute_altitude_m = altitude_m + shooter_altitude_m;
378
379    // OPTIMIZATION: Skip complex shear for very small altitude changes
380    // This avoids numerical issues near ground level
381    if absolute_altitude_m.abs() < 0.1 && altitude_m.abs() < 0.1 {
382        // Near ground level - use base wind directly with reduction
383        let ang = base_direction_deg.to_radians();
384        return Vector3::new(
385            -base_speed_mps * ang.sin() * 0.5, // Reduced at ground
386            0.0,
387            -base_speed_mps * ang.cos() * 0.5,
388        );
389    }
390
391    // OPTIMIZATION: For long-range shots, use simplified model
392    // to avoid numerical instability in RK45 integration
393    if range_m > 800.0 {
394        // Use simplified linear interpolation for stability
395        let altitude_factor = (1.0 + absolute_altitude_m / 100.0).min(2.0).max(0.1);
396        let sheared_speed = base_speed_mps * altitude_factor;
397        let ang = base_direction_deg.to_radians();
398        return Vector3::new(-sheared_speed * ang.sin(), 0.0, -sheared_speed * ang.cos());
399    }
400
401    // For normal ranges, use full shear model with clamped altitude
402    let clamped_altitude = absolute_altitude_m.max(-10.0).min(1000.0);
403    profile.get_wind_at_altitude(clamped_altitude)
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    #[test]
411    fn test_wind_layer() {
412        // Standard ballistics coordinate system: X=lateral, Y=vertical, Z=downrange
413        // Wind direction: 0°=headwind, 90°=from right, 180°=tailwind, 270°=from left
414
415        // Test 0° wind (from north/front - headwind)
416        let layer_headwind = WindLayer {
417            altitude_m: 100.0,
418            speed_mps: 10.0,
419            direction_deg: 0.0, // Wind from front (headwind)
420        };
421
422        let vec = layer_headwind.to_vector();
423        assert!(
424            (vec.x).abs() < 1e-6,
425            "0° wind (headwind) should have zero lateral (X) component"
426        );
427        assert_eq!(vec.y, 0.0);
428        assert!(
429            (vec.z - (-10.0)).abs() < 1e-6,
430            "0° wind should be headwind (negative Z downrange)"
431        );
432
433        // Test 90° wind (from right)
434        let layer_crosswind = WindLayer {
435            altitude_m: 100.0,
436            speed_mps: 10.0,
437            direction_deg: 90.0, // Wind from right
438        };
439
440        let vec_cross = layer_crosswind.to_vector();
441        assert!(
442            (vec_cross.x - (-10.0)).abs() < 1e-6,
443            "90° wind should have negative X lateral (from right)"
444        );
445        assert_eq!(vec_cross.y, 0.0);
446        assert!(
447            (vec_cross.z).abs() < 1e-6,
448            "90° wind (crosswind) should have zero downrange (Z) component"
449        );
450    }
451
452    #[test]
453    fn test_logarithmic_profile() {
454        let mut profile = WindShearProfile::default();
455        profile.model = WindShearModel::Logarithmic;
456        profile.surface_wind = WindLayer {
457            altitude_m: 0.0,
458            speed_mps: 10.0,
459            direction_deg: 0.0,
460        };
461
462        // Wind should increase with altitude
463        let v10 = profile.get_wind_at_altitude(10.0).norm();
464        let v50 = profile.get_wind_at_altitude(50.0).norm();
465        let v100 = profile.get_wind_at_altitude(100.0).norm();
466
467        assert!(v10 > 0.0);
468        assert!(v50 > v10);
469        assert!(v100 > v50);
470    }
471
472    #[test]
473    fn test_power_law_profile() {
474        let mut profile = WindShearProfile::default();
475        profile.model = WindShearModel::PowerLaw;
476        profile.surface_wind = WindLayer {
477            altitude_m: 0.0,
478            speed_mps: 10.0,
479            direction_deg: 0.0,
480        };
481
482        // Check power law relationship
483        let v100 = profile.get_wind_at_altitude(100.0).norm();
484        let expected = 10.0 * (100.0_f64 / 10.0).powf(1.0 / 7.0);
485        assert!((v100 - expected).abs() < 0.01);
486    }
487}