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,2043 @@
1
+ import gzip
2
+ import io
3
+ import json
4
+ import os
5
+ import sys
6
+ import uuid
7
+ from dataclasses import asdict
8
+ from datetime import datetime
9
+ from typing import Optional, Callable, Any
10
+
11
+ import numpy as np
12
+ import pandas as pd
13
+ from nicegui import app, ui
14
+ from nicegui.events import UploadEventArguments, ValueChangeEventArguments
15
+ import bindtools.binding as bd
16
+
17
+ from ..classes import BindingConstant, Component, Model, Simulation, ExptData, FitResult, RawData,ExptDataType, ChemicalShiftParam, MCMCSim, UIBindings
18
+ from ..utils import eq_mat_from_equation_str_infer_components
19
+ from lmfit import Parameter as LMFitParameter
20
+ import logging
21
+ logger = logging.getLogger(__name__)
22
+
23
+ class StateManager:
24
+
25
+ def __init__(self,load_prior_state: bool = True):
26
+ self.models: dict[uuid.UUID,Model] = {} # make these uuid-keyed dicts?
27
+ self.fits: dict[uuid.UUID,FitResult] = {}
28
+ self.expt_datas: dict[uuid.UUID,ExptData] = {}
29
+ self.simulations: dict[uuid.UUID,Simulation] = {}
30
+ self.experimental_data: Optional[pd.DataFrame] = None
31
+ self.raw_datas: dict[uuid.UUID, RawData] = {}
32
+ self.mcmcs: dict[uuid.UUID, MCMCSim] = {}
33
+ self._active_model_id: Optional[uuid.UUID] = None
34
+ self._active_sim_id: Optional[uuid.UUID] = None
35
+ self._active_fit_id: Optional[uuid.UUID] = None
36
+ self._active_expt_data_id: Optional[uuid.UUID] = None
37
+ self._active_raw_data_id: Optional[uuid.UUID] = None
38
+ self._active_mcmc_id: Optional[uuid.UUID] = None
39
+ self._listeners: dict[str, list[Callable]] = {}
40
+ self.default_model_ids: list[uuid.UUID] = []
41
+ self.raw_data: RawData = RawData() # Initialize with an empty RawData instance
42
+ self.ui_bindings: UIBindings = UIBindings()
43
+
44
+ self._expt_dtypes: dict[str,ExptDataType] = {
45
+ 'conc': ExptDataType(name='Conc.', init_meas='grav_vol', units='M'),
46
+ 'nmr conc': ExptDataType(name='NMR Conc.', init_meas='nmr_integ', units='M'),
47
+ 'delta h': ExptDataType(name='H (ppm)',init_meas='nmr_ppm',units='ppm'),
48
+ 'delta f': ExptDataType(name='F (ppm)',init_meas='nmr_ppm',units='ppm'),
49
+ 'absorbance': ExptDataType(name='Absorbance', init_meas='uvvis', units='absorbance'),
50
+ 'fluorescence': ExptDataType(name='Fluorescence int.', init_meas='fluor', units='intensity'),
51
+ }
52
+
53
+
54
+
55
+ self._init_bindables()
56
+ self.add_listener(
57
+ "model_changed", self.save_to_storage
58
+ ) # Save state when model is updated
59
+ self.add_listener(
60
+ "simulation_completed", self.save_to_storage
61
+ ) # Save state when simulation is completed
62
+ self.add_listener(
63
+ "fit_completed", self.save_to_storage
64
+ ) # Save state when simulation is completed
65
+ self.add_listener(
66
+ "simulation_deleted", self.save_to_storage
67
+ )
68
+
69
+ # self._set_initial_simFig_style()
70
+
71
+ if load_prior_state and app.storage.user.get("state-data"):
72
+ try:
73
+ self.load_storage_into_state()
74
+ except Exception as e:
75
+ logger.error(f"Error loading model data: {e}")
76
+
77
+ # Initialize with default model
78
+ if len(self.models) == 0:
79
+ self._initialize_default_models(emit_events=False)
80
+
81
+
82
+ def load_storage_into_state(self):
83
+
84
+ orphans = []
85
+ self.from_json(
86
+ app.storage.user["state-data"]
87
+ ) # Load SimData from user storage if available
88
+ logger.info("Loaded model data from user storage.")
89
+ # link simulations (which specify a modelID, not a full model object) to the actual models
90
+ for sim in self.simulations.values():
91
+ try:
92
+ sim.find_and_link_model(
93
+ self.models
94
+ ) # TODO: handle case where model is not found (seems unlikely?)
95
+ except(ValueError):
96
+ orphans.append(sim)
97
+ for o in orphans:
98
+ logger.info(f'Simulation {o.id} is orphaned; deleting.')
99
+ self._delete_object_core(o)
100
+
101
+ orphans = []
102
+ for fit in self.fits.values():
103
+ try:
104
+ fit.find_and_link_model(
105
+ self.models)
106
+ fit.find_and_link_expt_data(
107
+ self.expt_datas)
108
+ except(ValueError):
109
+ orphans.append(fit)
110
+
111
+ for expt_data in self.expt_datas.values():
112
+ try:
113
+ expt_data.find_and_link_model(
114
+ self.models)
115
+ expt_data.find_and_link_raw_data(
116
+ self.raw_datas)
117
+
118
+ # Initialize selected_columns after raw data is linked
119
+ if not expt_data.selected_columns and hasattr(expt_data, '_raw_data') and expt_data._raw_data:
120
+ raw_data = expt_data._raw_data.data if hasattr(expt_data._raw_data, 'data') else pd.DataFrame()
121
+ if not raw_data.empty:
122
+ expt_data.selected_columns = raw_data.columns.tolist()
123
+ except(ValueError):
124
+ orphans.append(expt_data)
125
+
126
+ for mcmc in self.mcmcs.values():
127
+ try:
128
+ mcmc.find_and_link_model(self.models)
129
+ mcmc.find_and_link_expt_data(self.expt_datas)
130
+ except(ValueError):
131
+ orphans.append(mcmc)
132
+
133
+ for o in orphans:
134
+ if isinstance(o,ExptData):
135
+ otype='ExptData'
136
+ elif isinstance(o,MCMCSim):
137
+ otype = 'MCMCSim'
138
+ elif isinstance(o,FitResult):
139
+ otype = 'FitResult'
140
+ else:
141
+ otype = "Unknown object"
142
+ logger.warning(f'{otype} {o.id} is orphaned, deleting.')
143
+ self._delete_object_core(o)
144
+
145
+ self._finalize_active_context(reason="load_storage_into_state", emit_events=True)
146
+ # Emit key events so that UI components refresh
147
+ self.notify_listeners("active_context_changed", {})
148
+ self.notify_listeners("model_changed")
149
+ self.notify_listeners("data_imported")
150
+ self.notify_listeners("fits_loaded")
151
+
152
+ def add_listener(self, event: str, callback: Callable):
153
+ """Add a listener for a specific event."""
154
+ if event not in self._listeners:
155
+ self._listeners[event] = []
156
+ self._listeners[event].append(callback)
157
+
158
+ # model_changed - called from binding_model.py; listened by data_gen
159
+
160
+ def notify_listeners(self, event: ValueChangeEventArguments|str, *args, **kwargs):
161
+ """Notify all listeners of a specific event."""
162
+ if event in self._listeners and isinstance(event,str):
163
+ logger.info(f"Listener notified: {event}")
164
+ uniq_list = list(set(self._listeners[event])) # Ensure unique callbacks
165
+ for callback in uniq_list:
166
+ callback(*args, **kwargs)
167
+
168
+ def _snapshot_object_ids(self) -> dict[str, set[uuid.UUID]]:
169
+ return {
170
+ "models": set(self.models.keys()),
171
+ "fits": set(self.fits.keys()),
172
+ "simulations": set(self.simulations.keys()),
173
+ "expt_datas": set(self.expt_datas.keys()),
174
+ "raw_datas": set(self.raw_datas.keys()),
175
+ "mcmcs": set(self.mcmcs.keys()),
176
+ }
177
+
178
+ def _emit_collection_events(self, before: dict[str, set[uuid.UUID]], after: dict[str, set[uuid.UUID]], notify_listeners: bool) -> None:
179
+ """Emit compatibility events for collection-level changes."""
180
+ if not notify_listeners:
181
+ return
182
+ if before["models"] != after["models"]:
183
+ self.notify_listeners("model_changed")
184
+ if before["fits"] != after["fits"]:
185
+ self.notify_listeners("fit_deleted")
186
+ if before["simulations"] != after["simulations"]:
187
+ self.notify_listeners("simulation_deleted")
188
+ if before["expt_datas"] != after["expt_datas"] or before["raw_datas"] != after["raw_datas"]:
189
+ self.notify_listeners("expt_data_changed")
190
+ self.notify_listeners("data_imported")
191
+ if before["mcmcs"] != after["mcmcs"]:
192
+ self.notify_listeners("mcmc_changed")
193
+
194
+ def _latest_id(self, coll: dict[uuid.UUID, Any]) -> uuid.UUID | None:
195
+ return next(reversed(coll), None) if coll else None
196
+
197
+ def _latest_matching_id(self, coll: dict[uuid.UUID, Any], predicate: Callable[[Any], bool]) -> uuid.UUID | None:
198
+ for id, obj in reversed(list(coll.items())):
199
+ if predicate(obj):
200
+ return id
201
+ return None
202
+
203
+ def _normalize_uuid(self, value: uuid.UUID | str | None) -> uuid.UUID | None:
204
+ if value is None:
205
+ return None
206
+ if isinstance(value, str):
207
+ if value in ("", "None"):
208
+ return None
209
+ try:
210
+ return uuid.UUID(value)
211
+ except Exception:
212
+ return None
213
+ if isinstance(value, uuid.UUID):
214
+ return value
215
+ return None
216
+
217
+ def _reconcile_active_ids(self, reason: str = "") -> dict[str, tuple[uuid.UUID | None, uuid.UUID | None]]:
218
+ """Ensure all active IDs are valid and context-consistent."""
219
+ changes: dict[str, tuple[uuid.UUID | None, uuid.UUID | None]] = {}
220
+
221
+ def _set_active(attr: str, new_value: uuid.UUID | None) -> None:
222
+ old_value = getattr(self, attr)
223
+ if old_value != new_value:
224
+ setattr(self, attr, new_value)
225
+ changes[attr] = (old_value, new_value)
226
+
227
+ if not self.models:
228
+ self._initialize_default_models(emit_events=False)
229
+ _set_active("_active_model_id", self._normalize_uuid(self._active_model_id))
230
+ if self._active_model_id not in self.models:
231
+ _set_active("_active_model_id", self._latest_id(self.models))
232
+
233
+ # Raw data: valid ID or fallback to latest.
234
+ _set_active("_active_raw_data_id", self._normalize_uuid(self._active_raw_data_id))
235
+ if self._active_raw_data_id not in self.raw_datas:
236
+ fallback_raw = None
237
+ maybe_expt_id = self._normalize_uuid(self._active_expt_data_id)
238
+ if maybe_expt_id in self.expt_datas and maybe_expt_id is not None:
239
+ maybe_raw = self._normalize_uuid(self.expt_datas[maybe_expt_id].raw_data_id)
240
+ if maybe_raw in self.raw_datas:
241
+ fallback_raw = maybe_raw
242
+ if fallback_raw is None:
243
+ fallback_raw = self._latest_id(self.raw_datas)
244
+ _set_active("_active_raw_data_id", fallback_raw)
245
+
246
+ # Expt data: prefer active raw data, then active model, then any.
247
+ _set_active("_active_expt_data_id", self._normalize_uuid(self._active_expt_data_id))
248
+ expt_is_valid = self._active_expt_data_id in self.expt_datas
249
+ if expt_is_valid and self._active_raw_data_id is not None and self._active_expt_data_id is not None:
250
+ expt_is_valid = self.expt_datas[self._active_expt_data_id].raw_data_id == self._active_raw_data_id
251
+ if not expt_is_valid:
252
+ candidate = None
253
+ if self._active_raw_data_id is not None:
254
+ candidate = self._latest_matching_id(
255
+ self.expt_datas, lambda d: d.raw_data_id == self._active_raw_data_id
256
+ )
257
+ if candidate is None and self._active_model_id is not None:
258
+ candidate = self._latest_matching_id(
259
+ self.expt_datas, lambda d: d.model_id == self._active_model_id
260
+ )
261
+ if candidate is None:
262
+ candidate = self._latest_id(self.expt_datas)
263
+ _set_active("_active_expt_data_id", candidate)
264
+
265
+ if self._active_raw_data_id is None and self._active_expt_data_id in self.expt_datas and self._active_expt_data_id is not None:
266
+ maybe_raw = self._normalize_uuid(self.expt_datas[self._active_expt_data_id].raw_data_id)
267
+ if maybe_raw in self.raw_datas:
268
+ _set_active("_active_raw_data_id", maybe_raw)
269
+
270
+ # Fits: prefer active model + expt, then expt, then model, then any.
271
+ _set_active("_active_fit_id", self._normalize_uuid(self._active_fit_id))
272
+ fit_is_valid = self._active_fit_id in self.fits
273
+ if fit_is_valid and self._active_fit_id is not None:
274
+ afit = self.fits[self._active_fit_id]
275
+ if self._active_model_id is not None and afit.model_id != self._active_model_id:
276
+ fit_is_valid = False
277
+ if self._active_expt_data_id is not None and afit.expt_data_id != self._active_expt_data_id:
278
+ fit_is_valid = False
279
+ if not fit_is_valid:
280
+ candidate = None
281
+ if self._active_model_id is not None and self._active_expt_data_id is not None:
282
+ candidate = self._latest_matching_id(
283
+ self.fits,
284
+ lambda f: f.model_id == self._active_model_id and f.expt_data_id == self._active_expt_data_id,
285
+ )
286
+ if candidate is None and self._active_expt_data_id is not None:
287
+ candidate = self._latest_matching_id(
288
+ self.fits, lambda f: f.expt_data_id == self._active_expt_data_id
289
+ )
290
+ if candidate is None and self._active_model_id is not None:
291
+ candidate = self._latest_matching_id(
292
+ self.fits, lambda f: f.model_id == self._active_model_id
293
+ )
294
+ if candidate is None:
295
+ candidate = self._latest_id(self.fits)
296
+ _set_active("_active_fit_id", candidate)
297
+
298
+ # Simulations: prefer active model then any.
299
+ _set_active("_active_sim_id", self._normalize_uuid(self._active_sim_id))
300
+ sim_is_valid = self._active_sim_id in self.simulations
301
+ if sim_is_valid and self._active_model_id is not None and self._active_sim_id is not None:
302
+ sim_is_valid = self.simulations[self._active_sim_id].model_id == self._active_model_id
303
+ if not sim_is_valid:
304
+ candidate = None
305
+ if self._active_model_id is not None:
306
+ candidate = self._latest_matching_id(
307
+ self.simulations, lambda s: s.model_id == self._active_model_id
308
+ )
309
+ if candidate is None:
310
+ candidate = self._latest_id(self.simulations)
311
+ _set_active("_active_sim_id", candidate)
312
+
313
+ # MCMC: prefer active model + expt, then expt, then model, then any.
314
+ _set_active("_active_mcmc_id", self._normalize_uuid(self._active_mcmc_id))
315
+ mcmc_is_valid = self._active_mcmc_id in self.mcmcs
316
+ if mcmc_is_valid and self._active_mcmc_id is not None:
317
+ amcmc = self.mcmcs[self._active_mcmc_id]
318
+ if self._active_model_id is not None and amcmc.model_id != self._active_model_id:
319
+ mcmc_is_valid = False
320
+ if self._active_expt_data_id is not None and amcmc.expt_data_id != self._active_expt_data_id:
321
+ mcmc_is_valid = False
322
+ if not mcmc_is_valid:
323
+ candidate = None
324
+ if self._active_model_id is not None and self._active_expt_data_id is not None:
325
+ candidate = self._latest_matching_id(
326
+ self.mcmcs,
327
+ lambda m: m.model_id == self._active_model_id and m.expt_data_id == self._active_expt_data_id,
328
+ )
329
+ if candidate is None and self._active_expt_data_id is not None:
330
+ candidate = self._latest_matching_id(
331
+ self.mcmcs, lambda m: m.expt_data_id == self._active_expt_data_id
332
+ )
333
+ if candidate is None and self._active_model_id is not None:
334
+ candidate = self._latest_matching_id(
335
+ self.mcmcs, lambda m: m.model_id == self._active_model_id
336
+ )
337
+ if candidate is None:
338
+ candidate = self._latest_id(self.mcmcs)
339
+ _set_active("_active_mcmc_id", candidate)
340
+
341
+ if reason and changes != {}:
342
+ logger.info(f"Reconciled active IDs ({reason}): {changes}")
343
+ return changes
344
+
345
+ def _emit_active_context_events(self, changes: dict[str, tuple[uuid.UUID | None, uuid.UUID | None]]) -> None:
346
+ if not changes:
347
+ return
348
+ self.notify_listeners("active_context_changed", changes)
349
+ if "_active_model_id" in changes:
350
+ self.update_ui_bindings(["model_name"])
351
+ self.notify_listeners("model_changed")
352
+ if "_active_expt_data_id" in changes or "_active_raw_data_id" in changes:
353
+ self.notify_listeners("expt_data_changed")
354
+ self.notify_listeners("data_imported")
355
+ if "_active_fit_id" in changes:
356
+ self.notify_listeners("fit_changed")
357
+ if "_active_sim_id" in changes:
358
+ self.notify_listeners("sim_changed")
359
+
360
+ def _finalize_active_context(self, reason: str, emit_events: bool = True) -> dict[str, tuple[uuid.UUID | None, uuid.UUID | None]]:
361
+ changes = self._reconcile_active_ids(reason=reason)
362
+ if emit_events:
363
+ self._emit_active_context_events(changes)
364
+ return changes
365
+
366
+ def reconcile_active_context(self, reason: str = "manual", emit_events: bool = True) -> dict[str, tuple[uuid.UUID | None, uuid.UUID | None]]:
367
+ """Public wrapper to reconcile and optionally emit active-context events."""
368
+ return self._finalize_active_context(reason=reason, emit_events=emit_events)
369
+
370
+ def _delete_object_core(self, obj: Model | FitResult | Simulation | ExptData | RawData | MCMCSim) -> None:
371
+ """Delete an object and dependent objects without emitting events."""
372
+ if isinstance(obj, Model):
373
+ if obj.id in self.models:
374
+ del self.models[obj.id]
375
+ if self._active_model_id == obj.id:
376
+ self._active_model_id = None
377
+ elif isinstance(obj, FitResult):
378
+ if obj.id in self.fits:
379
+ del self.fits[obj.id]
380
+ if self._active_fit_id == obj.id:
381
+ self._active_fit_id = None
382
+ elif isinstance(obj, Simulation):
383
+ if obj.id in self.simulations:
384
+ del self.simulations[obj.id]
385
+ if self._active_sim_id == obj.id:
386
+ self._active_sim_id = None
387
+ elif isinstance(obj, ExptData):
388
+ if obj.id in self.expt_datas:
389
+ del self.expt_datas[obj.id]
390
+ if self._active_expt_data_id == obj.id:
391
+ self._active_expt_data_id = None
392
+ elif isinstance(obj, RawData):
393
+ if obj.id in self.raw_datas:
394
+ del self.raw_datas[obj.id]
395
+ if self._active_raw_data_id == obj.id:
396
+ self._active_raw_data_id = None
397
+ elif isinstance(obj, MCMCSim):
398
+ if obj.id in self.mcmcs:
399
+ del self.mcmcs[obj.id]
400
+ if self._active_mcmc_id == obj.id:
401
+ self._active_mcmc_id = None
402
+ else:
403
+ raise TypeError("Expected an instance of Model, FitResult, Simulation, ExptData, RawData, or MCMCSim.")
404
+
405
+ self.clean_up_dependencies_for_obj(obj)
406
+
407
+ def delete_object(self, obj: Model | FitResult | Simulation | ExptData | RawData | MCMCSim) -> None:
408
+ """Compatibility wrapper; prefer typed delete methods."""
409
+ logger.warning("delete_object is a low-level API. Prefer typed delete methods.")
410
+ before = self._snapshot_object_ids()
411
+ self._delete_object_core(obj)
412
+ after = self._snapshot_object_ids()
413
+ self._emit_collection_events(before, after, notify_listeners=True)
414
+ self._finalize_active_context(reason="delete_object", emit_events=True)
415
+
416
+ def clean_up_dependencies_for_obj(self, obj: Model | FitResult | Simulation | ExptData | RawData | MCMCSim) -> None:
417
+ """Clean up dependencies when an object is deleted."""
418
+ objs_to_delete = []
419
+
420
+ if isinstance(obj, MCMCSim):
421
+ pass # nothing to do
422
+ elif isinstance(obj, Simulation):
423
+ pass # nothing to do
424
+ elif isinstance(obj, FitResult):
425
+ pass # nothing to do
426
+ elif isinstance(obj, ExptData):
427
+ for fit in list(self.fits.values()):
428
+ if fit.expt_data_id == obj.id:
429
+ objs_to_delete.append(fit)
430
+ for mcmc in list(self.mcmcs.values()):
431
+ if mcmc.expt_data_id == obj.id:
432
+ objs_to_delete.append(mcmc)
433
+ elif isinstance(obj, RawData):
434
+ for expt_data in list(self.expt_datas.values()):
435
+ if expt_data.raw_data_id == obj.id:
436
+ objs_to_delete.append(expt_data)
437
+ elif isinstance(obj, Model):
438
+ for sim in list(self.simulations.values()):
439
+ if sim.model_id == obj.id:
440
+ objs_to_delete.append(sim)
441
+ for fit in list(self.fits.values()):
442
+ if fit.model_id == obj.id:
443
+ objs_to_delete.append(fit)
444
+ for expt_data in list(self.expt_datas.values()):
445
+ if expt_data.model_id == obj.id:
446
+ objs_to_delete.append(expt_data)
447
+ for mcmc in list(self.mcmcs.values()):
448
+ if mcmc.model_id == obj.id:
449
+ objs_to_delete.append(mcmc)
450
+
451
+ for o in objs_to_delete:
452
+ self._delete_object_core(o)
453
+
454
+ @property
455
+ def active_model(self) -> Model:
456
+ """Get the currently active model."""
457
+ if self._active_model_id is None or self._active_model_id not in self.models:
458
+ raise IndexError("No active model set.")
459
+ return self.models[self._active_model_id]
460
+
461
+ @property
462
+ def active_model_id(self) -> uuid.UUID | None:
463
+ """Get the ID of the currently active model."""
464
+ return self._active_model_id
465
+
466
+ @active_model_id.setter
467
+ def active_model_id(self, id: uuid.UUID | str | None) -> None:
468
+ """Set the active model index and update the active model."""
469
+ if id is not None:
470
+ if isinstance(id,str):
471
+ id = uuid.UUID(id)
472
+ if not isinstance(id, uuid.UUID):
473
+ raise TypeError("Expected a UUID for active model ID.")
474
+ if id not in self.models:
475
+ raise ValueError(f"Model with ID {id} does not exist.")
476
+ self._active_model_id = id
477
+ self.update_ui_bindings(["model_name",])
478
+ # self.active_model = self.models[value] # Set the active model based on the index
479
+ # self.active_model_dict.update(self.active_model.to_dict()) # Update active_model with the new model's data
480
+
481
+
482
+ @property
483
+ def expt_data(self):
484
+ """Get the experimental data."""
485
+ logger.warning("Deprecated L109 expt_data getter sm")
486
+ return self.active_expt_data
487
+
488
+ @expt_data.setter
489
+ def expt_data(self, value):
490
+ """Set the experimental data."""
491
+ # if isinstance(value, ExptData):
492
+ # self._expt_data = value
493
+ logger.warning("Deprecated L117 expt_data setter sm")
494
+ raise NotImplementedError("Use add_expt_data instead")
495
+ # else:
496
+ # raise TypeError("Expected an instance of ExptData.")
497
+
498
+ @property
499
+ def active_sim_id(self) -> uuid.UUID|None:
500
+ return self._active_sim_id
501
+
502
+ @active_sim_id.setter
503
+ def active_sim_id(self, value: uuid.UUID | str | None ) -> None:
504
+ if value is not None:
505
+ if isinstance(value, str):
506
+ value = uuid.UUID(value)
507
+ if not isinstance(value, uuid.UUID):
508
+ raise TypeError("Expected a UUID for active simulation ID.")
509
+ if value not in self.simulations:
510
+ raise IndexError("Active simulation index does not exist in state.")
511
+ self._active_sim_id = value
512
+
513
+ @property
514
+ def active_simulation(self) -> Simulation|None:
515
+ """Get the currently active simulation."""
516
+ return self.active_sim
517
+
518
+ @property
519
+ def active_sim(self) -> Simulation|None:
520
+ if self.active_sim_id is None:
521
+ return None
522
+ if self.active_sim_id in self.simulations:
523
+ return self.simulations[self.active_sim_id]
524
+ else:
525
+ raise ValueError("Active simulation ID not present in simulation list.")
526
+
527
+ @property
528
+ def active_expt_data_id(self) -> Optional[uuid.UUID]:
529
+ return self._active_expt_data_id
530
+
531
+ @active_expt_data_id.setter
532
+ def active_expt_data_id(self, value: uuid.UUID | str | None) -> None:
533
+ if value is not None:
534
+ if isinstance(value,str):
535
+ value = uuid.UUID(value)
536
+ if not isinstance(value,uuid.UUID):
537
+ raise TypeError("Expected a UUID for active expt_data ID.")
538
+ if value not in self.expt_datas:
539
+ raise IndexError("Active expt_data index does not exist in state.")
540
+ self._active_expt_data_id = value
541
+
542
+ @property
543
+ def active_expt_data(self) -> ExptData :
544
+ """Get the currently active ExptData."""
545
+ if self._active_expt_data_id is None:
546
+ raise IndexError("No active ExptData set.")
547
+ # new_obj = ExptData()
548
+ # self.add_expt_data(new_obj)
549
+ # self.active_expt_data_id = new_obj.id # Set the active model to the newly created one
550
+ # return new_obj
551
+ else:
552
+ return(self.expt_datas[self._active_expt_data_id])
553
+
554
+ @property
555
+ def active_raw_data_id(self) -> Optional[uuid.UUID]:
556
+ return self._active_raw_data_id
557
+
558
+ @active_raw_data_id.setter
559
+ def active_raw_data_id(self, value: uuid.UUID | str | None) -> None:
560
+ if value is not None:
561
+ if isinstance(value,str):
562
+ value = uuid.UUID(value)
563
+ if not isinstance(value,uuid.UUID):
564
+ raise TypeError("Expected a UUID for active raw_data ID.")
565
+ if value not in self.raw_datas:
566
+ raise IndexError("Active raw_data index out of range")
567
+ self._active_raw_data_id =value
568
+
569
+ @property
570
+ def active_raw_data(self) -> Optional[RawData]:
571
+ if self.active_raw_data_id is None:
572
+ return None
573
+ if self.active_raw_data_id in self.raw_datas:
574
+ return self.raw_datas[self.active_raw_data_id]
575
+ else:
576
+ raise ValueError(f"Active raw_data id {self.active_raw_data_id} does not exist.")
577
+
578
+ @property
579
+ def active_fit_id(self) -> Optional[uuid.UUID]:
580
+ if self._active_fit_id is None:
581
+ return None
582
+ else:
583
+ return self._active_fit_id
584
+
585
+ @active_fit_id.setter
586
+ def active_fit_id(self, value: uuid.UUID | str | None) -> None:
587
+ if value is not None:
588
+ if isinstance(value,str):
589
+ value=uuid.UUID(value)
590
+ if not isinstance(value,uuid.UUID):
591
+ raise TypeError("Expected a UUID for active fit ID.")
592
+ if value not in self.fits:
593
+ raise IndexError("Active fit index out of range.")
594
+ self._active_fit_id = value
595
+
596
+ @property
597
+ def active_fit(self) -> FitResult:
598
+ """Get the currently active fit."""
599
+ if self.active_fit_id is None:
600
+ raise IndexError("No active fit set.")
601
+ elif self.active_fit_id in self.fits:
602
+ return self.fits[self.active_fit_id]
603
+ else:
604
+ raise(IndexError("Active fit ID not present in fit list."))
605
+
606
+ @property
607
+ def active_mcmc_id(self) -> Optional[uuid.UUID]:
608
+ return self._active_mcmc_id
609
+
610
+ @active_mcmc_id.setter
611
+ def active_mcmc_id(self, value: uuid.UUID | str | None) -> None:
612
+ if value is not None:
613
+ if isinstance(value,str):
614
+ value=uuid.UUID(value)
615
+ if not isinstance(value,uuid.UUID):
616
+ raise TypeError("Expected a UUID for active mcmc ID.")
617
+ if value not in self.mcmcs:
618
+ raise IndexError("Active mcmc index out of range.")
619
+ self._active_mcmc_id = value
620
+
621
+ @property
622
+ def active_mcmc(self) -> MCMCSim:
623
+ """Get the currently active mcmc."""
624
+ if self.active_mcmc_id is None:
625
+ raise IndexError("No active mcmc set.")
626
+ elif self.active_mcmc_id in self.mcmcs:
627
+ return self.mcmcs[self.active_mcmc_id]
628
+ else:
629
+ raise(IndexError("Active mcmc ID not present in mcmc list."))
630
+
631
+ @property
632
+ def active_sim_or_none(self) -> Simulation | None:
633
+ if self.active_sim_id is None:
634
+ return None
635
+ try:
636
+ return self.active_sim
637
+ except Exception:
638
+ return None
639
+
640
+ @property
641
+ def active_expt_data_or_none(self) -> ExptData | None:
642
+ if self.active_expt_data_id is None:
643
+ return None
644
+ try:
645
+ return self.active_expt_data
646
+ except Exception:
647
+ return None
648
+
649
+ @property
650
+ def active_raw_data_or_none(self) -> RawData | None:
651
+ if self.active_raw_data_id is None:
652
+ return None
653
+ try:
654
+ return self.active_raw_data
655
+ except Exception:
656
+ return None
657
+
658
+ @property
659
+ def active_fit_or_none(self) -> FitResult | None:
660
+ if self.active_fit_id is None:
661
+ return None
662
+ try:
663
+ return self.active_fit
664
+ except Exception:
665
+ return None
666
+
667
+ @property
668
+ def active_mcmc_or_none(self) -> MCMCSim | None:
669
+ if self.active_mcmc_id is None:
670
+ return None
671
+ try:
672
+ return self.active_mcmc
673
+ except Exception:
674
+ return None
675
+
676
+ @property
677
+ def model_name(self):
678
+ return self.active_model.name if isinstance(self.active_model, Model) else ""
679
+
680
+ @model_name.setter
681
+ def model_name(self, value):
682
+ if self.active_model is not None:
683
+ self.active_model.name = value
684
+ self._model_name = value
685
+ else:
686
+ self._model_name = value
687
+
688
+ @property
689
+ def eq_mat(self):
690
+ return (
691
+ self.active_model.eq_mat
692
+ if isinstance(self.active_model, Model)
693
+ else self._eq_mat
694
+ )
695
+
696
+ @eq_mat.setter
697
+ def eq_mat(self, value):
698
+ if self.active_model is not None:
699
+ self.active_model.eq_mat = value
700
+ self._eq_mat = value
701
+ else:
702
+ self._eq_mat = value
703
+
704
+ @property
705
+ def nComp(self):
706
+ return (
707
+ self.active_model.nComp
708
+ if isinstance(self.active_model, Model)
709
+ else self._nComp
710
+ )
711
+
712
+ @nComp.setter
713
+ def nComp(self, value):
714
+ if self.active_model is not None:
715
+ self.active_model.nComp = value
716
+ self._nComp = value
717
+ else:
718
+ self._nComp = value
719
+
720
+ @property
721
+ def nStep(self):
722
+ return (
723
+ self.active_model.nStep
724
+ if isinstance(self.active_model, Model)
725
+ else self._nStep
726
+ )
727
+
728
+ @nStep.setter
729
+ def nStep(self, value):
730
+ if self.active_model is not None:
731
+ self.active_model.nStep = value
732
+ self._nStep = value
733
+ else:
734
+ self._nStep = value
735
+
736
+ @property
737
+ def eq_str(self):
738
+ return (
739
+ self.active_model.eq_str
740
+ if isinstance(self.active_model, Model)
741
+ else self._eq_str
742
+ )
743
+
744
+ @eq_str.setter
745
+ def eq_str(self, value):
746
+ if self.active_model is not None:
747
+ self.active_model.eq_str = value
748
+ self._eq_str = value
749
+ else:
750
+ self._eq_str = value
751
+
752
+ @property
753
+ def eq_mat_str(self):
754
+ return (
755
+ self.active_model.eq_mat_str
756
+ if isinstance(self.active_model, Model)
757
+ else self._eq_mat_str
758
+ )
759
+
760
+ @eq_mat_str.setter
761
+ def eq_mat_str(self, value):
762
+ if self.active_model is not None:
763
+ self.active_model.eq_mat_str = value
764
+ self._eq_mat_str = value
765
+ else:
766
+ self._eq_mat_str = value
767
+
768
+ @property
769
+ def components(self):
770
+ return (
771
+ self.active_model.components
772
+ if isinstance(self.active_model, Model)
773
+ else self._components
774
+ )
775
+
776
+ @components.setter
777
+ def components(self, value):
778
+ if self.active_model is not None:
779
+ self.active_model.components = value
780
+ self._components = value
781
+ else:
782
+ self._components = value
783
+
784
+ @property
785
+ def species(self):
786
+ return (
787
+ self.active_model.species
788
+ if isinstance(self.active_model, Model)
789
+ else self._species
790
+ )
791
+
792
+ @species.setter
793
+ def species(self, value):
794
+ if self.active_model is not None:
795
+ self.active_model.species = value
796
+ self._species = value
797
+ else:
798
+ self._species = value
799
+
800
+ @property
801
+ def comp_concs(self):
802
+ return (
803
+ self.active_model.component_concs
804
+ if isinstance(self.active_model, Model)
805
+ else self._comp_concs
806
+ )
807
+
808
+ @comp_concs.setter
809
+ def comp_concs(self, value):
810
+ if self.active_model is not None:
811
+ self.active_model.component_concs = value
812
+ self._comp_concs = value
813
+ else:
814
+ self._comp_concs = value
815
+
816
+ @property
817
+ def binding_constants(self):
818
+ return (
819
+ self.active_model.binding_constants
820
+ if isinstance(self.active_model, Model)
821
+ else self._binding_constants
822
+ )
823
+
824
+ @binding_constants.setter
825
+ def binding_constants(self, value):
826
+ if self.active_model is not None:
827
+ self.active_model.binding_constants = value
828
+ self._binding_constants = value
829
+ else:
830
+ self._binding_constants = value
831
+
832
+ @property
833
+ def component_names(self):
834
+ return (
835
+ self.active_model.component_names
836
+ if isinstance(self.active_model, Model)
837
+ else self._component_names
838
+ )
839
+
840
+ @component_names.setter
841
+ def component_names(self, value):
842
+ if self.active_model is not None:
843
+ self.active_model.component_names = value
844
+ self._component_names = value
845
+ else:
846
+ self._component_names = value
847
+
848
+
849
+ def add_model(self, model: Model, emit_events: bool = True) -> None:
850
+ """Add a model to the state manager."""
851
+ if not isinstance(model, Model):
852
+ raise TypeError("Expected a Model instance.")
853
+ self.models[model.id] = model # Use UUID as key
854
+ self.active_model_id = model.id
855
+ self._finalize_active_context(reason="add_model", emit_events=emit_events)
856
+
857
+ def add_fit(self, fit: FitResult, emit_events: bool = True) -> None:
858
+ """Add a fit result to the state manager."""
859
+ if not isinstance(fit, FitResult):
860
+ raise TypeError("Expected a FitResult instance.")
861
+ self.fits[fit.id] = fit
862
+ self.active_fit_id = fit.id # Set the newly added fit as active
863
+ self._finalize_active_context(reason="add_fit", emit_events=emit_events)
864
+
865
+ def add_sim(self, sim: Simulation, emit_events: bool = True) -> None:
866
+ """Add a simulation to the state manager."""
867
+ if not isinstance(sim, Simulation):
868
+ raise TypeError("Expected a Simulation instance.")
869
+ self.simulations[sim.id]=sim
870
+ self.active_sim_id = sim.id
871
+ self._finalize_active_context(reason="add_sim", emit_events=emit_events)
872
+
873
+ def add_expt_data(self, expt_data: ExptData, emit_events: bool = True) -> None:
874
+ """Add experimental data to the state manager."""
875
+ if not isinstance(expt_data, ExptData):
876
+ raise TypeError("Expected an ExptData instance.")
877
+ self.expt_datas[expt_data.id]=expt_data
878
+ self.active_expt_data_id = expt_data.id
879
+ self.active_expt_data.col_details = {k: self.active_expt_data.col_details[k] if k in self.active_expt_data.col_details else {'depindep': None} for k in self.active_expt_data.data.columns}
880
+ self._finalize_active_context(reason="add_expt_data", emit_events=emit_events)
881
+
882
+
883
+ def add_raw_data(self, raw_data: RawData, emit_events: bool = True) -> None:
884
+ """Add raw data to the state manager."""
885
+ if not isinstance(raw_data, RawData):
886
+ raise TypeError("Expected a RawData instance.")
887
+ if raw_data.id in self.raw_datas:
888
+ raise ValueError(f"RawData with id {raw_data.id} already exists.")
889
+ self.raw_datas[raw_data.id] = raw_data
890
+ self.active_raw_data_id = raw_data.id
891
+ self._finalize_active_context(reason="add_raw_data", emit_events=emit_events)
892
+
893
+ def add_mcmc(self, mcmc: MCMCSim, emit_events: bool = True) -> None:
894
+ """Add a MCMC result to the state manager."""
895
+ if not isinstance(mcmc, MCMCSim):
896
+ raise TypeError("Expected an MCMCSim instance.")
897
+ self.mcmcs[mcmc.id] = mcmc
898
+ self.active_mcmc_id = mcmc.id
899
+ self._finalize_active_context(reason="add_mcmc", emit_events=emit_events)
900
+
901
+ def add_expt_data_type(self, expt_data_type: ExptDataType) -> None:
902
+ """Add an experimental data type to the state manager."""
903
+ if not isinstance(expt_data_type, ExptDataType):
904
+ raise TypeError("Expected an ExptDataType instance.")
905
+ if expt_data_type.name.lower() in self._expt_dtypes:
906
+ raise ValueError(f"ExptDataType with name {expt_data_type.name} already exists.")
907
+ self._expt_dtypes[expt_data_type.name] = expt_data_type
908
+
909
+ def _init_bindables(self):
910
+ # """Initialize bindable attributes."""
911
+
912
+ self._nComp: int = 2
913
+ self._nComp: int = 2 # Number of components
914
+ self._nStep: int = 20
915
+ self._eq_str = ""
916
+ self._eq_consts = None # Optional attribute for equilibrium constants
917
+ self._model_name = ""
918
+ self._comp_concs = pd.DataFrame({})
919
+ self._components = []
920
+ self._eq_mat = np.array([]) # Initialize eqMat as an empty array
921
+ self._binding_constants = []
922
+ self._species = []
923
+ self._component_names = []
924
+ self._eq_mat_str = ""
925
+
926
+ def new_model(self, name="New model") -> uuid.UUID:
927
+ """Create a new model and set it as the active model."""
928
+ existing_names = [model.name for model in self.models.values()]
929
+ if name in existing_names:
930
+ n_models_with_name = len([
931
+ model for model in self.models.values() if model.name.startswith(name)])
932
+ name = f"{name} ({n_models_with_name})" # Append a number to the name if it already exists
933
+
934
+ new_model = Model(name=name,
935
+ nComp=self.nComp,
936
+ nStep=self.nStep,
937
+ component_concs=self.comp_concs.copy(),
938
+ component_names=self.component_names.copy(),
939
+ components=self.components.copy(),
940
+ )
941
+
942
+ self.add_model(new_model)
943
+
944
+ return new_model.id
945
+
946
+ def _default_models_path(self) -> str:
947
+ if getattr(sys, 'frozen', False):
948
+ # PyInstaller onefile: data files are extracted to sys._MEIPASS
949
+ return os.path.join(getattr(sys, '_MEIPASS'), 'webgui', 'default_models.json')
950
+ return os.path.join(os.path.dirname(__file__), '..', 'default_models.json')
951
+
952
+ def _load_default_models_data(self) -> dict[str, Any]:
953
+ with open(self._default_models_path(), 'r') as f:
954
+ return json.load(f)
955
+
956
+ def _default_model_ids_from_bundle(self) -> set[uuid.UUID]:
957
+ ids: set[uuid.UUID] = set()
958
+ try:
959
+ default_models_data = self._load_default_models_data()
960
+ except Exception as e:
961
+ logger.warning(f"Could not load bundled default model IDs: {e}")
962
+ return ids
963
+ for model_data in default_models_data.get("models", []):
964
+ mid = self._normalize_uuid(model_data.get("id"))
965
+ if mid is not None:
966
+ ids.add(mid)
967
+ return ids
968
+
969
+ def _initialize_default_models(self, emit_events: bool = True):
970
+ """Initialize with default models."""
971
+ self.default_model_ids = []
972
+ try:
973
+ default_models_data = self._load_default_models_data()
974
+ except Exception as e:
975
+ logger.error(f"Error loading default models: {e}")
976
+ raise RuntimeError("Unable to initialize built-in models.") from e
977
+
978
+ # Create models from the loaded data
979
+ for model_data in default_models_data.get('models', []):
980
+ model = Model(**model_data)
981
+ # Reconstruct complex objects
982
+ model.components = [Component(**comp) for comp in model_data.get("components", [])]
983
+ model.binding_constants = [BindingConstant(**k) for k in model_data.get("binding_constants", [])]
984
+ model.eq_mat = np.array(model_data.get("eq_mat", []))
985
+ model.component_concs = pd.DataFrame(model_data.get("component_concs", {}))
986
+ self.add_model(model, emit_events=emit_events)
987
+ self.default_model_ids.append(model.id)
988
+
989
+ if len(self.models) == 0:
990
+ raise RuntimeError("No built-in models were loaded.")
991
+
992
+ logger.info(f"Loaded {len(self.default_model_ids)} default models")
993
+
994
+ # def _set_initial_simFig_style(self):
995
+ # """Set the initial style for the simulation figure."""
996
+ # self.simFig_data = {
997
+ # 'data': [],
998
+ # 'layout': {
999
+ # 'margin': {'l':50, 'r':0, 'b':20, 't':0},
1000
+ # 'plot_bgcolor': '#E5ECF6',
1001
+ # 'legend': {'y': .95},
1002
+ # 'xaxis': {'title': 'x-axis'},
1003
+ # 'yaxis': {'title': 'y-axis'},
1004
+ # },
1005
+ # }
1006
+
1007
+ async def new_project(self):
1008
+ """Create a new project."""
1009
+ try:
1010
+ with ui.dialog() as dialog, ui.card():
1011
+ ui.label("Create new project? Unsaved changes will be lost.")
1012
+ with ui.row():
1013
+ ui.button("Yes", on_click=lambda: dialog.submit(True))
1014
+ ui.button("No", on_click=lambda: dialog.submit(False))
1015
+
1016
+ result = await dialog
1017
+ if result:
1018
+ # Reset the UIState and reload the page
1019
+ # self.sd = UIState() # Reset the SimData instance
1020
+ app.storage.user["state-data_old"] = app.storage.user.get("state-data", "")
1021
+ self.__init__(load_prior_state=False) # Reinitialize the StateManager
1022
+
1023
+ # self.simFig_data["data"] = [] # Clear the simulation figure data
1024
+ # self.simFig.update()
1025
+ app.storage.user["state-data"] = self.to_json()
1026
+ ui.navigate.reload()
1027
+ else:
1028
+ ui.notify("New project creation cancelled", type="info")
1029
+ except Exception as e:
1030
+ logger.error(f"Error creating new project: {str(e)}")
1031
+ ui.notify("Failed to create new project", type="negative")
1032
+
1033
+ async def open_project(self):
1034
+ """Open an existing project."""
1035
+ try:
1036
+
1037
+ with ui.dialog() as dialog, ui.card():
1038
+ ui.label("Open Project File")
1039
+ upload_box = ui.upload(label="Choose file", auto_upload=True).props(
1040
+ 'accept=".json, .json.gz"'
1041
+ )
1042
+ ui.button("Cancel", on_click=lambda: dialog.submit("cancel"))
1043
+
1044
+ def on_upload_complete(e: UploadEventArguments) -> None:
1045
+ dialog.submit(e) # Store result for later
1046
+
1047
+ upload_box.on_upload(on_upload_complete)
1048
+
1049
+ result: UploadEventArguments|str= await dialog
1050
+
1051
+ if isinstance(result, str) and result == "cancel":
1052
+ ui.notify("Project loading cancelled", type="info")
1053
+ return
1054
+ elif isinstance(result, UploadEventArguments):
1055
+ filename = result.file.name
1056
+
1057
+ if filename.endswith(".gz"):
1058
+ file_content = await result.file.read()
1059
+ with gzip.GzipFile(fileobj=io.BytesIO(file_content), mode="rb") as gz:
1060
+ file_content = gz.read().decode("utf-8")
1061
+ else:
1062
+ file_content = await result.file.text("utf-8")
1063
+
1064
+ app.storage.user["state-data"] = file_content
1065
+ try:
1066
+ self.load_storage_into_state()
1067
+ except Exception as e:
1068
+ logger.error(f"Error loading model data: {e}")
1069
+ #ui.navigate.reload()
1070
+ ui.notify("Project loaded successfully", type="info")
1071
+ else:
1072
+ raise RuntimeError("Unexpected result type from dialog submission.")
1073
+
1074
+ except Exception as e:
1075
+ logger.error(f"Error opening project: {str(e)}")
1076
+ ui.notify("Failed to open project", type="negative")
1077
+
1078
+ def save_to_storage(self, e=None):
1079
+ """Save the current state to user storage."""
1080
+ app.storage.user["state-data"] = self.to_json()
1081
+
1082
+ async def save_project(self):
1083
+ """Save the current project."""
1084
+ logger.info("saving...")
1085
+ self.save_to_storage() # Save the current state to user storage
1086
+
1087
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
1088
+
1089
+ buffer = io.BytesIO()
1090
+
1091
+ with gzip.GzipFile(fileobj=buffer, mode="wb") as gz:
1092
+ gz.write(app.storage.user["state-data"].encode("utf-8"))
1093
+
1094
+ # Ensure data is flushed and gzip stream is closed before reading
1095
+ buffer.seek(0)
1096
+ filename = f"bindtools_project_{timestamp}.json.gz"
1097
+ ui.download.content(buffer.read(), filename=filename)
1098
+ ui.notify(f"Project saved as {filename}", type="info")
1099
+
1100
+ def to_json(self):
1101
+ return json.dumps(self.to_dict())
1102
+
1103
+ def to_dict(self):
1104
+ """Convert the state to a dictionary."""
1105
+ return {
1106
+ #"simFig_data": self.simFig_data if hasattr(self, "simFig_data") else {},
1107
+ #"simFig": self.simFig.to_dict() if hasattr(self, "simFig") else {},
1108
+ "nComp": int(self.nComp),
1109
+ "nStep": int(self.nStep),
1110
+ "eq_str": self.eq_str,
1111
+ "eq_mat": (
1112
+ self.eq_mat.tolist() if isinstance(self.eq_mat, np.ndarray) else []
1113
+ ), # Convert numpy arrays to lists
1114
+ "eq_mat_str": (
1115
+ self.eq_mat_str if hasattr(self, "eq_mat_str") else ""
1116
+ ), # Optional attribute
1117
+ "components": (
1118
+ [asdict(comp) for comp in self.components]
1119
+ if hasattr(self, "components")
1120
+ else []
1121
+ ), # Convert Component objects to dicts
1122
+ "species": (
1123
+ self.species if hasattr(self, "species") else []
1124
+ ), # Optional attribute
1125
+ # "eq_consts": (
1126
+ # self.eq_consts if hasattr(self, "eq_consts") else None
1127
+ # ), # Optional attribute
1128
+ "model_name": self.model_name,
1129
+ "comp_concs": self.comp_concs.to_dict(orient="list"),
1130
+ "binding_constants": (
1131
+ [asdict(k) for k in self.binding_constants]
1132
+ if hasattr(self, "binding_constants")
1133
+ else []
1134
+ ),
1135
+ "component_names": (
1136
+ self.component_names if hasattr(self, "component_names") else []
1137
+ ), # Optional attribute
1138
+ "models": (
1139
+ [model.to_dict() for model in self.models.values()]
1140
+ if hasattr(self, "models")
1141
+ else []
1142
+ ), # Optional attribute
1143
+ "simulations": (
1144
+ [sim.to_dict() for sim in self.simulations.values()]
1145
+ if hasattr(self, "simulations")
1146
+ else []
1147
+ ), # List of simulations
1148
+ "raw_datas": (
1149
+ [raw_data.to_dict() for raw_data in self.raw_datas.values()]
1150
+ if hasattr(self, "raw_datas")
1151
+ else []
1152
+ ), # List of raw data
1153
+ "expt_datas": (
1154
+ [expt_data.to_dict() for expt_data in self.expt_datas.values()]
1155
+ if hasattr(self, "expt_datas")
1156
+ else []
1157
+ ), # List of experimental data
1158
+ "_expt_dtypes": (
1159
+ {name: asdict(dtype) for name, dtype in self._expt_dtypes.items()}
1160
+ if hasattr(self, "_expt_dtypes")
1161
+ else {}
1162
+ ), # Dictionary of experimental data types
1163
+ "fits": (
1164
+ [fit.to_dict() for fit in self.fits.values()]
1165
+ if hasattr(self, "fits")
1166
+ else []
1167
+ ), # List of fits
1168
+ "mcmcs": (
1169
+ [
1170
+ {
1171
+ "id": str(m.id),
1172
+ "nwalkers": m.nwalkers,
1173
+ "nsteps_target": m.nsteps_target,
1174
+ "burn": m.burn,
1175
+ "thin": m.thin,
1176
+ "seed": m.seed,
1177
+ "chains": m.chains.tolist() if isinstance(m.chains, np.ndarray) else [],
1178
+ "priors": m.priors,
1179
+ "model_id": str(m.model_id) if m.model_id else None,
1180
+ "expt_data_id": str(m.expt_data_id) if m.expt_data_id else None,
1181
+ "nsteps_done": m.nsteps_done,
1182
+ }
1183
+ for m in self.mcmcs.values()
1184
+ ]
1185
+ if hasattr(self, "mcmcs")
1186
+ else []
1187
+ ),
1188
+ "active_model_id": (
1189
+ str(self.active_model_id) if hasattr(self, "_active_model_id") else None
1190
+ ), # Index of the active model
1191
+ "active_sim_id": (
1192
+ str(self.active_sim_id) if hasattr(self, "_active_sim_id") else None
1193
+ ), # Index of the active simulation
1194
+ "active_expt_data_id": (
1195
+ str(self.active_expt_data_id) if hasattr(self, "_active_expt_data_id") else None
1196
+ ), # Index of the active experimental data
1197
+ "active_fit_id": (
1198
+ str(self.active_fit_id) if hasattr(self, "_active_fit_id") else None
1199
+ ), # Index of the active fit
1200
+ "active_raw_data_id": (
1201
+ str(self.active_raw_data_id) if hasattr(self, "_active_raw_data_id") else None
1202
+ ),
1203
+ "active_mcmc_id": (
1204
+ str(self.active_mcmc_id) if hasattr(self, "_active_mcmc_id") else None
1205
+ ),
1206
+ "default_model_ids": (
1207
+ [str(m) for m in self.default_model_ids]
1208
+ if hasattr(self,"default_model_ids")
1209
+ else
1210
+ []
1211
+ )
1212
+ }
1213
+
1214
+ def from_json(self, json_str: str) -> None:
1215
+ """Load the state from a JSON string."""
1216
+ data = json.loads(json_str)
1217
+ mtemp = data.get("models", [])
1218
+ self.models = {}
1219
+ if mtemp:
1220
+ for model in mtemp:
1221
+ self.add_model(Model(**model), emit_events=False)
1222
+ m_id = uuid.UUID(model['id'])
1223
+ self.models[m_id].components = [
1224
+ Component(**comp) for comp in model.get("components", [])
1225
+ ]
1226
+ self.models[m_id].binding_constants = [
1227
+ BindingConstant(**k) for k in model.get("binding_constants", [])
1228
+ ]
1229
+ self.models[m_id].eq_mat = np.array(
1230
+ model.get("eq_mat", [])
1231
+ ) # Convert numpy arrays to lists
1232
+ self.models[m_id].component_concs = pd.DataFrame(
1233
+ model.get("component_concs", {})
1234
+ )
1235
+
1236
+
1237
+ self.simulations = {}
1238
+ simtemp = data.get("simulations", [])
1239
+ if simtemp:
1240
+ for sim in simtemp:
1241
+ simulation = Simulation(**sim)
1242
+ simulation.model_id = uuid.UUID(sim.get("model_id"))
1243
+ simulation.comp_concs = pd.DataFrame(sim.get("comp_concs", {}))
1244
+ # simulation.model = Model(**sim.get("model", {}))
1245
+ # simulation.params = [BindingConstant(**k) for k in sim.get('params', [])]
1246
+ simulation.results = pd.DataFrame(sim.get("results", {}))
1247
+ self.simulations[simulation.id]=simulation
1248
+
1249
+ self.fits = {}
1250
+ fittemp = data.get("fits", []) # List of fits
1251
+ if fittemp:
1252
+ for fit in fittemp:
1253
+ fit_result = FitResult(**fit)
1254
+ fit_result.fit_speciation = pd.DataFrame(
1255
+ fit.get("fit_speciation", {}))
1256
+ fit_result.calc_obs = pd.DataFrame(
1257
+ fit.get("calc_obs", {}))
1258
+ self.fits[fit_result.id]=fit_result
1259
+
1260
+ self.mcmcs = {}
1261
+ mcmc_temp = data.get("mcmcs", [])
1262
+ if mcmc_temp:
1263
+ for mcmc in mcmc_temp:
1264
+ try:
1265
+ mcmc_obj = MCMCSim(**mcmc)
1266
+ if not isinstance(mcmc_obj.chains, np.ndarray):
1267
+ mcmc_obj.chains = np.array(mcmc_obj.chains)
1268
+ mcmc_obj.model_id = self._normalize_uuid(mcmc_obj.model_id)
1269
+ mcmc_obj.expt_data_id = self._normalize_uuid(mcmc_obj.expt_data_id)
1270
+ self.mcmcs[mcmc_obj.id] = mcmc_obj
1271
+ except Exception as e:
1272
+ logger.warning(f"Skipping invalid serialized MCMC entry: {e}")
1273
+
1274
+ rawtemp = data.get("raw_datas", []) # List of raw data
1275
+ self.raw_datas = {}
1276
+ if rawtemp:
1277
+ for raw in rawtemp:
1278
+ raw_data = RawData(**raw)
1279
+ raw_data.data = pd.DataFrame(
1280
+ raw.get("data", {})
1281
+ )
1282
+ self.raw_datas[raw_data.id] = raw_data # Store by UUID
1283
+
1284
+ expttemp = data.get("expt_datas", []) # List of experimental data
1285
+ self.expt_datas = {}
1286
+ if expttemp:
1287
+ for expt in expttemp:
1288
+ # Ensure backward compatibility - add selected_columns if missing
1289
+ if 'selected_columns' not in expt:
1290
+ expt['selected_columns'] = []
1291
+
1292
+ expt_data = ExptData(**expt)
1293
+ # limiting_shifts now serialized as list of ChemicalShiftParam dicts
1294
+ expt_data.limiting_shifts = {}
1295
+ for cs in expt.get("limiting_shifts", []) or []:
1296
+ csp = ChemicalShiftParam(**cs)
1297
+ key = (csp.species, csp.col)
1298
+ expt_data.limiting_shifts[key] = csp
1299
+
1300
+ # Reconstruct delta_to_spec (object ndarray with floats or lmfit Parameters)
1301
+ serialized_delta = expt.get("delta_to_spec", [])
1302
+ if serialized_delta == []:
1303
+ expt_data.delta_to_spec = None
1304
+ elif isinstance(serialized_delta, list) and serialized_delta:
1305
+ # Expect a list of rows
1306
+ new_rows: list[list[LMFitParameter|float]] = []
1307
+ for row in serialized_delta:
1308
+ reconstructed_row: list[LMFitParameter|float] = []
1309
+ for cell in row:
1310
+ if isinstance(cell, dict) and cell.get("type") == "lmfit_param":
1311
+ # Construct lmfit Parameter
1312
+ parameter = LMFitParameter(
1313
+ name=cell.get("name", ""),
1314
+ value=float(cell.get("value", 0.0)),
1315
+ )
1316
+ if cell.get("min") is not None:
1317
+ parameter.min = float(cell["min"]) # type: ignore[index]
1318
+ if cell.get("max") is not None:
1319
+ parameter.max = float(cell["max"]) # type: ignore[index]
1320
+ parameter.vary = bool(cell.get("vary", True))
1321
+ reconstructed_row.append(parameter)
1322
+ elif cell is None:
1323
+ reconstructed_row.append(0.0)
1324
+ elif isinstance(cell,(str, int, float)):
1325
+ try:
1326
+ reconstructed_row.append(float(cell))
1327
+ except Exception:
1328
+ logger.warning(f"Failed to convert cell {cell} to float, appending 0.0")
1329
+ reconstructed_row.append(0.0)
1330
+ new_rows.append(reconstructed_row)
1331
+ try:
1332
+ nr = len(new_rows)
1333
+ nc = len(new_rows[0]) if nr > 0 else 0
1334
+ # Attempt to create a 2D numpy array with dtype=object
1335
+ # need to do this long-winded approach to stop numpy from
1336
+ # trying (and failing) to coerce the object array to a float array
1337
+ # which calls Parameter.__array__ and fails.
1338
+ if nc > 0:
1339
+ expt_data.delta_to_spec = np.empty((nr, nc), dtype=object)
1340
+ for i in range(nr):
1341
+ for j in range(nc):
1342
+ expt_data.delta_to_spec[i, j] = new_rows[i][j]
1343
+ #expt_data.delta_to_spec = np.array(new_rows, dtype=object)
1344
+ except Exception:
1345
+ # If object array fails, still set as object array coercing to objects
1346
+ expt_data.delta_to_spec = np.array([[x for x in row] for row in new_rows], dtype=object)
1347
+ self.expt_datas[expt_data.id]=expt_data
1348
+
1349
+ self._nComp = int(data.get("nComp", 2))
1350
+ self._nStep = int(data.get("nStep", 20))
1351
+ self._eq_str = data.get("eq_str", "")
1352
+ self._eq_mat_str = data.get("eq_mat_str", "[]")
1353
+ self._eq_mat = np.array(data.get("eq_mat", []))
1354
+ comp_temp = data.get("components", [])
1355
+ self._components = (
1356
+ [Component(**comp) for comp in comp_temp] if comp_temp else []
1357
+ )
1358
+ ktemp = data.get("binding_constants", [])
1359
+ self._binding_constants = [BindingConstant(**k) for k in ktemp] if ktemp else []
1360
+ self._species = data.get("species", [])
1361
+ self._eq_consts = data.get("eq_consts", None)
1362
+ self._model_name = data.get("model_name", "")
1363
+ self._component_names = data.get("component_names", [])
1364
+ self._comp_concs = pd.DataFrame(data.get("comp_concs", {}))
1365
+ self._expt_data = ExptData(**data.get("expt_data", {}))
1366
+ self._active_model_id = self._normalize_uuid(data.get("active_model_id", None))
1367
+ self._active_sim_id = self._normalize_uuid(data.get("active_sim_id", None))
1368
+ self._active_fit_id = self._normalize_uuid(data.get("active_fit_id", None))
1369
+ self._active_raw_data_id = self._normalize_uuid(data.get("active_raw_data_id", None))
1370
+ self._active_expt_data_id = self._normalize_uuid(data.get("active_expt_data_id", None))
1371
+ self._active_mcmc_id = self._normalize_uuid(data.get("active_mcmc_id", None))
1372
+
1373
+ mids = data.get("default_model_ids", None)
1374
+ if mids:
1375
+ self.default_model_ids = [uuid.UUID(m) for m in mids]
1376
+ else:
1377
+ # Backward compatibility for older saved states without default_model_ids.
1378
+ # Any model ID that matches the bundled defaults is treated as built-in.
1379
+ bundled_default_ids = self._default_model_ids_from_bundle()
1380
+ self.default_model_ids = [mid for mid in self.models.keys() if mid in bundled_default_ids]
1381
+
1382
+ #self._active_raw_data_id = data.get("active_raw_data_id", None)
1383
+ for name, dtype in data.get("_expt_dtypes", {}).items():
1384
+ self._expt_dtypes[name] = ExptDataType(**dtype)
1385
+
1386
+ self._finalize_active_context(reason="from_json", emit_events=False)
1387
+
1388
+ def resolve_str_None(self, id: uuid.UUID | str | None):
1389
+ if id == "None":
1390
+ id = None
1391
+ return id
1392
+
1393
+ # def parse_equations(self, e=None):
1394
+ # """Parse the equilibrium equations and update the model data output."""
1395
+ # # This method is now async to allow for UI interactions
1396
+ # return self._parse_equations(e)
1397
+
1398
+ async def parse_equations(self, e):
1399
+ """Parse the equilibrium equations and update the model data output."""
1400
+
1401
+ # if current model_name is already a model, ask the user if they want to overwrite
1402
+ if self.model_name in [m.name for m in self.models.values()]:
1403
+ #model = [m for m in self.models if m.name == self.model_name][0]
1404
+ model = self.active_model
1405
+ if model.eq_mat_str == "" or model.eq_mat is np.array([]):
1406
+ # then this is a new model and we don't need to ask about overwriting
1407
+ pass
1408
+ # otherwise, we need to ask the user if they want to overwrite the model
1409
+ else:
1410
+ with ui.dialog() as dialog, ui.card():
1411
+ ui.label(
1412
+ f"Are you sure you want to overwrite model \"{self.model_name}\"? \nDoing so will delete all simulations and fits which rely on this model."
1413
+ )
1414
+ with ui.row():
1415
+ ui.button("Yes", on_click=lambda: dialog.submit(True))
1416
+ ui.button("No", on_click=lambda: dialog.submit(False))
1417
+
1418
+ async def show():
1419
+ result = await dialog
1420
+ return result
1421
+
1422
+ res = await show()
1423
+ if res is False: # i.e. user does not want to save
1424
+ ui.notify(
1425
+ "Please give a unique model name and re-try.", type="info"
1426
+ )
1427
+ return
1428
+ else:
1429
+ ui.notify(f"Overwriting model {self.model_name}", type="warning")
1430
+
1431
+ eq_input = self.active_model.eq_str.strip() if self.active_model.eq_str else ""
1432
+
1433
+ if not eq_input:
1434
+ ui.notify("Please enter equilibrium equations.", type="negative")
1435
+ return
1436
+ try:
1437
+ eq_matrix, component_names, species = eq_mat_from_equation_str_infer_components(
1438
+ eq_input
1439
+ )
1440
+ model = self.active_model
1441
+ nmodel = len(self.models)
1442
+ self.delete_model(self.active_model, notify_user=False, notify_listeners=True)
1443
+ if nmodel==1:
1444
+ # if we had only one model, then deletion would have initialized a default model
1445
+ # since we cannot have zero models.
1446
+ self.active_model.name = model.name # Set the name of the default model
1447
+ else:
1448
+ self.new_model(name=model.name) # Create a new model with the same name
1449
+ self.active_model.eq_str = model.eq_str # Copy the equilibrium string from the old model
1450
+ self.active_model.nComp = model.nComp # Copy the number of components from the old model
1451
+ self.active_model.nStep = model.nStep # Copy the number of steps from the
1452
+ self.active_model.component_concs = model.component_concs # Copy the component concentrations from the old model
1453
+ self.active_model.component_names = model.component_names # Copy the component names from the old model
1454
+ self.active_model.components = model.components # Copy the components from the old model
1455
+ #self.notify_listeners('model_changed') # Notify listeners that the model has been updated
1456
+ #self.active_model_idx = self.models.index(model)
1457
+
1458
+
1459
+
1460
+ self.active_model.eq_mat = eq_matrix
1461
+ # TODO replace next line with a property
1462
+ self.active_model.eq_mat_str = str(eq_matrix.tolist()).replace(
1463
+ "],", "]\n"
1464
+ ) # Convert to list for display
1465
+ self.species = species
1466
+ self.component_names = component_names
1467
+
1468
+ self.active_model.nComp = len(component_names) # Update number of components in SimData
1469
+ # self.modelData.set_visibility(True) # Show the model data output area
1470
+
1471
+ for comp in component_names:
1472
+ if comp not in [comp.name for comp in self.components]:
1473
+ # Add new component to SimData
1474
+ self.components.append(Component(name=comp))
1475
+
1476
+ self.components = [
1477
+ comp for comp in self.components if comp.name in component_names
1478
+ ] # Keep only components that are in the list
1479
+
1480
+ # reorder sd.components to match components list, based on the name attribute of sd.components elements
1481
+ self.components = sorted(
1482
+ self.components,
1483
+ key=lambda x: (
1484
+ component_names.index(x.name) if x.name in component_names else float("inf")
1485
+ ),
1486
+ )
1487
+
1488
+ self.generate_binding_constants() # Generate binding constants based on the current model
1489
+
1490
+
1491
+ self.notify_listeners(
1492
+ "model_changed", self.active_model
1493
+ ) # Notify listeners that the model has been updated
1494
+
1495
+ # Disable save button when equations are parsed
1496
+ # TODO restore #self.save_sim_details_button.set_enabled(False)
1497
+ # default to model name + values of Ks
1498
+
1499
+ except Exception as e:
1500
+ logger.error(e)
1501
+ ui.notify("Error: " + str(e), type="negative")
1502
+
1503
+
1504
+ def generate_binding_constants(self):
1505
+ """Generate binding constants based on the current model."""
1506
+ if not self.active_model:
1507
+ ui.notify("No active model to generate binding constants.", type="negative")
1508
+ return
1509
+
1510
+ species = self.active_model.species
1511
+ components = self.active_model.component_names
1512
+
1513
+ # number of bound species:
1514
+ boundspecies = [s for s in species if s not in components]
1515
+ # num_bound_species = len(species) - len(components)
1516
+
1517
+
1518
+ for s in species:
1519
+
1520
+ if s not in [
1521
+ k.species
1522
+ for k in self.active_model.binding_constants
1523
+ if k.species not in species
1524
+ ]:
1525
+ logK = None
1526
+ isComp = False
1527
+ vary = True
1528
+ if s not in boundspecies:
1529
+ # this is a component so logK = 0
1530
+ logK = 0
1531
+ isComp = True
1532
+ vary = False
1533
+ self.active_model.binding_constants.append(
1534
+ BindingConstant(species=s, logK=logK, vary=vary, isComp=isComp)
1535
+ )
1536
+
1537
+ # remove stale binding constants
1538
+ # self.sd.binding_constants = [k for k in self.sd.binding_constants if k.species in species]
1539
+
1540
+ # ensure isComp is set correctly for existing binding constants, and remove stale ones
1541
+ for k in self.active_model.binding_constants:
1542
+ if k.species not in species:
1543
+ self.active_model.binding_constants.remove(k)
1544
+ else:
1545
+ if k.species in components:
1546
+ k.isComp = True
1547
+ else:
1548
+ k.isComp = False
1549
+
1550
+ # reorder self.sd.binding_constants to match species list
1551
+ ro = []
1552
+ for s in species:
1553
+ # as a quick check, ensure components have logK=0
1554
+ for k in self.active_model.binding_constants:
1555
+ if k.species in components:
1556
+ k.logK = 0
1557
+ # reorder self.sd.binding_constants to match species list
1558
+ ro.append([k for k in self.active_model.binding_constants if k.species == s][0])
1559
+
1560
+ self.active_model.binding_constants = ro
1561
+
1562
+ def remove_model_dependent_objs(self, model: Model, notify_user: bool = True, notify_listeners: bool = True):
1563
+ """Backward-compatible helper for model cascade deletion."""
1564
+ if model.id not in self.models:
1565
+ ui.notify("Model not found.", type="negative")
1566
+ return
1567
+ self.delete_model(model, notify_user=notify_user, notify_listeners=notify_listeners)
1568
+
1569
+ def delete_model(self, model: Model, notify_user: bool = True, notify_listeners: bool = True, reconcile: bool = True):
1570
+ """Delete a model from the state manager."""
1571
+ if model.id in self.default_model_ids:
1572
+ if notify_user:
1573
+ ui.notify("Built-in models cannot be deleted.", type="warning")
1574
+ return
1575
+
1576
+ if model.id in self.models:
1577
+ before = self._snapshot_object_ids()
1578
+ self._delete_object_core(model)
1579
+ if len(self.models) == 0:
1580
+ self._initialize_default_models(emit_events=False)
1581
+ if notify_user:
1582
+ ui.notify("No models left. Default models have been created.", type="info")
1583
+ after = self._snapshot_object_ids()
1584
+ self._emit_collection_events(before, after, notify_listeners=notify_listeners)
1585
+ if reconcile:
1586
+ self._finalize_active_context(reason="delete_model", emit_events=notify_listeners)
1587
+ else:
1588
+ ui.notify("Model not found.", type="negative")
1589
+
1590
+ def delete_fit(self, fit: FitResult, notify_user: bool = True, notify_listeners: bool = True, reconcile: bool = True):
1591
+ """Delete a fit from the state manager."""
1592
+ if fit.id in self.fits:
1593
+ before = self._snapshot_object_ids()
1594
+ self._delete_object_core(fit)
1595
+ after = self._snapshot_object_ids()
1596
+ self._emit_collection_events(before, after, notify_listeners=notify_listeners)
1597
+ if reconcile:
1598
+ self._finalize_active_context(reason="delete_fit", emit_events=notify_listeners)
1599
+ else:
1600
+ ui.notify("Fit not found.", type="negative")
1601
+
1602
+ def delete_simulation(self, simulation: Simulation, notify_user: bool = True, notify_listeners: bool = True, reconcile: bool = True):
1603
+ """Delete a simulation from the state manager."""
1604
+ if simulation.id in self.simulations:
1605
+ before = self._snapshot_object_ids()
1606
+ self._delete_object_core(simulation)
1607
+ after = self._snapshot_object_ids()
1608
+ self._emit_collection_events(before, after, notify_listeners=notify_listeners)
1609
+ if reconcile:
1610
+ self._finalize_active_context(reason="delete_simulation", emit_events=notify_listeners)
1611
+ else:
1612
+ ui.notify("Simulation not found.", type="negative")
1613
+
1614
+ def delete_expt_data(self, expt_data: ExptData, notify_user: bool = True, notify_listeners: bool = True, reconcile: bool = True):
1615
+ """Delete experimental data from the state manager."""
1616
+ if expt_data.id in self.expt_datas:
1617
+ before = self._snapshot_object_ids()
1618
+ self._delete_object_core(expt_data)
1619
+ after = self._snapshot_object_ids()
1620
+ self._emit_collection_events(before, after, notify_listeners=notify_listeners)
1621
+ if reconcile:
1622
+ self._finalize_active_context(reason="delete_expt_data", emit_events=notify_listeners)
1623
+ else:
1624
+ ui.notify("Experimental data not found.", type="negative")
1625
+
1626
+ def delete_raw_data(self, raw_data: RawData, notify_user: bool = True, notify_listeners: bool = True, reconcile: bool = True):
1627
+ """Delete raw data from the state manager."""
1628
+ if raw_data.id in self.raw_datas:
1629
+ before = self._snapshot_object_ids()
1630
+ self._delete_object_core(raw_data)
1631
+ after = self._snapshot_object_ids()
1632
+ self._emit_collection_events(before, after, notify_listeners=notify_listeners)
1633
+ if reconcile:
1634
+ self._finalize_active_context(reason="delete_raw_data", emit_events=notify_listeners)
1635
+ else:
1636
+ ui.notify("Raw data not found.", type="negative")
1637
+
1638
+ def delete_mcmc(self, mcmc: MCMCSim, notify_user: bool = True, notify_listeners: bool = True, reconcile: bool = True):
1639
+ """Delete MCMC results from the state manager."""
1640
+ if mcmc.id in self.mcmcs:
1641
+ before = self._snapshot_object_ids()
1642
+ self._delete_object_core(mcmc)
1643
+ after = self._snapshot_object_ids()
1644
+ self._emit_collection_events(before, after, notify_listeners=notify_listeners)
1645
+ if reconcile:
1646
+ self._finalize_active_context(reason="delete_mcmc", emit_events=notify_listeners)
1647
+ else:
1648
+ ui.notify("MCMC result not found.", type="negative")
1649
+
1650
+ def update_ui_bindings(self, bindables: str|list|None=None):
1651
+ if bindables is None:
1652
+ bindables = list(self.ui_bindings.__dict__.keys())
1653
+
1654
+ def set_bindable(dest,src,parent=None):
1655
+ if parent is not None:
1656
+ if not hasattr(self,parent) or getattr(self,parent) is None:
1657
+ val = ""
1658
+ else:
1659
+ val = getattr(getattr(self,parent),src)
1660
+ else:
1661
+ val = getattr(self,src)
1662
+ if hasattr(self.ui_bindings, dest):
1663
+ setattr(self.ui_bindings, dest, val)
1664
+
1665
+
1666
+ for b in bindables:
1667
+ if hasattr(self.ui_bindings, b):
1668
+ if b == 'model_name':
1669
+ set_bindable(b, 'name', parent='active_model')
1670
+
1671
+
1672
+ def dump_setup_and_model_def(self,model:Model) ->str:
1673
+ outstr = f"""
1674
+ import bindtools as bd
1675
+ import numpy as np
1676
+ import matplotlib.pyplot as plt
1677
+
1678
+ #model = Model(name={model.name}, eq_mat={repr(model.eq_mat.tolist())}))
1679
+
1680
+ eq_mat = {model.eq_mat.tolist()}
1681
+ model_name = {model.name}
1682
+
1683
+ comp_names = {model.component_names}
1684
+ species_names = {model.species}
1685
+ nstep = {model.nStep}
1686
+
1687
+ """
1688
+ return outstr
1689
+
1690
+ def dump_simulation_to_python(self, model: Model) -> str:
1691
+ # dump initial imports and model setup
1692
+ outstr = self.dump_setup_and_model_def(model)
1693
+ # generate component blocks
1694
+ for c in model.components:
1695
+ if c.spacing == 'lin':
1696
+ outstr += f"\n{c.name}_concs = np.linspace({c.start_conc if c.start_conc is not None else 0},{c.end_conc if c.end_conc is not None else 0},nstep)"
1697
+ else:
1698
+ raise(NotImplementedError)
1699
+
1700
+ outstr += f"""
1701
+ comp_concs = np.vstack(({','.join([c+'_concs' for c in model.component_names])}))
1702
+
1703
+ logKs = dict()
1704
+ for i,s in enumerate(species_names):
1705
+ logKs[s] = model.binding_constants[i].logK
1706
+
1707
+
1708
+
1709
+ # model parameters are all defined
1710
+ # now let's generate the model and simulate it
1711
+
1712
+ model = bd.bindingModel(eq_mat, comp_names, species_names,compConcs=comp_concs)
1713
+ model.prepModel()
1714
+ model.params.pretty_print()
1715
+
1716
+ for s in species_names:
1717
+ model.params[s].set(value=logKs[s])
1718
+
1719
+ spec=model.calcSpeciation()
1720
+
1721
+ """
1722
+
1723
+
1724
+ return outstr
1725
+
1726
+ def dump_fit_to_python(self, fit: FitResult) -> str:
1727
+ outstr = self.dump_setup_and_model_def(fit.model)
1728
+
1729
+ bm = self.generate_binding_model_for_fit(fit)
1730
+
1731
+ # add some imports
1732
+ outstr = """
1733
+ import pandas as pd
1734
+ import lmfit
1735
+ """ + outstr
1736
+
1737
+ # load datafile
1738
+ outstr += f"""
1739
+ #load datafile
1740
+ data = pd.read_csv({fit.expt_data.raw_data.filename})
1741
+
1742
+ # sort datafile
1743
+ column_mapping = {repr(fit.expt_data.column_mapping)}
1744
+ if column_mapping is not None:
1745
+ old_cols = self.data.columns
1746
+ new_cols = [None] * len(old_cols)
1747
+ for raw_proc in self.column_mapping:
1748
+ new_cols[proc] = old_cols[raw]
1749
+ data = data[new_cols]"""
1750
+
1751
+ # specify compconc etc matrices
1752
+ outstr += f"""
1753
+ # various matrices
1754
+ obs_list =
1755
+ component_names = {repr(bm.compNames)}
1756
+ integ_to_spec = {repr(bm.colToSpec)}
1757
+ delta_to_spec = {repr(bm.specToDd)}
1758
+ species = {repr(bm.plist)}
1759
+ col_to_comp = {repr(bm.colToComp)}"""
1760
+
1761
+ # set up fit
1762
+ outstr += """
1763
+ # fitting and plotting routines
1764
+ fit = bd.bindingModel(eqMat=eqMat,
1765
+ compNames=component_names,
1766
+ speciesList=species,
1767
+ colToComp=col_to_comp,
1768
+ specToInteg=integ_to_spec,
1769
+ specToDd=delta_to_spec,
1770
+ obsList=obs_list,
1771
+ rawData=data)
1772
+ #sigma = [0.0005, 0.0005,0.0005,0.0005,0.0005]
1773
+ fit.prepModel()
1774
+ """
1775
+ outstr += "# Define parameters"
1776
+ for k in fit.model.binding_constants:
1777
+ outstr += f"\nfit.params['log{k.species}'].set(value={k.logK if k.logK is not None else 0.0}, vary={k.vary}, min={k.min}, max={k.max})"
1778
+
1779
+
1780
+ outstr += f"""
1781
+ fit.runModel(sigma=sigma,skip_col=2,method={fit.fit_method})
1782
+ bd.makeFitResidPlot(fit,plotMask=(0,1),ylabel='Chemical shift (ppm)')"""
1783
+
1784
+
1785
+
1786
+ return outstr
1787
+ # model_str = self.dump_model_to_python(fit._model) if fit._model is not None else "# Model not linked."
1788
+ # return model_str
1789
+
1790
+ def generate_binding_model_for_fit(self, fit: Optional[FitResult]=None, analytical_cfg: Optional[dict[str, object]]=None) -> bd.bindingModel:
1791
+ """Generate a bindtools.bindingModel from the current active model."""
1792
+ old_fit = None
1793
+ if fit is not None and fit.id != self.active_fit_id:
1794
+ old_fit = str(self.active_fit_id)
1795
+ self.active_fit_id = fit.id
1796
+ self.active_expt_data_id = fit.expt_data_id
1797
+ self.active_model_id = fit.model_id
1798
+
1799
+ if not self.active_model:
1800
+ raise ValueError("No active model to generate binding model.")
1801
+
1802
+ obs_list = [x for x in self.active_expt_data.sorted_data.columns if self.active_expt_data.col_details[x]['depindep'] == 'dep']
1803
+ integ_to_spec = self.active_expt_data.integ_to_spec
1804
+ if not (isinstance(integ_to_spec, np.ndarray) and integ_to_spec.ndim == 2 and integ_to_spec.size > 0):
1805
+ integ_to_spec = None
1806
+ delta_to_spec = self.active_expt_data.delta_to_spec
1807
+ if not (isinstance(delta_to_spec, np.ndarray) and delta_to_spec.ndim == 2 and delta_to_spec.size > 0):
1808
+ delta_to_spec = None
1809
+
1810
+ model = bd.bindingModel(
1811
+ eqMat=self.active_model.eq_mat,
1812
+ compNames=self.active_model.component_names,
1813
+ speciesList=self.active_model.species,
1814
+ colToComp=self.active_expt_data.col_to_comp,
1815
+ specToInteg=integ_to_spec,
1816
+ specToDd = delta_to_spec.T if delta_to_spec is not None else None,
1817
+ obsList=obs_list,
1818
+ rawData=np.array(self.active_expt_data.sorted_data)
1819
+ )
1820
+
1821
+ cfg = analytical_cfg
1822
+ if cfg is None and fit is not None and getattr(fit, "analytical_fast_exchange", False):
1823
+ cfg = {
1824
+ "topology": fit.analytical_topology,
1825
+ "complex_indices": list(getattr(fit, "analytical_complex_indices", [])),
1826
+ "obs_columns": list(getattr(fit, "analytical_obs_columns", [])),
1827
+ "obs_components": list(getattr(fit, "analytical_obs_components", [])),
1828
+ }
1829
+
1830
+ if cfg is not None:
1831
+ model.analytical_fast_exchange = True
1832
+ model.analytical_topology = str(cfg["topology"])
1833
+ model.analytical_complex_indices = [int(x) for x in cfg["complex_indices"]] # type: ignore[index]
1834
+ model.analytical_obs_columns = [str(x) for x in cfg["obs_columns"]] # type: ignore[index]
1835
+ model.analytical_obs_components = [int(x) for x in cfg["obs_components"]] # type: ignore[index]
1836
+ logger.info(
1837
+ "Using analytical fast-exchange backend (%s model) for fitting.",
1838
+ model.analytical_topology,
1839
+ )
1840
+
1841
+ # Handle UV-vis / fluorescence linear observables (numerical and analytical paths).
1842
+ if self.active_expt_data.has_linear_obs(self._expt_dtypes):
1843
+ self.active_expt_data.build_abs_to_spec(self._expt_dtypes)
1844
+ abs_to_spec = self.active_expt_data.abs_to_spec
1845
+ if abs_to_spec is not None and isinstance(abs_to_spec, np.ndarray) and abs_to_spec.ndim == 2 and abs_to_spec.size > 0:
1846
+ model.specToLinear = abs_to_spec.T # (n_species, n_obs)
1847
+
1848
+ # Build per-observable param name map for the analytical path.
1849
+ linear_obs_param_map: list[list] = []
1850
+ for obs_idx in range(abs_to_spec.shape[0]):
1851
+ pnames: list = []
1852
+ for species_idx in range(abs_to_spec.shape[1]):
1853
+ cell = abs_to_spec[obs_idx, species_idx]
1854
+ pnames.append(cell.name if isinstance(cell, LMFitParameter) else None)
1855
+ linear_obs_param_map.append(pnames)
1856
+
1857
+ lin_cols = [col for col in obs_list
1858
+ if self.active_expt_data.col_details.get(col, {}).get("depindep") == "dep"
1859
+ # already filtered above — just iterate obs_list directly
1860
+ ]
1861
+ model.analytical_linear_obs_columns = lin_cols
1862
+ model.analytical_linear_obs_param_map = linear_obs_param_map
1863
+
1864
+ model.prepModel()
1865
+
1866
+ for k in self.active_model.binding_constants:
1867
+ model.params[f'log{k.species}'].set(value=k.logK if k.logK is not None else 0.0, vary=k.vary, min=k.min, max=k.max)
1868
+
1869
+
1870
+ # cleanup by restoring previous fit if needed
1871
+ if old_fit is not None:
1872
+ self.active_fit_id = uuid.UUID(old_fit)
1873
+ self.active_expt_data_id = self.active_fit.expt_data_id
1874
+ self.active_model_id = self.active_fit.model_id
1875
+
1876
+ return model
1877
+
1878
+ # ------------------------------------------------------------------
1879
+ # Jupyter notebook export
1880
+ # ------------------------------------------------------------------
1881
+
1882
+ def dump_simulation_notebook(self, sim: Simulation) -> dict:
1883
+ """Export *sim* as an nbformat-4 notebook dict.
1884
+
1885
+ The notebook embeds component concentrations from ``sim.comp_concs``
1886
+ and uses the binding constants from the linked model. It is entirely
1887
+ self-contained (no nicegui / webgui imports).
1888
+ """
1889
+ from webgui.export.notebook_exporter import export_simulation_notebook
1890
+
1891
+ model = self.models.get(sim.model_id)
1892
+ if model is None:
1893
+ raise ValueError(f"Model {sim.model_id} not found for simulation.")
1894
+ return export_simulation_notebook(sim, model)
1895
+
1896
+ def dump_fit_notebook(self, fit: FitResult) -> tuple[dict, "pd.DataFrame"]:
1897
+ """Export *fit* as an nbformat-4 notebook dict plus a CSV DataFrame.
1898
+
1899
+ The notebook is set up to re-run the fit; original fitted values
1900
+ appear as inline comments. The companion CSV DataFrame contains the
1901
+ raw experimental data and should be saved as ``data.csv`` alongside
1902
+ the exported notebook.
1903
+
1904
+ Returns
1905
+ -------
1906
+ notebook : dict
1907
+ nbformat-4 compatible dict (serialise with ``json.dumps``).
1908
+ csv_df : pd.DataFrame
1909
+ Raw data to be saved as CSV.
1910
+ """
1911
+ import pandas as pd # noqa: F401 — type annotation only
1912
+ from webgui.export.notebook_exporter import export_fit_notebook
1913
+
1914
+ # Ensure all objects are linked
1915
+ if not hasattr(fit, "_model") or fit._model is None:
1916
+ fit.find_and_link_model(self.models)
1917
+ if not hasattr(fit, "_expt_data") or fit._expt_data is None:
1918
+ fit.find_and_link_expt_data(self.expt_datas)
1919
+ expt_data = fit._expt_data
1920
+ if expt_data is not None:
1921
+ obs_type_names: list[str] = [
1922
+ ot.name for ot in expt_data.get_obs_list(self._expt_dtypes)
1923
+ ]
1924
+ if not hasattr(expt_data, "_raw_data") or expt_data._raw_data is None:
1925
+ expt_data.find_and_link_raw_data(self.raw_datas)
1926
+ if not hasattr(expt_data, "_model") or expt_data._model is None:
1927
+ expt_data.find_and_link_model(self.models)
1928
+ else:
1929
+ obs_type_names = []
1930
+
1931
+ # Extract linear observable (UV-vis / fluorescence) structure for the notebook.
1932
+ lin_obs_col_names: list[str] | None = None
1933
+ lin_obs_param_map: list[list] | None = None
1934
+ if expt_data is not None and expt_data.has_linear_obs(self._expt_dtypes):
1935
+ expt_data.build_abs_to_spec(self._expt_dtypes)
1936
+ abs_to_spec = expt_data.abs_to_spec
1937
+ if abs_to_spec is not None and abs_to_spec.ndim == 2 and abs_to_spec.size > 0:
1938
+ lin_obs_col_names = [col for col, _ in expt_data.linear_obs_cols(self._expt_dtypes)]
1939
+ lin_obs_param_map = []
1940
+ for obs_idx in range(abs_to_spec.shape[0]):
1941
+ row: list = []
1942
+ for spec_idx in range(abs_to_spec.shape[1]):
1943
+ cell = abs_to_spec[obs_idx, spec_idx]
1944
+ if isinstance(cell, LMFitParameter):
1945
+ row.append({"name": cell.name, "min": float(cell.min), "max": float(cell.max)})
1946
+ else:
1947
+ row.append(None)
1948
+ lin_obs_param_map.append(row)
1949
+
1950
+
1951
+
1952
+
1953
+ return export_fit_notebook(
1954
+ fit, fit._model, expt_data, expt_data._raw_data,
1955
+ obs_type_names=obs_type_names,
1956
+ lin_obs_col_names=lin_obs_col_names,
1957
+ lin_obs_param_map=lin_obs_param_map,
1958
+ )
1959
+
1960
+ def dump_mcmc_notebook(
1961
+ self,
1962
+ mcmc: "MCMCSim | None",
1963
+ include_chains: bool,
1964
+ ) -> "tuple[dict, pd.DataFrame, bytes | None]":
1965
+ """Export an MCMC run as a notebook zip bundle.
1966
+
1967
+ The notebook contains the full fit setup (identical to
1968
+ :meth:`dump_fit_notebook`) followed by a live MCMC section.
1969
+
1970
+ Parameters
1971
+ ----------
1972
+ mcmc : MCMCSim or None
1973
+ The MCMC run to export. When *None* (no run yet), a code-only
1974
+ notebook is generated using the active fit's context.
1975
+ include_chains : bool
1976
+ When True, the companion HDF5 chain file is also produced.
1977
+ Requires ``mcmc.mc.sampler`` to be non-None.
1978
+
1979
+ Returns
1980
+ -------
1981
+ notebook : dict
1982
+ csv_df : pd.DataFrame
1983
+ h5_bytes : bytes or None
1984
+ """
1985
+ import pandas as pd # noqa: F401
1986
+ from webgui.export.notebook_exporter import export_mcmc_notebook
1987
+
1988
+ # Always use active_fit so that the fit name embedded in the notebook's
1989
+ # read_csv call matches the ZIP entry name produced by the caller.
1990
+ # The MCMCSim is used only for its MCMC parameters and chains.
1991
+ fit = self.active_fit # raises if not set
1992
+
1993
+ # Link model onto fit
1994
+ if not hasattr(fit, "_model") or fit._model is None:
1995
+ fit.find_and_link_model(self.models)
1996
+
1997
+ # Link expt_data onto fit
1998
+ if not hasattr(fit, "_expt_data") or fit._expt_data is None:
1999
+ fit.find_and_link_expt_data(self.expt_datas)
2000
+ expt_data = fit._expt_data
2001
+
2002
+ if expt_data is not None:
2003
+ if not hasattr(expt_data, "_raw_data") or expt_data._raw_data is None:
2004
+ expt_data.find_and_link_raw_data(self.raw_datas)
2005
+ if not hasattr(expt_data, "_model") or expt_data._model is None:
2006
+ expt_data.find_and_link_model(self.models)
2007
+
2008
+ if expt_data is None:
2009
+ raise ValueError("No experimental data found for MCMC notebook export.")
2010
+ if fit._model is None:
2011
+ raise ValueError("No model found for MCMC notebook export.")
2012
+ if expt_data._raw_data is None:
2013
+ raise ValueError("No raw data found for MCMC notebook export.")
2014
+
2015
+ obs_type_names: list[str] = [
2016
+ ot.name for ot in expt_data.get_obs_list(self._expt_dtypes)
2017
+ ]
2018
+
2019
+ # Extract linear observable (UV-vis / fluorescence) structure for the notebook.
2020
+ lin_obs_col_names: list[str] | None = None
2021
+ lin_obs_param_map: list[list] | None = None
2022
+ if expt_data.has_linear_obs(self._expt_dtypes):
2023
+ expt_data.build_abs_to_spec(self._expt_dtypes)
2024
+ abs_to_spec = expt_data.abs_to_spec
2025
+ if abs_to_spec is not None and abs_to_spec.ndim == 2 and abs_to_spec.size > 0:
2026
+ lin_obs_col_names = [col for col, _ in expt_data.linear_obs_cols(self._expt_dtypes)]
2027
+ lin_obs_param_map = []
2028
+ for obs_idx in range(abs_to_spec.shape[0]):
2029
+ row: list = []
2030
+ for spec_idx in range(abs_to_spec.shape[1]):
2031
+ cell = abs_to_spec[obs_idx, spec_idx]
2032
+ if isinstance(cell, LMFitParameter):
2033
+ row.append({"name": cell.name, "min": float(cell.min), "max": float(cell.max)})
2034
+ else:
2035
+ row.append(None)
2036
+ lin_obs_param_map.append(row)
2037
+
2038
+ return export_mcmc_notebook(
2039
+ mcmc, fit, fit._model, expt_data, expt_data._raw_data,
2040
+ obs_type_names, include_chains,
2041
+ lin_obs_col_names=lin_obs_col_names,
2042
+ lin_obs_param_map=lin_obs_param_map,
2043
+ )