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, AtmoSock},
13    constants::GRAINS_TO_KG,
14    derivatives::compute_derivatives,
15    drag::DragTable,
16    wind::{WindSegment, WindSock},
17    BallisticInputs,
18};
19
20/// Atmospheric inputs supported by the upstream derivative kernel.
21#[derive(Debug, Clone, Copy)]
22pub enum Atmosphere {
23    /// Station measurements; the upstream model applies its local altitude lapse.
24    /// Humidity affects station density; this derivative path uses dry-air sound speed.
25    Standard {
26        /// Elevation of the weather station above sea level, in metres.
27        station_altitude_m: f64,
28        /// Station temperature in degrees Celsius (greater than absolute zero).
29        temperature_c: f64,
30        /// Positive station pressure in hectopascals.
31        pressure_hpa: f64,
32        /// Relative humidity as a percentage in [0, 100].
33        humidity_percent: f64,
34    },
35    /// Constant density and sound speed, independent of position.
36    /// Upstream's direct-mode sentinel requires 0 <= density < 2 and sound speed > 200.
37    Uniform {
38        /// Air density in kilograms per cubic metre; zero disables drag.
39        density_kg_m3: f64,
40        /// Sound speed in metres per second.
41        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/// Gravity and aerodynamic drag delegated to ballistics-engine's force kernel.
101#[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/// Validated angle-of-attack aerodynamics: normal-force slope and induced-drag
114/// factor, each piecewise-linear in Mach via the upstream table type.
115#[derive(Debug, Clone)]
116struct BodyLift {
117    /// Normal force coefficient slope per radian of angle of attack, vs Mach.
118    cna_per_rad: DragTable,
119    /// Induced drag increment per radian squared (Cd += k·α²), vs Mach.
120    induced_k_per_rad2: DragTable,
121}
122
123/// Earth rotation rate, rad/s (McCoy-level precision, matching the upstream kernel).
124const OMEGA_EARTH_RPS: f64 = 7.2921159e-5;
125
126impl BallisticForces {
127    /// Build using the vehicle's actual Cd curve, not a G-model reference curve or BC.
128    /// At least two ascending, nonnegative Mach values and positive Cd values are required.
129    /// Wind is a world-frame air velocity vector (positive X is a tailwind).
130    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    /// Apply a positive multiplier to the entire Cd curve. Default is 1.0.
172    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    /// Change uniform world-frame wind between public integration steps.
190    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    /// Modified point-mass spin: enable the kernel's Magnus force and yaw of
199    /// repose for a spinning body. `twist_rate_in` is the effective twist in
200    /// inches per turn — rifling language upstream; for a fin-canted rocket it
201    /// is the roll rate the cant produces per unit velocity. `body_length_m` is
202    /// the full airframe length (the stability correlations need it; [`Self::new`]
203    /// only knows the diameter). `right_hand` selects the roll direction.
204    ///
205    /// The correlations are McCoy bullet shapes, so treat magnitudes as
206    /// approximate for blunt or finned bodies; signs and scaling are physical.
207    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    /// Modified point-mass Coriolis deflection, for planet-scale arcs.
223    /// `latitude_deg` is the launch latitude in [-90, 90]; `shot_azimuth_deg` is
224    /// the compass bearing fired along, 0 = north, 90 = east. Do not combine
225    /// with an inertial-frame [`crate::CentralGravity`] model: Coriolis is the
226    /// rotating-frame term and would double-count Earth rotation.
227    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(),  // X: downrange
241            OMEGA_EARTH_RPS * lat.sin(),             // Y: vertical
242            -OMEGA_EARTH_RPS * lat.cos() * az.sin(), // Z: lateral
243        ));
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    /// Launch tip-off: an initial yaw in radians that decays exponentially with
251    /// downrange distance over `decay_distance_m`. Models the rail/launcher
252    /// disturbance; it perturbs the effective angle of attack early in flight.
253    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    /// Angle-of-attack aerodynamics without attitude integration: a quasi-static
264    /// normal force and induced drag from the commanded axis's angle to the
265    /// relative wind.
266    ///
267    /// `cna_vs_mach` is the normal-force coefficient slope per radian of angle of
268    /// attack (body + fins) versus Mach; `induced_k_vs_mach` adds Cd += k·α².
269    /// Both need at least two strictly ascending Mach points and nonnegative,
270    /// finite values. The angle of attack is measured each RK stage between the
271    /// solver-resolved thrust axis and the wind-relative velocity, so a guided
272    /// turn at angle of attack both turns harder (body lift) and pays drag for it.
273    /// Reference area derives from the diameter passed to [`Self::new`].
274    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    /// Downrange-segmented atmosphere (upstream `AtmoSock`): station-referenced
308    /// zones, each `(temperature_c, pressure_hpa, humidity_percent, until_downrange_m)`.
309    /// The zone is selected by downrange distance `position.x`; the base atmosphere
310    /// must be [`Atmosphere::Standard`], and each zone's humidity applies to its
311    /// station density while this derivative path uses dry-air sound speed.
312    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    /// Downrange-segmented wind (upstream `WindSock`). Each zone is
345    /// `(speed_mps, angle_deg, until_downrange_m, vertical_mps)`, with the angle
346    /// following the upstream wind-FROM convention (0° = headwind, 90° = from the
347    /// right). Vertical is positive updraft. Zones are evaluated statelessly at
348    /// each stage's `position.x`, so wind can change mid-integration.
349    /// Overrides the uniform wind set in [`Self::new`]/[`Self::set_wind`].
350    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    /// Wind at a stage: segmented downrange wind if configured, else the uniform vector.
375    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    /// Local air density (kg/m³) and sound speed (m/s) at a geometric altitude,
383    /// consistent with the atmosphere handed to the kernel.
384    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    /// Quasi-static AoA force increment: normal force toward the body-axis side of
400    /// the velocity, plus induced drag along it. Zero when aligned or in still air.
401    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        // Degenerate cases: no relative wind, or a zero/ill-posed axis → no increment.
414        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        // Angle of attack from the axis to the relative wind.
420        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    /// Axis-aware counterpart to [`Self::acceleration_in_wind`], for wrappers that
446    /// sample their own wind but want the body-lift increment.
447    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    /// Evaluate using externally sampled wind, for a spatial or time-varying force wrapper.
459    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}