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

maurergroup / dfttoolkit / 19927157305

04 Dec 2025 11:19AM UTC coverage: 32.565% (+0.1%) from 32.455%
19927157305

Pull #126

github

web-flow
Merge f3dc6c38f into c73822af7
Pull Request #126: CodeQL fixes and minor refactoring

1371 of 4210 relevant lines covered (32.57%)

0.33 hits per line

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

0.0
dfttoolkit/cube.py
1
import copy
×
2
from pathlib import Path
×
3
from typing import Any, Literal, Self
×
4

5
import ase.io.cube
×
6
import numpy as np
×
7
import numpy.typing as npt
×
8
from ase import Atoms
×
9
from scipy.ndimage.interpolation import shift
×
10

11
from .base import Parser
×
12
from .geometry import Geometry
×
13
from .utils.math_utils import get_triple_product
×
14
from .utils.periodic_table import PeriodicTable
×
15
from .utils.units import BOHR_IN_ANGSTROM, EPSILON0_AIMS
×
16

17

18
class Cube(Parser):
×
19
    """
20
    Read, interpolate, and perform operations on cube files.
21

22
    NOTE: this class is currently untested - use with caution!
23

24
    ...
25

26
    Attributes
27
    ----------
28
    atoms
29
    comment
30
    cube_vectors
31
    dV
32
    dv1
33
    dv2
34
    dv3
35
    geometry
36
    grid
37
    grid_vectors
38
    n_atoms
39
    n_points
40
    shape
41
    volume
42
    path: str
43
        path to the cube file
44
    lines: List[str]
45
        contents of the cube file
46
    """
47

48
    def __init__(self, cube: str):
×
49
        # Parse file information and perform checks
50
        super().__init__(self._supported_files, cube=cube)
×
51

52
        # Check that the file is a cube file and in the correct format
53
        self._check_binary(False)
×
54

55
        # Parse the cube data here rather than in base.File.__post_init__ so we can call
56
        # ASE's read_cube()
57
        with self._path.open() as f:
×
58
            _cf = ase.io.cube.read_cube(f)
×
59
            self.lines = f.readlines()
×
60
            self.data = b""
×
61
            self._binary = False
×
62

63
        self._atoms = _cf["atoms"]
×
64
        self._n_atoms = len(self._atoms)
×
65
        self._origin = _cf["origin"]
×
66
        self._volume = _cf["datas"]
×
67

68
        # Centre the atoms to cube origin
69
        self._atoms.translate(-self._origin)  # pyright: ignore[reportOperatorIssue]
×
70

71
        # Get other cube file parameters
72
        self._grid_vectors = np.array(
×
73
            [float(j) for i in self.lines[2:5] for j in i.split()[1:]]
74
        )
75

76
        self._shape = np.array(
×
77
            [int(i.split()[0]) for i in self.lines[3:5]], dtype=np.int64
78
        )
79

80
        self._calculate_cube_vectors()
×
81

82
        # Get atoms
83
        atom_Z = np.zeros(self._n_atoms, dtype=int)
×
84
        atom_pos = np.zeros((self._n_atoms, 3))
×
85
        for i in range(self._n_atoms):
×
86
            spl_atom_line = self.lines[6 + i].split()
×
87
            atom_Z[i] = int(spl_atom_line[0])
×
88
            atom_pos[i, :] = np.array(spl_atom_line[2:5])
×
89

90
        self._geom = Geometry()
×
91
        atom_pos *= BOHR_IN_ANGSTROM
×
92
        species = [PeriodicTable.get_symbol(i) for i in atom_Z]
×
93
        self._geom.add_atoms(atom_pos, species)
×
94

95
        # Parse the grid data
96
        self._grid = np.fromiter(
×
97
            (float(x) for line in self.lines[7:] for x in line.split()),
98
            dtype=np.float64,
99
        )
100
        self._n_points = len(self._grid)
×
101
        self._grid = np.reshape(self._grid, self._shape)
×
102

103
    @property
×
104
    def _supported_files(self) -> dict[str, str]:
×
105
        return {"cube": ".cube"}
×
106

107
    def __init_subclass__(cls, **kwargs: str):
×
108
        # Override the parent's __init_subclass__ without calling it
109
        pass
×
110

111
    @property
×
112
    def atoms(self) -> Atoms:
×
113
        """Atoms present in the cube file."""
114
        return self._atoms
×
115

116
    @property
×
117
    def comment(self) -> str:
×
118
        """Comment line of the cube file."""
119
        return " ".join(self.lines[0:1])
×
120

121
    @property
×
122
    def cube_vectors(self) -> npt.NDArray[np.int64]:
×
123
        """Cube vectors of the cube file."""
124
        return self._cube_vectors
×
125

126
    @property
×
127
    def dV(self) -> npt.NDArray:  # noqa: N802
×
128
        """Volume of the cube file."""
129
        return self._dV
×
130

131
    @property
×
132
    def dv1(self) -> np.floating[Any]:
×
133
        """First voxel dimension of the cube file."""
134
        return self._dv1
×
135

136
    @property
×
137
    def dv2(self) -> np.floating[Any]:
×
138
        """Second voxel dimension of the cube file."""
139
        return self._dv2
×
140

141
    @property
×
142
    def dv3(self) -> np.floating[Any]:
×
143
        """Third voxel dimension of the cube file."""
144
        return self._dv3
×
145

146
    @property
×
147
    def geometry(self) -> Geometry:
×
148
        """Atoms represented in the cube file."""
149
        return self._geom
×
150

151
    @geometry.setter
×
152
    def geometry(self, geometry: Geometry) -> None:
×
153
        self._geom = geometry
×
154
        self._atoms = geometry.get_as_ase()
×
155
        self._n_atoms = len(geometry)
×
156

157
    @property
×
158
    def grid(self) -> npt.NDArray:
×
159
        """Grid data of the cube file."""
160
        return self._grid
×
161

162
    @grid.setter
×
163
    def grid(self, grid: npt.NDArray) -> None:
×
164
        self._grid = grid
×
165

166
    @property
×
167
    def grid_vectors(self) -> npt.NDArray:
×
168
        """Grid vectors of the cube file."""
169
        return self._grid_vectors
×
170

171
    @property
×
172
    def n_atoms(self) -> int:
×
173
        """Number of atoms in the cube file."""
174
        return self._n_atoms
×
175

176
    @property
×
177
    def n_points(self) -> int:
×
178
        """Number of points in the grid data."""
179
        return self._n_points
×
180

181
    @property
×
182
    def origin(self) -> npt.NDArray[np.float64]:
×
183
        """Origin of the cube file."""
184
        return self._origin  # pyright: ignore
×
185

186
    @property
×
187
    def shape(self) -> npt.NDArray[np.int64]:
×
188
        """Number of dimensions of the grid vectors."""
189
        return self._shape
×
190

191
    @property
×
192
    def volume(self) -> npt.NDArray[np.float64]:
×
193
        """Volume of the cube file."""
194
        return self._volume  # pyright: ignore
×
195

196
    def __add__(self, other: Self):
×
197
        new_cube = copy.deepcopy(self)
×
198
        new_cube.grid += other.grid
×
199

200
        return new_cube
×
201

202
    def __sub__(self, other: Self):
×
203
        new_cube = copy.deepcopy(self)
×
204
        new_cube.grid -= other.grid
×
205

206
        return new_cube
×
207

208
    def __isub__(self, other: Self):
×
209
        self.grid -= other.grid
×
210

211
        return self
×
212

213
    def __mul__(self, other: Self):
×
214
        new_cube = copy.deepcopy(self)
×
215

216
        if isinstance(other, float | int):
×
217
            new_cube.grid *= other
×
218
        else:
219
            new_cube.grid *= other.data
×
220

221
        return new_cube
×
222

223
    def __imul__(self, other: Self):
×
224
        if isinstance(other, float | int):
×
225
            self.grid *= other
×
226
        else:
227
            self.grid *= other.data
×
228

229
        return self
×
230

231
    def _calculate_cube_vectors(self) -> None:
×
232
        self._cube_vectors = ((self.grid_vectors.T) * self.shape).T
×
233

234
        self._dV = np.abs(
×
235
            get_triple_product(
236
                self.grid_vectors[0, :],
237
                self.grid_vectors[1, :],
238
                self.grid_vectors[2, :],
239
            )
240
        )
241

242
        self._dv1 = np.linalg.norm(self.grid_vectors[0, :])
×
243
        self._dv2 = np.linalg.norm(self.grid_vectors[1, :])
×
244
        self._dv3 = np.linalg.norm(self.grid_vectors[2, :])
×
245

246
    def get_periodic_replica(self, periodic_replica: tuple) -> Self:
×
247
        new_cube = copy.deepcopy(self)
×
248

249
        # add geometry
250
        geom = copy.deepcopy(self.geometry)
×
251
        geom.lattice_vectors = self.cube_vectors
×
252
        new_geom = geom.get_periodic_replica(periodic_replica)
×
253
        new_cube.geometry = new_geom
×
254

255
        # add data
256
        new_cube.grid = np.tile(self.grid, periodic_replica)
×
257

258
        # add lattice vectors
259
        new_shape = self.shape * np.array(periodic_replica)
×
260
        new_cube._shape = new_shape
×
261
        new_cube._calculate_cube_vectors()
×
262
        new_cube._n_points = len(new_cube.grid)
×
263

264
        return new_cube
×
265

266
    def save_to_file(self, name: str | Path) -> None:
×
267
        """
268
        Save cube file.
269

270
        Parameters
271
        ----------
272
        new_file : str Path
273
            name of the new cube file
274
        """
275
        if isinstance(name, str):
×
276
            filename = Path(name)
×
277
        elif isinstance(name, Path):
×
278
            filename = name
×
279

280
        filename = filename.with_suffix(".cube")
×
281

282
        header = ""
×
283

284
        # comments
285
        header += self.comment
×
286

287
        # cube file needs exactly 2 comment lines
288
        n_comment_lines = header.count("\n")
×
289
        if n_comment_lines < 2:
×
290
            header += "\n" * (2 - n_comment_lines)
×
291
        elif n_comment_lines > 2:
×
292
            split_head = header.split("\n")
×
293
            header = (
×
294
                split_head[0]
295
                + "\n"
296
                + " ".join(split_head[1:-1])
297
                + "\n"
298
                + split_head[-1]
299
            )
300

301
        # n_atoms and origin
302
        header += (
×
303
            f"{self.n_atoms:5d}"
304
            + "   "
305
            + "   ".join([f"{x / BOHR_IN_ANGSTROM: 10.6f}" for x in self.origin])
306
            + "\n"
307
        )
308

309
        # lattice vectors
310
        for i in range(3):
×
311
            header += (
×
312
                f"{self.shape[i]:5d}"
313
                + "   "
314
                + "   ".join(
315
                    [
316
                        f"{self.grid_vectors[i, j] / BOHR_IN_ANGSTROM: 10.6f}"
317
                        for j in range(3)
318
                    ]
319
                )
320
                + "\n"
321
            )
322

323
        # atoms
324
        atom_pos = self.geometry.coords
×
325
        atom_Z = [PeriodicTable.get_atomic_number(x) for x in self.geometry.species]
×
326
        for i in range(self.n_atoms):
×
327
            header += (
×
328
                f"{atom_Z[i]:5d}"
329
                + "   "
330
                + "0.000000"
331
                + "   "
332
                + "   ".join(
333
                    [f"{atom_pos[i, j] / BOHR_IN_ANGSTROM: 10.6f}" for j in range(3)]
334
                )
335
                + "\n"
336
            )
337

338
        x_len = self.shape[0]
×
339
        y_len = self.shape[1]
×
340
        z_len = self.shape[2]
×
341
        str_arr_size = int(x_len * y_len)
×
342
        string_array = np.empty([str_arr_size, z_len], dtype="<U18")
×
343

344
        # values
345
        for ix in range(x_len):
×
346
            for iy in range(y_len):
×
347
                for iz in range(z_len):
×
348
                    # for each ix we are consecutively writing all iy elements
349
                    string_array[ix * y_len + iy, iz] = f" {self.grid[ix, iy, iz]: .8e}"
×
350

351
        # write to file
352
        with filename.open(mode="w", newline="\n") as f:
×
353
            f.write(header)
×
354
            for i in range(str_arr_size):
×
355
                for j in range(int(np.ceil(z_len / 6))):
×
356
                    start_ind = 6 * j
×
357
                    end_ind = 6 * (j + 1)
×
358
                    end_ind = min(end_ind, z_len)
×
359
                    f.write("".join(string_array[i, start_ind:end_ind]) + "\n")
×
360

361
    def get_on_sparser_grid(
×
362
        self, reduction_factors: tuple[int, int, int]
363
    ) -> npt.NDArray:
364
        """
365
        TODO.
366

367
        Parameters
368
        ----------
369
        reduction_factors : tuple[int, int, int]
370
            factors to reduce the grid by in each dimension
371

372
        Returns
373
        -------
374
        rho : npt.NDArray
375
            reduced grid
376
        """
377
        rho = self.grid[:: reduction_factors[0], :, :]
×
378
        rho = rho[:, :: reduction_factors[1], :]
×
379
        return rho[:, :, :: reduction_factors[2]]
×
380

381
    def get_value_list(self) -> npt.NDArray[np.float64]:
×
382
        """
383
        TODO.
384

385
        Returns
386
        -------
387
        npt.NDArray[np.float64]
388
            TODO
389
        """
390
        return np.reshape(self.grid, [self.n_points])
×
391

392
    def get_point_list(self) -> Any:
×
393
        """
394
        TODO.
395

396
        Returns
397
        -------
398
        TODO
399
        also fix type annotations
400
        """
401
        ind = np.meshgrid(
×
402
            np.arange(self.shape[0]),
403
            np.arange(self.shape[1]),
404
            np.arange(self.shape[2]),
405
            indexing="ij",
406
        )
407

408
        fractional_point_list = np.array([i.ravel() for i in ind]).T
×
409
        r = np.dot(fractional_point_list, self.grid_vectors)
×
410

411
        return r + self.origin
×
412

413
    def get_point_coordinates(self) -> Any:
×
414
        """
415
        Create n1 x n2 x n3 x 3 array of coordinates for each data point.
416

417
        Returns
418
        -------
419
        TODO
420
        also fix type annotations
421
        """
422
        r = self.get_point_list()
×
423
        return np.reshape(r, [*self.shape, 3], order="C")
×
424

425
    def get_integrated_projection_on_axis(
×
426
        self, axis: Literal[0, 1, 2]
427
    ) -> tuple[npt.NDArray, npt.NDArray]:
428
        """
429
        Integrate cube file over the plane perpendicular to the selected axis.
430

431
        Returns
432
        -------
433
        proj : TODO
434
            projected values
435
        xaxis : TODO
436
            coordinates of axis (same length as proj)
437
        """
438
        axsum = list(range(3))
×
439
        axsum.pop(axis)
×
440

441
        dA = np.linalg.norm(
×
442
            np.cross(self.grid_vectors[axsum[0], :], self.grid_vectors[axsum[1], :])
443
        )
444

445
        # trapeziodal rule: int(f) = sum_i (f_i + f_i+1) * dA / 2
446
        # but here not div by 2 because no double counting in sum
447
        proj = np.sum(self.data, axis=tuple(axsum)) * dA
×
448
        xstart = self.origin[axis]
×
449
        xend = self.origin[axis] + self.grid_vectors[axis, axis] * self.shape[axis]
×
450
        xaxis = np.linspace(xstart, xend, self.shape[axis])
×
451

452
        return proj, xaxis
×
453

454
    def get_averaged_projection_on_axis(
×
455
        self, axis: Literal[0, 1, 2], divide_by_area: bool = True
456
    ) -> tuple[npt.NDArray, npt.NDArray]:
457
        """
458
        Project cube values onto an axis and normalise by the perpendicular area.
459

460
        Returns
461
        -------
462
        tuple
463
            proj : npt.NDArray
464
                projected values
465
            xaxis : npt.NDArray
466
                coordinates of axis (same length as proj)
467
        """
468
        axsum = list(range(3))
×
469
        axsum.pop(axis)
×
470

471
        dA = np.linalg.norm(
×
472
            np.cross(self.grid_vectors[axsum[0], :], self.grid_vectors[axsum[1], :])
473
        )
474
        n_datapoints = self.shape[axsum[0]] * self.shape[axsum[1]]
×
475
        A = dA * n_datapoints
×
476

477
        # this gives sum(data) * dA
478
        proj, xaxis = self.get_integrated_projection_on_axis(axis)
×
479

480
        # remove dA from integration
481
        proj = proj / dA
×
482

483
        # average per area or pure mathematical average
484
        averaged = proj / A if divide_by_area else proj / n_datapoints
×
485

486
        return averaged, xaxis
×
487

488
    def get_charge_field_potential_along_axis(
×
489
        self, axis: Literal[0, 1, 2]
490
    ) -> tuple[npt.NDArray, npt.NDArray, npt.NDArray, npt.NDArray]:
491
        """
492
        TODO.
493

494
        Parameters
495
        ----------
496
        TODO
497

498
        Returns
499
        -------
500
        TODO
501
        """
502
        axsum = list(range(3))
×
503
        axsum.pop(axis)
×
504

505
        dA = np.linalg.norm(
×
506
            np.cross(self.grid_vectors[axsum[0], :], self.grid_vectors[axsum[1], :])
507
        )
508
        n_datapoints = self.shape[axsum[0]] * self.shape[axsum[1]]
×
509
        A = dA * n_datapoints
×
510

511
        charge_density, axis_coords = self.get_integrated_projection_on_axis(2)
×
512

513
        cum_density = np.cumsum(charge_density) * self.dv3
×
514

515
        field = cum_density / EPSILON0_AIMS / A
×
516
        potential = -np.cumsum(field) * self.dv3
×
517

518
        return axis_coords, charge_density, cum_density, potential
×
519

520
    def get_voxel_coordinates(
×
521
        self,
522
    ) -> tuple[
523
        npt.NDArray[np.floating[Any]],
524
        npt.NDArray[np.floating[Any]],
525
        npt.NDArray[np.floating[Any]],
526
    ]:
527
        v1_vec = (
×
528
            np.array([self.origin[0] + i * self.dv1 for i in range(self.shape[0])])
529
            - self.dv1 / 2
530
        )  # shift by half a grid vector to align voxel to center
531
        v2_vec = (
×
532
            np.array([self.origin[1] + i * self.dv2 for i in range(self.shape[1])])
533
            - self.dv2 / 2
534
        )
535
        v3_vec = (
×
536
            np.array([self.origin[2] + i * self.dv3 for i in range(self.shape[2])])
537
            - self.dv3 / 2
538
        )
539

540
        return v1_vec, v2_vec, v3_vec
×
541

542
    def get_voxel_coordinates_along_lattice(
×
543
        self, periodic_replica: tuple
544
    ) -> tuple[npt.NDArray, npt.NDArray]:
545
        """
546
        TODO.
547

548
        unit cell is usually not at 90 degree angle therefore the plot of xy plane
549
        has to be projected onto the lattice vectors
550

551
        Parameters
552
        ----------
553
        periodic_replica : tuple
554
            number of periodic replicas in each direction
555

556
        Returns
557
        -------
558
        tuple
559
            v1_plot : NDArray
560
                x-coordinates of the grid points
561
            v2_plot : NDArray
562
                y-coordinates of the grid points
563
        """
564
        grid_vec = copy.deepcopy(self.grid_vectors)
×
565

566
        # get lattice vectors
567
        latt_mat = grid_vec[:-1, :-1]
×
568
        latt_mat[0, :] /= np.linalg.norm(latt_mat[0, :])
×
569
        latt_mat[1, :] /= np.linalg.norm(latt_mat[1, :])
×
570
        R = latt_mat.T
×
571

572
        # get points in cube grid
573
        v1_vec = (
×
574
            np.array([i * self.dv1 for i in range(self.shape[0] * periodic_replica[0])])
575
            - self.dv1 / 2
576
        )
577
        v2_vec = (
×
578
            np.array([i * self.dv2 for i in range(self.shape[1] * periodic_replica[1])])
579
            - self.dv2 / 2
580
        )
581
        v1, v2 = np.meshgrid(v1_vec, v2_vec)
×
582

583
        # project points onto lattice
584
        mult = np.dot(R, np.array([v1.ravel(), v2.ravel()]))
×
585
        v1_plot = mult[0, :].reshape(v1.shape) + self.origin[0]
×
586
        v2_plot = mult[1, :].reshape(v2.shape) + self.origin[1]
×
587

588
        return v1_plot, v2_plot
×
589

590
    def heights_for_constant_current(self, constant_current: float) -> npt.NDArray:
×
591
        """
592
        Find heights where the STM cube file current is closest to I = constant_current.
593

594
        Parameters
595
        ----------
596
        constant_current : float
597
            The current value to find the heights for.
598

599
        Returns
600
        -------
601
        TODO
602
        also fix type annotations
603
        """
604
        # difference of the current in each point to the constant_current
605
        delta = np.abs(self.grid - constant_current)
×
606

607
        # get indices of z-dimension of points that were closest to the current
608
        z_indices = np.argmin(delta, axis=2)
×
609

610
        # get the z-values that correspond to this indices
611
        _, _, v3_vec = self.get_voxel_coordinates()
×
612

613
        # create an array of the shape of indices with the heights
614
        # (repeat the v3_vec array to get to the shape of indices)
615
        heights = np.ones_like(z_indices)[:, :, np.newaxis] * v3_vec
×
616

617
        # cutout those hights that correspond to the indices
618
        x_indices, y_indices = np.indices(z_indices.shape)
×
619
        return heights[(x_indices, y_indices, z_indices)]
×
620

621
    def shift_content_along_vector(
×
622
        self,
623
        vec: npt.NDArray,
624
        repeat: bool = False,
625
        integer_only: bool = False,
626
        return_shift_indices: bool = False,
627
    ) -> npt.NDArray | tuple[npt.NDArray, npt.NDArray]:
628
        """
629
        Shifts values of the Cube along a specific vector.
630

631
        All values that are not known are set to zero.
632
        All values that are now outside the cube are deleted.
633
        TODO: Extrapolate unknown values
634
        ---------               ---------
635
        |xxxxxxx|               |00xxxxx| xx
636
        |xxxxxxx| shift by vec  |00xxxxx| xx  <-- deleted
637
        |xxxxxxx|      ---->    |00xxxxx| xx
638
        |xxxxxxx|               |00xxxxx| xx
639
        |xxxxxxx|               |00xxxxx| xx
640
        ---------               ---------
641

642
        Parameters
643
        ----------
644
        vec : npt.NDArray
645
            vector to shift the cube along
646
        repeat : bool, default=False
647
            if True, the cube is shifted in a periodic way
648
        integer_only : bool, default=False
649
            if True, the shift is rounded to the nearest integer
650
        return_shift_indices : bool, default=False
651
            if True, the shift indices are returned as well
652

653
        Returns
654
        -------
655
        Union
656
            tuple[npt.NDArray, npt.NDArray]
657
                shifted cube data and  shift indices
658
            npt.NDArray
659
                shifted cube data
660
        """
661
        # convert vec to indices
662
        trans_mat = copy.deepcopy(self.grid_vectors).T
×
663
        shift_inds = np.dot(np.linalg.inv(trans_mat), vec)
×
664

665
        if integer_only:
×
666
            shift_inds = shift_inds.astype(int)
×
667

668
        mode = "wrap" if repeat else "constant"
×
669
        data = shift(self.data, shift_inds, mode=mode)
×
670

671
        if return_shift_indices:
×
672
            return data, shift_inds
×
673

674
        return data
×
675

676
    def get_value_at_positions(
×
677
        self,
678
        coords: npt.NDArray,
679
        return_mapped_coords: bool = False,
680
        xy_periodic: bool = True,
681
    ) -> npt.NDArray | tuple[npt.NDArray, npt.NDArray]:
682
        """
683
        Get the value of the closest data point in cube grid.
684

685
        Parameters
686
        ----------
687
        coords : npt.NDArray
688
            List of Cartesian coordinates at which the cubefile values should
689
            be returned.
690
        return_mapped_coords : bool, default=False
691
            Return the Cartesian coordinates, minus the origin of the cubefile,
692
            of the grid point in the cubefile that is closest to the respective
693
            position in coords. The default is False.
694
        xy_periodic : bool, default=True
695
            If True, the x and y coordinates are treated as periodic. The
696
            default is True. If False, the x and y coordinates are not
697
            periodic. This means that if a coordinate is outside the grid, it
698
            will be set to the closest grid point.
699

700
        Returns
701
        -------
702
        NDArray
703
            Vaules at the grid point closest to the respective positions in
704
            coords
705
        """
706
        trans_mat = copy.deepcopy(self.grid_vectors).T
×
707
        coords = np.atleast_2d(coords)
×
708
        pos_inds = np.round(np.dot(np.linalg.inv(trans_mat), (coords - self.origin).T))
×
709
        pos_inds = pos_inds.astype(int)
×
710

711
        n_coords = np.shape(pos_inds)[1]
×
712
        if not xy_periodic:
×
713
            pos_inds[0, pos_inds[0, :] > self.shape[0]] = self.shape[0] - 1
×
714
            pos_inds[0, pos_inds[0, :] < 0] = 0
×
715
            pos_inds[1, pos_inds[1, :] > self.shape[1]] = self.shape[1] - 1
×
716
            pos_inds[1, pos_inds[1, :] < 0] = 0
×
717
        values = np.zeros([n_coords])
×
718

719
        for i in range(n_coords):
×
720
            x, y, z = pos_inds[0, i], pos_inds[1, i], pos_inds[2, i]
×
721
            if (
×
722
                isinstance(x, int)
723
                and isinstance(y, int)
724
                and isinstance(z, int)
725
                and 0 <= x < self.grid.shape[0]
726
                and 0 <= y < self.grid.shape[1]
727
                and 0 <= z < self.grid.shape[2]
728
            ):
729
                values[i] = self.grid[x, y, z]
×
730
            else:
731
                values[i] = np.nan
×
732

733
        if return_mapped_coords:
×
734
            return values, self.origin + np.dot(trans_mat, pos_inds).T
×
735

736
        return values
×
737

738
    def get_interpolated_value_at_positions(  # noqa: PLR0912, PLR0915
×
739
        self,
740
        coords: npt.NDArray,
741
        return_mapped_coords: bool = False,
742
        xy_periodic: bool = True,
743
    ) -> npt.NDArray | tuple[npt.NDArray, npt.NDArray]:
744
        """
745
        Get value of closest data point in cube grid.
746

747
        Parameters
748
        ----------
749
        coords : npt.NDArray
750
            List of Cartesian coordinates at which the cubefile values should
751
            be returned.
752
        return_mapped_coords : bool, default=False
753
            Return the Cartesian coordinates, minus the origin of the cubefile,
754
            of the grid point in the cubefile that is closest to the respective
755
            position in coords.
756
        xy_periodic : bool, default=True
757
            If True, the x and y coordinates are treated as periodic.
758

759
        Returns
760
        -------
761
        Union
762
            npt.NDArray
763
                Values at the grid point closest to the respective positions in
764
                coords
765
            tuple[npt.NDArray, npt.NDArray]
766
                Values at the grid point closest to the respective positions in
767
                coords and the Cartesian coordinates of the grid point in the
768
                cubefile that is closest to the respective position in coords.
769
        """
770
        trans_mat = copy.deepcopy(self.grid_vectors).T
×
771
        coords = np.atleast_2d(coords)
×
772
        pos_inds_0 = np.dot(np.linalg.inv(trans_mat), (coords - self.origin).T)
×
773
        pos_inds = np.round(pos_inds_0).astype(int)
×
774

775
        n_coords = np.shape(pos_inds)[1]
×
776
        if not xy_periodic:
×
777
            pos_inds[0, pos_inds[0, :] >= self.shape[0]] = self.shape[0] - 1
×
778
            pos_inds[0, pos_inds[0, :] < 0] = 0
×
779
            pos_inds[1, pos_inds[1, :] >= self.shape[1]] = self.shape[1] - 1
×
780
            pos_inds[1, pos_inds[1, :] < 0] = 0
×
781
        else:
782
            pos_inds_0[0, :] = pos_inds_0[0, :] % self.shape[0]
×
783
            pos_inds_0[1, :] = pos_inds_0[1, :] % self.shape[1]
×
784

785
            pos_inds[0, :] = pos_inds[0, :] % self.shape[0]
×
786
            pos_inds[1, :] = pos_inds[1, :] % self.shape[1]
×
787

788
        pos_inds[2, pos_inds[2, :] >= self.shape[2]] = self.shape[2] - 1
×
789
        pos_inds[2, pos_inds[2, :] < 0] = 0
×
790

791
        values = np.zeros([n_coords])
×
792

793
        difference = pos_inds_0 - pos_inds
×
794

795
        if xy_periodic:
×
796
            difference[0, difference[0, :] > 1.0] = difference[0, :] - self.shape[0]
×
797
            difference[1, difference[1, :] > 1.0] = difference[1, :] - self.shape[1]
×
798

799
        for i in range(n_coords):
×
800
            pos_inds_x = pos_inds[:, i] + np.array([np.sign(difference[0])[i], 0, 0])
×
801
            pos_inds_y = pos_inds[:, i] + np.array([0, np.sign(difference[1])[i], 0])
×
802
            pos_inds_z = pos_inds[:, i] + np.array([0, 0, np.sign(difference[2])[i]])
×
803

804
            # periodic boundary conditions
805
            if not xy_periodic:
×
806
                if pos_inds_x[0] >= self.shape[0]:
×
807
                    pos_inds_x[0] = self.shape[0] - 1
×
808

809
                pos_inds_x[0] = max(pos_inds_x[0], 0)
×
810

811
                if pos_inds_y[1] >= self.shape[1]:
×
812
                    pos_inds_y[1] = self.shape[1] - 1
×
813

814
                pos_inds_y[1] = max(pos_inds_y[1], 0)
×
815

816
            else:
817
                if pos_inds_x[0] >= self.shape[0]:
×
818
                    pos_inds_x[0] = self.shape[0] - pos_inds_x[0]
×
819

820
                if pos_inds_y[1] >= self.shape[1]:
×
821
                    pos_inds_y[1] = self.shape[1] - pos_inds_y[1]
×
822

823
            if pos_inds_z[2] >= self.shape[2]:
×
824
                pos_inds_z[2] = self.shape[2] - 1
×
825

826
            pos_inds_z[2] = max(pos_inds_z[2], 0)
×
827

828
            pos_inds_x = pos_inds_x.astype(int)
×
829
            pos_inds_y = pos_inds_y.astype(int)
×
830
            pos_inds_z = pos_inds_z.astype(int)
×
831

832
            values_0 = self.grid[pos_inds[0, i], pos_inds[1, i], pos_inds[2, i]]
×
833
            values_x = self.grid[pos_inds_x[0], pos_inds_x[1], pos_inds_x[2]]
×
834
            values_y = self.grid[pos_inds_y[0], pos_inds_y[1], pos_inds_y[2]]
×
835
            values_z = self.grid[pos_inds_z[0], pos_inds_z[1], pos_inds_z[2]]
×
836

837
            d_v_x = (values_x - values_0) / np.sign(difference[0])[i]
×
838
            d_v_y = (values_y - values_0) / np.sign(difference[1])[i]
×
839
            d_v_z = (values_z - values_0) / np.sign(difference[2])[i]
×
840

841
            if np.isnan(d_v_x):
×
842
                d_v_x = 0
×
843
            if np.isnan(d_v_y):
×
844
                d_v_y = 0
×
845
            if np.isnan(d_v_z):
×
846
                d_v_z = 0
×
847

848
            normal_vector = np.array([-d_v_x, -d_v_y, -d_v_z, 1.0])
×
849
            normal_vector /= np.linalg.norm(normal_vector)
×
850

851
            values[i] = (
×
852
                normal_vector[3] * values_0
853
                - normal_vector[0] * difference[0][i]
854
                - normal_vector[1] * difference[1][i]
855
                - normal_vector[2] * difference[2][i]
856
            ) / normal_vector[3]
857

858
        if return_mapped_coords:
×
859
            return values, self.origin + np.dot(trans_mat, pos_inds).T
×
860

861
        return values
×
862

863
    def get_values_on_plane(
×
864
        self,
865
        plane_centre: npt.NDArray,
866
        plane_normal: npt.NDArray,
867
        plane_extent: float,
868
        plane_points: int = 100,
869
    ) -> npt.NDArray:
870
        """
871
        Retruns the cubefile values on a given plane.
872

873
        Parameters
874
        ----------
875
        plane_centre : NDArray
876
            Centre of the plane.
877
        plane_normal : NDArray
878
            Vector normal to the plane, i.e. viewing direction.
879
        plane_extent : float
880
            Size of the plane in Angstrom
881

882
        Returns
883
        -------
884
        values_on_plane : NDArray
885
        """
886
        plane_normal /= np.linalg.norm(plane_normal)
×
887

888
        vec_z = np.array([0.0, 0.0, 1.0])
×
889

890
        plane_vec_xy = np.cross(vec_z, plane_normal)
×
891
        plane_vec_xy /= np.linalg.norm(plane_vec_xy)
×
892
        plane_vec_z = np.cross(plane_normal, plane_vec_xy)
×
893
        plane_vec_z /= np.linalg.norm(plane_vec_z)
×
894

895
        extent_vec = np.linspace(-plane_extent, plane_extent, plane_points)
×
896

897
        values_on_plane = np.zeros((len(extent_vec), len(extent_vec)))
×
898

899
        max_dist = (self.dv1 + self.dv2 + self.dv3) / 3
×
900

901
        for ind_1, x in enumerate(extent_vec):
×
902
            for ind_2, y in enumerate(extent_vec):
×
903
                plane_pos = plane_centre - x * plane_vec_xy + y * plane_vec_z
×
904

905
                value, mapped_coords = self.get_value_at_positions(
×
906
                    plane_pos, return_mapped_coords=True
907
                )
908

909
                vec = mapped_coords - plane_pos + self.origin
×
910
                mag = np.linalg.norm(vec)
×
911

912
                if mag < max_dist:
×
913
                    values_on_plane[ind_1, ind_2] = value
×
914

915
        return values_on_plane
×
916

917
    def calculate_overlap_integral(
×
918
        self,
919
        other: Self,
920
        print_normalisation_factors: bool = True,
921
        take_absolute_value: bool = True,
922
        output_overlap_cube: bool = False,
923
    ) -> float | tuple[float, Self]:
924
        """
925
        Calculate the overlap integral of some quantity with another cubefile.
926

927
        NOTE: this is written to work with the standard FHI-aims voxels in Angstrom³
928
        NOTE: the two orbitals should describe the same exact volume of space!
929

930
        Parameters
931
        ----------
932
        other: Self
933
            Cubefile to calculate the overlap with.
934
        print_normalisation_factors: bool, default=True
935
            Print the normalisation factors.
936
        take_absolute_value: bool, default=True
937
            Take the absolute value of the overlap.
938

939
        Returns
940
        -------
941
        Union
942
            float
943
                Overlap integral of the two cubefiles.
944
            tuple[float, Self]
945
                Overlap integral of the two cubefiles and the overlap cubefile.
946
        """
947
        # this data is normally provided in angstrom^(-3/2)
948
        first = copy.deepcopy(self.grid)
×
949
        second = copy.deepcopy(other.grid)
×
950

951
        # let's pass to bohr_radius^(-3/2)
952
        first *= np.sqrt(BOHR_IN_ANGSTROM**3)
×
953
        second *= np.sqrt(BOHR_IN_ANGSTROM**3)
×
954

955
        # both arrays get normalised
956
        first_squared = first * first
×
957
        second_squared = second * second
×
958
        first_total = first_squared.sum()
×
959
        second_total = second_squared.sum()
×
960
        first_normalization_factor = np.sqrt(1 / first_total)
×
961
        second_normalization_factor = np.sqrt(1 / second_total)
×
962

963
        if print_normalisation_factors:
×
964
            print("Normalization factors:")
×
965
            print("self: ", first_normalization_factor)
×
966
            print("other cubefile: ", second_normalization_factor)
×
967

968
        first *= first_normalization_factor
×
969
        second *= second_normalization_factor
×
970

971
        # Now the sum corresponds to the overall overlap (we are integrating in d(Voxel)
972
        # which is d(bohr_radius^(-3)) ), and we take its absolute value (overlap has
973
        # no sign)
974
        product = first * second
×
975

976
        overlap_cube = None
×
977

978
        if output_overlap_cube:
×
979
            overlap_cube = copy.deepcopy(self)
×
980
            overlap_cube.data = product
×
981

982
        overlap = np.sum(product)
×
983

984
        if take_absolute_value:
×
985
            overlap = np.abs(overlap)
×
986

987
        if output_overlap_cube:
×
988
            return overlap, overlap_cube  # pyright: ignore[reportReturnType]
×
989

990
        return overlap
×
991

992
    def get_eigenstate_number(self) -> int:
×
993
        """
994
        Get the eigenstate number from the filename.
995

996
        Returns
997
        -------
998
        int
999
            The eigenstate number.
1000
        """
1001
        components = self._path.stem.split("_")
×
1002
        eig_num_i = components.index("eigenstate") + 1
×
1003

1004
        return int(components[eig_num_i])
×
1005

1006
    def get_spin_channel(self) -> int:
×
1007
        """
1008
        Get the spin channel from the filename.
1009

1010
        Returns
1011
        -------
1012
        int
1013
            The spin channel.
1014
        """
1015
        components = self._path.stem.split("_")
×
1016
        spin_i = components.index("spin") + 1
×
1017

1018
        return int(components[spin_i])
×
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