Skip to content

Repository files navigation

CubeSatTestbed

CI PyPI Python License

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

Project scheme

Status: v1.0.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.
  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

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.
  • 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.

About

Modular CubeSat subsystem emulation and hardware-in-the-loop test framework

Topics

Resources

Contributing

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages