1use crate::{finite_vec, positive, Error, FlightState, ForceModel, Result, Vec3};
11use ballistics_engine::{
12 atmosphere::{calculate_air_density_cimp, AtmoSock},
13 constants::GRAINS_TO_KG,
14 derivatives::compute_derivatives,
15 drag::DragTable,
16 wind::{WindSegment, WindSock},
17 BallisticInputs,
18};
19
20#[derive(Debug, Clone, Copy)]
22pub enum Atmosphere {
23 Standard {
26 station_altitude_m: f64,
28 temperature_c: f64,
30 pressure_hpa: f64,
32 humidity_percent: f64,
34 },
35 Uniform {
38 density_kg_m3: f64,
40 speed_of_sound_mps: f64,
42 },
43}
44
45impl Default for Atmosphere {
46 fn default() -> Self {
47 Self::Standard {
48 station_altitude_m: 0.0,
49 temperature_c: 15.0,
50 pressure_hpa: 1013.25,
51 humidity_percent: 0.0,
52 }
53 }
54}
55
56impl Atmosphere {
57 pub(crate) fn parameters(self) -> Result<(f64, f64, f64, f64)> {
58 match self {
59 Self::Standard {
60 station_altitude_m,
61 temperature_c,
62 pressure_hpa,
63 humidity_percent,
64 } => {
65 if !station_altitude_m.is_finite()
66 || !temperature_c.is_finite()
67 || temperature_c <= -273.15
68 || !humidity_percent.is_finite()
69 || !(0.0..=100.0).contains(&humidity_percent)
70 {
71 return Err(Error::invalid("standard atmosphere", "requires finite altitude, temperature above absolute zero and humidity in [0, 100]"));
72 }
73 positive(pressure_hpa, "pressure_hpa")?;
74 let ratio =
75 calculate_air_density_cimp(temperature_c, pressure_hpa, humidity_percent)
76 / 1.225;
77 positive(ratio, "station density ratio")?;
78 Ok((station_altitude_m, temperature_c, pressure_hpa, ratio))
79 }
80 Self::Uniform {
81 density_kg_m3,
82 speed_of_sound_mps,
83 } => {
84 if !density_kg_m3.is_finite()
85 || !(0.0..2.0).contains(&density_kg_m3)
86 || !speed_of_sound_mps.is_finite()
87 || speed_of_sound_mps <= 200.0
88 {
89 return Err(Error::invalid(
90 "uniform atmosphere",
91 "upstream requires density in [0, 2) and sound speed > 200 m/s",
92 ));
93 }
94 Ok((density_kg_m3, speed_of_sound_mps, 0.0, 0.0))
95 }
96 }
97 }
98}
99
100pub(crate) fn local_air(
103 atmosphere: Atmosphere,
104 atmos_params: (f64, f64, f64, f64),
105 altitude_m: f64,
106) -> (f64, f64) {
107 match atmosphere {
108 Atmosphere::Standard { .. } => {
109 let (base_alt, temp_c, press_hpa, ratio) = atmos_params;
110 ballistics_engine::atmosphere::get_local_atmosphere(
111 altitude_m, base_alt, temp_c, press_hpa, ratio,
112 )
113 }
114 Atmosphere::Uniform { .. } => {
115 let (density, speed_of_sound, _, _) = atmos_params;
116 (density, speed_of_sound)
117 }
118 }
119}
120
121pub(crate) fn mach_table(pairs: Vec<(f64, f64)>, name: &'static str) -> Result<DragTable> {
124 if pairs.len() < 2 {
125 return Err(Error::invalid(name, "needs at least two Mach points"));
126 }
127 for (i, (mach, value)) in pairs.iter().enumerate() {
128 if !mach.is_finite() || *mach < 0.0 {
129 return Err(Error::invalid(name, "Mach must be finite and nonnegative"));
130 }
131 if i > 0 && *mach <= pairs[i - 1].0 {
132 return Err(Error::invalid(name, "Mach must strictly ascend"));
133 }
134 if !value.is_finite() || *value < 0.0 {
135 return Err(Error::invalid(
136 name,
137 "values must be finite and nonnegative",
138 ));
139 }
140 }
141 let (mach_values, cd_values) = pairs.into_iter().unzip();
142 Ok(DragTable::new(mach_values, cd_values))
143}
144
145#[derive(Debug, Clone)]
147pub struct BallisticForces {
148 inputs: BallisticInputs,
149 atmos_params: (f64, f64, f64, f64),
150 atmosphere: Atmosphere,
151 wind_mps: Vec3,
152 wind_field: Option<SharedWindField>,
155 turbulence: Option<Turbulence>,
156 omega_rps: Option<Vec3>,
157 body_lift: Option<BodyLift>,
158 atmo_sock: Option<AtmoSock>,
159 wind_sock: Option<WindSock>,
160}
161
162#[derive(Clone)]
166pub(crate) struct SharedWindField(
167 pub(crate) std::sync::Arc<dyn winds_engine::WindField + Send + Sync>,
168);
169
170impl std::fmt::Debug for SharedWindField {
171 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172 f.write_str("SharedWindField(..)")
173 }
174}
175
176const TURBULENCE_COMPONENTS: usize = 8;
178
179#[derive(Debug, Clone)]
183pub(crate) struct Turbulence {
184 axes: [[(f64, f64, f64); TURBULENCE_COMPONENTS]; 3],
186}
187
188impl Turbulence {
189 fn hash01(mut z: u64) -> f64 {
191 z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
192 z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
193 let z = z ^ (z >> 31);
194 (z >> 11) as f64 / (1u64 << 53) as f64
195 }
196
197 pub(crate) fn new(sigma_mps: f64, tau_s: f64, seed: u64) -> Result<Self> {
200 if !sigma_mps.is_finite() || sigma_mps < 0.0 {
201 return Err(Error::invalid("turbulence sigma", "must be nonnegative"));
202 }
203 if !tau_s.is_finite() || tau_s <= 0.0 {
204 return Err(Error::invalid("turbulence tau", "must be positive"));
205 }
206 let lo = 0.1 / tau_s;
207 let ratio = 100.0_f64;
208 let step = ratio.powf(1.0 / (TURBULENCE_COMPONENTS - 1) as f64);
209 let norm: f64 = (0..TURBULENCE_COMPONENTS)
210 .map(|i| 1.0 / (lo * step.powi(i as i32)))
211 .sum();
212 let scale = (2.0 * sigma_mps * sigma_mps / norm).sqrt();
213 let mut axes = [[(0.0, 0.0, 0.0); TURBULENCE_COMPONENTS]; 3];
214 for (axis, comps) in axes.iter_mut().enumerate() {
215 for (i, comp) in comps.iter_mut().enumerate() {
216 let omega = lo * step.powi(i as i32);
217 let amplitude = scale / omega.sqrt();
218 let phase = 2.0
219 * std::f64::consts::PI
220 * Self::hash01(
221 seed ^ 0x9E3779B97F4A7C15u64.wrapping_mul(1 + axis as u64 * 64 + i as u64),
222 );
223 *comp = (omega, amplitude, phase);
224 }
225 }
226 Ok(Self { axes })
227 }
228
229 pub(crate) fn gust_vec(&self, t: f64) -> Vec3 {
230 Vec3::new(
231 self.axes[0]
232 .iter()
233 .map(|(w, a, p)| a * (w * t + p).sin())
234 .sum(),
235 self.axes[1]
236 .iter()
237 .map(|(w, a, p)| a * (w * t + p).sin())
238 .sum(),
239 self.axes[2]
240 .iter()
241 .map(|(w, a, p)| a * (w * t + p).sin())
242 .sum(),
243 )
244 }
245}
246
247#[derive(Debug, Clone)]
250struct BodyLift {
251 cna_per_rad: DragTable,
253 induced_k_per_rad2: DragTable,
255}
256
257const OMEGA_EARTH_RPS: f64 = 7.2921159e-5;
259
260impl BallisticForces {
261 pub fn new(
265 diameter_m: f64,
266 cd_vs_mach: Vec<(f64, f64)>,
267 atmosphere: Atmosphere,
268 wind_mps: Vec3,
269 ) -> Result<Self> {
270 positive(diameter_m, "diameter_m")?;
271 let diameter_in = diameter_m / 0.0254;
272 positive(diameter_in * diameter_in, "diameter area")?;
273 if !finite_vec(&wind_mps) {
274 return Err(Error::invalid("wind_mps", "must be finite"));
275 }
276 let (mach, cd) = cd_vs_mach.into_iter().unzip();
277 let drag = DragTable::try_new(mach, cd).map_err(Error::Model)?;
278 let inputs = BallisticInputs {
279 bullet_diameter: diameter_m,
280 caliber_inches: diameter_in,
281 bullet_length: diameter_m,
282 custom_drag_table: Some(drag),
283 custom_drag_reference: false,
284 altitude: 0.0,
285 shooting_angle: 0.0,
286 enable_advanced_effects: false,
287 enable_magnus: false,
288 enable_coriolis: false,
289 use_enhanced_spin_drift: false,
290 tipoff_yaw: 0.0,
291 ..BallisticInputs::default()
292 };
293 Ok(Self {
294 inputs,
295 atmos_params: atmosphere.parameters()?,
296 atmosphere,
297 wind_mps,
298 wind_field: None,
299 turbulence: None,
300 omega_rps: None,
301 body_lift: None,
302 atmo_sock: None,
303 wind_sock: None,
304 })
305 }
306
307 pub fn with_drag_scale(mut self, scale: f64) -> Result<Self> {
309 positive(scale, "drag scale")?;
310 if self
311 .inputs
312 .custom_drag_table
313 .as_ref()
314 .unwrap()
315 .cd_values
316 .iter()
317 .any(|cd| !(cd * scale).is_finite())
318 {
319 return Err(Error::invalid("drag scale", "scaled Cd must remain finite"));
320 }
321 self.inputs.cd_scale = scale;
322 Ok(self)
323 }
324
325 pub fn set_wind(&mut self, wind_mps: Vec3) -> Result<()> {
327 if !finite_vec(&wind_mps) {
328 return Err(Error::invalid("wind_mps", "must be finite"));
329 }
330 self.wind_mps = wind_mps;
331 Ok(())
332 }
333
334 pub fn with_spin(
344 mut self,
345 twist_rate_in: f64,
346 body_length_m: f64,
347 right_hand: bool,
348 ) -> Result<Self> {
349 positive(twist_rate_in, "twist_rate_in")?;
350 positive(body_length_m, "body_length_m")?;
351 self.inputs.twist_rate = twist_rate_in;
352 self.inputs.is_twist_right = right_hand;
353 self.inputs.bullet_length = body_length_m;
354 self.inputs.enable_magnus = true;
355 Ok(self)
356 }
357
358 pub fn with_coriolis(mut self, latitude_deg: f64, shot_azimuth_deg: f64) -> Result<Self> {
364 if !latitude_deg.is_finite() || !(-90.0..=90.0).contains(&latitude_deg) {
365 return Err(Error::invalid(
366 "latitude_deg",
367 "must be finite and in [-90, 90]",
368 ));
369 }
370 if !shot_azimuth_deg.is_finite() {
371 return Err(Error::invalid("shot_azimuth_deg", "must be finite"));
372 }
373 let lat = latitude_deg.to_radians();
374 let az = shot_azimuth_deg.to_radians();
375 self.omega_rps = Some(Vec3::new(
376 OMEGA_EARTH_RPS * lat.cos() * az.cos(), OMEGA_EARTH_RPS * lat.sin(), -OMEGA_EARTH_RPS * lat.cos() * az.sin(), ));
380 self.inputs.latitude = Some(latitude_deg);
381 self.inputs.shot_azimuth = az;
382 self.inputs.enable_coriolis = true;
383 Ok(self)
384 }
385
386 pub fn with_tipoff_yaw(mut self, yaw_rad: f64, decay_distance_m: f64) -> Result<Self> {
390 if !yaw_rad.is_finite() {
391 return Err(Error::invalid("tipoff yaw_rad", "must be finite"));
392 }
393 positive(decay_distance_m, "tipoff decay_distance_m")?;
394 self.inputs.tipoff_yaw = yaw_rad;
395 self.inputs.tipoff_decay_distance = decay_distance_m;
396 Ok(self)
397 }
398
399 pub fn with_body_lift(
411 mut self,
412 cna_vs_mach: Vec<(f64, f64)>,
413 induced_k_vs_mach: Vec<(f64, f64)>,
414 ) -> Result<Self> {
415 let table = |pairs: Vec<(f64, f64)>, name: &'static str| -> Result<DragTable> {
416 mach_table(pairs, name)
417 };
418 self.body_lift = Some(BodyLift {
419 cna_per_rad: table(cna_vs_mach, "cna_vs_mach")?,
420 induced_k_per_rad2: table(induced_k_vs_mach, "induced_k_vs_mach")?,
421 });
422 Ok(self)
423 }
424
425 pub fn set_twist(&mut self, twist_rate_in: f64) -> Result<()> {
429 positive(twist_rate_in, "twist_rate_in")?;
430 self.inputs.twist_rate = twist_rate_in;
431 Ok(())
432 }
433
434 pub fn with_atmosphere_zones(mut self, zones: Vec<(f64, f64, f64, f64)>) -> Result<Self> {
440 if !matches!(self.atmosphere, Atmosphere::Standard { .. }) {
441 return Err(Error::invalid(
442 "atmosphere zones",
443 "require the Standard atmosphere, not Uniform",
444 ));
445 }
446 if zones.is_empty() {
447 return Err(Error::invalid(
448 "atmosphere zones",
449 "needs at least one zone",
450 ));
451 }
452 for (temp_c, pressure_hpa, humidity, until_m) in &zones {
453 if !temp_c.is_finite()
454 || !pressure_hpa.is_finite()
455 || *pressure_hpa <= 0.0
456 || !humidity.is_finite()
457 || !(0.0..=100.0).contains(humidity)
458 || !until_m.is_finite()
459 || *until_m <= 0.0
460 {
461 return Err(Error::invalid(
462 "atmosphere zone",
463 "needs finite temperature, positive pressure, humidity in [0, 100] and positive until_m",
464 ));
465 }
466 }
467 self.atmo_sock = Some(AtmoSock::new(zones));
468 Ok(self)
469 }
470
471 pub fn with_wind_segments(mut self, zones: Vec<(f64, f64, f64, f64)>) -> Result<Self> {
478 if zones.is_empty() {
479 return Err(Error::invalid(
480 "wind segments",
481 "needs at least one segment",
482 ));
483 }
484 let segments: Vec<WindSegment> = zones
485 .into_iter()
486 .map(
487 |(speed_mps, angle_deg, until_m, vertical_mps)| WindSegment {
488 speed_kmh: speed_mps * 3.6,
489 angle_deg,
490 until_m,
491 vertical_mps,
492 },
493 )
494 .collect();
495 let sock = WindSock::try_new(segments)
496 .map_err(|e| Error::Model(format!("invalid wind segments: {e:?}")))?;
497 self.wind_sock = Some(sock);
498 Ok(self)
499 }
500
501 pub fn with_turbulence(mut self, sigma_mps: f64, tau_s: f64, seed: u64) -> Result<Self> {
506 self.turbulence = Some(Turbulence::new(sigma_mps, tau_s, seed)?);
507 Ok(self)
508 }
509
510 pub fn with_wind_field(
515 mut self,
516 field: std::sync::Arc<dyn winds_engine::WindField + Send + Sync>,
517 ) -> Result<Self> {
518 self.wind_field = Some(SharedWindField(field));
519 Ok(self)
520 }
521
522 fn effective_wind(&self, state: &FlightState) -> Vec3 {
526 let base = match &self.wind_field {
527 Some(field) => {
528 let w = field.0.wind(
529 state.time_s,
530 [state.position_m.x, state.position_m.y, state.position_m.z],
531 );
532 Vec3::new(w.x_mps, w.y_mps, w.z_mps)
533 }
534 None => match &self.wind_sock {
535 Some(sock) => sock.vector_for_range_stateless(state.position_m.x),
536 None => self.wind_mps,
537 },
538 };
539 match &self.turbulence {
540 Some(t) => base + t.gust_vec(state.time_s),
541 None => base,
542 }
543 }
544
545 fn local_air(&self, altitude_m: f64) -> (f64, f64) {
548 local_air(self.atmosphere, self.atmos_params, altitude_m)
549 }
550
551 fn body_lift_accel(
554 &self,
555 state: &FlightState,
556 mass_kg: f64,
557 wind_mps: Vec3,
558 body_axis: Vec3,
559 ) -> Result<Vec3> {
560 let Some(lift) = &self.body_lift else {
561 return Ok(Vec3::zeros());
562 };
563 let v_air = state.velocity_mps - wind_mps;
564 let speed = v_air.norm();
565 if speed < 1e-12 || body_axis.amax() == 0.0 {
567 return Ok(Vec3::zeros());
568 }
569 let v_hat = v_air / speed;
570 let axis = body_axis / body_axis.norm();
571 let perp = axis - axis.dot(&v_hat) * v_hat;
573 let sin_alpha = perp.norm();
574 let cos_alpha = axis.dot(&v_hat).clamp(-1.0, 1.0);
575 let alpha = sin_alpha.atan2(cos_alpha);
576 if alpha < 1e-12 {
577 return Ok(Vec3::zeros());
578 }
579 let (rho, sound) = self.local_air(state.position_m.y);
580 if rho <= 0.0 || sound <= 0.0 {
581 return Ok(Vec3::zeros());
582 }
583 let mach = speed / sound;
584 let q = 0.5 * rho * speed * speed;
585 let area = std::f64::consts::FRAC_PI_4 * self.inputs.bullet_diameter.powi(2);
586 let normal_force = q * area * lift.cna_per_rad.interpolate(mach) * alpha;
587 let induced_drag = q * area * lift.induced_k_per_rad2.interpolate(mach) * alpha * alpha;
588 let accel = (normal_force * (perp / sin_alpha) - induced_drag * v_hat) / mass_kg;
589 if !finite_vec(&accel) {
590 return Err(Error::NumericalFailure(
591 "body lift returned non-finite acceleration",
592 ));
593 }
594 Ok(accel)
595 }
596
597 pub fn acceleration_with_axis_in_wind(
600 &self,
601 state: &FlightState,
602 mass_kg: f64,
603 wind_mps: Vec3,
604 body_axis: Vec3,
605 ) -> Result<Vec3> {
606 let base = self.acceleration_in_wind(state, mass_kg, wind_mps)?;
607 Ok(base + self.body_lift_accel(state, mass_kg, wind_mps, body_axis)?)
608 }
609
610 pub fn acceleration_in_wind(
612 &self,
613 state: &FlightState,
614 mass_kg: f64,
615 wind_mps: Vec3,
616 ) -> Result<Vec3> {
617 state.validate()?;
618 positive(mass_kg, "mass_kg")?;
619 if !finite_vec(&wind_mps) {
620 return Err(Error::invalid("wind_mps", "must be finite"));
621 }
622 let mut inputs = self.inputs.clone();
623 inputs.bullet_mass = mass_kg;
624 inputs.weight_grains = mass_kg / GRAINS_TO_KG;
625 positive(inputs.weight_grains, "mass in grains")?;
626 let derivatives = compute_derivatives(
627 state.position_m,
628 state.velocity_mps,
629 &inputs,
630 wind_mps,
631 self.atmos_params,
632 inputs.bc_value,
633 self.omega_rps,
634 state.time_s,
635 self.atmo_sock.as_ref(),
636 );
637 let acceleration = Vec3::new(derivatives[3], derivatives[4], derivatives[5]);
638 if !finite_vec(&acceleration) {
639 return Err(Error::NumericalFailure(
640 "ballistics force model returned non-finite acceleration",
641 ));
642 }
643 Ok(acceleration)
644 }
645}
646
647impl ForceModel for BallisticForces {
648 fn acceleration(&self, state: &FlightState, mass_kg: f64) -> Result<Vec3> {
649 self.acceleration_in_wind(state, mass_kg, self.effective_wind(state))
650 }
651
652 fn acceleration_with_axis(
653 &self,
654 state: &FlightState,
655 mass_kg: f64,
656 body_axis: Vec3,
657 ) -> Result<Vec3> {
658 self.acceleration_with_axis_in_wind(state, mass_kg, self.effective_wind(state), body_axis)
659 }
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665
666 #[test]
667 fn turbulence_rms_matches_sigma() {
668 let gust = Turbulence::new(2.5, 2.0, 42).unwrap();
669 for axis in 0..3 {
671 let samples: Vec<f64> = (0..20_000)
672 .map(|i| gust.gust_vec(i as f64 * 0.05)[axis])
673 .collect();
674 let mean = samples.iter().sum::<f64>() / samples.len() as f64;
675 let var =
676 samples.iter().map(|s| (s - mean).powi(2)).sum::<f64>() / samples.len() as f64;
677 assert!(mean.abs() < 0.1, "axis {axis} mean {mean}");
678 assert!(
679 (var.sqrt() - 2.5).abs() / 2.5 < 0.05,
680 "axis {axis} rms {}",
681 var.sqrt()
682 );
683 }
684 }
685
686 #[test]
687 fn turbulence_is_deterministic_per_seed() {
688 let a = Turbulence::new(1.0, 2.0, 7).unwrap();
689 let b = Turbulence::new(1.0, 2.0, 7).unwrap();
690 let c = Turbulence::new(1.0, 2.0, 8).unwrap();
691 for i in 0..100 {
692 let t = i as f64 * 0.1;
693 assert_eq!(a.gust_vec(t), b.gust_vec(t));
694 }
695 assert_ne!(a.gust_vec(3.3), c.gust_vec(3.3));
696 }
697
698 #[test]
699 fn turbulence_validates_its_numbers() {
700 assert!(Turbulence::new(-1.0, 2.0, 0).is_err());
701 assert!(Turbulence::new(1.0, 0.0, 0).is_err());
702 assert!(Turbulence::new(f64::NAN, 2.0, 0).is_err());
703 }
704
705 #[test]
706 fn wind_field_is_sampled_per_state() {
707 let profile = winds_engine::ProfileField::new(vec![
708 winds_engine::ProfileLevel {
709 altitude_m: 0.0,
710 speed_mps: 0.0,
711 direction_from_deg: 0.0,
712 },
713 winds_engine::ProfileLevel {
714 altitude_m: 1000.0,
715 speed_mps: 10.0,
716 direction_from_deg: 0.0,
717 },
718 ])
719 .unwrap();
720 let forces = BallisticForces::new(
721 0.05,
722 vec![(0.0, 0.4), (3.0, 0.4)],
723 Atmosphere::default(),
724 Vec3::zeros(),
725 )
726 .unwrap()
727 .with_wind_field(std::sync::Arc::new(profile))
728 .unwrap();
729 let state = FlightState {
730 time_s: 1.0,
731 position_m: Vec3::new(0.0, 500.0, 0.0),
732 velocity_mps: Vec3::zeros(),
733 };
734 let wind = forces.effective_wind(&state);
736 assert!((wind.x + 5.0).abs() < 1e-9, "wind {wind:?}");
737 assert!(wind.y.abs() < 1e-12 && wind.z.abs() < 1e-9);
738 }
739
740 #[test]
741 fn turbulence_adds_to_the_effective_wind() {
742 let forces = BallisticForces::new(
743 0.05,
744 vec![(0.0, 0.4), (3.0, 0.4)],
745 Atmosphere::default(),
746 Vec3::new(3.0, 0.0, 0.0),
747 )
748 .unwrap()
749 .with_turbulence(2.0, 2.0, 42)
750 .unwrap();
751 let state = FlightState {
752 time_s: 1.7,
753 position_m: Vec3::zeros(),
754 velocity_mps: Vec3::zeros(),
755 };
756 let expected = Vec3::new(3.0, 0.0, 0.0) + forces.turbulence.as_ref().unwrap().gust_vec(1.7);
757 assert_eq!(forces.effective_wind(&state), expected);
758 }
759}