Skip to main content

rockets_engine/
ballistics.rs

1//! Adapter to ballistics-engine 0.48's public derivative kernel.
2//!
3//! Uses the body's own Cd-versus-Mach curve, fixed diameter, standard gravity and
4//! a level world frame. SI mass and its imperial mirror are updated at every stage,
5//! so aerodynamic deceleration responds to fuel consumption. Modified point-mass
6//! effects — spin/Magnus with yaw of repose, tip-off yaw and Coriolis — are
7//! opt-in via the `with_*` builders; reference-BC and postprocessing effects
8//! (Litz spin drift) stay disabled.
9
10use 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/// Atmospheric inputs supported by the upstream derivative kernel.
17#[derive(Debug, Clone, Copy)]
18pub enum Atmosphere {
19    /// Station measurements; the upstream model applies its local altitude lapse.
20    /// Humidity affects station density; this derivative path uses dry-air sound speed.
21    Standard {
22        /// Elevation of the weather station above sea level, in metres.
23        station_altitude_m: f64,
24        /// Station temperature in degrees Celsius (greater than absolute zero).
25        temperature_c: f64,
26        /// Positive station pressure in hectopascals.
27        pressure_hpa: f64,
28        /// Relative humidity as a percentage in [0, 100].
29        humidity_percent: f64,
30    },
31    /// Constant density and sound speed, independent of position.
32    /// Upstream's direct-mode sentinel requires 0 <= density < 2 and sound speed > 200.
33    Uniform {
34        /// Air density in kilograms per cubic metre; zero disables drag.
35        density_kg_m3: f64,
36        /// Sound speed in metres per second.
37        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/// Gravity and aerodynamic drag delegated to ballistics-engine's force kernel.
97#[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/// Validated angle-of-attack aerodynamics: normal-force slope and induced-drag
108/// factor, each piecewise-linear in Mach via the upstream table type.
109#[derive(Debug, Clone)]
110struct BodyLift {
111    /// Normal force coefficient slope per radian of angle of attack, vs Mach.
112    cna_per_rad: DragTable,
113    /// Induced drag increment per radian squared (Cd += k·α²), vs Mach.
114    induced_k_per_rad2: DragTable,
115}
116
117/// Earth rotation rate, rad/s (McCoy-level precision, matching the upstream kernel).
118const OMEGA_EARTH_RPS: f64 = 7.2921159e-5;
119
120impl BallisticForces {
121    /// Build using the vehicle's actual Cd curve, not a G-model reference curve or BC.
122    /// At least two ascending, nonnegative Mach values and positive Cd values are required.
123    /// Wind is a world-frame air velocity vector (positive X is a tailwind).
124    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    /// Apply a positive multiplier to the entire Cd curve. Default is 1.0.
164    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    /// Change uniform world-frame wind between public integration steps.
182    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    /// Modified point-mass spin: enable the kernel's Magnus force and yaw of
191    /// repose for a spinning body. `twist_rate_in` is the effective twist in
192    /// inches per turn — rifling language upstream; for a fin-canted rocket it
193    /// is the roll rate the cant produces per unit velocity. `body_length_m` is
194    /// the full airframe length (the stability correlations need it; [`Self::new`]
195    /// only knows the diameter). `right_hand` selects the roll direction.
196    ///
197    /// The correlations are McCoy bullet shapes, so treat magnitudes as
198    /// approximate for blunt or finned bodies; signs and scaling are physical.
199    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    /// Modified point-mass Coriolis deflection, for planet-scale arcs.
215    /// `latitude_deg` is the launch latitude in [-90, 90]; `shot_azimuth_deg` is
216    /// the compass bearing fired along, 0 = north, 90 = east. Do not combine
217    /// with an inertial-frame [`crate::CentralGravity`] model: Coriolis is the
218    /// rotating-frame term and would double-count Earth rotation.
219    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(),  // X: downrange
233            OMEGA_EARTH_RPS * lat.sin(),             // Y: vertical
234            -OMEGA_EARTH_RPS * lat.cos() * az.sin(), // Z: lateral
235        ));
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    /// Launch tip-off: an initial yaw in radians that decays exponentially with
243    /// downrange distance over `decay_distance_m`. Models the rail/launcher
244    /// disturbance; it perturbs the effective angle of attack early in flight.
245    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    /// Angle-of-attack aerodynamics without attitude integration: a quasi-static
256    /// normal force and induced drag from the commanded axis's angle to the
257    /// relative wind.
258    ///
259    /// `cna_vs_mach` is the normal-force coefficient slope per radian of angle of
260    /// attack (body + fins) versus Mach; `induced_k_vs_mach` adds Cd += k·α².
261    /// Both need at least two strictly ascending Mach points and nonnegative,
262    /// finite values. The angle of attack is measured each RK stage between the
263    /// solver-resolved thrust axis and the wind-relative velocity, so a guided
264    /// turn at angle of attack both turns harder (body lift) and pays drag for it.
265    /// Reference area derives from the diameter passed to [`Self::new`].
266    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    /// Local air density (kg/m³) and sound speed (m/s) at a geometric altitude,
300    /// consistent with the atmosphere handed to the kernel.
301    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    /// Quasi-static AoA force increment: normal force toward the body-axis side of
317    /// the velocity, plus induced drag along it. Zero when aligned or in still air.
318    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        // Degenerate cases: no relative wind, or a zero/ill-posed axis → no increment.
331        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        // Angle of attack from the axis to the relative wind.
337        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    /// Axis-aware counterpart to [`Self::acceleration_in_wind`], for wrappers that
363    /// sample their own wind but want the body-lift increment.
364    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    /// Evaluate using externally sampled wind, for a spatial or time-varying force wrapper.
376    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}