pymagnetos 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. pymagnetos/__init__.py +15 -0
  2. pymagnetos/cli.py +40 -0
  3. pymagnetos/core/__init__.py +19 -0
  4. pymagnetos/core/_config.py +340 -0
  5. pymagnetos/core/_data.py +132 -0
  6. pymagnetos/core/_processor.py +905 -0
  7. pymagnetos/core/config_models.py +57 -0
  8. pymagnetos/core/gui/__init__.py +6 -0
  9. pymagnetos/core/gui/_base_mainwindow.py +819 -0
  10. pymagnetos/core/gui/widgets/__init__.py +19 -0
  11. pymagnetos/core/gui/widgets/_batch_processing.py +319 -0
  12. pymagnetos/core/gui/widgets/_configuration.py +167 -0
  13. pymagnetos/core/gui/widgets/_files.py +129 -0
  14. pymagnetos/core/gui/widgets/_graphs.py +93 -0
  15. pymagnetos/core/gui/widgets/_param_content.py +20 -0
  16. pymagnetos/core/gui/widgets/_popup_progressbar.py +29 -0
  17. pymagnetos/core/gui/widgets/_text_logger.py +32 -0
  18. pymagnetos/core/signal_processing.py +1004 -0
  19. pymagnetos/core/utils.py +85 -0
  20. pymagnetos/log.py +126 -0
  21. pymagnetos/py.typed +0 -0
  22. pymagnetos/pytdo/__init__.py +6 -0
  23. pymagnetos/pytdo/_config.py +24 -0
  24. pymagnetos/pytdo/_config_models.py +59 -0
  25. pymagnetos/pytdo/_tdoprocessor.py +1052 -0
  26. pymagnetos/pytdo/assets/config_default.toml +84 -0
  27. pymagnetos/pytdo/gui/__init__.py +26 -0
  28. pymagnetos/pytdo/gui/_worker.py +106 -0
  29. pymagnetos/pytdo/gui/main.py +617 -0
  30. pymagnetos/pytdo/gui/widgets/__init__.py +8 -0
  31. pymagnetos/pytdo/gui/widgets/_buttons.py +66 -0
  32. pymagnetos/pytdo/gui/widgets/_configuration.py +78 -0
  33. pymagnetos/pytdo/gui/widgets/_graphs.py +280 -0
  34. pymagnetos/pytdo/gui/widgets/_param_content.py +137 -0
  35. pymagnetos/pyuson/__init__.py +7 -0
  36. pymagnetos/pyuson/_config.py +26 -0
  37. pymagnetos/pyuson/_config_models.py +71 -0
  38. pymagnetos/pyuson/_echoprocessor.py +1901 -0
  39. pymagnetos/pyuson/assets/config_default.toml +92 -0
  40. pymagnetos/pyuson/gui/__init__.py +26 -0
  41. pymagnetos/pyuson/gui/_worker.py +135 -0
  42. pymagnetos/pyuson/gui/main.py +767 -0
  43. pymagnetos/pyuson/gui/widgets/__init__.py +7 -0
  44. pymagnetos/pyuson/gui/widgets/_buttons.py +95 -0
  45. pymagnetos/pyuson/gui/widgets/_configuration.py +85 -0
  46. pymagnetos/pyuson/gui/widgets/_graphs.py +248 -0
  47. pymagnetos/pyuson/gui/widgets/_param_content.py +193 -0
  48. pymagnetos-0.1.0.dist-info/METADATA +23 -0
  49. pymagnetos-0.1.0.dist-info/RECORD +51 -0
  50. pymagnetos-0.1.0.dist-info/WHEEL +4 -0
  51. pymagnetos-0.1.0.dist-info/entry_points.txt +7 -0
@@ -0,0 +1,767 @@
1
+ """
2
+ Application for ultra-sound echoes analysis.
3
+
4
+ This is the main window definition, built with the custom widgets found in the widgets
5
+ folder.
6
+ """
7
+
8
+ import importlib.metadata
9
+ from pathlib import Path
10
+
11
+ import pyqtgraph as pg
12
+ from PyQt6 import QtGui, QtWidgets
13
+ from PyQt6.QtCore import pyqtSignal, pyqtSlot
14
+
15
+ from pymagnetos import sp
16
+ from pymagnetos.core.gui import BaseMainWindow, widgets
17
+
18
+ from ._worker import ProcessorWorker
19
+ from .widgets import ButtonsWidget, ConfigurationWidget, GraphsWidget, ParamContent
20
+
21
+ ICON_PATH = str(Path(__file__).parent / "assets" / "icon.png")
22
+ REGEXP_EXPID_SEPARATORS = r"[_-]"
23
+ PROGRAM_NAME = "pymagnetos"
24
+ ALLOWED_FORMAT = ("toml", "json", "nx5", "nxs", "h5", "hdf5")
25
+ LOG_LEVEL = "INFO"
26
+ VERSION = importlib.metadata.version(PROGRAM_NAME)
27
+
28
+
29
+ class MainWindow(BaseMainWindow):
30
+ """
31
+ A graphical user interface application for ultra-sound echoes analysis.
32
+
33
+ The `MainWindow` class defines the main thread with the front-end user interface.
34
+
35
+ It is built with the custom components defined in the `widgets` module. Those
36
+ components are referenced in the main thread with a leading `w` :
37
+ `self.wconfiguration` : the "Configuration" tab
38
+ `self.wfiles` : the "Files" tab
39
+ `self.wbatch` : the "Batch processing" tab
40
+ `self.wgraphs` : the widget holding all the graphs
41
+ `self.wbuttons` : the widget holding the main buttons
42
+ `self.wlog` : the widget that streams the log output
43
+
44
+ Each of those components exposes signals, to which the main thread connects its own
45
+ methods, instead of connecting directly to the widgets (buttons, checkboxes and the
46
+ like). Those signals are named with a trailing `sig_` so that makes it easy to know
47
+ exactly what should be connected in the main thread.
48
+
49
+ When loading a configuration (or a NeXus) file, the main threads creates a worker
50
+ thread (`DataWorker`). The latter instantiates an `EchoProcessor` object that
51
+ perform the actual analysis.
52
+
53
+ pyqtSignals
54
+ -------
55
+ All signals emit in their corresponding methods and are connected to the
56
+ corresponding methods in the worker thread.
57
+
58
+ `sig_worker_load`
59
+ `sig_worker_rolling`
60
+ `sig_worker_find_f0`
61
+ `sig_worker_demodulate`
62
+ `sig_worker_average`
63
+ `sig_worker_batch`
64
+ `sig_worker_export_csv`
65
+ `sig_worker_save_nexus`
66
+ """
67
+
68
+ sig_worker_rolling = pyqtSignal()
69
+ sig_worker_find_f0 = pyqtSignal()
70
+ sig_worker_demodulate = pyqtSignal()
71
+ sig_worker_average = pyqtSignal()
72
+ sig_worker_batch = pyqtSignal(list, bool, bool, bool, bool)
73
+ sig_worker_export_csv = pyqtSignal(str, bool)
74
+
75
+ worker: ProcessorWorker
76
+
77
+ _window_icon = ICON_PATH
78
+
79
+ def __init__(self):
80
+ # Register the widgets to use
81
+ self._type_wbatch = widgets.BatchProcessingWidget
82
+ self._type_wbuttons = ButtonsWidget
83
+ self._type_wconfiguration = ConfigurationWidget
84
+ self._type_wfiles = widgets.FileBrowserWidget
85
+ self._type_wgraphs = GraphsWidget
86
+ self._param_content = ParamContent
87
+ self._type_worker = ProcessorWorker
88
+
89
+ super().__init__()
90
+
91
+ # Initialize window
92
+ self.setGeometry(300, 300, 900, 450)
93
+ self.setWindowTitle("EchoAnalyzer")
94
+ self.setWindowIcon(QtGui.QIcon(self._window_icon))
95
+
96
+ self.logger.info(f"Running pyuson v{VERSION}")
97
+
98
+ def init_parameter_tree(self):
99
+ """
100
+ Create the "Configuration" tab.
101
+
102
+ It holds the PyQtGraph parameter tree. Each parameter is synchronised with the
103
+ `Configuration` object of the `EchoProcess` worker.
104
+ """
105
+ super().init_parameter_tree()
106
+
107
+ # Connect
108
+ self.wconfiguration.sig_echo_index_changed.connect(
109
+ self.update_echo_index_from_settings
110
+ )
111
+
112
+ def init_demodulation(self):
113
+ """
114
+ Additionnal initializations for digital demodulation mode.
115
+
116
+ Add the Demodulation section in the parameter tree and the frame reference plot.
117
+
118
+ It happens only when a worker is created so we can connect its signals as well,
119
+ instead of in `connect_worker()` for the other buttons.
120
+ """
121
+ # Add the Demodulation section to ParameterTree
122
+ self.wconfiguration.init_demodulation_tree()
123
+
124
+ # Update the files filter suffix in the batch processing panel
125
+ self.wbatch.line_suffix.setText(".wfm")
126
+ self.wbatch.add_findf0_checkbox()
127
+
128
+ # Fill the demodulation parameters from the worker
129
+ for p in self.wconfiguration.demodulation_parameters:
130
+ self.set_param_from_worker(p.name(), context="demodulation")
131
+
132
+ # Add the frame reference signal plot tab
133
+ self.wgraphs.add_reference_in_frame_tab()
134
+
135
+ # Connect
136
+ self.wconfiguration.sig_find_f0.connect(self.find_f0)
137
+ self.sig_worker_find_f0.connect(self.worker.find_f0)
138
+ self.worker.sig_find_f0_finished.connect(self.find_f0_finished)
139
+ self.wconfiguration.sig_demodulate.connect(self.demodulate)
140
+ self.sig_worker_demodulate.connect(self.worker.demodulate)
141
+ self.worker.sig_demodulate_finished.connect(self.demodulate_finished)
142
+
143
+ def init_batch_processing(self):
144
+ """
145
+ Create the "Batch processing tab.
146
+
147
+ It's multi-panel file picker, where the user can queue files to be
148
+ batch-processed with the same settings.
149
+ """
150
+ super().init_batch_processing()
151
+
152
+ # Connect
153
+ self.wbatch.sig_echo_index_changed.connect(self.update_echo_index_from_batch)
154
+
155
+ def init_buttons(self):
156
+ """
157
+ Create and connect the main action buttons.
158
+
159
+ The created buttons and their functions are :
160
+ Load Data : load raw data
161
+ Show Frames : display raw data from frames specified in the settings
162
+ Rolling average : apply rolling average
163
+ Refresh : re-compute the average, attenuation and phase-shift
164
+ Export as CSV : export the results for the current echo index as a CSV file
165
+ Save as NeXus : export all data as a single NeXus (hdf5) file
166
+ """
167
+ super().init_buttons()
168
+
169
+ # Connect
170
+ self.wbuttons.sig_show.connect(self.show_frames)
171
+ self.wbuttons.sig_rolling.connect(self.rolling_average)
172
+ self.wbuttons.sig_refresh.connect(self.roi_changed)
173
+ self.wbuttons.sig_save_csv.connect(self.export_csv)
174
+ self.wbuttons.sig_save_nexus.connect(self.save_nexus)
175
+
176
+ def init_plots(self):
177
+ """
178
+ Create the graph area that holds all the plots.
179
+
180
+ The user-draggable ROI defining the time window in which the data is averaged is
181
+ connected here.
182
+ """
183
+ super().init_plots()
184
+
185
+ # Connect
186
+ self.wgraphs.sig_roi_changed.connect(self.roi_changed)
187
+
188
+ # Connect ROI changed from the tree
189
+ self.wconfiguration.settings_parameters.child(
190
+ "analysis_window"
191
+ ).sigValueChanged.connect(self.update_roi)
192
+
193
+ def init_log(self):
194
+ """
195
+ Create the widget where the log will be printed.
196
+
197
+ The logger is shared with the `pyuson` package-level logger so stream is also
198
+ printed in stdout and the log file.
199
+ """
200
+ super()._init_log(PROGRAM_NAME, log_level=LOG_LEVEL)
201
+
202
+ @pyqtSlot()
203
+ def load_file(self):
204
+ """
205
+ Load a configuration or NeXus file and start a worker.
206
+
207
+ Callback for when the "File" parameter changes, or a file is drag & dropped in
208
+ the window.
209
+ """
210
+ super().load_file()
211
+ if self.worker.proc.is_digital:
212
+ self.init_demodulation()
213
+
214
+ def set_param_from_worker(self, param_name: str, context: str):
215
+ """
216
+ Set a parameter read from the `Config` object of the `EchoProcessor` worker.
217
+
218
+ Parameters
219
+ ----------
220
+ param_name : str
221
+ Name of the parameter in the tree and `Config`.
222
+ context : {"parameters", "settings", "demodulation"}
223
+ Configuration file section.
224
+ """
225
+ if context == "demodulation":
226
+ if hasattr(self.worker.proc.cfg.demodulation, param_name):
227
+ config_value = getattr(self.worker.proc.cfg.demodulation, param_name)
228
+ if param_name in self.wconfiguration.parameters_to_parse:
229
+ # special case that needs to be converted to string
230
+ config_value = self.wconfiguration.get_numbers_from_text(
231
+ config_value
232
+ )
233
+
234
+ self.wconfiguration.demodulation_parameters[param_name] = config_value
235
+ else:
236
+ super().set_param_from_worker(param_name, context)
237
+
238
+ def connect_worker(self):
239
+ """
240
+ Connect signals from the main thread to tasks in the worker.
241
+
242
+ Note that the demodulation tasks are initialized in the
243
+ `init_parameter_tree_demodulation()` method instead.
244
+ """
245
+ super().connect_worker()
246
+
247
+ # Rolling average
248
+ self.sig_worker_rolling.connect(self.worker.rolling_average)
249
+ self.worker.sig_rolling_finished.connect(self.rolling_average_finished)
250
+
251
+ # Average frames and computation
252
+ self.sig_worker_average.connect(self.worker.average_frame)
253
+ self.worker.sig_average_finished.connect(self.average_frame_finished)
254
+
255
+ # Export as CSV
256
+ self.sig_worker_export_csv.connect(self.worker.export_as_csv)
257
+ self.worker.sig_export_csv_finished.connect(self.export_csv_finished)
258
+
259
+ @pyqtSlot(str, str)
260
+ def set_worker_config_from_tree(self, param_name: str, context: str):
261
+ """
262
+ Read parameter from the Parameter Tree and set its sibling in the Config object.
263
+
264
+ Callback for any change in the the "Parameters", "Settings" and "Demodulation"
265
+ sections of the parameter tree, the arguments are passed with the signal.
266
+
267
+ Parameters
268
+ ----------
269
+ param_name : str
270
+ Name of the parameter in the Parameter Tree.
271
+ context : {"parameters", "settings", "demodulation"}
272
+ Define where the key is stored in the Config object (corresponding to the
273
+ configuration file section).
274
+ """
275
+ if context == "demodulation":
276
+ current_value = self.wconfiguration.demodulation_parameters[param_name]
277
+ if param_name in self.wconfiguration.parameters_to_parse:
278
+ # special case that needs to be parsed
279
+ current_value = self.wconfiguration.get_numbers_from_text(current_value)
280
+ setattr(self.worker.proc.cfg.demodulation, param_name, current_value)
281
+ else:
282
+ super().set_worker_config_from_tree(param_name, context)
283
+
284
+ @pyqtSlot()
285
+ def load_data_finished(self):
286
+ """
287
+ Show frames after data is loaded.
288
+
289
+ Callback for when the worker has finished loading data.
290
+ """
291
+ self.show_frames()
292
+ self.align_field_finished()
293
+ self.roi_changed()
294
+ super().load_data_finished()
295
+
296
+ @pyqtSlot()
297
+ def align_field_finished(self):
298
+ """
299
+ Re-plot magnetic field with its new time vector.
300
+
301
+ Set the crosshair that tracks frames in the plot on mouse hover, and store
302
+ increasing and decreasing magnetic field indices to plot them in different
303
+ colors.
304
+
305
+ Callback for when the worker has finished loading data. This is re-triggered
306
+ everytime the time vector is susceptible to change (subsampling, ...).
307
+ """
308
+ # Update field plot axis label
309
+ self.wgraphs.field.setLabel("top", "frame (#)")
310
+
311
+ super().align_field_finished()
312
+
313
+ @pyqtSlot()
314
+ def find_f0(self):
315
+ """
316
+ Find center frequency.
317
+
318
+ Send the signal to the worker to find the radio-frequency from the signal (or
319
+ the configuration file). Callback for the "Find f0" button in the demodulation
320
+ section of the parameter tree.
321
+ """
322
+ if not self.check_data_loaded():
323
+ return
324
+ self.disable_buttons()
325
+ self.sig_worker_find_f0.emit()
326
+
327
+ @pyqtSlot(float)
328
+ def find_f0_finished(self, f0: float):
329
+ """
330
+ Update f0 in the parameter tree.
331
+
332
+ Callback for when the worker has finished the frequency detection. `f0` is
333
+ passed with the signal.
334
+ """
335
+ self.wconfiguration.demodulation_parameters["f0"] = f0
336
+
337
+ self.enable_buttons()
338
+
339
+ @pyqtSlot()
340
+ def demodulate(self):
341
+ """
342
+ Run the digital demodulation.
343
+
344
+ Create a progress bar and send the signal to the worker to perform the digital
345
+ demodulation. Callback for the "Demodulate" button in the "Demodulation" section
346
+ of the parameter tree.
347
+ """
348
+ if not self.check_data_loaded():
349
+ return
350
+ self.disable_buttons()
351
+
352
+ # Create the progress bar
353
+ nchunks = self.worker.proc.nframes
354
+ self.pbar_demodulation = widgets.PopupProgressBar(
355
+ min=0, max=nchunks, title="Digital demodulation"
356
+ )
357
+ self.worker.sig_demodulation_progress.connect(
358
+ self.pbar_demodulation.update_progress
359
+ )
360
+ self.pbar_demodulation.start_progress()
361
+
362
+ self.sig_worker_demodulate.emit()
363
+
364
+ @pyqtSlot()
365
+ def demodulate_finished(self):
366
+ """
367
+ Show the frames demodulated signal and run computations after demodulation.
368
+
369
+ Callback for when the worker has finished the digital demodulation.
370
+ """
371
+ self.pbar_demodulation.close()
372
+ self.worker.sig_demodulation_progress.disconnect()
373
+
374
+ # Rolling average is resetted and signal might be subsampled
375
+ self.align_field_finished()
376
+ self.show_frames()
377
+ self.roi_changed()
378
+
379
+ self.enable_buttons()
380
+
381
+ @pyqtSlot()
382
+ def show_frames(self):
383
+ """
384
+ Show some frames echoes, in amplitude and phase.
385
+
386
+ Only the data for the frames specified in the settings are shown. It might seem
387
+ convoluted because it is needed to fetch the correct dataset names, depending on
388
+ whether we're in analog or digital mode.
389
+
390
+ Callback for the "Show frames" button, also triggered whenever new data is
391
+ loaded (raw or demodulated).
392
+ """
393
+ if not self.check_data_loaded():
394
+ return
395
+ elif "time_meas" not in self.worker.proc.data_raw:
396
+ self.logger.warning(
397
+ "[GUI] Data was not properly loaded, check messages above."
398
+ )
399
+ return
400
+
401
+ # Clear existing traces
402
+ self.wgraphs.amp_frame.clearPlots()
403
+ self.wgraphs.phase_frame.clearPlots()
404
+ if self.worker.proc.is_digital:
405
+ self.wgraphs.reference_frame.clearPlots()
406
+
407
+ frame_indices = self.worker.proc.cfg.settings.frame_indices
408
+ for ind in frame_indices:
409
+ idx = int(ind)
410
+
411
+ # Set the curve legend only for the last one
412
+ if idx == frame_indices[-1]:
413
+ legend_flag = True
414
+ else:
415
+ legend_flag = False
416
+
417
+ if self.worker.proc.is_digital:
418
+ # Plot raw trace
419
+ self.wgraphs.amp_frame.plot(
420
+ self.worker.proc.get_data_raw("time_meas"),
421
+ self.worker.proc.get_data_raw(self.worker.proc._sig_name)[:, idx],
422
+ pen=self.wgraphs.pen_amp,
423
+ )
424
+ # Plot demodulated results if it exists
425
+ iname = self.worker.proc.measurements[0]
426
+ qname = self.worker.proc.measurements[1]
427
+ if self.worker.proc.get_data_processed(
428
+ iname, checkonly=True
429
+ ) and self.worker.proc.get_data_processed(qname, checkonly=True):
430
+ self.show_frames_demodulated()
431
+ # Plot reference trace
432
+ self.wgraphs.reference_frame.plot(
433
+ self.worker.proc.get_data_raw("time_meas"),
434
+ self.worker.proc.get_data_raw("reference")[:, idx],
435
+ pen=pg.mkPen(color=pg.intColor(idx), hues=len(frame_indices)),
436
+ )
437
+ else:
438
+ # Analog mode
439
+ aname = self.worker.proc.measurements[0]
440
+ self.wgraphs.amp_frame.plot(
441
+ self.worker.proc.get_data_raw("time_meas"),
442
+ self.worker.proc.get_data_raw(aname)[:, idx],
443
+ pen=self.wgraphs.pen_amp,
444
+ )
445
+ iname = self.worker.proc.measurements[1]
446
+ self.wgraphs.phase_frame.plot(
447
+ self.worker.proc.get_data_raw("time_meas"),
448
+ self.worker.proc.get_data_raw(iname)[:, idx],
449
+ pen=self.wgraphs.pen_in_phase,
450
+ name="in-phase" if legend_flag else None,
451
+ )
452
+ qname = self.worker.proc.measurements[2]
453
+ self.wgraphs.phase_frame.plot(
454
+ self.worker.proc.get_data_raw("time_meas"),
455
+ self.worker.proc.get_data_raw(qname)[:, idx],
456
+ pen=self.wgraphs.pen_out_phase,
457
+ name="out-of-phase" if legend_flag else None,
458
+ )
459
+
460
+ def show_frames_demodulated(self):
461
+ """Overlay demodulation result on raw frames."""
462
+ # Get measurements names
463
+ iname = self.worker.proc.measurements[0]
464
+ qname = self.worker.proc.measurements[1]
465
+
466
+ # Get correct time vector if decimation was used
467
+ if self.worker.proc.is_decimated:
468
+ xt = self.worker.proc.get_data_processed("time_meas")
469
+ else:
470
+ xt = self.worker.proc.get_data_raw("time_meas")
471
+
472
+ # Change labels
473
+ self.wgraphs.phase_frame.setLabel("left", "phase (rad)")
474
+
475
+ # Compute amplitude and phase of demodulated signal
476
+ frames_ind = [int(ind) for ind in self.worker.proc.cfg.settings.frame_indices]
477
+ amplitude = sp.compute_amp_iq(
478
+ self.worker.proc.get_data_processed(iname)[:, frames_ind],
479
+ self.worker.proc.get_data_processed(qname)[:, frames_ind],
480
+ )
481
+ phase = sp.compute_phase_iq(
482
+ self.worker.proc.get_data_processed(iname)[:, frames_ind],
483
+ self.worker.proc.get_data_processed(qname)[:, frames_ind],
484
+ )
485
+
486
+ # Rescale amplitude to show it with the raw trace on the same scale
487
+ amplitude = sp.rescale_a2b(
488
+ amplitude,
489
+ self.worker.proc.get_data_raw(self.worker.proc._sig_name)[:, frames_ind],
490
+ )
491
+
492
+ for ind in range(len(frames_ind)):
493
+ idx = int(ind)
494
+ self.wgraphs.amp_frame.plot(
495
+ xt,
496
+ amplitude[:, idx],
497
+ pen=self.wgraphs.pen_amp_demod,
498
+ )
499
+ self.wgraphs.phase_frame.plot(
500
+ xt,
501
+ phase[:, idx],
502
+ pen=self.wgraphs.pen_phase_demod,
503
+ )
504
+
505
+ @pyqtSlot()
506
+ def rolling_average(self):
507
+ """
508
+ Perform rolling average.
509
+
510
+ Send the signal to the worker to perform the rolling average. Callback for the
511
+ "Rolling average" button.
512
+ """
513
+ if not self.check_data_loaded():
514
+ return
515
+ self.disable_buttons()
516
+ self.sig_worker_rolling.emit()
517
+
518
+ @pyqtSlot()
519
+ def rolling_average_finished(self):
520
+ """
521
+ Plot magnetic field in case it was subsampled and retrigger computations.
522
+
523
+ Callback for the when the worker has finished the rolling average.
524
+ """
525
+ self.align_field_finished()
526
+ self.roi_changed()
527
+ self.enable_buttons()
528
+
529
+ @pyqtSlot()
530
+ def export_csv(self):
531
+ """
532
+ Export results for the current echo index as CSV.
533
+
534
+ Generate a default file name and open a file picker dialog for the user to
535
+ choose an output file. Determine if the attenuation should be converted to
536
+ dB/cm, and send the signal to the worker to save the results as CSV.
537
+ Callback for the "Export as CSV" button.
538
+ """
539
+ if not self.check_data_loaded():
540
+ return
541
+
542
+ self.disable_buttons()
543
+
544
+ default_fname = self.worker.proc.get_csv_filename()
545
+ fname, _ = QtWidgets.QFileDialog.getSaveFileName(
546
+ self,
547
+ "Save current echo index as...",
548
+ default_fname,
549
+ "Text files (*.txt, *.csv, *.tsv)",
550
+ )
551
+
552
+ if fname:
553
+ self.sig_worker_export_csv.emit(
554
+ fname, self.wbuttons.checkbox_to_cm.isChecked()
555
+ )
556
+ else:
557
+ self.logger.error("[GUI] Invalid output file name for CSV file.")
558
+ self.export_csv_finished()
559
+
560
+ @pyqtSlot()
561
+ def export_csv_finished(self):
562
+ """Callback for when the worker has finished exporting as CSV."""
563
+ self.enable_buttons()
564
+
565
+ @pyqtSlot()
566
+ def batch_process(self):
567
+ """
568
+ Run batch processing on selected files.
569
+
570
+ List files in the queue, determine corresponding experiment IDs, create a
571
+ progress bar and send the signal to the worker to perform the batch-processing.
572
+ Callback for the "Batch process" button in the "Batch processing" tab.
573
+ """
574
+ super().batch_process()
575
+
576
+ # Get settings
577
+ rolling_average = self.wbatch.checkbox_rolling_average.isChecked()
578
+ to_csv = self.wbatch.checkbox_save_as_csv.isChecked()
579
+ to_cm = self.wbuttons.checkbox_to_cm.isChecked()
580
+ if hasattr(self.wbatch, "checkbox_find_f0"):
581
+ find_f0 = self.wbatch.checkbox_find_f0.isChecked()
582
+ else:
583
+ find_f0 = False
584
+
585
+ # Set up progress bar
586
+ self.pbar_batch = widgets.PopupProgressBar(
587
+ min=0, max=len(self.batch_expids), title="Batch processing"
588
+ )
589
+ self.worker.sig_batch_progress.connect(self.batch_process_step_finished)
590
+ self.pbar_batch.start_progress()
591
+
592
+ # Run the batch processing
593
+ self.disable_buttons()
594
+ self.sig_worker_batch.emit(
595
+ self.batch_expids, rolling_average, to_csv, to_cm, find_f0
596
+ )
597
+
598
+ @pyqtSlot(int)
599
+ def batch_process_step_finished(self, idx: int):
600
+ """Track progress of the batch processing."""
601
+ self.pbar_batch.update_progress(idx)
602
+ super().batch_process_step_finished(idx)
603
+
604
+ @pyqtSlot()
605
+ def batch_process_finished(self):
606
+ """
607
+ Cleanup after batch-processing.
608
+
609
+ Callback for when the worker has finished the batch-processing.
610
+ """
611
+ self.pbar_batch.close()
612
+ super().batch_process_finished()
613
+
614
+ @pyqtSlot()
615
+ def roi_changed(self):
616
+ """
617
+ Trigger averaging and computation of attenuation and phase shift.
618
+
619
+ Callback for when the ROI is changed.
620
+ """
621
+ # Get time range
622
+ xmin, xmax = self.wgraphs.roi.getRegion()
623
+
624
+ # Check the ROI was changed since it was created
625
+ if (xmin, xmax) == (0, 1):
626
+ return
627
+
628
+ # Update parameter in the tree if the region was changed from the graph
629
+ if self.flag_do_update_roi:
630
+ self.flag_do_update_roi = False
631
+ self.wconfiguration.settings_parameters["analysis_window"] = (
632
+ self.wconfiguration.get_numbers_from_text([xmin, xmax])
633
+ )
634
+ self.flag_do_update_roi = True
635
+
636
+ self.logger.info(f"[GUI] Analysis window set to : {xmin, xmax}")
637
+
638
+ if not self.check_data_loaded():
639
+ return
640
+
641
+ self.average_frame()
642
+
643
+ @pyqtSlot()
644
+ def update_roi(self):
645
+ """
646
+ Update the ROI in the graph from "Analysis time window" in the parameter tree.
647
+
648
+ Use `flag_do_update_roi` to check if the change is done programatically or from
649
+ the user.
650
+ In the first case, this function is ignored.
651
+ In the second case, the ROI in the graph is updated and computation is
652
+ triggered.
653
+ """
654
+ if self.flag_do_update_roi:
655
+ # ROI changed from the tree, update the ROI in the graph
656
+ new_region = self.wconfiguration.get_numbers_from_text(
657
+ self.wconfiguration.settings_parameters["analysis_window"]
658
+ )
659
+ if not isinstance(new_region, list):
660
+ # for type checking
661
+ return
662
+ if len(new_region) != 2:
663
+ self.logger.error(f"[GUI] Invalid analysis window : {new_region}")
664
+ return
665
+ elif new_region[0] >= new_region[1]:
666
+ self.logger.error(f"[GUI] Invalid analysis window : {new_region}")
667
+ return
668
+
669
+ # Update ROI, without recomputing
670
+ self.flag_do_update_roi = False
671
+ self.wgraphs.roi.setRegion(new_region)
672
+ self.flag_do_update_roi = True
673
+ else:
674
+ return
675
+
676
+ def average_frame(self):
677
+ """
678
+ Average frame and compute attenuation and phase-shift.
679
+
680
+ The current analysis window and echo index are used. Send the signal to the
681
+ worker to perform the average and the computation. This happens everytime the
682
+ ROI is changed.
683
+ """
684
+ if not self.check_data_loaded():
685
+ return
686
+ self.disable_buttons()
687
+ self.sig_worker_average.emit()
688
+
689
+ @pyqtSlot(bool)
690
+ def average_frame_finished(self, status: bool):
691
+ """
692
+ Update the plots with the new attenuation and phase-shift.
693
+
694
+ Callback for when the worker has finished averaging and computing. `status` is
695
+ passed with the pyqtSignal, it tells if the averaging was successful.
696
+ """
697
+ if not status:
698
+ self.enable_buttons()
699
+ self.logger.error(
700
+ "[GUI] Averaging frames failed, check the messages above."
701
+ )
702
+ return
703
+
704
+ # Check the magnetic field is aligned
705
+ if not self.check_field_aligned():
706
+ # Force align
707
+ self.worker.align_field()
708
+
709
+ # Plot results
710
+ # Attenuation
711
+ # Versus magnetic field (dB/cm)
712
+ self.plot_var_field(self.wgraphs.amp_field, "attenuation", mult=1e-2)
713
+ # Versus time (dB/cm)
714
+ self.plot_var_time(self.wgraphs.amp_time, "attenuation", mult=1e-2)
715
+
716
+ # Phase-shift
717
+ # Versus magnetic field
718
+ self.plot_var_field(self.wgraphs.phase_field, "phaseshift")
719
+ # Versus time
720
+ self.plot_var_time(self.wgraphs.phase_time, "phaseshift")
721
+
722
+ self.enable_buttons()
723
+
724
+ def plot_field(self):
725
+ """
726
+ Display magnetic field versus time.
727
+
728
+ The pickup coil voltage and the magnetic field are shown. If it is aligned with
729
+ the experiment time vector, a cross-hair tracks the frame number on mouse hover.
730
+ """
731
+ super().plot_field()
732
+
733
+ # If data was loaded, we can show frames indices on top with a crosshair
734
+ if self.check_data_loaded():
735
+ self.wgraphs.time2frame_scale = (
736
+ self.worker.proc.get_data_processed("time_exp").size
737
+ / self.worker.proc.get_data_processed("magfield_time")[-1]
738
+ )
739
+
740
+ # Create the crosshair if it does not exist yet
741
+ if not hasattr(self.wgraphs, "field_vline"):
742
+ self.wgraphs.init_field_crosshair()
743
+
744
+ @pyqtSlot()
745
+ def update_echo_index_from_batch(self):
746
+ """Update echo index in the parameter tree when changed from the Batch tab."""
747
+ self.wconfiguration.settings_parameters["echo_index"] = self.wbatch.echo_index
748
+
749
+ @pyqtSlot()
750
+ def update_echo_index_from_settings(self):
751
+ """Update echo index in the Batch tab when changed from the parameter tree."""
752
+ self.wbatch.echo_index = self.wconfiguration.settings_parameters["echo_index"]
753
+
754
+ def reset(self):
755
+ """Quit and delete worker and thread, resetting plots and parameters."""
756
+ # Quit thread
757
+ super().reset()
758
+
759
+ # Reset demodulation parameter section
760
+ if (
761
+ hasattr(self.wconfiguration, "demodulation_parameters")
762
+ and self.wconfiguration.demodulation_parameters is not None
763
+ ):
764
+ self.wconfiguration.host_parameters.removeChild(
765
+ self.wconfiguration.demodulation_parameters
766
+ )
767
+ self.wconfiguration.demodulation_parameters = None