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,609 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import uuid
|
|
3
|
+
|
|
4
|
+
from nicegui import ui
|
|
5
|
+
from nicegui.events import ClickEventArguments
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from .base import BaseComponent
|
|
9
|
+
from ..classes import ChemicalShiftParam, ExptData
|
|
10
|
+
from ..utils import _infer_simple_fast_exchange_topology
|
|
11
|
+
|
|
12
|
+
# def _default_analytical_shift_species(
|
|
13
|
+
# eq_mat: np.ndarray, component_names: list[str], species_names: list[str], n_fast_ex_cols: int
|
|
14
|
+
# ) -> str | None:
|
|
15
|
+
# """Return a default shift-expression species token for simple analytical fast exchange.
|
|
16
|
+
|
|
17
|
+
# """
|
|
18
|
+
# # if n_fast_ex_cols != 1:
|
|
19
|
+
# # return None
|
|
20
|
+
# if _infer_simple_fast_exchange_topology(eq_mat, len(component_names)) is None:
|
|
21
|
+
# return None
|
|
22
|
+
# if len(component_names) < 1:
|
|
23
|
+
# return None
|
|
24
|
+
|
|
25
|
+
# default_species = f"{component_names[0]}_free"
|
|
26
|
+
# if default_species in species_names:
|
|
27
|
+
# return default_species
|
|
28
|
+
# return species_names[0] if species_names else None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DataModelPanel(BaseComponent):
|
|
32
|
+
def setup_nicegui(self):
|
|
33
|
+
self.container = ui.column().classes("w-full")
|
|
34
|
+
|
|
35
|
+
with self.container:
|
|
36
|
+
ui.label("Data model setup panel").classes("text-lg font-bold mb-4")
|
|
37
|
+
|
|
38
|
+
self.data_model_inp = ui.input(
|
|
39
|
+
"Data model name", placeholder="Enter data model name"
|
|
40
|
+
).classes("mb-5").props("clearable")
|
|
41
|
+
|
|
42
|
+
ui.label("Columns").classes("text-md font-semibold mt-4 mb-2")
|
|
43
|
+
self.dataModel_col_block = ui.element()
|
|
44
|
+
|
|
45
|
+
ui.label("Component mapping").classes("text-md font-semibold mt-4 mb-2")
|
|
46
|
+
self.dataModel_colComp_block = ui.element()
|
|
47
|
+
|
|
48
|
+
self.slow_ex_label = ui.label("Species mapping (slow exchange)").classes("text-md font-semibold mt-4 mb-2")
|
|
49
|
+
self.dataModel_specInteg_block = ui.element()
|
|
50
|
+
|
|
51
|
+
self.fast_ex_label = ui.label("Species mapping (fast exchange)").classes("text-md font-semibold mt-4 mb-2")
|
|
52
|
+
self.dataModel_specFastExchange_block = ui.element()
|
|
53
|
+
|
|
54
|
+
self.data_model_apply = ui.button(
|
|
55
|
+
"Apply Data Model", on_click=self.process_data_model
|
|
56
|
+
)
|
|
57
|
+
if len(self.sm.expt_datas) > 0:
|
|
58
|
+
self._populate_blocks()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def setup_bindings(self):
|
|
62
|
+
super().setup_bindings()
|
|
63
|
+
self.sm.add_listener("data_imported", self._populate_blocks)
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
def _populate_blocks(self):
|
|
67
|
+
# add column chips to data model page
|
|
68
|
+
|
|
69
|
+
nmr_fast_ex = False
|
|
70
|
+
nmr_slow_ex = False
|
|
71
|
+
|
|
72
|
+
# work out what we need
|
|
73
|
+
if self.sm.active_expt_data_or_none is not None:
|
|
74
|
+
self._gen_column_chips()
|
|
75
|
+
self._gen_col_comp_block()
|
|
76
|
+
for f in self.sm.active_expt_data.col_details.values():
|
|
77
|
+
if f.get('dtype') is not None:
|
|
78
|
+
dtype = self.sm._expt_dtypes.get(f['dtype'])
|
|
79
|
+
if getattr(dtype, 'meas', None) == 'nmr_ppm' and f.get('depindep') == 'dep':
|
|
80
|
+
nmr_fast_ex = True
|
|
81
|
+
self._gen_spec_fast_exchange_block()
|
|
82
|
+
elif getattr(dtype, 'meas', None) == 'nmr_conc' and f.get('depindep') == 'dep':
|
|
83
|
+
nmr_slow_ex = True
|
|
84
|
+
self._gen_spec_integ_block()
|
|
85
|
+
|
|
86
|
+
if not nmr_fast_ex:
|
|
87
|
+
self.fast_ex_label.visible = False
|
|
88
|
+
self.dataModel_specFastExchange_block.visible = False
|
|
89
|
+
if not nmr_slow_ex:
|
|
90
|
+
self.slow_ex_label.visible = False
|
|
91
|
+
self.dataModel_specInteg_block.visible = False
|
|
92
|
+
|
|
93
|
+
if self.sm.active_expt_data_or_none is not None:
|
|
94
|
+
self.data_model_inp.value = self.sm.active_expt_data.name[:] # copy it
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _gen_column_chips(self):
|
|
98
|
+
"""Generate the column chips for the data model."""
|
|
99
|
+
self.dataModel_col_block.clear()
|
|
100
|
+
active_expt = self.sm.active_expt_data_or_none
|
|
101
|
+
if active_expt is not None:
|
|
102
|
+
with self.dataModel_col_block:
|
|
103
|
+
with ui.row().classes("gap-1"):
|
|
104
|
+
for col in active_expt.columns:
|
|
105
|
+
text = col
|
|
106
|
+
ui.chip(
|
|
107
|
+
text,
|
|
108
|
+
color="blue",
|
|
109
|
+
on_click=lambda h=text: self.insert_term(f'[{h}]'),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def _gen_col_comp_block(self):
|
|
113
|
+
"""Generate the column to component mapping block."""
|
|
114
|
+
# add col to comp boxes
|
|
115
|
+
self.dataModel_colComp_block.clear()
|
|
116
|
+
|
|
117
|
+
active_expt = self.sm.active_expt_data_or_none
|
|
118
|
+
if active_expt is None:
|
|
119
|
+
return # stop now if there is no active data
|
|
120
|
+
|
|
121
|
+
with self.dataModel_colComp_block:
|
|
122
|
+
self.compConcInps = []
|
|
123
|
+
for i, comp in enumerate(self.sm.active_model.components):
|
|
124
|
+
with ui.row().classes("items-center"):
|
|
125
|
+
ui.label(f"Component [{comp.name}]_tot:")
|
|
126
|
+
self.compConcInps.append(
|
|
127
|
+
ui.input().classes("flex-1").props("clearable")
|
|
128
|
+
)
|
|
129
|
+
self.compConcInps[-1].on(
|
|
130
|
+
"blur", lambda c=self.compConcInps[-1]: self.set_focus(c)
|
|
131
|
+
)
|
|
132
|
+
if hasattr(active_expt, "col_to_comp") and len(active_expt.col_to_comp) > 0:
|
|
133
|
+
self.compConcInps[-1].value = self.vec_to_conc_expression(
|
|
134
|
+
active_expt.col_to_comp[i], active_expt.columns
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _gen_spec_integ_block(self):
|
|
139
|
+
"""Generate the species integration block."""
|
|
140
|
+
self.dataModel_specInteg_block.clear()
|
|
141
|
+
|
|
142
|
+
active_expt = self.sm.active_expt_data_or_none
|
|
143
|
+
if active_expt is None:
|
|
144
|
+
return # stop now if there is no active data
|
|
145
|
+
|
|
146
|
+
with self.dataModel_specInteg_block:
|
|
147
|
+
self.spec_integ_inps: dict[str,ui.input] = {}
|
|
148
|
+
for i, spec in enumerate(self.sm.species):
|
|
149
|
+
with ui.row().classes("items-center"):
|
|
150
|
+
ui.label(f"Species conc. [{spec}]_free:")
|
|
151
|
+
self.spec_integ_inps[spec]= ui.input().classes("flex-1").props("clearable")
|
|
152
|
+
|
|
153
|
+
self.spec_integ_inps[spec].on(
|
|
154
|
+
"blur", lambda c=self.spec_integ_inps[spec]: self.set_focus(c)
|
|
155
|
+
)
|
|
156
|
+
b = ui.checkbox("Enabled", value=True)
|
|
157
|
+
self.spec_integ_inps[spec].bind_enabled_from(b, "value")
|
|
158
|
+
if hasattr(active_expt, 'integ_to_spec') and active_expt.integ_to_spec is not None and len(active_expt.integ_to_spec) > 0:
|
|
159
|
+
self.spec_integ_inps[spec].value = self.vec_to_conc_expression(
|
|
160
|
+
active_expt.integ_to_spec[i], active_expt.columns
|
|
161
|
+
)
|
|
162
|
+
if self.spec_integ_inps[spec].value == "":
|
|
163
|
+
b.value = False
|
|
164
|
+
else:
|
|
165
|
+
b.value = False
|
|
166
|
+
|
|
167
|
+
def _gen_spec_fast_exchange_block(self):
|
|
168
|
+
"""Generate the species fast exchange block.
|
|
169
|
+
|
|
170
|
+
For each column mapped to NMR chemical shifts (nmr_ppm) and marked as dependent,
|
|
171
|
+
create an input row to enter the concentration expression (specDeltaInps). When a
|
|
172
|
+
non-empty/valid expression is provided, create a parameter block immediately below
|
|
173
|
+
the input with: Chemical shift (number), Fixed (checkbox), Minimum (number), Maximum (number).
|
|
174
|
+
|
|
175
|
+
If Fixed is checked, Minimum/Maximum are disabled.
|
|
176
|
+
"""
|
|
177
|
+
self.dataModel_specFastExchange_block.clear()
|
|
178
|
+
active_expt = self.sm.active_expt_data_or_none
|
|
179
|
+
if active_expt is None:
|
|
180
|
+
self.fast_ex_label.visible = False
|
|
181
|
+
self.dataModel_specFastExchange_block.visible = False
|
|
182
|
+
return # stop now if there is no active data
|
|
183
|
+
active_expt.is_analytical_fast_ex = False
|
|
184
|
+
|
|
185
|
+
# find columns that are NMR chemical-shift dependent variables
|
|
186
|
+
fast_ex_list = []
|
|
187
|
+
dep_list = []
|
|
188
|
+
if active_expt.col_details:
|
|
189
|
+
for name, col in active_expt.col_details.items():
|
|
190
|
+
if col.get('dtype') is None:
|
|
191
|
+
continue
|
|
192
|
+
dtype = self.sm._expt_dtypes.get(col['dtype'])
|
|
193
|
+
if dtype and getattr(dtype, 'meas', None) == 'nmr_ppm' and col.get('depindep') == 'dep':
|
|
194
|
+
fast_ex_list.append(name)
|
|
195
|
+
if col.get('depindep') == 'dep':
|
|
196
|
+
dep_list.append(name)
|
|
197
|
+
|
|
198
|
+
if not fast_ex_list:
|
|
199
|
+
self.fast_ex_label.visible = False
|
|
200
|
+
self.dataModel_specFastExchange_block.visible = False
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
# if dep_list contains columns that are not in fast_ex_list, warn the user that those dependent variables will be ignored in the fast-exchange processing
|
|
204
|
+
extra_deps = [col for col in dep_list if col not in fast_ex_list]
|
|
205
|
+
simple_model= _infer_simple_fast_exchange_topology(self.sm.active_model.eq_mat, len(self.sm.active_model.component_names))
|
|
206
|
+
if simple_model is not None:
|
|
207
|
+
if len(extra_deps) == 0:
|
|
208
|
+
self.fast_ex_label.visible = False
|
|
209
|
+
with self.dataModel_specFastExchange_block:
|
|
210
|
+
ui.label(f"This is a simple model ({simple_model[0]} binding) with only fast-exchange observables, so we will use standard binding isotherms with analytical solutions.").classes("text-xs text-gray-600")
|
|
211
|
+
self.sm.active_expt_data.is_analytical_fast_ex = True
|
|
212
|
+
#return
|
|
213
|
+
else:
|
|
214
|
+
with self.dataModel_specFastExchange_block:
|
|
215
|
+
ui.label(f"This is a simple model ({simple_model}) but there are dependent variables ({', '.join(extra_deps)}) that are not recognized as fast-exchange observables, so a full numerical fit will be attempted. If you want to use the quicker analytical method, delete the extra observables data.").classes("text-xs text-gray-600")
|
|
216
|
+
|
|
217
|
+
self.fast_ex_label.visible = True
|
|
218
|
+
self.dataModel_specFastExchange_block.visible = True
|
|
219
|
+
|
|
220
|
+
# reset lists and UI block
|
|
221
|
+
self.specDeltaInps = []
|
|
222
|
+
self.specDeltaCards = []
|
|
223
|
+
self.fast_ex_chem_shift_blocks = []
|
|
224
|
+
self.fast_ex_chem_shift_params = []
|
|
225
|
+
# map per spec-delta index -> { species_name: {card, shift_num, fixed_cb, min_num, max_num} }
|
|
226
|
+
self.fast_ex_chem_shift_map = []
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# keep the fast-exchange column names for later processing and bindings
|
|
230
|
+
self.fast_ex_list_names = list(fast_ex_list)
|
|
231
|
+
if len(extra_deps) > 0:
|
|
232
|
+
with self.dataModel_specFastExchange_block:
|
|
233
|
+
# species_list = [f'{x}_free' for x in self.sm.species]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# default_shift_species = _default_analytical_shift_species(
|
|
239
|
+
# self.sm.active_model.eq_mat,
|
|
240
|
+
# list(self.sm.active_model.component_names),
|
|
241
|
+
# species_list,
|
|
242
|
+
# len(self.fast_ex_list_names),
|
|
243
|
+
# )
|
|
244
|
+
# if default_shift_species is not None:
|
|
245
|
+
# ui.label(
|
|
246
|
+
# f"Analytical model detected: defaulting fast-exchange expression to [{default_shift_species}] "
|
|
247
|
+
# "for this observable. You can edit it if needed."
|
|
248
|
+
# ).classes("text-xs text-gray-600")
|
|
249
|
+
|
|
250
|
+
for i, shift in enumerate(self.fast_ex_list_names):
|
|
251
|
+
# card per chemical shift column
|
|
252
|
+
card = ui.card().classes('mb-2')
|
|
253
|
+
self.specDeltaCards.append(card)
|
|
254
|
+
with card:
|
|
255
|
+
ui.label(f'Fast exchange shift {i+1} ({shift})').classes('text-sm font-semibold')
|
|
256
|
+
with ui.row().classes('items-center'):
|
|
257
|
+
inp = ui.input().classes('flex-1').props('clearable')
|
|
258
|
+
self.specDeltaInps.append(inp)
|
|
259
|
+
# species chips row will be added below the input to allow quick insertion
|
|
260
|
+
with ui.row().classes('gap-1 mt-2'):
|
|
261
|
+
for text in [f'{x}_free' for x in self.sm.species]:
|
|
262
|
+
ui.chip(text, color='teal', on_click=lambda h=text: self._insert_species_into_fast_inp(h))
|
|
263
|
+
# placeholder checkbox to enable the input
|
|
264
|
+
en_cb = ui.checkbox('Enabled', value=True)
|
|
265
|
+
inp.bind_enabled_from(en_cb, 'value')
|
|
266
|
+
# placeholder param block element (initially empty/hidden)
|
|
267
|
+
pb = ui.element()
|
|
268
|
+
self.fast_ex_chem_shift_blocks.append(pb)
|
|
269
|
+
self.fast_ex_chem_shift_map.append({})
|
|
270
|
+
# store param dict for later
|
|
271
|
+
# self.fast_ex_chem_shift_params.append({'shift': None, 'fixed': False, 'min': None, 'max': None})
|
|
272
|
+
|
|
273
|
+
# bind blur handler to create/update parameter block
|
|
274
|
+
# use default args to capture current inp and index
|
|
275
|
+
# allow chips/clicks to insert into this input by remembering last focus
|
|
276
|
+
inp.on('focus', lambda e, widget=inp: self.set_focus(widget))
|
|
277
|
+
inp.on('click', lambda e, widget=inp: self.set_focus(widget))
|
|
278
|
+
# handle blur to create/update parameter sub-blocks
|
|
279
|
+
inp.on('blur', lambda e, idx=i, widget=inp: self._handle_spec_delta_blur(idx, widget))
|
|
280
|
+
# immediate change handler: reparse on every change
|
|
281
|
+
inp.on_value_change(lambda e, idx=i, widget=inp: self._handle_spec_delta_blur(idx, widget))
|
|
282
|
+
|
|
283
|
+
# if there is saved delta_to_spec data, populate the input
|
|
284
|
+
if hasattr(active_expt, 'delta_to_spec') and active_expt.delta_to_spec is not None and len(active_expt.delta_to_spec) > 0:
|
|
285
|
+
delta_for_eqn = active_expt.delta_to_spec[i].copy()
|
|
286
|
+
|
|
287
|
+
for ij,el in enumerate(delta_for_eqn):
|
|
288
|
+
if np.isclose(el, 0):
|
|
289
|
+
delta_for_eqn[ij] = 0
|
|
290
|
+
else:
|
|
291
|
+
if (f'{self.sm.species[ij]}_free',shift) in active_expt.limiting_shifts:
|
|
292
|
+
s = active_expt.limiting_shifts[f'{self.sm.species[ij]}_free',shift]
|
|
293
|
+
if s.value:
|
|
294
|
+
delta_for_eqn[ij] = delta_for_eqn[ij] / s.value
|
|
295
|
+
else:
|
|
296
|
+
delta_for_eqn[ij] = 1
|
|
297
|
+
|
|
298
|
+
#self.sm.active_expt_data.limiting_shifts[f'{self.sm.species[i]}_free',shift].value
|
|
299
|
+
|
|
300
|
+
#delta_for_eqn[delta_for_eqn != 0] = 1
|
|
301
|
+
|
|
302
|
+
value = self.vec_to_conc_expression(delta_for_eqn, [f'{x}_free' for x in self.sm.species])
|
|
303
|
+
inp.value = value
|
|
304
|
+
if value == '':
|
|
305
|
+
en_cb.value = False
|
|
306
|
+
# elif default_shift_species is not None:
|
|
307
|
+
# inp.value = f"[{default_shift_species}]"
|
|
308
|
+
# # Build corresponding ChemicalShiftParam widgets immediately.
|
|
309
|
+
# self._handle_spec_delta_blur(i, inp)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# finished generating fast-exchange block
|
|
313
|
+
|
|
314
|
+
def _handle_spec_delta_blur(self, fast_ex_idx, inp):
|
|
315
|
+
"""Called when a specDelta input loses focus. If it contains a valid concentration expression
|
|
316
|
+
(non-zero vector), create or update the parameter UI block for that shift inside its card.
|
|
317
|
+
"""
|
|
318
|
+
# defensive checks
|
|
319
|
+
active_expt = self.sm.active_expt_data_or_none
|
|
320
|
+
if active_expt is None:
|
|
321
|
+
ui.notify("Experimental data object doesn't exist in handle_blur; stopping")
|
|
322
|
+
return
|
|
323
|
+
|
|
324
|
+
# For fast-exchange inputs, concentration expressions refer to model species, not columns
|
|
325
|
+
species_list = [f'{x}_free' for x in self.sm.active_model.species]
|
|
326
|
+
|
|
327
|
+
vec = self.conc_expression_to_vec(inp.value, species_list)
|
|
328
|
+
|
|
329
|
+
# if expression is invalid or maps to no species -> remove/hide param block
|
|
330
|
+
if vec is None or (isinstance(vec, (list, tuple, np.ndarray)) and np.all(np.isclose(vec, 0))):
|
|
331
|
+
# clear/hide param block if present
|
|
332
|
+
curr_block = self.fast_ex_chem_shift_blocks[fast_ex_idx]
|
|
333
|
+
curr_block.clear()
|
|
334
|
+
curr_block.visible = False
|
|
335
|
+
# reset stored params
|
|
336
|
+
if len(self.fast_ex_chem_shift_params)>0:
|
|
337
|
+
del self.fast_ex_chem_shift_params[fast_ex_idx]
|
|
338
|
+
#self.specDeltaParams[fast_ex_idx] = {'shift': None, 'fixed': False, 'min': None, 'max': None}
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
# ensure param block exists and is visible
|
|
342
|
+
curr_block = self.fast_ex_chem_shift_blocks[fast_ex_idx]
|
|
343
|
+
curr_block.visible = True
|
|
344
|
+
|
|
345
|
+
# build per-species parameter sub-blocks but reuse widgets when possible
|
|
346
|
+
nonzero_indices = [j for j, val in enumerate(vec) if not np.isclose(val, 0)]
|
|
347
|
+
# map indices back to species names
|
|
348
|
+
desired_species = [species_list[j] for j in nonzero_indices]
|
|
349
|
+
|
|
350
|
+
# hide any widgets for species not present
|
|
351
|
+
widget_map = self.fast_ex_chem_shift_map[fast_ex_idx]
|
|
352
|
+
for existing_species in list(widget_map.keys()):
|
|
353
|
+
if existing_species not in desired_species:
|
|
354
|
+
widget_map[existing_species]['card'].visible = False
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# create or update widgets for desired species
|
|
358
|
+
with curr_block:
|
|
359
|
+
for spec_name in desired_species:
|
|
360
|
+
|
|
361
|
+
# set up a ChemicalShiftParam instance keyed by (species, column)
|
|
362
|
+
col_name = self.fast_ex_list_names[fast_ex_idx]
|
|
363
|
+
k = (spec_name, col_name)
|
|
364
|
+
cs_obj = active_expt.limiting_shifts.get(k)
|
|
365
|
+
if not isinstance(cs_obj, ChemicalShiftParam):
|
|
366
|
+
# create with safe defaults
|
|
367
|
+
cs_obj = ChemicalShiftParam(species=spec_name, col=col_name, fixed=False)
|
|
368
|
+
active_expt.limiting_shifts[k] = cs_obj
|
|
369
|
+
|
|
370
|
+
# reuse if exists
|
|
371
|
+
w = widget_map.get(spec_name)
|
|
372
|
+
if w is not None:
|
|
373
|
+
w['card'].visible = True
|
|
374
|
+
else:
|
|
375
|
+
# create widgets and store references
|
|
376
|
+
card = ui.card().classes('q-pa-sm q-mb-sm')
|
|
377
|
+
with card:
|
|
378
|
+
ui.label(f'Species: {spec_name}').classes('text-sm font-semibold')
|
|
379
|
+
with ui.row().classes('items-center gap-2'):
|
|
380
|
+
shift_num = ui.number('Chemical shift').classes('w-40').props('clearable')
|
|
381
|
+
fixed_cb = ui.checkbox('Fixed', value=False)
|
|
382
|
+
min_num = ui.number('Minimum').classes('w-40').props('clearable')
|
|
383
|
+
max_num = ui.number('Maximum').classes('w-40').props('clearable')
|
|
384
|
+
|
|
385
|
+
w = {'card': card, 'shift_num': shift_num, 'fixed_cb': fixed_cb, 'min_num': min_num, 'max_num': max_num}
|
|
386
|
+
widget_map[spec_name] = w
|
|
387
|
+
|
|
388
|
+
# two-way bind control values directly to the object
|
|
389
|
+
w['shift_num'].bind_value(cs_obj, 'value')
|
|
390
|
+
w['fixed_cb'].bind_value(cs_obj, 'fixed')
|
|
391
|
+
w['min_num'].bind_value(cs_obj, '_min')
|
|
392
|
+
w['max_num'].bind_value(cs_obj, '_max')
|
|
393
|
+
|
|
394
|
+
# min/max enabled reflect inverted fixed
|
|
395
|
+
w['min_num'].bind_enabled_from(cs_obj, 'fixed', backward=lambda v: not v)
|
|
396
|
+
w['max_num'].bind_enabled_from(cs_obj, 'fixed', backward=lambda v: not v)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _clone_expt_data(self, old_expt: ExptData, new_name: str) -> ExptData:
|
|
400
|
+
"""Return a copy of old_expt with a new UUID and name, linked to the same model and raw data."""
|
|
401
|
+
d = old_expt.to_dict()
|
|
402
|
+
d['id'] = str(uuid.uuid4())
|
|
403
|
+
d['name'] = new_name
|
|
404
|
+
limiting_shifts_raw = d.pop('limiting_shifts', []) or []
|
|
405
|
+
new_expt = ExptData(**d)
|
|
406
|
+
new_expt.limiting_shifts = {}
|
|
407
|
+
for cs in limiting_shifts_raw:
|
|
408
|
+
csp = ChemicalShiftParam(**cs)
|
|
409
|
+
key = (csp.species, csp.col)
|
|
410
|
+
new_expt.limiting_shifts[key] = csp
|
|
411
|
+
new_expt.find_and_link_model(self.sm.models)
|
|
412
|
+
new_expt.find_and_link_raw_data(self.sm.raw_datas)
|
|
413
|
+
return new_expt
|
|
414
|
+
|
|
415
|
+
async def process_data_model(self):
|
|
416
|
+
"""Process the data model based on user input."""
|
|
417
|
+
active_expt = self.sm.active_expt_data_or_none
|
|
418
|
+
if active_expt is None:
|
|
419
|
+
ui.notify("No active experimental data to process.", type="negative")
|
|
420
|
+
return
|
|
421
|
+
|
|
422
|
+
# If existing fits depend on this ExptData, require saving as a new one
|
|
423
|
+
dependent_fits = [f for f in self.sm.fits.values() if f.expt_data_id == active_expt.id]
|
|
424
|
+
new_name = self.data_model_inp.value # default; overridden by dialog when cloning
|
|
425
|
+
if dependent_fits:
|
|
426
|
+
with ui.dialog() as dialog, ui.card().classes("w-96"):
|
|
427
|
+
ui.label("Existing fits depend on this data model.").classes("font-semibold")
|
|
428
|
+
ui.label("Save changes as a new data model to keep old fits valid:")
|
|
429
|
+
name_input = ui.input("New data model name", value=f"{active_expt.name} v2").classes("w-full")
|
|
430
|
+
with ui.row().classes("justify-end gap-2 mt-2"):
|
|
431
|
+
ui.button("Cancel", on_click=lambda: dialog.submit(None)).props("flat")
|
|
432
|
+
ui.button("Save as new", on_click=lambda: dialog.submit(name_input.value)).props("color=primary")
|
|
433
|
+
result = await dialog
|
|
434
|
+
if result is None:
|
|
435
|
+
ui.notify("Cancelled — data model unchanged.", type="info")
|
|
436
|
+
return
|
|
437
|
+
new_name = result
|
|
438
|
+
target = self._clone_expt_data(active_expt, new_name)
|
|
439
|
+
else:
|
|
440
|
+
target = active_expt
|
|
441
|
+
|
|
442
|
+
# make col_to_comp matrix
|
|
443
|
+
col_to_comp = [self.conc_expression_to_vec(input.value, target.columns) for input in self.compConcInps]
|
|
444
|
+
col_to_comp = np.array(col_to_comp)
|
|
445
|
+
target.col_to_comp = col_to_comp
|
|
446
|
+
target.name = new_name
|
|
447
|
+
|
|
448
|
+
# generate integ_to_spec block
|
|
449
|
+
# If there are no species inputs, set integ_to_spec to None
|
|
450
|
+
|
|
451
|
+
if not hasattr(self, 'spec_integ_inps') or len(self.spec_integ_inps) == 0:
|
|
452
|
+
target.integ_to_spec = None
|
|
453
|
+
else:
|
|
454
|
+
integ_to_spec = [self.conc_expression_to_vec(input.value, target.columns) if input.enabled else
|
|
455
|
+
np.zeros(len(target.columns)) for input in self.spec_integ_inps.values()]
|
|
456
|
+
integ_to_spec = np.array(integ_to_spec)
|
|
457
|
+
if integ_to_spec.size == 0 or np.all(np.isclose(integ_to_spec, 0)):
|
|
458
|
+
target.integ_to_spec = None
|
|
459
|
+
elif integ_to_spec.shape[0] != len(self.sm.species):
|
|
460
|
+
raise ValueError("Integ_to_spec matrix shape does not match number of species.")
|
|
461
|
+
else:
|
|
462
|
+
target.integ_to_spec = integ_to_spec
|
|
463
|
+
|
|
464
|
+
# If there is a fast-exchange block with enabled sections, save delta_to_spec as an object ndarray
|
|
465
|
+
fast_exchange_vectors: list[np.ndarray] = []
|
|
466
|
+
species_label_list: list[str] = []
|
|
467
|
+
row_columns: list[str] = []
|
|
468
|
+
if hasattr(self, 'specDeltaInps') and isinstance(self.specDeltaInps, list) and len(self.specDeltaInps) > 0:
|
|
469
|
+
species_label_list = [f"{s}_free" for s in self.sm.species]
|
|
470
|
+
for idx, input_widget in enumerate(self.specDeltaInps):
|
|
471
|
+
if hasattr(input_widget, 'enabled') and input_widget.enabled and isinstance(input_widget.value, str) and input_widget.value.strip():
|
|
472
|
+
parsed_vector = self.conc_expression_to_vec(input_widget.value, species_label_list)
|
|
473
|
+
fast_exchange_vectors.append(parsed_vector)
|
|
474
|
+
if hasattr(self, 'fast_ex_list_names') and idx < len(self.fast_ex_list_names):
|
|
475
|
+
row_columns.append(self.fast_ex_list_names[idx])
|
|
476
|
+
if fast_exchange_vectors:
|
|
477
|
+
target.build_delta_to_spec(fast_exchange_vectors, species_label_list, row_columns=row_columns)
|
|
478
|
+
else:
|
|
479
|
+
target.delta_to_spec = None
|
|
480
|
+
|
|
481
|
+
# Remove stale limiting_shifts entries
|
|
482
|
+
for k in list(target.limiting_shifts.keys()):
|
|
483
|
+
species, col_idx = k
|
|
484
|
+
if species[:-5] not in self.sm.active_model.species or col_idx not in target.columns:
|
|
485
|
+
del target.limiting_shifts[k]
|
|
486
|
+
|
|
487
|
+
# Rebuild column mapping (reset first to avoid accumulation on re-apply)
|
|
488
|
+
if hasattr(self, 'fast_ex_list_names') and len(self.fast_ex_list_names) > 0:
|
|
489
|
+
target.column_mapping = []
|
|
490
|
+
conc_cols = []
|
|
491
|
+
delta_cols = []
|
|
492
|
+
for i, col in enumerate(target.columns):
|
|
493
|
+
if col in self.fast_ex_list_names:
|
|
494
|
+
delta_cols.append(i)
|
|
495
|
+
else:
|
|
496
|
+
conc_cols.append(i)
|
|
497
|
+
i = 0
|
|
498
|
+
for col_idx in conc_cols:
|
|
499
|
+
target.column_mapping.append((col_idx, i))
|
|
500
|
+
i += 1
|
|
501
|
+
for col_idx in delta_cols:
|
|
502
|
+
target.column_mapping.append((col_idx, i))
|
|
503
|
+
i += 1
|
|
504
|
+
|
|
505
|
+
# Track simple analytical fast-exchange mode at save-time
|
|
506
|
+
dep_cols = [
|
|
507
|
+
name for name in target.columns
|
|
508
|
+
if target.col_details.get(name, {}).get("depindep") == "dep"
|
|
509
|
+
]
|
|
510
|
+
analytical_topology = _infer_simple_fast_exchange_topology(
|
|
511
|
+
self.sm.active_model.eq_mat,
|
|
512
|
+
len(self.sm.active_model.component_names),
|
|
513
|
+
)
|
|
514
|
+
target.is_analytical_fast_ex = False
|
|
515
|
+
if analytical_topology is not None and dep_cols:
|
|
516
|
+
all_dep_are_nmr = True
|
|
517
|
+
for col in dep_cols:
|
|
518
|
+
dtype_key = target.col_details.get(col, {}).get("dtype")
|
|
519
|
+
dtype = self.sm._expt_dtypes.get(dtype_key) if dtype_key is not None else None
|
|
520
|
+
if dtype is None or getattr(dtype, "meas", None) != "nmr_ppm":
|
|
521
|
+
all_dep_are_nmr = False
|
|
522
|
+
break
|
|
523
|
+
if all_dep_are_nmr:
|
|
524
|
+
target.is_analytical_fast_ex = True
|
|
525
|
+
|
|
526
|
+
if target is not active_expt:
|
|
527
|
+
self.sm.add_expt_data(target) # sets as active, reconciles, emits expt events
|
|
528
|
+
self.sm.active_fit_id = None # deselect any auto-selected fit
|
|
529
|
+
self.sm.notify_listeners("fit_changed")
|
|
530
|
+
self.sm.notify_listeners("fits_loaded")
|
|
531
|
+
self.sm.save_to_storage()
|
|
532
|
+
self.sm.notify_listeners("data_model_processed")
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def conc_expression_to_vec(self,input_str,cols):
|
|
536
|
+
"""Takes a string like "+#[H]+2#[G]" or "+#[H]+2*#[G]"
|
|
537
|
+
or any of the below:
|
|
538
|
+
[[H]]+[[G]]
|
|
539
|
+
-[[H]]+2[[G]]+3*[[F]]-0.5[[I]]
|
|
540
|
+
#[H]+2#[G]
|
|
541
|
+
and relates it to the column indices in the
|
|
542
|
+
cols, returning
|
|
543
|
+
a vector of the concentrations of each component."""
|
|
544
|
+
if input_str is None or input_str.strip() == "":
|
|
545
|
+
return np.zeros(len(cols))
|
|
546
|
+
|
|
547
|
+
cols_escaped = [re.escape(col) for col in cols]
|
|
548
|
+
pattern = r'(\+|-)?(\d*\.?\d+)?\*?\[(' + '|'.join(cols_escaped) + r')\]'
|
|
549
|
+
matches = re.findall(pattern, input_str)
|
|
550
|
+
|
|
551
|
+
res = np.zeros(len(cols))
|
|
552
|
+
for m in matches:
|
|
553
|
+
sign = 1 if m[0] != '-' else -1
|
|
554
|
+
coeff = sign*float(m[1]) if m[1] else sign*1.0
|
|
555
|
+
idx = cols.index(m[2])
|
|
556
|
+
res[idx] += coeff
|
|
557
|
+
|
|
558
|
+
return res
|
|
559
|
+
|
|
560
|
+
def vec_to_conc_expression(self, vec,cols):
|
|
561
|
+
"""Takes a vector of concentrations and returns a string
|
|
562
|
+
representation of the concentrations in the form of
|
|
563
|
+
"+[[H]]+2[[G]]" or similar."""
|
|
564
|
+
terms = []
|
|
565
|
+
for i, v in enumerate(vec):
|
|
566
|
+
if v =='-1':
|
|
567
|
+
terms.append(f"-[{cols[i]}]")
|
|
568
|
+
elif v == 1:
|
|
569
|
+
terms.append(f"+[{cols[i]}]")
|
|
570
|
+
elif v != 0:
|
|
571
|
+
terms.append(f"{v}*[{cols[i]}]")
|
|
572
|
+
return ''.join(terms).lstrip('+')
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def set_focus(self, c):
|
|
577
|
+
self.last_focus = c
|
|
578
|
+
|
|
579
|
+
def insert_term(self, h: str | ClickEventArguments) -> None:
|
|
580
|
+
if not isinstance(h,str):
|
|
581
|
+
raise ValueError("Species name from chip is not a str")
|
|
582
|
+
if hasattr(self,"last_focus") and self.last_focus is not None and self.last_focus.value is not None:
|
|
583
|
+
self.last_focus.value += f"+{h}"
|
|
584
|
+
# if the focused widget is a fast-exchange input, trigger its handler to regenerate param blocks
|
|
585
|
+
|
|
586
|
+
if hasattr(self, 'specDeltaInps') and self.last_focus in self.specDeltaInps:
|
|
587
|
+
idx = self.specDeltaInps.index(self.last_focus)
|
|
588
|
+
# call handler (simulate a change/blur)
|
|
589
|
+
self._handle_spec_delta_blur(idx, self.last_focus)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _insert_species_into_fast_inp(self, species_name: str | ClickEventArguments) -> None:
|
|
593
|
+
"""Insert a species chip term into the currently focused fast-exchange input only.
|
|
594
|
+
|
|
595
|
+
The inserted token matches the concentration expression token format: `[Species]`.
|
|
596
|
+
"""
|
|
597
|
+
if not isinstance(species_name,str):
|
|
598
|
+
raise ValueError("Species name from chip is not a str")
|
|
599
|
+
if not hasattr(self, 'last_focus') or self.last_focus is None:
|
|
600
|
+
return
|
|
601
|
+
# only insert into fast-exchange inputs
|
|
602
|
+
if hasattr(self, 'specDeltaInps') and self.last_focus in self.specDeltaInps:
|
|
603
|
+
# insert as +[Species]
|
|
604
|
+
self.last_focus.value = "" if self.last_focus.value is None else self.last_focus.value
|
|
605
|
+
self.last_focus.value += "+[" + species_name + "]"
|
|
606
|
+
|
|
607
|
+
idx = self.specDeltaInps.index(self.last_focus)
|
|
608
|
+
self._handle_spec_delta_blur(idx, self.last_focus)
|
|
609
|
+
|