Source code for cdl.core.model.signal

# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.

"""
Signal object and related classes
---------------------------------

"""

# pylint: disable=invalid-name  # Allows short reference names like x, y, ...
# pylint: disable=duplicate-code

from __future__ import annotations

from contextlib import contextmanager
from typing import TYPE_CHECKING, Generator
from uuid import uuid4

import guidata.dataset as gds
import numpy as np
import scipy.signal as sps
from guidata.configtools import get_icon
from guidata.dataset import restore_dataset, update_dataset
from guidata.qthelpers import exec_dialog
from numpy import ma
from plotpy.builder import make
from plotpy.tools import EditPointTool
from qtpy import QtWidgets as QW

from cdl.algorithms.signal import GaussianModel, LorentzianModel, VoigtModel
from cdl.config import Conf, _
from cdl.core.model import base

if TYPE_CHECKING:
    from plotpy.items import CurveItem
    from plotpy.plot import PlotDialog
    from plotpy.styles import CurveParam


class CurveStyles:
    """Object to manage curve styles"""

    #: Curve colors
    COLORS = (
        "#1f77b4",  # muted blue
        "#ff7f0e",  # safety orange
        "#2ca02c",  # cooked asparagus green
        "#d62728",  # brick red
        "#9467bd",  # muted purple
        "#8c564b",  # chestnut brown
        "#e377c2",  # raspberry yogurt pink
        "#7f7f7f",  # gray
        "#bcbd22",  # curry yellow-green
        "#17becf",  # blue-teal
    )
    #: Curve line styles
    LINESTYLES = ("SolidLine", "DashLine", "DashDotLine", "DashDotDotLine")

    def __init__(self) -> None:
        self.__suspend = False
        self.curve_style = self.style_generator()

    @staticmethod
    def style_generator() -> Generator[tuple[str, str], None, None]:
        """Cycling through curve styles"""
        while True:
            for linestyle in CurveStyles.LINESTYLES:
                for color in CurveStyles.COLORS:
                    yield (color, linestyle)

    def apply_style(self, param: CurveParam) -> None:
        """Apply style to curve"""
        if self.__suspend:
            # Suspend mode: always apply the first style
            color, linestyle = CurveStyles.COLORS[0], CurveStyles.LINESTYLES[0]
        else:
            color, linestyle = next(self.curve_style)
        param.line.color = color
        param.line.style = linestyle
        param.symbol.marker = "NoSymbol"

    def reset_styles(self) -> None:
        """Reset styles"""
        self.curve_style = self.style_generator()

    @contextmanager
    def alternative(
        self, other_style_generator: Generator[tuple[str, str], None, None]
    ) -> Generator[None, None, None]:
        """Use an alternative style generator"""
        old_style_generator = self.curve_style
        self.curve_style = other_style_generator
        yield
        self.curve_style = old_style_generator

    @contextmanager
    def suspend(self) -> Generator[None, None, None]:
        """Suspend style generator"""
        self.__suspend = True
        yield
        self.__suspend = False


CURVESTYLES = CurveStyles()  # This is the unique instance of the CurveStyles class


[docs] class ROI1DParam(gds.DataSet): """Signal ROI parameters""" xmin = gds.FloatItem(_("First point coordinate")) xmax = gds.FloatItem(_("Last point coordinate"))
[docs] def get_data(self, obj: SignalObj) -> np.ndarray: """Get signal data in ROI Args: obj: signal object Returns: Data in ROI """ imin, imax = np.searchsorted(obj.x, [self.xmin, self.xmax]) return np.array([obj.x[imin:imax], obj.y[imin:imax]])
def apply_downsampling(item: CurveItem, do_not_update: bool = False) -> None: """Apply downsampling to curve item Args: item: curve item do_not_update: if True, do not update the item even if the downsampling parameters have changed """ old_use_dsamp = item.param.use_dsamp item.param.use_dsamp = False if Conf.view.sig_autodownsampling.get(): nbpoints = item.get_data()[0].size maxpoints = Conf.view.sig_autodownsampling_maxpoints.get() if nbpoints > 5 * maxpoints: item.param.use_dsamp = True item.param.dsamp_factor = nbpoints // maxpoints if not do_not_update and old_use_dsamp != item.param.use_dsamp: item.update_data()
[docs] class SignalObj(gds.DataSet, base.BaseObj): """Signal object""" PREFIX = "s" CONF_FMT = Conf.view.sig_format DEFAULT_FMT = "g" VALID_DTYPES = (np.float32, np.float64, np.complex128) uuid = gds.StringItem("UUID").set_prop("display", hide=True) _tabs = gds.BeginTabGroup("all") _datag = gds.BeginGroup(_("Data and metadata")) title = gds.StringItem(_("Signal title"), default=_("Untitled")) xydata = gds.FloatArrayItem(_("Data"), transpose=True, minmax="rows") metadata = gds.DictItem(_("Metadata"), default={}) _e_datag = gds.EndGroup(_("Data and metadata")) _unitsg = gds.BeginGroup(_("Titles and units")) title = gds.StringItem(_("Signal title"), default=_("Untitled")) _tabs_u = gds.BeginTabGroup("units") _unitsx = gds.BeginGroup(_("X-axis")) xlabel = gds.StringItem(_("Title"), default="") xunit = gds.StringItem(_("Unit"), default="") _e_unitsx = gds.EndGroup(_("X-axis")) _unitsy = gds.BeginGroup(_("Y-axis")) ylabel = gds.StringItem(_("Title"), default="") yunit = gds.StringItem(_("Unit"), default="") _e_unitsy = gds.EndGroup(_("Y-axis")) _e_tabs_u = gds.EndTabGroup("units") _e_unitsg = gds.EndGroup(_("Titles and units")) _scalesg = gds.BeginGroup(_("Scales")) _prop_autoscale = gds.GetAttrProp("autoscale") autoscale = gds.BoolItem(_("Auto scale"), default=True).set_prop( "display", store=_prop_autoscale ) _tabs_b = gds.BeginTabGroup("bounds") _boundsx = gds.BeginGroup(_("X-axis")) xscalelog = gds.BoolItem(_("Logarithmic scale"), default=False) xscalemin = gds.FloatItem(_("Lower bound"), check=False).set_prop( "display", active=gds.NotProp(_prop_autoscale) ) xscalemax = gds.FloatItem(_("Upper bound"), check=False).set_prop( "display", active=gds.NotProp(_prop_autoscale) ) _e_boundsx = gds.EndGroup(_("X-axis")) _boundsy = gds.BeginGroup(_("Y-axis")) yscalelog = gds.BoolItem(_("Logarithmic scale"), default=False) yscalemin = gds.FloatItem(_("Lower bound"), check=False).set_prop( "display", active=gds.NotProp(_prop_autoscale) ) yscalemax = gds.FloatItem(_("Upper bound"), check=False).set_prop( "display", active=gds.NotProp(_prop_autoscale) ) _e_boundsy = gds.EndGroup(_("Y-axis")) _e_tabs_b = gds.EndTabGroup("bounds") _e_scalesg = gds.EndGroup(_("Scales")) _e_tabs = gds.EndTabGroup("all") def __init__(self, title=None, comment=None, icon=""): """Constructor Args: title: title comment: comment icon: icon """ gds.DataSet.__init__(self, title, comment, icon) base.BaseObj.__init__(self) self.regenerate_uuid() self._maskdata_cache = None
[docs] def regenerate_uuid(self): """Regenerate UUID This method is used to regenerate UUID after loading the object from a file. This is required to avoid UUID conflicts when loading objects from file without clearing the workspace first. """ self.uuid = str(uuid4())
[docs] def copy( self, title: str | None = None, dtype: np.dtype | None = None ) -> SignalObj: """Copy object. Args: title: title dtype: data type Returns: Copied object """ title = self.title if title is None else title obj = SignalObj(title=title) obj.title = title obj.xlabel = self.xlabel obj.xunit = self.xunit obj.yunit = self.yunit if dtype not in (None, float, complex, np.complex128): raise RuntimeError("Signal data only supports float64/complex128 dtype") obj.metadata = base.deepcopy_metadata(self.metadata) obj.xydata = np.array(self.xydata, copy=True, dtype=dtype) return obj
[docs] def set_data_type(self, dtype: np.dtype) -> None: # pylint: disable=unused-argument """Change data type. Args: Data type """ raise RuntimeError("Setting data type is not support for signals")
[docs] def set_xydata( self, x: np.ndarray | list, y: np.ndarray | list, dx: np.ndarray | list | None = None, dy: np.ndarray | list | None = None, ) -> None: """Set xy data Args: x: x data y: y data dx: dx data (optional: error bars) dy: dy data (optional: error bars) """ if x is not None: x = np.array(x) if y is not None: y = np.array(y) if dx is not None: dx = np.array(dx) if dy is not None: dy = np.array(dy) if dx is None and dy is None: self.xydata = np.vstack([x, y]) else: if dx is None: dx = np.zeros_like(dy) if dy is None: dy = np.zeros_like(dx) self.xydata = np.vstack((x, y, dx, dy))
def __get_x(self) -> np.ndarray | None: """Get x data""" if self.xydata is not None: return self.xydata[0] return None def __set_x(self, data) -> None: """Set x data""" self.xydata[0] = np.array(data) def __get_y(self) -> np.ndarray | None: """Get y data""" if self.xydata is not None: return self.xydata[1] return None def __set_y(self, data) -> None: """Set y data""" self.xydata[1] = np.array(data) def __get_dx(self) -> np.ndarray | None: """Get dx data""" if self.xydata is not None and len(self.xydata) > 2: return self.xydata[2] return None def __set_dx(self, data) -> None: """Set dx data""" if self.xydata is not None and len(self.xydata) > 2: self.xydata[2] = np.array(data) else: raise ValueError("dx data not available") def __get_dy(self) -> np.ndarray | None: """Get dy data""" if self.xydata is not None and len(self.xydata) > 3: return self.xydata[3] return None def __set_dy(self, data) -> None: """Set dy data""" if self.xydata is not None and len(self.xydata) > 3: self.xydata[3] = np.array(data) else: raise ValueError("dy data not available") x = property(__get_x, __set_x) y = data = property(__get_y, __set_y) dx = property(__get_dx, __set_dx) dy = property(__get_dy, __set_dy)
[docs] def get_data(self, roi_index: int | None = None) -> tuple[np.ndarray, np.ndarray]: """ Return original data (if ROI is not defined or `roi_index` is None), or ROI data (if both ROI and `roi_index` are defined). Args: roi_index: ROI index Returns: Data """ if self.roi is None or roi_index is None: return self.x, self.y i1, i2 = self.roi[roi_index, :] return self.x[i1:i2], self.y[i1:i2]
[docs] def update_plot_item_parameters(self, item: CurveItem) -> None: """Update plot item parameters from object data/metadata Takes into account a subset of plot item parameters. Those parameters may have been overriden by object metadata entries or other object data. The goal is to update the plot item accordingly. This is *almost* the inverse operation of `update_metadata_from_plot_item`. Args: item: plot item """ update_dataset(item.param.line, self.metadata) update_dataset(item.param.symbol, self.metadata) super().update_plot_item_parameters(item)
[docs] def update_metadata_from_plot_item(self, item: CurveItem) -> None: """Update metadata from plot item. Takes into account a subset of plot item parameters. Those parameters may have been modified by the user through the plot item GUI. The goal is to update the metadata accordingly. This is *almost* the inverse operation of `update_plot_item_parameters`. Args: item: plot item """ super().update_metadata_from_plot_item(item) restore_dataset(item.param.line, self.metadata) restore_dataset(item.param.symbol, self.metadata)
[docs] def make_item(self, update_from: CurveItem = None) -> CurveItem: """Make plot item from data. Args: update_from: plot item to update from Returns: Plot item """ if len(self.xydata) in (2, 3, 4): if len(self.xydata) == 2: # x, y signal x, y = self.xydata item = make.mcurve(x.real, y.real, label=self.title) elif len(self.xydata) == 3: # x, y, dy error bar signal x, y, dy = self.xydata item = make.merror(x.real, y.real, dy.real, label=self.title) elif len(self.xydata) == 4: # x, y, dx, dy error bar signal x, y, dx, dy = self.xydata item = make.merror(x.real, y.real, dx.real, dy.real, label=self.title) CURVESTYLES.apply_style(item.param) apply_downsampling(item, do_not_update=True) else: raise RuntimeError("data not supported") if update_from is None: self.update_plot_item_parameters(item) else: update_dataset(item.param, update_from.param) item.update_params() return item
[docs] def update_item(self, item: CurveItem, data_changed: bool = True) -> None: """Update plot item from data. Args: item: plot item data_changed: if True, data has changed """ if data_changed: if len(self.xydata) == 2: # x, y signal x, y = self.xydata item.set_data(x.real, y.real) elif len(self.xydata) == 3: # x, y, dy error bar signal x, y, dy = self.xydata item.set_data(x.real, y.real, dy=dy.real) elif len(self.xydata) == 4: # x, y, dx, dy error bar signal x, y, dx, dy = self.xydata item.set_data(x.real, y.real, dx.real, dy.real) item.param.label = self.title apply_downsampling(item) self.update_plot_item_parameters(item)
[docs] def roi_coords_to_indexes(self, coords: list) -> np.ndarray: """Convert ROI coordinates to indexes. Args: coords: coordinates Returns: Indexes """ indexes = np.array(coords, int) for row in range(indexes.shape[0]): for col in range(indexes.shape[1]): x0 = coords[row][col] indexes[row, col] = np.abs(self.x - x0).argmin() return indexes
[docs] def get_roi_param(self, title: str, *defaults: int) -> ROI1DParam: """Return ROI parameters dataset (converting ROI point indexes to coordinates) Args: title: title *defaults: default values (first, last point indexes) Returns: ROI parameters dataset (containing the ROI coordinates: first and last X) """ i0, i1 = defaults param = ROI1DParam(title) param.xmin = self.x[i0] param.xmax = self.x[i1] param.set_global_prop("data", unit=self.xunit) return param
[docs] def params_to_roidata(self, params: gds.DataSetGroup) -> np.ndarray: """Convert ROI dataset group to ROI array data. Args: params: ROI dataset group Returns: ROI array data """ roilist = [] for roiparam in params.datasets: roiparam: ROI1DParam idx1 = np.searchsorted(self.x, roiparam.xmin) idx2 = np.searchsorted(self.x, roiparam.xmax) roilist.append([idx1, idx2]) if len(roilist) == 0: return None return np.array(roilist, int)
[docs] def new_roi_item(self, fmt: str, lbl: bool, editable: bool): """Return a new ROI item from scratch Args: fmt: format string lbl: if True, add label editable: if True, ROI is editable """ # We take the real part of the x data to avoid `ComplexWarning` warnings # when creating and manipulating the `XRangeSelection` shape (`plotpy`) xmin, xmax = self.x.real.min(), self.x.real.max() xdelta = (xmax - xmin) * 0.2 coords = xmin + xdelta, xmax - xdelta return base.make_roi_item( lambda x, y, _title: make.range(x, y), coords, "ROI", fmt, lbl, editable, option=self.PREFIX, )
[docs] def iterate_roi_items(self, fmt: str, lbl: bool, editable: bool = True): """Make plot item representing a Region of Interest. Args: fmt: format string lbl: if True, add label editable: if True, ROI is editable Yields: Plot item """ if self.roi is not None: # We take the real part of the x data to avoid `ComplexWarning` warnings # when creating and manipulating the `XRangeSelection` shape (`plotpy`) for index, coords in enumerate(self.x.real[self.roi]): yield base.make_roi_item( lambda x, y, _title: make.range(x, y), coords, f"ROI{index:02d}", fmt, lbl, editable, option=self.PREFIX, )
@property def maskdata(self) -> np.ndarray: """Return masked data (areas outside defined regions of interest) Returns: Masked data """ roi_changed = self.roi_has_changed() if self.roi is None: if roi_changed: self._maskdata_cache = None elif roi_changed or self._maskdata_cache is None: mask = np.ones_like(self.xydata, dtype=bool) for roirow in self.roi: mask[:, roirow[0] : roirow[1]] = False self._maskdata_cache = mask return self._maskdata_cache
[docs] def get_masked_view(self) -> ma.MaskedArray: """Return masked view for data Returns: Masked view """ self.data: np.ndarray view = self.data.view(ma.MaskedArray) view.mask = self.maskdata return view
[docs] def add_label_with_title(self, title: str | None = None) -> None: """Add label with title annotation Args: title: title (if None, use signal title) """ title = self.title if title is None else title if title: label = make.label(title, "TL", (0, 0), "TL") self.add_annotations_from_items([label])
[docs] def create_signal( title: str, x: np.ndarray | None = None, y: np.ndarray | None = None, dx: np.ndarray | None = None, dy: np.ndarray | None = None, metadata: dict | None = None, units: tuple[str, str] | None = None, labels: tuple[str, str] | None = None, ) -> SignalObj: """Create a new Signal object. Args: title: signal title x: X data y: Y data dx: dX data (optional: error bars) dy: dY data (optional: error bars) metadata: signal metadata units: X, Y units (tuple of strings) labels: X, Y labels (tuple of strings) Returns: Signal object """ assert isinstance(title, str) signal = SignalObj(title=title) signal.title = title signal.set_xydata(x, y, dx=dx, dy=dy) if units is not None: signal.xunit, signal.yunit = units if labels is not None: signal.xlabel, signal.ylabel = labels if metadata is not None: signal.metadata.update(metadata) return signal
[docs] class SignalTypes(base.Choices): """Signal types""" #: Signal filled with zeros ZEROS = _("zeros") #: Gaussian function GAUSS = _("gaussian") #: Lorentzian function LORENTZ = _("lorentzian") #: Voigt function VOIGT = "Voigt" #: Random signal (uniform law) UNIFORMRANDOM = _("random (uniform law)") #: Random signal (normal law) NORMALRANDOM = _("random (normal law)") #: Sinusoid SINUS = _("sinus") #: Cosinusoid COSINUS = _("cosinus") #: Sawtooth function SAWTOOTH = _("sawtooth") #: Triangle function TRIANGLE = _("triangle") #: Square function SQUARE = _("square") #: Cardinal sine SINC = _("cardinal sine") #: Step function STEP = _("step") #: Exponential function EXPONENTIAL = _("exponential") #: Pulse function PULSE = _("pulse") #: Polynomial function POLYNOMIAL = _("polynomial") #: Experimental function EXPERIMENTAL = _("experimental")
[docs] class GaussLorentzVoigtParam(gds.DataSet): """Parameters for Gaussian and Lorentzian functions""" a = gds.FloatItem("A", default=1.0) ymin = gds.FloatItem("Ymin", default=0.0).set_pos(col=1) sigma = gds.FloatItem("σ", default=1.0) mu = gds.FloatItem("μ", default=0.0).set_pos(col=1)
class FreqUnits(base.Choices): """Frequency units""" HZ = "Hz" KHZ = "kHz" MHZ = "MHz" GHZ = "GHz" @classmethod def convert_in_hz(cls, value, unit): """Convert value in Hz""" factor = {cls.HZ: 1, cls.KHZ: 1e3, cls.MHZ: 1e6, cls.GHZ: 1e9}.get(unit) if factor is None: raise ValueError(f"Unknown unit: {unit}") return value * factor
[docs] class PeriodicParam(gds.DataSet): """Parameters for periodic functions"""
[docs] def get_frequency_in_hz(self): """Return frequency in Hz""" return FreqUnits.convert_in_hz(self.freq, self.freq_unit)
a = gds.FloatItem("A", default=1.0) ymin = gds.FloatItem("Ymin", default=0.0).set_pos(col=1) freq = gds.FloatItem(_("Frequency"), default=1.0) freq_unit = gds.ChoiceItem( _("Unit"), FreqUnits.get_choices(), default=FreqUnits.HZ ).set_pos(col=1) phase = gds.FloatItem(_("Phase"), default=0.0, unit="°").set_pos(col=1)
[docs] class StepParam(gds.DataSet): """Parameters for step function""" a1 = gds.FloatItem("A1", default=0.0) a2 = gds.FloatItem("A2", default=1.0).set_pos(col=1) x0 = gds.FloatItem("X0", default=0.0)
class ExponentialParam(gds.DataSet): """Parameters for exponential function""" a = gds.FloatItem("A", default=1.0) offset = gds.FloatItem(_("Offset"), default=0.0) exponent = gds.FloatItem(_("Exponent"), default=1.0) class PulseParam(gds.DataSet): """Parameters for pulse function""" amp = gds.FloatItem("Amplitude", default=1.0) start = gds.FloatItem(_("Start"), default=0.0).set_pos(col=1) offset = gds.FloatItem(_("Offset"), default=0.0) stop = gds.FloatItem(_("End"), default=0.0).set_pos(col=1) class PolyParam(gds.DataSet): """Parameters for polynomial function""" a0 = gds.FloatItem("a0", default=1.0) a3 = gds.FloatItem("a3", default=0.0).set_pos(col=1) a1 = gds.FloatItem("a1", default=1.0) a4 = gds.FloatItem("a4", default=0.0).set_pos(col=1) a2 = gds.FloatItem("a2", default=0.0) a5 = gds.FloatItem("a5", default=0.0).set_pos(col=1) class ExperSignalParam(gds.DataSet): """Parameters for experimental signal""" size = gds.IntItem("Size", default=5).set_prop("display", hide=True) xyarray = gds.FloatArrayItem( "XY Values", format="%g", ) xmin = gds.FloatItem("Min", default=0).set_prop("display", hide=True) xmax = gds.FloatItem("Max", default=1).set_prop("display", hide=True) def edit_curve(self, *args) -> None: # pylint: disable=unused-argument """Edit experimental curve""" win: PlotDialog = make.dialog( wintitle=_("Select one point then press OK to accept"), edit=True, type="curve", ) edit_tool = win.manager.add_tool( EditPointTool, title=_("Edit experimental curve") ) edit_tool.activate() plot = win.manager.get_plot() x, y = self.xyarray[:, 0], self.xyarray[:, 1] curve = make.mcurve(x, y, "-+") plot.add_item(curve) plot.set_active_item(curve) insert_btn = QW.QPushButton(_("Insert point"), win) insert_btn.clicked.connect(edit_tool.trigger_insert_point_at_selection) win.button_layout.insertWidget(0, insert_btn) exec_dialog(win) new_x, new_y = curve.get_data() self.xmax = new_x.max() self.xmin = new_x.min() self.size = new_x.size self.xyarray = np.vstack((new_x, new_y)).T btn_curve_edit = gds.ButtonItem( "Edit curve", callback=edit_curve, icon="signal.svg" ) def setup_array( self, size: int | None = None, xmin: float | None = None, xmax: float | None = None, ) -> None: """Setup the xyarray from size, xmin and xmax (use the current values is not provided) Args: size: xyarray size (default: None) xmin: X min (default: None) xmax: X max (default: None) """ self.size = size or self.size self.xmin = xmin or self.xmin self.xmax = xmax or self.xmax x_arr = np.linspace(self.xmin, self.xmax, self.size) # type: ignore self.xyarray = np.vstack((x_arr, x_arr)).T
[docs] class NewSignalParam(gds.DataSet): """New signal dataset""" hide_signal_type = False title = gds.StringItem(_("Title")) xmin = gds.FloatItem("Xmin", default=-10.0) xmax = gds.FloatItem("Xmax", default=10.0) size = gds.IntItem( _("Size"), help=_("Signal size (total number of points)"), min=1, default=500 ) stype = gds.ChoiceItem(_("Type"), SignalTypes.get_choices()).set_prop( "display", hide=gds.GetAttrProp("hide_signal_type") )
DEFAULT_TITLE = _("Untitled signal")
[docs] def new_signal_param( title: str | None = None, stype: str | None = None, xmin: float | None = None, xmax: float | None = None, size: int | None = None, ) -> NewSignalParam: """Create a new Signal dataset instance. Args: title: dataset title (default: None, uses default title) stype: signal type (default: None, uses default type) xmin: X min (default: None, uses default value) xmax: X max (default: None, uses default value) size: signal size (default: None, uses default value) Returns: NewSignalParam: new signal dataset instance """ title = DEFAULT_TITLE if title is None else title param = NewSignalParam(title=title, icon=get_icon("new_signal.svg")) param.title = title if xmin is not None: param.xmin = xmin if xmax is not None: param.xmax = xmax if size is not None: param.size = size if stype is not None: param.stype = stype return param
SIG_NB = 0 def triangle_func(xarr: np.ndarray) -> np.ndarray: """Triangle function Args: xarr: x data """ return sps.sawtooth(xarr, width=0.5)
[docs] def create_signal_from_param( newparam: NewSignalParam, addparam: gds.DataSet | None = None, edit: bool = False, parent: QW.QWidget | None = None, ) -> SignalObj | None: """Create a new Signal object from a dialog box. Args: newparam: new signal parameters addparam: additional parameters edit: Open a dialog box to edit parameters (default: False) parent: parent widget Returns: Signal object or None if canceled """ global SIG_NB # pylint: disable=global-statement if newparam is None: newparam = new_signal_param() incr_sig_nb = not newparam.title if incr_sig_nb: newparam.title = f"{newparam.title} {SIG_NB + 1:d}" if not edit or addparam is not None or newparam.edit(parent=parent): prefix = newparam.stype.name.lower() if incr_sig_nb: SIG_NB += 1 signal = create_signal(newparam.title) xarr = np.linspace(newparam.xmin, newparam.xmax, newparam.size) p = addparam if newparam.stype == SignalTypes.ZEROS: signal.set_xydata(xarr, np.zeros(newparam.size)) elif newparam.stype in (SignalTypes.UNIFORMRANDOM, SignalTypes.NORMALRANDOM): pclass = { SignalTypes.UNIFORMRANDOM: base.UniformRandomParam, SignalTypes.NORMALRANDOM: base.NormalRandomParam, }[newparam.stype] if p is None: p = pclass(_("Signal") + " - " + prefix) if edit and not p.edit(parent=parent): return None rng = np.random.default_rng(p.seed) if newparam.stype == SignalTypes.UNIFORMRANDOM: yarr = rng.random((newparam.size,)) * (p.vmax - p.vmin) + p.vmin if signal.title == DEFAULT_TITLE: signal.title = f"{prefix}(vmin={p.vmin:.3g},vmax={p.vmax:.3g})" elif newparam.stype == SignalTypes.NORMALRANDOM: yarr = rng.normal(p.mu, p.sigma, size=(newparam.size,)) if signal.title == DEFAULT_TITLE: signal.title = f"{prefix}(mu={p.mu:.3g},sigma={p.sigma:.3g})" else: raise NotImplementedError(f"New param type: {prefix}") signal.set_xydata(xarr, yarr) elif newparam.stype in ( SignalTypes.GAUSS, SignalTypes.LORENTZ, SignalTypes.VOIGT, ): func, title = { SignalTypes.GAUSS: (GaussianModel.func, _("Gaussian")), SignalTypes.LORENTZ: (LorentzianModel.func, _("Lorentzian")), SignalTypes.VOIGT: (VoigtModel.func, "Voigt"), }[newparam.stype] if p is None: p = GaussLorentzVoigtParam(title) if edit and not p.edit(parent=parent): return None yarr = func(xarr, p.a, p.sigma, p.mu, p.ymin) signal.set_xydata(xarr, yarr) if signal.title == DEFAULT_TITLE: signal.title = ( f"{prefix}(a={p.a:.3g},sigma={p.sigma:.3g}," f"mu={p.mu:.3g},ymin={p.ymin:.3g})" ) elif newparam.stype in ( SignalTypes.SINUS, SignalTypes.COSINUS, SignalTypes.SAWTOOTH, SignalTypes.TRIANGLE, SignalTypes.SQUARE, SignalTypes.SINC, ): func, title = { SignalTypes.SINUS: (np.sin, _("Sinusoid")), SignalTypes.COSINUS: (np.cos, _("Sinusoid")), SignalTypes.SAWTOOTH: (sps.sawtooth, _("Sawtooth function")), SignalTypes.TRIANGLE: (triangle_func, _("Triangle function")), SignalTypes.SQUARE: (sps.square, _("Square function")), SignalTypes.SINC: (np.sinc, _("Cardinal sine")), }[newparam.stype] if p is None: p = PeriodicParam(title) if edit and not p.edit(parent=parent): return None freq = p.get_frequency_in_hz() yarr = p.a * func(2 * np.pi * freq * xarr + np.deg2rad(p.phase)) + p.ymin signal.set_xydata(xarr, yarr) if signal.title == DEFAULT_TITLE: signal.title = ( f"{prefix}(f={p.freq:.3g} {p.freq_unit.value})," f"a={p.a:.3g},ymin={p.ymin:.3g},phase={p.phase:.3g}°)" ) elif newparam.stype == SignalTypes.STEP: if p is None: p = StepParam( _("Step function"), comment="y(x) = a1 if x <= x0 else a2" ) if edit and not p.edit(parent=parent): return None yarr = np.ones_like(xarr) * p.a1 yarr[xarr > p.x0] = p.a2 signal.set_xydata(xarr, yarr) if signal.title == DEFAULT_TITLE: signal.title = f"{prefix}(x0={p.x0:.3g},a1={p.a1:.3g},a2={p.a2:.3g})" elif newparam.stype is SignalTypes.EXPONENTIAL: if p is None: p = ExponentialParam( _("Exponential function"), comment="y(x) = a.e<sup>exponent.x</sup> + offset", ) if edit and not p.edit(parent=parent): return None yarr = p.a * np.exp(p.exponent * xarr) + p.offset signal.set_xydata(xarr, yarr) if signal.title == DEFAULT_TITLE: signal.title = ( f"{prefix}(a={p.a:.3g},exponent={p.exponent:.3g}," f"offset={p.offset:.3g})" ) elif newparam.stype is SignalTypes.PULSE: if p is None: p = PulseParam( _("Pulse function"), comment="y(x) = offset + amp if start <= x <= stop else offset", ) if edit and not p.edit(parent=parent): return None yarr = np.full_like(xarr, p.offset) yarr[(xarr >= p.start) & (xarr <= p.stop)] += p.amp signal.set_xydata(xarr, yarr) if signal.title == DEFAULT_TITLE: signal.title = ( f"{prefix}(start={p.start:.3g},stop={p.stop:.3g}" f",offset={p.offset:.3g})" ) elif newparam.stype is SignalTypes.POLYNOMIAL: if p is None: p = PolyParam( _("Polynomial function"), comment=( "y(x) = a<sub>0</sub> + a<sub>1</sub>.x + " "a<sub>2</sub>.x<sup>2</sup> + a<sub>3</sub>.x<sup>3</sup>" " + a<sub>4</sub>.x<sup>4</sup> + a<sub>5</sub>.x<sup>5</sup>" ), ) if edit and not p.edit(parent=parent): return None yarr = np.polyval([p.a5, p.a4, p.a3, p.a2, p.a1, p.a0], xarr) signal.set_xydata(xarr, yarr) if signal.title == DEFAULT_TITLE: signal.title = ( f"{prefix}(a0={p.a0:2g},a1={p.a1:2g},a2={p.a2:2g}," f"a3={p.a3:2g},a4={p.a4:2g},a5={p.a5:2g})" ) elif newparam.stype is SignalTypes.EXPERIMENTAL: p2 = ExperSignalParam(_("Experimental points")) p2.setup_array(size=newparam.size, xmin=newparam.xmin, xmax=newparam.xmax) if edit and not p2.edit(parent=parent): return None signal.xydata = p2.xyarray.T if signal.title == DEFAULT_TITLE: signal.title = f"{prefix}(npts={p2.size})" return signal return None