Merge pull request #20 from nqrduck/formbuilder-and-function-optimization

Formbuilder and function optimization
This commit is contained in:
Julia P 2024-04-26 17:54:09 +02:00 committed by GitHub
commit 17d5e79a70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 225 additions and 443 deletions

View file

@ -1,5 +1,8 @@
# Changelog
### Version 0.0.9 (26-04-2024)
- Switched to new formbuilder, moved Function to core
### Version 0.0.8 (18-04-2024)
- Automatic deployment to PyPI

View file

@ -7,7 +7,7 @@ allow-direct-references = true
[project]
name = "nqrduck-spectrometer"
version = "0.0.8"
version = "0.0.9"
authors = [
{ name="jupfi", email="support@nqruck.cool" },
]

View file

@ -88,7 +88,7 @@ class BaseSpectrometerModel(ModuleModel):
for option in self.options:
if option.name == name:
return option
raise ValueError("Option with name %s not found" % name)
raise ValueError(f"Option with name {name} not found")
def __init__(self, module):
"""Initializes the spectrometer model.
@ -127,7 +127,7 @@ class BaseSpectrometerModel(ModuleModel):
for setting in self.settings[category]:
if setting.name == name:
return setting
raise ValueError("Setting with name %s not found" % name)
raise ValueError(f"Setting with name {name} not found")
def add_pulse_parameter_option(
self, name: str, pulse_parameter_class: PulseParameter

View file

@ -38,7 +38,7 @@ class BaseSpectrometerView(ModuleView):
grid = self._ui_form.gridLayout
self._ui_form.verticalLayout.removeItem(self._ui_form.gridLayout)
# Add name of the spectrometer to the view
label = QLabel("%s Settings:" % self.module.model.toolbar_name)
label = QLabel(f"{self.module.model.toolbar_name} Settings:")
label.setStyleSheet("font-weight: bold;")
self._ui_form.verticalLayout.setSpacing(5)
self._ui_form.verticalLayout.addWidget(label)
@ -47,7 +47,7 @@ class BaseSpectrometerView(ModuleView):
for category_count, category in enumerate(self.module.model.settings.keys()):
logger.debug("Adding settings for category: %s", category)
category_layout = QVBoxLayout()
category_label = QLabel("%s:" % category)
category_label = QLabel(f"{category}:")
category_label.setStyleSheet("font-weight: bold;")
row = category_count // 2
column = category_count % 2

View file

@ -3,6 +3,7 @@
import logging
import numpy as np
from nqrduck.helpers.signalprocessing import SignalProcessing as sp
from nqrduck.helpers.functions import Function
logger = logging.getLogger(__name__)
@ -39,12 +40,43 @@ class Measurement:
IF_frequency: float = 0,
) -> None:
"""Initializes the measurement."""
# Convert to decimal
self.tdx = tdx
self.tdy = tdy
self.target_frequency = target_frequency
self.fdx, self.fdy = sp.fft(tdx, tdy, frequency_shift)
self.IF_frequency = IF_frequency
def apodization(self, function : Function):
"""Applies apodization to the measurement data.
Args:
function (Function): Apodization function.
returns:
Measurement : The apodized measurement.
"""
# Get the y data weights from the function
duration = (self.tdx[-1] - self.tdx[0]) * 1e-6
resolution = duration / len(self.tdx)
logger.debug("Resolution: %s", resolution)
y_weight = function.get_pulse_amplitude(duration, resolution)
tdy_measurement = self.tdy * y_weight
apodized_measurement = Measurement(
self.tdx,
tdy_measurement,
target_frequency=self.target_frequency,
IF_frequency=self.IF_frequency,
)
return apodized_measurement
# Data saving and loading
def to_json(self):

View file

@ -6,425 +6,22 @@ Todo:
from __future__ import annotations
import logging
import numpy as np
import sympy
from decimal import Decimal
from PyQt6.QtGui import QPixmap
from nqrduck.contrib.mplwidget import MplWidget
from nqrduck.helpers.signalprocessing import SignalProcessing as sp
from numpy.core.multiarray import array as array
from nqrduck.assets.icons import PulseParamters
from nqrduck.helpers.functions import (
Function,
RectFunction,
SincFunction,
GaussianFunction,
CustomFunction,
)
from .base_spectrometer_model import BaseSpectrometerModel
logger = logging.getLogger(__name__)
class Function:
"""A function that can be used as a pulse parameter.
This class is the base class for all functions that can be used as pulse parameters. Functions can be used for pulse shapes, for example.
Args:
expr (str | sympy.Expr): The expression of the function.
Attributes:
name (str): The name of the function.
parameters (list): The parameters of the function.
expr (sympy.Expr): The sympy expression of the function.
resolution (Decimal): The resolution of the function in seconds.
start_x (float): The x value where the evalution of the function starts.
end_x (float): The x value where the evalution of the function ends.
"""
name: str
parameters: list
expression: str | sympy.Expr
resolution: Decimal
start_x: float
end_x: float
def __init__(self, expr) -> None:
"""Initializes the function."""
self.parameters = []
self.expr = expr
self.resolution = Decimal(1 / 30.72e6)
self.start_x = -1
self.end_x = 1
def get_time_points(self, pulse_length: Decimal) -> np.ndarray:
"""Returns the time domain points for the function with the given pulse length.
Args:
pulse_length (Decimal): The pulse length in seconds.
Returns:
np.ndarray: The time domain points.
"""
# Get the time domain points
n = int(pulse_length / self.resolution)
t = np.linspace(0, float(pulse_length), n)
return t
def evaluate(self, pulse_length: Decimal, resolution: Decimal = None) -> np.ndarray:
"""Evaluates the function for the given pulse length.
Args:
pulse_length (Decimal): The pulse length in seconds.
resolution (Decimal, optional): The resolution of the function in seconds. Defaults to None.
Returns:
np.ndarray: The evaluated function.
"""
if resolution is None:
resolution = self.resolution
n = int(pulse_length / resolution)
t = np.linspace(self.start_x, self.end_x, n)
x = sympy.symbols("x")
found_variables = dict()
# Create a dictionary of the parameters and their values
for parameter in self.parameters:
found_variables[parameter.symbol] = parameter.value
final_expr = self.expr.subs(found_variables)
# If the expression is a number (does not depend on x), return an array of that number
if final_expr.is_number:
return np.full(t.shape, float(final_expr))
f = sympy.lambdify([x], final_expr, "numpy")
return f(t)
def get_tdx(self, pulse_length: float) -> np.ndarray:
"""Returns the time domain points and the evaluated function for the given pulse length.
Args:
pulse_length (float): The pulse length in seconds.
Returns:
np.ndarray: The time domain points.
"""
n = int(pulse_length / self.resolution)
t = np.linspace(self.start_x, self.end_x, n)
return t
def frequency_domain_plot(self, pulse_length: Decimal) -> MplWidget:
"""Plots the frequency domain of the function for the given pulse length.
Args:
pulse_length (Decimal): The pulse length in seconds.
Returns:
MplWidget: The matplotlib widget containing the plot.
"""
mpl_widget = MplWidget()
td = self.get_time_points(pulse_length)
yd = self.evaluate(pulse_length)
xdf, ydf = sp.fft(td, yd)
mpl_widget.canvas.ax.plot(xdf, abs(ydf))
mpl_widget.canvas.ax.set_xlabel("Frequency in Hz")
mpl_widget.canvas.ax.set_ylabel("Magnitude")
mpl_widget.canvas.ax.grid(True)
return mpl_widget
def time_domain_plot(self, pulse_length: Decimal) -> MplWidget:
"""Plots the time domain of the function for the given pulse length.
Args:
pulse_length (Decimal): The pulse length in seconds.
Returns:
MplWidget: The matplotlib widget containing the plot.
"""
mpl_widget = MplWidget()
td = self.get_time_points(pulse_length)
mpl_widget.canvas.ax.plot(td, abs(self.evaluate(pulse_length)))
mpl_widget.canvas.ax.set_xlabel("Time in s")
mpl_widget.canvas.ax.set_ylabel("Magnitude")
mpl_widget.canvas.ax.grid(True)
return mpl_widget
def get_pulse_amplitude(
self, pulse_length: Decimal, resolution: Decimal = None
) -> np.array:
"""Returns the pulse amplitude in the time domain.
Args:
pulse_length (Decimal): The pulse length in seconds.
resolution (Decimal, optional): The resolution of the function in seconds. Defaults to None.
Returns:
np.array: The pulse amplitude.
"""
return self.evaluate(pulse_length, resolution=resolution)
def add_parameter(self, parameter: Function.Parameter) -> None:
"""Adds a parameter to the function.
Args:
parameter (Function.Parameter): The parameter to add.
"""
self.parameters.append(parameter)
def to_json(self) -> dict:
"""Returns a json representation of the function.
Returns:
dict: The json representation of the function.
"""
return {
"name": self.name,
"parameters": [parameter.to_json() for parameter in self.parameters],
"expression": str(self.expr),
"resolution": self.resolution,
"start_x": self.start_x,
"end_x": self.end_x,
}
@classmethod
def from_json(cls, data: dict) -> Function:
"""Creates a function from a json representation.
Args:
data (dict): The json representation of the function.
Returns:
Function: The function.
"""
for subclass in cls.__subclasses__():
if subclass.name == data["name"]:
cls = subclass
break
obj = cls()
obj.expr = data["expression"]
obj.name = data["name"]
obj.resolution = data["resolution"]
obj.start_x = data["start_x"]
obj.end_x = data["end_x"]
obj.parameters = []
for parameter in data["parameters"]:
obj.add_parameter(Function.Parameter.from_json(parameter))
return obj
@property
def expr(self):
"""The sympy expression of the function."""
return self._expr
@expr.setter
def expr(self, expr):
if isinstance(expr, str):
try:
self._expr = sympy.sympify(expr)
except ValueError:
logger.error("Could not convert %s to a sympy expression", expr)
raise SyntaxError("Could not convert %s to a sympy expression" % expr)
elif isinstance(expr, sympy.Expr):
self._expr = expr
@property
def resolution(self):
"""The resolution of the function in seconds."""
return self._resolution
@resolution.setter
def resolution(self, resolution):
try:
self._resolution = Decimal(resolution)
except ValueError:
logger.error("Could not convert %s to a decimal", resolution)
raise SyntaxError("Could not convert %s to a decimal" % resolution)
@property
def start_x(self):
"""The x value where the evalution of the function starts."""
return self._start_x
@start_x.setter
def start_x(self, start_x):
try:
self._start_x = float(start_x)
except ValueError:
logger.error("Could not convert %s to a float", start_x)
raise SyntaxError("Could not convert %s to a float" % start_x)
@property
def end_x(self):
"""The x value where the evalution of the function ends."""
return self._end_x
@end_x.setter
def end_x(self, end_x):
try:
self._end_x = float(end_x)
except ValueError:
logger.error("Could not convert %s to a float", end_x)
raise SyntaxError("Could not convert %s to a float" % end_x)
def get_pixmap(self):
"""This is the default pixmap for every function. If one wants to have a custom pixmap, this method has to be overwritten.
Returns:
QPixmap : The default pixmap for every function
"""
pixmap = PulseParamters.TXCustom()
return pixmap
class Parameter:
"""A parameter of a function.
This can be for example the standard deviation of a Gaussian function.
Args:
name (str): The name of the parameter.
symbol (str): The symbol of the parameter.
value (float): The value of the parameter.
Attributes:
name (str): The name of the parameter.
symbol (str): The symbol of the parameter.
value (float): The value of the parameter.
default (float): The default value of the parameter.
"""
def __init__(self, name: str, symbol: str, value: float) -> None:
"""Initializes the parameter."""
self.name = name
self.symbol = symbol
self.value = value
self.default = value
def set_value(self, value: float) -> None:
"""Sets the value of the parameter.
Args:
value (float): The new value of the parameter.
"""
self.value = value
logger.debug("Parameter %s set to %s", self.name, self.value)
def to_json(self) -> dict:
"""Returns a json representation of the parameter.
Returns:
dict: The json representation of the parameter.
"""
return {
"name": self.name,
"symbol": self.symbol,
"value": self.value,
"default": self.default,
}
@classmethod
def from_json(cls, data):
"""Creates a parameter from a json representation.
Args:
data (dict): The json representation of the parameter.
Returns:
Function.Parameter: The parameter.
"""
obj = cls(data["name"], data["symbol"], data["value"])
obj.default = data["default"]
return obj
class RectFunction(Function):
"""The rectangular function."""
name = "Rectangular"
def __init__(self) -> None:
"""Initializes the RecFunction."""
expr = sympy.sympify("1")
super().__init__(expr)
def get_pixmap(self) -> QPixmap:
"""Returns the pixmap of the rectangular function.
Returns:
QPixmap: The pixmap of the rectangular function.
"""
pixmap = PulseParamters.TXRect()
return pixmap
class SincFunction(Function):
"""The sinc function.
The sinc function is defined as sin(x * l) / (x * l).
The parameter is the scale factor l.
"""
name = "Sinc"
def __init__(self) -> None:
"""Initializes the SincFunction."""
expr = sympy.sympify("sin(x * l)/ (x * l)")
super().__init__(expr)
self.add_parameter(Function.Parameter("Scale Factor", "l", 2))
self.start_x = -np.pi
self.end_x = np.pi
def get_pixmap(self):
"""Returns the pixmap of the sinc function.
Returns:
QPixmap: The pixmap of the sinc function.
"""
pixmap = PulseParamters.TXSinc()
return pixmap
class GaussianFunction(Function):
"""The Gaussian function.
The Gaussian function is defined as exp(-0.5 * ((x - mu) / sigma)**2).
The parameters are the mean and the standard deviation.
"""
name = "Gaussian"
def __init__(self) -> None:
"""Initializes the GaussianFunction."""
expr = sympy.sympify("exp(-0.5 * ((x - mu) / sigma)**2)")
super().__init__(expr)
self.add_parameter(Function.Parameter("Mean", "mu", 0))
self.add_parameter(Function.Parameter("Standard Deviation", "sigma", 1))
self.start_x = -np.pi
self.end_x = np.pi
def get_pixmap(self):
"""Returns the QPixmap of the Gaussian function.
Returns:
QPixmap: The QPixmap of the Gaussian function.
"""
pixmap = PulseParamters.TXGauss()
return pixmap
# class TriangleFunction(Function):
# def __init__(self) -> None:
# expr = sympy.sympify("triang(x)")
# super().__init__(lambda x: triang(x))
class CustomFunction(Function):
"""A custom function."""
name = "Custom"
def __init__(self) -> None:
"""Initializes the Custom Function."""
expr = sympy.sympify(" 2 * x**2 + 3 * x + 1")
super().__init__(expr)
class Option:
"""Defines options for the pulse parameters which can then be set accordingly.
@ -439,6 +36,13 @@ class Option:
value: The value of the option.
"""
subclasses = []
def __init_subclass__(cls, **kwargs):
"""Adds the subclass to the list of subclasses."""
super().__init_subclass__(**kwargs)
cls.subclasses.append(cls)
def __init__(self, name: str, value) -> None:
"""Initializes the option."""
self.name = name
@ -457,7 +61,11 @@ class Option:
Returns:
dict: The json representation of the option.
"""
return {"name": self.name, "value": self.value, "type": self.TYPE}
return {
"name": self.name,
"value": self.value,
"class": self.__class__.__name__,
}
@classmethod
def from_json(cls, data) -> Option:
@ -469,8 +77,9 @@ class Option:
Returns:
Option: The option.
"""
for subclass in cls.__subclasses__():
if subclass.TYPE == data["type"]:
for subclass in cls.subclasses:
logger.debug(f"Keys data: {data.keys()}")
if subclass.__name__ == data["class"]:
cls = subclass
break
@ -486,8 +95,6 @@ class Option:
class BooleanOption(Option):
"""Defines a boolean option for a pulse parameter option."""
TYPE = "Boolean"
def set_value(self, value):
"""Sets the value of the option."""
self.value = value
@ -496,11 +103,67 @@ class BooleanOption(Option):
class NumericOption(Option):
"""Defines a numeric option for a pulse parameter option."""
TYPE = "Numeric"
def __init__(
self, name: str, value, is_float=True, min_value=None, max_value=None
) -> None:
"""Initializes the NumericOption.
Args:
name (str): The name of the option.
value: The value of the option.
is_float (bool): If the value is a float.
min_value: The minimum value of the option.
max_value: The maximum value of the option.
"""
super().__init__(name, value)
self.is_float = is_float
self.min_value = min_value
self.max_value = max_value
def set_value(self, value):
"""Sets the value of the option."""
self.value = float(value)
if value < self.min_value:
self.value = self.min_value
elif value >= self.max_value:
self.value = self.max_value
else:
raise ValueError(
f"Value {value} is not in the range of {self.min_value} to {self.max_value}. This should have been cought earlier."
)
def to_json(self):
"""Returns a json representation of the option.
Returns:
dict: The json representation of the option.
"""
return {
"name": self.name,
"value": self.value,
"class": self.__class__.__name__,
"is_float": self.is_float,
"min_value": self.min_value,
"max_value": self.max_value,
}
@classmethod
def from_json(cls, data):
"""Creates a NumericOption from a json representation.
Args:
data (dict): The json representation of the NumericOption.
Returns:
NumericOption: The NumericOption.
"""
obj = cls(
data["name"],
data["value"],
is_float=data["is_float"],
min_value=data["min_value"],
max_value=data["max_value"],
)
return obj
class FunctionOption(Option):
@ -517,8 +180,6 @@ class FunctionOption(Option):
functions (list): The functions that can be selected.
"""
TYPE = "Function"
def __init__(self, name, functions) -> None:
"""Initializes the FunctionOption."""
super().__init__(name, functions[0])
@ -544,7 +205,7 @@ class FunctionOption(Option):
for function in self.functions:
if function.name == name:
return function
raise ValueError("Function with name %s not found" % name)
raise ValueError(f"Function with name {name} not found")
def to_json(self):
"""Returns a json representation of the option.
@ -552,7 +213,12 @@ class FunctionOption(Option):
Returns:
dict: The json representation of the option.
"""
return {"name": self.name, "value": self.value.to_json(), "type": self.TYPE}
return {
"name": self.name,
"value": self.value.to_json(),
"class": self.__class__.__name__,
"functions": [function.to_json() for function in self.functions],
}
@classmethod
def from_json(cls, data):
@ -564,7 +230,9 @@ class FunctionOption(Option):
Returns:
FunctionOption: The FunctionOption.
"""
functions = [function() for function in Function.__subclasses__()]
logger.debug(f"Data: {data}")
# These are all available functions
functions = [Function.from_json(function) for function in data["functions"]]
obj = cls(data["name"], functions)
obj.value = Function.from_json(data["value"])
return obj
@ -574,34 +242,102 @@ class FunctionOption(Option):
return self.value.get_pixmap()
class TXRectFunction(RectFunction):
"""TX Rectangular function.
Adds the pixmap of the function to the class.
"""
def __init__(self) -> None:
"""Initializes the TX Rectangular function."""
super().__init__()
self.name = "Rectangular"
def get_pixmap(self):
"""Returns the pixmaps of the function."""
return PulseParamters.TXRect()
class TXSincFunction(SincFunction):
"""TX Sinc function.
Adds the pixmap of the function to the class.
"""
def __init__(self) -> None:
"""Initializes the TX Sinc function."""
super().__init__()
self.name = "Sinc"
def get_pixmap(self):
"""Returns the pixmaps of the function."""
return PulseParamters.TXSinc()
class TXGaussianFunction(GaussianFunction):
"""TX Gaussian function.
Adds the pixmap of the function to the class.
"""
def __init__(self) -> None:
"""Initializes the TX Gaussian function."""
super().__init__()
self.name = "Gaussian"
def get_pixmap(self):
"""Returns the pixmaps of the function."""
return PulseParamters.TXGauss()
class TXCustomFunction(CustomFunction):
"""TX Custom function.
Adds the pixmap of the function to the class.
"""
def __init__(self) -> None:
"""Initializes the TX Custom function."""
super().__init__()
self.name = "Custom"
def get_pixmap(self):
"""Returns the pixmaps of the function."""
return PulseParamters.TXCustom()
class TXPulse(BaseSpectrometerModel.PulseParameter):
"""Basic TX Pulse Parameter. It includes options for the relative amplitude, the phase and the pulse shape.
Args:
name (str): The name of the pulse parameter.
Attributes:
RELATIVE_AMPLITUDE (str): The relative amplitude of the pulse.
TX_PHASE (str): The phase of the pulse.
TX_PULSE_SHAPE (str): The pulse shape.
"""
RELATIVE_AMPLITUDE = "Relative TX Amplitude"
RELATIVE_AMPLITUDE = "Relative TX Amplitude (%)"
TX_PHASE = "TX Phase"
TX_PULSE_SHAPE = "TX Pulse Shape"
def __init__(self, name) -> None:
def __init__(self, name: str) -> None:
"""Initializes the TX Pulse Parameter.
It adds the options for the relative amplitude, the phase and the pulse shape.
"""
super().__init__(name)
self.add_option(NumericOption(self.RELATIVE_AMPLITUDE, 0))
self.add_option(
NumericOption(
self.RELATIVE_AMPLITUDE, 0, is_float=False, min_value=0, max_value=100
)
)
self.add_option(NumericOption(self.TX_PHASE, 0))
self.add_option(
FunctionOption(
self.TX_PULSE_SHAPE,
[RectFunction(), SincFunction(), GaussianFunction(), CustomFunction()],
[
TXRectFunction(),
TXSincFunction(),
TXGaussianFunction(),
TXCustomFunction(),
],
),
)

View file

@ -1,6 +1,7 @@
"""Contains the PulseSequence class that is used to store a pulse sequence and its events."""
import logging
import importlib.metadata
from collections import OrderedDict
from nqrduck.helpers.unitconverter import UnitConverter
from nqrduck_spectrometer.pulseparameters import Option
@ -19,9 +20,14 @@ class PulseSequence:
events (list): The events of the pulse sequence
"""
def __init__(self, name) -> None:
def __init__(self, name, version = None) -> None:
"""Initializes the pulse sequence."""
self.name = name
# Saving version to check for compatability of saved sequence
if version is not None:
self.version = version
else:
self.version = importlib.metadata.version("nqrduck_spectrometer")
self.events = list()
def get_event_names(self) -> list:
@ -111,7 +117,7 @@ class PulseSequence:
def duration(self, duration: str):
# Duration needs to be a positive number
try:
duration = UnitConverter.to_decimal(duration)
duration = UnitConverter.to_float(duration)
except ValueError:
raise ValueError("Duration needs to be a number")
if duration < 0:
@ -125,7 +131,8 @@ class PulseSequence:
Returns:
dict: The dict with the sequence data
"""
data = {"name": self.name, "events": []}
# Get the versions of this package
data = {"name": self.name, "version" : self.version, "events": []}
for event in self.events:
event_data = {
"name": event.name,
@ -156,7 +163,7 @@ class PulseSequence:
Raises:
KeyError: If the pulse parameter options are not the same as the ones in the pulse sequence
"""
obj = cls(sequence["name"])
obj = cls(sequence["name"], version = sequence["version"])
for event_data in sequence["events"]:
obj.events.append(cls.Event.load_event(event_data, pulse_parameter_options))

View file

@ -156,6 +156,7 @@ class FloatSetting(NumericalSetting):
"""
if state:
self.value = text
self.settings_changed.emit()
@property
def value(self):
@ -164,6 +165,7 @@ class FloatSetting(NumericalSetting):
@value.setter
def value(self, value):
logger.debug(f"Setting {self.name} to {value}")
self._value = float(value)
self.settings_changed.emit()
@ -210,6 +212,7 @@ class IntSetting(NumericalSetting):
"""
if state:
self.value = text
self.settings_changed.emit()
@property
def value(self):
@ -218,6 +221,7 @@ class IntSetting(NumericalSetting):
@value.setter
def value(self, value):
logger.debug(f"Setting {self.name} to {value}")
value = int(float(value))
self._value = value
self.settings_changed.emit()