bindtools 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.
bindtools/binding.py ADDED
@@ -0,0 +1,1658 @@
1
+ # mamba create -n binding -c conda-forge python jupyter tqdm ipython uncertainties lmfit scipy numpy emcee tqdm numba corner matplotlib numdifftools
2
+ # test
3
+
4
+ # Import necessary modules.
5
+ import datetime
6
+ import math
7
+ import os
8
+ import re
9
+ from multiprocessing import Pool, cpu_count
10
+ from contextlib import nullcontext
11
+
12
+ import corner
13
+ import emcee
14
+ import h5py
15
+ import lmfit
16
+ import matplotlib.pyplot as plt
17
+ import numpy as np
18
+ import pandas as pd
19
+ import scipy as sp
20
+ from numba import jit, njit
21
+ from scipy.integrate import odeint
22
+
23
+ #from optimparallel import minimize_parallel # not required but can be useful if you want to do parallel LBGFS
24
+ #from IPython.display import display, Markdown # lets us print tables of values tables using standard iPython
25
+ from uncertainties import ufloat, umath
26
+
27
+ from typing import Optional,List
28
+
29
+ import logging
30
+ logger = logging.getLogger(__name__)
31
+ #import pickle as pkl
32
+
33
+ class EquilibriumError(Exception):
34
+ def __init__(self,message,val,params):
35
+ # print("Equilibrium solver failed to converge.")
36
+ self.val = val
37
+ #pkl.dump(params,open('params.pkl','wb'))
38
+
39
+ #raise ValueError("End now")
40
+
41
+
42
+ # Set everything up - all functions for fitting
43
+ #
44
+ # #https://github.com/numba/numba/issues/1269#issuecomment-702665837
45
+ # This solution enables us to apply the overloaded np.prod function along a single axis, see link above
46
+ @jit(nopython=True)
47
+ def apply_along_axis_0(func1d, arr):
48
+ """Like calling func1d(arr, axis=0)"""
49
+ if arr.size == 0:
50
+ raise RuntimeError("Must have arr.size > 0")
51
+ ndim = arr.ndim
52
+ if ndim == 0:
53
+ raise RuntimeError("Must have ndim > 0")
54
+ elif 1 == ndim:
55
+ return func1d(arr)
56
+ else:
57
+ result_shape = arr.shape[1:]
58
+ out = np.empty(result_shape, arr.dtype)
59
+ _apply_along_axis_0(func1d, arr, out)
60
+ return out
61
+
62
+ # This solution enables us to apply the overloaded np.prod function along a single axis, see link above
63
+ @jit(nopython=True,nogil=True)
64
+ def _apply_along_axis_0(func1d, arr, out):
65
+ """Like calling func1d(arr, axis=0, out=out). Require arr to be 2d or bigger."""
66
+ ndim = arr.ndim
67
+ if ndim < 2:
68
+ raise RuntimeError("_apply_along_axis_0 requires 2d array or bigger")
69
+ elif ndim == 2: # 2-dimensional case
70
+ for i in range(len(out)):
71
+ out[i] = func1d(arr[:, i])
72
+ else: # higher dimensional case
73
+ for i, out_slice in enumerate(out):
74
+ _apply_along_axis_0(func1d, arr[:, i], out_slice)
75
+
76
+ # This function solves the equilibrium concentration problem based on equilibrium constants, adapted
77
+ # from Maeder and co workers: https://doi.org/10.1016/S0922-3487(07)80006-2
78
+ @jit(nopython=True,nogil=True,cache=True)
79
+ def getConcs(eqMat,initComponentConc,logK):
80
+ # now we give a vector containing the (log) equilibrium constants. Be careful
81
+ # with sign because these constants are for formation of the complexes
82
+ # above from the "pure components".
83
+ K = 10.0**logK
84
+ # make an initial guess. This doesn't need to be good - it should just
85
+ # be nearly-equally bad for all parameters
86
+ # the initial guess is for the concentrations of the *free components* only
87
+ guessCompConc = np.zeros((1,len(initComponentConc))) + np.mean(initComponentConc)
88
+ eqMat = eqMat.astype('float64') # fix bug in simple systems
89
+ # solve the equilibrium problem above using the Newton Raphson method
90
+ comp,spec = DoNR(eqMat,K,initComponentConc,guessCompConc)
91
+ return spec
92
+
93
+ def getConcsScipy(eqMat,initComponentConc,logK,alg='L-BFGS-B'):
94
+ K = 10.0**logK
95
+ guessCompConc = np.zeros(len(initComponentConc)) + np.mean(initComponentConc)
96
+
97
+ bds = [(0,np.max(initComponentConc)) for _ in range(len(guessCompConc))]
98
+ # jac=True says that specObj returns float,arr(n) where arr(n) is the Jacobian
99
+ res=sp.optimize.minimize(specObj,guessCompConc,jac=True,args=(initComponentConc,eqMat,K),method=alg,bounds=bds,tol=0,options={'gtol': 1e-20})
100
+
101
+ compTotCalc,specConc = specCalc(res.x,len(K),eqMat,K)
102
+ return specConc
103
+
104
+
105
+ @jit(nopython=True,nogil=True,cache=True)
106
+ def specCalc(conc,nspecies,eqMat,K): # this function calculates the equilibrium concentrations of the species
107
+ specmat = conc.repeat(nspecies).reshape((-1, nspecies))
108
+ eq3pt47 = apply_along_axis_0(np.prod,specmat**eqMat)
109
+ speciesConc = K * eq3pt47 # eq 3.48
110
+
111
+ compTotCalc = eqMat @ speciesConc
112
+ return compTotCalc,speciesConc
113
+
114
+ @jit(nopython=True,nogil=True,cache=True)
115
+ def specJac(conc,initComponentConc,eqMat,K): # this function calculates the Jacobian of the equilibrium problem
116
+ ncomp=np.shape(eqMat)[0]
117
+ nspecies=len(K)
118
+ _,speciesConc = specCalc(conc,nspecies,eqMat,K) # calculate species concentrations
119
+ J = _specJac(ncomp,eqMat,speciesConc)
120
+ return J
121
+
122
+ @jit(nopython=True,nogil=True,cache=True)
123
+ def _specJac(ncomp,eqMat,speciesConc): # this function calculates the Jacobian of the equilibrium problem
124
+
125
+ J = np.zeros((ncomp,ncomp))
126
+ # calculate Jacobian
127
+ for ii in range(ncomp):
128
+ for jj in range(ii, ncomp):
129
+ J[ii, jj] = np.sum(eqMat[ii, :] * eqMat[jj, :] * speciesConc)
130
+ if ii != jj:
131
+ J[jj, ii] = J[ii, jj]
132
+
133
+ return J
134
+
135
+ def specObj(conc,initComponentConc,eqMat,K):
136
+ nspecies = len(K)
137
+ compTotCalc,specConc = specCalc(conc,nspecies,eqMat,K)
138
+
139
+ # Calculate the objective function (sum of squared residuals)
140
+ residual = initComponentConc - compTotCalc
141
+ obj = np.sum(residual**2)
142
+
143
+ # Calculate the Jacobian of the objective function
144
+ J = _specJac(len(initComponentConc),eqMat,specConc)
145
+ # Jacobian of objective function: d/dc_i sum((target - calculated)^2) = -2 * sum(J_ij * (target_j - calculated_j))
146
+ grad = -2.0 * J @ residual
147
+
148
+ return obj, grad
149
+
150
+
151
+ # This function implements the Newton Raphson method for solving the equilibrium problem
152
+ # following: https://doi.org/10.1016/S0922-3487(07)80006-2
153
+ #@jit("Tuple((f8[:],f8[:]))(f8[:,:],f8[:],f8[:],f8[:])",nopython=True,nogil=True)
154
+ @jit(nopython=True,nogil=True,cache=True)
155
+ def DoNR(eqMat,K,initComponentConc,guessCompConc):
156
+ nspecies = len(K)
157
+ ncomp = len(initComponentConc)
158
+
159
+ initComponentConc[initComponentConc<=0] = 1e-20 # avoids numerical errors. Changed to <=0 rather than == 0 because sometimes small negative errors appear which makes
160
+ # the optimisation impossible
161
+
162
+ conc = np.copy(guessCompConc)
163
+ for iter in range(0,600):
164
+
165
+ compTotCalc,speciesConc = specCalc(conc,nspecies,eqMat,K)
166
+
167
+ # if our computed component concentrations are close
168
+ # enough to the true concentrations, we can stop
169
+ deltaComp = initComponentConc - compTotCalc
170
+ if np.all(np.abs(deltaComp) < 1e-15):
171
+ return compTotCalc,speciesConc
172
+
173
+ # otherwise calculate the Jacobian
174
+ J = _specJac(ncomp,eqMat,speciesConc)
175
+
176
+ # estimate the change in component concentrations
177
+ deltaConc = np.linalg.lstsq(J, deltaComp)[0].T * conc # , rcond=None
178
+
179
+ # if the change in component concentrations is too small, we are stuck
180
+ if(np.any(deltaConc == 0) and np.max(np.abs(deltaConc)) < 1e-15):
181
+ # we are not going anywhere, let's start again with a subtly different initial guess
182
+ conc = guessCompConc+np.random.randn(len(guessCompConc))*1e-10
183
+ else:
184
+ conc += deltaConc
185
+
186
+ if (iter+1) % 200 == 0:
187
+ # assume we are stuck, try new guess
188
+ conc = guessCompConc+np.random.randn(len(guessCompConc))*1e-10
189
+
190
+ while np.any(conc <= 0):
191
+ deltaConc = deltaConc/2
192
+ conc -= deltaConc
193
+ if np.all(np.abs(deltaConc)<1e-19):
194
+ break
195
+
196
+ raise EquilibriumError("Failed to converge in equilibrium solver doNR",speciesConc,(eqMat,K,initComponentConc,guessCompConc))
197
+
198
+
199
+ def _analytical_obs_param_token(col_name: str) -> str:
200
+ token = re.sub(r"[^\w]+", "_", str(col_name))
201
+ token = re.sub(r"_+", "_", token).strip("_")
202
+ if not token:
203
+ token = "obs"
204
+ if not (token[0].isalpha() or token[0] == "_"):
205
+ token = f"obs_{token}"
206
+ return token
207
+
208
+
209
+ def _best_real_root(coeffs: np.ndarray, lower: float, upper: float, score_fn) -> float:
210
+ nz = np.where(np.abs(coeffs) > 1e-18)[0]
211
+ if len(nz) == 0:
212
+ return float(lower)
213
+
214
+ trimmed = coeffs[nz[0]:]
215
+ roots = np.roots(trimmed)
216
+ real_roots = [float(r.real) for r in roots if abs(r.imag) < 1e-10]
217
+ if not real_roots:
218
+ return float(lower)
219
+
220
+ bounded = [r for r in real_roots if lower - 1e-12 <= r <= upper + 1e-12]
221
+ candidates = bounded if bounded else real_roots
222
+ best = min(candidates, key=score_fn)
223
+ return float(np.clip(best, lower, upper))
224
+
225
+
226
+ def _solve_row_11(h_tot: float, g_tot: float, beta11: float) -> tuple[float, float, float]:
227
+ if beta11 <= 0:
228
+ raise ValueError("Binding constant (in natural units) cannot be negative")
229
+ if h_tot < 0 or g_tot < 0:
230
+ raise ValueError("Total concentrations must be non-negative")
231
+ if h_tot ==0 or g_tot == 0:
232
+ return max(h_tot, 0.0), max(g_tot, 0.0), 0.0 # if either total concentration is zero, then there can be no complex
233
+
234
+ first_term = h_tot + g_tot + 1.0 / max(beta11, 1e-300)
235
+ hg = 0.5 * (first_term - math.sqrt(max(first_term**2 - 4.0 * h_tot * g_tot, 0.0)))
236
+ hg = float(np.clip(hg, 0.0, min(h_tot, g_tot))) # [HG] cannot be negative or greater than either total concentration
237
+ return h_tot - hg, g_tot - hg, hg
238
+
239
+
240
+ def _solve_row_12(h_tot: float, g_tot: float, beta11: float, beta12: float) -> tuple[float, float, float, float]:
241
+ if h_tot <= 0 or g_tot <= 0:
242
+ return max(h_tot, 0.0), max(g_tot, 0.0), 0.0, 0.0
243
+
244
+ coeffs = np.array(
245
+ [
246
+ beta12,
247
+ beta11 + 2.0 * h_tot * beta12 - g_tot * beta12,
248
+ 1.0 + h_tot * beta11 - g_tot * beta11,
249
+ -g_tot,
250
+ ],
251
+ dtype=float,
252
+ )
253
+
254
+ def score_fn(g_free: float) -> float:
255
+ d = 1.0 + beta11 * g_free + beta12 * (g_free**2)
256
+ if d <= 0:
257
+ return np.inf
258
+ h_free = h_tot / d
259
+ hg = beta11 * h_free * g_free
260
+ hg2 = beta12 * h_free * (g_free**2)
261
+ r1 = abs(h_tot - (h_free + hg + hg2))
262
+ r2 = abs(g_tot - (g_free + hg + 2.0 * hg2))
263
+ return r1 + r2
264
+
265
+ g_free = _best_real_root(coeffs, 0.0, max(g_tot, 0.0), score_fn)
266
+ d = max(1.0 + beta11 * g_free + beta12 * (g_free**2), 1e-300)
267
+ h_free = h_tot / d
268
+ hg = beta11 * h_free * g_free
269
+ hg2 = beta12 * h_free * (g_free**2)
270
+ return max(h_free, 0.0), max(g_free, 0.0), max(hg, 0.0), max(hg2, 0.0)
271
+
272
+
273
+ def _solve_row_21(h_tot: float, g_tot: float, beta11: float, beta21: float) -> tuple[float, float, float, float]:
274
+ if h_tot <= 0 or g_tot <= 0:
275
+ return max(h_tot, 0.0), max(g_tot, 0.0), 0.0, 0.0
276
+
277
+ coeffs = np.array(
278
+ [
279
+ beta21,
280
+ beta11 + 2.0 * g_tot * beta21 - h_tot * beta21,
281
+ 1.0 + g_tot * beta11 - h_tot * beta11,
282
+ -h_tot,
283
+ ],
284
+ dtype=float,
285
+ )
286
+
287
+ def score_fn(h_free: float) -> float:
288
+ d = 1.0 + beta11 * h_free + beta21 * (h_free**2)
289
+ if d <= 0:
290
+ return np.inf
291
+ g_free = g_tot / d
292
+ hg = beta11 * h_free * g_free
293
+ h2g = beta21 * (h_free**2) * g_free
294
+ r1 = abs(h_tot - (h_free + hg + 2.0 * h2g))
295
+ r2 = abs(g_tot - (g_free + hg + h2g))
296
+ return r1 + r2
297
+
298
+ h_free = _best_real_root(coeffs, 0.0, max(h_tot, 0.0), score_fn)
299
+ d = max(1.0 + beta11 * h_free + beta21 * (h_free**2), 1e-300)
300
+ g_free = g_tot / d
301
+ hg = beta11 * h_free * g_free
302
+ h2g = beta21 * (h_free**2) * g_free
303
+ return max(h_free, 0.0), max(g_free, 0.0), max(hg, 0.0), max(h2g, 0.0)
304
+
305
+
306
+ def calc_analytical_speciation(
307
+ comp_concs: np.ndarray,
308
+ eq_mat: np.ndarray,
309
+ binding_params: np.ndarray,
310
+ topology: str,
311
+ n_comp: int,
312
+ complex_indices: list[int],
313
+ ) -> tuple[np.ndarray, bool]:
314
+
315
+
316
+ n_rows = int(np.shape(comp_concs)[0])
317
+ n_species = int(np.shape(eq_mat)[1])
318
+ spec_calc = np.zeros((n_rows, n_species), dtype=float)
319
+ error = False
320
+
321
+ if n_comp != 2:
322
+ raise ValueError("Analytical fast-exchange solver requires exactly 2 components.")
323
+
324
+ beta11 = 10.0 ** float(binding_params[complex_indices[0]])
325
+ beta2 = None
326
+ if topology in ("1:2", "2:1"):
327
+ beta2 = 10.0 ** float(binding_params[complex_indices[1]])
328
+
329
+ for ii, row in enumerate(comp_concs):
330
+ h_tot = float(max(row[0], 0.0))
331
+ g_tot = float(max(row[1], 0.0))
332
+ try:
333
+ if topology == "1:1":
334
+ h_free, g_free, hg = _solve_row_11(h_tot, g_tot, beta11)
335
+ spec_calc[ii, 0] = h_free
336
+ spec_calc[ii, 1] = g_free
337
+ spec_calc[ii, complex_indices[0]] = hg
338
+ elif topology == "1:2":
339
+ h_free, g_free, hg, hg2 = _solve_row_12(h_tot, g_tot, beta11, float(beta2))
340
+ spec_calc[ii, 0] = h_free
341
+ spec_calc[ii, 1] = g_free
342
+ spec_calc[ii, complex_indices[0]] = hg
343
+ spec_calc[ii, complex_indices[1]] = hg2
344
+ elif topology == "2:1":
345
+ h_free, g_free, hg, h2g = _solve_row_21(h_tot, g_tot, beta11, float(beta2))
346
+ spec_calc[ii, 0] = h_free
347
+ spec_calc[ii, 1] = g_free
348
+ spec_calc[ii, complex_indices[0]] = hg
349
+ spec_calc[ii, complex_indices[1]] = h2g
350
+ else:
351
+ raise ValueError(f"Unsupported analytical topology: {topology}")
352
+ except Exception:
353
+ error = True
354
+ try:
355
+ spec_calc[ii, :] = getConcs(eq_mat, np.array([h_tot, g_tot]), binding_params)
356
+ except Exception:
357
+ spec_calc[ii, :] = 0.0
358
+
359
+ return spec_calc, error
360
+
361
+
362
+ def _analytical_param_value(
363
+ values: np.ndarray, name_to_idx: dict[str, int], param_name: str
364
+ ) -> float:
365
+ idx = name_to_idx.get(param_name)
366
+ if idx is None or idx >= len(values):
367
+ return 0.0
368
+ return float(values[idx])
369
+
370
+
371
+ def calc_analytical_observables(
372
+ spec_calc: np.ndarray,
373
+ comp_concs: np.ndarray,
374
+ eq_mat: np.ndarray,
375
+ obs_components: list[int],
376
+ complex_indices: list[int],
377
+ shift_param_values: np.ndarray,
378
+ shift_param_names: list[str],
379
+ obs_param_map: list[list[str]],
380
+ ) -> np.ndarray:
381
+
382
+
383
+ n_obs = len(obs_components)
384
+ out = np.zeros((spec_calc.shape[0], n_obs), dtype=float)
385
+ name_to_idx = {name: i for i, name in enumerate(shift_param_names)}
386
+
387
+ for obs_idx in range(n_obs):
388
+ comp_idx = int(obs_components[obs_idx])
389
+ denom = comp_concs[:, comp_idx]
390
+ pnames = obs_param_map[obs_idx]
391
+ baseline = _analytical_param_value(shift_param_values, name_to_idx, pnames[0])
392
+ out[:, obs_idx] = baseline
393
+
394
+ for cidx, complex_idx in enumerate(complex_indices):
395
+ if cidx + 1 >= len(pnames):
396
+ continue
397
+ amp = _analytical_param_value(shift_param_values, name_to_idx, pnames[cidx + 1])
398
+ stoich = float(eq_mat[comp_idx, complex_idx])
399
+ frac = np.zeros_like(denom, dtype=float)
400
+ np.divide(
401
+ stoich * spec_calc[:, complex_idx],
402
+ denom,
403
+ out=frac,
404
+ where=np.isfinite(denom) & (np.abs(denom) > 0),
405
+ )
406
+ out[:, obs_idx] += amp * frac
407
+
408
+ return out
409
+
410
+
411
+ def calc_analytical_linear_observables(
412
+ spec_calc: np.ndarray,
413
+ linear_param_values: np.ndarray,
414
+ linear_param_names: list,
415
+ linear_obs_param_map: list,
416
+ ) -> np.ndarray:
417
+ """Compute concentration-weighted linear observables for the analytical fast-exchange path.
418
+
419
+ Used for UV-vis (Beer-Lambert) and fluorescence: A_i = sum_j eps_{ij} * [species_j].
420
+
421
+ Args:
422
+ spec_calc: species concentrations, shape (n_pts, n_species).
423
+ linear_param_values: flat array of current non-binding parameter values.
424
+ linear_param_names: list of param names corresponding to linear_param_values.
425
+ linear_obs_param_map: list of lists; outer index = observable column, inner index = species.
426
+ Each inner entry is a param name string or None (dark/silent species).
427
+
428
+ Returns:
429
+ ndarray of shape (n_pts, n_obs).
430
+ """
431
+ n_obs = len(linear_obs_param_map)
432
+ out = np.zeros((spec_calc.shape[0], n_obs), dtype=float)
433
+ name_to_idx = {name: i for i, name in enumerate(linear_param_names)}
434
+
435
+ for obs_idx, pnames in enumerate(linear_obs_param_map):
436
+ for species_idx, pname in enumerate(pnames):
437
+ if not pname:
438
+ continue # dark / silent species
439
+ coeff = _analytical_param_value(linear_param_values, name_to_idx, pname)
440
+ out[:, obs_idx] += coeff * spec_calc[:, species_idx]
441
+
442
+ return out
443
+
444
+
445
+ def fitfun_analytical_fast_exchange(params, fcn_opts):
446
+ if isinstance(params, lmfit.parameter.Parameters):
447
+ parvals = list(params.valuesdict().values())
448
+ else:
449
+ parvals = params
450
+
451
+ nK = int(fcn_opts["nK"])
452
+ binding_params = np.array([*parvals][:nK], dtype=np.float64)
453
+ comp_concs = np.array(fcn_opts["compConcs"], dtype=float)
454
+ eq_mat = np.array(fcn_opts["eqMat"], dtype=float)
455
+ topology = str(fcn_opts.get("analytical_topology", ""))
456
+ complex_indices = [int(x) for x in fcn_opts.get("analytical_complex_indices", [])]
457
+ obs_components = [int(x) for x in fcn_opts.get("analytical_obs_components", [])]
458
+ obs_param_map = list(fcn_opts.get("analytical_obs_param_map", []))
459
+ linear_obs_param_map = list(fcn_opts.get("analytical_linear_obs_param_map", []))
460
+
461
+ spec_calc, error = calc_analytical_speciation(
462
+ comp_concs=comp_concs,
463
+ eq_mat=eq_mat,
464
+ binding_params=binding_params,
465
+ topology=topology,
466
+ n_comp=int(comp_concs.shape[1]),
467
+ complex_indices=complex_indices,
468
+ )
469
+
470
+ if fcn_opts["optTarget"] == "concs":
471
+ if fcn_opts["ret"] == "residual":
472
+ res = (fcn_opts["exptData"] - spec_calc) / fcn_opts["sigma"]
473
+ if error and fcn_opts["mcmc"] is True:
474
+ return np.nan
475
+ if error:
476
+ res = res * 10
477
+ return res
478
+ if fcn_opts["ret"] == "concs":
479
+ return spec_calc
480
+ return -1
481
+
482
+ shift_params = np.array([*parvals][nK:], dtype=np.float64)
483
+ shift_param_names = fcn_opts["paramNames"][nK:]
484
+
485
+ # Route to linear (UV-vis / fluorescence) or NMR-shift observable calculation.
486
+ if linear_obs_param_map:
487
+ obs_calc = calc_analytical_linear_observables(
488
+ spec_calc=spec_calc,
489
+ linear_param_values=shift_params,
490
+ linear_param_names=shift_param_names,
491
+ linear_obs_param_map=linear_obs_param_map,
492
+ )
493
+ else:
494
+ obs_calc = calc_analytical_observables(
495
+ spec_calc=spec_calc,
496
+ comp_concs=comp_concs,
497
+ eq_mat=eq_mat,
498
+ obs_components=obs_components,
499
+ complex_indices=complex_indices,
500
+ shift_param_values=shift_params,
501
+ shift_param_names=shift_param_names,
502
+ obs_param_map=obs_param_map,
503
+ )
504
+
505
+ if fcn_opts["ret"] == "residual":
506
+ res = (fcn_opts["exptData"] - obs_calc) / fcn_opts["sigma"]
507
+ nan_mask = np.isnan(fcn_opts["exptData"])
508
+ res[nan_mask] = 0
509
+ if error and fcn_opts["mcmc"] is True:
510
+ return np.nan
511
+ if error:
512
+ res = res * 10
513
+ if fcn_opts["mcmc"] is True:
514
+ res = np.nan_to_num(res)
515
+ return res
516
+ if fcn_opts["ret"] == "concs":
517
+ return obs_calc
518
+ return -1
519
+
520
+
521
+ def fitfun(params,fcn_opts):#,eqMat,specConcs,startShifts=None,sigma=1,ret='residual'):
522
+
523
+ # fcn_opts = {'compConcs': compConcs,
524
+ # 'eqMat': eqMat,
525
+ # 'optTarget': 'obs', # or concs
526
+ # 'concsExpt': None,
527
+ # # 'obsExpt': integrals,
528
+ # # 'concToObs': concToObs,
529
+ # 'sigma': 1,
530
+ # 'ret': 'residual'}
531
+
532
+ if fcn_opts.get('analytical_fast_exchange') is True and fcn_opts.get('analytical_topology') in ('1:1', '1:2', '2:1'):
533
+ return fitfun_analytical_fast_exchange(params, fcn_opts)
534
+
535
+ if isinstance(params,lmfit.parameter.Parameters):
536
+ parvals = list(params.valuesdict().values())
537
+ else:
538
+ parvals = params
539
+
540
+ bindingParams = np.array([*parvals][:fcn_opts['nK']],dtype=np.float64)
541
+ error = False
542
+ specCalc = []
543
+ for row in fcn_opts['compConcs']:
544
+ try:
545
+ yEq = getConcs(fcn_opts['eqMat'],row,bindingParams)
546
+ except EquilibriumError as e:
547
+ yEq = e.val
548
+ error = True
549
+ specCalc.append(yEq)#yEq[4:])
550
+ specCalc = np.array(specCalc)
551
+ # except EquilibriumError as e:
552
+ # if fcn_opts['ret'] == 'residual':
553
+ # return 1000
554
+ # if fnc_opts
555
+ # specCalc = np.zeros((len(fcn_opts['compConcs']),len(e.val)))+1e-50
556
+ # print("Equilibrium solver failed to converge. Returning zeros.")
557
+
558
+
559
+ if fcn_opts['optTarget'] == 'concs':
560
+ if fcn_opts['ret'] == 'residual':
561
+ # normalize residuals
562
+ res= (fcn_opts['exptData']-specCalc)
563
+ res = res/fcn_opts['sigma']
564
+ if error is True: # penalty if there is an error in doNR
565
+ res = res*10
566
+ if fcn_opts['mcmc'] is True:
567
+ return np.nan
568
+ return res
569
+ elif fcn_opts['ret'] == 'concs':
570
+ return specCalc
571
+
572
+ elif fcn_opts['optTarget'] == 'obs':
573
+ shiftParams = np.array([*parvals][fcn_opts['nK']:],dtype=np.float64)
574
+ paramNames = fcn_opts['paramNames'][fcn_opts['nK']:]
575
+ obsCalc = concToObservable(
576
+ specCalc,
577
+ fcn_opts['specToInteg'],
578
+ fcn_opts['specToDd'],
579
+ shiftParams,
580
+ paramNames,
581
+ specToLinear=fcn_opts.get('specToLinear'),
582
+ )
583
+
584
+ if fcn_opts['ret'] == 'residual':
585
+ # normalize residuals
586
+ res= (fcn_opts['exptData'] - obsCalc) # function needs to know if it's working in conc or observables
587
+ res = res/fcn_opts['sigma']
588
+
589
+ # Set positions of nan values in exptData to 0 in res
590
+ nan_mask = np.isnan(fcn_opts['exptData'])
591
+ res[nan_mask] = 0
592
+ res[fcn_opts['exptData']==np.nan] = 0
593
+
594
+ if error is True: # penalty if there is an error in doNR
595
+ res = res*10
596
+ if fcn_opts['mcmc'] is True:
597
+ return np.nan
598
+ if fcn_opts['mcmc'] is True:
599
+ # remove nan values which tend to come from where we
600
+ # are unable to calculate a chemical shift due to insufficient
601
+ # data (i.e. zeros in specToDd)
602
+ if fcn_opts['specToDd'] is not None:
603
+ nShift = np.shape(fcn_opts['specToDd'])[1]
604
+ res[:,-nShift:] = np.nan_to_num(res[:,-nShift:])
605
+ # res[np.isnan(res)] = 1000 # set a huge residual where we have NaNs (which arise from normalizing the chemical shift)
606
+ # print(res)
607
+ return res
608
+ elif fcn_opts['ret'] == 'concs':
609
+ return obsCalc
610
+
611
+ else:
612
+ print("Invalid return argument. Options: 'residual' [default] or 'concs'")
613
+ return -1
614
+
615
+
616
+ def deltaToConc(deltas,mapping,startShifts,endShifts,isHost,isHG):
617
+ # deltas is a vector of chemical shifts
618
+ pass
619
+
620
+ def concToDelta(concs,specToDd,shiftParams,paramNames):
621
+ mlen,nlen = np.shape(specToDd)
622
+ #nlen = np.shape(specToDd)[1]
623
+ specToDdTrial = np.copy(specToDd)
624
+
625
+ # here we replace tuples in the original mapping with parameter values from the fitting engine
626
+ # this could be done a lot more elegantly/cleanly TODO
627
+ for ii in range(mlen):
628
+ for ij in range(nlen):
629
+ if isinstance(specToDdTrial[ii,ij], tuple):
630
+ specToDdTrial[ii,ij] = shiftParams[paramNames.index('shift_{}_{}'.format(ii,ij))]
631
+
632
+ elif isinstance(specToDdTrial[ii,ij], lmfit.Parameter):
633
+ specToDdTrial[ii,ij] = shiftParams[paramNames.index(specToDdTrial[ii,ij].name)]
634
+ specToDdTrial = specToDdTrial.astype(np.float64)
635
+
636
+ # normalize concs to mol fractions
637
+ truthMat = np.array(specToDdTrial,dtype=bool)
638
+ shiftCalc = []
639
+ for cc in concs:
640
+ tt = (truthMat.T*cc).T
641
+ moleFracs = tt / tt.sum(axis=0)
642
+ sc = (moleFracs*specToDdTrial).sum(axis=0)
643
+ shiftCalc.append(sc)
644
+
645
+ return shiftCalc
646
+
647
+
648
+ def concToLinearObs(concs: np.ndarray, specToLinear: np.ndarray, shiftParams: np.ndarray, paramNames: list) -> np.ndarray:
649
+ """Compute concentration-weighted linear observables (Beer-Lambert / fluorescence).
650
+
651
+ obs_i = sum_j ( coeff_ij * [species_j] ) — no mole-fraction normalisation.
652
+
653
+ specToLinear has shape (n_species, n_obs) with dtype=object; each entry is either
654
+ a float (including 0.0 for dark/silent species) or an lmfit.Parameter whose current
655
+ value is looked up in shiftParams / paramNames.
656
+
657
+ Returns an ndarray of shape (n_pts, n_obs).
658
+ """
659
+ mlen, nlen = np.shape(specToLinear) # (n_species, n_obs)
660
+ specToLinearTrial = np.copy(specToLinear)
661
+
662
+ for ii in range(mlen):
663
+ for ij in range(nlen):
664
+ if isinstance(specToLinearTrial[ii, ij], lmfit.Parameter):
665
+ specToLinearTrial[ii, ij] = shiftParams[paramNames.index(specToLinearTrial[ii, ij].name)]
666
+ specToLinearTrial = specToLinearTrial.astype(np.float64)
667
+
668
+ # Direct linear combination: (n_pts, n_species) @ (n_species, n_obs) -> (n_pts, n_obs)
669
+ return np.dot(concs, specToLinearTrial)
670
+
671
+
672
+ # This function converts a matrix of concentrations (n measurements x m species) into
673
+ # a matrix which permits comparison to experimental data (e.g. NMR integrals where
674
+ # each integral might comprise several species). The conversion is via a matrix (m species x p observables)
675
+ # which maps species onto integrals
676
+ # Scalefactor - when provided as a vector of length m - allows the observable values to be scaled
677
+ def concToObservable(specConcs, specToInteg, specToDd, shiftParams, paramNames, scaleFactor=None, specToLinear=None):
678
+ """Convert species concentrations to predicted observables.
679
+
680
+ Observable ordering in the returned matrix: [integ cols, linear cols, shift cols].
681
+ The experimental data matrix must use the same column ordering.
682
+ """
683
+ parts = []
684
+
685
+ if specToInteg is not None:
686
+ if scaleFactor is None:
687
+ scaleFactor = np.ones((np.shape(specToInteg)[1],))
688
+ parts.append(np.array(np.dot(specConcs, specToInteg) * scaleFactor))
689
+
690
+ if specToLinear is not None:
691
+ parts.append(concToLinearObs(specConcs, specToLinear, shiftParams, paramNames))
692
+
693
+ if specToDd is not None:
694
+ parts.append(np.array(concToDelta(specConcs, specToDd, shiftParams, paramNames)))
695
+
696
+ if len(parts) == 1:
697
+ return parts[0]
698
+ elif len(parts) > 1:
699
+ return np.concatenate(parts, axis=1)
700
+ else:
701
+ return np.zeros((np.shape(specConcs)[0], 0))
702
+
703
+
704
+
705
+
706
+ # def logprior(par):
707
+ # if par[2]<par[1] or par[1]<par[0]:
708
+ # return -np.inf
709
+ # if par[2]>30 or par[2]<8:
710
+ # return -np.inf
711
+ # if par[1]>20 or par[1]<3:
712
+ # return -np.inf
713
+ # if par[0]>12 or par[0]<3:
714
+ # return -np.inf
715
+ # else:
716
+ # return 0
717
+
718
+
719
+ # def fitfun_mc(params,compConcs,eqMat,specConcs):
720
+ # if type(params) == lmfit.parameter.Parameters:
721
+ # parvals = list(params.valuesdict().values())
722
+ # else:
723
+ # parvals = params
724
+
725
+
726
+ # params = parvals[:-1]#.valuesdict().values()
727
+ # lnsigma = parvals[-1]
728
+ # params = [*params][0:len(eqMat)]
729
+ # specCalc = []
730
+
731
+
732
+ # # if a param is getting silly, reject by checking against the priors
733
+ # lp = logprior(params)
734
+ # if np.isinf(lp):
735
+ # return lp
736
+
737
+ # for row in compConcs:
738
+ # try:
739
+ # yEq = getConcs(eqMat,row,np.array([0,0,0,0,*params],dtype=np.float64))
740
+ # except np.linalg.LinAlgError:
741
+ # return -np.inf
742
+ # # if the maths doesn't work then the solution is deemed impossible
743
+ # specCalc.append(yEq[3:])
744
+ # specCalc = np.array(specCalc)
745
+
746
+ # return (-0.5*np.sum(
747
+ # ((specConcs-specCalc) / np.exp(lnsigma))**2
748
+ # + np.log(2*np.pi) + 2*lnsigma))
749
+
750
+
751
+ # def diffEvolFunc(x,specConcs,compConcs):
752
+ # res = fitfun(compConcs,x[0],x[0]*x[1],x[0]*x[1]*x[2])
753
+
754
+ # return sum((specConcs.flatten() - res)**2)
755
+
756
+ class bindingModel():
757
+ def __init__(self,eqMat,compNames,speciesList,specToInteg=None,specToDd=None,colToComp=None,obsList=None,rawData=None,compConcs=None):
758
+ self.plist = speciesList
759
+ self.compNames = compNames
760
+ self.eqMat = eqMat
761
+ self._colToComp = None
762
+ self._compConcs = None
763
+ self.nConcs = None
764
+ self.nComp = None
765
+ self.obsList=obsList
766
+ self.comment = None
767
+
768
+ # if compConcs is not None:
769
+ # self.compConcs = compConcs
770
+
771
+ if colToComp is not None:
772
+ self.colToComp = colToComp
773
+
774
+ self._specConcs = None
775
+ if specToInteg is not None:
776
+ self.colToSpec = specToInteg
777
+ else:
778
+ self.colToSpec = None
779
+
780
+ self.rawData = rawData
781
+
782
+ if compConcs is not None:
783
+ self._compConcs = compConcs
784
+ self.nComp = np.shape(compConcs)[1]
785
+ self.nConcs = np.shape(compConcs)[0]
786
+
787
+ self.concUnits = None # mM #TODO
788
+ self.specToDd = specToDd
789
+ self.specToLinear: Optional[np.ndarray] = None # (n_species, n_obs) object array for UV-vis / fluorescence
790
+ self.analytical_fast_exchange: bool = False
791
+ self.analytical_topology: Optional[str] = None
792
+ self.analytical_obs_columns: list[str] = []
793
+ self.analytical_obs_components: list[int] = []
794
+ self.analytical_complex_indices: list[int] = []
795
+ self.analytical_obs_param_map: list[list[str]] = []
796
+ self.analytical_linear_obs_columns: list[str] = []
797
+ self.analytical_linear_obs_param_map: list[list] = [] # per-obs list of param names (or None) in species order
798
+
799
+ self.params = lmfit.Parameters()
800
+ self.mini=None
801
+ self.miniResult = None
802
+ self.fcn_opts=None
803
+ self.colTypes: Optional[List[ObsType]] = None
804
+
805
+
806
+ def _addParam(self,name,init=3,min=0,max=14,vary=True):
807
+ self.params[name] = lmfit.Parameter(name,init,min=min,max=max,vary=vary)
808
+
809
+ def _addExistingParam(self,param):
810
+ self.params[param.name] = param
811
+
812
+ @property
813
+ def colToComp(self):
814
+ return self._colToComp
815
+
816
+ @colToComp.setter
817
+ def colToComp(self,v):
818
+ self._colToComp = v
819
+ if v is not None:
820
+ self.nConcs = np.shape(v)[1]
821
+ self.nComp = np.shape(v)[0]
822
+
823
+ @property
824
+ def specConcs(self):
825
+ if self._specConcs is not None:
826
+ return self._specConcs
827
+ elif (self.rawData is not None) and (self.colToSpec is not None):
828
+ self.genSpecConcs()
829
+ return self._specConcs
830
+ else:
831
+ raise ValueError("Data or mapping not available, unable to generate species concentrations.")
832
+
833
+ @specConcs.setter
834
+ def specConcs(self,v):
835
+ self._specConcs = v
836
+
837
+ @property
838
+ def compConcs(self):
839
+ if self._compConcs is not None:
840
+ return self._compConcs
841
+ elif (self.rawData is not None) and (self.colToComp is not None):
842
+ self._compConcs = np.dot(self.rawData[:,:self.nConcs],self.colToComp.T) # [Htot, Gtot]
843
+ return self._compConcs
844
+ else:
845
+ raise ValueError("Data or mapping not available, unable to generate component concentrations.")
846
+
847
+ @compConcs.setter
848
+ def compConcs(self,v):
849
+ self._compConcs = v
850
+
851
+ def genSpecConcs(self,data=None,colToSpec=None):
852
+ if (data is None and self.rawData is None) or (self.colToSpec is None and colToSpec is None):
853
+ raise ValueError("No data and/or column-to-species mapping stored. Doing nothing")
854
+ else:
855
+ if data is not None:
856
+ self.rawData = data
857
+ if colToSpec is not None:
858
+ self.colToSpec = colToSpec
859
+
860
+ self.specConcs=np.dot(self.rawData,self.colToSpec.T)
861
+
862
+ def setColumnToSpeciesMapping(self,colToSpec):
863
+ self.colToSpec=colToSpec
864
+
865
+
866
+ def prepModel(self):
867
+ for paramName in self.compNames:
868
+ self._addParam('log'+paramName,init=0,vary=False)
869
+
870
+ for paramName in self.plist[self.nComp:]:
871
+ self._addParam('log'+paramName)
872
+
873
+ # Register UV-vis / fluorescence parameters from specToLinear (object array).
874
+ # Done before the analytical-mode branch so params are available in both paths.
875
+ if self.specToLinear is not None:
876
+ for _, x in np.ndenumerate(self.specToLinear):
877
+ if isinstance(x, lmfit.Parameter):
878
+ self._addExistingParam(x)
879
+
880
+ if self.analytical_fast_exchange:
881
+ n_complex = len(self.analytical_complex_indices)
882
+ if n_complex == 0:
883
+ n_complex = 1
884
+ self.analytical_obs_param_map = []
885
+ for col_name in self.analytical_obs_columns:
886
+ token = _analytical_obs_param_token(col_name)
887
+ pname_list = [f"delta0_{token}"]
888
+ self._addParam(pname_list[0], init=0.0, min=-1000, max=1000, vary=True)
889
+ for cidx in range(n_complex):
890
+ pname = f"deltac{cidx+1}_{token}"
891
+ pname_list.append(pname)
892
+ self._addParam(pname, init=0.0, min=-1000, max=1000, vary=True)
893
+ self.analytical_obs_param_map.append(pname_list)
894
+ # specToLinear params already registered above; no further action needed.
895
+ return
896
+
897
+ # add chemical shift fitting params
898
+ if self.specToDd is not None:
899
+ for ii,x in np.ndenumerate(self.specToDd):
900
+ if isinstance(x,tuple):
901
+ self._addParam('shift_{}_{}'.format(ii[0],ii[1]),x[1],min=x[0],max=x[2])
902
+ elif isinstance(x,lmfit.Parameter):
903
+ self._addExistingParam(x)
904
+
905
+
906
+
907
+ def runModel(self,sigma=1,skip_col=1,method='least_squares',ret=False,kwargs={}) -> Optional['bindingModel']:
908
+
909
+ exptData = np.copy(self.rawData)
910
+ spec_to_integ = None
911
+ if isinstance(self.colToSpec, np.ndarray) and self.colToSpec.ndim == 2 and self.colToSpec.size > 0:
912
+ spec_to_integ = self.colToSpec[:, skip_col:]
913
+
914
+
915
+ analytical_mode = bool(self.analytical_fast_exchange)
916
+
917
+ # if chemical shifts mappings not provided, then don't try to fit the chemical shifts
918
+ if analytical_mode:
919
+ exptData = exptData[:,skip_col:]
920
+ elif self.specToDd is None and self.specToLinear is None:
921
+ exptData = exptData[:,skip_col:]
922
+ if spec_to_integ is None:
923
+ raise ValueError("specToInteg mapping is required when specToDd and specToLinear are not provided.")
924
+ exptData = exptData[:,:np.shape(spec_to_integ)[1]]
925
+ else:
926
+ exptData = exptData[:,skip_col:]
927
+
928
+ fcn_opts = {'compConcs': self.compConcs,
929
+ 'eqMat': self.eqMat,
930
+ 'optTarget': 'obs', # or concs
931
+ #'specConcs': None,#specConcs,
932
+ 'exptData': exptData,
933
+ 'specToInteg': spec_to_integ,
934
+ 'specToDd': self.specToDd,
935
+ 'specToLinear': self.specToLinear,
936
+ 'sigma': sigma,
937
+ 'nK': np.shape(self.eqMat)[1],
938
+ 'ret': 'residual',
939
+ 'mcmc': False,
940
+ 'paramNames': list(self.params.keys()),
941
+ 'analytical_fast_exchange': analytical_mode,
942
+ 'analytical_topology': self.analytical_topology,
943
+ 'analytical_obs_columns': list(self.analytical_obs_columns),
944
+ 'analytical_obs_components': list(self.analytical_obs_components),
945
+ 'analytical_complex_indices': list(self.analytical_complex_indices),
946
+ 'analytical_obs_param_map': [list(x) for x in self.analytical_obs_param_map],
947
+ 'analytical_linear_obs_param_map': [list(x) for x in self.analytical_linear_obs_param_map],
948
+ }
949
+
950
+ #save settings for later plots
951
+ self.fcn_opts = fcn_opts
952
+
953
+ # do minimization, save result
954
+ self.mini = lmfit.Minimizer(fitfun,self.params,fcn_args=(fcn_opts,),nan_policy='omit')
955
+ #self.miniResult = self.mini.minimize(method='ampgo')#,verbose=1)
956
+ #TODO: check effect of x_scale
957
+ if method == 'least_squares' or method == 'leastsq':
958
+ # if 'method' not in kwargs:
959
+ # kwargs['method'] = method
960
+ if 'xtol' not in kwargs:
961
+ kwargs['xtol'] = 1e-8
962
+ self.miniResult = self.mini.minimize(method=method,**kwargs)#,x_scale='jac')#,verbose=1)
963
+ else:
964
+ if 'xtol' in kwargs:
965
+ del kwargs['xtol']
966
+ if 'method' not in kwargs:
967
+ # kwargs['method'] = method
968
+ pass
969
+
970
+ self.miniResult = self.mini.minimize(method=method,**kwargs)#,x_scale='jac')#,verbose=1)
971
+ if ret:
972
+ return self
973
+
974
+
975
+ def calcSpeciation(self,params=None):
976
+ """
977
+ Calculate the speciation based on the current model parameters. If the optimisation has been run, it will use the fitted parameters.
978
+
979
+ Parameters:
980
+ - params: parameters for the model (optional)
981
+
982
+ Returns:
983
+ - species concentrations
984
+ """
985
+ if params is None:
986
+ if self.miniResult is None:
987
+ params = self.params
988
+ else:
989
+ params = self.miniResult.params
990
+
991
+ fcn_opts = {'eqMat': self.eqMat,
992
+ 'compConcs': self.compConcs,
993
+ 'optTarget': 'concs',
994
+ 'ret': 'concs',
995
+ 'nK': np.shape(self.eqMat)[1],
996
+ 'paramNames': list(params.keys()),
997
+ 'analytical_fast_exchange': bool(self.analytical_fast_exchange),
998
+ 'analytical_topology': self.analytical_topology,
999
+ 'analytical_obs_columns': list(self.analytical_obs_columns),
1000
+ 'analytical_obs_components': list(self.analytical_obs_components),
1001
+ 'analytical_complex_indices': list(self.analytical_complex_indices),
1002
+ 'analytical_obs_param_map': [list(x) for x in self.analytical_obs_param_map],
1003
+ }
1004
+
1005
+ return np.array(fitfun(params, fcn_opts))
1006
+
1007
+
1008
+ def plotSpeciation(self,params=None,xaxisidx=None,xaxisvals=None,specToPlot=None,figname=None):
1009
+ """
1010
+ Plot the speciation based on the current model parameters. If the optimisation has been run, it will use the fitted parameters.
1011
+
1012
+ Parameters:
1013
+ - params: parameters for the model (optional)
1014
+ - xaxisidx: which index of compConcs to use for the x-axis (default is to plot the ratio of the second/first columns (i.e. typically G/H))
1015
+ - xaxisvals: values for the x-axis (optional, if not provided, will use the ratio mentioned above). xaxisvals takes precedence over xaxisidx.
1016
+ - specToPlot: list of species to plot (optional, if not provided, will plot all species)
1017
+ - figname: name of the figure to save (optional, if not provided, will not save the figure)
1018
+ """
1019
+ # If no parameters are provided, use fitted or initial parameters
1020
+ if params is None:
1021
+ if self.miniResult is None:
1022
+ params = self.params
1023
+ else:
1024
+ params = self.miniResult.params
1025
+ xx = []
1026
+ # Determine x-axis values for plotting
1027
+ if xaxisidx is None and xaxisvals is None:
1028
+ # Default: plot ratio of second to first component concentration
1029
+ xx = self.compConcs[:, 1] / self.compConcs[:, 0]
1030
+ elif xaxisidx is not None and xaxisvals is None:
1031
+ # Use specified component concentration as x-axis
1032
+ xx = self.compConcs[:, xaxisidx]
1033
+ elif xaxisvals is not None:
1034
+ # Use provided x-axis values
1035
+ xx = xaxisvals
1036
+ # Calculate species concentrations using current/fitted parameters
1037
+ specConcs = self.calcSpeciation(params)
1038
+ s = 5 # marker size for scatter plot
1039
+ plt.figure(figsize=(7, 5))
1040
+ # Loop over species to plot
1041
+ for i, species in enumerate(specToPlot if specToPlot is not None else self.plist):
1042
+ if specToPlot is not None:
1043
+ # Only plot species specified in specToPlot
1044
+ if species in self.plist:
1045
+ plt.scatter(xx, specConcs[:, self.plist.index(species)], label=f'[{species}]$_\\text{{free}}$', s=s)
1046
+ else:
1047
+ print(f"Species '{species}' not found in the model. Skipping.")
1048
+ else:
1049
+ # Plot all species
1050
+ plt.scatter(xx, specConcs[:, i], label=f'[{self.plist[i]}]$_\\text{{free}}$', s=s)
1051
+
1052
+ # Set x-axis label depending on what is plotted
1053
+ if xaxisidx is None and xaxisvals is None:
1054
+ plt.xlabel('Component Concentration Ratio ([G]/[H])$_\\text{tot}$')
1055
+ else:
1056
+ plt.xlabel('Component Concentration (M)' if xaxisidx is None else f'[{self.compNames[xaxisidx]}]$_\\text{{tot}}$ (M)')
1057
+ plt.ylabel('Free species Concentration (M)')
1058
+ plt.legend()
1059
+ plt.title('Speciation')
1060
+ plt.show()
1061
+ # Save figure if filename is provided
1062
+ if figname is not None:
1063
+ plt.savefig(figname + '.pdf', bbox_inches="tight")
1064
+ plt.savefig(figname + '.png', dpi=1200, bbox_inches="tight")
1065
+
1066
+ return plt.gcf()
1067
+
1068
+
1069
+ def simulateModel(model,compConcs=None,params=None):
1070
+ """
1071
+ Simulate the model with given component concentrations and parameters.
1072
+
1073
+ Parameters:
1074
+ - model: bindingModel instance
1075
+ - compConcs: component concentrations (optional)
1076
+ - params: parameters for the model (optional)
1077
+
1078
+ Returns:
1079
+ - simulated concentrations of species
1080
+ """
1081
+ if compConcs is None:
1082
+ compConcs = model.compConcs
1083
+ if params is None:
1084
+ params = model.miniResult.params
1085
+
1086
+ fcn_opts = model.fcn_opts.copy()
1087
+ fcn_opts['optTarget'] = 'obs'
1088
+ fcn_opts['ret'] = 'concs'
1089
+ fcn_opts['compConcs'] = compConcs
1090
+
1091
+ return np.array(fitfun(params, fcn_opts))
1092
+
1093
+ def getCalcData(model,newConcs=None):
1094
+ # Calculate calcData using fitfun
1095
+ fcn_opts = model.fcn_opts.copy()
1096
+ fcn_opts['optTarget'] = 'obs'
1097
+ fcn_opts['ret'] = 'concs'
1098
+ if newConcs is not None:
1099
+ fcn_opts['compConcs'] = newConcs
1100
+ calcData = np.array(fitfun(model.miniResult.params, fcn_opts))
1101
+ return calcData
1102
+
1103
+ def makeFitResidPlot(model,plotMask=None,skip_start=0,skip_end=None,figname=None,xindex=1,xvals=None,xlabel=None,ylabel='Conc. (M)',labels=None):
1104
+ calcData = getCalcData(model)
1105
+ compConcs = model.compConcs.copy()
1106
+ exptData = model.fcn_opts['exptData'].copy()
1107
+ if labels is None:
1108
+ labels = model.obsList
1109
+ # Optionally skip start and end datapoints
1110
+ if skip_end is None:
1111
+ compConcs = compConcs[skip_start:,:]
1112
+ exptData = exptData[skip_start:,:]
1113
+ calcData = calcData[skip_start:,:]
1114
+ else:
1115
+ compConcs = compConcs[skip_start:-skip_end,:]
1116
+ exptData = exptData[skip_start:-skip_end,:]
1117
+ calcData = calcData[skip_start:-skip_end,:]
1118
+
1119
+ if plotMask is not None:
1120
+
1121
+ calcData= calcData[:,plotMask]
1122
+ exptData = exptData[:,plotMask]
1123
+
1124
+ if xvals is None:
1125
+ xvals = compConcs[:,xindex]
1126
+
1127
+ if xlabel is None:
1128
+ xlabel='[Guest]$_{tot}$ (M)'
1129
+
1130
+ plt.gcf().clear()
1131
+ sf=1 # fig scale factor from single col
1132
+ ms=10 #marker size
1133
+ fig=plt.subplots(1,2,figsize=(sf*150/22.5,sf*70/22.5))
1134
+ #plt.figure(figsize=(sf*85/22.5,sf*70/22.5))
1135
+ # plt.scatter(compConcs[:,2],(calcSpec[:,3]-specConcs[:,0])/specConcs[:,0])
1136
+ # plt.scatter(compConcs[:,2],(calcSpec[:,4]-specConcs[:,1])/specConcs[:,1])
1137
+ # plt.scatter(compConcs[:,2],(calcSpec[:,5]-specConcs[:,2])/specConcs[:,2])
1138
+
1139
+ colours = ['r','k','b']
1140
+ points = ['^','s','o']
1141
+ plt.subplot(122)
1142
+ for ii in range(0,len(exptData[0])):
1143
+ plt.scatter(xvals,calcData[:,ii]-exptData[:,ii],label=labels[ii],s=ms)
1144
+ plt.legend()
1145
+
1146
+ # plt.scatter(10e3*compConcs[:,2],10e6*(specConcs[:,0]-specCalc[:,0]),marker=points[0],c=colours[0],label="ZnNc${\cdot}$pyridine",s=ms)
1147
+ # plt.scatter(10e3*compConcs[:,2],10e6*(specConcs[:,1]-specCalc[:,1]),marker=points[1],c=colours[1],label="ZnNc${\cdot}$DABCO",s=ms)
1148
+ # plt.scatter(10e3*compConcs[:,2],10e6*(specConcs[:,2]-specCalc[:,2]),marker=points[2],c=colours[2],label="ZnNc$_{2}{\cdot}$DABCO",s=ms)
1149
+
1150
+
1151
+ plt.legend()
1152
+ plt.xlabel(xlabel)
1153
+
1154
+ plt.ylabel("residuals")
1155
+ # if figname is not None:
1156
+ # plt.savefig(figname+'-residuals.pdf', bbox_inches="tight")
1157
+ # plt.savefig(figname+'-residuals.png',dpi=1200, bbox_inches="tight")
1158
+
1159
+ # plt.show()
1160
+
1161
+ # plt.figure(figsize=(sf*85/22.5,sf*70/22.5))
1162
+ plt.subplot(121)
1163
+ for ii in range(0,len(exptData[0])):
1164
+ plt.scatter(xvals,exptData[:,ii],s=ms)
1165
+ plt.plot(xvals,calcData[:,ii],label=labels[ii])
1166
+ #plt.xlim(0,0.07)
1167
+ #plt.legend()
1168
+ plt.xlabel(xlabel)
1169
+ plt.ylabel(ylabel)
1170
+
1171
+
1172
+ plt.tight_layout()
1173
+ if figname is not None:
1174
+ plt.savefig(figname+'.pdf', bbox_inches="tight")
1175
+ plt.savefig(figname+'.png',dpi=1200, bbox_inches="tight")
1176
+ plt.show()
1177
+
1178
+
1179
+ def saveFitCSV(m,mask,filename,xindex=1,xvals=None,xlabel='[G]_tot (M)',labels=None):
1180
+ calcData = getCalcData(m)
1181
+ compConcs = m.compConcs.copy()
1182
+ exptData = m.fcn_opts['exptData'].copy()
1183
+
1184
+ if labels is None:
1185
+ labels = [m.obsList[ii] for ii in mask]
1186
+
1187
+
1188
+ if mask is not None:
1189
+ calcData= calcData[:,mask]
1190
+ exptData = exptData[:,mask]
1191
+
1192
+ if xvals is None:
1193
+ xvals = compConcs[:,xindex]
1194
+
1195
+
1196
+
1197
+ ydata = np.concatenate((exptData,calcData),axis=1)
1198
+ data = np.concatenate((xvals[:,np.newaxis],ydata),axis=1)
1199
+
1200
+ cols = [xlabel,*['expt '+x for x in labels],*['calc '+x for x in labels]]
1201
+ print(cols)
1202
+ dataExport = pd.DataFrame(data,columns=cols)
1203
+
1204
+
1205
+ dataExport.to_csv(filename,index=False)
1206
+
1207
+ @jit(nopython=True,nogil=True,cache=True)
1208
+ def calclnprob(r,lnsigma):
1209
+ r= (-0.5*np.sum(
1210
+ np.divide(r, np.exp(lnsigma))**2
1211
+ + np.log(2*np.pi) + 2*lnsigma))
1212
+
1213
+ if math.isnan(r):
1214
+ print('Probability function returned NaN, fudging to -inf.')
1215
+ return -np.inf
1216
+ return r
1217
+
1218
+
1219
+
1220
+ def log_prob(params,fcn_opts,bounds):
1221
+ #def log_prob(params):
1222
+ lp = log_prior(params,bounds)
1223
+
1224
+ if not np.isfinite(lp):
1225
+ return -np.inf
1226
+
1227
+ mapping = fcn_opts['sigmaMapping']
1228
+ lnsigmaVals = params[-(max(mapping)+1):]
1229
+ # make lnsigma vector using simparams
1230
+ lnsigma = np.array([lnsigmaVals[ii] for ii in mapping])
1231
+
1232
+ pp = np.zeros(len(params)+ np.shape(fcn_opts['compConcs'])[1]) # need extra parameters - for logK=0 - for each component in the eqMat
1233
+
1234
+
1235
+ pp[-len(params):] = params
1236
+
1237
+ fcn_opts['ret']='residual'
1238
+ r = np.array(fitfun(pp, fcn_opts))
1239
+ if np.isnan(r).any():
1240
+ return -np.inf
1241
+
1242
+ #r = -0.5 * ((np.sum(r**2) / np.exp(lnsigma) ) + np.log(2*np.pi) + 2*lnsigma)
1243
+
1244
+ rout = calclnprob(r,lnsigma)
1245
+
1246
+
1247
+
1248
+ # r = r/np.exp(params[-1])
1249
+ return rout
1250
+
1251
+ @jit(nopython=True,nogil=True,cache=True)
1252
+ def log_prior(val,bounds):
1253
+ # if val[-1]<-20 or val[-1] > -2:
1254
+ # return -np.inf
1255
+ for ii in range(len(val)):
1256
+ if not ( bounds[ii][0] < val[ii] < bounds[ii][1] ):
1257
+ return -np.inf
1258
+ return 0.0
1259
+
1260
+ class ObsType():
1261
+ def __init__(self,name,units=None,value=None,minlim=None,maxlim=None):
1262
+ self.name = name
1263
+
1264
+ # TODO use pint for units
1265
+
1266
+
1267
+
1268
+ if name == 'NMRInteg':
1269
+ if units is None:
1270
+ self.units = 'M'
1271
+ else:
1272
+ self.units = units
1273
+
1274
+ self.param = lmfit.Parameter('lnsigmaNMRInteg',
1275
+ value=-8,vary=False,
1276
+ min=-11,max=-5)
1277
+
1278
+ elif name == 'concMeas':
1279
+ if units is None:
1280
+ self.units = 'M'
1281
+ else:
1282
+ self.units = units
1283
+
1284
+ self.param = lmfit.Parameter('lnsigmaconcMeas',
1285
+ value=-8,vary=False,
1286
+ min=-11,max=-5)
1287
+
1288
+ elif name == 'deltaH':
1289
+ self.units = 'ppm'
1290
+
1291
+ self.param = lmfit.Parameter('lnsigmadeltaH',
1292
+ value=-9,vary=False,
1293
+ min=-13,max=-5)
1294
+
1295
+
1296
+ elif name == 'deltaF':
1297
+ self.units = 'ppm'
1298
+
1299
+ self.param = lmfit.Parameter('lnsigmadeltaF',
1300
+ value=-5,vary=False,
1301
+ min=-8,max=-3)
1302
+
1303
+ elif name == 'absorbance' or name =='uvvis':
1304
+ self.units = units if units is not None else 'absorbance'
1305
+ self.param = lmfit.Parameter('lnsigmaUVvis',
1306
+ value=-7, vary=True,
1307
+ min=-11, max=-3)
1308
+
1309
+ elif name == 'fluorescence':
1310
+ self.units = units if units is not None else 'intensity'
1311
+ self.param = lmfit.Parameter('lnsigmaFluorescence',
1312
+ value=-4, vary=True,
1313
+ min=-8, max=0)
1314
+
1315
+ else:
1316
+ self.units = units
1317
+ self.param = lmfit.Parameter('lnsigma'+name)
1318
+
1319
+
1320
+ if value is not None:
1321
+ self.param.value = value
1322
+
1323
+ if minlim is not None:
1324
+ self.param.min = minlim
1325
+
1326
+ if maxlim is not None:
1327
+ self.param.max = maxlim
1328
+
1329
+ self.lnsigma = self.param.value
1330
+ self.sigma = np.exp(self.lnsigma)
1331
+ self.minlim = self.param.min
1332
+ self.maxlim = self.param.max
1333
+
1334
+ if self.lnsigma > self.maxlim or self.lnsigma < self.minlim:
1335
+ print("lnsigma value out of bounds. Setting to midpoint between limits.")
1336
+ self.lnsigma = (self.maxlim+self.minlim)/2
1337
+ self.param.value = self.lnsigma
1338
+ print("New starting value: ",self.lnsigma)
1339
+
1340
+
1341
+ # convert a list of colNames (string) to a list of indices corresponding to
1342
+ # unique sigmas (ints)
1343
+ def sigmaMapping(colNames):
1344
+ sc=list(dict.fromkeys(colNames)) # get ordered unique list members
1345
+ ix = []
1346
+ for nn in colNames:
1347
+ ix.append(sc.index(nn))
1348
+ return ix
1349
+
1350
+
1351
+ class MCMC:
1352
+ def __init__(self, model: bindingModel, obs: List[ObsType], walkers: int =25, samples: int =5000, variance: float=0.2) -> None:
1353
+ self.model = model
1354
+ self.obs = obs
1355
+ self.walkers = walkers
1356
+ self.samples = samples
1357
+ self.variance = variance
1358
+ self.sampler = None
1359
+ self._labels = None
1360
+
1361
+ @property
1362
+ def labels(self):
1363
+ if self._labels is not None:
1364
+ return self._labels
1365
+ else:
1366
+ self._labels = self.make_labels()
1367
+ return self._labels
1368
+
1369
+ def make_labels(self):
1370
+ params = [self.model.params[pp].name for pp in self.model.params if self.model.params[pp].vary]
1371
+ ss = [pp.name for pp in self.obs]
1372
+ ss = list(dict.fromkeys(ss))
1373
+ ss = ['lnsigma' + x for x in ss]
1374
+ params += ss
1375
+ return params
1376
+
1377
+
1378
+ def run(self,ret=False,thin=1,samples=None,pool=None,tqdm_kwargs=None):
1379
+ bm = self.model
1380
+ bm.colTypes = self.obs
1381
+
1382
+ fcn_opts = bm.fcn_opts or {}
1383
+ bm.fcn_opts = fcn_opts
1384
+
1385
+ fcn_opts['sigma'] = 1
1386
+ fcn_opts['ret'] = 'residual'
1387
+
1388
+ colNames = []
1389
+
1390
+ sigmaParams = lmfit.Parameters()
1391
+ for pp in bm.colTypes:
1392
+ sigmaParams[pp.name] = pp.param
1393
+ colNames.append(pp.name)
1394
+
1395
+ fcn_opts['sigmaMapping'] = sigmaMapping(colNames)
1396
+ fcn_opts['mcmc'] = True
1397
+
1398
+ bounds = []
1399
+ optResult = []
1400
+ for pp in bm.miniResult.params.keys():
1401
+ if bm.miniResult.params[pp].vary:
1402
+ bounds.append([bm.params[pp].min, bm.params[pp].max])
1403
+ optResult.append(bm.miniResult.params[pp].value)
1404
+
1405
+ for pp in sigmaParams.keys():
1406
+ bounds.append([sigmaParams[pp].min, sigmaParams[pp].max])
1407
+ optResult.append(sigmaParams[pp].value)
1408
+
1409
+ override_bounds = fcn_opts.get("mcmc_bounds")
1410
+ if override_bounds is not None:
1411
+ bounds = np.array(override_bounds, dtype=float)
1412
+ else:
1413
+ bounds = np.array(bounds)
1414
+ ndim = len(bounds)
1415
+ p0 = [np.array(optResult) * (self.variance * np.random.randn(ndim) / 100 + 1) for _ in range(self.walkers)]
1416
+
1417
+ for i in range(self.walkers):
1418
+ if (p0[i] < bounds[:, 0]).any() or (p0[i] > bounds[:, 1]).any():
1419
+ logger.info("Walker initialized out of bounds; set to bound.")
1420
+ p0[i] = np.clip(p0[i], bounds[:, 0] + 1e-7, bounds[:, 1] - 1e-7)
1421
+ if samples is None:
1422
+ samples = self.samples
1423
+
1424
+ if self.sampler is not None:
1425
+ # run from previous state
1426
+ p0 = None
1427
+ self.samples += samples
1428
+ else:
1429
+ # if new samples number is being given in this function,
1430
+ # overwrite the object's samples record
1431
+ self.samples = samples
1432
+
1433
+ if os.name == 'posix' or pool is not None:
1434
+ # running on linux
1435
+ logger.debug("Running on linux. Trying to use pool/multiprocessing.")
1436
+ p = Pool() if pool is None else nullcontext(pool)
1437
+ with p as active_pool:
1438
+ if self.sampler is None:
1439
+ self.sampler = emcee.EnsembleSampler(self.walkers, ndim, log_prob, args=[bm.fcn_opts, bounds], pool=active_pool)
1440
+ else:
1441
+ self.sampler.pool = active_pool
1442
+ self.sampler.run_mcmc(p0, samples, progress=True,progress_kwargs=tqdm_kwargs)
1443
+ else:
1444
+ if self.sampler is None:
1445
+ self.sampler = emcee.EnsembleSampler(self.walkers, ndim, log_prob, args=[bm.fcn_opts, bounds])
1446
+
1447
+ self.sampler.run_mcmc(p0, samples, progress=True,thin_by=thin)#,skip_initial_state_check=True)
1448
+
1449
+ if ret is True:
1450
+ return self.sampler, bm
1451
+
1452
+ def plot_chain(self,title=None,fig=None):
1453
+ ndim = self.sampler.ndim
1454
+ if fig is None:
1455
+ fig, axes = plt.subplots(ndim, figsize=(10, 7), sharex=True)
1456
+ else:
1457
+ axes = fig.subplots(nrows=ndim,ncols=1,sharex=True)
1458
+ samples = self.sampler.get_chain()
1459
+ for i in range(ndim):
1460
+ ax = axes[i]
1461
+ ax.plot(samples[:, :, i], "k", alpha=0.3)
1462
+ ax.set_xlim(0, len(samples))
1463
+ ax.set_ylabel(self.labels[i])
1464
+ ax.yaxis.set_label_coords(-0.1, 0.5)
1465
+ axes[-1].set_xlabel("step number")
1466
+ if title is not None:
1467
+ fig.suptitle(title)
1468
+
1469
+ def plot_corner(self,title=None,burnin=None,corner_kwargs={},fig=None):
1470
+ try:
1471
+ tau = self.sampler.get_autocorr_time()
1472
+ except emcee.autocorr.AutocorrError as e:
1473
+ m = "Warning: Autocorrelation time is likely too short. Check the chain."
1474
+ print(m)
1475
+ logger.warning(m)
1476
+ tau = e.tau
1477
+ if burnin is None:
1478
+ burnin = int(5 * np.max(tau))
1479
+ logger.info("Burnin set to ",burnin)
1480
+
1481
+ f= self.make_corner_fig(title=title,burnin=burnin,corner_kwargs=corner_kwargs,fig=fig)
1482
+ return f
1483
+
1484
+ def make_corner_fig(self,title=None,burnin=None,corner_kwargs={},fig=None):
1485
+ samples = self.sampler.get_chain(discard=burnin, flat=True)
1486
+ if fig is None:
1487
+ fig=plt.figure()
1488
+ if title is not None:
1489
+ fig.suptitle(title)
1490
+ fig=corner.corner(samples, labels=self.labels, show_titles=True,fig=fig,**corner_kwargs)
1491
+ return fig
1492
+ else:
1493
+ fig=corner.corner(samples, labels=self.labels, show_titles=True,fig=fig,**corner_kwargs)
1494
+ return fig
1495
+
1496
+
1497
+
1498
+
1499
+ def get_tau(self):
1500
+ try:
1501
+ tau = self.sampler.get_autocorr_time()
1502
+ print("{:<20s} {:<10s}".format("Param","Tau (steps)"))
1503
+ print('\n'.join(["{:<20s} {:<10f}".format(*x) for x in zip(self.labels,tau)]))
1504
+
1505
+ except emcee.autocorr.AutocorrError as e:
1506
+ #print("Warning: Autocorrelation time is likely too short. Check the chain.")
1507
+ print("{:<20s} {:<10s}".format("Param","Tau (steps)"))
1508
+ print('\n'.join(["{:<20s} {:<10f}".format(*x) for x in zip(self.labels,e.tau)]))
1509
+ print("Nsteps this run = ",self.samples)
1510
+ print("Ideal nsteps >= ",int(50*np.max(e.tau)))
1511
+
1512
+ def save(self,fname=None):
1513
+ # inspired by the emcee hdf backend
1514
+ # could add a thin function here but best to apply in run() above
1515
+ if fname is None:
1516
+ if self.model.comment is not None:
1517
+ fname = self.model.comment +datetime.datetime.now().strftime("%Y-%m-%d-%H%M%S")+".hdf"
1518
+ print("Saving model to {}".format(fname))
1519
+ else:
1520
+ print("Please provide a filename: mcmc.save(fname='filename.hd5')")
1521
+
1522
+ with h5py.File(fname,'w') as f:
1523
+ g = f.create_group('mcmc')
1524
+ g.create_dataset('chain',data=self.sampler.backend.chain)
1525
+ g.create_dataset('accepted',data=self.sampler.backend.accepted)
1526
+ g.create_dataset('log_prob',data=self.sampler.backend.log_prob)
1527
+
1528
+ if self.sampler.backend.blobs is not None:
1529
+ g.create_dataset('blobs',data=self.sampler.backend.blobs)
1530
+ g.attrs['has_blobs'] = True
1531
+ else:
1532
+ g.attrs['has_blobs'] = False
1533
+ g.attrs['iteration'] = self.sampler.backend.iteration
1534
+
1535
+
1536
+ def load(self,fname):
1537
+ if self.sampler is None:
1538
+ print("Cannot load file, sampler does not exist")
1539
+ print("Run a short chain first, then load the data")
1540
+ # TODO auto-generate sampler based on file?
1541
+
1542
+ with h5py.File(fname,"r") as f:
1543
+ g = f['mcmc']
1544
+ self.sampler.backend.chain = g['chain'][:]
1545
+ self.sampler.backend.accepted = g['accepted'][:]
1546
+ if g.attrs['has_blobs'] is True:
1547
+ self.sampler.backend.blobs = g['blobs'][:]
1548
+ self.sampler.backend.iteration = g.attrs['iteration']
1549
+ self.sampler.backend.log_prob = g['log_prob'][:]
1550
+ # sampler.backend.random_state = g['random_state'][:]
1551
+ self.sampler.backend.initialized=True
1552
+ self.sampler._previous_state = self.sampler.get_last_sample()
1553
+
1554
+
1555
+
1556
+
1557
+
1558
+
1559
+ def doMCMC(model,obs,samples=1000,variance=0.1,walkers=10):
1560
+ print("Warning: doMCMC() is deprecated. Use the MCMC class and MCMC.run() in future.")
1561
+ mcmcModel = MCMC(model,obs,walkers=walkers,samples=samples,variance=variance)
1562
+ sampler,bm = mcmcModel.run(ret=True)
1563
+ return sampler,bm
1564
+
1565
+
1566
+ def plotMCMC(sampler,labels):
1567
+ print("Warning: plotMCMC() is deprecated. Use the MCMC class and MCMC.plot_chains() in future.")
1568
+
1569
+ ndim = sampler.ndim
1570
+ fig, axes = plt.subplots(ndim, figsize=(10, 7), sharex=True)
1571
+ samples = sampler.get_chain()
1572
+ #labels = ["k1","k2","lnsigma"] # TODO programmatically generate
1573
+ for i in range(ndim):
1574
+ ax = axes[i]
1575
+ ax.plot(samples[:, :, i], "k", alpha=0.3)
1576
+ ax.set_xlim(0, len(samples))
1577
+ ax.set_ylabel(labels[i])
1578
+ ax.yaxis.set_label_coords(-0.1, 0.5)
1579
+
1580
+ axes[-1].set_xlabel("step number")
1581
+ plt.show()
1582
+
1583
+ def plotCorner(sampler,labels):
1584
+ print("Warning: plotCorner() is deprecated. Use the MCMC class and MCMC.plot_corner() in future.")
1585
+
1586
+ try:
1587
+ tau = sampler.get_autocorr_time()
1588
+ except emcee.autocorr.AutocorrError as e:
1589
+ print("Warning: Autocorrelation time is likely too short. Check the chain.")
1590
+ tau=e.tau
1591
+
1592
+ burnin = int(2*np.max(tau))
1593
+ samples = sampler.get_chain(discard=burnin, flat=True)
1594
+
1595
+ corner.corner(samples,labels=labels,show_titles=True)
1596
+ plt.show()
1597
+
1598
+ def getTau(sampler):
1599
+ print("Warning: getTau is deprecated. Use the MCMC class and MCMC.get_tau() in future.")
1600
+
1601
+ try:
1602
+ tau = sampler.get_autocorr_time()
1603
+ print(tau)
1604
+ except emcee.autocorr.AutocorrError as e:
1605
+ print("Warning: Autocorrelation time is likely too short. Check the chain.")
1606
+ print(e.tau)
1607
+
1608
+ # TODO make this function into a method of bindingModel
1609
+ def makeMCMCLabels(model,obs):
1610
+ print("Warning: makeMCMCLabels is deprecated. Use the MCMC class and MCMC.labels in future.")
1611
+
1612
+ params = []
1613
+ for pp in model.params:
1614
+ if (model.params[pp].vary):
1615
+ params.append(model.params[pp].name)
1616
+
1617
+
1618
+
1619
+ ss = []
1620
+ for pp in obs:
1621
+ ss.append(pp.name) # todo - make sure everything (not just name) is the same, if not throw an error
1622
+
1623
+ ss = list(set(ss))
1624
+ ss = ['lnsigma'+x for x in ss]
1625
+ params += ss
1626
+
1627
+ return(params)
1628
+
1629
+ def mcmchelper(model,obsList=None,obs_types=None,walkers=25,samples=10000,variance=0.1,thin=500,figName='out.jpg'):
1630
+ """
1631
+ Helper function to run MCMC on a binding model and plot results.
1632
+
1633
+ Parameters:
1634
+ - model: The binding model to run MCMC on.
1635
+ - obsList: List of observable types.
1636
+ - walkers: Number of walkers for MCMC.
1637
+ - samples: Number of samples to draw.
1638
+ - variance: Variance for the MCMC sampling.
1639
+ - thin: Thinning factor for the samples.
1640
+ - figName: Name of the output figure file.
1641
+ """
1642
+ if obs_types is None:
1643
+ if obsList is None:
1644
+ raise ValueError("Either obsList or obs_types must be provided.")
1645
+ # If obs_types is not provided, create it based on obsList
1646
+ obs_types = [ObsType('deltaH')] * len(obsList)
1647
+
1648
+ # Create and run the MCMC sampler
1649
+ mcmc = MCMC(model, obs_types, walkers=walkers, samples=samples, variance=variance)
1650
+ mcmc.run(thin=thin)
1651
+
1652
+ # Plot results
1653
+ mcmc.plot_chain()
1654
+ mcmc.get_tau()
1655
+ ff = mcmc.plot_corner()
1656
+ ff.savefig(figName, dpi=150)
1657
+ return mcmc
1658
+ #sampler,bm=doMCMC(c8a,obsc8a)