diff --git a/.gitignore b/.gitignore index d9853b91..63ca072d 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,20 @@ elastic_* template potential all_result.json +usage +confs + +# Keep packaged skill reference docs (overrides the bare `reference` rule above) +!apex/skills/ +!apex/skills/**/ +!apex/skills/**/reference/ +!apex/skills/**/reference/** # dflow debug folders generated by examples or local runs -**/dflow_debug/ \ No newline at end of file +**/dflow_debug/ +# Generated MatMaster skill archive +apex-flow.zip + +# Large DeePMD checkpoints (fetch with scripts/fetch_models.py; keep frozen *.pb in git) +apex/skills/apex-flow/models/**/*.pt +apex/skills/apex-flow/models/**/*.partial diff --git a/README.md b/README.md index 36a321aa..f9e39acf 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ Create `global_bohrium.json` to submit workflows to the Bohrium cloud platform: ```json { - "lammps_image_name": "registry.dp.tech/dptech/prod-11045/deepmdkit-phonolammps:2.1.1", + "lammps_image_name": "registry.dp.tech/dptech/dp/native/prod-397637/deepmd-kit-phonolammps:3.1.3", "lammps_run_command":"lmp -in in.lammps", "scass_type":"c8_m31_1 * NVIDIA T4" } @@ -455,7 +455,7 @@ When you run `apex submit -c global_bohrium.json`, APEX auto-fills these default - `k8s_api_server`: `https://workflows.deepmodeling.com` - `batch_type`: `Bohrium` - `context_type`: `Bohrium` -- `apex_image_name`: `registry.dp.tech/dptech/dp/native/prod-397637/apex:1.3.0` +- `apex_image_name`: `registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post` Priority rule: values in your `-c` json file override account defaults. @@ -638,12 +638,31 @@ Property selection behavior: ### 4.5 Decohesive energy line +Decohesive builds a rigid-separation series on one user-specified Miller plane via pymatgen `SlabGenerator`. It does **not** auto-enumerate planes (unlike `surface`) and has **no** crystal-type nested overrides (unlike `gamma`). Any structure that can form that slab is supported; set `miller_index` explicitly. + +Recommended default planes by crystal family (JSON `miller_index` uses **3-index** Miller notation): + +| Crystal structure | Recommended planes | JSON `miller_index` examples | +|-------------------|--------------------|------------------------------| +| **FCC** | $(100)$, $(110)$, $(111)$ | `[1,0,0]`, `[1,1,0]`, `[1,1,1]` | +| **BCC** | $(100)$, $(110)$, $(111)$ | `[1,0,0]`, `[1,1,0]`, `[1,1,1]` | +| **Diamond** | $(100)$, $(110)$, $(111)$ | `[1,0,0]`, `[1,1,0]`, `[1,1,1]` | +| **Zinc blende** | $(100)$, $(110)$, $(111)$ | `[1,0,0]`, `[1,1,0]`, `[1,1,1]` | +| **Rocksalt** | $(100)$, $(110)$, $(111)$ | `[1,0,0]`, `[1,1,0]`, `[1,1,1]` | +| **HCP** | $(0001)$, $(10\bar{1}0)$, $(11\bar{2}0)$ | `[0,0,1]`, `[1,0,0]`, `[1,1,0]` | +| **Perovskite** | $(001)$, $(110)$, $(111)$ | `[0,0,1]`, `[1,1,0]`, `[1,1,1]` | + +Notes: + +- HCP must use the **3-index** values above. Four-index Miller–Bravais vectors (e.g. `[0,0,0,1]`) are **not** accepted by `decohesive` (no Bravais conversion). +- Polar / multi-termination faces (common for zinc blende $(111)$ and some perovskite cuts) still generate; APEX takes the first matching slab termination from pymatgen. + | Key | Type | Example | Description | |-----|------|---------|-------------| | `min_slab_size` | Integer | `10` | Minimum slab thickness. | | `max_vacuum_size` | Integer | `11` | Maximum vacuum width. | | `pert_xz` | Float | `0.01` | Perturbation along xz plane for surface energy. | -| `miller_miller` | List[Int] | `[1, 1, 0]` | Miller indices of the target plane. | +| `miller_index` | List[Int] | `[1, 1, 0]` | Miller indices of the target plane (**required**, 3-index). | ### 4.6 Elastic @@ -733,6 +752,7 @@ Key parameters: | `vacuum_size` | Float | `0` | Added vacuum layer thickness (Å). | | `supercell_size` | Sequence[Int] | `[1, 1, 5]` | Slab supercell size. | | `add_fix` | Sequence[String] | `["true","true","false"]` | Position constraints along x/y/z. | +| `closed_loop` | Bool | `false` | when `true`, derive two periodic in-plane translation vectors from the generated slab. and slip_length or slip_length_y will be **ignored** | Example: @@ -778,6 +798,7 @@ adds vacuum along the selected fault normal for slab/free-surface calculations. "slip_direction": [1, -1, -1], "supercell_size": [1, 1, 20], "vacuum_size": 15, + "closed_loop": false, "add_fix": ["true", "true", "false"], "n_steps_x": 20, "n_steps_y": 20 @@ -788,7 +809,7 @@ adds vacuum along the selected fault normal for slab/free-surface calculations. APEX integrates parts of [dflow-phonon](https://github.com/Chengqian-Zhang/dflow-phonon) and wraps [Phonopy](https://github.com/phonopy/phonopy) / [phonoLAMMPS](https://github.com/abelcarreras/phonolammps). [SeeK-path](https://seekpath.readthedocs.io/en/latest/index.html) automatically generates high-symmetry k-paths. -> **Important:** Ensure the `run_image` (or local environment in debug mode) contains `phonoLAMMPS` when running LAMMPS-based phonon workflows. +> **Important:** LAMMPS phonon and Grüneisen workflows always use `registry.dp.tech/dptech/dp/native/prod-397637/deepmd-kit-phonolammps:3.1.3`. During `apex submit`, APEX overrides any other configured LAMMPS image for these properties. | Key | Type | Default | Description | |-----|------|---------|-------------| @@ -796,7 +817,7 @@ APEX integrates parts of [dflow-phonon](https://github.com/Chengqian-Zhang/dflow | `approach` | String | `"linear"` | VASP phonon method: `"linear"` or `"displacement"`. | | `supercell_size` | Sequence[Int] | `[2, 2, 2]` | Supercell dimensions. | | `MESH` | Sequence[Int] | `None` | Reciprocal-space mesh (e.g., `[8, 8, 8]`). | -| `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"`). | +| `PRIMITIVE_AXES` | String | `"P"` | Primitive axes used consistently by phonopy and phonoLAMMPS; accepts `"P"`, `"AUTO"`, or a 3×3 matrix. | | `BAND` | String | `None` | Band path definition (falls back to SeeK-path when omitted). | | `BAND_LABELS` | String | `None` | Labels for band segments. | | `BAND_POINTS` | Integer | `51` | Number of sampling points per segment. | @@ -820,8 +841,8 @@ APEX supports Grüneisen workflows based on phonon calculations at multiple stra | `primitive` | Bool | `false` | Reduce to primitive cell before phonon calculation. | | `approach` | String | `"linear"` | Phonon workflow approach; VASP Grüneisen currently uses linear response. | | `supercell_size` | Sequence[Int] | `[2, 2, 2]` | Phonon supercell dimensions. | -| `MESH` | Sequence[Int] | `None` | Reciprocal-space mesh for mode summation. | -| `PRIMITIVE_AXES` | String | `None` | Custom primitive axes definition. | +| `MESH` | Sequence[Int] | `[20, 20, 20]` | Three positive reciprocal-space mesh dimensions for mode summation. | +| `PRIMITIVE_AXES` | String | `"P"` | Primitive axes used consistently for displacement generation and post-processing. | | `BAND` | String | `None` | Band path definition (falls back to SeeK-path when omitted). | | `BAND_LABELS` | String | `None` | Labels for band segments. | | `BAND_POINTS` | Integer | `51` | Number of sampling points per segment. | diff --git a/apex/__init__.py b/apex/__init__.py index 220d954f..ffd2caa0 100644 --- a/apex/__init__.py +++ b/apex/__init__.py @@ -1,5 +1,5 @@ import os -__version__ = '1.3.0.post1' +__version__ = '1.3.0.post5' LOCAL_PATH = os.getcwd() diff --git a/apex/account.py b/apex/account.py index 42e575b2..48ec9095 100644 --- a/apex/account.py +++ b/apex/account.py @@ -13,7 +13,7 @@ "k8s_api_server": BOHRIUM_WORKFLOWS_HOST, "batch_type": "Bohrium", "context_type": "Bohrium", - "apex_image_name": "registry.dp.tech/dptech/dp/native/prod-397637/apex:1.3.0", + "apex_image_name": "registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post", } SENSITIVE_KEYS = {"password"} ACCOUNT_FILE_ENV = "APEX_ACCOUNT_FILE" diff --git a/apex/config.py b/apex/config.py index 7fd4aee5..b26d3cb2 100644 --- a/apex/config.py +++ b/apex/config.py @@ -60,7 +60,10 @@ class Config: pool_size: int = None upload_python_packages: list = field(default_factory=list) exclude_upload_files: list = field(default_factory=list) - lammps_image_name: str = None + lammps_image_name: str = ( + "registry.dp.tech/dptech/dp/native/prod-397637/" + "deepmd-kit-phonolammps:3.1.3" + ) lammps_run_command: str = None phonolammps_run_command: str = None vasp_image_name: str = None diff --git a/apex/core/calculator/Lammps.py b/apex/core/calculator/Lammps.py index 661607e5..1084fd36 100644 --- a/apex/core/calculator/Lammps.py +++ b/apex/core/calculator/Lammps.py @@ -123,8 +123,12 @@ def _apply_gamma_fix_to_lammps_input(contents, add_fix): elif line.split()[:4] == ["variable", "N", "equal", "count(all)"]: upper_id = idx break - if lower_id is None or upper_id is None: - return contents + if lower_id is None or upper_id is None or lower_id >= upper_id: + raise RuntimeError( + "Gamma add_fix was requested, but the generated LAMMPS input does " + "not contain a compatible minimization block with 'min_style cg' " + "and 'variable N equal count(all)' markers" + ) del lines[lower_id + 1 : upper_id - 1] lines.insert(lower_id + 1, add_fix_str) return "".join(lines) @@ -386,6 +390,18 @@ def make_input_file(self, output_dir, task_type, task_param): else: raise RuntimeError("not supported calculation setting for LAMMPS") + elif cal_type == "static": + fc = lammps_utils.make_lammps_eval( + "conf.lmp", self.type_map, self.inter_func, self.model_param + ) + elif cal_type == "finite_t_elastic": + fc = lammps_utils.make_lammps_FiniteTelastic( + "conf.lmp", + self.type_map, + self.inter_func, + self.model_param, + output_dir, + ) elif task_type in ["annealing", "Annealing"]: # MD annealing schedule: equilibrate -> ramp -> hold -> cool fc = lammps_utils.make_lammps_annealing( @@ -396,11 +412,7 @@ def make_input_file(self, output_dir, task_type, task_param): cal_setting, ) - elif cal_type == "static": - fc = lammps_utils.make_lammps_eval( - "conf.lmp", self.type_map, self.inter_func, self.model_param - ) - elif cal_type == "npt+ave/time": + elif task_type == "finite_t_latt": fc = lammps_utils.make_lammps_FiniteTlatt( "conf.lmp", self.type_map, @@ -408,14 +420,6 @@ def make_input_file(self, output_dir, task_type, task_param): self.model_param, cal_setting, ) - elif cal_type == "finite_t_elastic": - fc = lammps_utils.make_lammps_FiniteTelastic( - "conf.lmp", - self.type_map, - self.inter_func, - self.model_param, - output_dir, - ) else: raise RuntimeError("not supported calculation type for LAMMPS") @@ -423,7 +427,7 @@ def make_input_file(self, output_dir, task_type, task_param): if ( prop_type in {"gamma", "gamma_surface"} and cal_type == "relaxation" - and "add_fix" in task_param + and task_param.get("add_fix") is not None ): fc = _apply_gamma_fix_to_lammps_input(fc, task_param["add_fix"]) diff --git a/apex/core/property/Decohesive.py b/apex/core/property/Decohesive.py index 4235450c..208b9438 100644 --- a/apex/core/property/Decohesive.py +++ b/apex/core/property/Decohesive.py @@ -258,17 +258,50 @@ def __gen_slab_pmg( sorted_frac_coords, sorted_species = zip(*ordered) a_vec, b_vec, c_vec = slab.lattice.matrix - slab_height = abs(c_vec[2]) + # ``reorient_lattice=False`` preserves the input orientation, so the + # slab-normal c vector is not necessarily aligned with Cartesian z. + # Using c_vec[2] therefore produces a zero divisor for valid surfaces + # such as bcc (100) and (110), yielding NaNs in the LAMMPS data file. + slab_height = np.linalg.norm(c_vec) + if slab_height <= np.finfo(float).eps: + raise RuntimeError("Generated slab has a zero-length c lattice vector") self.is_flip = c_vec[2] < 0 elong_scale = 1 + (abs(vacuum_size) / slab_height) - new_lattice = [a_vec, b_vec, elong_scale * c_vec] + new_lattice = np.array([a_vec, b_vec, elong_scale * c_vec], dtype=float) new_frac_coords = [] for frac in sorted_frac_coords: - coord = frac.copy() + coord = np.array(frac, dtype=float) coord[2] = coord[2] / elong_scale new_frac_coords.append(coord) + # LAMMPS requires a right-handed box: det([a, b, c]) > 0. + # Slab construction (odd axis permutations / mirroring) can produce a + # left-handed cell; swapping a↔b restores chirality without changing + # the physical slab. + new_lattice, new_frac_coords = _ensure_right_handed_cell( + new_lattice, new_frac_coords + ) + return Structure( - lattice=np.array(new_lattice), coords=new_frac_coords, species=sorted_species + lattice=new_lattice, coords=new_frac_coords, species=sorted_species + ) + + +def _ensure_right_handed_cell(lattice, frac_coords): + """Swap a↔b when det([a, b, c]) ≤ 0 so the cell is right-handed.""" + lattice = np.array(lattice, dtype=float) + det = np.linalg.det(lattice) + if det > 0: + return lattice, [np.array(fc, dtype=float) for fc in frac_coords] + if abs(det) <= np.finfo(float).eps: + raise RuntimeError( + "Generated slab has a singular lattice (det ≈ 0); cannot convert to LAMMPS" ) + lattice = lattice[[1, 0, 2]] + fixed_frac = [] + for frac in frac_coords: + coord = np.array(frac, dtype=float) + coord[0], coord[1] = coord[1], coord[0] + fixed_frac.append(coord) + return lattice, fixed_frac diff --git a/apex/core/property/GammaSurface.py b/apex/core/property/GammaSurface.py index 7835fd1b..c4a3868e 100644 --- a/apex/core/property/GammaSurface.py +++ b/apex/core/property/GammaSurface.py @@ -31,8 +31,13 @@ class GammaSurface(Property): """Calculation of generalized stacking fault energy surface.""" def __init__(self, parameter, inter_param=None): + self._add_fix_explicit = "add_fix" in parameter parameter["reproduce"] = parameter.get("reproduce", False) self.reprod = parameter["reproduce"] + parameter["closed_loop"] = parameter.get("closed_loop", False) + if not isinstance(parameter["closed_loop"], (bool, np.bool_)): + raise ValueError("gamma_surface closed_loop must be a boolean") + self.closed_loop = bool(parameter["closed_loop"]) if not self.reprod: if not ("init_from_suffix" in parameter and "output_suffix" in parameter): parameter["plane_miller"] = parameter.get("plane_miller", None) @@ -62,6 +67,8 @@ def __init__(self, parameter, inter_param=None): parameter["n_steps_y"] = parameter.get("n_steps_y", self.n_steps_x) self.n_steps_y = parameter["n_steps_y"] self.atom_num = None + else: + self.add_fix = parameter.get("add_fix") parameter["cal_type"] = parameter.get("cal_type", "relaxation") default_cal_setting = { "relax_pos": True, @@ -79,6 +86,13 @@ def __init__(self, parameter, inter_param=None): parameter["init_from_suffix"] = parameter.get("init_from_suffix", "00") self.init_from_suffix = parameter["init_from_suffix"] self.cal_type = parameter["cal_type"] + if self.cal_type == "static": + if self._add_fix_explicit and parameter.get("add_fix") is not None: + raise RuntimeError( + "gamma_surface add_fix is only valid for relaxation calculations" + ) + parameter["add_fix"] = None + self.add_fix = None parameter["cal_setting"] = parameter.get("cal_setting", default_cal_setting) for key in default_cal_setting: parameter["cal_setting"].setdefault(key, default_cal_setting[key]) @@ -115,7 +129,13 @@ def _fix_task_output(self, task_dir, first_task): return self.__poscar_fix(os.path.join(task_dir, "POSCAR")) - def make_confs(self, path_to_work, path_to_equi, refine=False): + def make_confs( + self, + path_to_work, + path_to_equi, + refine=False, + require_relaxation_result=True, + ): path_to_work = os.path.abspath(path_to_work) if os.path.exists(path_to_work): logging.debug("%s already exists" % path_to_work) @@ -172,28 +192,73 @@ def make_confs(self, path_to_work, path_to_equi, refine=False): count=1, )[::-1] task_list_basename = list(map(os.path.basename, task_list)) + if not task_list_basename: + raise RuntimeError( + "gamma_surface refine found no source task directories" + ) for ii in task_list_basename: init_from_task = os.path.join(init_from_path, ii) output_task = os.path.join(path_to_work, ii) - os.chdir(output_task) - if os.path.exists("miller.json"): - os.remove("miller.json") - os.symlink( - os.path.relpath(os.path.join(init_from_task, "miller.json")), + for metadata in ( "miller.json", - ) + "displacement.json", + "slip_length_x.json", + "slip_length_y.json", + "slip_vector_x.json", + "slip_vector_y.json", + ): + source = os.path.join(init_from_task, metadata) + if not os.path.exists(source): + if metadata in { + "miller.json", + "displacement.json", + "slip_length_x.json", + "slip_length_y.json", + }: + raise FileNotFoundError( + f"gamma_surface refine metadata is missing: {source}" + ) + continue + target = os.path.join(output_task, metadata) + if os.path.lexists(target): + os.remove(target) + os.symlink( + os.path.relpath(source, output_task), + target, + ) + + source_task_json = os.path.join( + init_from_path, task_list_basename[0], "task.json" + ) + if not self._add_fix_explicit and os.path.isfile(source_task_json): + source_parameter = loadfn(source_task_json) + self.add_fix = source_parameter.get("add_fix") + self.parameter["add_fix"] = self.add_fix + self.closed_loop = all( + os.path.isfile(os.path.join(task, "slip_vector_x.json")) + and os.path.isfile(os.path.join(task, "slip_vector_y.json")) + for task in task_list + ) + self.parameter["closed_loop"] = self.closed_loop else: equi_contcar, POSCAR = self._resolve_equilibrium_structure(path_to_equi) if not os.path.exists(equi_contcar): raise RuntimeError("please do relaxation first") + equi_result = os.path.join(path_to_equi, "result.json") + if require_relaxation_result and not os.path.isfile(equi_result): + raise RuntimeError( + "gamma_surface requires relaxation/relax_task/result.json; " + "please finish relaxation post-processing first" + ) ptypes, ss = self._load_equilibrium_structure(equi_contcar) os.chdir(path_to_equi) ss.to("CONTCAR.direct", "POSCAR") ss = Structure.from_file("CONTCAR.direct") + os.chdir(cwd) st = StructureInfo(ss) self.structure_type = st.lattice_structure self.conv_std_structure = st.conventional_structure @@ -207,6 +272,10 @@ def make_confs(self, path_to_work, path_to_equi, refine=False): self.slip_direction = type_param.get("slip_direction", self.slip_direction) self.slip_length = type_param.get("slip_length", self.slip_length) self.slip_length_y = type_param.get("slip_length_y", self.slip_length_y) + closed_loop = type_param.get("closed_loop", self.closed_loop) + if not isinstance(closed_loop, (bool, np.bool_)): + raise ValueError("gamma_surface closed_loop must be a boolean") + self.closed_loop = bool(closed_loop) self.plane_shift = type_param.get("plane_shift", self.plane_shift) self.supercell_size = type_param.get("supercell_size", self.supercell_size) self.vacuum_size = type_param.get("vacuum_size", self.vacuum_size) @@ -221,6 +290,28 @@ def make_confs(self, path_to_work, path_to_equi, refine=False): raise RuntimeError( "fail to obtain both slip plane and slip direction info from input json file!" ) + if any( + isinstance(value, (bool, np.bool_)) + or not isinstance(value, (int, np.integer)) + or value <= 0 + for value in (self.n_steps_x, self.n_steps_y) + ): + raise RuntimeError( + "n_steps_x and n_steps_y must be positive integers" + ) + self.n_steps_x = int(self.n_steps_x) + self.n_steps_y = int(self.n_steps_y) + if self.closed_loop and ( + self.slip_length is not None or self.slip_length_y is not None + ): + raise RuntimeError( + "closed_loop=true derives periodic slip vectors and cannot " + "be combined with slip_length/slip_length_y; set " + "closed_loop=false to use custom slip lengths" + ) + self.parameter["closed_loop"] = self.closed_loop + self.parameter["n_steps_x"] = self.n_steps_x + self.parameter["n_steps_y"] = self.n_steps_y if self.structure_type not in ["bcc", "fcc", "hcp"]: logging.warning( @@ -242,28 +333,40 @@ def make_confs(self, path_to_work, path_to_equi, refine=False): os.remove(POSCAR) os.symlink(os.path.relpath(equi_contcar), POSCAR) - slip_length_x = self.__resolve_slip_length( - slip_length_x, relax_a, relax_b, relax_c - ) - if self.slip_length_y is None: - self.slip_length_y = slip_length_x - slip_length_y = self.__resolve_slip_length( - self.slip_length_y, relax_a, relax_b, relax_c - ) + if self.closed_loop: + slip_vector_x = self.__find_periodic_slip_vector(slab, axis=0) + slip_vector_y = self.__find_independent_periodic_slip_vector( + slab, slip_vector_x + ) + self.__validate_closed_loop( + slab, slip_vector_x, slip_vector_y + ) + slip_length_x = float(np.linalg.norm(slip_vector_x)) + slip_length_y = float(np.linalg.norm(slip_vector_y)) + else: + slip_length_x = self.__resolve_slip_length( + slip_length_x, relax_a, relax_b, relax_c + ) + if self.slip_length_y is None: + slip_length_y = slip_length_x + else: + slip_length_y = self.__resolve_slip_length( + self.slip_length_y, relax_a, relax_b, relax_c + ) + slip_vector_x = np.array([slip_length_x, 0.0, 0.0]) + slip_vector_y = np.array([0.0, slip_length_y, 0.0]) self.slip_length = slip_length_x self.slip_length_y = slip_length_y top_atoms = np.where(slab.frac_coords[:, 2] > 0.5)[0] - n_steps_x = int(self.n_steps_x) - n_steps_y = int(self.n_steps_y) - n_steps_x_denom = max(n_steps_x, 1) - n_steps_y_denom = max(n_steps_y, 1) + n_steps_x = self.n_steps_x + n_steps_y = self.n_steps_y count = 0 for idx_x in range(n_steps_x + 1): - frac_x = idx_x / n_steps_x_denom + frac_x = idx_x / n_steps_x for idx_y in range(n_steps_y + 1): - frac_y = idx_y / n_steps_y_denom + frac_y = idx_y / n_steps_y output_task = os.path.join(path_to_work, "task.%06d" % count) os.makedirs(output_task, exist_ok=True) os.chdir(output_task) @@ -272,8 +375,8 @@ def make_confs(self, path_to_work, path_to_equi, refine=False): os.remove(jj) task_list.append(output_task) - disp_cart = np.array( - [slip_length_x * frac_x, slip_length_y * frac_y, 0.0] + disp_cart = ( + slip_vector_x * frac_x + slip_vector_y * frac_y ) slab_task = slab.copy() if np.linalg.norm(disp_cart) > 0: @@ -291,12 +394,16 @@ def make_confs(self, path_to_work, path_to_equi, refine=False): dumpfn(self.plane_miller, "miller.json") dumpfn(slip_length_x, "slip_length_x.json") dumpfn(slip_length_y, "slip_length_y.json") + if self.closed_loop: + dumpfn(slip_vector_x.tolist(), "slip_vector_x.json") + dumpfn(slip_vector_y.tolist(), "slip_vector_y.json") dumpfn( { "frac_x": frac_x, "frac_y": frac_y, "idx_x": idx_x, "idx_y": idx_y, + "disp_cart": disp_cart.tolist(), }, "displacement.json", ) @@ -305,6 +412,127 @@ def make_confs(self, path_to_work, path_to_equi, refine=False): os.chdir(cwd) return task_list + @staticmethod + def __find_periodic_slip_vector( + slab: Structure, + axis: int, + max_index: int = 24, + direction_tol: float = 1e-3, + ) -> np.ndarray: + """Find the shortest in-plane lattice translation along a Cartesian axis.""" + if axis not in (0, 1): + raise ValueError("axis must be 0 (x) or 1 (y)") + + target = np.zeros(3) + target[axis] = 1.0 + inplane = np.asarray(slab.lattice.matrix[:2], dtype=float) + candidates = [] + for ii in range(-max_index, max_index + 1): + for jj in range(-max_index, max_index + 1): + if ii == 0 and jj == 0: + continue + vector = ii * inplane[0] + jj * inplane[1] + length = float(np.linalg.norm(vector)) + if length == 0: + continue + projection = float(np.dot(vector, target)) + if projection <= 0: + continue + perpendicular = vector - projection * target + if np.linalg.norm(perpendicular) / length <= direction_tol: + candidates.append((length, abs(ii) + abs(jj), vector)) + + if not candidates: + axis_name = "x" if axis == 0 else "y" + raise RuntimeError( + "Cannot find an in-plane slab lattice translation aligned with " + f"the {axis_name} slip axis (search limit={max_index}, relative " + f"tolerance={direction_tol}). Increase the in-plane supercell " + "or set closed_loop=false." + ) + return min(candidates, key=lambda item: (item[0], item[1]))[2] + + @staticmethod + def __find_independent_periodic_slip_vector( + slab: Structure, + reference_vector: np.ndarray, + max_index: int = 24, + independence_tol: float = 1e-8, + ) -> np.ndarray: + """Find the shortest in-plane translation independent of a reference.""" + reference_vector = np.asarray(reference_vector, dtype=float) + reference_length = float(np.linalg.norm(reference_vector)) + if reference_length == 0: + raise ValueError("reference_vector must be non-zero") + + inplane = np.asarray(slab.lattice.matrix[:2], dtype=float) + candidates = [] + for ii in range(-max_index, max_index + 1): + for jj in range(-max_index, max_index + 1): + if ii == 0 and jj == 0: + continue + vector = ii * inplane[0] + jj * inplane[1] + length = float(np.linalg.norm(vector)) + if length == 0: + continue + relative_area = ( + np.linalg.norm(np.cross(reference_vector, vector)) + / (reference_length * length) + ) + if relative_area <= independence_tol: + continue + if vector[1] < 0 or ( + np.isclose(vector[1], 0.0) and vector[0] < 0 + ): + vector = -vector + y_misalignment = abs(float(vector[0])) / length + candidates.append( + (length, y_misalignment, abs(ii) + abs(jj), vector) + ) + + if not candidates: + raise RuntimeError( + "Cannot find a second in-plane slab lattice translation " + "independent of the x slip vector. Increase the in-plane " + "supercell or set closed_loop=false." + ) + return min(candidates, key=lambda item: (item[0], item[1], item[2]))[3] + + @staticmethod + def __validate_closed_loop( + slab: Structure, + slip_vector_x: np.ndarray, + slip_vector_y: np.ndarray, + tol: float = 1e-8, + ) -> None: + """Verify that translating the upper slab closes all grid corners.""" + top_atoms = np.where(slab.frac_coords[:, 2] > 0.5)[0] + if len(top_atoms) == 0 or len(top_atoms) == len(slab): + raise RuntimeError( + "Cannot define gamma_surface fault plane: the slab is not split " + "into lower and upper atom groups" + ) + for label, vector in ( + ("(1,0)", slip_vector_x), + ("(0,1)", slip_vector_y), + ("(1,1)", slip_vector_x + slip_vector_y), + ): + translated = slab.copy() + translated.translate_sites( + indices=top_atoms, + vector=vector, + frac_coords=False, + to_unit_cell=True, + ) + delta = translated.frac_coords - slab.frac_coords + delta -= np.rint(delta) + if not np.allclose(delta, 0.0, atol=tol, rtol=0.0): + max_error = float(np.max(np.abs(delta))) + raise RuntimeError( + f"Gamma-surface corner {label} is not closed under PBC " + f"(maximum fractional-coordinate error {max_error:.3e})" + ) + def __resolve_slip_length(self, slip_length, relax_a, relax_b, relax_c): if isinstance(slip_length, (int, float)): return float(slip_length) * relax_a @@ -394,8 +622,15 @@ def __gen_slab_pmg(self, structure: Structure, plane_miller, trans_matrix=None) primitive=False, ) slabs_pmg = slab_gen.get_slabs(ftol=0.001) - slab = [s for s in slabs_pmg if s.miller_index == plane_miller][0] - if trans_matrix.any(): + matching_slabs = [ + slab for slab in slabs_pmg if slab.miller_index == plane_miller + ] + if not matching_slabs: + raise RuntimeError( + f"Cannot generate a gamma_surface slab for Miller plane {plane_miller}" + ) + slab = matching_slabs[0] + if trans_matrix is not None and np.asarray(trans_matrix).any(): reoriented_lattice_vectors = [trans_matrix.dot(v) for v in slab.lattice.matrix] slab = Structure( lattice=np.matrix(reoriented_lattice_vectors), @@ -476,6 +711,8 @@ def __inLammpes_fix(self, inLammps) -> None: ) with open(inLammps, "r") as fin1: contents = fin1.readlines() + lower_id = None + upper_id = None for ii in range(len(contents)): upper = contents[ii].split()[:3] == ["variable", "N", "equal"] lower = re.search("min_style cg", contents[ii]) @@ -483,6 +720,12 @@ def __inLammpes_fix(self, inLammps) -> None: lower_id = ii elif upper: upper_id = ii + if lower_id is None or upper_id is None or lower_id >= upper_id: + raise RuntimeError( + "Gamma-surface add_fix was requested, but in.lammps does not " + "contain a compatible minimization block with 'min_style cg' " + "and 'variable N equal' markers" + ) del contents[lower_id + 1 : upper_id - 1] contents.insert(lower_id + 1, add_fix_str) with open(inLammps, "w") as fin2: @@ -517,13 +760,29 @@ def _compute_lower(self, output_file, all_tasks, all_res): if not self.reprod: ptr_data += ( - "No_task: \tFrac_X\tFrac_Y\tDisp_X(\\AA)\tDisp_Y(\\AA)\t" + "No_task: \tFrac_X\tFrac_Y\tPath_X(\\AA)\tPath_Y(\\AA)\t" + "Cart_X(\\AA)\tCart_Y(\\AA)\tCart_Z(\\AA)\t" "Stacking_Fault_E(J/m^2)\tEpA(eV)\tslab_equi_EpA(eV)\n" ) all_tasks.sort() task_result_slab_equi = loadfn(os.path.join(all_tasks[0], "result_task.json")) slip_length_x = loadfn(os.path.join(all_tasks[0], "slip_length_x.json")) slip_length_y = loadfn(os.path.join(all_tasks[0], "slip_length_y.json")) + slip_vector_x_path = os.path.join(all_tasks[0], "slip_vector_x.json") + slip_vector_y_path = os.path.join(all_tasks[0], "slip_vector_y.json") + has_periodic_basis = os.path.isfile( + slip_vector_x_path + ) and os.path.isfile(slip_vector_y_path) + slip_vector_x = ( + np.asarray(loadfn(slip_vector_x_path), dtype=float) + if has_periodic_basis + else np.array([slip_length_x, 0.0, 0.0]) + ) + slip_vector_y = ( + np.asarray(loadfn(slip_vector_y_path), dtype=float) + if has_periodic_basis + else np.array([0.0, slip_length_y, 0.0]) + ) equi_path = os.path.abspath( os.path.join(os.path.dirname(output_file), "../relaxation/relax_task") ) @@ -536,7 +795,7 @@ def _compute_lower(self, output_file, all_tasks, all_res): epa = task_result["energies"][-1] / natoms equi_epa_slab = task_result_slab_equi["energies"][-1] / natoms area = np.linalg.norm( - np.cross(task_result["cells"][0][0], task_result["cells"][0][1]) + np.cross(task_result["cells"][-1][0], task_result["cells"][-1][1]) ) structure_dir = os.path.basename(ii) @@ -550,28 +809,48 @@ def _compute_lower(self, output_file, all_tasks, all_res): * cf ) miller_index = loadfn(os.path.join(ii, "miller.json")) - disp_x = slip_length_x * frac_x - disp_y = slip_length_y * frac_y + path_x = slip_length_x * frac_x + path_y = slip_length_y * frac_y + disp_cart = np.asarray( + disp_info.get( + "disp_cart", + slip_vector_x * frac_x + slip_vector_y * frac_y, + ), + dtype=float, + ) ptr_data += ( - "%-25s %7.3f %7.3f %7.3f %7.3f %7.3f %8.3f %8.3f\n" + "%-25s %7.3f %7.3f %7.3f %7.3f " + "%7.3f %7.3f %7.3f %7.3f %8.3f %8.3f\n" % ( str(miller_index) + "-" + structure_dir + ":", frac_x, frac_y, - disp_x, - disp_y, + path_x, + path_y, + disp_cart[0], + disp_cart[1], + disp_cart[2], sfe, epa, equi_epa_slab, ) ) - res_data[f"{frac_x:.6f},{frac_y:.6f}"] = [ - disp_x, - disp_y, + result_entry = [ + path_x, + path_y, sfe, epa, equi_epa, ] + if has_periodic_basis: + result_entry.append( + { + "disp_cart": disp_cart.tolist(), + "slip_vector_x": slip_vector_x.tolist(), + "slip_vector_y": slip_vector_y.tolist(), + } + ) + res_data[f"{frac_x:.6f},{frac_y:.6f}"] = result_entry else: if "init_data_path" not in self.parameter: diff --git a/apex/core/property/Gruneisen.py b/apex/core/property/Gruneisen.py index f4842f5c..1e93c3c2 100644 --- a/apex/core/property/Gruneisen.py +++ b/apex/core/property/Gruneisen.py @@ -32,6 +32,7 @@ } DEFAULT_SUPERCELL = [2, 2, 2] +DEFAULT_MESH = [20, 20, 20] THZ_TO_K = 47.99243073366221 KB_EV_PER_K = 8.617333262145e-5 @@ -49,7 +50,8 @@ def __init__(self, parameter, inter_param=None): self.seekpath_from_original = parameter["seekpath_from_original"] parameter["seekpath_param"] = parameter.get("seekpath_param", {}) self.seekpath_param = parameter["seekpath_param"] - parameter["MESH"] = parameter.get("MESH", None) + mesh = parameter.get("MESH") + parameter["MESH"] = list(DEFAULT_MESH) if mesh is None else mesh self.MESH = parameter["MESH"] parameter["PRIMITIVE_AXES"] = parameter.get("PRIMITIVE_AXES", "P") self.PRIMITIVE_AXES = parameter["PRIMITIVE_AXES"] @@ -88,6 +90,17 @@ def __init__(self, parameter, inter_param=None): self._validate() def _validate(self): + if ( + not isinstance(self.MESH, (list, tuple)) + or len(self.MESH) != 3 + or any( + not isinstance(value, int) + or isinstance(value, bool) + or value <= 0 + for value in self.MESH + ) + ): + raise ValueError("gruneisen MESH must contain exactly 3 positive integers") if len(self.volume_strains) < 3: raise ValueError("volume_strains must contain at least 3 points") if len(self.temperatures) < 1: @@ -326,8 +339,11 @@ def _make_abacus_confs(self, path_to_work: str, path_to_equi: str) -> List[str]: "ATOM_NAME =%s\n" % "".join(f" {name}" for name in vasp_utils.get_poscar_types("POSCAR")) ) - if self.PRIMITIVE_AXES: - fp.write(f"PRIMITIVE_AXES = {self.PRIMITIVE_AXES}\n") + primitive_axes = Phonon.primitive_axes_config_value( + self.PRIMITIVE_AXES + ) + if primitive_axes: + fp.write(f"PRIMITIVE_AXES = {primitive_axes}\n") subprocess.check_call( Phonon.phonopy_setup_command("setting.conf --abacus -d"), shell=True, @@ -484,7 +500,15 @@ def post_process(self, task_list): def _prepare_vasp_phonon_task(self) -> None: if self.primitive: - subprocess.check_call(Phonon.phonopy_setup_command("--symmetry"), shell=True) + subprocess.check_call( + Phonon.phonopy_setup_command( + Phonon._join_phonopy_arguments( + "--symmetry", + Phonon.primitive_axes_setup_argument(self.PRIMITIVE_AXES), + ) + ), + shell=True, + ) if not os.path.isfile("PPOSCAR"): raise FileNotFoundError("PPOSCAR was not created by phonopy --symmetry") shutil.copyfile("PPOSCAR", "POSCAR-unitcell") @@ -494,8 +518,11 @@ def _prepare_vasp_phonon_task(self) -> None: subprocess.check_call( Phonon.phonopy_setup_command( - '-d --dim="%s %s %s" -c POSCAR' - % (self.supercell_size[0], self.supercell_size[1], self.supercell_size[2]) + Phonon._join_phonopy_arguments( + '-d --dim="%s %s %s" -c POSCAR' + % (self.supercell_size[0], self.supercell_size[1], self.supercell_size[2]), + Phonon.primitive_axes_setup_argument(self.PRIMITIVE_AXES), + ) ), shell=True, ) @@ -671,10 +698,10 @@ def _build_band_payload(self, poscar_path: str) -> dict: self.supercell_size[2], ) ) - if self.MESH: - lines.append("MESH = %s %s %s" % (self.MESH[0], self.MESH[1], self.MESH[2])) - if self.PRIMITIVE_AXES: - lines.append(f"PRIMITIVE_AXES = {self.PRIMITIVE_AXES}") + lines.append("MESH = %s %s %s" % (self.MESH[0], self.MESH[1], self.MESH[2])) + primitive_axes = Phonon.primitive_axes_config_value(self.PRIMITIVE_AXES) + if primitive_axes: + lines.append(f"PRIMITIVE_AXES = {primitive_axes}") lines.append(f"BAND = {band_string}") if band_labels: lines.append(f"BAND_LABELS = {band_labels}") @@ -790,15 +817,28 @@ def _ensure_mesh_yaml(self, task_dir: str) -> None: raise FileNotFoundError(f"FORCE_CONSTANTS not found in {task_dir}") if not os.path.isfile(band_conf): raise FileNotFoundError(f"band.conf not found in {task_dir}") + with open(band_conf, "r") as fp: + has_mesh = any( + line.partition("=")[0].strip().upper() == "MESH" + for line in fp + ) + if not has_mesh: + raise ValueError( + f"band.conf in {task_dir} does not define MESH; " + f"Gruneisen requires MESH={self.MESH}. Regenerate the task inputs." + ) if not os.path.isfile(poscar): raise FileNotFoundError(f"POSCAR not found in {task_dir}") os.chdir(task_dir) cell_file = "POSCAR-unitcell" if self.inter_param["type"] == "vasp" else "POSCAR" - command = Phonon.phonopy_command( - '--nomeshsym --dim="%s %s %s" -c %s band.conf' - % (self.supercell_size[0], self.supercell_size[1], self.supercell_size[2], cell_file) + Phonon.run_first_success( + Phonon.phonopy_load_commands( + supercell_size=self.supercell_size, + cell_file=cell_file, + extra_args="--nomeshsym", + ), + required_file="mesh.yaml", ) - subprocess.check_call(command, shell=True) if not os.path.isfile(mesh_path): raise FileNotFoundError(f"mesh.yaml was not created in {task_dir}") @@ -869,7 +909,7 @@ def _ensure_vasp_volume_outputs( try: os.chdir(helper_dir) Phonon.run_first_success( - Phonon.phonopy_writefc_commands( + Phonon.phonopy_writefc_load_commands( '--dim="%s %s %s" -c POSCAR-unitcell --writefc' % ( self.supercell_size[0], @@ -887,12 +927,12 @@ def _ensure_vasp_volume_outputs( cwd = os.getcwd() try: os.chdir(helper_dir) - subprocess.check_call( - Phonon.phonopy_command( - '--dim="%s %s %s" -c POSCAR-unitcell band.conf' - % (self.supercell_size[0], self.supercell_size[1], self.supercell_size[2]) + Phonon.run_first_success( + Phonon.phonopy_load_commands( + supercell_size=self.supercell_size, + cell_file="POSCAR-unitcell", ), - shell=True, + required_file="mesh.yaml", ) self._write_band_dat() finally: @@ -957,13 +997,16 @@ def _ensure_abacus_volume_outputs( # rather than falling into old-style POSCAR mode (which has no DIM). if not os.path.isfile("FORCE_CONSTANTS"): Phonon.run_first_success( - Phonon.phonopy_writefc_commands("phonopy_disp.yaml --writefc"), + Phonon.phonopy_writefc_load_commands("phonopy_disp.yaml --writefc"), required_file="FORCE_CONSTANTS", ) if not os.path.isfile("FORCE_CONSTANTS"): raise FileNotFoundError(f"FORCE_CONSTANTS was not created in {helper_dir}") if not os.path.isfile("mesh.yaml"): - subprocess.check_call(Phonon.phonopy_command("phonopy_disp.yaml --config band.conf"), shell=True) + Phonon.run_first_success( + Phonon.phonopy_load_commands(), + required_file="mesh.yaml", + ) self._write_band_dat() finally: os.chdir(cwd) diff --git a/apex/core/property/Phonon.py b/apex/core/property/Phonon.py index f9e317ee..f681b683 100644 --- a/apex/core/property/Phonon.py +++ b/apex/core/property/Phonon.py @@ -5,6 +5,7 @@ import shutil import re import subprocess +from fractions import Fraction from typing import List, Dict, Any import dpdata @@ -25,6 +26,8 @@ class Phonon(Property): + PHONOPY_LOAD_YAML_CANDIDATES = ("phonopy_disp.yaml", "phonopy.yaml") + @staticmethod def phonopy_setup_command(arguments: str) -> str: executable = "phonopy-init" if shutil.which("phonopy-init") else "phonopy" @@ -34,14 +37,156 @@ def phonopy_setup_command(arguments: str) -> str: def phonopy_command(arguments: str) -> str: return f"phonopy {arguments}" + @staticmethod + def sorted_displacement_files(pattern: str) -> List[str]: + """Return phonopy displacement files in displacement order.""" + return sorted(glob.glob(pattern)) + @staticmethod def phonopy_writefc_commands(arguments: str) -> List[str]: setup_command = Phonon.phonopy_setup_command(arguments) phonopy_command = Phonon.phonopy_command(arguments) if setup_command == phonopy_command: return [setup_command] + if "--dim" in arguments: + return [setup_command] return [setup_command, phonopy_command] + @staticmethod + def _join_phonopy_arguments(*arguments: str) -> str: + return " ".join(argument for argument in arguments if argument) + + @staticmethod + def _format_dim(supercell_size: List[int]) -> str: + return '"%s %s %s"' % ( + supercell_size[0], + supercell_size[1], + supercell_size[2], + ) + + @staticmethod + def phonopy_load_commands( + config_file: str = "band.conf", + supercell_size: List[int] | None = None, + cell_file: str | None = None, + extra_args: str = "", + yaml_files: tuple[str, ...] | None = None, + ) -> List[str]: + yaml_files = yaml_files or Phonon.PHONOPY_LOAD_YAML_CANDIDATES + commands = [] + for yaml_file in yaml_files: + if os.path.isfile(yaml_file): + commands.append( + Phonon.phonopy_command( + Phonon._join_phonopy_arguments( + extra_args, + yaml_file, + "--config", + config_file, + ) + ) + ) + + if supercell_size is not None and cell_file is not None: + setup_command = Phonon.phonopy_setup_command( + '-d --dim=%s -c %s' + % (Phonon._format_dim(supercell_size), cell_file) + ) + commands.append( + "%s && %s" + % ( + setup_command, + Phonon.phonopy_command( + Phonon._join_phonopy_arguments( + extra_args, + "phonopy_disp.yaml", + "--config", + config_file, + ) + ), + ) + ) + if not shutil.which("phonopy-init"): + commands.append( + Phonon.phonopy_command( + Phonon._join_phonopy_arguments( + extra_args, + '--dim=%s -c %s' + % (Phonon._format_dim(supercell_size), cell_file), + config_file, + ) + ) + ) + else: + commands.append( + Phonon.phonopy_command( + Phonon._join_phonopy_arguments(extra_args, config_file) + ) + ) + + return list(dict.fromkeys(commands)) + + @staticmethod + def phonopy_writefc_load_commands( + legacy_arguments: str, + yaml_files: tuple[str, ...] | None = None, + ) -> List[str]: + yaml_files = yaml_files or Phonon.PHONOPY_LOAD_YAML_CANDIDATES + commands = [ + Phonon.phonopy_command(f"{yaml_file} --writefc") + for yaml_file in yaml_files + if os.path.isfile(yaml_file) + ] + commands.extend(Phonon.phonopy_writefc_commands(legacy_arguments)) + return list(dict.fromkeys(commands)) + + @staticmethod + def primitive_axes_setup_argument(primitive_axes: str | None) -> str: + primitive_axes = Phonon.primitive_axes_config_value(primitive_axes) + if not primitive_axes: + return "" + return f"--pa {primitive_axes}" + + @staticmethod + def primitive_axes_config_value(primitive_axes: Any) -> str | None: + if primitive_axes is None: + return None + if isinstance(primitive_axes, str): + value = primitive_axes.strip() + if not value or value.upper() == "AUTO": + return None + if value.upper() == "P": + return "P" + tokens = value.replace(",", " ").split() + elif isinstance(primitive_axes, (list, tuple)): + tokens = [] + for item in primitive_axes: + if isinstance(item, (list, tuple)): + tokens.extend(item) + else: + tokens.append(item) + else: + raise ValueError( + "PRIMITIVE_AXES must be 'P', 'AUTO', or a 3x3 numeric matrix" + ) + if len(tokens) != 9: + raise ValueError( + "PRIMITIVE_AXES must be 'P', 'AUTO', or contain exactly 9 values" + ) + try: + return " ".join(str(float(Fraction(str(token)))) for token in tokens) + except (ValueError, ZeroDivisionError) as exc: + raise ValueError("PRIMITIVE_AXES contains a non-numeric value") from exc + + @staticmethod + def primitive_axes_phonolammps_argument(primitive_axes: Any) -> str: + config_value = Phonon.primitive_axes_config_value(primitive_axes) + if config_value is None: + return "" + if config_value == "P": + config_value = "1 0 0 0 1 0 0 0 1" + return f"-pa {config_value}" + @staticmethod def run_first_success(commands: List[str], required_file: str | None = None) -> None: errors = [] @@ -55,6 +200,12 @@ def run_first_success(commands: List[str], required_file: str | None = None) -> return errors.append(FileNotFoundError(f"{required_file} was not created by: {command}")) if errors: + if required_file is not None: + attempted = "; ".join(commands) + raise RuntimeError( + f"{required_file} was not created after trying: {attempted}. " + f"Last error: {errors[-1]}" + ) from errors[-1] raise errors[-1] @staticmethod @@ -154,16 +305,30 @@ def _ensure_deepmd_plugin_loaded(self, input_text: str) -> str: def _build_phonolammps_run_command(self) -> str: dim_x, dim_y, dim_z = self.supercell_size command_template = self.phonolammps_run_command + primitive_axes = self.primitive_axes_phonolammps_argument( + self.PRIMITIVE_AXES + ) if not command_template: - return f"phonolammps in.lammps -c POSCAR --dim {dim_x} {dim_y} {dim_z} " - return command_template.format( + return self._join_phonopy_arguments( + f"phonolammps in.lammps -c POSCAR --dim {dim_x} {dim_y} {dim_z}", + primitive_axes, + ) + command = command_template.format( input_file="in.lammps", poscar="POSCAR", dim=f"{dim_x} {dim_y} {dim_z}", dim_x=dim_x, dim_y=dim_y, dim_z=dim_z, + primitive_axes=primitive_axes, ) + if ( + primitive_axes + and "{primitive_axes}" not in command_template + and not re.search(r"(?:^|\s)(?:-pa|--primitive_axis)(?:\s|=)", command) + ): + command = self._join_phonopy_arguments(command, primitive_axes) + return command def make_confs(self, path_to_work, path_to_equi, refine=False): path_to_work = os.path.abspath(path_to_work) @@ -309,8 +474,9 @@ def make_confs(self, path_to_work, path_to_equi, refine=False): ret += "MESH = %s %s %s\n" % ( self.MESH[0], self.MESH[1], self.MESH[2] ) - if self.PRIMITIVE_AXES: - ret += "PRIMITIVE_AXES = %s\n" % self.PRIMITIVE_AXES + primitive_axes = self.primitive_axes_config_value(self.PRIMITIVE_AXES) + if primitive_axes: + ret += "PRIMITIVE_AXES = %s\n" % primitive_axes ret += "BAND = %s\n" % self.BAND if self.BAND_LABELS: ret += "BAND_LABELS = %s\n" % self.BAND_LABELS @@ -347,7 +513,8 @@ def make_confs(self, path_to_work, path_to_equi, refine=False): with open("band.conf", "a") as fp: fp.write(ret) # generate task.000* - stru_list = glob.glob("STRU-0*") + # Keep task numbering aligned with phonopy_disp.yaml. + stru_list = self.sorted_displacement_files("STRU-0*") for ii in range(len(stru_list)): task_path = os.path.join(path_to_work, 'task.%06d' % ii) os.makedirs(task_path, exist_ok=True) @@ -366,7 +533,15 @@ def make_confs(self, path_to_work, path_to_equi, refine=False): # ------------make for vasp and lammps------------ if self.primitive: - subprocess.call(self.phonopy_setup_command("--symmetry"), shell=True) + subprocess.call( + self.phonopy_setup_command( + self._join_phonopy_arguments( + "--symmetry", + self.primitive_axes_setup_argument(self.PRIMITIVE_AXES), + ) + ), + shell=True, + ) subprocess.call('cp PPOSCAR POSCAR', shell=True) shutil.copyfile("PPOSCAR", "POSCAR-unitcell") else: @@ -375,11 +550,14 @@ def make_confs(self, path_to_work, path_to_equi, refine=False): # make tasks if self.inter_param["type"] == 'vasp': cmd = self.phonopy_setup_command( - "-d --dim='%d %d %d' -c POSCAR" - % ( - int(self.supercell_size[0]), - int(self.supercell_size[1]), - int(self.supercell_size[2]), + self._join_phonopy_arguments( + "-d --dim='%d %d %d' -c POSCAR" + % ( + int(self.supercell_size[0]), + int(self.supercell_size[1]), + int(self.supercell_size[2]), + ), + self.primitive_axes_setup_argument(self.PRIMITIVE_AXES), ) ) subprocess.call(cmd, shell=True) @@ -395,7 +573,8 @@ def make_confs(self, path_to_work, path_to_equi, refine=False): fp.write(ret_force_read) # finite displacement method elif self.approach == 'displacement': - poscar_list = glob.glob("POSCAR-0*") + # Keep task numbering aligned with phonopy_disp.yaml. + poscar_list = self.sorted_displacement_files("POSCAR-0*") for ii in range(len(poscar_list)): task_path = os.path.join(path_to_work, 'task.%06d' % ii) os.makedirs(task_path, exist_ok=True) @@ -622,7 +801,10 @@ def _compute_lower(self, output_file, all_tasks, all_res): if not os.path.exists("FORCE_SETS"): raise FileNotFoundError("FORCE_SETS was not created") print('FORCE_SETS is created') - subprocess.check_call(self.phonopy_command("phonopy_disp.yaml --config band.conf"), shell=True) + self.run_first_success( + self.phonopy_load_commands(), + required_file="band.yaml", + ) self.write_band_dat() elif self.inter_param["type"] == 'vasp': @@ -635,10 +817,13 @@ def _compute_lower(self, output_file, all_tasks, all_res): assert os.path.isfile('vasprun.xml'), "vasprun.xml not found" subprocess.check_call(self.phonopy_setup_command("--fc vasprun.xml"), shell=True) assert os.path.isfile('FORCE_CONSTANTS'), "FORCE_CONSTANTS not created" - subprocess.check_call(self.phonopy_command('--dim="%s %s %s" -c POSCAR-unitcell band.conf' % ( - self.supercell_size[0], - self.supercell_size[1], - self.supercell_size[2])), shell=True) + self.run_first_success( + self.phonopy_load_commands( + supercell_size=self.supercell_size, + cell_file="POSCAR-unitcell", + ), + required_file="band.yaml", + ) self.write_band_dat() print('band.dat is created') shutil.copyfile("band.dat", work_path/"band.dat") @@ -650,18 +835,25 @@ def _compute_lower(self, output_file, all_tasks, all_res): if not os.path.exists("FORCE_SETS"): raise FileNotFoundError("FORCE_SETS was not created") print('FORCE_SETS is created') - subprocess.check_call(self.phonopy_command('--dim="%s %s %s" -c POSCAR-unitcell band.conf' % ( - self.supercell_size[0], - self.supercell_size[1], - self.supercell_size[2])), shell=True) + self.run_first_success( + self.phonopy_load_commands( + supercell_size=self.supercell_size, + cell_file="POSCAR-unitcell", + ), + required_file="band.yaml", + ) self.write_band_dat() elif self.inter_param["type"] in LAMMPS_INTER_TYPE: os.chdir(all_tasks[0]) assert os.path.isfile('FORCE_CONSTANTS'), "FORCE_CONSTANTS not created" - subprocess.check_call(self.phonopy_command('--dim="%s %s %s" -c POSCAR band.conf' % ( - self.supercell_size[0], self.supercell_size[1], self.supercell_size[2]) - ), shell=True) + self.run_first_success( + self.phonopy_load_commands( + supercell_size=self.supercell_size, + cell_file="POSCAR", + ), + required_file="band.yaml", + ) self.write_band_dat() shutil.copyfile("band.dat", work_path/"band.dat") diff --git a/apex/default_config/abacus/param_props.json b/apex/default_config/abacus/param_props.json index 15376086..0435790b 100755 --- a/apex/default_config/abacus/param_props.json +++ b/apex/default_config/abacus/param_props.json @@ -49,6 +49,7 @@ "slip_direction": [1,-1,-1], "supercell_size": [1,1,20], "vacuum_size": 15, + "closed_loop": false, "add_fix": ["true","true","false"], "n_steps_x": 20, "n_steps_y": 20 diff --git a/apex/default_config/lammps/param_props.json b/apex/default_config/lammps/param_props.json index e6319f03..7123ecba 100755 --- a/apex/default_config/lammps/param_props.json +++ b/apex/default_config/lammps/param_props.json @@ -103,6 +103,7 @@ "slip_direction": [1,-1,-1], "supercell_size": [1,1,20], "vacuum_size": 15, + "closed_loop": false, "add_fix": ["true","true","false"], "n_steps_x": 20, "n_steps_y": 20 diff --git a/apex/default_config/vasp/param_props.json b/apex/default_config/vasp/param_props.json index fb99e52d..8d9fee19 100755 --- a/apex/default_config/vasp/param_props.json +++ b/apex/default_config/vasp/param_props.json @@ -62,6 +62,7 @@ "slip_direction": [1,-1,-1], "supercell_size": [1,1,20], "vacuum_size": 15, + "closed_loop": false, "add_fix": ["true","true","false"], "n_steps_x": 20, "n_steps_y": 20 diff --git a/apex/main.py b/apex/main.py index de049c0e..8fc4b753 100644 --- a/apex/main.py +++ b/apex/main.py @@ -615,6 +615,25 @@ def parse_args(): parser_account.add_argument("--program-id", dest="program_id", type=int, default=None) parser_account.add_argument("--apex-image-name", dest="apex_image_name", type=str, default=None) + ########################################## + # Agent skill + parser_skill = subparsers.add_parser( + "skill", + help="Print an Agent prompt for installing apex-flow, or build a zip file for uploading to MatMaster", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser_skill.add_argument( + "--zip", + action="store_true", + help="Write a zip of the bundled apex-flow", + ) + parser_skill.add_argument( + "-o", "--output", + type=str, + default=None, + help="Output zip path when using --zip (default: ./apex-flow.zip)", + ) + parsed_args = parser.parse_args() # print help if no parser if not parsed_args.cmd: @@ -1546,6 +1565,10 @@ def main(): from apex.preview import preview_from_args preview_from_args(args) + elif args.cmd == 'skill': + from apex.skill import skill_from_args + + skill_from_args(args) else: raise RuntimeError( f"unknown command {args.cmd}\n{parser.print_help()}" diff --git a/apex/preview.py b/apex/preview.py index 9b7321cd..9d71ad5d 100644 --- a/apex/preview.py +++ b/apex/preview.py @@ -395,7 +395,12 @@ def preview_parameter_file( temp_root, f"structure_{structure_index}_{prop_index}", ) - task_list = prop_obj.make_confs(str(work_dir), equi_dir, refine=do_refine) + make_kwargs = {} + if prop.get("type") == "gamma_surface": + make_kwargs["require_relaxation_result"] = False + task_list = prop_obj.make_confs( + str(work_dir), equi_dir, refine=do_refine, **make_kwargs + ) prop_obj.post_process(task_list) if prop.get("type") == "gamma_surface": task_list = _arrange_gamma_surface_tasks(task_list) diff --git a/apex/reporter/property_report.py b/apex/reporter/property_report.py index a402c3ce..dbdb7319 100644 --- a/apex/reporter/property_report.py +++ b/apex/reporter/property_report.py @@ -841,17 +841,38 @@ def dash_table(res_data: dict, decimal: int = 3, **kwargs) -> dash_table.DataTab class GammaSurfaceReport(PropertyReport): @staticmethod - def plotly_graph(res_data: dict, name: str, **kwargs): + def _rows_from_result(res_data: dict): rows = [] - for k, v in res_data.items(): - frac_x_str, frac_y_str = str(k).split(",") + for key, values in res_data.items(): + if not isinstance(key, str) or "," not in key: + continue + frac_x_str, frac_y_str = key.split(",", 1) + metadata = values[5] if len(values) > 5 else {} + if isinstance(metadata, dict) and "disp_cart" in metadata: + disp_cart = list(metadata["disp_cart"]) + else: + disp_cart = [values[0], values[1], 0.0] rows.append( { "frac_x": float(frac_x_str), "frac_y": float(frac_y_str), - "fault_en": float(v[2]), + "path_x": float(values[0]), + "path_y": float(values[1]), + "cart_x": float(disp_cart[0]), + "cart_y": float(disp_cart[1]), + "cart_z": float(disp_cart[2]), + "fault_en": float(values[2]), + "struct_en": float(values[3]), + "equi_en": float(values[4]), } ) + return rows + + @staticmethod + def plotly_graph(res_data: dict, name: str, **kwargs): + rows = GammaSurfaceReport._rows_from_result(res_data) + if not rows: + return [], go.Layout(title="Stacking Fault Energy (Gamma Surface)") df = pd.DataFrame(rows) pivot = df.pivot_table(index="frac_y", columns="frac_x", values="fault_en") @@ -878,31 +899,25 @@ def plotly_graph(res_data: dict, name: str, **kwargs): @staticmethod def dash_table(res_data: dict, decimal: int = 3, **kwargs) -> dash_table.DataTable: - frac_x = [] - frac_y = [] - disp_x = [] - disp_y = [] - fault_en = [] - struct_en = [] - equi_en = [] - for k, v in res_data.items(): - frac_x_str, frac_y_str = str(k).split(",") - frac_x.append(float(frac_x_str)) - frac_y.append(float(frac_y_str)) - disp_x.append(v[0]) - disp_y.append(v[1]) - fault_en.append(v[2]) - struct_en.append(v[3]) - equi_en.append(v[4]) + rows = GammaSurfaceReport._rows_from_result(res_data) df = pd.DataFrame( { - "Slip_frac_x": round_format(frac_x, decimal), - "Slip_frac_y": round_format(frac_y, decimal), - "Slip_x (A)": round_format(disp_x, decimal), - "Slip_y (A)": round_format(disp_y, decimal), - "E_Fault (J/m^2)": round_format(fault_en, decimal), - "E_Slab (eV)": round_format(struct_en, decimal), - "E_Equilib (eV)": round_format(equi_en, decimal), + "Slip_frac_x": round_format([r["frac_x"] for r in rows], decimal), + "Slip_frac_y": round_format([r["frac_y"] for r in rows], decimal), + "Path_x (A)": round_format([r["path_x"] for r in rows], decimal), + "Path_y (A)": round_format([r["path_y"] for r in rows], decimal), + "Cart_x (A)": round_format([r["cart_x"] for r in rows], decimal), + "Cart_y (A)": round_format([r["cart_y"] for r in rows], decimal), + "Cart_z (A)": round_format([r["cart_z"] for r in rows], decimal), + "E_Fault (J/m^2)": round_format( + [r["fault_en"] for r in rows], decimal + ), + "E_Slab (eV)": round_format( + [r["struct_en"] for r in rows], decimal + ), + "E_Equilib (eV)": round_format( + [r["equi_en"] for r in rows], decimal + ), } ) diff --git a/apex/rss.py b/apex/rss.py index b2c9cc89..cff4baa6 100644 --- a/apex/rss.py +++ b/apex/rss.py @@ -1,4 +1,5 @@ import json +import sys from pathlib import Path from pymatgen.core import Structure @@ -18,6 +19,21 @@ from apex.core.lib.rss import generate_rss, resolve_parent_lattice_auto +def _resolve_show_progress(config: dict, stdout=None) -> bool: + """Resolve RSS progress-bar flag. + + Explicit ``show_progress`` in rss.json always wins. Otherwise enable tqdm + only on an interactive TTY so agent/CI captured runs do not flood logs. + """ + if "show_progress" in config: + return bool(config["show_progress"]) + stream = sys.stdout if stdout is None else stdout + try: + return bool(stream.isatty()) + except Exception: + return False + + def _jsonable(value): if isinstance(value, dict): result = {} @@ -235,7 +251,7 @@ def run_rss_config(config_file: str) -> None: "allow_vacancies": config.get("allow_vacancies", False), "num_configs": config.get("num_configs", 1), "interval": config.get("interval", 100), - "show_progress": config.get("show_progress", True), + "show_progress": _resolve_show_progress(config), "patience": config.get("patience"), "return_metadata": write_metadata, } diff --git a/apex/skill.py b/apex/skill.py new file mode 100644 index 00000000..aaefddf5 --- /dev/null +++ b/apex/skill.py @@ -0,0 +1,146 @@ +"""CLI helpers for the bundled APEX agent skill.""" + +from __future__ import annotations + +import zipfile +from pathlib import Path + +from apex.skills import SKILL_NAME, get_skill_root + +# Skip noise when packing for MatMaster / agent skill upload. +_ZIP_SKIP_DIR_NAMES = {"__pycache__", ".git"} +_ZIP_SKIP_FILE_NAMES = {".DS_Store"} +_ZIP_SKIP_FILE_SUFFIXES = {".pyc", ".pyo", ".pt", ".partial"} + + +def _agent_install_prompt(skill_root: Path) -> str: + skill_md = skill_root / "SKILL.md" + return f"""\ +# Agent prompt: install the APEX skill ({SKILL_NAME}) + +You are an AI coding agent. Install the bundled APEX agent skill so future +sessions can orchestrate APEX (Alloy Property EXplorer) workflows. + +## Preferred: MatMaster zip upload + +Ask the user (or run yourself) to build an uploadable archive: + +```bash +apex skill --zip +``` + +This writes `./{SKILL_NAME}.zip` with top-level folder `{SKILL_NAME}/` +(containing `SKILL.md`, `reference/`, `scripts/`, small frozen models under +`models/`, etc.). Upload that zip in MatMaster's skill upload UI. + +Optional output path: + +```bash +apex skill --zip -o /tmp/{SKILL_NAME}.zip +``` + +## Alternative: copy from the local package + +- Skill directory: `{skill_root}` +- Entry file: `{skill_md}` + +Confirm `{skill_md}` exists before copying. Do not invent or regenerate the +skill content; copy the directory as-is (including `reference/`, `scripts/`, +`data/`, `models/DPA-3.2-5M`, and `plugin.yaml`). + +For DeePMD/DPA jobs, use the bundled frozen DPA-3.2 model: +- `models/DPA-3.2-5M/DPA-3.2-5M-OMat24.pth` + +The source multi-head checkpoint (`.pt`) is **not** bundled. Download it only +when the user explicitly needs a different task head: + +```bash +python scripts/fetch_models.py --source-checkpoint +``` + +Install destinations (create parents if missing): + +1. Cursor (user-global): `~/.cursor/skills/{SKILL_NAME}/` +2. Codex / OpenAI agents (user-global): `~/.codex/skills/{SKILL_NAME}/` +3. Cursor (project-local, optional): `/.cursor/skills/{SKILL_NAME}/` + +```bash +mkdir -p ~/.cursor/skills ~/.codex/skills +cp -R "{skill_root}" ~/.cursor/skills/{SKILL_NAME} +cp -R "{skill_root}" ~/.codex/skills/{SKILL_NAME} +``` + +## Verification + +- MatMaster: skill appears after zip upload, name `{SKILL_NAME}` +- Local: `~/.cursor/skills/{SKILL_NAME}/SKILL.md` exists +- YAML frontmatter `name:` is `{SKILL_NAME}` + +## After install + +- Tell the user the skill is available as `{SKILL_NAME}` / `/apex-flow` +- For APEX calculation requests, read `SKILL.md` first, then load referenced + docs under `reference/` only as needed +## Notes + +- The PyPI / pip package and agent skill both use the name `{SKILL_NAME}`. +- Re-run `apex skill` to reprint this prompt, or `apex skill --zip` to rebuild + the MatMaster upload archive. +""" + + +def build_skill_zip(output: Path | None = None) -> Path: + """ + Pack the bundled apex-flow directory into a zip for MatMaster upload. + + Large DeePMD source checkpoints (``*.pt``) are excluded on purpose. + Ready-to-run frozen ``*.pb`` and ``*.pth`` models under ``models/`` are + included. + """ + skill_root = get_skill_root() + if not (skill_root / "SKILL.md").is_file(): + raise FileNotFoundError( + f"Bundled skill not found at {skill_root}. " + "Reinstall apex-flow or check package data." + ) + + out = Path(output).expanduser().resolve() if output else ( + Path.cwd() / f"{SKILL_NAME}.zip" + ) + if out.suffix.lower() != ".zip": + out = out.with_suffix(".zip") + out.parent.mkdir(parents=True, exist_ok=True) + + with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for path in sorted(skill_root.rglob("*")): + if not path.is_file(): + continue + if any(part in _ZIP_SKIP_DIR_NAMES for part in path.parts): + continue + if path.name in _ZIP_SKIP_FILE_NAMES: + continue + if path.suffix in _ZIP_SKIP_FILE_SUFFIXES: + continue + if path.name.endswith(".partial"): + continue + arcname = Path(SKILL_NAME) / path.relative_to(skill_root) + zf.write(path, arcname.as_posix()) + + return out + + +def skill_from_args(args) -> None: + """Print the Agent install prompt, or write a MatMaster-ready skill zip.""" + skill_root = get_skill_root() + if not (skill_root / "SKILL.md").is_file(): + raise FileNotFoundError( + f"Bundled skill not found at {skill_root}. " + "Reinstall apex-flow or check package data." + ) + if getattr(args, "zip", False): + output = getattr(args, "output", None) + zip_path = build_skill_zip(Path(output) if output else None) + print(f"Wrote MatMaster skill archive: {zip_path}") + print(f"Upload this zip in MatMaster (top-level folder: {SKILL_NAME}/).") + return + print(_agent_install_prompt(skill_root).rstrip()) diff --git a/apex/skills/__init__.py b/apex/skills/__init__.py new file mode 100644 index 00000000..9dbb0d35 --- /dev/null +++ b/apex/skills/__init__.py @@ -0,0 +1,12 @@ +"""Bundled Agent Skills shipped with the APEX package.""" + +from __future__ import annotations + +from pathlib import Path + +SKILL_NAME = "apex-flow" + + +def get_skill_root() -> Path: + """Return the filesystem path to the bundled ``apex-flow`` directory.""" + return Path(__file__).resolve().parent / SKILL_NAME diff --git a/apex/skills/apex-flow/SKILL.md b/apex/skills/apex-flow/SKILL.md new file mode 100644 index 00000000..eb826d64 --- /dev/null +++ b/apex/skills/apex-flow/SKILL.md @@ -0,0 +1,385 @@ +--- +name: apex-flow +description: Batch multi-property materials calculations (EOS, 0K elastic constant, surface energy, phonon, finite temperature elastic constant, gamma surface, gamma line, cohesive energy) and random-solid-solution structure generation via APEX calculator backends VASP/ABACUS/LAMMPS. The bundled DPA-3.2-5M OMat24 model is a DeePMD potential for LAMMPS. Use when the user mentions APEX, apex, alloy property, generate/give random solid solution, generate/give solid solution, generate/give high-entropy alloy, generate/give high-entropy oxide, generate/give high-entropy material, or multi-property DFT/MLIP screening. +--- + +# APEX Flow — Alloy Properties EXplorer + +APEX is an automated workflow for computing alloy/material properties via batch DFT or MLIP calculations. It handles the full pipeline: structure preparation → task generation → computation → result extraction. + +## When to Use APEX vs Other Skills + +**Definitely use APEX when:** + +- User explicitly says "APEX" or "apex" +- Batch multi-property calculation (EOS + elastic + surface + ... together) +- Multi-structure screening (e.g. 10 compositions × 3 properties) +- RSS (random solid solution) generation + property evaluation +- High-entropy alloy / multi-component alloy property exploration + +**APEX is one option (present as choice) when:** + +- User requests a single property (elastic, EOS, surface energy, etc.) — could also be done with mlips/vasp/abacus/lammps skills directly +- User has a single structure and one property — APEX adds orchestration overhead vs direct calculation + +**Do NOT use APEX when:** + +- Single-point energy/force evaluation with MLIP → use `mlips` skill +- Custom LAMMPS simulation (GCMC, shock, free-form script) → use `lammps` skill +- VASP/ABACUS input preparation only (no execution) → use `vasp`/`abacus` skill +- Phonopy phonon calculation with existing DFT forces → use the DFT skill directly + +When the task is ambiguous (e.g. "help me calculate elastic constants of Cu"), present options: + +``` +Options to offer via AskQuestion: +1. APEX workflow (automated pipeline, supports multiple properties at once, uses dflow orchestration) +2. Direct engine (e.g. mlips skill for MLIP, vasp skill for DFT — simpler for single property) +``` + + + +## High-Level Workflow (5 Steps) + +1. **Prepare inputs** — Check `BOHRIUM_ACCESS_KEY`, then generate `param.json` + `global.json` (including a fresh ticket) and copy structure/model files into a job directory using `scripts/generate_config.py create ...`. Never hand-write either JSON file. +2. **Submit outer Bohrium job** — A lightweight client (`c1_m2_cpu`, recommended) that runs `apex submit ...` without `-s`, connects to the dflow orchestration server, and waits for completion +3. **dflow executes** — Inner containers (LAMMPS/ABACUS/VASP) run the actual calculations, managed by `workflows.deepmodeling.com` +4. **Monitor and retrieve results** — `apex submit` monitors the inner workflow and retrieves results after completion; parse `confs//_00/result.json` +5. **Present results** — Summarize in a table with physical units (GPa for elastic, J/m² for surface, eV for energies) + + + +## Critical Rules + +1. **STOP: Confirm the APEX calculator backend before submission.** + In APEX, **backend** means the calculator that runs tasks: `lammps` / `abacus` / `vasp`. + DPA / DeePMD model names are potential files used under the LAMMPS backend, not APEX backends. + Ask in two steps when needed: + **Step A — APEX calculator backend** (`AskQuestion`): + - LAMMPS + MLIP (DeePMD / DPA / MACE / NEP): fast, GPU-friendly + - LAMMPS + classical (EAM / MEAM / SNAP): fast, CPU + - ABACUS (DFT) + - VASP (DFT; license-gated — resolve image via Bohrium `list_images` keyword=`vasp` or a user-known authorized address; otherwise stop) + **Step B — only if Step A is LAMMPS + DeePMD/DPA: use the model bundled with this skill.** + - Copy `models/DPA-3.2-5M/DPA-3.2-5M-OMat24.pth` into the job directory. + - This is the frozen, single-task `OMat24` branch of DPA-3.2-5M. It is + ready for APEX/LAMMPS, includes oxygen, and has 89 observed elements. + - Set `interaction.model` to the copied filename and set `"type_map": "auto"`. + APEX reads the structure at submission time and writes a zero-based, + contiguous element map. Do not derive indices from atomic numbers or the + model's internal type order. + - Do not invent a model path, download another model, or use the source multi-head `.pt` directly. Use a user-provided or separately frozen task head only when the user explicitly requests it or the bundled OMat24 model is unsuitable; explain the choice and obtain confirmation first. + **Skip Step A/B ONLY if** the user already stated them in THIS message + (e.g. “用 EAM”, “用 ABACUS 做 EOS”, “用 DPA-3.2-5M-OMat24.pth”). + **If AskQuestion times out or fails**: state the intended APEX backend and bundled model selection (if LAMMPS+DPA) in plain text and WAIT. Never silently submit. +2. **STOP: Confirm property parameters before submission — DO NOT PROCEED WITHOUT USER ANSWER.** Before submitting, present the full `properties` configuration (JSON) to the user. Show the defaults that will be used and highlight: + - Miller indices / slip systems (for surface/gamma/gamma_surface/decohesive) + - Supercell sizes (for vacancy/interstitial/phonon/gruneisen/finite-T) + - Temperature ranges (for finite_t_latt/finite_t_elastic/annealing) + - Number of deformation/step points + For crystallographic planes: + - `gamma` / `gamma_surface`: pick from the canonical FCC/BCC/HCP table in + repository **README §4.10** (see also `reference/properties.md` §8–9). Do not + invent slip systems; do not silently change an approved plane/direction. + - `decohesive`: pick `miller_index` from the crystal-family table in + **README §4.5** / `reference/properties.md` §10 (FCC/BCC/Diamond/ZB/Rocksalt/ + HCP/Perovskite). HCP must use **3-index** only. + Let the user approve or modify. **Skip ONLY if** the user provided explicit property parameters already. + **If AskQuestion times out or fails**: display the parameters in your message and WAIT for confirmation before submitting. +3. **Two-layer architecture.** The outer Bohrium job is a thin submission client only. Never attempt `apex do` for production workflows — use `apex submit` which delegates to dflow. See `reference/submission.md` for the full architecture diagram. + For agent-managed Bohrium runs, always use `apex submit ...` without `-s`. + The outer client must remain active while dflow runs so APEX can monitor the + workflow and retrieve results automatically. + Immediately preserve the exact inner dflow workflow ID printed by + `apex submit` and report it to the user. Keep this ID available for all later + monitoring, retrieval, and workflow-control actions; do not confuse it with + the outer Bohrium job ID. Follow the exact status-query and reporting + protocol in `reference/workflow-control.md`; never infer workflow identity + or material identity from the outer job name alone. Use the returned + workflow/step phases and durations to track progress. A + long-running or failed step should be investigated by its step ID/key rather + than treated as successful completion. After the workflow reaches + `Succeeded`, verify that automatic result retrieval completed as described + in `reference/submission.md`. +4. **Kill = inner FIRST, outer SECOND.** If you only kill the outer Bohrium node, the dflow workflow continues consuming resources silently. Always terminate the inner dflow workflow first. See `reference/workflow-control.md`. +5. **MUST use `generate_config.py`; never hand-write `param.json` or `global.json`.** + - Create the complete job with `python /scripts/generate_config.py create ...`. + - For multiple structures, pass repeated/space-separated `--structure` and/or `--structure-dir` to `create` (it copies each into `confs//` and fills `structures`); do not hand-edit `structures` after create. + - To preserve an approved `param.json` while refreshing credentials, run + `python /scripts/generate_config.py refresh-global --global global.json` + from the task directory. This updates only `global.json`. + - Do not invent unsupported flags or call the ticket API directly. +6. **Generate the ticket before packaging the job; never refresh it in `run.sh`.** + - First inspect the agent/local environment for `BOHRIUM_ACCESS_KEY`. + - If it is missing, STOP and ask the user to provide/configure it. `generate_config.py` cannot generate a ticket without an access key. + - If it exists, use `create` for a new job or `refresh-global` for an existing job; both convert the key to a fresh ticket and write it to `global.json`. + - Verify that `global.json` contains a non-empty `bohrium_config.ticket` before submission. + - `run.sh` must only install/verify APEX and call `apex submit`. Do not add ticket API calls or depend on `BOHRIUM_ACCESS_KEY` inside the APEX container. + - Install with `python3 -m pip install --upgrade --no-cache-dir apex-flow`. + See `reference/submission.md`. +7. **Project ID from environment only.** `generate_config.py` reads `BOHRIUM_PROJECT_ID` (or `--project-id`). Never hardcode a project ID (including old examples like `13529`) into `global.json`, docs, or prompts. +8. **Hard-validate inside the task directory before every upload.** Run: + ```bash + cd + python /scripts/validate_inputs.py \ + --param param.json --global global.json + ``` + Do not upload or submit unless it prints `Validation PASSED` and reports both + `program_id` and `bohrium_config.project_id` with `type=int`. A quoted numeric + string is invalid. Upload the newly validated directory as a new outer job; + never retry an outer job whose input snapshot was invalid. +9. **Screen image × machine before submit.** Before writing `global.json` or submitting, run: + ```bash + python scripts/validate_apex_combo.py list-combos --backend lammps --prefer gpu + python scripts/validate_apex_combo.py check \ + --image registry.dp.tech/dptech/dp/native/prod-397637/deepmd-kit-phonolammps:3.1.3 \ + --scass "c8_m31_1 * NVIDIA T4" + ``` + Do **not** hardcode an unverified `scass_type`. Prefer `recommend` / `list-combos` output. Known failures include `deepmd-kit:3.1.0`, `3.1.1-cuda12.1`, `3.1.2`, the combination `deepmd-kit:3.1.1` × `NVIDIA T4`, `c4_m16_cpu`, and `c12_m46_1 * NVIDIA T4`. The default LAMMPS image is `registry.dp.tech/dptech/dp/native/prod-397637/deepmd-kit-phonolammps:3.1.3`; `apex submit` enforces it for LAMMPS phonon and Grüneisen workflows. +10. **MUST use the bundled frozen DPA model under** `models/` **for LAMMPS + DeePMD unless the user explicitly requests another compatible model.** The skill ships + `models/DPA-3.2-5M/DPA-3.2-5M-OMat24.pth`, a ready-to-run frozen + DPA-3.2-5M OMat24 model. Copy it into the job directory before generating + `param.json`. The multi-head source checkpoint is **not** in the skill zip — + fetch it only when the user explicitly needs another task head + (`scripts/fetch_models.py --source-checkpoint` or + `dp --pt pretrained download DPA-3.2-5M`) and freeze that head before use. + See `models/README.md`. +11. **STOP: Check atom count / cell size before property submit — decide whether to expand.** + APEX does not require a conventional cell. Do not convert a primitive cell or + user-provided supercell to a conventional cell merely because an example uses + `confs/std-fcc` or another `std-*` name. + Before confirming property parameters, **always read the user's structure** and + report: formula, atom count, lattice lengths, and whether it looks like a + primitive / conventional / already-expanded supercell. + Then decide with the user whether further bulk expansion is needed: + - **Too small for the property** (typical primitive or tiny conventional cell) + → recommend expanding; do not silently submit with an undersized cell. + - **Already large enough / already a supercell** → ask whether to expand again; + if no, set applicable volumetric `supercell` / `supercell_size` to `[1,1,1]` + so APEX does not expand twice. + - Keep `elastic.conventional` false unless the user explicitly requests a + conventional-cell elastic calculation. + - Slab properties (`surface` / `gamma` / `decohesive`) use `min_slab_size` / + in-plane replication, not bulk `supercell`; confirm those separately. + Use the helper wording and size guidance in + `reference/workflow-control.md` → **Pre-Submission Structure Validation**. +12. **STOP: When the user gives a VASP POTCAR path, verify it and stage it into the job.** + Host libraries such as `/share/PAW_PBE/...` are **not** available inside + Bohrium/dflow containers. Leaving an absolute `potcar_prefix` in `param.json` + causes `FileNotFoundError: .../Ti_pv/POTCAR` after upload. + As soon as the user specifies a POTCAR library path: + 1. Confirm the path exists and is readable locally. + 2. Confirm every structure element has a POTCAR file under that library. + 3. Stage into the **job root** as flat files (e.g. `POTCAR_Ti`, `POTCAR_V`) + via `generate_config.py create --potcar-prefix --potcars '...'` + (or Agent `cp`/`mv`). Set `"potcar_prefix": "."`. + 4. Verify `param.json` uses `"potcar_prefix": "."` (not `/share/...`) and + that `POTCAR_` files exist in the job directory; then run + `validate_inputs.py`. + If the library is missing/incomplete: **STOP**, tell the user the path is + unusable, and ask for the correct POTCAR location. Never submit with an + absolute host POTCAR path hoping the container can see it. +13. **MUST set DFT k-spacing; never rely on a hand-written KPOINTS/KPT file.** + APEX auto-generates the k-mesh from spacing; omitting it fails at make time. + - **VASP**: `INCAR` (or `cal_setting`) **must** contain `KSPACING`. Prefer also + setting `KGAMMA` (`True` = Gamma-centered, `False` = Monkhorst-Pack). + Do not hand-author per-task `KPOINTS`; APEX writes it from POSCAR + spacing. + - **ABACUS**: `INPUT` **must** contain `kspacing` (1/Bohr), **or** set + `cal_setting.K_POINTS` like `[nx, ny, nz, 0, 0, 0]`. APEX writes `KPT`. + - **Screening defaults** (APEX is screening-oriented, not publication-grade): + VASP `KSPACING=0.1–0.2` Å⁻¹; ABACUS `kspacing=0.20` (relax) / `0.15` (phonon SCF). + Smaller spacing → denser mesh and much higher cost (~8× from 0.20→0.10 on ABACUS). + - Confirm the spacing with the user before submit when they care about accuracy. + Details: `reference/calculators.md` → k-spacing sections. +14. **STOP: Resolve VASP image via Bohrium `list_images` or a user-known authorized address — never invent a default.** + VASP is commercial. There is **no** default `vasp_image_name`. Before any + VASP `create` / submit, resolve an image by **exactly one** of these paths: + 1. **Private image discovery (preferred):** query the user's own private + Bohrium Docker images filtered by keyword `vasp`: + - If the MatMaster / Bohrium tool is available: + `Bohrium(action="list_images", keyword="vasp")` + (tool description: *list the user's own private Docker images + (filtered by keyword)*). + - Otherwise run the skill helper (same OpenAPI): + ```bash + python /scripts/list_bohrium_images.py \ + --keyword vasp --require + ``` + Present matching image URL(s) to the user and get approval before use. + 2. **User-known authorized address:** the user explicitly provides a + licensed/authorized VASP image path they are allowed to use. + **If neither path yields an image → TERMINATE the VASP workflow.** Do not + guess public tags (including `vasp:5.4.4-dflow`), do not submit, and tell + the user that a private VASP image or an authorized image address is + required. Only after a confirmed image exists, pass + `--vasp-image ` to `generate_config.py create` (writes + `vasp_image_name`). +15. **MUST use a Bohrium-safe VASP `vasp_run_command` — never bare `vasp_std`.** + After a licensed VASP image is resolved (Rule 14), `global.json` should use a + command that sources Intel oneAPI, raises stack limit, and calls an absolute + binary. Typical Bohrium layout: + ```text + bash -c "source /opt/intel/oneapi/setvars.sh && ulimit -s unlimited && mpirun -n /opt/vasp.5.4.4/bin/vasp_std" + ``` + Constraints: + - Always `source /opt/intel/oneapi/setvars.sh` (Intel MPI / MKL env). + - Always `ulimit -s unlimited` (avoids stack overflow on large cells). + - Prefer absolute binary path (PATH `vasp_std` is unreliable); adjust path if + the user-approved image differs. + - Align `` with `scass_type` CPU count (`c32_*` → `-n 32`, `c16_*` → `-n 16`). + - Do **not** use bare `mpirun -n 16 vasp_std`. + `generate_config.py` writes the run_command template for `--backend vasp` + and sets `vasp_image_name` only from `--vasp-image`. + + + +## Supported Properties (14 types) + + +| Type | JSON `type` value | Backend | Description | +| ---------------- | ------------------ | ----------- | ----------------------------------------- | +| EOS | `eos` | All | Equation of state (volume-energy curve) | +| Elastic | `elastic` | All | Elastic constants Cij, B, G, E, ν | +| Surface | `surface` | All | Surface formation energy | +| Vacancy | `vacancy` | All | Vacancy formation energy | +| Interstitial | `interstitial` | All | Interstitial formation energy | +| Phonon | `phonon` | All | Phonon dispersion & DOS | +| Gamma line | `gamma` | All | 1D generalized stacking fault energy | +| Gamma surface | `gamma_surface` | All | 2D GSFE map | +| Cohesive | `cohesive` | All | Cohesive energy curve | +| Decohesive | `decohesive` | All | Ideal work of separation | +| Finite-T lattice | `finite_t_latt` | All | Lattice parameter vs temperature (NPT MD) | +| Finite-T elastic | `finite_t_elastic` | LAMMPS only | Elastic constants at finite temperature | +| Grüneisen | `gruneisen` | All | Grüneisen parameters & thermal expansion | +| Annealing | `annealing` | All | Heat-hold-quench MD cycle | + + +> See `reference/properties.md` for full parameter details of each property. + + + +## LAMMPS Potential Types (Quick Reference) + + +| `interaction.type` | pair_style | Model file | GPU? | +| ------------------ | ------------------------------ | --------------- | ---- | +| `deepmd` | `deepmd` | `.pb` or `.pth` | Yes | +| `mace` | `mace no_domain_decomposition` | `.model` | Yes | +| `nep` | `nep` | `nep.txt` | Yes | +| `eam_alloy` | `eam/alloy` | `.eam.alloy` | No | +| `eam_fs` | `eam/fs` | `.eam.fs` | No | +| `meam` | `meam` | library + param | No | + + +> See `reference/lammps_potentials.md` for the full table and examples. + + + +## Input File Format (Minimal) + + + +### param.json (calculation parameters) + +```json +{ + "structures": ["confs/std-fcc"], + "interaction": { + "type": "eam_alloy", + "model": "Cu01.eam.alloy", + "type_map": "auto" + }, + "relaxation": { + "cal_setting": {"etol": 0, "ftol": 1e-10, "maxiter": 5000, "maximal": 500000} + }, + "properties": [ + {"type": "eos", "vol_start": 0.8, "vol_end": 1.2, "vol_step": 0.05}, + {"type": "elastic", "norm_deform": 0.01, "shear_deform": 0.01} + ] +} +``` + + + +### global.json (workflow/machine config) + +See `reference/submission.md` for the full validated template. + +## Key Additional Rules + +1. **LAMMPS-only properties**: `finite_t_elastic` only works with LAMMPS. `finite_t_latt` and `annealing` also support VASP Langevin–Parrinello–Rahman NpT and ABACUS Nose–Hoover-style NpT. +2. **Model files must be in job directory.** For MLIP workflows, the model file (`.pb`, `.pth`, `.model`, etc.) must be present in the submitted directory. Use relative paths in `param.json`. For DeePMD/DPA, copy `models/DPA-3.2-5M/DPA-3.2-5M-OMat24.pth`. Default to `"type_map": "auto"` for every LAMMPS interaction; specify a dictionary only when the user explicitly needs a fixed custom ordering. +3. **Joint workflow recommended.** Use `joint` flow (relaxation + properties) for most use cases to ensure proper relaxation before property calculations. +4. **GPU for ML potentials.** DeePMD, MACE, and NEP benefit from GPU acceleration. Set `scass_type` to a validated GPU SKU from `validate_apex_combo.py recommend --prefer gpu` (default: `"c8_m31_1 * NVIDIA T4"`). +5. **Supercell sizing depends on the input atom count, not only the default JSON.** + Treat defaults as targets for **unit-cell inputs**. First inspect the user's + structure; if it is already large enough, prefer `[1,1,1]` after confirmation. + Rough total-size guidance (after any expansion): + - vacancy / interstitial: ≳ [2,2,2] conventional-cell equivalent + - phonon / gruneisen / finite-T: ≳ [3,3,3] (smaller cells often fail) + - surface / gamma / decohesive: ensure slab thickness / in-plane size, not bulk supercell + If the cell is too small, ask the user to expand before submit. See + `reference/workflow-control.md`. +6. **Outer job machine.** Use `c1_m2_cpu` for the outer Bohrium job since it only calls `apex submit` and waits. Don't waste larger CPU or GPU resources on the submission client. + + + +## RSS (Random Solid Solution) Workflow + +For random solid solutions, solid solutions, high-entropy alloys, high-entropy +oxides/ceramics, and other high-entropy materials, use `apex rss` to generate +structures before property calculations. Read `reference/rss_workflow.md` +before asking the user questions or writing `rss.json`; it defines the required +QA, current JSON schema, output layout, and visualization fallback. + +**Agent rules for RSS (mandatory):** +- Always set `"show_progress": false` in `rss.json`. tqdm step bars (default + `max_steps=20000`) flood captured terminal output and waste context; do not + leave progress enabled “to see if it is working.” +- After `apex rss`, judge success from files + metadata — not from live bars: + count `conf_*/POSCAR`, then read `rss_metadata.json` for convergence / + composition / duplicate warnings. +- If zero configs are written, do **not** re-call `generate_rss` from Python to + bypass the CLI. Fix `rss.json` (`max_steps`, `interval`, `num_configs`, + compositions, cell size) and re-run `apex rss` once; report the metadata + diagnosis to the user. + +## Working Test Case (Reference) + +Successfully validated workflow (ID: `cu-fcc-elastic-v3-joint-sdfml`): + +- **System**: Cu FCC elastic constants +- **Potential**: EAM (Cu01.eam.alloy, Mishin 2001) +- **Flow**: joint (relaxation + elastic properties), `scass_type`: `c16_m32_cpu` + + + +## Scripts + + +| Script | Purpose | +| -------------------------- | --------------------------------------------------------------------------------- | +| `generate_config.py` | `create` a complete job or `refresh-global` credentials without changing param.json | +| `list_bohrium_images.py` | List private Bohrium images by keyword (MatMaster `list_images` equivalent) | +| `validate_apex_combo.py` | List / check / recommend safe image × scass_type combos | +| `fetch_models.py` | Optional: download the DPA-3.2-5M multi-head source `.pt` for freezing another head | +| `parse_results.py` | Parse APEX output into summary | +| `validate_inputs.py` | Validate configuration before submission | + + + + +## Reference Index + + +| File | Content | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `reference/submission.md` | Authentication (ticket API + refresh), run.sh template, Bohrium config (images/machines), global.json template, RFC 1123 naming, submission lifecycle | +| `reference/workflow-control.md` | Running-task status/count format, live Argo link, stopping/killing procedure, and structure validation | +| `reference/properties.md` | Complete parameter reference for all 14 property types | +| `reference/calculators.md` | Detailed backend configuration (VASP, ABACUS, LAMMPS) | +| `reference/lammps_potentials.md` | LAMMPS potential type details and examples | +| `reference/rss_workflow.md` | RSS structure generation workflow | +| `reference/examples.md` | Complete worked examples for common scenarios | + + diff --git a/apex/skills/apex-flow/data/default_templates.json b/apex/skills/apex-flow/data/default_templates.json new file mode 100644 index 00000000..556d53f0 --- /dev/null +++ b/apex/skills/apex-flow/data/default_templates.json @@ -0,0 +1,42 @@ +{ + "apex_image": "registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post", + "backends": { + "lammps_gpu": { + "image": "registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post", + "machine": "c8_m31_1 * NVIDIA T4", + "potentials": ["deepmd", "mace", "nep"] + }, + "lammps_cpu": { + "image": "registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post", + "machine": "c16_m32_cpu", + "potentials": ["gap", "snap", "rann", "eam_alloy", "eam_fs", "meam", "meam_spline"] + }, + "abacus": { + "image": "registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post", + "machine": "c16_m32_cpu", + "run_command": "mpirun -n 8 abacus" + }, + "vasp": { + "image": null, + "machine": "c32_m128_cpu", + "run_command": "bash -c \"source /opt/intel/oneapi/setvars.sh && ulimit -s unlimited && mpirun -n 32 /opt/vasp.5.4.4/bin/vasp_std\"", + "note": "Commercial software. Resolve image via Bohrium list_images(keyword=vasp) or a user-known authorized address; if neither exists, stop. Never invent a default image. Never use bare mpirun ... vasp_std." + } + }, + "properties": { + "eos": {"lammps": true, "abacus": true, "vasp": true}, + "cohesive": {"lammps": true, "abacus": true, "vasp": true}, + "elastic": {"lammps": true, "abacus": true, "vasp": true}, + "surface": {"lammps": true, "abacus": true, "vasp": true}, + "vacancy": {"lammps": true, "abacus": true, "vasp": true}, + "interstitial": {"lammps": true, "abacus": true, "vasp": true}, + "phonon": {"lammps": true, "abacus": true, "vasp": true}, + "gamma": {"lammps": true, "abacus": true, "vasp": true}, + "gamma_surface": {"lammps": true, "abacus": true, "vasp": true}, + "decohesive": {"lammps": true, "abacus": true, "vasp": true}, + "finite_t_latt": {"lammps": true, "abacus": false, "vasp": false}, + "finite_t_elastic": {"lammps": true, "abacus": false, "vasp": false}, + "gruneisen": {"lammps": true, "abacus": true, "vasp": true}, + "annealing": {"lammps": true, "abacus": false, "vasp": false} + } +} diff --git a/apex/skills/apex-flow/models/DPA-3.2-5M/DPA-3.2-5M-OMat24.pth b/apex/skills/apex-flow/models/DPA-3.2-5M/DPA-3.2-5M-OMat24.pth new file mode 100644 index 00000000..71432055 Binary files /dev/null and b/apex/skills/apex-flow/models/DPA-3.2-5M/DPA-3.2-5M-OMat24.pth differ diff --git a/apex/skills/apex-flow/models/DPA-3.2-5M/README.md b/apex/skills/apex-flow/models/DPA-3.2-5M/README.md new file mode 100644 index 00000000..8b23a5d0 --- /dev/null +++ b/apex/skills/apex-flow/models/DPA-3.2-5M/README.md @@ -0,0 +1,14 @@ +# DPA-3.2-5M + +- `DPA-3.2-5M-OMat24.pth` — frozen single-task PyTorch model, ready for + `interaction.model` in APEX. +- Source: official DPA-3.2-5M multi-task checkpoint. +- Frozen head: `OMat24`. +- DeePMD-kit version: 3.1.3. +- Observed-element coverage: 89 elements, including O. +- SHA-256: + `055fbbcb83c9063f7809a74803c600eb34cf0b3f7caf15e0e0d2b86834f30e8e`. + +Use `"type_map": "auto"` and copy the `.pth` file into the submitted job +directory. The original multi-task `.pt` checkpoint is intentionally excluded +from the skill zip. diff --git a/apex/skills/apex-flow/models/README.md b/apex/skills/apex-flow/models/README.md new file mode 100644 index 00000000..863074a5 --- /dev/null +++ b/apex/skills/apex-flow/models/README.md @@ -0,0 +1,44 @@ +# Bundled DPA model + +> **Naming:** APEX **backend** = `lammps` / `abacus` / `vasp`. +> DPA-3.2-5M is a DeePMD model used by the LAMMPS backend. + +The skill ships one ready-to-run frozen model: + +| Path | Size | Branch | Ready for APEX? | +|------|------|--------|-----------------| +| `DPA-3.2-5M/DPA-3.2-5M-OMat24.pth` | ~23MB | `OMat24` | Yes | + +The bundled model is a single-task PyTorch model frozen from the official +DPA-3.2-5M checkpoint with DeePMD-kit 3.1.3. Its `OMat24` branch has 89 +observed elements, including oxygen. The model `type_map` spans all 118 element +symbols, but an element should be considered supported only when it appears in +the branch's observed-element list. + +## Agent workflow + +1. For LAMMPS + DeePMD/DPA, copy + `models/DPA-3.2-5M/DPA-3.2-5M-OMat24.pth` into the job directory. +2. Set `"type": "deepmd"`, `"model": "DPA-3.2-5M-OMat24.pth"`, and + `"type_map": "auto"`. APEX infers a zero-based, contiguous map from the + structure during submission. Do not use atomic numbers or model-internal + indices. +3. If the user explicitly needs another DPA-3.2 task head, download the source + checkpoint outside the skill zip: + ```bash + python scripts/fetch_models.py --source-checkpoint + # or: + dp --pt pretrained download DPA-3.2-5M + ``` +4. Freeze the selected head before LAMMPS/APEX use: + ```bash + dp --pt freeze -c DPA-3.2-5M.pt -o DPA-3.2-5M-Alloy_APEX.pth --head Alloy_APEX + ``` + +Never pass the multi-task `.pt` checkpoint directly to LAMMPS. Do not invent a +model filename. If the OMat24 observed-element coverage or training domain is +unsuitable, explain the limitation and obtain the user's task-head choice. + +## Source + +- Official checkpoint: [DPA-3.2-5M](https://huggingface.co/deepmodelingcommunity/DPA-3.2-5M) diff --git a/apex/skills/apex-flow/plugin.yaml b/apex/skills/apex-flow/plugin.yaml new file mode 100644 index 00000000..53ce3957 --- /dev/null +++ b/apex/skills/apex-flow/plugin.yaml @@ -0,0 +1,3 @@ +description: APEX (Alloy Property EXplorer) workflow orchestration for batch multi-property DFT/MLIP calculations via VASP/ABACUS/LAMMPS backends through dflow +category: materials-science +name: apex-flow diff --git a/apex/skills/apex-flow/reference/calculators.md b/apex/skills/apex-flow/reference/calculators.md new file mode 100644 index 00000000..11c887e2 --- /dev/null +++ b/apex/skills/apex-flow/reference/calculators.md @@ -0,0 +1,619 @@ +# APEX Calculator Backends — Configuration Guide + +## Overview + +APEX supports three calculator **backends**: LAMMPS, ABACUS, and VASP. +Each requires specific configuration in `param.json` under the `"interaction"` key. + +> APEX **backend** = calculator (`lammps` / `abacus` / `vasp`). +> DPA-3.2-5M is a DeePMD **model** under LAMMPS (`interaction.type: deepmd`). +> Ask calculator backend first; if LAMMPS+DeePMD, then ask which model file. + +--- + +## 1. LAMMPS (Classical & ML Potentials) + +### Basic Configuration + +```json +{ + "interaction": { + "type": "", + "model": "", + "type_map": "auto" + } +} +``` + +### Potential Types + +| type | pair_style | Model File | Notes | +|------|-----------|-----------|-------| +| `deepmd` | `deepmd` | `.pb` or `.pth` | DeePMD-kit model | +| `mace` | `mace no_domain_decomposition` | `.model` | MACE model | +| `nep` | `nep` | `nep.txt` | NEP potential | +| `gap` | `quip` | `.xml` + `.xml.sparseX.TERM` | GAP/QUIP potential | +| `snap` | `snap` | coeff + param files | SNAP potential | +| `rann` | `rann` | `.nn` | RANN potential | +| `eam_alloy` | `eam/alloy` | `.eam.alloy` | EAM alloy | +| `eam_fs` | `eam/fs` | `.eam.fs` | Finnis-Sinclair | +| `meam` | `meam` | library + parameter files | MEAM | +| `meam_spline` | `meam/spline` | `.meam.spline` | MEAM spline | + +### DeePMD Example + +```json +{ + "interaction": { + "type": "deepmd", + "model": "frozen_model.pb", + "type_map": "auto" + } +} +``` + +### MACE Example + +```json +{ + "interaction": { + "type": "mace", + "model": "mace_model.model", + "type_map": "auto" + } +} +``` + +### NEP Example + +```json +{ + "interaction": { + "type": "nep", + "model": "nep.txt", + "type_map": "auto" + } +} +``` + +### EAM Example + +```json +{ + "interaction": { + "type": "eam_alloy", + "model": "AlCu.eam.alloy", + "type_map": "auto" + } +} +``` + +### MEAM Example + +```json +{ + "interaction": { + "type": "meam", + "model": "library.meam TiAl.meam", + "type_map": "auto" + } +} +``` + +### ⚠️ LAMMPS Image Version + +Default image: `registry.dp.tech/dptech/dp/native/prod-397637/deepmd-kit-phonolammps:3.1.3` + +> ⚠️ **Do NOT use `deepmd-kit:3.1.1` with GPU/T4** — known startup / triclinic issues. Prefer `3.1.3` or later. + +> **LAMMPS phonon and Grüneisen**: `apex submit` forces the validated default image above. It supports the tested NVIDIA T4 configuration and includes phonoLAMMPS. + +### DPA-3.2-5M Multi-Head Model Preparation + +The source DPA-3.2-5M `.pt` checkpoint is **multi-head** and cannot be used +directly as `interaction.model` in APEX/LAMMPS. Freeze a specific task head +first. + +**Freeze command:** +```bash +dp --pt freeze -c DPA-3.2-5M.pt -o DPA-3.2-5M-OMat24.pth --head OMat24 +``` + +- `-c`: Input multi-head `.pt` checkpoint +- `-o`: Output frozen model (`.pth` for PyTorch, `.pb` for TensorFlow) +- `--head`: Which task head to extract + +The **frozen** `.pth` or `.pb` file is what goes into `interaction.model`: +```json +{ + "interaction": { + "type": "deepmd", + "model": "DPA-3.2-5M-OMat24.pth", + "type_map": "auto" + } +} +``` + +Useful heads include: +- `OMat24` — broad materials coverage; bundled default, including oxygen +- `Alloy_APEX` — APEX alloy/defect data; 53 metallic observed elements and no oxygen +- `Domains_Alloy` — general alloy energetics; 53 metallic observed elements +- `OC22` — oxide electrocatalyst structures + +Inspect the checkpoint before choosing: +```bash +dp --pt show DPA-3.2-5M.pt model-branch observed-type +``` + +> 💡 If you pass an unfrozen multi-head `.pt` file directly to LAMMPS, it will error with a message about missing head selection. + +### Bundled DPA model (`models/`) + +**Priority:** use the bundled **frozen** model first. The skill zip includes +the ready-to-run OMat24 `.pth`, not the multi-head source `.pt`. Details: +`models/README.md`. + +| Path | Format | Use | +|------|--------|-----| +| `models/DPA-3.2-5M/DPA-3.2-5M-OMat24.pth` | Frozen PyTorch (~23MB) | Default DPA-3.2 model for APEX | + +Copy the bundled model into the task directory and set: + +```json +{ + "interaction": { + "type": "deepmd", + "model": "DPA-3.2-5M-OMat24.pth", + "type_map": "auto" + } +} +``` + +The source checkpoint is optional and downloaded only when another task head +is explicitly requested: + +```bash +python scripts/fetch_models.py --source-checkpoint +``` + +> Use `"type_map": "auto"` by default. APEX infers the local contiguous type +> mapping from the structure; do not copy atomic-number or model-internal indices. +> Multi-head `.pt` files **must** be frozen (`dp --pt freeze ... --head `) +> before use as `interaction.model`. + +### Relaxation cal_setting (LAMMPS) + +```json +{ + "relaxation": { + "cal_setting": { + "etol": 0, + "ftol": 1e-10, + "maxiter": 5000, + "maximal": 500000 + } + } +} +``` + +Key LAMMPS-specific relaxation settings: +- `etol`: Energy tolerance for convergence (0 = disabled) +- `ftol`: Force tolerance (eV/Å) +- `maxiter`: Max minimization iterations +- `maximal`: Max force/energy evaluations + +--- + +## 2. ABACUS (DFT) + +### Basic Configuration + +```json +{ + "interaction": { + "type": "abacus", + "incar": "abacus_input/INPUT", + "potcar_prefix": "abacus_input", + "potcars": { + "Cu": "Cu_ONCV_PBE-1.0.upf" + }, + "orb_files": { + "Cu": "Cu_gga_9au_100Ry_4s2p2d1f.orb" + } + } +} +``` + +### Required Files + +| File | Description | Location | +|------|-------------|----------| +| INPUT | ABACUS input control file | `incar` path | +| INPUT_phonon | (Optional) INPUT for phonon/force SCF | Same directory | +| `*.upf` | Pseudopotential files | `potcar_prefix` directory | +| `*.orb` | Numerical orbital files (LCAO) | `potcar_prefix` directory | +| STRU | Structure file with PP/orbital refs | `confs//` | + +### ⚠️ Critical: STRU File Required + +APEX converts POSCAR → STRU via dpdata, but the generated STRU **lacks PP filename and NUMERICAL_ORBITAL section**, causing ABACUS to crash silently. + +**Always provide a STRU file** alongside POSCAR in `confs//`: + +``` +ATOMIC_SPECIES +Cu 63.546 Cu_ONCV_PBE-1.0.upf + +NUMERICAL_ORBITAL +Cu_gga_9au_100Ry_4s2p2d1f.orb + +LATTICE_CONSTANT +1.8897261246 + +LATTICE_VECTORS +0.000000000000 1.807500000000 1.807500000000 +1.807500000000 0.000000000000 1.807500000000 +1.807500000000 1.807500000000 0.000000000000 + +ATOMIC_POSITIONS +Direct + +Cu +0.0 +1 +0.000000000000 0.000000000000 0.000000000000 1 1 1 +``` + +### ⚠️ Critical: Do NOT Set pseudo_dir/orbital_dir in INPUT + +APEX's `modify_stru_path()` automatically prefixes PP/orbital filenames in STRU with `pp_orb/`. If INPUT also has `pseudo_dir ./pp_orb`, ABACUS looks for `./pp_orb/pp_orb/file.upf` (double prefix) → crash. + +**Correct**: INPUT has NO `pseudo_dir` / `orbital_dir` lines. + +### INPUT File — System-Dependent Defaults + +> ⚠️ **mixing_beta 是收敛关键参数**:金属体系用 0.7 会震荡不收敛!必须根据体系类型选择。推荐使用 `mixing_beta -10.0`(ABACUS 3.4+ 自动检测)或按下表手动设置。 + +**体系类型判断规则:** +- 金属:含 Li/Na/K/Ca/Al/Fe/Cu/Ni/Co/Ti/Zr/Hf/Ag/Au/Pt/Pd/W/Mo/Nb/Ta/V/Cr/Mn/Zn 等且无大带隙 +- 过渡金属:含 3d/4d/5d 元素(Fe/Co/Ni/Ti/V/Cr/Mn/Cu/Zn/Zr/Nb/Mo/Pd/Ag/Hf/Ta/W/Pt/Au) +- 非金属/半导体:Si/Ge/GaAs/ZnO/TiO2/MgO 等有明确带隙的体系 + +**Relaxation INPUT — 按体系分类:** + +| 参数 | 金属/合金 | 过渡金属/磁性 | 非金属/半导体 | +|------|-----------|--------------|--------------| +| `mixing_beta` | 0.2 | 0.2 | 0.7 | +| `mixing_gg0` | 0.0 | 1.5 (Kerker) | 0.0 | +| `smearing_method` | mp | mp | gauss | +| `smearing_sigma` | 0.01 | 0.01 | 0.01 | +| `scf_nmax` | 200 | 200 | 100 | +| `force_thr_ev` | 0.03 | 0.03 | 0.02 | +| `relax_nmax` | 100 | 100 | 50 | + +**通用 Relaxation INPUT(金属/合金,推荐默认):** +``` +INPUT_PARAMETERS +calculation cell-relax +basis_type lcao +ecutwfc 100 +scf_thr 1.0e-5 +scf_nmax 200 +smearing_method mp +smearing_sigma 0.01 +mixing_type broyden +mixing_beta 0.2 +mixing_gg0 1.5 +cal_force 1 +cal_stress 1 +force_thr_ev 0.03 +stress_thr 1.0 +relax_nmax 100 +kspacing 0.20 +``` + +**非金属/半导体 Relaxation INPUT:** +``` +INPUT_PARAMETERS +calculation cell-relax +basis_type lcao +ecutwfc 100 +scf_thr 1.0e-5 +scf_nmax 100 +smearing_method gauss +smearing_sigma 0.01 +mixing_type broyden +mixing_beta 0.7 +cal_force 1 +cal_stress 1 +force_thr_ev 0.02 +stress_thr 0.5 +relax_nmax 50 +kspacing 0.20 +``` + +**Phonon/Force SCF INPUT(通用):** +``` +INPUT_PARAMETERS +calculation scf +basis_type lcao +ecutwfc 100 +scf_thr 1.0e-7 +scf_nmax 200 +smearing_method mp +smearing_sigma 0.01 +mixing_type broyden +mixing_beta 0.2 +mixing_gg0 1.5 +cal_force 1 +kspacing 0.15 +``` + +### 收敛困难排查 + +如果 relaxation 不收敛: +1. **先检查 mixing_beta** — 金属体系必须 ≤ 0.2 +2. **加 Kerker 预条件** — 设 `mixing_gg0 1.5` +3. **增大 mixing_ndim** — 从 8 增到 20 +4. **减小 mixing_beta** — 试 0.1 或更小 +5. **增大 scf_nmax** — 从 100 增到 300 +6. **增大 relax_nmax** — 从 50 增到 200 + +### Key ABACUS Parameters + +| Parameter | 金属 | 非金属 | Description | +|-----------|------|--------|-------------| +| `ecutwfc` | 100 Ry | 100 Ry | PW cutoff for LCAO charge density | +| `scf_thr` | **1e-5** | **1e-5** | SCF convergence (relaxation) | +| `mixing_beta` | **0.2** | **0.7** | Mixing strength (⚠️ 关键!) | +| `mixing_gg0` | **1.5** | 0.0 | Kerker preconditioning | +| `smearing_method` | **mp** | gauss | Smearing type | +| `force_thr_ev` | 0.03 | 0.02 | Force convergence (eV/Å) | +| `stress_thr` | 1.0 | 0.5 | Stress convergence (GPa) | +| `kspacing` | **0.20 (required)** | **0.20 (required)** | K-point spacing (1/Bohr); APEX writes `KPT` from it | +| `scf_nmax` | **200** | 100 | Max SCF iterations | +| `relax_nmax` | **100** | 50 | Max relaxation steps | + +### ABACUS k-spacing constraints (REQUIRED) + +APEX does **not** accept a hand-written `KPT` as the primary control. At make time it requires either: +1. `kspacing` in `INPUT` (float, unit **1/Bohr**), or +2. `cal_setting.K_POINTS` as a 6-int list, e.g. `[6, 6, 6, 0, 0, 0]`. + +Missing both → `RuntimeError: K point information is not defined`. + +| Use case | Recommended `kspacing` | Notes | +|----------|------------------------|-------| +| Relaxation / screening | **0.20** | Default in skill templates | +| Phonon / force SCF | **0.15** | Slightly denser | +| Higher accuracy (user request) | 0.10 | ~8× cost vs 0.20 | + +> **kspacing 对比**: 0.20 → ~6×6×6 k-mesh (216 pts), 0.10 → ~12×12×12 (1728 pts). 计算量差 **8 倍**。 + +### ABACUS global.json Settings (Bohrium/dflow) + +```json +{ + "abacus_image_name": "registry.dp.tech/dptech/abacus:3.2.3", + "abacus_run_command": "mpirun -n 16 abacus", + "scass_type": "c16_m32_cpu" +} +``` + +### ⚠️ Critical: PRIMITIVE_AXES vs Cell Type (Phonon/Gruneisen) + +`PRIMITIVE_AXES` in param.json tells phonopy how to extract a primitive cell from a conventional cell. **Using it when the input is already a primitive cell causes `RuntimeError: Remapping of atoms by TrimmedCell failed`.** + +**Pre-submission check — MUST verify before setting PRIMITIVE_AXES:** + +| Input structure type | Example | PRIMITIVE_AXES? | +|---------------------|---------|----------------| +| FCC primitive cell (1 atom, `[0,a/2,a/2]` vectors) | Cu STRU with rhombohedral vectors | ❌ Do NOT set | +| FCC conventional cell (4 atoms, cubic `a×a×a`) | Cu POSCAR with cubic cell | ✅ Set `"0 1/2 1/2 1/2 0 1/2 1/2 1/2 0"` | +| BCC primitive cell (1 atom) | Fe with BCC primitive vectors | ❌ Do NOT set | +| BCC conventional cell (2 atoms, cubic) | Fe POSCAR cubic cell | ✅ Set `"1/2 1/2 1/2 -1/2 1/2 1/2 1/2 -1/2 1/2"` | +| HCP primitive cell (2 atoms) | Ti with hexagonal cell | ❌ Do NOT set | +| Simple cubic (1 atom) | Any SC structure | ❌ Do NOT set | + +**How to check**: Count atoms in the unit cell and examine lattice vectors. +- FCC with 1 atom + non-orthogonal vectors → already primitive → no PRIMITIVE_AXES +- FCC with 4 atoms + cubic orthogonal vectors → conventional → needs PRIMITIVE_AXES + +**If unsure**: Omit PRIMITIVE_AXES entirely. phonopy will use the cell as-is (with primitive_matrix = identity from phonopy_disp.yaml). The band path may use the full BZ of the input cell, which is correct but gives a denser path. + +### DFT Property Defaults (Auto-applied) + +When `interaction.type` is `abacus` or `vasp`, the skill auto-applies smaller supercells: + +| Property | LAMMPS Default | DFT Default | Reason | +|----------|---------------|-------------|--------| +| phonon supercell | 3×3×3 (27 atoms) | **2×2×2 (8 atoms)** | 3x fewer SCF tasks | +| gruneisen volumes | 5 points | **3 points** | 2x fewer | +| vacancy supercell | 3×3×3 | **2×2×2** | Fewer atoms | +| surface max_miller | 2 | **1** | Fewer surfaces | + +--- + +## 3. VASP (DFT) + +> ⚠️ **Commercial software**: VASP requires a valid license. Never assume VASP is available. Always confirm with user before proceeding. + +### Basic Configuration + +```json +{ + "interaction": { + "type": "vasp", + "incar": "INCAR", + "potcar_prefix": ".", + "potcars": { + "Mo": "POTCAR_Mo", + "Al": "POTCAR_Al", + "Fe": "POTCAR_Fe" + } + } +} +``` + +### Required Files + +| File | Description | User Responsibility | +|------|-------------|-------------------| +| INCAR | VASP input parameters | Provided or generated; **must include `KSPACING`** | +| POTCAR | Pseudopotentials | **User must provide** (license-restricted) | +| KPOINTS | K-mesh | **Auto-generated by APEX** from `KSPACING` (+ `KGAMMA`); do not hand-author | + +### VASP k-spacing constraints (REQUIRED) + +APEX **requires** `KSPACING` in `INCAR` (or `cal_setting.kspacing`). At make time it: +1. Reads `KSPACING` / `KGAMMA` from INCAR +2. Builds a Monkhorst/Gamma mesh from the POSCAR reciprocal lattice +3. Writes per-task `KPOINTS` + +Missing `KSPACING` → `RuntimeError: KSPACING must be given in INCAR`. + +| Tag | Required? | Default / recommendation | Notes | +|-----|-----------|--------------------------|-------| +| `KSPACING` | **Yes** | `0.1`–`0.2` (Å⁻¹) for screening | Smaller → denser mesh, higher cost | +| `KGAMMA` | Strongly recommended | `True` (Gamma) or `False` (MP) | Controls mesh centering | + +Do **not** ship a static `KPOINTS` file expecting APEX to use it as the primary control — spacing drives generation. For `elastic`, APEX regenerates one shared `KPOINTS` from the undeformed cell and symlinks it into every deformation task. + +### INCAR Example (Relaxation) + +``` +SYSTEM = APEX relaxation +PREC = Accurate +ENCUT = 520 +EDIFF = 1E-6 +EDIFFG = -0.01 +IBRION = 2 +NSW = 200 +ISIF = 3 +ISMEAR = 1 +SIGMA = 0.1 +LREAL = Auto +KSPACING = 0.15 +KGAMMA = True +``` + +### VASP global.json Settings (Bohrium / dflow) + +> ⚠️ **Never** use bare `mpirun -n 16 vasp_std`. The Bohrium VASP image needs Intel oneAPI env + absolute binary path. + +```json +{ + "dflow_host": "https://workflows.deepmodeling.com", + "k8s_api_server": "https://workflows.deepmodeling.com", + "batch_type": "Bohrium", + "context_type": "Bohrium", + "vasp_image_name": "", + "vasp_run_command": "bash -c \"source /opt/intel/oneapi/setvars.sh && ulimit -s unlimited && mpirun -n 32 /opt/vasp.5.4.4/bin/vasp_std\"", + "scass_type": "c32_m128_cpu", + "group_size": 1, + "pool_size": 1 +} +``` + +> ⚠️ **Do not auto-fill `vasp_image_name`.** VASP is commercial. Resolve the image +> first via Bohrium private-image listing or a user-known authorized address: +> +> ```text +> # Preferred when MatMaster Bohrium tool is available: +> Bohrium(action="list_images", keyword="vasp") +> +> # Skill helper (same OpenAPI /openapi/v2/image/private): +> python scripts/list_bohrium_images.py --keyword vasp --require +> ``` +> +> Then pass the approved URL to `generate_config.py create --vasp-image `. +> If `list_images` returns nothing **and** the user does not know an authorized +> image address → **terminate** the VASP workflow (do not invent a public tag). + +`vasp_run_command` constraints (typical Bohrium VASP layout; adjust binary path if the user image differs): + +| Piece | Why | +|-------|-----| +| `source /opt/intel/oneapi/setvars.sh` | Loads Intel MPI / MKL | +| `ulimit -s unlimited` | Avoids stack overflow on large cells | +| Absolute `vasp_std` path | PATH `vasp_std` is unreliable | +| `mpirun -n ` | `` must match `scass_type` CPUs (`c32_*`→32, `c16_*`→16) | + +Local/debug Shell jobs may use a simpler command only when the host already has VASP + MPI on PATH; for Bohrium use the template above. `generate_config.py` emits the run_command template for `--backend vasp` but leaves `vasp_image_name` unset. + +### VASP POTCAR Handling + +APEX concatenates files at: + +```text +os.path.join(potcar_prefix, potcars[element]) # must be a readable FILE +``` + +Source potpaw libraries often look like: + +```text +/path/to/POTCAR_LIBRARY/ +├── Mo_sv/POTCAR +├── Al/POTCAR +├── Fe_pv/POTCAR +└── ... +``` + +For Bohrium upload, **copy flat files into the job root** and use: + +```json +"potcar_prefix": ".", +"potcars": { + "Mo": "POTCAR_Mo", + "Al": "POTCAR_Al", + "Fe": "POTCAR_Fe" +} +``` + +(`generate_config.py create` does this staging automatically. Nested +`Ti_pv/POTCAR` under `potcar_prefix: "."` is **not** reliably uploaded.) + +### Agent check (mandatory when user supplies a POTCAR path) + +**Critical:** absolute host paths (e.g. `/share/PAW_PBE`) work only on that +machine. After Bohrium/dflow upload they vanish → +`FileNotFoundError: '/share/PAW_PBE/Ti_pv/POTCAR'`. + +Before submit: + +1. Confirm the user library exists and is readable locally. +2. For each element, confirm a readable POTCAR file. +3. Stage into the job root as `POTCAR_` (via create or `cp`/`mv`) and + set `"potcar_prefix": "."`. Never ship absolute `/share/...`. +4. Confirm the job directory contains those flat POTCAR files before upload. +5. On failure: tell the user the path is unusable and ask for the correct + library. Do not guess. + +`scripts/validate_inputs.py` checks that staged relative POTCAR files exist. + +--- + +## Backend Selection Guide + +| Scenario | Recommended Backend | Rationale | +|----------|-------------------|-----------| +| Quick screening of many compositions | LAMMPS + DeePMD/MACE | Fast, GPU-accelerated | +| High-accuracy single material | VASP or ABACUS | Full DFT | +| High-entropy alloy (large supercell) | LAMMPS + MLIP | Scalable to 1000+ atoms | +| Finite-T properties | LAMMPS (required) | NPT MD required | +| Phonon (quick) | LAMMPS + phonoLAMMPS | Direct force constants | +| Phonon (accurate) | ABACUS/VASP | DFPT or finite differences | +| Surface/defect (accurate) | ABACUS/VASP | DFT accuracy needed | + +--- + +## Machine Type Recommendations + +| Workload | Bohrium Machine | Rationale | +|----------|----------------|-----------| +| LAMMPS + GPU potential (DeePMD/MACE/NEP) | `c8_m31_1 * NVIDIA T4` | GPU acceleration | +| LAMMPS + CPU potential (EAM/MEAM/SNAP) | `c16_m32_cpu` | CPU sufficient | +| ABACUS DFT (small cell <50 atoms) | `c16_m32_cpu` | 8 MPI ranks | +| ABACUS DFT (large cell 50-200 atoms) | `c32_m128_cpu` | 16-32 MPI ranks | +| VASP DFT | User choice | Depends on system size | +| Finite-T MD (long runs) | `c8_m31_1 * NVIDIA T4` | Long MD = GPU beneficial | diff --git a/apex/skills/apex-flow/reference/examples.md b/apex/skills/apex-flow/reference/examples.md new file mode 100644 index 00000000..65cb0d85 --- /dev/null +++ b/apex/skills/apex-flow/reference/examples.md @@ -0,0 +1,508 @@ +# APEX Examples — Common Workflow Configurations + +## Example 1: BCC Mo with DeePMD — EOS + Elastic + +**Scenario**: Quick property screening of Mo using a DeePMD potential. + +### Directory Structure +``` +job_mo_deepmd/ +├── confs/std-bcc/POSCAR +├── frozen_model.pb +├── global.json +└── param.json +``` + +### POSCAR (BCC Mo) +``` +Mo +1.0 +3.168 0.000 0.000 +0.000 3.168 0.000 +0.000 0.000 3.168 +Mo +2 +Direct +0.000 0.000 0.000 +0.500 0.500 0.500 +``` + +### global.json +```json +{ + "machine": { + "batch_type": "Shell", + "context_type": "LazyLocalContext", + "local_root": "." + }, + "resources": { + "number_node": 1, + "cpu_per_node": 4, + "group_size": 1 + }, + "run_command": "lmp -in in.lammps" +} +``` + +### param.json +```json +{ + "structures": ["confs/std-bcc"], + "interaction": { + "type": "deepmd", + "model": "frozen_model.pb", + "type_map": "auto" + }, + "relaxation": { + "cal_setting": { + "etol": 0, + "ftol": 1e-10, + "maxiter": 5000, + "maximal": 500000 + } + }, + "properties": [ + { + "type": "eos", + "vol_start": 0.8, + "vol_end": 1.2, + "vol_step": 0.05 + }, + { + "type": "elastic", + "norm_deform": 0.01, + "shear_deform": 0.01 + } + ] +} +``` + +### Preferred: APEX workflow submission +``` +image: registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post +machine: c1_m2_cpu +cmd: apex submit param.json -c global.json -f joint -n "mo-bcc-eos-elastic" > log 2>&1 +``` + +Use `apex submit` for production and Bohrium workflows. It delegates the +calculation to dflow. For agent-managed runs, do not use `-s`; the outer job +waits for completion and retrieves results automatically. + +### Alternative: local step-by-step debugging +```bash +apex do param.json make_relax -c global.json && +apex do param.json run_relax -c global.json && +apex do param.json post_relax -c global.json && +apex do param.json make_props -c global.json && +apex do param.json run_props -c global.json && +apex do param.json post_props -c global.json +``` + +--- + +## Example 2: FCC Cu with ABACUS — Surface + Vacancy + +**Scenario**: DFT-level surface energy and vacancy formation energy for Cu. + +### Directory Structure +``` +job_cu_abacus/ +├── confs/std-fcc/POSCAR +├── INPUT +├── Cu_ONCV_PBE-1.0.upf +├── Cu_gga_7au_100Ry_4s2p2d1f.orb +├── global.json +└── param.json +``` + +### INPUT +``` +INPUT_PARAMETERS +calculation relax +basis_type lcao +ecutwfc 100 +scf_thr 1.0e-7 +force_thr_ev 0.02 +stress_thr 0.5 +relax_method cg +relax_nmax 200 +smearing_method gaussian +smearing_sigma 0.002 +kspacing 0.15 +``` + +### param.json +```json +{ + "structures": ["confs/std-fcc"], + "interaction": { + "type": "abacus", + "incar": "INPUT", + "potcar_prefix": ".", + "potcars": {"Cu": "Cu_ONCV_PBE-1.0.upf"}, + "orb_files": {"Cu": "Cu_gga_7au_100Ry_4s2p2d1f.orb"} + }, + "relaxation": { + "cal_setting": { + "relax_pos": true, + "relax_shape": true, + "relax_vol": true + } + }, + "properties": [ + { + "type": "surface", + "min_slab_size": 40, + "min_vacuum_size": 15, + "max_miller": 2 + }, + { + "type": "vacancy", + "supercell": [3, 3, 3] + } + ] +} +``` + +### global.json +```json +{ + "machine": { + "batch_type": "Shell", + "context_type": "LazyLocalContext", + "local_root": "." + }, + "resources": { + "number_node": 1, + "cpu_per_node": 8, + "group_size": 1 + }, + "run_command": "mpirun -n 8 abacus" +} +``` + +### Preferred: APEX workflow submission +``` +image: registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post +machine: c1_m2_cpu +cmd: apex submit param.json -c global.json -f joint -n "cu-fcc-surface-vacancy" > log 2>&1 +``` + +Use `apex submit` for production and Bohrium workflows. The calculator resources +are configured for the inner dflow tasks in `global.json`; the outer submission +client only needs a small CPU machine. For agent-managed runs, omit `-s` so the +client waits for completion and retrieves results automatically. + +### Alternative: local step-by-step debugging +```bash +apex do param.json make_relax -c global.json && +apex do param.json run_relax -c global.json && +apex do param.json post_relax -c global.json && +apex do param.json make_props -c global.json && +apex do param.json run_props -c global.json && +apex do param.json post_props -c global.json +``` + +--- + +## Example 3: HEA CoCrFeMnNi with DeePMD — RSS + Properties + +**Scenario**: High-entropy alloy property screening using random solid solutions. + +### Step 1: Generate RSS structures (run locally) + +Write `rss.json`: + +```json +{ + "parent_lattice": { + "type": "fcc", + "a": "auto", + "supercell": "auto" + }, + "compositions": { + "all": { + "Co": 0.2, + "Cr": 0.2, + "Fe": 0.2, + "Mn": 0.2, + "Ni": 0.2 + } + }, + "composition_tolerance": 0.005, + "maximum_num_atoms": 200, + "num_configs": 5, + "seed": 21, + "output_structure": "confs/rss_fcc" +} +``` + +```bash +apex rss rss.json +``` + +### Step 2: Property calculation + +### param.json +```json +{ + "structures": ["confs/rss_fcc/conf_*"], + "interaction": { + "type": "deepmd", + "model": "CoCrFeMnNi.pb", + "type_map": "auto" + }, + "relaxation": { + "cal_setting": { + "etol": 0, + "ftol": 1e-10, + "maxiter": 5000, + "maximal": 500000 + } + }, + "properties": [ + {"type": "eos", "vol_start": 0.85, "vol_end": 1.15, "vol_step": 0.05}, + {"type": "elastic", "norm_deform": 0.01, "shear_deform": 0.01} + ] +} +``` + +--- + +## Example 4: Al with LAMMPS — Finite-T Elastic + Annealing + +**Scenario**: Temperature-dependent elastic constants and annealing for Al. + +### param.json +```json +{ + "structures": ["confs/std-fcc"], + "interaction": { + "type": "eam_alloy", + "model": "Al99.eam.alloy", + "type_map": "auto" + }, + "relaxation": { + "cal_setting": { + "etol": 0, + "ftol": 1e-10 + } + }, + "properties": [ + { + "type": "finite_t_elastic", + "supercell_size": [4, 4, 4], + "cal_setting": { + "temperature": [200, 300, 400, 500, 600], + "strain": 0.001, + "equi_step": 20000, + "response_step": 20000 + } + }, + { + "type": "annealing", + "supercell_size": [5, 5, 5], + "cal_setting": { + "start_temp": 4, + "target_temp": 900, + "end_temp": 300, + "temp_ramp_rate": 500, + "cool_rate": 100, + "hold_step": 40000 + } + } + ] +} +``` + +--- + +## Example 5: Si with VASP — Phonon + +**Scenario**: Phonon band structure for Si using VASP. + +> ⚠️ Confirm VASP license/image with the user. Stage POTCARs into the job root +> (`POTCAR_Si` + `"potcar_prefix": "."`); do not leave absolute host paths +> like `/share/PAW_PBE`. INCAR **must** include `KSPACING`. Use the Bohrium +> `vasp_run_command` template (never bare `mpirun ... vasp_std`). + +### param.json +```json +{ + "structures": ["confs/std-diamond"], + "interaction": { + "type": "vasp", + "incar": "INCAR", + "potcar_prefix": ".", + "potcars": {"Si": "POTCAR_Si"} + }, + "relaxation": { + "cal_setting": { + "relax_pos": true, + "relax_shape": true, + "relax_vol": true + } + }, + "properties": [ + { + "type": "phonon", + "supercell_size": [3, 3, 3], + "MESH": [20, 20, 20], + "BAND_POINTS": 51 + } + ] +} +``` + +### INCAR +``` +PREC = Accurate +ENCUT = 400 +EDIFF = 1E-8 +IBRION = -1 +NSW = 0 +ISMEAR = 0 +SIGMA = 0.05 +LREAL = .FALSE. +KSPACING = 0.15 +KGAMMA = True +``` + +### Resolve VASP image first +```bash +# MatMaster: Bohrium(action="list_images", keyword="vasp") +python /scripts/list_bohrium_images.py --keyword vasp --require +# If empty and user has no authorized address → stop. +# Else create with --vasp-image +``` + +### global.json (VASP keys) +```json +{ + "vasp_image_name": "", + "vasp_run_command": "bash -c \"source /opt/intel/oneapi/setvars.sh && ulimit -s unlimited && mpirun -n 32 /opt/vasp.5.4.4/bin/vasp_std\"", + "scass_type": "c32_m128_cpu" +} +``` + +--- + +## Example 6: BCC W — Gamma Surface (111) plane + +**Scenario**: 2D generalized stacking fault energy map for W {111} plane. + +### param.json +```json +{ + "structures": ["confs/std-bcc"], + "interaction": { + "type": "deepmd", + "model": "W_model.pb", + "type_map": "auto" + }, + "relaxation": { + "cal_setting": { + "etol": 0, + "ftol": 1e-10 + } + }, + "properties": [ + { + "type": "gamma_surface", + "plane_miller": [1, 1, 0], + "slip_direction": [1, -1, 1], + "supercell_size": [1, 1, 8], + "n_steps_x": 12, + "n_steps_y": 12, + "cal_setting": { + "relax_pos": true, + "relax_shape": false, + "relax_vol": false, + "etol": 0, + "ftol": 1e-10 + } + } + ] +} +``` + +--- + +## Example 7: Grüneisen & Thermal Expansion for Cu + +**Scenario**: Compute Grüneisen parameters and thermal expansion coefficient. + +### param.json +```json +{ + "structures": ["confs/std-fcc"], + "interaction": { + "type": "deepmd", + "model": "Cu_model.pb", + "type_map": "auto" + }, + "relaxation": { + "cal_setting": { + "etol": 0, + "ftol": 1e-10 + } + }, + "properties": [ + { + "type": "gruneisen", + "supercell_size": [3, 3, 3], + "volume_strains": [-0.02, -0.01, 0.0, 0.01, 0.02], + "temperatures": [100, 200, 300, 400, 500, 600, 800, 1000], + "alpha_mode": "full", + "MESH": [20, 20, 20], + "cal_setting": { + "relax_pos": true, + "relax_shape": false, + "relax_vol": false, + "etol": 0, + "ftol": 1e-10 + } + } + ] +} +``` + +--- + +## Example 8: Decohesive + Cohesive Energy + +**Scenario**: Ideal work of separation for (110) plane and cohesive energy curve. + +### param.json +```json +{ + "structures": ["confs/std-bcc"], + "interaction": { + "type": "deepmd", + "model": "Fe_model.pb", + "type_map": "auto" + }, + "relaxation": { + "cal_setting": { + "etol": 0, + "ftol": 1e-10 + } + }, + "properties": [ + { + "type": "cohesive", + "latt_start": 0.8, + "latt_end": 1.5, + "latt_step": 0.02, + "cal_type": "static" + }, + { + "type": "decohesive", + "miller_index": [1, 1, 0], + "min_slab_size": 40, + "max_vacuum_size": 20, + "vacuum_size_step": 0.5 + } + ] +} +``` diff --git a/apex/skills/apex-flow/reference/lammps_potentials.md b/apex/skills/apex-flow/reference/lammps_potentials.md new file mode 100644 index 00000000..d19f6786 --- /dev/null +++ b/apex/skills/apex-flow/reference/lammps_potentials.md @@ -0,0 +1,244 @@ +# APEX LAMMPS Potential Types — Detailed Reference + +## Overview + +APEX supports 10 LAMMPS potential types through the `interaction.type` field. Each maps to a specific LAMMPS `pair_style` and requires corresponding model/parameter files. + +--- + +## Machine Learning Potentials (GPU-recommended) + +### 1. DeePMD (`deepmd`) + +**pair_style**: `deepmd` + +```json +{ + "type": "deepmd", + "model": "frozen_model.pb", + "type_map": "auto" +} +``` + +**Model files**: `.pb` (frozen graph) or `.pth` (PyTorch) +**Notes**: +- Most widely used MLIP in APEX workflows +- GPU strongly recommended for large systems +- APEX infers the local element ordering from the submitted structure +- Supports model deviation with multiple models: `"model": "model_0.pb model_1.pb"` + +### 2. MACE (`mace`) + +**pair_style**: `mace no_domain_decomposition` + +```json +{ + "type": "mace", + "model": "MACE_model.model", + "type_map": "auto" +} +``` + +**Model files**: `.model` (MACE checkpoint) +**Notes**: +- `no_domain_decomposition` is required (single GPU) +- Excellent for multi-element systems +- GPU recommended + +### 3. NEP (`nep`) + +**pair_style**: `nep` + +```json +{ + "type": "nep", + "model": "nep.txt", + "type_map": "auto" +} +``` + +**Model files**: `nep.txt` (NEP parameter file) +**Notes**: +- Neuroevolution potential from GPUMD ecosystem +- Very fast on GPU +- Compact model files + +### 4. GAP (`gap`) + +**pair_style**: `quip` + +```json +{ + "type": "gap", + "model": "GAP.xml", + "type_map": "auto" +} +``` + +**Model files**: `.xml` (GAP descriptor file) + associated sparse point files +**Notes**: +- Gaussian Approximation Potential +- CPU-based (no GPU acceleration in LAMMPS) +- Can be slow for large systems +- Requires QUIP compiled with LAMMPS + +### 5. SNAP (`snap`) + +**pair_style**: `snap` + +```json +{ + "type": "snap", + "model": "W.snapcoeff W.snapparam", + "type_map": "auto" +} +``` + +**Model files**: `.snapcoeff` + `.snapparam` +**Notes**: +- Spectral Neighbor Analysis Potential +- CPU-based +- Good for single-element systems + +### 6. RANN (`rann`) + +**pair_style**: `rann` + +```json +{ + "type": "rann", + "model": "Fe.nn", + "type_map": "auto" +} +``` + +**Model files**: `.nn` (neural network file) +**Notes**: +- Rapid Artificial Neural Network potential +- CPU-based + +--- + +## Classical Potentials (CPU) + +### 7. EAM Alloy (`eam_alloy`) + +**pair_style**: `eam/alloy` + +```json +{ + "type": "eam_alloy", + "model": "AlCu.eam.alloy", + "type_map": "auto" +} +``` + +**Model files**: `.eam.alloy` (DYNAMO setfl format) +**Notes**: +- Standard embedded atom method for alloys +- Very fast, CPU sufficient +- Large library of potentials available (e.g., from NIST) + +### 8. EAM Finnis-Sinclair (`eam_fs`) + +**pair_style**: `eam/fs` + +```json +{ + "type": "eam_fs", + "model": "Fe.eam.fs", + "type_map": "auto" +} +``` + +**Model files**: `.eam.fs` (Finnis-Sinclair format) +**Notes**: +- Variant of EAM with element-pair-dependent density functions +- Fast, CPU sufficient + +### 9. MEAM (`meam`) + +**pair_style**: `meam` + +```json +{ + "type": "meam", + "model": "library.meam TiAl.meam", + "type_map": "auto" +} +``` + +**Model files**: Two files — library file + parameter file (space-separated in `model`) +**Notes**: +- Modified EAM with angular-dependent terms +- Better for covalent/metallic bonding mix +- CPU-based + +### 10. MEAM Spline (`meam_spline`) + +**pair_style**: `meam/spline` + +```json +{ + "type": "meam_spline", + "model": "Ti.meam.spline", + "type_map": "auto" +} +``` + +**Model files**: `.meam.spline` +**Notes**: +- Spline-based MEAM (no library file needed) +- More flexible functional form +- CPU-based + +--- + +## type_map Convention + +Use automatic inference for LAMMPS interactions: + +```json +"type_map": "auto" +``` + +At submission time, APEX reads the first matching structure, creates a +zero-based contiguous element map, and writes the resolved dictionary back to +`param.json`. Do not derive values from atomic numbers or a model's internal +type indices. + +Use a manual dictionary only when the user explicitly needs a fixed custom +ordering. If multiple matched structures contain different element sets, +split them into compatible submissions or provide and verify one complete +manual map. + +--- + +## Image Selection Guide + +| Potential Type | Recommended Image | GPU? | +|---------------|-------------------|------| +| deepmd | `registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post` | Yes | +| mace | `registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post` | Yes | +| nep | `registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post` | Yes | +| gap | `registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post` | No (CPU) | +| snap | `registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post` | No | +| rann | `registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post` | No | +| eam_alloy | `registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post` | No | +| eam_fs | `registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post` | No | +| meam | `registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post` | No | +| meam_spline | `registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post` | No | + +All potential types are supported by the unified APEX image. The APEX 1.3.0 image ships LAMMPS compiled with DeePMD, MACE, NEP, and standard LAMMPS potentials. + +--- + +## Troubleshooting + +| Error | Cause | Fix | +|-------|-------|-----| +| `pair_style not found` | LAMMPS not compiled with required package | Use APEX official image | +| `type_map` inference failure | Structure file is missing or matched structures use incompatible element sets | Fix `structures` paths or provide one verified manual map | +| `model file not found` | File not in job directory | Ensure model file is copied to submission dir | +| `GPU not available` | Running GPU potential on CPU node | Switch to GPU machine type | +| `segfault in mace` | Domain decomposition issue | `no_domain_decomposition` is already set by APEX | diff --git a/apex/skills/apex-flow/reference/properties.md b/apex/skills/apex-flow/reference/properties.md new file mode 100644 index 00000000..29d9ed05 --- /dev/null +++ b/apex/skills/apex-flow/reference/properties.md @@ -0,0 +1,654 @@ +# APEX Properties — Complete Parameter Reference (Stable Defaults) + +> **Version**: Updated with tested, stable defaults that prevent KeyError failures. +> Every property below includes a **complete working default JSON** that will run +> without modification for typical FCC/BCC metals. + +## Common Parameters + +These parameters appear in most property configurations: + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `type` | str | — | Property type identifier (**REQUIRED**) | +| `cal_type` | str | varies | Calculation type: `"relaxation"` or `"static"` | +| `reproduce` | bool | `false` | Reproduce mode: re-run from prior data | +| `init_from_suffix` | str | `"00"` | Source suffix for refine/reproduce mode | +| `init_data_path` | str | — | Path to prior data (required in reproduce mode) | +| `start_confs_path` | str | — | Override structure path | +| `skip` | bool | `false` | Skip this property entirely | +| `req_calc` | bool | `true` | Whether to include in workflow | + +### cal_setting common keys + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `relax_pos` | bool | varies | Allow ionic relaxation | +| `relax_shape` | bool | varies | Allow cell shape change | +| `relax_vol` | bool | varies | Allow volume change | +| `etol` | float | — | Energy tolerance (LAMMPS) | +| `ftol` | float | — | Force tolerance (LAMMPS) | +| `maxiter` | int | — | Max iterations (LAMMPS) | +| `overwrite_interaction` | dict | — | Override global interaction for this property | + +--- + +## 1. EOS (Equation of State) + +**type**: `"eos"` + +| Parameter | Required? | Type | Default | Description | +|-----------|-----------|------|---------|-------------| +| `vol_start` | **REQUIRED** | float | `0.8` | Start volume ratio (or absolute ų if vol_abs=true) | +| `vol_end` | **REQUIRED** | float | `1.2` | End volume ratio | +| `vol_step` | **REQUIRED** | float | `0.05` | Volume step | +| `vol_abs` | optional | bool | `false` | Use absolute volumes instead of ratios | + +**cal_setting defaults**: `relax_pos=true`, `relax_shape=true`, `relax_vol=false` + +**Complete working default**: +```json +{ + "type": "eos", + "vol_start": 0.8, + "vol_end": 1.2, + "vol_step": 0.05 +} +``` + +**Output**: Volume-energy data points, Birch-Murnaghan fit (V₀, B₀, B₀'). + +**Notes**: +- 9 volume points from 0.8 to 1.2 in steps of 0.05 is a good balance of accuracy vs cost. +- For strongly anharmonic systems, narrow to 0.9–1.1 with step 0.02. + +--- + +## 2. Cohesive Energy + +**type**: `"cohesive"` + +| Parameter | Required? | Type | Default | Description | +|-----------|-----------|------|---------|-------------| +| `latt_start` | **REQUIRED** | float | `0.8` | Start lattice constant ratio | +| `latt_end` | **REQUIRED** | float | `1.5` | End lattice constant ratio | +| `latt_step` | **REQUIRED** | float | `0.05` | Lattice constant step | +| `latt_abs` | optional | bool | `false` | Use absolute lattice constants | +| `cal_type` | optional | str | `"static"` | Should be "static" for cohesive | + +**cal_setting defaults**: none explicitly set (static by default) + +**Complete working default**: +```json +{ + "type": "cohesive", + "latt_start": 0.8, + "latt_end": 1.5, + "latt_step": 0.05, + "cal_type": "static" +} +``` + +**Output**: Lattice constant vs. cohesive energy (E_atom − E_isolated). + +**Notes**: +- Range 0.8–1.5 is wide enough to capture the energy minimum and repulsive wall. +- `cal_type: "static"` is correct — no relaxation at each lattice point. + +--- + +## 3. Elastic Constants + +**type**: `"elastic"` + +| Parameter | Required? | Type | Default | Description | +|-----------|-----------|------|---------|-------------| +| `norm_deform` | optional | float | `0.01` | Normal strain magnitude | +| `shear_deform` | optional | float | `0.01` | Shear strain magnitude | +| `conventional` | optional | bool | `false` | Use conventional cell | +| `ieee` | optional | bool | `false` | Apply IEEE rotation | +| `modulus_type` | optional | str | `"voigt"` | Averaging: `"voigt"`, `"reuss"`, or `"vrh"` | + +**cal_setting defaults**: `relax_pos=true`, `relax_shape=false`, `relax_vol=false` + +**Complete working default**: +```json +{ + "type": "elastic", + "norm_deform": 0.01, + "shear_deform": 0.01 +} +``` + +**Output**: Elastic tensor Cij (6×6), bulk modulus B, shear modulus G, Young's modulus E, Poisson's ratio ν. + +**Notes**: +- Strain magnitude 0.01 (1%) is standard for metals. Reduce to 0.005 for stiff ceramics. +- No required parameters beyond `type` — all have safe defaults. + +--- + +## 4. Surface Energy + +**type**: `"surface"` + +| Parameter | Required? | Type | Default | Description | +|-----------|-----------|------|---------|-------------| +| `min_slab_size` | **REQUIRED** | float | `50` | Minimum slab thickness (Å) | +| `min_vacuum_size` | **REQUIRED** | float | `20` | Minimum vacuum layer (Å) | +| `max_miller` | optional | int | `2` | Maximum Miller index for enumeration | +| `pert_xz` | optional | float | `0.01` | Perturbation to break symmetry | + +**cal_setting defaults**: `relax_pos=true`, `relax_shape=true`, `relax_vol=false` + +**Complete working default**: +```json +{ + "type": "surface", + "min_slab_size": 50, + "min_vacuum_size": 20, + "max_miller": 2 +} +``` + +**Output**: Surface energy (J/m²) for each inequivalent (hkl) surface. + +**Notes**: +- `max_miller=2` enumerates (100), (110), (111), (210), (211), (221) etc. +- 50 Å slab + 20 Å vacuum is conservative; ensures convergence for most metals. +- Many surfaces generated → can be costly with DFT. Use `max_miller=1` for quick tests. + +--- + +## 5. Vacancy Formation Energy + +**type**: `"vacancy"` + +| Parameter | Required? | Type | Default | Description | +|-----------|-----------|------|---------|-------------| +| `supercell` | optional | list[int] | `[3,3,3]` | Supercell dimensions [nx, ny, nz] | + +**cal_setting defaults**: `relax_pos=true`, `relax_shape=true`, `relax_vol=true` + +**Complete working default**: +```json +{ + "type": "vacancy", + "supercell": [3, 3, 3] +} +``` + +**Output**: Formation energy (eV) for each symmetrically inequivalent vacancy site. + +**Notes**: +- [3,3,3] supercell = 27 atoms for simple cubic, 108 for FCC — adequate for convergence. +- [2,2,2] is minimum acceptable; [4,4,4] for publication-quality DFT. + +--- + +## 6. Interstitial Formation Energy + +**type**: `"interstitial"` + +| Parameter | Required? | Type | Default | Description | +|-----------|-----------|------|---------|-------------| +| `supercell` | optional | list[int] | `[3,3,3]` | Supercell dimensions | +| `insert_ele` | optional | str/list | `null` | Element(s) to insert (null = use host species) | +| `lattice_type` | optional | str | `null` | Crystal type for special sites: `"bcc"`, `"fcc"`, `"hcp"` | +| `special_list` | optional | list[str] | `["bcc","fcc","hcp"]` | Lattice types eligible for predefined sites | +| `voronoi_param` | optional | dict | `{}` | Parameters for VoronoiInterstitialGenerator | + +**cal_setting defaults**: `relax_pos=true`, `relax_shape=true`, `relax_vol=true` + +**Complete working default**: +```json +{ + "type": "interstitial", + "supercell": [3, 3, 3] +} +``` + +**Output**: Formation energy (eV) for each interstitial configuration. + +**Notes**: +- Without `insert_ele`, inserts the host element as self-interstitial. +- `lattice_type` enables predefined octahedral/tetrahedral sites for known crystal types. +- For H/C/N interstitials, specify `"insert_ele": "H"` (or list for multiple). + +--- + +## 7. Phonon Spectra + +**type**: `"phonon"` + +| Parameter | Required? | Type | Default | Description | +|-----------|-----------|------|---------|-------------| +| `supercell_size` | **REQUIRED** | list[int] | `[3,3,3]` | Phonon supercell | +| `primitive` | optional | bool | `false` | Use primitive cell | +| `approach` | optional | str | `"linear"` | `"linear"` (finite diff) or `"displacement"` | +| `seekpath_from_original` | optional | bool | `false` | Band path from original cell | +| `MESH` | optional | list[int] | `null` | Phonopy DOS mesh (e.g., [20,20,20]) | +| `BAND_POINTS` | optional | int | `51` | Points per band segment | +| `BAND_CONNECTION` | optional | bool | `true` | Connect bands | +| `PRIMITIVE_AXES` | optional | str | `"P"` | Phonopy PRIMITIVE_AXES | +| `BAND` | optional | str | `null` | Explicit band path | +| `BAND_LABELS` | optional | list[str] | `null` | High-symmetry labels | +| `phonolammps_run_command` | optional | str | `null` | Custom phonoLAMMPS command; supports `{primitive_axes}`, otherwise the matching `-pa` argument is appended | + +**cal_setting defaults**: `relax_pos=true`, `relax_shape=false`, `relax_vol=false`, `cal_type="static"` + +**Complete working default**: +```json +{ + "type": "phonon", + "supercell_size": [3, 3, 3], + "BAND_POINTS": 51 +} +``` + +**Output**: Phonon band structure, DOS, force constants. + +**Notes**: +- ⚠️ **[3,3,3] strongly recommended.** [2,2,2] may cause failures or inaccurate results with LAMMPS phonoLAMMPS. +- For LAMMPS, uses phonoLAMMPS for direct force constant calculation. For VASP/ABACUS, generates displaced supercells. +- Add `"MESH": [20, 20, 20]` to compute phonon DOS alongside the band structure. + +--- + +## 8. Gamma Line (1D Stacking Fault) + +**type**: `"gamma"` + +| Parameter | Required? | Type | Default | Description | +|-----------|-----------|------|---------|-------------| +| `plane_miller` | **REQUIRED** | list[int] | `[1,1,1]` | Slip plane Miller indices | +| `slip_direction` | **REQUIRED** | list[int] | `[-1,1,0]` | Slip direction (**must lie ON the plane**) | +| `slip_length` | optional | float | `null` | Total slip distance (Å); auto if null | +| `plane_shift` | optional | int/float | `0` | Shift of slip plane position | +| `supercell_size` | optional | list[int] | `[1,1,5]` | Supercell for slab | +| `vacuum_size` | optional | float | `0` | Vacuum above slab (Å) | +| `n_steps` | optional | int | `10` | Number of slip increments | +| `add_fix` | optional | list[str] | `["true","true","false"]` | Selective dynamics per axis | + +**cal_setting defaults**: `relax_pos=true`, `relax_shape=false`, `relax_vol=false` + +**Complete working default**: +```json +{ + "type": "gamma", + "plane_miller": [1, 1, 1], + "slip_direction": [-1, 1, 0], + "supercell_size": [1, 1, 5], + "n_steps": 10 +} +``` + +**Output**: Stacking fault energy (mJ/m²) vs displacement fraction. + +⚠️ **CRITICAL CONSTRAINT**: The `slip_direction` **must be a vector ON the slip plane** (dot product with `plane_miller` must equal zero). + +**Canonical slip systems (FCC / BCC / HCP):** use the predefined table in the +APEX repository **README §4.10 Gamma line/surface** — do not invent planes or +directions outside that table without explicit user confirmation. Nested +`fcc` / `bcc` / `hcp` blocks in `param.json` override top-level +`plane_miller` / `slip_direction` for the matching lattice type (see README). + +Quick primary picks when the user has not specified a system (still confirm): + +| Structure | plane_miller | slip_direction | +|---|---|---| +| FCC {111} | `[1,1,1]` | `[-1,1,0]` | +| BCC {110} | `[1,1,0]` | `[-1,1,1]` | +| HCP basal | see README §4.10 (Miller–Bravais OK for `gamma`) | see README §4.10 | + +**INVALID examples** (will produce wrong results or error): +- `[1,1,1]` + `[1,1,0]` → dot product = 2 ≠ 0, **NOT on the plane** +- `[1,1,1]` + `[0,0,1]` → dot product = 1 ≠ 0, **NOT on the plane** + +--- + +## 9. Gamma Surface (2D GSFE Map) + +**type**: `"gamma_surface"` + +| Parameter | Required? | Type | Default | Description | +|-----------|-----------|------|---------|-------------| +| `plane_miller` | **REQUIRED** | list[int] | `[1,1,1]` | Slip plane | +| `slip_direction` | **REQUIRED** | list[int] | `[-1,1,0]` | x-direction of 2D grid (**must lie ON the plane**) | +| `slip_length` | optional | float | `null` | Slip distance in x | +| `slip_length_y` | optional | float | `null` | Slip distance in y | +| `plane_shift` | optional | int/float | `0` | Slip plane shift | +| `supercell_size` | optional | list[int] | `[1,1,5]` | Supercell | +| `vacuum_size` | optional | float | `0` | Vacuum (Å) | +| `closed_loop` | optional | bool | `false` | Derive a periodic, possibly oblique in-plane basis | +| `n_steps_x` | optional | int | `10` | Grid points in x | +| `n_steps_y` | optional | int | `n_steps_x` | Grid points in y | +| `add_fix` | optional | list[str] | `["true","true","false"]` | Selective dynamics | + +**cal_setting defaults**: `relax_pos=true`, `relax_shape=false`, `relax_vol=false` + +**Complete working default**: +```json +{ + "type": "gamma_surface", + "plane_miller": [1, 1, 1], + "slip_direction": [-1, 1, 0], + "supercell_size": [1, 1, 5], + "closed_loop": false, + "n_steps_x": 10, + "n_steps_y": 10 +} +``` + +**Output**: 2D grid of SFE values (J/m²), (n_steps_x+1) × (n_steps_y+1) points. +With `closed_loop=true`, `slip_length` and `slip_length_y` must be omitted. +APEX records the periodic basis vectors and the true Cartesian displacement of +every grid point; use this mode for oblique or disordered supercells. + +⚠️ **Same constraint as gamma**: `slip_direction` must have zero dot product with +`plane_miller`. Canonical FCC/BCC/HCP systems: **README §4.10** (same table as `gamma`). + +**Notes**: +- The y-direction is automatically computed as `plane_miller × slip_direction`. +- Total calculation count = (n_steps_x+1) × (n_steps_y+1) = 121 for 10×10 grid. Expensive with DFT! +- For quick tests, use `"n_steps_x": 5, "n_steps_y": 5` (36 calculations). + +--- + +## 10. Decohesive Energy + +**type**: `"decohesive"` + +| Parameter | Required? | Type | Default | Description | +|-----------|-----------|------|---------|-------------| +| `miller_index` | **REQUIRED** | list[int] | `[1,1,1]` | Specific Miller plane for cleavage (**3-index only**) | +| `min_slab_size` | **REQUIRED** | float | `40` | Minimum slab thickness (Å) | +| `max_vacuum_size` | optional | float | `15` | Maximum vacuum to open (Å) | +| `vacuum_size_step` | optional | float | `1.0` | Vacuum increment (Å) | +| `pert_xz` | optional | float | `0.01` | Symmetry-breaking perturbation | + +**cal_setting defaults**: `relax_pos=false`, `relax_shape=false`, `relax_vol=false` (all static) + +**Complete working default**: +```json +{ + "type": "decohesive", + "miller_index": [1, 1, 1], + "min_slab_size": 40, + "max_vacuum_size": 15, + "vacuum_size_step": 1.0 +} +``` + +**Output**: Work of separation (J/m²) vs vacuum gap distance. + +⚠️ **`miller_index` is REQUIRED.** Without it → `KeyError: 'miller_index'` at runtime. + +**Supported crystal families & recommended planes** (aligned with README §4.5). +Decohesive has **no** auto plane enumeration and **no** `fcc`/`bcc`/`hcp` nested +overrides — detect the lattice, pick from this table, and confirm with the user. + +| Crystal structure | Recommended planes | JSON `miller_index` | +|---|---|---| +| FCC | $(100)$, $(110)$, $(111)$ | `[1,0,0]`, `[1,1,0]`, `[1,1,1]` | +| BCC | $(100)$, $(110)$, $(111)$ | `[1,0,0]`, `[1,1,0]`, `[1,1,1]` | +| Diamond | $(100)$, $(110)$, $(111)$ | `[1,0,0]`, `[1,1,0]`, `[1,1,1]` | +| Zinc blende | $(100)$, $(110)$, $(111)$ | `[1,0,0]`, `[1,1,0]`, `[1,1,1]` | +| Rocksalt | $(100)$, $(110)$, $(111)$ | `[1,0,0]`, `[1,1,0]`, `[1,1,1]` | +| HCP | $(0001)$, $(10\bar{1}0)$, $(11\bar{2}0)$ | `[0,0,1]`, `[1,0,0]`, `[1,1,0]` | +| Perovskite | $(001)$, $(110)$, $(111)$ | `[0,0,1]`, `[1,1,0]`, `[1,1,1]` | + +**Notes**: +- Unlike `surface` (which auto-enumerates all planes), `decohesive` requires a specific plane. +- HCP: use **3-index only** (`[0,0,1]` not `[0,0,0,1]`). Four-index Miller–Bravais fails at slab generation. +- Polar / multi-termination faces (e.g. zinc blende $(111)$) still run; APEX keeps the first pymatgen match. +- Do not silently change an approved `miller_index`. +- 15 vacuum steps at 1 Å spacing = 15 single-point calculations. +- All calculations are fully static (no relaxation) — measuring rigid separation. + +--- + +## 11. Finite-Temperature Lattice + +**type**: `"finite_t_latt"` + +| Parameter | Required? | Type | Default | Description | +|-----------|-----------|------|---------|-------------| +| `supercell_size` | optional | list[int] | `[3,3,3]` | MD supercell | + +**cal_setting keys**: + +| Key | Required? | Type | Default | Description | +|-----|-----------|------|---------|-------------| +| `temperature` | **REQUIRED** | list[float] | `[200,400,600,800]` | Target temperatures (K) | +| `equi_step` | optional | int | `80000` | Equilibration steps | +| `ave_step` | optional | int | `40000` | Averaging steps | +| `timestep` | optional | float | `0.001` | Timestep (ps) | +| `tdamp` | optional | float | `0.1` | Thermostat damping | +| `pdamp` | optional | float | `1.0` | Barostat damping | +| `N_every` | optional | int | `100` | Sample frequency | +| `N_repeat` | optional | int | `10` | Repeat count | +| `N_freq` | optional | int | `2000` | Output frequency | +| `timestep_fs` | DFT optional | float | `1.0` | VASP/ABACUS timestep (fs) | +| `pressure_kbar` | DFT optional | float | `0.0` | VASP/ABACUS target pressure (kbar) | + +**Complete working default**: +```json +{ + "type": "finite_t_latt", + "supercell_size": [3, 3, 3], + "cal_setting": { + "temperature": [200, 400, 600, 800] + } +} +``` + +**Output**: Lattice parameter a (and c/a for non-cubic) vs temperature. + +VASP uses Langevin–Parrinello–Rahman NpT; ABACUS uses Nose–Hoover-style NpT. These integrations share the thermodynamic target but not thermostat/barostat parameters. + +**Notes**: +- Each temperature is a separate NPT MD run. +- [3,3,3] = 108 atoms for FCC — adequate for thermal expansion. +- Temperatures should stay below melting point. For Cu: safe up to ~1200 K. + +--- + +## 12. Finite-Temperature Elastic Constants (LAMMPS only) + +**type**: `"finite_t_elastic"` **[LAMMPS only]** + +> ⚠️ This property requires MD sampling (NPT molecular dynamics with paired Langevin thermostat). It is **incompatible with DFT backends** (VASP/ABACUS) — only LAMMPS with a classical or ML potential can be used. + +| Parameter | Required? | Type | Default | Description | +|-----------|-----------|------|---------|-------------| +| `supercell_size` | optional | list[int] | `[3,3,3]` | MD supercell | + +**cal_setting keys**: + +| Key | Required? | Type | Default | Description | +|-----|-----------|------|---------|-------------| +| `temperature` | **REQUIRED** | list[float] | `[300]` | Target temperatures | +| `strain` | optional | float | `0.001` | Strain magnitude ε | +| `strain_components` | optional | list | `[0,1,2,3,4,5]` | Voigt indices | +| `equi_step` | optional | int | `16000` | Equilibration steps | +| `response_step` | optional | int | `16000` | Response measurement steps | +| `stress_output_every` | optional | int | `100` | Stress output interval | +| `timestep` | optional | float | `0.001` | Timestep (ps) | +| `tdamp` | optional | float | `0.1` | Thermostat damping | +| `pdamp` | optional | float | `1.0` | Barostat damping | +| `seed` | optional | int | `12345` | Random seed | +| `n_blocks` | optional | int | `10` | Block averaging for error bars | +| `method` | optional | str | `"paired_langevin"` | **Only** `"paired_langevin"` supported | + +**Complete working default**: +```json +{ + "type": "finite_t_elastic", + "supercell_size": [3, 3, 3], + "cal_setting": { + "temperature": [300], + "strain": 0.001 + } +} +``` + +**Output**: Elastic tensor Cij at each temperature, with error estimates. + +⚠️ **LAMMPS-only** — paired Langevin method for noise cancellation. + +**Notes**: +- Small strain (0.001) needed for linear response regime. +- Each temperature × strain component = one MD run. 6 components × N temperatures can be expensive. +- [3,3,3] supercell is minimum for finite-T elastic; [4,4,4] for publication quality. + +--- + +## 13. Grüneisen Parameters & Thermal Expansion + +**type**: `"gruneisen"` + +| Parameter | Required? | Type | Default | Description | +|-----------|-----------|------|---------|-------------| +| `supercell_size` | optional | list[int] | `[2,2,2]` | Phonon supercell | +| `MESH` | optional | list[int] | `[20,20,20]` | Three positive reciprocal-space mesh dimensions used for mode summation | +| `volume_strains` | **REQUIRED** | list[float] | `[-0.02,-0.01,0.0,0.01,0.02]` | Must include 0.0, ≥3 points | +| `temperatures` | **REQUIRED** | list[float] | `[100,200,300,400,500]` | Temperature points for evaluation | +| `alpha_mode` | optional | str | `"sign_only"` | `"sign_only"` or `"full"` | + +**Complete working default**: +```json +{ + "type": "gruneisen", + "supercell_size": [2, 2, 2], + "MESH": [20, 20, 20], + "volume_strains": [-0.02, -0.01, 0.0, 0.01, 0.02], + "temperatures": [100, 200, 300, 400, 500], + "alpha_mode": "full" +} +``` + +**Output**: Mode Grüneisen parameters γ_i, thermal expansion coefficient α(T). + +⚠️ **Both `volume_strains` and `temperatures` are REQUIRED.** Missing either → KeyError at runtime. + +**Notes**: +- `volume_strains` MUST contain `0.0` (equilibrium reference) and have ≥3 points for numerical differentiation. +- Phonon calculations are performed at each strained volume → total cost = N_strains × phonon cost. +- 5 volume points is standard for reliable Grüneisen parameter extraction. +- `"alpha_mode": "full"` uses the full phonon spectrum; `"debye"` uses Debye model approximation. + +--- + +## 14. Annealing + +**type**: `"annealing"` + +| Parameter | Required? | Type | Default | Description | +|-----------|-----------|------|---------|-------------| +| `supercell_size` | optional | list[int] | `[3,3,3]` | MD supercell | +| `supercell_length` | optional | float | `null` | Auto-size from target edge length | + +**cal_setting keys (temperature cycle)**: + +| Key | Required? | Type | Default | Description | +|-----|-----------|------|---------|-------------| +| `start_temp` | optional | float | `4` | Initial temperature (K) | +| `target_temp` | optional | float | `300` | Peak temperature (K) | +| `end_temp` | optional | float | `4` | Final temperature (K) | +| `temp_ramp_rate` | optional | float | `1000` | Heating rate (K/ps) | +| `cool_rate` | optional | float | = ramp_rate | Cooling rate (K/ps) | +| `equi_step` | optional | int | `20000` | Initial equilibration steps | +| `hold_step` | optional | int | `20000` | Hold at target_temp steps | +| `timestep` | optional | float | `0.001` | Timestep (ps) | +| `thermostat` | optional | str | `"nose_hoover"` | Thermostat type | +| `ensemble` | optional | str | `"npt"` | Ensemble type | +| `velocity_seed` | optional | int | `123457` | Random seed | +| `timestep_fs` | DFT optional | float | `1.0` | VASP/ABACUS timestep (fs) | +| `pressure_kbar` | DFT optional | float | `0.0` | VASP/ABACUS target pressure (kbar) | + +**cal_setting keys (analysis)**: + +| Key | Required? | Type | Default | Description | +|-----|-----------|------|---------|-------------| +| `req_compute_rdf` | optional | bool | `true` | Compute RDF | +| `rdf_bins` | optional | int | `100` | RDF histogram bins | +| `rdf_cutoff` | optional | float | `6.0` | RDF cutoff (Å) | +| `req_compute_msd` | optional | bool | `true` | Compute MSD | + +**Complete working default**: +```json +{ + "type": "annealing", + "supercell_size": [3, 3, 3], + "cal_setting": { + "start_temp": 4, + "target_temp": 300, + "end_temp": 4, + "temp_ramp_rate": 1000 + } +} +``` + +**Output**: RDF at each stage, MSD, volume-temperature curves, final quenched structure. + +VASP and ABACUS run the same temperature schedule with their native NpT integrators and post-process compact trajectories into the same result schema. + +**Notes**: +- [3,3,3] supercell recommended for statistical sampling (108+ atoms). +- `target_temp=300` is safe; for studying phase transitions, increase to above melting. +- `temp_ramp_rate=1000` K/ps is relatively fast — use 100 K/ps for more physical quenching. + +--- + +## RSS Structure Generation + +Use `apex rss ` to generate input structures. + +It generates random-solid-solution configurations for multi-component alloys, +high-entropy oxides, and related materials. Use the generated +`conf_###/POSCAR` directories as inputs to the property calculations above. + +See `reference/rss_workflow.md` for full details. + +--- + +## Quick Reference: Stable Defaults Summary Table + +| Property | Key Required Params | Safe Default Values | Common Pitfall | +|----------|-------------------|--------------------|----| +| `eos` | vol_start, vol_end, vol_step | 0.8, 1.2, 0.05 | — | +| `cohesive` | latt_start, latt_end, latt_step | 0.8, 1.5, 0.05 | Missing `cal_type` | +| `elastic` | (none beyond type) | norm/shear_deform=0.01 | — | +| `surface` | min_slab_size, min_vacuum_size | 50 Å, 20 Å | Too many surfaces with high max_miller | +| `vacancy` | (none beyond type) | supercell=[3,3,3] | Small supercell → size effects | +| `interstitial` | (none beyond type) | supercell=[3,3,3] | Missing insert_ele for foreign atoms | +| `phonon` | supercell_size | [3,3,3] | ⚠️ [2,2,2] may fail in LAMMPS | +| `gamma` | plane_miller, slip_direction | [1,1,1], [-1,1,0] | ⚠️ slip_dir NOT on plane | +| `gamma_surface` | plane_miller, slip_direction | [1,1,1], [-1,1,0] | ⚠️ slip_dir NOT on plane | +| `decohesive` | miller_index, min_slab_size | [1,1,1], 40 Å | ⚠️ Missing miller_index → KeyError | +| `finite_t_latt` | cal_setting.temperature | [200,400,600,800] | DFT MD is expensive | +| `finite_t_elastic` | cal_setting.temperature | [300] | LAMMPS-only | +| `gruneisen` | volume_strains, temperatures | [-0.02..0.02], [100..500] | ⚠️ Missing temperatures → KeyError | +| `annealing` | (none critical) | target_temp=300 | DFT MD is expensive | + +--- + +## Validation Checklist Before Submission + +Before submitting any APEX property calculation, verify: + +1. ✅ **All REQUIRED parameters present** — check the table above +2. ✅ **Crystallographic constraints satisfied**: + - `gamma` / `gamma_surface`: `dot(plane_miller, slip_direction) == 0`; system from README §4.10 + - `decohesive`: `miller_index` explicitly specified (3-index); plane from README §4.5 table for the crystal family +3. ✅ **Supercell sizes adequate**: + - Vacancy/interstitial: ≥ [2,2,2], prefer [3,3,3] + - Phonon: ≥ [3,3,3] for LAMMPS + - Finite-T properties: ≥ [3,3,3] +4. ✅ **LAMMPS-only properties** not sent to DFT backend: + - `finite_t_elastic` +5. ✅ **Physical reasonableness**: + - Temperatures below melting point + - Volume strains ≤ ±5% (avoid unphysical compression) + - Slab thickness large enough for bulk-like interior diff --git a/apex/skills/apex-flow/reference/rss_workflow.md b/apex/skills/apex-flow/reference/rss_workflow.md new file mode 100644 index 00000000..14352d6e --- /dev/null +++ b/apex/skills/apex-flow/reference/rss_workflow.md @@ -0,0 +1,229 @@ +# APEX RSS (Random Solid Solution) Workflow + +## Overview + +APEX's RSS workflow generates occupationally disordered structures for random +solid solutions, solid solutions, high-entropy alloys (HEA), high-entropy +oxides/ceramics (HEO), and other high-entropy materials. RSS generates +structures locally; it is not one of the 14 APEX properties and does not require +choosing a calculator backend unless the user also asks to calculate properties. + +When the user's request contains any of the material classes above, explicitly +offer or use `apex rss `. Do not substitute the legacy +`scripts/generate_rss.py` interface: the current APEX CLI is JSON-driven. + +## Supported Prototypes + +| Prototype | Atoms/Cell | Description | +|-----------|-----------|-------------| +| `fcc` | 1 | Primitive face-centered cubic | +| `bcc` | 2 | Body-centered cubic | +| `hcp` | 2 | Hexagonal close-packed | +| `sc` | 1 | Simple cubic | +| `tetragonal` | 1 | Simple tetragonal | +| `diamond` | 2 | Primitive diamond cubic | +| `B2` | 2 | CsCl-type ordered | +| `L12` | 4 | Cu₃Au-type ordered (3:1 sublattice) | +| `L10` | 2 | CuAu-type ordered (1:1 sublattice) | + +## Sublattice-Aware Generation + +For ordered phases (B2, L12, L10), APEX respects sublattice occupancy: + +### B2 Structure +- **`corner`**: corner-site composition +- **`body`**: body-center composition +- Example: (TiZr)(NiCu) — Ti/Zr on A-sites, Ni/Cu on B-sites + +### L12 Structure +- **`corner`**: corner-site composition +- **`face`**: face-center composition (3 sites per cell) +- Example: (CoCrFe)₃(Al) — Co/Cr/Fe on A-sites, Al on B-sites + +### L10 Structure +- **`layer_A`**: first alternating layer +- **`layer_B`**: second alternating layer +- Example: (FeNi)(PtPd) — Fe/Ni on A-sites, Pt/Pd on B-sites + +## Required User QA + +Do not invent RSS chemistry or sublattices. Ask the following in small batches +(normally one or two questions per turn), reusing information already supplied: + +1. **Parent structure**: use an existing POSCAR/CIF (`parent_structure`) or + build a prototype (`parent_lattice.type`)? For a built prototype, ask for + `fcc`, `bcc`, `hcp`, `sc`, `tetragonal`, `diamond`, `B2`, `L12`, or `L10`, + and ask whether lattice constants should be explicit or `"auto"`. +2. **Sublattice compositions**: ask which species and fractions occupy each + sublattice. Every composition block must sum to `1.0`. A single disordered + lattice uses `{"all": {...}}`; HEOs normally need separate cation/anion (or + A/B/O) blocks. Ask explicitly if vacancies are intended. +3. **Cell sizing**: ask whether to use an explicit supercell or automatic + sizing. For automatic sizing, present defaults + `composition_tolerance=0.005`, `supercell_shape="near_cubic"`, and an atom + budget (`maximum_num_atoms`) for approval. Warn that top-level `supercell` + and `parent_lattice.supercell` are both applied if both are present. +4. **Order and sampling**: ask whether the target is random SRO + (`sro_targets` omitted, target zero) or specified Warren-Cowley values. Then + confirm `num_configs` (default 1), random `seed`, and output directory. + +If the user supplies an ordered B2/L12/L10 parent, confirm which species belong +to each crystallographic sublattice. For an arbitrary parent file, use explicit +`sublattices.site_indices` when automatic assignment cannot be proven. + +## Current CLI and `rss.json` + +Run: + +```bash +apex rss path/to/rss.json +``` + +The JSON must contain `compositions` and exactly one parent source: + +- `parent_structure`: structure path relative to `rss.json`; or +- `parent_lattice`: object with `type`, optional `element`/`species`, `a`, + optional `c`, and optional `supercell`. + +Core optional keys are: + +| Key | Default | Purpose | +|-----|---------|---------| +| `composition_tolerance` | `0.005` | Accuracy target for automatic cell sizing | +| `supercell_shape` | `"near_cubic"` | Or `"xy_equal_z_free"` | +| `maximum_num_atoms` | unset | Atom budget; old misspelled aliases are accepted but do not generate them | +| `supercell` | unset | Additional expansion after loading/building the parent | +| `sublattices` | auto/none | Explicit `{name, site_indices}` mappings | +| `sro_targets` | zero/random | Per-shell pair targets such as `"Co-Ni": -0.1` | +| `shell_cutoffs` | inferred first shell | Positive ascending neighbor cutoffs | +| `shell_weights` | all `1.0` | SRO objective weights | +| `max_steps` | `20000` | Monte Carlo attempts | +| `temperature` | `0.05` | Metropolis temperature | +| `tol` | `1e-3` | Numerical/target tolerance | +| `allow_vacancies` | `false` | Permit vacancy aliases, normalized to `X` | +| `num_configs` | `1` | Number of unique structures | +| `interval` | `100` | Candidate-cache update interval | +| `show_progress` | TTY→`true`, else `false` | tqdm Monte Carlo bar; **agents must set `false`** | +| `seed` | unset | Reproducible random seed | +| `metadata` | `true` | Write `rss_metadata.json` | +| `output_structure` | `"RSS"` | Output root relative to `rss.json` | + +### Agent / non-interactive runs (tqdm) + +`show_progress` defaults to `true` only when stdout is an interactive TTY. +Captured agent/CI runs default to off. **Still set `"show_progress": false` +explicitly in every agent-written `rss.json`** so tqdm never fills the +transcript (each bar refresh is a new log line; `max_steps=20000` is huge). + +Do not diagnose RSS by “watching tqdm.” After `apex rss`: + +1. List `output_structure/conf_*/POSCAR` (expect `num_configs` files). +2. Read `rss_metadata.json` for RMSE / composition / duplicate warnings. +3. If nothing was written: adjust JSON (`max_steps` ≥ a few×`interval` when + `num_configs > 1`, cell size, compositions) and re-run `apex rss` — do not + bypass the CLI with a direct Python `generate_rss(...)` call unless the user + asks for a code-level debug. + +Minimal single-sublattice example: + +```json +{ + "parent_lattice": { + "type": "fcc", + "a": "auto", + "supercell": "auto" + }, + "compositions": { + "all": {"Co": 0.2, "Cr": 0.2, "Fe": 0.2, "Mn": 0.2, "Ni": 0.2} + }, + "composition_tolerance": 0.005, + "maximum_num_atoms": 200, + "num_configs": 5, + "seed": 21, + "show_progress": false, + "output_structure": "RSS_HEA" +} +``` + +## Workflow Integration + +### Step 1: Generate RSS structures +```bash +# Done locally; no Bohrium job is needed. +apex rss rss.json +``` + +### Step 2: Use in property calculation +```json +{ + "structures": ["RSS_HEA/conf_*"], + "interaction": { ... }, + "properties": [ ... ] +} +``` + +Each matched directory contains a `POSCAR`. + +## Output Structure + +Generated structures are saved as APEX-ready directories: +``` +RSS_HEA/ +├── conf_001/POSCAR +├── conf_002/POSCAR +└── rss_metadata.json +``` + +After generation, verify every POSCAR's atom count, realized composition, +lattice, and duplicate status. Read `rss_metadata.json` and report convergence +or composition warnings instead of describing every generated structure as +valid automatically. + +## Visualize Before Handoff + +Always give the user a visual check of at least the first generated structure. +Use the environment's supported structure/image artifact viewer first. In this +repository, the reliable headless fallback is the same ASE plotting stack used +by `apex/preview.py`: read the POSCAR with ASE, render it with +`ase.visualize.plot.plot_atoms`, save a PNG, and present that image artifact. +`apex preview` itself expects a property parameter file and is not an RSS +preview command. + +Example static fallback: + +```python +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +from ase.io import read +from ase.visualize.plot import plot_atoms + +atoms = read("RSS_HEA/conf_001/POSCAR", format="vasp") +fig, ax = plt.subplots(figsize=(7, 7)) +plot_atoms(atoms, ax, rotation="10x,20y,0z", show_unit_cell=2) +ax.set_axis_off() +fig.savefig("RSS_HEA/rss_preview.png", dpi=180, bbox_inches="tight") +``` + +If no supported static/artifact visualization works and a desktop display is +available, attempt `ase.visualize.view(atoms)` (ASE view). In a headless +environment, report the display limitation and still return the POSCAR/CIF and +metadata; do not claim that an unseen GUI opened successfully. + +## Best Practices + +1. **Supercell size**: Choose a cell that realizes the requested fractions + within tolerance. Do not blindly apply a second 3×3×3 expansion to a parent + that is already a supercell. + +2. **Number of configurations**: Generate 5-10 for property averaging when the + cost is acceptable; use a fixed seed for reproducibility. + +3. **Lattice constant**: Treat `"auto"` as an estimate. Prefer an experimental + or relaxed DFT value when quantitative fidelity matters. + +4. **B2/L12/L10 and HEOs**: Confirm sublattice chemistry and mapping. Random + mixing across unintended sublattices changes the physical system. + +5. **Large systems**: For subsequent calculations on large RSS cells, present + LAMMPS+MLIP as the practical option before proposing DFT. diff --git a/apex/skills/apex-flow/reference/submission.md b/apex/skills/apex-flow/reference/submission.md new file mode 100644 index 00000000..36cf3fe8 --- /dev/null +++ b/apex/skills/apex-flow/reference/submission.md @@ -0,0 +1,260 @@ +# APEX Submission Reference + +## Execution Architecture (Two-Layer Model) + +APEX uses a **two-layer submission architecture**: + +1. **Outer layer — Bohrium job**: A thin submission client running the APEX image. Its sole purpose is to call `apex submit` which connects to the dflow orchestration server. +2. **Inner layer — dflow/Argo orchestration**: The actual LAMMPS/ABACUS/VASP calculations run in **separate containers** orchestrated by `workflows.deepmodeling.com`. dflow handles task parallelism, retries, and artifact collection. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ MatMaster (local) │ +│ 1. Generate param.json + global.json + structure files │ +│ 2. Submit outer Bohrium job │ +└──────────────────────────┬──────────────────────────────────────┘ + │ Bohrium submit + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Outer Bohrium Job (APEX image, c1_m2_cpu) │ +│ 1. pip install --upgrade --no-cache-dir apex-flow │ +│ 2. apex submit param.json -c global.json -n "" │ +│ → Connects to workflows.deepmodeling.com │ +│ → Waits for completion and retrieves results automatically │ +│ → upload_packages sends new APEX code to all inner steps │ +└──────────────────────────┬──────────────────────────────────────┘ + │ dflow orchestration + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Inner containers (managed by dflow on Bohrium) │ +│ - LAMMPS image: deepmd-kit-phonolammps:3.1.3 │ +│ - ABACUS image: registry.dp.tech/dptech/abacus:3.2.3 │ +│ - VASP image: (user-provided) │ +│ - Machine type per task: scass_type in global.json │ +│ - APEX code: received via upload_packages (always latest) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Authentication: access_key → ticket Conversion (CRITICAL) + +APEX/dflow authentication requires a **ticket** (UUID), not the raw Bohrium access_key. The `generate_config.py` script handles this conversion automatically: + +``` +GET https://openapi.dp.tech/openapi/v1/ticket/get?accessKey= +Header: x-app-key: (can be empty string) +Response: {"code": 0, "data": {"ticket": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"}} +``` + +The ticket goes into `global.json` as `bohrium_config.ticket`. Generate it before +the job directory is packaged: + +1. Inspect the agent/local environment for `BOHRIUM_ACCESS_KEY`. +2. If the variable is missing, stop and ask the user to provide/configure it. + `generate_config.py` cannot obtain a ticket without an access key. +3. For a new task, run `scripts/generate_config.py create ...`; never hand-write + `param.json` or `global.json`. +4. For an existing generated task, `cd` into its directory and run + `python /scripts/generate_config.py refresh-global --global global.json`. + This refreshes the ticket and integer project IDs atomically without changing + `param.json`. +5. Verify that `global.json` contains a non-empty `bohrium_config.ticket`. + +> ⚠️ **Ticket 有效期约 1 周,会过期!** Use `refresh-global` when preparing +> an existing generated task for a new submission. Do not refresh the +> ticket in `run.sh`: the outer APEX container may not receive +> `BOHRIUM_ACCESS_KEY`, and ad-hoc response parsing there has no reliable error +> handling. + +> **WARNING**: The log may show `WARNING:root:Missing Bohrium account fields: email, password.` — this is **non-fatal**; ticket-based auth works without email/password. + +## Outer Job run.sh Template + +```bash +#!/bin/bash +set -eo pipefail + +python3 -m pip install --upgrade --no-cache-dir apex-flow 2>&1 | tail -5 +python3 -c "import apex; print(f'APEX version: {apex.__version__}')" + +# Authentication is already stored in global.json by generate_config.py. +# Do not read BOHRIUM_ACCESS_KEY or refresh the ticket in this container. +set +eo pipefail +apex submit param.json -c global.json -n "" 2>&1 | tee apex_submit.log +EXIT_CODE=${PIPESTATUS[0]} +set -eo pipefail + +if [ $EXIT_CODE -eq 0 ]; then + echo "=== APEX workflow completed and results retrieved ===" + echo "Retain apex_submit.log and the workflow ID." + exit 0 +else + echo "APEX failed (exit $EXIT_CODE)" + tail -50 apex_submit.log 2>/dev/null || true + exit 1 +fi +``` + +**Key points:** +- Must use `pip install --upgrade --no-cache-dir apex-flow` (no `==` pin). +- The installed APEX code is automatically sent to inner dflow steps via `upload_packages` +- `run.sh` does not access `BOHRIUM_ACCESS_KEY` or modify `global.json` +- **单次执行,不重试** — 失败即退出,由用户决定是否重新提交 +- `-n` flag sets workflow name (NOT `-w` which is work directory) +- Do not use `-s` for agent-managed runs. The outer job must wait for dflow to + finish so `apex submit` retrieves results automatically + +## RFC 1123 Workflow Name Constraint (CRITICAL) + +dflow validates workflow names against RFC 1123 subdomain regex. Names like `"Cu-FCC-elastic"` will **FAIL**. The name must: +- Be all lowercase +- Contain only `[a-z0-9-]` +- Not start/end with `-` +- Max 63 characters + +**Always auto-lowercase** any user-provided name: `"Cu-FCC-elastic"` → `"cu-fcc-elastic"`. + +## Bohrium Image & Machine Defaults + +| Role | Image | Notes | +|------|-------|-------| +| **Outer job (submission client)** | `registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post` | Lightweight; just runs `apex submit` | +| **LAMMPS calculator** | `registry.dp.tech/dptech/dp/native/prod-397637/deepmd-kit-phonolammps:3.1.3` | Default; includes phonoLAMMPS | +| **ABACUS calculator** | (same APEX image has ABACUS) | Or user-specified | +| **VASP calculator** | User must provide after confirming license | Commercial; **never invent a default image** | + +> ⚠️ **Do NOT combine `deepmd-kit:3.1.1` with any NVIDIA T4 machine**. It also has a known segfault bug when handling triclinic cells (non-orthogonal boxes), including on CPU. Use `3.1.3` or later. + +> LAMMPS phonon and Grüneisen tasks are forced to use `registry.dp.tech/dptech/dp/native/prod-397637/deepmd-kit-phonolammps:3.1.3`, which includes the required phonoLAMMPS executable. + +| Backend | scass_type (inner containers) | Notes | +|---------|-------------------------------|-------| +| LAMMPS (DeePMD/MACE/NEP) | `c8_m31_1 * NVIDIA T4` | GPU beneficial | +| LAMMPS (EAM/MEAM/SNAP) | `c16_m32_cpu` | CPU sufficient | +| ABACUS | `c16_m32_cpu` | CPU | +| VASP | `c32_m128_cpu` (default) | Align `mpirun -n ` with CPU count | + +### VASP image resolution + `vasp_run_command` (license-gated) + +**Image resolution (mandatory before VASP submit):** + +1. Query the user's **private** Bohrium images with keyword `vasp`: + - MatMaster tool: `Bohrium(action="list_images", keyword="vasp")` + (*list the user's own private Docker images (filtered by keyword)*) + - Or skill helper: `python scripts/list_bohrium_images.py --keyword vasp --require` +2. Else use a **user-known authorized** VASP image address. +3. If neither exists → **stop**. Do not invent `vasp:5.4.4-dflow` or any default. +4. Pass the approved image as `--vasp-image` to `generate_config.py create`. + +**Run command:** do **not** use bare `mpirun -n 16 vasp_std`. Typical +Bohrium-safe command (adjust binary path to the user-approved image): + +```text +bash -c "source /opt/intel/oneapi/setvars.sh && ulimit -s unlimited && mpirun -n 32 /opt/vasp.5.4.4/bin/vasp_std" +``` + +Must include: Intel `setvars.sh`, `ulimit -s unlimited`, absolute `vasp_std`, +and `-n` matching `scass_type`. See `reference/calculators.md` and Critical +Rules in `SKILL.md`. + +### DFT k-spacing (REQUIRED) + +- VASP: `INCAR` must set `KSPACING` (APEX auto-writes `KPOINTS`). +- ABACUS: `INPUT` must set `kspacing`, or `cal_setting.K_POINTS`. +- `validate_inputs.py` rejects missing spacing / bare VASP run commands. + +> **IMPORTANT**: Use the minimal `c1_m2_cpu` machine for the outer Bohrium job since it only submits to dflow and waits. The heavy compute is in the inner containers specified by `scass_type` in `global.json`. + +## Validated global.json Structure + +`program_id` and `bohrium_config.project_id` **must** come from the environment +variable `BOHRIUM_PROJECT_ID` (or `--project-id`). Never hardcode a personal +project ID in examples or generated configs. **Do not write `global.json` +manually and do not copy the placeholders below into a real file.** Run +`scripts/generate_config.py create ...` or `scripts/generate_config.py +refresh-global --global global.json`; both convert the environment string to an +integer and write both ID fields as JSON numbers. + +The following is a type-annotated shape, not valid JSON: + +```text +{ + "dflow_host": "https://workflows.deepmodeling.com", + "k8s_api_server": "https://workflows.deepmodeling.com", + "batch_type": "Bohrium", + "context_type": "Bohrium", + "program_id": , + "bohrium_config": { + "ticket": "", + "project_id": + }, + "apex_image_name": "registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post", + "lammps_image_name": "registry.dp.tech/dptech/dp/native/prod-397637/deepmd-kit-phonolammps:3.1.3", + "lammps_run_command": "lmp -in in.lammps", + "scass_type": "c16_m32_cpu", + "group_size": 1, + "pool_size": 1 +} +``` + +Quoted digits such as `"program_id": "..."` are invalid for DPDispatcher even +though they look numeric. From the task directory, always run: + +```bash +cd +python /scripts/validate_inputs.py \ + --param param.json --global global.json +``` + +Do not upload or submit unless validation reports `Validation PASSED` and both +project ID lines report `type=int`. Submit the newly validated directory as a +new outer Bohrium job; retrying an old outer job reuses its old input snapshot. + +> For GPU potentials (DeePMD, MACE, NEP), change `scass_type` to `"c8_m31_1 * NVIDIA T4"`. +> Before submitting, run `scripts/validate_apex_combo.py check` on the chosen image × scass_type. + +## Agent-Managed Submission Workflow (Complete Lifecycle) + +The default agent workflow omits `-s` so the outer job waits for the inner dflow +workflow and retrieves its results automatically: + +1. **Prepare inputs locally** (Bash): run `scripts/generate_config.py create ...` + to generate `global.json` + `param.json` + copy structure/model files into a + job directory. Never hand-write these files. If credentials must be refreshed + later, run `refresh-global --global global.json` from that job directory. + +2. **Submit outer Bohrium job** (blocking mode): + ```python + Bohrium(action="submit", + input_dir="", + image="registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post", + machine="c1_m2_cpu", + cmd='apex submit param.json -c global.json -f joint -n "cu-fcc-elastic" > log 2>&1') + ``` + Parse and retain the workflow ID printed in the submission log. The outer + Bohrium job remains active until the inner dflow workflow completes. + +3. **Monitor the inner workflow by ID** using APEX workflow query commands while + the outer job remains active. + +4. **Verify automatic retrieval after the inner workflow finishes**: + - **If Finished**: verify retrieved results, then parse `confs/*/elastic_00/result.json` + (or equivalent property results) → summarize and present to user. + - **If Failed**: read `log` and any + `dpdispatcher.log` → analyze the failure and report suggested fixes. + +5. **Result parsing**: After retrieval, read the property result files: + - Elastic: `confs//elastic_00/result.json` → contains `elastic_tensor`, `B`, `G`, `E`, `u` + - EOS: `confs//eos_00/result.json` → contains `volume`, `energy`, fitted EOS params + - Surface: `confs//surface_00/result.json` → surface energies per miller index + - Other properties follow the same pattern: `confs//_00/result.json` + +6. **Present results**: Summarize in a table with physical units (GPa for elastic, J/m² for surface, eV for formation energies). Compare with literature values when available. + +> **Agent responsibility**: Never add `-s` to the submission command. A +> successful outer job means the inner workflow completed and APEX performed +> automatic retrieval; verify the expected result files before reporting success. + +## Expected Log Warnings (Non-Fatal) + +- `WARNING:root:Missing Bohrium account fields: email, password.` — Normal with ticket auth +- `WARNING:root:Skip copying relaxation for confs/std-fcc: .../relaxation not found.` — Normal for first-time joint workflow (relaxation hasn't produced output yet when props phase starts scanning) diff --git a/apex/skills/apex-flow/reference/workflow-control.md b/apex/skills/apex-flow/reference/workflow-control.md new file mode 100644 index 00000000..22258999 --- /dev/null +++ b/apex/skills/apex-flow/reference/workflow-control.md @@ -0,0 +1,231 @@ +# APEX Workflow Control Reference + +## Key Command + +```bash +apex submit param.json -c global.json -n "" +``` + +| Flag | Meaning | +|------|---------| +| `-c global.json` | Workflow/machine configuration | +| `-f joint\|props\|relax` | Flow type: `joint` (relax+props), `props` (props only), `relax` (relax only) | +| `-n ""` | Workflow name — **MUST be RFC 1123 lowercase** (see `submission.md`) | + +Do not use `-s`; agent-managed submissions must wait for completion and automatic +result retrieval. + +## Checking a Running Job (Required Format) + +Do not decide that an existing job matches the user's request from its name. +Work from the outer job's submitted input directory and log. + +### 1. Identify the inner workflow + +1. Read the outer job log and capture the exact line: + `Workflow has been submitted (ID: , UID: )`. +2. Keep the outer Bohrium job ID, inner workflow ID, and inner workflow UID as + three separate values. +3. From the submitted job directory, run: + + ```bash + apex get -i -c global.json + apex getsteps -c global.json + ``` + + `getsteps` takes the workflow ID as its positional argument. Do not write + `apex getsteps -i `. +4. Confirm that `apex get` reports `Running` or `Pending` before describing the + workflow as running. + +### 2. Count relaxation and property tasks + +In the `getsteps` output, classify only the top-level task rows: + +- keys beginning with `relaxcal-` are per-structure relaxation tasks; +- keys beginning with `propertycal-` are per-structure, per-property tasks; +- `Succeeded` counts as successful; +- `Failed` or `Error` counts as failed; +- `Pending` or `Running` counts as unfinished. + +Report unexpected terminal phases separately instead of silently counting them +as successful. Verify: + +```text +property_success + property_failed + property_unfinished = property_total +``` + +For one structure requesting all properties, `property_total` is 14. For +multiple structures, these are property-task counts, normally +`number of structures × number of requested properties`, less explicitly +skipped tasks. + +The outer log text `relax N, props M` reports counts still being monitored: + +- `relax 0` means no relaxation task remains; it does **not** mean relaxation + has not started or has not finished. +- `props 1` means one property task remains unfinished. +- These two counters alone do not reveal how many tasks failed, because failed + tasks are removed from the remaining list. Always use the top-level step + phases to count successes and failures. + +### 3. Give the live Argo location + +For a Bohrium workflow that is confirmed `Running` or `Pending`, report: + +```text +https://workflows.deepmodeling.com/workflows/argo/ +``` + +`argo` is the managed Bohrium workflow namespace. Use the workflow **ID/name**, +not the UID, in this live route. Never give an `/archived-workflows/...` URL for +a running task. Also print the workflow ID and UID as plain text so the user can +verify the target. + +### 4. Verify the submitted material + +Read `param.json`, resolve its `structures` entry inside the submitted job +directory, and inspect the actual submitted POSCAR/STRU. Verify formula, atom +count, lattice lengths, lattice angles, and—when needed—space group. A matching +outer job name such as `srtio3-*` is not evidence that the correct structure was +submitted. + +For the SrTiO3 case, the expected input is: + +```text +SrTiO3; cubic perovskite; 5 atoms; a = b = c = 3.9316 Å; +alpha = beta = gamma = 90 degrees +``` + +Report it as `SrTiO₃(立方钙钛矿,a=3.9316 Å,5 atoms)` only after the submitted +file passes these checks. If it does not, report the observed structure and do +not treat the existing job as a match. + +### 5. User-facing status template + +Use this exact field order: + +```text +作业名称: +材料: +当前阶段:relax , props (剩余弛豫 ;剩余性质任务 ) +性质进度:成功 ,失败 ,未完成 ,总计 +Argo workflow:https://workflows.deepmodeling.com/workflows/argo/ +Workflow ID: +Workflow UID: +``` + +Example for the single-structure SrTiO3 all-properties run: + +```text +作业名称:apex-srtio3-all-14props +材料:SrTiO₃(立方钙钛矿,a=3.9316 Å,5 atoms;已核对提交的 POSCAR) +当前阶段:relax 0, props 1(剩余弛豫 0;剩余性质任务 1) +性质进度:成功 <以 propertycal-* 的 Succeeded 数为准>,失败 <以 Failed/Error 数为准>,未完成 1,总计 14 +Argo workflow:https://workflows.deepmodeling.com/workflows/argo/ +Workflow ID: +Workflow UID: +``` + +Do not infer `成功 13,失败 0` merely from `props 1`; query the phases first. + +## Stopping/Killing a Running APEX Workflow (CRITICAL) + +When the user wants to stop/kill an APEX job, **you MUST terminate the inner dflow/Argo workflow FIRST**, then kill the outer Bohrium node. If you only kill the Bohrium node, the dflow workflow on `workflows.deepmodeling.com` continues running and consuming resources silently. + +### Correct Kill Procedure + +1. **Find the workflow ID** — from the outer job's log (look for `Workflow has been submitted (ID: , UID: )`) +2. **Terminate the Argo workflow** — run inside the Bohrium node: + ```bash + apex terminate -i -c global.json + ``` +3. **Then kill the Bohrium node** — via `Bohrium(action="kill", job_id=...)` + +### Available APEX Workflow Control Commands + +All commands require `-c global.json`: + +| Command | Effect | +|---------|--------| +| `apex stop -i ` | Gracefully stop (lets running steps finish, then stops) | +| `apex terminate -i ` | Immediately terminate all running steps | +| `apex delete -i ` | Delete the workflow record from Argo | +| `apex suspend -i ` | Pause the workflow (can resume later) | +| `apex retrieve -i ` | Retrieve results from a completed/stopped workflow | + +> ⚠️ **Never just kill the Bohrium job without terminating the inner workflow.** The outer job is just a submission client; the actual compute happens in dflow containers that will keep running independently. + +## Pre-Submission Structure Validation (MANDATORY) + +Before generating param.json, **always inspect the input structure** to determine +cell type, atom count, and whether further expansion is needed. APEX accepts +primitive cells, conventional cells, and user-provided supercells; a +conventional cell is not required. + +1. **Read the STRU/POSCAR** — atom count, species, lattice vectors / lengths +2. **Classify the input** — primitive, conventional, or an already-expanded supercell +3. **Preserve the input cell** — do not standardize or reduce it unless the user asks +4. **Decide whether the cell is large enough for the requested properties** + (see size guidance below). Do **not** blindly apply default `supercell` / + `supercell_size` values from templates. +5. **If already a supercell / already large enough**, ask whether to expand again +6. **If no further expansion is requested**, set applicable bulk-property + `supercell` / `supercell_size` values to `[1,1,1]`; do not reapply unit-cell defaults +7. **Set or omit PRIMITIVE_AXES based on cell type** (see `calculators.md`) +8. **Verify consistency** — ensure param.json parameters match the actual structure + +### Size guidance: when to recommend expansion + +| Property family | Typical undersized input | Recommended total size (after expansion) | +| --- | --- | --- | +| vacancy / interstitial | 1–4 atom unit cell | ≳ conventional `[2,2,2]` equivalent | +| phonon / gruneisen / finite-T | unit cell or tiny supercell | ≳ `[3,3,3]` (smaller often fails) | +| elastic / EOS / cohesive | unit cell is usually OK | expand only if user wants finite-size check | +| surface / gamma / decohesive | very small in-plane cell | thicken with `min_slab_size`; confirm in-plane replication separately — **not** bulk `supercell` | + +Rules of thumb: + +- Primitive metal cell (1–2 atoms) for defect/phonon/finite-T → **recommend expand**. +- Conventional FCC/BCC (4 / 2 atoms) for phonon → still recommend `[3,3,3]` unless the user already supplied a large supercell. +- User supercell with tens–hundreds of atoms that already meets the table → prefer `[1,1,1]` after confirmation; do not expand twice. +- Expanding a tiny cell can also make slab construction more robust for tilted surfaces (e.g. bcc (110)), but it is **not** a substitute for correct APEX slab/handedness handling. + +### Helper words (AskQuestion / confirmation) + +Present a short structure summary, then ask in plain language. Example: + +``` +当前结构:Mo BCC 原胞,2 atoms,a≈3.16 Å(看起来是原胞,不是超胞)。 + +计划性质:decohesive (110)、phonon。 +- decohesive 主要靠 slab 厚度 / 面内尺寸控制,不是 bulk supercell。 +- phonon 建议总尺寸达到约 [3,3,3];当前原子数偏少,建议先扩包。 + +请确认: +1) 保持原胞提交(phonon 可能不稳定) +2) 扩到 [2,2,2] / [3,3,3] 后再算 +3) 你已有更大超胞,请提供新结构,并把 supercell 设为 [1,1,1] +``` + +If the input is already large: + +``` +当前结构:Cu FCC 超胞,108 atoms(已是约 3×3×3)。 +缺陷/声子类参数将默认设为 supercell=[1,1,1],避免二次扩包。 +是否还要再扩一层? +``` + +### Example Check Logic + +- FCC 1 atom + non-orthogonal vectors → primitive → NO `PRIMITIVE_AXES`; for phonon/defect recommend expand +- FCC 4 atoms + cubic vectors → conventional → SET `PRIMITIVE_AXES`; phonon still usually needs `[3,3,3]` +- User-provided 108-atom FCC supercell + no further expansion → preserve it and + set applicable `supercell` / `supercell_size` to `[1,1,1]` +- When in doubt → OMIT `PRIMITIVE_AXES` (safer default) and ask about expansion + +> ⚠️ Getting this wrong causes phonopy `RuntimeError: Remapping of atoms by TrimmedCell failed` at Post step — after all expensive DFT calculations have already completed. + +For surface, gamma, and decohesive properties, slab thickness and in-plane +replication define the requested geometry. Confirm those values separately; +do not silently replace them with `[1,1,1]`. diff --git a/apex/skills/apex-flow/scripts/fetch_models.py b/apex/skills/apex-flow/scripts/fetch_models.py new file mode 100644 index 00000000..7b06b295 --- /dev/null +++ b/apex/skills/apex-flow/scripts/fetch_models.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +""" +Optional helper to download the DPA-3.2-5M source checkpoint. + +The skill itself ships the ready-to-run frozen model: + models/DPA-3.2-5M/DPA-3.2-5M-OMat24.pth + +The multi-head source ``.pt`` is NOT bundled and is NOT downloaded by +``apex skill --zip``. Fetch it only when the user explicitly needs another +task head. + +Usage: + python fetch_models.py # show model location + python fetch_models.py --source-checkpoint # optional ~62MB source .pt + python fetch_models.py --source-checkpoint --force +""" + +from __future__ import annotations + +import argparse +import sys +import urllib.request +from pathlib import Path + +MODELS_ROOT = Path(__file__).resolve().parents[1] / "models" + +SOURCE_CHECKPOINT = ( + "DPA-3.2-5M", + "DPA-3.2-5M.pt", + "https://huggingface.co/deepmodelingcommunity/DPA-3.2-5M/resolve/main/DPA-3.2-5M.pt", +) +FROZEN_MODEL = MODELS_ROOT / "DPA-3.2-5M" / "DPA-3.2-5M-OMat24.pth" + + +def _download(url: str, dest: Path, force: bool = False) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + if dest.is_file() and dest.stat().st_size > 1_000_000 and not force: + print(f"SKIP {dest} ({dest.stat().st_size // (1024 * 1024)} MB)") + return + partial = dest.with_suffix(dest.suffix + ".partial") + print(f"GET {url}") + print(f" -> {dest}") + urllib.request.urlretrieve(url, partial) + partial.replace(dest) + print(f"OK {dest} ({dest.stat().st_size // (1024 * 1024)} MB)") + + +def main(argv: list[str] | None = None) -> int: + # Important: default to [] so runpy / accidental sys.argv inheritance + # (e.g. from `apex skill --zip`) cannot break this script. + if argv is None: + argv = sys.argv[1:] + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--source-checkpoint", + action="store_true", + help="Download DPA-3.2-5M.pt (~62MB) to freeze a different task head", + ) + parser.add_argument("--force", action="store_true", help="Re-download even if present") + args = parser.parse_args(argv) + + MODELS_ROOT.mkdir(parents=True, exist_ok=True) + if args.source_checkpoint: + _download( + SOURCE_CHECKPOINT[2], + MODELS_ROOT / SOURCE_CHECKPOINT[0] / SOURCE_CHECKPOINT[1], + force=args.force, + ) + else: + print("No source checkpoint requested; use --source-checkpoint if needed.") + + print(f"Bundled frozen model: {FROZEN_MODEL}") + print(f"Models root: {MODELS_ROOT}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/apex/skills/apex-flow/scripts/generate_config.py b/apex/skills/apex-flow/scripts/generate_config.py new file mode 100644 index 00000000..a63a42c4 --- /dev/null +++ b/apex/skills/apex-flow/scripts/generate_config.py @@ -0,0 +1,1222 @@ +#!/usr/bin/env python3 +""" +APEX Configuration Generator + +Generates global.json and param.json for APEX workflows. +Handles access_key → ticket conversion and RFC 1123 name compliance. + +Environment variables: + BOHRIUM_ACCESS_KEY — Bohrium access key (converted to ticket via API) + BOHRIUM_PROJECT_ID — Bohrium project ID (required unless --project-id is set) + +Usage: + python generate_config.py create \ + --structure POSCAR \ + --backend lammps \ + --potential eam_alloy \ + --model Cu01.eam.alloy \ + --properties elastic \ + --flow-type joint \ + --workflow-name "cu-fcc-elastic" \ + --output-dir ./job + + # Multiple structures (repeat --structure and/or pass --structure-dir) + python generate_config.py create \ + --structure pristine.vasp Ti_hcp.vasp V_bcc.vasp \ + --structure-dir ./defects/ \ + --backend lammps --potential deepmd --model DPA.pth \ + --properties elastic --flow-type relax \ + --output-dir ./job + + python generate_config.py refresh-global --global ./job/global.json +""" + +import argparse +import json +import os +import re +import shutil +import sys +from pathlib import Path +from urllib.request import Request, urlopen +from urllib.error import URLError, HTTPError + + +# ============================================================================= +# Constants +# ============================================================================= + +TICKET_API_URL = "https://openapi.dp.tech/openapi/v1/ticket/get" +DFLOW_HOST = "https://workflows.deepmodeling.com" +APEX_IMAGE = "registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post" +LAMMPS_IMAGE = ( + "registry.dp.tech/dptech/dp/native/prod-397637/" + "deepmd-kit-phonolammps:3.1.3" +) +# Recommended Bohrium VASP run command pieces (Intel oneAPI + absolute vasp_std). +# Do NOT auto-set vasp_image_name — VASP is commercial; only set an image after +# the user confirms they have a license and provides/approves the image. +# Bare `mpirun -n N vasp_std` fails in typical Bohrium VASP images. +VASP_BIN = "/opt/vasp.5.4.4/bin/vasp_std" + +# Default parameters for each property type +# These are TESTED stable defaults that prevent KeyError failures. +# All required parameters are included. Crystallographic constraints are satisfied. +# Defaults are physically reasonable for typical FCC/BCC metals. +PROPERTY_DEFAULTS = { + "eos": { + "type": "eos", + "vol_start": 0.8, + "vol_end": 1.2, + "vol_step": 0.05, + }, + "cohesive": { + "type": "cohesive", + "latt_start": 0.8, + "latt_end": 1.5, + "latt_step": 0.05, + "cal_type": "static", + }, + "elastic": { + "type": "elastic", + "norm_deform": 0.01, + "shear_deform": 0.01, + }, + "surface": { + "type": "surface", + "min_slab_size": 50, + "min_vacuum_size": 20, + "max_miller": 2, + }, + "vacancy": { + "type": "vacancy", + "supercell": [3, 3, 3], + }, + "interstitial": { + "type": "interstitial", + "supercell": [3, 3, 3], + }, + "phonon": { + "type": "phonon", + "supercell_size": [3, 3, 3], + "BAND_POINTS": 51, + }, + "gamma": { + "type": "gamma", + "plane_miller": [1, 1, 1], + "slip_direction": [-1, 1, 0], + "supercell_size": [1, 1, 5], + "n_steps": 10, + }, + "gamma_surface": { + "type": "gamma_surface", + "plane_miller": [1, 1, 1], + "slip_direction": [-1, 1, 0], + "supercell_size": [1, 1, 5], + "closed_loop": False, + "n_steps_x": 10, + "n_steps_y": 10, + }, + "decohesive": { + "type": "decohesive", + "miller_index": [1, 1, 1], + "min_slab_size": 40, + "max_vacuum_size": 15, + "vacuum_size_step": 1.0, + }, + "finite_t_latt": { + "type": "finite_t_latt", + "supercell_size": [3, 3, 3], + "cal_setting": { + "temperature": [200, 400, 600, 800], + }, + }, + "finite_t_elastic": { + "type": "finite_t_elastic", + "supercell_size": [3, 3, 3], + "cal_setting": { + "temperature": [300], + "strain": 0.001, + }, + }, + "gruneisen": { + "type": "gruneisen", + "supercell_size": [2, 2, 2], + "MESH": [20, 20, 20], + "volume_strains": [-0.02, -0.01, 0.0, 0.01, 0.02], + "temperatures": [100, 200, 300, 400, 500], + "alpha_mode": "full", + }, + "annealing": { + "type": "annealing", + "supercell_size": [3, 3, 3], + "cal_setting": { + "start_temp": 4, + "target_temp": 300, + "end_temp": 4, + "temp_ramp_rate": 1000, + }, + }, +} + +# LAMMPS-only properties +LAMMPS_ONLY = {"finite_t_elastic"} + +# GPU potential types — benefit from GPU scass_type +GPU_POTENTIALS = {"deepmd", "mace", "nep"} + +# scass_type defaults for inner dflow containers +SCASS_TYPES = { + "lammps_gpu": "c8_m31_1 * NVIDIA T4", + "lammps_cpu": "c16_m32_cpu", + "abacus": "c16_m32_cpu", + "vasp": "c32_m128_cpu", +} + + +def _nprocs_from_scass(scass_type: str, default: int = 16) -> int: + """Extract CPU count from scass strings like ``c32_m128_cpu`` → 32.""" + if not scass_type: + return default + match = re.search(r"\bc(\d+)_", scass_type) + if match: + return int(match.group(1)) + return default + + +def default_vasp_run_command(nprocs: int = 32) -> str: + """Recommended Bohrium VASP run_command (user must still supply a licensed image).""" + return ( + 'bash -c "source /opt/intel/oneapi/setvars.sh && ' + f'ulimit -s unlimited && mpirun -n {nprocs} {VASP_BIN}"' + ) + +# ============================================================================= +# DFT Calculation Defaults (fast/loose for APEX validation workflows) +# ============================================================================= +# Philosophy: APEX is for property screening, NOT publication-grade DFT. +# Use fast settings to minimize wall time. Users can override for production. +# +# Key principle for ABACUS in APEX: +# - Do NOT set pseudo_dir/orbital_dir in INPUT (APEX manages paths via STRU) +# - STRU must have ATOMIC_SPECIES with PP filename and NUMERICAL_ORBITAL section +# - APEX's modify_stru_path() adds pp_orb/ prefix; INPUT pseudo_dir would double it + +ABACUS_INPUT_DEFAULTS = { + "relax": { + "calculation": "cell-relax", + "basis_type": "lcao", + "ecutwfc": 100, + "scf_thr": "1.0e-6", + "scf_nmax": 100, + "smearing_method": "gauss", + "smearing_sigma": 0.02, + "mixing_type": "broyden", + "mixing_beta": 0.7, + "cal_force": 1, + "cal_stress": 1, + "force_thr_ev": 0.02, + "stress_thr": 1.0, + "relax_nmax": 50, + "kspacing": 0.20, # FAST: ~6x6x6 for typical metals (vs 0.10 → 12x12x12) + }, + "phonon_scf": { + "calculation": "scf", + "basis_type": "lcao", + "ecutwfc": 100, + "scf_thr": "1.0e-7", + "scf_nmax": 100, + "smearing_method": "gauss", + "smearing_sigma": 0.01, + "mixing_type": "broyden", + "mixing_beta": 0.7, + "cal_force": 1, + "kspacing": 0.15, # Slightly tighter for force accuracy + }, +} + +# DFT-specific property defaults: smaller supercells, fewer points +DFT_PROPERTY_OVERRIDES = { + "phonon": { + "supercell_size": [2, 2, 2], # 8 atoms (vs 27 for 3x3x3) + "BAND_POINTS": 21, + }, + "gruneisen": { + "supercell_size": [2, 2, 2], + "volume_strains": [-0.02, 0.0, 0.02], # 3 points (vs 5), faster + "temperatures": [100, 300, 500], + }, + "vacancy": { + "supercell": [2, 2, 2], # 8 atoms (vs 27 for 3x3x3) + }, + "interstitial": { + "supercell": [2, 2, 2], + }, + "surface": { + "min_slab_size": 30, # Thinner slab, fewer atoms + "min_vacuum_size": 15, + "max_miller": 1, # Only low-index surfaces + }, +} + + +# ============================================================================= +# Ticket conversion +# ============================================================================= + +def get_bohrium_ticket(access_key: str) -> str: + """ + Convert a Bohrium access_key to a dflow ticket via the OpenAPI. + + API: GET https://openapi.dp.tech/openapi/v1/ticket/get?accessKey= + Header: x-app-key: (empty string) + Response: {"code": 0, "data": {"ticket": "UUID-36-chars"}} + + Returns the ticket string (UUID). + Raises RuntimeError on failure. + """ + url = f"{TICKET_API_URL}?accessKey={access_key}" + req = Request(url, method="GET") + req.add_header("x-app-key", "") + + try: + with urlopen(req, timeout=30) as resp: + body = json.loads(resp.read().decode()) + except (URLError, HTTPError) as e: + raise RuntimeError(f"Failed to get ticket from API: {e}") from e + + if body.get("code") != 0: + raise RuntimeError( + f"Ticket API returned error: code={body.get('code')}, " + f"msg={body.get('message', body.get('msg', 'unknown'))}" + ) + + ticket = body.get("data", {}).get("ticket") + if not ticket or len(ticket) < 30: + raise RuntimeError(f"Invalid ticket received: {ticket!r}") + + return ticket + + +# ============================================================================= +# RFC 1123 workflow name +# ============================================================================= + +def sanitize_workflow_name(name: str) -> str: + """ + Sanitize a workflow name to comply with RFC 1123 subdomain rules. + + Rules: + - All lowercase + - Only [a-z0-9-] allowed + - Cannot start or end with '-' + - Max 63 characters + + Examples: + "Cu-FCC-elastic" → "cu-fcc-elastic" + "Mo_BCC Surface" → "mo-bcc-surface" + """ + # Lowercase + name = name.lower() + # Replace underscores, spaces, and other invalid chars with hyphens + name = re.sub(r"[^a-z0-9-]", "-", name) + # Collapse multiple hyphens + name = re.sub(r"-+", "-", name) + # Strip leading/trailing hyphens + name = name.strip("-") + # Truncate to 63 chars + name = name[:63].rstrip("-") + + if not name: + name = "apex-workflow" + + return name + + +# ============================================================================= +# Global config generation +# ============================================================================= + +def resolve_project_id(project_id: int = None) -> int: + """ + Resolve Bohrium project ID from --project-id or BOHRIUM_PROJECT_ID. + + Never hardcode a personal/project-specific ID. Fail loudly if unset. + """ + if project_id is not None: + return int(project_id) + env = os.environ.get("BOHRIUM_PROJECT_ID", "").strip() + if not env: + raise RuntimeError( + "BOHRIUM_PROJECT_ID environment variable not set and --project-id " + "not provided. Set BOHRIUM_PROJECT_ID to the active Bohrium project " + "before generating global.json." + ) + try: + return int(env) + except ValueError as exc: + raise RuntimeError( + f"BOHRIUM_PROJECT_ID must be an integer, got: {env!r}" + ) from exc + + +def _validate_image_scass(image: str, scass: str) -> None: + """Reject known-bad image × scass_type combinations before write-out.""" + script_dir = Path(__file__).resolve().parent + if str(script_dir) not in sys.path: + sys.path.insert(0, str(script_dir)) + from validate_apex_combo import check_combo # noqa: WPS433 + + ok, errors = check_combo(image, scass) + if not ok: + raise RuntimeError( + "Blocked image × scass_type combination:\n - " + + "\n - ".join(errors) + + "\nRun: python scripts/validate_apex_combo.py list-combos" + ) + + +def build_global_json(backend: str, potential: str = None, + access_key: str = None, project_id: int = None, + scass_type: str = None, + run_command: str = None, + vasp_image: str = None) -> dict: + """ + Build global.json for APEX dflow submission. + + This config tells APEX how to orchestrate calculations via + workflows.deepmodeling.com (dflow/Argo). + + ``program_id`` / ``bohrium_config.project_id`` always come from + ``--project-id`` or ``BOHRIUM_PROJECT_ID`` (required). + """ + pid = resolve_project_id(project_id) + + # Resolve access key and convert to ticket + key = access_key or os.environ.get("BOHRIUM_ACCESS_KEY") + if not key: + raise RuntimeError( + "BOHRIUM_ACCESS_KEY environment variable not set and --access-key not provided. " + "Cannot generate ticket for dflow authentication." + ) + ticket = get_bohrium_ticket(key) + + # Determine scass_type for inner containers + if scass_type: + inner_scass = scass_type + elif backend == "lammps" and potential in GPU_POTENTIALS: + inner_scass = SCASS_TYPES["lammps_gpu"] + elif backend == "lammps": + inner_scass = SCASS_TYPES["lammps_cpu"] + elif backend == "abacus": + inner_scass = SCASS_TYPES["abacus"] + elif backend == "vasp": + inner_scass = SCASS_TYPES["vasp"] + else: + inner_scass = SCASS_TYPES["lammps_cpu"] + + # Determine run command for calculator + if run_command: + calc_run_command = run_command + elif backend == "lammps": + calc_run_command = "lmp -in in.lammps" + elif backend == "abacus": + calc_run_command = "mpirun -n 16 abacus" + elif backend == "vasp": + calc_run_command = default_vasp_run_command( + _nprocs_from_scass(inner_scass, default=32) + ) + else: + calc_run_command = "lmp -in in.lammps" + + # Determine calculator image + if backend == "lammps": + lammps_image = LAMMPS_IMAGE + else: + lammps_image = LAMMPS_IMAGE # Still needed as fallback in global.json + + _validate_image_scass(lammps_image, inner_scass) + + config = { + "dflow_host": DFLOW_HOST, + "k8s_api_server": DFLOW_HOST, + "batch_type": "Bohrium", + "context_type": "Bohrium", + "program_id": pid, + "bohrium_config": { + "ticket": ticket, + "project_id": pid, + }, + "apex_image_name": APEX_IMAGE, + "lammps_image_name": lammps_image, + "lammps_run_command": calc_run_command, + "scass_type": inner_scass, + "group_size": 1, + "pool_size": 1, + } + + # Add backend-specific image fields + if backend == "abacus": + config["abacus_image_name"] = APEX_IMAGE + config["abacus_run_command"] = calc_run_command + elif backend == "vasp": + # Never invent a VASP image — commercial license required. + # Set only when caller passes an explicitly resolved/approved image + # (from Bohrium list_images or a user-known authorized address). + config["vasp_run_command"] = calc_run_command + if vasp_image: + config["vasp_image_name"] = vasp_image.strip() + + return config + + +def validate_project_id_types(config: dict) -> int: + """Hard-check every supported Bohrium project ID before submission.""" + program_id = config.get("program_id") + if type(program_id) is not int or program_id <= 0: + raise ValueError( + "global.json program_id must be a positive, unquoted JSON integer" + ) + + bohrium_config = config.get("bohrium_config") + if not isinstance(bohrium_config, dict): + raise ValueError("global.json must contain a bohrium_config object") + project_id = bohrium_config.get("project_id") + if type(project_id) is not int or project_id <= 0: + raise ValueError( + "global.json bohrium_config.project_id must be a positive, " + "unquoted JSON integer" + ) + if project_id != program_id: + raise ValueError( + "global.json program_id and bohrium_config.project_id must match" + ) + + machine = config.get("machine") + if isinstance(machine, dict): + remote_profile = machine.get("remote_profile") + if isinstance(remote_profile, dict) and "program_id" in remote_profile: + nested_id = remote_profile["program_id"] + if type(nested_id) is not int or nested_id <= 0: + raise ValueError( + "global.json machine.remote_profile.program_id must be a " + "positive, unquoted JSON integer" + ) + if nested_id != program_id: + raise ValueError( + "global.json machine.remote_profile.program_id must match " + "program_id" + ) + return program_id + + +def refresh_global_json(global_path, access_key: str = None, + project_id: int = None) -> dict: + """Refresh ticket and integer project IDs without touching param.json.""" + path = Path(global_path) + if not path.is_file(): + raise FileNotFoundError(f"global.json not found: {path}") + + with open(path, encoding="utf-8") as f: + config = json.load(f) + if not isinstance(config, dict): + raise ValueError("global.json must contain a JSON object") + + pid = resolve_project_id(project_id) + key = access_key or os.environ.get("BOHRIUM_ACCESS_KEY") + if not key: + raise RuntimeError( + "BOHRIUM_ACCESS_KEY environment variable not set and --access-key " + "not provided. Cannot refresh dflow authentication." + ) + ticket = get_bohrium_ticket(key) + + config["program_id"] = pid + bohrium_config = config.setdefault("bohrium_config", {}) + if not isinstance(bohrium_config, dict): + raise ValueError("global.json bohrium_config must be a JSON object") + bohrium_config["project_id"] = pid + bohrium_config["ticket"] = ticket + + machine = config.get("machine") + if isinstance(machine, dict): + remote_profile = machine.get("remote_profile") + if isinstance(remote_profile, dict) and "program_id" in remote_profile: + remote_profile["program_id"] = pid + + validate_project_id_types(config) + + temp_path = path.with_name(f".{path.name}.tmp") + try: + with open(temp_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=4) + f.write("\n") + os.chmod(temp_path, path.stat().st_mode) + os.replace(temp_path, path) + finally: + if temp_path.exists(): + temp_path.unlink() + return config + + +# ============================================================================= +# Interaction and param.json +# ============================================================================= + +def build_interaction(backend: str, potential: str = None, + model: str = None, + incar: str = None, potcar_prefix: str = None, + potcars: dict = None, orb_files: dict = None) -> dict: + """Build interaction configuration.""" + if backend == "lammps": + if not potential: + raise ValueError("--potential required for LAMMPS backend") + if not model: + raise ValueError("--model required for LAMMPS backend") + return { + "type": potential, + "model": model, + "type_map": "auto", + } + elif backend == "abacus": + interaction = { + "type": "abacus", + "incar": incar or "INPUT", + "potcar_prefix": potcar_prefix or ".", + } + if potcars: + interaction["potcars"] = potcars + if orb_files: + interaction["orb_files"] = orb_files + return interaction + elif backend == "vasp": + interaction = { + "type": "vasp", + "incar": incar or "INCAR", + "potcar_prefix": potcar_prefix or ".", + } + if potcars: + interaction["potcars"] = potcars + return interaction + else: + raise ValueError(f"Unknown backend: {backend}") + + +def resolve_source_potcar_file(prefix: Path, entry: str) -> Path: + """Locate a readable POTCAR under prefix for a potcars entry.""" + direct = (prefix / entry).expanduser() + if direct.is_file(): + return direct.resolve() + nested = (prefix / entry / "POTCAR").expanduser() + if nested.is_file(): + return nested.resolve() + raise FileNotFoundError( + f"POTCAR not found for entry '{entry}' under '{prefix}' " + f"(tried '{direct}' and '{nested}')" + ) + + +def stage_vasp_potcars( + output_dir: Path, source_prefix: str, potcars: dict +) -> tuple: + """Copy required POTCAR files into the job root and return upload-safe paths. + + Bohrium/dflow containers do not see host absolute libraries such as + ``/share/PAW_PBE``. Stage flat files into the job directory and set + ``potcar_prefix`` to ``"."`` so ``pack_upload_dir`` uploads them as + ordinary job-root files (nested dirs under ``"."`` are not included). + """ + if not source_prefix: + raise ValueError( + "VASP requires --potcar-prefix pointing to a readable POTCAR library" + ) + if not potcars: + raise ValueError( + "VASP requires --potcars mapping, e.g. " + "'Ti:Ti_pv/POTCAR,V:V_sv/POTCAR'" + ) + + src_root = Path(source_prefix).expanduser() + if not src_root.exists(): + raise FileNotFoundError(f"potcar_prefix not accessible: {src_root}") + if not os.access(src_root, os.R_OK): + raise PermissionError(f"potcar_prefix not readable: {src_root}") + + staged = {} + for element, entry in potcars.items(): + src_file = resolve_source_potcar_file(src_root, entry) + # Flat job-root name so Bohrium upload packs the file (not a subdir). + flat_name = f"POTCAR_{element}" + dest_file = output_dir / flat_name + shutil.copy2(src_file, dest_file) + staged[element] = flat_name + print(f"Staged POTCAR {element}: {src_file} → {dest_file}") + + return ".", staged + + +STRUCTURE_FILENAMES = {"POSCAR", "CONTCAR", "STRU"} +STRUCTURE_SUFFIXES = {".vasp", ".cif", ".xyz"} + + +def sanitize_conf_name(name: str) -> str: + """Make a filesystem-safe confs/ directory name.""" + cleaned = re.sub(r"[^a-zA-Z0-9._-]+", "_", name.strip()) + cleaned = cleaned.strip("._-") + return cleaned or "input" + + +def unique_conf_name(stem: str, used: set) -> str: + """Return a unique conf name, appending _2, _3, ... on collision.""" + base = sanitize_conf_name(stem) + name = base + idx = 2 + while name in used: + name = f"{base}_{idx}" + idx += 1 + used.add(name) + return name + + +def collect_structures_from_dir(dir_path: Path) -> list: + """Collect structure files from a directory. + + Includes: + - direct files named POSCAR/CONTCAR/STRU or with .vasp/.cif/.xyz + - immediate child directories that contain POSCAR/CONTCAR/STRU + """ + root = Path(dir_path) + if not root.is_dir(): + raise ValueError(f"--structure-dir is not a directory: {dir_path}") + + found = [] + for entry in sorted(root.iterdir(), key=lambda p: p.name): + if entry.is_file(): + if entry.name in STRUCTURE_FILENAMES or entry.suffix.lower() in STRUCTURE_SUFFIXES: + found.append(entry.resolve()) + elif entry.is_dir(): + for candidate in ("POSCAR", "CONTCAR", "STRU"): + nested = entry / candidate + if nested.is_file(): + found.append(nested.resolve()) + break + return found + + +def resolve_structure_sources(structure_args, structure_dir_args) -> list: + """Resolve --structure / --structure-dir into an ordered list of source files.""" + sources = [] + seen = set() + + for path_str in structure_args or []: + path = Path(path_str) + if not path.is_file(): + raise ValueError(f"--structure file not found: {path_str}") + resolved = path.resolve() + if resolved not in seen: + sources.append(resolved) + seen.add(resolved) + + for dir_str in structure_dir_args or []: + for path in collect_structures_from_dir(Path(dir_str)): + if path not in seen: + sources.append(path) + seen.add(path) + + if not sources: + raise ValueError( + "No structure files found. Pass --structure FILE [FILE ...] " + "and/or --structure-dir DIR [DIR ...]." + ) + return sources + + +def plan_structure_layout(sources: list) -> list: + """Map source files to job-relative conf dirs and param.json patterns. + + Single structure → confs/input (backward compatible). + Multiple → confs//POSCAR with an explicit structures list. + """ + if len(sources) == 1: + return [{ + "source": sources[0], + "conf_name": "input", + "rel_dir": "confs/input", + "dest_name": "POSCAR", + }] + + used = set() + layout = [] + for source in sources: + stem = source.stem + if source.name in STRUCTURE_FILENAMES: + # Prefer parent directory name when the file itself is POSCAR/etc. + parent = source.parent.name + stem = parent if parent not in (".", "") else "input" + conf_name = unique_conf_name(stem, used) + layout.append({ + "source": source, + "conf_name": conf_name, + "rel_dir": f"confs/{conf_name}", + "dest_name": "POSCAR", + }) + return layout + + +def build_param_json(structure_paths, interaction: dict, + properties: list, flow_type: str = "joint", + relaxation_settings: dict = None) -> dict: + """Build param.json configuration. + + structure_paths: one path string or a list of path strings for `structures`. + """ + if isinstance(structure_paths, str): + structures = [structure_paths] + else: + structures = list(structure_paths) + param = { + "structures": structures, + "interaction": interaction, + } + + # Relaxation settings + if flow_type in ("joint", "relax"): + if relaxation_settings: + param["relaxation"] = {"cal_setting": relaxation_settings} + else: + # Default relaxation settings based on backend + if interaction["type"] in ("vasp", "abacus"): + param["relaxation"] = { + "cal_setting": { + "relax_pos": True, + "relax_shape": True, + "relax_vol": True, + } + } + else: + param["relaxation"] = { + "cal_setting": { + "etol": 0, + "ftol": 1e-10, + "maxiter": 5000, + "maximal": 500000, + } + } + + # Properties + if flow_type in ("joint", "props"): + prop_configs = [] + is_dft = interaction.get("type") in ("abacus", "vasp") + for prop_name in properties: + if prop_name in PROPERTY_DEFAULTS: + prop_config = PROPERTY_DEFAULTS[prop_name].copy() + # Apply DFT-specific overrides (smaller supercells, fewer points) + if is_dft and prop_name in DFT_PROPERTY_OVERRIDES: + prop_config.update(DFT_PROPERTY_OVERRIDES[prop_name]) + prop_configs.append(prop_config) + else: + print(f"Warning: Unknown property '{prop_name}', skipping", + file=sys.stderr) + param["properties"] = prop_configs + + return param + + +# ============================================================================= +# Validation +# ============================================================================= + +def validate_config(backend: str, potential: str, properties: list): + """Validate configuration consistency.""" + errors = [] + + # Check LAMMPS-only properties + if backend != "lammps": + lammps_props = set(properties) & LAMMPS_ONLY + if lammps_props: + errors.append( + f"Properties {lammps_props} are LAMMPS-only but backend is '{backend}'" + ) + + # Check potential is valid for LAMMPS + valid_potentials = { + "deepmd", "mace", "nep", "gap", "snap", "rann", + "eam_alloy", "eam_fs", "meam", "meam_spline" + } + if backend == "lammps" and potential not in valid_potentials: + errors.append(f"Unknown LAMMPS potential type: '{potential}'") + + return errors + + +# ============================================================================= +# Helpers +# ============================================================================= + +def parse_str_map(map_str: str) -> dict: + """Parse a string map like 'Element1:value1,Element2:value2'.""" + if not map_str: + return None + result = {} + for pair in map_str.split(","): + parts = pair.strip().split(":") + if len(parts) == 2: + result[parts[0].strip()] = parts[1].strip() + return result + + +# ============================================================================= +# Main +# ============================================================================= + +def main(): + parser = argparse.ArgumentParser( + description="Create APEX configs or refresh an existing global.json." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + create = subparsers.add_parser( + "create", help="Generate a complete APEX job directory" + ) + create.add_argument( + "--structure", "-s", nargs="+", action="extend", default=[], + help="Structure file(s) (POSCAR/CIF/VASP). Repeatable / space-separated.", + ) + create.add_argument( + "--structure-dir", nargs="+", action="extend", default=[], + help=( + "Directory(ies) of structures. Collects POSCAR/CONTCAR/STRU and " + "*.vasp/*.cif/*.xyz, plus immediate child dirs that contain those files." + ), + ) + create.add_argument("--backend", "-b", required=True, + choices=["lammps", "abacus", "vasp"], + help="Calculator backend") + create.add_argument("--potential", "-p", + help="LAMMPS potential type (deepmd/mace/nep/eam_alloy/...)") + create.add_argument("--model", "-m", + help="Model/potential file path") + create.add_argument("--properties", nargs="+", required=True, + help="Property types to calculate") + create.add_argument("--flow-type", default="joint", + choices=["joint", "relax", "props"], + help="Workflow type (default: joint)") + create.add_argument("--workflow-name", "-n", + help="Workflow name (auto-lowercased for RFC 1123 compliance)") + create.add_argument("--output-dir", "-o", default="./apex_job", + help="Output directory for configs") + create.add_argument( + "--access-key", + help="Bohrium access key (or set BOHRIUM_ACCESS_KEY env var)", + ) + create.add_argument( + "--project-id", type=int, + help="Bohrium project ID (or set BOHRIUM_PROJECT_ID)", + ) + create.add_argument("--scass-type", + help="Override scass_type for inner dflow containers") + create.add_argument("--run-command", + help="Override calculator run command") + create.add_argument( + "--vasp-image", + help=( + "Licensed VASP image URL/name for global.json vasp_image_name. " + "Required for --backend vasp. Resolve via Bohrium list_images " + "(keyword=vasp) or a user-known authorized address — never invent." + ), + ) + create.add_argument("--incar", + help="INCAR/INPUT file path (for VASP/ABACUS)") + create.add_argument("--potcar-prefix", + help="POTCAR prefix directory (for VASP/ABACUS)") + create.add_argument( + "--potcars", + help="POTCAR mapping as 'Element1:potcar1,Element2:potcar2'", + ) + create.add_argument( + "--orb-files", + help="Orbital files as 'Element1:file1,Element2:file2' (ABACUS)", + ) + + refresh = subparsers.add_parser( + "refresh-global", + help="Refresh ticket and project IDs without changing param.json", + ) + refresh.add_argument( + "--global", "-g", dest="global_json", required=True, + help="Existing global.json to update atomically", + ) + refresh.add_argument( + "--access-key", + help="Bohrium access key (or set BOHRIUM_ACCESS_KEY env var)", + ) + refresh.add_argument( + "--project-id", type=int, + help="Bohrium project ID (or set BOHRIUM_PROJECT_ID)", + ) + + args = parser.parse_args() + + if args.command == "refresh-global": + try: + config = refresh_global_json( + args.global_json, + access_key=args.access_key, + project_id=args.project_id, + ) + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + sys.exit(1) + pid = validate_project_id_types(config) + print(f"Refreshed: {Path(args.global_json).resolve()}") + print("Hard type check passed:") + print(f" program_id={pid!r} type={type(pid).__name__}") + project_id = config["bohrium_config"]["project_id"] + print( + " bohrium_config.project_id=" + f"{project_id!r} type={type(project_id).__name__}" + ) + return + + # ------------------------------------------------------------------------- + # Parse complex arguments + # ------------------------------------------------------------------------- + potcars = parse_str_map(args.potcars) + orb_files = parse_str_map(args.orb_files) + + # ------------------------------------------------------------------------- + # Resolve structures (--structure and/or --structure-dir) + # ------------------------------------------------------------------------- + try: + structure_sources = resolve_structure_sources( + args.structure, args.structure_dir + ) + except ValueError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + sys.exit(1) + structure_layout = plan_structure_layout(structure_sources) + structure_paths = [item["rel_dir"] for item in structure_layout] + print( + f"Resolved {len(structure_layout)} structure(s): " + + ", ".join(structure_paths) + ) + + # ------------------------------------------------------------------------- + # Validate + # ------------------------------------------------------------------------- + errors = validate_config(args.backend, args.potential, args.properties) + if errors: + for err in errors: + print(f"ERROR: {err}", file=sys.stderr) + sys.exit(1) + + # ------------------------------------------------------------------------- + # Sanitize workflow name (RFC 1123) + # ------------------------------------------------------------------------- + if args.workflow_name: + workflow_name = sanitize_workflow_name(args.workflow_name) + if workflow_name != args.workflow_name: + print(f"Workflow name sanitized: '{args.workflow_name}' → '{workflow_name}'") + else: + # Auto-generate from first structure basename and properties + first_stem = structure_layout[0]["conf_name"].lower() + if len(structure_layout) > 1: + first_stem = f"{first_stem}-n{len(structure_layout)}" + props_str = "-".join(args.properties[:3]) # First 3 properties + workflow_name = sanitize_workflow_name(f"{first_stem}-{props_str}") + print(f"Auto-generated workflow name: '{workflow_name}'") + + # ------------------------------------------------------------------------- + # Build global.json (includes ticket conversion) + # ------------------------------------------------------------------------- + if args.backend == "vasp" and not (args.vasp_image or "").strip(): + print( + "ERROR: --backend vasp requires --vasp-image. Resolve a licensed " + "image with Bohrium list_images (keyword=vasp) / " + "scripts/list_bohrium_images.py, or use a user-known authorized " + "address. If neither exists, stop the VASP workflow.", + file=sys.stderr, + ) + sys.exit(1) + + print("Converting access_key to dflow ticket...") + global_config = build_global_json( + backend=args.backend, + potential=args.potential, + access_key=args.access_key, + project_id=args.project_id, + scass_type=args.scass_type, + run_command=args.run_command, + vasp_image=args.vasp_image, + ) + validate_project_id_types(global_config) + print(f"Ticket obtained: {global_config['bohrium_config']['ticket'][:8]}...") + + # ------------------------------------------------------------------------- + # Create output directory and copy files + # ------------------------------------------------------------------------- + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + for item in structure_layout: + dest_dir = output_dir / item["rel_dir"] + dest_dir.mkdir(parents=True, exist_ok=True) + dest_file = dest_dir / item["dest_name"] + shutil.copy2(item["source"], dest_file) + print(f"Copied structure to {dest_file}") + + # Copy model file if specified + if args.model and os.path.exists(args.model): + model_dest = output_dir / os.path.basename(args.model) + shutil.copy2(args.model, model_dest) + print(f"Copied model to {model_dest}") + + # Stage VASP POTCAR (+ INCAR) into the job. Absolute host libraries such as + # /share/PAW_PBE are invisible inside Bohrium/dflow containers. + staged_prefix = args.potcar_prefix + staged_potcars = potcars + staged_incar = args.incar + if args.backend == "vasp": + try: + staged_prefix, staged_potcars = stage_vasp_potcars( + output_dir, args.potcar_prefix, potcars + ) + except (OSError, ValueError, FileNotFoundError, PermissionError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + print( + "POTCAR must be readable locally and will be copied into the " + "job root as POTCAR_ with potcar_prefix='.'. " + "Confirm --potcar-prefix and --potcars.", + file=sys.stderr, + ) + sys.exit(1) + if args.incar and os.path.isfile(args.incar): + incar_dest = output_dir / "INCAR" + shutil.copy2(args.incar, incar_dest) + staged_incar = "INCAR" + print(f"Copied INCAR to {incar_dest}") + else: + staged_incar = "INCAR" + print( + "WARNING: no --incar file copied; place INCAR in the job dir " + "before submit", + file=sys.stderr, + ) + + # ------------------------------------------------------------------------- + # Build interaction + param.json (after staging so paths are job-relative) + # ------------------------------------------------------------------------- + interaction = build_interaction( + backend=args.backend, + potential=args.potential, + model=args.model, + incar=staged_incar, + potcar_prefix=staged_prefix, + potcars=staged_potcars, + orb_files=orb_files, + ) + param_config = build_param_json( + structure_paths, interaction, args.properties, args.flow_type + ) + + # ------------------------------------------------------------------------- + # Write configs + # ------------------------------------------------------------------------- + global_path = output_dir / "global.json" + with open(global_path, "w") as f: + json.dump(global_config, f, indent=4) + print(f"Written: {global_path}") + pid = validate_project_id_types(global_config) + print( + "Hard type check passed: " + f"program_id={pid!r} type={type(pid).__name__}; " + "bohrium_config.project_id=" + f"{global_config['bohrium_config']['project_id']!r} " + f"type={type(global_config['bohrium_config']['project_id']).__name__}" + ) + + param_path = output_dir / "param.json" + with open(param_path, "w") as f: + json.dump(param_config, f, indent=4) + print(f"Written: {param_path}") + if args.backend == "vasp": + print( + "VASP potcar_prefix rewritten to job-relative " + f"'{staged_prefix}' (do not keep absolute /share/... paths)." + ) + + # Write outer-job run.sh (force-upgrade APEX; bare pip install keeps the + # image-bundled older version and uploads that stale code to inner steps). + run_script = output_dir / "run.sh" + cmd = f'apex submit param.json -c global.json -f {args.flow_type} -n "{workflow_name}"' + with open(run_script, "w") as f: + f.write("#!/bin/bash\n") + f.write("set -eo pipefail\n\n") + f.write("# Upgrade to latest apex-flow.\n") + f.write( + "python3 -m pip install --upgrade --no-cache-dir apex-flow " + "2>&1 | tail -5\n" + ) + f.write( + 'python3 -c "import apex; ' + 'print(f\'APEX version: {apex.__version__}\')"\n\n' + ) + f.write( + "# Authentication is already stored in global.json by " + "generate_config.py.\n" + ) + f.write("set +eo pipefail\n") + f.write(f"{cmd} 2>&1 | tee apex_submit.log\n") + f.write("EXIT_CODE=${PIPESTATUS[0]}\n") + f.write("set -eo pipefail\n\n") + f.write("if [ $EXIT_CODE -eq 0 ]; then\n") + f.write( + ' echo "=== APEX workflow completed and results retrieved ==="\n' + ) + f.write(' echo "Retain apex_submit.log and the workflow ID."\n') + f.write(" exit 0\n") + f.write("else\n") + f.write(' echo "APEX failed (exit $EXIT_CODE)"\n') + f.write(" tail -50 apex_submit.log 2>/dev/null || true\n") + f.write(" exit 1\n") + f.write("fi\n") + os.chmod(run_script, 0o755) + print(f"Written: {run_script}") + + # Keep a minimal submit.sh for local/debug use inside an already-upgraded env. + submit_script = output_dir / "submit.sh" + with open(submit_script, "w") as f: + f.write("#!/bin/bash\n") + f.write("# APEX submission command (run inside APEX container)\n") + f.write(f"# Workflow name: {workflow_name}\n") + f.write(f"# Flow type: {args.flow_type}\n") + f.write( + "# Prefer run.sh for Bohrium outer jobs (it upgrades apex-flow).\n\n" + ) + f.write(f"{cmd}\n") + os.chmod(submit_script, 0o755) + print(f"Written: {submit_script}") + + # ------------------------------------------------------------------------- + # Print summary + # ------------------------------------------------------------------------- + print(f"\n{'='*60}") + print(f"APEX Job Configuration Generated") + print(f"{'='*60}") + print(f"Backend: {args.backend}") + if args.potential: + print(f"Potential: {args.potential}") + print(f"Structures: {len(structure_paths)} ({', '.join(structure_paths)})") + print(f"Properties: {', '.join(args.properties)}") + print(f"Flow type: {args.flow_type}") + print(f"Workflow name: {workflow_name}") + print(f"scass_type: {global_config['scass_type']}") + print(f"Output dir: {output_dir}") + print(f"\nBohrium submit command (for outer job):") + print(f" cmd: {cmd}") + print(f"\nOuter job image: {APEX_IMAGE}") + print(f"Outer job machine: c1_m2_cpu (recommended lightweight client)") + + +if __name__ == "__main__": + main() diff --git a/apex/skills/apex-flow/scripts/generate_rss.py b/apex/skills/apex-flow/scripts/generate_rss.py new file mode 100644 index 00000000..3b4403b0 --- /dev/null +++ b/apex/skills/apex-flow/scripts/generate_rss.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +""" +APEX RSS Structure Generator + +Generates Random Solid Solution structures for multi-component alloys. +Uses pymatgen for structure generation. + +Usage: + python generate_rss.py \ + --composition "Co0.2Cr0.2Fe0.2Mn0.2Ni0.2" \ + --prototype fcc \ + --supercell 3 3 3 \ + --n-configs 10 \ + --output-dir confs/rss +""" + +import argparse +import json +import os +import re +import sys +from pathlib import Path + +import numpy as np + +try: + from pymatgen.core import Structure, Lattice + from pymatgen.io.vasp import Poscar + from pymatgen.transformations.standard_transformations import ( + SupercellTransformation, + ) +except ImportError: + print("ERROR: pymatgen is required. Install with: pip install pymatgen", + file=sys.stderr) + sys.exit(1) + + +# Metallic radii (Å) for lattice constant estimation +METALLIC_RADII = { + "Li": 1.52, "Be": 1.12, "Na": 1.86, "Mg": 1.60, "Al": 1.43, + "K": 2.27, "Ca": 1.97, "Sc": 1.62, "Ti": 1.47, "V": 1.34, + "Cr": 1.28, "Mn": 1.27, "Fe": 1.26, "Co": 1.25, "Ni": 1.24, + "Cu": 1.28, "Zn": 1.34, "Ga": 1.35, "Ge": 1.22, "Sr": 2.15, + "Y": 1.80, "Zr": 1.60, "Nb": 1.46, "Mo": 1.39, "Tc": 1.36, + "Ru": 1.34, "Rh": 1.34, "Pd": 1.37, "Ag": 1.44, "Cd": 1.51, + "In": 1.67, "Sn": 1.58, "Hf": 1.59, "Ta": 1.46, "W": 1.39, + "Re": 1.37, "Os": 1.35, "Ir": 1.36, "Pt": 1.39, "Au": 1.44, + "Pb": 1.75, "Bi": 1.56, +} + +# Packing factors for different prototypes +PACKING_FACTORS = { + "fcc": np.sqrt(2) * 2, # a = 2√2 * r + "bcc": 4 / np.sqrt(3), # a = 4r/√3 + "hcp": 2.0, # a = 2r + "sc": 2.0, # a = 2r + "diamond": 8 / np.sqrt(3), # a = 8r/√3 +} + +# Prototype structures (fractional coordinates) +PROTOTYPES = { + "fcc": { + "sites": [[0, 0, 0], [0.5, 0.5, 0], [0.5, 0, 0.5], [0, 0.5, 0.5]], + "n_atoms": 4, + }, + "bcc": { + "sites": [[0, 0, 0], [0.5, 0.5, 0.5]], + "n_atoms": 2, + }, + "hcp": { + "sites": [[0, 0, 0], [1/3, 2/3, 0.5]], + "n_atoms": 2, + "angles": [90, 90, 120], + "c_over_a": 1.633, + }, + "sc": { + "sites": [[0, 0, 0]], + "n_atoms": 1, + }, + "diamond": { + "sites": [[0, 0, 0], [0.5, 0.5, 0], [0.5, 0, 0.5], [0, 0.5, 0.5], + [0.25, 0.25, 0.25], [0.75, 0.75, 0.25], + [0.75, 0.25, 0.75], [0.25, 0.75, 0.75]], + "n_atoms": 8, + }, + "B2": { + "sites": [[0, 0, 0], [0.5, 0.5, 0.5]], + "n_atoms": 2, + "sublattices": {"A": [0], "B": [1]}, + }, + "L12": { + "sites": [[0, 0, 0], [0.5, 0.5, 0], [0.5, 0, 0.5], [0, 0.5, 0.5]], + "n_atoms": 4, + "sublattices": {"A": [1, 2, 3], "B": [0]}, # 3:1 ratio + }, + "L10": { + "sites": [[0, 0, 0], [0.5, 0.5, 0], [0.5, 0, 0.5], [0, 0.5, 0.5]], + "n_atoms": 4, + "sublattices": {"A": [0, 1], "B": [2, 3]}, # 1:1 ratio + }, +} + + +def parse_composition(comp_str: str) -> dict: + """Parse composition string like 'Co0.2Cr0.2Fe0.2Mn0.2Ni0.2' or 'CoCrFeMnNi'.""" + # Try pattern: Element + number + pattern = r"([A-Z][a-z]?)(\d*\.?\d*)" + matches = re.findall(pattern, comp_str) + + composition = {} + for element, fraction in matches: + if not element: + continue + if fraction: + composition[element] = float(fraction) + else: + composition[element] = 1.0 + + # Normalize to fractions + total = sum(composition.values()) + if total > 0: + composition = {k: v / total for k, v in composition.items()} + + return composition + + +def estimate_lattice_constant(composition: dict, prototype: str) -> float: + """Estimate lattice constant from composition-weighted radii.""" + avg_radius = 0.0 + for element, fraction in composition.items(): + if element in METALLIC_RADII: + avg_radius += fraction * METALLIC_RADII[element] + else: + avg_radius += fraction * 1.4 # default fallback + + factor = PACKING_FACTORS.get(prototype, 2.0) + return avg_radius * factor + + +def build_prototype_structure(prototype: str, lattice_constant: float, + elements: list) -> Structure: + """Build a prototype unit cell with placeholder species.""" + proto_info = PROTOTYPES[prototype] + n_atoms = proto_info["n_atoms"] + + # Build lattice + if prototype == "hcp": + c_over_a = proto_info.get("c_over_a", 1.633) + lattice = Lattice.hexagonal(lattice_constant, lattice_constant * c_over_a) + else: + lattice = Lattice.cubic(lattice_constant) + + # Use first element as placeholder + species = [elements[0]] * n_atoms + coords = proto_info["sites"] + + return Structure(lattice, species, coords) + + +def generate_random_config(structure: Structure, composition: dict, + prototype: str = None, sublattice_map: dict = None, + seed: int = None) -> Structure: + """Generate a random configuration by assigning elements to sites.""" + if seed is not None: + np.random.seed(seed) + + n_sites = len(structure) + elements = list(composition.keys()) + fractions = np.array(list(composition.values())) + + proto_info = PROTOTYPES.get(prototype, {}) + sublattices = proto_info.get("sublattices") + + if sublattices and sublattice_map: + # Sublattice-aware assignment + new_species = list(structure.species) + + for sub_name, site_indices_in_unit in sublattices.items(): + sub_elements = sublattice_map.get(sub_name, elements) + # Find all sites belonging to this sublattice in the supercell + # (site_indices_in_unit are for the unit cell; supercell replicates) + n_unit = proto_info["n_atoms"] + n_replicas = n_sites // n_unit + + sub_site_indices = [] + for replica in range(n_replicas): + for idx in site_indices_in_unit: + sub_site_indices.append(replica * n_unit + idx) + + # Randomly assign from sub_elements (equimolar within sublattice) + n_sub = len(sub_site_indices) + sub_fracs = np.ones(len(sub_elements)) / len(sub_elements) + counts = np.round(sub_fracs * n_sub).astype(int) + # Adjust for rounding + diff = n_sub - counts.sum() + counts[0] += diff + + assignment = [] + for elem, count in zip(sub_elements, counts): + assignment.extend([elem] * count) + np.random.shuffle(assignment) + + for i, site_idx in enumerate(sub_site_indices): + new_species[site_idx] = assignment[i] + + new_structure = structure.copy() + for i, sp in enumerate(new_species): + new_structure[i] = sp + return new_structure + + else: + # Random assignment based on composition + counts = np.round(fractions * n_sites).astype(int) + # Adjust for rounding errors + diff = n_sites - counts.sum() + counts[np.argmax(fractions)] += diff + + assignment = [] + for elem, count in zip(elements, counts): + assignment.extend([elem] * count) + np.random.shuffle(assignment) + + new_structure = structure.copy() + for i, elem in enumerate(assignment): + new_structure[i] = elem + return new_structure + + +def main(): + parser = argparse.ArgumentParser( + description="Generate Random Solid Solution structures" + ) + parser.add_argument("--composition", "-c", required=True, + help="Composition string (e.g., 'Co0.2Cr0.2Fe0.2Mn0.2Ni0.2')") + parser.add_argument("--prototype", "-p", required=True, + choices=list(PROTOTYPES.keys()), + help="Crystal prototype") + parser.add_argument("--supercell", "-s", nargs=3, type=int, default=[3, 3, 3], + help="Supercell dimensions (default: 3 3 3)") + parser.add_argument("--n-configs", "-n", type=int, default=1, + help="Number of configurations to generate") + parser.add_argument("--lattice-constant", type=float, + help="Override lattice constant (Å)") + parser.add_argument("--sublattice-A", nargs="+", + help="Elements for sublattice A (B2/L12/L10)") + parser.add_argument("--sublattice-B", nargs="+", + help="Elements for sublattice B (B2/L12/L10)") + parser.add_argument("--output-dir", "-o", default="./confs/rss", + help="Output directory") + parser.add_argument("--seed", type=int, + help="Random seed for reproducibility") + parser.add_argument("--format", default="poscar", + choices=["poscar", "cif"], + help="Output format") + + args = parser.parse_args() + + # Parse composition + composition = parse_composition(args.composition) + if not composition: + print("ERROR: Could not parse composition string", file=sys.stderr) + sys.exit(1) + + print(f"Composition: {composition}") + print(f"Prototype: {args.prototype}") + print(f"Supercell: {args.supercell}") + + # Estimate or use provided lattice constant + elements = list(composition.keys()) + if args.lattice_constant: + a = args.lattice_constant + else: + a = estimate_lattice_constant(composition, args.prototype) + print(f"Lattice constant: {a:.4f} Å") + + # Build prototype and make supercell + unit_cell = build_prototype_structure(args.prototype, a, elements) + + # Apply supercell transformation + supercell_matrix = np.diag(args.supercell) + transformation = SupercellTransformation(supercell_matrix.tolist()) + supercell = transformation.apply_transformation(unit_cell) + + print(f"Supercell: {len(supercell)} atoms") + + # Sublattice mapping + sublattice_map = None + if args.sublattice_A or args.sublattice_B: + sublattice_map = {} + if args.sublattice_A: + sublattice_map["A"] = args.sublattice_A + if args.sublattice_B: + sublattice_map["B"] = args.sublattice_B + + # Create output directory + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + # Generate configurations + base_seed = args.seed if args.seed else 42 + generated = [] + + for i in range(args.n_configs): + seed = base_seed + i + config = generate_random_config( + supercell, composition, args.prototype, sublattice_map, seed + ) + + # Write output + if args.format == "poscar": + filename = f"POSCAR_{i+1:04d}" + filepath = output_dir / filename + poscar = Poscar(config) + poscar.write_file(str(filepath)) + else: + filename = f"config_{i+1:04d}.cif" + filepath = output_dir / filename + config.to(str(filepath)) + + generated.append(str(filepath)) + + # Print composition check + actual_comp = config.composition.fractional_composition + print(f" Config {i+1}: {filename} " + f"({config.composition.reduced_formula})") + + print(f"\nGenerated {len(generated)} configurations in {output_dir}") + print(f"Total atoms per config: {len(supercell)}") + + +if __name__ == "__main__": + main() diff --git a/apex/skills/apex-flow/scripts/list_bohrium_images.py b/apex/skills/apex-flow/scripts/list_bohrium_images.py new file mode 100644 index 00000000..4c3bd87c --- /dev/null +++ b/apex/skills/apex-flow/scripts/list_bohrium_images.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +""" +List the user's private Bohrium Docker images (keyword-filtered). + +Mirrors MatMaster Bohrium tool action ``list_images``: + GET /openapi/v2/image/private (header: accessKey) + +Usage: + python list_bohrium_images.py --keyword vasp + python list_bohrium_images.py --keyword vasp --max-results 20 --json + python list_bohrium_images.py --keyword vasp --require # exit 1 if none + +Environment: + BOHRIUM_ACCESS_KEY required (or pass --access-key) + BOHRIUM_BASE_URL optional, default https://openapi.dp.tech +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +DEFAULT_BASE_URL = "https://openapi.dp.tech" +PRIVATE_IMAGE_PATH = "/openapi/v2/image/private" + + +def _get_json( + base_url: str, + path: str, + access_key: str, + params: dict[str, Any] | None = None, + timeout: int = 30, +) -> dict[str, Any]: + query = f"?{urlencode(params)}" if params else "" + url = f"{base_url.rstrip('/')}{path}{query}" + req = Request(url, method="GET") + req.add_header("accessKey", access_key) + req.add_header("Accept", "application/json") + try: + with urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode()) + except HTTPError as exc: + body = exc.read().decode(errors="replace") if exc.fp else "" + raise RuntimeError( + f"Bohrium API HTTP {exc.code} for {path}: {body[:300]}" + ) from exc + except URLError as exc: + raise RuntimeError(f"Bohrium API request failed for {path}: {exc}") from exc + + +def fetch_private_images( + access_key: str, + *, + base_url: str = DEFAULT_BASE_URL, + page_size: int = 200, + max_pages: int = 50, +) -> list[dict[str, Any]]: + """Paginate ``/openapi/v2/image/private`` and return raw records.""" + private_images: list[dict[str, Any]] = [] + page = 1 + while page <= max_pages: + payload = _get_json( + base_url, + PRIVATE_IMAGE_PATH, + access_key, + params={ + "page": page, + "pageSize": page_size, + "device": "container", + "type": "image", + }, + ) + data_block = payload.get("data") or {} + batch = data_block.get("items") or [] + if not batch: + break + private_images.extend(batch) + total = data_block.get("total") + if isinstance(total, int) and len(private_images) >= total: + break + if len(batch) < page_size: + break + page += 1 + return private_images + + +def filter_images( + records: list[dict[str, Any]], + keyword: str, + *, + max_results: int = 20, +) -> dict[str, Any]: + """Filter private image records by keyword; shape like MatMaster list_images.""" + lowered = (keyword or "").strip().lower() + if lowered: + filtered = [ + record + for record in records + if lowered + in str(record.get("name") or record.get("imageName") or "").lower() + or lowered in str(record.get("description") or "").lower() + or lowered in str(record.get("url") or "").lower() + ] + else: + filtered = list(records) + + results: list[dict[str, Any]] = [] + for record in filtered[:max_results]: + img_id = record.get("id") or record.get("imageId") + if img_id is None: + continue + name = record.get("name") or record.get("imageName") or "" + description = record.get("description") or "" + direct_url = record.get("url") or "" + entry: dict[str, Any] = {} + if direct_url: + entry["url"] = direct_url + ver_str = name.split(":")[-1] if ":" in name else "" + if ver_str: + entry["version"] = ver_str + size = record.get("size") or "" + if size: + entry["size"] = size + item: dict[str, Any] = { + "id": img_id, + "name": name, + "versions": [entry] if entry else [], + "private": True, + } + if description: + item["description"] = description + results.append(item) + + return { + "success": True, + "keyword": lowered, + "total_found": len(filtered), + "returned": len(results), + "images": results, + "source": "openapi_v2_image_private", + } + + +def image_urls(payload: dict[str, Any]) -> list[str]: + """Extract ready-to-use image URLs / names from a list_images payload.""" + urls: list[str] = [] + for image in payload.get("images") or []: + versions = image.get("versions") or [] + found_url = False + for ver in versions: + url = (ver.get("url") or "").strip() + if url: + urls.append(url) + found_url = True + if not found_url: + # Fallback when API returns registry path only in name. + name = (image.get("name") or "").strip() + if name: + urls.append(name) + # Deduplicate preserving order + seen = set() + unique: list[str] = [] + for u in urls: + if u not in seen: + seen.add(u) + unique.append(u) + return unique + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description=( + "List the user's own private Bohrium Docker images " + "(filtered by keyword). Same API as MatMaster " + 'Bohrium(action="list_images").' + ) + ) + parser.add_argument( + "--keyword", "-k", default="vasp", + help="Filter keyword (default: vasp)", + ) + parser.add_argument( + "--max-results", type=int, default=20, + help="Maximum images to return (default: 20)", + ) + parser.add_argument( + "--access-key", + help="Bohrium access key (or set BOHRIUM_ACCESS_KEY)", + ) + parser.add_argument( + "--base-url", + default=os.environ.get("BOHRIUM_BASE_URL", DEFAULT_BASE_URL), + help="Bohrium OpenAPI base URL", + ) + parser.add_argument( + "--json", action="store_true", + help="Print full JSON payload (default)", + ) + parser.add_argument( + "--urls-only", action="store_true", + help="Print one image URL/name per line", + ) + parser.add_argument( + "--require", action="store_true", + help="Exit 1 when no images match (for VASP gate)", + ) + args = parser.parse_args(argv) + + access_key = args.access_key or os.environ.get("BOHRIUM_ACCESS_KEY") + if not access_key: + print( + "ERROR: BOHRIUM_ACCESS_KEY not set and --access-key not provided", + file=sys.stderr, + ) + return 2 + + try: + records = fetch_private_images(access_key, base_url=args.base_url) + payload = filter_images( + records, args.keyword, max_results=args.max_results + ) + except RuntimeError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + urls = image_urls(payload) + if args.require and not urls and not payload.get("images"): + print( + f"ERROR: no private Bohrium images matched keyword " + f"{args.keyword!r}. User must provide an authorized VASP " + "image address, or the VASP workflow must stop.", + file=sys.stderr, + ) + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 1 + + if args.urls_only: + for url in urls: + print(url) + if not urls: + for image in payload.get("images") or []: + name = (image.get("name") or "").strip() + if name: + print(name) + else: + print(json.dumps(payload, ensure_ascii=False, indent=2)) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/apex/skills/apex-flow/scripts/parse_results.py b/apex/skills/apex-flow/scripts/parse_results.py new file mode 100644 index 00000000..c7d3889d --- /dev/null +++ b/apex/skills/apex-flow/scripts/parse_results.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +""" +APEX Result Parser + +Parses APEX output directories and extracts structured results. + +Usage: + python parse_results.py --work-dir ./results + python parse_results.py --work-dir ./results --property elastic --format csv +""" + +import argparse +import json +import os +import sys +from pathlib import Path + + +def find_result_files(work_dir: Path) -> list: + """Find all result.json and result files in APEX output.""" + results = [] + for root, dirs, files in os.walk(work_dir): + for f in files: + if f in ("result.json", "all_result.json", "result"): + results.append(Path(root) / f) + return sorted(results) + + +def parse_elastic_result(result_data: dict) -> dict: + """Parse elastic property results.""" + parsed = {} + if "elastic_tensor" in result_data: + parsed["elastic_tensor"] = result_data["elastic_tensor"] + + # Extract from common APEX output format + for key in ("BulkModulus_Voigt", "ShearModulus_Voigt", + "YoungModulus_Voigt", "PoissonRatio_Voigt", + "BulkModulus_Reuss", "ShearModulus_Reuss", + "BulkModulus_VRH", "ShearModulus_VRH"): + if key in result_data: + parsed[key] = result_data[key] + + # Look for Cij values + for i in range(1, 7): + for j in range(i, 7): + key = f"C{i}{j}" + if key in result_data: + parsed[key] = result_data[key] + + return parsed + + +def parse_eos_result(result_data: dict) -> dict: + """Parse EOS results.""" + parsed = {} + for key in ("equilibrium_volume", "bulk_modulus", "bulk_modulus_derivative", + "equilibrium_energy", "volumes", "energies"): + if key in result_data: + parsed[key] = result_data[key] + return parsed + + +def parse_surface_result(result_data: dict) -> dict: + """Parse surface energy results.""" + parsed = {} + if isinstance(result_data, dict): + for key, value in result_data.items(): + if "surface_energy" in str(key).lower() or "miller" in str(key).lower(): + parsed[key] = value + return parsed + + +def parse_vacancy_result(result_data: dict) -> dict: + """Parse vacancy formation energy results.""" + parsed = {} + if isinstance(result_data, dict): + for key, value in result_data.items(): + if "formation" in str(key).lower() or "vacancy" in str(key).lower(): + parsed[key] = value + return parsed + + +def parse_phonon_result(result_data: dict) -> dict: + """Parse phonon results.""" + parsed = {} + for key in ("band_structure", "dos", "force_constants_file", + "thermal_properties"): + if key in result_data: + parsed[key] = result_data[key] + return parsed + + +def parse_general_result(result_data: dict) -> dict: + """Generic result parser for unrecognized property types.""" + return result_data + + +PROPERTY_PARSERS = { + "elastic": parse_elastic_result, + "eos": parse_eos_result, + "surface": parse_surface_result, + "vacancy": parse_vacancy_result, + "interstitial": parse_vacancy_result, + "phonon": parse_phonon_result, +} + + +def detect_property_type(path: Path) -> str: + """Detect property type from directory path.""" + path_str = str(path).lower() + for prop_type in PROPERTY_PARSERS: + if prop_type in path_str: + return prop_type + return "unknown" + + +def parse_result_file(filepath: Path) -> dict: + """Parse a single result file.""" + try: + with open(filepath) as f: + data = json.load(f) + return data + except (json.JSONDecodeError, IOError) as e: + print(f"Warning: Could not parse {filepath}: {e}", file=sys.stderr) + return None + + +def format_output(results: list, fmt: str = "json") -> str: + """Format parsed results for output.""" + if fmt == "json": + return json.dumps(results, indent=2, default=str) + elif fmt == "csv": + if not results: + return "" + # Simple CSV: flatten first level + lines = [] + headers = set() + for r in results: + if isinstance(r.get("data"), dict): + headers.update(r["data"].keys()) + headers = sorted(headers) + lines.append(",".join(["source", "property"] + list(headers))) + for r in results: + row = [str(r.get("source", "")), str(r.get("property", ""))] + data = r.get("data", {}) + for h in headers: + val = data.get(h, "") + if isinstance(val, (list, dict)): + val = json.dumps(val) + row.append(str(val)) + lines.append(",".join(row)) + return "\n".join(lines) + elif fmt == "summary": + lines = [] + for r in results: + lines.append(f"\n{'='*60}") + lines.append(f"Source: {r.get('source', 'unknown')}") + lines.append(f"Property: {r.get('property', 'unknown')}") + lines.append(f"{'='*60}") + data = r.get("data", {}) + for k, v in data.items(): + if isinstance(v, float): + lines.append(f" {k}: {v:.6f}") + elif isinstance(v, list) and len(v) <= 10: + lines.append(f" {k}: {v}") + elif isinstance(v, list): + lines.append(f" {k}: [{v[0]:.4f}, ..., {v[-1]:.4f}] ({len(v)} points)") + else: + lines.append(f" {k}: {v}") + return "\n".join(lines) + else: + return json.dumps(results, indent=2, default=str) + + +def main(): + parser = argparse.ArgumentParser( + description="Parse APEX calculation results" + ) + parser.add_argument("--work-dir", "-w", required=True, + help="APEX work/output directory") + parser.add_argument("--property", "-p", + help="Filter by property type") + parser.add_argument("--format", "-f", default="summary", + choices=["json", "csv", "summary"], + help="Output format (default: summary)") + parser.add_argument("--output", "-o", + help="Output file (default: stdout)") + + args = parser.parse_args() + work_dir = Path(args.work_dir) + + if not work_dir.exists(): + print(f"ERROR: Directory not found: {work_dir}", file=sys.stderr) + sys.exit(1) + + # Find result files + result_files = find_result_files(work_dir) + if not result_files: + print(f"No result files found in {work_dir}", file=sys.stderr) + # Try looking for specific output patterns + for pattern in ("*.csv", "*.json"): + found = list(work_dir.rglob(pattern)) + if found: + print(f"Found {len(found)} {pattern} files", file=sys.stderr) + sys.exit(1) + + # Parse all results + all_results = [] + for rf in result_files: + prop_type = detect_property_type(rf) + if args.property and prop_type != args.property: + continue + + data = parse_result_file(rf) + if data is None: + continue + + parser_func = PROPERTY_PARSERS.get(prop_type, parse_general_result) + parsed = parser_func(data) + + all_results.append({ + "source": str(rf.relative_to(work_dir)), + "property": prop_type, + "data": parsed, + }) + + # Format and output + output = format_output(all_results, args.format) + + if args.output: + with open(args.output, "w") as f: + f.write(output) + print(f"Results written to {args.output}") + else: + print(output) + + +if __name__ == "__main__": + main() diff --git a/apex/skills/apex-flow/scripts/validate_apex_combo.py b/apex/skills/apex-flow/scripts/validate_apex_combo.py new file mode 100644 index 00000000..b0bbf1d8 --- /dev/null +++ b/apex/skills/apex-flow/scripts/validate_apex_combo.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +""" +Validate / recommend APEX Bohrium image × machine (scass_type) combinations. + +Prevents known-failing DeepMD-kit image + scass_type pairs before submission. +Use from the agent skill or as a pre-check in generate_config.py. + +Usage: + python validate_apex_combo.py list-combos --backend lammps --prefer gpu + python validate_apex_combo.py check \\ + --image registry.dp.tech/dptech/dp/native/prod-397637/deepmd-kit-phonolammps:3.1.3 \\ + --scass "c8_m31_1 * NVIDIA T4" + python validate_apex_combo.py recommend --backend lammps --prefer cpu +""" + +from __future__ import annotations + +import argparse +import json +import sys +from typing import Optional + + +REGISTRY_PREFIX = "registry.dp.tech/dptech/" + +# Normalized tag → reason (always reject) +BLOCKED_IMAGES = { + "deepmd-kit:3.1.0": "imageName not found on Bohrium for this project", + "deepmd-kit:3.1.1-cuda12.1": "imageName not found on Bohrium for this project", + "deepmd-kit:3.1.2": "LAMMPS lacks pair style 'deepmd' in this image", +} + +# Exact scass_type strings that fail scheduling / do not exist +BLOCKED_SCASS = { + "c4_m16_cpu": "No resources available for this machine type", + "c12_m46_1 * NVIDIA T4": "machine type does not exist on Bohrium", +} + +# Image × accelerator combinations that are known to be incompatible. +# Match the accelerator name so every T4 machine size is covered. +BLOCKED_IMAGE_ACCELERATORS = { + ("deepmd-kit:3.1.1", "NVIDIA T4"): + "DeepMD-kit 3.1.1 is incompatible with NVIDIA T4", +} + +# Recommended allow-lists (short tags or full image refs) +RECOMMENDED_LAMMPS_IMAGES = { + "cpu": [ + "registry.dp.tech/dptech/dp/native/prod-397637/deepmd-kit-phonolammps:3.1.3", + "registry.dp.tech/dptech/deepmd-kit:3.1.1", + "registry.dp.tech/dptech/deepmd-kit:2024Q1-d23cf3e", + ], + "gpu": [ + "registry.dp.tech/dptech/dp/native/prod-397637/deepmd-kit-phonolammps:3.1.3", + "registry.dp.tech/dptech/deepmd-kit:3.1.1", + "registry.dp.tech/dptech/deepmd-kit:2024Q1-d23cf3e", + "registry.dp.tech/dptech/deepmd-kit:3.1.0-cuda12.1", + "registry.dp.tech/dptech/deepmd-kit:3.0.1-cuda12.1", + ], +} + +RECOMMENDED_SCASS = { + "lammps_cpu": [ + "c32_m64_cpu", + "c32_m128_cpu", + "c16_m64_cpu", + "c16_m32_cpu", + "c8_m32_cpu", + ], + "lammps_gpu": [ + "c8_m31_1 * NVIDIA T4", + "c4_m15_1 * NVIDIA T4", + "c16_m62_1 * NVIDIA T4", + ], + "abacus": ["c16_m32_cpu", "c32_m64_cpu", "c32_m128_cpu"], + "vasp": ["c32_m128_cpu", "c16_m64_cpu", "c32_m64_cpu"], + "outer": ["c1_m2_cpu", "c2_m4_cpu", "c2_m8_cpu"], +} + +# Triclinic / non-orthogonal cells: avoid deepmd-kit:3.1.1 (segfault) +TRICLINIC_UNSAFE_TAGS = {"deepmd-kit:3.1.1"} + + +def normalize_image(image: str) -> str: + """Return short tag like deepmd-kit:3.1.3 from a full or short ref.""" + image = (image or "").strip() + if image.startswith(REGISTRY_PREFIX): + image = image[len(REGISTRY_PREFIX) :] + return image + + +def full_image(image: str) -> str: + image = (image or "").strip() + if not image: + return image + if image.startswith("registry.") or "/" in image.split(":")[0]: + return image + return REGISTRY_PREFIX + image + + +def check_combo( + image: str, + scass: str, + *, + triclinic: bool = False, +) -> tuple[bool, list[str]]: + """ + Return (ok, messages). ok=False means do not submit. + """ + errors: list[str] = [] + tag = normalize_image(image) + scass = (scass or "").strip() + + if tag in BLOCKED_IMAGES: + errors.append(f"blocked image '{tag}': {BLOCKED_IMAGES[tag]}") + if scass in BLOCKED_SCASS: + errors.append(f"blocked scass_type '{scass}': {BLOCKED_SCASS[scass]}") + for (blocked_tag, accelerator), reason in BLOCKED_IMAGE_ACCELERATORS.items(): + if tag == blocked_tag and accelerator in scass: + errors.append( + f"blocked image × accelerator '{tag}' × '{accelerator}': {reason}" + ) + if triclinic and tag in TRICLINIC_UNSAFE_TAGS: + errors.append( + f"image '{tag}' is unsafe for triclinic/non-orthogonal cells " + "(known segfault); use deepmd-kit:3.1.3 or later" + ) + return (len(errors) == 0, errors) + + +def recommend( + backend: str = "lammps", + prefer: str = "cpu", +) -> dict: + """Return a recommended image + scass_type for the backend.""" + prefer = prefer.lower() + backend = backend.lower() + + if backend == "lammps": + key = "lammps_gpu" if prefer == "gpu" else "lammps_cpu" + images = RECOMMENDED_LAMMPS_IMAGES["gpu" if prefer == "gpu" else "cpu"] + return { + "backend": backend, + "prefer": prefer, + "image": images[0], + "scass_type": RECOMMENDED_SCASS[key][0], + "alternatives": { + "images": images, + "scass_types": RECOMMENDED_SCASS[key], + }, + "outer_machine": RECOMMENDED_SCASS["outer"][0], + } + if backend == "abacus": + return { + "backend": backend, + "prefer": "cpu", + "image": "registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post", + "scass_type": RECOMMENDED_SCASS["abacus"][0], + "alternatives": {"scass_types": RECOMMENDED_SCASS["abacus"]}, + "outer_machine": RECOMMENDED_SCASS["outer"][0], + } + if backend == "vasp": + return { + "backend": backend, + "prefer": "cpu", + "image": "", + "scass_type": RECOMMENDED_SCASS["vasp"][0], + "alternatives": {"scass_types": RECOMMENDED_SCASS["vasp"]}, + "outer_machine": RECOMMENDED_SCASS["outer"][0], + } + raise ValueError(f"Unknown backend: {backend}") + + +def list_combos(backend: str = "lammps", prefer: str = "cpu") -> dict: + rec = recommend(backend, prefer) + return { + "recommended": rec, + "blocked_images": BLOCKED_IMAGES, + "blocked_scass": BLOCKED_SCASS, + "blocked_image_accelerators": { + f"{image} × {accelerator}": reason + for (image, accelerator), reason in BLOCKED_IMAGE_ACCELERATORS.items() + }, + "notes": [ + "Always validate image×scass before writing global.json / submitting.", + "Outer Bohrium job should use c1_m2_cpu, never GPU.", + "For triclinic cells, avoid deepmd-kit:3.1.1; prefer 3.1.3.", + ], + } + + +def cmd_list_combos(args: argparse.Namespace) -> int: + data = list_combos(args.backend, args.prefer) + if args.format == "json": + print(json.dumps(data, indent=2, ensure_ascii=False)) + else: + rec = data["recommended"] + print(f"Backend: {rec['backend']} prefer={rec['prefer']}") + print(f" image: {rec['image']}") + print(f" scass_type: {rec['scass_type']}") + print(f" outer: {rec['outer_machine']}") + print("\nBlocked images:") + for k, v in BLOCKED_IMAGES.items(): + print(f" - {k}: {v}") + print("\nBlocked scass_type:") + for k, v in BLOCKED_SCASS.items(): + print(f" - {k}: {v}") + print("\nBlocked image x accelerator:") + for (image, accelerator), reason in BLOCKED_IMAGE_ACCELERATORS.items(): + print(f" - {image} x {accelerator}: {reason}") + return 0 + + +def cmd_check(args: argparse.Namespace) -> int: + ok, errors = check_combo(args.image, args.scass, triclinic=args.triclinic) + payload = {"ok": ok, "image": full_image(args.image), "scass_type": args.scass, "errors": errors} + if args.format == "json": + print(json.dumps(payload, indent=2, ensure_ascii=False)) + else: + if ok: + print(f"OK: {payload['image']} @ {args.scass}") + else: + print("FAIL:", file=sys.stderr) + for e in errors: + print(f" - {e}", file=sys.stderr) + return 0 if ok else 1 + + +def cmd_recommend(args: argparse.Namespace) -> int: + rec = recommend(args.backend, args.prefer) + if args.format == "json": + print(json.dumps(rec, indent=2, ensure_ascii=False)) + else: + print(f"image={rec['image']}") + print(f"scass_type={rec['scass_type']}") + print(f"outer_machine={rec['outer_machine']}") + return 0 + + +def main(argv: Optional[list[str]] = None) -> int: + parser = argparse.ArgumentParser( + description="Validate APEX Bohrium image × scass_type combinations", + ) + sub = parser.add_subparsers(dest="cmd", required=True) + + p_list = sub.add_parser("list-combos", help="List recommended and blocked combos") + p_list.add_argument("--backend", default="lammps", choices=["lammps", "abacus", "vasp"]) + p_list.add_argument("--prefer", default="cpu", choices=["cpu", "gpu"]) + p_list.add_argument("--format", choices=["json", "text"], default="text") + p_list.set_defaults(func=cmd_list_combos) + + p_check = sub.add_parser("check", help="Check one image × scass pair") + p_check.add_argument("--image", "-m", required=True) + p_check.add_argument("--scass", "-t", required=True, help="Inner scass_type") + p_check.add_argument("--triclinic", action="store_true", + help="Cell is triclinic / non-orthogonal") + p_check.add_argument("--format", choices=["json", "text"], default="text") + p_check.set_defaults(func=cmd_check) + + p_rec = sub.add_parser("recommend", help="Print a safe default combo") + p_rec.add_argument("--backend", default="lammps", choices=["lammps", "abacus", "vasp"]) + p_rec.add_argument("--prefer", default="cpu", choices=["cpu", "gpu"]) + p_rec.add_argument("--format", choices=["json", "text"], default="text") + p_rec.set_defaults(func=cmd_recommend) + + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/apex/skills/apex-flow/scripts/validate_inputs.py b/apex/skills/apex-flow/scripts/validate_inputs.py new file mode 100644 index 00000000..a6a4aa11 --- /dev/null +++ b/apex/skills/apex-flow/scripts/validate_inputs.py @@ -0,0 +1,737 @@ +#!/usr/bin/env python3 +""" +APEX Input Validator + +Validates param.json and global.json before submission. + +Usage: + python validate_inputs.py --param param.json --global global.json +""" + +import argparse +import glob +import json +import os +import re +import sys +from pathlib import Path + + +# Valid property types +VALID_PROPERTIES = { + "eos", "cohesive", "elastic", "surface", "vacancy", + "interstitial", "phonon", "gamma", "gamma_surface", + "decohesive", "finite_t_latt", "finite_t_elastic", + "gruneisen", "annealing", +} + +# LAMMPS-only properties +LAMMPS_ONLY_PROPERTIES = {"finite_t_elastic"} + +# Valid LAMMPS potential types +VALID_LAMMPS_TYPES = { + "deepmd", "mace", "nep", "gap", "snap", "rann", + "eam_alloy", "eam_fs", "meam", "meam_spline", +} + +# Valid backend types +VALID_BACKENDS = {"vasp", "abacus"} | VALID_LAMMPS_TYPES + +# Bare PATH-based vasp_std is unreliable in Bohrium VASP images. +_BARE_VASP_RUN_RE = re.compile( + r"^\s*mpirun\b.*\bvasp_std\s*$", re.IGNORECASE +) + + +def _resolve_under_base(path_str: str, base_dir: Path) -> Path: + path = Path(path_str).expanduser() + if not path.is_absolute(): + path = base_dir / path + return path + + +def _elements_from_poscar(poscar_path: Path) -> list: + """Best-effort VASP5 species line parse (no pymatgen required).""" + try: + lines = poscar_path.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError: + return [] + if len(lines) < 6: + return [] + # VASP 5+: line index 5 is element symbols when tokens start with a letter + tokens = lines[5].split() + if tokens and all(tok and tok[0].isalpha() for tok in tokens): + return tokens + return [] + + +def _iter_structure_poscars(param_config: dict, base_dir: Path) -> list: + """Resolve structures patterns to POSCAR/CONTCAR/STRU files.""" + found = [] + for pattern in param_config.get("structures", []) or []: + search = pattern if os.path.isabs(pattern) else str(base_dir / pattern) + matches = sorted(glob.glob(search)) + if not matches and not any(ch in pattern for ch in "*?[]"): + # literal directory that exists but was not glob-expanded + candidate = _resolve_under_base(pattern, base_dir) + if candidate.exists(): + matches = [str(candidate)] + for match in matches: + path = Path(match) + if path.is_file(): + found.append(path) + continue + if path.is_dir(): + for name in ("POSCAR", "CONTCAR", "STRU"): + nested = path / name + if nested.is_file(): + found.append(nested) + break + else: + found.extend(sorted(path.glob("conf_*/POSCAR"))) + return found + + +def collect_structure_elements(param_config: dict, base_dir: Path) -> set: + elements = set() + for poscar in _iter_structure_poscars(param_config, base_dir): + elements.update(_elements_from_poscar(poscar)) + return elements + + +def resolve_vasp_potcar_file(prefix: Path, potcar_entry: str) -> tuple: + """Resolve POTCAR file the same way APEX VASP.py opens it. + + Returns (path_or_None, hint_for_agent). + APEX uses ``os.path.join(prefix, potcars[element])`` as a file path. + """ + direct = (prefix / potcar_entry).expanduser() + if direct.is_file(): + return direct.resolve(), None + nested = (prefix / potcar_entry / "POTCAR").expanduser() + if nested.is_file(): + return None, ( + f"found potpaw-style file at '{nested}', but APEX opens " + f"join(potcar_prefix, potcars[el]) as a file — set potcars " + f"entry to '{potcar_entry}/POTCAR' (not '{potcar_entry}')" + ) + return None, None + + +def validate_vasp_potcars( + interaction: dict, base_dir: Path, required_elements: set = None +) -> tuple: + """Check potcar_prefix accessibility and required POTCAR files.""" + errors = [] + warnings = [] + + prefix_raw = interaction.get("potcar_prefix") + potcars = interaction.get("potcars") or {} + if not prefix_raw: + errors.append( + "VASP: 'potcar_prefix' missing — ask the user for a readable " + "POTCAR library path" + ) + return errors, warnings + if not potcars: + errors.append("VASP: 'potcars' mapping missing") + return errors, warnings + + prefix = _resolve_under_base(str(prefix_raw), base_dir) + # Absolute host libraries are invisible inside Bohrium/dflow containers. + if Path(str(prefix_raw)).expanduser().is_absolute(): + errors.append( + f"VASP: potcar_prefix is an absolute host path ({prefix_raw}). " + "Bohrium/dflow cannot see paths like /share/PAW_PBE after upload. " + "Copy POTCAR_ files into the job root and set " + "potcar_prefix to '.' (or re-run generate_config.py create)." + ) + return errors, warnings + if not prefix.exists(): + errors.append( + f"VASP: potcar_prefix not accessible (path does not exist): {prefix}" + ) + return errors, warnings + if not os.access(prefix, os.R_OK): + errors.append(f"VASP: potcar_prefix exists but is not readable: {prefix}") + return errors, warnings + if not prefix.is_dir(): + warnings.append( + f"VASP: potcar_prefix is not a directory ({prefix}); " + "continuing file checks anyway" + ) + + check_elements = set(required_elements or []) + check_elements.update(potcars.keys()) + if not check_elements: + warnings.append( + "VASP: no structure elements parsed and potcars is empty — " + "cannot verify POTCAR files" + ) + return errors, warnings + + missing = [] + for element in sorted(check_elements): + if element not in potcars: + missing.append(f"{element} (no potcars mapping)") + continue + entry = potcars[element] + path, hint = resolve_vasp_potcar_file(prefix, entry) + if path is None: + expected = prefix / entry + if hint: + missing.append(f"{element} → {hint}") + else: + missing.append(f"{element} → missing file {expected}") + + if missing: + errors.append( + "VASP: POTCAR location unusable or incomplete for required " + f"elements under '{prefix}'. Missing/invalid:\n - " + + "\n - ".join(missing) + + "\nAsk the user to confirm the correct POTCAR library path " + "and potpaw folder names." + ) + else: + mapped = ", ".join( + f"{el}:{potcars[el]}" for el in sorted(check_elements) if el in potcars + ) + warnings.append(f"VASP: POTCAR files OK ({mapped})") + + return errors, warnings + + +def _incar_has_kspacing(text: str) -> bool: + return bool(re.search(r"(?im)^\s*KSPACING\s*=", text)) + + +def _input_has_kspacing(text: str) -> bool: + return bool(re.search(r"(?im)^\s*kspacing\b", text)) + + +def validate_dft_kspacing( + param_config: dict, base_dir: Path, interaction: dict +) -> tuple: + """Require VASP KSPACING / ABACUS kspacing (or K_POINTS) before submit.""" + errors = [] + warnings = [] + int_type = interaction.get("type") + + if int_type == "vasp": + incar_name = interaction.get("incar") or "INCAR" + incar_path = _resolve_under_base(incar_name, base_dir) + cal_setting = {} + relaxation = param_config.get("relaxation") or {} + if isinstance(relaxation, dict): + cal_setting = relaxation.get("cal_setting") or {} + has_cal_kspacing = "kspacing" in cal_setting or "KSPACING" in cal_setting + if incar_path.is_file(): + text = incar_path.read_text(encoding="utf-8", errors="replace") + if not _incar_has_kspacing(text) and not has_cal_kspacing: + errors.append( + "VASP: INCAR (or relaxation.cal_setting) must set KSPACING; " + "APEX auto-generates KPOINTS from it and will fail without it" + ) + elif not re.search(r"(?im)^\s*KGAMMA\s*=", text): + warnings.append( + "VASP: KGAMMA not set in INCAR " + "(recommend True=Gamma or False=Monkhorst-Pack)" + ) + elif not has_cal_kspacing: + errors.append( + f"VASP: INCAR not found at '{incar_path}' and " + "cal_setting has no kspacing/KSPACING" + ) + + elif int_type == "abacus": + input_name = interaction.get("incar") or "INPUT" + input_path = _resolve_under_base(input_name, base_dir) + cal_setting = {} + relaxation = param_config.get("relaxation") or {} + if isinstance(relaxation, dict): + cal_setting = relaxation.get("cal_setting") or {} + has_k_points = "K_POINTS" in cal_setting + if input_path.is_file(): + text = input_path.read_text(encoding="utf-8", errors="replace") + if not _input_has_kspacing(text) and not has_k_points: + errors.append( + "ABACUS: INPUT must set kspacing (1/Bohr), or set " + "cal_setting.K_POINTS like [nx,ny,nz,0,0,0]; " + "APEX writes KPT from these" + ) + elif not has_k_points: + warnings.append( + f"ABACUS: INPUT not found at '{input_path}'; ensure " + "kspacing or cal_setting.K_POINTS is set before submit" + ) + + return errors, warnings + + +def validate_vasp_run_command(global_config: dict) -> tuple: + """Enforce Bohrium-safe VASP run_command when vasp_run_command is set.""" + errors = [] + warnings = [] + cmd = global_config.get("vasp_run_command") + if not cmd: + return errors, warnings + if not isinstance(cmd, str): + errors.append("'vasp_run_command' must be a string") + return errors, warnings + + bare = _BARE_VASP_RUN_RE.match(cmd) + missing_setvars = "setvars.sh" not in cmd + missing_ulimit = "ulimit" not in cmd + missing_abs = "/opt/vasp" not in cmd and "vasp.5" not in cmd + + if bare or (missing_setvars and missing_abs): + errors.append( + "VASP: vasp_run_command must use the Bohrium template " + '(source /opt/intel/oneapi/setvars.sh && ulimit -s unlimited && ' + "mpirun -n /opt/vasp.5.4.4/bin/vasp_std); " + "bare 'mpirun -n N vasp_std' is not allowed" + ) + else: + if missing_setvars: + errors.append( + "VASP: vasp_run_command must source " + "/opt/intel/oneapi/setvars.sh" + ) + if missing_ulimit: + warnings.append( + "VASP: vasp_run_command should include " + "'ulimit -s unlimited'" + ) + if missing_abs: + warnings.append( + "VASP: prefer absolute binary " + "/opt/vasp.5.4.4/bin/vasp_std in vasp_run_command" + ) + + return errors, warnings + + +def validate_global(global_config: dict) -> list: + """Validate global.json configuration.""" + errors = [] + warnings = [] + + # generate_config.py writes the current, top-level APEX schema. Keep + # accepting the older nested-machine schema for existing user configs. + is_current_schema = any( + key in global_config + for key in ("dflow_host", "context_type", "bohrium_config", "program_id") + ) + + if is_current_schema: + for key in ("batch_type", "context_type"): + if not global_config.get(key): + errors.append(f"Missing '{key}' in global.json") + + program_id = global_config.get("program_id") + if not isinstance(program_id, int) or isinstance(program_id, bool): + errors.append( + "'program_id' must be an unquoted JSON integer, not a string; " + "generate global.json with " + "generate_config.py" + ) + elif program_id <= 0: + errors.append("'program_id' must be a positive integer") + + bohrium_config = global_config.get("bohrium_config") + if not isinstance(bohrium_config, dict): + errors.append("Missing 'bohrium_config' section in global.json") + else: + project_id = bohrium_config.get("project_id") + if not isinstance(project_id, int) or isinstance(project_id, bool): + errors.append( + "'bohrium_config.project_id' must be a JSON integer, " + "not a quoted string" + ) + elif project_id <= 0: + errors.append( + "'bohrium_config.project_id' must be a positive integer" + ) + elif isinstance(program_id, int) and not isinstance(program_id, bool): + if project_id != program_id: + errors.append( + "'program_id' and 'bohrium_config.project_id' must match" + ) + + ticket = bohrium_config.get("ticket") + if not isinstance(ticket, str) or not ticket.strip(): + errors.append( + "Missing non-empty 'bohrium_config.ticket'; regenerate " + "global.json with generate_config.py" + ) + + machine = global_config.get("machine") + if isinstance(machine, dict): + remote_profile = machine.get("remote_profile") + if isinstance(remote_profile, dict) and "program_id" in remote_profile: + nested_id = remote_profile["program_id"] + if not isinstance(nested_id, int) or isinstance(nested_id, bool): + errors.append( + "'machine.remote_profile.program_id' must be a JSON " + "integer, not a quoted string" + ) + elif nested_id != program_id: + errors.append( + "'machine.remote_profile.program_id' must match " + "'program_id'" + ) + + if not global_config.get("scass_type"): + errors.append("Missing 'scass_type' in global.json") + + run_commands = ( + "lammps_run_command", "abacus_run_command", "vasp_run_command" + ) + if not any(global_config.get(key) for key in run_commands): + errors.append( + "Missing calculator run command in global.json " + "(expected one of lammps_run_command, abacus_run_command, " + "vasp_run_command)" + ) + + rc_errors, rc_warnings = validate_vasp_run_command(global_config) + errors.extend(rc_errors) + warnings.extend(rc_warnings) + else: + # Legacy nested-machine schema. + if "machine" not in global_config: + errors.append("Missing 'machine' section in global.json") + else: + machine = global_config["machine"] + if "batch_type" not in machine: + errors.append("Missing 'machine.batch_type'") + + if "resources" not in global_config: + warnings.append("No 'resources' section - will use defaults") + + if "run_command" not in global_config: + errors.append("Missing 'run_command' in global.json") + + return errors, warnings + + +def validate_interaction(interaction: dict) -> list: + """Validate interaction configuration.""" + errors = [] + warnings = [] + + if "type" not in interaction: + errors.append("Missing 'interaction.type'") + return errors, warnings + + int_type = interaction["type"] + + if int_type not in VALID_BACKENDS: + errors.append(f"Unknown interaction type: '{int_type}'") + return errors, warnings + + # LAMMPS-specific checks + if int_type in VALID_LAMMPS_TYPES: + if "model" not in interaction: + errors.append(f"LAMMPS potential '{int_type}' requires 'model' field") + if "type_map" not in interaction: + errors.append(f"LAMMPS potential '{int_type}' requires 'type_map' field") + + # ABACUS-specific checks + elif int_type == "abacus": + if "potcars" not in interaction: + errors.append("ABACUS requires 'potcars' (pseudopotential mapping)") + if "orb_files" not in interaction: + warnings.append("ABACUS: 'orb_files' not specified (required for LCAO)") + + # VASP-specific checks (filesystem checks run in validate_vasp_potcars) + elif int_type == "vasp": + if "potcars" not in interaction: + errors.append("VASP requires 'potcars' mapping") + if "potcar_prefix" not in interaction: + errors.append("VASP requires 'potcar_prefix' (POTCAR library path)") + + return errors, warnings + + +def validate_properties(properties: list, interaction_type: str) -> list: + """Validate property configurations.""" + errors = [] + warnings = [] + + if not properties: + errors.append("No properties defined") + return errors, warnings + + for i, prop in enumerate(properties): + prefix = f"properties[{i}]" + + if "type" not in prop: + errors.append(f"{prefix}: missing 'type' field") + continue + + prop_type = prop["type"] + if prop_type not in VALID_PROPERTIES: + errors.append(f"{prefix}: unknown property type '{prop_type}'") + continue + + # Check LAMMPS-only constraint + if prop_type in LAMMPS_ONLY_PROPERTIES: + if interaction_type in ("vasp", "abacus"): + errors.append( + f"{prefix}: '{prop_type}' is LAMMPS-only, " + f"but backend is '{interaction_type}'" + ) + + # Property-specific validation + if prop_type == "eos": + for key in ("vol_start", "vol_end", "vol_step"): + if key not in prop: + errors.append(f"{prefix}: EOS requires '{key}'") + + elif prop_type == "cohesive": + for key in ("latt_start", "latt_end", "latt_step"): + if key not in prop: + errors.append(f"{prefix}: cohesive requires '{key}'") + + elif prop_type == "surface": + for key in ("min_slab_size", "min_vacuum_size"): + if key not in prop: + errors.append(f"{prefix}: surface requires '{key}'") + + elif prop_type == "decohesive": + if "miller_index" not in prop: + errors.append(f"{prefix}: decohesive requires 'miller_index'") + if "min_slab_size" not in prop: + errors.append(f"{prefix}: decohesive requires 'min_slab_size'") + + elif prop_type == "gruneisen": + mesh = prop.get("MESH") + if mesh is None: + mesh = [20, 20, 20] + if ( + not isinstance(mesh, (list, tuple)) + or len(mesh) != 3 + or any( + not isinstance(value, int) + or isinstance(value, bool) + or value <= 0 + for value in mesh + ) + ): + errors.append( + f"{prefix}: gruneisen 'MESH' must contain 3 positive integers" + ) + if "volume_strains" not in prop: + errors.append(f"{prefix}: gruneisen requires 'volume_strains'") + else: + vs = prop["volume_strains"] + if 0.0 not in vs: + errors.append(f"{prefix}: gruneisen 'volume_strains' must include 0.0") + if len(vs) < 3: + errors.append(f"{prefix}: gruneisen needs ≥3 volume_strains points") + if "temperatures" not in prop: + errors.append(f"{prefix}: gruneisen requires 'temperatures'") + + elif prop_type == "finite_t_elastic": + cal_setting = prop.get("cal_setting", {}) + method = cal_setting.get("method", "paired_langevin") + if method != "paired_langevin": + errors.append( + f"{prefix}: finite_t_elastic only supports method='paired_langevin'" + ) + + if prop_type in {"gamma", "gamma_surface"}: + plane = prop.get("plane_miller") + direction = prop.get("slip_direction") + if not plane or not direction: + errors.append( + f"{prefix}: {prop_type} requires plane_miller and slip_direction" + ) + elif len(plane) != len(direction): + errors.append( + f"{prefix}: plane_miller and slip_direction dimensions differ" + ) + elif sum(p * d for p, d in zip(plane, direction)) != 0: + errors.append( + f"{prefix}: slip_direction must lie on plane_miller" + ) + + if prop_type == "gamma_surface": + for key in ("n_steps_x", "n_steps_y"): + value = prop.get(key, prop.get("n_steps", 10)) + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + errors.append(f"{prefix}: {key} must be a positive integer") + closed_loop = prop.get("closed_loop", False) + if not isinstance(closed_loop, bool): + errors.append(f"{prefix}: closed_loop must be a boolean") + if closed_loop is True and ( + prop.get("slip_length") is not None + or prop.get("slip_length_y") is not None + ): + errors.append( + f"{prefix}: closed_loop cannot be combined with " + "slip_length/slip_length_y" + ) + + return errors, warnings + + +def validate_structures(param_config: dict, base_dir: Path) -> list: + """Validate structure paths.""" + errors = [] + warnings = [] + + structures = param_config.get("structures", []) + if not structures: + errors.append("No 'structures' defined in param.json") + return errors, warnings + + for s in structures: + struct_path = base_dir / s + if not struct_path.exists(): + warnings.append(f"Structure path not found: {s} (may exist in container)") + + return errors, warnings + + +def main(): + parser = argparse.ArgumentParser( + description="Validate APEX configuration files" + ) + parser.add_argument("--param", "-p", required=True, + help="Path to param.json") + parser.add_argument("--global", "-g", dest="global_json", + help="Path to global.json") + parser.add_argument("--strict", action="store_true", + help="Treat warnings as errors") + + args = parser.parse_args() + + all_errors = [] + all_warnings = [] + global_config = None + + # Load param.json + param_path = Path(args.param) + if not param_path.exists(): + print(f"ERROR: param.json not found: {param_path}", file=sys.stderr) + sys.exit(1) + + with open(param_path) as f: + param_config = json.load(f) + + # Validate global.json if provided + if args.global_json: + global_path = Path(args.global_json) + if not global_path.exists(): + all_errors.append(f"global.json not found: {global_path}") + else: + with open(global_path) as f: + global_config = json.load(f) + errors, warnings = validate_global(global_config) + all_errors.extend(errors) + all_warnings.extend(warnings) + + # Validate interaction + interaction = param_config.get("interaction", {}) + base_dir = param_path.parent + if not interaction: + all_errors.append("Missing 'interaction' in param.json") + else: + errors, warnings = validate_interaction(interaction) + all_errors.extend(errors) + all_warnings.extend(warnings) + + # Validate properties + properties = param_config.get("properties", []) + interaction_type = interaction.get("type", "unknown") + errors, warnings = validate_properties(properties, interaction_type) + all_errors.extend(errors) + all_warnings.extend(warnings) + + # Validate structures + errors, warnings = validate_structures(param_config, base_dir) + all_errors.extend(errors) + all_warnings.extend(warnings) + + # VASP POTCAR filesystem check (prefix + per-element files) + if interaction.get("type") == "vasp": + required_elements = collect_structure_elements(param_config, base_dir) + errors, warnings = validate_vasp_potcars( + interaction, base_dir, required_elements=required_elements + ) + all_errors.extend(errors) + # "POTCAR files OK" is informational; keep as warning only when useful + for w in warnings: + if w.startswith("VASP: POTCAR files OK"): + print(f" {w}") + else: + all_warnings.append(w) + + # DFT k-spacing (VASP KSPACING / ABACUS kspacing) + if interaction.get("type") in ("vasp", "abacus"): + errors, warnings = validate_dft_kspacing( + param_config, base_dir, interaction + ) + all_errors.extend(errors) + all_warnings.extend(warnings) + + # VASP image is license-gated: require an explicit user-provided image. + if interaction.get("type") == "vasp" and global_config is not None: + image = global_config.get("vasp_image_name") + if not isinstance(image, str) or not image.strip(): + all_errors.append( + "VASP: missing 'vasp_image_name' in global.json. " + "VASP is commercial — do not invent a default image; " + "ask the user to confirm a license and provide the image" + ) + elif any( + token in image.lower() + for token in ("user-provided", "your_", "example", "<", "todo") + ): + all_errors.append( + "VASP: 'vasp_image_name' looks like a placeholder. " + "Replace it with a user-confirmed licensed VASP image" + ) + + # Report + if all_warnings: + print("WARNINGS:", file=sys.stderr) + for w in all_warnings: + print(f" ⚠ {w}", file=sys.stderr) + + if all_errors: + print("\nERRORS:", file=sys.stderr) + for e in all_errors: + print(f" ✗ {e}", file=sys.stderr) + print(f"\nValidation FAILED: {len(all_errors)} error(s)", file=sys.stderr) + sys.exit(1) + elif args.strict and all_warnings: + print(f"\nValidation FAILED (strict mode): {len(all_warnings)} warning(s)", + file=sys.stderr) + sys.exit(1) + else: + print(f"✓ Validation PASSED ({len(all_warnings)} warnings)") + # Print summary + print(f" Backend: {interaction_type}") + print(f" Properties: {[p.get('type') for p in properties]}") + print(f" Structures: {param_config.get('structures', [])}") + if global_config and "program_id" in global_config: + program_id = global_config["program_id"] + project_id = global_config.get( + "bohrium_config", {} + ).get("project_id") + print(" Hard project ID type check:") + print( + f" program_id={program_id!r} " + f"type={type(program_id).__name__}" + ) + print( + f" bohrium_config.project_id={project_id!r} " + f"type={type(project_id).__name__}" + ) + + +if __name__ == "__main__": + main() diff --git a/apex/submit.py b/apex/submit.py index 09a2116f..c3977736 100644 --- a/apex/submit.py +++ b/apex/submit.py @@ -31,6 +31,12 @@ ) +LAMMPS_PHONON_IMAGE = ( + "registry.dp.tech/dptech/dp/native/prod-397637/" + "deepmd-kit-phonolammps:3.1.3" +) + + def validate_submit_paths(parameter_dicts: List[dict]) -> None: """ dflow rejects structure path patterns containing '.'. @@ -54,6 +60,22 @@ def validate_submit_paths(parameter_dicts: List[dict]) -> None: ) +def _select_run_image(calculator: str, props_param: dict, run_image: str) -> str: + if calculator == "lammps" and props_param and any( + prop.get("type") in {"phonon", "gruneisen"} + for prop in props_param.get("properties", []) + ): + if run_image != LAMMPS_PHONON_IMAGE: + logging.warning( + "LAMMPS phonon/Gruneisen requires the validated phonoLAMMPS image; " + "overriding run image %r with %r.", + run_image, + LAMMPS_PHONON_IMAGE, + ) + return LAMMPS_PHONON_IMAGE + return run_image + + def _with_lammps_retry_env(run_command: str, wf_config: Config) -> str: if not run_command: return run_command @@ -563,6 +585,7 @@ def submit_workflow( run_image = wf_config.basic_config_dict[f"{calculator}_image_name"] if not run_image: run_image = wf_config.basic_config_dict["run_image_name"] + run_image = _select_run_image(calculator, props_param, run_image) run_command = wf_config.basic_config_dict[f"{calculator}_run_command"] if not run_command: run_command = wf_config.basic_config_dict["run_command"] diff --git a/apex/utils.py b/apex/utils.py index a59a6185..3cce602b 100644 --- a/apex/utils.py +++ b/apex/utils.py @@ -178,7 +178,7 @@ def handle_prop_suffix(parameter: dict): return None, None elif parameter.get("skip", False): return None, None - if 'init_from_suffix' and 'output_suffix' in parameter: + if 'init_from_suffix' in parameter and 'output_suffix' in parameter: do_refine = True suffix = parameter['output_suffix'] elif 'reproduce' in parameter and parameter['reproduce']: diff --git a/docs/Dockerfile.deepmd-phonolammps b/docs/Dockerfile.deepmd-phonolammps new file mode 100644 index 00000000..7915a4cb --- /dev/null +++ b/docs/Dockerfile.deepmd-phonolammps @@ -0,0 +1,11 @@ +FROM registry.bohrium.dp.tech/dptech/deepmd-kit:3.1.3 + +ARG PHONOLAMMPS_VERSION=0.10.1 +ARG PIP_INDEX_URL=https://pypi.org/simple + +ENV PATH="/opt/deepmd-kit-3.1.3/bin:${PATH}" + +RUN python -m pip install --no-cache-dir --proxy "" \ + --index-url "${PIP_INDEX_URL}" \ + "phonoLAMMPS==${PHONOLAMMPS_VERSION}" \ + && python -c "from importlib.metadata import version; import phonolammps, phonopy; from lammps import lammps; engine = lammps(cmdargs=['-log', 'none', '-screen', 'none']); engine.close(); print('phonoLAMMPS', version('phonoLAMMPS'), 'verified')" diff --git a/examples/abacus/global_bohrium.json b/examples/abacus/global_bohrium.json index ee084e13..5009a75a 100644 --- a/examples/abacus/global_bohrium.json +++ b/examples/abacus/global_bohrium.json @@ -4,7 +4,7 @@ "email": "YOUR_EMAIL", "password": "YOUR_PASSWD", "program_id": 1234, - "apex_image_name":"registry.dp.tech/dptech/dp/native/prod-397637/apex:1.3.0", + "apex_image_name":"registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post", "abacus_image_name":"registry.dp.tech/dptech/abacus:3.2.3", "abacus_run_command":"mpirun -n 32 abacus", "batch_type": "Bohrium", diff --git a/examples/annealing/global.json b/examples/annealing/global.json index 704aa2fa..9caab247 100644 --- a/examples/annealing/global.json +++ b/examples/annealing/global.json @@ -1,5 +1,5 @@ { - "lammps_image_name": "registry.dp.tech/dptech/deepmd-kit:3.1.1", + "lammps_image_name": "registry.dp.tech/dptech/dp/native/prod-397637/deepmd-kit-phonolammps:3.1.3", "lammps_run_command": "lmp -in in.lammps -log log.lammps -screen outlog", "group_size": 1, "pool_size": 1, diff --git a/examples/finite_t_elastic_fcc_Al/global.json b/examples/finite_t_elastic_fcc_Al/global.json index 704aa2fa..9caab247 100644 --- a/examples/finite_t_elastic_fcc_Al/global.json +++ b/examples/finite_t_elastic_fcc_Al/global.json @@ -1,5 +1,5 @@ { - "lammps_image_name": "registry.dp.tech/dptech/deepmd-kit:3.1.1", + "lammps_image_name": "registry.dp.tech/dptech/dp/native/prod-397637/deepmd-kit-phonolammps:3.1.3", "lammps_run_command": "lmp -in in.lammps -log log.lammps -screen outlog", "group_size": 1, "pool_size": 1, diff --git a/examples/gamma/global_bohrium.json b/examples/gamma/global_bohrium.json index 35e6b47c..897db59b 100644 --- a/examples/gamma/global_bohrium.json +++ b/examples/gamma/global_bohrium.json @@ -1,5 +1,5 @@ { - "lammps_image_name": "registry.dp.tech/dptech/deepmd-kit:3.1.1", + "lammps_image_name": "registry.dp.tech/dptech/dp/native/prod-397637/deepmd-kit-phonolammps:3.1.3", "lammps_run_command":"lmp -in in.lammps -log log.lammps -screen outlog", "scass_type":"c8_m31_1 * NVIDIA T4" } diff --git a/examples/gamma/param_props_gammasurface.json b/examples/gamma/param_props_gammasurface.json index e268aa47..de8c7eaa 100644 --- a/examples/gamma/param_props_gammasurface.json +++ b/examples/gamma/param_props_gammasurface.json @@ -21,6 +21,7 @@ "slip_direction": [1, -1, -1], "supercell_size": [1, 1, 20], "vacuum_size": 15, + "closed_loop": false, "add_fix": ["true", "true", "false"], "n_steps_x": 20, "n_steps_y": 20 diff --git a/examples/lammps/apex_lammps_tutorial.md b/examples/lammps/apex_lammps_tutorial.md index 469b4641..13fd4dcc 100755 --- a/examples/lammps/apex_lammps_tutorial.md +++ b/examples/lammps/apex_lammps_tutorial.md @@ -115,8 +115,8 @@ Bohrium platform provides pre-configured environments, automated scheduling, and "email": "your_email@example.com", "password": "your_password", "program_id": 12345, - "apex_image_name": "registry.dp.tech/dptech/dp/native/prod-397637/apex:1.3.0", - "lammps_image_name": "registry.dp.tech/dptech/prod-11045/deepmdkit-phonolammps:3.1.1", + "apex_image_name": "registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post", + "lammps_image_name": "registry.dp.tech/dptech/dp/native/prod-397637/deepmd-kit-phonolammps:3.1.3", "lammps_run_command": "lmp -in in.lammps", "scass_type": "c8_m31_1 * NVIDIA T4" } diff --git a/examples/lammps/lammps_tutorial1_quick_start/lammps_example1.1_Mo/global_bohrium.json b/examples/lammps/lammps_tutorial1_quick_start/lammps_example1.1_Mo/global_bohrium.json index 06aa61a6..ddd55f42 100755 --- a/examples/lammps/lammps_tutorial1_quick_start/lammps_example1.1_Mo/global_bohrium.json +++ b/examples/lammps/lammps_tutorial1_quick_start/lammps_example1.1_Mo/global_bohrium.json @@ -6,8 +6,8 @@ "email": "YOUREMAIL@abc.com", "password": "password", "program_id": 1234, - "apex_image_name":"registry.dp.tech/dptech/dp/native/prod-397637/apex:1.3.0", - "lammps_image_name": "registry.dp.tech/dptech/deepmd-kit:3.1.1", + "apex_image_name":"registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post", + "lammps_image_name": "registry.dp.tech/dptech/dp/native/prod-397637/deepmd-kit-phonolammps:3.1.3", "lammps_run_command":"lmp -in in.lammps", "scass_type":"c8_m31_1 * NVIDIA T4" } diff --git a/examples/lammps/lammps_tutorial2_submission_methods/lammps_example2.1_bohrium/global_bohrium.json b/examples/lammps/lammps_tutorial2_submission_methods/lammps_example2.1_bohrium/global_bohrium.json index 06aa61a6..ddd55f42 100755 --- a/examples/lammps/lammps_tutorial2_submission_methods/lammps_example2.1_bohrium/global_bohrium.json +++ b/examples/lammps/lammps_tutorial2_submission_methods/lammps_example2.1_bohrium/global_bohrium.json @@ -6,8 +6,8 @@ "email": "YOUREMAIL@abc.com", "password": "password", "program_id": 1234, - "apex_image_name":"registry.dp.tech/dptech/dp/native/prod-397637/apex:1.3.0", - "lammps_image_name": "registry.dp.tech/dptech/deepmd-kit:3.1.1", + "apex_image_name":"registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post", + "lammps_image_name": "registry.dp.tech/dptech/dp/native/prod-397637/deepmd-kit-phonolammps:3.1.3", "lammps_run_command":"lmp -in in.lammps", "scass_type":"c8_m31_1 * NVIDIA T4" } diff --git a/examples/vasp/global_bohrium.json b/examples/vasp/global_bohrium.json index c551a281..b5a0804e 100755 --- a/examples/vasp/global_bohrium.json +++ b/examples/vasp/global_bohrium.json @@ -5,7 +5,7 @@ "password": "YOUR_PASSWD", "program_id": 1234, "group_size": 1, - "apex_image_name":"registry.dp.tech/dptech/dp/native/prod-397637/apex:1.3.0", + "apex_image_name":"registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post", "vasp_image_name":"registry.dp.tech/dptech/vasp:5.4.4-dflow", "vasp_run_command":"bash -c \"source /opt/intel/oneapi/setvars.sh && ulimit -s unlimited && mpirun -n 32 /opt/vasp.5.4.4/bin/vasp_std \"", "batch_type": "Bohrium", diff --git a/setup.py b/setup.py index 5054f88b..c33ed6b1 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name="apex-flow", - version="1.3.0.post1", + version="1.3.0.post5", author="Zhuoyuan Li, Tongqi Wen", author_email="zhuoyli@outlook.com", description="Alloy Properties EXplorer using simulations", @@ -13,6 +13,18 @@ long_description_content_type="text/markdown", url="https://github.com/deepmodeling/APEX.git", packages=setuptools.find_packages(), + package_data={ + "apex": [ + "skills/apex-flow/SKILL.md", + "skills/apex-flow/plugin.yaml", + "skills/apex-flow/data/*", + "skills/apex-flow/models/*", + "skills/apex-flow/models/*/*", + "skills/apex-flow/reference/*", + "skills/apex-flow/scripts/*", + ], + }, + include_package_data=True, install_requires=[ "numpy<2.0.0", "pydflow>=1.7.83", diff --git a/tests/test_decohesive.py b/tests/test_decohesive.py index 09b755c8..7964c824 100644 --- a/tests/test_decohesive.py +++ b/tests/test_decohesive.py @@ -6,7 +6,7 @@ import numpy as np from monty.serialization import loadfn -from pymatgen.core import Structure +from pymatgen.core import Lattice, Structure from pymatgen.core.surface import SlabGenerator sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) @@ -99,9 +99,74 @@ def test_make_confs_0(self): # slb = sl.get_slab() st2 = Structure(sl.lattice, sl.species, sl.frac_coords) self.assertEqual(len(st1), len(st2)) - + + def test_make_confs_with_c_vector_not_aligned_to_z(self): + bcc_mo = Structure( + Lattice.cubic(3.16), + ["Mo", "Mo"], + [[0, 0, 0], [0.5, 0.5, 0.5]], + ) + bcc_mo.to(filename=os.path.join(self.equi_path, "CONTCAR"), fmt="poscar") + parameter = { + "type": "decohesive", + "min_slab_size": 15, + "max_vacuum_size": 1, + "vacuum_size_step": 1, + "miller_index": [1, 0, 0], + "cal_type": "static", + } + + tasks = Decohesive(parameter).make_confs(self.target_path, self.equi_path) + zero_vacuum = Structure.from_file(os.path.join(tasks[0], "POSCAR.tmp")) + one_angstrom_vacuum = Structure.from_file(os.path.join(tasks[1], "POSCAR.tmp")) + + self.assertTrue(np.isfinite(zero_vacuum.lattice.matrix).all()) + self.assertTrue(np.isfinite(one_angstrom_vacuum.lattice.matrix).all()) + self.assertAlmostEqual( + np.linalg.norm(one_angstrom_vacuum.lattice.matrix[2]) + - np.linalg.norm(zero_vacuum.lattice.matrix[2]), + 1.0, + places=8, + ) + + def test_make_confs_bcc_110_is_finite_and_right_handed(self): + bcc_mo = Structure( + Lattice.cubic(3.16), + ["Mo", "Mo"], + [[0, 0, 0], [0.5, 0.5, 0.5]], + ) + bcc_mo.to(filename=os.path.join(self.equi_path, "CONTCAR"), fmt="poscar") + parameter = { + "type": "decohesive", + "min_slab_size": 25, + "max_vacuum_size": 2, + "vacuum_size_step": 1, + "miller_index": [1, 1, 0], + "cal_type": "static", + } + + tasks = Decohesive(parameter).make_confs(self.target_path, self.equi_path) + for task in tasks: + slab = Structure.from_file(os.path.join(task, "POSCAR.tmp")) + self.assertTrue(np.isfinite(slab.lattice.matrix).all()) + self.assertGreater(np.linalg.det(slab.lattice.matrix), 0.0) + + def test_ensure_right_handed_cell_swaps_ab(self): + from apex.core.property.Decohesive import _ensure_right_handed_cell + + left_handed = np.array( + [[0.0, 3.0, 0.0], [3.0, 0.0, 0.0], [0.0, 0.0, 3.0]], dtype=float + ) + self.assertLess(np.linalg.det(left_handed), 0.0) + fixed, frac = _ensure_right_handed_cell( + left_handed, [np.array([0.1, 0.2, 0.3])] + ) + self.assertGreater(np.linalg.det(fixed), 0.0) + np.testing.assert_allclose(frac[0], [0.2, 0.1, 0.3]) + def __gen_slab_pmg(self, structure: Structure, plane_miller, slab_size, vacuum_size) -> Structure: + from apex.core.property.Decohesive import _ensure_right_handed_cell # Generate slab via Pymatgen slabGen = SlabGenerator(structure, miller_index=plane_miller, @@ -121,19 +186,20 @@ def __gen_slab_pmg(self, structure: Structure, sorted_species.append(species) # add vacuum layer to the slab with height unit of angstrom a_vec, b_vec, c_vec = slab.lattice.matrix - slab_height = abs(c_vec[2]) + slab_height = np.linalg.norm(c_vec) elong_scale = 1 + (abs(vacuum_size) / slab_height) - new_lattice = [a_vec, b_vec, elong_scale * c_vec] + new_lattice = np.array([a_vec, b_vec, elong_scale * c_vec], dtype=float) new_frac_coords = [] for frac in sorted_frac_coords: - coord = frac.copy() + coord = np.array(frac, dtype=float) coord[2] = coord[2] / elong_scale new_frac_coords.append(coord) - slab_new = Structure( - lattice=np.array(new_lattice), coords=new_frac_coords, species=sorted_species + new_lattice, new_frac_coords = _ensure_right_handed_cell( + new_lattice, new_frac_coords + ) + return Structure( + lattice=new_lattice, coords=new_frac_coords, species=sorted_species ) - - return slab_new class TestDecohesiveReport(unittest.TestCase): def setUp(self): diff --git a/tests/test_gamma_surface.py b/tests/test_gamma_surface.py index 4e302c58..4dd345ad 100644 --- a/tests/test_gamma_surface.py +++ b/tests/test_gamma_surface.py @@ -9,9 +9,12 @@ import numpy as np import pytest from monty.serialization import dumpfn, loadfn +from pymatgen.core import Lattice, Structure from apex.core.calculator.Lammps import Lammps from apex.core.property.GammaSurface import GammaSurface +from apex.reporter.property_report import GammaSurfaceReport +from apex.utils import handle_prop_suffix sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) __package__ = "tests" @@ -65,6 +68,12 @@ def test_make_confs_bcc(self): os.path.join(self.source_path, "CONTCAR_Mo_bcc"), os.path.join(self.equi_path, "CONTCAR"), ) + with self.assertRaisesRegex(RuntimeError, "requires relaxation"): + self.gamma_surface.make_confs(self.target_path, self.equi_path) + dumpfn( + {"energies": [-1.0], "atom_numbs": [1]}, + os.path.join(self.equi_path, "result.json"), + ) task_list = self.gamma_surface.make_confs(self.target_path, self.equi_path) dfm_dirs = glob.glob(os.path.join(self.target_path, "task.*")) self.assertEqual(len(dfm_dirs), (self.gamma_surface.n_steps_x + 1) * (self.gamma_surface.n_steps_y + 1)) @@ -76,6 +85,8 @@ def test_make_confs_bcc(self): self.assertTrue(os.path.isfile(os.path.join(ii, "miller.json"))) self.assertTrue(os.path.isfile(os.path.join(ii, "displacement.json"))) disp = loadfn(os.path.join(ii, "displacement.json")) + self.assertEqual(len(disp["disp_cart"]), 3) + self.assertFalse(os.path.exists(os.path.join(ii, "slip_vector_x.json"))) pairs.add((disp["frac_x"], disp["frac_y"])) self.assertIn((0.0, 0.0), pairs) @@ -252,6 +263,115 @@ def test_gamma_surface_resolve_slip_length_numeric_vector_and_invalid(): resolve("bad", 3.0, 4.0, 5.0) +def test_gamma_surface_closed_loop_is_opt_in(): + legacy = GammaSurface({"type": "gamma_surface"}) + closed = GammaSurface({"type": "gamma_surface", "closed_loop": True}) + + assert legacy.closed_loop is False + assert legacy.task_param()["closed_loop"] is False + assert closed.closed_loop is True + with pytest.raises(ValueError, match="must be a boolean"): + GammaSurface({"type": "gamma_surface", "closed_loop": "true"}) + + +def test_gamma_surface_finds_and_validates_oblique_periodic_vectors(): + slab = Structure( + lattice=Lattice( + [ + [0.02, 10.0, 0.0], + [-5.0, 5.0, 0.0], + [0.0, 0.0, 20.0], + ] + ), + species=["Al", "Mo"], + coords=[[0.2, 0.3, 0.25], [0.7, 0.8, 0.75]], + ) + find_vector = GammaSurface._GammaSurface__find_periodic_slip_vector + find_independent = ( + GammaSurface._GammaSurface__find_independent_periodic_slip_vector + ) + validate = GammaSurface._GammaSurface__validate_closed_loop + + vector_x = find_vector(slab, axis=0) + vector_y = find_independent(slab, vector_x) + + np.testing.assert_allclose( + vector_x, slab.lattice.matrix[0] - 2 * slab.lattice.matrix[1] + ) + np.testing.assert_allclose(vector_y, slab.lattice.matrix[1]) + validate(slab, vector_x, vector_y) + + +def test_gamma_surface_closed_loop_generates_periodic_corner_tasks(tmp_path): + root = Path(__file__).resolve().parent + equi_path = tmp_path / "relaxation" / "relax_task" + work_path = tmp_path / "gamma_surface_00" + equi_path.mkdir(parents=True) + shutil.copy(root / "equi/vasp/CONTCAR_Mo_bcc", equi_path / "CONTCAR") + dumpfn({"energies": [-1.0], "atom_numbs": [1]}, equi_path / "result.json") + prop = GammaSurface( + { + "type": "gamma_surface", + "plane_miller": [0, 0, 1], + "slip_direction": [1, 0, 0], + "supercell_size": [1, 1, 4], + "closed_loop": True, + "n_steps_x": 1, + "n_steps_y": 1, + } + ) + + tasks = prop.make_confs(str(work_path), str(equi_path)) + assert len(tasks) == 4 + structures = {} + for task in tasks: + disp = loadfn(Path(task) / "displacement.json") + structures[(disp["frac_x"], disp["frac_y"])] = Structure.from_file( + Path(task) / "POSCAR" + ) + assert (Path(task) / "slip_vector_x.json").is_file() + assert (Path(task) / "slip_vector_y.json").is_file() + + reference = structures[(0.0, 0.0)] + for corner_key in ((1.0, 0.0), (0.0, 1.0), (1.0, 1.0)): + corner = structures[corner_key] + delta = corner.frac_coords - reference.frac_coords + delta -= np.rint(delta) + np.testing.assert_allclose(delta, 0.0, atol=1e-8) + + +def test_gamma_surface_rejects_invalid_steps_and_mixed_closed_loop(tmp_path): + equi_path = tmp_path / "relax" + equi_path.mkdir() + Structure( + Lattice.cubic(3.5), ["Al"], [[0.0, 0.0, 0.0]] + ).to(equi_path / "CONTCAR", "POSCAR") + dumpfn({"energies": [-1.0], "atom_numbs": [1]}, equi_path / "result.json") + + invalid_steps = GammaSurface( + { + "type": "gamma_surface", + "plane_miller": [0, 0, 1], + "slip_direction": [1, 0, 0], + "n_steps_x": 0, + } + ) + with pytest.raises(RuntimeError, match="positive integers"): + invalid_steps.make_confs(str(tmp_path / "steps"), str(equi_path)) + + mixed = GammaSurface( + { + "type": "gamma_surface", + "plane_miller": [0, 0, 1], + "slip_direction": [1, 0, 0], + "closed_loop": True, + "slip_length": 1, + } + ) + with pytest.raises(RuntimeError, match="closed_loop=true"): + mixed.make_confs(str(tmp_path / "mixed"), str(equi_path)) + + def test_gamma_surface_post_process_injects_lammps_setforce(tmp_path): task = tmp_path / "task.000000" task.mkdir() @@ -278,6 +398,43 @@ def test_gamma_surface_post_process_injects_lammps_setforce(tmp_path): assert "fix 1 all setforce 0 NULL 0" in text +def test_gamma_surface_lammps_missing_markers_and_static_behavior(tmp_path): + task = tmp_path / "task.000000" + task.mkdir() + (task / "in.lammps").write_text("clear\nrun 0\n") + relaxation = GammaSurface( + { + "type": "gamma_surface", + "plane_miller": [0, 0, 1], + "slip_direction": [1, 0, 0], + }, + {"type": "lammps"}, + ) + with pytest.raises(RuntimeError, match="compatible minimization block"): + relaxation.post_process([str(task)]) + + static = GammaSurface( + { + "type": "gamma_surface", + "plane_miller": [0, 0, 1], + "slip_direction": [1, 0, 0], + "cal_type": "static", + }, + {"type": "lammps"}, + ) + assert static.add_fix is None + static.post_process([str(task)]) + + with pytest.raises(RuntimeError, match="only valid for relaxation"): + GammaSurface( + { + "type": "gamma_surface", + "cal_type": "static", + "add_fix": ["true", "true", "false"], + } + ) + + def test_gamma_surface_compute_lower_with_synthetic_results(tmp_path): prop_dir = tmp_path / "conf" / "gamma_surface_00" task0 = prop_dir / "task.000000" @@ -319,6 +476,145 @@ def test_gamma_surface_compute_lower_with_synthetic_results(tmp_path): assert (prop_dir / "result.json").is_file() +def test_gamma_surface_compute_lower_preserves_closed_loop_cartesian_data(tmp_path): + prop_dir = tmp_path / "conf" / "gamma_surface_00" + task = prop_dir / "task.000000" + equi_dir = tmp_path / "conf" / "relaxation" / "relax_task" + task.mkdir(parents=True) + equi_dir.mkdir(parents=True) + + cell = np.eye(3).tolist() + dumpfn({"energies": [-2.0], "atom_numbs": [2]}, equi_dir / "result.json") + dumpfn( + {"energies": [-2.0], "atom_numbs": [2], "cells": [cell]}, + task / "result_task.json", + ) + dumpfn([0, 0, 1], task / "miller.json") + dumpfn( + { + "frac_x": 0.5, + "frac_y": 1.0, + "disp_cart": [2.25, 3.0, 0.5], + }, + task / "displacement.json", + ) + dumpfn(4.0, task / "slip_length_x.json") + dumpfn(3.1, task / "slip_length_y.json") + dumpfn([4.0, 0.0, 0.0], task / "slip_vector_x.json") + dumpfn([0.25, 3.0, 0.5], task / "slip_vector_y.json") + + prop = GammaSurface( + { + "type": "gamma_surface", + "plane_miller": [0, 0, 1], + "slip_direction": [1, 0, 0], + "closed_loop": True, + } + ) + result, _ = prop._compute_lower( + str(prop_dir / "result.json"), [str(task)], {} + ) + + entry = result["0.500000,1.000000"] + assert entry[:2] == [2.0, 3.1] + assert entry[5]["disp_cart"] == [2.25, 3.0, 0.5] + assert entry[5]["slip_vector_y"] == [0.25, 3.0, 0.5] + + +def test_gamma_surface_refine_inherits_metadata_and_constraints(tmp_path): + init_dir = tmp_path / "gamma_surface_00" + init_task = init_dir / "task.000000" + output_dir = tmp_path / "gamma_surface_01" + init_task.mkdir(parents=True) + (init_task / "POSCAR").write_text("placeholder\n") + for name, value in { + "miller.json": [0, 0, 1], + "displacement.json": { + "frac_x": 0.0, + "frac_y": 0.0, + "disp_cart": [0.0, 0.0, 0.0], + }, + "slip_length_x.json": 4.0, + "slip_length_y.json": 3.0, + "slip_vector_x.json": [4.0, 0.0, 0.0], + "slip_vector_y.json": [0.2, 3.0, 0.0], + }.items(): + dumpfn(value, init_task / name) + dumpfn( + { + "type": "gamma_surface", + "add_fix": ["true", "true", "false"], + "closed_loop": True, + }, + init_task / "task.json", + ) + + prop = GammaSurface( + { + "type": "gamma_surface", + "init_from_suffix": "00", + "output_suffix": "01", + } + ) + tasks = prop.make_confs(str(output_dir), str(tmp_path), refine=True) + + assert len(tasks) == 1 + output_task = Path(tasks[0]) + for metadata in ( + "miller.json", + "displacement.json", + "slip_length_x.json", + "slip_length_y.json", + "slip_vector_x.json", + "slip_vector_y.json", + ): + assert (output_task / metadata).is_file() + assert prop.add_fix == ["true", "true", "false"] + assert prop.closed_loop is True + + +def test_gamma_surface_report_uses_fractional_grid_and_cartesian_table(): + result = { + "0.000000,0.000000": [ + 0.0, + 0.0, + 0.0, + -1.0, + -1.0, + {"disp_cart": [0.0, 0.0, 0.0]}, + ], + "1.000000,1.000000": [ + 4.0, + 3.1, + 0.2, + -0.9, + -1.0, + {"disp_cart": [4.25, 3.0, 0.5]}, + ], + } + + traces, layout = GammaSurfaceReport.plotly_graph(result, "closed") + assert list(traces[0].x) == [0.0, 1.0] + assert layout.xaxis.title.text == "Slip Fraction X" + _, dataframe = GammaSurfaceReport.dash_table(result) + assert list(dataframe["Cart_x (A)"]) == ["0.000", "4.250"] + assert list(dataframe["Cart_z (A)"]) == ["0.000", "0.500"] + + +def test_handle_prop_suffix_requires_both_refine_suffixes(): + assert handle_prop_suffix({"type": "gamma_surface", "output_suffix": "01"}) == ( + False, + "00", + ) + assert handle_prop_suffix( + { + "type": "gamma_surface", + "init_from_suffix": "00", + "output_suffix": "01", + } + ) == (True, "01") + + class TestGammaSurfaceCoverage(unittest.TestCase): def test_gamma_surface_reproduce_defaults_to_static_calculation(self): test_gamma_surface_reproduce_defaults_to_static_calculation() @@ -338,14 +634,48 @@ def test_gamma_surface_resolve_equilibrium_structure_for_vasp_and_abacus(self): def test_gamma_surface_resolve_slip_length_numeric_vector_and_invalid(self): test_gamma_surface_resolve_slip_length_numeric_vector_and_invalid() + def test_gamma_surface_closed_loop_is_opt_in(self): + test_gamma_surface_closed_loop_is_opt_in() + + def test_gamma_surface_finds_and_validates_oblique_periodic_vectors(self): + test_gamma_surface_finds_and_validates_oblique_periodic_vectors() + + def test_gamma_surface_closed_loop_generates_periodic_corner_tasks(self): + with tempfile.TemporaryDirectory() as tmp: + test_gamma_surface_closed_loop_generates_periodic_corner_tasks(Path(tmp)) + + def test_gamma_surface_rejects_invalid_steps_and_mixed_closed_loop(self): + with tempfile.TemporaryDirectory() as tmp: + test_gamma_surface_rejects_invalid_steps_and_mixed_closed_loop(Path(tmp)) + def test_gamma_surface_post_process_injects_lammps_setforce(self): with tempfile.TemporaryDirectory() as tmp: test_gamma_surface_post_process_injects_lammps_setforce(Path(tmp)) + def test_gamma_surface_lammps_missing_markers_and_static_behavior(self): + with tempfile.TemporaryDirectory() as tmp: + test_gamma_surface_lammps_missing_markers_and_static_behavior(Path(tmp)) + def test_gamma_surface_compute_lower_with_synthetic_results(self): with tempfile.TemporaryDirectory() as tmp: test_gamma_surface_compute_lower_with_synthetic_results(Path(tmp)) + def test_gamma_surface_compute_lower_preserves_closed_loop_cartesian_data(self): + with tempfile.TemporaryDirectory() as tmp: + test_gamma_surface_compute_lower_preserves_closed_loop_cartesian_data( + Path(tmp) + ) + + def test_gamma_surface_refine_inherits_metadata_and_constraints(self): + with tempfile.TemporaryDirectory() as tmp: + test_gamma_surface_refine_inherits_metadata_and_constraints(Path(tmp)) + + def test_gamma_surface_report_uses_fractional_grid_and_cartesian_table(self): + test_gamma_surface_report_uses_fractional_grid_and_cartesian_table() + + def test_handle_prop_suffix_requires_both_refine_suffixes(self): + test_handle_prop_suffix_requires_both_refine_suffixes() + if __name__ == "__main__": unittest.main() diff --git a/tests/test_gruneisen.py b/tests/test_gruneisen.py index d6729a8c..44b0d3dd 100644 --- a/tests/test_gruneisen.py +++ b/tests/test_gruneisen.py @@ -45,6 +45,10 @@ def valid_gruneisen_params(**overrides): ({"volume_strains": [-0.02, 0.0, 0.0, 0.02]}, "duplicates"), ({"temperatures": [50, 10]}, "temperatures must be strictly increasing"), ({"volume_strains": [-0.02, 0.0, 0.03]}, "symmetric"), + ({"MESH": []}, "MESH"), + ({"MESH": [20, 20]}, "MESH"), + ({"MESH": [20, 0, 20]}, "MESH"), + ({"MESH": [20, True, 20]}, "MESH"), ], ) 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): ({"volume_strains": [-0.02, 0.0, 0.0, 0.02]}, "duplicates"), ({"temperatures": [50, 10]}, "temperatures must be strictly increasing"), ({"volume_strains": [-0.02, 0.0, 0.03]}, "symmetric"), + ({"MESH": []}, "MESH"), + ({"MESH": [20, 20]}, "MESH"), + ({"MESH": [20, 0, 20]}, "MESH"), + ({"MESH": [20, True, 20]}, "MESH"), ] for overrides, message in cases: with self.subTest(overrides=overrides): @@ -110,6 +118,7 @@ def test_task_param_defaults(self): self.assertEqual(task_param["supercell_size"], [2, 2, 2]) self.assertEqual(task_param["approach"], "linear") self.assertEqual(task_param["PRIMITIVE_AXES"], "P") + self.assertEqual(task_param["MESH"], [20, 20, 20]) def test_validation_rejects_invalid_schema(self): with self.assertRaises(ValueError): @@ -166,6 +175,10 @@ def fake_check_call(command, shell): self.assertGreater(volume_data["volume"], 0.0) self.assertGreater(volume_data["volume_per_atom"], 0.0) self.assertTrue((Path(task_dir) / "band.conf").is_file()) + self.assertIn( + "MESH = 20 20 20", + (Path(task_dir) / "band.conf").read_text(), + ) self.assertTrue((self.target_path / "band_path.json").is_file()) @@ -266,6 +279,23 @@ def fake_check_call(command, shell): self.assertIn("-c POSCAR-unitcell", calls[1]) self.assertIn("--nomeshsym", calls[1]) + def test_ensure_mesh_yaml_rejects_legacy_band_conf_without_mesh(self): + task_dir = self.work_root / "missing_mesh" / "task.000000" + task_dir.mkdir(parents=True) + (task_dir / "FORCE_CONSTANTS").write_text("fake force constants\n") + (task_dir / "band.conf").write_text("BAND = 0 0 0 0.5 0 0\n") + (task_dir / "POSCAR").write_text(self.source_path.read_text()) + + gruneisen = Gruneisen( + valid_gruneisen_params(), + inter_param={"type": "deepmd"}, + ) + with self.assertRaisesRegex( + ValueError, + r"does not define MESH.*MESH=\[20, 20, 20\]", + ): + gruneisen._ensure_mesh_yaml(str(task_dir)) + def test_sign_only_compute_lower_from_vasp_displacement_manifest(self): gruneisen = Gruneisen( { @@ -286,9 +316,14 @@ def fake_check_call(command, shell): calls.append((Path.cwd().name, command)) if command.startswith(Phonon.phonopy_setup_command("-f")): Path("FORCE_SETS").write_text("fake force sets\n") - elif command.startswith(Phonon.phonopy_setup_command("--dim=")) and "--writefc" in command: + elif command in Phonon.phonopy_writefc_load_commands( + '--dim="2 2 2" -c POSCAR-unitcell --writefc' + ): Path("FORCE_CONSTANTS").write_text("fake force constants\n") - elif command.startswith(Phonon.phonopy_command("--dim=")): + elif command in Phonon.phonopy_load_commands( + supercell_size=[2, 2, 2], + cell_file="POSCAR-unitcell", + ): strain = loadfn("volume.json")["strain"] if strain < 0: frequencies = [4.2, 8.4] @@ -406,9 +441,9 @@ def fake_check_call(command, shell): calls.append((Path.cwd().name, command)) if command.startswith(Phonon.phonopy_setup_command("-f")): Path("FORCE_SETS").write_text("fake force sets\n") - elif command == Phonon.phonopy_setup_command("phonopy_disp.yaml --writefc"): + elif command in Phonon.phonopy_writefc_load_commands("phonopy_disp.yaml --writefc"): Path("FORCE_CONSTANTS").write_text("fake force constants\n") - elif command == Phonon.phonopy_command("phonopy_disp.yaml --config band.conf"): + elif command in Phonon.phonopy_load_commands(): strain = loadfn("volume.json")["strain"] if strain < 0: frequencies = [4.2, 8.4] @@ -455,12 +490,16 @@ def fake_run(command, stdout, stderr, text): len([ cmd for _, cmd in calls - if cmd == Phonon.phonopy_setup_command("phonopy_disp.yaml --writefc") + if cmd == Phonon.phonopy_command("phonopy_disp.yaml --writefc") ]), 3, ) self.assertEqual( - len([cmd for _, cmd in calls if cmd == Phonon.phonopy_command("phonopy_disp.yaml --config band.conf")]), + len([ + cmd + for _, cmd in calls + if cmd == Phonon.phonopy_command("phonopy_disp.yaml --config band.conf") + ]), 3, ) self.assertFalse(any(cmd == "phonopy phonopy_disp.yaml --config band.conf --abacus" for _, cmd in calls)) @@ -507,6 +546,7 @@ def test_post_process_prepares_phonon_run_inputs_for_lammps(self): self.assertIn("/root/.dp1s/bin/lmp -in in.relax.lammps", run_script) self.assertIn("python3 convert_relax_dump_to_poscar.py dump.relax POSCAR.relaxed type_map.json", run_script) self.assertIn("phonolammps in.lammps -c POSCAR --dim 2 2 2", run_script) + self.assertIn("-pa 1 0 0 0 1 0 0 0 1", run_script) self.assertIn("cp POSCAR.relaxed POSCAR", run_script) def test_lammps_backward_files_for_gruneisen(self): diff --git a/tests/test_phonon.py b/tests/test_phonon.py index b28415e8..04fa439d 100644 --- a/tests/test_phonon.py +++ b/tests/test_phonon.py @@ -90,6 +90,16 @@ def test_phonopy_setup_command_prefers_v4_setup_tool(self): "phonopy -d --dim='2 2 2' -c POSCAR", ) + def test_sorted_displacement_files_restores_phonopy_order(self): + with patch( + "apex.core.property.Phonon.glob.glob", + return_value=["POSCAR-003", "POSCAR-002", "POSCAR-001"], + ): + self.assertEqual( + Phonon.sorted_displacement_files("POSCAR-0*"), + ["POSCAR-001", "POSCAR-002", "POSCAR-003"], + ) + def test_phonopy_writefc_commands_collapse_for_phonopy_2(self): with patch("apex.core.property.Phonon.shutil.which", return_value=None): self.assertEqual( @@ -97,6 +107,82 @@ def test_phonopy_writefc_commands_collapse_for_phonopy_2(self): ["phonopy phonopy_disp.yaml --writefc"], ) + def test_phonopy_load_commands_prefer_yaml_config_and_keep_legacy_fallback(self): + work_dir = Path("output/phonopy_load_commands") + shutil.rmtree(work_dir, ignore_errors=True) + work_dir.mkdir(parents=True) + cwd = os.getcwd() + try: + os.chdir(work_dir) + Path("phonopy_disp.yaml").write_text("phonopy yaml\n") + with patch("apex.core.property.Phonon.shutil.which", return_value=None): + commands = Phonon.phonopy_load_commands( + supercell_size=[2, 2, 2], + cell_file="POSCAR-unitcell", + ) + self.assertEqual(commands[0], "phonopy phonopy_disp.yaml --config band.conf") + self.assertIn( + 'phonopy --dim="2 2 2" -c POSCAR-unitcell band.conf', + commands, + ) + finally: + os.chdir(cwd) + shutil.rmtree(work_dir, ignore_errors=True) + + def test_phonopy_load_commands_can_create_yaml_before_v4_load(self): + work_dir = Path("output/phonopy_load_commands_no_yaml") + shutil.rmtree(work_dir, ignore_errors=True) + work_dir.mkdir(parents=True) + cwd = os.getcwd() + try: + os.chdir(work_dir) + with patch( + "apex.core.property.Phonon.shutil.which", + return_value="/usr/bin/phonopy-init", + ): + commands = Phonon.phonopy_load_commands( + supercell_size=[2, 2, 2], + cell_file="POSCAR", + ) + self.assertEqual( + commands[0], + 'phonopy-init -d --dim="2 2 2" -c POSCAR' + + " && phonopy phonopy_disp.yaml --config band.conf", + ) + self.assertFalse( + any(command.startswith("phonopy --dim") for command in commands) + ) + finally: + os.chdir(cwd) + shutil.rmtree(work_dir, ignore_errors=True) + + def test_phonopy_writefc_load_commands_prefer_yaml_load_mode(self): + work_dir = Path("output/phonopy_writefc_load_commands") + shutil.rmtree(work_dir, ignore_errors=True) + work_dir.mkdir(parents=True) + cwd = os.getcwd() + try: + os.chdir(work_dir) + Path("phonopy_disp.yaml").write_text("phonopy yaml\n") + with patch( + "apex.core.property.Phonon.shutil.which", + return_value="/usr/bin/phonopy-init", + ): + commands = Phonon.phonopy_writefc_load_commands( + '--dim="2 2 2" -c POSCAR-unitcell --writefc' + ) + self.assertEqual(commands[0], "phonopy phonopy_disp.yaml --writefc") + self.assertIn( + 'phonopy-init --dim="2 2 2" -c POSCAR-unitcell --writefc', + commands, + ) + self.assertFalse( + any(command.startswith("phonopy --dim") for command in commands) + ) + finally: + os.chdir(cwd) + shutil.rmtree(work_dir, ignore_errors=True) + def test_writefc_command_falls_back_to_phonopy(self): work_dir = Path("output/phonopy_writefc_fallback") shutil.rmtree(work_dir, ignore_errors=True) @@ -165,6 +251,45 @@ def fake_check_call(command, shell): os.chdir(cwd) shutil.rmtree(work_dir, ignore_errors=True) + def test_run_first_success_reports_missing_required_output_and_commands(self): + with patch( + "apex.core.property.Phonon.subprocess.check_call", + side_effect=subprocess.CalledProcessError(2, "phonopy load"), + ): + with self.assertRaisesRegex( + RuntimeError, + "mesh.yaml was not created.*phonopy load", + ): + Phonon.run_first_success( + ["phonopy load"], + required_file="mesh.yaml", + ) + + def test_primitive_axes_render_consistently_for_phonolammps(self): + self.assertEqual(Phonon.primitive_axes_config_value("P"), "P") + self.assertEqual( + Phonon.primitive_axes_phonolammps_argument("P"), + "-pa 1 0 0 0 1 0 0 0 1", + ) + self.assertIsNone(Phonon.primitive_axes_config_value("AUTO")) + self.assertEqual( + Phonon.primitive_axes_phonolammps_argument("AUTO"), + "", + ) + axes = "0 1/2 1/2 1/2 0 1/2 1/2 1/2 0" + self.assertEqual( + Phonon.primitive_axes_phonolammps_argument(axes), + "-pa 0.0 0.5 0.5 0.5 0.0 0.5 0.5 0.5 0.0", + ) + + def test_phonolammps_command_includes_matching_primitive_axes(self): + phonon = Phonon({"type": "phonon", "supercell_size": [2, 2, 2]}) + self.assertEqual( + phonon._build_phonolammps_run_command(), + "phonolammps in.lammps -c POSCAR --dim 2 2 2 " + "-pa 1 0 0 0 1 0 0 0 1", + ) + def test_write_band_dat_accepts_nonzero_exit_with_output(self): work_dir = Path("output/phonopy_bandplot_nonzero") shutil.rmtree(work_dir, ignore_errors=True) @@ -340,7 +465,7 @@ def fake_check_call(command, shell): calls.append(command) if command.startswith(Phonon.phonopy_setup_command("-f")): Path("FORCE_SETS").write_text("fake force sets\n") - elif command == Phonon.phonopy_command("phonopy_disp.yaml --config band.conf"): + elif command in Phonon.phonopy_load_commands(): Path("band.yaml").write_text("phonon: []\n") try: @@ -390,7 +515,10 @@ def fake_check_call(command, shell): calls.append(command) if command == Phonon.phonopy_setup_command("--fc vasprun.xml"): Path("FORCE_CONSTANTS").write_text("fake force constants\n") - elif command == Phonon.phonopy_command('--dim="2 2 2" -c POSCAR-unitcell band.conf'): + elif command in Phonon.phonopy_load_commands( + supercell_size=[2, 2, 2], + cell_file="POSCAR-unitcell", + ): Path("band.yaml").write_text("phonon: []\n") try: @@ -399,7 +527,11 @@ def fake_check_call(command, shell): patch.object(Phonon, "write_band_dat", side_effect=self._write_band_dat_for_compute): phonon._compute_lower(str(work_dir / "result.json"), [str(task_dir)], []) self.assertEqual(calls[0], Phonon.phonopy_setup_command("--fc vasprun.xml")) - self.assertEqual(calls[1], Phonon.phonopy_command('--dim="2 2 2" -c POSCAR-unitcell band.conf')) + self.assertEqual( + calls[1], + Phonon.phonopy_setup_command('-d --dim="2 2 2" -c POSCAR-unitcell') + + " && phonopy phonopy_disp.yaml --config band.conf", + ) finally: shutil.rmtree(work_dir, ignore_errors=True) @@ -420,7 +552,10 @@ def fake_check_call(command, shell): calls.append(command) if command == Phonon.phonopy_setup_command("-f task.0*/vasprun.xml"): Path("FORCE_SETS").write_text("fake force sets\n") - elif command == Phonon.phonopy_command('--dim="2 2 2" -c POSCAR-unitcell band.conf'): + elif command in Phonon.phonopy_load_commands( + supercell_size=[2, 2, 2], + cell_file="POSCAR-unitcell", + ): Path("band.yaml").write_text("phonon: []\n") try: @@ -432,7 +567,7 @@ def fake_check_call(command, shell): patch.object(Phonon, "write_band_dat", side_effect=self._write_band_dat_for_compute): phonon._compute_lower(str(work_dir / "result.json"), [str(task_dir)], []) self.assertEqual(calls[0], Phonon.phonopy_setup_command("-f task.0*/vasprun.xml")) - self.assertEqual(calls[1], Phonon.phonopy_command('--dim="2 2 2" -c POSCAR-unitcell band.conf')) + self.assertEqual(calls[1], Phonon.phonopy_command("phonopy_disp.yaml --config band.conf")) finally: shutil.rmtree(work_dir, ignore_errors=True) @@ -470,7 +605,10 @@ def test_compute_lower_lammps_uses_phonopy_for_band(self): def fake_check_call(command, shell): self.assertTrue(shell) calls.append(command) - if command == Phonon.phonopy_command('--dim="2 2 2" -c POSCAR band.conf'): + if command in Phonon.phonopy_load_commands( + supercell_size=[2, 2, 2], + cell_file="POSCAR", + ): Path("band.yaml").write_text("phonon: []\n") try: @@ -478,7 +616,13 @@ def fake_check_call(command, shell): with patch("apex.core.property.Phonon.subprocess.check_call", side_effect=fake_check_call), \ patch.object(Phonon, "write_band_dat", side_effect=self._write_band_dat_for_compute): phonon._compute_lower(str(work_dir / "result.json"), [str(task_dir)], []) - self.assertEqual(calls, [Phonon.phonopy_command('--dim="2 2 2" -c POSCAR band.conf')]) + self.assertEqual( + calls, + [ + Phonon.phonopy_setup_command('-d --dim="2 2 2" -c POSCAR') + + " && phonopy phonopy_disp.yaml --config band.conf" + ], + ) finally: shutil.rmtree(work_dir, ignore_errors=True) diff --git a/tests/test_rss.py b/tests/test_rss.py index ad331824..7fd65cec 100644 --- a/tests/test_rss.py +++ b/tests/test_rss.py @@ -37,6 +37,7 @@ ) from apex.rss import ( _auto_assign_sublattices, + _resolve_show_progress, rss_from_args, run_rss_config, ) @@ -808,6 +809,19 @@ def test_rss_from_args_delegates_to_runner(self): rss_from_args("dummy.json") mocked.assert_called_once_with("dummy.json") + def test_resolve_show_progress_prefers_explicit_and_tty_default(self): + class _Stream: + def __init__(self, tty): + self._tty = tty + + def isatty(self): + return self._tty + + self.assertFalse(_resolve_show_progress({"show_progress": False}, _Stream(True))) + self.assertTrue(_resolve_show_progress({"show_progress": True}, _Stream(False))) + self.assertTrue(_resolve_show_progress({}, _Stream(True))) + self.assertFalse(_resolve_show_progress({}, _Stream(False))) + def test_auto_assign_sublattices_helper_branches(self): base_parent = Structure(Lattice.cubic(3.0), ["Na", "Cl"], [[0, 0, 0], [0.5, 0.5, 0.5]]) expanded_parent = base_parent.copy() diff --git a/tests/test_skill.py b/tests/test_skill.py new file mode 100644 index 00000000..24b53b3e --- /dev/null +++ b/tests/test_skill.py @@ -0,0 +1,286 @@ +import importlib.util +import io +import os +import sys +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from apex.skills import SKILL_NAME, get_skill_root +from apex.skill import skill_from_args + + +def _load_script(name: str): + root = get_skill_root() / "scripts" / name + spec = importlib.util.spec_from_file_location(name.replace(".py", ""), root) + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) + return mod + + +class TestApexSkill(unittest.TestCase): + def test_bundled_skill_exists(self): + root = get_skill_root() + self.assertEqual(root.name, SKILL_NAME) + self.assertTrue((root / "SKILL.md").is_file()) + text = (root / "SKILL.md").read_text(encoding="utf-8") + self.assertIn(f"name: {SKILL_NAME}", text) + self.assertIn("BOHRIUM_PROJECT_ID", text) + self.assertIn("validate_apex_combo.py", text) + self.assertIn("models/DPA-3.2-5M", text) + self.assertIn("DPA-3.2-5M-OMat24.pth", text) + root = get_skill_root() + self.assertTrue((root / "models" / "README.md").is_file()) + self.assertTrue( + ( + root + / "models" + / "DPA-3.2-5M" + / "DPA-3.2-5M-OMat24.pth" + ).is_file() + ) + self.assertTrue((root / "scripts" / "fetch_models.py").is_file()) + + def test_no_hardcoded_project_id_in_skill_docs(self): + root = get_skill_root() + for path in [root / "SKILL.md", root / "reference" / "submission.md"]: + text = path.read_text(encoding="utf-8") + # Allow mentioning 13529 only as a negative example in SKILL.md + if path.name == "SKILL.md": + self.assertNotRegex(text, r'"program_id":\s*13529') + self.assertNotRegex(text, r'"project_id":\s*13529') + else: + self.assertNotIn("13529", text) + + def test_skill_documents_safe_submission_defaults(self): + root = get_skill_root() + skill = (root / "SKILL.md").read_text(encoding="utf-8") + submission = (root / "reference" / "submission.md").read_text( + encoding="utf-8" + ) + structure = (root / "reference" / "workflow-control.md").read_text( + encoding="utf-8" + ) + + self.assertIn("never refresh it in `run.sh`", skill) + self.assertIn("Do not read BOHRIUM_ACCESS_KEY", submission) + self.assertNotIn("Recommended run.sh Ticket Refresh Template", submission) + self.assertIn("use the model bundled with this skill", skill) + self.assertIn('"type_map": "auto"', skill) + self.assertIn("already a supercell", structure) + self.assertIn("`supercell` / `supercell_size` to `[1,1,1]`", structure) + + def test_skill_zip_flag(self): + import tempfile + import zipfile + + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "apex-flow.zip" + buf = io.StringIO() + with patch("sys.stdout", buf): + skill_from_args(SimpleNamespace(zip=True, output=str(out))) + self.assertTrue(out.is_file()) + printed = buf.getvalue() + self.assertIn(str(out), printed) + self.assertNotIn("Local DPA checkpoints missing", printed) + with zipfile.ZipFile(out) as zf: + names = zf.namelist() + self.assertTrue(any(n == f"{SKILL_NAME}/SKILL.md" for n in names)) + self.assertTrue( + any(n.startswith(f"{SKILL_NAME}/scripts/") for n in names) + ) + self.assertTrue( + any(n.endswith("DPA-3.2-5M-OMat24.pth") for n in names) + ) + self.assertFalse(any(n.endswith(".pt") for n in names)) + + def test_skill_agent_prompt(self): + buf = io.StringIO() + with patch("sys.stdout", buf): + skill_from_args(SimpleNamespace(zip=False, output=None)) + out = buf.getvalue() + self.assertIn("Agent prompt", out) + self.assertIn(SKILL_NAME, out) + self.assertIn("apex skill --zip", out) + self.assertIn("MatMaster", out) + + +class TestValidateApexCombo(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.combo = _load_script("validate_apex_combo.py") + + def test_blocked_image(self): + ok, errors = self.combo.check_combo( + "registry.dp.tech/dptech/deepmd-kit:3.1.2", + "c32_m64_cpu", + ) + self.assertFalse(ok) + self.assertTrue(any("3.1.2" in e for e in errors)) + + def test_blocked_scass(self): + ok, errors = self.combo.check_combo( + "deepmd-kit:3.1.3", + "c4_m16_cpu", + ) + self.assertFalse(ok) + self.assertTrue(any("c4_m16_cpu" in e for e in errors)) + + def test_deepmd_311_blocked_on_all_t4_machine_sizes(self): + for scass in ( + "c8_m31_1 * NVIDIA T4", + "c4_m15_1 * NVIDIA T4", + "c16_m62_1 * NVIDIA T4", + ): + with self.subTest(scass=scass): + ok, errors = self.combo.check_combo( + "registry.dp.tech/dptech/deepmd-kit:3.1.1", + scass, + ) + self.assertFalse(ok) + self.assertTrue(any("NVIDIA T4" in e for e in errors)) + + def test_deepmd_311_cpu_remains_allowed(self): + ok, errors = self.combo.check_combo( + "deepmd-kit:3.1.1", + "c32_m64_cpu", + ) + self.assertTrue(ok) + self.assertEqual(errors, []) + + def test_ok_combo(self): + ok, errors = self.combo.check_combo( + "registry.dp.tech/dptech/deepmd-kit:3.1.3", + "c8_m31_1 * NVIDIA T4", + ) + self.assertTrue(ok) + self.assertEqual(errors, []) + + def test_recommend_lammps_gpu(self): + rec = self.combo.recommend("lammps", "gpu") + self.assertIn("3.1.3", rec["image"]) + self.assertIn("T4", rec["scass_type"]) + + def test_cli_check_exit_codes(self): + self.assertEqual( + self.combo.main( + [ + "check", + "--image", + "deepmd-kit:3.1.3", + "--scass", + "c32_m64_cpu", + "--format", + "json", + ] + ), + 0, + ) + self.assertEqual( + self.combo.main( + [ + "check", + "--image", + "deepmd-kit:3.1.0", + "--scass", + "c32_m64_cpu", + ] + ), + 1, + ) + + +class TestGenerateConfigProjectId(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.gen = _load_script("generate_config.py") + + def test_requires_env_or_arg(self): + env = {k: v for k, v in os.environ.items() if k != "BOHRIUM_PROJECT_ID"} + with patch.dict(os.environ, env, clear=True): + with self.assertRaises(RuntimeError): + self.gen.resolve_project_id(None) + + def test_reads_env(self): + with patch.dict(os.environ, {"BOHRIUM_PROJECT_ID": "4242"}): + self.assertEqual(self.gen.resolve_project_id(None), 4242) + + def test_arg_overrides_env(self): + with patch.dict(os.environ, {"BOHRIUM_PROJECT_ID": "4242"}): + self.assertEqual(self.gen.resolve_project_id(99), 99) + + def test_no_default_constant(self): + self.assertFalse(hasattr(self.gen, "DEFAULT_PROJECT_ID")) + + def test_lammps_interaction_defaults_to_auto_type_map(self): + interaction = self.gen.build_interaction( + backend="lammps", + potential="deepmd", + model="DPA-3.2-5M-OMat24.pth", + ) + self.assertEqual(interaction["type_map"], "auto") + + def test_generate_config_has_no_type_map_cli_option(self): + source = ( + get_skill_root() / "scripts" / "generate_config.py" + ).read_text(encoding="utf-8") + self.assertNotIn('add_argument("--type-map"', source) + + def test_lammps_default_uses_validated_phonolammps_image(self): + self.assertEqual( + self.gen.LAMMPS_IMAGE, + "registry.dp.tech/dptech/dp/native/prod-397637/" + "deepmd-kit-phonolammps:3.1.3", + ) + + def test_combo_validator_has_no_property_cli(self): + source = ( + get_skill_root() / "scripts" / "validate_apex_combo.py" + ).read_text(encoding="utf-8") + self.assertNotIn('add_argument("--property"', source) + self.assertNotIn("BLOCKED_IMAGE_PROPERTIES", source) + + +class TestGammaSurfaceSkillConfig(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.gen = _load_script("generate_config.py") + cls.validator = _load_script("validate_inputs.py") + + def test_generated_gamma_surface_preserves_legacy_default(self): + self.assertIs( + self.gen.PROPERTY_DEFAULTS["gamma_surface"]["closed_loop"], + False, + ) + + def test_closed_loop_requires_boolean_and_no_custom_lengths(self): + properties = [ + { + "type": "gamma_surface", + "plane_miller": [1, 1, 1], + "slip_direction": [-1, 1, 0], + "closed_loop": "true", + }, + { + "type": "gamma_surface", + "plane_miller": [1, 1, 1], + "slip_direction": [-1, 1, 0], + "closed_loop": True, + "slip_length": 1, + }, + ] + + errors, _ = self.validator.validate_properties(properties, "lammps") + + self.assertTrue( + any("closed_loop must be a boolean" in error for error in errors) + ) + self.assertTrue( + any("cannot be combined with slip_length" in error for error in errors) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_skill_scripts.py b/tests/test_skill_scripts.py new file mode 100644 index 00000000..d3d537a8 --- /dev/null +++ b/tests/test_skill_scripts.py @@ -0,0 +1,766 @@ +import importlib.util +import io +import json +import os +import stat +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch +from urllib.error import URLError + +from apex.skills import get_skill_root + + +def _load_script(name): + path = get_skill_root() / "scripts" / name + spec = importlib.util.spec_from_file_location( + f"apex_skill_test_{name.replace('.', '_')}", path + ) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +class _Response: + def __init__(self, payload): + self.payload = payload + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self): + return json.dumps(self.payload).encode() + + +class TestGenerateConfigHelpers(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.gen = _load_script("generate_config.py") + + def test_get_bohrium_ticket_success(self): + ticket = "12345678-1234-1234-1234-123456789abc" + with patch.object( + self.gen, "urlopen", return_value=_Response( + {"code": 0, "data": {"ticket": ticket}} + ) + ) as mocked: + self.assertEqual(self.gen.get_bohrium_ticket("secret"), ticket) + request = mocked.call_args.args[0] + self.assertIn("accessKey=secret", request.full_url) + + def test_get_bohrium_ticket_rejects_transport_and_api_errors(self): + with patch.object(self.gen, "urlopen", side_effect=URLError("offline")): + with self.assertRaisesRegex(RuntimeError, "Failed to get ticket"): + self.gen.get_bohrium_ticket("secret") + + for payload, message in ( + ({"code": 401, "message": "denied"}, "code=401"), + ({"code": 0, "data": {"ticket": "short"}}, "Invalid ticket"), + ({"code": 0, "data": {}}, "Invalid ticket"), + ): + with self.subTest(payload=payload): + with patch.object( + self.gen, "urlopen", return_value=_Response(payload) + ): + with self.assertRaisesRegex(RuntimeError, message): + self.gen.get_bohrium_ticket("secret") + + def test_sanitize_workflow_name(self): + self.assertEqual( + self.gen.sanitize_workflow_name(" Mo_BCC Surface! "), + "mo-bcc-surface", + ) + self.assertEqual(self.gen.sanitize_workflow_name("***"), "apex-workflow") + sanitized = self.gen.sanitize_workflow_name("A" * 70 + "-") + self.assertEqual(len(sanitized), 63) + self.assertFalse(sanitized.endswith("-")) + + def test_resolve_project_id_rejects_non_integer_env(self): + with patch.dict(os.environ, {"BOHRIUM_PROJECT_ID": "not-an-int"}): + with self.assertRaisesRegex(RuntimeError, "must be an integer"): + self.gen.resolve_project_id() + + def test_build_global_json_selects_backend_resources(self): + cases = ( + ("lammps", "deepmd", self.gen.SCASS_TYPES["lammps_gpu"], "lmp"), + ("lammps", "eam_alloy", self.gen.SCASS_TYPES["lammps_cpu"], "lmp"), + ("abacus", None, self.gen.SCASS_TYPES["abacus"], "abacus"), + ("vasp", None, self.gen.SCASS_TYPES["vasp"], "/opt/vasp.5.4.4/bin/vasp_std"), + ("other", None, self.gen.SCASS_TYPES["lammps_cpu"], "lmp"), + ) + with patch.object( + self.gen, "get_bohrium_ticket", return_value="t" * 36 + ), patch.object(self.gen, "_validate_image_scass") as validate: + for backend, potential, expected_scass, command in cases: + with self.subTest(backend=backend, potential=potential): + config = self.gen.build_global_json( + backend, potential, access_key="key", project_id=42 + ) + self.assertEqual(config["program_id"], 42) + self.assertEqual(config["scass_type"], expected_scass) + self.assertIn(command, config["lammps_run_command"]) + self.assertEqual(validate.call_count, len(cases)) + + def test_build_global_json_backend_fields_and_overrides(self): + with patch.object( + self.gen, "get_bohrium_ticket", return_value="t" * 36 + ), patch.object(self.gen, "_validate_image_scass"): + abacus = self.gen.build_global_json( + "abacus", + access_key="key", + project_id=42, + scass_type="custom", + run_command="custom-abacus", + ) + vasp = self.gen.build_global_json( + "vasp", access_key="key", project_id=42 + ) + vasp_with_image = self.gen.build_global_json( + "vasp", + access_key="key", + project_id=42, + vasp_image="registry.example/private/vasp:licensed", + ) + self.assertEqual(abacus["scass_type"], "custom") + self.assertEqual(abacus["abacus_run_command"], "custom-abacus") + self.assertIn("abacus_image_name", abacus) + self.assertIn("setvars.sh", vasp["vasp_run_command"]) + self.assertIn("ulimit -s unlimited", vasp["vasp_run_command"]) + self.assertIn("/opt/vasp.5.4.4/bin/vasp_std", vasp["vasp_run_command"]) + self.assertIn("mpirun -n 32", vasp["vasp_run_command"]) + self.assertNotIn("vasp_image_name", vasp) + self.assertEqual( + vasp_with_image["vasp_image_name"], + "registry.example/private/vasp:licensed", + ) + + def test_build_global_json_requires_access_key_and_propagates_combo_error(self): + with patch.dict(os.environ, {}, clear=True): + with self.assertRaisesRegex(RuntimeError, "BOHRIUM_ACCESS_KEY"): + self.gen.build_global_json("lammps", "deepmd", project_id=1) + + with patch.object( + self.gen, "get_bohrium_ticket", return_value="t" * 36 + ), patch.object( + self.gen, + "_validate_image_scass", + side_effect=RuntimeError("blocked"), + ): + with self.assertRaisesRegex(RuntimeError, "blocked"): + self.gen.build_global_json( + "lammps", "deepmd", access_key="key", project_id=1 + ) + + def test_validate_image_scass_accepts_and_rejects_combos(self): + self.gen._validate_image_scass( + "deepmd-kit:3.1.3", "c32_m64_cpu" + ) + with self.assertRaisesRegex(RuntimeError, "Blocked image"): + self.gen._validate_image_scass( + "deepmd-kit:3.1.2", "c32_m64_cpu" + ) + + def test_build_interaction_all_backends(self): + with self.assertRaisesRegex(ValueError, "--potential"): + self.gen.build_interaction("lammps", model="model.pb") + with self.assertRaisesRegex(ValueError, "--model"): + self.gen.build_interaction("lammps", potential="deepmd") + + abacus = self.gen.build_interaction( + "abacus", + potcars={"Al": "Al.upf"}, + orb_files={"Al": "Al.orb"}, + ) + self.assertEqual(abacus["incar"], "INPUT") + self.assertEqual(abacus["potcars"]["Al"], "Al.upf") + self.assertEqual(abacus["orb_files"]["Al"], "Al.orb") + + vasp = self.gen.build_interaction( + "vasp", incar="CUSTOM", potcars={"Al": "Al"} + ) + self.assertEqual(vasp["incar"], "CUSTOM") + self.assertEqual(vasp["potcar_prefix"], ".") + with self.assertRaisesRegex(ValueError, "Unknown backend"): + self.gen.build_interaction("unknown") + + def test_build_param_json_flow_types_and_dft_overrides(self): + lammps = {"type": "deepmd"} + joint = self.gen.build_param_json( + "confs/input", lammps, ["elastic", "unknown"], "joint" + ) + self.assertIn("relaxation", joint) + self.assertEqual(joint["properties"], [ + self.gen.PROPERTY_DEFAULTS["elastic"] + ]) + + relax = self.gen.build_param_json( + "confs/input", + lammps, + [], + "relax", + relaxation_settings={"etol": 1}, + ) + self.assertEqual(relax["relaxation"]["cal_setting"], {"etol": 1}) + self.assertNotIn("properties", relax) + self.assertEqual( + self.gen.PROPERTY_DEFAULTS["gruneisen"]["MESH"], + [20, 20, 20], + ) + + dft = self.gen.build_param_json( + "confs/input", {"type": "vasp"}, ["phonon"], "props" + ) + self.assertNotIn("relaxation", dft) + self.assertEqual(dft["properties"][0]["BAND_POINTS"], 21) + + def test_validate_config_and_parse_str_map(self): + self.assertTrue( + self.gen.validate_config("vasp", None, ["finite_t_elastic"]) + ) + self.assertTrue( + self.gen.validate_config("lammps", "unknown", ["elastic"]) + ) + self.assertEqual( + self.gen.validate_config("lammps", "deepmd", ["elastic"]), [] + ) + self.assertIsNone(self.gen.parse_str_map("")) + self.assertEqual( + self.gen.parse_str_map("Al:Al.upf, Cu : Cu.upf,invalid"), + {"Al": "Al.upf", "Cu": "Cu.upf"}, + ) + + def test_main_generates_complete_job_directory(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + structure = root / "Al structure.vasp" + model = root / "model.pb" + output = root / "job" + structure.write_text("structure", encoding="utf-8") + model.write_text("model", encoding="utf-8") + argv = [ + "generate_config.py", + "create", + "--structure", str(structure), + "--backend", "lammps", + "--potential", "deepmd", + "--model", str(model), + "--properties", "elastic", "phonon", + "--workflow-name", "Al Test!", + "--output-dir", str(output), + "--project-id", "7", + "--access-key", "key", + ] + stdout = io.StringIO() + with patch.object(sys, "argv", argv), patch.object( + self.gen, "get_bohrium_ticket", return_value="t" * 36 + ), patch("sys.stdout", stdout): + self.gen.main() + + global_config = json.loads( + (output / "global.json").read_text(encoding="utf-8") + ) + param = json.loads( + (output / "param.json").read_text(encoding="utf-8") + ) + submit = output / "submit.sh" + self.assertEqual(global_config["program_id"], 7) + self.assertEqual( + [prop["type"] for prop in param["properties"]], + ["elastic", "phonon"], + ) + self.assertEqual( + (output / "confs" / "input" / "POSCAR").read_text(), + "structure", + ) + self.assertEqual((output / "model.pb").read_text(), "model") + self.assertIn('-n "al-test"', submit.read_text()) + self.assertTrue(submit.stat().st_mode & stat.S_IXUSR) + self.assertIn("Workflow name sanitized", stdout.getvalue()) + + def test_main_rejects_invalid_config_before_ticket_request(self): + argv = [ + "generate_config.py", + "create", + "--structure", "POSCAR", + "--backend", "vasp", + "--properties", "finite_t_elastic", + ] + with patch.object(sys, "argv", argv), patch.object( + self.gen, "get_bohrium_ticket" + ) as ticket, self.assertRaises(SystemExit) as raised: + self.gen.main() + self.assertEqual(raised.exception.code, 1) + ticket.assert_not_called() + + def test_refresh_global_subcommand_preserves_param_and_fixes_id_types(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + global_path = root / "global.json" + param_path = root / "param.json" + param_text = '{"properties": [{"type": "elastic"}]}\n' + param_path.write_text(param_text, encoding="utf-8") + global_path.write_text(json.dumps({ + "program_id": "old", + "bohrium_config": { + "project_id": "old", + "ticket": "expired", + }, + "machine": { + "remote_profile": {"program_id": "old"}, + }, + }), encoding="utf-8") + + argv = [ + "generate_config.py", + "refresh-global", + "--global", str(global_path), + "--project-id", "42", + "--access-key", "key", + ] + stdout = io.StringIO() + with patch.object(sys, "argv", argv), patch.object( + self.gen, "get_bohrium_ticket", return_value="t" * 36 + ), patch("sys.stdout", stdout): + self.gen.main() + + config = json.loads(global_path.read_text(encoding="utf-8")) + self.assertIs(type(config["program_id"]), int) + self.assertIs( + type(config["bohrium_config"]["project_id"]), int + ) + self.assertIs( + type(config["machine"]["remote_profile"]["program_id"]), int + ) + self.assertEqual(config["bohrium_config"]["ticket"], "t" * 36) + self.assertEqual(param_path.read_text(encoding="utf-8"), param_text) + self.assertIn("Hard type check passed", stdout.getvalue()) + + +class TestListBohriumImages(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.mod = _load_script("list_bohrium_images.py") + + def test_filter_images_by_keyword_and_urls(self): + records = [ + { + "id": 1, + "name": "my-vasp:5.4.4", + "description": "licensed VASP", + "url": "registry.dp.tech/user/my-vasp:5.4.4", + }, + { + "id": 2, + "name": "lammps:latest", + "url": "registry.dp.tech/user/lammps:latest", + }, + ] + payload = self.mod.filter_images(records, "vasp", max_results=20) + self.assertEqual(payload["total_found"], 1) + self.assertEqual(payload["returned"], 1) + self.assertTrue(payload["images"][0]["private"]) + self.assertEqual( + self.mod.image_urls(payload), + ["registry.dp.tech/user/my-vasp:5.4.4"], + ) + + def test_main_require_exits_when_empty(self): + with patch.object(self.mod, "fetch_private_images", return_value=[]), patch.dict( + os.environ, {"BOHRIUM_ACCESS_KEY": "ak"}, clear=False + ): + code = self.mod.main(["--keyword", "vasp", "--require"]) + self.assertEqual(code, 1) + + def test_main_prints_matching_urls(self): + records = [ + { + "id": 9, + "name": "priv-vasp:1", + "url": "registry.dp.tech/acct/priv-vasp:1", + } + ] + with patch.object( + self.mod, "fetch_private_images", return_value=records + ), patch.dict(os.environ, {"BOHRIUM_ACCESS_KEY": "ak"}, clear=False), patch( + "sys.stdout", new_callable=io.StringIO + ) as stdout: + code = self.mod.main(["--keyword", "vasp", "--urls-only"]) + self.assertEqual(code, 0) + self.assertIn("registry.dp.tech/acct/priv-vasp:1", stdout.getvalue()) + + +class TestValidateInputs(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.validator = _load_script("validate_inputs.py") + + def test_validate_global(self): + errors, warnings = self.validator.validate_global({}) + self.assertTrue( + any("Missing 'machine' section" in error for error in errors) + ) + self.assertTrue( + any("Missing 'run_command'" in error for error in errors) + ) + self.assertTrue(warnings) + + errors, warnings = self.validator.validate_global( + {"machine": {}, "run_command": "run"} + ) + self.assertIn("Missing 'machine.batch_type'", errors) + self.assertTrue(warnings) + + self.assertEqual( + self.validator.validate_global( + { + "machine": {"batch_type": "Bohrium"}, + "resources": {}, + "run_command": "run", + } + ), + ([], []), + ) + + def test_validate_current_global_requires_integer_matching_project_ids(self): + config = { + "dflow_host": "https://workflows.deepmodeling.com", + "batch_type": "Bohrium", + "context_type": "Bohrium", + "program_id": "42", + "bohrium_config": { + "ticket": "ticket", + "project_id": "42", + }, + "machine": { + "remote_profile": {"program_id": "42"}, + }, + "scass_type": "c8_m31_1 * NVIDIA T4", + "lammps_run_command": "lmp -in in.lammps", + } + errors, warnings = self.validator.validate_global(config) + self.assertTrue(any("'program_id'" in error for error in errors)) + self.assertTrue( + any("'bohrium_config.project_id'" in error for error in errors) + ) + self.assertTrue( + any("'machine.remote_profile.program_id'" in error for error in errors) + ) + self.assertFalse(warnings) + + config["program_id"] = 42 + config["bohrium_config"]["project_id"] = 43 + config["machine"]["remote_profile"]["program_id"] = 42 + errors, _ = self.validator.validate_global(config) + self.assertTrue(any("must match" in error for error in errors)) + + config["bohrium_config"]["project_id"] = 42 + self.assertEqual(self.validator.validate_global(config), ([], [])) + + def test_validate_vasp_run_command_rejects_bare_mpirun(self): + errors, _ = self.validator.validate_vasp_run_command( + {"vasp_run_command": "mpirun -n 16 vasp_std"} + ) + self.assertTrue(any("Bohrium template" in e for e in errors)) + + errors, warnings = self.validator.validate_vasp_run_command( + { + "vasp_run_command": ( + 'bash -c "source /opt/intel/oneapi/setvars.sh && ' + "ulimit -s unlimited && " + 'mpirun -n 32 /opt/vasp.5.4.4/bin/vasp_std"' + ) + } + ) + self.assertEqual(errors, []) + self.assertEqual(warnings, []) + + def test_validate_dft_kspacing_requires_vasp_kspacing(self): + with tempfile.TemporaryDirectory() as tmp: + base = Path(tmp) + incar = base / "INCAR" + incar.write_text("ENCUT = 400\n", encoding="utf-8") + errors, _ = self.validator.validate_dft_kspacing( + {}, + base, + {"type": "vasp", "incar": "INCAR"}, + ) + self.assertTrue(any("KSPACING" in e for e in errors)) + + incar.write_text( + "ENCUT = 400\nKSPACING = 0.15\nKGAMMA = True\n", + encoding="utf-8", + ) + errors, warnings = self.validator.validate_dft_kspacing( + {}, + base, + {"type": "vasp", "incar": "INCAR"}, + ) + self.assertEqual(errors, []) + self.assertEqual(warnings, []) + + def test_validate_interaction(self): + cases = ( + ({}, "Missing 'interaction.type'"), + ({"type": "invalid"}, "Unknown interaction type"), + ({"type": "deepmd"}, "requires 'model'"), + ({"type": "deepmd", "model": "m"}, "requires 'type_map'"), + ({"type": "abacus"}, "requires 'potcars'"), + ({"type": "vasp"}, "requires 'potcars'"), + ) + for interaction, message in cases: + with self.subTest(interaction=interaction): + errors, _ = self.validator.validate_interaction(interaction) + self.assertTrue(any(message in error for error in errors)) + + errors, warnings = self.validator.validate_interaction( + {"type": "abacus", "potcars": {}} + ) + self.assertFalse(errors) + self.assertTrue(any("orb_files" in warning for warning in warnings)) + errors, warnings = self.validator.validate_interaction( + {"type": "vasp", "potcars": {}} + ) + self.assertTrue(any("potcar_prefix" in error for error in errors)) + self.assertFalse(warnings) + + def test_validate_property_required_fields(self): + cases = ( + ({}, "missing 'type'"), + ({"type": "bad"}, "unknown property"), + ({"type": "eos"}, "EOS requires"), + ({"type": "cohesive"}, "cohesive requires"), + ({"type": "surface"}, "surface requires"), + ({"type": "decohesive"}, "decohesive requires"), + ({"type": "gruneisen"}, "volume_strains"), + ( + { + "type": "finite_t_elastic", + "cal_setting": {"method": "other"}, + }, + "only supports", + ), + ) + for prop, message in cases: + with self.subTest(prop=prop): + errors, _ = self.validator.validate_properties( + [prop], "lammps" + ) + self.assertTrue(any(message in error for error in errors)) + + errors, _ = self.validator.validate_properties( + [{"type": "finite_t_elastic"}], "vasp" + ) + self.assertTrue(any("LAMMPS-only" in error for error in errors)) + + def test_validate_gruneisen_and_gamma_geometry(self): + errors, _ = self.validator.validate_properties( + [{ + "type": "gruneisen", + "volume_strains": [-0.01, 0.01], + "temperatures": [300], + }], + "lammps", + ) + self.assertTrue(any("include 0.0" in error for error in errors)) + self.assertTrue(any("≥3" in error for error in errors)) + + errors, _ = self.validator.validate_properties( + [{ + "type": "gruneisen", + "volume_strains": [-0.01, 0.0, 0.01], + "temperatures": [300], + "MESH": [20, 0, 20], + }], + "lammps", + ) + self.assertTrue(any("MESH" in error for error in errors)) + + for prop, message in ( + ({"type": "gamma"}, "requires plane_miller"), + ({ + "type": "gamma", + "plane_miller": [1, 1, 1], + "slip_direction": [1, 0], + }, "dimensions differ"), + ({ + "type": "gamma", + "plane_miller": [1, 1, 1], + "slip_direction": [1, 0, 0], + }, "must lie"), + ): + with self.subTest(prop=prop): + errors, _ = self.validator.validate_properties( + [prop], "lammps" + ) + self.assertTrue(any(message in error for error in errors)) + + def test_validate_gamma_surface_steps(self): + errors, _ = self.validator.validate_properties( + [{ + "type": "gamma_surface", + "plane_miller": [1, 1, 1], + "slip_direction": [-1, 1, 0], + "n_steps_x": True, + "n_steps_y": 0, + }], + "lammps", + ) + self.assertTrue(any("n_steps_x" in error for error in errors)) + self.assertTrue(any("n_steps_y" in error for error in errors)) + + def test_validate_structures(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "POSCAR").write_text("structure", encoding="utf-8") + self.assertEqual( + self.validator.validate_structures( + {"structures": ["POSCAR"]}, root + ), + ([], []), + ) + errors, warnings = self.validator.validate_structures({}, root) + self.assertTrue(errors) + self.assertFalse(warnings) + errors, warnings = self.validator.validate_structures( + {"structures": ["missing"]}, root + ) + self.assertFalse(errors) + self.assertTrue(warnings) + + def _run_main(self, argv): + stdout = io.StringIO() + stderr = io.StringIO() + with patch.object(sys, "argv", argv), patch( + "sys.stdout", stdout + ), patch("sys.stderr", stderr): + try: + self.validator.main() + except SystemExit as exc: + return exc.code, stdout.getvalue(), stderr.getvalue() + return 0, stdout.getvalue(), stderr.getvalue() + + def test_main_success_and_strict_warning(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "POSCAR").write_text("structure", encoding="utf-8") + param = root / "param.json" + global_json = root / "global.json" + param.write_text(json.dumps({ + "structures": ["POSCAR"], + "interaction": { + "type": "deepmd", + "model": "model.pb", + "type_map": "auto", + }, + "properties": [{ + "type": "elastic", + }], + })) + global_json.write_text(json.dumps({ + "machine": {"batch_type": "Bohrium"}, + "resources": {}, + "run_command": "lmp", + })) + code, stdout, stderr = self._run_main([ + "validate_inputs.py", "--param", str(param), + "--global", str(global_json), + ]) + self.assertEqual(code, 0) + self.assertIn("Validation PASSED", stdout) + self.assertEqual(stderr, "") + + param_data = json.loads(param.read_text()) + param_data["structures"] = ["missing"] + param.write_text(json.dumps(param_data)) + code, _, stderr = self._run_main([ + "validate_inputs.py", "--param", str(param), "--strict", + ]) + self.assertEqual(code, 1) + self.assertIn("strict mode", stderr) + + def test_main_reports_missing_and_invalid_sections(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + missing = root / "missing.json" + code, _, stderr = self._run_main([ + "validate_inputs.py", "--param", str(missing) + ]) + self.assertEqual(code, 1) + self.assertIn("param.json not found", stderr) + + param = root / "param.json" + param.write_text(json.dumps({})) + code, _, stderr = self._run_main([ + "validate_inputs.py", "--param", str(param), + "--global", str(missing), + ]) + self.assertEqual(code, 1) + self.assertIn("global.json not found", stderr) + self.assertIn("Missing 'interaction'", stderr) + + +class TestValidateComboAdditionalPaths(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.combo = _load_script("validate_apex_combo.py") + + def test_image_normalization(self): + self.assertEqual( + self.combo.normalize_image( + "registry.dp.tech/dptech/deepmd-kit:3.1.3" + ), + "deepmd-kit:3.1.3", + ) + self.assertEqual(self.combo.full_image(""), "") + self.assertEqual( + self.combo.full_image("deepmd-kit:3.1.3"), + "registry.dp.tech/dptech/deepmd-kit:3.1.3", + ) + full = "ghcr.io/example/image:tag" + self.assertEqual(self.combo.full_image(full), full) + + def test_triclinic_and_multiple_block_reasons(self): + ok, errors = self.combo.check_combo( + "deepmd-kit:3.1.1", + "c12_m46_1 * NVIDIA T4", + triclinic=True, + ) + self.assertFalse(ok) + self.assertGreaterEqual(len(errors), 3) + self.assertTrue(any("triclinic" in error for error in errors)) + + def test_recommend_all_backends_and_unknown(self): + self.assertEqual( + self.combo.recommend("abacus", "gpu")["prefer"], "cpu" + ) + self.assertIn( + "user-provided VASP", self.combo.recommend("vasp")["image"] + ) + with self.assertRaisesRegex(ValueError, "Unknown backend"): + self.combo.recommend("unknown") + + def test_list_and_recommend_cli_text_and_json(self): + for argv, expected in ( + (["list-combos"], "Blocked images:"), + (["list-combos", "--format", "json"], '"recommended"'), + (["recommend", "--backend", "abacus"], "image="), + ( + ["recommend", "--backend", "vasp", "--format", "json"], + '"backend": "vasp"', + ), + ): + with self.subTest(argv=argv): + stdout = io.StringIO() + with patch("sys.stdout", stdout): + self.assertEqual(self.combo.main(argv), 0) + self.assertIn(expected, stdout.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_submit_path_validation.py b/tests/test_submit_path_validation.py index 865ca4f1..e880050c 100644 --- a/tests/test_submit_path_validation.py +++ b/tests/test_submit_path_validation.py @@ -6,6 +6,8 @@ import apex.submit as submit_module from apex.submit import ( + LAMMPS_PHONON_IMAGE, + _select_run_image, _with_lammps_retry_env, submit_workflow, validate_submit_paths, @@ -15,6 +17,31 @@ class TestSubmitPathValidation(unittest.TestCase): + def test_lammps_phonon_forces_validated_image(self): + selected = _select_run_image( + "lammps", + {"properties": [{"type": "phonon"}]}, + "registry.dp.tech/dptech/deepmd-kit:3.1.3", + ) + self.assertEqual(selected, LAMMPS_PHONON_IMAGE) + + def test_lammps_gruneisen_forces_validated_image(self): + selected = _select_run_image( + "lammps", + {"properties": [{"type": "gruneisen"}]}, + "registry.dp.tech/dptech/deepmd-kit:3.1.3", + ) + self.assertEqual(selected, LAMMPS_PHONON_IMAGE) + + def test_non_phonon_keeps_configured_lammps_image(self): + configured = "registry.example/custom-lammps:latest" + selected = _select_run_image( + "lammps", + {"properties": [{"type": "eos"}]}, + configured, + ) + self.assertEqual(selected, configured) + def test_with_lammps_retry_env_handles_empty_existing_and_new_commands(self): class DummyConfig: lammps_header_retry_attempts = 4