Skip to content

Commit 4b83167

Browse files
committed
fix: harden abacuslite ASE interface
1 parent 33a7acd commit 4b83167

4 files changed

Lines changed: 283 additions & 54 deletions

File tree

interfaces/ASE_interface/abacuslite/core.py

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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,10 +185,6 @@ 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],
@@ -204,17 +201,28 @@ 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 normalize_keyword_value(value):
205+
if isinstance(value, (list, tuple, set)):
206+
return ' '.join(str(i) for i in value)
207+
return str(value)
208+
209+
param_cache_ = {
210+
key: normalize_keyword_value(value)
211+
for key, value in parameters.items()
212+
if value is not None
213+
}
214+
212215
def counter(param_new: Dict[str, str]) -> Dict[str, str]:
213-
info = 'desired properties required contradictory keywords'
216+
info = 'desired properties or explicit parameters required contradictory keywords'
217+
staged = {}
214218
for k, v in param_new.items():
215-
if k in param_cache_ and param_cache_[k] != v:
219+
if v is None:
220+
continue
221+
normalized_value = normalize_keyword_value(v)
222+
if k in param_cache_ and param_cache_[k] != normalized_value:
216223
raise ValueError(f'{info}: {k}={v} (now), {param_cache_[k]} (before)')
217-
# if it is alright, pass through
224+
staged[k] = normalized_value
225+
param_cache_.update(staged)
218226
return param_new
219227

220228
# update the parameters with the keywords for the properties
@@ -260,9 +268,9 @@ def write_input(self,
260268

261269
# STRU
262270
_ = file_safe_backup(directory / parameters.get('stru_file', 'STRU'))
263-
# reorder the atoms according to the alphabet. Keep the reverse map
271+
# group atoms by first-occurrence species order. Keep the reverse map
264272
# so that we will recover the order in function read_results()
265-
ind = sorted(range(len(atoms)), key=lambda i: atoms[i].symbol)
273+
ind = species_group_indices(atoms.get_chemical_symbols())
266274
self.atomorder = sorted(range(len(atoms)), key=lambda i: ind[i]) # revmap
267275
# then we write
268276
_ = write_stru(atoms[ind],
@@ -663,5 +671,28 @@ def test_version_number_check(self):
663671
self.assertFalse(switch_io_backend_version('v3.11.0-beta.2'))
664672
self.assertFalse(switch_io_backend_version('v3.11.0'))
665673

674+
def test_property_keywords_reject_conflicting_user_parameters(self):
675+
template = AbacusTemplate()
676+
with self.assertRaises(ValueError):
677+
template.get_property_keywords({'nspin': 1}, ['magmom'])
678+
679+
parameters = template.get_property_keywords({'nspin': 2}, ['magmom'])
680+
self.assertEqual(str(parameters['nspin']), '2')
681+
682+
def test_property_keywords_reject_conflicting_properties(self):
683+
template = AbacusTemplate()
684+
template.implemented_properties = ['prop_a', 'prop_b']
685+
template.get_prop_a_keywords = lambda parameters: {'calculation': 'scf'}
686+
template.get_prop_b_keywords = lambda parameters: {'calculation': 'md'}
687+
688+
with self.assertRaises(ValueError):
689+
template.get_property_keywords({}, ['prop_a', 'prop_b'])
690+
691+
def test_dipole_property_is_not_implemented(self):
692+
template = AbacusTemplate()
693+
self.assertNotIn('dipole', template.implemented_properties)
694+
with self.assertRaises(AssertionError):
695+
template.get_property_keywords({}, ['dipole'])
696+
666697
if __name__ == '__main__':
667-
unittest.main()
698+
unittest.main()

interfaces/ASE_interface/abacuslite/io/generalio.py

Lines changed: 111 additions & 22 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
]
@@ -632,6 +660,25 @@ def test_input_io(self):
632660
self.assertDictEqual(data, data_)
633661
# will automatically delete the file after the context manager
634662

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

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

688777
def test_kpt_io(self):
689778
kpt = {
@@ -818,4 +907,4 @@ def test_write_stru_with_magmom(self):
818907
self.assertEqual(stru['species'][2]['atom'][0]['mag'], ('Cartesian', [0.0, 0.0, 3.0]))
819908

820909
if __name__ == '__main__':
821-
unittest.main()
910+
unittest.main()

interfaces/ASE_interface/abacuslite/io/latestio.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -507,7 +507,7 @@ def read_abacus_out(fileobj,
507507
calc = SinglePointDFTCalculator(atoms=atoms, energy=ener['E_KohnSham'],
508508
free_energy=ener['E_KohnSham'],
509509
forces=frs, stress=strs,
510-
magmoms=mag, efermi=ener['E_Fermi'],
510+
magmoms=mag[ind], efermi=ener['E_Fermi'],
511511
ibzkpts=kvecd, dipole=None)
512512
# import the eigenvalues and occupations kpoint-by-kpoint
513513
calc.kpts = []
@@ -531,6 +531,44 @@ class TestLatestIO(unittest.TestCase):
531531
here = Path(__file__).parent
532532
testfiles = here / 'testfiles'
533533

534+
def test_read_abacus_out_reorders_calculator_magmoms(self):
535+
import tempfile
536+
from unittest.mock import patch
537+
538+
frame = {
539+
'elem': ['Na', 'Na', 'Cl'],
540+
'coords': np.array([[0.0, 0.0, 0.0],
541+
[1.0, 0.0, 0.0],
542+
[2.0, 0.0, 0.0]]),
543+
'cell': np.eye(3),
544+
}
545+
elecstate = [{
546+
'k': np.zeros((1, 1, 3)),
547+
'e': np.zeros((1, 1, 1)),
548+
'occ': np.ones((1, 1, 1)),
549+
}]
550+
energies = [{'E_KohnSham': -1.0, 'E_Fermi': 0.0}]
551+
kpoints = ((np.zeros((1, 3)), np.ones(1), None), None, None, None, None)
552+
553+
with tempfile.TemporaryDirectory() as tmpdir:
554+
running_log = Path(tmpdir) / 'running_scf.log'
555+
running_log.write_text('')
556+
(Path(tmpdir) / 'eig_occ.txt').write_text('')
557+
with patch(__name__ + '.read_esolver_type_from_running_log', return_value='ksdft'), \
558+
patch(__name__ + '.read_traj_from_running_log', return_value=[frame]), \
559+
patch(__name__ + '.read_band_from_eig_occ', return_value=elecstate), \
560+
patch(__name__ + '.read_forces_from_running_log', return_value=[]), \
561+
patch(__name__ + '.read_stress_from_running_log', return_value=[]), \
562+
patch(__name__ + '.read_kpoints_from_running_log', return_value=kpoints), \
563+
patch(__name__ + '.read_energies_from_running_log', return_value=([], [])), \
564+
patch(__name__ + '.read_iter_header_from_running_log', return_value=[]), \
565+
patch(__name__ + '.find_final_info_with_iter_header', return_value=energies), \
566+
patch(__name__ + '.read_magmom_from_running_log', return_value=[np.array([10.0, 20.0, 30.0])]):
567+
atoms = read_abacus_out(running_log, sort_atoms_with=[0, 2, 1])[0]
568+
569+
self.assertEqual(atoms.get_chemical_symbols(), ['Na', 'Cl', 'Na'])
570+
self.assertTrue(np.allclose(atoms.calc.results['magmoms'], [10.0, 30.0, 20.0]))
571+
534572
def test_read_esolver_type_from_running_log(self):
535573
self.assertEqual(
536574
read_esolver_type_from_running_log(
@@ -674,4 +712,4 @@ def test_read_iter_header_from_running_log(self):
674712
self.assertTupleEqual(header[2], (2, 1))
675713

676714
if __name__ == '__main__':
677-
unittest.main()
715+
unittest.main()

0 commit comments

Comments
 (0)