clophfit.prenspire.mcr#

MCR-ALS: multivariate curve resolution by alternating least squares.

Factor the corrected spectrum matrix of one sample/temperature, D[n_wells, n_lambda], into non-negative concentrations and spectra:

D ~= C @ S.T      C >= 0, S >= 0, rows of C closed (sum to 1)

Alternating non-negative least squares (scipy nnls) solves one factor with the other fixed; closure (C rows normalised to 1) pins the rotational freedom that ordinary PCA leaves open, which is what makes the recovered species spectra interpretable without a mechanistic model.

The pKa is read off afterwards: the component whose closed profile tracks the titrant (largest |Spearman rho|) is fitted against a Henderson-Hasselbalch fraction, profile ~= F0 + (F1 - F0)/(1 + 10**(K - x)), leaving K (and a residual affine mixing) free.

Contrast with clophfit.fitting.spectral.fit_spectra_global(): there the two-state model is the factorisation (one K, species spectra solved linearly). MCR-ALS relaxes the profile shape - it can expose a third species or a non-Hill transition - at the price of a less tight K. fit_spectra_global’s sv_ratio says when that trade is worth it.

Classes#

McrFit

A converged MCR-ALS factorisation and its reconstruction quality.

StackedScans

The scans of one plate, joined into one data matrix.

Functions#

dead_components(c, *[, absolute, ratio])

Find the concentration columns that carry no well-to-well change.

mcr_als(d[, n_species, closure, max_iter, tol, s_init])

Resolve D [n_wells, n_lambda] into C [n_wells, k] and S [n_lambda, k].

model_fraction(x, k, *[, is_ph])

Return the model's bound fraction on the 0-1 scale.

component_pka(x, c, *[, is_ph])

Equilibrium constant of the component that tracks the titrant.

stack_scans(scans)

Join per-scan (wavelengths, Y[n_lambda, n_wells]) blocks into one matrix.

scan_blocks(stacked)

Map each scan name to the column block it occupies.

species_by_scan(spec, n_species)

Split the resolved species spectra into one curve set per scan.

mcr_titration(spectra_by_temp, x_by_temp, *[, ...])

MCR-ALS every temperature's scans; return (K table, spectra, profiles).

Module Contents#

class clophfit.prenspire.mcr.McrFit#

Bases: NamedTuple

A converged MCR-ALS factorisation and its reconstruction quality.

c: numpy.ndarray#

[n_wells, n_species] closed, non-negative concentration profiles.

s: numpy.ndarray#

[n_species, n_lambda] non-negative species spectra (arbitrary scale).

rel_err: float#

||D - C S.T|| / ||D||.

dead_components: tuple[int, ...] = ()#

Components whose profile never varies across the wells.

A dead component is the signature of the MCR scale degeneracy: the species was parked at an arbitrary scale while its concentration column sat at a constant, so it holds no titration information.

property degenerate: bool#

Whether any component is dead, i.e. the decomposition is unusable.

rel_err cannot detect this: a spurious component is a free lamp that lowers the residual while contributing nothing, so a degenerate fit can report a better error than a sound one.

Return type:

bool

clophfit.prenspire.mcr.dead_components(c, *, absolute=_DEAD_PTP_ABS, ratio=_DEAD_PTP_RATIO)#

Find the concentration columns that carry no well-to-well change.

A component whose profile is the same number in every well cannot be fitted for a K: the curve would be fitted to round-off, and the result is a confident, meaningless number.

A column counts as dead when it varies by less than the absolute floor, or by less than a small fraction of the most mobile column. The absolute floor catches the case where every column is dead; the relative term catches a redundant column parked on a scale that is not itself tiny.

Parameters:
  • c (np.ndarray) – Closed concentration profiles [n_wells, n_species].

  • absolute (float) – Peak-to-peak below which a column counts as dead outright.

  • ratio (float) – A column whose peak-to-peak is below this fraction of the most mobile column’s also counts as dead.

Returns:

The indices of the dead columns, in order.

Return type:

tuple[int, …]

clophfit.prenspire.mcr.mcr_als(d, n_species=2, *, closure=True, max_iter=_MAX_ITER, tol=_TOL, s_init=None)#

Resolve D [n_wells, n_lambda] into C [n_wells, k] and S [n_lambda, k].

Parameters:
  • d (np.ndarray) – Data matrix, one row per well, one column per wavelength (already corrected: buffer-subtracted).

  • n_species (int) – Number of components to resolve.

  • closure (bool) – Normalise each row of C to sum to 1 (fractions), pinning rotation.

  • max_iter (int) – ALS sweeps per start.

  • tol (float) – Relative error change that counts as converged.

  • s_init (np.ndarray | None) – Optional extra spectra start [n_lambda, k] - e.g. the two extreme wells of a titration. ALS from a single start is a non-convex problem; all starts (this one, clipped and absolute PCA loadings) are run and the lowest-error factorisation returned.

Returns:

Non-negative factors and the relative reconstruction error.

Return type:

McrFit

Raises:

ValueError – If the requested number of species exceeds the matrix rank bound.

clophfit.prenspire.mcr.model_fraction(x, k, *, is_ph=True)#

Return the model’s bound fraction on the 0-1 scale.

This is the quantity the figure draws over the closed profiles, and the one the component is compared against. Keeping it a fraction on one scale means the curve and the points it overlays share an axis, so the distance between them is a real measure of fit quality.

Parameters:
  • x (np.ndarray) – Titrant values.

  • k (float) – pKa for a pH titration, Kd in the units of x for a ligand one.

  • is_ph (bool) – Henderson-Hasselbalch fraction (pH) if true, Langmuir (ligand) else.

Returns:

The fraction in [0, 1] for every value of x.

Return type:

np.ndarray

clophfit.prenspire.mcr.component_pka(x, c, *, is_ph=True)#

Equilibrium constant of the component that tracks the titrant.

Returns K on the natural scale of the model fitted to the component: pKa for a pH titration (Henderson-Hasselbalch fraction), Kd in the units of x for a ligand titration (Langmuir fraction - linear, so no log-space propagation happens here). The fitted curve parameters (F0, F1) are the tracking component’s own plateau pair, i.e. the affine mixing that maps the model fraction onto that component’s scale; they are properties of the component, not of the model, and they are free because the component’s baseline need not sit at 0 and its saturation need not sit at 1.

Parameters:
  • x (np.ndarray) – Titrant per well.

  • c (np.ndarray) – Closed concentration profiles [n_wells, k].

  • is_ph (bool) – Henderson-Hasselbalch fraction (pH) if true, Langmuir (ligand) else.

Returns:

(K, standard error, F0, F1, component index, signed rho of that component). With k = 2 a positively tracking component is preferred: under closure the columns are complements, and the signed choice keeps the reported component index stable instead of arbitrarily picking whichever ties first on |rho|.

Return type:

tuple[float, float, float, float, int, float]

class clophfit.prenspire.mcr.StackedScans#

Bases: NamedTuple

The scans of one plate, joined into one data matrix.

The columns keep one block per scan: the scans are different physical measurements, so they do not share a wavelength axis.

tags: numpy.ndarray#

Scan name of every column.

wavelengths: numpy.ndarray#

Wavelength of every column, in its own scan’s units.

data: numpy.ndarray#

D[n_wells, n_columns], each scan scaled by the common maximum.

clophfit.prenspire.mcr.stack_scans(scans)#

Join per-scan (wavelengths, Y[n_lambda, n_wells]) blocks into one matrix.

The scans of one plate are separate physical measurements - an excitation scan and an emission scan are different observables - and they do not share one wavelength axis: their ranges overlap but the values at an overlapping wavelength are not the same measurement. Stacking them therefore keeps one column block per scan, tagged with the scan it came from, rather than gluing them into a single ramp that runs backwards across the seam.

Parameters:

scans (Sequence[tuple[str, tuple[ArrayF, ArrayF]]]) – One (scan name, (wavelengths, Y[n_lambda, n_wells])) block per scan.

Returns:

The scan tag and the wavelength of every column, and the data matrix itself, each scan scaled by the common maximum so blocks stay commensurable.

Return type:

StackedScans

clophfit.prenspire.mcr.scan_blocks(stacked)#

Map each scan name to the column block it occupies.

Parameters:

stacked (StackedScans) – The stacked scans.

Returns:

Scan name to the slice of columns it holds, in order of appearance.

Return type:

dict[str, slice]

clophfit.prenspire.mcr.species_by_scan(spec, n_species)#

Split the resolved species spectra into one curve set per scan.

The species spectra of a scan are that scan’s own measurement: an excitation scan’s species spectrum is an excitation spectrum, and the emission scan’s is an emission spectrum. Drawing them on one axis would put two different observables on the same ramp.

Parameters:
  • spec (pd.DataFrame) – The resolved spectra, with scan, lambda and one column per species.

  • n_species (int) – How many species columns to read.

Returns:

Scan name to (wavelengths, one array per species).

Return type:

dict[str, tuple[np.ndarray, list[np.ndarray]]]

clophfit.prenspire.mcr.mcr_titration(spectra_by_temp, x_by_temp, *, n_species=2, closure=True, is_ph=True)#

MCR-ALS every temperature’s scans; return (K table, spectra, profiles).

Parameters:
  • spectra_by_temp (dict[str, Sequence[tuple[str, tuple[ArrayF, ArrayF]]]]) – Temperature label to its scans, each (name, (lambda, Y[n_lambda, n_wells])).

  • x_by_temp (dict[str, ArrayF]) – Titrant value per well, same temperature keys.

  • n_species (int) – Components resolved per temperature.

  • closure (bool) – Constrain profiles to fractions.

  • is_ph (bool) – Titrant axis kind: pH (Henderson-Hasselbalch) or ligand (Langmuir).

Returns:

One table row per (temp): K, se, component, rho, rel_err, n; per temperature a table of the resolved species spectra, tagged with the scan each row came from; and per temperature the titrant with the closed profiles, for plotting.

Return type:

tuple[pd.DataFrame, dict[str, pd.DataFrame], dict[str, tuple[ArrayF, np.ndarray]]]