bindmc 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bindmc/main.py +67 -0
- bindmc/webgui/__init__.py +0 -0
- bindmc/webgui/app.py +54 -0
- bindmc/webgui/classes/BindingConstant.py +23 -0
- bindmc/webgui/classes/ChemicalShiftParam.py +40 -0
- bindmc/webgui/classes/Component.py +111 -0
- bindmc/webgui/classes/ExptData.py +485 -0
- bindmc/webgui/classes/ExptDataType.py +92 -0
- bindmc/webgui/classes/FitResult.py +173 -0
- bindmc/webgui/classes/MCMCSim.py +232 -0
- bindmc/webgui/classes/Model.py +86 -0
- bindmc/webgui/classes/RawData.py +36 -0
- bindmc/webgui/classes/Simulation.py +104 -0
- bindmc/webgui/classes/UIBindings.py +19 -0
- bindmc/webgui/classes/__init__.py +28 -0
- bindmc/webgui/components/__init__.py +29 -0
- bindmc/webgui/components/base.py +24 -0
- bindmc/webgui/components/bayes.py +689 -0
- bindmc/webgui/components/bayes_priors.py +351 -0
- bindmc/webgui/components/binding_model.py +330 -0
- bindmc/webgui/components/body.py +276 -0
- bindmc/webgui/components/data_gen.py +419 -0
- bindmc/webgui/components/data_import.py +450 -0
- bindmc/webgui/components/data_model.py +609 -0
- bindmc/webgui/components/fitting.py +886 -0
- bindmc/webgui/components/graph.py +649 -0
- bindmc/webgui/components/header.py +124 -0
- bindmc/webgui/components/simulation.py +385 -0
- bindmc/webgui/export/__init__.py +0 -0
- bindmc/webgui/export/notebook_exporter.py +727 -0
- bindmc/webgui/state/__init__.py +1 -0
- bindmc/webgui/state/statemanager.py +2043 -0
- bindmc/webgui/utils.py +322 -0
- bindmc-0.1.0.dist-info/METADATA +22 -0
- bindmc-0.1.0.dist-info/RECORD +37 -0
- bindmc-0.1.0.dist-info/WHEEL +5 -0
- bindmc-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import uuid
|
|
4
|
+
import re
|
|
5
|
+
import zipfile
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from nicegui import ui,run,events
|
|
9
|
+
|
|
10
|
+
import bindtools.binding as bd
|
|
11
|
+
|
|
12
|
+
from .base import BaseComponent
|
|
13
|
+
from .graph import Graph
|
|
14
|
+
from ..classes import FitResult
|
|
15
|
+
from ..utils import safe_filename,_infer_simple_fast_exchange_topology
|
|
16
|
+
from functools import partial
|
|
17
|
+
from typing import cast
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _mapped_dependent_columns_for_fit(fit: FitResult) -> set[str]:
|
|
24
|
+
"""Infer which dependent columns are actually mapped for fitting."""
|
|
25
|
+
expt = fit.expt_data
|
|
26
|
+
dep_cols = {
|
|
27
|
+
col for col in expt.columns
|
|
28
|
+
if expt.col_details.get(col, {}).get("depindep") == "dep"
|
|
29
|
+
}
|
|
30
|
+
mapped_cols: set[str] = set()
|
|
31
|
+
|
|
32
|
+
integ_to_spec = expt.integ_to_spec
|
|
33
|
+
if isinstance(integ_to_spec, np.ndarray) and integ_to_spec.ndim == 2 and integ_to_spec.size > 0:
|
|
34
|
+
max_cols = min(integ_to_spec.shape[1], len(expt.columns))
|
|
35
|
+
for idx in range(max_cols):
|
|
36
|
+
col_name = expt.columns[idx]
|
|
37
|
+
if col_name not in dep_cols:
|
|
38
|
+
continue
|
|
39
|
+
values = pd.to_numeric(pd.Series(integ_to_spec[:, idx]), errors="coerce").to_numpy(dtype=float)
|
|
40
|
+
finite_values = values[np.isfinite(values)]
|
|
41
|
+
if finite_values.size == 0:
|
|
42
|
+
continue
|
|
43
|
+
if np.any(~np.isclose(finite_values, 0.0)):
|
|
44
|
+
mapped_cols.add(col_name)
|
|
45
|
+
|
|
46
|
+
for _, col_name in expt.limiting_shifts.keys():
|
|
47
|
+
if col_name in dep_cols:
|
|
48
|
+
mapped_cols.add(col_name)
|
|
49
|
+
|
|
50
|
+
return mapped_cols
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _prepare_fit_plot_frames(fit: FitResult) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, list[str]]:
|
|
54
|
+
"""Return aligned (x, calc, expt) frames and list of skipped calc columns."""
|
|
55
|
+
x_df = fit.comp_concs.copy().reset_index(drop=True)
|
|
56
|
+
calc_df = fit.calc_obs.copy().reset_index(drop=True)
|
|
57
|
+
# Use full experimental data, not selected_data, to avoid coupling to mutable UI state.
|
|
58
|
+
# This ensures each fit's plot is based on its own calc_obs, not on the current state of expt_data.
|
|
59
|
+
expt_df = fit.expt_data.data.copy().reset_index(drop=True)
|
|
60
|
+
|
|
61
|
+
# Start with columns that exist in both calc and expt.
|
|
62
|
+
# calc_df.columns is the source of truth for what was actually computed by the fit.
|
|
63
|
+
common_cols = [col for col in calc_df.columns if col in expt_df.columns]
|
|
64
|
+
|
|
65
|
+
# Remove columns with all NaN values
|
|
66
|
+
common_cols = [col for col in common_cols if not calc_df[col].isna().all()]
|
|
67
|
+
skipped_cols = [col for col in calc_df.columns if col not in common_cols]
|
|
68
|
+
|
|
69
|
+
if not common_cols:
|
|
70
|
+
return x_df.iloc[0:0], calc_df.iloc[:, 0:0], expt_df.iloc[:, 0:0], skipped_cols
|
|
71
|
+
|
|
72
|
+
calc_plot = calc_df[common_cols]
|
|
73
|
+
expt_plot = expt_df[common_cols]
|
|
74
|
+
n_rows = min(len(x_df), len(calc_plot), len(expt_plot))
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
x_df.iloc[:n_rows].reset_index(drop=True),
|
|
78
|
+
calc_plot.iloc[:n_rows].reset_index(drop=True),
|
|
79
|
+
expt_plot.iloc[:n_rows].reset_index(drop=True),
|
|
80
|
+
skipped_cols,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _infer_analytical_fast_exchange_config(model, expt_data, expt_dtypes: dict) -> dict[str, object] | None:
|
|
87
|
+
if model is None or expt_data is None:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
obs_list = [
|
|
91
|
+
col_name
|
|
92
|
+
for col_name in expt_data.sorted_data.columns
|
|
93
|
+
if expt_data.col_details.get(col_name, {}).get("depindep") == "dep"
|
|
94
|
+
]
|
|
95
|
+
if not obs_list:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
topology = _infer_simple_fast_exchange_topology(model.eq_mat, len(model.component_names))
|
|
99
|
+
if topology is None:
|
|
100
|
+
return None
|
|
101
|
+
topo_name, complex_indices = topology
|
|
102
|
+
|
|
103
|
+
# Classify each dependent column as NMR-shift or linear (UV-vis/fluorescence).
|
|
104
|
+
has_nmr = False
|
|
105
|
+
has_linear = False
|
|
106
|
+
for col in obs_list:
|
|
107
|
+
col_meta = expt_data.col_details.get(col, {})
|
|
108
|
+
dtype_key = col_meta.get("dtype")
|
|
109
|
+
dtype = expt_dtypes.get(dtype_key) if dtype_key is not None else None
|
|
110
|
+
meas = getattr(dtype, "meas", None) if dtype is not None else None
|
|
111
|
+
if meas == "nmr_ppm":
|
|
112
|
+
has_nmr = True
|
|
113
|
+
elif meas in ("uvvis", "fluorescence"):
|
|
114
|
+
has_linear = True
|
|
115
|
+
else:
|
|
116
|
+
return None # Unknown or unsupported observable type for analytical path
|
|
117
|
+
|
|
118
|
+
if has_nmr and has_linear:
|
|
119
|
+
return None # Mixed NMR + UV-vis not yet supported on analytical path
|
|
120
|
+
|
|
121
|
+
# Pure linear (UV-vis / fluorescence) path: no NMR shift columns needed.
|
|
122
|
+
if has_linear and not has_nmr:
|
|
123
|
+
# If slow-exchange mapping is active, stay on generic backend.
|
|
124
|
+
integ_to_spec = expt_data.integ_to_spec
|
|
125
|
+
if isinstance(integ_to_spec, np.ndarray) and integ_to_spec.size > 0:
|
|
126
|
+
numeric = pd.to_numeric(pd.Series(integ_to_spec.flatten()), errors="coerce").to_numpy(dtype=float)
|
|
127
|
+
finite = numeric[np.isfinite(numeric)]
|
|
128
|
+
if finite.size > 0 and np.any(~np.isclose(finite, 0.0)):
|
|
129
|
+
return None
|
|
130
|
+
return {
|
|
131
|
+
"topology": topo_name,
|
|
132
|
+
"complex_indices": complex_indices,
|
|
133
|
+
"obs_columns": [], # no NMR shift columns
|
|
134
|
+
"obs_components": [],
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
# Pure NMR shift path (existing behaviour).
|
|
138
|
+
|
|
139
|
+
# If slow-exchange mapping is active, stay on the generic backend.
|
|
140
|
+
integ_to_spec = expt_data.integ_to_spec
|
|
141
|
+
if isinstance(integ_to_spec, np.ndarray) and integ_to_spec.size > 0:
|
|
142
|
+
numeric = pd.to_numeric(pd.Series(integ_to_spec.flatten()), errors="coerce").to_numpy(dtype=float)
|
|
143
|
+
finite = numeric[np.isfinite(numeric)]
|
|
144
|
+
if finite.size > 0 and np.any(~np.isclose(finite, 0.0)):
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
component_free_labels = [f"{name}_free" for name in model.component_names]
|
|
148
|
+
if len(component_free_labels) != 2:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
# Build optional hints from existing user mappings when available.
|
|
152
|
+
shift_species_by_col: dict[str, set[str]] = {}
|
|
153
|
+
if isinstance(expt_data.limiting_shifts, dict):
|
|
154
|
+
for (species, col_name), _ in expt_data.limiting_shifts.items():
|
|
155
|
+
if col_name is None:
|
|
156
|
+
continue
|
|
157
|
+
shift_species_by_col.setdefault(str(col_name), set()).add(str(species))
|
|
158
|
+
|
|
159
|
+
delta_species_hints: dict[str, set[str]] = {}
|
|
160
|
+
delta_to_spec = expt_data.delta_to_spec
|
|
161
|
+
if isinstance(delta_to_spec, np.ndarray) and delta_to_spec.ndim == 2 and delta_to_spec.size > 0:
|
|
162
|
+
# In this UI flow, delta_to_spec rows are created from fast-exchange observable columns.
|
|
163
|
+
# For analytical mode all dependent observables are shift observables, so the row order
|
|
164
|
+
# corresponds to obs_list.
|
|
165
|
+
n_rows = min(delta_to_spec.shape[0], len(obs_list))
|
|
166
|
+
n_species = min(delta_to_spec.shape[1], len(component_free_labels))
|
|
167
|
+
for ridx in range(n_rows):
|
|
168
|
+
col_name = obs_list[ridx]
|
|
169
|
+
for sidx in range(n_species):
|
|
170
|
+
try:
|
|
171
|
+
is_nonzero = not np.isclose(float(delta_to_spec[ridx, sidx]), 0.0)
|
|
172
|
+
except Exception:
|
|
173
|
+
is_nonzero = bool(delta_to_spec[ridx, sidx])
|
|
174
|
+
if is_nonzero:
|
|
175
|
+
delta_species_hints.setdefault(col_name, set()).add(component_free_labels[sidx])
|
|
176
|
+
|
|
177
|
+
def _component_from_text_hints(col_name: str, dtype_key: str | None) -> int | None:
|
|
178
|
+
# Tokenize to avoid over-matching short component names in arbitrary strings.
|
|
179
|
+
tokens = []
|
|
180
|
+
for src in (col_name, dtype_key or ""):
|
|
181
|
+
parts = [t for t in re.split(r"[^A-Za-z0-9]+", str(src).lower()) if t]
|
|
182
|
+
tokens.extend(parts)
|
|
183
|
+
matches = [idx for idx, comp in enumerate(model.component_names) if str(comp).lower() in tokens]
|
|
184
|
+
return matches[0] if len(matches) == 1 else None
|
|
185
|
+
|
|
186
|
+
obs_components: list[int] = []
|
|
187
|
+
unresolved: list[tuple[int, str]] = []
|
|
188
|
+
for obs_idx, col in enumerate(obs_list):
|
|
189
|
+
col_meta = expt_data.col_details.get(col, {})
|
|
190
|
+
dtype_key = col_meta.get("dtype")
|
|
191
|
+
|
|
192
|
+
included_species = set()
|
|
193
|
+
included_species |= shift_species_by_col.get(col, set())
|
|
194
|
+
included_species |= delta_species_hints.get(col, set())
|
|
195
|
+
|
|
196
|
+
has_comp0 = component_free_labels[0] in included_species
|
|
197
|
+
has_comp1 = component_free_labels[1] in included_species
|
|
198
|
+
if has_comp0 != has_comp1:
|
|
199
|
+
obs_components.append(0 if has_comp0 else 1)
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
inferred = _component_from_text_hints(col, dtype_key)
|
|
203
|
+
if inferred is not None:
|
|
204
|
+
obs_components.append(inferred)
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
unresolved.append((obs_idx, col))
|
|
208
|
+
obs_components.append(-1)
|
|
209
|
+
|
|
210
|
+
# Final fallback keeps analytical mode enabled even without manual shift metadata.
|
|
211
|
+
if unresolved:
|
|
212
|
+
for obs_idx, col in unresolved:
|
|
213
|
+
fallback = obs_idx % len(component_free_labels)
|
|
214
|
+
obs_components[obs_idx] = fallback
|
|
215
|
+
logger.info(
|
|
216
|
+
"Analytical fast-exchange: inferred observable '%s' as component %d by default fallback.",
|
|
217
|
+
col,
|
|
218
|
+
fallback,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
"topology": topo_name,
|
|
223
|
+
"complex_indices": complex_indices,
|
|
224
|
+
"obs_columns": list(obs_list),
|
|
225
|
+
"obs_components": obs_components,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class FittingPanel(BaseComponent):
|
|
230
|
+
def setup_nicegui(self) -> None:
|
|
231
|
+
self._dark_species_visible = False # must be set before bind_visibility_from
|
|
232
|
+
self.container = ui.column().classes("w-full")
|
|
233
|
+
|
|
234
|
+
with self.container:
|
|
235
|
+
ui.label("Fitting panel").classes("text-lg font-bold mb-4")
|
|
236
|
+
with ui.row():
|
|
237
|
+
with ui.card():
|
|
238
|
+
ui.label("Fitting options to go here.")
|
|
239
|
+
self.fit_alg_select=ui.select(
|
|
240
|
+
["least_squares", "l-bfgs", "ampgo"],
|
|
241
|
+
label="Algorithm",
|
|
242
|
+
on_change=lambda e: print(f"Selected: {e.value}"),value="least_squares",
|
|
243
|
+
).classes('w-full')
|
|
244
|
+
|
|
245
|
+
self.fit_results_card = FitResultsCard(state_manager=self.sm)
|
|
246
|
+
|
|
247
|
+
with ui.row().classes("w-full mb-4"):
|
|
248
|
+
self.fit_button = ui.button("Run Fit", on_click=self.run_fit)
|
|
249
|
+
self.clear_fit_graph_button = ui.button("Clear Graph", on_click=self.clear_fit_graphs).classes("mb-4")
|
|
250
|
+
self.download_fit_data_button = ui.button(
|
|
251
|
+
"Download Fit CSV", on_click=self.download_fit_data_csv
|
|
252
|
+
).classes("mb-4")
|
|
253
|
+
self.export_fit_notebook_button = ui.button(
|
|
254
|
+
"Export to Notebook",
|
|
255
|
+
on_click=self.download_fit_notebook,
|
|
256
|
+
).classes("mb-4")
|
|
257
|
+
self.delete_fit_dropdown = ui.dropdown_button(
|
|
258
|
+
"Delete Fit", auto_close=True
|
|
259
|
+
).classes("mb-4")
|
|
260
|
+
self.delete_fit_dropdown.clear()
|
|
261
|
+
with self.delete_fit_dropdown:
|
|
262
|
+
for fit in self.sm.fits.values():
|
|
263
|
+
ui.item(
|
|
264
|
+
fit.name,
|
|
265
|
+
on_click=lambda e, fit=fit: self.delete_fit(fit),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# Add name and comment inputs with save button
|
|
269
|
+
with ui.card().classes("w-full mb-4"):
|
|
270
|
+
ui.label("Fit Details")
|
|
271
|
+
with ui.row().classes("w-full"):
|
|
272
|
+
self.fit_name_input = ui.input(
|
|
273
|
+
"Fit Name", placeholder="Enter fit name"
|
|
274
|
+
).classes("flex-1")
|
|
275
|
+
self.fit_comment_input = ui.input(
|
|
276
|
+
"Comment", placeholder="Enter comment"
|
|
277
|
+
).classes("flex-1")
|
|
278
|
+
ui.button(
|
|
279
|
+
"Auto-generate title",
|
|
280
|
+
on_click=lambda: self.fit_name_input.set_value(
|
|
281
|
+
self.sm.active_model.name
|
|
282
|
+
+ " fit"
|
|
283
|
+
)
|
|
284
|
+
).classes("ml-4")
|
|
285
|
+
self.save_fit_details_button = ui.button(
|
|
286
|
+
"Save changes to name/comment", on_click=self.save_fit_details
|
|
287
|
+
).classes("ml-4")
|
|
288
|
+
self.save_fit_details_button.set_enabled(False)
|
|
289
|
+
|
|
290
|
+
with ui.row().classes("w-full"):
|
|
291
|
+
with ui.column().classes("w-3/4"):
|
|
292
|
+
self.fit_graph = Graph(self.sm, mode="fit")
|
|
293
|
+
self.spinner = ui.spinner(size="xl").classes("absolute-center")
|
|
294
|
+
self.speciation_graph = Graph(self.sm, mode="fit_speciation")
|
|
295
|
+
|
|
296
|
+
self.spinner.visible = False
|
|
297
|
+
|
|
298
|
+
with ui.card().classes("w-full mb-4").bind_visibility_from(self, "_dark_species_visible"):
|
|
299
|
+
ui.label("Dark / Silent Species").classes("text-base font-semibold mb-1")
|
|
300
|
+
ui.label(
|
|
301
|
+
"Tick species whose ε / fluorescence amplitude is zero for each observable column."
|
|
302
|
+
).classes("text-xs text-gray-500 mb-2")
|
|
303
|
+
self.dark_species_rows = ui.column().classes("w-full gap-1")
|
|
304
|
+
|
|
305
|
+
self._rebuild_dark_species_card()
|
|
306
|
+
self.graphs = [self.fit_graph, self.speciation_graph]
|
|
307
|
+
|
|
308
|
+
def setup_bindings(self) -> None:
|
|
309
|
+
super().setup_bindings()
|
|
310
|
+
self.sm.add_listener("fit_results_updated", self._update_fit_graphs)
|
|
311
|
+
self.sm.add_listener("fits_loaded", self._init_load_fits)
|
|
312
|
+
self.sm.add_listener("active_context_changed", self._update_fit_graphs)
|
|
313
|
+
self.sm.add_listener("fit_changed", self._refresh_for_active_fit)
|
|
314
|
+
self.sm.add_listener("active_context_changed", self._rebuild_dark_species_card)
|
|
315
|
+
self.sm.add_listener("data_model_processed", self._rebuild_dark_species_card)
|
|
316
|
+
|
|
317
|
+
if len(self.sm.fits) > 0:
|
|
318
|
+
self.fit_name_input.set_value(self.sm.active_fit.name)
|
|
319
|
+
self.fit_comment_input.set_value(self.sm.active_fit.description)
|
|
320
|
+
self.save_fit_details_button.set_enabled(True)
|
|
321
|
+
|
|
322
|
+
def _refresh_for_active_fit(self, e=None) -> None:
|
|
323
|
+
"""Refresh fit name/comment inputs to reflect the current active fit."""
|
|
324
|
+
active_fit = self.sm.active_fit_or_none
|
|
325
|
+
if active_fit is None:
|
|
326
|
+
self.fit_name_input.set_value("")
|
|
327
|
+
self.fit_comment_input.set_value("")
|
|
328
|
+
self.save_fit_details_button.set_enabled(False)
|
|
329
|
+
else:
|
|
330
|
+
self.fit_name_input.set_value(active_fit.name)
|
|
331
|
+
self.fit_comment_input.set_value(active_fit.description)
|
|
332
|
+
self.save_fit_details_button.set_enabled(True)
|
|
333
|
+
|
|
334
|
+
def _set_fit_running(self, running: bool) -> None:
|
|
335
|
+
"""Toggle fitting UI state so teardown is consistent across all exit paths."""
|
|
336
|
+
self.spinner.visible = running
|
|
337
|
+
if running:
|
|
338
|
+
self.fit_button.disable()
|
|
339
|
+
else:
|
|
340
|
+
self.fit_button.enable()
|
|
341
|
+
|
|
342
|
+
def _rebuild_dark_species_card(self) -> None:
|
|
343
|
+
"""Rebuild the dark-species toggle rows based on the active dataset."""
|
|
344
|
+
expt_data = self.sm.active_expt_data_or_none
|
|
345
|
+
if expt_data is None or not expt_data.has_linear_obs(self.sm._expt_dtypes):
|
|
346
|
+
self._dark_species_visible = False
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
species = list(self.sm.species)
|
|
350
|
+
lin_cols = expt_data.linear_obs_cols(self.sm._expt_dtypes)
|
|
351
|
+
self._dark_species_visible = bool(lin_cols)
|
|
352
|
+
|
|
353
|
+
self.dark_species_rows.clear()
|
|
354
|
+
with self.dark_species_rows:
|
|
355
|
+
for col, meas in lin_cols:
|
|
356
|
+
dark_set = set(expt_data.dark_species.get(col, []))
|
|
357
|
+
prefix = "UV-vis" if meas == "uvvis" else "Fluorescence"
|
|
358
|
+
with ui.row().classes("items-center gap-4 flex-wrap"):
|
|
359
|
+
ui.label(f"{prefix}: {col}").classes("font-medium w-40")
|
|
360
|
+
for sp in species:
|
|
361
|
+
def _toggle(e, _col=col, _sp=sp) -> None:
|
|
362
|
+
ed = self.sm.active_expt_data
|
|
363
|
+
d = list(ed.dark_species.get(_col, []))
|
|
364
|
+
if e.value:
|
|
365
|
+
if _sp not in d:
|
|
366
|
+
d.append(_sp)
|
|
367
|
+
else:
|
|
368
|
+
d = [s for s in d if s != _sp]
|
|
369
|
+
ed.dark_species[_col] = d
|
|
370
|
+
|
|
371
|
+
ui.checkbox(sp, value=(sp in dark_set), on_change=_toggle).classes("text-sm")
|
|
372
|
+
|
|
373
|
+
async def run_fit(self) -> None:
|
|
374
|
+
if self.sm.active_expt_data_id is None:
|
|
375
|
+
ui.notify("No experimental data loaded. Please import data first.", type="negative")
|
|
376
|
+
return
|
|
377
|
+
|
|
378
|
+
# Check if the simulation name looks auto-generated but doesn't match the current auto-generated value
|
|
379
|
+
auto_name = (
|
|
380
|
+
self.sm.active_model.name
|
|
381
|
+
+ " "
|
|
382
|
+
+ "fit"
|
|
383
|
+
) # move to a function
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
if (
|
|
387
|
+
self.sm.active_fit_id is not None and
|
|
388
|
+
hasattr(self.sm.active_fit,"name") and
|
|
389
|
+
self.fit_name_input.value.startswith(self.sm.active_fit.name)
|
|
390
|
+
and self.fit_name_input.value != auto_name
|
|
391
|
+
):
|
|
392
|
+
with ui.dialog() as dialog, ui.card():
|
|
393
|
+
ui.label(
|
|
394
|
+
"Warning: The fit name appears auto-generated but does not match the current model name. Consider updating the name."
|
|
395
|
+
)
|
|
396
|
+
with ui.row():
|
|
397
|
+
ui.button("Continue", on_click=lambda: dialog.submit("continue"))
|
|
398
|
+
ui.button(
|
|
399
|
+
"Generate new name and run",
|
|
400
|
+
on_click=lambda: dialog.submit("update"),
|
|
401
|
+
)
|
|
402
|
+
ui.button("Cancel", on_click=lambda: dialog.submit("cancel"))
|
|
403
|
+
|
|
404
|
+
async def show_dialog():
|
|
405
|
+
result = await dialog
|
|
406
|
+
return result
|
|
407
|
+
|
|
408
|
+
res = await show_dialog()
|
|
409
|
+
if res == "cancel":
|
|
410
|
+
ui.notify(
|
|
411
|
+
"Fit not run. Please choose a different name or update the current one.",
|
|
412
|
+
type="info",
|
|
413
|
+
)
|
|
414
|
+
return
|
|
415
|
+
elif res == "continue":
|
|
416
|
+
ui.notify("Continuing with the fit.", type="info")
|
|
417
|
+
elif res == "update":
|
|
418
|
+
# Update the fit name to the auto-generated value
|
|
419
|
+
self.fit_name_input.set_value(auto_name)
|
|
420
|
+
ui.notify(
|
|
421
|
+
f"Fit name updated to '{auto_name}'; now running.",
|
|
422
|
+
type="info",
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
if any(fit.name == self.fit_name_input.value for fit in self.sm.fits.values()):
|
|
427
|
+
with ui.dialog() as dialog, ui.card():
|
|
428
|
+
ui.label(
|
|
429
|
+
f"A fit named '{self.fit_name_input.value}' already exists. Do you want to overwrite it?"
|
|
430
|
+
)
|
|
431
|
+
with ui.row():
|
|
432
|
+
ui.button("Overwrite", on_click=lambda: dialog.submit(True))
|
|
433
|
+
ui.button("Cancel", on_click=lambda: dialog.submit(False))
|
|
434
|
+
|
|
435
|
+
async def show_dialog():
|
|
436
|
+
result = await dialog
|
|
437
|
+
return result
|
|
438
|
+
|
|
439
|
+
res = await show_dialog()
|
|
440
|
+
if not res:
|
|
441
|
+
ui.notify(
|
|
442
|
+
"Fit not run. Please choose a different name or delete the old fit.",
|
|
443
|
+
type="info",
|
|
444
|
+
)
|
|
445
|
+
return
|
|
446
|
+
else:
|
|
447
|
+
# Delete the existing simulation with that name
|
|
448
|
+
existing_fit = next(
|
|
449
|
+
(
|
|
450
|
+
fit
|
|
451
|
+
for fit in self.sm.fits.values()
|
|
452
|
+
if fit.name == self.fit_name_input.value
|
|
453
|
+
),
|
|
454
|
+
None,
|
|
455
|
+
)
|
|
456
|
+
if existing_fit:
|
|
457
|
+
self.sm.delete_fit(existing_fit, notify_user=False, notify_listeners=False)
|
|
458
|
+
ui.notify(
|
|
459
|
+
f"Overwriting fit '{self.fit_name_input.value}'.",
|
|
460
|
+
type="info",
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
analytical_cfg = _infer_analytical_fast_exchange_config(
|
|
466
|
+
self.sm.active_model,
|
|
467
|
+
self.sm.active_expt_data,
|
|
468
|
+
self.sm._expt_dtypes,
|
|
469
|
+
)
|
|
470
|
+
self.m1 = self.sm.generate_binding_model_for_fit(analytical_cfg=analytical_cfg)
|
|
471
|
+
if analytical_cfg is not None:
|
|
472
|
+
ui.notify(
|
|
473
|
+
f"Using analytical fast-exchange backend ({self.m1.analytical_topology}).",
|
|
474
|
+
type="info",
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
for param in self.m1.params:
|
|
478
|
+
logger.info(f"Parameter before fit: {param} = {self.m1.params[param]}")
|
|
479
|
+
|
|
480
|
+
self._set_fit_running(True)
|
|
481
|
+
try:
|
|
482
|
+
# self.m1.prepModel()
|
|
483
|
+
# set parameter values/limits here... later.
|
|
484
|
+
self.m1 = await run.cpu_bound(partial(self.m1.runModel,ret=True,skip_col=np.shape(self.sm.active_expt_data.col_to_comp)[0],method=str(self.fit_alg_select.value)))
|
|
485
|
+
if self.m1 is None:
|
|
486
|
+
ui.notify("Fit failed to run. Check the console for details.", type="negative")
|
|
487
|
+
return
|
|
488
|
+
|
|
489
|
+
calc_obs = await run.cpu_bound(partial(bd.getCalcData,self.m1)) # TODO resolve inconsistency between this and following line in bindtools
|
|
490
|
+
speciation = await run.cpu_bound(self.m1.calcSpeciation)
|
|
491
|
+
|
|
492
|
+
if self.m1.miniResult is None:
|
|
493
|
+
ui.notify("Fit failed to converge. Check the console for details.", type="negative")
|
|
494
|
+
return
|
|
495
|
+
|
|
496
|
+
new_fit = FitResult(
|
|
497
|
+
model_id=self.sm.active_model.id,
|
|
498
|
+
expt_data_id=self.sm.active_expt_data_id,
|
|
499
|
+
name=self.fit_name_input.value,
|
|
500
|
+
description=self.fit_comment_input.value,
|
|
501
|
+
aic=self.m1.miniResult.aic,
|
|
502
|
+
bic=self.m1.miniResult.bic,
|
|
503
|
+
chisqr= self.m1.miniResult.chisqr,
|
|
504
|
+
termination_message=self.m1.miniResult.message if hasattr(self.m1.miniResult, 'message') else "N/A", # type: ignore
|
|
505
|
+
success=getattr(self.m1.miniResult, 'success', None),
|
|
506
|
+
fit_method=str(self.fit_alg_select.value),
|
|
507
|
+
init_expt_data = self.sm.active_expt_data,
|
|
508
|
+
init_model=self.sm.active_model,
|
|
509
|
+
bd_model=self.m1,
|
|
510
|
+
analytical_fast_exchange=analytical_cfg is not None,
|
|
511
|
+
analytical_topology=(str(analytical_cfg["topology"]) if analytical_cfg is not None else None),
|
|
512
|
+
analytical_obs_columns=([str(x) for x in cast(list[str], analytical_cfg["obs_columns"])] if analytical_cfg is not None else []),
|
|
513
|
+
analytical_obs_components=([int(x) for x in cast(list[int], analytical_cfg["obs_components"])] if analytical_cfg is not None else []),
|
|
514
|
+
analytical_complex_indices=([int(x) for x in cast(list[int], analytical_cfg["complex_indices"])] if analytical_cfg is not None else []),
|
|
515
|
+
)
|
|
516
|
+
self.sm.add_fit(new_fit)
|
|
517
|
+
|
|
518
|
+
for k, v in self.m1.miniResult.params.items(): # type: ignore
|
|
519
|
+
self.sm.active_fit.params[k] = {
|
|
520
|
+
"value": v.value,
|
|
521
|
+
"stderr": v.stderr,
|
|
522
|
+
"min": v.min,
|
|
523
|
+
"max": v.max,
|
|
524
|
+
"vary": v.vary,
|
|
525
|
+
"initial_value": v.init_value
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
self.sm.active_fit.calc_obs = pd.DataFrame(calc_obs, columns=self.m1.obsList)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
self.sm.active_fit.fit_speciation = pd.DataFrame(speciation, columns=[x + "_free" for x in self.sm.species])
|
|
532
|
+
|
|
533
|
+
self.sm.notify_listeners('redraw_fits_table')
|
|
534
|
+
self.sm.notify_listeners('fit_completed')
|
|
535
|
+
self._update_fit_graphs()
|
|
536
|
+
except Exception:
|
|
537
|
+
logger.exception("Fit run failed with an unhandled exception.")
|
|
538
|
+
raise
|
|
539
|
+
finally:
|
|
540
|
+
self._set_fit_running(False)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _update_fit_graphs(self, e=None) -> None:
|
|
545
|
+
"""Update the fit results display."""
|
|
546
|
+
print("Updating fit results...")
|
|
547
|
+
self.fit_graph.clear_graph(update=False)
|
|
548
|
+
if len(self.sm.fits)>0:
|
|
549
|
+
self.speciation_graph.clear_graph(update=False)
|
|
550
|
+
skipped_fit_names: list[str] = []
|
|
551
|
+
for fit in self.sm.fits.values():
|
|
552
|
+
x_plot, calc_plot, expt_plot, _ = _prepare_fit_plot_frames(fit)
|
|
553
|
+
if calc_plot.empty:
|
|
554
|
+
skipped_fit_names.append(fit.name)
|
|
555
|
+
continue
|
|
556
|
+
|
|
557
|
+
self.fit_graph.add_graph_lines_xy(x_plot, calc_plot,
|
|
558
|
+
run_name=fit.name,
|
|
559
|
+
run_id=fit.id,
|
|
560
|
+
scatter="lines")
|
|
561
|
+
self.fit_graph.add_graph_lines_xy(x_plot, expt_plot,
|
|
562
|
+
run_name=fit.name,
|
|
563
|
+
run_id=fit.id,
|
|
564
|
+
scatter="markers")
|
|
565
|
+
self.speciation_graph.add_graph_lines_xy(fit.comp_concs, fit.fit_speciation,
|
|
566
|
+
run_name=fit.name,
|
|
567
|
+
run_id=fit.id,
|
|
568
|
+
scatter="lines")
|
|
569
|
+
if skipped_fit_names:
|
|
570
|
+
ui.notify(
|
|
571
|
+
"Skipped plotting non-fitted or invalid observable columns for: "
|
|
572
|
+
+ ", ".join(skipped_fit_names),
|
|
573
|
+
type="warning",
|
|
574
|
+
)
|
|
575
|
+
self.sync_graphs()
|
|
576
|
+
self.generate_delete_fit_dropdown()
|
|
577
|
+
|
|
578
|
+
def _init_load_fits(self, e=None) -> None:
|
|
579
|
+
"""Load initial set of fits"""
|
|
580
|
+
fits_to_delete = []
|
|
581
|
+
skipped_plot_fits: list[str] = []
|
|
582
|
+
for fit in self.sm.fits.values():
|
|
583
|
+
if hasattr(fit, "fit_speciation") and not fit.fit_speciation.empty and \
|
|
584
|
+
hasattr(fit, "calc_obs") and not fit.calc_obs.empty and \
|
|
585
|
+
hasattr(fit, "comp_concs") and not fit.comp_concs.empty:
|
|
586
|
+
x_plot, calc_plot, expt_plot, _ = _prepare_fit_plot_frames(fit)
|
|
587
|
+
if calc_plot.empty:
|
|
588
|
+
skipped_plot_fits.append(fit.name)
|
|
589
|
+
continue
|
|
590
|
+
self.fit_graph.add_graph_lines_xy(
|
|
591
|
+
x_plot, calc_plot, scatter="lines", run_name=fit.name, run_id=fit.id
|
|
592
|
+
)
|
|
593
|
+
self.fit_graph.add_graph_lines_xy(
|
|
594
|
+
x_plot, expt_plot, scatter="markers", run_name=fit.name, run_id=fit.id
|
|
595
|
+
)
|
|
596
|
+
self.speciation_graph.add_graph_lines_xy(
|
|
597
|
+
fit.comp_concs, fit.fit_speciation, scatter="lines", run_name=fit.name, run_id=fit.id
|
|
598
|
+
)
|
|
599
|
+
self.sync_graphs()
|
|
600
|
+
else:
|
|
601
|
+
fits_to_delete.append(fit)
|
|
602
|
+
ui.notify(f"Fit {fit.name} is missing data and will be deleted.", type="negative")
|
|
603
|
+
if skipped_plot_fits:
|
|
604
|
+
ui.notify(
|
|
605
|
+
"Skipped plotting non-fitted or invalid observable columns for: "
|
|
606
|
+
+ ", ".join(skipped_plot_fits),
|
|
607
|
+
type="warning",
|
|
608
|
+
)
|
|
609
|
+
for fit in fits_to_delete:
|
|
610
|
+
self.sm.delete_fit(fit, notify_user=False, notify_listeners=False)
|
|
611
|
+
active_fit = self.sm.active_fit_or_none
|
|
612
|
+
if active_fit is not None:
|
|
613
|
+
self.fit_name_input.set_value(active_fit.name)
|
|
614
|
+
self.fit_comment_input.set_value(active_fit.description)
|
|
615
|
+
self.save_fit_details_button.set_enabled(True)
|
|
616
|
+
else:
|
|
617
|
+
self.fit_name_input.set_value("")
|
|
618
|
+
self.fit_comment_input.set_value("")
|
|
619
|
+
self.save_fit_details_button.set_enabled(False)
|
|
620
|
+
|
|
621
|
+
def delete_fit(self, fit: FitResult) -> None:
|
|
622
|
+
if fit.id in self.sm.fits:
|
|
623
|
+
self.sm.delete_fit(fit)
|
|
624
|
+
self.sm.notify_listeners("fits_loaded")
|
|
625
|
+
self.sm.notify_listeners("fit_results_updated")
|
|
626
|
+
ui.notify(f"Deleted fit: {fit.name}")
|
|
627
|
+
self.sync_graphs()
|
|
628
|
+
self.generate_delete_fit_dropdown()
|
|
629
|
+
|
|
630
|
+
else:
|
|
631
|
+
ui.notify(f"Fit {fit.name} not found in the list of fits.", type="negative")
|
|
632
|
+
|
|
633
|
+
def sync_graphs(self) -> None:
|
|
634
|
+
"""Sync the graphs to the current state."""
|
|
635
|
+
|
|
636
|
+
for x in self.graphs:
|
|
637
|
+
x.update_x_axis_selects()
|
|
638
|
+
x.update_graph_x()
|
|
639
|
+
x.graph.update()
|
|
640
|
+
|
|
641
|
+
def clear_fit_graphs(self) -> None:
|
|
642
|
+
"""Clear both fit graphs."""
|
|
643
|
+
self.fit_graph.clear_graph(update=True)
|
|
644
|
+
self.speciation_graph.clear_graph(update=True)
|
|
645
|
+
ui.notify("Fit graphs cleared.", type="info")
|
|
646
|
+
|
|
647
|
+
def generate_delete_fit_dropdown(self) -> None:
|
|
648
|
+
"""Generate the dropdown for deleting fits."""
|
|
649
|
+
self.delete_fit_dropdown.clear()
|
|
650
|
+
with self.delete_fit_dropdown:
|
|
651
|
+
for fit in self.sm.fits.values():
|
|
652
|
+
ui.item(
|
|
653
|
+
fit.name,
|
|
654
|
+
on_click=lambda e, fit=fit: self.delete_fit(fit),
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
def save_fit_details(self) -> None:
|
|
658
|
+
self.sm.active_fit.name = self.fit_name_input.value
|
|
659
|
+
self.sm.active_fit.description = self.fit_comment_input.value
|
|
660
|
+
|
|
661
|
+
def download_fit_data_csv(self) -> None:
|
|
662
|
+
if self.sm.active_fit_id is None:
|
|
663
|
+
ui.notify("No active fit to download.", type="negative")
|
|
664
|
+
return
|
|
665
|
+
|
|
666
|
+
fit = self.sm.active_fit
|
|
667
|
+
if fit.calc_obs is None or fit.calc_obs.empty:
|
|
668
|
+
ui.notify("No calculated fit data available to download.", type="negative")
|
|
669
|
+
return
|
|
670
|
+
|
|
671
|
+
export_frames: list[pd.DataFrame] = []
|
|
672
|
+
expt_obj = getattr(fit, "_expt_data", None)
|
|
673
|
+
|
|
674
|
+
if expt_obj is not None:
|
|
675
|
+
comp_concs = fit.comp_concs.copy()
|
|
676
|
+
if isinstance(comp_concs, pd.DataFrame) and not comp_concs.empty:
|
|
677
|
+
export_frames.append(comp_concs.add_suffix("_component_conc"))
|
|
678
|
+
|
|
679
|
+
expt_source = expt_obj.data if expt_obj is not None else pd.DataFrame()
|
|
680
|
+
if isinstance(expt_source, pd.DataFrame) and not expt_source.empty:
|
|
681
|
+
matching_cols = [col for col in fit.calc_obs.columns if col in expt_source.columns]
|
|
682
|
+
if matching_cols:
|
|
683
|
+
expt_df = expt_source[matching_cols].copy().reindex(fit.calc_obs.index)
|
|
684
|
+
expt_df.columns = [f"{col}_expt" for col in expt_df.columns]
|
|
685
|
+
export_frames.append(expt_df)
|
|
686
|
+
|
|
687
|
+
calc_df = fit.calc_obs.copy()
|
|
688
|
+
calc_df.columns = [f"{col}_calc" for col in calc_df.columns]
|
|
689
|
+
export_frames.append(calc_df)
|
|
690
|
+
|
|
691
|
+
if fit.fit_speciation is not None and not fit.fit_speciation.empty:
|
|
692
|
+
spec_df = fit.fit_speciation.copy()
|
|
693
|
+
spec_df.columns = [f"{col}_calc_species" for col in spec_df.columns]
|
|
694
|
+
export_frames.append(spec_df)
|
|
695
|
+
|
|
696
|
+
export_df = pd.concat(export_frames, axis=1)
|
|
697
|
+
filename = f"fit_{safe_filename(fit.name, fallback='fit')}_data.csv"
|
|
698
|
+
csv = export_df.to_csv(index=False, encoding="utf-8", float_format="{:.5e}".format)
|
|
699
|
+
ui.download.content(csv, filename=filename)
|
|
700
|
+
ui.notify(f"Fit data downloaded as {filename}.", type="info")
|
|
701
|
+
|
|
702
|
+
def download_fit_notebook(self) -> None:
|
|
703
|
+
"""Export the active fit as a zip containing a Jupyter notebook and a data CSV."""
|
|
704
|
+
if self.sm.active_fit_id is None:
|
|
705
|
+
ui.notify("No active fit to export.", type="negative")
|
|
706
|
+
return
|
|
707
|
+
|
|
708
|
+
fit = self.sm.active_fit
|
|
709
|
+
try:
|
|
710
|
+
notebook, csv_df = self.sm.dump_fit_notebook(fit)
|
|
711
|
+
except Exception as exc:
|
|
712
|
+
ui.notify(f"Notebook export failed: {exc}", type="negative")
|
|
713
|
+
return
|
|
714
|
+
|
|
715
|
+
stem = safe_filename(fit.name, fallback="fit")
|
|
716
|
+
nb_bytes = json.dumps(notebook, indent=1).encode()
|
|
717
|
+
csv_bytes = csv_df.to_csv(index=False, float_format="{:.5e}".format).encode()
|
|
718
|
+
|
|
719
|
+
buf = io.BytesIO()
|
|
720
|
+
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
721
|
+
zf.writestr(f"{stem}.ipynb", nb_bytes)
|
|
722
|
+
zf.writestr(f"{stem}_data.csv", csv_bytes)
|
|
723
|
+
buf.seek(0)
|
|
724
|
+
|
|
725
|
+
zip_filename = f"{stem}_notebook.zip"
|
|
726
|
+
ui.download.content(buf.read(), filename=zip_filename)
|
|
727
|
+
ui.notify(f"Notebook exported as {zip_filename}.", type="positive")
|
|
728
|
+
|
|
729
|
+
class FitResultsCard(BaseComponent):
|
|
730
|
+
def setup_nicegui(self) -> None:
|
|
731
|
+
|
|
732
|
+
with ui.card():
|
|
733
|
+
# ui.label("Fitting Results to go here.")
|
|
734
|
+
with ui.row().classes("w-full"):
|
|
735
|
+
ui.label("Results:")
|
|
736
|
+
self._show_params_cb = ui.checkbox("Show params", value=True, on_change=self._load_fits_to_table)
|
|
737
|
+
self._show_stats_cb = ui.checkbox("Show stats", value=False, on_change=self._load_fits_to_table)
|
|
738
|
+
with ui.element('div').classes("w-full overflow-x-auto"):
|
|
739
|
+
self.default_columns = [
|
|
740
|
+
{'name': 'name', 'label': 'Name', 'field': 'name', 'align': 'left'},
|
|
741
|
+
{'name': 'K1', 'label': 'K1', 'field': 'K1'},
|
|
742
|
+
{'name': 'chisqr', 'label': 'ChiSqr', 'field': 'chisqr'},
|
|
743
|
+
{'name': 'aic', 'label': 'AIC', 'field': 'aic'},
|
|
744
|
+
{'name': 'bic', 'label': 'BIC', 'field': 'bic'},
|
|
745
|
+
{'name': 'message', 'label': 'Message', 'field': 'message'},
|
|
746
|
+
{'name': 'fit_id', 'label': 'Fit ID', 'field': 'fit_id', 'classes': 'hidden','headerClasses': 'hidden'},
|
|
747
|
+
]
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
rows = [
|
|
751
|
+
{
|
|
752
|
+
'name': ''
|
|
753
|
+
}
|
|
754
|
+
]
|
|
755
|
+
|
|
756
|
+
self.table = ui.table(
|
|
757
|
+
columns=self.default_columns,
|
|
758
|
+
rows=rows,
|
|
759
|
+
row_key='name'
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
self.table.add_slot('header', r'''
|
|
763
|
+
<q-tr :props="props">
|
|
764
|
+
<q-th auto-width />
|
|
765
|
+
<q-th v-for="col in props.cols" :key="col.name" :props="props" style="white-space:normal; word-break:break-all; max-width:64px">
|
|
766
|
+
{{ col.label }}
|
|
767
|
+
</q-th>
|
|
768
|
+
</q-tr>
|
|
769
|
+
''')
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
#self.add_body_slot()
|
|
773
|
+
self.table.on('delete', self.delete_row)
|
|
774
|
+
self.table.on('rename', self.rename_fit)
|
|
775
|
+
self.table.on('select', self.select_fit)
|
|
776
|
+
# https://github.com/zauberzeug/nicegui/blob/main/examples/editable_table/main.py
|
|
777
|
+
|
|
778
|
+
def setup_bindings(self) -> None:
|
|
779
|
+
super().setup_bindings()
|
|
780
|
+
self.sm.add_listener("fits_loaded", self._load_fits_to_table)
|
|
781
|
+
self.sm.add_listener("redraw_fits_table", self._load_fits_to_table)
|
|
782
|
+
self.sm.add_listener("fit_changed", self._load_fits_to_table)
|
|
783
|
+
|
|
784
|
+
def _load_fits_to_table(self, e=None) -> None:
|
|
785
|
+
"""Load fits into the table."""
|
|
786
|
+
show_params = getattr(self, '_show_params_cb', None)
|
|
787
|
+
show_params = show_params.value if show_params is not None else True
|
|
788
|
+
show_stats = getattr(self, '_show_stats_cb', None)
|
|
789
|
+
show_stats = show_stats.value if show_stats is not None else False
|
|
790
|
+
|
|
791
|
+
self.table.rows = []
|
|
792
|
+
fitParams = []
|
|
793
|
+
for fit in self.sm.fits.values():
|
|
794
|
+
if hasattr(fit, "params"):
|
|
795
|
+
fitParams+=[x for x in list(fit.params.keys()) if x[3:] not in fit.model.component_names]
|
|
796
|
+
|
|
797
|
+
fitParams = list(dict.fromkeys(fitParams)) # Remove duplicates
|
|
798
|
+
|
|
799
|
+
paramCols = [{'name': param, 'label': param, 'field': param} for param in fitParams]
|
|
800
|
+
|
|
801
|
+
stat_col_names = {'chisqr', 'aic', 'bic', 'message', 'covariance'}
|
|
802
|
+
stat_cols = [c for c in self.default_columns if c['name'] in stat_col_names]
|
|
803
|
+
hidden_cols = [c for c in self.default_columns if c.get('classes') == 'hidden']
|
|
804
|
+
|
|
805
|
+
cols = [self.default_columns[0]]
|
|
806
|
+
if show_params:
|
|
807
|
+
cols.extend(paramCols)
|
|
808
|
+
if show_stats:
|
|
809
|
+
cols.extend(stat_cols)
|
|
810
|
+
cols.extend(hidden_cols)
|
|
811
|
+
|
|
812
|
+
self.table.columns = cols
|
|
813
|
+
|
|
814
|
+
for fit in self.sm.fits.values():
|
|
815
|
+
row = {
|
|
816
|
+
'name': fit.name,
|
|
817
|
+
'chisqr': self.rounded_value(fit.chisqr),
|
|
818
|
+
'aic': self.rounded_value(fit.aic, dp=1),
|
|
819
|
+
'bic': self.rounded_value(fit.bic, dp=1),
|
|
820
|
+
'message': fit.termination_message,
|
|
821
|
+
'fit_id': str(fit.id),
|
|
822
|
+
'is_active': str(fit.id) == str(self.sm.active_fit_id),
|
|
823
|
+
# 'covariance': fit.miniResult.covariance if hasattr(fit.miniResult, 'covariance') else 'N/A'
|
|
824
|
+
}
|
|
825
|
+
for param in fitParams:
|
|
826
|
+
if param in fit.params:
|
|
827
|
+
row[param] = self.rounded_value(fit.params[param]['value'])
|
|
828
|
+
else:
|
|
829
|
+
row[param] = 'N/A'
|
|
830
|
+
self.table.rows.append(row)
|
|
831
|
+
self.add_body_slot()
|
|
832
|
+
self.table.update()
|
|
833
|
+
|
|
834
|
+
def rounded_value(self, value: float,dp: int=3) -> str:
|
|
835
|
+
"""Round the value for display."""
|
|
836
|
+
if abs(value) >= 10000 or abs(value) < 0.001:
|
|
837
|
+
return f'{value:.4e}'
|
|
838
|
+
else:
|
|
839
|
+
return f'{value:.{dp}f}' if isinstance(value, float) else str(value)
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def add_body_slot(self) -> None:
|
|
843
|
+
"""Add the body slot for the table."""
|
|
844
|
+
self.table.add_slot('body', r'''
|
|
845
|
+
<q-tr :props="props" :class="props.row.is_active ? 'bg-blue-1' : ''" @click="() => $parent.$emit('select', props.row)" style="cursor: pointer">
|
|
846
|
+
<q-td auto-width >
|
|
847
|
+
<q-btn size="xs" color="warning" round dense icon="delete"
|
|
848
|
+
@click="() => $parent.$emit('delete', props.row)"
|
|
849
|
+
/>
|
|
850
|
+
</q-td>
|
|
851
|
+
<q-td key="name" :props="props">
|
|
852
|
+
{{ props.row.name }}
|
|
853
|
+
<q-popup-edit v-model="props.row.name" v-slot="scope"
|
|
854
|
+
@update:model-value="() => $parent.$emit('rename', props.row)"
|
|
855
|
+
>
|
|
856
|
+
<q-input v-model="scope.value" dense autofocus counter @keyup.enter="scope.set" />
|
|
857
|
+
</q-popup-edit>
|
|
858
|
+
</q-td>'''+
|
|
859
|
+
''.join(
|
|
860
|
+
[f'''
|
|
861
|
+
<q-td key="{col['name']}" :props="props">
|
|
862
|
+
{{{{ props.row.{col['field']} }}}}
|
|
863
|
+
</q-td>''' for col in self.table.columns[1:]]
|
|
864
|
+
) +
|
|
865
|
+
'''</q-tr>
|
|
866
|
+
''')
|
|
867
|
+
|
|
868
|
+
def delete_row(self, e: events.GenericEventArguments) -> None:
|
|
869
|
+
"""Delete a row from the table."""
|
|
870
|
+
fit_id = uuid.UUID(e.args['fit_id'])
|
|
871
|
+
fit = self.sm.fits.get(fit_id)
|
|
872
|
+
if fit is None:
|
|
873
|
+
return
|
|
874
|
+
self.sm.delete_fit(fit)
|
|
875
|
+
self.sm.notify_listeners('fits_loaded')
|
|
876
|
+
self.sm.notify_listeners('fit_results_updated')
|
|
877
|
+
|
|
878
|
+
def select_fit(self, e: events.GenericEventArguments) -> None:
|
|
879
|
+
fit_id = uuid.UUID(e.args['fit_id'])
|
|
880
|
+
if fit_id in self.sm.fits:
|
|
881
|
+
self.sm.active_fit_id = fit_id
|
|
882
|
+
self.sm.notify_listeners("fit_changed")
|
|
883
|
+
self.sm.notify_listeners("active_context_changed", {})
|
|
884
|
+
|
|
885
|
+
def rename_fit(self, e: events.GenericEventArguments) -> None:
|
|
886
|
+
print(e)
|