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