import math
import re
from dataclasses import dataclass
from functools import partial
from itertools import product
from pathlib import Path
import numpy as np
import pandas as pd
from more_itertools import batched
from ppqm.utils import functools as ppqm_functools
from qm_atlas.wrappers.cosmotherm import cosmo_utils, interface
SOLUBILITY_SETTINGS = cosmo_utils.CosmoSettings(
action_lines=[], # overwritten in code
command_lines=["unit notempty ehfile wtln"],
comment_line="!! solubility calculation !!",
)
NUM_STEPS_BINARY_MIXTURE = 11
NUM_STEPS_TERNARY_MIXTURE = 7
# global variables for parsing of multi-solvent cosmotherm output
SOLVENT_COL = "Solvent"
MOL_WEIGHT_COL = "Solvent_MolWeight"
NEW_SOLVENT_COL = "Solvent {}"
MOLAR_FRAC_COL = "Molar Fraction {}"
MASS_FRAC_COL = "Mass Fraction {}"
[docs]
@dataclass
class ReferenceSolubility:
"""class to hold data about measured solubility in a solvent or solvent mixture."""
value: float
solvent_names: list[str]
temperature: float
mass_fractions: None | list[float] = None
solubility_unit: str = "c"
def __post_init__(self):
if len(self.solvent_names) == 1 and self.mass_fractions is None:
self.mass_fractions = [1.0]
if self.mass_fractions is None:
raise ValueError(
"Mass fractions must be provided for reference solubilities in multiple solvents"
)
if len(self.solvent_names) != len(self.mass_fractions):
raise ValueError("Number of solvents and mass fractions must match")
[docs]
def calculate_solubility(
compound_conformers: list[str | Path],
solvent_names: list[str],
melting_temperature_C: float | None = None,
melting_enthalpy_kJ_per_mol: float | None = None,
level: str = "bp-tzvpd",
references: list[ReferenceSolubility] | None = None,
options: cosmo_utils.CosmoSettings = SOLUBILITY_SETTINGS,
solvents_dirs: list[Path] | None = None,
temperature: float = 25.0,
output_units: str = "wsolout_c lsolout_gl",
n_cores: int = 1,
use_config_cosmo_dir: bool = True,
**kwargs,
) -> pd.DataFrame:
"""
This function calculates the solubility of compounds in pure solvents
using Cosmotherm. The method and example applications are described in reference [1].
Args:
compound_conformers (list[str | Path]):
List of compound conformers, either specifying the path to the file or
providing the contents as a string. In the latter case, the file must be
a .cosmo file, otherwise it can be any file that cosmotherm accepts.
solvent_names (list[str]):
List of solvents, specified by their names. Corresponding .cosmo files must
be found in the database, either the standard cosmotherm one or an
additional, user-provided one.
melting_temperature_C (float | None):
The experimental melting temperature of the compound in degree Celsius. This
can be used to determine the heat of fusion. Defaults to None, in this case
no information on the melting temperature is passed to cosmotherm.
melting_enthalpy_kJ_per_mol (float | None):
The experimental melting enthalpy of the compound. This
can be used to determine the heat of fusion. Defaults to None, in this case
no information on the melting enthalpy is passed to cosmotherm.
level (str, optional):
Level of theory. Defaults to "bp-tzvpd".
references (list[ReferenceSolubility], optional):
List of reference solubilities. Defaults to None.
options (CosmoSettings, optional):
Settings for the Cosmo task. Defaults to :data:`SOLUBILITY_SETTINGS`.
solvents_dirs (list[Path] | None, optional):
Additional directories to find the .cosmo files for the solvents.
Defaults to None, i.e. just relying on the cosmotherm database. If
additional directories are considered, the cosmotherm database is searched
first, the the solvents_dirs.
temperature (float, optional):
Temperature for the calculation in °C. Defaults to 25.
output_units (str, optional):
Units for the output. Defaults to "wsolout_c lsolout_gl".
Check the cosmotherm documentation to see which strings can be used.
n_cores (int, optional):
Number of cores to use for the calculation. Defaults to 1.
use_config_cosmo_dir (bool, optional):
Whether to prepend the solvent directories from the software configuration
file to the user-provided solvents_dirs. If False, only the user-provided
directories are used. Defaults to True.
**kwargs: Additional keyword arguments for the output reader.
Returns:
pd.DataFrame: A DataFrame containing the results of the solubility calculation.
References:
[1] Loschen, C. & Klamt, A. Solubility prediction, solvate and cocrystal
screening as tools for rational crystal engineering.
*J. Pharm. Pharmacol.* **67**, 803-811 (2015).
https://doi.org/10.1111/jphp.12376
"""
if (melting_temperature_C is not None) != (melting_enthalpy_kJ_per_mol is not None):
raise ValueError("Need to provide both melting temperature and enthalpy")
n_procs = min(n_cores, len(solvent_names))
# split solvents in chunks
batch_size = math.ceil(len(solvent_names) / n_procs)
solvent_batches = list(batched(solvent_names, batch_size))
calculator_function = partial(
_calculate_solubility,
compound_conformers=compound_conformers,
melting_temperature_C=melting_temperature_C,
melting_enthalpy_kJ_per_mol=melting_enthalpy_kJ_per_mol,
level=level,
references=references,
options=options,
solvents_dirs=solvents_dirs,
temperature=temperature,
output_units=output_units,
use_config_cosmo_dir=use_config_cosmo_dir,
**kwargs,
)
if len(solvent_batches) == 1:
dataframes = [calculator_function(solvent_batches[0])]
else:
dataframes = ppqm_functools.func_parallel(
calculator_function,
solvent_batches,
n_cores=n_procs,
title="Cosmo_Solubility",
)
return pd.concat(dataframes, ignore_index=True)
def _calculate_solubility(
solvent_names: list[str],
compound_conformers: list[str | Path],
melting_temperature_C: float | None = None,
melting_enthalpy_kJ_per_mol: float | None = None,
level: str = "bp-tzvpd",
references: list[ReferenceSolubility] | None = None,
options: cosmo_utils.CosmoSettings = SOLUBILITY_SETTINGS,
solvents_dirs: list[Path] | None = None,
temperature: float = 25.0,
output_units: str = "wsolout_c lsolout_gl",
use_config_cosmo_dir: bool = True,
**kwargs,
) -> pd.DataFrame:
if references is None:
references = list()
# determine mode of calculation. Will get overwritten to iterative
# if melting enthalpy and temperature are provided
cosmo_key = "relative" if len(references) == 0 else "iterative"
cosmo_utils.check_input_conformers(compound_conformers)
cosmo_paths = interface.get_cosmo_paths(level=level)
cosmo_dirs = cosmo_utils.get_databases(
cosmo_paths,
level=level,
other_databases=solvents_dirs,
use_config_cosmo_dir=use_config_cosmo_dir,
)
# organize solvents
input_table = SolventInputTable(cosmo_dirs)
input_table.add_solvents(solvent_names)
for reference in references:
input_table.add_solvents(reference.solvent_names)
input_table.finish_setup()
conformer_names: list[str | None] = [None]
api_prop = ""
if melting_temperature_C is not None and melting_enthalpy_kJ_per_mol is not None:
api_prop += f"Tmelt_C={melting_temperature_C}"
api_prop += f" DHfus_SI={melting_enthalpy_kJ_per_mol}"
cosmo_key = "iterative"
additional_properties_list = [api_prop]
conformers: list[list[str | Path]] = [compound_conformers]
for solvent_name, cosmo_files in input_table.iterate_solvents():
conformers.append(cosmo_files)
conformer_names.append(solvent_name)
additional_properties_list.append("DGfus=0")
action_line = input_table.get_solubility_action_line(
solvent_names, cosmo_key, references, temperature, output_units
)
command_lines = options.get_command_lines(cosmo_paths)
comment_line = options.comment_line
res_df_list = interface.calculate_cosmotherm(
conformers,
[action_line],
command_lines=command_lines,
comment_line=comment_line,
table_start_patterns=["Nr Solvent"],
conformer_names=conformer_names,
additional_properties_list=additional_properties_list,
**kwargs,
)
return res_df_list[0]
[docs]
def calculate_solubility_mixture(
compound_conformers: list[str | Path],
solvent_combinations: list[list[str]],
melting_temperature_C: float | None = None,
melting_enthalpy_kJ_per_mol: float | None = None,
level: str = "bp-tzvpd",
references: list[ReferenceSolubility] | None = None,
options: cosmo_utils.CosmoSettings = SOLUBILITY_SETTINGS,
solvents_dirs: list[Path] | None = None,
temperature: float = 25.0,
output_units: str = "wsolout_c lsolout_gl",
num_steps_binary: int = NUM_STEPS_BINARY_MIXTURE,
num_steps_ternary: int = NUM_STEPS_TERNARY_MIXTURE,
optimize: bool = False,
n_cores: int = 1,
use_config_cosmo_dir: bool = True,
**kwargs,
) -> pd.DataFrame:
"""
This function calculates the solubility of compounds in binary or ternary mixtures
of solvents using Cosmotherm. For each solvent combination, it goes through
equally spaced mixture ratios (in terms of mass fractions). The number of
steps for this spacing can be controlled via the arguments
num_steps_binary and num_steps ternary.
The method and example applications are described in reference [1].
Args:
compound_conformers (list[str | Path]):
List of compound conformers, either specifying the path to the file or
providing the contents as a string. In the latter case, the file must be
a .cosmo file, otherwise it can be any file that cosmotherm accepts.
solvent_combinations (list[list[str]]):
List of solvent combinations, each combination is a list of solvents,
specified by their names. Corresponding .cosmo files must be found
in the database, either the standard cosmotherm one or an additional,
user-provided one.
melting_temperature_C (float | None):
The experimental melting temperature of the compound in degree Celsius. This
can be used to determine the heat of fusion. Defaults to None, in this case
no information on the melting temperature is passed to cosmotherm.
melting_enthalpy_kJ_per_mol (float | None):
The experimental melting enthalpy of the compound. This
can be used to determine the heat of fusion. Defaults to None, in this case
no information on the melting enthalpy is passed to cosmotherm.
level (str, optional):
Level of theory. Defaults to "bp-tzvpd".
references (list[ReferenceSolubility], optional):
List of reference solubilities. Defaults to None.
options (CosmoSettings, optional):
Settings for the Cosmo task. Defaults to :data:`SOLUBILITY_SETTINGS`.
solvents_dirs (list[Path] | None, optional):
Additional directories to find the .cosmo files for the solvents.
Defaults to None, i.e. just relying on the cosmotherm database. If
additional directories are considered, the cosmotherm database is searched
first, the the solvents_dirs.
temperature (float, optional):
Temperature for the calculation in °C. Defaults to 25.
output_units (str, optional):
Units for the output. Defaults to "wsolout_c lsolout_gl".
Check the cosmotherm documentation to see which strings can be used.
num_steps_binary (int, optional):
Number of steps for binary mixtures. Defaults to :data:`NUM_STEPS_BINARY_MIXTURE`.
num_steps_ternary (int, optional):
Number of steps for ternary mixtures. Defaults to :data:`NUM_STEPS_TERNARY_MIXTURE`.
optimize (bool):
Whether to ask cosmotherm to ptimize the mixture ratios. Note that in this
case no mixture ratios will be screened. Defaults to False.
n_cores (int, optional):
Number of cores to use for the calculation. Defaults to 1.
use_config_cosmo_dir (bool, optional):
Whether to prepend the solvent directories from the software configuration
file to the user-provided solvents_dirs. If False, only the user-provided
directories are used. Defaults to True.
**kwargs: Additional keyword arguments for the output reader.
Returns:
pd.DataFrame: A DataFrame containing the results of the solubility calculation.
References:
[1] Loschen, C. & Klamt, A. Solubility prediction, solvate and cocrystal
screening as tools for rational crystal engineering.
*J. Pharm. Pharmacol.* **67**, 803-811 (2015).
https://doi.org/10.1111/jphp.12376
"""
if (melting_temperature_C is not None) != (melting_enthalpy_kJ_per_mol is not None):
raise ValueError("Need to provide both melting temperature and enthalpy")
n_procs = min(n_cores, len(solvent_combinations))
calculator_function = partial(
_calculate_solubility_mixture,
compound_conformers=compound_conformers,
melting_temperature_C=melting_temperature_C,
melting_enthalpy_kJ_per_mol=melting_enthalpy_kJ_per_mol,
level=level,
references=references,
options=options,
solvents_dirs=solvents_dirs,
temperature=temperature,
output_units=output_units,
num_steps_binary=num_steps_binary,
num_steps_ternary=num_steps_ternary,
optimize=optimize,
use_config_cosmo_dir=use_config_cosmo_dir,
**kwargs,
)
if n_procs == 1:
dataframes = [
calculator_function(solvents, n_cores=n_cores) for solvents in solvent_combinations
]
else:
dataframes = ppqm_functools.func_parallel(
calculator_function,
solvent_combinations,
n_cores=n_procs,
title="Cosmo_Solubility",
)
return pd.concat(dataframes, ignore_index=True)
def _calculate_solubility_mixture(
solvent_names: list[str],
compound_conformers: list[str | Path],
melting_temperature_C: float | None = None,
melting_enthalpy_kJ_per_mol: float | None = None,
level: str = "bp-tzvpd",
references: list[ReferenceSolubility] | None = None,
options: cosmo_utils.CosmoSettings = SOLUBILITY_SETTINGS,
solvents_dirs: list[Path] | None = None,
temperature: float = 25.0,
output_units: str = "wsolout_c lsolout_gl",
num_steps_binary: int = NUM_STEPS_BINARY_MIXTURE,
num_steps_ternary: int = NUM_STEPS_TERNARY_MIXTURE,
optimize: bool = False,
use_config_cosmo_dir: bool = True,
**kwargs,
) -> pd.DataFrame:
if references is None:
references = list()
if len(solvent_names) not in [2, 3]:
raise ValueError("Mixtures must have two or three components")
# determine mode of calculation. Will get overwritten to iterative
# if melting enthalpy and temperature are provided
cosmo_key = "relative" if len(references) == 0 else "iterative"
cosmo_utils.check_input_conformers(compound_conformers)
cosmo_paths = interface.get_cosmo_paths(level=level)
cosmo_dirs = cosmo_utils.get_databases(
cosmo_paths,
level=level,
other_databases=solvents_dirs,
use_config_cosmo_dir=use_config_cosmo_dir,
)
# organize solvents
input_table = SolventInputTable(cosmo_dirs)
input_table.add_solvents(solvent_names)
for reference in references:
input_table.add_solvents(reference.solvent_names)
input_table.finish_setup()
conformer_names = [None]
api_prop = ""
if melting_temperature_C is not None and melting_enthalpy_kJ_per_mol is not None:
api_prop += f"Tmelt_C={melting_temperature_C}"
api_prop += f" DHfus_SI={melting_enthalpy_kJ_per_mol}"
cosmo_key = "iterative"
additional_properties_list = [api_prop]
conformers = [compound_conformers]
for solvent_name, cosmo_files in input_table.iterate_solvents():
conformers.append(cosmo_files)
conformer_names.append(solvent_name)
additional_properties_list.append("DGfus=0")
action_line = input_table.get_solubility_action_line_mixture(
solvent_names,
cosmo_key,
references,
temperature,
output_units,
num_steps_binary=num_steps_binary,
num_steps_ternary=num_steps_ternary,
optimize=optimize,
)
command_lines = options.get_command_lines(cosmo_paths)
comment_line = options.comment_line
res_df_list = interface.calculate_cosmotherm(
conformers,
[action_line],
command_lines=command_lines,
comment_line=comment_line,
conformer_names=conformer_names,
additional_properties_list=additional_properties_list,
table_start_patterns=["Nr Solvent"],
multi_solvent=True,
**kwargs,
)
return res_df_list[0]
[docs]
def parse_solvent_row(row_entry: str) -> tuple[list[str], list[float]]:
"""Parses a row entry from a Cosmotherm solubility calculation output.
The row entry has the form
"solvent_1 solvent_2 X ={ frac_1 frac_2 }"
for a binary mixture, but ternatry mixtures or pure solvents are handled as
well.
Args:
row_entry (str): A single solvent row from the cosmotherm solubility output.
Returns:
list[str]: A list of solvent names
list[float]: The corresponding molar fractions
"""
data_list = re.findall(r"[\w-]+", row_entry)
molar_fractions = re.findall(r"\d+\.\d+", row_entry)
# If it's a pure solvent
if not molar_fractions:
molar_fractions = [1.0]
else:
molar_fractions = list(map(float, molar_fractions))
return data_list[: len(molar_fractions)], molar_fractions
[docs]
def calculate_mass_fractions(
molar_fractions: list[float],
molar_weights: list[float],
) -> list[float]:
"""Converts molar fractions to mass fractions given the molar weights of the components.
Args:
molar_fractions (list[float]): Molar fractions of the components
molar_weights (list[float]): Molar weights of the components
Returns:
list[float]: Mass fractions of the components
"""
total_mass = sum(mf * mw for mf, mw in zip(molar_fractions, molar_weights))
return [mf * mw / total_mass for mf, mw in zip(molar_fractions, molar_weights)]
[docs]
def parse_solubility_data(
sol_df: pd.DataFrame,
num_solvents: int = 2,
) -> pd.DataFrame:
"""Parses the output dataframe of a cosmotherm solubility calculation to obtain
a more readable version for binary or ternary mixtures.
The cosmotherm solvent row has the form
"solvent_1 solvent_2 X ={ frac_1 frac_2 }"
this is converted to two (or more) additional columns containing the names,
molar fractions and mass fractions of the mixtures.
Args:
sol_df (pd.DataFrame):
The output dataframe of a solubility calculation
num_solvents (int, optional):
The maximum number of pure solvents in the mixtures. Defaults to 2.
Raises:
ValueError: If there are more solvents in a mixture than the specified maximum.
Returns:
pd.DataFrame: The parsed dataframe
"""
# Initialize new columns
parsed_df = sol_df.copy()
new_col_names = [NEW_SOLVENT_COL, MOLAR_FRAC_COL, MASS_FRAC_COL]
for col_name, i in product(new_col_names, range(1, num_solvents + 1)):
parsed_df[col_name.format(i)] = None
# Create a dictionary to store the molar weights of the solvents
solvent_molar_weights = extract_molar_weights(sol_df)
# Calculate molar and mass fractions
for index, row in sol_df.iterrows():
row_entry = row[SOLVENT_COL]
solvents, molar_fractions = parse_solvent_row(row_entry)
if len(solvents) > num_solvents:
raise ValueError(f"Line {index} contains more than {num_solvents} solvents")
molar_weights = [solvent_molar_weights[solvent_name] for solvent_name in solvents]
mass_fractions = calculate_mass_fractions(molar_fractions, molar_weights)
for idx, (solvent_name, molar_fraction, mass_fraction) in enumerate(
zip(solvents, molar_fractions, mass_fractions), start=1
):
parsed_df.at[index, NEW_SOLVENT_COL.format(idx)] = solvent_name
parsed_df.at[index, MOLAR_FRAC_COL.format(idx)] = molar_fraction
parsed_df.at[index, MASS_FRAC_COL.format(idx)] = mass_fraction
return parsed_df