stellar generate writes code from the compiled configuration, so that code outside the MCS
follows the catalogues and libraries it depends on instead of repeating them by hand:
| Command | From | Writes |
|---|---|---|
stellar generate driver <catalogue> | A catalogue package | The typed skeleton of a driver on the Python SDK: arguments of each telecommand, builders of the raw samples, coverage, and the encoding and decoding to write |
stellar generate client <library> | A library of steps and procedures | One pair of functions per procedure, launching it through the API client with its roles, inputs and environments typed |
stellar generate simulation <catalogue> | A catalogue package | The simulation of the platform, simulations/<name>.yaml: a simulated target that keeps the contract of the catalogue, checked before it is written. See A simulation written from the catalogue |
They compile the configuration repository first, like stellar check: a repository with errors
generates nothing. driver and client write a Python package in their output directory, and
separate what the generator owns from what the developer owns:
| Owner | Written | Meaning |
|---|---|---|
| The generator | Every time | Says what the configuration says; never edited by hand, generated again with each version of the catalogue or library |
| The developer | Only when missing | Written once as a starting point, then yours: stellar generate never overwrites it (kept … (yours)) |
The code is written as ruff format writes it, lines of 100 characters at most, so that
formatting it changes nothing.
Command line#
stellar generate driver <CATALOGUE> -o <DIR> [--software NAME] [--version VERSION]
[--repository DIR] [--lang python] [--check] [--config FILE]
stellar generate client <LIBRARY> -o <DIR>
[--repository DIR] [--lang python] [--check] [--config FILE]| Argument or option | Default | Meaning |
|---|---|---|
CATALOGUE | driver: the catalogue package (lab-psu), or lab-psu@1.0.0 to pick one of several versions; the highest version of the repository otherwise | |
LIBRARY | client: the library of steps and procedures (platform-v3-steps) | |
-o, --output <DIR> | required | Output directory, a Python package; created when missing |
--software <NAME> | the catalogue package | driver: the software name the driver registers with |
--version <VERSION> | 0.1.0 | driver: the version of the driver software |
--repository <DIR> | . | Configuration repository |
--lang | python | Language of the SDK; Python only for now |
--check | off | Write nothing; fail when the generator's files of the output directory do not match the configuration (CI) |
--config <FILE> | STELLAR_CONFIG | Global configuration, for the compilation parameters |
Each file written or kept is reported on standard error (wrote lab_psu/_generated.py,
kept lab_psu/driver.py (yours)).
| Exit code | Meaning |
|---|---|
| 0 | Generated; with --check, up to date (`lab_psu` is up to date) |
| 1 | The repository has errors (its diagnostics are printed, then Nothing generated: fix the errors first.); with --check, a generated file differs from what the configuration gives (`…/_generated.py` does not match the configuration: run `stellar generate` again) |
| 2 | The command could not run: unknown catalogue or library (the message lists those of the repository), a directory that cannot be written |
stellar generate driver#
stellar generate driver lab-psu --repository examples/config -o lab_psu \
--software scpi-psu --version 1.0.0The package has four files:
| File | Owner | Content |
|---|---|---|
_generated.py | the generator | What the catalogue says: constants, enums, one class per telecommand, one class of sample builders per component, and the abstract base class of the driver |
__init__.py | the developer | The package |
driver.py | the developer | The concrete driver: one encode_… method per telecommand and decode, as placeholders raising NotImplementedError, and main() |
test_driver.py | the developer | One test per telecommand, with example arguments, to compare with the frames of the ICD |
examples/python/lab_psu is the skeleton of lab-psu, completed.
_generated.py#
The module starts with the identity of the catalogue and the coverage the driver registers:
PACKAGE = "lab-psu"
VERSION = "1.0.0"
HASH = "e39a56b1df97b0432a111efe4903e9b3ab4a4ff61371cf52842907bf23bee3ad"
"""Hash of the compiled catalogue the module was generated from."""
CATALOGUE = "lab-psu@^1.0"
"""Catalogue the driver implements, and the versions it fits."""
TELECOMMANDS: list[str] = [
"psu.set_voltage",
"psu.output_on",
"psu.output_off",
]
MEASURES: list[str] = [
"psu.voltage",
"psu.current",
"psu.output_enabled",
]CATALOGUEis the requirement the driver registers: this version and the later ones of the same major version (platform-v3@^1.4). See Registration: catalogue and coverage.TELECOMMANDSlists every telecommand of the catalogue, those offilesincluded;MEASURESthe measures the driver decodes, without those offilesandstream, which the MCS produces itself.- Each enum of the catalogue is an
enum.StrEnum(TcuOperatingMode.STANDBY).
Telecommands. Each telecommand has a frozen dataclass of its typed arguments, with the
defaults of the catalogue, an instance field for a multi-instance component, and semantic, the
SemanticTc as the MCS sent it (identifier, target, link, environment). Its docstring carries the
description of the catalogue, its type, unit and range, and whether it is hazardous. from_tc
reads the arguments of a telecommand received, defaults applied, bytes decoded from
hexadecimal, enum values checked:
@dataclass(frozen=True, slots=True, kw_only=True)
class PsuSetVoltage:
"""Arguments of `psu.set_voltage`: Sets the output voltage, the output on or off."""
voltage: float
"""Voltage to deliver. Type f32 in V, within [0 V, 60 V]."""
semantic: SemanticTc | None = None
"""The telecommand as the MCS sent it: identifier, target, link, environment."""
@classmethod
def from_tc(cls, tc: SemanticTc) -> PsuSetVoltage:
"""The typed arguments of a telecommand received from the MCS."""
return cls(
voltage=float(tc["voltage"]),
semantic=tc,
)Samples. A class per component builds the raw samples of its measures, typed by the raw type of a calibrated measure (the MCS applies the calibration), with the instance for a multi-instance component and an optional on-board time:
class PsuMeasures:
@staticmethod
def voltage(
value: float,
*,
onboard_time: Timestamp | None = None,
) -> RawSample:
"""Output voltage. Type f32 in V."""
return RawSample("psu", "voltage", value, onboard_time=onboard_time)Base class. <Package>DriverBase builds the Driver of the SDK with the catalogue and the
coverage, reads each telecommand into its class and dispatches it to its abstract
encode_<component>_<telecommand>(tc, context); decode(frame, context) is abstract too. Its
constructor takes the software name and version, and passes other options (instance, codec,
output, params_schema) to the Driver. run() runs the driver until SIGINT or SIGTERM.
driver.py and test_driver.py#
The developer writes the encoding and the decoding from the ICD in driver.py, on the typed
classes. This is the completed driver of lab-psu:
class LabPsuDriver(LabPsuDriverBase):
"""Encodes the telecommands of lab-psu@1.0.0 and decodes its frames."""
def encode_psu_set_voltage(self, tc: PsuSetVoltage, context: LinkContext) -> bytes | str:
return f"VOLT {tc.voltage}"
def encode_psu_output_on(self, tc: PsuOutputOn, context: LinkContext) -> bytes | str:
return "OUTP 1"
def encode_psu_output_off(self, tc: PsuOutputOff, context: LinkContext) -> bytes | str:
return "OUTP 0"
def decode(self, frame: bytes, context: LinkContext) -> Iterable[RawSample]:
# `VOLT 28.0;CURR 0.5;OUTP 1`: three samples of the `psu` component.
for field in frame.decode().strip().split(";"):
key, value = field.split()
match key:
case "VOLT":
yield PsuMeasures.voltage(float(value))
case "CURR":
yield PsuMeasures.current(float(value))
case "OUTP":
yield PsuMeasures.output_enabled(value == "1")
def main() -> None:
LabPsuDriver("scpi-psu", "1.0.0").run()test_driver.py is written with one test per telecommand, its arguments set to their default,
the low end of their range, or zero; the developer replaces the generated assertion with the
frame of the ICD:
CONTEXT = LinkContext(target="lab-psu-1", link="nominal")
DRIVER = LabPsuDriver("scpi-psu", "1.0.0")
def test_psu_set_voltage() -> None:
frame = DRIVER.encode_psu_set_voltage(
PsuSetVoltage(voltage=28.0),
CONTEXT,
)
assert frame == "VOLT 28.0"Run the driver with python -m lab_psu.driver, and its tests with pytest.
A new version of the catalogue#
Generate the package again: _generated.py follows the catalogue, driver.py and
test_driver.py are kept. A telecommand added to the catalogue is a new abstract method, which a
type checker (mypy) and the instantiation of the driver report until it is written; a
telecommand renamed or removed leaves an encode_… method that no longer overrides anything.
stellar generate client#
stellar generate client platform-v3-steps --repository examples/config -o platform_v3_stepsThe package has two files, both the generator's: _generated.py and an __init__.py that
re-exports it. examples/python/platform_v3_steps is the package of platform-v3-steps.
For each procedure of the library, the module has two functions, named after the procedure in
snake case (Hot standby test → hot_standby_test):
| Function | Returns | Does |
|---|---|---|
<procedure>(client, *, <roles>, <inputs>, environment, on_ask=None, on_event=None) | RunResult | Launches the procedure and follows the run to its end (Run.wait) |
launch_<procedure>(client, *, <roles>, <inputs>, environment) | Run | Launches it and returns once the MCS accepted it |
The procedures still run in the executor of the MCS, with its leases, confirmations, log and
validated versions: a function only builds the run request, with library set to the library,
and calls Client.launch.
async def launch_hot_standby_test(
client: Client,
*,
sat: str,
psu: str,
tcu: TcuId,
bus_voltage: float | str,
environment: Literal["AIT", "IVV"],
) -> Run:
"""Launches `Hot standby test` and returns once the MCS accepted it: follow it with
`Run.events()` or `Run.wait()`. Powers the platform from the bench supply, then brings a TCU
to hot standby.
Roles: `sat` a target of platform-v3, `psu` a target of lab-psu. Inputs: `tcu` a value of
`tcu_id`; `bus_voltage` a number in V or a quantity such as `"28 V"`. Allowed in AIT, IVV.
Lasts at most 71s."""
return await client.launch(
"Hot standby test",
library=LIBRARY,
environment=environment,
targets={
"sat": sat,
"psu": psu,
},
inputs={
"tcu": tcu.value,
"bus_voltage": _quantity(bus_voltage, "V"),
},
)Parameters. Every role and input is a keyword parameter, named after it (a Python keyword
or a clash with client, environment, on_ask or on_event gets a trailing _):
| Role or input | Python type | Sent as |
|---|---|---|
| Role | str | The target of the role |
bool | bool | As is |
Number with a unit (f32 V) | float or str | 28.0 becomes "28.0 V"; a quantity such as "28 V" is sent as written and converted by the MCS |
| Number without unit | int or float | As is |
| Enum of a platform | an enum.StrEnum of the module (TcuId) | Its value |
bytes | bytes | Hexadecimal |
| Duration, instant | str | As written ("30 s") |
file_id of <type> | int or str | The on-board file identifier |
file | bytes or str | Bytes are uploaded first with Client.upload; a string is the hash of a content already stored |
environment is a Literal of the environments of allowed in when the procedure declares
them, a str otherwise. Each enum used by an input becomes a class, named after the enum, or
after its platform and the enum when two platforms have an enum of that name. The docstrings say
the roles, inputs, environments, the maximum duration of the procedure and whether it sends
hazardous telecommands, with its description.
Use. examples/python/hot_standby.py runs « Hot standby test » on the simulated targets:
from platform_v3_steps import TcuId, hot_standby_test
from stellar_mcs import Client
async with Client(args.api, args.identity) as mcs:
result = await hot_standby_test(
mcs,
sat="sim-1",
psu="psu-sim-1",
tcu=args.tcu,
bus_voltage=args.bus_voltage,
environment="AIT",
on_ask=confirm,
on_event=show,
)examples/python/test_procedures_live.py runs the generated functions with pytest against a
running MCS with the example configuration and its simulators, when STELLAR_TEST_API_URL is
set: a way to test procedures end to end from a test bench.
The MCS still checks everything at launch: a wrong type caught by the type checker is a
convenience, not a guarantee, and an input out of range or a target of the wrong platform is
refused by the API (ApiError, see Error Codes).
In CI#
Keep the generator's files in the repository of the code that uses them, and check them against the configuration in CI: a new version of the catalogue or of the library then fails the build until the package is generated again.
# GitHub Actions, in the repository of the driver and of the scripts
- run: stellar generate driver lab-psu --repository config -o lab_psu --software scpi-psu --check
- run: stellar generate client platform-v3-steps --repository config -o platform_v3_steps --check
- run: uv run mypy . && uv run pytest--check compares only the generator's files: driver.py, test_driver.py and the
__init__.py of a driver are the developer's and never checked.
See also Writing a Driver, Python SDK and CLI Reference.