Skip to main content

ballistics_engine/
fast_trajectory.rs

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