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}