1use crate::InternalBallisticInputs as BallisticInputs;
2
3pub fn compute_stability_coefficient(
15 inputs: &BallisticInputs,
16 atmo_params: (f64, f64, f64, f64),
17) -> f64 {
18 if inputs.twist_rate == 0.0 || inputs.bullet_length == 0.0 || inputs.bullet_diameter == 0.0 {
20 return 0.0;
21 }
22
23 const MILLER_CONST: f64 = 30.0;
25 const TEMP_REF_K: f64 = 288.15; const PRESS_REF_HPA: f64 = 1013.25;
27
28 let twist_rate_m = inputs.twist_rate.abs() * 0.0254; let twist_calibers = twist_rate_m / inputs.bullet_diameter;
32 let length_calibers = inputs.bullet_length / inputs.bullet_diameter;
33
34 let mass_grains = inputs.bullet_mass / 0.00006479891; let diameter_inches = inputs.bullet_diameter / 0.0254; let mass_term = MILLER_CONST * mass_grains;
40 let geom_term = twist_calibers.powi(2)
41 * diameter_inches.powi(3)
42 * length_calibers
43 * (1.0 + length_calibers.powi(2));
44
45 if geom_term == 0.0 {
46 return 0.0;
47 }
48
49 let (_, temp_c, current_press_hpa, _) = atmo_params;
51 let temp_k = temp_c + 273.15;
52
53 let density_correction = (temp_k / TEMP_REF_K) * (PRESS_REF_HPA / current_press_hpa);
56
57 let velocity_correction = miller_velocity_correction(inputs.muzzle_velocity);
59
60 (mass_term / geom_term) * velocity_correction * density_correction
63}
64
65pub const BULLET_LENGTH_RHO_EFF_KG_M3: f64 = 7600.0;
72
73const MIN_LENGTH_CALIBERS: f64 = 1.2;
77const MAX_LENGTH_CALIBERS: f64 = 6.5;
78
79pub const DEFAULT_TWIST_SG_TARGET: f64 = 2.0;
87
88const GRAINS_PER_KG: f64 = 1.0 / 0.00006479891;
89const METERS_PER_INCH: f64 = 0.0254;
90const MPS_TO_FPS: f64 = 3.28084;
91const MILLER_VEL_REF_FPS: f64 = 2800.0;
92
93pub(crate) fn miller_velocity_correction(muzzle_velocity_mps: f64) -> f64 {
98 (muzzle_velocity_mps * MPS_TO_FPS / MILLER_VEL_REF_FPS).powf(1.0 / 3.0)
99}
100
101pub fn estimate_bullet_length_m(diameter_m: f64, mass_kg: f64) -> f64 {
115 if mass_kg <= 0.0 || diameter_m <= 0.0 || !mass_kg.is_finite() || !diameter_m.is_finite() {
116 return 0.0;
117 }
118
119 let frontal_area = std::f64::consts::FRAC_PI_4 * diameter_m * diameter_m;
120 let raw_length = mass_kg / (BULLET_LENGTH_RHO_EFF_KG_M3 * frontal_area);
121
122 let length_calibers = (raw_length / diameter_m).clamp(MIN_LENGTH_CALIBERS, MAX_LENGTH_CALIBERS);
124 length_calibers * diameter_m
125}
126
127pub fn default_twist_inches(diameter_m: f64, mass_kg: f64, muzzle_velocity_mps: f64) -> f64 {
159 const FALLBACK_TWIST_IN: f64 = 12.0;
160
161 if diameter_m <= 0.0
162 || mass_kg <= 0.0
163 || muzzle_velocity_mps <= 0.0
164 || !diameter_m.is_finite()
165 || !mass_kg.is_finite()
166 || !muzzle_velocity_mps.is_finite()
167 {
168 return FALLBACK_TWIST_IN;
169 }
170
171 let length_m = estimate_bullet_length_m(diameter_m, mass_kg);
172 if length_m <= 0.0 {
173 return FALLBACK_TWIST_IN;
174 }
175
176 let d_in = diameter_m / METERS_PER_INCH;
177 let m_gr = mass_kg * GRAINS_PER_KG;
178 let l_cal = length_m / diameter_m;
179 let velocity_correction = miller_velocity_correction(muzzle_velocity_mps);
180
181 let geom = d_in.powi(3) * l_cal * (1.0 + l_cal * l_cal);
182 let denom = DEFAULT_TWIST_SG_TARGET * geom;
183 if denom <= 0.0 {
184 return FALLBACK_TWIST_IN;
185 }
186
187 let t_cal_sq = 30.0 * m_gr * velocity_correction / denom;
188 if !t_cal_sq.is_finite() || t_cal_sq <= 0.0 {
189 return FALLBACK_TWIST_IN;
190 }
191
192 let twist_in = t_cal_sq.sqrt() * d_in;
193 if twist_in.is_finite() && twist_in > 0.0 {
194 twist_in
195 } else {
196 FALLBACK_TWIST_IN
197 }
198}
199
200#[cfg(any(target_arch = "wasm32", test))]
203pub(crate) fn resolve_twist_inches(
204 explicit_twist_inches: Option<f64>,
205 diameter_m: f64,
206 mass_kg: f64,
207 muzzle_velocity_mps: f64,
208) -> f64 {
209 explicit_twist_inches
210 .unwrap_or_else(|| default_twist_inches(diameter_m, mass_kg, muzzle_velocity_mps))
211}
212
213pub fn compute_spin_drift(
224 time_s: f64,
225 stability: f64,
226 twist_rate: f64,
227 is_twist_right: bool,
228) -> f64 {
229 compute_spin_drift_with_decay(time_s, stability, twist_rate, is_twist_right, None)
230}
231
232#[allow(clippy::manual_clamp)] pub fn compute_spin_drift_with_decay(
239 time_s: f64,
240 stability: f64,
241 twist_rate: f64,
242 is_twist_right: bool,
243 decay_factor: Option<f64>, ) -> f64 {
245 if stability == 0.0 || time_s <= 0.0 || twist_rate == 0.0 {
246 return 0.0;
247 }
248
249 let sign = if is_twist_right { 1.0 } else { -1.0 };
250
251 let scaling_factor = 1.25;
254 let base_drift = sign * scaling_factor * (stability + 1.2) * time_s.powf(1.83);
255
256 let effective_drift = if let Some(decay) = decay_factor {
258 base_drift * decay.max(0.0).min(1.0)
259 } else {
260 base_drift
261 };
262
263 effective_drift * 0.0254
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 fn create_test_inputs() -> BallisticInputs {
272 BallisticInputs {
273 muzzle_velocity: 823.0, bc_value: 0.5,
275 bullet_mass: 0.0109, bullet_diameter: 0.00782, bullet_length: 0.033, twist_rate: 10.0,
279 ..Default::default()
280 }
281 }
282
283 #[test]
284 fn test_compute_stability_coefficient() {
285 let inputs = create_test_inputs();
286 let atmo_params = (0.0, 15.0, 1013.25, 1.0); let stability = compute_stability_coefficient(&inputs, atmo_params);
289
290 println!("Computed stability: {}", stability);
292
293 assert!(stability > 0.0);
295 assert!(stability < 10.0); assert!(stability > 1.0);
299 assert!(stability < 3.0);
300 }
301
302 #[test]
303 fn test_compute_stability_coefficient_zero_cases() {
304 let mut inputs = create_test_inputs();
305 let atmo_params = (0.0, 15.0, 1013.25, 1.0);
306
307 inputs.twist_rate = 0.0;
309 assert_eq!(compute_stability_coefficient(&inputs, atmo_params), 0.0);
310
311 inputs = create_test_inputs();
313 inputs.bullet_length = 0.0;
314 assert_eq!(compute_stability_coefficient(&inputs, atmo_params), 0.0);
315
316 inputs = create_test_inputs();
318 inputs.bullet_diameter = 0.0;
319 assert_eq!(compute_stability_coefficient(&inputs, atmo_params), 0.0);
320 }
321
322 #[test]
323 fn test_compute_stability_coefficient_atmospheric_effects() {
324 let inputs = create_test_inputs();
325
326 let standard_atmo = (0.0, 15.0, 1013.25, 1.0);
328 let standard_stability = compute_stability_coefficient(&inputs, standard_atmo);
329
330 let high_alt_atmo = (3000.0, 5.0, 900.0, 1.0);
332 let high_alt_stability = compute_stability_coefficient(&inputs, high_alt_atmo);
333
334 assert!(high_alt_stability > standard_stability);
336
337 let hot_atmo = (0.0, 35.0, 1013.25, 1.0);
339 let hot_stability = compute_stability_coefficient(&inputs, hot_atmo);
340
341 assert!(hot_stability > standard_stability);
343 }
344
345 #[test]
346 fn test_compute_spin_drift() {
347 let time_s = 1.5;
348 let stability = 2.0;
349 let twist_rate = 10.0;
350
351 let drift_right = compute_spin_drift(time_s, stability, twist_rate, true);
353 assert!(drift_right > 0.0); let drift_left = compute_spin_drift(time_s, stability, twist_rate, false);
357 assert!(drift_left < 0.0); assert!((drift_left + drift_right).abs() < 1e-10); assert!(drift_right.abs() < 0.25); }
363
364 #[test]
365 fn test_compute_spin_drift_zero_cases() {
366 assert_eq!(compute_spin_drift(1.5, 0.0, 10.0, true), 0.0);
368
369 assert_eq!(compute_spin_drift(0.0, 2.0, 10.0, true), 0.0);
371
372 assert_eq!(compute_spin_drift(-1.0, 2.0, 10.0, true), 0.0);
374
375 assert_eq!(compute_spin_drift(1.5, 2.0, 0.0, true), 0.0);
377 }
378
379 const GR_TO_KG: f64 = 0.00006479891;
382 const IN_TO_M: f64 = 0.0254;
383
384 fn len_in(diameter_in: f64, mass_gr: f64) -> f64 {
385 estimate_bullet_length_m(diameter_in * IN_TO_M, mass_gr * GR_TO_KG) / IN_TO_M
386 }
387
388 fn twist_in(diameter_in: f64, mass_gr: f64, v_fps: f64) -> f64 {
389 default_twist_inches(diameter_in * IN_TO_M, mass_gr * GR_TO_KG, v_fps * 0.3048)
390 }
391
392 #[test]
393 fn test_estimate_bullet_length_reference_bullets() {
394 let cases = [
396 (0.308, 175.0, 1.24, 0.06), (0.224, 77.0, 0.99, 0.06), (0.338, 300.0, 1.74, 0.06), (0.224, 55.0, 0.72, 0.05), (0.510, 750.0, 1.90, 0.08), ];
402 for (d, m, expected, tol) in cases {
403 let got = len_in(d, m);
404 assert!(
405 (got - expected).abs() < tol,
406 ".{d}/{m}gr length: expected ~{expected}\", got {got:.4}\" (L/d {:.2})",
407 got / d
408 );
409 }
410 }
411
412 #[test]
413 fn test_estimate_bullet_length_preserves_handgun_geometry() {
414 for (diameter_in, mass_gr, expected_ratio) in [
417 (0.355, 115.0, 1.702_847_900_515), (0.451, 230.0, 1.660_968_083_966), (0.355, 90.0, 1.332_663_574_316), ] {
421 let diameter_m = diameter_in * IN_TO_M;
422 let mass_kg = mass_gr * GR_TO_KG;
423 let frontal_area = std::f64::consts::FRAC_PI_4 * diameter_m * diameter_m;
424 let model_ratio = mass_kg / (BULLET_LENGTH_RHO_EFF_KG_M3 * frontal_area) / diameter_m;
425 let estimated_ratio = estimate_bullet_length_m(diameter_m, mass_kg) / diameter_m;
426
427 assert!((estimated_ratio - model_ratio).abs() < 1e-12);
428 assert!((estimated_ratio - expected_ratio).abs() < 1e-12);
429 }
430 }
431
432 #[test]
433 fn test_estimate_bullet_length_degenerate_inputs() {
434 assert_eq!(estimate_bullet_length_m(0.00782, 0.0), 0.0);
435 assert_eq!(estimate_bullet_length_m(0.00782, -1.0), 0.0);
436 assert_eq!(estimate_bullet_length_m(0.0, 0.011), 0.0);
437 assert_eq!(estimate_bullet_length_m(-0.1, 0.011), 0.0);
438 assert_eq!(estimate_bullet_length_m(f64::NAN, 0.011), 0.0);
439 }
440
441 #[test]
442 fn test_estimate_bullet_length_clamps_ld_ratio() {
443 const EXPECTED_MIN: f64 = 1.2;
444 const EXPECTED_MAX: f64 = 6.5;
445
446 let diameter_m = 0.355 * IN_TO_M;
447 for raw_ratio in [1.19_f64, 1.20, 1.21, 6.49, 6.50, 6.51] {
448 let mass_kg = raw_ratio
449 * BULLET_LENGTH_RHO_EFF_KG_M3
450 * std::f64::consts::FRAC_PI_4
451 * diameter_m.powi(3);
452 let actual_ratio = estimate_bullet_length_m(diameter_m, mass_kg) / diameter_m;
453 let expected_ratio = raw_ratio.clamp(EXPECTED_MIN, EXPECTED_MAX);
454
455 assert!(
456 (actual_ratio - expected_ratio).abs() < 1e-12,
457 "raw L/d {raw_ratio}: expected {expected_ratio}, got {actual_ratio}"
458 );
459 }
460 }
461
462 #[test]
463 fn test_default_twist_reference_bullets() {
464 let cases = [
467 (0.308, 175.0, 2600.0, 11.2, 1.2), (0.224, 55.0, 3240.0, 10.1, 1.5), (0.224, 77.0, 2750.0, 7.2, 1.2), (0.264, 140.0, 2700.0, 7.7, 1.2), ];
472 for (d, m, v, expected, tol) in cases {
473 let got = twist_in(d, m, v);
474 assert!(got > 0.0, ".{d}/{m}gr twist must be positive, got {got}");
475 assert!(
476 (got - expected).abs() < tol,
477 ".{d}/{m}gr twist: expected ~1:{expected}\", got 1:{got:.2}\"",
478 );
479 }
480 }
481
482 #[test]
483 fn test_default_twist_yields_target_sg() {
484 let d_m = 0.308 * IN_TO_M;
487 let m_kg = 175.0 * GR_TO_KG;
488 let v_mps = 2600.0 * 0.3048;
489 let twist = default_twist_inches(d_m, m_kg, v_mps);
490 let inputs = BallisticInputs {
491 muzzle_velocity: v_mps,
492 bullet_mass: m_kg,
493 bullet_diameter: d_m,
494 bullet_length: estimate_bullet_length_m(d_m, m_kg),
495 twist_rate: twist,
496 ..Default::default()
497 };
498 let sg = compute_stability_coefficient(&inputs, (0.0, 15.0, 1013.25, 1.0));
499 assert!(
500 (sg - DEFAULT_TWIST_SG_TARGET).abs() < 0.02,
501 "expected Sg ~{DEFAULT_TWIST_SG_TARGET}, got {sg}"
502 );
503 }
504
505 #[test]
506 fn test_default_twist_degenerate_inputs_fall_back() {
507 assert_eq!(default_twist_inches(0.0, 0.011, 800.0), 12.0);
508 assert_eq!(default_twist_inches(0.00782, 0.0, 800.0), 12.0);
509 assert_eq!(default_twist_inches(0.00782, 0.011, 0.0), 12.0);
510 assert_eq!(default_twist_inches(f64::NAN, 0.011, 800.0), 12.0);
511 }
512
513 #[test]
514 fn test_resolve_twist_preserves_explicit_or_uses_miller_default() {
515 let diameter_m = 0.224 * IN_TO_M;
516 let mass_kg = 77.0 * GR_TO_KG;
517 let velocity_mps = 2750.0 * 0.3048;
518 let expected_default = default_twist_inches(diameter_m, mass_kg, velocity_mps);
519
520 assert_eq!(
521 resolve_twist_inches(Some(9.5), diameter_m, mass_kg, velocity_mps).to_bits(),
522 9.5_f64.to_bits()
523 );
524 assert_eq!(
525 resolve_twist_inches(None, diameter_m, mass_kg, velocity_mps).to_bits(),
526 expected_default.to_bits()
527 );
528 assert_ne!(expected_default.to_bits(), 12.0_f64.to_bits());
529
530 let metric_default = resolve_twist_inches(
531 None,
532 5.6896 * 0.001,
533 4.98951607 * 0.001,
534 838.2,
535 );
536 assert!((metric_default - expected_default).abs() < 1e-12);
537 }
538
539 #[test]
540 fn test_heavier_bullet_needs_faster_twist() {
541 let light = twist_in(0.224, 55.0, 2900.0);
543 let heavy = twist_in(0.224, 77.0, 2900.0);
544 assert!(heavy < light, "77gr twist {heavy} should be faster than 55gr {light}");
545 }
546
547 #[test]
548 fn test_print_reference_estimates() {
549 println!("\n=== MBA-1135 estimate_bullet_length_m ===");
551 for (d, m) in [
552 (0.308, 175.0),
553 (0.224, 77.0),
554 (0.338, 300.0),
555 (0.224, 55.0),
556 (0.510, 750.0),
557 (0.264, 140.0),
558 ] {
559 let l = len_in(d, m);
560 println!(".{d}/{m}gr -> {l:.4}\" (L/d {:.3})", l / d);
561 }
562 println!("\n=== MBA-1135 default_twist_inches (SG=1.5 vs 2.0) ===");
563 for (d, m, v) in [
564 (0.308, 175.0, 2600.0),
565 (0.224, 55.0, 3240.0),
566 (0.224, 77.0, 2750.0),
567 (0.264, 140.0, 2700.0),
568 ] {
569 let t20 = twist_in(d, m, v);
571 let t15 = t20 * (DEFAULT_TWIST_SG_TARGET / 1.5_f64).sqrt();
572 println!(".{d}/{m}gr @ {v}fps -> SG1.5: 1:{t15:.2}\" SG2.0: 1:{t20:.2}\"");
573 }
574 println!();
575 }
576
577 #[test]
578 fn test_compute_spin_drift_scaling() {
579 let stability = 2.0;
580 let twist_rate = 10.0;
581
582 let drift_1s = compute_spin_drift(1.0, stability, twist_rate, true);
584 let drift_2s = compute_spin_drift(2.0, stability, twist_rate, true);
585
586 assert!(drift_2s > drift_1s);
588
589 let drift_low_stability = compute_spin_drift(1.5, 1.0, twist_rate, true);
591 let drift_high_stability = compute_spin_drift(1.5, 3.0, twist_rate, true);
592
593 assert!(drift_high_stability > drift_low_stability);
595 }
596}