diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e0df9a..f6f4d34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Version 0.0.12 (27-05-2024) + +- Implemented loading and saving of settings and default settings (`6496ec6824da33fa41a38e26fdcac9a03ceb51fb`) + +- Added fitting of measurement data (`d43639c2f9796d5055dd01bd2f36bae43877bfe8`) + ## Version 0.0.11 (20-05-2024) - Measurements are now run in a separate worker thread to prevent the GUI from freezing (`27865aa6d44158e74c0e537be8407c12b4e3725b`) diff --git a/pyproject.toml b/pyproject.toml index 460df08..6194303 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ allow-direct-references = true [project] name = "nqrduck-spectrometer" -version = "0.0.11" +version = "0.0.12" authors = [ { name="jupfi", email="support@nqruck.cool" }, ] diff --git a/src/nqrduck_spectrometer/base_spectrometer_controller.py b/src/nqrduck_spectrometer/base_spectrometer_controller.py index a96e368..156cd97 100644 --- a/src/nqrduck_spectrometer/base_spectrometer_controller.py +++ b/src/nqrduck_spectrometer/base_spectrometer_controller.py @@ -1,7 +1,10 @@ """Base class for all spectrometer controllers.""" +import logging +import ast from nqrduck.module.module_controller import ModuleController +logger = logging.getLogger(__name__) class BaseSpectrometerController(ModuleController): """The base class for all spectrometer controllers.""" @@ -10,6 +13,52 @@ class BaseSpectrometerController(ModuleController): """Initializes the spectrometer controller.""" super().__init__(module) + def on_loading(self): + """Called when the module is loading.""" + logger.debug("Loading spectrometer controller") + self.module.model.load_default_settings() + + def save_settings(self, path: str) -> None: + """Saves the settings of the spectrometer.""" + # We get the different settings objects from the model + settings = self.module.model.settings + + json = {} + json["name"] = self.module.model.name + + for category in settings.keys(): + for setting in settings[category]: + json[setting.name] = setting.value + + with open(path, "w") as f: + f.write(str(json)) + + def load_settings(self, path: str) -> None: + """Loads the settings of the spectrometer.""" + with open(path) as f: + json = f.read() + + # string to dict + json = ast.literal_eval(json) + + module_name = self.module.model.name + json_name = json["name"] + + # For some reason the notification is shown twice + if module_name != json_name: + message = f"Module: {module_name} not compatible with module specified in settings file: {json_name}. Did you select the correct settings file?" + self.module.nqrduck_signal.emit("notification", ["Error", message]) + return + + settings = self.module.model.settings + for category in settings.keys(): + for setting in settings[category]: + if setting.name in json: + setting.value = json[setting.name] + else: + message = f"Setting {setting.name} not found in settings file. A change in settings might have broken compatibility." + self.module.nqrduck_signal.emit("notification", ["Error", message]) + def start_measurement(self): """Starts the measurement. diff --git a/src/nqrduck_spectrometer/base_spectrometer_model.py b/src/nqrduck_spectrometer/base_spectrometer_model.py index 8e5e1dd..00c19a2 100644 --- a/src/nqrduck_spectrometer/base_spectrometer_model.py +++ b/src/nqrduck_spectrometer/base_spectrometer_model.py @@ -2,6 +2,7 @@ import logging from collections import OrderedDict +from PyQt6.QtCore import QSettings from PyQt6.QtGui import QPixmap from nqrduck.module.module_model import ModuleModel from .settings import Setting @@ -22,6 +23,8 @@ class BaseSpectrometerModel(ModuleModel): pulse_parameter_options (OrderedDict) : The pulse parameter options of the spectrometer """ + SETTING_FILE_EXTENSION = "setduck" + settings: OrderedDict pulse_parameter_options: OrderedDict @@ -99,6 +102,29 @@ class BaseSpectrometerModel(ModuleModel): super().__init__(module) self.settings = OrderedDict() self.pulse_parameter_options = OrderedDict() + self.default_settings = QSettings("nqrduck-spectrometer", "nqrduck") + + def set_default_settings(self) -> None: + """Sets the default settings of the spectrometer.""" + self.default_settings.clear() + for category in self.settings.keys(): + for setting in self.settings[category]: + setting_string = f"{self.module.model.name},{setting.name}" + self.default_settings.setValue(setting_string, setting.value) + logger.debug(f"Setting default value for {setting_string} to {setting.value}") + + def load_default_settings(self) -> None: + """Load the default settings of the spectrometer.""" + for category in self.settings.keys(): + for setting in self.settings[category]: + setting_string = f"{self.module.model.name},{setting.name}" + if self.default_settings.contains(setting_string): + logger.debug(f"Loading default value for {setting_string}") + setting.value = self.default_settings.value(setting_string) + + def clear_default_settings(self) -> None: + """Clear the default settings of the spectrometer.""" + self.default_settings.clear() def add_setting(self, setting: Setting, category: str) -> None: """Adds a setting to the spectrometer. diff --git a/src/nqrduck_spectrometer/base_spectrometer_view.py b/src/nqrduck_spectrometer/base_spectrometer_view.py index 1b53600..0480a7e 100644 --- a/src/nqrduck_spectrometer/base_spectrometer_view.py +++ b/src/nqrduck_spectrometer/base_spectrometer_view.py @@ -8,6 +8,8 @@ from PyQt6.QtWidgets import ( QSizePolicy, QSpacerItem, QVBoxLayout, + QPushButton, + QDialog, ) from nqrduck.module.module_view import ModuleView from nqrduck.assets.icons import Logos @@ -82,7 +84,7 @@ class BaseSpectrometerView(ModuleView): layout.addWidget(edit_widget) layout.addStretch(1) layout.addWidget(icon_label) - + # Add the layout to the vertical layout of the widget category_layout.addLayout(layout) @@ -91,3 +93,108 @@ class BaseSpectrometerView(ModuleView): # Push all the settings to the top of the widget self._ui_form.verticalLayout.addStretch(1) + + # Now we add a save and load button to the widget + self.button_layout = QHBoxLayout() + + # Default Settings Button + self.default_button = QPushButton("Default Settings") + self.default_button.clicked.connect(self.on_default_button_clicked) + self.button_layout.addWidget(self.default_button) + + # Save Button + self.save_button = QPushButton("Save Settings") + self.save_button.setIcon(Logos.Save16x16()) + self.save_button.setIconSize(Logos.Save16x16().availableSizes()[0]) + self.save_button.clicked.connect(self.on_save_button_clicked) + self.button_layout.addWidget(self.save_button) + + # Load Button + self.load_button = QPushButton("Load Settings") + self.load_button.setIcon(Logos.Load16x16()) + self.load_button.clicked.connect(self.on_load_button_clicked) + self.button_layout.addWidget(self.load_button) + self.load_button.setIconSize(Logos.Load16x16().availableSizes()[0]) + + self.button_layout.addStretch(1) + + self._ui_form.verticalLayout.addLayout(self.button_layout) + + def on_save_button_clicked(self): + """This method is called when the save button is clicked.""" + logger.debug("Save button clicked") + # Open a dialog to save the settings to a file + file_manager = self.FileManager( + extension=self.module.model.SETTING_FILE_EXTENSION, parent=self + ) + path = file_manager.saveFileDialog() + if path: + self.module.controller.save_settings(path) + + def on_load_button_clicked(self): + """This method is called when the load button is clicked.""" + logger.debug("Load button clicked") + # Open a dialog to load the settings from a file + file_manager = self.FileManager( + extension=self.module.model.SETTING_FILE_EXTENSION, parent=self + ) + path = file_manager.loadFileDialog() + self.module.controller.load_settings(path) + if path: + self.module.controller.load_settings(path) + + def on_default_button_clicked(self): + """This method is called when the default button is clicked.""" + logger.debug("Default button clicked") + dialog = self.DefaultSettingsDialog(self) + dialog.exec() + + class DefaultSettingsDialog(QDialog): + """Dialog to set or clear the default settings of the spectrometer.""" + def __init__(self, parent=None): + """Initializes the default settings dialog.""" + super().__init__(parent) + self.parent = parent + self.setWindowTitle("Default Settings") + + self.layout = QVBoxLayout() + + # Either we set the current settings as default + self.set_current_button = QPushButton("Set Current Settings as Default") + self.set_current_button.clicked.connect( + self.on_set_current_button_clicked + ) + + # Or we clear the default settings + self.clear_button = QPushButton("Clear Default Settings") + self.clear_button.clicked.connect( + self.on_clear_button_clicked + ) + + self.layout.addWidget(self.set_current_button) + self.layout.addWidget(self.clear_button) + + self.setLayout(self.layout) + + # Ok Button + self.ok_button = QPushButton("Ok") + self.ok_button.clicked.connect(self.accept) + self.layout.addWidget(self.ok_button) + + def on_set_current_button_clicked(self): + """This method is called when the set current button is clicked.""" + logger.debug("Set current button clicked") + self.parent.module.model.set_default_settings() + # Show notification that the settings have been set as default + self.parent.module.nqrduck_signal.emit( + "notification", ["Info", "Settings have been set as default."] + ) + + def on_clear_button_clicked(self): + """This method is called when the clear button is clicked.""" + logger.debug("Clear button clicked") + self.parent.module.model.clear_default_settings() + # Show notification that the default settings have been cleared + self.parent.module.nqrduck_signal.emit( + "notification", ["Info", "Default settings have been cleared."] + ) diff --git a/src/nqrduck_spectrometer/controller.py b/src/nqrduck_spectrometer/controller.py index 717de66..93a27c3 100644 --- a/src/nqrduck_spectrometer/controller.py +++ b/src/nqrduck_spectrometer/controller.py @@ -43,6 +43,8 @@ class SpectrometerController(ModuleController): logger.debug("Adding spectrometer to spectrometer model: %s", module_name) self._module.model.add_spectrometers(module_name, module) + module.controller.on_loading() + self._module.view.create_menu_entry() def process_signals(self, key: str, value: object) -> None: diff --git a/src/nqrduck_spectrometer/measurement.py b/src/nqrduck_spectrometer/measurement.py index a0d59dc..61d04d1 100644 --- a/src/nqrduck_spectrometer/measurement.py +++ b/src/nqrduck_spectrometer/measurement.py @@ -1,7 +1,8 @@ -"""Class for handling measurement data.""" +"""This module defines the measurement data structure and the fit class for measurement data.""" import logging import numpy as np +from scipy.optimize import curve_fit from nqrduck.helpers.signalprocessing import SignalProcessing as sp from nqrduck.helpers.functions import Function @@ -28,8 +29,8 @@ class Measurement: target_frequency (float): Target frequency of the measurement. frequency_shift (float): Frequency shift of the measurement. IF_frequency (float): Intermediate frequency of the measurement. - xf (np.array): Frequency axis for the x axis of the measurement data. - yf (np.array): Frequency axis for the y axis of the measurement data. + fdx (np.array): Frequency axis for the x axis of the measurement data. + fdy (np.array): Frequency axis for the y axis of the measurement data. """ def __init__( @@ -46,69 +47,89 @@ class Measurement: self.tdx = tdx self.tdy = tdy self.target_frequency = target_frequency - self.fdx, self.fdy = sp.fft(tdx, tdy, frequency_shift) + self.frequency_shift = frequency_shift self.IF_frequency = IF_frequency + self.fdx, self.fdy = sp.fft(tdx, tdy, frequency_shift) + self.fits = [] - def apodization(self, function: Function): + def apodization(self, function: Function) -> "Measurement": """Applies apodization to the measurement data. Args: function (Function): Apodization function. - returns: - Measurement : The apodized measurement. + 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 + tdy_apodized = self.tdy * y_weight apodized_measurement = Measurement( self.name, self.tdx, - tdy_measurement, + tdy_apodized, target_frequency=self.target_frequency, IF_frequency=self.IF_frequency, ) - return apodized_measurement - # Data saving and loading + def add_fit(self, fit: "Fit") -> None: + """Adds a fit to the measurement. - def to_json(self): - """Converts the measurement to a json-compatible format. + Args: + fit (Fit): The fit to add. + """ + self.fits.append(fit) + + def delete_fit(self, fit: "Fit") -> None: + """Deletes a fit from the measurement. + + Args: + fit (Fit): The fit to delete. + """ + self.fits.remove(fit) + + def edit_fit_name(self, fit: "Fit", name: str) -> None: + """Edits the name of a fit. + + Args: + fit (Fit): The fit to edit. + name (str): The new name. + """ + logger.debug(f"Editing fit name to {name}.") + fit.name = name + + def to_json(self) -> dict: + """Converts the measurement to a JSON-compatible format. Returns: - dict : The measurement in json-compatible format. + dict: The measurement in JSON-compatible format. """ return { "name": self.name, "tdx": self.tdx.tolist(), - "tdy": [ - [x.real, x.imag] for x in self.tdy - ], # Convert complex numbers to list + "tdy": [[x.real, x.imag] for x in self.tdy], "target_frequency": self.target_frequency, "IF_frequency": self.IF_frequency, + "fits": [fit.to_json() for fit in self.fits], } @classmethod - def from_json(cls, json: dict): - """Converts the json format to a measurement. + def from_json(cls, json: dict) -> "Measurement": + """Converts the JSON format to a measurement. Args: - json (dict) : The measurement in json-compatible format. + json (dict): The measurement in JSON-compatible format. Returns: - Measurement : The measurement. + Measurement: The measurement. """ tdy = np.array([complex(y[0], y[1]) for y in json["tdy"]]) - return cls( + measurement = cls( json["name"], np.array(json["tdx"]), tdy, @@ -116,58 +137,227 @@ class Measurement: IF_frequency=json["IF_frequency"], ) - # Measurement data + for fit_json in json["fits"]: + measurement.add_fit(Fit.from_json(fit_json, measurement)) + + return measurement + + # Properties for encapsulation @property - def name(self): + def name(self) -> str: """Name of the measurement.""" return self._name - + @name.setter - def name(self, value): + def name(self, value: str) -> None: self._name = value @property - def tdx(self): - """Time axis for the x axis of the measurement data.""" + def tdx(self) -> np.array: + """Time domain data for the measurement (x).""" return self._tdx @tdx.setter - def tdx(self, value): + def tdx(self, value: np.array) -> None: self._tdx = value @property - def tdy(self): - """Time axis for the y axis of the measurement data.""" + def tdy(self) -> np.array: + """Time domain data for the measurement (y).""" return self._tdy @tdy.setter - def tdy(self, value): + def tdy(self, value: np.array) -> None: self._tdy = value @property - def fdx(self): - """Frequency axis for the x axis of the measurement data.""" + def fdx(self) -> np.array: + """Frequency domain data for the measurement (x).""" return self._fdx @fdx.setter - def fdx(self, value): + def fdx(self, value: np.array) -> None: self._fdx = value @property - def fdy(self): - """Frequency axis for the y axis of the measurement data.""" + def fdy(self) -> np.array: + """Frequency domain data for the measurement (y).""" return self._fdy @fdy.setter - def fdy(self, value): + def fdy(self, value: np.array) -> None: self._fdy = value - # Pulse parameters @property - def target_frequency(self): + def target_frequency(self) -> float: """Target frequency of the measurement.""" return self._target_frequency @target_frequency.setter - def target_frequency(self, value): + def target_frequency(self, value: float) -> None: self._target_frequency = value + + @property + def fits(self) -> list: + """Fits of the measurement.""" + return self._fits + + @fits.setter + def fits(self, value: list) -> None: + self._fits = value + + +class Fit: + """The fit class for measurement data. A fit can be performed on either the frequency or time domain data. + + A measurement can have multiple fits. + """ + + subclasses = [] + + def __init_subclass__(cls, **kwargs) -> None: + """Adds the subclass to the list of subclasses.""" + super().__init_subclass__(**kwargs) + cls.subclasses.append(cls) + + def __init__(self, name: str, domain: str, measurement: Measurement) -> None: + """Initializes the fit.""" + self.name = name + self.domain = domain + self.measurement = measurement + self.fit() + + def fit(self) -> None: + """Fits the measurement data and sets the fit parameters and covariance.""" + if self.domain == "time": + x = self.measurement.tdx + y = self.measurement.tdy + elif self.domain == "frequency": + x = self.measurement.fdx + y = self.measurement.fdy + else: + raise ValueError("Domain not recognized.") + + initial_guess = self.initial_guess() + self.parameters, self.covariance = curve_fit( + self.fit_function, x, abs(y), p0=initial_guess + ) + + self.x = x + self.y = self.fit_function(x, *self.parameters) + + def fit_function(self, x: np.array, *parameters) -> np.array: + """The fit function. + + Args: + x (np.array): The x data. + *parameters: The fit parameters. + + Returns: + np.array: The y data. + """ + raise NotImplementedError + + def initial_guess(self) -> list: + """Initial guess for the fit. + + Returns: + list: The initial guess. + """ + raise NotImplementedError + + def to_json(self) -> dict: + """Converts the fit to a JSON-compatible format. + + Returns: + dict: The fit in JSON-compatible format. + """ + return { + "name": self.name, + "class": self.__class__.__name__, + } + + @classmethod + def from_json(cls, data: dict, measurement: Measurement) -> "Fit": + """Converts the JSON format to a fit. + + Args: + data (dict): The fit in JSON-compatible format. + measurement (Measurement): The measurement. + + Returns: + Fit: The fit. + """ + for subclass in cls.subclasses: + if subclass.__name__ == data["class"]: + return subclass(name=data["name"], measurement=measurement) + + raise ValueError(f"Subclass {data['class']} not found.") + + @property + def x(self) -> np.array: + """The x data of the fit.""" + return self._x + + @x.setter + def x(self, value: np.array) -> None: + self._x = value + + @property + def y(self) -> np.array: + """The y data of the fit.""" + return self._y + + @y.setter + def y(self, value: np.array) -> None: + self._y = value + + +class T2StarFit(Fit): + """T2* fit for measurement data.""" + + def __init__(self, measurement: Measurement, name: str = "T2*") -> None: + """Initializes the T2* fit.""" + super().__init__(name, "time", measurement) + + def fit(self) -> None: + """Fits the measurement data and sets the fit parameters and covariance.""" + super().fit() + self.parameters = { + "S0": self.parameters[0], + "T2Star": self.parameters[1], + "covariance": self.covariance, + } + + def fit_function(self, t: np.array, S0: float, T2Star: float) -> np.array: + """The T2* fit function used for curve fitting.""" + return S0 * np.exp(-t / T2Star) + + def initial_guess(self) -> list: + """Initial guess for the T2* fit.""" + return [1, 1] + +class LorentzianFit(Fit): + """Lorentzian fit for measurement data.""" + + def __init__(self, measurement: Measurement, name: str = "Lorentzian") -> None: + """Initializes the Lorentzian fit.""" + super().__init__(name, "frequency", measurement) + + def fit(self) -> None: + """Fits the measurement data and sets the fit parameters and covariance.""" + super().fit() + self.parameters = { + "S0": self.parameters[0], + "T2Star": self.parameters[1], + "covariance": self.covariance, + } + logger.debug("Lorentzian fit parameters: %s", self.parameters) + + def fit_function(self, f: np.array, S0: float, T2Star: float) -> np.array: + """The Lorentzian fit function used for curve fitting.""" + return S0 / (1 + (2 * np.pi * f * T2Star) ** 2) + + def initial_guess(self) -> list: + """Initial guess for the Lorentzian fit.""" + return [1, 1] diff --git a/src/nqrduck_spectrometer/settings.py b/src/nqrduck_spectrometer/settings.py index 7187f1d..4f39529 100644 --- a/src/nqrduck_spectrometer/settings.py +++ b/src/nqrduck_spectrometer/settings.py @@ -36,6 +36,7 @@ class Setting(QObject): description (str): A description of the setting. default: The default value of the setting. """ + self.widget = None super().__init__() self.name = name self.description = description @@ -81,34 +82,41 @@ class Setting(QObject): lambda x=widget, s=self: s.on_value_changed(x.text()) ) return widget - + class NumericalSetting(Setting): """A setting that is a numerical value. It can additionally have a minimum and maximum value. """ - def __init__(self, name: str, description: str, default, min_value = None, max_value = None ) -> None: + + def __init__( + self, name: str, description: str, default, min_value=None, max_value=None + ) -> None: """Create a new numerical setting.""" - super().__init__(name, self.description_limit_info(description, min_value, max_value), default) + super().__init__( + name, + self.description_limit_info(description, min_value, max_value), + default, + ) def description_limit_info(self, description: str, min_value, max_value) -> str: """Updates the description with the limits of the setting if there are any. - + Args: description (str): The description of the setting. min_value: The minimum value of the setting. max_value: The maximum value of the setting. - + Returns: str: The description of the setting with the limits. """ if min_value is not None and max_value is not None: - description += (f"\n (min: {min_value}, max: {max_value})") + description += f"\n (min: {min_value}, max: {max_value})" elif min_value is not None: - description += (f"\n (min: {min_value})") + description += f"\n (min: {min_value})" elif max_value is not None: - description += (f"\n (max: {max_value})") - + description += f"\n (max: {max_value})" + return description @@ -121,7 +129,7 @@ class FloatSetting(NumericalSetting): description (str) : A description of the setting min_value : The minimum value of the setting max_value : The maximum value of the setting - spin_box : A tuple with two booleans that determine if a spin box is used if the second value is True, a slider will be created as well. + spin_box : A tuple with two booleans that determine if a spin box is used if the second value is True, a slider will be created as well. """ DEFAULT_LENGTH = 100 @@ -133,13 +141,19 @@ class FloatSetting(NumericalSetting): description: str, min_value: float = None, max_value: float = None, - spin_box: tuple = (False, False) + spin_box: tuple = (False, False), ) -> None: """Create a new float setting.""" + self.spin_box = spin_box super().__init__(name, description, default, min_value, max_value) if spin_box[0]: - self.widget = DuckSpinBox(min_value=min_value, max_value=max_value, slider=spin_box[1], double_box=True) + self.widget = DuckSpinBox( + min_value=min_value, + max_value=max_value, + slider=spin_box[1], + double_box=True, + ) self.widget.spin_box.setValue(default) else: self.widget = DuckFloatEdit(min_value=min_value, max_value=max_value) @@ -169,6 +183,12 @@ class FloatSetting(NumericalSetting): self._value = float(value) self.settings_changed.emit() + if self.widget: + if self.spin_box[0]: + self.widget.spin_box.setValue(self._value) + else: + self.widget.setText(str(self._value)) + class IntSetting(NumericalSetting): """A setting that is an Integer. @@ -189,13 +209,15 @@ class IntSetting(NumericalSetting): description: str, min_value=None, max_value=None, - spin_box: tuple = (False, False) + spin_box: tuple = (False, False), ) -> None: """Create a new int setting.""" + self.spin_box = spin_box super().__init__(name, description, default, min_value, max_value) - - if spin_box[0]: - self.widget = DuckSpinBox(min_value=min_value, max_value=max_value, slider=spin_box[1]) + if self.spin_box[0]: + self.widget = DuckSpinBox( + min_value=min_value, max_value=max_value, slider=spin_box[1] + ) self.widget.spin_box.setValue(default) else: self.widget = DuckIntEdit(min_value=min_value, max_value=max_value) @@ -225,7 +247,11 @@ class IntSetting(NumericalSetting): value = int(float(value)) self._value = value self.settings_changed.emit() - + if self.widget: + if self.spin_box[0]: + self.widget.spin_box.setValue(value) + else: + self.widget.setText(str(value)) class BooleanSetting(Setting): @@ -253,6 +279,8 @@ class BooleanSetting(Setting): def value(self, value): try: self._value = bool(value) + if self.widget: + self.widget.setChecked(self._value) self.settings_changed.emit() except ValueError: raise ValueError("Value must be a bool") @@ -307,6 +335,8 @@ class SelectionSetting(Setting): try: if value in self.options: self._value = value + if self.widget: + self.widget.setCurrentText(value) self.settings_changed.emit() else: raise ValueError("Value must be one of the options")