bindtools 0.2.1__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.2.1
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
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "bindtools"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  readme = "README.md"
9
9
  classifiers = [
10
10
  # How mature is this project? Common values are
@@ -218,16 +218,6 @@ 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
221
 
232
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:
233
223
  A = b / a
@@ -437,162 +427,6 @@ def calc_analytical_speciation(
437
427
  return spec_calc, error
438
428
 
439
429
 
440
- def _analytical_param_value(values: np.ndarray, name_to_idx: dict[str, int], param_name: str) -> float:
441
- idx = name_to_idx.get(param_name)
442
- if idx is None or idx >= len(values):
443
- return 0.0
444
- return float(values[idx])
445
-
446
-
447
- def calc_analytical_observables(
448
- spec_calc: np.ndarray,
449
- comp_concs: np.ndarray,
450
- eq_mat: np.ndarray,
451
- obs_components: list[int],
452
- complex_indices: list[int],
453
- shift_param_values: np.ndarray,
454
- shift_param_names: list[str],
455
- obs_param_map: list[list[str]],
456
- ) -> np.ndarray:
457
-
458
- n_obs = len(obs_components)
459
- out = np.zeros((spec_calc.shape[0], n_obs), dtype=float)
460
- name_to_idx = {name: i for i, name in enumerate(shift_param_names)}
461
-
462
- for obs_idx in range(n_obs):
463
- comp_idx = int(obs_components[obs_idx])
464
- denom = comp_concs[:, comp_idx]
465
- pnames = obs_param_map[obs_idx]
466
- baseline = _analytical_param_value(shift_param_values, name_to_idx, pnames[0])
467
- out[:, obs_idx] = baseline
468
-
469
- for cidx, complex_idx in enumerate(complex_indices):
470
- if cidx + 1 >= len(pnames):
471
- continue
472
- amp = _analytical_param_value(shift_param_values, name_to_idx, pnames[cidx + 1])
473
- stoich = float(eq_mat[comp_idx, complex_idx])
474
- frac = np.zeros_like(denom, dtype=float)
475
- np.divide(
476
- stoich * spec_calc[:, complex_idx],
477
- denom,
478
- out=frac,
479
- where=np.isfinite(denom) & (np.abs(denom) > 0),
480
- )
481
- out[:, obs_idx] += amp * frac
482
-
483
- return out
484
-
485
-
486
- def calc_analytical_linear_observables(
487
- spec_calc: np.ndarray,
488
- linear_param_values: np.ndarray,
489
- linear_param_names: list,
490
- linear_obs_param_map: list,
491
- ) -> np.ndarray:
492
- """Compute concentration-weighted linear observables for the analytical fast-exchange path.
493
-
494
- Used for UV-vis (Beer-Lambert) and fluorescence: A_i = sum_j eps_{ij} * [species_j].
495
-
496
- Args:
497
- spec_calc: species concentrations, shape (n_pts, n_species).
498
- linear_param_values: flat array of current non-binding parameter values.
499
- linear_param_names: list of param names corresponding to linear_param_values.
500
- linear_obs_param_map: list of lists; outer index = observable column, inner index = species.
501
- Each inner entry is a param name string or None (dark/silent species).
502
-
503
- Returns:
504
- ndarray of shape (n_pts, n_obs).
505
- """
506
- n_obs = len(linear_obs_param_map)
507
- out = np.zeros((spec_calc.shape[0], n_obs), dtype=float)
508
- name_to_idx = {name: i for i, name in enumerate(linear_param_names)}
509
-
510
- for obs_idx, pnames in enumerate(linear_obs_param_map):
511
- for species_idx, pname in enumerate(pnames):
512
- if not pname:
513
- continue # dark / silent species
514
- coeff = _analytical_param_value(linear_param_values, name_to_idx, pname)
515
- out[:, obs_idx] += coeff * spec_calc[:, species_idx]
516
-
517
- return out
518
-
519
-
520
- def fitfun_analytical_fast_exchange(params, fcn_opts):
521
- if isinstance(params, lmfit.parameter.Parameters):
522
- parvals = list(params.valuesdict().values())
523
- else:
524
- parvals = params
525
-
526
- nK = int(fcn_opts["nK"])
527
- binding_params = np.array([*parvals][:nK], dtype=np.float64)
528
- comp_concs = np.array(fcn_opts["compConcs"], dtype=float)
529
- eq_mat = np.array(fcn_opts["eqMat"], dtype=float)
530
- topology = str(fcn_opts.get("analytical_topology", ""))
531
- complex_indices = [int(x) for x in fcn_opts.get("analytical_complex_indices", [])]
532
- obs_components = [int(x) for x in fcn_opts.get("analytical_obs_components", [])]
533
- obs_param_map = list(fcn_opts.get("analytical_obs_param_map", []))
534
- linear_obs_param_map = list(fcn_opts.get("analytical_linear_obs_param_map", []))
535
-
536
- spec_calc, error = calc_analytical_speciation(
537
- comp_concs=comp_concs,
538
- eq_mat=eq_mat,
539
- binding_params=binding_params,
540
- topology=topology,
541
- n_comp=int(comp_concs.shape[1]),
542
- complex_indices=complex_indices,
543
- )
544
-
545
- if fcn_opts["optTarget"] == "concs":
546
- if fcn_opts["ret"] == "residual":
547
- res = (fcn_opts["exptData"] - spec_calc) / fcn_opts["sigma"]
548
- if error and fcn_opts["mcmc"] is True:
549
- return np.nan
550
- if error:
551
- res = res * 10
552
- return res
553
- if fcn_opts["ret"] == "concs":
554
- return spec_calc
555
- return -1
556
-
557
- shift_params = np.array([*parvals][nK:], dtype=np.float64)
558
- shift_param_names = fcn_opts["paramNames"][nK:]
559
-
560
- # Route to linear (UV-vis / fluorescence) or NMR-shift observable calculation.
561
- if linear_obs_param_map:
562
- obs_calc = calc_analytical_linear_observables(
563
- spec_calc=spec_calc,
564
- linear_param_values=shift_params,
565
- linear_param_names=shift_param_names,
566
- linear_obs_param_map=linear_obs_param_map,
567
- )
568
- else:
569
- obs_calc = calc_analytical_observables(
570
- spec_calc=spec_calc,
571
- comp_concs=comp_concs,
572
- eq_mat=eq_mat,
573
- obs_components=obs_components,
574
- complex_indices=complex_indices,
575
- shift_param_values=shift_params,
576
- shift_param_names=shift_param_names,
577
- obs_param_map=obs_param_map,
578
- )
579
-
580
- if fcn_opts["ret"] == "residual":
581
- res = (fcn_opts["exptData"] - obs_calc) / fcn_opts["sigma"]
582
- nan_mask = np.isnan(fcn_opts["exptData"])
583
- res[nan_mask] = 0
584
- if error and fcn_opts["mcmc"] is True:
585
- return np.nan
586
- if error:
587
- res = res * 10
588
- if fcn_opts["mcmc"] is True:
589
- res = np.nan_to_num(res)
590
- return res
591
- if fcn_opts["ret"] == "concs":
592
- return obs_calc
593
- return -1
594
-
595
-
596
430
  def fitfun(params, fcn_opts): # ,eqMat,specConcs,startShifts=None,sigma=1,ret='residual'):
597
431
 
598
432
  # fcn_opts = {'compConcs': compConcs,
@@ -604,12 +438,7 @@ def fitfun(params, fcn_opts): # ,eqMat,specConcs,startShifts=None,sigma=1,ret='
604
438
  # 'sigma': 1,
605
439
  # 'ret': 'residual'}
606
440
 
607
- if fcn_opts.get("analytical_fast_exchange") is True and fcn_opts.get("analytical_topology") in (
608
- "1:1",
609
- "1:2",
610
- "2:1",
611
- ):
612
- return fitfun_analytical_fast_exchange(params, fcn_opts)
441
+
613
442
 
614
443
  if isinstance(params, lmfit.parameter.Parameters):
615
444
  parvals = list(params.valuesdict().values())
@@ -727,12 +556,12 @@ def concToDelta(concs, specToDd, shiftParams, paramNames):
727
556
  specToDdTrial = specToDdTrial.astype(np.float64)
728
557
 
729
558
  # normalize concs to mol fractions
730
- truthMat = np.array(specToDdTrial, dtype=bool)
559
+ truthMat = ~np.isnan(specToDdTrial)
731
560
  shiftCalc = []
732
561
  for cc in concs:
733
562
  tt = (truthMat.T * cc).T
734
563
  moleFracs = tt / tt.sum(axis=0)
735
- sc = (moleFracs * specToDdTrial).sum(axis=0)
564
+ sc = np.nansum(moleFracs * specToDdTrial, axis=0)
736
565
  shiftCalc.append(sc)
737
566
 
738
567
  return shiftCalc
@@ -924,12 +753,7 @@ class bindingModel:
924
753
  self.specToLinear: Optional[np.ndarray] = None # (n_species, n_obs) object array for UV-vis / fluorescence
925
754
  self.analytical_fast_exchange: bool = False
926
755
  self.analytical_topology: Optional[str] = None
927
- self.analytical_obs_columns: list[str] = []
928
- self.analytical_obs_components: list[int] = []
929
756
  self.analytical_complex_indices: list[int] = []
930
- self.analytical_obs_param_map: list[list[str]] = []
931
- self.analytical_linear_obs_columns: list[str] = []
932
- self.analytical_linear_obs_param_map: list[list] = [] # per-obs list of param names (or None) in species order
933
757
 
934
758
  self.params = lmfit.Parameters()
935
759
  self.mini = None
@@ -1012,91 +836,7 @@ class bindingModel:
1012
836
  if topology_res is not None:
1013
837
  self.analytical_topology, self.analytical_complex_indices = topology_res
1014
838
 
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 = []
839
+
1100
840
 
1101
841
  for paramName in self.compNames:
1102
842
  self._addParam("log" + paramName, init=0, vary=False)
@@ -1111,22 +851,7 @@ class bindingModel:
1111
851
  if isinstance(x, lmfit.Parameter):
1112
852
  self._addExistingParam(x)
1113
853
 
1114
- if self.analytical_fast_exchange:
1115
- n_complex = len(self.analytical_complex_indices)
1116
- if n_complex == 0:
1117
- n_complex = 1
1118
- self.analytical_obs_param_map = []
1119
- for col_name in self.analytical_obs_columns:
1120
- token = _analytical_obs_param_token(col_name)
1121
- pname_list = [f"delta0_{token}"]
1122
- self._addParam(pname_list[0], init=0.0, min=-1000, max=1000, vary=True)
1123
- for cidx in range(n_complex):
1124
- pname = f"deltac{cidx + 1}_{token}"
1125
- pname_list.append(pname)
1126
- self._addParam(pname, init=0.0, min=-1000, max=1000, vary=True)
1127
- self.analytical_obs_param_map.append(pname_list)
1128
- # specToLinear params already registered above; no further action needed.
1129
- return
854
+
1130
855
 
1131
856
  # add chemical shift fitting params
1132
857
  if self.specToDd is not None:
@@ -1172,11 +897,7 @@ class bindingModel:
1172
897
  "paramNames": list(self.params.keys()),
1173
898
  "analytical_fast_exchange": analytical_mode,
1174
899
  "analytical_topology": self.analytical_topology,
1175
- "analytical_obs_columns": list(self.analytical_obs_columns),
1176
- "analytical_obs_components": list(self.analytical_obs_components),
1177
900
  "analytical_complex_indices": list(self.analytical_complex_indices),
1178
- "analytical_obs_param_map": [list(x) for x in self.analytical_obs_param_map],
1179
- "analytical_linear_obs_param_map": [list(x) for x in self.analytical_linear_obs_param_map],
1180
901
  }
1181
902
 
1182
903
  # save settings for later plots
@@ -1228,10 +949,7 @@ class bindingModel:
1228
949
  "paramNames": list(params.keys()),
1229
950
  "analytical_fast_exchange": bool(self.analytical_fast_exchange),
1230
951
  "analytical_topology": self.analytical_topology,
1231
- "analytical_obs_columns": list(self.analytical_obs_columns),
1232
- "analytical_obs_components": list(self.analytical_obs_components),
1233
952
  "analytical_complex_indices": list(self.analytical_complex_indices),
1234
- "analytical_obs_param_map": [list(x) for x in self.analytical_obs_param_map],
1235
953
  }
1236
954
 
1237
955
  return np.array(fitfun(params, fcn_opts))
File without changes