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
6#[cfg(test)]
7use crate::constants::G_ACCEL_MPS2;
8use crate::gravity::Gravity;
9use crate::{
10    atmosphere::{calculate_air_density_cimp, get_local_atmosphere_humid_with_gravity, AtmoSock},
11    bc_estimation::velocity_segment_bc,
12    constants::{MPS_TO_FPS, STANDARD_AIR_DENSITY},
13    drag::get_drag_coefficient,
14    wind::WindSock,
15    DragModel, InternalBallisticInputs as BallisticInputs,
16};
17use nalgebra::Vector3;
18
19/// Fast solution container matching Python implementation
20#[derive(Debug, Clone)]
21pub struct FastSolution {
22    /// Time points
23    pub t: Vec<f64>,
24    /// State vectors at each time point [6 x n_points]
25    pub y: Vec<Vec<f64>>,
26    /// Event times [target_hit, max_ord, ground_hit]
27    pub t_events: [Vec<f64>; 3],
28    /// Whether integration succeeded
29    pub success: bool,
30}
31
32impl FastSolution {
33    /// Interpolate solution at time t
34    pub fn sol(&self, t_query: &[f64]) -> Vec<Vec<f64>> {
35        let mut result = vec![vec![0.0; t_query.len()]; 6];
36
37        for (i, &tq) in t_query.iter().enumerate() {
38            // Find the right interval using binary search
39            // Use unwrap_or to safely handle NaN values by treating them as greater
40            let idx = match self
41                .t
42                .binary_search_by(|&t| t.partial_cmp(&tq).unwrap_or(std::cmp::Ordering::Greater))
43            {
44                Ok(idx) => idx,
45                Err(idx) => idx,
46            };
47
48            if idx == 0 {
49                // Before first point
50                for (result_component, source_component) in result.iter_mut().zip(&self.y) {
51                    result_component[i] = source_component[0];
52                }
53            } else if idx >= self.t.len() {
54                // After last point
55                for (result_component, source_component) in result.iter_mut().zip(&self.y) {
56                    result_component[i] = source_component[self.t.len() - 1];
57                }
58            } else {
59                // Linear interpolation
60                let t0 = self.t[idx - 1];
61                let t1 = self.t[idx];
62                let span = t1 - t0;
63
64                for (result_component, source_component) in result.iter_mut().zip(&self.y) {
65                    let y0 = source_component[idx - 1];
66                    let y1 = source_component[idx];
67                    result_component[i] = if span.abs() < f64::EPSILON {
68                        y1
69                    } else {
70                        let frac = (tq - t0) / span;
71                        y0 + frac * (y1 - y0)
72                    };
73                }
74            }
75        }
76
77        result
78    }
79
80    /// Convert from row-major to column-major format for compatibility
81    pub fn from_trajectory_data(
82        times: Vec<f64>,
83        states: Vec<[f64; 6]>,
84        t_events: [Vec<f64>; 3],
85    ) -> Self {
86        let n_points = times.len();
87        let mut y = vec![vec![0.0; n_points]; 6];
88
89        for (i, state) in states.iter().enumerate() {
90            for j in 0..6 {
91                y[j][i] = state[j];
92            }
93        }
94
95        FastSolution {
96            t: times,
97            y,
98            t_events,
99            success: true,
100        }
101    }
102
103    /// A clearly-failed solution carrying only the launch state, with `success = false`.
104    /// Used when inputs are too degenerate to integrate (e.g. non-physical atmosphere),
105    /// so callers see `success = false` instead of a stub trajectory reported as success.
106    fn degenerate(initial_state: &[f64; 6]) -> Self {
107        let mut y = vec![Vec::new(); 6];
108        for (j, slot) in y.iter_mut().enumerate() {
109            slot.push(initial_state[j]);
110        }
111        FastSolution {
112            t: vec![0.0],
113            y,
114            t_events: [Vec::new(), Vec::new(), Vec::new()],
115            success: false,
116        }
117    }
118}
119
120fn direct_atmosphere_values(atmo_params: (f64, f64, f64, f64)) -> Option<(f64, f64)> {
121    let (a, b, c, d) = atmo_params;
122    (a.is_finite() && b.is_finite() && c == 0.0 && d == 0.0 && a > 0.0 && a < 2.0 && b > 200.0)
123        .then_some((a, b))
124}
125
126fn stability_atmosphere_params(atmo_params: (f64, f64, f64, f64)) -> (f64, f64, f64, f64) {
127    if let Some((air_density, _)) = direct_atmosphere_values(atmo_params) {
128        // compute_stability_coefficient consumes standard-mode temperature/pressure, whose
129        // Miller correction is rho_ref/rho. At the 15 C reference temperature, scaling pressure
130        // by rho/rho_ref supplies that exact correction without misreading sound speed as temp.
131        (0.0, 15.0, 1013.25 * air_density / STANDARD_AIR_DENSITY, 1.0)
132    } else {
133        atmo_params
134    }
135}
136
137const MAX_STANDARD_DENSITY_RATIO: f64 = 2.0;
138
139/// True if `atmo_params` can yield a finite, positive air density. `atmo_params` has TWO
140/// modes that `compute_derivatives` distinguishes:
141///   * **Standard**: `(base_alt_m, base_temp_c, base_pressure_hPa, base_density_ratio)` —
142///     positive station pressure. Slot 3 is a density RATIO, not humidity, despite the
143///     `humidity` field it lands in via `build_inputs`. A nonpositive ratio means "not supplied"
144///     and falls back to `1.0`; a supplied ratio must be below `2.0`.
145///   * **Direct**: `(air_density, speed_of_sound, 0.0, 0.0)` — slots 2 and 3 are `0.0`
146///     sentinels, with a real density (< 2.0 kg/m³) and speed of sound (> 200 m/s).
147///
148/// A pressure <= 0 that ISN'T the direct-mode sentinel, an overlarge supplied density ratio, or
149/// any non-finite value would yield a non-physical atmosphere, so it is rejected. (Earlier this
150/// guard also rejected legitimate direct-mode input; the direct-mode allowance below fixes that.)
151fn atmo_is_physical(atmo_params: (f64, f64, f64, f64)) -> bool {
152    let (a, b, c, d) = atmo_params;
153    if !(a.is_finite() && b.is_finite() && c.is_finite() && d.is_finite()) {
154        return false;
155    }
156    // Direct-atmosphere mode: (density, speed_of_sound, 0, 0). Constants mirror
157    // derivatives.rs (MAX_REALISTIC_DENSITY = 2.0 kg/m³, MIN_REALISTIC_SPEED_OF_SOUND = 200 m/s).
158    // Standard mode: positive station pressure (hPa), plus either the documented missing-ratio
159    // sentinel or a supplied ratio below the physical ceiling.
160    direct_atmosphere_values(atmo_params).is_some() || (c > 0.0 && d < MAX_STANDARD_DENSITY_RATIO)
161}
162
163#[derive(Debug, Clone, Copy)]
164enum FastAtmosphere {
165    Direct {
166        air_density: f64,
167        speed_of_sound: f64,
168    },
169    Standard {
170        base_density: f64,
171    },
172}
173
174/// Fast trajectory integration parameters
175pub struct FastIntegrationParams {
176    pub horiz: f64,
177    pub vert: f64,
178    /// Launch state `[x, y, z, vx, vy, vz]` in the shot frame. y is measured from the level
179    /// `BallisticInputs::muzzle_height` is measured from: the ground when it is nonzero (start
180    /// y at `muzzle_height`), the bore when it is 0 (start y at 0). Boundary-layer wind shear
181    /// reads its height above the ground from that datum.
182    pub initial_state: [f64; 6],
183    pub t_span: (f64, f64),
184    /// Dual-mode atmosphere tuple. Standard mode is
185    /// `(base_alt_m, base_temp_c, base_pressure_hPa, base_density_ratio)` (slot 3 is a density
186    /// RATIO, not humidity; nonpositive means "not supplied" and falls back to `1.0`, while a
187    /// supplied ratio must be below `2.0`). Direct mode is
188    /// `(air_density, speed_of_sound, 0.0, 0.0)`.
189    pub atmo_params: (f64, f64, f64, f64),
190    /// MBA-1137: optional downrange-segmented atmosphere. When `Some`, the per-substep drag
191    /// density samples the base (station-referenced) T/P/H for the zone at the current downrange
192    /// distance before applying the altitude lapse. `None` (default) keeps the single-station
193    /// base and — combined with the MBA-1137 density-freeze fix — varies density with altitude only.
194    pub atmo_sock: Option<AtmoSock>,
195}
196
197/// Aerodynamic-jump vertical launch-angle offset (radians) for the fast-integrate path.
198///
199/// Bryan Litz's crosswind estimator (`Y = 0.01*Sg - 0.0024*L + 0.032` MOA/mph) fed by the
200/// engine's Miller Sg. The fast path receives a prebuilt initial velocity (no muzzle angle),
201/// so the caller rotates that velocity by this offset. Returns 0 when the feature is off or
202/// the inputs are degenerate. Crosswind is taken from `wind_speed`/`wind_angle`
203/// (BallisticInputs convention: 0 = headwind, +PI/2 = from the right). MBA-959, EXPERIMENTAL.
204pub fn aerodynamic_jump_launch_offset_rad(
205    inputs: &BallisticInputs,
206    atmo_params: (f64, f64, f64, f64),
207) -> f64 {
208    let crosswind_from_right_mps = inputs.wind_speed * inputs.wind_angle.sin();
209    aerodynamic_jump_launch_offset_for_crosswind_rad(inputs, atmo_params, crosswind_from_right_mps)
210}
211
212fn aerodynamic_jump_launch_offset_for_crosswind_rad(
213    inputs: &BallisticInputs,
214    atmo_params: (f64, f64, f64, f64),
215    crosswind_from_right_mps: f64,
216) -> f64 {
217    if !inputs.enable_aerodynamic_jump {
218        return 0.0;
219    }
220    let diameter = inputs.bullet_diameter;
221    if !(inputs.twist_rate.is_finite()
222        && inputs.twist_rate != 0.0
223        && diameter.is_finite()
224        && diameter > 0.0
225        && inputs.bullet_length.is_finite()
226        && inputs.bullet_length > 0.0
227        && inputs.muzzle_velocity.is_finite())
228    {
229        return 0.0;
230    }
231    let stability_atmo = stability_atmosphere_params(atmo_params);
232    let sg = crate::stability::compute_stability_coefficient(inputs, stability_atmo);
233    if !(sg.is_finite() && sg > 0.0) {
234        return 0.0;
235    }
236    let length_cal = inputs.bullet_length / diameter;
237    const MS_TO_MPH: f64 = 2.236_936_292_054_4;
238    let crosswind_from_right_mph = crosswind_from_right_mps * MS_TO_MPH;
239    let vertical_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
240        sg,
241        length_cal,
242        crosswind_from_right_mph,
243        inputs.is_twist_right,
244    );
245    if !vertical_moa.is_finite() {
246        return 0.0;
247    }
248    const MOA_PER_RAD: f64 = 3437.7467707849;
249    vertical_moa / MOA_PER_RAD
250}
251
252/// Rotate a McCoy-frame state's velocity (indices 3..6) by `theta_rad` in the vertical
253/// (downrange–vertical) plane, preserving speed and horizontal heading. Positive = up.
254fn rotate_launch_velocity(state: &mut [f64; 6], theta_rad: f64) {
255    let (vx, vy, vz) = (state[3], state[4], state[5]);
256    let speed = (vx * vx + vy * vy + vz * vz).sqrt();
257    if speed <= 0.0 {
258        return;
259    }
260    let h = (vx * vx + vz * vz).sqrt(); // horizontal (downrange+lateral) speed
261    let new_elev = vy.atan2(h) + theta_rad;
262    state[4] = speed * new_elev.sin();
263    let new_h = speed * new_elev.cos();
264    let scale = if h > 1e-12 { new_h / h } else { 0.0 };
265    state[3] = vx * scale;
266    state[5] = vz * scale;
267}
268
269fn launch_state_with_aerodynamic_jump(
270    inputs: &BallisticInputs,
271    atmo_params: (f64, f64, f64, f64),
272    segmented_crosswind_from_right_mps: Option<f64>,
273    mut initial_state: [f64; 6],
274) -> [f64; 6] {
275    let offset = match segmented_crosswind_from_right_mps {
276        Some(crosswind) => {
277            aerodynamic_jump_launch_offset_for_crosswind_rad(inputs, atmo_params, crosswind)
278        }
279        None => aerodynamic_jump_launch_offset_rad(inputs, atmo_params),
280    };
281    if offset != 0.0 {
282        rotate_launch_velocity(&mut initial_state, offset);
283    }
284    initial_state
285}
286
287/// Fast fixed-step integration for longer trajectories
288pub fn fast_integrate(
289    inputs: &BallisticInputs,
290    wind_sock: &WindSock,
291    params: FastIntegrationParams,
292) -> FastSolution {
293    fast_integrate_with_gravity(inputs, wind_sock, params, Gravity::EARTH)
294}
295
296pub(crate) fn fast_integrate_with_gravity(
297    inputs: &BallisticInputs,
298    wind_sock: &WindSock,
299    params: FastIntegrationParams,
300    gravity: Gravity,
301) -> FastSolution {
302    // Malformed wind or a degenerate atmosphere would otherwise silently stub or poison the run.
303    if wind_sock.validate_segments().is_err() || !atmo_is_physical(params.atmo_params) {
304        return FastSolution::degenerate(&params.initial_state);
305    }
306    let mut effective_inputs = inputs.clone();
307    // MBA-1415: this is a public entry point that external bindings call directly, bypassing
308    // TrajectorySolver::new. It must therefore carry every input-conditioning step itself, or a
309    // caller setting a conditioned field gets no error and no effect. Idempotent, so inputs that
310    // already passed through a solver are unaffected.
311    effective_inputs.normalize_for_solve();
312    if params.atmo_params.2 > 0.0 {
313        effective_inputs.altitude = params.atmo_params.0;
314        effective_inputs.temperature = params.atmo_params.1;
315        effective_inputs.pressure = params.atmo_params.2;
316        effective_inputs.humidity = params.atmo_params.3;
317    }
318    let inputs = &effective_inputs;
319    // Extract parameters
320    let _mass_kg = inputs.bullet_mass; // SI (kg)
321    let bc = inputs.bc_value;
322    let drag_model = &inputs.bc_type;
323
324    // Check for BC segments
325    let has_bc_segments =
326        inputs.bc_segments.is_some() && !inputs.bc_segments.as_ref().unwrap().is_empty();
327    let has_bc_segments_data =
328        inputs.bc_segments_data.is_some() && !inputs.bc_segments_data.as_ref().unwrap().is_empty();
329
330    // Time step - adjust based on distance
331    let dt = if params.horiz > 200.0 {
332        0.001
333    } else if params.horiz > 100.0 {
334        0.0005
335    } else {
336        0.0001
337    };
338
339    // MBA-959: aerodynamic jump perturbs the prebuilt launch velocity vertically (this path is
340    // handed an initial_state, not a muzzle angle). A no-op returning the original when disabled.
341    let segmented_crosswind_from_right_mps = if inputs.enable_aerodynamic_jump {
342        wind_sock.muzzle_crosswind_from_right_mps()
343    } else {
344        None
345    };
346    let initial_state = launch_state_with_aerodynamic_jump(
347        inputs,
348        params.atmo_params,
349        segmented_crosswind_from_right_mps,
350        params.initial_state,
351    );
352    let vx = initial_state[3]; // horizontal (downrange) velocity
353
354    // MBA-1145: decouple the integration-loop ceiling from the pre-allocation heuristic.
355    // Previously t_max = min(4*horiz/vx, t_span.1) bounded BOTH the Vec sizing AND the loop
356    // itself, so the 4x-horiz/vx estimate doubled as the loop cap. That estimate is only a
357    // heuristic — extreme high-drag or high-launch-angle shots exceed it, and the loop then
358    // terminated BEFORE hit_target/hit_ground, silently truncating the trajectory tail (Monte
359    // Carlo reported impact metrics short of the real range). The loop already breaks on
360    // hit_target (pos.x >= horiz) and hit_ground (pos.y <= ground_threshold), and any real
361    // trajectory descends below the ground threshold within a few seconds of apex, so the loop
362    // bound's only job is a runaway safety ceiling: use the full t_span.1 (default 30 s). The
363    // 4x estimate is retained ONLY to size the pre-allocation (keeps the small-allocation fast
364    // path for short shots); the Vec grows for the rare long run because the loop bound is n_steps.
365    let n_steps = ((params.t_span.1 / dt) as usize) + 1;
366    let est_steps = if vx > 1e-6 && params.horiz > 0.0 {
367        (((4.0 * params.horiz / vx) / dt) as usize) + 1
368    } else {
369        n_steps
370    };
371    let cap = est_steps.min(n_steps);
372    let mut times = Vec::with_capacity(cap);
373    let mut states = Vec::with_capacity(cap);
374
375    // Initial state (with the aerodynamic-jump launch perturbation applied above)
376    times.push(0.0);
377    states.push(initial_state);
378
379    // Direct mode supplies a fixed density and speed of sound. Standard mode supplies station
380    // conditions plus base_ratio; its local density/sound speed are lapsed at every substep.
381    // Guard a missing standard-mode ratio by falling back to sea-level density (MBA-1157 owns
382    // stricter validation of that separate contract).
383    let atmosphere =
384        if let Some((air_density, speed_of_sound)) = direct_atmosphere_values(params.atmo_params) {
385            FastAtmosphere::Direct {
386                air_density,
387                speed_of_sound,
388            }
389        } else {
390            let base_density = if params.atmo_params.3 > 0.0 {
391                params.atmo_params.3 * 1.225
392            } else {
393                1.225
394            };
395            FastAtmosphere::Standard { base_density }
396        };
397
398    // MBA-1137: borrow the optional downrange-segmented atmosphere once (queried 4x per step).
399    let atmo_sock = params.atmo_sock.as_ref();
400
401    // Hoist invariants out of compute_derivatives (called 4x per step). Both the drag-model name
402    // and the projectile shape depend only on inputs, not on state/mach, so computing them per
403    // call wasted an allocation + heuristic every k1..k4. Mirrors cli_api.rs exactly.
404
405    // Drag-model name as a borrowed &'static str. DragModel's Display goes via Debug, which
406    // heap-allocates a String on every call; this match is bit-identical (Display == Debug ==
407    // variant name) with no per-step allocation.
408    let drag_model_str: &str = match drag_model {
409        DragModel::G1 => "G1",
410        DragModel::G2 => "G2",
411        DragModel::G5 => "G5",
412        DragModel::G6 => "G6",
413        DragModel::G7 => "G7",
414        DragModel::G8 => "G8",
415        DragModel::GI => "GI",
416        DragModel::GS => "GS",
417        DragModel::RA4 => "RA4",
418    };
419
420    // SI fallbacks for caliber/weight (SI-only MC callers may leave the imperial fields 0).
421    let caliber_in = if inputs.caliber_inches > 0.0 {
422        inputs.caliber_inches
423    } else {
424        inputs.bullet_diameter / 0.0254
425    };
426    let weight_gr = if inputs.weight_grains > 0.0 {
427        inputs.weight_grains
428    } else {
429        inputs.bullet_mass / crate::constants::GRAINS_TO_KG
430    };
431
432    // Projectile shape for transonic corrections (MBA-949: shared resolver — bullet_model name
433    // first, then the caliber/weight/drag-model heuristic).
434    let projectile_shape = crate::transonic_drag::resolve_projectile_shape(
435        inputs.bullet_model.as_deref(),
436        caliber_in,
437        weight_gr,
438        drag_model_str,
439    );
440
441    // Coriolis omega (Earth rotation), hoisted (invariant over the flight). MBA-957:
442    // fast_integrate — the Monte Carlo / Python-binding path — previously applied NO Coriolis.
443    // First project into level downrange/up/lateral axes: azimuth 0 = North; omega.Z is NEGATIVE
444    // (Omega.East = -Omega cos(lat) sin(az)). The derivative kernel then projects this vector into
445    // the inclined shot frame and applies the physical -2 Omega x v.
446    let omega_vector = match inputs.latitude {
447        Some(latitude) if inputs.enable_coriolis => {
448            let omega_earth = 7.2921159e-5_f64; // rad/s
449            let lat = latitude.to_radians();
450            let az = inputs.shot_azimuth; // compass bearing (0=N), NOT the aiming offset
451            Some(Vector3::new(
452                omega_earth * lat.cos() * az.cos(),  // X: downrange
453                omega_earth * lat.sin(),             // Y: vertical
454                -omega_earth * lat.cos() * az.sin(), // Z: lateral (corrected sign)
455            ))
456        }
457        _ => None,
458    };
459    // Parse the string model once; the derivative kernel is called four times per RK4 step.
460    let wind_shear_model = if inputs.enable_wind_shear {
461        let model = crate::wind_shear::boundary_layer_model_from_name(&inputs.wind_shear_model);
462        (model != crate::wind_shear::WindShearModel::None).then_some(model)
463    } else {
464        None
465    };
466
467    // Integration loop
468    let mut hit_target = false;
469    let mut hit_ground = false;
470    let mut max_ord_time = None;
471    let mut max_ord_y = 0.0;
472    let ground_threshold = inputs.ground_threshold;
473
474    // RK4 integration
475    for i in 0..n_steps - 1 {
476        let t = i as f64 * dt;
477        let state = states[i];
478
479        let pos = Vector3::new(state[0], state[1], state[2]);
480        let _vel = Vector3::new(state[3], state[4], state[5]);
481
482        // Check termination conditions (X is downrange, McCoy)
483        if pos.x >= params.horiz {
484            hit_target = true;
485            break;
486        }
487
488        if pos.y <= ground_threshold {
489            hit_ground = true;
490            break;
491        }
492
493        // Track maximum ordinate
494        if pos.y > max_ord_y {
495            max_ord_y = pos.y;
496            max_ord_time = Some(t);
497        }
498
499        // RK4 step
500        let k1 = compute_derivatives_with_gravity(
501            &state,
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            gravity,
514        );
515
516        let mut state2 = state;
517        for j in 0..6 {
518            state2[j] = state[j] + 0.5 * dt * k1[j];
519        }
520        let k2 = compute_derivatives_with_gravity(
521            &state2,
522            inputs,
523            wind_sock,
524            atmosphere,
525            drag_model,
526            projectile_shape,
527            bc,
528            has_bc_segments,
529            has_bc_segments_data,
530            omega_vector,
531            wind_shear_model,
532            atmo_sock,
533            gravity,
534        );
535
536        let mut state3 = state;
537        for j in 0..6 {
538            state3[j] = state[j] + 0.5 * dt * k2[j];
539        }
540        let k3 = compute_derivatives_with_gravity(
541            &state3,
542            inputs,
543            wind_sock,
544            atmosphere,
545            drag_model,
546            projectile_shape,
547            bc,
548            has_bc_segments,
549            has_bc_segments_data,
550            omega_vector,
551            wind_shear_model,
552            atmo_sock,
553            gravity,
554        );
555
556        let mut state4 = state;
557        for j in 0..6 {
558            state4[j] = state[j] + dt * k3[j];
559        }
560        let k4 = compute_derivatives_with_gravity(
561            &state4,
562            inputs,
563            wind_sock,
564            atmosphere,
565            drag_model,
566            projectile_shape,
567            bc,
568            has_bc_segments,
569            has_bc_segments_data,
570            omega_vector,
571            wind_shear_model,
572            atmo_sock,
573            gravity,
574        );
575
576        // Update state
577        let mut new_state = state;
578        for j in 0..6 {
579            new_state[j] = state[j] + dt * (k1[j] + 2.0 * k2[j] + 2.0 * k3[j] + k4[j]) / 6.0;
580        }
581
582        if state[0] < params.horiz && new_state[0] >= params.horiz {
583            // Keep every terminal metric on the requested target plane. The former top-of-loop
584            // check retained this full-step endpoint, biasing time, drop, and velocity past it.
585            let alpha = (params.horiz - state[0]) / (new_state[0] - state[0]);
586            let mut target_state = state;
587            for j in 0..6 {
588                target_state[j] = state[j] + alpha * (new_state[j] - state[j]);
589            }
590            target_state[0] = params.horiz;
591            times.push(t + alpha * dt);
592            states.push(target_state);
593            hit_target = true;
594            break;
595        }
596
597        times.push(t + dt);
598        states.push(new_state);
599    }
600
601    // Create event arrays
602    let t_events = [
603        if hit_target {
604            vec![*times.last().unwrap()]
605        } else {
606            vec![]
607        },
608        if let Some(t) = max_ord_time {
609            vec![t]
610        } else {
611            vec![]
612        },
613        if hit_ground {
614            vec![*times.last().unwrap()]
615        } else {
616            vec![]
617        },
618    ];
619
620    // MBA-1134 (rank 10) — regression fix: apply the canonical empirical Litz drift as a
621    // post-process to the lateral (McCoy Z) here too. The derivatives kernel no longer
622    // integrates spin drift, and this plain fast_integrate is the single-shot fast path
623    // (solve_trajectory_rust / the API), so without this it would carry NO spin drift and
624    // twist direction would have no effect (the Magnus term is also suppressed when
625    // use_enhanced_spin_drift is set). Mirrors fast_integrate_with_segments,
626    // cli_api::apply_spin_drift and the Monte-Carlo path so all solver families agree; uses
627    // the SAME muzzle Sg (spin_drift::effective_sg_from_inputs).
628    if inputs.use_enhanced_spin_drift {
629        // Standard-mode atmo_params is (base_alt, temp_c, press_hpa, ratio); direct mode
630        // (density, sound, 0, 0) carries no explicit temp/pressure, so fall back to sea-level
631        // standard (the Sg density correction is a no-op there).
632        let (sd_temp_c, sd_press_hpa) = if params.atmo_params.2 > 0.0 {
633            (params.atmo_params.1, params.atmo_params.2)
634        } else {
635            (15.0, 1013.25)
636        };
637        let sg = crate::spin_drift::effective_sg_from_inputs(inputs, sd_temp_c, sd_press_hpa);
638        for (t, state) in times.iter().zip(states.iter_mut()) {
639            if *t > 0.0 {
640                state[2] += crate::spin_drift::litz_drift_meters(sg, *t, inputs.is_twist_right);
641            }
642        }
643    }
644
645    FastSolution::from_trajectory_data(times, states, t_events)
646}
647
648fn fast_magnus_acceleration(
649    inputs: &BallisticInputs,
650    air_velocity: Vector3<f64>,
651    air_density: f64,
652    mach: f64,
653    gravity_acceleration: Vector3<f64>,
654    gravity: Gravity,
655) -> Vector3<f64> {
656    if !inputs.enable_magnus
657        || inputs.use_enhanced_spin_drift
658        || inputs.bullet_diameter <= 0.0
659        || inputs.twist_rate <= 0.0
660        || inputs.bullet_mass <= 0.0
661    {
662        return Vector3::zeros();
663    }
664
665    let speed_air = air_velocity.norm();
666    let diameter_m = inputs.bullet_diameter;
667    let (spin_rate_rad_s, spin_param) = crate::spin_drift::calculate_magnus_spin_state(
668        inputs.muzzle_velocity,
669        speed_air,
670        inputs.twist_rate,
671        diameter_m,
672    );
673    let d_in = if inputs.caliber_inches > 0.0 {
674        inputs.caliber_inches
675    } else {
676        diameter_m / 0.0254
677    };
678    let m_gr = if inputs.weight_grains > 0.0 {
679        inputs.weight_grains
680    } else {
681        inputs.bullet_mass / crate::constants::GRAINS_TO_KG
682    };
683    let l_in = if inputs.bullet_length > 0.0 {
684        inputs.bullet_length / 0.0254
685    } else {
686        let estimated = crate::stability::estimate_bullet_length_m(diameter_m, inputs.bullet_mass);
687        if estimated > 0.0 {
688            estimated / 0.0254
689        } else {
690            4.5 * d_in.max(1e-9)
691        }
692    };
693    let sg = crate::spin_drift::calculate_dynamic_stability(
694        m_gr,
695        speed_air,
696        spin_rate_rad_s,
697        d_in,
698        l_in,
699        air_density,
700    );
701    let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose_with_gravity(
702        sg,
703        speed_air,
704        spin_rate_rad_s,
705        0.0,
706        0.0,
707        air_density,
708        d_in,
709        l_in,
710        m_gr,
711        mach,
712        "match",
713        false,
714        gravity,
715    );
716    let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
717    let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
718    let force = 0.5 * air_density * speed_air.powi(2) * area * c_np * spin_param * yaw_rad.sin();
719    if force <= 1e-12 {
720        return Vector3::zeros();
721    }
722
723    crate::derivatives::yaw_of_repose_magnus_direction(
724        air_velocity,
725        gravity_acceleration,
726        inputs.is_twist_right,
727    )
728    .map_or_else(Vector3::zeros, |direction| {
729        (force / inputs.bullet_mass) * direction
730    })
731}
732
733fn interpolated_vertical_apex(
734    previous_time: f64,
735    previous: &[f64; 6],
736    current_time: f64,
737    current: &[f64; 6],
738) -> Option<(f64, [f64; 6])> {
739    let dt = current_time - previous_time;
740    let previous_vy = previous[4];
741    let current_vy = current[4];
742    if !dt.is_finite()
743        || dt <= 0.0
744        || !previous_vy.is_finite()
745        || !current_vy.is_finite()
746        || previous_vy <= 0.0
747        || current_vy > 0.0
748    {
749        return None;
750    }
751
752    let denominator = previous_vy - current_vy;
753    if !denominator.is_finite() || denominator <= 0.0 {
754        return None;
755    }
756    let alpha = previous_vy / denominator;
757    // An exact endpoint root is already retained as the current saved point. Only synthesize a
758    // strictly interior point so the solution remains ordered without duplicate event times.
759    if !alpha.is_finite() || !(0.0..1.0).contains(&alpha) {
760        return None;
761    }
762
763    let mut apex = [0.0; 6];
764    for component in 0..6 {
765        apex[component] = previous[component] + alpha * (current[component] - previous[component]);
766    }
767
768    // Cubic Hermite interpolation uses each endpoint's position and velocity, avoiding the
769    // chord-height bias that linear interpolation has around a stationary point.
770    let alpha2 = alpha * alpha;
771    let alpha3 = alpha2 * alpha;
772    let h00 = 2.0 * alpha3 - 3.0 * alpha2 + 1.0;
773    let h10 = alpha3 - 2.0 * alpha2 + alpha;
774    let h01 = -2.0 * alpha3 + 3.0 * alpha2;
775    let h11 = alpha3 - alpha2;
776    for axis in 0..3 {
777        apex[axis] = h00 * previous[axis]
778            + h10 * dt * previous[axis + 3]
779            + h01 * current[axis]
780            + h11 * dt * current[axis + 3];
781    }
782    apex[4] = 0.0;
783
784    apex.iter()
785        .all(|component| component.is_finite())
786        .then_some((previous_time + alpha * dt, apex))
787}
788
789/// Compute derivatives for the state vector
790#[allow(clippy::too_many_arguments)]
791fn compute_derivatives_with_gravity(
792    state: &[f64; 6],
793    inputs: &BallisticInputs,
794    wind_sock: &WindSock,
795    atmosphere: FastAtmosphere,
796    drag_model: &DragModel,
797    projectile_shape: crate::transonic_drag::ProjectileShape,
798    bc: f64,
799    has_bc_segments: bool,
800    has_bc_segments_data: bool,
801    omega: Option<Vector3<f64>>,
802    wind_shear_model: Option<crate::wind_shear::WindShearModel>,
803    // MBA-1137: optional downrange-segmented atmosphere (zone base swapped by pos.x before lapse).
804    atmo_sock: Option<&AtmoSock>,
805    gravity: Gravity,
806) -> [f64; 6] {
807    let pos = Vector3::new(state[0], state[1], state[2]);
808    let vel = Vector3::new(state[3], state[4], state[5]);
809
810    // Resolve the cached downrange wind in level axes, then apply boundary-layer shear at the
811    // height above the ground: the shot-frame y, plus 0 when the launch state's y is measured
812    // from the ground (`muzzle_height` set) or the assumed bore height when y = 0 is the bore
813    // (`muzzle_height` 0). The ground is the one the ground_threshold check uses, parallel to the
814    // line of sight, so the shooting angle does not enter. Site elevation is MSL and
815    // intentionally does not enter shear.
816    let level_wind = wind_sock.vector_for_range_stateless(pos.x);
817    let level_wind = if let Some(model) = wind_shear_model {
818        let height_agl = crate::wind_shear::shear_height_agl_m(pos.y, inputs.muzzle_height);
819        crate::wind_shear::apply_boundary_layer_shear_agl(level_wind, height_agl, model)
820    } else {
821        level_wind
822    };
823    let wind_vector =
824        crate::derivatives::level_vector_to_shot_frame(level_wind, inputs.shooting_angle);
825
826    // Velocity relative to air
827    let vel_adjusted = vel - wind_vector;
828    let v_mag = vel_adjusted.norm();
829
830    // Gravity acceleration vector, rotated into the shot-aligned frame by shooting_angle
831    // (uphill/downhill inclined fire), matching cli_api::TrajectorySolver::gravity_acceleration.
832    let theta = inputs.shooting_angle;
833    let accel_gravity = gravity.in_frame(theta);
834
835    // Calculate acceleration
836    let mut accel = if v_mag < 1e-6 {
837        accel_gravity
838    } else {
839        // Calculate drag
840        let v_fps = v_mag * MPS_TO_FPS;
841
842        // Resolve LOCAL density and speed of sound. Direct mode uses its supplied fixed values;
843        // standard mode evaluates the substep altitude with the lapse-rate pipeline.
844        // `inputs.temperature`/`inputs.pressure` are the standard-mode base (station) T/P set by
845        // fast_integrate from atmo_params; `base_density` is the station-altitude density, so
846        // `base_density / 1.225` recovers the station base_ratio the lapse pipeline expects.
847        //
848        // MBA-1137 (density-freeze fix): previously ONLY the speed of sound was taken from this
849        // call and `density_scale` used the flight-constant `base_density`, so the fast/MC path
850        // held density frozen for the whole flight (density did NOT vary with altitude — a latent
851        // bug the cli_api/derivatives paths already avoid). Now the LOCAL density is used for
852        // `density_scale` below, so the fast path varies density with altitude too.
853        //
854        // MBA-1137 (zones): when a downrange-segmented atmosphere is present, swap the BASE
855        // (station-referenced) temp/pressure/ratio for the zone at the current downrange distance
856        // (pos.x), recomputing the zone base_ratio via CIPM, BEFORE the altitude lapse — so
857        // downrange-zone selection and the world-vertical lapse compose without double-counting.
858        // `None` keeps the single-station base.
859        //
860        // Humidity is NOT plumbed to this call site: on the fast path (fast_integrate) the
861        // `humidity` FIELD is overwritten with atmo_params.3 (the density RATIO), so
862        // `inputs.humidity` is not a real RH — pass 0.0 (dry) rather than fabricate.
863        let (local_density, speed_of_sound) = match atmosphere {
864            FastAtmosphere::Direct {
865                air_density,
866                speed_of_sound,
867            } => (air_density, speed_of_sound),
868            FastAtmosphere::Standard { base_density } => {
869                let altitude = crate::atmosphere::shot_frame_altitude(
870                    inputs.altitude,
871                    pos.x,
872                    pos.y,
873                    inputs.shooting_angle,
874                );
875                let (base_temp_c, base_press_hpa, base_ratio) = match atmo_sock {
876                    Some(sock) => {
877                        let (zt, zp, zh) = sock.atmo_for_range(pos.x);
878                        (zt, zp, calculate_air_density_cimp(zt, zp, zh) / 1.225)
879                    }
880                    None => (inputs.temperature, inputs.pressure, base_density / 1.225),
881                };
882                get_local_atmosphere_humid_with_gravity(
883                    altitude,
884                    inputs.altitude, // base_alt approximation
885                    base_temp_c,
886                    base_press_hpa,
887                    base_ratio,
888                    0.0, // humidity not available here (see note above)
889                    gravity,
890                )
891            }
892        };
893        let mach = v_mag / speed_of_sound;
894
895        // Get BC value (potentially from segments)
896        let bc_current = match (
897            inputs.bc_segments_data.as_ref(),
898            inputs.bc_segments.as_ref(),
899        ) {
900            (Some(segments_data), _) if inputs.use_bc_segments && has_bc_segments_data => {
901                velocity_segment_bc(v_fps, segments_data, bc)
902            }
903            (_, Some(segments)) if has_bc_segments => {
904                crate::derivatives::interpolated_bc(mach, segments, Some(inputs))
905            }
906            _ => bc,
907        };
908        // Guard bc_value == 0 (allowed on FFI/WASM/public MC surfaces): the division below
909        // would be Inf -> NaN. Mirrors cli_api's effective_bc.max(1e-6); inert for valid BCs.
910        let bc_current = bc_current.max(1e-6);
911
912        // Apply the transonic drag-rise correction once (mirrors derivatives.rs / cli_api) so
913        // the Monte Carlo / fast path doesn't under-predict drag near Mach 1. The projectile
914        // shape is invariant for the whole integration, so it is hoisted into fast_integrate and
915        // passed in rather than recomputed per call. wave_drag=false: the G1/G7 tables already
916        // embed the rise.
917        // MBA-940: a user-supplied custom drag table is the final Cd, used as-is (no G-model
918        // lookup, transonic, or form-factor correction — the curve already encodes the true drag).
919        // When it is the projectile's own curve its Cd is the ACTUAL drag coefficient, so the
920        // retardation denominator must be the sectional density (lb/in²), not a BC:
921        // Cd_own / SD == Cd_ref / BC. A reference curve (custom_drag_reference) divides by
922        // bc_current, the BC resolved above (band BC under a ladder), exactly as the G-model
923        // branch does (see BallisticInputs::custom_drag_denominator).
924        let (drag_factor, retard_denom) = if let Some(ref table) = inputs.custom_drag_table {
925            (
926                // MBA-1357: cd_scale is a single whole-curve drag multiplier applied here, at
927                // the Cd lookup site. The Mach-keyed DSF table (truing_dsf.rs) is a SEPARATE,
928                // drop-only post-processing correction applied to a solved TrajectoryResult's
929                // points after integration finishes — it never touches this drag computation.
930                table.interpolate(mach) * inputs.cd_scale,
931                inputs.custom_drag_denominator(bc_current),
932            )
933        } else {
934            let base_cd = get_drag_coefficient(mach, drag_model);
935            let cd =
936                crate::transonic_drag::transonic_correction(mach, base_cd, projectile_shape, false);
937            (cd, bc_current)
938        };
939
940        // Calculate drag acceleration using proper ballistics formula
941        let cd_to_retard = crate::constants::CD_TO_RETARD;
942        let standard_factor = drag_factor * cd_to_retard;
943        // MBA-1137: use the LOCAL (per-substep) density, not the frozen flight-constant
944        // `base_density`, so drag varies with altitude AND downrange zone.
945        let density_scale = local_density / 1.225;
946
947        // Drag acceleration in ft/s^2
948        let a_drag_ft_s2 = (v_fps * v_fps) * standard_factor * density_scale / retard_denom;
949
950        // Convert to m/s^2 and apply to velocity vector
951        let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; // ft/s^2 to m/s^2
952        let accel_drag = -a_drag_m_s2 * (vel_adjusted / v_mag);
953
954        // The Litz post-process owns the gyroscopic effect whenever it is enabled. Otherwise,
955        // honor the explicit Magnus flag with the same dynamic-Sg/yaw model as sibling solvers.
956        let accel_magnus = fast_magnus_acceleration(
957            inputs,
958            vel_adjusted,
959            local_density,
960            mach,
961            accel_gravity,
962            gravity,
963        );
964
965        // Total acceleration
966        accel_drag + accel_gravity + accel_magnus
967    };
968
969    // Coriolis (Earth rotation), MBA-957. Omega arrives in level downrange/up/lateral axes;
970    // project it into the inclined shot frame before applying the physical -2 Omega x v.
971    if let Some(omega) = omega {
972        let omega = crate::derivatives::level_vector_to_shot_frame(omega, inputs.shooting_angle);
973        accel += -2.0 * omega.cross(&vel);
974    }
975
976    // Return derivatives [vx, vy, vz, ax, ay, az]
977    [vel.x, vel.y, vel.z, accel.x, accel.y, accel.z]
978}
979
980/// Fast integration with explicit wind segments using RK45
981/// MBA-155: Upstreamed from ballistics_rust
982///
983/// A velocity-keyed `bc_segments_data` ladder is flown when `use_bc_segments` is on
984/// (MBA-1602). With the flag on and no ladder (None or empty) and no Mach `bc_segments`, this
985/// RK45 path auto-estimates a ladder from diameter, mass and BC, unlike `TrajectorySolver` and
986/// [`fast_integrate`], which fly the scalar BC.
987pub fn fast_integrate_with_segments(
988    inputs: &BallisticInputs,
989    wind_segments: Vec<crate::wind::WindSegment>,
990    params: FastIntegrationParams,
991) -> FastSolution {
992    fast_integrate_with_segments_with_gravity(inputs, wind_segments, params, Gravity::EARTH)
993}
994
995pub(crate) fn fast_integrate_with_segments_with_gravity(
996    inputs: &BallisticInputs,
997    wind_segments: Vec<crate::wind::WindSegment>,
998    params: FastIntegrationParams,
999    gravity: Gravity,
1000) -> FastSolution {
1001    // Use the RK45 implementation from trajectory_integration module
1002    use crate::trajectory_integration::{
1003        integrate_trajectory_from_muzzle_height, TrajectoryParams,
1004    };
1005
1006    // Malformed wind or a degenerate atmosphere would otherwise silently stub or poison the run.
1007    if crate::wind::validate_wind_segments(&wind_segments).is_err()
1008        || !atmo_is_physical(params.atmo_params)
1009    {
1010        return FastSolution::degenerate(&params.initial_state);
1011    }
1012
1013    // MBA-1415: like plain fast_integrate, this is a public entry point external bindings call
1014    // directly, so it must apply every input-conditioning step rather than relying on
1015    // TrajectorySolver::new. Shadowing `inputs` here means every read below — mass, bc_value,
1016    // drag model, the launch state — sees the conditioned values, with no site left to miss.
1017    // Idempotent, so inputs that already passed through a solver are unaffected.
1018    let mut normalized_inputs = inputs.clone();
1019    normalized_inputs.normalize_for_solve();
1020    let inputs = &normalized_inputs;
1021
1022    // Match plain fast_integrate: this entry point also receives a prebuilt launch state, so
1023    // apply the experimental aerodynamic-jump angle exactly once before the low-level integrator.
1024    let segmented_crosswind_from_right_mps =
1025        if inputs.enable_aerodynamic_jump && !wind_segments.is_empty() {
1026            WindSock::new(wind_segments.clone()).muzzle_crosswind_from_right_mps()
1027        } else {
1028            None
1029        };
1030    let initial_state = launch_state_with_aerodynamic_jump(
1031        inputs,
1032        params.atmo_params,
1033        segmented_crosswind_from_right_mps,
1034        params.initial_state,
1035    );
1036
1037    // Extract parameters
1038    let mass_kg = inputs.bullet_mass; // SI (kg)
1039    let bc = inputs.bc_value;
1040    let drag_model = inputs.bc_type;
1041
1042    // Coriolis omega — gated on enable_coriolis (+ a latitude), INDEPENDENT of
1043    // spin-drift/Magnus. A caller can now request Coriolis-only (enable_coriolis=true
1044    // with enable_advanced_effects=false) instead of being forced to enable all three.
1045    let omega_vector = if inputs.enable_coriolis && inputs.latitude.is_some() {
1046        // Calculate omega based on latitude and shot azimuth
1047        // First project Earth's rotation into level downrange/up/lateral axes based on azimuth;
1048        // the derivative kernel handles the additional inclined-shot projection.
1049        // azimuth_angle: 0 = North, pi/2 = East
1050        let omega_earth = 7.2921159e-5; // rad/s
1051        let lat_rad = inputs.latitude.unwrap_or(0.0).to_radians();
1052        let azimuth = inputs.shot_azimuth; // compass bearing (0=N), NOT the aiming offset
1053        Some(Vector3::new(
1054            omega_earth * lat_rad.cos() * azimuth.cos(), // X: downrange component
1055            omega_earth * lat_rad.sin(),                 // Y: vertical component
1056            -omega_earth * lat_rad.cos() * azimuth.sin(), // Z: lateral (MBA-957: corrected sign)
1057        ))
1058    } else {
1059        None
1060    };
1061
1062    // Set up trajectory parameters
1063    let traj_params = TrajectoryParams {
1064        mass_kg,
1065        bc,
1066        drag_model,
1067        wind_segments,
1068        atmos_params: params.atmo_params,
1069        omega_vector,
1070        enable_spin_drift: inputs.use_enhanced_spin_drift,
1071        enable_magnus: inputs.enable_magnus,
1072        enable_coriolis: inputs.enable_coriolis,
1073        target_distance_m: params.horiz,
1074        enable_wind_shear: inputs.enable_wind_shear,
1075        wind_shear_model: inputs.wind_shear_model.clone(),
1076        shooter_altitude_m: inputs.altitude,
1077        is_twist_right: inputs.is_twist_right,
1078        shooting_angle: inputs.shooting_angle,
1079        // MBA-717: carry the real bullet geometry so spin-drift / Magnus use it.
1080        bullet_diameter: inputs.bullet_diameter,
1081        bullet_length: inputs.bullet_length,
1082        twist_rate: inputs.twist_rate,
1083        cd_scale: inputs.cd_scale,
1084        custom_drag_reference: inputs.custom_drag_reference,
1085        custom_drag_table: inputs.custom_drag_table.clone(),
1086        bc_segments: inputs.bc_segments.clone(),
1087        use_bc_segments: inputs.use_bc_segments,
1088        // MBA-1602: the caller's velocity ladder, read from the normalized inputs so an
1089        // Army-Standard-Metro ladder arrives ICAO-converted like the scalar.
1090        bc_segments_data: inputs.bc_segments_data.clone(),
1091        // MBA-954: keep the historical -1000.0 here (behavior-preserving for this binding path);
1092        // threading inputs.ground_threshold would change the default ground plane for existing
1093        // callers. Direct TrajectoryParams constructors can now configure it.
1094        ground_threshold: -1000.0,
1095        // MBA-1137: forward the downrange-segmented atmosphere to the RK45 derivatives path.
1096        atmo_sock: params.atmo_sock,
1097    };
1098
1099    // Use RK45 adaptive integration. The launch state's y is measured from the level
1100    // `muzzle_height` is measured from, as for plain fast_integrate, so the kernel's shear
1101    // height starts from the same datum.
1102    let trajectory = integrate_trajectory_from_muzzle_height(
1103        initial_state,
1104        params.t_span,
1105        traj_params,
1106        "RK45", // Use RK45 implementation
1107        1e-6,   // tolerance
1108        0.01,   // max_step
1109        gravity,
1110        inputs.muzzle_height,
1111    );
1112
1113    // Convert trajectory to FastSolution format
1114    let n_points = trajectory.len();
1115    let mut times = Vec::with_capacity(n_points + 1);
1116    let mut states = Vec::with_capacity(n_points + 1);
1117
1118    let mut target_hit_time: Option<f64> = None;
1119    let mut ground_hit_time: Option<f64> = None;
1120    let mut max_ord_time = None;
1121    let mut max_ord_y = 0.0;
1122
1123    for (t, state_vec) in trajectory {
1124        // Convert Vector6 to array
1125        let state = [
1126            state_vec[0],
1127            state_vec[1],
1128            state_vec[2],
1129            state_vec[3],
1130            state_vec[4],
1131            state_vec[5],
1132        ];
1133
1134        // Check termination conditions
1135        // McCoy: state[0]=downrange, state[1]=vertical, state[2]=lateral
1136
1137        // The RK45 integrator intentionally returns only ~50 range-spaced states. Use vertical
1138        // velocity to recover an apex between adjacent saves, and retain the synthetic point so
1139        // FastSolution::sol(max_ord_time) returns its Hermite-interpolated height rather than the
1140        // lower straight chord between coarse samples.
1141        if let Some((&previous_time, &previous_state)) = times.last().zip(states.last()) {
1142            if let Some((apex_time, apex_state)) =
1143                interpolated_vertical_apex(previous_time, &previous_state, t, &state)
1144            {
1145                if apex_state[1] > max_ord_y {
1146                    max_ord_y = apex_state[1];
1147                    max_ord_time = Some(apex_time);
1148                }
1149                times.push(apex_time);
1150                states.push(apex_state);
1151            }
1152        }
1153
1154        // Record FIRST time target is hit
1155        if target_hit_time.is_none() && state[0] >= params.horiz {
1156            target_hit_time = Some(t);
1157        }
1158
1159        // Record ground hit
1160        if ground_hit_time.is_none() && state[1] <= inputs.ground_threshold {
1161            ground_hit_time = Some(t);
1162        }
1163
1164        // Track maximum ordinate
1165        if state[1] > max_ord_y {
1166            max_ord_y = state[1];
1167            max_ord_time = Some(t);
1168        }
1169
1170        times.push(t);
1171        states.push(state);
1172    }
1173
1174    // Create event arrays
1175    let t_events = [
1176        if let Some(t) = target_hit_time {
1177            vec![t]
1178        } else {
1179            vec![]
1180        },
1181        if let Some(t) = max_ord_time {
1182            vec![t]
1183        } else {
1184            vec![]
1185        },
1186        if let Some(t) = ground_hit_time {
1187            vec![t]
1188        } else {
1189            vec![]
1190        },
1191    ];
1192
1193    // MBA-1134 (rank 10): the derivatives kernel no longer integrates spin drift, so apply the
1194    // canonical empirical Litz drift as a post-process to the lateral (McCoy Z) of every point at
1195    // its time of flight — matching cli_api::apply_spin_drift and the Monte-Carlo path so all three
1196    // solver families agree. Uses the SAME muzzle Sg (spin_drift::effective_sg_from_inputs).
1197    if inputs.use_enhanced_spin_drift {
1198        // Standard-mode atmo_params is (base_alt, temp_c, press_hpa, ratio); direct mode
1199        // (density, sound, 0, 0) carries no explicit temp/pressure, so fall back to sea-level
1200        // standard (the Sg density correction is a no-op there).
1201        let (sd_temp_c, sd_press_hpa) = if params.atmo_params.2 > 0.0 {
1202            (params.atmo_params.1, params.atmo_params.2)
1203        } else {
1204            (15.0, 1013.25)
1205        };
1206        let sg = crate::spin_drift::effective_sg_from_inputs(inputs, sd_temp_c, sd_press_hpa);
1207        for (t, state) in times.iter().zip(states.iter_mut()) {
1208            if *t > 0.0 {
1209                state[2] += crate::spin_drift::litz_drift_meters(sg, *t, inputs.is_twist_right);
1210            }
1211        }
1212    }
1213
1214    FastSolution::from_trajectory_data(times, states, t_events)
1215}
1216
1217#[cfg(test)]
1218mod tests {
1219    use super::*;
1220    use crate::BCSegmentData;
1221
1222    #[test]
1223    fn uniform_gravity_reaches_both_fast_integration_paths() {
1224        let inputs = BallisticInputs {
1225            bullet_mass: 0.0459,
1226            bullet_diameter: 0.0427,
1227            bullet_length: 0.0427,
1228            muzzle_velocity: 10.0,
1229            ..BallisticInputs::default()
1230        };
1231        let params = || FastIntegrationParams {
1232            horiz: 10.0,
1233            vert: 0.0,
1234            initial_state: [0.0, 10.0, 0.0, 10.0, 2.0, 0.0],
1235            t_span: (0.0, 2.0),
1236            atmo_params: (1e-12, 340.0, 0.0, 0.0),
1237            atmo_sock: None,
1238        };
1239        for magnitude in [0.0, 1.62, 9.80665, 20.0] {
1240            let gravity = Gravity::new(magnitude).unwrap();
1241            for segmented in [false, true] {
1242                let result = if segmented {
1243                    fast_integrate_with_segments_with_gravity(&inputs, vec![], params(), gravity)
1244                } else {
1245                    fast_integrate_with_gravity(&inputs, &WindSock::new(vec![]), params(), gravity)
1246                };
1247                assert!(result.success);
1248                let time = *result.t.last().unwrap();
1249                assert!((time - 1.0).abs() < 1e-8);
1250                // Terminal linear interpolation of the parabola contributes at most g*dt²/8.
1251                let step: f64 = if segmented { 0.01 } else { 0.0001 };
1252                let tolerance = magnitude * step * step / 8.0 + 1e-8;
1253                assert!(
1254                    (result.y[1].last().unwrap() - (12.0 - 0.5 * magnitude)).abs() < tolerance,
1255                    "g={magnitude}, segmented={segmented}, y={:?}, time={time}",
1256                    result.y[1].last()
1257                );
1258                assert!((result.y[4].last().unwrap() - (2.0 - magnitude)).abs() < 1e-8);
1259                if magnitude == 9.80665 {
1260                    let public = if segmented {
1261                        fast_integrate_with_segments(&inputs, vec![], params())
1262                    } else {
1263                        fast_integrate(&inputs, &WindSock::new(vec![]), params())
1264                    };
1265                    assert_eq!(public.t, result.t);
1266                    assert_eq!(public.y, result.y);
1267                    assert_eq!(public.t_events, result.t_events);
1268                }
1269            }
1270        }
1271    }
1272
1273    fn match_308_at_1000_m() -> BallisticInputs {
1274        BallisticInputs {
1275            muzzle_velocity: 800.0,
1276            bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
1277            bullet_diameter: 0.308 * 0.0254,
1278            bullet_length: 1.215 * 0.0254,
1279            caliber_inches: 0.308,
1280            weight_grains: 168.0,
1281            twist_rate: 10.0,
1282            altitude: 1000.0,
1283            temperature: 15.0,
1284            pressure: 1013.25,
1285            ..BallisticInputs::default()
1286        }
1287    }
1288
1289    /// The fast kernel's acceleration under `gravity`, minus the shot-frame gravity it adds: what
1290    /// is left is drag and Magnus, the parts that read gravity through the atmosphere and the yaw
1291    /// of repose.
1292    fn aerodynamic_acceleration_with_gravity(
1293        state: &[f64; 6],
1294        inputs: &BallisticInputs,
1295        gravity: Gravity,
1296    ) -> Vector3<f64> {
1297        let result = compute_derivatives_with_gravity(
1298            state,
1299            inputs,
1300            &WindSock::new(vec![]),
1301            FastAtmosphere::Standard {
1302                base_density: 1.225,
1303            },
1304            &inputs.bc_type,
1305            crate::transonic_drag::ProjectileShape::Spitzer,
1306            inputs.bc_value,
1307            false,
1308            false,
1309            None,
1310            None,
1311            None,
1312            gravity,
1313        );
1314        Vector3::new(result[3], result[4], result[5]) - gravity.in_frame(inputs.shooting_angle)
1315    }
1316
1317    #[test]
1318    fn local_atmosphere_uses_the_simulation_gravity() {
1319        let inputs = match_308_at_1000_m(); // Magnus off
1320        let low_gravity = Gravity::new(3.71).unwrap();
1321        let aerodynamic = |height_above_station_m, gravity| {
1322            let state = [0.0, height_above_station_m, 0.0, 600.0, 0.0, 0.0];
1323            aerodynamic_acceleration_with_gravity(&state, &inputs, gravity)
1324        };
1325
1326        // Control: at the station there is no air column to integrate, so gravity cannot reach
1327        // the density.
1328        let earth = aerodynamic(0.0, Gravity::EARTH);
1329        let low = aerodynamic(0.0, low_gravity);
1330        assert!(
1331            (earth - low).norm() < 1e-12,
1332            "station-altitude drag must not depend on gravity: earth={earth:?}, low={low:?}"
1333        );
1334
1335        // 2000 m above the station, pressure falls more slowly under weaker gravity, so the air is
1336        // denser and drag is stronger.
1337        let earth = aerodynamic(2_000.0, Gravity::EARTH);
1338        let low = aerodynamic(2_000.0, low_gravity);
1339        let ratio = low.x / earth.x;
1340        assert!(earth.x < 0.0, "drag must oppose flight: {earth:?}");
1341        assert!(
1342            (ratio - 1.162_702).abs() < 1e-4,
1343            "drag aloft must follow the simulation's hydrostatic density: low/earth={ratio}, \
1344             expected 1.162702 (standard lapse rate, 1000 m station, 2000 m above it)"
1345        );
1346    }
1347
1348    #[test]
1349    fn magnus_yaw_of_repose_uses_the_simulation_gravity() {
1350        let low_gravity = Gravity::new(3.71).unwrap();
1351        // At the station, so the density cannot depend on gravity.
1352        let state = [0.0, 0.0, 0.0, 800.0, 0.0, 0.0];
1353        let aerodynamic = |enable_magnus, gravity| {
1354            let inputs = BallisticInputs {
1355                enable_magnus,
1356                ..match_308_at_1000_m()
1357            };
1358            aerodynamic_acceleration_with_gravity(&state, &inputs, gravity)
1359        };
1360
1361        // Control: without Magnus the aerodynamic acceleration is the same under both gravities.
1362        let earth = aerodynamic(false, Gravity::EARTH);
1363        let low = aerodynamic(false, low_gravity);
1364        assert!(
1365            (earth - low).norm() < 1e-12,
1366            "Magnus-off drag must not depend on gravity: earth={earth:?}, low={low:?}"
1367        );
1368
1369        // Yaw of repose is proportional to g, and the Magnus force to the sine of that small yaw.
1370        let earth = aerodynamic(true, Gravity::EARTH) - earth;
1371        let low = aerodynamic(true, low_gravity) - low;
1372        let ratio = low.y / earth.y;
1373        let expected = low_gravity.magnitude() / Gravity::EARTH.magnitude();
1374        assert!(
1375            earth.y < 0.0,
1376            "right-hand Magnus must point down: {earth:?}"
1377        );
1378        assert!(
1379            (ratio - expected).abs() < 1e-6,
1380            "Magnus must scale with the simulation's gravity: low/earth={ratio}, expected={expected}"
1381        );
1382    }
1383
1384    fn expected_shot_frame_vector(level: Vector3<f64>, angle: f64) -> Vector3<f64> {
1385        let (sin_angle, cos_angle) = angle.sin_cos();
1386        Vector3::new(
1387            level.x * cos_angle + level.y * sin_angle,
1388            -level.x * sin_angle + level.y * cos_angle,
1389            level.z,
1390        )
1391    }
1392
1393    #[test]
1394    fn measured_bc_fast_drag_ignores_name_based_form_factor_flag() {
1395        let derivatives_with_flag = |use_form_factor| {
1396            let inputs = BallisticInputs {
1397                bc_value: 0.462,
1398                bc_type: DragModel::G1,
1399                bullet_model: Some("168gr SMK Match".to_string()),
1400                use_form_factor,
1401                temperature: 15.0,
1402                pressure: 1013.25,
1403                ..BallisticInputs::default()
1404            };
1405
1406            compute_derivatives_with_gravity(
1407                &[0.0, 0.0, 0.0, 600.0, 0.0, 0.0],
1408                &inputs,
1409                &WindSock::new(vec![]),
1410                FastAtmosphere::Standard {
1411                    base_density: 1.225,
1412                },
1413                &inputs.bc_type,
1414                crate::transonic_drag::ProjectileShape::Spitzer,
1415                inputs.bc_value,
1416                false,
1417                false,
1418                None,
1419                None,
1420                None,
1421                Gravity::EARTH,
1422            )
1423        };
1424
1425        let baseline = derivatives_with_flag(false);
1426        let flagged = derivatives_with_flag(true);
1427
1428        for component in 3..6 {
1429            assert_eq!(
1430                flagged[component].to_bits(),
1431                baseline[component].to_bits(),
1432                "published BC already encodes form factor: component {component}, baseline={} flagged={}",
1433                baseline[component],
1434                flagged[component]
1435            );
1436        }
1437    }
1438
1439    #[test]
1440    fn velocity_bc_data_requires_opt_in_in_plain_fast_kernel() {
1441        let acceleration = |inputs: &BallisticInputs| {
1442            let has_mach_segments = inputs
1443                .bc_segments
1444                .as_ref()
1445                .is_some_and(|segments| !segments.is_empty());
1446            let has_velocity_segments = inputs
1447                .bc_segments_data
1448                .as_ref()
1449                .is_some_and(|segments| !segments.is_empty());
1450            compute_derivatives_with_gravity(
1451                &[0.0, 0.0, 0.0, 600.0, 0.0, 0.0],
1452                inputs,
1453                &WindSock::new(vec![]),
1454                FastAtmosphere::Standard {
1455                    base_density: 1.225,
1456                },
1457                &inputs.bc_type,
1458                crate::transonic_drag::ProjectileShape::Spitzer,
1459                inputs.bc_value,
1460                has_mach_segments,
1461                has_velocity_segments,
1462                None,
1463                None,
1464                None,
1465                Gravity::EARTH,
1466            )
1467        };
1468
1469        let scalar_inputs = BallisticInputs {
1470            bc_value: 0.5,
1471            bc_type: DragModel::G7,
1472            temperature: 15.0,
1473            pressure: 1013.25,
1474            ..BallisticInputs::default()
1475        };
1476        let mut disabled_inputs = scalar_inputs.clone();
1477        disabled_inputs.bc_segments_data = Some(vec![BCSegmentData {
1478            velocity_min: 0.0,
1479            velocity_max: 4_000.0,
1480            bc_value: 0.46,
1481        }]);
1482        disabled_inputs.use_bc_segments = false;
1483        let mut enabled_inputs = disabled_inputs.clone();
1484        enabled_inputs.use_bc_segments = true;
1485        let mut mach_only_inputs = scalar_inputs.clone();
1486        mach_only_inputs.bc_segments = Some(vec![(0.0, 0.4), (3.0, 0.4)]);
1487        let mut disabled_with_both = mach_only_inputs.clone();
1488        disabled_with_both.bc_segments_data = disabled_inputs.bc_segments_data.clone();
1489
1490        let scalar = acceleration(&scalar_inputs);
1491        let disabled = acceleration(&disabled_inputs);
1492        let enabled = acceleration(&enabled_inputs);
1493        let mach_only = acceleration(&mach_only_inputs);
1494        let disabled_with_both = acceleration(&disabled_with_both);
1495
1496        assert_eq!(
1497            disabled[3].to_bits(),
1498            scalar[3].to_bits(),
1499            "a populated velocity table must not change drag while use_bc_segments is false"
1500        );
1501        assert!(
1502            enabled[3] < disabled[3] - 1.0,
1503            "enabling the lower BC table must increase drag: disabled ax={} enabled ax={}",
1504            disabled[3],
1505            enabled[3]
1506        );
1507        assert_eq!(
1508            disabled_with_both[3].to_bits(),
1509            mach_only[3].to_bits(),
1510            "disabling velocity data must fall through to an explicit Mach table"
1511        );
1512    }
1513
1514    #[test]
1515    fn inclined_positions_at_same_world_altitude_have_same_fast_acceleration() {
1516        let angle = std::f64::consts::FRAC_PI_6;
1517        let inputs = BallisticInputs {
1518            altitude: 100.0,
1519            temperature: 15.0,
1520            pressure: 1013.25,
1521            shooting_angle: angle,
1522            ..BallisticInputs::default()
1523        };
1524        let wind_sock = WindSock::new(vec![]);
1525        let atmosphere = FastAtmosphere::Standard {
1526            base_density: 1.225,
1527        };
1528        let state_along_slant = [1_000.0, 0.0, 0.0, 600.0, 0.0, 0.0];
1529        let state_across_slant = [0.0, 500.0 / angle.cos(), 0.0, 600.0, 0.0, 0.0];
1530
1531        let a = compute_derivatives_with_gravity(
1532            &state_along_slant,
1533            &inputs,
1534            &wind_sock,
1535            atmosphere,
1536            &inputs.bc_type,
1537            crate::transonic_drag::ProjectileShape::Spitzer,
1538            inputs.bc_value,
1539            false,
1540            false,
1541            None,
1542            None,
1543            None,
1544            Gravity::EARTH,
1545        );
1546        let b = compute_derivatives_with_gravity(
1547            &state_across_slant,
1548            &inputs,
1549            &wind_sock,
1550            atmosphere,
1551            &inputs.bc_type,
1552            crate::transonic_drag::ProjectileShape::Spitzer,
1553            inputs.bc_value,
1554            false,
1555            false,
1556            None,
1557            None,
1558            None,
1559            Gravity::EARTH,
1560        );
1561
1562        for component in 3..6 {
1563            assert!(
1564                (a[component] - b[component]).abs() < 1e-10,
1565                "fast derivative component {component} differs at equal world altitude: {} vs {}",
1566                a[component],
1567                b[component]
1568            );
1569        }
1570    }
1571
1572    #[test]
1573    fn inclined_headwind_is_rotated_into_solver_frame() {
1574        let angle = std::f64::consts::FRAC_PI_6;
1575        let inputs = BallisticInputs {
1576            shooting_angle: angle,
1577            ..BallisticInputs::default()
1578        };
1579        let speed_mps = 360.0 * (1000.0 / 3600.0);
1580        let level_headwind = Vector3::new(-speed_mps, 0.0, 0.0);
1581        let velocity = expected_shot_frame_vector(level_headwind, angle);
1582        let state = [0.0, 0.0, 0.0, velocity.x, velocity.y, velocity.z];
1583        let actual = compute_derivatives_with_gravity(
1584            &state,
1585            &inputs,
1586            &WindSock::new(vec![crate::wind::WindSegment::new(360.0, 0.0, 1000.0)]),
1587            FastAtmosphere::Direct {
1588                air_density: 1.225,
1589                speed_of_sound: 340.0,
1590            },
1591            &inputs.bc_type,
1592            crate::transonic_drag::ProjectileShape::Spitzer,
1593            inputs.bc_value,
1594            false,
1595            false,
1596            None,
1597            None,
1598            None,
1599            Gravity::EARTH,
1600        );
1601        let expected = Vector3::new(
1602            -G_ACCEL_MPS2 * angle.sin(),
1603            -G_ACCEL_MPS2 * angle.cos(),
1604            0.0,
1605        );
1606
1607        assert!(
1608            (Vector3::new(actual[3], actual[4], actual[5]) - expected).norm() < 1e-12,
1609            "co-moving horizontal wind must leave only shot-frame gravity: {actual:?}"
1610        );
1611    }
1612
1613    #[test]
1614    fn plain_fast_kernel_applies_power_law_wind_shear() {
1615        let state = [500.0, 100.0, 0.0, 700.0, 0.0, 0.0];
1616        let run = |enable_wind_shear: bool, model: &str, wind_speed_kmh: f64| {
1617            let inputs = BallisticInputs {
1618                bc_value: 0.5,
1619                bc_type: DragModel::G7,
1620                enable_wind_shear,
1621                wind_shear_model: model.to_string(),
1622                ..BallisticInputs::default()
1623            };
1624            let wind_shear_model = enable_wind_shear
1625                .then(|| crate::wind_shear::boundary_layer_model_from_name(model))
1626                .filter(|model| *model != crate::wind_shear::WindShearModel::None);
1627            compute_derivatives_with_gravity(
1628                &state,
1629                &inputs,
1630                &WindSock::new(vec![crate::wind::WindSegment::new(
1631                    wind_speed_kmh,
1632                    90.0,
1633                    2_000.0,
1634                )]),
1635                FastAtmosphere::Direct {
1636                    air_density: 1.225,
1637                    speed_of_sound: 340.0,
1638                },
1639                &inputs.bc_type,
1640                crate::transonic_drag::ProjectileShape::Spitzer,
1641                inputs.bc_value,
1642                false,
1643                false,
1644                None,
1645                wind_shear_model,
1646                None,
1647                Gravity::EARTH,
1648            )
1649        };
1650
1651        let uniform = run(false, "power_law", 36.0);
1652        let model_none = run(true, "none", 36.0);
1653        assert_eq!(model_none, uniform, "model=none must preserve uniform wind");
1654
1655        let sheared = run(true, "power_law", 36.0);
1656        assert!(
1657            sheared[5] < uniform[5],
1658            "stronger aloft crosswind must increase leftward acceleration: uniform={}, shear={}",
1659            uniform[5],
1660            sheared[5]
1661        );
1662
1663        let ratio = crate::wind_shear::boundary_layer_speed_ratio(
1664            state[1],
1665            crate::wind_shear::WindShearModel::PowerLaw,
1666        );
1667        let equivalent_uniform = run(false, "none", 36.0 * ratio);
1668        for component in 3..6 {
1669            assert!(
1670                (sheared[component] - equivalent_uniform[component]).abs() < 1e-12,
1671                "shear component {component} must equal base wind scaled by {ratio}: shear={}, expected={}",
1672                sheared[component],
1673                equivalent_uniform[component]
1674            );
1675        }
1676    }
1677
1678    #[test]
1679    fn plain_fast_path_wind_shear_changes_high_arc_drift() {
1680        let run = |enable_wind_shear: bool, model: &str| {
1681            let inputs = BallisticInputs {
1682                muzzle_velocity: 800.0,
1683                bc_value: 0.5,
1684                bc_type: DragModel::G7,
1685                bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
1686                bullet_diameter: 0.308 * 0.0254,
1687                enable_wind_shear,
1688                wind_shear_model: model.to_string(),
1689                ground_threshold: -100.0,
1690                ..BallisticInputs::default()
1691            };
1692            let elevation = 0.12_f64;
1693            let solution = fast_integrate(
1694                &inputs,
1695                &WindSock::new(vec![crate::wind::WindSegment::new(36.0, 90.0, 2_000.0)]),
1696                FastIntegrationParams {
1697                    horiz: 1_000.0,
1698                    vert: 0.0,
1699                    initial_state: [
1700                        0.0,
1701                        0.0,
1702                        0.0,
1703                        inputs.muzzle_velocity * elevation.cos(),
1704                        inputs.muzzle_velocity * elevation.sin(),
1705                        0.0,
1706                    ],
1707                    t_span: (0.0, 5.0),
1708                    atmo_params: (0.0, 15.0, 1013.25, 1.0),
1709                    atmo_sock: None,
1710                },
1711            );
1712            let last = solution.t.len() - 1;
1713            assert_eq!(solution.y[0][last].to_bits(), 1_000.0_f64.to_bits());
1714            solution.y[2][last]
1715        };
1716
1717        let uniform = run(false, "power_law");
1718        let model_none = run(true, "none");
1719        assert_eq!(model_none.to_bits(), uniform.to_bits());
1720        let sheared = run(true, "power_law");
1721        assert!(
1722            sheared.abs() > uniform.abs() + 0.01,
1723            "aloft shear must increase drift magnitude: uniform={uniform}, shear={sheared}"
1724        );
1725    }
1726
1727    fn shear_ratio_rel_launch(height_rel_launch_m: f64, model: &str) -> f64 {
1728        crate::wind_shear::boundary_layer_speed_ratio(
1729            height_rel_launch_m,
1730            crate::wind_shear::boundary_layer_model_from_name(model),
1731        )
1732    }
1733
1734    /// Asserts that the boundary-layer multiplier the fast kernel applies at `(x, y)` is
1735    /// `expected_ratio`: its derivatives with shear on equal its derivatives with shear off and
1736    /// the wind scaled by that ratio.
1737    fn assert_fast_shear_ratio(
1738        model: &str,
1739        muzzle_height: f64,
1740        shooting_angle: f64,
1741        (x, y): (f64, f64),
1742        expected_ratio: f64,
1743    ) {
1744        let state = [x, y, 0.0, 700.0, 5.0, 0.0];
1745        let run = |enable_wind_shear: bool, wind_speed_kmh: f64| {
1746            let inputs = BallisticInputs {
1747                bc_value: 0.5,
1748                bc_type: DragModel::G7,
1749                muzzle_height,
1750                shooting_angle,
1751                enable_wind_shear,
1752                wind_shear_model: model.to_string(),
1753                ..BallisticInputs::default()
1754            };
1755            let wind_shear_model = enable_wind_shear
1756                .then(|| crate::wind_shear::boundary_layer_model_from_name(model))
1757                .filter(|model| *model != crate::wind_shear::WindShearModel::None);
1758            compute_derivatives_with_gravity(
1759                &state,
1760                &inputs,
1761                &WindSock::new(vec![crate::wind::WindSegment::new(
1762                    wind_speed_kmh,
1763                    90.0,
1764                    2_000.0,
1765                )]),
1766                FastAtmosphere::Direct {
1767                    air_density: 1.225,
1768                    speed_of_sound: 340.0,
1769                },
1770                &inputs.bc_type,
1771                crate::transonic_drag::ProjectileShape::Spitzer,
1772                inputs.bc_value,
1773                false,
1774                false,
1775                None,
1776                wind_shear_model,
1777                None,
1778                Gravity::EARTH,
1779            )
1780        };
1781        let sheared = run(true, 36.0);
1782        let scaled = run(false, 36.0 * expected_ratio);
1783        for component in 3..6 {
1784            assert!(
1785                (sheared[component] - scaled[component]).abs() < 1e-12,
1786                "{model}, muzzle_height {muzzle_height} m, shooting angle {shooting_angle} rad, \
1787                 ({x}, {y}): the shear multiplier is not {expected_ratio} (component \
1788                 {component}: {} vs {})",
1789                sheared[component],
1790                scaled[component]
1791            );
1792        }
1793    }
1794
1795    /// One height above the ground reads one multiplier, whatever `muzzle_height` says: with
1796    /// `muzzle_height` 1.5 m, y = 60 m is 60 m above the ground (y = 0 is the ground); with it
1797    /// left at 0, y = 0 is the bore, taken as 1.5 m up, so y = 58.5 m is. The fast kernel used
1798    /// to add the 1.5 m to every y, counting a real bore height twice.
1799    #[test]
1800    fn fast_shear_reads_one_multiplier_at_one_height_above_ground() {
1801        for model in ["logarithmic", "power_law"] {
1802            let expected = shear_ratio_rel_launch(58.5, model);
1803            assert!(
1804                expected > 1.2,
1805                "{model}: the fixture must be well above the floor"
1806            );
1807            assert_fast_shear_ratio(model, 1.5, 0.0, (1_000.0, 60.0), expected);
1808            assert_fast_shear_ratio(model, 0.0, 0.0, (1_000.0, 58.5), expected);
1809        }
1810    }
1811
1812    /// On an inclined shot the shear height is the shot-frame y plus the height of y = 0 above
1813    /// the ground, the ground running parallel to the line of sight as it does for the
1814    /// ground_threshold check, exactly as `TrajectorySolver` reads it on the same fixture
1815    /// (`cli_api::boundary_layer_shear_height_tests::
1816    /// inclined_solver_shear_measures_height_along_the_line_of_sight`). This kernel used to take
1817    /// the world height above the shooter's level, x·sin θ + y·cos θ.
1818    #[test]
1819    fn inclined_fast_shear_measures_height_along_the_line_of_sight() {
1820        let (x, y) = (800.0, 60.0);
1821        for angle in [0.3_f64, -0.3] {
1822            let world_height = crate::atmosphere::shot_frame_altitude(0.0, x, y, angle);
1823            for model in ["logarithmic", "power_law"] {
1824                let bore_frame = shear_ratio_rel_launch(y, model);
1825                let ground_frame = shear_ratio_rel_launch(y - 1.5, model);
1826                assert!(
1827                    bore_frame > 1.25 && ground_frame > 1.25,
1828                    "{model}: fixture too low"
1829                );
1830                assert!(
1831                    (shear_ratio_rel_launch(world_height, model) - bore_frame).abs() > 0.1,
1832                    "{model}, angle {angle}: the fixture must tell the world height apart"
1833                );
1834                assert_fast_shear_ratio(model, 0.0, angle, (x, y), bore_frame);
1835                assert_fast_shear_ratio(model, 1.5, angle, (x, y), ground_frame);
1836            }
1837        }
1838    }
1839
1840    /// A flat shot fired up or down a slope stays within a few metres of the ground along its
1841    /// line of sight, below the 10 m reference height, so shear on must leave it where shear
1842    /// off does through both fast entry points. Read against level ground at the shooter's
1843    /// elevation, the 15° uphill shot climbed into wind nearly 50% stronger by 600 m.
1844    #[test]
1845    fn inclined_flat_fast_fire_keeps_the_operative_wind_with_shear_on() {
1846        let run = |shooting_angle: f64, model: Option<&str>, segmented: bool| {
1847            let inputs = BallisticInputs {
1848                muzzle_velocity: 800.0,
1849                bc_value: 0.5,
1850                bc_type: DragModel::G7,
1851                bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
1852                bullet_diameter: 0.308 * 0.0254,
1853                muzzle_height: 1.5,
1854                shooting_angle,
1855                enable_wind_shear: model.is_some(),
1856                wind_shear_model: model.unwrap_or("none").to_string(),
1857                ground_threshold: -1_000.0,
1858                ..BallisticInputs::default()
1859            };
1860            let elevation = 0.002_f64;
1861            let params = FastIntegrationParams {
1862                horiz: 600.0,
1863                vert: 1.5,
1864                initial_state: [
1865                    0.0,
1866                    1.5,
1867                    0.0,
1868                    inputs.muzzle_velocity * elevation.cos(),
1869                    inputs.muzzle_velocity * elevation.sin(),
1870                    0.0,
1871                ],
1872                t_span: (0.0, 3.0),
1873                atmo_params: (1.225, 340.0, 0.0, 0.0),
1874                atmo_sock: None,
1875            };
1876            let wind = vec![crate::wind::WindSegment::new(36.0, 90.0, 2_000.0)];
1877            let solution = if segmented {
1878                fast_integrate_with_segments(&inputs, wind, params)
1879            } else {
1880                fast_integrate(&inputs, &WindSock::new(wind), params)
1881            };
1882            assert!(solution.success);
1883            let last = solution.t.len() - 1;
1884            let peak_y = solution.y[1]
1885                .iter()
1886                .copied()
1887                .fold(f64::NEG_INFINITY, f64::max);
1888            (solution.y[2][last], peak_y)
1889        };
1890
1891        for segmented in [false, true] {
1892            for shooting_angle in [15.0_f64.to_radians(), (-10.0_f64).to_radians()] {
1893                let (off, peak_y) = run(shooting_angle, None, segmented);
1894                assert!(
1895                    peak_y < 8.0 && off.abs() > 0.5,
1896                    "the fixture must stay low and drift: peak y {peak_y} m, drift {off} m"
1897                );
1898                for model in ["logarithmic", "power_law"] {
1899                    let (on, _) = run(shooting_angle, Some(model), segmented);
1900                    // The RK45 kernel takes smaller steps with shear on, so allow for its
1901                    // integration tolerance; level ground would move this drift by tens of %.
1902                    assert!(
1903                        (on - off).abs() <= 1e-4 * off.abs(),
1904                        "segmented={segmented}, {model}, shooting angle {shooting_angle} rad: \
1905                         shear moved a flat shot ({on} m vs {off} m)"
1906                    );
1907                }
1908            }
1909        }
1910    }
1911
1912    /// Both public fast entry points take a launch state whose y is measured from the level
1913    /// `muzzle_height` is measured from. The same physical shot, a bore 1.5 m above the ground,
1914    /// stated as `muzzle_height` 1.5 m with y starting at 1.5 m or as `muzzle_height` 0 with y
1915    /// starting at 0, must drift the same with shear on. A fixed air density keeps the 1.5 m
1916    /// shift in y out of the drag, so shear is the only height-dependent term.
1917    #[test]
1918    fn fast_entry_points_shear_one_shot_the_same_whichever_way_its_bore_height_is_stated() {
1919        let run = |muzzle_height: f64, segmented: bool| {
1920            let inputs = BallisticInputs {
1921                muzzle_velocity: 800.0,
1922                bc_value: 0.5,
1923                bc_type: DragModel::G7,
1924                bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
1925                bullet_diameter: 0.308 * 0.0254,
1926                muzzle_height,
1927                enable_wind_shear: true,
1928                wind_shear_model: "power_law".to_string(),
1929                ground_threshold: -1_000.0,
1930                ..BallisticInputs::default()
1931            };
1932            let elevation = 0.12_f64;
1933            let params = FastIntegrationParams {
1934                horiz: 1_000.0,
1935                vert: muzzle_height,
1936                initial_state: [
1937                    0.0,
1938                    muzzle_height,
1939                    0.0,
1940                    inputs.muzzle_velocity * elevation.cos(),
1941                    inputs.muzzle_velocity * elevation.sin(),
1942                    0.0,
1943                ],
1944                t_span: (0.0, 5.0),
1945                atmo_params: (1.225, 340.0, 0.0, 0.0),
1946                atmo_sock: None,
1947            };
1948            let wind = vec![crate::wind::WindSegment::new(36.0, 90.0, 2_000.0)];
1949            let solution = if segmented {
1950                fast_integrate_with_segments(&inputs, wind, params)
1951            } else {
1952                fast_integrate(&inputs, &WindSock::new(wind), params)
1953            };
1954            assert!(solution.success);
1955            let last = solution.t.len() - 1;
1956            assert_eq!(solution.y[0][last].to_bits(), 1_000.0_f64.to_bits());
1957            (solution.y[1][last] - muzzle_height, solution.y[2][last])
1958        };
1959
1960        for segmented in [false, true] {
1961            let (rise_bore, drift_bore) = run(0.0, segmented);
1962            let (rise_ground, drift_ground) = run(1.5, segmented);
1963            assert!(drift_bore.abs() > 1.0, "the fixture must drift");
1964            assert!(
1965                (drift_ground - drift_bore).abs() < 1e-9,
1966                "segmented={segmented}: the same shot drifted {drift_bore} m stated from the bore \
1967                 and {drift_ground} m stated from the ground"
1968            );
1969            assert!(
1970                (rise_ground - rise_bore).abs() < 1e-9,
1971                "segmented={segmented}: height above the bore {rise_bore} vs {rise_ground} m"
1972            );
1973        }
1974    }
1975
1976    #[test]
1977    fn inclined_coriolis_is_rotated_into_solver_frame() {
1978        let angle = std::f64::consts::FRAC_PI_6;
1979        let inputs = BallisticInputs {
1980            shooting_angle: angle,
1981            ..BallisticInputs::default()
1982        };
1983        let velocity = Vector3::new(600.0, 20.0, 5.0);
1984        let state = [0.0, 0.0, 0.0, velocity.x, velocity.y, velocity.z];
1985        let level_omega = Vector3::new(3.0e-5, 6.0e-5, -2.0e-5);
1986        let run = |omega| {
1987            compute_derivatives_with_gravity(
1988                &state,
1989                &inputs,
1990                &WindSock::new(vec![]),
1991                FastAtmosphere::Direct {
1992                    air_density: 1.225,
1993                    speed_of_sound: 340.0,
1994                },
1995                &inputs.bc_type,
1996                crate::transonic_drag::ProjectileShape::Spitzer,
1997                inputs.bc_value,
1998                false,
1999                false,
2000                omega,
2001                None,
2002                None,
2003                Gravity::EARTH,
2004            )
2005        };
2006        let baseline = run(None);
2007        let with_coriolis = run(Some(level_omega));
2008        let actual = Vector3::new(
2009            with_coriolis[3] - baseline[3],
2010            with_coriolis[4] - baseline[4],
2011            with_coriolis[5] - baseline[5],
2012        );
2013        let expected = -2.0 * expected_shot_frame_vector(level_omega, angle).cross(&velocity);
2014
2015        assert!(
2016            (actual - expected).norm() < 1e-12,
2017            "inclined Coriolis mismatch: actual={actual:?}, expected={expected:?}"
2018        );
2019    }
2020
2021    #[test]
2022    fn test_fast_solution_interpolation() {
2023        let times = vec![0.0, 1.0, 2.0];
2024        let states = vec![
2025            [0.0, 0.0, 0.0, 100.0, 50.0, 0.0],
2026            [100.0, 45.0, 0.0, 99.0, 40.0, 0.0],
2027            [198.0, 80.0, 0.0, 98.0, 30.0, 0.0],
2028        ];
2029
2030        let solution = FastSolution::from_trajectory_data(times, states, [vec![], vec![], vec![]]);
2031
2032        // Test interpolation at t=1.5
2033        let result = solution.sol(&[1.5]);
2034
2035        assert!((result[0][0] - 149.0).abs() < 1e-10); // x position
2036        assert!((result[1][0] - 62.5).abs() < 1e-10); // y position
2037        assert!((result[3][0] - 98.5).abs() < 1e-10); // vx velocity
2038    }
2039
2040    #[test]
2041    fn test_bc_from_velocity_segments() {
2042        let segments = vec![
2043            BCSegmentData {
2044                velocity_min: 0.0,
2045                velocity_max: 1000.0,
2046                bc_value: 0.5,
2047            },
2048            BCSegmentData {
2049                velocity_min: 1000.0,
2050                velocity_max: 2000.0,
2051                bc_value: 0.52,
2052            },
2053            BCSegmentData {
2054                velocity_min: 2000.0,
2055                velocity_max: 3000.0,
2056                bc_value: 0.55,
2057            },
2058        ];
2059
2060        // MBA-1404: every one of these query points is >25 fps (half the 50 fps-capped
2061        // margin on all three 1000 fps-wide bands) away from the nearest boundary/coverage
2062        // edge, so none of them fall inside a smoothstep blend window -- values are
2063        // unchanged from the pre-MBA-1404 step behavior.
2064        assert_eq!(velocity_segment_bc(500.0, &segments, 0.5), 0.5);
2065        assert_eq!(velocity_segment_bc(1500.0, &segments, 0.5), 0.52);
2066        assert_eq!(velocity_segment_bc(2500.0, &segments, 0.5), 0.55);
2067
2068        // Test edge cases
2069        assert_eq!(velocity_segment_bc(-100.0, &segments, 0.5), 0.5); // Below min
2070        assert_eq!(velocity_segment_bc(3500.0, &segments, 0.5), 0.55); // Above max
2071    }
2072
2073    #[test]
2074    fn test_fast_solution_interpolation_edge_cases() {
2075        let times = vec![0.0, 1.0, 2.0, 3.0];
2076        let states = vec![
2077            [0.0, 0.0, 0.0, 800.0, 50.0, 0.0],
2078            [800.0, 40.0, 100.0, 750.0, 30.0, 0.0],
2079            [1550.0, 60.0, 200.0, 700.0, 10.0, 0.0],
2080            [2250.0, 50.0, 300.0, 650.0, -10.0, 0.0],
2081        ];
2082
2083        let solution = FastSolution::from_trajectory_data(times, states, [vec![], vec![], vec![]]);
2084
2085        // Test interpolation before first point
2086        let result_before = solution.sol(&[-0.5]);
2087        assert!((result_before[0][0] - 0.0).abs() < 1e-10); // Should clamp to first
2088
2089        // Test interpolation after last point
2090        let result_after = solution.sol(&[5.0]);
2091        assert!((result_after[0][0] - 2250.0).abs() < 1e-10); // Should clamp to last
2092
2093        // Test interpolation at exact points
2094        let result_exact = solution.sol(&[1.0]);
2095        assert!((result_exact[0][0] - 800.0).abs() < 1e-10);
2096
2097        // Test multiple query points
2098        let result_multi = solution.sol(&[0.5, 1.5, 2.5]);
2099        assert_eq!(result_multi[0].len(), 3);
2100    }
2101
2102    #[test]
2103    fn test_fast_solution_from_trajectory_data() {
2104        let times = vec![0.0, 0.5, 1.0];
2105        let states = vec![
2106            [0.0, 1.0, 2.0, 3.0, 4.0, 5.0],
2107            [10.0, 11.0, 12.0, 13.0, 14.0, 15.0],
2108            [20.0, 21.0, 22.0, 23.0, 24.0, 25.0],
2109        ];
2110        let t_events = [vec![1.0], vec![0.5], vec![]];
2111
2112        let solution = FastSolution::from_trajectory_data(times.clone(), states, t_events);
2113
2114        // Check that data is stored correctly
2115        assert_eq!(solution.t, times);
2116        assert_eq!(solution.y.len(), 6); // 6 state components
2117        assert_eq!(solution.y[0].len(), 3); // 3 time points
2118        assert!(solution.success);
2119
2120        // Verify column-major storage
2121        assert_eq!(solution.y[0][0], 0.0); // x at t=0
2122        assert_eq!(solution.y[1][0], 1.0); // y at t=0
2123        assert_eq!(solution.y[0][2], 20.0); // x at t=1.0
2124    }
2125
2126    #[test]
2127    fn test_bc_segments_boundary_conditions() {
2128        // Test with single segment
2129        // MBA-1404: a single-segment table has no adjacent band to blend against, so this
2130        // stays byte-identical to the pre-MBA-1404 step/clamp lookup everywhere.
2131        let single_segment = vec![BCSegmentData {
2132            velocity_min: 1000.0,
2133            velocity_max: 2000.0,
2134            bc_value: 0.5,
2135        }];
2136
2137        assert_eq!(velocity_segment_bc(500.0, &single_segment, 0.5), 0.5); // Below
2138        assert_eq!(velocity_segment_bc(1500.0, &single_segment, 0.5), 0.5); // In range
2139        assert_eq!(velocity_segment_bc(2500.0, &single_segment, 0.5), 0.5); // Above
2140
2141        // Test with exact boundary values
2142        // Half-open bands make a shared boundary belong to the band that starts there.
2143        // A ends at 999, B starts at 1000: a 1 fps interior gap falls back to bc=0.7.
2144        let segments = vec![
2145            BCSegmentData {
2146                velocity_min: 0.0,
2147                velocity_max: 999.0, // Exclusive upper bound to avoid overlap
2148                bc_value: 0.45,
2149            },
2150            BCSegmentData {
2151                velocity_min: 1000.0,
2152                velocity_max: 2000.0,
2153                bc_value: 0.50,
2154            },
2155        ];
2156
2157        // MBA-1404: the 1 fps gap caps both of its edges' margins to
2158        // min(50, 0.25*1) = 0.25 fps (half-margin 0.125 fps either side of 999 and 1000).
2159        // v=1000.0 is now the gap-entry boundary's exact center (t=0.5): it blends halfway
2160        // between the gap's fallback (0.7) and band B (0.50) instead of hard-stepping to
2161        // band B alone. This is a deliberate, exact-value update -- not a loosened
2162        // tolerance -- and would fail against the old step behavior (0.50).
2163        assert_eq!(velocity_segment_bc(1000.0, &segments, 0.7), 0.6); // At second segment start (blended)
2164                                                                      // v=0.0 is the coverage-entry boundary's center, but that edge is a no-op blend:
2165                                                                      // the clamp below band A returns the same value band A itself would, so this stays
2166                                                                      // exactly flat and is unchanged from before MBA-1404.
2167        assert_eq!(velocity_segment_bc(0.0, &segments, 0.7), 0.45); // At min (coverage edge: still flat, no jump to smooth)
2168                                                                    // v=998.999 is inside the gap-exit boundary's [998.875, 999.125] margin window
2169                                                                    // (t=0.496), partway through the smoothstep blend from band A (0.45) toward the
2170                                                                    // gap's fallback (0.7).
2171        assert_eq!(
2172            velocity_segment_bc(998.999, &segments, 0.7),
2173            0.5735000320000354
2174        ); // Just below exclusive max (blending toward the gap)
2175           // v=999.0 is the gap-exit boundary's exact center (t=0.5): halfway between band A
2176           // (0.45) and the fallback (0.7), not the old hard fallback value (0.7) alone.
2177        assert_eq!(velocity_segment_bc(999.0, &segments, 0.7), 0.575); // Gap starts at exclusive max (blended)
2178    }
2179
2180    #[test]
2181    fn velocity_segment_gaps_and_clamps_do_not_depend_on_order() {
2182        let fallback_bc = 0.73;
2183        let ascending_with_gap = vec![
2184            BCSegmentData {
2185                velocity_min: 0.0,
2186                velocity_max: 999.0,
2187                bc_value: 0.6,
2188            },
2189            BCSegmentData {
2190                velocity_min: 1000.0,
2191                velocity_max: 2000.0,
2192                bc_value: 0.8,
2193            },
2194        ];
2195        // MBA-1404: v=999.5 is dead center of the 1 fps gap, 0.5 fps from each edge --
2196        // comfortably outside either edge's +/-0.125 fps blend margin (min(50, 0.25*1)) --
2197        // so this stays exactly the fallback, unchanged from before MBA-1404.
2198        assert_eq!(
2199            velocity_segment_bc(999.5, &ascending_with_gap, fallback_bc),
2200            fallback_bc,
2201            "coverage gaps must use the projectile's base BC"
2202        );
2203
2204        let mut descending = ascending_with_gap.clone();
2205        descending.reverse();
2206        // MBA-1404: -100/2500 are far outside the +/-25 fps coverage-entry/exit margins
2207        // (min(50, 0.25*999) and min(50, 0.25*1000)), so these clamps are unchanged too.
2208        assert_eq!(
2209            velocity_segment_bc(-100.0, &descending, fallback_bc),
2210            0.6,
2211            "below coverage must clamp to the lowest-velocity band"
2212        );
2213        assert_eq!(
2214            velocity_segment_bc(2500.0, &descending, fallback_bc),
2215            0.8,
2216            "above coverage must clamp to the highest-velocity band"
2217        );
2218    }
2219
2220    #[test]
2221    fn test_bc_segments_empty_fallback() {
2222        let empty_segments: Vec<BCSegmentData> = vec![];
2223
2224        // MBA-1404: an empty table has no adjacent bands to blend against, so this stays
2225        // byte-identical to the pre-MBA-1404 behavior (always the fallback).
2226        // With empty segments, should return fallback value
2227        let result = velocity_segment_bc(1500.0, &empty_segments, 0.73);
2228        assert_eq!(result, 0.73); // Caller-provided fallback value
2229    }
2230
2231    #[test]
2232    fn test_fast_integration_params() {
2233        // Verify FastIntegrationParams struct can be constructed
2234        let params = FastIntegrationParams {
2235            horiz: 1000.0,
2236            vert: 0.0,
2237            initial_state: [0.0, 0.0, 0.0, 800.0, 50.0, 0.0], // McCoy: vx=downrange
2238            t_span: (0.0, 5.0),
2239            atmo_params: (0.0, 15.0, 1013.25, 1.0),
2240            atmo_sock: None,
2241        };
2242
2243        assert_eq!(params.horiz, 1000.0);
2244        assert_eq!(params.t_span.0, 0.0);
2245        assert_eq!(params.t_span.1, 5.0);
2246        assert_eq!(params.initial_state[3], 800.0); // vx (downrange, McCoy)
2247    }
2248
2249    #[test]
2250    fn test_fast_solution_event_arrays() {
2251        let times = vec![0.0, 1.0, 2.0];
2252        let states = vec![
2253            [0.0, 0.0, 0.0, 800.0, 50.0, 0.0],
2254            [800.0, 40.0, 500.0, 750.0, 30.0, 0.0],
2255            [1500.0, 20.0, 1000.0, 700.0, 10.0, 0.0],
2256        ];
2257
2258        // Create solution with events
2259        let t_events = [
2260            vec![2.0], // target_hit at t=2
2261            vec![0.5], // max_ord at t=0.5
2262            vec![],    // no ground_hit
2263        ];
2264
2265        let solution = FastSolution::from_trajectory_data(times, states, t_events);
2266
2267        assert_eq!(solution.t_events[0], vec![2.0]); // Target hit
2268        assert_eq!(solution.t_events[1], vec![0.5]); // Max ordinate
2269        assert!(solution.t_events[2].is_empty()); // No ground hit
2270    }
2271
2272    #[test]
2273    fn segmented_fast_path_interpolates_max_ordinate_between_saved_points() {
2274        let expected_apex_time = 5.105_f64;
2275        let downrange_velocity = 100.0_f64;
2276        let vertical_velocity = G_ACCEL_MPS2 * expected_apex_time;
2277        let inputs = BallisticInputs {
2278            muzzle_velocity: downrange_velocity.hypot(vertical_velocity),
2279            bc_value: 0.5,
2280            bc_type: DragModel::G7,
2281            use_enhanced_spin_drift: false,
2282            ..BallisticInputs::default()
2283        };
2284        let solution = fast_integrate_with_segments(
2285            &inputs,
2286            vec![],
2287            FastIntegrationParams {
2288                horiz: 1_000.0,
2289                vert: 0.0,
2290                initial_state: [0.0, 0.0, 0.0, downrange_velocity, vertical_velocity, 0.0],
2291                t_span: (0.0, 12.0),
2292                // Negligible density makes this an analytic constant-gravity arc while retaining
2293                // the valid direct-atmosphere sentinel.
2294                atmo_params: (1e-12, 340.0, 0.0, 0.0),
2295                atmo_sock: None,
2296            },
2297        );
2298
2299        assert!(solution.success);
2300        assert_eq!(solution.t_events[1].len(), 1);
2301        let reported_time = solution.t_events[1][0];
2302        assert!(
2303            (reported_time - expected_apex_time).abs() < 0.006,
2304            "max-ordinate time must be interpolated between coarse saves: reported={reported_time} expected={expected_apex_time}"
2305        );
2306        let event_index = solution
2307            .t
2308            .iter()
2309            .position(|time| time.to_bits() == reported_time.to_bits())
2310            .expect("the interpolated apex must be retained in the solution");
2311        assert_eq!(solution.y[4][event_index].to_bits(), 0.0_f64.to_bits());
2312
2313        let expected_height = vertical_velocity * expected_apex_time
2314            - 0.5 * G_ACCEL_MPS2 * expected_apex_time.powi(2);
2315        let event_state = solution.sol(&[reported_time]);
2316        assert!(
2317            (event_state[1][0] - expected_height).abs() < 2e-4,
2318            "max-ordinate state must preserve the interpolated apex height: reported={} expected={expected_height}",
2319            event_state[1][0]
2320        );
2321    }
2322
2323    #[test]
2324    fn plain_fast_path_interpolates_the_target_crossing() {
2325        let target = 500.123456789;
2326        let initial_state = [0.0, 0.0, 0.25, 800.0, 12.0, -2.5];
2327        let inputs = BallisticInputs {
2328            muzzle_velocity: 800.0,
2329            bc_value: 0.5,
2330            bc_type: DragModel::G7,
2331            ground_threshold: -100.0,
2332            use_enhanced_spin_drift: false,
2333            ..BallisticInputs::default()
2334        };
2335        let run = |horiz| {
2336            fast_integrate(
2337                &inputs,
2338                &WindSock::new(vec![]),
2339                FastIntegrationParams {
2340                    horiz,
2341                    vert: 0.0,
2342                    initial_state,
2343                    t_span: (0.0, 2.0),
2344                    atmo_params: (0.0, 15.0, 1013.25, 1.0),
2345                    atmo_sock: None,
2346                },
2347            )
2348        };
2349
2350        // A longer run retains the two full RK4 samples bracketing the requested target.
2351        let reference = run(target + 2.0);
2352        let left = reference.y[0]
2353            .windows(2)
2354            .position(|x| x[0] < target && x[1] > target)
2355            .expect("reference trajectory must bracket target");
2356        let right = left + 1;
2357        let alpha =
2358            (target - reference.y[0][left]) / (reference.y[0][right] - reference.y[0][left]);
2359
2360        let solution = run(target);
2361        let last = solution.t.len() - 1;
2362        assert_eq!(solution.y[0][last].to_bits(), target.to_bits());
2363        let expected_time = reference.t[left] + alpha * (reference.t[right] - reference.t[left]);
2364        assert!((solution.t[last] - expected_time).abs() < 1e-12);
2365        assert_eq!(solution.t_events[0], vec![solution.t[last]]);
2366
2367        for component in 0..6 {
2368            assert_eq!(solution.y[component].len(), solution.t.len());
2369            let expected = reference.y[component][left]
2370                + alpha * (reference.y[component][right] - reference.y[component][left]);
2371            assert!(
2372                (solution.y[component][last] - expected).abs() < 1e-9,
2373                "component {component} is not at the crossing: actual={}, expected={expected}",
2374                solution.y[component][last]
2375            );
2376        }
2377    }
2378
2379    #[test]
2380    fn fast_path_coriolis_uses_shot_direction() {
2381        // Regression: fast_integrate_with_segments (the path the Python binding uses)
2382        // built its Coriolis omega from azimuth_angle (the aiming offset, always ~0)
2383        // instead of shot_azimuth, so east and west shots came out identical. After the
2384        // fix they must differ with the correct Eotvos sign (east lifted, higher).
2385        use std::f64::consts::FRAC_PI_2;
2386        // Returns (final_downrange, final_vertical) for a shot fired along `shot_az`.
2387        fn final_xy(shot_az: f64) -> (f64, f64) {
2388            let inputs = BallisticInputs {
2389                muzzle_velocity: 800.0,
2390                bc_value: 0.5,
2391                bc_type: DragModel::G7,
2392                enable_advanced_effects: true, // gates the omega vector
2393                enable_coriolis: true,
2394                latitude: Some(45.0),
2395                shot_azimuth: shot_az,
2396                ..BallisticInputs::default()
2397            };
2398            let v = 800.0_f64;
2399            let elev = 0.02_f64;
2400            let params = FastIntegrationParams {
2401                horiz: 1000.0,
2402                vert: 0.0,
2403                initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
2404                t_span: (0.0, 5.0),
2405                atmo_params: (0.0, 15.0, 1013.25, 1.0),
2406                atmo_sock: None,
2407            };
2408            let sol = fast_integrate_with_segments(&inputs, vec![], params);
2409            let n = sol.y[0].len();
2410            (sol.y[0][n - 1], sol.y[1][n - 1])
2411        }
2412        let (ex, ey) = final_xy(FRAC_PI_2); // east
2413        let (wx, wy) = final_xy(3.0 * FRAC_PI_2); // west
2414                                                  // Both shots cover essentially the same downrange (Coriolis barely affects x),
2415                                                  // so comparing the final vertical is apples-to-apples.
2416        assert!(
2417            (ex - wx).abs() < 0.5,
2418            "east/west downrange should be ~equal (ex={ex:.4}, wx={wx:.4})"
2419        );
2420        // Pre-fix the fast path was North-locked, making these byte-identical. The Eotvos
2421        // term now lifts the east shot above the west shot.
2422        assert!(
2423            ey > wy,
2424            "fast-path east ({ey:.6}) must be higher than west ({wy:.6}) (Eotvos)"
2425        );
2426        assert!(
2427            (ey - wy) > 1e-5,
2428            "fast-path E-W vertical separation ({:.8} m) should be non-zero (the pre-fix bug was exact equality)",
2429            ey - wy
2430        );
2431    }
2432
2433    #[test]
2434    fn fast_path_coriolis_independent_of_advanced_effects() {
2435        // Coriolis is now gated on enable_coriolis (+ latitude), NOT enable_advanced_effects.
2436        // So a caller can request Coriolis-only without being forced to enable spin/Magnus.
2437        use std::f64::consts::FRAC_PI_2;
2438        fn final_y(coriolis: bool, shot_az: f64) -> f64 {
2439            let inputs = BallisticInputs {
2440                muzzle_velocity: 800.0,
2441                bc_value: 0.5,
2442                bc_type: DragModel::G7,
2443                enable_coriolis: coriolis,
2444                enable_advanced_effects: false, // explicitly OFF — Coriolis must still work
2445                latitude: Some(45.0),
2446                shot_azimuth: shot_az,
2447                ..BallisticInputs::default()
2448            };
2449            let v = 800.0_f64;
2450            let elev = 0.02_f64;
2451            let params = FastIntegrationParams {
2452                horiz: 1000.0,
2453                vert: 0.0,
2454                initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
2455                t_span: (0.0, 5.0),
2456                atmo_params: (0.0, 15.0, 1013.25, 1.0),
2457                atmo_sock: None,
2458            };
2459            let sol = fast_integrate_with_segments(&inputs, vec![], params);
2460            let n = sol.y[0].len();
2461            sol.y[1][n - 1]
2462        }
2463        // enable_coriolis=true with advanced effects OFF: directional Coriolis still applies.
2464        let e = final_y(true, FRAC_PI_2);
2465        let w = final_y(true, 3.0 * FRAC_PI_2);
2466        assert!(
2467            e > w && (e - w) > 1e-5,
2468            "Coriolis-only (no advanced effects) must still be directional: E={e} W={w}"
2469        );
2470        // enable_coriolis=false: no Coriolis at all, east == west.
2471        let e2 = final_y(false, FRAC_PI_2);
2472        let w2 = final_y(false, 3.0 * FRAC_PI_2);
2473        assert!(
2474            (e2 - w2).abs() < 1e-9,
2475            "with enable_coriolis=false, east/west must be identical: E={e2} W={w2}"
2476        );
2477    }
2478
2479    #[test]
2480    fn fast_path_rejects_degenerate_atmosphere() {
2481        let inputs = BallisticInputs {
2482            muzzle_velocity: 800.0,
2483            bc_value: 0.5,
2484            bc_type: DragModel::G7,
2485            ..BallisticInputs::default()
2486        };
2487        let v = 800.0_f64;
2488        let e = 0.02_f64;
2489        let mk = |atmo: (f64, f64, f64, f64)| FastIntegrationParams {
2490            horiz: 500.0,
2491            vert: 0.0,
2492            initial_state: [0.0, 0.0, 0.0, v * e.cos(), v * e.sin(), 0.0],
2493            t_span: (0.0, 5.0),
2494            atmo_params: atmo,
2495            atmo_sock: None,
2496        };
2497        // pressure <= 0 -> fail loudly (success=false) instead of a 1-point stub as success.
2498        let zero_p = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 0.0, 1.0)));
2499        assert!(
2500            !zero_p.success,
2501            "pressure=0 atmosphere must yield success=false"
2502        );
2503        // non-finite pressure -> also rejected.
2504        let nan_p = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, f64::NAN, 1.0)));
2505        assert!(!nan_p.success, "NaN pressure must yield success=false");
2506        // A supplied density ratio must imply a physically plausible density in both wrappers.
2507        let segmented_too_dense =
2508            fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 1013.25, 50.0)));
2509        let plain_too_dense = fast_integrate(
2510            &inputs,
2511            &WindSock::new(vec![]),
2512            mk((0.0, 15.0, 1013.25, 50.0)),
2513        );
2514        assert!(
2515            !segmented_too_dense.success && !plain_too_dense.success,
2516            "ratio=50 atmosphere must fail in both wrappers: segmented={}, plain={}",
2517            segmented_too_dense.success,
2518            plain_too_dense.success
2519        );
2520        // realistic atmosphere -> success.
2521        let good = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 1013.25, 1.0)));
2522        assert!(good.success, "realistic atmosphere must yield success=true");
2523        // Direct-atmosphere mode (density, speed_of_sound, 0, 0) is legitimate and must NOT
2524        // be rejected by the guard (regression: 0.21.2 rejected it via the pressure<=0 check).
2525        let direct = fast_integrate_with_segments(&inputs, vec![], mk((1.225, 340.0, 0.0, 0.0)));
2526        assert!(
2527            direct.success,
2528            "direct-atmosphere mode (pressure=0 sentinel) must yield success=true"
2529        );
2530    }
2531
2532    #[test]
2533    fn fast_paths_reject_invalid_wind_segments() {
2534        let inputs = BallisticInputs {
2535            muzzle_velocity: 800.0,
2536            bc_value: 0.5,
2537            bc_type: DragModel::G7,
2538            ..BallisticInputs::default()
2539        };
2540        let mk = || FastIntegrationParams {
2541            horiz: 100.0,
2542            vert: 0.0,
2543            initial_state: [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
2544            t_span: (0.0, 5.0),
2545            atmo_params: (0.0, 15.0, 1013.25, 1.0),
2546            atmo_sock: None,
2547        };
2548        let invalid = crate::wind::WindSegment::new(10.0, 90.0, f64::NAN);
2549
2550        let plain = fast_integrate(&inputs, &WindSock::new(vec![invalid]), mk());
2551        let segmented = fast_integrate_with_segments(&inputs, vec![invalid], mk());
2552
2553        assert!(
2554            !plain.success && !segmented.success,
2555            "invalid segmented wind must fail in both fast wrappers: plain={}, segmented={}",
2556            plain.success,
2557            segmented.success
2558        );
2559    }
2560
2561    #[test]
2562    fn plain_fast_path_honors_direct_atmosphere_values() {
2563        fn final_speed(muzzle_velocity: f64, atmo_params: (f64, f64, f64, f64)) -> f64 {
2564            let inputs = BallisticInputs {
2565                muzzle_velocity,
2566                bc_value: 0.5,
2567                bc_type: DragModel::G7,
2568                ground_threshold: -100.0,
2569                ..BallisticInputs::default()
2570            };
2571
2572            let wind_sock = WindSock::new(vec![]);
2573            let solution = fast_integrate(
2574                &inputs,
2575                &wind_sock,
2576                FastIntegrationParams {
2577                    horiz: 10_000.0,
2578                    vert: 0.0,
2579                    initial_state: [0.0, 0.0, 0.0, muzzle_velocity, 0.0, 0.0],
2580                    t_span: (0.0, 0.2),
2581                    atmo_params,
2582                    atmo_sock: None,
2583                },
2584            );
2585            assert!(solution.success);
2586
2587            let last = solution.y[0].len() - 1;
2588            (solution.y[3][last].powi(2)
2589                + solution.y[4][last].powi(2)
2590                + solution.y[5][last].powi(2))
2591            .sqrt()
2592        }
2593
2594        let thin_air = final_speed(800.0, (0.905, 340.0, 0.0, 0.0));
2595        let dense_air = final_speed(800.0, (1.225, 340.0, 0.0, 0.0));
2596        assert!(
2597            thin_air > dense_air,
2598            "lower supplied density must retain more velocity: thin={thin_air}, dense={dense_air}"
2599        );
2600
2601        let low_sound_speed = final_speed(340.0, (1.0, 300.0, 0.0, 0.0));
2602        let high_sound_speed = final_speed(340.0, (1.0, 400.0, 0.0, 0.0));
2603        assert!(
2604            (low_sound_speed - high_sound_speed).abs() > 1e-6,
2605            "supplied sound speed must affect Mach-dependent drag"
2606        );
2607    }
2608
2609    #[test]
2610    fn segmented_fast_path_nonpositive_density_ratio_uses_standard_fallback() {
2611        fn terminal_velocity(base_ratio: f64) -> f64 {
2612            let inputs = BallisticInputs {
2613                muzzle_velocity: 800.0,
2614                bc_value: 0.5,
2615                bc_type: DragModel::G7,
2616                ground_threshold: -100.0,
2617                ..BallisticInputs::default()
2618            };
2619
2620            let solution = fast_integrate_with_segments(
2621                &inputs,
2622                vec![],
2623                FastIntegrationParams {
2624                    horiz: 500.0,
2625                    vert: 0.0,
2626                    initial_state: [0.0, 0.0, 0.0, 800.0, 0.0, 0.0],
2627                    t_span: (0.0, 5.0),
2628                    atmo_params: (0.0, 15.0, 1013.25, base_ratio),
2629                    atmo_sock: None,
2630                },
2631            );
2632            assert!(solution.success);
2633
2634            let last = solution.y[3].len() - 1;
2635            solution.y[3][last]
2636        }
2637
2638        let explicit_sea_level = terminal_velocity(1.0);
2639        for base_ratio in [0.0, -1.0] {
2640            let missing_ratio = terminal_velocity(base_ratio);
2641            assert!(
2642                missing_ratio < 800.0,
2643                "missing density ratio must not create a vacuum trajectory: {missing_ratio}"
2644            );
2645            assert!((missing_ratio - explicit_sea_level).abs() < 1e-9);
2646        }
2647    }
2648
2649    #[test]
2650    fn fast_path_carries_real_bullet_geometry() {
2651        // MBA-717: build_inputs hardcoded diameter=.308 / length=1.24in / twist=10 because
2652        // TrajectoryParams didn't carry them. They're now plumbed through, so the BallisticInputs
2653        // the derivatives see reflect the real bullet (caliber gates the Magnus block at
2654        // bullet_diameter > 0.0). Guard against regressing back to the hardcoded values: a
2655        // zero-diameter input must reach the derivatives as zero (Magnus skipped), whereas the
2656        // old code would have forced .308 regardless. We assert the run still completes and the
2657        // two geometries don't crash — the data path is exercised end-to-end.
2658        let run = |diameter: f64, twist: f64| {
2659            let inputs = BallisticInputs {
2660                muzzle_velocity: 800.0,
2661                bc_value: 0.5,
2662                bc_type: DragModel::G7,
2663                bullet_diameter: diameter,
2664                bullet_length: 0.0318,
2665                twist_rate: twist,
2666                enable_advanced_effects: true,
2667                enable_magnus: true,
2668                ..BallisticInputs::default()
2669            };
2670            let v = 800.0_f64;
2671            let elev = 0.02_f64;
2672            let params = FastIntegrationParams {
2673                horiz: 1000.0,
2674                vert: 0.0,
2675                initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
2676                t_span: (0.0, 5.0),
2677                atmo_params: (0.0, 15.0, 1013.25, 1.0),
2678                atmo_sock: None,
2679            };
2680            fast_integrate_with_segments(&inputs, vec![], params)
2681        };
2682        // Both a real .224 and a real .338 produce a valid trajectory (geometry is honored, not
2683        // overridden by a hardcoded .308). Regression sentinel for the plumbing.
2684        assert!(run(0.00569, 7.0).success, ".224 geometry must solve");
2685        assert!(run(0.00858, 10.0).success, ".338 geometry must solve");
2686    }
2687
2688    #[test]
2689    fn segmented_fast_spin_flags_do_not_depend_on_advanced_umbrella() {
2690        fn endpoint(
2691            enable_advanced_effects: bool,
2692            enable_magnus: bool,
2693            use_enhanced_spin_drift: bool,
2694        ) -> Vector3<f64> {
2695            let inputs = BallisticInputs {
2696                muzzle_velocity: 823.0,
2697                bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
2698                bullet_diameter: 0.308 * 0.0254,
2699                bullet_length: 1.215 * 0.0254,
2700                caliber_inches: 0.308,
2701                weight_grains: 168.0,
2702                bc_value: 0.475,
2703                bc_type: DragModel::G1,
2704                twist_rate: 12.0,
2705                is_twist_right: true,
2706                enable_advanced_effects,
2707                enable_magnus,
2708                use_enhanced_spin_drift,
2709                ..BallisticInputs::default()
2710            };
2711            let elevation = 0.02_f64;
2712            let solution = fast_integrate_with_segments(
2713                &inputs,
2714                vec![],
2715                FastIntegrationParams {
2716                    horiz: 1_000.0,
2717                    vert: 0.0,
2718                    initial_state: [
2719                        0.0,
2720                        0.0,
2721                        0.0,
2722                        inputs.muzzle_velocity * elevation.cos(),
2723                        inputs.muzzle_velocity * elevation.sin(),
2724                        0.0,
2725                    ],
2726                    t_span: (0.0, 5.0),
2727                    atmo_params: (0.0, 15.0, 1013.25, 1.0),
2728                    atmo_sock: None,
2729                },
2730            );
2731            assert!(solution.success);
2732            let last = solution.t.len() - 1;
2733            Vector3::new(
2734                solution.y[0][last],
2735                solution.y[1][last],
2736                solution.y[2][last],
2737            )
2738        }
2739
2740        let baseline = endpoint(false, false, false);
2741        let magnus_without_umbrella = endpoint(false, true, false);
2742        let magnus_with_umbrella = endpoint(true, true, false);
2743        assert!(
2744            (magnus_without_umbrella - baseline).norm() > 1e-5,
2745            "test shot must produce a measurable Magnus displacement"
2746        );
2747        assert!(
2748            (magnus_with_umbrella - magnus_without_umbrella).norm() < 1e-12,
2749            "the legacy umbrella must not suppress explicitly enabled Magnus: without={magnus_without_umbrella:?} with={magnus_with_umbrella:?}"
2750        );
2751
2752        let litz_only = endpoint(false, false, true);
2753        let litz_without_umbrella = endpoint(false, true, true);
2754        let litz_with_umbrella = endpoint(true, true, true);
2755        assert!(
2756            (litz_without_umbrella - litz_only).norm() < 1e-12,
2757            "Litz mode must suppress explicitly enabled Magnus: litz={litz_only:?} both={litz_without_umbrella:?}"
2758        );
2759        assert!(
2760            (litz_with_umbrella - litz_without_umbrella).norm() < 1e-12,
2761            "the legacy umbrella must not change Magnus suppression in Litz mode: without={litz_without_umbrella:?} with={litz_with_umbrella:?}"
2762        );
2763    }
2764
2765    /// MBA-1356: cd_scale — this module's custom-deck interpolation site.
2766    fn deck_test_inputs(cd_scale: f64) -> BallisticInputs {
2767        BallisticInputs {
2768            bullet_mass: 0.0106,
2769            bullet_diameter: 0.00782,
2770            muzzle_velocity: 850.0,
2771            custom_drag_table: Some(crate::drag::DragTable::new(
2772                vec![0.5, 1.0, 2.0, 3.0],
2773                vec![0.23, 0.40, 0.30, 0.26],
2774            )),
2775            cd_scale,
2776            temperature: 15.0,
2777            pressure: 1013.25,
2778            ..BallisticInputs::default()
2779        }
2780    }
2781
2782    fn deck_accel_x(cd_scale: f64) -> f64 {
2783        let inputs = deck_test_inputs(cd_scale);
2784        compute_derivatives_with_gravity(
2785            &[0.0, 0.0, 0.0, 700.0, 0.0, 0.0],
2786            &inputs,
2787            &WindSock::new(vec![]),
2788            FastAtmosphere::Standard {
2789                base_density: 1.225,
2790            },
2791            &inputs.bc_type,
2792            crate::transonic_drag::ProjectileShape::Spitzer,
2793            inputs.bc_value,
2794            false,
2795            false,
2796            None,
2797            None,
2798            None,
2799            Gravity::EARTH,
2800        )[3]
2801    }
2802
2803    #[test]
2804    fn cd_scale_default_is_one_and_absent_matches_explicit() {
2805        assert_eq!(BallisticInputs::default().cd_scale, 1.0);
2806
2807        let omitted_inputs = BallisticInputs {
2808            bullet_mass: 0.0106,
2809            bullet_diameter: 0.00782,
2810            muzzle_velocity: 850.0,
2811            custom_drag_table: Some(crate::drag::DragTable::new(
2812                vec![0.5, 1.0, 2.0, 3.0],
2813                vec![0.23, 0.40, 0.30, 0.26],
2814            )),
2815            temperature: 15.0,
2816            pressure: 1013.25,
2817            ..BallisticInputs::default()
2818        };
2819        assert_eq!(omitted_inputs.cd_scale, 1.0);
2820
2821        let a_omitted = compute_derivatives_with_gravity(
2822            &[0.0, 0.0, 0.0, 700.0, 0.0, 0.0],
2823            &omitted_inputs,
2824            &WindSock::new(vec![]),
2825            FastAtmosphere::Standard {
2826                base_density: 1.225,
2827            },
2828            &omitted_inputs.bc_type,
2829            crate::transonic_drag::ProjectileShape::Spitzer,
2830            omitted_inputs.bc_value,
2831            false,
2832            false,
2833            None,
2834            None,
2835            None,
2836            Gravity::EARTH,
2837        )[3];
2838        let a_explicit = deck_accel_x(1.0);
2839        assert_eq!(
2840            a_omitted.to_bits(),
2841            a_explicit.to_bits(),
2842            "omitted cd_scale (Default) must be bit-identical to an explicit 1.0"
2843        );
2844    }
2845
2846    /// (b) Scale-direction test, fast_trajectory path: cd_scale=1.10 must add more drag
2847    /// (a more negative x-acceleration) than 1.0; 0.90 must add less.
2848    #[test]
2849    fn cd_scale_direction_on_fast_trajectory_kernel() {
2850        let baseline = deck_accel_x(1.0);
2851        let scaled_up = deck_accel_x(1.10);
2852        let scaled_down = deck_accel_x(0.90);
2853
2854        assert!(
2855            scaled_up < baseline,
2856            "cd_scale=1.10 must increase drag deceleration (more negative ax): \
2857             base={baseline} up={scaled_up}"
2858        );
2859        assert!(
2860            scaled_down > baseline,
2861            "cd_scale=0.90 must decrease drag deceleration (less negative ax): \
2862             base={baseline} down={scaled_down}"
2863        );
2864    }
2865
2866    /// cd_scale must be inert on the standard G-model/BC path (no custom_drag_table).
2867    #[test]
2868    fn cd_scale_is_inert_without_a_custom_drag_table() {
2869        let make = |cd_scale: f64| BallisticInputs {
2870            bc_value: 0.5,
2871            bc_type: DragModel::G1,
2872            temperature: 15.0,
2873            pressure: 1013.25,
2874            cd_scale,
2875            ..BallisticInputs::default()
2876        };
2877        let accel = |cd_scale: f64| {
2878            let inputs = make(cd_scale);
2879            compute_derivatives_with_gravity(
2880                &[0.0, 0.0, 0.0, 700.0, 0.0, 0.0],
2881                &inputs,
2882                &WindSock::new(vec![]),
2883                FastAtmosphere::Standard {
2884                    base_density: 1.225,
2885                },
2886                &inputs.bc_type,
2887                crate::transonic_drag::ProjectileShape::Spitzer,
2888                inputs.bc_value,
2889                false,
2890                false,
2891                None,
2892                None,
2893                None,
2894                Gravity::EARTH,
2895            )[3]
2896        };
2897        assert_eq!(
2898            accel(1.0).to_bits(),
2899            accel(1.5).to_bits(),
2900            "cd_scale must not affect the G-model/BC drag path"
2901        );
2902    }
2903
2904    /// Fly `inputs` through the binding entry point and return the airspeed at t = 1.5 s.
2905    fn segmented_speed_at_1_5_s(inputs: &BallisticInputs) -> f64 {
2906        let v = inputs.muzzle_velocity;
2907        let elev = 0.012_f64;
2908        let solution = fast_integrate_with_segments(
2909            inputs,
2910            vec![],
2911            FastIntegrationParams {
2912                horiz: 1000.0,
2913                vert: 0.0,
2914                initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
2915                t_span: (0.0, 5.0),
2916                atmo_params: (0.0, 15.0, 1013.25, 1.0),
2917                atmo_sock: None,
2918            },
2919        );
2920        assert!(solution.success);
2921        let s = solution.sol(&[1.5]);
2922        (s[3][0].powi(2) + s[4][0].powi(2) + s[5][0].powi(2)).sqrt()
2923    }
2924
2925    fn mba1602_308_inputs() -> BallisticInputs {
2926        BallisticInputs {
2927            muzzle_velocity: 792.48,
2928            bullet_mass: 0.01134,
2929            bullet_diameter: 0.0078232,
2930            bullet_length: 0.031496,
2931            bc_value: 0.505,
2932            bc_type: DragModel::G1,
2933            ..BallisticInputs::default()
2934        }
2935    }
2936
2937    #[test]
2938    fn segmented_fast_path_flies_caller_velocity_bc_ladder() {
2939        // MBA-1602: fast_integrate_with_segments (the Python binding's fast_integrate) built
2940        // its derivative inputs with bc_segments_data: None, so a caller's ladder was dropped
2941        // and, with use_bc_segments on, silently replaced by the auto-estimated one. A one-band
2942        // ladder at BC X must fly exactly like the scalar BC X.
2943        let base = mba1602_308_inputs();
2944        let mut ladder = base.clone();
2945        ladder.use_bc_segments = true;
2946        ladder.bc_segments_data = Some(vec![BCSegmentData {
2947            velocity_min: 0.0,
2948            velocity_max: 5000.0,
2949            bc_value: 0.300,
2950        }]);
2951        let scalar = BallisticInputs {
2952            bc_value: 0.300,
2953            ..base.clone()
2954        };
2955        let mut estimated = base.clone();
2956        estimated.use_bc_segments = true;
2957
2958        let flown = segmented_speed_at_1_5_s(&ladder);
2959        let expected = segmented_speed_at_1_5_s(&scalar);
2960        let auto = segmented_speed_at_1_5_s(&estimated);
2961        assert!(
2962            (flown - expected).abs() < 1e-9,
2963            "a one-band 0.300 ladder must fly as scalar BC 0.300: ladder={flown} scalar={expected}"
2964        );
2965        assert!(
2966            (flown - auto).abs() > 10.0,
2967            "the caller's ladder must not be replaced by the auto-estimate: ladder={flown} estimate={auto}"
2968        );
2969    }
2970
2971    #[test]
2972    fn segmented_fast_path_converts_army_standard_metro_caller_ladder() {
2973        // MBA-1602 + MBA-1365: the ladder is threaded AFTER normalize_for_solve, so an ASM ladder
2974        // flies as its ICAO equivalent, exactly like an ASM scalar.
2975        let base = mba1602_308_inputs();
2976        let mut ladder = base.clone();
2977        ladder.bc_reference_standard = crate::cli_api::BcReferenceStandard::ArmyStandardMetro;
2978        ladder.use_bc_segments = true;
2979        ladder.bc_segments_data = Some(vec![BCSegmentData {
2980            velocity_min: 0.0,
2981            velocity_max: 5000.0,
2982            bc_value: 0.300,
2983        }]);
2984        let scalar = BallisticInputs {
2985            bc_value: 0.300,
2986            bc_reference_standard: crate::cli_api::BcReferenceStandard::ArmyStandardMetro,
2987            ..base
2988        };
2989        let flown = segmented_speed_at_1_5_s(&ladder);
2990        let expected = segmented_speed_at_1_5_s(&scalar);
2991        assert!(
2992            (flown - expected).abs() < 1e-9,
2993            "an ASM ladder must be converted like an ASM scalar: ladder={flown} scalar={expected}"
2994        );
2995    }
2996
2997    #[test]
2998    fn segmented_fast_path_empty_ladder_matches_absent_ladder() {
2999        // MBA-1602: an empty ladder is no ladder. With use_bc_segments on, Some([]) must give the
3000        // byte-identical result None gives (today's auto-estimate on this path).
3001        let mut absent = mba1602_308_inputs();
3002        absent.use_bc_segments = true;
3003        let mut empty = absent.clone();
3004        empty.bc_segments_data = Some(Vec::new());
3005        assert_eq!(
3006            segmented_speed_at_1_5_s(&empty).to_bits(),
3007            segmented_speed_at_1_5_s(&absent).to_bits(),
3008            "Some(empty) must behave exactly like None"
3009        );
3010    }
3011
3012    /// A small reference curve and a one-band ladder whose BC differs from the scalar 0.505.
3013    fn reference_table_inputs(band_bc: f64) -> BallisticInputs {
3014        BallisticInputs {
3015            custom_drag_table: Some(crate::drag::DragTable::new(
3016                vec![0.5, 1.0, 2.0, 3.0],
3017                vec![0.23, 0.40, 0.30, 0.26],
3018            )),
3019            custom_drag_reference: true,
3020            use_bc_segments: true,
3021            bc_segments_data: Some(vec![BCSegmentData {
3022                velocity_min: 0.0,
3023                velocity_max: 5000.0,
3024                bc_value: band_bc,
3025            }]),
3026            ..mba1602_308_inputs()
3027        }
3028    }
3029
3030    /// The same curve read as the projectile's own Cd, pre-scaled by SD / `bc`: algebraically
3031    /// `Cd / bc`, the quotient a reference curve flown with BC `bc` must produce.
3032    fn own_curve_oracle(bc: f64) -> BallisticInputs {
3033        let base = mba1602_308_inputs();
3034        // The entry points re-derive the imperial mirrors from the SI mass and diameter, so
3035        // read the sectional density they will divide by off normalized inputs.
3036        let mut normalized = base.clone();
3037        normalized.normalize_for_solve();
3038        let sd = normalized.sectional_density_lb_in2().expect("SD");
3039        BallisticInputs {
3040            custom_drag_table: reference_table_inputs(bc).custom_drag_table,
3041            cd_scale: sd / bc,
3042            ..base
3043        }
3044    }
3045
3046    /// `custom_drag_reference` reaches the binding entry point's kernel (derivatives.rs through
3047    /// `TrajectoryParams`), which solve-json never drives. The curve flies over the ladder's band
3048    /// BC, not the scalar and not sectional density; a flag dropped on the way would fly it as
3049    /// the projectile's own Cd, which is a different trajectory.
3050    #[test]
3051    fn segmented_fast_path_flies_a_reference_table_over_the_ladder_bc() {
3052        for band_bc in [0.30, 0.40] {
3053            let flown = segmented_speed_at_1_5_s(&reference_table_inputs(band_bc));
3054            let expected = segmented_speed_at_1_5_s(&own_curve_oracle(band_bc));
3055            assert!(
3056                (flown - expected).abs() < 1e-9 * expected,
3057                "band {band_bc}: flown {flown}, expected {expected}"
3058            );
3059        }
3060        let dropped = BallisticInputs {
3061            custom_drag_reference: false,
3062            ..reference_table_inputs(0.30)
3063        };
3064        let flown = segmented_speed_at_1_5_s(&reference_table_inputs(0.30));
3065        let own_curve = segmented_speed_at_1_5_s(&dropped);
3066        assert!(
3067            (own_curve - flown).abs() > 5.0,
3068            "the fixture must tell the readings apart: own curve {own_curve} vs reference {flown}"
3069        );
3070    }
3071
3072    /// The same for `fast_integrate`'s own kernel in this module.
3073    #[test]
3074    fn fast_path_flies_a_reference_table_over_the_ladder_bc() {
3075        let final_speed = |inputs: &BallisticInputs| {
3076            let v = inputs.muzzle_velocity;
3077            let solution = fast_integrate(
3078                inputs,
3079                &WindSock::new(vec![]),
3080                FastIntegrationParams {
3081                    horiz: 500.0,
3082                    vert: 0.0,
3083                    initial_state: [0.0, 0.0, 0.0, v, 0.0, 0.0],
3084                    t_span: (0.0, 5.0),
3085                    atmo_params: (0.0, 15.0, 1013.25, 1.0),
3086                    atmo_sock: None,
3087                },
3088            );
3089            assert!(solution.success);
3090            let last = solution.t.len() - 1;
3091            (solution.y[3][last].powi(2)
3092                + solution.y[4][last].powi(2)
3093                + solution.y[5][last].powi(2))
3094            .sqrt()
3095        };
3096        for band_bc in [0.30, 0.40] {
3097            let flown = final_speed(&reference_table_inputs(band_bc));
3098            let expected = final_speed(&own_curve_oracle(band_bc));
3099            assert!(
3100                (flown - expected).abs() < 1e-9 * expected,
3101                "band {band_bc}: flown {flown}, expected {expected}"
3102            );
3103        }
3104        let dropped = BallisticInputs {
3105            custom_drag_reference: false,
3106            ..reference_table_inputs(0.30)
3107        };
3108        assert!(
3109            (final_speed(&dropped) - final_speed(&reference_table_inputs(0.30))).abs() > 5.0,
3110            "the fixture must tell the readings apart"
3111        );
3112    }
3113}