Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

geophysix: design details

The implementation reference behind the geophysix design: the records and their invariants, the formulas step by step, JAX behaviour, tests, dependencies, and the survey of what each library holds today.

Core types

Two kinds of record: a set of physical constants, and a body (an ellipsoid, of which a sphere is the case f = 0). Both are frozen dataclasses of floats, not eqx.Modules, because geoarray must install and run without JAX (gridax decision 6) and geoarray reaches geophysix through gridax. Under JAX a float field is a compile-time constant; a caller who needs a gradient with respect to a constant (fitting an Earth radius, say) passes a JAX scalar in that field, and the record is registered as a pytree when JAX is importable.

# Shapes: every field is a scalar (float or 0-d array); no array dims

import dataclasses
import math


# proposed (geophysix)
@dataclasses.dataclass(frozen=True)
class Constants:
    """One convention's physical constants, SI throughout.

    Attributes:
        boltzmann: k_B, J K⁻¹.
        avogadro: N_A, mol⁻¹.
        planck: h, J s.
        speed_of_light: c, m s⁻¹.
        standard_gravity: g₀, m s⁻².
        standard_pressure: p₀, Pa.
        dry_air_molar_mass: M_dry, kg mol⁻¹.
        dry_air_heat_capacity: c_p, J kg⁻¹ K⁻¹.
    """

    boltzmann: float = 1.380649e-23
    avogadro: float = 6.02214076e23
    planck: float = 6.62607015e-34
    speed_of_light: float = 299_792_458.0
    standard_gravity: float = 9.80665
    standard_pressure: float = 101_325.0
    dry_air_molar_mass: float = 0.0289644
    dry_air_heat_capacity: float = 1004.5

    @property
    def gas_constant(self) -> float:
        # R = k_B N_A
        return self.boltzmann * self.avogadro

    @property
    def dry_air_gas_constant(self) -> float:
        # R_d = R / M_dry
        return self.gas_constant / self.dry_air_molar_mass


# proposed (geophysix)
@dataclasses.dataclass(frozen=True)
class Gas:
    """A trace gas by name and molar mass M, kg mol⁻¹."""

    name: str
    molar_mass: float


# proposed (geophysix)
@dataclasses.dataclass(frozen=True)
class Ellipsoid:
    """An ellipsoid of revolution: semi-major axis a, flattening f, rotation Ω."""

    semi_major: float
    flattening: float
    rotation_rate: float = 7.292115e-5

    def __post_init__(self) -> None:
        if not self.semi_major > 0.0:
            raise EarthError(f"semi_major must be > 0, got {self.semi_major}")
        if not 0.0 <= self.flattening < 1.0:
            raise EarthError(f"flattening must be in [0, 1), got {self.flattening}")

    @property
    def eccentricity_squared(self) -> float:
        # e² = f (2 − f)
        return self.flattening * (2.0 - self.flattening)

    @property
    def semi_minor(self) -> float:
        # b = a (1 − f)
        return self.semi_major * (1.0 - self.flattening)

    @property
    def mean_radius(self) -> float:
        # R₁ = (2a + b) / 3, the IUGG mean radius
        return (2.0 * self.semi_major + self.semi_minor) / 3.0


# proposed (geophysix)
def sphere(radius: float, rotation_rate: float = 7.292115e-5) -> Ellipsoid:
    # a sphere is the ellipsoid with f = 0
    return Ellipsoid(semi_major=radius, flattening=0.0, rotation_rate=rotation_rate)


# proposed (geophysix)
STANDARD = Constants()
# proposed (geophysix)
WGS84 = Ellipsoid(semi_major=6_378_137.0, flattening=1.0 / 298.257223563)
# proposed (geophysix)
GRS80 = Ellipsoid(semi_major=6_378_137.0, flattening=1.0 / 298.257222101)
# proposed (geophysix)
# R₁ of WGS84 = 6,371,008.77 m
SPHERE = sphere(WGS84.mean_radius)
# proposed (geophysix)
CH4 = Gas("CH4", 0.0160425)
TypeHoldsInvariants
ConstantsCODATA 2018 exact constants (k_B, N_A, h, c), g₀, p₀, dry-air M and c_pfrozen; SI; derived values (R, R_d, κ = R_d / c_p) are properties, never stored, so they cannot drift from their parts
Gasa name and a molar massM > 0; one instance per gas in the module (CH4, CO2, H2O, N2O, CO)
Ellipsoida, f, Ωa > 0, 0 ≤ f < 1 (checked at construction, raising EarthError); a sphere is f = 0
SeawaterEOS (wave 3)ρ₀, α, β_S, T₀, S₀ of a linear equation of stateρ₀ > 0; α, β_S ≥ 0

Errors subclass one base, GeophysixError(ValueError): EarthError for a bad body, UnitError for an unknown column unit, RangeError for a latitude outside [−90°, 90°] when a function checks it.

Types to adhere to

Functions read only the attributes they need, so any object with them works; the protocols say which:

ProtocolAttributes a caller’s object must haveRead by
EarthLikesemi_major, flattening, and for rotation rotation_rateearth.*, rotation.*
ConstantsLikethe Constants fields a function usesthermo.*, columns.*
GasLikemolar_masscolumns.*

A model on another planet, or a test that wants round numbers, passes its own record:

# user
mars = geophysix.earth.Ellipsoid(semi_major=3_396_200.0, flattening=1.0 / 169.8)
# proposed (geophysix)
# f = 2 Ω sin φ on Mars
coriolis = geophysix.rotation.coriolis(lat, earth=mars)  # (N,) → (N,)

Arrays follow the array-namespace contract: a function takes NumPy or JAX arrays, computes with the namespace of its first array argument (pipekit_array.array_namespace, as geoops’ stencils already do), and returns an array of the same kind and dtype. Core libraries, which cannot import geophysix, take the number they need (radius=SPHERE.semi_major) rather than the record.

Algorithms

Every algorithm here is closed form or a fixed number of steps, so each is a pure function that traces once.

Geodetic to Earth-centred coordinates, and back

With N(φ) the prime-vertical radius of curvature, the forward map from longitude λ, latitude φ and height h is

N(φ)=a1−e2sin⁡2φN(\varphi) = \frac{a}{\sqrt{1 - e^2 \sin^2\varphi}}
(X,Y,Z)=((N+h)cos⁡φcos⁡λ,(N+h)cos⁡φsin⁡λ,(N(1−e2)+h)sin⁡φ)(X, Y, Z) = \left( (N + h) \cos\varphi \cos\lambda, (N + h) \cos\varphi \sin\lambda, (N (1 - e^2) + h) \sin\varphi \right)

The inverse has no closed form in φ. geophysix uses Bowring’s method Bowring, 1976, as geonnax does today:

  1. λ = atan2(Y, X) and p = √(X² + Y²).

  2. Parametric latitude β₀ = atan2(a Z, b p).

  3. φ = atan2(Z + e’² b sin³β₀, p − e² a cos³β₀), with e’² = e² / (1 − e²).

  4. h = p / cos φ − N(φ), or (Z / sin φ) − N (1 − e²) near the poles.

One Bowring step is accurate to well under a millimetre for heights within ±10 km; a second fixed step covers orbital heights. Cost: a handful of trigonometric calls per point.

Pitfalls: an Earth-centred coordinate of 6.4 × 10⁶ m has a float32 spacing of 0.5 m, so sub-metre work needs float64; at the poles p → 0 and step 4 switches branch, which needs a double where so the gradient of the unused branch is not NaN.

Cell area on the ellipsoid

With s = sin φ and q the authalic function, the area between latitudes φ₁ and φ₂ across Δλ of longitude is

q(φ)=s1−e2s2+artanh⁡(es)eq(\varphi) = \frac{s}{1 - e^2 s^2} + \frac{\operatorname{artanh}(e s)}{e}
A=b2Δλ2(q(φ2)−q(φ1))A = \frac{b^2 \Delta\lambda}{2} \left( q(\varphi_2) - q(\varphi_1) \right)
  1. Evaluate q at each edge latitude (Ny + 1 values for Ny rows).

  2. Difference along latitude, multiply by b² Δλ / 2 per column.

Cost O(Ny + Nx), then an outer product for the grid. Pitfalls: written as (1 / 2e) ln((1 + es) / (1 − es)), the second term cancels badly for small e; artanh(es) / e does not, and as e → 0 it is replaced by its limit s behind a double where, which recovers the sphere’s A = R² Δλ (sin φ₂ − sin φ₁) exactly and keeps the gradient finite. The sum over the globe is 4π Rₚ² with Rₚ the authalic radius, which is a test.

Coriolis and β-planes

f=2Ωsin⁡φf = 2 \Omega \sin\varphi
β=2Ωcos⁡φR\beta = \frac{2 \Omega \cos\varphi}{R}

A β-plane at reference latitude φ₀ is f(y) = f₀ + β₀ (y − y₀). finitevolX’s coriolis_fn defaults, f₀ = 9.375 × 10⁻⁵ s⁻¹ and β = 1.754 × 10⁻¹¹ m⁻¹ s⁻¹, are this pair at 40° N. Pitfalls: degrees or radians, decided by an argument and never guessed; on the ellipsoid R becomes the meridional radius R_M(φ).

Hypsometric thickness

With T_v = T (1 + 0.61 q_v) the virtual temperature (q_v the specific humidity) and T̄_v its mean in ln p over the layer,

z2−z1=RdTˉvgln⁡p1p2z_2 - z_1 = \frac{R_d \bar T_v}{g} \ln \frac{p_1}{p_2}
  1. Virtual temperature at each level.

  2. Layer mean in ln p (the average of the two levels to second order).

  3. Thickness per layer; heights are its cumulative sum from the surface, an integrix scan.

Pitfalls: pressure in Pa against hPa, and level order (top-down in most reanalyses); the function takes the order as an argument and checks monotonicity outside jit.

Column units

With X a column enhancement in ppm·m, V_m = R T / p the molar volume and M the gas’s molar mass:

Ωmol=10−6XVm\Omega_{\mathrm{mol}} = \frac{10^{-6} X}{V_m}
Ωkg=MΩmol\Omega_{\mathrm{kg}} = M \Omega_{\mathrm{mol}}
Ndry=NAMdry(psg−W)N_{\mathrm{dry}} = \frac{N_A}{M_{\mathrm{dry}}} \left( \frac{p_s}{g} - W \right)

with W the water-vapour column in kg m⁻²; molecules per m² are N_A Ω_mol, and XCH₄ = N_CH₄ / N_dry. Pitfall: geoops fixes V_m = 0.024465 m³ mol⁻¹ (25 °C, 1 atm), which is about 9 % off at 0 °C (22.414 litres per mole); geophysix takes p and T, with that value only as an explicit default.

Equation of state of sea water

ρ=ρ0(1−α(T−T0)+βS(S−S0))\rho = \rho_0 \left( 1 - \alpha (T - T_0) + \beta_S (S - S_0) \right)

The linear form first, with finitevolX’s defaults (ρ₀ = 1025 kg m⁻³, α = 2 × 10⁻⁴ K⁻¹, β_S = 7 × 10⁻⁴, T₀ = 10 °C, S₀ = 35). TEOS-10 follows as the 75-term polynomial for specific volume of Roquet et al. (2015), which is differentiable and cheap, tested against gsw.

JAX compatibility

FunctionjitvmapgradNotes
earth.geodetic_to_ecef✓✓✓closed form
earth.ecef_to_geodetic✓✓✓fixed Bowring steps; double where at the poles
earth.cell_area✓✓✓double where at e = 0
earth.geodesic (decision 9)✓✓to checkKarney’s inverse Karney, 2013 iterates; a fixed count under jit, coincident and antipodal points to test
rotation.coriolis, beta, beta_plane✓✓✓
thermo.*✓✓✓
seawater.linear_density✓✓✓
seawater.teos10_density (wave 3)✓✓✓polynomial
columns.convert✓✓✓the units are static strings

Testing

Every public function gets the four tests of the house style.

  1. Oracles. pyproj: Transformer between EPSG:4979 and EPSG:4978 for geodetic and Earth-centred coordinates, Geod for geodesics and polygon areas, at rtol=1e-12 in float64. CODATA 2018 for the constants, compared exactly. The closed forms against their limits: the cell area of the whole sphere is 4πR², of the ellipsoid 4πRₚ². gsw for TEOS-10 densities.

  2. Properties (hypothesis). Geodetic to ECEF and back returns the input to 10⁻⁹ ° and 10⁻⁴ m for |h| < 10 km; cell areas are positive and additive under splitting a cell; f(−φ) = −f(φ); column conversions are inverses of each other.

  3. Gradients. jax.jacrev against central differences, including at the poles for ecef_to_geodetic and at f = 0 for cell_area.

  4. Transforms. Eager, jit and vmap agree with a Python loop; NumPy and JAX inputs give the same numbers.

A cross-library test pins the consumers’ values once they switch: finitevolX’s and sesmax-core’s results with R = 6,371,200 m against R₁, at the 3 × 10⁻⁵ relative tolerance of decision 4.

External dependencies

LibraryUsed forRequired or optional
NumPythe default array namespacerequired
pipekit-arrayarray_namespace, the NumPy and JAX dispatchrequired
JAXtraced use, gradientsoptional ([jax])
pyprojthe oracle in tests onlytest
gswthe TEOS-10 oracle in tests onlytest
hypothesisproperty teststest

pipekit-array is Core, so geophysix still imports nothing from GeoNumerics or GeoStack. Whether a dependency on pipekit-array is acceptable at GeoNumerics tier 0, or geophysix carries its own five-line dispatch, is a small decision for wave 0.

Where it stands

Read on 5 Nov 2026 at: finitevolX 82090a3 (0.1.0), somax aea3a03, sesmax 314de57, xrtoolz b3855d7, geotoolz d2f53f4, geonnax 9579986, kernellib 98fbbc7, gaussx 7fa1a38, radtrax ed04971, plumax 28f257e.

The values side by side

QuantityValueWhere
Earth radius6,371,200 mfinitevolX R_EARTH; somax and sesmax-core, the same file
6,371,000 mxrops grad EARTH_RADIUS; gaussx haversine_distance(radius=6.371e6)
6,371,008.8 mgeonnax EARTH_MEAN_RADIUS_M; kernellib EARTH_RADIUS_KM = 6371.0088; geoops _EARTH_MEAN_RADIUS_KM
6,378,137 m (semi-major, used as a radius)geoops parallax earth_radius_m; geoproducts Himawari EARTH_SEMI_MAJOR_M
Gravity9.80665 m s⁻²finitevolX, somax, sesmax-core, xrops grad and atm
9.81 m s⁻²plumax met/wrf.py
Rotation rate2π / 86,164 s = 7.29212 × 10⁻⁵ s⁻¹finitevolX, somax, sesmax-core
7.2921159 × 10⁻⁵ s⁻¹xrops grad
Dry airR_d = 287.04, c_p = 1004.5, M_dry = 0.0289644xrops atm; plumax met/wrf.py (κ as 287.04 / 1004.5)
CH₄ molar mass0.0160425 kg mol⁻¹plumax coupled/rtm.py
0.01604 kg mol⁻¹geoops MOLAR_MASS_KG_PER_MOL
k_B, 1 atm1.380649 × 10⁻²³ J K⁻¹, 101,325 Paradtrax atmosphere/gas.py; plumax hapi_lut/beers.py and radtran/config.py; xrops atm (k_B only)

The radii differ by up to 3 × 10⁻⁵ relative between mean radii and by 1.1 × 10⁻³ when the semi-major axis stands in for a mean radius; the two CH₄ masses by 1.6 × 10⁻⁴. The 6,371,200 m radius is the one somax inherited and finitevolX and sesmax-core copied; it is the geomagnetic reference radius of the IGRF, not a geodetic mean radius of the Earth.

Per library

Sources

References
  1. Bowring, B. R. (1976). Transformation from spatial to geographical coordinates. Survey Review, 23(181), 323–327.
  2. Roquet, F., Madec, G., McDougall, T. J., & Barker, P. M. (2015). Accurate polynomial expressions for the density and specific volume of seawater using the TEOS-10 standard. Ocean Modelling, 90, 29–43.
  3. Karney, C. F. F. (2013). Algorithms for geodesics. Journal of Geodesy, 87(1), 43–55. 10.1007/s00190-012-0578-z
  4. Moritz, H. (2000). Geodetic Reference System 1980. Journal of Geodesy, 74, 128–133.
  5. Snyder, J. P. (1987). Map Projections: A Working Manual. U.S. Geological Survey.