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