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