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 turbulence: Option<Turbulence>,
153 omega_rps: Option<Vec3>,
154 body_lift: Option<BodyLift>,
155 atmo_sock: Option<AtmoSock>,
156 wind_sock: Option<WindSock>,
157}
158
159const TURBULENCE_COMPONENTS: usize = 8;
161
162#[derive(Debug, Clone)]
166pub(crate) struct Turbulence {
167 axes: [[(f64, f64, f64); TURBULENCE_COMPONENTS]; 3],
169}
170
171impl Turbulence {
172 fn hash01(mut z: u64) -> f64 {
174 z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
175 z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
176 let z = z ^ (z >> 31);
177 (z >> 11) as f64 / (1u64 << 53) as f64
178 }
179
180 pub(crate) fn new(sigma_mps: f64, tau_s: f64, seed: u64) -> Result<Self> {
183 if !sigma_mps.is_finite() || sigma_mps < 0.0 {
184 return Err(Error::invalid("turbulence sigma", "must be nonnegative"));
185 }
186 if !tau_s.is_finite() || tau_s <= 0.0 {
187 return Err(Error::invalid("turbulence tau", "must be positive"));
188 }
189 let lo = 0.1 / tau_s;
190 let ratio = 100.0_f64;
191 let step = ratio.powf(1.0 / (TURBULENCE_COMPONENTS - 1) as f64);
192 let norm: f64 = (0..TURBULENCE_COMPONENTS)
193 .map(|i| 1.0 / (lo * step.powi(i as i32)))
194 .sum();
195 let scale = (2.0 * sigma_mps * sigma_mps / norm).sqrt();
196 let mut axes = [[(0.0, 0.0, 0.0); TURBULENCE_COMPONENTS]; 3];
197 for (axis, comps) in axes.iter_mut().enumerate() {
198 for (i, comp) in comps.iter_mut().enumerate() {
199 let omega = lo * step.powi(i as i32);
200 let amplitude = scale / omega.sqrt();
201 let phase = 2.0
202 * std::f64::consts::PI
203 * Self::hash01(
204 seed ^ 0x9E3779B97F4A7C15u64.wrapping_mul(1 + axis as u64 * 64 + i as u64),
205 );
206 *comp = (omega, amplitude, phase);
207 }
208 }
209 Ok(Self { axes })
210 }
211
212 pub(crate) fn gust_vec(&self, t: f64) -> Vec3 {
213 Vec3::new(
214 self.axes[0]
215 .iter()
216 .map(|(w, a, p)| a * (w * t + p).sin())
217 .sum(),
218 self.axes[1]
219 .iter()
220 .map(|(w, a, p)| a * (w * t + p).sin())
221 .sum(),
222 self.axes[2]
223 .iter()
224 .map(|(w, a, p)| a * (w * t + p).sin())
225 .sum(),
226 )
227 }
228}
229
230#[derive(Debug, Clone)]
233struct BodyLift {
234 cna_per_rad: DragTable,
236 induced_k_per_rad2: DragTable,
238}
239
240const OMEGA_EARTH_RPS: f64 = 7.2921159e-5;
242
243impl BallisticForces {
244 pub fn new(
248 diameter_m: f64,
249 cd_vs_mach: Vec<(f64, f64)>,
250 atmosphere: Atmosphere,
251 wind_mps: Vec3,
252 ) -> Result<Self> {
253 positive(diameter_m, "diameter_m")?;
254 let diameter_in = diameter_m / 0.0254;
255 positive(diameter_in * diameter_in, "diameter area")?;
256 if !finite_vec(&wind_mps) {
257 return Err(Error::invalid("wind_mps", "must be finite"));
258 }
259 let (mach, cd) = cd_vs_mach.into_iter().unzip();
260 let drag = DragTable::try_new(mach, cd).map_err(Error::Model)?;
261 let inputs = BallisticInputs {
262 bullet_diameter: diameter_m,
263 caliber_inches: diameter_in,
264 bullet_length: diameter_m,
265 custom_drag_table: Some(drag),
266 custom_drag_reference: false,
267 altitude: 0.0,
268 shooting_angle: 0.0,
269 enable_advanced_effects: false,
270 enable_magnus: false,
271 enable_coriolis: false,
272 use_enhanced_spin_drift: false,
273 tipoff_yaw: 0.0,
274 ..BallisticInputs::default()
275 };
276 Ok(Self {
277 inputs,
278 atmos_params: atmosphere.parameters()?,
279 atmosphere,
280 wind_mps,
281 turbulence: None,
282 omega_rps: None,
283 body_lift: None,
284 atmo_sock: None,
285 wind_sock: None,
286 })
287 }
288
289 pub fn with_drag_scale(mut self, scale: f64) -> Result<Self> {
291 positive(scale, "drag scale")?;
292 if self
293 .inputs
294 .custom_drag_table
295 .as_ref()
296 .unwrap()
297 .cd_values
298 .iter()
299 .any(|cd| !(cd * scale).is_finite())
300 {
301 return Err(Error::invalid("drag scale", "scaled Cd must remain finite"));
302 }
303 self.inputs.cd_scale = scale;
304 Ok(self)
305 }
306
307 pub fn set_wind(&mut self, wind_mps: Vec3) -> Result<()> {
309 if !finite_vec(&wind_mps) {
310 return Err(Error::invalid("wind_mps", "must be finite"));
311 }
312 self.wind_mps = wind_mps;
313 Ok(())
314 }
315
316 pub fn with_spin(
326 mut self,
327 twist_rate_in: f64,
328 body_length_m: f64,
329 right_hand: bool,
330 ) -> Result<Self> {
331 positive(twist_rate_in, "twist_rate_in")?;
332 positive(body_length_m, "body_length_m")?;
333 self.inputs.twist_rate = twist_rate_in;
334 self.inputs.is_twist_right = right_hand;
335 self.inputs.bullet_length = body_length_m;
336 self.inputs.enable_magnus = true;
337 Ok(self)
338 }
339
340 pub fn with_coriolis(mut self, latitude_deg: f64, shot_azimuth_deg: f64) -> Result<Self> {
346 if !latitude_deg.is_finite() || !(-90.0..=90.0).contains(&latitude_deg) {
347 return Err(Error::invalid(
348 "latitude_deg",
349 "must be finite and in [-90, 90]",
350 ));
351 }
352 if !shot_azimuth_deg.is_finite() {
353 return Err(Error::invalid("shot_azimuth_deg", "must be finite"));
354 }
355 let lat = latitude_deg.to_radians();
356 let az = shot_azimuth_deg.to_radians();
357 self.omega_rps = Some(Vec3::new(
358 OMEGA_EARTH_RPS * lat.cos() * az.cos(), OMEGA_EARTH_RPS * lat.sin(), -OMEGA_EARTH_RPS * lat.cos() * az.sin(), ));
362 self.inputs.latitude = Some(latitude_deg);
363 self.inputs.shot_azimuth = az;
364 self.inputs.enable_coriolis = true;
365 Ok(self)
366 }
367
368 pub fn with_tipoff_yaw(mut self, yaw_rad: f64, decay_distance_m: f64) -> Result<Self> {
372 if !yaw_rad.is_finite() {
373 return Err(Error::invalid("tipoff yaw_rad", "must be finite"));
374 }
375 positive(decay_distance_m, "tipoff decay_distance_m")?;
376 self.inputs.tipoff_yaw = yaw_rad;
377 self.inputs.tipoff_decay_distance = decay_distance_m;
378 Ok(self)
379 }
380
381 pub fn with_body_lift(
393 mut self,
394 cna_vs_mach: Vec<(f64, f64)>,
395 induced_k_vs_mach: Vec<(f64, f64)>,
396 ) -> Result<Self> {
397 let table = |pairs: Vec<(f64, f64)>, name: &'static str| -> Result<DragTable> {
398 mach_table(pairs, name)
399 };
400 self.body_lift = Some(BodyLift {
401 cna_per_rad: table(cna_vs_mach, "cna_vs_mach")?,
402 induced_k_per_rad2: table(induced_k_vs_mach, "induced_k_vs_mach")?,
403 });
404 Ok(self)
405 }
406
407 pub fn set_twist(&mut self, twist_rate_in: f64) -> Result<()> {
411 positive(twist_rate_in, "twist_rate_in")?;
412 self.inputs.twist_rate = twist_rate_in;
413 Ok(())
414 }
415
416 pub fn with_atmosphere_zones(mut self, zones: Vec<(f64, f64, f64, f64)>) -> Result<Self> {
422 if !matches!(self.atmosphere, Atmosphere::Standard { .. }) {
423 return Err(Error::invalid(
424 "atmosphere zones",
425 "require the Standard atmosphere, not Uniform",
426 ));
427 }
428 if zones.is_empty() {
429 return Err(Error::invalid(
430 "atmosphere zones",
431 "needs at least one zone",
432 ));
433 }
434 for (temp_c, pressure_hpa, humidity, until_m) in &zones {
435 if !temp_c.is_finite()
436 || !pressure_hpa.is_finite()
437 || *pressure_hpa <= 0.0
438 || !humidity.is_finite()
439 || !(0.0..=100.0).contains(humidity)
440 || !until_m.is_finite()
441 || *until_m <= 0.0
442 {
443 return Err(Error::invalid(
444 "atmosphere zone",
445 "needs finite temperature, positive pressure, humidity in [0, 100] and positive until_m",
446 ));
447 }
448 }
449 self.atmo_sock = Some(AtmoSock::new(zones));
450 Ok(self)
451 }
452
453 pub fn with_wind_segments(mut self, zones: Vec<(f64, f64, f64, f64)>) -> Result<Self> {
460 if zones.is_empty() {
461 return Err(Error::invalid(
462 "wind segments",
463 "needs at least one segment",
464 ));
465 }
466 let segments: Vec<WindSegment> = zones
467 .into_iter()
468 .map(
469 |(speed_mps, angle_deg, until_m, vertical_mps)| WindSegment {
470 speed_kmh: speed_mps * 3.6,
471 angle_deg,
472 until_m,
473 vertical_mps,
474 },
475 )
476 .collect();
477 let sock = WindSock::try_new(segments)
478 .map_err(|e| Error::Model(format!("invalid wind segments: {e:?}")))?;
479 self.wind_sock = Some(sock);
480 Ok(self)
481 }
482
483 pub fn with_turbulence(mut self, sigma_mps: f64, tau_s: f64, seed: u64) -> Result<Self> {
488 self.turbulence = Some(Turbulence::new(sigma_mps, tau_s, seed)?);
489 Ok(self)
490 }
491
492 fn effective_wind(&self, state: &FlightState) -> Vec3 {
495 let base = match &self.wind_sock {
496 Some(sock) => sock.vector_for_range_stateless(state.position_m.x),
497 None => self.wind_mps,
498 };
499 match &self.turbulence {
500 Some(t) => base + t.gust_vec(state.time_s),
501 None => base,
502 }
503 }
504
505 fn local_air(&self, altitude_m: f64) -> (f64, f64) {
508 local_air(self.atmosphere, self.atmos_params, altitude_m)
509 }
510
511 fn body_lift_accel(
514 &self,
515 state: &FlightState,
516 mass_kg: f64,
517 wind_mps: Vec3,
518 body_axis: Vec3,
519 ) -> Result<Vec3> {
520 let Some(lift) = &self.body_lift else {
521 return Ok(Vec3::zeros());
522 };
523 let v_air = state.velocity_mps - wind_mps;
524 let speed = v_air.norm();
525 if speed < 1e-12 || body_axis.amax() == 0.0 {
527 return Ok(Vec3::zeros());
528 }
529 let v_hat = v_air / speed;
530 let axis = body_axis / body_axis.norm();
531 let perp = axis - axis.dot(&v_hat) * v_hat;
533 let sin_alpha = perp.norm();
534 let cos_alpha = axis.dot(&v_hat).clamp(-1.0, 1.0);
535 let alpha = sin_alpha.atan2(cos_alpha);
536 if alpha < 1e-12 {
537 return Ok(Vec3::zeros());
538 }
539 let (rho, sound) = self.local_air(state.position_m.y);
540 if rho <= 0.0 || sound <= 0.0 {
541 return Ok(Vec3::zeros());
542 }
543 let mach = speed / sound;
544 let q = 0.5 * rho * speed * speed;
545 let area = std::f64::consts::FRAC_PI_4 * self.inputs.bullet_diameter.powi(2);
546 let normal_force = q * area * lift.cna_per_rad.interpolate(mach) * alpha;
547 let induced_drag = q * area * lift.induced_k_per_rad2.interpolate(mach) * alpha * alpha;
548 let accel = (normal_force * (perp / sin_alpha) - induced_drag * v_hat) / mass_kg;
549 if !finite_vec(&accel) {
550 return Err(Error::NumericalFailure(
551 "body lift returned non-finite acceleration",
552 ));
553 }
554 Ok(accel)
555 }
556
557 pub fn acceleration_with_axis_in_wind(
560 &self,
561 state: &FlightState,
562 mass_kg: f64,
563 wind_mps: Vec3,
564 body_axis: Vec3,
565 ) -> Result<Vec3> {
566 let base = self.acceleration_in_wind(state, mass_kg, wind_mps)?;
567 Ok(base + self.body_lift_accel(state, mass_kg, wind_mps, body_axis)?)
568 }
569
570 pub fn acceleration_in_wind(
572 &self,
573 state: &FlightState,
574 mass_kg: f64,
575 wind_mps: Vec3,
576 ) -> Result<Vec3> {
577 state.validate()?;
578 positive(mass_kg, "mass_kg")?;
579 if !finite_vec(&wind_mps) {
580 return Err(Error::invalid("wind_mps", "must be finite"));
581 }
582 let mut inputs = self.inputs.clone();
583 inputs.bullet_mass = mass_kg;
584 inputs.weight_grains = mass_kg / GRAINS_TO_KG;
585 positive(inputs.weight_grains, "mass in grains")?;
586 let derivatives = compute_derivatives(
587 state.position_m,
588 state.velocity_mps,
589 &inputs,
590 wind_mps,
591 self.atmos_params,
592 inputs.bc_value,
593 self.omega_rps,
594 state.time_s,
595 self.atmo_sock.as_ref(),
596 );
597 let acceleration = Vec3::new(derivatives[3], derivatives[4], derivatives[5]);
598 if !finite_vec(&acceleration) {
599 return Err(Error::NumericalFailure(
600 "ballistics force model returned non-finite acceleration",
601 ));
602 }
603 Ok(acceleration)
604 }
605}
606
607impl ForceModel for BallisticForces {
608 fn acceleration(&self, state: &FlightState, mass_kg: f64) -> Result<Vec3> {
609 self.acceleration_in_wind(state, mass_kg, self.effective_wind(state))
610 }
611
612 fn acceleration_with_axis(
613 &self,
614 state: &FlightState,
615 mass_kg: f64,
616 body_axis: Vec3,
617 ) -> Result<Vec3> {
618 self.acceleration_with_axis_in_wind(state, mass_kg, self.effective_wind(state), body_axis)
619 }
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625
626 #[test]
627 fn turbulence_rms_matches_sigma() {
628 let gust = Turbulence::new(2.5, 2.0, 42).unwrap();
629 for axis in 0..3 {
631 let samples: Vec<f64> = (0..20_000)
632 .map(|i| gust.gust_vec(i as f64 * 0.05)[axis])
633 .collect();
634 let mean = samples.iter().sum::<f64>() / samples.len() as f64;
635 let var =
636 samples.iter().map(|s| (s - mean).powi(2)).sum::<f64>() / samples.len() as f64;
637 assert!(mean.abs() < 0.1, "axis {axis} mean {mean}");
638 assert!(
639 (var.sqrt() - 2.5).abs() / 2.5 < 0.05,
640 "axis {axis} rms {}",
641 var.sqrt()
642 );
643 }
644 }
645
646 #[test]
647 fn turbulence_is_deterministic_per_seed() {
648 let a = Turbulence::new(1.0, 2.0, 7).unwrap();
649 let b = Turbulence::new(1.0, 2.0, 7).unwrap();
650 let c = Turbulence::new(1.0, 2.0, 8).unwrap();
651 for i in 0..100 {
652 let t = i as f64 * 0.1;
653 assert_eq!(a.gust_vec(t), b.gust_vec(t));
654 }
655 assert_ne!(a.gust_vec(3.3), c.gust_vec(3.3));
656 }
657
658 #[test]
659 fn turbulence_validates_its_numbers() {
660 assert!(Turbulence::new(-1.0, 2.0, 0).is_err());
661 assert!(Turbulence::new(1.0, 0.0, 0).is_err());
662 assert!(Turbulence::new(f64::NAN, 2.0, 0).is_err());
663 }
664
665 #[test]
666 fn turbulence_adds_to_the_effective_wind() {
667 let forces = BallisticForces::new(
668 0.05,
669 vec![(0.0, 0.4), (3.0, 0.4)],
670 Atmosphere::default(),
671 Vec3::new(3.0, 0.0, 0.0),
672 )
673 .unwrap()
674 .with_turbulence(2.0, 2.0, 42)
675 .unwrap();
676 let state = FlightState {
677 time_s: 1.7,
678 position_m: Vec3::zeros(),
679 velocity_mps: Vec3::zeros(),
680 };
681 let expected = Vec3::new(3.0, 0.0, 0.0) + forces.turbulence.as_ref().unwrap().gust_vec(1.7);
682 assert_eq!(forces.effective_wind(&state), expected);
683 }
684}