Skip to main content
This tutorial walks through the complete path for a Monte Carlo orbit propagation study: submitting a dispersed batch, running it through lynx-runner with the Basilisk engine, streaming telemetry, and retrieving per-job trajectory artifacts. The scenario used here is scenario_basic_orbit_lynx.py.

What Lynx handles for you

You bring the simulation. Lynx handles everything around it.
Handled by LynxProvided by you
Run configurationsGenerated from your dispersion spec
Job distributionQueued and routed across your runner pool
RetriesAutomatic, up to the limit you set
Output directoryCreated and isolated per run
Telemetry collectionIngested and storedStreamed from your scenario (optional)
Early terminationWatchdog monitors and kills on breachStopping conditions defined in your config
Simulation physicsYour code and models
Result filesWritten to the directory Lynx provides
Lynx invokes your scenario as a subprocess. Your scenario reads its parameters, runs, writes results, and exits — that’s the full contract.

How the runner launches your scenario

For each job, the Basilisk engine plugin in lynx-runner does the following:
  1. Creates a per-job output directory
  2. Starts a gRPC telemetry server on a dynamic local port
  3. Spawns your Python entrypoint as a subprocess with these environment variables injected:
VariableDescription
TETRYX_RUN_CONFIGPath to a JSON file containing the sampled run configuration for this job
TETRYX_TELEMETRY_URLgRPC endpoint to send scalar telemetry to
TETRYX_OUTPUT_DIRDirectory where your scenario should write artifacts
Where the runner places the run configuration file on disk is an internal detail. Your scenario reads the path from TETRYX_RUN_CONFIG and does not need to know or hardcode the backing location.
  1. Waits for the process to exit
  2. If an early termination condition fires, sends SIGKILL to the subprocess immediately
  3. Collects output files from TETRYX_OUTPUT_DIR and attaches them to the job result
Your scenario does not need to communicate with the Lynx API directly. Everything flows through the injected environment.

The run configuration

TETRYX_RUN_CONFIG points to a JSON file with this shape:
{
  "run_id": 2,
  "parameters": {
    "semi_major_axis_m": 6983421.7,
    "eccentricity": 0.00094,
    "inclination_deg": 34.1,
    "raan_deg": 47.3,
    "arg_perigee_deg": 341.2,
    "true_anomaly_deg": 88.6,
    "duration_orbits": 0.74
  },
  "metadata": {
    "simulation_name": "Basic Orbit Tutorial",
    "simulation_version": "1.0.0",
    "duration_minutes": 90,
    "sampling_method": "latin_hypercube",
    "generated_at": "2026-04-09T00:00:00Z"
  }
}
The parameters keys and values come directly from the dispersions you defined in your batch config. Lynx samples them using the method you specified (Latin Hypercube Sampling in this tutorial).

The scenario

scenarios/aerospace/python/scenario_basic_orbit_lynx.py reads the run config, builds a headless Basilisk simulation, steps through the orbit, and emits scalar telemetry at each time step. Reading the run config:
import json, os
from pathlib import Path

run_config = json.loads(Path(os.environ["TETRYX_RUN_CONFIG"]).read_text())
run_id     = int(run_config.get("run_id", 0))
params     = run_config.get("parameters", {})

semi_major_axis_m = float(params.get("semi_major_axis_m", 7_000_000.0))
eccentricity      = float(params.get("eccentricity", 0.0001))
inclination_deg   = float(params.get("inclination_deg", 33.3))
# ... and so on
Streaming telemetry:
from lynx_telemetry_client import telemetry_client_from_env

client = telemetry_client_from_env()   # reads TETRYX_TELEMETRY_URL

# inside the simulation loop
client.send({
    "orbit.radius_km":         float(np.linalg.norm(r_vec) / 1000.0),
    "orbit.speed_m_s":         float(np.linalg.norm(v_vec)),
    "orbit.semi_major_axis_km": float(oe.a / 1000.0),
    "orbit.eccentricity":      float(oe.e),
    "orbit.inclination_deg":   float(oe.i * macros.R2D),
    "orbit.true_anomaly_deg":  float(oe.f * macros.R2D),
}, timestamp_ns=int(times_ns[-1]))
Telemetry is optional — if TETRYX_TELEMETRY_URL is not set the client silently no-ops. Your scenario will still run and produce artifacts. Writing artifacts: The scenario writes three files to TETRYX_OUTPUT_DIR before exiting:
FileContents
summary.jsonFinal orbital state, sample count, execution time, success flag
trajectory.csvTime-series of position and velocity vectors (one row per 10-second step)
orbit_position.pngPosition components in km over time, normalised to orbital periods
These become the output_files attached to the job result, retrievable via GET /jobs/{job_id}/results.

Dispersed parameters

The tutorial config (scenarios/aerospace/configs/basic_orbit_tutorial.yaml) disperses these orbital elements using uniform distributions:
ParameterRangeUnits
semi_major_axis_m6 950 000 – 7 050 000m
eccentricity0.00005 – 0.002dimensionless
inclination_deg30 – 36deg
raan_deg40 – 55deg
arg_perigee_deg330 – 355deg
true_anomaly_deg70 – 100deg
duration_orbits0.70 – 0.80orbits
Each job in the batch receives a unique point sampled from this 7-dimensional space via Latin Hypercube Sampling. With LHS, 50 runs give better coverage than 50 purely random draws.

Runner configuration

The runner needs to know which Python executable to use, where the scenario entrypoint lives, and where to write per-job output. These are set in the runner’s YAML config file. A minimal config for this tutorial (configs/lynx-runner-basilisk.yaml):
engines:
  - basilisk
capabilities:
  - basilisk
  - orbit
  - aerospace

working_directory: .
output_root: /tmp/tetryx-runs
python_executable: python3
basilisk_entrypoint: scenarios/aerospace/python/scenario_basic_orbit_lynx.py

max_concurrent_jobs: 1
heartbeat_interval_secs: 2
Connection credentials (the platform endpoint) are set via environment variables provided during onboarding — they are not stored in this file. All config fields can also be set via environment variables, which is the preferred approach in containerised deployments:
Environment variableEquivalent config field
LYNX_RUNNER_ENGINESengines
LYNX_RUNNER_CAPABILITIEScapabilities
LYNX_RUNNER_PYTHON_EXECUTABLEpython_executable
LYNX_RUNNER_BASILISK_ENTRYPOINTbasilisk_entrypoint
LYNX_RUNNER_OUTPUT_ROOToutput_root
LYNX_RUNNER_WORKING_DIRECTORYworking_directory
LYNX_RUNNER_MAX_CONCURRENT_JOBSmax_concurrent_jobs

Start a Basilisk runner

lynx-runner \
  --config configs/lynx-runner-basilisk.yaml \
  --runner-id basilisk-runner-01 \
  --name "Basilisk Runner 01"
Verify it registered and is advertising the right capabilities:
curl -s https://api.lynx.tetryx.io/runners/basilisk-runner-01 | jq '.capabilities'
Expected:
{
  "max_concurrent_jobs": 1,
  "supported_simulation_types": ["basilisk", "orbit", "aerospace"],
  "gpu_available": false,
  "memory_gb": 8.0,
  "cpu_cores": 4
}

Submit the batch

curl -sS \
  -X POST https://api.lynx.tetryx.io/jobs \
  -H 'content-type: application/json' \
  -d '{
    "name": "basic-orbit-tutorial",
    "runs_override": 5,
    "config": {
      "simulation": {
        "name": "Basic Orbit Tutorial",
        "version": "1.0.0",
        "duration_minutes": 90
      },
      "monte_carlo": {
        "runs": 5,
        "sampling_method": "latin_hypercube",
        "seed": 42
      },
      "dispersions": {
        "semi_major_axis_m": {
          "distribution": { "type": "uniform", "min": 6950000.0, "max": 7050000.0 },
          "units": "m"
        },
        "eccentricity": {
          "distribution": { "type": "uniform", "min": 0.00005, "max": 0.002 },
          "units": "dimensionless"
        },
        "inclination_deg": {
          "distribution": { "type": "uniform", "min": 30.0, "max": 36.0 },
          "units": "deg"
        },
        "raan_deg": {
          "distribution": { "type": "uniform", "min": 40.0, "max": 55.0 },
          "units": "deg"
        },
        "arg_perigee_deg": {
          "distribution": { "type": "uniform", "min": 330.0, "max": 355.0 },
          "units": "deg"
        },
        "true_anomaly_deg": {
          "distribution": { "type": "uniform", "min": 70.0, "max": 100.0 },
          "units": "deg"
        },
        "duration_orbits": {
          "distribution": { "type": "uniform", "min": 0.70, "max": 0.80 },
          "units": "orbits"
        }
      },
      "compute": {
        "container": {
          "image": "tetryx/basilisk-basic-orbit:latest",
          "cpu_request": "500m",
          "memory_request": "1Gi"
        },
        "max_parallel_jobs": 2,
        "job_timeout_seconds": 1800
      },
      "early_termination": {
        "enabled": false,
        "check_frequency_hz": 1.0,
        "conditions": []
      },
      "telemetry": {
        "sampling_rate_hz": 0.1,
        "variables": [
          "orbit.radius_km",
          "orbit.speed_m_s",
          "orbit.semi_major_axis_km",
          "orbit.eccentricity",
          "orbit.inclination_deg",
          "orbit.true_anomaly_deg"
        ],
        "storage": {
          "s3_bucket": "your-telemetry-bucket",
          "s3_prefix": "basic-orbit/",
          "compression": "gzip"
        },
        "buffer_flush_interval_seconds": 30
      }
    }
  }' | jq
Response:
{
  "batch_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "total_jobs": 5,
  "estimated_completion_time_mins": null
}

Monitor progress

# Poll until all jobs are terminal
curl -s https://api.lynx.tetryx.io/batches/3fa85f64-5717-4562-b3fc-2c963f66afa6 | jq '{
  status: .batch.status,
  progress: .jobs_by_status,
  throughput: .throughput_jobs_per_minute
}'
Example mid-run output:
{
  "status": "Running",
  "progress": {
    "Completed": 3,
    "Running": 1,
    "Pending": 1
  },
  "throughput": 4.2
}

Retrieve results

Once the batch status is Completed, fetch all job results:
curl -s https://api.lynx.tetryx.io/batches/3fa85f64-5717-4562-b3fc-2c963f66afa6/results | jq
Each job result looks like:
{
  "job_id": "a1b2c3d4-...",
  "success": true,
  "execution_time_secs": 34,
  "output_files": [
    "/tmp/tetryx-runs/a1b2c3d4-.../orbit_position.png",
    "/tmp/tetryx-runs/a1b2c3d4-.../summary.json",
    "/tmp/tetryx-runs/a1b2c3d4-.../trajectory.csv"
  ],
  "metadata": {
    "engine": "basilisk",
    "simulation_name": "Basic Orbit Tutorial",
    "entrypoint": "scenarios/aerospace/python/scenario_basic_orbit_lynx.py",
    "runner_id": "basilisk-runner-01"
  },
  "error_message": null
}
The output_files paths are on the runner’s local filesystem. Artifact upload to S3 (and retrieval via the API) is a planned feature.

Early termination

Early termination lets you kill a simulation mid-run when a condition is met, without waiting for the job timeout. The runner’s watchdog monitors telemetry and sends SIGKILL to the subprocess if a condition fires. To enable it in the batch config:
"early_termination": {
  "enabled": true,
  "check_frequency_hz": 10.0,
  "conditions": [
    {
      "variable": "orbit.radius_km",
      "condition": "< 6371",
      "description": "Orbit has decayed below Earth surface radius"
    }
  ]
}
The condition evaluates against the telemetry variables your scenario is emitting. If orbit.radius_km drops below 6371 km (Earth’s mean radius), the runner kills the subprocess and marks the job failed.

Adapting this scenario to your simulation

The integration contract your scenario must satisfy:
  1. Read TETRYX_RUN_CONFIG at startup — this is the path to your sampled parameter set
  2. Write artifacts to TETRYX_OUTPUT_DIR before exiting
  3. Exit 0 on success, non-zero on failure
  4. Optionally stream scalar telemetry to TETRYX_TELEMETRY_URL during execution
That’s the complete surface area. Your scenario can use any language, framework, or physics engine — as long as it respects those three required points, Lynx will orchestrate it.

What comes next

  • Connect a GitHub Actions workflow that submits a batch on push and fails CI if jobs fail
  • Expand to 500+ runs with "sampling_method": "latin_hypercube" and a fixed seed for reproducible studies
  • Add a second runner type (e.g. px4_sitl) using the same control plane and API
  • Enable artifact upload to S3 for cross-machine artifact retrieval (planned)