Skip to content

Commit b389b8f

Browse files
Fix and harden abacuslite ASE interface (#7588)
* fix: harden abacuslite ASE interface * fix: accept boolean abacuslite property keywords * fix: clarify abacuslite keyword comparison
1 parent c9a59ae commit b389b8f

4 files changed

Lines changed: 331 additions & 58 deletions

File tree

interfaces/ASE_interface/abacuslite/core.py

Lines changed: 95 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
import tempfile
3737
import unittest
3838
from pathlib import Path
39-
from typing import Dict, Optional, List, Tuple, Set
39+
from typing import Dict, Optional, List
4040

4141
import numpy as np
4242
from ase.calculators.genericfileio import (
@@ -54,6 +54,7 @@
5454
read_input,
5555
read_stru,
5656
read_kpt,
57+
species_group_indices,
5758
write_input,
5859
write_stru,
5960
write_kpt
@@ -142,7 +143,7 @@ def version(self) -> str:
142143
class AbacusTemplate(CalculatorTemplate):
143144

144145
implemented_properties = [
145-
'energy', 'forces', 'stress', 'free_energy', 'magmom', 'dipole'
146+
'energy', 'forces', 'stress', 'free_energy', 'magmom'
146147
]
147148
_label = 'abacus'
148149

@@ -184,17 +185,13 @@ def get_free_energy_keywords(self) -> Dict[str, str]:
184185
@staticmethod
185186
def get_magmom_keywords(self) -> Dict[str, str]:
186187
return {'nspin': '2'}
187-
188-
@staticmethod
189-
def get_dipole_keywords(self) -> Dict[str, str]:
190-
return {'esolver_type': 'tddft', 'out_dipole': '1'}
191188

192189
def get_property_keywords(self,
193190
parameters: Dict[str, str],
194191
properties: List[str]) -> Dict[str, str]:
195192
'''Connect the relationship between the properties calculation and
196193
the ABACUS keywords. May be more complicated in the future, therefore
197-
it is better to have a seperate mapping function instead of
194+
it is better to have a separate mapping function instead of
198195
implementing in some other functions.
199196
200197
Parameters
@@ -204,17 +201,30 @@ def get_property_keywords(self,
204201
properties : list of str
205202
The list of properties to calculate
206203
'''
207-
# update the parameters with the keywords for the properties
208-
# however, one should also consider that there may be the case that
209-
# contradictory keywords are needed. In this kind of cases,
210-
# we should raise a ValueError
211-
param_cache_ = {}
204+
def keyword_compare_value(value):
205+
if isinstance(value, bool):
206+
return '1' if value else '0'
207+
if isinstance(value, (list, tuple, set)):
208+
return ' '.join(str(i) for i in value)
209+
return str(value)
210+
211+
param_cache_ = {
212+
key: keyword_compare_value(value)
213+
for key, value in parameters.items()
214+
if value is not None
215+
}
216+
212217
def counter(param_new: Dict[str, str]) -> Dict[str, str]:
213-
info = 'desired properties required contradictory keywords'
218+
info = 'desired properties or explicit parameters required contradictory keywords'
219+
staged = {}
214220
for k, v in param_new.items():
215-
if k in param_cache_ and param_cache_[k] != v:
221+
if v is None:
222+
continue
223+
normalized_value = keyword_compare_value(v)
224+
if k in param_cache_ and param_cache_[k] != normalized_value:
216225
raise ValueError(f'{info}: {k}={v} (now), {param_cache_[k]} (before)')
217-
# if it is alright, pass through
226+
staged[k] = normalized_value
227+
param_cache_.update(staged)
218228
return param_new
219229

220230
# update the parameters with the keywords for the properties
@@ -260,9 +270,9 @@ def write_input(self,
260270

261271
# STRU
262272
_ = file_safe_backup(directory / parameters.get('stru_file', 'STRU'))
263-
# reorder the atoms according to the alphabet. Keep the reverse map
273+
# group atoms by first-occurrence species order. Keep the reverse map
264274
# so that we will recover the order in function read_results()
265-
ind = sorted(range(len(atoms)), key=lambda i: atoms[i].symbol)
275+
ind = species_group_indices(atoms.get_chemical_symbols())
266276
self.atomorder = sorted(range(len(atoms)), key=lambda i: ind[i]) # revmap
267277
# then we write
268278
_ = write_stru(atoms[ind],
@@ -297,7 +307,7 @@ def write_input(self,
297307
# array, convert to the string spaced by whitespace
298308
for k, v in parameters.items():
299309
# if the v is iterable, convert to the string spaced by whitespace
300-
if isinstance(v, (List, Tuple, Set)):
310+
if isinstance(v, (list, tuple, set)):
301311
parameters[k] = ' '.join(str(i) for i in v)
302312
dst = directory / self.inputname
303313
_ = file_safe_backup(dst)
@@ -663,5 +673,71 @@ def test_version_number_check(self):
663673
self.assertFalse(switch_io_backend_version('v3.11.0-beta.2'))
664674
self.assertFalse(switch_io_backend_version('v3.11.0'))
665675

676+
def test_property_keywords_reject_conflicting_user_parameters(self):
677+
template = AbacusTemplate()
678+
with self.assertRaises(ValueError):
679+
template.get_property_keywords({'nspin': 1}, ['magmom'])
680+
681+
parameters = template.get_property_keywords({'nspin': 2}, ['magmom'])
682+
self.assertEqual(str(parameters['nspin']), '2')
683+
684+
def test_property_keywords_accept_equivalent_boolean_user_parameters(self):
685+
template = AbacusTemplate()
686+
687+
parameters = template.get_property_keywords(
688+
{'cal_force': True, 'cal_stress': True},
689+
['forces', 'stress']
690+
)
691+
692+
self.assertEqual(str(parameters['cal_force']), '1')
693+
self.assertEqual(str(parameters['cal_stress']), '1')
694+
695+
def test_property_keywords_reject_conflicting_boolean_user_parameters(self):
696+
template = AbacusTemplate()
697+
698+
with self.assertRaises(ValueError):
699+
template.get_property_keywords({'cal_force': False}, ['forces'])
700+
701+
with self.assertRaises(ValueError):
702+
template.get_property_keywords({'cal_stress': False}, ['stress'])
703+
704+
def test_property_keywords_treat_string_values_as_scalars(self):
705+
template = AbacusTemplate()
706+
template.implemented_properties = ['probe']
707+
template.get_probe_keywords = lambda parameters: {'custom_switch': 'true'}
708+
709+
parameters = template.get_property_keywords(
710+
{'custom_switch': 'true'}, ['probe']
711+
)
712+
713+
self.assertEqual(parameters['custom_switch'], 'true')
714+
715+
def test_property_keywords_compare_iterables_like_input_writer(self):
716+
template = AbacusTemplate()
717+
template.implemented_properties = ['probe']
718+
template.get_probe_keywords = lambda parameters: {
719+
'custom_vector': [1, 'true']
720+
}
721+
722+
with self.assertRaises(ValueError):
723+
template.get_property_keywords(
724+
{'custom_vector': [True, 'true']}, ['probe']
725+
)
726+
727+
def test_property_keywords_reject_conflicting_properties(self):
728+
template = AbacusTemplate()
729+
template.implemented_properties = ['prop_a', 'prop_b']
730+
template.get_prop_a_keywords = lambda parameters: {'calculation': 'scf'}
731+
template.get_prop_b_keywords = lambda parameters: {'calculation': 'md'}
732+
733+
with self.assertRaises(ValueError):
734+
template.get_property_keywords({}, ['prop_a', 'prop_b'])
735+
736+
def test_dipole_property_is_not_implemented(self):
737+
template = AbacusTemplate()
738+
self.assertNotIn('dipole', template.implemented_properties)
739+
with self.assertRaises(AssertionError):
740+
template.get_property_keywords({}, ['dipole'])
741+
666742
if __name__ == '__main__':
667-
unittest.main()
743+
unittest.main()

interfaces/ASE_interface/abacuslite/io/generalio.py

Lines changed: 111 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import numpy as np
1212
from ase.atoms import Atoms
1313
from ase.build import bulk
14+
from ase.constraints import FixAtoms, FixCartesian
1415
from ase.data import chemical_symbols, atomic_masses
1516
ATOM_MASS = dict(zip(chemical_symbols, atomic_masses.tolist()))
1617

@@ -84,16 +85,17 @@ def file_safe_backup(fn: Path, suffix: str = 'bak'):
8485
'''
8586
assert isinstance(fn, Path)
8687
where = fn.parent
88+
prefix = f'{fn.name}.{suffix}.'
89+
indexed_backups = []
8790

88-
# get the backup files
89-
fbak = sorted(list(where.glob(f'{fn.name}.{suffix}.*')),
90-
key=lambda p: int(p.name.split('.')[-1]))
91-
if fbak:
92-
# rename the elder by adding 1 to the suffix
93-
for i, f in enumerate(fbak[::-1]): # reverse order, to avoid overwrite
94-
j = len(fbak) - i + 1 #: STRU.bak.i -> STRU.bak.i+1
95-
fname = f.name.replace(f'.{j}', f'.{j+1}')
96-
f.rename(f.parent / fname)
91+
for backup in where.glob(f'{fn.name}.{suffix}.*'):
92+
index_text = backup.name.removeprefix(prefix)
93+
if not re.fullmatch(r'0|[1-9][0-9]*', index_text):
94+
continue
95+
indexed_backups.append((int(index_text), backup))
96+
97+
for backup_index, backup in sorted(indexed_backups, key=lambda item: item[0], reverse=True):
98+
backup.rename(backup.parent / f'{fn.name}.{suffix}.{backup_index + 1}')
9799

98100
# backup the latest file, if there is one
99101
if fn.exists():
@@ -177,6 +179,29 @@ def _write_stru(job_dir, stru, fname='STRU'):
177179

178180
f.write('\n')
179181

182+
def species_group_indices(symbols: List[str]) -> List[int]:
183+
"""Return indices grouped by first-occurrence species order."""
184+
species_order = list(dict.fromkeys(symbols))
185+
return [i for species in species_order for i, symbol in enumerate(symbols) if symbol == species]
186+
187+
188+
def _constraint_mobility(atoms: Atoms) -> np.ndarray:
189+
"""Return ABACUS mobility flags derived from ASE constraints."""
190+
mobility = np.ones((len(atoms), 3), dtype=int)
191+
for constraint in atoms.constraints:
192+
if isinstance(constraint, FixAtoms):
193+
mobility[constraint.get_indices()] = 0
194+
elif isinstance(constraint, FixCartesian):
195+
indices = np.asarray(constraint.get_indices(), dtype=int)
196+
mask = np.asarray(constraint.mask, dtype=bool)
197+
if mask.ndim == 1:
198+
mobility[np.ix_(indices, np.where(mask)[0])] = 0
199+
else:
200+
for atom_index, atom_mask in zip(indices, mask):
201+
mobility[atom_index, atom_mask] = 0
202+
return mobility
203+
204+
180205
def write_stru(stru: Atoms,
181206
outdir: str,
182207
pp_file: Optional[Dict[str, str]],
@@ -216,17 +241,20 @@ def write_stru(stru: Atoms,
216241

217242
elem = stru.get_chemical_symbols()
218243
# ABACUS requires the atoms ranged species-by-species, therefore
219-
# we need to sort the atoms by species
220-
ind = np.argsort(elem)
244+
# we need to group atoms by species. Preserve first-occurrence species
245+
# order from ASE instead of forcing alphabetical order.
246+
ind = species_group_indices(elem)
221247
coords = stru.get_positions()[ind]
248+
mobility = _constraint_mobility(stru)[ind]
222249
elem = [elem[i] for i in ind]
223250

224251
# handle the atomic magnetic moment (issue #6516)
225252
magmoms = np.array([stru[i].magmom for i in ind]).reshape(len(stru), -1) # ncol in [1, 3]
226253
magmoms = [{} if abs(np.linalg.norm(m)) <= 1e-10
227254
else {'mag': m[0] if len(m) == 1 else ('Cartesian', m.tolist())}
228255
for m in magmoms]
229-
elem_uniq, nat = np.unique(elem, return_counts=True)
256+
elem_uniq = list(dict.fromkeys(elem))
257+
nat = np.array([elem.count(e) for e in elem_uniq])
230258
stru_dict = {
231259
'coord_type': 'Cartesian',
232260
'lat': {
@@ -244,7 +272,7 @@ def write_stru(stru: Atoms,
244272
'atom': [
245273
magmoms[j] | {
246274
'coord': coords[j].tolist(), # coordinate
247-
'm': [1, 1, 1], # mobility
275+
'm': mobility[j].tolist(), # mobility
248276
'v': [0.0, 0.0, 0.0], # velocity
249277
} for j in range(np.sum(nat[:i]), np.sum(nat[:i+1]))
250278
]
@@ -531,7 +559,6 @@ def _read_kline(raw: List[str]) -> Dict[str, Any]:
531559
assert all(m for m in mymatch), \
532560
'Invalid KPT file, expected the k-points to be in the format ' \
533561
'"x y z n # comment"'
534-
print(raw)
535562
return {
536563
'mode': 'line',
537564
'coordinate': 'Cartesian' if raw[2].lower().endswith('cartesian') else 'Direct',
@@ -632,6 +659,25 @@ def test_input_io(self):
632659
self.assertDictEqual(data, data_)
633660
# will automatically delete the file after the context manager
634661

662+
def test_file_safe_backup_rotates_numbered_backups(self):
663+
with tempfile.TemporaryDirectory() as tmpdir:
664+
workdir = Path(tmpdir)
665+
live = workdir / 'STRU'
666+
live.write_text('live')
667+
(workdir / 'STRU.bak.0').write_text('bak0')
668+
(workdir / 'STRU.bak.1').write_text('bak1')
669+
(workdir / 'STRU.bak.01').write_text('bak01')
670+
(workdir / 'STRU.bak.note').write_text('note')
671+
672+
file_safe_backup(live)
673+
674+
self.assertFalse(live.exists())
675+
self.assertEqual((workdir / 'STRU.bak.0').read_text(), 'live')
676+
self.assertEqual((workdir / 'STRU.bak.1').read_text(), 'bak0')
677+
self.assertEqual((workdir / 'STRU.bak.2').read_text(), 'bak1')
678+
self.assertEqual((workdir / 'STRU.bak.01').read_text(), 'bak01')
679+
self.assertEqual((workdir / 'STRU.bak.note').read_text(), 'note')
680+
635681
def test_stru_io(self):
636682
from ase.units import Bohr, Angstrom
637683
nacl = bulk('NaCl', 'rocksalt', a=5.64)
@@ -676,14 +722,56 @@ def test_stru_io(self):
676722
self.assertEqual(a['m'], [1, 1, 1])
677723
self.assertEqual(a['v'], [0.0, 0.0, 0.0])
678724

679-
self.assertEqual(stru_['species'][0]['symbol'], 'Cl')
680-
self.assertEqual(stru_['species'][1]['symbol'], 'Na')
681-
self.assertEqual(stru_['species'][0]['mass'], ATOM_MASS['Cl'])
682-
self.assertEqual(stru_['species'][1]['mass'], ATOM_MASS['Na'])
683-
self.assertEqual(stru_['species'][0]['pp_file'], 'Cl.pz-bhs.UPF')
684-
self.assertEqual(stru_['species'][1]['pp_file'], 'Na.pz-bhs.UPF')
685-
self.assertEqual(stru_['species'][0]['orb_file'], 'Cl_gga_6au_100Ry_2s2p1d.orb')
686-
self.assertEqual(stru_['species'][1]['orb_file'], 'Na_gga_6au_100Ry_2s2p1d.orb')
725+
self.assertEqual(stru_['species'][0]['symbol'], 'Na')
726+
self.assertEqual(stru_['species'][1]['symbol'], 'Cl')
727+
self.assertEqual(stru_['species'][0]['mass'], ATOM_MASS['Na'])
728+
self.assertEqual(stru_['species'][1]['mass'], ATOM_MASS['Cl'])
729+
self.assertEqual(stru_['species'][0]['pp_file'], 'Na.pz-bhs.UPF')
730+
self.assertEqual(stru_['species'][1]['pp_file'], 'Cl.pz-bhs.UPF')
731+
self.assertEqual(stru_['species'][0]['orb_file'], 'Na_gga_6au_100Ry_2s2p1d.orb')
732+
self.assertEqual(stru_['species'][1]['orb_file'], 'Cl_gga_6au_100Ry_2s2p1d.orb')
733+
734+
def test_write_stru_preserves_first_occurrence_species_order(self):
735+
atoms = Atoms(
736+
symbols=['C', 'C', 'Pt', 'H', 'H'],
737+
positions=np.zeros((5, 3)),
738+
cell=np.eye(3),
739+
)
740+
741+
with tempfile.TemporaryDirectory() as tmpdir:
742+
write_stru(
743+
atoms,
744+
outdir=tmpdir,
745+
pp_file={'C': 'C.upf', 'Pt': 'Pt.upf', 'H': 'H.upf'},
746+
)
747+
stru = read_stru(Path(tmpdir) / 'STRU')
748+
749+
self.assertEqual([s['symbol'] for s in stru['species']], ['C', 'Pt', 'H'])
750+
self.assertEqual([s['natom'] for s in stru['species']], [2, 1, 2])
751+
752+
def test_write_stru_uses_ase_constraints_for_mobility(self):
753+
atoms = Atoms(
754+
symbols=['C', 'C', 'Pt', 'H'],
755+
positions=np.zeros((4, 3)),
756+
cell=np.eye(3),
757+
)
758+
atoms.set_constraint([
759+
FixAtoms(indices=[0]),
760+
FixCartesian(2, mask=[True, False, True]),
761+
])
762+
763+
with tempfile.TemporaryDirectory() as tmpdir:
764+
write_stru(
765+
atoms,
766+
outdir=tmpdir,
767+
pp_file={'C': 'C.upf', 'Pt': 'Pt.upf', 'H': 'H.upf'},
768+
)
769+
stru = read_stru(Path(tmpdir) / 'STRU')
770+
771+
self.assertEqual(stru['species'][0]['atom'][0]['m'], [0, 0, 0])
772+
self.assertEqual(stru['species'][0]['atom'][1]['m'], [1, 1, 1])
773+
self.assertEqual(stru['species'][1]['atom'][0]['m'], [0, 1, 0])
774+
self.assertEqual(stru['species'][2]['atom'][0]['m'], [1, 1, 1])
687775

688776
def test_kpt_io(self):
689777
kpt = {
@@ -818,4 +906,4 @@ def test_write_stru_with_magmom(self):
818906
self.assertEqual(stru['species'][2]['atom'][0]['mag'], ('Cartesian', [0.0, 0.0, 3.0]))
819907

820908
if __name__ == '__main__':
821-
unittest.main()
909+
unittest.main()

0 commit comments

Comments
 (0)