Spherical-harmonic gravity
SphericalHarmonics adds zonal, tesseral, and sectoral gravity terms to the
existing point-mass EOM. It works with coast, mass-carrying coast, finite-burn,
low-thrust, and coupled ECI chief/deputy phases, as well as numerical
propagate.inertial and propagate.relative histories.
Published Earth coefficients: no manual arrays required
from octavian import Dynamics, Perturbations, SphericalHarmonics
gravity = SphericalHarmonics.earth() # EGM2008, degree/order 200, C++ backend
dynamics = Dynamics(
perturbations=Perturbations(spherical_harmonics=gravity),
)
This loads a bundled, offline 200×200 subset of NGA EGM2008. No coefficient calculation, network access, Java, or database account is required. NGA recommends EGM2008 over legacy EGM96. The full published model extends beyond this subset; Octavian bundles through 200×200 and rejects larger requests rather than silently padding with zeros.
gravity = SphericalHarmonics.earth(degree=20, order=20)
small_python_field = SphericalHarmonics.earth(degree=4, backend="python")
The factory loads fully normalized, tide-free C/S values, the published
reference radius (6378136.3 m), and reference GM (3.986004415e14 m³/s²).
The selected field supplies GM automatically to Dynamics, ASSET EOMs and
numerical propagation. It takes precedence over the general central-body GM,
so point-mass and harmonic acceleration always use the same published constant.
No replacement of EARTH, coefficient conversion, or manual parameter copying
is needed. Custom fields without reference_mu_m3ps2 use the caller's GM.
from octavian import propagate, state
from octavian.dynamics import PerturbedECI
forces = Perturbations(spherical_harmonics=SphericalHarmonics.earth(degree=20, order=20))
history = propagate.inertial(state([7e6, 0, 0], [0, 7500, 0]), [0, 600], perturbations=forces)
ode = PerturbedECI(spherical_harmonics=forces.spherical_harmonics)
Uniform Z rotation defaults to 7.292115e-5 rad/s. Set reference_angle_rad and
reference_time_s to your mission's orientation convention; the factory does
not turn an arbitrary mission epoch into an ITRF transform. Tides, nutation,
precession, polar motion, and time-varying coefficients remain separate work.
The dataset manifest records source URL, original archive SHA-256, uncompressed
subset SHA-256, constants, tide system, and citation. The runtime checks the
subset checksum. tools/prepare_egm2008.py reproduces it from NGA's archive;
coefficient digits are retained unchanged. The gzip subset adds about 636 KiB.
Source: Pavlis et al. (2012), EGM2008.
The ICGEM model database provides other
published models; automatic network fetching and a generic model-file importer
are not part of this API. Custom arrays still work through the constructor.
Propagation, optimization, and third bodies
Harmonics combine with Sun/Moon point-mass third-body gravity in the same force model:
from octavian import Perturbations, SphericalHarmonics, propagate, state
forces = Perturbations(
spherical_harmonics=SphericalHarmonics.earth(degree=8, order=8),
sun=True,
moon=True,
)
history = propagate.inertial(
state([7e6, 0, 2e5], [0, 7400, 800]),
[0, 600, 1200],
perturbations=forces,
initial_epoch="2026-01-01T00:00:00Z",
)
This is the quick propagation entry point: no Mission, Phase, or solver
is needed. It uses fixed-step RK4; set max_step_s and check step convergence
for your accuracy needs. C++ evaluates the harmonic force, while Python drives
the numerical integration. For ASSET adaptive integration use the ODEs below.
Pass the same forces to Dynamics(perturbations=forces) for optimization.
Set Mission.initial_epoch for Sun/Moon tables, and set the gravity field's
prime-meridian angle independently.
These flags do not add the Sun's or Moon's own spherical harmonics. Custom central-body coefficient arrays are accepted, but the bundled field is Earth only. General non-Earth third-body ephemerides, arbitrary body poles, and body-fixed frame transformations need separate features.
Examples:
examples/composable/earth_centered/08_chemical_burn_j2.py: C++ degree 2/order 0 J2-only finite burns and coast.examples/composable/earth_centered/10_sun_moon_perturbations.py: optimized transfer with 8×8 EGM2008 and Sun/Moon gravity.examples/composable/earth_centered/14_high_fidelity_propagation.py: six-hour propagation through the short public API.examples/composable/earth_centered/15_spherical_harmonics.py: direct ASSET integration of point-mass, J2, and 8×8 EGM2008, saving PNG/CSV outputs.
Developer timing comparisons live in
native/spherical_harmonics/runtime_comparison.py. They compare Python/C++
construction, forces, derivatives, and adaptive integration at identical settings.
The manually triggered Harmonics benchmark GitHub Actions workflow runs
the full comparison and saves its output as an artifact. Run it locally with
python native/spherical_harmonics/runtime_comparison.py. PR and release CI
keep short 4×4 backend comparisons and native high-degree correctness checks;
they do not run repeated 8×8/10×10 Python expression benchmarks.
Use the ODEs in your own ASSET formulation
octavian.dynamics exposes TwoBodyECI, PerturbedECI, MassCoastECI,
FiniteThrustECI, and ThirdBodyTable. These are ASSET ODEBase objects;
use their integrator and phase methods without an Octavian mission:
from octavian import SphericalHarmonics
from octavian.dynamics import PerturbedECI
ode = PerturbedECI(spherical_harmonics=SphericalHarmonics.earth(degree=8))
integrator = ode.integrator(10.0)
integrator.setAbsTol(1e-7)
initial_row = [7e6, 0, 2e5, 0, 7400, 800, 0]
history = integrator.integrate_dense(initial_row, 600.0, 41)
phase = ode.phase("LGL3", history, 20)
States and time are SI: [x, y, z, vx, vy, vz, time] for coast histories.
The ODE has six states; time is ASSET's independent variable. Powered ODEs
add mass and thrust controls; consult their docstrings for layouts. No mission
compiler scaling is applied when constructing these ODEs directly.
For Sun/Moon in a direct ASSET formulation, pass third_body_tables containing
ThirdBodyTable objects with Earth-centered positions in meters as functions
of elapsed seconds. octavian.data.ephemeris.sample_sun_moon_positions_eci_tod
and asset_asrl.VectorFunctions.InterpTable1D provide the sampling and table
building blocks. Tables and state vectors must share the same frame, time zero,
and time coverage. A convenience factory for configured ODEs and ephemeris
tables would be a separate API feature; the existing ODE constructors are public.
Composable missions reject Sun/Moon flags for a non-Earth central body because
their bundled ephemerides are Earth-centered.
Coefficients, units, and orientation
Supply square C/S arrays indexed by [degree, order], with zero entries above
the diagonal and zero S[:, 0]. The model copies them into immutable tuples.
Arrays must contain at least degree two. Degree and order can truncate a larger
field without modifying its input arrays.
Coefficients use fully normalized geodetic associated Legendre functions, without the Condon–Shortley sign:
[ \bar P_{nm}(u)=\sqrt{(2-\delta_{m0})(2n+1)\frac{(n-m)!}{(n+m)!}}P_{nm}(u). ]
The positive perturbing potential is
[ U_p=\frac{\mu}{r}\sum_{n=2}^N(R/r)^n \sum_{m=0}^{\min(n,M)}\bar P_{nm}(\sin\phi) [\bar C_{nm}\cos(m\lambda)+\bar S_{nm}\sin(m\lambda)], \quad \mathbf a_p=\nabla U_p. ]
Convert unnormalized coefficients by dividing them by the normalization factor above. Schmidt-normalized coefficients need a different conversion; do not pass them directly. The Earth factory supplies this convention automatically; custom arrays must use it explicitly. Octavian does not infer custom-array normalization. Use the field's published reference radius and gravitational parameter together. The coefficient radius need not equal the body's mean radius, which continues to define altitude for other perturbations.
Point-mass gravity remains in the EOM. C00 may be zero or one and is ignored;
degree one must be zero, assuming a center-of-mass origin. j2=True together
with a harmonic field is rejected to prevent double counting. A zonal-only
model uses order=0; J2 alone has C[2,0] = -J2/sqrt(5).
The coefficient frame rotates about the inertial +Z axis by
reference_angle_rad + rotation_rate_radps * (time_s - reference_time_s).
The angle measures the body's prime meridian from inertial +X toward +Y.
Times are mission-relative seconds, including accumulated time across
phases. Choose the angle at your mission epoch explicitly; setting
initial_epoch does not set this angle. A zero rotation rate fixes the
coefficient frame. The model does not include precession, nutation, polar
motion, arbitrary pole orientation, or time-varying/tidal coefficients.
The exterior gravity expansion is intended for positions outside the field's
bounding sphere; high-degree expansions can diverge inside it.
Python and C++ backends
The Python implementation constructs ordinary ASSET vector-function expressions, consistent with the other EOMs. It also evaluates directly with Python/NumPy for analysis propagation. A normalized Cartesian solid-harmonic recurrence avoids longitude and divisions by distance to the pole. Explicit Cartesian derivative identities obtain acceleration from the next degree of harmonics. ASSET differentiates the resulting expressions for optimization.
The optional C++ backend runs the recurrence in compiled loops and returns a
native ASSET VectorFunction. It evaluates acceleration and its derivatives
without crossing into Python during integration or optimization. Truncated
Taylor arithmetic through third order differentiates the potential analytically;
no finite-difference derivatives or Python callback wrappers are used. Body
rotation is composed outside that function, so ASSET includes the time derivatives.
All evaluation scratch data is local to each call.
The compiled recurrence takes O(NM) work (O(N²) for a square field), and uses
constant recurrence scratch storage in addition to coefficient arrays. The
Python expression tree grows much faster as shared subexpressions are repeated.
Use the Python backend for small fields and transparent inspection; use C++ for
higher degree/order fields. Selecting cpp without an installed extension raises
an actionable error and never silently switches backends.
Pip installation and backend selection
The release build bundles the compiled backend into Octavian wheels for:
| Platform | Python | Native requirements |
|---|---|---|
| Linux x86-64 | 3.10, 3.11, 3.12 | glibc 2.34 or newer, AVX2/FMA |
| Windows x64 | 3.10, 3.11, 3.12 | AVX2, ASSET's MSVC runtime |
On those platforms, pip install octavian installs the binary alongside the
Python implementation: no compiler, headers, or ASSET source build is required.
The default is backend="cpp". Select backend="python" explicitly for readable
ASSET expressions or installations without the extension. There is no silent
fallback on a missing or incompatible binary. These wheels are delivered when this change is
released; a checkout alone does not install them.
Source distributions and editable installations compile the same extension by default. There is no separate native-package installation step.
Editable and source installations
Install Git and a compatible C++ toolchain once:
- Linux x86-64: Clang 17/18 and C++ standard-library development headers
(the validated Conda toolchain is
clangxx=18 gcc_impl_linux-64=13 gxx_impl_linux-64=13). - Windows x64: Visual Studio 2022 C++ Build Tools and LLVM/Clang-CL. Run installation in an x64 Visual Studio developer terminal. The build hook sets MSVC compatibility version 19.40 to match pip ASSET 0.5.1.
- Python: the supported native matrix is 3.10–3.12. A system Python also needs its development headers; Conda supplies these with Python.
With the compiler available on PATH, a venv uses the normal installation command:
python -m pip install -e ".[dev]"
python -c "from octavian import SphericalHarmonics; print(SphericalHarmonics.earth(degree=200).acceleration([7e6, 1e6, 2e6]))"
Pip supplies Hatchling, CMake, and Ninja in its isolated build environment. The
build downloads ASSET revision 6cb73ac174b140ecc6ac1b9563012424f2f0a748
and its pinned Eigen, pybind11, and fmt submodules into .native-build/.
Only the gravity extension is compiled; the runtime remains the existing
asset_asrl==0.5.1 wheel. Initial source builds require network access to GitHub.
To use an existing checkout or build offline, set ASSET_SOURCE_DIR to the pinned
ASSET source with its submodules initialized. Set CXX to select a compiler explicitly.
Editable builds place the ABI-tagged extension inside the checkout's octavian/
directory. Python edits take effect immediately. After changing C++ sources,
rerun python -m pip install -e .; close processes using the extension first,
particularly on Windows. Generated binaries, license copies, and cached headers
are ignored by Git. Use separate checkouts for environments requiring different
native ABIs; pip uninstall does not remove an in-place editable binary.
A compiler-free developer installation is an explicit exception:
OCTAVIAN_BUILD_NATIVE=0 python -m pip install -e .
That configuration requires backend="python" for harmonic evaluation. It does
not remove an existing in-place binary. Native build failures are errors; the
installer never silently substitutes a Python-only build. The separately built
legacy extension remains import-compatible, but is no longer needed for normal
installation.
Release wheels are built by the same default hook and tested outside the source checkout. The six Linux/Windows and Python 3.10–3.12 jobs also test default editable installs in fresh environments. Publishing ships those native wheels plus the source distribution, without a competing pure-Python wheel.
Accuracy checks and degree limits
tests/test_spherical_harmonics.py checks:
- C20 against the existing analytic J2 acceleration, including the poles.
- Acceleration against finite differences of an independent latitude/longitude Legendre potential through 20×20, including a truncated-order field.
- A closed-form single Cnn/Snn term through degree 100, using factorial normalization rather than the implementation's recurrence.
- Python/C++ agreement, pole behavior, Jacobian and adjoint-Hessian finite differences at 50×50 and 100×100; symmetry and zero divergence of the exterior gravity gradient for high-degree sectoral fields.
- Body rotation/time derivatives, integration, and solver optimization.
These are numerical implementation checks, not certification of a measured Earth model or arbitrary degrees. For mission validation, compare a published coefficient model against an independent trusted gravity implementation using identical coefficients, normalization, GM, radius, body orientation, and epoch. Also check degree-truncation and integration-tolerance convergence. Current orientation is uniform rotation about Z, not a full Earth orientation model.
There is no hard-coded maximum degree/order. Arrays of shape (N+1, N+1) support
maximum degree N, with order M ≤ N. Synthetic regression coverage reaches 100×100; the separate Actium/Orekit
campaign validates measured EGM2008 fields through 200×200. Higher degrees need
their own accuracy and performance validation. For full
order, degrees 2…N contain (N+1)**2 - 4 potentially nonzero real coefficients
(C plus S): 437 at 20×20, 2,597 at 50×50, and 10,197 at 100×100. The arrays also
contain required zero/unused slots. Zero-padding a low-degree field does not
add physical detail. Work scales approximately quadratically for the native
square-field recurrence; the Python ASSET expression tree becomes expensive
around 10×10.
What the cosine and sine inputs mean
cosine[n, m] is the dimensionless fully normalized coefficient Cbar_nm;
sine[n, m] is Sbar_nm. They are amplitudes, not angles or precomputed trig
functions. They multiply cos(m * longitude) and sin(m * longitude) in the
potential above. Degree n describes spatial complexity; order m describes the
longitude dependence. For m=0 the term is longitude-independent (zonal), and
S[n,0] is zero. C22/S22 together specify a degree-two longitude pattern.
Use coefficients from a gravity model for the body, with its published GM and reference radius. The Earth factory and both examples use measured EGM2008 coefficients; custom arrays remain available for other bodies or test fields. See the ICGEM coefficient explanation and model file format.
Independent validation through Actium/Orekit
The independent Actium repository provides
validation/validate_spherical_harmonics.py.
Its installable reference package never imports Octavian. Orekit independently
parses the same ICGEM coefficient file and evaluates Holmes–Featherstone gravity;
the bridge then compares Octavian/ASSET and Orekit numerical propagation.
Run in the Octavian development environment after installing the Actium checkout
and Java 11+ (or Actium's jdk extra):
git clone https://github.com/GHevia/actium.git ../actium
PYTHONNOUSERSITE=1 conda run -n octavian-dev python -m pip install -e "../actium[dev,jdk]"
PYTHONNOUSERSITE=1 conda run -n octavian-dev python ../actium/validation/validate_spherical_harmonics.py
The introductory script compares one 200×200 orbit in five sequential steps.
The detailed campaign in validation/regression/spherical_harmonics.py chooses
20×20, 100×100, and 200×200, six hours and 121
identical samples for inclined and near-polar LEO, with two tolerance settings.
It verifies every coefficient and both constants via independent readers,
evaluates forces at identical states (including near-pole and high-altitude
points), and compares position/velocity/acceleration histories. Tolerance
sensitivity is recorded for each integrator. CSV, PNG, and JSON reports are
written under Actium's validation/results/spherical_harmonics before any
nonzero failure exit. Gates are 1 mm, 1 µm/s, 1e-9 m/s² history acceleration,
and 1e-11 m/s² same-state force disagreement.
Both engines use identical uniform Z rotation and point mass plus static
EGM2008 only. This validates the implemented gravity/EOM and integration; it
does not claim equivalence to a full high-fidelity ITRF/tides/drag/SRP model.
See Actium’s validation guide
and the generated summary.json for measured results.
Recorded six-hour EGM2008 result
All 12 comparisons passed (two orbit types × three degrees × two tolerance settings). Each row reports the maximum across both orbit cases and both settings, over all 121 samples:
| degree/order | position [m] | velocity [m/s] | history acceleration [m/s²] | same-state force [m/s²] |
|---|---|---|---|---|
| 20×20 | 2.168e-04 | 1.362e-07 | 4.956e-10 | 7.692e-17 |
| 100×100 | 2.085e-04 | 3.112e-07 | 2.805e-10 | 2.187e-16 |
| 200×200 | 3.267e-04 | 4.007e-07 | 4.284e-10 | 4.165e-16 |
These bounds apply to the documented matched-physics campaign, not arbitrary
missions or full Earth-orientation models. The recorded reference uses
Orekit-JPype 13.1.8.0. See Actium’s validation/results/spherical_harmonics/summary.json for every case and convergence metrics.