AMS-BP 0.3.1__py3-none-any.whl → 0.4.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 (60) hide show
  1. AMS_BP/__init__.py +1 -1
  2. AMS_BP/configio/configmodels.py +26 -12
  3. AMS_BP/configio/convertconfig.py +508 -632
  4. AMS_BP/gui/README.md +77 -0
  5. AMS_BP/gui/__init__.py +0 -0
  6. AMS_BP/gui/assets/__init__.py +0 -0
  7. AMS_BP/gui/assets/drawing.svg +107 -0
  8. AMS_BP/gui/configuration_window.py +333 -0
  9. AMS_BP/gui/help_docs/__init__.py +0 -0
  10. AMS_BP/gui/help_docs/cell_help.md +45 -0
  11. AMS_BP/gui/help_docs/channels_help.md +78 -0
  12. AMS_BP/gui/help_docs/condensate_help.md +59 -0
  13. AMS_BP/gui/help_docs/detector_help.md +57 -0
  14. AMS_BP/gui/help_docs/experiment_help.md +92 -0
  15. AMS_BP/gui/help_docs/fluorophore_help.md +128 -0
  16. AMS_BP/gui/help_docs/general_help.md +43 -0
  17. AMS_BP/gui/help_docs/global_help.md +47 -0
  18. AMS_BP/gui/help_docs/laser_help.md +76 -0
  19. AMS_BP/gui/help_docs/molecule_help.md +78 -0
  20. AMS_BP/gui/help_docs/output_help.md +5 -0
  21. AMS_BP/gui/help_docs/psf_help.md +51 -0
  22. AMS_BP/gui/help_window.py +26 -0
  23. AMS_BP/gui/logging_window.py +93 -0
  24. AMS_BP/gui/main.py +255 -0
  25. AMS_BP/gui/sim_worker.py +58 -0
  26. AMS_BP/gui/template_window_selection.py +100 -0
  27. AMS_BP/gui/widgets/__init__.py +0 -0
  28. AMS_BP/gui/widgets/camera_config_widget.py +213 -0
  29. AMS_BP/gui/widgets/cell_config_widget.py +225 -0
  30. AMS_BP/gui/widgets/channel_config_widget.py +307 -0
  31. AMS_BP/gui/widgets/condensate_config_widget.py +341 -0
  32. AMS_BP/gui/widgets/experiment_config_widget.py +259 -0
  33. AMS_BP/gui/widgets/flurophore_config_widget.py +513 -0
  34. AMS_BP/gui/widgets/general_config_widget.py +47 -0
  35. AMS_BP/gui/widgets/global_config_widget.py +142 -0
  36. AMS_BP/gui/widgets/laser_config_widget.py +255 -0
  37. AMS_BP/gui/widgets/molecule_config_widget.py +714 -0
  38. AMS_BP/gui/widgets/output_config_widget.py +61 -0
  39. AMS_BP/gui/widgets/psf_config_widget.py +128 -0
  40. AMS_BP/gui/widgets/utility_widgets/__init__.py +0 -0
  41. AMS_BP/gui/widgets/utility_widgets/scinotation_widget.py +21 -0
  42. AMS_BP/gui/widgets/utility_widgets/spectrum_widget.py +115 -0
  43. AMS_BP/logging/__init__.py +0 -0
  44. AMS_BP/logging/logutil.py +83 -0
  45. AMS_BP/logging/setup_run_directory.py +35 -0
  46. AMS_BP/{run_cell_simulation.py → main_cli.py} +27 -72
  47. AMS_BP/optics/filters/filters.py +2 -0
  48. AMS_BP/resources/template_configs/metadata_configs.json +20 -0
  49. AMS_BP/resources/template_configs/sim_config.toml +408 -0
  50. AMS_BP/resources/template_configs/twocolor_widefield_timeseries_live.toml +399 -0
  51. AMS_BP/resources/template_configs/twocolor_widefield_zstack_fixed.toml +406 -0
  52. AMS_BP/resources/template_configs/twocolor_widefield_zstack_live.toml +408 -0
  53. AMS_BP/run_sim_util.py +76 -0
  54. {ams_bp-0.3.1.dist-info → ams_bp-0.4.0.dist-info}/METADATA +46 -27
  55. ams_bp-0.4.0.dist-info/RECORD +103 -0
  56. ams_bp-0.4.0.dist-info/entry_points.txt +2 -0
  57. ams_bp-0.3.1.dist-info/RECORD +0 -55
  58. ams_bp-0.3.1.dist-info/entry_points.txt +0 -2
  59. {ams_bp-0.3.1.dist-info → ams_bp-0.4.0.dist-info}/WHEEL +0 -0
  60. {ams_bp-0.3.1.dist-info → ams_bp-0.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,714 @@
1
+ from pathlib import Path
2
+
3
+ from pydantic import ValidationError
4
+ from PyQt6.QtCore import pyqtSignal
5
+ from PyQt6.QtWidgets import (
6
+ QCheckBox,
7
+ QComboBox,
8
+ QDoubleSpinBox,
9
+ QFormLayout,
10
+ QGridLayout,
11
+ QHBoxLayout,
12
+ QLabel,
13
+ QMessageBox,
14
+ QPushButton,
15
+ QScrollArea,
16
+ QSpinBox,
17
+ QTabWidget,
18
+ QVBoxLayout,
19
+ QWidget,
20
+ )
21
+
22
+
23
+ class MoleculeConfigWidget(QWidget):
24
+ # Signal to notify when molecule count changes
25
+ molecule_count_changed = pyqtSignal(int)
26
+
27
+ def __init__(self):
28
+ super().__init__()
29
+
30
+ self.main_layout = QVBoxLayout()
31
+ self.setLayout(self.main_layout)
32
+
33
+ # Number of molecule types spinner
34
+ self.num_types_layout = QHBoxLayout()
35
+ self.num_types_label = QLabel("Number of molecule types:")
36
+ self.num_types_spinner = QSpinBox()
37
+ self.num_types_spinner.setRange(1, 10)
38
+ self.num_types_spinner.setValue(1)
39
+ self.num_types_spinner.valueChanged.connect(self._on_molecule_count_changed)
40
+ self.num_types_layout.addWidget(self.num_types_label)
41
+ self.num_types_layout.addWidget(self.num_types_spinner)
42
+ self.num_types_layout.addStretch()
43
+
44
+ # Validate button
45
+ self.validate_button = QPushButton("Validate")
46
+ self.validate_button.clicked.connect(self.validate)
47
+ self.num_types_layout.addWidget(self.validate_button)
48
+
49
+ self.main_layout.addLayout(self.num_types_layout)
50
+
51
+ # Create tab widget to hold molecule type configs
52
+ self.tabs = QTabWidget()
53
+ self.main_layout.addWidget(self.tabs)
54
+
55
+ # Initialize with one molecule type
56
+ self.molecule_type_widgets = []
57
+ self.update_molecule_types(1)
58
+
59
+ def _on_molecule_count_changed(self, count):
60
+ self.update_molecule_types(count)
61
+ self.molecule_count_changed.emit(count)
62
+
63
+ def set_molecule_count(self, count):
64
+ if self.num_types_spinner.value() != count:
65
+ self.num_types_spinner.blockSignals(True)
66
+ self.num_types_spinner.setValue(count)
67
+ self.num_types_spinner.blockSignals(False)
68
+ self.update_molecule_types(count)
69
+ self.molecule_count_changed.emit(count)
70
+
71
+ def update_molecule_types(self, num_types):
72
+ # Store current data if any
73
+ current_data = self.get_data() if self.molecule_type_widgets else None
74
+
75
+ # Remove existing tabs
76
+ while self.tabs.count() > 0:
77
+ self.tabs.removeTab(0)
78
+
79
+ self.molecule_type_widgets = []
80
+
81
+ # Create new tabs
82
+ for i in range(num_types):
83
+ molecule_widget = MoleculeTypeWidget(i)
84
+ self.molecule_type_widgets.append(molecule_widget)
85
+ self.tabs.addTab(molecule_widget, f"Molecule Type {i+1}")
86
+
87
+ # Restore data only if tab count matches current data
88
+ if current_data and len(current_data["num_molecules"]) == num_types:
89
+ self.set_data_from_widget_selection(current_data)
90
+
91
+ def validate(self) -> bool:
92
+ try:
93
+ data = self.get_data()
94
+
95
+ # This will validate the schema using the backend logic
96
+ from ...configio.configmodels import MoleculeParameters
97
+ from ...configio.convertconfig import create_dataclass_schema
98
+
99
+ _ = create_dataclass_schema(MoleculeParameters, data)
100
+
101
+ QMessageBox.information(
102
+ self, "Validation Successful", "Molecule parameters are valid."
103
+ )
104
+ return True
105
+ except ValidationError as e:
106
+ QMessageBox.critical(self, "Validation Error", str(e))
107
+ return False
108
+ except ValueError as e:
109
+ QMessageBox.critical(self, "Validation Error", str(e))
110
+ return False
111
+
112
+ def set_data(self, config: dict):
113
+ """
114
+ Load molecule configuration from TOML config format.
115
+ """
116
+ try:
117
+ # Validate format first
118
+ from ...configio.configmodels import MoleculeParameters
119
+ from ...configio.convertconfig import create_dataclass_schema
120
+
121
+ validated = create_dataclass_schema(MoleculeParameters, config)
122
+
123
+ # Determine number of types
124
+ num_types = len(validated.num_molecules)
125
+ self.set_molecule_count(num_types)
126
+
127
+ # Build compatible format to use set_data_from_widget_state()
128
+ parsed = {
129
+ "num_molecules": validated.num_molecules,
130
+ "track_type": validated.track_type,
131
+ "diffusion_coefficient": validated.diffusion_coefficient,
132
+ "hurst_exponent": validated.hurst_exponent,
133
+ "allow_transition_probability": validated.allow_transition_probability,
134
+ "transition_matrix_time_step": validated.transition_matrix_time_step,
135
+ "diffusion_transition_matrix": validated.diffusion_transition_matrix,
136
+ "hurst_transition_matrix": validated.hurst_transition_matrix,
137
+ "state_probability_diffusion": validated.state_probability_diffusion,
138
+ "state_probability_hurst": validated.state_probability_hurst,
139
+ }
140
+
141
+ self.set_data_from_widget_selection(parsed)
142
+
143
+ except Exception as e:
144
+ print(f"[MoleculeConfigWidget] Failed to load from config: {e}")
145
+
146
+ def get_data(self):
147
+ num_molecules = []
148
+ track_type = []
149
+ diffusion_coefficient = []
150
+ hurst_exponent = []
151
+ allow_transition_probability = []
152
+ transition_matrix_time_step = []
153
+ diffusion_transition_matrix = []
154
+ hurst_transition_matrix = []
155
+ state_probability_diffusion = []
156
+ state_probability_hurst = []
157
+
158
+ for widget in self.molecule_type_widgets:
159
+ type_data = widget.get_data()
160
+
161
+ num_molecules.append(type_data["num_molecules"])
162
+ track_type.append(type_data["track_type"])
163
+ diffusion_coefficient.append(type_data["diffusion_coefficient"])
164
+ hurst_exponent.append(type_data["hurst_exponent"])
165
+ allow_transition_probability.append(
166
+ type_data["allow_transition_probability"]
167
+ )
168
+ transition_matrix_time_step.append(type_data["transition_matrix_time_step"])
169
+ diffusion_transition_matrix.append(type_data["diffusion_transition_matrix"])
170
+ hurst_transition_matrix.append(type_data["hurst_transition_matrix"])
171
+ state_probability_diffusion.append(type_data["state_probability_diffusion"])
172
+ state_probability_hurst.append(type_data["state_probability_hurst"])
173
+
174
+ # Normalize shapes: make sure every list has the same shape
175
+ def ensure_nested_list_shape(lst, expected_len, default_val):
176
+ return [x if len(x) > 0 else [default_val] for x in lst]
177
+
178
+ def pad_matrix_list(mat_list, expected_size):
179
+ result = []
180
+ for mat in mat_list:
181
+ if len(mat) == 0:
182
+ result.append([[1.0]])
183
+ else:
184
+ result.append(mat)
185
+ return result
186
+
187
+ diffusion_coefficient = ensure_nested_list_shape(
188
+ diffusion_coefficient, len(num_molecules), 1.0
189
+ )
190
+ hurst_exponent = ensure_nested_list_shape(
191
+ hurst_exponent, len(num_molecules), 0.5
192
+ )
193
+ state_probability_diffusion = ensure_nested_list_shape(
194
+ state_probability_diffusion, len(num_molecules), 1.0
195
+ )
196
+ state_probability_hurst = ensure_nested_list_shape(
197
+ state_probability_hurst, len(num_molecules), 1.0
198
+ )
199
+
200
+ diffusion_transition_matrix = pad_matrix_list(
201
+ diffusion_transition_matrix, len(num_molecules)
202
+ )
203
+ hurst_transition_matrix = pad_matrix_list(
204
+ hurst_transition_matrix, len(num_molecules)
205
+ )
206
+ return {
207
+ "num_molecules": num_molecules,
208
+ "track_type": track_type,
209
+ "diffusion_coefficient": diffusion_coefficient,
210
+ "hurst_exponent": hurst_exponent,
211
+ "allow_transition_probability": allow_transition_probability,
212
+ "transition_matrix_time_step": transition_matrix_time_step,
213
+ "diffusion_transition_matrix": diffusion_transition_matrix,
214
+ "hurst_transition_matrix": hurst_transition_matrix,
215
+ "state_probability_diffusion": state_probability_diffusion,
216
+ "state_probability_hurst": state_probability_hurst,
217
+ }
218
+
219
+ def set_data_from_widget_selection(self, data):
220
+ num_types = min(len(data["num_molecules"]), len(self.molecule_type_widgets))
221
+ self.num_types_spinner.setValue(num_types)
222
+
223
+ for i in range(num_types):
224
+ type_data = {
225
+ "num_molecules": data["num_molecules"][i],
226
+ "track_type": data["track_type"][i],
227
+ "diffusion_coefficient": data["diffusion_coefficient"][i],
228
+ "hurst_exponent": data["hurst_exponent"][i],
229
+ "allow_transition_probability": data["allow_transition_probability"][i],
230
+ "transition_matrix_time_step": data["transition_matrix_time_step"][i],
231
+ "diffusion_transition_matrix": data["diffusion_transition_matrix"][i],
232
+ "hurst_transition_matrix": data["hurst_transition_matrix"][i],
233
+ "state_probability_diffusion": data["state_probability_diffusion"][i],
234
+ "state_probability_hurst": data["state_probability_hurst"][i],
235
+ }
236
+ self.molecule_type_widgets[i].set_data(type_data)
237
+
238
+
239
+ class MoleculeTypeWidget(QWidget):
240
+ def __init__(self, type_index):
241
+ super().__init__()
242
+ self.type_index = type_index
243
+
244
+ # Create a scroll area to handle potentially large configs
245
+ self.scroll = QScrollArea()
246
+ self.scroll.setWidgetResizable(True)
247
+
248
+ self.container = QWidget()
249
+ self.layout = QVBoxLayout(self.container)
250
+
251
+ # Basic parameters
252
+ self.form_layout = QFormLayout()
253
+
254
+ # Number of molecules
255
+ self.num_molecules = QSpinBox()
256
+ self.num_molecules.setRange(1, 100000)
257
+ self.num_molecules.setValue(100)
258
+ self.form_layout.addRow("Number of molecules:", self.num_molecules)
259
+
260
+ # Track type
261
+ self.track_type = QComboBox()
262
+ self.track_type.addItems(["constant", "fbm"])
263
+ self.track_type.currentTextChanged.connect(self.update_visibility)
264
+ self.form_layout.addRow("Track type:", self.track_type)
265
+
266
+ # Transition probability
267
+ self.allow_transition = QCheckBox("Allow transition probability")
268
+ self.allow_transition.setChecked(False)
269
+ self.allow_transition.stateChanged.connect(self.update_visibility)
270
+ self.form_layout.addRow("", self.allow_transition)
271
+
272
+ # Transition matrix time step
273
+ self.transition_time_step = QSpinBox()
274
+ self.transition_time_step.setRange(1, 10000)
275
+ self.transition_time_step.setValue(100)
276
+ self.transition_time_step.setSuffix(" ms")
277
+ self.form_layout.addRow(
278
+ "Transition matrix time step:", self.transition_time_step
279
+ )
280
+
281
+ self.layout.addLayout(self.form_layout)
282
+
283
+ # Diffusion coefficient section
284
+ self.diffusion_group = QWidget()
285
+ self.diffusion_layout = QVBoxLayout(self.diffusion_group)
286
+
287
+ self.diffusion_header = QHBoxLayout()
288
+ self.diffusion_label = QLabel("<b>Diffusion Coefficients</b>")
289
+ self.diffusion_count = QSpinBox()
290
+ self.diffusion_count.setValue(1)
291
+ self.diffusion_count.valueChanged.connect(self.update_diffusion_coefficients)
292
+ self.diffusion_header.addWidget(self.diffusion_label)
293
+ self.diffusion_header.addWidget(QLabel("Number of states:"))
294
+ self.diffusion_header.addWidget(self.diffusion_count)
295
+ self.diffusion_header.addStretch()
296
+
297
+ self.diffusion_layout.addLayout(self.diffusion_header)
298
+
299
+ self.diffusion_grid = QGridLayout()
300
+ self.diffusion_grid.addWidget(QLabel("Coefficient (μm²/s)"), 0, 0)
301
+ self.diffusion_grid.addWidget(
302
+ QLabel("Initial Fraction in this State (0-1)"), 0, 1
303
+ )
304
+
305
+ self.diffusion_coefficients = []
306
+ self.diffusion_amounts = []
307
+
308
+ self.update_diffusion_coefficients(1)
309
+ self.diffusion_layout.addLayout(self.diffusion_grid)
310
+
311
+ # Transition matrix for diffusion
312
+ self.diffusion_matrix_widget = TransitionMatrixWidget("Diffusion Coefficient")
313
+ self.diffusion_layout.addWidget(self.diffusion_matrix_widget)
314
+
315
+ self.layout.addWidget(self.diffusion_group)
316
+
317
+ # Hurst exponent section (visible only for fbm)
318
+ self.hurst_group = QWidget()
319
+ self.hurst_layout = QVBoxLayout(self.hurst_group)
320
+
321
+ self.hurst_header = QHBoxLayout()
322
+ self.hurst_label = QLabel("<b>Hurst Exponents</b>")
323
+ self.hurst_count = QSpinBox()
324
+ self.hurst_count.setRange(1, 10) # set minimum to 1
325
+ self.hurst_count.setValue(1)
326
+ self.hurst_count.valueChanged.connect(self.update_hurst_exponents)
327
+ self.hurst_header.addWidget(self.hurst_label)
328
+ self.hurst_header.addWidget(QLabel("Number of states:"))
329
+ self.hurst_header.addWidget(self.hurst_count)
330
+ self.hurst_header.addStretch()
331
+
332
+ self.hurst_layout.addLayout(self.hurst_header)
333
+
334
+ self.hurst_grid = QGridLayout()
335
+ self.hurst_grid.addWidget(QLabel("Exponent (0-1)"), 0, 0)
336
+ self.hurst_grid.addWidget(QLabel("Initial Fraction in this State (0-1)"), 0, 1)
337
+
338
+ self.hurst_exponents = []
339
+ self.hurst_amounts = []
340
+
341
+ self.update_hurst_exponents(1)
342
+ self.hurst_layout.addLayout(self.hurst_grid)
343
+
344
+ # Transition matrix for Hurst
345
+ self.hurst_matrix_widget = TransitionMatrixWidget("Hurst Exponent")
346
+ self.hurst_layout.addWidget(self.hurst_matrix_widget)
347
+
348
+ self.layout.addWidget(self.hurst_group)
349
+
350
+ # Set the container as the scroll area widget
351
+ self.scroll.setWidget(self.container)
352
+
353
+ # Main layout for this widget
354
+ main_layout = QVBoxLayout(self)
355
+ main_layout.addWidget(self.scroll)
356
+
357
+ # Connect signals for dependency updates
358
+ self.diffusion_count.valueChanged.connect(
359
+ lambda val: self.diffusion_matrix_widget.update_matrix_size(val)
360
+ )
361
+ self.hurst_count.valueChanged.connect(
362
+ lambda val: self.hurst_matrix_widget.update_matrix_size(val)
363
+ )
364
+
365
+ # Update visibility based on initial values
366
+ self.update_visibility()
367
+
368
+ def update_visibility(self):
369
+ track = self.track_type.currentText()
370
+ is_fbm = track == "fbm"
371
+ is_constant = track == "constant"
372
+ allow_transitions = self.allow_transition.isChecked()
373
+
374
+ # Hide/show all motion-related groups
375
+ self.diffusion_group.setVisible(not is_constant)
376
+ self.hurst_group.setVisible(is_fbm and not is_constant)
377
+
378
+ # Hide/show allow_transition checkbox and transition step field
379
+ self.allow_transition.setVisible(not is_constant)
380
+ label = self.form_layout.labelForField(self.transition_time_step)
381
+ if label:
382
+ label.setVisible(not is_constant and allow_transitions)
383
+ self.transition_time_step.setVisible(not is_constant and allow_transitions)
384
+
385
+ # Transition matrices visibility
386
+ self.diffusion_matrix_widget.setVisible(not is_constant and allow_transitions)
387
+ self.hurst_matrix_widget.setVisible(
388
+ not is_constant and is_fbm and allow_transitions
389
+ )
390
+
391
+ def update_diffusion_coefficients(self, count):
392
+ # Store current total amount
393
+ current_total = (
394
+ sum(spin.value() for spin in self.diffusion_amounts)
395
+ if self.diffusion_amounts
396
+ else 1.0
397
+ )
398
+
399
+ # Clear existing items
400
+ while len(self.diffusion_coefficients) > count:
401
+ # Remove the last row
402
+ self.diffusion_grid.removeWidget(self.diffusion_coefficients[-1])
403
+ self.diffusion_grid.removeWidget(self.diffusion_amounts[-1])
404
+ self.diffusion_coefficients[-1].deleteLater()
405
+ self.diffusion_amounts[-1].deleteLater()
406
+ self.diffusion_coefficients.pop()
407
+ self.diffusion_amounts.pop()
408
+
409
+ # Add new items if needed
410
+ while len(self.diffusion_coefficients) < count:
411
+ row = len(self.diffusion_coefficients) + 1
412
+ coeff = QDoubleSpinBox()
413
+ coeff.setRange(0, 1000)
414
+ coeff.setValue(1.0)
415
+ coeff.setSuffix(" μm²/s")
416
+ coeff.setDecimals(3)
417
+
418
+ amount = QDoubleSpinBox()
419
+ amount.setRange(0, 1)
420
+ amount.setValue(1.0 / count) # Default value will be adjusted later
421
+ amount.setSingleStep(0.1)
422
+ amount.setDecimals(2)
423
+ amount.valueChanged.connect(self.normalize_diffusion_amounts)
424
+
425
+ self.diffusion_grid.addWidget(coeff, row, 0)
426
+ self.diffusion_grid.addWidget(amount, row, 1)
427
+
428
+ self.diffusion_coefficients.append(coeff)
429
+ self.diffusion_amounts.append(amount)
430
+
431
+ # Normalize the amounts to sum to 1
432
+ self.normalize_diffusion_amounts()
433
+
434
+ def normalize_diffusion_amounts(self):
435
+ # Get the sender (which spin box was changed)
436
+ sender = self.sender()
437
+
438
+ # Skip if this wasn't triggered by a spin box change
439
+ if not sender or sender not in self.diffusion_amounts:
440
+ # Equal distribution
441
+ if self.diffusion_amounts:
442
+ for amount in self.diffusion_amounts:
443
+ amount.blockSignals(True)
444
+ amount.setValue(1.0 / len(self.diffusion_amounts))
445
+ amount.blockSignals(False)
446
+ return
447
+
448
+ # Get total and adjust other values proportionally
449
+ total = sum(spin.value() for spin in self.diffusion_amounts)
450
+
451
+ if total > 0:
452
+ # If total > 1, scale down others
453
+ if total > 1.0:
454
+ # How much we need to reduce by
455
+ excess = total - 1.0
456
+
457
+ # Get the sum of other amounts
458
+ other_sum = total - sender.value()
459
+
460
+ # Adjust each amount proportionally
461
+ for amount in self.diffusion_amounts:
462
+ if amount != sender:
463
+ if other_sum > 0:
464
+ # Reduce proportionally
465
+ amount.blockSignals(True)
466
+ new_value = max(
467
+ 0,
468
+ amount.value()
469
+ - (excess * (amount.value() / other_sum)),
470
+ )
471
+ amount.setValue(new_value)
472
+ amount.blockSignals(False)
473
+ else:
474
+ # If other sum is 0, we can't adjust proportionally
475
+ amount.blockSignals(True)
476
+ amount.setValue(0)
477
+ amount.blockSignals(False)
478
+
479
+ def update_hurst_exponents(self, count):
480
+ # Store current total amount
481
+ current_total = (
482
+ sum(spin.value() for spin in self.hurst_amounts)
483
+ if self.hurst_amounts
484
+ else 1.0
485
+ )
486
+
487
+ # Clear existing items
488
+ while len(self.hurst_exponents) > count:
489
+ # Remove the last row
490
+ self.hurst_grid.removeWidget(self.hurst_exponents[-1])
491
+ self.hurst_grid.removeWidget(self.hurst_amounts[-1])
492
+ self.hurst_exponents[-1].deleteLater()
493
+ self.hurst_amounts[-1].deleteLater()
494
+ self.hurst_exponents.pop()
495
+ self.hurst_amounts.pop()
496
+
497
+ # Add new items if needed
498
+ while len(self.hurst_exponents) < count:
499
+ row = len(self.hurst_exponents) + 1
500
+ exp = QDoubleSpinBox()
501
+ exp.setRange(0, 1)
502
+ exp.setValue(0.5)
503
+ exp.setSingleStep(0.1)
504
+ exp.setDecimals(2)
505
+
506
+ amount = QDoubleSpinBox()
507
+ amount.setRange(0, 1)
508
+ amount.setValue(1.0 / count) # Default value will be adjusted later
509
+ amount.setSingleStep(0.1)
510
+ amount.setDecimals(2)
511
+ amount.valueChanged.connect(self.normalize_hurst_amounts)
512
+
513
+ self.hurst_grid.addWidget(exp, row, 0)
514
+ self.hurst_grid.addWidget(amount, row, 1)
515
+
516
+ self.hurst_exponents.append(exp)
517
+ self.hurst_amounts.append(amount)
518
+
519
+ # Normalize the amounts to sum to 1
520
+ self.normalize_hurst_amounts()
521
+
522
+ def normalize_hurst_amounts(self):
523
+ # Get the sender (which spin box was changed)
524
+ sender = self.sender()
525
+
526
+ # Skip if this wasn't triggered by a spin box change
527
+ if not sender or sender not in self.hurst_amounts:
528
+ # Equal distribution
529
+ if self.hurst_amounts:
530
+ for amount in self.hurst_amounts:
531
+ amount.blockSignals(True)
532
+ amount.setValue(1.0 / len(self.hurst_amounts))
533
+ amount.blockSignals(False)
534
+ return
535
+
536
+ # Get total and adjust other values proportionally
537
+ total = sum(spin.value() for spin in self.hurst_amounts)
538
+
539
+ if total > 0:
540
+ # If total > 1, scale down others
541
+ if total > 1.0:
542
+ # How much we need to reduce by
543
+ excess = total - 1.0
544
+
545
+ # Get the sum of other amounts
546
+ other_sum = total - sender.value()
547
+
548
+ # Adjust each amount proportionally
549
+ for amount in self.hurst_amounts:
550
+ if amount != sender:
551
+ if other_sum > 0:
552
+ # Reduce proportionally
553
+ amount.blockSignals(True)
554
+ new_value = max(
555
+ 0,
556
+ amount.value()
557
+ - (excess * (amount.value() / other_sum)),
558
+ )
559
+ amount.setValue(new_value)
560
+ amount.blockSignals(False)
561
+ else:
562
+ # If other sum is 0, we can't adjust proportionally
563
+ amount.blockSignals(True)
564
+ amount.setValue(0)
565
+ amount.blockSignals(False)
566
+
567
+ def get_data(self):
568
+ # Get diffusion coefficients and state probabilities
569
+ diff_coeff = [spin.value() for spin in self.diffusion_coefficients]
570
+ diff_prob = [spin.value() for spin in self.diffusion_amounts]
571
+
572
+ # Get Hurst exponents and state probabilities (only if visible)
573
+ hurst_exp = (
574
+ [spin.value() for spin in self.hurst_exponents]
575
+ if self.hurst_group.isVisible()
576
+ else []
577
+ )
578
+ hurst_prob = (
579
+ [spin.value() for spin in self.hurst_amounts]
580
+ if self.hurst_group.isVisible()
581
+ else []
582
+ )
583
+
584
+ # Transition matrices
585
+ diff_matrix = self.diffusion_matrix_widget.get_matrix()
586
+ hurst_matrix = (
587
+ self.hurst_matrix_widget.get_matrix()
588
+ if self.hurst_group.isVisible()
589
+ else []
590
+ )
591
+
592
+ return {
593
+ "num_molecules": self.num_molecules.value(),
594
+ "track_type": self.track_type.currentText(),
595
+ "diffusion_coefficient": diff_coeff,
596
+ "hurst_exponent": hurst_exp,
597
+ "allow_transition_probability": self.allow_transition.isChecked(),
598
+ "transition_matrix_time_step": self.transition_time_step.value(),
599
+ "diffusion_transition_matrix": diff_matrix,
600
+ "hurst_transition_matrix": hurst_matrix,
601
+ "state_probability_diffusion": diff_prob,
602
+ "state_probability_hurst": hurst_prob,
603
+ }
604
+
605
+ def set_data(self, data):
606
+ self.num_molecules.setValue(data["num_molecules"])
607
+
608
+ index = self.track_type.findText(data["track_type"])
609
+ if index >= 0:
610
+ self.track_type.setCurrentIndex(index)
611
+
612
+ self.allow_transition.setChecked(data["allow_transition_probability"])
613
+ self.transition_time_step.setValue(data["transition_matrix_time_step"])
614
+
615
+ # Diffusion Coefficients
616
+ diff_count = len(data["diffusion_coefficient"])
617
+ self.diffusion_count.setValue(diff_count)
618
+ for i in range(diff_count):
619
+ if i < len(self.diffusion_coefficients):
620
+ self.diffusion_coefficients[i].setValue(
621
+ data["diffusion_coefficient"][i]
622
+ )
623
+ self.diffusion_amounts[i].setValue(
624
+ data["state_probability_diffusion"][i]
625
+ )
626
+
627
+ # Hurst Exponents (only if track_type == "fbm")
628
+ if data["track_type"] == "fbm":
629
+ hurst_count = max(1, len(data["hurst_exponent"])) # ensure at least 1
630
+ self.hurst_count.setValue(hurst_count)
631
+ for i in range(hurst_count):
632
+ if i < len(self.hurst_exponents):
633
+ self.hurst_exponents[i].setValue(data["hurst_exponent"][i])
634
+ self.hurst_amounts[i].setValue(data["state_probability_hurst"][i])
635
+
636
+ self.hurst_matrix_widget.set_matrix(data["hurst_transition_matrix"])
637
+ else:
638
+ self.hurst_count.setValue(0)
639
+
640
+ # Set diffusion matrix
641
+ self.diffusion_matrix_widget.set_matrix(data["diffusion_transition_matrix"])
642
+
643
+ # Refresh UI visibility
644
+ self.update_visibility()
645
+
646
+
647
+ class TransitionMatrixWidget(QWidget):
648
+ def __init__(self, title):
649
+ super().__init__()
650
+ self.layout = QVBoxLayout(self)
651
+
652
+ self.layout.addWidget(QLabel(f"<b>{title} Transition Matrix</b>"))
653
+ self.layout.addWidget(
654
+ QLabel(
655
+ "Probabilities of transitioning between states (rows must sum to 1.0):"
656
+ )
657
+ )
658
+
659
+ self.grid_container = QWidget()
660
+ self.grid_layout = QGridLayout(self.grid_container)
661
+ self.layout.addWidget(self.grid_container)
662
+
663
+ self.spinboxes = []
664
+ self.update_matrix_size(1)
665
+
666
+ def update_matrix_size(self, size):
667
+ # Clear existing grid
668
+ while self.grid_layout.count():
669
+ item = self.grid_layout.takeAt(0)
670
+ if item.widget():
671
+ item.widget().deleteLater()
672
+
673
+ self.spinboxes = []
674
+
675
+ # Add column headers (to state)
676
+ for i in range(size):
677
+ self.grid_layout.addWidget(QLabel(f"To {i+1}"), 0, i + 1)
678
+
679
+ # Add row headers (from state)
680
+ for i in range(size):
681
+ self.grid_layout.addWidget(QLabel(f"From {i+1}"), i + 1, 0)
682
+
683
+ # Create transition probability spinboxes
684
+ for i in range(size):
685
+ row = []
686
+ for j in range(size):
687
+ spin = QDoubleSpinBox()
688
+ spin.setRange(0, 1)
689
+ spin.setValue(
690
+ 1.0 / size if i == j else 0.0
691
+ ) # Default to self-transitions
692
+ spin.setSingleStep(0.1)
693
+ spin.setDecimals(2)
694
+ self.grid_layout.addWidget(spin, i + 1, j + 1)
695
+ row.append(spin)
696
+ self.spinboxes.append(row)
697
+
698
+ def get_matrix(self):
699
+ matrix = []
700
+ for row in self.spinboxes:
701
+ matrix.append([spin.value() for spin in row])
702
+ return matrix
703
+
704
+ def set_matrix(self, matrix):
705
+ size = len(matrix)
706
+ if size != len(self.spinboxes):
707
+ self.update_matrix_size(size)
708
+
709
+ for i in range(size):
710
+ for j in range(min(size, len(matrix[i]))):
711
+ self.spinboxes[i][j].setValue(matrix[i][j])
712
+
713
+ def get_help_path(self) -> Path:
714
+ return Path(__file__).parent.parent / "help_docs" / "molecule_help.md"