# `LTC5582Controller` - Control for the LTC5582 power detector board.
#
# Copyright (C) 2021 Dyadic Pty Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import (
Optional, Callable
)
from PyQt5.QtCore import (
pyqtSignal, QObject
)
import json
import numpy as np
import serial
from scipy import (
interpolate
)
from rfblocks import (
LogDetector, write_cmd
)
[docs]class CalibrationRangeError(Exception):
"""Raised when calibration data is unavailable.
This is either because there is no calibration data at all
or if the calibration is attempted outside of the range of
the available calibration data.
"""
pass
[docs]class PwrDetectorController(QObject):
"""Higher level control for power detector boards
:py:`PwrDetectorController` maintains the state configuration for
a power detector board. This includes the current
signal input power and frequency. The controller also holds
application specific calibration data associated with the
detector and optionally applies corrections to the measured
signal power using the calibration data.
The following signals are defined:
- :py:`freq_changed(float)`
- :py:`pwr_changed(float)`
- :py:`caldata_changed()`
- :py:`detector_initialized()`
- :py:`correction_state_changed(bool)`
- :py:`enable_state_changed(bool)`
Documentation for the LTC5582 and LT5537 power detector rfblocks modules
can be found here: `LTC5582 and LT5537 Power Detectors <../boards/LTC5582-Power-Detector.html>`_
"""
ATTEN_CAL_CONDITION = 'attenuation'
freq_changed = pyqtSignal(float)
pwr_changed = pyqtSignal(float)
caldata_changed = pyqtSignal()
detector_initialized = pyqtSignal()
correction_state_changed = pyqtSignal(bool)
enable_state_changed = pyqtSignal(bool)
def __init__(self,
controller_id: str,
device: LogDetector) -> None:
"""
:param controller_id: The controller name
:type controller_id: str
:param device: :py:class:`rfblocks.LogDetector`.
:type device: :py:class:`rfblocks.LogDetector`
"""
super().__init__()
self._controller_id: str = controller_id
self._detector: LogDetector = device
self._enabled: bool = True
self._freq: float = self.min_frequency
self._pwr: float = self.min_power
self._avg: int = 16
self._cal_data = None
self._cal_data_conditions = {}
self._calibrating: bool = False
self._apply_correction: bool = False
self._insertion_loss_offset: float = 0.0
self._power_offset_fn = None
self.loss_interp_fn = None
@property
def enabled(self) -> bool:
"""Enable/disable operation of the power detector.
By default the power detector operation is enabled.
"""
return self._enabled
@enabled.setter
def enabled(self, flag: bool) -> None:
if self._enabled != flag:
self._enabled = flag
self.enable_state_changed.emit(flag)
@property
def min_frequency(self) -> float:
"""The minimum valid detector signal frequency.
"""
return self._detector.frequency_range[0]
@property
def max_frequency(self) -> float:
"""The maximum valid detector signal frequency.
"""
return self._detector.frequency_range[1]
@property
def min_power(self) -> float:
return self._detector.power_range[0]
@property
def max_power(self) -> float:
return self._detector.power_range[1]
@property
def apply_correction(self) -> bool:
"""Control applying corrections to the measured signal power.
By default, corrections are not applied. Set to True in
order to apply corrections. Note that calibration data
must be available via the :py:`cal_data` property.
"""
return self._apply_correction
@apply_correction.setter
def apply_correction(self, f: bool) -> None:
if self._apply_correction != f:
self._apply_correction = f
self.correction_state_changed.emit(f)
@property
def calibrating(self) -> bool:
"""Inform the controller when a calibration is taking place.
During calibration corrections are not applied and any
specified :py:`power_offset` is ignored.
"""
return self._calibrating
@calibrating.setter
def calibrating(self, cal) -> None:
self._calibrating = cal
@property
def insertion_loss_offset(self) -> float:
"""The insertion loss offset.
:param loss: The new insertion loss offset value in dB
:type loss: float
The insertion loss offset is added to the insertion loss
as calculated from the calibration data (see :py:`insertion_loss`).
The offset is generally used in situations where some
known level of attenuation (or amplification) is added
between the signal source and the detector after the
calibration data has been captured. The correction then
becomes an estimate since the additional attenuation/amplification
has not been calibrated and may exhibit unquantified
variations as a function of frequency (for example).
"""
return self._insertion_loss_offset
@insertion_loss_offset.setter
def insertion_loss_offset(self, loss):
self._insertion_loss_offset = loss
[docs] def set_power_offset_fn(self,
fn: Optional[Callable[[float], float]]) -> None:
"""Set the power offset function.
:param fn: A function returning the power loss (or gain) at
a specified frequency. The power loss (or gain) value
calculated by this function will generally be the
(usually frequency dependent) value of an attenuator or
amplifier inserted between the power detector and the
signal input. This would be used, for example, to
condition the input signal to be measured so that it falls
into the linear range of the detector.
:type fn: Callable[[float], float]
This function will only be used when :py:`apply_correction` is
False and takes the place of the correction that would be applied
using the calibration data.
"""
self._power_offset_fn = fn
[docs] def power_offset(self, freq: float) -> float:
"""Calculate the power offset at the specified frequency.
:param freq: Frequency (in MHz) to calculate power_offset.
:type freq: float
:return: The power loss (or gain) at the specified frequency.
The ``power_loss`` value is added to the measured power value
in the circumstance where ``apply_correction`` is ``False``.
In general, ``power_loss`` will be set to the value of any
attenuation or gain which is permanently inserted between the
power detector and the power meter input.
"""
pwr = 0.0
if self._power_offset_fn is not None:
pwr = self._power_offset_fn(freq)
return pwr
@property
def avg(self) -> int:
"""The number of detector measurements to average over.
By default this is 16.
"""
return self._avg
@avg.setter
def avg(self, a: int):
self._avg = a
@property
def freq(self) -> float:
"""The frequency of the detector input signal.
"""
return self._freq
def set_freq(self, f: float) -> None:
self.freq = f
@freq.setter
def freq(self, f: float) -> None:
"""Set the frequency of the power detector input signal.
:param f: The signal frequency in MHz.
:type f: float
See :py:class:`rfblocks.ltc5582` for more details about
how the signal frequency is used to correct the raw
measured detector power.
"""
if self._freq != f:
self._freq = f
self.freq_changed.emit(f)
@property
def pwr(self) -> float:
"""Returns the measured signal power in dBm.
.. note::
If :py:`apply_correction` is True and :py:`cal_data`
provides valid calibration data then the measured
signal power will be corrected using this.
"""
return self._pwr
@property
def linear_range(self):
"""Return the detector's linear response range.
:return: A tuple containing the minimum and maximum input signal powers
for which the detector provides a linear response.
If no calibration data is available the
:py:exc:`CalibrationDataException` is raised.
"""
min_limit, max_limit = self._detector.linear_range(self.freq)
min_limit += self.insertion_loss_offset
max_limit += self.insertion_loss_offset
if self.apply_correction and self.cal_data:
loss = self.insertion_loss(self.freq)
return min_limit+loss, max_limit+loss
else:
return min_limit, max_limit
@property
def cal_data(self):
"""Return application calibration data.
The format for the calibration data is::
{ frequency_string: power_in_dbm, ... }
For example::
{'100.0': 0.204, '200.0': -0.031, '300.0': 0.063, ...
...
'4100.0': 0.262, '4200.0': 0.246}
where the frequency is specified in MHz.
If no calibration data is available this will return `None`.
.. seealso::
:py:meth:`load_caldata`
"""
return self._cal_data
@cal_data.setter
def cal_data(self, d):
self._cal_data = d
if d is not None:
self.loss_interp_fn = None
self.caldata_changed.emit()
@property
def cal_data_conditions(self):
"""A dictionary for storing any conditions associated with
acquisition of detector calibration data. For example, if
the detector has a step attenuator associated with it then
the attenuation setting for which the calibration was carried
out can be saved here.
"""
return self._cal_data_conditions
[docs] def save_caldata(self, output_file):
"""Save the prevailing calibration data to a file.
:param output_file: Path to file for saving the calibration data.
:type output_file: str
"""
with open(output_file, 'w') as fd:
json.dump(self.cal_data, fd)
[docs] def load_caldata(self, input_file):
"""Load calibration data from a file.
:param input_file: Path to file for loading the calibration data.
:type input_file: str
.. seealso::
:py:meth:`cal_data`
"""
with open(input_file, 'r') as fd:
self.cal_data = json.load(fd)
[docs] def insertion_loss(self, f):
"""Return the insertion loss at the specified signal frequency.
:param f: The signal frequency in MHz
:type f: float
:return: The insertion loss in dB.
:raises: ValueError
If the specified signal frequency is outside the range
of the calibration data.
The insertion loss is calculated by cubic spline interpolation
on the calibration data as returned from :py:`cal_data`.
"""
if self.loss_interp_fn is None:
freqs = np.array([float(f) for f in self.cal_data.keys()])
loss = np.array([v for v in self.cal_data.values()])
self.loss_interp_fn = interpolate.interp1d(
freqs, loss, kind='cubic')
return self.loss_interp_fn(f)
[docs] def is_connected(self, ser: serial.Serial) -> bool:
"""Test if there is a connected detector.
:param ser: serial device to write commands to
:type ser: serial.Serial
:return: True if there is a connected power detector,
False otherwise.
"""
return self._detector.is_connected(ser)
[docs] def initialize(self, ser: serial.Serial) -> None:
"""Initialize the power detector.
"""
write_cmd(ser, self._detector.pin_config())
self._detector.initialize(ser)
self.detector_initialized.emit()
[docs] def measure(self, ser: serial.Serial) -> None:
"""Measure the detector signal power.
:param ser: Power detector serial connection
:type ser: serial.Serial
:raises: :py:class:`rfblocks.CalibrationRangeError`
If :py:`apply_correction` is True then insertion loss
correction will be carried out on the measured signal
power. Insertion loss is calculated from the calibration
data returned by :py:`cal_data`. If no calibration is
available or if the specified signal frequency is outside
the calibration data range,
a :py:class:`rfblocks.CalibrationRangeError`
exception is raised.
If :py:`apply_correction` is False and the detector is
not being calibrated then :py:meth:`power_offset` is
subtracted from the measured signal power.
The :py:meth:`insertion_loss_offset` value is applied
to the measured signal power regardless of whether
:py:`apply_correction` is True or False.
"""
if self.enabled:
self._pwr = self._detector.power(ser, self.freq, self.avg)
if self.apply_correction is True:
if self.cal_data:
# Adjust the measured pwr using the provided cal. data.
# The cal. data is interpolated to the specified
# frequency and treated as an insertion loss.
try:
self._pwr += self.insertion_loss(self.freq)
except ValueError:
raise CalibrationRangeError(
"Signal frequency outside calibration range")
else:
raise CalibrationRangeError(
"No calibration data available")
else:
if self.calibrating is False:
self._pwr -= self.power_offset(self.freq)
self._pwr += self.insertion_loss_offset
self.pwr_changed.emit(self._pwr)