Stellar ControlMission control · by Stellar Systems v0.1.0

Tools

Code Generators

stellar generate: the typed skeleton of a Python driver from a catalogue, typed Python functions launching the procedures of a library, and the simulation of a platform.

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:

CommandFromWrites
stellar generate driver <catalogue>A catalogue packageThe 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 proceduresOne pair of functions per procedure, launching it through the API client with its roles, inputs and environments typed
stellar generate simulation <catalogue>A catalogue packageThe 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:

OwnerWrittenMeaning
The generatorEvery timeSays what the configuration says; never edited by hand, generated again with each version of the catalogue or library
The developerOnly when missingWritten 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#

text
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 optionDefaultMeaning
CATALOGUEdriver: the catalogue package (lab-psu), or lab-psu@1.0.0 to pick one of several versions; the highest version of the repository otherwise
LIBRARYclient: the library of steps and procedures (platform-v3-steps)
-o, --output <DIR>requiredOutput directory, a Python package; created when missing
--software <NAME>the catalogue packagedriver: the software name the driver registers with
--version <VERSION>0.1.0driver: the version of the driver software
--repository <DIR>.Configuration repository
--langpythonLanguage of the SDK; Python only for now
--checkoffWrite nothing; fail when the generator's files of the output directory do not match the configuration (CI)
--config <FILE>STELLAR_CONFIGGlobal 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 codeMeaning
0Generated; with --check, up to date (`lab_psu` is up to date)
1The 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)
2The command could not run: unknown catalogue or library (the message lists those of the repository), a directory that cannot be written

stellar generate driver#

Shell
stellar generate driver lab-psu --repository examples/config -o lab_psu \
    --software scpi-psu --version 1.0.0

The package has four files:

FileOwnerContent
_generated.pythe generatorWhat 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__.pythe developerThe package
driver.pythe developerThe concrete driver: one encode_… method per telecommand and decode, as placeholders raising NotImplementedError, and main()
test_driver.pythe developerOne 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:

Python
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",
]
  • CATALOGUE is 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.
  • TELECOMMANDS lists every telecommand of the catalogue, those of files included; MEASURES the measures the driver decodes, without those of files and stream, 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:

Python
@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:

Python
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:

Python
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:

Python
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#

Shell
stellar generate client platform-v3-steps --repository examples/config -o platform_v3_steps

The 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):

FunctionReturnsDoes
<procedure>(client, *, <roles>, <inputs>, environment, on_ask=None, on_event=None)RunResultLaunches the procedure and follows the run to its end (Run.wait)
launch_<procedure>(client, *, <roles>, <inputs>, environment)RunLaunches 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.

Python
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 inputPython typeSent as
RolestrThe target of the role
boolboolAs is
Number with a unit (f32 V)float or str28.0 becomes "28.0 V"; a quantity such as "28 V" is sent as written and converted by the MCS
Number without unitint or floatAs is
Enum of a platforman enum.StrEnum of the module (TcuId)Its value
bytesbytesHexadecimal
Duration, instantstrAs written ("30 s")
file_id of <type>int or strThe on-board file identifier
filebytes or strBytes 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:

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

YAML
# 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.

Stellar Control · v0.1.0

↑↓ to moveEnter to open