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    /// Meters below the horizontal line of sight (positive = below LOS).
18    pub drop: f64,
19    pub wind_drift: f64, // meters
20    pub time: f64,       // seconds
21    pub velocity: f64,   // m/s
22    pub energy: f64,     // joules
23    pub mach: f64,       // mach number
24    pub spin_drift: f64, // meters
25    pub distance: f64,   // meters
26}
27
28/// Solve trajectory for Monte Carlo run
29///
30/// This function evaluates a single trajectory with the given inputs and returns
31/// simplified output suitable for statistical analysis.
32pub fn solve_trajectory_for_monte_carlo(
33    inputs: &BallisticInputs,
34) -> Result<TrajectoryOutput, String> {
35    // BallisticInputs is SI-canonical (meters, m/s, kg, radians).
36    let target_distance_m = inputs.target_distance; // meters
37    let muzzle_velocity_mps = inputs.muzzle_velocity; // m/s
38    let mass_kg = inputs.bullet_mass; // kg
39
40    // Guard a non-finite or non-positive target distance: the wind extent, integration target,
41    // and reachability check all scale with target_distance_m, so 0/NaN/negative/+inf would yield
42    // a degenerate result that poisons mean/stddev/CEP aggregation. The is_finite() check also
43    // rejects +inf, which `> 0.0` alone lets through. Engine default is 100 m.
44    if !(target_distance_m.is_finite() && target_distance_m > 0.0) {
45        return Err("target_distance must be a positive, finite distance".to_string());
46    }
47
48    // Calculate atmosphere at altitude. resolve_station_pressure / _temperature keep explicit
49    // values authoritative (no altitude double-count) but return None when left at the sea-level
50    // default, so altitude drives BOTH the base station pressure AND the lapse-rate temperature
51    // via the ICAO standard (this base_ratio feeds the fast/Monte-Carlo kernel's base_density).
52    // Matches calculate_air_density.
53    let (resolved_temp_c, resolved_pressure_hpa) = crate::atmosphere::resolve_station_conditions(
54        inputs.temperature,
55        inputs.pressure,
56        inputs.altitude,
57    );
58    let (air_density, speed_of_sound) = calculate_atmosphere(
59        inputs.altitude, // meters
60        Some(resolved_temp_c),
61        Some(resolved_pressure_hpa),
62        // BallisticInputs.humidity is a 0-1 fraction; calculate_atmosphere expects 0-100 percent
63        // (matching AtmosphericConditions.humidity). Passing the raw fraction under-applied
64        // humidity 100x. Centralized in humidity_percent() (MBA-722).
65        inputs.humidity_percent(),
66    );
67
68    // Create wind segments. WindSock expects (speed_kmh, angle_deg, until_distance_m);
69    // convert from the SI fields (m/s, radians) at this boundary.
70    let wind_segments = vec![(
71        inputs.wind_speed * 3.6,        // m/s -> km/h
72        inputs.wind_angle.to_degrees(), // radians -> degrees
73        target_distance_m * 2.0,        // wind extends beyond target
74    )];
75    let wind_sock = WindSock::new(wind_segments);
76
77    // Set up initial state
78    let muzzle_angle_rad = inputs.muzzle_angle;
79    // McCoy: X=downrange, Y=vertical, Z=lateral
80    let initial_velocity = Vector3::new(
81        muzzle_velocity_mps * muzzle_angle_rad.cos(),
82        muzzle_velocity_mps * muzzle_angle_rad.sin(),
83        0.0,
84    );
85
86    // Launch from the bore, not from the sight. In this shot-aligned McCoy frame the horizontal
87    // line of sight sits `sight_height` above the bore (MBA-1160).
88    let bore_y = inputs.muzzle_height;
89    let line_of_sight_y = bore_y + inputs.sight_height;
90    let initial_position = Vector3::new(0.0, bore_y, 0.0); // meters
91    let mut initial_state_array = [0.0; 6];
92    initial_state_array[0..3].copy_from_slice(&[
93        initial_position.x,
94        initial_position.y,
95        initial_position.z,
96    ]);
97    initial_state_array[3..6].copy_from_slice(&[
98        initial_velocity.x,
99        initial_velocity.y,
100        initial_velocity.z,
101    ]);
102
103    // Create integration params. fast_integrate's atmo_params is
104    // (base_alt_m, base_temp_c, base_press_hpa, base_ratio) — NOT
105    // (temp, pressure, density, sound). Packing it wrong scrambled the base
106    // density (~417 kg/m^3) and produced ~340x drag. base_ratio is the
107    // density relative to 1.225 (get_local_atmosphere returns base_ratio*1.225
108    // at the base altitude), so derive it from the computed air density.
109    let base_ratio = air_density / 1.225;
110    let params = FastIntegrationParams {
111        initial_state: initial_state_array,
112        t_span: (0.0, 30.0),
113        horiz: target_distance_m,
114        vert: line_of_sight_y,
115        atmo_params: (
116            inputs.altitude,
117            resolved_temp_c,
118            resolved_pressure_hpa,
119            base_ratio,
120        ),
121        // MBA-1137: the Monte-Carlo entry takes a single BallisticInputs with no downrange
122        // atmosphere, so no zones here. The fast kernel still honors the field for callers that
123        // build a FastIntegrationParams directly with a Some(AtmoSock).
124        atmo_sock: None,
125    };
126
127    // Solve trajectory
128    let solution = fast_integrate(inputs, &wind_sock, params);
129
130    if solution.t.is_empty() {
131        return Err("Empty trajectory solution".to_string());
132    }
133
134    // Get final state
135    // FastSolution.y is Vec<Vec<f64>> where y[i] is the ith state variable across all time points
136    let final_idx = solution.t.len() - 1;
137
138    let final_downrange = solution.y[0][final_idx]; // McCoy: X=downrange
139
140    // Exclude runs that did not reach the target distance (a short/steep/subsonic shot, or any
141    // residual time-budget truncation) instead of silently reporting their too-short impact
142    // metrics at the target downrange, which would poison the mean / stddev / CEP aggregation.
143    if final_downrange < target_distance_m * 0.999 {
144        return Err("trajectory did not reach target distance".to_string());
145    }
146
147    let final_y = solution.y[1][final_idx]; // vertical
148    let final_lateral = solution.y[2][final_idx]; // McCoy: Z=lateral drift
149
150    let final_vx = solution.y[3][final_idx];
151    let final_vy = solution.y[4][final_idx];
152    let final_vz = solution.y[5][final_idx];
153
154    let final_speed = (final_vx * final_vx + final_vy * final_vy + final_vz * final_vz).sqrt();
155    let final_mach = final_speed / speed_of_sound;
156    let final_energy = 0.5 * mass_kg * final_speed * final_speed;
157
158    // Positive drop is below the horizontal line of sight. The launch angle is already relative
159    // to that line, so it must not be used to tilt the LOS a second time.
160    let drop = line_of_sight_y - final_y;
161
162    // MBA-1134 (rank 6/10): the canonical empirical Litz spin drift is now applied INSIDE
163    // fast_integrate itself (post-process on the state lateral), so `final_lateral` already
164    // includes it — matching cli_api::apply_spin_drift and fast_integrate_with_segments. We
165    // therefore must NOT add it a second time here (doing so double-counted the drift, ~2x).
166    // We still recompute the Litz component with the SAME muzzle Sg for the reported
167    // `spin_drift` field (the old `// Approximation for now` had copied total lateral).
168    let spin_drift_m = if inputs.use_enhanced_spin_drift {
169        let sg = crate::spin_drift::effective_sg_from_inputs(
170            inputs,
171            resolved_temp_c,
172            resolved_pressure_hpa,
173        );
174        crate::spin_drift::litz_drift_meters(sg, solution.t[final_idx], inputs.is_twist_right)
175    } else {
176        0.0
177    };
178
179    Ok(TrajectoryOutput {
180        drop,
181        // final_lateral already carries the Litz spin drift (applied in fast_integrate).
182        wind_drift: final_lateral,
183        time: solution.t[final_idx],
184        velocity: final_speed,
185        energy: final_energy,
186        mach: final_mach,
187        spin_drift: spin_drift_m,
188        distance: final_downrange,
189    })
190}
191
192/// Calculate CEP (Circular Error Probable) from impact points
193///
194/// CEP is the radius of a circle centered at the mean point of impact,
195/// within which 50% of the shots fall. It's a standard measure of precision.
196pub fn calculate_cep(wind_drift_values: &[f64], drop_values: &[f64]) -> f64 {
197    if wind_drift_values.len() != drop_values.len() || wind_drift_values.is_empty() {
198        return 0.0;
199    }
200
201    // Calculate mean point of impact
202    let mean_x = wind_drift_values.iter().sum::<f64>() / wind_drift_values.len() as f64;
203    let mean_y = drop_values.iter().sum::<f64>() / drop_values.len() as f64;
204
205    // Calculate distance from each point to mean
206    let mut distances: Vec<f64> = wind_drift_values
207        .iter()
208        .zip(drop_values.iter())
209        .map(|(x, y)| {
210            let dx = x - mean_x;
211            let dy = y - mean_y;
212            (dx * dx + dy * dy).sqrt()
213        })
214        .collect();
215
216    // Sort distances to find median (50th percentile)
217    distances.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
218
219    // CEP is the median distance from center
220    percentile(&distances, 0.50)
221}
222
223/// Calculate 95% confidence ellipse parameters using covariance matrix
224///
225/// Returns (center_x, center_y, semi_major_axis, semi_minor_axis, rotation_degrees)
226pub fn calculate_confidence_ellipse(
227    wind_drift_values: &[f64],
228    drop_values: &[f64],
229) -> (f64, f64, f64, f64, f64) {
230    if wind_drift_values.len() != drop_values.len() || wind_drift_values.len() < 2 {
231        return (0.0, 0.0, 0.0, 0.0, 0.0);
232    }
233
234    let n = wind_drift_values.len() as f64;
235
236    // Calculate means
237    let mean_x = wind_drift_values.iter().sum::<f64>() / n;
238    let mean_y = drop_values.iter().sum::<f64>() / n;
239
240    // Calculate covariance matrix elements
241    let mut cov_xx = 0.0;
242    let mut cov_yy = 0.0;
243    let mut cov_xy = 0.0;
244
245    for (x, y) in wind_drift_values.iter().zip(drop_values.iter()) {
246        let dx = x - mean_x;
247        let dy = y - mean_y;
248        cov_xx += dx * dx;
249        cov_yy += dy * dy;
250        cov_xy += dx * dy;
251    }
252
253    cov_xx /= n - 1.0;
254    cov_yy /= n - 1.0;
255    cov_xy /= n - 1.0;
256
257    // Calculate eigenvalues of covariance matrix
258    // For 2x2 matrix: [[cov_xx, cov_xy], [cov_xy, cov_yy]]
259    let trace = cov_xx + cov_yy;
260    let det = cov_xx * cov_yy - cov_xy * cov_xy;
261    let discriminant = (trace * trace / 4.0 - det).max(0.0).sqrt();
262
263    let lambda1 = trace / 2.0 + discriminant; // Larger eigenvalue
264    let lambda2 = trace / 2.0 - discriminant; // Smaller eigenvalue
265
266    // 95% confidence interval chi-square value for 2 DOF is 5.991
267    let scale_factor = 5.991_f64.sqrt();
268    let semi_major = lambda1.max(0.0).sqrt() * scale_factor;
269    let semi_minor = lambda2.max(0.0).sqrt() * scale_factor;
270
271    // Calculate rotation angle (angle of major axis)
272    let rotation_rad = if cov_xy.abs() < 1e-10 {
273        if cov_xx >= cov_yy {
274            0.0
275        } else {
276            std::f64::consts::PI / 2.0
277        }
278    } else {
279        ((lambda1 - cov_xx) / cov_xy).atan()
280    };
281
282    let rotation_deg = rotation_rad.to_degrees();
283
284    (mean_x, mean_y, semi_major, semi_minor, rotation_deg)
285}
286
287/// Sample points for visualization (limit to avoid huge payloads)
288pub fn sample_points_for_visualization(
289    wind_drift_values: &[f64],
290    drop_values: &[f64],
291    max_points: usize,
292) -> Vec<(f64, f64)> {
293    let n = wind_drift_values.len();
294    if n == 0 {
295        return Vec::new();
296    }
297
298    if n <= max_points {
299        // Return all points
300        wind_drift_values
301            .iter()
302            .zip(drop_values.iter())
303            .map(|(x, y)| (*x, *y))
304            .collect()
305    } else {
306        // Sample evenly spaced points
307        let step = n as f64 / max_points as f64;
308        (0..max_points)
309            .map(|i| {
310                let idx = (i as f64 * step) as usize;
311                (wind_drift_values[idx], drop_values[idx])
312            })
313            .collect()
314    }
315}
316
317/// Calculate percentile from sorted values
318pub fn percentile(sorted_values: &[f64], p: f64) -> f64 {
319    if sorted_values.is_empty() {
320        return 0.0;
321    }
322
323    if sorted_values.len() == 1 {
324        return sorted_values[0];
325    }
326
327    // Clamp p to [0,1]: percentile is public (callable from ballistics_rust / FFI). p > 1 made
328    // upper_idx exceed the slice and panic; p < 0 silently returned a wrong value.
329    let p = p.clamp(0.0, 1.0);
330    let rank = p * (sorted_values.len() - 1) as f64;
331    let lower_idx = rank.floor() as usize;
332    let upper_idx = rank.ceil() as usize;
333    let fraction = rank - lower_idx as f64;
334
335    if lower_idx == upper_idx {
336        sorted_values[lower_idx]
337    } else {
338        sorted_values[lower_idx] * (1.0 - fraction) + sorted_values[upper_idx] * fraction
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn test_calculate_cep() {
348        let wind_drift = vec![0.0, 1.0, -1.0, 0.5, -0.5];
349        let drop = vec![0.0, 0.5, -0.5, 1.0, -1.0];
350
351        let cep = calculate_cep(&wind_drift, &drop);
352        assert!(cep > 0.0);
353        assert!(cep < 2.0); // Reasonable range
354    }
355
356    #[test]
357    fn test_calculate_confidence_ellipse() {
358        let wind_drift = vec![0.0, 1.0, -1.0, 0.5, -0.5];
359        let drop = vec![0.0, 0.5, -0.5, 1.0, -1.0];
360
361        let (cx, cy, major, minor, _rotation) = calculate_confidence_ellipse(&wind_drift, &drop);
362
363        // Center should be near origin
364        assert!(cx.abs() < 0.5);
365        assert!(cy.abs() < 0.5);
366
367        // Axes should be positive
368        assert!(major > 0.0);
369        assert!(minor > 0.0);
370        assert!(major >= minor); // Major axis should be >= minor axis
371    }
372
373    #[test]
374    fn test_sample_points() {
375        let wind_drift = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0];
376        let drop = vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5];
377
378        let sampled = sample_points_for_visualization(&wind_drift, &drop, 3);
379        assert_eq!(sampled.len(), 3);
380    }
381
382    #[test]
383    fn test_percentile() {
384        let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
385
386        assert_eq!(percentile(&values, 0.0), 1.0);
387        assert_eq!(percentile(&values, 0.5), 3.0);
388        assert_eq!(percentile(&values, 1.0), 5.0);
389    }
390}