libassist_sys/wrappers.rs
1//! Safe RAII wrappers around ASSIST C objects.
2
3use std::ffi::CString;
4use std::path::Path;
5
6use librebound_sys::Simulation;
7
8use crate::ffi;
9use crate::{Error, Result};
10
11// ---------------------------------------------------------------------------
12// Ephemeris
13// ---------------------------------------------------------------------------
14
15/// Owned ASSIST ephemeris data. Freed on drop.
16///
17/// Read-only after creation — safe to share across threads.
18pub struct Ephemeris {
19 ptr: *mut ffi::assist_ephem,
20}
21
22impl Ephemeris {
23 /// Load ephemeris from SPK files.
24 pub fn from_paths(planets: &Path, asteroids: &Path) -> Result<Self> {
25 let planets_cstr = path_to_cstring(planets)?;
26 let asteroids_cstr = path_to_cstring(asteroids)?;
27 let ptr =
28 unsafe { ffi::assist_ephem_create(planets_cstr.as_ptr(), asteroids_cstr.as_ptr()) };
29 if ptr.is_null() {
30 return Err(Error::EphemerisError(
31 "assist_ephem_create returned null — check file paths".into(),
32 ));
33 }
34 Ok(Self { ptr })
35 }
36
37 /// Raw pointer to the underlying `assist_ephem`. Useful for direct FFI calls.
38 ///
39 /// Returns a `*const` pointer because `Ephemeris` implements `Sync` on the
40 /// premise that the underlying data is read-only after construction. Call
41 /// `.cast_mut()` at the call site if the target FFI signature requires
42 /// `*mut`; that cast is the caller's assertion of unique access.
43 pub fn as_ptr(&self) -> *const ffi::assist_ephem {
44 self.ptr
45 }
46
47 /// Reference Julian date for the ephemeris (typically 2451545.0 = J2000.0 TDB).
48 pub fn jd_ref(&self) -> f64 {
49 unsafe { ffi::assist_rs_ephem_get_jd_ref(self.ptr) }
50 }
51
52 /// Override the reference Julian date.
53 ///
54 /// Requires `&mut self`, which prevents concurrent mutation when the
55 /// `Ephemeris` is shared across threads via `Arc`. Must be called before
56 /// any `AssistSim` is attached.
57 pub fn set_jd_ref(&mut self, jd: f64) {
58 unsafe { ffi::assist_rs_ephem_set_jd_ref(self.ptr, jd) }
59 }
60
61 /// Speed of light in AU/day.
62 pub fn c_au_per_day(&self) -> f64 {
63 unsafe { ffi::assist_rs_ephem_get_c_au_per_day(self.ptr) }
64 }
65
66 /// Convert MJD TDB to ASSIST simulation time (days from `jd_ref`).
67 ///
68 /// ASSIST stores times as days offset from the ephemeris reference JD,
69 /// where `JD = MJD + 2_400_000.5`.
70 pub fn mjd_to_assist_time(&self, mjd_tdb: f64) -> f64 {
71 (mjd_tdb + 2_400_000.5) - self.jd_ref()
72 }
73
74 /// Earth equatorial radius in AU.
75 pub fn earth_radius_au(&self) -> f64 {
76 unsafe { ffi::assist_rs_ephem_get_re_eq(self.ptr) }
77 }
78
79 /// Earth/Moon mass ratio.
80 pub fn emrat(&self) -> f64 {
81 unsafe { ffi::assist_rs_ephem_get_emrat(self.ptr) }
82 }
83
84 /// Get a solar system body's state at time `t` (days from `jd_ref`).
85 pub fn get_body_state(&self, body_id: i32, t: f64) -> Result<ffi::reb_particle> {
86 let mut error: i32 = 0;
87 let p = unsafe { ffi::assist_get_particle_with_error(self.ptr, body_id, t, &mut error) };
88 if error != 0 {
89 return Err(Error::EphemerisError(format!(
90 "assist_get_particle failed for body {body_id} at t={t}: error code {error}"
91 )));
92 }
93 Ok(p)
94 }
95
96 /// Get a solar system body's 6-element state `[x, y, z, vx, vy, vz]` at
97 /// time `t` (days from `jd_ref`). Convenience over [`Self::get_body_state`]
98 /// when the caller only needs the kinematic state, not the full
99 /// `reb_particle` (mass, hash, etc.).
100 pub fn get_body_state_array(&self, body_id: i32, t: f64) -> Result<[f64; 6]> {
101 let p = self.get_body_state(body_id, t)?;
102 Ok([p.x, p.y, p.z, p.vx, p.vy, p.vz])
103 }
104}
105
106impl Drop for Ephemeris {
107 fn drop(&mut self) {
108 if !self.ptr.is_null() {
109 unsafe { ffi::assist_ephem_free(self.ptr) }
110 }
111 }
112}
113
114// SAFETY: Ephemeris data is read-only after construction. The underlying
115// assist_ephem struct is only mutated during init (assist_ephem_create),
116// and all subsequent access (assist_get_particle*) is const-correct in C.
117// `set_jd_ref` takes &mut self so Rust's aliasing rules prevent races through
118// Arc<Ephemeris>, and `as_ptr` returns *const to forbid back-door mutation.
119//
120// Note on process-wide concurrency with AssistSim: REBOUND's IAS15 integrator
121// and ASSIST force routines use only `static const` tables (no mutable global
122// state). The only shared process-global is the SIGINT handler / flag
123// (reb_sigint), which all concurrent simulations legitimately share.
124// Concurrent AssistSim instances on separate threads are therefore safe.
125unsafe impl Send for Ephemeris {}
126unsafe impl Sync for Ephemeris {}
127
128// ---------------------------------------------------------------------------
129// AssistSim — Simulation with ASSIST forces attached
130// ---------------------------------------------------------------------------
131
132/// A REBOUND simulation with ASSIST ephemeris forces attached.
133///
134/// Owns the simulation. Borrows the ephemeris (caller must keep it alive).
135/// ASSIST extras are freed on drop, then the simulation is freed.
136pub struct AssistSim {
137 sim: Simulation,
138 ax: *mut ffi::assist_extras,
139 /// Backing storage for ASSIST's `particle_params` pointer. Kept alive
140 /// here so its heap buffer lives as long as the simulation.
141 particle_params: Option<Vec<f64>>,
142}
143
144impl AssistSim {
145 /// Create a new ASSIST-powered simulation.
146 ///
147 /// The `ephem` must outlive this `AssistSim`. ASSIST stores a raw pointer
148 /// to the ephemeris data internally.
149 pub fn new(mut sim: Simulation, ephem: &Ephemeris) -> Result<Self> {
150 let ax = unsafe { ffi::assist_attach(sim.as_mut_ptr(), ephem.ptr) };
151 if ax.is_null() {
152 return Err(Error::Other("assist_attach returned null".into()));
153 }
154 // ASSIST sets integrator=IAS15, gravity=NONE, registers force callbacks.
155 // Ensure exact finish time is on.
156 sim.set_exact_finish_time(true);
157 Ok(Self {
158 sim,
159 ax,
160 particle_params: None,
161 })
162 }
163
164 /// Set the ASSIST force model flags.
165 pub fn set_forces(&mut self, flags: i32) {
166 unsafe { ffi::assist_rs_extras_set_forces(self.ax, flags) }
167 }
168
169 /// Get current force model flags.
170 pub fn forces(&self) -> i32 {
171 unsafe { ffi::assist_rs_extras_get_forces(self.ax) }
172 }
173
174 /// Access the underlying simulation.
175 pub fn sim(&self) -> &Simulation {
176 &self.sim
177 }
178
179 /// Mutable access to the underlying simulation.
180 pub fn sim_mut(&mut self) -> &mut Simulation {
181 &mut self.sim
182 }
183
184 // --- Non-gravitational force model parameters ---
185
186 /// Set the g(r) model exponent α. Default: 1.0.
187 pub fn set_alpha(&mut self, v: f64) {
188 unsafe { ffi::assist_rs_extras_set_alpha(self.ax, v) }
189 }
190 pub fn alpha(&self) -> f64 {
191 unsafe { ffi::assist_rs_extras_get_alpha(self.ax) }
192 }
193
194 /// Set the g(r) model exponent k. Default: 0.0 (pure inverse-power law).
195 pub fn set_nk(&mut self, v: f64) {
196 unsafe { ffi::assist_rs_extras_set_nk(self.ax, v) }
197 }
198 pub fn nk(&self) -> f64 {
199 unsafe { ffi::assist_rs_extras_get_nk(self.ax) }
200 }
201
202 /// Set the g(r) model exponent m. Default: 2.0 (inverse-square).
203 pub fn set_nm(&mut self, v: f64) {
204 unsafe { ffi::assist_rs_extras_set_nm(self.ax, v) }
205 }
206 pub fn nm(&self) -> f64 {
207 unsafe { ffi::assist_rs_extras_get_nm(self.ax) }
208 }
209
210 /// Set the g(r) model exponent n. Default: 5.093 (Marsden-Sekanina water ice).
211 pub fn set_nn(&mut self, v: f64) {
212 unsafe { ffi::assist_rs_extras_set_nn(self.ax, v) }
213 }
214 pub fn nn(&self) -> f64 {
215 unsafe { ffi::assist_rs_extras_get_nn(self.ax) }
216 }
217
218 /// Set the g(r) model scale distance r₀ in AU. Default: 1.0.
219 pub fn set_r0(&mut self, v: f64) {
220 unsafe { ffi::assist_rs_extras_set_r0(self.ax, v) }
221 }
222 pub fn r0(&self) -> f64 {
223 unsafe { ffi::assist_rs_extras_get_r0(self.ax) }
224 }
225
226 /// Install ASSIST's `particle_params` array (3 doubles per particle:
227 /// `[A1, A2, A3]`, in `[real | variational]` order).
228 ///
229 /// Takes ownership of the `Vec`; its heap buffer lives for as long as the
230 /// `AssistSim`, matching the lifetime ASSIST requires for the pointer it
231 /// stashes internally. Must be called *after* all particles (real +
232 /// variational) have been added; `params.len()` must equal
233 /// `3 * n_particles`.
234 ///
235 /// Replacing a previously installed array drops the old storage; the
236 /// previous pointer ASSIST held is already overwritten at that point.
237 pub fn set_particle_params(&mut self, mut params: Vec<f64>) {
238 let n = self.sim.n_particles();
239 assert_eq!(
240 params.len(),
241 3 * n,
242 "particle_params length must equal 3 * n_particles (got {}, expected {})",
243 params.len(),
244 3 * n
245 );
246 let ptr = params.as_mut_ptr();
247 unsafe { ffi::assist_rs_extras_set_particle_params(self.ax, ptr) }
248 self.particle_params = Some(params);
249 }
250
251 /// Integrate to target time.
252 pub fn integrate(&mut self, tmax: f64) -> Result<()> {
253 self.sim.integrate(tmax).map_err(Error::from)
254 }
255
256 /// Integrate to target time `t`, with interpolation inside the last
257 /// completed IAS15 step when possible.
258 ///
259 /// On first call this behaves like [`integrate`] except it sets
260 /// `exact_finish_time = 0` so IAS15 may overshoot; the final state
261 /// is reconstructed at `t` via polynomial interpolation using the
262 /// integrator's `br` coefficients. On subsequent calls where `t`
263 /// falls within the last completed step's interval, no integration
264 /// happens at all — pure polynomial evaluation, typically one-to-two
265 /// orders of magnitude cheaper than a full step. Intended for
266 /// light-time iteration loops where the target shifts by
267 /// femtoseconds-to-microseconds per iteration.
268 ///
269 /// After the call, `sim.particles[i]` holds the state at `t`
270 /// (interpolated), even though `sim.t` may be past `t`.
271 ///
272 /// [`integrate`]: Self::integrate
273 pub fn integrate_or_interpolate(&mut self, t: f64) -> Result<()> {
274 unsafe { ffi::assist_integrate_or_interpolate(self.ax, t) }
275 Ok(())
276 }
277
278 /// Raw mutable pointer to the underlying REBOUND simulation.
279 /// See [`Simulation::as_mut_ptr`].
280 #[doc(hidden)]
281 pub fn raw_sim_ptr_mut(&mut self) -> *mut librebound_sys::ffi::reb_simulation {
282 self.sim.as_mut_ptr()
283 }
284
285 /// Zero IAS15's compensated-summation and predictor state (csx, csv,
286 /// csa0, b, e, br, er, g) *in place*, leaving the allocations intact.
287 /// Also invalidates the ASSIST ephemeris-lookup cache (sets every slot's
288 /// `t` to a sentinel so matched-t comparisons miss on the first
289 /// post-reset call). Required between two unrelated orbits integrated on
290 /// the same simulation — otherwise stale b/e predictor state seeds the
291 /// new orbit's first step and causes extra corrector iterations, and a
292 /// populated ephem cache causes per-lookup LRU work that adds up across
293 /// ~7000 lookups per 30-day integrate (≈190 µs regression).
294 ///
295 /// Cheaper than [`librebound_sys::ffi::reb_integrator_ias15_reset`]
296 /// (no free/malloc), and faster in practice: a pool-style benchmark with
297 /// this helper matches or beats the unpooled free-function path.
298 pub fn reset_integrator(&mut self) {
299 unsafe {
300 librebound_sys::ffi::assist_rs_ias15_zero_state(self.sim.as_mut_ptr());
301 ffi::assist_rs_ephem_cache_reset(self.ax);
302 }
303 }
304
305 /// Rewrite the first three slots of the installed `particle_params`
306 /// array (the real test particle's A1, A2, A3) without reallocating.
307 /// Returns `Error::Other` if `set_particle_params` was never called —
308 /// silently no-op'ing here would let a non-grav orbit keep the previous
309 /// orbit's A1/A2/A3 values without any indication.
310 ///
311 /// The variational-particle parameter columns (indices 3 onward) are
312 /// orbit-invariant IC perturbations (identity for parameter
313 /// variationals, zero for state variationals) and are left untouched.
314 pub fn update_nongrav_coeffs(&mut self, a1: f64, a2: f64, a3: f64) -> Result<()> {
315 let params = self.particle_params.as_mut().ok_or_else(|| {
316 Error::Other(
317 "update_nongrav_coeffs called before set_particle_params; \
318 install a particle_params array first"
319 .into(),
320 )
321 })?;
322 params[0] = a1;
323 params[1] = a2;
324 params[2] = a3;
325 Ok(())
326 }
327}
328
329impl Drop for AssistSim {
330 fn drop(&mut self) {
331 if !self.ax.is_null() {
332 // Detach ASSIST first, then assist_free, then sim drops automatically.
333 unsafe {
334 ffi::assist_detach(self.sim.as_mut_ptr(), self.ax);
335 ffi::assist_free(self.ax);
336 }
337 }
338 }
339}
340
341// ---------------------------------------------------------------------------
342// Helpers
343// ---------------------------------------------------------------------------
344
345fn path_to_cstring(path: &Path) -> Result<CString> {
346 let s = path.to_str().ok_or_else(|| {
347 Error::Other(format!(
348 "path contains non-UTF8 characters: {}",
349 path.display()
350 ))
351 })?;
352 CString::new(s).map_err(|e| Error::Other(format!("path contains null byte: {e}")))
353}