bindtools 0.1.3__tar.gz → 0.2.2__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.3
3
+ Version: 0.2.2
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
@@ -19,7 +19,7 @@ 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.3"
7
+ version = "0.2.2"
8
8
  readme = "README.md"
9
9
  classifiers = [
10
10
  # How mature is this project? Common values are
@@ -35,7 +35,7 @@ dependencies = [
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",
@@ -218,117 +218,76 @@ def DoNR(eqMat, K, initComponentConc, guessCompConc):
218
218
  )
219
219
 
220
220
 
221
- def _analytical_obs_param_token(col_name: str) -> str:
222
- token = re.sub(r"[^\w]+", "_", str(col_name))
223
- token = re.sub(r"_+", "_", token).strip("_")
224
- if not token:
225
- token = "obs"
226
- if not (token[0].isalpha() or token[0] == "_"):
227
- token = f"obs_{token}"
228
- return token
229
-
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
221
 
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
- )
222
+ 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:
223
+ A = b / a
224
+ B = c / a
225
+ C = d / a
226
+
227
+ p = B - A**2 / 3.0
228
+ q = C - A * B / 3.0 + 2.0 * A**3 / 27.0
229
+
230
+ D = q**2 / 4.0 + p**3 / 27.0
231
+
232
+ N = len(a)
233
+ candidates = np.zeros((3, N), dtype=float)
234
+
235
+ mask_single = D > 0
236
+ mask_three = ~mask_single
237
+
238
+ # Solve single root cases (D > 0)
239
+ if np.any(mask_single):
240
+ D_s = D[mask_single]
241
+ q_s = q[mask_single]
242
+ A_s = A[mask_single]
243
+
244
+ sqrt_D = np.sqrt(D_s)
245
+ val1 = -q_s / 2.0 + sqrt_D
246
+ val2 = -q_s / 2.0 - sqrt_D
247
+
248
+ u = np.sign(val1) * np.abs(val1)**(1.0/3.0)
249
+ v = np.sign(val2) * np.abs(val2)**(1.0/3.0)
250
+
251
+ root_s = u + v - A_s / 3.0
252
+ candidates[0, mask_single] = root_s
253
+ candidates[1, mask_single] = root_s
254
+ candidates[2, mask_single] = root_s
314
255
 
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)
256
+ # Solve three root cases (D <= 0)
257
+ if np.any(mask_three):
258
+ p_t = p[mask_three]
259
+ q_t = q[mask_three]
260
+ A_t = A[mask_three]
261
+
262
+ valid = -p_t > 1e-12
263
+ phi = np.zeros_like(p_t)
264
+ r = np.zeros_like(p_t)
265
+ if np.any(valid):
266
+ p_v = p_t[valid]
267
+ q_v = q_t[valid]
268
+ cos_phi = (3.0 * q_v) / (2.0 * p_v * np.sqrt(-p_v / 3.0))
269
+ cos_phi = np.clip(cos_phi, -1.0, 1.0)
270
+ phi[valid] = np.arccos(cos_phi)
271
+ r[valid] = 2.0 * np.sqrt(-p_v / 3.0)
272
+
273
+ y0 = r * np.cos(phi / 3.0)
274
+ y1 = r * np.cos(phi / 3.0 + 2.0 * np.pi / 3.0)
275
+ y2 = r * np.cos(phi / 3.0 + 4.0 * np.pi / 3.0)
276
+
277
+ candidates[0, mask_three] = y0 - A_t / 3.0
278
+ candidates[1, mask_three] = y1 - A_t / 3.0
279
+ candidates[2, mask_three] = y2 - A_t / 3.0
280
+
281
+ # Evaluate residuals using the vectorized score function
282
+ residuals = score_fn_list(candidates)
283
+
284
+ # Penalize out of bounds candidates
285
+ out_of_bounds = (candidates < lower - 1e-12) | (candidates > upper + 1e-12)
286
+ residuals[out_of_bounds] += 1e10
287
+
288
+ best_idx = np.argmin(residuals, axis=0)
289
+ best_root = candidates[best_idx, np.arange(N)]
290
+ return np.clip(best_root, lower, upper)
332
291
 
333
292
 
334
293
  def calc_analytical_speciation(
@@ -340,208 +299,134 @@ def calc_analytical_speciation(
340
299
  complex_indices: list[int],
341
300
  ) -> tuple[np.ndarray, bool]:
342
301
 
302
+ if n_comp != 2:
303
+ raise ValueError("Analytical fast-exchange solver requires exactly 2 components.")
304
+
343
305
  n_rows = int(np.shape(comp_concs)[0])
344
306
  n_species = int(np.shape(eq_mat)[1])
345
307
  spec_calc = np.zeros((n_rows, n_species), dtype=float)
346
308
  error = False
347
309
 
348
- if n_comp != 2:
349
- raise ValueError("Analytical fast-exchange solver requires exactly 2 components.")
310
+ h_tot = np.maximum(comp_concs[:, 0], 0.0)
311
+ g_tot = np.maximum(comp_concs[:, 1], 0.0)
312
+ zero_mask = (h_tot <= 0.0) | (g_tot <= 0.0)
350
313
 
351
314
  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
315
 
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
316
+ try:
317
+ if topology == "1:1":
318
+ if beta11 <= 0:
319
+ spec_calc[:, 0] = h_tot
320
+ spec_calc[:, 1] = g_tot
377
321
  else:
378
- raise ValueError(f"Unsupported analytical topology: {topology}")
379
- except Exception:
380
- error = True
322
+ first_term = h_tot + g_tot + 1.0 / max(beta11, 1e-300)
323
+ sqrt_val = np.sqrt(np.maximum(first_term**2 - 4.0 * h_tot * g_tot, 0.0))
324
+ hg = 0.5 * (first_term - sqrt_val)
325
+ hg = np.clip(hg, 0.0, np.minimum(h_tot, g_tot))
326
+ hg[zero_mask] = 0.0
327
+
328
+ spec_calc[:, 0] = np.maximum(h_tot - hg, 0.0)
329
+ spec_calc[:, 1] = np.maximum(g_tot - hg, 0.0)
330
+ spec_calc[:, complex_indices[0]] = hg
331
+
332
+ elif topology == "1:2":
333
+ beta12 = 10.0 ** float(binding_params[complex_indices[1]])
334
+
335
+ # Setup coefficients for cubic equation a*g^3 + b*g^2 + c*g + d = 0
336
+ a = np.full(n_rows, beta12, dtype=float)
337
+ b = beta11 + 2.0 * h_tot * beta12 - g_tot * beta12
338
+ c = 1.0 + h_tot * beta11 - g_tot * beta11
339
+ d = -g_tot
340
+
341
+ def score_12(g_free_candidates):
342
+ h_t = h_tot[np.newaxis, :]
343
+ g_t = g_tot[np.newaxis, :]
344
+ denom = np.maximum(1.0 + beta11 * g_free_candidates + beta12 * (g_free_candidates**2), 1e-300)
345
+ h_f = h_t / denom
346
+ hg = beta11 * h_f * g_free_candidates
347
+ hg2 = beta12 * h_f * (g_free_candidates**2)
348
+ r1 = np.abs(h_t - (h_f + hg + hg2))
349
+ r2 = np.abs(g_t - (g_free_candidates + hg + 2.0 * hg2))
350
+ return r1 + r2
351
+
352
+ g_free = _solve_cubic_vectorized(a, b, c, d, np.zeros(n_rows), g_tot, score_12)
353
+ denom = np.maximum(1.0 + beta11 * g_free + beta12 * (g_free**2), 1e-300)
354
+ h_free = h_tot / denom
355
+ hg = beta11 * h_free * g_free
356
+ hg2 = beta12 * h_free * (g_free**2)
357
+
358
+ # Apply zero/negative overrides
359
+ h_free = np.maximum(h_free, 0.0)
360
+ g_free = np.maximum(g_free, 0.0)
361
+ hg = np.maximum(hg, 0.0)
362
+ hg2 = np.maximum(hg2, 0.0)
363
+
364
+ h_free[zero_mask] = h_tot[zero_mask]
365
+ g_free[zero_mask] = g_tot[zero_mask]
366
+ hg[zero_mask] = 0.0
367
+ hg2[zero_mask] = 0.0
368
+
369
+ spec_calc[:, 0] = h_free
370
+ spec_calc[:, 1] = g_free
371
+ spec_calc[:, complex_indices[0]] = hg
372
+ spec_calc[:, complex_indices[1]] = hg2
373
+
374
+ elif topology == "2:1":
375
+ beta21 = 10.0 ** float(binding_params[complex_indices[1]])
376
+
377
+ # Setup coefficients for cubic equation a*h^3 + b*h^2 + c*h + d = 0
378
+ a = np.full(n_rows, beta21, dtype=float)
379
+ b = beta11 + 2.0 * g_tot * beta21 - h_tot * beta21
380
+ c = 1.0 + g_tot * beta11 - h_tot * beta11
381
+ d = -h_tot
382
+
383
+ def score_21(h_free_candidates):
384
+ h_t = h_tot[np.newaxis, :]
385
+ g_t = g_tot[np.newaxis, :]
386
+ denom = np.maximum(1.0 + beta11 * h_free_candidates + beta21 * (h_free_candidates**2), 1e-300)
387
+ g_f = g_t / denom
388
+ hg = beta11 * h_free_candidates * g_f
389
+ h2g = beta21 * (h_free_candidates**2) * g_f
390
+ r1 = np.abs(h_t - (h_free_candidates + hg + 2.0 * h2g))
391
+ r2 = np.abs(g_t - (g_f + hg + h2g))
392
+ return r1 + r2
393
+
394
+ h_free = _solve_cubic_vectorized(a, b, c, d, np.zeros(n_rows), h_tot, score_21)
395
+ denom = np.maximum(1.0 + beta11 * h_free + beta21 * (h_free**2), 1e-300)
396
+ g_free = g_tot / denom
397
+ hg = beta11 * h_free * g_free
398
+ h2g = beta21 * (h_free**2) * g_free
399
+
400
+ # Apply zero/negative overrides
401
+ h_free = np.maximum(h_free, 0.0)
402
+ g_free = np.maximum(g_free, 0.0)
403
+ hg = np.maximum(hg, 0.0)
404
+ h2g = np.maximum(h2g, 0.0)
405
+
406
+ h_free[zero_mask] = h_tot[zero_mask]
407
+ g_free[zero_mask] = g_tot[zero_mask]
408
+ hg[zero_mask] = 0.0
409
+ h2g[zero_mask] = 0.0
410
+
411
+ spec_calc[:, 0] = h_free
412
+ spec_calc[:, 1] = g_free
413
+ spec_calc[:, complex_indices[0]] = hg
414
+ spec_calc[:, complex_indices[1]] = h2g
415
+
416
+ else:
417
+ raise ValueError(f"Unsupported analytical topology: {topology}")
418
+ except Exception:
419
+ error = True
420
+ # Fallback to numerical solver row-by-row
421
+ for ii in range(n_rows):
381
422
  try:
382
- spec_calc[ii, :] = getConcs(eq_mat, np.array([h_tot, g_tot]), binding_params)
423
+ spec_calc[ii, :] = getConcs(eq_mat, comp_concs[ii], binding_params)
383
424
  except Exception:
384
425
  spec_calc[ii, :] = 0.0
385
426
 
386
427
  return spec_calc, error
387
428
 
388
429
 
389
- def _analytical_param_value(values: np.ndarray, name_to_idx: dict[str, int], param_name: str) -> float:
390
- idx = name_to_idx.get(param_name)
391
- if idx is None or idx >= len(values):
392
- return 0.0
393
- return float(values[idx])
394
-
395
-
396
- def calc_analytical_observables(
397
- spec_calc: np.ndarray,
398
- comp_concs: np.ndarray,
399
- eq_mat: np.ndarray,
400
- obs_components: list[int],
401
- complex_indices: list[int],
402
- shift_param_values: np.ndarray,
403
- shift_param_names: list[str],
404
- obs_param_map: list[list[str]],
405
- ) -> np.ndarray:
406
-
407
- n_obs = len(obs_components)
408
- out = np.zeros((spec_calc.shape[0], n_obs), dtype=float)
409
- name_to_idx = {name: i for i, name in enumerate(shift_param_names)}
410
-
411
- for obs_idx in range(n_obs):
412
- comp_idx = int(obs_components[obs_idx])
413
- denom = comp_concs[:, comp_idx]
414
- pnames = obs_param_map[obs_idx]
415
- baseline = _analytical_param_value(shift_param_values, name_to_idx, pnames[0])
416
- out[:, obs_idx] = baseline
417
-
418
- for cidx, complex_idx in enumerate(complex_indices):
419
- if cidx + 1 >= len(pnames):
420
- continue
421
- amp = _analytical_param_value(shift_param_values, name_to_idx, pnames[cidx + 1])
422
- stoich = float(eq_mat[comp_idx, complex_idx])
423
- frac = np.zeros_like(denom, dtype=float)
424
- np.divide(
425
- stoich * spec_calc[:, complex_idx],
426
- denom,
427
- out=frac,
428
- where=np.isfinite(denom) & (np.abs(denom) > 0),
429
- )
430
- out[:, obs_idx] += amp * frac
431
-
432
- return out
433
-
434
-
435
- def calc_analytical_linear_observables(
436
- spec_calc: np.ndarray,
437
- linear_param_values: np.ndarray,
438
- linear_param_names: list,
439
- linear_obs_param_map: list,
440
- ) -> np.ndarray:
441
- """Compute concentration-weighted linear observables for the analytical fast-exchange path.
442
-
443
- Used for UV-vis (Beer-Lambert) and fluorescence: A_i = sum_j eps_{ij} * [species_j].
444
-
445
- Args:
446
- spec_calc: species concentrations, shape (n_pts, n_species).
447
- linear_param_values: flat array of current non-binding parameter values.
448
- linear_param_names: list of param names corresponding to linear_param_values.
449
- linear_obs_param_map: list of lists; outer index = observable column, inner index = species.
450
- Each inner entry is a param name string or None (dark/silent species).
451
-
452
- Returns:
453
- ndarray of shape (n_pts, n_obs).
454
- """
455
- n_obs = len(linear_obs_param_map)
456
- out = np.zeros((spec_calc.shape[0], n_obs), dtype=float)
457
- name_to_idx = {name: i for i, name in enumerate(linear_param_names)}
458
-
459
- for obs_idx, pnames in enumerate(linear_obs_param_map):
460
- for species_idx, pname in enumerate(pnames):
461
- if not pname:
462
- continue # dark / silent species
463
- coeff = _analytical_param_value(linear_param_values, name_to_idx, pname)
464
- out[:, obs_idx] += coeff * spec_calc[:, species_idx]
465
-
466
- return out
467
-
468
-
469
- def fitfun_analytical_fast_exchange(params, fcn_opts):
470
- if isinstance(params, lmfit.parameter.Parameters):
471
- parvals = list(params.valuesdict().values())
472
- else:
473
- parvals = params
474
-
475
- nK = int(fcn_opts["nK"])
476
- binding_params = np.array([*parvals][:nK], dtype=np.float64)
477
- comp_concs = np.array(fcn_opts["compConcs"], dtype=float)
478
- eq_mat = np.array(fcn_opts["eqMat"], dtype=float)
479
- topology = str(fcn_opts.get("analytical_topology", ""))
480
- complex_indices = [int(x) for x in fcn_opts.get("analytical_complex_indices", [])]
481
- obs_components = [int(x) for x in fcn_opts.get("analytical_obs_components", [])]
482
- obs_param_map = list(fcn_opts.get("analytical_obs_param_map", []))
483
- linear_obs_param_map = list(fcn_opts.get("analytical_linear_obs_param_map", []))
484
-
485
- spec_calc, error = calc_analytical_speciation(
486
- comp_concs=comp_concs,
487
- eq_mat=eq_mat,
488
- binding_params=binding_params,
489
- topology=topology,
490
- n_comp=int(comp_concs.shape[1]),
491
- complex_indices=complex_indices,
492
- )
493
-
494
- if fcn_opts["optTarget"] == "concs":
495
- if fcn_opts["ret"] == "residual":
496
- res = (fcn_opts["exptData"] - spec_calc) / fcn_opts["sigma"]
497
- if error and fcn_opts["mcmc"] is True:
498
- return np.nan
499
- if error:
500
- res = res * 10
501
- return res
502
- if fcn_opts["ret"] == "concs":
503
- return spec_calc
504
- return -1
505
-
506
- shift_params = np.array([*parvals][nK:], dtype=np.float64)
507
- shift_param_names = fcn_opts["paramNames"][nK:]
508
-
509
- # Route to linear (UV-vis / fluorescence) or NMR-shift observable calculation.
510
- if linear_obs_param_map:
511
- obs_calc = calc_analytical_linear_observables(
512
- spec_calc=spec_calc,
513
- linear_param_values=shift_params,
514
- linear_param_names=shift_param_names,
515
- linear_obs_param_map=linear_obs_param_map,
516
- )
517
- else:
518
- obs_calc = calc_analytical_observables(
519
- spec_calc=spec_calc,
520
- comp_concs=comp_concs,
521
- eq_mat=eq_mat,
522
- obs_components=obs_components,
523
- complex_indices=complex_indices,
524
- shift_param_values=shift_params,
525
- shift_param_names=shift_param_names,
526
- obs_param_map=obs_param_map,
527
- )
528
-
529
- if fcn_opts["ret"] == "residual":
530
- res = (fcn_opts["exptData"] - obs_calc) / fcn_opts["sigma"]
531
- nan_mask = np.isnan(fcn_opts["exptData"])
532
- res[nan_mask] = 0
533
- if error and fcn_opts["mcmc"] is True:
534
- return np.nan
535
- if error:
536
- res = res * 10
537
- if fcn_opts["mcmc"] is True:
538
- res = np.nan_to_num(res)
539
- return res
540
- if fcn_opts["ret"] == "concs":
541
- return obs_calc
542
- return -1
543
-
544
-
545
430
  def fitfun(params, fcn_opts): # ,eqMat,specConcs,startShifts=None,sigma=1,ret='residual'):
546
431
 
547
432
  # fcn_opts = {'compConcs': compConcs,
@@ -553,12 +438,7 @@ def fitfun(params, fcn_opts): # ,eqMat,specConcs,startShifts=None,sigma=1,ret='
553
438
  # 'sigma': 1,
554
439
  # 'ret': 'residual'}
555
440
 
556
- if fcn_opts.get("analytical_fast_exchange") is True and fcn_opts.get("analytical_topology") in (
557
- "1:1",
558
- "1:2",
559
- "2:1",
560
- ):
561
- return fitfun_analytical_fast_exchange(params, fcn_opts)
441
+
562
442
 
563
443
  if isinstance(params, lmfit.parameter.Parameters):
564
444
  parvals = list(params.valuesdict().values())
@@ -676,12 +556,12 @@ def concToDelta(concs, specToDd, shiftParams, paramNames):
676
556
  specToDdTrial = specToDdTrial.astype(np.float64)
677
557
 
678
558
  # normalize concs to mol fractions
679
- truthMat = np.array(specToDdTrial, dtype=bool)
559
+ truthMat = ~np.isnan(specToDdTrial)
680
560
  shiftCalc = []
681
561
  for cc in concs:
682
562
  tt = (truthMat.T * cc).T
683
563
  moleFracs = tt / tt.sum(axis=0)
684
- sc = (moleFracs * specToDdTrial).sum(axis=0)
564
+ sc = np.nansum(moleFracs * specToDdTrial, axis=0)
685
565
  shiftCalc.append(sc)
686
566
 
687
567
  return shiftCalc
@@ -796,6 +676,36 @@ def concToObservable(specConcs, specToInteg, specToDd, shiftParams, paramNames,
796
676
  # return sum((specConcs.flatten() - res)**2)
797
677
 
798
678
 
679
+ def _infer_simple_fast_exchange_topology(eq_mat: np.ndarray, n_comp: int) -> Optional[tuple[str, list[int]]]:
680
+ if not isinstance(eq_mat, np.ndarray) or eq_mat.ndim != 2:
681
+ return None
682
+ if n_comp != 2 or eq_mat.shape[0] != 2 or eq_mat.shape[1] <= n_comp:
683
+ return None
684
+
685
+ bound_indices = list(range(n_comp, eq_mat.shape[1]))
686
+ sig_to_idx = {}
687
+ for idx in bound_indices:
688
+ a = eq_mat[0, idx]
689
+ b = eq_mat[1, idx]
690
+ if not np.isfinite(a) or not np.isfinite(b):
691
+ return None
692
+ if not np.isclose(a, round(a)) or not np.isclose(b, round(b)):
693
+ return None
694
+ sig = (int(round(a)), int(round(b)))
695
+ if sig in sig_to_idx:
696
+ return None
697
+ sig_to_idx[sig] = idx
698
+
699
+ sigs = set(sig_to_idx.keys())
700
+ if len(bound_indices) == 1 and sigs == {(1, 1)}:
701
+ return "1:1", [sig_to_idx[(1, 1)]]
702
+ if len(bound_indices) == 2 and sigs == {(1, 1), (1, 2)}:
703
+ return "1:2", [sig_to_idx[(1, 1)], sig_to_idx[(1, 2)]]
704
+ if len(bound_indices) == 2 and sigs == {(1, 1), (2, 1)}:
705
+ return "2:1", [sig_to_idx[(1, 1)], sig_to_idx[(2, 1)]]
706
+ return None
707
+
708
+
799
709
  class bindingModel:
800
710
  def __init__(
801
711
  self,
@@ -843,12 +753,7 @@ class bindingModel:
843
753
  self.specToLinear: Optional[np.ndarray] = None # (n_species, n_obs) object array for UV-vis / fluorescence
844
754
  self.analytical_fast_exchange: bool = False
845
755
  self.analytical_topology: Optional[str] = None
846
- self.analytical_obs_columns: list[str] = []
847
- self.analytical_obs_components: list[int] = []
848
756
  self.analytical_complex_indices: list[int] = []
849
- self.analytical_obs_param_map: list[list[str]] = []
850
- self.analytical_linear_obs_columns: list[str] = []
851
- self.analytical_linear_obs_param_map: list[list] = [] # per-obs list of param names (or None) in species order
852
757
 
853
758
  self.params = lmfit.Parameters()
854
759
  self.mini = None
@@ -915,7 +820,24 @@ class bindingModel:
915
820
  def setColumnToSpeciesMapping(self, colToSpec):
916
821
  self.colToSpec = colToSpec
917
822
 
918
- def prepModel(self):
823
+ def prepModel(self, force_numerical=False):
824
+ if force_numerical:
825
+ self.analytical_fast_exchange = False
826
+ self.analytical_topology = None
827
+ self.analytical_obs_columns = []
828
+ self.analytical_obs_components = []
829
+ self.analytical_complex_indices = []
830
+ self.analytical_obs_param_map = []
831
+ self.analytical_linear_obs_columns = []
832
+ self.analytical_linear_obs_param_map = []
833
+ else:
834
+ if not self.analytical_topology:
835
+ topology_res = _infer_simple_fast_exchange_topology(self.eqMat, len(self.compNames))
836
+ if topology_res is not None:
837
+ self.analytical_topology, self.analytical_complex_indices = topology_res
838
+
839
+
840
+
919
841
  for paramName in self.compNames:
920
842
  self._addParam("log" + paramName, init=0, vary=False)
921
843
 
@@ -929,22 +851,7 @@ class bindingModel:
929
851
  if isinstance(x, lmfit.Parameter):
930
852
  self._addExistingParam(x)
931
853
 
932
- if self.analytical_fast_exchange:
933
- n_complex = len(self.analytical_complex_indices)
934
- if n_complex == 0:
935
- n_complex = 1
936
- self.analytical_obs_param_map = []
937
- for col_name in self.analytical_obs_columns:
938
- token = _analytical_obs_param_token(col_name)
939
- pname_list = [f"delta0_{token}"]
940
- self._addParam(pname_list[0], init=0.0, min=-1000, max=1000, vary=True)
941
- for cidx in range(n_complex):
942
- pname = f"deltac{cidx + 1}_{token}"
943
- pname_list.append(pname)
944
- self._addParam(pname, init=0.0, min=-1000, max=1000, vary=True)
945
- self.analytical_obs_param_map.append(pname_list)
946
- # specToLinear params already registered above; no further action needed.
947
- return
854
+
948
855
 
949
856
  # add chemical shift fitting params
950
857
  if self.specToDd is not None:
@@ -990,11 +897,7 @@ class bindingModel:
990
897
  "paramNames": list(self.params.keys()),
991
898
  "analytical_fast_exchange": analytical_mode,
992
899
  "analytical_topology": self.analytical_topology,
993
- "analytical_obs_columns": list(self.analytical_obs_columns),
994
- "analytical_obs_components": list(self.analytical_obs_components),
995
900
  "analytical_complex_indices": list(self.analytical_complex_indices),
996
- "analytical_obs_param_map": [list(x) for x in self.analytical_obs_param_map],
997
- "analytical_linear_obs_param_map": [list(x) for x in self.analytical_linear_obs_param_map],
998
901
  }
999
902
 
1000
903
  # save settings for later plots
@@ -1046,10 +949,7 @@ class bindingModel:
1046
949
  "paramNames": list(params.keys()),
1047
950
  "analytical_fast_exchange": bool(self.analytical_fast_exchange),
1048
951
  "analytical_topology": self.analytical_topology,
1049
- "analytical_obs_columns": list(self.analytical_obs_columns),
1050
- "analytical_obs_components": list(self.analytical_obs_components),
1051
952
  "analytical_complex_indices": list(self.analytical_complex_indices),
1052
- "analytical_obs_param_map": [list(x) for x in self.analytical_obs_param_map],
1053
953
  }
1054
954
 
1055
955
  return np.array(fitfun(params, fcn_opts))
@@ -1401,6 +1301,7 @@ class MCMC:
1401
1301
  self.variance = variance
1402
1302
  self.sampler = None
1403
1303
  self._labels = None
1304
+ self.thin = 1
1404
1305
 
1405
1306
  @property
1406
1307
  def labels(self):
@@ -1419,6 +1320,7 @@ class MCMC:
1419
1320
  return params
1420
1321
 
1421
1322
  def run(self, ret=False, thin=1, samples=None, pool=None, tqdm_kwargs=None):
1323
+ self.thin = thin
1422
1324
  bm = self.model
1423
1325
  bm.colTypes = self.obs
1424
1326
 
@@ -1484,7 +1386,7 @@ class MCMC:
1484
1386
  )
1485
1387
  else:
1486
1388
  self.sampler.pool = active_pool
1487
- self.sampler.run_mcmc(p0, samples, progress=True, progress_kwargs=tqdm_kwargs)
1389
+ self.sampler.run_mcmc(p0, samples, progress=True, thin_by=thin, progress_kwargs=tqdm_kwargs)
1488
1390
  else:
1489
1391
  if self.sampler is None:
1490
1392
  self.sampler = emcee.EnsembleSampler(self.walkers, ndim, log_prob, args=[bm.fcn_opts, bounds])
@@ -1573,6 +1475,7 @@ class MCMC:
1573
1475
  else:
1574
1476
  g.attrs["has_blobs"] = False
1575
1477
  g.attrs["iteration"] = self.sampler.backend.iteration
1478
+ g.attrs["thin"] = getattr(self, "thin", 1)
1576
1479
 
1577
1480
  def load(self, fname):
1578
1481
  if self.sampler is None:
@@ -1588,6 +1491,8 @@ class MCMC:
1588
1491
  self.sampler.backend.blobs = g["blobs"][:]
1589
1492
  self.sampler.backend.iteration = g.attrs["iteration"]
1590
1493
  self.sampler.backend.log_prob = g["log_prob"][:]
1494
+ if "thin" in g.attrs:
1495
+ self.thin = int(g.attrs["thin"])
1591
1496
  # sampler.backend.random_state = g['random_state'][:]
1592
1497
  self.sampler.backend.initialized = True
1593
1498
  self.sampler._previous_state = self.sampler.get_last_sample()
File without changes