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    constants::{G_ACCEL_MPS2, MPS_TO_FPS},
9    drag::get_drag_coefficient,
10    wind::WindSock,
11    BCSegmentData, DragModel, InternalBallisticInputs as BallisticInputs,
12};
13use nalgebra::Vector3;
14
15/// Fast solution container matching Python implementation
16#[derive(Debug, Clone)]
17pub struct FastSolution {
18    /// Time points
19    pub t: Vec<f64>,
20    /// State vectors at each time point [6 x n_points]
21    pub y: Vec<Vec<f64>>,
22    /// Event times [target_hit, max_ord, ground_hit]
23    pub t_events: [Vec<f64>; 3],
24    /// Whether integration succeeded
25    pub success: bool,
26}
27
28impl FastSolution {
29    /// Interpolate solution at time t
30    pub fn sol(&self, t_query: &[f64]) -> Vec<Vec<f64>> {
31        let mut result = vec![vec![0.0; t_query.len()]; 6];
32
33        for (i, &tq) in t_query.iter().enumerate() {
34            // Find the right interval using binary search
35            // Use unwrap_or to safely handle NaN values by treating them as greater
36            let idx = match self
37                .t
38                .binary_search_by(|&t| t.partial_cmp(&tq).unwrap_or(std::cmp::Ordering::Greater))
39            {
40                Ok(idx) => idx,
41                Err(idx) => idx,
42            };
43
44            if idx == 0 {
45                // Before first point
46                for j in 0..6 {
47                    result[j][i] = self.y[j][0];
48                }
49            } else if idx >= self.t.len() {
50                // After last point
51                for j in 0..6 {
52                    result[j][i] = self.y[j][self.t.len() - 1];
53                }
54            } else {
55                // Linear interpolation
56                let t0 = self.t[idx - 1];
57                let t1 = self.t[idx];
58                let span = t1 - t0;
59
60                for j in 0..6 {
61                    let y0 = self.y[j][idx - 1];
62                    let y1 = self.y[j][idx];
63                    result[j][i] = if span.abs() < f64::EPSILON {
64                        y1
65                    } else {
66                        let frac = (tq - t0) / span;
67                        y0 + frac * (y1 - y0)
68                    };
69                }
70            }
71        }
72
73        result
74    }
75
76    /// Convert from row-major to column-major format for compatibility
77    pub fn from_trajectory_data(
78        times: Vec<f64>,
79        states: Vec<[f64; 6]>,
80        t_events: [Vec<f64>; 3],
81    ) -> Self {
82        let n_points = times.len();
83        let mut y = vec![vec![0.0; n_points]; 6];
84
85        for (i, state) in states.iter().enumerate() {
86            for j in 0..6 {
87                y[j][i] = state[j];
88            }
89        }
90
91        FastSolution {
92            t: times,
93            y,
94            t_events,
95            success: true,
96        }
97    }
98
99    /// A clearly-failed solution carrying only the launch state, with `success = false`.
100    /// Used when inputs are too degenerate to integrate (e.g. non-physical atmosphere),
101    /// so callers see `success = false` instead of a stub trajectory reported as success.
102    fn degenerate(initial_state: &[f64; 6]) -> Self {
103        let mut y = vec![Vec::new(); 6];
104        for (j, slot) in y.iter_mut().enumerate() {
105            slot.push(initial_state[j]);
106        }
107        FastSolution {
108            t: vec![0.0],
109            y,
110            t_events: [Vec::new(), Vec::new(), Vec::new()],
111            success: false,
112        }
113    }
114}
115
116/// True if `atmo_params` can yield a finite, positive air density. `atmo_params` has TWO
117/// modes that `compute_derivatives` distinguishes:
118///   * **Standard**: `(base_alt_m, base_temp_c, base_pressure_hPa, base_density_ratio)` —
119///     positive station pressure. (Slot 3 is a density RATIO, not humidity, despite the
120///     `humidity` field it lands in via `build_inputs`.)
121///   * **Direct**: `(air_density, speed_of_sound, 0.0, 0.0)` — slots 2 and 3 are `0.0`
122///     sentinels, with a real density (< 2.0 kg/m³) and speed of sound (> 200 m/s).
123///
124/// A pressure <= 0 that ISN'T the direct-mode sentinel (or any non-finite value) — often a
125/// unit mistake like inHg where hPa is expected — gives zero/NaN density and would silently
126/// truncate the integration to a single point, so it's rejected. (Earlier this guard also
127/// rejected legitimate direct-mode input; the direct-mode allowance below fixes that.)
128fn atmo_is_physical(atmo_params: (f64, f64, f64, f64)) -> bool {
129    let (a, b, c, d) = atmo_params;
130    if !(a.is_finite() && b.is_finite() && c.is_finite() && d.is_finite()) {
131        return false;
132    }
133    // Direct-atmosphere mode: (density, speed_of_sound, 0, 0). Constants mirror
134    // derivatives.rs (MAX_REALISTIC_DENSITY = 2.0 kg/m³, MIN_REALISTIC_SPEED_OF_SOUND = 200 m/s).
135    let direct_mode = c == 0.0 && d == 0.0 && a > 0.0 && a < 2.0 && b > 200.0;
136    // Standard mode: positive station pressure (hPa).
137    direct_mode || c > 0.0
138}
139
140/// Fast trajectory integration parameters
141pub struct FastIntegrationParams {
142    pub horiz: f64,
143    pub vert: f64,
144    pub initial_state: [f64; 6],
145    pub t_span: (f64, f64),
146    /// Dual-mode atmosphere tuple — see [`atmo_is_physical`]. Standard mode is
147    /// `(base_alt_m, base_temp_c, base_pressure_hPa, base_density_ratio)` (slot 3 is a density
148    /// RATIO, not humidity); direct mode is `(air_density, speed_of_sound, 0.0, 0.0)`.
149    pub atmo_params: (f64, f64, f64, f64),
150    /// MBA-1137: optional downrange-segmented atmosphere. When `Some`, the per-substep drag
151    /// density samples the base (station-referenced) T/P/H for the zone at the current downrange
152    /// distance before applying the altitude lapse. `None` (default) keeps the single-station
153    /// base and — combined with the MBA-1137 density-freeze fix — varies density with altitude only.
154    pub atmo_sock: Option<AtmoSock>,
155}
156
157/// Aerodynamic-jump vertical launch-angle offset (radians) for the fast-integrate path.
158///
159/// Bryan Litz's crosswind estimator (`Y = 0.01*Sg - 0.0024*L + 0.032` MOA/mph) fed by the
160/// engine's Miller Sg. The fast path receives a prebuilt initial velocity (no muzzle angle),
161/// so the caller rotates that velocity by this offset. Returns 0 when the feature is off or
162/// the inputs are degenerate. Crosswind is taken from `wind_speed`/`wind_angle`
163/// (BallisticInputs convention: 0 = headwind, +90deg = from the right). MBA-959, EXPERIMENTAL.
164pub fn aerodynamic_jump_launch_offset_rad(
165    inputs: &BallisticInputs,
166    atmo_params: (f64, f64, f64, f64),
167) -> f64 {
168    if !inputs.enable_aerodynamic_jump {
169        return 0.0;
170    }
171    let diameter = inputs.bullet_diameter;
172    if !(inputs.twist_rate.is_finite() && inputs.twist_rate != 0.0)
173        || !(diameter.is_finite() && diameter > 0.0)
174        || !(inputs.bullet_length.is_finite() && inputs.bullet_length > 0.0)
175        || !inputs.muzzle_velocity.is_finite()
176    {
177        return 0.0;
178    }
179    let sg = crate::stability::compute_stability_coefficient(inputs, atmo_params);
180    if !(sg.is_finite() && sg > 0.0) {
181        return 0.0;
182    }
183    let length_cal = inputs.bullet_length / diameter;
184    const MS_TO_MPH: f64 = 2.236_936_292_054_4;
185    let crosswind_from_right_mph = inputs.wind_speed * inputs.wind_angle.sin() * MS_TO_MPH;
186    let vertical_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
187        sg,
188        length_cal,
189        crosswind_from_right_mph,
190        inputs.is_twist_right,
191    );
192    if !vertical_moa.is_finite() {
193        return 0.0;
194    }
195    const MOA_PER_RAD: f64 = 3437.7467707849;
196    vertical_moa / MOA_PER_RAD
197}
198
199/// Rotate a McCoy-frame state's velocity (indices 3..6) by `theta_rad` in the vertical
200/// (downrange–vertical) plane, preserving speed and horizontal heading. Positive = up.
201fn rotate_launch_velocity(state: &mut [f64; 6], theta_rad: f64) {
202    let (vx, vy, vz) = (state[3], state[4], state[5]);
203    let speed = (vx * vx + vy * vy + vz * vz).sqrt();
204    if speed <= 0.0 {
205        return;
206    }
207    let h = (vx * vx + vz * vz).sqrt(); // horizontal (downrange+lateral) speed
208    let new_elev = vy.atan2(h) + theta_rad;
209    state[4] = speed * new_elev.sin();
210    let new_h = speed * new_elev.cos();
211    let scale = if h > 1e-12 { new_h / h } else { 0.0 };
212    state[3] = vx * scale;
213    state[5] = vz * scale;
214}
215
216/// Fast fixed-step integration for longer trajectories
217pub fn fast_integrate(
218    inputs: &BallisticInputs,
219    wind_sock: &WindSock,
220    params: FastIntegrationParams,
221) -> FastSolution {
222    // Degenerate atmosphere -> non-physical air density would silently stub the run.
223    if !atmo_is_physical(params.atmo_params) {
224        return FastSolution::degenerate(&params.initial_state);
225    }
226    let mut effective_inputs = inputs.clone();
227    if params.atmo_params.2 > 0.0 {
228        effective_inputs.altitude = params.atmo_params.0;
229        effective_inputs.temperature = params.atmo_params.1;
230        effective_inputs.pressure = params.atmo_params.2;
231        effective_inputs.humidity = params.atmo_params.3;
232    }
233    let inputs = &effective_inputs;
234    // Extract parameters
235    let _mass_kg = inputs.bullet_mass; // SI (kg)
236    let bc = inputs.bc_value;
237    let drag_model = &inputs.bc_type;
238
239    // Check for BC segments
240    let has_bc_segments =
241        inputs.bc_segments.is_some() && !inputs.bc_segments.as_ref().unwrap().is_empty();
242    let has_bc_segments_data =
243        inputs.bc_segments_data.is_some() && !inputs.bc_segments_data.as_ref().unwrap().is_empty();
244
245    // Time step - adjust based on distance
246    let dt = if params.horiz > 200.0 {
247        0.001
248    } else if params.horiz > 100.0 {
249        0.0005
250    } else {
251        0.0001
252    };
253
254    // MBA-959: aerodynamic jump perturbs the prebuilt launch velocity vertically (this path is
255    // handed an initial_state, not a muzzle angle). A no-op returning the original when disabled.
256    let mut initial_state = params.initial_state;
257    let aj_offset = aerodynamic_jump_launch_offset_rad(inputs, params.atmo_params);
258    if aj_offset != 0.0 {
259        rotate_launch_velocity(&mut initial_state, aj_offset);
260    }
261    let vx = initial_state[3]; // horizontal (downrange) velocity
262
263    // MBA-1145: decouple the integration-loop ceiling from the pre-allocation heuristic.
264    // Previously t_max = min(4*horiz/vx, t_span.1) bounded BOTH the Vec sizing AND the loop
265    // itself, so the 4x-horiz/vx estimate doubled as the loop cap. That estimate is only a
266    // heuristic — extreme high-drag or high-launch-angle shots exceed it, and the loop then
267    // terminated BEFORE hit_target/hit_ground, silently truncating the trajectory tail (Monte
268    // Carlo reported impact metrics short of the real range). The loop already breaks on
269    // hit_target (pos.x >= horiz) and hit_ground (pos.y <= ground_threshold), and any real
270    // trajectory descends below the ground threshold within a few seconds of apex, so the loop
271    // bound's only job is a runaway safety ceiling: use the full t_span.1 (default 30 s). The
272    // 4x estimate is retained ONLY to size the pre-allocation (keeps the small-allocation fast
273    // path for short shots); the Vec grows for the rare long run because the loop bound is n_steps.
274    let n_steps = ((params.t_span.1 / dt) as usize) + 1;
275    let est_steps = if vx > 1e-6 && params.horiz > 0.0 {
276        (((4.0 * params.horiz / vx) / dt) as usize) + 1
277    } else {
278        n_steps
279    };
280    let cap = est_steps.min(n_steps);
281    let mut times = Vec::with_capacity(cap);
282    let mut states = Vec::with_capacity(cap);
283
284    // Initial state (with the aerodynamic-jump launch perturbation applied above)
285    times.push(0.0);
286    states.push(initial_state);
287
288    // Base drag density = the muzzle (shooter-altitude) density. atmo_params.3 is base_ratio
289    // = air_density/1.225 at the shooter altitude (the MC caller computes it via
290    // calculate_atmosphere). Previously this called get_local_atmosphere with query alt 0.0
291    // while base_alt = shooter altitude, which re-scaled that ratio DOWN to sea level —
292    // discarding the correct altitude density and inflating drag for every elevated MC run.
293    // Guard a missing/absent ratio (base_ratio <= 0, e.g. legacy or uninitialized atmo_params):
294    // fall back to the standard sea-level density rather than 0, so a zero ratio cannot collapse
295    // density_scale to 0 and silently disable drag entirely. (atmo_params.0 is base_alt here, not
296    // a density, so it is not a usable fallback.)
297    let base_density = if params.atmo_params.3 > 0.0 {
298        params.atmo_params.3 * 1.225
299    } else {
300        1.225 // standard sea-level air density (kg/m^3)
301    };
302
303    // MBA-1137: borrow the optional downrange-segmented atmosphere once (queried 4x per step).
304    let atmo_sock = params.atmo_sock.as_ref();
305
306    // Hoist invariants out of compute_derivatives (called 4x per step). Both the drag-model name
307    // and the projectile shape depend only on inputs, not on state/mach, so computing them per
308    // call wasted an allocation + heuristic every k1..k4. Mirrors cli_api.rs exactly.
309
310    // Drag-model name as a borrowed &'static str. DragModel's Display goes via Debug, which
311    // heap-allocates a String on every call; this match is bit-identical (Display == Debug ==
312    // variant name) with no per-step allocation.
313    let drag_model_str: &str = match drag_model {
314        DragModel::G1 => "G1",
315        DragModel::G2 => "G2",
316        DragModel::G5 => "G5",
317        DragModel::G6 => "G6",
318        DragModel::G7 => "G7",
319        DragModel::G8 => "G8",
320        DragModel::GI => "GI",
321        DragModel::GS => "GS",
322    };
323
324    // SI fallbacks for caliber/weight (SI-only MC callers may leave the imperial fields 0).
325    let caliber_in = if inputs.caliber_inches > 0.0 {
326        inputs.caliber_inches
327    } else {
328        inputs.bullet_diameter / 0.0254
329    };
330    let weight_gr = if inputs.weight_grains > 0.0 {
331        inputs.weight_grains
332    } else {
333        inputs.bullet_mass / 0.00006479891
334    };
335
336    // Projectile shape for transonic corrections (MBA-949: shared resolver — bullet_model name
337    // first, then the caliber/weight/drag-model heuristic).
338    let projectile_shape = crate::transonic_drag::resolve_projectile_shape(
339        inputs.bullet_model.as_deref(),
340        caliber_in,
341        weight_gr,
342        drag_model_str,
343    );
344
345    // Coriolis omega (Earth rotation), hoisted (invariant over the flight). MBA-957:
346    // fast_integrate — the Monte Carlo / Python-binding path — previously applied NO Coriolis.
347    // Mirror the validated cli_api solver exactly: McCoy frame X=downrange, Y=up, Z=lateral;
348    // azimuth 0 = North; omega.Z is NEGATIVE (Omega.East = -Omega cos(lat) sin(az)); applied as
349    // the physical -2 Omega x v inside compute_derivatives.
350    let omega_vector = if inputs.enable_coriolis && inputs.latitude.is_some() {
351        let omega_earth = 7.2921159e-5_f64; // rad/s
352        let lat = inputs.latitude.unwrap().to_radians();
353        let az = inputs.shot_azimuth; // compass bearing (0=N), NOT the aiming offset
354        Some(Vector3::new(
355            omega_earth * lat.cos() * az.cos(),  // X: downrange
356            omega_earth * lat.sin(),             // Y: vertical
357            -omega_earth * lat.cos() * az.sin(), // Z: lateral (corrected sign)
358        ))
359    } else {
360        None
361    };
362
363    // Integration loop
364    let mut hit_target = false;
365    let mut hit_ground = false;
366    let mut max_ord_time = None;
367    let mut max_ord_y = 0.0;
368    let ground_threshold = inputs.ground_threshold;
369
370    // RK4 integration
371    for i in 0..n_steps - 1 {
372        let t = i as f64 * dt;
373        let state = states[i];
374
375        let pos = Vector3::new(state[0], state[1], state[2]);
376        let _vel = Vector3::new(state[3], state[4], state[5]);
377
378        // Check termination conditions (X is downrange, McCoy)
379        if pos.x >= params.horiz {
380            hit_target = true;
381            break;
382        }
383
384        if pos.y <= ground_threshold {
385            hit_ground = true;
386            break;
387        }
388
389        // Track maximum ordinate
390        if pos.y > max_ord_y {
391            max_ord_y = pos.y;
392            max_ord_time = Some(t);
393        }
394
395        // RK4 step
396        let k1 = compute_derivatives(
397            &state,
398            inputs,
399            wind_sock,
400            base_density,
401            drag_model,
402            projectile_shape,
403            bc,
404            has_bc_segments,
405            has_bc_segments_data,
406            omega_vector,
407            atmo_sock,
408        );
409
410        let mut state2 = state;
411        for j in 0..6 {
412            state2[j] = state[j] + 0.5 * dt * k1[j];
413        }
414        let k2 = compute_derivatives(
415            &state2,
416            inputs,
417            wind_sock,
418            base_density,
419            drag_model,
420            projectile_shape,
421            bc,
422            has_bc_segments,
423            has_bc_segments_data,
424            omega_vector,
425            atmo_sock,
426        );
427
428        let mut state3 = state;
429        for j in 0..6 {
430            state3[j] = state[j] + 0.5 * dt * k2[j];
431        }
432        let k3 = compute_derivatives(
433            &state3,
434            inputs,
435            wind_sock,
436            base_density,
437            drag_model,
438            projectile_shape,
439            bc,
440            has_bc_segments,
441            has_bc_segments_data,
442            omega_vector,
443            atmo_sock,
444        );
445
446        let mut state4 = state;
447        for j in 0..6 {
448            state4[j] = state[j] + dt * k3[j];
449        }
450        let k4 = compute_derivatives(
451            &state4,
452            inputs,
453            wind_sock,
454            base_density,
455            drag_model,
456            projectile_shape,
457            bc,
458            has_bc_segments,
459            has_bc_segments_data,
460            omega_vector,
461            atmo_sock,
462        );
463
464        // Update state
465        let mut new_state = state;
466        for j in 0..6 {
467            new_state[j] = state[j] + dt * (k1[j] + 2.0 * k2[j] + 2.0 * k3[j] + k4[j]) / 6.0;
468        }
469
470        times.push(t + dt);
471        states.push(new_state);
472    }
473
474    // Create event arrays
475    let t_events = [
476        if hit_target {
477            vec![*times.last().unwrap()]
478        } else {
479            vec![]
480        },
481        if let Some(t) = max_ord_time {
482            vec![t]
483        } else {
484            vec![]
485        },
486        if hit_ground {
487            vec![*times.last().unwrap()]
488        } else {
489            vec![]
490        },
491    ];
492
493    // MBA-1134 (rank 10) — regression fix: apply the canonical empirical Litz drift as a
494    // post-process to the lateral (McCoy Z) here too. The derivatives kernel no longer
495    // integrates spin drift, and this plain fast_integrate is the single-shot fast path
496    // (solve_trajectory_rust / the API), so without this it would carry NO spin drift and
497    // twist direction would have no effect (the Magnus term is also suppressed when
498    // use_enhanced_spin_drift is set). Mirrors fast_integrate_with_segments,
499    // cli_api::apply_spin_drift and the Monte-Carlo path so all solver families agree; uses
500    // the SAME muzzle Sg (spin_drift::effective_sg_from_inputs).
501    if inputs.use_enhanced_spin_drift {
502        // Standard-mode atmo_params is (base_alt, temp_c, press_hpa, ratio); direct mode
503        // (density, sound, 0, 0) carries no explicit temp/pressure, so fall back to sea-level
504        // standard (the Sg density correction is a no-op there).
505        let (sd_temp_c, sd_press_hpa) = if params.atmo_params.2 > 0.0 {
506            (params.atmo_params.1, params.atmo_params.2)
507        } else {
508            (15.0, 1013.25)
509        };
510        let sg = crate::spin_drift::effective_sg_from_inputs(inputs, sd_temp_c, sd_press_hpa);
511        for (t, state) in times.iter().zip(states.iter_mut()) {
512            if *t > 0.0 {
513                state[2] += crate::spin_drift::litz_drift_meters(sg, *t, inputs.is_twist_right);
514            }
515        }
516    }
517
518    FastSolution::from_trajectory_data(times, states, t_events)
519}
520
521/// Compute derivatives for the state vector
522#[allow(clippy::too_many_arguments)]
523fn compute_derivatives(
524    state: &[f64; 6],
525    inputs: &BallisticInputs,
526    wind_sock: &WindSock,
527    base_density: f64,
528    drag_model: &DragModel,
529    projectile_shape: crate::transonic_drag::ProjectileShape,
530    bc: f64,
531    has_bc_segments: bool,
532    has_bc_segments_data: bool,
533    omega: Option<Vector3<f64>>,
534    // MBA-1137: optional downrange-segmented atmosphere (zone base swapped by pos.x before lapse).
535    atmo_sock: Option<&AtmoSock>,
536) -> [f64; 6] {
537    let pos = Vector3::new(state[0], state[1], state[2]);
538    let vel = Vector3::new(state[3], state[4], state[5]);
539
540    // Get wind vector (based on downrange distance, which is X coordinate, McCoy)
541    let wind_vector = wind_sock.vector_for_range_stateless(pos.x);
542
543    // Velocity relative to air
544    let vel_adjusted = vel - wind_vector;
545    let v_mag = vel_adjusted.norm();
546
547    // Gravity acceleration vector, rotated into the shot-aligned frame by shooting_angle
548    // (uphill/downhill inclined fire), matching cli_api::TrajectorySolver::gravity_acceleration.
549    let theta = inputs.shooting_angle;
550    let accel_gravity = Vector3::new(
551        -G_ACCEL_MPS2 * theta.sin(),
552        -G_ACCEL_MPS2 * theta.cos(),
553        0.0,
554    );
555
556    // Calculate acceleration
557    let mut accel = if v_mag < 1e-6 {
558        accel_gravity
559    } else {
560        // Calculate drag
561        let v_fps = v_mag * MPS_TO_FPS;
562
563        // Calculate LOCAL density and speed of sound from the substep altitude using the standard
564        // lapse rate. `inputs.temperature`/`inputs.pressure` are the base (station) T/P set by
565        // fast_integrate from atmo_params; `base_density` is the station-altitude density, so
566        // `base_density / 1.225` recovers the station base_ratio the lapse pipeline expects.
567        //
568        // MBA-1137 (density-freeze fix): previously ONLY the speed of sound was taken from this
569        // call and `density_scale` used the flight-constant `base_density`, so the fast/MC path
570        // held density frozen for the whole flight (density did NOT vary with altitude — a latent
571        // bug the cli_api/derivatives paths already avoid). Now the LOCAL density is used for
572        // `density_scale` below, so the fast path varies density with altitude too.
573        //
574        // MBA-1137 (zones): when a downrange-segmented atmosphere is present, swap the BASE
575        // (station-referenced) temp/pressure/ratio for the zone at the current downrange distance
576        // (pos.x), recomputing the zone base_ratio via CIPM, BEFORE the altitude lapse — so the X
577        // zone and the Y lapse compose without double-count. `None` keeps the single-station base.
578        //
579        // Humidity is NOT plumbed to this call site: on the fast path (fast_integrate) the
580        // `humidity` FIELD is overwritten with atmo_params.3 (the density RATIO), so
581        // `inputs.humidity` is not a real RH — pass 0.0 (dry) rather than fabricate.
582        let altitude = inputs.altitude + pos.y;
583        let (base_temp_c, base_press_hpa, base_ratio) = match atmo_sock {
584            Some(sock) => {
585                let (zt, zp, zh) = sock.atmo_for_range(pos.x);
586                (zt, zp, calculate_air_density_cimp(zt, zp, zh) / 1.225)
587            }
588            None => (inputs.temperature, inputs.pressure, base_density / 1.225),
589        };
590        let (local_density, speed_of_sound) = get_local_atmosphere_humid(
591            altitude,
592            inputs.altitude, // base_alt approximation
593            base_temp_c,
594            base_press_hpa,
595            base_ratio,
596            0.0, // humidity not available here (see note above)
597        );
598        let mach = v_mag / speed_of_sound;
599
600        // Get BC value (potentially from segments)
601        let bc_current = if has_bc_segments_data && inputs.bc_segments_data.is_some() {
602            get_bc_from_velocity_segments(v_fps, inputs.bc_segments_data.as_ref().unwrap())
603        } else if has_bc_segments && inputs.bc_segments.is_some() {
604            crate::derivatives::interpolated_bc(
605                mach,
606                inputs.bc_segments.as_ref().unwrap(),
607                Some(inputs),
608            )
609        } else {
610            bc
611        };
612        // Guard bc_value == 0 (allowed on FFI/WASM/public MC surfaces): the division below
613        // would be Inf -> NaN. Mirrors cli_api's effective_bc.max(1e-6); inert for valid BCs.
614        let bc_current = bc_current.max(1e-6);
615
616        // Apply the transonic drag-rise correction once (mirrors derivatives.rs / cli_api) so
617        // the Monte Carlo / fast path doesn't under-predict drag near Mach 1. The projectile
618        // shape is invariant for the whole integration, so it is hoisted into fast_integrate and
619        // passed in rather than recomputed per call. wave_drag=false: the G1/G7 tables already
620        // embed the rise.
621        // MBA-940: a user-supplied custom drag table is the final Cd, used as-is (no G-model
622        // lookup, transonic, or form-factor correction — the curve already encodes the true drag).
623        // Its Cd is the projectile's ACTUAL drag coefficient, so the retardation denominator
624        // must be the sectional density (lb/in²), not a BC: Cd_own / SD == Cd_ref / BC
625        // (see BallisticInputs::custom_drag_denominator).
626        let (drag_factor, retard_denom) = if let Some(ref table) = inputs.custom_drag_table {
627            (
628                table.interpolate(mach),
629                inputs.custom_drag_denominator(bc_current),
630            )
631        } else {
632            let base_cd = get_drag_coefficient(mach, drag_model);
633            let cd =
634                crate::transonic_drag::transonic_correction(mach, base_cd, projectile_shape, false);
635            // MBA-948: honor use_form_factor in the fast path too (was derivatives.rs-only). No-op
636            // when the flag is false (apply_form_factor_to_drag short-circuits), as it is on every
637            // current consumer surface.
638            let cd = crate::form_factor::apply_form_factor_to_drag(
639                cd,
640                inputs.bullet_model.as_deref(),
641                &inputs.bc_type,
642                inputs.use_form_factor,
643            );
644            (cd, bc_current)
645        };
646
647        // Calculate drag acceleration using proper ballistics formula
648        let cd_to_retard = crate::constants::CD_TO_RETARD;
649        let standard_factor = drag_factor * cd_to_retard;
650        // MBA-1137: use the LOCAL (per-substep) density, not the frozen flight-constant
651        // `base_density`, so drag varies with altitude AND downrange zone.
652        let density_scale = local_density / 1.225;
653
654        // Drag acceleration in ft/s^2
655        let a_drag_ft_s2 = (v_fps * v_fps) * standard_factor * density_scale / retard_denom;
656
657        // Convert to m/s^2 and apply to velocity vector
658        let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; // ft/s^2 to m/s^2
659        let accel_drag = -a_drag_m_s2 * (vel_adjusted / v_mag);
660
661        // Total acceleration
662        accel_drag + accel_gravity
663    };
664
665    // Coriolis (Earth rotation), MBA-957. omega already carries the corrected lateral sign; use
666    // the ground-frame velocity and the physical -2 Omega x v, exactly as the validated cli_api.
667    if let Some(omega) = omega {
668        accel += -2.0 * omega.cross(&vel);
669    }
670
671    // Return derivatives [vx, vy, vz, ax, ay, az]
672    [vel.x, vel.y, vel.z, accel.x, accel.y, accel.z]
673}
674
675/// Get BC from velocity-based segments
676fn get_bc_from_velocity_segments(velocity_fps: f64, segments: &[BCSegmentData]) -> f64 {
677    for segment in segments {
678        if velocity_fps >= segment.velocity_min && velocity_fps <= segment.velocity_max {
679            return segment.bc_value;
680        }
681    }
682
683    // If no matching segment, use the BC from the closest segment
684    if let Some(first) = segments.first() {
685        if velocity_fps < first.velocity_min {
686            return first.bc_value;
687        }
688    }
689
690    if let Some(last) = segments.last() {
691        if velocity_fps > last.velocity_max {
692            return last.bc_value;
693        }
694    }
695
696    // Fallback (shouldn't reach here if segments are properly defined)
697    0.5
698}
699
700/// Fast integration with explicit wind segments using RK45
701/// MBA-155: Upstreamed from ballistics_rust
702pub fn fast_integrate_with_segments(
703    inputs: &BallisticInputs,
704    wind_segments: Vec<crate::wind::WindSegment>,
705    params: FastIntegrationParams,
706) -> FastSolution {
707    // Use the RK45 implementation from trajectory_integration module
708    use crate::trajectory_integration::{integrate_trajectory, TrajectoryParams};
709
710    // Degenerate atmosphere -> non-physical air density would silently stub the run.
711    if !atmo_is_physical(params.atmo_params) {
712        return FastSolution::degenerate(&params.initial_state);
713    }
714
715    // Extract parameters
716    let mass_kg = inputs.bullet_mass; // SI (kg)
717    let bc = inputs.bc_value;
718    let drag_model = inputs.bc_type;
719
720    // Coriolis omega — gated on enable_coriolis (+ a latitude), INDEPENDENT of
721    // spin-drift/Magnus. A caller can now request Coriolis-only (enable_coriolis=true
722    // with enable_advanced_effects=false) instead of being forced to enable all three.
723    let omega_vector = if inputs.enable_coriolis && inputs.latitude.is_some() {
724        // Calculate omega based on latitude and shot azimuth
725        // The Earth's rotation vector must be projected into the shooter's
726        // local frame which depends on azimuth (shooting direction).
727        // azimuth_angle: 0 = North, pi/2 = East
728        let omega_earth = 7.2921159e-5; // rad/s
729        let lat_rad = inputs.latitude.unwrap_or(0.0).to_radians();
730        let azimuth = inputs.shot_azimuth; // compass bearing (0=N), NOT the aiming offset
731        Some(Vector3::new(
732            omega_earth * lat_rad.cos() * azimuth.cos(), // X: downrange component
733            omega_earth * lat_rad.sin(),                 // Y: vertical component
734            -omega_earth * lat_rad.cos() * azimuth.sin(), // Z: lateral (MBA-957: corrected sign)
735        ))
736    } else {
737        None
738    };
739
740    // Set up trajectory parameters
741    let traj_params = TrajectoryParams {
742        mass_kg,
743        bc,
744        drag_model,
745        wind_segments,
746        atmos_params: params.atmo_params,
747        omega_vector,
748        enable_spin_drift: inputs.enable_advanced_effects,
749        enable_magnus: inputs.enable_magnus,
750        enable_coriolis: inputs.enable_coriolis,
751        target_distance_m: params.horiz,
752        enable_wind_shear: inputs.enable_wind_shear,
753        wind_shear_model: inputs.wind_shear_model.clone(),
754        shooter_altitude_m: inputs.altitude,
755        is_twist_right: inputs.is_twist_right,
756        shooting_angle: inputs.shooting_angle,
757        // MBA-717: carry the real bullet geometry so spin-drift / Magnus use it.
758        bullet_diameter: inputs.bullet_diameter,
759        bullet_length: inputs.bullet_length,
760        twist_rate: inputs.twist_rate,
761        custom_drag_table: inputs.custom_drag_table.clone(),
762        bc_segments: inputs.bc_segments.clone(),
763        use_bc_segments: inputs.use_bc_segments,
764        // MBA-954: keep the historical -1000.0 here (behavior-preserving for this binding path);
765        // threading inputs.ground_threshold would change the default ground plane for existing
766        // callers. Direct TrajectoryParams constructors can now configure it.
767        ground_threshold: -1000.0,
768        // MBA-1137: forward the downrange-segmented atmosphere to the RK45 derivatives path.
769        atmo_sock: params.atmo_sock,
770    };
771
772    // Use RK45 adaptive integration
773    let trajectory = integrate_trajectory(
774        params.initial_state,
775        params.t_span,
776        traj_params,
777        "RK45", // Use RK45 implementation
778        1e-6,   // tolerance
779        0.01,   // max_step
780    );
781
782    // Convert trajectory to FastSolution format
783    let n_points = trajectory.len();
784    let mut times = Vec::with_capacity(n_points);
785    let mut states = Vec::with_capacity(n_points);
786
787    let mut target_hit_time: Option<f64> = None;
788    let mut ground_hit_time: Option<f64> = None;
789    let mut max_ord_time = None;
790    let mut max_ord_y = 0.0;
791
792    for (t, state_vec) in trajectory {
793        // Convert Vector6 to array
794        let state = [
795            state_vec[0],
796            state_vec[1],
797            state_vec[2],
798            state_vec[3],
799            state_vec[4],
800            state_vec[5],
801        ];
802
803        // Check termination conditions
804        // McCoy: state[0]=downrange, state[1]=vertical, state[2]=lateral
805
806        // Record FIRST time target is hit
807        if target_hit_time.is_none() && state[0] >= params.horiz {
808            target_hit_time = Some(t);
809        }
810
811        // Record ground hit
812        if ground_hit_time.is_none() && state[1] <= inputs.ground_threshold {
813            ground_hit_time = Some(t);
814        }
815
816        // Track maximum ordinate
817        if state[1] > max_ord_y {
818            max_ord_y = state[1];
819            max_ord_time = Some(t);
820        }
821
822        times.push(t);
823        states.push(state);
824    }
825
826    // Create event arrays
827    let t_events = [
828        if let Some(t) = target_hit_time {
829            vec![t]
830        } else {
831            vec![]
832        },
833        if let Some(t) = max_ord_time {
834            vec![t]
835        } else {
836            vec![]
837        },
838        if let Some(t) = ground_hit_time {
839            vec![t]
840        } else {
841            vec![]
842        },
843    ];
844
845    // MBA-1134 (rank 10): the derivatives kernel no longer integrates spin drift, so apply the
846    // canonical empirical Litz drift as a post-process to the lateral (McCoy Z) of every point at
847    // its time of flight — matching cli_api::apply_spin_drift and the Monte-Carlo path so all three
848    // solver families agree. Uses the SAME muzzle Sg (spin_drift::effective_sg_from_inputs).
849    if inputs.use_enhanced_spin_drift {
850        // Standard-mode atmo_params is (base_alt, temp_c, press_hpa, ratio); direct mode
851        // (density, sound, 0, 0) carries no explicit temp/pressure, so fall back to sea-level
852        // standard (the Sg density correction is a no-op there).
853        let (sd_temp_c, sd_press_hpa) = if params.atmo_params.2 > 0.0 {
854            (params.atmo_params.1, params.atmo_params.2)
855        } else {
856            (15.0, 1013.25)
857        };
858        let sg = crate::spin_drift::effective_sg_from_inputs(inputs, sd_temp_c, sd_press_hpa);
859        for (t, state) in times.iter().zip(states.iter_mut()) {
860            if *t > 0.0 {
861                state[2] += crate::spin_drift::litz_drift_meters(sg, *t, inputs.is_twist_right);
862            }
863        }
864    }
865
866    FastSolution::from_trajectory_data(times, states, t_events)
867}
868
869#[cfg(test)]
870mod tests {
871    use super::*;
872
873    #[test]
874    fn test_fast_solution_interpolation() {
875        let times = vec![0.0, 1.0, 2.0];
876        let states = vec![
877            [0.0, 0.0, 0.0, 100.0, 50.0, 0.0],
878            [100.0, 45.0, 0.0, 99.0, 40.0, 0.0],
879            [198.0, 80.0, 0.0, 98.0, 30.0, 0.0],
880        ];
881
882        let solution = FastSolution::from_trajectory_data(times, states, [vec![], vec![], vec![]]);
883
884        // Test interpolation at t=1.5
885        let result = solution.sol(&[1.5]);
886
887        assert!((result[0][0] - 149.0).abs() < 1e-10); // x position
888        assert!((result[1][0] - 62.5).abs() < 1e-10); // y position
889        assert!((result[3][0] - 98.5).abs() < 1e-10); // vx velocity
890    }
891
892    #[test]
893    fn test_bc_from_velocity_segments() {
894        let segments = vec![
895            BCSegmentData {
896                velocity_min: 0.0,
897                velocity_max: 1000.0,
898                bc_value: 0.5,
899            },
900            BCSegmentData {
901                velocity_min: 1000.0,
902                velocity_max: 2000.0,
903                bc_value: 0.52,
904            },
905            BCSegmentData {
906                velocity_min: 2000.0,
907                velocity_max: 3000.0,
908                bc_value: 0.55,
909            },
910        ];
911
912        assert_eq!(get_bc_from_velocity_segments(500.0, &segments), 0.5);
913        assert_eq!(get_bc_from_velocity_segments(1500.0, &segments), 0.52);
914        assert_eq!(get_bc_from_velocity_segments(2500.0, &segments), 0.55);
915
916        // Test edge cases
917        assert_eq!(get_bc_from_velocity_segments(-100.0, &segments), 0.5); // Below min
918        assert_eq!(get_bc_from_velocity_segments(3500.0, &segments), 0.55); // Above max
919    }
920
921    #[test]
922    fn test_fast_solution_interpolation_edge_cases() {
923        let times = vec![0.0, 1.0, 2.0, 3.0];
924        let states = vec![
925            [0.0, 0.0, 0.0, 800.0, 50.0, 0.0],
926            [800.0, 40.0, 100.0, 750.0, 30.0, 0.0],
927            [1550.0, 60.0, 200.0, 700.0, 10.0, 0.0],
928            [2250.0, 50.0, 300.0, 650.0, -10.0, 0.0],
929        ];
930
931        let solution = FastSolution::from_trajectory_data(times, states, [vec![], vec![], vec![]]);
932
933        // Test interpolation before first point
934        let result_before = solution.sol(&[-0.5]);
935        assert!((result_before[0][0] - 0.0).abs() < 1e-10); // Should clamp to first
936
937        // Test interpolation after last point
938        let result_after = solution.sol(&[5.0]);
939        assert!((result_after[0][0] - 2250.0).abs() < 1e-10); // Should clamp to last
940
941        // Test interpolation at exact points
942        let result_exact = solution.sol(&[1.0]);
943        assert!((result_exact[0][0] - 800.0).abs() < 1e-10);
944
945        // Test multiple query points
946        let result_multi = solution.sol(&[0.5, 1.5, 2.5]);
947        assert_eq!(result_multi[0].len(), 3);
948    }
949
950    #[test]
951    fn test_fast_solution_from_trajectory_data() {
952        let times = vec![0.0, 0.5, 1.0];
953        let states = vec![
954            [0.0, 1.0, 2.0, 3.0, 4.0, 5.0],
955            [10.0, 11.0, 12.0, 13.0, 14.0, 15.0],
956            [20.0, 21.0, 22.0, 23.0, 24.0, 25.0],
957        ];
958        let t_events = [vec![1.0], vec![0.5], vec![]];
959
960        let solution = FastSolution::from_trajectory_data(times.clone(), states, t_events);
961
962        // Check that data is stored correctly
963        assert_eq!(solution.t, times);
964        assert_eq!(solution.y.len(), 6); // 6 state components
965        assert_eq!(solution.y[0].len(), 3); // 3 time points
966        assert!(solution.success);
967
968        // Verify column-major storage
969        assert_eq!(solution.y[0][0], 0.0); // x at t=0
970        assert_eq!(solution.y[1][0], 1.0); // y at t=0
971        assert_eq!(solution.y[0][2], 20.0); // x at t=1.0
972    }
973
974    #[test]
975    fn test_bc_segments_boundary_conditions() {
976        // Test with single segment
977        let single_segment = vec![BCSegmentData {
978            velocity_min: 1000.0,
979            velocity_max: 2000.0,
980            bc_value: 0.5,
981        }];
982
983        assert_eq!(get_bc_from_velocity_segments(500.0, &single_segment), 0.5); // Below
984        assert_eq!(get_bc_from_velocity_segments(1500.0, &single_segment), 0.5); // In range
985        assert_eq!(get_bc_from_velocity_segments(2500.0, &single_segment), 0.5); // Above
986
987        // Test with exact boundary values
988        // Note: When velocity matches boundary, first matching segment wins
989        let segments = vec![
990            BCSegmentData {
991                velocity_min: 0.0,
992                velocity_max: 999.0, // Exclusive upper bound to avoid overlap
993                bc_value: 0.45,
994            },
995            BCSegmentData {
996                velocity_min: 1000.0,
997                velocity_max: 2000.0,
998                bc_value: 0.50,
999            },
1000        ];
1001
1002        assert_eq!(get_bc_from_velocity_segments(1000.0, &segments), 0.50); // At second segment start
1003        assert_eq!(get_bc_from_velocity_segments(0.0, &segments), 0.45); // At min
1004        assert_eq!(get_bc_from_velocity_segments(999.0, &segments), 0.45); // At first segment max
1005    }
1006
1007    #[test]
1008    fn test_bc_segments_empty_fallback() {
1009        let empty_segments: Vec<BCSegmentData> = vec![];
1010
1011        // With empty segments, should return fallback value
1012        let result = get_bc_from_velocity_segments(1500.0, &empty_segments);
1013        assert_eq!(result, 0.5); // Fallback value
1014    }
1015
1016    #[test]
1017    fn test_fast_integration_params() {
1018        // Verify FastIntegrationParams struct can be constructed
1019        let params = FastIntegrationParams {
1020            horiz: 1000.0,
1021            vert: 0.0,
1022            initial_state: [0.0, 0.0, 0.0, 800.0, 50.0, 0.0], // McCoy: vx=downrange
1023            t_span: (0.0, 5.0),
1024            atmo_params: (0.0, 59.0, 29.92, 0.0),
1025            atmo_sock: None,
1026        };
1027
1028        assert_eq!(params.horiz, 1000.0);
1029        assert_eq!(params.t_span.0, 0.0);
1030        assert_eq!(params.t_span.1, 5.0);
1031        assert_eq!(params.initial_state[3], 800.0); // vx (downrange, McCoy)
1032    }
1033
1034    #[test]
1035    fn test_fast_solution_event_arrays() {
1036        let times = vec![0.0, 1.0, 2.0];
1037        let states = vec![
1038            [0.0, 0.0, 0.0, 800.0, 50.0, 0.0],
1039            [800.0, 40.0, 500.0, 750.0, 30.0, 0.0],
1040            [1500.0, 20.0, 1000.0, 700.0, 10.0, 0.0],
1041        ];
1042
1043        // Create solution with events
1044        let t_events = [
1045            vec![2.0], // target_hit at t=2
1046            vec![0.5], // max_ord at t=0.5
1047            vec![],    // no ground_hit
1048        ];
1049
1050        let solution = FastSolution::from_trajectory_data(times, states, t_events);
1051
1052        assert_eq!(solution.t_events[0], vec![2.0]); // Target hit
1053        assert_eq!(solution.t_events[1], vec![0.5]); // Max ordinate
1054        assert!(solution.t_events[2].is_empty()); // No ground hit
1055    }
1056
1057    #[test]
1058    fn fast_path_coriolis_uses_shot_direction() {
1059        // Regression: fast_integrate_with_segments (the path the Python binding uses)
1060        // built its Coriolis omega from azimuth_angle (the aiming offset, always ~0)
1061        // instead of shot_azimuth, so east and west shots came out identical. After the
1062        // fix they must differ with the correct Eotvos sign (east lifted, higher).
1063        use std::f64::consts::FRAC_PI_2;
1064        // Returns (final_downrange, final_vertical) for a shot fired along `shot_az`.
1065        fn final_xy(shot_az: f64) -> (f64, f64) {
1066            let mut inputs = BallisticInputs::default();
1067            inputs.muzzle_velocity = 800.0;
1068            inputs.bc_value = 0.5;
1069            inputs.bc_type = DragModel::G7;
1070            inputs.enable_advanced_effects = true; // gates the omega vector
1071            inputs.enable_coriolis = true;
1072            inputs.latitude = Some(45.0);
1073            inputs.shot_azimuth = shot_az;
1074            let v = 800.0_f64;
1075            let elev = 0.02_f64;
1076            let params = FastIntegrationParams {
1077                horiz: 1000.0,
1078                vert: 0.0,
1079                initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
1080                t_span: (0.0, 5.0),
1081                atmo_params: (0.0, 59.0, 29.92, 0.0),
1082                atmo_sock: None,
1083            };
1084            let sol = fast_integrate_with_segments(&inputs, vec![], params);
1085            let n = sol.y[0].len();
1086            (sol.y[0][n - 1], sol.y[1][n - 1])
1087        }
1088        let (ex, ey) = final_xy(FRAC_PI_2); // east
1089        let (wx, wy) = final_xy(3.0 * FRAC_PI_2); // west
1090                                                  // Both shots cover essentially the same downrange (Coriolis barely affects x),
1091                                                  // so comparing the final vertical is apples-to-apples.
1092        assert!(
1093            (ex - wx).abs() < 0.5,
1094            "east/west downrange should be ~equal (ex={ex:.4}, wx={wx:.4})"
1095        );
1096        // Pre-fix the fast path was North-locked, making these byte-identical. The Eotvos
1097        // term now lifts the east shot above the west shot.
1098        assert!(
1099            ey > wy,
1100            "fast-path east ({ey:.6}) must be higher than west ({wy:.6}) (Eotvos)"
1101        );
1102        assert!(
1103            (ey - wy) > 1e-5,
1104            "fast-path E-W vertical separation ({:.8} m) should be non-zero (the pre-fix bug was exact equality)",
1105            ey - wy
1106        );
1107    }
1108
1109    #[test]
1110    fn fast_path_coriolis_independent_of_advanced_effects() {
1111        // Coriolis is now gated on enable_coriolis (+ latitude), NOT enable_advanced_effects.
1112        // So a caller can request Coriolis-only without being forced to enable spin/Magnus.
1113        use std::f64::consts::FRAC_PI_2;
1114        fn final_y(coriolis: bool, shot_az: f64) -> f64 {
1115            let mut inputs = BallisticInputs::default();
1116            inputs.muzzle_velocity = 800.0;
1117            inputs.bc_value = 0.5;
1118            inputs.bc_type = DragModel::G7;
1119            inputs.enable_coriolis = coriolis;
1120            inputs.enable_advanced_effects = false; // explicitly OFF — Coriolis must still work
1121            inputs.latitude = Some(45.0);
1122            inputs.shot_azimuth = shot_az;
1123            let v = 800.0_f64;
1124            let elev = 0.02_f64;
1125            let params = FastIntegrationParams {
1126                horiz: 1000.0,
1127                vert: 0.0,
1128                initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
1129                t_span: (0.0, 5.0),
1130                atmo_params: (0.0, 59.0, 29.92, 0.0),
1131                atmo_sock: None,
1132            };
1133            let sol = fast_integrate_with_segments(&inputs, vec![], params);
1134            let n = sol.y[0].len();
1135            sol.y[1][n - 1]
1136        }
1137        // enable_coriolis=true with advanced effects OFF: directional Coriolis still applies.
1138        let e = final_y(true, FRAC_PI_2);
1139        let w = final_y(true, 3.0 * FRAC_PI_2);
1140        assert!(
1141            e > w && (e - w) > 1e-5,
1142            "Coriolis-only (no advanced effects) must still be directional: E={e} W={w}"
1143        );
1144        // enable_coriolis=false: no Coriolis at all, east == west.
1145        let e2 = final_y(false, FRAC_PI_2);
1146        let w2 = final_y(false, 3.0 * FRAC_PI_2);
1147        assert!(
1148            (e2 - w2).abs() < 1e-9,
1149            "with enable_coriolis=false, east/west must be identical: E={e2} W={w2}"
1150        );
1151    }
1152
1153    #[test]
1154    fn fast_path_rejects_degenerate_atmosphere() {
1155        let mut inputs = BallisticInputs::default();
1156        inputs.muzzle_velocity = 800.0;
1157        inputs.bc_value = 0.5;
1158        inputs.bc_type = DragModel::G7;
1159        let v = 800.0_f64;
1160        let e = 0.02_f64;
1161        let mk = |atmo: (f64, f64, f64, f64)| FastIntegrationParams {
1162            horiz: 500.0,
1163            vert: 0.0,
1164            initial_state: [0.0, 0.0, 0.0, v * e.cos(), v * e.sin(), 0.0],
1165            t_span: (0.0, 5.0),
1166            atmo_params: atmo,
1167            atmo_sock: None,
1168        };
1169        // pressure <= 0 -> fail loudly (success=false) instead of a 1-point stub as success.
1170        let zero_p = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 0.0, 50.0)));
1171        assert!(
1172            !zero_p.success,
1173            "pressure=0 atmosphere must yield success=false"
1174        );
1175        // non-finite pressure -> also rejected.
1176        let nan_p = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, f64::NAN, 50.0)));
1177        assert!(!nan_p.success, "NaN pressure must yield success=false");
1178        // realistic atmosphere -> success.
1179        let good = fast_integrate_with_segments(&inputs, vec![], mk((0.0, 15.0, 1013.25, 50.0)));
1180        assert!(good.success, "realistic atmosphere must yield success=true");
1181        // Direct-atmosphere mode (density, speed_of_sound, 0, 0) is legitimate and must NOT
1182        // be rejected by the guard (regression: 0.21.2 rejected it via the pressure<=0 check).
1183        let direct = fast_integrate_with_segments(&inputs, vec![], mk((1.225, 340.0, 0.0, 0.0)));
1184        assert!(
1185            direct.success,
1186            "direct-atmosphere mode (pressure=0 sentinel) must yield success=true"
1187        );
1188    }
1189
1190    #[test]
1191    fn fast_path_carries_real_bullet_geometry() {
1192        // MBA-717: build_inputs hardcoded diameter=.308 / length=1.24in / twist=10 because
1193        // TrajectoryParams didn't carry them. They're now plumbed through, so the BallisticInputs
1194        // the derivatives see reflect the real bullet (caliber gates the Magnus block at
1195        // bullet_diameter > 0.0). Guard against regressing back to the hardcoded values: a
1196        // zero-diameter input must reach the derivatives as zero (Magnus skipped), whereas the
1197        // old code would have forced .308 regardless. We assert the run still completes and the
1198        // two geometries don't crash — the data path is exercised end-to-end.
1199        let run = |diameter: f64, twist: f64| {
1200            let mut inputs = BallisticInputs::default();
1201            inputs.muzzle_velocity = 800.0;
1202            inputs.bc_value = 0.5;
1203            inputs.bc_type = DragModel::G7;
1204            inputs.bullet_diameter = diameter;
1205            inputs.bullet_length = 0.0318;
1206            inputs.twist_rate = twist;
1207            inputs.enable_advanced_effects = true;
1208            inputs.enable_magnus = true;
1209            let v = 800.0_f64;
1210            let elev = 0.02_f64;
1211            let params = FastIntegrationParams {
1212                horiz: 1000.0,
1213                vert: 0.0,
1214                initial_state: [0.0, 0.0, 0.0, v * elev.cos(), v * elev.sin(), 0.0],
1215                t_span: (0.0, 5.0),
1216                atmo_params: (0.0, 15.0, 1013.25, 50.0),
1217                atmo_sock: None,
1218            };
1219            fast_integrate_with_segments(&inputs, vec![], params)
1220        };
1221        // Both a real .224 and a real .338 produce a valid trajectory (geometry is honored, not
1222        // overridden by a hardcoded .308). Regression sentinel for the plumbing.
1223        assert!(run(0.00569, 7.0).success, ".224 geometry must solve");
1224        assert!(run(0.00858, 10.0).success, ".338 geometry must solve");
1225    }
1226}