Source code for aimspy.matrix

"""Public — AimspyMatrix + aims↔aimspy format conversions.

The aimspy standard matrix format is a block-sparse real-space
representation:

    blocks: dict[tuple[int, int, int, int, int], np.ndarray]
           key = (R1, R2, R3, i_atom, j_atom)

Conventions
-----------
- *R*: ``R_aimspy = -R_aims`` (same sign as DeepH).
- *Atoms*: aims native order (no reordering).
- *Orbitals*: aims native basis order (no reordering).
- *Parity*: wiki/DeepH convention (``phase_i * phase_j`` already applied).
- *Units*: Hartree.
- *Hermitian partners*: both ``(R,i,j)`` and ``(-R,j,i)`` stored.
- *Spin*: ``n_spin=1`` blocks are ``(n_orb_i, n_orb_j)``.  ``n_spin=2``
  (collinear) blocks are stacked ``(2*n_orb_i, n_orb_j)``: the alpha
  (spin-up) channel occupies rows ``[0, n_orb_i)`` and the beta
  (spin-down) channel rows ``[n_orb_i, 2*n_orb_i)``.  Spin-independent
  matrices (the overlap) always use ``n_spin=1`` blocks.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Dict, Optional, Tuple

import numpy as np

from .data import CsrMatrixDescriptor
from .structure import AimspyStructure


# =============================================================================
# Helper: pointer to ndarray copy  (unchanged from earlier version)
# =============================================================================
def _ptr_to_ndarray(ptr, shape, dtype=np.float64) -> np.ndarray:
    from ctypes import cast, c_void_p, POINTER, c_double as _cd

    n = 1
    for d in shape:
        n *= d
    try:
        flat = np.ctypeslib.as_array(ptr, shape=(n,))
    except Exception:
        flat = np.ctypeslib.as_array(cast(c_void_p(ptr), POINTER(_cd)), shape=(n,))
    return np.ascontiguousarray(flat.reshape(shape), dtype=dtype).copy()


# =============================================================================
# Accessors — read Fortran arrays through ctypes  (unchanged)
# =============================================================================
def get_rs_hamiltonian(binding, n_spin: int, n_ham_size: int) -> np.ndarray:
    from ._exceptions import AimspyBindingError

    ptr = binding.c_rs_hamiltonian()
    if not ptr:
        raise AimspyBindingError("c_rs_hamiltonian() returned NULL")
    return _ptr_to_ndarray(ptr, (n_spin, n_ham_size))


def get_rs_overlap(binding, n_ham_size: int) -> np.ndarray:
    from ._exceptions import AimspyBindingError

    ptr = binding.c_rs_overlap()
    if not ptr:
        raise AimspyBindingError("c_rs_overlap() returned NULL")
    return _ptr_to_ndarray(ptr, (n_ham_size,))


[docs] def get_forces(binding, n_atoms: int) -> Optional[np.ndarray]: """Read total_forces (3, n_atoms) Fortran array → (n_atoms, 3) eV/Å. Fortran stores total_forces in Hartree/Bohr; we convert to eV/Å (the same convention FHI-aims uses for printed forces in aims.out). Returns None if use_forces=False (Fortran returns c_null_ptr when `compute_forces .true.` was not set in control.in). """ from .data import HARTREE_TO_EV, BOHR_TO_ANG ptr = binding.aimspy_forces() if not ptr: return None # use_forces=False — forces not computed # Fortran (3, n_atoms) column-major → (n_atoms, 3) raw = _ptr_to_ndarray(ptr, (n_atoms, 3)) # Hartree/Bohr → eV/Å return raw * (HARTREE_TO_EV / BOHR_TO_ANG)
[docs] def get_stress(binding) -> Optional[np.ndarray]: """Read the final analytical stress tensor in eV/ų. FHI-aims stores the tensor as a Fortran ``(3, 3)`` array in Hartree/Bohr³. The sign convention is preserved exactly as reported by FHI-aims. Returns ``None`` when analytical stress was not computed. """ from .data import HARTREE_TO_EV, BOHR_TO_ANG ptr = binding.aimspy_stress() if not ptr: return None flat = _ptr_to_ndarray(ptr, (9,)) raw = flat.reshape((3, 3), order="F") return np.ascontiguousarray(raw * (HARTREE_TO_EV / BOHR_TO_ANG**3))
# ============================================================================= # AimspyMatrix — canonical block-sparse matrix in aimspy standard format # =============================================================================
[docs] @dataclass class AimspyMatrix: """Block-sparse real-space matrix in aimspy standard format. Key = ``(R1, R2, R3, i_atom, j_atom)`` with all ints: - R follows ``R_aimspy = -R_aims`` (same sign as DeepH). - i_atom / j_atom in aims native order. - Orbital order within each atom is aims native. - Parity = wiki/DeepH (phase already applied). - Units = Hartree. Block shape: - ``n_spin == 1``: ``(n_orb_i, n_orb_j)``. - ``n_spin == 2`` (collinear): ``(2*n_orb_i, n_orb_j)`` with the alpha (spin-up) channel in rows ``[0, n_orb_i)`` and the beta (spin-down) channel in rows ``[n_orb_i, 2*n_orb_i)``. """ blocks: Dict[Tuple[int, ...], np.ndarray] # key -> (n_spin*n_orb_i, n_orb_j) n_spin: int = 1 # ---------------------------------------------------------------- # aims CSR ↔ aimspy # ----------------------------------------------------------------
[docs] @classmethod def from_aims_csr( cls, h0: np.ndarray, # (n_spin, n_ham_size), C-contiguous csr_descr: CsrMatrixDescriptor, structure: AimspyStructure, ) -> "AimspyMatrix": """Convert aims CSR flat array to aimspy block dict. Steps: 1. Walk CSR triplanes (cell, basis‑row, k‑index). 2. R_aimspy = -R_aims (sign flip) → lookup key matches DeepH. 3. Apply wiki parity: ``v *= phase_i * phase_j``. 4. Store block[orb_i, orb_j] and its Hermitian partner. The number of spin channels is inferred from ``h0.shape[0]`` (the data), not from ``csr_descr.n_spin`` (the system): the spin-independent overlap arrives as ``(1, n_ham_size)`` even for spin-polarized systems and yields standard ``n_spin=1`` blocks, while the Hamiltonian arrives as ``(2, n_ham_size)`` and yields stacked ``(2*n_orb_i, n_orb_j)`` blocks (alpha rows first, beta rows second). Raises ------ AimspyError If ``h0.shape[0]`` is neither 1 nor 2, or if 2-channel data is combined with a descriptor whose ``n_spin != 2``. """ n_spin = int(h0.shape[0]) if n_spin not in (1, 2): from ._exceptions import AimspyError raise AimspyError( f"from_aims_csr: unsupported n_spin={n_spin}; expected 1 or 2" ) if n_spin == 2 and csr_descr.n_spin != 2: from ._exceptions import AimspyError raise AimspyError( f"from_aims_csr: 2-channel data (h0.shape[0]=2) but " f"csr_descr.n_spin={csr_descr.n_spin}" ) phase = structure.phase_factor subidx = structure.basis_subidx opa = [int(v) for v in structure.orbit_per_atom] blocks: dict = {} n_cells_loop = csr_descr.n_cells - 1 # skip sentinel n_ham = csr_descr.n_ham_size for ic in range(n_cells_loop): R0 = -int(csr_descr.cell_idx[0, ic]) # R_aimspy = -R_aims R1 = -int(csr_descr.cell_idx[1, ic]) R2 = -int(csr_descr.cell_idx[2, ic]) for ib_row in range(csr_descr.n_basis): start = int(csr_descr.row_mx_idx[ib_row, ic, 0]) end = int(csr_descr.row_mx_idx[ib_row, ic, 1]) if start < 1 or end < start: continue atom_i = int(structure.basis_atom[ib_row]) orb_i = int(subidx[ib_row]) pi = int(phase[ib_row]) oi = opa[atom_i] for k in range(start - 1, end): if k >= n_ham: continue # skip trash ib_col = int(csr_descr.col_mx_idx[k]) - 1 atom_j = int(structure.basis_atom[ib_col]) orb_j = int(subidx[ib_col]) pj = int(phase[ib_col]) oj = opa[atom_j] key = (R0, R1, R2, atom_i, atom_j) rev_key = (-R0, -R1, -R2, atom_j, atom_i) if key not in blocks: blocks[key] = np.zeros((n_spin * oi, oj), dtype=np.float64) if rev_key not in blocks: blocks[rev_key] = np.zeros((n_spin * oj, oi), dtype=np.float64) # Hermitian partner: write if unset, else verify consistency. # CSR stores upper-triangle only, so the reverse entry # (j,i) at -R should already equal (i,j) at R. If it was # previously written (abs > 1e-12), check agreement within # 1e-11 — well above double round-off (~1e-13 for |v|~1e3). if n_spin == 1: v = h0[0, k] * pi * pj # apply parity blocks[key][orb_i, orb_j] = v existing_rev = blocks[rev_key][orb_j, orb_i] if abs(existing_rev) <= 1e-12: blocks[rev_key][orb_j, orb_i] = v elif abs(existing_rev - v) > 1e-11: from ._exceptions import AimspyError raise AimspyError( f"Hermitian check failed at R=({R0},{R1},{R2}), " f"atom=({atom_i},{atom_j}), orb=({orb_i},{orb_j}): " f"existing={existing_rev:.6e}, new={v:.6e}" ) else: # n_spin == 2 — stacked alpha/beta channels. Both # channels are always written together, so probing the # beta row is sufficient to detect a prior write. # Known weakening (same "unwritten" heuristic as the # spinless path): beta-channel values that are exactly # zero skip the alpha-channel consistency check; the # last write still wins, so blocks stay Hermitian. v_up = h0[0, k] * pi * pj v_dn = h0[1, k] * pi * pj blocks[key][orb_i, orb_j] = v_up blocks[key][orb_i + oi, orb_j] = v_dn existing_rev = blocks[rev_key][orb_j + oj, orb_i] if abs(existing_rev) <= 1e-12: blocks[rev_key][orb_j, orb_i] = v_up blocks[rev_key][orb_j + oj, orb_i] = v_dn elif abs(existing_rev - v_dn) > 1e-11: from ._exceptions import AimspyError raise AimspyError( f"Hermitian check failed (spin channel 1) at " f"R=({R0},{R1},{R2}), " f"atom=({atom_i},{atom_j}), orb=({orb_i},{orb_j}): " f"existing={existing_rev:.6e}, new={v_dn:.6e}" ) return cls(blocks=blocks, n_spin=n_spin)
[docs] def to_aims_csr( self, csr_descr: CsrMatrixDescriptor, structure: AimspyStructure, ) -> np.ndarray: """Convert aimspy block dict back to aims CSR flat array. Steps: 1. Walk CSR triplanes (same order as ``from_aims_csr``). 2. Look up block in ``self.blocks`` (dict, O(1)). 3. Hermitian fallback: if forward key missing, try ``(-R, j, i)``. 4. Undo parity: ``v *= phase_i * phase_j`` (self‑inverse). 5. Return ``(n_spin, n_ham_size)`` C‑contiguous, ready to memmove. For stacked (``n_spin == 2``) blocks, the alpha row ``[orb_i, orb_j]`` and the beta row ``[orb_i + n_orb_i, orb_j]`` are unpacked into ``out[0, k]`` and ``out[1, k]``. Note the asymmetry with :meth:`from_aims_csr`: *reading* may be spin-independent (``(1, n_ham_size)`` overlap data combined with an ``n_spin == 2`` descriptor is accepted and yields standard blocks), but *writing* requires an exact ``n_spin`` match — a non-stacked matrix written through a spinful descriptor would produce a partially-zero Hamiltonian (not a valid non-polarized guess, which would require both channels equal), so it is rejected. Raises ------ AimspyError If the matrix ``n_spin`` does not match ``csr_descr.n_spin``. """ n_spin = csr_descr.n_spin if self.n_spin != n_spin: from ._exceptions import AimspyError raise AimspyError( f"to_aims_csr: matrix n_spin={self.n_spin} does not match " f"descriptor n_spin={n_spin}" ) stacked = self.n_spin == 2 phase = structure.phase_factor subidx = structure.basis_subidx opa = [int(v) for v in structure.orbit_per_atom] n_ham = csr_descr.n_ham_size out = np.zeros((n_spin, n_ham), dtype=np.float64) n_cells_loop = csr_descr.n_cells - 1 for ic in range(n_cells_loop): R0 = -int(csr_descr.cell_idx[0, ic]) R1 = -int(csr_descr.cell_idx[1, ic]) R2 = -int(csr_descr.cell_idx[2, ic]) for ib_row in range(csr_descr.n_basis): start = int(csr_descr.row_mx_idx[ib_row, ic, 0]) end = int(csr_descr.row_mx_idx[ib_row, ic, 1]) if start < 1 or end < start: continue atom_i = int(structure.basis_atom[ib_row]) orb_i = int(subidx[ib_row]) pi = int(phase[ib_row]) oi = opa[atom_i] for k in range(start - 1, end): if k >= n_ham: continue ib_col = int(csr_descr.col_mx_idx[k]) - 1 atom_j = int(structure.basis_atom[ib_col]) orb_j = int(subidx[ib_col]) pj = int(phase[ib_col]) oj = opa[atom_j] key = (R0, R1, R2, atom_i, atom_j) blk = self.blocks.get(key) if blk is not None: if stacked: if orb_i + oi < blk.shape[0] and orb_j < blk.shape[1]: val_up = blk[orb_i, orb_j] val_dn = blk[orb_i + oi, orb_j] else: val_up = val_dn = 0.0 elif orb_i < blk.shape[0] and orb_j < blk.shape[1]: val = blk[orb_i, orb_j] else: val = 0.0 else: rev_key = (-R0, -R1, -R2, atom_j, atom_i) blk = self.blocks.get(rev_key) if stacked: if ( blk is not None and orb_j + oj < blk.shape[0] and orb_i < blk.shape[1] ): val_up = blk[orb_j, orb_i] # Hermitian fallback val_dn = blk[orb_j + oj, orb_i] else: val_up = val_dn = 0.0 elif ( blk is not None and orb_j < blk.shape[0] and orb_i < blk.shape[1] ): val = blk[orb_j, orb_i] # Hermitian fallback else: val = 0.0 pp = pi * pj # undo parity (self-inverse) if stacked: out[0, k] = val_up * pp out[1, k] = val_dn * pp else: out[0, k] = val * pp return out
@property def n_pairs(self) -> int: return len(self.blocks) def __repr__(self) -> str: return f"AimspyMatrix(n_pairs={self.n_pairs}, n_spin={self.n_spin})"