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

The srb.core.ephemeris subsystem computes real sun/planet geometry — azimuth, elevation, distance, angular diameter, and solar-irradiance scale — for a given body-surface site and UTC epoch, using NASA/JPL’s NAIF SPICE toolkit via the spiceypy bindings. It replaces the hand-authored elevation/azimuth presets that ship with SRB’s lighting catalogs when you need geographically and temporally accurate lighting — for example, matching a specific Apollo 17 EVA timestamp or a Mars-rover landing epoch, or auditing whether a lunar polar site experiences a grazing sunrise on a given day.

The core query API and srb ephemeris CLI (Phase 1), ephemeris-driven terrain lighting (Phase 2 — a real-terrain site can bake its Sun/Earth lights from ephemeris data at a curated epoch; see Terrain lighting below), ephemeris-driven environment lighting (Phase 3 — an opt-in env.ephemeris config drives a running environment’s Sun light directly from ephemeris data, with optional per-reset epoch randomization; see Environment Wiring below), and ephemeris-driven orbit & dynamics (Phase 4 — a Domain.ORBIT circular-orbit observer, analytic eclipse, a moving sun, phase-dependent earthshine, an opt-in observation vector, and deterministic skydome co-drive; see Environment Wiring (Phase 4) below), and precomputed illumination products (Phase 5 — a kernel-free "table" provider drives the same env path from imported lunarlab/PGDA illumination rasters instead of live SPICE queries, plus PSR-aware spawn/reward consumers and epoch-window curricula; see Illumination Products (Phase 5) below) are all available today.

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

The pinned filenames, URLs, and hashes live in srb/core/ephemeris/kernels.py’s KERNEL_BUNDLE — treat that module as the single source of truth; this table is copied from it and must be kept in sync.

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 repo>/.cache/ephemerisDirectory kernels are downloaded into and read from. Override 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() dispatches to a registered EphemerisProvider. The "spice" backend (srb.core.ephemeris.spice_provider) performs live kernel queries. The "table" backend (srb.core.ephemeris.table_provider) consumes imported srb_illum/2 products without a spiceypy dependency at query time. Terrain lighting bakes still use the SPICE-oriented bridge; table products are not a drop-in terrain-lighting backend.

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 the bridge (srb/terrain/lights/ephemeris_bridge.py) runs a single query() for the site/epoch, maps the result to a LightingPreset (Sun intensity scaled by solar irradiance and a night-policy visibility ramp; color temperature and base intensity inherited from the body-default preset), and writes an epoch-keyed lights.usd into a content-hashed cache slot under .../srb_lights/ephemeris/<body>_<site>_<epoch>_<hash8>/. A changed epoch or grid-north correction yields a fresh file; identical inputs reuse one.

Grid-north convergence (γ). The Sun’s true-north azimuth from query() is folded onto the terrain’s grid north via az_grid = az_true + γ, where γ (north_convergence_deg) is read from the baked terrain’s persisted georef block (its sibling meta.json) — never recomputed. γ 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.

Flipping a site’s lighting from ephemeris back to a named static preset is config-only and produces byte-identical output to the pre-ephemeris bake — the feature is inert when unused.

Environment Wiring (Phase 3)

BaseEnvCfg.ephemeris: EphemerisCfg (srb/core/ephemeris/config.py, default EphemerisCfg(), i.e. enabled=False) drives a running environment’s shared Sun light (/World/sunlight) directly from real SPICE geometry — no terrain bake required. The field is never None — only its enabled flag gates the behavior — so the default (disabled) path never calls query(), never touches the scene graph, and is byte-identical to dev:

# 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) or utc_range (sampled):

  • 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; a mode="reset" event term (randomize_sun_ephemeris, srb/core/mdp/events.py) re-queries and re-applies the sun on every reset, drawing the epoch uniformly from the range via a dedicated np.random.default_rng(env_cfg.seed) stashed on env.unwrapped — two envs 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 honored as of Phase 4 — see Skydome co-drive below: 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 = −γ. This looks like the opposite sign from Phase 2’s terrain-lighting az_grid = az_true + γ convention, but the two are equivalent (an active/passive rotation duality) — verified both analytically and numerically. Treat the sign as settled; do not re-derive it. 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 deliberately not CLI-addressable. SiteSpec’s default is None, and the config-key extraction that backs both Hydra overrides and tab-completion (extract_defaults_from_class) only emits sub-keys for a field whose default is a populated nested config — a None default is a leaf with no children, the same reason BaseEnvCfg.ephemeris itself is declared as a non-None EphemerisCfg() default rather than None. There is therefore 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_scalestate.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).

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 now also valid — see Environment Wiring (Phase 4). 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.
  • env.ephemeris.site.* is not CLI-addressable (above); the same is true of env.ephemeris.orbit.* (Phase 4).
  • env.ephemeris.drive_skydome is now honored — see Phase 4 below.
  • env.ephemeris.bodies, expose_observations, eclipse, epoch.time_scale, and epoch.update_interval_s are all consumed as of Phase 4 — see Environment Wiring (Phase 4).
  • A real terrain’s companion lighting now reaches a running environment — arbitrated against this ephemeris sun. As of terrain v2 Phase 4 (OI-1), BaseEnvCfg._add_scenery calls RealTerrain.attach_companions() automatically, and an enabled env.ephemeris.drive_sunlight wins: the companion lighting product is suppressed with a one-time warning naming both knobs. Without an ephemeris sun, an attaching companion lighting owns the scene sun (scene.sunlight = None, static sun randomizers skipped) — which is how a manifest lighting: "ephemeris" site finally takes effect in a running env. The full precedence table lives in Terrain Stacks → Companions and the ephemeris sun.
  • Orbit mode, a moving sun, eclipse, and observation terms are Phase 4 — see Environment Wiring (Phase 4) below. Per-env suns (as opposed to the single global /World/sunlight) remain out of scope.

Environment Wiring (Phase 4)

Phase 4 wires up the three EphemerisCfg knobs that Phase 3 accepted but did not consume — eclipse, expose_observations, 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) — "earth" is auto-added to the query bodies whenever eclipse=True on a moon surface site (query_bodies), even if not already listed in env.ephemeris.bodies.
  • 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); listing other bodies (e.g. "earth") explicitly is only needed when you want their geometry beyond what eclipse auto-adds.

Moving sun

EpochSpec.time_scale (default 0.0, must be >= 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) 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 term (ephemeris_observation, srb/core/ephemeris/observations.py) is pure tensor math over the same _ephemeris_state/_ephemeris_occluder/ _ephemeris_eclipse_factor stash every ephemeris application site (config- time fixed-epoch, per-reset, and per-interval) writes onto the unwrapped env — it does not call SPICE and does not run any USD/Isaac code, so it is cheap per-step. Note it bypasses the observation-delay and non-finite-scrub wrappers that other observation terms go through (see the Task-7 review note in the Phase 4 plan) — obs["ephemeris"] is written directly into the assembled observation dict after those wrappers have already run over the rest of the terms (merged post-assembly), so an observation_delay config or the non-finite scrub applied to other terms never touches it: it is always the current-step value, and a NaN there is never scrubbed to 0.0 the way a blown-up proprio term would be.

Skydome co-drive

env.ephemeris.drive_skydome: bool (default False) is now honored on surface domains (it remains inert on Domain.ORBIT, which has no skydome). When both drive_sunlight and drive_skydome are True, 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) every time the sun itself is applied — config time, per-reset, and per- interval alike — and randomize_skydome_orientation is nulled for the same reason the sun randomizers are nulled in Phase 3 (leaving it enabled would clobber the deterministic yaw on its next interval tick). Setting drive_skydome=True without drive_sunlight=True is now 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 lines up with the real sun” is not a claim this feature makes. What Phase 4 delivers is determinism and co-motion: the same epoch always 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

Exactly like env.ephemeris.site.* in Phase 3, 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.
  • Sun direction taken from the body center, not offset for the observer’s LEO position — ~5×10⁻⁵ rad error at LEO altitudes (negligible for lighting, stated explicitly rather than silently absorbed).
  • Disk-overlap eclipse only — no atmosphere, no limb darkening, no penumbra gradient within the partial band beyond the geometric lens-area fraction.
  • Skydome co-drive is deterministic, not absolutely aligned (above).
  • Earthshine phase-scaling is terrain-lighting-only — the env path drives no earthshine light prim (above).
  • env.ephemeris.orbit.* is not CLI-addressable (above).
  • The observation term bypasses the observation-delay and non-finite-scrub wrappers (above).
  • An optional visual Earth/Moon prim in orbit mode (mentioned as a stretch goal in the Phase 4 outline) was deliberately dropped from this phase.

Illumination Products (Phase 5)

Phase 5 adds a kernel-free "table" EphemerisProvider (srb/core/ephemeris/table_provider.py, registry key "table", reserved since Phase 1) that 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. Everything downstream of query() (the env sun/skydome drive, radiometry, eclipse plumbing) is unchanged: provider="table" is just a different EphemerisState source, so the whole Phase 3/4 env path runs 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 (P5-D1/D2/D6). 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 at the site, 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 — 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), plus a --body {moon,mars,earth} flag (default moon, threaded straight into the importer’s body= kwarg) naming the body the target terrain patch sits on:

  • --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] now pulls in zarr>=2.16,<4 and numcodecs (needed only by import_lunarlab_zarr); the pyproject.toml extra and uv.lock were updated together. The dev container’s image-baked venv predates this change and does not have zarr installed today — the Dockerfile runs uv sync --frozen --extra all (Dockerfile:466), so the next image rebuild picks it up; until then, 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. if not site.is_surface: raise ValueError(...) — the table provider has no orbit-mode support (no central-body-relative geometry in the schema). As of this task, EphemerisCfg.validate() rejects provider="table" combined with env.ephemeris.orbit set at config time (srb/core/ephemeris/config.py), rather than leaving the identical rejection to the first query/reset after a Kit boot.
  • epoch_et is approximate. EphemerisState.epoch_et is filled via approx_et_seconds() (P5-D4): 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 Phase 2 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" is data-dependent, not rejected. query_bodies() still adds "earth" to the query bodies whenever eclipse=True on a Moon surface site, exactly as with "spice". Whether this then does anything depends on the loaded product: when it carries earth_azel (as lunarlab products do), TableProvider.query() builds a real per-epoch Earth BodyState (direction from the corrected az/el, angular diameter from a fixed Earth mean distance) and the eclipse factor computes normally; when it does not (e.g. a PGDA-only or static-only product — although a static-only product cannot back "table" at all, above), the Moon-site occluder lookup silently returns None and the eclipse factor stays pinned at 1.0 for the whole run. A warning is emitted once per (body, provider) the first time this happens (srb.core.ephemeris.env_support.warn_eclipse_occluder_missing_once, called from _apply_sun_state in srb/core/mdp/events.py) — the analogous Mars/Earth “no occluder modeled” case (Phase 4) already warned once via its own occluder_body is None guard, which never fires here because surface_occluder_body("moon") is "earth" (not None); this is a second, independent warn-once path for exactly that gap. The factor still pins at 1.0 regardless — the warning is diagnostic only. This was investigated for this task and left as a documented limit rather than a blanket EphemerisCfg.validate() rejection: rejecting eclipse=True outright for every provider="table" config would also block the working, intended case (a lunarlab product that does carry Earth geometry) — the config layer has no way to inspect the product file’s contents (it is resolved lazily, independently of EphemerisCfg construction), so there is no config-time signal to gate on. Import a product with Earth geometry if a working table-provider eclipse is wanted; otherwise expect a once-warned no-op.

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 is not wired to any reward/observation consumer as of this phase — 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 (Phase 5 Task 6) 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 with no conversion — this is pinned by a dedicated shape- compat unit test (test_epoch_windows_shape_compat_with_sunlit_epoch_windows, tests/unit/test_ephemeris_config.py). Note (Task 6 reviewer finding): EpochSpec.time_scale > 0 (the Phase 4 moving-sun knob) 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

  • The precomputed sidecar is not part of CacheKey (srb/terrain/cache.py). Re-importing a product over an existing illumination.npz at a fixed cache key is invisible to the terrain bake cache — this is by design (the sidecar is a separate out-of-band artifact, not a bake input), but it means a stale sidecar is not detected or invalidated automatically the way a changed DEM/colour stack would be.
  • Per-env location variety (K>1) uses the primary location’s sidecar only. RealTerrain.illumination_product_path (srb/assets/scenery/real_terrain.py) always derives from primary_baked_usd_path — the index-0 / requested-center child of a multi-location variant set (Terrain v2 Phase 5a) — never from any of the other K−1 per-env locations. A masked-spawn/reward term therefore reads the same illumination raster regardless of which of the K locations a given env actually landed on.
  • Leap-second caveat for imported ladders. approx_et_seconds’s fixed 69.184 s TT−UTC offset (above) is only valid for epochs from 2017-01-01 onward; an imported product with pre-2017 epochs, or any future epoch after a new leap second is inserted (none since 2016-12-31, per naif0012.tls’s own “current through the last leap second” caveat in the kernel bundle table), needs this constant revisited.
  • earth_los semantics are inherited, not redefined. As noted in the decode-gotchas table above, lunarlab’s earth_los is a DSN-union visibility mask, not strict geometric line-of-sight from the site point; SRB imports and samples it as-is.
  • eclipse=True + provider="table" is data-dependent (above) — works when the product carries Earth geometry, otherwise no-ops (factor pinned at 1.0) with a warning emitted once per (body, provider) at first application. provider="table" + env.ephemeris.orbit set is rejected at config time (above); the table provider has no orbit-mode support.
  • The terrain-lighting bake path (lighting: "ephemeris" in the terrain manifest) remains SPICE-only — it always queries with the default provider="spice"; Phase 5’s "table" provider is env-path-only.
  • The epoch/time-cube tier has no reward/observation consumer yet. illumination_fraction_at/the masked-spawn term both read only the static tier (psr/lit_fraction/max_sun_el); nothing in srb/core/mdp/illumination.py (or elsewhere) samples the per-epoch sunlit/sun_fraction cube for a reward/observation term — that epoch-indexed data is consumed only by the "table" provider’s own query() (to synthesize sun geometry) and by sunlit_epoch_windows (to derive utc_windows), both at the config/curriculum level, not per-step. A future per-step “instantaneous predicted illumination at time t” reward is deliberately deferred.

Memory and Threading Contract

  • spiceypy is imported in exactly one place: spice_provider.py. No other module in SRB — core, CLI, tests outside the ephemeris suite, or otherwise — should import spiceypy directly. This keeps srb --help, tab-completion, and parser construction Isaac-free and import-light, and keeps the optional dependency isolated to a single, easily-mocked seam.
  • SPICE’s C kernel pool is global, mutable, and not thread-safe across independent furnish/query cycles. SpiceProvider serializes access with a module-level lock and furnishes the kernel set at most once per process (_FURNISHED guard) — repeated query() calls reuse the already-loaded pool rather than re-furnishing.
  • Forked worker processes (e.g. pytest-xdist, multiprocessing data loaders) each inherit — or, after an actual fork(), share low-level SPICE state in ways that are not safe to rely on. Call srb.core.ephemeris.spice_provider._reset_for_tests() (clears SRB’s _FURNISHED flag so the next query re-furnishes) and/or spiceypy.kclear() (clears the underlying CSPICE kernel pool itself) at the start of any new worker/process that will use the ephemeris subsystem, and in test fixtures that need a clean furnish state between cases.