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 VEL_REF_FPS: f64 = 2800.0;
26 const TEMP_REF_K: f64 = 288.15; const PRESS_REF_HPA: f64 = 1013.25;
28
29 let twist_rate_m = inputs.twist_rate.abs() * 0.0254; let twist_calibers = twist_rate_m / inputs.bullet_diameter;
33 let length_calibers = inputs.bullet_length / inputs.bullet_diameter;
34
35 let mass_grains = inputs.bullet_mass / 0.00006479891; let diameter_inches = inputs.bullet_diameter / 0.0254; let velocity_fps = inputs.muzzle_velocity * 3.28084; let mass_term = MILLER_CONST * mass_grains;
42 let geom_term = twist_calibers.powi(2)
43 * diameter_inches.powi(3)
44 * length_calibers
45 * (1.0 + length_calibers.powi(2));
46
47 if geom_term == 0.0 {
48 return 0.0;
49 }
50
51 let (_, temp_c, current_press_hpa, _) = atmo_params;
53 let temp_k = temp_c + 273.15;
54
55 let density_correction = (temp_k / TEMP_REF_K) * (PRESS_REF_HPA / current_press_hpa);
58
59 let velocity_correction = (velocity_fps / VEL_REF_FPS).powf(1.0 / 3.0);
61
62 (mass_term / geom_term) * velocity_correction * density_correction
65}
66
67pub const BULLET_LENGTH_RHO_EFF_KG_M3: f64 = 7600.0;
74
75const MIN_LENGTH_CALIBERS: f64 = 2.5;
78const MAX_LENGTH_CALIBERS: f64 = 6.5;
79
80pub const DEFAULT_TWIST_SG_TARGET: f64 = 2.0;
88
89const GRAINS_PER_KG: f64 = 1.0 / 0.00006479891;
90const METERS_PER_INCH: f64 = 0.0254;
91const MPS_TO_FPS: f64 = 3.28084;
92const MILLER_VEL_REF_FPS: f64 = 2800.0;
93
94pub fn estimate_bullet_length_m(diameter_m: f64, mass_kg: f64) -> f64 {
107 if mass_kg <= 0.0 || diameter_m <= 0.0 || !mass_kg.is_finite() || !diameter_m.is_finite() {
108 return 0.0;
109 }
110
111 let frontal_area = std::f64::consts::FRAC_PI_4 * diameter_m * diameter_m;
112 let raw_length = mass_kg / (BULLET_LENGTH_RHO_EFF_KG_M3 * frontal_area);
113
114 let length_calibers = (raw_length / diameter_m).clamp(MIN_LENGTH_CALIBERS, MAX_LENGTH_CALIBERS);
116 length_calibers * diameter_m
117}
118
119pub fn default_twist_inches(diameter_m: f64, mass_kg: f64, muzzle_velocity_mps: f64) -> f64 {
151 const FALLBACK_TWIST_IN: f64 = 12.0;
152
153 if diameter_m <= 0.0
154 || mass_kg <= 0.0
155 || muzzle_velocity_mps <= 0.0
156 || !diameter_m.is_finite()
157 || !mass_kg.is_finite()
158 || !muzzle_velocity_mps.is_finite()
159 {
160 return FALLBACK_TWIST_IN;
161 }
162
163 let length_m = estimate_bullet_length_m(diameter_m, mass_kg);
164 if length_m <= 0.0 {
165 return FALLBACK_TWIST_IN;
166 }
167
168 let d_in = diameter_m / METERS_PER_INCH;
169 let m_gr = mass_kg * GRAINS_PER_KG;
170 let l_cal = length_m / diameter_m;
171 let v_fps = muzzle_velocity_mps * MPS_TO_FPS;
172 let velocity_correction = (v_fps / MILLER_VEL_REF_FPS).powf(1.0 / 3.0);
173
174 let geom = d_in.powi(3) * l_cal * (1.0 + l_cal * l_cal);
175 let denom = DEFAULT_TWIST_SG_TARGET * geom;
176 if denom <= 0.0 {
177 return FALLBACK_TWIST_IN;
178 }
179
180 let t_cal_sq = 30.0 * m_gr * velocity_correction / denom;
181 if !t_cal_sq.is_finite() || t_cal_sq <= 0.0 {
182 return FALLBACK_TWIST_IN;
183 }
184
185 let twist_in = t_cal_sq.sqrt() * d_in;
186 if twist_in.is_finite() && twist_in > 0.0 {
187 twist_in
188 } else {
189 FALLBACK_TWIST_IN
190 }
191}
192
193pub fn compute_spin_drift(
204 time_s: f64,
205 stability: f64,
206 twist_rate: f64,
207 is_twist_right: bool,
208) -> f64 {
209 compute_spin_drift_with_decay(time_s, stability, twist_rate, is_twist_right, None)
210}
211
212pub fn compute_spin_drift_with_decay(
214 time_s: f64,
215 stability: f64,
216 twist_rate: f64,
217 is_twist_right: bool,
218 decay_factor: Option<f64>, ) -> f64 {
220 if stability == 0.0 || time_s <= 0.0 || twist_rate == 0.0 {
221 return 0.0;
222 }
223
224 let sign = if is_twist_right { 1.0 } else { -1.0 };
225
226 let scaling_factor = 1.25;
229 let base_drift = sign * scaling_factor * (stability + 1.2) * time_s.powf(1.83);
230
231 let effective_drift = if let Some(decay) = decay_factor {
233 base_drift * decay.max(0.0).min(1.0)
234 } else {
235 base_drift
236 };
237
238 effective_drift * 0.0254
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 fn create_test_inputs() -> BallisticInputs {
247 BallisticInputs {
248 muzzle_velocity: 823.0, bc_value: 0.5,
250 bullet_mass: 0.0109, bullet_diameter: 0.00782, bullet_length: 0.033, twist_rate: 10.0,
254 ..Default::default()
255 }
256 }
257
258 #[test]
259 fn test_compute_stability_coefficient() {
260 let inputs = create_test_inputs();
261 let atmo_params = (0.0, 15.0, 1013.25, 1.0); let stability = compute_stability_coefficient(&inputs, atmo_params);
264
265 println!("Computed stability: {}", stability);
267
268 assert!(stability > 0.0);
270 assert!(stability < 10.0); assert!(stability > 1.0);
274 assert!(stability < 3.0);
275 }
276
277 #[test]
278 fn test_compute_stability_coefficient_zero_cases() {
279 let mut inputs = create_test_inputs();
280 let atmo_params = (0.0, 15.0, 1013.25, 1.0);
281
282 inputs.twist_rate = 0.0;
284 assert_eq!(compute_stability_coefficient(&inputs, atmo_params), 0.0);
285
286 inputs = create_test_inputs();
288 inputs.bullet_length = 0.0;
289 assert_eq!(compute_stability_coefficient(&inputs, atmo_params), 0.0);
290
291 inputs = create_test_inputs();
293 inputs.bullet_diameter = 0.0;
294 assert_eq!(compute_stability_coefficient(&inputs, atmo_params), 0.0);
295 }
296
297 #[test]
298 fn test_compute_stability_coefficient_atmospheric_effects() {
299 let inputs = create_test_inputs();
300
301 let standard_atmo = (0.0, 15.0, 1013.25, 1.0);
303 let standard_stability = compute_stability_coefficient(&inputs, standard_atmo);
304
305 let high_alt_atmo = (3000.0, 5.0, 900.0, 1.0);
307 let high_alt_stability = compute_stability_coefficient(&inputs, high_alt_atmo);
308
309 assert!(high_alt_stability > standard_stability);
311
312 let hot_atmo = (0.0, 35.0, 1013.25, 1.0);
314 let hot_stability = compute_stability_coefficient(&inputs, hot_atmo);
315
316 assert!(hot_stability > standard_stability);
318 }
319
320 #[test]
321 fn test_compute_spin_drift() {
322 let time_s = 1.5;
323 let stability = 2.0;
324 let twist_rate = 10.0;
325
326 let drift_right = compute_spin_drift(time_s, stability, twist_rate, true);
328 assert!(drift_right > 0.0); let drift_left = compute_spin_drift(time_s, stability, twist_rate, false);
332 assert!(drift_left < 0.0); assert!((drift_left + drift_right).abs() < 1e-10); assert!(drift_right.abs() < 0.25); }
338
339 #[test]
340 fn test_compute_spin_drift_zero_cases() {
341 assert_eq!(compute_spin_drift(1.5, 0.0, 10.0, true), 0.0);
343
344 assert_eq!(compute_spin_drift(0.0, 2.0, 10.0, true), 0.0);
346
347 assert_eq!(compute_spin_drift(-1.0, 2.0, 10.0, true), 0.0);
349
350 assert_eq!(compute_spin_drift(1.5, 2.0, 0.0, true), 0.0);
352 }
353
354 const GR_TO_KG: f64 = 0.00006479891;
357 const IN_TO_M: f64 = 0.0254;
358
359 fn len_in(diameter_in: f64, mass_gr: f64) -> f64 {
360 estimate_bullet_length_m(diameter_in * IN_TO_M, mass_gr * GR_TO_KG) / IN_TO_M
361 }
362
363 fn twist_in(diameter_in: f64, mass_gr: f64, v_fps: f64) -> f64 {
364 default_twist_inches(diameter_in * IN_TO_M, mass_gr * GR_TO_KG, v_fps * 0.3048)
365 }
366
367 #[test]
368 fn test_estimate_bullet_length_reference_bullets() {
369 let cases = [
371 (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), ];
377 for (d, m, expected, tol) in cases {
378 let got = len_in(d, m);
379 assert!(
380 (got - expected).abs() < tol,
381 ".{d}/{m}gr length: expected ~{expected}\", got {got:.4}\" (L/d {:.2})",
382 got / d
383 );
384 }
385 }
386
387 #[test]
388 fn test_estimate_bullet_length_degenerate_inputs() {
389 assert_eq!(estimate_bullet_length_m(0.00782, 0.0), 0.0);
390 assert_eq!(estimate_bullet_length_m(0.00782, -1.0), 0.0);
391 assert_eq!(estimate_bullet_length_m(0.0, 0.011), 0.0);
392 assert_eq!(estimate_bullet_length_m(-0.1, 0.011), 0.0);
393 assert_eq!(estimate_bullet_length_m(f64::NAN, 0.011), 0.0);
394 }
395
396 #[test]
397 fn test_estimate_bullet_length_clamps_ld_ratio() {
398 let d = 0.172 * IN_TO_M;
400 let long = estimate_bullet_length_m(d, 0.05); assert!((long / d - 6.5).abs() < 1e-9, "expected clamp at 6.5 cal, got {}", long / d);
402 let short = estimate_bullet_length_m(0.510 * IN_TO_M, 0.001);
404 assert!(
405 (short / (0.510 * IN_TO_M) - 2.5).abs() < 1e-9,
406 "expected clamp at 2.5 cal, got {}",
407 short / (0.510 * IN_TO_M)
408 );
409 }
410
411 #[test]
412 fn test_default_twist_reference_bullets() {
413 let cases = [
416 (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), ];
421 for (d, m, v, expected, tol) in cases {
422 let got = twist_in(d, m, v);
423 assert!(got > 0.0, ".{d}/{m}gr twist must be positive, got {got}");
424 assert!(
425 (got - expected).abs() < tol,
426 ".{d}/{m}gr twist: expected ~1:{expected}\", got 1:{got:.2}\"",
427 );
428 }
429 }
430
431 #[test]
432 fn test_default_twist_yields_target_sg() {
433 let d_m = 0.308 * IN_TO_M;
436 let m_kg = 175.0 * GR_TO_KG;
437 let v_mps = 2600.0 * 0.3048;
438 let twist = default_twist_inches(d_m, m_kg, v_mps);
439 let inputs = BallisticInputs {
440 muzzle_velocity: v_mps,
441 bullet_mass: m_kg,
442 bullet_diameter: d_m,
443 bullet_length: estimate_bullet_length_m(d_m, m_kg),
444 twist_rate: twist,
445 ..Default::default()
446 };
447 let sg = compute_stability_coefficient(&inputs, (0.0, 15.0, 1013.25, 1.0));
448 assert!(
449 (sg - DEFAULT_TWIST_SG_TARGET).abs() < 0.02,
450 "expected Sg ~{DEFAULT_TWIST_SG_TARGET}, got {sg}"
451 );
452 }
453
454 #[test]
455 fn test_default_twist_degenerate_inputs_fall_back() {
456 assert_eq!(default_twist_inches(0.0, 0.011, 800.0), 12.0);
457 assert_eq!(default_twist_inches(0.00782, 0.0, 800.0), 12.0);
458 assert_eq!(default_twist_inches(0.00782, 0.011, 0.0), 12.0);
459 assert_eq!(default_twist_inches(f64::NAN, 0.011, 800.0), 12.0);
460 }
461
462 #[test]
463 fn test_heavier_bullet_needs_faster_twist() {
464 let light = twist_in(0.224, 55.0, 2900.0);
466 let heavy = twist_in(0.224, 77.0, 2900.0);
467 assert!(heavy < light, "77gr twist {heavy} should be faster than 55gr {light}");
468 }
469
470 #[test]
471 fn test_print_reference_estimates() {
472 println!("\n=== MBA-1135 estimate_bullet_length_m ===");
474 for (d, m) in [
475 (0.308, 175.0),
476 (0.224, 77.0),
477 (0.338, 300.0),
478 (0.224, 55.0),
479 (0.510, 750.0),
480 (0.264, 140.0),
481 ] {
482 let l = len_in(d, m);
483 println!(".{d}/{m}gr -> {l:.4}\" (L/d {:.3})", l / d);
484 }
485 println!("\n=== MBA-1135 default_twist_inches (SG=1.5 vs 2.0) ===");
486 for (d, m, v) in [
487 (0.308, 175.0, 2600.0),
488 (0.224, 55.0, 3240.0),
489 (0.224, 77.0, 2750.0),
490 (0.264, 140.0, 2700.0),
491 ] {
492 let t20 = twist_in(d, m, v);
494 let t15 = t20 * (DEFAULT_TWIST_SG_TARGET / 1.5_f64).sqrt();
495 println!(".{d}/{m}gr @ {v}fps -> SG1.5: 1:{t15:.2}\" SG2.0: 1:{t20:.2}\"");
496 }
497 println!();
498 }
499
500 #[test]
501 fn test_compute_spin_drift_scaling() {
502 let stability = 2.0;
503 let twist_rate = 10.0;
504
505 let drift_1s = compute_spin_drift(1.0, stability, twist_rate, true);
507 let drift_2s = compute_spin_drift(2.0, stability, twist_rate, true);
508
509 assert!(drift_2s > drift_1s);
511
512 let drift_low_stability = compute_spin_drift(1.5, 1.0, twist_rate, true);
514 let drift_high_stability = compute_spin_drift(1.5, 3.0, twist_rate, true);
515
516 assert!(drift_high_stability > drift_low_stability);
518 }
519}