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

maurergroup / dfttoolkit / 13888876829

17 Mar 2025 12:20AM UTC coverage: 28.951% (+7.2%) from 21.747%
13888876829

Pull #61

github

98a614
web-flow
Merge deb33a115 into e64b4934e
Pull Request #61: Bump spglib from 2.5.0 to 2.6.0

916 of 3164 relevant lines covered (28.95%)

0.29 hits per line

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

72.75
dfttoolkit/output.py
1
import warnings
1✔
2
from typing import List, Tuple, Union
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):
1✔
31
        # Parse file information and perform checks
32
        super().__init__(self._supported_files.keys(), **kwargs)
1✔
33

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

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

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

51
    def __init_subclass__(cls, **kwargs):
1✔
52
        # Revert back to the original __init_subclass__ method to avoid checking for
53
        # required methods in child class of this class too
54
        return super(Parser, cls).__init_subclass__(**kwargs)
1✔
55

56

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

61
    ...
62

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

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

75
    def __init__(self, **kwargs):
1✔
76
        super().__init__(**kwargs)
1✔
77

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

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

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

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

96
        return n_atoms
1✔
97

98
    def get_geometry(self) -> AimsGeometry:
1✔
99
        """
100
        Extract the geometry file from the aims output and return it as a
101
        Geometry object
102

103
        Returns
104
        -------
105
        AimsGeometry
106
            Geometry object
107
        """
108

109
        geometry_lines = []
1✔
110
        read_trigger = False
1✔
111
        for l in self.lines:
1✔
112
            if (
1✔
113
                "Parsing geometry.in (first pass over file, find array dimensions only)."
114
                in l
115
            ):
116
                read_trigger = True
1✔
117

118
            if read_trigger:
1✔
119
                geometry_lines.append(l)
1✔
120

121
            if "Completed first pass over input file geometry.in ." in l:
1✔
122
                break
1✔
123

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

126
        geometry = AimsGeometry()
1✔
127
        geometry.parse(geometry_text)
1✔
128

129
        return geometry
1✔
130

131
    def get_geometry_steps_of_optimisation(self, n_occurrence=None) -> list:
1✔
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
×
151

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

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

167
            if state > 0 and "atom " in l:
×
168
                state = 3
×
169
            if state == 1 and "lattice_vector  " in l:
×
170
                state = 2
×
171

172
            if state > 0:
×
173
                geometry_lines.append(l)
×
174

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

182
        if n_occurrence is not None:
×
183
            geometry_files = geometry_files[n_occurrence]
×
184

185
        return geometry_files
×
186

187
    def get_control_file(self) -> List[str]:
1✔
188
        """
189
        Extract the control file from the aims output
190

191
        Returns
192
        -------
193
        List[str]
194
            Lines from the control file found in the aims output
195
        """
196

197
        control_lines = []
1✔
198
        control_file_reached = False
1✔
199
        for l in self.lines:
1✔
200
            if (
1✔
201
                "Parsing control.in (first pass over file, find array dimensions only)."
202
                in l
203
            ):
204
                control_file_reached = True
1✔
205

206
            if control_file_reached:
1✔
207
                control_lines.append(l.strip())
1✔
208

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

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

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

218
        Returns
219
        -------
220
        dict
221
            The parameters of the FHI-aims control file found in the aims output
222
        """
223

224
        # Find where the parameters start
225
        for i, line in enumerate(self.lines):
1✔
226
            if (
1✔
227
                "Parsing control.in (first pass over file, find array dimensions only)."
228
                in line
229
            ):
230
                break
1✔
231

232
        parameters = {}
1✔
233

234
        for line in self.lines[i + 6 :]:  # pyright: ignore
1✔
235
            # End of parameters and start of basis sets
236
            if "#" * 80 in line:
1✔
237
                break
1✔
238

239
            spl = line.split()
1✔
240
            parameters[spl[0]] = " ".join(spl[1:])
1✔
241

242
        return parameters
1✔
243

244
    def get_basis_sets(self): ...
1✔
245

246
    def check_exit_normal(self) -> bool:
1✔
247
        """
248
        Check if the FHI-aims calculation exited normally.
249

250
        Returns
251
        -------
252
        bool
253
            whether the calculation exited normally or not
254
        """
255

256
        if "Have a nice day." == self.lines[-2].strip():
1✔
257
            exit_normal = True
1✔
258
        else:
259
            exit_normal = False
1✔
260

261
        return exit_normal
1✔
262

263
    def get_time_per_scf(self) -> npt.NDArray[np.float64]:
1✔
264
        """
265
        Calculate the average time taken per SCF iteration.
266

267
        Returns
268
        -------
269
        npt.NDArray[np.float64]
270
            The average time taken per SCF iteration.
271
        """
272

273
        # Get the number of SCF iterations
274
        n_scf_iters = self.get_n_scf_iters()
1✔
275
        scf_iter_times = np.zeros(n_scf_iters)
1✔
276

277
        # Get the time taken for each SCF iteration
278
        iter_num = 0
1✔
279
        for line in self.lines:
1✔
280
            if "Time for this iteration" in line:
1✔
281
                scf_iter_times[iter_num] = float(line.split()[-4])
1✔
282
                iter_num += 1
1✔
283

284
        return scf_iter_times
1✔
285

286
    ###############################################################################
287
    #                                   Energies                                  #
288
    ###############################################################################
289
    def _get_energy(
1✔
290
        self,
291
        n_occurrence: Union[int, None],
292
        search_string: str,
293
        token_nr: Union[int, None] = None,
294
        energy_invalid_indicator: Union[list, int, str, None] = None,
295
        energy_valid_indicator: Union[list, int, str, None] = None,
296
    ) -> Union[float, npt.NDArray[np.float64]]:
297
        """
298
        Generalized energy parser
299

300
        Parameters
301
        ----------
302
        n_occurrence : Union[int, None]
303
            If there are multiple energies in a file (e.g. during a geometry optimization)
304
            this parameters allows to select which energy is returned.
305
            If set to -1 the last one is returned (e.g. result of a geometry optimization),
306
            if set to None, all values will be returned as a numpy array.
307
        search_string : str
308
            string to be searched in the output file
309
        token_nr : Union[int, None]
310
            take n-th element of found line
311
        energy_invalid_indicator : Union[list, int, str, None] = None
312
            In some cases an energy value can be found in the output file although it is invalid -> ignore this value
313
            example: a line having 'restarting mixer to attempt better convergence'
314
                        indicates that this scf-cycle leads to invalid energies
315
        param energy_valid_indicator : Union[list, int, str, None] = None
316
            In some cases the value is only valid after a certain phrase is used -> ignore all values before
317
            example: The post-SCF vdW energy correction is 0.00 until the SCF is converged.
318

319
        Returns
320
        -------
321
        energies : Union[float, npt.NDArray[np.float64]]
322
            Energies that have been grepped
323

324
        """
325
        skip_next_energy = (
1✔
326
            False  # only relevant if energy_invalid_indicator is not None
327
        )
328
        use_next_energy = False  # only relevant if energy_valid_indicator is not None
1✔
329

330
        assert not (skip_next_energy and use_next_energy), (
1✔
331
            "AIMSOutput._get_energy: usage of skip_next_energy and "
332
            "use_next_energy at the same function call is undefined!"
333
        )
334
        # energy (in)valid indicator allows now for multiple values, if a list is
335
        # provided. Otherwise, everything works out as before.
336
        if energy_valid_indicator is not None and not isinstance(
1✔
337
            energy_valid_indicator, list
338
        ):
339
            energy_valid_indicator = [energy_valid_indicator]
×
340

341
        if energy_invalid_indicator is not None and not isinstance(
1✔
342
            energy_invalid_indicator, list
343
        ):
344
            energy_invalid_indicator = [energy_invalid_indicator]
×
345

346
        energies = []
1✔
347

348
        for line_text in self.lines:
1✔
349
            # check for energy_invalid_indicator:
350
            if energy_invalid_indicator is not None:
1✔
351
                for ind in energy_invalid_indicator:
×
352
                    if ind in line_text:
×
353
                        skip_next_energy = True
×
354

355
            if energy_valid_indicator is not None:
1✔
356
                for ind in energy_valid_indicator:
×
357
                    if ind in line_text:
×
358
                        use_next_energy = True
×
359
            else:
360
                use_next_energy = True
1✔
361

362
            if search_string in line_text:
1✔
363
                if skip_next_energy is True:
1✔
364
                    skip_next_energy = False  # reset this 'counter'
×
365
                elif use_next_energy:
1✔
366
                    if token_nr is None:
1✔
367
                        token_nr = len(search_string.split()) + 3
×
368
                    energies.append(float(line_text.strip().split()[token_nr]))
1✔
369
                    use_next_energy = False
1✔
370
                else:
371
                    pass
×
372

373
        if len(energies) == 0:
1✔
374
            raise ValueError(f"Energy not found in aims.out file for {search_string}")
1✔
375

376
        energies = np.array(energies)
1✔
377

378
        if n_occurrence is None:
1✔
379
            return energies
1✔
380
        else:
381
            return energies[n_occurrence]
1✔
382

383
    def get_change_of_total_energy(
1✔
384
        self,
385
        n_occurrence: Union[int, None] = -1,
386
        energy_invalid_indicator=None,
387
    ) -> Union[float, npt.NDArray[np.float64]]:
388
        return self._get_energy(
1✔
389
            n_occurrence,
390
            "Change of total energy",
391
            token_nr=6,
392
            energy_invalid_indicator=energy_invalid_indicator,
393
        )
394

395
    def get_change_of_forces(
1✔
396
        self, n_occurrence=-1, energy_invalid_indicator=None
397
    ) -> Union[float, npt.NDArray[np.float64]]:
398
        return self._get_energy(
1✔
399
            n_occurrence,
400
            "Change of forces",
401
            token_nr=5,
402
            energy_invalid_indicator=energy_invalid_indicator,
403
        )
404

405
    def get_change_of_sum_of_eigenvalues(
1✔
406
        self, n_occurrence=-1, energy_invalid_indicator=None
407
    ) -> Union[float, npt.NDArray[np.float64]]:
408
        return self._get_energy(
×
409
            n_occurrence,
410
            "Change of sum of eigenvalues",
411
            token_nr=7,
412
            energy_invalid_indicator=energy_invalid_indicator,
413
        )
414

415
    def get_maximum_force(
1✔
416
        self, n_occurrence=-1, energy_invalid_indicator=None
417
    ) -> Union[float, npt.NDArray[np.float64]]:
418
        return self._get_energy(
×
419
            n_occurrence,
420
            "Maximum force component",
421
            token_nr=4,
422
            energy_invalid_indicator=energy_invalid_indicator,
423
        )
424

425
    def get_energy_corrected(
1✔
426
        self,
427
        n_occurrence: Union[int, None] = -1,
428
        skip_E_after_mixer: bool = True,
429
        all_scfs: bool = False,
430
        energy_invalid_indicator: list[str] = [],
431
    ) -> Union[float, npt.NDArray[np.float64]]:
432
        """
433
        Return the total corrected energy.
434

435
        Parameters
436
        ----------
437
        n_occurrence : Union[int, None]
438
            If there are multiple energies in a file (e.g. during a geometry optimization)
439
            this parameters allows to select which energy is returned.
440
            If set to -1 the last one is returned (e.g. result of a geometry optimization),
441
            if set to None, all values will be returned as a numpy array.
442

443
        skip_E_after_mixer : bool, default=True
444
            If the scf cycles of one geometry optimisation step didn't converge,
445
            aims will restart the mixer and this optimisation step.
446
            However, it still prints out the total energy, which can be totally nonsense.
447
            if skip_E_after_mixer==True ignore first total energy after 'restarting
448
            mixer to attempt better convergence'
449

450
        Examples
451
        ---------
452
        >>> AimsOutput.get_energy_corrected()
453
        -2080.83225450528
454
        """
455

456
        if skip_E_after_mixer:
×
457
            energy_invalid_indicator += [
×
458
                "restarting mixer to attempt better convergence"
459
            ]
460

461
        if all_scfs:
×
462
            return self.get_total_energy_T0(n_occurrence, skip_E_after_mixer)
×
463
        else:
464
            return self._get_energy(
×
465
                n_occurrence,
466
                search_string="| Total energy corrected",
467
                energy_invalid_indicator=energy_invalid_indicator,
468
                token_nr=5,
469
            )
470

471
    def get_total_energy_T0(
1✔
472
        self,
473
        n_occurrence: Union[None, int] = -1,
474
        skip_E_after_mixer: bool = True,
475
        energy_invalid_indicator: list[str] = [],
476
    ) -> Union[float, npt.NDArray[np.float64]]:
477
        if skip_E_after_mixer:
×
478
            energy_invalid_indicator += [
×
479
                "restarting mixer to attempt better convergence"
480
            ]
481

482
        return self._get_energy(
×
483
            n_occurrence,
484
            search_string="| Total energy, T -> 0",
485
            energy_invalid_indicator=energy_invalid_indicator,
486
            token_nr=9,
487
        )
488

489
    def get_energy_uncorrected(
1✔
490
        self,
491
        n_occurrence: Union[None, int] = -1,
492
        skip_E_after_mixer: bool = True,
493
        energy_invalid_indicator: list[str] = [],
494
    ) -> Union[float, npt.NDArray[np.float64]]:
495
        """
496
        Return uncorrected (without smearing correction) energy
497

498
        Parameters
499
        ----------
500
        n_occurrence : Union[int, None]
501
            see getEnergyCorrected()
502

503
        skip_E_after_mixer : bool
504
            If the scf cycles of one geometry optimisation step didn't converge,
505
            aims will restart the mixer and this optimisation step.
506
            However, it still prints out the total energy, which can be totally nonsense.
507
            if skip_E_after_mixer==True: ignore first total energy after 'restarting
508
            mixer to attempt better convergence'
509

510
        Returns
511
        -------
512
        Union[float, npt.NDArray[np.float64]]
513
            Uncorrected energy
514
        """
515

516
        if skip_E_after_mixer:
×
517
            energy_invalid_indicator += [
×
518
                "restarting mixer to attempt better convergence"
519
            ]
520

521
        return self._get_energy(
×
522
            n_occurrence,
523
            search_string="| Total energy uncorrected",
524
            energy_invalid_indicator=energy_invalid_indicator,
525
            token_nr=5,
526
        )
527

528
    def get_energy_without_vdw(
1✔
529
        self,
530
        n_occurrence: Union[None, int] = -1,
531
        energy_invalid_indicator: list[str] = [],
532
    ) -> Union[float, npt.NDArray[np.float64]]:
533
        energy = self.get_energy_corrected(
×
534
            n_occurrence=n_occurrence,
535
            energy_invalid_indicator=energy_invalid_indicator,
536
        )
537

538
        energy_vdw = self.get_vdw_energy(
×
539
            n_occurrence=n_occurrence,
540
            energy_invalid_indicator=energy_invalid_indicator,
541
        )
542

543
        energy_without_vdw = energy - energy_vdw
×
544

545
        return energy_without_vdw
×
546

547
    def get_HOMO_energy(
1✔
548
        self,
549
        n_occurrence: Union[None, int] = -1,
550
        energy_invalid_indicator: list[str] = [],
551
    ) -> Union[float, npt.NDArray[np.float64]]:
552
        return self._get_energy(
×
553
            n_occurrence,
554
            "Highest occupied state",
555
            token_nr=5,
556
            energy_invalid_indicator=energy_invalid_indicator,
557
        )
558

559
    def get_LUMO_energy(
1✔
560
        self,
561
        n_occurrence: Union[None, int] = -1,
562
        energy_invalid_indicator: list[str] = [],
563
    ) -> Union[float, npt.NDArray[np.float64]]:
564
        return self._get_energy(
×
565
            n_occurrence,
566
            "Lowest unoccupied state",
567
            token_nr=5,
568
            energy_invalid_indicator=energy_invalid_indicator,
569
        )
570

571
    def get_vdw_energy(
1✔
572
        self,
573
        n_occurrence: Union[None, int] = -1,
574
        energy_invalid_indicator: list[str] = [],
575
    ) -> Union[float, npt.NDArray[np.float64]]:
576

577
        search_keyword = "| vdW energy correction"
×
578
        token_nr = None
×
579

580
        result = self._get_energy(
×
581
            n_occurrence,
582
            search_keyword,
583
            token_nr=token_nr,
584
            energy_invalid_indicator=energy_invalid_indicator,
585
            energy_valid_indicator="Self-consistency cycle converged",
586
        )
587
        return result
×
588

589
    def get_exchange_correlation_energy(
1✔
590
        self,
591
        n_occurrence: Union[None, int] = -1,
592
        energy_invalid_indicator: list[str] = [],
593
    ) -> Union[float, npt.NDArray[np.float64]]:
594
        return self._get_energy(
×
595
            n_occurrence,
596
            "| XC energy correction",
597
            energy_invalid_indicator=energy_invalid_indicator,
598
        )
599

600
    def get_electrostatic_energy(
1✔
601
        self,
602
        n_occurrence: Union[None, int] = -1,
603
        energy_invalid_indicator: list[str] = [],
604
    ) -> Union[float, npt.NDArray[np.float64]]:
605
        return self._get_energy(
×
606
            n_occurrence,
607
            "| Electrostatic energy ",
608
            energy_invalid_indicator=energy_invalid_indicator,
609
        )
610

611
    def get_kinetic_energy(
1✔
612
        self,
613
        n_occurrence: Union[None, int] = -1,
614
        energy_invalid_indicator: list[str] = [],
615
    ) -> Union[float, npt.NDArray[np.float64]]:
616
        return self._get_energy(
×
617
            n_occurrence,
618
            "| Kinetic energy ",
619
            energy_invalid_indicator=energy_invalid_indicator,
620
        )
621

622
    def get_sum_of_eigenvalues(
1✔
623
        self,
624
        n_occurrence: Union[None, int] = -1,
625
        energy_invalid_indicator: list[str] = [],
626
    ) -> Union[float, npt.NDArray[np.float64]]:
627
        return self._get_energy(
×
628
            n_occurrence,
629
            "| Sum of eigenvalues  ",
630
            energy_invalid_indicator=energy_invalid_indicator,
631
        )
632

633
    def get_cx_potential_correction(
1✔
634
        self,
635
        n_occurrence: Union[None, int] = -1,
636
        energy_invalid_indicator: list[str] = [],
637
    ) -> Union[float, npt.NDArray[np.float64]]:
638
        return self._get_energy(
×
639
            n_occurrence,
640
            "| XC potential correction",
641
            energy_invalid_indicator=energy_invalid_indicator,
642
        )
643

644
    def get_free_atom_electrostatic_energy(
1✔
645
        self,
646
        n_occurrence: Union[None, int] = -1,
647
        energy_invalid_indicator: list[str] = [],
648
    ) -> Union[float, npt.NDArray[np.float64]]:
649
        return self._get_energy(
×
650
            n_occurrence,
651
            "| Free-atom electrostatic energy:",
652
            token_nr=6,
653
            energy_invalid_indicator=energy_invalid_indicator,
654
        )
655

656
    def get_entropy_correction(
1✔
657
        self,
658
        n_occurrence: Union[None, int] = -1,
659
        energy_invalid_indicator: list[str] = [],
660
    ) -> Union[float, npt.NDArray[np.float64]]:
661
        return self._get_energy(
×
662
            n_occurrence,
663
            "| Entropy correction ",
664
            energy_invalid_indicator=energy_invalid_indicator,
665
        )
666

667
    def get_hartree_energy_correction(
1✔
668
        self,
669
        n_occurrence: Union[None, int] = -1,
670
        energy_invalid_indicator: list[str] = [],
671
    ) -> Union[float, npt.NDArray[np.float64]]:
672
        return self._get_energy(
×
673
            n_occurrence,
674
            "| Hartree energy correction",
675
            energy_invalid_indicator=energy_invalid_indicator,
676
        )
677

678
    def get_ionic_embedding_energy(
1✔
679
        self,
680
        n_occurrence: Union[None, int] = -1,
681
        energy_invalid_indicator: list[str] = [],
682
    ) -> Union[float, npt.NDArray[np.float64]]:
683
        """The energy of the nuclei in the potential of the external electric
684
        field."""
685
        return self._get_energy(
×
686
            n_occurrence,
687
            "| Ionic    embedding energy",
688
            energy_invalid_indicator=energy_invalid_indicator,
689
        )
690

691
    def get_density_embedding_energy(
1✔
692
        self,
693
        n_occurrence: Union[None, int] = -1,
694
        energy_invalid_indicator: list[str] = [],
695
    ) -> Union[float, npt.NDArray[np.float64]]:
696
        """The energy of the electrons (electron density) in the potential of
697
        the external electric field."""
698
        return self._get_energy(
×
699
            n_occurrence,
700
            "| Density  embedding energy",
701
            energy_invalid_indicator=energy_invalid_indicator,
702
        )
703

704
    def get_nonlocal_embedding_energy(
1✔
705
        self,
706
        n_occurrence: Union[None, int] = -1,
707
        energy_invalid_indicator: list[str] = [],
708
    ) -> Union[float, npt.NDArray[np.float64]]:
709
        """
710
        Energy of non local electron interaction (i think?) in the potential
711
        of the electric field.
712

713
        """
714
        return self._get_energy(
×
715
            n_occurrence,
716
            "| Nonlocal embedding energy",
717
            energy_invalid_indicator=energy_invalid_indicator,
718
        )
719

720
    def get_external_embedding_energy(
1✔
721
        self,
722
        n_occurrence: Union[None, int] = -1,
723
        energy_invalid_indicator: list[str] = [],
724
    ) -> Union[float, npt.NDArray[np.float64]]:
725
        """
726
        This is the sum of all the embedding energies.
727
        I.e. ionic + (electronic) density + nonlocal.
728

729
        """
730
        return self._get_energy(
×
731
            n_occurrence,
732
            "| External embedding energy",
733
            energy_invalid_indicator=energy_invalid_indicator,
734
        )
735

736
    def get_forces(
1✔
737
        self, n_occurrence: Union[None, int] = -1
738
    ) -> npt.NDArray[np.float64]:
739
        """
740
        Return forces on all atoms
741

742
        """
743
        natoms = self.get_number_of_atoms()
×
744
        all_force_values = []
×
745

746
        for j, l in enumerate(self.lines):
×
747
            if "Total atomic forces" in l:
×
748
                force_values = np.ones([natoms, 3]) * np.nan
×
749
                for i in range(natoms):
×
750
                    force_values[i, :] = [
×
751
                        float(x) for x in self.lines[j + i + 1].split()[2:5]
752
                    ]
753
                all_force_values.append(np.array(force_values))
×
754

755
        if len(all_force_values) == 0:
×
756
            raise ValueError(f"Forces not found in {self.path} file")
×
757

758
        if n_occurrence is None:
×
759
            return np.array(all_force_values)
×
760
        else:
761
            return all_force_values[n_occurrence]
×
762

763
    def check_spin_polarised(self) -> bool:
1✔
764
        """
765
        Check if the FHI-aims calculation was spin polarised.
766

767
        Returns
768
        -------
769
        bool
770
            Whether the calculation was spin polarised or not
771
        """
772

773
        spin_polarised = False
1✔
774

775
        for line in self.lines:
1✔
776
            spl = line.split()
1✔
777
            if len(spl) == 2:
1✔
778
                # Don't break the loop if spin polarised calculation is found as if the
779
                # keyword is specified again, it is the last one that is used
780
                if spl[0] == "spin" and spl[1] == "collinear":
1✔
781
                    spin_polarised = True
1✔
782

783
                if spl[0] == "spin" and spl[1] == "none":
1✔
784
                    spin_polarised = False
×
785

786
        return spin_polarised
1✔
787

788
    def get_convergence_parameters(self) -> dict:
1✔
789
        """
790
        Get the convergence parameters from the aims.out file.
791

792
        Returns
793
        -------
794
        dict
795
            The convergence parameters from the aims.out file
796
        """
797

798
        # Setup dictionary to store convergence parameters
799
        self.convergence_params = {
1✔
800
            "charge_density": 0.0,
801
            "sum_eigenvalues": 0.0,
802
            "total_energy": 0.0,
803
            "total_force": 0.0,
804
        }
805

806
        for line in self.lines:
1✔
807
            spl = line.split()
1✔
808
            if len(spl) > 1:
1✔
809
                if "accuracy" == spl[1] and "charge density" in line:
1✔
810
                    self.convergence_params["charge_density"] = float(spl[-1])
1✔
811
                if "accuracy" == spl[1] and "sum of eigenvalues" in line:
1✔
812
                    self.convergence_params["sum_eigenvalues"] = float(spl[-1])
1✔
813
                if "accuracy" == spl[1] and "total energy" in line:
1✔
814
                    self.convergence_params["total_energy"] = float(spl[-1])
1✔
815
                if "accuracy" == spl[1] and "forces:" == spl[3]:
1✔
816
                    self.convergence_params["total_force"] = float(spl[-1])
1✔
817

818
                # No more values to get after SCF starts
819
                if "Begin self-consistency loop" in line:
1✔
820
                    break
1✔
821

822
        return self.convergence_params
1✔
823

824
    def get_final_energy(self) -> Union[float, None]:
1✔
825
        """
826
        Get the final energy from a FHI-aims calculation.
827

828
        Returns
829
        -------
830
        Union[float, None]
831
            The final energy of the calculation
832
        """
833

834
        for line in self.lines:
1✔
835
            if "s.c.f. calculation      :" in line:
1✔
836
                return float(line.split()[-2])
1✔
837

838
    def get_final_spin_moment(self) -> Union[tuple, None]:
1✔
839
        """
840
        Get the final spin moment from a FHI-aims calculation.
841

842
        Returns
843
        -------
844
        Union[tuple, None]
845
            The final spin moment of the calculation, if it exists
846
        """
847

848
        N, S, J = None, None, None
1✔
849

850
        # Iterate through the lines in reverse order to find the final spin moment
851
        for i, line in enumerate(reversed(self.lines)):
1✔
852
            if "Current spin moment of the entire structure :" in line:
1✔
853
                if len(self.lines[-1 - i + 3].split()) > 0:
1✔
854
                    N = float(self.lines[-1 - i + 1].split()[-1])
1✔
855
                    S = float(self.lines[-1 - i + 2].split()[-1])
1✔
856
                    J = float(self.lines[-1 - i + 3].split()[-1])
1✔
857

858
                    return N, S, J
1✔
859

860
                else:  # Non-gamma point periodic calculation
861
                    N = float(self.lines[-1 - i + 1].split()[-1])
1✔
862
                    S = float(self.lines[-1 - i + 2].split()[-1])
1✔
863

864
                    return N, S
1✔
865

866
        if N is None or S is None:
1✔
867
            return None
1✔
868

869
    def get_n_relaxation_steps(self) -> int:
1✔
870
        """
871
        Get the number of relaxation steps from the aims.out file.
872

873
        Returns
874
        -------
875
        int
876
            the number of relaxation steps
877
        """
878

879
        n_relax_steps = 0
×
880
        for line in reversed(self.lines):
×
881
            if "Number of relaxation steps" in line:
×
882
                return int(line.split()[-1])
×
883

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

890
        return n_relax_steps
×
891

892
    def get_n_scf_iters(self) -> int:
1✔
893
        """
894
        Get the number of SCF iterations from the aims.out file.
895

896
        Returns
897
        -------
898
        int
899
            The number of scf iterations
900
        """
901

902
        n_scf_iters = 0
1✔
903
        for line in reversed(self.lines):
1✔
904
            if "Number of self-consistency cycles" in line:
1✔
905
                return int(line.split()[-1])
1✔
906

907
            # If the calculation did not finish normally, the number of SCF iterations
908
            # will not be printed. In this case, count each SCF iteration as they were
909
            # calculated
910
            if "Begin self-consistency iteration #" in line:
1✔
911
                n_scf_iters += 1
1✔
912

913
        return n_scf_iters
1✔
914

915
    def get_i_scf_conv_acc(self) -> dict:
1✔
916
        """
917
        Get SCF convergence accuracy values from the aims.out file.
918

919
        Returns
920
        -------
921
        dict
922
            The scf convergence accuracy values from the aims.out file
923
        """
924

925
        # Read the total number of SCF iterations
926
        n_scf_iters = self.get_n_scf_iters()
×
927
        n_relax_steps = self.get_n_relaxation_steps() + 1
×
928

929
        # Check that the calculation finished normally otherwise number of SCF
930
        # iterations is not known
931
        self.scf_conv_acc_params = {
×
932
            "scf_iter": np.zeros(n_scf_iters),
933
            "change_of_charge": np.zeros(n_scf_iters),
934
            "change_of_charge_spin_density": np.zeros(n_scf_iters),
935
            "change_of_sum_eigenvalues": np.zeros(n_scf_iters),
936
            "change_of_total_energy": np.zeros(n_scf_iters),
937
            # "change_of_forces": np.zeros(n_relax_steps),
938
            "forces_on_atoms": np.zeros(n_relax_steps),
939
        }
940

941
        current_scf_iter = 0
×
942
        current_relax_step = 0
×
943
        # new_scf_iter = True
944

945
        for line in self.lines:
×
946
            spl = line.split()
×
947
            if len(spl) > 1:
×
948
                if "Begin self-consistency iteration #" in line:
×
949
                    # save the scf iteration number
950
                    self.scf_conv_acc_params["scf_iter"][current_scf_iter] = int(
×
951
                        spl[-1]
952
                    )
953
                    # use a counter rather than reading the SCF iteration number as it
954
                    # resets upon re-initialisation and for each geometry opt step
955
                    current_scf_iter += 1
×
956

957
                # Use spin density if spin polarised calculation
958
                if "Change of charge/spin density" in line:
×
959

960
                    self.scf_conv_acc_params["change_of_charge"][
×
961
                        current_scf_iter - 1
962
                    ] = float(spl[-2])
963
                    self.scf_conv_acc_params["change_of_charge_spin_density"][
×
964
                        current_scf_iter - 1
965
                    ] = float(spl[-1])
966

967
                # Otherwise just use change of charge
968
                elif "Change of charge" in line:
×
969
                    self.scf_conv_acc_params["change_of_charge"][
×
970
                        current_scf_iter - 1
971
                    ] = float(spl[-1])
972

973
                if "Change of sum of eigenvalues" in line:
×
974
                    self.scf_conv_acc_params["change_of_sum_eigenvalues"][
×
975
                        current_scf_iter - 1
976
                    ] = float(spl[-2])
977

978
                if "Change of total energy" in line:
×
979
                    self.scf_conv_acc_params["change_of_total_energy"][
×
980
                        current_scf_iter - 1
981
                    ] = float(spl[-2])
982

983
                # NOTE
984
                # In the current aims compilation I'm using to test this, there is
985
                # something wrong with printing the change of forces. It happens
986
                # multiple times per relaxation and is clearly wrong so I am removing
987
                # this functionality for now
988

989
                # if "Change of forces" in line:
990
                #     # Only save the smallest change of forces for each geometry
991
                #     # relaxation step. I have no idea why it prints multiple times but
992
                #     # I assume it's a data race of some sort
993
                #     if new_scf_iter:
994
                #         self.scf_conv_acc_params["change_of_forces"][
995
                #             current_relax_step - 1
996
                #         ] = float(spl[-2])
997

998
                #         new_scf_iter = False
999

1000
                #     elif (
1001
                #         float(spl[-2])
1002
                #         < self.scf_conv_acc_params["change_of_forces"][-1]
1003
                #     ):
1004
                #         self.scf_conv_acc_params["change_of_forces"][
1005
                #             current_relax_step - 1
1006
                #         ] = float(spl[-2])
1007

1008
                if "Forces on atoms" in line:
×
1009
                    self.scf_conv_acc_params["forces_on_atoms"][
×
1010
                        current_relax_step - 1
1011
                    ] = float(spl[-2])
1012

1013
                if line.strip() == "Self-consistency cycle converged.":
×
1014
                    # new_scf_iter = True
1015
                    current_relax_step += 1
×
1016

1017
        return self.scf_conv_acc_params
×
1018

1019
    def get_n_initial_ks_states(self, include_spin_polarised: bool = True) -> int:
1✔
1020
        """
1021
        Get the number of Kohn-Sham states from the first SCF step.
1022

1023
        Parameters
1024
        ----------
1025
        include_spin_polarised : bool, default=True
1026
            Whether to include the spin-down states in the count if the calculation is
1027
            spin polarised.
1028

1029
        Returns
1030
        -------
1031
        int
1032
            The number of kohn-sham states
1033

1034
        Raises
1035
        ------
1036
        ValueError
1037
            No KS states found in aims.out file
1038
        """
1039

1040
        target_line = "State    Occupation    Eigenvalue [Ha]    Eigenvalue [eV]"
1✔
1041

1042
        init_ev_start = 0
1✔
1043
        n_ks_states = 0
1✔
1044

1045
        # Find the first time the KS states are printed
1046
        for init_ev_start, line in enumerate(self.lines):
1✔
1047
            if target_line == line.strip():
1✔
1048
                init_ev_start += 1
1✔
1049
                break
1✔
1050

1051
        # Then count the number of lines until the next empty line
1052
        for n_ks_states, line in enumerate(self.lines[init_ev_start:]):
1✔
1053
            if len(line) <= 1:
1✔
1054
                break
1✔
1055

1056
        if n_ks_states == 0:
1✔
1057
            raise ValueError("No KS states found in aims.out file.")
×
1058

1059
        # Count the spin-down eigenvalues if the calculation is spin polarised
1060
        if include_spin_polarised:
1✔
1061
            init_ev_end = init_ev_start + n_ks_states
1✔
1062
            if target_line == self.lines[init_ev_end + 3].strip():
1✔
1063
                init_ev_end += 4
1✔
1064
                for line in self.lines[init_ev_end:]:
1✔
1065
                    if len(line) > 1:
1✔
1066
                        n_ks_states += 1
1✔
1067
                    else:
1068
                        break
1✔
1069

1070
            else:  # If SD states are not found 4 lines below end of SU states
1071
                warnings.warn(
1✔
1072
                    "A spin polarised calculation was expected but not found."
1073
                )
1074

1075
        return n_ks_states
1✔
1076

1077
    def _get_ks_states(
1✔
1078
        self, ev_start: int, eigenvalues: dict, scf_iter: int, n_ks_states: int
1079
    ) -> None:
1080
        """
1081
        Get any set of KS states, occupations, and eigenvalues.
1082

1083
        Parameters
1084
        ----------
1085
        ev_start : int
1086
            The line number where the KS states start.
1087
        eigenvalues : dict
1088
            The dictionary to store the KS states, occupations, and eigenvalues.
1089
        scf_iter : int
1090
            The current SCF iteration.
1091
        n_ks_states : int
1092
            The number of KS states to save.
1093

1094
        Raises
1095
        ------
1096
        ValueError
1097
            Something went wrong with parsing the KS states.
1098
        """
1099

1100
        if (
1✔
1101
            eigenvalues["state"].ndim == 1
1102
            or eigenvalues["occupation"].ndim == 1
1103
            or eigenvalues["eigenvalue_eV"].ndim == 1
1104
        ):
1105
            if (
1✔
1106
                eigenvalues["state"].ndim > 1
1107
                or eigenvalues["occupation"].ndim > 1
1108
                or eigenvalues["eigenvalue_eV"].ndim > 1
1109
            ):
1110
                raise ValueError("Something went wrong with parsing the KS states.")
×
1111

1112
            # This is the case for finding the final KS eigenvalues
1113
            # Therefore only parse the KS states from the final SCF iteration
1114
            for i, line in enumerate(self.lines[ev_start : ev_start + n_ks_states]):
1✔
1115
                values = line.split()
1✔
1116
                eigenvalues["state"][i] = int(values[0])
1✔
1117
                eigenvalues["occupation"][i] = float(values[1])
1✔
1118
                eigenvalues["eigenvalue_eV"][i] = float(values[3])
1✔
1119

1120
        else:
1121
            for i, line in enumerate(self.lines[ev_start : ev_start + n_ks_states]):
1✔
1122
                values = line.split()
1✔
1123
                eigenvalues["state"][scf_iter][i] = int(values[0])
1✔
1124
                eigenvalues["occupation"][scf_iter][i] = float(values[1])
1✔
1125
                eigenvalues["eigenvalue_eV"][scf_iter][i] = float(values[3])
1✔
1126

1127
    def get_all_ks_eigenvalues(self) -> Union[dict, Tuple[dict, dict]]:
1✔
1128
        """
1129
        Get all Kohn-Sham eigenvalues from a calculation.
1130

1131
        Returns
1132
        -------
1133
        Union[dict, Tuple[dict, dict]]
1134
            dict
1135
                the kohn-sham eigenvalues
1136
            Tuple[dict, dict]
1137
                dict
1138
                    the spin-up kohn-sham eigenvalues
1139
                dict
1140
                    the spin-down kohn-sham eigenvalues
1141

1142
        Raises
1143
        ------
1144
        ItemNotFoundError
1145
            the 'output_level full' keyword was not found in the calculation
1146
        ValueError
1147
            could not determine if the calculation was spin polarised
1148
        """
1149

1150
        # Check if the calculation was spin polarised
1151
        spin_polarised = self.check_spin_polarised()
1✔
1152

1153
        # Check if output_level full was specified in the calculation
1154
        required_item = ("output_level", "full")
1✔
1155
        if required_item not in self.get_parameters().items():
1✔
1156
            raise ItemNotFoundError(required_item)
1✔
1157

1158
        # Get the number of KS states and scf iterations
1159
        # Add 2 to SCF iters as if output_level full is specified, FHI-aims prints the
1160
        # KS states once before the SCF starts and once after it finishes
1161
        n_scf_iters = self.get_n_scf_iters() + 2
1✔
1162
        n_ks_states = self.get_n_initial_ks_states(include_spin_polarised=False)
1✔
1163

1164
        # Parse line to find the start of the KS eigenvalues
1165
        target_line = "State    Occupation    Eigenvalue [Ha]    Eigenvalue [eV]"
1✔
1166

1167
        if not spin_polarised:
1✔
1168
            eigenvalues = {
1✔
1169
                "state": np.zeros((n_scf_iters, n_ks_states), dtype=int),
1170
                "occupation": np.zeros((n_scf_iters, n_ks_states), dtype=float),
1171
                "eigenvalue_eV": np.zeros((n_scf_iters, n_ks_states), dtype=float),
1172
            }
1173

1174
            n = 0  # Count the current SCF iteration
1✔
1175
            for i, line in enumerate(self.lines):
1✔
1176
                if target_line in line:
1✔
1177
                    # Get the KS states from this line until the next empty line
1178
                    self._get_ks_states(i + 1, eigenvalues, n, n_ks_states)
1✔
1179
                    n += 1
1✔
1180

1181
            return eigenvalues
1✔
1182

1183
        elif spin_polarised:
1✔
1184
            su_eigenvalues = {
1✔
1185
                "state": np.zeros((n_scf_iters, n_ks_states), dtype=int),
1186
                "occupation": np.zeros((n_scf_iters, n_ks_states), dtype=float),
1187
                "eigenvalue_eV": np.zeros((n_scf_iters, n_ks_states), dtype=float),
1188
            }
1189
            sd_eigenvalues = {
1✔
1190
                "state": np.zeros((n_scf_iters, n_ks_states), dtype=int),
1191
                "occupation": np.zeros((n_scf_iters, n_ks_states), dtype=float),
1192
                "eigenvalue_eV": np.zeros((n_scf_iters, n_ks_states), dtype=float),
1193
            }
1194

1195
            # Count the number of SCF iterations for each spin channel
1196
            up_n = 0
1✔
1197
            down_n = 0
1✔
1198
            for i, line in enumerate(self.lines):
1✔
1199

1200
                # Printing of KS states is weird in aims.out. Ensure that we don't add
1201
                # more KS states than the array is long
1202
                if up_n == n_scf_iters and down_n == n_scf_iters:
1✔
1203
                    break
1✔
1204

1205
                if target_line in line:
1✔
1206
                    # The spin-up line is two lines above the target line
1207
                    if self.lines[i - 2].strip() == "Spin-up eigenvalues:":
1✔
1208
                        # Get the KS states from this line until the next empty line
1209
                        self._get_ks_states(i + 1, su_eigenvalues, up_n, n_ks_states)
1✔
1210
                        up_n += 1
1✔
1211

1212
                    # The spin-down line is two lines above the target line
1213
                    if self.lines[i - 2].strip() == "Spin-down eigenvalues:":
1✔
1214
                        # Get the KS states from this line until the next empty line
1215
                        self._get_ks_states(i + 1, sd_eigenvalues, down_n, n_ks_states)
1✔
1216
                        down_n += 1
1✔
1217

1218
            return su_eigenvalues, sd_eigenvalues
1✔
1219

1220
        else:
1221
            raise ValueError("Could not determine if calculation was spin polarised.")
×
1222

1223
    def get_final_ks_eigenvalues(self) -> Union[dict, Tuple[dict, dict]]:
1✔
1224
        """Get the final Kohn-Sham eigenvalues from a calculation.
1225

1226
        Returns
1227
        -------
1228
        Union[dict, Tuple[dict, dict]]
1229
            dict
1230
                the final kohn-sham eigenvalues
1231
            Tuple[dict, dict]
1232
                dict
1233
                    the spin-up kohn-sham eigenvalues
1234
                dict
1235
                    the spin-down kohn-sham eigenvalues
1236

1237
        Raises
1238
        ------
1239
        ValueError
1240
            the calculation was not spin polarised
1241
        ValueError
1242
            the final KS states were not found in aims.out file
1243
        """
1244

1245
        # Check if the calculation was spin polarised
1246
        spin_polarised = self.check_spin_polarised()
1✔
1247

1248
        # Get the number of KS states
1249
        n_ks_states = self.get_n_initial_ks_states(include_spin_polarised=False)
1✔
1250

1251
        # Parse line to find the start of the KS eigenvalues
1252
        target_line = "State    Occupation    Eigenvalue [Ha]    Eigenvalue [eV]"
1✔
1253

1254
        # Iterate backwards from end of aims.out to find the final KS eigenvalues
1255
        final_ev_start = None
1✔
1256
        for i, line in enumerate(reversed(self.lines)):
1✔
1257
            if target_line == line.strip():
1✔
1258
                final_ev_start = -i
1✔
1259
                break
1✔
1260

1261
        if final_ev_start is None:
1✔
1262
            raise ValueError("Final KS states not found in aims.out file.")
×
1263

1264
        if not spin_polarised:
1✔
1265
            eigenvalues = {
1✔
1266
                "state": np.zeros((n_ks_states), dtype=int),
1267
                "occupation": np.zeros((n_ks_states), dtype=float),
1268
                "eigenvalue_eV": np.zeros((n_ks_states), dtype=float),
1269
            }
1270
            # Get the KS states from this line until the next empty line
1271
            self._get_ks_states(final_ev_start, eigenvalues, 0, n_ks_states)
1✔
1272

1273
            return eigenvalues
1✔
1274

1275
        elif spin_polarised:
1✔
1276
            su_eigenvalues = {
1✔
1277
                "state": np.zeros((n_ks_states), dtype=int),
1278
                "occupation": np.zeros((n_ks_states), dtype=float),
1279
                "eigenvalue_eV": np.zeros((n_ks_states), dtype=float),
1280
            }
1281
            sd_eigenvalues = su_eigenvalues.copy()
1✔
1282

1283
            # The spin-down states start from here
1284
            self._get_ks_states(final_ev_start, sd_eigenvalues, 0, n_ks_states)
1✔
1285

1286
            # Go back one more target line to get the spin-up states
1287
            for i, line in enumerate(reversed(self.lines[: final_ev_start - 1])):
1✔
1288
                if target_line == line.strip():
1✔
1289
                    final_ev_start += -i - 1
1✔
1290
                    break
1✔
1291

1292
            self._get_ks_states(final_ev_start, su_eigenvalues, 0, n_ks_states)
1✔
1293

1294
            return su_eigenvalues, sd_eigenvalues
1✔
1295

1296
        else:
1297
            raise ValueError("Could not determine if calculation was spin polarised.")
×
1298

1299
    def get_pert_soc_ks_eigenvalues(self) -> dict:
1✔
1300
        """
1301
        Get the perturbative SOC Kohn-Sham eigenvalues from a calculation.
1302

1303
        Returns
1304
        -------
1305
        dict
1306
            The perturbative SOC Kohn-Sham eigenvalues
1307

1308
        Raises
1309
        ------
1310
        ValueError
1311
            the final KS states were not found in aims.out file
1312
        """
1313

1314
        # Get the number of KS states
1315
        n_ks_states = self.get_n_initial_ks_states()
1✔
1316

1317
        target_line = (
1✔
1318
            "State    Occupation    Unperturbed Eigenvalue [eV]"
1319
            "    Eigenvalue [eV]    Level Spacing [eV]"
1320
        )
1321

1322
        # Iterate backwards from end of aims.out to find the perturbative SOC
1323
        # eigenvalues
1324
        final_ev_start = None
1✔
1325
        for i, line in enumerate(reversed(self.lines)):
1✔
1326
            if target_line == line.strip():
1✔
1327
                final_ev_start = -i
1✔
1328
                break
1✔
1329

1330
        if final_ev_start is None:
1✔
1331
            raise ValueError("Final KS states not found in aims.out file.")
1✔
1332

1333
        eigenvalues = {
1✔
1334
            "state": np.zeros(n_ks_states, dtype=int),
1335
            "occupation": np.zeros(n_ks_states, dtype=float),
1336
            "unperturbed_eigenvalue_eV": np.zeros(n_ks_states, dtype=float),
1337
            "eigenvalue_eV": np.zeros(n_ks_states, dtype=float),
1338
            "level_spacing_eV": np.zeros(n_ks_states, dtype=float),
1339
        }
1340

1341
        for i, line in enumerate(
1✔
1342
            self.lines[final_ev_start : final_ev_start + n_ks_states]
1343
        ):
1344
            spl = line.split()
1✔
1345

1346
            eigenvalues["state"][i] = int(spl[0])
1✔
1347
            eigenvalues["occupation"][i] = float(spl[1])
1✔
1348
            eigenvalues["unperturbed_eigenvalue_eV"][i] = float(spl[2])
1✔
1349
            eigenvalues["eigenvalue_eV"][i] = float(spl[3])
1✔
1350
            eigenvalues["level_spacing_eV"][i] = float(spl[4])
1✔
1351

1352
        return eigenvalues
1✔
1353

1354

1355
class ELSIOutput(Output):
1✔
1356
    """
1357
    Parse matrix output written in a binary csc format from ELSI.
1358

1359
    ...
1360

1361
    Attributes
1362
    ----------
1363
    data : bytes
1364
        The binary data from the ELSI csc file
1365
    path : str
1366
        Path to ELSI csc file.
1367
    n_basis : int
1368
        Number of basis functions
1369
    n_non_zero : int
1370
        Number of non-zero elements in the matrix
1371
    """
1372

1373
    def __init__(self, **kwargs):
1✔
1374
        super().__init__(**kwargs)
1✔
1375

1376
    def get_elsi_csc_header(self) -> npt.NDArray[np.int64]:
1✔
1377
        """
1378
        Get the contents of the ELSI file header
1379

1380
        Returns
1381
        -------
1382
        tuple
1383
            The contents of the ELSI csc file header
1384
        """
1385

1386
        return np.frombuffer(self.data[0:128], dtype=np.int64)
1✔
1387

1388
    @property
1✔
1389
    def n_basis(self) -> int:
1✔
1390
        return self.get_elsi_csc_header()[3]
1✔
1391

1392
    @property
1✔
1393
    def n_non_zero(self) -> int:
1✔
1394
        return self.get_elsi_csc_header()[5]
1✔
1395

1396
    def read_elsi_as_csc(
1✔
1397
        self, csc_format: bool = False
1398
    ) -> Union[sp.csc_matrix, npt.NDArray[np.float64]]:
1399
        """
1400
        Get a CSC matrix from an ELSI output file
1401

1402
        Parameters
1403
        ----------
1404
        csc_format : bool, default=True
1405
            Whether to return the matrix in CSC format or a standard numpy array
1406

1407
        Returns
1408
        -------
1409
        Tuple[sp.csc_matrix, np.ndarray]
1410
            The CSC matrix or numpy array
1411
        """
1412

1413
        header = self.get_elsi_csc_header()
1✔
1414

1415
        # Get the column pointer
1416
        end = 128 + self.n_basis * 8
1✔
1417
        col_i = np.frombuffer(self.data[128:end], dtype=np.int64)
1✔
1418
        col_i = np.append(col_i, self.n_non_zero + 1)
1✔
1419
        col_i -= 1
1✔
1420

1421
        # Get the row index
1422
        start = end + self.n_non_zero * 4
1✔
1423
        row_i = np.array(np.frombuffer(self.data[end:start], dtype=np.int32))
1✔
1424
        row_i -= 1
1✔
1425

1426
        if header[2] == 0:  # real
1✔
1427
            nnz = np.frombuffer(
1✔
1428
                self.data[start : start + self.n_non_zero * 8],
1429
                dtype=np.float64,
1430
            )
1431

1432
        else:  # complex
1433
            nnz = np.frombuffer(
×
1434
                self.data[start : start + self.n_non_zero * 16],
1435
                dtype=np.complex128,
1436
            )
1437

1438
        if csc_format:
1✔
1439
            return sp.csc_array((nnz, row_i, col_i), shape=(self.n_basis, self.n_basis))
1✔
1440

1441
        else:
1442
            return sp.csc_array(
1✔
1443
                (nnz, row_i, col_i), shape=(self.n_basis, self.n_basis)
1444
            ).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

© 2025 Coveralls, Inc