bindtools 0.1.2__tar.gz → 0.2.1__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: bindtools
3
- Version: 0.1.2
3
+ Version: 0.2.1
4
4
  Keywords: chemistry,analytical chemistry,binding constants,supramolecular
5
5
  Author: Martin Peeks
6
6
  Author-email: Martin Peeks <martinp23@googlemail.com>, m.peeks@unsw.edu.au
@@ -14,12 +14,12 @@ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
14
14
  Requires-Dist: arviz==0.21.0
15
15
  Requires-Dist: corner==2.2.3
16
16
  Requires-Dist: emcee==3.1.6
17
- Requires-Dist: h5py==3.13.0
17
+ Requires-Dist: h5py>=3.14.0
18
18
  Requires-Dist: lmfit==1.3.3
19
19
  Requires-Dist: matplotlib==3.10.7
20
20
  Requires-Dist: numba>=0.65.1
21
21
  Requires-Dist: numpy>=2.3.4
22
- Requires-Dist: openpyxl==3.1.2
22
+ Requires-Dist: openpyxl>=3.1.5
23
23
  Requires-Dist: pandas>=2.3.3
24
24
  Requires-Dist: scipy==1.16.3
25
25
  Requires-Dist: tqdm==4.66.3
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "bindtools"
7
- version = "0.1.2"
7
+ version = "0.2.1"
8
8
  readme = "README.md"
9
9
  classifiers = [
10
10
  # How mature is this project? Common values are
@@ -30,12 +30,12 @@ dependencies = [
30
30
  "arviz==0.21.0",
31
31
  "corner==2.2.3",
32
32
  "emcee==3.1.6",
33
- "h5py==3.13.0",
33
+ "h5py>=3.14.0",
34
34
  "lmfit==1.3.3",
35
35
  "matplotlib==3.10.7",
36
36
  "numba>=0.65.1",
37
37
  "numpy>=2.3.4",
38
- "openpyxl==3.1.2",
38
+ "openpyxl>=3.1.5",
39
39
  "pandas>=2.3.3",
40
40
  "scipy==1.16.3",
41
41
  "tqdm==4.66.3",
@@ -97,9 +97,9 @@ def getConcsScipy(eqMat, initComponentConc, logK, alg="L-BFGS-B"):
97
97
  eqMat = eqMat.astype("float64")
98
98
  K = 10.0**logK
99
99
  guessCompConc = np.zeros(len(initComponentConc)) + np.mean(initComponentConc)
100
- guessLogCompConc = np.log(guessCompConc)
100
+ guessLogCompConc = np.log10(guessCompConc)
101
101
 
102
- bds = [(None, np.log(np.max(initComponentConc))) for _ in range(len(guessLogCompConc))]
102
+ bds = [(None, np.log10(np.max(initComponentConc))) for _ in range(len(guessLogCompConc))]
103
103
  # jac=True says that specObj returns float,arr(n) where arr(n) is the Jacobian
104
104
  res = sp.optimize.minimize(
105
105
  specObj,
@@ -112,7 +112,7 @@ def getConcsScipy(eqMat, initComponentConc, logK, alg="L-BFGS-B"):
112
112
  options={"gtol": 1e-20},
113
113
  )
114
114
 
115
- compTotCalc, specConc = specCalc(np.exp(res.x), len(K), eqMat, K)
115
+ compTotCalc, specConc = specCalc(10**(res.x), len(K), eqMat, K)
116
116
  return specConc
117
117
 
118
118
 
@@ -150,7 +150,7 @@ def _specJac(ncomp, eqMat, speciesConc): # this function calculates the Jacobia
150
150
 
151
151
 
152
152
  def specObj(logConc, initComponentConc, eqMat, K):
153
- conc = np.exp(logConc)
153
+ conc = 10**(logConc)
154
154
  nspecies = len(K)
155
155
  compTotCalc, specConc = specCalc(conc, nspecies, eqMat, K)
156
156
 
@@ -228,107 +228,76 @@ def _analytical_obs_param_token(col_name: str) -> str:
228
228
  return token
229
229
 
230
230
 
231
- def _best_real_root(coeffs: np.ndarray, lower: float, upper: float, score_fn) -> float:
232
- nz = np.where(np.abs(coeffs) > 1e-18)[0]
233
- if len(nz) == 0:
234
- return float(lower)
235
-
236
- trimmed = coeffs[nz[0] :]
237
- roots = np.roots(trimmed)
238
- real_roots = [float(r.real) for r in roots if abs(r.imag) < 1e-10]
239
- if not real_roots:
240
- return float(lower)
241
-
242
- bounded = [r for r in real_roots if lower - 1e-12 <= r <= upper + 1e-12]
243
- candidates = bounded if bounded else real_roots
244
- best = min(candidates, key=score_fn)
245
- return float(np.clip(best, lower, upper))
246
-
247
-
248
- def _solve_row_11(h_tot: float, g_tot: float, beta11: float) -> tuple[float, float, float]:
249
- if beta11 <= 0:
250
- raise ValueError("Binding constant (in natural units) cannot be negative")
251
- if h_tot < 0 or g_tot < 0:
252
- raise ValueError("Total concentrations must be non-negative")
253
- if h_tot == 0 or g_tot == 0:
254
- return (
255
- max(h_tot, 0.0),
256
- max(g_tot, 0.0),
257
- 0.0,
258
- ) # if either total concentration is zero, then there can be no complex
259
-
260
- first_term = h_tot + g_tot + 1.0 / max(beta11, 1e-300)
261
- hg = 0.5 * (first_term - math.sqrt(max(first_term**2 - 4.0 * h_tot * g_tot, 0.0)))
262
- hg = float(
263
- np.clip(hg, 0.0, min(h_tot, g_tot))
264
- ) # [HG] cannot be negative or greater than either total concentration
265
- return h_tot - hg, g_tot - hg, hg
266
-
267
-
268
- def _solve_row_12(h_tot: float, g_tot: float, beta11: float, beta12: float) -> tuple[float, float, float, float]:
269
- if h_tot <= 0 or g_tot <= 0:
270
- return max(h_tot, 0.0), max(g_tot, 0.0), 0.0, 0.0
271
-
272
- coeffs = np.array(
273
- [
274
- beta12,
275
- beta11 + 2.0 * h_tot * beta12 - g_tot * beta12,
276
- 1.0 + h_tot * beta11 - g_tot * beta11,
277
- -g_tot,
278
- ],
279
- dtype=float,
280
- )
281
231
 
282
- def score_fn(g_free: float) -> float:
283
- d = 1.0 + beta11 * g_free + beta12 * (g_free**2)
284
- if d <= 0:
285
- return np.inf
286
- h_free = h_tot / d
287
- hg = beta11 * h_free * g_free
288
- hg2 = beta12 * h_free * (g_free**2)
289
- r1 = abs(h_tot - (h_free + hg + hg2))
290
- r2 = abs(g_tot - (g_free + hg + 2.0 * hg2))
291
- return r1 + r2
292
-
293
- g_free = _best_real_root(coeffs, 0.0, max(g_tot, 0.0), score_fn)
294
- d = max(1.0 + beta11 * g_free + beta12 * (g_free**2), 1e-300)
295
- h_free = h_tot / d
296
- hg = beta11 * h_free * g_free
297
- hg2 = beta12 * h_free * (g_free**2)
298
- return max(h_free, 0.0), max(g_free, 0.0), max(hg, 0.0), max(hg2, 0.0)
299
-
300
-
301
- def _solve_row_21(h_tot: float, g_tot: float, beta11: float, beta21: float) -> tuple[float, float, float, float]:
302
- if h_tot <= 0 or g_tot <= 0:
303
- return max(h_tot, 0.0), max(g_tot, 0.0), 0.0, 0.0
304
-
305
- coeffs = np.array(
306
- [
307
- beta21,
308
- beta11 + 2.0 * g_tot * beta21 - h_tot * beta21,
309
- 1.0 + g_tot * beta11 - h_tot * beta11,
310
- -h_tot,
311
- ],
312
- dtype=float,
313
- )
232
+ def _solve_cubic_vectorized(a: np.ndarray, b: np.ndarray, c: np.ndarray, d: np.ndarray, lower: np.ndarray, upper: np.ndarray, score_fn_list) -> np.ndarray:
233
+ A = b / a
234
+ B = c / a
235
+ C = d / a
236
+
237
+ p = B - A**2 / 3.0
238
+ q = C - A * B / 3.0 + 2.0 * A**3 / 27.0
239
+
240
+ D = q**2 / 4.0 + p**3 / 27.0
241
+
242
+ N = len(a)
243
+ candidates = np.zeros((3, N), dtype=float)
244
+
245
+ mask_single = D > 0
246
+ mask_three = ~mask_single
247
+
248
+ # Solve single root cases (D > 0)
249
+ if np.any(mask_single):
250
+ D_s = D[mask_single]
251
+ q_s = q[mask_single]
252
+ A_s = A[mask_single]
253
+
254
+ sqrt_D = np.sqrt(D_s)
255
+ val1 = -q_s / 2.0 + sqrt_D
256
+ val2 = -q_s / 2.0 - sqrt_D
257
+
258
+ u = np.sign(val1) * np.abs(val1)**(1.0/3.0)
259
+ v = np.sign(val2) * np.abs(val2)**(1.0/3.0)
260
+
261
+ root_s = u + v - A_s / 3.0
262
+ candidates[0, mask_single] = root_s
263
+ candidates[1, mask_single] = root_s
264
+ candidates[2, mask_single] = root_s
314
265
 
315
- def score_fn(h_free: float) -> float:
316
- d = 1.0 + beta11 * h_free + beta21 * (h_free**2)
317
- if d <= 0:
318
- return np.inf
319
- g_free = g_tot / d
320
- hg = beta11 * h_free * g_free
321
- h2g = beta21 * (h_free**2) * g_free
322
- r1 = abs(h_tot - (h_free + hg + 2.0 * h2g))
323
- r2 = abs(g_tot - (g_free + hg + h2g))
324
- return r1 + r2
325
-
326
- h_free = _best_real_root(coeffs, 0.0, max(h_tot, 0.0), score_fn)
327
- d = max(1.0 + beta11 * h_free + beta21 * (h_free**2), 1e-300)
328
- g_free = g_tot / d
329
- hg = beta11 * h_free * g_free
330
- h2g = beta21 * (h_free**2) * g_free
331
- return max(h_free, 0.0), max(g_free, 0.0), max(hg, 0.0), max(h2g, 0.0)
266
+ # Solve three root cases (D <= 0)
267
+ if np.any(mask_three):
268
+ p_t = p[mask_three]
269
+ q_t = q[mask_three]
270
+ A_t = A[mask_three]
271
+
272
+ valid = -p_t > 1e-12
273
+ phi = np.zeros_like(p_t)
274
+ r = np.zeros_like(p_t)
275
+ if np.any(valid):
276
+ p_v = p_t[valid]
277
+ q_v = q_t[valid]
278
+ cos_phi = (3.0 * q_v) / (2.0 * p_v * np.sqrt(-p_v / 3.0))
279
+ cos_phi = np.clip(cos_phi, -1.0, 1.0)
280
+ phi[valid] = np.arccos(cos_phi)
281
+ r[valid] = 2.0 * np.sqrt(-p_v / 3.0)
282
+
283
+ y0 = r * np.cos(phi / 3.0)
284
+ y1 = r * np.cos(phi / 3.0 + 2.0 * np.pi / 3.0)
285
+ y2 = r * np.cos(phi / 3.0 + 4.0 * np.pi / 3.0)
286
+
287
+ candidates[0, mask_three] = y0 - A_t / 3.0
288
+ candidates[1, mask_three] = y1 - A_t / 3.0
289
+ candidates[2, mask_three] = y2 - A_t / 3.0
290
+
291
+ # Evaluate residuals using the vectorized score function
292
+ residuals = score_fn_list(candidates)
293
+
294
+ # Penalize out of bounds candidates
295
+ out_of_bounds = (candidates < lower - 1e-12) | (candidates > upper + 1e-12)
296
+ residuals[out_of_bounds] += 1e10
297
+
298
+ best_idx = np.argmin(residuals, axis=0)
299
+ best_root = candidates[best_idx, np.arange(N)]
300
+ return np.clip(best_root, lower, upper)
332
301
 
333
302
 
334
303
  def calc_analytical_speciation(
@@ -340,46 +309,128 @@ def calc_analytical_speciation(
340
309
  complex_indices: list[int],
341
310
  ) -> tuple[np.ndarray, bool]:
342
311
 
312
+ if n_comp != 2:
313
+ raise ValueError("Analytical fast-exchange solver requires exactly 2 components.")
314
+
343
315
  n_rows = int(np.shape(comp_concs)[0])
344
316
  n_species = int(np.shape(eq_mat)[1])
345
317
  spec_calc = np.zeros((n_rows, n_species), dtype=float)
346
318
  error = False
347
319
 
348
- if n_comp != 2:
349
- raise ValueError("Analytical fast-exchange solver requires exactly 2 components.")
320
+ h_tot = np.maximum(comp_concs[:, 0], 0.0)
321
+ g_tot = np.maximum(comp_concs[:, 1], 0.0)
322
+ zero_mask = (h_tot <= 0.0) | (g_tot <= 0.0)
350
323
 
351
324
  beta11 = 10.0 ** float(binding_params[complex_indices[0]])
352
- beta2 = None
353
- if topology in ("1:2", "2:1"):
354
- beta2 = 10.0 ** float(binding_params[complex_indices[1]])
355
325
 
356
- for ii, row in enumerate(comp_concs):
357
- h_tot = float(max(row[0], 0.0))
358
- g_tot = float(max(row[1], 0.0))
359
- try:
360
- if topology == "1:1":
361
- h_free, g_free, hg = _solve_row_11(h_tot, g_tot, beta11)
362
- spec_calc[ii, 0] = h_free
363
- spec_calc[ii, 1] = g_free
364
- spec_calc[ii, complex_indices[0]] = hg
365
- elif topology == "1:2":
366
- h_free, g_free, hg, hg2 = _solve_row_12(h_tot, g_tot, beta11, float(beta2))
367
- spec_calc[ii, 0] = h_free
368
- spec_calc[ii, 1] = g_free
369
- spec_calc[ii, complex_indices[0]] = hg
370
- spec_calc[ii, complex_indices[1]] = hg2
371
- elif topology == "2:1":
372
- h_free, g_free, hg, h2g = _solve_row_21(h_tot, g_tot, beta11, float(beta2))
373
- spec_calc[ii, 0] = h_free
374
- spec_calc[ii, 1] = g_free
375
- spec_calc[ii, complex_indices[0]] = hg
376
- spec_calc[ii, complex_indices[1]] = h2g
326
+ try:
327
+ if topology == "1:1":
328
+ if beta11 <= 0:
329
+ spec_calc[:, 0] = h_tot
330
+ spec_calc[:, 1] = g_tot
377
331
  else:
378
- raise ValueError(f"Unsupported analytical topology: {topology}")
379
- except Exception:
380
- error = True
332
+ first_term = h_tot + g_tot + 1.0 / max(beta11, 1e-300)
333
+ sqrt_val = np.sqrt(np.maximum(first_term**2 - 4.0 * h_tot * g_tot, 0.0))
334
+ hg = 0.5 * (first_term - sqrt_val)
335
+ hg = np.clip(hg, 0.0, np.minimum(h_tot, g_tot))
336
+ hg[zero_mask] = 0.0
337
+
338
+ spec_calc[:, 0] = np.maximum(h_tot - hg, 0.0)
339
+ spec_calc[:, 1] = np.maximum(g_tot - hg, 0.0)
340
+ spec_calc[:, complex_indices[0]] = hg
341
+
342
+ elif topology == "1:2":
343
+ beta12 = 10.0 ** float(binding_params[complex_indices[1]])
344
+
345
+ # Setup coefficients for cubic equation a*g^3 + b*g^2 + c*g + d = 0
346
+ a = np.full(n_rows, beta12, dtype=float)
347
+ b = beta11 + 2.0 * h_tot * beta12 - g_tot * beta12
348
+ c = 1.0 + h_tot * beta11 - g_tot * beta11
349
+ d = -g_tot
350
+
351
+ def score_12(g_free_candidates):
352
+ h_t = h_tot[np.newaxis, :]
353
+ g_t = g_tot[np.newaxis, :]
354
+ denom = np.maximum(1.0 + beta11 * g_free_candidates + beta12 * (g_free_candidates**2), 1e-300)
355
+ h_f = h_t / denom
356
+ hg = beta11 * h_f * g_free_candidates
357
+ hg2 = beta12 * h_f * (g_free_candidates**2)
358
+ r1 = np.abs(h_t - (h_f + hg + hg2))
359
+ r2 = np.abs(g_t - (g_free_candidates + hg + 2.0 * hg2))
360
+ return r1 + r2
361
+
362
+ g_free = _solve_cubic_vectorized(a, b, c, d, np.zeros(n_rows), g_tot, score_12)
363
+ denom = np.maximum(1.0 + beta11 * g_free + beta12 * (g_free**2), 1e-300)
364
+ h_free = h_tot / denom
365
+ hg = beta11 * h_free * g_free
366
+ hg2 = beta12 * h_free * (g_free**2)
367
+
368
+ # Apply zero/negative overrides
369
+ h_free = np.maximum(h_free, 0.0)
370
+ g_free = np.maximum(g_free, 0.0)
371
+ hg = np.maximum(hg, 0.0)
372
+ hg2 = np.maximum(hg2, 0.0)
373
+
374
+ h_free[zero_mask] = h_tot[zero_mask]
375
+ g_free[zero_mask] = g_tot[zero_mask]
376
+ hg[zero_mask] = 0.0
377
+ hg2[zero_mask] = 0.0
378
+
379
+ spec_calc[:, 0] = h_free
380
+ spec_calc[:, 1] = g_free
381
+ spec_calc[:, complex_indices[0]] = hg
382
+ spec_calc[:, complex_indices[1]] = hg2
383
+
384
+ elif topology == "2:1":
385
+ beta21 = 10.0 ** float(binding_params[complex_indices[1]])
386
+
387
+ # Setup coefficients for cubic equation a*h^3 + b*h^2 + c*h + d = 0
388
+ a = np.full(n_rows, beta21, dtype=float)
389
+ b = beta11 + 2.0 * g_tot * beta21 - h_tot * beta21
390
+ c = 1.0 + g_tot * beta11 - h_tot * beta11
391
+ d = -h_tot
392
+
393
+ def score_21(h_free_candidates):
394
+ h_t = h_tot[np.newaxis, :]
395
+ g_t = g_tot[np.newaxis, :]
396
+ denom = np.maximum(1.0 + beta11 * h_free_candidates + beta21 * (h_free_candidates**2), 1e-300)
397
+ g_f = g_t / denom
398
+ hg = beta11 * h_free_candidates * g_f
399
+ h2g = beta21 * (h_free_candidates**2) * g_f
400
+ r1 = np.abs(h_t - (h_free_candidates + hg + 2.0 * h2g))
401
+ r2 = np.abs(g_t - (g_f + hg + h2g))
402
+ return r1 + r2
403
+
404
+ h_free = _solve_cubic_vectorized(a, b, c, d, np.zeros(n_rows), h_tot, score_21)
405
+ denom = np.maximum(1.0 + beta11 * h_free + beta21 * (h_free**2), 1e-300)
406
+ g_free = g_tot / denom
407
+ hg = beta11 * h_free * g_free
408
+ h2g = beta21 * (h_free**2) * g_free
409
+
410
+ # Apply zero/negative overrides
411
+ h_free = np.maximum(h_free, 0.0)
412
+ g_free = np.maximum(g_free, 0.0)
413
+ hg = np.maximum(hg, 0.0)
414
+ h2g = np.maximum(h2g, 0.0)
415
+
416
+ h_free[zero_mask] = h_tot[zero_mask]
417
+ g_free[zero_mask] = g_tot[zero_mask]
418
+ hg[zero_mask] = 0.0
419
+ h2g[zero_mask] = 0.0
420
+
421
+ spec_calc[:, 0] = h_free
422
+ spec_calc[:, 1] = g_free
423
+ spec_calc[:, complex_indices[0]] = hg
424
+ spec_calc[:, complex_indices[1]] = h2g
425
+
426
+ else:
427
+ raise ValueError(f"Unsupported analytical topology: {topology}")
428
+ except Exception:
429
+ error = True
430
+ # Fallback to numerical solver row-by-row
431
+ for ii in range(n_rows):
381
432
  try:
382
- spec_calc[ii, :] = getConcs(eq_mat, np.array([h_tot, g_tot]), binding_params)
433
+ spec_calc[ii, :] = getConcs(eq_mat, comp_concs[ii], binding_params)
383
434
  except Exception:
384
435
  spec_calc[ii, :] = 0.0
385
436
 
@@ -567,15 +618,29 @@ def fitfun(params, fcn_opts): # ,eqMat,specConcs,startShifts=None,sigma=1,ret='
567
618
 
568
619
  bindingParams = np.array([*parvals][: fcn_opts["nK"]], dtype=np.float64)
569
620
  error = False
570
- specCalc = []
571
- for row in fcn_opts["compConcs"]:
572
- try:
573
- yEq = getConcs(fcn_opts["eqMat"], row, bindingParams)
574
- except EquilibriumError as e:
575
- yEq = e.val
576
- error = True
577
- specCalc.append(yEq) # yEq[4:])
578
- specCalc = np.array(specCalc)
621
+
622
+ analytical_topology = fcn_opts.get("analytical_topology")
623
+ if analytical_topology in ("1:1", "1:2", "2:1"):
624
+ comp_concs = np.array(fcn_opts["compConcs"], dtype=float)
625
+ complex_indices = [int(x) for x in fcn_opts.get("analytical_complex_indices", [])]
626
+ specCalc, error = calc_analytical_speciation(
627
+ comp_concs=comp_concs,
628
+ eq_mat=np.array(fcn_opts["eqMat"], dtype=float),
629
+ binding_params=bindingParams,
630
+ topology=str(analytical_topology),
631
+ n_comp=int(comp_concs.shape[1]),
632
+ complex_indices=complex_indices,
633
+ )
634
+ else:
635
+ specCalc = []
636
+ for row in fcn_opts["compConcs"]:
637
+ try:
638
+ yEq = getConcs(fcn_opts["eqMat"], row, bindingParams)
639
+ except EquilibriumError as e:
640
+ yEq = e.val
641
+ error = True
642
+ specCalc.append(yEq) # yEq[4:])
643
+ specCalc = np.array(specCalc)
579
644
  # except EquilibriumError as e:
580
645
  # if fcn_opts['ret'] == 'residual':
581
646
  # return 1000
@@ -782,6 +847,36 @@ def concToObservable(specConcs, specToInteg, specToDd, shiftParams, paramNames,
782
847
  # return sum((specConcs.flatten() - res)**2)
783
848
 
784
849
 
850
+ def _infer_simple_fast_exchange_topology(eq_mat: np.ndarray, n_comp: int) -> Optional[tuple[str, list[int]]]:
851
+ if not isinstance(eq_mat, np.ndarray) or eq_mat.ndim != 2:
852
+ return None
853
+ if n_comp != 2 or eq_mat.shape[0] != 2 or eq_mat.shape[1] <= n_comp:
854
+ return None
855
+
856
+ bound_indices = list(range(n_comp, eq_mat.shape[1]))
857
+ sig_to_idx = {}
858
+ for idx in bound_indices:
859
+ a = eq_mat[0, idx]
860
+ b = eq_mat[1, idx]
861
+ if not np.isfinite(a) or not np.isfinite(b):
862
+ return None
863
+ if not np.isclose(a, round(a)) or not np.isclose(b, round(b)):
864
+ return None
865
+ sig = (int(round(a)), int(round(b)))
866
+ if sig in sig_to_idx:
867
+ return None
868
+ sig_to_idx[sig] = idx
869
+
870
+ sigs = set(sig_to_idx.keys())
871
+ if len(bound_indices) == 1 and sigs == {(1, 1)}:
872
+ return "1:1", [sig_to_idx[(1, 1)]]
873
+ if len(bound_indices) == 2 and sigs == {(1, 1), (1, 2)}:
874
+ return "1:2", [sig_to_idx[(1, 1)], sig_to_idx[(1, 2)]]
875
+ if len(bound_indices) == 2 and sigs == {(1, 1), (2, 1)}:
876
+ return "2:1", [sig_to_idx[(1, 1)], sig_to_idx[(2, 1)]]
877
+ return None
878
+
879
+
785
880
  class bindingModel:
786
881
  def __init__(
787
882
  self,
@@ -901,7 +996,108 @@ class bindingModel:
901
996
  def setColumnToSpeciesMapping(self, colToSpec):
902
997
  self.colToSpec = colToSpec
903
998
 
904
- def prepModel(self):
999
+ def prepModel(self, force_numerical=False):
1000
+ if force_numerical:
1001
+ self.analytical_fast_exchange = False
1002
+ self.analytical_topology = None
1003
+ self.analytical_obs_columns = []
1004
+ self.analytical_obs_components = []
1005
+ self.analytical_complex_indices = []
1006
+ self.analytical_obs_param_map = []
1007
+ self.analytical_linear_obs_columns = []
1008
+ self.analytical_linear_obs_param_map = []
1009
+ else:
1010
+ if not self.analytical_topology:
1011
+ topology_res = _infer_simple_fast_exchange_topology(self.eqMat, len(self.compNames))
1012
+ if topology_res is not None:
1013
+ self.analytical_topology, self.analytical_complex_indices = topology_res
1014
+
1015
+ if self.analytical_topology in ("1:1", "1:2", "2:1") and not self.analytical_fast_exchange:
1016
+ # 1. Check slow exchange mapping (specToInteg / colToSpec)
1017
+ has_slow_exchange = False
1018
+ if self.colToSpec is not None and self.colToSpec.size > 0:
1019
+ try:
1020
+ flat_vals = []
1021
+ for x in self.colToSpec.flat:
1022
+ try:
1023
+ val = float(x)
1024
+ except (TypeError, ValueError):
1025
+ val = 0.0
1026
+ flat_vals.append(val)
1027
+ flat_arr = np.array(flat_vals)
1028
+ if np.any(~np.isclose(flat_arr, 0.0)):
1029
+ has_slow_exchange = True
1030
+ except Exception:
1031
+ pass
1032
+
1033
+ # 2. Check types of columns (NMR vs Linear vs Mixed)
1034
+ n_integ = self.colToSpec.shape[1] if (self.colToSpec is not None and self.colToSpec.size > 0) else 0
1035
+ n_linear = self.specToLinear.shape[1] if (self.specToLinear is not None and self.specToLinear.size > 0) else 0
1036
+ n_shift = self.specToDd.shape[1] if (self.specToDd is not None and self.specToDd.size > 0) else 0
1037
+
1038
+ obs_list = self.obsList if self.obsList is not None else []
1039
+ if n_shift == 0:
1040
+ n_shift = max(0, len(obs_list) - n_integ - n_linear)
1041
+
1042
+ if len(obs_list) < n_integ + n_linear + n_shift:
1043
+ obs_list = list(obs_list) + [f"obs_{i}" for i in range(len(obs_list), n_integ + n_linear + n_shift)]
1044
+
1045
+ linear_cols = obs_list[n_integ:n_integ + n_linear]
1046
+ shift_cols = obs_list[n_integ + n_linear:n_integ + n_linear + n_shift]
1047
+
1048
+
1049
+ if not has_slow_exchange and not (n_shift > 0 and n_linear > 0) and (n_shift > 0 or n_linear > 0):
1050
+ self.analytical_fast_exchange = True
1051
+
1052
+ if n_linear > 0:
1053
+ self.analytical_linear_obs_columns = linear_cols
1054
+ linear_obs_param_map = []
1055
+ for obs_idx in range(n_linear):
1056
+ pnames = []
1057
+ for species_idx in range(self.specToLinear.shape[0]):
1058
+ cell = self.specToLinear[species_idx, obs_idx]
1059
+ if isinstance(cell, lmfit.Parameter):
1060
+ pnames.append(cell.name)
1061
+ else:
1062
+ pnames.append(None)
1063
+ linear_obs_param_map.append(pnames)
1064
+ self.analytical_linear_obs_param_map = linear_obs_param_map
1065
+ self.analytical_obs_columns = []
1066
+ self.analytical_obs_components = []
1067
+ else:
1068
+ self.analytical_obs_columns = shift_cols
1069
+
1070
+ def is_nonzero_mapping(cell):
1071
+ if cell is None:
1072
+ return False
1073
+ if isinstance(cell, (int, float)):
1074
+ return not np.isclose(cell, 0.0)
1075
+ return True
1076
+
1077
+ obs_components = []
1078
+ for obs_idx in range(n_shift):
1079
+ has_comp0 = False
1080
+ has_comp1 = False
1081
+ if self.specToDd is not None and self.specToDd.shape[0] > 1:
1082
+ has_comp0 = is_nonzero_mapping(self.specToDd[0, obs_idx])
1083
+ has_comp1 = is_nonzero_mapping(self.specToDd[1, obs_idx])
1084
+
1085
+ if has_comp0 and not has_comp1:
1086
+ obs_components.append(0)
1087
+ elif has_comp1 and not has_comp0:
1088
+ obs_components.append(1)
1089
+ else:
1090
+ col_name = shift_cols[obs_idx] if obs_idx < len(shift_cols) else ""
1091
+ tokens = [t for t in re.split(r"[^A-Za-z0-9]+", col_name.lower()) if t]
1092
+ matches = [idx for idx, comp in enumerate(self.compNames) if str(comp).lower() in tokens]
1093
+ if len(matches) == 1:
1094
+ obs_components.append(matches[0])
1095
+ else:
1096
+ obs_components.append(obs_idx % 2)
1097
+ self.analytical_obs_components = obs_components
1098
+ self.analytical_linear_obs_columns = []
1099
+ self.analytical_linear_obs_param_map = []
1100
+
905
1101
  for paramName in self.compNames:
906
1102
  self._addParam("log" + paramName, init=0, vary=False)
907
1103
 
@@ -1387,6 +1583,7 @@ class MCMC:
1387
1583
  self.variance = variance
1388
1584
  self.sampler = None
1389
1585
  self._labels = None
1586
+ self.thin = 1
1390
1587
 
1391
1588
  @property
1392
1589
  def labels(self):
@@ -1405,6 +1602,7 @@ class MCMC:
1405
1602
  return params
1406
1603
 
1407
1604
  def run(self, ret=False, thin=1, samples=None, pool=None, tqdm_kwargs=None):
1605
+ self.thin = thin
1408
1606
  bm = self.model
1409
1607
  bm.colTypes = self.obs
1410
1608
 
@@ -1470,7 +1668,7 @@ class MCMC:
1470
1668
  )
1471
1669
  else:
1472
1670
  self.sampler.pool = active_pool
1473
- self.sampler.run_mcmc(p0, samples, progress=True, progress_kwargs=tqdm_kwargs)
1671
+ self.sampler.run_mcmc(p0, samples, progress=True, thin_by=thin, progress_kwargs=tqdm_kwargs)
1474
1672
  else:
1475
1673
  if self.sampler is None:
1476
1674
  self.sampler = emcee.EnsembleSampler(self.walkers, ndim, log_prob, args=[bm.fcn_opts, bounds])
@@ -1559,6 +1757,7 @@ class MCMC:
1559
1757
  else:
1560
1758
  g.attrs["has_blobs"] = False
1561
1759
  g.attrs["iteration"] = self.sampler.backend.iteration
1760
+ g.attrs["thin"] = getattr(self, "thin", 1)
1562
1761
 
1563
1762
  def load(self, fname):
1564
1763
  if self.sampler is None:
@@ -1574,6 +1773,8 @@ class MCMC:
1574
1773
  self.sampler.backend.blobs = g["blobs"][:]
1575
1774
  self.sampler.backend.iteration = g.attrs["iteration"]
1576
1775
  self.sampler.backend.log_prob = g["log_prob"][:]
1776
+ if "thin" in g.attrs:
1777
+ self.thin = int(g.attrs["thin"])
1577
1778
  # sampler.backend.random_state = g['random_state'][:]
1578
1779
  self.sampler.backend.initialized = True
1579
1780
  self.sampler._previous_state = self.sampler.get_last_sample()
File without changes