Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# sphinx_parser

Python interface for the [Sphinx DFT code](https://sxrepo.mpie.de) — a high-performance density functional theory (DFT) package developed at the Max Planck Institute.

## What this project does

- Provides a Python API to construct Sphinx input files programmatically
- Integrates with ASE (Atomic Simulation Environment) as a standard `FileIOCalculator`
- Auto-generates input classes from a YAML specification (`sphinx_parser/src/input_data.yml`)
- Parses Sphinx output files (logs, energies, forces)
- Handles unit conversions between Hartree/Bohr (Sphinx) and eV/Å (ASE)

## Data flow

```
input_data.yml → generator.py → input.py (generated, do not hand-edit)
jobs.py (convenience wrappers)
toolkit.py (serialisation to .sx format)
calculator.py or manual file write
input.sx → [Sphinx binary]
sphinx.log / energy.dat / forces.sx
output.py (SphinxLogParser)
```

## Key files

| File | Role |
|------|------|
| `sphinx_parser/input.py` | Auto-generated input classes — **do not edit by hand** |
| `sphinx_parser/src/input_data.yml` | Source-of-truth YAML spec for all Sphinx input parameters |
| `sphinx_parser/src/generator.py` | Reads the YAML and regenerates `input.py` |
| `sphinx_parser/calculator.py` | ASE `FileIOCalculator` subclass (`SphinxDft`) |
| `sphinx_parser/jobs.py` | High-level helpers (`set_base_parameters`, `apply_minimization`) |
| `sphinx_parser/toolkit.py` | Low-level formatting (`to_sphinx`, `format_value`, `fill_values`) |
| `sphinx_parser/ase.py` | ASE ↔ Sphinx structure conversion |
| `sphinx_parser/output.py` | `SphinxLogParser` — parses log files into result dicts |
| `sphinx_parser/potential.py` | PAW potential lookup (VASP and JTH formats) |

## Regenerating input.py

```bash
python sphinx_parser/src/generator.py
```

Run this after editing `input_data.yml`.

## Tests

```bash
python -m pytest tests/
```

- `tests/unit/` — unit tests for jobs, output parsing, generator
- `tests/integration/` — integration tests including README examples
- `tests/static/` — static reference data

CI runs on Python 3.11–3.14 via GitHub Actions.

## Code style

```bash
black sphinx_parser/
ruff check sphinx_parser/
```

Both are enforced in CI.

## Dependencies

- **numpy**, **ase** — core scientific stack
- **semantikon**, **pint** — unit handling
- **h5py** — HDF5 I/O
- **pyyaml** — YAML parsing

## Active branch: `calculator`

This branch adds `calculator.py` — the new ASE `FileIOCalculator` interface. It is not yet on `main`.

## Notes

- Units inside Sphinx are Hartree / Bohr; the calculator layer converts to eV / Å for ASE.
- Atom ordering in Sphinx input must group species together; `sphinx_parser/ase.py` handles this reordering.
- Magnetic moments and constraints are also handled in `ase.py`.
63 changes: 63 additions & 0 deletions sphinx_parser/calculator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from pathlib import Path

from ase.calculators.calculator import FileIOCalculator, FileIORules, StandardProfile
from ase.units import Bohr, Hartree

from sphinx_parser.jobs import set_base_parameters
from sphinx_parser.output import SphinxLogParser
from sphinx_parser.toolkit import to_sphinx


class SphinxDft(FileIOCalculator):
"""ASE FileIOCalculator interface for the Sphinx DFT code.

Writes a Sphinx input file, runs the Sphinx binary, and parses the
resulting log for energy and forces.

Args:
command (str): Sphinx executable name or path. Defaults to "sphinx".
potentials (dict[str, dict] | None): Optional per-element PAW potential
overrides. Each key is an element symbol; each value is a dict with:
- ``potential`` (Path | str): path to the potential file.
- ``potType`` (str): potential type, e.g. ``"AtomPAW"`` or
``"VASP"``.
Elements absent from this dict fall back to the JTH-GGA-PBE
potentials found under ``$CONDA_PREFIX``.
*args, **kwargs: Forwarded to :class:`ase.calculators.calculator.FileIOCalculator`.

Example::

calc = SphinxDft(directory="./run")

calc = SphinxDft(
directory="./run",
potentials={
"Fe": {"potential": Path("/pots/Fe_GGA.atomicdata"), "potType": "AtomPAW"},
"Al": {"potential": Path("/pots/Al_VASP.POTCAR"), "potType": "VASP"},
},
)
"""

implemented_properties = ["energy", "forces"]

def __init__(self, *args, command: str = "sphinx", potentials=None, **kwargs):
super().__init__(*args, **kwargs, profile=StandardProfile(command=command))
self.fileio_rules = FileIORules(stdout_name="sphinx.log")
self.potentials = potentials

def write_input(self, atoms, properties=None, system_changes=None):
super().write_input(atoms, properties, system_changes)

# Reuse the shared helper to build the Sphinx input structure,
# avoiding divergence from the defaults defined in jobs.set_base_parameters.
input_sx = set_base_parameters(atoms, potentials=self.potentials)

cwd = self.directory
with open(Path(cwd) / "input.sx", "w") as f:
f.write(to_sphinx(input_sx))

def read_results(self):
parser = SphinxLogParser.load_from_path(Path(self.directory) / "sphinx.log")
self.results["energy"] = parser.get_energy_free()[-1][-1] * Hartree
forces_au = parser.get_forces()[-1]
self.results["forces"] = forces_au * Hartree / Bohr
3 changes: 2 additions & 1 deletion sphinx_parser/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ def set_base_parameters(
maxSteps: int = 30,
ekt: float = 0.2,
k_point_coords: list = [0.5, 0.5, 0.5],
potentials=None,
):
"""
Set the base parameters for the sphinx input file
Expand All @@ -32,7 +33,7 @@ def set_base_parameters(
maxSteps=maxSteps, blockCCG=sphinx.main.scfDiag.blockCCG()
)
)
pawPot_group = get_paw_from_structure(structure)
pawPot_group = get_paw_from_structure(structure, potentials)
basis_group = sphinx.basis(
eCut=eCut, kPoint=sphinx.basis.kPoint(coords=k_point_coords)
)
Expand Down
31 changes: 19 additions & 12 deletions sphinx_parser/potential.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,28 @@
from sphinx_parser.input import sphinx


def get_paw_from_structure(structure):
return get_paw_from_chemical_symbols(structure.get_chemical_symbols())
def get_paw_from_structure(structure, potentials=None):
return get_paw_from_chemical_symbols(
structure.get_chemical_symbols(), potentials=potentials
)


def get_paw_from_chemical_symbols(chemical_symbols):
return sphinx.pawPot(
species=[
sphinx.pawPot.species(
potential=get_potential_path(c),
potType="AtomPAW",
element=c,
def get_paw_from_chemical_symbols(chemical_symbols, potentials=None):
def _species(element):
if potentials and element in potentials:
entry = potentials[element]
return sphinx.pawPot.species(
potential=str(entry["potential"]),
potType=entry["potType"],
element=element,
)
for c in np.unique(chemical_symbols)
]
)
return sphinx.pawPot.species(
potential=get_potential_path(element),
potType="AtomPAW",
element=element,
)

return sphinx.pawPot(species=[_species(c) for c in np.unique(chemical_symbols)])


def get_potential_path(element: str):
Expand Down
12 changes: 12 additions & 0 deletions tests/unit/test_jobs.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import unittest
from pathlib import Path

from ase.build import bulk

Expand All @@ -13,6 +14,17 @@ def test_magnetic_bulk(self):
structure = bulk("Al", cubic=True)
self.assertFalse("atomicSpin" in to_sphinx(set_base_parameters(structure)))

def test_potentials_forwarded(self):
structure = bulk("Al", cubic=True)
custom_path = Path("/custom/Al_GGA.atomicdata")
input_sx = set_base_parameters(
structure,
potentials={"Al": {"potential": custom_path, "potType": "VaspPAW"}},
)
output = to_sphinx(input_sx)
self.assertIn(str(custom_path), output)
self.assertIn("VaspPAW", output)

def test_calc_minimize(self):
structure = bulk("Fe", cubic=True)
input_sx = set_base_parameters(structure)
Expand Down
61 changes: 61 additions & 0 deletions tests/unit/test_potential.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import os
import unittest
from pathlib import Path

from sphinx_parser.potential import (
_is_jth_potential,
_is_vasp_potential,
_remove_hash_tag,
get_paw_from_chemical_symbols,
get_potential_path,
)

Expand All @@ -19,6 +21,65 @@ def setUpClass(cls):
def test_path_exists(self):
self.assertTrue(os.path.exists(get_potential_path("Ag")))

# Repeated species blocks are stored as "species", "species___0", ...
@staticmethod
def _all_species(paw_dict):
return [
v
for k, v in paw_dict.items()
if k == "species" or k.startswith("species___")
]

def test_injected_potential_used(self):
custom_path = Path("/custom/pots/Ag_custom.atomicdata")
result = get_paw_from_chemical_symbols(
["Ag"],
potentials={"Ag": {"potential": custom_path, "potType": "VaspPAW"}},
)
species = self._all_species(result)
self.assertEqual(len(species), 1)
self.assertEqual(species[0]["potential"], f'"{custom_path}"')
self.assertEqual(species[0]["potType"], '"VaspPAW"')

def test_injected_potType_respected(self):
custom_path = Path("/pots/Fe_GGA.atomicdata")
result = get_paw_from_chemical_symbols(
["Fe"],
potentials={"Fe": {"potential": custom_path, "potType": "AtomPAW"}},
)
self.assertEqual(self._all_species(result)[0]["potType"], '"AtomPAW"')

def test_fallback_when_element_not_in_potentials(self):
# Ag is not in the injected dict, so it must fall back to the env path
result = get_paw_from_chemical_symbols(
["Ag"],
potentials={
"Fe": {"potential": Path("/pots/Fe.atomicdata"), "potType": "AtomPAW"}
},
)
species = self._all_species(result)
self.assertEqual(len(species), 1)
self.assertIn(get_potential_path("Ag"), species[0]["potential"])
self.assertEqual(species[0]["potType"], '"AtomPAW"')

def test_fallback_when_potentials_none(self):
result = get_paw_from_chemical_symbols(["Ag"], potentials=None)
self.assertIn(
get_potential_path("Ag"), self._all_species(result)[0]["potential"]
)

def test_mixed_injected_and_fallback(self):
custom_path = Path("/pots/Fe_custom.atomicdata")
result = get_paw_from_chemical_symbols(
["Ag", "Fe"],
potentials={"Fe": {"potential": custom_path, "potType": "VaspPAW"}},
)
by_element = {s["element"].strip('"'): s for s in self._all_species(result)}
self.assertIn(str(custom_path), by_element["Fe"]["potential"])
self.assertEqual(by_element["Fe"]["potType"], '"VaspPAW"')
self.assertIn(get_potential_path("Ag"), by_element["Ag"]["potential"])
self.assertEqual(by_element["Ag"]["potType"], '"AtomPAW"')

def test_is_xyz_potential(self):
with open(os.path.join(self.file_path, "potentials", "Ag_POTCAR"), "r") as f:
file_content = f.read()
Expand Down
Loading