Source code for qm_atlas.tasks.boltzmann
"""Boltzmann weighting and conformer-property averaging utilities.
These helpers operate on already-calculated per-conformer scalar properties.
They are used by the ``calculate_boltzmann_weights`` and
``aggregate_conformer_properties`` command-line interfaces.
"""
from collections.abc import Sequence
from typing import Literal
import numpy as np
# Ideal gas constant in J / (mol K)
R_J_PER_MOL_K = 8.314462618
# Supported energy units mapped to their conversion factor to J/mol.
EnergyUnit = Literal["hartree", "kcal/mol", "kJ/mol", "eV", "J/mol"]
ENERGY_UNIT_TO_J_PER_MOL: dict[str, float] = {
"hartree": 2625499.6394799, # 1 Hartree = 2625.4996 kJ/mol
"kcal/mol": 4184.0,
"kj/mol": 1000.0,
"ev": 96485.3321, # 96491.5666370759 Joule Per Mole
"j/mol": 1.0,
}
[docs]
def energy_to_j_per_mol(energies: Sequence[float], unit: str) -> np.ndarray:
"""Convert a sequence of energies to J/mol.
Args:
energies (Sequence[float]): Energy values in *unit*.
unit (str): The energy unit (case-insensitive). One of the keys of
:data:`ENERGY_UNIT_TO_J_PER_MOL`.
Returns:
np.ndarray: The energies converted to J/mol.
Raises:
ValueError: If *unit* is not a recognised energy unit.
"""
factor = ENERGY_UNIT_TO_J_PER_MOL.get(unit.lower())
if factor is None:
raise ValueError(
f"Unknown energy unit '{unit}'. "
f"Supported units: {sorted(ENERGY_UNIT_TO_J_PER_MOL.keys())}"
)
return np.asarray(energies, dtype=float) * factor
[docs]
def compute_boltzmann_weights(
energies: Sequence[float],
unit: str,
temperature: float,
) -> list[float]:
"""Compute normalised Boltzmann weights for a set of conformer energies.
Weights are computed relative to the lowest-energy conformer to avoid
numerical overflow, then normalised so they sum to one.
Args:
energies (Sequence[float]): Per-conformer energies in *unit*.
unit (str): Energy unit (see :func:`energy_to_j_per_mol`).
temperature (float): Temperature in Kelvin. Must be positive.
Returns:
list[float]: Normalised Boltzmann weights, one per input energy.
Raises:
ValueError: If *energies* is empty or *temperature* is not positive.
"""
if len(energies) == 0:
raise ValueError("Cannot compute Boltzmann weights for an empty set of energies")
if temperature <= 0:
raise ValueError(f"Temperature must be positive, got {temperature}")
energies_j = energy_to_j_per_mol(energies, unit)
relative = energies_j - np.min(energies_j)
exponentials = np.exp(-relative / (R_J_PER_MOL_K * temperature))
weights = exponentials / exponentials.sum()
return weights.tolist()
[docs]
def compute_relative_energies(
energies: Sequence[float],
from_unit: str,
to_unit: str,
) -> list[float]:
"""Compute per-conformer energy differences to the lowest-energy conformer.
Args:
energies (Sequence[float]): Per-conformer energies in *from_unit*.
from_unit (str): Unit of the input energies (see
:func:`energy_to_j_per_mol`).
to_unit (str): Unit in which the returned differences are expressed
(see :func:`energy_to_j_per_mol`).
Returns:
list[float]: Energy differences to the lowest-energy conformer,
expressed in *to_unit*. The lowest-energy conformer has a value of 0.
Raises:
ValueError: If *energies* is empty or a unit is not recognised.
"""
if len(energies) == 0:
raise ValueError("Cannot compute relative energies for an empty set of energies")
to_factor = ENERGY_UNIT_TO_J_PER_MOL.get(to_unit.lower())
if to_factor is None:
raise ValueError(
f"Unknown energy unit '{to_unit}'. "
f"Supported units: {sorted(ENERGY_UNIT_TO_J_PER_MOL.keys())}"
)
energies_j = energy_to_j_per_mol(energies, from_unit)
relative_j = energies_j - np.min(energies_j)
return (relative_j / to_factor).tolist()
[docs]
def plain_average(
values: Sequence[float] | np.ndarray, axis: int | None = None
) -> float | np.ndarray:
"""Return the arithmetic mean of *values*.
Args:
values: Per-conformer property values. May be multi-dimensional, in
which case *axis* selects the axis to average over.
axis (int | None): Axis to average over. ``None`` (default) averages the
flattened input and returns a float.
Returns:
float | np.ndarray: The unweighted average; a float when the result is
scalar, otherwise an array.
Raises:
ValueError: If *values* is empty.
"""
arr = np.asarray(values, dtype=float)
count = arr.shape[axis] if axis is not None else arr.size
if count == 0:
raise ValueError("Cannot average an empty set of values")
result = np.mean(arr, axis=axis)
return float(result) if np.ndim(result) == 0 else result
[docs]
def weighted_average(
values: Sequence[float] | np.ndarray,
weights: Sequence[float] | np.ndarray,
axis: int | None = None,
) -> float | np.ndarray:
"""Return the weighted mean of *values* using *weights*.
Weights are normalised internally, so they need not sum to one.
Args:
values: Per-conformer property values. May be multi-dimensional, in
which case *axis* selects the axis to average over.
weights: Per-conformer weights (e.g. Boltzmann weights), one per entry
along *axis*.
axis (int | None): Axis to average over. ``None`` (default) averages the
flattened input and returns a float.
Returns:
float | np.ndarray: The weighted average; a float when the result is
scalar, otherwise an array.
Raises:
ValueError: If the inputs are empty, differ in length, or the weights
sum to zero.
"""
values_arr = np.asarray(values, dtype=float)
weights_arr = np.asarray(weights, dtype=float)
count = values_arr.shape[axis] if axis is not None else values_arr.size
if count == 0:
raise ValueError("Cannot average an empty set of values")
if weights_arr.size != count:
raise ValueError(
f"values and weights must have the same length, got {count} and {weights_arr.size}"
)
if weights_arr.sum() == 0:
raise ValueError("Sum of weights is zero; cannot compute weighted average")
result = np.average(values_arr, axis=axis, weights=weights_arr)
return float(result) if np.ndim(result) == 0 else result