1111import numpy as np
1212from ase .atoms import Atoms
1313from ase .build import bulk
14+ from ase .constraints import FixAtoms , FixCartesian
1415from ase .data import chemical_symbols , atomic_masses
1516ATOM_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+
180205def 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
820908if __name__ == '__main__' :
821- unittest .main ()
909+ unittest .main ()
0 commit comments