Design Guide
This document provides an overview of the design principles and architecture of the PySquared Flight Software. It is intended for developers who want to understand how the software is structured and how to contribute effectively.
CircuitPython
PySquared is built on top of CircuitPython, which is a version of Python designed for microcontrollers. CircuitPython is a fork of MicroPython which adhears to a subset of the Python language specification. Python 3.4 syntax is supported with some additional features pulled from later releases such as type hinting.
Resources
- Python 3.4 Reference
- Differences between MicroPython and Python
- Differences between CircuitPython and MicroPython
- CircuitPython Shared Bindings Documentation
- CircuitPython Standard Libraries Documentation
- CircuitPython Design Guide
Types and Type Checking
We use type hints throughout the PySquared codebase to ensure that our code is clear and maintainable. Type hints help us catch errors early and make it easier to understand the expected types of variables and function parameters.
We do not accept changes with lines that are ignored the type checker i.e. # type: ignore
. If you run into an issue where you think you need to ignore a type, it is likely a problem with the design of your component. Please take a moment to think about how you can fix the type error instead. If you need help, please reach out for assistance.
Using the Typing Module
For more advanced type hinting we can use the Python standard library's typing
module which was introduced in Python 3.5. This module provides a variety of type hints that can be used to specify more complex types, such as List
, Dict
, and Optional
. CircuitPython does not support the typing
module so we must wrap the import in a try/except block to avoid import errors. For example:
This pattern allows us to use type hints in our code while still being compatible with CircuitPython.
Additionally we cannot use typing
's Any
type hint in CircuitPython. Instead, we can use object
as a generic type hint.
Protocols
Protocols are a way to define a set of methods that a class must implement. They are similar to interfaces in other programming languages or header files in C. Protocols allow us to define a contract for classes to follow, ensuring that they implement the required methods. CircuitPython does not support Protocols, so we use base classes to define our protocols where all required methods are implemented with ...
(Ellipsis). All classes that implement the protocol must override these methods. Protocols can be found in pysquared/protos/
.
Testing
We use pytest for unit testing our code. We are designing software for spacecraft, so it is important that we have a robust testing framework to ensure our code is reliable and works as expected. We write tests for all of our code, and we run these tests automatically using GitHub Actions. We aim to have 100% test coverage for all of our code, which means that every line of code is tested by at least one test case.
Documentation
We use MkDocs to build our documentation. We write our documentation in Markdown, which is a lightweight markup language that is easy to read and write. We document our code using docstrings, which are special comments that describe the purpose and usage of a function or class. We also use type hints in our docstrings to provide additional information about the expected types of parameters and return values. Where it makes sense, add usage examples following CommonMark fenced code blocks to document how to use your code.
Module Documentation
Start with a brief summary, followed by an optional extended description:
"""This module provides utilities for parsing and validating telemetry data from spacecraft sensors.
It includes classes and functions for decoding sensor packets, verifying data integrity, and converting
raw readings into SI units for further analysis.
"""
Class Documentation
Begin with a short description, a detailed explanation, and a practical usage example:
"""The TelemetryParser class extracts and validates sensor readings from raw telemetry packets.
TelemetryParser handles packet decoding, error checking, and conversion to SI units. It is designed
for use in spacecraft flight software where reliable sensor data is critical.
**Usage:**
~~~python
from pysquared.telemetry import TelemetryParser
parser = TelemetryParser()
packet = b'\x01\x02\x03\x04'
reading = parser.parse(packet)
print(reading.timestamp, reading.acceleration) # Output: 2024-06-01T12:00:00Z (0.0, 9.8, 0.0)
~~~
"""
Function/Method Documentation
Include a description, argument details, return values, and any exceptions raised:
"""
Validate a sensor reading and convert it to SI units.
Args:
reading: Raw sensor reading with keys 'value' and 'unit'.
sensor_type: Type of sensor (e.g., 'acceleration', 'temperature').
Returns:
float: The validated reading in SI units.
Raises:
KeyError: If required keys are missing from the reading.
ValueError: If the reading value is out of expected range.
"""
Sensor Readings
All sensor readings must be in SI units and stored in a structure that includes the time of the reading. Including the time of the reading is important for analysing sensor data and ensuring that processes such as detumbling and attitude control can be performed accurately.
The following table lists possible sensor properties, their corresponding types and units for common sensor readings. The table was pulled directly from the CircuitPython Design Guide:
Property Name | Python Type | Units / Description |
---|---|---|
acceleration | (float, float, float) | x, y, z meter per second² |
alarm | (time.struct, str) | Sample alarm time and frequency string |
CO2 | float | measured CO₂ in ppm |
color | int | RGB, eight bits per channel (0xff0000 is red) |
current | float | milliamps (mA) |
datetime | time.struct | date and time |
distance | float | centimeters (cm) |
duty_cycle | int | 16-bit PWM duty cycle |
eCO2 | float | equivalent/estimated CO₂ in ppm |
frequency | int | Hertz (Hz) |
gyro | (float, float, float) | x, y, z radians per second |
light | float | non-unit-specific light levels |
lux | float | SI lux |
magnetic | (float, float, float) | x, y, z micro-Tesla (uT) |
orientation | (float, float, float) | x, y, z degrees |
pressure | float | hectopascal (hPa) |
proximity | int | non-unit-specific proximity values |
relative_humidity | float | percent |
sound_level | float | non-unit-specific sound level |
temperature | float | degrees Celsius |
TVOC | float | Total Volatile Organic Compounds in ppb |
voltage | float | volts (V) |
weight | float | grams (g) |
Definitions for sensor readings can be found in pysquared/sensors/
Handling Sensor Reading Failures
Sensor reading failures must be expected and handled gracefully. If a sensor reading fails, the code should log an error message and return a default value (e.g., 0.0
for numeric readings or None
for optional readings). This ensures that the system can continue to operate even if a sensor is temporarily unavailable. In the case of a sensor hanging, the attempt must time out and return a default value.
Resources
- Adafruit Unified Sensor Driver
- Android Motion Sensor Documentation
- Android Position Sensor Documentation
- Android Environment Sensor Documentation
Dependency Management
We use uv
for managing our python development environment and dependencies. It allows us to define our dependencies in a pyproject.toml
file and provides a consistent way to install and manage them across different environments. We use dependency groups to separate the dependencies needed for running on the satellite pyproject.dependencies
, development pyproject.dev
, and documentation pyproject.docs
.
uv
is downloaded and installed automatically when you use run make
commands. Please see the Makefile or make help
for more information on how to use uv
to manage your development environment.
Linting and Code Style
We use ruff
for linting and formatting our code. ruff
is a fast, extensible linter that checks our code for errors and enforces specific coding standards and style. We use ruff
's default configuration with only one addition, isort (-I
), for linting and formatting our code.
Linting
ruff
checks our code for errors following pyflakes logic.
Code Style
By default ruff
, enforces the black
style with a few deviations decided by ruff
for formatting our code. Code formatting ensures that our code is consistent and easy to read.
Error Handling
Error handling in PySquared is designed to be robust and predictable. We use standard try...except
blocks to catch exceptions. When an exception is caught, it should be logged with the logger.error()
or logger.critical()
method. This ensures that we have a record of the error and can diagnose it later.
try:
# Code that may raise an exception
except Exception as e:
logger.error("An error occurred", err=e)
Custom exceptions should be used to represent specific error conditions in your code. This allows us to handle different types of errors in a more granular way. Custom exceptions should inherit from the built-in Exception
class and should be named using the Error
suffix.
class CustomError(Exception):
"""Custom exception for specific error conditions."""
pass
try:
# Code that may raise a CustomError
except CustomError as e:
logger.error("A custom error occurred", err=e)
When raising exceptions, always provide a clear and descriptive error message. This will help us understand the context of the error when it is logged.
Logging
The syntax for our logging module logger
is based off the popular Python logger Loguru
. We use the logger
module to log messages at different levels (debug
, info
, warning
, error
, critical
) throughout our code. This allows us to track the flow of execution and diagnose issues when they arise.
Logs are structured as JSON, which makes them easy to parse and analyze. When logging, you can include additional key-value pairs to provide context.
Code that raises an exception should log at the error
level. Code that failed but is recoverable should log at the warning
level. The debug
level should be used to understand the flow of the program during development and debugging. The info
level should be used for general information about the program's execution, such as startup, shutdown, and other important updates. critical
should be used for serious errors that may prevent the satellite from continuing operation, requiring a restart.
Configuration
Configuration management in PySquared is centralized in the Config
class. This class is responsible for loading, validating, and providing access to all configuration settings, which are stored in a JSON file.
Loading and Accessing Configuration
The Config
class is initialized with the path to the configuration file. It parses the JSON and exposes the settings as attributes.
Usage:
from pysquared.config import Config
# Initialize the config with the path to your settings file
config = Config("config.json")
# Access configuration values directly
print(f"Satellite Name: {config.cubesat_name}")
print(f"Sleep Duration: {config.sleep_duration} seconds")
Updating Configuration
The update_config
method allows for both temporary (in-memory) and permanent (persisted to the JSON file) changes to the configuration.
- Temporary Updates: Changes are only applied to the
Config
object in memory and will be lost on restart. - Permanent Updates: Changes are written back to the configuration file.
# Temporarily update the sleep duration
config.update_config("sleep_duration", 120, temporary=True)
# Permanently update the satellite's name
config.update_config("cubesat_name", "PyCubed", temporary=False)
Validation
The Config
class includes a validation schema to ensure that all configuration values are within expected ranges and of the correct type. Any attempt to set an invalid value will raise a TypeError
or ValueError
. This helps prevent runtime errors due to misconfiguration.
Radio Configuration
Radio-specific settings are managed by the RadioConfig
class, which is a nested object within the main Config
class.
Imports
We use relative imports for all of our modules. This allows us to easily import pysquared into downstream libraries like our board specific repos. For example, if we have a module pysquared.sensors.temperature
, we can import it in another module using:
Non-Volatile Memory (NVM)
We use the pysquared.nvm
module to manage non-volatile memory (NVM) on the flight control board. This module provides a way to store persistent data across reboots, such as error counters and other important state information. The NVM module includes a Counter
class for counting events and a Flag
class for storing boolean flags.