diff --git a/CHANGELOG.md b/CHANGELOG.md index e159fac..07f3563 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] - DATE + +### Changed + +* The default output power interlock threshold has been increased from 0 dBm to 20 dBm. + +### Added + +* Booster CLI tool support for power transform calibrations. + ## [0.6.0] - 2024-08-28 ### Changed diff --git a/py/booster/__init__.py b/py/booster/__init__.py index 4863944..ccfe552 100644 --- a/py/booster/__init__.py +++ b/py/booster/__init__.py @@ -9,6 +9,7 @@ import json import enum import logging +import sys import miniconf from aiomqtt import MqttError @@ -45,7 +46,6 @@ def __init__(self, client, prefix): client: A connected MQTT5 client. prefix: The prefix of the booster to control. """ - self.prefix = prefix self.miniconf = miniconf.Miniconf(client, prefix) async def perform_action(self, action: Action, channel: str): @@ -58,16 +58,11 @@ async def perform_action(self, action: Action, channel: str): Returns: The received response to the action. """ - message = json.dumps( - { - "channel": CHANNEL[channel], - } - ) + message = json.dumps({"channel": CHANNEL[channel]}) - response = await self.miniconf._do( - f"{self.prefix}/command/{action.value}", payload=message + return await self.miniconf._do( + f"{self.miniconf.prefix}/command/{action.value}", payload=message ) - return json.loads(response[0]) async def tune_bias(self, channel, current): """Set a booster RF bias current. @@ -86,9 +81,10 @@ async def tune_bias(self, channel, current): async def set_bias(voltage): await self.miniconf.set(f"/channel/{channel}/bias_voltage", voltage) - # Sleep 100 ms for bias current to settle and for ADC to take current measurement. + # Sleep 200 ms for bias current to settle and for ADC to take current measurement. await asyncio.sleep(0.2) response = await self.perform_action(Action.ReadBiasCurrent, channel) + response = json.loads(response[0]) vgs, ids = response["vgs"], response["ids"] print(f"Vgs = {vgs:.3f} V, Ids = {ids * 1000:.2f} mA") return vgs, ids @@ -128,3 +124,122 @@ async def set_bias(voltage): break return vgs, ids + + async def calibrate(self, channel, transform): + """Calibrate a linear transform. + + Args: + channel: The channel to calibrate the transform on. + transform: The transform to calibrate (input, output, reflected) + + Returns: + The transform + """ + backup = {} + for k in f"/channel/{channel}/state /telemetry_period".split(): + backup[k] = json.loads(await self.miniconf.get(k)) + + try: + print( + f"""Calibrating the `{transform}` power detector linear transform on channel {channel}. + +The transform calibration routine requires two different operating powers. +For optimal accuracy, they should support the desired operating power range +and both be at the same input signal carrier frequency representative of the +desired operating frequency range. + +For each power condition, establish it, determine the true power by measuring it and +input it at the prompt. The routine determines the apparent power and computes a new +corrected transform.""" + ) + await self.miniconf.set(f"/channel/{channel}/state", "Enabled") + await asyncio.sleep(0.4) + await self.miniconf.set(f"/telemetry_period", 1) + + # This is merely to avoid the queue full warnings + # and the inconvenient async-iterator-only interface of aiomqtt + queue = asyncio.Queue(1) + + async def tele(): + async with miniconf.Client( + self.miniconf.client._hostname, + protocol=miniconf.MQTTv5, + ) as tele: + topic = f"{self.miniconf.prefix}/telemetry/ch{channel}" + await tele.subscribe(topic) + try: + async for msg in tele.messages: + try: + queue.put_nowait(msg) + except asyncio.QueueFull: + continue + except asyncio.CancelledError: + pass + finally: + await tele.unsubscribe(topic) + + cal = [] + async with asyncio.TaskGroup() as tg: + tele = tg.create_task(tele()) + for i in range(2): + while True: + try: + true = float( + await ainput( + f"Enter current true `{transform}` power in dBm:" + ) + ) + break + except ValueError as e: + print(f"Error: {e}, try again") + while True: + try: + queue.get_nowait() + except asyncio.QueueEmpty: + break + msg = json.loads((await queue.get()).payload) + if ( + msg["reflected_overdrive"] + or msg["output_overdrive"] + or msg["alert"] + or msg["state"] != "Enabled" + ): + raise ValueError( + "Channel tripped, overdriven, or in alert condition: ", msg + ) + apparent = msg[f"{transform}_power"] + print(f"Current apparent `{transform}` power: {apparent} dBm") + cal.append((apparent, true)) + tele.cancel() + + current = json.loads( + await self.miniconf.get( + f"/channel/{channel}/{transform}_power_transform" + ) + ) + print(f"Current `{transform}` transform: {current}") + (qa, pa), (qb, pb) = cal + pm = (pa + pb) / 2 + qm = (qa + qb) / 2 + dpdq = (pa - pb) / (qa - qb) + new = dict( + slope=dpdq * current["slope"], + offset=pm + dpdq * (current["offset"] - qm), + ) + print(f"Proposed new `{transform}` transform: {new}") + if await ainput("Enter `y` to set new transform:") == "y": + await self.miniconf.set( + f"/channel/{channel}/{transform}_power_transform", new + ) + print("Set (use `save` to write to flash memory)") + else: + print("Not set") + + finally: + for k, v in backup.items(): + await self.miniconf.set(k, v) + + +async def ainput(string: str) -> str: + print(string, end=" ", flush=True) + return (await asyncio.to_thread(sys.stdin.readline)).rstrip("\n") diff --git a/py/booster/__main__.py b/py/booster/__main__.py index f43f395..20240ef 100644 --- a/py/booster/__main__.py +++ b/py/booster/__main__.py @@ -6,11 +6,10 @@ """ import argparse import asyncio +import logging from . import Booster, Action import miniconf -import logging - # A dictionary of all the available commands, their number of arguments, argument type, and help # information about the command. @@ -24,6 +23,11 @@ "type": float, "help": "Tune the channel RF drain current to the specified amps", }, + "calibrate": { + "nargs": 1, + "type": str, + "help": "Calibrate a transform (input, output, or reflected)", + } } @@ -113,9 +117,10 @@ async def channel_configuration(args): print( f"Channel {args.channel}: Vgs = {vgs:.3f} V, Ids = {ids * 1000:.2f} mA" ) + elif command == "calibrate": + await booster.calibrate(args.channel, cmd_args[0]) - loop = asyncio.get_event_loop() - loop.run_until_complete(channel_configuration(parser.parse_args())) + asyncio.run(channel_configuration(parser.parse_args())) if __name__ == "__main__": diff --git a/src/settings/eeprom/rf_channel.rs b/src/settings/eeprom/rf_channel.rs index 17ca57a..f3f0e74 100644 --- a/src/settings/eeprom/rf_channel.rs +++ b/src/settings/eeprom/rf_channel.rs @@ -77,13 +77,20 @@ impl DecodeOwned for ChannelState { /// Represents booster channel-specific configuration values. #[derive(Tree, Encode, DecodeOwned, Debug, Copy, Clone, PartialEq)] pub struct ChannelSettings { + // dBm #[tree(validate=Self::validate_output_interlock)] pub output_interlock_threshold: f32, + + // V #[tree(validate=Self::validate_bias_voltage)] pub bias_voltage: f32, + pub state: ChannelState, + pub input_power_transform: LinearTransformation, + pub output_power_transform: LinearTransformation, + pub reflected_power_transform: LinearTransformation, } @@ -91,25 +98,29 @@ impl Default for ChannelSettings { /// Generate default booster channel data. fn default() -> Self { Self { - output_interlock_threshold: 0.0, + // dBm + output_interlock_threshold: 20.0, + + // V bias_voltage: -3.2, + state: ChannelState::Off, // When operating at 100MHz, the power detectors specify the following output - // characteristics for -10 dBm to 10 dBm (the equation uses slightly different coefficients - // for different power levels and frequencies): + // characteristics for -10 dBm to 10 dBm: // // dBm = V(Vout) / .035 V/dB - 35.6 dBm // // All of the power meters are preceded by attenuators which are incorporated in // the offset. output_power_transform: LinearTransformation::new(1.0 / 0.035, -35.6 + 19.8 + 10.0), - // The input power and reflected power detectors are then passed through an - // op-amp with gain 1.5x - this modifies the slope from 35mV/dB to 52.5mV/dB + + // The input power and reflected power detectors have an op-amp gain of 1.5 reflected_power_transform: LinearTransformation::new( 1.0 / 1.5 / 0.035, -35.6 + 19.8 + 10.0, ), + input_power_transform: LinearTransformation::new(1.0 / 1.5 / 0.035, -35.6 + 8.9), } } @@ -131,8 +142,7 @@ impl ChannelSettings { } // Verify the interlock is mappable to a DAC threshold. - let output_interlock_voltage = self.output_power_transform.invert(new); - if !(0.00..=ad5627::MAX_VOLTAGE).contains(&output_interlock_voltage) { + if !(0.0..=ad5627::MAX_VOLTAGE).contains(&self.output_power_transform.invert(new)) { return Err("Output interlock threshold voltage out of range"); }