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,485 @@
1
+ import uuid
2
+ from dataclasses import asdict, dataclass, field, InitVar
3
+ from typing import Optional,Any
4
+ import unicodedata
5
+ import re
6
+ from nicegui import binding
7
+ import numpy as np
8
+ import pandas as pd
9
+ from lmfit import Parameter as LMFitParameter
10
+ from asteval import valid_symbol_name
11
+ import bindtools.binding as bd
12
+ from .Model import Model
13
+ from .RawData import RawData
14
+ from .ChemicalShiftParam import ChemicalShiftParam
15
+ from .ExptDataType import ExptDataType
16
+
17
+ @dataclass
18
+ class ExptData():
19
+ """Data class to represent experimental data."""
20
+
21
+ name: str = ""
22
+ #filename: str = ""
23
+ #data: pd.DataFrame = field(default_factory=pd.DataFrame, compare=False)
24
+ col_to_comp: np.ndarray = field(default_factory=lambda: np.array([])) # Matrix to convert columns to components
25
+ component_names: list[str] = field(default_factory=list) # Names of components
26
+ integ_to_spec: np.ndarray | None = field(default_factory=lambda: np.array([]))
27
+ delta_to_spec: np.ndarray | None = field(default_factory=lambda: np.array([]))
28
+ # Keyed by (species_name, col_name) to support multiple shifts per species; col_name can be None for generic/default
29
+ limiting_shifts: dict[tuple[str, str|None], ChemicalShiftParam] = field(default_factory=dict)
30
+ # UV-vis / fluorescence: (n_obs_cols, n_species) object array — lmfit.Parameter or 0.0.
31
+ # Not serialised (rebuilt on demand via build_abs_to_spec).
32
+ abs_to_spec: np.ndarray | None = field(default=None, compare=False)
33
+ # Maps observable col name → list of species names that are dark (ε/k fixed at 0).
34
+ dark_species: dict[str, list[str]] = field(default_factory=dict)
35
+ col_details: dict = field(default_factory=dict)
36
+ id: uuid.UUID = field(
37
+ default_factory=lambda: (uuid.uuid4()))
38
+ model_id: Optional[uuid.UUID] = None
39
+ raw_data_id: Optional[uuid.UUID] = None
40
+ column_mapping: list[tuple[int, int]] = field(default_factory=list) # List of tuples (col_idx, comp_idx) for reordering raw data before fitting
41
+ is_analytical_fast_ex: bool = False # Flag to indicate if this is a simple fast-exchange case that can use analytical solutions
42
+ selected_columns: list[str] = field(default_factory=list) # List of column names to include in data operations
43
+
44
+ init_model: InitVar[Optional[Model]] = None # Model associated with this experimental data
45
+ init_raw_data: InitVar[Optional[RawData]] = None
46
+ _comp_concs: pd.DataFrame = field(default_factory=pd.DataFrame, compare=False)
47
+ _model: Optional[Model] = None # The model used for the experimental data, if any
48
+ _raw_data: Optional[RawData] = None
49
+
50
+ def __post_init__(self,init_model, init_raw_data) -> None:
51
+ # Load initvars
52
+ if isinstance(init_model,Model):
53
+ self._model=init_model
54
+ self.model_id = init_model.id
55
+
56
+ if isinstance(init_raw_data,RawData):
57
+ self._raw_data = init_raw_data
58
+ self.raw_data_id = init_raw_data.id
59
+
60
+ """Ensure data are appropriate types."""
61
+ # if not isinstance(self.data, pd.DataFrame):
62
+ # self.data = pd.DataFrame(self.data)
63
+ if not isinstance(self.col_to_comp, np.ndarray):
64
+ self.col_to_comp = np.array(self.col_to_comp, dtype=float)
65
+ if not isinstance(self.integ_to_spec, np.ndarray):
66
+ self.integ_to_spec = np.array(self.integ_to_spec, dtype=float)
67
+
68
+ # if len(self.col_details) != len(self.data.columns):
69
+ # # Initialize col_details if it doesn't match the number of columns
70
+ # self.col_details = {
71
+ # col: {"depindep": None} for col in self.data.columns
72
+ # }
73
+
74
+ if not isinstance(self.id, uuid.UUID):
75
+ if isinstance(self.id, str):
76
+ self.id = uuid.UUID(self.id)
77
+
78
+ if not isinstance(self.model_id, uuid.UUID):
79
+ if isinstance(self.model_id, str) and self.model_id != 'None':
80
+ self.model_id = uuid.UUID(self.model_id)
81
+
82
+ if isinstance(self._model, Model) and not self.model_id:
83
+ self.model_id = self._model.id
84
+
85
+ # Initialize selected_columns to include all columns if not set
86
+ if not self.selected_columns:
87
+ # Get data without triggering property access that might cause recursion
88
+ raw_data = self._raw_data.data if isinstance(self._raw_data, RawData) else pd.DataFrame([])
89
+ if not raw_data.empty:
90
+ self.selected_columns = raw_data.columns.tolist()
91
+
92
+
93
+ def find_and_link_model(self, models: Optional[dict[uuid.UUID,Model]] = None) -> None:
94
+ """Set the model for this experimental data."""
95
+ if models is not None:
96
+ if self.model_id in models and self.model_id is not None:
97
+ self._model = models[self.model_id]
98
+ else:
99
+ raise ValueError(f"Corresponding model {self.model_id} not found for ExptData.")
100
+
101
+
102
+ else:
103
+ raise ValueError(f"Corresponding model {self.model_id} not found for ExptData.")
104
+
105
+ def find_and_link_raw_data(self, raw_datas: dict[uuid.UUID, RawData]) -> None:
106
+ """Set the raw data for this experimental data."""
107
+ if raw_datas is not None and isinstance(self.raw_data_id,(str,uuid.UUID)):
108
+ self.raw_data_id = uuid.UUID(self.raw_data_id) if isinstance(self.raw_data_id, str) else self.raw_data_id
109
+ self._raw_data = raw_datas.get(self.raw_data_id)
110
+ return
111
+
112
+ raise ValueError(f"Corresponding raw data {self.raw_data_id} not found for ExptData.")
113
+
114
+ @property
115
+ def sorted_data(self) -> pd.DataFrame:
116
+ """Return the selected data sorted by the column mapping."""
117
+ base_data = self.selected_data # Use selected data instead of full data
118
+ if self.column_mapping and base_data is not None and not base_data.empty:
119
+ old_cols = base_data.columns
120
+ new_cols: list[None|str] = [None] * len(old_cols)
121
+ for raw,proc in self.column_mapping:
122
+ if raw < len(old_cols): # Ensure mapping is valid for selected columns
123
+ new_cols[proc] = old_cols[raw]
124
+ # Filter out None values in case column mapping refers to unselected columns
125
+ valid_cols = [col for col in new_cols if col is not None]
126
+ return base_data[valid_cols] if valid_cols else pd.DataFrame()
127
+ else:
128
+ return base_data
129
+
130
+ @property
131
+ def data(self) -> pd.DataFrame:
132
+ return self._raw_data.data if isinstance(self._raw_data, RawData) else pd.DataFrame([])
133
+
134
+ @property
135
+ def selected_data(self) -> pd.DataFrame:
136
+ """Return a view of the data with only selected columns."""
137
+ full_data = self.data
138
+ if full_data.empty or not self.selected_columns:
139
+ return full_data
140
+ # Filter to only selected columns that actually exist in the data
141
+ available_selected = [col for col in self.selected_columns if col in full_data.columns]
142
+ return full_data[available_selected] if available_selected else pd.DataFrame()
143
+
144
+ @property
145
+ def obsdata(self) -> pd.DataFrame:
146
+ """Get the observed data which are to be included in the fit (i.e. not disabled)."""
147
+ data_to_use = self.selected_data # Use selected data instead of full data
148
+ if data_to_use is not None and not data_to_use.empty:
149
+ return data_to_use[[x for x in data_to_use.columns if x in self.col_details and self.col_details[x]['depindep'] == 'dep']]
150
+ else:
151
+ return pd.DataFrame([])
152
+
153
+
154
+ @property
155
+ def columns(self) -> list[str]:
156
+ """Get the column names of the selected experimental data."""
157
+ return self.selected_data.columns.tolist() if not self.selected_data.empty else []
158
+
159
+ @property
160
+ def comp_concs(self) -> pd.DataFrame:
161
+ """Get the component concentrations for this fit."""
162
+ if isinstance(self._comp_concs,pd.DataFrame) and not self._comp_concs.empty:
163
+ return self._comp_concs
164
+ elif (self.selected_data is not None) and (self.col_to_comp is not None):
165
+ nconcs = np.shape(self.col_to_comp)[1]
166
+ cc = np.dot(self.selected_data.iloc[:,:nconcs],self.col_to_comp.T) # [Htot, Gtot]
167
+
168
+ if self.model is not None:
169
+ self._comp_concs = pd.DataFrame(
170
+ cc, columns=self.model.component_names
171
+ )
172
+ return self._comp_concs
173
+ else:
174
+ raise ValueError("Model is not set for ExptData, cannot get component_names.")
175
+ else:
176
+ raise ValueError("Model is not set or does not have component concentrations.")
177
+
178
+
179
+ def get_obs_list(self,expt_dtyes: dict[str,ExptDataType]) -> list[bd.ObsType]:
180
+ """Get the list of ExptDataType for each observed column."""
181
+ obs_list = []
182
+ for col in self.col_details:
183
+ if self.col_details[col]['depindep'] == 'dep' and self.col_details[col]['dtype'] is not None:
184
+ edt = expt_dtyes.get(self.col_details[col]['dtype'])
185
+ if edt is None:
186
+ raise ValueError(f"ExptDataType {self.col_details[col]['dtype']} not found for column {col}.")
187
+ obs_list.append(bd.ObsType(name=edt.meas, units=edt.units, value=edt.lnsigma, minlim=edt.lnsigma_min, maxlim=edt.lnsigma_max))
188
+ return obs_list
189
+
190
+ def has_linear_obs(self, expt_dtypes: dict) -> bool:
191
+ """Return True if any dependent column has a UV-vis or fluorescence measurement type."""
192
+ for col, details in self.col_details.items():
193
+ if details.get('depindep') == 'dep':
194
+ dtype_key = details.get('dtype')
195
+ if dtype_key is not None:
196
+ edt = expt_dtypes.get(dtype_key)
197
+ if edt is not None and edt.meas in ('uvvis', 'fluorescence'):
198
+ return True
199
+ return False
200
+
201
+ def linear_obs_cols(self, expt_dtypes: dict) -> list[tuple[str, str]]:
202
+ """Return list of (col_name, measurement_method) for UV-vis/fluorescence dep columns."""
203
+ result = []
204
+ for col in self.col_details: # preserves insertion order
205
+ details = self.col_details[col]
206
+ if details.get('depindep') == 'dep':
207
+ dtype_key = details.get('dtype')
208
+ if dtype_key is not None:
209
+ edt = expt_dtypes.get(dtype_key)
210
+ if edt is not None and edt.meas in ('uvvis', 'fluorescence'):
211
+ result.append((col, edt.meas))
212
+ return result
213
+
214
+ def build_abs_to_spec(self, expt_dtypes: dict) -> None:
215
+ """Build the abs_to_spec object array for UV-vis / fluorescence observables.
216
+
217
+ Shape: (n_linear_obs_cols, n_species), dtype=object.
218
+ Active species → lmfit.Parameter with auto-estimated initial value.
219
+ Dark species → 0.0 (fixed).
220
+
221
+ Initial value heuristic: eps_init ≈ max(|obs_col|) / sum(max([comp]) for active comps),
222
+ with bounds spanning ±3 orders of magnitude. Falls back to 1.0 when concentrations
223
+ are zero or the model is not linked.
224
+ """
225
+ lin_cols = self.linear_obs_cols(expt_dtypes)
226
+ if not lin_cols:
227
+ self.abs_to_spec = None
228
+ return
229
+
230
+ model = self.model
231
+ species_names = model.species if model is not None else []
232
+ n_species = len(species_names)
233
+ n_obs = len(lin_cols)
234
+
235
+ matrix = np.zeros((n_obs, n_species), dtype=object)
236
+
237
+ # Pre-compute max component concentrations for auto-estimation.
238
+ try:
239
+ comp_concs_vals = self.comp_concs.values if not self.comp_concs.empty else None
240
+ except Exception:
241
+ comp_concs_vals = None
242
+
243
+ used_param_names: set[str] = set()
244
+
245
+ for obs_idx, (col, meas) in enumerate(lin_cols):
246
+ dark = set(self.dark_species.get(col, []))
247
+ prefix = 'eps' if meas == 'uvvis' else 'fluor'
248
+
249
+ # Auto-estimate a scale for epsilon from the observable column.
250
+ try:
251
+ obs_vals = self.selected_data[col].dropna().abs().values
252
+ max_obs = float(obs_vals.max()) if obs_vals.size > 0 else 1.0
253
+ except Exception:
254
+ max_obs = 1.0
255
+
256
+ # Sum of max concentrations of non-dark species for scale estimate.
257
+ active_species = [s for s in species_names if s not in dark]
258
+ denom = 1.0
259
+ if comp_concs_vals is not None and len(active_species) > 0 and comp_concs_vals.shape[1] > 0:
260
+ # Use total component concentrations as a proxy for species concentrations.
261
+ n_comps = comp_concs_vals.shape[1]
262
+ denom = max(float(comp_concs_vals.max()), 1e-30)
263
+ eps_init = max_obs / denom if denom > 0 else 1.0
264
+ if eps_init == 0.0 or not np.isfinite(eps_init):
265
+ eps_init = 1.0
266
+
267
+ token = self._sanitize_shift_param_name(col)
268
+
269
+ for species_idx, species in enumerate(species_names):
270
+ if species in dark:
271
+ matrix[obs_idx, species_idx] = 0.0
272
+ else:
273
+ base_name = f"{prefix}_{self._sanitize_shift_param_name(species)}_{token}"
274
+ # Ensure uniqueness across all observables.
275
+ candidate = base_name
276
+ suffix = 2
277
+ while candidate in used_param_names:
278
+ candidate = f"{base_name}_{suffix}"
279
+ suffix += 1
280
+ used_param_names.add(candidate)
281
+
282
+ param = LMFitParameter(
283
+ name=candidate,
284
+ value=eps_init,
285
+ min=eps_init * 1e-3,
286
+ max=eps_init * 1e3,
287
+ vary=True,
288
+ )
289
+ matrix[obs_idx, species_idx] = param
290
+
291
+ self.abs_to_spec = matrix
292
+
293
+
294
+
295
+
296
+ @property
297
+ def model(self) -> Optional[Model]:
298
+ """Get the model associated with this experimental data."""
299
+ return self._model if isinstance(self._model, Model) else None
300
+
301
+
302
+ @model.setter
303
+ def model(self, model: Model) -> None:
304
+ """Set the model for this experimental data."""
305
+ if model is not None:
306
+ self.model_id = model.id
307
+ self._model = model
308
+
309
+
310
+ @property
311
+ def raw_data(self) -> RawData:
312
+ """Get the rawdata associated with this experimental data."""
313
+ return self._raw_data if isinstance(self._raw_data, RawData) else RawData()
314
+
315
+
316
+
317
+ @raw_data.setter
318
+ def raw_data(self, raw_data:RawData) -> None:
319
+ if raw_data is not None:
320
+ self.raw_data_id = raw_data.id
321
+ self._raw_data = raw_data
322
+
323
+ def to_dict(self):
324
+ """Convert ExptData to a dictionary."""
325
+ return {
326
+ "name": self.name,
327
+ # "filename": self.raw_data.filename,
328
+ # "data": (
329
+ # self.data.to_dict(orient="list")
330
+ # if isinstance(self.data, pd.DataFrame) else {}
331
+ # ),
332
+ "col_to_comp": self.col_to_comp.tolist() if isinstance(self.col_to_comp, np.ndarray) else [],
333
+ "integ_to_spec": self.integ_to_spec.tolist() if isinstance(self.integ_to_spec, np.ndarray) else [],
334
+ "col_details": self.col_details if isinstance(self.col_details, dict) else {},
335
+ "id": str(self.id) if self.id else "",
336
+ "model_id": str(self.model_id) if hasattr(self, "model_id") else "",
337
+ "raw_data_id": str(self.raw_data_id) if hasattr(self, "raw_data_id") else "",
338
+ # Serialize delta_to_spec safely even when it contains objects (e.g., lmfit Parameters)
339
+ "delta_to_spec": self._delta_to_spec_jsonable(),
340
+ # limiting_shifts as a list for JSON safety (tuple keys not JSON-serializable)
341
+ "limiting_shifts": [asdict(v) for v in self.limiting_shifts.values()],
342
+ "is_analytical_fast_ex": self.is_analytical_fast_ex,
343
+ "selected_columns": self.selected_columns,
344
+ "dark_species": {col: list(species) for col, species in self.dark_species.items()},
345
+ }
346
+
347
+ # --- Fast-exchange helpers ---
348
+ def _sanitize_shift_param_name(self, raw_suffix: str) -> str:
349
+ """Convert species/column suffix into a readable symbol-like token."""
350
+ safe = re.sub(r"[^\w]+", "_", raw_suffix)
351
+ safe = re.sub(r"_+", "_", safe).strip("_")
352
+ return safe or "param"
353
+
354
+ def _unique_shift_param_name(self, base_name: str, used_names: set[str]) -> str:
355
+ """Ensure a deterministic unique parameter name within one build call."""
356
+ candidate = base_name
357
+ idx = 2
358
+ while candidate in used_names:
359
+ candidate = f"{base_name}_{idx}"
360
+ idx += 1
361
+ used_names.add(candidate)
362
+ return candidate
363
+
364
+ def build_delta_to_spec(self, spec_vectors: list[np.ndarray], species_names: list[str], row_columns: list[str]) -> np.ndarray:
365
+ """Build an object ndarray mapping species to chemical-shift parameters for fast exchange.
366
+
367
+ Each nonzero entry becomes either a float (fixed) or an lmfit Parameter (variable).
368
+ The same species across multiple rows will reuse the same Parameter object.
369
+
370
+ Args:
371
+ spec_vectors: list of length n_rows, each a 1D array over species_names with coefficients.
372
+ species_names: ordered list of species corresponding to columns (e.g., ["H_free", "G_free"]).
373
+
374
+ Returns:
375
+ np.ndarray of shape (n_rows, n_species) with dtype=object.
376
+ """
377
+ num_rows = len(spec_vectors)
378
+ num_species = len(species_names)
379
+ parameter_matrix = np.zeros((num_rows, num_species), dtype=object)
380
+
381
+ # cache to reuse the same Parameter per species
382
+ parameter_cache: dict[tuple[str,str], LMFitParameter|float] = {}
383
+ used_param_names: set[str] = set()
384
+
385
+ def _get_parameter_for_species(species_key: str, column_name: str):
386
+ # species_key should match keys used in limiting_shifts (e.g., "H_free")
387
+ cache_key = (species_key, column_name)
388
+ if cache_key in parameter_cache:
389
+ return parameter_cache[cache_key]
390
+
391
+ # Look up by (species, column) first; then fallback to generic (species, None);
392
+ # and finally support legacy dict[str] keys by scanning values with species match.
393
+ shift_param = self.limiting_shifts.get((species_key, column_name))
394
+ if shift_param is None:
395
+ raise ValueError(f"No shift parameter found for species '{species_key}' and column '{column_name}'.")
396
+
397
+ # Extract value/bounds/fixed
398
+ param_value = 0.0
399
+ not_fixed = False
400
+ min_value = None
401
+ max_value = None
402
+ if isinstance(shift_param, ChemicalShiftParam):
403
+ param_value = float(shift_param.value) if shift_param.value is not None else 0.0
404
+ not_fixed = not bool(shift_param.fixed)
405
+ min_value = getattr(shift_param, "_min", None)
406
+ max_value = getattr(shift_param, "_max", None)
407
+ else:
408
+ raise ValueError(f"Expected ChemicalShiftParam, got {type(shift_param)} for species '{species_key}' and column '{column_name}'.")
409
+
410
+ if not_fixed:
411
+ raw_suffix = f"{species_key}_{column_name or ''}"
412
+ name_suffix = self._sanitize_shift_param_name(raw_suffix)
413
+ base_name = f"delta_{name_suffix}"
414
+ if not valid_symbol_name(base_name):
415
+ base_name = "delta_param"
416
+ final_name = self._unique_shift_param_name(base_name, used_param_names)
417
+ parameter = LMFitParameter(name=final_name, value=param_value)
418
+
419
+ if min_value is not None:
420
+ parameter.min = float(min_value)
421
+ if max_value is not None:
422
+ parameter.max = float(max_value)
423
+ parameter.vary = True
424
+ else:
425
+ parameter = float(param_value) # Fixed parameters are just floats
426
+ parameter_cache[cache_key] = parameter
427
+ return parameter
428
+
429
+
430
+ for i, spec_vector in enumerate(spec_vectors):
431
+ if spec_vector is None:
432
+ continue
433
+ delta_col = row_columns[i]
434
+
435
+ for j, coeff in enumerate(list(spec_vector)):
436
+ try:
437
+ is_nonzero = not np.isclose(coeff, 0)
438
+ except Exception:
439
+ is_nonzero = bool(coeff)
440
+ if is_nonzero:
441
+ species_key = species_names[j]
442
+ parameter_matrix[i, j] = _get_parameter_for_species(species_key, delta_col)
443
+ else:
444
+ parameter_matrix[i, j] = 0.0
445
+
446
+ self.delta_to_spec = parameter_matrix
447
+ return parameter_matrix
448
+
449
+ def _delta_to_spec_jsonable(self) -> list:
450
+ """Return a JSON-serializable representation of delta_to_spec.
451
+
452
+ - Numeric arrays: return .tolist()
453
+ - Object arrays: floats stay as floats; lmfit Parameters become dicts
454
+ - Anything else falls back to None
455
+ """
456
+ if not isinstance(self.delta_to_spec, np.ndarray):
457
+ return []
458
+ if self.delta_to_spec.dtype != object:
459
+ return self.delta_to_spec.tolist()
460
+ serialized_rows: list[list[object]] = []
461
+ for row_values in self.delta_to_spec:
462
+ serialized_row: list[object] = []
463
+ for cell_value in row_values:
464
+ if isinstance(cell_value, (int, float, np.floating)):
465
+ serialized_row.append(float(cell_value))
466
+ elif isinstance(cell_value, LMFitParameter):
467
+ # Note: min/max may be +/-inf; replace with None for JSON friendliness
468
+ min_bound = getattr(cell_value, "min", None)
469
+ max_bound = getattr(cell_value, "max", None)
470
+ serialized_row.append({
471
+ "type": "lmfit_param",
472
+ "name": getattr(cell_value, "name", ""),
473
+ "value": float(getattr(cell_value, "value", 0.0)),
474
+ "min": (float(min_bound) if (min_bound is not None and np.isfinite(min_bound)) else None),
475
+ "max": (float(max_bound) if (max_bound is not None and np.isfinite(max_bound)) else None),
476
+ "vary": bool(getattr(cell_value, "vary", True)),
477
+ })
478
+ else:
479
+ # Try best-effort float conversion, else None
480
+ try:
481
+ serialized_row.append(float(cell_value))
482
+ except Exception:
483
+ serialized_row.append(None)
484
+ serialized_rows.append(serialized_row)
485
+ return serialized_rows
@@ -0,0 +1,92 @@
1
+
2
+ import uuid
3
+ from dataclasses import asdict, dataclass, field, InitVar
4
+ from typing import Optional,Any
5
+ import unicodedata
6
+ from nicegui import binding
7
+ import numpy as np
8
+ import pandas as pd
9
+ from lmfit import Parameter as LMFitParameter
10
+
11
+ @dataclass
12
+ class ExptDataType:
13
+ name: str = ""
14
+ lnsigma: Optional[float] = None
15
+ lnsigma_min: Optional[float] = None
16
+ lnsigma_max: Optional[float] = None
17
+ units: str = ""
18
+ _measurement_method: str = ""
19
+ init_meas: InitVar[str] = "" # Default method for experimental data type
20
+
21
+ def __post_init__(self, init_meas: str) -> None:
22
+ # Normalise legacy measurement key before processing.
23
+ if init_meas == 'uv_abs':
24
+ init_meas = 'uvvis'
25
+ if init_meas:
26
+ self._measurement_method = init_meas
27
+ lnsigma = (-10, -6, -4) # Default values for lnsigma
28
+ if init_meas == "nmrInteg":
29
+ lnsigma = (-11,-8,-4)
30
+ self.lnsigma = self.lnsigma if self.lnsigma is not None else -8
31
+
32
+ elif init_meas == "Hppm" or init_meas == 'Fppm' or init_meas == "nmrShift":
33
+ lnsigma = (-8,-5,-3)
34
+
35
+ elif init_meas == "measConc":
36
+ lnsigma = (-10,-6,-4)
37
+
38
+ elif init_meas == 'uvvis':
39
+ lnsigma = (-11, -7, -3)
40
+ if not self.units:
41
+ self.units = 'absorbance'
42
+
43
+ elif init_meas == 'fluorescence':
44
+ lnsigma = (-8, -4, 0)
45
+ if not self.units:
46
+ self.units = 'intensity'
47
+
48
+ self.lnsigma_min = self.lnsigma_min if self.lnsigma_min is not None else lnsigma[0]
49
+ self.lnsigma = self.lnsigma if self.lnsigma is not None else lnsigma[1]
50
+ self.lnsigma_max = self.lnsigma_max if self.lnsigma_max is not None else lnsigma[2]
51
+
52
+
53
+ self.name = unicodedata.normalize('NFC', self.name) # Normalize the name to NFC form
54
+
55
+ @property
56
+ def meas(self) -> str:
57
+ """Get the measurement method for the experimental data type."""
58
+ # Normalise legacy key: uv_abs is an alias for uvvis.
59
+ if self._measurement_method == 'uv_abs':
60
+ return 'uvvis'
61
+ return self._measurement_method
62
+ # @property
63
+ # def method(self) -> str:
64
+ # """Get the method for the experimental data type."""
65
+ # return self._method
66
+
67
+ # # @method.setter
68
+ # # def method(self, value: str):
69
+ # # """Set the method for the experimental data type."""
70
+ # # if value in ["nmrConc", "nmrShift", "measConc", "other"]:
71
+ # # self._method = value
72
+
73
+ # # if value == "nmrConc":
74
+ # # self.lnsigma = -8
75
+ # # self.lnsigma_min = -11
76
+ # # self.lnsigma_max = -4
77
+ # # elif value == "nmrShift":
78
+ # # self.lnsigma = -6
79
+ # # self.lnsigma_min = -10
80
+ # # self.lnsigma_max = -4
81
+ # # elif value == "measConc":
82
+ # # self.lnsigma = -6
83
+ # # self.lnsigma_min = -10
84
+ # # self.lnsigma_max = -4
85
+
86
+ # # TODO make config file?
87
+ # else:
88
+ # raise ValueError(
89
+ # f"Invalid method: {value}. Must be one of ['nmrConc', 'nmrShift', 'measConc', 'other']."
90
+ # )
91
+
92
+