Deterministic CubeSat subsystem emulation and hardware-in-the-loop testbed
Project description
CubeSatTestbed
A modular CubeSat subsystem emulation and hardware-in-the-loop test framework.
Status: pre-v1, under active development. See
docs/roadmap.md for what's implemented.
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
- 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.
- DUT + switchable node modes. Every node is
simulated(a module insidecubesat_testbedemulates the subsystem),software(an external implementation runs as a peer), orhardware(a real board through a bus adapter). - 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
uv sync --extra dev
uv run cubesat-testbed run \
--config configs/default_satellite.toml \
--scenario configs/scenarios/low_battery.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
Walkthrough: your first scenario
Same run as Quickstart above, but step by step — what each config field means and how to read the result.
1. The CubeSat setup (configs/default_satellite.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 (configs/scenarios/low_battery.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"
inject_fault— force EPS's battery to 25% for 5 virtual seconds (bypasses the discharge model to test the reaction, not the physics).wait— advance virtual time 3s, giving the rule'sfor = "3s"window a chance to elapse.assert— checkpayload.telemetry.power_status == "offline", retried against incoming telemetry for up to 1s.
3. Run it
uv run cubesat-testbed run \
--config configs/default_satellite.toml \
--scenario configs/scenarios/low_battery.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); addname:to a step for a readable label.passed=1 failed=0and exit code0— wire straight into CI.
5. When it fails
Drop the inject_fault step (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
uv run 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.
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 and JUnit XML output.
Full detail, constraints, and what's deliberately out of scope for v1:
docs/v1-scope.md.
Documentation
- Architecture
- Product v1 scope
- Config schema reference
- CSP golden vectors
- Roadmap
- Contributing
- Changelog
Stack
Python >=3.11, Pydantic, PyYAML, python-can, uv for dependency/environment
management, pytest + ruff + mypy for development. FastAPI/Uvicorn will be
added once the Mission Control API phase lands (see
docs/roadmap.md); they are not a v1 dependency.
License
Apache License 2.0. See LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file cubesat_testbed-1.0.0.tar.gz.
File metadata
- Download URL: cubesat_testbed-1.0.0.tar.gz
- Upload date:
- Size: 512.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9df6b5aa650504e6268434e0270f59d716917fbf38b7adeb1cd3b2f60b6a8433
|
|
| MD5 |
1055b7910c8db75bf6966f5ed50c107d
|
|
| BLAKE2b-256 |
8f81e3da02b8b3f0e7157d16b694fcfb25ab392ec2836c94eb3c4f4e05cdb022
|
Provenance
The following attestation bundles were made for cubesat_testbed-1.0.0.tar.gz:
Publisher:
publish.yml on glocker/CubeSatTestbed
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
cubesat_testbed-1.0.0.tar.gz -
Subject digest:
9df6b5aa650504e6268434e0270f59d716917fbf38b7adeb1cd3b2f60b6a8433 - Sigstore transparency entry: 2300842516
- Sigstore integration time:
-
Permalink:
glocker/CubeSatTestbed@b9b1bf4c5defc4e83656d5d72abbf1808c1b2638 -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/glocker
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@b9b1bf4c5defc4e83656d5d72abbf1808c1b2638 -
Trigger Event:
push
-
Statement type:
File details
Details for the file cubesat_testbed-1.0.0-py3-none-any.whl.
File metadata
- Download URL: cubesat_testbed-1.0.0-py3-none-any.whl
- Upload date:
- Size: 80.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1d9541a1fb5a79d90ca05afa0d7e4294c0520b5f110e375e233f65db69d1ef59
|
|
| MD5 |
6ee24bf01e210c5fd9524cafa1a4c859
|
|
| BLAKE2b-256 |
ccf5026ae5c995176ca18cf5c90e714f24918530b39fa61445ea8d703ed19dc3
|
Provenance
The following attestation bundles were made for cubesat_testbed-1.0.0-py3-none-any.whl:
Publisher:
publish.yml on glocker/CubeSatTestbed
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
cubesat_testbed-1.0.0-py3-none-any.whl -
Subject digest:
1d9541a1fb5a79d90ca05afa0d7e4294c0520b5f110e375e233f65db69d1ef59 - Sigstore transparency entry: 2300842538
- Sigstore integration time:
-
Permalink:
glocker/CubeSatTestbed@b9b1bf4c5defc4e83656d5d72abbf1808c1b2638 -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/glocker
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@b9b1bf4c5defc4e83656d5d72abbf1808c1b2638 -
Trigger Event:
push
-
Statement type: