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    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
100/// Local air density (kg/m³) and sound speed (m/s) for an atmosphere and its
101/// kernel parameter tuple, shared with the reduced-order dynamics models.
102pub(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
121/// Validated Mach table with nonnegative values, shared by the lift and
122/// weathercock parameter curves.
123pub(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/// Gravity and aerodynamic drag delegated to ballistics-engine's force kernel.
146#[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/// Validated angle-of-attack aerodynamics: normal-force slope and induced-drag
159/// factor, each piecewise-linear in Mach via the upstream table type.
160#[derive(Debug, Clone)]
161struct BodyLift {
162    /// Normal force coefficient slope per radian of angle of attack, vs Mach.
163    cna_per_rad: DragTable,
164    /// Induced drag increment per radian squared (Cd += k·α²), vs Mach.
165    induced_k_per_rad2: DragTable,
166}
167
168/// Earth rotation rate, rad/s (McCoy-level precision, matching the upstream kernel).
169const OMEGA_EARTH_RPS: f64 = 7.2921159e-5;
170
171impl BallisticForces {
172    /// Build using the vehicle's actual Cd curve, not a G-model reference curve or BC.
173    /// At least two ascending, nonnegative Mach values and positive Cd values are required.
174    /// Wind is a world-frame air velocity vector (positive X is a tailwind).
175    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    /// Apply a positive multiplier to the entire Cd curve. Default is 1.0.
217    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    /// Change uniform world-frame wind between public integration steps.
235    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    /// Modified point-mass spin: enable the kernel's Magnus force and yaw of
244    /// repose for a spinning body. `twist_rate_in` is the effective twist in
245    /// inches per turn — rifling language upstream; for a fin-canted rocket it
246    /// is the roll rate the cant produces per unit velocity. `body_length_m` is
247    /// the full airframe length (the stability correlations need it; [`Self::new`]
248    /// only knows the diameter). `right_hand` selects the roll direction.
249    ///
250    /// The correlations are McCoy bullet shapes, so treat magnitudes as
251    /// approximate for blunt or finned bodies; signs and scaling are physical.
252    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    /// Modified point-mass Coriolis deflection, for planet-scale arcs.
268    /// `latitude_deg` is the launch latitude in [-90, 90]; `shot_azimuth_deg` is
269    /// the compass bearing fired along, 0 = north, 90 = east. Do not combine
270    /// with an inertial-frame [`crate::CentralGravity`] model: Coriolis is the
271    /// rotating-frame term and would double-count Earth rotation.
272    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(),  // X: downrange
286            OMEGA_EARTH_RPS * lat.sin(),             // Y: vertical
287            -OMEGA_EARTH_RPS * lat.cos() * az.sin(), // Z: lateral
288        ));
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    /// Launch tip-off: an initial yaw in radians that decays exponentially with
296    /// downrange distance over `decay_distance_m`. Models the rail/launcher
297    /// disturbance; it perturbs the effective angle of attack early in flight.
298    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    /// Angle-of-attack aerodynamics without attitude integration: a quasi-static
309    /// normal force and induced drag from the commanded axis's angle to the
310    /// relative wind.
311    ///
312    /// `cna_vs_mach` is the normal-force coefficient slope per radian of angle of
313    /// attack (body + fins) versus Mach; `induced_k_vs_mach` adds Cd += k·α².
314    /// Both need at least two strictly ascending Mach points and nonnegative,
315    /// finite values. The angle of attack is measured each RK stage between the
316    /// solver-resolved thrust axis and the wind-relative velocity, so a guided
317    /// turn at angle of attack both turns harder (body lift) and pays drag for it.
318    /// Reference area derives from the diameter passed to [`Self::new`].
319    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    /// Change the effective twist between public steps, for a caller-integrated
335    /// roll model (see [`crate::RollDynamics`]). Pair with [`Self::with_spin`]
336    /// for the body length and handedness; the rate must stay positive.
337    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    /// Downrange-segmented atmosphere (upstream `AtmoSock`): station-referenced
344    /// zones, each `(temperature_c, pressure_hpa, humidity_percent, until_downrange_m)`.
345    /// The zone is selected by downrange distance `position.x`; the base atmosphere
346    /// must be [`Atmosphere::Standard`], and each zone's humidity applies to its
347    /// station density while this derivative path uses dry-air sound speed.
348    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    /// Downrange-segmented wind (upstream `WindSock`). Each zone is
381    /// `(speed_mps, angle_deg, until_downrange_m, vertical_mps)`, with the angle
382    /// following the upstream wind-FROM convention (0° = headwind, 90° = from the
383    /// right). Vertical is positive updraft. Zones are evaluated statelessly at
384    /// each stage's `position.x`, so wind can change mid-integration.
385    /// Overrides the uniform wind set in [`Self::new`]/[`Self::set_wind`].
386    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    /// Wind at a stage: segmented downrange wind if configured, else the uniform vector.
411    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    /// Local air density (kg/m³) and sound speed (m/s) at a geometric altitude,
419    /// consistent with the atmosphere handed to the kernel.
420    fn local_air(&self, altitude_m: f64) -> (f64, f64) {
421        local_air(self.atmosphere, self.atmos_params, altitude_m)
422    }
423
424    /// Quasi-static AoA force increment: normal force toward the body-axis side of
425    /// the velocity, plus induced drag along it. Zero when aligned or in still air.
426    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        // Degenerate cases: no relative wind, or a zero/ill-posed axis → no increment.
439        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        // Angle of attack from the axis to the relative wind.
445        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    /// Axis-aware counterpart to [`Self::acceleration_in_wind`], for wrappers that
471    /// sample their own wind but want the body-lift increment.
472    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    /// Evaluate using externally sampled wind, for a spatial or time-varying force wrapper.
484    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}