bindtools 0.1.3__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.3
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
@@ -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.1"
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",
@@ -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
265
+
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)
314
293
 
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)
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
 
@@ -796,6 +847,36 @@ def concToObservable(specConcs, specToInteg, specToDd, shiftParams, paramNames,
796
847
  # return sum((specConcs.flatten() - res)**2)
797
848
 
798
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
+
799
880
  class bindingModel:
800
881
  def __init__(
801
882
  self,
@@ -915,7 +996,108 @@ class bindingModel:
915
996
  def setColumnToSpeciesMapping(self, colToSpec):
916
997
  self.colToSpec = colToSpec
917
998
 
918
- 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
+
919
1101
  for paramName in self.compNames:
920
1102
  self._addParam("log" + paramName, init=0, vary=False)
921
1103
 
@@ -1401,6 +1583,7 @@ class MCMC:
1401
1583
  self.variance = variance
1402
1584
  self.sampler = None
1403
1585
  self._labels = None
1586
+ self.thin = 1
1404
1587
 
1405
1588
  @property
1406
1589
  def labels(self):
@@ -1419,6 +1602,7 @@ class MCMC:
1419
1602
  return params
1420
1603
 
1421
1604
  def run(self, ret=False, thin=1, samples=None, pool=None, tqdm_kwargs=None):
1605
+ self.thin = thin
1422
1606
  bm = self.model
1423
1607
  bm.colTypes = self.obs
1424
1608
 
@@ -1484,7 +1668,7 @@ class MCMC:
1484
1668
  )
1485
1669
  else:
1486
1670
  self.sampler.pool = active_pool
1487
- 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)
1488
1672
  else:
1489
1673
  if self.sampler is None:
1490
1674
  self.sampler = emcee.EnsembleSampler(self.walkers, ndim, log_prob, args=[bm.fcn_opts, bounds])
@@ -1573,6 +1757,7 @@ class MCMC:
1573
1757
  else:
1574
1758
  g.attrs["has_blobs"] = False
1575
1759
  g.attrs["iteration"] = self.sampler.backend.iteration
1760
+ g.attrs["thin"] = getattr(self, "thin", 1)
1576
1761
 
1577
1762
  def load(self, fname):
1578
1763
  if self.sampler is None:
@@ -1588,6 +1773,8 @@ class MCMC:
1588
1773
  self.sampler.backend.blobs = g["blobs"][:]
1589
1774
  self.sampler.backend.iteration = g.attrs["iteration"]
1590
1775
  self.sampler.backend.log_prob = g["log_prob"][:]
1776
+ if "thin" in g.attrs:
1777
+ self.thin = int(g.attrs["thin"])
1591
1778
  # sampler.backend.random_state = g['random_state'][:]
1592
1779
  self.sampler.backend.initialized = True
1593
1780
  self.sampler._previous_state = self.sampler.get_last_sample()
File without changes