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