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