def test_generate(): # Test with a simple molecule inchi, xyz = generate_inchi_and_xyz('C') assert xyz.startswith('5') assert inchi == 'InChI=1S/CH4/h1H4' # Test with a molecule that has defined stereochemistry inchi, xyz = generate_inchi_and_xyz("C/C=C\\C") assert inchi == "InChI=1S/C4H8/c1-3-4-2/h3-4H,1-2H3/b4-3-" # Change the stereo chemistry inchi_man_iso = Chem.MolToInchi(Chem.MolFromSmiles("C/C=C/C")) inchi_iso, xyz_iso = generate_inchi_and_xyz("C/C=C/C") assert inchi_man_iso == inchi_iso assert inchi != inchi_iso # Test with a molecule w/o defined sterochemistry inchi_undef, xyz = generate_inchi_and_xyz("CC=CC") assert inchi_undef == inchi_man_iso # Make sure it gets the lower-energy isomer
def test_hash(): inchi, xyz = generate_inchi_and_xyz('O') mol = Molecule.from_data(xyz, 'xyz') assert mol.get_hash() != mol.orient_molecule().get_hash() assert get_hash(mol) == get_hash(mol.orient_molecule()) ox_mol = Molecule.from_data(xyz, 'xyz', molecular_charge=1) assert ox_mol.molecular_multiplicity != mol.molecular_multiplicity assert mol.get_hash() != ox_mol.get_hash() assert get_hash(mol) == get_hash(ox_mol.orient_molecule())
def test_subtract_reference_energies(): # Make a RDKit and QCEngine representation of a molecule _, xyz = generate_inchi_and_xyz('C') molr = Chem.MolFromSmiles('C') molr = Chem.AddHs(molr) molq = Molecule.from_data(xyz, 'xyz') # Get the desired answer my_ref = lookup_reference_energies('small_basis') actual = my_ref['C'] + 4 * my_ref['H'] # Check it works with either RDKit or QCElemental inputs assert subtract_reference_energies(0, molr, my_ref) == -actual assert subtract_reference_energies(0, molq, my_ref) == -actual
def run_simulation(smiles: str, n_nodes: int, spec_name: str = 'small_basis', solvent: Optional[str] = None)\ -> Tuple[List[OptimizationResult], List[AtomicResult]]: """Run the ionization potential computation Args: smiles: SMILES string to evaluate n_nodes: Number of nodes to use spec_name: Name of the quantum chemistry specification solvent: Name of the solvent to use Returns: Relax records for the neutral and ionized geometry """ from moldesign.simulate.functions import generate_inchi_and_xyz, relax_structure, run_single_point from moldesign.simulate.specs import get_qcinput_specification from moldesign.utils.chemistry import get_baseline_charge # Make the initial geometry inchi, xyz = generate_inchi_and_xyz(smiles) neu_charge = get_baseline_charge(smiles) chg_charge = neu_charge + 1 # Make the compute spec compute_config = {'nnodes': n_nodes, 'cores_per_rank': 2, 'ncores': 64} # Get the specification and make it more resilient spec, code = get_qcinput_specification(spec_name) if code == "nwchem": spec.keywords["dft__iterations"] = 150 spec.keywords["geometry__noautoz"] = True # Compute the neutral geometry and hessian neutral_xyz, _, neutral_relax = relax_structure(xyz, spec, compute_config=compute_config, charge=neu_charge, code=code) # Compute the relaxed geometry oxidized_xyz, _, oxidized_relax = relax_structure(neutral_xyz, spec, compute_config=compute_config, charge=chg_charge, code=code) # If desired, compute the solvent energies if solvent is None: return [neutral_relax, oxidized_relax], [] spec, code = get_qcinput_specification(spec_name, solvent=solvent) if code == "nwchem": spec.keywords["dft__iterations"] = 150 spec.keywords["geometry__noautoz"] = True neutral_solvent = run_single_point(neutral_xyz, 'energy', spec, compute_config=compute_config, charge=neu_charge, code=code) charged_solvent = run_single_point(oxidized_xyz, 'energy', spec, compute_config=compute_config, charge=chg_charge, code=code) return [neutral_relax, oxidized_relax], [neutral_solvent, charged_solvent]
def _run_simulation(smiles: str, solvent: Optional[str], spec_name: str = 'xtb')\ -> Tuple[List[OptimizationResult], List[AtomicResult]]: """Run the ionization potential computation Args: smiles: SMILES string to evaluate solvent: Name of the solvent spec: Quantum chemistry specification for the molecule Returns: Relax records for the neutral and ionized geometry """ from moldesign.simulate.functions import generate_inchi_and_xyz, relax_structure, run_single_point from moldesign.simulate.specs import get_qcinput_specification from moldesign.utils.chemistry import get_baseline_charge from qcelemental.models import DriverEnum # Make the initial geometry inchi, xyz = generate_inchi_and_xyz(smiles) init_charge = get_baseline_charge(smiles) # Get the specification and make it more resilient spec, code = get_qcinput_specification(spec_name) # Compute the geometries neutral_xyz, _, neutral_relax = relax_structure(xyz, spec, charge=init_charge, code=code) oxidized_xyz, _, oxidized_relax = relax_structure(neutral_xyz, spec, charge=init_charge + 1, code=code) # Perform the solvation energy computations, if desired if solvent is None: return [neutral_relax, oxidized_relax], [] solv_spec, code = get_qcinput_specification(spec_name, solvent=solvent) neutral_solv = run_single_point(neutral_xyz, DriverEnum.energy, solv_spec, charge=init_charge, code=code) oxidized_solv = run_single_point(oxidized_xyz, DriverEnum.energy, solv_spec, charge=init_charge + 1, code=code) return [neutral_relax, oxidized_relax], [neutral_solv, oxidized_solv]
def run_simulation( smiles: str, n_nodes: int) -> Tuple[List[OptimizationResult], List[AtomicResult]]: """Run the ionization potential computation Args: smiles: SMILES string to evaluate n_nodes: Number of nodes to use Returns: Relax records for the neutral and ionized geometry """ from moldesign.simulate.functions import generate_inchi_and_xyz, relax_structure, run_single_point from moldesign.simulate.specs import get_qcinput_specification from qcelemental.models import DriverEnum # Make the initial geometry inchi, xyz = generate_inchi_and_xyz(smiles) # Make the compute spec compute_config = {'nnodes': n_nodes, 'cores_per_rank': 2} # Get the specification and make it more resilient spec, code = get_qcinput_specification('small_basis') if code == "nwchem": spec.keywords["dft__iterations"] = 150 spec.keywords["geometry__noautoz"] = True # Compute the neutral geometry and hessian neutral_xyz, _, neutral_relax = relax_structure( xyz, spec, compute_config=compute_config, charge=0, code=code) neutral_hessian = run_single_point(neutral_xyz, DriverEnum.hessian, spec, charge=0, compute_config=compute_config, code=code) # Compute the relaxed geometry oxidized_xyz, _, oxidized_relax = relax_structure( neutral_xyz, spec, compute_config=compute_config, charge=1, code=code) oxidized_hessian = run_single_point(oxidized_xyz, DriverEnum.hessian, spec, charge=1, compute_config=compute_config, code=code) return [neutral_relax, oxidized_relax], [neutral_hessian, oxidized_hessian]
def test_cyclopropyl(): smiles = 'Oc1c[cH+]1' inchi, xyz = generate_inchi_and_xyz(smiles, special_cases=True) # Make sure the initial ring is indeed buckled atoms = next(simple_read_xyz(StringIO(xyz), slice(None))) def _is_coplanar(x: np.ndarray): y = x[:3, :] - x[-1, :] return np.linalg.det(y) < 1e-4 assert not _is_coplanar(atoms.positions[:4, :]) # Attempt to flatten it back out xyz = fix_cyclopropenyl(xyz, 'Oc1c[cH+]1') atoms = next(simple_read_xyz(StringIO(xyz), slice(None))) with open('test.xyz', 'w') as fp: fp.write(xyz) assert _is_coplanar(atoms.positions[:4, :])
def run_simulation(smiles: str, n_nodes: int, mode: str) -> Union[OptimizationResult, AtomicResult]: """Run a single-point or relaxation computation computation Args: smiles: SMILES string to evaluate n_nodes: Number of nodes to use mode: What computation to perform: single, gradient, hessian, relax Returns: Result of the energy computation """ from moldesign.simulate.functions import generate_inchi_and_xyz, relax_structure, run_single_point from moldesign.simulate.specs import get_qcinput_specification from qcelemental.models import DriverEnum # Make the initial geometry inchi, xyz = generate_inchi_and_xyz(smiles) # Make the compute spec compute_config = {'nnodes': n_nodes, 'cores_per_rank': 2} # Get the specification and make it more resilient spec, code = get_qcinput_specification('small_basis') if code == "nwchem": spec.keywords["dft__iterations"] = 150 spec.keywords["geometry__noautoz"] = True # Compute the neutral geometry and hessian if mode == 'relax': _, _, neutral_relax = relax_structure(xyz, spec, compute_config=compute_config, charge=0, code=code) return neutral_relax else: return run_single_point(xyz, mode, spec, charge=0, compute_config=compute_config, code=code)
def molecules() -> List[str]: return [generate_inchi_and_xyz(x)[1] for x in ['C', 'CC', 'O']]
def compute_vertical(smiles: str, oxidize: bool, solvent: Optional[str] = None, spec_name: str = 'small_basis', n_nodes: int = 2) \ -> Tuple[List[OptimizationResult], List[AtomicResult]]: """Perform the initial ionization potential computation of the vertical First relaxes the structure and then runs a single-point energy at the Args: smiles: SMILES string to evaluate oxidize: Whether to perform an oxidation or reduction solvent: Name of the solvent, if desired. Runs on the neutral geometry after relaxation spec_name: Quantum chemistry specification for the molecule n_nodes: Number of nodes per computation Returns: - Relax records for the neutral - Single point energy in oxidized or reduced state """ # Make the initial geometry inchi, xyz = generate_inchi_and_xyz(smiles) init_charge = get_baseline_charge(smiles) # Make the compute spec compute_config = {'nnodes': n_nodes, 'cores_per_rank': 2} # Get the specification and make it more resilient spec, code = get_qcinput_specification(spec_name) if code == "nwchem": spec.keywords['dft__convergence__energy'] = 1e-7 spec.keywords['dft__convergence__fast'] = True spec.keywords["dft__iterations"] = 150 spec.keywords["driver__maxiter"] = 150 spec.keywords["geometry__noautoz"] = True # Make a repeatably-named scratch directory. # We cannot base it off a hash of the input file, # because the XYZ file generator is stochastic. runhash = hashlib.sha256( f'{smiles}_{oxidize}_{spec_name}'.encode()).hexdigest()[:12] spec.extras["scratch_name"] = f'nwc_{runhash}' spec.extras["allow_restarts"] = True # Compute the geometries neutral_xyz, _, neutral_relax = relax_structure( xyz, spec, charge=init_charge, code=code, compute_config=compute_config) # Perform the single-point energy for the ionized geometry new_charge = init_charge + 1 if oxidize else init_charge - 1 oxid_spe = run_single_point(neutral_xyz, DriverEnum.energy, spec, charge=new_charge, code=code, compute_config=compute_config) # If desired, submit a solvent computation as well if solvent is None: return [neutral_relax], [oxid_spe] spec, code = get_qcinput_specification(spec_name, solvent) if code == "nwchem": # Reduce the accuracy needed to 1e-7 spec.keywords['dft__convergence__energy'] = 1e-7 spec.keywords['dft__convergence__fast'] = True spec.keywords["dft__iterations"] = 150 spec.keywords["geometry__noautoz"] = True # Make sure to allow restarting spec.extras["allow_restarts"] = True solv_spe = run_single_point(neutral_xyz, DriverEnum.energy, spec, charge=init_charge, code=code, compute_config=compute_config) return [neutral_relax], [oxid_spe, solv_spe]
def get_required_calculations(self, record: MoleculeData, oxidation_state: OxidationState, previous_level: Optional['RedoxEnergyRecipe'] = None) \ -> List[Tuple[AccuracyLevel, str, int, Optional[str], bool]]: """List the required computations to complete this recipe given the current information about a molecule If this method returns relaxation calculations, then there may be more yet to perform after those complete before one can evaluate the redox potential at the desired level of accuracy. Calculations that use those input geometries may be needed Args: record: Information available about the molecule oxidation_state: Oxidation state for the redox computation previous_level: Previous level of accuracy, used to determine a starting point for relaxations Returns: List of required computations as tuples of (level of accuracy, input XYZ structure, charge state, solvent, whether to relax) All computations can be performed in parallel """ # Required computations required = [] # Get the neutral and oxidized charges neutral_charge = get_baseline_charge(record.identifier['inchi']) charged_charge = neutral_charge + (-1 if oxidation_state == OxidationState.REDUCED else 1) # Determine the starting point for relaxations, if required # We'll use geometries from the previous level of fidelity with priority over those at the current level # and procedurally-generated ones last if previous_level is not None and previous_level.geometry_level in record.data: neutral_start = record.data[previous_level.geometry_level][ OxidationState.NEUTRAL].xyz if oxidation_state in record.data[previous_level.geometry_level]: charged_start = record.data[ previous_level.geometry_level][oxidation_state].xyz else: charged_start = neutral_start elif self.geometry_level not in record.data: neutral_start = charged_start = generate_inchi_and_xyz( record.identifier['inchi'])[1] else: neutral_start = record.data[self.geometry_level][ OxidationState.NEUTRAL].xyz if oxidation_state in record.data[self.geometry_level]: charged_start = record.data[ self.geometry_level][oxidation_state].xyz else: charged_start = neutral_start # Determine if any relaxations are needed geom_level = self.geometry_level if geom_level not in record.data or OxidationState.NEUTRAL not in record.data[ geom_level]: required.append( (geom_level, neutral_start, neutral_charge, None, True)) if self.adiabatic and (geom_level not in record.data or oxidation_state not in record.data[geom_level]): required.append( (geom_level, charged_start, charged_charge, None, True)) # If any relaxations are triggered, then return the list now if len(required) > 0: return required # Determine if any single-point calculations are required neutral_geom_data = record.data[geom_level][OxidationState.NEUTRAL] if self.adiabatic: charged_geom_data = record.data[geom_level][oxidation_state] else: charged_geom_data = neutral_geom_data for state, data, chg in zip([OxidationState.NEUTRAL, oxidation_state], [neutral_geom_data, charged_geom_data], [neutral_charge, charged_charge]): if self.energy_level not in data.total_energy.get(state, {}): required.append( (self.energy_level, data.xyz, chg, None, False)) if self.solvent is not None and ( self.solvent not in data.total_energy_in_solvent.get( state, {}) or self.solvation_level not in data.total_energy_in_solvent[state][self.solvent]): required.append( (self.energy_level, data.xyz, chg, self.solvent, False)) return required
def training_set() -> Tuple[List[str], List[float]]: xyzs = [generate_inchi_and_xyz(x)[1] for x in ['C', 'CC']] return xyzs, [1., 2.]
"""Generate the data used by the tests""" from moldesign.simulate.functions import generate_inchi_and_xyz, relax_structure, run_single_point from moldesign.simulate.specs import get_qcinput_specification if __name__ == "__main__": # Make a water molecule inchi, xyz = generate_inchi_and_xyz('O') # Generate the neutral geometry with XTB xtb_spec, xtb = get_qcinput_specification("xtb") xtb_neutral_xyz, _, record = relax_structure(xyz, xtb_spec, code=xtb) with open('records/xtb-neutral.json', 'w') as fp: print(record.json(), file=fp) # Compute the vertical oxidation potential record = run_single_point(xtb_neutral_xyz, "energy", xtb_spec, code=xtb, charge=1) with open('records/xtb-neutral_xtb-oxidized-energy.json', 'w') as fp: print(record.json(), file=fp) # Compute the adiabatic oxidation potential xtb_oxidized_xyz, _, record = relax_structure(xtb_neutral_xyz, xtb_spec, code=xtb, charge=1) with open('records/xtb-oxidized.json', 'w') as fp: print(record.json(), file=fp)