Skip to content

Commit bdcee37

Browse files
Zikkyingcursoragent
andcommitted
Fix Grüneisen support for Phonopy 4
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8e15fdf commit bdcee37

9 files changed

Lines changed: 248 additions & 37 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -798,7 +798,7 @@ APEX integrates parts of [dflow-phonon](https://github.com/Chengqian-Zhang/dflow
798798
| `approach` | String | `"linear"` | VASP phonon method: `"linear"` or `"displacement"`. |
799799
| `supercell_size` | Sequence[Int] | `[2, 2, 2]` | Supercell dimensions. |
800800
| `MESH` | Sequence[Int] | `None` | Reciprocal-space mesh (e.g., `[8, 8, 8]`). |
801-
| `PRIMITIVE_AXES` | String | `None` | Custom primitive axes definition (`"0.0 0.5 0.5 0.5 0.0 0.5 0.5 0.5 0.0"`). |
801+
| `PRIMITIVE_AXES` | String | `"P"` | Primitive axes used consistently by phonopy and phonoLAMMPS; accepts `"P"`, `"AUTO"`, or a 3×3 matrix. |
802802
| `BAND` | String | `None` | Band path definition (falls back to SeeK-path when omitted). |
803803
| `BAND_LABELS` | String | `None` | Labels for band segments. |
804804
| `BAND_POINTS` | Integer | `51` | Number of sampling points per segment. |
@@ -822,8 +822,8 @@ APEX supports Grüneisen workflows based on phonon calculations at multiple stra
822822
| `primitive` | Bool | `false` | Reduce to primitive cell before phonon calculation. |
823823
| `approach` | String | `"linear"` | Phonon workflow approach; VASP Grüneisen currently uses linear response. |
824824
| `supercell_size` | Sequence[Int] | `[2, 2, 2]` | Phonon supercell dimensions. |
825-
| `MESH` | Sequence[Int] | `None` | Reciprocal-space mesh for mode summation. |
826-
| `PRIMITIVE_AXES` | String | `None` | Custom primitive axes definition. |
825+
| `MESH` | Sequence[Int] | `[20, 20, 20]` | Three positive reciprocal-space mesh dimensions for mode summation. |
826+
| `PRIMITIVE_AXES` | String | `"P"` | Primitive axes used consistently for displacement generation and post-processing. |
827827
| `BAND` | String | `None` | Band path definition (falls back to SeeK-path when omitted). |
828828
| `BAND_LABELS` | String | `None` | Labels for band segments. |
829829
| `BAND_POINTS` | Integer | `51` | Number of sampling points per segment. |

apex/core/property/Gruneisen.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
}
3333

3434
DEFAULT_SUPERCELL = [2, 2, 2]
35+
DEFAULT_MESH = [20, 20, 20]
3536
THZ_TO_K = 47.99243073366221
3637
KB_EV_PER_K = 8.617333262145e-5
3738

@@ -49,7 +50,8 @@ def __init__(self, parameter, inter_param=None):
4950
self.seekpath_from_original = parameter["seekpath_from_original"]
5051
parameter["seekpath_param"] = parameter.get("seekpath_param", {})
5152
self.seekpath_param = parameter["seekpath_param"]
52-
parameter["MESH"] = parameter.get("MESH", None)
53+
mesh = parameter.get("MESH")
54+
parameter["MESH"] = list(DEFAULT_MESH) if mesh is None else mesh
5355
self.MESH = parameter["MESH"]
5456
parameter["PRIMITIVE_AXES"] = parameter.get("PRIMITIVE_AXES", "P")
5557
self.PRIMITIVE_AXES = parameter["PRIMITIVE_AXES"]
@@ -88,6 +90,17 @@ def __init__(self, parameter, inter_param=None):
8890
self._validate()
8991

9092
def _validate(self):
93+
if (
94+
not isinstance(self.MESH, (list, tuple))
95+
or len(self.MESH) != 3
96+
or any(
97+
not isinstance(value, int)
98+
or isinstance(value, bool)
99+
or value <= 0
100+
for value in self.MESH
101+
)
102+
):
103+
raise ValueError("gruneisen MESH must contain exactly 3 positive integers")
91104
if len(self.volume_strains) < 3:
92105
raise ValueError("volume_strains must contain at least 3 points")
93106
if len(self.temperatures) < 1:
@@ -326,8 +339,11 @@ def _make_abacus_confs(self, path_to_work: str, path_to_equi: str) -> List[str]:
326339
"ATOM_NAME =%s\n"
327340
% "".join(f" {name}" for name in vasp_utils.get_poscar_types("POSCAR"))
328341
)
329-
if self.PRIMITIVE_AXES:
330-
fp.write(f"PRIMITIVE_AXES = {self.PRIMITIVE_AXES}\n")
342+
primitive_axes = Phonon.primitive_axes_config_value(
343+
self.PRIMITIVE_AXES
344+
)
345+
if primitive_axes:
346+
fp.write(f"PRIMITIVE_AXES = {primitive_axes}\n")
331347
subprocess.check_call(
332348
Phonon.phonopy_setup_command("setting.conf --abacus -d"),
333349
shell=True,
@@ -682,10 +698,10 @@ def _build_band_payload(self, poscar_path: str) -> dict:
682698
self.supercell_size[2],
683699
)
684700
)
685-
if self.MESH:
686-
lines.append("MESH = %s %s %s" % (self.MESH[0], self.MESH[1], self.MESH[2]))
687-
if self.PRIMITIVE_AXES:
688-
lines.append(f"PRIMITIVE_AXES = {self.PRIMITIVE_AXES}")
701+
lines.append("MESH = %s %s %s" % (self.MESH[0], self.MESH[1], self.MESH[2]))
702+
primitive_axes = Phonon.primitive_axes_config_value(self.PRIMITIVE_AXES)
703+
if primitive_axes:
704+
lines.append(f"PRIMITIVE_AXES = {primitive_axes}")
689705
lines.append(f"BAND = {band_string}")
690706
if band_labels:
691707
lines.append(f"BAND_LABELS = {band_labels}")
@@ -801,6 +817,16 @@ def _ensure_mesh_yaml(self, task_dir: str) -> None:
801817
raise FileNotFoundError(f"FORCE_CONSTANTS not found in {task_dir}")
802818
if not os.path.isfile(band_conf):
803819
raise FileNotFoundError(f"band.conf not found in {task_dir}")
820+
with open(band_conf, "r") as fp:
821+
has_mesh = any(
822+
line.partition("=")[0].strip().upper() == "MESH"
823+
for line in fp
824+
)
825+
if not has_mesh:
826+
raise ValueError(
827+
f"band.conf in {task_dir} does not define MESH; "
828+
f"Gruneisen requires MESH={self.MESH}. Regenerate the task inputs."
829+
)
804830
if not os.path.isfile(poscar):
805831
raise FileNotFoundError(f"POSCAR not found in {task_dir}")
806832
os.chdir(task_dir)

apex/core/property/Phonon.py

Lines changed: 78 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import shutil
66
import re
77
import subprocess
8+
from fractions import Fraction
89
from typing import List, Dict, Any
910

1011
import dpdata
@@ -47,6 +48,8 @@ def phonopy_writefc_commands(arguments: str) -> List[str]:
4748
phonopy_command = Phonon.phonopy_command(arguments)
4849
if setup_command == phonopy_command:
4950
return [setup_command]
51+
if "--dim" in arguments:
52+
return [setup_command]
5053
return [setup_command, phonopy_command]
5154

5255
@staticmethod
@@ -103,15 +106,17 @@ def phonopy_load_commands(
103106
),
104107
)
105108
)
106-
commands.append(
107-
Phonon.phonopy_command(
108-
Phonon._join_phonopy_arguments(
109-
extra_args,
110-
'--dim=%s -c %s' % (Phonon._format_dim(supercell_size), cell_file),
111-
config_file,
109+
if not shutil.which("phonopy-init"):
110+
commands.append(
111+
Phonon.phonopy_command(
112+
Phonon._join_phonopy_arguments(
113+
extra_args,
114+
'--dim=%s -c %s'
115+
% (Phonon._format_dim(supercell_size), cell_file),
116+
config_file,
117+
)
112118
)
113119
)
114-
)
115120
else:
116121
commands.append(
117122
Phonon.phonopy_command(
@@ -137,10 +142,51 @@ def phonopy_writefc_load_commands(
137142

138143
@staticmethod
139144
def primitive_axes_setup_argument(primitive_axes: str | None) -> str:
145+
primitive_axes = Phonon.primitive_axes_config_value(primitive_axes)
140146
if not primitive_axes:
141147
return ""
142148
return f"--pa {primitive_axes}"
143149

150+
@staticmethod
151+
def primitive_axes_config_value(primitive_axes: Any) -> str | None:
152+
if primitive_axes is None:
153+
return None
154+
if isinstance(primitive_axes, str):
155+
value = primitive_axes.strip()
156+
if not value or value.upper() == "AUTO":
157+
return None
158+
if value.upper() == "P":
159+
return "P"
160+
tokens = value.replace(",", " ").split()
161+
elif isinstance(primitive_axes, (list, tuple)):
162+
tokens = []
163+
for item in primitive_axes:
164+
if isinstance(item, (list, tuple)):
165+
tokens.extend(item)
166+
else:
167+
tokens.append(item)
168+
else:
169+
raise ValueError(
170+
"PRIMITIVE_AXES must be 'P', 'AUTO', or a 3x3 numeric matrix"
171+
)
172+
if len(tokens) != 9:
173+
raise ValueError(
174+
"PRIMITIVE_AXES must be 'P', 'AUTO', or contain exactly 9 values"
175+
)
176+
try:
177+
return " ".join(str(float(Fraction(str(token)))) for token in tokens)
178+
except (ValueError, ZeroDivisionError) as exc:
179+
raise ValueError("PRIMITIVE_AXES contains a non-numeric value") from exc
180+
181+
@staticmethod
182+
def primitive_axes_phonolammps_argument(primitive_axes: Any) -> str:
183+
config_value = Phonon.primitive_axes_config_value(primitive_axes)
184+
if config_value is None:
185+
return ""
186+
if config_value == "P":
187+
config_value = "1 0 0 0 1 0 0 0 1"
188+
return f"-pa {config_value}"
189+
144190
@staticmethod
145191
def run_first_success(commands: List[str], required_file: str | None = None) -> None:
146192
errors = []
@@ -154,6 +200,12 @@ def run_first_success(commands: List[str], required_file: str | None = None) ->
154200
return
155201
errors.append(FileNotFoundError(f"{required_file} was not created by: {command}"))
156202
if errors:
203+
if required_file is not None:
204+
attempted = "; ".join(commands)
205+
raise RuntimeError(
206+
f"{required_file} was not created after trying: {attempted}. "
207+
f"Last error: {errors[-1]}"
208+
) from errors[-1]
157209
raise errors[-1]
158210

159211
@staticmethod
@@ -253,16 +305,30 @@ def _ensure_deepmd_plugin_loaded(self, input_text: str) -> str:
253305
def _build_phonolammps_run_command(self) -> str:
254306
dim_x, dim_y, dim_z = self.supercell_size
255307
command_template = self.phonolammps_run_command
308+
primitive_axes = self.primitive_axes_phonolammps_argument(
309+
self.PRIMITIVE_AXES
310+
)
256311
if not command_template:
257-
return f"phonolammps in.lammps -c POSCAR --dim {dim_x} {dim_y} {dim_z} "
258-
return command_template.format(
312+
return self._join_phonopy_arguments(
313+
f"phonolammps in.lammps -c POSCAR --dim {dim_x} {dim_y} {dim_z}",
314+
primitive_axes,
315+
)
316+
command = command_template.format(
259317
input_file="in.lammps",
260318
poscar="POSCAR",
261319
dim=f"{dim_x} {dim_y} {dim_z}",
262320
dim_x=dim_x,
263321
dim_y=dim_y,
264322
dim_z=dim_z,
323+
primitive_axes=primitive_axes,
265324
)
325+
if (
326+
primitive_axes
327+
and "{primitive_axes}" not in command_template
328+
and not re.search(r"(?:^|\s)(?:-pa|--primitive_axis)(?:\s|=)", command)
329+
):
330+
command = self._join_phonopy_arguments(command, primitive_axes)
331+
return command
266332

267333
def make_confs(self, path_to_work, path_to_equi, refine=False):
268334
path_to_work = os.path.abspath(path_to_work)
@@ -408,8 +474,9 @@ def make_confs(self, path_to_work, path_to_equi, refine=False):
408474
ret += "MESH = %s %s %s\n" % (
409475
self.MESH[0], self.MESH[1], self.MESH[2]
410476
)
411-
if self.PRIMITIVE_AXES:
412-
ret += "PRIMITIVE_AXES = %s\n" % self.PRIMITIVE_AXES
477+
primitive_axes = self.primitive_axes_config_value(self.PRIMITIVE_AXES)
478+
if primitive_axes:
479+
ret += "PRIMITIVE_AXES = %s\n" % primitive_axes
413480
ret += "BAND = %s\n" % self.BAND
414481
if self.BAND_LABELS:
415482
ret += "BAND_LABELS = %s\n" % self.BAND_LABELS

apex/skills/apex-flow/reference/properties.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ These parameters appear in most property configurations:
233233
| `PRIMITIVE_AXES` | optional | str | `"P"` | Phonopy PRIMITIVE_AXES |
234234
| `BAND` | optional | str | `null` | Explicit band path |
235235
| `BAND_LABELS` | optional | list[str] | `null` | High-symmetry labels |
236-
| `phonolammps_run_command` | optional | str | `null` | Custom phonoLAMMPS command |
236+
| `phonolammps_run_command` | optional | str | `null` | Custom phonoLAMMPS command; supports `{primitive_axes}`, otherwise the matching `-pa` argument is appended |
237237

238238
**cal_setting defaults**: `relax_pos=true`, `relax_shape=false`, `relax_vol=false`, `cal_type="static"`
239239

@@ -494,15 +494,17 @@ VASP uses Langevin–Parrinello–Rahman NpT; ABACUS uses Nose–Hoover-style Np
494494
| Parameter | Required? | Type | Default | Description |
495495
|-----------|-----------|------|---------|-------------|
496496
| `supercell_size` | optional | list[int] | `[2,2,2]` | Phonon supercell |
497+
| `MESH` | optional | list[int] | `[20,20,20]` | Three positive reciprocal-space mesh dimensions used for mode summation |
497498
| `volume_strains` | **REQUIRED** | list[float] | `[-0.02,-0.01,0.0,0.01,0.02]` | Must include 0.0, ≥3 points |
498499
| `temperatures` | **REQUIRED** | list[float] | `[100,200,300,400,500]` | Temperature points for evaluation |
499-
| `alpha_mode` | optional | str | `"full"` | `"full"` or `"debye"` |
500+
| `alpha_mode` | optional | str | `"sign_only"` | `"sign_only"` or `"full"` |
500501

501502
**Complete working default**:
502503
```json
503504
{
504505
"type": "gruneisen",
505506
"supercell_size": [2, 2, 2],
507+
"MESH": [20, 20, 20],
506508
"volume_strains": [-0.02, -0.01, 0.0, 0.01, 0.02],
507509
"temperatures": [100, 200, 300, 400, 500],
508510
"alpha_mode": "full"

apex/skills/apex-flow/scripts/generate_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@
127127
"gruneisen": {
128128
"type": "gruneisen",
129129
"supercell_size": [2, 2, 2],
130+
"MESH": [20, 20, 20],
130131
"volume_strains": [-0.02, -0.01, 0.0, 0.01, 0.02],
131132
"temperatures": [100, 200, 300, 400, 500],
132133
"alpha_mode": "full",

apex/skills/apex-flow/scripts/validate_inputs.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,22 @@ def validate_properties(properties: list, interaction_type: str) -> list:
210210
errors.append(f"{prefix}: decohesive requires 'min_slab_size'")
211211

212212
elif prop_type == "gruneisen":
213+
mesh = prop.get("MESH")
214+
if mesh is None:
215+
mesh = [20, 20, 20]
216+
if (
217+
not isinstance(mesh, (list, tuple))
218+
or len(mesh) != 3
219+
or any(
220+
not isinstance(value, int)
221+
or isinstance(value, bool)
222+
or value <= 0
223+
for value in mesh
224+
)
225+
):
226+
errors.append(
227+
f"{prefix}: gruneisen 'MESH' must contain 3 positive integers"
228+
)
213229
if "volume_strains" not in prop:
214230
errors.append(f"{prefix}: gruneisen requires 'volume_strains'")
215231
else:

tests/test_gruneisen.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ def valid_gruneisen_params(**overrides):
4545
({"volume_strains": [-0.02, 0.0, 0.0, 0.02]}, "duplicates"),
4646
({"temperatures": [50, 10]}, "temperatures must be strictly increasing"),
4747
({"volume_strains": [-0.02, 0.0, 0.03]}, "symmetric"),
48+
({"MESH": []}, "MESH"),
49+
({"MESH": [20, 20]}, "MESH"),
50+
({"MESH": [20, 0, 20]}, "MESH"),
51+
({"MESH": [20, True, 20]}, "MESH"),
4852
],
4953
)
5054
def test_gruneisen_validation_rejects_low_cost_invalid_options(overrides, message):
@@ -67,6 +71,10 @@ def test_gruneisen_validation_rejects_low_cost_invalid_options(self):
6771
({"volume_strains": [-0.02, 0.0, 0.0, 0.02]}, "duplicates"),
6872
({"temperatures": [50, 10]}, "temperatures must be strictly increasing"),
6973
({"volume_strains": [-0.02, 0.0, 0.03]}, "symmetric"),
74+
({"MESH": []}, "MESH"),
75+
({"MESH": [20, 20]}, "MESH"),
76+
({"MESH": [20, 0, 20]}, "MESH"),
77+
({"MESH": [20, True, 20]}, "MESH"),
7078
]
7179
for overrides, message in cases:
7280
with self.subTest(overrides=overrides):
@@ -110,6 +118,7 @@ def test_task_param_defaults(self):
110118
self.assertEqual(task_param["supercell_size"], [2, 2, 2])
111119
self.assertEqual(task_param["approach"], "linear")
112120
self.assertEqual(task_param["PRIMITIVE_AXES"], "P")
121+
self.assertEqual(task_param["MESH"], [20, 20, 20])
113122

114123
def test_validation_rejects_invalid_schema(self):
115124
with self.assertRaises(ValueError):
@@ -166,6 +175,10 @@ def fake_check_call(command, shell):
166175
self.assertGreater(volume_data["volume"], 0.0)
167176
self.assertGreater(volume_data["volume_per_atom"], 0.0)
168177
self.assertTrue((Path(task_dir) / "band.conf").is_file())
178+
self.assertIn(
179+
"MESH = 20 20 20",
180+
(Path(task_dir) / "band.conf").read_text(),
181+
)
169182

170183
self.assertTrue((self.target_path / "band_path.json").is_file())
171184

@@ -266,6 +279,23 @@ def fake_check_call(command, shell):
266279
self.assertIn("-c POSCAR-unitcell", calls[1])
267280
self.assertIn("--nomeshsym", calls[1])
268281

282+
def test_ensure_mesh_yaml_rejects_legacy_band_conf_without_mesh(self):
283+
task_dir = self.work_root / "missing_mesh" / "task.000000"
284+
task_dir.mkdir(parents=True)
285+
(task_dir / "FORCE_CONSTANTS").write_text("fake force constants\n")
286+
(task_dir / "band.conf").write_text("BAND = 0 0 0 0.5 0 0\n")
287+
(task_dir / "POSCAR").write_text(self.source_path.read_text())
288+
289+
gruneisen = Gruneisen(
290+
valid_gruneisen_params(),
291+
inter_param={"type": "deepmd"},
292+
)
293+
with self.assertRaisesRegex(
294+
ValueError,
295+
r"does not define MESH.*MESH=\[20, 20, 20\]",
296+
):
297+
gruneisen._ensure_mesh_yaml(str(task_dir))
298+
269299
def test_sign_only_compute_lower_from_vasp_displacement_manifest(self):
270300
gruneisen = Gruneisen(
271301
{
@@ -516,6 +546,7 @@ def test_post_process_prepares_phonon_run_inputs_for_lammps(self):
516546
self.assertIn("/root/.dp1s/bin/lmp -in in.relax.lammps", run_script)
517547
self.assertIn("python3 convert_relax_dump_to_poscar.py dump.relax POSCAR.relaxed type_map.json", run_script)
518548
self.assertIn("phonolammps in.lammps -c POSCAR --dim 2 2 2", run_script)
549+
self.assertIn("-pa 1 0 0 0 1 0 0 0 1", run_script)
519550
self.assertIn("cp POSCAR.relaxed POSCAR", run_script)
520551

521552
def test_lammps_backward_files_for_gruneisen(self):

0 commit comments

Comments
 (0)