empyrean-sys
Low-level FFI bindings to the libempyrean astrodynamics C ABI
empyrean-sys exposes the C ABI of libempyrean to Rust as raw,
unsafe, bindgen-generated declarations. It does not attempt to wrap,
type-check, or RAII-manage the underlying handles.
[]
= "0.10.0"
use *;
// All entry points are unsafe; pointer ownership and lifetime are the
// caller's responsibility. See include/empyrean.h at the repository
// root for the authoritative C ABI documentation.
unsafe
Most users want the safe wrapper instead — see the
empyrean crate, which builds on
empyrean-sys to provide typed handles, Result-returning entry points,
and Rust-native lifetime management.
What the bindings cover
The declarations track the full C ABI of this crate's own release, including the v0.9.0 wide-parameter fitting surface and the output surface below.
EMPYREAN_ABI_VERSION carries that release, encoded
major * 10000 + minor * 100 + patch — the 0.10.0 ABI reports 1000 —
and it advances with every release, whether or not any boundary type
changed. So the number no longer tells you anything about layout, and
the only reading it supports is equality: if the loaded engine reports a
different value, it is a different release, and the fix is to rebuild
against the matching header or repoint at the matching engine. There is
no compatible range to reason about, and a value that did not move is no
longer a promise that nothing did.
The scheme begins with 0.10.0. Every release before it reported an independent counter instead, now retired, whose last published value is 2 (v0.9.0); that counter is the subject of the historical notes below, and no library has ever reported a value between it and 1000.
Only the base version is encoded — the pre-release suffix is not, so
0.10.0-rc.1 and 0.10.0 both report 1000. The handshake therefore
separates one version from another and never a version from its own
pre-releases: a boundary change inside a pre-release cycle is not caught
by it, and both sides must be rebuilt together.
The 0.10.0 ABI — the joint covariance
The boundary now carries the off-diagonal blocks of a fit's
(6+P) × (6+P) covariance, not only its diagonal blocks. Input side,
caller-owned: CoordinateState gains has_non_grav_cross /
non_grav_cross[6][3] (the state↔Marsden border, placed beside the 6×6 it
borders so a coordinate transform moves both), and EmpyreanOrbit gains
state_param_cross / n_state_param_cross and param_pair_cross /
n_param_pair_cross as side arrays. Output side, library-owned:
EmpyreanOrbitCovariance rides EmpyreanPropagatedState::orbit_cov, and an
EmpyreanODResult's orbit is itself an EmpyreanPropagatedState, so a
fitted orbit and a propagated row expose their joint under one field name
with one ownership rule.
This is what closes leg chaining: the engine's propagated border is non-zero even from a block-diagonal input, because propagation itself generates the correlation, so a caller who chained legs on the 6×6 alone was quoting a tighter uncertainty than the propagation supports.
Two new exported symbols carry it:
empyrean_propagation_joint_at(result, orbit_index, epoch_index, out) returns the propagated joint at one row, and
empyrean_orbit_covariance_free releases what it wrote. They are a separate
call rather than fields on EmpyreanTaggedCovariance to keep that struct
free of owned storage — a caller declares one on the stack and frees nothing,
and giving it owned arrays would have turned correct, silently recompiling
code into a leaking caller at two allocations per call.
Four more join them for orbit determination through the pre-built
force-model handle: empyrean_builtsystem_new_for_od freezes the
recipe a fit actually runs under, and
empyrean_builtsystem_determine / _evaluate / _refine mirror
their one-shots with the handle prepended, exactly as
empyrean_builtsystem_propagate mirrors empyrean_propagate. Six
exported symbols in all for the 0.10.0 break itself — the retired
counter's own additions, which reach consumers for the first time
here, are in the ABI 3 notes below.
Three new input structs carry the terms: EmpyreanParamColumn (16 bytes),
EmpyreanStateParamCross (64), EmpyreanParamPairCross (40), plus
EmpyreanOrbitCovariance (184) on the output side. Nine new constants:
EMPYREAN_PARAM_COLUMN_MARSDEN / _DT / _AMRAT / _THRUST,
EMPYREAN_PARAM_FIXED / _SOLVED / _CONSIDERED,
EMPYREAN_MAX_THRUST_SEGMENTS, and
EMPYREAN_REJECTION_PER_OBSERVATION_SITE_REQUIRED (15).
The shrink. EmpyreanSolveFor replaces thrust_segments (a u32) with
thrust_dispositions[3] (three u8s), taking it from 8 bytes to 6 and its
alignment from 4 to 1. That shifts every field after solve_for_flags inside
the EmpyreanODConfig embedding it — allow_unbracketed_maneuvers 392 →
390, has_photometry 393 → 391, photometry 400 → 392 — and shrinks that
config 432 → 424. A consumer with a hand-mirrored config must re-derive its
whole layout rather than append: keeping the old prefix and writing
photometry at its former offset lands eight bytes past where the library
reads it, corrupting that config and the two bytes before it with no
diagnostic. Every other change in this release is an append:
CoordinateState 360 → 512, EmpyreanOrbit 648 → 832 → 912,
EmpyreanPropagatedState 2392 → 2576, EmpyreanNonGravParams 160 → 176
(has_dt_variance / dt_variance, which had no wire at all before),
EmpyreanObservatoryConfig 40 → 64 (the visibility fields, which are
marshaled in full but which no exported entry point applies — the gates that
read them live in the engine's unexported visibility survey), and
EmpyreanODResult 7688 → 8128 → 8192 (the joint, dispositions, the
per-segment thrust posteriors and the warnings channel, then the
solver-termination block), carrying
EmpyreanODObjectResult 7720 → 8160 → 8224 with it.
Semantic breaks the sizes will not catch. EmpyreanSolveFor's axes
become a disposition tri-state — 0 fixed, 1 solved, 2 considered — so
memset(0) and every value an older caller could write keep their exact
former meaning while the type gains a third. And
EmpyreanODResult::thrust_delta_m_per_s is re-indexed from solved to
declared order, with thrust_delta_count becoming the declared count, so
it shares one index space with the new thrust_correction_covariances and
with dispositions.thrust_dispositions. An unsolved segment is NaN-filled in
both arrays.
ABI 3 — the last of the retired counter
Historical: 3 was the final value of the independent counter. It was
prepared but never published — every released library on the counter tops
out at 2 (v0.9.0) — so the changes below reach consumers for the first
time in this release, and every upgrading consumer crosses both breaks at
once.
Function shapes. empyrean_determine keeps its name and arity, but
its final out-parameter is now EmpyreanDetermineResults * — the batch
table, one slot per ADES object — rather than a single
EmpyreanODResult *, and it is released with the new
empyrean_determine_results_free (empyrean_od_result_free still
releases empyrean_refine's single result).
empyrean_transform_coordinates becomes the batched (array in / array
out) entry point, with the one-state form renamed
empyrean_transform_coordinates_single, and empyrean_get_observers
gains frame / origin parameters. New entry points:
empyrean_context_from_data_dir_with with empyrean_missing_data_files
/ empyrean_missing_data_files_free, empyrean_download_data, and
empyrean_fit_summary_write_parquet / _csv / _json. The weighting
preset constant is now EMPYREAN_WEIGHTING_PRESET_VFCC2017 (Vereš,
Farnocchia, Chesley & Chamberlin 2017), replacing the ..._VFC17
spelling.
Struct shapes (64-bit sizes, every one asserted at compile time in
the generated bindings): EmpyreanPropagationConfig 288 → 296
(ephemeris_overlap_policy), EmpyreanObserver 72 → 80 (frame /
origin), EmpyreanTaggedCovariance 520 → 528
(quality_kappa_state), and EmpyreanAcceptabilityReport 120 → 208
(the new gates), carrying EmpyreanODResult 7600 → 7688 with it. Two
are interior changes rather than appends:
EmpyreanObservationResult 264 → 272 inserts object_id right after
obs_id, and EmpyreanODConfig stays exactly 432 bytes while
replacing use_stm_cache with allow_arc_truncation and
coorbital_enabled at offset 208 — same size, different meaning, which
no size check would catch. New types: EmpyreanDetermineResults (32),
EmpyreanODObjectResult (7720), EmpyreanFitSummary (184),
EmpyreanDataDirOptions (8), EmpyreanMissingDataFiles (16).
The version handshake
It is enforced here, not merely documented: the loader calls
empyrean_abi_version() the moment it opens libempyrean and panics —
naming both versions and the resolved path — if the engine disagrees with
EMPYREAN_ABI_VERSION. Any inequality panics; there is no tolerated
range, because the value is a release identity rather than a layout
generation.
dlsym matches on symbol name alone, so a stale engine picked up from
EMPYREAN_LIB or a leftover target/release would do worse than return
wrong numbers. An ABI-2 empyrean_get_observers reads the caller's
frame integer as its out-pointer; an ABI-2 empyrean_determine writes a
7600-byte EmpyreanODResult through a pointer the caller sized for a
32-byte EmpyreanDetermineResults; and an ABI-3 empyrean_refine reads
its EmpyreanODConfig's photometry block eight bytes past where a 0.10.0
caller wrote it. Every struct size named above is additionally asserted at
compile time in the generated bindings, so a header/binding drift fails the
build rather than the physics.
The engine binary this crate resolves is version-pinned by construction:
the checksummed download in build.rs targets the v{crate version} tag,
and the copy bundled in the Python wheel ships beside the wheel's own
bindings. A manual EMPYREAN_LIB is the one path that can pair mismatched
releases.
Each type below maps 1:1 onto a C struct; consult include/empyrean.h
at the repository root for field-level semantics.
- Batch orbit determination.
empyrean_determinegroups the observations by ADES object identifier (permID / provID / trkSub), fits each group, and returns oneEmpyreanODObjectResultper object in ascendingobject_idorder insideEmpyreanDetermineResults. Each slot'sdeliveredflag selects the live payload: a fully populatedEmpyreanODResult, or a typed failure carrying the engine'serrormessage and anEMPYREAN_OD_FAILURE_*error_code(IOD,OD,RADAR_ONLY,DUPLICATE_OBS_IDS,OBSERVATION_CONVERSION,OBSERVER_CONSTRUCTION,EARTH_ORIENTATION_COVERAGE,NON_GRAV_NOT_RECOVERED,UNSUPPORTED_COORDINATE_SYSTEM) — withresultNaN-poisoned so an unchecked read is obviously invalid rather than a plausible all-zero fit. One object's failure never aborts the batch; every object failing returnsEMPYREAN_DETERMINE_NONE_DELIVERED(-4), which still populates the table and still requires freeing it. Seed orbits matching no group come back inunmatched_orbit_idsrather than being dropped, and eachEmpyreanObservationResultin the table carries theobject_idit was fitted under, so a caller may concatenate every object's rows into one flat table and still know which fit each row belongs to. Radar observations ride the same call as the optical set and are fitted jointly with it. - Acceptability verdicts and typed rejections.
EmpyreanAcceptabilityReportcarries bothfit_acceptableandextrapolation_acceptablealongside a per-gate_okflag and, where the gate is numeric, the_value/_thresholdpair behind it — convergence, reduced χ², RMS, residual isotropy, covariance, arc coverage, fractional σ_a, selection fraction, selected-arc coverage, trailing gap, and radar fit — so a verdict serializes together with the number that produced it.EmpyreanFitSummaryis the flat per-object row of that verdict (identity, status, counts, RMS, reduced χ², solved width, and the gates), written byempyrean_fit_summary_write_parquet/_csv/_json. Rejection reasons gainedEMPYREAN_REJECTION_NON_FINITE_CHI2,EMPYREAN_REJECTION_MISSING_JACOBIAN,EMPYREAN_REJECTION_OBSERVER_CONSTRUCTION_FAILED,EMPYREAN_REJECTION_SPACECRAFT_KERNEL_MISSING,EMPYREAN_REJECTION_NEVER_ABSORBED, andEMPYREAN_REJECTION_PER_OBSERVATION_SITE_REQUIRED(a roving-observer247/270or occultation275code, whose site travels with each observation, so there are no published planetodetic constants to look up), so a deselected observation always carries a reason rather than a bare flag. The codes are kept distinct because each names a different thing the caller can do: an unmodelled observatory, a kernel to load, or coordinates the ADES record already carries per observation. - The joint covariance.
EmpyreanOrbitCovariancecarries a fitted or propagated covariance's off-diagonal blocks —has_non_grav_cross/non_grav_cross[6][3]for the state↔Marsden border, andstate_param_cross/param_pair_crosswith their counts for everything else. Entries are keyed byEmpyreanParamColumnidentity (kindplusindex/segment/component), never by column index, since which column a parameter occupies depends on what else the orbit declares. The output arrays are library-owned and released with the parent result; the identically-named fields onEmpyreanOrbitare caller-owned and borrowed for the call, so re-feeding by pointer assignment is valid only while the result outlives the orbit. Absence is a null pointer with a zero count, never a zeroed block: a supplied zero correlation is a claim, and it engages the engine's definiteness gate. - Hard-object switches.
EmpyreanODConfig::allow_arc_truncationandcoorbital_enabledare tri-state (-1engine default,1on,0off). Forbidding truncation makes an arc spanning a dynamical discontinuity fail loudly instead of delivering a fit of the reconcilable sub-arc with the rest taggedEMPYREAN_REJECTION_OUTSIDE_ARC; the co-orbital IOD lane is what recovers 2010 TK7 / 2020 XL5-class Earth co-orbitals. - Wide-parameter OD.
empyrean_determine/empyrean_refinesolve beyond the 6-parameter state and the Marsden A1/A2/A3 non-gravitational block for the cometary outgassing time delay DT, the SRP area-to-mass ratio AMRAT, and per-segment thrust Δv corrections on continuous-thrust arcs — each partial produced analytically by the hyperdual integrator. The per-axisEmpyreanSolveFor(marsden/dt/amrat/thrust_dispositions) is read underEMPYREAN_SOLVE_FOR_EXPLICIT. Each axis carries a disposition rather than a flag: solved is estimated, considered is not estimated but still reaches the posterior through its measurement partials, and fixed is marginalized out. DT / AMRAT / thrust are refine-path solves: the input orbit must carry the corresponding prior (its declared variance) to open the axis, and requesting an axis without its prior errors out rather than returning a zeroed or defaulted column.EmpyreanODResultechoes the partition it actually ran ondispositions— the only place a considered axis is visible, since it occupies no solved slot — reports per-declared-segmentthrust_delta_m_per_sandthrust_correction_covariances(NaN-filled where a segment was not solved), and carrieswarnings/num_warningsfor covariance it was handed and deliberately did not use. Consider analysis is not a conservatism knob: with cross terms to the solved axes the correction is sign-indefinite, so a considered axis can tighten the posterior. - Tagged solved covariance.
EmpyreanSolvedCovariancecarries the fitted-parameter identities alongside the matrix:marsden_slot,dt_slot,amrat_slot, andthrust_slotslocate each parameter's row/column, withEMPYREAN_SLOT_NONEmarking an axis that was not solved. Read a parameter's variance by its slot — thewidthalone is ambiguous. - Post-OD photometry. With
EmpyreanODConfig::has_photometryset,EmpyreanPhotometryConfigrequests a fit recovering absolute magnitudeHand the phase-function slopes from the observation magnitudes, climbing the H-only → HG12 → HG1G2 ladder (Muinonen et al. 2010) to the richest model the arc's phase coverage supports.EmpyreanODPhotometryResultreports the fittedh/slope1/slope2, themodel_used, a 3×3covariancegiving an honest 1σ onh, plusn_mags_dropped_unconvertibleand the distinct offending band codes indropped_bandswhen magnitudes could not be converted to V — the observations' astrometry is unaffected. - Ephemeris covariances and warnings. Each
EmpyreanEphemerisEntrycarries a 6×6 sky-plane covariance over (rho, RA, Dec) and their rates in (AU, deg) units, and the aberrated (light-time corrected) barycentric ICRF Cartesian state at the photon-emission epoch with its own 6×6 covariance —has_covariance/has_aberrated_covariancegate each block (absent unless the input orbit carried a state covariance).EmpyreanEphemerisResultreturns run-level non-fatalwarnings, e.g. Earth-orientation kernel coverage gaps served by the analytic IAU 2006 fallback, or rows whose sensitivity chain was skipped. - Per-observation diagnostics.
EmpyreanObservationResultcarries radar delay/Doppler residuals (observed − predicted, seconds / hertz, withradar_chi2,radar_dof,radar_probability, and the combinedradar_variance), the D-optimalityinfluence_information_losson removal (+∞ marks an indispensable observation), andalong_cross_covariance_arcsec2completing the 2×2 along/cross-track covariance. - The solver's stopping verdict.
EmpyreanODResultcloses with ten fields saying how the solve ended, ints before doubles:termination(one of the tenEMPYREAN_SOLVER_STOP_*codes, whereNOT_REPORTED(0) is nothing-reported andUNRECOGNIZED(9) is a stop the engine build cannot name — never the same claim),gn_step_qnorm(the undamped Gauss-Newton step's quadratic form at the delivered iterate, the quantityconvergence_tolbounds and the only step norm comparable to it),mu_final,accepted_steps,final_solve_iterations, and the fivestall_*fields (stall_delivered,stall_underlying_stop,stall_gn_qnorm,stall_convergence_tol,stall_optical_reduced_chi2) describing a fit the stable-stall acceptance delivered rather than a convergence criterion.update_normis the μ-damped last accepted step and is not comparable toconvergence_tol; read these instead. The block pads once (4 bytes) and grows the struct by 64. - Covariance trust verdict.
EmpyreanODResult::covariance_trustreports an event-aware verdict on the delivered covariance (EMPYREAN_COVARIANCE_TRUST_*):TRUSTED,ENCOUNTER_INTERVENES(with the intervening close-approach or high-nonlinearity event and whether a second-order state-only correction can recover it), orWEAKLY_DETERMINED_HIGH_N.NOT_EVALUATEDmeans no gate ran — absence of a verdict is not trust. - Impact-probability detail. Each
EmpyreanImpactProbabilityrow carries the geodetic impact point (latitude / longitude / altitude on the body's reference ellipsoid, when the surface projection is available), the Monte-Carlo 95% binomial confidence half-width onip_mc, the second-order corrected mean miss distance with its 1σ uncertainty and skewness, the closest-approach distancegradient(6-vector) anddistance_hessian(6×6) with respect to the initial state, and the adaptive Gaussian-mixture component count. - Plan evaluation. Radar candidates in
EmpyreanPlanCandidatecarry the effective SNR (linear power ratio), one-way range (km), link-budget provenance notes, and the measurement mode;EmpyreanPlanResultcarries the predicted optical ephemeris (epoch MJD TDB, RA, Dec per optical candidate).EmpyreanObservatoryConfigadditionally declaresmin_elevation_degand thehas_max_sun_altitude_deg/max_sun_altitude_degpair (unset takes the engine's default of −18°, astronomical twilight; thehas_switch exists because0.0is a legal solar altitude, so a defaulted zero would plan a campaign in daylight). Both are marshaled across in full, and no exported entry point applies them: the gates that read them belong to the engine's visibility survey, which this ABI does not export. They ride the struct so that exposing the survey later needs no further ABI break. - Basis-tagged mixture components. Each
EmpyreanMixtureComponentis tagged with the referenceframeand center-bodyorigin(NAIF id) its mean and covariance are expressed in. - Data provisioning and strict offline.
empyrean_context_from_data_dir_withtakesEmpyreanDataDirOptions(refresh,tier); a null or zeroed struct reproducesempyrean_context_from_data_direxactly.refresh = EMPYREAN_DATA_REFRESH_OFFresolves the tier's kernels fromdata_diralone and fails naming every absent file — no lower-tier fallback, no partially loaded context — with the list retrievable throughempyrean_missing_data_filesas structured entries rather than a string to split (file names may contain the separator); release it withempyrean_missing_data_files_free.empyrean_download_dataprovisions a data directory without building a context at all. These entry points read no environment variable: the C ABI honours exactly what the caller passed.
Runtime requirement
empyrean-sys opens libempyrean.{dylib,so} at run time via
libloading (dlopen). The library is distributed separately as a
binary release on
GitHub and
inside the published Python wheel. The path is resolved from the
EMPYREAN_LIB environment variable if set, else a libempyrean.*
sitting next to the loaded module, else a build-time location — an
EMPYREAN_LIB_DIR override, a sibling ../target/release build, or
a checksum-pinned download from the GitHub release tagged
v{crate version} (in that order).
The FFI bindings are pre-generated and committed, so no C header,
libclang, or bindgen is needed to build.
checksums.txt pins one more thing than the tarball hashes. A hash
proves which bytes were downloaded, never which struct layouts they
hold, and EMPYREAN_ABI_VERSION cannot close the gap either — it
encodes the base version, so every build inside one release cycle
reports the same number while EmpyreanOrbit can grow underneath it.
So the file also carries a # Header-SHA256: line naming the
include/empyrean.h the pinned binaries were built from, and in a
checkout build.rs refuses to link a downloaded prebuilt when the
header in the tree hashes to anything else — pointing at a local
empyrean-c build or an EMPYREAN_LIB_DIR engine built from that
exact header. The guard is inert for the packaged crate from
crates.io, which ships no header, and for EMPYREAN_LIB_DIR, which is
an override the caller owns.
Prebuilt engine binaries are currently published for four targets:
macOS arm64 (macos-aarch64), macOS x86_64 (macos-x86_64), Linux
x86_64 (linux-x86_64), and Linux aarch64 (linux-aarch64). On other
targets the build stops with an error unless EMPYREAN_LIB_DIR points
at an engine build.
License
Source code in this crate is licensed under the
BSD 3-Clause License. The closed-source libempyrean
runtime it loads at runtime is governed by a separate proprietary binary
license; see the main repository for the dual-license breakdown.
Copyright © 2024–2026 Joachim Moeyens. All rights reserved.