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 omega_rps: Option<Vec3>,
153 body_lift: Option<BodyLift>,
154 atmo_sock: Option<AtmoSock>,
155 wind_sock: Option<WindSock>,
156}
157
158#[derive(Debug, Clone)]
161struct BodyLift {
162 cna_per_rad: DragTable,
164 induced_k_per_rad2: DragTable,
166}
167
168const OMEGA_EARTH_RPS: f64 = 7.2921159e-5;
170
171impl BallisticForces {
172 pub fn new(
176 diameter_m: f64,
177 cd_vs_mach: Vec<(f64, f64)>,
178 atmosphere: Atmosphere,
179 wind_mps: Vec3,
180 ) -> Result<Self> {
181 positive(diameter_m, "diameter_m")?;
182 let diameter_in = diameter_m / 0.0254;
183 positive(diameter_in * diameter_in, "diameter area")?;
184 if !finite_vec(&wind_mps) {
185 return Err(Error::invalid("wind_mps", "must be finite"));
186 }
187 let (mach, cd) = cd_vs_mach.into_iter().unzip();
188 let drag = DragTable::try_new(mach, cd).map_err(Error::Model)?;
189 let inputs = BallisticInputs {
190 bullet_diameter: diameter_m,
191 caliber_inches: diameter_in,
192 bullet_length: diameter_m,
193 custom_drag_table: Some(drag),
194 custom_drag_reference: false,
195 altitude: 0.0,
196 shooting_angle: 0.0,
197 enable_advanced_effects: false,
198 enable_magnus: false,
199 enable_coriolis: false,
200 use_enhanced_spin_drift: false,
201 tipoff_yaw: 0.0,
202 ..BallisticInputs::default()
203 };
204 Ok(Self {
205 inputs,
206 atmos_params: atmosphere.parameters()?,
207 atmosphere,
208 wind_mps,
209 omega_rps: None,
210 body_lift: None,
211 atmo_sock: None,
212 wind_sock: None,
213 })
214 }
215
216 pub fn with_drag_scale(mut self, scale: f64) -> Result<Self> {
218 positive(scale, "drag scale")?;
219 if self
220 .inputs
221 .custom_drag_table
222 .as_ref()
223 .unwrap()
224 .cd_values
225 .iter()
226 .any(|cd| !(cd * scale).is_finite())
227 {
228 return Err(Error::invalid("drag scale", "scaled Cd must remain finite"));
229 }
230 self.inputs.cd_scale = scale;
231 Ok(self)
232 }
233
234 pub fn set_wind(&mut self, wind_mps: Vec3) -> Result<()> {
236 if !finite_vec(&wind_mps) {
237 return Err(Error::invalid("wind_mps", "must be finite"));
238 }
239 self.wind_mps = wind_mps;
240 Ok(())
241 }
242
243 pub fn with_spin(
253 mut self,
254 twist_rate_in: f64,
255 body_length_m: f64,
256 right_hand: bool,
257 ) -> Result<Self> {
258 positive(twist_rate_in, "twist_rate_in")?;
259 positive(body_length_m, "body_length_m")?;
260 self.inputs.twist_rate = twist_rate_in;
261 self.inputs.is_twist_right = right_hand;
262 self.inputs.bullet_length = body_length_m;
263 self.inputs.enable_magnus = true;
264 Ok(self)
265 }
266
267 pub fn with_coriolis(mut self, latitude_deg: f64, shot_azimuth_deg: f64) -> Result<Self> {
273 if !latitude_deg.is_finite() || !(-90.0..=90.0).contains(&latitude_deg) {
274 return Err(Error::invalid(
275 "latitude_deg",
276 "must be finite and in [-90, 90]",
277 ));
278 }
279 if !shot_azimuth_deg.is_finite() {
280 return Err(Error::invalid("shot_azimuth_deg", "must be finite"));
281 }
282 let lat = latitude_deg.to_radians();
283 let az = shot_azimuth_deg.to_radians();
284 self.omega_rps = Some(Vec3::new(
285 OMEGA_EARTH_RPS * lat.cos() * az.cos(), OMEGA_EARTH_RPS * lat.sin(), -OMEGA_EARTH_RPS * lat.cos() * az.sin(), ));
289 self.inputs.latitude = Some(latitude_deg);
290 self.inputs.shot_azimuth = az;
291 self.inputs.enable_coriolis = true;
292 Ok(self)
293 }
294
295 pub fn with_tipoff_yaw(mut self, yaw_rad: f64, decay_distance_m: f64) -> Result<Self> {
299 if !yaw_rad.is_finite() {
300 return Err(Error::invalid("tipoff yaw_rad", "must be finite"));
301 }
302 positive(decay_distance_m, "tipoff decay_distance_m")?;
303 self.inputs.tipoff_yaw = yaw_rad;
304 self.inputs.tipoff_decay_distance = decay_distance_m;
305 Ok(self)
306 }
307
308 pub fn with_body_lift(
320 mut self,
321 cna_vs_mach: Vec<(f64, f64)>,
322 induced_k_vs_mach: Vec<(f64, f64)>,
323 ) -> Result<Self> {
324 let table = |pairs: Vec<(f64, f64)>, name: &'static str| -> Result<DragTable> {
325 mach_table(pairs, name)
326 };
327 self.body_lift = Some(BodyLift {
328 cna_per_rad: table(cna_vs_mach, "cna_vs_mach")?,
329 induced_k_per_rad2: table(induced_k_vs_mach, "induced_k_vs_mach")?,
330 });
331 Ok(self)
332 }
333
334 pub fn set_twist(&mut self, twist_rate_in: f64) -> Result<()> {
338 positive(twist_rate_in, "twist_rate_in")?;
339 self.inputs.twist_rate = twist_rate_in;
340 Ok(())
341 }
342
343 pub fn with_atmosphere_zones(mut self, zones: Vec<(f64, f64, f64, f64)>) -> Result<Self> {
349 if !matches!(self.atmosphere, Atmosphere::Standard { .. }) {
350 return Err(Error::invalid(
351 "atmosphere zones",
352 "require the Standard atmosphere, not Uniform",
353 ));
354 }
355 if zones.is_empty() {
356 return Err(Error::invalid(
357 "atmosphere zones",
358 "needs at least one zone",
359 ));
360 }
361 for (temp_c, pressure_hpa, humidity, until_m) in &zones {
362 if !temp_c.is_finite()
363 || !pressure_hpa.is_finite()
364 || *pressure_hpa <= 0.0
365 || !humidity.is_finite()
366 || !(0.0..=100.0).contains(humidity)
367 || !until_m.is_finite()
368 || *until_m <= 0.0
369 {
370 return Err(Error::invalid(
371 "atmosphere zone",
372 "needs finite temperature, positive pressure, humidity in [0, 100] and positive until_m",
373 ));
374 }
375 }
376 self.atmo_sock = Some(AtmoSock::new(zones));
377 Ok(self)
378 }
379
380 pub fn with_wind_segments(mut self, zones: Vec<(f64, f64, f64, f64)>) -> Result<Self> {
387 if zones.is_empty() {
388 return Err(Error::invalid(
389 "wind segments",
390 "needs at least one segment",
391 ));
392 }
393 let segments: Vec<WindSegment> = zones
394 .into_iter()
395 .map(
396 |(speed_mps, angle_deg, until_m, vertical_mps)| WindSegment {
397 speed_kmh: speed_mps * 3.6,
398 angle_deg,
399 until_m,
400 vertical_mps,
401 },
402 )
403 .collect();
404 let sock = WindSock::try_new(segments)
405 .map_err(|e| Error::Model(format!("invalid wind segments: {e:?}")))?;
406 self.wind_sock = Some(sock);
407 Ok(self)
408 }
409
410 fn effective_wind(&self, state: &FlightState) -> Vec3 {
412 match &self.wind_sock {
413 Some(sock) => sock.vector_for_range_stateless(state.position_m.x),
414 None => self.wind_mps,
415 }
416 }
417
418 fn local_air(&self, altitude_m: f64) -> (f64, f64) {
421 local_air(self.atmosphere, self.atmos_params, altitude_m)
422 }
423
424 fn body_lift_accel(
427 &self,
428 state: &FlightState,
429 mass_kg: f64,
430 wind_mps: Vec3,
431 body_axis: Vec3,
432 ) -> Result<Vec3> {
433 let Some(lift) = &self.body_lift else {
434 return Ok(Vec3::zeros());
435 };
436 let v_air = state.velocity_mps - wind_mps;
437 let speed = v_air.norm();
438 if speed < 1e-12 || body_axis.amax() == 0.0 {
440 return Ok(Vec3::zeros());
441 }
442 let v_hat = v_air / speed;
443 let axis = body_axis / body_axis.norm();
444 let perp = axis - axis.dot(&v_hat) * v_hat;
446 let sin_alpha = perp.norm();
447 let cos_alpha = axis.dot(&v_hat).clamp(-1.0, 1.0);
448 let alpha = sin_alpha.atan2(cos_alpha);
449 if alpha < 1e-12 {
450 return Ok(Vec3::zeros());
451 }
452 let (rho, sound) = self.local_air(state.position_m.y);
453 if rho <= 0.0 || sound <= 0.0 {
454 return Ok(Vec3::zeros());
455 }
456 let mach = speed / sound;
457 let q = 0.5 * rho * speed * speed;
458 let area = std::f64::consts::FRAC_PI_4 * self.inputs.bullet_diameter.powi(2);
459 let normal_force = q * area * lift.cna_per_rad.interpolate(mach) * alpha;
460 let induced_drag = q * area * lift.induced_k_per_rad2.interpolate(mach) * alpha * alpha;
461 let accel = (normal_force * (perp / sin_alpha) - induced_drag * v_hat) / mass_kg;
462 if !finite_vec(&accel) {
463 return Err(Error::NumericalFailure(
464 "body lift returned non-finite acceleration",
465 ));
466 }
467 Ok(accel)
468 }
469
470 pub fn acceleration_with_axis_in_wind(
473 &self,
474 state: &FlightState,
475 mass_kg: f64,
476 wind_mps: Vec3,
477 body_axis: Vec3,
478 ) -> Result<Vec3> {
479 let base = self.acceleration_in_wind(state, mass_kg, wind_mps)?;
480 Ok(base + self.body_lift_accel(state, mass_kg, wind_mps, body_axis)?)
481 }
482
483 pub fn acceleration_in_wind(
485 &self,
486 state: &FlightState,
487 mass_kg: f64,
488 wind_mps: Vec3,
489 ) -> Result<Vec3> {
490 state.validate()?;
491 positive(mass_kg, "mass_kg")?;
492 if !finite_vec(&wind_mps) {
493 return Err(Error::invalid("wind_mps", "must be finite"));
494 }
495 let mut inputs = self.inputs.clone();
496 inputs.bullet_mass = mass_kg;
497 inputs.weight_grains = mass_kg / GRAINS_TO_KG;
498 positive(inputs.weight_grains, "mass in grains")?;
499 let derivatives = compute_derivatives(
500 state.position_m,
501 state.velocity_mps,
502 &inputs,
503 wind_mps,
504 self.atmos_params,
505 inputs.bc_value,
506 self.omega_rps,
507 state.time_s,
508 self.atmo_sock.as_ref(),
509 );
510 let acceleration = Vec3::new(derivatives[3], derivatives[4], derivatives[5]);
511 if !finite_vec(&acceleration) {
512 return Err(Error::NumericalFailure(
513 "ballistics force model returned non-finite acceleration",
514 ));
515 }
516 Ok(acceleration)
517 }
518}
519
520impl ForceModel for BallisticForces {
521 fn acceleration(&self, state: &FlightState, mass_kg: f64) -> Result<Vec3> {
522 self.acceleration_in_wind(state, mass_kg, self.effective_wind(state))
523 }
524
525 fn acceleration_with_axis(
526 &self,
527 state: &FlightState,
528 mass_kg: f64,
529 body_axis: Vec3,
530 ) -> Result<Vec3> {
531 self.acceleration_with_axis_in_wind(state, mass_kg, self.effective_wind(state), body_axis)
532 }
533}