Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Local pre-commit checks (no remote hook repos; works behind restricted proxies).
# Install once:
# pip install pre-commit && pre-commit install
repos:
- repo: local
hooks:
- id: check-merge-conflict
name: check merge conflict markers
entry: python3 -c
args:
- |
import pathlib, re, sys
pat = re.compile(r"^(<<<<<<< |>>>>>>> )")
bad = False
for raw in sys.argv[1:]:
path = pathlib.Path(raw)
if not path.is_file():
continue
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except Exception:
continue
for i, line in enumerate(text.splitlines(), 1):
if pat.match(line):
print(f"{path}:{i}: merge conflict marker")
bad = True
raise SystemExit(1 if bad else 0)
language: system
types: [text]

- id: apex-unit-smoke
name: APEX unit smoke (gamma + ticket + failed-result)
entry: bash -c 'cd tests && python3 -m unittest -v test_gamma.TestGamma.test_compute_lower test_skill_scripts.TestGenerateConfigHelpers.test_get_bohrium_ticket_success test_ops.TestTaskStatusHelpers.test_is_failed_task_result_accepts_dict_and_mapping_like'
language: system
pass_filenames: false
types: [python]
21 changes: 14 additions & 7 deletions apex/core/property/Property.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,20 @@


def is_failed_task_result(result) -> bool:
"""Return True when a task result cannot be used for property aggregation."""
return (
result is None
or not isinstance(result, dict)
or result.get("failed") is True
or "energies" not in result
)
"""Return True when a task result cannot be used for property aggregation.

Accepts plain dicts and mapping-like objects such as dpdata LabeledSystem
(fixture ``result_task.json`` files often load as the latter).
"""
if result is None:
return True
if isinstance(result, dict) and result.get("failed") is True:
return True
try:
energies = result["energies"]
except Exception:
return True
return energies is None


def _task_marked_failed(task_dir: str) -> bool:
Expand Down
6 changes: 5 additions & 1 deletion apex/skills/apex-flow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,12 @@ Options to offer via AskQuestion:
- 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`.
- **Ticket API**: `GET https://openapi.dp.tech/openapi/v1/ticket/get?accessKey=<KEY>&expireIn=<seconds>`
- Header: `x-app-key: ""` (empty string)
- `expireIn` 单位为秒,默认值 `604800`(7 天)。`generate_config.py` 已内置此默认值。
- 返回: `{"code": 0, "data": {"ticket": "UUID"}}`
- 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.
- `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.
Expand Down
105 changes: 100 additions & 5 deletions apex/skills/apex-flow/scripts/generate_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
# =============================================================================

TICKET_API_URL = "https://openapi.dp.tech/openapi/v1/ticket/get"
TICKET_EXPIRE_SECONDS = 604800 # 7 days; default ticket validity for APEX workflows
DFLOW_HOST = "https://workflows.deepmodeling.com"
APEX_IMAGE = "registry.dp.tech/dptech/dp/native/prod-397637/apex-flow:1.3.0.post"
LAMMPS_IMAGE = (
Expand Down Expand Up @@ -174,6 +175,76 @@
}


# =============================================================================
# Adaptive KSPACING based on system type and atom count
# =============================================================================
# Rules:
# - Bulk crystal: denser k-mesh for small unit cells, sparser for supercells
# - Surface/slab: moderate k-mesh (vacuum direction handled by APEX)
# - Amorphous/liquid: low k-point requirement (no long-range periodicity)
# Always use KGAMMA = .TRUE.
#
# Users can override by providing explicit kspacing in --incar or cal_setting.

def classify_system(structure) -> str:
"""Classify a pymatgen Structure as 'bulk', 'surface', or 'amorphous'.

Heuristic:
- If any lattice vector > 15 Å and the cell is highly anisotropic
(max/min ratio > 2.5), treat as surface/slab.
- Otherwise treat as bulk crystal.
- 'amorphous' must be explicitly requested by the user (not auto-detected).
"""
lengths = sorted(structure.lattice.abc)
ratio = lengths[-1] / max(lengths[0], 0.1)
if lengths[-1] > 15.0 and ratio > 2.5:
return "surface"
return "bulk"


def adaptive_kspacing(structure, system_type: str = None) -> float:
"""Determine KSPACING based on system type and atom count.

Args:
structure: pymatgen Structure object
system_type: 'bulk', 'surface', or 'amorphous' (auto-detected if None)

Returns:
Recommended KSPACING value (Å⁻¹ for VASP, 1/Bohr for ABACUS)

Rules (unified for all DFT properties):
bulk_crystal:
N <= 10: 0.16
10 < N <= 50: 0.20
50 < N <= 150: 0.25
N > 150: 0.30
surface_slab:
N <= 100: 0.22
N > 100: 0.28
amorphous_liquid:
N <= 100: 0.30
N > 100: 0.38
"""
if system_type is None:
system_type = classify_system(structure)

n_atoms = len(structure)

if system_type == "amorphous":
return 0.30 if n_atoms <= 100 else 0.38
elif system_type == "surface":
return 0.22 if n_atoms <= 100 else 0.28
else: # bulk (default)
if n_atoms <= 10:
return 0.16
elif n_atoms <= 50:
return 0.20
elif n_atoms <= 150:
return 0.25
else:
return 0.30


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:
Expand Down Expand Up @@ -218,7 +289,7 @@ def default_vasp_run_command(nprocs: int = 32) -> str:
"force_thr_ev": 0.02,
"stress_thr": 1.0,
"relax_nmax": 50,
"kspacing": 0.20, # FAST: ~6x6x6 for typical metals (vs 0.10 → 12x12x12)
"kspacing": None, # Auto-filled by adaptive_kspacing(structure)
},
"phonon_scf": {
"calculation": "scf",
Expand All @@ -231,10 +302,27 @@ def default_vasp_run_command(nprocs: int = 32) -> str:
"mixing_type": "broyden",
"mixing_beta": 0.7,
"cal_force": 1,
"kspacing": 0.15, # Slightly tighter for force accuracy
"kspacing": None, # Auto-filled by adaptive_kspacing(structure)
},
}

# Default VASP INCAR template (KSPACING auto-filled by adaptive_kspacing)
VASP_INCAR_TEMPLATE = """\
SYSTEM = APEX calculation
PREC = Accurate
ENCUT = 520
EDIFF = 1E-6
EDIFFG = -0.01
IBRION = 2
NSW = 200
ISIF = 3
ISMEAR = 1
SIGMA = 0.1
LREAL = Auto
KSPACING = {kspacing}
KGAMMA = .TRUE.
"""

# DFT-specific property defaults: smaller supercells, fewer points
DFT_PROPERTY_OVERRIDES = {
"phonon": {
Expand Down Expand Up @@ -264,18 +352,25 @@ def default_vasp_run_command(nprocs: int = 32) -> str:
# Ticket conversion
# =============================================================================

def get_bohrium_ticket(access_key: str) -> str:
def get_bohrium_ticket(access_key: str, expire_seconds: int = TICKET_EXPIRE_SECONDS) -> str:
"""
Convert a Bohrium access_key to a dflow ticket via the OpenAPI.

API: GET https://openapi.dp.tech/openapi/v1/ticket/get?accessKey=<KEY>
API: GET https://openapi.dp.tech/openapi/v1/ticket/get?accessKey=<KEY>&expireIn=<seconds>
Header: x-app-key: (empty string)
Response: {"code": 0, "data": {"ticket": "UUID-36-chars"}}

Args:
access_key: Bohrium access key from environment.
expire_seconds: Ticket validity in seconds. Default 604800 (7 days).
Must be called from sandbox where BOHRIUM_ACCESS_KEY is available.
The generated ticket is embedded in global.json for use by
containers that lack the access key.

Returns the ticket string (UUID).
Raises RuntimeError on failure.
"""
url = f"{TICKET_API_URL}?accessKey={access_key}"
url = f"{TICKET_API_URL}?accessKey={access_key}&expireIn={expire_seconds}"
req = Request(url, method="GET")
req.add_header("x-app-key", "")

Expand Down
5 changes: 3 additions & 2 deletions tests/test_abacus_property.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,6 @@ def test_make_property_refine(self):
pwd = os.getcwd()
target_path_0 = "confs/fcc-Al/eos_00"
target_path_2 = "confs/fcc-Al/eos_02"
path_to_work = os.path.abspath(target_path_0)

make_property(self.jdata["structures"], self.jdata["interaction"], [property])
dfm_dirs_0 = glob.glob(os.path.join(target_path_0, "task.*"))
Expand All @@ -336,7 +335,9 @@ def test_make_property_refine(self):
make_property(
self.jdata["structures"], self.jdata["interaction"], new_prop_list
)
self.assertTrue(os.path.isdir(path_to_work.replace("00", "02")))
# Prefer relative target path: abspath(...).replace("00","02") breaks when
# the workspace path itself contains "00" (e.g. /personal/00_APEX/...).
self.assertTrue(os.path.isdir(target_path_2))
os.chdir(pwd)
dfm_dirs_2 = glob.glob(os.path.join(target_path_2, "task.*"))
self.assertEqual(len(dfm_dirs_2), len(dfm_dirs_0))
15 changes: 15 additions & 0 deletions tests/test_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
load_and_classify_task_status,
)
from apex.utils import apex_task_succeeded, all_apex_task_status_succeeded
from apex.core.property.Property import is_failed_task_result
try:
from context import write_poscar
except ModuleNotFoundError:
Expand All @@ -45,6 +46,20 @@


class TestTaskStatusHelpers(unittest.TestCase):
def test_is_failed_task_result_accepts_dict_and_mapping_like(self):
self.assertTrue(is_failed_task_result(None))
self.assertTrue(is_failed_task_result({"failed": True, "energies": [1.0]}))
self.assertTrue(is_failed_task_result({"atom_numbs": [1]}))
self.assertFalse(is_failed_task_result({"energies": [-1.0], "atom_numbs": [1]}))

class MappingLike:
def __getitem__(self, key):
if key == "energies":
return [-1.0]
raise KeyError(key)

self.assertFalse(is_failed_task_result(MappingLike()))

def test_task_failure_helpers_cover_error_branches(self):
with tempfile.TemporaryDirectory() as tmpdir:
task_dir = Path(tmpdir)
Expand Down
3 changes: 3 additions & 0 deletions tests/test_skill_scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ def test_get_bohrium_ticket_success(self):
self.assertEqual(self.gen.get_bohrium_ticket("secret"), ticket)
request = mocked.call_args.args[0]
self.assertIn("accessKey=secret", request.full_url)
self.assertIn(
f"expireIn={self.gen.TICKET_EXPIRE_SECONDS}", request.full_url
)

def test_get_bohrium_ticket_rejects_transport_and_api_errors(self):
with patch.object(self.gen, "urlopen", side_effect=URLError("offline")):
Expand Down