Source code for mfml_qc.oracles.orca

import numpy as np
import os
import subprocess
import glob
from typing import Dict, Any, Union
from .base import QuantumEngine


[docs] class OrcaEngine(QuantumEngine): """Custom quantum chemistry engine for ORCA calculations. This engine handles the generation of ORCA input files, execution of the ORCA binary via subprocess, and provides a simple parsing of the resulting output files including energies, gradients, and TD-DFT spectra. """ def __init__( self, orca_path: str = "/bin/orca", properties_to_extract: Dict[str, str] = None ): """ Initializes the ORCA engine. Parameters ---------- orca_path : str, optional Full path to the ORCA executable. Defaults to '/bin/orca'. Note that if you wish to run ORCA with parallel computations, you will need to provide the full path (eg: ~/bin/opt/orca/orca_5_0_1) properties_to_extract : dict, optional Dictionary mapping property names to the exact string to search for in the output file. If None, defaults to extracting the final single point energy. """ self.orca_path = orca_path if properties_to_extract is None: # Default fallback behaviour self.properties_to_extract = {"energy": "FINAL SINGLE POINT ENERGY"} else: self.properties_to_extract = properties_to_extract
[docs] def generate_input( self, geometry: Union[str, tuple], fidelity_params: Dict[str, Any], work_dir: str, ) -> str: """ Generates the ORCA input file (.inp) from geometry and fidelity parameters. Parameters ---------- geometry : str or tuple The molecular geometry (XYZ filepath or coordinate arrays). fidelity_params : dict Parameters specifying the calculation (e.g., method, basis, charge, multiplicity, nprocs, maxcore, optional_tags, custom_blocks, EnGrad, template_file). work_dir : str The directory where the input file should be created. Returns ------- str The path to the generated ORCA input file. """ os.makedirs(work_dir, exist_ok=True) input_path = os.path.join(work_dir, "calc.inp") charge = fidelity_params.get("charge", 0) multiplicity = fidelity_params.get("multiplicity", 1) template_file = fidelity_params.get("template_file", None) if template_file and os.path.exists(template_file): # Read from user-provided template file with open(template_file, "r") as f: inp_content = f.read() # Ensure it ends with a newline before appending geometry # I did this because this is how I have seen input files # unsure if we need this. But hey, don't fix something # that is not broken... if not inp_content.endswith("\n"): inp_content += "\n" else: # build the input from scratch method = fidelity_params.get("method", "B3LYP") basis = fidelity_params.get("basis", "def2-SVP") nprocs = fidelity_params.get("nprocs", 1) maxcore = fidelity_params.get("maxcore", None) optional_tags = fidelity_params.get( "optional_tags", [] ) # e.g., ["TightSCF", "RIJCOSX"] custom_blocks = fidelity_params.get( "custom_blocks", "" ) # e.g., "%tddft\n nroots 10\nend" engrad = fidelity_params.get("EnGrad", None) tags_str = " ".join(optional_tags) if engrad is not None: inp_content = f"! {method} {basis} EnGrad {tags_str}\n" else: inp_content = f"! {method} {basis} {tags_str}\n" if maxcore is not None: inp_content += f"%maxcore {maxcore}\n" if nprocs > 1: inp_content += f"%PAL nproc {nprocs} end\n" if custom_blocks: inp_content += f"{custom_blocks}\n" inp_content += "\n" if isinstance(geometry, str): geometry_path = os.path.abspath(geometry) else: geometry_path = geometry inp_content += f"* xyzfile {charge} {multiplicity} {geometry_path}\n" with open(input_path, "w") as f: f.write(inp_content) return input_path
[docs] def run_calculation(self, input_file: str, work_dir: str) -> str: """ Executes the ORCA quantum chemistry program. Parameters ---------- input_file : str The path to the input file generated by `generate_input`. work_dir : str The directory where the calculation is being executed. Returns ------- str The path to the generated ORCA output file (.out). """ output_file = input_file.replace(".inp", ".out") work_dir_abs = os.path.abspath(work_dir) input_file_abs = os.path.abspath(input_file) output_file_abs = os.path.abspath(output_file) # Run ORCA using subprocess # `orca calc.inp > calc.out` with open(output_file, "w") as out_f: try: subprocess.run( [self.orca_path, input_file_abs], stdout=out_f, stderr=subprocess.STDOUT, cwd=work_dir_abs, check=True, # Raises exception if ORCA crashes ) except subprocess.CalledProcessError: pass return output_file
[docs] def parse_output( self, output_file: str, parse_gradients=False, parse_spectra=False ) -> Dict[str, Any]: """ Parses the ORCA output file to extract chemical properties. This is a very bare minimum and rather specific extractor. Ideally one can use any custom parser once the .out file is generated by the previous step. Parameters ---------- output_file : str The path to the primary ORCA output file. parse_gradients : bool, optional Flag to enable parsing of the .engrad file. Defaults to False. parse_spectra : bool, optional Flag to enable parsing of the TD-DFT absorption spectrum table. Defaults to False. Returns ------- dict A dictionary containing the extracted properties (e.g., 'success', 'energy', 'gradients', 'tddft_spectrum'). """ results = {"success": False} for prop in self.properties_to_extract.keys(): results[prop] = None if not os.path.exists(output_file): return results with open(output_file, "r") as f: lines = f.readlines() # check if ORCA terminated normally # usually present in the last few lines of the code if any("ORCA TERMINATED NORMALLY" in line for line in lines[-10:]): results["success"] = True # Extract Property for line in reversed(lines): for prop_name, search_string in self.properties_to_extract.items(): if results[prop_name] is None and search_string in line: # Extract the last valid number on the line (ignores units like 'Eh' or trailing labels) words = line.split() for word in reversed(words): try: results[prop_name] = float(word) break # Found the float! except ValueError: pass # gradients if engrad if parse_gradients: engrad_file = output_file.replace(".out", ".engrad") if os.path.exists(engrad_file): with open(engrad_file, "r") as f: eg_lines = f.readlines() n_atoms = 0 gradients = [] for i, line in enumerate(eg_lines): if "Number of atoms" in line: n_atoms = int(eg_lines[i + 2].strip()) elif "The current gradient" in line: start_idx = i + 2 for j in range(n_atoms * 3): gradients.append(float(eg_lines[start_idx + j].strip())) break if gradients and n_atoms > 0: results["gradients"] = np.array(gradients).reshape(n_atoms, 3) # extract spectra details if parse_spectra: spectra = [] for i, line in enumerate(lines): if ( "ABSORPTION SPECTRUM VIA TRANSITION VELOCITY DIPOLE MOMENTS" in line or "ABSORPTION SPECTRUM VIA TRANSITION ELECTRIC DIPOLE MOMENTS" in line ): # Table data typically starts 5 lines after the header start_idx = i + 5 for j in range(start_idx, len(lines)): if "--------" in lines[j] or lines[j].strip() == "": break # End of table parts = lines[j].split() if len(parts) >= 8: spectra.append( { "state": int(parts[0]), "energy_cm1": float(parts[1]), "wavelength_nm": float(parts[2]), "fosc": float(parts[3]), "px": float(parts[5]), # Index 4 is P2, 5 is PX "py": float(parts[6]), "pz": float(parts[7]), } ) # Save it and break to avoid parsing earlier identical blocks results["tddft_spectrum"] = spectra break return results
[docs] def cleanup(self, work_dir: str): """ Removes temporary/scratch files generated by ORCA to save disk space. Parameters ---------- work_dir : str The directory where the calculation was executed. """ patterns_to_delete = ["*.tmp", "*.dens", "*.gbw", "*.prop"] for pattern in patterns_to_delete: for f in glob.glob(os.path.join(work_dir, pattern)): try: os.remove(f) except OSError: pass