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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
349
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
File without changes
|