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. Consult inputs.bullet_model first (matching
201    // cli_api's canonical choice), then fall back to the caliber/weight/drag-model heuristic.
202    let projectile_shape = if let Some(ref model) = inputs.bullet_model {
203        let m = model.to_lowercase();
204        if m.contains("boat") || m.contains("bt") {
205            crate::transonic_drag::ProjectileShape::BoatTail
206        } else if m.contains("round") || m.contains("rn") {
207            crate::transonic_drag::ProjectileShape::RoundNose
208        } else if m.contains("flat") || m.contains("fb") {
209            crate::transonic_drag::ProjectileShape::FlatBase
210        } else {
211            crate::transonic_drag::get_projectile_shape(caliber_in, weight_gr, drag_model_str)
212        }
213    } else {
214        crate::transonic_drag::get_projectile_shape(caliber_in, weight_gr, drag_model_str)
215    };
216
217    // Integration loop
218    let mut hit_target = false;
219    let mut hit_ground = false;
220    let mut max_ord_time = None;
221    let mut max_ord_y = 0.0;
222    let ground_threshold = inputs.ground_threshold;
223
224    // RK4 integration
225    for i in 0..n_steps - 1 {
226        let t = i as f64 * dt;
227        let state = states[i];
228
229        let pos = Vector3::new(state[0], state[1], state[2]);
230        let _vel = Vector3::new(state[3], state[4], state[5]);
231
232        // Check termination conditions (X is downrange, McCoy)
233        if pos.x >= params.horiz {
234            hit_target = true;
235            times.push(t);
236            states.push(state);
237            break;
238        }
239
240        if pos.y <= ground_threshold {
241            hit_ground = true;
242            times.push(t);
243            states.push(state);
244            break;
245        }
246
247        // Track maximum ordinate
248        if pos.y > max_ord_y {
249            max_ord_y = pos.y;
250            max_ord_time = Some(t);
251        }
252
253        // RK4 step
254        let k1 = compute_derivatives(
255            &state,
256            inputs,
257            wind_sock,
258            base_density,
259            drag_model,
260            projectile_shape,
261            bc,
262            has_bc_segments,
263            has_bc_segments_data,
264        );
265
266        let mut state2 = state;
267        for j in 0..6 {
268            state2[j] = state[j] + 0.5 * dt * k1[j];
269        }
270        let k2 = compute_derivatives(
271            &state2,
272            inputs,
273            wind_sock,
274            base_density,
275            drag_model,
276            projectile_shape,
277            bc,
278            has_bc_segments,
279            has_bc_segments_data,
280        );
281
282        let mut state3 = state;
283        for j in 0..6 {
284            state3[j] = state[j] + 0.5 * dt * k2[j];
285        }
286        let k3 = compute_derivatives(
287            &state3,
288            inputs,
289            wind_sock,
290            base_density,
291            drag_model,
292            projectile_shape,
293            bc,
294            has_bc_segments,
295            has_bc_segments_data,
296        );
297
298        let mut state4 = state;
299        for j in 0..6 {
300            state4[j] = state[j] + dt * k3[j];
301        }
302        let k4 = compute_derivatives(
303            &state4,
304            inputs,
305            wind_sock,
306            base_density,
307            drag_model,
308            projectile_shape,
309            bc,
310            has_bc_segments,
311            has_bc_segments_data,
312        );
313
314        // Update state
315        let mut new_state = state;
316        for j in 0..6 {
317            new_state[j] = state[j] + dt * (k1[j] + 2.0 * k2[j] + 2.0 * k3[j] + k4[j]) / 6.0;
318        }
319
320        times.push(t + dt);
321        states.push(new_state);
322    }
323
324    // Create event arrays
325    let t_events = [
326        if hit_target {
327            vec![*times.last().unwrap()]
328        } else {
329            vec![]
330        },
331        if let Some(t) = max_ord_time {
332            vec![t]
333        } else {
334            vec![]
335        },
336        if hit_ground {
337            vec![*times.last().unwrap()]
338        } else {
339            vec![]
340        },
341    ];
342
343    FastSolution::from_trajectory_data(times, states, t_events)
344}
345
346/// Compute derivatives for the state vector
347fn compute_derivatives(
348    state: &[f64; 6],
349    inputs: &BallisticInputs,
350    wind_sock: &WindSock,
351    base_density: f64,
352    drag_model: &DragModel,
353    projectile_shape: crate::transonic_drag::ProjectileShape,
354    bc: f64,
355    has_bc_segments: bool,
356    has_bc_segments_data: bool,
357) -> [f64; 6] {
358    let pos = Vector3::new(state[0], state[1], state[2]);
359    let vel = Vector3::new(state[3], state[4], state[5]);
360
361    // Get wind vector (based on downrange distance, which is X coordinate, McCoy)
362    let wind_vector = wind_sock.vector_for_range_stateless(pos.x);
363
364    // Velocity relative to air
365    let vel_adjusted = vel - wind_vector;
366    let v_mag = vel_adjusted.norm();
367
368    // Calculate acceleration
369    let accel = if v_mag < 1e-6 {
370        Vector3::new(0.0, -G_ACCEL_MPS2, 0.0)
371    } else {
372        // Calculate drag
373        let v_fps = v_mag * MPS_TO_FPS;
374
375        // Calculate speed of sound from altitude using standard lapse rate
376        // atmo_params: (base_alt, base_temp_c, base_press_hpa, base_ratio)
377        let altitude = inputs.altitude + pos.y;
378        let (_, speed_of_sound) = get_local_atmosphere(
379            altitude,
380            inputs.altitude, // base_alt approximation
381            inputs.temperature,
382            inputs.pressure,
383            if inputs.humidity > 0.0 { inputs.humidity } else { 1.0 },
384        );
385        let mach = v_mag / speed_of_sound;
386
387        // Get BC value (potentially from segments)
388        let bc_current = if has_bc_segments_data && inputs.bc_segments_data.is_some() {
389            get_bc_from_velocity_segments(v_fps, inputs.bc_segments_data.as_ref().unwrap())
390        } else if has_bc_segments && inputs.bc_segments.is_some() {
391            crate::derivatives::interpolated_bc(
392                mach,
393                inputs.bc_segments.as_ref().unwrap(),
394                Some(inputs),
395            )
396        } else {
397            bc
398        };
399        // Guard bc_value == 0 (allowed on FFI/WASM/public MC surfaces): the division below
400        // would be Inf -> NaN. Mirrors cli_api's effective_bc.max(1e-6); inert for valid BCs.
401        let bc_current = bc_current.max(1e-6);
402
403        // Apply the transonic drag-rise correction once (mirrors derivatives.rs / cli_api) so
404        // the Monte Carlo / fast path doesn't under-predict drag near Mach 1. The projectile
405        // shape is invariant for the whole integration, so it is hoisted into fast_integrate and
406        // passed in rather than recomputed per call. wave_drag=false: the G1/G7 tables already
407        // embed the rise.
408        let base_cd = get_drag_coefficient(mach, drag_model);
409        let drag_factor =
410            crate::transonic_drag::transonic_correction(mach, base_cd, projectile_shape, false);
411
412        // Calculate drag acceleration using proper ballistics formula
413        let cd_to_retard = 0.000683 * 0.30;
414        let standard_factor = drag_factor * cd_to_retard;
415        let density_scale = base_density / 1.225;
416
417        // Drag acceleration in ft/s^2
418        let a_drag_ft_s2 = (v_fps * v_fps) * standard_factor * density_scale / bc_current;
419
420        // Convert to m/s^2 and apply to velocity vector
421        let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; // ft/s^2 to m/s^2
422        let accel_drag = -a_drag_m_s2 * (vel_adjusted / v_mag);
423
424        // Total acceleration
425        accel_drag + Vector3::new(0.0, -G_ACCEL_MPS2, 0.0)
426    };
427
428    // Return derivatives [vx, vy, vz, ax, ay, az]
429    [vel.x, vel.y, vel.z, accel.x, accel.y, accel.z]
430}
431
432/// Get BC from velocity-based segments
433fn get_bc_from_velocity_segments(velocity_fps: f64, segments: &[BCSegmentData]) -> f64 {
434    for segment in segments {
435        if velocity_fps >= segment.velocity_min && velocity_fps <= segment.velocity_max {
436            return segment.bc_value;
437        }
438    }
439
440    // If no matching segment, use the BC from the closest segment
441    if let Some(first) = segments.first() {
442        if velocity_fps < first.velocity_min {
443            return first.bc_value;
444        }
445    }
446
447    if let Some(last) = segments.last() {
448        if velocity_fps > last.velocity_max {
449            return last.bc_value;
450        }
451    }
452
453    // Fallback (shouldn't reach here if segments are properly defined)
454    0.5
455}
456
457/// Fast integration with explicit wind segments using RK45
458/// MBA-155: Upstreamed from ballistics_rust
459pub fn fast_integrate_with_segments(
460    inputs: &BallisticInputs,
461    wind_segments: Vec<crate::wind::WindSegment>,
462    params: FastIntegrationParams,
463) -> FastSolution {
464    // Use the RK45 implementation from trajectory_integration module
465    use crate::trajectory_integration::{integrate_trajectory, TrajectoryParams};
466
467    // Extract parameters
468    let mass_kg = inputs.bullet_mass; // SI (kg)
469    let bc = inputs.bc_value;
470    let drag_model = inputs.bc_type;
471
472    // Get omega vector if advanced effects enabled
473    let omega_vector = if inputs.enable_advanced_effects {
474        // Calculate omega based on latitude and shot azimuth
475        // The Earth's rotation vector must be projected into the shooter's
476        // local frame which depends on azimuth (shooting direction).
477        // azimuth_angle: 0 = North, pi/2 = East
478        let omega_earth = 7.2921159e-5; // rad/s
479        let lat_rad = inputs.latitude.unwrap_or(0.0).to_radians();
480        let azimuth = inputs.azimuth_angle; // already in radians
481        Some(Vector3::new(
482            omega_earth * lat_rad.cos() * azimuth.cos(), // X: downrange component
483            omega_earth * lat_rad.sin(),                 // Y: vertical component
484            omega_earth * lat_rad.cos() * azimuth.sin(), // Z: lateral component
485        ))
486    } else {
487        None
488    };
489
490    // Set up trajectory parameters
491    let traj_params = TrajectoryParams {
492        mass_kg,
493        bc,
494        drag_model,
495        wind_segments,
496        atmos_params: params.atmo_params,
497        omega_vector,
498        enable_spin_drift: inputs.enable_advanced_effects,
499        enable_magnus: inputs.enable_advanced_effects,
500        enable_coriolis: inputs.enable_advanced_effects,
501        target_distance_m: params.horiz,
502        enable_wind_shear: inputs.enable_wind_shear,
503        wind_shear_model: inputs.wind_shear_model.clone(),
504        shooter_altitude_m: inputs.altitude,
505        is_twist_right: inputs.is_twist_right,
506        custom_drag_table: inputs.custom_drag_table.clone(),
507        bc_segments: inputs.bc_segments.clone(),
508        use_bc_segments: inputs.use_bc_segments,
509    };
510
511    // Use RK45 adaptive integration
512    let trajectory = integrate_trajectory(
513        params.initial_state,
514        params.t_span,
515        traj_params,
516        "RK45", // Use RK45 implementation
517        1e-6,   // tolerance
518        0.01,   // max_step
519    );
520
521    // Convert trajectory to FastSolution format
522    let n_points = trajectory.len();
523    let mut times = Vec::with_capacity(n_points);
524    let mut states = Vec::with_capacity(n_points);
525
526    let mut target_hit_time: Option<f64> = None;
527    let mut ground_hit_time: Option<f64> = None;
528    let mut max_ord_time = None;
529    let mut max_ord_y = 0.0;
530
531    for (t, state_vec) in trajectory {
532        // Convert Vector6 to array
533        let state = [
534            state_vec[0],
535            state_vec[1],
536            state_vec[2],
537            state_vec[3],
538            state_vec[4],
539            state_vec[5],
540        ];
541
542        // Check termination conditions
543        // McCoy: state[0]=downrange, state[1]=vertical, state[2]=lateral
544
545        // Record FIRST time target is hit
546        if target_hit_time.is_none() && state[0] >= params.horiz {
547            target_hit_time = Some(t);
548        }
549
550        // Record ground hit
551        if ground_hit_time.is_none() && state[1] <= inputs.ground_threshold {
552            ground_hit_time = Some(t);
553        }
554
555        // Track maximum ordinate
556        if state[1] > max_ord_y {
557            max_ord_y = state[1];
558            max_ord_time = Some(t);
559        }
560
561        times.push(t);
562        states.push(state);
563    }
564
565    // Create event arrays
566    let t_events = [
567        if let Some(t) = target_hit_time {
568            vec![t]
569        } else {
570            vec![]
571        },
572        if let Some(t) = max_ord_time {
573            vec![t]
574        } else {
575            vec![]
576        },
577        if let Some(t) = ground_hit_time {
578            vec![t]
579        } else {
580            vec![]
581        },
582    ];
583
584    FastSolution::from_trajectory_data(times, states, t_events)
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590
591    #[test]
592    fn test_fast_solution_interpolation() {
593        let times = vec![0.0, 1.0, 2.0];
594        let states = vec![
595            [0.0, 0.0, 0.0, 100.0, 50.0, 0.0],
596            [100.0, 45.0, 0.0, 99.0, 40.0, 0.0],
597            [198.0, 80.0, 0.0, 98.0, 30.0, 0.0],
598        ];
599
600        let solution = FastSolution::from_trajectory_data(times, states, [vec![], vec![], vec![]]);
601
602        // Test interpolation at t=1.5
603        let result = solution.sol(&[1.5]);
604
605        assert!((result[0][0] - 149.0).abs() < 1e-10); // x position
606        assert!((result[1][0] - 62.5).abs() < 1e-10); // y position
607        assert!((result[3][0] - 98.5).abs() < 1e-10); // vx velocity
608    }
609
610    #[test]
611    fn test_bc_from_velocity_segments() {
612        let segments = vec![
613            BCSegmentData {
614                velocity_min: 0.0,
615                velocity_max: 1000.0,
616                bc_value: 0.5,
617            },
618            BCSegmentData {
619                velocity_min: 1000.0,
620                velocity_max: 2000.0,
621                bc_value: 0.52,
622            },
623            BCSegmentData {
624                velocity_min: 2000.0,
625                velocity_max: 3000.0,
626                bc_value: 0.55,
627            },
628        ];
629
630        assert_eq!(get_bc_from_velocity_segments(500.0, &segments), 0.5);
631        assert_eq!(get_bc_from_velocity_segments(1500.0, &segments), 0.52);
632        assert_eq!(get_bc_from_velocity_segments(2500.0, &segments), 0.55);
633
634        // Test edge cases
635        assert_eq!(get_bc_from_velocity_segments(-100.0, &segments), 0.5); // Below min
636        assert_eq!(get_bc_from_velocity_segments(3500.0, &segments), 0.55); // Above max
637    }
638
639    #[test]
640    fn test_fast_solution_interpolation_edge_cases() {
641        let times = vec![0.0, 1.0, 2.0, 3.0];
642        let states = vec![
643            [0.0, 0.0, 0.0, 800.0, 50.0, 0.0],
644            [800.0, 40.0, 100.0, 750.0, 30.0, 0.0],
645            [1550.0, 60.0, 200.0, 700.0, 10.0, 0.0],
646            [2250.0, 50.0, 300.0, 650.0, -10.0, 0.0],
647        ];
648
649        let solution = FastSolution::from_trajectory_data(times, states, [vec![], vec![], vec![]]);
650
651        // Test interpolation before first point
652        let result_before = solution.sol(&[-0.5]);
653        assert!((result_before[0][0] - 0.0).abs() < 1e-10); // Should clamp to first
654
655        // Test interpolation after last point
656        let result_after = solution.sol(&[5.0]);
657        assert!((result_after[0][0] - 2250.0).abs() < 1e-10); // Should clamp to last
658
659        // Test interpolation at exact points
660        let result_exact = solution.sol(&[1.0]);
661        assert!((result_exact[0][0] - 800.0).abs() < 1e-10);
662
663        // Test multiple query points
664        let result_multi = solution.sol(&[0.5, 1.5, 2.5]);
665        assert_eq!(result_multi[0].len(), 3);
666    }
667
668    #[test]
669    fn test_fast_solution_from_trajectory_data() {
670        let times = vec![0.0, 0.5, 1.0];
671        let states = vec![
672            [0.0, 1.0, 2.0, 3.0, 4.0, 5.0],
673            [10.0, 11.0, 12.0, 13.0, 14.0, 15.0],
674            [20.0, 21.0, 22.0, 23.0, 24.0, 25.0],
675        ];
676        let t_events = [vec![1.0], vec![0.5], vec![]];
677
678        let solution = FastSolution::from_trajectory_data(times.clone(), states, t_events);
679
680        // Check that data is stored correctly
681        assert_eq!(solution.t, times);
682        assert_eq!(solution.y.len(), 6); // 6 state components
683        assert_eq!(solution.y[0].len(), 3); // 3 time points
684        assert!(solution.success);
685
686        // Verify column-major storage
687        assert_eq!(solution.y[0][0], 0.0); // x at t=0
688        assert_eq!(solution.y[1][0], 1.0); // y at t=0
689        assert_eq!(solution.y[0][2], 20.0); // x at t=1.0
690    }
691
692    #[test]
693    fn test_bc_segments_boundary_conditions() {
694        // Test with single segment
695        let single_segment = vec![BCSegmentData {
696            velocity_min: 1000.0,
697            velocity_max: 2000.0,
698            bc_value: 0.5,
699        }];
700
701        assert_eq!(get_bc_from_velocity_segments(500.0, &single_segment), 0.5); // Below
702        assert_eq!(get_bc_from_velocity_segments(1500.0, &single_segment), 0.5); // In range
703        assert_eq!(get_bc_from_velocity_segments(2500.0, &single_segment), 0.5); // Above
704
705        // Test with exact boundary values
706        // Note: When velocity matches boundary, first matching segment wins
707        let segments = vec![
708            BCSegmentData {
709                velocity_min: 0.0,
710                velocity_max: 999.0,  // Exclusive upper bound to avoid overlap
711                bc_value: 0.45,
712            },
713            BCSegmentData {
714                velocity_min: 1000.0,
715                velocity_max: 2000.0,
716                bc_value: 0.50,
717            },
718        ];
719
720        assert_eq!(get_bc_from_velocity_segments(1000.0, &segments), 0.50); // At second segment start
721        assert_eq!(get_bc_from_velocity_segments(0.0, &segments), 0.45); // At min
722        assert_eq!(get_bc_from_velocity_segments(999.0, &segments), 0.45); // At first segment max
723    }
724
725    #[test]
726    fn test_bc_segments_empty_fallback() {
727        let empty_segments: Vec<BCSegmentData> = vec![];
728
729        // With empty segments, should return fallback value
730        let result = get_bc_from_velocity_segments(1500.0, &empty_segments);
731        assert_eq!(result, 0.5); // Fallback value
732    }
733
734    #[test]
735    fn test_fast_integration_params() {
736        // Verify FastIntegrationParams struct can be constructed
737        let params = FastIntegrationParams {
738            horiz: 1000.0,
739            vert: 0.0,
740            initial_state: [0.0, 0.0, 0.0, 800.0, 50.0, 0.0], // McCoy: vx=downrange
741            t_span: (0.0, 5.0),
742            atmo_params: (0.0, 59.0, 29.92, 0.0),
743        };
744
745        assert_eq!(params.horiz, 1000.0);
746        assert_eq!(params.t_span.0, 0.0);
747        assert_eq!(params.t_span.1, 5.0);
748        assert_eq!(params.initial_state[3], 800.0); // vx (downrange, McCoy)
749    }
750
751    #[test]
752    fn test_fast_solution_event_arrays() {
753        let times = vec![0.0, 1.0, 2.0];
754        let states = vec![
755            [0.0, 0.0, 0.0, 800.0, 50.0, 0.0],
756            [800.0, 40.0, 500.0, 750.0, 30.0, 0.0],
757            [1500.0, 20.0, 1000.0, 700.0, 10.0, 0.0],
758        ];
759
760        // Create solution with events
761        let t_events = [
762            vec![2.0],  // target_hit at t=2
763            vec![0.5],  // max_ord at t=0.5
764            vec![],     // no ground_hit
765        ];
766
767        let solution = FastSolution::from_trajectory_data(times, states, t_events);
768
769        assert_eq!(solution.t_events[0], vec![2.0]); // Target hit
770        assert_eq!(solution.t_events[1], vec![0.5]); // Max ordinate
771        assert!(solution.t_events[2].is_empty()); // No ground hit
772    }
773}