Decode CCSDS telemetry packets in Rust, according to an XTCE definition. Part of RustSpaceLab.
The reference implementation in this niche, lasp/space_packet_parser, has a known
performance problem: on real mission databases, loading the XTCE file costs more than parsing
the packets (issue #112). This project attacks both halves — a fast XML-to-IR
loader, and a decoder that walks a flat arena instead of a graph of Python objects — and is
validated against that same implementation packet for packet.
| Crate | What it does |
|---|---|
xtce-model |
XTCE XML → arena-backed IR, reference resolution, validation; telemetry and telecommands |
xtce-decode |
IR + &[u8] → parameter values |
xtce-codegen |
IR → a static Rust decoder with every offset baked in |
xtce-cli |
xtce info, xtce decode, xtce codegen, xtce bench |
xtce-py |
Python bindings (PyO3), built by maturin |
xtask |
differential test harness against the Python reference |
Scope is a deliberate subset of XTCE, documented in SUPPORTED.md.
Telecommands are read too, as of 2026-08-23. <CommandMetaData> was dropped during parsing
until then, which is defensible for a telemetry decoder and not for the spacecraft side, where
a telecommand is defined there and nowhere else. A command turns out to need no new machinery:
it is a container of fields selected by fixed values, so an argument becomes a parameter, a
<CommandContainer> becomes a container, and an <ArgumentAssignment> becomes the restriction
criterion it already was. The reference has no command support of any kind, so the schema is
the oracle — as it is for arrays and aggregates.
Correctness is defined as agreement with the reference, not as passing tests written
alongside the code. testdata/golden/ holds the reference's output for nine
definition/packet pairs — six of them real mission streams — and cargo xtask diff
re-derives all of it:
- every raw and engineering value of the first 64 packets, compared field by field;
- a SHA-256 over a canonical encoding of all 19 025 packets, so a divergence past the detail window cannot hide — and one per 256 packets besides, so it cannot hide where it is either;
- one case is a definition pointed at a stream it does not describe, so the "both refuse this packet" half of the contract is tested too.
All nine agree.
Measured with criterion against the same reference on the same inputs
(testdata/golden/reference_timings.json):
| xtce-rs | space_packet_parser |
||
|---|---|---|---|
| Load a 1.6 MB definition (CTIM, 9 493 parameters) | 10.5 ms | 120 ms | 11× |
| Decode 1499 CTIM packets | 48 ms | 5109 ms | 106× |
| Decode 7200 JPSS packets | 9.3 ms | 954 ms | 102× |
| The same, through a generated decoder | 84 µs | 954 ms | 11 400× |
The generated decoder is what xtce-codegen produces: a struct per container whose
decode is loads, shifts and masks with every bit offset already a literal. It compiles a
narrower subset than the interpreter and refuses anything else by name rather than falling
back — see SUPPORTED.md.
$ cargo run --release -p xtce-cli -- info testdata/spp/ctim/ctim_xtce_v1.xml
$ cargo run --release -p xtce-cli -- decode \
testdata/spp/jpss/jpss1_geolocation_xtce_v1.xml \
testdata/spp/jpss/J01_G011_LZ_2021-04-09T00-00-00Z_V01.DAT1 --limit 3 --raw
$ cargo run --release -p xtce-cli -- codegen \
testdata/spp/jpss/jpss1_geolocation_xtce_v1.xml
$ cargo xtask diff
$ cargo benchFrom Python:
import xtce
definition = xtce.Definition("jpss1_geolocation_xtce_v1.xml")
packets = definition.decode_stream(open("telemetry.dat", "rb").read())
print(len(packets), packets[0]["PKT_APID"])decode_stream frames and decodes a whole buffer in one call, releasing the GIL for the Rust
part. That matters: a per-packet round trip would give the speed-up back to the interpreter.
7200 JPSS packets take 38 ms including building every Python dictionary, against the
reference's 954 ms.
The bindings are their own Cargo workspace, so cargo build --workspace never needs
libpython. Build them with maturin develop --release --manifest-path crates/xtce-py/Cargo.toml.
Nothing below needs Python unless it says so: the reference implementation's output is
committed under testdata/golden/.
$ cargo test --workspace # 179 tests
$ cargo xtask diff # the differential suite, 9 definition/stream pairscargo xtask diff is the one that matters. It decodes every packet of nine streams — six of
them real mission downlinks — and checks the result against what space_packet_parser
produced:
ctim ok
1499 packets (0 not described) load 17.6x decode 9.8x digest ok
105 packet(s) had bits no entry claimed (both implementations agree on the values)
...
all 9 case(s) match the reference implementation
Two independent checks per case. Every raw and engineering value of the first 64 packets is
compared field by field, so a failure names the packet, the parameter and both values. Then a
SHA-256 over a canonical encoding of all 19 025 packets is compared against the digest
the reference produced — so a divergence past the detail window cannot hide. digest ok and a
clean detail section together mean the two implementations agree on every packet.
A digest alone says whether, not where. So the goldens carry one per 256 packets as well, and a mismatch reports the range it falls in and the command that widens the detail to reach it — turning "somewhere in nineteen thousand packets" into "somewhere in these 256".
The load/decode multipliers in that output include the harness's own bookkeeping. They are
a floor, not a benchmark; use cargo bench for real numbers.
| Command | Proves |
|---|---|
cargo test -p xtce-model |
the XML reader and IR on all 19 bundled definitions, including what <CommandMetaData> lowers into |
cargo test -p xtce-decode |
bit reading (property tests against a one-bit-at-a-time oracle), and 29 end-to-end cases over inline XML snippets |
cargo test -p xtce-codegen |
the generated decoder equals the interpreted one on all 7200 JPSS packets, field by field |
cargo test -p xtce-codegen-e2e |
every bundled definition compiles under #![no_std], and each agrees with the interpreter |
cargo xtask diff |
the interpreted decoder equals the Python reference on nine streams, six of them real |
pytest crates/xtce-py/tests |
the Python bindings lose nothing crossing the boundary |
They are deliberately layered: codegen is checked against the interpreter, and the interpreter against the reference, so codegen inherits the reference's authority without needing its own golden files.
$ cargo xtask diff --case ctim
$ cargo xtask diff --case suda --max-differences 100$ cargo bench # everything
$ cargo bench -p xtce-decode --bench decode # decoding only
$ cargo bench -p xtce-model --bench load # loading only, plus the raw-parser ceiling
$ cargo bench -p xtce-codegen # generated vs interpreted, side by sideCriterion writes an HTML report to target/criterion/report/index.html and compares against
the previous run, so a regression shows up as a red percentage.
The reference implementation's own timings for the same inputs are in
testdata/golden/reference_timings.json, recorded by the run that produced the goldens.
$ cargo run --release -p xtce-cli -- info testdata/spp/ctim/ctim_xtce_v1.xml
$ cargo run --release -p xtce-cli -- decode \
testdata/spp/jpss/jpss1_geolocation_xtce_v1.xml \
testdata/spp/jpss/J01_G011_LZ_2021-04-09T00-00-00Z_V01.DAT1 --limit 2 --rawinfo reports how many containers are fully decodable and what blocks the rest. decode
reports packets with bits no entry claimed — on CTIM that is 105 of 1499, which says the
definition does not describe the whole packet.
--param narrows the output to the parameters you name, and * in a value stands for any
run of characters, so --param 'ADGPSPOS*' selects the three position components. A value
that matches nothing in the definition is an error rather than an empty report, and the
message names the closest parameter:
$ ... decode ctim_xtce_v1.xml ccsds_2021_155_14_39_51 --root CCSDSTelemetryPacket \
--param ana_proc_tmep
error: 1 of 1 --param value(s) match no parameter in this definition, which describes 9493:
ana_proc_tmep — did you mean ana_proc_temp?These do need a Python toolchain:
$ python3.12 -m venv .venv
$ .venv/bin/pip install maturin pytest \
'space_packet_parser @ git+https://github.com/lasp/space_packet_parser@6de220ff25e75d0a8b6258086f9d99ce9eae820b'
$ .venv/bin/maturin develop --release --manifest-path crates/xtce-py/Cargo.toml
$ .venv/bin/pytest crates/xtce-py/tests -qThe reference comes from that commit rather than from PyPI. space_packet_parser==6.1.2 installs
and reports the same version, but is not the same code — see testdata/SOURCES.md for what
differs and why it matters to the goldens.
Only needed after deliberately changing what is compared, or to add a case. It runs the Python reference, so it needs the venv above:
$ .venv/bin/python tools/gen_goldens.pyA change to testdata/golden/ in a diff means the reference's output changed — treat that
as something to explain, not to accept.
crates/ library and binary crates
testdata/spp/ real XTCE files and packet streams (BSD-3, see testdata/SOURCES.md)
testdata/golden/ reference output, committed so CI needs no Python
tools/ the golden-file generator
Dual-licensed under either MIT or Apache-2.0, at your option.
The vendored test data under testdata/spp/ keeps its own licence — BSD 3-Clause,
© 2023 University of Colorado — and is redistributed with the copyright notice that licence
requires. Provenance for every file is in testdata/SOURCES.md.
Unless you state otherwise, any contribution you intentionally submit for inclusion in this work shall be dual-licensed as above, without any additional terms or conditions.