• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

maurergroup / dfttoolkit / 15077298886

16 May 2025 08:57PM UTC coverage: 28.848% (+7.1%) from 21.747%
15077298886

Pull #59

github

b0d5e4
web-flow
Merge 473bfe91e into e895278a4
Pull Request #59: Vibrations refactor

1162 of 4028 relevant lines covered (28.85%)

0.29 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

74.37
dfttoolkit/output.py
1
import copy
1✔
2
import warnings
1✔
3

4
import numpy as np
1✔
5
import numpy.typing as npt
1✔
6
import scipy.sparse as sp
1✔
7

8
from dfttoolkit.base import Parser
1✔
9
from dfttoolkit.geometry import AimsGeometry
1✔
10
from dfttoolkit.utils.exceptions import ItemNotFoundError
1✔
11

12

13
class Output(Parser):
1✔
14
    """
15
    Parse output files from electronic structure calculations.
16

17
    If contributing a new parser, please subclass this class, add the new supported file
18
    type to _supported_files, call the super().__init__ method, include the new file
19
    type as a kwarg in the super().__init__ call. Optionally include the self.lines line
20
    in examples.
21

22
    ...
23

24
    Attributes
25
    ----------
26
    _supported_files : dict
27
        List of supported file types.
28
    """
29

30
    def __init__(self, **kwargs: str):
1✔
31
        # Parse file information and perform checks
32

33
        super().__init__(self._supported_files, **kwargs)
1✔
34

35
        # Check that the files are in the correct format
36
        match self._format:
1✔
37
            case "aims_out":
1✔
38
                self._check_binary(False)
1✔
39
            case "elsi_csc":
1✔
40
                self._check_binary(True)
1✔
41

42
    @property
1✔
43
    def _supported_files(self) -> dict[str, str]:
1✔
44
        # FHI-aims, ELSI, ...
45
        return {"aims_out": ".out", "elsi_csc": ".csc"}
1✔
46

47
    def __repr__(self) -> str:
1✔
48
        return f"{self.__class__.__name__}({self._format}={self._path})"
×
49

50
    def __init_subclass__(cls, **kwargs: str):  # pyright: ignore[reportMissingSuperCall]
1✔
51
        # Override the parent's __init_subclass__ without calling it
52
        pass
1✔
53

54

55
class AimsOutput(Output):
1✔
56
    """
57
    FHI-aims output file parser.
58

59
    ...
60

61
    Attributes
62
    ----------
63
    path: str
64
        path to the aims.out file
65
    lines: List[str]
66
        contents of the aims.out file
67

68
    Examples
69
    --------
70
    >>> ao = AimsOutput(aims_out="./aims.out")
71
    """
72

73
    def __init__(self, aims_out: str = "aims.out"):
1✔
74
        super().__init__(aims_out=aims_out)
1✔
75

76
    def get_number_of_atoms(self) -> int:
1✔
77
        """
78
        Return number of atoms in unit cell.
79

80
        Returns
81
        -------
82
        int
83
            Number of atoms in the unit cell
84
        """
85
        n_atoms = None
1✔
86

87
        for line in self.lines:
1✔
88
            if "| Number of atoms" in line:
1✔
89
                n_atoms = int(line.strip().split()[5])
1✔
90

91
        if n_atoms is None:
1✔
92
            raise ValueError("Number of atoms not found in aims.out file")
×
93

94
        return n_atoms
1✔
95

96
    def get_geometry(self) -> AimsGeometry:
1✔
97
        """
98
        Extract the geometry file from the aims output.
99

100
        Return as a Geometry object.
101

102
        Returns
103
        -------
104
        AimsGeometry
105
            Geometry object
106
        """
107
        geometry_lines = []
1✔
108
        read_trigger = False
1✔
109
        for line in self.lines:
1✔
110
            if (
1✔
111
                "Parsing geometry.in "
112
                "(first pass over file, find array dimensions only)." in line
113
            ):
114
                read_trigger = True
1✔
115

116
            if read_trigger:
1✔
117
                geometry_lines.append(line)
1✔
118

119
            if "Completed first pass over input file geometry.in ." in line:
1✔
120
                break
1✔
121

122
        geometry_text = "\n".join(geometry_lines[6:-3])
1✔
123

124
        geometry = AimsGeometry()
1✔
125
        geometry.parse(geometry_text)
1✔
126

127
        return geometry
1✔
128

129
    def get_geometry_steps_of_optimisation(
1✔
130
        self, n_occurrence: int | None = None
131
    ) -> AimsGeometry | list[AimsGeometry]:
132
        """
133
        Get a list of all geometry steps performed.
134

135
        Parameters
136
        ----------
137
        n_occurrence : int or None
138
            If there are multiple energies in a file (e.g. during a geometry
139
            optimization) this parameters allows to select which energy is
140
            returned. If set to -1 the last one is returned (e.g. result of a
141
            geometry optimization), if set to None, all values will be returned
142
            as a numpy array.
143

144
        Returns
145
        -------
146
        geometry_files : list
147
            List of geometry objects.
148

149
        """
150
        geometry_files = [self.get_geometry()]  # append initial geometry
1✔
151
        geometry_lines = []
1✔
152

153
        state = 0
1✔
154
        # 0... before geometry file,
155
        # 1... between start of geometry file and lattice section
156
        # 2... in lattice section of geometry file
157
        # 3... in atoms section of geometry file
158

159
        for line in self.lines:
1✔
160
            if (
1✔
161
                "Updated atomic structure:" in line
162
                or "Atomic structure that was used in the preceding time step of the "
163
                "wrapper"
164
                in line
165
            ):
166
                state = 1
1✔
167
                geometry_lines = []
1✔
168

169
            if state > 0 and "atom " in line:
1✔
170
                state = 3
1✔
171
            if state == 1 and "lattice_vector  " in line:
1✔
172
                state = 2
×
173

174
            if state > 0:
1✔
175
                geometry_lines.append(line)
1✔
176

177
            if state == 3 and "atom " not in line:
1✔
178
                state = 0
1✔
179
                geometry_text = "".join(geometry_lines[2:-1])
1✔
180
                g = AimsGeometry()
1✔
181
                g.parse(geometry_text)
1✔
182
                geometry_files.append(g)
1✔
183

184
        if n_occurrence is not None:
1✔
185
            geometry_files = geometry_files[n_occurrence]
×
186

187
        return geometry_files
1✔
188

189
    def get_control_file(self) -> list[str]:
1✔
190
        """
191
        Extract the control file from the aims output.
192

193
        Returns
194
        -------
195
        list[str]
196
            Lines from the control file found in the aims output
197
        """
198
        control_lines = []
1✔
199
        control_file_reached = False
1✔
200
        for line in self.lines:
1✔
201
            if (
1✔
202
                "Parsing control.in (first pass over file, find array dimensions only)."
203
                in line
204
            ):
205
                control_file_reached = True
1✔
206

207
            if control_file_reached:
1✔
208
                control_lines.append(line.strip())
1✔
209

210
            if "Completed first pass over input file control.in ." in line:
1✔
211
                break
1✔
212

213
        return control_lines[6:-3]
1✔
214

215
    def get_parameters(self) -> dict:
1✔
216
        """
217
        Parse the parameters of the FHI-aims control file from the aims output.
218

219
        Returns
220
        -------
221
        dict
222
            The parameters of the FHI-aims control file found in the aims output
223
        """
224
        # Find where the parameters start
225
        start_params_line = 0
1✔
226
        for i, line in enumerate(self.lines):
1✔
227
            if (
1✔
228
                "Parsing control.in (first pass over file, find array dimensions only)."
229
                in line
230
            ):
231
                start_params_line = i
1✔
232
                break
1✔
233

234
        parameters = {}
1✔
235

236
        # If the file was written with ASE, there is an extra header/keyword delimiter
237
        extra_lines = (
1✔
238
            11 if self.lines[start_params_line + 8].split()[-1] == "(ASE)" else 6
239
        )
240

241
        for line in self.lines[start_params_line + extra_lines :]:
1✔
242
            # End of parameters and start of basis sets
243
            if line.strip() == "#" * 80 or line.strip() == "#" + ("=" * 79):
1✔
244
                break
1✔
245

246
            spl = line.split()
1✔
247
            if len(spl) > 1:
1✔
248
                parameters[spl[0]] = " ".join(spl[1:])
1✔
249

250
        return parameters
1✔
251

252
    def get_basis_sets(self) -> dict[str, str]: ...
1✔
253

254
    def check_exit_normal(self) -> bool:
1✔
255
        """
256
        Check if the FHI-aims calculation exited normally.
257

258
        Returns
259
        -------
260
        bool
261
            whether the calculation exited normally or not
262
        """
263
        exit_normal = False
1✔
264
        for i in range(1, 10):  # only read last few lines
1✔
265
            if self.lines[-i].strip() == "Have a nice day.":
1✔
266
                exit_normal = True
1✔
267
                break
1✔
268

269
        return exit_normal
1✔
270

271
    def get_time_per_scf(self) -> npt.NDArray[np.float64]:
1✔
272
        """
273
        Calculate the average time taken per SCF iteration.
274

275
        Returns
276
        -------
277
        npt.NDArray[np.float64]
278
            The average time taken per SCF iteration.
279
        """
280
        # Get the number of SCF iterations
281
        n_scf_iters = self.get_n_scf_iters()
1✔
282
        scf_iter_times = np.zeros(n_scf_iters)
1✔
283

284
        # Get the time taken for each SCF iteration
285
        iter_num = 0
1✔
286
        for line in self.lines:
1✔
287
            if "Time for this iteration" in line:
1✔
288
                scf_iter_times[iter_num] = float(line.split()[-4])
1✔
289
                iter_num += 1
1✔
290

291
        return scf_iter_times
1✔
292

293
    def check_geometry_optimisation_has_completed(self) -> bool:
1✔
294
        """Check whether present geometry is converged."""
295
        geometry_optimisation_has_completed = False
×
296
        for line in reversed(self.lines):
×
297
            if "Present geometry is converged." in line:
×
298
                geometry_optimisation_has_completed = True
×
299

300
        return geometry_optimisation_has_completed
×
301

302
    ###############################################################################
303
    #                                   Energies                                  #
304
    ###############################################################################
305
    def _get_energy(  # noqa: PLR0912
1✔
306
        self,
307
        n_occurrence: int | None,
308
        search_string: str,
309
        token_nr: int | None = None,
310
        energy_invalid_indicator: list | int | str | None = None,
311
        energy_valid_indicator: list | int | str | None = None,
312
    ) -> float | npt.NDArray[np.float64]:
313
        """
314
        Generalized energy parser.
315

316
        Parameters
317
        ----------
318
        n_occurrence : Union[int, None]
319
            If there are multiple energies in a file (e.g. during a geometry
320
            optimization) this parameters allows to select which energy is returned.
321
            If set to -1 the last one is returned (e.g. result of a geometry
322
            optimization), if set to None, all values will be returned as a numpy array.
323
        search_string : str
324
            string to be searched in the output file
325
        token_nr : Union[int, None]
326
            take n-th element of found line
327
        energy_invalid_indicator : Union[list, int, str, None] = None
328
            In some cases an energy value can be found in the output file although it is
329
            invalid -> ignore this value. For example, a line having
330
            'restarting mixer to attempt better convergence' indicates that this
331
            SCF-cycle leads to invalid energies.
332
        param energy_valid_indicator : list | int | str | None = None
333
            In some cases the value is only valid after a certain phrase is used ->
334
            ignore all values before. For example, the post-SCF vdW energy correction is
335
            0.00 until the SCF is converged.
336

337
        Returns
338
        -------
339
        energies : float | npt.NDArray[np.float64]
340
            Energies that have been grepped
341
        """
342
        skip_next_energy = False  # only relevant if energy_invalid_indicator != None
1✔
343
        use_next_energy = False  # only relevant if energy_valid_indicator != None
1✔
344

345
        if skip_next_energy and use_next_energy:
1✔
346
            raise ValueError(
×
347
                "AIMSOutput._get_energy: usage of skip_next_energy and "
348
                "use_next_energy at the same function call is undefined!"
349
            )
350

351
        # energy (in)valid indicator allows now for multiple values, if a list is
352
        # provided. Otherwise, everything works out as before.
353
        if energy_valid_indicator is not None and not isinstance(
1✔
354
            energy_valid_indicator, list
355
        ):
356
            energy_valid_indicator = [energy_valid_indicator]
×
357

358
        if energy_invalid_indicator is not None and not isinstance(
1✔
359
            energy_invalid_indicator, list
360
        ):
361
            energy_invalid_indicator = [energy_invalid_indicator]
×
362

363
        energies = []
1✔
364

365
        for line_text in self.lines:
1✔
366
            # check for energy_invalid_indicator:
367
            if energy_invalid_indicator is not None:
1✔
368
                for ind in energy_invalid_indicator:
×
369
                    if ind in line_text:
×
370
                        skip_next_energy = True
×
371

372
            if energy_valid_indicator is not None:
1✔
373
                for ind in energy_valid_indicator:
×
374
                    if ind in line_text:
×
375
                        use_next_energy = True
×
376
            else:
377
                use_next_energy = True
1✔
378

379
            if search_string in line_text:
1✔
380
                if skip_next_energy is True:
1✔
381
                    skip_next_energy = False  # reset this 'counter'
×
382
                elif use_next_energy:
1✔
383
                    if token_nr is None:
1✔
384
                        token_nr = len(search_string.split()) + 3
×
385
                    energies.append(float(line_text.strip().split()[token_nr]))
1✔
386
                    use_next_energy = False
1✔
387
                else:
388
                    pass
×
389

390
        if len(energies) == 0:
1✔
391
            msg = f"Energy not found in aims.out file for {search_string}"
1✔
392
            raise ValueError(msg)
1✔
393

394
        energies = np.array(energies)
1✔
395

396
        if n_occurrence is None:
1✔
397
            return energies
1✔
398
        return energies[n_occurrence]
1✔
399

400
    def get_change_of_total_energy(
1✔
401
        self,
402
        n_occurrence: int | None = -1,
403
        energy_invalid_indicator: str | None = None,
404
    ) -> float | npt.NDArray[np.float64]:
405
        return self._get_energy(
1✔
406
            n_occurrence,
407
            "Change of total energy",
408
            token_nr=6,
409
            energy_invalid_indicator=energy_invalid_indicator,
410
        )
411

412
    def get_change_of_forces(
1✔
413
        self,
414
        n_occurrence: int | None = -1,
415
        energy_invalid_indicator: str | None = None,
416
    ) -> float | npt.NDArray[np.float64]:
417
        return self._get_energy(
1✔
418
            n_occurrence,
419
            "Change of forces",
420
            token_nr=5,
421
            energy_invalid_indicator=energy_invalid_indicator,
422
        )
423

424
    def get_change_of_sum_of_eigenvalues(
1✔
425
        self,
426
        n_occurrence: int | None = -1,
427
        energy_invalid_indicator: str | None = None,
428
    ) -> float | npt.NDArray[np.float64]:
429
        return self._get_energy(
×
430
            n_occurrence,
431
            "Change of sum of eigenvalues",
432
            token_nr=7,
433
            energy_invalid_indicator=energy_invalid_indicator,
434
        )
435

436
    def get_change_of_charge_density(
1✔
437
        self,
438
        n_occurrence: int | None = -1,
439
        energy_invalid_indicator: str | None = None,
440
    ) -> float | npt.NDArray[np.float64]:
441
        return self._get_energy(
×
442
            n_occurrence,
443
            "Change of charge density",
444
            token_nr=6,
445
            energy_invalid_indicator=energy_invalid_indicator,
446
        )
447

448
    def get_maximum_force(
1✔
449
        self,
450
        n_occurrence: int | None = -1,
451
        energy_invalid_indicator: str | None = None,
452
    ) -> float | npt.NDArray[np.float64]:
453
        return self._get_energy(
×
454
            n_occurrence,
455
            "Maximum force component",
456
            token_nr=4,
457
            energy_invalid_indicator=energy_invalid_indicator,
458
        )
459

460
    def get_energy_corrected(
1✔
461
        self,
462
        n_occurrence: int | None = -1,
463
        skip_E_after_mixer: bool = True,  # noqa: N803
464
        all_scfs: bool = False,
465
        energy_invalid_indicator: list[str] | None = None,
466
    ) -> float | npt.NDArray[np.float64]:
467
        """
468
        Return the total corrected energy.
469

470
        Parameters
471
        ----------
472
        n_occurrence : int | None
473
            If there are multiple energies in a file (e.g. during a geometry op)
474
            this parameters allows to select which energy is returned.
475
            If set to -1 the last one is returned (e.g. result of a geometry opt)
476
            if set to None, all values will be returned as a numpy array.
477

478
        skip_E_after_mixer : bool, default=True
479
            If the scf cycles of one geometry optimisation step didn't converge,
480
            aims will restart the mixer and this optimisation step.
481
            However, it still prints out the total energy, which can be totally
482
            nonsense. if skip_E_after_mixer==True ignore first total energy after
483
            'restarting mixer to attempt better convergence'
484

485
        Examples
486
        --------
487
        >>> AimsOutput.get_energy_corrected()
488
        -2080.83225450528
489
        """
490
        if energy_invalid_indicator is None:
×
491
            energy_invalid_indicator = []
×
492
        if skip_E_after_mixer:
×
493
            energy_invalid_indicator += [
×
494
                "restarting mixer to attempt better convergence"
495
            ]
496

497
        if all_scfs:
×
498
            return self.get_total_energy_T0(n_occurrence, skip_E_after_mixer)
×
499
        return self._get_energy(
×
500
            n_occurrence,
501
            search_string="| Total energy corrected",
502
            energy_invalid_indicator=energy_invalid_indicator,
503
            token_nr=5,
504
        )
505

506
    def get_total_energy_T0(  # noqa: N802
1✔
507
        self,
508
        n_occurrence: None | int = -1,
509
        skip_E_after_mixer: bool = True,  # noqa: N803
510
        energy_invalid_indicator: list[str] | None = None,
511
    ) -> float | npt.NDArray[np.float64]:
512
        if energy_invalid_indicator is None:
×
513
            energy_invalid_indicator = []
×
514
        if skip_E_after_mixer:
×
515
            energy_invalid_indicator += [
×
516
                "restarting mixer to attempt better convergence"
517
            ]
518

519
        return self._get_energy(
×
520
            n_occurrence,
521
            search_string="| Total energy, T -> 0",
522
            energy_invalid_indicator=energy_invalid_indicator,
523
            token_nr=9,
524
        )
525

526
    def get_energy_uncorrected(
1✔
527
        self,
528
        n_occurrence: None | int = -1,
529
        skip_E_after_mixer: bool = True,  # noqa: N803
530
        energy_invalid_indicator: list[str] | None = None,
531
    ) -> float | npt.NDArray[np.float64]:
532
        """
533
        Return uncorrected (without smearing correction) energy.
534

535
        Parameters
536
        ----------
537
        n_occurrence : Union[int, None]
538
            see getEnergyCorrected()
539

540
        skip_E_after_mixer : bool
541
            If the scf cycles of one geometry optimisation step didn't converge,
542
            aims will restart the mixer and this optimisation step.
543
            However, it still prints out the total energy, which can be totally
544
            nonsense. if skip_E_after_mixer==True: ignore first total energy after
545
            'restarting mixer to attempt better convergence'.
546

547
        Returns
548
        -------
549
        float | npt.NDArray[np.float64]
550
            Uncorrected energy
551
        """
552
        if energy_invalid_indicator is None:
×
553
            energy_invalid_indicator = []
×
554
        if skip_E_after_mixer:
×
555
            energy_invalid_indicator += [
×
556
                "restarting mixer to attempt better convergence"
557
            ]
558

559
        return self._get_energy(
×
560
            n_occurrence,
561
            search_string="| Total energy uncorrected",
562
            energy_invalid_indicator=energy_invalid_indicator,
563
            token_nr=5,
564
        )
565

566
    def get_energy_without_vdw(
1✔
567
        self,
568
        n_occurrence: None | int = -1,
569
        energy_invalid_indicator: list[str] | None = None,
570
    ) -> float | npt.NDArray[np.float64]:
571
        if energy_invalid_indicator is None:
×
572
            energy_invalid_indicator = []
×
573
        energy = self.get_energy_corrected(
×
574
            n_occurrence=n_occurrence,
575
            energy_invalid_indicator=energy_invalid_indicator,
576
        )
577

578
        energy_vdw = self.get_vdw_energy(
×
579
            n_occurrence=n_occurrence,
580
            energy_invalid_indicator=energy_invalid_indicator,
581
        )
582

583
        return energy - energy_vdw
×
584

585
    def get_HOMO_energy(  # noqa: N802
1✔
586
        self,
587
        n_occurrence: None | int = -1,
588
        energy_invalid_indicator: list[str] | None = None,
589
    ) -> float | npt.NDArray[np.float64]:
590
        return self._get_energy(
×
591
            n_occurrence,
592
            "Highest occupied state",
593
            token_nr=5,
594
            energy_invalid_indicator=energy_invalid_indicator,
595
        )
596

597
    def get_LUMO_energy(  # noqa: N802
1✔
598
        self,
599
        n_occurrence: None | int = -1,
600
        energy_invalid_indicator: list[str] | None = None,
601
    ) -> float | npt.NDArray[np.float64]:
602
        if energy_invalid_indicator is None:
×
603
            energy_invalid_indicator = []
×
604
        return self._get_energy(
×
605
            n_occurrence,
606
            "Lowest unoccupied state",
607
            token_nr=5,
608
            energy_invalid_indicator=energy_invalid_indicator,
609
        )
610

611
    def get_vdw_energy(
1✔
612
        self,
613
        n_occurrence: None | int = -1,
614
        energy_invalid_indicator: list[str] | None = None,
615
    ) -> float | npt.NDArray[np.float64]:
616
        if energy_invalid_indicator is None:
×
617
            energy_invalid_indicator = []
×
618
        search_keyword = "| vdW energy correction"
×
619
        token_nr = None
×
620

621
        return self._get_energy(
×
622
            n_occurrence,
623
            search_keyword,
624
            token_nr=token_nr,
625
            energy_invalid_indicator=energy_invalid_indicator,
626
            energy_valid_indicator="Self-consistency cycle converged",
627
        )
628

629
    def get_exchange_correlation_energy(
1✔
630
        self,
631
        n_occurrence: None | int = -1,
632
        energy_invalid_indicator: list[str] | None = None,
633
    ) -> float | npt.NDArray[np.float64]:
634
        if energy_invalid_indicator is None:
×
635
            energy_invalid_indicator = []
×
636
        return self._get_energy(
×
637
            n_occurrence,
638
            "| XC energy correction",
639
            energy_invalid_indicator=energy_invalid_indicator,
640
        )
641

642
    def get_electrostatic_energy(
1✔
643
        self,
644
        n_occurrence: None | int = -1,
645
        energy_invalid_indicator: list[str] | None = None,
646
    ) -> float | npt.NDArray[np.float64]:
647
        if energy_invalid_indicator is None:
×
648
            energy_invalid_indicator = []
×
649
        return self._get_energy(
×
650
            n_occurrence,
651
            "| Electrostatic energy ",
652
            energy_invalid_indicator=energy_invalid_indicator,
653
        )
654

655
    def get_kinetic_energy(
1✔
656
        self,
657
        n_occurrence: None | int = -1,
658
        energy_invalid_indicator: list[str] | None = None,
659
    ) -> float | npt.NDArray[np.float64]:
660
        if energy_invalid_indicator is None:
×
661
            energy_invalid_indicator = []
×
662
        return self._get_energy(
×
663
            n_occurrence,
664
            "| Kinetic energy ",
665
            energy_invalid_indicator=energy_invalid_indicator,
666
        )
667

668
    def get_sum_of_eigenvalues(
1✔
669
        self,
670
        n_occurrence: None | int = -1,
671
        energy_invalid_indicator: list[str] | None = None,
672
    ) -> float | npt.NDArray[np.float64]:
673
        if energy_invalid_indicator is None:
×
674
            energy_invalid_indicator = []
×
675
        return self._get_energy(
×
676
            n_occurrence,
677
            "| Sum of eigenvalues  ",
678
            energy_invalid_indicator=energy_invalid_indicator,
679
        )
680

681
    def get_cx_potential_correction(
1✔
682
        self,
683
        n_occurrence: None | int = -1,
684
        energy_invalid_indicator: list[str] | None = None,
685
    ) -> float | npt.NDArray[np.float64]:
686
        if energy_invalid_indicator is None:
×
687
            energy_invalid_indicator = []
×
688
        return self._get_energy(
×
689
            n_occurrence,
690
            "| XC potential correction",
691
            energy_invalid_indicator=energy_invalid_indicator,
692
        )
693

694
    def get_free_atom_electrostatic_energy(
1✔
695
        self,
696
        n_occurrence: None | int = -1,
697
        energy_invalid_indicator: list[str] | None = None,
698
    ) -> float | npt.NDArray[np.float64]:
699
        if energy_invalid_indicator is None:
×
700
            energy_invalid_indicator = []
×
701
        return self._get_energy(
×
702
            n_occurrence,
703
            "| Free-atom electrostatic energy:",
704
            token_nr=6,
705
            energy_invalid_indicator=energy_invalid_indicator,
706
        )
707

708
    def get_entropy_correction(
1✔
709
        self,
710
        n_occurrence: None | int = -1,
711
        energy_invalid_indicator: list[str] | None = None,
712
    ) -> float | npt.NDArray[np.float64]:
713
        if energy_invalid_indicator is None:
×
714
            energy_invalid_indicator = []
×
715
        return self._get_energy(
×
716
            n_occurrence,
717
            "| Entropy correction ",
718
            energy_invalid_indicator=energy_invalid_indicator,
719
        )
720

721
    def get_hartree_energy_correction(
1✔
722
        self,
723
        n_occurrence: None | int = -1,
724
        energy_invalid_indicator: list[str] | None = None,
725
    ) -> float | npt.NDArray[np.float64]:
726
        if energy_invalid_indicator is None:
×
727
            energy_invalid_indicator = []
×
728
        return self._get_energy(
×
729
            n_occurrence,
730
            "| Hartree energy correction",
731
            energy_invalid_indicator=energy_invalid_indicator,
732
        )
733

734
    def get_ionic_embedding_energy(
1✔
735
        self,
736
        n_occurrence: None | int = -1,
737
        energy_invalid_indicator: list[str] | None = None,
738
    ) -> float | npt.NDArray[np.float64]:
739
        """Get the energy of the nuclei in the potential of the electric field."""
740
        if energy_invalid_indicator is None:
×
741
            energy_invalid_indicator = []
×
742
        return self._get_energy(
×
743
            n_occurrence,
744
            "| Ionic    embedding energy",
745
            energy_invalid_indicator=energy_invalid_indicator,
746
        )
747

748
    def get_density_embedding_energy(
1✔
749
        self,
750
        n_occurrence: None | int = -1,
751
        energy_invalid_indicator: list[str] | None = None,
752
    ) -> float | npt.NDArray[np.float64]:
753
        """
754
        Get the energy of the electrons in the potential of the electric field.
755

756
        Electrons given as electron density
757
        """
758
        if energy_invalid_indicator is None:
×
759
            energy_invalid_indicator = []
×
760
        return self._get_energy(
×
761
            n_occurrence,
762
            "| Density  embedding energy",
763
            energy_invalid_indicator=energy_invalid_indicator,
764
        )
765

766
    def get_nonlocal_embedding_energy(
1✔
767
        self,
768
        n_occurrence: None | int = -1,
769
        energy_invalid_indicator: list[str] | None = None,
770
    ) -> float | npt.NDArray[np.float64]:
771
        """Non-local electron interaction energy in the electric field potential."""
772
        if energy_invalid_indicator is None:
×
773
            energy_invalid_indicator = []
×
774
        return self._get_energy(
×
775
            n_occurrence,
776
            "| Nonlocal embedding energy",
777
            energy_invalid_indicator=energy_invalid_indicator,
778
        )
779

780
    def get_external_embedding_energy(
1✔
781
        self,
782
        n_occurrence: None | int = -1,
783
        energy_invalid_indicator: list[str] | None = None,
784
    ) -> float | npt.NDArray[np.float64]:
785
        """
786
        Calculate the sum of all the embedding energies.
787

788
        Ionic + (electronic) density + nonlocal.
789
        """
790
        if energy_invalid_indicator is None:
×
791
            energy_invalid_indicator = []
×
792
        return self._get_energy(
×
793
            n_occurrence,
794
            "| External embedding energy",
795
            energy_invalid_indicator=energy_invalid_indicator,
796
        )
797

798
    def get_forces(self, n_occurrence: None | int = -1) -> npt.NDArray[np.float64]:
1✔
799
        """Return forces on all atoms."""
800
        natoms = self.get_number_of_atoms()
1✔
801
        all_force_values = []
1✔
802

803
        for j, line in enumerate(self.lines):
1✔
804
            if "Total atomic forces" in line:
1✔
805
                force_values = np.ones([natoms, 3]) * np.nan
1✔
806
                for i in range(natoms):
1✔
807
                    force_values[i, :] = [
1✔
808
                        float(x) for x in self.lines[j + i + 1].split()[2:5]
809
                    ]
810
                all_force_values.append(np.array(force_values))
1✔
811

812
        if len(all_force_values) == 0:
1✔
813
            msg = f"Forces not found in {self.path} file"
×
814
            raise ValueError(msg)
×
815

816
        if n_occurrence is None:
1✔
817
            return np.array(all_force_values)
×
818
        return all_force_values[n_occurrence]
1✔
819

820
    def get_vdw_forces(self, nr_of_occurrence: int = -1) -> dict:  # noqa: ARG002
1✔
821
        components_of_gradients = self.get_force_components()
1✔
822

823
        return components_of_gradients["van_der_waals"]
1✔
824
        # TODO: implement occurences
825
        # if nr_of_occurrence is None:
826
        #     return components_of_gradients["van_der_waals"]  # noqa: ERA001
827
        # else:  # noqa: ERA001
828
        #     return components_of_gradients["van_der_waals"][nr_of_occurrence]  # noqa: E501, ERA001
829

830
    def get_forces_without_vdw(self, nr_of_occurrence: int = -1) -> npt.NDArray:  # noqa: ARG002
1✔
831
        """
832
        Return the uncleaned forces with the vdW component.
833

834
        Note that for the final output, which you get when envoding `self.get_forces()`
835
        you get the cleaned forces. Look up "final_forces_cleaned" in the AIMS manual
836
        for more info.
837

838
        Parameters
839
        ----------
840
        nr_of_occurrence : int, default=-1
841
            Currently not used. The default is -1.
842

843
        Returns
844
        -------
845
        gradients_without_vdW : npt.NDArray
846
        """
847
        components_of_gradients = self.get_force_components()
1✔
848

849
        return (
1✔
850
            components_of_gradients["total"] - components_of_gradients["van_der_waals"]
851
        )
852

853
        # TODO: implement occurences
854
        # if nr_of_occurrence is None:
855
        #     return gradients_without_vdW  # noqa: ERA001
856
        # else:  # noqa: ERA001
857
        #     return gradients_without_vdW[nr_of_occurrence]  # noqa: ERA001
858

859
    def get_force_components(self, nr_of_occurrence: int = -1) -> dict:  # noqa: ARG002
1✔
860
        """
861
        Return the force component specified in "component" for all atoms.
862

863
        These are the Hellmann-Feynman, Ionic, Multipole, Pulay + GGA, Van der Waals,
864
        and total forces
865

866
        Parameters
867
        ----------
868
        nr_of_occurrence : int, default=-1
869
            Currently not used. The default is -1.
870

871
        Returns
872
        -------
873
        forces : dict
874
            Dictionary with the force components as keys and the forces as values.
875
            The forces are in the form of a numpy array with shape (n_atoms, 3).
876
        """
877
        number_of_atoms = self.get_number_of_atoms()
1✔
878

879
        force_key_list = [
1✔
880
            "Hellmann-Feynman              :",
881
            "Ionic forces                  :",
882
            "Multipole                     :",
883
            "Pulay + GGA                   :",
884
            "Van der Waals                 :",
885
            "Total forces",
886
        ]
887
        force_key_list_2 = [
1✔
888
            "hellmann_feynman",
889
            "ionic",
890
            "multipole",
891
            "pulay_gga",
892
            "van_der_waals",
893
            "total",
894
        ]
895
        force_values = {}
1✔
896

897
        for ind_0, force_key in enumerate(force_key_list):
1✔
898
            force_key_2 = force_key_list_2[ind_0]
1✔
899
            force_values[force_key_2] = np.ones([number_of_atoms, 3]) * np.nan
1✔
900

901
            for ind_1 in range(3):
1✔
902
                force = self._get_energy(
1✔
903
                    None,
904
                    force_key,
905
                    token_nr=ind_1 - 3,
906
                    energy_invalid_indicator=None,
907
                )
908
                force_values[force_key_2][:, ind_1] = force[-number_of_atoms:]  # pyright: ignore[reportIndexIssue]
1✔
909

910
            centre_fo_mass_force = np.mean(force_values[force_key_2], axis=0)
1✔
911
            centre_fo_mass_force = np.tile(centre_fo_mass_force, (number_of_atoms, 1))
1✔
912

913
            force_values[force_key_2] -= centre_fo_mass_force
1✔
914

915
        return force_values
1✔
916

917
    def check_spin_polarised(self) -> bool:
1✔
918
        """
919
        Check if the FHI-aims calculation was spin polarised.
920

921
        Returns
922
        -------
923
        bool
924
            Whether the calculation was spin polarised or not
925
        """
926
        spin_polarised = False
1✔
927

928
        for line in self.lines:
1✔
929
            spl = line.split()
1✔
930
            if len(spl) == 2:
1✔
931
                # Don't break the loop if spin polarised calculation is found as if the
932
                # keyword is specified again, it is the last one that is used
933
                if spl[0] == "spin" and spl[1] == "collinear":
1✔
934
                    spin_polarised = True
1✔
935

936
                if spl[0] == "spin" and spl[1] == "none":
1✔
937
                    spin_polarised = False
1✔
938

939
        return spin_polarised
1✔
940

941
    def get_convergence_parameters(self) -> dict:
1✔
942
        """
943
        Get the convergence parameters from the aims.out file.
944

945
        Returns
946
        -------
947
        dict
948
            The convergence parameters from the aims.out file
949
        """
950
        # Setup dictionary to store convergence parameters
951
        self.convergence_params = {
1✔
952
            "charge_density": 0.0,
953
            "sum_eigenvalues": 0.0,
954
            "total_energy": 0.0,
955
            "total_force": 0.0,
956
        }
957

958
        for line in self.lines:
1✔
959
            spl = line.split()
1✔
960
            if len(spl) > 1:
1✔
961
                if spl[1] == "accuracy" and "charge density" in line:
1✔
962
                    self.convergence_params["charge_density"] = float(spl[-1])
1✔
963
                if spl[1] == "accuracy" and "sum of eigenvalues" in line:
1✔
964
                    self.convergence_params["sum_eigenvalues"] = float(spl[-1])
1✔
965
                if spl[1] == "accuracy" and "total energy" in line:
1✔
966
                    self.convergence_params["total_energy"] = float(spl[-1])
1✔
967
                if spl[1] == "accuracy" and spl[3] == "forces:":
1✔
968
                    self.convergence_params["total_force"] = float(spl[-1])
1✔
969

970
                # No more values to get after SCF starts
971
                if "Begin self-consistency loop" in line:
1✔
972
                    break
1✔
973

974
        return self.convergence_params
1✔
975

976
    def get_final_energy(self) -> float | None:
1✔
977
        """
978
        Get the final energy from a FHI-aims calculation.
979

980
        Returns
981
        -------
982
        Union[float, None]
983
            The final energy of the calculation
984
        """
985
        for line in self.lines:
1✔
986
            if "s.c.f. calculation      :" in line:
1✔
987
                return float(line.split()[-2])
1✔
988

989
        return None
1✔
990

991
    def get_final_spin_moment(self) -> tuple | None:
1✔
992
        """
993
        Get the final spin moment from a FHI-aims calculation.
994

995
        Returns
996
        -------
997
        Union[tuple, None]
998
            The final spin moment of the calculation, if it exists
999
        """
1000
        n, s, j = None, None, None
1✔
1001

1002
        # Iterate through the lines in reverse order to find the final spin moment
1003
        for i, line in enumerate(reversed(self.lines)):
1✔
1004
            if "Current spin moment of the entire structure :" in line:
1✔
1005
                if len(self.lines[-1 - i + 3].split()) > 0:
1✔
1006
                    n = float(self.lines[-1 - i + 1].split()[-1])
1✔
1007
                    s = float(self.lines[-1 - i + 2].split()[-1])
1✔
1008
                    j = float(self.lines[-1 - i + 3].split()[-1])
1✔
1009

1010
                    return n, s, j
1✔
1011

1012
                # Non-gamma point periodic calculation
1013
                n = float(self.lines[-1 - i + 1].split()[-1])
1✔
1014
                s = float(self.lines[-1 - i + 2].split()[-1])
1✔
1015

1016
                return n, s
1✔
1017

1018
        if n is None or s is None:
1✔
1019
            return None
1✔
1020

1021
        return None
×
1022

1023
    def get_n_relaxation_steps(self) -> int:
1✔
1024
        """
1025
        Get the number of relaxation steps from the aims.out file.
1026

1027
        Returns
1028
        -------
1029
        int
1030
            the number of relaxation steps
1031
        """
1032
        n_relax_steps = 0
×
1033
        for line in reversed(self.lines):
×
1034
            if "Number of relaxation steps" in line:
×
1035
                return int(line.split()[-1])
×
1036

1037
            # If the calculation did not finish normally, the number of relaxation steps
1038
            # will not be printed. In this case, count each relaxation step as they were
1039
            # calculated by checking when the SCF cycle converged.
1040
            if line.strip() == "Self-consistency cycle converged.":
×
1041
                n_relax_steps += 1
×
1042

1043
        return n_relax_steps
×
1044

1045
    def get_n_scf_iters(self) -> int:
1✔
1046
        """
1047
        Get the number of SCF iterations from the aims.out file.
1048

1049
        Returns
1050
        -------
1051
        int
1052
            The number of scf iterations
1053
        """
1054
        n_scf_iters = 0
1✔
1055
        for line in reversed(self.lines):
1✔
1056
            if "Number of self-consistency cycles" in line:
1✔
1057
                return int(line.split()[-1])
1✔
1058

1059
            # If the calculation did not finish normally, the number of SCF iterations
1060
            # will not be printed. In this case, count each SCF iteration as they were
1061
            # calculated
1062
            if "Begin self-consistency iteration #" in line:
1✔
1063
                n_scf_iters += 1
1✔
1064

1065
        return n_scf_iters
1✔
1066

1067
    def get_i_scf_conv_acc(self) -> dict:
1✔
1068
        """
1069
        Get SCF convergence accuracy values from the aims.out file.
1070

1071
        Returns
1072
        -------
1073
        dict
1074
            The scf convergence accuracy values from the aims.out file
1075
        """
1076
        # Read the total number of SCF iterations
1077
        n_scf_iters = self.get_n_scf_iters()
×
1078
        n_relax_steps = self.get_n_relaxation_steps() + 1
×
1079

1080
        # Check that the calculation finished normally otherwise number of SCF
1081
        # iterations is not known
1082
        self.scf_conv_acc_params = {
×
1083
            "scf_iter": np.zeros(n_scf_iters),
1084
            "change_of_charge": np.zeros(n_scf_iters),
1085
            "change_of_charge_spin_density": np.zeros(n_scf_iters),
1086
            "change_of_sum_eigenvalues": np.zeros(n_scf_iters),
1087
            "change_of_total_energy": np.zeros(n_scf_iters),
1088
            # "change_of_forces": np.zeros(n_relax_steps),  # noqa: ERA001
1089
            "forces_on_atoms": np.zeros(n_relax_steps),
1090
        }
1091

1092
        current_scf_iter = 0
×
1093
        current_relax_step = 0
×
1094
        # new_scf_iter = True  # noqa: ERA001
1095

1096
        for line in self.lines:
×
1097
            spl = line.split()
×
1098
            if len(spl) > 1:
×
1099
                if "Begin self-consistency iteration #" in line:
×
1100
                    # save the scf iteration number
1101
                    self.scf_conv_acc_params["scf_iter"][current_scf_iter] = int(
×
1102
                        spl[-1]
1103
                    )
1104
                    # use a counter rather than reading the SCF iteration number as it
1105
                    # resets upon re-initialisation and for each geometry opt step
1106
                    current_scf_iter += 1
×
1107

1108
                # Use spin density if spin polarised calculation
1109
                if "Change of charge/spin density" in line:
×
1110
                    self.scf_conv_acc_params["change_of_charge"][
×
1111
                        current_scf_iter - 1
1112
                    ] = float(spl[-2])
1113
                    self.scf_conv_acc_params["change_of_charge_spin_density"][
×
1114
                        current_scf_iter - 1
1115
                    ] = float(spl[-1])
1116

1117
                # Otherwise just use change of charge
1118
                elif "Change of charge" in line:
×
1119
                    self.scf_conv_acc_params["change_of_charge"][
×
1120
                        current_scf_iter - 1
1121
                    ] = float(spl[-1])
1122

1123
                if "Change of sum of eigenvalues" in line:
×
1124
                    self.scf_conv_acc_params["change_of_sum_eigenvalues"][
×
1125
                        current_scf_iter - 1
1126
                    ] = float(spl[-2])
1127

1128
                if "Change of total energy" in line:
×
1129
                    self.scf_conv_acc_params["change_of_total_energy"][
×
1130
                        current_scf_iter - 1
1131
                    ] = float(spl[-2])
1132

1133
                # NOTE
1134
                # In the current aims compilation I'm using to test this, there is
1135
                # something wrong with printing the change of forces. It happens
1136
                # multiple times per relaxation and is clearly wrong so I am removing
1137
                # this functionality for now
1138

1139
                # if "Change of forces" in line:
1140
                #     # Only save the smallest change of forces for each geometry
1141
                #     # relaxation step. I have no idea why it prints multiple times but
1142
                #     # I assume it's a data race of some sort
1143
                #     if new_scf_iter:
1144
                #         self.scf_conv_acc_params["change_of_forces"][
1145
                #             current_relax_step - 1
1146
                #         ] = float(spl[-2])
1147

1148
                #         new_scf_iter = False  # noqa: ERA001
1149

1150
                #     elif (
1151
                #         float(spl[-2])  # noqa: ERA001
1152
                #         < self.scf_conv_acc_params["change_of_forces"][-1]
1153
                #     ):
1154
                #         self.scf_conv_acc_params["change_of_forces"][
1155
                #             current_relax_step - 1
1156
                #         ] = float(spl[-2])
1157

1158
                if "Forces on atoms" in line:
×
1159
                    self.scf_conv_acc_params["forces_on_atoms"][
×
1160
                        current_relax_step - 1
1161
                    ] = float(spl[-2])
1162

1163
                if line.strip() == "Self-consistency cycle converged.":
×
1164
                    # new_scf_iter = True  # noqa: ERA001
1165
                    current_relax_step += 1
×
1166

1167
        return self.scf_conv_acc_params
×
1168

1169
    def get_n_initial_ks_states(self, include_spin_polarised: bool = True) -> int:
1✔
1170
        """
1171
        Get the number of Kohn-Sham states from the first SCF step.
1172

1173
        Parameters
1174
        ----------
1175
        include_spin_polarised : bool, default=True
1176
            Whether to include the spin-down states in the count if the calculation is
1177
            spin polarised.
1178

1179
        Returns
1180
        -------
1181
        int
1182
            The number of kohn-sham states
1183

1184
        Raises
1185
        ------
1186
        ValueError
1187
            No KS states found in aims.out file
1188
        """
1189
        target_line = "State    Occupation    Eigenvalue [Ha]    Eigenvalue [eV]"
1✔
1190

1191
        init_ev_start = 0
1✔
1192
        n_ks_states = 0
1✔
1193

1194
        # Find the first time the KS states are printed
1195
        init_ev_start = None
1✔
1196
        for i, line in enumerate(self.lines):
1✔
1197
            if target_line == line.strip():
1✔
1198
                init_ev_start = i + 1
1✔
1199
                break
1✔
1200

1201
        if init_ev_start is None:
1✔
1202
            raise ValueError("No KS states found in aims.out file.")
×
1203

1204
        # Then count the number of lines until the next empty line
1205
        for i, line in enumerate(self.lines[init_ev_start:]):
1✔
1206
            if len(line.strip()) == 0:
1✔
1207
                n_ks_states = i
1✔
1208
                break
1✔
1209

1210
        if n_ks_states == 0:
1✔
1211
            raise ValueError("No KS states found in aims.out file.")
×
1212

1213
        # Count the spin-down eigenvalues if the calculation is spin polarised
1214
        if include_spin_polarised:
1✔
1215
            init_ev_end = init_ev_start + n_ks_states
1✔
1216
            if target_line == self.lines[init_ev_end + 3].strip():
1✔
1217
                init_ev_end += 4
1✔
1218
                for line in self.lines[init_ev_end:]:
1✔
1219
                    if len(line) > 1:
1✔
1220
                        n_ks_states += 1
1✔
1221
                    else:
1222
                        break
1✔
1223

1224
            else:  # If SD states are not found 4 lines below end of SU states
1225
                warnings.warn(
1✔
1226
                    "A spin polarised calculation was expected but not found.",
1227
                    stacklevel=2,
1228
                )
1229

1230
        return n_ks_states
1✔
1231

1232
    def _get_ks_states(
1✔
1233
        self, ev_start: int, eigenvalues: dict, scf_iter: int, n_ks_states: int
1234
    ) -> None:
1235
        """
1236
        Get any set of KS states, occupations, and eigenvalues.
1237

1238
        Parameters
1239
        ----------
1240
        ev_start : int
1241
            The line number where the KS states start.
1242
        eigenvalues : dict
1243
            The dictionary to store the KS states, occupations, and eigenvalues.
1244
        scf_iter : int
1245
            The current SCF iteration.
1246
        n_ks_states : int
1247
            The number of KS states to save.
1248
        """
1249
        if (
1✔
1250
            eigenvalues["state"].ndim == 1
1251
            or eigenvalues["occupation"].ndim == 1
1252
            or eigenvalues["eigenvalue_eV"].ndim == 1
1253
        ):
1254
            # This is the case for finding the final KS eigenvalues
1255
            # Therefore only parse the KS states from the final SCF iteration
1256
            for i, line in enumerate(self.lines[ev_start : ev_start + n_ks_states]):
1✔
1257
                values = line.split()
1✔
1258
                eigenvalues["state"][i] = int(values[0])
1✔
1259
                eigenvalues["occupation"][i] = float(values[1])
1✔
1260
                eigenvalues["eigenvalue_eV"][i] = float(values[3])
1✔
1261

1262
        else:
1263
            for i, line in enumerate(self.lines[ev_start : ev_start + n_ks_states]):
1✔
1264
                values = line.split()
1✔
1265
                eigenvalues["state"][scf_iter][i] = int(values[0])
1✔
1266
                eigenvalues["occupation"][scf_iter][i] = float(values[1])
1✔
1267
                eigenvalues["eigenvalue_eV"][scf_iter][i] = float(values[3])
1✔
1268

1269
    def get_all_ks_eigenvalues(self) -> dict | tuple[dict, dict]:
1✔
1270
        """
1271
        Get all Kohn-Sham eigenvalues from a calculation.
1272

1273
        Returns
1274
        -------
1275
        Union[dict, Tuple[dict, dict]]
1276
            dict
1277
                the kohn-sham eigenvalues
1278
            Tuple[dict, dict]
1279
                dict
1280
                    the spin-up kohn-sham eigenvalues
1281
                dict
1282
                    the spin-down kohn-sham eigenvalues
1283

1284
        Raises
1285
        ------
1286
        ItemNotFoundError
1287
            the 'output_level full' keyword was not found in the calculation
1288
        ValueError
1289
            could not determine if the calculation was spin polarised
1290
        """
1291
        # Check if the calculation was spin polarised
1292
        spin_polarised = self.check_spin_polarised()
1✔
1293

1294
        # Check if output_level full was specified in the calculation
1295
        required_item = ("output_level", "full")
1✔
1296
        if required_item not in self.get_parameters().items():
1✔
1297
            raise ItemNotFoundError(required_item)
1✔
1298

1299
        # Get the number of KS states and scf iterations
1300
        # Add 2 to SCF iters as if output_level full is specified, FHI-aims prints the
1301
        # KS states once before the SCF starts and once after it finishes
1302
        n_scf_iters = self.get_n_scf_iters() + 2
1✔
1303
        n_ks_states = self.get_n_initial_ks_states(include_spin_polarised=False)
1✔
1304

1305
        # Parse line to find the start of the KS eigenvalues
1306
        target_line = "State    Occupation    Eigenvalue [Ha]    Eigenvalue [eV]"
1✔
1307

1308
        if not spin_polarised:
1✔
1309
            eigenvalues = {
1✔
1310
                "state": np.zeros((n_scf_iters, n_ks_states), dtype=int),
1311
                "occupation": np.zeros((n_scf_iters, n_ks_states), dtype=float),
1312
                "eigenvalue_eV": np.zeros((n_scf_iters, n_ks_states), dtype=float),
1313
            }
1314

1315
            n = 0  # Count the current SCF iteration
1✔
1316
            for i, line in enumerate(self.lines):
1✔
1317
                if target_line in line:
1✔
1318
                    # Get the KS states from this line until the next empty line
1319
                    self._get_ks_states(i + 1, eigenvalues, n, n_ks_states)
1✔
1320
                    n += 1
1✔
1321

1322
            return eigenvalues
1✔
1323

1324
        if spin_polarised:
1✔
1325
            su_eigenvalues = {
1✔
1326
                "state": np.zeros((n_scf_iters, n_ks_states), dtype=int),
1327
                "occupation": np.zeros((n_scf_iters, n_ks_states), dtype=float),
1328
                "eigenvalue_eV": np.zeros((n_scf_iters, n_ks_states), dtype=float),
1329
            }
1330
            sd_eigenvalues = {
1✔
1331
                "state": np.zeros((n_scf_iters, n_ks_states), dtype=int),
1332
                "occupation": np.zeros((n_scf_iters, n_ks_states), dtype=float),
1333
                "eigenvalue_eV": np.zeros((n_scf_iters, n_ks_states), dtype=float),
1334
            }
1335

1336
            # Count the number of SCF iterations for each spin channel
1337
            up_n = 0
1✔
1338
            down_n = 0
1✔
1339
            for i, line in enumerate(self.lines):
1✔
1340
                # Printing of KS states is weird in aims.out. Ensure that we don't add
1341
                # more KS states than the array is long
1342
                if up_n == n_scf_iters and down_n == n_scf_iters:
1✔
1343
                    break
1✔
1344

1345
                if target_line in line:
1✔
1346
                    # The spin-up line is two lines above the target line
1347
                    if self.lines[i - 2].strip() == "Spin-up eigenvalues:":
1✔
1348
                        # Get the KS states from this line until the next empty line
1349
                        self._get_ks_states(i + 1, su_eigenvalues, up_n, n_ks_states)
1✔
1350
                        up_n += 1
1✔
1351

1352
                    # The spin-down line is two lines above the target line
1353
                    if self.lines[i - 2].strip() == "Spin-down eigenvalues:":
1✔
1354
                        # Get the KS states from this line until the next empty line
1355
                        self._get_ks_states(i + 1, sd_eigenvalues, down_n, n_ks_states)
1✔
1356
                        down_n += 1
1✔
1357

1358
            return su_eigenvalues, sd_eigenvalues
1✔
1359

1360
        raise ValueError("Could not determine if calculation was spin polarised.")
×
1361

1362
    def get_final_ks_eigenvalues(self) -> dict | tuple[dict, dict]:
1✔
1363
        """Get the final Kohn-Sham eigenvalues from a calculation.
1364

1365
        Returns
1366
        -------
1367
        Union[dict, Tuple[dict, dict]]
1368
            dict
1369
                the final kohn-sham eigenvalues
1370
            Tuple[dict, dict]
1371
                dict
1372
                    the spin-up kohn-sham eigenvalues
1373
                dict
1374
                    the spin-down kohn-sham eigenvalues
1375

1376
        Raises
1377
        ------
1378
        ValueError
1379
            the calculation was not spin polarised
1380
        ValueError
1381
            the final KS states were not found in aims.out file
1382
        """
1383
        # Check if the calculation was spin polarised
1384
        spin_polarised = self.check_spin_polarised()
1✔
1385

1386
        # Get the number of KS states
1387
        n_ks_states = self.get_n_initial_ks_states(include_spin_polarised=False)
1✔
1388

1389
        # Parse line to find the start of the KS eigenvalues
1390
        target_line = "State    Occupation    Eigenvalue [Ha]    Eigenvalue [eV]"
1✔
1391

1392
        # Iterate backwards from end of aims.out to find the final KS eigenvalues
1393
        final_ev_start = None
1✔
1394
        for i, line in enumerate(reversed(self.lines)):
1✔
1395
            if target_line == line.strip():
1✔
1396
                final_ev_start = -i
1✔
1397
                break
1✔
1398

1399
        if final_ev_start is None:
1✔
1400
            raise ValueError("Final KS states not found in aims.out file.")
×
1401

1402
        if not spin_polarised:
1✔
1403
            eigenvalues = {
1✔
1404
                "state": np.zeros((n_ks_states), dtype=int),
1405
                "occupation": np.zeros((n_ks_states), dtype=float),
1406
                "eigenvalue_eV": np.zeros((n_ks_states), dtype=float),
1407
            }
1408
            # Get the KS states from this line until the next empty line
1409
            self._get_ks_states(final_ev_start, eigenvalues, 0, n_ks_states)
1✔
1410

1411
            return eigenvalues
1✔
1412

1413
        if spin_polarised:
1✔
1414
            su_eigenvalues = {
1✔
1415
                "state": np.zeros((n_ks_states), dtype=int),
1416
                "occupation": np.zeros((n_ks_states), dtype=float),
1417
                "eigenvalue_eV": np.zeros((n_ks_states), dtype=float),
1418
            }
1419
            sd_eigenvalues = copy.deepcopy(su_eigenvalues)
1✔
1420

1421
            # The spin-down states start from here
1422
            self._get_ks_states(final_ev_start, sd_eigenvalues, 0, n_ks_states)
1✔
1423

1424
            # Go back one more target line to get the spin-up states
1425
            for i, line in enumerate(reversed(self.lines[: final_ev_start - 1])):
1✔
1426
                if target_line == line.strip():
1✔
1427
                    final_ev_start += -i - 1
1✔
1428
                    break
1✔
1429

1430
            self._get_ks_states(final_ev_start, su_eigenvalues, 0, n_ks_states)
1✔
1431

1432
            return su_eigenvalues, sd_eigenvalues
1✔
1433

1434
        raise ValueError("Could not determine if calculation was spin polarised.")
×
1435

1436
    def get_pert_soc_ks_eigenvalues(self) -> dict:
1✔
1437
        """
1438
        Get the perturbative SOC Kohn-Sham eigenvalues from a calculation.
1439

1440
        Returns
1441
        -------
1442
        dict
1443
            The perturbative SOC Kohn-Sham eigenvalues
1444

1445
        Raises
1446
        ------
1447
        ValueError
1448
            the final KS states were not found in aims.out file
1449
        """
1450
        # Get the number of KS states
1451
        n_ks_states = self.get_n_initial_ks_states()
1✔
1452

1453
        target_line = (
1✔
1454
            "State    Occupation    Unperturbed Eigenvalue [eV]"
1455
            "    Eigenvalue [eV]    Level Spacing [eV]"
1456
        )
1457

1458
        # Iterate backwards from end of aims.out to find the perturbative SOC
1459
        # eigenvalues
1460
        final_ev_start = None
1✔
1461
        for i, line in enumerate(reversed(self.lines)):
1✔
1462
            if target_line == line.strip():
1✔
1463
                final_ev_start = -i
1✔
1464
                break
1✔
1465

1466
        if final_ev_start is None:
1✔
1467
            raise ValueError("Final KS states not found in aims.out file.")
1✔
1468

1469
        eigenvalues = {
1✔
1470
            "state": np.zeros(n_ks_states, dtype=int),
1471
            "occupation": np.zeros(n_ks_states, dtype=float),
1472
            "unperturbed_eigenvalue_eV": np.zeros(n_ks_states, dtype=float),
1473
            "eigenvalue_eV": np.zeros(n_ks_states, dtype=float),
1474
            "level_spacing_eV": np.zeros(n_ks_states, dtype=float),
1475
        }
1476

1477
        for i, line in enumerate(
1✔
1478
            self.lines[final_ev_start : final_ev_start + n_ks_states]
1479
        ):
1480
            spl = line.split()
1✔
1481

1482
            eigenvalues["state"][i] = int(spl[0])
1✔
1483
            eigenvalues["occupation"][i] = float(spl[1])
1✔
1484
            eigenvalues["unperturbed_eigenvalue_eV"][i] = float(spl[2])
1✔
1485
            eigenvalues["eigenvalue_eV"][i] = float(spl[3])
1✔
1486
            eigenvalues["level_spacing_eV"][i] = float(spl[4])
1✔
1487

1488
        return eigenvalues
1✔
1489

1490

1491
class ELSIOutput(Output):
1✔
1492
    """
1493
    Parse matrix output written in a binary csc format from ELSI.
1494

1495
    ...
1496

1497
    Attributes
1498
    ----------
1499
    data : bytes
1500
        The binary data from the ELSI csc file
1501
    path : str
1502
        Path to ELSI csc file.
1503
    n_basis : int
1504
        Number of basis functions
1505
    n_non_zero : int
1506
        Number of non-zero elements in the matrix
1507
    """
1508

1509
    def __init__(self, elsi_csc: str = "elsi.csc"):
1✔
1510
        super().__init__(elsi_csc=elsi_csc)
1✔
1511

1512
    def get_elsi_csc_header(self) -> npt.NDArray[np.int64]:
1✔
1513
        """
1514
        Get the contents of the ELSI file header.
1515

1516
        Returns
1517
        -------
1518
        tuple
1519
            The contents of the ELSI csc file header
1520
        """
1521
        return np.frombuffer(self.data[0:128], dtype=np.int64)
1✔
1522

1523
    @property
1✔
1524
    def n_basis(self) -> int:
1✔
1525
        return self.get_elsi_csc_header()[3]
1✔
1526

1527
    @property
1✔
1528
    def n_non_zero(self) -> int:
1✔
1529
        return self.get_elsi_csc_header()[5]
1✔
1530

1531
    def read_elsi_as_csc(
1✔
1532
        self, csc_format: bool = False
1533
    ) -> sp.csc_matrix | npt.NDArray[np.float64]:
1534
        """
1535
        Get a CSC matrix from an ELSI output file.
1536

1537
        Parameters
1538
        ----------
1539
        csc_format : bool, default=True
1540
            Whether to return the matrix in CSC format or a standard numpy array
1541

1542
        Returns
1543
        -------
1544
        Tuple[sp.csc_matrix, np.ndarray]
1545
            The CSC matrix or numpy array
1546
        """
1547
        header = self.get_elsi_csc_header()
1✔
1548

1549
        # Get the column pointer
1550
        end = 128 + self.n_basis * 8
1✔
1551
        col_i = np.frombuffer(self.data[128:end], dtype=np.int64)
1✔
1552
        col_i = np.append(col_i, self.n_non_zero + 1)
1✔
1553
        col_i -= 1
1✔
1554

1555
        # Get the row index
1556
        start = end + self.n_non_zero * 4
1✔
1557
        row_i = np.array(np.frombuffer(self.data[end:start], dtype=np.int32))
1✔
1558
        row_i -= 1
1✔
1559

1560
        if header[2] == 0:  # real
1✔
1561
            nnz = np.frombuffer(
1✔
1562
                self.data[start : start + self.n_non_zero * 8],
1563
                dtype=np.float64,
1564
            )
1565

1566
        else:  # complex
1567
            nnz = np.frombuffer(
×
1568
                self.data[start : start + self.n_non_zero * 16],
1569
                dtype=np.complex128,
1570
            )
1571

1572
        if csc_format:
1✔
1573
            return sp.csc_array((nnz, row_i, col_i), shape=(self.n_basis, self.n_basis))
1✔
1574

1575
        return sp.csc_array(
1✔
1576
            (nnz, row_i, col_i), shape=(self.n_basis, self.n_basis)
1577
        ).toarray()
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc