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
95/// Fast trajectory integration parameters
96pub struct FastIntegrationParams {
97    pub horiz: f64,
98    pub vert: f64,
99    pub initial_state: [f64; 6],
100    pub t_span: (f64, f64),
101    pub atmo_params: (f64, f64, f64, f64),
102}
103
104/// Aerodynamic-jump vertical launch-angle offset (radians) for the fast-integrate path.
105///
106/// Bryan Litz's crosswind estimator (`Y = 0.01*Sg - 0.0024*L + 0.032` MOA/mph) fed by the
107/// engine's Miller Sg. The fast path receives a prebuilt initial velocity (no muzzle angle),
108/// so the caller rotates that velocity by this offset. Returns 0 when the feature is off or
109/// the inputs are degenerate. Crosswind is taken from `wind_speed`/`wind_angle`
110/// (BallisticInputs convention: 0 = headwind, +90deg = from the right). MBA-959, EXPERIMENTAL.
111pub fn aerodynamic_jump_launch_offset_rad(
112    inputs: &BallisticInputs,
113    atmo_params: (f64, f64, f64, f64),
114) -> f64 {
115    if !inputs.enable_aerodynamic_jump {
116        return 0.0;
117    }
118    let diameter = inputs.bullet_diameter;
119    if !(inputs.twist_rate.is_finite() && inputs.twist_rate != 0.0)
120        || !(diameter.is_finite() && diameter > 0.0)
121        || !(inputs.bullet_length.is_finite() && inputs.bullet_length > 0.0)
122        || !inputs.muzzle_velocity.is_finite()
123    {
124        return 0.0;
125    }
126    let sg = crate::stability::compute_stability_coefficient(inputs, atmo_params);
127    if !(sg.is_finite() && sg > 0.0) {
128        return 0.0;
129    }
130    let length_cal = inputs.bullet_length / diameter;
131    const MS_TO_MPH: f64 = 2.236_936_292_054_4;
132    let crosswind_from_right_mph = inputs.wind_speed * inputs.wind_angle.sin() * MS_TO_MPH;
133    let vertical_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
134        sg,
135        length_cal,
136        crosswind_from_right_mph,
137        inputs.is_twist_right,
138    );
139    if !vertical_moa.is_finite() {
140        return 0.0;
141    }
142    const MOA_PER_RAD: f64 = 3437.7467707849;
143    vertical_moa / MOA_PER_RAD
144}
145
146/// Rotate a McCoy-frame state's velocity (indices 3..6) by `theta_rad` in the vertical
147/// (downrange–vertical) plane, preserving speed and horizontal heading. Positive = up.
148fn rotate_launch_velocity(state: &mut [f64; 6], theta_rad: f64) {
149    let (vx, vy, vz) = (state[3], state[4], state[5]);
150    let speed = (vx * vx + vy * vy + vz * vz).sqrt();
151    if speed <= 0.0 {
152        return;
153    }
154    let h = (vx * vx + vz * vz).sqrt(); // horizontal (downrange+lateral) speed
155    let new_elev = vy.atan2(h) + theta_rad;
156    state[4] = speed * new_elev.sin();
157    let new_h = speed * new_elev.cos();
158    let scale = if h > 1e-12 { new_h / h } else { 0.0 };
159    state[3] = vx * scale;
160    state[5] = vz * scale;
161}
162
163/// Fast fixed-step integration for longer trajectories
164pub fn fast_integrate(
165    inputs: &BallisticInputs,
166    wind_sock: &WindSock,
167    params: FastIntegrationParams,
168) -> FastSolution {
169    // Extract parameters
170    let _mass_kg = inputs.bullet_mass; // SI (kg)
171    let bc = inputs.bc_value;
172    let drag_model = &inputs.bc_type;
173
174    // Check for BC segments
175    let has_bc_segments =
176        inputs.bc_segments.is_some() && !inputs.bc_segments.as_ref().unwrap().is_empty();
177    let has_bc_segments_data =
178        inputs.bc_segments_data.is_some() && !inputs.bc_segments_data.as_ref().unwrap().is_empty();
179
180    // Time step - adjust based on distance
181    let dt = if params.horiz > 200.0 {
182        0.001
183    } else if params.horiz > 100.0 {
184        0.0005
185    } else {
186        0.0001
187    };
188
189    // Maximum integration time. This bounds BOTH the step-array pre-allocation (n_steps) AND the
190    // integration loop itself (the loop runs for at most n_steps-1 iterations); the
191    // hit_target / hit_ground early-breaks below terminate the loop sooner for real shots. Estimate
192    // the flight time from the HORIZONTAL velocity with a 4x margin: the previous 2x estimate using
193    // the FULL muzzle speed truncated long-range trajectories once drag slowed the bullet (real
194    // time of flight to the target far exceeds horiz/v0), so Monte Carlo reported impact metrics
195    // at a too-short downrange. NOTE: the 4x factor is a heuristic, NOT a proven upper bound — it
196    // can still be exceeded by extreme high-drag / high-launch-angle shots, which would truncate
197    // the loop before impact.
198    // MBA-959: aerodynamic jump perturbs the prebuilt launch velocity vertically (this path is
199    // handed an initial_state, not a muzzle angle). A no-op returning the original when disabled.
200    let mut initial_state = params.initial_state;
201    let aj_offset = aerodynamic_jump_launch_offset_rad(inputs, params.atmo_params);
202    if aj_offset != 0.0 {
203        rotate_launch_velocity(&mut initial_state, aj_offset);
204    }
205    let vx = initial_state[3]; // horizontal (downrange) velocity
206    let t_max = if vx > 1e-6 && params.horiz > 0.0 {
207        (4.0 * params.horiz / vx).min(params.t_span.1)
208    } else {
209        params.t_span.1
210    };
211
212    // Initialize arrays
213    let n_steps = ((t_max / dt) as usize) + 1;
214    let mut times = Vec::with_capacity(n_steps);
215    let mut states = Vec::with_capacity(n_steps);
216
217    // Initial state (with the aerodynamic-jump launch perturbation applied above)
218    times.push(0.0);
219    states.push(initial_state);
220
221    // Base drag density = the muzzle (shooter-altitude) density. atmo_params.3 is base_ratio
222    // = air_density/1.225 at the shooter altitude (the MC caller computes it via
223    // calculate_atmosphere). Previously this called get_local_atmosphere with query alt 0.0
224    // while base_alt = shooter altitude, which re-scaled that ratio DOWN to sea level —
225    // discarding the correct altitude density and inflating drag for every elevated MC run.
226    // Guard a missing/absent ratio (base_ratio <= 0, e.g. legacy or uninitialized atmo_params):
227    // fall back to the standard sea-level density rather than 0, so a zero ratio cannot collapse
228    // density_scale to 0 and silently disable drag entirely. (atmo_params.0 is base_alt here, not
229    // a density, so it is not a usable fallback.)
230    let base_density = if params.atmo_params.3 > 0.0 {
231        params.atmo_params.3 * 1.225
232    } else {
233        1.225 // standard sea-level air density (kg/m^3)
234    };
235
236    // Hoist invariants out of compute_derivatives (called 4x per step). Both the drag-model name
237    // and the projectile shape depend only on inputs, not on state/mach, so computing them per
238    // call wasted an allocation + heuristic every k1..k4. Mirrors cli_api.rs exactly.
239
240    // Drag-model name as a borrowed &'static str. DragModel's Display goes via Debug, which
241    // heap-allocates a String on every call; this match is bit-identical (Display == Debug ==
242    // variant name) with no per-step allocation.
243    let drag_model_str: &str = match drag_model {
244        DragModel::G1 => "G1",
245        DragModel::G2 => "G2",
246        DragModel::G5 => "G5",
247        DragModel::G6 => "G6",
248        DragModel::G7 => "G7",
249        DragModel::G8 => "G8",
250        DragModel::GI => "GI",
251        DragModel::GS => "GS",
252    };
253
254    // SI fallbacks for caliber/weight (SI-only MC callers may leave the imperial fields 0).
255    let caliber_in = if inputs.caliber_inches > 0.0 {
256        inputs.caliber_inches
257    } else {
258        inputs.bullet_diameter / 0.0254
259    };
260    let weight_gr = if inputs.weight_grains > 0.0 {
261        inputs.weight_grains
262    } else {
263        inputs.bullet_mass / 0.00006479891
264    };
265
266    // Projectile shape for transonic corrections (MBA-949: shared resolver — bullet_model name
267    // first, then the caliber/weight/drag-model heuristic).
268    let projectile_shape = crate::transonic_drag::resolve_projectile_shape(
269        inputs.bullet_model.as_deref(),
270        caliber_in,
271        weight_gr,
272        drag_model_str,
273    );
274
275    // Coriolis omega (Earth rotation), hoisted (invariant over the flight). MBA-957:
276    // fast_integrate — the Monte Carlo / Python-binding path — previously applied NO Coriolis.
277    // Mirror the validated cli_api solver exactly: McCoy frame X=downrange, Y=up, Z=lateral;
278    // azimuth 0 = North; omega.Z is NEGATIVE (Omega.East = -Omega cos(lat) sin(az)); applied as
279    // the physical -2 Omega x v inside compute_derivatives.
280    let omega_vector = if inputs.enable_coriolis && inputs.latitude.is_some() {
281        let omega_earth = 7.2921159e-5_f64; // rad/s
282        let lat = inputs.latitude.unwrap().to_radians();
283        let az = inputs.azimuth_angle; // radians, 0 = North
284        Some(Vector3::new(
285            omega_earth * lat.cos() * az.cos(),  // X: downrange
286            omega_earth * lat.sin(),             // Y: vertical
287            -omega_earth * lat.cos() * az.sin(), // Z: lateral (corrected sign)
288        ))
289    } else {
290        None
291    };
292
293    // Integration loop
294    let mut hit_target = false;
295    let mut hit_ground = false;
296    let mut max_ord_time = None;
297    let mut max_ord_y = 0.0;
298    let ground_threshold = inputs.ground_threshold;
299
300    // RK4 integration
301    for i in 0..n_steps - 1 {
302        let t = i as f64 * dt;
303        let state = states[i];
304
305        let pos = Vector3::new(state[0], state[1], state[2]);
306        let _vel = Vector3::new(state[3], state[4], state[5]);
307
308        // Check termination conditions (X is downrange, McCoy)
309        if pos.x >= params.horiz {
310            hit_target = true;
311            times.push(t);
312            states.push(state);
313            break;
314        }
315
316        if pos.y <= ground_threshold {
317            hit_ground = true;
318            times.push(t);
319            states.push(state);
320            break;
321        }
322
323        // Track maximum ordinate
324        if pos.y > max_ord_y {
325            max_ord_y = pos.y;
326            max_ord_time = Some(t);
327        }
328
329        // RK4 step
330        let k1 = compute_derivatives(
331            &state,
332            inputs,
333            wind_sock,
334            base_density,
335            drag_model,
336            projectile_shape,
337            bc,
338            has_bc_segments,
339            has_bc_segments_data,
340            omega_vector,
341        );
342
343        let mut state2 = state;
344        for j in 0..6 {
345            state2[j] = state[j] + 0.5 * dt * k1[j];
346        }
347        let k2 = compute_derivatives(
348            &state2,
349            inputs,
350            wind_sock,
351            base_density,
352            drag_model,
353            projectile_shape,
354            bc,
355            has_bc_segments,
356            has_bc_segments_data,
357            omega_vector,
358        );
359
360        let mut state3 = state;
361        for j in 0..6 {
362            state3[j] = state[j] + 0.5 * dt * k2[j];
363        }
364        let k3 = compute_derivatives(
365            &state3,
366            inputs,
367            wind_sock,
368            base_density,
369            drag_model,
370            projectile_shape,
371            bc,
372            has_bc_segments,
373            has_bc_segments_data,
374            omega_vector,
375        );
376
377        let mut state4 = state;
378        for j in 0..6 {
379            state4[j] = state[j] + dt * k3[j];
380        }
381        let k4 = compute_derivatives(
382            &state4,
383            inputs,
384            wind_sock,
385            base_density,
386            drag_model,
387            projectile_shape,
388            bc,
389            has_bc_segments,
390            has_bc_segments_data,
391            omega_vector,
392        );
393
394        // Update state
395        let mut new_state = state;
396        for j in 0..6 {
397            new_state[j] = state[j] + dt * (k1[j] + 2.0 * k2[j] + 2.0 * k3[j] + k4[j]) / 6.0;
398        }
399
400        times.push(t + dt);
401        states.push(new_state);
402    }
403
404    // Create event arrays
405    let t_events = [
406        if hit_target {
407            vec![*times.last().unwrap()]
408        } else {
409            vec![]
410        },
411        if let Some(t) = max_ord_time {
412            vec![t]
413        } else {
414            vec![]
415        },
416        if hit_ground {
417            vec![*times.last().unwrap()]
418        } else {
419            vec![]
420        },
421    ];
422
423    FastSolution::from_trajectory_data(times, states, t_events)
424}
425
426/// Compute derivatives for the state vector
427fn compute_derivatives(
428    state: &[f64; 6],
429    inputs: &BallisticInputs,
430    wind_sock: &WindSock,
431    base_density: f64,
432    drag_model: &DragModel,
433    projectile_shape: crate::transonic_drag::ProjectileShape,
434    bc: f64,
435    has_bc_segments: bool,
436    has_bc_segments_data: bool,
437    omega: Option<Vector3<f64>>,
438) -> [f64; 6] {
439    let pos = Vector3::new(state[0], state[1], state[2]);
440    let vel = Vector3::new(state[3], state[4], state[5]);
441
442    // Get wind vector (based on downrange distance, which is X coordinate, McCoy)
443    let wind_vector = wind_sock.vector_for_range_stateless(pos.x);
444
445    // Velocity relative to air
446    let vel_adjusted = vel - wind_vector;
447    let v_mag = vel_adjusted.norm();
448
449    // Calculate acceleration
450    let mut accel = if v_mag < 1e-6 {
451        Vector3::new(0.0, -G_ACCEL_MPS2, 0.0)
452    } else {
453        // Calculate drag
454        let v_fps = v_mag * MPS_TO_FPS;
455
456        // Calculate speed of sound from altitude using standard lapse rate
457        // atmo_params: (base_alt, base_temp_c, base_press_hpa, base_ratio)
458        let altitude = inputs.altitude + pos.y;
459        let (_, speed_of_sound) = get_local_atmosphere(
460            altitude,
461            inputs.altitude, // base_alt approximation
462            inputs.temperature,
463            inputs.pressure,
464            if inputs.humidity > 0.0 { inputs.humidity } else { 1.0 },
465        );
466        let mach = v_mag / speed_of_sound;
467
468        // Get BC value (potentially from segments)
469        let bc_current = if has_bc_segments_data && inputs.bc_segments_data.is_some() {
470            get_bc_from_velocity_segments(v_fps, inputs.bc_segments_data.as_ref().unwrap())
471        } else if has_bc_segments && inputs.bc_segments.is_some() {
472            crate::derivatives::interpolated_bc(
473                mach,
474                inputs.bc_segments.as_ref().unwrap(),
475                Some(inputs),
476            )
477        } else {
478            bc
479        };
480        // Guard bc_value == 0 (allowed on FFI/WASM/public MC surfaces): the division below
481        // would be Inf -> NaN. Mirrors cli_api's effective_bc.max(1e-6); inert for valid BCs.
482        let bc_current = bc_current.max(1e-6);
483
484        // Apply the transonic drag-rise correction once (mirrors derivatives.rs / cli_api) so
485        // the Monte Carlo / fast path doesn't under-predict drag near Mach 1. The projectile
486        // shape is invariant for the whole integration, so it is hoisted into fast_integrate and
487        // passed in rather than recomputed per call. wave_drag=false: the G1/G7 tables already
488        // embed the rise.
489        // MBA-940: a user-supplied custom drag table is the final Cd, used as-is (no G-model
490        // lookup, transonic, or form-factor correction — the curve already encodes the true drag).
491        let drag_factor = if let Some(ref table) = inputs.custom_drag_table {
492            table.interpolate(mach)
493        } else {
494            let base_cd = get_drag_coefficient(mach, drag_model);
495            let cd =
496                crate::transonic_drag::transonic_correction(mach, base_cd, projectile_shape, false);
497            // MBA-948: honor use_form_factor in the fast path too (was derivatives.rs-only). No-op
498            // when the flag is false (apply_form_factor_to_drag short-circuits), as it is on every
499            // current consumer surface.
500            crate::form_factor::apply_form_factor_to_drag(
501                cd,
502                inputs.bullet_model.as_deref(),
503                &inputs.bc_type,
504                inputs.use_form_factor,
505            )
506        };
507
508        // Calculate drag acceleration using proper ballistics formula
509        let cd_to_retard = 0.000683 * 0.30;
510        let standard_factor = drag_factor * cd_to_retard;
511        let density_scale = base_density / 1.225;
512
513        // Drag acceleration in ft/s^2
514        let a_drag_ft_s2 = (v_fps * v_fps) * standard_factor * density_scale / bc_current;
515
516        // Convert to m/s^2 and apply to velocity vector
517        let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; // ft/s^2 to m/s^2
518        let accel_drag = -a_drag_m_s2 * (vel_adjusted / v_mag);
519
520        // Total acceleration
521        accel_drag + Vector3::new(0.0, -G_ACCEL_MPS2, 0.0)
522    };
523
524    // Coriolis (Earth rotation), MBA-957. omega already carries the corrected lateral sign; use
525    // the ground-frame velocity and the physical -2 Omega x v, exactly as the validated cli_api.
526    if let Some(omega) = omega {
527        accel += -2.0 * omega.cross(&vel);
528    }
529
530    // Return derivatives [vx, vy, vz, ax, ay, az]
531    [vel.x, vel.y, vel.z, accel.x, accel.y, accel.z]
532}
533
534/// Get BC from velocity-based segments
535fn get_bc_from_velocity_segments(velocity_fps: f64, segments: &[BCSegmentData]) -> f64 {
536    for segment in segments {
537        if velocity_fps >= segment.velocity_min && velocity_fps <= segment.velocity_max {
538            return segment.bc_value;
539        }
540    }
541
542    // If no matching segment, use the BC from the closest segment
543    if let Some(first) = segments.first() {
544        if velocity_fps < first.velocity_min {
545            return first.bc_value;
546        }
547    }
548
549    if let Some(last) = segments.last() {
550        if velocity_fps > last.velocity_max {
551            return last.bc_value;
552        }
553    }
554
555    // Fallback (shouldn't reach here if segments are properly defined)
556    0.5
557}
558
559/// Fast integration with explicit wind segments using RK45
560/// MBA-155: Upstreamed from ballistics_rust
561pub fn fast_integrate_with_segments(
562    inputs: &BallisticInputs,
563    wind_segments: Vec<crate::wind::WindSegment>,
564    params: FastIntegrationParams,
565) -> FastSolution {
566    // Use the RK45 implementation from trajectory_integration module
567    use crate::trajectory_integration::{integrate_trajectory, TrajectoryParams};
568
569    // Extract parameters
570    let mass_kg = inputs.bullet_mass; // SI (kg)
571    let bc = inputs.bc_value;
572    let drag_model = inputs.bc_type;
573
574    // Get omega vector if advanced effects enabled
575    let omega_vector = if inputs.enable_advanced_effects {
576        // Calculate omega based on latitude and shot azimuth
577        // The Earth's rotation vector must be projected into the shooter's
578        // local frame which depends on azimuth (shooting direction).
579        // azimuth_angle: 0 = North, pi/2 = East
580        let omega_earth = 7.2921159e-5; // rad/s
581        let lat_rad = inputs.latitude.unwrap_or(0.0).to_radians();
582        let azimuth = inputs.azimuth_angle; // already in radians
583        Some(Vector3::new(
584            omega_earth * lat_rad.cos() * azimuth.cos(), // X: downrange component
585            omega_earth * lat_rad.sin(),                 // Y: vertical component
586            -omega_earth * lat_rad.cos() * azimuth.sin(), // Z: lateral (MBA-957: corrected sign)
587        ))
588    } else {
589        None
590    };
591
592    // Set up trajectory parameters
593    let traj_params = TrajectoryParams {
594        mass_kg,
595        bc,
596        drag_model,
597        wind_segments,
598        atmos_params: params.atmo_params,
599        omega_vector,
600        enable_spin_drift: inputs.enable_advanced_effects,
601        enable_magnus: inputs.enable_advanced_effects,
602        enable_coriolis: inputs.enable_advanced_effects,
603        target_distance_m: params.horiz,
604        enable_wind_shear: inputs.enable_wind_shear,
605        wind_shear_model: inputs.wind_shear_model.clone(),
606        shooter_altitude_m: inputs.altitude,
607        is_twist_right: inputs.is_twist_right,
608        custom_drag_table: inputs.custom_drag_table.clone(),
609        bc_segments: inputs.bc_segments.clone(),
610        use_bc_segments: inputs.use_bc_segments,
611        // MBA-954: keep the historical -1000.0 here (behavior-preserving for this binding path);
612        // threading inputs.ground_threshold would change the default ground plane for existing
613        // callers. Direct TrajectoryParams constructors can now configure it.
614        ground_threshold: -1000.0,
615    };
616
617    // Use RK45 adaptive integration
618    let trajectory = integrate_trajectory(
619        params.initial_state,
620        params.t_span,
621        traj_params,
622        "RK45", // Use RK45 implementation
623        1e-6,   // tolerance
624        0.01,   // max_step
625    );
626
627    // Convert trajectory to FastSolution format
628    let n_points = trajectory.len();
629    let mut times = Vec::with_capacity(n_points);
630    let mut states = Vec::with_capacity(n_points);
631
632    let mut target_hit_time: Option<f64> = None;
633    let mut ground_hit_time: Option<f64> = None;
634    let mut max_ord_time = None;
635    let mut max_ord_y = 0.0;
636
637    for (t, state_vec) in trajectory {
638        // Convert Vector6 to array
639        let state = [
640            state_vec[0],
641            state_vec[1],
642            state_vec[2],
643            state_vec[3],
644            state_vec[4],
645            state_vec[5],
646        ];
647
648        // Check termination conditions
649        // McCoy: state[0]=downrange, state[1]=vertical, state[2]=lateral
650
651        // Record FIRST time target is hit
652        if target_hit_time.is_none() && state[0] >= params.horiz {
653            target_hit_time = Some(t);
654        }
655
656        // Record ground hit
657        if ground_hit_time.is_none() && state[1] <= inputs.ground_threshold {
658            ground_hit_time = Some(t);
659        }
660
661        // Track maximum ordinate
662        if state[1] > max_ord_y {
663            max_ord_y = state[1];
664            max_ord_time = Some(t);
665        }
666
667        times.push(t);
668        states.push(state);
669    }
670
671    // Create event arrays
672    let t_events = [
673        if let Some(t) = target_hit_time {
674            vec![t]
675        } else {
676            vec![]
677        },
678        if let Some(t) = max_ord_time {
679            vec![t]
680        } else {
681            vec![]
682        },
683        if let Some(t) = ground_hit_time {
684            vec![t]
685        } else {
686            vec![]
687        },
688    ];
689
690    FastSolution::from_trajectory_data(times, states, t_events)
691}
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696
697    #[test]
698    fn test_fast_solution_interpolation() {
699        let times = vec![0.0, 1.0, 2.0];
700        let states = vec![
701            [0.0, 0.0, 0.0, 100.0, 50.0, 0.0],
702            [100.0, 45.0, 0.0, 99.0, 40.0, 0.0],
703            [198.0, 80.0, 0.0, 98.0, 30.0, 0.0],
704        ];
705
706        let solution = FastSolution::from_trajectory_data(times, states, [vec![], vec![], vec![]]);
707
708        // Test interpolation at t=1.5
709        let result = solution.sol(&[1.5]);
710
711        assert!((result[0][0] - 149.0).abs() < 1e-10); // x position
712        assert!((result[1][0] - 62.5).abs() < 1e-10); // y position
713        assert!((result[3][0] - 98.5).abs() < 1e-10); // vx velocity
714    }
715
716    #[test]
717    fn test_bc_from_velocity_segments() {
718        let segments = vec![
719            BCSegmentData {
720                velocity_min: 0.0,
721                velocity_max: 1000.0,
722                bc_value: 0.5,
723            },
724            BCSegmentData {
725                velocity_min: 1000.0,
726                velocity_max: 2000.0,
727                bc_value: 0.52,
728            },
729            BCSegmentData {
730                velocity_min: 2000.0,
731                velocity_max: 3000.0,
732                bc_value: 0.55,
733            },
734        ];
735
736        assert_eq!(get_bc_from_velocity_segments(500.0, &segments), 0.5);
737        assert_eq!(get_bc_from_velocity_segments(1500.0, &segments), 0.52);
738        assert_eq!(get_bc_from_velocity_segments(2500.0, &segments), 0.55);
739
740        // Test edge cases
741        assert_eq!(get_bc_from_velocity_segments(-100.0, &segments), 0.5); // Below min
742        assert_eq!(get_bc_from_velocity_segments(3500.0, &segments), 0.55); // Above max
743    }
744
745    #[test]
746    fn test_fast_solution_interpolation_edge_cases() {
747        let times = vec![0.0, 1.0, 2.0, 3.0];
748        let states = vec![
749            [0.0, 0.0, 0.0, 800.0, 50.0, 0.0],
750            [800.0, 40.0, 100.0, 750.0, 30.0, 0.0],
751            [1550.0, 60.0, 200.0, 700.0, 10.0, 0.0],
752            [2250.0, 50.0, 300.0, 650.0, -10.0, 0.0],
753        ];
754
755        let solution = FastSolution::from_trajectory_data(times, states, [vec![], vec![], vec![]]);
756
757        // Test interpolation before first point
758        let result_before = solution.sol(&[-0.5]);
759        assert!((result_before[0][0] - 0.0).abs() < 1e-10); // Should clamp to first
760
761        // Test interpolation after last point
762        let result_after = solution.sol(&[5.0]);
763        assert!((result_after[0][0] - 2250.0).abs() < 1e-10); // Should clamp to last
764
765        // Test interpolation at exact points
766        let result_exact = solution.sol(&[1.0]);
767        assert!((result_exact[0][0] - 800.0).abs() < 1e-10);
768
769        // Test multiple query points
770        let result_multi = solution.sol(&[0.5, 1.5, 2.5]);
771        assert_eq!(result_multi[0].len(), 3);
772    }
773
774    #[test]
775    fn test_fast_solution_from_trajectory_data() {
776        let times = vec![0.0, 0.5, 1.0];
777        let states = vec![
778            [0.0, 1.0, 2.0, 3.0, 4.0, 5.0],
779            [10.0, 11.0, 12.0, 13.0, 14.0, 15.0],
780            [20.0, 21.0, 22.0, 23.0, 24.0, 25.0],
781        ];
782        let t_events = [vec![1.0], vec![0.5], vec![]];
783
784        let solution = FastSolution::from_trajectory_data(times.clone(), states, t_events);
785
786        // Check that data is stored correctly
787        assert_eq!(solution.t, times);
788        assert_eq!(solution.y.len(), 6); // 6 state components
789        assert_eq!(solution.y[0].len(), 3); // 3 time points
790        assert!(solution.success);
791
792        // Verify column-major storage
793        assert_eq!(solution.y[0][0], 0.0); // x at t=0
794        assert_eq!(solution.y[1][0], 1.0); // y at t=0
795        assert_eq!(solution.y[0][2], 20.0); // x at t=1.0
796    }
797
798    #[test]
799    fn test_bc_segments_boundary_conditions() {
800        // Test with single segment
801        let single_segment = vec![BCSegmentData {
802            velocity_min: 1000.0,
803            velocity_max: 2000.0,
804            bc_value: 0.5,
805        }];
806
807        assert_eq!(get_bc_from_velocity_segments(500.0, &single_segment), 0.5); // Below
808        assert_eq!(get_bc_from_velocity_segments(1500.0, &single_segment), 0.5); // In range
809        assert_eq!(get_bc_from_velocity_segments(2500.0, &single_segment), 0.5); // Above
810
811        // Test with exact boundary values
812        // Note: When velocity matches boundary, first matching segment wins
813        let segments = vec![
814            BCSegmentData {
815                velocity_min: 0.0,
816                velocity_max: 999.0,  // Exclusive upper bound to avoid overlap
817                bc_value: 0.45,
818            },
819            BCSegmentData {
820                velocity_min: 1000.0,
821                velocity_max: 2000.0,
822                bc_value: 0.50,
823            },
824        ];
825
826        assert_eq!(get_bc_from_velocity_segments(1000.0, &segments), 0.50); // At second segment start
827        assert_eq!(get_bc_from_velocity_segments(0.0, &segments), 0.45); // At min
828        assert_eq!(get_bc_from_velocity_segments(999.0, &segments), 0.45); // At first segment max
829    }
830
831    #[test]
832    fn test_bc_segments_empty_fallback() {
833        let empty_segments: Vec<BCSegmentData> = vec![];
834
835        // With empty segments, should return fallback value
836        let result = get_bc_from_velocity_segments(1500.0, &empty_segments);
837        assert_eq!(result, 0.5); // Fallback value
838    }
839
840    #[test]
841    fn test_fast_integration_params() {
842        // Verify FastIntegrationParams struct can be constructed
843        let params = FastIntegrationParams {
844            horiz: 1000.0,
845            vert: 0.0,
846            initial_state: [0.0, 0.0, 0.0, 800.0, 50.0, 0.0], // McCoy: vx=downrange
847            t_span: (0.0, 5.0),
848            atmo_params: (0.0, 59.0, 29.92, 0.0),
849        };
850
851        assert_eq!(params.horiz, 1000.0);
852        assert_eq!(params.t_span.0, 0.0);
853        assert_eq!(params.t_span.1, 5.0);
854        assert_eq!(params.initial_state[3], 800.0); // vx (downrange, McCoy)
855    }
856
857    #[test]
858    fn test_fast_solution_event_arrays() {
859        let times = vec![0.0, 1.0, 2.0];
860        let states = vec![
861            [0.0, 0.0, 0.0, 800.0, 50.0, 0.0],
862            [800.0, 40.0, 500.0, 750.0, 30.0, 0.0],
863            [1500.0, 20.0, 1000.0, 700.0, 10.0, 0.0],
864        ];
865
866        // Create solution with events
867        let t_events = [
868            vec![2.0],  // target_hit at t=2
869            vec![0.5],  // max_ord at t=0.5
870            vec![],     // no ground_hit
871        ];
872
873        let solution = FastSolution::from_trajectory_data(times, states, t_events);
874
875        assert_eq!(solution.t_events[0], vec![2.0]); // Target hit
876        assert_eq!(solution.t_events[1], vec![0.5]); // Max ordinate
877        assert!(solution.t_events[2].is_empty()); // No ground hit
878    }
879}