Skip to content

Commit

Permalink
Add tests for basic IO and calculations with default settings (#44)
Browse files Browse the repository at this point in the history
Also changes multiplicity to spin to match xtb's usage
  • Loading branch information
matterhorn103 authored Nov 26, 2024
1 parent 2be7a6f commit 8bb3e7f
Show file tree
Hide file tree
Showing 37 changed files with 6,230 additions and 56 deletions.
12 changes: 10 additions & 2 deletions easyxtb/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,18 @@ Atom(element='O', x=2.9034, y=0.39476, z=1e-05)
Atom(element='H', x=2.60455, y=-0.50701, z=2e-05)
```

Charge and spin are picked up automatically from a CJSON file, but for XYZ files or for overriding they can be specified:

```python
>>> acetate_anion = py_xtb.Geometry.from_file(Path.home() / "calcs/acetate.xyz", charge=-1)
```

Note that `easyxtb` follows the convention used by `xtb` itself, where `spin` is the number of unpaired electrons.

The package provides a function API for basic xtb calculation types (`energy`, `optimize`, `frequencies`, `opt_freq`, `orbitals`):

```python
>>> optimized = py_xtb.calc.optimize(input_geom, charge=0, multiplicity=1, solvation="water", method=2, level="normal")
>>> optimized = py_xtb.calc.optimize(input_geom, solvation="water", method=2, level="normal")
>>> for atom in optimized:
... print(atom)
...
Expand All @@ -54,7 +62,7 @@ Atom(element='H', x=1.55896002888703, y=-0.46876579604809, z=-0.00948378184114)
For greater control and for runtypes or command line options that don't yet have support in the API the `Calculation` object can be used:

```python
>>> freq_calc = py_xtb.Calculation(program="xtb", runtype="hess", options={"charge": 0, "multiplicity": 1, "solvation": "water"})
>>> freq_calc = py_xtb.Calculation(program="xtb", runtype="hess", options={"solvation": "water"})
>>> freq_calc.input_geometry = input_geom
>>> freq_calc.run()
>>> freq_calc.energy
Expand Down
5 changes: 5 additions & 0 deletions easyxtb/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,8 @@ target-version = "py310"
[tool.ruff.format]
quote-style = "double"
indent-style = "space"

[tool.pytest.ini_options]
addopts = [
"--import-mode=importlib",
]
2 changes: 1 addition & 1 deletion easyxtb/src/easyxtb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from .conf import PLUGIN_DIR, CALC_DIR, TEMP_DIR, BIN_DIR, XTB_BIN, CREST_BIN
from .geometry import Atom, Geometry
from .calc import Calculation
from . import calc, conf, convert
from . import calc, conf, convert, format


logger = logging.getLogger(__name__)
Expand Down
49 changes: 28 additions & 21 deletions easyxtb/src/easyxtb/calc.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,21 +161,14 @@ def run(self):
command = self.command
else:
# Build command line args
if self.runtype is None:
# Simple single point
command = [str(self.program_path), str(geom_file)]
else:
command = [
str(self.program_path),
str(geom_file),
"--" + self.runtype,
*self.runtype_args,
"--",
]
# "xtb"
command = [str(self.program_path)]
# Charge and spin from the initial Geometry
if "chrg" not in self.options and self.input_geometry.charge != 0:
command.extend(["--chrg", str(self.input_geometry.charge)])
if "uhf" not in self.options and self.input_geometry.multiplicity != 1:
command.extend(["--uhf", str(self.input_geometry.multiplicity - 1)])
if "uhf" not in self.options and self.input_geometry.spin != 0:
command.extend(["--uhf", str(self.input_geometry.spin)])
# Any other options
for flag, value in self.options.items():
# Add appropriate number of minuses to flags
if len(flag) == 1:
Expand All @@ -188,6 +181,19 @@ def run(self):
continue
else:
command.extend([flag, str(value)])
# Which calculation to run
if self.runtype is None:
# Simple single point
pass
else:
command.extend([
"--" + self.runtype,
*self.runtype_args,
"--",
])
# Path to the input geometry file, preceded by a -- to ensure that it is not
# parsed as an argument to the runtype
command.extend(["--", str(geom_file)])
logger.debug(f"Calculation will be run with the command: {' '.join(command)}")

# Run xtb or crest from command line
Expand Down Expand Up @@ -223,7 +229,7 @@ def process_xtb(self):
self.output_geometry = Geometry.from_file(
self.output_file.with_name("xtbopt.xyz"),
charge=self.input_geometry.charge,
multiplicity=self.input_geometry.multiplicity,
spin=self.input_geometry.spin,
)
logger.debug("Read output geometry")
else:
Expand Down Expand Up @@ -259,7 +265,7 @@ def process_crest(self):
best = Geometry.from_file(
self.output_file.with_name("crest_best.xyz"),
charge=self.input_geometry.charge,
multiplicity=self.input_geometry.multiplicity,
spin=self.input_geometry.spin,
)
logger.debug(f"Geometry of lowest energy conformer read from {self.output_file.with_name('crest_best.xyz')}")
self.output_geometry = best
Expand All @@ -270,7 +276,7 @@ def process_crest(self):
self.output_file.with_name("crest_conformers.xyz"),
multi=True,
charge=self.input_geometry.charge,
multiplicity=self.input_geometry.multiplicity,
spin=self.input_geometry.spin,
)
logger.debug(f"Geometries of conformers read from {self.output_file.with_name('crest_conformers.xyz')}")
self.conformers = [
Expand All @@ -288,7 +294,7 @@ def process_crest(self):
self.output_file.with_name("protonated.xyz"),
multi=True,
charge=self.input_geometry.charge + 1,
multiplicity=self.input_geometry.multiplicity,
spin=self.input_geometry.spin,
)
logger.debug(f"Geometries of tautomers read from {self.output_file.with_name('protonated.xyz')}")
self.tautomers = [
Expand All @@ -311,7 +317,7 @@ def process_crest(self):
self.output_file.with_name("deprotonated.xyz"),
multi=True,
charge=self.input_geometry.charge - 1,
multiplicity=self.input_geometry.multiplicity,
spin=self.input_geometry.spin,
)
logger.debug(f"Geometries of tautomers read from {self.output_file.with_name('deprotonated.xyz')}")
self.tautomers = [
Expand Down Expand Up @@ -416,13 +422,14 @@ def opt_freq(
method: int = 2,
level: str = "normal",
n_proc: int | None = None,
auto_restart: bool = True,
return_calc: bool = False,
) -> tuple[Geometry, list[dict]] | tuple[Geometry, list[dict], Calculation]:
"""Optimize geometry then calculate vibrational frequencies.
If a negative frequency is detected by xtb, it recommends to restart the calculation
from a distorted geometry that it provides, so this is done automatically if
applicable.
applicable by default.
"""

calc = Calculation(
Expand All @@ -441,9 +448,9 @@ def opt_freq(
# (Generated automatically by xtb if result had negative frequency)
# If found, rerun
distorted_geom_file = calc.output_file.with_name("xtbhess.xyz")
while distorted_geom_file.exists():
while distorted_geom_file.exists() and auto_restart:
calc = Calculation(
input_geometry=Geometry.from_file(distorted_geom_file, charge=input_geometry.charge, multiplicity=input_geometry.multiplicity),
input_geometry=Geometry.from_file(distorted_geom_file, charge=input_geometry.charge, spin=input_geometry.spin),
runtype="ohess",
runtype_args=[level],
options={
Expand Down
53 changes: 53 additions & 0 deletions easyxtb/src/easyxtb/format.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Pretty print CJSON files but with flattened arrays for improved readability without
significantly increasing footprint."""

import json


def _flatten_arrays(data: dict) -> dict:
"""Turn any lists of simple items (not dicts or lists) into strings."""
if isinstance(data, list):
# Turn simple lists into flat strings
if all(not isinstance(i, (dict, list)) for i in data):
return json.dumps(data)
# Recursively flatten any nested lists
else:
items = [_flatten_arrays(i) for i in data]
return items
elif isinstance(data, dict):
# Recursively flatten all entries
new = {k: _flatten_arrays(v) for k, v in data.items()}
return new
else:
return data


def cjson_dumps(
cjson: dict,
prettyprint=True,
indent=2,
**kwargs,
) -> str:
"""Serialize a CJSON object to a JSON formatted string.
With the default `prettyprint` option, all simple arrays (not themselves containing
objects/dicts or arrays/lists) will be flattened onto a single line, while all other
array elements and object members will be pretty-printed with the specified indent
level (2 spaces by default).
`indent` and any `**kwargs` are passed to Python's `json.dumps()` as is, so the same
values are valid e.g. `indent=0` will insert newlines while `indent=None` will
afford a compact single-line representation.
"""
if prettyprint:
flattened = _flatten_arrays(cjson)
# Lists are now strings, remove quotes to turn them back into lists
cjson_string = (
json.dumps(flattened, indent=indent, **kwargs)
.replace('"[', '[').replace(']"', ']')
)
# Any strings within lists will have had their quotes escaped, so get rid of escapes
cjson_string = cjson_string.replace(r'\"', '"')
else:
cjson_string = json.dumps(cjson, indent=indent, **kwargs)
return cjson_string
Loading

0 comments on commit 8bb3e7f

Please sign in to comment.