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::{calculate_air_density_cimp, get_local_atmosphere_humid, AtmoSock},
8    bc_estimation::velocity_segment_bc,
9    constants::{G_ACCEL_MPS2, MPS_TO_FPS, STANDARD_AIR_DENSITY},
10    drag::get_drag_coefficient,
11    wind::WindSock,
12    DragModel, InternalBallisticInputs as BallisticInputs,
13};
14use nalgebra::Vector3;
15
16/// Fast solution container matching Python implementation
17#[derive(Debug, Clone)]
18pub struct FastSolution {
19    /// Time points
20    pub t: Vec<f64>,
21    /// State vectors at each time point [6 x n_points]
22    pub y: Vec<Vec<f64>>,
23    /// Event times [target_hit, max_ord, ground_hit]
24    pub t_events: [Vec<f64>; 3],
25    /// Whether integration succeeded
26    pub success: bool,
27}
28
29impl FastSolution {
30    /// Interpolate solution at time t
31    pub fn sol(&self, t_query: &[f64]) -> Vec<Vec<f64>> {
32        let mut result = vec![vec![0.0; t_query.len()]; 6];
33
34        for (i, &tq) in t_query.iter().enumerate() {
35            // Find the right interval using binary search
36            // Use unwrap_or to safely handle NaN values by treating them as greater
37            let idx = match self
38                .t
39                .binary_search_by(|&t| t.partial_cmp(&tq).unwrap_or(std::cmp::Ordering::Greater))
40            {
41                Ok(idx) => idx,
42                Err(idx) => idx,
43            };
44
45            if idx == 0 {
46                // Before first point
47                for (result_component, source_component) in result.iter_mut().zip(&self.y) {
48                    result_component[i] = source_component[0];
49                }
50            } else if idx >= self.t.len() {
51                // After last point
52                for (result_component, source_component) in result.iter_mut().zip(&self.y) {
53                    result_component[i] = source_component[self.t.len() - 1];
54                }
55            } else {
56                // Linear interpolation
57                let t0 = self.t[idx - 1];
58                let t1 = self.t[idx];
59                let span = t1 - t0;
60
61                for (result_component, source_component) in result.iter_mut().zip(&self.y) {
62                    let y0 = source_component[idx - 1];
63                    let y1 = source_component[idx];
64                    result_component[i] = if span.abs() < f64::EPSILON {
65                        y1
66                    } else {
67                        let frac = (tq - t0) / span;
68                        y0 + frac * (y1 - y0)
69                    };
70                }
71            }
72        }
73
74        result
75    }
76
77    /// Convert from row-major to column-major format for compatibility
78    pub fn from_trajectory_data(
79        times: Vec<f64>,
80        states: Vec<[f64; 6]>,
81        t_events: [Vec<f64>; 3],
82    ) -> Self {
83        let n_points = times.len();
84        let mut y = vec![vec![0.0; n_points]; 6];
85
86        for (i, state) in states.iter().enumerate() {
87            for j in 0..6 {
88                y[j][i] = state[j];
89            }
90        }
91
92        FastSolution {
93            t: times,
94            y,
95            t_events,
96            success: true,
97        }
98    }
99
100    /// A clearly-failed solution carrying only the launch state, with `success = false`.
101    /// Used when inputs are too degenerate to integrate (e.g. non-physical atmosphere),
102    /// so callers see `success = false` instead of a stub trajectory reported as success.
103    fn degenerate(initial_state: &[f64; 6]) -> Self {
104        let mut y = vec![Vec::new(); 6];
105        for (j, slot) in y.iter_mut().enumerate() {
106            slot.push(initial_state[j]);
107        }
108        FastSolution {
109            t: vec![0.0],
110            y,
111            t_events: [Vec::new(), Vec::new(), Vec::new()],
112            success: false,
113        }
114    }
115}
116
117fn direct_atmosphere_values(
118    atmo_params: (f64, f64, f64, f64),
119) -> Option<(f64, f64)> {
120    let (a, b, c, d) = atmo_params;
121    (a.is_finite()
122        && b.is_finite()
123        && c == 0.0
124        && d == 0.0
125        && a > 0.0
126        && a < 2.0
127        && b > 200.0)
128        .then_some((a, b))
129}
130
131fn stability_atmosphere_params(atmo_params: (f64, f64, f64, f64)) -> (f64, f64, f64, f64) {
132    if let Some((air_density, _)) = direct_atmosphere_values(atmo_params) {
133        // compute_stability_coefficient consumes standard-mode temperature/pressure, whose
134        // Miller correction is rho_ref/rho. At the 15 C reference temperature, scaling pressure
135        // by rho/rho_ref supplies that exact correction without misreading sound speed as temp.
136        (0.0, 15.0, 1013.25 * air_density / STANDARD_AIR_DENSITY, 1.0)
137    } else {
138        atmo_params
139    }
140}
141
142const MAX_STANDARD_DENSITY_RATIO: f64 = 2.0;
143
144/// True if `atmo_params` can yield a finite, positive air density. `atmo_params` has TWO
145/// modes that `compute_derivatives` distinguishes:
146///   * **Standard**: `(base_alt_m, base_temp_c, base_pressure_hPa, base_density_ratio)` —
147///     positive station pressure. Slot 3 is a density RATIO, not humidity, despite the
148///     `humidity` field it lands in via `build_inputs`. A nonpositive ratio means "not supplied"
149///     and falls back to `1.0`; a supplied ratio must be below `2.0`.
150///   * **Direct**: `(air_density, speed_of_sound, 0.0, 0.0)` — slots 2 and 3 are `0.0`
151///     sentinels, with a real density (< 2.0 kg/m³) and speed of sound (> 200 m/s).
152///
153/// A pressure <= 0 that ISN'T the direct-mode sentinel, an overlarge supplied density ratio, or
154/// any non-finite value would yield a non-physical atmosphere, so it is rejected. (Earlier this
155/// guard also rejected legitimate direct-mode input; the direct-mode allowance below fixes that.)
156fn atmo_is_physical(atmo_params: (f64, f64, f64, f64)) -> bool {
157    let (a, b, c, d) = atmo_params;
158    if !(a.is_finite() && b.is_finite() && c.is_finite() && d.is_finite()) {
159        return false;
160    }
161    // Direct-atmosphere mode: (density, speed_of_sound, 0, 0). Constants mirror
162    // derivatives.rs (MAX_REALISTIC_DENSITY = 2.0 kg/m³, MIN_REALISTIC_SPEED_OF_SOUND = 200 m/s).
163    // Standard mode: positive station pressure (hPa), plus either the documented missing-ratio
164    // sentinel or a supplied ratio below the physical ceiling.
165    direct_atmosphere_values(atmo_params).is_some() || (c > 0.0 && d < MAX_STANDARD_DENSITY_RATIO)
166}
167
168#[derive(Debug, Clone, Copy)]
169enum FastAtmosphere {
170    Direct {
171        air_density: f64,
172        speed_of_sound: f64,
173    },
174    Standard {
175        base_density: f64,
176    },
177}
178
179/// Fast trajectory integration parameters
180pub struct FastIntegrationParams {
181    pub horiz: f64,
182    pub vert: f64,
183    pub initial_state: [f64; 6],
184    pub t_span: (f64, f64),
185    /// Dual-mode atmosphere tuple. Standard mode is
186    /// `(base_alt_m, base_temp_c, base_pressure_hPa, base_density_ratio)` (slot 3 is a density
187    /// RATIO, not humidity; nonpositive means "not supplied" and falls back to `1.0`, while a
188    /// supplied ratio must be below `2.0`). Direct mode is
189    /// `(air_density, speed_of_sound, 0.0, 0.0)`.
190    pub atmo_params: (f64, f64, f64, f64),
191    /// MBA-1137: optional downrange-segmented atmosphere. When `Some`, the per-substep drag
192    /// density samples the base (station-referenced) T/P/H for the zone at the current downrange
193    /// distance before applying the altitude lapse. `None` (default) keeps the single-station
194    /// base and — combined with the MBA-1137 density-freeze fix — varies density with altitude only.
195    pub atmo_sock: Option<AtmoSock>,
196}
197
198/// Aerodynamic-jump vertical launch-angle offset (radians) for the fast-integrate path.
199///
200/// Bryan Litz's crosswind estimator (`Y = 0.01*Sg - 0.0024*L + 0.032` MOA/mph) fed by the
201/// engine's Miller Sg. The fast path receives a prebuilt initial velocity (no muzzle angle),
202/// so the caller rotates that velocity by this offset. Returns 0 when the feature is off or
203/// the inputs are degenerate. Crosswind is taken from `wind_speed`/`wind_angle`
204/// (BallisticInputs convention: 0 = headwind, +PI/2 = from the right). MBA-959, EXPERIMENTAL.
205pub fn aerodynamic_jump_launch_offset_rad(
206    inputs: &BallisticInputs,
207    atmo_params: (f64, f64, f64, f64),
208) -> f64 {
209    if !inputs.enable_aerodynamic_jump {
210        return 0.0;
211    }
212    let diameter = inputs.bullet_diameter;
213    if !(inputs.twist_rate.is_finite()
214        && inputs.twist_rate != 0.0
215        && diameter.is_finite()
216        && diameter > 0.0
217        && inputs.bullet_length.is_finite()
218        && inputs.bullet_length > 0.0
219        && inputs.muzzle_velocity.is_finite())
220    {
221        return 0.0;
222    }
223    let stability_atmo = stability_atmosphere_params(atmo_params);
224    let sg = crate::stability::compute_stability_coefficient(inputs, stability_atmo);
225    if !(sg.is_finite() && sg > 0.0) {
226        return 0.0;
227    }
228    let length_cal = inputs.bullet_length / diameter;
229    const MS_TO_MPH: f64 = 2.236_936_292_054_4;
230    let crosswind_from_right_mph = inputs.wind_speed * inputs.wind_angle.sin() * MS_TO_MPH;
231    let vertical_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
232        sg,
233        length_cal,
234        crosswind_from_right_mph,
235        inputs.is_twist_right,
236    );
237    if !vertical_moa.is_finite() {
238        return 0.0;
239    }
240    const MOA_PER_RAD: f64 = 3437.7467707849;
241    vertical_moa / MOA_PER_RAD
242}
243
244/// Rotate a McCoy-frame state's velocity (indices 3..6) by `theta_rad` in the vertical
245/// (downrange–vertical) plane, preserving speed and horizontal heading. Positive = up.
246fn rotate_launch_velocity(state: &mut [f64; 6], theta_rad: f64) {
247    let (vx, vy, vz) = (state[3], state[4], state[5]);
248    let speed = (vx * vx + vy * vy + vz * vz).sqrt();
249    if speed <= 0.0 {
250        return;
251    }
252    let h = (vx * vx + vz * vz).sqrt(); // horizontal (downrange+lateral) speed
253    let new_elev = vy.atan2(h) + theta_rad;
254    state[4] = speed * new_elev.sin();
255    let new_h = speed * new_elev.cos();
256    let scale = if h > 1e-12 { new_h / h } else { 0.0 };
257    state[3] = vx * scale;
258    state[5] = vz * scale;
259}
260
261fn launch_state_with_aerodynamic_jump(
262    inputs: &BallisticInputs,
263    atmo_params: (f64, f64, f64, f64),
264    mut initial_state: [f64; 6],
265) -> [f64; 6] {
266    let offset = aerodynamic_jump_launch_offset_rad(inputs, atmo_params);
267    if offset != 0.0 {
268        rotate_launch_velocity(&mut initial_state, offset);
269    }
270    initial_state
271}
272
273/// Fast fixed-step integration for longer trajectories
274pub fn fast_integrate(
275    inputs: &BallisticInputs,
276    wind_sock: &WindSock,
277    params: FastIntegrationParams,
278) -> FastSolution {
279    // Degenerate atmosphere -> non-physical air density would silently stub the run.
280    if !atmo_is_physical(params.atmo_params) {
281        return FastSolution::degenerate(&params.initial_state);
282    }
283    let mut effective_inputs = inputs.clone();
284    if params.atmo_params.2 > 0.0 {
285        effective_inputs.altitude = params.atmo_params.0;
286        effective_inputs.temperature = params.atmo_params.1;
287        effective_inputs.pressure = params.atmo_params.2;
288        effective_inputs.humidity = params.atmo_params.3;
289    }
290    let inputs = &effective_inputs;
291    // Extract parameters
292    let _mass_kg = inputs.bullet_mass; // SI (kg)
293    let bc = inputs.bc_value;
294    let drag_model = &inputs.bc_type;
295
296    // Check for BC segments
297    let has_bc_segments =
298        inputs.bc_segments.is_some() && !inputs.bc_segments.as_ref().unwrap().is_empty();
299    let has_bc_segments_data =
300        inputs.bc_segments_data.is_some() && !inputs.bc_segments_data.as_ref().unwrap().is_empty();
301
302    // Time step - adjust based on distance
303    let dt = if params.horiz > 200.0 {
304        0.001
305    } else if params.horiz > 100.0 {
306        0.0005
307    } else {
308        0.0001
309    };
310
311    // MBA-959: aerodynamic jump perturbs the prebuilt launch velocity vertically (this path is
312    // handed an initial_state, not a muzzle angle). A no-op returning the original when disabled.
313    let initial_state =
314        launch_state_with_aerodynamic_jump(inputs, params.atmo_params, params.initial_state);
315    let vx = initial_state[3]; // horizontal (downrange) velocity
316
317    // MBA-1145: decouple the integration-loop ceiling from the pre-allocation heuristic.
318    // Previously t_max = min(4*horiz/vx, t_span.1) bounded BOTH the Vec sizing AND the loop
319    // itself, so the 4x-horiz/vx estimate doubled as the loop cap. That estimate is only a
320    // heuristic — extreme high-drag or high-launch-angle shots exceed it, and the loop then
321    // terminated BEFORE hit_target/hit_ground, silently truncating the trajectory tail (Monte
322    // Carlo reported impact metrics short of the real range). The loop already breaks on
323    // hit_target (pos.x >= horiz) and hit_ground (pos.y <= ground_threshold), and any real
324    // trajectory descends below the ground threshold within a few seconds of apex, so the loop
325    // bound's only job is a runaway safety ceiling: use the full t_span.1 (default 30 s). The
326    // 4x estimate is retained ONLY to size the pre-allocation (keeps the small-allocation fast
327    // path for short shots); the Vec grows for the rare long run because the loop bound is n_steps.
328    let n_steps = ((params.t_span.1 / dt) as usize) + 1;
329    let est_steps = if vx > 1e-6 && params.horiz > 0.0 {
330        (((4.0 * params.horiz / vx) / dt) as usize) + 1
331    } else {
332        n_steps
333    };
334    let cap = est_steps.min(n_steps);
335    let mut times = Vec::with_capacity(cap);
336    let mut states = Vec::with_capacity(cap);
337
338    // Initial state (with the aerodynamic-jump launch perturbation applied above)
339    times.push(0.0);
340    states.push(initial_state);
341
342    // Direct mode supplies a fixed density and speed of sound. Standard mode supplies station
343    // conditions plus base_ratio; its local density/sound speed are lapsed at every substep.
344    // Guard a missing standard-mode ratio by falling back to sea-level density (MBA-1157 owns
345    // stricter validation of that separate contract).
346    let atmosphere = if let Some((air_density, speed_of_sound)) =
347        direct_atmosphere_values(params.atmo_params)
348    {
349        FastAtmosphere::Direct {
350            air_density,
351            speed_of_sound,
352        }
353    } else {
354        let base_density = if params.atmo_params.3 > 0.0 {
355            params.atmo_params.3 * 1.225
356        } else {
357            1.225
358        };
359        FastAtmosphere::Standard { base_density }
360    };
361
362    // MBA-1137: borrow the optional downrange-segmented atmosphere once (queried 4x per step).
363    let atmo_sock = params.atmo_sock.as_ref();
364
365    // Hoist invariants out of compute_derivatives (called 4x per step). Both the drag-model name
366    // and the projectile shape depend only on inputs, not on state/mach, so computing them per
367    // call wasted an allocation + heuristic every k1..k4. Mirrors cli_api.rs exactly.
368
369    // Drag-model name as a borrowed &'static str. DragModel's Display goes via Debug, which
370    // heap-allocates a String on every call; this match is bit-identical (Display == Debug ==
371    // variant name) with no per-step allocation.
372    let drag_model_str: &str = match drag_model {
373        DragModel::G1 => "G1",
374        DragModel::G2 => "G2",
375        DragModel::G5 => "G5",
376        DragModel::G6 => "G6",
377        DragModel::G7 => "G7",
378        DragModel::G8 => "G8",
379        DragModel::GI => "GI",
380        DragModel::GS => "GS",
381    };
382
383    // SI fallbacks for caliber/weight (SI-only MC callers may leave the imperial fields 0).
384    let caliber_in = if inputs.caliber_inches > 0.0 {
385        inputs.caliber_inches
386    } else {
387        inputs.bullet_diameter / 0.0254
388    };
389    let weight_gr = if inputs.weight_grains > 0.0 {
390        inputs.weight_grains
391    } else {
392        inputs.bullet_mass / 0.00006479891
393    };
394
395    // Projectile shape for transonic corrections (MBA-949: shared resolver — bullet_model name
396    // first, then the caliber/weight/drag-model heuristic).
397    let projectile_shape = crate::transonic_drag::resolve_projectile_shape(
398        inputs.bullet_model.as_deref(),
399        caliber_in,
400        weight_gr,
401        drag_model_str,
402    );
403
404    // Coriolis omega (Earth rotation), hoisted (invariant over the flight). MBA-957:
405    // fast_integrate — the Monte Carlo / Python-binding path — previously applied NO Coriolis.
406    // First project into level downrange/up/lateral axes: azimuth 0 = North; omega.Z is NEGATIVE
407    // (Omega.East = -Omega cos(lat) sin(az)). The derivative kernel then projects this vector into
408    // the inclined shot frame and applies the physical -2 Omega x v.
409    let omega_vector = if inputs.enable_coriolis && inputs.latitude.is_some() {
410        let omega_earth = 7.2921159e-5_f64; // rad/s
411        let lat = inputs.latitude.unwrap().to_radians();
412        let az = inputs.shot_azimuth; // compass bearing (0=N), NOT the aiming offset
413        Some(Vector3::new(
414            omega_earth * lat.cos() * az.cos(),  // X: downrange
415            omega_earth * lat.sin(),             // Y: vertical
416            -omega_earth * lat.cos() * az.sin(), // Z: lateral (corrected sign)
417        ))
418    } else {
419        None
420    };
421    // Parse the string model once; the derivative kernel is called four times per RK4 step.
422    let wind_shear_model = if inputs.enable_wind_shear {
423        let model = crate::wind_shear::boundary_layer_model_from_name(&inputs.wind_shear_model);
424        (model != crate::wind_shear::WindShearModel::None).then_some(model)
425    } else {
426        None
427    };
428
429    // Integration loop
430    let mut hit_target = false;
431    let mut hit_ground = false;
432    let mut max_ord_time = None;
433    let mut max_ord_y = 0.0;
434    let ground_threshold = inputs.ground_threshold;
435
436    // RK4 integration
437    for i in 0..n_steps - 1 {
438        let t = i as f64 * dt;
439        let state = states[i];
440
441        let pos = Vector3::new(state[0], state[1], state[2]);
442        let _vel = Vector3::new(state[3], state[4], state[5]);
443
444        // Check termination conditions (X is downrange, McCoy)
445        if pos.x >= params.horiz {
446            hit_target = true;
447            break;
448        }
449
450        if pos.y <= ground_threshold {
451            hit_ground = true;
452            break;
453        }
454
455        // Track maximum ordinate
456        if pos.y > max_ord_y {
457            max_ord_y = pos.y;
458            max_ord_time = Some(t);
459        }
460
461        // RK4 step
462        let k1 = compute_derivatives(
463            &state,
464            inputs,
465            wind_sock,
466            atmosphere,
467            drag_model,
468            projectile_shape,
469            bc,
470            has_bc_segments,
471            has_bc_segments_data,
472            omega_vector,
473            wind_shear_model,
474            atmo_sock,
475        );
476
477        let mut state2 = state;
478        for j in 0..6 {
479            state2[j] = state[j] + 0.5 * dt * k1[j];
480        }
481        let k2 = compute_derivatives(
482            &state2,
483            inputs,
484            wind_sock,
485            atmosphere,
486            drag_model,
487            projectile_shape,
488            bc,
489            has_bc_segments,
490            has_bc_segments_data,
491            omega_vector,
492            wind_shear_model,
493            atmo_sock,
494        );
495
496        let mut state3 = state;
497        for j in 0..6 {
498            state3[j] = state[j] + 0.5 * dt * k2[j];
499        }
500        let k3 = compute_derivatives(
501            &state3,
502            inputs,
503            wind_sock,
504            atmosphere,
505            drag_model,
506            projectile_shape,
507            bc,
508            has_bc_segments,
509            has_bc_segments_data,
510            omega_vector,
511            wind_shear_model,
512            atmo_sock,
513        );
514
515        let mut state4 = state;
516        for j in 0..6 {
517            state4[j] = state[j] + dt * k3[j];
518        }
519        let k4 = compute_derivatives(
520            &state4,
521            inputs,
522            wind_sock,
523            atmosphere,
524            drag_model,
525            projectile_shape,
526            bc,
527            has_bc_segments,
528            has_bc_segments_data,
529            omega_vector,
530            wind_shear_model,
531            atmo_sock,
532        );
533
534        // Update state
535        let mut new_state = state;
536        for j in 0..6 {
537            new_state[j] = state[j] + dt * (k1[j] + 2.0 * k2[j] + 2.0 * k3[j] + k4[j]) / 6.0;
538        }
539
540        if state[0] < params.horiz && new_state[0] >= params.horiz {
541            // Keep every terminal metric on the requested target plane. The former top-of-loop
542            // check retained this full-step endpoint, biasing time, drop, and velocity past it.
543            let alpha = (params.horiz - state[0]) / (new_state[0] - state[0]);
544            let mut target_state = state;
545            for j in 0..6 {
546                target_state[j] = state[j] + alpha * (new_state[j] - state[j]);
547            }
548            target_state[0] = params.horiz;
549            times.push(t + alpha * dt);
550            states.push(target_state);
551            hit_target = true;
552            break;
553        }
554
555        times.push(t + dt);
556        states.push(new_state);
557    }
558
559    // Create event arrays
560    let t_events = [
561        if hit_target {
562            vec![*times.last().unwrap()]
563        } else {
564            vec![]
565        },
566        if let Some(t) = max_ord_time {
567            vec![t]
568        } else {
569            vec![]
570        },
571        if hit_ground {
572            vec![*times.last().unwrap()]
573        } else {
574            vec![]
575        },
576    ];
577
578    // MBA-1134 (rank 10) — regression fix: apply the canonical empirical Litz drift as a
579    // post-process to the lateral (McCoy Z) here too. The derivatives kernel no longer
580    // integrates spin drift, and this plain fast_integrate is the single-shot fast path
581    // (solve_trajectory_rust / the API), so without this it would carry NO spin drift and
582    // twist direction would have no effect (the Magnus term is also suppressed when
583    // use_enhanced_spin_drift is set). Mirrors fast_integrate_with_segments,
584    // cli_api::apply_spin_drift and the Monte-Carlo path so all solver families agree; uses
585    // the SAME muzzle Sg (spin_drift::effective_sg_from_inputs).
586    if inputs.use_enhanced_spin_drift {
587        // Standard-mode atmo_params is (base_alt, temp_c, press_hpa, ratio); direct mode
588        // (density, sound, 0, 0) carries no explicit temp/pressure, so fall back to sea-level
589        // standard (the Sg density correction is a no-op there).
590        let (sd_temp_c, sd_press_hpa) = if params.atmo_params.2 > 0.0 {
591            (params.atmo_params.1, params.atmo_params.2)
592        } else {
593            (15.0, 1013.25)
594        };
595        let sg = crate::spin_drift::effective_sg_from_inputs(inputs, sd_temp_c, sd_press_hpa);
596        for (t, state) in times.iter().zip(states.iter_mut()) {
597            if *t > 0.0 {
598                state[2] += crate::spin_drift::litz_drift_meters(sg, *t, inputs.is_twist_right);
599            }
600        }
601    }
602
603    FastSolution::from_trajectory_data(times, states, t_events)
604}
605
606fn fast_magnus_acceleration(
607    inputs: &BallisticInputs,
608    air_velocity: Vector3<f64>,
609    air_density: f64,
610    mach: f64,
611    gravity_acceleration: Vector3<f64>,
612) -> Vector3<f64> {
613    if !inputs.enable_magnus
614        || inputs.use_enhanced_spin_drift
615        || inputs.bullet_diameter <= 0.0
616        || inputs.twist_rate <= 0.0
617        || inputs.bullet_mass <= 0.0
618    {
619        return Vector3::zeros();
620    }
621
622    let speed_air = air_velocity.norm();
623    let diameter_m = inputs.bullet_diameter;
624    let (spin_rate_rad_s, spin_param) = crate::spin_drift::calculate_magnus_spin_state(
625        inputs.muzzle_velocity,
626        speed_air,
627        inputs.twist_rate,
628        diameter_m,
629    );
630    let d_in = if inputs.caliber_inches > 0.0 {
631        inputs.caliber_inches
632    } else {
633        diameter_m / 0.0254
634    };
635    let m_gr = if inputs.weight_grains > 0.0 {
636        inputs.weight_grains
637    } else {
638        inputs.bullet_mass / 0.00006479891
639    };
640    let l_in = if inputs.bullet_length > 0.0 {
641        inputs.bullet_length / 0.0254
642    } else {
643        let estimated = crate::stability::estimate_bullet_length_m(diameter_m, inputs.bullet_mass);
644        if estimated > 0.0 {
645            estimated / 0.0254
646        } else {
647            4.5 * d_in.max(1e-9)
648        }
649    };
650    let sg = crate::spin_drift::calculate_dynamic_stability(
651        m_gr,
652        speed_air,
653        spin_rate_rad_s,
654        d_in,
655        l_in,
656        air_density,
657    );
658    let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
659        sg,
660        speed_air,
661        spin_rate_rad_s,
662        0.0,
663        0.0,
664        air_density,
665        d_in,
666        l_in,
667        m_gr,
668        mach,
669        "match",
670        false,
671    );
672    let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
673    let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
674    let force = 0.5 * air_density * speed_air.powi(2) * area * c_np * spin_param * yaw_rad.sin();
675    if force <= 1e-12 {
676        return Vector3::zeros();
677    }
678
679    crate::derivatives::yaw_of_repose_magnus_direction(
680        air_velocity,
681        gravity_acceleration,
682        inputs.is_twist_right,
683    )
684    .map_or_else(Vector3::zeros, |direction| {
685        (force / inputs.bullet_mass) * direction
686    })
687}
688
689fn interpolated_vertical_apex(
690    previous_time: f64,
691    previous: &[f64; 6],
692    current_time: f64,
693    current: &[f64; 6],
694) -> Option<(f64, [f64; 6])> {
695    let dt = current_time - previous_time;
696    let previous_vy = previous[4];
697    let current_vy = current[4];
698    if !dt.is_finite()
699        || dt <= 0.0
700        || !previous_vy.is_finite()
701        || !current_vy.is_finite()
702        || previous_vy <= 0.0
703        || current_vy > 0.0
704    {
705        return None;
706    }
707
708    let denominator = previous_vy - current_vy;
709    if !denominator.is_finite() || denominator <= 0.0 {
710        return None;
711    }
712    let alpha = previous_vy / denominator;
713    // An exact endpoint root is already retained as the current saved point. Only synthesize a
714    // strictly interior point so the solution remains ordered without duplicate event times.
715    if !alpha.is_finite() || !(0.0..1.0).contains(&alpha) {
716        return None;
717    }
718
719    let mut apex = [0.0; 6];
720    for component in 0..6 {
721        apex[component] = previous[component] + alpha * (current[component] - previous[component]);
722    }
723
724    // Cubic Hermite interpolation uses each endpoint's position and velocity, avoiding the
725    // chord-height bias that linear interpolation has around a stationary point.
726    let alpha2 = alpha * alpha;
727    let alpha3 = alpha2 * alpha;
728    let h00 = 2.0 * alpha3 - 3.0 * alpha2 + 1.0;
729    let h10 = alpha3 - 2.0 * alpha2 + alpha;
730    let h01 = -2.0 * alpha3 + 3.0 * alpha2;
731    let h11 = alpha3 - alpha2;
732    for axis in 0..3 {
733        apex[axis] = h00 * previous[axis]
734            + h10 * dt * previous[axis + 3]
735            + h01 * current[axis]
736            + h11 * dt * current[axis + 3];
737    }
738    apex[4] = 0.0;
739
740    apex.iter()
741        .all(|component| component.is_finite())
742        .then_some((previous_time + alpha * dt, apex))
743}
744
745/// Compute derivatives for the state vector
746#[allow(clippy::too_many_arguments)]
747fn compute_derivatives(
748    state: &[f64; 6],
749    inputs: &BallisticInputs,
750    wind_sock: &WindSock,
751    atmosphere: FastAtmosphere,
752    drag_model: &DragModel,
753    projectile_shape: crate::transonic_drag::ProjectileShape,
754    bc: f64,
755    has_bc_segments: bool,
756    has_bc_segments_data: bool,
757    omega: Option<Vector3<f64>>,
758    wind_shear_model: Option<crate::wind_shear::WindShearModel>,
759    // MBA-1137: optional downrange-segmented atmosphere (zone base swapped by pos.x before lapse).
760    atmo_sock: Option<&AtmoSock>,
761) -> [f64; 6] {
762    let pos = Vector3::new(state[0], state[1], state[2]);
763    let vel = Vector3::new(state[3], state[4], state[5]);
764
765    // Resolve the cached downrange wind in level axes, then apply boundary-layer shear using
766    // height gained above launch. Site elevation is MSL and intentionally does not enter shear.
767    let level_wind = wind_sock.vector_for_range_stateless(pos.x);
768    let level_wind = if let Some(model) = wind_shear_model {
769        let height_rel_launch =
770            crate::atmosphere::shot_frame_altitude(0.0, pos.x, pos.y, inputs.shooting_angle);
771        crate::wind_shear::apply_boundary_layer_shear(level_wind, height_rel_launch, model)
772    } else {
773        level_wind
774    };
775    let wind_vector =
776        crate::derivatives::level_vector_to_shot_frame(level_wind, inputs.shooting_angle);
777
778    // Velocity relative to air
779    let vel_adjusted = vel - wind_vector;
780    let v_mag = vel_adjusted.norm();
781
782    // Gravity acceleration vector, rotated into the shot-aligned frame by shooting_angle
783    // (uphill/downhill inclined fire), matching cli_api::TrajectorySolver::gravity_acceleration.
784    let theta = inputs.shooting_angle;
785    let accel_gravity = Vector3::new(
786        -G_ACCEL_MPS2 * theta.sin(),
787        -G_ACCEL_MPS2 * theta.cos(),
788        0.0,
789    );
790
791    // Calculate acceleration
792    let mut accel = if v_mag < 1e-6 {
793        accel_gravity
794    } else {
795        // Calculate drag
796        let v_fps = v_mag * MPS_TO_FPS;
797
798        // Resolve LOCAL density and speed of sound. Direct mode uses its supplied fixed values;
799        // standard mode evaluates the substep altitude with the lapse-rate pipeline.
800        // `inputs.temperature`/`inputs.pressure` are the standard-mode base (station) T/P set by
801        // fast_integrate from atmo_params; `base_density` is the station-altitude density, so
802        // `base_density / 1.225` recovers the station base_ratio the lapse pipeline expects.
803        //
804        // MBA-1137 (density-freeze fix): previously ONLY the speed of sound was taken from this
805        // call and `density_scale` used the flight-constant `base_density`, so the fast/MC path
806        // held density frozen for the whole flight (density did NOT vary with altitude — a latent
807        // bug the cli_api/derivatives paths already avoid). Now the LOCAL density is used for
808        // `density_scale` below, so the fast path varies density with altitude too.
809        //
810        // MBA-1137 (zones): when a downrange-segmented atmosphere is present, swap the BASE
811        // (station-referenced) temp/pressure/ratio for the zone at the current downrange distance
812        // (pos.x), recomputing the zone base_ratio via CIPM, BEFORE the altitude lapse — so
813        // downrange-zone selection and the world-vertical lapse compose without double-counting.
814        // `None` keeps the single-station base.
815        //
816        // Humidity is NOT plumbed to this call site: on the fast path (fast_integrate) the
817        // `humidity` FIELD is overwritten with atmo_params.3 (the density RATIO), so
818        // `inputs.humidity` is not a real RH — pass 0.0 (dry) rather than fabricate.
819        let (local_density, speed_of_sound) = match atmosphere {
820            FastAtmosphere::Direct {
821                air_density,
822                speed_of_sound,
823            } => (air_density, speed_of_sound),
824            FastAtmosphere::Standard { base_density } => {
825                let altitude = crate::atmosphere::shot_frame_altitude(
826                    inputs.altitude,
827                    pos.x,
828                    pos.y,
829                    inputs.shooting_angle,
830                );
831                let (base_temp_c, base_press_hpa, base_ratio) = match atmo_sock {
832                    Some(sock) => {
833                        let (zt, zp, zh) = sock.atmo_for_range(pos.x);
834                        (zt, zp, calculate_air_density_cimp(zt, zp, zh) / 1.225)
835                    }
836                    None => (inputs.temperature, inputs.pressure, base_density / 1.225),
837                };
838                get_local_atmosphere_humid(
839                    altitude,
840                    inputs.altitude, // base_alt approximation
841                    base_temp_c,
842                    base_press_hpa,
843                    base_ratio,
844                    0.0, // humidity not available here (see note above)
845                )
846            }
847        };
848        let mach = v_mag / speed_of_sound;
849
850        // Get BC value (potentially from segments)
851        let bc_current = if inputs.use_bc_segments
852            && has_bc_segments_data
853            && inputs.bc_segments_data.is_some()
854        {
855            velocity_segment_bc(v_fps, inputs.bc_segments_data.as_ref().unwrap(), bc)
856        } else if has_bc_segments && inputs.bc_segments.is_some() {
857            crate::derivatives::interpolated_bc(
858                mach,
859                inputs.bc_segments.as_ref().unwrap(),
860                Some(inputs),
861            )
862        } else {
863            bc
864        };
865        // Guard bc_value == 0 (allowed on FFI/WASM/public MC surfaces): the division below
866        // would be Inf -> NaN. Mirrors cli_api's effective_bc.max(1e-6); inert for valid BCs.
867        let bc_current = bc_current.max(1e-6);
868
869        // Apply the transonic drag-rise correction once (mirrors derivatives.rs / cli_api) so
870        // the Monte Carlo / fast path doesn't under-predict drag near Mach 1. The projectile
871        // shape is invariant for the whole integration, so it is hoisted into fast_integrate and
872        // passed in rather than recomputed per call. wave_drag=false: the G1/G7 tables already
873        // embed the rise.
874        // MBA-940: a user-supplied custom drag table is the final Cd, used as-is (no G-model
875        // lookup, transonic, or form-factor correction — the curve already encodes the true drag).
876        // Its Cd is the projectile's ACTUAL drag coefficient, so the retardation denominator
877        // must be the sectional density (lb/in²), not a BC: Cd_own / SD == Cd_ref / BC
878        // (see BallisticInputs::custom_drag_denominator).
879        let (drag_factor, retard_denom) = if let Some(ref table) = inputs.custom_drag_table {
880            (
881                table.interpolate(mach),
882                inputs.custom_drag_denominator(bc_current),
883            )
884        } else {
885            let base_cd = get_drag_coefficient(mach, drag_model);
886            let cd =
887                crate::transonic_drag::transonic_correction(mach, base_cd, projectile_shape, false);
888            (cd, bc_current)
889        };
890
891        // Calculate drag acceleration using proper ballistics formula
892        let cd_to_retard = crate::constants::CD_TO_RETARD;
893        let standard_factor = drag_factor * cd_to_retard;
894        // MBA-1137: use the LOCAL (per-substep) density, not the frozen flight-constant
895        // `base_density`, so drag varies with altitude AND downrange zone.
896        let density_scale = local_density / 1.225;
897
898        // Drag acceleration in ft/s^2
899        let a_drag_ft_s2 = (v_fps * v_fps) * standard_factor * density_scale / retard_denom;
900
901        // Convert to m/s^2 and apply to velocity vector
902        let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; // ft/s^2 to m/s^2
903        let accel_drag = -a_drag_m_s2 * (vel_adjusted / v_mag);
904
905        // The Litz post-process owns the gyroscopic effect whenever it is enabled. Otherwise,
906        // honor the explicit Magnus flag with the same dynamic-Sg/yaw model as sibling solvers.
907        let accel_magnus =
908            fast_magnus_acceleration(inputs, vel_adjusted, local_density, mach, accel_gravity);
909
910        // Total acceleration
911        accel_drag + accel_gravity + accel_magnus
912    };
913
914    // Coriolis (Earth rotation), MBA-957. Omega arrives in level downrange/up/lateral axes;
915    // project it into the inclined shot frame before applying the physical -2 Omega x v.
916    if let Some(omega) = omega {
917        let omega = crate::derivatives::level_vector_to_shot_frame(omega, inputs.shooting_angle);
918        accel += -2.0 * omega.cross(&vel);
919    }
920
921    // Return derivatives [vx, vy, vz, ax, ay, az]
922    [vel.x, vel.y, vel.z, accel.x, accel.y, accel.z]
923}
924
925/// Fast integration with explicit wind segments using RK45
926/// MBA-155: Upstreamed from ballistics_rust
927pub fn fast_integrate_with_segments(
928    inputs: &BallisticInputs,
929    wind_segments: Vec<crate::wind::WindSegment>,
930    params: FastIntegrationParams,
931) -> FastSolution {
932    // Use the RK45 implementation from trajectory_integration module
933    use crate::trajectory_integration::{integrate_trajectory, TrajectoryParams};
934
935    // Degenerate atmosphere -> non-physical air density would silently stub the run.
936    if !atmo_is_physical(params.atmo_params) {
937        return FastSolution::degenerate(&params.initial_state);
938    }
939
940    // Match plain fast_integrate: this entry point also receives a prebuilt launch state, so
941    // apply the experimental aerodynamic-jump angle exactly once before the low-level integrator.
942    let initial_state =
943        launch_state_with_aerodynamic_jump(inputs, params.atmo_params, params.initial_state);
944
945    // Extract parameters
946    let mass_kg = inputs.bullet_mass; // SI (kg)
947    let bc = inputs.bc_value;
948    let drag_model = inputs.bc_type;
949
950    // Coriolis omega — gated on enable_coriolis (+ a latitude), INDEPENDENT of
951    // spin-drift/Magnus. A caller can now request Coriolis-only (enable_coriolis=true
952    // with enable_advanced_effects=false) instead of being forced to enable all three.
953    let omega_vector = if inputs.enable_coriolis && inputs.latitude.is_some() {
954        // Calculate omega based on latitude and shot azimuth
955        // First project Earth's rotation into level downrange/up/lateral axes based on azimuth;
956        // the derivative kernel handles the additional inclined-shot projection.
957        // azimuth_angle: 0 = North, pi/2 = East
958        let omega_earth = 7.2921159e-5; // rad/s
959        let lat_rad = inputs.latitude.unwrap_or(0.0).to_radians();
960        let azimuth = inputs.shot_azimuth; // compass bearing (0=N), NOT the aiming offset
961        Some(Vector3::new(
962            omega_earth * lat_rad.cos() * azimuth.cos(), // X: downrange component
963            omega_earth * lat_rad.sin(),                 // Y: vertical component
964            -omega_earth * lat_rad.cos() * azimuth.sin(), // Z: lateral (MBA-957: corrected sign)
965        ))
966    } else {
967        None
968    };
969
970    // Set up trajectory parameters
971    let traj_params = TrajectoryParams {
972        mass_kg,
973        bc,
974        drag_model,
975        wind_segments,
976        atmos_params: params.atmo_params,
977        omega_vector,
978        enable_spin_drift: inputs.use_enhanced_spin_drift,
979        enable_magnus: inputs.enable_magnus,
980        enable_coriolis: inputs.enable_coriolis,
981        target_distance_m: params.horiz,
982        enable_wind_shear: inputs.enable_wind_shear,
983        wind_shear_model: inputs.wind_shear_model.clone(),
984        shooter_altitude_m: inputs.altitude,
985        is_twist_right: inputs.is_twist_right,
986        shooting_angle: inputs.shooting_angle,
987        // MBA-717: carry the real bullet geometry so spin-drift / Magnus use it.
988        bullet_diameter: inputs.bullet_diameter,
989        bullet_length: inputs.bullet_length,
990        twist_rate: inputs.twist_rate,
991        custom_drag_table: inputs.custom_drag_table.clone(),
992        bc_segments: inputs.bc_segments.clone(),
993        use_bc_segments: inputs.use_bc_segments,
994        // MBA-954: keep the historical -1000.0 here (behavior-preserving for this binding path);
995        // threading inputs.ground_threshold would change the default ground plane for existing
996        // callers. Direct TrajectoryParams constructors can now configure it.
997        ground_threshold: -1000.0,
998        // MBA-1137: forward the downrange-segmented atmosphere to the RK45 derivatives path.
999        atmo_sock: params.atmo_sock,
1000    };
1001
1002    // Use RK45 adaptive integration
1003    let trajectory = integrate_trajectory(
1004        initial_state,
1005        params.t_span,
1006        traj_params,
1007        "RK45", // Use RK45 implementation
1008        1e-6,   // tolerance
1009        0.01,   // max_step
1010    );
1011
1012    // Convert trajectory to FastSolution format
1013    let n_points = trajectory.len();
1014    let mut times = Vec::with_capacity(n_points + 1);
1015    let mut states = Vec::with_capacity(n_points + 1);
1016
1017    let mut target_hit_time: Option<f64> = None;
1018    let mut ground_hit_time: Option<f64> = None;
1019    let mut max_ord_time = None;
1020    let mut max_ord_y = 0.0;
1021
1022    for (t, state_vec) in trajectory {
1023        // Convert Vector6 to array
1024        let state = [
1025            state_vec[0],
1026            state_vec[1],
1027            state_vec[2],
1028            state_vec[3],
1029            state_vec[4],
1030            state_vec[5],
1031        ];
1032
1033        // Check termination conditions
1034        // McCoy: state[0]=downrange, state[1]=vertical, state[2]=lateral
1035
1036        // The RK45 integrator intentionally returns only ~50 range-spaced states. Use vertical
1037        // velocity to recover an apex between adjacent saves, and retain the synthetic point so
1038        // FastSolution::sol(max_ord_time) returns its Hermite-interpolated height rather than the
1039        // lower straight chord between coarse samples.
1040        if let Some((&previous_time, &previous_state)) = times.last().zip(states.last()) {
1041            if let Some((apex_time, apex_state)) =
1042                interpolated_vertical_apex(previous_time, &previous_state, t, &state)
1043            {
1044                if apex_state[1] > max_ord_y {
1045                    max_ord_y = apex_state[1];
1046                    max_ord_time = Some(apex_time);
1047                }
1048                times.push(apex_time);
1049                states.push(apex_state);
1050            }
1051        }
1052
1053        // Record FIRST time target is hit
1054        if target_hit_time.is_none() && state[0] >= params.horiz {
1055            target_hit_time = Some(t);
1056        }
1057
1058        // Record ground hit
1059        if ground_hit_time.is_none() && state[1] <= inputs.ground_threshold {
1060            ground_hit_time = Some(t);
1061        }
1062
1063        // Track maximum ordinate
1064        if state[1] > max_ord_y {
1065            max_ord_y = state[1];
1066            max_ord_time = Some(t);
1067        }
1068
1069        times.push(t);
1070        states.push(state);
1071    }
1072
1073    // Create event arrays
1074    let t_events = [
1075        if let Some(t) = target_hit_time {
1076            vec![t]
1077        } else {
1078            vec![]
1079        },
1080        if let Some(t) = max_ord_time {
1081            vec![t]
1082        } else {
1083            vec![]
1084        },
1085        if let Some(t) = ground_hit_time {
1086            vec![t]
1087        } else {
1088            vec![]
1089        },
1090    ];
1091
1092    // MBA-1134 (rank 10): the derivatives kernel no longer integrates spin drift, so apply the
1093    // canonical empirical Litz drift as a post-process to the lateral (McCoy Z) of every point at
1094    // its time of flight — matching cli_api::apply_spin_drift and the Monte-Carlo path so all three
1095    // solver families agree. Uses the SAME muzzle Sg (spin_drift::effective_sg_from_inputs).
1096    if inputs.use_enhanced_spin_drift {
1097        // Standard-mode atmo_params is (base_alt, temp_c, press_hpa, ratio); direct mode
1098        // (density, sound, 0, 0) carries no explicit temp/pressure, so fall back to sea-level
1099        // standard (the Sg density correction is a no-op there).
1100        let (sd_temp_c, sd_press_hpa) = if params.atmo_params.2 > 0.0 {
1101            (params.atmo_params.1, params.atmo_params.2)
1102        } else {
1103            (15.0, 1013.25)
1104        };
1105        let sg = crate::spin_drift::effective_sg_from_inputs(inputs, sd_temp_c, sd_press_hpa);
1106        for (t, state) in times.iter().zip(states.iter_mut()) {
1107            if *t > 0.0 {
1108                state[2] += crate::spin_drift::litz_drift_meters(sg, *t, inputs.is_twist_right);
1109            }
1110        }
1111    }
1112
1113    FastSolution::from_trajectory_data(times, states, t_events)
1114}
1115
1116#[cfg(test)]
1117mod tests {
1118    use super::*;
1119    use crate::BCSegmentData;
1120
1121    fn expected_shot_frame_vector(level: Vector3<f64>, angle: f64) -> Vector3<f64> {
1122        let (sin_angle, cos_angle) = angle.sin_cos();
1123        Vector3::new(
1124            level.x * cos_angle + level.y * sin_angle,
1125            -level.x * sin_angle + level.y * cos_angle,
1126            level.z,
1127        )
1128    }
1129
1130    #[test]
1131    fn measured_bc_fast_drag_ignores_name_based_form_factor_flag() {
1132        let derivatives_with_flag = |use_form_factor| {
1133            let inputs = BallisticInputs {
1134                bc_value: 0.462,
1135                bc_type: DragModel::G1,
1136                bullet_model: Some("168gr SMK Match".to_string()),
1137                use_form_factor,
1138                temperature: 15.0,
1139                pressure: 1013.25,
1140                ..BallisticInputs::default()
1141            };
1142
1143            compute_derivatives(
1144                &[0.0, 0.0, 0.0, 600.0, 0.0, 0.0],
1145                &inputs,
1146                &WindSock::new(vec![]),
1147                FastAtmosphere::Standard {
1148                    base_density: 1.225,
1149                },
1150                &inputs.bc_type,
1151                crate::transonic_drag::ProjectileShape::Spitzer,
1152                inputs.bc_value,
1153                false,
1154                false,
1155                None,
1156                None,
1157                None,
1158            )
1159        };
1160
1161        let baseline = derivatives_with_flag(false);
1162        let flagged = derivatives_with_flag(true);
1163
1164        for component in 3..6 {
1165            assert_eq!(
1166                flagged[component].to_bits(),
1167                baseline[component].to_bits(),
1168                "published BC already encodes form factor: component {component}, baseline={} flagged={}",
1169                baseline[component],
1170                flagged[component]
1171            );
1172        }
1173    }
1174
1175    #[test]
1176    fn velocity_bc_data_requires_opt_in_in_plain_fast_kernel() {
1177        let acceleration = |inputs: &BallisticInputs| {
1178            let has_mach_segments = inputs
1179                .bc_segments
1180                .as_ref()
1181                .is_some_and(|segments| !segments.is_empty());
1182            let has_velocity_segments = inputs
1183                .bc_segments_data
1184                .as_ref()
1185                .is_some_and(|segments| !segments.is_empty());
1186            compute_derivatives(
1187                &[0.0, 0.0, 0.0, 600.0, 0.0, 0.0],
1188                inputs,
1189                &WindSock::new(vec![]),
1190                FastAtmosphere::Standard {
1191                    base_density: 1.225,
1192                },
1193                &inputs.bc_type,
1194                crate::transonic_drag::ProjectileShape::Spitzer,
1195                inputs.bc_value,
1196                has_mach_segments,
1197                has_velocity_segments,
1198                None,
1199                None,
1200                None,
1201            )
1202        };
1203
1204        let scalar_inputs = BallisticInputs {
1205            bc_value: 0.5,
1206            bc_type: DragModel::G7,
1207            temperature: 15.0,
1208            pressure: 1013.25,
1209            ..BallisticInputs::default()
1210        };
1211        let mut disabled_inputs = scalar_inputs.clone();
1212        disabled_inputs.bc_segments_data = Some(vec![BCSegmentData {
1213            velocity_min: 0.0,
1214            velocity_max: 4_000.0,
1215            bc_value: 0.46,
1216        }]);
1217        disabled_inputs.use_bc_segments = false;
1218        let mut enabled_inputs = disabled_inputs.clone();
1219        enabled_inputs.use_bc_segments = true;
1220        let mut mach_only_inputs = scalar_inputs.clone();
1221        mach_only_inputs.bc_segments = Some(vec![(0.0, 0.4), (3.0, 0.4)]);
1222        let mut disabled_with_both = mach_only_inputs.clone();
1223        disabled_with_both.bc_segments_data = disabled_inputs.bc_segments_data.clone();
1224
1225        let scalar = acceleration(&scalar_inputs);
1226        let disabled = acceleration(&disabled_inputs);
1227        let enabled = acceleration(&enabled_inputs);
1228        let mach_only = acceleration(&mach_only_inputs);
1229        let disabled_with_both = acceleration(&disabled_with_both);
1230
1231        assert_eq!(
1232            disabled[3].to_bits(),
1233            scalar[3].to_bits(),
1234            "a populated velocity table must not change drag while use_bc_segments is false"
1235        );
1236        assert!(
1237            enabled[3] < disabled[3] - 1.0,
1238            "enabling the lower BC table must increase drag: disabled ax={} enabled ax={}",
1239            disabled[3],
1240            enabled[3]
1241        );
1242        assert_eq!(
1243            disabled_with_both[3].to_bits(),
1244            mach_only[3].to_bits(),
1245            "disabling velocity data must fall through to an explicit Mach table"
1246        );
1247    }
1248
1249    #[test]
1250    fn inclined_positions_at_same_world_altitude_have_same_fast_acceleration() {
1251        let angle = std::f64::consts::FRAC_PI_6;
1252        let inputs = BallisticInputs {
1253            altitude: 100.0,
1254            temperature: 15.0,
1255            pressure: 1013.25,
1256            shooting_angle: angle,
1257            ..BallisticInputs::default()
1258        };
1259        let wind_sock = WindSock::new(vec![]);
1260        let atmosphere = FastAtmosphere::Standard {
1261            base_density: 1.225,
1262        };
1263        let state_along_slant = [1_000.0, 0.0, 0.0, 600.0, 0.0, 0.0];
1264        let state_across_slant = [0.0, 500.0 / angle.cos(), 0.0, 600.0, 0.0, 0.0];
1265
1266        let a = compute_derivatives(
1267            &state_along_slant,
1268            &inputs,
1269            &wind_sock,
1270            atmosphere,
1271            &inputs.bc_type,
1272            crate::transonic_drag::ProjectileShape::Spitzer,
1273            inputs.bc_value,
1274            false,
1275            false,
1276            None,
1277            None,
1278            None,
1279        );
1280        let b = compute_derivatives(
1281            &state_across_slant,
1282            &inputs,
1283            &wind_sock,
1284            atmosphere,
1285            &inputs.bc_type,
1286            crate::transonic_drag::ProjectileShape::Spitzer,
1287            inputs.bc_value,
1288            false,
1289            false,
1290            None,
1291            None,
1292            None,
1293        );
1294
1295        for component in 3..6 {
1296            assert!(
1297                (a[component] - b[component]).abs() < 1e-10,
1298                "fast derivative component {component} differs at equal world altitude: {} vs {}",
1299                a[component],
1300                b[component]
1301            );
1302        }
1303    }
1304
1305    #[test]
1306    fn inclined_headwind_is_rotated_into_solver_frame() {
1307        let angle = std::f64::consts::FRAC_PI_6;
1308        let inputs = BallisticInputs {
1309            shooting_angle: angle,
1310            ..BallisticInputs::default()
1311        };
1312        let speed_mps = 360.0 * (1000.0 / 3600.0);
1313        let level_headwind = Vector3::new(-speed_mps, 0.0, 0.0);
1314        let velocity = expected_shot_frame_vector(level_headwind, angle);
1315        let state = [0.0, 0.0, 0.0, velocity.x, velocity.y, velocity.z];
1316        let actual = compute_derivatives(
1317            &state,
1318            &inputs,
1319            &WindSock::new(vec![crate::wind::WindSegment::new(360.0, 0.0, 1000.0)]),
1320            FastAtmosphere::Direct {
1321                air_density: 1.225,
1322                speed_of_sound: 340.0,
1323            },
1324            &inputs.bc_type,
1325            crate::transonic_drag::ProjectileShape::Spitzer,
1326            inputs.bc_value,
1327            false,
1328            false,
1329            None,
1330            None,
1331            None,
1332        );
1333        let expected = Vector3::new(
1334            -G_ACCEL_MPS2 * angle.sin(),
1335            -G_ACCEL_MPS2 * angle.cos(),
1336            0.0,
1337        );
1338
1339        assert!(
1340            (Vector3::new(actual[3], actual[4], actual[5]) - expected).norm() < 1e-12,
1341            "co-moving horizontal wind must leave only shot-frame gravity: {actual:?}"
1342        );
1343    }
1344
1345    #[test]
1346    fn plain_fast_kernel_applies_power_law_wind_shear() {
1347        let state = [500.0, 100.0, 0.0, 700.0, 0.0, 0.0];
1348        let run = |enable_wind_shear: bool, model: &str, wind_speed_kmh: f64| {
1349            let inputs = BallisticInputs {
1350                bc_value: 0.5,
1351                bc_type: DragModel::G7,
1352                enable_wind_shear,
1353                wind_shear_model: model.to_string(),
1354                ..BallisticInputs::default()
1355            };
1356            let wind_shear_model = enable_wind_shear
1357                .then(|| crate::wind_shear::boundary_layer_model_from_name(model))
1358                .filter(|model| *model != crate::wind_shear::WindShearModel::None);
1359            compute_derivatives(
1360                &state,
1361                &inputs,
1362                &WindSock::new(vec![crate::wind::WindSegment::new(wind_speed_kmh, 90.0, 2_000.0)]),
1363                FastAtmosphere::Direct {
1364                    air_density: 1.225,
1365                    speed_of_sound: 340.0,
1366                },
1367                &inputs.bc_type,
1368                crate::transonic_drag::ProjectileShape::Spitzer,
1369                inputs.bc_value,
1370                false,
1371                false,
1372                None,
1373                wind_shear_model,
1374                None,
1375            )
1376        };
1377
1378        let uniform = run(false, "power_law", 36.0);
1379        let model_none = run(true, "none", 36.0);
1380        assert_eq!(model_none, uniform, "model=none must preserve uniform wind");
1381
1382        let sheared = run(true, "power_law", 36.0);
1383        assert!(
1384            sheared[5] < uniform[5],
1385            "stronger aloft crosswind must increase leftward acceleration: uniform={}, shear={}",
1386            uniform[5],
1387            sheared[5]
1388        );
1389
1390        let ratio = crate::wind_shear::boundary_layer_speed_ratio(
1391            state[1],
1392            crate::wind_shear::WindShearModel::PowerLaw,
1393        );
1394        let equivalent_uniform = run(false, "none", 36.0 * ratio);
1395        for component in 3..6 {
1396            assert!(
1397                (sheared[component] - equivalent_uniform[component]).abs() < 1e-12,
1398                "shear component {component} must equal base wind scaled by {ratio}: shear={}, expected={}",
1399                sheared[component],
1400                equivalent_uniform[component]
1401            );
1402        }
1403    }
1404
1405    #[test]
1406    fn plain_fast_path_wind_shear_changes_high_arc_drift() {
1407        let run = |enable_wind_shear: bool, model: &str| {
1408            let inputs = BallisticInputs {
1409                muzzle_velocity: 800.0,
1410                bc_value: 0.5,
1411                bc_type: DragModel::G7,
1412                bullet_mass: 168.0 * 0.00006479891,
1413                bullet_diameter: 0.308 * 0.0254,
1414                enable_wind_shear,
1415                wind_shear_model: model.to_string(),
1416                ground_threshold: -100.0,
1417                ..BallisticInputs::default()
1418            };
1419            let elevation = 0.12_f64;
1420            let solution = fast_integrate(
1421                &inputs,
1422                &WindSock::new(vec![crate::wind::WindSegment::new(36.0, 90.0, 2_000.0)]),
1423                FastIntegrationParams {
1424                    horiz: 1_000.0,
1425                    vert: 0.0,
1426                    initial_state: [
1427                        0.0,
1428                        0.0,
1429                        0.0,
1430                        inputs.muzzle_velocity * elevation.cos(),
1431                        inputs.muzzle_velocity * elevation.sin(),
1432                        0.0,
1433                    ],
1434                    t_span: (0.0, 5.0),
1435                    atmo_params: (0.0, 15.0, 1013.25, 1.0),
1436                    atmo_sock: None,
1437                },
1438            );
1439            let last = solution.t.len() - 1;
1440            assert_eq!(solution.y[0][last].to_bits(), 1_000.0_f64.to_bits());
1441            solution.y[2][last]
1442        };
1443
1444        let uniform = run(false, "power_law");
1445        let model_none = run(true, "none");
1446        assert_eq!(model_none.to_bits(), uniform.to_bits());
1447        let sheared = run(true, "power_law");
1448        assert!(
1449            sheared.abs() > uniform.abs() + 0.01,
1450            "aloft shear must increase drift magnitude: uniform={uniform}, shear={sheared}"
1451        );
1452    }
1453
1454    #[test]
1455    fn inclined_coriolis_is_rotated_into_solver_frame() {
1456        let angle = std::f64::consts::FRAC_PI_6;
1457        let inputs = BallisticInputs {
1458            shooting_angle: angle,
1459            ..BallisticInputs::default()
1460        };
1461        let velocity = Vector3::new(600.0, 20.0, 5.0);
1462        let state = [0.0, 0.0, 0.0, velocity.x, velocity.y, velocity.z];
1463        let level_omega = Vector3::new(3.0e-5, 6.0e-5, -2.0e-5);
1464        let run = |omega| {
1465            compute_derivatives(
1466                &state,
1467                &inputs,
1468                &WindSock::new(vec![]),
1469                FastAtmosphere::Direct {
1470                    air_density: 1.225,
1471                    speed_of_sound: 340.0,
1472                },
1473                &inputs.bc_type,
1474                crate::transonic_drag::ProjectileShape::Spitzer,
1475                inputs.bc_value,
1476                false,
1477                false,
1478                omega,
1479                None,
1480                None,
1481            )
1482        };
1483        let baseline = run(None);
1484        let with_coriolis = run(Some(level_omega));
1485        let actual = Vector3::new(
1486            with_coriolis[3] - baseline[3],
1487            with_coriolis[4] - baseline[4],
1488            with_coriolis[5] - baseline[5],
1489        );
1490        let expected = -2.0 * expected_shot_frame_vector(level_omega, angle).cross(&velocity);
1491
1492        assert!(
1493            (actual - expected).norm() < 1e-12,
1494            "inclined Coriolis mismatch: actual={actual:?}, expected={expected:?}"
1495        );
1496    }
1497
1498    #[test]
1499    fn test_fast_solution_interpolation() {
1500        let times = vec![0.0, 1.0, 2.0];
1501        let states = vec![
1502            [0.0, 0.0, 0.0, 100.0, 50.0, 0.0],
1503            [100.0, 45.0, 0.0, 99.0, 40.0, 0.0],
1504            [198.0, 80.0, 0.0, 98.0, 30.0, 0.0],
1505        ];
1506
1507        let solution = FastSolution::from_trajectory_data(times, states, [vec![], vec![], vec![]]);
1508
1509        // Test interpolation at t=1.5
1510        let result = solution.sol(&[1.5]);
1511
1512        assert!((result[0][0] - 149.0).abs() < 1e-10); // x position
1513        assert!((result[1][0] - 62.5).abs() < 1e-10); // y position
1514        assert!((result[3][0] - 98.5).abs() < 1e-10); // vx velocity
1515    }
1516
1517    #[test]
1518    fn test_bc_from_velocity_segments() {
1519        let segments = vec![
1520            BCSegmentData {
1521                velocity_min: 0.0,
1522                velocity_max: 1000.0,
1523                bc_value: 0.5,
1524            },
1525            BCSegmentData {
1526                velocity_min: 1000.0,
1527                velocity_max: 2000.0,
1528                bc_value: 0.52,
1529            },
1530            BCSegmentData {
1531                velocity_min: 2000.0,
1532                velocity_max: 3000.0,
1533                bc_value: 0.55,
1534            },
1535        ];
1536
1537        assert_eq!(velocity_segment_bc(500.0, &segments, 0.5), 0.5);
1538        assert_eq!(velocity_segment_bc(1500.0, &segments, 0.5), 0.52);
1539        assert_eq!(velocity_segment_bc(2500.0, &segments, 0.5), 0.55);
1540
1541        // Test edge cases
1542        assert_eq!(velocity_segment_bc(-100.0, &segments, 0.5), 0.5); // Below min
1543        assert_eq!(velocity_segment_bc(3500.0, &segments, 0.5), 0.55); // Above max
1544    }
1545
1546    #[test]
1547    fn test_fast_solution_interpolation_edge_cases() {
1548        let times = vec![0.0, 1.0, 2.0, 3.0];
1549        let states = vec![
1550            [0.0, 0.0, 0.0, 800.0, 50.0, 0.0],
1551            [800.0, 40.0, 100.0, 750.0, 30.0, 0.0],
1552            [1550.0, 60.0, 200.0, 700.0, 10.0, 0.0],
1553            [2250.0, 50.0, 300.0, 650.0, -10.0, 0.0],
1554        ];
1555
1556        let solution = FastSolution::from_trajectory_data(times, states, [vec![], vec![], vec![]]);
1557
1558        // Test interpolation before first point
1559        let result_before = solution.sol(&[-0.5]);
1560        assert!((result_before[0][0] - 0.0).abs() < 1e-10); // Should clamp to first
1561
1562        // Test interpolation after last point
1563        let result_after = solution.sol(&[5.0]);
1564        assert!((result_after[0][0] - 2250.0).abs() < 1e-10); // Should clamp to last
1565
1566        // Test interpolation at exact points
1567        let result_exact = solution.sol(&[1.0]);
1568        assert!((result_exact[0][0] - 800.0).abs() < 1e-10);
1569
1570        // Test multiple query points
1571        let result_multi = solution.sol(&[0.5, 1.5, 2.5]);
1572        assert_eq!(result_multi[0].len(), 3);
1573    }
1574
1575    #[test]
1576    fn test_fast_solution_from_trajectory_data() {
1577        let times = vec![0.0, 0.5, 1.0];
1578        let states = vec![
1579            [0.0, 1.0, 2.0, 3.0, 4.0, 5.0],
1580            [10.0, 11.0, 12.0, 13.0, 14.0, 15.0],
1581            [20.0, 21.0, 22.0, 23.0, 24.0, 25.0],
1582        ];
1583        let t_events = [vec![1.0], vec![0.5], vec![]];
1584
1585        let solution = FastSolution::from_trajectory_data(times.clone(), states, t_events);
1586
1587        // Check that data is stored correctly
1588        assert_eq!(solution.t, times);
1589        assert_eq!(solution.y.len(), 6); // 6 state components
1590        assert_eq!(solution.y[0].len(), 3); // 3 time points
1591        assert!(solution.success);
1592
1593        // Verify column-major storage
1594        assert_eq!(solution.y[0][0], 0.0); // x at t=0
1595        assert_eq!(solution.y[1][0], 1.0); // y at t=0
1596        assert_eq!(solution.y[0][2], 20.0); // x at t=1.0
1597    }
1598
1599    #[test]
1600    fn test_bc_segments_boundary_conditions() {
1601        // Test with single segment
1602        let single_segment = vec![BCSegmentData {
1603            velocity_min: 1000.0,
1604            velocity_max: 2000.0,
1605            bc_value: 0.5,
1606        }];
1607
1608        assert_eq!(velocity_segment_bc(500.0, &single_segment, 0.5), 0.5); // Below
1609        assert_eq!(velocity_segment_bc(1500.0, &single_segment, 0.5), 0.5); // In range
1610        assert_eq!(velocity_segment_bc(2500.0, &single_segment, 0.5), 0.5); // Above
1611
1612        // Test with exact boundary values
1613        // Half-open bands make a shared boundary belong to the band that starts there.
1614        let segments = vec![
1615            BCSegmentData {
1616                velocity_min: 0.0,
1617                velocity_max: 999.0, // Exclusive upper bound to avoid overlap
1618                bc_value: 0.45,
1619            },
1620            BCSegmentData {
1621                velocity_min: 1000.0,
1622                velocity_max: 2000.0,
1623                bc_value: 0.50,
1624            },
1625        ];
1626
1627        assert_eq!(velocity_segment_bc(1000.0, &segments, 0.7), 0.50); // At second segment start
1628        assert_eq!(velocity_segment_bc(0.0, &segments, 0.7), 0.45); // At min
1629        assert_eq!(velocity_segment_bc(998.999, &segments, 0.7), 0.45); // Just below exclusive max
1630        assert_eq!(velocity_segment_bc(999.0, &segments, 0.7), 0.7); // Gap starts at exclusive max
1631    }
1632
1633    #[test]
1634    fn velocity_segment_gaps_and_clamps_do_not_depend_on_order() {
1635        let fallback_bc = 0.73;
1636        let ascending_with_gap = vec![
1637            BCSegmentData {
1638                velocity_min: 0.0,
1639                velocity_max: 999.0,
1640                bc_value: 0.6,
1641            },
1642            BCSegmentData {
1643                velocity_min: 1000.0,
1644                velocity_max: 2000.0,
1645                bc_value: 0.8,
1646            },
1647        ];
1648        assert_eq!(
1649            velocity_segment_bc(999.5, &ascending_with_gap, fallback_bc),
1650            fallback_bc,
1651            "coverage gaps must use the projectile's base BC"
1652        );
1653
1654        let mut descending = ascending_with_gap.clone();
1655        descending.reverse();
1656        assert_eq!(
1657            velocity_segment_bc(-100.0, &descending, fallback_bc),
1658            0.6,
1659            "below coverage must clamp to the lowest-velocity band"
1660        );
1661        assert_eq!(
1662            velocity_segment_bc(2500.0, &descending, fallback_bc),
1663            0.8,
1664            "above coverage must clamp to the highest-velocity band"
1665        );
1666    }
1667
1668    #[test]
1669    fn test_bc_segments_empty_fallback() {
1670        let empty_segments: Vec<BCSegmentData> = vec![];
1671
1672        // With empty segments, should return fallback value
1673        let result = velocity_segment_bc(1500.0, &empty_segments, 0.73);
1674        assert_eq!(result, 0.73); // Caller-provided fallback value
1675    }
1676
1677    #[test]
1678    fn test_fast_integration_params() {
1679        // Verify FastIntegrationParams struct can be constructed
1680        let params = FastIntegrationParams {
1681            horiz: 1000.0,
1682            vert: 0.0,
1683            initial_state: [0.0, 0.0, 0.0, 800.0, 50.0, 0.0], // McCoy: vx=downrange
1684            t_span: (0.0, 5.0),
1685            atmo_params: (0.0, 15.0, 1013.25, 1.0),
1686            atmo_sock: None,
1687        };
1688
1689        assert_eq!(params.horiz, 1000.0);
1690        assert_eq!(params.t_span.0, 0.0);
1691        assert_eq!(params.t_span.1, 5.0);
1692        assert_eq!(params.initial_state[3], 800.0); // vx (downrange, McCoy)
1693    }
1694
1695    #[test]
1696    fn test_fast_solution_event_arrays() {
1697        let times = vec![0.0, 1.0, 2.0];
1698        let states = vec![
1699            [0.0, 0.0, 0.0, 800.0, 50.0, 0.0],
1700            [800.0, 40.0, 500.0, 750.0, 30.0, 0.0],
1701            [1500.0, 20.0, 1000.0, 700.0, 10.0, 0.0],
1702        ];
1703
1704        // Create solution with events
1705        let t_events = [
1706            vec![2.0], // target_hit at t=2
1707            vec![0.5], // max_ord at t=0.5
1708            vec![],    // no ground_hit
1709        ];
1710
1711        let solution = FastSolution::from_trajectory_data(times, states, t_events);
1712
1713        assert_eq!(solution.t_events[0], vec![2.0]); // Target hit
1714        assert_eq!(solution.t_events[1], vec![0.5]); // Max ordinate
1715        assert!(solution.t_events[2].is_empty()); // No ground hit
1716    }
1717
1718    #[test]
1719    fn segmented_fast_path_interpolates_max_ordinate_between_saved_points() {
1720        let expected_apex_time = 5.105_f64;
1721        let downrange_velocity = 100.0_f64;
1722        let vertical_velocity = G_ACCEL_MPS2 * expected_apex_time;
1723        let inputs = BallisticInputs {
1724            muzzle_velocity: downrange_velocity.hypot(vertical_velocity),
1725            bc_value: 0.5,
1726            bc_type: DragModel::G7,
1727            use_enhanced_spin_drift: false,
1728            ..BallisticInputs::default()
1729        };
1730        let solution = fast_integrate_with_segments(
1731            &inputs,
1732            vec![],
1733            FastIntegrationParams {
1734                horiz: 1_000.0,
1735                vert: 0.0,
1736                initial_state: [0.0, 0.0, 0.0, downrange_velocity, vertical_velocity, 0.0],
1737                t_span: (0.0, 12.0),
1738                // Negligible density makes this an analytic constant-gravity arc while retaining
1739                // the valid direct-atmosphere sentinel.
1740                atmo_params: (1e-12, 340.0, 0.0, 0.0),
1741                atmo_sock: None,
1742            },
1743        );
1744
1745        assert!(solution.success);
1746        assert_eq!(solution.t_events[1].len(), 1);
1747        let reported_time = solution.t_events[1][0];
1748        assert!(
1749            (reported_time - expected_apex_time).abs() < 0.006,
1750            "max-ordinate time must be interpolated between coarse saves: reported={reported_time} expected={expected_apex_time}"
1751        );
1752        let event_index = solution
1753            .t
1754            .iter()
1755            .position(|time| time.to_bits() == reported_time.to_bits())
1756            .expect("the interpolated apex must be retained in the solution");
1757        assert_eq!(solution.y[4][event_index].to_bits(), 0.0_f64.to_bits());
1758
1759        let expected_height = vertical_velocity * expected_apex_time
1760            - 0.5 * G_ACCEL_MPS2 * expected_apex_time.powi(2);
1761        let event_state = solution.sol(&[reported_time]);
1762        assert!(
1763            (event_state[1][0] - expected_height).abs() < 2e-4,
1764            "max-ordinate state must preserve the interpolated apex height: reported={} expected={expected_height}",
1765            event_state[1][0]
1766        );
1767    }
1768
1769    #[test]
1770    fn plain_fast_path_interpolates_the_target_crossing() {
1771        let target = 500.123456789;
1772        let initial_state = [0.0, 0.0, 0.25, 800.0, 12.0, -2.5];
1773        let inputs = BallisticInputs {
1774            muzzle_velocity: 800.0,
1775            bc_value: 0.5,
1776            bc_type: DragModel::G7,
1777            ground_threshold: -100.0,
1778            use_enhanced_spin_drift: false,
1779            ..BallisticInputs::default()
1780        };
1781        let run = |horiz| {
1782            fast_integrate(
1783                &inputs,
1784                &WindSock::new(vec![]),
1785                FastIntegrationParams {
1786                    horiz,
1787                    vert: 0.0,
1788                    initial_state,
1789                    t_span: (0.0, 2.0),
1790                    atmo_params: (0.0, 15.0, 1013.25, 1.0),
1791                    atmo_sock: None,
1792                },
1793            )
1794        };
1795
1796        // A longer run retains the two full RK4 samples bracketing the requested target.
1797        let reference = run(target + 2.0);
1798        let left = reference.y[0]
1799            .windows(2)
1800            .position(|x| x[0] < target && x[1] > target)
1801            .expect("reference trajectory must bracket target");
1802        let right = left + 1;
1803        let alpha =
1804            (target - reference.y[0][left]) / (reference.y[0][right] - reference.y[0][left]);
1805
1806        let solution = run(target);
1807        let last = solution.t.len() - 1;
1808        assert_eq!(solution.y[0][last].to_bits(), target.to_bits());
1809        let expected_time = reference.t[left] + alpha * (reference.t[right] - reference.t[left]);
1810        assert!((solution.t[last] - expected_time).abs() < 1e-12);
1811        assert_eq!(solution.t_events[0], vec![solution.t[last]]);
1812
1813        for component in 0..6 {
1814            assert_eq!(solution.y[component].len(), solution.t.len());
1815            let expected = reference.y[component][left]
1816                + alpha * (reference.y[component][right] - reference.y[component][left]);
1817            assert!(
1818                (solution.y[component][last] - expected).abs() < 1e-9,
1819                "component {component} is not at the crossing: actual={}, expected={expected}",
1820                solution.y[component][last]
1821            );
1822        }
1823    }
1824
1825    #[test]
1826    fn fast_path_coriolis_uses_shot_direction() {
1827        // Regression: fast_integrate_with_segments (the path the Python binding uses)
1828        // built its Coriolis omega from azimuth_angle (the aiming offset, always ~0)
1829        // instead of shot_azimuth, so east and west shots came out identical. After the
1830        // fix they must differ with the correct Eotvos sign (east lifted, higher).
1831        use std::f64::consts::FRAC_PI_2;
1832        // Returns (final_downrange, final_vertical) for a shot fired along `shot_az`.
1833        fn final_xy(shot_az: f64) -> (f64, f64) {
1834            let inputs = BallisticInputs {
1835                muzzle_velocity: 800.0,
1836                bc_value: 0.5,
1837                bc_type: DragModel::G7,
1838                enable_advanced_effects: true, // gates the omega vector
1839                enable_coriolis: true,
1840                latitude: Some(45.0),
1841                shot_azimuth: shot_az,
1842                ..BallisticInputs::default()
1843            };
1844            let v = 800.0_f64;
1845            let elev = 0.02_f64;
1846            let params = FastIntegrationParams {
1847                horiz: 1000.0,
1848                vert: 0.0,
1849                initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
1850                t_span: (0.0, 5.0),
1851                atmo_params: (0.0, 15.0, 1013.25, 1.0),
1852                atmo_sock: None,
1853            };
1854            let sol = fast_integrate_with_segments(&inputs, vec![], params);
1855            let n = sol.y[0].len();
1856            (sol.y[0][n - 1], sol.y[1][n - 1])
1857        }
1858        let (ex, ey) = final_xy(FRAC_PI_2); // east
1859        let (wx, wy) = final_xy(3.0 * FRAC_PI_2); // west
1860                                                  // Both shots cover essentially the same downrange (Coriolis barely affects x),
1861                                                  // so comparing the final vertical is apples-to-apples.
1862        assert!(
1863            (ex - wx).abs() < 0.5,
1864            "east/west downrange should be ~equal (ex={ex:.4}, wx={wx:.4})"
1865        );
1866        // Pre-fix the fast path was North-locked, making these byte-identical. The Eotvos
1867        // term now lifts the east shot above the west shot.
1868        assert!(
1869            ey > wy,
1870            "fast-path east ({ey:.6}) must be higher than west ({wy:.6}) (Eotvos)"
1871        );
1872        assert!(
1873            (ey - wy) > 1e-5,
1874            "fast-path E-W vertical separation ({:.8} m) should be non-zero (the pre-fix bug was exact equality)",
1875            ey - wy
1876        );
1877    }
1878
1879    #[test]
1880    fn fast_path_coriolis_independent_of_advanced_effects() {
1881        // Coriolis is now gated on enable_coriolis (+ latitude), NOT enable_advanced_effects.
1882        // So a caller can request Coriolis-only without being forced to enable spin/Magnus.
1883        use std::f64::consts::FRAC_PI_2;
1884        fn final_y(coriolis: bool, shot_az: f64) -> f64 {
1885            let inputs = BallisticInputs {
1886                muzzle_velocity: 800.0,
1887                bc_value: 0.5,
1888                bc_type: DragModel::G7,
1889                enable_coriolis: coriolis,
1890                enable_advanced_effects: false, // explicitly OFF — Coriolis must still work
1891                latitude: Some(45.0),
1892                shot_azimuth: shot_az,
1893                ..BallisticInputs::default()
1894            };
1895            let v = 800.0_f64;
1896            let elev = 0.02_f64;
1897            let params = FastIntegrationParams {
1898                horiz: 1000.0,
1899                vert: 0.0,
1900                initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
1901                t_span: (0.0, 5.0),
1902                atmo_params: (0.0, 15.0, 1013.25, 1.0),
1903                atmo_sock: None,
1904            };
1905            let sol = fast_integrate_with_segments(&inputs, vec![], params);
1906            let n = sol.y[0].len();
1907            sol.y[1][n - 1]
1908        }
1909        // enable_coriolis=true with advanced effects OFF: directional Coriolis still applies.
1910        let e = final_y(true, FRAC_PI_2);
1911        let w = final_y(true, 3.0 * FRAC_PI_2);
1912        assert!(
1913            e > w && (e - w) > 1e-5,
1914            "Coriolis-only (no advanced effects) must still be directional: E={e} W={w}"
1915        );
1916        // enable_coriolis=false: no Coriolis at all, east == west.
1917        let e2 = final_y(false, FRAC_PI_2);
1918        let w2 = final_y(false, 3.0 * FRAC_PI_2);
1919        assert!(
1920            (e2 - w2).abs() < 1e-9,
1921            "with enable_coriolis=false, east/west must be identical: E={e2} W={w2}"
1922        );
1923    }
1924
1925    #[test]
1926    fn fast_path_rejects_degenerate_atmosphere() {
1927        let inputs = BallisticInputs {
1928            muzzle_velocity: 800.0,
1929            bc_value: 0.5,
1930            bc_type: DragModel::G7,
1931            ..BallisticInputs::default()
1932        };
1933        let v = 800.0_f64;
1934        let e = 0.02_f64;
1935        let mk = |atmo: (f64, f64, f64, f64)| FastIntegrationParams {
1936            horiz: 500.0,
1937            vert: 0.0,
1938            initial_state: [0.0, 0.0, 0.0, v * e.cos(), v * e.sin(), 0.0],
1939            t_span: (0.0, 5.0),
1940            atmo_params: atmo,
1941            atmo_sock: None,
1942        };
1943        // pressure <= 0 -> fail loudly (success=false) instead of a 1-point stub as success.
1944        let zero_p = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 0.0, 1.0)));
1945        assert!(
1946            !zero_p.success,
1947            "pressure=0 atmosphere must yield success=false"
1948        );
1949        // non-finite pressure -> also rejected.
1950        let nan_p = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, f64::NAN, 1.0)));
1951        assert!(!nan_p.success, "NaN pressure must yield success=false");
1952        // A supplied density ratio must imply a physically plausible density in both wrappers.
1953        let segmented_too_dense =
1954            fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 1013.25, 50.0)));
1955        let plain_too_dense = fast_integrate(
1956            &inputs,
1957            &WindSock::new(vec![]),
1958            mk((0.0, 15.0, 1013.25, 50.0)),
1959        );
1960        assert!(
1961            !segmented_too_dense.success && !plain_too_dense.success,
1962            "ratio=50 atmosphere must fail in both wrappers: segmented={}, plain={}",
1963            segmented_too_dense.success,
1964            plain_too_dense.success
1965        );
1966        // realistic atmosphere -> success.
1967        let good = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 1013.25, 1.0)));
1968        assert!(good.success, "realistic atmosphere must yield success=true");
1969        // Direct-atmosphere mode (density, speed_of_sound, 0, 0) is legitimate and must NOT
1970        // be rejected by the guard (regression: 0.21.2 rejected it via the pressure<=0 check).
1971        let direct = fast_integrate_with_segments(&inputs, vec![], mk((1.225, 340.0, 0.0, 0.0)));
1972        assert!(
1973            direct.success,
1974            "direct-atmosphere mode (pressure=0 sentinel) must yield success=true"
1975        );
1976    }
1977
1978    #[test]
1979    fn plain_fast_path_honors_direct_atmosphere_values() {
1980        fn final_speed(muzzle_velocity: f64, atmo_params: (f64, f64, f64, f64)) -> f64 {
1981            let inputs = BallisticInputs {
1982                muzzle_velocity,
1983                bc_value: 0.5,
1984                bc_type: DragModel::G7,
1985                ground_threshold: -100.0,
1986                ..BallisticInputs::default()
1987            };
1988
1989            let wind_sock = WindSock::new(vec![]);
1990            let solution = fast_integrate(
1991                &inputs,
1992                &wind_sock,
1993                FastIntegrationParams {
1994                    horiz: 10_000.0,
1995                    vert: 0.0,
1996                    initial_state: [0.0, 0.0, 0.0, muzzle_velocity, 0.0, 0.0],
1997                    t_span: (0.0, 0.2),
1998                    atmo_params,
1999                    atmo_sock: None,
2000                },
2001            );
2002            assert!(solution.success);
2003
2004            let last = solution.y[0].len() - 1;
2005            (solution.y[3][last].powi(2)
2006                + solution.y[4][last].powi(2)
2007                + solution.y[5][last].powi(2))
2008            .sqrt()
2009        }
2010
2011        let thin_air = final_speed(800.0, (0.905, 340.0, 0.0, 0.0));
2012        let dense_air = final_speed(800.0, (1.225, 340.0, 0.0, 0.0));
2013        assert!(
2014            thin_air > dense_air,
2015            "lower supplied density must retain more velocity: thin={thin_air}, dense={dense_air}"
2016        );
2017
2018        let low_sound_speed = final_speed(340.0, (1.0, 300.0, 0.0, 0.0));
2019        let high_sound_speed = final_speed(340.0, (1.0, 400.0, 0.0, 0.0));
2020        assert!(
2021            (low_sound_speed - high_sound_speed).abs() > 1e-6,
2022            "supplied sound speed must affect Mach-dependent drag"
2023        );
2024    }
2025
2026    #[test]
2027    fn segmented_fast_path_nonpositive_density_ratio_uses_standard_fallback() {
2028        fn terminal_velocity(base_ratio: f64) -> f64 {
2029            let inputs = BallisticInputs {
2030                muzzle_velocity: 800.0,
2031                bc_value: 0.5,
2032                bc_type: DragModel::G7,
2033                ground_threshold: -100.0,
2034                ..BallisticInputs::default()
2035            };
2036
2037            let solution = fast_integrate_with_segments(
2038                &inputs,
2039                vec![],
2040                FastIntegrationParams {
2041                    horiz: 500.0,
2042                    vert: 0.0,
2043                    initial_state: [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
2044                    t_span: (0.0, 5.0),
2045                    atmo_params: (0.0, 15.0, 1013.25, base_ratio),
2046                    atmo_sock: None,
2047                },
2048            );
2049            assert!(solution.success);
2050
2051            let last = solution.y[3].len() - 1;
2052            solution.y[3][last]
2053        }
2054
2055        let explicit_sea_level = terminal_velocity(1.0);
2056        for base_ratio in [0.0, -1.0] {
2057            let missing_ratio = terminal_velocity(base_ratio);
2058            assert!(
2059                missing_ratio < 800.0,
2060                "missing density ratio must not create a vacuum trajectory: {missing_ratio}"
2061            );
2062            assert!((missing_ratio - explicit_sea_level).abs() < 1e-9);
2063        }
2064    }
2065
2066    #[test]
2067    fn fast_path_carries_real_bullet_geometry() {
2068        // MBA-717: build_inputs hardcoded diameter=.308 / length=1.24in / twist=10 because
2069        // TrajectoryParams didn't carry them. They're now plumbed through, so the BallisticInputs
2070        // the derivatives see reflect the real bullet (caliber gates the Magnus block at
2071        // bullet_diameter > 0.0). Guard against regressing back to the hardcoded values: a
2072        // zero-diameter input must reach the derivatives as zero (Magnus skipped), whereas the
2073        // old code would have forced .308 regardless. We assert the run still completes and the
2074        // two geometries don't crash — the data path is exercised end-to-end.
2075        let run = |diameter: f64, twist: f64| {
2076            let inputs = BallisticInputs {
2077                muzzle_velocity: 800.0,
2078                bc_value: 0.5,
2079                bc_type: DragModel::G7,
2080                bullet_diameter: diameter,
2081                bullet_length: 0.0318,
2082                twist_rate: twist,
2083                enable_advanced_effects: true,
2084                enable_magnus: true,
2085                ..BallisticInputs::default()
2086            };
2087            let v = 800.0_f64;
2088            let elev = 0.02_f64;
2089            let params = FastIntegrationParams {
2090                horiz: 1000.0,
2091                vert: 0.0,
2092                initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
2093                t_span: (0.0, 5.0),
2094                atmo_params: (0.0, 15.0, 1013.25, 1.0),
2095                atmo_sock: None,
2096            };
2097            fast_integrate_with_segments(&inputs, vec![], params)
2098        };
2099        // Both a real .224 and a real .338 produce a valid trajectory (geometry is honored, not
2100        // overridden by a hardcoded .308). Regression sentinel for the plumbing.
2101        assert!(run(0.00569, 7.0).success, ".224 geometry must solve");
2102        assert!(run(0.00858, 10.0).success, ".338 geometry must solve");
2103    }
2104
2105    #[test]
2106    fn segmented_fast_spin_flags_do_not_depend_on_advanced_umbrella() {
2107        fn endpoint(
2108            enable_advanced_effects: bool,
2109            enable_magnus: bool,
2110            use_enhanced_spin_drift: bool,
2111        ) -> Vector3<f64> {
2112            let inputs = BallisticInputs {
2113                muzzle_velocity: 823.0,
2114                bullet_mass: 168.0 * 0.00006479891,
2115                bullet_diameter: 0.308 * 0.0254,
2116                bullet_length: 1.215 * 0.0254,
2117                caliber_inches: 0.308,
2118                weight_grains: 168.0,
2119                bc_value: 0.475,
2120                bc_type: DragModel::G1,
2121                twist_rate: 12.0,
2122                is_twist_right: true,
2123                enable_advanced_effects,
2124                enable_magnus,
2125                use_enhanced_spin_drift,
2126                ..BallisticInputs::default()
2127            };
2128            let elevation = 0.02_f64;
2129            let solution = fast_integrate_with_segments(
2130                &inputs,
2131                vec![],
2132                FastIntegrationParams {
2133                    horiz: 1_000.0,
2134                    vert: 0.0,
2135                    initial_state: [
2136                        0.0,
2137                        0.0,
2138                        0.0,
2139                        inputs.muzzle_velocity * elevation.cos(),
2140                        inputs.muzzle_velocity * elevation.sin(),
2141                        0.0,
2142                    ],
2143                    t_span: (0.0, 5.0),
2144                    atmo_params: (0.0, 15.0, 1013.25, 1.0),
2145                    atmo_sock: None,
2146                },
2147            );
2148            assert!(solution.success);
2149            let last = solution.t.len() - 1;
2150            Vector3::new(
2151                solution.y[0][last],
2152                solution.y[1][last],
2153                solution.y[2][last],
2154            )
2155        }
2156
2157        let baseline = endpoint(false, false, false);
2158        let magnus_without_umbrella = endpoint(false, true, false);
2159        let magnus_with_umbrella = endpoint(true, true, false);
2160        assert!(
2161            (magnus_without_umbrella - baseline).norm() > 1e-5,
2162            "test shot must produce a measurable Magnus displacement"
2163        );
2164        assert!(
2165            (magnus_with_umbrella - magnus_without_umbrella).norm() < 1e-12,
2166            "the legacy umbrella must not suppress explicitly enabled Magnus: without={magnus_without_umbrella:?} with={magnus_with_umbrella:?}"
2167        );
2168
2169        let litz_only = endpoint(false, false, true);
2170        let litz_without_umbrella = endpoint(false, true, true);
2171        let litz_with_umbrella = endpoint(true, true, true);
2172        assert!(
2173            (litz_without_umbrella - litz_only).norm() < 1e-12,
2174            "Litz mode must suppress explicitly enabled Magnus: litz={litz_only:?} both={litz_without_umbrella:?}"
2175        );
2176        assert!(
2177            (litz_with_umbrella - litz_without_umbrella).norm() < 1e-12,
2178            "the legacy umbrella must not change Magnus suppression in Litz mode: without={litz_without_umbrella:?} with={litz_with_umbrella:?}"
2179        );
2180    }
2181}