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.
Files changed (37) hide show
  1. bindmc/main.py +67 -0
  2. bindmc/webgui/__init__.py +0 -0
  3. bindmc/webgui/app.py +54 -0
  4. bindmc/webgui/classes/BindingConstant.py +23 -0
  5. bindmc/webgui/classes/ChemicalShiftParam.py +40 -0
  6. bindmc/webgui/classes/Component.py +111 -0
  7. bindmc/webgui/classes/ExptData.py +485 -0
  8. bindmc/webgui/classes/ExptDataType.py +92 -0
  9. bindmc/webgui/classes/FitResult.py +173 -0
  10. bindmc/webgui/classes/MCMCSim.py +232 -0
  11. bindmc/webgui/classes/Model.py +86 -0
  12. bindmc/webgui/classes/RawData.py +36 -0
  13. bindmc/webgui/classes/Simulation.py +104 -0
  14. bindmc/webgui/classes/UIBindings.py +19 -0
  15. bindmc/webgui/classes/__init__.py +28 -0
  16. bindmc/webgui/components/__init__.py +29 -0
  17. bindmc/webgui/components/base.py +24 -0
  18. bindmc/webgui/components/bayes.py +689 -0
  19. bindmc/webgui/components/bayes_priors.py +351 -0
  20. bindmc/webgui/components/binding_model.py +330 -0
  21. bindmc/webgui/components/body.py +276 -0
  22. bindmc/webgui/components/data_gen.py +419 -0
  23. bindmc/webgui/components/data_import.py +450 -0
  24. bindmc/webgui/components/data_model.py +609 -0
  25. bindmc/webgui/components/fitting.py +886 -0
  26. bindmc/webgui/components/graph.py +649 -0
  27. bindmc/webgui/components/header.py +124 -0
  28. bindmc/webgui/components/simulation.py +385 -0
  29. bindmc/webgui/export/__init__.py +0 -0
  30. bindmc/webgui/export/notebook_exporter.py +727 -0
  31. bindmc/webgui/state/__init__.py +1 -0
  32. bindmc/webgui/state/statemanager.py +2043 -0
  33. bindmc/webgui/utils.py +322 -0
  34. bindmc-0.1.0.dist-info/METADATA +22 -0
  35. bindmc-0.1.0.dist-info/RECORD +37 -0
  36. bindmc-0.1.0.dist-info/WHEEL +5 -0
  37. 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)