Skip to main content

ballistics_engine/
monte_carlo.rs

1//! Monte Carlo simulation support with statistical analysis
2//!
3//! This module provides core statistical functions for Monte Carlo trajectory analysis,
4//! including CEP (Circular Error Probable), confidence ellipses, and trajectory evaluation.
5//!
6//! MBA-157: Upstreamed from ballistics_rust for shared use across the ecosystem
7
8use crate::atmosphere::calculate_atmosphere;
9use crate::fast_trajectory::{fast_integrate, FastIntegrationParams};
10use crate::wind::WindSock;
11use crate::BallisticInputs;
12use nalgebra::Vector3;
13
14/// Simple trajectory output for Monte Carlo analysis
15#[derive(Debug, Clone)]
16pub struct TrajectoryOutput {
17    pub drop: f64,       // meters
18    pub wind_drift: f64, // meters
19    pub time: f64,       // seconds
20    pub velocity: f64,   // m/s
21    pub energy: f64,     // joules
22    pub mach: f64,       // mach number
23    pub spin_drift: f64, // meters
24    pub distance: f64,   // meters
25}
26
27/// Solve trajectory for Monte Carlo run
28///
29/// This function evaluates a single trajectory with the given inputs and returns
30/// simplified output suitable for statistical analysis.
31pub fn solve_trajectory_for_monte_carlo(
32    inputs: &BallisticInputs,
33) -> Result<TrajectoryOutput, String> {
34    // BallisticInputs is SI-canonical (meters, m/s, kg, radians).
35    let target_distance_m = inputs.target_distance; // meters
36    let muzzle_velocity_mps = inputs.muzzle_velocity; // m/s
37    let mass_kg = inputs.bullet_mass; // kg
38
39    // Calculate atmosphere at altitude
40    let (air_density, speed_of_sound) = calculate_atmosphere(
41        inputs.altitude, // meters
42        Some(inputs.temperature),
43        Some(inputs.pressure),
44        inputs.humidity,
45    );
46
47    // Create wind segments. WindSock expects (speed_kmh, angle_deg, until_distance_m);
48    // convert from the SI fields (m/s, radians) at this boundary.
49    let wind_segments = vec![(
50        inputs.wind_speed * 3.6,          // m/s -> km/h
51        inputs.wind_angle.to_degrees(),   // radians -> degrees
52        target_distance_m * 2.0,          // wind extends beyond target
53    )];
54    let wind_sock = WindSock::new(wind_segments);
55
56    // Set up initial state
57    let muzzle_angle_rad = inputs.muzzle_angle;
58    // McCoy: X=downrange, Y=vertical, Z=lateral
59    let initial_velocity = Vector3::new(
60        muzzle_velocity_mps * muzzle_angle_rad.cos(),
61        muzzle_velocity_mps * muzzle_angle_rad.sin(),
62        0.0,
63    );
64
65    let initial_position = Vector3::new(0.0, inputs.sight_height, 0.0); // meters
66    let mut initial_state_array = [0.0; 6];
67    initial_state_array[0..3].copy_from_slice(&[
68        initial_position.x,
69        initial_position.y,
70        initial_position.z,
71    ]);
72    initial_state_array[3..6].copy_from_slice(&[
73        initial_velocity.x,
74        initial_velocity.y,
75        initial_velocity.z,
76    ]);
77
78    // Create integration params. fast_integrate's atmo_params is
79    // (base_alt_m, base_temp_c, base_press_hpa, base_ratio) — NOT
80    // (temp, pressure, density, sound). Packing it wrong scrambled the base
81    // density (~417 kg/m^3) and produced ~340x drag. base_ratio is the
82    // density relative to 1.225 (get_local_atmosphere returns base_ratio*1.225
83    // at the base altitude), so derive it from the computed air density.
84    let base_ratio = air_density / 1.225;
85    let params = FastIntegrationParams {
86        initial_state: initial_state_array,
87        t_span: (0.0, 30.0),
88        horiz: target_distance_m,
89        vert: 0.0, // Target at ground level
90        atmo_params: (inputs.altitude, inputs.temperature, inputs.pressure, base_ratio),
91    };
92
93    // Solve trajectory
94    let solution = fast_integrate(inputs, &wind_sock, params);
95
96    if solution.t.is_empty() {
97        return Err("Empty trajectory solution".to_string());
98    }
99
100    // Get final state
101    // FastSolution.y is Vec<Vec<f64>> where y[i] is the ith state variable across all time points
102    let final_idx = solution.t.len() - 1;
103
104    let final_downrange = solution.y[0][final_idx]; // McCoy: X=downrange
105    let final_y = solution.y[1][final_idx]; // vertical
106    let final_lateral = solution.y[2][final_idx]; // McCoy: Z=lateral drift
107
108    let final_vx = solution.y[3][final_idx];
109    let final_vy = solution.y[4][final_idx];
110    let final_vz = solution.y[5][final_idx];
111
112    let final_speed = (final_vx * final_vx + final_vy * final_vy + final_vz * final_vz).sqrt();
113    let final_mach = final_speed / speed_of_sound;
114    let final_energy = 0.5 * mass_kg * final_speed * final_speed;
115
116    // Calculate line-of-sight drop
117    let sight_height_m = inputs.sight_height; // meters
118    let los_y = sight_height_m + (0.0 - sight_height_m) * (final_downrange / target_distance_m);
119    let drop = los_y - final_y;
120
121    Ok(TrajectoryOutput {
122        drop,
123        wind_drift: final_lateral,
124        time: solution.t[final_idx],
125        velocity: final_speed,
126        energy: final_energy,
127        mach: final_mach,
128        spin_drift: final_lateral, // Approximation for now
129        distance: final_downrange,
130    })
131}
132
133/// Calculate CEP (Circular Error Probable) from impact points
134///
135/// CEP is the radius of a circle centered at the mean point of impact,
136/// within which 50% of the shots fall. It's a standard measure of precision.
137pub fn calculate_cep(wind_drift_values: &[f64], drop_values: &[f64]) -> f64 {
138    if wind_drift_values.len() != drop_values.len() || wind_drift_values.is_empty() {
139        return 0.0;
140    }
141
142    // Calculate mean point of impact
143    let mean_x = wind_drift_values.iter().sum::<f64>() / wind_drift_values.len() as f64;
144    let mean_y = drop_values.iter().sum::<f64>() / drop_values.len() as f64;
145
146    // Calculate distance from each point to mean
147    let mut distances: Vec<f64> = wind_drift_values
148        .iter()
149        .zip(drop_values.iter())
150        .map(|(x, y)| {
151            let dx = x - mean_x;
152            let dy = y - mean_y;
153            (dx * dx + dy * dy).sqrt()
154        })
155        .collect();
156
157    // Sort distances to find median (50th percentile)
158    distances.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
159
160    // CEP is the median distance from center
161    percentile(&distances, 0.50)
162}
163
164/// Calculate 95% confidence ellipse parameters using covariance matrix
165///
166/// Returns (center_x, center_y, semi_major_axis, semi_minor_axis, rotation_degrees)
167pub fn calculate_confidence_ellipse(
168    wind_drift_values: &[f64],
169    drop_values: &[f64],
170) -> (f64, f64, f64, f64, f64) {
171    if wind_drift_values.len() != drop_values.len() || wind_drift_values.len() < 2 {
172        return (0.0, 0.0, 0.0, 0.0, 0.0);
173    }
174
175    let n = wind_drift_values.len() as f64;
176
177    // Calculate means
178    let mean_x = wind_drift_values.iter().sum::<f64>() / n;
179    let mean_y = drop_values.iter().sum::<f64>() / n;
180
181    // Calculate covariance matrix elements
182    let mut cov_xx = 0.0;
183    let mut cov_yy = 0.0;
184    let mut cov_xy = 0.0;
185
186    for (x, y) in wind_drift_values.iter().zip(drop_values.iter()) {
187        let dx = x - mean_x;
188        let dy = y - mean_y;
189        cov_xx += dx * dx;
190        cov_yy += dy * dy;
191        cov_xy += dx * dy;
192    }
193
194    cov_xx /= n - 1.0;
195    cov_yy /= n - 1.0;
196    cov_xy /= n - 1.0;
197
198    // Calculate eigenvalues of covariance matrix
199    // For 2x2 matrix: [[cov_xx, cov_xy], [cov_xy, cov_yy]]
200    let trace = cov_xx + cov_yy;
201    let det = cov_xx * cov_yy - cov_xy * cov_xy;
202    let discriminant = (trace * trace / 4.0 - det).max(0.0).sqrt();
203
204    let lambda1 = trace / 2.0 + discriminant; // Larger eigenvalue
205    let lambda2 = trace / 2.0 - discriminant; // Smaller eigenvalue
206
207    // 95% confidence interval chi-square value for 2 DOF is 5.991
208    let scale_factor = 5.991_f64.sqrt();
209    let semi_major = lambda1.max(0.0).sqrt() * scale_factor;
210    let semi_minor = lambda2.max(0.0).sqrt() * scale_factor;
211
212    // Calculate rotation angle (angle of major axis)
213    let rotation_rad = if cov_xy.abs() < 1e-10 {
214        if cov_xx >= cov_yy {
215            0.0
216        } else {
217            std::f64::consts::PI / 2.0
218        }
219    } else {
220        ((lambda1 - cov_xx) / cov_xy).atan()
221    };
222
223    let rotation_deg = rotation_rad.to_degrees();
224
225    (mean_x, mean_y, semi_major, semi_minor, rotation_deg)
226}
227
228/// Sample points for visualization (limit to avoid huge payloads)
229pub fn sample_points_for_visualization(
230    wind_drift_values: &[f64],
231    drop_values: &[f64],
232    max_points: usize,
233) -> Vec<(f64, f64)> {
234    let n = wind_drift_values.len();
235    if n == 0 {
236        return Vec::new();
237    }
238
239    if n <= max_points {
240        // Return all points
241        wind_drift_values
242            .iter()
243            .zip(drop_values.iter())
244            .map(|(x, y)| (*x, *y))
245            .collect()
246    } else {
247        // Sample evenly spaced points
248        let step = n as f64 / max_points as f64;
249        (0..max_points)
250            .map(|i| {
251                let idx = (i as f64 * step) as usize;
252                (wind_drift_values[idx], drop_values[idx])
253            })
254            .collect()
255    }
256}
257
258/// Calculate percentile from sorted values
259pub fn percentile(sorted_values: &[f64], p: f64) -> f64 {
260    if sorted_values.is_empty() {
261        return 0.0;
262    }
263
264    if sorted_values.len() == 1 {
265        return sorted_values[0];
266    }
267
268    let rank = p * (sorted_values.len() - 1) as f64;
269    let lower_idx = rank.floor() as usize;
270    let upper_idx = rank.ceil() as usize;
271    let fraction = rank - lower_idx as f64;
272
273    if lower_idx == upper_idx {
274        sorted_values[lower_idx]
275    } else {
276        sorted_values[lower_idx] * (1.0 - fraction) + sorted_values[upper_idx] * fraction
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn test_calculate_cep() {
286        let wind_drift = vec![0.0, 1.0, -1.0, 0.5, -0.5];
287        let drop = vec![0.0, 0.5, -0.5, 1.0, -1.0];
288
289        let cep = calculate_cep(&wind_drift, &drop);
290        assert!(cep > 0.0);
291        assert!(cep < 2.0); // Reasonable range
292    }
293
294    #[test]
295    fn test_calculate_confidence_ellipse() {
296        let wind_drift = vec![0.0, 1.0, -1.0, 0.5, -0.5];
297        let drop = vec![0.0, 0.5, -0.5, 1.0, -1.0];
298
299        let (cx, cy, major, minor, _rotation) = calculate_confidence_ellipse(&wind_drift, &drop);
300
301        // Center should be near origin
302        assert!(cx.abs() < 0.5);
303        assert!(cy.abs() < 0.5);
304
305        // Axes should be positive
306        assert!(major > 0.0);
307        assert!(minor > 0.0);
308        assert!(major >= minor); // Major axis should be >= minor axis
309    }
310
311    #[test]
312    fn test_sample_points() {
313        let wind_drift = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0];
314        let drop = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5];
315
316        let sampled = sample_points_for_visualization(&wind_drift, &drop, 3);
317        assert_eq!(sampled.len(), 3);
318    }
319
320    #[test]
321    fn test_percentile() {
322        let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
323
324        assert_eq!(percentile(&values, 0.0), 1.0);
325        assert_eq!(percentile(&values, 0.5), 3.0);
326        assert_eq!(percentile(&values, 1.0), 5.0);
327    }
328}