diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7638550d3..8b4a7e6d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,18 @@ jobs: command: check args: --verbose + - uses: actions/setup-python@v1 + with: + python-version: 3.8 + - name: Install Python Dependencies + run: | + python -m pip install --upgrade pip + pip install -r scripts/requirements.txt + pip install pylint + - name: Run Pylint + run: | + pylint scripts/ + compile: runs-on: ubuntu-latest continue-on-error: ${{ matrix.toolchain == 'nightly' }} diff --git a/.github/workflows/release-docs.yml b/.github/workflows/release-docs.yml index 4a9542913..1b305f7fc 100644 --- a/.github/workflows/release-docs.yml +++ b/.github/workflows/release-docs.yml @@ -28,6 +28,12 @@ jobs: command: install args: mdbook-linkcheck + - name: Install ToC + uses: actions-rs/cargo@v1 + with: + command: install + args: mdbook-toc + - uses: peaceiris/actions-mdbook@v1 with: mdbook-version: '0.4.12' diff --git a/book/README.md b/book/README.md index 97b2d91f7..f80eb38d4 100644 --- a/book/README.md +++ b/book/README.md @@ -5,6 +5,7 @@ This folder hosts the source used for generating Stabilizer's user manual. The user manual is generated using `mdbook`, which can be installed via cargo: ``` cargo install mdbook +cargo install mdbook-toc cargo install mdbook-linkcheck ``` diff --git a/book/book.toml b/book/book.toml index 3ed564715..9be7b2e03 100644 --- a/book/book.toml +++ b/book/book.toml @@ -9,6 +9,11 @@ title = "Stabilizer" create-missing = false build-dir = "stabilizer-manual" +[preprocessor.toc] +marker = "<-- TOC -->" +command = "mdbook-toc" +renderer = ["html"] + [output.html] site-url = "/stabilizer" git-repository-url = "https://github.com/quartiq/stabilizer" diff --git a/book/src/usage.md b/book/src/usage.md index e27651470..26a0626b2 100644 --- a/book/src/usage.md +++ b/book/src/usage.md @@ -1,3 +1,10 @@ + + +### Table of Contents + +<-- TOC --> + + # Miniconf Run-time Settings Stabilizer supports run-time settings configuration using MQTT. @@ -67,6 +74,29 @@ Refer to the documentation for [Miniconf](firmware/miniconf/enum.Error.html) for description of the possible error codes that Miniconf may return if the settings update was unsuccessful. +# IIR Configuration +For the `dual-iir` application, a Python utility has been written to easily configure the IIR +filters for a variety of filtering and control applications. + +The script is located in `scripts/dual_iir_configuration.py`. + +To use the script, install dependencies: +```bash +python -m venv --system-site-packages vpy + +# Refer to https://docs.python.org/3/tutorial/venv.html for more information on activating the +# virtual environment. This command is different on different platforms. +./vpy/Scripts/activate + +python -m pip install scripts/requirements.txt +``` + +Then, use the built-in help to learn how the utility can automatically configure your IIR filters +for you: +```bash +python scripts/dual_iir_configuration.py --help +``` + # Telemetry Stabilizer applications publish telemetry utilizes MQTT for managing run-time settings configurations as well as live telemetry diff --git a/hitl/loopback.py b/hitl/loopback.py index 475cd6397..942c7eab8 100644 --- a/hitl/loopback.py +++ b/hitl/loopback.py @@ -11,25 +11,12 @@ from gmqtt import Client as MqttClient from miniconf import Miniconf +import stabilizer # The minimum allowable loopback voltage error (difference between output set point and input # measured value). MINIMUM_VOLTAGE_ERROR = 0.010 -def _voltage_to_machine_units(voltage): - """ Convert a voltage to IIR machine units. - - Args: - voltage: The voltage to convert - - Returns: - The IIR machine-units associated with the voltage. - """ - dac_range = 4.096 * 2.5 - assert abs(voltage) <= dac_range, 'Voltage out-of-range' - return voltage / dac_range * 0x7FFF - - def static_iir_output(output_voltage): """ Generate IIR configuration for a static output voltage. @@ -39,7 +26,7 @@ def static_iir_output(output_voltage): Returns The IIR configuration to send over Miniconf. """ - machine_units = _voltage_to_machine_units(output_voltage) + machine_units = stabilizer.voltage_to_machine_units(output_voltage) return { 'y_min': machine_units, 'y_max': machine_units, diff --git a/py/stabilizer/__init__.py b/py/stabilizer/__init__.py index e69de29bb..0cf97d0cd 100644 --- a/py/stabilizer/__init__.py +++ b/py/stabilizer/__init__.py @@ -0,0 +1,26 @@ +#!/usr/bin/python3 +""" +Author: QUARTIQ GmbH + +Description: General utilities for interfacing with Stabilizer using Python. +""" + +# The number of DAC LSB codes per volt on Stabilizer outputs. +DAC_LSB_PER_VOLT = (1 << 16) / (4.096 * 5) + +# The absolute full-scale output voltage in either positive or negative direction exposed by the +# DAC. +DAC_FULL_SCALE = float(0x7FFF / DAC_LSB_PER_VOLT) + +def voltage_to_machine_units(voltage): + """ Convert a voltage to machine units. + + Args: + voltage: The voltage to convert + + Returns: + The machine-units associated with the voltage. + """ + code = int(round(voltage * DAC_LSB_PER_VOLT)) + assert abs(code) <= 0x7FFF, f'Voltage out-of-range ({hex(code)})' + return code diff --git a/scripts/dual_iir_configuration.py b/scripts/dual_iir_configuration.py new file mode 100644 index 000000000..1087294e3 --- /dev/null +++ b/scripts/dual_iir_configuration.py @@ -0,0 +1,278 @@ +#!/usr/bin/python3 +""" +Author: Leibniz University Hannover, Institute of Quantum Optics, Étienne Wodey + Vertigo Designs, Ryan Summers + +Description: Provides a mechanism to configure dual-iir IIR filters using a high-level API. +""" +import argparse +import asyncio +import collections +import logging + +from math import pi + +from miniconf import Miniconf + +import stabilizer + +#pylint: disable=invalid-name + +# The base Stabilizer tick rate in Hz. +STABILIZER_TICK_RATE = 100e6 + +# Generic type for containing a command-line argument. Use `add_argument` for simple construction. +Argument = collections.namedtuple('Argument', ['positionals', 'keywords']) + +""" Represents a generic filter that can be represented by an IIR filter. + +# Fields + * `help`: This field specifies helpful human-readable information that will be presented to + users on the command line. + * `arguments`: A list of `Argument` objects representing available command-line arguments for + the filter. Use the `add_argument()` function to easily parse options as they would be provided + to argparse. + * `calculate_coefficients`: A function, provided two argumetns, that returns the IIR + coefficients. See below for more information on this function. + +# Coefficients Calculation Function + + Description: + This function takes in two input arguments and returns the IIR filter coefficients for + Stabilizer to represent the necessary filter. + + Args: + sampling_period: The period between discrete samples of the input signal. + args: The filter command-line arguments. Any filter-related arguments may be accessed via + their name. E.g. `args.K`. + + Returns: + [b0, b1, b2, -a1, -a2] IIR coefficients to be programmed into a Stabilizer IIR filter + configuration. +""" +Filter = collections.namedtuple('Filter', ['help', 'arguments', 'calculate_coefficients']) + +def add_argument(*args, **kwargs): + """ Convert arguments into an Argument tuple. """ + return Argument(args, kwargs) + + +def get_filters(): + """ Get a dictionary of all available filters. + + Note: + Calculations coefficient largely taken using the derivations in page 9 of + https://arxiv.org/pdf/1508.06319.pdf + + PII/PID coefficient equations are taken from the PID-IIR primer written by Robert Jördens at + https://hackmd.io/IACbwcOTSt6Adj3_F9bKuw + """ + return { + 'lowpass': Filter(help='Gain-limited low-pass filter', + arguments=[ + add_argument('--f0', required=True, type=float, + help='Corner frequency (Hz)'), + add_argument('--K', required=True, type=float, + help='Lowpass filter gain'), + ], + calculate_coefficients=calculate_lowpass_coefficients), + 'highpass': Filter(help='Gain-limited high-pass filter', + arguments=[ + add_argument('--f0', required=True, type=float, + help='Corner frequency (Hz)'), + add_argument('--K', required=True, type=float, + help='Highpass filter gain'), + ], + calculate_coefficients=calculate_highpass_coefficients), + 'allpass': Filter(help='Gain-limited all-pass filter', + arguments=[ + add_argument('--f0', required=True, type=float, + help='Corner frequency (Hz)'), + add_argument('--K', required=True, type=float, + help='Allpass filter gain'), + ], + calculate_coefficients=calculate_allpass_coefficients), + 'notch': Filter(help='Notch filter', + arguments=[ + add_argument('--f0', required=True, type=float, + help='Corner frequency (Hz)'), + add_argument('--Q', required=True, type=float, + help='Filter quality factor'), + add_argument('--K', required=True, type=float, + help='Filter gain'), + ], + calculate_coefficients=calculate_notch_coefficients), + 'pid': Filter(help='PID controller', + arguments=[ + add_argument('--Kp', default=0, type=float, + help='Proportional (P) gain at 1 Hz'), + add_argument('--Ki', default=0, type=float, + help='Integral (I) gain at 1 Hz'), + add_argument('--Kii', default=0, type=float, + help='Integral Squared (I^2) gain at 1 Hz'), + add_argument('--Kd', default=0, type=float, + help='Derivative (D) gain at 1 Hz'), + add_argument('--Kdd', default=0, type=float, + help='Derivative Squared (D^2) gain at 1 Hz'), + ], + calculate_coefficients=calculate_pid_coefficients), + } + + +def calculate_lowpass_coefficients(sampling_period, args): + """ Calculate low-pass IIR filter coefficients. """ + f0_bar = pi * args.f0 * sampling_period + + a1 = (1 - f0_bar) / (1 + f0_bar) + b0 = args.K * (f0_bar / (1 + f0_bar)) + b1 = args.K * f0_bar / (1 + f0_bar) + + return [b0, b1, 0, a1, 0] + + +def calculate_highpass_coefficients(sampling_period, args): + """ Calculate high-pass IIR filter coefficients. """ + f0_bar = pi * args.f0 * sampling_period + + a1 = (1 - f0_bar) / (1 + f0_bar) + b0 = args.K * (f0_bar / (1 + f0_bar)) + b1 = - args.K / (1 + f0_bar) + + return [b0, b1, 0, a1, 0] + + +def calculate_allpass_coefficients(sampling_period, args): + """ Calculate all-pass IIR filter coefficients. """ + f0_bar = pi * args.f0 * sampling_period + + a1 = (1 - f0_bar) / (1 + f0_bar) + + b0 = args.K * (1 - f0_bar) / (1 + f0_bar) + b1 = - args.K + + return [b0, b1, 0, a1, 0] + + +def calculate_notch_coefficients(sampling_period, args): + """ Calculate notch IIR filter coefficients. """ + f0_bar = pi * args.f0 * sampling_period + + denominator = (1 + f0_bar / args.Q + f0_bar ** 2) + + a1 = 2 * (1 - f0_bar ** 2) / denominator + a2 = - (1 - f0_bar / args.Q + f0_bar ** 2) / denominator + b0 = args.K * (1 + f0_bar ** 2) / denominator + b1 = - (2 * args.K * (1 - f0_bar ** 2)) / denominator + b2 = args.K * (1 + f0_bar ** 2) / denominator + + return [b0, b1, b2, a1, a2] + + +def calculate_pid_coefficients(sampling_period, args): + """ Calculate PID IIR filter coefficients. """ + + # First, determine the lowest feed-back rank we can use. + if args.Kii != 0: + assert args.Kdd == 0, 'IIR filters with both I^2 and D^2 coefficients are unsupported' + assert args.Kd == 0, 'IIR filters with both I^2 and D coefficients are unsupported' + feedback_kernel = 2 + elif args.Ki != 0: + assert args.Kdd == 0, 'IIR filters with both I and D^2 coefficients are unsupported' + feedback_kernel = 1 + else: + feedback_kernel = 0 + + KERNELS = [ + [1, 0, 0], + [1, -1, 0], + [1, -2, 1] + ] + + digital_conversion = (2 * pi) / sampling_period + + FEEDFORWARD_COEFFICIENTS = [ + [args.Kp, args.Kd / digital_conversion, args.Kdd / (digital_conversion ** 2)], + [args.Ki * digital_conversion, args.Kp, args.Kd / digital_conversion], + [args.Kii * (digital_conversion ** 2), args.Ki * digital_conversion, args.Kp], + ] + + # We now select the type of kernel using the rank of the feedback rank. + # a-coefficients are defined purely by the feedback kernel. + a_coefficients = KERNELS[feedback_kernel] + + b_coefficients = [0, 0, 0] + for (kernel, gain) in zip(KERNELS, FEEDFORWARD_COEFFICIENTS[feedback_kernel]): + for index, kernel_value in enumerate(kernel): + b_coefficients[index] += kernel_value * gain + + # Note: Normalization is redundant because a0 is defined to be 1 in all cases. Because of this, + # normalization is skipped. + b_norm = b_coefficients + a_norm = a_coefficients + + return [b_norm[0], b_norm[1], b_norm[2], -1 * a_norm[1], -1 * a_norm[2]] + + +def main(): + """ Main program entry point. """ + parser = argparse.ArgumentParser( + description='Configure Stabilizer dual-iir filter parameters. Note: This script assumes ' + 'an AFE input gain of 1.') + parser.add_argument('--broker', '-b', type=str, default='mqtt', + help='The MQTT broker to use to communicate with Stabilizer') + parser.add_argument('--prefix', '-p', type=str, required=True, + help='The Stabilizer device prefix to use for communication. E.g. ' + 'dt/sinara/dual-iir/00-11-22-33-44-55') + parser.add_argument('--channel', '-c', type=int, choices=[0, 1], required=True, + help='The filter channel to configure.') + parser.add_argument('--sample-ticks', type=int, default=128, + help='The number of Stabilizer hardware ticks between each sample') + + parser.add_argument('--x-offset', type=float, default=0, + help='The channel input offset level (Volts)') + parser.add_argument('--y-min', type=float, default=-stabilizer.DAC_FULL_SCALE, + help='The channel minimum output level (Volts)') + parser.add_argument('--y-max', type=float, default=stabilizer.DAC_FULL_SCALE, + help='The channel maximum output level (Volts)') + parser.add_argument('--y-offset', type=float, default=0, + help='The channel output offset level (Volts)') + + # Next, add subparsers and their arguments. + subparsers = parser.add_subparsers(help='Filter-specific design parameters', dest='filter_type') + + filters = get_filters() + + for (filter_name, filt) in filters.items(): + subparser = subparsers.add_parser(filter_name, help=filt.help) + for arg in filt.arguments: + subparser.add_argument(*arg.positionals, **arg.keywords) + + args = parser.parse_args() + + # Calculate the IIR coefficients for the filter. + sampling_period = args.sample_ticks / STABILIZER_TICK_RATE + coefficients = filters[args.filter_type].calculate_coefficients(sampling_period, args) + + # The feed-forward gain of the IIR filter is equivalent to the summation of the 'b' components + # of the filter. + forward_gain = sum(coefficients[:3]) + + async def configure(): + logging.info('Connecting to broker') + interface = await Miniconf.create(args.prefix, args.broker) + + # Set the filter coefficients. + # Note: In the future, we will need to Handle higher-order cascades. + await interface.command(f'iir_ch/{args.channel}/0', { + 'ba': coefficients, + 'y_min': stabilizer.voltage_to_machine_units(args.y_min), + 'y_max': stabilizer.voltage_to_machine_units(args.y_max), + 'y_offset': stabilizer.voltage_to_machine_units( + args.y_offset + forward_gain * args.x_offset) + }) + + asyncio.run(configure()) + + +if __name__ == '__main__': + main()