1#[derive(Debug, Clone)]
12pub struct SpinDriftCoefficients {
13 pub litz_coefficient: f64,
15 pub mccoy_jump_factor: f64,
17 pub transonic_factor: f64,
19 pub yaw_damping: f64,
21}
22
23impl SpinDriftCoefficients {
24 pub fn for_bullet_type(bullet_type: &str) -> Self {
26 match bullet_type.to_lowercase().as_str() {
27 "match" | "bthp" | "boat_tail" => Self {
28 litz_coefficient: 1.25,
29 mccoy_jump_factor: 0.85,
30 transonic_factor: 0.75,
31 yaw_damping: 0.92,
32 },
33 "vld" | "very_low_drag" => Self {
34 litz_coefficient: 1.15,
35 mccoy_jump_factor: 0.78,
36 transonic_factor: 0.68,
37 yaw_damping: 0.88,
38 },
39 "hybrid" | "hybrid_ogive" => Self {
40 litz_coefficient: 1.20,
41 mccoy_jump_factor: 0.82,
42 transonic_factor: 0.72,
43 yaw_damping: 0.90,
44 },
45 "flat_base" | "fb" => Self {
46 litz_coefficient: 1.35,
47 mccoy_jump_factor: 0.95,
48 transonic_factor: 0.85,
49 yaw_damping: 0.95,
50 },
51 _ => Self::default(),
52 }
53 }
54
55 #[allow(clippy::should_implement_trait)] pub fn default() -> Self {
61 <Self as Default>::default()
62 }
63}
64
65impl Default for SpinDriftCoefficients {
66 fn default() -> Self {
67 Self {
68 litz_coefficient: 1.25,
69 mccoy_jump_factor: 0.85,
70 transonic_factor: 0.75,
71 yaw_damping: 0.92,
72 }
73 }
74}
75
76#[allow(clippy::too_many_arguments)] pub fn calculate_advanced_spin_drift(
86 stability_factor: f64,
87 time_of_flight_s: f64,
88 _velocity_mps: f64,
89 muzzle_velocity_mps: f64,
90 _spin_rate_rad_s: f64,
91 _caliber_m: f64,
92 _mass_kg: f64,
93 air_density_kg_m3: f64,
94 is_right_twist: bool,
95 _bullet_type: &str,
96) -> f64 {
97 if !stability_factor.is_finite()
98 || stability_factor <= 1.0
99 || !time_of_flight_s.is_finite()
100 || time_of_flight_s <= 0.0
101 || !muzzle_velocity_mps.is_finite()
102 || muzzle_velocity_mps <= 0.0
103 || !air_density_kg_m3.is_finite()
104 || air_density_kg_m3 <= 0.0
105 {
106 return 0.0;
107 }
108
109 crate::spin_drift::litz_drift_meters(stability_factor, time_of_flight_s, is_right_twist)
110}
111
112#[deprecated(
121 since = "0.22.18",
122 note = "use precession_nutation::calculate_limit_cycle_yaw_with_inertias"
123)]
124pub fn calculate_advanced_yaw_of_repose(
125 stability_factor: f64,
126 velocity_mps: f64,
127 _crosswind_mps: f64,
128 spin_rate_rad_s: f64,
129 _air_density_kg_m3: f64,
130 _caliber_m: f64,
131) -> f64 {
132 let reference = crate::precession_nutation::PrecessionNutationParams::default();
133 crate::precession_nutation::calculate_limit_cycle_yaw_with_inertias(
134 velocity_mps,
135 spin_rate_rad_s,
136 stability_factor,
137 reference.spin_inertia,
138 reference.transverse_inertia,
139 )
140}
141
142pub fn apply_ml_correction(
144 base_drift: f64,
145 stability: f64,
146 mach: f64,
147 time_s: f64,
148 caliber_inches: f64,
149 mass_grains: f64,
150) -> f64 {
151 let mut correction = 1.0;
162
163 if stability > 2.5 && mach < 1.0 {
165 correction *= 0.92; }
167
168 if time_s > 2.0 && mach < 0.9 {
169 correction *= 1.08; }
171
172 if caliber_inches < 0.264 && mass_grains < 100.0 {
173 correction *= 0.88; }
175
176 base_drift * correction
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn test_advanced_spin_drift() {
185 let drift = calculate_advanced_spin_drift(
187 1.5, 1.2, 600.0, 850.0, 1500.0, 0.00308, 0.0108, 1.225, true, "match", );
198
199 assert!(drift > 0.0);
201 assert!(drift < 0.3); }
203
204 #[test]
205 fn advanced_spin_drift_matches_canonical_litz_total() {
206 let cases = [
207 (1.50, 0.25, 850.0, 850.0),
208 (1.74, 1.75, 440.0, 800.0),
209 (1.90, 1.40, 343.0, 850.0),
210 ];
211
212 for (stability, time, velocity, muzzle_velocity) in cases {
213 for is_right_twist in [false, true] {
214 let expected =
215 crate::spin_drift::litz_drift_meters(stability, time, is_right_twist);
216 let actual = calculate_advanced_spin_drift(
217 stability,
218 time,
219 velocity,
220 muzzle_velocity,
221 0.0,
222 0.308 * 0.0254,
223 175.0 * crate::constants::GRAINS_TO_KG,
224 1.225,
225 is_right_twist,
226 "match",
227 );
228
229 assert_eq!(
230 actual.to_bits(),
231 expected.to_bits(),
232 "advanced API changed calibrated Litz total: actual={actual} expected={expected}"
233 );
234 }
235 }
236 }
237
238 #[test]
239 fn litz_total_ignores_legacy_refinement_arguments() {
240 let stability = 1.74;
241 let time = 1.75;
242 let expected = crate::spin_drift::litz_drift_meters(stability, time, true);
243 let legacy_states = [
244 (440.0, 800.0, 0.0, 0.00782, 0.01134, 1.225, "match"),
245 (343.0, 900.0, 19_000.0, 0.00556, 0.00500, 1.000, "vld"),
246 (
247 100.0,
248 700.0,
249 -25_000.0,
250 0.01270,
251 0.04860,
252 0.900,
253 "flat_base",
254 ),
255 (
256 1_000.0, 1_200.0, 2_000.0, 0.02000, 0.10000, 1.400, "unknown",
257 ),
258 ];
259
260 for (velocity, muzzle_velocity, spin, caliber, mass, density, bullet_type) in legacy_states
261 {
262 let actual = calculate_advanced_spin_drift(
263 stability,
264 time,
265 velocity,
266 muzzle_velocity,
267 spin,
268 caliber,
269 mass,
270 density,
271 true,
272 bullet_type,
273 );
274 assert_eq!(actual.to_bits(), expected.to_bits());
275 }
276 }
277
278 #[test]
279 fn advanced_spin_drift_has_no_fixed_jump_intercept() {
280 let time = 1e-9;
281 let expected = crate::spin_drift::litz_drift_meters(1.74, time, true);
282 let actual = calculate_advanced_spin_drift(
283 1.74,
284 time,
285 800.0,
286 800.0,
287 17_000.0,
288 0.308 * 0.0254,
289 175.0 * crate::constants::GRAINS_TO_KG,
290 1.225,
291 true,
292 "match",
293 );
294
295 assert_eq!(actual.to_bits(), expected.to_bits());
296 }
297
298 #[test]
299 fn test_spin_drift_direction() {
300 let right_drift = calculate_advanced_spin_drift(
302 1.5, 1.0, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 1.225, true, "match",
303 );
304
305 let left_drift = calculate_advanced_spin_drift(
307 1.5, 1.0, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 1.225, false, "match",
308 );
309
310 assert!(right_drift > 0.0, "Right twist should give positive drift");
311 assert!(left_drift < 0.0, "Left twist should give negative drift");
312 assert!(
313 (right_drift.abs() - left_drift.abs()).abs() < 0.001,
314 "Magnitude should be equal"
315 );
316 }
317
318 #[test]
319 fn test_spin_drift_edge_cases() {
320 let zero_time = calculate_advanced_spin_drift(
322 1.5, 0.0, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 1.225, true, "match",
323 );
324 assert_eq!(zero_time, 0.0);
325
326 let zero_stability = calculate_advanced_spin_drift(
328 0.0, 1.0, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 1.225, true, "match",
329 );
330 assert_eq!(zero_stability, 0.0);
331
332 let zero_muzzle_vel = calculate_advanced_spin_drift(
334 1.5, 1.0, 700.0, 0.0, 1500.0, 0.00308, 0.0108, 1.225, true, "match",
335 );
336 assert_eq!(zero_muzzle_vel, 0.0);
337
338 let zero_density = calculate_advanced_spin_drift(
340 1.5, 1.0, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 0.0, true, "match",
341 );
342 assert_eq!(zero_density, 0.0);
343 }
344
345 #[test]
346 fn test_spin_drift_coefficients_bullet_types() {
347 let match_coeffs = SpinDriftCoefficients::for_bullet_type("match");
348 let vld_coeffs = SpinDriftCoefficients::for_bullet_type("vld");
349 let flat_base_coeffs = SpinDriftCoefficients::for_bullet_type("flat_base");
350 let default_coeffs = SpinDriftCoefficients::for_bullet_type("unknown");
351
352 assert!(vld_coeffs.litz_coefficient < match_coeffs.litz_coefficient);
354
355 assert!(flat_base_coeffs.litz_coefficient > match_coeffs.litz_coefficient);
357
358 assert_eq!(
360 default_coeffs.litz_coefficient,
361 match_coeffs.litz_coefficient
362 );
363 }
364
365 #[test]
366 fn test_spin_drift_increases_with_time() {
367 let drift_short = calculate_advanced_spin_drift(
368 1.5, 0.5, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 1.225, true, "match",
369 );
370 let drift_medium = calculate_advanced_spin_drift(
371 1.5, 1.0, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 1.225, true, "match",
372 );
373 let drift_long = calculate_advanced_spin_drift(
374 1.5, 2.0, 700.0, 850.0, 1500.0, 0.00308, 0.0108, 1.225, true, "match",
375 );
376
377 assert!(
378 drift_medium > drift_short,
379 "Drift should increase with time"
380 );
381 assert!(drift_long > drift_medium, "Drift should increase with time");
382 }
383
384 #[test]
385 #[allow(deprecated)]
386 fn test_advanced_yaw_of_repose() {
387 let yaw = calculate_advanced_yaw_of_repose(
388 1.5, 800.0, 5.0, 1500.0, 1.225, 0.00782, );
395
396 assert!(yaw.abs() < 0.1, "Yaw should be small angle, got {}", yaw);
398 }
399
400 #[test]
401 #[allow(deprecated)]
402 fn advanced_yaw_of_repose_matches_gravity_gyroscopic_scaling() {
403 let calculate = |stability, velocity, crosswind, spin, density, caliber| {
404 calculate_advanced_yaw_of_repose(stability, velocity, crosswind, spin, density, caliber)
405 };
406
407 let reference = crate::precession_nutation::PrecessionNutationParams::default();
408 let expected = crate::precession_nutation::calculate_limit_cycle_yaw_with_inertias(
409 850.0,
410 17_522.0,
411 2.5,
412 reference.spin_inertia,
413 reference.transverse_inertia,
414 );
415 let actual = calculate(2.5, 850.0, 0.0, 17_522.0, 1.225, 0.00782);
416 assert_eq!(actual.to_bits(), expected.to_bits());
417
418 for crosswind in [-10.0, 10.0] {
419 assert_eq!(
420 calculate(2.5, 850.0, crosswind, 17_522.0, 1.225, 0.00782).to_bits(),
421 actual.to_bits()
422 );
423 }
424
425 let fixed_sg_fast = calculate(2.5, 800.0, 0.0, 17_522.0, 1.225, 0.00782);
426 let fixed_sg_slow = calculate(2.5, 400.0, 0.0, 17_522.0, 1.225, 0.00782);
427 assert!((fixed_sg_slow / fixed_sg_fast - 2.0).abs() < 2e-12);
428
429 let physical_fast = calculate(1.5, 800.0, 0.0, 17_522.0, 1.225, 0.00782);
430 let physical_slow = calculate(6.0, 400.0, 0.0, 17_522.0, 1.225, 0.00782);
431 assert!((physical_slow / physical_fast - 8.0).abs() < 8e-12);
432
433 let dense = calculate(1.5, 300.0, 0.0, 17_522.0, 1.225, 0.00782);
434 let thin_same_sg = calculate(1.5, 300.0, 0.0, 17_522.0, 0.6125, 0.00782);
435 let thin_coupled_sg = calculate(3.0, 300.0, 0.0, 17_522.0, 0.6125, 0.00782);
436 assert_eq!(thin_same_sg.to_bits(), dense.to_bits());
437 assert!((thin_coupled_sg / dense - 2.0).abs() < 2e-12);
438 }
439
440 #[test]
441 #[allow(deprecated)]
442 fn test_yaw_of_repose_edge_cases() {
443 let zero_stability =
445 calculate_advanced_yaw_of_repose(0.5, 800.0, 5.0, 1500.0, 1.225, 0.00782);
446 assert_eq!(zero_stability, 0.0);
447
448 let zero_velocity = calculate_advanced_yaw_of_repose(1.5, 0.0, 5.0, 1500.0, 1.225, 0.00782);
450 assert_eq!(zero_velocity, 0.0);
451
452 let zero_spin = calculate_advanced_yaw_of_repose(1.5, 800.0, 5.0, 0.0, 1.225, 0.00782);
453 assert_eq!(zero_spin, 0.0);
454
455 let positive_spin =
456 calculate_advanced_yaw_of_repose(1.5, 800.0, 5.0, 1500.0, 1.225, 0.00782);
457 let negative_spin =
458 calculate_advanced_yaw_of_repose(1.5, 800.0, 5.0, -1500.0, 1.225, 0.00782);
459 assert_eq!(negative_spin.to_bits(), positive_spin.to_bits());
460
461 let at_boundary = calculate_advanced_yaw_of_repose(1.0, 800.0, 0.0, 1500.0, 1.225, 0.00782);
462 let below_boundary = calculate_advanced_yaw_of_repose(
463 1.0_f64.next_down(),
464 800.0,
465 0.0,
466 1500.0,
467 1.225,
468 0.00782,
469 );
470 assert!(at_boundary > 0.0);
471 assert_eq!(below_boundary, 0.0);
472
473 let no_wind = calculate_advanced_yaw_of_repose(1.5, 800.0, 0.0, 1500.0, 1.225, 0.00782);
475 assert!(
476 no_wind > 0.0,
477 "Should have natural yaw from trajectory curvature"
478 );
479 }
480
481 #[test]
482 fn test_ml_correction_placeholder() {
483 let base_drift = 0.1;
485 let corrected = apply_ml_correction(base_drift, 1.5, 2.5, 1.0, 0.308, 168.0);
486
487 assert!(corrected > 0.0);
489
490 let over_stab_subsonic = apply_ml_correction(0.1, 3.0, 0.8, 1.0, 0.308, 168.0);
493 assert!(
494 over_stab_subsonic < 0.1,
495 "Over-stabilized subsonic should drift less"
496 );
497
498 let long_subsonic = apply_ml_correction(0.1, 1.5, 0.85, 2.5, 0.308, 168.0);
500 assert!(
501 long_subsonic > 0.1,
502 "Long subsonic flight should need more correction"
503 );
504
505 let light_small = apply_ml_correction(0.1, 1.5, 2.5, 1.0, 0.224, 55.0);
507 assert!(light_small < 0.1, "Light small caliber should drift less");
508 }
509}