Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

calibration #448

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
135 changes: 125 additions & 10 deletions py/booster/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import json
import enum
import logging
import sys

import miniconf
from aiomqtt import MqttError
Expand Down Expand Up @@ -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):
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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")
13 changes: 9 additions & 4 deletions py/booster/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)",
}
}


Expand Down Expand Up @@ -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__":
Expand Down
24 changes: 17 additions & 7 deletions src/settings/eeprom/rf_channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,39 +77,50 @@ 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,
}

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),
}
}
Expand All @@ -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");
}

Expand Down