bindmc 0.1.8__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.
- {bindmc-0.1.8 → bindmc-0.1.9}/PKG-INFO +1 -1
- {bindmc-0.1.8 → bindmc-0.1.9}/pyproject.toml +1 -1
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/classes/ExptData.py +85 -2
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/components/data_import.py +197 -86
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/components/data_model.py +37 -40
- bindmc-0.1.9/src/bindmc/webgui/components/dataset_selector.py +202 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/components/graph.py +39 -22
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/state/statemanager.py +12 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/README.md +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/__init__.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/__main__.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/main.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/Class model.md +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/TODO.md +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/TODO_old.md +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/__init__.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/app.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/classes/BindingConstant.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/classes/ChemicalShiftParam.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/classes/Component.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/classes/ExptDataType.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/classes/FitResult.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/classes/MCMCSim.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/classes/Model.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/classes/RawData.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/classes/Simulation.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/classes/UIBindings.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/classes/__init__.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/components/__init__.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/components/base.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/components/bayes.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/components/bayes_priors.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/components/binding_model.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/components/body.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/components/data_gen.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/components/fitting.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/components/header.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/components/simulation.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/default_models.json +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/export/__init__.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/export/notebook_exporter.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/state/__init__.py +0 -0
- {bindmc-0.1.8 → bindmc-0.1.9}/src/bindmc/webgui/utils.py +0 -0
|
@@ -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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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)
|
|
@@ -305,7 +301,7 @@ class DataModelPanel(BaseComponent):
|
|
|
305
301
|
if np.isclose(val, 0):
|
|
306
302
|
delta_for_eqn[ij] = 0
|
|
307
303
|
else:
|
|
308
|
-
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:
|
|
309
305
|
s = active_expt.limiting_shifts[f"{self.sm.species[ij]}_free", shift]
|
|
310
306
|
if s.value:
|
|
311
307
|
delta_for_eqn[ij] = val / s.value
|
|
@@ -348,6 +344,7 @@ class DataModelPanel(BaseComponent):
|
|
|
348
344
|
curr_block = self.fast_ex_chem_shift_blocks[fast_ex_idx]
|
|
349
345
|
curr_block.clear()
|
|
350
346
|
curr_block.visible = False
|
|
347
|
+
self.fast_ex_chem_shift_map[fast_ex_idx].clear()
|
|
351
348
|
# reset stored params
|
|
352
349
|
if len(self.fast_ex_chem_shift_params) > 0:
|
|
353
350
|
del self.fast_ex_chem_shift_params[fast_ex_idx]
|
|
@@ -423,21 +420,7 @@ class DataModelPanel(BaseComponent):
|
|
|
423
420
|
w["min_num"].bind_enabled_from(cs_obj, "fixed", backward=lambda v: not v)
|
|
424
421
|
w["max_num"].bind_enabled_from(cs_obj, "fixed", backward=lambda v: not v)
|
|
425
422
|
|
|
426
|
-
|
|
427
|
-
"""Return a copy of old_expt with a new UUID and name, linked to the same model and raw data."""
|
|
428
|
-
d = old_expt.to_dict()
|
|
429
|
-
d["id"] = str(uuid.uuid4())
|
|
430
|
-
d["name"] = new_name
|
|
431
|
-
limiting_shifts_raw = d.pop("limiting_shifts", []) or []
|
|
432
|
-
new_expt = ExptData(**d)
|
|
433
|
-
new_expt.limiting_shifts = {}
|
|
434
|
-
for cs in limiting_shifts_raw:
|
|
435
|
-
csp = ChemicalShiftParam(**cs)
|
|
436
|
-
key = (csp.species, csp.col)
|
|
437
|
-
new_expt.limiting_shifts[key] = csp
|
|
438
|
-
new_expt.find_and_link_model(self.sm.models)
|
|
439
|
-
new_expt.find_and_link_raw_data(self.sm.raw_datas)
|
|
440
|
-
return new_expt
|
|
423
|
+
|
|
441
424
|
|
|
442
425
|
async def process_data_model(self):
|
|
443
426
|
"""Process the data model based on user input."""
|
|
@@ -448,7 +431,6 @@ class DataModelPanel(BaseComponent):
|
|
|
448
431
|
|
|
449
432
|
# If existing fits depend on this ExptData, require saving as a new one
|
|
450
433
|
dependent_fits = [f for f in self.sm.fits.values() if f.expt_data_id == active_expt.id]
|
|
451
|
-
new_name = self.data_model_inp.value # default; overridden by dialog when cloning
|
|
452
434
|
if dependent_fits:
|
|
453
435
|
with ui.dialog() as dialog, ui.card().classes("w-96"):
|
|
454
436
|
ui.label("Existing fits depend on this data model.").classes("font-semibold")
|
|
@@ -462,8 +444,9 @@ class DataModelPanel(BaseComponent):
|
|
|
462
444
|
ui.notify("Cancelled — data model unchanged.", type="info")
|
|
463
445
|
return
|
|
464
446
|
new_name = result
|
|
465
|
-
target = self._clone_expt_data(active_expt, new_name)
|
|
447
|
+
target = self.selector._clone_expt_data(active_expt, new_name)
|
|
466
448
|
else:
|
|
449
|
+
new_name = active_expt.name
|
|
467
450
|
target = active_expt
|
|
468
451
|
|
|
469
452
|
# make col_to_comp matrix
|
|
@@ -561,6 +544,8 @@ class DataModelPanel(BaseComponent):
|
|
|
561
544
|
self.sm.active_fit_id = None # deselect any auto-selected fit
|
|
562
545
|
self.sm.notify_listeners("fit_changed")
|
|
563
546
|
self.sm.notify_listeners("fits_loaded")
|
|
547
|
+
self.sm.notify_listeners("active_context_changed")
|
|
548
|
+
self.sm.notify_listeners("data_imported")
|
|
564
549
|
self.sm.save_to_storage()
|
|
565
550
|
self.sm.notify_listeners("data_model_processed")
|
|
566
551
|
|
|
@@ -595,6 +580,8 @@ class DataModelPanel(BaseComponent):
|
|
|
595
580
|
"+[[H]]+2[[G]]" or similar."""
|
|
596
581
|
terms = []
|
|
597
582
|
for i, v in enumerate(vec):
|
|
583
|
+
if i >= len(cols):
|
|
584
|
+
continue
|
|
598
585
|
if v == "-1":
|
|
599
586
|
terms.append(f"-[{cols[i]}]")
|
|
600
587
|
elif v == 1:
|
|
@@ -609,8 +596,12 @@ class DataModelPanel(BaseComponent):
|
|
|
609
596
|
def insert_term(self, h: str | ClickEventArguments) -> None:
|
|
610
597
|
if not isinstance(h, str):
|
|
611
598
|
raise ValueError("Species name from chip is not a str")
|
|
612
|
-
if hasattr(self, "last_focus") and self.last_focus is not None
|
|
613
|
-
self.last_focus.value
|
|
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}"
|
|
614
605
|
# if the focused widget is a fast-exchange input, trigger its handler to regenerate param blocks
|
|
615
606
|
|
|
616
607
|
if hasattr(self, "specDeltaInps") and self.last_focus in self.specDeltaInps:
|
|
@@ -618,20 +609,26 @@ class DataModelPanel(BaseComponent):
|
|
|
618
609
|
# call handler (simulate a change/blur)
|
|
619
610
|
self._handle_spec_delta_blur(idx, self.last_focus)
|
|
620
611
|
|
|
621
|
-
def _insert_species_into_fast_inp(self, species_name: str | ClickEventArguments) -> None:
|
|
622
|
-
"""Insert a species chip term into the currently focused fast-exchange input
|
|
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.
|
|
623
614
|
|
|
624
615
|
The inserted token matches the concentration expression token format: `[Species]`.
|
|
625
616
|
"""
|
|
626
617
|
if not isinstance(species_name, str):
|
|
627
618
|
raise ValueError("Species name from chip is not a str")
|
|
628
|
-
if not
|
|
619
|
+
target_widget = widget if widget is not None else getattr(self, "last_focus", None)
|
|
620
|
+
if target_widget is None:
|
|
629
621
|
return
|
|
630
622
|
# only insert into fast-exchange inputs
|
|
631
|
-
if hasattr(self, "specDeltaInps") and
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
+
|
|
635
634
|
|
|
636
|
-
idx = self.specDeltaInps.index(self.last_focus)
|
|
637
|
-
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
|
-
|
|
214
|
-
self.
|
|
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
|
-
|
|
275
|
-
self.
|
|
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
|
-
|
|
489
|
-
if
|
|
490
|
-
|
|
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[
|
|
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
|
|
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
|
|
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[
|
|
522
|
+
d["x"] = self.data_frames[df_key][0][numName].tolist()
|
|
514
523
|
else:
|
|
515
|
-
d["x"] = (self.data_frames[
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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[
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
del self.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|