bindmc 0.1.7__tar.gz → 0.1.9__tar.gz

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 (43) hide show
  1. {bindmc-0.1.7 → bindmc-0.1.9}/PKG-INFO +1 -1
  2. {bindmc-0.1.7 → bindmc-0.1.9}/pyproject.toml +1 -1
  3. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/classes/ExptData.py +85 -2
  4. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/components/data_import.py +197 -86
  5. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/components/data_model.py +52 -47
  6. bindmc-0.1.9/src/bindmc/webgui/components/dataset_selector.py +202 -0
  7. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/components/graph.py +39 -22
  8. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/state/statemanager.py +12 -0
  9. {bindmc-0.1.7 → bindmc-0.1.9}/README.md +0 -0
  10. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/__init__.py +0 -0
  11. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/__main__.py +0 -0
  12. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/main.py +0 -0
  13. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/Class model.md +0 -0
  14. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/TODO.md +0 -0
  15. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/TODO_old.md +0 -0
  16. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/__init__.py +0 -0
  17. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/app.py +0 -0
  18. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/classes/BindingConstant.py +0 -0
  19. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/classes/ChemicalShiftParam.py +0 -0
  20. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/classes/Component.py +0 -0
  21. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/classes/ExptDataType.py +0 -0
  22. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/classes/FitResult.py +0 -0
  23. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/classes/MCMCSim.py +0 -0
  24. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/classes/Model.py +0 -0
  25. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/classes/RawData.py +0 -0
  26. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/classes/Simulation.py +0 -0
  27. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/classes/UIBindings.py +0 -0
  28. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/classes/__init__.py +0 -0
  29. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/components/__init__.py +0 -0
  30. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/components/base.py +0 -0
  31. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/components/bayes.py +0 -0
  32. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/components/bayes_priors.py +0 -0
  33. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/components/binding_model.py +0 -0
  34. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/components/body.py +0 -0
  35. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/components/data_gen.py +0 -0
  36. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/components/fitting.py +0 -0
  37. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/components/header.py +0 -0
  38. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/components/simulation.py +0 -0
  39. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/default_models.json +0 -0
  40. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/export/__init__.py +0 -0
  41. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/export/notebook_exporter.py +0 -0
  42. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/state/__init__.py +0 -0
  43. {bindmc-0.1.7 → bindmc-0.1.9}/src/bindmc/webgui/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: bindmc
3
- Version: 0.1.7
3
+ Version: 0.1.9
4
4
  Keywords: chemistry,analytical chemistry,binding constants,supramolecular
5
5
  Author: Martin Peeks
6
6
  Author-email: Martin Peeks <martinp23@googlemail.com>, m.peeks@unsw.edu.au
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "bindmc"
7
- version = "0.1.7"
7
+ version = "0.1.9"
8
8
  readme = "README.md"
9
9
  keywords = ["chemistry", "analytical chemistry", "binding constants", "supramolecular"]
10
10
  classifiers = [
@@ -66,6 +66,11 @@ class ExptData:
66
66
  self.col_to_comp = np.array(self.col_to_comp, dtype=float)
67
67
  if not isinstance(self.integ_to_spec, np.ndarray):
68
68
  self.integ_to_spec = np.array(self.integ_to_spec, dtype=float)
69
+ if not isinstance(self.delta_to_spec, np.ndarray):
70
+ if self.delta_to_spec is not None:
71
+ self.delta_to_spec = np.array(self.delta_to_spec, dtype=object)
72
+ else:
73
+ self.delta_to_spec = np.array([], dtype=object)
69
74
 
70
75
  # if len(self.col_details) != len(self.data.columns):
71
76
  # # Initialize col_details if it doesn't match the number of columns
@@ -81,6 +86,10 @@ class ExptData:
81
86
  if isinstance(self.model_id, str) and self.model_id != "None":
82
87
  self.model_id = uuid.UUID(self.model_id)
83
88
 
89
+ if not isinstance(self.raw_data_id, uuid.UUID):
90
+ if isinstance(self.raw_data_id, str) and self.raw_data_id != "None":
91
+ self.raw_data_id = uuid.UUID(self.raw_data_id)
92
+
84
93
  if isinstance(self._model, Model) and not self.model_id:
85
94
  self.model_id = self._model.id
86
95
 
@@ -155,6 +164,7 @@ class ExptData:
155
164
  @property
156
165
  def columns(self) -> list[str]:
157
166
  """Get the column names of the selected experimental data."""
167
+ self.reconcile_matrices()
158
168
  return self.selected_data.columns.tolist() if not self.selected_data.empty else []
159
169
 
160
170
  @property
@@ -330,7 +340,7 @@ class ExptData:
330
340
  # ),
331
341
  "col_to_comp": self.col_to_comp.tolist() if isinstance(self.col_to_comp, np.ndarray) else [],
332
342
  "integ_to_spec": self.integ_to_spec.tolist() if isinstance(self.integ_to_spec, np.ndarray) else [],
333
- "col_details": self.col_details if isinstance(self.col_details, dict) else {},
343
+ "col_details": {k: dict(v) if isinstance(v, dict) else v for k, v in self.col_details.items()} if isinstance(self.col_details, dict) else {},
334
344
  "id": str(self.id) if self.id else "",
335
345
  "model_id": str(self.model_id) if hasattr(self, "model_id") else "",
336
346
  "raw_data_id": str(self.raw_data_id) if hasattr(self, "raw_data_id") else "",
@@ -339,7 +349,7 @@ class ExptData:
339
349
  # limiting_shifts as a list for JSON safety (tuple keys not JSON-serializable)
340
350
  "limiting_shifts": [asdict(v) for v in self.limiting_shifts.values()],
341
351
  "is_analytical_fast_ex": self.is_analytical_fast_ex,
342
- "selected_columns": self.selected_columns,
352
+ "selected_columns": list(self.selected_columns),
343
353
  "dark_species": {col: list(species) for col, species in self.dark_species.items()},
344
354
  }
345
355
 
@@ -487,3 +497,76 @@ class ExptData:
487
497
  serialized_row.append(None)
488
498
  serialized_rows.append(serialized_row)
489
499
  return serialized_rows
500
+
501
+ def reconcile_matrices(self) -> None:
502
+ """Align col_to_comp, integ_to_spec, and delta_to_spec with selected_columns and col_details."""
503
+ current_sel_cols = self.selected_columns
504
+ n_comp_cols = len(current_sel_cols)
505
+
506
+ # 1. col_to_comp reconciliation
507
+ if isinstance(self.col_to_comp, np.ndarray) and self.col_to_comp.ndim == 2:
508
+ if not hasattr(self, "_matrix_columns") or len(self._matrix_columns) != self.col_to_comp.shape[1]:
509
+ if self.col_to_comp.shape[1] == n_comp_cols:
510
+ self._matrix_columns = list(current_sel_cols)
511
+ else:
512
+ self._matrix_columns = list(current_sel_cols)[:self.col_to_comp.shape[1]]
513
+ if len(self._matrix_columns) < self.col_to_comp.shape[1]:
514
+ self._matrix_columns += [f"__unknown_col_{i}__" for i in range(self.col_to_comp.shape[1] - len(self._matrix_columns))]
515
+
516
+ if self._matrix_columns != current_sel_cols:
517
+ new_col_to_comp = np.zeros((self.col_to_comp.shape[0], len(current_sel_cols)), dtype=float)
518
+ for new_idx, col_name in enumerate(current_sel_cols):
519
+ if col_name in self._matrix_columns:
520
+ old_idx = self._matrix_columns.index(col_name)
521
+ new_col_to_comp[:, new_idx] = self.col_to_comp[:, old_idx]
522
+ self.col_to_comp = new_col_to_comp
523
+ self._matrix_columns = list(current_sel_cols)
524
+
525
+ # 2. integ_to_spec reconciliation
526
+ if isinstance(self.integ_to_spec, np.ndarray) and self.integ_to_spec.ndim == 2 and self.integ_to_spec.size > 0:
527
+ if not hasattr(self, "_matrix_integ_columns") or len(self._matrix_integ_columns) != self.integ_to_spec.shape[1]:
528
+ if self.integ_to_spec.shape[1] == n_comp_cols:
529
+ self._matrix_integ_columns = list(current_sel_cols)
530
+ else:
531
+ self._matrix_integ_columns = list(current_sel_cols)[:self.integ_to_spec.shape[1]]
532
+ if len(self._matrix_integ_columns) < self.integ_to_spec.shape[1]:
533
+ self._matrix_integ_columns += [f"__unknown_col_{i}__" for i in range(self.integ_to_spec.shape[1] - len(self._matrix_integ_columns))]
534
+
535
+ if self._matrix_integ_columns != current_sel_cols:
536
+ new_integ_to_spec = np.zeros((self.integ_to_spec.shape[0], len(current_sel_cols)), dtype=float)
537
+ for new_idx, col_name in enumerate(current_sel_cols):
538
+ if col_name in self._matrix_integ_columns:
539
+ old_idx = self._matrix_integ_columns.index(col_name)
540
+ new_integ_to_spec[:, new_idx] = self.integ_to_spec[:, old_idx]
541
+ self.integ_to_spec = new_integ_to_spec
542
+ self._matrix_integ_columns = list(current_sel_cols)
543
+
544
+ # 3. delta_to_spec reconciliation
545
+ current_fast_ex_cols = []
546
+ if self.col_details:
547
+ for name, col in self.col_details.items():
548
+ if col.get("dtype") is None:
549
+ continue
550
+ dtype_key = str(col.get("dtype", "")).lower()
551
+ if col.get("depindep") == "dep" and ("delta" in dtype_key or "ppm" in dtype_key or "shift" in dtype_key):
552
+ current_fast_ex_cols.append(name)
553
+
554
+ if isinstance(self.delta_to_spec, np.ndarray) and self.delta_to_spec.ndim == 2 and self.delta_to_spec.size > 0:
555
+ if not hasattr(self, "_matrix_fast_ex_columns") or len(self._matrix_fast_ex_columns) != self.delta_to_spec.shape[0]:
556
+ if self.delta_to_spec.shape[0] == len(current_fast_ex_cols):
557
+ self._matrix_fast_ex_columns = list(current_fast_ex_cols)
558
+ else:
559
+ self._matrix_fast_ex_columns = list(current_fast_ex_cols)[:self.delta_to_spec.shape[0]]
560
+ if len(self._matrix_fast_ex_columns) < self.delta_to_spec.shape[0]:
561
+ self._matrix_fast_ex_columns += [f"__unknown_fast_col_{i}__" for i in range(self.delta_to_spec.shape[0] - len(self._matrix_fast_ex_columns))]
562
+
563
+ if self._matrix_fast_ex_columns != current_fast_ex_cols:
564
+ n_species = self.delta_to_spec.shape[1]
565
+ new_delta_to_spec = np.empty((len(current_fast_ex_cols), n_species), dtype=object)
566
+ new_delta_to_spec.fill(None)
567
+ for new_idx, col_name in enumerate(current_fast_ex_cols):
568
+ if col_name in self._matrix_fast_ex_columns:
569
+ old_idx = self._matrix_fast_ex_columns.index(col_name)
570
+ new_delta_to_spec[new_idx, :] = self.delta_to_spec[old_idx, :]
571
+ self.delta_to_spec = new_delta_to_spec
572
+ self._matrix_fast_ex_columns = list(current_fast_ex_cols)
@@ -7,6 +7,7 @@ from nicegui.events import UploadEventArguments, ClickEventArguments
7
7
 
8
8
  from .base import BaseComponent
9
9
  from .graph import Graph
10
+ from .dataset_selector import DatasetSelector
10
11
  from ..classes import ExptData, RawData, ExptDataType
11
12
 
12
13
 
@@ -19,6 +20,7 @@ class DataImportPanel(BaseComponent):
19
20
 
20
21
  self.dtype_labels = []
21
22
  self.dtype_dropdowns = []
23
+ self._restore_point = self._backup_expt(self.sm.active_expt_data_or_none)
22
24
 
23
25
  def setup_nicegui(self):
24
26
  self.container = ui.column().classes("w-full")
@@ -30,22 +32,7 @@ class DataImportPanel(BaseComponent):
30
32
  # - wide screens: show cards side-by-side (controls left, table/graph right)
31
33
  with ui.row().classes("w-full gap-4 items-start flex-col lg:flex-row"):
32
34
  with ui.card().classes("w-full lg:flex-1 min-w-0"):
33
- active_expt = self.sm.active_expt_data_or_none
34
- if active_expt is not None:
35
- self.expt_data_dropdown_button = (
36
- ui.dropdown_button("Choose dataset", auto_close=True)
37
- .bind_text(active_expt, "name")
38
- .classes("mb-5")
39
- )
40
- else:
41
- self.expt_data_dropdown_button = ui.dropdown_button("No active model", auto_close=True).classes(
42
- "mb-5"
43
- )
44
-
45
- if len(self.sm.raw_datas) > 0:
46
- self.generate_data_dropdown()
47
- else:
48
- self.expt_data_dropdown_button.visible = False
35
+ self.selector = DatasetSelector(self.sm)
49
36
 
50
37
  ui.label("Upload Data File (CSV or Excel)")
51
38
  ui.button("Upload File", on_click=self.load_exptdata)
@@ -94,42 +81,219 @@ class DataImportPanel(BaseComponent):
94
81
  def setup_bindings(self):
95
82
  super().setup_bindings()
96
83
  self.sm.add_listener("data_imported", self._load_data_to_table)
97
- self.sm.add_listener("data_imported", self.generate_data_dropdown)
98
84
  self.sm.add_listener("active_context_changed", self._load_data_to_table)
99
- self.sm.add_listener("active_context_changed", self.generate_data_dropdown)
100
85
  self.sm.add_listener(
101
86
  "expt_data_columns_changed", self._load_data_to_table
102
87
  ) # Update when column selection changes
103
88
 
89
+ def _backup_expt(self, old_expt: ExptData) -> ExptData:
90
+ """Return a copy of old_expt with the SAME UUID and name (a true backup)."""
91
+ if old_expt is None:
92
+ return None
93
+ import uuid
94
+ from dataclasses import asdict
95
+ from ..classes import ChemicalShiftParam
96
+ d = old_expt.to_dict()
97
+ limiting_shifts_raw = d.pop("limiting_shifts", []) or []
98
+ new_expt = ExptData(**d)
99
+ new_expt.limiting_shifts = {}
100
+ for cs in limiting_shifts_raw:
101
+ csp = ChemicalShiftParam(**cs)
102
+ key = (csp.species, csp.col)
103
+ new_expt.limiting_shifts[key] = csp
104
+ new_expt.find_and_link_model(self.sm.models)
105
+ new_expt.find_and_link_raw_data(self.sm.raw_datas)
106
+ return new_expt
107
+
108
+
109
+
104
110
  def prepare_data_model(self, e: ClickEventArguments):
105
111
  active_raw = self.sm.active_raw_data_or_none
106
112
  active_expt = self.sm.active_expt_data_or_none
107
113
  if self.sm.active_raw_data_id is not None and active_raw is not None:
108
114
  if active_expt is not None and active_expt.raw_data_id == active_raw.id:
109
- # Auto-deselect unassigned columns
115
+ # Check if selected columns or details have changed from the restore point
116
+ has_changes = False
117
+ if hasattr(self, "_restore_point") and self._restore_point is not None:
118
+ if set(active_expt.selected_columns) != set(self._restore_point.selected_columns):
119
+ has_changes = True
120
+ else:
121
+ for col in active_expt.selected_columns:
122
+ old_det = self._restore_point.col_details.get(col, {})
123
+ new_det = active_expt.col_details.get(col, {})
124
+ if old_det.get("depindep") != new_det.get("depindep") or old_det.get("dtype") != new_det.get("dtype"):
125
+ has_changes = True
126
+ break
127
+
128
+ # Check if there are changes in the unassigned auto-deselect columns too
110
129
  for col in active_expt.data.columns:
111
130
  col_details = active_expt.col_details.get(col, {})
112
131
  is_component = col.startswith("[") and col.endswith("]")
113
132
  has_assignment = (col_details.get("depindep") in ["dep", "indep"]) and (
114
133
  col_details.get("dtype") is not None
115
134
  )
116
-
117
135
  if not is_component and not has_assignment:
118
- # Remove from selected columns instead of marking as ignored
119
136
  if col in active_expt.selected_columns:
120
- active_expt.selected_columns.remove(col)
121
-
122
- self._load_expt_data_col_details() # Refresh UI to show changes
123
- self._load_data_to_table() # Refresh table and graph to show selected data
124
- ui.notify("Data model prepared. Unassigned columns have been deselected.", type="positive")
137
+ has_changes = True
138
+
139
+ if has_changes:
140
+ # Detect if there is existing work associated with this ExptData
141
+ has_model_work = False
142
+ if (
143
+ (active_expt.limiting_shifts and len(active_expt.limiting_shifts) > 0)
144
+ or (active_expt.delta_to_spec is not None and active_expt.delta_to_spec.size > 0)
145
+ or (active_expt.integ_to_spec is not None and active_expt.integ_to_spec.size > 0)
146
+ ):
147
+ has_model_work = True
148
+
149
+ has_fits = any(fit.expt_data_id == active_expt.id for fit in self.sm.fits.values())
150
+ has_mcmc = any(mcmc.expt_data_id == active_expt.id for mcmc in self.sm.mcmcs.values())
151
+
152
+ if has_model_work or has_fits or has_mcmc:
153
+ # Existing work exists, prompt user to Overwrite or Rename
154
+ self.prompt_overwrite_or_rename(active_expt, has_fits, has_mcmc, has_model_work)
155
+ else:
156
+ # No existing work to lose, perform direct overwrite in-place
157
+ self.prepare_data_model_overwrite(active_expt, delete_dependents=False)
158
+ else:
159
+ self._load_expt_data_col_details()
160
+ self._load_data_to_table()
161
+ ui.notify("Data model prepared.", type="positive")
125
162
  else:
126
163
  rd = active_raw
127
164
  new_expt_data = ExptData(name=rd.filename, init_raw_data=rd, init_model=self.sm.active_model)
128
165
  self.sm.add_expt_data(new_expt_data)
129
- self.sm.notify_listeners("data_imported") # Trigger table and graph update
166
+ self._restore_point = self._backup_expt(new_expt_data)
167
+ self.sm.notify_listeners("data_imported")
130
168
  else:
131
169
  ui.notify("No raw data selected to prepare data model from.", type="negative")
132
170
 
171
+ def prompt_overwrite_or_rename(self, active_expt, has_fits, has_mcmc, has_model_work):
172
+ with ui.dialog() as dialog, ui.card().classes("p-4"):
173
+ ui.label("Existing Work Detected").classes("text-lg font-bold")
174
+
175
+ msg = f"The active data model '{active_expt.name}' has existing work associated with it"
176
+ reasons = []
177
+ if has_model_work:
178
+ reasons.append("data model configuration")
179
+ if has_fits:
180
+ reasons.append("dependent fit results")
181
+ if has_mcmc:
182
+ reasons.append("MCMC simulation results")
183
+ msg += " (" + ", ".join(reasons) + ")."
184
+
185
+ ui.label(msg).classes("text-sm text-gray-700 mb-2")
186
+
187
+ if has_fits or has_mcmc:
188
+ ui.label(
189
+ "WARNING: Overwriting this data model will PERMANENTLY delete all dependent fits and MCMC simulations."
190
+ ).classes("text-sm text-red-600 font-semibold mb-4")
191
+
192
+ with ui.row().classes("justify-end gap-2 w-full"):
193
+ ui.button("Cancel", on_click=dialog.close).props("flat")
194
+
195
+ # Rename / Create New button
196
+ def on_rename():
197
+ dialog.close()
198
+ self.prepare_data_model_rename(active_expt)
199
+ ui.button("Rename (Create New)", on_click=on_rename).props("outline color=primary")
200
+
201
+ # Overwrite button
202
+ def on_overwrite():
203
+ dialog.close()
204
+ self.prepare_data_model_overwrite(active_expt, delete_dependents=True)
205
+ ui.button("Overwrite", on_click=on_overwrite).props("unelevated color=negative")
206
+
207
+ def prepare_data_model_overwrite(self, active_expt, delete_dependents=True):
208
+ # Auto-deselect unassigned columns in place
209
+ for col in active_expt.data.columns:
210
+ col_details = active_expt.col_details.get(col, {})
211
+ is_component = col.startswith("[") and col.endswith("]")
212
+ has_assignment = (col_details.get("depindep") in ["dep", "indep"]) and (
213
+ col_details.get("dtype") is not None
214
+ )
215
+ if not is_component and not has_assignment:
216
+ if col in active_expt.selected_columns:
217
+ active_expt.selected_columns.remove(col)
218
+
219
+ # In-place reconciliation
220
+ active_expt.reconcile_matrices()
221
+
222
+ if delete_dependents:
223
+ # Delete dependent fits & mcmcs
224
+ to_delete = []
225
+ for fit in list(self.sm.fits.values()):
226
+ if fit.expt_data_id == active_expt.id:
227
+ to_delete.append(fit)
228
+ for mcmc in list(self.sm.mcmcs.values()):
229
+ if mcmc.expt_data_id == active_expt.id:
230
+ to_delete.append(mcmc)
231
+
232
+ for obj in to_delete:
233
+ self.sm.delete_object(obj)
234
+
235
+ # Update restore point to current reconciled state
236
+ self._restore_point = self._backup_expt(active_expt)
237
+
238
+ self._load_expt_data_col_details()
239
+ self._load_data_to_table()
240
+ ui.notify(f"Data model '{active_expt.name}' updated and reconciled.", type="positive")
241
+
242
+ def prepare_data_model_rename(self, active_expt):
243
+ import numpy as np
244
+ from dataclasses import asdict
245
+ from ..classes import ChemicalShiftParam
246
+
247
+ # 1. Clone the current mutated active_expt to create a new version
248
+ existing_names = [ed.name for ed in self.sm.expt_datas.values()]
249
+ new_name = self.selector.get_next_version_name(self._restore_point.name, existing_names)
250
+ target = self.selector._clone_expt_data(active_expt, new_name)
251
+
252
+ # 2. Seed target's old matrix column associations from the restore point
253
+ target._matrix_columns = list(self._restore_point.selected_columns)
254
+ if self._restore_point.integ_to_spec is not None:
255
+ target._matrix_integ_columns = list(self._restore_point.selected_columns)
256
+
257
+ fast_ex_cols = []
258
+ if self._restore_point.col_details:
259
+ for name, col in self._restore_point.col_details.items():
260
+ if col.get("dtype") is None:
261
+ continue
262
+ dtype_key = str(col.get("dtype", "")).lower()
263
+ if col.get("depindep") == "dep" and ("delta" in dtype_key or "ppm" in dtype_key or "shift" in dtype_key):
264
+ fast_ex_cols.append(name)
265
+ target._matrix_fast_ex_columns = fast_ex_cols
266
+
267
+ # Auto-deselect unassigned columns in place on the clone
268
+ for col in target.data.columns:
269
+ col_details = target.col_details.get(col, {})
270
+ is_component = col.startswith("[") and col.endswith("]")
271
+ has_assignment = (col_details.get("depindep") in ["dep", "indep"]) and (
272
+ col_details.get("dtype") is not None
273
+ )
274
+ if not is_component and not has_assignment:
275
+ if col in target.selected_columns:
276
+ target.selected_columns.remove(col)
277
+
278
+ # 3. Reconcile the new clone
279
+ target.reconcile_matrices()
280
+
281
+ # 4. Restore the original active_expt back to the restore point
282
+ active_expt.selected_columns = list(self._restore_point.selected_columns)
283
+ active_expt.col_details = {k: dict(v) for k, v in self._restore_point.col_details.items()}
284
+ active_expt.col_to_comp = np.copy(self._restore_point.col_to_comp) if isinstance(self._restore_point.col_to_comp, np.ndarray) else self._restore_point.col_to_comp
285
+ active_expt.integ_to_spec = np.copy(self._restore_point.integ_to_spec) if isinstance(self._restore_point.integ_to_spec, np.ndarray) else self._restore_point.integ_to_spec
286
+ active_expt.delta_to_spec = np.copy(self._restore_point.delta_to_spec) if isinstance(self._restore_point.delta_to_spec, np.ndarray) else self._restore_point.delta_to_spec
287
+ active_expt.limiting_shifts = {k: ChemicalShiftParam(**asdict(v)) for k, v in self._restore_point.limiting_shifts.items()}
288
+
289
+ # 5. Add the clone to StateManager (becomes active, reconciles active ID, notify context changed)
290
+ self.sm.add_expt_data(target)
291
+ self._restore_point = self._backup_expt(target)
292
+ self.sm.notify_listeners("active_context_changed")
293
+ self.sm.notify_listeners("data_imported")
294
+
295
+ ui.notify(f"New data model version '{new_name}' prepared. Original version preserved.", type="positive")
296
+
133
297
  async def load_exptdata(self):
134
298
  try:
135
299
  with ui.dialog() as dialog, ui.card():
@@ -237,6 +401,12 @@ class DataImportPanel(BaseComponent):
237
401
  self.dtype_labels.clear()
238
402
  self.dtype_dropdowns.clear()
239
403
 
404
+ # Update restore point if active context has changed
405
+ active_expt = self.sm.active_expt_data_or_none
406
+ if active_expt is not None:
407
+ if not hasattr(self, "_restore_point") or self._restore_point is None or self._restore_point.id != active_expt.id:
408
+ self._restore_point = self._backup_expt(active_expt)
409
+
240
410
  with self.expt_data_col_block:
241
411
  if not self.sm.active_expt_data.data.empty:
242
412
  ui.label("Experimental Data Columns:")
@@ -398,63 +568,4 @@ class DataImportPanel(BaseComponent):
398
568
  ui.notify(f"New experimental data type '{name_input.value}' added.", type="positive")
399
569
  self._load_expt_data_col_details()
400
570
 
401
- def generate_data_dropdown(self, e=None):
402
- """Generate the dropdown for selecting experimental data."""
403
-
404
- self.expt_data_dropdown_button.clear()
405
- if len(self.sm.expt_datas) > 0 and len(self.sm.raw_datas) > 0:
406
- self.expt_data_dropdown_button.visible = True
407
- with self.expt_data_dropdown_button:
408
- self.expt_data_dropdown_rows = []
409
- for m in self.sm.raw_datas.values():
410
- with ui.row().classes("p-5 items-center") as x:
411
- self.expt_data_dropdown_rows.append(x)
412
- with ui.item(on_click=lambda m=m: self.load_raw_data(m)):
413
- ui.item_label(m.filename)
414
- ui.icon("delete").on("click", lambda m=m: self.delete_raw_data(m)).classes(
415
- "cursor-pointer text-red-600"
416
- )
417
- # ui.item("Add a new model...", on_click= self.add_new_expt_data)
418
- else:
419
- self.expt_data_dropdown_button.visible = False
420
- active_raw = self.sm.active_raw_data_or_none
421
- if active_raw is not None and hasattr(active_raw, "filename"):
422
- self.expt_data_dropdown_button.bind_text_from(active_raw, "filename")
423
-
424
- def load_raw_data(self, raw_data):
425
- self.sm.active_raw_data_id = raw_data.id
426
- active_expt = self.sm.active_expt_data_or_none
427
- if active_expt is None or active_expt.raw_data_id != raw_data.id:
428
- self.sm.active_expt_data_id = None # reset active expt data if raw data changes
429
- # activate the newest expt data that uses this raw data
430
- for ed in reversed(list(self.sm.expt_datas.values())):
431
- if ed.raw_data_id == raw_data.id:
432
- self.sm.active_expt_data_id = ed.id
433
- break
434
-
435
- active_fit = self.sm.active_fit_or_none
436
- if active_fit is None or active_fit.expt_data_id != self.sm.active_expt_data_id:
437
- self.sm.active_fit_id = None # reset active fit if expt data changes
438
- # activate the newest fit that uses this expt data
439
- for f in reversed(list(self.sm.fits.values())):
440
- if f.expt_data_id == self.sm.active_expt_data_id:
441
- self.sm.active_fit_id = f.id
442
- break
443
-
444
- self.sm.reconcile_active_context(reason="load_raw_data_selection", emit_events=True)
445
- self.sm.notify_listeners("data_imported")
446
571
 
447
- def delete_raw_data(self, raw_data):
448
- self.sm.delete_raw_data(raw_data)
449
- # objs_to_delete = []
450
- # for f in self.sm.expt_datas.values():
451
- # if f.raw_data_id == raw_data.id:
452
- # objs_to_delete.append(f)
453
- # for ff in self.sm.fits.values():
454
- # if ff.expt_data_id == f.id:
455
- # objs_to_delete.append(ff)
456
- # for obj in objs_to_delete:
457
- # self.sm.delete_object(obj)
458
-
459
- def add_new_raw_data(self):
460
- pass
@@ -6,6 +6,7 @@ from nicegui.events import ClickEventArguments
6
6
  import numpy as np
7
7
 
8
8
  from .base import BaseComponent
9
+ from .dataset_selector import DatasetSelector
9
10
  from ..classes import ChemicalShiftParam, ExptData
10
11
  from ..utils import _infer_simple_fast_exchange_topology
11
12
 
@@ -33,11 +34,7 @@ class DataModelPanel(BaseComponent):
33
34
  self.container = ui.column().classes("w-full")
34
35
 
35
36
  with self.container:
36
- ui.label("Data model setup panel").classes("text-lg font-bold mb-4")
37
-
38
- self.data_model_inp = (
39
- ui.input("Data model name", placeholder="Enter data model name").classes("mb-5").props("clearable")
40
- )
37
+ self.selector = DatasetSelector(self.sm)
41
38
 
42
39
  ui.label("Columns").classes("text-md font-semibold mt-4 mb-2")
43
40
  self.dataModel_col_block = ui.element()
@@ -55,12 +52,11 @@ class DataModelPanel(BaseComponent):
55
52
  if len(self.sm.expt_datas) > 0:
56
53
  self._populate_blocks()
57
54
 
58
- def setup_bindings(self):
59
- super().setup_bindings()
60
55
  self.sm.add_listener("data_imported", self._populate_blocks)
56
+ self.sm.add_listener("active_context_changed", self._populate_blocks)
61
57
  return
62
58
 
63
- def _populate_blocks(self):
59
+ def _populate_blocks(self, e=None):
64
60
  # add column chips to data model page
65
61
 
66
62
  nmr_fast_ex = False
@@ -91,8 +87,8 @@ class DataModelPanel(BaseComponent):
91
87
  self.slow_ex_label.visible = False
92
88
  self.dataModel_specInteg_block.visible = False
93
89
 
94
- if self.sm.active_expt_data_or_none is not None:
95
- self.data_model_inp.value = self.sm.active_expt_data.name[:] # copy it
90
+ # Name input field is removed
91
+ pass
96
92
 
97
93
  def _gen_column_chips(self):
98
94
  """Generate the column chips for the data model."""
@@ -260,7 +256,7 @@ class DataModelPanel(BaseComponent):
260
256
  ui.chip(
261
257
  text,
262
258
  color="teal",
263
- on_click=lambda h=text: self._insert_species_into_fast_inp(h),
259
+ on_click=lambda h=text, widget=inp: self._insert_species_into_fast_inp(h, widget=widget),
264
260
  )
265
261
  # placeholder checkbox to enable the input
266
262
  en_cb = ui.checkbox("Enabled", value=True)
@@ -292,19 +288,27 @@ class DataModelPanel(BaseComponent):
292
288
  delta_for_eqn = active_expt.delta_to_spec[i].copy()
293
289
 
294
290
  for ij, el in enumerate(delta_for_eqn):
295
- if np.isclose(el, 0):
291
+ val = 0.0
292
+ if el is not None:
293
+ if hasattr(el, "value"):
294
+ val = el.value
295
+ else:
296
+ try:
297
+ val = float(el)
298
+ except (TypeError, ValueError):
299
+ val = 0.0
300
+
301
+ if np.isclose(val, 0):
296
302
  delta_for_eqn[ij] = 0
297
303
  else:
298
- if (f"{self.sm.species[ij]}_free", shift) in active_expt.limiting_shifts:
304
+ if ij < len(self.sm.species) and (f"{self.sm.species[ij]}_free", shift) in active_expt.limiting_shifts:
299
305
  s = active_expt.limiting_shifts[f"{self.sm.species[ij]}_free", shift]
300
306
  if s.value:
301
- delta_for_eqn[ij] = delta_for_eqn[ij] / s.value
307
+ delta_for_eqn[ij] = val / s.value
302
308
  else:
303
- delta_for_eqn[ij] = 1
304
-
305
- # self.sm.active_expt_data.limiting_shifts[f'{self.sm.species[i]}_free',shift].value
306
-
307
- # delta_for_eqn[delta_for_eqn != 0] = 1
309
+ delta_for_eqn[ij] = 1.0
310
+ else:
311
+ delta_for_eqn[ij] = val
308
312
 
309
313
  value = self.vec_to_conc_expression(
310
314
  delta_for_eqn, [f"{x}_free" for x in self.sm.species]
@@ -340,6 +344,7 @@ class DataModelPanel(BaseComponent):
340
344
  curr_block = self.fast_ex_chem_shift_blocks[fast_ex_idx]
341
345
  curr_block.clear()
342
346
  curr_block.visible = False
347
+ self.fast_ex_chem_shift_map[fast_ex_idx].clear()
343
348
  # reset stored params
344
349
  if len(self.fast_ex_chem_shift_params) > 0:
345
350
  del self.fast_ex_chem_shift_params[fast_ex_idx]
@@ -415,21 +420,7 @@ class DataModelPanel(BaseComponent):
415
420
  w["min_num"].bind_enabled_from(cs_obj, "fixed", backward=lambda v: not v)
416
421
  w["max_num"].bind_enabled_from(cs_obj, "fixed", backward=lambda v: not v)
417
422
 
418
- def _clone_expt_data(self, old_expt: ExptData, new_name: str) -> ExptData:
419
- """Return a copy of old_expt with a new UUID and name, linked to the same model and raw data."""
420
- d = old_expt.to_dict()
421
- d["id"] = str(uuid.uuid4())
422
- d["name"] = new_name
423
- limiting_shifts_raw = d.pop("limiting_shifts", []) or []
424
- new_expt = ExptData(**d)
425
- new_expt.limiting_shifts = {}
426
- for cs in limiting_shifts_raw:
427
- csp = ChemicalShiftParam(**cs)
428
- key = (csp.species, csp.col)
429
- new_expt.limiting_shifts[key] = csp
430
- new_expt.find_and_link_model(self.sm.models)
431
- new_expt.find_and_link_raw_data(self.sm.raw_datas)
432
- return new_expt
423
+
433
424
 
434
425
  async def process_data_model(self):
435
426
  """Process the data model based on user input."""
@@ -440,7 +431,6 @@ class DataModelPanel(BaseComponent):
440
431
 
441
432
  # If existing fits depend on this ExptData, require saving as a new one
442
433
  dependent_fits = [f for f in self.sm.fits.values() if f.expt_data_id == active_expt.id]
443
- new_name = self.data_model_inp.value # default; overridden by dialog when cloning
444
434
  if dependent_fits:
445
435
  with ui.dialog() as dialog, ui.card().classes("w-96"):
446
436
  ui.label("Existing fits depend on this data model.").classes("font-semibold")
@@ -454,8 +444,9 @@ class DataModelPanel(BaseComponent):
454
444
  ui.notify("Cancelled — data model unchanged.", type="info")
455
445
  return
456
446
  new_name = result
457
- target = self._clone_expt_data(active_expt, new_name)
447
+ target = self.selector._clone_expt_data(active_expt, new_name)
458
448
  else:
449
+ new_name = active_expt.name
459
450
  target = active_expt
460
451
 
461
452
  # make col_to_comp matrix
@@ -553,6 +544,8 @@ class DataModelPanel(BaseComponent):
553
544
  self.sm.active_fit_id = None # deselect any auto-selected fit
554
545
  self.sm.notify_listeners("fit_changed")
555
546
  self.sm.notify_listeners("fits_loaded")
547
+ self.sm.notify_listeners("active_context_changed")
548
+ self.sm.notify_listeners("data_imported")
556
549
  self.sm.save_to_storage()
557
550
  self.sm.notify_listeners("data_model_processed")
558
551
 
@@ -587,6 +580,8 @@ class DataModelPanel(BaseComponent):
587
580
  "+[[H]]+2[[G]]" or similar."""
588
581
  terms = []
589
582
  for i, v in enumerate(vec):
583
+ if i >= len(cols):
584
+ continue
590
585
  if v == "-1":
591
586
  terms.append(f"-[{cols[i]}]")
592
587
  elif v == 1:
@@ -601,8 +596,12 @@ class DataModelPanel(BaseComponent):
601
596
  def insert_term(self, h: str | ClickEventArguments) -> None:
602
597
  if not isinstance(h, str):
603
598
  raise ValueError("Species name from chip is not a str")
604
- if hasattr(self, "last_focus") and self.last_focus is not None and self.last_focus.value is not None:
605
- self.last_focus.value += f"+{h}"
599
+ if hasattr(self, "last_focus") and self.last_focus is not None:
600
+ val = self.last_focus.value
601
+ if not val:
602
+ self.last_focus.value = h
603
+ else:
604
+ self.last_focus.value = val + f"+{h}"
606
605
  # if the focused widget is a fast-exchange input, trigger its handler to regenerate param blocks
607
606
 
608
607
  if hasattr(self, "specDeltaInps") and self.last_focus in self.specDeltaInps:
@@ -610,20 +609,26 @@ class DataModelPanel(BaseComponent):
610
609
  # call handler (simulate a change/blur)
611
610
  self._handle_spec_delta_blur(idx, self.last_focus)
612
611
 
613
- def _insert_species_into_fast_inp(self, species_name: str | ClickEventArguments) -> None:
614
- """Insert a species chip term into the currently focused fast-exchange input only.
612
+ def _insert_species_into_fast_inp(self, species_name: str | ClickEventArguments, widget=None) -> None:
613
+ """Insert a species chip term into the specified or currently focused fast-exchange input.
615
614
 
616
615
  The inserted token matches the concentration expression token format: `[Species]`.
617
616
  """
618
617
  if not isinstance(species_name, str):
619
618
  raise ValueError("Species name from chip is not a str")
620
- if not hasattr(self, "last_focus") or self.last_focus is None:
619
+ target_widget = widget if widget is not None else getattr(self, "last_focus", None)
620
+ if target_widget is None:
621
621
  return
622
622
  # only insert into fast-exchange inputs
623
- if hasattr(self, "specDeltaInps") and self.last_focus in self.specDeltaInps:
624
- # insert as +[Species]
625
- self.last_focus.value = "" if self.last_focus.value is None else self.last_focus.value
626
- self.last_focus.value += "+[" + species_name + "]"
623
+ if hasattr(self, "specDeltaInps") and target_widget in self.specDeltaInps:
624
+ val = target_widget.value
625
+ term = f"[{species_name}]"
626
+ if not val:
627
+ target_widget.value = term
628
+ else:
629
+ target_widget.value = val + f"+{term}"
630
+
631
+ idx = self.specDeltaInps.index(target_widget)
632
+ self._handle_spec_delta_blur(idx, target_widget)
633
+
627
634
 
628
- idx = self.specDeltaInps.index(self.last_focus)
629
- self._handle_spec_delta_blur(idx, self.last_focus)
@@ -0,0 +1,202 @@
1
+ import uuid
2
+ import re
3
+ from nicegui import ui
4
+ from .base import BaseComponent
5
+ from ..classes import ExptData, RawData
6
+
7
+
8
+ class DatasetSelector(BaseComponent):
9
+ """Reusable dropdown selection and management component for RawData and ExptData."""
10
+
11
+ def setup_nicegui(self):
12
+ self.container = ui.column().classes("w-full gap-2 p-0 m-0")
13
+ with self.container:
14
+ # Raw Dataset dropdown
15
+ with ui.row().classes("items-center gap-2 mb-2 no-wrap"):
16
+ ui.label("Raw Data:").classes("text-sm font-semibold w-24")
17
+ self.raw_data_dropdown_button = (
18
+ ui.dropdown_button("Choose raw dataset", auto_close=True)
19
+ .classes("w-80")
20
+ )
21
+
22
+ # Interpretation / Data Model dropdown
23
+ with ui.row().classes("items-center gap-2 mb-4 no-wrap"):
24
+ ui.label("Data Model:").classes("text-sm font-semibold w-24")
25
+ self.expt_data_dropdown_button = (
26
+ ui.dropdown_button("Choose data model", auto_close=True)
27
+ .classes("w-80")
28
+ )
29
+ self.rename_expt_button = (
30
+ ui.button(icon="edit", on_click=self.rename_active_expt)
31
+ .props("flat dense")
32
+ .classes("q-px-sm")
33
+ .tooltip("Rename current data model")
34
+ )
35
+ self.duplicate_expt_button = (
36
+ ui.button(icon="content_copy", on_click=self.duplicate_active_expt)
37
+ .props("flat dense")
38
+ .classes("q-px-sm")
39
+ .tooltip("Duplicate current configuration")
40
+ )
41
+ self.update_dropdown_visibility()
42
+ self.generate_dropdowns()
43
+
44
+ def setup_bindings(self):
45
+ super().setup_bindings()
46
+ self.sm.add_listener("data_imported", self.generate_dropdowns)
47
+ self.sm.add_listener("active_context_changed", self.generate_dropdowns)
48
+ self.sm.add_listener("expt_data_columns_changed", self.generate_dropdowns)
49
+
50
+ def update_dropdown_visibility(self):
51
+ has_raw = len(self.sm.raw_datas) > 0
52
+ has_expt = len(self.sm.expt_datas) > 0
53
+
54
+ if hasattr(self, "raw_data_dropdown_button"):
55
+ self.raw_data_dropdown_button.visible = has_raw
56
+ if hasattr(self, "expt_data_dropdown_button"):
57
+ self.expt_data_dropdown_button.visible = has_expt
58
+ if hasattr(self, "rename_expt_button"):
59
+ self.rename_expt_button.visible = has_expt
60
+ if hasattr(self, "duplicate_expt_button"):
61
+ self.duplicate_expt_button.visible = has_expt
62
+
63
+ def generate_dropdowns(self, e=None):
64
+ """Generate the dropdowns for selecting raw datasets and data models."""
65
+ self.update_dropdown_visibility()
66
+
67
+ if hasattr(self, "raw_data_dropdown_button"):
68
+ self.raw_data_dropdown_button.clear()
69
+ active_raw = self.sm.active_raw_data_or_none
70
+ if active_raw is not None:
71
+ self.raw_data_dropdown_button.set_text(active_raw.filename)
72
+ else:
73
+ self.raw_data_dropdown_button.set_text("Choose raw dataset")
74
+
75
+ if len(self.sm.raw_datas) > 0:
76
+ with self.raw_data_dropdown_button:
77
+ for raw in list(self.sm.raw_datas.values()):
78
+ ui.item(raw.filename, on_click=lambda e, raw=raw: self.load_raw_data(raw))
79
+
80
+ if hasattr(self, "expt_data_dropdown_button"):
81
+ self.expt_data_dropdown_button.clear()
82
+ active_expt = self.sm.active_expt_data_or_none
83
+ active_raw = self.sm.active_raw_data_or_none
84
+
85
+ if active_expt is not None:
86
+ self.expt_data_dropdown_button.set_text(active_expt.name)
87
+ else:
88
+ self.expt_data_dropdown_button.set_text("Choose data model")
89
+
90
+ if active_raw is not None:
91
+ # Filter interpretations to only those belonging to the active raw dataset
92
+ matching_expts = [
93
+ expt for expt in self.sm.expt_datas.values()
94
+ if expt.raw_data_id == active_raw.id
95
+ ]
96
+ if matching_expts:
97
+ with self.expt_data_dropdown_button:
98
+ for expt in matching_expts:
99
+ with ui.row().classes("p-1 items-center justify-between w-full no-wrap"):
100
+ ui.item(expt.name, on_click=lambda e, expt=expt: self.load_expt_data(expt)).classes("flex-grow min-w-0")
101
+ ui.icon("delete").on("click", lambda e, expt=expt: self.delete_expt_data(expt)).classes(
102
+ "cursor-pointer text-red-600 flex-shrink-0 self-center"
103
+ ).tooltip("Delete data model")
104
+
105
+ def load_raw_data(self, raw):
106
+ self.sm.active_raw_data_id = raw.id
107
+ self.sm.reconcile_active_context(reason="load_raw_data", emit_events=True)
108
+ self.sm.notify_listeners("active_context_changed")
109
+ self.sm.notify_listeners("data_imported")
110
+
111
+ def load_expt_data(self, expt):
112
+ self.sm.active_expt_data_id = expt.id
113
+ self.sm.active_raw_data_id = expt.raw_data_id
114
+
115
+ active_fit = self.sm.active_fit_or_none
116
+ if active_fit is None or active_fit.expt_data_id != expt.id:
117
+ self.sm.active_fit_id = None
118
+ for f in reversed(list(self.sm.fits.values())):
119
+ if f.expt_data_id == expt.id:
120
+ self.sm.active_fit_id = f.id
121
+ break
122
+
123
+ self.sm.reconcile_active_context(reason="load_expt_data", emit_events=True)
124
+ self.sm.notify_listeners("active_context_changed")
125
+ self.sm.notify_listeners("data_imported")
126
+
127
+ def delete_expt_data(self, expt):
128
+ self.sm.delete_expt_data(expt)
129
+ self.sm.notify_listeners("data_imported")
130
+
131
+ def get_next_version_name(self, base_name: str, existing_names: list[str]) -> str:
132
+ """Return the next incremented version name for base_name."""
133
+ match = re.search(r" v(\d+)$", base_name)
134
+ if match:
135
+ version = int(match.group(1))
136
+ prefix = base_name[:match.start()]
137
+ else:
138
+ version = 1
139
+ prefix = base_name
140
+
141
+ new_version = version + 1
142
+ candidate = f"{prefix} v{new_version}"
143
+ while candidate in existing_names:
144
+ new_version += 1
145
+ candidate = f"{prefix} v{new_version}"
146
+ return candidate
147
+
148
+ def _clone_expt_data(self, old_expt: ExptData, new_name: str) -> ExptData:
149
+ """Return a copy of old_expt with a new UUID and name, linked to the same model and raw data."""
150
+ from ..classes import ChemicalShiftParam
151
+ d = old_expt.to_dict()
152
+ d["id"] = str(uuid.uuid4())
153
+ d["name"] = new_name
154
+ limiting_shifts_raw = d.pop("limiting_shifts", []) or []
155
+ new_expt = ExptData(**d)
156
+ new_expt.limiting_shifts = {}
157
+ for cs in limiting_shifts_raw:
158
+ csp = ChemicalShiftParam(**cs)
159
+ key = (csp.species, csp.col)
160
+ new_expt.limiting_shifts[key] = csp
161
+ new_expt.find_and_link_model(self.sm.models)
162
+ new_expt.find_and_link_raw_data(self.sm.raw_datas)
163
+ return new_expt
164
+
165
+ def duplicate_active_expt(self):
166
+ active_expt = self.sm.active_expt_data_or_none
167
+ if active_expt is not None:
168
+ # Clone active_expt to create a new version
169
+ existing_names = [ed.name for ed in self.sm.expt_datas.values()]
170
+ new_name = self.get_next_version_name(active_expt.name, existing_names)
171
+ target = self._clone_expt_data(active_expt, new_name)
172
+
173
+ # Reconcile target matrices to align with its current columns
174
+ target.reconcile_matrices()
175
+
176
+ # Add to StateManager and set as active
177
+ self.sm.add_expt_data(target)
178
+ ui.notify(f"Configuration duplicated as '{new_name}'.", type="positive")
179
+ self.sm.notify_listeners("data_imported")
180
+ else:
181
+ ui.notify("No active data model to duplicate.", type="warning")
182
+
183
+ async def rename_active_expt(self):
184
+ active_expt = self.sm.active_expt_data_or_none
185
+ if active_expt is not None:
186
+ with ui.dialog() as dialog, ui.card().classes("w-96"):
187
+ ui.label("Rename Data Model").classes("font-semibold text-lg")
188
+ name_input = ui.input("Data model name", value=active_expt.name).classes("w-full")
189
+ with ui.row().classes("justify-end gap-2 mt-2"):
190
+ ui.button("Cancel", on_click=lambda: dialog.submit(None)).props("flat")
191
+ ui.button("Rename", on_click=lambda: dialog.submit(name_input.value)).props("color=primary")
192
+ result = await dialog
193
+ if result is not None and result.strip() != "":
194
+ new_name = result.strip()
195
+ if new_name != active_expt.name:
196
+ active_expt.name = new_name
197
+ self.sm.save_to_storage()
198
+ ui.notify(f"Data model renamed to '{new_name}'.", type="positive")
199
+ self.sm.notify_listeners("active_context_changed")
200
+ self.sm.notify_listeners("data_imported")
201
+ else:
202
+ ui.notify("No active data model to rename.", type="warning")
@@ -210,8 +210,9 @@ class Graph(BaseComponent):
210
210
  raise ValueError("run_id cannot be None")
211
211
  # run_id = str(uuid.uuid4())
212
212
 
213
- self.data_frames[str(run_id)] = (x, y) # Store the original x and y data for later use
214
- self.line_styles[str(run_id)] = scatter
213
+ df_key = f"{run_id}-{scatter}"
214
+ self.data_frames[df_key] = (x, y) # Store the original x and y data for later use
215
+ self.line_styles[df_key] = scatter
215
216
 
216
217
  for ii, col in enumerate(x.columns):
217
218
  self.add_comp_name(col)
@@ -227,6 +228,7 @@ class Graph(BaseComponent):
227
228
  "trace_id": str(run_id) # is the uuid for the fit/etc
228
229
  + "-"
229
230
  + col, # Unique trace ID for this simulation
231
+ "df_key": df_key,
230
232
  "visible": True,
231
233
  #'legendgroup': self.sm.modelName,
232
234
  #'legendgrouptitle': dict(text=self.sm.modelName)
@@ -271,8 +273,9 @@ class Graph(BaseComponent):
271
273
  # y DataFrame includes species concentrations
272
274
  y_df = df[spec_cols] if spec_cols else pd.DataFrame()
273
275
 
274
- self.data_frames[str(run_id)] = (x_df, y_df)
275
- self.line_styles[str(run_id)] = scatter
276
+ df_key = f"{run_id}-{scatter}"
277
+ self.data_frames[df_key] = (x_df, y_df)
278
+ self.line_styles[df_key] = scatter
276
279
 
277
280
  for ii, col in enumerate(df.columns):
278
281
  if col.endswith("_tot"):
@@ -289,7 +292,8 @@ class Graph(BaseComponent):
289
292
  "y": df[col].tolist(),
290
293
  "name": run_name[:GRAPH_LEGEND_TITLE_W] + " " + col,
291
294
  "species": col,
292
- "trace_id": run_id + "-" + col, # Unique trace ID for this simulation
295
+ "trace_id": str(run_id) + "-" + col, # Unique trace ID for this simulation
296
+ "df_key": df_key,
293
297
  "visible": True,
294
298
  #'legendgroup': self.sm.modelName,
295
299
  #'legendgrouptitle': dict(text=self.sm.modelName)
@@ -317,7 +321,8 @@ class Graph(BaseComponent):
317
321
  "y": df[col].tolist(),
318
322
  "name": run_name[:GRAPH_LEGEND_TITLE_W] + " " + col,
319
323
  "species": col,
320
- "trace_id": run_id + "-" + col, # Unique trace ID for this simulation
324
+ "trace_id": str(run_id) + "-" + col, # Unique trace ID for this simulation
325
+ "df_key": df_key,
321
326
  "visible": True,
322
327
  #'legendgroup': self.sm.modelName,
323
328
  #'legendgrouptitle': dict(text=self.sm.modelName)
@@ -485,11 +490,15 @@ if (!plot || typeof Plotly === 'undefined') {{
485
490
 
486
491
  throw_warning = False
487
492
  for d in self.graph_data["data"]:
488
- run_id = "-".join(d["trace_id"].split("-", 5)[0:5]) # Get the simulation ID from the trace_id
489
- if run_id not in self.data_frames.keys():
490
- ui.notify("Data not found for trace ID: " + run_id, type="warning")
493
+ df_key = d.get("df_key")
494
+ if df_key is None:
495
+ run_id = "-".join(d["trace_id"].split("-", 5)[0:5])
496
+ df_key = run_id
497
+
498
+ if df_key not in self.data_frames:
499
+ ui.notify("Data not found for trace: " + d.get("name", ""), type="warning")
491
500
  continue
492
- x_df = self.data_frames[run_id][0]
501
+ x_df = self.data_frames[df_key][0]
493
502
  x_cols = x_df.columns
494
503
 
495
504
  if numName == X_AXIS_ROW_INDEX:
@@ -497,22 +506,22 @@ if (!plot || typeof Plotly === 'undefined') {{
497
506
  continue
498
507
  if numName not in x_cols:
499
508
  ui.notify(
500
- f"Numerator '{numName}' not found in simulation data for trace ID: {run_id}",
509
+ f"Numerator '{numName}' not found in simulation data for trace: {d.get('name', '')}",
501
510
  type="warning",
502
511
  )
503
512
  continue
504
513
 
505
514
  if deNomName != 1 and deNomName not in x_cols:
506
515
  ui.notify(
507
- f"Denomination '{deNomName}' not found in simulation data for trace ID: {run_id}",
516
+ f"Denomination '{deNomName}' not found in simulation data for trace: {d.get('name', '')}",
508
517
  type="warning",
509
518
  )
510
519
  continue
511
520
 
512
521
  if deNomName == 1 or deNomName is None:
513
- d["x"] = self.data_frames[run_id][0][numName].tolist()
522
+ d["x"] = self.data_frames[df_key][0][numName].tolist()
514
523
  else:
515
- d["x"] = (self.data_frames[run_id][0][numName] / self.data_frames[run_id][0][deNomName]).tolist()
524
+ d["x"] = (self.data_frames[df_key][0][numName] / self.data_frames[df_key][0][deNomName]).tolist()
516
525
 
517
526
  self.graph_data.setdefault("layout", {})
518
527
  self.graph_data["layout"].setdefault("xaxis", {})
@@ -554,12 +563,16 @@ if (!plot || typeof Plotly === 'undefined') {{
554
563
  normalize = hasattr(self, "chkNormalizeY") and bool(self.chkNormalizeY.value)
555
564
 
556
565
  for d in self.graph_data["data"]:
557
- trace_id = d.get("trace_id", "")
558
- run_id = "-".join(trace_id.split("-", 5)[0:5])
559
- if run_id not in self.data_frames:
566
+ df_key = d.get("df_key")
567
+ if df_key is None:
568
+ trace_id = d.get("trace_id", "")
569
+ run_id = "-".join(trace_id.split("-", 5)[0:5])
570
+ df_key = run_id
571
+
572
+ if df_key not in self.data_frames:
560
573
  continue
561
574
 
562
- x_df, y_df = self.data_frames[run_id]
575
+ x_df, y_df = self.data_frames[df_key]
563
576
  species = d.get("species")
564
577
  y_values = None
565
578
  if species in y_df.columns:
@@ -660,10 +673,14 @@ if (!plot || typeof Plotly === 'undefined') {{
660
673
  run_id = str(run_id.id)
661
674
 
662
675
  """Remove data for a specific run_id from the graph."""
663
- if run_id in self.data_frames:
664
- del self.data_frames[run_id]
665
- if run_id in self.line_styles:
666
- del self.line_styles[run_id]
676
+ # Delete matching keys from data_frames and line_styles
677
+ keys_to_delete = [k for k in self.data_frames if k.startswith(run_id)]
678
+ for k in keys_to_delete:
679
+ del self.data_frames[k]
680
+
681
+ line_style_keys_to_delete = [k for k in self.line_styles if k.startswith(run_id)]
682
+ for k in line_style_keys_to_delete:
683
+ del self.line_styles[k]
667
684
 
668
685
  # Remove traces from the graph data
669
686
  self.graph_data["data"] = [d for d in self.graph_data["data"] if not d["trace_id"].startswith(run_id)]
@@ -381,6 +381,18 @@ class StateManager:
381
381
  del self.expt_datas[obj.id]
382
382
  if self._active_expt_data_id == obj.id:
383
383
  self._active_expt_data_id = None
384
+
385
+ # Check if this was the last exptdata associated with its rawdata
386
+ raw_data = self.raw_datas.get(obj.raw_data_id)
387
+ if raw_data is not None:
388
+ remaining_expts = [expt for expt in self.expt_datas.values() if expt.raw_data_id == raw_data.id]
389
+ if len(remaining_expts) == 0:
390
+ try:
391
+ model = self.active_model
392
+ except IndexError:
393
+ model = None
394
+ new_expt = ExptData(name=raw_data.filename, init_raw_data=raw_data, init_model=model)
395
+ self.add_expt_data(new_expt, emit_events=False)
384
396
  elif isinstance(obj, RawData):
385
397
  if obj.id in self.raw_datas:
386
398
  del self.raw_datas[obj.id]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes