Source code for cinnabar.measurements

"""
Measurements
============

Contains the :class:`Measurement` class which is used to define a single free energy difference,
as well as the :class:`ReferenceState` class which denotes the end point for absolute measurements.

"""

from dataclasses import dataclass
from typing import Hashable, cast

from openff.units import Quantity, unit

from cinnabar.conversion import convert_observable


[docs] class ReferenceState: """A label indicating a reference point to which absolute measurements are relative A ``ReferenceState`` optionally has a label, which is used to differentiate it to other absolute measurements that might be relative to a different reference point. E.g. MLE estimations are against an arbitrary reference state that can be linked to the reference point of experiments. """ label: str def __init__(self, label: str = ""): """ Parameters ---------- label: str, default "" Label for this reference point. If no label is given, an empty string is used, signifying the "true zero" reference point. """ self.label = label
[docs] def is_true_ground(self) -> bool: """If this ReferenceState is the zero point of all other measurements""" return not self.label
def __repr__(self): if self.is_true_ground(): return "<ReferenceState Zero>" else: return f"<ReferenceState ({self.label})>" def __eq__(self, other): return other.__class__ == self.__class__ and other.label == self.label def __hash__(self): return hash(self.label)
[docs] @dataclass(frozen=True) class Measurement: """The free energy difference of moving from A to B All quantities are accompanied by units, to prevent mix-ups associated with kcal and kJ. This is done via the `openff.units` package:: >>> m = Measurement(labelA='LigandA', labelB='LigandB', ... DG=2.4 * unit.kilocalorie_per_mol, ... uncertainty=0.2 * unit.kilocalorie_per_mol, ... computational=True, ... source='gromacs') Alternatively strings are automatically coerced into quantities, making this equivalent to above:: >>> m = Measurement(labelA='LigandA', labelB='LigandB', ... DG='2.4 kcal/mol', ... uncertainty='0.2 kcal/mol', ... computational=True, ... source='gromacs') Where a measurement is "absolute" then a `ReferenceState` can be used as the label at one end of the measurement. I.e. it is relative to a reference ground state. E.g. an absolute measurement for "LigandA" is defined as:: >>> m = Measurement(labelA=ReferenceState(), labelB='LigandA', ... DG=-11.2 * unit.kilocalorie_per_mol, ... uncertainty=0.3 * unit.kilocalorie_per_mol, ... computational=False) """ labelA: Hashable """Label of state A, e.g. a ligand name or any hashable Python object""" labelB: Hashable """Label of state B""" DG: Quantity """The free energy difference of moving from A to B in kcal/mol""" uncertainty: Quantity """The uncertainty of the DG measurement in kcal/mol""" computational: bool """If this measurement is computationally based (or experimental)""" source: str = "" """An arbitrary label to group measurements from a common source""" temperature: Quantity = 298.15 * unit.kelvin """Temperature that the measurement was taken at in K. By default: 298 K (298.15 * unit.kelvin)""" def __init__( self, labelA: Hashable, labelB: Hashable, DG: Quantity | str, uncertainty: Quantity | str, computational: bool, source: str = "", temperature: Quantity | str = 298.15 * unit.kelvin, ): """ Initialize a Measurement object converting all quantities to the correct default units. Parameters ---------- labelA, labelB: Hashable Label of the A/B state e.g. a ligand name. DG: openff.units.Quantity | str The free energy difference of moving from A to B in units compatible with kcal/mol. uncertainty: openff.units.Quantity | str The uncertainty of the DG measurement in units compatible with kcal/mol. computational: bool If this measurement is computationally based ``True`` (or experimental ``False``). source: str, default "" An arbitrary label to group measurements from a common source, by default an empty string. temperature: openff.units.Quantity | str, default 298.15 * unit.kelvin Temperature that the measurement was taken at in K. """ # This dataclass used to be based on pydantic and could automatically convert units from strings # we now do this manually to avoid the dependency and not break old behavior. unit_values = [DG, uncertainty, temperature] for i in range(len(unit_values)): if isinstance(unit_values[i], str): # convert inplace to a quantity with units unit_values[i] = Quantity(unit_values[i]) elif isinstance(unit_values[i], (float, int)): raise ValueError("DG, uncertainty, and temperature values must have units. Check input.") # unpack the converted values, cast to make mypy happy DG, uncertainty, temperature = (cast(Quantity, v) for v in unit_values) object.__setattr__(self, "labelA", labelA) object.__setattr__(self, "labelB", labelB) object.__setattr__(self, "DG", DG.to(unit.kilocalorie_per_mole)) object.__setattr__(self, "uncertainty", uncertainty.to(unit.kilocalorie_per_mole)) object.__setattr__(self, "computational", computational) object.__setattr__(self, "source", source) object.__setattr__(self, "temperature", temperature.to(unit.kelvin))
[docs] @classmethod def from_experiment( cls, label: str | Hashable, Ki: Quantity | str, uncertainty: Quantity | str = 0 * unit.nanomolar, *, source: str = "", temperature: Quantity | str = 298.15 * unit.kelvin, ): """Shortcut to create a Measurement from experimental data Can perform conversion from Ki values to kcal/mol values. Parameters ---------- label: str | Hashable Label for this data point. Ki: openff.units.Quantity | str Experimental Ki value ex.: 500 * unit.nanomolar OR 0.5 * unit.micromolar or "0.5 * unit.nanomolar" uncertainty: openff.units.Quantity | str Uncertainty of the experimental value default is zero if no uncertainty is provided (0 * unit.nanomolar) source: str, default "" Source of experimental measurement, by default an empty string. temperature: openff.units.Quantity | str, default 298.15 * unit.kelvin Temperature in K at which the experimental measurement was carried out. """ # check for units unit_values = [Ki, uncertainty, temperature] for i in range(len(unit_values)): if isinstance(unit_values[i], str): # try to convert inplace to a quantity with units where possible unit_values[i] = Quantity(unit_values[i]) elif isinstance(unit_values[i], (float, int)): raise ValueError("Ki, uncertainty, and temperature values must have units. Check input.") # unpack with units again, cast to make mypy happy Ki, uncertainty, temperature = (cast(Quantity, v) for v in unit_values) if Ki > 0 * unit.molar: if uncertainty >= 0 * unit.molar: DG, uncertainty_DG = convert_observable( value=Ki, uncertainty=uncertainty, original_type="ki", final_type="dg", temperature=temperature ) else: raise ValueError("Uncertainty cannot be negative. Check input.") else: raise ValueError("Ki value cannot be zero or negative. Check if dG value was provided instead of Ki.") return cls( labelA=ReferenceState(), labelB=label, DG=DG, uncertainty=uncertainty_DG, # type: ignore[arg-type] computational=False, source=source, temperature=temperature, )