Coverage for ase / io / vasp_parsers / vasp_outcar_parsers.py: 94.84%
465 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-30 08:22 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-30 08:22 +0000
1# fmt: off
3"""
4Module for parsing OUTCAR files.
5"""
6import re
7from abc import ABC, abstractmethod
8from collections.abc import Iterator, Sequence
9from pathlib import Path, PurePath
10from typing import Any, TextIO
11from warnings import warn
13import numpy as np
15import ase
16from ase import Atoms
17from ase.calculators.singlepoint import (
18 SinglePointDFTCalculator,
19 SinglePointKPoint,
20)
21from ase.data import atomic_numbers
22from ase.io import ParseError, read
23from ase.io.utils import ImageChunk
25# Denotes end of Ionic step for OUTCAR reading
26_OUTCAR_SCF_DELIM = 'FREE ENERGIE OF THE ION-ELECTRON SYSTEM'
28# Some type aliases
29_HEADER = dict[str, Any]
30_CURSOR = int
31_CHUNK = Sequence[str]
32_RESULT = dict[str, Any]
35class NoNonEmptyLines(Exception):
36 """No more non-empty lines were left in the provided chunck"""
39class UnableToLocateDelimiter(Exception):
40 """Did not find the provided delimiter"""
42 def __init__(self, delimiter, msg):
43 self.delimiter = delimiter
44 super().__init__(msg)
47def _check_line(line: str) -> str:
48 """Auxiliary check line function for OUTCAR numeric formatting.
49 See issue #179, https://gitlab.com/ase/ase/issues/179
50 Only call in cases we need the numeric values
51 """
52 if re.search('[0-9]-[0-9]', line):
53 line = re.sub('([0-9])-([0-9])', r'\1 -\2', line)
54 return line
57def find_next_non_empty_line(cursor: _CURSOR, lines: _CHUNK) -> _CURSOR:
58 """Fast-forward the cursor from the current position to the next
59 line which is non-empty.
60 Returns the new cursor position on the next non-empty line.
61 """
62 for line in lines[cursor:]:
63 if line.strip():
64 # Line was non-empty
65 return cursor
66 # Empty line, increment the cursor position
67 cursor += 1
68 # There was no non-empty line
69 raise NoNonEmptyLines("Did not find a next line which was not empty")
72def search_lines(delim: str, cursor: _CURSOR, lines: _CHUNK) -> _CURSOR:
73 """Search through a chunk of lines starting at the cursor position for
74 a given delimiter. The new position of the cursor is returned."""
75 for line in lines[cursor:]:
76 if delim in line:
77 # The cursor should be on the line with the delimiter now
78 assert delim in lines[cursor]
79 return cursor
80 # We didn't find the delimiter
81 cursor += 1
82 raise UnableToLocateDelimiter(
83 delim, f'Did not find starting point for delimiter {delim}')
86def convert_vasp_outcar_stress(stress: Sequence):
87 """Helper function to convert the stress line in an OUTCAR to the
88 expected units in ASE """
89 stress_arr = -np.array(stress)
90 shape = stress_arr.shape
91 if shape != (6, ):
92 raise ValueError(
93 f'Stress has the wrong shape. Expected (6,), got {shape}')
94 stress_arr = stress_arr[[0, 1, 2, 4, 5, 3]] * 1e-1 * ase.units.GPa
95 return stress_arr
98def read_constraints_from_file(
99 directory: str | Path
100 ) -> Sequence[str] | None:
101 directory = Path(directory)
102 constraint = None
103 for filename in ('CONTCAR', 'POSCAR'):
104 if (directory / filename).is_file():
105 atoms = read(directory / filename,
106 format='vasp',
107 parallel=False)
108 if isinstance(atoms, Atoms):
109 constraint = atoms.constraints
110 break
111 return constraint
114class VaspPropertyParser(ABC):
115 NAME: str | None = None
117 @classmethod
118 def get_name(cls):
119 """Name of parser. Override the NAME constant in the class to
120 specify a custom name,
121 otherwise the class name is used"""
122 return cls.NAME or cls.__name__
124 @abstractmethod
125 def has_property(self, cursor: _CURSOR, lines: _CHUNK) -> bool:
126 """Function which checks if a property can be derived from a given
127 cursor position"""
129 @staticmethod
130 def get_line(cursor: _CURSOR, lines: _CHUNK) -> str:
131 """Helper function to get a line, and apply the check_line function"""
132 return _check_line(lines[cursor])
134 @abstractmethod
135 def parse(self, cursor: _CURSOR, lines: _CHUNK) -> _RESULT:
136 """Extract a property from the cursor position.
137 Assumes that "has_property" would evaluate to True
138 from cursor position """
141class SimpleProperty(VaspPropertyParser, ABC):
142 LINE_DELIMITER: str | None = None
144 def __init__(self):
145 super().__init__()
146 if self.LINE_DELIMITER is None:
147 raise ValueError('Must specify a line delimiter.')
149 def has_property(self, cursor, lines) -> bool:
150 line = lines[cursor]
151 return self.LINE_DELIMITER in line
154class VaspChunkPropertyParser(VaspPropertyParser, ABC):
155 """Base class for parsing a chunk of the OUTCAR.
156 The base assumption is that only a chunk of lines is passed"""
158 def __init__(self, header: _HEADER | None = None):
159 super().__init__()
160 header = header or {}
161 self.header = header
163 def get_from_header(self, key: str) -> Any:
164 """Get a key from the header, and raise a ParseError
165 if that key doesn't exist"""
166 try:
167 return self.header[key]
168 except KeyError:
169 raise ParseError(
170 'Parser requested unavailable key "{}" from header'.format(
171 key))
174class VaspHeaderPropertyParser(VaspPropertyParser, ABC):
175 """Base class for parsing the header of an OUTCAR"""
178class SimpleVaspChunkParser(VaspChunkPropertyParser, SimpleProperty, ABC):
179 """Class for properties in a chunk can be
180 determined to exist from 1 line"""
183class SimpleVaspHeaderParser(VaspHeaderPropertyParser, SimpleProperty, ABC):
184 """Class for properties in the header
185 which can be determined to exist from 1 line"""
188class Spinpol(SimpleVaspHeaderParser):
189 """Parse if the calculation is spin-polarized.
191 Example line:
192 " ISPIN = 2 spin polarized calculation?"
194 """
195 LINE_DELIMITER = 'ISPIN'
197 def parse(self, cursor: _CURSOR, lines: _CHUNK) -> _RESULT:
198 line = lines[cursor].strip()
199 parts = line.split()
200 ispin = int(parts[2])
201 # ISPIN 2 = spinpolarized, otherwise no
202 # ISPIN 1 = non-spinpolarized
203 spinpol = ispin == 2
204 return {'spinpol': spinpol}
207class SpeciesTypes(SimpleVaspHeaderParser):
208 """Parse species types.
210 Example line:
211 " POTCAR: PAW_PBE Ni 02Aug2007"
213 We must parse this multiple times, as it's scattered in the header.
214 So this class has to simply parse the entire header.
215 """
216 LINE_DELIMITER = 'POTCAR:'
218 def __init__(self, *args, **kwargs):
219 self._species = [] # Store species as we find them
220 # We count the number of times we found the line,
221 # as we only want to parse every second,
222 # due to repeated entries in the OUTCAR
223 super().__init__(*args, **kwargs)
225 @property
226 def species(self) -> list[str]:
227 """Internal storage of each found line.
228 Will contain the double counting.
229 Use the get_species() method to get the un-doubled list."""
230 return self._species
232 def get_species(self) -> list[str]:
233 """The OUTCAR will contain two 'POTCAR:' entries per species.
234 This method only returns the first half,
235 effectively removing the double counting.
236 """
237 # Get the index of the first half
238 # In case we have an odd number, we round up (for testing purposes)
239 # Tests like to just add species 1-by-1
240 # Having an odd number should never happen in a real OUTCAR
241 # For even length lists, this is just equivalent to idx =
242 # len(self.species) // 2
243 idx = sum(divmod(len(self.species), 2))
244 # Make a copy
245 return list(self.species[:idx])
247 def _make_returnval(self) -> _RESULT:
248 """Construct the return value for the "parse" method"""
249 return {'species': self.get_species()}
251 def parse(self, cursor: _CURSOR, lines: _CHUNK) -> _RESULT:
252 line = lines[cursor].strip()
254 parts = line.split()
255 # Determine in what position we'd expect to find the symbol
256 if '1/r potential' in line:
257 # This denotes an AE potential
258 # Currently only H_AE
259 # " H 1/r potential "
260 idx = 1
261 else:
262 # Regular PAW potential, e.g.
263 # "PAW_PBE H1.25 07Sep2000" or
264 # "PAW_PBE Fe_pv 02Aug2007"
265 idx = 2
267 sym = parts[idx]
268 # remove "_h", "_GW", "_3" tags etc.
269 sym = sym.split('_')[0]
270 # in the case of the "H1.25" potentials etc.,
271 # remove any non-alphabetic characters
272 sym = ''.join([s for s in sym if s.isalpha()])
274 if sym not in atomic_numbers:
275 # Check that we have properly parsed the symbol, and we found
276 # an element
277 raise ParseError(
278 f'Found an unexpected symbol {sym} in line {line}')
280 self.species.append(sym)
282 return self._make_returnval()
285class IonsPerSpecies(SimpleVaspHeaderParser):
286 """Example line:
288 " ions per type = 32 31 2"
289 """
290 LINE_DELIMITER = 'ions per type'
292 def parse(self, cursor: _CURSOR, lines: _CHUNK) -> _RESULT:
293 line = lines[cursor].strip()
294 parts = line.split()
295 ion_types = list(map(int, parts[4:]))
296 return {'ion_types': ion_types}
299class KpointHeader(VaspHeaderPropertyParser):
300 """Reads nkpts and nbands from the line delimiter.
301 Then it also searches for the ibzkpts and kpt_weights"""
303 def has_property(self, cursor: _CURSOR, lines: _CHUNK) -> bool:
304 line = lines[cursor]
305 return "NKPTS" in line and "NBANDS" in line
307 def parse(self, cursor: _CURSOR, lines: _CHUNK) -> _RESULT:
308 line = lines[cursor].strip()
309 parts = line.split()
310 nkpts = int(parts[3])
311 nbands = int(parts[-1])
313 results: dict[str, Any] = {'nkpts': nkpts, 'nbands': nbands}
314 # We also now get the k-point weights etc.,
315 # because we need to know how many k-points we have
316 # for parsing that
317 # Move cursor down to next delimiter
318 delim2 = 'k-points in reciprocal lattice and weights'
319 for offset, line in enumerate(lines[cursor:], start=0):
320 line = line.strip()
321 if delim2 in line:
322 # build k-points
323 ibzkpts = np.zeros((nkpts, 3))
324 kpt_weights = np.zeros(nkpts)
325 for nk in range(nkpts):
326 # Offset by 1, as k-points starts on the next line
327 line = lines[cursor + offset + nk + 1].strip()
328 parts = line.split()
329 ibzkpts[nk] = list(map(float, parts[:3]))
330 kpt_weights[nk] = float(parts[-1])
331 results['ibzkpts'] = ibzkpts
332 results['kpt_weights'] = kpt_weights
333 break
334 else:
335 raise ParseError('Did not find the K-points in the OUTCAR')
337 return results
340class Stress(SimpleVaspChunkParser):
341 """Process the stress from an OUTCAR"""
342 LINE_DELIMITER = 'in kB '
344 def parse(self, cursor: _CURSOR, lines: _CHUNK) -> _RESULT:
345 line = self.get_line(cursor, lines)
346 result: Sequence[float] | None = None
347 try:
348 stress = [float(a) for a in line.split()[2:]]
349 except ValueError:
350 # Vasp FORTRAN string formatting issues, can happen with
351 # some bad geometry steps Alternatively, we can re-raise
352 # as a ParseError?
353 warn('Found badly formatted stress line. Setting stress to None.')
354 else:
355 result = convert_vasp_outcar_stress(stress)
356 return {'stress': result}
359class Cell(SimpleVaspChunkParser):
360 LINE_DELIMITER = 'direct lattice vectors'
362 def parse(self, cursor: _CURSOR, lines: _CHUNK) -> _RESULT:
363 nskip = 1
364 cell = np.zeros((3, 3))
365 for i in range(3):
366 line = self.get_line(cursor + i + nskip, lines)
367 parts = line.split()
368 cell[i, :] = list(map(float, parts[0:3]))
369 return {'cell': cell}
372class PositionsAndForces(SimpleVaspChunkParser):
373 """Positions and forces are written in the same block.
374 We parse both simultaneously"""
375 LINE_DELIMITER = 'POSITION '
377 def parse(self, cursor: _CURSOR, lines: _CHUNK) -> _RESULT:
378 nskip = 2
379 natoms = self.get_from_header('natoms')
380 positions = np.zeros((natoms, 3))
381 forces = np.zeros((natoms, 3))
383 for i in range(natoms):
384 line = self.get_line(cursor + i + nskip, lines)
385 parts = list(map(float, line.split()))
386 positions[i] = parts[0:3]
387 forces[i] = parts[3:6]
388 return {'positions': positions, 'forces': forces}
391class Magmom(VaspChunkPropertyParser):
392 def has_property(self, cursor: _CURSOR, lines: _CHUNK) -> bool:
393 """ We need to check for two separate delimiter strings,
394 to ensure we are at the right place """
395 line = lines[cursor]
396 if 'number of electron' in line:
397 parts = line.split()
398 if len(parts) > 5 and parts[0].strip() != "NELECT":
399 return True
400 return False
402 def parse(self, cursor: _CURSOR, lines: _CHUNK) -> _RESULT:
403 line = self.get_line(cursor, lines)
404 parts = line.split()
405 idx = parts.index('magnetization') + 1
406 magmom_lst = parts[idx:]
407 if len(magmom_lst) != 1:
408 magmom: np.ndarray | float = np.array(
409 list(map(float, magmom_lst))
410 )
411 else:
412 magmom = float(magmom_lst[0])
413 return {'magmom': magmom}
416class Magmoms(VaspChunkPropertyParser):
417 def has_property(self, cursor: _CURSOR, lines: _CHUNK) -> bool:
418 line = lines[cursor]
419 if 'magnetization (x)' in line:
420 natoms = self.get_from_header('natoms')
421 self.non_collinear = False
422 if cursor + natoms + 9 < len(lines):
423 line_y = self.get_line(cursor + natoms + 9, lines)
424 if 'magnetization (y)' in line_y:
425 self.non_collinear = True
426 return True
427 return False
429 def parse(self, cursor: _CURSOR, lines: _CHUNK) -> _RESULT:
431 natoms = self.get_from_header('natoms')
432 if self.non_collinear:
433 magmoms = np.zeros((natoms, 3))
434 nskip = 4 # Skip some lines
435 for i in range(natoms):
436 line = self.get_line(cursor + i + nskip, lines)
437 magmoms[i, 0] = float(line.split()[-1])
438 nskip = natoms + 13 # Skip some lines
439 for i in range(natoms):
440 line = self.get_line(cursor + i + nskip, lines)
441 magmoms[i, 1] = float(line.split()[-1])
442 nskip = 2 * natoms + 22 # Skip some lines
443 for i in range(natoms):
444 line = self.get_line(cursor + i + nskip, lines)
445 magmoms[i, 2] = float(line.split()[-1])
446 else:
447 magmoms = np.zeros(natoms)
448 nskip = 4 # Skip some lines
449 for i in range(natoms):
450 line = self.get_line(cursor + i + nskip, lines)
451 magmoms[i] = float(line.split()[-1])
453 return {'magmoms': magmoms}
456class EFermi(SimpleVaspChunkParser):
457 LINE_DELIMITER = 'E-fermi :'
459 def parse(self, cursor: _CURSOR, lines: _CHUNK) -> _RESULT:
460 line = self.get_line(cursor, lines)
461 parts = line.split()
462 efermi = float(parts[2])
463 return {'efermi': efermi}
466class Energy(SimpleVaspChunkParser):
467 LINE_DELIMITER = _OUTCAR_SCF_DELIM
469 def parse(self, cursor: _CURSOR, lines: _CHUNK) -> _RESULT:
470 nskip = 2
471 line = self.get_line(cursor + nskip, lines)
472 parts = line.strip().split()
473 energy_free = float(parts[4]) # Force consistent
475 nskip = 4
476 line = self.get_line(cursor + nskip, lines)
477 parts = line.strip().split()
478 energy_zero = float(parts[6]) # Extrapolated to 0 K
480 return {'free_energy': energy_free, 'energy': energy_zero}
483class Kpoints(VaspChunkPropertyParser):
484 def has_property(self, cursor: _CURSOR, lines: _CHUNK) -> bool:
485 line = lines[cursor]
486 # Example line:
487 # " spin component 1" or " spin component 2"
488 # We only check spin up, as if we are spin-polarized, we'll parse that
489 # as well
490 if 'spin component 1' in line:
491 parts = line.strip().split()
492 # This string is repeated elsewhere, but not with this exact shape
493 if len(parts) == 3:
494 try:
495 # The last part of te line should be an integer, denoting
496 # spin-up or spin-down
497 int(parts[-1])
498 except ValueError:
499 pass
500 else:
501 return True
502 return False
504 def parse(self, cursor: _CURSOR, lines: _CHUNK) -> _RESULT:
505 nkpts = self.get_from_header('nkpts')
506 nbands = self.get_from_header('nbands')
507 weights = self.get_from_header('kpt_weights')
508 spinpol = self.get_from_header('spinpol')
509 nspins = 2 if spinpol else 1
511 kpts = []
512 for spin in range(nspins):
513 # for Vasp 6, they added some extra information after the
514 # spin components. so we might need to seek the spin
515 # component line
516 cursor = search_lines(f'spin component {spin + 1}', cursor, lines)
518 cursor += 2 # Skip two lines
519 for _ in range(nkpts):
520 # Skip empty lines
521 cursor = find_next_non_empty_line(cursor, lines)
523 line = self.get_line(cursor, lines)
524 # Example line:
525 # "k-point 1 : 0.0000 0.0000 0.0000"
526 parts = line.strip().split()
527 ikpt = int(parts[1]) - 1 # Make kpt idx start from 0
528 weight = weights[ikpt]
530 cursor += 2 # Move down two
531 eigenvalues = np.zeros(nbands)
532 occupations = np.zeros(nbands)
533 for n in range(nbands):
534 # Example line:
535 # " 1 -9.9948 1.00000"
536 parts = lines[cursor].strip().split()
537 eps_n, f_n = map(float, parts[1:])
538 occupations[n] = f_n
539 eigenvalues[n] = eps_n
540 cursor += 1
541 kpt = SinglePointKPoint(weight,
542 spin,
543 ikpt,
544 eps_n=eigenvalues,
545 f_n=occupations)
546 kpts.append(kpt)
548 return {'kpts': kpts}
551class DefaultParsersContainer:
552 """Container for the default OUTCAR parsers.
553 Allows for modification of the global default parsers.
555 Takes in an arbitrary number of parsers.
556 The parsers should be uninitialized,
557 as they are created on request.
558 """
560 def __init__(self, *parsers_cls):
561 self._parsers_dct = {}
562 for parser in parsers_cls:
563 self.add_parser(parser)
565 @property
566 def parsers_dct(self) -> dict:
567 return self._parsers_dct
569 def make_parsers(self):
570 """Return a copy of the internally stored parsers.
571 Parsers are created upon request."""
572 return [parser() for parser in self.parsers_dct.values()]
574 def remove_parser(self, name: str):
575 """Remove a parser based on the name.
576 The name must match the parser name exactly."""
577 self.parsers_dct.pop(name)
579 def add_parser(self, parser) -> None:
580 """Add a parser"""
581 self.parsers_dct[parser.get_name()] = parser
584class TypeParser(ABC):
585 """Base class for parsing a type, e.g. header or chunk,
586 by applying the internal attached parsers"""
588 def __init__(self, parsers):
589 self.parsers = parsers
591 @property
592 def parsers(self):
593 return self._parsers
595 @parsers.setter
596 def parsers(self, new_parsers) -> None:
597 self._check_parsers(new_parsers)
598 self._parsers = new_parsers
600 @abstractmethod
601 def _check_parsers(self, parsers) -> None:
602 """Check the parsers are of correct type"""
604 def parse(self, lines) -> _RESULT:
605 """Execute the attached paresers, and return the parsed properties"""
606 properties = {}
607 for cursor, _ in enumerate(lines):
608 for parser in self.parsers:
609 # Check if any of the parsers can extract a property
610 # from this line Note: This will override any existing
611 # properties we found, if we found it previously. This
612 # is usually correct, as some VASP settings can cause
613 # certain pieces of information to be written multiple
614 # times during SCF. We are only interested in the
615 # final values within a given chunk.
616 if parser.has_property(cursor, lines):
617 prop = parser.parse(cursor, lines)
618 properties.update(prop)
619 return properties
622class ChunkParser(TypeParser, ABC):
623 def __init__(self, parsers, header=None):
624 super().__init__(parsers)
625 self.header = header
627 @property
628 def header(self) -> _HEADER:
629 return self._header
631 @header.setter
632 def header(self, value: _HEADER | None) -> None:
633 self._header = value or {}
634 self.update_parser_headers()
636 def update_parser_headers(self) -> None:
637 """Apply the header to all available parsers"""
638 for parser in self.parsers:
639 parser.header = self.header
641 def _check_parsers(self,
642 parsers: Sequence[VaspChunkPropertyParser]) -> None:
643 """Check the parsers are of correct type 'VaspChunkPropertyParser'"""
644 if not all(
645 isinstance(parser, VaspChunkPropertyParser)
646 for parser in parsers):
647 raise TypeError(
648 'All parsers must be of type VaspChunkPropertyParser')
650 @abstractmethod
651 def build(self, lines: _CHUNK) -> Atoms:
652 """Construct an atoms object of the chunk from the parsed results"""
655class HeaderParser(TypeParser, ABC):
656 def _check_parsers(self,
657 parsers: Sequence[VaspHeaderPropertyParser]) -> None:
658 """Check the parsers are of correct type 'VaspHeaderPropertyParser'"""
659 if not all(
660 isinstance(parser, VaspHeaderPropertyParser)
661 for parser in parsers):
662 raise TypeError(
663 'All parsers must be of type VaspHeaderPropertyParser')
665 @abstractmethod
666 def build(self, lines: _CHUNK) -> _HEADER:
667 """Construct the header object from the parsed results"""
670class OutcarChunkParser(ChunkParser):
671 """Class for parsing a chunk of an OUTCAR."""
673 def __init__(self,
674 header: _HEADER | None = None,
675 parsers: Sequence[VaspChunkPropertyParser] | None = None):
676 global default_chunk_parsers
677 parsers = parsers or default_chunk_parsers.make_parsers()
678 super().__init__(parsers, header=header)
680 def build(self, lines: _CHUNK) -> Atoms:
681 """Apply outcar chunk parsers, and build an atoms object"""
682 self.update_parser_headers() # Ensure header is in sync
684 results = self.parse(lines)
685 symbols = self.header['symbols']
686 constraint = self.header.get('constraint', None)
688 atoms_kwargs = dict(symbols=symbols, constraint=constraint, pbc=True)
690 # Find some required properties in the parsed results.
691 # Raise ParseError if they are not present
692 for prop in ('positions', 'cell'):
693 try:
694 atoms_kwargs[prop] = results.pop(prop)
695 except KeyError:
696 raise ParseError(
697 'Did not find required property {} during parse.'.format(
698 prop))
699 atoms = Atoms(**atoms_kwargs)
701 kpts = results.pop('kpts', None)
702 calc = SinglePointDFTCalculator(atoms, **results)
703 if kpts is not None:
704 calc.kpts = kpts
705 calc.name = 'vasp'
706 atoms.calc = calc
707 return atoms
710class OutcarHeaderParser(HeaderParser):
711 """Class for parsing a chunk of an OUTCAR."""
713 def __init__(self,
714 parsers: Sequence[VaspHeaderPropertyParser] | None = None,
715 workdir: str | PurePath | None = None):
716 global default_header_parsers
717 parsers = parsers or default_header_parsers.make_parsers()
718 super().__init__(parsers)
719 self.workdir = workdir
721 @property
722 def workdir(self):
723 return self._workdir
725 @workdir.setter
726 def workdir(self, value):
727 if value is not None:
728 value = Path(value)
729 self._workdir = value
731 def _build_symbols(self, results: _RESULT) -> Sequence[str]:
732 if 'symbols' in results:
733 # Safeguard, in case a different parser already
734 # did this. Not currently available in a default parser
735 return results.pop('symbols')
737 # Build the symbols of the atoms
738 for required_key in ('ion_types', 'species'):
739 if required_key not in results:
740 raise ParseError(
741 'Did not find required key "{}" in parsed header results.'.
742 format(required_key))
744 ion_types = results.pop('ion_types')
745 species = results.pop('species')
746 if len(ion_types) != len(species):
747 raise ParseError(
748 ('Expected length of ion_types to be same as species, '
749 'but got ion_types={} and species={}').format(
750 len(ion_types), len(species)))
752 # Expand the symbols list
753 symbols = []
754 for n, sym in zip(ion_types, species):
755 symbols.extend(n * [sym])
756 return symbols
758 def _get_constraint(self):
759 """Try and get the constraints from the POSCAR of CONTCAR
760 since they aren't located in the OUTCAR, and thus we cannot construct an
761 OUTCAR parser which does this.
762 """
763 constraint = None
764 if self.workdir is not None:
765 constraint = read_constraints_from_file(self.workdir)
766 return constraint
768 def build(self, lines: _CHUNK) -> _RESULT:
769 """Apply the header parsers, and build the header"""
770 results = self.parse(lines)
772 # Get the symbols from the parsed results
773 # will pop the keys which we use for that purpose
774 symbols = self._build_symbols(results)
775 natoms = len(symbols)
777 constraint = self._get_constraint()
779 # Remaining results from the parse goes into the header
780 header = dict(symbols=symbols,
781 natoms=natoms,
782 constraint=constraint,
783 **results)
784 return header
787class OUTCARChunk(ImageChunk):
788 """Container class for a chunk of the OUTCAR which consists of a
789 self-contained SCF step, i.e. and image. Also contains the header_data
790 """
792 def __init__(self,
793 lines: _CHUNK,
794 header: _HEADER,
795 parser: ChunkParser | None = None):
796 super().__init__()
797 self.lines = lines
798 self.header = header
799 self.parser = parser or OutcarChunkParser()
801 def build(self):
802 self.parser.header = self.header # Ensure header is syncronized
803 return self.parser.build(self.lines)
806def build_header(fd: TextIO) -> _CHUNK:
807 """Build a chunk containing the header data"""
808 lines = []
809 for line in fd:
810 lines.append(line)
811 if 'Iteration' in line:
812 # Start of SCF cycle
813 return lines
815 # We never found the SCF delimiter, so the OUTCAR must be incomplete
816 raise ParseError('Incomplete OUTCAR')
819def build_chunk(fd: TextIO) -> _CHUNK:
820 """Build chunk which contains 1 complete atoms object"""
821 lines = []
822 while True:
823 line = next(fd)
824 lines.append(line)
825 if _OUTCAR_SCF_DELIM in line:
826 # Add 4 more lines to include energy
827 for _ in range(4):
828 lines.append(next(fd))
829 break
830 return lines
833def outcarchunks(
834 fd: TextIO,
835 chunk_parser: ChunkParser | None = None,
836 header_parser: HeaderParser | None = None
837 ) -> Iterator[OUTCARChunk]:
838 """Function to build chunks of OUTCAR from a file stream"""
839 name = Path(fd.name)
840 workdir = name.parent
842 # First we get header info
843 # pass in the workdir from the fd, so we can try and get the constraints
844 header_parser = header_parser or OutcarHeaderParser(workdir=workdir)
846 lines = build_header(fd)
847 header = header_parser.build(lines)
848 assert isinstance(header, dict)
850 chunk_parser = chunk_parser or OutcarChunkParser()
852 while True:
853 try:
854 lines = build_chunk(fd)
855 except StopIteration:
856 # End of file
857 return
858 yield OUTCARChunk(lines, header, parser=chunk_parser)
861# Create the default chunk parsers
862default_chunk_parsers = DefaultParsersContainer(
863 Cell,
864 PositionsAndForces,
865 Stress,
866 Magmoms,
867 Magmom,
868 EFermi,
869 Kpoints,
870 Energy,
871)
873# Create the default header parsers
874default_header_parsers = DefaultParsersContainer(
875 SpeciesTypes,
876 IonsPerSpecies,
877 Spinpol,
878 KpointHeader,
879)