Example #1
0
class Input_Coeffs(QWidget):
    """
    Create widget with a (sort of) model-view architecture for viewing /
    editing / entering data contained in `self.ba` which is a list of two numpy
    arrays:

    - `self.ba[0]` contains the numerator coefficients ("b")
    - `self.ba[1]` contains the denominator coefficients ("a")

    The list don't neccessarily have the same length but they are always defined.
    For FIR filters, `self.ba[1][0] = 1`, all other elements are zero.

    The length of both lists can be egalized with `self._equalize_ba_length()`.

    Views / formats are handled by the ItemDelegate() class.


    """
    sig_tx = pyqtSignal(object) # emitted when filter has been saved
    sig_rx = pyqtSignal(object) # incoming from input_tab_widgets

    def __init__(self, parent):
        super(Input_Coeffs, self).__init__(parent)

        self.opt_widget = None # handle for pop-up options widget
        self.tool_tip = "Display and edit filter coefficients."
        self.tab_label = "b,a"
        
        self.data_changed = True # initialize flag: filter data has been changed
        self.fx_specs_changed = True # fixpoint specs have been changed outside

        self.ui = Input_Coeffs_UI(self) # create the UI part with buttons etc.
        self._construct_UI()

#------------------------------------------------------------------------------
    def process_sig_rx(self, dict_sig=None):
        """
        Process signals coming from sig_rx
        """
        logger.debug("process_sig_rx(): vis={0}\n{1}"\
                    .format(self.isVisible(), pprint_log(dict_sig)))

        if dict_sig['sender'] == __name__:
            logger.debug("Stopped infinite loop\n{0}".format(pprint_log(dict_sig)))
            return

        if  'ui_changed' in dict_sig and dict_sig['ui_changed'] == 'csv':
            self.ui._set_load_save_icons()

        elif self.isVisible():
            if self.data_changed or 'data_changed' in dict_sig:
                self.load_dict()
                self.data_changed = False
            if self.fx_specs_changed or ('fx_sim' in dict_sig and dict_sig['fx_sim'] == 'specs_changed'):
                self.qdict2ui()
                self.fx_specs_changed = False
        else:
            # TODO: draw wouldn't be necessary for 'view_changed', only update view 
            if 'data_changed' in dict_sig:
                self.data_changed = True
            elif 'fx_sim' in dict_sig and dict_sig['fx_sim'] == 'specs_changed':
                self.fx_specs_changed = True

#------------------------------------------------------------------------------
    def _construct_UI(self):
        """
        Intitialize the widget, consisting of:
        - top chkbox row
        - coefficient table
        - two bottom rows with action buttons
        """
        # ---------------------------------------------------------------------
        #   Coefficient table widget
        # ---------------------------------------------------------------------
        self.tblCoeff = QTableWidget(self)
        self.tblCoeff.setAlternatingRowColors(True)
        self.tblCoeff.horizontalHeader().setHighlightSections(True) # highlight when selected
        self.tblCoeff.horizontalHeader().setFont(self.ui.bfont)

#        self.tblCoeff.QItemSelectionModel.Clear
        self.tblCoeff.setDragEnabled(True)
#        self.tblCoeff.setDragDropMode(QAbstractItemView.InternalMove) # doesn't work like intended
        self.tblCoeff.setItemDelegate(ItemDelegate(self))

        # ============== Main UI Layout =====================================
        layVMain = QVBoxLayout()
        layVMain.setAlignment(Qt.AlignTop) # this affects only the first widget (intended here)
        layVMain.addWidget(self.ui)
        layVMain.addWidget(self.tblCoeff)

        layVMain.setContentsMargins(*params['wdg_margins'])

        self.setLayout(layVMain)

        self.myQ = fx.Fixed(fb.fil[0]['fxqc']['QCB']) # initialize fixpoint object
        self.load_dict() # initialize + refresh table with default values from filter dict
        # TODO: this needs to be optimized - self._refresh is being called in both routines
        self._set_number_format()

        #----------------------------------------------------------------------
        # GLOBAL SIGNALS & SLOTs
        #----------------------------------------------------------------------
        self.sig_rx.connect(self.process_sig_rx)
        #----------------------------------------------------------------------
        # LOCAL (UI) SIGNALS & SLOTs
        #----------------------------------------------------------------------
        # wdg.textChanged() is emitted when contents of widget changes
        # wdg.textEdited() is only emitted for user changes
        # wdg.editingFinished() is only emitted for user changes
        self.ui.butEnable.clicked.connect(self._refresh_table)
        self.ui.spnDigits.editingFinished.connect(self._refresh_table)

        self.ui.cmbQFrmt.currentIndexChanged.connect(self._set_number_format)
        self.ui.butFromTable.clicked.connect(self._copy_from_table)
        self.ui.butToTable.clicked.connect(self._copy_to_table)

        self.ui.cmbFilterType.currentIndexChanged.connect(self._filter_type)

        self.ui.butDelCells.clicked.connect(self._delete_cells)
        self.ui.butAddCells.clicked.connect(self._add_cells)
        self.ui.butLoad.clicked.connect(self.load_dict)
        self.ui.butSave.clicked.connect(self._save_dict)
        self.ui.butClear.clicked.connect(self._clear_table)
        self.ui.ledEps.editingFinished.connect(self._set_eps)
        self.ui.butSetZero.clicked.connect(self._set_coeffs_zero)

        # store new settings and refresh table
        self.ui.cmbFormat.currentIndexChanged.connect(self.ui2qdict)
        self.ui.cmbQOvfl.currentIndexChanged.connect(self.ui2qdict)
        self.ui.cmbQuant.currentIndexChanged.connect(self.ui2qdict)
        self.ui.ledWF.editingFinished.connect(self.ui2qdict)
        self.ui.ledWI.editingFinished.connect(self.ui2qdict)
        self.ui.ledW.editingFinished.connect(self._W_changed)

        self.ui.ledScale.editingFinished.connect(self._set_scale)

        self.ui.butQuant.clicked.connect(self.quant_coeffs)

        self.ui.sig_tx.connect(self.sig_tx)
        # =====================================================================

#------------------------------------------------------------------------------
    def _filter_type(self, ftype=None):
        """
        Get / set 'FIR' and 'IIR' filter from cmbFilterType combobox and set filter
            dict and table properties accordingly.

        When argument fil_type is not None, set the combobox accordingly.

        Reload from filter dict unless ftype is specified [does this make sense?!]
        """
        if ftype in {'FIR', 'IIR'}:
            ret=qset_cmb_box(self.ui.cmbFilterType, ftype)
            if ret == -1:
                logger.warning("Unknown filter type {0}".format(ftype))

        if self.ui.cmbFilterType.currentText() == 'IIR':
            fb.fil[0]['ft'] = 'IIR'
            self.col = 2
            self.tblCoeff.setColumnCount(2)
            self.tblCoeff.setHorizontalHeaderLabels(["b", "a"])
        else:
            fb.fil[0]['ft'] = 'FIR'
            self.col = 1
            self.tblCoeff.setColumnCount(1)
            self.tblCoeff.setHorizontalHeaderLabels(["b"])
            self.ba[1] = np.zeros_like(self.ba[1]) # enforce FIR filter
            self.ba[1][0] = 1.

        self._equalize_ba_length()
        qstyle_widget(self.ui.butSave, 'changed')
        self._refresh_table()

#------------------------------------------------------------------------------
    def _W_changed(self):
        """
        Set fractional and integer length `WF` and `WI` when wordlength `W` has
        been changed. Try to preserve `WI` or `WF` settings depending on the
        number format (integer or fractional).
        """
        W = safe_eval(self.ui.ledW.text(), self.myQ.W, return_type='int', sign='pos')

        if W < 2:
            logger.warn("W must be > 1, restoring previous value.")
            W = self.myQ.W # fall back to previous value
        self.ui.ledW.setText(str(W))

        if qget_cmb_box(self.ui.cmbQFrmt) == 'qint': # integer format, preserve WI bits
            WI = W - self.myQ.WF - 1
            self.ui.ledWI.setText(str(WI))
            self.ui.ledScale.setText(str(1 << (W-1)))
        else: # fractional format, preserve WF bit setting
            WF = W - self.myQ.WI - 1
            if WF < 0:
                self.ui.ledWI.setText(str(W - 1))
                WF = 0
            self.ui.ledWF.setText(str(WF))

        self.ui2qdict()

        #------------------------------------------------------------------------------
    def _set_scale(self):
        """
        Triggered by `ui.ledScale`
        Set scale for calculating floating point value from fixpoint representation
        and vice versa
        """
        # if self.ui.ledScale.isModified() ... self.ui.ledScale.setModified(False)
        scale = safe_eval(self.ui.ledScale.text(), self.myQ.scale, return_type='float', sign='pos')
        self.ui.ledScale.setText(str(scale))
        self.ui2qdict()

#------------------------------------------------------------------------------
    def _refresh_table_item(self, row, col):
        """
        Refresh the table item with the index `row, col` from self.ba
        """
        item = self.tblCoeff.item(row, col)
        if item: # does item exist?
            item.setText(str(self.ba[col][row]).strip('()'))
        else: # no, construct it:
            self.tblCoeff.setItem(row,col,QTableWidgetItem(
                  str(self.ba[col][row]).strip('()')))
        self.tblCoeff.item(row, col).setTextAlignment(Qt.AlignRight|Qt.AlignCenter)

#------------------------------------------------------------------------------
    def _refresh_table(self):
        """
        (Re-)Create the displayed table from `self.ba` (list with 2 one-dimensional
        numpy arrays). Data is displayed via `ItemDelegate.displayText()` in
        the number format set by `self.frmt`.

        - self.ba[0] -> b coefficients
        - self.ba[1] -> a coefficients
        
        The table dimensions are set according to the filter type set in 
        `fb.fil[0]['ft']` which is either 'FIR' or 'IIR' and by the number of 
        rows in `self.ba`.

        Called at the end of nearly every method.
        """
        if np.ndim(self.ba) == 1 or fb.fil[0]['ft'] == 'FIR':
            self.num_rows = len(self.ba[0])
        else:
            self.num_rows = max(len(self.ba[1]), len(self.ba[0]))

        # logger.warning("np.shape(ba) = {0}".format(np.shape(self.ba)))

        params['FMT_ba'] = int(self.ui.spnDigits.text())

        # When format is 'float', disable all fixpoint options
        is_float = (qget_cmb_box(self.ui.cmbFormat, data=False).lower() == 'float')

        self.ui.spnDigits.setVisible(is_float) # number of digits can only be selected
        self.ui.lblDigits.setVisible(is_float) # for format = 'float'
        self.ui.cmbQFrmt.setVisible(not is_float) # hide unneeded widgets for format = 'float'
        self.ui.lbl_W.setVisible(not is_float)
        self.ui.ledW.setVisible(not is_float)

        self.ui.frmQSettings.setVisible(not is_float) # hide all q-settings for float

        if self.ui.butEnable.isChecked():
            self.ui.butEnable.setIcon(QIcon(':/circle-x.svg'))
            self.ui.frmButtonsCoeffs.setVisible(True)
            self.tblCoeff.setVisible(True)

            # check whether filter is FIR and only needs one column
            if fb.fil[0]['ft'] == 'FIR':
                self.num_cols = 1
                self.tblCoeff.setColumnCount(1)
                self.tblCoeff.setHorizontalHeaderLabels(["b"])
                qset_cmb_box(self.ui.cmbFilterType, 'FIR')
            else:
                self.num_cols = 2
                self.tblCoeff.setColumnCount(2)
                self.tblCoeff.setHorizontalHeaderLabels(["b", "a"])
                qset_cmb_box(self.ui.cmbFilterType, 'IIR')

                self.ba[1][0] = 1.0 # restore fa[0] = 1 of denonimator polynome

            self.tblCoeff.setRowCount(self.num_rows)
            self.tblCoeff.setColumnCount(self.num_cols)
            # Create strings for index column (vertical header), starting with "0"
            idx_str = [str(n) for n in range(self.num_rows)]
            self.tblCoeff.setVerticalHeaderLabels(idx_str)

            self.tblCoeff.blockSignals(True)
            for col in range(self.num_cols):
                for row in range(self.num_rows):
                    self._refresh_table_item(row, col)

            # make a[0] selectable but not editable
            if fb.fil[0]['ft'] == 'IIR':
                item = self.tblCoeff.item(0,1)
                item.setFlags(Qt.ItemIsSelectable| Qt.ItemIsEnabled)
                item.setFont(self.ui.bfont)

            self.tblCoeff.blockSignals(False)

            self.tblCoeff.resizeColumnsToContents()
            self.tblCoeff.resizeRowsToContents()
            self.tblCoeff.clearSelection()

        else:
            self.ui.frmButtonsCoeffs.setVisible(False)
            self.ui.butEnable.setIcon(QIcon(':/circle-check.svg'))
            self.tblCoeff.setVisible(False)

#------------------------------------------------------------------------------
    def load_dict(self):
        """
        Load all entries from filter dict `fb.fil[0]['ba']` into the coefficient
        list `self.ba` and update the display via `self._refresh_table()`.

        The filter dict is a "normal" 2D-numpy float array for the b and a coefficients
        while the coefficient register `self.ba` is a list of two float ndarrays to allow
        for different lengths of b and a subarrays while adding / deleting items.
        """

        self.ba = [0., 0.] # initial list with two elements
        self.ba[0] = np.array(fb.fil[0]['ba'][0]) # deep copy from filter dict to
        self.ba[1] = np.array(fb.fil[0]['ba'][1]) # coefficient register

        # set quantization comboBoxes from dictionary
        self.qdict2ui()

        self._refresh_table()
        qstyle_widget(self.ui.butSave, 'normal')

    #------------------------------------------------------------------------------
    def _copy_options(self):
        """
        Set options for copying to/from clipboard or file.
        """
        self.opt_widget = CSV_option_box(self) # important: Handle must be class attribute
        #self.opt_widget.show() # modeless dialog, i.e. non-blocking
        self.opt_widget.exec_() # modal dialog (blocking)

    #------------------------------------------------------------------------------
    def _copy_from_table(self):
        """
        Copy data from coefficient table `self.tblCoeff` to clipboard / file in
        CSV format.
        """
        qtable2text(self.tblCoeff, self.ba, self, 'ba', self.myQ.frmt, title="Export Filter Coefficients")

    #------------------------------------------------------------------------------
    def _copy_to_table(self):
        """
        Read data from clipboard / file and copy it to `self.ba` as float / cmplx
        # TODO: More checks for swapped row <-> col, single values, wrong data type ...
        """
        data_str = qtext2table(self, 'ba', title="Import Filter Coefficients") # returns ndarray of str
        if data_str is None: # file operation has been aborted or some other error
            return

        logger.debug("importing data: dim - shape = {0} - {1} - {2}\n{3}"\
                       .format(type(data_str), np.ndim(data_str), np.shape(data_str), data_str))

        conv = self.myQ.frmt2float # frmt2float_vec?
        frmt = self.myQ.frmt

        if np.ndim(data_str) > 1:
            num_cols, num_rows = np.shape(data_str)
            orientation_horiz = num_cols > num_rows # need to transpose data
        elif np.ndim(data_str) == 1:
            num_rows = len(data_str)
            num_cols = 1
            orientation_horiz = False
        else:
            logger.error("Imported data is a single value or None.")
            return None
        logger.info("_copy_to_table: c x r = {0} x {1}".format(num_cols, num_rows))
        if orientation_horiz:
            self.ba = [[],[]]
            for c in range(num_cols):
                self.ba[0].append(conv(data_str[c][0], frmt))
                if num_rows > 1:
                    self.ba[1].append(conv(data_str[c][1], frmt))
            if num_rows > 1:
                self._filter_type(ftype='IIR')
            else:
                self._filter_type(ftype='FIR')
        else:
            self.ba[0] = [conv(s, frmt) for s in data_str[0]]
            if num_cols > 1:
                self.ba[1] = [conv(s, frmt) for s in data_str[1]]
                self._filter_type(ftype='IIR')
            else:
                self.ba[1] = [1]
                self._filter_type(ftype='FIR')

        self.ba[0] = np.asarray(self.ba[0])
        self.ba[1] = np.asarray(self.ba[1])

        self._equalize_ba_length()
        qstyle_widget(self.ui.butSave, 'changed')
        self._refresh_table()

#------------------------------------------------------------------------------
    def _set_number_format(self):
        """
        Triggered by `contruct_UI()`, `qdict2ui()`and by `ui.cmbQFrmt.currentIndexChanged()`
        
        Set one of three number formats: Integer, fractional, normalized fractional
        (triggered by self.ui.cmbQFrmt combobox)
        """

        qfrmt = qget_cmb_box(self.ui.cmbQFrmt)
        is_qfrac = False
        W = safe_eval(self.ui.ledW.text(), self.myQ.W, return_type='int', sign='pos')
        if qfrmt == 'qint':
            self.ui.ledWI.setText(str(W - 1))
            self.ui.ledWF.setText("0")
        elif qfrmt == 'qnfrac': # normalized fractional format
            self.ui.ledWI.setText("0")
            self.ui.ledWF.setText(str(W - 1))
        else: # qfrmt == 'qfrac':
            is_qfrac = True
            
        WI = safe_eval(self.ui.ledWI.text(), self.myQ.WI, return_type='int')

        self.ui.ledScale.setText(str(1 << WI))
        self.ui.ledWI.setEnabled(is_qfrac)
        self.ui.lblDot.setEnabled(is_qfrac)
        self.ui.ledWF.setEnabled(is_qfrac)
        self.ui.ledW.setEnabled(not is_qfrac)
        self.ui.ledScale.setEnabled(False)

        self.ui2qdict() # save UI to dict and to class attributes
        
#------------------------------------------------------------------------------
    def _update_MSB_LSB(self):
        """
        Update the infos (LSB, MSB, Max)
        """
        self.ui.lblLSB.setText("{0:.{1}g}".format(self.myQ.LSB, params['FMT_ba']))
        self.ui.lblMSB.setText("{0:.{1}g}".format(self.myQ.MSB, params['FMT_ba']))
        self.ui.lblMAX.setText("{0:.6g}".format(self.myQ.MAX))


#------------------------------------------------------------------------------
    def qdict2ui(self):
        """
        Triggered by:
        - process_sig_rx()  if self.fx_specs_changed or dict_sig['fx_sim'] == 'specs_changed'
        - 
        Set the UI from the quantization dict and update the fixpoint object.
        When neither WI == 0 nor WF == 0, set the quantization format to general
        fractional format qfrac.
        """
        self.ui.ledWI.setText(qstr(fb.fil[0]['fxqc']['QCB']['WI']))
        self.ui.ledWF.setText(qstr(fb.fil[0]['fxqc']['QCB']['WF']))
        self.ui.ledW.setText(qstr(fb.fil[0]['fxqc']['QCB']['W']))
        if fb.fil[0]['fxqc']['QCB']['WI'] != 0 and fb.fil[0]['fxqc']['QCB']['WF'] != 0:
            qset_cmb_box(self.ui.cmbQFrmt, 'qfrac', data=True)

        self.ui.ledScale.setText(qstr(fb.fil[0]['fxqc']['QCB']['scale']))
        qset_cmb_box(self.ui.cmbQuant, fb.fil[0]['fxqc']['QCB']['quant'])
        qset_cmb_box(self.ui.cmbQOvfl,  fb.fil[0]['fxqc']['QCB']['ovfl'])

        self.myQ.setQobj(fb.fil[0]['fxqc']['QCB']) # update class attributes

        self._set_number_format() # quant format has been changed, update display
        self._update_MSB_LSB()

#------------------------------------------------------------------------------
    def ui2qdict(self):
        """
        Triggered by modifying 
        `ui.cmbFormat`, `ui.cmbQOvfl`, `ui.cmbQuant`, `ui.ledWF`, `ui.ledWI`
        or `ui.ledW` (via `_W_changed()`)
        or `ui.cmbQFrmt` (via `_set_number_format()`)
        or `ui.ledScale()` (via `_set_scale()`)
        or 'qdict2ui()' via `_set_number_format()`
        
        Read out the settings of the quantization comboboxes.

        - Store them in the filter dict `fb.fil[0]['fxqc']['QCB']` and as class
            attributes in the fixpoint object `self.myQ`

        - Emit a signal with `'view_changed':'q_coeff'`

        - Refresh the table
        """
        fb.fil[0]['fxqc']['QCB'] = {
                'WI':safe_eval(self.ui.ledWI.text(), self.myQ.WI, return_type='int'),
                'WF':safe_eval(self.ui.ledWF.text(), self.myQ.WF, return_type='int', sign='poszero'),
                'W':safe_eval(self.ui.ledW.text(), self.myQ.W, return_type='int', sign='pos'),
                'quant':qstr(self.ui.cmbQuant.currentText()),
                'ovfl':qstr(self.ui.cmbQOvfl.currentText()),
                'frmt':qstr(self.ui.cmbFormat.currentText().lower()),
                'scale':qstr(self.ui.ledScale.text())
                }

        self.myQ.setQobj(fb.fil[0]['fxqc']['QCB']) # update fixpoint object

        self.sig_tx.emit({'sender':__name__, 'view_changed':'q_coeff'})
        
        self._update_MSB_LSB()

        self._refresh_table()

#------------------------------------------------------------------------------
    def _save_dict(self):
        """
        Save the coefficient register `self.ba` to the filter dict `fb.fil[0]['ba']`.
        """

        logger.debug("_save_dict called")

        fb.fil[0]['N'] = max(len(self.ba[0]), len(self.ba[1])) - 1

        self.ui2qdict()

        if fb.fil[0]['ft'] == 'IIR':
            fb.fil[0]['fc'] = 'Manual_IIR'
        else:
            fb.fil[0]['fc'] = 'Manual_FIR'

        # save, check and convert coeffs, check filter type
        try:
            fil_save(fb.fil[0], self.ba, 'ba', __name__)
        except Exception as e:
            # catch exception due to malformatted coefficients:
            logger.error("While saving the filter coefficients, "
                         "the following error occurred:\n{0}".format(e))

        if __name__ == '__main__':
            self.load_dict() # only needed for stand-alone test

        self.sig_tx.emit({'sender':__name__, 'data_changed':'input_coeffs'})
        # -> input_tab_widgets

        qstyle_widget(self.ui.butSave, 'normal')

#------------------------------------------------------------------------------
    def _clear_table(self):
        """
        Clear self.ba: Initialize coeff for a poles and a zero @ origin,
        a = b = [1; 0].

        Refresh QTableWidget
        """
        self.ba = [np.asarray([1., 0.]), np.asarray([1., 0.])]

        self._refresh_table()
        qstyle_widget(self.ui.butSave, 'changed')


#------------------------------------------------------------------------------
    def _equalize_ba_length(self):
        """
        test and equalize if b and a subarray have different lengths:
        """
        try:
            a_len = len(self.ba[1])
        except IndexError:
            self.ba.append(np.array(1))
            a_len = 1

        D = len(self.ba[0]) - a_len

        if D > 0: # b is longer than a
            self.ba[1] = np.append(self.ba[1], np.zeros(D))
        elif D < 0: # a is longer than b
            if fb.fil[0]['ft'] == 'IIR':
                self.ba[0] = np.append(self.ba[0], np.zeros(-D))
            else:
                self.ba[1] = self.ba[1][:D] # discard last D elements of a

#------------------------------------------------------------------------------
    def _delete_cells(self):
        """
        Delete all selected elements in self.ba by:
        - determining the indices of all selected cells in the P and Z arrays
        - deleting elements with those indices
        - equalizing the lengths of b and a array by appending the required
          number of zeros.
        When nothing is selected, delete the last row.
        Finally, the QTableWidget is refreshed from self.ba.
        """
        sel = qget_selected(self.tblCoeff)['sel'] # get indices of all selected cells

        if not any(sel) and len(self.ba[0]) > 0: # delete last row
            self.ba = np.delete(self.ba, -1, axis=1)
        elif np.all(sel[0] == sel[1]) or fb.fil[0]['ft'] == 'FIR':
            # only complete rows selected or FIR -> delete row
            self.ba = np.delete(self.ba, sel[0], axis=1)
        else:
            self.ba[0][sel[0]] = 0
            self.ba[1][sel[1]] = 0
            #self.ba[0] = np.delete(self.ba[0], sel[0])
            #self.ba[1] = np.delete(self.ba[1], sel[1])    
        # test and equalize if b and a array have different lengths:
        self._equalize_ba_length()
        # if length is less than 2, clear the table: this ain't no filter!
        if len(self.ba[0]) < 2:
            self._clear_table() # sets 'changed' attribute
        else:
            self._refresh_table()
            qstyle_widget(self.ui.butSave, 'changed')

#------------------------------------------------------------------------------
    def _add_cells(self):
        """
        Add the number of selected rows to self.ba and fill new cells with
        zeros from the bottom. If nothing is selected, add one row at the bottom.
        Refresh QTableWidget.
        """
        # get indices of all selected cells
        sel = qget_selected(self.tblCoeff)['sel']

        if not any(sel): # nothing selected, append one row of zeros to table
            self.ba = np.insert(self.ba, len(self.ba[0]), 0, axis=1) #"insert" row after last
        elif np.all(sel[0] == sel[1]) or fb.fil[0]['ft'] == 'FIR': # only complete rows selected
            self.ba = np.insert(self.ba, sel[0], 0, axis=1)
#        elif len(sel[0]) == len(sel[1]):
#            self.ba = np.insert(self.ba, sel, 0, axis=1)
#       not allowed, sel needs to be a scalar or one-dimensional
        else:
            logger.warning("It is only possible to insert complete rows!")
            # The following doesn't work because the subarrays wouldn't have 
            # the same length for a moment
            #self.ba[0] = np.insert(self.ba[0], sel[0], 0)
            #self.ba[1] = np.insert(self.ba[1], sel[1], 0)
            return
        # insert 'sel' contiguous rows  before 'row':
        # self.ba[0] = np.insert(self.ba[0], row, np.zeros(sel))

        self._equalize_ba_length()
        self._refresh_table()
        # don't tag as 'changed' when only zeros have been added at the end
        if any(sel):
            qstyle_widget(self.ui.butSave, 'changed')

#------------------------------------------------------------------------------
    def _set_eps(self):
        """
        Set all coefficients = 0 in self.ba with a magnitude less than eps
        and refresh QTableWidget
        """
        self.ui.eps = safe_eval(self.ui.ledEps.text(), return_type='float', sign='pos', alt_expr=self.ui.eps)
        self.ui.ledEps.setText(str(self.ui.eps))

#------------------------------------------------------------------------------
    def _set_coeffs_zero(self):
        """
        Set all coefficients = 0 in self.ba with a magnitude less than eps
        and refresh QTableWidget
        """
        self._set_eps()
        idx = qget_selected(self.tblCoeff)['idx'] # get all selected indices

        test_val = 0. # value against which array is tested
        targ_val = 0. # value which is set when condition is true
        changed = False

        if not idx: # nothing selected, check whole table
            b_close = np.logical_and(np.isclose(self.ba[0], test_val, rtol=0, atol=self.ui.eps),
                                    (self.ba[0] != targ_val))
            if np.any(b_close): # found at least one coeff where condition was true
                self.ba[0] = np.where(b_close, targ_val, self.ba[0])
                changed = True

            if  fb.fil[0]['ft'] == 'IIR':
                a_close = np.logical_and(np.isclose(self.ba[1], test_val, rtol=0, atol=self.ui.eps),
                                    (self.ba[1] != targ_val))
                if np.any(a_close):
                    self.ba[1] = np.where(a_close, targ_val, self.ba[1])
                    changed = True

        else: # only check selected cells
            for i in idx:
                if np.logical_and(np.isclose(self.ba[i[0]][i[1]], test_val, rtol=0, atol=self.ui.eps),
                                  (self.ba[i[0]][i[1]] != targ_val)):
                    self.ba[i[0]][i[1]] = targ_val
                    changed = True
        if changed:
            qstyle_widget(self.ui.butSave, 'changed') # mark save button as changed

        self._refresh_table()

#------------------------------------------------------------------------------
    def quant_coeffs(self):
        """
        Quantize selected / all coefficients in self.ba and refresh QTableWidget
        """
        idx = qget_selected(self.tblCoeff)['idx'] # get all selected indices
        if not idx: # nothing selected, quantize all elements
            self.ba[0] = self.myQ.fixp(self.ba, scaling='multdiv')[0]
            if fb.fil[0]['ft'] == "IIR":
                self.ba[1] = self.myQ.fixp(self.ba, scaling='multdiv')[0]
        else:
            for i in idx:
                self.ba[i[0]][i[1]] = self.myQ.fixp(self.ba[i[0]][i[1]], scaling = 'multdiv')

        qstyle_widget(self.ui.butSave, 'changed')
        self._refresh_table()
Example #2
0
class Input_Info(QWidget):
    """
    Create widget for displaying infos about filter specs and filter design method
    """
    sig_rx = pyqtSignal(object)  # incoming signals from input_tab_widgets
    sig_tx = pyqtSignal(object)
    from pyfda.libs.pyfda_qt_lib import emit

    def __init__(self, parent=None):
        super(Input_Info, self).__init__(parent)

        self.tab_label = 'Info'
        self.tool_tip = (
            "<span>Display the achieved filter specifications"
            " and more info about the filter design algorithm.</span>")

        self._construct_UI()
        self.load_dict()

    def process_sig_rx(self, dict_sig=None):
        """
        Process signals coming from sig_rx
        """
        # logger.debug("Processing {0}: {1}".format(type(dict_sig).__name__, dict_sig))
        if 'data_changed' in dict_sig or 'view_changed' in dict_sig\
                or 'specs_changed' in dict_sig:
            self.load_dict()

    def _construct_UI(self):
        """
        Intitialize the widget, consisting of:
        - Checkboxes for selecting the info to be displayed
        - A large text window for displaying infos about the filter design
          algorithm
        """
        bfont = QFont()
        bfont.setBold(True)

        # ============== UI Layout =====================================
        # widget / subwindow for filter infos
#        self.butFiltPerf = QToolButton("H(f)", self)
        self.butFiltPerf = QPushButton(self)
        self.butFiltPerf.setText("H(f)")
        self.butFiltPerf.setCheckable(True)
        self.butFiltPerf.setChecked(True)
        self.butFiltPerf.setToolTip("Display frequency response at test frequencies.")

        self.butDebug = QPushButton(self)
        self.butDebug.setText("Debug")
        self.butDebug.setCheckable(True)
        self.butDebug.setChecked(False)
        self.butDebug.setToolTip("Show debugging options.")

        self.butAbout = QPushButton("About", self)  # pop-up "About" window

        self.butSettings = QPushButton("Settings", self)  #
        self.butSettings.setCheckable(True)
        self.butSettings.setChecked(False)
        self.butSettings.setToolTip("Display and set some settings")

        layHControls1 = QHBoxLayout()
        layHControls1.addWidget(self.butFiltPerf)
        layHControls1.addWidget(self.butAbout)
        layHControls1.addWidget(self.butSettings)
        layHControls1.addWidget(self.butDebug)

        self.butDocstring = QPushButton("Doc$", self)
        self.butDocstring.setCheckable(True)
        self.butDocstring.setChecked(False)
        self.butDocstring.setToolTip("Display docstring from python filter method.")

        self.butRichText = QPushButton("RTF", self)
        self.butRichText.setCheckable(HAS_DOCUTILS)
        self.butRichText.setChecked(HAS_DOCUTILS)
        self.butRichText.setEnabled(HAS_DOCUTILS)
        self.butRichText.setToolTip("Render documentation in Rich Text Format.")

        self.butFiltDict = QPushButton("FiltDict", self)
        self.butFiltDict.setToolTip("Show filter dictionary for debugging.")
        self.butFiltDict.setCheckable(True)
        self.butFiltDict.setChecked(False)

        self.butFiltTree = QPushButton("FiltTree", self)
        self.butFiltTree.setToolTip("Show filter tree for debugging.")
        self.butFiltTree.setCheckable(True)
        self.butFiltTree.setChecked(False)

        layHControls2 = QHBoxLayout()
        layHControls2.addWidget(self.butDocstring)
        # layHControls2.addStretch(1)
        layHControls2.addWidget(self.butRichText)
        # layHControls2.addStretch(1)
        layHControls2.addWidget(self.butFiltDict)
        # layHControls2.addStretch(1)
        layHControls2.addWidget(self.butFiltTree)

        self.frmControls2 = QFrame(self)
        self.frmControls2.setLayout(layHControls2)
        self.frmControls2.setVisible(self.butDebug.isChecked())
        self.frmControls2.setContentsMargins(0, 0, 0, 0)

        lbl_settings_NFFT = QLabel(to_html("N_FFT =", frmt='bi'), self)
        self.led_settings_NFFT = QLineEdit(self)
        self.led_settings_NFFT.setText(str(params['N_FFT']))
        self.led_settings_NFFT.setToolTip("<span>Number of FFT points for frequency "
                                          "domain widgets.</span>")

        layGSettings = QGridLayout()
        layGSettings.addWidget(lbl_settings_NFFT, 1, 0)
        layGSettings.addWidget(self.led_settings_NFFT, 1, 1)

        self.frmSettings = QFrame(self)
        self.frmSettings.setLayout(layGSettings)
        self.frmSettings.setVisible(self.butSettings.isChecked())
        self.frmSettings.setContentsMargins(0, 0, 0, 0)

        layVControls = QVBoxLayout()
        layVControls.addLayout(layHControls1)
        layVControls.addWidget(self.frmControls2)
        layVControls.addWidget(self.frmSettings)

        self.frmMain = QFrame(self)
        self.frmMain.setLayout(layVControls)

        self.tblFiltPerf = QTableWidget(self)
        self.tblFiltPerf.setAlternatingRowColors(True)
#        self.tblFiltPerf.verticalHeader().setVisible(False)
        self.tblFiltPerf.horizontalHeader().setHighlightSections(False)
        self.tblFiltPerf.horizontalHeader().setFont(bfont)
        self.tblFiltPerf.verticalHeader().setHighlightSections(False)
        self.tblFiltPerf.verticalHeader().setFont(bfont)

        self.txtFiltInfoBox = QTextBrowser(self)
        self.txtFiltDict = QTextBrowser(self)
        self.txtFiltTree = QTextBrowser(self)

        layVMain = QVBoxLayout()
        layVMain.addWidget(self.frmMain)

#        layVMain.addLayout(self.layHControls)
        splitter = QSplitter(self)
        splitter.setOrientation(Qt.Vertical)
        splitter.addWidget(self.tblFiltPerf)
        splitter.addWidget(self.txtFiltInfoBox)
        splitter.addWidget(self.txtFiltDict)
        splitter.addWidget(self.txtFiltTree)
        # setSizes uses absolute pixel values, but can be "misused" by specifying values
        # that are way too large: in this case, the space is distributed according
        # to the _ratio_ of the values:
        splitter.setSizes([3000, 10000, 1000, 1000])
        layVMain.addWidget(splitter)

        layVMain.setContentsMargins(*params['wdg_margins'])

        self.setLayout(layVMain)

        # ----------------------------------------------------------------------
        # GLOBAL SIGNALS & SLOTs
        # ----------------------------------------------------------------------
        self.sig_rx.connect(self.process_sig_rx)
        # ----------------------------------------------------------------------
        # LOCAL SIGNALS & SLOTs
        # ----------------------------------------------------------------------
        self.butFiltPerf.clicked.connect(self._show_filt_perf)
        self.butAbout.clicked.connect(self._about_window)
        self.butSettings.clicked.connect(self._show_settings)
        self.led_settings_NFFT.editingFinished.connect(self._update_settings_nfft)
        self.butDebug.clicked.connect(self._show_debug)

        self.butFiltDict.clicked.connect(self._show_filt_dict)
        self.butFiltTree.clicked.connect(self._show_filt_tree)
        self.butDocstring.clicked.connect(self._show_doc)
        self.butRichText.clicked.connect(self._show_doc)

    def _about_window(self):
        self.about_widget = AboutWindow(self)  # important: Handle must be class attribute
        # self.opt_widget.show() # modeless dialog, i.e. non-blocking
        self.about_widget.exec_()  # modal dialog (blocking)

# ------------------------------------------------------------------------------
    def _show_debug(self):
        """
        Show / hide debug options depending on the state of the debug button
        """
        self.frmControls2.setVisible(self.butDebug.isChecked())

# ------------------------------------------------------------------------------
    def _show_settings(self):
        """
        Show / hide settings options depending on the state of the settings button
        """
        self.frmSettings.setVisible(self.butSettings.isChecked())

    def _update_settings_nfft(self):
        """ Update value for self.par1 from QLineEdit Widget"""
        params['N_FFT'] = safe_eval(self.led_settings_NFFT.text(), params['N_FFT'],
                                    sign='pos', return_type='int')
        self.led_settings_NFFT.setText(str(params['N_FFT']))
        self.emit({'data_changed': 'n_fft'})

# ------------------------------------------------------------------------------
    def load_dict(self):
        """
        update docs and filter performance
        """
        self._show_doc()
        self._show_filt_perf()
        self._show_filt_dict()
        self._show_filt_tree()

# ------------------------------------------------------------------------------
    def _show_doc(self):
        """
        Display info from filter design file and docstring
        """
        if hasattr(ff.fil_inst, 'info'):
            if self.butRichText.isChecked():
                self.txtFiltInfoBox.setText(publish_string(
                    self._clean_doc(ff.fil_inst.info), writer_name='html',
                    settings_overrides={'output_encoding': 'unicode'}))
            else:
                self.txtFiltInfoBox.setText(textwrap.dedent(ff.fil_inst.info))
        else:
            self.txtFiltInfoBox.setText("")

        if self.butDocstring.isChecked() and hasattr(ff.fil_inst, 'info_doc'):
            if self.butRichText.isChecked():
                self.txtFiltInfoBox.append(
                    '<hr /><b>Python module docstring:</b>\n')
                for doc in ff.fil_inst.info_doc:
                    self.txtFiltInfoBox.append(publish_string(
                     self._clean_doc(doc), writer_name='html',
                     settings_overrides={'output_encoding': 'unicode'}))
            else:
                self.txtFiltInfoBox.append('\nPython module docstring:\n')
                for doc in ff.fil_inst.info_doc:
                    self.txtFiltInfoBox.append(self._clean_doc(doc))

        self.txtFiltInfoBox.moveCursor(QTextCursor.Start)

    def _clean_doc(self, doc):
        """
        Remove uniform number of leading blanks from docstrings for subsequent
        processing of rich text. The first line is treated differently, _all_
        leading blanks are removed (if any). This allows for different formats
        of docstrings.
        """
        lines = doc.splitlines()
        result = lines[0].lstrip() + "\n" + textwrap.dedent("\n".join(lines[1:]))
        return result

# ------------------------------------------------------------------------------
    def _show_filt_perf(self):
        """
        Print filter properties in a table at frequencies of interest. When
        specs are violated, colour the table entry in red.
        """

        antiC = False

        def _find_min_max(self, f_start, f_stop, unit='dB'):
            """
            Find minimum and maximum magnitude and the corresponding frequencies
            for the filter defined in the filter dict in a given frequency band
            [f_start, f_stop].
            """
            w = np.linspace(f_start, f_stop, params['N_FFT'])*2*np.pi
            [w, H] = sig.freqz(bb, aa, worN=w)

            # add antiCausals if we have them
            if (antiC):
               #
               # Evaluate transfer function of anticausal half on the same freq grid.
               #
               wa, ha = sig.freqz(bbA, aaA, worN=w)
               ha = ha.conjugate()
               #
               # Total transfer function is the product
               #
               H = H*ha

            f = w / (2.0 * pi)  # frequency normalized to f_S
            H_abs = abs(H)
            H_max = max(H_abs)
            H_min = min(H_abs)
            F_max = f[np.argmax(H_abs)]  # find the frequency where H_abs
            F_min = f[np.argmin(H_abs)]  # becomes max resp. min
            if unit == 'dB':
                H_max = 20*log10(H_max)
                H_min = 20*log10(H_min)
            return F_min, H_min, F_max, H_max
        # ------------------------------------------------------------------

        self.tblFiltPerf.setVisible(self.butFiltPerf.isChecked())
        if self.butFiltPerf.isChecked():

            bb = fb.fil[0]['ba'][0]
            aa = fb.fil[0]['ba'][1]

            # 'rpk' means nonCausal filter
            if 'rpk' in fb.fil[0]:
                antiC = True
                bbA = fb.fil[0]['baA'][0]
                aaA = fb.fil[0]['baA'][1]
                bbA = bbA.conjugate()
                aaA = aaA.conjugate()

            f_S = fb.fil[0]['f_S']

            f_lbls = []
            f_vals = []
            a_lbls = []
            a_targs = []
            a_targs_dB = []
            a_test = []
            ft = fb.fil[0]['ft']  # get filter type ('IIR', 'FIR')
            unit = fb.fil[0]['amp_specs_unit']
            unit = 'dB'  # fix this for the moment
            # construct pairs of corner frequency and corresponding amplitude
            # labels in ascending frequency for each response type
            if fb.fil[0]['rt'] in {'LP', 'HP', 'BP', 'BS', 'HIL'}:
                if fb.fil[0]['rt'] == 'LP':
                    f_lbls = ['F_PB', 'F_SB']
                    a_lbls = ['A_PB', 'A_SB']
                elif fb.fil[0]['rt'] == 'HP':
                    f_lbls = ['F_SB', 'F_PB']
                    a_lbls = ['A_SB', 'A_PB']
                elif fb.fil[0]['rt'] == 'BP':
                    f_lbls = ['F_SB', 'F_PB', 'F_PB2', 'F_SB2']
                    a_lbls = ['A_SB', 'A_PB', 'A_PB', 'A_SB2']
                elif fb.fil[0]['rt'] == 'BS':
                    f_lbls = ['F_PB', 'F_SB', 'F_SB2', 'F_PB2']
                    a_lbls = ['A_PB', 'A_SB', 'A_SB', 'A_PB2']
                elif fb.fil[0]['rt'] == 'HIL':
                    f_lbls = ['F_PB', 'F_PB2']
                    a_lbls = ['A_PB', 'A_PB']

            # Try to get lists of frequency / amplitude specs from the filter dict
            # that correspond to the f_lbls / a_lbls pairs defined above
            # When one of the labels doesn't exist in the filter dict, delete
            # all corresponding amplitude and frequency entries.
                err = [False] * len(f_lbls)  # initialize error list
                f_vals = []
                a_targs = []
                for i in range(len(f_lbls)):
                    try:
                        f = fb.fil[0][f_lbls[i]]
                        f_vals.append(f)
                    except KeyError as e:
                        f_vals.append('')
                        err[i] = True
                        logger.debug(e)
                    try:
                        a = fb.fil[0][a_lbls[i]]
                        a_dB = lin2unit(fb.fil[0][a_lbls[i]], ft, a_lbls[i], unit)
                        a_targs.append(a)
                        a_targs_dB.append(a_dB)
                    except KeyError as e:
                        a_targs.append('')
                        a_targs_dB.append('')
                        err[i] = True
                        logger.debug(e)

                for i in range(len(f_lbls)):
                    if err[i]:
                        del f_lbls[i]
                        del f_vals[i]
                        del a_lbls[i]
                        del a_targs[i]
                        del a_targs_dB[i]

                f_vals = np.asarray(f_vals)  # convert to numpy array

                logger.debug("F_test_labels = %s" % f_lbls)

                # Calculate frequency response at test frequencies
                [w_test, a_test] = sig.freqz(bb, aa, 2.0 * pi * f_vals.astype(float))
                # add antiCausals if we have them
                if (antiC):
                   wa, ha = sig.freqz(bbA, aaA, 2.0 * pi * f_vals.astype(float))
                   ha = ha.conjugate()
                   a_test = a_test*ha

            (F_min, H_min, F_max, H_max) = _find_min_max(self, 0, 1, unit='V')
            # append frequencies and values for min. and max. filter reponse to
            # test vector

            f_lbls += ['Min.', 'Max.']
            # QTableView does not support direct formatting, use QLabel

            f_vals = np.append(f_vals, [F_min, F_max])
            a_targs = np.append(a_targs, [np.nan, np.nan])
            a_targs_dB = np.append(a_targs_dB, [np.nan, np.nan])
            a_test = np.append(a_test, [H_min, H_max])
            # calculate response of test frequencies in dB
            a_test_dB = -20*log10(abs(a_test))

            # get filter type ('IIR', 'FIR') for dB <-> lin conversion
            ft = fb.fil[0]['ft']
#            unit = fb.fil[0]['amp_specs_unit']
            unit = 'dB'  # make this fixed for the moment

            # build a list with the corresponding target specs:
            a_targs_pass = []
            eps = 1e-3
            for i in range(len(f_lbls)):
                if 'PB' in f_lbls[i]:
                    a_targs_pass.append((a_test_dB[i] - a_targs_dB[i]) < eps)
                    a_test[i] = 1 - abs(a_test[i])
                elif 'SB' in f_lbls[i]:
                    a_targs_pass.append(a_test_dB[i] >= a_targs_dB[i])
                else:
                    a_targs_pass.append(True)

            self.targs_spec_passed = np.all(a_targs_pass)

            logger.debug(
                "H_targ = {0}\n"
                "H_test = {1}\n"
                "H_test_dB = {2}\n"
                "F_test = {3}\n"
                "H_targ_pass = {4}\n"
                "passed: {5}\n".format(a_targs,  a_test,  a_test_dB, f_vals,
                                       a_targs_pass, self.targs_spec_passed))

            self.tblFiltPerf.setRowCount(len(a_test))  # number of table rows
            self.tblFiltPerf.setColumnCount(5)  # number of table columns

            self.tblFiltPerf.setHorizontalHeaderLabels([
                'f/{0:s}'.format(fb.fil[0]['freq_specs_unit']), 'Spec\n(dB)',
                '|H(f)|\n(dB)', 'Spec', '|H(f)|'])
            self.tblFiltPerf.setVerticalHeaderLabels(f_lbls)
            for row in range(len(a_test)):
                self.tblFiltPerf.setItem(
                    row, 0, QTableWidgetItem(str('{0:.4g}'.format(f_vals[row]*f_S))))
                self.tblFiltPerf.setItem(
                    row, 1, QTableWidgetItem(str('%2.3g'%(-a_targs_dB[row]))))
                self.tblFiltPerf.setItem(
                    row, 2, QTableWidgetItem(str('%2.3f'%(-a_test_dB[row]))))
                if a_targs[row] < 0.01:
                    self.tblFiltPerf.setItem(
                        row, 3, QTableWidgetItem(str('%.3e'%(a_targs[row]))))
                else:
                    self.tblFiltPerf.setItem(
                        row, 3, QTableWidgetItem(str('%2.4f'%(a_targs[row]))))
                if a_test[row] < 0.01:
                    self.tblFiltPerf.setItem(
                        row, 4, QTableWidgetItem(str('%.3e'%(abs(a_test[row])))))
                else:
                    self.tblFiltPerf.setItem(
                        row, 4, QTableWidgetItem(str('%.4f'%(abs(a_test[row])))))

                if not a_targs_pass[row]:
                    self.tblFiltPerf.item(row, 1).setBackground(QtGui.QColor('red'))
                    self.tblFiltPerf.item(row, 3).setBackground(QtGui.QColor('red'))

            self.tblFiltPerf.resizeColumnsToContents()
            self.tblFiltPerf.resizeRowsToContents()

# ------------------------------------------------------------------------------
    def _show_filt_dict(self):
        """
        Print filter dict for debugging
        """
        self.txtFiltDict.setVisible(self.butFiltDict.isChecked())

        fb_sorted = [str(key) + ' : ' + str(fb.fil[0][key])
                     for key in sorted(fb.fil[0].keys())]
        dictstr = pprint.pformat(fb_sorted)
#        dictstr = pprint.pformat(fb.fil[0])
        self.txtFiltDict.setText(dictstr)

# ------------------------------------------------------------------------------
    def _show_filt_tree(self):
        """
        Print filter tree for debugging
        """
        self.txtFiltTree.setVisible(self.butFiltTree.isChecked())

        ftree_sorted = ['<b>' + str(key) + ' : ' + '</b>' + str(fb.fil_tree[key])
                        for key in sorted(fb.fil_tree.keys())]
        dictstr = pprint.pformat(ftree_sorted, indent=4)
#        dictstr = pprint.pformat(fb.fil[0])
        self.txtFiltTree.setText(dictstr)
Example #3
0
class Input_PZ(QWidget):
    """
    Create the window for entering exporting / importing and saving / loading data
    """
    sig_rx = pyqtSignal(object)  # incoming from input_tab_widgets
    sig_tx = pyqtSignal(object)  # emitted when filter has been saved

    def __init__(self, parent):
        super(Input_PZ, self).__init__(parent)

        self.data_changed = True  # initialize flag: filter data has been changed

        self.Hmax_last = 1  # initial setting for maximum gain
        self.angle_char = "\u2220"

        self.tab_label = "P/Z"
        self.tool_tip = "Display and edit filter poles and zeros."

        self.ui = Input_PZ_UI(self)  # create the UI part with buttons etc.
        self.norm_last = qget_cmb_box(self.ui.cmbNorm,
                                      data=False)  # initial setting of cmbNorm
        self._construct_UI()  # construct the rest of the UI

        self.load_dict()  # initialize table from filterbroker
        self._refresh_table()  # initialize table with values

        self.setup_signal_slot(
        )  # setup signal-slot connections and eventFilters

#------------------------------------------------------------------------------

    def process_sig_rx(self, dict_sig=None):
        """
        Process signals coming from sig_rx
        """

        if dict_sig['sender'] == __name__:
            logger.debug("Stopped infinite loop:\n{0}".format(
                pprint_log(dict_sig)))
            return
        else:
            logger.debug("SIG_RX - data_changed = {0}, vis = {1}\n{2}"\
                     .format(self.data_changed, self.isVisible(), pprint_log(dict_sig)))

        if 'ui_changed' in dict_sig and dict_sig['ui_changed'] == 'csv':
            self.ui._set_load_save_icons()
            #self.sig_tx.emit(dict_sig)

        elif self.isVisible():
            if 'data_changed' in dict_sig or self.data_changed:
                self.load_dict()
                self.data_changed = False
        else:
            # TODO: draw wouldn't be necessary for 'view_changed', only update view
            if 'data_changed' in dict_sig:
                self.data_changed = True

#------------------------------------------------------------------------------

    def _construct_UI(self):
        """
        Intitialize the widget
        """
        self.tblPZ = QTableWidget(self)
        #        self.tblPZ.setEditTriggers(QTableWidget.AllEditTriggers) # make everything editable
        self.tblPZ.setAlternatingRowColors(True)  # alternating row colors)
        self.tblPZ.setObjectName("tblPZ")

        self.tblPZ.horizontalHeader().setHighlightSections(
            True)  # highlight when selected
        self.tblPZ.horizontalHeader().setFont(self.ui.bfont)

        self.tblPZ.verticalHeader().setHighlightSections(True)
        self.tblPZ.verticalHeader().setFont(self.ui.bfont)
        self.tblPZ.setColumnCount(2)
        self.tblPZ.setItemDelegate(ItemDelegate(self))

        layVMain = QVBoxLayout()
        layVMain.setAlignment(
            Qt.AlignTop)  # this affects only the first widget (intended here)
        layVMain.addWidget(self.ui)
        layVMain.addWidget(self.tblPZ)

        layVMain.setContentsMargins(*params['wdg_margins'])

        self.setLayout(layVMain)

    def setup_signal_slot(self):
        """
        Setup setup signal-slot connections
        """
        #----------------------------------------------------------------------
        # GLOBAL SIGNALS & SLOTs
        #----------------------------------------------------------------------
        self.sig_rx.connect(self.process_sig_rx)
        self.ui.sig_tx.connect(self.sig_tx)

        #----------------------------------------------------------------------
        # LOCAL (UI) SIGNALS & SLOTs
        #----------------------------------------------------------------------
        self.ui.cmbPZFrmt.activated.connect(self._refresh_table)
        self.ui.spnDigits.editingFinished.connect(self._refresh_table)
        self.ui.butLoad.clicked.connect(self.load_dict)
        self.ui.butEnable.clicked.connect(self.load_dict)

        self.ui.butSave.clicked.connect(self._save_entries)
        self.ui.cmbNorm.activated.connect(self._normalize_gain)

        self.ui.butDelCells.clicked.connect(self._delete_cells)
        self.ui.butAddCells.clicked.connect(self._add_rows)
        self.ui.butClear.clicked.connect(self._clear_table)

        self.ui.butFromTable.clicked.connect(self._copy_from_table)
        self.ui.butToTable.clicked.connect(self._copy_to_table)

        self.ui.butSetZero.clicked.connect(self._zero_PZ)

        self.ui.ledGain.installEventFilter(self)
        self.ui.ledEps.editingFinished.connect(self._set_eps)

        #----------------------------------------------------------------------
        # self.tblPZ.itemSelectionChanged.connect(self._copy_item)
        #
        # Every time a table item is edited, call self._copy_item to copy the
        # item content to self.zpk. This is triggered by the itemChanged signal.
        # The event filter monitors the focus of the input fields.

        # signal itemChanged is also triggered programmatically,
        # itemSelectionChanged is only triggered when entering cell

#------------------------------------------------------------------------------

    def eventFilter(self, source, event):
        """
        Filter all events generated by the QLineEdit widgets. Source and type
        of all events generated by monitored objects are passed to this eventFilter,
        evaluated and passed on to the next hierarchy level.

        - When a QLineEdit widget gains input focus (`QEvent.FocusIn`), display
          the stored value from filter dict with full precision
        - When a key is pressed inside the text field, set the `spec_edited` flag
          to True.
        - When a QLineEdit widget loses input focus (`QEvent.FocusOut`), store
          current value in linear format with full precision (only if
          `spec_edited == True`) and display the stored value in selected format
        """

        if isinstance(source, QLineEdit):
            if event.type() == QEvent.FocusIn:  # 8
                self.spec_edited = False
                self._restore_gain(source)
                return True  # event processing stops here

            elif event.type() == QEvent.KeyPress:
                self.spec_edited = True  # entry has been changed
                key = event.key()  # key press: 6, key release: 7
                if key in {QtCore.Qt.Key_Return,
                           QtCore.Qt.Key_Enter}:  # store entry
                    self._store_gain(source)
                    self._restore_gain(source)  # display in desired format
                    return True

                elif key == QtCore.Qt.Key_Escape:  # revert changes
                    self.spec_edited = False
                    self._restore_gain(source)
                    return True

            elif event.type() == QEvent.FocusOut:  # 9
                self._store_gain(source)
                self._restore_gain(source)  # display in desired format
                return True

        return super(Input_PZ, self).eventFilter(source, event)

#------------------------------------------------------------------------------

    def _store_gain(self, source):
        """
        When the textfield of `source` has been edited (flag `self.spec_edited` =  True),
        store it in the shadow dict. This is triggered by `QEvent.focusOut` or
        RETURN key.
        """
        if self.spec_edited:
            self.zpk[2] = safe_eval(source.text(), alt_expr=str(self.zpk[2]))
            self.spec_edited = False  # reset flag

#------------------------------------------------------------------------------

    def _normalize_gain(self):
        """
        Normalize the gain factor so that the maximum of |H(f)| stays 1 or a
        previously stored maximum value of |H(f)|. Do this every time a P or Z
        has been changed.
        Called by setModelData() and when cmbNorm is activated

        """
        norm = qget_cmb_box(self.ui.cmbNorm, data=False)
        self.ui.ledGain.setEnabled(norm == 'None')
        if norm != self.norm_last:
            qstyle_widget(self.ui.butSave, 'changed')
        if not np.isfinite(self.zpk[2]):
            self.zpk[2] = 1.
        self.zpk[2] = np.real_if_close(self.zpk[2]).item()
        if np.iscomplex(self.zpk[2]):
            logger.warning("Casting complex to real for gain k!")
            self.zpk[2] = np.abs(self.zpk[2])

        if norm != "None":
            b, a = zpk2tf(self.zpk[0], self.zpk[1], self.zpk[2])
            [w, H] = freqz(b, a, whole=True)
            Hmax = max(abs(H))
            if not np.isfinite(Hmax) or Hmax > 1e4 or Hmax < 1e-4:
                Hmax = 1.
            if norm == "1":
                self.zpk[2] = self.zpk[2] / Hmax  # normalize to 1
            elif norm == "Max":
                if norm != self.norm_last:  # setting has been changed -> 'Max'
                    self.Hmax_last = Hmax  # use current design to set Hmax_last
                self.zpk[2] = self.zpk[2] / Hmax * self.Hmax_last
        self.norm_last = norm  # store current setting of combobox

        self._restore_gain()

#------------------------------------------------------------------------------

    def _restore_gain(self, source=None):
        """
        Update QLineEdit with either full (has focus) or reduced precision (no focus)

        Called by eventFilter, _normalize_gain() and _refresh_table()
        """

        if self.ui.butEnable.isChecked():
            if len(self.zpk) == 3:
                pass
            elif len(self.zpk) == 2:  # k is missing in zpk:
                self.zpk.append(1.)  # use k = 1
            else:
                logger.error("P/Z list zpk has wrong length {0}".format(
                    len(self.zpk)))

            k = safe_eval(self.zpk[2], return_type='auto')

            if not self.ui.ledGain.hasFocus():  # no focus, round the gain
                self.ui.ledGain.setText(str(params['FMT'].format(k)))
            else:  # widget has focus, show gain with full precision
                self.ui.ledGain.setText(str(k))

#------------------------------------------------------------------------------

    def _refresh_table_item(self, row, col):
        """
        Refresh the table item with the index `row, col` from self.zpk
        """
        item = self.tblPZ.item(row, col)
        if item:  # does item exist?
            item.setText(str(self.zpk[col][row]).strip('()'))
        else:  # no, construct it:
            self.tblPZ.setItem(
                row, col,
                QTableWidgetItem(str(self.zpk[col][row]).strip('()')))
        self.tblPZ.item(row,
                        col).setTextAlignment(Qt.AlignRight | Qt.AlignCenter)

#------------------------------------------------------------------------------

    def _refresh_table(self):
        """
        (Re-)Create the displayed table from self.zpk with the
        desired number format.

        TODO:
        Update zpk[2]?

        Called by: load_dict(), _clear_table(), _zero_PZ(), _delete_cells(), 
                add_row(), _copy_to_table()
        """

        params['FMT_pz'] = int(self.ui.spnDigits.text())

        self.tblPZ.setVisible(self.ui.butEnable.isChecked())

        if self.ui.butEnable.isChecked():

            self.ui.butEnable.setIcon(QIcon(':/circle-x.svg'))

            self._restore_gain()

            self.tblPZ.setHorizontalHeaderLabels(["Zeros", "Poles"])
            self.tblPZ.setRowCount(max(len(self.zpk[0]), len(self.zpk[1])))

            self.tblPZ.blockSignals(True)
            for col in range(2):
                for row in range(len(self.zpk[col])):
                    self._refresh_table_item(row, col)

            self.tblPZ.blockSignals(False)

            self.tblPZ.resizeColumnsToContents()
            self.tblPZ.resizeRowsToContents()
            self.tblPZ.clearSelection()

        else:  # disable widgets
            self.ui.butEnable.setIcon(QIcon(':/circle-check.svg'))

#------------------------------------------------------------------------------

    def load_dict(self):
        """
        Load all entries from filter dict fb.fil[0]['zpk'] into the Zero/Pole/Gain list
        self.zpk and update the display via `self._refresh_table()`.
        The explicit np.array( ... ) statement enforces a deep copy of fb.fil[0],
        otherwise the filter dict would be modified inadvertedly.

        The filter dict is a "normal" numpy float array for z / p / k values
        The ZPK register `self.zpk` should be a list of float ndarrays to allow
        for different lengths of z / p / k subarrays while adding / deleting items.?
        """
        # TODO: check the above
        self.zpk = np.array(fb.fil[0]['zpk'])  # this enforces a deep copy
        qstyle_widget(self.ui.butSave, 'normal')
        self._refresh_table()

#------------------------------------------------------------------------------

    def _save_entries(self):
        """
        Save the values from self.zpk to the filter PZ dict,
        the QLineEdit for setting the gain has to be treated separately.
        """

        logger.debug("_save_entries called")

        fb.fil[0]['N'] = len(self.zpk[0])
        if np.any(self.zpk[1]):  # any non-zero poles?
            fb.fil[0]['fc'] = 'Manual_IIR'
        else:
            fb.fil[0]['fc'] = 'Manual_FIR'

        try:
            fil_save(fb.fil[0], self.zpk, 'zpk',
                     __name__)  # save with new gain
        except Exception as e:
            # catch exception due to malformatted P/Zs:
            logger.error("While saving the poles / zeros, "
                         "the following error occurred:\n{0}".format(e))

        if __name__ == '__main__':
            self.load_dict()  # only needed for stand-alone test

        self.sig_tx.emit({'sender': __name__, 'data_changed': 'input_pz'})
        # -> input_tab_widgets

        qstyle_widget(self.ui.butSave, 'normal')

        logger.debug("b,a = {0}\n\n"
                     "zpk = {1}\n".format(pformat(fb.fil[0]['ba']),
                                          pformat(fb.fil[0]['zpk'])))

#------------------------------------------------------------------------------

    def _clear_table(self):
        """
        Clear & initialize table and zpk for two poles and zeros @ origin,
        P = Z = [0; 0], k = 1
        """
        self.zpk = np.array([[0, 0], [0, 0], 1])
        self.Hmax_last = 1.0
        self.anti = False

        qstyle_widget(self.ui.butSave, 'changed')
        self._refresh_table()

#------------------------------------------------------------------------------

    def _get_selected(self, table):
        """
        get selected cells and return:
        - indices of selected cells
        - selected colums
        - selected rows
        - current cell
        """
        idx = []
        for _ in table.selectedItems():
            idx.append([
                _.column(),
                _.row(),
            ])
        cols = sorted(list({i[0] for i in idx}))
        rows = sorted(list({i[1] for i in idx}))
        cur = (table.currentColumn(), table.currentRow())
        #cur_idx_row = table.currentIndex().row()

        return {'idx': idx, 'cols': cols, 'rows': rows, 'cur': cur}

#------------------------------------------------------------------------------

    def _delete_cells(self):
        """
        Delete all selected elements by:
        - determining the indices of all selected cells in the P and Z arrays
        - deleting elements with those indices
        - equalizing the lengths of P and Z array by appending the required
          number of zeros.
        - deleting all P/Z pairs
        Finally, the table is refreshed from self.zpk.
        """
        sel = self._get_selected(self.tblPZ)['idx']  # get all selected indices
        Z = [s[1] for s in sel
             if s[0] == 0]  # all selected indices in 'Z' column
        P = [s[1] for s in sel
             if s[0] == 1]  # all selected indices in 'P' column

        # Delete array entries with selected indices. If nothing is selected
        # (Z and P are empty), delete the last row.
        if len(Z) < 1 and len(P) < 1:
            Z = [len(self.zpk[0]) - 1]
            P = [len(self.zpk[1]) - 1]
        self.zpk[0] = np.delete(self.zpk[0], Z)
        self.zpk[1] = np.delete(self.zpk[1], P)

        # test and equalize if P and Z array have different lengths:
        D = len(self.zpk[0]) - len(self.zpk[1])
        if D > 0:
            self.zpk[1] = np.append(self.zpk[1], np.zeros(D))
        elif D < 0:
            self.zpk[0] = np.append(self.zpk[0], np.zeros(-D))

        self._delete_PZ_pairs()
        self._normalize_gain()
        qstyle_widget(self.ui.butSave, 'changed')
        self._refresh_table()

#------------------------------------------------------------------------------

    def _add_rows(self):
        """
        Add the number of selected rows to the table and fill new cells with
        zeros. If nothing is selected, add one row.
        """
        row = self.tblPZ.currentRow()
        sel = len(self._get_selected(self.tblPZ)['rows'])
        # TODO: evaluate and create non-contiguous selections as well?

        if sel == 0:  # nothing selected ->
            sel = 1  # add at least one row ...
            row = min(len(self.zpk[0]), len(self.zpk[1]))  # ... at the bottom

        self.zpk[0] = np.insert(self.zpk[0], row, np.zeros(sel))
        self.zpk[1] = np.insert(self.zpk[1], row, np.zeros(sel))

        self._refresh_table()

#------------------------------------------------------------------------------

    def _set_eps(self):
        """
        Set tolerance value 
        """
        self.ui.eps = safe_eval(self.ui.ledEps.text(),
                                alt_expr=self.ui.eps,
                                sign='pos')
        self.ui.ledEps.setText(str(self.ui.eps))

#------------------------------------------------------------------------------

    def _zero_PZ(self):
        """
        Set all P/Zs = 0 with a magnitude less than eps and delete P/Z pairs
        afterwards.
        """
        changed = False
        targ_val = 0.
        test_val = 0
        sel = self._get_selected(self.tblPZ)['idx']  # get all selected indices

        if not sel:  # nothing selected, check all cells
            z_close = np.logical_and(
                np.isclose(self.zpk[0], test_val, rtol=0, atol=self.ui.eps),
                (self.zpk[0] != targ_val))
            p_close = np.logical_and(
                np.isclose(self.zpk[1], test_val, rtol=0, atol=self.ui.eps),
                (self.zpk[1] != targ_val))
            if z_close.any():
                self.zpk[0] = np.where(z_close, targ_val, self.zpk[0])
                changed = True
            if p_close.any():
                self.zpk[1] = np.where(p_close, targ_val, self.zpk[1])
                changed = True
        else:
            for i in sel:  # check only selected cells
                if np.logical_and(
                        np.isclose(self.zpk[i[0]][i[1]],
                                   test_val,
                                   rtol=0,
                                   atol=self.ui.eps),
                    (self.zpk[i[0]][i[1]] != targ_val)):
                    self.zpk[i[0]][i[1]] = targ_val
                    changed = True

        self._delete_PZ_pairs()
        self._normalize_gain()
        if changed:
            qstyle_widget(self.ui.butSave,
                          'changed')  # mark save button as changed
        self._refresh_table()

#------------------------------------------------------------------------------

    def _delete_PZ_pairs(self):
        """
        Find and delete pairs of poles and zeros in self.zpk
        The filter dict and the table have to be updated afterwards.
        """
        for z in range(len(self.zpk[0]) - 1, -1, -1):  # start at the bottom
            for p in range(len(self.zpk[1]) - 1, -1, -1):
                if np.isclose(self.zpk[0][z],
                              self.zpk[1][p],
                              rtol=0,
                              atol=self.ui.eps):
                    self.zpk[0] = np.delete(self.zpk[0], z)
                    self.zpk[1] = np.delete(self.zpk[1], p)
                    break  # ... out of loop

        if len(self.zpk[0]) < 1:  # no P / Z, add 1 row
            self.zpk[0] = np.append(self.zpk[0], 0.)
            self.zpk[1] = np.append(self.zpk[1], 0.)

    #------------------------------------------------------------------------------
    def cmplx2frmt(self, text, places=-1):
        """
        Convert number "text" (real or complex or string) to the format defined 
        by cmbPZFrmt.
        
        Returns: 
            string
        """
        # convert to "normal" string and prettify via safe_eval:
        data = safe_eval(qstr(text), return_type='auto')
        frmt = qget_cmb_box(self.ui.cmbPZFrmt)  # get selected format

        if places == -1:
            full_prec = True
        else:
            full_prec = False

        if frmt == 'cartesian' or not (type(data) == complex):
            if full_prec:
                return "{0}".format(data)
            else:
                return "{0:.{plcs}g}".format(data, plcs=places)

        elif frmt == 'polar_rad':
            r, phi = np.absolute(data), np.angle(data, deg=False)
            if full_prec:
                return "{r} * {angle_char}{p} rad"\
                    .format(r=r, p=phi, angle_char=self.angle_char)
            else:
                return "{r:.{plcs}g} * {angle_char}{p:.{plcs}g} rad"\
                    .format(r=r, p=phi, plcs=places, angle_char=self.angle_char)

        elif frmt == 'polar_deg':
            r, phi = np.absolute(data), np.angle(data, deg=True)
            if full_prec:
                return "{r} * {angle_char}{p}°"\
                    .format(r=r, p=phi, angle_char=self.angle_char)
            else:
                return "{r:.{plcs}g} * {angle_char}{p:.{plcs}g}°"\
                    .format(r=r, p=phi, plcs=places, angle_char=self.angle_char)

        elif frmt == 'polar_pi':
            r, phi = np.absolute(data), np.angle(data, deg=False) / np.pi
            if full_prec:
                return "{r} * {angle_char}{p} pi"\
                    .format(r=r, p=phi, angle_char=self.angle_char)
            else:
                return "{r:.{plcs}g} * {angle_char}{p:.{plcs}g} pi"\
                    .format(r=r, p=phi, plcs=places, angle_char=self.angle_char)

        else:
            logger.error("Unknown format {0}.".format(frmt))

    #------------------------------------------------------------------------------
    def frmt2cmplx(self, text, default=0.):
        """
        Convert format defined by cmbPZFrmt to real or complex
        """
        conv_error = False
        text = qstr(text).replace(
            " ", "")  # convert to "proper" string without blanks
        if qget_cmb_box(self.ui.cmbPZFrmt) == 'cartesian':
            return safe_eval(text, default, return_type='auto')
        else:
            polar_str = text.split('*' + self.angle_char, 1)
            if len(polar_str) < 2:  # input is real or imaginary
                r = safe_eval(re.sub('[' + self.angle_char + '<∠°]', '', text),
                              default,
                              return_type='auto')
                x = r.real
                y = r.imag
            else:
                r = safe_eval(polar_str[0], sign='pos')
                if safe_eval.err > 0:
                    conv_error = True

                if "°" in polar_str[1]:
                    scale = np.pi / 180.  # angle in degrees
                elif re.search('π$|pi$', polar_str[1]):
                    scale = np.pi
                else:
                    scale = 1.  # angle in rad

                # remove right-most special characters (regex $)
                polar_str[1] = re.sub(
                    '[' + self.angle_char + '<∠°π]$|rad$|pi$', '',
                    polar_str[1])
                phi = safe_eval(polar_str[1]) * scale
                if safe_eval.err > 0:
                    conv_error = True

                if not conv_error:
                    x = r * np.cos(phi)
                    y = r * np.sin(phi)
                else:
                    x = default.real
                    y = default.imag
                    logger.error(
                        "Expression {0} could not be evaluated.".format(text))
            return x + 1j * y

        #------------------------------------------------------------------------------
    def _copy_from_table(self):
        """
        Copy data from coefficient table `self.tblCoeff` to clipboard in CSV format
        or to file using a selected format
        """
        # pass table instance, numpy data and current class for accessing the
        # clipboard instance or for constructing a QFileDialog instance
        qtable2text(self.tblPZ,
                    self.zpk,
                    self,
                    'zpk',
                    title="Export Poles / Zeros")

    #------------------------------------------------------------------------------
    def _copy_to_table(self):
        """
        Read data from clipboard / file and copy it to `self.zpk` as array of complex
        # TODO: More checks for swapped row <-> col, single values, wrong data type ...
        """
        data_str = qtext2table(self, 'zpk', title="Import Poles / Zeros ")
        if data_str is None:  # file operation has been aborted
            return

        conv = self.frmt2cmplx  # routine for converting to cartesian coordinates

        if np.ndim(data_str) > 1:
            num_cols, num_rows = np.shape(data_str)
            orientation_horiz = num_cols > num_rows  # need to transpose data
        elif np.ndim(data_str) == 1:
            num_rows = len(data_str)
            num_cols = 1
            orientation_horiz = False
        else:
            logger.error("Imported data is a single value or None.")
            return None
        logger.debug("_copy_to_table: c x r:", num_cols, num_rows)
        if orientation_horiz:
            self.zpk = [[], []]
            for c in range(num_cols):
                self.zpk[0].append(conv(data_str[c][0]))
                if num_rows > 1:
                    self.zpk[1].append(conv(data_str[c][1]))
        else:
            self.zpk[0] = [conv(s) for s in data_str[0]]
            if num_cols > 1:
                self.zpk[1] = [conv(s) for s in data_str[1]]
            else:
                self.zpk[1] = [1]

        self.zpk[0] = np.asarray(self.zpk[0])
        self.zpk[1] = np.asarray(self.zpk[1])

        self._equalize_columns()
        qstyle_widget(self.ui.butSave, 'changed')
        self._refresh_table()

#------------------------------------------------------------------------------

    def _equalize_columns(self):
        """
        test and equalize if P and Z subarray have different lengths:
        """
        try:
            p_len = len(self.zpk[1])
        except IndexError:
            p_len = 0

        try:
            z_len = len(self.zpk[0])
        except IndexError:
            z_len = 0

        D = z_len - p_len

        if D > 0:  # more zeros than poles
            self.zpk[1] = np.append(self.zpk[1], np.zeros(D))
        elif D < 0:  # more poles than zeros
            self.zpk[0] = np.append(self.zpk[0], np.zeros(-D))