Skip to main content

ballistics_engine/
trajectory_integration.rs

1//! Advanced trajectory integration methods (RK4, RK45)
2//!
3//! This module provides production-grade numerical integration for ballistic trajectories:
4//! - RK4: 4th-order Runge-Kutta (fixed step)
5//! - RK45: Dormand-Prince adaptive method (same as scipy.integrate.solve_ivp)
6//!
7//! MBA-155: Upstreamed from ballistics_rust for shared use
8
9use nalgebra::{Vector3, Vector6};
10use std::collections::HashMap;
11
12use crate::derivatives::compute_derivatives;
13use crate::wind::WindSegment;
14use crate::BallisticInputs;
15use crate::DragModel;
16
17/// RK4 integration step
18fn rk4_step(
19    state: &Vector6<f64>,
20    t: f64,
21    dt: f64,
22    params: &TrajectoryParams,
23    inputs: &BallisticInputs,
24) -> Vector6<f64> {
25    // RK4 integration
26    let k1 = compute_derivatives_vec(state, t, params, inputs);
27    let k2 = compute_derivatives_vec(&(state + dt * 0.5 * k1), t + dt * 0.5, params, inputs);
28    let k3 = compute_derivatives_vec(&(state + dt * 0.5 * k2), t + dt * 0.5, params, inputs);
29    let k4 = compute_derivatives_vec(&(state + dt * k3), t + dt, params, inputs);
30
31    state + (dt / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4)
32}
33
34/// Adaptive RK45 integration step (Dormand-Prince method)
35fn rk45_step(
36    state: &Vector6<f64>,
37    t: f64,
38    dt: f64,
39    params: &TrajectoryParams,
40    inputs: &BallisticInputs,
41    tol: f64,
42) -> (Vector6<f64>, f64, f64) {
43    // Dormand-Prince coefficients (same as scipy.integrate.solve_ivp RK45)
44    const A21: f64 = 1.0 / 5.0;
45    const A31: f64 = 3.0 / 40.0;
46    const A32: f64 = 9.0 / 40.0;
47    const A41: f64 = 44.0 / 45.0;
48    const A42: f64 = -56.0 / 15.0;
49    const A43: f64 = 32.0 / 9.0;
50    const A51: f64 = 19372.0 / 6561.0;
51    const A52: f64 = -25360.0 / 2187.0;
52    const A53: f64 = 64448.0 / 6561.0;
53    const A54: f64 = -212.0 / 729.0;
54    const A61: f64 = 9017.0 / 3168.0;
55    const A62: f64 = -355.0 / 33.0;
56    const A63: f64 = 46732.0 / 5247.0;
57    const A64: f64 = 49.0 / 176.0;
58    const A65: f64 = -5103.0 / 18656.0;
59    const A71: f64 = 35.0 / 384.0;
60    const A73: f64 = 500.0 / 1113.0;
61    const A74: f64 = 125.0 / 192.0;
62    const A75: f64 = -2187.0 / 6784.0;
63    const A76: f64 = 11.0 / 84.0;
64
65    // 5th order coefficients
66    const B1: f64 = 35.0 / 384.0;
67    const B3: f64 = 500.0 / 1113.0;
68    const B4: f64 = 125.0 / 192.0;
69    const B5: f64 = -2187.0 / 6784.0;
70    const B6: f64 = 11.0 / 84.0;
71
72    // 4th order coefficients (for error estimation)
73    const B1_ERR: f64 = 5179.0 / 57600.0;
74    const B3_ERR: f64 = 7571.0 / 16695.0;
75    const B4_ERR: f64 = 393.0 / 640.0;
76    const B5_ERR: f64 = -92097.0 / 339200.0;
77    const B6_ERR: f64 = 187.0 / 2100.0;
78    const B7_ERR: f64 = 1.0 / 40.0;
79
80    // Compute stages
81    let k1 = compute_derivatives_vec(state, t, params, inputs);
82    let k2 = compute_derivatives_vec(&(state + dt * A21 * k1), t + dt * 0.2, params, inputs);
83    let k3 =
84        compute_derivatives_vec(&(state + dt * (A31 * k1 + A32 * k2)), t + dt * 0.3, params, inputs);
85    let k4 = compute_derivatives_vec(
86        &(state + dt * (A41 * k1 + A42 * k2 + A43 * k3)),
87        t + dt * 0.8,
88        params,
89        inputs,
90    );
91    let k5 = compute_derivatives_vec(
92        &(state + dt * (A51 * k1 + A52 * k2 + A53 * k3 + A54 * k4)),
93        t + dt * 8.0 / 9.0,
94        params,
95        inputs,
96    );
97    let k6 = compute_derivatives_vec(
98        &(state + dt * (A61 * k1 + A62 * k2 + A63 * k3 + A64 * k4 + A65 * k5)),
99        t + dt,
100        params,
101        inputs,
102    );
103    let k7 = compute_derivatives_vec(
104        &(state + dt * (A71 * k1 + A73 * k3 + A74 * k4 + A75 * k5 + A76 * k6)),
105        t + dt,
106        params,
107        inputs,
108    );
109
110    // 5th order solution
111    let y_new = state + dt * (B1 * k1 + B3 * k3 + B4 * k4 + B5 * k5 + B6 * k6);
112
113    // 4th order solution for error estimate
114    let y_err = state
115        + dt * (B1_ERR * k1 + B3_ERR * k3 + B4_ERR * k4 + B5_ERR * k5 + B6_ERR * k6 + B7_ERR * k7);
116
117    // Error estimate
118    let error = (y_new - y_err).norm() / (1.0 + state.norm());
119
120    // Adaptive step size
121    let safety = 0.9;
122    let dt_new = if error < tol {
123        dt * safety * (tol / error).powf(0.2).min(2.0)
124    } else {
125        dt * safety * (tol / error).powf(0.25).max(0.1)
126    };
127
128    (y_new, dt_new, error)
129}
130
131/// Parameters for trajectory computation
132pub struct TrajectoryParams {
133    pub mass_kg: f64,
134    pub bc: f64,
135    pub drag_model: DragModel,
136    pub wind_segments: Vec<WindSegment>,
137    /// Dual-mode atmosphere tuple consumed by `compute_derivatives`:
138    /// **Standard** `(base_alt_m, base_temp_c, base_pressure_hPa, base_density_ratio)` — note
139    /// slot 3 is a density RATIO, NOT humidity, even though it rides in the `humidity` field;
140    /// or **Direct** `(air_density, speed_of_sound, 0.0, 0.0)` — slots 2 and 3 are zero
141    /// sentinels. A pressure of 0 that is not the direct-mode sentinel disables drag.
142    pub atmos_params: (f64, f64, f64, f64),
143    pub omega_vector: Option<Vector3<f64>>,
144    pub enable_spin_drift: bool,
145    pub enable_magnus: bool,
146    pub enable_coriolis: bool,
147    pub target_distance_m: f64, // Target horizontal distance in meters
148    pub enable_wind_shear: bool,
149    pub wind_shear_model: String,
150    pub shooter_altitude_m: f64,
151    pub is_twist_right: bool, // True for right-hand twist, false for left-hand
152    // MBA-717: real bullet geometry so spin-drift / Magnus / stability on this fast/MC
153    // path use the actual bullet instead of hardcoded .308 / 1.24in / 10-twist placeholders.
154    pub bullet_diameter: f64, // meters
155    pub bullet_length: f64,   // meters (0.0 -> derivatives falls back to the 4.5-caliber heuristic)
156    pub twist_rate: f64,      // inches per turn
157    pub custom_drag_table: Option<crate::drag::DragTable>, // Custom Drag Model (CDM) data
158    pub bc_segments: Option<Vec<(f64, f64)>>, // Mach-based BC segments: (mach, bc)
159    pub use_bc_segments: bool, // Whether to use BC segment interpolation
160    /// MBA-954: altitude (m, relative to launch) below which integration stops. -1000.0 is the
161    /// historical default — effectively "no early ground impact" for normal flat-fire shots.
162    pub ground_threshold: f64,
163}
164
165/// Build the loop-invariant BallisticInputs for the derivatives function ONCE per integration,
166/// instead of rebuilding it (a "none".to_string() alloc plus bc_segments / custom_drag_table
167/// clones) on every derivative evaluation (4x per RK4 step, 7x per RK45 step). muzzle_velocity is
168/// set to 0.0 because compute_derivatives never reads it on this path; every other field depends
169/// only on `params`, so the struct is constant for the whole integration.
170fn build_inputs(params: &TrajectoryParams) -> BallisticInputs {
171    let mut inputs = BallisticInputs {
172        bc_value: params.bc,
173        bc_type: params.drag_model,
174        bullet_mass: params.mass_kg, // kg
175        muzzle_velocity: 0.0,        // unread by compute_derivatives on this path
176        bullet_diameter: params.bullet_diameter, // MBA-717: real geometry, not placeholders
177        bullet_length: params.bullet_length,
178        twist_rate: params.twist_rate,
179        is_twist_right: params.is_twist_right,
180        enable_advanced_effects: params.enable_spin_drift
181            || params.enable_magnus
182            || params.enable_coriolis,
183        enable_magnus: params.enable_magnus,
184        enable_coriolis: params.enable_coriolis,
185        altitude: params.atmos_params.0,
186        temperature: params.atmos_params.1,
187        pressure: params.atmos_params.2,
188        humidity: params.atmos_params.3,
189        tipoff_yaw: 0.0,
190        target_distance: 1000.0, // default
191        muzzle_angle: 0.0,
192        wind_speed: if !params.wind_segments.is_empty() {
193            params.wind_segments[0].0 * 0.2777778 // km/h -> m/s
194        } else {
195            0.0
196        },
197        wind_angle: if !params.wind_segments.is_empty() {
198            params.wind_segments[0].1.to_radians() // degrees -> radians
199        } else {
200            0.0
201        },
202        latitude: None,
203        shooting_angle: 0.0,
204        azimuth_angle: 0.0,
205        shot_azimuth: 0.0, // this fast path doesn't plumb latitude/bearing (no directional Coriolis here)
206        use_powder_sensitivity: false,
207        powder_temp_sensitivity: 0.0,
208        powder_temp: 59.0,
209        tipoff_decay_distance: 0.0,
210        ground_threshold: params.ground_threshold, // MBA-954: honor the configured ground plane
211        bc_segments: params.bc_segments.clone(),
212        caliber_inches: params.bullet_diameter / 0.0254, // MBA-717: from real diameter
213        weight_grains: params.mass_kg / 0.00006479891,
214        use_bc_segments: params.use_bc_segments,
215        bullet_id: None,
216        bc_segments_data: None,
217        use_enhanced_spin_drift: params.enable_spin_drift,
218        use_form_factor: false,
219        manufacturer: None,
220        bullet_model: None,
221        enable_wind_shear: false,
222        wind_shear_model: "none".to_string(),
223        use_cluster_bc: false,
224        bullet_cluster: None,
225        custom_drag_table: params.custom_drag_table.clone(),
226        bc_type_str: None,
227        enable_pitch_damping: false,
228        enable_precession_nutation: false,
229        // MBA-959: aerodynamic jump is intentionally OFF on this path. integrate_trajectory
230        // is a low-level state integrator: it advances a raw initial_state (no muzzle angle to
231        // perturb) and an unread muzzle_velocity, so a meaningful Litz Sg can't be formed here
232        // (Sg would be ~0 and the AJ guard would suppress it regardless). AJ belongs on the
233        // BallisticInputs + TrajectorySolver path, which bindings use and already honors the flag.
234        // (Bullet geometry IS now carried through TrajectoryParams — MBA-717 — so spin-drift /
235        // Magnus on this path use the real diameter/length/twist.)
236        enable_aerodynamic_jump: false,
237        use_rk4: true,
238        use_adaptive_rk45: false,
239        enable_trajectory_sampling: false,
240        sample_interval: 10.0,
241        sight_height: 0.0,
242        muzzle_height: 0.0,
243        target_height: 0.0,
244    };
245
246    // MBA-955: pre-populate velocity-BC segments ONCE here, instead of get_bc_for_velocity
247    // rebuilding them (a model String + a segment Vec) on every derivative evaluation (4-7x per
248    // step). Gated to EXACTLY the case where the per-step path would estimate: use_bc_segments on,
249    // no explicit velocity segments, and no Mach-based bc_segments (those take a different,
250    // unchanged path). bc_used there == params.bc == inputs.bc_value, so the estimated segments are
251    // identical and the per-step fast-path lookup returns the same BC -> byte-identical output.
252    if inputs.use_bc_segments && inputs.bc_segments_data.is_none() && inputs.bc_segments.is_none() {
253        inputs.bc_segments_data =
254            crate::derivatives::estimate_bc_segments_for(&inputs, inputs.bc_value);
255    }
256    inputs
257}
258
259/// Convert state to Vector6 and call compute_derivatives
260fn compute_derivatives_vec(
261    state: &Vector6<f64>,
262    t: f64,
263    params: &TrajectoryParams,
264    inputs: &BallisticInputs,
265) -> Vector6<f64> {
266    let pos = Vector3::new(state[0], state[1], state[2]);
267    let vel = Vector3::new(state[3], state[4], state[5]);
268
269    // Calculate wind at current position with shear support
270    let wind_vector = if !params.wind_segments.is_empty() {
271        if params.enable_wind_shear && params.wind_shear_model != "none" {
272            crate::wind_shear::get_wind_at_position(
273                &pos,
274                &params.wind_segments,
275                params.enable_wind_shear,
276                &params.wind_shear_model,
277                params.shooter_altitude_m,
278            )
279        } else {
280            // Simple constant wind (original implementation)
281            let seg = &params.wind_segments[0];
282            let wind_speed_mps = seg.0 * 0.2777778; // km/h to m/s
283            let wind_angle_rad = seg.1.to_radians();
284            // McCoy: x=downrange, y=vertical, z=lateral
285            Vector3::new(
286                -wind_speed_mps * wind_angle_rad.cos(), // x (downrange - head/tail component)
287                0.0,                                    // y (vertical)
288                -wind_speed_mps * wind_angle_rad.sin(), // z (lateral - crosswind component)
289            )
290        }
291    } else {
292        Vector3::zeros()
293    };
294
295    // Call compute_derivatives - returns [f64; 6] directly. `inputs` is built once per
296    // integration by build_inputs() and threaded in, instead of rebuilt every call.
297    let deriv_result = compute_derivatives(
298        pos,
299        vel,
300        inputs,
301        wind_vector,
302        params.atmos_params,
303        params.bc,
304        params.omega_vector,
305        t,
306    );
307
308    Vector6::new(
309        deriv_result[0],
310        deriv_result[1],
311        deriv_result[2],
312        deriv_result[3],
313        deriv_result[4],
314        deriv_result[5],
315    )
316}
317
318/// Main trajectory integration function
319pub fn integrate_trajectory(
320    initial_state: [f64; 6],
321    t_span: (f64, f64),
322    params: TrajectoryParams,
323    method: &str,
324    tolerance: f64,
325    max_step: f64,
326) -> Vec<(f64, Vector6<f64>)> {
327    let mut state = Vector6::new(
328        initial_state[0],
329        initial_state[1],
330        initial_state[2],
331        initial_state[3],
332        initial_state[4],
333        initial_state[5],
334    );
335
336    let mut t = t_span.0;
337    let t_end = t_span.1;
338    let mut dt = (t_end - t) / 1000.0; // Initial step size
339
340    let mut trajectory = Vec::with_capacity(10000);
341    trajectory.push((t, state));
342
343    // Build the (loop-invariant) derivative inputs once for the whole integration, instead of
344    // rebuilding the struct on every derivative evaluation.
345    let inputs = build_inputs(&params);
346
347    match method {
348        "RK4" => {
349            // Fixed step RK4 with target detection
350            dt = dt.min(max_step).min(0.001); // Use smaller steps for accuracy
351
352            while t < t_end {
353                if t + dt > t_end {
354                    dt = t_end - t;
355                }
356
357                let new_state = rk4_step(&state, t, dt, &params, &inputs);
358
359                // Check if we're about to pass the target (X is downrange, McCoy)
360                if state[0] < params.target_distance_m && new_state[0] >= params.target_distance_m {
361                    // Interpolate to find exact target crossing
362                    let alpha = (params.target_distance_m - state[0]) / (new_state[0] - state[0]);
363                    let dt_to_target = dt * alpha;
364
365                    // Take a smaller step to reach target exactly
366                    let final_state = rk4_step(&state, t, dt_to_target, &params, &inputs);
367
368                    // Ensure we don't overshoot
369                    let mut corrected_state = final_state;
370                    if corrected_state[0] > params.target_distance_m {
371                        corrected_state[0] = params.target_distance_m;
372                    }
373
374                    trajectory.push((t + dt_to_target, corrected_state));
375                    break; // Stop at target
376                }
377
378                state = new_state;
379                t += dt;
380                trajectory.push((t, state));
381
382                // Check if we've reached or passed the target
383                if state[0] >= params.target_distance_m {
384                    // X is downrange (McCoy)
385                    // Add final point exactly at target
386                    let mut final_state = state;
387                    final_state[0] = params.target_distance_m; // X is downrange
388                    trajectory.push((t, final_state));
389                    break;
390                }
391
392                // Check if bullet hit ground (MBA-954: honor the configured ground plane,
393                // not a hardcoded -1000.0)
394                if state[1] < params.ground_threshold {
395                    break;
396                }
397            }
398        }
399        "RK45" | _ => {
400            // Adaptive RK45 with better sampling
401            let mut last_save_x = 0.0; // X is downrange (McCoy)
402            let save_interval_m = params.target_distance_m / 50.0; // Save ~50 points minimum
403
404            // OPTIMIZATION: Adjust max step size when wind shear is enabled
405            // This improves numerical stability at long ranges
406            let effective_max_step =
407                if params.enable_wind_shear && params.wind_shear_model != "none" {
408                    // Use smaller steps for wind shear, but not TOO small
409                    if params.target_distance_m > 800.0 {
410                        0.01 // Smaller steps for long range with shear (10ms)
411                    } else {
412                        0.02 // Normal steps for medium range with shear (20ms)
413                    }
414                } else {
415                    max_step // Use provided max_step when no wind shear
416                };
417
418            // Set initial step size - ensure it's reasonable
419            dt = dt.min(effective_max_step).max(0.0001); // At least 0.1ms to avoid infinite loops
420
421            // Safety check: maximum iterations to prevent infinite loops
422            let max_iterations = 100000; // Should be more than enough for any realistic trajectory
423            let mut iteration_count = 0;
424
425            while t < t_end && iteration_count < max_iterations {
426                iteration_count += 1;
427
428                // Limit time step for better resolution
429                if t + dt > t_end {
430                    dt = t_end - t;
431                }
432
433                let (new_state, dt_new, _error) =
434                    rk45_step(&state, t, dt, &params, &inputs, tolerance);
435
436                // Check if we're about to pass the target (X is downrange, McCoy)
437                if state[0] < params.target_distance_m && new_state[0] >= params.target_distance_m {
438                    // Interpolate to find exact target crossing
439                    let alpha = (params.target_distance_m - state[0]) / (new_state[0] - state[0]);
440                    let dt_to_target = dt * alpha;
441
442                    // Take a smaller step to reach target exactly
443                    let (final_state, _, _) =
444                        rk45_step(&state, t, dt_to_target, &params, &inputs, tolerance);
445
446                    // Make sure we don't overshoot
447                    let mut corrected_state = final_state;
448                    if corrected_state[0] > params.target_distance_m {
449                        corrected_state[0] = params.target_distance_m;
450                    }
451
452                    trajectory.push((t + dt_to_target, corrected_state));
453                    break; // Stop at target - no more points after this
454                }
455
456                // Update state
457                state = new_state;
458                t += dt;
459
460                // Save trajectory point if we've moved enough distance
461                if state[0] - last_save_x >= save_interval_m || state[0] >= params.target_distance_m
462                {
463                    // X is downrange
464                    trajectory.push((t, state));
465                    last_save_x = state[0];
466                }
467
468                // Limit dt for next step - ensure we get enough resolution
469                dt = dt_new.min(effective_max_step).max(0.0001); // Use effective max step, min 0.1ms
470
471                // Stop if we've reached the target
472                if state[0] >= params.target_distance_m {
473                    // X is downrange (McCoy)
474                    // Add final point at target distance
475                    let mut final_state = state;
476                    final_state[0] = params.target_distance_m; // X is downrange
477                    trajectory.push((t, final_state));
478                    break;
479                }
480
481                // Check if bullet hit ground (MBA-954: honor the configured ground plane,
482                // not a hardcoded -1000.0)
483                if state[1] < params.ground_threshold {
484                    break;
485                }
486            }
487
488            // Warn if we hit the iteration limit
489            if iteration_count >= max_iterations {
490                eprintln!(
491                    "WARNING: Trajectory integration hit maximum iteration limit ({} iterations)",
492                    max_iterations
493                );
494                eprintln!("  Final time: {}, Target time: {}", t, t_end);
495                eprintln!(
496                    "  Final position: downrange(x)={}, Target: {}m",
497                    state[0], params.target_distance_m
498                );
499            }
500        }
501    }
502
503    trajectory
504}
505
506/// Python-exposed function for complete trajectory integration
507pub fn solve_trajectory_rust(
508    initial_state: [f64; 6],
509    t_span: (f64, f64),
510    mass_kg: f64,
511    bc: f64,
512    drag_model: DragModel,
513    wind_segments: Vec<WindSegment>,
514    atmos_params: (f64, f64, f64, f64),
515    omega_vector: Option<Vec<f64>>,
516    enable_spin_drift: bool,
517    enable_magnus: bool,
518    enable_coriolis: bool,
519    method: String,
520    tolerance: f64,
521    max_step: f64,
522    target_distance_m: f64,
523) -> Vec<HashMap<String, f64>> {
524    let omega_vec = omega_vector.map(|v| Vector3::new(v[0], v[1], v[2]));
525
526    let params = TrajectoryParams {
527        mass_kg,
528        bc,
529        drag_model,
530        wind_segments,
531        atmos_params,
532        omega_vector: omega_vec,
533        enable_spin_drift,
534        enable_magnus,
535        enable_coriolis,
536        target_distance_m,
537        enable_wind_shear: false, // Default for test function
538        wind_shear_model: "none".to_string(),
539        shooter_altitude_m: 0.0,
540        is_twist_right: true,    // Default for test function
541        // This legacy entry takes no geometry args; keep the historical placeholders so its
542        // behavior is unchanged (callers needing real geometry use fast_integrate_with_segments).
543        bullet_diameter: 0.0078232,
544        bullet_length: 0.031496,
545        twist_rate: 10.0,
546        custom_drag_table: None, // No CDM for test function
547        bc_segments: None,       // No BC segments for legacy function
548        use_bc_segments: false,
549        ground_threshold: -1000.0, // MBA-954: preserve the historical default
550    };
551
552    let trajectory =
553        integrate_trajectory(initial_state, t_span, params, &method, tolerance, max_step);
554
555    // Convert to Python-friendly format
556    trajectory
557        .into_iter()
558        .map(|(t, state)| {
559            let mut point = HashMap::new();
560            point.insert("t".to_string(), t);
561            point.insert("x".to_string(), state[0]);
562            point.insert("y".to_string(), state[1]);
563            point.insert("z".to_string(), state[2]);
564            point.insert("vx".to_string(), state[3]);
565            point.insert("vy".to_string(), state[4]);
566            point.insert("vz".to_string(), state[5]);
567            point
568        })
569        .collect()
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575
576    fn create_test_params(target_distance_m: f64) -> TrajectoryParams {
577        TrajectoryParams {
578            mass_kg: 0.01134, // 175 grains in kg
579            bc: 0.442,
580            bullet_diameter: 0.0078232, // .308 in
581            bullet_length: 0.031496,    // 1.24 in
582            twist_rate: 10.0,
583            drag_model: DragModel::G7,
584            wind_segments: vec![],
585            atmos_params: (0.0, 59.0, 29.92, 0.0),
586            omega_vector: None,
587            enable_spin_drift: false,
588            enable_magnus: false,
589            enable_coriolis: false,
590            target_distance_m,
591            enable_wind_shear: false,
592            wind_shear_model: "none".to_string(),
593            shooter_altitude_m: 0.0,
594            is_twist_right: true,
595            custom_drag_table: None,
596            bc_segments: None,
597            use_bc_segments: false,
598            ground_threshold: -1000.0,
599        }
600    }
601
602    #[test]
603    fn test_mba954_ground_threshold_honored() {
604        // MBA-954: integrate_trajectory must honor the configured ground plane, not a hardcoded
605        // -1000.0. A descending bullet with a shallow ground_threshold must terminate earlier
606        // (fewer points) than one with the historical deep default.
607        let initial_state = [0.0, 0.0, 0.0, 300.0, -30.0, 0.0]; // descending (vy = -30 m/s)
608
609        let mut shallow = create_test_params(1_000_000.0); // huge target so range never terminates
610        shallow.ground_threshold = -20.0; // stop ~20 m below launch
611        let mut deep = create_test_params(1_000_000.0);
612        deep.ground_threshold = -1000.0; // historical default
613
614        let t_shallow =
615            integrate_trajectory(initial_state, (0.0, 60.0), shallow, "RK4", 1e-6, 0.001);
616        let t_deep = integrate_trajectory(initial_state, (0.0, 60.0), deep, "RK4", 1e-6, 0.001);
617
618        assert!(
619            t_shallow.len() < t_deep.len(),
620            "shallow ground_threshold (-20) should terminate earlier than deep (-1000): \
621             shallow={}, deep={}",
622            t_shallow.len(),
623            t_deep.len()
624        );
625    }
626
627    #[test]
628    fn test_integrate_trajectory_basic() {
629        // Initial state [x,y,z,vx,vy,vz] (McCoy: X=downrange, Z=lateral)
630        // x=0 (downrange start), vx=821.52 (downrange velocity)
631        let initial_state = [0.0, -0.038, 0.0, 821.52, 48.61, 0.0];
632
633        let params = TrajectoryParams {
634            mass_kg: 0.01134, // 175 grains in kg
635            bc: 0.442,
636            bullet_diameter: 0.0078232, // .308 in
637            bullet_length: 0.031496,    // 1.24 in
638            twist_rate: 10.0,
639            drag_model: DragModel::G7,
640            wind_segments: vec![(0.0, 90.0, 914.4)],
641            atmos_params: (0.0, 59.0, 29.92, 0.0),
642            omega_vector: None,
643            enable_spin_drift: false,
644            enable_magnus: false,
645            enable_coriolis: false,
646            target_distance_m: 914.4, // 1000 yards in meters
647            enable_wind_shear: false,
648            wind_shear_model: "none".to_string(),
649            shooter_altitude_m: 0.0,
650            is_twist_right: true,
651            custom_drag_table: None,
652            bc_segments: None,
653            use_bc_segments: false,
654            ground_threshold: -1000.0,
655        };
656
657        println!("Running integrate_trajectory test...");
658        println!("Initial state: {:?}", initial_state);
659        println!("Target distance: {} m", params.target_distance_m);
660
661        let trajectory =
662            integrate_trajectory(initial_state, (0.0, 10.0), params, "RK45", 1e-6, 0.01);
663
664        println!("Trajectory has {} points", trajectory.len());
665
666        // Should have more than just initial point
667        assert!(
668            trajectory.len() > 1,
669            "Trajectory should have more than 1 point, but has {}",
670            trajectory.len()
671        );
672
673        // Check that we actually moved downrange
674        if let Some((_, final_state)) = trajectory.last() {
675            println!("Final state: downrange(x)={}", final_state[0]);
676            assert!(
677                final_state[0] > 0.0,
678                "Final x should be positive (bullet moved downrange)"
679            );
680            assert!(
681                final_state[0] >= 900.0,
682                "Final x should be near target distance"
683            );
684        }
685    }
686
687    #[test]
688    fn test_rk4_vs_rk45_consistency() {
689        // Both methods should give similar results for the same trajectory
690        let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; // McCoy: vx=downrange
691        let target_distance = 500.0;
692
693        let params_rk4 = create_test_params(target_distance);
694        let params_rk45 = create_test_params(target_distance);
695
696        let trajectory_rk4 =
697            integrate_trajectory(initial_state, (0.0, 5.0), params_rk4, "RK4", 1e-6, 0.001);
698        let trajectory_rk45 =
699            integrate_trajectory(initial_state, (0.0, 5.0), params_rk45, "RK45", 1e-6, 0.01);
700
701        // Both should reach target
702        assert!(!trajectory_rk4.is_empty());
703        assert!(!trajectory_rk45.is_empty());
704
705        let (_, final_rk4) = trajectory_rk4.last().unwrap();
706        let (_, final_rk45) = trajectory_rk45.last().unwrap();
707
708        // Final downrange positions should be within 1% of each other
709        let rk4_z = final_rk4[0];
710        let rk45_z = final_rk45[0];
711        let diff_percent = ((rk4_z - rk45_z) / rk45_z).abs() * 100.0;
712
713        assert!(
714            diff_percent < 1.0,
715            "RK4 and RK45 final positions differ by {}%: RK4={}, RK45={}",
716            diff_percent,
717            rk4_z,
718            rk45_z
719        );
720    }
721
722    #[test]
723    fn test_ground_impact_detection() {
724        // Trajectory with steep downward angle should hit ground
725        let initial_state = [0.0, 100.0, 0.0, 300.0, -50.0, 0.0]; // McCoy: vx=downrange // Steep descent
726
727        let mut params = create_test_params(10000.0); // Far target
728        params.target_distance_m = 10000.0;
729
730        let trajectory =
731            integrate_trajectory(initial_state, (0.0, 20.0), params, "RK45", 1e-6, 0.01);
732
733        // Should stop before reaching target due to ground impact
734        let (_, final_state) = trajectory.last().unwrap();
735
736        // y should be near ground threshold (-1000m)
737        assert!(
738            final_state[1] <= -900.0,
739            "Should hit ground, but y={}",
740            final_state[1]
741        );
742        assert!(
743            final_state[0] < 10000.0,
744            "Should not reach target, but z={}",
745            final_state[0]
746        );
747    }
748
749    #[test]
750    fn test_target_distance_reached() {
751        let initial_state = [0.0, 0.0, 0.0, 800.0, 20.0, 0.0]; // McCoy: vx=downrange
752        let target_distance = 300.0;
753
754        let params = create_test_params(target_distance);
755
756        let trajectory =
757            integrate_trajectory(initial_state, (0.0, 5.0), params, "RK45", 1e-6, 0.01);
758
759        let (_, final_state) = trajectory.last().unwrap();
760
761        // Should stop at or very near target distance
762        assert!(
763            (final_state[0] - target_distance).abs() < 1.0,
764            "Should reach target at {}m, but stopped at {}m",
765            target_distance,
766            final_state[0]
767        );
768    }
769
770    #[test]
771    fn test_wind_affects_trajectory() {
772        // Test that wind segments are properly stored and passed through
773        // The actual wind effect depends on the derivatives computation which
774        // uses the wind vector in the drag calculation
775        let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; // McCoy: vx=downrange
776        let target_distance = 500.0;
777
778        // No wind
779        let params_no_wind = create_test_params(target_distance);
780
781        // Strong headwind (0 degrees = headwind)
782        let mut params_headwind = create_test_params(target_distance);
783        params_headwind.wind_segments = vec![(72.0, 0.0, 500.0)]; // 72 km/h = 20 m/s headwind
784
785        let trajectory_no_wind =
786            integrate_trajectory(initial_state, (0.0, 5.0), params_no_wind, "RK45", 1e-6, 0.01);
787        let trajectory_headwind =
788            integrate_trajectory(initial_state, (0.0, 5.0), params_headwind, "RK45", 1e-6, 0.01);
789
790        // Both trajectories should complete
791        assert!(!trajectory_no_wind.is_empty(), "No-wind trajectory should complete");
792        assert!(!trajectory_headwind.is_empty(), "Headwind trajectory should complete");
793
794        let (time_no_wind, final_no_wind) = trajectory_no_wind.last().unwrap();
795        let (time_headwind, final_headwind) = trajectory_headwind.last().unwrap();
796
797        // Headwind should slow the bullet, resulting in longer flight time
798        // or different drop at same distance
799        let drop_no_wind = final_no_wind[1];
800        let drop_headwind = final_headwind[1];
801
802        // With headwind, bullet should drop more (more negative y) due to slower velocity
803        // The effect might be small, so we check that both trajectories are valid
804        println!("No wind: time={}, drop={}", time_no_wind, drop_no_wind);
805        println!("Headwind: time={}, drop={}", time_headwind, drop_headwind);
806
807        // Both should reach approximately the target distance
808        assert!(
809            (final_no_wind[0] - target_distance).abs() < 10.0,
810            "No-wind should reach target"
811        );
812        assert!(
813            (final_headwind[0] - target_distance).abs() < 10.0,
814            "Headwind should reach target"
815        );
816    }
817
818    #[test]
819    fn test_solve_trajectory_rust_output_format() {
820        let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; // McCoy: vx=downrange
821
822        let result = solve_trajectory_rust(
823            initial_state,
824            (0.0, 2.0),
825            0.01134,        // mass_kg
826            0.442,          // bc
827            DragModel::G7,  // drag_model
828            vec![],         // wind_segments
829            (0.0, 59.0, 29.92, 0.0), // atmos_params
830            None,           // omega_vector
831            false,          // enable_spin_drift
832            false,          // enable_magnus
833            false,          // enable_coriolis
834            "RK45".to_string(), // method
835            1e-6,           // tolerance
836            0.01,           // max_step
837            500.0,          // target_distance_m
838        );
839
840        // Should return Vec of HashMaps with expected keys
841        assert!(!result.is_empty());
842
843        let first_point = &result[0];
844        assert!(first_point.contains_key("t"));
845        assert!(first_point.contains_key("x"));
846        assert!(first_point.contains_key("y"));
847        assert!(first_point.contains_key("z"));
848        assert!(first_point.contains_key("vx"));
849        assert!(first_point.contains_key("vy"));
850        assert!(first_point.contains_key("vz"));
851    }
852
853    #[test]
854    fn test_left_vs_right_twist() {
855        let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; // McCoy: vx=downrange
856        let target_distance = 500.0;
857
858        let mut params_right = create_test_params(target_distance);
859        params_right.is_twist_right = true;
860        params_right.enable_spin_drift = true;
861
862        let mut params_left = create_test_params(target_distance);
863        params_left.is_twist_right = false;
864        params_left.enable_spin_drift = true;
865
866        let trajectory_right =
867            integrate_trajectory(initial_state, (0.0, 5.0), params_right, "RK45", 1e-6, 0.01);
868        let trajectory_left =
869            integrate_trajectory(initial_state, (0.0, 5.0), params_left, "RK45", 1e-6, 0.01);
870
871        // Both should complete
872        assert!(!trajectory_right.is_empty());
873        assert!(!trajectory_left.is_empty());
874
875        // Right and left twist should produce valid trajectories
876        let (_, final_right) = trajectory_right.last().unwrap();
877        let (_, final_left) = trajectory_left.last().unwrap();
878
879        // Both should reach approximately the same downrange distance
880        assert!((final_right[2] - final_left[2]).abs() < 10.0);
881    }
882}