Source code for rfblocks.pwr_detector_controller

# `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)