Source code for rfblocks.ad9552_controller

# `AD9552Controller` - Control for the AD9552 clock generator.
#
#    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, Dict
)

import serial

from PyQt5.QtCore import (
    pyqtSignal, QObject
)

from rfblocks import (
    ad9552, write_cmd, query_cmd
)


[docs]class AD9552Channel(QObject): """Higher level control for an AD9552 clock generator channel. :py:`AD9552Channel` maintains the state configuration for a single ad9552 clock generator channel. The following signals are defined: - :py:`state_changed(int, int)` - :py:`mode_changed(int, int)` - :py:`drive_changed(int, int)` - :py:`polarity_changed(int, int)` - :py:`source_changed(int, int)` """ DEFAULT_CHAN_MODE = ad9552.OutputMode.LVPECL DEFAULT_CHAN_STATE = ad9552.OutputState.POWERED_DOWN DEFAULT_CHAN_DRIVE = ad9552.DriveStrength.STRONG DEFAULT_CHAN_POLARIY = ad9552.CmosPolarity.DIFF_POS DEFAULT_CHAN_SOURCE = ad9552.SourceControl.PLL DEFAULT_CHAN_CONFIG = { 'mode': DEFAULT_CHAN_MODE, 'state': DEFAULT_CHAN_STATE, 'drive': DEFAULT_CHAN_DRIVE, 'polarity': DEFAULT_CHAN_POLARIY, 'source': DEFAULT_CHAN_SOURCE } state_changed = pyqtSignal(int, int) mode_changed = pyqtSignal(int, int) drive_changed = pyqtSignal(int, int) polarity_changed = pyqtSignal(int, int) source_changed = pyqtSignal(int, int) def __init__(self, chan_id: str, chan_config: Optional[Dict] = None) -> None: """ :param chan_id: Channel identifier (for example: '1'). :type chan_id: str :param chan_config: Initial configuration for the clock channel. If this is None the default configuration will be used. See :py:attr:`AD9552Channel.DEFAULT_CHAN_CONFIG` for a brief description of the structure for :py:`chan_config`. :type chan_config: Dict """ super().__init__() self._chan_id: int = int(chan_id) self._initial_config: Dict = chan_config if self._initial_config is None: self._initial_config = AD9552Channel.DEFAULT_CHAN_CONFIG self._label: str = self._initial_config['label'] self._mode: ad9552.OutputMode = self._initial_config['mode'] self._state: ad9552.OutputState = self._initial_config['state'] self._drive: ad9552.DriveStrength = self._initial_config['drive'] self._polarity: ad9552.CmosPolarity = self._initial_config['polarity'] self._source: ad9552.SourceControl = self._initial_config['source'] def __str__(self) -> str: s = (f'{self.__class__.__name__}: chan_id: {self.chan_id}, ' f'label: {self.label}, mode: {self.mode.name}, ' f'state: {self.state.name}, drive: {self.drive.name}, ' f'polarity: {self.polarity.name}, source: {self.source.name}') return s def __repr__(self) -> str: return "{}({!r})".format(self.__class__.__name__, vars(self)) @property def chan_id(self) -> int: return self._chan_id @property def label(self) -> str: return self._label @property def initial_config(self) -> Dict: """Return a dictionary containing the initial channel configuration. """ return self._initial_config @property def state(self) -> ad9552.OutputState: """The state of the clock channel. This should be either :py:`ad9552.OutputState.ACTIVE` or :py:`ad9552.OutputState.POWERED_DOWN`. :type: :class:`rfblocks.ad9552.OutputState` """ return self._state @state.setter def state(self, state: ad9552.OutputState) -> None: if state != self._state: self._state = state self.state_changed.emit(self.chan_id, self._state) @property def mode(self) -> ad9552.OutputMode: """The channel output mode. :type: :class:`rfblocks.ad9552.OutputMode` Note that this will set the value of the :py:attr:`mode` property only. Updating the clock channel hardware should be done separately. See, for example, :py:meth:`AD9552Controller.configure`. """ return self._mode @mode.setter def mode(self, mode: ad9552.OutputMode) -> None: if mode != self._mode: self._mode = mode self.mode_changed.emit(self.chan_id, self._mode) @property def drive(self) -> ad9552.DriveStrength: """The channel drive strength. :type: :class:`rfblocks.ad9552.DriveStrength` Note that this will set the value of the :py:attr:`drive` property only. Updating the clock channel hardware should be done separately. See, for example, :py:meth:`AD9552Controller.configure`. """ return self._drive @drive.setter def drive(self, drive: ad9552.DriveStrength) -> None: if drive != self._drive: self._drive = drive self.drive_changed.emit(self.chan_id, self._drive) @property def polarity(self) -> ad9552.CmosPolarity: """The channel CMOS polarity :type: :class:`rfblocks.ad9552.CmosPolarity` Note that this will set the value of the :py:attr:`polarity` property only. Updating the clock channel hardware should be done separately. See, for example, :py:meth:`AD9552Controller.configure`. """ return self._polarity @polarity.setter def polarity(self, polarity: ad9552.CmosPolarity) -> None: if polarity != self._polarity: self._polarity = polarity self.polarity_changed.emit(self.chan_id, self._polarity) @property def source(self) -> ad9552.SourceControl: """The channel source control setting :type: :class:`rfblocks.ad9552.SourceControl` Note that this will set the value of the :py:attr:`source` property only. Updating the clock channel hardware should be done separately. See, for example, :py:meth:`AD9552Controller.configure`. """ return self._source @source.setter def source(self, source: ad9552.SourceControl) -> None: if source != self._source: self._source = source self.source_changed.emit(self.chan_id, self._source)
[docs] def dump_config(self) -> Dict: """Return the current configuration for this channel. :return: A dictionary containing the current clock channel configuration: :mode: channel mode (:py:class:`rfblocks.ad9552.OutputMode`) :state: channel output state (:py:class:`rfblocks.ad9552.OutputState`) :drive: channel drive strength (:py:class:`rfblocks.ad9552.DriveStrength`) :polarity: channel CMOS polarity (:py:class:`rfblocks.ad9552.CmosPolarity`) :source: channel source (:py:class:`rfblocks.ad9552.SourceControl`) >>> clk = ad9552('d0', 'c4', 'c5') >>> clk_ctl = AD9552Controller('Clk 1', clk) >>> chan1 = clk_ctl.channels['1'] >>> from pprint import pprint as pp >>> pp(chan1.dump_config()) {'drive': <DriveStrength.STRONG: 1>, 'mode': <OutputMode.LVPECL: 5>, 'polarity': <CmosPolarity.DIFF_POS: 0>, 'source': <SourceControl.PLL: 0>, 'state': <OutputState.POWERED_DOWN: 1>} """ config = { 'mode': self.mode, 'state': self.state, 'drive': self.drive, 'polarity': self.polarity, 'source': self.source } return config
[docs] def load_config(self, config: Dict) -> None: """Set the current configuration for this channel. :param config: A dictionary containing the channel configuration to be set. :type config: Dict >>> clk = ad9552('d0', 'c4', 'c5') >>> clk_ctl = AD9552Controller('Clk 1', clk) >>> chan1 = clk_ctl.channels['1'] >>> import copy >>> config = copy.deepcopy(chan1.dump_config()) >>> config['mode'] = ad9552.OutputMode.LVDS >>> config['drive'] = ad9552.DriveStrength.WEAK >>> chan1.load_config(config) >>> pp(chan1.dump_config()) {'drive': <DriveStrength.WEAK: 0>, 'mode': <OutputMode.LVDS: 4>, 'polarity': <CmosPolarity.DIFF_POS: 0>, 'source': <SourceControl.PLL: 0>, 'state': <OutputState.POWERED_DOWN: 1>} """ self.mode = config['mode'] self.state = config['state'] self.drive = config['drive'] self.polarity = config['polarity'] self.source = config['source']
[docs]class AD9552Controller(QObject): """Higher level control for the AD9552 clock generator. Documentation for the clock generator rfblocks module which uses the AD9552 can be found here: `A Low Jitter Variable Rate Clock Generator to 1000 MHz <../boards/AD9552-ClockSource.html>`_ """ DEFAULT_CLKOUT_FREQ: float = 250.0 DEFAULT_REFSRC: ad9552.ReferenceSource = ad9552.ReferenceSource.INTERNAL MIN_FREQUENCY: float = 10.0 MAX_FREQUENCY: float = 1100.0 DEFAULT_DEVICE_CONFIG: Dict = { 'freq': DEFAULT_CLKOUT_FREQ, 'channels': { '1': {'label': '1', **AD9552Channel.DEFAULT_CHAN_CONFIG}, '2': {'label': '2', **AD9552Channel.DEFAULT_CHAN_CONFIG} } } freq_changed = pyqtSignal(float) refsrc_changed = pyqtSignal(int) lock_status_changed = pyqtSignal(bool) def __init__(self, controller_id: str, device: ad9552, config: Optional[Dict] = None) -> None: """ :param controller_id: The controller name. :type controller_id: str :param device: An instance of :py:class:`rfblocks.ad9552`. :type device: :py:class:`rfblocks.ad9552` :param config: Initial configuration for the AD9552 clock generator board. If this is None the default configuration will be used. See :py:attr:`AD9552Controller.DEFAULT_DEVICE_CONFIG` for a brief description of the structure for :py:`config`. :py:`AD9552Controller` maintains the state configuration for an AD9552 clock generator board. The following signals are defined: - :py:`freq_changed(float)` - :py:`refsrc_changed(str)` """ super().__init__() self._controller_id: str = controller_id self._ad9552: ad9552 = device self._initial_config: Dict = config if self._initial_config is None: self._initial_config = AD9552Controller.DEFAULT_DEVICE_CONFIG self._freq: float = self._initial_config['freq'] self._min_freq: float = AD9552Controller.MIN_FREQUENCY self._max_freq: float = AD9552Controller.MAX_FREQUENCY self._ref_src = AD9552Controller.DEFAULT_REFSRC self._lock_status = False self._channels: Dict = { chan_id: AD9552Channel(chan_id, chan_config) for chan_id, chan_config in self._initial_config['channels'].items()} def __str__(self) -> str: s = (f'{self.__class__.__name__}: ' f'controller_id: {self.controller_id}, ' f'freq: {self.freq:.3f}, ref_src: {self.refsrc}, ' f'lock_status: {self.lock_status}, ' f'channels: {", ".join([f"[{ch}]" for ch_id, ch in self.channels.items()])}') return s def __repr__(self) -> str: return "{}({!r})".format(self.__class__.__name__, vars(self)) @property def controller_id(self) -> str: return self._controller_id @property def channels(self) -> Dict: return self._channels @property def freq(self) -> float: """Returns the current output frequency (in MHz) """ return self._freq @freq.setter def freq(self, f: float) -> None: """Set the current output frequency. :param f: The new output frequency (in MHz) :type f: float Note that this will set the value of the :py:attr:`freq` property only. Updating the clock module hardware should be done separately. See, for example, :py:meth:`configure_freq`. """ if f != self._freq: self._freq = f self.freq_changed.emit(self._freq) @property def channels(self) -> Dict: return self._channels @property def refsrc(self) -> str: """The reference source for the module. """ return self._ref_src @refsrc.setter def refsrc(self, src: ad9552.ReferenceSource) -> None: """Set the module reference source. :param src: The new reference source. This should be either :py:`ad9552.ReferenceSource.INTERNAL` for the on board reference or :py:`ad9552.ReferenceSource.EXTERNAL` for an external reference connected to the 'Ref' board input. :type src: ad9552.ReferenceSource Note that this will set the value of the :py:attr:`refsrc` property only. Updating the clock module hardware should be done separately. See, for example, :py:meth:`configure`. """ if src != self._ref_src: self._ref_src = src self._ad9552.refsrc = src self.refsrc_changed.emit(self._ref_src) @property def min_freq(self) -> float: return self._min_freq @min_freq.setter def min_freq(self, f: float) -> None: self._min_freq = f @property def max_freq(self) -> float: return self._max_freq @max_freq.setter def max_freq(self, f: float) -> None: self._max_freq = f @property def lock_status(self) -> bool: return self._lock_status @lock_status.setter def lock_status(self, status: bool) -> None: if status is not self._lock_status: self._lock_status = status self.lock_status_changed.emit(status)
[docs] def dump_config(self) -> Dict: """Return the current configuration for this clock module. :return: A dictionary containing the current clock modules configuration: :freq: The currently set output frequency (in MHz). :channels: A dictionary containing the channel configurations keyed using the channel id. >>> clk = ad9552('d0', 'c4', 'c5') >>> clk_ctl = AD9552Controller('Clk 1', clk) >>> from pprint import pprint as pp >>> pp(clk_ctl.dump_config()) {'channels': {'1': {'drive': <DriveStrength.STRONG: 1>, 'mode': <OutputMode.LVPECL: 5>, 'polarity': <CmosPolarity.DIFF_POS: 0>, 'source': <SourceControl.PLL: 0>, 'state': <OutputState.POWERED_DOWN: 1>}, '2': {'drive': <DriveStrength.STRONG: 1>, 'mode': <OutputMode.LVPECL: 5>, 'polarity': <CmosPolarity.DIFF_POS: 0>, 'source': <SourceControl.PLL: 0>, 'state': <OutputState.POWERED_DOWN: 1>}}, 'freq': 250.0} """ config = { 'freq': self._freq, 'channels': { chan_id: channel.dump_config() for chan_id, channel in self._channels.items() } } return config
[docs] def load_config(self, config: Dict) -> None: """Set the current configuration for this clock module. :param config: A dictionary containing the module configuration to be set. :type config: Dict >>> clk = ad9552('d0', 'c4', 'c5') >>> clk_ctl = AD9552Controller('Clk 1', clk) >>> chan1 = clk_ctl.channels['1'] >>> import copy >>> ch_config = copy.deepcopy(chan1.dump_config()) >>> ch_config['mode'] = ad9552.OutputMode.LVDS >>> ch_config['drive'] = ad9552.DriveStrength.WEAK >>> config = copy.deepcopy(clk_ctl.dump_config()) >>> config['channels']['1'] = ch_config >>> config['freq'] = 644.1 >>> clk_ctl.load_config(config) >>> pp(clk_ctl.dump_config()) {'channels': {'1': {'drive': <DriveStrength.WEAK: 0>, 'mode': <OutputMode.LVDS: 4>, 'polarity': <CmosPolarity.DIFF_POS: 0>, 'source': <SourceControl.PLL: 0>, 'state': <OutputState.POWERED_DOWN: 1>}, '2': {'drive': <DriveStrength.STRONG: 1>, 'mode': <OutputMode.LVPECL: 5>, 'polarity': <CmosPolarity.DIFF_POS: 0>, 'source': <SourceControl.PLL: 0>, 'state': <OutputState.POWERED_DOWN: 1>}}, 'freq': 644.1} """ self.freq = config['freq'] for chan_id, channel in self._channels.items(): channel.load_config(config['channels'][chan_id])
[docs] def initialize(self, ser: serial.Serial) -> bool: """Initialize the clock module. """ write_cmd(ser, self._ad9552.pin_config()) return self.configure(ser)
[docs] def configure(self, ser: serial.Serial) -> bool: """Update the clock module hardware using the currently set configuration. :param ser: Device update commands will be sent via this serial device. :type ser: serial.Serial :return: A boolean value indicating the clock generator PLL lock status. ``True`` for locked. """ locked = self.configure_freq(ser) for chan in self._channels.values(): cmd = self._ad9552.config_output_mode( chan.chan_id, chan.mode, chan.state, chan.drive, chan.polarity) write_cmd(ser, cmd) cmd = self._ad9552.config_src_control(chan.source) write_cmd(ser, cmd) write_cmd(ser, self._ad9552.config_refsrc()) return locked
[docs] def configure_freq(self, ser: serial.Serial) -> bool: """Update the clock module hardware with the currently set output frequency. :param ser: Device update commands will be sent via this serial device. :type ser: serial.Serial :return: A boolean value indicating the clock generator PLL lock status. ``True`` for locked. """ cmd = self._ad9552.config_output_frequency( self._ad9552.divider_values(self.freq)) write_cmd(ser, cmd) # Check the LOCKED pin cmd = self._ad9552.check_is_locked() resp = query_cmd(ser, cmd) locked: bool = False try: locked: bool = bool(resp[0]) except (ValueError, IndexError): pass self.lock_status = locked return locked