From 5a2c2790ccb0c726712b7f8cbe7c55dd28e73e8b Mon Sep 17 00:00:00 2001 From: jupfi Date: Wed, 22 May 2024 17:51:41 +0200 Subject: [PATCH 1/6] Implemented basic fitting. --- README.md | 12 +++- src/nqrduck_measurement/controller.py | 34 ++++++++++- .../signalprocessing_options.py | 58 ++++++++++++++++++- src/nqrduck_measurement/view.py | 45 +++++++++++++- 4 files changed, 142 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index c7b5a32..2433bf1 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,15 @@ A module for the [nqrduck](https://github.com/nqrduck/nqrduck) project. This mod ## Installation ### Requirements + Dependencies are handled via the pyproject.toml file. ### Setup + To install the module you need the NQRduck core. You can find the installation instructions for the NQRduck core [here](https://github.com/nqrduck/nqrduck). Ideally you should install the module in a virtual environment. You can create a virtual environment by running the following command in the terminal: + ```bash python -m venv nqrduck # Activate the virtual environment @@ -18,22 +21,25 @@ python -m venv nqrduck ``` You can install this module and the dependencies by running the following command in the terminal while the virtual environment is activated and you are in the root directory of this module: + ```bash pip install . ``` Alternatively, you can install the module and the dependencies by running the following command in the terminal while the virtual environment is activated: + ```bash pip install nqrduck-measurement ``` ## Usage + The module is used with the [Spectrometer](https://github.com/nqrduck/nqrduck-spectrometer) module. However you need to use an actual submodule of the spectrometer module like: - [nqrduck-spectrometer-limenqr](https://github.com/nqrduck/nqrduck-spectrometer-limenqr) A module used for magnetic resonance experiments with the LimeSDR (USB or Mini 2.0). - [nqrduck-spectrometer-simulator](https://github.com/nqrduck/nqrduck-spectrometer-simulator) A module used for simulating magnetic resonance experiments. -The pulse sequence and spectrometer settings can be adjusted using the 'Spectrometer' tab. +The pulse sequence and spectrometer settings can be adjusted using the 'Spectrometer' tab. drawing @@ -42,8 +48,12 @@ The pulse sequence and spectrometer settings can be adjusted using the 'Spectrom - c.) The 'Measurement Plot'. Here the measured data is displayed. One can switch time and frequency domain plots. - d.) The import and export buttons for the measurement data. +You can then remove the folder of the virtual environment. + ## License + This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details ## Contributing + If you're interested in contributing to the project, start by checking out our [nqrduck-module template](https://github.com/nqrduck/nqrduck-module). To contribute to existing modules, please first open an issue in the respective module repository to discuss your ideas or report bugs. diff --git a/src/nqrduck_measurement/controller.py b/src/nqrduck_measurement/controller.py index e4f3c12..7002b87 100644 --- a/src/nqrduck_measurement/controller.py +++ b/src/nqrduck_measurement/controller.py @@ -4,7 +4,7 @@ import json from PyQt6.QtCore import pyqtSlot, pyqtSignal from PyQt6.QtWidgets import QApplication -from .signalprocessing_options import Apodization +from .signalprocessing_options import Apodization, Fitting from nqrduck.module.module_controller import ModuleController from nqrduck_spectrometer.measurement import Measurement @@ -231,6 +231,38 @@ def show_apodization_dialog(self) -> None: self.module.model.displayed_measurement = apodized_measurement self.module.model.add_measurement(apodized_measurement) + + def show_fitting_dialog(self) -> None: + """Show fitting dialog.""" + logger.debug("Showing fitting dialog.") + # First we check if there is a measurement. + if not self.module.model.displayed_measurement: + logger.debug("No measurement to fit.") + self.module.nqrduck_signal.emit( + "notification", ["Error", "No measurement to fit."] + ) + return + + measurement = self.module.model.displayed_measurement + + dialog = Fitting(measurement, parent=self.module.view) + result = dialog.exec() + + logger.debug("Dialog result: %s", result) + if not result: + return + + fit = dialog.get_fit()[1] + + logger.debug("Fitting function: %s", fit) + + params = fit.fit() + + measurement.add_fit(fit) + + self.module.view.update_displayed_measurement() + + dialog.deleteLater() @pyqtSlot() def change_displayed_measurement(self, measurement=None) -> None: diff --git a/src/nqrduck_measurement/signalprocessing_options.py b/src/nqrduck_measurement/signalprocessing_options.py index eac33fd..91d8f20 100644 --- a/src/nqrduck_measurement/signalprocessing_options.py +++ b/src/nqrduck_measurement/signalprocessing_options.py @@ -2,9 +2,14 @@ import logging import sympy -from nqrduck_spectrometer.measurement import Measurement +from nqrduck_spectrometer.measurement import Measurement, Fit, T2StarFit from nqrduck.helpers.functions import Function, GaussianFunction, CustomFunction -from nqrduck.helpers.formbuilder import DuckFormBuilder, DuckFormFunctionSelectionField +from nqrduck.helpers.formbuilder import ( + DuckFormBuilder, + DuckFormFunctionSelectionField, + DuckFormDropdownField, + DuckLabelField, +) logger = logging.getLogger(__name__) @@ -24,6 +29,21 @@ def __init__(self) -> None: self.add_parameter(Function.Parameter("T2star (microseconds)", "T2star", 10)) +class LorentzianFunction(Function): + """The Lorentzian function.""" + + name = "Lorentzian" + + def __init__(self) -> None: + """Lorentzian function.""" + expr = sympy.sympify("1 / (1 + (x / T2star)^2)") + super().__init__(expr) + self.start_x = 0 + self.end_x = 30 + + self.add_parameter(Function.Parameter("T2star (microseconds)", "T2star", 10)) + + class Apodization(DuckFormBuilder): """Apodization parameter. @@ -62,3 +82,37 @@ def get_function(self) -> Function: Function: The selected function. """ return self.get_values()[0] + + +class Fitting(DuckFormBuilder): + """Fitting parameter. + + This parameter is used to apply fitting functions to the signal. + The fitting functions are used to reduce the noise in the signal. + """ + + def __init__(self, measurement: Measurement, parent=None) -> None: + """Fitting parameter.""" + super().__init__("Fitting", parent=parent) + + self.measurement = measurement + + fits = {} + fits["T2*"] = T2StarFit(self.measurement) + + selection_field = DuckFormDropdownField( + text=None, + tooltip=None, + options=fits, + default_option=0, + ) + + self.add_field(selection_field) + + def get_fit(self) -> Fit: + """Get the selected fit. + + Returns: + Fit: The selected fit. + """ + return self.get_values()[0] diff --git a/src/nqrduck_measurement/view.py b/src/nqrduck_measurement/view.py index af344d9..b29e06b 100644 --- a/src/nqrduck_measurement/view.py +++ b/src/nqrduck_measurement/view.py @@ -89,6 +89,10 @@ def __init__(self, module): self.module.controller.show_apodization_dialog ) + self._ui_form.fittingButton.clicked.connect( + self.module.controller.show_fitting_dialog + ) + # Add logos self._ui_form.buttonStart.setIcon(Logos.Play_16x16()) self._ui_form.buttonStart.setIconSize(self._ui_form.buttonStart.size()) @@ -120,7 +124,7 @@ def __init__(self, module): self._ui_form.averagesEdit.set_min_value(1) self._ui_form.averagesEdit.set_max_value(1e6) - # Connect selectionBox signal fors switching the displayed measurement + # Connect selectionBox signal for switching the displayed measurement self._ui_form.selectionBox.valueChanged.connect( self.module.controller.change_displayed_measurement ) @@ -207,6 +211,9 @@ def update_displayed_measurement(self) -> None: x, np.abs(y), label="Magnitude", color="blue" ) + # Plot fits + self.plot_fits() + # Add legend self._ui_form.plotter.canvas.ax.legend() @@ -230,10 +237,42 @@ def update_displayed_measurement(self) -> None: ) break - except AttributeError: - logger.debug("No measurement data to display.") + except AttributeError as e: + logger.debug(f"No measurement data to display: {e}") + self._ui_form.plotter.canvas.draw() + def plot_fits(self): + """Plots the according fits to the displayed measurement if there are any and if the view mode is correct.""" + measurement = self.module.model.displayed_measurement + + if not measurement.fits: + logger.debug("No fits to plot.") + return + + for fit in measurement.fits: + logger.debug(f"Plotting fit {fit.name}.") + if fit.domain == self.module.model.view_mode: + x = fit.x + y = fit.y + self._ui_form.plotter.canvas.ax.plot(x, y, label=f"{fit.name} Fit", color="black", linestyle="--") + + # Add the parameters to the plot + offset = 0 + for name, value in fit.parameters.items(): + if name == "covariance": + continue + + # Only two digits after the comma + value = round(value, 2) + + self._ui_form.plotter.canvas.ax.text( + max(x) / 90, + max(y)/2 + offset, + f"{name}: {value}", + ) + offset += max(y)/10 + @pyqtSlot() def on_measurement_start_button_clicked(self) -> None: """Slot for when the measurement start button is clicked.""" From b14391e6a782894b358f5f6ab699f7e676009c28 Mon Sep 17 00:00:00 2001 From: jupfi Date: Thu, 23 May 2024 15:55:45 +0200 Subject: [PATCH 2/6] Implemented editing of fits. --- src/nqrduck_measurement/view.py | 105 ++++++++++++++++++++++++++------ 1 file changed, 85 insertions(+), 20 deletions(-) diff --git a/src/nqrduck_measurement/view.py b/src/nqrduck_measurement/view.py index b29e06b..72c31a3 100644 --- a/src/nqrduck_measurement/view.py +++ b/src/nqrduck_measurement/view.py @@ -249,13 +249,15 @@ def plot_fits(self): if not measurement.fits: logger.debug("No fits to plot.") return - + for fit in measurement.fits: logger.debug(f"Plotting fit {fit.name}.") if fit.domain == self.module.model.view_mode: x = fit.x y = fit.y - self._ui_form.plotter.canvas.ax.plot(x, y, label=f"{fit.name} Fit", color="black", linestyle="--") + self._ui_form.plotter.canvas.ax.plot( + x, y, label=f"{fit.name} Fit", color="black", linestyle="--" + ) # Add the parameters to the plot offset = 0 @@ -268,10 +270,10 @@ def plot_fits(self): self._ui_form.plotter.canvas.ax.text( max(x) / 90, - max(y)/2 + offset, + max(y) / 2 + offset, f"{name}: {value}", ) - offset += max(y)/10 + offset += max(y) / 10 @pyqtSlot() def on_measurement_start_button_clicked(self) -> None: @@ -426,7 +428,9 @@ def __init__(self, parent=None): self.setModal(True) self.setWindowFlag(Qt.WindowType.FramelessWindowHint) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) - self.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint) # Ensure the window stays on top + self.setWindowFlag( + Qt.WindowType.WindowStaysOnTopHint + ) # Ensure the window stays on top self.message_label = QLabel("Measuring...") # Make label bold and text larger @@ -437,7 +441,7 @@ def __init__(self, parent=None): self.spinner_movie = DuckAnimations.DuckKick128x128() self.spinner_label = QLabel(self) - # Make spinner label + # Make spinner label self.spinner_label.setMovie(self.spinner_movie) self.layout = QVBoxLayout(self) @@ -464,9 +468,7 @@ def hide(self) -> None: class MeasurementEdit(QDialog): """This dialog is displayed when the measurement edit button is clicked. - - It allows the user to edit the measurement parameters (e.g. name, ...) - """ + It allows the user to edit the measurement parameters (e.g. name, ...).""" def __init__(self, measurement, parent=None) -> None: """Initialize the dialog.""" @@ -474,43 +476,105 @@ def __init__(self, measurement, parent=None) -> None: self.setParent(parent) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - logger.debug("Edit measurement dialog started.") self.measurement = measurement - self.setWindowTitle("Edit Measurement") + self.layout = QVBoxLayout(self) self.setLayout(self.layout) + self.setup_name_section() + self.setup_fit_section() + self.setup_buttons() + + # Resize the dialog + self.adjustSize() + + def setup_name_section(self): + """Sets up the name layout section.""" self.name_layout = QHBoxLayout() self.name_label = QLabel("Name:") - self.name_edit = QLineEdit(measurement.name) + + self.name_edit = QLineEdit(self.measurement.name) font_metrics = self.name_edit.fontMetrics() - self.name_edit.setFixedWidth( - font_metrics.horizontalAdvance(self.name_edit.text()) + 10 - ) + self.name_edit.setFixedWidth(font_metrics.horizontalAdvance( + self.name_edit.text()) + 10) self.name_edit.adjustSize() self.name_layout.addWidget(self.name_label) self.name_layout.addWidget(self.name_edit) + self.layout.addLayout(self.name_layout) + def setup_fit_section(self): + """Sets up the fit layout section.""" + self.fit_layout = QVBoxLayout() + self.update_fit_info() + self.layout.addLayout(self.fit_layout) + + def setup_buttons(self): + """Sets up the OK and Cancel buttons.""" self.ok_button = QPushButton("OK") self.ok_button.clicked.connect(self.on_ok_button_clicked) self.cancel_button = QPushButton("Cancel") self.cancel_button.clicked.connect(self.close) - self.layout.addLayout(self.name_layout) - button_layout = QHBoxLayout() button_layout.addWidget(self.cancel_button) button_layout.addWidget(self.ok_button) - self.layout.addLayout(button_layout) - # Resize the dialog - self.adjustSize() + def update_fit_info(self) -> None: + """Adds the associated fits to the dialog.""" + # Clear the layout from previous fits + while self.fit_layout.count(): + item = self.fit_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + elif item.layout(): + self.clearLayout(item.layout()) + + if not self.measurement.fits: + logger.debug("No fits to display.") + return + + # Adds the fit information + fit_label = QLabel("Fits:") + self.fit_layout.addWidget(fit_label) + + for fit in self.measurement.fits: + specific_fit_layout = QHBoxLayout() + specific_fit_layout.addStretch() + logger.debug(f"Fit: {fit.name}") + + fit_name_edit = QLineEdit(fit.name) + fit_name_edit.textChanged.connect( + lambda text, fit=fit: self.measurement.edit_fit_name(fit, text) + ) + + fit_delete_button = QPushButton() + fit_delete_button.setIcon(Logos.Garbage12x12()) + fit_delete_button.clicked.connect(partial(self.on_delete_fit, fit)) + + specific_fit_layout.addWidget(fit_name_edit) + specific_fit_layout.addWidget(fit_delete_button) + self.fit_layout.addLayout(specific_fit_layout) + + def clearLayout(self, layout): + """Clears all items in the given layout.""" + while layout.count(): + item = layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + elif item.layout(): + self.clearLayout(item.layout()) + + def on_delete_fit(self, fit) -> None: + """Slot for when the delete fit button is clicked.""" + logger.debug(f"Delete fit {fit.name}.") + self.measurement.delete_fit(fit) + self.update_fit_info() # Update the dialog with the changes def on_ok_button_clicked(self) -> None: """Slot for when the OK button is clicked.""" @@ -518,3 +582,4 @@ def on_ok_button_clicked(self) -> None: self.measurement.name = self.name_edit.text() self.accept() self.close() + From a1cd03d91d818bb4b56569baab5032e33d854553 Mon Sep 17 00:00:00 2001 From: jupfi Date: Thu, 23 May 2024 16:40:22 +0200 Subject: [PATCH 3/6] Linting, minor improvements in visualization. --- src/nqrduck_measurement/controller.py | 2 -- src/nqrduck_measurement/signalprocessing_options.py | 1 - src/nqrduck_measurement/view.py | 10 ++++++---- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/nqrduck_measurement/controller.py b/src/nqrduck_measurement/controller.py index 7002b87..8c0e49c 100644 --- a/src/nqrduck_measurement/controller.py +++ b/src/nqrduck_measurement/controller.py @@ -256,8 +256,6 @@ def show_fitting_dialog(self) -> None: logger.debug("Fitting function: %s", fit) - params = fit.fit() - measurement.add_fit(fit) self.module.view.update_displayed_measurement() diff --git a/src/nqrduck_measurement/signalprocessing_options.py b/src/nqrduck_measurement/signalprocessing_options.py index 91d8f20..694c9cf 100644 --- a/src/nqrduck_measurement/signalprocessing_options.py +++ b/src/nqrduck_measurement/signalprocessing_options.py @@ -8,7 +8,6 @@ DuckFormBuilder, DuckFormFunctionSelectionField, DuckFormDropdownField, - DuckLabelField, ) logger = logging.getLogger(__name__) diff --git a/src/nqrduck_measurement/view.py b/src/nqrduck_measurement/view.py index 72c31a3..237b3ab 100644 --- a/src/nqrduck_measurement/view.py +++ b/src/nqrduck_measurement/view.py @@ -256,7 +256,7 @@ def plot_fits(self): x = fit.x y = fit.y self._ui_form.plotter.canvas.ax.plot( - x, y, label=f"{fit.name} Fit", color="black", linestyle="--" + x, y, label=f"{fit.name} Fit", linestyle="--" ) # Add the parameters to the plot @@ -299,7 +299,7 @@ def on_measurement_save_button_clicked(self) -> None: logger.debug("Measurement save button clicked.") file_manager = self.FileManager( - self.module.model.FILE_EXTENSION, parent=self.widget + self.module.model.FILE_EXTENSION, parent=self ) file_name = file_manager.saveFileDialog() if file_name: @@ -311,7 +311,7 @@ def on_measurement_load_button_clicked(self) -> None: logger.debug("Measurement load button clicked.") file_manager = self.FileManager( - self.module.model.FILE_EXTENSION, parent=self.widget + self.module.model.FILE_EXTENSION, parent=self ) file_name = file_manager.loadFileDialog() if file_name: @@ -468,7 +468,9 @@ def hide(self) -> None: class MeasurementEdit(QDialog): """This dialog is displayed when the measurement edit button is clicked. - It allows the user to edit the measurement parameters (e.g. name, ...).""" + + It allows the user to edit the measurement parameters (e.g. name, ...). + """ def __init__(self, measurement, parent=None) -> None: """Initialize the dialog.""" From df9461019c342cec197d2dd25862dd914c541cf2 Mon Sep 17 00:00:00 2001 From: jupfi Date: Thu, 23 May 2024 16:46:34 +0200 Subject: [PATCH 4/6] Corrected FunctionSelectionWidget view mode for apodization. --- src/nqrduck_measurement/signalprocessing_options.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/nqrduck_measurement/signalprocessing_options.py b/src/nqrduck_measurement/signalprocessing_options.py index 694c9cf..8f7dad4 100644 --- a/src/nqrduck_measurement/signalprocessing_options.py +++ b/src/nqrduck_measurement/signalprocessing_options.py @@ -70,6 +70,8 @@ def __init__(self, measurement: Measurement, parent=None) -> None: duration=self.duration, parent=parent, default_function=0, + view_mode="time", + mode_selection=0, ) self.add_field(function_selection_field) From dca1c6816f0697ca3c6827fd07a0236a3189b922 Mon Sep 17 00:00:00 2001 From: jupfi Date: Thu, 23 May 2024 17:04:58 +0200 Subject: [PATCH 5/6] Added fitting of frequency domain data. --- src/nqrduck_measurement/model.py | 2 +- .../signalprocessing_options.py | 19 ++----------------- src/nqrduck_measurement/view.py | 8 +++++++- 3 files changed, 10 insertions(+), 19 deletions(-) diff --git a/src/nqrduck_measurement/model.py b/src/nqrduck_measurement/model.py index 871a99b..9ba0e56 100644 --- a/src/nqrduck_measurement/model.py +++ b/src/nqrduck_measurement/model.py @@ -45,7 +45,7 @@ class MeasurementModel(ModuleModel): FILE_EXTENSION = "meas" # This constants are used to determine which view is currently displayed. - FFT_VIEW = "fft" + FFT_VIEW = "frequency" TIME_VIEW = "time" displayed_measurement_changed = pyqtSignal(Measurement) diff --git a/src/nqrduck_measurement/signalprocessing_options.py b/src/nqrduck_measurement/signalprocessing_options.py index 8f7dad4..4768e5c 100644 --- a/src/nqrduck_measurement/signalprocessing_options.py +++ b/src/nqrduck_measurement/signalprocessing_options.py @@ -2,7 +2,7 @@ import logging import sympy -from nqrduck_spectrometer.measurement import Measurement, Fit, T2StarFit +from nqrduck_spectrometer.measurement import Measurement, Fit, T2StarFit, LorentzianFit from nqrduck.helpers.functions import Function, GaussianFunction, CustomFunction from nqrduck.helpers.formbuilder import ( DuckFormBuilder, @@ -27,22 +27,6 @@ def __init__(self) -> None: self.add_parameter(Function.Parameter("T2star (microseconds)", "T2star", 10)) - -class LorentzianFunction(Function): - """The Lorentzian function.""" - - name = "Lorentzian" - - def __init__(self) -> None: - """Lorentzian function.""" - expr = sympy.sympify("1 / (1 + (x / T2star)^2)") - super().__init__(expr) - self.start_x = 0 - self.end_x = 30 - - self.add_parameter(Function.Parameter("T2star (microseconds)", "T2star", 10)) - - class Apodization(DuckFormBuilder): """Apodization parameter. @@ -100,6 +84,7 @@ def __init__(self, measurement: Measurement, parent=None) -> None: fits = {} fits["T2*"] = T2StarFit(self.measurement) + fits["Lorentzian"] = LorentzianFit(self.measurement) selection_field = DuckFormDropdownField( text=None, diff --git a/src/nqrduck_measurement/view.py b/src/nqrduck_measurement/view.py index 237b3ab..4702019 100644 --- a/src/nqrduck_measurement/view.py +++ b/src/nqrduck_measurement/view.py @@ -251,10 +251,16 @@ def plot_fits(self): return for fit in measurement.fits: - logger.debug(f"Plotting fit {fit.name}.") if fit.domain == self.module.model.view_mode: + logger.debug(f"Plotting {fit.name} fit in domain {fit.domain}.") x = fit.x y = fit.y + # Shift the x values if the view mode is FFT + if fit.domain == self.module.model.FFT_VIEW: + x = x + float( + measurement.target_frequency - measurement.IF_frequency + ) * 1e-6 + self._ui_form.plotter.canvas.ax.plot( x, y, label=f"{fit.name} Fit", linestyle="--" ) From 9b1052bb74cd03f105e97e272f6d62b21b481d61 Mon Sep 17 00:00:00 2001 From: jupfi Date: Mon, 27 May 2024 19:46:55 +0200 Subject: [PATCH 6/6] Version bump --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b1300d..b09b75a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Version 0.0.6 (27-05-2024) + +- Added fitting functions to the measurement module (`dca1c6816f0697ca3c6827fd07a0236a3189b922`). + ## Version 0.0.5 (20-05-2024) - Fixed measurement dialog not showing in wayland (`f5705e4efcbaf1aa0efd558b1ec1dacf42a53944`) diff --git a/pyproject.toml b/pyproject.toml index b7d55ac..96b7035 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ allow-direct-references = true [project] name = "nqrduck-measurement" -version = "0.0.5" +version = "0.0.6" authors = [ { name="jupfi", email="support@nqrduck.cool" }, ]