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:
| File | Purpose | SHA-256 |
|---|---|---|
naif0012.tls | Leap-second kernel (current through the last leap second, 2016-12-31) | 678e32bdb5a744117a467cd9601cd6b373f0e9bc9bbde1371d5eee39600a039b |
pck00011.tpc | Planetary constants (radii, orientation models, IAU_MARS/IAU_EARTH frames) | 3dff7b1dbeceaa01f25467767d3fa25816051c85d162d1edf04acb310ee28bb1 |
de440s.bsp | Planetary/lunar ephemeris (positions of Sun, Earth, Moon, Mars barycenter), 1849–2150 | c1c7feeab882263fc493a9d5a5b2ddd71b54826cdf65d8d17a76126b260a49f2 |
moon_pa_de440_200625.bpc | Lunar orientation (principal-axis, MOON_ME frame) | 60cd55aa401ea2ea97360636f567554bfe4e37bb829f901b4460a455dfaf783f |
moon_de440_250416.tf | Lunar 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
| Variable | Default | Purpose |
|---|---|---|
SRB_EPHEMERIS_CACHE_ROOT | <SRB repo>/.cache/ephemeris | Directory 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_FETCH | 1 (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_ID | unset | Hugging 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 terrainprints the site’s Sun geometry as the raw true-north azimuth straight from the ephemeris (sun=EL°@AZ° (ephemeris @ <utc>, true-north)), whereas the bakedlights.usdfolds in+γ. The two match only whereγ = 0(e.g.shackleton_rimat 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_ephemerisqueries once, at config time, and builds/World/sunlight’s orientation (vialight_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; amode="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 dedicatednp.random.default_rng(env_cfg.seed)stashed onenv.unwrapped— two envs built with the same seed draw the same epoch sequence. A wide range can silently produce black episodes.sun_light_paramszeroesintensityoutright once the sun is at or below the horizon (sun_visibility_factorreturns exactly0.0below-angular_radius), so autc_rangespanning 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: narrowutc_rangeif 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:
- An explicit
env.ephemeris.site— always wins (and itsbodymust matchenv.domain’s body, or building the env raises). - A real-terrain scenery: if
env.sceneryresolves to aRealTerrainwhose curated manifest entry declares asiteand whosebodymatchesenv.domain, that site’scoordsbecome the observer location, and the terrain’s baked grid-north convergenceγ(persisted in itsgeorefmetadata) is folded in asSiteSpec.north_yaw_deg = −γ. This looks like the opposite sign from Phase 2’s terrain-lightingaz_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 whosebodydisagrees withenv.domainis ignored (with a warning), never silently relabeled onto the wrong body. - Otherwise, a per-
Domaindefault (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_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).
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.ORBITis now also valid — see Environment Wiring (Phase 4).Domain.ASTEROIDremains a config error (no ephemeris body exists for it). - Setting both
env.sunlight_rpyand an enabledenv.ephemerisis a config error — both would drive the sun’s orientation. env.ephemeris.site.*is not CLI-addressable (above); the same is true ofenv.ephemeris.orbit.*(Phase 4).env.ephemeris.drive_skydomeis now honored — see Phase 4 below.env.ephemeris.bodies,expose_observations,eclipse,epoch.time_scale, andepoch.update_interval_sare 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_scenerycallsRealTerrain.attach_companions()automatically, and an enabledenv.ephemeris.drive_sunlightwins: 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 manifestlighting: "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):
| Field | Default | Meaning |
|---|---|---|
altitude_m | required | Circular-orbit altitude above the body’s mean radius, meters. Must be > 0. |
inclination_deg | 0.0 | Orbital inclination, degrees. Must be in [0, 180]. |
raan_deg | 0.0 | Right ascension of the ascending node, degrees. |
arg_lat_deg | 0.0 | Argument 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 querybodieswhenevereclipse=Trueon a moon surface site (query_bodies), even if not already listed inenv.ephemeris.bodies. - Surface
Domain.MARS/Domain.EARTH: no occluder is modeled;eclipse=Truethere is a no-op (factor stays1.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:
| Slots | Content |
|---|---|
0:3 | Sun unit direction, rotated from world into the robot’s base frame (xyzw quaternion inverse rotate). |
3:6 | Occluder unit direction, base frame; all-zero when there is no active occluder (eclipse disabled, or a surface domain/site with none modeled). |
6:7 | Eclipse 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:
| Tier | Key | Dtype/shape | Notes |
|---|---|---|---|
| epoch (all-or-none) | sunlit | bool (T,H,W) | Per-epoch boolean line-of-sight mask. |
| epoch | sun_fraction | float32 (T,H,W), NaN holes | Continuous illuminated-disk fraction. |
| epoch | times_utc | (T,) ISO-8601 strings | Epoch axis. |
| epoch | sun_azel | (T,2) float64 | [true-north compass azimuth deg, patch-center elevation deg]. |
| epoch | sun_angular_diameter_deg | (T,) float64 | Per-epoch sun angular diameter. |
| epoch, optional | earth_los | bool (T,H,W) | Present only when the source carries Earth visibility. |
| epoch, optional | earth_azel | (T,2) float64 | Present only when the source carries Earth geometry. |
| static, per-key optional | psr | uint8 (H,W), {0, 1, 255=nodata} | Permanently-shadowed-region mask. |
| static, per-key optional | lit_fraction | float32 (H,W), NaN holes | Time-averaged illuminated fraction. |
| static, per-key optional | max_sun_el | float32 (H,W), NaN holes | Maximum 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.
| Source | Function | Notes |
|---|---|---|
lunarlab spice_maps epochs.zarr | import_lunarlab_zarr | Requires zarr/numcodecs. |
lunarlab legacy save_masks .npz | import_lunarlab_npz | No extra dependency beyond numpy. |
| PGDA product-69 LPSR GeoTIFF | import_pgda_lpsr | Requires 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-dirmode — target grid, CRS, patch center, and site lat/lon are all read from a baked Real-Terrain-v2 patch’smeta.json(target_grid_from_baked_dir); the output defaults to<baked-dir>/illumination.npzand ailluminationblock (file name, source label, epoch count/step, static keys present, import timestamp) is recorded into the same patch’s cachemeta.jsonunder acache_lock.--lat/--lonare rejected in this mode (SystemExit(2)) since the bake’smeta.jsonis 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-mmust 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:
| Gotcha | Detail |
|---|---|
| Bit-packed masks | lunarlab zarr’s sunlit/earth_los are packed along the last axis; decoded via np.unpackbits(arr, axis=-1)[..., :W]. |
| 254-scaled fraction | lunarlab zarr’s sun_fraction is uint8; decoded as q / 254.0 with sentinel q == 255 → NaN. |
| Map-grid azimuth | Source 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 elevation | The 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 semantics | Lunarlab’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 rule | PGDA 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 decode | Neither 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()raisesValueErrorfor a static- only product (“the table provider needs per-epoch sun az/el”) — a PSR-only PGDA import can drive masked spawn/reward viaillumination.py(below) but cannot itself back the"table"provider’squery(). - 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 raisesValueErrorrather 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()rejectsprovider="table"combined withenv.ephemeris.orbitset at config time (srb/core/ephemeris/config.py), rather than leaving the identical rejection to the first query/reset after a Kit boot. epoch_etis approximate.EphemerisState.epoch_etis filled viaapprox_et_seconds()(P5-D4): a fixedTT − UTC = 69.184 soffset (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 consumesepoch_ettoday (recorded, not load-bearing).- Bypasses the kernel-presence gate. The shared reset-event prologue
(
_resolve_sun_event_context,srb/core/mdp/events.py) explicitly skipskernels_present()whenephemeris_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 callsquery()with noprovider=argument, which defaults to"spice"(srb/core/ephemeris/provider.py) — a manifest site’slighting: "ephemeris"bake is not (yet) table-provider-addressable; only theenv.ephemerisenv path can use"table". eclipse=True+provider="table"is data-dependent, not rejected.query_bodies()still adds"earth"to the query bodies whenevereclipse=Trueon a Moon surface site, exactly as with"spice". Whether this then does anything depends on the loaded product: when it carriesearth_azel(as lunarlab products do),TableProvider.query()builds a real per-epoch EarthBodyState(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 returnsNoneand the eclipse factor stays pinned at1.0for 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_stateinsrb/core/mdp/events.py) — the analogous Mars/Earth “no occluder modeled” case (Phase 4) already warned once via its ownoccluder_body is Noneguard, which never fires here becausesurface_occluder_body("moon")is"earth"(notNone); this is a second, independent warn-once path for exactly that gap. The factor still pins at1.0regardless — the warning is diagnostic only. This was investigated for this task and left as a documented limit rather than a blanketEphemerisCfg.validate()rejection: rejectingeclipse=Trueoutright for everyprovider="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 ofEphemerisCfgconstruction), 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/helper | Kind | Params (non-exhaustive) | Behavior |
|---|---|---|---|
reset_root_state_uniform_illumination_masked | reset-mode event term | pose_range, velocity_range, asset_cfg; mask is "psr" (default) or "lit_fraction"; threshold defaults to 0.5; invert defaults to False; optional product_path override | Spawns 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_at | torch helper | positions_w; key is "lit_fraction" (default) or "max_sun_el"; optional product_path override | Samples 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 existingillumination.npzat 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 fromprimary_baked_usd_path— the index-0 / requested-center child of a multi-location variant set (Terrain v2 Phase 5a) — never from any of the otherK−1per-env locations. A masked-spawn/reward term therefore reads the same illumination raster regardless of which of theKlocations a given env actually landed on. - Leap-second caveat for imported ladders.
approx_et_seconds’s fixed69.184 sTT−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, pernaif0012.tls’s own “current through the last leap second” caveat in the kernel bundle table), needs this constant revisited. earth_lossemantics are inherited, not redefined. As noted in the decode-gotchas table above, lunarlab’searth_losis 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 at1.0) with a warning emitted once per(body, provider)at first application.provider="table"+env.ephemeris.orbitset 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 defaultprovider="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 insrb/core/mdp/illumination.py(or elsewhere) samples the per-epochsunlit/sun_fractioncube for a reward/observation term — that epoch-indexed data is consumed only by the"table"provider’s ownquery()(to synthesize sun geometry) and bysunlit_epoch_windows(to deriveutc_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
spiceypyis imported in exactly one place:spice_provider.py. No other module in SRB — core, CLI, tests outside the ephemeris suite, or otherwise — should importspiceypydirectly. This keepssrb --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.
SpiceProviderserializes access with a module-level lock and furnishes the kernel set at most once per process (_FURNISHEDguard) — repeatedquery()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 actualfork(), share low-level SPICE state in ways that are not safe to rely on. Callsrb.core.ephemeris.spice_provider._reset_for_tests()(clears SRB’s_FURNISHEDflag so the next query re-furnishes) and/orspiceypy.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.