1use crate::{finite_vec, positive, Error, FlightState, ForceModel, Result, Vec3};
11use ballistics_engine::{
12 atmosphere::{
13 calculate_air_density_cimp, get_local_atmosphere, reduce_qnh_to_station_pressure, AtmoSock,
14 },
15 constants::GRAINS_TO_KG,
16 derivatives::compute_derivatives,
17 drag::DragTable,
18 wind::{WindSegment, WindSock},
19 BallisticInputs,
20};
21
22#[derive(Debug, Clone, Copy, PartialEq)]
24pub enum Atmosphere {
25 Standard {
28 station_altitude_m: f64,
30 temperature_c: f64,
32 pressure_hpa: f64,
34 humidity_percent: f64,
36 },
37 Uniform {
40 density_kg_m3: f64,
42 speed_of_sound_mps: f64,
44 },
45}
46
47impl Default for Atmosphere {
48 fn default() -> Self {
49 Self::Standard {
50 station_altitude_m: 0.0,
51 temperature_c: 15.0,
52 pressure_hpa: 1013.25,
53 humidity_percent: 0.0,
54 }
55 }
56}
57
58pub(crate) const ICAO_STATION_RANGE_M: (f64, f64) = (-1000.0, 11000.0);
61
62const GEOPOTENTIAL_EARTH_RADIUS_M: f64 = 6_356_766.0;
65
66impl Atmosphere {
67 pub fn icao_standard(station_altitude_m: f64) -> Result<Self> {
79 let (lowest, highest) = ICAO_STATION_RANGE_M;
80 if !station_altitude_m.is_finite() || !(lowest..=highest).contains(&station_altitude_m) {
81 return Err(Error::invalid(
82 "station_altitude_m",
83 "must be finite and within the ICAO troposphere, -1000 to 11000 m",
84 ));
85 }
86 let geopotential_m = GEOPOTENTIAL_EARTH_RADIUS_M * station_altitude_m
87 / (GEOPOTENTIAL_EARTH_RADIUS_M + station_altitude_m);
88 let temperature_k = 288.15 - 0.0065 * geopotential_m;
89 Ok(Self::Standard {
90 station_altitude_m,
91 temperature_c: temperature_k - 273.15,
92 pressure_hpa: reduce_qnh_to_station_pressure(1013.25, station_altitude_m),
93 humidity_percent: 0.0,
94 })
95 }
96
97 pub(crate) fn parameters(self) -> Result<(f64, f64, f64, f64)> {
98 match self {
99 Self::Standard {
100 station_altitude_m,
101 temperature_c,
102 pressure_hpa,
103 humidity_percent,
104 } => {
105 if !station_altitude_m.is_finite()
106 || !temperature_c.is_finite()
107 || temperature_c <= -273.15
108 || !humidity_percent.is_finite()
109 || !(0.0..=100.0).contains(&humidity_percent)
110 {
111 return Err(Error::invalid("standard atmosphere", "requires finite altitude, temperature above absolute zero and humidity in [0, 100]"));
112 }
113 positive(pressure_hpa, "pressure_hpa")?;
114 let ratio =
115 calculate_air_density_cimp(temperature_c, pressure_hpa, humidity_percent)
116 / 1.225;
117 positive(ratio, "station density ratio")?;
118 Ok((station_altitude_m, temperature_c, pressure_hpa, ratio))
119 }
120 Self::Uniform {
121 density_kg_m3,
122 speed_of_sound_mps,
123 } => {
124 if !density_kg_m3.is_finite()
125 || !(0.0..2.0).contains(&density_kg_m3)
126 || !speed_of_sound_mps.is_finite()
127 || speed_of_sound_mps <= 200.0
128 {
129 return Err(Error::invalid(
130 "uniform atmosphere",
131 "upstream requires density in [0, 2) and sound speed > 200 m/s",
132 ));
133 }
134 Ok((density_kg_m3, speed_of_sound_mps, 0.0, 0.0))
135 }
136 }
137 }
138}
139
140fn local_air(
143 atmosphere: Atmosphere,
144 atmos_params: (f64, f64, f64, f64),
145 altitude_m: f64,
146) -> (f64, f64) {
147 match atmosphere {
148 Atmosphere::Standard { .. } => {
149 let (base_alt, temp_c, press_hpa, ratio) = atmos_params;
150 get_local_atmosphere(altitude_m, base_alt, temp_c, press_hpa, ratio)
151 }
152 Atmosphere::Uniform { .. } => {
153 let (density, speed_of_sound, _, _) = atmos_params;
154 (density, speed_of_sound)
155 }
156 }
157}
158
159#[derive(Debug, Clone)]
166pub(crate) struct Air {
167 atmosphere: Atmosphere,
168 params: (f64, f64, f64, f64),
169 zones: Option<AtmoSock>,
170}
171
172impl Air {
173 pub(crate) fn new(atmosphere: Atmosphere) -> Result<Self> {
174 Ok(Self {
175 params: atmosphere.parameters()?,
176 atmosphere,
177 zones: None,
178 })
179 }
180
181 pub(crate) fn with_zones(mut self, zones: Vec<(f64, f64, f64, f64)>) -> Result<Self> {
185 if !matches!(self.atmosphere, Atmosphere::Standard { .. }) {
186 return Err(Error::invalid(
187 "atmosphere zones",
188 "require the Standard atmosphere, not Uniform",
189 ));
190 }
191 if zones.is_empty() {
192 return Err(Error::invalid(
193 "atmosphere zones",
194 "needs at least one zone",
195 ));
196 }
197 for (temp_c, pressure_hpa, humidity, until_m) in &zones {
198 if !temp_c.is_finite()
199 || !pressure_hpa.is_finite()
200 || *pressure_hpa <= 0.0
201 || !humidity.is_finite()
202 || !(0.0..=100.0).contains(humidity)
203 || !until_m.is_finite()
204 || *until_m <= 0.0
205 {
206 return Err(Error::invalid(
207 "atmosphere zone",
208 "needs finite temperature, positive pressure, humidity in [0, 100] and positive until_m",
209 ));
210 }
211 }
212 self.zones = Some(AtmoSock::new(zones));
213 Ok(self)
214 }
215
216 pub(crate) fn params(&self) -> (f64, f64, f64, f64) {
218 self.params
219 }
220
221 pub(crate) fn zones(&self) -> Option<&AtmoSock> {
223 self.zones.as_ref()
224 }
225
226 pub(crate) fn at(&self, position_m: Vec3) -> (f64, f64) {
239 match (self.atmosphere, &self.zones) {
240 (Atmosphere::Standard { .. }, Some(sock)) => {
241 let (temp_c, pressure_hpa, humidity) = sock.atmo_for_range(position_m.x);
242 let ratio = calculate_air_density_cimp(temp_c, pressure_hpa, humidity) / 1.225;
243 get_local_atmosphere(position_m.y, self.params.0, temp_c, pressure_hpa, ratio)
244 }
245 _ => local_air(self.atmosphere, self.params, position_m.y),
246 }
247 }
248}
249
250pub(crate) fn mach_table(pairs: Vec<(f64, f64)>, name: &'static str) -> Result<DragTable> {
253 if pairs.len() < 2 {
254 return Err(Error::invalid(name, "needs at least two Mach points"));
255 }
256 for (i, (mach, value)) in pairs.iter().enumerate() {
257 if !mach.is_finite() || *mach < 0.0 {
258 return Err(Error::invalid(name, "Mach must be finite and nonnegative"));
259 }
260 if i > 0 && *mach <= pairs[i - 1].0 {
261 return Err(Error::invalid(name, "Mach must strictly ascend"));
262 }
263 if !value.is_finite() || *value < 0.0 {
264 return Err(Error::invalid(
265 name,
266 "values must be finite and nonnegative",
267 ));
268 }
269 }
270 let (mach_values, cd_values) = pairs.into_iter().unzip();
271 Ok(DragTable::new(mach_values, cd_values))
272}
273
274#[derive(Debug, Clone)]
276pub struct BallisticForces {
277 inputs: BallisticInputs,
278 air: Air,
279 wind_mps: Vec3,
280 wind_field: Option<SharedWindField>,
283 turbulence: Option<Turbulence>,
284 omega_rps: Option<Vec3>,
285 body_lift: Option<BodyLift>,
286 wind_sock: Option<WindSock>,
287}
288
289#[derive(Clone)]
293pub(crate) struct SharedWindField(
294 pub(crate) std::sync::Arc<dyn winds_engine::WindField + Send + Sync>,
295);
296
297impl std::fmt::Debug for SharedWindField {
298 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299 f.write_str("SharedWindField(..)")
300 }
301}
302
303const TURBULENCE_COMPONENTS: usize = 8;
305
306#[derive(Debug, Clone)]
310pub(crate) struct Turbulence {
311 axes: [[(f64, f64, f64); TURBULENCE_COMPONENTS]; 3],
313}
314
315impl Turbulence {
316 fn hash01(mut z: u64) -> f64 {
318 z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
319 z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
320 let z = z ^ (z >> 31);
321 (z >> 11) as f64 / (1u64 << 53) as f64
322 }
323
324 pub(crate) fn new(sigma_mps: f64, tau_s: f64, seed: u64) -> Result<Self> {
327 if !sigma_mps.is_finite() || sigma_mps < 0.0 {
328 return Err(Error::invalid("turbulence sigma", "must be nonnegative"));
329 }
330 if !tau_s.is_finite() || tau_s <= 0.0 {
331 return Err(Error::invalid("turbulence tau", "must be positive"));
332 }
333 let lo = 0.1 / tau_s;
334 let ratio = 100.0_f64;
335 let step = ratio.powf(1.0 / (TURBULENCE_COMPONENTS - 1) as f64);
336 let norm: f64 = (0..TURBULENCE_COMPONENTS)
337 .map(|i| 1.0 / (lo * step.powi(i as i32)))
338 .sum();
339 let scale = (2.0 * sigma_mps * sigma_mps / norm).sqrt();
340 let mut axes = [[(0.0, 0.0, 0.0); TURBULENCE_COMPONENTS]; 3];
341 for (axis, comps) in axes.iter_mut().enumerate() {
342 for (i, comp) in comps.iter_mut().enumerate() {
343 let omega = lo * step.powi(i as i32);
344 let amplitude = scale / omega.sqrt();
345 let phase = 2.0
346 * std::f64::consts::PI
347 * Self::hash01(
348 seed ^ 0x9E3779B97F4A7C15u64.wrapping_mul(1 + axis as u64 * 64 + i as u64),
349 );
350 *comp = (omega, amplitude, phase);
351 }
352 }
353 Ok(Self { axes })
354 }
355
356 pub(crate) fn gust_vec(&self, t: f64) -> Vec3 {
357 Vec3::new(
358 self.axes[0]
359 .iter()
360 .map(|(w, a, p)| a * (w * t + p).sin())
361 .sum(),
362 self.axes[1]
363 .iter()
364 .map(|(w, a, p)| a * (w * t + p).sin())
365 .sum(),
366 self.axes[2]
367 .iter()
368 .map(|(w, a, p)| a * (w * t + p).sin())
369 .sum(),
370 )
371 }
372}
373
374#[derive(Debug, Clone)]
377struct BodyLift {
378 cna_per_rad: DragTable,
380 induced_k_per_rad2: DragTable,
382}
383
384const OMEGA_EARTH_RPS: f64 = 7.2921159e-5;
386
387impl BallisticForces {
388 pub fn new(
392 diameter_m: f64,
393 cd_vs_mach: Vec<(f64, f64)>,
394 atmosphere: Atmosphere,
395 wind_mps: Vec3,
396 ) -> Result<Self> {
397 positive(diameter_m, "diameter_m")?;
398 let diameter_in = diameter_m / 0.0254;
399 positive(diameter_in * diameter_in, "diameter area")?;
400 if !finite_vec(&wind_mps) {
401 return Err(Error::invalid("wind_mps", "must be finite"));
402 }
403 let (mach, cd) = cd_vs_mach.into_iter().unzip();
404 let drag = DragTable::try_new(mach, cd).map_err(Error::Model)?;
405 let inputs = BallisticInputs {
406 bullet_diameter: diameter_m,
407 caliber_inches: diameter_in,
408 bullet_length: diameter_m,
409 custom_drag_table: Some(drag),
410 custom_drag_reference: false,
411 altitude: 0.0,
412 shooting_angle: 0.0,
413 enable_advanced_effects: false,
414 enable_magnus: false,
415 enable_coriolis: false,
416 use_enhanced_spin_drift: false,
417 tipoff_yaw: 0.0,
418 ..BallisticInputs::default()
419 };
420 Ok(Self {
421 inputs,
422 air: Air::new(atmosphere)?,
423 wind_mps,
424 wind_field: None,
425 turbulence: None,
426 omega_rps: None,
427 body_lift: None,
428 wind_sock: None,
429 })
430 }
431
432 pub fn with_drag_scale(mut self, scale: f64) -> Result<Self> {
434 positive(scale, "drag scale")?;
435 if self
436 .inputs
437 .custom_drag_table
438 .as_ref()
439 .unwrap()
440 .cd_values
441 .iter()
442 .any(|cd| !(cd * scale).is_finite())
443 {
444 return Err(Error::invalid("drag scale", "scaled Cd must remain finite"));
445 }
446 self.inputs.cd_scale = scale;
447 Ok(self)
448 }
449
450 pub fn set_wind(&mut self, wind_mps: Vec3) -> Result<()> {
452 if !finite_vec(&wind_mps) {
453 return Err(Error::invalid("wind_mps", "must be finite"));
454 }
455 self.wind_mps = wind_mps;
456 Ok(())
457 }
458
459 pub fn with_spin(
469 mut self,
470 twist_rate_in: f64,
471 body_length_m: f64,
472 right_hand: bool,
473 ) -> Result<Self> {
474 positive(twist_rate_in, "twist_rate_in")?;
475 positive(body_length_m, "body_length_m")?;
476 self.inputs.twist_rate = twist_rate_in;
477 self.inputs.is_twist_right = right_hand;
478 self.inputs.bullet_length = body_length_m;
479 self.inputs.enable_magnus = true;
480 Ok(self)
481 }
482
483 pub fn with_coriolis(mut self, latitude_deg: f64, shot_azimuth_deg: f64) -> Result<Self> {
489 if !latitude_deg.is_finite() || !(-90.0..=90.0).contains(&latitude_deg) {
490 return Err(Error::invalid(
491 "latitude_deg",
492 "must be finite and in [-90, 90]",
493 ));
494 }
495 if !shot_azimuth_deg.is_finite() {
496 return Err(Error::invalid("shot_azimuth_deg", "must be finite"));
497 }
498 let lat = latitude_deg.to_radians();
499 let az = shot_azimuth_deg.to_radians();
500 self.omega_rps = Some(Vec3::new(
501 OMEGA_EARTH_RPS * lat.cos() * az.cos(), OMEGA_EARTH_RPS * lat.sin(), -OMEGA_EARTH_RPS * lat.cos() * az.sin(), ));
505 self.inputs.latitude = Some(latitude_deg);
506 self.inputs.shot_azimuth = az;
507 self.inputs.enable_coriolis = true;
508 Ok(self)
509 }
510
511 pub fn with_tipoff_yaw(mut self, yaw_rad: f64, decay_distance_m: f64) -> Result<Self> {
515 if !yaw_rad.is_finite() {
516 return Err(Error::invalid("tipoff yaw_rad", "must be finite"));
517 }
518 positive(decay_distance_m, "tipoff decay_distance_m")?;
519 self.inputs.tipoff_yaw = yaw_rad;
520 self.inputs.tipoff_decay_distance = decay_distance_m;
521 Ok(self)
522 }
523
524 pub fn with_body_lift(
536 mut self,
537 cna_vs_mach: Vec<(f64, f64)>,
538 induced_k_vs_mach: Vec<(f64, f64)>,
539 ) -> Result<Self> {
540 let table = |pairs: Vec<(f64, f64)>, name: &'static str| -> Result<DragTable> {
541 mach_table(pairs, name)
542 };
543 self.body_lift = Some(BodyLift {
544 cna_per_rad: table(cna_vs_mach, "cna_vs_mach")?,
545 induced_k_per_rad2: table(induced_k_vs_mach, "induced_k_vs_mach")?,
546 });
547 Ok(self)
548 }
549
550 pub fn set_twist(&mut self, twist_rate_in: f64) -> Result<()> {
554 positive(twist_rate_in, "twist_rate_in")?;
555 self.inputs.twist_rate = twist_rate_in;
556 Ok(())
557 }
558
559 pub fn with_atmosphere_zones(mut self, zones: Vec<(f64, f64, f64, f64)>) -> Result<Self> {
570 self.air = self.air.with_zones(zones)?;
571 Ok(self)
572 }
573
574 pub fn with_wind_segments(mut self, zones: Vec<(f64, f64, f64, f64)>) -> Result<Self> {
581 if zones.is_empty() {
582 return Err(Error::invalid(
583 "wind segments",
584 "needs at least one segment",
585 ));
586 }
587 let segments: Vec<WindSegment> = zones
588 .into_iter()
589 .map(
590 |(speed_mps, angle_deg, until_m, vertical_mps)| WindSegment {
591 speed_kmh: speed_mps * 3.6,
592 angle_deg,
593 until_m,
594 vertical_mps,
595 },
596 )
597 .collect();
598 let sock = WindSock::try_new(segments)
599 .map_err(|e| Error::Model(format!("invalid wind segments: {e:?}")))?;
600 self.wind_sock = Some(sock);
601 Ok(self)
602 }
603
604 pub fn with_turbulence(mut self, sigma_mps: f64, tau_s: f64, seed: u64) -> Result<Self> {
609 self.turbulence = Some(Turbulence::new(sigma_mps, tau_s, seed)?);
610 Ok(self)
611 }
612
613 pub fn with_wind_field(
618 mut self,
619 field: std::sync::Arc<dyn winds_engine::WindField + Send + Sync>,
620 ) -> Result<Self> {
621 self.wind_field = Some(SharedWindField(field));
622 Ok(self)
623 }
624
625 fn effective_wind(&self, state: &FlightState) -> Vec3 {
629 let base = match &self.wind_field {
630 Some(field) => {
631 let w = field.0.wind(
632 state.time_s,
633 [state.position_m.x, state.position_m.y, state.position_m.z],
634 );
635 Vec3::new(w.x_mps, w.y_mps, w.z_mps)
636 }
637 None => match &self.wind_sock {
638 Some(sock) => sock.vector_for_range_stateless(state.position_m.x),
639 None => self.wind_mps,
640 },
641 };
642 match &self.turbulence {
643 Some(t) => base + t.gust_vec(state.time_s),
644 None => base,
645 }
646 }
647
648 fn body_lift_accel(
651 &self,
652 state: &FlightState,
653 mass_kg: f64,
654 wind_mps: Vec3,
655 body_axis: Vec3,
656 ) -> Result<Vec3> {
657 let Some(lift) = &self.body_lift else {
658 return Ok(Vec3::zeros());
659 };
660 let v_air = state.velocity_mps - wind_mps;
661 let speed = v_air.norm();
662 if speed < 1e-12 || body_axis.amax() == 0.0 {
664 return Ok(Vec3::zeros());
665 }
666 let v_hat = v_air / speed;
667 let axis = body_axis / body_axis.norm();
668 let perp = axis - axis.dot(&v_hat) * v_hat;
670 let sin_alpha = perp.norm();
671 let cos_alpha = axis.dot(&v_hat).clamp(-1.0, 1.0);
672 let alpha = sin_alpha.atan2(cos_alpha);
673 if alpha < 1e-12 {
674 return Ok(Vec3::zeros());
675 }
676 let (rho, sound) = self.air.at(state.position_m);
677 if rho <= 0.0 || sound <= 0.0 {
678 return Ok(Vec3::zeros());
679 }
680 let mach = speed / sound;
681 let q = 0.5 * rho * speed * speed;
682 let area = std::f64::consts::FRAC_PI_4 * self.inputs.bullet_diameter.powi(2);
683 let normal_force = q * area * lift.cna_per_rad.interpolate(mach) * alpha;
684 let induced_drag = q * area * lift.induced_k_per_rad2.interpolate(mach) * alpha * alpha;
685 let accel = (normal_force * (perp / sin_alpha) - induced_drag * v_hat) / mass_kg;
686 if !finite_vec(&accel) {
687 return Err(Error::NumericalFailure(
688 "body lift returned non-finite acceleration",
689 ));
690 }
691 Ok(accel)
692 }
693
694 pub fn acceleration_with_axis_in_wind(
697 &self,
698 state: &FlightState,
699 mass_kg: f64,
700 wind_mps: Vec3,
701 body_axis: Vec3,
702 ) -> Result<Vec3> {
703 let base = self.acceleration_in_wind(state, mass_kg, wind_mps)?;
704 Ok(base + self.body_lift_accel(state, mass_kg, wind_mps, body_axis)?)
705 }
706
707 pub fn acceleration_in_wind(
709 &self,
710 state: &FlightState,
711 mass_kg: f64,
712 wind_mps: Vec3,
713 ) -> Result<Vec3> {
714 state.validate()?;
715 positive(mass_kg, "mass_kg")?;
716 if !finite_vec(&wind_mps) {
717 return Err(Error::invalid("wind_mps", "must be finite"));
718 }
719 let mut inputs = self.inputs.clone();
720 inputs.bullet_mass = mass_kg;
721 inputs.weight_grains = mass_kg / GRAINS_TO_KG;
722 positive(inputs.weight_grains, "mass in grains")?;
723 let derivatives = compute_derivatives(
724 state.position_m,
725 state.velocity_mps,
726 &inputs,
727 wind_mps,
728 self.air.params(),
729 inputs.bc_value,
730 self.omega_rps,
731 state.time_s,
732 self.air.zones(),
733 );
734 let acceleration = Vec3::new(derivatives[3], derivatives[4], derivatives[5]);
735 if !finite_vec(&acceleration) {
736 return Err(Error::NumericalFailure(
737 "ballistics force model returned non-finite acceleration",
738 ));
739 }
740 Ok(acceleration)
741 }
742}
743
744impl ForceModel for BallisticForces {
745 fn acceleration(&self, state: &FlightState, mass_kg: f64) -> Result<Vec3> {
746 self.acceleration_in_wind(state, mass_kg, self.effective_wind(state))
747 }
748
749 fn acceleration_with_axis(
750 &self,
751 state: &FlightState,
752 mass_kg: f64,
753 body_axis: Vec3,
754 ) -> Result<Vec3> {
755 self.acceleration_with_axis_in_wind(state, mass_kg, self.effective_wind(state), body_axis)
756 }
757}
758
759#[cfg(test)]
760mod tests {
761 use super::*;
762
763 #[test]
764 fn turbulence_rms_matches_sigma() {
765 let gust = Turbulence::new(2.5, 2.0, 42).unwrap();
766 for axis in 0..3 {
768 let samples: Vec<f64> = (0..20_000)
769 .map(|i| gust.gust_vec(i as f64 * 0.05)[axis])
770 .collect();
771 let mean = samples.iter().sum::<f64>() / samples.len() as f64;
772 let var =
773 samples.iter().map(|s| (s - mean).powi(2)).sum::<f64>() / samples.len() as f64;
774 assert!(mean.abs() < 0.1, "axis {axis} mean {mean}");
775 assert!(
776 (var.sqrt() - 2.5).abs() / 2.5 < 0.05,
777 "axis {axis} rms {}",
778 var.sqrt()
779 );
780 }
781 }
782
783 #[test]
784 fn turbulence_is_deterministic_per_seed() {
785 let a = Turbulence::new(1.0, 2.0, 7).unwrap();
786 let b = Turbulence::new(1.0, 2.0, 7).unwrap();
787 let c = Turbulence::new(1.0, 2.0, 8).unwrap();
788 for i in 0..100 {
789 let t = i as f64 * 0.1;
790 assert_eq!(a.gust_vec(t), b.gust_vec(t));
791 }
792 assert_ne!(a.gust_vec(3.3), c.gust_vec(3.3));
793 }
794
795 #[test]
796 fn turbulence_validates_its_numbers() {
797 assert!(Turbulence::new(-1.0, 2.0, 0).is_err());
798 assert!(Turbulence::new(1.0, 0.0, 0).is_err());
799 assert!(Turbulence::new(f64::NAN, 2.0, 0).is_err());
800 }
801
802 fn pad_station() -> Atmosphere {
803 Atmosphere::Standard {
804 station_altitude_m: 1500.0,
805 temperature_c: 5.25,
806 pressure_hpa: 845.6,
807 humidity_percent: 30.0,
808 }
809 }
810
811 const POSITIONS: [(f64, f64); 6] = [
812 (0.0, 0.0),
813 (50.0, 1500.0),
814 (99.0, 1800.0),
815 (500.0, 4000.0),
816 (5_000.0, 12_000.0),
817 (50_000.0, -200.0),
818 ];
819
820 #[test]
821 fn air_without_zones_is_the_station_lapse() {
822 for atmosphere in [Atmosphere::default(), pad_station()] {
825 let (base_alt, temp_c, press_hpa, ratio) = atmosphere.parameters().unwrap();
826 let air = Air::new(atmosphere).unwrap();
827 for (x, y) in POSITIONS {
828 assert_eq!(
829 air.at(Vec3::new(x, y, 7.0)),
830 get_local_atmosphere(y, base_alt, temp_c, press_hpa, ratio)
831 );
832 }
833 }
834 let uniform = Air::new(Atmosphere::Uniform {
835 density_kg_m3: 0.9,
836 speed_of_sound_mps: 330.0,
837 })
838 .unwrap();
839 assert_eq!(uniform.at(Vec3::new(10.0, 9000.0, 0.0)), (0.9, 330.0));
840 }
841
842 #[test]
843 fn a_zone_reading_replaces_the_station_reading() {
844 let low = Atmosphere::Standard {
847 station_altitude_m: 1500.0,
848 temperature_c: -5.0,
849 pressure_hpa: 700.0,
850 humidity_percent: 50.0,
851 };
852 let zoned = Air::new(pad_station())
853 .unwrap()
854 .with_zones(vec![(5.25, 845.6, 30.0, 100.0), (-5.0, 700.0, 50.0, 1e6)])
855 .unwrap();
856 let near = Air::new(pad_station()).unwrap();
857 let far = Air::new(low).unwrap();
858 for (x, y) in POSITIONS {
859 let p = Vec3::new(x, y, 0.0);
860 let expected = if x < 100.0 { near.at(p) } else { far.at(p) };
861 assert_eq!(zoned.at(p), expected, "at x {x}, y {y}");
862 }
863 }
864
865 #[test]
866 fn air_density_is_the_drag_kernels_density() {
867 let cd = vec![(0.0, 0.4), (5.0, 0.4)];
871 let zones = vec![(5.25, 845.6, 30.0, 100.0), (-5.0, 700.0, 50.0, 1e6)];
872 let zoned = BallisticForces::new(0.05, cd.clone(), pad_station(), Vec3::zeros())
873 .unwrap()
874 .with_atmosphere_zones(zones.clone())
875 .unwrap();
876 let air = Air::new(pad_station()).unwrap().with_zones(zones).unwrap();
877 for (x, y) in POSITIONS {
878 let state = FlightState {
879 time_s: 0.0,
880 position_m: Vec3::new(x, y, 0.0),
881 velocity_mps: Vec3::new(150.0, 40.0, 0.0),
882 };
883 let (density_kg_m3, speed_of_sound_mps) = air.at(state.position_m);
884 let direct = BallisticForces::new(
885 0.05,
886 cd.clone(),
887 Atmosphere::Uniform {
888 density_kg_m3,
889 speed_of_sound_mps,
890 },
891 Vec3::zeros(),
892 )
893 .unwrap();
894 assert_eq!(
895 zoned.acceleration_in_wind(&state, 0.3, Vec3::zeros()),
896 direct.acceleration_in_wind(&state, 0.3, Vec3::zeros()),
897 "at x {x}, y {y}"
898 );
899 }
900 }
901
902 #[test]
903 fn icao_standard_at_sea_level_is_the_default() {
904 assert_eq!(Atmosphere::icao_standard(0.0), Ok(Atmosphere::default()));
905 }
906
907 #[test]
908 fn icao_standard_matches_upstreams_standard_atmosphere() {
909 use ballistics_engine::atmosphere::resolve_station_conditions;
913 for altitude in [1.5, 10.0, 100.0, 1500.0, 4000.0, 8848.0, 10_999.0, -400.0] {
914 let Ok(Atmosphere::Standard {
915 station_altitude_m,
916 temperature_c,
917 pressure_hpa,
918 humidity_percent,
919 }) = Atmosphere::icao_standard(altitude)
920 else {
921 panic!("standard station at {altitude} m");
922 };
923 let (t, p) = resolve_station_conditions(15.0, 1013.25, altitude);
924 assert_eq!(station_altitude_m, altitude);
925 assert_eq!(humidity_percent, 0.0);
926 assert!(
927 (temperature_c - t).abs() < 1e-9,
928 "{altitude} m: {temperature_c} vs {t}"
929 );
930 assert!(
931 (pressure_hpa - p).abs() < 1e-9 * p,
932 "{altitude} m: {pressure_hpa} vs {p}"
933 );
934 }
935 }
936
937 #[test]
938 fn standard_air_is_the_same_whatever_the_station_altitude() {
939 let pad = Air::new(Atmosphere::icao_standard(1500.0).unwrap()).unwrap();
944 let sea = Air::new(Atmosphere::default()).unwrap();
945 for y in [0.0, 1500.0, 3000.0, 10_000.0, 15_000.0] {
946 let (rho_pad, c_pad) = pad.at(Vec3::new(0.0, y, 0.0));
947 let (rho_sea, c_sea) = sea.at(Vec3::new(0.0, y, 0.0));
948 assert!((rho_pad - rho_sea).abs() < 3e-5 * rho_sea, "rho at {y} m");
949 assert!((c_pad - c_sea).abs() < 1e-12 * c_sea, "sound at {y} m");
950 }
951 }
952
953 #[test]
954 fn icao_standard_refuses_altitudes_outside_the_troposphere() {
955 for altitude in [f64::NAN, f64::INFINITY, -1000.5, 11_000.5] {
956 assert!(Atmosphere::icao_standard(altitude).is_err(), "{altitude}");
957 }
958 }
959
960 #[test]
961 fn wind_field_is_sampled_per_state() {
962 let profile = winds_engine::ProfileField::new(vec![
963 winds_engine::ProfileLevel {
964 altitude_m: 0.0,
965 speed_mps: 0.0,
966 direction_from_deg: 0.0,
967 },
968 winds_engine::ProfileLevel {
969 altitude_m: 1000.0,
970 speed_mps: 10.0,
971 direction_from_deg: 0.0,
972 },
973 ])
974 .unwrap();
975 let forces = BallisticForces::new(
976 0.05,
977 vec![(0.0, 0.4), (3.0, 0.4)],
978 Atmosphere::default(),
979 Vec3::zeros(),
980 )
981 .unwrap()
982 .with_wind_field(std::sync::Arc::new(profile))
983 .unwrap();
984 let state = FlightState {
985 time_s: 1.0,
986 position_m: Vec3::new(0.0, 500.0, 0.0),
987 velocity_mps: Vec3::zeros(),
988 };
989 let wind = forces.effective_wind(&state);
991 assert!((wind.x + 5.0).abs() < 1e-9, "wind {wind:?}");
992 assert!(wind.y.abs() < 1e-12 && wind.z.abs() < 1e-9);
993 }
994
995 #[test]
996 fn turbulence_adds_to_the_effective_wind() {
997 let forces = BallisticForces::new(
998 0.05,
999 vec![(0.0, 0.4), (3.0, 0.4)],
1000 Atmosphere::default(),
1001 Vec3::new(3.0, 0.0, 0.0),
1002 )
1003 .unwrap()
1004 .with_turbulence(2.0, 2.0, 42)
1005 .unwrap();
1006 let state = FlightState {
1007 time_s: 1.7,
1008 position_m: Vec3::zeros(),
1009 velocity_mps: Vec3::zeros(),
1010 };
1011 let expected = Vec3::new(3.0, 0.0, 0.0) + forces.turbulence.as_ref().unwrap().gust_vec(1.7);
1012 assert_eq!(forces.effective_wind(&state), expected);
1013 }
1014}