1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
// SPDX-License-Identifier: AGPL-3.0-only
//! Mars neutral-atmosphere density and atmospheric-drag acceleration.
//!
//! This is the Mars analogue of the Earth drag model in [`crate::forces`]
//! ([`crate::forces::atmospheric_density`] / [`crate::forces::drag_accel`]): a
//! piecewise-exponential density profile and a quadratic-drag acceleration taken relative to
//! the **co-rotating** Mars atmosphere. A Low-Mars-Orbit (LMO) spacecraft — and especially one
//! aerobraking — sees a drag force the deep-space OD must model; the **scale factor** on that
//! drag is the quantity the reduced-dynamic SRIF (D2.2) estimates, so this module supplies the
//! deterministic acceleration model and an explicit `..._scaled` variant for the estimator to
//! multiply.
//!
//! ## Scope (honest)
//!
//! [`mars_density`] is a **representative engineering model**, not a flight-validated
//! atmosphere. It is a static, mean, dust-and-season-independent piecewise-exponential —
//! the Mars counterpart of Earth's CIRA-72 / Vallado table, *not* the full Mars-GRAM 2010 or
//! Mars Climate Database (MCD) with their dust-storm, local-time, latitude and solar-cycle
//! dependence. The anchor values are the well-known Mars figures (surface density
//! ~0.020 kg/m³ and a near-surface scale height ~11 km, consistent with the Mars-GRAM 2010 /
//! MCD low-dust mean and the ~610 Pa CO₂ surface pressure at ~210 K); the higher bands carry
//! the rising scale height of the thin upper atmosphere. Use a vendored Mars-GRAM/MCD profile
//! for production aerobraking work.
type Vec3 = [f64; 3];
/// Mars reference radius (m) — the IAU mean equatorial radius, matching
/// [`crate::body::Body::mars`]'s `re`. Altitudes are measured above a sphere of this radius.
pub const MARS_RE: f64 = 3_396_200.0;
/// Mars sidereal spin rate `ω` (rad/s) — the atmosphere is assumed to co-rotate rigidly with
/// it, matching [`crate::body::Body::mars`]'s `rotation_rate`. (A Mars sol is ~24.6 h, so `ω`
/// is close to Earth's; the co-rotation subtracts a real along-track speed at LMO.)
pub const MARS_ROTATION_RATE: f64 = 7.088_218e-5;
fn norm(r: Vec3) -> f64 {
(r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt()
}
/// Mars neutral mass density `ρ` (kg/m³) at geometric altitude `altitude_m` (m above the Mars
/// reference sphere of radius [`MARS_RE`]), a **piecewise-exponential** Mars-GRAM-lite model.
/// Within the band whose base altitude `h0` brackets `h`, `ρ = ρ0·exp(−(h − h0)/H)`. Below the
/// surface it clamps to the surface value; above the top band it continues that band's
/// exponential (no hard cutoff).
///
/// The bands are anchored at a **surface density of 0.020 kg/m³** with a **near-surface scale
/// height of 11.1 km**, the representative Mars-GRAM 2010 / Mars Climate Database low-dust mean
/// (the ~610 Pa CO₂ surface pressure at ~210 K). Each band's base density is the previous band
/// evaluated at its top, so the profile is continuous, strictly decreasing, and the
/// scale height rises into the thin upper atmosphere. This is a representative engineering
/// model — see the module docs.
pub fn mars_density(altitude_m: f64) -> f64 {
// (base altitude h0 [km], base density ρ0 [kg/m³], scale height H [km]). Each ρ0 is the
// previous band evaluated at this base altitude, so the profile is continuous.
const BANDS: [(f64, f64, f64); 10] = [
(0.0, 2.0000e-2, 11.100),
(25.0, 2.1032e-3, 8.500),
(50.0, 1.1106e-4, 7.500),
(75.0, 3.9619e-6, 8.000),
(100.0, 1.7407e-7, 10.000),
(125.0, 1.4289e-8, 14.000),
(150.0, 2.3959e-9, 20.000),
(200.0, 1.9667e-10, 30.000),
(250.0, 3.7146e-11, 45.000),
(300.0, 1.2228e-11, 60.000),
];
let h_km = (altitude_m / 1000.0).max(0.0);
// Highest base altitude ≤ h_km (i stays 0 at/below the surface band).
let mut i = 0;
while i + 1 < BANDS.len() && BANDS[i + 1].0 <= h_km {
i += 1;
}
let (h0, rho0, scale) = BANDS[i];
rho0 * (-(h_km - h0) / scale).exp()
}
/// Atmospheric-drag acceleration (m/s², Mars-centred inertial) on a spacecraft at areocentric
/// position `r` (m) and inertial velocity `v` (m/s): the standard quadratic drag
/// `a = −½ · ρ(h) · (C_D·A/m) · |v_rel| · v_rel`, opposing the velocity **relative to the
/// co-rotating Mars atmosphere** `v_rel = v − ω ẑ × r` (`ω ẑ × r = (−ω·r_y, ω·r_x, 0)`).
/// `cd_area_over_mass` is the ballistic area term `C_D·A/m` (m²/kg) and `ρ` is [`mars_density`]
/// at the spherical altitude `|r| − `[`MARS_RE`]. This force is **dissipative** — it always
/// removes orbital energy. Mirrors [`crate::forces::drag_accel`] with Mars constants.
pub fn mars_drag_accel(r: Vec3, v: Vec3, cd_area_over_mass: f64) -> Vec3 {
let rho = mars_density(norm(r) - MARS_RE);
let w = MARS_ROTATION_RATE;
// v_rel = v − ω ẑ × r.
let v_rel = [v[0] + w * r[1], v[1] - w * r[0], v[2]];
let coef = -0.5 * rho * cd_area_over_mass * norm(v_rel);
[coef * v_rel[0], coef * v_rel[1], coef * v_rel[2]]
}
/// [`mars_drag_accel`] multiplied by an estimated drag **scale factor** `scale` — the form the
/// reduced-dynamic SRIF (D2.2) uses, carrying the unknown `C_D·A/m` mis-modelling and the
/// atmospheric-density uncertainty in one estimable multiplier (`scale = 1` is the nominal
/// model). Linear in `scale`, so the estimator's partial `∂a/∂scale` is just
/// [`mars_drag_accel`] itself.
pub fn mars_drag_accel_scaled(r: Vec3, v: Vec3, cd_area_over_mass: f64, scale: f64) -> Vec3 {
let a = mars_drag_accel(r, v, cd_area_over_mass);
[scale * a[0], scale * a[1], scale * a[2]]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mars_density_decreases_with_altitude() {
// Surface anchor ~0.020 kg/m³ (Mars-GRAM/MCD low-dust mean), clamps below the surface.
let rho0 = mars_density(0.0);
assert!(
(rho0 - 0.020).abs() < 1e-4,
"surface density {rho0}, expected ~0.020 kg/m³"
);
assert_eq!(mars_density(-5_000.0), rho0, "clamps below the surface");
// The near-surface profile e-folds over ~11 km: ρ(0)/ρ(11.1 km) ≈ e.
let ratio = mars_density(0.0) / mars_density(11_100.0);
assert!(
(ratio - std::f64::consts::E).abs() < 0.02,
"near-surface e-fold over ~11 km: ρ(0)/ρ(11.1 km) = {ratio}, expected ≈ e"
);
// Strictly monotone decreasing across the modelled band (no transcription inversion).
let alts = [
0.0, 25e3, 50e3, 75e3, 100e3, 150e3, 200e3, 250e3, 300e3, 400e3,
];
for w in alts.windows(2) {
let (lo, hi) = (mars_density(w[0]), mars_density(w[1]));
assert!(
hi < lo,
"density must decrease: {hi} at {} km not < {lo} at {} km",
w[1] / 1e3,
w[0] / 1e3
);
}
// Continuity across a band boundary: the base density equals the previous band
// evaluated at the boundary (no step discontinuity at 100 km).
let below = mars_density(100e3 - 1.0);
let at = mars_density(100e3);
assert!(
(below - at).abs() / at < 1e-3,
"density jumps at the 100 km band boundary: {below} vs {at}"
);
}
#[test]
fn mars_density_local_scale_height_is_physical() {
// Two samples 10 km apart in the near-surface band: the recovered e-folding scale
// height H = −Δh/ln(ρ₂/ρ₁) must be the ~11 km Mars near-surface value, a physical
// signature rather than a re-statement of any single tabulated number.
let (h1, h2) = (5e3, 15e3);
let ratio = mars_density(h2) / mars_density(h1);
let scale_km = -(h2 - h1) / 1000.0 / ratio.ln();
assert!(
(10.0..=12.0).contains(&scale_km),
"recovered near-surface scale height {scale_km} km outside ~11 km Mars band"
);
}
#[test]
fn mars_drag_opposes_relative_velocity() {
// A ~150 km LMO prograde state. Drag must oppose the velocity relative to the
// co-rotating Mars atmosphere (v_rel = v − ω ẑ × r) and have a sane LMO magnitude for
// C_D·A/m = 0.02 m²/kg.
let alt = 150e3;
let r = [MARS_RE + alt, 0.0, 0.0];
let mu_mars = 4.282_837e13; // Body::mars().mu
let vcirc = (mu_mars / (MARS_RE + alt)).sqrt(); // ~3.48 km/s
let v = [0.0, vcirc, 0.0];
let a = mars_drag_accel(r, v, 0.02);
// v_rel = (0, vcirc − ω·(MARS_RE+alt), 0): the co-rotation subtracts along-track speed.
let v_rel = [0.0, vcirc - MARS_ROTATION_RATE * (MARS_RE + alt), 0.0];
let dot_av = a[0] * v_rel[0] + a[1] * v_rel[1] + a[2] * v_rel[2];
assert!(
dot_av < 0.0,
"drag must oppose the relative velocity: {dot_av}"
);
// Anti-parallel to v_rel: a = −|a|·v̂_rel, so the components are collinear and opposed.
assert!(
a[0] == 0.0 && a[2] == 0.0,
"drag should be purely along −v_rel for this in-plane state: {a:?}"
);
assert!(
a[1] < 0.0,
"drag along-track component must be retrograde: {}",
a[1]
);
// Magnitude in a sane LMO band (~1e-4 m/s² at 150 km for this ballistic coefficient).
let mag = norm(a);
assert!(
(1e-5..=1e-3).contains(&mag),
"150 km Mars drag magnitude {mag} m/s² outside the expected ~3e-4 band"
);
}
#[test]
fn mars_drag_is_strictly_antiparallel_to_relative_velocity() {
// For an off-axis / out-of-plane state the drag vector must still be exactly
// anti-parallel to v_rel: the normalised drag direction equals −v̂_rel to machine
// precision (a cross-product of a and v_rel vanishes).
let r = [MARS_RE + 180e3, 2.0e5, -1.0e5];
let v = [-300.0, 3300.0, 150.0];
let a = mars_drag_accel(r, v, 0.015);
let w = MARS_ROTATION_RATE;
let v_rel = [v[0] + w * r[1], v[1] - w * r[0], v[2]];
// a × v_rel ≈ 0 (collinear), and a · v_rel < 0 (opposed).
let cross = [
a[1] * v_rel[2] - a[2] * v_rel[1],
a[2] * v_rel[0] - a[0] * v_rel[2],
a[0] * v_rel[1] - a[1] * v_rel[0],
];
let scale = norm(a) * norm(v_rel);
assert!(
norm(cross) / scale < 1e-12,
"drag not anti-parallel to v_rel: cross = {cross:?}"
);
assert!(
a[0] * v_rel[0] + a[1] * v_rel[1] + a[2] * v_rel[2] < 0.0,
"drag must oppose v_rel"
);
}
#[test]
fn mars_drag_scales() {
// Doubling C_D·A/m doubles the drag magnitude (drag is linear in the ballistic term).
let r = [MARS_RE + 160e3, 0.0, 0.0];
let v = [0.0, 3400.0, 0.0];
let a1 = mars_drag_accel(r, v, 0.02);
let a2 = mars_drag_accel(r, v, 0.04);
let ratio = norm(a2) / norm(a1);
assert!(
(ratio - 2.0).abs() < 1e-12,
"doubling C_D·A/m must double drag: ratio {ratio}"
);
// The estimator's scale factor multiplies the whole acceleration linearly, and
// scale = 1 reproduces the nominal model bit-for-bit.
let nominal = mars_drag_accel(r, v, 0.02);
let unit = mars_drag_accel_scaled(r, v, 0.02, 1.0);
assert_eq!(unit, nominal, "scale = 1 must equal the nominal drag");
let doubled = mars_drag_accel_scaled(r, v, 0.02, 2.0);
for k in 0..3 {
assert!(
(doubled[k] - 2.0 * nominal[k]).abs() <= 1e-18 + 1e-12 * nominal[k].abs(),
"scale = 2 must double component {k}"
);
}
}
#[test]
fn mars_drag_vanishes_above_the_atmosphere_density_floor() {
// Far above the modelled bands the density is vanishingly small, so the drag is many
// orders of magnitude below a near-surface-LMO value — a sanity floor, not a hard cut.
let low = norm(mars_drag_accel(
[MARS_RE + 150e3, 0.0, 0.0],
[0.0, 3475.0, 0.0],
0.02,
));
let high = norm(mars_drag_accel(
[MARS_RE + 1000e3, 0.0, 0.0],
[0.0, 3000.0, 0.0],
0.02,
));
assert!(
high < low * 1e-4,
"drag at 1000 km ({high}) should be far below 150 km ({low})"
);
}
}