From 38f9a9672e770bf9bc70695905e797c3b5b2c6b2 Mon Sep 17 00:00:00 2001 From: Marvin Poul Date: Mon, 20 Apr 2026 16:01:01 -0400 Subject: [PATCH 1/4] Add injectable potentials to allow per-element PAW path and potType override Adds an optional `potentials: dict[str, {potential, potType}]` parameter to `get_paw_from_structure`, `get_paw_from_chemical_symbols`, `set_base_parameters`, and `SphinxDft.__init__`. Elements absent from the dict fall back to the existing `get_potential_path` / AtomPAW default. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 89 ++++++++++++++++++++++++++++++++++++ sphinx_parser/calculator.py | 8 ++-- sphinx_parser/jobs.py | 3 +- sphinx_parser/potential.py | 31 ++++++++----- tests/unit/test_jobs.py | 12 +++++ tests/unit/test_potential.py | 53 +++++++++++++++++++++ 6 files changed, 178 insertions(+), 18 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d438567 --- /dev/null +++ b/CLAUDE.md @@ -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`. diff --git a/sphinx_parser/calculator.py b/sphinx_parser/calculator.py index b08e45d..b3d7593 100644 --- a/sphinx_parser/calculator.py +++ b/sphinx_parser/calculator.py @@ -3,27 +3,25 @@ from ase.calculators.calculator import FileIOCalculator, FileIORules, StandardProfile from ase.units import Hartree, Bohr -from sphinx_parser.ase import get_structure_group -from sphinx_parser.input import sphinx from sphinx_parser.jobs import set_base_parameters from sphinx_parser.output import SphinxLogParser -from sphinx_parser.potential import get_paw_from_structure from sphinx_parser.toolkit import to_sphinx class SphinxDft(FileIOCalculator): implemented_properties = ["energy", "forces"] - def __init__(self, *args, command: str = "sphinx", **kwargs): + 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) + input_sx = set_base_parameters(atoms, potentials=self.potentials) cwd = self.directory with open(Path(cwd) / "input.sx", "w") as f: diff --git a/sphinx_parser/jobs.py b/sphinx_parser/jobs.py index 6758f39..19d4d42 100644 --- a/sphinx_parser/jobs.py +++ b/sphinx_parser/jobs.py @@ -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 @@ -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) ) diff --git a/sphinx_parser/potential.py b/sphinx_parser/potential.py index 6fd2b48..3c25458 100644 --- a/sphinx_parser/potential.py +++ b/sphinx_parser/potential.py @@ -7,20 +7,27 @@ 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) + + +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, + ) + return sphinx.pawPot.species( + potential=get_potential_path(element), + potType="AtomPAW", + element=element, + ) -def get_paw_from_chemical_symbols(chemical_symbols): return sphinx.pawPot( - species=[ - sphinx.pawPot.species( - potential=get_potential_path(c), - potType="AtomPAW", - element=c, - ) - for c in np.unique(chemical_symbols) - ] + species=[_species(c) for c in np.unique(chemical_symbols)] ) diff --git a/tests/unit/test_jobs.py b/tests/unit/test_jobs.py index 0d5ac0e..0508e55 100644 --- a/tests/unit/test_jobs.py +++ b/tests/unit/test_jobs.py @@ -1,4 +1,5 @@ import unittest +from pathlib import Path from ase.build import bulk @@ -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) diff --git a/tests/unit/test_potential.py b/tests/unit/test_potential.py index 54c1991..f157e7a 100644 --- a/tests/unit/test_potential.py +++ b/tests/unit/test_potential.py @@ -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, ) @@ -19,6 +21,57 @@ 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() From fb23aa68976b322bffab6215ea24ee8d21a3542e Mon Sep 17 00:00:00 2001 From: Marvin Poul Date: Mon, 20 Apr 2026 16:10:12 -0400 Subject: [PATCH 2/4] Add docstring to SphinxDft calculator Co-Authored-By: Claude Sonnet 4.6 --- sphinx_parser/calculator.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/sphinx_parser/calculator.py b/sphinx_parser/calculator.py index b3d7593..8aa8d8d 100644 --- a/sphinx_parser/calculator.py +++ b/sphinx_parser/calculator.py @@ -9,6 +9,35 @@ 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.atomicdata"), "potType": "VASP"}, + }, + ) + """ + implemented_properties = ["energy", "forces"] def __init__(self, *args, command: str = "sphinx", potentials=None, **kwargs): From 87c269b34f07560887c361ce37ac46892cc5e476 Mon Sep 17 00:00:00 2001 From: Marvin Poul Date: Mon, 20 Apr 2026 20:11:26 +0000 Subject: [PATCH 3/4] Apply suggestion from @pmrv --- sphinx_parser/calculator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sphinx_parser/calculator.py b/sphinx_parser/calculator.py index 8aa8d8d..4678328 100644 --- a/sphinx_parser/calculator.py +++ b/sphinx_parser/calculator.py @@ -33,7 +33,7 @@ class SphinxDft(FileIOCalculator): directory="./run", potentials={ "Fe": {"potential": Path("/pots/Fe_GGA.atomicdata"), "potType": "AtomPAW"}, - "Al": {"potential": Path("/pots/Al_VASP.atomicdata"), "potType": "VASP"}, + "Al": {"potential": Path("/pots/Al_VASP.POTCAR"), "potType": "VASP"}, }, ) """ From 7f1d4fb964fb5379f1b94c2cb52e7bb4e86794a1 Mon Sep 17 00:00:00 2001 From: Marvin Poul Date: Tue, 21 Apr 2026 13:48:26 +0000 Subject: [PATCH 4/4] Update sphinx_parser/potential.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- sphinx_parser/potential.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sphinx_parser/potential.py b/sphinx_parser/potential.py index 3c25458..758bce6 100644 --- a/sphinx_parser/potential.py +++ b/sphinx_parser/potential.py @@ -8,7 +8,9 @@ def get_paw_from_structure(structure, potentials=None): - return get_paw_from_chemical_symbols(structure.get_chemical_symbols(), potentials) + return get_paw_from_chemical_symbols( + structure.get_chemical_symbols(), potentials=potentials + ) def get_paw_from_chemical_symbols(chemical_symbols, potentials=None):