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        // MBA-1227: quadratic yaw drag is ADDITIVE per McCoy: CD = CD0 + CD_delta2*delta^2.
329        // The previous multiplicative form Cd*(1 + delta^2) implied CD_delta2 == CD0
330        // (~0.3 per rad^2), an order of magnitude below literature (~4-20 per rad^2 for
331        // spitzer rifle bullets). The additive term is expressed in retardation space
332        // (actual-Cd over sectional density), which is denominator-correct for BOTH the
333        // G-model path (drag_factor/BC == Cd_actual/SD) and the custom-table path
334        // (drag_factor/SD). Computed after the (drag_factor, retard_denom) resolution below.
335
336        // Calculate density scaling
337        let density_scale = air_density / STANDARD_AIR_DENSITY;
338
339        // Apply the transonic drag-rise correction exactly ONCE. The base Cd above is taken
340        // WITHOUT transonic correction (apply_transonic_correction=false), so this is the only
341        // application. Previously the correction was applied here AND inside
342        // get_drag_coefficient_full, which squared the drag-rise factor and double-counted wave
343        // drag across the transonic band (Cd ~3x too high near Mach 1). transonic_correction
344        // self-gates via the projectile's critical Mach (returns the input unchanged outside the
345        // band), and include_wave_drag=false matches cli_api::calculate_drag_coefficient — the
346        // G1/G7 tables already embed the transonic rise, so additive wave drag would double-count.
347        // Use the same SI fallbacks as the get_drag_coefficient_full call above (and
348        // fast_trajectory): an SI-only caller may leave caliber_inches/weight_grains at 0, so
349        // derive them from the SI bullet_diameter/bullet_mass rather than feeding zeros into
350        // get_projectile_shape (which would mis-classify the shape via weight/caliber).
351        let caliber_in = if inputs.caliber_inches > 0.0 {
352            inputs.caliber_inches
353        } else {
354            inputs.bullet_diameter / 0.0254 // meters -> inches
355        };
356        let weight_gr = if inputs.weight_grains > 0.0 {
357            inputs.weight_grains
358        } else {
359            inputs.bullet_mass / crate::constants::GRAINS_TO_KG // kg -> grains
360        };
361        // MBA-949: shared resolver so named bullet_model shapes are honored here too (this path
362        // previously used only the caliber/weight heuristic and ignored the name).
363        let shape = crate::transonic_drag::resolve_projectile_shape(
364            inputs.bullet_model.as_deref(),
365            caliber_in,
366            weight_gr,
367            &inputs.bc_type.to_string(),
368        );
369        let drag_factor =
370            crate::transonic_drag::transonic_correction(mach, drag_factor, shape, false);
371
372        // MBA-945: the low-velocity Reynolds drag correction was applied ONLY here (not in cli_api
373        // or fast_trajectory), so subsonic shots diverged across the three solver families. Removed
374        // for consistency — py_ballisticcalc (the validation reference) does not model it, and
375        // cli_api already matches pbc subsonically without it (validated to 1300yd in MBA-939). The
376        // reynolds module and get_drag_coefficient_full's apply_reynolds flag remain available for a
377        // future opt-in wired across all three solvers.
378
379        // MBA-940: a user-supplied custom drag table overrides the G-model Cd entirely and is used
380        // as-is — no reference-table transonic correction or name-derived form-factor multiplier
381        // is applied to it (the curve already encodes the projectile's true drag, so applying one
382        // would distort/double-count it).
383        // The custom table's Cd is the projectile's ACTUAL drag coefficient, so the
384        // retardation denominator must be the sectional density (lb/in²), not a BC:
385        // Cd_own / SD == Cd_ref / BC (see BallisticInputs::custom_drag_denominator).
386        let (drag_factor, retard_denom) = match inputs.custom_drag_table {
387            Some(ref table) => (
388                // MBA-1357: cd_scale is a single whole-curve drag multiplier applied here, at
389                // the Cd lookup site. The Mach-keyed DSF table (truing_dsf.rs) is a SEPARATE,
390                // drop-only post-processing correction applied to a solved TrajectoryResult's
391                // points after integration finishes — it never touches this drag computation.
392                table.interpolate(mach) * inputs.cd_scale,
393                inputs.custom_drag_denominator(bc_val),
394            ),
395            None => (drag_factor, bc_val),
396        };
397
398        // Calculate drag acceleration
399        let standard_factor = drag_factor * CD_TO_RETARD;
400        let mut a_drag_ft_s2 =
401            (v_rel_fps.powi(2) * standard_factor * density_scale) / retard_denom;
402        // MBA-1227 additive yaw-drag term (see comment above). Skipped entirely when
403        // tip-off yaw is zero (the default), leaving baseline trajectories bit-identical.
404        // When sectional density is unavailable (degenerate SI-less inputs) the term is
405        // dropped rather than mis-scaled through an unrelated denominator.
406        if yaw_rad != 0.0 {
407            if let Some(sd) = inputs.sectional_density_lb_in2() {
408                if sd > 0.0 {
409                    a_drag_ft_s2 += v_rel_fps.powi(2)
410                        * CD_TO_RETARD
411                        * density_scale
412                        * (inputs.cd_delta2 * yaw_rad.powi(2) / sd);
413                }
414            }
415        }
416        let a_drag_m_s2 = a_drag_ft_s2 * FPS_TO_MPS;
417
418        // Apply drag in opposite direction of relative velocity
419        accel_drag = -a_drag_m_s2 * (velocity_adjusted / speed_air);
420
421        // Magnus Effect calculation. Gated on enable_magnus specifically so it is
422        // independent of Coriolis (matches the cli_api solver's decoupled flags).
423        // MBA-1134 (rank 35): when the canonical empirical Litz spin-drift post-process is active
424        // (use_enhanced_spin_drift + advanced effects — the same condition that drove the now-
425        // deprecated in-integration spin-drift term), it already captures the gyroscopic/yaw-of-
426        // repose lateral. The explicit Magnus side force must NOT be added on top or the two
427        // lateral models stack and double-count the drift, so suppress Magnus in that case.
428        if inputs.enable_magnus
429            && !(inputs.use_enhanced_spin_drift && inputs.enable_advanced_effects)
430            && inputs.bullet_diameter > 0.0
431            && inputs.twist_rate > 0.0
432        {
433            let diameter_m = inputs.bullet_diameter;
434            let (spin_rate_rad_s, spin_param) = crate::spin_drift::calculate_magnus_spin_state(
435                inputs.muzzle_velocity,
436                speed_air,
437                inputs.twist_rate,
438                diameter_m,
439            );
440
441            let c_np = calculate_magnus_moment_coefficient(mach);
442
443            // Calculate reference area
444            let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
445
446            // Yaw of repose for the proper Magnus force. Stability/yaw helpers are
447            // imperial: use the explicit imperial mirror fields, and convert the SI
448            // bullet_length to inches at this boundary.
449            let d_in = inputs.caliber_inches;
450            let m_gr = inputs.weight_grains;
451            let l_in = if inputs.bullet_length > 0.0 {
452                inputs.bullet_length / 0.0254 // meters -> inches
453            } else {
454                // MBA-1135: mass-based length estimate (was a mass-blind 4.5-caliber default).
455                let est_m = crate::stability::estimate_bullet_length_m(
456                    inputs.bullet_diameter,
457                    inputs.bullet_mass,
458                );
459                if est_m > 0.0 {
460                    est_m / 0.0254
461                } else {
462                    4.5 * d_in.max(1e-9)
463                }
464            };
465            // Use current-flight Sg with the muzzle-set spin. Back-calculating the effective
466            // twist from fixed spin and current airspeed lets gyroscopic stability (and therefore
467            // yaw of repose) grow as translational velocity decays; local density supplies the
468            // canonical Miller atmospheric correction.
469            let sg = crate::spin_drift::calculate_dynamic_stability(
470                m_gr,
471                speed_air,
472                spin_rate_rad_s,
473                d_in,
474                l_in,
475                air_density,
476            );
477            let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
478                sg,
479                speed_air,
480                spin_rate_rad_s,
481                0.0,
482                0.0,
483                air_density,
484                d_in,
485                l_in,
486                m_gr,
487                mach,
488                "match",
489                false,
490            );
491
492            // Proper McCoy Magnus FORCE: F = q S C_Npa (pd/2V) sin(alpha_R).
493            let magnus_force_magnitude =
494                0.5 * air_density * speed_air.powi(2) * area * c_np * spin_param * yaw_rad.sin();
495
496            // A lateral yaw of repose produces a vertical Magnus force. Its lateral force is
497            // aerodynamic lift, represented separately by the Litz spin-drift model.
498            if magnus_force_magnitude > 1e-12 {
499                if let Some(magnus_direction) = yaw_of_repose_magnus_direction(
500                    velocity_adjusted,
501                    accel_gravity,
502                    inputs.is_twist_right,
503                ) {
504                    let bullet_mass_kg = inputs.bullet_mass; // already kg (SI)
505                    accel_magnus = (magnus_force_magnitude / bullet_mass_kg) * magnus_direction;
506                }
507            }
508        }
509    }
510
511    // Total acceleration
512    let mut accel = accel_gravity + accel_drag + accel_magnus;
513
514    // Add Coriolis acceleration if a level-frame omega vector is provided. The physical term is
515    // -2 Ω×v (MBA-957: the old +2 "frame-relabel" justification was wrong — it flipped the
516    // lateral drift; the caller now builds omega with the corrected lateral sign, matching the
517    // validated cli_api solver, so the canonical -2 applies directly).
518    if let Some(omega) = omega_vector {
519        let omega = level_vector_to_shot_frame(omega, theta);
520        let accel_coriolis = -2.0 * omega.cross(&vel);
521        accel += accel_coriolis;
522    }
523
524    // MBA-1134 (rank 10): the in-integration enhanced-spin-drift ACCELERATION term
525    // (spin_drift::calculate_enhanced_spin_drift / apply_enhanced_spin_drift) is DEPRECATED and is
526    // no longer applied here. It carried a unit bug and was a SECOND, inconsistent lateral model.
527    // Spin drift is now the single canonical empirical Litz post-process
528    // (spin_drift::litz_drift_meters), applied by cli_api::apply_spin_drift and the fast /
529    // Monte-Carlo path (fast_trajectory + monte_carlo) at the endpoint time-of-flight. Keeping only
530    // one model here guarantees the three solver families agree on lateral drift and prevents the
531    // Magnus + spin-drift double-count (see the Magnus gate above). The `calculate_enhanced_spin_drift`
532    // / `apply_enhanced_spin_drift` helpers remain in spin_drift.rs for backward compatibility but
533    // are no longer wired into any integration path.
534
535    // Return state derivatives: [velocity, acceleration]
536    [vel[0], vel[1], vel[2], accel[0], accel[1], accel[2]]
537}
538
539/// Interpolate a ballistic coefficient from Mach-keyed segments.
540///
541/// An empty optional table is not a request to invent a new BC: when `inputs` are present, the
542/// caller's scalar `bc_value` is preserved. Standalone calls without inputs retain the historical
543/// conservative fallback because no caller BC is available.
544pub fn interpolated_bc(
545    mach: f64,
546    segments: &[(f64, f64)],
547    inputs: Option<&BallisticInputs>,
548) -> f64 {
549    if segments.is_empty() {
550        if let Some(inputs) = inputs {
551            return inputs.bc_value;
552        }
553        return crate::constants::BC_FALLBACK_CONSERVATIVE;
554    }
555
556    if segments.len() == 1 {
557        return segments[0].1;
558    }
559
560    // Ensure ascending-Mach order for interpolation. Fast path: when the segments are
561    // already sorted (the common case — they are normalized once at construction), borrow
562    // them and skip the per-call heap alloc + O(n log n) sort on the integration hot path.
563    let sorted_segments: std::borrow::Cow<[(f64, f64)]> =
564        if segments.windows(2).all(|w| w[0].0 <= w[1].0) {
565            std::borrow::Cow::Borrowed(segments)
566        } else {
567            let mut v = segments.to_vec();
568            v.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
569            std::borrow::Cow::Owned(v)
570        };
571
572    // Handle out-of-range cases first
573    if mach <= sorted_segments[0].0 {
574        return sorted_segments[0].1;
575    }
576    if mach >= sorted_segments[sorted_segments.len() - 1].0 {
577        return sorted_segments[sorted_segments.len() - 1].1;
578    }
579
580    // Find the appropriate segment using binary search
581    let idx = sorted_segments.partition_point(|(m, _)| *m <= mach);
582    if idx == 0 || idx >= sorted_segments.len() {
583        // Should not happen given the checks above
584        return sorted_segments[0].1;
585    }
586
587    let (mach1, bc1) = sorted_segments[idx - 1];
588    let (mach2, bc2) = sorted_segments[idx];
589
590    // Linear interpolation with safe division
591    let denominator = mach2 - mach1;
592    if denominator.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
593        return bc1; // Return first BC value if Mach values are identical
594    }
595    let t = (mach - mach1) / denominator;
596    bc1 + t * (bc2 - bc1)
597}
598
599/// Get BC value for current velocity, supporting velocity-based BC segments
600fn get_bc_for_velocity(velocity_fps: f64, inputs: &BallisticInputs, bc_used: f64) -> f64 {
601    // Check if velocity-based BC segments are enabled
602    if !inputs.use_bc_segments {
603        return bc_used;
604    }
605
606    // Try direct BC segments data first
607    if let Some(bc_segments_data) = inputs
608        .bc_segments_data
609        .as_ref()
610        .filter(|segments| !segments.is_empty())
611    {
612        // An explicit table is authoritative even when this velocity lies in a coverage gap.
613        // Do not silently replace it with an auto-estimated table after a miss.
614        return velocity_segment_bc(velocity_fps, bc_segments_data, bc_used);
615    }
616
617    // Try BC estimation if we have bullet details but no segments. MBA-955: the estimation is
618    // factored into estimate_bc_segments_for so the per-integration setup (build_inputs) can
619    // pre-populate bc_segments_data ONCE rather than rebuilding it here every step.
620    if let Some(segments) = estimate_bc_segments_for(inputs, bc_used) {
621        return velocity_segment_bc(velocity_fps, &segments, bc_used);
622    }
623
624    // Fallback to constant BC
625    bc_used
626}
627
628/// Estimate velocity-BC segments from bullet characteristics (MBA-955). Extracted from
629/// get_bc_for_velocity's slow path so the per-integration setup can compute the segments ONCE
630/// (build_inputs pre-populates bc_segments_data) instead of rebuilding them — allocating a model
631/// String and a segment Vec — on every derivative evaluation. Returns None when the bullet
632/// details needed for estimation are absent (the caller then falls back to the constant BC). The
633/// logic is byte-identical to the previous inline slow path.
634pub(crate) fn estimate_bc_segments_for(
635    inputs: &BallisticInputs,
636    bc_used: f64,
637) -> Option<Vec<crate::BCSegmentData>> {
638    if !(inputs.bullet_diameter > 0.0 && inputs.bullet_mass > 0.0 && bc_used > 0.0) {
639        return None;
640    }
641    // Model string from bullet_id or a generic weight-based description (unchanged).
642    let model = if let Some(ref bullet_id) = inputs.bullet_id {
643        bullet_id.clone()
644    } else {
645        format!("{}gr bullet", inputs.weight_grains as i32)
646    };
647    // Prefer the legacy explicit string when present, but otherwise preserve the
648    // typed drag model carried by every current BallisticInputs constructor.
649    let bc_type_str = inputs.bc_type_str.as_deref().unwrap_or(match inputs.bc_type {
650        crate::DragModel::G7 => "G7",
651        _ => "G1",
652    });
653    Some(BCSegmentEstimator::estimate_bc_segments(
654        bc_used,
655        inputs.caliber_inches,
656        inputs.weight_grains,
657        &model,
658        bc_type_str,
659    ))
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665
666    fn expected_shot_frame_vector(level: Vector3<f64>, angle: f64) -> Vector3<f64> {
667        let (sin_angle, cos_angle) = angle.sin_cos();
668        Vector3::new(
669            level.x * cos_angle + level.y * sin_angle,
670            -level.x * sin_angle + level.y * cos_angle,
671            level.z,
672        )
673    }
674
675    #[test]
676    fn level_vector_projection_is_bit_exact_at_zero_incline() {
677        let level = Vector3::new(12.5, -0.0, -7.25);
678        let projected = level_vector_to_shot_frame(level, 0.0);
679
680        for component in 0..3 {
681            assert_eq!(projected[component].to_bits(), level[component].to_bits());
682        }
683    }
684
685    #[test]
686    fn dry_air_sound_speed_round_trips_to_local_celsius() {
687        for expected_temp_c in [-40.0_f64, 15.0, 40.0] {
688            let sound_speed = (1.4 * 287.05 * (expected_temp_c + 273.15)).sqrt();
689            let actual_temp_c = dry_air_temperature_c_from_sound_speed(sound_speed);
690            assert!(
691                (actual_temp_c - expected_temp_c).abs() < 1e-10,
692                "sound speed {sound_speed} m/s recovered {actual_temp_c} C, expected {expected_temp_c} C"
693            );
694        }
695
696        let direct_mode_temp_c = dry_air_temperature_c_from_sound_speed(340.0);
697        assert!(
698            direct_mode_temp_c > 10.0 && direct_mode_temp_c < 20.0,
699            "direct-mode 340 m/s must be interpreted as sound speed, not 340 C"
700        );
701    }
702
703    #[test]
704    fn tipoff_yaw_uses_documented_radians_for_drag_and_decay() {
705        let mut baseline_inputs = create_test_inputs();
706        baseline_inputs.tipoff_yaw = 0.0;
707        baseline_inputs.tipoff_decay_distance = 50.0;
708        let mut yawed_inputs = baseline_inputs.clone();
709        yawed_inputs.tipoff_yaw = 0.1; // radians, per the field docs
710
711        let drag_x = |inputs: &BallisticInputs, downrange_m: f64| {
712            compute_derivatives(
713                Vector3::new(downrange_m, 0.0, 0.0),
714                Vector3::new(300.0, 0.0, 0.0),
715                inputs,
716                Vector3::zeros(),
717                (1.225, 340.0, 0.0, 0.0),
718                0.5,
719                None,
720                0.0,
721                None,
722            )[3]
723        };
724
725        // MBA-1227: yaw drag is ADDITIVE per McCoy (CD = CD0 + CD_delta2 * delta^2), so the
726        // extra retardation has a closed form we can assert exactly:
727        //   extra_mps2 = v_fps^2 * CD_TO_RETARD * density_scale * CD_delta2 * delta_eff^2 / SD
728        // with delta_eff = tipoff_yaw * exp(-x / decay). At x = decay*ln(2) the effective yaw
729        // halves, so the extra drag falls to a quarter — which also proves the documented
730        // RADIAN interpretation and the decay wiring in one pass.
731        let sd = baseline_inputs
732            .sectional_density_lb_in2()
733            .expect("test inputs carry mass/diameter");
734        let v_fps = 300.0 * MPS_TO_FPS; // same (rounded) constant the kernel uses
735        let density_scale = 1.225 / STANDARD_AIR_DENSITY;
736
737        for (downrange_m, decay_scale) in [(0.0_f64, 1.0_f64), (50.0 * std::f64::consts::LN_2, 0.5)]
738        {
739            let delta_eff = 0.1 * decay_scale;
740            let expected_extra_mps2 = v_fps.powi(2)
741                * CD_TO_RETARD
742                * density_scale
743                * (yawed_inputs.cd_delta2 * delta_eff.powi(2) / sd)
744                * FPS_TO_MPS;
745
746            let baseline_drag = drag_x(&baseline_inputs, downrange_m);
747            let yawed_drag = drag_x(&yawed_inputs, downrange_m);
748            assert!(baseline_drag < 0.0, "baseline must be downrange drag");
749            let measured_extra = baseline_drag - yawed_drag; // yawed is MORE negative
750
751            assert!(
752                (measured_extra - expected_extra_mps2).abs() <= 1e-9 * expected_extra_mps2,
753                "additive yaw drag at x={downrange_m} m: measured extra {measured_extra:.9} \
754                 != expected {expected_extra_mps2:.9}"
755            );
756        }
757    }
758
759    fn create_test_inputs() -> BallisticInputs {
760        // SI-canonical geometry/mass (kg, meters) — same convention as the struct
761        // docs and cli_api — plus the explicit imperial mirror fields
762        // (caliber_inches/weight_grains) the stability/Magnus helpers read.
763        BallisticInputs {
764            muzzle_velocity: 800.0, // m/s
765            bc_value: 0.5,
766            bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG, // kg (168 gr)
767            bullet_diameter: 0.308 * 0.0254,    // meters (.308 in)
768            bullet_length: 1.215 * 0.0254,      // meters
769            caliber_inches: 0.308,
770            weight_grains: 168.0,
771            altitude: 1000.0,
772            ..Default::default()
773        }
774    }
775
776    #[test]
777    fn measured_bc_drag_ignores_name_based_form_factor_flag() {
778        let derivatives_with_flag = |use_form_factor| {
779            let inputs = BallisticInputs {
780                bc_value: 0.462,
781                bc_type: crate::DragModel::G1,
782                bullet_model: Some("168gr SMK Match".to_string()),
783                use_form_factor,
784                ..create_test_inputs()
785            };
786
787            compute_derivatives(
788                Vector3::zeros(),
789                Vector3::new(600.0, 0.0, 0.0),
790                &inputs,
791                Vector3::zeros(),
792                (1.225, 340.0, 0.0, 0.0),
793                inputs.bc_value,
794                None,
795                0.0,
796                None,
797            )
798        };
799
800        let baseline = derivatives_with_flag(false);
801        let flagged = derivatives_with_flag(true);
802
803        for component in 3..6 {
804            assert_eq!(
805                flagged[component].to_bits(),
806                baseline[component].to_bits(),
807                "published BC already encodes form factor: component {component}, baseline={} flagged={}",
808                baseline[component],
809                flagged[component]
810            );
811        }
812    }
813
814    #[test]
815    fn inclined_positions_at_same_world_altitude_have_same_atmospheric_acceleration() {
816        let angle = std::f64::consts::FRAC_PI_6;
817        let mut inputs = create_test_inputs();
818        inputs.altitude = 100.0;
819        inputs.shooting_angle = angle;
820        let velocity = Vector3::new(600.0, 0.0, 0.0);
821        let along_slant = Vector3::new(1_000.0, 0.0, 0.0);
822        let across_slant = Vector3::new(0.0, 500.0 / angle.cos(), 0.0);
823        let atmo = (inputs.altitude, 15.0, 1013.25, 1.0);
824
825        let a = compute_derivatives(
826            along_slant,
827            velocity,
828            &inputs,
829            Vector3::zeros(),
830            atmo,
831            inputs.bc_value,
832            None,
833            0.0,
834            None,
835        );
836        let b = compute_derivatives(
837            across_slant,
838            velocity,
839            &inputs,
840            Vector3::zeros(),
841            atmo,
842            inputs.bc_value,
843            None,
844            0.0,
845            None,
846        );
847
848        for component in 3..6 {
849            assert!(
850                (a[component] - b[component]).abs() < 1e-10,
851                "derivative component {component} differs at equal world altitude: {} vs {}",
852                a[component],
853                b[component]
854            );
855        }
856    }
857
858    #[test]
859    fn inclined_headwind_is_rotated_into_solver_frame() {
860        let angle = std::f64::consts::FRAC_PI_6;
861        let mut inputs = create_test_inputs();
862        inputs.shooting_angle = angle;
863        let level_headwind = Vector3::new(-100.0, 0.0, 0.0);
864        let velocity = expected_shot_frame_vector(level_headwind, angle);
865        let actual = compute_derivatives(
866            Vector3::zeros(),
867            velocity,
868            &inputs,
869            level_headwind,
870            (1.225, 340.0, 0.0, 0.0),
871            inputs.bc_value,
872            None,
873            0.0,
874            None,
875        );
876        let expected = Vector3::new(
877            -G_ACCEL_MPS2 * angle.sin(),
878            -G_ACCEL_MPS2 * angle.cos(),
879            0.0,
880        );
881
882        assert!(
883            (Vector3::new(actual[3], actual[4], actual[5]) - expected).norm() < 1e-12,
884            "co-moving horizontal wind must leave only shot-frame gravity: {actual:?}"
885        );
886    }
887
888    #[test]
889    fn inclined_coriolis_is_rotated_into_solver_frame() {
890        let angle = std::f64::consts::FRAC_PI_6;
891        let mut inputs = create_test_inputs();
892        inputs.shooting_angle = angle;
893        let velocity = Vector3::new(600.0, 20.0, 5.0);
894        let level_omega = Vector3::new(3.0e-5, 6.0e-5, -2.0e-5);
895        let run = |omega| {
896            compute_derivatives(
897                Vector3::zeros(),
898                velocity,
899                &inputs,
900                Vector3::zeros(),
901                (1.225, 340.0, 0.0, 0.0),
902                inputs.bc_value,
903                omega,
904                0.0,
905                None,
906            )
907        };
908        let baseline = run(None);
909        let with_coriolis = run(Some(level_omega));
910        let actual = Vector3::new(
911            with_coriolis[3] - baseline[3],
912            with_coriolis[4] - baseline[4],
913            with_coriolis[5] - baseline[5],
914        );
915        let expected = -2.0 * expected_shot_frame_vector(level_omega, angle).cross(&velocity);
916
917        assert!(
918            (actual - expected).norm() < 1e-12,
919            "inclined Coriolis mismatch: actual={actual:?}, expected={expected:?}"
920        );
921    }
922
923    #[test]
924    fn explicit_velocity_segment_gap_uses_scalar_bc_without_auto_estimation() {
925        let mut inputs = create_test_inputs();
926        inputs.bc_value = 0.91;
927        inputs.use_bc_segments = true;
928        inputs.bc_segments_data = Some(vec![
929            crate::BCSegmentData {
930                velocity_min: 0.0,
931                velocity_max: 999.0,
932                bc_value: 0.2,
933            },
934            crate::BCSegmentData {
935                velocity_min: 1000.0,
936                velocity_max: 2000.0,
937                bc_value: 0.3,
938            },
939        ]);
940
941        assert_eq!(
942            get_bc_for_velocity(999.5, &inputs, inputs.bc_value),
943            inputs.bc_value,
944            "an explicit table gap must not be replaced by an auto-estimated segment"
945        );
946    }
947
948    #[test]
949    fn test_mba955_bc_segments_prepopulate_byte_identical() {
950        // MBA-955: pre-populating bc_segments_data once (in build_inputs) must return
951        // BYTE-IDENTICAL BC to the old per-step estimation. Build the slow-path inputs
952        // (bc_segments_data = None -> get_bc_for_velocity estimates every call) and the
953        // pre-populated inputs (bc_segments_data = estimate_bc_segments_for, the same helper
954        // build_inputs now calls), and assert get_bc_for_velocity agrees bit-for-bit across the
955        // whole velocity range.
956        let mut slow = create_test_inputs();
957        slow.use_bc_segments = true;
958        slow.bc_segments_data = None;
959        slow.bc_segments = None;
960
961        let bc_used = slow.bc_value;
962        let mut fast = slow.clone();
963        fast.bc_segments_data = estimate_bc_segments_for(&fast, bc_used);
964        assert!(
965            fast.bc_segments_data.is_some(),
966            "estimation should yield segments for a valid bullet"
967        );
968
969        for v in (200..=3500).step_by(50) {
970            let vf = v as f64;
971            let a = get_bc_for_velocity(vf, &slow, bc_used);
972            let b = get_bc_for_velocity(vf, &fast, bc_used);
973            assert_eq!(
974                a.to_bits(),
975                b.to_bits(),
976                "BC differs at {vf} fps: slow={a} fast={b}"
977            );
978        }
979    }
980
981    #[test]
982    fn estimated_segments_inherit_typed_g7_drag_model() {
983        let mut inputs = create_test_inputs();
984        inputs.bc_value = 0.243;
985        inputs.bc_type = crate::DragModel::G7;
986        inputs.bc_type_str = None;
987        inputs.bullet_mass = 175.0 * crate::constants::GRAINS_TO_KG;
988        inputs.weight_grains = 175.0;
989
990        let actual = estimate_bc_segments_for(&inputs, inputs.bc_value).unwrap();
991        let expected = BCSegmentEstimator::estimate_bc_segments(
992            inputs.bc_value,
993            inputs.caliber_inches,
994            inputs.weight_grains,
995            "175gr bullet",
996            "G7",
997        );
998
999        assert_eq!(actual.len(), expected.len());
1000        for (actual, expected) in actual.iter().zip(&expected) {
1001            assert_eq!(actual.velocity_min.to_bits(), expected.velocity_min.to_bits());
1002            assert_eq!(actual.velocity_max.to_bits(), expected.velocity_max.to_bits());
1003            assert_eq!(actual.bc_value.to_bits(), expected.bc_value.to_bits());
1004        }
1005
1006        // An explicitly populated legacy string remains authoritative.
1007        inputs.bc_type_str = Some("G1".to_string());
1008        let legacy = estimate_bc_segments_for(&inputs, inputs.bc_value).unwrap();
1009        let expected_g1 = BCSegmentEstimator::estimate_bc_segments(
1010            inputs.bc_value,
1011            inputs.caliber_inches,
1012            inputs.weight_grains,
1013            "175gr bullet",
1014            "G1",
1015        );
1016        assert_eq!(legacy.len(), expected_g1.len());
1017        for (legacy, expected) in legacy.iter().zip(&expected_g1) {
1018            assert_eq!(legacy.bc_value.to_bits(), expected.bc_value.to_bits());
1019        }
1020    }
1021
1022    #[test]
1023    fn test_compute_derivatives_basic() {
1024        let pos = Vector3::new(0.0, 0.0, 0.0);
1025        let vel = Vector3::new(800.0, 0.0, 0.0);
1026        let inputs = create_test_inputs();
1027        let wind_vector = Vector3::zeros();
1028        // Use direct atmosphere values: (air_density, speed_of_sound, 0.0, 0.0)
1029        let atmos_params = (1.225, 340.0, 0.0, 0.0); // Standard air density and speed of sound
1030        let bc_used = 0.5;
1031
1032        let result = compute_derivatives(
1033            pos,
1034            vel,
1035            &inputs,
1036            wind_vector,
1037            atmos_params,
1038            bc_used,
1039            None,
1040            0.0,
1041            None,
1042        );
1043
1044        // Check that we get velocity and acceleration components
1045        assert_eq!(result.len(), 6);
1046
1047        // Velocity components should match input velocity
1048        assert!((result[0] - vel[0]).abs() < 1e-10);
1049        assert!((result[1] - vel[1]).abs() < 1e-10);
1050        assert!((result[2] - vel[2]).abs() < 1e-10);
1051
1052        // Should have gravitational acceleration
1053        assert!(result[4] < 0.0); // Negative y acceleration due to gravity
1054
1055        // Should have drag acceleration opposing motion
1056        assert!(result[3] < 0.0); // Negative x acceleration due to drag
1057    }
1058
1059    #[test]
1060    fn standard_atmosphere_zero_ratio_uses_sea_level_fallback() {
1061        let pos = Vector3::new(0.0, 0.0, 0.0);
1062        let vel = Vector3::new(800.0, 0.0, 0.0);
1063        let inputs = create_test_inputs();
1064        let run = |base_ratio| {
1065            compute_derivatives(
1066                pos,
1067                vel,
1068                &inputs,
1069                Vector3::zeros(),
1070                (inputs.altitude, 15.0, 1013.25, base_ratio),
1071                inputs.bc_value,
1072                None,
1073                0.0,
1074                None,
1075            )
1076        };
1077
1078        let missing_ratio = run(0.0);
1079        let explicit_sea_level = run(1.0);
1080        assert!(
1081            missing_ratio[3] < 0.0,
1082            "missing standard density ratio must not produce vacuum drag"
1083        );
1084        assert!((missing_ratio[3] - explicit_sea_level[3]).abs() < 1e-12);
1085    }
1086
1087    #[test]
1088    fn test_compute_derivatives_with_wind() {
1089        let pos = Vector3::new(0.0, 0.0, 0.0);
1090        let vel = Vector3::new(800.0, 0.0, 0.0);
1091        let inputs = create_test_inputs();
1092        let wind_vector = Vector3::new(10.0, 0.0, 0.0); // Tailwind
1093        let atmos_params = (1.225, 340.0, 0.0, 0.0); // Standard air density and speed of sound
1094        let bc_used = 0.5;
1095
1096        let result = compute_derivatives(
1097            pos,
1098            vel,
1099            &inputs,
1100            wind_vector,
1101            atmos_params,
1102            bc_used,
1103            None,
1104            0.0,
1105            None,
1106        );
1107
1108        // With tailwind, effective velocity should be lower, thus less drag
1109        // Just check that we have some drag (negative acceleration)
1110        assert!(result[3] < 0.0); // Should have drag
1111    }
1112
1113    #[test]
1114    fn test_compute_derivatives_with_coriolis() {
1115        let pos = Vector3::new(0.0, 0.0, 0.0);
1116        let vel = Vector3::new(800.0, 0.0, 0.0);
1117        let inputs = create_test_inputs();
1118        let wind_vector = Vector3::zeros();
1119        let atmos_params = (1.225, 340.0, 0.0, 0.0); // Standard air density and speed of sound
1120        let bc_used = 0.5;
1121        let omega = Vector3::new(0.0, 0.0, 7.2921e-5); // Earth's rotation
1122
1123        let result = compute_derivatives(
1124            pos,
1125            vel,
1126            &inputs,
1127            wind_vector,
1128            atmos_params,
1129            bc_used,
1130            Some(omega),
1131            0.0,
1132            None,
1133        );
1134
1135        // Should have Coriolis effect
1136        assert!(result[4].abs() > 1e-3); // Should have some y-component from Coriolis
1137    }
1138
1139    #[test]
1140    fn test_interpolated_bc() {
1141        let segments = vec![(0.5, 0.4), (1.0, 0.5), (1.5, 0.6), (2.0, 0.5)];
1142
1143        // Test exact matches
1144        assert!((interpolated_bc(1.0, &segments, None) - 0.5).abs() < 1e-10);
1145
1146        // Test interpolation
1147        let bc_075 = interpolated_bc(0.75, &segments, None);
1148        assert!(bc_075 > 0.4 && bc_075 < 0.5);
1149
1150        // Test out of range
1151        assert!((interpolated_bc(0.1, &segments, None) - 0.4).abs() < 1e-10);
1152        assert!((interpolated_bc(3.0, &segments, None) - 0.5).abs() < 1e-10);
1153    }
1154
1155    #[test]
1156    fn test_interpolated_bc_edge_cases() {
1157        // Empty segments
1158        assert!(
1159            (interpolated_bc(1.0, &[], None) - crate::constants::BC_FALLBACK_CONSERVATIVE).abs()
1160                < 1e-10
1161        );
1162
1163        let mut inputs = create_test_inputs();
1164        inputs.bc_type = crate::DragModel::G7;
1165        inputs.bc_value = 0.487;
1166        assert_eq!(
1167            interpolated_bc(1.0, &[], Some(&inputs)).to_bits(),
1168            inputs.bc_value.to_bits(),
1169            "an empty optional table must preserve the caller's scalar BC"
1170        );
1171
1172        // Single segment
1173        let single = vec![(1.0, 0.7)];
1174        assert!((interpolated_bc(1.5, &single, None) - 0.7).abs() < 1e-10);
1175    }
1176
1177    #[test]
1178    fn empty_mach_segments_preserve_active_bc_used() {
1179        let mut inputs = create_test_inputs();
1180        inputs.bc_type = crate::DragModel::G7;
1181        inputs.bc_value = 0.123; // Deliberately different from the active fitted/adjusted BC.
1182
1183        let drag_acceleration = |inputs: &BallisticInputs| {
1184            compute_derivatives(
1185                Vector3::zeros(),
1186                Vector3::new(700.0, 0.0, 0.0),
1187                inputs,
1188                Vector3::zeros(),
1189                (1.225, 340.0, 0.0, 0.0),
1190                0.487,
1191                None,
1192                0.0,
1193                None,
1194            )[3]
1195        };
1196
1197        inputs.use_bc_segments = false;
1198        inputs.bc_segments = None;
1199        let no_table = drag_acceleration(&inputs);
1200
1201        for use_bc_segments in [false, true] {
1202            inputs.use_bc_segments = use_bc_segments;
1203            inputs.bc_segments = Some(Vec::new());
1204            let empty_table = drag_acceleration(&inputs);
1205
1206            assert_eq!(
1207                empty_table.to_bits(),
1208                no_table.to_bits(),
1209                "Some(empty) must preserve bc_used when use_bc_segments={use_bc_segments}"
1210            );
1211        }
1212    }
1213
1214    #[test]
1215    fn test_magnus_effect() {
1216        let pos = Vector3::new(0.0, 0.0, 0.0);
1217        let vel = Vector3::new(822.96, 0.0, 0.0); // 2700 fps
1218        let wind_vector = Vector3::zeros();
1219        let atmos_params = (1.225, 340.0, 0.0, 0.0); // Standard air density and speed of sound
1220        let bc_used = 0.5;
1221        let acceleration = |enable_magnus, is_twist_right| {
1222            let mut inputs = create_test_inputs();
1223            inputs.twist_rate = 10.0; // 1:10 twist
1224            inputs.is_twist_right = is_twist_right;
1225            inputs.enable_magnus = enable_magnus; // decoupled from enable_advanced_effects
1226
1227            let result = compute_derivatives(
1228                pos,
1229                vel,
1230                &inputs,
1231                wind_vector,
1232                atmos_params,
1233                bc_used,
1234                None,
1235                0.0,
1236                None,
1237            );
1238            Vector3::new(result[3], result[4], result[5])
1239        };
1240
1241        let baseline = acceleration(false, true);
1242        let right_twist = acceleration(true, true) - baseline;
1243        let left_twist = acceleration(true, false) - baseline;
1244
1245        // Yaw of repose is lateral, so its Magnus force is vertical: down for right-hand twist
1246        // and up for left-hand twist. The lateral force from yaw of repose is lift, represented by
1247        // the separate Litz spin-drift model when that model is enabled.
1248        assert!(
1249            right_twist.y < 0.0,
1250            "right-hand Magnus must point down, got {right_twist:?}"
1251        );
1252        assert!(
1253            left_twist.y > 0.0,
1254            "left-hand Magnus must point up, got {left_twist:?}"
1255        );
1256        assert!((right_twist.y + left_twist.y).abs() < 1e-12);
1257        assert!(right_twist.x.abs() < 1e-12 && right_twist.z.abs() < 1e-12);
1258        assert!(left_twist.x.abs() < 1e-12 && left_twist.z.abs() < 1e-12);
1259        assert!(
1260            right_twist.y.abs() < 0.05,
1261            "Magnus must remain a small force"
1262        );
1263    }
1264
1265    #[test]
1266    fn magnus_uses_velocity_corrected_muzzle_stability_gate() {
1267        let muzzle_velocity = 1_400.0 / MPS_TO_FPS;
1268        let mut inputs = create_test_inputs();
1269        inputs.muzzle_velocity = muzzle_velocity;
1270        inputs.twist_rate = 15.0;
1271        inputs.enable_magnus = true;
1272
1273        let bare_sg = crate::spin_drift::miller_stability(0.308, 168.0, 15.0, 1.215);
1274        let canonical_sg = crate::spin_drift::effective_sg_from_inputs(&inputs, 15.0, 1013.25);
1275        assert!(bare_sg > 1.0, "test requires bare Sg above the Magnus gate");
1276        assert!(
1277            canonical_sg < 1.0,
1278            "velocity-corrected Sg must be below the gate, got {canonical_sg}"
1279        );
1280
1281        let acceleration = |inputs: &BallisticInputs| {
1282            let result = compute_derivatives(
1283                Vector3::zeros(),
1284                Vector3::new(muzzle_velocity, 0.0, 0.0),
1285                inputs,
1286                Vector3::zeros(),
1287                (1.225, 340.0, 0.0, 0.0),
1288                0.5,
1289                None,
1290                0.0,
1291                None,
1292            );
1293            Vector3::new(result[3], result[4], result[5])
1294        };
1295        let enabled = acceleration(&inputs);
1296        inputs.enable_magnus = false;
1297        let disabled = acceleration(&inputs);
1298
1299        assert_eq!(
1300            enabled, disabled,
1301            "canonical Sg below 1 must suppress every Magnus acceleration component"
1302        );
1303    }
1304
1305    #[test]
1306    fn magnus_force_grows_as_fixed_spin_projectile_slows() {
1307        let mut inputs = create_test_inputs();
1308        inputs.muzzle_velocity = 800.0;
1309        inputs.twist_rate = 12.0;
1310        inputs.enable_magnus = true;
1311
1312        let magnus_acceleration = |speed_mps| {
1313            let evaluate = |enable_magnus| {
1314                let mut run_inputs = inputs.clone();
1315                run_inputs.enable_magnus = enable_magnus;
1316                compute_derivatives(
1317                    Vector3::zeros(),
1318                    Vector3::new(speed_mps, 0.0, 0.0),
1319                    &run_inputs,
1320                    Vector3::zeros(),
1321                    (1.225, 340.0, 0.0, 0.0),
1322                    0.5,
1323                    None,
1324                    0.0,
1325                    None,
1326                )[4]
1327            };
1328            (evaluate(true) - evaluate(false)).abs()
1329        };
1330
1331        let fast = magnus_acceleration(200.0);
1332        let slow = magnus_acceleration(100.0);
1333        let ratio = slow / fast;
1334        let expected_ratio = 2.0_f64.powf(5.0 / 3.0);
1335
1336        assert!(fast > 0.0 && slow > 0.0, "fast={fast}, slow={slow}");
1337        assert!(
1338            (ratio - expected_ratio).abs() < 1e-3,
1339            "fixed-spin Magnus acceleration must grow downrange; slow/fast={ratio}, \
1340             expected={expected_ratio}"
1341        );
1342    }
1343
1344    #[test]
1345    fn test_magnus_moment_coefficient() {
1346        // Test at various Mach numbers with corrected coefficients
1347        assert!((calculate_magnus_moment_coefficient(0.5) - 0.030).abs() < 0.001); // Subsonic
1348        assert!((calculate_magnus_moment_coefficient(0.8) - 0.030).abs() < 0.001); // Start of transonic
1349        assert!((calculate_magnus_moment_coefficient(1.0) - 0.0225).abs() < 0.001); // Mid transonic
1350        assert!((calculate_magnus_moment_coefficient(1.2) - 0.015).abs() < 0.001); // End of transonic
1351        assert!((calculate_magnus_moment_coefficient(2.0) - 0.01653).abs() < 0.001);
1352        // Supersonic
1353    }
1354
1355    /// MBA-1356: cd_scale — this module's custom-deck interpolation site.
1356    fn deck_test_inputs(cd_scale: f64) -> BallisticInputs {
1357        BallisticInputs {
1358            custom_drag_table: Some(crate::drag::DragTable::new(
1359                vec![0.5, 1.0, 2.0, 3.0],
1360                vec![0.23, 0.40, 0.30, 0.26],
1361            )),
1362            cd_scale,
1363            ..create_test_inputs()
1364        }
1365    }
1366
1367    fn deck_accel_x(cd_scale: f64) -> f64 {
1368        let inputs = deck_test_inputs(cd_scale);
1369        compute_derivatives(
1370            Vector3::zeros(),
1371            Vector3::new(700.0, 0.0, 0.0),
1372            &inputs,
1373            Vector3::zeros(),
1374            (1.225, 340.0, 0.0, 0.0),
1375            inputs.bc_value,
1376            None,
1377            0.0,
1378            None,
1379        )[3]
1380    }
1381
1382    #[test]
1383    fn cd_scale_default_is_one_and_absent_matches_explicit() {
1384        assert_eq!(BallisticInputs::default().cd_scale, 1.0);
1385
1386        let omitted = BallisticInputs {
1387            custom_drag_table: Some(crate::drag::DragTable::new(
1388                vec![0.5, 1.0, 2.0, 3.0],
1389                vec![0.23, 0.40, 0.30, 0.26],
1390            )),
1391            ..create_test_inputs()
1392        };
1393        assert_eq!(omitted.cd_scale, 1.0);
1394
1395        let a_omitted = compute_derivatives(
1396            Vector3::zeros(),
1397            Vector3::new(700.0, 0.0, 0.0),
1398            &omitted,
1399            Vector3::zeros(),
1400            (1.225, 340.0, 0.0, 0.0),
1401            omitted.bc_value,
1402            None,
1403            0.0,
1404            None,
1405        );
1406        let a_explicit = deck_accel_x(1.0);
1407        assert_eq!(
1408            a_omitted[3].to_bits(),
1409            a_explicit.to_bits(),
1410            "omitted cd_scale (Default) must be bit-identical to an explicit 1.0"
1411        );
1412    }
1413
1414    /// (b) Scale-direction test, derivatives-driven path: cd_scale=1.10 must add more drag
1415    /// (a more negative x-acceleration) than 1.0; 0.90 must add less.
1416    #[test]
1417    fn cd_scale_direction_on_derivatives_kernel() {
1418        let baseline = deck_accel_x(1.0);
1419        let scaled_up = deck_accel_x(1.10);
1420        let scaled_down = deck_accel_x(0.90);
1421
1422        assert!(
1423            scaled_up < baseline,
1424            "cd_scale=1.10 must increase drag deceleration (more negative ax): \
1425             base={baseline} up={scaled_up}"
1426        );
1427        assert!(
1428            scaled_down > baseline,
1429            "cd_scale=0.90 must decrease drag deceleration (less negative ax): \
1430             base={baseline} down={scaled_down}"
1431        );
1432    }
1433
1434    /// cd_scale must be inert on the standard G-model/BC path (no custom_drag_table).
1435    #[test]
1436    fn cd_scale_is_inert_without_a_custom_drag_table() {
1437        let make = |cd_scale: f64| BallisticInputs {
1438            cd_scale,
1439            ..create_test_inputs()
1440        };
1441        let accel = |cd_scale: f64| {
1442            let inputs = make(cd_scale);
1443            compute_derivatives(
1444                Vector3::zeros(),
1445                Vector3::new(700.0, 0.0, 0.0),
1446                &inputs,
1447                Vector3::zeros(),
1448                (1.225, 340.0, 0.0, 0.0),
1449                inputs.bc_value,
1450                None,
1451                0.0,
1452                None,
1453            )[3]
1454        };
1455        assert_eq!(
1456            accel(1.0).to_bits(),
1457            accel(1.5).to_bits(),
1458            "cd_scale must not affect the G-model/BC drag path"
1459        );
1460    }
1461}