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,819 @@
1
+ """
2
+ Generic application oriented toward a configuration > analysis > display workflow.
3
+
4
+ It consists of a Configuration Panel on the left, a graphs area on the right, some
5
+ buttons on the bottom right corner and the application log on the bottom right.
6
+ The Configuration Panel is a tab, other tabs are : a file browser and a batch processing
7
+ tab.
8
+
9
+ The BaseMainWindow should be sub-classed to customize buttons and plots, connect the
10
+ signals to a worker embeding a Processor object. See example implementations in the
11
+ `pyuson` or `pytdo` modules.
12
+ """
13
+
14
+ import logging
15
+ import re
16
+ from pathlib import Path
17
+
18
+ from PyQt6 import QtGui, QtWidgets
19
+ from PyQt6.QtCore import Qt, QThread, pyqtSignal, pyqtSlot
20
+
21
+ from .widgets import TextLoggerWidget
22
+
23
+ REGEXP_EXPID_SEPARATORS = r"[_-]"
24
+ ALLOWED_FORMAT = ("toml", "json", "nx5", "nxs", "h5", "hdf5")
25
+
26
+
27
+ class BaseMainWindow(QtWidgets.QMainWindow):
28
+ """
29
+ A generic graphical user interface application.
30
+
31
+ The `MainWindow` class defines the main thread with the front-end user interface.
32
+ It creates the main general layout and include some generic functions related to
33
+ standard analysis processes : generate a PyQtGraph ParameterTree, connect to a
34
+ worker in its own thread, load and plot a magnetic field...
35
+
36
+ The main window consists of the following :
37
+ - A left panel with three tabs, the Configuration tab, a file browser and a Batch
38
+ processing tab,
39
+ - A right panel with all the plots. There can be any number of graphs, in any number
40
+ of tabs,
41
+ - A bottom-left box with the main action button,
42
+ - A bottom-right box with the log.
43
+
44
+ It is built with pre-defined widgets that must implement a specific interface :
45
+ One should subclass this `BaseMainWindow` class and register those widgets as
46
+ private attributes before calling `super().__init__()`. The widgets' class must be
47
+ registered, not an instance of such, e.g. do `ConfigurationWidget`, not
48
+ `ConfigurationWidget()`. The mandatory widgets to implement and register are :
49
+
50
+ - _type_wconfiguration : a configuration widget for the parameter tree
51
+ - _param_content : a simple class that provides the ParameterTree content,
52
+ - _type_wfiles : a file browser widget,
53
+ - _type_wbatch : a batch-processing widget,
54
+ - _type_wgraphs : a graph widget that holds all the plots,
55
+ - _type_wbuttons : a widget with the main action buttons,
56
+ - _type_worker : a worker object.
57
+
58
+ Each of thos classes are instantiated here with the `_type_` removed, e.g.
59
+ `_type_wgraphs` is intantiated as `wgraphs`.
60
+
61
+ Each of those components exposes signals, to which the main thread connects its own
62
+ methods, instead of connecting directly to the widgets (buttons, checkboxes and the
63
+ like). Those signals are named with a trailing `sig_` so that makes it easy to know
64
+ exactly what should be connected in the main thread.
65
+
66
+ When loading a configuration (or a NeXus) file, the main thread creates a worker
67
+ thread. The latter instantiates an Processor object that perform the actual
68
+ analysis.
69
+
70
+ pyqtSignals
71
+ -------
72
+ All signals emit in their corresponding methods and are connected to the
73
+ corresponding methods in the worker thread. Any number of supplementary signals can
74
+ be implemented in subclasses.
75
+
76
+ `sig_worker_load`
77
+ `sig_worker_batch`
78
+ `sig_worker_save_nexus`
79
+ """
80
+
81
+ sig_worker_load = pyqtSignal()
82
+ sig_worker_batch = pyqtSignal()
83
+ sig_worker_save_nexus = pyqtSignal(str)
84
+
85
+ _type_wbatch: type
86
+ _type_wbuttons: type
87
+ _type_wconfiguration: type
88
+ _type_wfiles: type
89
+ _type_wgraphs: type
90
+ _param_content: type
91
+ _type_worker: type
92
+
93
+ def __init__(self):
94
+ super().__init__()
95
+
96
+ # Set flags
97
+ # Check if the ROI change is done by the user or programatically
98
+ self.flag_do_update_roi = True
99
+ # Check if the experiment ID should be reloaded or if the field is just being
100
+ # updated
101
+ self.flag_do_reload_expid = True
102
+
103
+ # Initialize components
104
+ self.init_parameter_tree()
105
+ self.init_files()
106
+ self.init_batch_processing()
107
+ self.init_buttons()
108
+ self.init_plots()
109
+ self.init_log()
110
+ self.init_layout()
111
+ self.setCentralWidget(self.splitter0)
112
+
113
+ # Setup drag&drop
114
+ self.setAcceptDrops(True)
115
+
116
+ # Show window
117
+ self.show()
118
+
119
+ def init_parameter_tree(self):
120
+ """
121
+ Create the "Configuration" tab.
122
+
123
+ It holds the PyQtGraph parameter tree. Each parameter is synchronised with the
124
+ `Config` object of the Processor worker.
125
+ """
126
+ self.wconfiguration = self._type_wconfiguration(self._param_content)
127
+
128
+ # Connect
129
+ self.wconfiguration.sig_file_changed.connect(self.load_file)
130
+ self.wconfiguration.sig_expid_changed.connect(self.reload_expid)
131
+ self.wconfiguration.sig_autoload_changed.connect(
132
+ self.update_autoload_from_config
133
+ )
134
+ self.wconfiguration.sig_reload_config.connect(self.load_file)
135
+ self.wconfiguration.sig_save_config.connect(self.save_config)
136
+
137
+ def init_files(self):
138
+ """
139
+ Create the "Files" tab.
140
+
141
+ It lists files in a directory, one can double-click a file to load either a
142
+ configuration file or change the experiment ID.
143
+ """
144
+ self.wfiles = self._type_wfiles()
145
+
146
+ # Connect
147
+ self.wfiles.sig_file_selected.connect(self.select_file_in_browser)
148
+ self.wfiles.sig_checkbox_changed.connect(self.update_autoload_from_files)
149
+
150
+ def init_batch_processing(self):
151
+ """
152
+ Create the "Batch processing tab.
153
+
154
+ It's multi-panel file picker, where the user can queue files to be
155
+ batch-processed with the same settings.
156
+ """
157
+ self.wbatch = self._type_wbatch()
158
+
159
+ # Connect
160
+ self.wbatch.sig_batch_process.connect(self.batch_process)
161
+
162
+ def init_buttons(self):
163
+ """Create the main action buttons."""
164
+ self.wbuttons = self._type_wbuttons()
165
+
166
+ # Connect
167
+ self.wbuttons.sig_load.connect(self.load_data)
168
+ self.wbuttons.sig_save_nexus.connect(self.save_nexus)
169
+
170
+ def init_plots(self):
171
+ """Create the graph area that holds all the plots."""
172
+ self.wgraphs = self._type_wgraphs()
173
+
174
+ def init_log(self):
175
+ """Initiliaze the log, calling the `_init_log()` method."""
176
+ raise NotImplementedError(
177
+ "Subclass must implement this method, using the "
178
+ "_init_log(program_name, log_level) method"
179
+ )
180
+
181
+ def _init_log(self, program_name: str, log_level: str = "info"):
182
+ """
183
+ Create the widget where the log will be printed.
184
+
185
+ The logger is shared with the package-level logger so stream is also printed in
186
+ stdout and the log file.
187
+ """
188
+ log_handler = TextLoggerWidget(self)
189
+
190
+ # Set up the formatter
191
+ log_handler.setFormatter(
192
+ logging.Formatter(
193
+ "{asctime}.{msecs:3g} [{levelname}] : {message}",
194
+ style="{",
195
+ datefmt="%Y-%m-%d %H:%M:%S",
196
+ )
197
+ )
198
+
199
+ # Set up the logger
200
+ self.logger = logging.getLogger(program_name)
201
+ self.logger.addHandler(log_handler)
202
+ self.logger.setLevel(log_level)
203
+
204
+ # Store the logger widget (QPlainTextEdit)
205
+ self.wlog = log_handler.widget
206
+
207
+ def init_layout(self):
208
+ """
209
+ Set up the main layout.
210
+
211
+ It is composed of a 4 panels seperated with resizable splitters.
212
+ Panel 1 (top left) : Tabs with Configuration, Files and Batch processing
213
+ Panel 2 (bottom left) : Action buttons
214
+ Panel 3 (top right) : Graphs
215
+ Panel 4 (bottom right) : Log
216
+ """
217
+ self.splitter0 = QtWidgets.QSplitter(self)
218
+ splitter1 = QtWidgets.QSplitter(Qt.Orientation.Vertical, self)
219
+ splitter2 = QtWidgets.QSplitter(Qt.Orientation.Vertical, self)
220
+
221
+ self.config_tabs = QtWidgets.QTabWidget(self)
222
+ self.config_tabs.addTab(self.wconfiguration, "Configuration")
223
+ self.config_tabs.addTab(self.wfiles, "Files")
224
+ self.config_tabs.addTab(self.wbatch, "Batch processing")
225
+
226
+ splitter1.addWidget(self.config_tabs)
227
+ splitter1.addWidget(self.wbuttons)
228
+
229
+ splitter2.addWidget(self.wgraphs)
230
+ splitter2.addWidget(self.wlog)
231
+
232
+ self.splitter0.addWidget(splitter1)
233
+ self.splitter0.addWidget(splitter2)
234
+
235
+ @pyqtSlot()
236
+ def load_file(self):
237
+ """
238
+ Load a configuration or NeXus file and start a worker.
239
+
240
+ Callback for when the "File" parameter changes, or a file is drag & dropped in
241
+ the window.
242
+ """
243
+ self.create_worker()
244
+ self.plot_field()
245
+
246
+ if self.worker.is_dataloaded:
247
+ # If NeXus file, data is already ready to be shown
248
+ self.load_data_finished()
249
+ if self.check_field_aligned():
250
+ # Allow plotting versus field
251
+ self.align_field_finished()
252
+ else:
253
+ # Force align field
254
+ self.worker.align_field()
255
+ else:
256
+ # Enable the "Load data" button
257
+ self.wbuttons.button_load.setEnabled(True)
258
+
259
+ # Set experiment ID read from the configuration, without reloading it
260
+ self.flag_do_reload_expid = False
261
+ self.wconfiguration.files_parameters["expid"] = self.worker.proc.expid
262
+ self.flag_do_reload_expid = True
263
+
264
+ # Update file explorer directory
265
+ self.wfiles.file_browser.set_directory(self.worker.proc.cfg.data_directory)
266
+ self.wbatch.current_directory = self.worker.proc.cfg.data_directory
267
+ self.wbatch.prefix = re.split(REGEXP_EXPID_SEPARATORS, self.worker.proc.expid)[
268
+ 0
269
+ ]
270
+
271
+ # Enable buttons
272
+ self.wconfiguration.button_reload_data.setEnabled(True)
273
+ self.wconfiguration.button_save_config.setEnabled(True)
274
+
275
+ # Load data directly if autoload is on
276
+ if self.wconfiguration.files_parameters["autoload"]:
277
+ self.load_data()
278
+
279
+ def create_worker(self):
280
+ """Create the Processor object in its own worker thread."""
281
+ # Delete previous instance if any
282
+ if hasattr(self, "worker_thread"):
283
+ self.reset()
284
+
285
+ # Create thread
286
+ self.worker_thread = QThread()
287
+ # Create worker
288
+ self.worker = self._type_worker(self.wconfiguration.files_parameters["file"])
289
+
290
+ # Move worker to thread
291
+ self.worker.moveToThread(self.worker_thread)
292
+
293
+ # Connect signals and slots
294
+ self.connect_worker()
295
+
296
+ # Start the thread
297
+ self.worker_thread.start()
298
+
299
+ # Read the configuration
300
+ self.set_tree_from_worker()
301
+
302
+ def set_tree_from_worker(self):
303
+ """
304
+ Set the parameters in the parameter tree from the worker configuration.
305
+
306
+ Loop through all parameter in the "parameters" and "settings" section and set
307
+ them from the `Config` object of the Processor object.
308
+ """
309
+ # In the Parameters section
310
+ for p in self.wconfiguration.param_parameters:
311
+ self.set_param_from_worker(p.name(), context="parameters")
312
+ # In the Settings section
313
+ for p in self.wconfiguration.settings_parameters:
314
+ self.set_param_from_worker(p.name(), context="settings")
315
+
316
+ def set_param_from_worker(self, param_name: str, context: str):
317
+ """
318
+ Set a parameter read from the `Config` object of the Processor worker.
319
+
320
+ Parameters
321
+ ----------
322
+ param_name : str
323
+ Name of the parameter in the tree and `Config`.
324
+ context : {"parameters", "settings"}
325
+ Configuration file section.
326
+ """
327
+ match context:
328
+ case "parameters":
329
+ if hasattr(self.worker.proc.cfg.parameters, param_name):
330
+ self.wconfiguration.param_parameters[param_name] = getattr(
331
+ self.worker.proc.cfg.parameters, param_name
332
+ )
333
+ case "settings":
334
+ if hasattr(self.worker.proc.cfg.settings, param_name):
335
+ config_value = getattr(self.worker.proc.cfg.settings, param_name)
336
+ if param_name in self.wconfiguration.parameters_to_parse:
337
+ # special case that needs to be converted to string
338
+ config_value = self.wconfiguration.get_numbers_from_text(
339
+ config_value
340
+ )
341
+
342
+ self.wconfiguration.settings_parameters[param_name] = config_value
343
+ case _:
344
+ raise ValueError(f"Error : unknown configuration section : {context}")
345
+
346
+ def connect_worker(self):
347
+ """Connect signals from the main thread to tasks in the worker."""
348
+ # Load data and align field
349
+ self.sig_worker_load.connect(self.worker.load_data)
350
+ self.worker.sig_load_finished.connect(self.load_data_finished)
351
+ self.worker.sig_align_finished.connect(self.align_field_finished)
352
+
353
+ # Batch process
354
+ self.sig_worker_batch.connect(self.worker.batch_process)
355
+ self.worker.sig_batch_finished.connect(self.batch_process_finished)
356
+
357
+ # Save as NeXus
358
+ self.sig_worker_save_nexus.connect(self.worker.save_as_nexus)
359
+ self.worker.sig_save_nexus_finished.connect(self.save_nexus_finished)
360
+
361
+ # Watch changes from the parameter tree to update the worker configuration
362
+ self.wconfiguration.sig_parameter_changed.connect(
363
+ self.set_worker_config_from_tree
364
+ )
365
+
366
+ @pyqtSlot(str, str)
367
+ def set_worker_config_from_tree(self, param_name: str, context: str):
368
+ """
369
+ Read parameter from the Parameter Tree and set its sibling in the Config object.
370
+
371
+ Callback for any change in the the "Parameters", "Settings" sections of the
372
+ parameter tree, the arguments are passed with the signal.
373
+
374
+ Parameters
375
+ ----------
376
+ param_name : str
377
+ Name of the parameter in the Parameter Tree.
378
+ context : {"parameters", "settings"}
379
+ Define where the key is stored in the Config object (corresponding to the
380
+ configuration file section).
381
+ """
382
+ match context:
383
+ case "parameters":
384
+ setattr(
385
+ self.worker.proc.cfg.parameters,
386
+ param_name,
387
+ self.wconfiguration.param_parameters[param_name],
388
+ )
389
+ case "settings":
390
+ current_value = self.wconfiguration.settings_parameters[param_name]
391
+ if param_name in self.wconfiguration.parameters_to_parse:
392
+ # special case that needs to be parsed
393
+ current_value = self.wconfiguration.get_numbers_from_text(
394
+ current_value
395
+ )
396
+ self.logger.debug(
397
+ f"[GUI] Setting Config from tree : {param_name} : {current_value}"
398
+ )
399
+ setattr(self.worker.proc.cfg.settings, param_name, current_value)
400
+ case _:
401
+ raise ValueError(f"Unknown configuration section : {context}")
402
+
403
+ @pyqtSlot()
404
+ def reload_expid(self):
405
+ """
406
+ Reload experiment ID, resetting the data via the worker.
407
+
408
+ `expid` is a property of the Processor object, when it is changed, it triggers a
409
+ reinitializtion of the object.
410
+
411
+ If data autoloading is enabled, the data is reloaded here.
412
+
413
+ Callback for a change of the experiment ID in the "Files" section of the
414
+ parameter tree and of the `Reload` button.
415
+ """
416
+ if not self.check_config_loaded():
417
+ self.logger.warning("[GUI] No configuration file loaded.")
418
+ return
419
+ if not self.flag_do_reload_expid:
420
+ # The experiment ID is being changed programatically, do not update
421
+ return
422
+ # Infer experiment ID
423
+ expid = self.infer_expid(self.wconfiguration.files_parameters["expid"])
424
+ # Set it, without reloading since that's we're doing
425
+ self.flag_do_reload_expid = False
426
+ self.wconfiguration.files_parameters["expid"] = expid
427
+ self.flag_do_reload_expid = True
428
+
429
+ self.logger.info(
430
+ f"[GUI] Reloading experiment ID, from {self.worker.proc.expid} to {expid}"
431
+ )
432
+
433
+ # Set the new experiment ID in the worker
434
+ self.worker.proc.expid = expid
435
+ self.worker.is_dataloaded = False
436
+
437
+ # Prepare
438
+ self.disable_buttons()
439
+ self.wgraphs.clear_all_plots()
440
+ self.plot_field()
441
+ self.wconfiguration.button_reload_data.setEnabled(True)
442
+ self.wconfiguration.button_save_config.setEnabled(True)
443
+ self.wbuttons.button_load.setEnabled(True)
444
+
445
+ if self.wconfiguration.files_parameters["autoload"]:
446
+ # Autoload data
447
+ self.load_data()
448
+
449
+ @pyqtSlot()
450
+ def save_config(self):
451
+ """
452
+ Save current configuration as a TOML file.
453
+
454
+ A default file name is generated and a file picker dialog box is openned for the
455
+ user to choose an output file.
456
+ """
457
+ if not self.check_config_loaded():
458
+ return
459
+
460
+ cfg_file = Path(self.wconfiguration.files_parameters["file"])
461
+ default_fname = cfg_file.with_stem(cfg_file.stem + "-2")
462
+ fname, _ = QtWidgets.QFileDialog.getSaveFileName(
463
+ self,
464
+ "Save configuration file as...",
465
+ directory=str(default_fname),
466
+ filter="TOML files (*.toml), JSON files (*.json)",
467
+ options=QtWidgets.QFileDialog.Option.DontConfirmOverwrite,
468
+ )
469
+ self.worker.proc.cfg.write(fname)
470
+
471
+ @pyqtSlot()
472
+ def load_data(self):
473
+ """
474
+ Load data.
475
+
476
+ Send the signal to the worker to load data. Callback for the "Load data" button.
477
+ It's also used whenever a new dataset is loaded, if data auto-loading is
478
+ enabled.
479
+ """
480
+ if not self.check_config_loaded():
481
+ self.logger.warning("[GUI] No configuration file loaded.")
482
+ return
483
+
484
+ self.disable_buttons()
485
+ self.sig_worker_load.emit()
486
+ self.logger.debug("emitted signal to load data")
487
+
488
+ @pyqtSlot()
489
+ def load_data_finished(self):
490
+ """Callback for when the worker has finished loading data."""
491
+ self.enable_buttons()
492
+
493
+ @pyqtSlot()
494
+ def align_field_finished(self):
495
+ """
496
+ Re-plot magnetic field with its new time vector.
497
+
498
+ Callback for when the worker has finished loading data. This is re-triggered
499
+ everytime the time vector is susceptible to change (subsampling, ...).
500
+ """
501
+ # Checks
502
+ if not self.check_field_aligned():
503
+ return
504
+
505
+ # Re-plot magnetic field
506
+ self.plot_field()
507
+
508
+ # Get indexer for field up and field down
509
+ t = self.worker.proc.get_data_processed("time_exp")
510
+ b = self.worker.proc.get_data_processed("magfield")
511
+ self.ind_bup = t <= t[b.argmax()] # increasing B
512
+ self.ind_bdown = t > t[b.argmax()] # decreasing B
513
+ self.enable_buttons()
514
+
515
+ @pyqtSlot()
516
+ def save_nexus(self):
517
+ """
518
+ Save everything as a NeXus file.
519
+
520
+ The output file is self-contained, it saves all parameters, echo indices, raw
521
+ and processed data.
522
+
523
+ Generate a default file name and open a file picker dialog for the user to
524
+ choose an output file, and send the signal to the worker to save the results as
525
+ NeXus.
526
+ Callback for the "Save as NeXus" button.
527
+ """
528
+ if not self.check_data_loaded():
529
+ return
530
+
531
+ self.disable_buttons()
532
+
533
+ default_fname = self.worker.proc.get_nexus_filename()
534
+ fname, _ = QtWidgets.QFileDialog.getSaveFileName(
535
+ self,
536
+ "Save NeXus file as...",
537
+ default_fname,
538
+ "NeXus files (*.hdf5, *.h5, *.nxs, *.nx5)",
539
+ )
540
+
541
+ if fname:
542
+ self.sig_worker_save_nexus.emit(fname)
543
+ else:
544
+ self.logger.error("[GUI] Invalid output file name for NeXus file.")
545
+ self.save_nexus_finished()
546
+
547
+ @pyqtSlot()
548
+ def save_nexus_finished(self):
549
+ """Callback for when the worker has finished saving as NeXus."""
550
+ self.enable_buttons()
551
+
552
+ @pyqtSlot(bool, str)
553
+ def select_file_in_browser(self, is_toml: bool, filepath: str):
554
+ """
555
+ Select a file and set it as a configuration file or a new experiment ID.
556
+
557
+ Callback for when the user double-click on a file in the "Files" tab.
558
+ """
559
+ if is_toml:
560
+ self.wconfiguration.files_parameters["file"] = filepath
561
+ else:
562
+ self.wconfiguration.files_parameters["expid"] = filepath
563
+
564
+ @pyqtSlot()
565
+ def batch_process(self):
566
+ """
567
+ Run batch processing on selected files.
568
+
569
+ List files in the queue, determine corresponding experiment IDs, create a
570
+ progress bar and send the signal to the worker to perform the batch-processing.
571
+ Callback for the "Batch process" button in the "Batch processing" tab.
572
+ """
573
+ if not self.check_config_loaded():
574
+ self.logger.warning("[GUI] No configuration was loaded.")
575
+ return
576
+
577
+ # List files in the queue
578
+ self.batch_files = self.wbatch.get_files_to_process()
579
+
580
+ if len(self.batch_files) == 0:
581
+ self.logger.warning("[GUI] No items in batch processing list.")
582
+ return
583
+
584
+ # Infer experiments ID from the file names
585
+ self.batch_expids = []
586
+ for file in self.batch_files:
587
+ self.batch_expids.append(self.infer_expid(file))
588
+
589
+ @pyqtSlot(int)
590
+ def batch_process_step_finished(self, idx: int):
591
+ """Track progress of the batch processing."""
592
+ # Move file in the "Done" list
593
+ self.wbatch.move_to_done(self.batch_files[idx])
594
+
595
+ @pyqtSlot()
596
+ def batch_process_finished(self):
597
+ """
598
+ Cleanup after batch-processing.
599
+
600
+ Callback for when the worker has finished the batch-processing.
601
+ """
602
+ self.worker.sig_batch_progress.disconnect()
603
+
604
+ # Set the last experiment ID in the parameter tree, without reloading since it
605
+ # is already the currently loaded dataset
606
+ self.flag_do_reload_expid = False
607
+ self.wconfiguration.files_parameters["expid"] = self.batch_expids[-1]
608
+ self.flag_do_reload_expid = True
609
+ self.batch_files = []
610
+ self.batch_expids = []
611
+
612
+ @pyqtSlot()
613
+ def roi_changed(self):
614
+ """
615
+ Trigger averaging and computation of attenuation and phase shift.
616
+
617
+ Callback for when the ROI is changed.
618
+ """
619
+ raise NotImplementedError("Subclasses must implement this method.")
620
+
621
+ @pyqtSlot()
622
+ def update_roi(self):
623
+ """Update the ROI in the graph."""
624
+ raise NotImplementedError("Subclasses must implement this method.")
625
+
626
+ def infer_expid(self, expid: str) -> str:
627
+ """
628
+ Determine experiment ID from a file name or path.
629
+
630
+ If the input is an existing file, it uses the base file name without extension,
631
+ removes "-pickup" and, for WFM files, the channels number, to build the
632
+ experiment ID. Otherwise, it is used as is.
633
+ """
634
+ file_path = Path(expid)
635
+
636
+ if file_path.is_file():
637
+ # Input is a file, get the corresponding experiment ID
638
+ if expid.lower().endswith(".wfm"):
639
+ # Remove the _chX bit for WFM files
640
+ res = re.sub(r"_ch\d+\.wfm$", ".wfm", expid, flags=re.IGNORECASE)
641
+ file_path = Path(res)
642
+
643
+ infered_expid = file_path.stem.replace("-pickup", "")
644
+
645
+ return infered_expid
646
+ else:
647
+ # Input experiment ID is a proper experiment ID, return as is
648
+ return expid
649
+
650
+ def plot_field(self):
651
+ """
652
+ Display magnetic field versus time.
653
+
654
+ The pickup coil voltage and the magnetic field are shown. If it is aligned with
655
+ the experiment time vector, a cross-hair tracks the frame number on mouse hover.
656
+ """
657
+ if not self.worker.proc.get_data_processed("magfield", checkonly=True):
658
+ self.worker.proc.compute_field()
659
+
660
+ if not self.worker.proc.get_data_processed("magfield", checkonly=True):
661
+ # Still no field to plot, maybe there was no pickup and data was not
662
+ # loaded so the pickup signal could be simulated
663
+ self.logger.warning("[GUI] Field was not computed, not plotting.")
664
+ return
665
+
666
+ # B(t)
667
+ self.wgraphs.field.clearPlots()
668
+ self.wgraphs.field.plot(
669
+ self.worker.proc.get_data_processed("magfield_time"),
670
+ self.worker.proc.get_data_processed("magfield"),
671
+ pen=self.wgraphs.pen_field,
672
+ )
673
+ self.wgraphs.field.setTitle(
674
+ "Magnetic field (max. field : "
675
+ f"{self.worker.proc.get_data_processed('magfield').max():2.2f}T)"
676
+ )
677
+
678
+ # dB/dt (pickup)
679
+ self.wgraphs.dfield.clearPlots()
680
+ if self.worker.proc.get_data_raw(
681
+ "pickup_time", checkonly=True
682
+ ) and self.worker.proc.get_data_raw("pickup", checkonly=True):
683
+ self.wgraphs.dfield.plot(
684
+ self.worker.proc.get_data_raw("pickup_time"),
685
+ self.worker.proc.get_data_raw("pickup"),
686
+ pen=self.wgraphs.pen_field,
687
+ )
688
+
689
+ def plot_var_time(self, pgplot, varname: str, mult: float = 1):
690
+ """
691
+ Plot `varname` versus experiment time.
692
+
693
+ Used to plot attenuation and phase-shift versus time.
694
+ """
695
+ pgplot.clearPlots()
696
+ pgplot.plot(
697
+ self.worker.proc.get_data_processed("time_exp"),
698
+ self.worker.proc.get_data_serie(varname) * mult,
699
+ )
700
+
701
+ def plot_var_field(self, pgplot, varname: str, mult: float = 1):
702
+ """Plot `varname` versus magnetic field.
703
+
704
+ Field rise and decay are distinguished with a different color. Corresponding
705
+ indices should be already available from the `ind_bdown` and `ind_bup`
706
+ attributes, that are created in the `align_field_finished()` method.
707
+ """
708
+ pgplot.clearPlots()
709
+ pgplot.plot(
710
+ self.worker.proc.get_data_processed("magfield")[self.ind_bdown],
711
+ self.worker.proc.get_data_serie(varname)[self.ind_bdown] * mult,
712
+ pen=self.wgraphs.pen_bdown,
713
+ name="B down",
714
+ )
715
+ pgplot.plot(
716
+ self.worker.proc.get_data_processed("magfield")[self.ind_bup],
717
+ self.worker.proc.get_data_serie(varname)[self.ind_bup] * mult,
718
+ pen=self.wgraphs.pen_bup,
719
+ name="B up",
720
+ )
721
+
722
+ def disable_buttons(self):
723
+ """Disable all buttons and ROIs."""
724
+ self.wconfiguration.disable_buttons()
725
+ self.wbuttons.disable_buttons()
726
+ self.wbatch.disable_buttons()
727
+ self.wgraphs.disable_rois()
728
+
729
+ def enable_buttons(self):
730
+ """Enable all buttons and ROIs."""
731
+ self.wconfiguration.enable_buttons()
732
+ self.wbuttons.enable_buttons()
733
+ self.wbatch.enable_buttons()
734
+ self.wgraphs.enable_rois()
735
+
736
+ @pyqtSlot()
737
+ def update_autoload_from_files(self):
738
+ """Update the autoload checkbox in the Configuration when toggled in Files."""
739
+ self.wconfiguration.files_parameters["autoload"] = (
740
+ self.wfiles.checkbox_autoload_data.isChecked()
741
+ )
742
+
743
+ @pyqtSlot()
744
+ def update_autoload_from_config(self):
745
+ """Update the autoload checkbox in Files when toggled in the Configuration."""
746
+ self.wfiles.checkbox_autoload_data.setChecked(
747
+ self.wconfiguration.files_parameters["autoload"]
748
+ )
749
+
750
+ def check_config_loaded(self) -> bool:
751
+ """Check if a worker was initialized."""
752
+ if hasattr(self, "worker"):
753
+ return True
754
+ else:
755
+ return False
756
+
757
+ def check_data_loaded(self) -> bool:
758
+ """Check if data was loaded."""
759
+ if not self.check_config_loaded():
760
+ return False
761
+ else:
762
+ if not self.worker.is_dataloaded:
763
+ return False
764
+ else:
765
+ return True
766
+
767
+ def check_field_aligned(self) -> bool:
768
+ """Check if the magnetic field is aligned on the experiment time vector."""
769
+ if not self.worker.proc.get_data_processed("magfield", checkonly=True):
770
+ self.logger.warning("[GUI] Magnetic field was not computed.")
771
+ return False
772
+ if not self.worker.proc.get_data_processed("time_exp", checkonly=True):
773
+ self.logger.warning("[GUI] Experiment time vector was not built.")
774
+ return False
775
+ fieldsize = self.worker.proc.get_data_processed("magfield").size
776
+ timesize = self.worker.proc.get_data_processed("time_exp").size
777
+ if fieldsize != timesize:
778
+ return False
779
+ else:
780
+ return True
781
+
782
+ def reset(self):
783
+ """Quit and delete worker and thread, resetting plots and parameters."""
784
+ # Quit thread
785
+ if hasattr(self, "worker_thread"):
786
+ self.worker_thread.quit()
787
+ self.worker_thread.deleteLater()
788
+ if hasattr(self, "worker"):
789
+ self.worker.deleteLater()
790
+
791
+ # Clear plots
792
+ self.wgraphs.clear_all_plots()
793
+ # Disable buttons
794
+ self.disable_buttons()
795
+
796
+ def dragEnterEvent(self, a0: QtGui.QDropEvent | None = None) -> None:
797
+ """Handle drag&drop configuration or data file."""
798
+ if a0.mimeData().hasText():
799
+ a0.accept()
800
+ else:
801
+ a0.ignore()
802
+
803
+ def dropEvent(self, a0: QtGui.QDropEvent | None = None) -> None:
804
+ """
805
+ Set the "File" parameter when a file is dropped in the main window.
806
+
807
+ This triggers the `load_file()` method.
808
+ """
809
+ for url in a0.mimeData().urls():
810
+ file_path = url.toLocalFile()
811
+ if file_path.endswith(ALLOWED_FORMAT):
812
+ self.wconfiguration.files_parameters["file"] = file_path
813
+
814
+ def closeEvent(self, a0):
815
+ """Quit."""
816
+ if self.check_config_loaded():
817
+ self.reset()
818
+ print("Bye !")
819
+ return super().closeEvent(a0)