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
17const RK45_MIN_STEP: f64 = 1e-6;
18const RK45_DEFAULT_TOLERANCE: f64 = 1e-6;
19const RK45_SAFETY_FACTOR: f64 = 0.9;
20const RK45_MIN_SCALE: f64 = 0.1;
21const RK45_MAX_SCALE: f64 = 2.0;
22
23#[derive(Clone, Copy)]
24struct Rk45Control {
25    tolerance: f64,
26    min_step: f64,
27    max_step: f64,
28    max_trials: usize,
29}
30
31struct Rk45AcceptedStep {
32    state: Vector6<f64>,
33    used_dt: f64,
34    next_dt: f64,
35    error: f64,
36    trials: usize,
37}
38
39fn wind_vector_for_range(range_m: f64, wind_segments: &[WindSegment]) -> Vector3<f64> {
40    if range_m.is_nan() {
41        return Vector3::zeros();
42    }
43    for seg in wind_segments {
44        if range_m < seg.2 {
45            let wind_speed_mps = seg.0 * 0.2777778; // km/h to m/s
46            let wind_angle_rad = seg.1.to_radians();
47            return Vector3::new(
48                -wind_speed_mps * wind_angle_rad.cos(),
49                0.0,
50                -wind_speed_mps * wind_angle_rad.sin(),
51            );
52        }
53    }
54    Vector3::zeros()
55}
56
57/// RK4 integration step
58fn rk4_step(
59    state: &Vector6<f64>,
60    t: f64,
61    dt: f64,
62    params: &TrajectoryParams,
63    inputs: &BallisticInputs,
64) -> Vector6<f64> {
65    // RK4 integration
66    let k1 = compute_derivatives_vec(state, t, params, inputs);
67    let k2 = compute_derivatives_vec(&(state + dt * 0.5 * k1), t + dt * 0.5, params, inputs);
68    let k3 = compute_derivatives_vec(&(state + dt * 0.5 * k2), t + dt * 0.5, params, inputs);
69    let k4 = compute_derivatives_vec(&(state + dt * k3), t + dt, params, inputs);
70
71    state + (dt / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4)
72}
73
74/// Weighted RMS error for a mixed position/velocity state.
75///
76/// Each component is scaled independently so a large downrange position cannot hide an error in
77/// a near-zero lateral velocity (and vice versa). The caller's tolerance therefore acts as both
78/// an absolute and relative tolerance in each component's own unit.
79pub(crate) fn rk45_error_norm(
80    state: &Vector6<f64>,
81    fifth_order: &Vector6<f64>,
82    fourth_order: &Vector6<f64>,
83) -> f64 {
84    let scaled_error_squared: f64 = (0..6)
85        .map(|index| {
86            let scale = 1.0 + state[index].abs().max(fifth_order[index].abs());
87            ((fifth_order[index] - fourth_order[index]) / scale).powi(2)
88        })
89        .sum();
90
91    (scaled_error_squared / 6.0).sqrt()
92}
93
94/// Adaptive RK45 integration step (Dormand-Prince method)
95fn rk45_step(
96    state: &Vector6<f64>,
97    t: f64,
98    dt: f64,
99    params: &TrajectoryParams,
100    inputs: &BallisticInputs,
101    tol: f64,
102) -> (Vector6<f64>, f64, f64) {
103    // Dormand-Prince coefficients (same as scipy.integrate.solve_ivp RK45)
104    const A21: f64 = 1.0 / 5.0;
105    const A31: f64 = 3.0 / 40.0;
106    const A32: f64 = 9.0 / 40.0;
107    const A41: f64 = 44.0 / 45.0;
108    const A42: f64 = -56.0 / 15.0;
109    const A43: f64 = 32.0 / 9.0;
110    const A51: f64 = 19372.0 / 6561.0;
111    const A52: f64 = -25360.0 / 2187.0;
112    const A53: f64 = 64448.0 / 6561.0;
113    const A54: f64 = -212.0 / 729.0;
114    const A61: f64 = 9017.0 / 3168.0;
115    const A62: f64 = -355.0 / 33.0;
116    const A63: f64 = 46732.0 / 5247.0;
117    const A64: f64 = 49.0 / 176.0;
118    const A65: f64 = -5103.0 / 18656.0;
119    const A71: f64 = 35.0 / 384.0;
120    const A73: f64 = 500.0 / 1113.0;
121    const A74: f64 = 125.0 / 192.0;
122    const A75: f64 = -2187.0 / 6784.0;
123    const A76: f64 = 11.0 / 84.0;
124
125    // 5th order coefficients
126    const B1: f64 = 35.0 / 384.0;
127    const B3: f64 = 500.0 / 1113.0;
128    const B4: f64 = 125.0 / 192.0;
129    const B5: f64 = -2187.0 / 6784.0;
130    const B6: f64 = 11.0 / 84.0;
131
132    // 4th order coefficients (for error estimation)
133    const B1_ERR: f64 = 5179.0 / 57600.0;
134    const B3_ERR: f64 = 7571.0 / 16695.0;
135    const B4_ERR: f64 = 393.0 / 640.0;
136    const B5_ERR: f64 = -92097.0 / 339200.0;
137    const B6_ERR: f64 = 187.0 / 2100.0;
138    const B7_ERR: f64 = 1.0 / 40.0;
139
140    // Compute stages
141    let k1 = compute_derivatives_vec(state, t, params, inputs);
142    let k2 = compute_derivatives_vec(&(state + dt * A21 * k1), t + dt * 0.2, params, inputs);
143    let k3 = compute_derivatives_vec(
144        &(state + dt * (A31 * k1 + A32 * k2)),
145        t + dt * 0.3,
146        params,
147        inputs,
148    );
149    let k4 = compute_derivatives_vec(
150        &(state + dt * (A41 * k1 + A42 * k2 + A43 * k3)),
151        t + dt * 0.8,
152        params,
153        inputs,
154    );
155    let k5 = compute_derivatives_vec(
156        &(state + dt * (A51 * k1 + A52 * k2 + A53 * k3 + A54 * k4)),
157        t + dt * 8.0 / 9.0,
158        params,
159        inputs,
160    );
161    let k6 = compute_derivatives_vec(
162        &(state + dt * (A61 * k1 + A62 * k2 + A63 * k3 + A64 * k4 + A65 * k5)),
163        t + dt,
164        params,
165        inputs,
166    );
167    let k7 = compute_derivatives_vec(
168        &(state + dt * (A71 * k1 + A73 * k3 + A74 * k4 + A75 * k5 + A76 * k6)),
169        t + dt,
170        params,
171        inputs,
172    );
173
174    // 5th order solution
175    let y_new = state + dt * (B1 * k1 + B3 * k3 + B4 * k4 + B5 * k5 + B6 * k6);
176
177    // 4th order solution for error estimate
178    let y_err = state
179        + dt * (B1_ERR * k1 + B3_ERR * k3 + B4_ERR * k4 + B5_ERR * k5 + B6_ERR * k6 + B7_ERR * k7);
180
181    let error = rk45_error_norm(state, &y_new, &y_err);
182
183    // Dormand-Prince 5(4) controls both accepted and rejected trials with a fifth-root scale.
184    let step_scale = if !error.is_finite() || !tol.is_finite() || tol <= 0.0 {
185        RK45_MIN_SCALE
186    } else if error == 0.0 {
187        RK45_MAX_SCALE
188    } else {
189        (RK45_SAFETY_FACTOR * (tol / error).powf(0.2)).clamp(RK45_MIN_SCALE, RK45_MAX_SCALE)
190    };
191    let dt_new = dt * step_scale;
192
193    (y_new, dt_new, error)
194}
195
196/// Retry an RK45 step from the same state until its embedded error estimate is acceptable.
197///
198/// `Err(n)` means no finite acceptable candidate was found in `n` trials. Rejected candidates
199/// never escape this function, so callers cannot accidentally advance state or time with one.
200fn adaptive_rk45_step(
201    state: &Vector6<f64>,
202    t: f64,
203    initial_dt: f64,
204    params: &TrajectoryParams,
205    inputs: &BallisticInputs,
206    control: Rk45Control,
207) -> Result<Rk45AcceptedStep, usize> {
208    let mut trial_dt = initial_dt;
209
210    for trials in 1..=control.max_trials {
211        let (new_state, suggested_dt, error) =
212            rk45_step(state, t, trial_dt, params, inputs, control.tolerance);
213        let candidate_is_finite = error.is_finite()
214            && suggested_dt.is_finite()
215            && new_state.iter().all(|value| value.is_finite());
216        let next_dt = suggested_dt.min(control.max_step).max(control.min_step);
217
218        if candidate_is_finite && (error <= control.tolerance || trial_dt <= control.min_step) {
219            return Ok(Rk45AcceptedStep {
220                state: new_state,
221                used_dt: trial_dt,
222                next_dt,
223                error,
224                trials,
225            });
226        }
227
228        if trial_dt <= control.min_step {
229            return Err(trials);
230        }
231        trial_dt = next_dt;
232    }
233
234    Err(control.max_trials)
235}
236
237/// Parameters for trajectory computation
238pub struct TrajectoryParams {
239    pub mass_kg: f64,
240    pub bc: f64,
241    pub drag_model: DragModel,
242    /// Downrange wind zones, normalized by `until_distance_m` when integration begins.
243    pub wind_segments: Vec<WindSegment>,
244    /// Dual-mode atmosphere tuple consumed by `compute_derivatives`:
245    /// **Standard** `(base_alt_m, base_temp_c, base_pressure_hPa, base_density_ratio)` — note
246    /// slot 3 is a density RATIO, NOT humidity, even though it rides in the `humidity` field;
247    /// or **Direct** `(air_density, speed_of_sound, 0.0, 0.0)` — slots 2 and 3 are zero
248    /// sentinels. A pressure of 0 that is not the direct-mode sentinel disables drag.
249    pub atmos_params: (f64, f64, f64, f64),
250    /// Earth rotation in level downrange/up/lateral axes. The derivative kernel projects it into
251    /// the inclined shot frame using `shooting_angle` before applying Coriolis acceleration.
252    pub omega_vector: Option<Vector3<f64>>,
253    pub enable_spin_drift: bool,
254    pub enable_magnus: bool,
255    pub enable_coriolis: bool,
256    pub target_distance_m: f64, // Target horizontal distance in meters
257    pub enable_wind_shear: bool,
258    pub wind_shear_model: String,
259    pub shooter_altitude_m: f64,
260    pub is_twist_right: bool, // True for right-hand twist, false for left-hand
261    pub shooting_angle: f64,  // uphill/downhill angle in radians
262    // MBA-717: real bullet geometry so spin-drift / Magnus / stability on this fast/MC
263    // path use the actual bullet instead of hardcoded .308 / 1.24in / 10-twist placeholders.
264    pub bullet_diameter: f64,                              // meters
265    pub bullet_length: f64, // meters (0.0 -> derivatives falls back to the 4.5-caliber heuristic)
266    pub twist_rate: f64,    // inches per turn
267    pub custom_drag_table: Option<crate::drag::DragTable>, // Custom Drag Model (CDM) data
268    pub bc_segments: Option<Vec<(f64, f64)>>, // Mach-based BC segments: (mach, bc)
269    pub use_bc_segments: bool, // Whether to use BC segment interpolation
270    /// MBA-954: altitude (m, relative to launch) below which integration stops. -1000.0 is the
271    /// historical default — effectively "no early ground impact" for normal flat-fire shots.
272    pub ground_threshold: f64,
273    /// MBA-1137: optional downrange-segmented atmosphere. When `Some`, `compute_derivatives`
274    /// swaps the standard-mode base T/P/H for the zone selected by downrange distance before the
275    /// altitude lapse. `None` (default) is byte-identical to pre-feature behavior.
276    pub atmo_sock: Option<crate::atmosphere::AtmoSock>,
277}
278
279/// Build the loop-invariant BallisticInputs for the derivatives function ONCE per integration,
280/// instead of rebuilding it (a "none".to_string() alloc plus bc_segments / custom_drag_table
281/// clones) on every derivative evaluation (4x per RK4 step, 7x per RK45 step). The launch-speed
282/// magnitude supplies the muzzle-set Magnus spin; every other field depends only on `params`, so
283/// the struct is constant for the whole integration.
284fn build_inputs(params: &TrajectoryParams, muzzle_velocity_mps: f64) -> BallisticInputs {
285    let mut inputs = BallisticInputs {
286        bc_value: params.bc,
287        bc_type: params.drag_model,
288        bullet_mass: params.mass_kg, // kg
289        muzzle_velocity: muzzle_velocity_mps,
290        bullet_diameter: params.bullet_diameter, // MBA-717: real geometry, not placeholders
291        bullet_length: params.bullet_length,
292        twist_rate: params.twist_rate,
293        is_twist_right: params.is_twist_right,
294        enable_advanced_effects: params.enable_spin_drift
295            || params.enable_magnus
296            || params.enable_coriolis,
297        enable_magnus: params.enable_magnus,
298        enable_coriolis: params.enable_coriolis,
299        altitude: params.atmos_params.0,
300        temperature: params.atmos_params.1,
301        pressure: params.atmos_params.2,
302        humidity: params.atmos_params.3,
303        tipoff_yaw: 0.0,
304        target_distance: 1000.0, // default
305        muzzle_angle: 0.0,
306        wind_speed: if !params.wind_segments.is_empty() {
307            params.wind_segments[0].0 * 0.2777778 // km/h -> m/s
308        } else {
309            0.0
310        },
311        wind_angle: if !params.wind_segments.is_empty() {
312            params.wind_segments[0].1.to_radians() // degrees -> radians
313        } else {
314            0.0
315        },
316        latitude: None,
317        shooting_angle: params.shooting_angle,
318        cant_angle: 0.0,
319        azimuth_angle: 0.0,
320        shot_azimuth: 0.0, // this fast path doesn't plumb latitude/bearing (no directional Coriolis here)
321        use_powder_sensitivity: false,
322        powder_temp_sensitivity: 0.0,
323        powder_temp: 59.0,
324        powder_temp_curve: None,
325        powder_curve_temp_c: None,
326        tipoff_decay_distance: 0.0,
327        ground_threshold: params.ground_threshold, // MBA-954: honor the configured ground plane
328        bc_segments: params.bc_segments.clone(),
329        caliber_inches: params.bullet_diameter / 0.0254, // MBA-717: from real diameter
330        weight_grains: params.mass_kg / 0.00006479891,
331        use_bc_segments: params.use_bc_segments,
332        bullet_id: None,
333        bc_segments_data: None,
334        use_enhanced_spin_drift: params.enable_spin_drift,
335        use_form_factor: false,
336        manufacturer: None,
337        bullet_model: None,
338        enable_wind_shear: false,
339        wind_shear_model: "none".to_string(),
340        use_cluster_bc: false,
341        bullet_cluster: None,
342        custom_drag_table: params.custom_drag_table.clone(),
343        bc_type_str: None,
344        enable_pitch_damping: false,
345        enable_precession_nutation: false,
346        // MBA-959/MBA-1183: aerodynamic jump stays OFF inside this low-level raw-state integrator.
347        // The high-level fast wrappers form Sg from their complete BallisticInputs and rotate the
348        // prebuilt initial velocity before entering their integration loops; enabling it again
349        // here would double-apply the launch perturbation. Direct low-level callers likewise own
350        // any desired launch-state rotation. (Real geometry is still carried for spin/Magnus.)
351        enable_aerodynamic_jump: false,
352        use_rk4: true,
353        use_adaptive_rk45: false,
354        enable_trajectory_sampling: false,
355        sample_interval: 10.0,
356        sight_height: 0.0,
357        muzzle_height: 0.0,
358        target_height: 0.0,
359    };
360
361    // MBA-955: pre-populate velocity-BC segments ONCE here, instead of get_bc_for_velocity
362    // rebuilding them (a model String + a segment Vec) on every derivative evaluation (4-7x per
363    // step). Gated to EXACTLY the case where the per-step path would estimate: use_bc_segments on,
364    // no explicit velocity segments, and no Mach-based bc_segments (those take a different,
365    // unchanged path). bc_used there == params.bc == inputs.bc_value, so the estimated segments are
366    // identical and the per-step fast-path lookup returns the same BC -> byte-identical output.
367    if inputs.use_bc_segments && inputs.bc_segments_data.is_none() && inputs.bc_segments.is_none() {
368        inputs.bc_segments_data =
369            crate::derivatives::estimate_bc_segments_for(&inputs, inputs.bc_value);
370    }
371    inputs
372}
373
374/// Convert state to Vector6 and call compute_derivatives
375fn compute_derivatives_vec(
376    state: &Vector6<f64>,
377    t: f64,
378    params: &TrajectoryParams,
379    inputs: &BallisticInputs,
380) -> Vector6<f64> {
381    let pos = Vector3::new(state[0], state[1], state[2]);
382    let vel = Vector3::new(state[3], state[4], state[5]);
383
384    // Calculate wind at current position with shear support
385    let wind_vector = if !params.wind_segments.is_empty() {
386        if params.enable_wind_shear && params.wind_shear_model != "none" {
387            crate::wind_shear::get_wind_at_position(
388                &pos,
389                &params.wind_segments,
390                params.enable_wind_shear,
391                &params.wind_shear_model,
392                params.shooter_altitude_m,
393            )
394        } else {
395            wind_vector_for_range(pos.x, &params.wind_segments)
396        }
397    } else {
398        Vector3::zeros()
399    };
400
401    // Call compute_derivatives - returns [f64; 6] directly. `inputs` is built once per
402    // integration by build_inputs() and threaded in, instead of rebuilt every call.
403    let deriv_result = compute_derivatives(
404        pos,
405        vel,
406        inputs,
407        wind_vector,
408        params.atmos_params,
409        params.bc,
410        params.omega_vector,
411        t,
412        params.atmo_sock.as_ref(),
413    );
414
415    Vector6::new(
416        deriv_result[0],
417        deriv_result[1],
418        deriv_result[2],
419        deriv_result[3],
420        deriv_result[4],
421        deriv_result[5],
422    )
423}
424
425/// Linearly localize a target crossing within an accepted forward integration step.
426///
427/// Callers provide a bracket with `start[0] <= target_x <= end[0]` and increasing downrange X.
428/// The same crossing fraction is applied to time and every phase-space component; X is then set
429/// exactly to the public target value to remove interpolation roundoff.
430fn interpolate_target_crossing(
431    start_time: f64,
432    start: &Vector6<f64>,
433    step_dt: f64,
434    end: &Vector6<f64>,
435    target_x: f64,
436) -> (f64, Vector6<f64>) {
437    debug_assert!(start[0] <= target_x && target_x <= end[0] && end[0] > start[0]);
438
439    let alpha = (target_x - start[0]) / (end[0] - start[0]);
440    let crossing_time = start_time + alpha * step_dt;
441    let mut crossing_state = start + alpha * (end - start);
442    crossing_state[0] = target_x;
443
444    (crossing_time, crossing_state)
445}
446
447/// Main trajectory integration function
448pub fn integrate_trajectory(
449    initial_state: [f64; 6],
450    t_span: (f64, f64),
451    mut params: TrajectoryParams,
452    method: &str,
453    tolerance: f64,
454    max_step: f64,
455) -> Vec<(f64, Vector6<f64>)> {
456    // Normalize once before build_inputs reads the first zone and before any RK stage performs a
457    // first-match lookup. Callers may supply zones in any order.
458    crate::wind::sort_wind_segments_by_distance(&mut params.wind_segments);
459
460    let mut state = Vector6::new(
461        initial_state[0],
462        initial_state[1],
463        initial_state[2],
464        initial_state[3],
465        initial_state[4],
466        initial_state[5],
467    );
468
469    let mut t = t_span.0;
470    let t_end = t_span.1;
471    let mut dt = (t_end - t) / 1000.0; // Initial step size
472
473    let mut trajectory = Vec::with_capacity(10000);
474    trajectory.push((t, state));
475    if state[0] >= params.target_distance_m {
476        return trajectory;
477    }
478
479    // Build the (loop-invariant) derivative inputs once for the whole integration, instead of
480    // rebuilding the struct on every derivative evaluation.
481    let muzzle_velocity_mps =
482        Vector3::new(initial_state[3], initial_state[4], initial_state[5]).norm();
483    let inputs = build_inputs(&params, muzzle_velocity_mps);
484
485    match method {
486        "RK4" => {
487            // Fixed step RK4 with target detection
488            dt = dt.min(max_step).min(0.001); // Use smaller steps for accuracy
489
490            while t < t_end {
491                if t + dt > t_end {
492                    dt = t_end - t;
493                }
494
495                let new_state = rk4_step(&state, t, dt, &params, &inputs);
496
497                // Check if we're about to pass the target (X is downrange, McCoy)
498                if state[0] < params.target_distance_m && new_state[0] >= params.target_distance_m {
499                    trajectory.push(interpolate_target_crossing(
500                        t,
501                        &state,
502                        dt,
503                        &new_state,
504                        params.target_distance_m,
505                    ));
506                    break; // Stop at target
507                }
508
509                state = new_state;
510                t += dt;
511                trajectory.push((t, state));
512
513                // Check if we've reached or passed the target
514                if state[0] >= params.target_distance_m {
515                    break;
516                }
517
518                // Check if bullet hit ground (MBA-954: honor the configured ground plane,
519                // not a hardcoded -1000.0)
520                if state[1] < params.ground_threshold {
521                    break;
522                }
523            }
524        }
525        "RK45" | _ => {
526            // Adaptive RK45 with better sampling
527            let mut last_save_x = 0.0; // X is downrange (McCoy)
528            let save_interval_m = params.target_distance_m / 50.0; // Save ~50 points minimum
529            let tolerance = if tolerance.is_finite() && tolerance > 0.0 {
530                tolerance
531            } else {
532                eprintln!(
533                    "WARNING: RK45 tolerance must be finite and positive; using {RK45_DEFAULT_TOLERANCE}"
534                );
535                RK45_DEFAULT_TOLERANCE
536            };
537
538            // OPTIMIZATION: Adjust max step size when wind shear is enabled
539            // This improves numerical stability at long ranges
540            let effective_max_step =
541                if params.enable_wind_shear && params.wind_shear_model != "none" {
542                    // Use smaller steps for wind shear, but not TOO small
543                    if params.target_distance_m > 800.0 {
544                        0.01 // Smaller steps for long range with shear (10ms)
545                    } else {
546                        0.02 // Normal steps for medium range with shear (20ms)
547                    }
548                } else {
549                    max_step // Use provided max_step when no wind shear
550                };
551            if !effective_max_step.is_finite() || effective_max_step <= 0.0 {
552                eprintln!("WARNING: RK45 max_step must be finite and positive");
553                return trajectory;
554            }
555            let min_step = RK45_MIN_STEP.min(effective_max_step);
556
557            // Set initial step size - ensure it's reasonable
558            dt = dt.min(effective_max_step).max(min_step);
559
560            // Safety check: maximum iterations to prevent infinite loops
561            let max_iterations = 100000; // Should be more than enough for any realistic trajectory
562            let mut iteration_count = 0;
563
564            while t < t_end && iteration_count < max_iterations {
565                // Limit time step for better resolution
566                if t + dt > t_end {
567                    dt = t_end - t;
568                }
569
570                let control = Rk45Control {
571                    tolerance,
572                    min_step,
573                    max_step: effective_max_step,
574                    max_trials: max_iterations - iteration_count,
575                };
576                let accepted = match adaptive_rk45_step(&state, t, dt, &params, &inputs, control) {
577                    Ok(accepted) => accepted,
578                    Err(trials) => {
579                        iteration_count += trials;
580                        if iteration_count < max_iterations {
581                            eprintln!("WARNING: RK45 minimum-step trial was non-finite");
582                        }
583                        break;
584                    }
585                };
586                iteration_count += accepted.trials;
587                debug_assert!(accepted.error <= tolerance || accepted.used_dt <= min_step);
588
589                // Target detection only examines an accepted candidate.
590                if state[0] < params.target_distance_m
591                    && accepted.state[0] >= params.target_distance_m
592                {
593                    trajectory.push(interpolate_target_crossing(
594                        t,
595                        &state,
596                        accepted.used_dt,
597                        &accepted.state,
598                        params.target_distance_m,
599                    ));
600                    break;
601                }
602
603                // Update state and time using the interval that actually passed acceptance.
604                state = accepted.state;
605                t += accepted.used_dt;
606
607                // Save trajectory point if we've moved enough distance
608                if state[0] - last_save_x >= save_interval_m || state[0] >= params.target_distance_m
609                {
610                    // X is downrange
611                    trajectory.push((t, state));
612                    last_save_x = state[0];
613                }
614
615                // Limit the proposal for the next trial; this does not change the time just used.
616                dt = accepted.next_dt;
617
618                // Stop if we've reached the target
619                if state[0] >= params.target_distance_m {
620                    break;
621                }
622
623                // Check if bullet hit ground (MBA-954: honor the configured ground plane,
624                // not a hardcoded -1000.0)
625                if state[1] < params.ground_threshold {
626                    break;
627                }
628            }
629
630            // Warn if we hit the iteration limit
631            if iteration_count >= max_iterations
632                && t < t_end
633                && state[0] < params.target_distance_m
634                && state[1] >= params.ground_threshold
635            {
636                eprintln!(
637                    "WARNING: Trajectory integration hit maximum iteration limit ({} iterations)",
638                    max_iterations
639                );
640                eprintln!("  Final time: {}, Target time: {}", t, t_end);
641                eprintln!(
642                    "  Final position: downrange(x)={}, Target: {}m",
643                    state[0], params.target_distance_m
644                );
645            }
646        }
647    }
648
649    trajectory
650}
651
652/// Python-exposed function for complete trajectory integration
653pub fn solve_trajectory_rust(
654    initial_state: [f64; 6],
655    t_span: (f64, f64),
656    mass_kg: f64,
657    bc: f64,
658    drag_model: DragModel,
659    wind_segments: Vec<WindSegment>,
660    atmos_params: (f64, f64, f64, f64),
661    omega_vector: Option<Vec<f64>>,
662    enable_spin_drift: bool,
663    enable_magnus: bool,
664    enable_coriolis: bool,
665    method: String,
666    tolerance: f64,
667    max_step: f64,
668    target_distance_m: f64,
669) -> Vec<HashMap<String, f64>> {
670    let omega_vec = omega_vector.map(|v| Vector3::new(v[0], v[1], v[2]));
671
672    let params = TrajectoryParams {
673        mass_kg,
674        bc,
675        drag_model,
676        wind_segments,
677        atmos_params,
678        omega_vector: omega_vec,
679        enable_spin_drift,
680        enable_magnus,
681        enable_coriolis,
682        target_distance_m,
683        enable_wind_shear: false, // Default for test function
684        wind_shear_model: "none".to_string(),
685        shooter_altitude_m: 0.0,
686        is_twist_right: true, // Default for test function
687        shooting_angle: 0.0,  // This legacy entry takes no inclined-fire arg; flat fire only
688        // This legacy entry takes no geometry args; keep the historical placeholders so its
689        // behavior is unchanged (callers needing real geometry use fast_integrate_with_segments).
690        bullet_diameter: 0.0078232,
691        bullet_length: 0.031496,
692        twist_rate: 10.0,
693        custom_drag_table: None, // No CDM for test function
694        bc_segments: None,       // No BC segments for legacy function
695        use_bc_segments: false,
696        ground_threshold: -1000.0, // MBA-954: preserve the historical default
697        atmo_sock: None,           // MBA-1137: legacy entry has no downrange atmosphere
698    };
699
700    let trajectory =
701        integrate_trajectory(initial_state, t_span, params, &method, tolerance, max_step);
702
703    // Convert to Python-friendly format
704    trajectory
705        .into_iter()
706        .map(|(t, state)| {
707            let mut point = HashMap::new();
708            point.insert("t".to_string(), t);
709            point.insert("x".to_string(), state[0]);
710            point.insert("y".to_string(), state[1]);
711            point.insert("z".to_string(), state[2]);
712            point.insert("vx".to_string(), state[3]);
713            point.insert("vy".to_string(), state[4]);
714            point.insert("vz".to_string(), state[5]);
715            point
716        })
717        .collect()
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723
724    fn create_test_params(target_distance_m: f64) -> TrajectoryParams {
725        TrajectoryParams {
726            mass_kg: 0.01134, // 175 grains in kg
727            bc: 0.442,
728            bullet_diameter: 0.0078232, // .308 in
729            bullet_length: 0.031496,    // 1.24 in
730            twist_rate: 10.0,
731            drag_model: DragModel::G7,
732            wind_segments: vec![],
733            atmos_params: (0.0, 15.0, 1013.25, 1.0),
734            omega_vector: None,
735            enable_spin_drift: false,
736            enable_magnus: false,
737            enable_coriolis: false,
738            target_distance_m,
739            enable_wind_shear: false,
740            wind_shear_model: "none".to_string(),
741            shooter_altitude_m: 0.0,
742            is_twist_right: true,
743            shooting_angle: 0.0,
744            custom_drag_table: None,
745            bc_segments: None,
746            use_bc_segments: false,
747            ground_threshold: -1000.0,
748            atmo_sock: None,
749        }
750    }
751
752    #[test]
753    fn derivative_inputs_preserve_initial_velocity_as_muzzle_speed() {
754        let params = create_test_params(1_000.0);
755        let launch_velocity = Vector3::new(700.0, 30.0, -20.0);
756        let inputs = build_inputs(&params, launch_velocity.norm());
757
758        assert_eq!(
759            inputs.muzzle_velocity.to_bits(),
760            launch_velocity.norm().to_bits()
761        );
762    }
763
764    #[test]
765    fn integrated_magnus_retains_nonzero_launch_spin() {
766        let initial_state = [0.0, 0.0, 0.0, 800.0, 0.0, 0.0];
767        let baseline = integrate_trajectory(
768            initial_state,
769            (0.0, 0.1),
770            create_test_params(1_000.0),
771            "RK4",
772            1e-6,
773            0.001,
774        );
775        let mut magnus_params = create_test_params(1_000.0);
776        magnus_params.enable_magnus = true;
777
778        let trajectory = integrate_trajectory(
779            initial_state,
780            (0.0, 0.1),
781            magnus_params,
782            "RK4",
783            1e-6,
784            0.001,
785        );
786        let baseline_y = baseline.last().expect("baseline trajectory is empty").1[1];
787        let magnus_y = trajectory.last().expect("trajectory is empty").1[1];
788        let vertical_delta = magnus_y - baseline_y;
789
790        assert!(
791            vertical_delta.is_finite() && vertical_delta < 0.0,
792            "right-twist Magnus should retain nonzero launch spin and point down, got \
793             delta_y={vertical_delta}"
794        );
795    }
796
797    #[test]
798    fn rk45_retries_rejected_wind_boundary_step() {
799        let initial_state = [0.0, 0.0, 0.0, 800.0, 0.0, 0.0];
800        let mut params = create_test_params(100.0);
801        params.wind_segments = vec![(0.0, 90.0, 4.0), (1_000.0, 90.0, 10_000.0)];
802
803        let state = Vector6::from_row_slice(&initial_state);
804        let launch_speed =
805            Vector3::new(initial_state[3], initial_state[4], initial_state[5]).norm();
806        let inputs = build_inputs(&params, launch_speed);
807        let initial_dt = 0.01;
808        let tolerance = 1e-6;
809        let (rejected_state, suggested_dt, error) =
810            rk45_step(&state, 0.0, initial_dt, &params, &inputs, tolerance);
811        assert!(
812            error > tolerance,
813            "wind-boundary trial must exceed tolerance, got {error}"
814        );
815        assert!(suggested_dt < initial_dt);
816
817        let accepted = adaptive_rk45_step(
818            &state,
819            0.0,
820            initial_dt,
821            &params,
822            &inputs,
823            Rk45Control {
824                tolerance,
825                min_step: RK45_MIN_STEP,
826                max_step: initial_dt,
827                max_trials: 100,
828            },
829        )
830        .expect("a smaller finite trial should satisfy the tolerance");
831
832        assert!(accepted.trials > 1, "oversized trial was not retried");
833        assert!(accepted.used_dt < initial_dt);
834        assert!(
835            accepted.error <= tolerance || accepted.used_dt <= RK45_MIN_STEP,
836            "accepted error {} exceeds tolerance at dt {}",
837            accepted.error,
838            accepted.used_dt
839        );
840
841        let (accepted_state, _, accepted_error) =
842            rk45_step(&state, 0.0, accepted.used_dt, &params, &inputs, tolerance);
843        assert_eq!(accepted.state, accepted_state);
844        assert_eq!(accepted.error, accepted_error);
845        assert_ne!(accepted.state, rejected_state);
846        assert!((RK45_MIN_STEP..=initial_dt).contains(&accepted.next_dt));
847    }
848
849    #[test]
850    fn integration_normalizes_wind_segments_by_distance() {
851        let initial_state = [0.0, 0.0, 0.0, 800.0, 0.0, 0.0];
852        let sorted_segments = vec![(40.0, 270.0, 300.0), (20.0, 90.0, 600.0)];
853
854        let mut sorted_params = create_test_params(100.0);
855        sorted_params.wind_segments = sorted_segments.clone();
856        let mut unsorted_params = create_test_params(100.0);
857        unsorted_params.wind_segments = sorted_segments.into_iter().rev().collect();
858
859        let sorted =
860            integrate_trajectory(initial_state, (0.0, 1.0), sorted_params, "RK4", 1e-6, 0.001);
861        let unsorted = integrate_trajectory(
862            initial_state,
863            (0.0, 1.0),
864            unsorted_params,
865            "RK4",
866            1e-6,
867            0.001,
868        );
869
870        assert_eq!(unsorted.len(), sorted.len());
871        for (index, ((sorted_t, sorted_state), (unsorted_t, unsorted_state))) in
872            sorted.iter().zip(&unsorted).enumerate()
873        {
874            assert_eq!(unsorted_t.to_bits(), sorted_t.to_bits());
875            for component in 0..6 {
876                assert_eq!(
877                    unsorted_state[component].to_bits(),
878                    sorted_state[component].to_bits(),
879                    "wind segment order changed state component {component} at point {index}"
880                );
881            }
882        }
883    }
884
885    #[test]
886    fn rk4_target_crossing_interpolates_complete_state_and_time() {
887        let initial_state = [0.0, 0.0, 0.0, 800.0, 5.0, 2.0];
888        let target_distance_m = 100.0;
889        let trajectory = integrate_trajectory(
890            initial_state,
891            (0.0, 1.0),
892            create_test_params(target_distance_m),
893            "RK4",
894            1e-6,
895            0.001,
896        );
897
898        let (previous_t, previous_state) = &trajectory[trajectory.len() - 2];
899        let (terminal_t, terminal_state) = trajectory.last().expect("trajectory is empty");
900        let reference_params = create_test_params(target_distance_m);
901        let inputs = build_inputs(&reference_params, Vector3::new(800.0, 5.0, 2.0).norm());
902        let full_step_dt = 0.001;
903        let bracket_end = rk4_step(
904            previous_state,
905            *previous_t,
906            full_step_dt,
907            &reference_params,
908            &inputs,
909        );
910        assert!(previous_state[0] < target_distance_m);
911        assert!(bracket_end[0] >= target_distance_m);
912
913        let alpha = (target_distance_m - previous_state[0]) / (bracket_end[0] - previous_state[0]);
914        let expected_t = previous_t + alpha * full_step_dt;
915        let mut expected_state = previous_state + alpha * (bracket_end - previous_state);
916        expected_state[0] = target_distance_m;
917
918        assert_eq!(terminal_t.to_bits(), expected_t.to_bits());
919        for component in 0..6 {
920            assert_eq!(
921                terminal_state[component].to_bits(),
922                expected_state[component].to_bits(),
923                "terminal component {component} was not interpolated at the target crossing"
924            );
925        }
926    }
927
928    #[test]
929    fn rk45_target_crossing_uses_the_accepted_state_and_time() {
930        let initial_state = [0.0, 0.0, 0.0, 800.0, 5.0, 2.0];
931        let initial = Vector6::from_row_slice(&initial_state);
932        let target_distance_m = 0.5;
933        let reference_params = create_test_params(target_distance_m);
934        let inputs = build_inputs(&reference_params, Vector3::new(800.0, 5.0, 2.0).norm());
935        let initial_dt = 0.001;
936        let accepted = adaptive_rk45_step(
937            &initial,
938            0.0,
939            initial_dt,
940            &reference_params,
941            &inputs,
942            Rk45Control {
943                tolerance: 1e-6,
944                min_step: RK45_MIN_STEP,
945                max_step: 0.01,
946                max_trials: 100_000,
947            },
948        )
949        .expect("first RK45 target bracket should be accepted");
950        assert!(accepted.state[0] >= target_distance_m);
951        let expected = interpolate_target_crossing(
952            0.0,
953            &initial,
954            accepted.used_dt,
955            &accepted.state,
956            target_distance_m,
957        );
958
959        let trajectory = integrate_trajectory(
960            initial_state,
961            (0.0, 1.0),
962            create_test_params(target_distance_m),
963            "RK45",
964            1e-6,
965            0.01,
966        );
967        let actual = trajectory.last().expect("trajectory is empty");
968
969        assert_eq!(actual.0.to_bits(), expected.0.to_bits());
970        for component in 0..6 {
971            assert_eq!(
972                actual.1[component].to_bits(),
973                expected.1[component].to_bits(),
974                "RK45 terminal component {component} was not interpolated from its accepted step"
975            );
976        }
977    }
978
979    #[test]
980    fn target_crossing_helper_interpolates_every_component() {
981        let start = Vector6::new(90.0, 10.0, -4.0, 700.0, -20.0, 5.0);
982        let end = Vector6::new(130.0, 6.0, 8.0, 660.0, -24.0, 9.0);
983        let (time, state) = interpolate_target_crossing(2.0, &start, 0.5, &end, 100.0);
984
985        assert_eq!(time.to_bits(), 2.125_f64.to_bits());
986        for (index, expected) in [100.0_f64, 9.0, -1.0, 690.0, -21.0, 6.0]
987            .into_iter()
988            .enumerate()
989        {
990            assert_eq!(state[index].to_bits(), expected.to_bits());
991        }
992    }
993
994    #[test]
995    fn already_at_or_past_target_returns_initial_state_without_advancing() {
996        let initial = [150.0, 12.0, -3.0, 700.0, -4.0, 5.0];
997
998        for method in ["RK4", "RK45"] {
999            for target in [150.0, 100.0] {
1000                let trajectory = integrate_trajectory(
1001                    initial,
1002                    (2.0, 3.0),
1003                    create_test_params(target),
1004                    method,
1005                    1e-6,
1006                    0.01,
1007                );
1008
1009                assert_eq!(trajectory.len(), 1, "{method} advanced a terminal state");
1010                let (time, state) = &trajectory[0];
1011                assert_eq!(time.to_bits(), 2.0_f64.to_bits());
1012                for index in 0..6 {
1013                    assert_eq!(state[index].to_bits(), initial[index].to_bits());
1014                }
1015            }
1016        }
1017    }
1018
1019    #[test]
1020    fn rk45_error_norm_scales_components_independently() {
1021        let state = Vector6::new(1.0e9, 0.0, 0.0, 800.0, 0.0, 0.0);
1022        let fifth_order = state;
1023        let mut fourth_order = state;
1024        fourth_order[4] = 1.0e-3;
1025
1026        let error = rk45_error_norm(&state, &fifth_order, &fourth_order);
1027        let expected = 1.0e-3 / 6.0_f64.sqrt();
1028
1029        assert!(
1030            (error - expected).abs() <= 1e-15,
1031            "large downrange position masked a velocity-component error: {error}"
1032        );
1033    }
1034
1035    #[test]
1036    fn test_mba954_ground_threshold_honored() {
1037        // MBA-954: integrate_trajectory must honor the configured ground plane, not a hardcoded
1038        // -1000.0. A descending bullet with a shallow ground_threshold must terminate earlier
1039        // (fewer points) than one with the historical deep default.
1040        let initial_state = [0.0, 0.0, 0.0, 300.0, -30.0, 0.0]; // descending (vy = -30 m/s)
1041
1042        let mut shallow = create_test_params(1_000_000.0); // huge target so range never terminates
1043        shallow.ground_threshold = -20.0; // stop ~20 m below launch
1044        let mut deep = create_test_params(1_000_000.0);
1045        deep.ground_threshold = -1000.0; // historical default
1046
1047        let t_shallow =
1048            integrate_trajectory(initial_state, (0.0, 60.0), shallow, "RK4", 1e-6, 0.001);
1049        let t_deep = integrate_trajectory(initial_state, (0.0, 60.0), deep, "RK4", 1e-6, 0.001);
1050
1051        assert!(
1052            t_shallow.len() < t_deep.len(),
1053            "shallow ground_threshold (-20) should terminate earlier than deep (-1000): \
1054             shallow={}, deep={}",
1055            t_shallow.len(),
1056            t_deep.len()
1057        );
1058    }
1059
1060    #[test]
1061    fn test_integrate_trajectory_basic() {
1062        // Initial state [x,y,z,vx,vy,vz] (McCoy: X=downrange, Z=lateral)
1063        // x=0 (downrange start), vx=821.52 (downrange velocity)
1064        let initial_state = [0.0, -0.038, 0.0, 821.52, 48.61, 0.0];
1065
1066        let params = TrajectoryParams {
1067            mass_kg: 0.01134, // 175 grains in kg
1068            bc: 0.442,
1069            bullet_diameter: 0.0078232, // .308 in
1070            bullet_length: 0.031496,    // 1.24 in
1071            twist_rate: 10.0,
1072            drag_model: DragModel::G7,
1073            wind_segments: vec![(0.0, 90.0, 914.4)],
1074            atmos_params: (0.0, 15.0, 1013.25, 1.0),
1075            omega_vector: None,
1076            enable_spin_drift: false,
1077            enable_magnus: false,
1078            enable_coriolis: false,
1079            target_distance_m: 914.4, // 1000 yards in meters
1080            enable_wind_shear: false,
1081            wind_shear_model: "none".to_string(),
1082            shooter_altitude_m: 0.0,
1083            is_twist_right: true,
1084            shooting_angle: 0.0,
1085            custom_drag_table: None,
1086            bc_segments: None,
1087            use_bc_segments: false,
1088            ground_threshold: -1000.0,
1089            atmo_sock: None,
1090        };
1091
1092        println!("Running integrate_trajectory test...");
1093        println!("Initial state: {:?}", initial_state);
1094        println!("Target distance: {} m", params.target_distance_m);
1095
1096        let trajectory =
1097            integrate_trajectory(initial_state, (0.0, 10.0), params, "RK45", 1e-6, 0.01);
1098
1099        println!("Trajectory has {} points", trajectory.len());
1100
1101        // Should have more than just initial point
1102        assert!(
1103            trajectory.len() > 1,
1104            "Trajectory should have more than 1 point, but has {}",
1105            trajectory.len()
1106        );
1107
1108        // Check that we actually moved downrange
1109        if let Some((_, final_state)) = trajectory.last() {
1110            println!("Final state: downrange(x)={}", final_state[0]);
1111            assert!(
1112                final_state[0] > 0.0,
1113                "Final x should be positive (bullet moved downrange)"
1114            );
1115            assert!(
1116                final_state[0] >= 900.0,
1117                "Final x should be near target distance"
1118            );
1119            assert!(
1120                final_state[3] < 0.9 * initial_state[3],
1121                "standard-atmosphere drag should reduce downrange velocity"
1122            );
1123        }
1124    }
1125
1126    #[test]
1127    fn test_rk4_vs_rk45_consistency() {
1128        // Both methods should give similar results for the same trajectory
1129        let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; // McCoy: vx=downrange
1130        let target_distance = 500.0;
1131
1132        let params_rk4 = create_test_params(target_distance);
1133        let params_rk45 = create_test_params(target_distance);
1134
1135        let trajectory_rk4 =
1136            integrate_trajectory(initial_state, (0.0, 5.0), params_rk4, "RK4", 1e-6, 0.001);
1137        let trajectory_rk45 =
1138            integrate_trajectory(initial_state, (0.0, 5.0), params_rk45, "RK45", 1e-6, 0.01);
1139
1140        // Both should reach target
1141        assert!(!trajectory_rk4.is_empty());
1142        assert!(!trajectory_rk45.is_empty());
1143
1144        let (time_rk4, final_rk4) = trajectory_rk4.last().unwrap();
1145        let (time_rk45, final_rk45) = trajectory_rk45.last().unwrap();
1146
1147        // Compare quantities that are not forced equal by target-distance clamping.
1148        assert!(
1149            (time_rk4 - time_rk45).abs() < 1e-4,
1150            "RK4/RK45 time of flight diverged: {time_rk4} vs {time_rk45}"
1151        );
1152        assert!((final_rk4[1] - final_rk45[1]).abs() < 1e-3);
1153        assert!((final_rk4[3] - final_rk45[3]).abs() < 1e-2);
1154        assert!(final_rk45[3] < 0.9 * initial_state[3]);
1155    }
1156
1157    #[test]
1158    fn test_ground_impact_detection() {
1159        // Trajectory with steep downward angle should hit ground
1160        let initial_state = [0.0, 100.0, 0.0, 300.0, -50.0, 0.0]; // McCoy: vx=downrange // Steep descent
1161
1162        let mut params = create_test_params(10000.0); // Far target
1163        params.target_distance_m = 10000.0;
1164        let ground_threshold = 0.0;
1165        params.ground_threshold = ground_threshold;
1166
1167        let trajectory =
1168            integrate_trajectory(initial_state, (0.0, 20.0), params, "RK4", 1e-6, 0.01);
1169
1170        // Should stop before reaching target due to ground impact
1171        let (_, final_state) = trajectory.last().unwrap();
1172
1173        // y should have crossed the configured ground threshold.
1174        assert!(
1175            final_state[1] <= ground_threshold,
1176            "Should hit ground, but y={}",
1177            final_state[1]
1178        );
1179        assert!(
1180            final_state[0] < 10000.0,
1181            "Should not reach target, but z={}",
1182            final_state[0]
1183        );
1184    }
1185
1186    #[test]
1187    fn test_target_distance_reached() {
1188        let initial_state = [0.0, 0.0, 0.0, 800.0, 20.0, 0.0]; // McCoy: vx=downrange
1189        let target_distance = 300.0;
1190
1191        let params = create_test_params(target_distance);
1192
1193        let trajectory =
1194            integrate_trajectory(initial_state, (0.0, 5.0), params, "RK45", 1e-6, 0.01);
1195
1196        let (_, final_state) = trajectory.last().unwrap();
1197
1198        // Should stop at or very near target distance
1199        assert!(
1200            (final_state[0] - target_distance).abs() < 1.0,
1201            "Should reach target at {}m, but stopped at {}m",
1202            target_distance,
1203            final_state[0]
1204        );
1205    }
1206
1207    #[test]
1208    fn test_wind_affects_trajectory() {
1209        // Test that wind segments are properly stored and passed through
1210        // The actual wind effect depends on the derivatives computation which
1211        // uses the wind vector in the drag calculation
1212        let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; // McCoy: vx=downrange
1213        let target_distance = 500.0;
1214
1215        // No wind
1216        let params_no_wind = create_test_params(target_distance);
1217
1218        // Strong headwind (0 degrees = headwind)
1219        let mut params_headwind = create_test_params(target_distance);
1220        params_headwind.wind_segments = vec![(72.0, 0.0, 500.0)]; // 72 km/h = 20 m/s headwind
1221
1222        let trajectory_no_wind = integrate_trajectory(
1223            initial_state,
1224            (0.0, 5.0),
1225            params_no_wind,
1226            "RK45",
1227            1e-6,
1228            0.01,
1229        );
1230        let trajectory_headwind = integrate_trajectory(
1231            initial_state,
1232            (0.0, 5.0),
1233            params_headwind,
1234            "RK45",
1235            1e-6,
1236            0.01,
1237        );
1238
1239        // Both trajectories should complete
1240        assert!(
1241            !trajectory_no_wind.is_empty(),
1242            "No-wind trajectory should complete"
1243        );
1244        assert!(
1245            !trajectory_headwind.is_empty(),
1246            "Headwind trajectory should complete"
1247        );
1248
1249        let (time_no_wind, final_no_wind) = trajectory_no_wind.last().unwrap();
1250        let (time_headwind, final_headwind) = trajectory_headwind.last().unwrap();
1251
1252        // Headwind should slow the bullet, resulting in longer flight time
1253        // or different drop at same distance
1254        let drop_no_wind = final_no_wind[1];
1255        let drop_headwind = final_headwind[1];
1256
1257        println!("No wind: time={}, drop={}", time_no_wind, drop_no_wind);
1258        println!("Headwind: time={}, drop={}", time_headwind, drop_headwind);
1259
1260        assert!(
1261            *time_headwind > *time_no_wind + 0.001,
1262            "headwind should increase time of flight: no-wind={time_no_wind}, headwind={time_headwind}"
1263        );
1264        assert!(
1265            final_headwind[3] < final_no_wind[3] - 1.0,
1266            "headwind should reduce terminal downrange velocity"
1267        );
1268
1269        // Both should reach approximately the target distance
1270        assert!(
1271            (final_no_wind[0] - target_distance).abs() < 10.0,
1272            "No-wind should reach target"
1273        );
1274        assert!(
1275            (final_headwind[0] - target_distance).abs() < 10.0,
1276            "Headwind should reach target"
1277        );
1278    }
1279
1280    #[test]
1281    fn test_solve_trajectory_rust_output_format() {
1282        let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; // McCoy: vx=downrange
1283
1284        let result = solve_trajectory_rust(
1285            initial_state,
1286            (0.0, 2.0),
1287            0.01134,       // mass_kg
1288            0.442,         // bc
1289            DragModel::G7, // drag_model
1290            vec![],        // wind_segments
1291            // Standard atmosphere: altitude m, temperature C, pressure hPa, density ratio.
1292            (0.0, 15.0, 1013.25, 1.0),
1293            None,               // omega_vector
1294            false,              // enable_spin_drift
1295            false,              // enable_magnus
1296            false,              // enable_coriolis
1297            "RK45".to_string(), // method
1298            1e-6,               // tolerance
1299            0.01,               // max_step
1300            500.0,              // target_distance_m
1301        );
1302
1303        // Should return Vec of HashMaps with expected keys
1304        assert!(!result.is_empty());
1305
1306        let first_point = &result[0];
1307        assert!(first_point.contains_key("t"));
1308        assert!(first_point.contains_key("x"));
1309        assert!(first_point.contains_key("y"));
1310        assert!(first_point.contains_key("z"));
1311        assert!(first_point.contains_key("vx"));
1312        assert!(first_point.contains_key("vy"));
1313        assert!(first_point.contains_key("vz"));
1314
1315        let final_point = result.last().unwrap();
1316        assert!(
1317            final_point["vx"] < 0.9 * initial_state[3],
1318            "standard-atmosphere wrapper fixture should exercise drag"
1319        );
1320    }
1321
1322    #[test]
1323    fn test_left_vs_right_twist() {
1324        let initial_state = [0.0, 0.0, 0.0, 800.0, 30.0, 0.0]; // McCoy: vx=downrange
1325        let target_distance = 500.0;
1326
1327        let mut params_right = create_test_params(target_distance);
1328        params_right.is_twist_right = true;
1329        params_right.enable_spin_drift = true;
1330
1331        let mut params_left = create_test_params(target_distance);
1332        params_left.is_twist_right = false;
1333        params_left.enable_spin_drift = true;
1334
1335        let trajectory_right =
1336            integrate_trajectory(initial_state, (0.0, 5.0), params_right, "RK45", 1e-6, 0.01);
1337        let trajectory_left =
1338            integrate_trajectory(initial_state, (0.0, 5.0), params_left, "RK45", 1e-6, 0.01);
1339
1340        // Both should complete
1341        assert!(!trajectory_right.is_empty());
1342        assert!(!trajectory_left.is_empty());
1343
1344        // Right and left twist should produce valid trajectories
1345        let (_, final_right) = trajectory_right.last().unwrap();
1346        let (_, final_left) = trajectory_left.last().unwrap();
1347
1348        // Both should reach approximately the same downrange distance
1349        assert!((final_right[2] - final_left[2]).abs() < 10.0);
1350    }
1351}