Skip to main content

ballistics_engine/
fast_trajectory.rs

1//! Fast trajectory solver for longer ranges.
2//!
3//! This is a Rust implementation of the fast fixed-step trajectory solver
4//! that provides significant performance improvements for long-range calculations.
5
6use crate::{
7    atmosphere::get_local_atmosphere,
8    constants::{G_ACCEL_MPS2, MPS_TO_FPS},
9    drag::get_drag_coefficient,
10    wind::WindSock,
11    BCSegmentData, DragModel, InternalBallisticInputs as BallisticInputs,
12};
13use nalgebra::Vector3;
14
15/// Fast solution container matching Python implementation
16#[derive(Debug, Clone)]
17pub struct FastSolution {
18    /// Time points
19    pub t: Vec<f64>,
20    /// State vectors at each time point [6 x n_points]
21    pub y: Vec<Vec<f64>>,
22    /// Event times [target_hit, max_ord, ground_hit]
23    pub t_events: [Vec<f64>; 3],
24    /// Whether integration succeeded
25    pub success: bool,
26}
27
28impl FastSolution {
29    /// Interpolate solution at time t
30    pub fn sol(&self, t_query: &[f64]) -> Vec<Vec<f64>> {
31        let mut result = vec![vec![0.0; t_query.len()]; 6];
32
33        for (i, &tq) in t_query.iter().enumerate() {
34            // Find the right interval using binary search
35            // Use unwrap_or to safely handle NaN values by treating them as greater
36            let idx = match self
37                .t
38                .binary_search_by(|&t| t.partial_cmp(&tq).unwrap_or(std::cmp::Ordering::Greater))
39            {
40                Ok(idx) => idx,
41                Err(idx) => idx,
42            };
43
44            if idx == 0 {
45                // Before first point
46                for j in 0..6 {
47                    result[j][i] = self.y[j][0];
48                }
49            } else if idx >= self.t.len() {
50                // After last point
51                for j in 0..6 {
52                    result[j][i] = self.y[j][self.t.len() - 1];
53                }
54            } else {
55                // Linear interpolation
56                let t0 = self.t[idx - 1];
57                let t1 = self.t[idx];
58                let frac = (tq - t0) / (t1 - t0);
59
60                for j in 0..6 {
61                    let y0 = self.y[j][idx - 1];
62                    let y1 = self.y[j][idx];
63                    result[j][i] = y0 + frac * (y1 - y0);
64                }
65            }
66        }
67
68        result
69    }
70
71    /// Convert from row-major to column-major format for compatibility
72    pub fn from_trajectory_data(
73        times: Vec<f64>,
74        states: Vec<[f64; 6]>,
75        t_events: [Vec<f64>; 3],
76    ) -> Self {
77        let n_points = times.len();
78        let mut y = vec![vec![0.0; n_points]; 6];
79
80        for (i, state) in states.iter().enumerate() {
81            for j in 0..6 {
82                y[j][i] = state[j];
83            }
84        }
85
86        FastSolution {
87            t: times,
88            y,
89            t_events,
90            success: true,
91        }
92    }
93
94    /// A clearly-failed solution carrying only the launch state, with `success = false`.
95    /// Used when inputs are too degenerate to integrate (e.g. non-physical atmosphere),
96    /// so callers see `success = false` instead of a stub trajectory reported as success.
97    fn degenerate(initial_state: &[f64; 6]) -> Self {
98        let mut y = vec![Vec::new(); 6];
99        for (j, slot) in y.iter_mut().enumerate() {
100            slot.push(initial_state[j]);
101        }
102        FastSolution {
103            t: vec![0.0],
104            y,
105            t_events: [Vec::new(), Vec::new(), Vec::new()],
106            success: false,
107        }
108    }
109}
110
111/// True if `atmo_params` (alt_m, temp_c, pressure_hPa, humidity) can yield a finite,
112/// positive air density. A pressure <= 0 (or non-finite temp/pressure — often a unit
113/// mistake, e.g. inHg passed where hPa is expected) gives zero/NaN density and would
114/// otherwise silently truncate the integration to a single point.
115fn atmo_is_physical(atmo_params: (f64, f64, f64, f64)) -> bool {
116    let (_alt, temp_c, pressure_hpa, _humidity) = atmo_params;
117    temp_c.is_finite() && pressure_hpa.is_finite() && pressure_hpa > 0.0
118}
119
120/// Fast trajectory integration parameters
121pub struct FastIntegrationParams {
122    pub horiz: f64,
123    pub vert: f64,
124    pub initial_state: [f64; 6],
125    pub t_span: (f64, f64),
126    pub atmo_params: (f64, f64, f64, f64),
127}
128
129/// Aerodynamic-jump vertical launch-angle offset (radians) for the fast-integrate path.
130///
131/// Bryan Litz's crosswind estimator (`Y = 0.01*Sg - 0.0024*L + 0.032` MOA/mph) fed by the
132/// engine's Miller Sg. The fast path receives a prebuilt initial velocity (no muzzle angle),
133/// so the caller rotates that velocity by this offset. Returns 0 when the feature is off or
134/// the inputs are degenerate. Crosswind is taken from `wind_speed`/`wind_angle`
135/// (BallisticInputs convention: 0 = headwind, +90deg = from the right). MBA-959, EXPERIMENTAL.
136pub fn aerodynamic_jump_launch_offset_rad(
137    inputs: &BallisticInputs,
138    atmo_params: (f64, f64, f64, f64),
139) -> f64 {
140    if !inputs.enable_aerodynamic_jump {
141        return 0.0;
142    }
143    let diameter = inputs.bullet_diameter;
144    if !(inputs.twist_rate.is_finite() && inputs.twist_rate != 0.0)
145        || !(diameter.is_finite() && diameter > 0.0)
146        || !(inputs.bullet_length.is_finite() && inputs.bullet_length > 0.0)
147        || !inputs.muzzle_velocity.is_finite()
148    {
149        return 0.0;
150    }
151    let sg = crate::stability::compute_stability_coefficient(inputs, atmo_params);
152    if !(sg.is_finite() && sg > 0.0) {
153        return 0.0;
154    }
155    let length_cal = inputs.bullet_length / diameter;
156    const MS_TO_MPH: f64 = 2.236_936_292_054_4;
157    let crosswind_from_right_mph = inputs.wind_speed * inputs.wind_angle.sin() * MS_TO_MPH;
158    let vertical_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
159        sg,
160        length_cal,
161        crosswind_from_right_mph,
162        inputs.is_twist_right,
163    );
164    if !vertical_moa.is_finite() {
165        return 0.0;
166    }
167    const MOA_PER_RAD: f64 = 3437.7467707849;
168    vertical_moa / MOA_PER_RAD
169}
170
171/// Rotate a McCoy-frame state's velocity (indices 3..6) by `theta_rad` in the vertical
172/// (downrange–vertical) plane, preserving speed and horizontal heading. Positive = up.
173fn rotate_launch_velocity(state: &mut [f64; 6], theta_rad: f64) {
174    let (vx, vy, vz) = (state[3], state[4], state[5]);
175    let speed = (vx * vx + vy * vy + vz * vz).sqrt();
176    if speed <= 0.0 {
177        return;
178    }
179    let h = (vx * vx + vz * vz).sqrt(); // horizontal (downrange+lateral) speed
180    let new_elev = vy.atan2(h) + theta_rad;
181    state[4] = speed * new_elev.sin();
182    let new_h = speed * new_elev.cos();
183    let scale = if h > 1e-12 { new_h / h } else { 0.0 };
184    state[3] = vx * scale;
185    state[5] = vz * scale;
186}
187
188/// Fast fixed-step integration for longer trajectories
189pub fn fast_integrate(
190    inputs: &BallisticInputs,
191    wind_sock: &WindSock,
192    params: FastIntegrationParams,
193) -> FastSolution {
194    // Degenerate atmosphere -> non-physical air density would silently stub the run.
195    if !atmo_is_physical(params.atmo_params) {
196        return FastSolution::degenerate(&params.initial_state);
197    }
198    // Extract parameters
199    let _mass_kg = inputs.bullet_mass; // SI (kg)
200    let bc = inputs.bc_value;
201    let drag_model = &inputs.bc_type;
202
203    // Check for BC segments
204    let has_bc_segments =
205        inputs.bc_segments.is_some() && !inputs.bc_segments.as_ref().unwrap().is_empty();
206    let has_bc_segments_data =
207        inputs.bc_segments_data.is_some() && !inputs.bc_segments_data.as_ref().unwrap().is_empty();
208
209    // Time step - adjust based on distance
210    let dt = if params.horiz > 200.0 {
211        0.001
212    } else if params.horiz > 100.0 {
213        0.0005
214    } else {
215        0.0001
216    };
217
218    // Maximum integration time. This bounds BOTH the step-array pre-allocation (n_steps) AND the
219    // integration loop itself (the loop runs for at most n_steps-1 iterations); the
220    // hit_target / hit_ground early-breaks below terminate the loop sooner for real shots. Estimate
221    // the flight time from the HORIZONTAL velocity with a 4x margin: the previous 2x estimate using
222    // the FULL muzzle speed truncated long-range trajectories once drag slowed the bullet (real
223    // time of flight to the target far exceeds horiz/v0), so Monte Carlo reported impact metrics
224    // at a too-short downrange. NOTE: the 4x factor is a heuristic, NOT a proven upper bound — it
225    // can still be exceeded by extreme high-drag / high-launch-angle shots, which would truncate
226    // the loop before impact.
227    // MBA-959: aerodynamic jump perturbs the prebuilt launch velocity vertically (this path is
228    // handed an initial_state, not a muzzle angle). A no-op returning the original when disabled.
229    let mut initial_state = params.initial_state;
230    let aj_offset = aerodynamic_jump_launch_offset_rad(inputs, params.atmo_params);
231    if aj_offset != 0.0 {
232        rotate_launch_velocity(&mut initial_state, aj_offset);
233    }
234    let vx = initial_state[3]; // horizontal (downrange) velocity
235    let t_max = if vx > 1e-6 && params.horiz > 0.0 {
236        (4.0 * params.horiz / vx).min(params.t_span.1)
237    } else {
238        params.t_span.1
239    };
240
241    // Initialize arrays
242    let n_steps = ((t_max / dt) as usize) + 1;
243    let mut times = Vec::with_capacity(n_steps);
244    let mut states = Vec::with_capacity(n_steps);
245
246    // Initial state (with the aerodynamic-jump launch perturbation applied above)
247    times.push(0.0);
248    states.push(initial_state);
249
250    // Base drag density = the muzzle (shooter-altitude) density. atmo_params.3 is base_ratio
251    // = air_density/1.225 at the shooter altitude (the MC caller computes it via
252    // calculate_atmosphere). Previously this called get_local_atmosphere with query alt 0.0
253    // while base_alt = shooter altitude, which re-scaled that ratio DOWN to sea level —
254    // discarding the correct altitude density and inflating drag for every elevated MC run.
255    // Guard a missing/absent ratio (base_ratio <= 0, e.g. legacy or uninitialized atmo_params):
256    // fall back to the standard sea-level density rather than 0, so a zero ratio cannot collapse
257    // density_scale to 0 and silently disable drag entirely. (atmo_params.0 is base_alt here, not
258    // a density, so it is not a usable fallback.)
259    let base_density = if params.atmo_params.3 > 0.0 {
260        params.atmo_params.3 * 1.225
261    } else {
262        1.225 // standard sea-level air density (kg/m^3)
263    };
264
265    // Hoist invariants out of compute_derivatives (called 4x per step). Both the drag-model name
266    // and the projectile shape depend only on inputs, not on state/mach, so computing them per
267    // call wasted an allocation + heuristic every k1..k4. Mirrors cli_api.rs exactly.
268
269    // Drag-model name as a borrowed &'static str. DragModel's Display goes via Debug, which
270    // heap-allocates a String on every call; this match is bit-identical (Display == Debug ==
271    // variant name) with no per-step allocation.
272    let drag_model_str: &str = match drag_model {
273        DragModel::G1 => "G1",
274        DragModel::G2 => "G2",
275        DragModel::G5 => "G5",
276        DragModel::G6 => "G6",
277        DragModel::G7 => "G7",
278        DragModel::G8 => "G8",
279        DragModel::GI => "GI",
280        DragModel::GS => "GS",
281    };
282
283    // SI fallbacks for caliber/weight (SI-only MC callers may leave the imperial fields 0).
284    let caliber_in = if inputs.caliber_inches > 0.0 {
285        inputs.caliber_inches
286    } else {
287        inputs.bullet_diameter / 0.0254
288    };
289    let weight_gr = if inputs.weight_grains > 0.0 {
290        inputs.weight_grains
291    } else {
292        inputs.bullet_mass / 0.00006479891
293    };
294
295    // Projectile shape for transonic corrections (MBA-949: shared resolver — bullet_model name
296    // first, then the caliber/weight/drag-model heuristic).
297    let projectile_shape = crate::transonic_drag::resolve_projectile_shape(
298        inputs.bullet_model.as_deref(),
299        caliber_in,
300        weight_gr,
301        drag_model_str,
302    );
303
304    // Coriolis omega (Earth rotation), hoisted (invariant over the flight). MBA-957:
305    // fast_integrate — the Monte Carlo / Python-binding path — previously applied NO Coriolis.
306    // Mirror the validated cli_api solver exactly: McCoy frame X=downrange, Y=up, Z=lateral;
307    // azimuth 0 = North; omega.Z is NEGATIVE (Omega.East = -Omega cos(lat) sin(az)); applied as
308    // the physical -2 Omega x v inside compute_derivatives.
309    let omega_vector = if inputs.enable_coriolis && inputs.latitude.is_some() {
310        let omega_earth = 7.2921159e-5_f64; // rad/s
311        let lat = inputs.latitude.unwrap().to_radians();
312        let az = inputs.shot_azimuth; // compass bearing (0=N), NOT the aiming offset
313        Some(Vector3::new(
314            omega_earth * lat.cos() * az.cos(),  // X: downrange
315            omega_earth * lat.sin(),             // Y: vertical
316            -omega_earth * lat.cos() * az.sin(), // Z: lateral (corrected sign)
317        ))
318    } else {
319        None
320    };
321
322    // Integration loop
323    let mut hit_target = false;
324    let mut hit_ground = false;
325    let mut max_ord_time = None;
326    let mut max_ord_y = 0.0;
327    let ground_threshold = inputs.ground_threshold;
328
329    // RK4 integration
330    for i in 0..n_steps - 1 {
331        let t = i as f64 * dt;
332        let state = states[i];
333
334        let pos = Vector3::new(state[0], state[1], state[2]);
335        let _vel = Vector3::new(state[3], state[4], state[5]);
336
337        // Check termination conditions (X is downrange, McCoy)
338        if pos.x >= params.horiz {
339            hit_target = true;
340            times.push(t);
341            states.push(state);
342            break;
343        }
344
345        if pos.y <= ground_threshold {
346            hit_ground = true;
347            times.push(t);
348            states.push(state);
349            break;
350        }
351
352        // Track maximum ordinate
353        if pos.y > max_ord_y {
354            max_ord_y = pos.y;
355            max_ord_time = Some(t);
356        }
357
358        // RK4 step
359        let k1 = compute_derivatives(
360            &state,
361            inputs,
362            wind_sock,
363            base_density,
364            drag_model,
365            projectile_shape,
366            bc,
367            has_bc_segments,
368            has_bc_segments_data,
369            omega_vector,
370        );
371
372        let mut state2 = state;
373        for j in 0..6 {
374            state2[j] = state[j] + 0.5 * dt * k1[j];
375        }
376        let k2 = compute_derivatives(
377            &state2,
378            inputs,
379            wind_sock,
380            base_density,
381            drag_model,
382            projectile_shape,
383            bc,
384            has_bc_segments,
385            has_bc_segments_data,
386            omega_vector,
387        );
388
389        let mut state3 = state;
390        for j in 0..6 {
391            state3[j] = state[j] + 0.5 * dt * k2[j];
392        }
393        let k3 = compute_derivatives(
394            &state3,
395            inputs,
396            wind_sock,
397            base_density,
398            drag_model,
399            projectile_shape,
400            bc,
401            has_bc_segments,
402            has_bc_segments_data,
403            omega_vector,
404        );
405
406        let mut state4 = state;
407        for j in 0..6 {
408            state4[j] = state[j] + dt * k3[j];
409        }
410        let k4 = compute_derivatives(
411            &state4,
412            inputs,
413            wind_sock,
414            base_density,
415            drag_model,
416            projectile_shape,
417            bc,
418            has_bc_segments,
419            has_bc_segments_data,
420            omega_vector,
421        );
422
423        // Update state
424        let mut new_state = state;
425        for j in 0..6 {
426            new_state[j] = state[j] + dt * (k1[j] + 2.0 * k2[j] + 2.0 * k3[j] + k4[j]) / 6.0;
427        }
428
429        times.push(t + dt);
430        states.push(new_state);
431    }
432
433    // Create event arrays
434    let t_events = [
435        if hit_target {
436            vec![*times.last().unwrap()]
437        } else {
438            vec![]
439        },
440        if let Some(t) = max_ord_time {
441            vec![t]
442        } else {
443            vec![]
444        },
445        if hit_ground {
446            vec![*times.last().unwrap()]
447        } else {
448            vec![]
449        },
450    ];
451
452    FastSolution::from_trajectory_data(times, states, t_events)
453}
454
455/// Compute derivatives for the state vector
456fn compute_derivatives(
457    state: &[f64; 6],
458    inputs: &BallisticInputs,
459    wind_sock: &WindSock,
460    base_density: f64,
461    drag_model: &DragModel,
462    projectile_shape: crate::transonic_drag::ProjectileShape,
463    bc: f64,
464    has_bc_segments: bool,
465    has_bc_segments_data: bool,
466    omega: Option<Vector3<f64>>,
467) -> [f64; 6] {
468    let pos = Vector3::new(state[0], state[1], state[2]);
469    let vel = Vector3::new(state[3], state[4], state[5]);
470
471    // Get wind vector (based on downrange distance, which is X coordinate, McCoy)
472    let wind_vector = wind_sock.vector_for_range_stateless(pos.x);
473
474    // Velocity relative to air
475    let vel_adjusted = vel - wind_vector;
476    let v_mag = vel_adjusted.norm();
477
478    // Calculate acceleration
479    let mut accel = if v_mag < 1e-6 {
480        Vector3::new(0.0, -G_ACCEL_MPS2, 0.0)
481    } else {
482        // Calculate drag
483        let v_fps = v_mag * MPS_TO_FPS;
484
485        // Calculate speed of sound from altitude using standard lapse rate
486        // atmo_params: (base_alt, base_temp_c, base_press_hpa, base_ratio)
487        let altitude = inputs.altitude + pos.y;
488        let (_, speed_of_sound) = get_local_atmosphere(
489            altitude,
490            inputs.altitude, // base_alt approximation
491            inputs.temperature,
492            inputs.pressure,
493            if inputs.humidity > 0.0 { inputs.humidity } else { 1.0 },
494        );
495        let mach = v_mag / speed_of_sound;
496
497        // Get BC value (potentially from segments)
498        let bc_current = if has_bc_segments_data && inputs.bc_segments_data.is_some() {
499            get_bc_from_velocity_segments(v_fps, inputs.bc_segments_data.as_ref().unwrap())
500        } else if has_bc_segments && inputs.bc_segments.is_some() {
501            crate::derivatives::interpolated_bc(
502                mach,
503                inputs.bc_segments.as_ref().unwrap(),
504                Some(inputs),
505            )
506        } else {
507            bc
508        };
509        // Guard bc_value == 0 (allowed on FFI/WASM/public MC surfaces): the division below
510        // would be Inf -> NaN. Mirrors cli_api's effective_bc.max(1e-6); inert for valid BCs.
511        let bc_current = bc_current.max(1e-6);
512
513        // Apply the transonic drag-rise correction once (mirrors derivatives.rs / cli_api) so
514        // the Monte Carlo / fast path doesn't under-predict drag near Mach 1. The projectile
515        // shape is invariant for the whole integration, so it is hoisted into fast_integrate and
516        // passed in rather than recomputed per call. wave_drag=false: the G1/G7 tables already
517        // embed the rise.
518        // MBA-940: a user-supplied custom drag table is the final Cd, used as-is (no G-model
519        // lookup, transonic, or form-factor correction — the curve already encodes the true drag).
520        let drag_factor = if let Some(ref table) = inputs.custom_drag_table {
521            table.interpolate(mach)
522        } else {
523            let base_cd = get_drag_coefficient(mach, drag_model);
524            let cd =
525                crate::transonic_drag::transonic_correction(mach, base_cd, projectile_shape, false);
526            // MBA-948: honor use_form_factor in the fast path too (was derivatives.rs-only). No-op
527            // when the flag is false (apply_form_factor_to_drag short-circuits), as it is on every
528            // current consumer surface.
529            crate::form_factor::apply_form_factor_to_drag(
530                cd,
531                inputs.bullet_model.as_deref(),
532                &inputs.bc_type,
533                inputs.use_form_factor,
534            )
535        };
536
537        // Calculate drag acceleration using proper ballistics formula
538        let cd_to_retard = 0.000683 * 0.30;
539        let standard_factor = drag_factor * cd_to_retard;
540        let density_scale = base_density / 1.225;
541
542        // Drag acceleration in ft/s^2
543        let a_drag_ft_s2 = (v_fps * v_fps) * standard_factor * density_scale / bc_current;
544
545        // Convert to m/s^2 and apply to velocity vector
546        let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; // ft/s^2 to m/s^2
547        let accel_drag = -a_drag_m_s2 * (vel_adjusted / v_mag);
548
549        // Total acceleration
550        accel_drag + Vector3::new(0.0, -G_ACCEL_MPS2, 0.0)
551    };
552
553    // Coriolis (Earth rotation), MBA-957. omega already carries the corrected lateral sign; use
554    // the ground-frame velocity and the physical -2 Omega x v, exactly as the validated cli_api.
555    if let Some(omega) = omega {
556        accel += -2.0 * omega.cross(&vel);
557    }
558
559    // Return derivatives [vx, vy, vz, ax, ay, az]
560    [vel.x, vel.y, vel.z, accel.x, accel.y, accel.z]
561}
562
563/// Get BC from velocity-based segments
564fn get_bc_from_velocity_segments(velocity_fps: f64, segments: &[BCSegmentData]) -> f64 {
565    for segment in segments {
566        if velocity_fps >= segment.velocity_min && velocity_fps <= segment.velocity_max {
567            return segment.bc_value;
568        }
569    }
570
571    // If no matching segment, use the BC from the closest segment
572    if let Some(first) = segments.first() {
573        if velocity_fps < first.velocity_min {
574            return first.bc_value;
575        }
576    }
577
578    if let Some(last) = segments.last() {
579        if velocity_fps > last.velocity_max {
580            return last.bc_value;
581        }
582    }
583
584    // Fallback (shouldn't reach here if segments are properly defined)
585    0.5
586}
587
588/// Fast integration with explicit wind segments using RK45
589/// MBA-155: Upstreamed from ballistics_rust
590pub fn fast_integrate_with_segments(
591    inputs: &BallisticInputs,
592    wind_segments: Vec<crate::wind::WindSegment>,
593    params: FastIntegrationParams,
594) -> FastSolution {
595    // Use the RK45 implementation from trajectory_integration module
596    use crate::trajectory_integration::{integrate_trajectory, TrajectoryParams};
597
598    // Degenerate atmosphere -> non-physical air density would silently stub the run.
599    if !atmo_is_physical(params.atmo_params) {
600        return FastSolution::degenerate(&params.initial_state);
601    }
602
603    // Extract parameters
604    let mass_kg = inputs.bullet_mass; // SI (kg)
605    let bc = inputs.bc_value;
606    let drag_model = inputs.bc_type;
607
608    // Coriolis omega — gated on enable_coriolis (+ a latitude), INDEPENDENT of
609    // spin-drift/Magnus. A caller can now request Coriolis-only (enable_coriolis=true
610    // with enable_advanced_effects=false) instead of being forced to enable all three.
611    let omega_vector = if inputs.enable_coriolis && inputs.latitude.is_some() {
612        // Calculate omega based on latitude and shot azimuth
613        // The Earth's rotation vector must be projected into the shooter's
614        // local frame which depends on azimuth (shooting direction).
615        // azimuth_angle: 0 = North, pi/2 = East
616        let omega_earth = 7.2921159e-5; // rad/s
617        let lat_rad = inputs.latitude.unwrap_or(0.0).to_radians();
618        let azimuth = inputs.shot_azimuth; // compass bearing (0=N), NOT the aiming offset
619        Some(Vector3::new(
620            omega_earth * lat_rad.cos() * azimuth.cos(), // X: downrange component
621            omega_earth * lat_rad.sin(),                 // Y: vertical component
622            -omega_earth * lat_rad.cos() * azimuth.sin(), // Z: lateral (MBA-957: corrected sign)
623        ))
624    } else {
625        None
626    };
627
628    // Set up trajectory parameters
629    let traj_params = TrajectoryParams {
630        mass_kg,
631        bc,
632        drag_model,
633        wind_segments,
634        atmos_params: params.atmo_params,
635        omega_vector,
636        enable_spin_drift: inputs.enable_advanced_effects,
637        enable_magnus: inputs.enable_magnus,
638        enable_coriolis: inputs.enable_coriolis,
639        target_distance_m: params.horiz,
640        enable_wind_shear: inputs.enable_wind_shear,
641        wind_shear_model: inputs.wind_shear_model.clone(),
642        shooter_altitude_m: inputs.altitude,
643        is_twist_right: inputs.is_twist_right,
644        custom_drag_table: inputs.custom_drag_table.clone(),
645        bc_segments: inputs.bc_segments.clone(),
646        use_bc_segments: inputs.use_bc_segments,
647        // MBA-954: keep the historical -1000.0 here (behavior-preserving for this binding path);
648        // threading inputs.ground_threshold would change the default ground plane for existing
649        // callers. Direct TrajectoryParams constructors can now configure it.
650        ground_threshold: -1000.0,
651    };
652
653    // Use RK45 adaptive integration
654    let trajectory = integrate_trajectory(
655        params.initial_state,
656        params.t_span,
657        traj_params,
658        "RK45", // Use RK45 implementation
659        1e-6,   // tolerance
660        0.01,   // max_step
661    );
662
663    // Convert trajectory to FastSolution format
664    let n_points = trajectory.len();
665    let mut times = Vec::with_capacity(n_points);
666    let mut states = Vec::with_capacity(n_points);
667
668    let mut target_hit_time: Option<f64> = None;
669    let mut ground_hit_time: Option<f64> = None;
670    let mut max_ord_time = None;
671    let mut max_ord_y = 0.0;
672
673    for (t, state_vec) in trajectory {
674        // Convert Vector6 to array
675        let state = [
676            state_vec[0],
677            state_vec[1],
678            state_vec[2],
679            state_vec[3],
680            state_vec[4],
681            state_vec[5],
682        ];
683
684        // Check termination conditions
685        // McCoy: state[0]=downrange, state[1]=vertical, state[2]=lateral
686
687        // Record FIRST time target is hit
688        if target_hit_time.is_none() && state[0] >= params.horiz {
689            target_hit_time = Some(t);
690        }
691
692        // Record ground hit
693        if ground_hit_time.is_none() && state[1] <= inputs.ground_threshold {
694            ground_hit_time = Some(t);
695        }
696
697        // Track maximum ordinate
698        if state[1] > max_ord_y {
699            max_ord_y = state[1];
700            max_ord_time = Some(t);
701        }
702
703        times.push(t);
704        states.push(state);
705    }
706
707    // Create event arrays
708    let t_events = [
709        if let Some(t) = target_hit_time {
710            vec![t]
711        } else {
712            vec![]
713        },
714        if let Some(t) = max_ord_time {
715            vec![t]
716        } else {
717            vec![]
718        },
719        if let Some(t) = ground_hit_time {
720            vec![t]
721        } else {
722            vec![]
723        },
724    ];
725
726    FastSolution::from_trajectory_data(times, states, t_events)
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732
733    #[test]
734    fn test_fast_solution_interpolation() {
735        let times = vec![0.0, 1.0, 2.0];
736        let states = vec![
737            [0.0, 0.0, 0.0, 100.0, 50.0, 0.0],
738            [100.0, 45.0, 0.0, 99.0, 40.0, 0.0],
739            [198.0, 80.0, 0.0, 98.0, 30.0, 0.0],
740        ];
741
742        let solution = FastSolution::from_trajectory_data(times, states, [vec![], vec![], vec![]]);
743
744        // Test interpolation at t=1.5
745        let result = solution.sol(&[1.5]);
746
747        assert!((result[0][0] - 149.0).abs() < 1e-10); // x position
748        assert!((result[1][0] - 62.5).abs() < 1e-10); // y position
749        assert!((result[3][0] - 98.5).abs() < 1e-10); // vx velocity
750    }
751
752    #[test]
753    fn test_bc_from_velocity_segments() {
754        let segments = vec![
755            BCSegmentData {
756                velocity_min: 0.0,
757                velocity_max: 1000.0,
758                bc_value: 0.5,
759            },
760            BCSegmentData {
761                velocity_min: 1000.0,
762                velocity_max: 2000.0,
763                bc_value: 0.52,
764            },
765            BCSegmentData {
766                velocity_min: 2000.0,
767                velocity_max: 3000.0,
768                bc_value: 0.55,
769            },
770        ];
771
772        assert_eq!(get_bc_from_velocity_segments(500.0, &segments), 0.5);
773        assert_eq!(get_bc_from_velocity_segments(1500.0, &segments), 0.52);
774        assert_eq!(get_bc_from_velocity_segments(2500.0, &segments), 0.55);
775
776        // Test edge cases
777        assert_eq!(get_bc_from_velocity_segments(-100.0, &segments), 0.5); // Below min
778        assert_eq!(get_bc_from_velocity_segments(3500.0, &segments), 0.55); // Above max
779    }
780
781    #[test]
782    fn test_fast_solution_interpolation_edge_cases() {
783        let times = vec![0.0, 1.0, 2.0, 3.0];
784        let states = vec![
785            [0.0, 0.0, 0.0, 800.0, 50.0, 0.0],
786            [800.0, 40.0, 100.0, 750.0, 30.0, 0.0],
787            [1550.0, 60.0, 200.0, 700.0, 10.0, 0.0],
788            [2250.0, 50.0, 300.0, 650.0, -10.0, 0.0],
789        ];
790
791        let solution = FastSolution::from_trajectory_data(times, states, [vec![], vec![], vec![]]);
792
793        // Test interpolation before first point
794        let result_before = solution.sol(&[-0.5]);
795        assert!((result_before[0][0] - 0.0).abs() < 1e-10); // Should clamp to first
796
797        // Test interpolation after last point
798        let result_after = solution.sol(&[5.0]);
799        assert!((result_after[0][0] - 2250.0).abs() < 1e-10); // Should clamp to last
800
801        // Test interpolation at exact points
802        let result_exact = solution.sol(&[1.0]);
803        assert!((result_exact[0][0] - 800.0).abs() < 1e-10);
804
805        // Test multiple query points
806        let result_multi = solution.sol(&[0.5, 1.5, 2.5]);
807        assert_eq!(result_multi[0].len(), 3);
808    }
809
810    #[test]
811    fn test_fast_solution_from_trajectory_data() {
812        let times = vec![0.0, 0.5, 1.0];
813        let states = vec![
814            [0.0, 1.0, 2.0, 3.0, 4.0, 5.0],
815            [10.0, 11.0, 12.0, 13.0, 14.0, 15.0],
816            [20.0, 21.0, 22.0, 23.0, 24.0, 25.0],
817        ];
818        let t_events = [vec![1.0], vec![0.5], vec![]];
819
820        let solution = FastSolution::from_trajectory_data(times.clone(), states, t_events);
821
822        // Check that data is stored correctly
823        assert_eq!(solution.t, times);
824        assert_eq!(solution.y.len(), 6); // 6 state components
825        assert_eq!(solution.y[0].len(), 3); // 3 time points
826        assert!(solution.success);
827
828        // Verify column-major storage
829        assert_eq!(solution.y[0][0], 0.0); // x at t=0
830        assert_eq!(solution.y[1][0], 1.0); // y at t=0
831        assert_eq!(solution.y[0][2], 20.0); // x at t=1.0
832    }
833
834    #[test]
835    fn test_bc_segments_boundary_conditions() {
836        // Test with single segment
837        let single_segment = vec![BCSegmentData {
838            velocity_min: 1000.0,
839            velocity_max: 2000.0,
840            bc_value: 0.5,
841        }];
842
843        assert_eq!(get_bc_from_velocity_segments(500.0, &single_segment), 0.5); // Below
844        assert_eq!(get_bc_from_velocity_segments(1500.0, &single_segment), 0.5); // In range
845        assert_eq!(get_bc_from_velocity_segments(2500.0, &single_segment), 0.5); // Above
846
847        // Test with exact boundary values
848        // Note: When velocity matches boundary, first matching segment wins
849        let segments = vec![
850            BCSegmentData {
851                velocity_min: 0.0,
852                velocity_max: 999.0,  // Exclusive upper bound to avoid overlap
853                bc_value: 0.45,
854            },
855            BCSegmentData {
856                velocity_min: 1000.0,
857                velocity_max: 2000.0,
858                bc_value: 0.50,
859            },
860        ];
861
862        assert_eq!(get_bc_from_velocity_segments(1000.0, &segments), 0.50); // At second segment start
863        assert_eq!(get_bc_from_velocity_segments(0.0, &segments), 0.45); // At min
864        assert_eq!(get_bc_from_velocity_segments(999.0, &segments), 0.45); // At first segment max
865    }
866
867    #[test]
868    fn test_bc_segments_empty_fallback() {
869        let empty_segments: Vec<BCSegmentData> = vec![];
870
871        // With empty segments, should return fallback value
872        let result = get_bc_from_velocity_segments(1500.0, &empty_segments);
873        assert_eq!(result, 0.5); // Fallback value
874    }
875
876    #[test]
877    fn test_fast_integration_params() {
878        // Verify FastIntegrationParams struct can be constructed
879        let params = FastIntegrationParams {
880            horiz: 1000.0,
881            vert: 0.0,
882            initial_state: [0.0, 0.0, 0.0, 800.0, 50.0, 0.0], // McCoy: vx=downrange
883            t_span: (0.0, 5.0),
884            atmo_params: (0.0, 59.0, 29.92, 0.0),
885        };
886
887        assert_eq!(params.horiz, 1000.0);
888        assert_eq!(params.t_span.0, 0.0);
889        assert_eq!(params.t_span.1, 5.0);
890        assert_eq!(params.initial_state[3], 800.0); // vx (downrange, McCoy)
891    }
892
893    #[test]
894    fn test_fast_solution_event_arrays() {
895        let times = vec![0.0, 1.0, 2.0];
896        let states = vec![
897            [0.0, 0.0, 0.0, 800.0, 50.0, 0.0],
898            [800.0, 40.0, 500.0, 750.0, 30.0, 0.0],
899            [1500.0, 20.0, 1000.0, 700.0, 10.0, 0.0],
900        ];
901
902        // Create solution with events
903        let t_events = [
904            vec![2.0],  // target_hit at t=2
905            vec![0.5],  // max_ord at t=0.5
906            vec![],     // no ground_hit
907        ];
908
909        let solution = FastSolution::from_trajectory_data(times, states, t_events);
910
911        assert_eq!(solution.t_events[0], vec![2.0]); // Target hit
912        assert_eq!(solution.t_events[1], vec![0.5]); // Max ordinate
913        assert!(solution.t_events[2].is_empty()); // No ground hit
914    }
915
916    #[test]
917    fn fast_path_coriolis_uses_shot_direction() {
918        // Regression: fast_integrate_with_segments (the path the Python binding uses)
919        // built its Coriolis omega from azimuth_angle (the aiming offset, always ~0)
920        // instead of shot_azimuth, so east and west shots came out identical. After the
921        // fix they must differ with the correct Eotvos sign (east lifted, higher).
922        use std::f64::consts::FRAC_PI_2;
923        // Returns (final_downrange, final_vertical) for a shot fired along `shot_az`.
924        fn final_xy(shot_az: f64) -> (f64, f64) {
925            let mut inputs = BallisticInputs::default();
926            inputs.muzzle_velocity = 800.0;
927            inputs.bc_value = 0.5;
928            inputs.bc_type = DragModel::G7;
929            inputs.enable_advanced_effects = true; // gates the omega vector
930            inputs.enable_coriolis = true;
931            inputs.latitude = Some(45.0);
932            inputs.shot_azimuth = shot_az;
933            let v = 800.0_f64;
934            let elev = 0.02_f64;
935            let params = FastIntegrationParams {
936                horiz: 1000.0,
937                vert: 0.0,
938                initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
939                t_span: (0.0, 5.0),
940                atmo_params: (0.0, 59.0, 29.92, 0.0),
941            };
942            let sol = fast_integrate_with_segments(&inputs, vec![], params);
943            let n = sol.y[0].len();
944            (sol.y[0][n - 1], sol.y[1][n - 1])
945        }
946        let (ex, ey) = final_xy(FRAC_PI_2); // east
947        let (wx, wy) = final_xy(3.0 * FRAC_PI_2); // west
948        // Both shots cover essentially the same downrange (Coriolis barely affects x),
949        // so comparing the final vertical is apples-to-apples.
950        assert!(
951            (ex - wx).abs() < 0.5,
952            "east/west downrange should be ~equal (ex={ex:.4}, wx={wx:.4})"
953        );
954        // Pre-fix the fast path was North-locked, making these byte-identical. The Eotvos
955        // term now lifts the east shot above the west shot.
956        assert!(
957            ey > wy,
958            "fast-path east ({ey:.6}) must be higher than west ({wy:.6}) (Eotvos)"
959        );
960        assert!(
961            (ey - wy) > 1e-5,
962            "fast-path E-W vertical separation ({:.8} m) should be non-zero (the pre-fix bug was exact equality)",
963            ey - wy
964        );
965    }
966
967    #[test]
968    fn fast_path_coriolis_independent_of_advanced_effects() {
969        // Coriolis is now gated on enable_coriolis (+ latitude), NOT enable_advanced_effects.
970        // So a caller can request Coriolis-only without being forced to enable spin/Magnus.
971        use std::f64::consts::FRAC_PI_2;
972        fn final_y(coriolis: bool, shot_az: f64) -> f64 {
973            let mut inputs = BallisticInputs::default();
974            inputs.muzzle_velocity = 800.0;
975            inputs.bc_value = 0.5;
976            inputs.bc_type = DragModel::G7;
977            inputs.enable_coriolis = coriolis;
978            inputs.enable_advanced_effects = false; // explicitly OFF — Coriolis must still work
979            inputs.latitude = Some(45.0);
980            inputs.shot_azimuth = shot_az;
981            let v = 800.0_f64;
982            let elev = 0.02_f64;
983            let params = FastIntegrationParams {
984                horiz: 1000.0,
985                vert: 0.0,
986                initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
987                t_span: (0.0, 5.0),
988                atmo_params: (0.0, 59.0, 29.92, 0.0),
989            };
990            let sol = fast_integrate_with_segments(&inputs, vec![], params);
991            let n = sol.y[0].len();
992            sol.y[1][n - 1]
993        }
994        // enable_coriolis=true with advanced effects OFF: directional Coriolis still applies.
995        let e = final_y(true, FRAC_PI_2);
996        let w = final_y(true, 3.0 * FRAC_PI_2);
997        assert!(e > w && (e - w) > 1e-5, "Coriolis-only (no advanced effects) must still be directional: E={e} W={w}");
998        // enable_coriolis=false: no Coriolis at all, east == west.
999        let e2 = final_y(false, FRAC_PI_2);
1000        let w2 = final_y(false, 3.0 * FRAC_PI_2);
1001        assert!((e2 - w2).abs() < 1e-9, "with enable_coriolis=false, east/west must be identical: E={e2} W={w2}");
1002    }
1003
1004    #[test]
1005    fn fast_path_rejects_degenerate_atmosphere() {
1006        let mut inputs = BallisticInputs::default();
1007        inputs.muzzle_velocity = 800.0;
1008        inputs.bc_value = 0.5;
1009        inputs.bc_type = DragModel::G7;
1010        let v = 800.0_f64;
1011        let e = 0.02_f64;
1012        let mk = |atmo: (f64, f64, f64, f64)| FastIntegrationParams {
1013            horiz: 500.0,
1014            vert: 0.0,
1015            initial_state: [0.0, 0.0, 0.0, v * e.cos(), v * e.sin(), 0.0],
1016            t_span: (0.0, 5.0),
1017            atmo_params: atmo,
1018        };
1019        // pressure <= 0 -> fail loudly (success=false) instead of a 1-point stub as success.
1020        let zero_p = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 0.0, 50.0)));
1021        assert!(!zero_p.success, "pressure=0 atmosphere must yield success=false");
1022        // non-finite pressure -> also rejected.
1023        let nan_p = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, f64::NAN, 50.0)));
1024        assert!(!nan_p.success, "NaN pressure must yield success=false");
1025        // realistic atmosphere -> success.
1026        let good = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 1013.25, 50.0)));
1027        assert!(good.success, "realistic atmosphere must yield success=true");
1028    }
1029}
1030