1use crate::{finite_vec, positive, Error, FlightState, ForceModel, Result, Vec3};
11use ballistics_engine::{
12 atmosphere::calculate_air_density_cimp, constants::GRAINS_TO_KG,
13 derivatives::compute_derivatives, drag::DragTable, BallisticInputs,
14};
15
16#[derive(Debug, Clone, Copy)]
18pub enum Atmosphere {
19 Standard {
22 station_altitude_m: f64,
24 temperature_c: f64,
26 pressure_hpa: f64,
28 humidity_percent: f64,
30 },
31 Uniform {
34 density_kg_m3: f64,
36 speed_of_sound_mps: f64,
38 },
39}
40
41impl Default for Atmosphere {
42 fn default() -> Self {
43 Self::Standard {
44 station_altitude_m: 0.0,
45 temperature_c: 15.0,
46 pressure_hpa: 1013.25,
47 humidity_percent: 0.0,
48 }
49 }
50}
51
52impl Atmosphere {
53 fn parameters(self) -> Result<(f64, f64, f64, f64)> {
54 match self {
55 Self::Standard {
56 station_altitude_m,
57 temperature_c,
58 pressure_hpa,
59 humidity_percent,
60 } => {
61 if !station_altitude_m.is_finite()
62 || !temperature_c.is_finite()
63 || temperature_c <= -273.15
64 || !humidity_percent.is_finite()
65 || !(0.0..=100.0).contains(&humidity_percent)
66 {
67 return Err(Error::invalid("standard atmosphere", "requires finite altitude, temperature above absolute zero and humidity in [0, 100]"));
68 }
69 positive(pressure_hpa, "pressure_hpa")?;
70 let ratio =
71 calculate_air_density_cimp(temperature_c, pressure_hpa, humidity_percent)
72 / 1.225;
73 positive(ratio, "station density ratio")?;
74 Ok((station_altitude_m, temperature_c, pressure_hpa, ratio))
75 }
76 Self::Uniform {
77 density_kg_m3,
78 speed_of_sound_mps,
79 } => {
80 if !density_kg_m3.is_finite()
81 || !(0.0..2.0).contains(&density_kg_m3)
82 || !speed_of_sound_mps.is_finite()
83 || speed_of_sound_mps <= 200.0
84 {
85 return Err(Error::invalid(
86 "uniform atmosphere",
87 "upstream requires density in [0, 2) and sound speed > 200 m/s",
88 ));
89 }
90 Ok((density_kg_m3, speed_of_sound_mps, 0.0, 0.0))
91 }
92 }
93 }
94}
95
96#[derive(Debug, Clone)]
98pub struct BallisticForces {
99 inputs: BallisticInputs,
100 atmos_params: (f64, f64, f64, f64),
101 atmosphere: Atmosphere,
102 wind_mps: Vec3,
103 omega_rps: Option<Vec3>,
104 body_lift: Option<BodyLift>,
105}
106
107#[derive(Debug, Clone)]
110struct BodyLift {
111 cna_per_rad: DragTable,
113 induced_k_per_rad2: DragTable,
115}
116
117const OMEGA_EARTH_RPS: f64 = 7.2921159e-5;
119
120impl BallisticForces {
121 pub fn new(
125 diameter_m: f64,
126 cd_vs_mach: Vec<(f64, f64)>,
127 atmosphere: Atmosphere,
128 wind_mps: Vec3,
129 ) -> Result<Self> {
130 positive(diameter_m, "diameter_m")?;
131 let diameter_in = diameter_m / 0.0254;
132 positive(diameter_in * diameter_in, "diameter area")?;
133 if !finite_vec(&wind_mps) {
134 return Err(Error::invalid("wind_mps", "must be finite"));
135 }
136 let (mach, cd) = cd_vs_mach.into_iter().unzip();
137 let drag = DragTable::try_new(mach, cd).map_err(Error::Model)?;
138 let inputs = BallisticInputs {
139 bullet_diameter: diameter_m,
140 caliber_inches: diameter_in,
141 bullet_length: diameter_m,
142 custom_drag_table: Some(drag),
143 custom_drag_reference: false,
144 altitude: 0.0,
145 shooting_angle: 0.0,
146 enable_advanced_effects: false,
147 enable_magnus: false,
148 enable_coriolis: false,
149 use_enhanced_spin_drift: false,
150 tipoff_yaw: 0.0,
151 ..BallisticInputs::default()
152 };
153 Ok(Self {
154 inputs,
155 atmos_params: atmosphere.parameters()?,
156 atmosphere,
157 wind_mps,
158 omega_rps: None,
159 body_lift: None,
160 })
161 }
162
163 pub fn with_drag_scale(mut self, scale: f64) -> Result<Self> {
165 positive(scale, "drag scale")?;
166 if self
167 .inputs
168 .custom_drag_table
169 .as_ref()
170 .unwrap()
171 .cd_values
172 .iter()
173 .any(|cd| !(cd * scale).is_finite())
174 {
175 return Err(Error::invalid("drag scale", "scaled Cd must remain finite"));
176 }
177 self.inputs.cd_scale = scale;
178 Ok(self)
179 }
180
181 pub fn set_wind(&mut self, wind_mps: Vec3) -> Result<()> {
183 if !finite_vec(&wind_mps) {
184 return Err(Error::invalid("wind_mps", "must be finite"));
185 }
186 self.wind_mps = wind_mps;
187 Ok(())
188 }
189
190 pub fn with_spin(
200 mut self,
201 twist_rate_in: f64,
202 body_length_m: f64,
203 right_hand: bool,
204 ) -> Result<Self> {
205 positive(twist_rate_in, "twist_rate_in")?;
206 positive(body_length_m, "body_length_m")?;
207 self.inputs.twist_rate = twist_rate_in;
208 self.inputs.is_twist_right = right_hand;
209 self.inputs.bullet_length = body_length_m;
210 self.inputs.enable_magnus = true;
211 Ok(self)
212 }
213
214 pub fn with_coriolis(mut self, latitude_deg: f64, shot_azimuth_deg: f64) -> Result<Self> {
220 if !latitude_deg.is_finite() || !(-90.0..=90.0).contains(&latitude_deg) {
221 return Err(Error::invalid(
222 "latitude_deg",
223 "must be finite and in [-90, 90]",
224 ));
225 }
226 if !shot_azimuth_deg.is_finite() {
227 return Err(Error::invalid("shot_azimuth_deg", "must be finite"));
228 }
229 let lat = latitude_deg.to_radians();
230 let az = shot_azimuth_deg.to_radians();
231 self.omega_rps = Some(Vec3::new(
232 OMEGA_EARTH_RPS * lat.cos() * az.cos(), OMEGA_EARTH_RPS * lat.sin(), -OMEGA_EARTH_RPS * lat.cos() * az.sin(), ));
236 self.inputs.latitude = Some(latitude_deg);
237 self.inputs.shot_azimuth = az;
238 self.inputs.enable_coriolis = true;
239 Ok(self)
240 }
241
242 pub fn with_tipoff_yaw(mut self, yaw_rad: f64, decay_distance_m: f64) -> Result<Self> {
246 if !yaw_rad.is_finite() {
247 return Err(Error::invalid("tipoff yaw_rad", "must be finite"));
248 }
249 positive(decay_distance_m, "tipoff decay_distance_m")?;
250 self.inputs.tipoff_yaw = yaw_rad;
251 self.inputs.tipoff_decay_distance = decay_distance_m;
252 Ok(self)
253 }
254
255 pub fn with_body_lift(
267 mut self,
268 cna_vs_mach: Vec<(f64, f64)>,
269 induced_k_vs_mach: Vec<(f64, f64)>,
270 ) -> Result<Self> {
271 let table = |pairs: Vec<(f64, f64)>, name: &'static str| -> Result<DragTable> {
272 if pairs.len() < 2 {
273 return Err(Error::invalid(name, "needs at least two Mach points"));
274 }
275 for (i, (mach, value)) in pairs.iter().enumerate() {
276 if !mach.is_finite() || *mach < 0.0 {
277 return Err(Error::invalid(name, "Mach must be finite and nonnegative"));
278 }
279 if i > 0 && *mach <= pairs[i - 1].0 {
280 return Err(Error::invalid(name, "Mach must strictly ascend"));
281 }
282 if !value.is_finite() || *value < 0.0 {
283 return Err(Error::invalid(
284 name,
285 "values must be finite and nonnegative",
286 ));
287 }
288 }
289 let (mach_values, cd_values) = pairs.into_iter().unzip();
290 Ok(DragTable::new(mach_values, cd_values))
291 };
292 self.body_lift = Some(BodyLift {
293 cna_per_rad: table(cna_vs_mach, "cna_vs_mach")?,
294 induced_k_per_rad2: table(induced_k_vs_mach, "induced_k_vs_mach")?,
295 });
296 Ok(self)
297 }
298
299 fn local_air(&self, altitude_m: f64) -> (f64, f64) {
302 match self.atmosphere {
303 Atmosphere::Standard { .. } => {
304 let (base_alt, temp_c, press_hpa, ratio) = self.atmos_params;
305 ballistics_engine::atmosphere::get_local_atmosphere(
306 altitude_m, base_alt, temp_c, press_hpa, ratio,
307 )
308 }
309 Atmosphere::Uniform { .. } => {
310 let (density, speed_of_sound, _, _) = self.atmos_params;
311 (density, speed_of_sound)
312 }
313 }
314 }
315
316 fn body_lift_accel(
319 &self,
320 state: &FlightState,
321 mass_kg: f64,
322 wind_mps: Vec3,
323 body_axis: Vec3,
324 ) -> Result<Vec3> {
325 let Some(lift) = &self.body_lift else {
326 return Ok(Vec3::zeros());
327 };
328 let v_air = state.velocity_mps - wind_mps;
329 let speed = v_air.norm();
330 if speed < 1e-12 || body_axis.amax() == 0.0 {
332 return Ok(Vec3::zeros());
333 }
334 let v_hat = v_air / speed;
335 let axis = body_axis / body_axis.norm();
336 let perp = axis - axis.dot(&v_hat) * v_hat;
338 let sin_alpha = perp.norm();
339 let cos_alpha = axis.dot(&v_hat).clamp(-1.0, 1.0);
340 let alpha = sin_alpha.atan2(cos_alpha);
341 if alpha < 1e-12 {
342 return Ok(Vec3::zeros());
343 }
344 let (rho, sound) = self.local_air(state.position_m.y);
345 if rho <= 0.0 || sound <= 0.0 {
346 return Ok(Vec3::zeros());
347 }
348 let mach = speed / sound;
349 let q = 0.5 * rho * speed * speed;
350 let area = std::f64::consts::FRAC_PI_4 * self.inputs.bullet_diameter.powi(2);
351 let normal_force = q * area * lift.cna_per_rad.interpolate(mach) * alpha;
352 let induced_drag = q * area * lift.induced_k_per_rad2.interpolate(mach) * alpha * alpha;
353 let accel = (normal_force * (perp / sin_alpha) - induced_drag * v_hat) / mass_kg;
354 if !finite_vec(&accel) {
355 return Err(Error::NumericalFailure(
356 "body lift returned non-finite acceleration",
357 ));
358 }
359 Ok(accel)
360 }
361
362 pub fn acceleration_with_axis_in_wind(
365 &self,
366 state: &FlightState,
367 mass_kg: f64,
368 wind_mps: Vec3,
369 body_axis: Vec3,
370 ) -> Result<Vec3> {
371 let base = self.acceleration_in_wind(state, mass_kg, wind_mps)?;
372 Ok(base + self.body_lift_accel(state, mass_kg, wind_mps, body_axis)?)
373 }
374
375 pub fn acceleration_in_wind(
377 &self,
378 state: &FlightState,
379 mass_kg: f64,
380 wind_mps: Vec3,
381 ) -> Result<Vec3> {
382 state.validate()?;
383 positive(mass_kg, "mass_kg")?;
384 if !finite_vec(&wind_mps) {
385 return Err(Error::invalid("wind_mps", "must be finite"));
386 }
387 let mut inputs = self.inputs.clone();
388 inputs.bullet_mass = mass_kg;
389 inputs.weight_grains = mass_kg / GRAINS_TO_KG;
390 positive(inputs.weight_grains, "mass in grains")?;
391 let derivatives = compute_derivatives(
392 state.position_m,
393 state.velocity_mps,
394 &inputs,
395 wind_mps,
396 self.atmos_params,
397 inputs.bc_value,
398 self.omega_rps,
399 state.time_s,
400 None,
401 );
402 let acceleration = Vec3::new(derivatives[3], derivatives[4], derivatives[5]);
403 if !finite_vec(&acceleration) {
404 return Err(Error::NumericalFailure(
405 "ballistics force model returned non-finite acceleration",
406 ));
407 }
408 Ok(acceleration)
409 }
410}
411
412impl ForceModel for BallisticForces {
413 fn acceleration(&self, state: &FlightState, mass_kg: f64) -> Result<Vec3> {
414 self.acceleration_in_wind(state, mass_kg, self.wind_mps)
415 }
416
417 fn acceleration_with_axis(
418 &self,
419 state: &FlightState,
420 mass_kg: f64,
421 body_axis: Vec3,
422 ) -> Result<Vec3> {
423 self.acceleration_with_axis_in_wind(state, mass_kg, self.wind_mps, body_axis)
424 }
425}