"""
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,
)