Automate Satellite Ops with Yamcs

Monitor spacecraft telemetry and script autonomous operations with Yamcs.

Introduction

30 Second Summary

Satellites orbiting Earth generate thousands of data points every second. Ground teams need automated systems to monitor this data and respond instantly when something goes wrong, because by the time a human notices an anomaly, it could already be too late.

In this project, you will build a Python automation toolkit that connects to Yamcs, an open-source mission control system running a simulated spacecraft. You'll write scripts that monitor real-time telemetry, detect off-nominal conditions, and execute corrective commands autonomously.

What You'll Build

A satellite operations automation system that monitors live spacecraft telemetry and scripts autonomous commanding procedures using Python and Yamcs.

By the end of this project, you'll have:

  • A running Yamcs mission control instance with a Docker-hosted spacecraft simulation generating live telemetry.
  • A Python telemetry monitoring system with configurable alarm thresholds that detects anomalies in real time.
  • An automated anomaly response procedure that issues corrective commands and verifies their acknowledgment.
  • An operations auto-pilot sequence that executes timed command sequences with safety gates and produces an execution report.
  • Secret Mission: Build a mission log recorder and deploy a public mission report portal to Render so stakeholders can access pass data from anywhere.

Are there any prerequisites?

This project assumes basic Python proficiency and familiarity with satellite operations concepts like telemetry and commanding. You do not need prior experience with Docker or Yamcs.

Set Up Docker and Yamcs Mission Control

Yamcs is an open-source mission control framework used by ESA and commercial satellite operators. It handles telemetry processing, commanding, and mission monitoring for real spacecraft.

In this step, you'll install Docker to run a containerized Yamcs instance with a simulated spacecraft. Then you'll install the Python client library that connects your automation scripts to Yamcs.

In this step, get ready to:
  • Install Docker Desktop and verify it's running.
  • Launch the Yamcs spacecraft simulator in a Docker container.
  • Create a Python 3.10 virtual environment for your automation scripts.
  • Install the yamcs-client Python package inside the virtual environment.
Install Docker Desktop on Windows

Docker lets you run pre-packaged software images in isolated containers. You'll use it to run the Yamcs mission control server without installing Java or configuring anything manually.

  • Open the Docker Desktop download page in your browser:

https://docs.docker.com/desktop/setup/install/windows-install/

  • Click the download button to get the installer.
  • Double-click Docker Desktop Installer.exe to run it.
  • When the installer asks which installation mode you prefer, select per-user installation.
  • Make sure Use WSL 2 is selected on the Configuration page, then click Ok to continue.
  • Wait for the installation to complete.

What is WSL 2?

WSL 2 (Windows Subsystem for Linux) is a lightweight Linux kernel built into Windows. Docker uses it to run Linux containers on your machine without needing a full virtual machine.

The per-user installation mode does not require admin privileges and uses the WSL 2 backend automatically.

  • Press the Windows key to open your search bar.
  • Type Docker Desktop and press Enter to open it.

What will I see on first launch?

Docker Desktop will display the Docker Subscription Service Agreement. Select Accept to continue. Docker Desktop is free for personal use, education, and small businesses.

If Docker prompts you to enable WSL 2 features, follow the on-screen instructions. This is a one-time setup. You may need to restart your computer.

  • Wait for the Docker engine to start. You'll see the Docker Desktop Dashboard once it's ready.
  • Open PowerShell by pressing the Windows key, typing PowerShell, and pressing Enter.
  • Confirm Docker is installed by running this command:
docker --version

✔️ I see a version number

Docker is ready. The exact version number may differ from what others see, and that's fine. Move on to the next substep.

ⓧ Command not found

This usually means Docker Desktop hasn't finished starting, or the terminal was opened before Docker was ready.

  • Close PowerShell and reopen it (the system PATH updates only apply to new terminal sessions).
  • Make sure Docker Desktop is fully running. Look for the whale icon in your system tray at the bottom-right of your screen.
  • If Docker Desktop failed to start with a WSL error, restart your computer. Enabling WSL 2 for the first time requires a reboot.
  • Run docker --version again after reopening PowerShell.
Pull and run the Yamcs simulation image

The Yamcs team publishes a ready-made Docker image that bundles the Yamcs server with a spacecraft simulator. It sends simulated CCSDS telemetry packets at 1 Hz, giving you a live data stream to work with.

  • Start the Yamcs simulation container by running this command in PowerShell:
docker run -d --name yamcs -p 8090:8090 yamcs/example-simulation:5.13.0

What does this command do?

  • docker run downloads the image (if not already cached) and starts a new container.
  • -d runs the container in the background so you can keep using your terminal.
  • --name yamcs gives the container a friendly name you can reference later.
  • -p 8090:8090 maps port 8090 inside the container to port 8090 on your machine, making the web UI accessible.
  • yamcs/example-simulation:5.13.0 is the official Yamcs simulation image pinned to version 5.13.0.
  • Wait 10-15 seconds for Yamcs to start up inside the container.
  • Open your browser and navigate to http://localhost:8090.

You should see the Yamcs web interface with a simulator instance listed. This confirms Yamcs is running and generating live telemetry.

Don't see the Yamcs web interface?

  • Make sure you waited at least 15 seconds after running the command. Yamcs needs time to initialize.
  • Run docker ps in PowerShell to check if the container is running. You should see a row with the name yamcs.
  • If docker ps shows nothing, the container may have exited. Run docker logs yamcs to see what went wrong.
  • Make sure no other application is already using port 8090 on your machine.

help me troubleshoot why my Yamcs Docker container isn't starting

Create a Python 3.10 virtual environment

A virtual environment isolates your project's Python packages from your system-wide installation. This prevents version conflicts and keeps your automation scripts reproducible.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create a project folder and move into it by running these commands:
mkdir yamcs-ops
cd yamcs-ops
  • Create a virtual environment using Python 3.10 by running:
python3.10 -m venv venv

What is a virtual environment?

The venv module creates a self-contained Python installation inside a venv/ folder. When activated, any packages you install with pip go into this folder instead of your global Python installation.

This means you can safely pin yamcs-client==2.0.0 without affecting other projects on your machine.

  • Activate the virtual environment by running:
venv\Scripts\Activate

macOS and Linux activation

If you're on macOS or Linux, activate with source venv/bin/activate instead.

You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active and all pip install commands will install packages into the venv.

Seeing python3.10 is not recognized?

If python3.10 is not found, try python -m venv venv instead. Then confirm your Python version is 3.10+ by running python --version.

If your system Python is older than 3.10, download Python 3.10 from python.org/downloads and install it. After installation, use the full path or py -3.10 -m venv venv on Windows.

Help me set up a Python 3.10 virtual environment on my machine.

Install the Python Yamcs client

The yamcs-client package is the official Python library for interacting with Yamcs. It lets you query telemetry, subscribe to real-time data, and issue commands from your scripts. You'll install it inside your virtual environment so it stays isolated from your system Python.

  • With the (venv) prompt visible, install the Yamcs Python client by running:
pip install yamcs-client==2.0.0

Why pin the version?

Pinning to 2.0.0 ensures your scripts behave identically to what this project expects. Version 2.0.0 vendors protobuf internally, so you don't need to manage that dependency separately.

  • Verify the installation by running this command:
python -c "from yamcs.client import YamcsClient; print('OK')"

You should see OK printed in your terminal. This confirms Python can import the Yamcs client library.

Seeing an import error or ModuleNotFoundError?

  • Make sure you ran pip install yamcs-client==2.0.0 in the same Python environment you're using. Run pip show yamcs-client to confirm it's installed.
  • If you have multiple Python versions, try pip3 install yamcs-client==2.0.0 instead.
  • If you see a permissions error, try adding --user to the pip command.

help me fix my yamcs-client import error

Your mission control environment is ready. You have Yamcs running with a live spacecraft simulator and the Python client installed to talk to it. Next up, you'll explore the Mission Database to discover what telemetry and commands are available.

Explore the Mission Database

Your Yamcs instance is running and simulating a live spacecraft. The next step in any satellite operations role is understanding what your spacecraft can tell you and what you can tell it.

Every spacecraft has a Mission Database (MDB) that defines all available telemetry parameters and telecommands. In this step, you will write a Python script that connects to Yamcs and queries the MDB programmatically.

In this step, get ready to:
  • Connect to the Yamcs Mission Database programmatically.
  • Query telemetry parameters and read live spacecraft data.
  • List available telecommands for spacecraft control.
Create the Mission Database explorer

When an operations engineer joins a new spacecraft program, the first task is learning what telemetry is available and what commands can be sent. You will build a script that does exactly this.

  • Move to your Desktop by running this command in PowerShell:
cd ~\Desktop
  • Create a project folder and move into it by running these commands:
mkdir satellite-ops
cd satellite-ops
  • Confirm the folder exists by running:
ls

You should see the empty satellite-ops folder listed on your Desktop.

  • Open this folder in Cursor by running:
cursor .

Command not recognized?

If cursor is not recognized, open Cursor manually. Press the Windows key, type Cursor, and press Enter. Then click File, select Open Folder, and navigate to Desktop > satellite-ops.

Help me open my project folder in Cursor.

  • In Cursor, create a new file by pressing Ctrl+N.
  • Save the file immediately with Ctrl+S and name it explore_mdb.py.
  • Add the following code to import the Yamcs client and define a function that lists all space systems:
from yamcs.client import YamcsClient


def print_space_systems(mdb):
    """Print all space systems in the Mission Database."""
    print("=" * 60)
    print("SPACE SYSTEMS")
    print("=" * 60)
    for space_system in mdb.list_space_systems():
        print(f"  {space_system}")

What does this code do?

The import brings in YamcsClient, which is the entry point for all interactions with the Yamcs server.

The print_space_systems function takes a Mission Database object and iterates over all space systems. Space systems are the organizational hierarchy in an MDB. They group related parameters and commands together, similar to folders on a file system.

You will not see any output yet. The remaining functions and the main block that runs everything come next.

List telemetry parameters and available commands

The MDB contains every parameter the spacecraft reports and every command it accepts. Querying these programmatically lets you build automation around specific data points.

  • In explore_mdb.py, add the following two functions below print_space_systems:
def print_parameters(mdb):
    """Print all float parameters."""
    print("\n" + "=" * 60)
    print("TELEMETRY PARAMETERS (float type)")
    print("=" * 60)
    for parameter in mdb.list_parameters(parameter_type="float"):
        print(f"  {parameter}")


def print_commands(mdb):
    """Print all available commands."""
    print("\n" + "=" * 60)
    print("AVAILABLE COMMANDS")
    print("=" * 60)
    for command in mdb.list_commands():
        print(f"  {command}")

What do these functions do?

  • print_parameters filters the MDB to show only float-type parameters. These are the numeric telemetry values like battery voltages and temperatures that you will monitor in later steps.
  • print_commands lists every telecommand the spacecraft accepts. Each command represents an action you can trigger remotely, such as switching a voltage regulator on or off.

You still will not see output yet. The next section adds the main block that calls all your functions.

Read current values and run the explorer

Listing what parameters exist is useful, but reading their live values confirms the simulation is generating data. This function reads the current cached telemetry from the realtime processor.

  • In explore_mdb.py, add the following code below print_commands:
def read_current_values(processor):
    """Read current telemetry values."""
    print("\n" + "=" * 60)
    print("CURRENT PARAMETER VALUES")
    print("=" * 60)
    params = [
        "/YSS/SIMULATOR/BatteryVoltage1",
        "/YSS/SIMULATOR/BatteryVoltage2",
    ]
    for param_name in params:
        pval = processor.get_parameter_value(param_name)
        print(f"  {param_name}: {pval}")


if __name__ == "__main__":
    client = YamcsClient("localhost:8090")
    mdb = client.get_mdb(instance="simulator")
    processor = client.get_processor(instance="simulator", processor="realtime")

    print_space_systems(mdb)
    print_parameters(mdb)
    print_commands(mdb)
    read_current_values(processor)

What does this code do?

  • read_current_values takes a processor object and reads the live cached value of each battery voltage parameter. This confirms the simulation is actively generating telemetry data.
  • The if __name__ == "__main__": block connects to Yamcs at localhost:8090, retrieves the MDB for the simulator instance, and gets a reference to the realtime processor. Then it calls all four functions in sequence.
  • Save explore_mdb.py with Ctrl+S.
  • Open a terminal in Cursor by pressing Ctrl+` (backtick key).
  • Run the script by entering this command:
python explore_mdb.py

You should see four sections of output printed to the terminal. The SPACE SYSTEMS section shows /YSS/SIMULATOR. The TELEMETRY PARAMETERS section lists float parameters including BatteryVoltage1 and BatteryVoltage2. The AVAILABLE COMMANDS section lists telecommands including SWITCH_VOLTAGE_OFF and SWITCH_VOLTAGE_ON. The CURRENT PARAMETER VALUES section shows live voltage readings.

Seeing a connection error?

  • Make sure your Yamcs Docker container is still running. Open Docker Desktop and check that the yamcs container shows a green "Running" status.
  • If the container stopped, restart it by running docker start yamcs in PowerShell. Wait 10-15 seconds for Yamcs to initialize before running the script again.
  • Verify that port 8090 is accessible by opening http://localhost:8090 in your browser. If the Yamcs web UI loads, the server is ready.

Help me fix a connection error when running my Yamcs explorer script.

💡 Why does this matter for operations?

This script mirrors what an operations engineer does when onboarding to a new spacecraft program. Before you can monitor health or send commands, you need to know what parameters exist and what actions are available. Querying the MDB programmatically is faster and more scriptable than browsing the web UI manually.

✔️ Awesome, I've got everything!

Great. Double check you have saved explore_mdb.py and confirmed all four output sections appear when you run it.

ⓧ I'd like to double check the full code

Here is the complete explore_mdb.py file. Compare your code against this:

from yamcs.client import YamcsClient


def print_space_systems(mdb):
    """Print all space systems in the Mission Database."""
    print("=" * 60)
    print("SPACE SYSTEMS")
    print("=" * 60)
    for space_system in mdb.list_space_systems():
        print(f"  {space_system}")


def print_parameters(mdb):
    """Print all float parameters."""
    print("\n" + "=" * 60)
    print("TELEMETRY PARAMETERS (float type)")
    print("=" * 60)
    for parameter in mdb.list_parameters(parameter_type="float"):
        print(f"  {parameter}")


def print_commands(mdb):
    """Print all available commands."""
    print("\n" + "=" * 60)
    print("AVAILABLE COMMANDS")
    print("=" * 60)
    for command in mdb.list_commands():
        print(f"  {command}")


def read_current_values(processor):
    """Read current telemetry values."""
    print("\n" + "=" * 60)
    print("CURRENT PARAMETER VALUES")
    print("=" * 60)
    params = [
        "/YSS/SIMULATOR/BatteryVoltage1",
        "/YSS/SIMULATOR/BatteryVoltage2",
    ]
    for param_name in params:
        pval = processor.get_parameter_value(param_name)
        print(f"  {param_name}: {pval}")


if __name__ == "__main__":
    client = YamcsClient("localhost:8090")
    mdb = client.get_mdb(instance="simulator")
    processor = client.get_processor(instance="simulator", processor="realtime")

    print_space_systems(mdb)
    print_parameters(mdb)
    print_commands(mdb)
    read_current_values(processor)

You now have a complete map of your simulated spacecraft. You know what telemetry it reports and what commands it accepts. Next up, you will build a real-time monitoring system that watches those battery voltages live and raises alerts when they cross dangerous thresholds.

Build a Real-Time Telemetry Monitor

In the previous step, you explored the Mission Database and confirmed which telemetry parameters and commands are available on the simulated spacecraft. Now you know what data exists. The next challenge is watching that data continuously.

A real satellite operations console displays live telemetry in real time, flagging values that drift outside safe bounds. In this step, you will build a Python monitoring script that subscribes to a live stream of battery voltage updates, checks each reading against configurable alarm thresholds, and prints color-coded alerts when something goes off-nominal.

In this step, get ready to:
  • Subscribe to real-time parameter updates for battery voltages using a callback function.
  • Add configurable alarm thresholds with WARNING and CRITICAL levels.
  • Add an event subscription for situational awareness of spacecraft and ground system activity.
Create the telemetry monitoring script

The previous script queried parameters once and exited. A monitoring script needs to stay connected and react to every new reading as it arrives from the spacecraft.

What is a parameter subscription?

The yamcs-client library uses WebSocket connections to push telemetry updates to your script in real time. You register a callback function, and Yamcs calls it every time new data arrives (roughly 1 Hz for this simulator).

This is the same pattern used in production mission control dashboards: subscribe once, receive continuously, react immediately.

  • In Cursor, create a new file called telemetry_monitor.py in the same folder as explore_mdb.py.
  • Add the imports and alarm configuration by pasting this code:
import time
from datetime import datetime

from yamcs.client import YamcsClient

ALARM_THRESHOLDS = {
    "/YSS/SIMULATOR/BatteryVoltage1": {"warning": 3.5, "critical": 2.0},
    "/YSS/SIMULATOR/BatteryVoltage2": {"warning": 3.5, "critical": 2.0},
}

MONITORED_PARAMETERS = list(ALARM_THRESHOLDS.keys())

What does this code do?

  • time and datetime handle the event loop and timestamps for each reading.
  • ALARM_THRESHOLDS defines the voltage levels that trigger alerts. Any reading at or below 3.5V is a WARNING. At or below 2.0V is CRITICAL.
  • MONITORED_PARAMETERS extracts the parameter names into a flat list that the subscription API expects.
Add configurable alarm thresholds

With the configuration in place, you need two functions: one that evaluates a reading against the threshold levels, and one that processes each incoming telemetry delivery.

  • Add the threshold checking function below the MONITORED_PARAMETERS line:
def check_threshold(param_name, value):
    """Check if a parameter value exceeds alarm thresholds."""
    thresholds = ALARM_THRESHOLDS.get(param_name)
    if thresholds is None:
        return "NOMINAL"
    if value <= thresholds["critical"]:
        return "CRITICAL"
    if value <= thresholds["warning"]:
        return "WARNING"
    return "NOMINAL"

What does this code do?

check_threshold compares a parameter's current value against its defined thresholds. It checks CRITICAL first because a value that is critically low is also below the warning level. The order matters for correct classification.

  • Add the telemetry callback function below check_threshold:
def on_parameter_data(delivery):
    """Callback for telemetry updates."""
    for pval in delivery.parameters:
        timestamp = datetime.now().strftime("%H:%M:%S")
        name = pval.name
        value = pval.eng_value
        status = check_threshold(name, value)

        if status == "CRITICAL":
            prefix = "[CRITICAL]"
        elif status == "WARNING":
            prefix = "[WARNING] "
        else:
            prefix = "[NOMINAL] "

        short_name = name.split("/")[-1]
        print(f"  {timestamp} {prefix} {short_name} = {value}")

What does this code do?

  • delivery.parameters contains one or more parameter value objects pushed by Yamcs each time new data arrives over the WebSocket.
  • pval.eng_value is the calibrated engineering value (in volts for battery parameters).
  • The function checks the threshold, selects a status prefix, and prints a formatted line. This is the core of your real-time health dashboard.

💡 Why subscriptions instead of polling?

You could repeatedly call get_parameter_value() in a loop, but that creates unnecessary network overhead and may miss values between polls. The subscription model is event-driven: Yamcs pushes data to your callback the instant it processes a new telemetry packet. This is how production ground systems work.

Add an event subscription

Telemetry values tell you what the spacecraft is doing. Events tell you what the ground system is doing. Subscribing to both gives you full situational awareness.

  • Add the event callback function below on_parameter_data:
def on_event_data(event):
    """Callback for Yamcs events."""
    timestamp = datetime.now().strftime("%H:%M:%S")
    print(f"  {timestamp} [EVENT] {event.source}: {event.message}")

What does this code do?

Yamcs generates events for system-level activity (command verifications, link status changes, simulator messages). This callback prints each event with its source and message so you can see what is happening alongside the telemetry stream.

  • Add the main execution block at the bottom of the file:
if __name__ == "__main__":
    client = YamcsClient("localhost:8090")
    processor = client.get_processor(instance="simulator", processor="realtime")

    print("=" * 60)
    print("SATELLITE TELEMETRY MONITOR")
    print("=" * 60)
    print(f"Monitoring: {', '.join(p.split('/')[-1] for p in MONITORED_PARAMETERS)}")
    print("Press Ctrl+C to stop.\n")

    processor.create_parameter_subscription(
        MONITORED_PARAMETERS, on_data=on_parameter_data
    )

    client.create_event_subscription(instance="simulator", on_data=on_event_data)

    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        print("\nMonitor stopped.")

What does this code do?

  • create_parameter_subscription opens a WebSocket to Yamcs and registers on_parameter_data as the handler for new telemetry. The subscription is non-blocking.
  • create_event_subscription does the same for system events, calling on_event_data when an event fires.
  • The while True loop keeps the script alive so the background WebSocket threads continue to receive data. Ctrl+C triggers a clean exit via KeyboardInterrupt.
  • Save telemetry_monitor.py.
  • Run the monitor by executing this command in your terminal:
python telemetry_monitor.py
  • You should see a continuous stream of output updating roughly once per second, showing lines like:
============================================================
SATELLITE TELEMETRY MONITOR
============================================================
Monitoring: BatteryVoltage1, BatteryVoltage2
Press Ctrl+C to stop.

  14:32:01 [NOMINAL]  BatteryVoltage1 = 4.15
  14:32:01 [NOMINAL]  BatteryVoltage2 = 3.89
  14:32:02 [NOMINAL]  BatteryVoltage1 = 4.12
  • Let it run for 10-15 seconds to observe the data flow. The simulated battery voltages fluctuate over time, so you may see WARNING alerts if a voltage dips below 3.5.
  • Press Ctrl+C to stop the monitor.

Not seeing any output?

  • Make sure the Yamcs Docker container is still running. Run docker ps and confirm yamcs appears in the list. If it stopped, restart it with docker start yamcs.
  • Check that you saved the file before running. Python executes the saved version, not what is visible in the editor.
  • Verify you are running the correct file name: telemetry_monitor.py (not explore_mdb.py).

Help me debug my telemetry monitor

✔️ Awesome, I've got everything!

Great. Double check you have saved telemetry_monitor.py and confirmed live output before moving on.

ⓧ I'd like to double check the full code

import time
from datetime import datetime

from yamcs.client import YamcsClient

ALARM_THRESHOLDS = {
    "/YSS/SIMULATOR/BatteryVoltage1": {"warning": 3.5, "critical": 2.0},
    "/YSS/SIMULATOR/BatteryVoltage2": {"warning": 3.5, "critical": 2.0},
}

MONITORED_PARAMETERS = list(ALARM_THRESHOLDS.keys())


def check_threshold(param_name, value):
    """Check if a parameter value exceeds alarm thresholds."""
    thresholds = ALARM_THRESHOLDS.get(param_name)
    if thresholds is None:
        return "NOMINAL"
    if value <= thresholds["critical"]:
        return "CRITICAL"
    if value <= thresholds["warning"]:
        return "WARNING"
    return "NOMINAL"


def on_parameter_data(delivery):
    """Callback for telemetry updates."""
    for pval in delivery.parameters:
        timestamp = datetime.now().strftime("%H:%M:%S")
        name = pval.name
        value = pval.eng_value
        status = check_threshold(name, value)

        if status == "CRITICAL":
            prefix = "[CRITICAL]"
        elif status == "WARNING":
            prefix = "[WARNING] "
        else:
            prefix = "[NOMINAL] "

        short_name = name.split("/")[-1]
        print(f"  {timestamp} {prefix} {short_name} = {value}")


def on_event_data(event):
    """Callback for Yamcs events."""
    timestamp = datetime.now().strftime("%H:%M:%S")
    print(f"  {timestamp} [EVENT] {event.source}: {event.message}")


if __name__ == "__main__":
    client = YamcsClient("localhost:8090")
    processor = client.get_processor(instance="simulator", processor="realtime")

    print("=" * 60)
    print("SATELLITE TELEMETRY MONITOR")
    print("=" * 60)
    print(f"Monitoring: {', '.join(p.split('/')[-1] for p in MONITORED_PARAMETERS)}")
    print("Press Ctrl+C to stop.\n")

    processor.create_parameter_subscription(
        MONITORED_PARAMETERS, on_data=on_parameter_data
    )

    client.create_event_subscription(instance="simulator", on_data=on_event_data)

    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        print("\nMonitor stopped.")

Your telemetry monitor is live. You can now watch spacecraft health in real time and spot anomalies the moment they happen. Next up, you will build a script that does not just detect problems but automatically responds to them by issuing corrective commands.

Script an Automated Anomaly Response Procedure

Your telemetry monitor from the previous step watches spacecraft health and flags anomalies. In real mission operations, an operator would then need to manually assess the situation and issue corrective commands. That manual loop is slow and error-prone, especially during passes with limited ground contact time.

In this step, you will build an automated anomaly response procedure. This script polls a telemetry parameter, detects when it crosses a threshold, and autonomously issues corrective commands back to the spacecraft. It then verifies the command was acknowledged before deciding what to do next.

In this step, get ready to:
  • Create a procedure script that monitors battery voltage and issues commands on threshold breach.
  • Implement command verification logic with acknowledgment tracking and escalation.
  • Add a procedure execution log that records every action with timestamps.
Set up the procedure configuration and logging infrastructure

Before writing the anomaly detection logic, you need to define what you are monitoring, what commands are available, and how the script will record its actions. This is the same preparation an operations engineer does when writing a procedure for a new spacecraft program.

  • Create a new file called anomaly_response.py in the same folder as your other scripts.
  • Add the imports, configuration constants, and the logging function by pasting this code:
import time
from datetime import datetime

from yamcs.client import YamcsClient

VOLTAGE_PARAM = "/YSS/SIMULATOR/BatteryVoltage1"
SWITCH_OFF_CMD = "/YSS/SIMULATOR/SWITCH_VOLTAGE_OFF"
SWITCH_ON_CMD = "/YSS/SIMULATOR/SWITCH_VOLTAGE_ON"
WARNING_THRESHOLD = 3.5
RECOVERY_CHECK_TIMEOUT = 10

procedure_log = []


def log_action(action, detail):
    """Record an action in the procedure log."""
    entry = {
        "timestamp": datetime.now().isoformat(),
        "action": action,
        "detail": detail,
    }
    procedure_log.append(entry)
    print(f"  [{entry['timestamp'][11:19]}] {action}: {detail}")

What does this code do?

The constants at the top define the parameter to monitor (BatteryVoltage1), the two commands the script can issue (SWITCH_VOLTAGE_OFF and SWITCH_VOLTAGE_ON), and the voltage threshold that triggers a response.

The procedure_log list stores every action the script takes as a timestamped dictionary. The log_action function both appends to this list and prints the action to the console, giving you real-time visibility and a persistent audit trail.

  • Save anomaly_response.py.
Add the anomaly response function with command verification

The core of this script is a function that continuously polls telemetry, detects threshold breaches, and issues commands through a CommandConnection. After issuing each command, it waits for acknowledgment to confirm the spacecraft received and executed it.

  • Below the log_action function, add the run_anomaly_response function by pasting this code:
def run_anomaly_response(processor):
    """Monitor telemetry and respond to anomalies."""
    print("=" * 60)
    print("AUTOMATED ANOMALY RESPONSE PROCEDURE")
    print("=" * 60)
    print(f"Monitoring: {VOLTAGE_PARAM}")
    print(f"Warning threshold: {WARNING_THRESHOLD}V")
    print("Press Ctrl+C to stop.\n")

    conn = processor.create_command_connection()

    while True:
        pval = processor.get_parameter_value(VOLTAGE_PARAM, from_cache=False, timeout=5)
        voltage = pval.eng_value
        log_action("READ", f"{VOLTAGE_PARAM.split('/')[-1]} = {voltage}")

        if voltage <= WARNING_THRESHOLD:
            log_action("THRESHOLD_BREACH", f"Voltage {voltage} <= {WARNING_THRESHOLD}")

            log_action("COMMANDING", f"Issuing {SWITCH_OFF_CMD}")
            command = conn.issue(SWITCH_OFF_CMD, args={"voltage_num": 1})
            command.await_complete(timeout=10)

            if command.is_success():
                log_action("ACK_SUCCESS", "Command completed successfully")
            else:
                log_action("ACK_FAILURE", f"Command failed: {command.error}")
                log_action("ESCALATE", "CRITICAL - Manual intervention required")
                break

            log_action("COMMANDING", f"Issuing {SWITCH_ON_CMD}")
            command2 = conn.issue(SWITCH_ON_CMD, args={"voltage_num": 1})
            command2.await_complete(timeout=10)

            if command2.is_success():
                log_action("ACK_SUCCESS", "Recovery command completed")
            else:
                log_action("ACK_FAILURE", f"Recovery failed: {command2.error}")

        time.sleep(5)

How does the command verification work?

The create_command_connection() method opens a persistent connection that tracks acknowledgment progress for every command you issue through it. In Yamcs, commands pass through three acknowledgment stages: Acknowledge_Queued, Acknowledge_Released, and Acknowledge_Sent.

Calling command.await_complete(timeout=10) blocks until the command finishes all verification stages. Then command.is_success() tells you whether it completed successfully. If it failed, the script escalates by logging a CRITICAL alert and halting the loop.

When the first command succeeds, the script issues a recovery command (SWITCH_VOLTAGE_ON) to restore normal operations. This is the "resolver" pattern: conditional logic that decides what action to take based on spacecraft state.

  • Save anomaly_response.py.
Add the entry point with execution log output

The final piece connects to Yamcs and runs the procedure. When you stop the script with Ctrl+C, it prints the full execution log so you have a complete audit trail of every action taken during the session.

  • At the bottom of anomaly_response.py, add the main entry point by pasting this code:
if __name__ == "__main__":
    client = YamcsClient("localhost:8090")
    processor = client.get_processor(instance="simulator", processor="realtime")

    try:
        run_anomaly_response(processor)
    except KeyboardInterrupt:
        print("\n\nProcedure stopped.")
        print(f"\nExecution log ({len(procedure_log)} entries):")
        for entry in procedure_log:
            print(f"  {entry['timestamp'][11:19]} | {entry['action']}: {entry['detail']}")

What does the execution log do?

The KeyboardInterrupt handler catches Ctrl+C and prints the entire procedure_log list as a formatted table. Each entry shows the timestamp, action type, and detail string. This mirrors real mission operations logging where every action during a pass is recorded for post-pass review.

  • Save anomaly_response.py.
  • Run the script by executing this command in your terminal:
python anomaly_response.py

You should see READ log entries appearing every 5 seconds with the current voltage value. If the voltage is at or below 3.5V, you will see THRESHOLD_BREACH, COMMANDING, and ACK_SUCCESS entries as the script issues commands and receives acknowledgments.

  • Let the script run for 15-30 seconds to observe the monitoring and response cycle.
  • Press Ctrl+C to stop the script.

After stopping, you should see the full execution log printed with all entries showing timestamps, action types, and details.

Not seeing any output?

Make sure your Yamcs Docker container is still running. Run docker ps to check. If it is not listed, restart it with docker start yamcs.

If you see a connection error, verify that http://localhost:8090 loads in your browser. The Yamcs web UI should be accessible.

If you see a timeout error on get_parameter_value, the simulator may need a few seconds to start generating data after a container restart. Wait 10 seconds and try again.

Help me debug my anomaly_response.py script not connecting to Yamcs.

✔️ Awesome, I've got everything!

Great. Double check you have saved anomaly_response.py and confirmed it runs successfully.

ⓧ I'd like to double check the full code

import time
from datetime import datetime

from yamcs.client import YamcsClient

VOLTAGE_PARAM = "/YSS/SIMULATOR/BatteryVoltage1"
SWITCH_OFF_CMD = "/YSS/SIMULATOR/SWITCH_VOLTAGE_OFF"
SWITCH_ON_CMD = "/YSS/SIMULATOR/SWITCH_VOLTAGE_ON"
WARNING_THRESHOLD = 3.5
RECOVERY_CHECK_TIMEOUT = 10

procedure_log = []


def log_action(action, detail):
    """Record an action in the procedure log."""
    entry = {
        "timestamp": datetime.now().isoformat(),
        "action": action,
        "detail": detail,
    }
    procedure_log.append(entry)
    print(f"  [{entry['timestamp'][11:19]}] {action}: {detail}")


def run_anomaly_response(processor):
    """Monitor telemetry and respond to anomalies."""
    print("=" * 60)
    print("AUTOMATED ANOMALY RESPONSE PROCEDURE")
    print("=" * 60)
    print(f"Monitoring: {VOLTAGE_PARAM}")
    print(f"Warning threshold: {WARNING_THRESHOLD}V")
    print("Press Ctrl+C to stop.\n")

    conn = processor.create_command_connection()

    while True:
        pval = processor.get_parameter_value(VOLTAGE_PARAM, from_cache=False, timeout=5)
        voltage = pval.eng_value
        log_action("READ", f"{VOLTAGE_PARAM.split('/')[-1]} = {voltage}")

        if voltage <= WARNING_THRESHOLD:
            log_action("THRESHOLD_BREACH", f"Voltage {voltage} <= {WARNING_THRESHOLD}")

            log_action("COMMANDING", f"Issuing {SWITCH_OFF_CMD}")
            command = conn.issue(SWITCH_OFF_CMD, args={"voltage_num": 1})
            command.await_complete(timeout=10)

            if command.is_success():
                log_action("ACK_SUCCESS", "Command completed successfully")
            else:
                log_action("ACK_FAILURE", f"Command failed: {command.error}")
                log_action("ESCALATE", "CRITICAL - Manual intervention required")
                break

            log_action("COMMANDING", f"Issuing {SWITCH_ON_CMD}")
            command2 = conn.issue(SWITCH_ON_CMD, args={"voltage_num": 1})
            command2.await_complete(timeout=10)

            if command2.is_success():
                log_action("ACK_SUCCESS", "Recovery command completed")
            else:
                log_action("ACK_FAILURE", f"Recovery failed: {command2.error}")

        time.sleep(5)


if __name__ == "__main__":
    client = YamcsClient("localhost:8090")
    processor = client.get_processor(instance="simulator", processor="realtime")

    try:
        run_anomaly_response(processor)
    except KeyboardInterrupt:
        print("\n\nProcedure stopped.")
        print(f"\nExecution log ({len(procedure_log)} entries):")
        for entry in procedure_log:
            print(f"  {entry['timestamp'][11:19]} | {entry['action']}: {entry['detail']}")

Your automated anomaly response procedure can now detect off-nominal conditions and issue corrective commands without human intervention. Next up, you will build an operations auto-pilot that executes a timed sequence of commands with safety gates.

Create an Operations Auto-Pilot Sequence

In the previous step, you built a reactive anomaly response system that waits for something to go wrong and then acts. Real satellite operations also need proactive automation.

In this step, you will build an auto-pilot sequencer that executes a pre-planned series of timed operations. Think of it as a subschedule: a defined sequence of commands, waits, and verification checks that runs autonomously with built-in safety gates.

In this step, get ready to:
  • Define a command sequence as a list of timed actions.
  • Implement a sequencer engine with safety checks that halt execution if parameters go out of bounds.
  • Run the full auto-pilot sequence and produce an execution report.
Build the command sequencer timeline

An auto-pilot plan is a list of actions that execute in order on a defined timeline. Each action has a type (log, wait, command, or verify) and the data it needs to execute.

  • Create a new file called autopilot_sequence.py in the same folder as your other scripts.
  • Add the imports, sequence definition, and configuration by pasting this code:
import time
from datetime import datetime

from yamcs.client import YamcsClient

SEQUENCE = [
    {"action": "log", "message": "Starting auto-pilot sequence"},
    {"action": "wait", "seconds": 2},
    {"action": "command", "cmd": "/YSS/SIMULATOR/SWITCH_VOLTAGE_OFF", "args": {"voltage_num": 1}},
    {"action": "wait", "seconds": 3},
    {"action": "verify", "param": "/YSS/SIMULATOR/BatteryVoltage1", "condition": "read"},
    {"action": "wait", "seconds": 2},
    {"action": "command", "cmd": "/YSS/SIMULATOR/SWITCH_VOLTAGE_ON", "args": {"voltage_num": 1}},
    {"action": "wait", "seconds": 3},
    {"action": "verify", "param": "/YSS/SIMULATOR/BatteryVoltage1", "condition": "read"},
    {"action": "log", "message": "Auto-pilot sequence complete"},
]

SAFETY_PARAM = "/YSS/SIMULATOR/BatteryVoltage2"
SAFETY_MIN = 0.5

execution_report = []

What does this code do?

The SEQUENCE list defines your auto-pilot plan as a series of dictionaries. Each dictionary has an action key that tells the engine what to do: log a message, wait a number of seconds, issue a command, or verify a telemetry parameter.

SAFETY_PARAM and SAFETY_MIN define the safety gate. Before every step in the sequence, the engine checks that BatteryVoltage2 stays above 0.5V. If it drops below, the entire sequence halts immediately.

Implement the sequencer engine with safety checks

The engine needs two helper functions: one to record each step's result in the execution report, and one to perform the safety check before each action.

  • Add the record() and safety_check() helper functions below the execution_report = [] line:
def record(step_num, action, status, detail):
    """Record step execution in the report."""
    entry = {
        "step": step_num,
        "timestamp": datetime.now().isoformat(),
        "action": action,
        "status": status,
        "detail": detail,
    }
    execution_report.append(entry)
    symbol = "PASS" if status == "pass" else "FAIL" if status == "fail" else "INFO"
    print(f"  Step {step_num:02d} [{symbol}] {action}: {detail}")


def safety_check(processor):
    """Check if critical parameters are within bounds."""
    pval = processor.get_parameter_value(SAFETY_PARAM)
    if pval.eng_value < SAFETY_MIN:
        return False, f"{SAFETY_PARAM} = {pval.eng_value} < {SAFETY_MIN}"
    return True, f"{SAFETY_PARAM} = {pval.eng_value} (nominal)"

What do these helpers do?

  • record() logs each action with a pass/fail/info status and prints it with a formatted symbol. This creates the audit trail for the execution report at the end.
  • safety_check() reads the safety parameter from Yamcs and returns a boolean plus a status message. If the parameter is below the minimum, it returns False to signal the engine to halt.

Now add the main engine function that processes the timeline. This function loops through every step in the sequence, performs a safety check before each one, and dispatches to the correct handler based on the action type.

  • Add the run_sequence() function below safety_check():
def run_sequence(processor):
    """Execute the auto-pilot sequence."""
    print("=" * 60)
    print("OPERATIONS AUTO-PILOT SEQUENCE")
    print("=" * 60)
    print(f"Steps: {len(SEQUENCE)}")
    print(f"Safety parameter: {SAFETY_PARAM} (min: {SAFETY_MIN})")
    print()

    conn = processor.create_command_connection()

    for i, step in enumerate(SEQUENCE):
        safe, msg = safety_check(processor)
        if not safe:
            record(i, "SAFETY_HALT", "fail", msg)
            print("\n  SEQUENCE HALTED - Safety check failed")
            return

        action = step["action"]

        if action == "log":
            record(i, "LOG", "pass", step["message"])

        elif action == "wait":
            record(i, "WAIT", "pass", f"Waiting {step['seconds']}s")
            time.sleep(step["seconds"])

        elif action == "command":
            cmd_name = step["cmd"].split("/")[-1]
            record(i, "COMMAND", "info", f"Issuing {cmd_name}")
            command = conn.issue(step["cmd"], args=step.get("args", {}))
            command.await_complete(timeout=10)
            if command.is_success():
                record(i, "COMMAND_ACK", "pass", f"{cmd_name} acknowledged")
            else:
                record(i, "COMMAND_ACK", "fail", f"{cmd_name} failed: {command.error}")
                print("\n  SEQUENCE HALTED - Command failed")
                return

        elif action == "verify":
            pval = processor.get_parameter_value(step["param"], from_cache=False, timeout=5)
            param_short = step["param"].split("/")[-1]
            record(i, "VERIFY", "pass", f"{param_short} = {pval.eng_value}")

    print("\n" + "=" * 60)
    print("EXECUTION REPORT")
    print("=" * 60)
    passed = sum(1 for e in execution_report if e["status"] == "pass")
    failed = sum(1 for e in execution_report if e["status"] == "fail")
    print(f"  Total steps: {len(execution_report)}")
    print(f"  Passed: {passed}")
    print(f"  Failed: {failed}")
    print(f"  Result: {'ALL PASS' if failed == 0 else 'FAILURES DETECTED'}")

What does this engine do?

The engine iterates through each step in the SEQUENCE list. Before processing each step, it calls safety_check(). If the safety parameter is out of bounds, the entire sequence halts immediately.

For command actions, the engine uses create_command_connection() and gates progression on successful acknowledgment. If a command fails, the sequence stops. After all steps complete, it prints a summary showing pass/fail counts.

  • Save autopilot_sequence.py.

Seeing a syntax error?

The f-strings in this function use both single and double quotes. Make sure the f-string delimiters match correctly. For example, f"Waiting {step['seconds']}s" uses double quotes for the f-string and single quotes for the dictionary key.

Help me fix a syntax error in my autopilot_sequence.py file

Run the full auto-pilot sequence

The entry point connects to Yamcs and kicks off the sequencer. Unlike your anomaly response script, this one runs a finite sequence and exits with a report.

  • Add the __main__ block at the bottom of autopilot_sequence.py:
if __name__ == "__main__":
    client = YamcsClient("localhost:8090")
    processor = client.get_processor(instance="simulator", processor="realtime")

    run_sequence(processor)

What does this code do?

This connects to your local Yamcs instance on port 8090, gets the realtime processor for the simulator instance, and calls run_sequence() to start the auto-pilot. The script will run through all 10 steps and then exit.

  • Save autopilot_sequence.py.
  • Run the auto-pilot sequence by executing this command in your terminal:
python autopilot_sequence.py
  • You should see each step execute in sequence with PASS, FAIL, or INFO labels. After roughly 15 seconds, the script prints an EXECUTION REPORT summary showing the total steps, passed count, failed count, and an overall result of ALL PASS.

Script halts with SAFETY_HALT or COMMAND_ACK fail?

If you see SAFETY_HALT, it means BatteryVoltage2 dropped below 0.5V in the simulation. This is rare but possible. Wait a few seconds and run the script again.

If COMMAND_ACK fails, confirm your Yamcs container is still running with docker ps. If the container stopped, restart it with docker start yamcs and wait 10 seconds before trying again.

Help me troubleshoot my autopilot_sequence.py failing during execution

✔️ Awesome, I've got everything!

Great. Double check you have saved autopilot_sequence.py and seen the execution report print successfully.

ⓧ I'd like to double check the full code

Here is the complete autopilot_sequence.py file for comparison:

import time
from datetime import datetime

from yamcs.client import YamcsClient

SEQUENCE = [
    {"action": "log", "message": "Starting auto-pilot sequence"},
    {"action": "wait", "seconds": 2},
    {"action": "command", "cmd": "/YSS/SIMULATOR/SWITCH_VOLTAGE_OFF", "args": {"voltage_num": 1}},
    {"action": "wait", "seconds": 3},
    {"action": "verify", "param": "/YSS/SIMULATOR/BatteryVoltage1", "condition": "read"},
    {"action": "wait", "seconds": 2},
    {"action": "command", "cmd": "/YSS/SIMULATOR/SWITCH_VOLTAGE_ON", "args": {"voltage_num": 1}},
    {"action": "wait", "seconds": 3},
    {"action": "verify", "param": "/YSS/SIMULATOR/BatteryVoltage1", "condition": "read"},
    {"action": "log", "message": "Auto-pilot sequence complete"},
]

SAFETY_PARAM = "/YSS/SIMULATOR/BatteryVoltage2"
SAFETY_MIN = 0.5

execution_report = []


def record(step_num, action, status, detail):
    """Record step execution in the report."""
    entry = {
        "step": step_num,
        "timestamp": datetime.now().isoformat(),
        "action": action,
        "status": status,
        "detail": detail,
    }
    execution_report.append(entry)
    symbol = "PASS" if status == "pass" else "FAIL" if status == "fail" else "INFO"
    print(f"  Step {step_num:02d} [{symbol}] {action}: {detail}")


def safety_check(processor):
    """Check if critical parameters are within bounds."""
    pval = processor.get_parameter_value(SAFETY_PARAM)
    if pval.eng_value < SAFETY_MIN:
        return False, f"{SAFETY_PARAM} = {pval.eng_value} < {SAFETY_MIN}"
    return True, f"{SAFETY_PARAM} = {pval.eng_value} (nominal)"


def run_sequence(processor):
    """Execute the auto-pilot sequence."""
    print("=" * 60)
    print("OPERATIONS AUTO-PILOT SEQUENCE")
    print("=" * 60)
    print(f"Steps: {len(SEQUENCE)}")
    print(f"Safety parameter: {SAFETY_PARAM} (min: {SAFETY_MIN})")
    print()

    conn = processor.create_command_connection()

    for i, step in enumerate(SEQUENCE):
        safe, msg = safety_check(processor)
        if not safe:
            record(i, "SAFETY_HALT", "fail", msg)
            print("\n  SEQUENCE HALTED - Safety check failed")
            return

        action = step["action"]

        if action == "log":
            record(i, "LOG", "pass", step["message"])

        elif action == "wait":
            record(i, "WAIT", "pass", f"Waiting {step['seconds']}s")
            time.sleep(step["seconds"])

        elif action == "command":
            cmd_name = step["cmd"].split("/")[-1]
            record(i, "COMMAND", "info", f"Issuing {cmd_name}")
            command = conn.issue(step["cmd"], args=step.get("args", {}))
            command.await_complete(timeout=10)
            if command.is_success():
                record(i, "COMMAND_ACK", "pass", f"{cmd_name} acknowledged")
            else:
                record(i, "COMMAND_ACK", "fail", f"{cmd_name} failed: {command.error}")
                print("\n  SEQUENCE HALTED - Command failed")
                return

        elif action == "verify":
            pval = processor.get_parameter_value(step["param"], from_cache=False, timeout=5)
            param_short = step["param"].split("/")[-1]
            record(i, "VERIFY", "pass", f"{param_short} = {pval.eng_value}")

    print("\n" + "=" * 60)
    print("EXECUTION REPORT")
    print("=" * 60)
    passed = sum(1 for e in execution_report if e["status"] == "pass")
    failed = sum(1 for e in execution_report if e["status"] == "fail")
    print(f"  Total steps: {len(execution_report)}")
    print(f"  Passed: {passed}")
    print(f"  Failed: {failed}")
    print(f"  Result: {'ALL PASS' if failed == 0 else 'FAILURES DETECTED'}")


if __name__ == "__main__":
    client = YamcsClient("localhost:8090")
    processor = client.get_processor(instance="simulator", processor="realtime")

    run_sequence(processor)

You now have a complete operations automation toolkit. Your scripts can explore the mission database, monitor telemetry in real time, respond to anomalies, and execute pre-planned command sequences with safety gates. That is the full loop of satellite operations automation.

Secret mission

Build a Mission Log Recorder and Deploy a Report Portal

Real satellite operations teams archive every telemetry reading and command during a ground station pass. Can you build a recorder that captures live spacecraft data, then deploy a public mission report portal so stakeholders can review pass results from anywhere?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally using Docker, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Docker container yamcs running the Yamcs simulation on port 8090.
  • Python scripts (explore_mdb.py, telemetry_monitor.py, anomaly_response.py, autopilot_sequence.py, mission_recorder.py) and output file mission_log.json on your Desktop.
  • Python package yamcs-client installed via pip.

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with your scripts.

  • The Docker container uses minimal system resources (CPU and memory) while running.
  • You can continue running your Python scripts against the live simulation at any time.
  • The yamcs-client package remains installed and ready to use.

Pause - I'll come back to this later

Shut down the Docker container to free up memory and CPU. Your container state is preserved so you can resume later.

  • Stop the Yamcs container by running:
docker stop yamcs
  • When you are ready to resume, start it again by running:
docker start yamcs
  • Your Python scripts and mission_log.json remain on disk, unchanged.

Delete - I don't want to use this again

Remove all project resources and start fresh. You can recreate everything anytime with the commands from Step 1.

  • Remove the Yamcs Docker container by running:
docker rm -f yamcs
  • Delete your project files by running:
cd ~/Desktop
rm -f explore_mdb.py telemetry_monitor.py anomaly_response.py autopilot_sequence.py mission_recorder.py mission_log.json
  • Uninstall the yamcs-client Python package by running:
pip uninstall yamcs-client -y

Nice Work!

Nice Work!

You just built a satellite operations automation toolkit from scratch. You connected Python to an industry-standard mission control system, monitored live spacecraft telemetry, and scripted autonomous procedures that respond to anomalies and execute command sequences.

You've learned how to:

  • Run a containerized Yamcs mission control system in Docker and query its Mission Database programmatically using the yamcs-client Python library.
  • Build a real-time telemetry monitor with configurable alarm thresholds that flags off-nominal spacecraft parameters.
  • Script an automated anomaly response procedure and an operations auto-pilot sequence with command verification, safety gates, and execution reporting.
  • Secret Mission: Built a mission log recorder and deployed a public mission report portal to Render so stakeholders can access pass data remotely.

Ready to quiz yourself?