wmaee.codes.pyiron.pyiron_NEB_task
==============================================================================
Module Name: pyiron_NEB_task.py
Description: This module provides a framework for setting up and executing NEB (Nudged Elastic Band) calculations using the pyiron framework with VASP. It includes classes and methods to manage initial and final states, reorder atoms, create NEB jobs, handle image interpolation, and collect and fit energy data.
WARNING: It assumes remote and manual execution of VASP (on the cluster), it
only handles transfer of the input/output files, not the execution (as should
be ideally the case with pyiron).
Dependencies: - pyiron_atomistics - pyiron_base - NumPy - SciPy
Usage: from NEB import NEB # Create a NEB object, set initial and final states, and run the NEB calculation neb = NEB(pr, 'NEB_Calculation') neb.set_initial_state(initial_job) neb.set_final_state(final_job) neb_job = neb.create_neb_job(job_name='neb', nImages=5) neb_job.write_and_transfer_to_remote() # run the job manually on HPC neb_job.transfer_from_remote_and_collect() # get the barrier print(job['output/neb/barrier_forward'])
==============================================================================
1""" 2============================================================================== 3Module Name: pyiron_NEB_task.py 4============================================================================== 5Description: 6 This module provides a framework for setting up and executing NEB (Nudged 7 Elastic Band) calculations using the pyiron framework with VASP. It includes 8 classes and methods to manage initial and final states, reorder atoms, 9 create NEB jobs, handle image interpolation, and collect and fit energy data. 10 11 WARNING: It assumes remote and manual execution of VASP (on the cluster), it 12 only handles transfer of the input/output files, not the execution (as should 13 be ideally the case with pyiron). 14 15Dependencies: 16 - pyiron_atomistics 17 - pyiron_base 18 - NumPy 19 - SciPy 20 21Usage: 22 from NEB import NEB 23 # Create a NEB object, set initial and final states, and run the NEB calculation 24 neb = NEB(pr, 'NEB_Calculation') 25 neb.set_initial_state(initial_job) 26 neb.set_final_state(final_job) 27 neb_job = neb.create_neb_job(job_name='neb', nImages=5) 28 neb_job.write_and_transfer_to_remote() 29 # run the job manually on HPC 30 neb_job.transfer_from_remote_and_collect() 31 # get the barrier 32 print(job['output/neb/barrier_forward']) 33 34============================================================================== 35""" 36 37__author__ = "David Holec" 38__version__ = "0.0.1" 39__email__ = "david.holec@unileoben.ac.ay" 40__status__ = "Prototype" # Options: "Development", "Production", "Prototype" 41__date__ = "2024-08-09" 42__license__ = "BSD 3-Clause License" 43 44 45from pyiron_atomistics.vasp.vasp import Vasp as Vasp_job 46from pyiron_atomistics.vasp.structure import read_atoms 47from pyiron_atomistics.vasp.parser.oszicar import Oszicar 48from pyiron_atomistics.atomistics.job.atomistic import Trajectory 49from pyiron_atomistics.atomistics.structure.atoms import Atoms 50from pyiron_base import state 51from pyiron_base.jobs.job.extension.jobstatus import job_status_successful_lst 52from glob import glob 53import os 54import os.path 55from shutil import rmtree 56import numpy as np 57from scipy.optimize import minimize 58from numpy.polynomial import Polynomial 59 60def get_species_symbols_new(self): 61 """ 62 MONKEY PATCH to get correct order of POTCARs. 63 64 Returns: 65 numpy.ndarray: List of the symbols of the species. 66 """ 67 sp = [self.elements[0]] 68 for el in self.elements[1:]: 69 if el != sp[-1]: 70 sp.append(el) 71 return np.array([el.Abbreviation for el in sp]) 72 73# Patch the `get_species_symbols` method of the `Atoms` class 74Atoms.get_species_symbols = get_species_symbols_new 75 76 77def _wrap_positions(struct, wrap): 78 """ 79 Wrap atomic positions into the unit cell. 80 81 Parameters: 82 struct (Atoms): Atomic structure. 83 wrap (float or list): Wrap value(s) for each dimension. 84 85 Returns: 86 Atoms: Structure with wrapped positions. 87 """ 88 if isinstance(wrap, float): 89 wrap = [wrap] * 3 90 91 new_pos = [] 92 for pos in struct.get_scaled_positions(): 93 for i in range(3): 94 if pos[i] > wrap[i]: 95 pos[i] -= 1.0 96 new_pos.append(pos) 97 98 struct.set_scaled_positions(new_pos) 99 return struct 100 101 102class NEB(): 103 """ 104 Class to handle the setup and execution of NEB (Nudged Elastic Band) calculations. 105 """ 106 107 def __init__(self, pr, NEB_calc_name): 108 """ 109 Initialize the NEB class. 110 111 Parameters: 112 pr (Project): Pyiron project object. 113 NEB_calc_name (str): Name of the NEB calculation. 114 """ 115 self.pr = pr.create_group(NEB_calc_name) 116 self.initial = None 117 118 def add_initial_state(self, initial_job, delete_existing=False, wrap=0.98): 119 """ 120 Add the initial state for the NEB calculation. 121 122 Parameters: 123 initial_job (Vasp_job): Initial state as a VASP job. 124 delete_existing (bool, optional): Whether to delete existing files. 125 wrap (float, optional): Wrap value for the atomic positions. 126 127 Raises: 128 TypeError: If `initial_job` is not of type `Vasp_job`. 129 """ 130 if not isinstance(initial_job, Vasp_job): 131 raise TypeError("Initial job must be a VASP job (pyiron_atomistics.vasp.vasp.Vasp)") 132 133 self.initial_job = initial_job 134 self.initial = initial_job.get_structure() 135 if wrap is not False: 136 self.initial = _wrap_positions(initial_job.get_structure(), wrap) 137 138 # Setup symbolic links and directory structure 139 rel_path_to_initial = os.path.relpath(self.initial_job.path, self.pr.path) 140 cwd = os.getcwd() 141 os.chdir(self.pr.path) 142 if delete_existing: 143 try: 144 os.remove('initial.h5') 145 rmtree('initial_hdf5') 146 except: 147 pass 148 os.symlink(rel_path_to_initial+'.h5', 'initial.h5') 149 os.mkdir('initial_hdf5') 150 os.chdir('initial_hdf5') 151 os.symlink(os.path.join('..', rel_path_to_initial+'_hdf5', initial_job.name), 'initial', target_is_directory=True) 152 os.chdir(cwd) 153 154 def set_initial_state(self, initial_job, wrap=0.98): 155 """ 156 Set the initial state from an existing VASP job. 157 158 Parameters: 159 initial_job (Vasp_job): Initial state as a VASP job. 160 wrap (float, optional): Wrap value for the atomic positions. 161 """ 162 self.initial_job = initial_job 163 struct = initial_job.get_structure() 164 self.initial = struct 165 if wrap is not False: 166 self.initial = _wrap_positions(struct, wrap) 167 168 def set_final_state(self, final_job, wrap=0.98): 169 """ 170 Set the final state for the NEB calculation. 171 172 Parameters: 173 final_job (Vasp_job): Final state as a VASP job. 174 wrap (float, optional): Wrap value for the atomic positions. 175 """ 176 self.final_job = final_job 177 struct = final_job.get_structure() 178 self.final = struct 179 if wrap is not False: 180 self.final = _wrap_positions(struct, wrap) 181 182 183 def reorder_final(self, moving_index, max_shift=0.05, verbose=True, wrap=0.98): 184 """ 185 Reorder the final structure based on the initial structure. 186 187 Parameters: 188 moving_index (int): Index of the moving atom. 189 max_shift (float, optional): Maximum allowable shift for atom mapping. 190 verbose (bool, optional): If True, prints information about the atom positions. 191 wrap (float, optional): Wrap value for the atomic positions. 192 193 Raises: 194 Exception: If there are issues with the atom mapping. 195 """ 196 197 initial = self.initial 198 final = self.final 199 if wrap is not False: 200 initial = _wrap_positions(initial, wrap) 201 final = _wrap_positions(final, wrap) 202 203 mapped = [False for _ in range(len(final))] 204 mapping = [None for _ in range(len(initial))] 205 206 for iat, at in enumerate(initial): 207 if iat == moving_index and verbose: 208 print('position in the initial structure:', at.position) 209 else: 210 nn = final.get_neighborhood(at.position, cutoff_radius=max_shift) 211 if len(nn.distances) != 1: 212 raise Exception(f"Problem with mapping: atom {iat} in the initial structure was identified with {len(nn.distances)} images") 213 elif mapped[nn.indices[0]]: 214 raise Exception(f"Problem with mapping: atom {iat} in the initial should be mapped to {nn.indices[0]} in the final structure but this atom has already been mapped.") 215 else: 216 mapped[nn.indices[0]] = True 217 mapping[iat] = nn.indices[0] 218 219 unmapped = [i for i in range(len(final)) if not mapped[i]] 220 if len(unmapped) != 1: 221 raise Exception(f"Problem with mapping: only 1 atom must remain after mapping! Unmapped atoms: {unmapped}") 222 223 mapping[moving_index] = unmapped[0] 224 if verbose: 225 print('position in the final structure:', self.final.positions[unmapped[0]]) 226 227 new_positions = [final.positions[mapping[i]] for i in range(len(initial))] 228 new_elements = [final.elements[mapping[i]] for i in range(len(initial))] 229 new_final = self.pr.create.structure.atoms( 230 cell=final.cell, 231 positions=new_positions, 232 elements=new_elements 233 ) 234 self.final = new_final 235 236 def create_neb_job(self, job_name='neb', nImages=3): 237 """ 238 Create a NEB job. 239 240 Parameters: 241 job_name (str, optional): Name of the NEB job. 242 nImages (int, optional): Number of images for the NEB calculation. 243 244 Returns: 245 NEBjob: Created NEB job object. 246 """ 247 try: 248 job = self.pr[job_name] 249 except: 250 job = self.pr.create_job(job_type=self.pr.job_type.Vasp, job_name=job_name) 251 job.__class__ = NEBjob 252 job.structure = self.initial 253 job.set_kpoints(mesh=[3, 3, 3], scheme='GC') 254 job.calc_minimize( 255 electronic_steps=60, 256 retain_charge_density=False, 257 ) 258 job.input.incar['ISIF'] = 2 259 job.input.incar['ENCUT'] = 520 # from MP 260 job.input.incar['IMAGES'] = nImages - 2 261 job.input.incar['IBRION'] = 1 262 job.input.incar['NFREE'] = 2 263 job.save() 264 265 job['input'].create_group('neb') 266 job['input/neb'].put('nImages', nImages) 267 job['input/neb'].put('initial_image', self.initial) 268 job['input/neb'].put('final_image', self.final) 269 job['input/neb'].put('initial_energy', self.initial_job['output/generic/energy_pot'][-1]) 270 job['input/neb'].put('final_energy', self.final_job['output/generic/energy_pot'][-1]) 271 job._create_images() 272 job.structure = job['input/neb/initial_image'].to_object() 273 job.write_input() 274 else: 275 state.logger.warning(f'Job `{job_name}` exists. Loading from database instead of creating it!') 276 return job 277 278 279class NEBjob(Vasp_job): 280 """ 281 Class for handling specific NEB (Nudged Elastic Band) job operations. 282 """ 283 284 285 286 def _create_images(self, wrap=0.98): 287 """ 288 Create interpolated images between the initial and final states. 289 290 Parameters: 291 wrap (float, optional): Wrap value for the atomic positions. 292 """ 293 cells = [] 294 positions = [] 295 initial = _wrap_positions(self['input/neb/initial_image'].to_object(), wrap) 296 final = _wrap_positions(self['input/neb/final_image'].to_object(), wrap) 297 nImages = self['input/neb/nImages'] 298 299 for x in np.linspace(0, 1, nImages): 300 cells.append(initial.cell * (1 - x) + final.cell * x) 301 positions.append(initial.positions * (1 - x) + final.positions * x) 302 303 self['input/neb'].put('initial_cells', cells) 304 self['input/neb'].put('initial_positions', positions) 305 306 def get_initial_images(self): 307 """ 308 Retrieve the initial interpolated images. 309 310 Returns: 311 Trajectory: Trajectory object containing the initial images. 312 """ 313 initial = self['input/neb/initial_image'].to_object() 314 positions = self['input/neb/initial_positions'] 315 cells = self['input/neb/initial_cells'] 316 return Trajectory(positions, initial, cells=cells) 317 318 def get_final_images(self): 319 """ 320 Retrieve the final interpolated images after relaxation. 321 322 Returns: 323 Trajectory: Trajectory object containing the final images. 324 """ 325 initial = self['input/neb/initial_image'].to_object() 326 positions = self['output/neb/final_positions'] 327 cells = self['output/neb/final_cells'] 328 return Trajectory(positions, initial, cells=cells) 329 330 def _write_images(self): 331 """ 332 Write the interpolated images to the appropriate directories. 333 """ 334 path = self.path + '_hdf5/' + self.job_name + '/' 335 nImages = self['input/neb/nImages'] 336 initial_structs = self.get_initial_images() 337 338 for i in range(nImages): 339 os.chdir(path) 340 case = f'{i:02d}' 341 if not os.path.exists(case): 342 os.mkdir(case) 343 os.chdir(case) 344 struct = initial_structs[i] 345 struct.write('POSCAR', format='vasp') 346 347 def write_and_transfer_to_remote(self): 348 """ 349 Write the input files and transfer them to the remote cluster. 350 351 Warnings: 352 If the job is already marked as finished, a warning is issued and no action is taken. 353 """ 354 if self.status not in job_status_successful_lst: 355 self._write_images() 356 self.write_input() 357 358 filename = state.queue_adapter.convert_path_to_remote( 359 path=self.project_hdf5.file_name 360 ) 361 working_directory = state.queue_adapter.convert_path_to_remote( 362 path=self.working_directory 363 ) 364 for filename in glob(self.project_hdf5.path + '_hdf5/**', recursive=True): 365 if os.path.isfile(filename): 366 state.queue_adapter.transfer_file_to_remote( 367 file=filename, transfer_back=False 368 ) 369 else: 370 state.logger.warning(f'Job `{self.job_name}` is marked as finished, uploading doesn\'t make sense') 371 372 def collect_data(self, wrap=0.98): 373 """ 374 Collect the data from the NEB calculation. 375 376 Parameters: 377 wrap (float or bool, optional): Wrap value for atomic positions. Set to `False` to disable wrapping. 378 """ 379 path = self.path + '_hdf5/' + self.job_name + '/' 380 nImages = self['input/neb/nImages'] 381 initial = self['input/neb/initial_image'].to_object() 382 final = self['input/neb/final_image'].to_object() 383 384 if wrap is not False: 385 initial = _wrap_positions(initial, wrap) 386 final = _wrap_positions(final, wrap) 387 388 cells = [initial.cell] 389 positions = [initial.positions] 390 energy_pot = [] 391 392 for i in range(1, nImages - 1): 393 case = f'{i:02d}' 394 out = Oszicar() 395 out.from_file(filename=path + case + '/OSZICAR') 396 energy_pot.append(out.parse_dict['energy_pot']) 397 struct = read_atoms(filename=path + case + '/CONTCAR') 398 if wrap is not False: 399 struct = _wrap_positions(struct, wrap) 400 cells.append(struct.cell) 401 positions.append(struct.positions) 402 403 relaxation_steps = len(out.parse_dict['energy_pot']) 404 energy_pot = np.array( 405 [[self['input/neb/initial_energy']] * relaxation_steps] + 406 energy_pot + 407 [[self['input/neb/final_energy']] * relaxation_steps] 408 ).T 409 410 cells.append(final.cell) 411 positions.append(final.positions) 412 413 self['output'].create_group('neb') 414 self['output/neb'].put('energy_pot', energy_pot) 415 self['output/neb'].put('final_cells', cells) 416 self['output/neb'].put('final_positions', positions) 417 ene = energy_pot[-1] 418 self['output/neb'].put('barrier_forward', max(ene) - ene[0]) 419 self['output/neb'].put('barrier_backward', max(ene) - ene[-1]) 420 self._polynomial_fit_with_derivative_constraints(n=nImages + 1) 421 422 def transfer_from_remote_and_collect(self): 423 """ 424 Transfer files from the remote cluster and collect data locally. 425 """ 426 self.transfer_from_remote() 427 self.status.collect = True 428 self.collect_data() 429 self.status.finished = True 430 431 def _polynomial_fit_with_derivative_constraints(self, n=4): 432 """ 433 Fit a polynomial to the NEB energy profile with derivative constraints. 434 435 The polynomial is constrained such that its derivatives at x=0 and x=1 are zero. 436 437 Parameters: 438 n (int, optional): The order of the polynomial. 439 440 Returns: 441 Polynomial: The fitted polynomial. 442 """ 443 y = self['output/neb/energy_pot'][-1] 444 x = np.linspace(0, 1, len(y)) 445 446 def poly_val_and_deriv(coeffs, x): 447 """ 448 Evaluate the polynomial and its derivative. 449 450 Parameters: 451 coeffs (list or numpy.ndarray): Coefficients of the polynomial. 452 x (float or numpy.ndarray): Point(s) at which to evaluate. 453 454 Returns: 455 tuple: Tuple containing the polynomial value and its derivative. 456 """ 457 p = Polynomial(coeffs) 458 dp = p.deriv() 459 return p(x), dp(x) 460 461 def objective(coeffs, x, y): 462 """ 463 Objective function to minimize. 464 465 Parameters: 466 coeffs (list or numpy.ndarray): Coefficients of the polynomial. 467 x (float or numpy.ndarray): x-values of the data points. 468 y (float or numpy.ndarray): y-values of the data points. 469 470 Returns: 471 float: Sum of squared residuals. 472 """ 473 p_vals, _ = poly_val_and_deriv(coeffs, x) 474 return np.sum((y - p_vals) ** 2) 475 476 def constraint_deriv_0(coeffs): 477 """ 478 Constraint for the derivative at x=0 to be zero. 479 480 Parameters: 481 coeffs (list or numpy.ndarray): Coefficients of the polynomial. 482 483 Returns: 484 float: Derivative at x=0. 485 """ 486 _, dp0 = poly_val_and_deriv(coeffs, 0) 487 return dp0 488 489 def constraint_deriv_1(coeffs): 490 """ 491 Constraint for the derivative at x=1 to be zero. 492 493 Parameters: 494 coeffs (list or numpy.ndarray): Coefficients of the polynomial. 495 496 Returns: 497 float: Derivative at x=1. 498 """ 499 _, dp1 = poly_val_and_deriv(coeffs, 1) 500 return dp1 501 502 def constraint_val_0(coeffs): 503 """ 504 Constraint for the polynomial value at x=0 to match the first data point. 505 506 Parameters: 507 coeffs (list or numpy.ndarray): Coefficients of the polynomial. 508 509 Returns: 510 float: Difference between polynomial value at x=0 and y[0]. 511 """ 512 p0, _ = poly_val_and_deriv(coeffs, 0) 513 return p0 - y[0] 514 515 def constraint_val_1(coeffs): 516 """ 517 Constraint for the polynomial value at x=1 to match the last data point. 518 519 Parameters: 520 coeffs (list or numpy.ndarray): Coefficients of the polynomial. 521 522 Returns: 523 float: Difference between polynomial value at x=1 and y[-1]. 524 """ 525 p1, _ = poly_val_and_deriv(coeffs, 1) 526 return p1 - y[-1] 527 528 initial_guess = np.ones(n + 1) 529 530 def constraint_max_val(coeffs): 531 """ 532 Constraint for the polynomial maximum to match the maximum y value. 533 534 Parameters: 535 coeffs (list or numpy.ndarray): Coefficients of the polynomial. 536 537 Returns: 538 float: Difference between maximum polynomial value and maximum y value. 539 """ 540 p = Polynomial(coeffs) 541 x_vals = np.linspace(0.2, 0.8, 10) 542 p_vals = p(x_vals) 543 return max(p_vals) - max(y) 544 545 constraints = [ 546 {'type': 'eq', 'fun': constraint_deriv_0}, 547 {'type': 'eq', 'fun': constraint_deriv_1}, 548 {'type': 'eq', 'fun': constraint_val_0}, 549 {'type': 'eq', 'fun': constraint_val_1}, 550 {'type': 'eq', 'fun': constraint_max_val} 551 ] 552 553 result = minimize(objective, initial_guess, args=(x, y), constraints=constraints) 554 optimized_coeffs = result.x 555 fitted_polynomial = Polynomial(optimized_coeffs) 556 self['output/neb'].put('fit', fitted_polynomial) 557 558 def get_energy_fit(self): 559 """ 560 Get the fitted polynomial for the NEB energy profile. 561 562 Returns: 563 Polynomial: The fitted polynomial. 564 """ 565 return self['output/neb/fit']
61def get_species_symbols_new(self): 62 """ 63 MONKEY PATCH to get correct order of POTCARs. 64 65 Returns: 66 numpy.ndarray: List of the symbols of the species. 67 """ 68 sp = [self.elements[0]] 69 for el in self.elements[1:]: 70 if el != sp[-1]: 71 sp.append(el) 72 return np.array([el.Abbreviation for el in sp])
MONKEY PATCH to get correct order of POTCARs.
Returns: numpy.ndarray: List of the symbols of the species.
103class NEB(): 104 """ 105 Class to handle the setup and execution of NEB (Nudged Elastic Band) calculations. 106 """ 107 108 def __init__(self, pr, NEB_calc_name): 109 """ 110 Initialize the NEB class. 111 112 Parameters: 113 pr (Project): Pyiron project object. 114 NEB_calc_name (str): Name of the NEB calculation. 115 """ 116 self.pr = pr.create_group(NEB_calc_name) 117 self.initial = None 118 119 def add_initial_state(self, initial_job, delete_existing=False, wrap=0.98): 120 """ 121 Add the initial state for the NEB calculation. 122 123 Parameters: 124 initial_job (Vasp_job): Initial state as a VASP job. 125 delete_existing (bool, optional): Whether to delete existing files. 126 wrap (float, optional): Wrap value for the atomic positions. 127 128 Raises: 129 TypeError: If `initial_job` is not of type `Vasp_job`. 130 """ 131 if not isinstance(initial_job, Vasp_job): 132 raise TypeError("Initial job must be a VASP job (pyiron_atomistics.vasp.vasp.Vasp)") 133 134 self.initial_job = initial_job 135 self.initial = initial_job.get_structure() 136 if wrap is not False: 137 self.initial = _wrap_positions(initial_job.get_structure(), wrap) 138 139 # Setup symbolic links and directory structure 140 rel_path_to_initial = os.path.relpath(self.initial_job.path, self.pr.path) 141 cwd = os.getcwd() 142 os.chdir(self.pr.path) 143 if delete_existing: 144 try: 145 os.remove('initial.h5') 146 rmtree('initial_hdf5') 147 except: 148 pass 149 os.symlink(rel_path_to_initial+'.h5', 'initial.h5') 150 os.mkdir('initial_hdf5') 151 os.chdir('initial_hdf5') 152 os.symlink(os.path.join('..', rel_path_to_initial+'_hdf5', initial_job.name), 'initial', target_is_directory=True) 153 os.chdir(cwd) 154 155 def set_initial_state(self, initial_job, wrap=0.98): 156 """ 157 Set the initial state from an existing VASP job. 158 159 Parameters: 160 initial_job (Vasp_job): Initial state as a VASP job. 161 wrap (float, optional): Wrap value for the atomic positions. 162 """ 163 self.initial_job = initial_job 164 struct = initial_job.get_structure() 165 self.initial = struct 166 if wrap is not False: 167 self.initial = _wrap_positions(struct, wrap) 168 169 def set_final_state(self, final_job, wrap=0.98): 170 """ 171 Set the final state for the NEB calculation. 172 173 Parameters: 174 final_job (Vasp_job): Final state as a VASP job. 175 wrap (float, optional): Wrap value for the atomic positions. 176 """ 177 self.final_job = final_job 178 struct = final_job.get_structure() 179 self.final = struct 180 if wrap is not False: 181 self.final = _wrap_positions(struct, wrap) 182 183 184 def reorder_final(self, moving_index, max_shift=0.05, verbose=True, wrap=0.98): 185 """ 186 Reorder the final structure based on the initial structure. 187 188 Parameters: 189 moving_index (int): Index of the moving atom. 190 max_shift (float, optional): Maximum allowable shift for atom mapping. 191 verbose (bool, optional): If True, prints information about the atom positions. 192 wrap (float, optional): Wrap value for the atomic positions. 193 194 Raises: 195 Exception: If there are issues with the atom mapping. 196 """ 197 198 initial = self.initial 199 final = self.final 200 if wrap is not False: 201 initial = _wrap_positions(initial, wrap) 202 final = _wrap_positions(final, wrap) 203 204 mapped = [False for _ in range(len(final))] 205 mapping = [None for _ in range(len(initial))] 206 207 for iat, at in enumerate(initial): 208 if iat == moving_index and verbose: 209 print('position in the initial structure:', at.position) 210 else: 211 nn = final.get_neighborhood(at.position, cutoff_radius=max_shift) 212 if len(nn.distances) != 1: 213 raise Exception(f"Problem with mapping: atom {iat} in the initial structure was identified with {len(nn.distances)} images") 214 elif mapped[nn.indices[0]]: 215 raise Exception(f"Problem with mapping: atom {iat} in the initial should be mapped to {nn.indices[0]} in the final structure but this atom has already been mapped.") 216 else: 217 mapped[nn.indices[0]] = True 218 mapping[iat] = nn.indices[0] 219 220 unmapped = [i for i in range(len(final)) if not mapped[i]] 221 if len(unmapped) != 1: 222 raise Exception(f"Problem with mapping: only 1 atom must remain after mapping! Unmapped atoms: {unmapped}") 223 224 mapping[moving_index] = unmapped[0] 225 if verbose: 226 print('position in the final structure:', self.final.positions[unmapped[0]]) 227 228 new_positions = [final.positions[mapping[i]] for i in range(len(initial))] 229 new_elements = [final.elements[mapping[i]] for i in range(len(initial))] 230 new_final = self.pr.create.structure.atoms( 231 cell=final.cell, 232 positions=new_positions, 233 elements=new_elements 234 ) 235 self.final = new_final 236 237 def create_neb_job(self, job_name='neb', nImages=3): 238 """ 239 Create a NEB job. 240 241 Parameters: 242 job_name (str, optional): Name of the NEB job. 243 nImages (int, optional): Number of images for the NEB calculation. 244 245 Returns: 246 NEBjob: Created NEB job object. 247 """ 248 try: 249 job = self.pr[job_name] 250 except: 251 job = self.pr.create_job(job_type=self.pr.job_type.Vasp, job_name=job_name) 252 job.__class__ = NEBjob 253 job.structure = self.initial 254 job.set_kpoints(mesh=[3, 3, 3], scheme='GC') 255 job.calc_minimize( 256 electronic_steps=60, 257 retain_charge_density=False, 258 ) 259 job.input.incar['ISIF'] = 2 260 job.input.incar['ENCUT'] = 520 # from MP 261 job.input.incar['IMAGES'] = nImages - 2 262 job.input.incar['IBRION'] = 1 263 job.input.incar['NFREE'] = 2 264 job.save() 265 266 job['input'].create_group('neb') 267 job['input/neb'].put('nImages', nImages) 268 job['input/neb'].put('initial_image', self.initial) 269 job['input/neb'].put('final_image', self.final) 270 job['input/neb'].put('initial_energy', self.initial_job['output/generic/energy_pot'][-1]) 271 job['input/neb'].put('final_energy', self.final_job['output/generic/energy_pot'][-1]) 272 job._create_images() 273 job.structure = job['input/neb/initial_image'].to_object() 274 job.write_input() 275 else: 276 state.logger.warning(f'Job `{job_name}` exists. Loading from database instead of creating it!') 277 return job
Class to handle the setup and execution of NEB (Nudged Elastic Band) calculations.
108 def __init__(self, pr, NEB_calc_name): 109 """ 110 Initialize the NEB class. 111 112 Parameters: 113 pr (Project): Pyiron project object. 114 NEB_calc_name (str): Name of the NEB calculation. 115 """ 116 self.pr = pr.create_group(NEB_calc_name) 117 self.initial = None
Initialize the NEB class.
Parameters: pr (Project): Pyiron project object. NEB_calc_name (str): Name of the NEB calculation.
119 def add_initial_state(self, initial_job, delete_existing=False, wrap=0.98): 120 """ 121 Add the initial state for the NEB calculation. 122 123 Parameters: 124 initial_job (Vasp_job): Initial state as a VASP job. 125 delete_existing (bool, optional): Whether to delete existing files. 126 wrap (float, optional): Wrap value for the atomic positions. 127 128 Raises: 129 TypeError: If `initial_job` is not of type `Vasp_job`. 130 """ 131 if not isinstance(initial_job, Vasp_job): 132 raise TypeError("Initial job must be a VASP job (pyiron_atomistics.vasp.vasp.Vasp)") 133 134 self.initial_job = initial_job 135 self.initial = initial_job.get_structure() 136 if wrap is not False: 137 self.initial = _wrap_positions(initial_job.get_structure(), wrap) 138 139 # Setup symbolic links and directory structure 140 rel_path_to_initial = os.path.relpath(self.initial_job.path, self.pr.path) 141 cwd = os.getcwd() 142 os.chdir(self.pr.path) 143 if delete_existing: 144 try: 145 os.remove('initial.h5') 146 rmtree('initial_hdf5') 147 except: 148 pass 149 os.symlink(rel_path_to_initial+'.h5', 'initial.h5') 150 os.mkdir('initial_hdf5') 151 os.chdir('initial_hdf5') 152 os.symlink(os.path.join('..', rel_path_to_initial+'_hdf5', initial_job.name), 'initial', target_is_directory=True) 153 os.chdir(cwd)
Add the initial state for the NEB calculation.
Parameters: initial_job (Vasp_job): Initial state as a VASP job. delete_existing (bool, optional): Whether to delete existing files. wrap (float, optional): Wrap value for the atomic positions.
Raises:
TypeError: If initial_job is not of type Vasp_job.
155 def set_initial_state(self, initial_job, wrap=0.98): 156 """ 157 Set the initial state from an existing VASP job. 158 159 Parameters: 160 initial_job (Vasp_job): Initial state as a VASP job. 161 wrap (float, optional): Wrap value for the atomic positions. 162 """ 163 self.initial_job = initial_job 164 struct = initial_job.get_structure() 165 self.initial = struct 166 if wrap is not False: 167 self.initial = _wrap_positions(struct, wrap)
Set the initial state from an existing VASP job.
Parameters: initial_job (Vasp_job): Initial state as a VASP job. wrap (float, optional): Wrap value for the atomic positions.
169 def set_final_state(self, final_job, wrap=0.98): 170 """ 171 Set the final state for the NEB calculation. 172 173 Parameters: 174 final_job (Vasp_job): Final state as a VASP job. 175 wrap (float, optional): Wrap value for the atomic positions. 176 """ 177 self.final_job = final_job 178 struct = final_job.get_structure() 179 self.final = struct 180 if wrap is not False: 181 self.final = _wrap_positions(struct, wrap)
Set the final state for the NEB calculation.
Parameters: final_job (Vasp_job): Final state as a VASP job. wrap (float, optional): Wrap value for the atomic positions.
184 def reorder_final(self, moving_index, max_shift=0.05, verbose=True, wrap=0.98): 185 """ 186 Reorder the final structure based on the initial structure. 187 188 Parameters: 189 moving_index (int): Index of the moving atom. 190 max_shift (float, optional): Maximum allowable shift for atom mapping. 191 verbose (bool, optional): If True, prints information about the atom positions. 192 wrap (float, optional): Wrap value for the atomic positions. 193 194 Raises: 195 Exception: If there are issues with the atom mapping. 196 """ 197 198 initial = self.initial 199 final = self.final 200 if wrap is not False: 201 initial = _wrap_positions(initial, wrap) 202 final = _wrap_positions(final, wrap) 203 204 mapped = [False for _ in range(len(final))] 205 mapping = [None for _ in range(len(initial))] 206 207 for iat, at in enumerate(initial): 208 if iat == moving_index and verbose: 209 print('position in the initial structure:', at.position) 210 else: 211 nn = final.get_neighborhood(at.position, cutoff_radius=max_shift) 212 if len(nn.distances) != 1: 213 raise Exception(f"Problem with mapping: atom {iat} in the initial structure was identified with {len(nn.distances)} images") 214 elif mapped[nn.indices[0]]: 215 raise Exception(f"Problem with mapping: atom {iat} in the initial should be mapped to {nn.indices[0]} in the final structure but this atom has already been mapped.") 216 else: 217 mapped[nn.indices[0]] = True 218 mapping[iat] = nn.indices[0] 219 220 unmapped = [i for i in range(len(final)) if not mapped[i]] 221 if len(unmapped) != 1: 222 raise Exception(f"Problem with mapping: only 1 atom must remain after mapping! Unmapped atoms: {unmapped}") 223 224 mapping[moving_index] = unmapped[0] 225 if verbose: 226 print('position in the final structure:', self.final.positions[unmapped[0]]) 227 228 new_positions = [final.positions[mapping[i]] for i in range(len(initial))] 229 new_elements = [final.elements[mapping[i]] for i in range(len(initial))] 230 new_final = self.pr.create.structure.atoms( 231 cell=final.cell, 232 positions=new_positions, 233 elements=new_elements 234 ) 235 self.final = new_final
Reorder the final structure based on the initial structure.
Parameters: moving_index (int): Index of the moving atom. max_shift (float, optional): Maximum allowable shift for atom mapping. verbose (bool, optional): If True, prints information about the atom positions. wrap (float, optional): Wrap value for the atomic positions.
Raises: Exception: If there are issues with the atom mapping.
237 def create_neb_job(self, job_name='neb', nImages=3): 238 """ 239 Create a NEB job. 240 241 Parameters: 242 job_name (str, optional): Name of the NEB job. 243 nImages (int, optional): Number of images for the NEB calculation. 244 245 Returns: 246 NEBjob: Created NEB job object. 247 """ 248 try: 249 job = self.pr[job_name] 250 except: 251 job = self.pr.create_job(job_type=self.pr.job_type.Vasp, job_name=job_name) 252 job.__class__ = NEBjob 253 job.structure = self.initial 254 job.set_kpoints(mesh=[3, 3, 3], scheme='GC') 255 job.calc_minimize( 256 electronic_steps=60, 257 retain_charge_density=False, 258 ) 259 job.input.incar['ISIF'] = 2 260 job.input.incar['ENCUT'] = 520 # from MP 261 job.input.incar['IMAGES'] = nImages - 2 262 job.input.incar['IBRION'] = 1 263 job.input.incar['NFREE'] = 2 264 job.save() 265 266 job['input'].create_group('neb') 267 job['input/neb'].put('nImages', nImages) 268 job['input/neb'].put('initial_image', self.initial) 269 job['input/neb'].put('final_image', self.final) 270 job['input/neb'].put('initial_energy', self.initial_job['output/generic/energy_pot'][-1]) 271 job['input/neb'].put('final_energy', self.final_job['output/generic/energy_pot'][-1]) 272 job._create_images() 273 job.structure = job['input/neb/initial_image'].to_object() 274 job.write_input() 275 else: 276 state.logger.warning(f'Job `{job_name}` exists. Loading from database instead of creating it!') 277 return job
Create a NEB job.
Parameters: job_name (str, optional): Name of the NEB job. nImages (int, optional): Number of images for the NEB calculation.
Returns: NEBjob: Created NEB job object.
280class NEBjob(Vasp_job): 281 """ 282 Class for handling specific NEB (Nudged Elastic Band) job operations. 283 """ 284 285 286 287 def _create_images(self, wrap=0.98): 288 """ 289 Create interpolated images between the initial and final states. 290 291 Parameters: 292 wrap (float, optional): Wrap value for the atomic positions. 293 """ 294 cells = [] 295 positions = [] 296 initial = _wrap_positions(self['input/neb/initial_image'].to_object(), wrap) 297 final = _wrap_positions(self['input/neb/final_image'].to_object(), wrap) 298 nImages = self['input/neb/nImages'] 299 300 for x in np.linspace(0, 1, nImages): 301 cells.append(initial.cell * (1 - x) + final.cell * x) 302 positions.append(initial.positions * (1 - x) + final.positions * x) 303 304 self['input/neb'].put('initial_cells', cells) 305 self['input/neb'].put('initial_positions', positions) 306 307 def get_initial_images(self): 308 """ 309 Retrieve the initial interpolated images. 310 311 Returns: 312 Trajectory: Trajectory object containing the initial images. 313 """ 314 initial = self['input/neb/initial_image'].to_object() 315 positions = self['input/neb/initial_positions'] 316 cells = self['input/neb/initial_cells'] 317 return Trajectory(positions, initial, cells=cells) 318 319 def get_final_images(self): 320 """ 321 Retrieve the final interpolated images after relaxation. 322 323 Returns: 324 Trajectory: Trajectory object containing the final images. 325 """ 326 initial = self['input/neb/initial_image'].to_object() 327 positions = self['output/neb/final_positions'] 328 cells = self['output/neb/final_cells'] 329 return Trajectory(positions, initial, cells=cells) 330 331 def _write_images(self): 332 """ 333 Write the interpolated images to the appropriate directories. 334 """ 335 path = self.path + '_hdf5/' + self.job_name + '/' 336 nImages = self['input/neb/nImages'] 337 initial_structs = self.get_initial_images() 338 339 for i in range(nImages): 340 os.chdir(path) 341 case = f'{i:02d}' 342 if not os.path.exists(case): 343 os.mkdir(case) 344 os.chdir(case) 345 struct = initial_structs[i] 346 struct.write('POSCAR', format='vasp') 347 348 def write_and_transfer_to_remote(self): 349 """ 350 Write the input files and transfer them to the remote cluster. 351 352 Warnings: 353 If the job is already marked as finished, a warning is issued and no action is taken. 354 """ 355 if self.status not in job_status_successful_lst: 356 self._write_images() 357 self.write_input() 358 359 filename = state.queue_adapter.convert_path_to_remote( 360 path=self.project_hdf5.file_name 361 ) 362 working_directory = state.queue_adapter.convert_path_to_remote( 363 path=self.working_directory 364 ) 365 for filename in glob(self.project_hdf5.path + '_hdf5/**', recursive=True): 366 if os.path.isfile(filename): 367 state.queue_adapter.transfer_file_to_remote( 368 file=filename, transfer_back=False 369 ) 370 else: 371 state.logger.warning(f'Job `{self.job_name}` is marked as finished, uploading doesn\'t make sense') 372 373 def collect_data(self, wrap=0.98): 374 """ 375 Collect the data from the NEB calculation. 376 377 Parameters: 378 wrap (float or bool, optional): Wrap value for atomic positions. Set to `False` to disable wrapping. 379 """ 380 path = self.path + '_hdf5/' + self.job_name + '/' 381 nImages = self['input/neb/nImages'] 382 initial = self['input/neb/initial_image'].to_object() 383 final = self['input/neb/final_image'].to_object() 384 385 if wrap is not False: 386 initial = _wrap_positions(initial, wrap) 387 final = _wrap_positions(final, wrap) 388 389 cells = [initial.cell] 390 positions = [initial.positions] 391 energy_pot = [] 392 393 for i in range(1, nImages - 1): 394 case = f'{i:02d}' 395 out = Oszicar() 396 out.from_file(filename=path + case + '/OSZICAR') 397 energy_pot.append(out.parse_dict['energy_pot']) 398 struct = read_atoms(filename=path + case + '/CONTCAR') 399 if wrap is not False: 400 struct = _wrap_positions(struct, wrap) 401 cells.append(struct.cell) 402 positions.append(struct.positions) 403 404 relaxation_steps = len(out.parse_dict['energy_pot']) 405 energy_pot = np.array( 406 [[self['input/neb/initial_energy']] * relaxation_steps] + 407 energy_pot + 408 [[self['input/neb/final_energy']] * relaxation_steps] 409 ).T 410 411 cells.append(final.cell) 412 positions.append(final.positions) 413 414 self['output'].create_group('neb') 415 self['output/neb'].put('energy_pot', energy_pot) 416 self['output/neb'].put('final_cells', cells) 417 self['output/neb'].put('final_positions', positions) 418 ene = energy_pot[-1] 419 self['output/neb'].put('barrier_forward', max(ene) - ene[0]) 420 self['output/neb'].put('barrier_backward', max(ene) - ene[-1]) 421 self._polynomial_fit_with_derivative_constraints(n=nImages + 1) 422 423 def transfer_from_remote_and_collect(self): 424 """ 425 Transfer files from the remote cluster and collect data locally. 426 """ 427 self.transfer_from_remote() 428 self.status.collect = True 429 self.collect_data() 430 self.status.finished = True 431 432 def _polynomial_fit_with_derivative_constraints(self, n=4): 433 """ 434 Fit a polynomial to the NEB energy profile with derivative constraints. 435 436 The polynomial is constrained such that its derivatives at x=0 and x=1 are zero. 437 438 Parameters: 439 n (int, optional): The order of the polynomial. 440 441 Returns: 442 Polynomial: The fitted polynomial. 443 """ 444 y = self['output/neb/energy_pot'][-1] 445 x = np.linspace(0, 1, len(y)) 446 447 def poly_val_and_deriv(coeffs, x): 448 """ 449 Evaluate the polynomial and its derivative. 450 451 Parameters: 452 coeffs (list or numpy.ndarray): Coefficients of the polynomial. 453 x (float or numpy.ndarray): Point(s) at which to evaluate. 454 455 Returns: 456 tuple: Tuple containing the polynomial value and its derivative. 457 """ 458 p = Polynomial(coeffs) 459 dp = p.deriv() 460 return p(x), dp(x) 461 462 def objective(coeffs, x, y): 463 """ 464 Objective function to minimize. 465 466 Parameters: 467 coeffs (list or numpy.ndarray): Coefficients of the polynomial. 468 x (float or numpy.ndarray): x-values of the data points. 469 y (float or numpy.ndarray): y-values of the data points. 470 471 Returns: 472 float: Sum of squared residuals. 473 """ 474 p_vals, _ = poly_val_and_deriv(coeffs, x) 475 return np.sum((y - p_vals) ** 2) 476 477 def constraint_deriv_0(coeffs): 478 """ 479 Constraint for the derivative at x=0 to be zero. 480 481 Parameters: 482 coeffs (list or numpy.ndarray): Coefficients of the polynomial. 483 484 Returns: 485 float: Derivative at x=0. 486 """ 487 _, dp0 = poly_val_and_deriv(coeffs, 0) 488 return dp0 489 490 def constraint_deriv_1(coeffs): 491 """ 492 Constraint for the derivative at x=1 to be zero. 493 494 Parameters: 495 coeffs (list or numpy.ndarray): Coefficients of the polynomial. 496 497 Returns: 498 float: Derivative at x=1. 499 """ 500 _, dp1 = poly_val_and_deriv(coeffs, 1) 501 return dp1 502 503 def constraint_val_0(coeffs): 504 """ 505 Constraint for the polynomial value at x=0 to match the first data point. 506 507 Parameters: 508 coeffs (list or numpy.ndarray): Coefficients of the polynomial. 509 510 Returns: 511 float: Difference between polynomial value at x=0 and y[0]. 512 """ 513 p0, _ = poly_val_and_deriv(coeffs, 0) 514 return p0 - y[0] 515 516 def constraint_val_1(coeffs): 517 """ 518 Constraint for the polynomial value at x=1 to match the last data point. 519 520 Parameters: 521 coeffs (list or numpy.ndarray): Coefficients of the polynomial. 522 523 Returns: 524 float: Difference between polynomial value at x=1 and y[-1]. 525 """ 526 p1, _ = poly_val_and_deriv(coeffs, 1) 527 return p1 - y[-1] 528 529 initial_guess = np.ones(n + 1) 530 531 def constraint_max_val(coeffs): 532 """ 533 Constraint for the polynomial maximum to match the maximum y value. 534 535 Parameters: 536 coeffs (list or numpy.ndarray): Coefficients of the polynomial. 537 538 Returns: 539 float: Difference between maximum polynomial value and maximum y value. 540 """ 541 p = Polynomial(coeffs) 542 x_vals = np.linspace(0.2, 0.8, 10) 543 p_vals = p(x_vals) 544 return max(p_vals) - max(y) 545 546 constraints = [ 547 {'type': 'eq', 'fun': constraint_deriv_0}, 548 {'type': 'eq', 'fun': constraint_deriv_1}, 549 {'type': 'eq', 'fun': constraint_val_0}, 550 {'type': 'eq', 'fun': constraint_val_1}, 551 {'type': 'eq', 'fun': constraint_max_val} 552 ] 553 554 result = minimize(objective, initial_guess, args=(x, y), constraints=constraints) 555 optimized_coeffs = result.x 556 fitted_polynomial = Polynomial(optimized_coeffs) 557 self['output/neb'].put('fit', fitted_polynomial) 558 559 def get_energy_fit(self): 560 """ 561 Get the fitted polynomial for the NEB energy profile. 562 563 Returns: 564 Polynomial: The fitted polynomial. 565 """ 566 return self['output/neb/fit']
Class for handling specific NEB (Nudged Elastic Band) job operations.
307 def get_initial_images(self): 308 """ 309 Retrieve the initial interpolated images. 310 311 Returns: 312 Trajectory: Trajectory object containing the initial images. 313 """ 314 initial = self['input/neb/initial_image'].to_object() 315 positions = self['input/neb/initial_positions'] 316 cells = self['input/neb/initial_cells'] 317 return Trajectory(positions, initial, cells=cells)
Retrieve the initial interpolated images.
Returns: Trajectory: Trajectory object containing the initial images.
319 def get_final_images(self): 320 """ 321 Retrieve the final interpolated images after relaxation. 322 323 Returns: 324 Trajectory: Trajectory object containing the final images. 325 """ 326 initial = self['input/neb/initial_image'].to_object() 327 positions = self['output/neb/final_positions'] 328 cells = self['output/neb/final_cells'] 329 return Trajectory(positions, initial, cells=cells)
Retrieve the final interpolated images after relaxation.
Returns: Trajectory: Trajectory object containing the final images.
348 def write_and_transfer_to_remote(self): 349 """ 350 Write the input files and transfer them to the remote cluster. 351 352 Warnings: 353 If the job is already marked as finished, a warning is issued and no action is taken. 354 """ 355 if self.status not in job_status_successful_lst: 356 self._write_images() 357 self.write_input() 358 359 filename = state.queue_adapter.convert_path_to_remote( 360 path=self.project_hdf5.file_name 361 ) 362 working_directory = state.queue_adapter.convert_path_to_remote( 363 path=self.working_directory 364 ) 365 for filename in glob(self.project_hdf5.path + '_hdf5/**', recursive=True): 366 if os.path.isfile(filename): 367 state.queue_adapter.transfer_file_to_remote( 368 file=filename, transfer_back=False 369 ) 370 else: 371 state.logger.warning(f'Job `{self.job_name}` is marked as finished, uploading doesn\'t make sense')
Write the input files and transfer them to the remote cluster.
Warnings: If the job is already marked as finished, a warning is issued and no action is taken.
373 def collect_data(self, wrap=0.98): 374 """ 375 Collect the data from the NEB calculation. 376 377 Parameters: 378 wrap (float or bool, optional): Wrap value for atomic positions. Set to `False` to disable wrapping. 379 """ 380 path = self.path + '_hdf5/' + self.job_name + '/' 381 nImages = self['input/neb/nImages'] 382 initial = self['input/neb/initial_image'].to_object() 383 final = self['input/neb/final_image'].to_object() 384 385 if wrap is not False: 386 initial = _wrap_positions(initial, wrap) 387 final = _wrap_positions(final, wrap) 388 389 cells = [initial.cell] 390 positions = [initial.positions] 391 energy_pot = [] 392 393 for i in range(1, nImages - 1): 394 case = f'{i:02d}' 395 out = Oszicar() 396 out.from_file(filename=path + case + '/OSZICAR') 397 energy_pot.append(out.parse_dict['energy_pot']) 398 struct = read_atoms(filename=path + case + '/CONTCAR') 399 if wrap is not False: 400 struct = _wrap_positions(struct, wrap) 401 cells.append(struct.cell) 402 positions.append(struct.positions) 403 404 relaxation_steps = len(out.parse_dict['energy_pot']) 405 energy_pot = np.array( 406 [[self['input/neb/initial_energy']] * relaxation_steps] + 407 energy_pot + 408 [[self['input/neb/final_energy']] * relaxation_steps] 409 ).T 410 411 cells.append(final.cell) 412 positions.append(final.positions) 413 414 self['output'].create_group('neb') 415 self['output/neb'].put('energy_pot', energy_pot) 416 self['output/neb'].put('final_cells', cells) 417 self['output/neb'].put('final_positions', positions) 418 ene = energy_pot[-1] 419 self['output/neb'].put('barrier_forward', max(ene) - ene[0]) 420 self['output/neb'].put('barrier_backward', max(ene) - ene[-1]) 421 self._polynomial_fit_with_derivative_constraints(n=nImages + 1)
Collect the data from the NEB calculation.
Parameters:
wrap (float or bool, optional): Wrap value for atomic positions. Set to False to disable wrapping.
423 def transfer_from_remote_and_collect(self): 424 """ 425 Transfer files from the remote cluster and collect data locally. 426 """ 427 self.transfer_from_remote() 428 self.status.collect = True 429 self.collect_data() 430 self.status.finished = True
Transfer files from the remote cluster and collect data locally.
559 def get_energy_fit(self): 560 """ 561 Get the fitted polynomial for the NEB energy profile. 562 563 Returns: 564 Polynomial: The fitted polynomial. 565 """ 566 return self['output/neb/fit']
Get the fitted polynomial for the NEB energy profile.
Returns: Polynomial: The fitted polynomial.