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