Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
5df8e62
SOF-7915: refactor espresso structure parser to use pymatgen
pranabdas May 23, 2026
9829d63
use qe-tools to parse structure from espresso-in
pranabdas May 23, 2026
1a1177d
add tests for espresso-in parser
pranabdas May 23, 2026
521ad33
merge main and resolve conflicts
pranabdas May 28, 2026
9bac1b5
implement qe pw input parser using made
pranabdas May 28, 2026
e141f3c
fix made[tools] dependency
pranabdas May 28, 2026
52a13cc
bump made
pranabdas May 28, 2026
94555a9
use BOHR_TO_ANGSTROM contstant via utils
pranabdas Jun 3, 2026
a93bb05
replace strip_comments with remove_comments_from_source_code from mat…
pranabdas Jun 3, 2026
b019cab
refactor after moving parsers to parsers repo
pranabdas Jun 5, 2026
9e2b20e
use updated parser to get_namelist
pranabdas Jun 12, 2026
e35f92d
merge main and resolve conflicts
pranabdas Jun 12, 2026
7be6036
refactor parser changes
pranabdas Jun 13, 2026
04c1f91
bump parser
pranabdas Jun 13, 2026
13da531
bump parsers
pranabdas Jun 13, 2026
c8a5252
update parsers
pranabdas Jun 13, 2026
c0be228
update parsers
pranabdas Jun 13, 2026
01565af
update parser class
pranabdas Jun 19, 2026
59ea4b1
remove espresso comments by chaining fortran and python comments
pranabdas Jun 19, 2026
26d8d61
chore: bump gh actions/checkout
pranabdas Jun 19, 2026
6980e3a
update parsers lib
pranabdas Jun 20, 2026
2cf45a1
update parsers and made
pranabdas Jun 20, 2026
9f8317b
remove espresso input parser from express and use parsers class
pranabdas Jun 26, 2026
616b607
update deps
pranabdas Jul 2, 2026
992f9fd
update libs
pranabdas Jul 4, 2026
b864353
fix linter
pranabdas Jul 4, 2026
de61a73
cache parsed lattice and basis
pranabdas Jul 4, 2026
1933ab7
use published version of parsers
pranabdas Jul 4, 2026
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
19 changes: 10 additions & 9 deletions .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,22 @@ jobs:

steps:
- name: Checkout this repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
lfs: true

- name: Checkout actions repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: Exabyte-io/actions
repository: mat3ra/actions
token: ${{ secrets.BOT_GITHUB_TOKEN }}
path: actions

- name: Run ruff linter
uses: ./actions/py/lint
with:
python-version: ${{ matrix.python-version }}
ruff-version: 0.15.20

run-tests:
runs-on: ubuntu-24.04
Expand All @@ -43,14 +44,14 @@ jobs:

steps:
- name: Checkout this repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
lfs: true

- name: Checkout actions repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: Exabyte-io/actions
repository: mat3ra/actions
token: ${{ secrets.BOT_GITHUB_TOKEN }}
path: actions

Expand All @@ -71,15 +72,15 @@ jobs:

steps:
- name: Checkout this repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
lfs: true
fetch-tags: true

- name: Checkout actions repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: Exabyte-io/actions
repository: mat3ra/actions
token: ${{ secrets.BOT_GITHUB_TOKEN }}
path: actions

Expand Down
4 changes: 2 additions & 2 deletions express/parsers/apps/espresso/formats/txt.py
Original file line number Diff line number Diff line change
Expand Up @@ -827,9 +827,9 @@ def average_quantity(self, stdout_file: str) -> np.ndarray:
"""
average_file = find_file(settings.AVERAGE_FILE, self.work_dir)
# backup in case avg.dat doesn't exist
if type(average_file) != str:
if type(average_file) is not str:
average_file = find_file(stdout_file, self.work_dir)
if type(average_file) == str and os.path.isfile(average_file):
if type(average_file) is str and os.path.isfile(average_file):
dtype = np.dtype([("x", float), ("planar_average", float), ("macroscopic_average", float)])
data = np.fromregex(average_file, settings.REGEX["average_quantity"]["regex"], dtype)
return data
Expand Down
2 changes: 1 addition & 1 deletion express/parsers/apps/nwchem/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def total_energy_contributions(self):
energy_contributions = self.txt_parser.total_energy_contributions(self._get_file_content(self.stdout_file))
for key1, value1 in energy_contributions.items():
for key2, value2 in value1.items():
if type(value2) == float:
if type(value2) is float:
value1[key2] = value2 * Constant.HARTREE
return energy_contributions

Expand Down
76 changes: 35 additions & 41 deletions express/parsers/structure.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import io
import json

import pymatgen as mg
from pymatgen.core.structure import Structure
from ase.io import read, write
from jarvis.core.atoms import Atoms
from jarvis.io.vasp.inputs import Poscar

Expand Down Expand Up @@ -38,22 +36,40 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.structure_string = kwargs.get("structure_string")
self.structure_format = kwargs.get("structure_format")
self.cell_type = kwargs.get("cell_type", "original") # original, primitive or conventional

# convert espresso input into poscar
# convert espresso input into pymatgen.core.structure
if self.structure_format == "espresso-in":
self.structure_format = "poscar"
self.structure_string = self.espresso_input_to_poscar(self.structure_string)
from mat3ra.parsers.applications.espresso.pw_x.stdin.material import EspressoPwxStdinMaterial

material_parser = EspressoPwxStdinMaterial(self.structure_string)

# Cache the parsed results to avoid multiple property evaluations
parsed_lattice = material_parser.lattice
parsed_basis = material_parser.basis

# Reconstruct the PyMatGen Structure object safely from the standardized MADE config outputs
lattice_matrix = [
parsed_lattice["vectors"]["a"],
parsed_lattice["vectors"]["b"],
parsed_lattice["vectors"]["c"],
]
species = [element["value"] for element in parsed_basis["elements"]]
coords = [coord["value"] for coord in parsed_basis["coordinates"]]
coords_are_cartesian = (parsed_basis["units"] == "cartesian")

self.structure = Structure(
lattice=lattice_matrix,
species=species,
coords=coords,
coords_are_cartesian=coords_are_cartesian,
)

# convert jarvis-db-entry JSON into poscar
if self.structure_format == "jarvis-db-entry":
self.structure_format = "poscar"
elif self.structure_format == "jarvis-db-entry":
self.structure_string = self.jarvis_db_entry_json_to_poscar(self.structure_string)

# cell_type is either original, primitive or conventional
self.cell_type = kwargs.get("cell_type", "original")

# Initialize structure class
if self.structure_format == "pymatgen.core.structure":
self.structure = Structure.from_str(self.structure_string, "poscar")
elif self.structure_format == "pymatgen.core.structure":
structure_as_dict = json.loads(self.structure_string)
self.structure = Structure.from_dict(structure_as_dict)
else:
Expand All @@ -63,7 +79,7 @@ def __init__(self, *args, **kwargs):
self.structure = STRUCTURE_MAP[self.cell_type](self.structure)

# keep only one atom inside the basis in order to have the original lattice type
self.lattice_only_structure = self.structure.copy() # deepcopy
self.lattice_only_structure = self.structure.copy() # deepcopy
self.lattice_only_structure.remove_sites(range(1, len(self.structure.sites)))

def lattice_vectors(self):
Expand Down Expand Up @@ -178,16 +194,15 @@ def basis(self):
for i, site in enumerate(self.structure.sites):
if not site.is_ordered:
raise ValueError(
f"Disordered site at {site.frac_coords.tolist()} with "
f"occupancy {site.species} is not supported."
f"Disordered site at {site.frac_coords.tolist()} with "
f"occupancy {site.species} is not supported."
)

# Use specie.symbol to strip oxidation state (e.g. "Li0+" → "Li", "O2-" → "O")
elements.append({"id": i, "value": site.specie.symbol})
coordinates.append({
"id": i,
"value": self._round(site.frac_coords.tolist(), PRECISION_MAP["coordinates_crystal"])
})
coordinates.append(
{"id": i, "value": self._round(site.frac_coords.tolist(), PRECISION_MAP["coordinates_crystal"])}
)
return {"units": "crystal", "elements": elements, "coordinates": coordinates}

def space_group_symbol(self):
Expand Down Expand Up @@ -240,27 +255,6 @@ def atomic_constraints(self):
"""
return self.structure.site_properties.get("selective_dynamics")

def espresso_input_to_poscar(self, espresso_input):
"""
Extracts structure from espresso input file and returns in poscar format.

Args:
espresso_input (str): input file content

Returns:
str: poscar
"""
input_ = io.StringIO()
input_.write(espresso_input)
input_.seek(0)
atoms = read(input_, format="espresso-in")
output_ = io.StringIO()
write(output_, atoms, format="vasp", vasp5=True)
content = output_.getvalue()
input_.close()
output_.close()
return content

def jarvis_db_entry_json_to_poscar(self, jarvis_db_entry_json_str):
"""
Extracts structure from jarvis atoms dictionary and returns in poscar format.
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ classifiers = [
"Topic :: Software Development",
]
dependencies = [
"pymatgen>=2023.8.10",
"ase>=3.17.0",
"mat3ra-esse>=2026.6.12",
"jarvis-tools>=2023.12.12",
"mat3ra-esse>=2026.6.30.post0",
"mat3ra-made[tools]>=2026.7.4.post0",
"mat3ra-parsers>=2026.7.4.post0",
# To avoid module 'numpy.linalg._umath_linalg' has no attribute '_ilp64' in Colab
"numpy>=1.24.4,<2",
]
Expand Down
1 change: 1 addition & 0 deletions tests/.gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ fixtures/vasp/** filter=lfs diff=lfs merge=lfs -text
fixtures/data.py filter=lfs diff=lfs merge=lfs -text
fixtures/pyML/test-001/*.JSON filter=lfs diff=lfs merge=lfs -text
*.cif filter=lfs diff=lfs merge=lfs -text
fixtures/structural/test-005/*.in filter=lfs diff=lfs merge=lfs -text
11 changes: 11 additions & 0 deletions tests/fixtures/structural/references.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,14 @@
{"id": 8, "value": [0.333333330, 0.666666670, 0.666666670]},
],
}

SI_IBRAV_BASIS = {
"units": "crystal",
"elements": [{"id": 0, "value": "Si"}, {"id": 1, "value": "Si"}],
"coordinates": [
{"id": 0, "value": [0.0, 0.0, 0.0]},
{"id": 1, "value": [0.25, 0.25, 0.25]},
],
}

SI_PRIMITIVE_LATTICE_A = 3.867 # Angstrom
3 changes: 3 additions & 0 deletions tests/fixtures/structural/test-005/pw_si_ibrav0.in
Git LFS file not shown
3 changes: 3 additions & 0 deletions tests/fixtures/structural/test-005/pw_si_ibrav2.in
Git LFS file not shown
70 changes: 67 additions & 3 deletions tests/unit/parsers/test_structure_parser.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os

from tests.unit import UnitTestBase
from tests.fixtures.structural.references import LI_CIF_BASIS
from tests.fixtures.structural.references import LI_CIF_BASIS, SI_IBRAV_BASIS, SI_PRIMITIVE_LATTICE_A
from express.parsers.structure import StructureParser

LI_CIF_PATH = os.path.join(os.path.dirname(__file__), "..", "..", "fixtures", "structural", "test-004", "Li.cif")
Expand All @@ -10,6 +10,14 @@
os.path.dirname(__file__), "..", "..", "fixtures", "structural", "test-004", "SrLaCoO4.cif"
)

SI_IBRAV0_PATH = os.path.join(
os.path.dirname(__file__), "..", "..", "fixtures", "structural", "test-005", "pw_si_ibrav0.in"
)

SI_IBRAV2_PATH = os.path.join(
os.path.dirname(__file__), "..", "..", "fixtures", "structural", "test-005", "pw_si_ibrav2.in"
)


def _read_file(path):
with open(path) as f:
Expand Down Expand Up @@ -46,12 +54,12 @@ class DisorderedStructureParserTest(UnitTestBase):
when basis() is called on a structure with disordered (mixed-occupancy) sites.

The SrLaCoO4 CIF has Sr2+ and La3+ sharing the same Wyckoff 4e site
with occupancy 0.5 each — a canonical disordered case.
with occupancy 0.5 each, a disordered case.
"""

def setUp(self):
super().setUp()
# Parsing itself succeeds pymatgen can load disordered structures.
# Parsing itself succeeds as pymatgen can load disordered structures.
# The error is raised lazily when basis() is called.
self.parser = StructureParser(
structure_string=_read_file(DISORDERED_CIF_PATH),
Expand All @@ -72,3 +80,59 @@ def test_basis_raises_for_disordered_site(self):
self.assertIn("is not supported", error)
self.assertIn("occupancy", error)
self.assertIn("0.361", error)


class EspressoInIbrav0StructureParserTest(UnitTestBase):
"""Tests that StructureParser correctly parses espresso-in files with ibrav=0
and explicit CELL_PARAMETERS.
The primitive lattice constant is 3.867 Angstrom."""

def setUp(self):
super().setUp()
self.parser = StructureParser(
structure_string=_read_file(SI_IBRAV0_PATH),
structure_format="espresso-in",
)

def tearDown(self):
super().tearDown()

def test_basis(self):
self.assertDeepAlmostEqual(self.parser.basis(), SI_IBRAV_BASIS, places=5)

def test_formula(self):
self.assertEqual(self.parser.formula(), "Si2")

def test_lattice_bravais_type(self):
self.assertEqual(self.parser.lattice_bravais()["type"], "FCC")

def test_lattice_parameter_a(self):
self.assertAlmostEqual(self.parser.lattice_bravais()["a"], SI_PRIMITIVE_LATTICE_A, places=3)


class EspressoInIbrav2StructureParserTest(UnitTestBase):
"""Tests that StructureParser correctly parses espresso-in files with ibrav=2
(FCC) defined via celldm(1), without explicit CELL_PARAMETERS.
The primitive lattice constant is 3.867 Angstrom."""

def setUp(self):
super().setUp()
self.parser = StructureParser(
structure_string=_read_file(SI_IBRAV2_PATH),
structure_format="espresso-in",
)

def tearDown(self):
super().tearDown()

def test_basis(self):
self.assertDeepAlmostEqual(self.parser.basis(), SI_IBRAV_BASIS, places=5)

def test_formula(self):
self.assertEqual(self.parser.formula(), "Si2")

def test_lattice_bravais_type(self):
self.assertEqual(self.parser.lattice_bravais()["type"], "FCC")

def test_lattice_parameter_a(self):
self.assertAlmostEqual(self.parser.lattice_bravais()["a"], SI_PRIMITIVE_LATTICE_A, places=3)