Source code for qm_atlas.wrappers.helpers

import gzip
import logging
import re
import time
import traceback
from collections.abc import Iterable
from pathlib import Path

from rdkit import Chem
from rdkit.Chem import AllChem

from qm_atlas.software_environment import SOFTWARE_CONFIG
from qm_atlas.wrappers.common import run_command

_logger = logging.getLogger(__name__)


[docs] def ensure_local_job_server(software: str) -> None: """Ensure the local Schrödinger job server is running, if configured. Recent Schrödinger releases route local (sub)jobs through a per-user job server; anything that spawns Job Control subjobs (e.g. parallel Jaguar runs, macrocycle Prime-MCS sampling) fails with "Local job submission requires a locally running job server" when none is running. ``jsc local-server-start`` is idempotent and near-instant when the server is already up (~0.02 s), so we call it before every submission; this also self-heals if the server was stopped mid-run. Best-effort: if ``jsc`` is not configured or fails, we log and continue so the job's own error surfaces. """ software_env_manager = SOFTWARE_CONFIG.get_environment_manager() if not software_env_manager.command_available(software, "jsc"): return jsc_cmd = software_env_manager.get_command(software, "jsc") env = software_env_manager.get_run_environment(software) try: run_command(jsc_cmd, env=env) except RuntimeError as exc: _logger.warning(f"Could not start local Schrödinger job server: {exc}")
[docs] def check_licenses( software: str, feature_name: str, lic_cmd: str | None = None, ) -> tuple[int, int]: """ Checks how many licenses are available Args: software (str): the software whose license server to query feature_name (str): the feature (module) name to be searched in the output lic_cmd (str | None): the command to query the license server. Defaults to None. Returns: tuple[int, int]: the number of licenses issued and the number in use. """ software_env_manager = SOFTWARE_CONFIG.get_environment_manager() env = software_env_manager.get_run_environment(software) if lic_cmd is None: # if lictool has not been provided, assume no license limitation if not software_env_manager.command_available(software, "lictool"): return 1000, 0 lic_cmd = software_env_manager.get_command(software, "lictool") stdout, _ = run_command(lic_cmd, env=env) if stdout is None: raise RuntimeError(f"Invalid Response from license command: {lic_cmd}") match_str = ( rf"Users\s+of\s+{feature_name}:\s*\(Total\s+of\s+(\d+)\s+licenses\s+issued;" r"\s*Total\s+of\s+(\d+)\s+licenses\s+in\s+use.*$" ) regex = re.compile(match_str) lines = stdout.split("\n") for line in lines: match = regex.match(line) if match: licenses_issued, licenses_used = int(match.group(1)), int(match.group(2)) return licenses_issued, licenses_used raise RuntimeError(f"Invalid Response from license command: {lic_cmd}")
[docs] def wait_for_license( software: str, feature_name: str, lic_cmd: str | None = None, buffer: int = 1, timeout: int = 60, ): """waits until a license is available Args: software (str): The software whose license server to query. buffer (int, optional): The number of licenses to leave as a buffer. Defaults to 1. timeout (int, optional): The number of minutes after which to timeout. Defaults to 60. lic_cmd (str): The command to query the license server feature_name (str): The feature (module) name to be searched in the output Raises: TimeoutError: If a license did not become available in the allocated time. returns None """ counter = 0 while True: num_issued, num_used = check_licenses(software, feature_name, lic_cmd=lic_cmd) num_available_lic = num_issued - num_used if num_available_lic > buffer: break else: time.sleep(30) counter += 1 if counter > 2 * timeout: raise TimeoutError _logger.debug(f"{num_available_lic} available for {feature_name}. Starting")
[docs] def read_conformers_as_mols(file: Path) -> list[Chem.Mol] | None: """Reads a maegz or sdf file containing conformers of the same graph and returns them as a list of separate rdkit molecules (each with a single conformer). Args: file (Path): The file in .sdf, .sdf.gz, .mae or .maegz format Returns: list[Chem.Mol]: One rdkit molecule per conformer record in the file. """ try: if file.suffix == ".maegz": with gzip.open(file) as out_unzipped: with Chem.MaeMolSupplier(out_unzipped, removeHs=False) as supplier: confs_as_mols = [mol for mol in supplier] elif file.suffix == ".mae": with Chem.MaeMolSupplier(str(file), removeHs=False) as supplier: confs_as_mols = [mol for mol in supplier] elif file.suffix == ".sdf.gz": with gzip.open(file) as out_unzipped: with Chem.SDMolSupplier(out_unzipped, removeHs=False) as supplier: confs_as_mols = [mol for mol in supplier] elif file.suffix == ".sdf": with Chem.SDMolSupplier(str(file), removeHs=False) as supplier: confs_as_mols = [mol for mol in supplier] else: _logger.error( f"Unknown file format. Expected .maegz, .mae, .sdf.gz or .sdf, got {file.suffix}." "Exiting" ) return None return confs_as_mols except OSError: _logger.error(f"{file} empty. Exiting conformer generation") return None except Exception as exc: _logger.error(f"{file} could not be read. Exiting conformer generation") _logger.error(f"Got Exception {exc}") for line in traceback.format_exc().split("\n"): _logger.error(line) return None
[docs] def store_atom_map_numbers(mol: Chem.Mol) -> Chem.Mol: """Store the atom map numbers in the molecule. These can be used to track changes of atom indices after conformer generation. Args: mol (Chem.Mol): The input molecule. Returns: Chem.Mol: A copy of the molecule with atom map numbers stored. """ mol_with_map = Chem.Mol(mol) for atom in mol_with_map.GetAtoms(): atom.SetAtomMapNum(atom.GetIdx() + 1) return mol_with_map
[docs] def map_conformers_onto_template( template_mol: Chem.Mol, generated_mols: Iterable[Chem.Mol], ) -> Chem.Mol: """Transfer the 3D coordinates of generated conformers back onto the original molecular graph. Conformer generators may re-perceive the molecular graph (e.g. assign or remove a stereo center, reorder atoms). To guarantee that the graph is never changed, this function keeps the ``template_mol`` graph untouched and only copies the generated 3D coordinates onto it as new conformers. Atoms in the generated molecules are matched to the template via atom map numbers, which must have been stored on the molecule passed to the conformer generator using :func:`store_atom_map_numbers`. If a generated atom carries no atom map number, its position is matched by atom index instead. Args: template_mol (Chem.Mol): The molecule whose graph should be preserved. Its own conformers are ignored. generated_mols (Iterable[Chem.Mol]): The molecules produced by the conformer generator, each carrying a single conformer. Returns: Chem.Mol: A copy of ``template_mol`` carrying the generated conformers. """ return_mol = Chem.Mol(template_mol) return_mol.RemoveAllConformers() dummy_mol = Chem.Mol(return_mol) AllChem.Compute2DCoords(dummy_mol) for mol in generated_mols: if mol is None: continue generated_conf = mol.GetConformer() _mol = Chem.Mol(dummy_mol) new_conf = _mol.GetConformer() for atom in mol.GetAtoms(): map_num = atom.GetAtomMapNum() old_idx = atom.GetIdx() template_idx = map_num - 1 if map_num > 0 else old_idx new_conf.SetAtomPosition(template_idx, generated_conf.GetAtomPosition(old_idx)) return_mol.AddConformer(new_conf, assignId=True) return return_mol