Ground stations¶
Real space-domain operations don't get to plan continuously from a fresh
belief. Vehicles update their on-board plans only when they pass through
a ground-station contact window — moments later, that plan executes
open-loop until the next contact, with no replanning in between.
The orbitalgym.groundstations and orbitalgym.policies.plan_cache
modules model that operational dynamic explicitly: contact-windowed
comms, an intentional uplink lag between belief and plan, and per-side
asymmetry in who has visibility of whom.
What this models¶
Two operationally distinct effects:
- Contact-windowed comms. A vehicle can only receive a fresh plan while it's in line-of-sight of a ground station. Outside the window, the cached plan executes open-loop.
- Uplink lag. Even inside a contact, the freshest belief used to build the plan you're uploading might lag by one or more contacts — ops loops are not instantaneous.
Together these recover the operational picture: contact c produces a
belief; contact c+1 uplinks the plan that belief drove; the belief
gathered during c+1 lands in the ring buffer for contact c+2.
Geometry layer¶
The geometry layer is plain JAX pytrees:
GroundStation— a fixed Earth point: name, lat/lon/alt, and an elevation mask. Validates lat/lon/alt at construction time.ContactSchedule— a side's precomputed contact windows over the episode horizon. Internally a fixed-shape(N_max, 2)padded array with sentinel rows guarded by anidx < n_validmask, so the runtime predicate runs cleanly underjitandlax.scan.GroundStationNetwork— pairs the static-tuple stations with the precomputed schedule. The schedule field is filled byprecompute_contact_scheduleat scenario construction.
The brahe-backed builder
precompute_contact_schedule runs once,
at construction time, outside the JAX trace. brahe handles
EOP-aware ECI↔ECEF rotation and elevation-constraint search via
KeplerianPropagator + location_accesses. The runtime predicate
in_contact_now, in contrast, is pure JAX
— a single jnp.any over the padded windows array — so it composes
freely under jit/vmap/scan.
This split is deliberate: brahe does precise once-per-scenario geometry; JAX does cheap per-tick predicate evaluation.
Decision layer: PlanCachePolicy¶
The contact-gated policy wrapper lives in
orbitalgym.policies.plan_cache.
It wraps any inner Policy and converts continuous replanning into
contact-gated replanning with an explicit lag.
Two knobs control the lag:
replan_contacts_lag |
Operational meaning |
|---|---|
0 |
This contact's belief drives this contact's plan (no operational lag). |
1 (default) |
Last contact's belief drives this contact's plan; this contact's belief feeds next contact. |
N >= 2 |
Belief from N contacts ago drives this contact's plan. |
upload_delay_s |
Operational meaning |
|---|---|
0.0 (default) |
Plan uploads at the start of contact. |
M > 0 |
Wait M seconds inside a contact before plan upload (handshake / negotiation lag). |
> contact length |
Upload silently skipped this contact (handles short passes correctly). |
The default replan_contacts_lag=1 matches the operational picture
described above.
Open-loop execution between contacts¶
The inner planner is invoked only at upload events (typically once
per contact, after upload_delay_s, gated by the lag predicate).
Out-of-contact ticks do exactly one thing: pop the next slot from the
cached plan and emit it. There is no MCTS rerun, no classical resolve, no
heuristic re-evaluation between contacts — the wrapper executes a
stored open-loop plan.
This matters for two reasons. First, it's the right operational model:
on-orbit vehicles don't run a fresh planner at every tick. Second, it
makes the cost of replan_contacts_lag legible — at lag N, the agent
goes longer between upload events, which is exactly the regime where
the opposing side gets to exploit a stale cached plan.
First-contact behavior at replan_contacts_lag >= 1¶
The upload trigger requires contact_count > replan_contacts_lag, so
at lag N the first N contacts cannot upload — there's no lagged
belief to act on yet. The cached command stays at its initial value
(computed from the initial belief by init_state) and the agent
executes that command every tick until the (N+1)th contact, when the
first upload fires. This is the correct operational analogue of "you
can't act on data you haven't received yet".
Sequence diagram for one contact¶
t = t_entered_contact: enter contact, push belief into ring buffer
t = t_entered + delay: upload trigger (if lag satisfied) —
call inner(belief[lag], ...) → store cached_dv
t = t_entered + dt: execute cached_dv (every tick)
...
t = t_exit_contact: leave contact; continue executing the
cached_dv until next upload event
Belief fusion¶
When teammates are simultaneously in contact, their beliefs about the
opposing side can be fused into a sharper joint posterior. The
BeliefSyncFn protocol runs once per side per
tick, after the per-side belief update and before policy invocation.
Three concrete implementations ship:
KFTeamFusion— information-form pooling forKFBelief. For each opposing target, observers in contact share information state(P^-1 mu, P^-1); the pooled posterior overwrites every in-contact observer's(mu_i, P_i)for that target. Out-of-contact observers untouched. Optimal under independent observers.EKFTeamFusion— same algorithm, EKFBelief shapes; distinct registry key for serialization.PFTeamFusion— joint-resample fusion forParticleFilterBelief. Per opposing target, gather particles from every in-contact observer (excluding self-pairs), build a combined weighted cloud, and systematic-resample to K particles per in-contact observer.
Default is information-form pooling, which is optimal under independent observers. Covariance intersection (for known-correlated cases — e.g. all teammates pulling from the same operator estimate) is a follow-up under a separate registry key.
The ContactAwareBelief wrapper exposes .mean (delegated to the
inner belief) plus .contact (per-agent bool mask). Existing policies
that read only .mean are unaffected; PlanCachePolicy is the only
consumer that reads .contact.
Side asymmetry¶
Contact networks are wired per side, so four configurations come for free:
- Both sides know all stations. Symmetric model — both sides treat the same network as ground truth and both sides plan with the same lag.
- Only one side has stations. Pass
Nonefor the other side's*_ground_station_networkkwarg; that side plans continuously, the other side plays the contact-gated game. The classic operational asymmetry: defenders own the ground network, attackers don't. - Asymmetric stations. Each side has its own network with different station locations or different schedules.
- Jittered opponent-model knowledge. The opposing side's MCTS
opponent_modelis built against a perturbed copy of the real network — models "I think I know where their stations are, but my estimate is imperfect."
See the how-to for runnable code snippets for each.
What this does NOT model¶
- Intermittent station outages — the precomputed schedule is static for the episode.
- Bandwidth budgets / partial uploads — uploads are atomic; the full H-step plan transfers in one event.
- Sub-tick contact entry/exit —
in_contact_nowevaluates at the tick boundary; sub-dtwindow edges round to the nearest tick. - Mobile platforms (ship-borne, airborne) — stations are Earth-fixed lat/lon/alt.
Where to next¶
- Modeling comms windows — runnable recipes: single-station network, configuring lag, asymmetric knowledge.
examples/lbg_groundstations_delayed_planning.ipynb— full 2-guard / 1-bandit lag-sweep with rendered animations.- API → groundstations, API → plan-cache, API → belief-sync.
- Spec — design rationale and acceptance criteria.