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 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
100#[derive(Debug, Clone)]
102pub struct BallisticForces {
103 inputs: BallisticInputs,
104 atmos_params: (f64, f64, f64, f64),
105 atmosphere: Atmosphere,
106 wind_mps: Vec3,
107 omega_rps: Option<Vec3>,
108 body_lift: Option<BodyLift>,
109 atmo_sock: Option<AtmoSock>,
110 wind_sock: Option<WindSock>,
111}
112
113#[derive(Debug, Clone)]
116struct BodyLift {
117 cna_per_rad: DragTable,
119 induced_k_per_rad2: DragTable,
121}
122
123const OMEGA_EARTH_RPS: f64 = 7.2921159e-5;
125
126impl BallisticForces {
127 pub fn new(
131 diameter_m: f64,
132 cd_vs_mach: Vec<(f64, f64)>,
133 atmosphere: Atmosphere,
134 wind_mps: Vec3,
135 ) -> Result<Self> {
136 positive(diameter_m, "diameter_m")?;
137 let diameter_in = diameter_m / 0.0254;
138 positive(diameter_in * diameter_in, "diameter area")?;
139 if !finite_vec(&wind_mps) {
140 return Err(Error::invalid("wind_mps", "must be finite"));
141 }
142 let (mach, cd) = cd_vs_mach.into_iter().unzip();
143 let drag = DragTable::try_new(mach, cd).map_err(Error::Model)?;
144 let inputs = BallisticInputs {
145 bullet_diameter: diameter_m,
146 caliber_inches: diameter_in,
147 bullet_length: diameter_m,
148 custom_drag_table: Some(drag),
149 custom_drag_reference: false,
150 altitude: 0.0,
151 shooting_angle: 0.0,
152 enable_advanced_effects: false,
153 enable_magnus: false,
154 enable_coriolis: false,
155 use_enhanced_spin_drift: false,
156 tipoff_yaw: 0.0,
157 ..BallisticInputs::default()
158 };
159 Ok(Self {
160 inputs,
161 atmos_params: atmosphere.parameters()?,
162 atmosphere,
163 wind_mps,
164 omega_rps: None,
165 body_lift: None,
166 atmo_sock: None,
167 wind_sock: None,
168 })
169 }
170
171 pub fn with_drag_scale(mut self, scale: f64) -> Result<Self> {
173 positive(scale, "drag scale")?;
174 if self
175 .inputs
176 .custom_drag_table
177 .as_ref()
178 .unwrap()
179 .cd_values
180 .iter()
181 .any(|cd| !(cd * scale).is_finite())
182 {
183 return Err(Error::invalid("drag scale", "scaled Cd must remain finite"));
184 }
185 self.inputs.cd_scale = scale;
186 Ok(self)
187 }
188
189 pub fn set_wind(&mut self, wind_mps: Vec3) -> Result<()> {
191 if !finite_vec(&wind_mps) {
192 return Err(Error::invalid("wind_mps", "must be finite"));
193 }
194 self.wind_mps = wind_mps;
195 Ok(())
196 }
197
198 pub fn with_spin(
208 mut self,
209 twist_rate_in: f64,
210 body_length_m: f64,
211 right_hand: bool,
212 ) -> Result<Self> {
213 positive(twist_rate_in, "twist_rate_in")?;
214 positive(body_length_m, "body_length_m")?;
215 self.inputs.twist_rate = twist_rate_in;
216 self.inputs.is_twist_right = right_hand;
217 self.inputs.bullet_length = body_length_m;
218 self.inputs.enable_magnus = true;
219 Ok(self)
220 }
221
222 pub fn with_coriolis(mut self, latitude_deg: f64, shot_azimuth_deg: f64) -> Result<Self> {
228 if !latitude_deg.is_finite() || !(-90.0..=90.0).contains(&latitude_deg) {
229 return Err(Error::invalid(
230 "latitude_deg",
231 "must be finite and in [-90, 90]",
232 ));
233 }
234 if !shot_azimuth_deg.is_finite() {
235 return Err(Error::invalid("shot_azimuth_deg", "must be finite"));
236 }
237 let lat = latitude_deg.to_radians();
238 let az = shot_azimuth_deg.to_radians();
239 self.omega_rps = Some(Vec3::new(
240 OMEGA_EARTH_RPS * lat.cos() * az.cos(), OMEGA_EARTH_RPS * lat.sin(), -OMEGA_EARTH_RPS * lat.cos() * az.sin(), ));
244 self.inputs.latitude = Some(latitude_deg);
245 self.inputs.shot_azimuth = az;
246 self.inputs.enable_coriolis = true;
247 Ok(self)
248 }
249
250 pub fn with_tipoff_yaw(mut self, yaw_rad: f64, decay_distance_m: f64) -> Result<Self> {
254 if !yaw_rad.is_finite() {
255 return Err(Error::invalid("tipoff yaw_rad", "must be finite"));
256 }
257 positive(decay_distance_m, "tipoff decay_distance_m")?;
258 self.inputs.tipoff_yaw = yaw_rad;
259 self.inputs.tipoff_decay_distance = decay_distance_m;
260 Ok(self)
261 }
262
263 pub fn with_body_lift(
275 mut self,
276 cna_vs_mach: Vec<(f64, f64)>,
277 induced_k_vs_mach: Vec<(f64, f64)>,
278 ) -> Result<Self> {
279 let table = |pairs: Vec<(f64, f64)>, name: &'static str| -> Result<DragTable> {
280 if pairs.len() < 2 {
281 return Err(Error::invalid(name, "needs at least two Mach points"));
282 }
283 for (i, (mach, value)) in pairs.iter().enumerate() {
284 if !mach.is_finite() || *mach < 0.0 {
285 return Err(Error::invalid(name, "Mach must be finite and nonnegative"));
286 }
287 if i > 0 && *mach <= pairs[i - 1].0 {
288 return Err(Error::invalid(name, "Mach must strictly ascend"));
289 }
290 if !value.is_finite() || *value < 0.0 {
291 return Err(Error::invalid(
292 name,
293 "values must be finite and nonnegative",
294 ));
295 }
296 }
297 let (mach_values, cd_values) = pairs.into_iter().unzip();
298 Ok(DragTable::new(mach_values, cd_values))
299 };
300 self.body_lift = Some(BodyLift {
301 cna_per_rad: table(cna_vs_mach, "cna_vs_mach")?,
302 induced_k_per_rad2: table(induced_k_vs_mach, "induced_k_vs_mach")?,
303 });
304 Ok(self)
305 }
306
307 pub fn with_atmosphere_zones(mut self, zones: Vec<(f64, f64, f64, f64)>) -> Result<Self> {
313 if !matches!(self.atmosphere, Atmosphere::Standard { .. }) {
314 return Err(Error::invalid(
315 "atmosphere zones",
316 "require the Standard atmosphere, not Uniform",
317 ));
318 }
319 if zones.is_empty() {
320 return Err(Error::invalid(
321 "atmosphere zones",
322 "needs at least one zone",
323 ));
324 }
325 for (temp_c, pressure_hpa, humidity, until_m) in &zones {
326 if !temp_c.is_finite()
327 || !pressure_hpa.is_finite()
328 || *pressure_hpa <= 0.0
329 || !humidity.is_finite()
330 || !(0.0..=100.0).contains(humidity)
331 || !until_m.is_finite()
332 || *until_m <= 0.0
333 {
334 return Err(Error::invalid(
335 "atmosphere zone",
336 "needs finite temperature, positive pressure, humidity in [0, 100] and positive until_m",
337 ));
338 }
339 }
340 self.atmo_sock = Some(AtmoSock::new(zones));
341 Ok(self)
342 }
343
344 pub fn with_wind_segments(mut self, zones: Vec<(f64, f64, f64, f64)>) -> Result<Self> {
351 if zones.is_empty() {
352 return Err(Error::invalid(
353 "wind segments",
354 "needs at least one segment",
355 ));
356 }
357 let segments: Vec<WindSegment> = zones
358 .into_iter()
359 .map(
360 |(speed_mps, angle_deg, until_m, vertical_mps)| WindSegment {
361 speed_kmh: speed_mps * 3.6,
362 angle_deg,
363 until_m,
364 vertical_mps,
365 },
366 )
367 .collect();
368 let sock = WindSock::try_new(segments)
369 .map_err(|e| Error::Model(format!("invalid wind segments: {e:?}")))?;
370 self.wind_sock = Some(sock);
371 Ok(self)
372 }
373
374 fn effective_wind(&self, state: &FlightState) -> Vec3 {
376 match &self.wind_sock {
377 Some(sock) => sock.vector_for_range_stateless(state.position_m.x),
378 None => self.wind_mps,
379 }
380 }
381
382 fn local_air(&self, altitude_m: f64) -> (f64, f64) {
385 match self.atmosphere {
386 Atmosphere::Standard { .. } => {
387 let (base_alt, temp_c, press_hpa, ratio) = self.atmos_params;
388 ballistics_engine::atmosphere::get_local_atmosphere(
389 altitude_m, base_alt, temp_c, press_hpa, ratio,
390 )
391 }
392 Atmosphere::Uniform { .. } => {
393 let (density, speed_of_sound, _, _) = self.atmos_params;
394 (density, speed_of_sound)
395 }
396 }
397 }
398
399 fn body_lift_accel(
402 &self,
403 state: &FlightState,
404 mass_kg: f64,
405 wind_mps: Vec3,
406 body_axis: Vec3,
407 ) -> Result<Vec3> {
408 let Some(lift) = &self.body_lift else {
409 return Ok(Vec3::zeros());
410 };
411 let v_air = state.velocity_mps - wind_mps;
412 let speed = v_air.norm();
413 if speed < 1e-12 || body_axis.amax() == 0.0 {
415 return Ok(Vec3::zeros());
416 }
417 let v_hat = v_air / speed;
418 let axis = body_axis / body_axis.norm();
419 let perp = axis - axis.dot(&v_hat) * v_hat;
421 let sin_alpha = perp.norm();
422 let cos_alpha = axis.dot(&v_hat).clamp(-1.0, 1.0);
423 let alpha = sin_alpha.atan2(cos_alpha);
424 if alpha < 1e-12 {
425 return Ok(Vec3::zeros());
426 }
427 let (rho, sound) = self.local_air(state.position_m.y);
428 if rho <= 0.0 || sound <= 0.0 {
429 return Ok(Vec3::zeros());
430 }
431 let mach = speed / sound;
432 let q = 0.5 * rho * speed * speed;
433 let area = std::f64::consts::FRAC_PI_4 * self.inputs.bullet_diameter.powi(2);
434 let normal_force = q * area * lift.cna_per_rad.interpolate(mach) * alpha;
435 let induced_drag = q * area * lift.induced_k_per_rad2.interpolate(mach) * alpha * alpha;
436 let accel = (normal_force * (perp / sin_alpha) - induced_drag * v_hat) / mass_kg;
437 if !finite_vec(&accel) {
438 return Err(Error::NumericalFailure(
439 "body lift returned non-finite acceleration",
440 ));
441 }
442 Ok(accel)
443 }
444
445 pub fn acceleration_with_axis_in_wind(
448 &self,
449 state: &FlightState,
450 mass_kg: f64,
451 wind_mps: Vec3,
452 body_axis: Vec3,
453 ) -> Result<Vec3> {
454 let base = self.acceleration_in_wind(state, mass_kg, wind_mps)?;
455 Ok(base + self.body_lift_accel(state, mass_kg, wind_mps, body_axis)?)
456 }
457
458 pub fn acceleration_in_wind(
460 &self,
461 state: &FlightState,
462 mass_kg: f64,
463 wind_mps: Vec3,
464 ) -> Result<Vec3> {
465 state.validate()?;
466 positive(mass_kg, "mass_kg")?;
467 if !finite_vec(&wind_mps) {
468 return Err(Error::invalid("wind_mps", "must be finite"));
469 }
470 let mut inputs = self.inputs.clone();
471 inputs.bullet_mass = mass_kg;
472 inputs.weight_grains = mass_kg / GRAINS_TO_KG;
473 positive(inputs.weight_grains, "mass in grains")?;
474 let derivatives = compute_derivatives(
475 state.position_m,
476 state.velocity_mps,
477 &inputs,
478 wind_mps,
479 self.atmos_params,
480 inputs.bc_value,
481 self.omega_rps,
482 state.time_s,
483 self.atmo_sock.as_ref(),
484 );
485 let acceleration = Vec3::new(derivatives[3], derivatives[4], derivatives[5]);
486 if !finite_vec(&acceleration) {
487 return Err(Error::NumericalFailure(
488 "ballistics force model returned non-finite acceleration",
489 ));
490 }
491 Ok(acceleration)
492 }
493}
494
495impl ForceModel for BallisticForces {
496 fn acceleration(&self, state: &FlightState, mass_kg: f64) -> Result<Vec3> {
497 self.acceleration_in_wind(state, mass_kg, self.effective_wind(state))
498 }
499
500 fn acceleration_with_axis(
501 &self,
502 state: &FlightState,
503 mass_kg: f64,
504 body_axis: Vec3,
505 ) -> Result<Vec3> {
506 self.acceleration_with_axis_in_wind(state, mass_kg, self.effective_wind(state), body_axis)
507 }
508}