Skip to content

Commit 99f5f34

Browse files
committed
MACE fixes & tests
1 parent 729b94a commit 99f5f34

10 files changed

Lines changed: 863 additions & 71 deletions

File tree

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,7 @@ generated.tar.gz
4040
generated_public.tar.gz
4141
uv.toml
4242
src/wyckoff_transformer/wyckoffs_enumerated_by_ss.json
43-
dist/
43+
dist/
44+
relax/
45+
mp_20_test.json.gz
46+
*.log

README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,53 @@ wyformer-generate <output-file> --hf-model SymmetryAdvantage/<model-name> --sg-d
122122
There are two ways to generate 3D structures from Wyckoff representations: DiffCSP++ and CHGNet. They later can be relaxed with CHGNet and/or DFT.
123123
#### DiffCSP++
124124
Wyckoffs can be relaxed with modified [DiffCSP++ code](https://github.com/kazeevn/DiffCSPNew/tree/master)
125+
#### CrySPR + MACE
126+
[CrySPR](https://chemrxiv.org/engage/chemrxiv/article-details/66b308a501103d79c5fd9b91) scheme using [`pyxtal`](https://pyxtal.readthedocs.io/en/latest/index.html) and a [MACE](https://github.com/ACEsuit/mace) ML force field is integrated directly into the package. Install the optional extra first:
127+
```bash
128+
pip install "wyckoff-transformer[relax]"
129+
```
130+
Then run from the command line:
131+
```bash
132+
wyformer-cryspr WyckoffTransformer_mp_20.json \
133+
--model https://github.com/ACEsuit/mace-foundations/releases/download/mace_mp_0/2023-12-10-mace-128-L0_energy_epoch-249.model \
134+
--output-dir results/ --start 0 --end 1000
135+
# model_name defaults to the model file stem
136+
head results/2023-12-10-mace-128-L0_energy_epoch-249_results.csv
137+
model,id,formula,energy,energy_per_atom
138+
...
139+
2023-12-10-mace-128-L0_energy_epoch-249,35,H6O8Si2,-97.98,...
140+
```
141+
URL-based models are downloaded once and cached in `~/.cache/wyckoff_transformer/mace_models/`. A local path is accepted too: `--model /path/to/model.model`.
142+
143+
Output layout is identical to the CHGNet variant below. Key options:
144+
- `--n-trials N` — number of random PyXtal trials per structure (default 6)
145+
- `--fmax F` — force convergence criterion in eV/Å (default 0.01)
146+
- `--model-name NAME` — label for the results CSV (default: model file stem)
147+
- `--device auto|cpu|cuda` — PyTorch device selection (default: auto)
148+
149+
To use as a library:
150+
```python
151+
from wyckoff_transformer.cryspr.calculator import build_mace_calculator
152+
from wyckoff_transformer.cryspr.generator import single_pyxtal, func_run
153+
import json
154+
155+
calculator = build_mace_calculator(
156+
"https://github.com/ACEsuit/mace-foundations/releases/download/"
157+
"mace_mp_0/2023-12-10-mace-128-L0_energy_epoch-249.model"
158+
)
159+
160+
with open("WyckoffTransformer_mp_20.json") as f:
161+
data = json.load(f)
162+
163+
atoms, formula, energy, energy_per_atom = func_run(
164+
id_gene=99,
165+
wyckoffgene=data[99],
166+
calculator=calculator,
167+
output_dir="results/",
168+
n_trials=6,
169+
)
170+
```
171+
125172
#### CrySPR + CHGNet
126173
[CrySPR](https://chemrxiv.org/engage/chemrxiv/article-details/66b308a501103d79c5fd9b91) scheme that combines [`pyxtal`](https://pyxtal.readthedocs.io/en/latest/index.html) with CHGNet (or any other machine-learning interatomic potentials)
127174
```bash

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ cdvae = ["cdvae-property-models"]
4949
publish = ["build", "twine", "packaging >=25"]
5050
research = ["ase", "matbench-discovery", "ipykernel", "ipywidgets", "jupyter", "ipympl"]
5151
relax = ["mace-torch >=0.3", "ase >=3.23"]
52+
cuequivariance-cu13 = ["cuequivariance-torch >=0.9", "triton >=3", "cuequivariance-ops-torch-cu13 >=0.9"]
5253

5354
[build-system]
5455
requires = ["hatchling", "pyxtal>=1.1.3", "numpy>=1.26.0,<2", "scipy", "scikit-learn>=1.5.0", "pydantic", "pandas"]

src/wyckoff_transformer/cli/relax.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""CLI entry point for CrySPR: crystal structure prediction via PyXtal + MACE."""
22
import argparse
33
import json
4+
import gzip
45
import logging
56
from pathlib import Path
67

@@ -93,7 +94,11 @@ def main() -> None:
9394
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
9495
)
9596

96-
with open(args.input) as f:
97+
if args.input.suffix == ".gz":
98+
opener = gzip.open
99+
else:
100+
opener = open
101+
with opener(args.input, mode="rt", encoding="utf-8") as f:
97102
data = json.load(f)
98103

99104
end = args.end if args.end is not None else len(data)
@@ -108,7 +113,7 @@ def main() -> None:
108113

109114
results = []
110115
for i, wyckoffgene in enumerate(selected, start=args.start):
111-
atoms, formula, energy, energy_per_atom = func_run(
116+
atoms, formula, energy, energy_per_atom, cif = func_run(
112117
id_gene=i,
113118
wyckoffgene=wyckoffgene,
114119
calculator=calculator,
@@ -123,6 +128,7 @@ def main() -> None:
123128
"formula": formula,
124129
"energy": energy,
125130
"energy_per_atom": energy_per_atom,
131+
"cif": cif,
126132
})
127133

128134
results_csv = args.output_dir / f"{model_name}_results.csv"

src/wyckoff_transformer/cli/tests/test_relax.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def test_missing_model_exits(self):
3737
@patch("wyckoff_transformer.cli.relax.build_mace_calculator")
3838
@patch("wyckoff_transformer.cli.relax.func_run")
3939
def test_basic_run_writes_csv(self, mock_func_run, mock_build_calc):
40-
mock_func_run.return_value = (MagicMock(), "NaCl", -10.0, -1.25)
40+
mock_func_run.return_value = (MagicMock(), "NaCl", -10.0, -1.25, "data_\nCIF content")
4141
mock_build_calc.return_value = MagicMock()
4242

4343
out_dir = self.tmp_path / "output"
@@ -54,12 +54,13 @@ def test_basic_run_writes_csv(self, mock_func_run, mock_build_calc):
5454
self.assertEqual(len(csv_files), 1)
5555
df = pd.read_csv(csv_files[0])
5656
self.assertIn("energy", df.columns)
57+
self.assertIn("cif", df.columns)
5758
self.assertEqual(len(df), 1)
5859

5960
@patch("wyckoff_transformer.cli.relax.build_mace_calculator")
6061
@patch("wyckoff_transformer.cli.relax.func_run")
6162
def test_model_name_used_as_csv_stem(self, mock_func_run, mock_build_calc):
62-
mock_func_run.return_value = (None, None, None, None)
63+
mock_func_run.return_value = (None, None, None, None, None)
6364
mock_build_calc.return_value = MagicMock()
6465

6566
out_dir = self.tmp_path / "out2"
@@ -80,7 +81,7 @@ def test_start_end_slices_input(self, mock_func_run, mock_build_calc):
8081
# Write a 3-element input file
8182
input3 = self.tmp_path / "genes3.json"
8283
input3.write_text(json.dumps([_NACL_GENE] * 3))
83-
mock_func_run.return_value = (None, None, None, None)
84+
mock_func_run.return_value = (None, None, None, None, None)
8485
mock_build_calc.return_value = MagicMock()
8586

8687
out_dir = self.tmp_path / "out3"
@@ -102,7 +103,7 @@ def test_start_end_slices_input(self, mock_func_run, mock_build_calc):
102103
@patch("wyckoff_transformer.cli.relax.build_mace_calculator")
103104
@patch("wyckoff_transformer.cli.relax.func_run")
104105
def test_url_model_name_derived_from_stem(self, mock_func_run, mock_build_calc):
105-
mock_func_run.return_value = (None, None, None, None)
106+
mock_func_run.return_value = (None, None, None, None, None)
106107
mock_build_calc.return_value = MagicMock()
107108

108109
out_dir = self.tmp_path / "out4"

src/wyckoff_transformer/cryspr/generator.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ def func_run(
7373
fix_symmetry: bool = True,
7474
fmax: float = 0.01,
7575
optimizer: type[Optimizer] = BFGS,
76-
) -> tuple[Optional[Atoms], Optional[str], Optional[float], Optional[float]]:
76+
) -> tuple[Optional[Atoms], Optional[str], Optional[float], Optional[float], Optional[str]]:
7777
"""Generate and relax crystal structures for one Wyckoff gene.
7878
7979
Runs *n_trials* independent PyXtal generation + MACE relaxation cycles.
@@ -92,9 +92,10 @@ def func_run(
9292
optimizer: ASE local optimisation algorithm class.
9393
9494
Returns:
95-
Tuple ``(atoms, formula, energy, energy_per_atom)`` for the
96-
lowest-energy successful trial, or ``(None, None, None, None)``
97-
when all trials fail.
95+
Tuple ``(atoms, formula, energy, energy_per_atom, cif)`` for the
96+
lowest-energy successful trial, where *cif* is the text of the
97+
symmetrized ``*_2_cell+pos_symmetrized.cif`` file.
98+
Returns ``(None, None, None, None, None)`` when all trials fail.
9899
"""
99100
output_dir = Path(output_dir)
100101
gene_dir = output_dir / str(id_gene)
@@ -141,7 +142,7 @@ def func_run(
141142
"[%s-%s] All %d trials failed or produced no structure.",
142143
model_name, id_gene, n_trials,
143144
)
144-
return None, None, None, None
145+
return None, None, None, None, None
145146

146147
lowest_key = min(energy_by_trial, key=energy_by_trial.__getitem__)
147148

@@ -162,4 +163,11 @@ def func_run(
162163
energy = energy_by_trial[lowest_key]
163164
energy_per_atom = energy / len(atoms)
164165

165-
return atoms, formula, energy, energy_per_atom
166+
# Read the symmetrized final CIF; fall back to the raw cell+pos CIF if absent.
167+
lowest_dir = gene_dir / lowest_key
168+
cif_candidates = sorted(lowest_dir.glob("*_2_cell+pos_symmetrized.cif"))
169+
if not cif_candidates:
170+
cif_candidates = sorted(lowest_dir.glob("*_cell+pos.cif"))
171+
cif_content: Optional[str] = cif_candidates[0].read_text() if cif_candidates else None
172+
173+
return atoms, formula, energy, energy_per_atom, cif_content

src/wyckoff_transformer/cryspr/relaxer.py

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,46 @@
11
"""ASE-based structure relaxation with optional symmetry and cell constraints."""
22
import logging
3+
import os
34
from pathlib import Path
45
from typing import Optional
56

7+
os.environ.setdefault("SPGLIB_OLD_ERROR_HANDLING", "0")
8+
69
from ase import Atoms
710
from ase.calculators.calculator import Calculator
811
from ase.constraints import FixAtoms, FixSymmetry
912
from ase.filters import FrechetCellFilter as CellFilter
1013
from ase.io import write
1114
from ase.optimize import BFGS
1215
from ase.optimize.optimize import Optimizer
13-
from ase.spacegroup import get_spacegroup
14-
from pymatgen.io.ase import AseAtomsAdaptor
16+
import spglib
1517

1618
logger = logging.getLogger(__name__)
1719

1820

21+
def _get_spacegroup_info(atoms: Atoms, symprec: float) -> tuple[str, int]:
22+
"""Return the international symbol and number from spglib."""
23+
try:
24+
dataset = spglib.get_symmetry_dataset(
25+
(atoms.cell.array, atoms.get_scaled_positions(), atoms.numbers),
26+
symprec=symprec,
27+
)
28+
except spglib.SpglibError as exc:
29+
logger.warning("Failed to determine symmetry via spglib: %s", exc)
30+
return "unknown", 0
31+
32+
if dataset is None:
33+
return "unknown", 0
34+
35+
symbol = getattr(dataset, "international", None)
36+
number = getattr(dataset, "number", None)
37+
if symbol is None:
38+
symbol = dataset["international"]
39+
if number is None:
40+
number = dataset["number"]
41+
return str(symbol), int(number)
42+
43+
1944
def run_ase_relaxer(
2045
atoms_in: Atoms,
2146
calculator: Calculator,
@@ -41,8 +66,8 @@ def run_ase_relaxer(
4166
fix_symmetry: Apply a :class:`~ase.constraints.FixSymmetry` constraint.
4267
fix_fractional: Fix all atomic positions (ions immobile).
4368
hydrostatic_strain: Restrict cell filter to isotropic strain only.
44-
symprec: Symmetry tolerance in Å used by :func:`~ase.spacegroup.get_spacegroup`
45-
and :class:`~ase.constraints.FixSymmetry`.
69+
symprec: Symmetry tolerance in Å used by :mod:`spglib` and
70+
:class:`~ase.constraints.FixSymmetry`.
4671
fmax: Force convergence criterion in eV/Å.
4772
steps_limit: Maximum number of optimisation steps.
4873
wdir: Directory for the output CIF file.
@@ -58,15 +83,15 @@ def run_ase_relaxer(
5883

5984
if fix_fractional:
6085
atoms.set_constraint([FixAtoms(indices=list(range(len(atoms))))])
61-
spg0 = get_spacegroup(atoms, symprec=symprec)
86+
spg0_symbol, spg0_number = _get_spacegroup_info(atoms, symprec=symprec)
6287
if fix_symmetry:
6388
atoms.set_constraint([FixSymmetry(atoms, symprec=symprec)])
6489
target = cell_filter(atoms, hydrostatic_strain=hydrostatic_strain) if cell_filter is not None else atoms
6590

6691
E0 = atoms.get_potential_energy()
6792
logger.info(
6893
"Start relaxation: E₀ = %.5f eV, symmetry = %s (%d), fix_sym = %s, relax_cell = %s",
69-
E0, spg0.symbol, spg0.no, fix_symmetry, cell_filter is not None,
94+
E0, spg0_symbol, spg0_number, fix_symmetry, cell_filter is not None,
7095
)
7196

7297
log_arg = str(logfile) if logfile is not None else "-"
@@ -81,11 +106,11 @@ def run_ase_relaxer(
81106
write(filename=str(cif_path), images=atoms, format="cif")
82107

83108
E1 = atoms.get_potential_energy()
84-
spg1 = get_spacegroup(atoms, symprec=symprec)
109+
spg1_symbol, spg1_number = _get_spacegroup_info(atoms, symprec=symprec)
85110
cell_diff = (atoms.cell.cellpar() / atoms_in.cell.cellpar() - 1.0) * 100
86111
logger.info(
87112
"End relaxation: E₁ = %.5f eV, symmetry = %s (%d), max|F| = %.4f eV/Å",
88-
E1, spg1.symbol, spg1.no, abs(atoms.get_forces()).max(),
113+
E1, spg1_symbol, spg1_number, abs(atoms.get_forces()).max(),
89114
)
90115
logger.debug("Cell diff (%%): %s", cell_diff)
91116

@@ -130,10 +155,10 @@ def stepwise_relax(
130155
full_formula = atoms.get_chemical_formula(mode="metal")
131156
reduced_formula = atoms.get_chemical_formula(mode="metal", empirical=True)
132157

133-
structure0 = AseAtomsAdaptor.get_structure(atoms)
134-
structure0.to(
158+
write(
135159
filename=str(wdir / f"{reduced_formula}_{full_formula}_0_initial_symmetrized.cif"),
136-
symprec=symprec,
160+
images=atoms,
161+
format="cif",
137162
)
138163

139164
# Stage 1: fix cell, relax atomic positions
@@ -152,10 +177,10 @@ def stepwise_relax(
152177
wdir=wdir,
153178
logfile=logfile1,
154179
)
155-
structure1 = AseAtomsAdaptor.get_structure(atoms1)
156-
structure1.to(
180+
write(
157181
filename=str(wdir / f"{reduced_formula}_{full_formula}_1_fix-cell_symmetrized.cif"),
158-
symprec=symprec,
182+
images=atoms1,
183+
format="cif",
159184
)
160185

161186
# Stage 2: relax both cell and atomic positions
@@ -174,10 +199,10 @@ def stepwise_relax(
174199
wdir=wdir,
175200
logfile=logfile2,
176201
)
177-
structure2 = AseAtomsAdaptor.get_structure(atoms2)
178-
structure2.to(
202+
write(
179203
filename=str(wdir / f"{reduced_formula}_{full_formula}_2_cell+pos_symmetrized.cif"),
180-
symprec=symprec,
204+
images=atoms2,
205+
format="cif",
181206
)
182207

183208
return atoms2

src/wyckoff_transformer/cryspr/tests/test_cryspr.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ def test_returns_none_tuple_when_all_trials_fail(self):
155155
output_dir=Path(tmp),
156156
n_trials=2,
157157
)
158-
self.assertEqual(result, (None, None, None, None))
158+
self.assertEqual(result, (None, None, None, None, None))
159159

160160

161161
# ---------------------------------------------------------------------------
@@ -174,7 +174,7 @@ def setUpClass(cls):
174174
def test_nacl_relaxation_produces_negative_energy(self):
175175
from wyckoff_transformer.cryspr.generator import func_run
176176
with tempfile.TemporaryDirectory() as tmp:
177-
atoms, formula, energy, energy_per_atom = func_run(
177+
atoms, formula, energy, energy_per_atom, cif = func_run(
178178
id_gene=0,
179179
wyckoffgene=NACL_GENE,
180180
calculator=self.calculator,
@@ -185,3 +185,5 @@ def test_nacl_relaxation_produces_negative_energy(self):
185185
self.assertIsNotNone(formula)
186186
self.assertLess(energy, 0.0, "Relaxed NaCl energy should be negative")
187187
self.assertLess(energy_per_atom, 0.0)
188+
self.assertIsNotNone(cif, "CIF content should be returned for a successful relaxation")
189+
self.assertIn("_cell_length_a", cif)

0 commit comments

Comments
 (0)