ballistics_engine/
derivatives.rs

1use crate::atmosphere::{
2    calculate_air_density_cimp, get_direct_atmosphere, get_local_atmosphere_humid, AtmoSock,
3};
4use crate::bc_estimation::{velocity_segment_bc, BCSegmentEstimator};
5use crate::constants::*;
6use crate::drag::get_drag_coefficient_full;
7use crate::InternalBallisticInputs as BallisticInputs;
8use nalgebra::Vector3;
9
10// Magnus Effect Constants
11//
12// The Magnus effect causes spinning projectiles to deflect perpendicular to both
13// their velocity vector and spin axis due to asymmetric pressure distribution.
14// These constants define the Magnus moment coefficient (C_Lα) for different flight regimes.
15
16/// Magnus coefficient for subsonic flow (M < 0.8)
17///
18/// Value: 0.030 (dimensionless coefficient)
19/// Physical basis: Fully developed boundary layer circulation around spinning projectile
20/// Regime: Subsonic flow where boundary layer remains attached
21/// Source: McCoy's "Modern Exterior Ballistics", validated against wind tunnel data
22const MAGNUS_COEFF_SUBSONIC: f64 = 0.030;
23
24/// Magnus coefficient reduction factor for transonic regime (0.8 < M < 1.2)
25///
26/// Value: 0.015 (continuous with the supersonic base at M=1.2)
27/// Physical basis: Shock waves disrupt circulation patterns, reducing Magnus effect
28/// Effect: Spin drift significantly reduced in transonic flight
29/// Source: Experimental spinning projectile studies
30const MAGNUS_COEFF_TRANSONIC_REDUCTION: f64 = 0.015;
31
32/// Base Magnus coefficient for supersonic flow (M > 1.2)
33///
34/// Value: 0.015 (dimensionless coefficient)
35/// Physical basis: Shock-dominated flow with reduced but persistent circulation
36/// Effect: Lower Magnus effect than subsonic, but higher than transonic minimum
37const MAGNUS_COEFF_SUPERSONIC_BASE: f64 = 0.015;
38
39/// Magnus coefficient scaling factor for high supersonic speeds
40///
41/// Value: 0.0044 (additional scaling with Mach number)
42/// Formula: Magnus_coeff = BASE + SCALE * (M - 1.2) for M > 1.2
43/// Physical basis: Partial recovery of circulation effects at higher Mach numbers
44const MAGNUS_COEFF_SUPERSONIC_SCALE: f64 = 0.0044;
45
46/// Transonic regime boundaries for Magnus effect calculations
47const MAGNUS_TRANSONIC_LOWER: f64 = 0.8; // Lower bound of transonic regime
48const MAGNUS_TRANSONIC_UPPER: f64 = 1.2; // Upper bound of transonic regime
49const MAGNUS_TRANSONIC_RANGE: f64 = 0.4; // Range width (1.2 - 0.8)
50const MAGNUS_SUPERSONIC_RANGE: f64 = 1.8; // Scaling range for supersonic recovery
51
52// Note: These Magnus coefficients are calibrated against real-world spin drift measurements
53// from McCoy's "Modern Exterior Ballistics" and experimental data. The dimensionless
54// coefficients represent the Magnus moment per unit angle of attack.
55
56// Atmosphere detection thresholds
57const MAX_REALISTIC_DENSITY: f64 = 2.0; // kg/m³
58const MIN_REALISTIC_SPEED_OF_SOUND: f64 = 200.0; // m/s
59
60fn dry_air_temperature_c_from_sound_speed(speed_of_sound_mps: f64) -> f64 {
61    speed_of_sound_mps * speed_of_sound_mps / (1.4 * 287.05) - 273.15
62}
63
64/// Calculate Magnus moment coefficient C_Lα based on Mach number
65/// Based on McCoy's 'Modern Exterior Ballistics' and empirical data
66pub(crate) fn calculate_magnus_moment_coefficient(mach: f64) -> f64 {
67    // Magnus moment coefficient varies with Mach number
68    // Values based on empirical data for spitzer bullets
69
70    if mach < MAGNUS_TRANSONIC_LOWER {
71        // Subsonic: relatively constant
72        MAGNUS_COEFF_SUBSONIC
73    } else if mach < MAGNUS_TRANSONIC_UPPER {
74        // Transonic: reduced due to shock formation
75        // Linear interpolation through transonic region
76        MAGNUS_COEFF_SUBSONIC
77            - MAGNUS_COEFF_TRANSONIC_REDUCTION * (mach - MAGNUS_TRANSONIC_LOWER)
78                / MAGNUS_TRANSONIC_RANGE
79    } else {
80        // Supersonic: gradually recovers
81        MAGNUS_COEFF_SUPERSONIC_BASE
82            + MAGNUS_COEFF_SUPERSONIC_SCALE
83                * ((mach - MAGNUS_TRANSONIC_UPPER) / MAGNUS_SUPERSONIC_RANGE).min(1.0)
84    }
85}
86
87/// Project a vector expressed in level downrange/up/lateral axes into the inclined shot frame.
88///
89/// Shot-frame X follows the slant trajectory, Y is perpendicular to it in the vertical plane,
90/// and Z remains lateral. The zero-angle return preserves level-fire values bit-for-bit.
91#[inline]
92pub(crate) fn level_vector_to_shot_frame(
93    vector: Vector3<f64>,
94    shooting_angle: f64,
95) -> Vector3<f64> {
96    if shooting_angle == 0.0 {
97        return vector;
98    }
99
100    let (sin_angle, cos_angle) = shooting_angle.sin_cos();
101    Vector3::new(
102        vector.x * cos_angle + vector.y * sin_angle,
103        -vector.x * sin_angle + vector.y * cos_angle,
104        vector.z,
105    )
106}
107
108/// Direction of the Magnus force generated by the lateral yaw of repose.
109///
110/// In the McCoy frame the yaw of repose is lateral, so `F_M ∝ v̂ × α_R` reduces to
111/// gravity projected onto the plane normal to flight: down for right-hand twist and up for
112/// left-hand twist. Passing the actual shot-frame gravity vector keeps this correct for inclined
113/// fire; a vertical shot has no normal gravity component and therefore no repose-driven Magnus
114/// direction.
115pub(crate) fn yaw_of_repose_magnus_direction(
116    air_velocity: Vector3<f64>,
117    gravity_acceleration: Vector3<f64>,
118    is_twist_right: bool,
119) -> Option<Vector3<f64>> {
120    let speed = air_velocity.norm();
121    if !speed.is_finite() || speed <= 1e-12 {
122        return None;
123    }
124
125    let velocity_unit = air_velocity / speed;
126    let gravity_normal =
127        gravity_acceleration - velocity_unit * gravity_acceleration.dot(&velocity_unit);
128    let normal_magnitude = gravity_normal.norm();
129    if !normal_magnitude.is_finite() || normal_magnitude <= 1e-12 {
130        return None;
131    }
132
133    let right_twist_direction = gravity_normal / normal_magnitude;
134    Some(if is_twist_right {
135        right_twist_direction
136    } else {
137        -right_twist_direction
138    })
139}
140
141/// Compute ballistic derivatives for trajectory integration.
142///
143/// `wind_vector` and `omega_vector` use level downrange/up/lateral axes and are projected into
144/// the inclined shot frame internally.
145#[allow(clippy::too_many_arguments)]
146pub fn compute_derivatives(
147    pos: Vector3<f64>,
148    vel: Vector3<f64>,
149    inputs: &BallisticInputs,
150    wind_vector: Vector3<f64>,
151    atmos_params: (f64, f64, f64, f64),
152    bc_used: f64,
153    omega_vector: Option<Vector3<f64>>,
154    // MBA-1134: the in-integration spin-drift term that consumed `time` is deprecated (spin drift
155    // is now a Litz post-process), so this is currently unused; kept in the signature for callers.
156    _time: f64,
157    // MBA-1137: optional downrange-segmented atmosphere. When `Some`, the STANDARD-mode base
158    // (station-referenced) T/P/H is swapped for the zone selected by downrange distance (pos.x)
159    // before the altitude lapse; the direct-atmosphere sentinel path is left untouched. `None`
160    // (every existing caller) is byte-identical to the pre-feature behavior.
161    atmo_sock: Option<&AtmoSock>,
162) -> [f64; 6] {
163    // Gravity acceleration vector, rotated into the shot-aligned frame by shooting_angle
164    // (uphill/downhill inclined fire), matching cli_api::TrajectorySolver::gravity_acceleration.
165    let theta = inputs.shooting_angle;
166    let accel_gravity = Vector3::new(
167        -G_ACCEL_MPS2 * theta.sin(),
168        -G_ACCEL_MPS2 * theta.cos(),
169        0.0,
170    );
171
172    // Wind-adjusted velocity. Wind sources are defined in the level frame; rotate the selected
173    // vector into the same inclined shot frame used by velocity and gravity.
174    let wind_vector = level_vector_to_shot_frame(wind_vector, theta);
175    let velocity_adjusted = vel - wind_vector;
176    let speed_air = velocity_adjusted.norm();
177
178    // Initialize drag acceleration
179    let mut accel_drag = Vector3::zeros();
180    let mut accel_magnus = Vector3::zeros();
181
182    // Calculate drag if velocity is significant
183    if speed_air > crate::constants::MIN_VELOCITY_THRESHOLD {
184        let v_rel_fps = speed_air * MPS_TO_FPS;
185
186        // Get atmospheric conditions
187        let altitude_at_pos = crate::atmosphere::shot_frame_altitude(
188            inputs.altitude,
189            pos[0],
190            pos[1],
191            inputs.shooting_angle,
192        );
193
194        // Check if we have direct atmosphere values
195        // Direct atmosphere is indicated by having only 2 parameters where:
196        // params[0] = air density, params[1] = speed of sound
197        // params[2] and params[3] would be 0.0
198        // BUT: we need to check if params[0] is a reasonable density value (< 2.0 kg/m³)
199        let (air_density, speed_of_sound, temperature_c) = if atmos_params.0 < MAX_REALISTIC_DENSITY
200            && atmos_params.1 > MIN_REALISTIC_SPEED_OF_SOUND
201            && atmos_params.2 == 0.0
202            && atmos_params.3 == 0.0
203        {
204            // Direct atmosphere values: atmos_params.1 is the SPEED OF SOUND here, NOT Celsius,
205            // so back-compute temperature from it (c = sqrt(1.4*287.05*T_k)) for the optional
206            // Reynolds-correction input below.
207            let (rho, sound) = get_direct_atmosphere(atmos_params.0, atmos_params.1);
208            (rho, sound, dry_air_temperature_c_from_sound_speed(sound))
209        } else {
210            // Calculate from base parameters. MBA-1136 (rank 9): route the local speed of sound
211            // through the moist-air formula. Humidity is NOT plumbed to this call site — on the
212            // sole production path (trajectory_integration::build_inputs) the `humidity` FIELD is
213            // overwritten with atmos_params.3 (the density RATIO), so `inputs.humidity` here is not
214            // a real RH. Pass 0.0 (dry) rather than fabricate humidity; density is unchanged and
215            // the dry speed of sound is numerically identical to the old get_local_atmosphere.
216            //
217            // MBA-1137: when a downrange-segmented atmosphere is present, swap the BASE
218            // (station-referenced) temp/pressure/ratio for the zone selected by downrange distance
219            // (pos[0]) BEFORE the altitude lapse. The zone base_ratio is recomputed via CIPM from
220            // the zone (temp, pressure, humidity); the swapped base then flows through the same
221            // altitude-lapse pipeline, so downrange-zone selection and the world-vertical lapse
222            // compose without double-counting.
223            // Only the base density changes — the local speed of sound is independent of base_ratio,
224            // so it still tracks the lapsed temperature/pressure. `None` -> byte-identical.
225            let (base_temp_c, base_press_hpa, base_ratio) = match atmo_sock {
226                Some(sock) => {
227                    let (zt, zp, zh) = sock.atmo_for_range(pos[0]);
228                    (zt, zp, calculate_air_density_cimp(zt, zp, zh) / 1.225)
229                }
230                None => {
231                    // A zero/nonpositive standard-mode ratio means "not supplied", not vacuum.
232                    // Match the plain fast path's sea-level fallback so sibling solvers cannot
233                    // interpret the same FastIntegrationParams tuple oppositely (MBA-1157).
234                    let base_ratio = if atmos_params.3 > 0.0 {
235                        atmos_params.3
236                    } else {
237                        1.0
238                    };
239                    (atmos_params.1, atmos_params.2, base_ratio)
240                }
241            };
242            let (rho, sound) = get_local_atmosphere_humid(
243                altitude_at_pos,
244                atmos_params.0, // base_alt
245                base_temp_c,    // base_temp_c (zone-swapped when AtmoSock present)
246                base_press_hpa, // base_press_hpa (zone-swapped when AtmoSock present)
247                base_ratio,     // base_ratio (zone-swapped when AtmoSock present)
248                0.0,            // humidity not available here (see note above)
249            );
250            // LOCAL temperature at the projectile altitude, back-computed from the LOCAL speed of
251            // sound (get_local_atmosphere returns density/sound at altitude_at_pos but not temp;
252            // its sound = sqrt(1.4*287.05*T_k)). Using base_temp_c here would feed the optional
253            // Reynolds viscosity input a shooter-altitude temperature while density/sound are
254            // local if that correction is enabled later.
255            (rho, sound, dry_air_temperature_c_from_sound_speed(sound))
256        };
257
258        // Calculate Mach number with safe division
259        let mach = if speed_of_sound > 1e-9 {
260            speed_air / speed_of_sound
261        } else {
262            0.0 // No meaningful Mach number at zero speed of sound
263        };
264
265        // Get the base drag coefficient; optional corrections are applied or controlled below.
266        let drag_factor = get_drag_coefficient_full(
267            mach,
268            &inputs.bc_type,
269            false, // transonic applied exactly once below (was double-applied here + in block)
270            false, // Reynolds remains disabled consistently across all solver families (MBA-945)
271            None,  // let it determine shape
272            if inputs.caliber_inches > 0.0 {
273                Some(inputs.caliber_inches)
274            } else {
275                Some(inputs.bullet_diameter / 0.0254) // meters -> inches
276            },
277            if inputs.weight_grains > 0.0 {
278                Some(inputs.weight_grains)
279            } else {
280                Some(inputs.bullet_mass / crate::constants::GRAINS_TO_KG) // kg -> grains
281            },
282            Some(speed_air),
283            Some(air_density),
284            Some(temperature_c),
285        );
286
287        // Get BC value
288        let mut bc_val = bc_used;
289
290        if inputs.use_bc_segments {
291            // First try velocity-based segments if available
292            if inputs.bc_segments_data.is_some() {
293                bc_val = get_bc_for_velocity(v_rel_fps, inputs, bc_used);
294            } else {
295                match inputs.bc_segments.as_deref() {
296                    // Fall back to a non-empty Mach table when no velocity data exist.
297                    Some(segments) if !segments.is_empty() => {
298                        bc_val = interpolated_bc(mach, segments, Some(inputs));
299                    }
300                    // An explicitly empty table is a no-op and preserves the active caller BC.
301                    Some(_) => {}
302                    // No explicit table: retain the opt-in automatic estimation behavior.
303                    None => bc_val = get_bc_for_velocity(v_rel_fps, inputs, bc_used),
304                }
305            }
306        } else if let Some(segments) = inputs
307            .bc_segments
308            .as_deref()
309            .filter(|segments| !segments.is_empty())
310        {
311            // Explicit Mach-based segments (legacy behavior when use_bc_segments=false)
312            bc_val = interpolated_bc(mach, segments, Some(inputs));
313        }
314
315        // Guard bc_val == 0 (allowed on the FFI/WASM/library surfaces, which lack the CLI's
316        // 0.001 floor, and a user-supplied BC segment can be 0): the drag division below would be
317        // Inf -> NaN, poisoning the whole trajectory. Mirrors the guards already in
318        // cli_api::calculate_acceleration and fast_trajectory::compute_derivatives. Inert for
319        // valid BCs (>= 0.001).
320        let bc_val = bc_val.max(1e-6);
321
322        // Apply the documented radian tip-off yaw, decaying exponentially with distance.
323        let yaw_rad = if inputs.tipoff_decay_distance.abs() > 1e-9 {
324            inputs.tipoff_yaw * (-pos[0] / inputs.tipoff_decay_distance).exp()
325        } else {
326            inputs.tipoff_yaw // No decay if distance is zero
327        };
328        let yaw_multiplier = 1.0 + yaw_rad.powi(2);
329
330        // Calculate density scaling
331        let density_scale = air_density / STANDARD_AIR_DENSITY;
332
333        // Apply the transonic drag-rise correction exactly ONCE. The base Cd above is taken
334        // WITHOUT transonic correction (apply_transonic_correction=false), so this is the only
335        // application. Previously the correction was applied here AND inside
336        // get_drag_coefficient_full, which squared the drag-rise factor and double-counted wave
337        // drag across the transonic band (Cd ~3x too high near Mach 1). transonic_correction
338        // self-gates via the projectile's critical Mach (returns the input unchanged outside the
339        // band), and include_wave_drag=false matches cli_api::calculate_drag_coefficient — the
340        // G1/G7 tables already embed the transonic rise, so additive wave drag would double-count.
341        // Use the same SI fallbacks as the get_drag_coefficient_full call above (and
342        // fast_trajectory): an SI-only caller may leave caliber_inches/weight_grains at 0, so
343        // derive them from the SI bullet_diameter/bullet_mass rather than feeding zeros into
344        // get_projectile_shape (which would mis-classify the shape via weight/caliber).
345        let caliber_in = if inputs.caliber_inches > 0.0 {
346            inputs.caliber_inches
347        } else {
348            inputs.bullet_diameter / 0.0254 // meters -> inches
349        };
350        let weight_gr = if inputs.weight_grains > 0.0 {
351            inputs.weight_grains
352        } else {
353            inputs.bullet_mass / crate::constants::GRAINS_TO_KG // kg -> grains
354        };
355        // MBA-949: shared resolver so named bullet_model shapes are honored here too (this path
356        // previously used only the caliber/weight heuristic and ignored the name).
357        let shape = crate::transonic_drag::resolve_projectile_shape(
358            inputs.bullet_model.as_deref(),
359            caliber_in,
360            weight_gr,
361            &inputs.bc_type.to_string(),
362        );
363        let drag_factor =
364            crate::transonic_drag::transonic_correction(mach, drag_factor, shape, false);
365
366        // MBA-945: the low-velocity Reynolds drag correction was applied ONLY here (not in cli_api
367        // or fast_trajectory), so subsonic shots diverged across the three solver families. Removed
368        // for consistency — py_ballisticcalc (the validation reference) does not model it, and
369        // cli_api already matches pbc subsonically without it (validated to 1300yd in MBA-939). The
370        // reynolds module and get_drag_coefficient_full's apply_reynolds flag remain available for a
371        // future opt-in wired across all three solvers.
372
373        // MBA-940: a user-supplied custom drag table overrides the G-model Cd entirely and is used
374        // as-is — no reference-table transonic correction or name-derived form-factor multiplier
375        // is applied to it (the curve already encodes the projectile's true drag, so applying one
376        // would distort/double-count it).
377        // The custom table's Cd is the projectile's ACTUAL drag coefficient, so the
378        // retardation denominator must be the sectional density (lb/in²), not a BC:
379        // Cd_own / SD == Cd_ref / BC (see BallisticInputs::custom_drag_denominator).
380        let (drag_factor, retard_denom) = match inputs.custom_drag_table {
381            Some(ref table) => (
382                // MBA-1357: cd_scale is a single whole-curve drag multiplier applied here, at
383                // the Cd lookup site. The Mach-keyed DSF table (truing_dsf.rs) is a SEPARATE,
384                // drop-only post-processing correction applied to a solved TrajectoryResult's
385                // points after integration finishes — it never touches this drag computation.
386                table.interpolate(mach) * inputs.cd_scale,
387                inputs.custom_drag_denominator(bc_val),
388            ),
389            None => (drag_factor, bc_val),
390        };
391
392        // Calculate drag acceleration
393        let standard_factor = drag_factor * CD_TO_RETARD;
394        let a_drag_ft_s2 =
395            (v_rel_fps.powi(2) * standard_factor * yaw_multiplier * density_scale) / retard_denom;
396        let a_drag_m_s2 = a_drag_ft_s2 * FPS_TO_MPS;
397
398        // Apply drag in opposite direction of relative velocity
399        accel_drag = -a_drag_m_s2 * (velocity_adjusted / speed_air);
400
401        // Magnus Effect calculation. Gated on enable_magnus specifically so it is
402        // independent of Coriolis (matches the cli_api solver's decoupled flags).
403        // MBA-1134 (rank 35): when the canonical empirical Litz spin-drift post-process is active
404        // (use_enhanced_spin_drift + advanced effects — the same condition that drove the now-
405        // deprecated in-integration spin-drift term), it already captures the gyroscopic/yaw-of-
406        // repose lateral. The explicit Magnus side force must NOT be added on top or the two
407        // lateral models stack and double-count the drift, so suppress Magnus in that case.
408        if inputs.enable_magnus
409            && !(inputs.use_enhanced_spin_drift && inputs.enable_advanced_effects)
410            && inputs.bullet_diameter > 0.0
411            && inputs.twist_rate > 0.0
412        {
413            let diameter_m = inputs.bullet_diameter;
414            let (spin_rate_rad_s, spin_param) = crate::spin_drift::calculate_magnus_spin_state(
415                inputs.muzzle_velocity,
416                speed_air,
417                inputs.twist_rate,
418                diameter_m,
419            );
420
421            let c_np = calculate_magnus_moment_coefficient(mach);
422
423            // Calculate reference area
424            let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
425
426            // Yaw of repose for the proper Magnus force. Stability/yaw helpers are
427            // imperial: use the explicit imperial mirror fields, and convert the SI
428            // bullet_length to inches at this boundary.
429            let d_in = inputs.caliber_inches;
430            let m_gr = inputs.weight_grains;
431            let l_in = if inputs.bullet_length > 0.0 {
432                inputs.bullet_length / 0.0254 // meters -> inches
433            } else {
434                // MBA-1135: mass-based length estimate (was a mass-blind 4.5-caliber default).
435                let est_m = crate::stability::estimate_bullet_length_m(
436                    inputs.bullet_diameter,
437                    inputs.bullet_mass,
438                );
439                if est_m > 0.0 {
440                    est_m / 0.0254
441                } else {
442                    4.5 * d_in.max(1e-9)
443                }
444            };
445            // Use current-flight Sg with the muzzle-set spin. Back-calculating the effective
446            // twist from fixed spin and current airspeed lets gyroscopic stability (and therefore
447            // yaw of repose) grow as translational velocity decays; local density supplies the
448            // canonical Miller atmospheric correction.
449            let sg = crate::spin_drift::calculate_dynamic_stability(
450                m_gr,
451                speed_air,
452                spin_rate_rad_s,
453                d_in,
454                l_in,
455                air_density,
456            );
457            let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
458                sg,
459                speed_air,
460                spin_rate_rad_s,
461                0.0,
462                0.0,
463                air_density,
464                d_in,
465                l_in,
466                m_gr,
467                mach,
468                "match",
469                false,
470            );
471
472            // Proper McCoy Magnus FORCE: F = q S C_Npa (pd/2V) sin(alpha_R).
473            let magnus_force_magnitude =
474                0.5 * air_density * speed_air.powi(2) * area * c_np * spin_param * yaw_rad.sin();
475
476            // A lateral yaw of repose produces a vertical Magnus force. Its lateral force is
477            // aerodynamic lift, represented separately by the Litz spin-drift model.
478            if magnus_force_magnitude > 1e-12 {
479                if let Some(magnus_direction) = yaw_of_repose_magnus_direction(
480                    velocity_adjusted,
481                    accel_gravity,
482                    inputs.is_twist_right,
483                ) {
484                    let bullet_mass_kg = inputs.bullet_mass; // already kg (SI)
485                    accel_magnus = (magnus_force_magnitude / bullet_mass_kg) * magnus_direction;
486                }
487            }
488        }
489    }
490
491    // Total acceleration
492    let mut accel = accel_gravity + accel_drag + accel_magnus;
493
494    // Add Coriolis acceleration if a level-frame omega vector is provided. The physical term is
495    // -2 Ω×v (MBA-957: the old +2 "frame-relabel" justification was wrong — it flipped the
496    // lateral drift; the caller now builds omega with the corrected lateral sign, matching the
497    // validated cli_api solver, so the canonical -2 applies directly).
498    if let Some(omega) = omega_vector {
499        let omega = level_vector_to_shot_frame(omega, theta);
500        let accel_coriolis = -2.0 * omega.cross(&vel);
501        accel += accel_coriolis;
502    }
503
504    // MBA-1134 (rank 10): the in-integration enhanced-spin-drift ACCELERATION term
505    // (spin_drift::calculate_enhanced_spin_drift / apply_enhanced_spin_drift) is DEPRECATED and is
506    // no longer applied here. It carried a unit bug and was a SECOND, inconsistent lateral model.
507    // Spin drift is now the single canonical empirical Litz post-process
508    // (spin_drift::litz_drift_meters), applied by cli_api::apply_spin_drift and the fast /
509    // Monte-Carlo path (fast_trajectory + monte_carlo) at the endpoint time-of-flight. Keeping only
510    // one model here guarantees the three solver families agree on lateral drift and prevents the
511    // Magnus + spin-drift double-count (see the Magnus gate above). The `calculate_enhanced_spin_drift`
512    // / `apply_enhanced_spin_drift` helpers remain in spin_drift.rs for backward compatibility but
513    // are no longer wired into any integration path.
514
515    // Return state derivatives: [velocity, acceleration]
516    [vel[0], vel[1], vel[2], accel[0], accel[1], accel[2]]
517}
518
519/// Interpolate a ballistic coefficient from Mach-keyed segments.
520///
521/// An empty optional table is not a request to invent a new BC: when `inputs` are present, the
522/// caller's scalar `bc_value` is preserved. Standalone calls without inputs retain the historical
523/// conservative fallback because no caller BC is available.
524pub fn interpolated_bc(
525    mach: f64,
526    segments: &[(f64, f64)],
527    inputs: Option<&BallisticInputs>,
528) -> f64 {
529    if segments.is_empty() {
530        if let Some(inputs) = inputs {
531            return inputs.bc_value;
532        }
533        return crate::constants::BC_FALLBACK_CONSERVATIVE;
534    }
535
536    if segments.len() == 1 {
537        return segments[0].1;
538    }
539
540    // Ensure ascending-Mach order for interpolation. Fast path: when the segments are
541    // already sorted (the common case — they are normalized once at construction), borrow
542    // them and skip the per-call heap alloc + O(n log n) sort on the integration hot path.
543    let sorted_segments: std::borrow::Cow<[(f64, f64)]> =
544        if segments.windows(2).all(|w| w[0].0 <= w[1].0) {
545            std::borrow::Cow::Borrowed(segments)
546        } else {
547            let mut v = segments.to_vec();
548            v.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
549            std::borrow::Cow::Owned(v)
550        };
551
552    // Handle out-of-range cases first
553    if mach <= sorted_segments[0].0 {
554        return sorted_segments[0].1;
555    }
556    if mach >= sorted_segments[sorted_segments.len() - 1].0 {
557        return sorted_segments[sorted_segments.len() - 1].1;
558    }
559
560    // Find the appropriate segment using binary search
561    let idx = sorted_segments.partition_point(|(m, _)| *m <= mach);
562    if idx == 0 || idx >= sorted_segments.len() {
563        // Should not happen given the checks above
564        return sorted_segments[0].1;
565    }
566
567    let (mach1, bc1) = sorted_segments[idx - 1];
568    let (mach2, bc2) = sorted_segments[idx];
569
570    // Linear interpolation with safe division
571    let denominator = mach2 - mach1;
572    if denominator.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
573        return bc1; // Return first BC value if Mach values are identical
574    }
575    let t = (mach - mach1) / denominator;
576    bc1 + t * (bc2 - bc1)
577}
578
579/// Get BC value for current velocity, supporting velocity-based BC segments
580fn get_bc_for_velocity(velocity_fps: f64, inputs: &BallisticInputs, bc_used: f64) -> f64 {
581    // Check if velocity-based BC segments are enabled
582    if !inputs.use_bc_segments {
583        return bc_used;
584    }
585
586    // Try direct BC segments data first
587    if let Some(bc_segments_data) = inputs
588        .bc_segments_data
589        .as_ref()
590        .filter(|segments| !segments.is_empty())
591    {
592        // An explicit table is authoritative even when this velocity lies in a coverage gap.
593        // Do not silently replace it with an auto-estimated table after a miss.
594        return velocity_segment_bc(velocity_fps, bc_segments_data, bc_used);
595    }
596
597    // Try BC estimation if we have bullet details but no segments. MBA-955: the estimation is
598    // factored into estimate_bc_segments_for so the per-integration setup (build_inputs) can
599    // pre-populate bc_segments_data ONCE rather than rebuilding it here every step.
600    if let Some(segments) = estimate_bc_segments_for(inputs, bc_used) {
601        return velocity_segment_bc(velocity_fps, &segments, bc_used);
602    }
603
604    // Fallback to constant BC
605    bc_used
606}
607
608/// Estimate velocity-BC segments from bullet characteristics (MBA-955). Extracted from
609/// get_bc_for_velocity's slow path so the per-integration setup can compute the segments ONCE
610/// (build_inputs pre-populates bc_segments_data) instead of rebuilding them — allocating a model
611/// String and a segment Vec — on every derivative evaluation. Returns None when the bullet
612/// details needed for estimation are absent (the caller then falls back to the constant BC). The
613/// logic is byte-identical to the previous inline slow path.
614pub(crate) fn estimate_bc_segments_for(
615    inputs: &BallisticInputs,
616    bc_used: f64,
617) -> Option<Vec<crate::BCSegmentData>> {
618    if !(inputs.bullet_diameter > 0.0 && inputs.bullet_mass > 0.0 && bc_used > 0.0) {
619        return None;
620    }
621    // Model string from bullet_id or a generic weight-based description (unchanged).
622    let model = if let Some(ref bullet_id) = inputs.bullet_id {
623        bullet_id.clone()
624    } else {
625        format!("{}gr bullet", inputs.weight_grains as i32)
626    };
627    // Prefer the legacy explicit string when present, but otherwise preserve the
628    // typed drag model carried by every current BallisticInputs constructor.
629    let bc_type_str = inputs.bc_type_str.as_deref().unwrap_or(match inputs.bc_type {
630        crate::DragModel::G7 => "G7",
631        _ => "G1",
632    });
633    Some(BCSegmentEstimator::estimate_bc_segments(
634        bc_used,
635        inputs.caliber_inches,
636        inputs.weight_grains,
637        &model,
638        bc_type_str,
639    ))
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645
646    fn expected_shot_frame_vector(level: Vector3<f64>, angle: f64) -> Vector3<f64> {
647        let (sin_angle, cos_angle) = angle.sin_cos();
648        Vector3::new(
649            level.x * cos_angle + level.y * sin_angle,
650            -level.x * sin_angle + level.y * cos_angle,
651            level.z,
652        )
653    }
654
655    #[test]
656    fn level_vector_projection_is_bit_exact_at_zero_incline() {
657        let level = Vector3::new(12.5, -0.0, -7.25);
658        let projected = level_vector_to_shot_frame(level, 0.0);
659
660        for component in 0..3 {
661            assert_eq!(projected[component].to_bits(), level[component].to_bits());
662        }
663    }
664
665    #[test]
666    fn dry_air_sound_speed_round_trips_to_local_celsius() {
667        for expected_temp_c in [-40.0_f64, 15.0, 40.0] {
668            let sound_speed = (1.4 * 287.05 * (expected_temp_c + 273.15)).sqrt();
669            let actual_temp_c = dry_air_temperature_c_from_sound_speed(sound_speed);
670            assert!(
671                (actual_temp_c - expected_temp_c).abs() < 1e-10,
672                "sound speed {sound_speed} m/s recovered {actual_temp_c} C, expected {expected_temp_c} C"
673            );
674        }
675
676        let direct_mode_temp_c = dry_air_temperature_c_from_sound_speed(340.0);
677        assert!(
678            direct_mode_temp_c > 10.0 && direct_mode_temp_c < 20.0,
679            "direct-mode 340 m/s must be interpreted as sound speed, not 340 C"
680        );
681    }
682
683    #[test]
684    fn tipoff_yaw_uses_documented_radians_for_drag_and_decay() {
685        let mut baseline_inputs = create_test_inputs();
686        baseline_inputs.tipoff_yaw = 0.0;
687        baseline_inputs.tipoff_decay_distance = 50.0;
688        let mut yawed_inputs = baseline_inputs.clone();
689        yawed_inputs.tipoff_yaw = 0.1;
690
691        let drag_x = |inputs: &BallisticInputs, downrange_m: f64| {
692            compute_derivatives(
693                Vector3::new(downrange_m, 0.0, 0.0),
694                Vector3::new(300.0, 0.0, 0.0),
695                inputs,
696                Vector3::zeros(),
697                (1.225, 340.0, 0.0, 0.0),
698                0.5,
699                None,
700                0.0,
701                None,
702            )[3]
703        };
704
705        for (downrange_m, expected_ratio) in
706            [(0.0_f64, 1.01), (50.0 * std::f64::consts::LN_2, 1.0025)]
707        {
708            let baseline_drag = drag_x(&baseline_inputs, downrange_m);
709            let yawed_drag = drag_x(&yawed_inputs, downrange_m);
710            let actual_ratio = yawed_drag / baseline_drag;
711
712            assert!(baseline_drag < 0.0, "baseline must be downrange drag");
713            assert!(
714                (actual_ratio - expected_ratio).abs() < 1e-12,
715                "tip-off yaw at x={downrange_m} m used the wrong angular unit or decay: \
716                 ratio={actual_ratio}, expected={expected_ratio}"
717            );
718        }
719    }
720
721    fn create_test_inputs() -> BallisticInputs {
722        // SI-canonical geometry/mass (kg, meters) — same convention as the struct
723        // docs and cli_api — plus the explicit imperial mirror fields
724        // (caliber_inches/weight_grains) the stability/Magnus helpers read.
725        BallisticInputs {
726            muzzle_velocity: 800.0, // m/s
727            bc_value: 0.5,
728            bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG, // kg (168 gr)
729            bullet_diameter: 0.308 * 0.0254,    // meters (.308 in)
730            bullet_length: 1.215 * 0.0254,      // meters
731            caliber_inches: 0.308,
732            weight_grains: 168.0,
733            altitude: 1000.0,
734            ..Default::default()
735        }
736    }
737
738    #[test]
739    fn measured_bc_drag_ignores_name_based_form_factor_flag() {
740        let derivatives_with_flag = |use_form_factor| {
741            let inputs = BallisticInputs {
742                bc_value: 0.462,
743                bc_type: crate::DragModel::G1,
744                bullet_model: Some("168gr SMK Match".to_string()),
745                use_form_factor,
746                ..create_test_inputs()
747            };
748
749            compute_derivatives(
750                Vector3::zeros(),
751                Vector3::new(600.0, 0.0, 0.0),
752                &inputs,
753                Vector3::zeros(),
754                (1.225, 340.0, 0.0, 0.0),
755                inputs.bc_value,
756                None,
757                0.0,
758                None,
759            )
760        };
761
762        let baseline = derivatives_with_flag(false);
763        let flagged = derivatives_with_flag(true);
764
765        for component in 3..6 {
766            assert_eq!(
767                flagged[component].to_bits(),
768                baseline[component].to_bits(),
769                "published BC already encodes form factor: component {component}, baseline={} flagged={}",
770                baseline[component],
771                flagged[component]
772            );
773        }
774    }
775
776    #[test]
777    fn inclined_positions_at_same_world_altitude_have_same_atmospheric_acceleration() {
778        let angle = std::f64::consts::FRAC_PI_6;
779        let mut inputs = create_test_inputs();
780        inputs.altitude = 100.0;
781        inputs.shooting_angle = angle;
782        let velocity = Vector3::new(600.0, 0.0, 0.0);
783        let along_slant = Vector3::new(1_000.0, 0.0, 0.0);
784        let across_slant = Vector3::new(0.0, 500.0 / angle.cos(), 0.0);
785        let atmo = (inputs.altitude, 15.0, 1013.25, 1.0);
786
787        let a = compute_derivatives(
788            along_slant,
789            velocity,
790            &inputs,
791            Vector3::zeros(),
792            atmo,
793            inputs.bc_value,
794            None,
795            0.0,
796            None,
797        );
798        let b = compute_derivatives(
799            across_slant,
800            velocity,
801            &inputs,
802            Vector3::zeros(),
803            atmo,
804            inputs.bc_value,
805            None,
806            0.0,
807            None,
808        );
809
810        for component in 3..6 {
811            assert!(
812                (a[component] - b[component]).abs() < 1e-10,
813                "derivative component {component} differs at equal world altitude: {} vs {}",
814                a[component],
815                b[component]
816            );
817        }
818    }
819
820    #[test]
821    fn inclined_headwind_is_rotated_into_solver_frame() {
822        let angle = std::f64::consts::FRAC_PI_6;
823        let mut inputs = create_test_inputs();
824        inputs.shooting_angle = angle;
825        let level_headwind = Vector3::new(-100.0, 0.0, 0.0);
826        let velocity = expected_shot_frame_vector(level_headwind, angle);
827        let actual = compute_derivatives(
828            Vector3::zeros(),
829            velocity,
830            &inputs,
831            level_headwind,
832            (1.225, 340.0, 0.0, 0.0),
833            inputs.bc_value,
834            None,
835            0.0,
836            None,
837        );
838        let expected = Vector3::new(
839            -G_ACCEL_MPS2 * angle.sin(),
840            -G_ACCEL_MPS2 * angle.cos(),
841            0.0,
842        );
843
844        assert!(
845            (Vector3::new(actual[3], actual[4], actual[5]) - expected).norm() < 1e-12,
846            "co-moving horizontal wind must leave only shot-frame gravity: {actual:?}"
847        );
848    }
849
850    #[test]
851    fn inclined_coriolis_is_rotated_into_solver_frame() {
852        let angle = std::f64::consts::FRAC_PI_6;
853        let mut inputs = create_test_inputs();
854        inputs.shooting_angle = angle;
855        let velocity = Vector3::new(600.0, 20.0, 5.0);
856        let level_omega = Vector3::new(3.0e-5, 6.0e-5, -2.0e-5);
857        let run = |omega| {
858            compute_derivatives(
859                Vector3::zeros(),
860                velocity,
861                &inputs,
862                Vector3::zeros(),
863                (1.225, 340.0, 0.0, 0.0),
864                inputs.bc_value,
865                omega,
866                0.0,
867                None,
868            )
869        };
870        let baseline = run(None);
871        let with_coriolis = run(Some(level_omega));
872        let actual = Vector3::new(
873            with_coriolis[3] - baseline[3],
874            with_coriolis[4] - baseline[4],
875            with_coriolis[5] - baseline[5],
876        );
877        let expected = -2.0 * expected_shot_frame_vector(level_omega, angle).cross(&velocity);
878
879        assert!(
880            (actual - expected).norm() < 1e-12,
881            "inclined Coriolis mismatch: actual={actual:?}, expected={expected:?}"
882        );
883    }
884
885    #[test]
886    fn explicit_velocity_segment_gap_uses_scalar_bc_without_auto_estimation() {
887        let mut inputs = create_test_inputs();
888        inputs.bc_value = 0.91;
889        inputs.use_bc_segments = true;
890        inputs.bc_segments_data = Some(vec![
891            crate::BCSegmentData {
892                velocity_min: 0.0,
893                velocity_max: 999.0,
894                bc_value: 0.2,
895            },
896            crate::BCSegmentData {
897                velocity_min: 1000.0,
898                velocity_max: 2000.0,
899                bc_value: 0.3,
900            },
901        ]);
902
903        assert_eq!(
904            get_bc_for_velocity(999.5, &inputs, inputs.bc_value),
905            inputs.bc_value,
906            "an explicit table gap must not be replaced by an auto-estimated segment"
907        );
908    }
909
910    #[test]
911    fn test_mba955_bc_segments_prepopulate_byte_identical() {
912        // MBA-955: pre-populating bc_segments_data once (in build_inputs) must return
913        // BYTE-IDENTICAL BC to the old per-step estimation. Build the slow-path inputs
914        // (bc_segments_data = None -> get_bc_for_velocity estimates every call) and the
915        // pre-populated inputs (bc_segments_data = estimate_bc_segments_for, the same helper
916        // build_inputs now calls), and assert get_bc_for_velocity agrees bit-for-bit across the
917        // whole velocity range.
918        let mut slow = create_test_inputs();
919        slow.use_bc_segments = true;
920        slow.bc_segments_data = None;
921        slow.bc_segments = None;
922
923        let bc_used = slow.bc_value;
924        let mut fast = slow.clone();
925        fast.bc_segments_data = estimate_bc_segments_for(&fast, bc_used);
926        assert!(
927            fast.bc_segments_data.is_some(),
928            "estimation should yield segments for a valid bullet"
929        );
930
931        for v in (200..=3500).step_by(50) {
932            let vf = v as f64;
933            let a = get_bc_for_velocity(vf, &slow, bc_used);
934            let b = get_bc_for_velocity(vf, &fast, bc_used);
935            assert_eq!(
936                a.to_bits(),
937                b.to_bits(),
938                "BC differs at {vf} fps: slow={a} fast={b}"
939            );
940        }
941    }
942
943    #[test]
944    fn estimated_segments_inherit_typed_g7_drag_model() {
945        let mut inputs = create_test_inputs();
946        inputs.bc_value = 0.243;
947        inputs.bc_type = crate::DragModel::G7;
948        inputs.bc_type_str = None;
949        inputs.bullet_mass = 175.0 * crate::constants::GRAINS_TO_KG;
950        inputs.weight_grains = 175.0;
951
952        let actual = estimate_bc_segments_for(&inputs, inputs.bc_value).unwrap();
953        let expected = BCSegmentEstimator::estimate_bc_segments(
954            inputs.bc_value,
955            inputs.caliber_inches,
956            inputs.weight_grains,
957            "175gr bullet",
958            "G7",
959        );
960
961        assert_eq!(actual.len(), expected.len());
962        for (actual, expected) in actual.iter().zip(&expected) {
963            assert_eq!(actual.velocity_min.to_bits(), expected.velocity_min.to_bits());
964            assert_eq!(actual.velocity_max.to_bits(), expected.velocity_max.to_bits());
965            assert_eq!(actual.bc_value.to_bits(), expected.bc_value.to_bits());
966        }
967
968        // An explicitly populated legacy string remains authoritative.
969        inputs.bc_type_str = Some("G1".to_string());
970        let legacy = estimate_bc_segments_for(&inputs, inputs.bc_value).unwrap();
971        let expected_g1 = BCSegmentEstimator::estimate_bc_segments(
972            inputs.bc_value,
973            inputs.caliber_inches,
974            inputs.weight_grains,
975            "175gr bullet",
976            "G1",
977        );
978        assert_eq!(legacy.len(), expected_g1.len());
979        for (legacy, expected) in legacy.iter().zip(&expected_g1) {
980            assert_eq!(legacy.bc_value.to_bits(), expected.bc_value.to_bits());
981        }
982    }
983
984    #[test]
985    fn test_compute_derivatives_basic() {
986        let pos = Vector3::new(0.0, 0.0, 0.0);
987        let vel = Vector3::new(800.0, 0.0, 0.0);
988        let inputs = create_test_inputs();
989        let wind_vector = Vector3::zeros();
990        // Use direct atmosphere values: (air_density, speed_of_sound, 0.0, 0.0)
991        let atmos_params = (1.225, 340.0, 0.0, 0.0); // Standard air density and speed of sound
992        let bc_used = 0.5;
993
994        let result = compute_derivatives(
995            pos,
996            vel,
997            &inputs,
998            wind_vector,
999            atmos_params,
1000            bc_used,
1001            None,
1002            0.0,
1003            None,
1004        );
1005
1006        // Check that we get velocity and acceleration components
1007        assert_eq!(result.len(), 6);
1008
1009        // Velocity components should match input velocity
1010        assert!((result[0] - vel[0]).abs() < 1e-10);
1011        assert!((result[1] - vel[1]).abs() < 1e-10);
1012        assert!((result[2] - vel[2]).abs() < 1e-10);
1013
1014        // Should have gravitational acceleration
1015        assert!(result[4] < 0.0); // Negative y acceleration due to gravity
1016
1017        // Should have drag acceleration opposing motion
1018        assert!(result[3] < 0.0); // Negative x acceleration due to drag
1019    }
1020
1021    #[test]
1022    fn standard_atmosphere_zero_ratio_uses_sea_level_fallback() {
1023        let pos = Vector3::new(0.0, 0.0, 0.0);
1024        let vel = Vector3::new(800.0, 0.0, 0.0);
1025        let inputs = create_test_inputs();
1026        let run = |base_ratio| {
1027            compute_derivatives(
1028                pos,
1029                vel,
1030                &inputs,
1031                Vector3::zeros(),
1032                (inputs.altitude, 15.0, 1013.25, base_ratio),
1033                inputs.bc_value,
1034                None,
1035                0.0,
1036                None,
1037            )
1038        };
1039
1040        let missing_ratio = run(0.0);
1041        let explicit_sea_level = run(1.0);
1042        assert!(
1043            missing_ratio[3] < 0.0,
1044            "missing standard density ratio must not produce vacuum drag"
1045        );
1046        assert!((missing_ratio[3] - explicit_sea_level[3]).abs() < 1e-12);
1047    }
1048
1049    #[test]
1050    fn test_compute_derivatives_with_wind() {
1051        let pos = Vector3::new(0.0, 0.0, 0.0);
1052        let vel = Vector3::new(800.0, 0.0, 0.0);
1053        let inputs = create_test_inputs();
1054        let wind_vector = Vector3::new(10.0, 0.0, 0.0); // Tailwind
1055        let atmos_params = (1.225, 340.0, 0.0, 0.0); // Standard air density and speed of sound
1056        let bc_used = 0.5;
1057
1058        let result = compute_derivatives(
1059            pos,
1060            vel,
1061            &inputs,
1062            wind_vector,
1063            atmos_params,
1064            bc_used,
1065            None,
1066            0.0,
1067            None,
1068        );
1069
1070        // With tailwind, effective velocity should be lower, thus less drag
1071        // Just check that we have some drag (negative acceleration)
1072        assert!(result[3] < 0.0); // Should have drag
1073    }
1074
1075    #[test]
1076    fn test_compute_derivatives_with_coriolis() {
1077        let pos = Vector3::new(0.0, 0.0, 0.0);
1078        let vel = Vector3::new(800.0, 0.0, 0.0);
1079        let inputs = create_test_inputs();
1080        let wind_vector = Vector3::zeros();
1081        let atmos_params = (1.225, 340.0, 0.0, 0.0); // Standard air density and speed of sound
1082        let bc_used = 0.5;
1083        let omega = Vector3::new(0.0, 0.0, 7.2921e-5); // Earth's rotation
1084
1085        let result = compute_derivatives(
1086            pos,
1087            vel,
1088            &inputs,
1089            wind_vector,
1090            atmos_params,
1091            bc_used,
1092            Some(omega),
1093            0.0,
1094            None,
1095        );
1096
1097        // Should have Coriolis effect
1098        assert!(result[4].abs() > 1e-3); // Should have some y-component from Coriolis
1099    }
1100
1101    #[test]
1102    fn test_interpolated_bc() {
1103        let segments = vec![(0.5, 0.4), (1.0, 0.5), (1.5, 0.6), (2.0, 0.5)];
1104
1105        // Test exact matches
1106        assert!((interpolated_bc(1.0, &segments, None) - 0.5).abs() < 1e-10);
1107
1108        // Test interpolation
1109        let bc_075 = interpolated_bc(0.75, &segments, None);
1110        assert!(bc_075 > 0.4 && bc_075 < 0.5);
1111
1112        // Test out of range
1113        assert!((interpolated_bc(0.1, &segments, None) - 0.4).abs() < 1e-10);
1114        assert!((interpolated_bc(3.0, &segments, None) - 0.5).abs() < 1e-10);
1115    }
1116
1117    #[test]
1118    fn test_interpolated_bc_edge_cases() {
1119        // Empty segments
1120        assert!(
1121            (interpolated_bc(1.0, &[], None) - crate::constants::BC_FALLBACK_CONSERVATIVE).abs()
1122                < 1e-10
1123        );
1124
1125        let mut inputs = create_test_inputs();
1126        inputs.bc_type = crate::DragModel::G7;
1127        inputs.bc_value = 0.487;
1128        assert_eq!(
1129            interpolated_bc(1.0, &[], Some(&inputs)).to_bits(),
1130            inputs.bc_value.to_bits(),
1131            "an empty optional table must preserve the caller's scalar BC"
1132        );
1133
1134        // Single segment
1135        let single = vec![(1.0, 0.7)];
1136        assert!((interpolated_bc(1.5, &single, None) - 0.7).abs() < 1e-10);
1137    }
1138
1139    #[test]
1140    fn empty_mach_segments_preserve_active_bc_used() {
1141        let mut inputs = create_test_inputs();
1142        inputs.bc_type = crate::DragModel::G7;
1143        inputs.bc_value = 0.123; // Deliberately different from the active fitted/adjusted BC.
1144
1145        let drag_acceleration = |inputs: &BallisticInputs| {
1146            compute_derivatives(
1147                Vector3::zeros(),
1148                Vector3::new(700.0, 0.0, 0.0),
1149                inputs,
1150                Vector3::zeros(),
1151                (1.225, 340.0, 0.0, 0.0),
1152                0.487,
1153                None,
1154                0.0,
1155                None,
1156            )[3]
1157        };
1158
1159        inputs.use_bc_segments = false;
1160        inputs.bc_segments = None;
1161        let no_table = drag_acceleration(&inputs);
1162
1163        for use_bc_segments in [false, true] {
1164            inputs.use_bc_segments = use_bc_segments;
1165            inputs.bc_segments = Some(Vec::new());
1166            let empty_table = drag_acceleration(&inputs);
1167
1168            assert_eq!(
1169                empty_table.to_bits(),
1170                no_table.to_bits(),
1171                "Some(empty) must preserve bc_used when use_bc_segments={use_bc_segments}"
1172            );
1173        }
1174    }
1175
1176    #[test]
1177    fn test_magnus_effect() {
1178        let pos = Vector3::new(0.0, 0.0, 0.0);
1179        let vel = Vector3::new(822.96, 0.0, 0.0); // 2700 fps
1180        let wind_vector = Vector3::zeros();
1181        let atmos_params = (1.225, 340.0, 0.0, 0.0); // Standard air density and speed of sound
1182        let bc_used = 0.5;
1183        let acceleration = |enable_magnus, is_twist_right| {
1184            let mut inputs = create_test_inputs();
1185            inputs.twist_rate = 10.0; // 1:10 twist
1186            inputs.is_twist_right = is_twist_right;
1187            inputs.enable_magnus = enable_magnus; // decoupled from enable_advanced_effects
1188
1189            let result = compute_derivatives(
1190                pos,
1191                vel,
1192                &inputs,
1193                wind_vector,
1194                atmos_params,
1195                bc_used,
1196                None,
1197                0.0,
1198                None,
1199            );
1200            Vector3::new(result[3], result[4], result[5])
1201        };
1202
1203        let baseline = acceleration(false, true);
1204        let right_twist = acceleration(true, true) - baseline;
1205        let left_twist = acceleration(true, false) - baseline;
1206
1207        // Yaw of repose is lateral, so its Magnus force is vertical: down for right-hand twist
1208        // and up for left-hand twist. The lateral force from yaw of repose is lift, represented by
1209        // the separate Litz spin-drift model when that model is enabled.
1210        assert!(
1211            right_twist.y < 0.0,
1212            "right-hand Magnus must point down, got {right_twist:?}"
1213        );
1214        assert!(
1215            left_twist.y > 0.0,
1216            "left-hand Magnus must point up, got {left_twist:?}"
1217        );
1218        assert!((right_twist.y + left_twist.y).abs() < 1e-12);
1219        assert!(right_twist.x.abs() < 1e-12 && right_twist.z.abs() < 1e-12);
1220        assert!(left_twist.x.abs() < 1e-12 && left_twist.z.abs() < 1e-12);
1221        assert!(
1222            right_twist.y.abs() < 0.05,
1223            "Magnus must remain a small force"
1224        );
1225    }
1226
1227    #[test]
1228    fn magnus_uses_velocity_corrected_muzzle_stability_gate() {
1229        let muzzle_velocity = 1_400.0 / MPS_TO_FPS;
1230        let mut inputs = create_test_inputs();
1231        inputs.muzzle_velocity = muzzle_velocity;
1232        inputs.twist_rate = 15.0;
1233        inputs.enable_magnus = true;
1234
1235        let bare_sg = crate::spin_drift::miller_stability(0.308, 168.0, 15.0, 1.215);
1236        let canonical_sg = crate::spin_drift::effective_sg_from_inputs(&inputs, 15.0, 1013.25);
1237        assert!(bare_sg > 1.0, "test requires bare Sg above the Magnus gate");
1238        assert!(
1239            canonical_sg < 1.0,
1240            "velocity-corrected Sg must be below the gate, got {canonical_sg}"
1241        );
1242
1243        let acceleration = |inputs: &BallisticInputs| {
1244            let result = compute_derivatives(
1245                Vector3::zeros(),
1246                Vector3::new(muzzle_velocity, 0.0, 0.0),
1247                inputs,
1248                Vector3::zeros(),
1249                (1.225, 340.0, 0.0, 0.0),
1250                0.5,
1251                None,
1252                0.0,
1253                None,
1254            );
1255            Vector3::new(result[3], result[4], result[5])
1256        };
1257        let enabled = acceleration(&inputs);
1258        inputs.enable_magnus = false;
1259        let disabled = acceleration(&inputs);
1260
1261        assert_eq!(
1262            enabled, disabled,
1263            "canonical Sg below 1 must suppress every Magnus acceleration component"
1264        );
1265    }
1266
1267    #[test]
1268    fn magnus_force_grows_as_fixed_spin_projectile_slows() {
1269        let mut inputs = create_test_inputs();
1270        inputs.muzzle_velocity = 800.0;
1271        inputs.twist_rate = 12.0;
1272        inputs.enable_magnus = true;
1273
1274        let magnus_acceleration = |speed_mps| {
1275            let evaluate = |enable_magnus| {
1276                let mut run_inputs = inputs.clone();
1277                run_inputs.enable_magnus = enable_magnus;
1278                compute_derivatives(
1279                    Vector3::zeros(),
1280                    Vector3::new(speed_mps, 0.0, 0.0),
1281                    &run_inputs,
1282                    Vector3::zeros(),
1283                    (1.225, 340.0, 0.0, 0.0),
1284                    0.5,
1285                    None,
1286                    0.0,
1287                    None,
1288                )[4]
1289            };
1290            (evaluate(true) - evaluate(false)).abs()
1291        };
1292
1293        let fast = magnus_acceleration(200.0);
1294        let slow = magnus_acceleration(100.0);
1295        let ratio = slow / fast;
1296        let expected_ratio = 2.0_f64.powf(5.0 / 3.0);
1297
1298        assert!(fast > 0.0 && slow > 0.0, "fast={fast}, slow={slow}");
1299        assert!(
1300            (ratio - expected_ratio).abs() < 1e-3,
1301            "fixed-spin Magnus acceleration must grow downrange; slow/fast={ratio}, \
1302             expected={expected_ratio}"
1303        );
1304    }
1305
1306    #[test]
1307    fn test_magnus_moment_coefficient() {
1308        // Test at various Mach numbers with corrected coefficients
1309        assert!((calculate_magnus_moment_coefficient(0.5) - 0.030).abs() < 0.001); // Subsonic
1310        assert!((calculate_magnus_moment_coefficient(0.8) - 0.030).abs() < 0.001); // Start of transonic
1311        assert!((calculate_magnus_moment_coefficient(1.0) - 0.0225).abs() < 0.001); // Mid transonic
1312        assert!((calculate_magnus_moment_coefficient(1.2) - 0.015).abs() < 0.001); // End of transonic
1313        assert!((calculate_magnus_moment_coefficient(2.0) - 0.01653).abs() < 0.001);
1314        // Supersonic
1315    }
1316
1317    /// MBA-1356: cd_scale — this module's custom-deck interpolation site.
1318    fn deck_test_inputs(cd_scale: f64) -> BallisticInputs {
1319        BallisticInputs {
1320            custom_drag_table: Some(crate::drag::DragTable::new(
1321                vec![0.5, 1.0, 2.0, 3.0],
1322                vec![0.23, 0.40, 0.30, 0.26],
1323            )),
1324            cd_scale,
1325            ..create_test_inputs()
1326        }
1327    }
1328
1329    fn deck_accel_x(cd_scale: f64) -> f64 {
1330        let inputs = deck_test_inputs(cd_scale);
1331        compute_derivatives(
1332            Vector3::zeros(),
1333            Vector3::new(700.0, 0.0, 0.0),
1334            &inputs,
1335            Vector3::zeros(),
1336            (1.225, 340.0, 0.0, 0.0),
1337            inputs.bc_value,
1338            None,
1339            0.0,
1340            None,
1341        )[3]
1342    }
1343
1344    #[test]
1345    fn cd_scale_default_is_one_and_absent_matches_explicit() {
1346        assert_eq!(BallisticInputs::default().cd_scale, 1.0);
1347
1348        let omitted = BallisticInputs {
1349            custom_drag_table: Some(crate::drag::DragTable::new(
1350                vec![0.5, 1.0, 2.0, 3.0],
1351                vec![0.23, 0.40, 0.30, 0.26],
1352            )),
1353            ..create_test_inputs()
1354        };
1355        assert_eq!(omitted.cd_scale, 1.0);
1356
1357        let a_omitted = compute_derivatives(
1358            Vector3::zeros(),
1359            Vector3::new(700.0, 0.0, 0.0),
1360            &omitted,
1361            Vector3::zeros(),
1362            (1.225, 340.0, 0.0, 0.0),
1363            omitted.bc_value,
1364            None,
1365            0.0,
1366            None,
1367        );
1368        let a_explicit = deck_accel_x(1.0);
1369        assert_eq!(
1370            a_omitted[3].to_bits(),
1371            a_explicit.to_bits(),
1372            "omitted cd_scale (Default) must be bit-identical to an explicit 1.0"
1373        );
1374    }
1375
1376    /// (b) Scale-direction test, derivatives-driven path: cd_scale=1.10 must add more drag
1377    /// (a more negative x-acceleration) than 1.0; 0.90 must add less.
1378    #[test]
1379    fn cd_scale_direction_on_derivatives_kernel() {
1380        let baseline = deck_accel_x(1.0);
1381        let scaled_up = deck_accel_x(1.10);
1382        let scaled_down = deck_accel_x(0.90);
1383
1384        assert!(
1385            scaled_up < baseline,
1386            "cd_scale=1.10 must increase drag deceleration (more negative ax): \
1387             base={baseline} up={scaled_up}"
1388        );
1389        assert!(
1390            scaled_down > baseline,
1391            "cd_scale=0.90 must decrease drag deceleration (less negative ax): \
1392             base={baseline} down={scaled_down}"
1393        );
1394    }
1395
1396    /// cd_scale must be inert on the standard G-model/BC path (no custom_drag_table).
1397    #[test]
1398    fn cd_scale_is_inert_without_a_custom_drag_table() {
1399        let make = |cd_scale: f64| BallisticInputs {
1400            cd_scale,
1401            ..create_test_inputs()
1402        };
1403        let accel = |cd_scale: f64| {
1404            let inputs = make(cd_scale);
1405            compute_derivatives(
1406                Vector3::zeros(),
1407                Vector3::new(700.0, 0.0, 0.0),
1408                &inputs,
1409                Vector3::zeros(),
1410                (1.225, 340.0, 0.0, 0.0),
1411                inputs.bc_value,
1412                None,
1413                0.0,
1414                None,
1415            )[3]
1416        };
1417        assert_eq!(
1418            accel(1.0).to_bits(),
1419            accel(1.5).to_bits(),
1420            "cd_scale must not affect the G-model/BC drag path"
1421        );
1422    }
1423}