# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from __future__ import annotations

import math as m
from numbers import Integral
from typing import Literal, Optional

import numpy as np
from numpy import add, dot, logical_and, repeat, subtract, unique
from scipy.sparse import coo_matrix, csr_matrix
from scipy.sparse import hstack as ss_hstack
from scipy.sparse import tril, triu

import sisl._array as _a
from sisl import BoundaryCondition as BC
from sisl import Geometry, Grid, Lattice
from sisl._core.sparse import SparseCSR, _ncol_to_indptr, _to_coo
from sisl._core.sparse_geometry import SparseAtom, SparseOrbital
from sisl._indices import indices_fabs_le, indices_le
from sisl._internal import set_module
from sisl._math_small import xyz_to_spherical_cos_phi
from sisl.messages import deprecate_argument, progressbar, warn
from sisl.typing import AtomsIndex, GaugeType, SeqFloat

from .sparse import SparseOrbitalBZSpin
from .spin import Spin

__all__ = ["DensityMatrix"]


def _get_density(DM, orthogonal, what="sum"):
    DM = DM.T
    if orthogonal:
        off = 0
    else:
        off = 1
    if what == "sum":
        if DM.shape[0] in (2 + off, 4 + off, 8 + off):
            return DM[0] + DM[1]
        return DM[0]
    if what == "spin":
        m = np.empty([3, DM.shape[1]], dtype=DM.dtype)
        if DM.shape[0] == 8 + off:
            m[0] = DM[2] + DM[6]
            m[1] = -DM[3] + DM[7]
            m[2] = DM[0] - DM[1]
        elif DM.shape[0] == 4 + off:
            m[0] = 2 * DM[2]
            m[1] = -2 * DM[3]
            m[2] = DM[0] - DM[1]
        elif DM.shape[0] == 2 + off:
            m[:2, :] = 0.0
            m[2] = DM[0] - DM[1]
        elif DM.shape[0] == 1 + off:
            m[...] = 0.0
        return m


class _densitymatrix(SparseOrbitalBZSpin):
    def spin_rotate(self, angles: SeqFloat, rad: bool = False):
        r"""Rotates spin-boxes by fixed angles around the :math:`x`, :math:`y` and :math:`z` axis, respectively.

        The angles are with respect to each spin-boxes initial angle.
        One should use `spin_align` to fix all angles along a specific direction.

        Notes
        -----
        For a polarized matrix:
        The returned matrix will be in non-collinear spin-configuration in case
        the angles does not reflect a pure flip of spin in the :math:`z`-axis.

        Parameters
        ----------
        angles : (3,)
           angle to rotate spin boxes around the Cartesian directions
           :math:`x`, :math:`y` and :math:`z`, respectively
        rad : bool, optional
           Determines the unit of `angles`, for true it is in radians

        See Also
        --------
        spin_align : align all spin-boxes along a specific direction

        Returns
        -------
        object
             a new object with rotated spins
        """
        angles = _a.asarrayd(angles)
        if not rad:
            angles = angles / 180 * np.pi

        def cos_sin(a):
            return m.cos(a), m.sin(a)

        calpha, salpha = cos_sin(angles[0])
        cbeta, sbeta = cos_sin(angles[1])
        cgamma, sgamma = cos_sin(angles[2])
        del cos_sin

        # define rotation matrix
        R = (
            # Rz
            np.array([[cgamma, -sgamma, 0], [sgamma, cgamma, 0], [0, 0, 1]])
            # Ry
            .dot([[cbeta, 0, sbeta], [0, 1, 0], [-sbeta, 0, cbeta]])
            # Rx
            .dot([[1, 0, 0], [0, calpha, -salpha], [0, salpha, calpha]])
        )

        if self.spin.is_noncolinear:
            A = np.empty([len(self._csr._D), 3], dtype=self.dtype)

            D = self._csr._D
            Q = (D[:, 0] + D[:, 1]) * 0.5
            A[:, 0] = 2 * D[:, 2]
            A[:, 1] = -2 * D[:, 3]
            A[:, 2] = D[:, 0] - D[:, 1]

            A = R.dot(A.T).T * 0.5

            out = self.copy()
            D = out._csr._D
            D[:, 0] = Q + A[:, 2]
            D[:, 1] = Q - A[:, 2]
            D[:, 2] = A[:, 0]
            D[:, 3] = -A[:, 1]

        elif self.spin.is_spinorbit:
            # Since this spin-matrix has all 8 components we will take
            # each half and align individually.
            # I believe this should retain most of the physics in its
            # intrinsic form and thus be a bit more accurate than
            # later re-creating the matrix by some scaling factor.
            A = np.empty([len(self._csr._D), 2, 3], dtype=self.dtype)

            D = self._csr._D
            Q = (D[:, 0] + D[:, 1]) * 0.5

            # we align each part individually
            # this *should* give us the same magnitude...
            A[:, :, 2] = (D[:, 0] - D[:, 1]).reshape(-1, 1)
            A[:, 0, 0] = 2 * D[:, 2]
            A[:, 1, 0] = 2 * D[:, 6]
            A[:, 0, 1] = -2 * D[:, 3]
            A[:, 1, 1] = 2 * D[:, 7]

            A = R.dot(A.reshape(-1, 3).T).T.reshape(-1, 2, 3) * 0.5

            # create output matrix
            out = self.copy()
            D = out._csr._D

            D[:, 0] = Q + A[:, :, 2].sum(1) * 0.5
            D[:, 1] = Q - A[:, :, 2].sum(1) * 0.5
            D[:, 2] = A[:, 0, 0]
            D[:, 3] = -A[:, 0, 1]
            # 4 and 5 are diagonal imaginary part (un-changed)
            # Since we copy, we don't need to do anything
            # D[:, 4] =
            # D[:, 5] =
            D[:, 6] = A[:, 1, 0]
            D[:, 7] = A[:, 1, 1]

        elif self.spin.is_polarized:

            def close(a, v):
                return abs(abs(a) - v) < np.pi / 1080

            # figure out if this is only rotating 180 for x or y
            if (close(angles[0], np.pi) and close(angles[1], 0)) or (
                close(angles[0], 0) and close(angles[1], np.pi)
            ):
                # flip spin
                out = self.copy()
                out._csr._D[:, [0, 1]] = out._csr._D[:, [1, 0]]

            else:
                spin = Spin("nc", dtype=self.dtype)
                out = self.__class__(
                    self.geometry,
                    dtype=self.dtype,
                    spin=spin,
                    orthogonal=self.orthogonal,
                )
                out._csr.ptr[:] = self._csr.ptr[:]
                out._csr.ncol[:] = self._csr.ncol[:]
                out._csr.col = self._csr.col.copy()
                out._csr._nnz = self._csr._nnz

                if self.orthogonal:
                    out._csr._D = np.zeros([len(self._csr._D), 4], dtype=self.dtype)
                    out._csr._D[:, [0, 1]] = self._csr._D[:, :]
                else:
                    out._csr._D = np.zeros([len(self._csr._D), 5], dtype=self.dtype)
                    out._csr._D[:, [0, 1, 4]] = self._csr._D[:, :]
                out = out.spin_rotate(angles, rad=True)

        else:
            raise ValueError(
                f"{self.__class__.__name__}.spin_rotate requires a matrix with some spin configuration, not an unpolarized matrix."
            )

        return out

    def spin_align(self, vec: SeqFloat, atoms: AtomsIndex = None):
        r"""Aligns *all* spin along the vector `vec`

        In case the matrix is polarized and `vec` is not aligned at the z-axis, the returned
        matrix will be a non-collinear spin configuration.

        Parameters
        ----------
        vec : (3,)
           vector to align the spin boxes against
        atoms : AtomsIndex, optional
           only perform alignment for matrix elements on atoms.
           If multiple atoms are specified, the off-diagonal elements between the
           atoms will also be aligned.
           To only align atomic on-site values, one would have to do a loop.

        See Also
        --------
        spin_rotate : rotate spin-boxes by a fixed amount (does not align spins)

        Returns
        -------
        object
            a new object with aligned spins
        """
        vec = _a.asarrayd(vec)
        # normalize vector
        vec = vec / (vec @ vec) ** 0.5

        # Calculate indices that corresponds to the `atoms` argument
        if atoms is None:
            idx = slice(None)
        else:
            g = self.geometry
            atoms = g._sanitize_atoms(atoms)
            orbs = g.a2o(atoms, all=True)
            csr = self._csr
            idx = _a.array_arange(csr.ptr[:-1], n=csr.ncol)
            rows, cols = self.nonzero()
            # Now check for existance in rows, cols
            idx = idx[
                np.logical_and(np.isin(rows, orbs), np.isin(cols % self.no, orbs))
            ]

        if self.spin.is_noncolinear:
            A = np.empty([len(self._csr._D), 3], dtype=self.dtype)

            D = self._csr._D
            Q = (D[idx, 0] + D[idx, 1]) * 0.5
            A[idx, 0] = 2 * D[idx, 2]
            A[idx, 1] = -2 * D[idx, 3]
            A[idx, 2] = D[idx, 0] - D[idx, 1]

            # align with vector
            # add factor 1/2 here (instead when unwrapping)
            A[idx] = (
                0.5
                * vec.reshape(1, 3)
                * (np.sum(A[idx] ** 2, axis=1).reshape(-1, 1)) ** 0.5
            )

            out = self.copy()
            D = out._csr._D
            D[idx, 0] = Q + A[idx, 2]
            D[idx, 1] = Q - A[idx, 2]
            D[idx, 2] = A[idx, 0]
            D[idx, 3] = -A[idx, 1]

        elif self.spin.is_spinorbit:
            # Since this spin-matrix has all 8 components we will take
            # each half and align individually.
            # I believe this should retain most of the physics in its
            # intrinsic form and thus be a bit more accurate than
            # later re-creating the matrix by some scaling factor.
            A = np.empty([len(self._csr._D), 2, 3], dtype=self.dtype)

            D = self._csr._D
            # we align each part individually
            # this *should* give us the same magnitude...
            Q = (D[idx, 0] + D[idx, 1]) * 0.5
            A[idx, :, 2] = (D[idx, 0] - D[idx, 1]).reshape(-1, 1)
            A[idx, 0, 0] = 2 * D[idx, 2]
            A[idx, 0, 1] = -2 * D[idx, 3]
            A[idx, 1, 0] = 2 * D[idx, 6]
            A[idx, 1, 1] = 2 * D[idx, 7]

            # align with vector
            # add factor 1/2 here (instead when unwrapping)
            A[idx, :, :] = (
                0.5
                * vec.reshape(1, 1, 3)
                * (np.sum(A[idx] ** 2, axis=2).reshape(-1, 2, 1)) ** 0.5
            )

            out = self.copy()
            D = out._csr._D
            D[idx, 0] = Q + A[idx, :, 2].sum(1) * 0.5
            D[idx, 1] = Q - A[idx, :, 2].sum(1) * 0.5
            D[idx, 2] = A[idx, 0, 0]
            D[idx, 3] = -A[idx, 0, 1]
            # 4 and 5 are diagonal imaginary part (un-changed)
            # Since we copy, we don't need to do anything
            # D[idx, 4] =
            # D[idx, 5] =
            D[idx, 6] = A[idx, 1, 0]
            D[idx, 7] = A[idx, 1, 1]

        elif self.spin.is_polarized:
            if vec[:2] @ vec[:2] > 1e-6:
                spin = Spin("nc", dtype=self.dtype)
                out = self.__class__(
                    self.geometry,
                    dtype=self.dtype,
                    spin=spin,
                    orthogonal=self.orthogonal,
                )
                out._csr.ptr[:] = self._csr.ptr[:]
                out._csr.ncol[:] = self._csr.ncol[:]
                out._csr.col = self._csr.col.copy()
                out._csr._nnz = self._csr._nnz

                if self.orthogonal:
                    out._csr._D = np.zeros([len(self._csr._D), 4], dtype=self.dtype)
                    out._csr._D[:, [0, 1]] = self._csr._D[:, :]
                else:
                    out._csr._D = np.zeros([len(self._csr._D), 5], dtype=self.dtype)
                    out._csr._D[:, [0, 1, 4]] = self._csr._D[:, :]
                out = out.spin_align(vec, atoms)

            elif vec[2] < 0:
                # flip spin
                out = self.copy()
                out._csr._D[idx, [0, 1]] = out._csr._D[idx, [1, 0]]
            else:
                out = self.copy()

        else:
            raise ValueError(
                f"{self.__class__.__name__}.spin_align requires a matrix with some spin configuration, not an unpolarized matrix."
            )

        return out

    def mulliken(self, projection="orbital"):
        r""" Calculate Mulliken charges from the density matrix

        See :ref:`this document <math_convention>` for details on the mathematical notation.
        Matrices :math:`\boldsymbol\rho` and :math:`\mathbf S` are density
        and overlap matrices, respectively.

        For polarized calculations the Mulliken charges are calculated as
        (for each spin-channel)

        .. math::

             \mathbf m_i &= \sum_{i} [\boldsymbol\rho_{ij} \mathbf S_{ij}]
             \\
             \mathbf m_I &= \sum_{i\in I} \mathbf m_i

        For non-colinear calculations (including spin-orbit) they are calculated
        as above but using the spin-box per orbital (:math:`\sigma` is spin)

        .. math::
             \mathbf m_i &= \sum_\sigma\sum_j [\boldsymbol\rho_{ij,\sigma\sigma} \mathbf S_{ij,\sigma\sigma}]
             \\
             \mathbf m^{S^x}_i &= \sum_j \Re [\boldsymbol\rho_{ij,\uparrow\downarrow} \mathbf S_{ij,\uparrow\downarrow}] +
                          \Re [\boldsymbol\rho_{ij,\downarrow\uparrow} \mathbf S_{ij,\downarrow\uparrow}]
             \\
             \mathbf m^{S^y}_i &= \sum_j \Im [\boldsymbol\rho_{ij,\uparrow\downarrow} \mathbf S_{ij,\uparrow\downarrow}] -
                          \Im [\boldsymbol\rho_{ij,\downarrow\uparrow} \mathbf S_{ij,\downarrow\uparrow}]
             \\
             \mathbf m^{S^z}_i &= \sum_{j} \Re [\boldsymbol\rho_{ij,\uparrow\uparrow} \mathbf S_{ij,\uparrow\uparrow}] -
                          \Re [\boldsymbol\rho_{ij,\downarrow\downarrow} \mathbf S_{ij,\downarrow\downarrow}]

        Parameters
        ----------
        projection : {'orbital', 'atom'}
            how the Mulliken charges are returned.
            Can be atom-resolved, orbital-resolved or the
            charge matrix (off-diagonal elements)

        Returns
        -------
        numpy.ndarray
            if `projection` does not contain matrix, otherwise ``[spin, no]``, for polarized spin is [T, Sz]
            and for non-colinear spin is [T, Sx, Sy, Sz]
        """

        def _convert(M):
            """Converts a non-colinear DM from [11, 22, Re(12), Im(12)] -> [T, Sx, Sy, Sz]"""
            if M.shape[0] == 8:
                # We need to calculate the corresponding values
                M[2] = 0.5 * (M[2] + M[6])
                M[3] = 0.5 * (M[3] - M[7])  # sign change again below
                M = M[:4]
            elif M.shape[0] == 2:
                # necessary to not overwrite data
                tmp = M[1].copy()
                M[1] = M[0] - M[1]
                M[0] += tmp
            elif M.shape[0] == 1:
                M = M[0]
            if M.shape[0] == 4:
                # catches both shape[0] in [4, 8]
                m = np.empty_like(M)
                m[0] = M[0] + M[1]
                m[3] = M[0] - M[1]
                m[1] = 2 * M[2]
                m[2] = -2 * M[3]
            else:
                return M
            return m

        projection = projection.lower()
        if projection.startswith("orbital"):  # allows orbitals
            # Orbital Mulliken population
            if self.orthogonal:
                D = np.array(
                    [self._csr.tocsr(i).diagonal() for i in range(self.shape[2])]
                )
            else:
                D = self._csr.copy(range(self.shape[2] - 1))
                D._D *= self._csr._D[:, -1].reshape(-1, 1)
                D = np.sum(D, axis=1).T

            return _convert(D)

        elif projection.startswith("atom"):  # allows atoms
            # Atomic Mulliken population
            if self.orthogonal:
                D = np.array(
                    [self._csr.tocsr(i).diagonal() for i in range(self.shape[2])]
                )
            else:
                D = self._csr.copy(range(self.shape[2] - 1))
                D._D *= self._csr._D[:, -1].reshape(-1, 1)
                D = np.sum(D, axis=1).T

            # Now perform summation per atom
            geom = self.geometry
            M = np.zeros([D.shape[0], geom.na], dtype=D.dtype)
            np.add.at(M.T, geom.o2a(np.arange(geom.no)), D.T)
            del D

            return _convert(M)

        raise ValueError(
            f"{self.__class__.__name__}.mulliken only allows projection [orbital, atom]"
        )

    def bond_order(self, method: str = "mayer", projection: str = "atom"):
        r"""Bond-order calculation using various methods

        For ``method='wiberg'``, the bond-order is calculated as:

        .. math::
            \mathbf B_{ij}^{\mathrm{Wiberg}} &= \mathbf D_{ij}^2

        For ``method='mayer'``, the bond-order is calculated as:

        .. math::
            \mathbf B_{ij}^{\mathrm{Mayer}} &= (\mathbf D\mathbf S)_{ij} (\mathbf D\mathbf S)_{ji}

        For ``method='mulliken'``, the bond-order is calculated as:

        .. math::
            \mathbf B_{ij}^{\mathrm{Mulliken}} &= 2\mathbf D_{ij}\mathbf S_{ij}

        The bond order will then be using the above notation for the summation for atoms:

        .. math::
            \mathbf B_{IJ}^{\langle\rangle} &= \sum_{i\in I}\sum_{j\in J} \mathbf B^{\langle\rangle}_{ij}

        The Mulliken bond-order is closely related to the COOP interpretation.
        The COOP is generally an energy resolved Mulliken bond-order. So if the
        density matrix represents a particular eigen-state, it would yield the COOP
        value for the energy of the eigenstate. Generally the density matrix is
        the sum over all occupied eigen states, and hence represents the full
        picture.

        For all options one can do the bond-order calculation for the
        spin components. Albeit, their meaning may be more doubtful.
        Simply add ``':spin'`` to the `method` argument, and the returned
        quantity will be spin-resolved with :math:`x`, :math:`y` and :math:`z`
        components.

        Note
        ----
        It is unclear what the spin-density bond-order really means.

        Parameters
        ----------
        method : {mayer, wiberg, mulliken}[:spin]
            which method to calculate the bond-order with

        projection : {atom, orbital}
            whether the returned matrix is in orbital form, or in atom form.
            If orbital is used, then the above formulas will be changed


        Returns
        -------
        SparseAtom : with the bond-order between any two atoms, in a supercell matrix.
            Returned only if projection is atom.
        SparseOrbital : with the bond-order between any two orbitals, in a supercell matrix.
            Returned only if projection is orbital.
        """
        method = method.lower()

        # split method to retrieve options
        m, *opts = method.split(":")

        # only extract the summed density
        what = "sum"
        if "spin" in opts:
            # do this for each spin x, y, z
            what = "spin"
            del opts[opts.index("spin")]

        # Check that there are no un-used options
        if opts:
            raise ValueError(
                f"{self.__class__.__name__}.bond_order got non-valid options {opts}"
            )

        # get all rows and columns
        geom = self.geometry
        rows, cols, DM = _to_coo(self._csr)

        # Convert to requested matrix form
        D = _get_density(DM, self.orthogonal, what)

        # Define a matrix-matrix multiplication
        def mm(A, B):
            n = A.shape[0]
            latt = self.geometry.lattice
            sc_off = latt.sc_off

            # we will extract sub-matrices n_s ** 2 times.
            # Extracting once should be fine (and ok)
            Al = [A[:, i * n : (i + 1) * n] for i in range(latt.n_s)]
            Bl = [B[:, i * n : (i + 1) * n] for i in range(latt.n_s)]

            # A matrix product in a supercell is a bit tricky
            # since the off-diagonal elements are formed with
            # respect to the supercell offsets from the diagonal
            # compoent

            # A = [[ sc1-sc1, sc2-sc1, sc3-sc1, ...
            #        sc1-sc2, sc2-sc2, sc3-sc2, ...
            #        sc1-sc3, sc2-sc3, sc3-sc3, ...

            # so each column has a *principal* supercell
            # which is used to calculate the offset of each
            # other component.
            # Now for the LHS in a MM, we have A[0, :]
            # which is only wrt. the 0,0 component.
            # In sisl this is forced to be the supercell 0,0.
            # Hence everything in that row requires no special
            # handling. Yet all others do.

            res = []
            for i_s in range(latt.n_s):

                # Calculate the 0,i_s column of the MM
                # This is equal to:
                #  A[0, :] @ B[:, i_s]
                # Calculate the offset for the B column
                sc_offj = sc_off[i_s] - sc_off

                # initialize the result array
                # Not strictly needed, but enforces that the
                # data always contains a csr_matrix
                r = csr_matrix((n, n), dtype=A.dtype)

                # get current supercell information
                for i, sc in enumerate(sc_offj):
                    # i == LHS matrix
                    # j == RHS matrix
                    try:
                        # if the supercell index does not exist, it means
                        # the matrix is 0. Hence we just neglect that contribution.
                        j = latt.sc_index(sc)
                        r = r + Al[i] @ Bl[j]
                    except:
                        continue

                res.append(r)

            # Clean-up...
            del Al, Bl

            # Re-create a matrix where each block is joined into a
            # big matrix, hstack == columnwise stacking.
            return ss_hstack(res)

        projection = projection.lower()

        if projection.startswith("atom"):  # allows atoms

            out_cls = SparseAtom

            def sparse2sparse(geom, M):

                # Ensure we have in COO-rdinate format
                M = M.tocoo()

                # Now re-create the sparse-atom component.
                rows = geom.o2a(M.row)
                cols = geom.o2a(M.col)
                shape = (geom.na, geom.na_s)
                M = coo_matrix((M.data, (rows, cols)), shape=shape).tocsr()
                M.sum_duplicates()
                return M

        elif projection.startswith("orbital"):  # allows orbitals

            out_cls = SparseOrbital

            def sparse2sparse(geom, M):
                M = M.tocsr()
                M.sum_duplicates()
                return M

        else:
            raise ValueError(
                f"{self.__class__.__name__}.bond_order got unexpected keyword projection"
            )

        S = False

        if m == "wiberg":

            def get_BO(geom, D, S, rows, cols):
                # square of each element
                BO = coo_matrix((D * D, (rows, cols)), shape=self.shape[:2])
                return sparse2sparse(geom, BO)

        elif m == "mayer":

            S = True

            def get_BO(geom, D, S, rows, cols):
                D = coo_matrix((D, (rows, cols)), shape=self.shape[:2]).tocsr()
                S = coo_matrix((S, (rows, cols)), shape=self.shape[:2]).tocsr()
                BO = mm(D, S).multiply(mm(S, D))
                return sparse2sparse(geom, BO)

        elif m == "mulliken":

            S = True

            def get_BO(geom, D, S, rows, cols):
                # Got the factor 2 from Multiwfn
                BO = coo_matrix((D * S * 2, (rows, cols)), shape=self.shape[:2]).tocsr()
                return sparse2sparse(geom, BO)

        else:
            raise ValueError(
                f"{self.__class__.__name__}.bond_order got non-valid method {method}"
            )

        if S:
            if self.orthogonal:
                S = np.zeros(rows.size, dtype=DM.dtype)
                S[rows == cols] = 1.0
            else:
                S = DM[:, -1]

        if D.ndim == 2:
            BO = [get_BO(geom, d, S, rows, cols) for d in D]
        else:
            BO = get_BO(geom, D, S, rows, cols)

        return out_cls.fromsp(geom, BO)

    @deprecate_argument(
        "tol",
        "atol",
        "argument tol has been deprecated in favor of atol, please update your code.",
        "0.15",
        "0.16",
    )
    def density(
        self,
        grid: Grid,
        spinor=None,
        atol: float = 1e-7,
        eta: Optional[bool] = False,
        method: Literal["pre-compute", "direct"] = "direct",
        **kwargs,
    ):
        r"""Expand the density matrix to the charge density on a grid

        This routine calculates the real-space density components on a specified grid.

        This is an *in-place* operation that *adds* to the current values in the grid.

        Note: To calculate :math:`\boldsymbol\rho(\mathbf r)` in a unit-cell different from the
        originating geometry, simply pass a grid with a unit-cell different than the originating
        supercell.

        The real-space density is calculated as:

        .. math::
            \boldsymbol\rho(\mathbf r) = \sum_{ij}\phi_i(\mathbf r)\phi_j(\mathbf r) \mathbf D_{ij}

        While for non-collinear/spin-orbit calculations the density is determined from the
        spinor component (`spinor`) by

        .. math::
           \boldsymbol\rho_{\boldsymbol\sigma}(\mathbf r) = \sum_{ij}\phi_i(\mathbf r)\phi_j(\mathbf r) \sum_\alpha [\boldsymbol\sigma \boldsymbol \rho_{ij}]_{\alpha\alpha}

        Here :math:`\boldsymbol\sigma` corresponds to a spinor operator to extract relevant quantities. By passing the identity matrix the total charge is added. By using the Pauli matrix :math:`\boldsymbol\sigma_x`
        only the :math:`x` component of the density is added to the grid (see `Spin.X`).

        Parameters
        ----------
        grid :
           the grid on which to add the density (the density is in ``e/Ang^3``)
        spinor : (2,) or (2, 2), optional
           the spinor matrix to obtain the diagonal components of the density. For un-polarized density matrices
           this keyword has no influence. For spin-polarized it *has* to be either 1 integer or a vector of
           length 2 (defaults to total density).
           For non-collinear/spin-orbit density matrices it has to be a 2x2 matrix (defaults to total density).
        atol :
           DM tolerance for accepted values. For all density matrix elements with absolute values below
           the tolerance, they will be treated as strictly zeros.
        eta :
           show a progressbar on stdout
        method:
           It determines if the orbital values are computed on the fly (direct) or they are all pre-computed
           on the grid at the beginning(pre-compute).
           Pre computing orbitals results in a faster computation, but it requires more memory.
           Currently pre-computing has a bug that results in out-of-bounds, it will be fixed.

        Notes
        -----

        The `method` argument may change at will since this is an experimental feature.
        """
        # Translate the density matrix to have all the unit cell atoms actually inside
        # the unit cell, since this will facilitate things greatly and it gives the
        # same result.
        uc_dm = self.translate2uc()

        # Get the DM components with which we want to compute the density
        csr = uc_dm._csr
        if self.spin.kind > Spin.POLARIZED:
            if spinor is None:
                # Default to the total density
                spinor = np.identity(2, dtype=np.complex128)
            else:
                spinor = _a.arrayz(spinor)
            if spinor.size != 4 or spinor.ndim != 2:
                raise ValueError(
                    f"{self.__class__.__name__}.density with NC/SO spin, requires a 2x2 matrix."
                )

            DM = _a.emptyz([self.nnz, 2, 2])
            idx = _a.array_arange(csr.ptr[:-1], n=csr.ncol)
            if self.spin.kind == Spin.NONCOLINEAR:
                # non-collinear
                DM[:, 0, 0] = csr._D[idx, 0]
                DM[:, 0, 1] = csr._D[idx, 2] + 1j * csr._D[idx, 3]
                DM[:, 1, 0] = np.conj(DM[:, 0, 1])
                DM[:, 1, 1] = csr._D[idx, 1]
            else:
                # spin-orbit
                DM[:, 0, 0] = csr._D[idx, 0] + 1j * csr._D[idx, 4]
                DM[:, 0, 1] = csr._D[idx, 2] + 1j * csr._D[idx, 3]
                DM[:, 1, 0] = csr._D[idx, 6] + 1j * csr._D[idx, 7]
                DM[:, 1, 1] = csr._D[idx, 1] + 1j * csr._D[idx, 5]

            # Perform dot-product with spinor, and take out the diagonal real part
            DM = dot(DM, spinor.T)[:, [0, 1], [0, 1]].sum(1).real

            # Create the DM csr matrix.
            csrDM = csr_matrix(
                (DM, csr.col[idx], _ncol_to_indptr(csr.ncol)),
                shape=(self.shape[:2]),
                dtype=DM.dtype,
            )

            del idx, DM

        elif self.spin.kind == Spin.POLARIZED:
            if spinor is None:
                spinor = _a.onesd(2)

            elif isinstance(spinor, Integral):
                # extract the provided spin-polarization
                s = _a.zerosd(2)
                s[spinor] = 1.0
                spinor = s
            else:
                spinor = _a.arrayd(spinor)

            if spinor.size != 2 or spinor.ndim != 1:
                raise ValueError(
                    f"{self.__class__.__name__}.density with polarized spin, requires spinor "
                    "argument as an integer, or a vector of length 2"
                )

            csrDM = csr.tocsr(dim=0) * spinor[0] + csr.tocsr(dim=1) * spinor[1]

        else:
            csrDM = csr.tocsr(dim=0)

        if method == "pre-compute":
            raise NotImplementedError(
                "Currently a memory bug is happening, cannot be used until fixed, use the direct method"
            )
            try:
                # Compute orbital values on the grid
                psi_values = uc_dm.geometry._orbital_values(grid.shape)

                psi_values.reduce_orbital_products(
                    csrDM, uc_dm.lattice, out=grid.grid, **kwargs
                )
            except MemoryError:
                raise MemoryError(
                    "Ran out of memory while computing the density with the 'pre-compute'"
                    " method. Try using method='direct', which is slower but requires much"
                    " less memory."
                )
        elif method == "direct":
            self._density_direct(grid, csrDM, atol=atol, eta=eta)

    def _density_direct(
        self, grid: Grid, csrDM, atol: float = 1e-7, eta: Optional[bool] = None
    ):
        r"""Compute the density by calculating the orbital values on the fly.

        Parameters
        ----------
        grid : Grid
           the grid on which to add the density (the density is in ``e/Ang^3``)
        spinor : (2,) or (2, 2), optional
           the spinor matrix to obtain the diagonal components of the density. For un-polarized density matrices
           this keyword has no influence. For spin-polarized it *has* to be either 1 integer or a vector of
           length 2 (defaults to total density).
           For non-collinear/spin-orbit density matrices it has to be a 2x2 matrix (defaults to total density).
        atol : float, optional
           DM tolerance for accepted values. For all density matrix elements with absolute values below
           the tolerance, they will be treated as strictly zeros.
        eta : bool, optional
           show a progressbar on stdout
        """
        geometry = self.geometry

        # Extract sub variables used throughout the loop
        shape = _a.asarrayi(grid.shape)
        dcell = grid.dcell

        # In the following we don't care about division
        # So 1) save error state, 2) turn off divide by 0, 3) calculate, 4) turn on old error state
        old_err = np.seterr(divide="ignore", invalid="ignore")

        # To heavily speed up the construction of the density we can recreate
        # the sparse csrDM matrix by summing the lower and upper triangular part.
        # This means we only traverse the sparse UPPER part of the DM matrix
        # I.e.:
        #    psi_i * DM_{ij} * psi_j + psi_j * DM_{ji} * psi_i
        # is equal to:
        #    psi_i * (DM_{ij} + DM_{ji}) * psi_j
        # Secondly, to ease the loops we extract the main diagonal (on-site terms)
        # and store this for separate usage
        csr_sum = [None] * geometry.n_s
        no = geometry.no
        primary_i_s = geometry.sc_index([0, 0, 0])
        for i_s in range(geometry.n_s):
            # Extract the csr matrix
            o_start, o_end = i_s * no, (i_s + 1) * no
            csr = csrDM[:, o_start:o_end]
            if i_s == primary_i_s:
                csr_sum[i_s] = triu(csr) + tril(csr, -1).transpose()
            else:
                csr_sum[i_s] = csr

        # Recreate the column-stacked csr matrix
        csrDM = ss_hstack(csr_sum, format="csr")
        del csr, csr_sum

        # Remove all zero elements (note we use the tolerance here!)
        csrDM.data = np.where(np.fabs(csrDM.data) > atol, csrDM.data, 0.0)

        # Eliminate zeros and sort indices etc.
        csrDM.eliminate_zeros()
        csrDM.sort_indices()
        csrDM.prune()

        # 1. Ensure the grid has a geometry associated with it
        lattice = grid.lattice.copy()
        # Find the periodic directions
        pbc = [
            bc == BC.PERIODIC or geometry.nsc[i] > 1
            for i, bc in enumerate(grid.lattice.boundary_condition[:, 0])
        ]
        if grid.geometry is None:
            # Create the actual geometry that encompass the grid
            ia, xyz, _ = geometry.within_inf(lattice, periodic=pbc)
            if len(ia) > 0:
                grid.set_geometry(Geometry(xyz, geometry.atoms[ia], lattice=lattice))

        # Instead of looping all atoms in the supercell we find the exact atoms
        # and their supercell indices.
        add_R = _a.fulld(3, geometry.maxR())
        # Calculate the required additional vectors required to increase the fictitious
        # supercell by add_R in each direction.
        # For extremely skewed lattices this will be way too much, hence we make
        # them square.
        o = lattice.to.Cuboid(orthogonal=True)
        lattice = Lattice(o._v + np.diag(2 * add_R), origin=o.origin - add_R)

        # Retrieve all atoms within the grid supercell
        # (and the neighbors that connect into the cell)
        IA, XYZ, ISC = geometry.within_inf(lattice, periodic=pbc)
        XYZ -= grid.lattice.origin.reshape(1, 3)

        # Retrieve progressbar
        eta = progressbar(len(IA), f"{self.__class__.__name__}.density", "atom", eta)

        cell = geometry.cell
        atoms = geometry.atoms
        axyz = geometry.axyz
        a2o = geometry.a2o

        def xyz2spherical(xyz, offset):
            """Calculate the spherical coordinates from indices"""
            rx = xyz[:, 0] - offset[0]
            ry = xyz[:, 1] - offset[1]
            rz = xyz[:, 2] - offset[2]

            # Calculate radius ** 2
            xyz_to_spherical_cos_phi(rx, ry, rz)
            return rx, ry, rz

        def xyz2sphericalR(xyz, offset, R):
            """Calculate the spherical coordinates from indices"""
            rx = xyz[:, 0] - offset[0]
            idx = indices_fabs_le(rx, R)
            ry = xyz[idx, 1] - offset[1]
            ix = indices_fabs_le(ry, R)
            ry = ry[ix]
            idx = idx[ix]
            rz = xyz[idx, 2] - offset[2]
            ix = indices_fabs_le(rz, R)
            ry = ry[ix]
            rz = rz[ix]
            idx = idx[ix]
            if len(idx) == 0:
                return [], [], [], []
            rx = rx[idx]

            # Calculate radius ** 2
            ix = indices_le(rx**2 + ry**2 + rz**2, R**2)
            idx = idx[ix]
            if len(idx) == 0:
                return [], [], [], []
            rx = rx[ix]
            ry = ry[ix]
            rz = rz[ix]
            xyz_to_spherical_cos_phi(rx, ry, rz)
            return idx, rx, ry, rz

        # Looping atoms in the sparse pattern is better since we can pre-calculate
        # the radial parts and then add them.
        # First create a SparseOrbital matrix, then convert to SparseAtom
        spO = SparseOrbital(geometry, dtype=np.int16)
        spO._csr = SparseCSR(csrDM)
        spA = spO.toSparseAtom(dtype=np.int16)
        del spO
        na = geometry.na
        # Remove the diagonal part of the sparse atom matrix
        off = na * primary_i_s
        for ia in range(na):
            del spA[ia, off + ia]

        # Get pointers and delete the atomic sparse pattern
        # The below complexity is because we are not finalizing spA
        csr = spA._csr
        a_ptr = _ncol_to_indptr(csr.ncol)
        a_col = csr.col[_a.array_arange(csr.ptr, n=csr.ncol)]
        del spA, csr

        # Get offset in supercell in orbitals
        off = geometry.no * primary_i_s
        origin = grid.origin
        # TODO sum the non-origin atoms to the csrDM matrix
        #      this would further decrease the loops required.

        # Loop over all atoms in the grid-cell
        for ia, ia_xyz, isc in zip(IA, XYZ, ISC):
            # Get current atom
            ia_atom = atoms[ia]
            IO = a2o(ia)
            IO_range = range(ia_atom.no)
            cell_offset = (cell * isc.reshape(3, 1)).sum(0) - origin

            # Extract maximum R
            R = ia_atom.maxR()
            if R <= 0.0:
                warn(f"Atom '{ia_atom}' does not have a wave-function, skipping atom.")
                eta.update()
                continue

            # Retrieve indices of the grid for the atomic shape
            idx = grid.index(ia_atom.to.Sphere(center=ia_xyz))

            # Now we have the indices for the largest orbital on the atom

            # Subsequently we have to loop the orbitals and the
            # connecting orbitals
            # Then we find the indices that overlap with these indices
            # First reduce indices to inside the grid-cell
            idx[idx[:, 0] < 0, 0] = 0
            idx[shape[0] <= idx[:, 0], 0] = shape[0] - 1
            idx[idx[:, 1] < 0, 1] = 0
            idx[shape[1] <= idx[:, 1], 1] = shape[1] - 1
            idx[idx[:, 2] < 0, 2] = 0
            idx[shape[2] <= idx[:, 2], 2] = shape[2] - 1

            idx = unique(idx, axis=0)
            if len(idx) == 0:
                eta.update()
                continue

            # Get real-space coordinates for the current atom
            # as well as the radial parts
            grid_xyz = dot(idx, dcell)

            # Perform loop on connection atoms
            # Allocate the DM_pj arrays
            # This will have a size equal to number of elements times number of
            # orbitals on this atom
            # In this way we do not have to calculate the psi_j multiple times
            DM_io = csrDM[IO : IO + ia_atom.no, :].tolil()
            DM_pj = _a.zerosd([ia_atom.no, grid_xyz.shape[0]])

            # Now we perform the loop on the connections for this atom
            # Remark that we have removed the diagonal atom (it-self)
            # As that will be calculated in the end
            for ja in a_col[a_ptr[ia] : a_ptr[ia + 1]]:
                # Retrieve atom (which contains the orbitals)
                ja_atom = atoms[ja % na]
                JO = a2o(ja)
                jR = ja_atom.maxR()
                # Get actual coordinate of the atom
                ja_xyz = axyz(ja) + cell_offset

                # Reduce the ia'th grid points to those that connects to the ja'th atom
                ja_idx, ja_r, ja_theta, ja_cos_phi = xyz2sphericalR(
                    grid_xyz, ja_xyz, jR
                )

                if len(ja_idx) == 0:
                    # Quick step
                    continue

                # Loop on orbitals on this atom
                for jo in range(ja_atom.no):
                    o = ja_atom.orbitals[jo]
                    oR = o.R

                    # Downsize to the correct indices
                    if jR - oR < 1e-6:
                        ja_idx1 = ja_idx
                        ja_r1 = ja_r
                        ja_theta1 = ja_theta
                        ja_cos_phi1 = ja_cos_phi
                    else:
                        ja_idx1 = indices_le(ja_r, oR)
                        if len(ja_idx1) == 0:
                            # Quick step
                            continue

                        # Reduce arrays
                        ja_r1 = ja_r[ja_idx1]
                        ja_theta1 = ja_theta[ja_idx1]
                        ja_cos_phi1 = ja_cos_phi[ja_idx1]
                        ja_idx1 = ja_idx[ja_idx1]

                    # Calculate the psi_j component
                    psi = o.psi_spher(ja_r1, ja_theta1, ja_cos_phi1, cos_phi=True)

                    # Now add this orbital to all components
                    for io in IO_range:
                        DM_pj[io, ja_idx1] += DM_io[io, JO + jo] * psi

                # Temporary clean up
                del ja_idx, ja_r, ja_theta, ja_cos_phi
                del ja_idx1, ja_r1, ja_theta1, ja_cos_phi1, psi

            # Now we have all components for all orbitals connection to all orbitals on atom
            # ia. We simply need to add the diagonal components

            # Loop on the orbitals on this atom
            ia_r, ia_theta, ia_cos_phi = xyz2spherical(grid_xyz, ia_xyz)
            del grid_xyz
            for io in IO_range:
                # Only loop halve the range.
                # This is because: triu + tril(-1).transpose()
                # removes the lower half of the on-site matrix.
                for jo in range(io + 1, ia_atom.no):
                    DM = DM_io[io, off + IO + jo]

                    oj = ia_atom.orbitals[jo]
                    ojR = oj.R

                    # Downsize to the correct indices
                    if R - ojR < 1e-6:
                        ja_idx1 = slice(None)
                        ja_r1 = ia_r
                        ja_theta1 = ia_theta
                        ja_cos_phi1 = ia_cos_phi
                    else:
                        ja_idx1 = indices_le(ia_r, ojR)
                        if len(ja_idx1) == 0:
                            # Quick step
                            continue

                        # Reduce arrays
                        ja_r1 = ia_r[ja_idx1]
                        ja_theta1 = ia_theta[ja_idx1]
                        ja_cos_phi1 = ia_cos_phi[ja_idx1]

                    # Calculate the psi_j component
                    DM_pj[io, ja_idx1] += DM * oj.psi_spher(
                        ja_r1, ja_theta1, ja_cos_phi1, cos_phi=True
                    )

                # Calculate the psi_i component
                # Note that this one *also* zeroes points outside the shell
                # I.e. this step is important because it "nullifies" all but points where
                # orbital io is defined.
                psi = ia_atom.orbitals[io].psi_spher(
                    ia_r, ia_theta, ia_cos_phi, cos_phi=True
                )
                DM_pj[io, :] += DM_io[io, off + IO + io] * psi
                DM_pj[io, :] *= psi

            # Temporary clean up
            ja_idx1 = ja_r1 = ja_theta1 = ja_cos_phi1 = None
            del ia_r, ia_theta, ia_cos_phi, psi, DM_io

            # Now add the density
            grid.grid[idx[:, 0], idx[:, 1], idx[:, 2]] += DM_pj.sum(0)

            # Clean-up
            del DM_pj, idx

            eta.update()
        eta.close()

        # Reset the error code for division
        np.seterr(**old_err)


@set_module("sisl.physics")
class DensityMatrix(_densitymatrix):
    """Sparse density matrix object

    Assigning or changing elements is as easy as with standard `numpy` assignments:

    >>> DM = DensityMatrix(...)
    >>> DM.D[1,2] = 0.1

    which assigns 0.1 as the density element between orbital 2 and 3.
    (remember that Python is 0-based elements).

    For spin matrices the elements are defined with an extra dimension.

    For a polarized matrix:

    >>> M = DensityMatrix(..., spin="polarized")
    >>> M[0, 0, 0] = # onsite spin up
    >>> M[0, 0, 1] = # onsite spin down

    For non-colinear the indices are a bit more tricky:

    >>> M = DensityMatrix(..., spin="non-colinear")
    >>> M[0, 0, M.M11] = # Re(up-up)
    >>> M[0, 0, M.M22] = # Re(down-down)
    >>> M[0, 0, M.M12r] = # Re(up-down)
    >>> M[0, 0, M.M12i] = # Im(up-down)

    For spin-orbit it looks like this:

    >>> M = DensityMatrix(..., spin="spin-orbit")
    >>> M[0, 0, M.M11r] = # Re(up-up)
    >>> M[0, 0, M.M11i] = # Im(up-up)
    >>> M[0, 0, M.M22r] = # Re(down-down)
    >>> M[0, 0, M.M22i] = # Im(down-down)
    >>> M[0, 0, M.M12r] = # Re(up-down)
    >>> M[0, 0, M.M12i] = # Im(up-down)
    >>> M[0, 0, M.M21r] = # Re(down-up)
    >>> M[0, 0, M.M21i] = # Im(down-up)

    Thus the number of *orbitals* is unchanged but a sub-block exists for
    the spin-block.

    When transferring the matrix to a k-point the spin-box is local to each
    orbital, meaning that the spin-box for orbital i will be:

    >>> Dk = M.Dk()
    >>> Dk[i*2:(i+1)*2, i*2:(i+1)*2]

    Parameters
    ----------
    geometry : Geometry
      parent geometry to create a density matrix from. The density matrix will
      have size equivalent to the number of orbitals in the geometry
    dim : int or Spin, optional
      number of components per element, may be a `Spin` object
    dtype : np.dtype, optional
      data type contained in the density matrix. See details of `Spin` for default values.
    nnzpr : int, optional
      number of initially allocated memory per orbital in the density matrix.
      For increased performance this should be larger than the actual number of entries
      per orbital.
    spin : Spin, optional
      equivalent to `dim` argument. This keyword-only argument has precedence over `dim`.
    orthogonal : bool, optional
      whether the density matrix corresponds to a non-orthogonal basis. In this case
      the dimensionality of the density matrix is one more than `dim`.
      This is a keyword-only argument.
    """

    def __init__(self, geometry, dim=1, dtype=None, nnzpr=None, **kwargs):
        """Initialize density matrix"""
        super().__init__(geometry, dim, dtype, nnzpr, **kwargs)
        self._reset()

    def _reset(self):
        super()._reset()
        self.Dk = self.Pk
        self.dDk = self.dPk
        self.ddDk = self.ddPk

    @property
    def D(self):
        r"""Access the density matrix elements"""
        self._def_dim = self.UP
        return self

    def orbital_momentum(self, projection="orbital", method="onsite"):
        r"""Calculate orbital angular momentum on either atoms or orbitals

        Currently this implementation equals the Siesta implementation in that
        the on-site approximation is enforced thus limiting the calculated quantities
        to obey the following conditions:

        1. Same atom
        2. :math:`l>0`
        3. :math:`l_i \equiv l_j`
        4. :math:`m_i \neq m_j`
        5. :math:`\zeta_i \equiv \zeta_j`

        This allows one to sum the orbital angular moments on a per atom site.

        Parameters
        ----------
        projection : {'orbital', 'atom'}
            whether the angular momentum is resolved per atom, or per orbital
        method : {'onsite'}
            method used to calculate the angular momentum

        Returns
        -------
        numpy.ndarray
            orbital angular momentum with the last dimension equalling the :math:`L_x`, :math:`L_y` and :math:`L_z` components
        """
        # Check that the spin configuration is correct
        if not self.spin.is_spinorbit:
            raise ValueError(
                f"{self.__class__.__name__}.orbital_momentum requires a spin-orbit matrix"
            )

        # First we calculate
        orb_lmZ = _a.emptyi([self.no, 3])
        for atom, idx in self.geometry.atoms.iter(True):
            # convert to FIRST orbital index per atom
            oidx = self.geometry.a2o(idx)
            # loop orbitals
            for io, orb in enumerate(atom):
                orb_lmZ[oidx + io, :] = orb.l, orb.m, orb.zeta

        # Now we need to calculate the stuff
        DM = self.copy()
        # The Siesta convention *only* calculates contributions
        # in the primary unit-cell.
        DM.set_nsc([1] * 3)
        geom = DM.geometry
        csr = DM._csr

        # The siesta moments are only *on-site* per atom.
        # 1. create a logical index for the matrix elements
        #    that is true for ia-ia interaction and false
        #    otherwise
        idx = repeat(_a.arangei(geom.no), csr.ncol)
        aidx = geom.o2a(idx)

        # Sparse matrix indices for data
        sidx = _a.array_arange(csr.ptr[:-1], n=csr.ncol, dtype=np.int32)
        jdx = csr.col[sidx]
        ajdx = geom.o2a(jdx)

        # Now only take the elements that are *on-site* and which are *not*
        # having the same m quantum numbers (if the orbital index is the same
        # it means they have the same m quantum number)
        #
        # 1. on the same atom
        # 2. l > 0
        # 3. same quantum number l
        # 4. different quantum number m
        # 5. same zeta
        onsite_idx = (
            (aidx == ajdx)
            & (orb_lmZ[idx, 0] > 0)
            & (orb_lmZ[idx, 0] == orb_lmZ[jdx, 0])
            & (orb_lmZ[idx, 1] != orb_lmZ[jdx, 1])
            & (orb_lmZ[idx, 2] == orb_lmZ[jdx, 2])
        ).nonzero()[0]
        # clean variables we don't need
        del aidx, ajdx

        # Now reduce arrays to the orbital connections that obey the
        # above criteria
        idx = idx[onsite_idx]
        idx_l = orb_lmZ[idx, 0]
        idx_m = orb_lmZ[idx, 1]
        jdx = jdx[onsite_idx]
        jdx_m = orb_lmZ[jdx, 1]
        sidx = sidx[onsite_idx]

        # Sum the spin-box diagonal imaginary parts
        DM = csr._D[sidx][:, [4, 5]].sum(1)

        # Define functions to calculate L projections
        def La(idx_l, DM, sub):
            if len(sub) == 0:
                return []
            return (idx_l[sub] * (idx_l[sub] + 1) * 0.5) ** 0.5 * DM[sub]

        def Lb(idx_l, DM, sub):
            if len(sub) == 0:
                return []
            return (idx_l[sub] * (idx_l[sub] + 1) - 2) ** 0.5 * 0.5 * DM[sub]

        def Lc(idx, idx_l, DM, sub):
            if len(sub) == 0:
                return [], []
            sub = sub[idx_l[sub] >= 3]
            if len(sub) == 0:
                return [], []
            return idx[sub], (idx_l[sub] * (idx_l[sub] + 1) - 6) ** 0.5 * 0.5 * DM[sub]

        # construct for different m
        # in Siesta the spin orbital angular momentum
        # is calculated by swapping i and j indices.
        # This is somewhat confusing to me, so I reversed everything.
        # This will probably add to the confusion when comparing the two
        # Additionally Siesta calculates L for <i|L|j> and then does:
        #    L(:) = [L(3), -L(2), -L(1)]
        # Here we *directly* store the quantities used.
        # Pre-allocate the L_xyz quantity per orbital.
        L = np.zeros([3, geom.no])
        L0 = L[0]
        L1 = L[1]
        L2 = L[2]

        # Pre-calculate all those which have m_i + m_j == 0
        b = (idx_m + jdx_m == 0).nonzero()[0]
        subtract.at(L2, idx[b], idx_m[b] * DM[b])
        del b

        #   mi == 0
        i_m = idx_m == 0
        #     mj == -1
        sub = logical_and(i_m, jdx_m == -1).nonzero()[0]
        subtract.at(L0, idx[sub], La(idx_l, DM, sub))
        #     mj == 1
        sub = logical_and(i_m, jdx_m == 1).nonzero()[0]
        add.at(L1, idx[sub], La(idx_l, DM, sub))

        #   mi == 1
        i_m = idx_m == 1
        #     mj == -2
        sub = logical_and(i_m, jdx_m == -2).nonzero()[0]
        subtract.at(L0, idx[sub], Lb(idx_l, DM, sub))
        #     mj == 0
        sub = logical_and(i_m, jdx_m == 0).nonzero()[0]
        subtract.at(L1, idx[sub], La(idx_l, DM, sub))
        #     mj == 2
        sub = logical_and(i_m, jdx_m == 2).nonzero()[0]
        add.at(L1, idx[sub], Lb(idx_l, DM, sub))

        #   mi == -1
        i_m = idx_m == -1
        #     mj == -2
        sub = logical_and(i_m, jdx_m == -2).nonzero()[0]
        add.at(L1, idx[sub], Lb(idx_l, DM, sub))
        #     mj == 0
        sub = logical_and(i_m, jdx_m == 0).nonzero()[0]
        add.at(L0, idx[sub], La(idx_l, DM, sub))
        #     mj == 2
        sub = logical_and(i_m, jdx_m == 2).nonzero()[0]
        add.at(L0, idx[sub], Lb(idx_l, DM, sub))

        #   mi == 2
        i_m = idx_m == 2
        #     mj == -3
        sub = logical_and(i_m, jdx_m == -3).nonzero()[0]
        subtract.at(L0, *Lc(idx, idx_l, DM, sub))
        #     mj == -1
        sub = logical_and(i_m, jdx_m == -1).nonzero()[0]
        subtract.at(L0, idx[sub], Lb(idx_l, DM, sub))
        #     mj == 1
        sub = logical_and(i_m, jdx_m == 1).nonzero()[0]
        subtract.at(L1, idx[sub], Lb(idx_l, DM, sub))
        #     mj == 3
        sub = logical_and(i_m, jdx_m == 3).nonzero()[0]
        add.at(L1, *Lc(idx, idx_l, DM, sub))

        #   mi == -2
        i_m = idx_m == -2
        #     mj == -3
        sub = logical_and(i_m, jdx_m == -3).nonzero()[0]
        add.at(L1, *Lc(idx, idx_l, DM, sub))
        #     mj == -1
        sub = logical_and(i_m, jdx_m == -1).nonzero()[0]
        subtract.at(L1, idx[sub], Lb(idx_l, DM, sub))
        #     mj == 1
        sub = logical_and(i_m, jdx_m == 1).nonzero()[0]
        add.at(L0, idx[sub], Lb(idx_l, DM, sub))
        #     mj == 3
        sub = logical_and(i_m, jdx_m == 3).nonzero()[0]
        add.at(L0, *Lc(idx, idx_l, DM, sub))

        #   mi == -3
        i_m = idx_m == -3
        #     mj == -2
        sub = logical_and(i_m, jdx_m == -2).nonzero()[0]
        subtract.at(L1, *Lc(idx, idx_l, DM, sub))
        #     mj == 2
        sub = logical_and(i_m, jdx_m == 2).nonzero()[0]
        add.at(L0, *Lc(idx, idx_l, DM, sub))

        #   mi == 3
        i_m = idx_m == 3
        #     mj == -2
        sub = logical_and(i_m, jdx_m == -2).nonzero()[0]
        subtract.at(L0, *Lc(idx, idx_l, DM, sub))
        #     mj == 2
        sub = logical_and(i_m, jdx_m == 2).nonzero()[0]
        subtract.at(L1, *Lc(idx, idx_l, DM, sub))

        projection = projection.lower()
        if projection.startswith("orbital"):  # allows orbitals
            return L

        if projection.startswith("atom"):  # allows atoms
            # Now perform summation per atom
            l = np.zeros([3, geom.na], dtype=L.dtype)
            add.at(l.T, geom.o2a(np.arange(geom.no)), L.T)
            return l

        raise ValueError(
            f"{self.__class__.__name__}.orbital_momentum only allows projection [orbital, atom]"
        )

    def Dk(
        self,
        k=(0, 0, 0),
        dtype=None,
        gauge: GaugeType = "cell",
        format="csr",
        *args,
        **kwargs,
    ):
        r"""Setup the density matrix for a given k-point

        Creation and return of the density matrix for a given k-point (default to Gamma).

        Notes
        -----

        Currently the implemented gauge for the k-point is the cell vector gauge:

        .. math::
           \mathbf D(\mathbf k) = \mathbf D_{ij} e^{i \mathbf k\cdot\mathbf R}

        where :math:`\mathbf R` is an integer times the cell vector and :math:`i`, :math:`j` are orbital indices.

        Another possible gauge is the atomic distance which can be written as

        .. math::
           \mathbf D(\mathbf k) = \mathbf D_{ij} e^{i \mathbf k\cdot\mathb r}

        where :math:`\mathbf r` is the distance between the orbitals.

        Parameters
        ----------
        k : array_like
           the k-point to setup the density matrix at
        dtype : numpy.dtype , optional
           the data type of the returned matrix. Do NOT request non-complex
           data-type for non-Gamma k.
           The default data-type is `numpy.complex128`
        gauge :
           the chosen gauge, ``cell`` for cell vector gauge, and ``atom`` for atomic distance
           gauge.
        format : {'csr', 'array', 'dense', 'coo', ...}
           the returned format of the matrix, defaulting to the `scipy.sparse.csr_matrix`,
           however if one always requires operations on dense matrices, one can always
           return in `numpy.ndarray` (`'array'`/`'dense'`/`'matrix'`).
           Prefixing with 'sc:', or simply 'sc' returns the matrix in supercell format
           with phases.
        spin : int, optional
           if the density matrix is a spin polarized one can extract the specific spin direction
           matrix by passing an integer (0 or 1). If the density matrix is not `Spin.POLARIZED`
           this keyword is ignored.

        See Also
        --------
        dDk : Density matrix derivative with respect to `k`
        ddDk : Density matrix double derivative with respect to `k`

        Returns
        -------
        matrix : numpy.ndarray or scipy.sparse.*_matrix
            the density matrix at :math:`\mathbf k`. The returned object depends on `format`.
        """
        pass

    def dDk(
        self,
        k=(0, 0, 0),
        dtype=None,
        gauge: GaugeType = "cell",
        format="csr",
        *args,
        **kwargs,
    ):
        r"""Setup the density matrix derivative for a given k-point

        Creation and return of the density matrix derivative for a given k-point (default to Gamma).

        Notes
        -----

        Currently the implemented gauge for the k-point is the cell vector gauge:

        .. math::
           \nabla_k \mathbf D_\alpha(\mathbf k) = i \mathbf R_\alpha \mathbf D_{ij} e^{i \mathbf k\cdot\mathbf R}

        where :math:`\mathbf R` is an integer times the cell vector and :math:`i`, :math:`j` are orbital indices.
        And :math:`\alpha` is one of the Cartesian directions.

        Another possible gauge is the atomic distance which can be written as

        .. math::
           \nabla_k \mathbf D_\alpha(\mathbf k) = i \mathbf r_\alpha \mathbf D_{ij} e^{i \mathbf k\cdot\mathbf r}

        where :math:`\mathbf r` is the distance between the orbitals.

        Parameters
        ----------
        k : array_like
           the k-point to setup the density matrix at
        dtype : numpy.dtype , optional
           the data type of the returned matrix. Do NOT request non-complex
           data-type for non-Gamma k.
           The default data-type is `numpy.complex128`
        gauge :
           the chosen gauge, ``cell`` for cell vector gauge, and ``atom`` for atomic distance
           gauge.
        format : {'csr', 'array', 'dense', 'coo', ...}
           the returned format of the matrix, defaulting to the `scipy.sparse.csr_matrix`,
           however if one always requires operations on dense matrices, one can always
           return in `numpy.ndarray` (`'array'`/`'dense'`/`'matrix'`).
        spin : int, optional
           if the density matrix is a spin polarized one can extract the specific spin direction
           matrix by passing an integer (0 or 1). If the density matrix is not `Spin.POLARIZED`
           this keyword is ignored.

        See Also
        --------
        Dk : Density matrix with respect to `k`
        ddDk : Density matrix double derivative with respect to `k`

        Returns
        -------
        tuple
             for each of the Cartesian directions a :math:`\partial \mathbf D(\mathbf k)/\partial\mathbf k` is returned.
        """
        pass

    def ddDk(
        self,
        k=(0, 0, 0),
        dtype=None,
        gauge: GaugeType = "cell",
        format="csr",
        *args,
        **kwargs,
    ):
        r"""Setup the density matrix double derivative for a given k-point

        Creation and return of the density matrix double derivative for a given k-point (default to Gamma).

        Notes
        -----

        Currently the implemented gauge for the k-point is the cell vector gauge:

        .. math::
           \nabla_k^2 \mathbf D^{(alpha\beta)}(\mathbf k) = - \mathbf R_\alpha \mathbf R_\beta \mathbf D_{ij} e^{i \mathbf k\cdot\mathbf R}

        where :math:`\mathbf R` is an integer times the cell vector and :math:`i`, :math:`j` are orbital indices.
        And :math:`\alpha` and :math:`\beta` are one of the Cartesian directions.

        Another possible gauge is the atomic distance which can be written as

        .. math::
           \nabla_k^2 \mathbf D^{(\alpha\beta)}(\mathbf k) = - \mathbf r^{(i)} \mathbf r^{\beta} \mathbf D_{ij} e^{i\mathbf k\cdot\mathbf r}

        where :math:`\mathbf r` is the distance between the orbitals.

        Parameters
        ----------
        k : array_like
           the k-point to setup the density matrix at
        dtype : numpy.dtype , optional
           the data type of the returned matrix. Do NOT request non-complex
           data-type for non-Gamma k.
           The default data-type is `numpy.complex128`
        gauge :
           the chosen gauge, ``cell`` for cell vector gauge, and ``atom`` for atomic distance
           gauge.
        format : {'csr', 'array', 'dense', 'coo', ...}
           the returned format of the matrix, defaulting to the `scipy.sparse.csr_matrix`,
           however if one always requires operations on dense matrices, one can always
           return in `numpy.ndarray` (`'array'`/`'dense'`/`'matrix'`).
        spin : int, optional
           if the density matrix is a spin polarized one can extract the specific spin direction
           matrix by passing an integer (0 or 1). If the density matrix is not `Spin.POLARIZED`
           this keyword is ignored.

        See Also
        --------
        Dk : Density matrix with respect to `k`
        dDk : Density matrix derivative with respect to `k`

        Returns
        -------
        list of matrices
            for each of the Cartesian directions (in Voigt representation); xx, yy, zz, zy, xz, xy
        """
        pass

    @staticmethod
    def read(sile, *args, **kwargs):
        """Reads density matrix from `Sile` using `read_density_matrix`.

        Parameters
        ----------
        sile : Sile, str or pathlib.Path
            a `Sile` object which will be used to read the density matrix
            and the overlap matrix (if any)
            if it is a string it will create a new sile using `get_sile`.
        * : args passed directly to ``read_density_matrix(,**)``
        """
        # This only works because, they *must*
        # have been imported previously
        from sisl.io import BaseSile, get_sile

        if isinstance(sile, BaseSile):
            return sile.read_density_matrix(*args, **kwargs)
        else:
            with get_sile(sile, mode="r") as fh:
                return fh.read_density_matrix(*args, **kwargs)
