cheatah
Module

space::irbem

cheatah-space v0.1.0-alpha — Biome Standard 0.6.5-alpha

Benchmarks
Every measured table for space::irbem — with its stamp (commit, host, harness) and the commands that reproduce it — is on the space::irbem benchmarks page.
Verification status
What space::irbem implements, what has been verified against the reference implementation it reimplements, and over which part of the domain, is on the space::irbem verification page. Implemented and verified are not the same claim, and that page keeps them apart.

Classes

Concepts

Functions

fn Status igrf_batch_host(const Igrf< NMAX, P > &model, std::span< const Position< Frame::GEO > > points, std::span< FieldVector< Frame::GEO > > b, std::span< double > b_mag) #

The internal field at every point of a batch, on the host, through the SIMD strip evaluator — the vectorised twin of the Igrf::evaluate-per-point loop, bit-identical to it.

Whole strips of igrf_strip_points go through detail::igrf_evaluate_strip; the remainder (at most a strip minus one) runs the scalar kernel, which computes the identical bits, so a caller can neither observe nor need to care where the boundary fell. This is what field_batch's host lane calls; it is public so a caller who KNOWS the batch is host-bound can skip the device-crossover reasoning entirely.

Measured by bench/irbem_bench.cpp: 100 ns/point against the scalar loop's 362 at 2¹⁶ points, degree 13, pinned to a P-core — 3.6×; see the file brief for the methodology and the variants that measured worse.

Template parameters
NMAX

the truncation degree.

P

the precision policy (policy.hpp).

Parameters
model

the model, already built for the epoch.

points

the points, GEO, Earth radii.

b

receives one field vector per point, GEO, nT; same length as points.

b_mag

receives |B| per point, nT, computed from the returned vector exactly as the scalar loop computes it; same length as points.

Returns

Status::DomainError on a length mismatch (nothing written), Status::Ok otherwise.

Complexity

O(N·NMAX²) — the scalar loop's flop count, retired ~3.6× faster.

Allocation

none — the strip workspace is a fixed stack array; asserted by IrbemBatchSimd.BatchLaneNeverTouchesTheHeap.

Unit testIrbemBatchSimd.BatchLaneIsBitIdenticalToTheScalarLane IrbemBatchSimd.EveryTailLengthIsBitIdentical IrbemBatchSimd.BatchRefusesMismatchedSpans IrbemBatchSimd.BatchLaneNeverTouchesTheHeap
fn name_of · 3 overloads
std::string_view name_of(Driver d)#
std::string_view name_of(Frame f)#
std::string_view name_of(ExternalModel m)#

The driver's short name, as the maginput table and the model papers spell it.

Parameters
d

the driver.

Returns

a static string such as "Pdyn"; "?" for a value outside the enumerator list, which the reserved slots 18..25 have no enumerator for.

Complexity

O(1).

Allocation

none.

fn std::size_t cartesian_slot(Frame f) #

The slot f occupies in a RotationTable.

Computed rather than tabulated: Frame's values run GDZ=0, GEO..MAG=1..6, SPH=7, RLL=8, HEE..HEEQ=9..11, so removing the three angular frames is two contiguous shifts. A switch would need a default arm that no correct call can reach, and unreachable code is code that cannot be tested.

Parameters
f

a Cartesian frame; the result is meaningless (and, for GDZ, wraps) otherwise, which is why every caller in this header is constrained on CartesianFrame.

Returns

the slot, 0..8, with GEO at 0.

Complexity

O(1).

Allocation

none.

fn describe · 2 overloads
std::string_view describe(ContextError e)#
std::string_view describe(Status s)#

A human-readable reason, for a log line or a test failure message.

Parameters
e

the failure.

Returns

a static string such as "driver not finite"; "?" for a value outside the enumerator list.

Complexity

O(1).

Allocation

none.

fn ContextResult make_field_context(const Epoch &epoch, double tilt_rad, const RotationTable &to_geo, const DriverSet &drivers) #

Validate the inputs and, if they are usable, build the context for this epoch.

The only function permitted to build one.

The only function permitted to build one, so every context that exists has been validated.

The checks run in a fixed order — epoch, tilt, drivers, rotations — so a caller with several problems is told about them in that sequence rather than in an order that depends on the build.

Every driver is checked for finiteness and none is range-checked. That is deliberate: a caller legitimately leaves the slots its chosen external model ignores filled with a placeholder, so refusing a negative solar-wind speed here would reject a perfectly valid internal-field-only evaluation. Whether a driver is inside the range its model was fitted over is that model's question, asked where the driver is actually consumed.

Parameters
epoch

the instant; see detail::epoch_defect for what makes one usable.

tilt_rad

the geodipole tilt psi, radians, |psi| <= pi/2.

to_geo

the rotations for this epoch, per RotationTable — each is checked to be a finite, orthogonal, proper rotation, which is the guard on the seam with the coordinate transform module.

drivers

all 25 maginput slots; each must be finite.

Returns

the context, or the first defect found.

Complexity

O(1) — bounded by the 25 driver checks and the nine 3x3 rotation checks, all paid once per epoch rather than once per field evaluation.

Allocation

none.

fn Position< Frame::GEO > gdz_to_geo(const Position< Frame::GDZ > &gdz) #

Geodetic → geocentric Cartesian: IRBEM's GDZ2GEO (gdz_geo).

The exact closed form, e.g. Torge, Geodesy (3rd ed.) §4.1, or Hofmann-Wellenhof et al., GPS: Theory and Practice, eq. (10.1): x = (N+h)cosφ cosλ, y = (N+h)cosφ sinλ, z = (N(1-e²)+h) sinφ, with the prime-vertical radius N = a/W. The (1-e²) on z is the ellipsoid: the surface normal at latitude φ does not pass through the centre, so the vertical drops short of the equatorial plane by exactly that factor.

Parameters
gdz

the geodetic position — altitude in km above the WGS84 ellipsoid, geodetic latitude in degrees (north positive), east longitude in degrees.

Returns

the geographic Cartesian position, in Earth radii.

Complexity

O(1) — four transcendentals and a square root.

Allocation

none.

fn Position< Frame::GDZ > geo_to_gdz(const Position< Frame::GEO > &geo) #

Geocentric Cartesian → geodetic: IRBEM's GEO2GDZ (geo_gdz).

Latitude comes from detail::geodetic_latitude (Bowring 1976, iterated). Altitude then uses the projection form h = p cosφ + z sinφ - aW rather than Bowring's h = p/cosφ - N. The two are algebraically identical — substituting the forward transform gives p cosφ + z sinφ = N(1 - e² sin²φ) + h = aW + h — but this one has no cosine in a denominator, so it is exact on the polar axis and loses no digits approaching it.

Parameters
geo

the geographic Cartesian position, in Earth radii.

Returns

the geodetic position — altitude km, geodetic latitude deg, east longitude deg in (-180, 180].

Complexity

O(1) — detail::bowring_iterations Bowring passes plus a handful of transcendentals.

Allocation

none.

Note

Diverges from IRBEM exactly on the polar axis, where IRBEM is wrong. For (0, 0, 1) IRBEM returns altitude 0; this returns Re - b = 14.447685754820668 km, which is what its own gdz_geo(90, 0, 0) inverts to and what IRBEM's own answer tends to as p → 0 (geo_gdz(1e-6, 0, 1) gives 14.447690). A GDZ→GEO→GDZ sweep through IRBEM is off by up to 2282 km at latitude −90; through this routine the same sweep is exact to 2.9e-11 km.

Note

At the geocentre the answer is degenerate, because a point with no direction has no geodetic latitude: the iteration alternates between 0 and π with the parity of the pass count, so with detail::bowring_iterations even the reported latitude is ~0 where IRBEM reports 180°. The altitude is -a exactly either way, which is the only part of the answer that means anything. Callers that care must reject r = 0 themselves.

fn Position< Frame::GEO > sph_to_car(const Position< Frame::SPH > &sph) #

Geographic spherical → geocentric Cartesian: IRBEM's SPH2CAR (sph_car).

The textbook spherical-polar relation with latitude in place of colatitude: x = r cosφ cosλ, y = r cosφ sinλ, z = r sinφ. No ellipsoid is involved — SPH is GEO re-expressed, nothing more — so this is exact and its inverse car_to_sph is a true inverse.

Parameters
sph

the spherical position — radius in Earth radii, geocentric latitude in degrees, east longitude in degrees.

Returns

the geographic Cartesian position, in Earth radii.

Complexity

O(1) — four transcendentals.

Allocation

none.

fn Position< Frame::SPH > car_to_sph(const Position< Frame::GEO > &car) #

Geocentric Cartesian → geographic spherical: IRBEM's CAR2SPH (car_sph).

Latitude is taken as 90° - θ with the colatitude θ = atan2(sqrt(x²+y²), z), and longitude as atan2(y, x). Both are atan2 rather than acos/atan for the usual reason — full range, no cancellation as the argument approaches ±1, and no division by a vanishing radius.

Parameters
car

the geographic Cartesian position, in Earth radii.

Returns

the spherical position — radius in Earth radii, geocentric latitude in [-90, 90], east longitude in (-180, 180].

Complexity

O(1) — a hypot and two atan2.

Allocation

none.

Note

At the origin the colatitude atan2(0, 0) is 0, so the result is (0, 90°, 0°). That is the limit along the +z axis and is what IRBEM reports there; a point with no direction has no honest latitude, and callers that care must reject r = 0 themselves.

Note

Longitude on the polar axis is undefined, and here it is IEEE-defined instead. With x and y both zero, atan2 reports the branch the signs of those zeros select — so (-0, -0, z) gives −180° where IRBEM gives 0°. That is the one genuine convention difference in this file. It is left alone rather than special-cased: atan2's answer makes sph_to_carcar_to_sph recover the original longitude for every input this library produces (its polar x, y are ~1e-17, not zero), which a hard-coded 0° would not.

fn Position< Frame::GDZ > rll_to_gdz(const Position< Frame::RLL > &rll) #

Radius/latitude/longitude → geodetic: IRBEM's RLL2GDZ (rll_gdz).

RLL is the awkward frame: a geocentric radius carried alongside a geodetic latitude (IRBEM's sysaxes table calls this out — "the latitude is still geodetic latitude and is therefore not interchangeable with SPH"). So the job is not a spherical-to-geodetic conversion; it is to solve for the altitude h that puts the point at geocentric radius r along the ellipsoid normal at a given geodetic latitude. Squaring the forward transform makes that a quadratic in h: r² = (N + h)²cos²φ + (N(1-e²) + h)²sin²φ = h² + 2Bh + C, B = N(1 - e² sin²φ) = aW, C = N²(cos²φ + (1-e²)² sin²φ), h = -B + sqrt(B² - C + r²).

and the discriminant simplifies exactly, since B² - C = -N² e⁴ sin²φ cos²φ: h = -B + sqrt(r² - N² e⁴ sin²φ cos²φ).

The positive root is the one taken: the other places the point on the far side of the axis.

Parameters
rll

the RLL position — geocentric radius in Earth radii, geodetic latitude in degrees, east longitude in degrees.

Returns

the geodetic position, with latitude and longitude passed through unchanged (RLL and GDZ share both by definition) and the solved altitude in km.

Complexity

O(1) — two transcendentals and two square roots.

Allocation

none.

Note

The subtracted term peaks at ~21.4 km, so the discriminant goes negative for points deeper than about 0.0034 Re from the centre near 45° latitude — there is no ellipsoid normal through such a point at that latitude. It is clamped to zero, giving h = -aW, the altitude of the foot of the normal. That is a well-defined answer for a degenerate input, not a silent failure: no radius a caller can physically observe reaches it.

Note

This is exact where IRBEM's is not. IRBEM returns 14.447685999998612 km for rll_gdz(1, 90, 0); the closed form gives Re - b = 14.447685754820668 km. The 2.4e-7 km gap is IRBEM's own iteration, and it is present at every latitude.

fn double astronomical_unit_km() #

One astronomical unit in kilometres.

Returns

149 597 870.7 km, the defining value of IAU 2012 Resolution B2.

Complexity

O(1).

Allocation

none.

fn double au_in_earth_radii() #

One astronomical unit in Earth radii — the factor between this module's positions and the AU that the heliospheric literature (and IRBEM's own GSE2HEE entry point) prefers.

Returns

astronomical_unit_km() / 6371.2, ≈ 23 480.33.

Complexity

O(1).

Allocation

none.

fn double solar_equator_inclination_deg() #

The inclination of the solar equator to the ecliptic.

Returns

7.25°, the conventional value of F&H eqn. 14. It is held fixed: later measurements make the axis direction less well determined, not better, and coordinate work sticks with Carrington's value so that datasets remain comparable.

Complexity

O(1).

Allocation

none.

fn HelioGeometry helio_geometry(double mjd_tt) #

The heliospheric geometry at an epoch — the only place in this file that evaluates a transcendental, and the only place that reads the published ephemeris constants.

λ_geo and r₀ come from the Earth–Moon barycentre's mean elements (F&H Table 4) fed through the equation-of-centre approximation of F&H eqn. 36; Ω from eqn. 14; and θ from eqn. 17, evaluated as a two-argument arctangent of (cos i · sin(λ−Ω), cos(λ−Ω)) so that the quadrant follows from the geometry instead of from a sign convention. F&H's prose ("the quadrant of θ is opposite that of λ−Ω") does not describe this construction; the two-argument form is what reproduces F&H's own worked example (their Table 8 implies θ = 259.899186°, against the 259.89919° they print), so the prose is read as a slip and the arithmetic is trusted.

Parameters
mjd_tt

the epoch as a Modified Julian Date on the TT scale (JD_TT − 2400000.5).

Returns

the geometry, with every angle folded into [0, 360) and both rotation matrices built.

Complexity

O(1) — a fixed count of transcendental evaluations and two 3×3 matrix products, independent of the epoch.

Allocation

none; the result is 6 doubles plus 18 more in two matrices, all inline.

fn V< Frame::HEE > HAE2HEE(const V< Frame::HAE > &in, const HelioGeometry &geometry) #

HAE → HEE: a rotation by the Earth's heliocentric longitude about the ecliptic pole.

Both frames are centred on the Sun, so this is a pure rotation for a position and for a field alike — T(HAE_D→HEE_D) = E(0, 0, λ_geo), F&H §3.2.2.

Template parameters
V

the vector kind, Position or FieldVector.

Parameters
in

the HAE components.

geometry

the epoch geometry from helio_geometry.

Returns

the same physical vector, in HEE.

Complexity

O(1) — nine multiplies and six adds.

Allocation

none.

fn V< Frame::HAE > HEE2HAE(const V< Frame::HEE > &in, const HelioGeometry &geometry) #

HEE → HAE: the inverse rotation, which for an orthonormal matrix is its transpose.

Template parameters
V

the vector kind, Position or FieldVector.

Parameters
in

the HEE components.

geometry

the epoch geometry from helio_geometry.

Returns

the same physical vector, in HAE.

Complexity

O(1).

Allocation

none.

fn V< Frame::HEEQ > HAE2HEEQ(const V< Frame::HAE > &in, const HelioGeometry &geometry) #

HAE → HEEQ: into the frame of the solar equator, with +X on the solar central meridian.

T(HAE_D→HEEQ) = E(Ω, i, θ) (F&H §3.2.2): swing +X onto the ascending node of the solar equator, tip by the 7.25° inclination, then rotate within the equator until +X lies under the Earth. Both frames are heliocentric, so again there is no translation.

Template parameters
V

the vector kind, Position or FieldVector.

Parameters
in

the HAE components.

geometry

the epoch geometry from helio_geometry.

Returns

the same physical vector, in HEEQ.

Complexity

O(1).

Allocation

none.

fn V< Frame::HAE > HEEQ2HAE(const V< Frame::HEEQ > &in, const HelioGeometry &geometry) #

HEEQ → HAE: the inverse of HAE2HEEQ, again by transpose.

Template parameters
V

the vector kind, Position or FieldVector.

Parameters
in

the HEEQ components.

geometry

the epoch geometry from helio_geometry.

Returns

the same physical vector, in HAE.

Complexity

O(1).

Allocation

none.

fn GSE2HEE · 2 overloads
Position< Frame::HEE > GSE2HEE(const Position< Frame::GSE > &in, const HelioGeometry &geometry)#
FieldVector< Frame::HEE > GSE2HEE(const FieldVector< Frame::GSE > &in, const HelioGeometry &geometry)#

GSE → HEE for a POSITION: half turn about the ecliptic pole, then the shift from the geocentric to the heliocentric origin.

Parameters
in

the GSE position, in Earth radii.

geometry

the epoch geometry from helio_geometry.

Returns

the HEE position, in Earth radii — the Earth itself (GSE origin) maps to (r₀, 0, 0).

Complexity

O(1) — two negations and one add.

Allocation

none.

fn HEE2GSE · 2 overloads
Position< Frame::GSE > HEE2GSE(const Position< Frame::HEE > &in, const HelioGeometry &geometry)#
FieldVector< Frame::GSE > HEE2GSE(const FieldVector< Frame::HEE > &in, const HelioGeometry &geometry)#

HEE → GSE for a POSITION: the inverse of GSE2HEE, which is the same expression again.

Parameters
in

the HEE position, in Earth radii.

geometry

the epoch geometry from helio_geometry.

Returns

the GSE position, in Earth radii — the Sun itself (HEE origin) maps to (r₀, 0, 0).

Complexity

O(1).

Allocation

none.

fn double gmst_iau1982_degrees(double jd_ut1) #

Greenwich Mean Sidereal Time from the IAU 1982 series.

The series (Aoki et al., Astron. Astrophys. 105, 359, 1982) is conventionally written for 0ʰ UT1 and then advanced by the elapsed day; the equivalent whole-instant form used here is GMST [s] = 67310.54841 + (876600ʰ·3600 + 8640184.812866)·T + 0.093104·T² − 6.2e-6·T³

with T in Julian centuries of UT1 from J2000.0. The leading 67310.54841 s is the familiar 24110.54841 s at 0ʰ plus the half day between midnight and the J2000.0 noon epoch.

The large linear coefficient is evaluated exactly rather than as written: 876600·3600 = 3155760000 is precisely 36525 · 86400, so that term is exactly 86400 · d for d days since J2000.0, and since the result is reduced modulo a day only its FRACTIONAL part matters. Dropping the whole days before multiplying keeps the sum from carrying an integer of order 10⁹ that is about to be discarded. Measured against an extended-precision evaluation of the same series over ±100 years, that is worth a factor of ~300: 1.5e-11° against 4.6e-9°, pinned by IrbemGmst.TheFractionalDayFoldingIsWorthAFactorOfHundreds.

Neither figure is anywhere near mattering physically — both are microarcseconds, and the coordinate-transform line of docs/ERROR_BUDGET.md is 1e-10 RELATIVE. It is done because it is free, not because the naive form would break anything.

Parameters
jd_ut1

the epoch as a UT1 Julian date.

Returns

Greenwich Mean Sidereal Time in degrees, reduced to [0, 360).

Complexity

O(1).

Allocation

none.

fn double gmst_hapgood_degrees(double jd_ut1) #

Greenwich Mean Sidereal Time from Hapgood 1992 eq.

(2): θ = 100.461 + 36000.770·T0 + 15.04107·H degrees, with T0 and H as in detail::hapgood_epoch.

This is a linear truncation of the IAU series and drops its quadratic term, so it drifts slowly away from gmst_iau1982_degrees — about 2 arcseconds near J2000, growing to a few tens of arcseconds a century out. It exists so a result can be reproduced against codes that use it.

Parameters
jd_ut1

the epoch as a UT1 Julian date.

Returns

Greenwich Mean Sidereal Time in degrees, reduced to [0, 360).

Complexity

O(1).

Allocation

none.

fn double gmst_degrees(double jd_ut1, GmstModel model) #

Greenwich Mean Sidereal Time, from whichever published series model names.

Parameters
jd_ut1

the epoch as a UT1 Julian date.

model

which series to evaluate.

Returns

Greenwich Mean Sidereal Time in degrees, reduced to [0, 360).

Complexity

O(1).

Allocation

none.

fn SolarEphemeris solar_ephemeris(double jd_ut1) #

The solar ephemeris of Hapgood 1992 eq.

(5), itself the Almanac for Computers low-precision series: M = 357.528 + 35999.050·T0 + 0.04107·H Λ = 280.460 + 36000.772·T0 + 0.04107·H λ☉ = Λ + (1.915 − 0.0048·T0)·sin M + 0.020·sin 2M ε = 23.439 − 0.013·T0

all in degrees, with T0 and H as in detail::hapgood_epoch. The sin M term is the equation of centre truncated at second order in the eccentricity; the series is accurate to about 0.01° in λ☉ over 1950–2050, which propagates to 0.01° in the GSE/GSM axes — far inside the budget for a frame whose defining direction is the Sun.

Parameters
jd_ut1

the epoch as a UT1 Julian date.

Returns

the mean anomaly, mean longitude, apparent ecliptic longitude and obliquity, in degrees. The two longitudes are reduced to [0, 360); the obliquity is not, being small.

Complexity

O(1).

Allocation

none.

fn fixarray::vec3d sun_direction_gei(const SolarEphemeris &sun) #

The unit vector from the Earth to the Sun, in GEI.

The Sun sits on the ecliptic by definition, so its ecliptic latitude is zero and the standard ecliptic-to-equatorial rotation by the obliquity reduces to (cos λ☉, cos ε · sin λ☉, sin ε · sin λ☉).

Parameters
sun

the solar ephemeris for the epoch, from solar_ephemeris.

Returns

the Earth-to-Sun unit vector in GEI (true equator and equinox of date).

Complexity

O(1), three transcendental calls.

Allocation

none.

fn fixarray::mat3d rotation_matrix(const Rotations &rotations) #

The rotation carrying frame From to frame To at this epoch.

The transposed-direction case of rotation_matrix; see it for the full description.

The stored-direction case of rotation_matrix; see it for the full description.

Three constrained overloads share this name and this documentation, because they are three cases of one operation and a caller never chooses between them:

  • To equal to From is the identity. It exists so that generic code parameterized on a target frame compiles for every frame including its own, instead of needing a special case at every call site.

  • A pair Rotations stores in that direction is a read of nine already-computed numbers.

  • The remaining pairs are stored the other way round, and are returned as the TRANSPOSE. A frame rotation is orthogonal — no scale, no shear — so its inverse is exactly its transpose. That is why the reverse direction is not stored: the transpose is a re-indexed read that cannot disagree with the forward matrix, whereas a second stored copy could, and a general inversion would cost a determinant and nine divisions to reproduce numbers already held exactly.

Any other pair fails RotationAvailable and does not compile.

Template parameters
To

the destination frame.

From

the source frame.

Parameters
rotations

the epoch's rotations. Unread by the identity case, which keeps the argument anyway so the identity is not a different call shape from every other transform.

Returns

the 3×3 rotation from From to To.

Complexity

O(1).

Allocation

none.

fn V< To > transform(V< From > value, const Rotations &rotations) #

Transform a frame-tagged position or field vector into frame To.

The whole cost of a transform, once rotations exists: one 3×3 matrix–vector product, no branches, no allocation, and the frame checked entirely at compile time. Call it as transform<Frame::GSM>(p, rotations) — the source frame and whether this is a position or a field are deduced from the argument, so a GEO-to-GSM transform simply cannot be handed a GSM input.

Supported pairs are the eight Rotations stores and their eight inverses; anything else is a compile error, and must be composed through GEO deliberately.

Template parameters
To

the destination frame.

V

the tagged vector template — Position or FieldVector; deduced.

From

the source frame; deduced.

Parameters
value

the position or field vector to transform.

rotations

the epoch's rotations, built once by Rotations::at.

Returns

the same physical quantity, expressed in frame To.

Complexity

O(1): nine multiplies and six adds.

Allocation

none.

fn Result< bool > make_lstar_batch(const M &model, const Rotations &rotations, std::span< const Position< Frame::GEO > > starts, std::span< const double > pitch_angles_deg, std::span< DriftShell > out, std::span< Status > statuses, const DriftShellOptions &opt={}) #

Roederer's L* for a whole batch of points — this is the routine to call.

The batch form is not a convenience wrapper. It is the only shape in which the problem is parallel: one L* point is Nder independent root-finds of a few traces each, and a device dispatch does not pay for Nder = 25 lines (gpu/dispatch.hpp measures the crossover at ~512). Hand over ntime points at once and every stage becomes ntime × Nder wide, which is where the measured 48.9× on the trace kernel actually lands. A loop calling make_lstar per point cannot be accelerated — the same fact lstar.hpp states about trace_invariant.

Each point is treated as a LOCALLY MIRRORING particle at pitch_angles_deg, matching IRBEM's make_lstar (90°) and make_lstar_shell_splitting (arbitrary). The shell that particle drifts on is the set of field lines carrying its (B_m, I), and Φ is the flux through the polar cap those lines' footpoints enclose.

Template parameters
NMAX

the IGRF truncation degree.

Parameters
model

the internal field model, already built for the epoch.

rotations

the epoch's rotations — only geo_to_mag is read, so the drift shell is organised about the DIPOLE axis rather than the geographic one.

starts

the points, GEO, Earth radii.

pitch_angles_deg

the local pitch angle at each point; same length as starts.

out

receives one DriftShell per point; same length as starts.

statuses

receives each point's status; same length as starts.

opt

the resolution and root-find settings.

Returns

Status::Ok when every point closed; Status::NotConverged when any shell failed to bracket or any footpoint failed to reach the surface; Status::OpenFieldLine when a starting line did not close; Status::DomainError on a length mismatch or a non-physical model. The value is true when the device serviced THE TRACES, and it is deliberately not an "any stage ran on the device" flag: the footpoint walk and the flux quadrature are folded out of it, because both take the device lane at batch sizes far below the ~512-line trace crossover and an OR across all three would read true on a call whose traces — 95 % of the cost, and the whole performance claim — ran on the host. MEASURED: with irbem_trace_i_f32.spv removed from the shader directory and every other kernel present, the OR'd flag still read true while the batch ran at 7.9 ms/point instead of 0.35, i.e. it reported success for exactly the silent fallback it exists to catch. IrbemDriftShell.UsesTheDeviceWhenOneIsAvailable asserts this narrower flag, so that fallback now fails the suite.

Complexity

O(points × Nder × (trials + iterations)) traces, plus O(points × Nder × 180/dθ) field evaluations for the flux. All of it concurrent on the device.

Allocation

O(rounds) vectors for the root-find and O(1) per flux chunk; nothing per trace and nothing per field evaluation.

Unit testIrbemDriftShell.BatchMatchesThePointAtATimeCall IrbemDriftShell.MatchesTheOracleAtIrbemDefaultResolution IrbemDriftShell.UsesTheDeviceWhenOneIsAvailable
fn Result< DriftShell > make_lstar(const M &model, const Rotations &rotations, const Position< Frame::GEO > &start, double pitch_angle_deg=90.0, const DriftShellOptions &opt={}) #

Roederer's L* for one point.

The reference lane, and the one to reach for when there is a single point to compute — but NOT the fast one. Nder = 25 root-finds is a batch of 25, an order of magnitude below the ~512-line crossover gpu/dispatch.hpp measures, so a single L* runs almost entirely on the host however much hardware is present. That is a property of the problem, not of this implementation: the parallelism in L* lives across POINTS, and make_lstar_batch is where it is taken.

Template parameters
NMAX

the IGRF truncation degree.

Parameters
model

the internal field model, already built for the epoch.

rotations

the epoch's rotations; only geo_to_mag and the dipole axis are read.

start

the point, GEO, Earth radii.

pitch_angle_deg

the local pitch angle; 90° is IRBEM's make_lstar convention.

opt

the resolution and root-find settings.

Returns

the shell, with the same statuses make_lstar_batch reports. The value is populated in every case — a failed shell still carries L_m, I and the fields, which is what makes the failure diagnosable.

Complexity

O(Nder × (trials + iterations)) traces plus O(Nder × 180/dθ) field evaluations.

Allocation

as make_lstar_batch, at n = 1.

Unit testIrbemDriftShell.MatchesTheOracleAtIrbemDefaultResolution IrbemDriftShell.BatchMatchesThePointAtATimeCall
fn int mead_kp_bin(double kp_times_ten) #

The Kp bin Mead & Fairfield (1975) uses, from Kp in IRBEM's OMNI2 scaling.

Four groups: {0, 0+}, {1-, 1, 1+, 2-}, {2, 2+, 3-} and Kp >= 3. In the Kp x 10 scaling the attainable values are 0, 3, 7, 10, 13, 17, 20, 23, 27, 30, ..., so the group edges are 4 (between 0+ = 3 and 1- = 7), 20 (2- = 17 from 2 = 20) and 30 (3- = 27 from 3 = 30). The edges are inclusive on the upper side — 20 is bin 3 and 30 is bin 4 — which the oracle switch points in tools/oracle/mead_diff.cpp confirm. A negative or NaN Kp (which check_validity reports separately) yields bin 1 and anything at or above 30 yields bin 4.

Parameters
kp_times_ten

Kp in IRBEM's slot-1 scaling, i.e. Kp x 10, nominally 0..90.

Returns

the bin, 1..4.

Complexity

O(1).

Allocation

none.

Unit testIrbemMead.KpBinsFollowThePublishedGroups
fn MeadParameters< T > mead_parameters(int bin) #

The coefficients for bin, in the scalar type the caller's lane evaluates in.

The float instantiation is how the host lane is made to run the same arithmetic as the device kernel: rounding the coefficients on the way in, rather than evaluating in double and rounding the answer, is what makes a host-vs-device disagreement attributable to the DEVICE.

Template parameters
T

the scalar type; double or float.

Parameters
bin

the Kp bin, 1..4. Values outside that range are clamped, because this function is called after mead_field has already decided what status the answer carries and must not be able to index out of the table.

Returns

the coefficient set, by value, converted to T.

Complexity

O(1) — 17 conversions, all folded for a compile-time bin.

Allocation

none; the returned object is inline storage.

Unit testIrbemMead.ParametersRoundTripThroughFloat IrbemMead.ParametersClampAnOutOfRangeBin
fn std::array< T, 3 > mead_components(const MeadParameters< T > &p, T sin_tilt, T cos_tilt, T tilt_deg, T x, T y, T z) #

The Mead-Fairfield external field at one GSM point, as three components in nanotesla.

This is the whole model. The order is: GSM to SM by the tilt (a rotation about y, the same one t89_components makes and for the same reason — the paper's frame has the dipole axis as z); the SM position to its aberrated twin (x_m, y_m) by a rotation about z through 4 degrees; the three polynomials of the file brief; and the SM components back to GSM. The aberration rotates the POSITION only — see the brief for the measurement that says so.

The tilt arrives three times over: as its sine and cosine for the frame rotation, and as the angle in degrees for the polynomials. All three are properties of the epoch, not of the point, so they are paid once per timestamp by the caller rather than recomputed here — there is no trigonometry in this function at all. The three MUST describe the same angle; the entry points below guarantee it by deriving all three from one tilt_rad.

Template parameters
T

the scalar type; double for the reference lane, float to mirror the device kernel.

Parameters
p

the coefficients for the Kp bin; see mead_parameters.

sin_tilt

sin(psi), the dipole tilt's sine.

cos_tilt

cos(psi).

tilt_deg

psi in DEGREES — the paper's unit for the tilt, and the unit the coefficients are printed in.

x

the GSM x coordinate, R_E.

y

the GSM y coordinate, R_E.

z

the GSM z coordinate, R_E.

Returns

{B_x, B_y, B_z} in GSM, nanotesla.

Complexity

O(1) — about 50 flops; no loop, no branch, no transcendental.

Allocation

none.

Unit testIrbemMead.DivergenceVanishesEverywhere IrbemMead.ZeroTiltIsMirrorSymmetricAboutTheEquator IrbemMead.TheTiltDependenceIsExactlyLinear IrbemMead.TheAberrationRotatesTheNoonMidnightPlaneByFourDegrees
fn FieldVector< Frame::GSM > mead_field_at(Position< Frame::GSM > p, double sin_tilt, double cos_tilt, double tilt_deg, int bin) #

The Mead-Fairfield external field at one GSM point, in double — the reference lane.

Parameters
p

the position, GSM, in Earth radii.

sin_tilt

sin(psi); HotState::sin_tilt holds it, precomputed per epoch.

cos_tilt

cos(psi).

tilt_deg

psi in degrees; HotState::tilt_rad * mead_deg_per_rad.

bin

the Kp bin, 1..4; out-of-range values are clamped by mead_parameters.

Returns

the external field at p, GSM, in nanotesla.

Complexity

O(1); see mead_components.

Allocation

none.

Unit testIrbemMead.ReferenceLaneMatchesTheComponentForm
fn mead_field · 2 overloads
Result< FieldVector< Frame::GSM > > mead_field(Position< Frame::GSM > p, double tilt_rad, double kp_times_ten)#
Result< FieldVector< Frame::GSM > > mead_field(Position< Frame::GSM > p, const FieldContext &ctx)#

The Mead-Fairfield external field, with the model's own verdict on whether to believe it here.

The value is always returned, including when the status is Status::OutOfValidityRange — status.hpp's standing rule. What is refused outright is input that has no meaning: a non-finite coordinate, tilt or Kp, a point inside the Earth, or a tilt beyond a right angle. The last is not a singularity as it is in t89_field (there is no tan(psi) here, and psi = 90 deg itself has a value) but a definition: the geodipole tilt is the angle between the dipole axis and the GSM z axis and lies in [-90, 90] degrees, the same bound make_field_context enforces. Within it, and with a finite radius, the three quadratics cannot overflow — every squared coordinate is bounded by the finite r^2 and every coefficient by 30 — so no check on the OUTPUT is needed, and none is made.

What IS checked against the published envelope, both through status.hpp so the rules live in one place: Kp against 0 <= Kp <= 9 (in IRBEM's Kp x 10 scaling) and the position against the paper's r <= 17 R_E. The Kp check happens before the binning, so a caller who passes Kp = 12 gets the Kp >= 3 set AND is told the model was never fitted there.

Parameters
p

the position, GSM, in Earth radii.

tilt_rad

the dipole tilt psi, radians; positive when the north dipole leans sunward.

kp_times_ten

Kp in IRBEM's maginput slot-1 scaling, i.e. Kp x 10, nominally 0..90.

Returns

the field and its caveat. Status::DomainError (with a zero field) for a non-finite input, a radius inside the Earth, or |psi| > pi/2; Status::OutOfValidityRange for a Kp or a radius outside the published envelope, with the field still computed; otherwise Status::Ok.

Complexity

O(1).

Allocation

none.

Unit testIrbemMead.OutOfRangeKpIsReportedButStillEvaluated IrbemMead.TheRadialEnvelopeIsCheckedFromBothSides IrbemMead.NonFiniteInputIsADomainError IrbemMead.ATiltBeyondARightAngleIsADomainError
fn bool mead_field_host(std::span< const float > pos, std::span< float > out, float sin_tilt, float cos_tilt, float tilt_deg, int bin) #

The Mead-Fairfield field over a whole batch, on the CPU, in float.

The host twin of irbem_mead_f32: the same expressions, in the same order, in the same precision, from coefficients rounded to float FIRST — what makes a disagreement between the two lanes attributable to the device. It is also the lane a machine with no GPU actually runs.

Parameters
pos

the points, xyz-interleaved, 3N floats, GSM, in Earth radii.

out

the field, xyz-interleaved, 3N floats, nanotesla; overwritten in full.

sin_tilt

sin(psi).

cos_tilt

cos(psi).

tilt_deg

psi in degrees.

bin

the Kp bin, 1..4.

Returns

false when pos is not a whole number of points or out is a different length, in which case nothing is written; true otherwise.

Complexity

O(N).

Allocation

none: the loop is over caller-provided spans and one stack parameter set.

Unit testIrbemMead.HostFloatLaneTracksTheReferenceLane IrbemMead.HostFloatLaneRejectsMismatchedSpans
fn std::array< float, mead_param_count > mead_param_block(float sin_tilt, float cos_tilt, float tilt_deg, int bin) #

Pack the epoch's tilt and a Kp bin's coefficients into the kernel's parameter buffer.

The layout is the kernel's ABI and is stated in exactly two places — here and the comment above irbem_mead_f32 in irbem.slang: [0] sin psi, [1] cos psi, [2] psi in degrees, [3..9] a_1..a_7, [10..12] b_1..b_3, [13..19] c_1..c_7. A test evaluates both lanes on the same points, which is what actually keeps the two statements in step.

Parameters
sin_tilt

sin(psi).

cos_tilt

cos(psi).

tilt_deg

psi in degrees.

bin

the Kp bin, 1..4.

Returns

the parameter block, mead_param_count floats, by value.

Complexity

O(1).

Allocation

none — the block is the returned object's own inline array.

Unit testIrbemMead.ParameterBlockCarriesTheTiltThenTheCoefficients
fn Result< bool > mead_field_batch(std::span< const Position< Frame::GSM > > points, double tilt_rad, double kp_times_ten, std::span< FieldVector< Frame::GSM > > out) #

The Mead-Fairfield field over a whole batch of GSM points, on the device when that is worth it.

The shape mirrors t89_field_batch exactly — device above the registry's measured crossover, the fp64 host loop otherwise, one folded Status for the batch, and a returned value that says which lane actually served the call. What differs is the arithmetic intensity: this model is ~50 flops for 24 bytes in and 12 out, about 1.4 flops/byte, which is within a factor of three of the streaming dipole kernel that LOSES on this seam and an order of magnitude below T89's ~11. The irbem_mead_f32 row of gpu/dispatch.hpp carries the measurement and the verdict.

The batch reports the same caveats the scalar entry point does, folded over the whole batch. If any point is beyond the published r <= 17 R_E the batch says Status::OutOfValidityRange and is still computed in full; if any point is inside the Earth or not finite it says Status::DomainError and every output is zeroed.

Parameters
points

the positions, GSM, in Earth radii.

tilt_rad

the dipole tilt psi, radians, |psi| <= pi/2 as for mead_field.

kp_times_ten

Kp in IRBEM's slot-1 scaling; binned by mead_kp_bin.

out

receives one field vector per input, GSM, nanotesla; same length as points.

Returns

Status::DomainError on a length mismatch, a non-finite tilt or Kp, a tilt beyond a right angle, or a point that is not finite or is inside the Earth, and then every output is zeroed; Status::OutOfValidityRange when Kp or any point's radius is outside the published range, with every point still computed; otherwise Status::Ok. The value is true exactly when the device lane serviced the call.

Complexity

O(N); on the device those N run concurrently over ceil(N/256) workgroups.

Allocation

the device lane stages positions and results into two std::vector<float> of 3N; the host lane allocates nothing.

Unit testIrbemMead.BatchAgreesWithTheReferenceLane IrbemMead.BatchRejectsMismatchedSpans IrbemMead.BatchReportsTheSameEnvelopeTheScalarLaneDoes
fn double opd_pressure_npa(double density_cc, double velocity_kms) #

The solar-wind dynamic pressure, in nPa.

P = m_p n V^2, the ram pressure of a proton wind. This is the ONLY combination of the two solar-wind drivers the model reads — a fact about the published idea (Chapman-Ferraro pressure balance knows nothing but P) that the oracle turns out to share bit for bit.

Parameters
density_cc

the proton density, cm^-3.

velocity_kms

the bulk speed, km/s.

Returns

the dynamic pressure, nPa; 1.338 nPa at the reference (5, 400).

Complexity

O(1).

Allocation

none.

Unit testIrbemOpd.DynamicPressureIsTheProtonRamPressure
fn double opd_reference_pressure_npa() #

The reference pressure P_0, at which the quiet field is used uncompressed.

Returns

opd_pressure_npa(5, 400), ~1.338 nPa.

Complexity

O(1).

Allocation

none.

Unit testIrbemOpd.ReferenceConditionsReduceToTheQuietField
fn double opd_scale(double density_cc, double velocity_kms) #

The compression ratio s = R_0 / R_mp = (P / P_0)^(1/6).

Pressure balance at the subsolar magnetopause, P = B_mp^2 / 2 mu_0 with B_mp ∝ R_mp^-3, puts the boundary at R_mp ∝ P^(-1/6) (Chapman & Ferraro 1931; the textbook statement is Kivelson & Russell, Introduction to Space Physics, 1995, §6.2). A self-similar compression of the whole boundary-current system by the factor s then scales its field as s^3 B(s r) (Mead, JGR 69, 1181, 1964, whose boundary-field coefficients all carry R_mp^-3). This function is the s; the evaluator applies the transform.

Parameters
density_cc

the proton density, cm^-3; must be positive.

velocity_kms

the bulk speed, km/s; must be positive.

Returns

s, exactly 1 at the reference conditions, 1.58 at the envelope's most compressed corner (50, 500), 0.86 at its least (5, 300).

Complexity

O(1) — one pow.

Allocation

none.

Unit testIrbemOpd.CompressionFollowsTheSixthRootOfPressure IrbemOpd.CompressionScalesTheCentralFieldAsTheCubeOfTheStandoff
fn double opd_dst_star(double dst_nt, double pressure_npa) #

The pressure-corrected Dst, Dst* = Dst - b sqrt(P) + c.

O'Brien & McPherron (2000), with b = 7.26 nT/sqrt(nPa) and c = 11 nT: the index with the magnetopause currents' contribution removed, leaving the part the ring current is responsible for. This is the "modified Dst" the model's description names as its ring-current driver.

Parameters
dst_nt

the Dst index, nT.

pressure_npa

the dynamic pressure, nPa; must be non-negative.

Returns

Dst*, nT.

Complexity

O(1) — one sqrt.

Allocation

none.

Unit testIrbemOpd.DstIsPressureCorrectedBeforeItDrivesTheRing
fn double opd_ring_increment(double density_cc, double velocity_kms, double dst_nt) #

The ring current's Dst* increment over the quiet field's own, ΔDst* = Dst*(P, Dst) - Dst*(P_0, 0).

The quiet field already contains a quiet ring current (T89's C_5 term), so what the Dst driver adds is the DIFFERENCE from quiet — which is what makes the model reduce to the quiet field exactly at the reference conditions rather than double-counting a ring. The offset c cancels in the difference; only Dst and the pressure correction survive.

Parameters
density_cc

the proton density, cm^-3.

velocity_kms

the bulk speed, km/s.

dst_nt

the Dst index, nT.

Returns

Dst - 7.26 (sqrt(P) - sqrt(P_0)), nT; zero at (5, 400, 0).

Complexity

O(1).

Allocation

none.

Unit testIrbemOpd.DstIsPressureCorrectedBeforeItDrivesTheRing
fn double opd_ring_coefficient(double delta_dst_star, double a, double d) #

The amplitude of the A^(3) ring disc whose field at the Earth's centre is delta_dst_star.

The ring is the finite-moment disc potential of Tsyganenko (1989) eq. (9), with radius a and half-thickness D. On its axis at the origin its field is B_z(0) = 2 C / (a + D)^3 (set rho = z = 0 in eq. 16-17's B_z = C (2 u^2 - rho^2) / S^5 with u = a + D, S = u) — z being the DIPOLE axis, since the ring lies in the dipole equator; in GSM the central field points along (sin psi, 0, cos psi). The Dessler-Parker-Sckopke relation (Dessler & Parker, JGR 64, 2239, 1959; Sckopke, JGR 71, 3125, 1966) says the symmetric ring's field at the centre IS the pressure-corrected Dst, so the amplitude that makes B_z(0) = ΔDst* is C = ΔDst* (a + D)^3 / 2.

Parameters
delta_dst_star

the ring's Dst* increment, nT; see opd_ring_increment.

a

the disc's radial scale, R_E — T89's quiet a_RC = 8.161.

d

the disc's half-thickness, R_E — T89's quiet D_0 = 2.08.

Returns

C, in the units that make eq. (16)-(17) come out in nT with positions in R_E.

Complexity

O(1).

Allocation

none.

Unit testIrbemOpd.TheRingIsNormalisedToDstAtTheCentre
fn OpdParameters< T > opd_parameters(double density_cc, double velocity_kms, double dst_nt) #

The parameters for one driver triple, computed in double and rounded once to T.

Computing in double and rounding the RESULT is what keeps the float block the nearest float to the true parameters rather than the product of float intermediates: s is a sixth root and C a cube, and both amplify a rounding in their argument.

Template parameters
T

the scalar type; double or float.

Parameters
density_cc

the proton density, cm^-3; positive, which opd_field has already checked.

velocity_kms

the bulk speed, km/s; positive, likewise.

dst_nt

the Dst index, nT.

Returns

the block, by value.

Complexity

O(1) — one pow, two sqrt, 30 conversions.

Allocation

none; the returned object is inline storage.

Unit testIrbemOpd.ParametersRoundTripThroughFloat
fn std::array< T, 3 > opd_ring_unit(T a, T d, T sin_tilt, T cos_tilt, T x, T y, T z) #

The Dst-driven ring's field at one GSM point, per unit amplitude, in nanotesla.

A flat, symmetric A^(3) disc in the dipole equator: Tsyganenko (1989) eqs. (16)-(17) with the sheet unwarped (z_s = 0) and its thickness constant, so every dz_s/dx, dz_s/dy and dD/dx term of the published form is identically zero and what is left is q = 3 u / (xi S^5), B_x = q x z, B_y = q y z, B_z = (2 u^2 - rho^2) / S^5,

with xi = sqrt(z^2 + D^2), u = a + xi, S = sqrt(rho^2 + u^2), all in SM, and the result rotated back to GSM. Split out of opd_components so that the unit shape — the model's ∂B/∂Dst — can be tested and normalised on its own.

Template parameters
T

the scalar type.

Parameters
a

the disc's radial scale, R_E.

d

the disc's half-thickness, R_E.

sin_tilt

sin(psi).

cos_tilt

cos(psi).

x

the GSM x coordinate, R_E.

y

the GSM y coordinate, R_E.

z

the GSM z coordinate, R_E.

Returns

{B_x, B_y, B_z} in GSM per unit C; multiply by opd_ring_coefficient's C.

Complexity

O(1) — about 40 flops and three square roots.

Allocation

none.

Unit testIrbemOpd.TheRingIsNormalisedToDstAtTheCentre IrbemOpd.TheDstGradientIsTheRingShapeAtEveryPressure
fn std::array< T, 3 > opd_components(const OpdParameters< T > &p, T sin_tilt, T cos_tilt, T x, T y, T z) #

The Olson-Pfitzer dynamic external field at one GSM point, as three components in nanotesla.

The one line of the file brief: s^3 B_q(s r) + C R(r). The quiet field is T89's evaluator on the SCALED position — a point at r in a magnetosphere compressed by s sees what the quiet magnetosphere has at s r — with the s^3 that keeps the transform a similarity of the currents; the ring is opd_ring_unit at the UNSCALED position, because the Dst-driven increment is a property of the inner magnetosphere and not of the boundary. Both are solenoidal, so their sum is.

Template parameters
T

the scalar type; double for the reference lane, float to mirror the device kernel.

Parameters
p

the parameters for the epoch's drivers; see opd_parameters.

sin_tilt

sin(psi), precomputed per epoch.

cos_tilt

cos(psi); must be non-zero, which opd_field checks before it gets here.

x

the GSM x coordinate, R_E.

y

the GSM y coordinate, R_E.

z

the GSM z coordinate, R_E.

Returns

{B_x, B_y, B_z} in GSM, nanotesla.

Complexity

O(1) — T89's ~400 flops plus ~50; no loop, no branch on data.

Allocation

none.

Unit testIrbemOpd.DivergenceVanishesEverywhere IrbemOpd.ReferenceConditionsReduceToTheQuietField IrbemOpd.CompressionScalesTheCentralFieldAsTheCubeOfTheStandoff IrbemOpd.DawnDuskSymmetryHoldsAtEveryTilt
fn FieldVector< Frame::GSM > opd_field_at(Position< Frame::GSM > p, double sin_tilt, double cos_tilt, double density_cc, double velocity_kms, double dst_nt) #

The dynamic external field at one GSM point, in double — the reference lane.

Parameters
p

the position, GSM, in Earth radii.

sin_tilt

sin(psi).

cos_tilt

cos(psi); must be non-zero, which opd_field checks.

density_cc

the solar-wind proton density, cm^-3; positive.

velocity_kms

the solar-wind speed, km/s; positive.

dst_nt

the Dst index, nT.

Returns

the external field at p, GSM, in nanotesla.

Complexity

O(1); see opd_components, plus opd_parameters once.

Allocation

none.

Unit testIrbemOpd.ReferenceLaneMatchesTheComponentForm
fn opd_field · 2 overloads
Result< FieldVector< Frame::GSM > > opd_field(Position< Frame::GSM > p, double tilt_rad, double density_cc, double velocity_kms, double dst_nt)#
Result< FieldVector< Frame::GSM > > opd_field(Position< Frame::GSM > p, const FieldContext &ctx)#

The dynamic external field, with the model's own verdict on whether it should be believed here.

The value is always returned, including under Status::OutOfValidityRange — status.hpp's standing rule. The oracle, by contrast, returns baddata strictly outside the same envelope (measured: n = 50.0001, V = 500.001, Dst = 20.001 and -100.001, r = 60.05 all refuse; the bounds themselves are accepted), and a caller porting from it should expect a number with a caveat where it got a sentinel.

Refused outright, as Status::DomainError with a zero field, is arithmetic with no answer: a non-finite input; a tilt of |psi| >= pi/2, at which the quiet field's tan(psi) does not exist; a radius inside the Earth; a density or speed that is not positive, for which there is no pressure and no magnetopause to compress (s would be zero or complex); and a non-finite answer, which the quiet field's exp(x / dx) produces far enough sunward that only a unit-confusion bug gets there, and which must not be handed to a tracer as a NaN.

Parameters
p

the position, GSM, in Earth radii.

tilt_rad

the dipole tilt psi, radians.

density_cc

the solar-wind proton density, cm^-3 — IRBEM's maginput(3).

velocity_kms

the solar-wind speed, km/s — maginput(4).

dst_nt

the Dst index, nT — maginput(2).

Returns

the field and its caveat: Status::DomainError (zero field) for the refusals above; Status::OutOfValidityRange for a driver or a radius outside the documented envelope, with the field still computed; otherwise Status::Ok.

Complexity

O(1).

Allocation

none.

Unit testIrbemOpd.ValidityIsReportedFromBothSidesOfEveryBound IrbemOpd.NonFiniteInputIsADomainError IrbemOpd.AVanishingSolarWindIsADomainError IrbemOpd.RightAngleTiltIsADomainError IrbemOpd.AnOverflowingExtrapolationIsADomainErrorNotANaN
fn bool opd_field_host(std::span< const float > pos, std::span< float > out, float sin_tilt, float cos_tilt, const OpdParameters< float > &p) #

The dynamic field over a whole batch, on the CPU, in float.

The host twin of irbem_opd_f32: the same expressions, in the same order, in the same precision, from a parameter block rounded to float FIRST. What makes a host-vs-device disagreement attributable to the device.

Parameters
pos

the points, xyz-interleaved, 3N floats, GSM, in Earth radii.

out

the field, xyz-interleaved, 3N floats, nanotesla; overwritten in full.

sin_tilt

sin(psi).

cos_tilt

cos(psi); must be non-zero.

p

the parameter block, already rounded; see opd_parameters.

Returns

false when pos is not a whole number of points or out is a different length, in which case nothing is written; true otherwise.

Complexity

O(N).

Allocation

none.

Unit testIrbemOpd.HostFloatLaneTracksTheReferenceLane IrbemOpd.HostFloatLaneRejectsMismatchedSpans
fn std::array< float, opd_param_count > opd_param_block(float sin_tilt, float cos_tilt, const OpdParameters< float > &p) #

Pack the epoch's tilt and the drivers' parameters into the kernel's parameter buffer.

The first thirty floats are EXACTLY t89_param_block for the quiet bin, because the kernel hands them to the shared t89_eval untouched; the last two are the scale and the ring amplitude. Stated here and above irbem_opd_f32 in irbem.slang, and kept in step by a test that runs both lanes on the same points.

Parameters
sin_tilt

sin(psi).

cos_tilt

cos(psi).

p

the parameter block, rounded to float.

Returns

the buffer, opd_param_count floats, by value.

Complexity

O(1).

Allocation

none.

Unit testIrbemOpd.ParameterBlockIsTheQuietBlockThenTheScaleAndTheRing
fn Result< bool > opd_field_batch(std::span< const Position< Frame::GSM > > points, double tilt_rad, double density_cc, double velocity_kms, double dst_nt, std::span< FieldVector< Frame::GSM > > out) #

The dynamic field over a whole batch of GSM points, on the device when that is worth it.

The shape of t89_field_batch exactly: one status for N points, the worst of them; an out-of-validity batch computed in full and a domain-error batch zeroed in full; the returned value true exactly when the device serviced the call. The kernel is T89's plus a ring, at the same ~11 flops/byte, and runs the SAME shared t89_eval — so the device wins by the same margin and the crossover is the T89 row's (see gpu/dispatch.hpp).

Parameters
points

the positions, GSM, in Earth radii.

tilt_rad

the dipole tilt psi, radians.

density_cc

the solar-wind proton density, cm^-3.

velocity_kms

the solar-wind speed, km/s.

dst_nt

the Dst index, nT.

out

receives one field vector per input, GSM, nanotesla; same length as points.

Returns

Status::DomainError on a length mismatch, a non-finite or non-positive driver, a tilt at which the model has no value, or a point that is not finite or is inside the Earth — every output then zeroed; Status::OutOfValidityRange when a driver or any point's radius is outside the documented envelope, every point still computed; otherwise Status::Ok. The value is true exactly when the device lane serviced the call.

Complexity

O(N); on the device those N run concurrently over ceil(N/256) workgroups.

Allocation

the device lane stages positions and results into two std::vector<float> of 3N; the host lane allocates nothing.

Unit testIrbemOpd.BatchAgreesWithTheReferenceLane IrbemOpd.BatchRejectsMismatchedSpans IrbemOpd.BatchReportsTheSameEnvelopeTheScalarLaneDoes
fn OpqParameters< T > opq_parameters(double tilt_deg) #

Fold the dipole tilt into the published pairs — the report's DO 1 and DO 2 loops.

TT = {1, t, t^2, t^3} with t the tilt in DEGREES; an even term takes pair[0] + pair[1] t^2, an odd term pair[0] t + pair[1] t^3. Done in double whatever T is, then rounded: the rounding on the way in is what makes the float lane the device's twin.

Template parameters
T

the scalar type of the result; double or float.

Parameters
tilt_deg

the dipole tilt psi in degrees, positive when the north dipole leans sunward.

Returns

the folded coefficient set, by value.

Complexity

O(1) — 172 fused pairs, no transcendental.

Allocation

none; the returned object is inline storage.

Unit testIrbemOpq.FoldedCoefficientsAreEvenOrOddInTheTilt IrbemOpq.ParametersRoundTripThroughFloat
fn std::array< T, 3 > opq_components(const OpqParameters< T > &p, T x, T y, T z) #

The OP-77 external field at one SOLAR-MAGNETIC point, as three SM components in nanotesla.

This is the report's subroutine, block for block: the radial rules first (zero inside 2 R_E, a linear-in-r^2 taper to 2.5 R_E, the zero template beyond 15 R_E), then the envelope E = exp(-0.06 r^2), then the series in the report's enumeration. That enumeration is a fixed pattern that reads as three nested loops with two truncation rules — the trip count depends on nothing but the loop counters, so the work is the same at every point and a device lane does not diverge:

  • x power p = 0..4 outermost; y^2 power q = 0..2 next, cut off when p + 2q > 5;

  • z power s innermost, from 0, cut off when s > 4 OR the running index p + 2q + s passes 5 — with B_y getting one term FEWER than B_x/B_z in each block, because the overall factor of y costs it one order.

Written with the report's own bookkeeping counters (ijk, k) rather than the closed-form rules above, so that the 32 + 22 + 32 terms come out in exactly the order the tables are laid out in — the counts are asserted, not assumed.

Template parameters
T

the scalar type; double for the reference lane, float to mirror the device kernel.

Parameters
p

the folded coefficients for the epoch's tilt; see opq_parameters.

x

the SM x coordinate, R_E (sunward, in the plane of the dipole axis and the Sun line).

y

the SM y coordinate, R_E (duskward).

z

the SM z coordinate, R_E (along the north dipole axis).

Returns

{B_x, B_y, B_z} in SM, nanotesla. Zero — exactly — inside 2 R_E and beyond 15 R_E, and for any input whose r^2 is not a number, so that a NaN can never propagate out.

Complexity

O(1) — 86 fused terms, ~400 flops, one exp, no square root and no trigonometry.

Allocation

none.

Unit testIrbemOpq.MonomialEnumerationHasThePublishedCounts IrbemOpq.ZeroTiltIsMirrorSymmetricAboutTheEquator IrbemOpq.DawnDuskSymmetryHoldsAtEveryTilt IrbemOpq.TheThreeRadialRulesArePublishedOnes IrbemOpq.StencilDivergenceConvergesToTheAnalyticOne IrbemOpq.TiltIsAContinuousParameter
fn opq_field_at · 2 overloads
FieldVector< Frame::GSM > opq_field_at(Position< Frame::GSM > p, const OpqParameters< double > &par, double sin_tilt, double cos_tilt)#
FieldVector< Frame::GSM > opq_field_at(Position< Frame::GSM > p, double tilt_rad)#

The OP-77 external field at one GSM point, in double, from an already-folded parameter set.

The rotation in and out is the one every Tsyganenko-family evaluator makes: about y by the tilt, GSM to SM, because the report's series is defined in SM (its z IS the dipole axis).

Parameters
p

the position, GSM, in Earth radii.

par

the coefficients folded for this epoch's tilt — opq_parameters. Folding is 172 fused pairs, so a caller evaluating many points at one epoch does it once.

sin_tilt

sin(psi), the dipole tilt's sine; HotState::sin_tilt holds it.

cos_tilt

cos(psi).

Returns

the external field at p, GSM, nanotesla.

Complexity

O(1); see opq_components, plus two rotations.

Allocation

none.

Unit testIrbemOpq.ReferenceLaneMatchesTheComponentForm IrbemOpq.HeavyDifferentialAgreesWithTheIrbemOracle IrbemOpq.HeavyDifferentialOracleIgnoresEveryDriverRegime
fn Status opq_status(Position< Frame::GSM > p, double tilt_rad) #

The model's verdict on a point and a tilt — everything opq_field decides apart from the arithmetic, exposed so a composite field can ask without evaluating twice.

Three checks, through status.hpp so the rules live in one place: finiteness of the inputs, the tilt against |psi| <= pi/2 (the model has no tan(psi) and is total at a right angle, so the bound is the one FieldContext already guarantees and nothing stricter), and the position against the published r_GEO <= 15 R_E. There is NO driver check because the model reads no driver; check_validity is still consulted so that the envelope table, not this file, is the authority on that.

Parameters
p

the position, GSM, in Earth radii.

tilt_rad

the dipole tilt psi, radians.

Returns

Status::DomainError for a non-finite input, a radius inside the Earth or a tilt beyond a right angle; Status::OutOfValidityRange beyond 15 R_E; otherwise Status::Ok.

Complexity

O(1).

Allocation

none.

Unit testIrbemOpq.ValidityIsReportedFromBothSides IrbemOpq.NonFiniteInputIsADomainError IrbemOpq.ATiltBeyondARightAngleIsADomainError
fn opq_field · 2 overloads
Result< FieldVector< Frame::GSM > > opq_field(Position< Frame::GSM > p, double tilt_rad)#
Result< FieldVector< Frame::GSM > > opq_field(Position< Frame::GSM > p, const FieldContext &ctx)#

The OP-77 external field, with the model's own verdict on whether it should be believed here.

The value is always returned, status.hpp's standing rule — and beyond 15 R_E that value is a ZERO external field, because that is what the published model says there: the report's template "sets the field to zero" where "the power series diverges". A caller who extrapolates this model gets the published extrapolation, which is no external field at all, and is told so. Below 2 R_E the field is likewise the published zero, and the status is Status::Ok because that region is inside the envelope: the report chose zero there on purpose.

Parameters
p

the position, GSM, in Earth radii.

tilt_rad

the dipole tilt psi, radians; positive when the north dipole leans sunward.

Returns

the field and its caveat. Status::DomainError (with a zero field) for a non-finite input, a radius inside the Earth or |psi| > pi/2; Status::OutOfValidityRange beyond 15 R_E, with the published zero; otherwise Status::Ok.

Complexity

O(1).

Allocation

none.

Unit testIrbemOpq.ValidityIsReportedFromBothSides IrbemOpq.NonFiniteInputIsADomainError IrbemOpq.ATiltBeyondARightAngleIsADomainError IrbemOpq.NothingOnTheHeapInTheHotPath
fn bool opq_field_host(std::span< const float > pos, std::span< float > out, float sin_tilt, float cos_tilt, const OpqParameters< float > &par) #

The OP-77 field over a whole batch, on the CPU, in float.

The host twin of irbem_opq_f32: the same expressions, in the same order, in the same precision, from coefficients folded in double and rounded to float FIRST. That is what makes a disagreement between the two lanes attributable to the device — a contraction, a driver's exp — rather than to the arithmetic having been written differently on the two sides.

Parameters
pos

the points, xyz-interleaved, 3N floats, GSM, in Earth radii.

out

the field, xyz-interleaved, 3N floats, nanotesla, GSM; overwritten in full.

sin_tilt

sin(psi).

cos_tilt

cos(psi).

par

the coefficients folded for the epoch's tilt, in float.

Returns

false when pos is not a whole number of points or out is a different length, in which case nothing is written; true otherwise.

Complexity

O(N).

Allocation

none: the loop is over caller-provided spans and the caller's parameter set.

Unit testIrbemOpq.HostFloatLaneTracksTheReferenceLane IrbemOpq.HostFloatLaneRejectsMismatchedSpans IrbemOpq.DeviceKernelAgreesWithTheHostLane
fn std::array< float, opq_param_count > opq_param_block(float sin_tilt, float cos_tilt, double tilt_deg) #

Pack the epoch's tilt and its folded coefficients into the kernel's parameter buffer.

The layout is the kernel's ABI and is stated in exactly two places — here and the comment above irbem_opq_f32 in irbem.slang. A test evaluates both lanes on the same points, which is what actually keeps the two statements in step.

Parameters
sin_tilt

sin(psi).

cos_tilt

cos(psi).

tilt_deg

the tilt in degrees, for the fold. Carried separately from its sine and cosine because the fold is a polynomial in the ANGLE and the rotation is not.

Returns

the parameter block, opq_param_count floats, by value.

Complexity

O(1).

Allocation

none — the block is the returned object's own inline array.

Unit testIrbemOpq.ParameterBlockCarriesTheTiltThenTheCoefficients
fn Result< bool > opq_field_batch(std::span< const Position< Frame::GSM > > points, double tilt_rad, std::span< FieldVector< Frame::GSM > > out) #

The OP-77 field over a whole batch of GSM points, on the device when that is worth it.

This is the routine to call for more than a handful of points. opq_field is the reference lane: it is what the batch is verified against, and what runs when there is no device or the batch is too small to pay for one.

The arithmetic is T89's regime: 86 fused polynomial terms and one exp for 24 bytes in and 12 out, ~15 flops/byte, no data-dependent branch, and a 174-float parameter block read identically by every lane in a workgroup — see the irbem_opq_f32 row of gpu/dispatch.hpp for the measurement and the crossover derived from it.

The batch reports the same caveats the scalar entry point does, folded over the batch. If any point is beyond 15 R_E the batch says Status::OutOfValidityRange and is still computed in full (those points come back as the published zero); if any point is inside the Earth or not finite it says Status::DomainError and every output is zeroed.

Parameters
points

the positions, GSM, in Earth radii.

tilt_rad

the dipole tilt psi, radians.

out

receives one field vector per input, GSM, nanotesla; same length as points.

Returns

Status::DomainError on a length mismatch, a non-finite or right-angle-exceeding tilt, or a point that is not finite or is inside the Earth, and then every output is zeroed; Status::OutOfValidityRange when any point's radius is beyond 15 R_E, with every point still computed; otherwise Status::Ok. The value is true exactly when the device lane serviced the call — asserted by a test rather than trusted, because a silent fallback is what makes a performance claim worthless.

Complexity

O(N); on the device those N run concurrently over ceil(N/256) workgroups.

Allocation

the device lane stages positions and results into two std::vector<float> of 3N; the host lane allocates nothing.

Unit testIrbemOpq.BatchAgreesWithTheReferenceLane IrbemOpq.BatchRejectsMismatchedSpans IrbemOpq.BatchReportsTheSameEnvelopeTheScalarLaneDoes IrbemOpq.BatchFallsBackToTheHostWhenTheShaderWasNeverBuilt IrbemOpq.BatchUsesTheDeviceWhenOneIsAvailable IrbemOpq.TheDeviceLaneRefusesABadPointBeforeItDispatches
fn trace_invariant_batch · 3 overloads
Result< bool > trace_invariant_batch(const TotalFieldOpq< NMAX > &field, std::span< const Position< Frame::GEO > > starts, std::span< const double > pitch_angles_deg, std::span< FieldLine > out, std::span< Status > statuses, const TraceOptions &opt={})#
Result< bool > trace_invariant_batch(const Igrf< NMAX > &model, std::span< const Position< Frame::GEO > > starts, std::span< const double > pitch_angles_deg, std::span< FieldLine > out, std::span< Status > statuses, const TraceOptions &opt={})#
Result< bool > trace_invariant_batch(const TotalFieldT89< NMAX > &field, std::span< const Position< Frame::GEO > > starts, std::span< const double > pitch_angles_deg, std::span< FieldLine > out, std::span< Status > statuses, const TraceOptions &opt={})#

Trace a batch of field lines through IGRF plus OP-77 — the entry point make_lstar reaches.

The shape mirrors the internal field's trace_invariant_batch, and the return value says which lane served the call for the same reason: a silent fallback is what makes a performance claim worthless. This overload is the host lane only. The device tracer that composes an external model (irbem_trace_total_f32) is written for T89's parameter block; composing OP-77 on the device would need its own tracer kernel, and until one exists the honest answer is false here every time, never a quiet substitution of the internal field. Single traces and drift shells still run — in fp64, on the host, at the reference lane's cost.

Template parameters
NMAX

the internal field's truncation degree.

Parameters
field

the superposed model; carries the epoch's rotations and folded coefficients.

starts

the starting positions, GEO, Earth radii.

pitch_angles_deg

the local pitch angle at each start; same length as starts.

out

one FieldLine per input.

statuses

one Status per input.

opt

the tracing options.

Returns

Status::Ok when every line closed, Status::OpenFieldLine when any did not, Status::DomainError on a length mismatch; the value is always false (host lane).

Complexity

O(lines x steps) total-field evaluations, ~900 flops each.

Allocation

none.

Unit testIrbemOpq.LstarRunsThroughTheTotalField IrbemOpq.TotalFieldBatchTraceIsTheHostLaneAndSaysSo IrbemOpq.HeavyDifferentialLstarMatchesTheOracleThroughTheTotalField
fn std::array< T, om97_harmonic_count > om97_amplitudes(const Om97Drivers &d, const Om97Normalization &norm=om97_normalization_published) #

The 17 harmonic amplitudes for one driver set: A_i = sum_k a_ik A~_k, eq.

(2)'s inner sum.

Done once per epoch rather than per point, because the drivers are a property of the epoch — the same economy HotState makes with the tilt's trigonometry. The result is what the evaluator, the host batch lane and the device kernel all consume, so the three lanes cannot disagree about what the drivers meant.

Computed in double and rounded to T at the end — the fp32 lane wants the amplitudes the kernel receives, and the kernel receives them as floats made from the best available doubles.

Template parameters
T

the scalar type of the result; double for the reference lane, float for the device-mirroring one.

Parameters
d

the drivers, in IRBEM's units; Kp is divided by ten here.

norm

the normalization; the published Table 2 by default.

Returns

the amplitudes, nanotesla, element i - 1 for harmonic i.

Complexity

O(1) — 85 multiply-adds.

Allocation

none; the returned object is inline storage.

Unit testIrbemOm97.AmplitudesAtTheMeansAreTheConstantColumn IrbemOm97.AmplitudesAreLinearInEveryDriver
fn Om97Basis< T > om97_basis(T sin_tilt, T x, T y, T z) #

Table 1 at one point — the 17 harmonics, each as its three SM Cartesian components.

Every row is the printed cylindrical entry with cos phi = x / rho, sin phi = y / rho substituted and rho cancelled, which turns each into a polynomial: B_x = b_rho cos phi - b_phi sin phi, B_y = b_rho sin phi + b_phi cos phi. For a row of the form (f cos phi, g sin phi, h cos phi) that is B_x = (f x^2 - g y^2) / rho^2, B_y = (f + g) x y / rho^2, B_z = h x / rho, and in every row the numerators carry the rho^2 or rho needed to cancel. The tilt rows carry their own sin psi, as printed.

The four irrational leading factors are the Schmidt-normalized Legendre coefficients: sqrt(3) (row 7, n = 2), sqrt(10) (row 8, n = 4), sqrt(3/2) (row 16, n = 3) and sqrt(15) / 8 (row 17, n = 5 — see the file brief for why the printed 1 / (8 sqrt(15)) is a misprint of that).

Template parameters
T

the scalar type.

Parameters
sin_tilt

sin(psi); multiplies rows 13-17.

x

SM x, in units of 10 R_E.

y

SM y, in units of 10 R_E.

z

SM z, in units of 10 R_E.

Returns

the 17 harmonics at the point, SM Cartesian, dimensionless (they multiply amplitudes in nT).

Complexity

O(1) — about 150 flops, no branch, no transcendental.

Allocation

none; the returned object is inline storage.

Unit testIrbemOm97.CartesianFormsMatchThePrintedCylindricalTable IrbemOm97.CurlFreeHarmonicsAreSchmidtNormalizedSolidHarmonics IrbemOm97.EveryHarmonicIsDivergenceFree
fn std::array< T, 3 > om97_components(const std::array< T, om97_harmonic_count > &amp, T sin_tilt, T cos_tilt, T x, T y, T z) #

The OM97 external field at one GSM point, as three components in nanotesla.

The whole model in one straight line: rotate GSM to SM about y by the tilt (SM's z IS the dipole axis, which is the frame the harmonics are defined in), divide by 10 R_E, evaluate the 17 harmonics, weight them by the amplitudes, rotate back. No loop with a data-dependent trip count, no branch, no transcendental: the tilt arrives as its sine and cosine.

Template parameters
T

the scalar type; double for the reference lane, float to mirror the device kernel.

Parameters
amp

the 17 amplitudes for the epoch's drivers; see om97_amplitudes.

sin_tilt

sin(psi), the dipole tilt's sine; positive when the north dipole leans sunward.

cos_tilt

cos(psi).

x

the GSM x coordinate, R_E.

y

the GSM y coordinate, R_E.

z

the GSM z coordinate, R_E.

Returns

{B_x, B_y, B_z} in GSM, nanotesla.

Complexity

O(1) — about 250 flops, no branch, no transcendental.

Allocation

none.

Unit testIrbemOm97.DivergenceVanishesEverywhere IrbemOm97.ZeroTiltIsMirrorSymmetricAboutTheEquator IrbemOm97.DawnDuskSymmetryHoldsAtEveryTilt
fn FieldVector< Frame::GSM > om97_field_at(Position< Frame::GSM > p, double sin_tilt, double cos_tilt, const std::array< double, om97_harmonic_count > &amp) #

The OM97 external field at one GSM point, in double — the reference lane.

Parameters
p

the position, GSM, in Earth radii.

sin_tilt

sin(psi); HotState::sin_tilt holds it, precomputed per epoch.

cos_tilt

cos(psi).

amp

the epoch's amplitudes, from om97_amplitudes.

Returns

the external field at p, GSM, in nanotesla.

Complexity

O(1); see om97_components.

Allocation

none.

Unit testIrbemOm97.ReferenceLaneMatchesTheComponentForm
fn Status om97_check_fitted_region(double r, double rho_sm, double abs_z_sm) #

Whether a point is inside the region the paper fitted, from its geocentric radius and its SM cylindrical coordinates.

Separate from status.hpp's check_position because the box is stated in SM, which that function does not know about, and because IRBEM's table — which that function reads — publishes no limit for this model at all. The bounds are closed: a point exactly on the boundary is inside.

Parameters
r

the geocentric radius, R_E.

rho_sm

the cylindrical radius in SM, R_E.

abs_z_sm

|z_SM|, R_E.

Returns

Status::OutOfValidityRange outside the paper's box, otherwise Status::Ok.

Complexity

O(1).

Allocation

none.

Unit testIrbemOm97.PositionValidityIsCheckedFromBothSides
fn Status om97_check_drivers(const Om97Drivers &d) #

Whether the drivers are ones the paper says the model can be believed at.

Two layers, in status.hpp's order. First check_validity for this model's row, which catches a non-finite driver among the four this model reads (and, the IRBEM table being unbounded for kext = 8, nothing else). Then the paper's own caveat, which the table does not carry: Dst < -200 nT is declared invalid. The interval is closed, so -200 itself is inside.

Parameters
d

the drivers.

Returns

Status::DomainError for a non-finite driver, Status::OutOfValidityRange below the paper's Dst floor, otherwise Status::Ok.

Complexity

O(1).

Allocation

none.

Unit testIrbemOm97.DriverValidityIsCheckedFromBothSides
fn om97_field · 2 overloads
Result< FieldVector< Frame::GSM > > om97_field(Position< Frame::GSM > p, double tilt_rad, const Om97Drivers &d, const Om97Normalization &norm=om97_normalization_published)#
Result< FieldVector< Frame::GSM > > om97_field(Position< Frame::GSM > p, const FieldContext &ctx, const Om97Normalization &norm=om97_normalization_published)#

The OM97 external field, with the model's own verdict on whether it should be believed here.

The value is always returned, including when the status is Status::OutOfValidityRange — status.hpp's standing rule. What is refused outright is arithmetic that has no answer: a non-finite input, a point inside the Earth, a tilt that is not an angle to an axis, or an extrapolation so far that the fourth-order polynomial overflows (a unit-confusion bug's signature — kilometres where Earth radii were meant — and the one way this evaluator can produce a non-finite number from finite input). Unlike t89_field there is no refusal at |psi| = pi/2: the model carries sin psi and the rotation, never tan psi, so a right-angle tilt is merely unphysical, not undefined.

What is checked against the paper: the position against the fitted box (om97_check_fitted_region) and the drivers against its Dst floor (om97_check_drivers). Both report, neither suppresses.

Parameters
p

the position, GSM, in Earth radii.

tilt_rad

the dipole tilt psi, radians; positive when the north dipole leans sunward.

d

the drivers, IRBEM units (Kp x 10).

norm

the normalization; the published Table 2 by default.

Returns

the field and its caveat. Status::DomainError (with a zero field) for a non-finite input, a radius inside the Earth, |psi| > pi/2, or a non-finite result; Status::OutOfValidityRange for a position outside the fitted box or a Dst below -200 nT, with the field still computed; otherwise Status::Ok.

Complexity

O(1).

Allocation

none.

Unit testIrbemOm97.PositionValidityIsCheckedFromBothSides IrbemOm97.DriverValidityIsCheckedFromBothSides IrbemOm97.NonFiniteInputIsADomainError IrbemOm97.RightAngleTiltIsNotRefusedButBeyondItIs IrbemOm97.AnOverflowingExtrapolationIsADomainErrorNotANaN
fn bool om97_field_host(std::span< const float > pos, std::span< float > out, float sin_tilt, float cos_tilt, const std::array< float, om97_harmonic_count > &amp) #

The OM97 field over a whole batch, on the CPU, in float.

The host twin of irbem_om97_f32: the same expressions, in the same order, in the same precision, from the same float amplitudes. That is what makes a disagreement between the two lanes attributable to the device rather than to the arithmetic having been written differently on the two sides. It is also the lane a machine with no GPU actually runs for float batches.

Parameters
pos

the points, xyz-interleaved, 3N floats, GSM, in Earth radii.

out

the field, xyz-interleaved, 3N floats, nanotesla; overwritten in full.

sin_tilt

sin(psi).

cos_tilt

cos(psi).

amp

the epoch's amplitudes, already rounded to float.

Returns

false when pos is not a whole number of points or out is a different length, in which case nothing is written; true otherwise.

Complexity

O(N).

Allocation

none.

Unit testIrbemOm97.HostFloatLaneTracksTheReferenceLane IrbemOm97.HostFloatLaneRejectsMismatchedSpans
fn std::array< float, om97_param_count > om97_param_block(float sin_tilt, float cos_tilt, const std::array< float, om97_harmonic_count > &amp) #

Pack the epoch's tilt and amplitudes into the kernel's parameter buffer.

The layout is the kernel's ABI and is stated in exactly two places — here and the comment above irbem_om97_f32 in irbem.slang. A test evaluates both lanes on the same points, which is what actually keeps the two statements in step.

Parameters
sin_tilt

sin(psi).

cos_tilt

cos(psi).

amp

the epoch's amplitudes, in float.

Returns

the parameter block, om97_param_count floats, by value.

Complexity

O(1).

Allocation

none — the block is the returned object's own inline array.

Unit testIrbemOm97.ParameterBlockCarriesTheTiltThenTheAmplitudes
fn Result< bool > om97_field_batch(std::span< const Position< Frame::GSM > > points, double tilt_rad, const Om97Drivers &d, std::span< FieldVector< Frame::GSM > > out, const Om97Normalization &norm=om97_normalization_published) #

The OM97 field over a whole batch of GSM points, on the device when that is worth it.

This is the routine to call for more than a handful of points. om97_field is the reference lane: it is what the batch is verified against, and what runs when there is no device or the batch is too small to pay for one.

The amplitudes are computed ONCE for the batch, which is the whole economy of the epoch model: per point the kernel does ~250 flops of polynomial over 24 bytes in and 12 out, a little under T89's ~11 flops/byte and well above the streaming dipole's 0.5. Its measured crossover is in the irbem_om97_f32 row of gpu/dispatch.hpp.

The batch reports the same caveats the scalar entry point does, folded over the whole batch. A DOMAIN-ERROR batch is zeroed in full and never reaches the device; an out-of-validity batch is computed in full.

Parameters
points

the positions, GSM, in Earth radii.

tilt_rad

the dipole tilt psi, radians.

d

the drivers, IRBEM units (Kp x 10).

out

receives one field vector per input, GSM, nanotesla; same length as points.

norm

the normalization; the published Table 2 by default.

Returns

Status::DomainError on a length mismatch, a non-finite tilt or driver, |psi| > pi/2, or a point that is not finite or is inside the Earth, and then every output is zeroed; Status::OutOfValidityRange when Dst is below the paper's floor or any point is outside the fitted box, with every point still computed; otherwise Status::Ok. The value is true exactly when the device lane serviced the call.

Complexity

O(N); on the device those N run concurrently over ceil(N/256) workgroups.

Allocation

the device lane stages positions and results into two std::vector<float> of 3N; the host lane allocates nothing.

Unit testIrbemOm97.BatchAgreesWithTheReferenceLane IrbemOm97.BatchRejectsMismatchedSpans IrbemOm97.BatchReportsTheSameEnvelopeTheScalarLaneDoes
fn bool t89_bin_is_published(int bin) #

Whether bin has a coefficient set of its own in the published table.

False only for bin 7, which repeats bin 6's published Kp >= 5- column. Exposed rather than left to a comment because a caller running a storm-time study deserves to know that the two highest bins are not distinguished by this implementation.

Parameters
bin

the Kp bin, 1..7 as t89_kp_bin returns.

Returns

true when the paper publishes a column for that bin; false for bin 7 and for any out-of-range value, which has no published set either.

Complexity

O(1).

Allocation

none.

Unit testIrbemT89.BinSevenRepeatsTheMostDisturbedPublishedSet
fn T89Parameters< T > t89_parameters(int bin) #

The parameters for bin, in the scalar type the caller's lane evaluates in.

The float instantiation is not a convenience: it is how the host lane is made to run the same arithmetic as the device kernel, which receives these as float. Rounding the coefficients on the way in — rather than evaluating in double and rounding the answer — is what makes a host-vs-device disagreement attributable to the DEVICE.

Template parameters
T

the scalar type; double or float.

Parameters
bin

the Kp bin, 1..7. Values outside that range are clamped, because this function is called after t89_field has already decided what status the answer carries and must not be able to index out of the table.

Returns

the parameter set, by value, converted to T.

Complexity

O(1) — 28 conversions, all of which the compiler folds for a compile-time bin.

Allocation

none; the returned object is inline storage.

Unit testIrbemT89.ParametersRoundTripThroughFloat IrbemT89.ParametersClampAnOutOfRangeBin
fn std::array< T, 3 > t89_components(const T89Parameters< T > &p, T sin_tilt, T cos_tilt, T x, T y, T z) #

The T89 external field at one GSM point, as three components in nanotesla.

This is the whole model, and it is written so that each block is one equation of the paper. The order is the paper's order, and the two coordinate systems are kept apart deliberately (see the file brief): the tail and ring current are evaluated in SM and rotated to GSM; the closure and Chapman-Ferraro terms are evaluated in GSM directly.

On the derivatives. Every d/dx and d/dy below is the analytic derivative of the expression above it, and none is optional: the field is the curl of a vector potential whose x and y dependence runs through W, through the sheet surface z_s and through the thickness D, so dropping any of them does not merely approximate the answer, it breaks div B = 0 — which is why a finite-difference divergence test catches exactly this class of mistake and is the main test this file carries.

With P = A_phi / rho the potential-over-radius, the curl of A = P * (-y, x, 0) is B_x = -x dP/dz, B_y = -y dP/dz, B_z = 2P + x dP/dx + y dP/dy — which is where the paper's eqs. (14)-(17) and (19) come from, and the form in which they are evaluated here.

Template parameters
T

the scalar type; double for the reference lane, float to mirror the device kernel.

Parameters
p

the fitted parameters for the Kp bin; see t89_parameters.

sin_tilt

sin(psi), the dipole tilt's sine. Taken precomputed because it is a property of the epoch, not of the point: FieldContext pays for it once per timestamp.

cos_tilt

cos(psi). Must be non-zero — eq. (11) carries tan(psi) — which t89_field checks before it gets here.

x

the GSM x coordinate, R_E.

y

the GSM y coordinate, R_E.

z

the GSM z coordinate, R_E.

Returns

{B_x, B_y, B_z} in GSM, nanotesla.

Complexity

O(1) — about 400 flops, thirteen square roots and one exp. No loop, no branch on data, and no trigonometry: the tilt arrives already resolved into its sine and cosine.

Allocation

none.

Unit testIrbemT89.DivergenceVanishesEverywhere IrbemT89.ZeroTiltIsMirrorSymmetricAboutTheEquator IrbemT89.DawnDuskSymmetryHoldsAtEveryTilt
fn FieldVector< Frame::GSM > t89_field_at(Position< Frame::GSM > p, double sin_tilt, double cos_tilt, int bin) #

The T89 external field at one GSM point, in double — the reference lane.

Parameters
p

the position, GSM, in Earth radii.

sin_tilt

sin(psi); HotState::sin_tilt holds it, precomputed per epoch.

cos_tilt

cos(psi); must be non-zero, which t89_field checks.

bin

the Kp bin, 1..7; out-of-range values are clamped by t89_parameters.

Returns

the external field at p, GSM, in nanotesla.

Complexity

O(1); see t89_components.

Allocation

none.

Unit testIrbemT89.ReferenceLaneMatchesTheComponentForm
fn t89_field · 2 overloads
Result< FieldVector< Frame::GSM > > t89_field(Position< Frame::GSM > p, double tilt_rad, double kp_times_ten)#
Result< FieldVector< Frame::GSM > > t89_field(Position< Frame::GSM > p, const FieldContext &ctx)#

The T89 external field, with the model's own verdict on whether it should be believed here.

The value is always returned, including when the status is Status::OutOfValidityRange — that is status.hpp's standing rule and the whole reason a Result exists: extrapolating an empirical fit is a decision only the caller can make. What is refused outright is arithmetic that has no answer: a non-finite input, or a tilt of exactly ±90 degrees, at which eq. (11)'s tan(psi) does not exist.

Two things are checked against the published envelope, both through status.hpp so the rules live in one place: Kp against 0 <= Kp <= 9 (in IRBEM's Kp x 10 scaling), and the position against T89's r_GEO <= 70 R_E. The Kp check happens before the binning, so a caller who passes Kp = 12 gets the most disturbed published set AND is told the model was never fitted there.

There is one more refusal and it is about the OUTPUT, not the input. Eq. (20) carries exp(x / dx) with dx ~ 20 R_E, so a caller who extrapolates far enough sunward — past about 1e4 R_E, which is nothing a trace can reach but is exactly what a unit-confusion bug produces when kilometres arrive where Earth radii were meant — overflows it. The field is then infinite, and inf * 0 in the B_y assembly makes a NaN out of a component that is EXACTLY zero everywhere the model means anything (IrbemT89.OnTheNoonMidnightMeridianTheFieldStaysInThatPlane). A NaN that escapes here surfaces a hundred RK4 steps later with nothing left pointing at its cause, so a non-finite answer is refused the same way a non-finite input is.

Parameters
p

the position, GSM, in Earth radii.

tilt_rad

the dipole tilt psi, radians; positive when the north dipole leans sunward.

kp_times_ten

Kp in IRBEM's maginput slot-1 scaling, i.e. Kp x 10, nominally 0..90.

Returns

the field and its caveat. Status::DomainError (with a zero field) for a non-finite input, a radius inside the Earth, |psi| >= pi/2, or a position so far extrapolated that eq. (20)'s exponential overflows; Status::OutOfValidityRange for a Kp or a radius outside the published envelope, with the field still computed; otherwise Status::Ok.

Complexity

O(1).

Allocation

none.

Unit testIrbemT89.OutOfRangeKpIsReportedButStillEvaluated IrbemT89.NonFiniteInputIsADomainError IrbemT89.RightAngleTiltIsADomainError IrbemT89.AnOverflowingExtrapolationIsADomainErrorNotANaN
fn bool t89_field_host(std::span< const float > pos, std::span< float > out, float sin_tilt, float cos_tilt, int bin) #

The T89 field over a whole batch, on the CPU, in float.

The host twin of irbem_t89_f32: the same expressions, in the same order, in the same precision, evaluated from coefficients rounded to float FIRST. That is what makes a disagreement between the two lanes attributable to the device — a contraction, a driver's exp — rather than to the arithmetic having been written differently on the two sides. It is also the lane a machine with no GPU actually runs.

Parameters
pos

the points, xyz-interleaved, 3N floats, GSM, in Earth radii.

out

the field, xyz-interleaved, 3N floats, nanotesla; overwritten in full.

sin_tilt

sin(psi).

cos_tilt

cos(psi); must be non-zero.

bin

the Kp bin, 1..7.

Returns

false when pos is not a whole number of points or out is a different length, in which case nothing is written; true otherwise. A bool rather than a throw because this is the fallback lane of a batch entry point that already reports through Result.

Complexity

O(N).

Allocation

none. Not one byte: the loop is over caller-provided spans and one stack parameter set.

Unit testIrbemT89.HostFloatLaneTracksTheReferenceLane IrbemT89.HostFloatLaneRejectsMismatchedSpans
fn std::array< float, t89_param_count > t89_param_block(float sin_tilt, float cos_tilt, int bin) #

Pack the epoch's tilt and a Kp bin's parameters into the kernel's parameter buffer.

The layout is the kernel's ABI and is stated in exactly two places — here and the comment above irbem_t89_f32 in irbem.slang. A test evaluates both lanes on the same points, which is what actually keeps the two statements in step.

Parameters
sin_tilt

sin(psi).

cos_tilt

cos(psi).

bin

the Kp bin, 1..7.

Returns

the parameter block, t89_param_count floats, by value.

Complexity

O(1).

Allocation

none — the block is the returned object's own inline array.

Unit testIrbemT89.ParameterBlockCarriesTheTiltThenTheCoefficients
fn Result< bool > t89_field_batch(std::span< const Position< Frame::GSM > > points, double tilt_rad, double kp_times_ten, std::span< FieldVector< Frame::GSM > > out) #

The T89 field over a whole batch of GSM points, on the device when that is worth it.

This is the routine to call for more than a handful of points. t89_field is the reference lane: it is what the batch is verified against, and what runs when there is no device or the batch is too small to pay for one.

T89 sits in the regime where the device wins. It is ~400 flops for 24 bytes in and 12 out — ~11 flops/byte, an order of magnitude above the streaming dipole kernel that LOSES 0.69x on this same seam and within a factor of two of IGRF, which wins 8.96x. Nothing is stored per point beyond the answer, there is no branch on data, and the 30-float parameter block is read identically by every lane in a workgroup.

The batch reports the same caveats the scalar entry point does, folded over the whole batch. One status for N points can only be the worst of them, so that is what it is: if any point is beyond T89's published r_GEO <= 70 R_E, the batch says Status::OutOfValidityRange, and if any point is inside the Earth or not finite it says Status::DomainError. An out-of-validity batch is still computed in full — status.hpp's standing rule — and a DOMAIN-ERROR batch is zeroed in full, which is exactly what t89_field does with its one point. Checking positions costs the batch two comparisons per point and two envelope lookups per CALL: the radii are folded as squares, so there is no per-point sqrt and the device lane's measured throughput is unchanged.

Parameters
points

the positions, GSM, in Earth radii.

tilt_rad

the dipole tilt psi, radians.

kp_times_ten

Kp in IRBEM's slot-1 scaling; binned by t89_kp_bin.

out

receives one field vector per input, GSM, nanotesla; same length as points.

Returns

Status::DomainError on a length mismatch, a tilt at which the model has no value, or a point that is not finite or is inside the Earth, and then every output is zeroed; Status::OutOfValidityRange when Kp or any point's radius is outside the published range, with every point still computed; otherwise Status::Ok. The value is true exactly when the device lane serviced the call — a test asserts this rather than trusting that a GPU was used, because a silent fallback is what makes a performance claim worthless.

Complexity

O(N); on the device those N run concurrently over ceil(N/256) workgroups.

Allocation

the device lane stages positions and results into two std::vector<float> of 3N; the host lane allocates nothing.

Unit testIrbemT89.BatchAgreesWithTheReferenceLane IrbemT89.BatchRejectsMismatchedSpans IrbemT89.BatchReportsTheSameEnvelopeTheScalarLaneDoes
fn double auto_step(double radius_re, DifferenceLane lane) #

The finite-difference step for a point at radius radius_re, in Earth radii.

Proportional to the radius on purpose: truncation error scales as h/r and cancellation as r/h, so their crossing point moves with r and a single absolute step is optimal at exactly one altitude. IRBEM takes an absolute dX for the whole batch, which is why this is a default rather than the only option — bderivs and bderivs_batch accept an explicit step, and a differential comparison must use one so both implementations are differenced identically.

Parameters
radius_re

the geocentric distance of the point, Earth radii.

lane

which precision the differenced field values will be in.

Returns

the step in Earth radii; floored at the ratio itself, so a point at or inside r = 1 still gets a positive step rather than a zero one that would divide by zero.

Complexity

O(1).

Allocation

none.

Unit testIrbemField.AutoStepTracksTheRadiusAndTheLane
fn Result< BDerivatives > bderivs(const M &model, const Position< Frame::GEO > &p, double step_re=0.0) #

The field and its first derivatives at one point, by forward differences — the fp64 reference lane.

Four field evaluations: the base point, then x + h·ê_x, x + h·ê_y, x + h·ê_z. Forward, not central, because that is what the reference does — established black-box, and exactly (the measured relative difference is 0.0, not "small"). A central difference would cost seven evaluations and buy two orders of magnitude; it is offered by nobody here because agreeing with IRBEM is the contract, and the accuracy that matters is quantified in the file brief instead of silently improved.

The gradient of the magnitude and the Jacobian of the vector are differenced from the SAME four evaluations. That is not merely an economy: it is what makes grad_b_mag and diff_b mutually consistent to the same order, which is what grad_curv_curl's grad_par and curvature silently assume when they compute the same parallel derivative two different ways.

Template parameters
M

the field model type.

Parameters
model

the field model.

p

the point, GEO, Earth radii.

step_re

the difference step dX in Earth radii; 0.0 (the default) selects auto_step for DifferenceLane::Fp64Host. Pass an explicit step to compare against another implementation at matched resolution.

Returns

the derivatives, or Status::DomainError for a non-finite point, a point at the origin, or a non-finite step. The value is zero-filled in the failure case rather than left indeterminate.

Complexity

Four field evaluations — ~7 600 flops at IGRF degree 13 — plus ~40 for the differencing.

Allocation

none.

Unit testIrbemField.BderivsMatchesTheAnalyticDipoleJacobian IrbemField.BderivsIsAForwardDifferenceNotACentralOne IrbemField.BderivsRefusesInputsItCannotAnswer
fn Result< GradCurvCurl > grad_curv_curl(const BDerivatives &d) #

The guiding-centre geometry at one point, from its field derivatives — pure algebra.

No field model, no evaluations, ~50 flops. This is the routine the file brief measures at 19 ns/point on the host and rules off the device permanently.

The one subtlety is curvature. The identity (B̂·∇)B̂ = [ − (·B̂)B̂] with  = (B̂·∇)B/|B| holds because B̂·B̂ = 1 forces the derivative of to be perpendicular to it. Writing it that way — projecting out Â's own parallel part rather than subtracting grad_par·B̂/|B| — is what the reference does, and the difference is not cosmetic: the two agree only when grad_b_mag and diff_b are mutually consistent, which finite differences make them only to first order. The form used here needs grad_b_mag not at all, so curvature and r_curv are exactly perpendicular to by construction rather than approximately.

Parameters
d

the field and its derivatives at the point, as bderivs produces them.

Returns

the geometry, or Status::DomainError when |B| is zero or non-finite — the point where does not exist and every output below is meaningless rather than merely large.

Complexity

O(1) — ~50 flops, one square root for |curvature|, no transcendentals.

Allocation

none.

Unit testIrbemField.GradCurvCurlReproducesTheOracleAlgebra IrbemField.CurvatureIsPerpendicularToTheField IrbemField.DivergenceAndCurlAreTheDifferencingResidual IrbemField.GradCurvCurlHandlesTheDegenerateCases
fn Result< Hemisphere > hemisphere(const M &model, const Position< Frame::GEO > &p, double step_re=0.0) #

Which magnetic hemisphere p lies in.

The criterion is the sign of d|B|/ds along +B̂: the field falls to a minimum at the magnetic equator and rises toward both feet, and B points from the southern foot over the equator to the northern one, so a rising field in the direction B points means the equator is behind you and you are north of it. That is one signed scalar and it is exactly GradCurvCurl::grad_par, which is why this routine and the derivative routines are in the same file.

Computed with a central difference along rather than the forward differences of bderivs — three evaluations instead of four, and symmetric, which matters here specifically: near the equator the true derivative passes through zero, and a one-sided step can overshoot the minimum and report the wrong side of a boundary the answer is a discrete function of. A derivative that is merely inaccurate is fine; a sign that is wrong is a different hemisphere.

Template parameters
M

the field model type.

Parameters
model

the field model.

p

the point, GEO, Earth radii.

step_re

the step along in Earth radii; 0.0 selects auto_step at DifferenceLane::Fp32Device — the device ratio, deliberately, on the host lane too. Unlike bderivs this is not accuracy-critical: only the sign of the difference survives, so the generous step costs nothing, and using one ratio on both lanes is what makes hemisphere_batch's host and device answers comparable point for point instead of differing by four orders of magnitude in resolution.

Returns

the hemisphere, with Status::DomainError for a non-finite or origin point and Hemisphere::Invalid where the field vanishes or the derivative is exactly zero.

Complexity

Three field evaluations.

Allocation

none.

Unit testIrbemField.HemisphereAgreesWithTheOracleGoldens IrbemField.HemisphereFlipsAcrossTheDipoleEquator IrbemField.HemisphereRefusesInputsItCannotAnswer
fn Result< bool > field_batch(const Igrf< NMAX > &model, std::span< const Position< Frame::GEO > > points, std::span< FieldVector< Frame::GEO > > b, std::span< double > b_mag) #

The field at every point of a batch — IRBEM's GET_FIELD_MULTI.

The reference is a bare loop over points, and it is the most trivially parallel routine in the whole library: one point in, one vector out, no state carried between iterations. It is also the least arithmetically intense thing here worth offloading — ~1 900 flops for 24 bytes in and 24 out, about 20 flops/byte — which is exactly why the crossover is consulted rather than assumed. Measured on an RTX 3070 Ti against this file's own fp64 host lane, -O3 -march=native -ffp-contract=off, best of five, transfers included:

points

128

256

512

1024

2048

2¹⁴

2²⁰

speedup

0.33×

0.62×

1.22×

2.39×

4.56×

21.8×

17.4×

— 14.2 ns/point on the device at 2¹⁴ against the host's 309. Below field_batch_crossover the ~115 µs dispatch floor alone exceeds the whole computation and the host wins; a per-point loop calling Igrf::evaluate can never reach the device at all, which is the reason this entry point takes the whole batch. The curve turns over slightly at 2²⁰ because the routine is staging-bound by then, not kernel-bound: the same dispatch measured in isolation costs 5.9 ns/point at 2²¹, so the remaining ~12 ns is this file converting fp64 positions down to fp32 and fp32 fields back up, which is inherent to the typed API and not to the device.

The device lane returns fp32. Measured maximum relative deviation against the fp64 host lane: 7.3 × 10⁻⁷ over 2 000 points and 1.1 × 10⁻⁶ over 2²⁰ — so at the larger sample it just exceeds the 1 × 10⁻⁶ Bgeo budget of docs/ERROR_BUDGET.md §4. That is stated rather than rounded away: it is the cost of summing 105 harmonic terms in fp32, it grows like the tail of a distribution as the sample grows, and the fix if a caller needs the budget honoured at every point is the host lane, not a different kernel. Nothing here accumulates across points — one point, one thread, no reduction — so the budget's reduction concern still bites only the integrals in lstar.hpp.

Template parameters
NMAX

the IGRF truncation degree. Degree 10 is IRBEM's internal truncation and the one a differential comparison must use; degree 13 is IGRF-14 as IAGA published it.

Parameters
model

the internal field model, already built for the epoch.

points

the points, GEO, Earth radii.

b

receives one field vector per point, GEO, nT; same length as points.

b_mag

receives |B| per point, nT; same length as points. Computed from the returned vector, so the two are consistent to the last bit on both lanes.

Returns

Status::DomainError on a length mismatch, Status::Ok otherwise. The value is true when the device serviced the call — asserted by a test rather than assumed, because a silent fallback to the host is what makes a speed claim worthless.

Note

The host lane is igrf_batch_host — the SIMD strip evaluator of batch_soa.hpp, eight points per strip with the point index as the vector lane. It is bit-identical per point to the scalar Igrf::evaluate loop it replaced (memcmp-asserted) and measured 3.6× faster at 2¹⁶ points (100 vs 362 ns/point on a pinned P-core; BM_cpu_igrf_batch_soa in bench/irbem_bench.cpp). The device figures in the table above were measured against the earlier scalar host loop and are left as measured; the SIMD lane moves the true device break-even upward by about that factor, and field_batch_crossover has NOT been re-tuned here — that needs the device side re-measured, not inferred.

Complexity

O(N·NMAX²); on the device those run concurrently.

Allocation

none on the host lane. The device lane stages 3N floats in and 3N out.

Unit testIrbemField.FieldBatchAgreesWithTheReferenceLane IrbemField.FieldBatchMatchesTheOracleGoldens IrbemField.FieldBatchUsesTheDeviceWhenOneIsAvailable IrbemField.BatchRoutinesRefuseMismatchedSpans IrbemBatchSimd.FieldBatchHostLaneIsBitIdentical
fn Result< bool > bderivs_batch(const Igrf< NMAX > &model, std::span< const Position< Frame::GEO > > points, std::span< BDerivatives > out, double step_re=0.0) #

The field and its first derivatives at every point of a batch — IRBEM's GET_BDERIVS.

One dispatch, not four. Each point needs four field evaluations — the base and three one-sided neighbours — and the obvious implementation issues four batched dispatches of N points each. That pays the ~115 µs dispatch floor four times for work that has no dependency between the four groups whatsoever. Building the 4N points up front and dispatching once pays it once, and at the same time quadruples the occupancy of the launch — which is what pulls the crossover down to bderivs_batch_crossover, a quarter of field_batch_crossover. Measured: 0.64× at 64 points, 1.27× at 128, 2.49× at 256, 8.44× at 1 024, 26.1× at 2¹⁴ (49.9 ns/point against the host's 1.30 µs). Four dispatches instead of one would have moved that crossover to 512 points and left everything below it on the host.

The layout is [all N base points][all N +x][all N +y][all N +z] rather than four consecutive points per input point. Same dispatch either way, but this way each of the four groups is a contiguous run whose lanes read neighbouring coefficients in the same order, and the host-side differencing walks four unit-stride streams instead of one stride-4 one.

On the device the differenced values are fp32, and the file brief's tables are the consequence: the achievable accuracy is ~1.5 × 10⁻³ relative against ~1 × 10⁻⁷ on the host, and the step that achieves it is eight thousand times larger. That four-order gap is why step_re defaults to the lane's step rather than to a constant, and why a caller comparing the two lanes must pass an explicit step to both — at a matched dX = 10⁻² they agree to three digits, and at a matched dX = 10⁻⁵ the device is wrong by 25%. It is a real limitation, quantified here rather than hidden behind an average.

One detail that is worth more than it looks: the differencing divides by the step the device actually took, recovered from the fp32 position it was handed, not by the step that was intended. x + h is not representable in fp32, and at h = 4 × 10⁻⁴·r the rounding is a relative error on h of order ε₃₂·r/h ≈ 1.5 × 10⁻⁴ — comparable to everything else in this routine's budget, and free to remove.

Template parameters
NMAX

the IGRF truncation degree.

Parameters
model

the internal field model, already built for the epoch.

points

the points, GEO, Earth radii.

out

receives one BDerivatives per point; same length as points.

step_re

the difference step dX in Earth radii; 0.0 (the default) selects auto_step for whichever lane actually runs.

Returns

Status::DomainError on a length mismatch or a non-finite step; Status::Ok otherwise. The value is true when the device serviced the call. A single point the pointwise bderivs would refuse — the origin, a non-finite coordinate — is zero-filled rather than spoiling the batch, and both lanes zero-fill the same point: the device lane applies the host's gate before differencing, because without it the device differences a field that is infinite one step inside the Earth and returns a NaN gradient where the host returns zeros.

Complexity

O(4·N·NMAX²) field evaluations plus ~40 flops per point of differencing.

Allocation

none on the host lane. The device lane stages 12N floats in and 12N out.

Unit testIrbemField.BderivsBatchAgreesWithTheReferenceLane IrbemField.BderivsBatchMatchesTheOracleGoldens IrbemField.BderivsBatchUsesTheDeviceWhenOneIsAvailable IrbemField.BothDerivativeLanesRefuseTheSameDegeneratePoint
fn Status grad_curv_curl_batch(std::span< const BDerivatives > derivs, std::span< GradCurvCurl > out, std::span< Status > statuses) #

The guiding-centre geometry at every point of a batch — IRBEM's COMPUTE_GRAD_CURV_CURL.

A host loop, permanently, and the number that settles it. 136 bytes would have to cross the bus per point (16 fp32 in, 18 out) for 19 ns of host arithmetic — measured, 17.7 ns/point at 2¹⁰ and 19.9 at 2¹⁸, i.e. 50–57 Mpts/s, -O3 -march=native -ffp-contract=off. This seam's payload bandwidth is 7.15 GB/s, measured directly by dispatching a degree-1 IGRF kernel over 2²¹ points — 30 flops of kernel, so what is left is the round trip — which puts 136 B/point at ≥ 19.0 ns/point of transfer alone. The copy costs exactly what the computation costs, before the kernel runs and before the ~115 µs dispatch floor is paid. Arithmetic intensity is ~0.4 flops/byte, below even the dipole kernel's 0.5 — and gpu/dispatch.hpp already records that the dipole kernel LOSES 0.69× at every batch size it was measured at. There is no size at which this one wins: the ratio is fixed, not amortizable. So no kernel for it exists in gpu/irbem.slang and none should be written.

The routine is still worth having as a batch entry point: it keeps the loop in one place, it vectorizes, and it lets a caller hand over an ephemeris rather than write the loop themselves.

Parameters
derivs

the field and derivatives per point, as bderivs_batch produces them.

out

receives one GradCurvCurl per point; same length as derivs.

statuses

receives each point's status, so a single degenerate point reports itself instead of spoiling the batch; same length as derivs.

Returns

Status::DomainError on a length mismatch, Status::Ok when every point was computable, and the first non-Ok per-point status otherwise.

Complexity

O(N) — ~50 flops and one square root per point.

Allocation

none.

Unit testIrbemField.GradCurvCurlBatchMatchesThePointwiseRoutine
fn Result< bool > hemisphere_batch(const Igrf< NMAX > &model, std::span< const Position< Frame::GEO > > points, std::span< Hemisphere > out, double step_re=0.0) #

The magnetic hemisphere of every point of a batch — IRBEM's GET_HEMI_MULTI.

Three field evaluations per point — the base, and one step either way along — and the same one-dispatch discipline as bderivs_batch, with one difference that is forced by the physics: is not known until the base evaluation has come back, so this cannot be a single 3N dispatch. It is two: one of N points to get the directions, then one of 2N to get the neighbours. Two dispatches for three evaluations is the best a data dependency of this shape allows, and it is still a factor of N better than the per-point loop the name suggests.

fp32 costs almost nothing here, because only the SIGN of the difference survives. Measured over 2¹⁸ random points spanning r = 1.58.5, the device lane and the fp64 host lane disagree on 11 points in 262 144 — 4 × 10⁻⁵ — and every one of them is a point sitting within a step of the magnetic equator, where d|B|/ds passes through zero and the two hemispheres are genuinely adjacent. No precision decides that question; a point on the equator is in neither hemisphere. Measured speedup: 0.51× at 128 points, 0.97× at 256, 3.76× at 1 024, 25.4× at 2¹⁴.

Template parameters
NMAX

the IGRF truncation degree.

Parameters
model

the internal field model, already built for the epoch.

points

the points, GEO, Earth radii.

out

receives one Hemisphere per point; same length as points.

step_re

the step along in Earth radii; 0.0 selects auto_step.

Returns

Status::DomainError on a length mismatch or a non-finite step, Status::Ok otherwise. The value is true when the device serviced the call.

Complexity

O(3·N·NMAX²) field evaluations.

Allocation

none on the host lane. The device lane stages 9N floats each way.

Unit testIrbemField.HemisphereBatchAgreesWithTheReferenceLane IrbemField.HemisphereBatchUsesTheDeviceWhenOneIsAvailable IrbemField.TheDeviceLaneReportsAPointWithNoField
fn FrameKind kind_of(Frame f) #

How to read f's three components.

Parameters
f

the frame.

Returns

the component convention; FrameKind::Cartesian for everything but GDZ/SPH/RLL.

Complexity

O(1).

Allocation

none.

fn std::optional< int > sysaxes_of(Frame f) #

The IRBEM sysaxes code naming f, when one exists.

Parameters
f

the frame.

Returns

the code 0..8, or std::nullopt for the heliospheric frames, which sysaxes cannot name (IRBEM reaches those through dedicated transform routines instead).

Complexity

O(1).

Allocation

none.

fn std::optional< Frame > frame_from_sysaxes(int sysaxes) #

The frame an IRBEM sysaxes code names — the ONE place a runtime frame selector enters the typed world.

Parameters
sysaxes

the IRBEM code, 0..8.

Returns

the frame, or std::nullopt when sysaxes is outside that range. Callers turn the empty case into a named error; it is never silently defaulted to a frame.

Complexity

O(1).

Allocation

none.

fn int max_batch_times() #

The batch cap on the time dimension of IRBEM's array entry points — IRBEM's GET_IRBEM_NTIME_MAX.

Returns

100000. That figure is IRBEM's own dimensioning constant, taken from the generated source/ntime_max.inc (PARAMETER (NTIME_MAX = 100000)) and confirmed by calling get_irbem_ntime_max1_ on the shipped shared library; it is a number, not an algorithm.

Complexity

O(1).

Allocation

none.

Note

Nothing in this implementation is actually limited to that many epochs — the batch lanes take a caller-provided span of any length. The value exists so a port of an IRBEM-shaped program that sizes its own arrays by this number keeps working unchanged.

fn int igrf_generation() #

The IGRF generation this module implements — IRBEM's GET_IGRF_VERSION.

Returns

14, i.e. IGRF-14 (IAGA, released 2024, valid 1900.0–2030.0). The shipped IRBEM library returns the same, measured.

Complexity

O(1).

Allocation

none.

fn int implementation_version() #

Implementation version — the compatibility shim standing in for IRBEM's IRBEM_FORTRAN_VERSION.

Returns

a monotonically increasing integer identifying this C++ implementation, starting at 1 and bumped only when the behaviour of this shim pair changes.

Complexity

O(1).

Allocation

none.

Warning

There is no Fortran here. IRBEM's routine reports the revision of its own Fortran sources, and returning a plausible-looking IRBEM revision would let a caller feature-detect against a value that means nothing. Comparing this number against an IRBEM revision is therefore meaningless; use implementation_release, which says in words what is running.

fn std::string_view implementation_release() #

Implementation release tag — the compatibility shim standing in for IRBEM_FORTRAN_RELEASE.

Returns

a static, never-empty string naming this implementation. It says "not IRBEM Fortran" in so many words, so a log line carrying it cannot be mistaken for the real library's release tag (which is a git short hash, measured).

Complexity

O(1).

Allocation

none — the returned view refers to a string literal with static storage duration.

Note

IRBEM's C entry point writes an 80-character space-padded buffer. This value is short enough to fit; padding it belongs in the C boundary layer, not here, so that the ordinary C++ caller gets a plain view with no trailing blanks to strip.

fn Result< FieldLine > trace_invariant(const M &model, const Position< Frame::GEO > &start, double pitch_angle_deg, const TraceOptions &opt={}) #

Trace the field line through start and accumulate the second invariant.

Three stages, because I is defined between the two mirror points and a one-directional walk from an arbitrary start point covers only part of that path:

  1. walk in the direction of DECREASING field to the magnetic equator, recording B_min;

  2. from the equator, integrate outward to the mirror point;

  3. from the equator, integrate outward the OTHER way, to the conjugate mirror point.

I is the sum. Doing only the first half and doubling it would be right for a centred dipole and wrong for every real field, which is asymmetric about the equator — and wrong by an amount that grows exactly where the models matter most.

Template parameters
NMAX

the IGRF truncation degree.

Parameters
model

the internal field model, already built for the epoch.

start

the starting position, GEO, Earth radii.

pitch_angle_deg

the LOCAL pitch angle at start; 90° mirrors immediately, so I is 0.

opt

the tracing options.

Returns

the trace. Status::OpenFieldLine when either half failed to reach a mirror point within TraceOptions::max_steps or ran into the atmosphere; Status::DomainError for a start inside the atmosphere or a non-physical pitch angle. The value is populated in every case, because a partial trace is still diagnostic.

Complexity

O(steps) IGRF evaluations, ~4 per step; 100–300 steps typically.

Allocation

none — the integral accumulates in registers and no path is stored.

Unit testIrbemLstar.DipoleTraceMatchesTheAnalyticInvariant
fn double dipole_moment(const M &model) #

The geomagnetic dipole moment of a model, M, in nT.

The magnitude of the degree-1 field: √(g₁⁰² + g₁¹² + h₁¹²). Epoch-dependent — the Earth's moment has fallen roughly 6% over the last century — and L ∝ M^(1/3), so taking it from the model rather than from a constant is what keeps L_m right across epochs.

Template parameters
NMAX

the truncation degree.

Parameters
model

the internal field model.

Returns

M in nT.

Complexity

O(1).

Allocation

none.

Unit testIrbemLstar.DipoleMomentTracksTheEpoch
fn Result< double > mcilwain_l(double invariant_i, double b_mirror, double dipole_moment_nt) #

McIlwain's L from the second invariant and the mirror field — Hilton's closed form.

McIlwain defined L through a tabulated function; Hilton (1971) fitted a closed form accurate to about one part in 10⁴, which is what every implementation since has used. With X = I³·B_m/M and M the dipole moment, L³·B_m/M = 1 + 1.35047·X^(1/3) + 0.465376·X^(2/3) + 0.0475455·X

Parameters
invariant_i

the second invariant I, Earth radii.

b_mirror

the mirror field, nT.

dipole_moment_nt

the dipole moment M in nT·R_E³. Use dipole_moment, which derives it from the epoch's own IGRF coefficients. Do NOT hard-code a constant: M drifts by several percent per century, and because L ∝ M^(1/3) a stale value shows up as a CONSTANT relative offset in L_m at every shell — the 1960s-era 0.311653e5 is 4.3% high against IGRF-2015 and produces a uniform 1.4% error, which is exactly how this was found.

Returns

L_m in Earth radii, or Status::DomainError for non-physical inputs.

Complexity

O(1) — two cube roots.

Allocation

none.

Unit testIrbemLstar.HiltonReproducesTheDipoleLimit
fn double naive_sum_bound(std::size_t terms) #

The worst-case relative error of summing terms values naively (left to right) in T.

Higham §4.2 gives |E| ≤ (n−1)u / (1 − (n−1)u) for recursive summation of n terms. What is returned is the numerator (n−1)u, so it understates that bound by the denominator — which differs from one by (n−1)u itself, a part in ~10⁴ for the fp32 cases here and a part in ~10¹³ for fp64, both far under the factor-of-two the budget's own spelling already carries (see the note below). It is a worst case — every rounding error the same sign and maximal — and the realistic figure is the random walk of random_walk_estimate.

Parameters
terms

how many values are summed; 0 and 1 involve no addition and cost nothing.

Template parameters
T

the format the accumulator is in — the point of the whole precision policy is that this is double even when the integrand is float.

Returns

the bound as a relative error, 0.0 for fewer than two terms.

Complexity

O(1).

Allocation

none.

Note

ERROR_BUDGET §3's "naive fp32 sum of 10³ terms ~1.2 × 10⁻⁴" row states the same bound in ε rather than u, so it is 2× this function at the same n. That row is the conservative spelling; both say the reduction cannot be fp32.

fn double random_walk_estimate(std::size_t terms) #

The realistic relative error of summing terms values in T, treating the individual roundings as independent and mean-zero: √n · u.

This is the √N·ε row of ERROR_BUDGET §3 (which, as in naive_sum_bound, states it in ε rather than u, so its ~4 × 10⁻⁶ at n = 10³ is twice what this returns). It is an estimate, rather than a bound — a summation whose errors correlate (a monotone integrand, which a field-line quadrature very much is) can exceed it, up to naive_sum_bound. Both are quoted in the budget because the honest margin lies between them: one to three orders above the discretization floor, not the two to three the planning estimate assumed.

Parameters
terms

how many values are summed.

Template parameters
T

the accumulator format.

Returns

the estimated relative error; 0.0 for no terms.

Complexity

O(1) — one square root.

Allocation

none.

Note

Not constexpr: std::sqrt is not a constant expression before C++26.

fn std::size_t max_terms_within(double budget) #

The inverse question, and the one downstream code actually asks: how many terms may be summed in T before naive summation alone breaches budget?

Answering it is what decides where a reduction may run. At the XJ budget of 1 × 10⁻⁴ relative (ERROR_BUDGET §4) this returns ~1.7 × 10³ for float — the same order as the term count of a single field-line quadrature, i.e. the reduction would consume its whole budget — against ~9 × 10¹¹ for double, where the question stops mattering. That contrast is the accumulator invariant, in one number.

Parameters
budget

the largest acceptable relative error; a non-positive or NaN budget admits no terms at all rather than being treated as unlimited.

Template parameters
T

the accumulator format.

Returns

the largest n for which (n−1)u ≤ budget, saturated at SIZE_MAX for a budget so loose that the count is not representable.

Complexity

O(1).

Allocation

none.

fn bool is_ok(Status s) #

Whether s is the no-caveat status.

Spelled as a function rather than left to == Status::Ok at every call site so that "success" has one definition — if a future status ever has to be treated as benign, it is added here.

Parameters
s

the status.

Returns

true only for Status::Ok.

Complexity

O(1).

Allocation

none.

Unit testIrbemStatus.IsOkIsExactlyOk
fn Status first_failure(Status a, Status b) #

Compose two checks: the first non-Ok of a and b.

A caller that must satisfy several envelopes at once (drivers, position, coefficient files) wants one status out, and wants the first reason rather than the last, because the checks are ordered cheapest-and-most-fundamental first.

Parameters
a

the first check's result.

b

the second check's result.

Returns

a when it is not Status::Ok, otherwise b.

Complexity

O(1).

Allocation

none.

Unit testIrbemStatus.FirstFailureKeepsTheFirstNonOk
fn std::uint32_t status_code(Status s) #

The status as the uint a compute kernel writes into its per-point status buffer.

It is the enumerator's own value, so the kernel needs no table: the device-side code assigns the same small integers by hand and this function is the host-side statement of that contract.

Parameters
s

the status.

Returns

the code, 0..status_count-1 for every enumerator.

Complexity

O(1).

Allocation

none.

Unit testIrbemStatus.StatusCodeRoundTrips
fn std::optional< Status > status_from_code(std::uint32_t code) #

The inverse of status_code, for reading a device-written status buffer back.

Validated rather than cast, because the bytes come from a buffer a driver wrote: an uninitialised slot, a kernel that never ran, or a device-side bug produces an integer with no meaning, and static_casting it into a Status would be undefined behaviour dressed up as a result.

Parameters
code

the raw value read out of the status buffer.

Returns

the corresponding status, or std::nullopt when code names no enumerator.

Complexity

O(1).

Allocation

none.

Unit testIrbemStatus.StatusCodeRejectsAnUnknownCode
fn double to_baddata(const Result< T > &r) #

Collapse a Result down to IRBEM's convention, for the C-compatible boundary only.

This is a lossy, one-way bridge: six statuses map onto one number and the reason is gone. There is deliberately no inverse — a caller who wants to know why keeps the Result.

Note that Status::OutOfValidityRange collapses to the sentinel here even though the value was computed, because IRBEM's convention has no way to say "here is the number, with a caveat". That loss is precisely the cost of the boundary, and precisely why nothing inside this library crosses it.

Parameters
r

the result to collapse.

Returns

r.value widened to double when r.ok(), otherwise baddata.

Template parameters
T

the payload type; must convert to double.

Complexity

O(1).

Allocation

none.

Unit testIrbemStatus.ToBaddataCollapsesEveryFailure
fn bool is_baddata(double v) #

Whether v is the sentinel, for code reading an IRBEM-convention array back.

Exact equality, matching IRBEM's own .eq. baddata test. A NaN is not the sentinel — it compares unequal to everything, this function included — which is correct: a NaN that escaped a computation is a different failure from one the library deliberately reported.

Parameters
v

the value read back.

Returns

true only for the bit pattern baddata names.

Complexity

O(1).

Allocation

none.

Unit testIrbemStatus.IsBaddataDetectsOnlyTheSentinel
fn bool is_recognised(ExternalModel m) #

Whether m names one of the model_count keys the envelope table covers.

Parameters
m

the model key, possibly a raw integer cast in from a C caller.

Returns

true when m is a table index.

Complexity

O(1).

Allocation

none.

Unit testIrbemStatus.UnrecognisedModelIsADomainError
fn ValidityEnvelope make_envelope(std::string_view citation, std::initializer_list< DriverBound > bounds, double max_r_geo, double min_x_gsm, bool needs_coefficient_files) #

Build a ValidityEnvelope from a braced list of bounds, so the table below cannot get its count out of step with its contents.

Parameters
citation

the source the ranges are quoted from.

bounds

the drivers the model reads, at most max_model_bounds of them; entries beyond that are dropped, which the table's own static_assert makes unreachable.

max_r_geo

the published radial limit in Earth radii, or unbounded_above.

min_x_gsm

the published GSM-x limit in Earth radii, or unbounded_below.

needs_coefficient_files

whether coefficient files must be provisioned first.

Returns

the envelope.

Complexity

O(bounds.size()).

Allocation

none — the storage is the returned object's own inline array.

Unit testIrbemStatus.MakeEnvelopeFillsTheCountFromTheList
fn const ValidityEnvelope & envelope_of(ExternalModel m) #

The published envelope for m.

Parameters
m

the model key.

Returns

a reference to the table row, or to unknown_envelope for an unrecognised key. The referent has static storage duration and outlives every caller.

Complexity

O(1) — one bounds check and an index.

Allocation

none.

Unit testIrbemStatus.EnvelopeTableMatchesTheIrbemKextTable
fn Status check_validity(ExternalModel m, const DriverSet &drivers) #

Whether drivers put m outside the data it was fitted to.

This never suppresses a value. It is called beside the evaluation, not instead of it: the caller gets the number the functional form produces AND the knowledge that the number is an extrapolation. Deciding what to do about that — clamp, refuse, or proceed and annotate — is a scientific judgement this library will not make on the caller's behalf.

Only the drivers the model actually reads are examined, so a maginput vector full of fill values in the slots a model ignores is still Ok for that model. That matters in practice: a caller running the same vector through T89 and T96 has, at most, the union of what both need.

The two failures are ordered, most fundamental first: every driver the model reads is checked for finiteness before any is checked against its range, so a NaN anywhere in the used set reports Status::DomainError rather than whichever came first in the list.

Parameters
m

the model.

drivers

the whole 25-slot maginput vector; the reserved slots are never read.

Returns

Status::DomainError for an unrecognised key or a non-finite used driver, Status::OutOfValidityRange for a used driver strictly outside its published closed interval, otherwise Status::Ok.

Complexity

O(max_model_bounds) — at most twenty compares, no branches on data.

Allocation

none.

Unit testIrbemStatus.EveryBoundedDriverIsCheckedFromBothSides
fn Status check_position(ExternalModel m, double r_geo, double x_gsm) #

Whether a point is inside the region m was fitted over.

The spatial envelope is stated two different ways by the two model families, and both are checked: the pre-2001 models publish a maximum geocentric radius (they stop having a tail), while T01 and later publish a minimum GSM x (their tail current sheet is what runs out). A model publishing neither accepts every finite point.

Parameters
m

the model.

r_geo

the geocentric radius, in Earth radii.

x_gsm

the GSM x coordinate, in Earth radii.

Returns

Status::DomainError for an unrecognised key, a non-finite coordinate, or a radius below min_r_geo; Status::OutOfValidityRange outside the published envelope; otherwise Status::Ok.

Complexity

O(1).

Allocation

none.

Unit testIrbemStatus.PositionEnvelopeIsCheckedFromBothSides
fn Status check_parameters(ExternalModel m, bool coefficients_present) #

Whether m can be evaluated at all with the coefficient files currently provisioned.

Only TS07D needs them: it is not a closed-form fit but a per-interval expansion whose radial basis function coefficients are downloaded per six-minute interval (IRBEM ships setup_ts07d_files.sh to fetch a Coeffs/ and a TAIL_PAR/ tree for exactly this reason). Without them the model has no parameters, which is a different failure from having bad ones — hence its own status rather than Status::DomainError.

Parameters
m

the model.

coefficients_present

whether the caller has located the files; the locating is the caller's business, so that this header stays free of filesystem I/O.

Returns

Status::DomainError for an unrecognised key, Status::ParametersMissing when the model needs files the caller does not have, otherwise Status::Ok.

Complexity

O(1).

Allocation

none.

Unit testIrbemStatus.ParametersAreMissingOnlyForTs07d
fn int t89_kp_bin(double kp_times_ten) #

The Kp bin T89 uses, from Kp in IRBEM's OMNI2 scaling.

T89 is not continuous in Kp. Tsyganenko (Planet. Space Sci. 37, 5, 1989) sorted the data into seven Kp intervals and fitted a separate coefficient set to each: {0, 0+}, {1-, 1, 1+}, {2-, 2, 2+}, {3-, 3, 3+}, {4-, 4, 4+}, {5-, 5, 5+}, and {>= 6-}. The returned index is the 1-based bin number, which is the iopt argument the published T89 interface takes.

The thresholds therefore fall at Kp x 10 = 5, 15, 25, 35, 45, 55 — between the values Kp can actually take, since the third-of-a-unit steps land on 0, 3, 7, 10, 13, 17, ... in this scaling. There is consequently no "exact boundary" case for a real Kp to sit on, and the bin edges are chosen midway rather than at a bin's own extreme so that the mapping is robust to a caller who has already rounded. The convention where it cannot matter is nonetheless decided and uniform: a threshold value belongs to the bin below it, so the bins are the half-open intervals (-inf, 5], (5, 15], ... (55, +inf) in this scaling. A negative Kp — which check_validity reports separately — still yields bin 1, and any Kp above 5.5 yields bin 7.

Parameters
kp_times_ten

Kp in IRBEM's slot-1 scaling, i.e. Kp x 10, nominally 0..90.

Returns

the bin, 1..7.

Complexity

O(1).

Allocation

none.

Unit testIrbemStatus.T89KpBinsFollowThePublishedIntervals
fn Result< MagneticEquator > find_magequator(const Igrf< NMAX > &model, const Position< Frame::GEO > &start, const PathTraceOptions &opt={}) #

The minimum-|B| point on the field line through start — IRBEM's FIND_MAGEQUATOR.

Walks downhill in |B| until the field stops falling, then fits a parabola in arc length through the three samples that bracket the minimum and takes a partial RK4 step to its vertex. The coarse sample is not the answer and returning it would be a silent O(ds) error in the position: measured on an L≈4 line at the default step, the coarse minimum sits 5.5 × 10⁻⁶ relative above the oracle's Bmin and 4.4 × 10⁻³ R_E from its position.

A start point that is ALREADY at the minimum is not a special case in the physics and is not one here: both neighbours are probed, and if both are higher the bracket is centred on the start.

Measured against the oracle over 84 start points × 4 epochs at matched IGRF: Bmin to 1.9 × 10⁻⁶ relative (budget 10⁻⁵) and the position to 4.6 × 10⁻⁵ R_E. The Bmin residual is the ORACLE's, not ours — our value at that resolution is within 10⁻⁸ of our own converged value, and the oracle sits the same 1.9 × 10⁻⁶ from it.

Template parameters
NMAX

the IGRF truncation degree.

Parameters
model

the internal field model, already built for the epoch.

start

the starting position, GEO, Earth radii.

opt

the tracing options.

Returns

the equator, with Status::OpenFieldLine when the walk reached PathTraceOptions::r0 or the step cap without finding a minimum, and Status::DomainError for a start inside r0 or a vanishing field. The value is populated in every case: the lowest field actually seen is still diagnostic.

Complexity

O(steps) IGRF evaluations, ~4 per step, plus the fit; typically 50–200 steps.

Allocation

none.

Unit testIrbemTraceApi.MagEquatorMatchesTheOracle IrbemTraceApi.MagEquatorIsTheMinimumAlongTheLine IrbemTraceApi.MagEquatorRefinesAStartThatIsAlreadyTheMinimum IrbemTraceApi.MagEquatorReportsAnOpenLine
fn Result< MirrorPoint > find_mirror_point(const Igrf< NMAX > &model, const Position< Frame::GEO > &start, double alpha_deg, const PathTraceOptions &opt={}) #

Where a particle of local pitch angle alpha_deg at start turns around — IRBEM's FIND_MIRROR_POINT.

The mirror field is not searched for, it is KNOWN: the first adiabatic invariant makes it B_m = B_local / sin²α exactly. What the trace finds is where along the line that value is reached, by walking in the direction of INCREASING |B| — which is what puts the mirror point in the particle's own magnetic hemisphere, as the reference does.

α = 90° is returned without tracing at all: the particle mirrors where it is, so the answer is the input point verbatim and B_m = B_local. That is not an optimisation, it is the only answer that is exactly right — a trace would return a point one refinement away from where the particle demonstrably is.

A line that reaches PathTraceOptions::r0 before B_m is a particle in the LOSS CONE: it hits the atmosphere instead of mirroring. That is physics, so it is Status::OpenFieldLine and not an error — the reference answers baddata here, which cannot distinguish it from a bad input.

Template parameters
NMAX

the IGRF truncation degree.

Parameters
model

the internal field model, already built for the epoch.

start

the starting position, GEO, Earth radii.

alpha_deg

the LOCAL pitch angle at start, degrees, strictly inside (0, 180). Both endpoints are refused rather than clamped: a particle with no perpendicular velocity has no mirror point at all, and sin(180 deg) is 1.2e-16 rather than zero in binary64, so accepting it would return a mirror field 10^32 times B_local and an OpenFieldLine that looks like a physics result instead of a bad argument.

opt

the tracing options.

Returns

the mirror point; Status::OpenFieldLine when the particle is in the loss cone or the step cap was reached, Status::DomainError for a start inside r0, a vanishing field, or a pitch angle outside the open interval (0, 180).

Complexity

O(steps) IGRF evaluations plus PathTraceOptions::refine_iterations more.

Allocation

none.

Unit testIrbemTraceApi.MirrorPointMatchesTheOracle IrbemTraceApi.MirrorPointAtNinetyDegreesIsTheInputPoint IrbemTraceApi.MirrorPointInTheLossConeIsReported
fn Result< FootPoint > find_foot_point(const Igrf< NMAX > &model, const Position< Frame::GEO > &start, double stop_alt_km, Hemisphere hemisphere, const PathTraceOptions &opt={}) #

Where the field line through start crosses geodetic altitude stop_alt_km — IRBEM's FIND_FOOT_POINT.

The termination is on GEODETIC altitude, which is why this converts inside the step loop rather than comparing radii and converting once at the end. The WGS-84 ellipsoid's polar semi-axis is 21.4 km shorter than its equatorial one and field-line feet sit at |latitude| > 45°, so a geocentric stand-in would land up to ~15 km from the requested surface — some 30× the ~0.5 km the oracle's own iteration converges to, and in the one place where an altitude error maps directly onto an atmospheric density.

hemisphere follows IRBEM's hemi_flag. Hemisphere::Same walks in the direction of increasing |B|, which is the input point's own side of the magnetic equator; Hemisphere::Opposite walks the other way. Hemisphere::North and Hemisphere::South name a side outright: the same-hemisphere foot is traced first and, if its geodetic latitude has the wrong sign, the other direction is traced instead. Classifying by the FOOT's latitude rather than the start's is what makes the answer right for a start point below the magnetic equator but above the geographic one — the two differ by up to 11° and the dip equator wanders further still.

Template parameters
NMAX

the IGRF truncation degree.

Parameters
model

the internal field model, already built for the epoch.

start

the starting position, GEO, Earth radii.

stop_alt_km

the geodetic altitude to stop at, kilometres above the WGS-84 ellipsoid.

hemisphere

which foot to return.

opt

the tracing options. PathTraceOptions::r0 must be below the surface the altitude names, or the trace terminates before it gets there.

Returns

the foot point; Status::OpenFieldLine when the line left the domain or hit the step cap before reaching the altitude, Status::DomainError for a start inside r0, a vanishing field, a non-finite altitude, or a start already BELOW stop_alt_km.

Complexity

O(steps) IGRF evaluations plus one geodetic conversion per step — the conversion is four Bowring iterations, ~40 flops, against the step's ~2 000.

Allocation

none.

Unit testIrbemTraceApi.FootPointMatchesTheOracle IrbemTraceApi.FootPointHemisphereFlagsSelectTheTwoFeet IrbemTraceApi.FootPointLandsOnTheRequestedGeodeticAltitude
fn Result< std::size_t > trace_field_line_toward_earth(const Igrf< NMAX > &model, const Position< Frame::GEO > &start, std::span< PathPoint > path, const PathTraceOptions &opt={}) #

Trace from start toward the Earth at a fixed step — IRBEM's TRACE_FIELD_LINE_TOWARD_EARTH.

The half-line the input point sits on, sampled uniformly for a plot. The direction is the input point's own magnetic hemisphere — the direction of increasing |B| — which is the reference's behaviour and the only one that makes "toward the Earth" well defined for a point off the magnetic equator. Sample 0 is the input point verbatim; the last sample is the first one inside PathTraceOptions::r0, so the path visibly crosses the surface rather than stopping short of it, which is what the reference does and what a plot wants.

The step is PathTraceOptions::step_size when positive and L/steps_per_l otherwise — the reference takes ds as a required argument here and nowhere else, and this is why.

Template parameters
NMAX

the IGRF truncation degree.

Parameters
model

the internal field model, already built for the epoch.

start

the starting position, GEO, Earth radii.

path

receives the samples; the caller sizes it, and the trace stops when it is full.

opt

the tracing options.

Returns

how many samples were written; Status::NotConverged when path filled before the surface was reached, Status::OpenFieldLine when the step cap was, and Status::DomainError for an empty span, a start inside r0, or a vanishing field.

Complexity

O(samples) IGRF evaluations, ~4 per sample.

Allocation

none — the caller's span is the only storage.

Unit testIrbemTraceApi.TowardEarthMatchesTheOracle IrbemTraceApi.TowardEarthSamplesAreOneStepApart IrbemTraceApi.TowardEarthReportsATruncatedPath
fn Result< TracedLine > trace_field_line(const Igrf< NMAX > &model, const Position< Frame::GEO > &start, std::span< PathPoint > path, const PathTraceOptions &opt={}) #

Trace the WHOLE field line through start, foot to foot — IRBEM's TRACE_FIELD_LINE.

Samples run along +B̂: index 0 is the foot the field points away from (the southern one, for the real geomagnetic field), the last index is the foot it points into, and the input point sits verbatim at TracedLine::start_index. The reference orders its posit the other way; the choice is arbitrary, so it is stated rather than inherited, and a caller who cares should read start_index instead of assuming an end.

Both ends are capped ON the reference surface by regula falsi inside the last step, so the path's extent is a property of the field line and not of the step size.

Lm, Bmin and XJ come from the samples already in the caller's buffer rather than from a second trace — one pass over the line, not two. XJ is I for a particle mirroring at the input point, integrated by the same right-endpoint rule lstar.hpp uses outward from the equator in both directions, so this and trace_invariant are the same quadrature on the same grid rather than two approximations that happen to be close. (IrbemTraceApi.XjAgreesWithTheInvariantTracer holds them to 1 × 10⁻⁴ relative; what separates them is only that RK4 is not exactly reversible, so the two grids drift apart at O(ds⁵) per step.)

Template parameters
NMAX

the IGRF truncation degree.

Parameters
model

the internal field model, already built for the epoch.

start

the starting position, GEO, Earth radii.

path

receives the samples; the caller sizes it. IRBEM's own cap is irbem_max_path_points, which at the default step is ~25× more room than an L=8 line needs.

opt

the tracing options.

Returns

the line's scalars and the sample count. Status::NotConverged when path filled before the line closed — TracedLine::truncated is then set, and what the scalars mean depends on WHICH half filled it, which that field's brief spells out: they are all zero when the backward half alone exhausted the buffer. Status::OpenFieldLine when a half hit the step cap, and Status::DomainError for an empty span, a start inside r0, or a vanishing field.

Complexity

O(samples) IGRF evaluations, ~4 per sample, plus O(samples) for the quadrature.

Allocation

none — the caller's span is the only storage, and the in-place reverse that puts the two halves in order needs none.

Unit testIrbemTraceApi.TraceFieldLineMatchesTheOracle IrbemTraceApi.TraceFieldLineEndsOnTheReferenceSurfaceAtBothFeet IrbemTraceApi.XjAgreesWithTheInvariantTracer IrbemTraceApi.TraceFieldLineReportsATruncatedPath IrbemTraceApi.TraceFieldLineTruncatesInTheForwardHalfToo
fn Result< bool > trace_field_line_toward_earth_batch(const Igrf< NMAX > &model, std::span< const Position< Frame::GEO > > starts, std::span< PathPoint > paths, std::span< std::uint32_t > counts, std::span< Status > statuses, const PathTraceOptions &opt) #

Trace a whole batch of field lines earthward and return every path.

The batch form of trace_field_line_toward_earth, and the only shape a device can accelerate: one trace is a serial RK4 chain, so no hardware makes it faster, and the parallelism is entirely ACROSS lines.

This kernel's crossover is 256 lines — MEASURED, and lower than the invariant tracer's 512 rather than higher, which is not what the output shape predicts. Both run the same RK4 chain over the same field. irbem_trace_i_f32 returns four floats per line — ~9 400 flops per byte moved. This one returns four floats per STEP, so at ~250 steps a line it moves ~250× as many bytes for the same arithmetic: ~125 flops/byte. The transfer term the invariant tracer never pays is real, but it does not move the crossover, because a crossover is a RATIO and this routine's host lane is ~2.6× dearer per line too. Where the transfer shows up instead is the ceiling — see the two bullets under the table. That is why it is a separate kernel with a separate registry row and a separate measured threshold, and why merging the two would be a mistake in both directions.

Measured on an RTX 3070 Ti against this header's own fp64 host lane (-O3 -march=native -ffp-contract=off), fixed step ds = 0.02 R_E, 512 samples of headroom per line, starts spread over L = 2…8 (IrbemTraceApiGpu.PathKernelCrossover):

lines

64

128

256

512

1024

4096

16384

65536

speedup

0.33–0.39×

0.66–0.69×

1.05–1.15×

2.05–2.15×

3.69–3.94×

9.9–13.3×

9.6–23.5×

19–26.3×

against a host lane flat at 154–165 µs/line. Every cell is a range over FOUR full runs, not a best-of: below 1 024 lines the spread is a few per cent and the crossover reproduces at 256 every time, while above 4 096 the same size has measured 9.6× and 23.5× on different runs. Two things in that row are worth stating plainly because they contradict the obvious expectation:

  • The crossover came out LOWER than the invariant tracer's, not higher. Not because this kernel is cheaper — because the HOST lane is dearer. A fixed ds = 0.02 R_E line is ~250 steps against the invariant tracer's ~120 at ds = L/50, so the host costs 154 µs/line against 60, and the same submit floor is paid off in half the batch. A crossover is a ratio.

  • Where the bandwidth shows is the CEILING. The invariant tracer's speedup is still climbing at 65 536 lines (48.9×); this one flattens in the low-to-mid twenties and goes run-to-run noisy above 4 096, where a batch stages 100 MB–1 GB of results through host memory. The ranges above are quoted as ranges on purpose: at 16 384 lines the spread across four runs is a factor of 2.4, so any single number from that end of the curve is a performance claim the next run will not support.

Template parameters
NMAX

the IGRF truncation degree.

Parameters
model

the internal field model, already built for the epoch.

starts

the starting positions, GEO, Earth radii.

paths

receives starts.size() × max_points samples, line-major: line i's samples are paths[i*max_points .. i*max_points + counts[i]). The caller sizes it, which at 3 000 samples and 4 096 lines is 393 MB — a number that belongs to the caller, not here.

counts

receives each line's sample count; same length as starts.

statuses

receives each line's status; same length as starts.

opt

the tracing options. PathTraceOptions::step_size MUST be positive: the device lane takes a fixed step, and an L-proportional one would make the step a per-line value the dims buffer cannot carry.

Returns

true when the device lane serviced the call — asserted by a test rather than trusted, because a silent fallback is what makes a performance claim worthless. The status is Status::Ok when every line reached the surface, Status::DomainError on a length mismatch or a non-positive step.

Complexity

O(lines × steps) field evaluations; on the device those run concurrently.

Allocation

the device lane stages coefficients, positions and results per BATCH; the host lane allocates nothing.

Unit testIrbemTraceApiGpu.PathKernelAgreesWithTheHostLane IrbemTraceApiGpu.PathBatchUsesTheDeviceWhenOneIsAvailable IrbemTraceApiGpu.PathBatchHonoursTheStepCapOnEitherLane
fn CalendarDate calendar_date(std::int64_t jdn) #

The calendar date of a Julian day number — the exact inverse of julian_day_number, including across the reform.

Parameters
jdn

the Julian day number.

Returns

the calendar date, in whichever calendar was in force at that JDN.

Complexity

O(1).

Allocation

none.

fn DateTime date_and_time_from_decimal_year(double decy) #

Decimal year back to a broken-down instant — IRBEM's DECY2DATE_AND_TIME, and the inverse of decimal_year.

The fraction is converted to an integer count of microseconds of year and then snapped to the millisecond before anything is split off it — see detail::decimal_year_resolution_us for the derivation. That snap is the whole point of this routine: a decimal year near 2000 resolves to only about 7 microseconds, so a whole hour recovered from one lands a few microseconds short and a plain truncation reports the previous second. Measured against the shipped IRBEM library, that defect fires on roughly one round trip in six across a sweep of the year 2000; this routine returns the instant it was given, exactly, for every input decimal_year can produce.

Parameters
decy

the decimal year, yyyy.0 being January 1 at 00:00 UT.

Returns

the instant, with month, day, day of year, h:m:s and seconds-of-day all filled in.

Complexity

O(1).

Allocation

none.

fn DateTime date_and_time_from_doy_and_ut(int year, int doy, double ut_seconds) #

Year, day of year and UT seconds to a broken-down instant — IRBEM's DOY_AND_UT2DATE_AND_TIME.

Parameters
year

the year, astronomical numbering.

doy

the day of year, 1 = January 1.

ut_seconds

UT time of day in seconds since midnight. Values outside [0, 86400) are carried into the day count rather than rejected, so a caller stepping a trajectory by adding seconds gets the right date across midnight — including backwards, where a negative offset walks into the previous day.

Returns

the instant. day_of_year in the result is recomputed from the resulting date, so it reflects any such carry rather than echoing doy back.

Complexity

O(1).

Allocation

none.

fn CalendarDate date_from_day_of_year(int year, int doy) #

The calendar date of a given day of year — the inverse of day_of_year, and the piece IRBEM's DOY_AND_UT2DATE_AND_TIME needs.

Parameters
year

the year, astronomical numbering.

doy

the day of year, 1 = January 1. Values outside [1, days_in_year(year)] are not rejected; they roll into the neighbouring year, which is what makes a UT offset that crosses midnight on December 31 come out right instead of producing a "day 367".

Returns

the calendar date.

Complexity

O(1).

Allocation

none.

fn int day_of_year(int year, int month, int day) #

The day of year of a calendar date, leap-year correct.

Parameters
year

the year.

month

1-12.

day

1-based.

Returns

the day of year, 1 = January 1.

Complexity

O(1).

Allocation

none.

fn int days_in_year(int year) #

How many days a year has.

Parameters
year

the year.

Returns

366 in a leap year, 365 otherwise — computed from the calendar rather than from the leap rule directly, so the two can never disagree.

Complexity

O(1).

Allocation

none.

fn double decimal_year(int year, int month, int day, int hour, int minute, int second) #

Date and time to decimal year — IRBEM's DATE_AND_TIME2DECY.

yyyy.0 is January 1 at 00:00 UT and the year's own length is the denominator, so the fraction is the elapsed portion of that year: a mid-year instant is .5 in a leap year and in a common year alike. This is the form IGRF coefficient interpolation consumes.

Everything above the final division is integer, so the only rounding in the whole routine is the one unavoidable division — and when the fraction is exactly representable the result is exact. decimal_year(2000, 7, 2, 0, 0, 0) is 2000.5 bit-for-bit, because 183 of 366 days is one half.

Parameters
year

the year, astronomical numbering.

month

the month, 1 = January … 12 = December.

day

the day of the month, 1-based.

hour

the UT hour of day, 0…23.

minute

the UT minute, 0…59.

second

the UT second, 0…59.

Returns

the decimal year.

Complexity

O(1).

Allocation

none.

fn bool is_leap_year(int year) noexcept #

Whether year is a leap year under proleptic Gregorian rules.

Parameters
year

astronomical year number.

Returns

true when year has 366 days.

Complexity

O(1).

Allocation

none.

System testsystests/test_civil.purr
Example
import io
import space.time as st

io.print(st.is_leap_year(2000))   # True  — divisible by 400
io.print(st.is_leap_year(1900))   # False — divisible by 100 but not 400
fn std::int64_t julian_day_number(int year, int month, int day) #

The Julian day number of a calendar date.

Reform-aware, not proleptic. Dates on or after the Gregorian reform use the Gregorian rule; earlier dates use the JULIAN rule, which is what they were actually recorded in. This differs deliberately from civil.hpp, which is proleptic Gregorian throughout because that is what the CDF format specifies — the two conventions disagree by ten days at the reform and by a growing amount before it, so they are separate functions rather than one with a flag.

Which convention a caller wants depends on what the date MEANS: a CDF timestamp is proleptic by definition, whereas a historical observation is in the calendar its observer used. Geomagnetic work reaches back to the 19th century and IGRF to 1900, both comfortably post-reform, so in practice the two agree everywhere this module is used — but agreeing by accident is not the same as agreeing by construction, and a silent ten-day error is exactly the kind that survives review.

Integer arithmetic throughout (Fliegel & Van Flandern 1968 for the Gregorian branch), so there is no floating-point intermediate and no rounding, valid into negative years under astronomical numbering: JDN 0 is -4712-01-01 in the Julian calendar.

Parameters
year

the year, astronomical numbering.

month

1-12.

day

1-based.

Returns

the Julian day number — a count of days where the DAY BEGINS AT NOON. A caller wanting a Julian DATE for a sidereal-time series must subtract 0.5 and add the UT fraction; getting that half-day wrong shifts GMST by twelve hours, which rotates GSM by 180 degrees and yields a field that looks plausible and is inverted.

Complexity

O(1).

Allocation

none.

Constants & variables

var std::size_t igrf_strip_points #

Points evaluated per strip by igrf_batch_host — the natural blocking for a caller that wants its batch length to divide evenly (any length works; a remainder runs through the scalar kernel, which is bit-identical anyway).

8 for the fp64 Exact policy (2 rows × 4 lanes), 16 for the fp32 Fast integrand.

Template parameters
P

the precision policy (policy.hpp).

var std::size_t driver_count #

The length of the driver vector — IRBEM's maginput is 25 doubles wide, reserved slots included.

var std::size_t named_driver_count #

How many of the 25 slots have a published meaning; the rest are reserved for future use.

var std::size_t cartesian_frame_count #

How many frames are Cartesian, and therefore have a rotation: every Frame but GDZ, SPH and RLL, whose components are an angle pair over a radius and are not related to anything by a rotation at all.

var std::int32_t epoch_min_year #

The earliest epoch accepted: 1900 is the first IAGA DGRF epoch, so nothing before it can be evaluated by any internal field model this library will carry.

var std::int32_t epoch_max_year #

The latest epoch accepted.

A deliberate sanity bound, not a model-validity bound — a model still reports for itself when an epoch is outside its coefficients' span.

var double max_seconds_of_day #

The largest accepted seconds-of-day.

86400 is the 24:00:00 spelling of midnight and 86401 admits a positive UTC leap second; both are things a real ephemeris contains.

var double rotation_orthogonality_tolerance #

How far M^T M may stray from the identity before a matrix is refused as a rotation.

Slack enough that a matrix assembled from sines and cosines (error a few ulp, ~1e-16) never trips it, tight enough that a scaled, sheared or garbage matrix always does.

var double max_tilt_rad #

The largest possible dipole tilt.

The tilt is the angle between the dipole axis and an axis, so it is bounded by pi/2 by definition; the physical excursion over a year is about +-35 degrees.

var double mead_aberration_deg #

The mean solar-wind aberration angle, degrees, by which the solar-magnetic position is rotated about the dipole axis before the polynomials are evaluated.

Mead & Fairfield (1975) prepared their data in aberrated coordinates with this mean value; the oracle experiment in the file brief measures it back as atan(0.0699268) = 4.000 deg.

var double mead_aberration_sin #

sin(4 deg), to the last bit of binary64 — asserted against std::sin by IrbemMead.AberrationConstantsAreExactTrigonometry, since std::sin is not constexpr.

var double mead_aberration_cos #

cos(4 deg), likewise.

var double mead_deg_per_rad #

Degrees per radian, 180 / pi: the tilt enters the model in degrees.

var std::size_t mead_coefficient_count #

How many coefficients one Kp group carries: seven for B_x, three for B_y, seven for B_z.

var std::size_t mead_bin_count #

How many Kp bins the model has — four, all published.

var std::array< MeadCoefficients, mead_bin_count > mead_coefficient_sets #

Mead & Fairfield (1975) Tables 2-3: the fitted coefficients, indexed by bin - 1.

The columns are, in order, Kp = {0, 0+}, {1-, 1, 1+, 2-}, {2, 2+, 3-} and Kp >= 3.

Every number here is a printed table value and every one is checked two ways: the three divergence identities per bin (IrbemMead.PublishedCoefficientsAreDivergenceFree) and the exact recovery from the IRBEM oracle described in the file brief, which reproduces each of these 68 decimals to 1e-9 relative. The monotone deepening of c_1 from -9.41 nT to -22.9 nT across the columns is the paper's central result and IrbemMead.PublishedCoefficientsAreOrderedByDisturbance pins it.

Unit testIrbemMead.PublishedCoefficientsAreDivergenceFree IrbemMead.PublishedCoefficientsAreOrderedByDisturbance
var std::size_t mead_param_count #

How many float scalars the device kernel's parameter buffer holds: sin(psi), cos(psi), psi in degrees, then the seventeen coefficients.

Asserted against the kernel registry.

var OpdConstants opd_constants #

The constants, as their sources state them.

Unit testIrbemOpd.TheConstantsAreThePublishedOnes
var int opd_quiet_bin #

The T89 Kp bin whose published parameter set is the quiet field: Kp = 0, 0+, Table 1's first column.

One bin, always, so nothing in this model is ever a step function of anything.

var std::size_t opd_param_count #

How many float scalars the device kernel's parameter buffer holds: T89's thirty (tilt sine and cosine, then the quiet set) followed by s and C.

Asserted against the kernel registry.

var std::size_t opq_xz_count #

How many monomials the B_x and B_z series carry: the report's A(32), E(32).

var std::size_t opq_y_count #

How many monomials the B_y series carries (before its overall factor of y): C(22).

var double opq_exp_rate #

The rate of the exponential envelope: E = exp(-0.06 r^2), r in Earth radii (report p. 67).

var double opq_inner_r2 #

Below this r^2 (2 R_E) the published field is identically zero (report p. 66).

var double opq_taper_r2 #

Below this r^2 (2.5 R_E) the field is tapered linearly in r^2 towards the inner zero.

var double opq_template_r2 #

Above this r^2 (15 R_E) the published template sets the field to zero (report p. 66).

var OpqTable opq_table #

Olson & Pfitzer (1977) pp.

64–66, as printed — see OpqTable for the layout and the file brief for how each entry was verified.

Unit testIrbemOpq.TablesHaveThePublishedShape
var std::size_t opq_param_count #

How many float scalars the device kernel's parameter buffer holds: sin(psi), cos(psi), then A(32) B(32) C(22) D(22) E(32) F(32).

Asserted against the kernel registry.

var std::size_t om97_harmonic_count #

How many harmonics Table 1 has: six axially symmetric, six day-night asymmetric, five tilt.

var std::size_t om97_regressor_count #

How many regressors each amplitude has: the constant term and the four activity parameters, in the paper's column order {a_i0, a_iDst, a_ip, a_iKp, a_iIMFz}.

var double om97_length_scale_re #

The length that positions are divided by before they enter the polynomials, in Earth radii.

The paper's r~ = r / 10 R_E, chosen so that the coefficients come out in nanotesla.

var Om97Normalization om97_normalization_published #

Table 2 of the paper, exactly as printed: Dst -17 / 25, p 2.2 / 1.9, Kp 2.3 / 1.3, IMFz 0.0 / 3.7.

This is the default normalization everywhere in this header, because it is the one a reader can check against the source.

Unit testIrbemOm97.PublishedNormalizationIsTable2
var Om97Normalization om97_normalization_measured #

The normalization IRBEM's kext = 8 is MEASURED to use — a black-box recovery, not a citation.

The paper prints Table 2 to two significant figures; the reference implementation evidently carries the dataset's means and dispersions to more. These eight values are what tools/oracle/ostapenko_diff.cpp recovers by least squares from the oracle's response — its 17 amplitude intercepts and 68 driver slopes, each measured to 1e-12 — under the model "the published `a_ik`, with the eight normalization scalars free". The fit's residual is what says the model is right: it is at the rounding of the two-decimal a_ik table and not above it. The recovered digits are quoted to the precision that fit supports and no further.

Use this when the requirement is agreement with IRBEM; use om97_normalization_published when the requirement is the paper. The difference is 1-2% of the external field.

Unit testIrbemOm97.MeasuredNormalizationIsCloseToThePublishedOne
var std::array< Om97Row, om97_harmonic_count > om97_relation_coefficients #

Table 4 of the paper: the 85 relation coefficients a_ik, in nanotesla, row i - 1 for harmonic i.

Columns are the paper's: the constant term, then the Dst, p, Kp and IMF Bz regressors on the NORMALIZED parameters.

Transcribed from the paper and then checked two ways that cannot both pass by accident: the paper's own Figure 5 profiles (the near-Earth B_z depression deepens with negative Dst and compresses with pressure — IrbemOm97.EquatorialBzFollowsThePapersProfiles), and the black-box differential against IRBEM described in the file brief, which recovers every column to its printed precision once the normalization is accounted for.

Unit testIrbemOm97.RelationCoefficientsAreTable4
var Om97FittedRegion om97_fitted_region #

The fitted region, as the paper states it.

Unit testIrbemOm97.FittedRegionIsThePapers
var std::size_t om97_param_count #

How many float scalars the device kernel's parameter buffer holds: sin(psi), cos(psi), then the 17 amplitudes.

Asserted against the kernel registry by the suite.

var T89FixedParameters t89_fixed #

The fixed parameters, exactly as Tsyganenko (1989) §3 and §4 state them.

Unit testIrbemT89.FixedParametersAreThePublishedOnes
var std::size_t t89_linear_count #

How many coefficients eq. (12), (18) and (20) between them carry: C_1 .. C_19.

var std::size_t t89_bin_count #

How many Kp bins the API carries.

Seven, because that is what t89_kp_bin returns and what the published iopt interface takes — see t89_coefficient_sets for what fills bin 7.

var std::size_t t89_published_set_count #

How many DISTINCT coefficient sets Tsyganenko (1989) Table 1 publishes. Six, not seven.

var std::array< T89Coefficients, t89_bin_count > t89_coefficient_sets #

Tsyganenko (1989) Table 1: the fitted parameters, indexed by bin - 1.

The columns are, in order, Kp = {0, 0+}, {1-, 1, 1+}, {2-, 2, 2+}, {3-, 3, 3+}, {4-, 4, 4+} and Kp >= 5-.

The seventh entry is a stated gap, not a seventh column. The published table has six columns; the seven-bin split that t89_kp_bin implements — with {5-, 5, 5+} and {>= 6-} separated — belongs to the post-publication "T89c" revision, whose coefficients appear only in Tsyganenko's GPL-3.0 source and are therefore unavailable to this MIT clean-room implementation. Bins 6 and 7 consequently carry the SAME published Kp >= 5- set, which is exactly what the 1989 model says about Kp >= 5- and is not an extrapolation of anything. A caller evaluating at Kp >= 6 gets the most disturbed published parameterization, and t89_bin_is_published says so.

Every number here is transcribed from the paper and then checked, not trusted: the paper states that C_16 .. C_19 are not free but are determined from C_6 .. C_15 and dx by div B = 0 applied to eq. (20), which is four linear identities per bin. All 24 hold to better than 1.5e-3 — see IrbemT89.PublishedCoefficientsAreDivergenceFree, which is a transcription check with no way to pass by accident.

Unit testIrbemT89.PublishedCoefficientsAreDivergenceFree IrbemT89.BinSevenRepeatsTheMostDisturbedPublishedSet
var std::size_t t89_param_count #

How many float scalars the device kernel's parameter buffer holds: sin(psi), cos(psi), C_1..C_19, then the nine non-linear parameters.

Asserted against the kernel registry below.

var double host_step_ratio #

The measured optimal step ratio for a difference of double field values, as a fraction of the geocentric radius.

h = r · 5 × 10⁻⁸ sits in the trough between truncation (~2h/r) and fp64 cancellation (~ε₆₄·r/h). Measured max / median relative error on ∇|B|, 60 points over r = 1.0511.5, against a Richardson-extrapolated reference: 1.1 × 10⁻⁷ / 7.3 × 10⁻⁸.

var double device_step_ratio #

The measured optimal step ratio for a difference of float field values.

Eight thousand times larger than host_step_ratio, and that factor is √(ε₃₂ᵉᶠᶠ/ε₆₄) and nothing else — where ε₃₂ᵉᶠᶠ is the 7.3 × 10⁻⁷ the fp32 kernel actually delivers on |B|, an order of magnitude above a single fp32 rounding because the harmonic sum runs over 105 terms. Assuming 6 × 10⁻⁸ instead lands the step three times too small and costs a factor of twenty in the answer, which is how this constant was found.

The ratio is flat across the belt: the per-shell sweep in the file brief puts the optimum between 2.5 × 10⁻⁴ and 5 × 10⁻⁴ from r = 1.2 to r = 10, with the achievable error a nearly constant 1.21.5 × 10⁻³ throughout.

var std::size_t field_batch_crossover #

The batch size at or above which field_batch's device lane is measured to win.

gpu/dispatch.hpp's registry records 128 for irbem_igrf_f32, derived from the kernel's own throughput and an assumed ~30 µs submit floor. This launcher's measured floor is ~115 µs — it also packs two coefficient tables, acquires five buffers and does four uploads — so the routine's crossover is four times the kernel's. Measured: 0.33× at 128 points, 0.62× at 256, 1.22× at 512, 2.39× at 1 024. Using the registry's number unmodified would make every batch between 128 and 450 points slower than the host, which is precisely the failure the per-kernel crossover exists to prevent — so it is recorded here rather than papered over.

var std::size_t bderivs_batch_crossover #

The batch size at or above which bderivs_batch's device lane wins.

A quarter of field_batch_crossover, because each point is four field evaluations and they all go in ONE dispatch: 128 points are 512 evaluations, and ~512 evaluations per dispatch is where this seam breaks even. Measured: 0.64× at 64 points, 1.27× at 128, 2.49× at 256, 8.44× at 1 024, 26.1× at 2¹⁴.

var std::size_t hemisphere_batch_crossover #

The batch size at or above which hemisphere_batch's device lane wins.

Between the other two, and for a reason worth naming: three evaluations per point would put it at ~171, but is not known until the first dispatch returns, so this routine pays the ~115 µs floor twice. Measured: 0.51× at 128 points, 0.97× at 256, 3.76× at 1 024, 25.4× at 2¹⁴.

var std::size_t frame_count #

The number of frames — the bound of any loop over Frame.

var bool is_igrf_v #

Whether M is an Igrf instantiation — the trait the drift-shell machinery uses to decide whether its DEVICE fast-paths apply.

The device kernels stage one internal model's coefficients; a composed model (internal plus external) must take the host lane for the stages that have no combined kernel yet, and if constexpr (is_igrf_v<M>) is what routes that at compile time instead of a runtime branch that would still have to compile the staging for types it cannot stage.

var bool is_igrf_v< Igrf< NMAX, P > > #

The specialization that answers true: exactly the Igrf instantiations, at any degree and precision policy, and nothing else — a composed model must not match, since matching is what routes a model into device paths that stage only an internal field.

var double unit_roundoff #

The unit roundoff of T: the largest relative error a single correctly-rounded operation can introduce, u = ε/2 for round-to-nearest.

This is the 6 × 10⁻⁸ row of ERROR_BUDGET §3 for float (2⁻²⁴ = 5.96 × 10⁻⁸). Note the distinction from std::numeric_limits<T>::epsilon(), which is the spacing 2⁻²³ — twice this. Bounds below are stated in u, per Higham, Accuracy and Stability of Numerical Algorithms (2nd ed., §2.2), which is the convention that makes "one operation" and "n operations" comparable.

Template parameters
T

the floating-point format.

var bool accumulates_in_double #

Whether P accumulates in double — the invariant of this header, stated as a trait so a downstream static_assert can name it directly.

Template parameters
P

a Precision policy.

var std::uint32_t status_count #

How many enumerators Status has — the bound the wire decoder checks against.

var double baddata #

A Result is exactly its payload plus one alignment unit of tag — the size claim the GPU lane rests on, checked here for the three payloads that actually cross the device boundary.

IRBEM's "no answer" sentinel, -1e31.

Kept for one reason: a caller coming from IRBEM's C, Fortran, IDL or Python bindings tests against this number, and the C-compatible boundary of this library must not break them. Note that 1e31 is not exactly representable in binary64 (10^31 = 2^31 * 5^31, and 5^31 needs 73 significant bits), so this constant is the nearest double to -10^31 — the same rounding IRBEM's own -1d31 literal gets, which is why an equality test against it is nonetheless exact.

Unit testIrbemStatus.BaddataIsTheIrbemSentinel
var std::size_t model_count #

How many kext keys the envelope table covers — 0..14, which is IRBEM's whole range.

var std::size_t max_model_bounds #

The largest number of drivers any one model reads — TS05 reads ten (Dst, Pdyn, By, Bz, W1..W6).

var double unbounded_above #

Positive infinity, spelled once so the table below reads as a table.

var double unbounded_below #

Negative infinity, likewise.

var DriverBound kp_bound #

Kp's bounds as the maginput vector carries it.

Every Kp-driven model publishes its range as 0 <= Kp <= 9, but IRBEM's slot 1 holds Kp x 10 (general_information.rst, "Magnetic field inputs": "consistent with OMNI2, this is Kp*10, and it is in the range 0 to 90"). The scaling is applied once, here, so no model row has to remember it.

Unit testIrbemStatus.KpBoundsAreInOmniScaling
var std::array< ValidityEnvelope, model_count > validity_envelopes #

The envelope table, indexed by kext.

Every number is quoted from IRBEM docs/source/api/general_information.rst, "External magnetic field model", which restates each model's published range; the per-row citation names the paper that range comes from. Rows whose drivers are listed with infinite bounds are the ones that table explicitly declares unbounded, or for which the row states a driver list and no limits at all.

Known gaps, stated rather than filled in:

  • OM97 (kext 8) and A2000 (kext 12): the table names the drivers each reads and publishes no ranges, and no ranges are given in the sources this library is allowed to read. Their entries are therefore all-unbounded.

  • T01storm (kext 10) and TS05 (kext 11): the table says in so many words that "there is no upper or lower limit for those inputs".

  • TS07D (kext 13): the table gives neither drivers nor limits. The model's own paper (Tsyganenko & Sitnov 2007) parameterizes it by Pdyn plus a per-interval coefficient set, so Pdyn is listed unbounded and needs_coefficient_files is set; IRBEM provisions those files with its setup_ts07d_files.sh, which fetches a Coeffs/ and a TAIL_PAR/ directory.

  • MeadT (kext 14): an IRBEM-specific refit with no separate publication, so it inherits only the Kp range its Mead form is defined over and states no spatial limit.

    Unit testIrbemStatus.EnvelopeTableMatchesTheIrbemKextTable
var ValidityEnvelope unknown_envelope #

The envelope handed back for a kext outside 0..14: no drivers, no limits, and never reached through check_validity, which rejects an unrecognised key before it looks anything up.

var double min_r_geo #

The smallest geocentric radius that is unambiguously above the ground, in Earth radii: the WGS84 polar semi-minor axis over the equatorial semi-major axis, 6356.752314245 / 6378.137.

A point below this radius is inside the solid Earth at every latitude, so rejecting it needs no geodetic conversion and cannot reject a legitimate low-altitude polar point by mistake. Between this and 1 Re a point may be above ground (near the poles) or below it (near the equator); deciding which is the geodetic module's job, and this check deliberately does not attempt it.

WGS84 axes per NIMA TR8350.2, 3rd ed. (2000), §3.2.

Unit testIrbemStatus.PositionInsideTheEarthIsADomainError
var std::size_t irbem_max_path_points #

IRBEM's posit(3,3000) cap — the largest path its Fortran entry points can return, and the default ceiling here so a caller porting from it needs no new number.

var std::int64_t gregorian_reform_jdn #

The Julian day number at which the Gregorian calendar takes over: 1582-10-15, the day Pope Gregory XIII's reform declared should follow 1582-10-04.

Types

enum std::uint8_t Driver #

A solar-wind / geomagnetic driver — an index into the maginput vector.

The names, order and units are IRBEM's maginput table (IRBEM docs/source/api/ general_information.rst, "Magnetic field inputs"), because that is the vector every caller already has and every external field model is parameterized by. IRBEM numbers the table from 1; these enumerators are zero-based, so an enumerator is the C array subscript directly and the off-by-one lives in exactly one place — this comment.

Slots 18..25 of the table are reserved and have no enumerator; they are still carried, still validated, and reachable through FieldContext::drivers.

enum std::uint8_t ContextError #

Why a set of inputs cannot form a FieldContext.

IRBEM's baddata = -1e31 sentinel is kept at the outer API boundary for compatibility and nowhere else: inside the library a failure carries a reason, so a wrong answer cannot be mistaken for a number and a rejected build says which input was wrong.

enum std::uint8_t GmstModel #

Which published series to use for Greenwich Mean Sidereal Time.

They differ by roughly two arcseconds near J2000 — irrelevant to any tolerance in the error budget, but not irrelevant when reproducing another implementation bit for bit, which is the only reason the choice is exposed.

enum std::uint8_t DifferenceLane #

Which arithmetic a finite difference will be taken in — the argument auto_step needs and the one thing a caller cannot infer from the step alone.

Not a lane selector: bderivs_batch still decides where to run from the batch size and the measured crossover. This says which precision the differenced values will have arrived in, which is what moves the optimal step by four orders of magnitude (see the file brief).

enum std::int8_t Hemisphere #

Which magnetic hemisphere a point lies in — IRBEM's xHEMI, with its integer values.

"Magnetic" and not geographic: the boundary is the magnetic equator of the field line through the point, which at the surface is displaced by up to ~15° from the geographic equator and, in the South Atlantic Anomaly, is somewhere a geographic test would not put it at all.

enum std::uint8_t FrameKind #

How a frame's three components are to be read.

The distinction is load-bearing: radius() means something for a spherical frame and nothing for a Cartesian one, so the accessors below are constrained on it rather than left to a comment.

enum std::uint8_t Frame #

A reference frame.

The first nine enumerators carry IRBEM's sysaxes codes as their values, so the boundary conversion in frame_from_sysaxes is a range check rather than a table. The heliospheric three have no sysaxes code — IRBEM reaches them through dedicated routines — so they are numbered above the sysaxes range and sysaxes_of reports them as absent.

enum std::uint8_t Status #

Why a computed value may not be trustworthy — the whole failure vocabulary of this library.

The enumerators are ordered so that Ok == 0 and the underlying values are the wire encoding the GPU lane writes (status_code). Appending is safe; renumbering is an ABI break.

The distinction that matters is between the physical conditions and the input one. OpenFieldLine and NotConverged are outcomes of a computation on well-formed input; OutOfValidityRange says the input is well-formed and outside the fit; DomainError says the input was never usable. A caller triaging a batch of points wants those three piles separate.

enum std::uint8_t ExternalModel #

An external magnetospheric field model — IRBEM's kext key, with the same numbering.

The numbering is IRBEM's because it is a published API: a caller migrating from IRBEM passes the integer it already has, and envelope_of indexes the envelope table with it directly. The table is IRBEM docs/source/api/general_information.rst, "External magnetic field model".

Unit testIrbemStatus.ModelNamesCoverEveryKey
enum int Hemisphere #

Which magnetic hemisphere find_foot_point should walk to.

The enumerator values ARE IRBEM's hemi_flag codes, so a caller porting from the Fortran can pass its integer through static_cast and a reader of either can check the other.

type std::array< double, driver_count > DriverSet #

The driver vector itself.

Fixed size, inline storage, trivially copyable — it rides inside FieldContext and therefore into a GPU uniform block.

type std::array< fixarray::mat3d, cartesian_frame_count > RotationTable #

The per-epoch rotations, hub-and-spoke: element cartesian_slot(F) is the matrix M_F for which v_GEO = M_F * v_F.

GEO's own slot is the identity.

This is the seam with the coordinate-transform module. That module owes exactly one function, RotationTable rotations_for(const Epoch&), plus the tilt angle its GEO->GSM construction already produces as a by-product; make_field_context takes both and its signature does not change when that function lands. Until then the table is a caller-supplied parameter, which is also what lets this file be tested against exact synthetic rotations instead of against transcendentals.

Storing nine matrices rather than all eighty-one ordered pairs is not only a size decision: GEO is the hub because the internal (IGRF) field is evaluated in GEO, so the two hops an inner loop actually makes — GSM->GEO and back — are stored outright, and only the rare cross pairs (say GSE->SM) cost a matrix product.

type MeadParameters< double > MeadCoefficients #

The fp64 reference spelling of a coefficient set.

type std::array< double, om97_regressor_count > Om97Row #

One harmonic's five relation coefficients, {a_i0, a_iDst, a_ip, a_iKp, a_iIMFz}, in nT.

type std::array< std::array< T, 3 >, om97_harmonic_count > Om97Basis #

One point's worth of basis: the 17 harmonics' three SM Cartesian components each.

type T89Parameters< double > T89Coefficients #

The fp64 reference spelling of a coefficient set.

type Policy< Exact, IrbemFaithful > OraclePolicy #

The lane the differential suite runs: IRBEM's algorithm in fp64, so a disagreement with the oracle is attributable to neither arithmetic nor resolution.

type Policy< Exact, Improved > ReferencePolicy #

The CPU production lane: the improved algorithm in fp64.

The tightest answer this module has, and what the GPU lane is measured against (ERROR_BUDGET §6 — the GPU lane cannot carry committed bit-goldens, because FMA contraction is at the driver's discretion).

type Policy< Fast, Improved > GpuPolicy #

The throughput lane: the improved algorithm with an fp32 integrand and an fp64 ordered reduction.