Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Environment Configuration — Ephemeris

SRB computes sun and planet geometry—azimuth, elevation, distance, angular diameter, and solar-irradiance scale—for a site and UTC epoch using NASA/JPL’s NAIF SPICE toolkit via the spiceypy. It supports the srb ephemeris CLI, terrain and environment lighting, orbit lighting, and precomputed illumination products.

Installation

The core srb install does not pull in spiceypy — it is an opt-in extra so that users who don’t need real ephemeris data avoid the ~30 MB wheel:

pip install 'srb[spice]'

srb[spice] is also included in the srb[all] umbrella extra. Calling into the SPICE backend without the extra installed raises srb.utils.extras.MissingExtraError with the exact pip install hint rather than an opaque ModuleNotFoundError.

Kernel Bundle

Real ephemeris queries require a set of NAIF SPICE kernels — binary/text data files describing body positions (.bsp), orientation (.bpc/.tf), leap seconds (.tls), and physical constants (.tpc). SRB pins a single ~43 MB bundle (order matches furnish order) that covers the Sun, Earth, Moon, and Mars from 1849-12-26 to 2150-01-22:

FilePurposeSHA-256
naif0012.tlsLeap-second kernel (current through the last leap second, 2016-12-31)678e32bdb5a744117a467cd9601cd6b373f0e9bc9bbde1371d5eee39600a039b
pck00011.tpcPlanetary constants (radii, orientation models, IAU_MARS/IAU_EARTH frames)3dff7b1dbeceaa01f25467767d3fa25816051c85d162d1edf04acb310ee28bb1
de440s.bspPlanetary/lunar ephemeris (positions of Sun, Earth, Moon, Mars barycenter), 1849–2150c1c7feeab882263fc493a9d5a5b2ddd71b54826cdf65d8d17a76126b260a49f2
moon_pa_de440_200625.bpcLunar orientation (principal-axis, MOON_ME frame)60cd55aa401ea2ea97360636f567554bfe4e37bb829f901b4460a455dfaf783f
moon_de440_250416.tfLunar frame kernel (MOON_ME alias definitions)a47c71e9c9f33796bdafb2c9d69a7ee447b6016ecad80f71cd6f3e479f9cf768

SRB verifies the pinned files by SHA-256 before use.

Fetching kernels

Kernels are fetched lazily on first use (SHA-256 verified, atomic download — the same srb.utils.artifact_fetch machinery used for event-camera and terrain assets) unless auto-fetch is disabled. To pre-fetch explicitly:

srb ephemeris download

Attribution and redistribution

Kernels originate from NAIF’s public archive: https://naif.jpl.nasa.gov/pub/naif/generic_kernels. NAIF’s rules page explicitly permits redistribution of unmodified kernels, which is what enables SRB’s primary mirror (a Hugging Face dataset repo, configured via SRB_EPHEMERIS_HF_REPO_ID) with the original NAIF URL always kept as a fallback so kernel access never depends on a single host. If you use ephemeris-derived data in published work, acknowledge SPICE/NAIF (Acton 1996, “Ancillary data services of NASA’s Navigation and Ancillary Information Facility”; Acton et al. 2018).

Environment Variables

VariableDefaultPurpose
SRB_EPHEMERIS_CACHE_ROOT<SRB cache root>/ephemerisDirectory kernels are downloaded into and read from (SRB cache root: SRB_CACHE_ROOT, else <SRB repo>/.cache in a source checkout or the XDG cache). Set it to share a kernel cache across checkouts or point at a pre-provisioned, read-only mount.
SRB_EPHEMERIS_AUTO_FETCH1 (enabled)Set to 0/false to disable on-demand downloading — useful for air-gapped CI. When disabled and kernels are missing, resolve_kernels() raises KernelsMissingError naming the exact srb ephemeris download command to run instead of silently reaching for the network.
SRB_EPHEMERIS_HF_REPO_IDunsetHugging Face dataset repo id (org/name) used as the primary kernel mirror, ahead of the NAIF fallback URL. Unset means requests go straight to NAIF.

CLI Usage

See the full verb reference at srb ephemeris. Two quick examples:

# Pre-fetch the pinned kernel bundle into the cache
srb ephemeris download

# Query sun/earth geometry for a Shackleton-rim-like site
srb ephemeris info --body moon --lat -89.66 --lon 0.0 \
  --utc 2026-11-01T12:00:00 --bodies sun,earth

Programmatic Usage

from srb.core.ephemeris import SiteSpec, query

site = SiteSpec(body="moon", lat_deg=-89.66, lon_deg=0.0, alt_m=0.0)
state = query(site, "2026-11-01T12:00:00", bodies=("sun", "earth"))

sun = state.bodies["sun"]
print(f"az={sun.azimuth_deg:.2f} el={sun.elevation_deg:.2f}")
print(f"irradiance_scale={state.irradiance_scale:.4f}")  # (1 AU / distance)^2

query() uses the "spice" backend for live kernel queries or the "table" backend for imported srb_illum/2 products. Table products do not require spiceypy at query time and cannot drive terrain-lighting bakes.

Terrain lighting

A real-terrain site (see Real Terrain Assets) can drive its Sun (and, on the Moon, Earthshine) lights from real ephemeris data instead of a hand-authored static preset. Set the site’s lighting companion to the ephemeris sentinel in the terrain manifest (srb/terrain/manifest.yaml); the site must also declare its surface coords and a curated epoch (UTC):

shackleton_rim:
  body: moon
  coords: { lat: -89.66, lon: 0.0 }
  lighting: ephemeris          # SPICE-driven lighting at the site epoch
  epoch: "2026-11-01T12:00:00" # required when lighting == ephemeris
  # ...

At bake time, SRB queries the site and epoch, then derives Sun intensity from solar irradiance and visibility. Color temperature and base intensity come from the body preset. Results are cached by lighting inputs.

Grid-north convergence (γ). SRB converts the queried true-north azimuth to terrain grid north using the terrain’s stored georeferencing metadata. γ is 0 for equirectangular / lon-0 sites and non-zero for polar-stereographic patches.

Provenance nuance. srb asset list terrain prints the site’s Sun geometry as the raw true-north azimuth straight from the ephemeris (sun=EL°@AZ° (ephemeris @ <utc>, true-north)), whereas the baked lights.usd folds in +γ. The two match only where γ = 0 (e.g. shackleton_rim at lon 0); for a polar-stereographic site expect the baked cast-shadow bearing to differ from the listed azimuth by γ.

Offline fallback. Ephemeris lighting needs the pinned NAIF kernels on disk. When they are absent the bridge does not query or download: with the default on_missing="fallback" it emits a single warning per (site, body, epoch) (warn_ephemeris_lighting_fallback_once) and returns the body-default static preset (lunar_default / mars_default), so an air-gapped run still bakes and renders; on_missing="error" raises KernelsMissingError instead. Run srb ephemeris download to enable the real path. srb asset list terrain shows (ephemeris @ <utc>, kernels missing) for such a site without attempting a query.

Static presets use a separate cache identity derived from the preset fields and writer version. Changing a preset creates a fresh generation; switching back to a named static preset produces byte-identical output to that preset’s existing bake.

Environment Wiring

BaseEnvCfg.ephemeris (disabled by default) drives a running environment’s shared Sun light (/World/sunlight) from its configured ephemeris provider (SPICE kernels or an imported illumination table) — no terrain bake required. The field is never None; when disabled, the default path never calls query() and never touches the scene graph:

# Fixed epoch: query once, at config/scene-build time
srb agent teleop -e _ground \
  env.ephemeris.enabled=true \
  env.ephemeris.epoch.utc=2026-11-01T12:00:00

# Sampled epoch: draw a fresh epoch (and therefore sun position) every reset,
# seeded from env.seed for reproducibility
srb agent zero -e waypoint_navigation \
  env.ephemeris.enabled=true \
  env.ephemeris.epoch.utc_range='[2026-01-01T00:00:00,2026-12-31T00:00:00]'

EpochSpec takes exactly one of utc (fixed), utc_range (sampled), or utc_windows (sampled from duration-weighted windows):

  • Fixed (epoch.utc). _add_sunlight_from_ephemeris queries once, at config time, and builds /World/sunlight’s orientation (via light_quat_xyzw) and radiometry from the result. A fixed epoch that puts the sun at or below the horizon is valid — it is simply night at that site/epoch — and only warns once rather than failing.
  • Sampled (epoch.utc_range). The config-time sun is a static placeholder; SRB queries it again at each reset, drawing the epoch uniformly from the range. Environments built with the same seed draw the same epoch sequence. A wide range can silently produce black episodes. sun_light_params zeroes intensity outright once the sun is at or below the horizon (sun_visibility_factor returns exactly 0.0 below -angular_radius), so a utc_range spanning a full day/night cycle at the resolved site — e.g. the year-long example above, which covers many lunar synodic months (~29.5 Earth days each) — yields roughly half fully dark episodes on the Moon. This is deliberate domain randomization, not a bug, but for a vision-based task it can be training-destroying if unnoticed: narrow utc_range if dark episodes are unwanted, or budget for them in the task/reward design. The first dark reset for a given site logs a one-time warning (mirroring the fixed-epoch below-horizon warning above).

Either way, /World/sunlight is a single global prim (there is no per-env sun), and all four static sun randomizers (randomize_sunlight_orientation, _intensity, _angular_diameter, _color_temperature) are disabled automatically — leaving even one enabled would let it clobber the ephemeris-derived orientation/radiometry on its next interval tick.

drive_sunlight (default true) gates whether the ephemeris drives /World/sunlight at all. drive_skydome (default false) is described under Skydome co-drive: when both drive_sunlight and drive_skydome are true, the skydome’s world yaw is co-driven deterministically from the same sun geometry, and setting drive_skydome=true without drive_sunlight=true is a config error (check_ephemeris_precedence raises) rather than a silently inert or warned-about combination.

Site resolution. env.ephemeris.site (SiteSpec | None, default None) picks the observer location. Precedence, resolved once per env build:

  1. An explicit env.ephemeris.site — always wins (and its body must match env.domain’s body, or building the env raises).
  2. A real-terrain scenery: if env.scenery resolves to a RealTerrain whose curated manifest entry declares a site and whose body matches env.domain, that site’s coords become the observer location, and the terrain’s baked grid-north convergence γ (persisted in its georef metadata) is folded in as SiteSpec.north_yaw_deg = −γ. The sign differs from the terrain-lighting az_grid = az_true + γ convention because the rotations are active in one path and passive in the other. A terrain whose body disagrees with env.domain is ignored (with a warning), never silently relabeled onto the wrong body.
  3. Otherwise, a per-Domain default (DEFAULT_SITES, srb/core/ephemeris/env_support.py): equatorial lon-0 for Moon and Earth, Jezero crater (18.44°N, 77.45°E) for Mars.

env.ephemeris.site.* is not CLI-addressable. There is no env.ephemeris.site.lat_deg=… CLI override. To pin an explicit site, either point env.scenery at a real-terrain site that already carries the coordinates you want (precedence #2 above), or set it from Python:

from srb.core.ephemeris import EphemerisCfg, SiteSpec

cfg.ephemeris = EphemerisCfg(
    enabled=True,
    site=SiteSpec(body="moon", lat_deg=-89.66, lon_deg=0.0),
)

Radiometry. /World/sunlight’s intensity is not domain.light_intensity × state.irradiance_scale — state.irradiance_scale is an absolute, 1-AU-referenced (1 AU / r)², but Domain.light_intensity is already quoted at each body’s own mean distance (Mars = 590 W/m², a surface value at ≈1.524 AU), so multiplying the two double-counts the distance (Mars would land at ≈254 W/m², 43 % of the correct value). Instead:

intensity = domain.light_intensity
          × irradiance_multiplier(body, state.irradiance_scale)
          × sun_visibility_factor(elevation_deg, 0.5 * angular_diameter_deg)

irradiance_multiplier (srb/core/ephemeris/env_support.py) normalizes irradiance_scale by the body’s own mean-distance value, so the ephemeris contributes only the variation around Domain.light_intensity’s already-correct mean — ≈±3.4 % on Earth/Moon, ≈±19 % on Mars — not an absolute rescale. Cross-checked against a real kernel query: a Mars epoch near mean heliocentric distance yields ≈591 W/m² against Domain.MARS’s 590 (0.18 % off).

This mean-distance normalization applies to surface-domain intensities. Orbit intensity is referenced to 1 AU and therefore uses state.irradiance_scale directly, including for Mars-centered orbits. Applying Mars’ surface normalization to the orbit path would count its distance correction twice.

Offline fallback. Exactly like the terrain-lighting path above, the env path never queries or downloads when the pinned kernels are absent — it only ever probes for their presence on disk. on_missing="error" fails fast: the presence probe runs at config time regardless of whether the epoch is fixed or sampled, so a doomed run raises KernelsMissingError before a multi-minute Kit boot, not after (a sampled epoch also re-probes on every reset, in case kernels vanish between config time and a later reset). on_missing="fallback" (default) instead warns once — per domain for a fixed epoch, per site body for a sampled one — and leaves the sun exactly as it already is: the built-in static default for a fixed epoch, or whatever the previous reset left it at for a sampled one (no query, no write, and the epoch RNG is not even created/advanced on a fallback reset).

Limits.

  • Ephemeris on Domain.{MOON, MARS, EARTH} is the surface path described above. Domain.ORBIT is also valid — see Orbit, Eclipse, and Observation Wiring. Domain.ASTEROID remains a config error (no ephemeris body exists for it).
  • Setting both env.sunlight_rpy and an enabled env.ephemeris is a config error — both would drive the sun’s orientation.
  • Companion lighting and ephemeris sun precedence: see the Terrain Stacks precedence table.
  • Per-env suns (as opposed to the single global /World/sunlight) remain out of scope.

Orbit, Eclipse, and Observation Wiring

This section describes eclipse, expose_observations, and drive_skydome, and extends env.ephemeris to Domain.ORBIT: a circular-orbit observer, analytic umbra/penumbra eclipse, a moving sun, phase-dependent earthshine, an opt-in observation vector, and deterministic skydome co-drive. All new numerics live in srb/core/ephemeris/orbit.py (pure numpy), with geometry additions in srb/core/ephemeris/geometry.py; the SpiceProvider backend itself is untouched.

Orbit mode (Domain.ORBIT)

EphemerisCfg.orbit: OrbitSpec | None (srb/core/ephemeris/orbit.py, default None) configures a circular Kepler orbit around the ephemeris site’s central body, with elements expressed in the central body’s J2000 axes (the scene world frame in orbit mode):

FieldDefaultMeaning
altitude_mrequiredCircular-orbit altitude above the body’s mean radius, meters. Must be > 0.
inclination_deg0.0Orbital inclination, degrees. Must be in [0, 180].
raan_deg0.0Right ascension of the ascending node, degrees.
arg_lat_deg0.0Argument of latitude (angle from the ascending node to the observer) at the reference epoch — i.e. the orbit phase at dt_s=0.

Enabling ephemeris on Domain.ORBIT requires env.ephemeris.orbit to be set — EphemerisCfg(enabled=True) with orbit=None on an orbital domain is a config error (check_ephemeris_precedence), and conversely setting env.ephemeris.orbit on any non-Domain.ORBIT domain is also a config error (an OrbitSpec left over on a surface task would silently mean nothing, so it is rejected instead of ignored). The central body defaults to "earth" (resolve_orbit_site) and is overridable via an explicit env.ephemeris.site = SiteSpec(body=..., lat_deg=None, lon_deg=None) — an orbit-mode observer has no lat/lon; a surface SiteSpec (non-None lat_deg) is rejected the same way a stray body mismatch is on the surface path.

orbit_position_j2000(orbit, body, dt_s) propagates the observer’s position analytically (mean motion n = sqrt(GM / a³), a = body_radius + altitude_m, u = arg_lat_deg + n·dt_s, rotated into J2000 by R3(-raan) · R1(-inc)); the sun’s direction at that position is taken from the existing body-center SPICE query and applied directly (no offset for the observer’s LEO position) — the resulting sun-direction error is ~5×10⁻⁵ rad at LEO altitudes, negligible for lighting/eclipse cadence but not appropriate for navigation truth. GM_M3S2/BODY_RADIUS_M (orbit.py) cover earth/moon/mars only; other bodies raise.

Eclipse

EphemerisCfg.eclipse: bool (default False) multiplies the sun’s intensity by an analytic disk-overlap eclipse factor (eclipse_factor(sun, occluder) -> float, srb/core/ephemeris/geometry.py): 1.0 for no overlap, 0.0 for total occultation, 1 − (a_occ/a_sun)² for an annular (occluder’s disk fully inside the sun’s, smaller angular radius), and the circular-lens partial-overlap area formula otherwise. The occluder depends on domain:

  • Domain.ORBIT: the occluder is the orbit’s own central body, its state synthesized analytically from the propagated observer position (central_body_state) — no extra SPICE query.
  • Surface Domain.MOON: the occluder is "earth" (real solar eclipses as seen from the Moon). When eclipse=True, the environment requests Earth as optional geometry unless it is explicitly listed in env.ephemeris.bodies. Missing optional geometry disables this eclipse contribution with a warning; an explicit Earth request remains required.
  • Surface Domain.MARS/Domain.EARTH: no occluder is modeled; eclipse=True there is a no-op (factor stays 1.0) with a one-time warning per site body — Mars’ moons and Earth’s own lunar/solar eclipse geometry from an Earth surface site are both out of scope.

env.ephemeris.bodies must always include "sun" (EphemerisCfg.validate enforces this — sun_light_params/orbit_sun_light_params always look it up). The supported request vocabulary is sun, earth, moon, mars, and venus; duplicate or unknown names are configuration errors. Listing other bodies (e.g. "earth") explicitly is only needed when you want their geometry beyond what eclipse auto-adds.

Provider availability is checked after that vocabulary check. SPICE can answer the supported bodies from its kernels. A table product always supplies the sun and may supply an Earth tier; an explicit request for a supported body absent from that product raises the typed UnsupportedCapabilityError with fallback allowed, rather than silently omitting the body. This preserves the on_missing decision for callers that choose an availability fallback while keeping an invalid body name an InvalidInputError.

Moving sun

EpochSpec.time_scale (default 0.0, must be finite and >= 0.0) turns on a moving sun: whenever time_scale > 0, an interval-mode event term progress_sun_ephemeris (srb/core/mdp/events.py, registered by BaseEventCfg._update_sunlight) fires every epoch.update_interval_s seconds (default 600.0; finite and greater than zero) of simulation time and re-applies the sun at epoch0 + time_scale * elapsed_sim_time, using the exact same sun_light_params/orbit_sun_light_params math as the fixed/sampled paths. epoch0 is the fixed utc or, under a sampled utc_range, the most-recently-reset-sampled epoch — a sampled epoch and a moving sun compose: each reset draws a new epoch0, and the interval term advances from there until the next reset. A shift that walks the epoch outside the pinned kernel bundle’s de440s.bsp coverage window (1850-01-01 to 2149-12-31) raises the same ValueError _parse_epoch always raises for an out-of-range epoch — a very large time_scale over a long episode can hit this.

Orbit + eclipse with time_scale=0 freezes the eclipse factor. Reset appliers use dt_s=0.0 for the observer’s orbital position, so without a moving sun (time_scale > 0) the spacecraft never advances along its orbit within an episode: the eclipse factor computed at reset is constant — pinned at whatever arg_lat_deg says the orbit phase is at that reset — for the entire run, rather than beating in and out of shadow as a real orbit would. Enable epoch.time_scale > 0 if an eclipse beat during the episode is wanted.

Phase-dependent earthshine

The terrain-lighting earthshine term (Moon sites; see Terrain lighting above) now scales its 200 lux base by the Earth-as-seen-from-the-Moon illuminated fraction f = (1 + cos φ) / 2, where φ is the phase angle at Earth between the Earth→site and Earth→sun directions (earthshine_fraction(sun, earth), srb/core/ephemeris/geometry.py) — full-Earth (“new Moon” from the lunar surface) yields f ≈ 1, new-Earth (“full Moon”) yields f ≈ 0, rather than the previous constant-200-lux approximation. The negative-sun-elevation clamp (max(elevation_deg, 0.0)) is also removed from the same lighting path: sun_visibility_factor already zeroes intensity below the horizon, so downstream consumers now see the true (possibly negative) elevation instead of a clamped 0.0. This earthshine term lives only in the terrain-lighting bake path (srb/core/ephemeris/lighting.py) — the env.ephemeris env path has no earthshine light prim; only /World/sunlight is driven there.

Observation vector

env.ephemeris.expose_observations: bool (default False) adds an obs["ephemeris"] key of shape (num_envs, 7) to the Direct observation dict (srb/core/env/common/base/direct/impl.py, wired centrally — not per-task). Slot order:

SlotsContent
0:3Sun unit direction, rotated from world into the robot’s base frame (xyzw quaternion inverse rotate).
3:6Occluder unit direction, base frame; all-zero when there is no active occluder (eclipse disabled, or a surface domain/site with none modeled).
6:7Eclipse factor, broadcast scalar; 1.0 whenever eclipse is disabled.

The ephemeris_observation term is pure tensor math, does not call SPICE or USD/Isaac code, and is inexpensive per step. It bypasses observation-delay and non-finite-scrub wrappers: the value is always from the current step, and NaNs are not scrubbed.

Skydome co-drive

env.ephemeris.drive_skydome: bool (default False) is inert on Domain.ORBIT, where the Sun azimuth is undefined. When both drive_sunlight and drive_skydome are True on a surface domain, the skydome’s world yaw is set deterministically to -azimuth_sun (the sun’s reported azimuth, in the same grid-corrected frame north_yaw_deg already folds in) when the ephemeris state is applied at reset and on each moving-sun interval update. Config-time sunlight construction does not set the dome yaw. SRB disables randomize_skydome_orientation to prevent it from clobbering the deterministic yaw at its next interval tick. Setting drive_skydome=True without drive_sunlight=True is a config error (check_ephemeris_precedence) rather than a silently inert combination — the co-drive logic lives inside the shared sun-apply path, so it is unreachable without drive_sunlight.

Absolute texture alignment is not, and cannot be, guaranteed. There is no per-texture metadata recording which real-world cardinal direction a skydome texture’s features point at, so the skydome’s bright region is not guaranteed to align with the real sun. The feature provides determinism and co-motion: the same epoch yields the same dome yaw, and the dome visibly turns in lockstep with the sun (moving-sun mode, per-reset resampling) rather than staying frozen at its domain-default orientation while the sun moves independently.

CLI-addressability caveat

Like env.ephemeris.site.*, env.ephemeris.orbit.* is not CLI-addressable. OrbitSpec’s field default on EphemerisCfg is None, and the config-key extraction backing both Hydra overrides and tab-completion only emits sub-keys for a field whose default is a populated nested config — a None default is a leaf with no children. There is therefore no env.ephemeris.orbit.altitude_m=… CLI override; set it from Python:

from srb.core.ephemeris import EphemerisCfg
from srb.core.ephemeris.orbit import OrbitSpec

cfg.ephemeris = EphemerisCfg(
    enabled=True,
    orbit=OrbitSpec(altitude_m=400_000.0, inclination_deg=51.6),
    eclipse=True,
)

Limits

  • Circular-orbit approximation. No J2 (oblateness) perturbation, no drag, no eccentricity — a real LEO orbit’s node/argument-of-latitude drift and altitude decay are not modeled. This is a lighting/eclipse-cadence model, not a navigation-truth one.
  • Disk-overlap eclipse only — no atmosphere, no limb darkening, no penumbra gradient within the partial band beyond the geometric lens-area fraction.
  • Orbit mode does not provide a visual Earth or Moon prim.

Illumination Products

The kernel-free "table" EphemerisProvider (srb/core/ephemeris/table_provider.py, registry key "table") synthesizes EphemerisState from a precomputed illumination product — sun (and, optionally, Earth) geometry rasterized onto a terrain-patch grid ahead of time by an external tool (lunarlab’s spice_maps, or PGDA/USGS PSR products) — instead of a live SPICE query. The environment sun and skydome, radiometry, and eclipse handling use the same query() interface with provider="table", so they run with zero NAIF kernels.

Schema srb_illum/2

srb/core/ephemeris/products.py defines the on-disk product format: a single np.savez_compressed archive (SCHEMA_ID = "srb_illum/2", product_version = 2) holding a meta JSON string plus rasters on a north-up, axis-aligned, pixel-center- sampled grid (ProductGrid) centered on a terrain patch — the same grid convention Real-Terrain-v2 bakes use. Two tiers, at least one required:

TierKeyDtype/shapeNotes
epoch (all-or-none)sunlitbool (T,H,W)Per-epoch boolean line-of-sight mask.
epochsun_fractionfloat32 (T,H,W), NaN holesContinuous illuminated-disk fraction.
epochtimes_utc(T,) ISO-8601 stringsEpoch axis.
epochsun_azel(T,2) float64[true-north compass azimuth deg, patch-center elevation deg].
epochsun_angular_diameter_deg(T,) float64Per-epoch sun angular diameter.
epoch, optionalearth_losbool (T,H,W)Present only when the source carries Earth visibility.
epoch, optionalearth_azel(T,2) float64Present only when the source carries Earth geometry.
static, per-key optionalpsruint8 (H,W), {0, 1, 255=nodata}Permanently-shadowed-region mask.
static, per-key optionallit_fractionfloat32 (H,W), NaN holesTime-averaged illuminated fraction.
static, per-key optionalmax_sun_elfloat32 (H,W), NaN holesMaximum sun elevation ever reached at that cell.

Legacy srb_illum/1 archives are not readable: load_product rejects them with legacy schema 'srb_illum/1' requires explicit migration, so re-import the source product instead of loading an old sidecar.

IlluminationProduct.validate() (called by both save_product/load_product) enforces the all-or-none rule on the epoch group and rejects a product with neither tier present. The epoch tier’s sunlit/sun_fraction (and, on the grid mode below, earth_los) share one _sample() core: bilinear for float rasters (with degenerate-axis fallback for a 1×N/N×1 grid), nearest for bool/uint8 masks, PSR_NODATA (255) returned out-of-bounds for psr.

ProductGrid.patch_center_xy_m anchors patch-local (x, y) meters (x=grid- east, y=grid-north, origin at the patch center) to the grid’s absolute pixel transform; local_xy_to_rowcol_f/rowcol_to_local_xy are exact inverses of each other, which is what makes illumination_fraction_at’s round trip through env.scene.env_origins correct (below).

Azimuth/elevation conventions. sun_azel/earth_azel are stored already corrected to true-north compass azimuth and patch-center elevation — i.e. in the exact convention query()’s BodyState.azimuth_deg/ elevation_deg use, so TableProvider.query() can hand them straight to geometry.direction_world() with no further transform (only site.north_yaw_deg, the same grid-north correction the SPICE path applies, is folded in at query time). The correction from the source’s map-grid azimuth and off-center elevation to this convention happens once, at import time (below), not at query time.

Importers

srb/core/ephemeris/importers.py converts three external formats into srb_illum/2, always reprojecting onto a north-up target ProductGrid and applying the map-grid → true-north/site azimuth-elevation correction lunarlab’s center_azel convention requires: az_true = az_grid − γ (γ = grid convergence in the source CRS at its reference center, from srb.terrain.ingest.planetary_crs.north_convergence_deg) plus a curvature- ramp elevation correction for the parallax between the source grid’s own center and the target patch center (el_site = el + degrees(s_par / body_radius_m), s_par the along-azimuth component of the center-to-center offset measured in the source grid’s CRS — a cell displaced toward the sun azimuth sees a higher sun, matching lunarlab’s own masks.ramp_elevation). All heavy dependencies (zarr, rasterio, pyproj) import lazily inside functions, so importing importers.py itself stays Isaac-free.

SourceFunctionNotes
lunarlab spice_maps epochs.zarrimport_lunarlab_zarrRequires zarr/numcodecs.
lunarlab legacy save_masks .npzimport_lunarlab_npzNo extra dependency beyond numpy.
PGDA product-69 LPSR GeoTIFFimport_pgda_lpsrRequires rasterio (a base SRB dependency, not extra). Static-only (no epoch tier).

Every importer takes exactly one of two target-resolution modes (_check_target_xor_baked_dir). The lunarlab importers accept --body {moon,mars,earth} (default moon), naming the body the target terrain patch sits on. The pgda-lpsr source is Moon-only and rejects other body values:

  • --baked-dir mode — target grid, CRS, patch center, and site lat/lon are all read from a baked Real-Terrain-v2 patch’s meta.json (target_grid_from_baked_dir); the output defaults to <baked-dir>/illumination.npz and a illumination block (file name, source label, epoch count/step, static keys present, import timestamp) is recorded into the same patch’s cache meta.json under a cache_lock. --lat/--lon are rejected in this mode (SystemExit(2)) since the bake’s meta.json is the sole georef authority — they cannot be overridden alongside --baked-dir.
  • Explicit-target mode (--crs/--center-xy/--size-m/--gsd-m/ --lat/--lon/--out) — no baked patch required; useful for importing a product ahead of a bake, or onto a grid that has nothing to do with a terrain cache. --size-m must be an integer multiple of --gsd-m (SystemExit(2) otherwise, naming both values and the remainder) — a non-integer ratio would skew the patch center by a sub-pixel offset.

--epoch-range/--epoch-stride (default stride 1) are rejected (SystemExit(2)) together with --source pgda-lpsr: that product is static-only and carries no epoch axis, so the flags have nothing to apply to.

CLI examples (full reference: srb ephemeris):

# --baked-dir mode: grid/CRS/site inferred from an already-baked patch
srb ephemeris import --source lunarlab-zarr \
  --input /data/lunarlab/shackleton_240m/epochs.zarr \
  --baked-dir .cache/terrain/moon/shackleton_rim/<patch-key>

# Explicit-target mode: no baked patch, target grid given directly
srb ephemeris import --source pgda-lpsr \
  --input /data/pgda/lpsr_shackleton.tif \
  --crs "IAU_2015:30135" --center-xy 0.0,10309.969398639505 \
  --size-m 4096 --gsd-m 5.0 --lat -89.66 --lon 0.0 \
  --out /tmp/shackleton_illumination.npz

The zarr dependency. srb[spice] pulls in zarr>=2.16,<4 and numcodecs (needed only by import_lunarlab_zarr). The Dockerfile installs the frozen all-extras export into the image venv, then installs the editable project with --no-deps, so the dev container carries the locked zarr and numcodecs distributions. Outside the container, in an environment built without the extra, import_lunarlab_zarr raises a clear ImportError naming the extra rather than an opaque ModuleNotFoundError (import_lunarlab_npz/import_pgda_lpsr need no such extra — rasterio is already a base dependency).

Decode gotchas, all copied honestly from the source formats rather than silently reinterpreted:

GotchaDetail
Bit-packed maskslunarlab zarr’s sunlit/earth_los are packed along the last axis; decoded via np.unpackbits(arr, axis=-1)[..., :W].
254-scaled fractionlunarlab zarr’s sun_fraction is uint8; decoded as q / 254.0 with sentinel q == 255 → NaN.
Map-grid azimuthSource az/el are in the source grid’s own map convention, not true-north/patch-center; corrected at import time (above), not left for the caller to reinterpret.
Curvature-ramp elevationThe elevation correction for the source-grid-center → target-patch-center offset (above) is a first-order parallax approximation, not a full spherical recomputation.
earth_los semanticsLunarlab’s earth_los is a DSN-union visibility mask (true for the union of Deep Space Network station view cones), not a strict single-point Earth-center line-of-sight — SRB imports this field verbatim and does not reinterpret or rename it; treat it as “Earth-observable by some real antenna network,” not “Earth geometrically above the local horizon.”
PSR DN rulePGDA LPSR int16 rasters: PSR iff DN == 20000 exactly, nodata iff DN == -32768; a uint8 canonical input {0, 1, 255} is passed through unchanged; any other dtype raises ValueError.
No fractional PSR decodeNeither lunarlab nor PGDA’s psr/LPSR sources carry a continuous shadow-fraction value — psr is always a hard {0, 1, 255} mask, never interpolated to a probability.

Table provider

TableProvider.query() (srb/core/ephemeris/table_provider.py) resolves a product file via set_table_product(path) (wins) or the SRB_EPHEMERIS_TABLE_PRODUCT environment variable (fallback), caching at most one loaded IlluminationProduct resident at a time (mtime-keyed, so a re-imported file on disk is picked up without a process restart). It:

  • Requires the epoch tier. _load() raises ValueError for a static- only product (“the table provider needs per-epoch sun az/el”) — a PSR-only PGDA import can drive masked spawn/reward via illumination.py (below) but cannot itself back the "table" provider’s query().
  • Resolves the nearest epoch via IlluminationProduct.epoch_index() with a tolerance of half the product’s own epoch step (or 3600 s if the product has fewer than two epochs); a query epoch further than that raises ValueError rather than silently interpolating.
  • Surface sites only. The table provider has no orbit-mode support (no central-body-relative geometry in the schema). EphemerisCfg.validate() rejects provider="table" combined with env.ephemeris.orbit set at config time.
  • epoch_et is approximate. EphemerisState.epoch_et is filled via approx_et_seconds(): a fixed TT − UTC = 69.184 s offset (32.184 s + the 37 leap seconds in effect since the last leap second, 2016-12-31), valid to <2 ms error for any epoch from 2017 onward — but wrong before 2017 and requiring an update if a future leap second is ever inserted. Nothing downstream numerically consumes epoch_et today (recorded, not load-bearing).
  • Bypasses the kernel-presence gate. The shared reset-event prologue (_resolve_sun_event_context, srb/core/mdp/events.py) explicitly skips kernels_present() when ephemeris_cfg.provider == "table" — the whole point of this provider is running with zero kernels on disk, so gating it on kernel presence would defeat itself.
  • Terrain-lighting bake path stays SPICE-only. The terrain-lighting bridge (srb/terrain/lights/ephemeris_bridge.py) always calls query() with no provider= argument, which defaults to "spice" (srb/core/ephemeris/provider.py) — a manifest site’s lighting: "ephemeris" bake is not (yet) table-provider-addressable; only the env.ephemeris env path can use "table".
  • eclipse=True + provider="table" depends on the product. On a Moon surface site, Earth added only for eclipse calculation is optional. A product carrying earth_azel supplies its per-epoch Earth geometry; otherwise the eclipse factor stays 1.0 and emits a once-per-body/provider warning. Explicitly listing Earth in env.ephemeris.bodies makes it required: a product without Earth geometry then raises the existing typed availability error. Both configuration-time lighting and reset events use this required/optional distinction.

SPICE queries reuse furnished kernels within a process. Native lighting acceptance is tracked in the capability ledger.

Consumers

srb/core/mdp/illumination.py bridges the schema into two MDP-facing surfaces, both operating on the static tier only (the epoch/time-cube tier does not supply reward or observation terms — see Limits below):

Term/helperKindParams (non-exhaustive)Behavior
reset_root_state_uniform_illumination_maskedreset-mode event termpose_range, velocity_range, asset_cfg; mask is "psr" (default) or "lit_fraction"; threshold defaults to 0.5; invert defaults to False; optional product_path overrideSpawns asset_cfg at an (x, y) uniformly drawn from candidate grid cells passing the mask/threshold test (jittered ±gsd/2), with z/roll/pitch/yaw/velocities sampled exactly like reset_root_state_uniform_poisson_disk_2d. mask="psr" selects cells with psr == 1 (nodata 255 always excluded); mask="lit_fraction" selects cells with lit_fraction >= threshold (NaN excluded); invert=True complements the selection within valid cells. Raises ValueError if the candidate set is empty.
illumination_fraction_attorch helperpositions_w; key is "lit_fraction" (default) or "max_sun_el"; optional product_path overrideSamples a float static raster at world positions (converted to patch-local via env.scene.env_origins), NaN → 0.0, safe to plug directly into a reward term. Restricted to the two float rasters — psr’s out-of-bounds sentinel is 255, not NaN, so it is deliberately excluded here; sample psr directly via IlluminationProduct.sample_static for that case.

Both resolve the product via resolve_illumination_product(): an explicit product_path argument wins, else env.unwrapped.cfg._scenery.illumination_product_path (a RealTerrain property, below) — raising ValueError naming srb ephemeris import as the remediation when neither resolves.

utc_windows + sunlit_epoch_windows end-to-end (product → windows → cfg). EpochSpec.utc_windows: tuple[tuple[str, str], ...] | None is a third, mutually-exclusive alternative to utc/utc_range: a set of duration-weighted epoch windows sampled per reset by sample_epoch_windows() (a window is picked with probability proportional to max(duration_s, 1.0), then an epoch drawn uniformly inside it — a single-epoch window, start == end, gets weight 1.0 rather than 0 so it stays sampleable). srb.core.ephemeris.products.sunlit_epoch_windows() derives such a tuple directly from a loaded product — either grid mode (fraction of valid, non-NaN cells with sunlit set across the whole patch) or point mode (xy_local given — the continuous sun_fraction value at that point) — merging consecutive passing epochs into (start_utc, end_utc) windows:

from srb.core.ephemeris import EpochSpec
from srb.core.ephemeris.products import load_product, sunlit_epoch_windows

product = load_product("illumination.npz")
windows = sunlit_epoch_windows(product, min_lit_fraction=0.6)  # grid mode

cfg.ephemeris.epoch = EpochSpec(utc=None, utc_windows=windows)

The tuple’s shape ((start, end) ISO-string pairs) feeds EpochSpec.utc_windows directly. EpochSpec.time_scale > 0 (the moving-sun setting) composes with utc_windows the same way it composes with utc_range — each reset still draws a fresh epoch0 from the window set, but the moving-sun interval term then walks the epoch forward from there with no further reference to the window boundaries, so a large time_scale over a long episode can walk the applied epoch well outside the curriculum window that was actually sampled.

Limits

  • Precomputed products are separate from terrain bake inputs. Replacing illumination.npz does not invalidate an existing terrain bake.
  • Multiple terrain locations use the primary location’s product. Masked spawn and reward terms read the same illumination raster for every env.
  • Leap-second caveat. Imported epochs before 2017 or after a future leap second require an updated TT−UTC offset.
  • Reward and observation terms currently use static rasters; epoch-indexed rasters support table queries and curriculum window selection.

Memory and Threading Contract

  • spiceypy is imported only by spice_provider.py, keeping CLI help and parser construction import-light.
  • SPICE’s kernel pool is process-global and not thread-safe. SpiceProvider serializes queries and reuses the furnished pool. After a fork, SRB clears inherited state before the next query. Tests needing a clean pool must call spiceypy.kclear(); _reset_for_tests() only resets SRB bookkeeping.