Skip to main content

CubeSatTestbed

CI PyPI Python License

A modular CubeSat subsystem emulation and hardware-in-the-loop test framework.

Project scheme

Status: v1.1.0 released and published to PyPI. See CHANGELOG.md for what shipped and docs/roadmap.md for what's next.

What this is

The framework is built around a Device Under Test (DUT) concept: any subsystem (OBC, EPS, ADCS, payload, ...) can be connected as real hardware, while every other subsystem it talks to is replaced by a configurable software peer. Switching which node is real and which is simulated is a config change, not a code change.

Why

  • Commercial subsystem simulators are proprietary and tied to specific hardware.
  • Full mission simulators are often too heavy for focused subsystem verification.
  • Hardcoded stubs inside flight code do not exercise the real bus and do not produce scenario-level PASS/FAIL results.

Core ideas

  1. Universal engine, not universal subsystem models. No single "generic EPS" can faithfully stand in for every real EPS board. The universal part is the engine: DUT/peer selection, protocol and transport adapters, deterministic scenario execution, fault injection and assertions. Concrete subsystem behavior lives in modules — including yours: a module registered from your own package is nameable in setup TOML, tunable, ticked and observed over the bus exactly like the built-in ones. See docs/writing-a-module.md.
  2. DUT + switchable node modes. Every node is simulated (a module inside cubesat_testbed emulates the subsystem), software (an external implementation runs as a peer), or hardware (a real board through a bus adapter).
  3. Deterministic scenarios with PASS/FAIL reports. Scenarios are declarative YAML scripts: inject a fault, wait in virtual time, send a command, assert telemetry. The runner is built on virtual time and produces a PASS/FAIL report per assertion.

See docs/architecture.md for the full layer breakdown.

Quickstart

Demo of a full run

One run end to end: install, a PASS with its exit code, the wire trace proving assertions read frames off the bus, a thermal module closing a heater loop, and a deliberate failure exiting 1. It was recorded just before the 1.1.0 release, so it installs from git rather than from PyPI; everything it shows is in 1.1.0. The cast is scripted and re-recordable: see docs/demo/.

pip install cubesat-testbed
cubesat-testbed run --example default
PASS t=4000000 assert_3: payload.telemetry.power_status == 'offline'; actual='offline'
SUMMARY scenario='EPS Low Battery Protection Test' assertions=1 passed=1 failed=0 started_at=0 finished_at=4000000

The examples ship inside the package, so that is the whole install. To get an editable copy of one to work from:

cubesat-testbed init my-testbed
cd my-testbed
cubesat-testbed run --config setup.toml --scenario scenario.yaml

init writes a setup.toml, a scenario.yaml and a README.md explaining them, and refuses to overwrite existing files unless you pass --force. cubesat-testbed init --list shows what else is available:

default        in-memory three-node satellite; OBC sheds the payload on a low battery
socketcan-hil  the same run against a real bus: payload as hardware on SocketCAN vcan0
module-params  retuning a built-in module through [nodes.<node>.params]

Working on the testbed itself instead of with it? See CONTRIBUTING.md — the development flow is uv sync --extra dev from a clone, and uv run cubesat-testbed ... for every command below.

Walkthrough: your first scenario

Same run as Quickstart above, but step by step — what each config field means and how to read the result. Run cubesat-testbed init my-testbed first, so the two files being taken apart here are in front of you.

1. The CubeSat setup (setup.toml)

Three nodes:

[nodes.obc]
mode = "simulated"
module_type = "obc_peer"
address = 1

[nodes.eps]
mode = "simulated"
module_type = "generic_eps"
address = 2

[nodes.payload]
mode = "simulated"
module_type = "simple_payload"
address = 3
  • mode = "simulated" — this node runs entirely inside the testbed. Switch to "hardware" later to point the same setup at a real board over SocketCAN — no code changes.
  • address — the node's CSP address on the bus.

The behavior under test lives here:

[nodes.eps.telemetry.battery_percent]
offset = 0
length = 4
type = "float"
...

[nodes.obc.rules.low_battery_shed_payload]
signal = "eps.telemetry.battery_percent"
op = "<"
threshold = 30.0
for = "3s"

[[nodes.obc.rules.low_battery_shed_payload.actions]]
type = "send_command"
command = "payload_power_off"

EPS reports battery_percent as a 4-byte float, encoded onto the bus like a real telemetry frame, not just held in memory. OBC has one rule: if battery stays below 30% for 3 virtual seconds, send payload_power_off.

2. The scenario (scenario.yaml)

steps:
  - action: "inject_fault"
    type: "state_override"
    target: "eps.model.battery_percent"
    value: 25
    duration: "5s"

  - action: "wait"
    virtual_time: "3s"

  - action: "assert"
    signal: "payload.telemetry.power_status"
    op: "=="
    value: "offline"
    timeout: "1s"
  1. inject_fault — force EPS's battery to 25% for 5 virtual seconds (bypasses the discharge model to test the reaction, not the physics).
  2. wait — advance virtual time 3s, giving the rule's for = "3s" window a chance to elapse.
  3. assert — check payload.telemetry.power_status == "offline", retried against incoming telemetry for up to 1s.

3. Run it

cubesat-testbed run --config setup.toml --scenario scenario.yaml
PASS t=4000000 assert_3: payload.telemetry.power_status == 'offline'; actual='offline'
SUMMARY scenario='EPS Low Battery Protection Test' assertions=1 passed=1 failed=0 started_at=0 finished_at=4000000

4. Reading it

  • t=4000000 — virtual microseconds (4s), not 3,000,000: telemetry is only re-encoded once per physical step, so the earliest honest chance to see "offline" is the beacon after the command actually lands. That one-step delay is expected, not a bug.
  • assert_3 — auto-generated name (3rd, unnamed step); add name: to a step for a readable label.
  • passed=1 failed=0 and exit code 0 — wire straight into CI.

5. When it fails

Drop the inject_fault step from scenario.yaml (battery never actually drops) and rerun:

FAIL t=4000000 assert_2: payload.telemetry.power_status == 'offline'; actual='online'

Exit code 1. actual='online' is the real, decoded-from-the-bus value — not a guess.

6. Machine-readable output

cubesat-testbed run --config ... --scenario ... --json --quiet
{
  "scenario": "EPS Low Battery Protection Test",
  "passed": true,
  "exit_code": 0,
  "assertions": {
    "total": 1, "passed": 1, "failed": 0,
    "results": [{
      "name": "assert_3",
      "signal": "payload.telemetry.power_status",
      "expected": "offline", "actual": "offline",
      "passed": true, "evaluated_at": 4000000
    }]
  }
}

Or --junit-xml PATH for CI dashboards. Full config syntax: configs/schema/module_schema.md.

7. Seeing the actual bus

Add --trace for a decoded frame-by-frame trace on stderr:

trace t=4000000 TX can_id=0x10004103 dlc=8 pri=2 src=2 dst=2 dport=20 sport=20 flags=0x00 data=0009450041c80000 payload=41c80000 telemetry eps.telemetry.battery_percent=25.0
trace t=4000000 TX can_id=0x10006083 dlc=5 pri=2 src=1 dst=3 dport=10 sport=10 flags=0x00 data=0004a28000 payload=00 command obc.payload_power_off->payload

Virtual timestamp, direction, CSP v2 header fields, the raw bytes as candump would show them, and the command route or telemetry value decoded out of the frame. This is the same decode path assertions use — the trace is a window on it, not a parallel report. It goes to stderr, so it composes with --json and --quiet, and it works on SocketCAN too, where it is the only way to see what your testbed actually put on the wire.

Hardware in the loop

Point the same scenario at a real bus: switch the node under test to mode = "hardware", declare a SocketCAN transport, and run with --realtime.

[transport]
type = "socketcan"
interface = "vcan0" # or a physical interface such as "can0"

That is exactly what the socketcan-hil example does to the setup above — one node's mode, one transport block, no code change:

cubesat-testbed init hil --example socketcan-hil
cubesat-testbed run --realtime --config hil/setup.toml --scenario hil/scenario.yaml

--realtime is what makes a HIL run work: without it the engine jumps straight through virtual time and a real board never gets wall-clock time to answer. A socketcan setup run without the flag therefore warns on stderr. Under --realtime, a scenario that waits 30s really does take 30 seconds.

Add --trace when a HIL run misbehaves: on SocketCAN the adapter never receives its own messages, so the trace is the only place both what the testbed sent and what the board answered show up side by side.

For local loopback without hardware, bring up vcan0 first:

sudo modprobe vcan
sudo ip link add dev vcan0 type vcan
sudo ip link set up vcan0

v1 scope at a glance

  • Protocol: CSP v2 only, single-frame, classic CAN 2.0, extended 29-bit IDs.
  • Modules: Generic EPS, OBC Peer (rule engine), Simple Payload, RC thermal node -- plus your own, registered the same way the built-ins are (docs/writing-a-module.md).
  • Transports: in-memory (CI/tests) and SocketCAN (Linux HIL).
  • Deterministic virtual-time engine, TOML setup + YAML scenarios, PASS/FAIL CLI report with CI-friendly exit codes, JUnit XML output and a decoded wire-level frame trace (--trace).

Full detail, constraints, and what's deliberately out of scope for v1: docs/v1-scope.md.

Documentation

Stack

Python >=3.11, Pydantic, PyYAML, python-can, uv for dependency/environment management, pytest + ruff + mypy for development.

License

Apache License 2.0. See LICENSE.

Metadata

Release files for cubesat-testbed 1.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for cubesat-testbed 1.1.0
File Size Uploaded
cubesat_testbed-1.1.0.tar.gz 2.2 MB Details

Built distribution (wheel)

Table of built distributions (wheels) for cubesat-testbed 1.1.0
File Interpreter ABI Platform
cubesat_testbed-1.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 2.3 MB

Release files / cubesat_testbed-1.1.0.tar.gz

Download URL cubesat_testbed-1.1.0.tar.gz
Size 2.2 MB
Tags Source
SHA-256 checksum
How to use checksums
d016c42f511ec778b5115066307bf183592f64903e393dad93088fd90bb3bd5c
BLAKE2b-256 checksum
How to use checksums
ebeff1d2556895107bf477aebdbd06cbd35d606c9279a249f42b2f46aaa85660
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 3, 2026.

Transparency log

Release files / cubesat_testbed-1.1.0-py3-none-any.whl

Download URL cubesat_testbed-1.1.0-py3-none-any.whl
Size 110.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
2c161dc28d15caeab8a48ce698aebfd65f68b04ac0e17790aad811a8e49a585b
BLAKE2b-256 checksum
How to use checksums
48064879a7da5d7fb71c3193481ff7eb9c61a193f6c971d900f43a752ec11a78
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 3, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

1.1.0 This release

2 release files

1.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page