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