Skip to main content

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}