Skip to main content

ballistics_engine/
derivatives.rs

1use crate::atmosphere::{get_direct_atmosphere, get_local_atmosphere};
2use crate::bc_estimation::BCSegmentEstimator;
3use crate::constants::*;
4use crate::drag::get_drag_coefficient_full;
5use crate::form_factor::apply_form_factor_to_drag;
6use crate::spin_drift::{apply_enhanced_spin_drift, calculate_enhanced_spin_drift};
7use crate::InternalBallisticInputs as BallisticInputs;
8use nalgebra::Vector3;
9
10// Physics constants
11const INCHES_PER_FOOT: f64 = 12.0;
12const STANDARD_AIR_DENSITY_METRIC: f64 = 1.225; // kg/m³ at sea level
13
14// Magnus Effect Constants
15//
16// The Magnus effect causes spinning projectiles to deflect perpendicular to both
17// their velocity vector and spin axis due to asymmetric pressure distribution.
18// These constants define the Magnus moment coefficient (C_Lα) for different flight regimes.
19
20/// Magnus coefficient for subsonic flow (M < 0.8)
21///
22/// Value: 0.030 (dimensionless coefficient)
23/// Physical basis: Fully developed boundary layer circulation around spinning projectile
24/// Regime: Subsonic flow where boundary layer remains attached
25/// Source: McCoy's "Modern Exterior Ballistics", validated against wind tunnel data
26const MAGNUS_COEFF_SUBSONIC: f64 = 0.030;
27
28/// Magnus coefficient reduction factor for transonic regime (0.8 < M < 1.2)
29///
30/// Value: 0.015 (continuous with the supersonic base at M=1.2)
31/// Physical basis: Shock waves disrupt circulation patterns, reducing Magnus effect
32/// Effect: Spin drift significantly reduced in transonic flight
33/// Source: Experimental spinning projectile studies
34const MAGNUS_COEFF_TRANSONIC_REDUCTION: f64 = 0.015;
35
36/// Base Magnus coefficient for supersonic flow (M > 1.2)
37///
38/// Value: 0.015 (dimensionless coefficient)
39/// Physical basis: Shock-dominated flow with reduced but persistent circulation
40/// Effect: Lower Magnus effect than subsonic, but higher than transonic minimum
41const MAGNUS_COEFF_SUPERSONIC_BASE: f64 = 0.015;
42
43/// Magnus coefficient scaling factor for high supersonic speeds
44///
45/// Value: 0.0044 (additional scaling with Mach number)
46/// Formula: Magnus_coeff = BASE + SCALE * (M - 1.2) for M > 1.2
47/// Physical basis: Partial recovery of circulation effects at higher Mach numbers
48const MAGNUS_COEFF_SUPERSONIC_SCALE: f64 = 0.0044;
49
50/// Transonic regime boundaries for Magnus effect calculations
51const MAGNUS_TRANSONIC_LOWER: f64 = 0.8; // Lower bound of transonic regime
52const MAGNUS_TRANSONIC_UPPER: f64 = 1.2; // Upper bound of transonic regime
53const MAGNUS_TRANSONIC_RANGE: f64 = 0.4; // Range width (1.2 - 0.8)
54const MAGNUS_SUPERSONIC_RANGE: f64 = 1.8; // Scaling range for supersonic recovery
55
56// Note: These Magnus coefficients are calibrated against real-world spin drift measurements
57// from McCoy's "Modern Exterior Ballistics" and experimental data. The dimensionless
58// coefficients represent the Magnus moment per unit angle of attack.
59
60// Atmosphere detection thresholds
61const MAX_REALISTIC_DENSITY: f64 = 2.0; // kg/m³
62const MIN_REALISTIC_SPEED_OF_SOUND: f64 = 200.0; // m/s
63
64/// Calculate spin rate from twist rate and velocity
65fn calculate_spin_rate(twist_rate: f64, velocity_mps: f64) -> f64 {
66    if twist_rate <= 0.0 {
67        return 0.0;
68    }
69
70    // Convert velocity to ft/s and twist rate to ft/turn
71    let velocity_fps = velocity_mps * MPS_TO_FPS;
72    let twist_rate_ft = twist_rate / INCHES_PER_FOOT;
73
74    // Calculate spin rate: revolutions per second = velocity_fps / twist_rate_ft
75    // Convert to rad/s: rad/s = (revolutions/s) * 2π
76    let revolutions_per_second = velocity_fps / twist_rate_ft;
77
78    revolutions_per_second * 2.0 * std::f64::consts::PI
79}
80
81/// Calculate Magnus moment coefficient C_Lα based on Mach number
82/// Based on McCoy's 'Modern Exterior Ballistics' and empirical data
83pub(crate) fn calculate_magnus_moment_coefficient(mach: f64) -> f64 {
84    // Magnus moment coefficient varies with Mach number
85    // Values based on empirical data for spitzer bullets
86
87    if mach < MAGNUS_TRANSONIC_LOWER {
88        // Subsonic: relatively constant
89        MAGNUS_COEFF_SUBSONIC
90    } else if mach < MAGNUS_TRANSONIC_UPPER {
91        // Transonic: reduced due to shock formation
92        // Linear interpolation through transonic region
93        MAGNUS_COEFF_SUBSONIC
94            - MAGNUS_COEFF_TRANSONIC_REDUCTION * (mach - MAGNUS_TRANSONIC_LOWER)
95                / MAGNUS_TRANSONIC_RANGE
96    } else {
97        // Supersonic: gradually recovers
98        MAGNUS_COEFF_SUPERSONIC_BASE
99            + MAGNUS_COEFF_SUPERSONIC_SCALE
100                * ((mach - MAGNUS_TRANSONIC_UPPER) / MAGNUS_SUPERSONIC_RANGE).min(1.0)
101    }
102}
103
104/// Compute ballistic derivatives for trajectory integration
105pub fn compute_derivatives(
106    pos: Vector3<f64>,
107    vel: Vector3<f64>,
108    inputs: &BallisticInputs,
109    wind_vector: Vector3<f64>,
110    atmos_params: (f64, f64, f64, f64),
111    bc_used: f64,
112    omega_vector: Option<Vector3<f64>>,
113    time: f64,
114) -> [f64; 6] {
115    // Gravity acceleration vector, rotated into the shot-aligned frame by shooting_angle
116    // (uphill/downhill inclined fire), matching cli_api::TrajectorySolver::gravity_acceleration.
117    let theta = inputs.shooting_angle;
118    let accel_gravity = Vector3::new(
119        -G_ACCEL_MPS2 * theta.sin(),
120        -G_ACCEL_MPS2 * theta.cos(),
121        0.0,
122    );
123
124    // Wind-adjusted velocity
125    let velocity_adjusted = vel - wind_vector;
126    let speed_air = velocity_adjusted.norm();
127
128    // Initialize drag acceleration
129    let mut accel_drag = Vector3::zeros();
130    let mut accel_magnus = Vector3::zeros();
131
132    // Calculate drag if velocity is significant
133    if speed_air > crate::constants::MIN_VELOCITY_THRESHOLD {
134        let v_rel_fps = speed_air * MPS_TO_FPS;
135
136        // Get atmospheric conditions
137        let altitude_at_pos = inputs.altitude + pos[1];
138
139        // Check if we have direct atmosphere values
140        // Direct atmosphere is indicated by having only 2 parameters where:
141        // params[0] = air density, params[1] = speed of sound
142        // params[2] and params[3] would be 0.0
143        // BUT: we need to check if params[0] is a reasonable density value (< 2.0 kg/m³)
144        let (air_density, speed_of_sound, _temperature_c) = if atmos_params.0
145            < MAX_REALISTIC_DENSITY
146            && atmos_params.1 > MIN_REALISTIC_SPEED_OF_SOUND
147            && atmos_params.2 == 0.0
148            && atmos_params.3 == 0.0
149        {
150            // Direct atmosphere values: atmos_params.1 is the SPEED OF SOUND here, NOT Celsius,
151            // so back-compute temperature from it (c = sqrt(1.4*287.05*T_k)) for the Reynolds
152            // correction below — which previously read atmos_params.1 as temperature directly.
153            let (rho, sound) = get_direct_atmosphere(atmos_params.0, atmos_params.1);
154            (rho, sound, sound * sound / (1.4 * 287.05) - 273.15)
155        } else {
156            // Calculate from base parameters
157            let (rho, sound) = get_local_atmosphere(
158                altitude_at_pos,
159                atmos_params.0, // base_alt
160                atmos_params.1, // base_temp_c
161                atmos_params.2, // base_press_hpa
162                atmos_params.3, // base_ratio
163            );
164            // LOCAL temperature at the projectile altitude, back-computed from the LOCAL speed of
165            // sound (get_local_atmosphere returns density/sound at altitude_at_pos but not temp;
166            // its sound = sqrt(1.4*287.05*T_k)). Using base_temp_c here would feed the Reynolds
167            // viscosity the shooter-altitude temperature while density/sound are local.
168            (rho, sound, sound * sound / (1.4 * 287.05) - 273.15)
169        };
170
171        // Calculate Mach number with safe division
172        let mach = if speed_of_sound > 1e-9 {
173            speed_air / speed_of_sound
174        } else {
175            0.0 // No meaningful Mach number at zero speed of sound
176        };
177
178        // Get drag coefficient with transonic and Reynolds corrections
179        let mut drag_factor = get_drag_coefficient_full(
180            mach,
181            &inputs.bc_type,
182            false, // transonic applied exactly once below (was double-applied here + in block)
183            false, // Reynolds applied once below (manual block ~243); was double-applied here + there
184            None,  // let it determine shape
185            if inputs.caliber_inches > 0.0 {
186                Some(inputs.caliber_inches)
187            } else {
188                Some(inputs.bullet_diameter / 0.0254) // meters -> inches
189            },
190            if inputs.weight_grains > 0.0 {
191                Some(inputs.weight_grains)
192            } else {
193                Some(inputs.bullet_mass / 0.00006479891) // kg -> grains
194            },
195            Some(speed_air),
196            Some(air_density),
197            Some(atmos_params.1), // temperature in Celsius
198        );
199
200        // Apply form factor if enabled
201        if inputs.use_form_factor {
202            drag_factor = apply_form_factor_to_drag(
203                drag_factor,
204                inputs.bullet_model.as_deref(),
205                &inputs.bc_type,
206                true,
207            );
208        }
209
210        // Get BC value
211        let mut bc_val = bc_used;
212
213        if inputs.use_bc_segments {
214            // First try velocity-based segments if available
215            if inputs.bc_segments_data.is_some() {
216                bc_val = get_bc_for_velocity(v_rel_fps, inputs, bc_used);
217            } else if let Some(ref segments) = inputs.bc_segments {
218                // Fall back to Mach-based segments when use_bc_segments=true but no velocity data
219                bc_val = interpolated_bc(mach, segments, Some(inputs));
220            } else {
221                // No explicit segments - try BC estimation
222                bc_val = get_bc_for_velocity(v_rel_fps, inputs, bc_used);
223            }
224        } else if let Some(ref segments) = inputs.bc_segments {
225            // Explicit Mach-based segments (legacy behavior when use_bc_segments=false)
226            bc_val = interpolated_bc(mach, segments, Some(inputs));
227        }
228
229        // Guard bc_val == 0 (allowed on the FFI/WASM/library surfaces, which lack the CLI's
230        // 0.001 floor, and a user-supplied BC segment can be 0): the drag division below would be
231        // Inf -> NaN, poisoning the whole trajectory. Mirrors the guards already in
232        // cli_api::calculate_acceleration and fast_trajectory::compute_derivatives. Inert for
233        // valid BCs (>= 0.001).
234        let bc_val = bc_val.max(1e-6);
235
236        // Calculate yaw effect with safe division
237        let yaw_deg = if inputs.tipoff_decay_distance.abs() > 1e-9 {
238            inputs.tipoff_yaw * (-pos[0] / inputs.tipoff_decay_distance).exp()
239        } else {
240            inputs.tipoff_yaw // No decay if distance is zero
241        };
242        let yaw_rad = yaw_deg.to_radians();
243        let yaw_multiplier = 1.0 + yaw_rad.powi(2);
244
245        // Calculate density scaling
246        let density_scale = air_density / STANDARD_AIR_DENSITY;
247
248        // Apply the transonic drag-rise correction exactly ONCE. The base Cd above is taken
249        // WITHOUT transonic correction (apply_transonic_correction=false), so this is the only
250        // application. Previously the correction was applied here AND inside
251        // get_drag_coefficient_full, which squared the drag-rise factor and double-counted wave
252        // drag across the transonic band (Cd ~3x too high near Mach 1). transonic_correction
253        // self-gates via the projectile's critical Mach (returns the input unchanged outside the
254        // band), and include_wave_drag=false matches cli_api::calculate_drag_coefficient — the
255        // G1/G7 tables already embed the transonic rise, so additive wave drag would double-count.
256        // Use the same SI fallbacks as the get_drag_coefficient_full call above (and
257        // fast_trajectory): an SI-only caller may leave caliber_inches/weight_grains at 0, so
258        // derive them from the SI bullet_diameter/bullet_mass rather than feeding zeros into
259        // get_projectile_shape (which would mis-classify the shape via weight/caliber).
260        let caliber_in = if inputs.caliber_inches > 0.0 {
261            inputs.caliber_inches
262        } else {
263            inputs.bullet_diameter / 0.0254 // meters -> inches
264        };
265        let weight_gr = if inputs.weight_grains > 0.0 {
266            inputs.weight_grains
267        } else {
268            inputs.bullet_mass / 0.00006479891 // kg -> grains
269        };
270        // MBA-949: shared resolver so named bullet_model shapes are honored here too (this path
271        // previously used only the caliber/weight heuristic and ignored the name).
272        let shape = crate::transonic_drag::resolve_projectile_shape(
273            inputs.bullet_model.as_deref(),
274            caliber_in,
275            weight_gr,
276            &inputs.bc_type.to_string(),
277        );
278        let drag_factor =
279            crate::transonic_drag::transonic_correction(mach, drag_factor, shape, false);
280
281        // MBA-945: the low-velocity Reynolds drag correction was applied ONLY here (not in cli_api
282        // or fast_trajectory), so subsonic shots diverged across the three solver families. Removed
283        // for consistency — py_ballisticcalc (the validation reference) does not model it, and
284        // cli_api already matches pbc subsonically without it (validated to 1300yd in MBA-939). The
285        // reynolds module and get_drag_coefficient_full's apply_reynolds flag remain available for a
286        // future opt-in wired across all three solvers.
287
288        // MBA-940: a user-supplied custom drag table overrides the G-model Cd entirely and is used
289        // as-is — the transonic/Reynolds/form-factor corrections above are intentionally NOT
290        // applied to it (the curve already encodes the projectile's true drag, so applying them
291        // would distort/double-count it).
292        // The custom table's Cd is the projectile's ACTUAL drag coefficient, so the
293        // retardation denominator must be the sectional density (lb/in²), not a BC:
294        // Cd_own / SD == Cd_ref / BC (see BallisticInputs::custom_drag_denominator).
295        let (drag_factor, retard_denom) = match inputs.custom_drag_table {
296            Some(ref table) => (
297                table.interpolate(mach),
298                inputs.custom_drag_denominator(bc_val),
299            ),
300            None => (drag_factor, bc_val),
301        };
302
303        // Calculate drag acceleration
304        let standard_factor = drag_factor * CD_TO_RETARD;
305        let a_drag_ft_s2 =
306            (v_rel_fps.powi(2) * standard_factor * yaw_multiplier * density_scale) / retard_denom;
307        let a_drag_m_s2 = a_drag_ft_s2 * FPS_TO_MPS;
308
309        // Apply drag in opposite direction of relative velocity
310        accel_drag = -a_drag_m_s2 * (velocity_adjusted / speed_air);
311
312        // Magnus Effect calculation. Gated on enable_magnus specifically so it is
313        // independent of Coriolis (matches the cli_api solver's decoupled flags).
314        if inputs.enable_magnus && inputs.bullet_diameter > 0.0 && inputs.twist_rate > 0.0 {
315            // Calculate spin rate from twist rate and velocity
316            let spin_rate_rad_s = calculate_spin_rate(inputs.twist_rate, speed_air);
317
318            let c_np = calculate_magnus_moment_coefficient(mach);
319
320            // bullet_diameter is SI (meters)
321            let diameter_m = inputs.bullet_diameter;
322
323            // Calculate spin parameter (dimensionless) with safe division
324            let spin_param = if speed_air > 1e-9 {
325                spin_rate_rad_s * diameter_m / (2.0 * speed_air)
326            } else {
327                0.0 // No spin effect at zero speed
328            };
329
330            // Calculate reference area
331            let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
332
333            // Yaw of repose for the proper Magnus force. Stability/yaw helpers are
334            // imperial: use the explicit imperial mirror fields, and convert the SI
335            // bullet_length to inches at this boundary.
336            let d_in = inputs.caliber_inches;
337            let m_gr = inputs.weight_grains;
338            let l_in = if inputs.bullet_length > 0.0 {
339                inputs.bullet_length / 0.0254 // meters -> inches
340            } else {
341                4.5 * d_in.max(1e-9)
342            };
343            // MBA-958: apply the canonical linear Miller density correction (rho0/rho) to the
344            // Magnus/yaw-of-repose Sg too, matching the spin-drift Sg (MBA-942) and stability.rs.
345            // No-op at sea-level standard (rho ~= 1.225 -> factor ~= 1.0).
346            let density_correction = if air_density > 0.0 {
347                STANDARD_AIR_DENSITY / air_density
348            } else {
349                1.0
350            };
351            let sg = crate::spin_drift::miller_stability(d_in, m_gr, inputs.twist_rate, l_in)
352                * density_correction;
353            let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose(
354                sg,
355                speed_air,
356                spin_rate_rad_s,
357                0.0,
358                0.0,
359                air_density,
360                d_in,
361                l_in,
362                m_gr,
363                mach,
364                "match",
365                false,
366            );
367
368            // Proper McCoy Magnus FORCE: F = q S C_Npa (pd/2V) sin(alpha_R).
369            let magnus_force_magnitude =
370                0.5 * air_density * speed_air.powi(2) * area * c_np * spin_param * yaw_rad.sin();
371
372            // Magnus force is perpendicular to both velocity and spin axis
373            // For a bullet spinning around its axis of travel, the spin vector is aligned with velocity
374            let velocity_unit = velocity_adjusted / speed_air;
375
376            // The Magnus force creates lift perpendicular to velocity
377            // For right-hand twist, force is to the right when looking downrange
378            // We need a vector perpendicular to velocity in the horizontal plane
379
380            // Simplified approach: Magnus primarily causes horizontal drift
381            // The force is perpendicular to both spin axis (velocity) and gravity
382            let vertical = Vector3::new(0.0, 1.0, 0.0); // Up direction
383
384            // Magnus force direction: velocity × vertical (for right-hand twist)
385            let magnus_direction = velocity_unit.cross(&vertical);
386            let magnus_norm = magnus_direction.norm();
387
388            if magnus_norm > 1e-12 && magnus_force_magnitude > 1e-12 {
389                let magnus_direction = magnus_direction / magnus_norm;
390
391                // Reverse direction for left-hand twist
392                let magnus_direction = if inputs.is_twist_right {
393                    magnus_direction
394                } else {
395                    -magnus_direction
396                };
397
398                // Convert bullet mass to kg
399                let bullet_mass_kg = inputs.bullet_mass; // already kg (SI)
400
401                // Calculate acceleration
402                accel_magnus = (magnus_force_magnitude / bullet_mass_kg) * magnus_direction;
403            }
404        }
405    }
406
407    // Total acceleration
408    let mut accel = accel_gravity + accel_drag + accel_magnus;
409
410    // Add Coriolis acceleration if omega vector is provided. The physical Coriolis term is
411    // -2 Ω×v (MBA-957: the old +2 "frame-relabel" justification was wrong — it flipped the
412    // lateral drift; the caller now builds omega with the corrected lateral sign, matching the
413    // validated cli_api solver, so the canonical -2 applies directly).
414    if let Some(omega) = omega_vector {
415        let accel_coriolis = -2.0 * omega.cross(&vel);
416        accel += accel_coriolis;
417    }
418
419    // Apply enhanced spin drift if enabled
420    let mut derivatives = [vel[0], vel[1], vel[2], accel[0], accel[1], accel[2]];
421
422    if inputs.use_enhanced_spin_drift && inputs.enable_advanced_effects && time > 0.0 {
423        // Calculate crosswind component
424        let velocity_adjusted = vel - wind_vector;
425        let crosswind_speed = if velocity_adjusted.norm() > crate::constants::MIN_VELOCITY_THRESHOLD
426        {
427            let trajectory_unit = velocity_adjusted / velocity_adjusted.norm();
428            let crosswind = wind_vector - wind_vector.dot(&trajectory_unit) * trajectory_unit;
429            crosswind.norm()
430        } else {
431            0.0
432        };
433
434        // Get air density (already calculated above)
435        let air_density = if speed_air > crate::constants::MIN_VELOCITY_THRESHOLD {
436            let altitude_at_pos = inputs.altitude + pos[1];
437            let (density, _) = if atmos_params.0 < MAX_REALISTIC_DENSITY
438                && atmos_params.1 > MIN_REALISTIC_SPEED_OF_SOUND
439                && atmos_params.2 == 0.0
440                && atmos_params.3 == 0.0
441            {
442                get_direct_atmosphere(atmos_params.0, atmos_params.1)
443            } else {
444                get_local_atmosphere(
445                    altitude_at_pos,
446                    atmos_params.0,
447                    atmos_params.1,
448                    atmos_params.2,
449                    atmos_params.3,
450                )
451            };
452            density
453        } else {
454            STANDARD_AIR_DENSITY_METRIC // Standard air density
455        };
456
457        // Calculate enhanced spin drift components
458        // calculate_enhanced_spin_drift is imperial (grains/inches): convert at boundary.
459        let spin_components = calculate_enhanced_spin_drift(
460            inputs.weight_grains,
461            vel.norm(),
462            inputs.twist_rate,
463            inputs.caliber_inches,
464            inputs.bullet_length / 0.0254, // meters -> inches
465            inputs.is_twist_right,
466            time,
467            air_density,
468            crosswind_speed,
469            0.0,   // pitch_rate_rad_s - we don't track angular rates yet
470            false, // use_pitch_damping - disabled for now
471        );
472
473        // Apply enhanced spin drift acceleration
474        apply_enhanced_spin_drift(
475            &mut derivatives,
476            &spin_components,
477            time,
478            inputs.is_twist_right,
479        );
480    }
481
482    // Return state derivatives: [velocity, acceleration]
483    derivatives
484}
485
486/// Calculate appropriate BC fallback based on available bullet parameters
487fn calculate_bc_fallback(
488    bullet_mass: Option<f64>,     // grains
489    bullet_diameter: Option<f64>, // inches
490    bc_type: Option<&str>,        // "G1" or "G7"
491) -> f64 {
492    use crate::constants::*;
493
494    // Weight-based fallback (most reliable predictor)
495    if let Some(weight) = bullet_mass {
496        let base_bc = if weight < 50.0 {
497            BC_FALLBACK_ULTRA_LIGHT
498        } else if weight < 100.0 {
499            BC_FALLBACK_LIGHT
500        } else if weight < 150.0 {
501            BC_FALLBACK_MEDIUM
502        } else if weight < 200.0 {
503            BC_FALLBACK_HEAVY
504        } else {
505            BC_FALLBACK_VERY_HEAVY
506        };
507
508        // G7 vs G1 adjustment
509        return if let Some(drag_model) = bc_type {
510            if drag_model == "G7" {
511                base_bc * 0.85 // G7 BCs are typically lower than G1
512            } else {
513                base_bc
514            }
515        } else {
516            base_bc
517        };
518    }
519
520    // Caliber-based fallback (second most reliable)
521    if let Some(caliber) = bullet_diameter {
522        let base_bc = if caliber <= 0.224 {
523            BC_FALLBACK_SMALL_CALIBER
524        } else if caliber <= 0.243 {
525            BC_FALLBACK_MEDIUM_CALIBER
526        } else if caliber <= 0.284 {
527            BC_FALLBACK_LARGE_CALIBER
528        } else {
529            BC_FALLBACK_XLARGE_CALIBER
530        };
531
532        // G7 vs G1 adjustment
533        return if let Some(drag_model) = bc_type {
534            if drag_model == "G7" {
535                base_bc * 0.85 // G7 BCs are typically lower than G1
536            } else {
537                base_bc
538            }
539        } else {
540            base_bc
541        };
542    }
543
544    // Final fallback - conservative overall
545    let base_fallback = BC_FALLBACK_CONSERVATIVE;
546    if let Some(drag_model) = bc_type {
547        if drag_model == "G7" {
548            return base_fallback * 0.85;
549        }
550    }
551
552    base_fallback
553}
554
555/// Interpolate ballistic coefficient from segments with dynamic fallback
556pub fn interpolated_bc(
557    mach: f64,
558    segments: &[(f64, f64)],
559    inputs: Option<&BallisticInputs>,
560) -> f64 {
561    if segments.is_empty() {
562        // Use dynamic fallback based on bullet characteristics if available
563        if let Some(inputs) = inputs {
564            let bc_type_str = match inputs.bc_type {
565                crate::DragModel::G1 => "G1",
566                crate::DragModel::G7 => "G7",
567                _ => "G1", // Default to G1 for other models
568            };
569            return calculate_bc_fallback(
570                Some(inputs.weight_grains),  // grains
571                Some(inputs.caliber_inches), // inches
572                Some(bc_type_str),
573            );
574        }
575        return crate::constants::BC_FALLBACK_CONSERVATIVE; // Conservative fallback based on database analysis
576    }
577
578    if segments.len() == 1 {
579        return segments[0].1;
580    }
581
582    // Ensure ascending-Mach order for interpolation. Fast path: when the segments are
583    // already sorted (the common case — they are normalized once at construction), borrow
584    // them and skip the per-call heap alloc + O(n log n) sort on the integration hot path.
585    let sorted_segments: std::borrow::Cow<[(f64, f64)]> =
586        if segments.windows(2).all(|w| w[0].0 <= w[1].0) {
587            std::borrow::Cow::Borrowed(segments)
588        } else {
589            let mut v = segments.to_vec();
590            v.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
591            std::borrow::Cow::Owned(v)
592        };
593
594    // Handle out-of-range cases first
595    if mach <= sorted_segments[0].0 {
596        return sorted_segments[0].1;
597    }
598    if mach >= sorted_segments[sorted_segments.len() - 1].0 {
599        return sorted_segments[sorted_segments.len() - 1].1;
600    }
601
602    // Find the appropriate segment using binary search
603    let idx = sorted_segments.partition_point(|(m, _)| *m <= mach);
604    if idx == 0 || idx >= sorted_segments.len() {
605        // Should not happen given the checks above
606        return sorted_segments[0].1;
607    }
608
609    let (mach1, bc1) = sorted_segments[idx - 1];
610    let (mach2, bc2) = sorted_segments[idx];
611
612    // Linear interpolation with safe division
613    let denominator = mach2 - mach1;
614    if denominator.abs() < crate::constants::MIN_DIVISION_THRESHOLD {
615        return bc1; // Return first BC value if Mach values are identical
616    }
617    let t = (mach - mach1) / denominator;
618    bc1 + t * (bc2 - bc1)
619}
620
621/// Get BC value for current velocity, supporting velocity-based BC segments
622fn get_bc_for_velocity(velocity_fps: f64, inputs: &BallisticInputs, bc_used: f64) -> f64 {
623    // Check if velocity-based BC segments are enabled
624    if !inputs.use_bc_segments {
625        return bc_used;
626    }
627
628    // Try direct BC segments data first
629    if let Some(ref bc_segments_data) = inputs.bc_segments_data {
630        for segment in bc_segments_data {
631            if velocity_fps >= segment.velocity_min && velocity_fps <= segment.velocity_max {
632                return segment.bc_value;
633            }
634        }
635    }
636
637    // Try BC estimation if we have bullet details but no segments. MBA-955: the estimation is
638    // factored into estimate_bc_segments_for so the per-integration setup (build_inputs) can
639    // pre-populate bc_segments_data ONCE rather than rebuilding it here every step.
640    if let Some(segments) = estimate_bc_segments_for(inputs, bc_used) {
641        for segment in &segments {
642            if velocity_fps >= segment.velocity_min && velocity_fps <= segment.velocity_max {
643                return segment.bc_value;
644            }
645        }
646    }
647
648    // Fallback to constant BC
649    bc_used
650}
651
652/// Estimate velocity-BC segments from bullet characteristics (MBA-955). Extracted from
653/// get_bc_for_velocity's slow path so the per-integration setup can compute the segments ONCE
654/// (build_inputs pre-populates bc_segments_data) instead of rebuilding them — allocating a model
655/// String and a segment Vec — on every derivative evaluation. Returns None when the bullet
656/// details needed for estimation are absent (the caller then falls back to the constant BC). The
657/// logic is byte-identical to the previous inline slow path.
658pub(crate) fn estimate_bc_segments_for(
659    inputs: &BallisticInputs,
660    bc_used: f64,
661) -> Option<Vec<crate::BCSegmentData>> {
662    if !(inputs.bullet_diameter > 0.0 && inputs.bullet_mass > 0.0 && bc_used > 0.0) {
663        return None;
664    }
665    // Model string from bullet_id or a generic weight-based description (unchanged).
666    let model = if let Some(ref bullet_id) = inputs.bullet_id {
667        bullet_id.clone()
668    } else {
669        format!("{}gr bullet", inputs.weight_grains as i32)
670    };
671    let bc_type_str = inputs.bc_type_str.as_deref().unwrap_or("G1");
672    Some(BCSegmentEstimator::estimate_bc_segments(
673        bc_used,
674        inputs.caliber_inches,
675        inputs.weight_grains,
676        &model,
677        bc_type_str,
678    ))
679}
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684
685    fn create_test_inputs() -> BallisticInputs {
686        // SI-canonical geometry/mass (kg, meters) — same convention as the struct
687        // docs and cli_api — plus the explicit imperial mirror fields
688        // (caliber_inches/weight_grains) the stability/Magnus helpers read.
689        BallisticInputs {
690            muzzle_velocity: 800.0, // m/s
691            bc_value: 0.5,
692            bullet_mass: 168.0 * 0.00006479891, // kg (168 gr)
693            bullet_diameter: 0.308 * 0.0254,    // meters (.308 in)
694            bullet_length: 1.215 * 0.0254,      // meters
695            caliber_inches: 0.308,
696            weight_grains: 168.0,
697            altitude: 1000.0,
698            ..Default::default()
699        }
700    }
701
702    #[test]
703    fn test_mba955_bc_segments_prepopulate_byte_identical() {
704        // MBA-955: pre-populating bc_segments_data once (in build_inputs) must return
705        // BYTE-IDENTICAL BC to the old per-step estimation. Build the slow-path inputs
706        // (bc_segments_data = None -> get_bc_for_velocity estimates every call) and the
707        // pre-populated inputs (bc_segments_data = estimate_bc_segments_for, the same helper
708        // build_inputs now calls), and assert get_bc_for_velocity agrees bit-for-bit across the
709        // whole velocity range.
710        let mut slow = create_test_inputs();
711        slow.use_bc_segments = true;
712        slow.bc_segments_data = None;
713        slow.bc_segments = None;
714
715        let bc_used = slow.bc_value;
716        let mut fast = slow.clone();
717        fast.bc_segments_data = estimate_bc_segments_for(&fast, bc_used);
718        assert!(
719            fast.bc_segments_data.is_some(),
720            "estimation should yield segments for a valid bullet"
721        );
722
723        for v in (200..=3500).step_by(50) {
724            let vf = v as f64;
725            let a = get_bc_for_velocity(vf, &slow, bc_used);
726            let b = get_bc_for_velocity(vf, &fast, bc_used);
727            assert_eq!(
728                a.to_bits(),
729                b.to_bits(),
730                "BC differs at {vf} fps: slow={a} fast={b}"
731            );
732        }
733    }
734
735    #[test]
736    fn test_compute_derivatives_basic() {
737        let pos = Vector3::new(0.0, 0.0, 0.0);
738        let vel = Vector3::new(800.0, 0.0, 0.0);
739        let inputs = create_test_inputs();
740        let wind_vector = Vector3::zeros();
741        // Use direct atmosphere values: (air_density, speed_of_sound, 0.0, 0.0)
742        let atmos_params = (1.225, 340.0, 0.0, 0.0); // Standard air density and speed of sound
743        let bc_used = 0.5;
744
745        let result = compute_derivatives(
746            pos,
747            vel,
748            &inputs,
749            wind_vector,
750            atmos_params,
751            bc_used,
752            None,
753            0.0,
754        );
755
756        // Check that we get velocity and acceleration components
757        assert_eq!(result.len(), 6);
758
759        // Velocity components should match input velocity
760        assert!((result[0] - vel[0]).abs() < 1e-10);
761        assert!((result[1] - vel[1]).abs() < 1e-10);
762        assert!((result[2] - vel[2]).abs() < 1e-10);
763
764        // Should have gravitational acceleration
765        assert!(result[4] < 0.0); // Negative y acceleration due to gravity
766
767        // Should have drag acceleration opposing motion
768        assert!(result[3] < 0.0); // Negative x acceleration due to drag
769    }
770
771    #[test]
772    fn test_compute_derivatives_with_wind() {
773        let pos = Vector3::new(0.0, 0.0, 0.0);
774        let vel = Vector3::new(800.0, 0.0, 0.0);
775        let inputs = create_test_inputs();
776        let wind_vector = Vector3::new(10.0, 0.0, 0.0); // Tailwind
777        let atmos_params = (1.225, 340.0, 0.0, 0.0); // Standard air density and speed of sound
778        let bc_used = 0.5;
779
780        let result = compute_derivatives(
781            pos,
782            vel,
783            &inputs,
784            wind_vector,
785            atmos_params,
786            bc_used,
787            None,
788            0.0,
789        );
790
791        // With tailwind, effective velocity should be lower, thus less drag
792        // Just check that we have some drag (negative acceleration)
793        assert!(result[3] < 0.0); // Should have drag
794    }
795
796    #[test]
797    fn test_compute_derivatives_with_coriolis() {
798        let pos = Vector3::new(0.0, 0.0, 0.0);
799        let vel = Vector3::new(800.0, 0.0, 0.0);
800        let inputs = create_test_inputs();
801        let wind_vector = Vector3::zeros();
802        let atmos_params = (1.225, 340.0, 0.0, 0.0); // Standard air density and speed of sound
803        let bc_used = 0.5;
804        let omega = Vector3::new(0.0, 0.0, 7.2921e-5); // Earth's rotation
805
806        let result = compute_derivatives(
807            pos,
808            vel,
809            &inputs,
810            wind_vector,
811            atmos_params,
812            bc_used,
813            Some(omega),
814            0.0,
815        );
816
817        // Should have Coriolis effect
818        assert!(result[4].abs() > 1e-3); // Should have some y-component from Coriolis
819    }
820
821    #[test]
822    fn test_interpolated_bc() {
823        let segments = vec![(0.5, 0.4), (1.0, 0.5), (1.5, 0.6), (2.0, 0.5)];
824
825        // Test exact matches
826        assert!((interpolated_bc(1.0, &segments, None) - 0.5).abs() < 1e-10);
827
828        // Test interpolation
829        let bc_075 = interpolated_bc(0.75, &segments, None);
830        assert!(bc_075 > 0.4 && bc_075 < 0.5);
831
832        // Test out of range
833        assert!((interpolated_bc(0.1, &segments, None) - 0.4).abs() < 1e-10);
834        assert!((interpolated_bc(3.0, &segments, None) - 0.5).abs() < 1e-10);
835    }
836
837    #[test]
838    fn test_interpolated_bc_edge_cases() {
839        // Empty segments
840        assert!(
841            (interpolated_bc(1.0, &[], None) - crate::constants::BC_FALLBACK_CONSERVATIVE).abs()
842                < 1e-10
843        );
844
845        // Single segment
846        let single = vec![(1.0, 0.7)];
847        assert!((interpolated_bc(1.5, &single, None) - 0.7).abs() < 1e-10);
848    }
849
850    #[test]
851    fn test_magnus_effect() {
852        let pos = Vector3::new(0.0, 0.0, 0.0);
853        let vel = Vector3::new(822.96, 0.0, 0.0); // 2700 fps
854        let mut inputs = create_test_inputs();
855        inputs.twist_rate = 10.0; // 1:10 twist
856        inputs.is_twist_right = true;
857        inputs.enable_magnus = true; // decoupled from enable_advanced_effects
858
859        let wind_vector = Vector3::zeros();
860        let atmos_params = (1.225, 340.0, 0.0, 0.0); // Standard air density and speed of sound
861        let bc_used = 0.5;
862
863        let result = compute_derivatives(
864            pos,
865            vel,
866            &inputs,
867            wind_vector,
868            atmos_params,
869            bc_used,
870            None,
871            0.0,
872        );
873
874        // Magnus is a small lateral (z) acceleration, positive (right) for RH twist,
875        // using the proper yaw-of-repose force model (the old 1.8 fudge factor is gone).
876        // For this .308/168gr/1:10 case at ~2700 fps it is ~0.003 m/s² — a fraction of
877        // gravity, integrating to a sub-inch drift, consistent with the cli_api solver.
878        assert!(
879            result[5] > 0.0,
880            "Magnus should drift right for RH twist, got {}",
881            result[5]
882        );
883        assert!(
884            result[5] < 0.05,
885            "Magnus accel should be small/physical, got {}",
886            result[5]
887        );
888    }
889
890    #[test]
891    fn test_magnus_moment_coefficient() {
892        // Test at various Mach numbers with corrected coefficients
893        assert!((calculate_magnus_moment_coefficient(0.5) - 0.030).abs() < 0.001); // Subsonic
894        assert!((calculate_magnus_moment_coefficient(0.8) - 0.030).abs() < 0.001); // Start of transonic
895        assert!((calculate_magnus_moment_coefficient(1.0) - 0.0225).abs() < 0.001); // Mid transonic
896        assert!((calculate_magnus_moment_coefficient(1.2) - 0.015).abs() < 0.001); // End of transonic
897        assert!((calculate_magnus_moment_coefficient(2.0) - 0.01653).abs() < 0.001);
898        // Supersonic
899    }
900}