bindmc 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bindmc/main.py +67 -0
- bindmc/webgui/__init__.py +0 -0
- bindmc/webgui/app.py +54 -0
- bindmc/webgui/classes/BindingConstant.py +23 -0
- bindmc/webgui/classes/ChemicalShiftParam.py +40 -0
- bindmc/webgui/classes/Component.py +111 -0
- bindmc/webgui/classes/ExptData.py +485 -0
- bindmc/webgui/classes/ExptDataType.py +92 -0
- bindmc/webgui/classes/FitResult.py +173 -0
- bindmc/webgui/classes/MCMCSim.py +232 -0
- bindmc/webgui/classes/Model.py +86 -0
- bindmc/webgui/classes/RawData.py +36 -0
- bindmc/webgui/classes/Simulation.py +104 -0
- bindmc/webgui/classes/UIBindings.py +19 -0
- bindmc/webgui/classes/__init__.py +28 -0
- bindmc/webgui/components/__init__.py +29 -0
- bindmc/webgui/components/base.py +24 -0
- bindmc/webgui/components/bayes.py +689 -0
- bindmc/webgui/components/bayes_priors.py +351 -0
- bindmc/webgui/components/binding_model.py +330 -0
- bindmc/webgui/components/body.py +276 -0
- bindmc/webgui/components/data_gen.py +419 -0
- bindmc/webgui/components/data_import.py +450 -0
- bindmc/webgui/components/data_model.py +609 -0
- bindmc/webgui/components/fitting.py +886 -0
- bindmc/webgui/components/graph.py +649 -0
- bindmc/webgui/components/header.py +124 -0
- bindmc/webgui/components/simulation.py +385 -0
- bindmc/webgui/export/__init__.py +0 -0
- bindmc/webgui/export/notebook_exporter.py +727 -0
- bindmc/webgui/state/__init__.py +1 -0
- bindmc/webgui/state/statemanager.py +2043 -0
- bindmc/webgui/utils.py +322 -0
- bindmc-0.1.0.dist-info/METADATA +22 -0
- bindmc-0.1.0.dist-info/RECORD +37 -0
- bindmc-0.1.0.dist-info/WHEEL +5 -0
- bindmc-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|
+
|