Harmonic lattice dynamics with hiPhive and a machine-learned potential¶
This notebook runs the hiPhive phonon workflow end to end using MACE-OMAT-0-medium for the forces. No DFT is needed, so it runs on a laptop.
Background¶
hiPhive extracts interatomic force constants by fitting a cluster expansion of the force-constant potential to a set of randomly displaced supercells. It plays the same role as pheasy: both replace phonopy’s symmetry-reduced finite differences with a regression over far fewer, randomly displaced configurations.
The workflow is identical to the pheasy one apart from the fitting step. ALM counts the irreducible force-constant elements and sizes the displacement set, phonopy generates the supercells, and phonopy post-processes the fitted force constants into band structures, densities of states and thermodynamic properties. Only the fit itself is hiPhive.
Second-order force constants are physically sparse, so the fit uses LASSO by default, matching pheasy. The Huang and Born-Huang rotational sum rules are enforced on the fitted parameters.
The same workflow is available for VASP as
atomate2.vasp.flows.hiphive.PhononMaker.
Installation¶
Two extras are needed, phonons and hiphive:
pip install 'atomate2[phonons,hiphive]'
pip install 'mace-torch>=0.3.16' mp-api
The hiphive extra pulls in hiPhive, trainstation and ALM. ALM is compiled
from source and needs a C++ toolchain with OpenMP, plus the Eigen, Boost and
spglib headers. The macOS system clang provides none of them, so install them
first (this is the same set atomate2’s CI uses):
conda install -c conda-forge compilers llvm-openmp "eigen=3.3" boost cmake spglib
On macOS, ALM’s build also applies its C++ flags to a C source file, which
clang rejects. Prefix the atomate2 install with CFLAGS="-x c++" to force that
file to compile as C++.
The potential¶
MACE-OMAT-0-medium downloads once and caches under ~/.cache/mace. It is
released under the Academic Software License.
float64 is worth the cost here. The fit reads forces from 0.01 A
displacements, where float32 noise is not negligible.
import os
import warnings
# macOS: conda's llvm-openmp and torch's bundled libomp both load and the
# duplicate aborts the process. This must be set before torch is imported,
# so the mace import below is deliberately not at the top of the cell.
os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
os.environ.setdefault("OMP_NUM_THREADS", "1")
warnings.filterwarnings("ignore")
from mace.calculators import mace_mp # noqa: E402
MODEL = "medium-omat-0"
CALC_KWARGS = {"model": MODEL, "device": "cpu", "default_dtype": "float64"}
_ = mace_mp(**CALC_KWARGS) # downloads and caches on first use
MODEL
The materials¶
These 14 entries are two per crystal system, all of which already have pheasy
phonons in the Materials Project, so the result can be compared against DFT.
They were selected with energy_above_hull < 0.05, at least two elements, and
spglib agreeing with the crystal system MP reports.
QUICK is one material per crystal system, chosen so the whole loop finishes
in about five minutes. The two omitted from it, HfO2 and K2ZnSi3O8, each take
roughly half an hour: HfO2 has a 432-atom supercell and K2ZnSi3O8 is triclinic
with over 5000 free parameters.
MATERIALS = {
"mp-861479": "PrYMg2", # cubic, spg 225
"mp-861950": "Ca2SbAu", # cubic, spg 225
"mp-867515": "NaCoO2", # hexagonal, spg 194
"mp-867841": "GaTc", # hexagonal, spg 187
"mp-985829": "HfS2", # trigonal, spg 164
"mp-998561": "CsSrCl3", # trigonal, spg 161
"mp-979115": "Ti2Ag", # tetragonal, spg 139
"mp-985278": "AcF3", # tetragonal, spg 139
"mp-784630": "Ni2Mo", # orthorhombic, spg 71
"mp-775757": "HfO2", # orthorhombic, spg 61, slow
"mp-997089": "NaAuO2", # monoclinic, spg 12
"mp-863754": "KYSiS4", # monoclinic, spg 4
"mp-997036": "CdAuO2", # triclinic, spg 2
"mp-1224204": "K2ZnSi3O8", # triclinic, spg 1, slow
}
# one per crystal system, about five minutes in total
QUICK = [
"mp-861479",
"mp-867841",
"mp-985829",
"mp-985278",
"mp-784630",
"mp-997089",
"mp-997036",
]
len(MATERIALS), len(QUICK)
Pulling the structures¶
Each pheasy entry stores the supercell it used. Reusing that supercell keeps supercell size from becoming a variable when comparing against DFT.
Two things to know about this endpoint. Its primary key is identifier, and
material_id is a filter rather than a returnable field. And it is
delta-backed, so a search without an explicit num_chunks downloads the whole
collection to disk.
Set your key first with export MP_API_KEY=....
from mp_api.client import MPRester
from pymatgen.core import Structure
if not os.environ.get("MP_API_KEY"):
raise OSError("export MP_API_KEY before running this cell")
def fetch(mp_id: str) -> tuple[Structure, list[list[int]]]:
"""Primitive cell and supercell matrix from the pheasy phonon entry."""
with MPRester() as mpr:
hits = mpr.materials.phonon.search(
material_ids=[mp_id],
phonon_method="pheasy",
num_chunks=1, # bounded: avoids pulling the whole delta table
chunk_size=4,
fields=["identifier", "phonon_method", "structure", "supercell_matrix"],
)
hits = [h for h in hits if str(h.phonon_method) == "pheasy"]
if not hits:
raise ValueError(f"no pheasy phonon entry for {mp_id}")
struct = hits[0].structure
if isinstance(struct, dict):
struct = Structure.from_dict(struct)
sc_matrix = [[round(x) for x in row] for row in hits[0].supercell_matrix]
return struct, sc_matrix
structure, supercell = fetch("mp-861479")
structure.composition.reduced_formula, supercell
Building the workflow¶
The forcefield PhononMaker needs its relaxation and static makers built
explicitly, because the defaults use a different MACE model. Everything else
stays at the workflow defaults: LASSO fitting, 0.01 A displacements, and the
ALM-based configuration count.
from atomate2.forcefields.flows.hiphive import PhononMaker
from atomate2.forcefields.jobs import ForceFieldRelaxMaker, ForceFieldStaticMaker
# any MACE member routes through mace_mp; the model name in calculator_kwargs
# selects which weights are used
FF_NAME = "MACE-MPA-0"
def make_maker() -> PhononMaker:
"""HiPhive PhononMaker wired to MACE-OMAT-0-medium."""
return PhononMaker(
bulk_relax_maker=ForceFieldRelaxMaker(
force_field_name=FF_NAME,
calculator_kwargs=CALC_KWARGS,
relax_kwargs={"fmax": 1e-5},
),
phonon_displacement_maker=ForceFieldStaticMaker(
force_field_name=FF_NAME, calculator_kwargs=CALC_KWARGS
),
static_energy_maker=None,
born_maker=None,
create_thermal_displacements=False,
)
flow = make_maker().make(structure=structure, supercell_matrix=supercell)
flow.draw_graph().show()
Running one material¶
The flow relaxes the cell, generates randomly displaced supercells, evaluates their forces with MACE, fits the force constants with hiPhive, and produces the band structure and DOS. PrYMg2 takes well under a minute.
from jobflow import run_locally
responses = run_locally(flow, create_folders=True, ensure_success=True)
doc = responses[flow.jobs[-1].uuid][1].output
# the output document keeps frequencies as plain lists; to_pmg returns the
# pymatgen objects that the plotters and the analysis helpers expect
band_structure = doc.phonon_bandstructure.to_pmg
round(float(band_structure.bands.max()), 3), doc.has_imaginary_modes
from pymatgen.phonon.plotter import PhononBSPlotter, PhononDosPlotter
dos_plot = PhononDosPlotter()
dos_plot.add_dos(label=MATERIALS["mp-861479"], dos=doc.phonon_dos.to_pmg)
dos_plot.get_plot()
bs_plot = PhononBSPlotter(bs=band_structure)
bs_plot.get_plot()
Running the quick set¶
One material per crystal system. Results are written after each one, so the
loop can be interrupted and resumed. Swap QUICK for MATERIALS to run all
14, which takes about an hour and a half.
Ti2Ag is in MATERIALS but not in QUICK. Its 2x2x2 supercell of a
body-centred cell gives a pair cutoff that reaches no neighbour shell, and the
workflow raises a clear error saying so.
import json
import time
from pathlib import Path
OUT = Path("hiphive_mace_results.json")
results = json.loads(OUT.read_text()) if OUT.exists() else {}
for mp_id in QUICK:
if mp_id in results:
continue
formula = MATERIALS[mp_id]
started = time.time()
try:
struct, sc_matrix = fetch(mp_id)
job = make_maker().make(structure=struct, supercell_matrix=sc_matrix)
out = run_locally(job, create_folders=True, ensure_success=True)[
job.jobs[-1].uuid
][1].output
freqs = out.phonon_bandstructure.to_pmg.bands
results[mp_id] = {
"formula": formula,
"min_freq_THz": round(float(freqs.min()), 4),
"max_freq_THz": round(float(freqs.max()), 4),
"imaginary": bool(out.has_imaginary_modes),
"minutes": round((time.time() - started) / 60, 1),
}
except Exception as exc: # noqa: BLE001
results[mp_id] = {"formula": formula, "error": f"{type(exc).__name__}: {exc}"}
print(mp_id, results[mp_id]) # noqa: T201
OUT.write_text(json.dumps(results, indent=1))
Comparing against DFT¶
The same entries have DFT force constants in the Materials Project.
phonon_method has to be passed explicitly: the convenience accessors on
MPRester default to "dfpt" and will not find a pheasy entry.
import numpy as np
import pandas as pd
rows = []
with MPRester() as mpr:
for mp_id, row in results.items():
if "error" in row:
rows.append(
{
"material_id": mp_id,
"formula": row["formula"],
"note": row["error"][:40],
}
)
continue
band_struct = mpr.materials.phonon.get_bandstructure_from_material_id(
mp_id, phonon_method="pheasy"
)
dft_max = float(np.asarray(band_struct.frequencies).max())
rows.append(
{
"material_id": mp_id,
"formula": row["formula"],
"dft_max_THz": round(dft_max, 3),
"mace_max_THz": row["max_freq_THz"],
"diff_pct": round(100 * (row["max_freq_THz"] - dft_max) / dft_max, 1),
"note": "",
}
)
pd.DataFrame(rows)
Reading the results¶
Across this set the MLIP maximum frequency lands within about 10 percent of
DFT. Most entries come out a few percent low. HfS2 comes out slightly high.
That gap is the potential, not the fit. To separate the two, run the same
material through the phonopy workflow with the identical potential and
supercell (atomate2.forcefields.flows.phonons.PhononMaker). Where phonopy and
hiPhive agree with each other, the fit is working and what remains is the error
in MACE.
Known limitations¶
The workflow has only been tested on the 14 materials listed above so far, not at high-throughput scale. What follows is what that test surfaced.
Low symmetry. On genuinely triclinic cells the cluster space grows into the thousands of parameters while the ALM-sized displacement set does not grow with it, and the LASSO fit can produce spurious soft modes. K2ZnSi3O8 is the example here. Raising the number of configurations does not fix it, so compare against phonopy before trusting a P1 result.
Small or skewed supercells. A diagonal repeat of a centred primitive cell
can be skewed enough that the largest usable pair cutoff reaches no neighbour
shell, leaving the cluster space empty. Ti2Ag is the example. Use a larger or
non-diagonal supercell_matrix for such cells.
Few displacements. When the symmetry-reduced finite-displacement count is three or fewer, the workflow uses those displacements with an ordinary least squares fit instead of random displacements with LASSO. This is inherited from pheasy and affects the most symmetric materials here.