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