Skip to content

Model comms windows

Three recipes for adding ground-station-gated comms to a scenario: build a basic single-station network, configure the upload-lag knobs, and set up asymmetric per-side knowledge.

For the operational rationale and design discussion, see Ground stations (in-depth).

1. Build a single-station network

The minimum viable comms network: one ground station, a precomputed contact schedule against the reference orbit, attached to one side of the LBG scenario.

import jax.numpy as jnp
from orbitalgym.groundstations import (
    GroundStation, GroundStationNetwork
)
from orbitalgym.groundstations.contacts import precompute_contact_schedule
from orbitalgym.reference_orbit import ReferenceOrbitState

reference = ReferenceOrbitState.from_keplerian(
    semi_major_axis_m=6878e3, eccentricity=0.0, inclination=51.6,
    raan=0.0, argument_of_perigee=0.0, mean_anomaly=0.0,
)

stations = (
    GroundStation(
        name="alaska",
        lat_deg=jnp.asarray(64.8),
        lon_deg=jnp.asarray(-147.7),
        altitude_m=jnp.asarray(150.0),
        elevation_mask_deg=jnp.asarray(5.0),
    ),
)

schedule = precompute_contact_schedule(
    stations=stations,
    reference_orbit=reference,
    epoch_mjd_utc=60067.0,
    horizon_s=3 * 3600.0,
)
network = GroundStationNetwork(stations=stations, schedule=schedule)

# Attach to the scenario:
from orbitalgym.games.lady_bandit_guard import make_lady_bandit_guard
cfg = make_lady_bandit_guard(
    n_guards=1, n_bandits=1,
    guard_ground_station_network=network,
)

precompute_contact_schedule runs brahe once at construction time to find every (station, satellite) access window over horizon_s. The runtime predicate in_contact_now is then pure JAX — no brahe at tick time.

2. Configure replan_contacts_lag and upload_delay_s

PlanCachePolicy wraps any inner planner and gates uploads on the contact schedule. The two knobs that shape its behaviour:

replan_contacts_lag 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 Meaning
0.0 (default) Plan uploads at the start of contact.
300.0 Plan uploads 5 minutes after contact entry (handshake delay).
> contact length Upload silently skipped this contact.
from orbitalgym.env.core import OrbitalGymEnv
from orbitalgym.policies.plan_cache import PlanCachePolicy
from orbitalgym.policies.mcts import MCTSPolicy  # or any inner planner

env = OrbitalGymEnv(cfg)

inner = MCTSPolicy(...)
cached = PlanCachePolicy(
    inner=inner,
    schedule=network.schedule,
    dt=cfg.dt,
    replan_contacts_lag=1,    # operational default
    upload_delay_s=300.0,     # 5-minute uplink handshake
    n_vehicles=cfg.n_bandits,
    command_cls=env.bandit_command_cls,
)

At replan_contacts_lag=N with N >= 1, the first N contacts cannot upload (no lagged belief exists yet) and the agent free-drifts until the (N+1)th contact — see Ground stations → first-contact behavior.

3. Asymmetric knowledge

Networks are wired per side, so common operational asymmetries fall out of the builder kwargs.

3a. Only one side has stations

The most common operational picture: defenders run the ground network, attackers do not. The unwired side plans continuously; the wired side is contact-gated.

cfg = make_lady_bandit_guard(
    guard_ground_station_network=None,         # guard plans continuously
    bandit_ground_station_network=bandit_net,  # bandit gated
)

3b. Different stations per side

Each side has its own network with its own stations and schedule. Useful for studying how station-geometry differences shape strategic windows.

cfg = make_lady_bandit_guard(
    guard_ground_station_network=guard_net,
    bandit_ground_station_network=bandit_net,
)

3c. Jittered opponent-model knowledge

Build the bandit's MCTS opponent_model against a perturbed copy of the guard's real network — models "I think I know where their stations are, but my model is imperfect."

import jax.random as jr

def jittered(network, key, sigma_deg=2.0):
    keys = jr.split(key, len(network.stations))
    new_stations = tuple(
        GroundStation(
            name=st.name + "_jittered",
            lat_deg=st.lat_deg + sigma_deg * jr.normal(k_i, ()),
            lon_deg=st.lon_deg + sigma_deg * jr.normal(jr.fold_in(k_i, 1), ()),
            altitude_m=st.altitude_m,
            elevation_mask_deg=st.elevation_mask_deg,
        )
        for st, k_i in zip(network.stations, keys, strict=True)
    )
    schedule = precompute_contact_schedule(
        stations=new_stations,
        reference_orbit=reference,
        epoch_mjd_utc=cfg.epoch_mjd_utc,
        horizon_s=3 * 3600.0,
    )
    return GroundStationNetwork(stations=new_stations, schedule=schedule)

bandits_belief_about_guard = jittered(guard_net, jr.PRNGKey(0))
# Use this in the bandit's MCTS opponent_model construction — the
# bandit's tree search treats the perturbed network as ground truth.

The bandit's tree search now reasons about a guard whose comms windows fire at the jittered schedule, while the actual rollout uses the real guard_net.

See also