Source code for tailwater.hr_export

"""Assemble a ``tbmodels.Model`` from the API's dense head predictions.

Most users won't call these directly — the standard workflow is to ask
the API for a finished HDF5 hr-model (the default mode of
:func:`tailwater.tw_api_call`) and load it with
:func:`tailwater.tb_model.load`. The functions in this module are for
the less common path where you want the *raw* dense head outputs
(``edge_pred`` / ``onsite_pred``) and need to build the tight-binding
model on the client side yourself — e.g. when experimenting with
alternative hop thresholds, alternative phase conventions, or custom
sublattice positions.

Two builders are provided:

* :func:`build_hr_model` — straightforward nested-loop assembly.
* :func:`build_hr_model_fast` — vectorised NumPy hop selection;
  byte-identical output to :func:`build_hr_model` at ~100-300× speedup
  on a 50-atom material.

Both return a ``tbmodels.Model`` constructed with the real lattice as
``uc`` and the atoms' Cartesian positions as ``pos``. Hops with
magnitude below ``hop_threshold`` (default 0.01 eV) are dropped.

Both require the optional ``pybinding-dev`` package (``pb.Lattice`` is
used as a parallel filter to mirror the reference assembly exactly).
If it's not installed, a friendly ``ImportError`` tells you so.
"""

from typing import List, Tuple

import numpy as np
import torch
import tbmodels

# pybinding is only called inside build_hr_model / build_hr_model_fast as
# a parallel filter that mirrors the reference notebook's semantics.
# Most users of the package never invoke those functions — they upload a
# structure, receive an HDF5, and post-process. So pybinding is OPTIONAL:
# the import is lazy so `import tailwater` succeeds without it installed,
# and a friendly error fires only when an hr_model build is actually called.
try:
    import pybinding as pb  # type: ignore
except ImportError:           # pragma: no cover
    pb = None


def _require_pybinding() -> None:
    """Raise a clear ImportError if pybinding isn't installed."""
    if pb is None:
        raise ImportError(
            "build_hr_model / build_hr_model_fast require the `pybinding-dev` "
            "package, which isn't installed. It's not a tailwater dependency "
            "(install it directly): pip install pybinding-dev"
        )


from .constants import NeighBrs, NUM_ELEMENTS


def _to_numpy(x):
    """Return x as a NumPy array (handles torch.Tensor / array_like)."""
    if isinstance(x, torch.Tensor):
        return x.detach().cpu().numpy()
    return np.asarray(x)


def _to_cpu_tensor(x):
    """Return ``x`` as a CPU ``torch.Tensor`` (detached).

    Both builders below allocate intermediate buffers on CPU (the dense
    ``HoppT`` tensor) and then fancy-index those buffers from the user
    supplied ``edge_pred`` / ``onsite_pred`` / ``gdata.edge_vectors``.
    If those inputs come from a CUDA-resident model (the common case
    after ``subspace_projection(device='cuda', ...)``), CUDA → CPU
    fancy-index assignment raises a cross-device ``RuntimeError`` mid
    build.

    This helper is the boundary that makes the hr-build CPU-only
    regardless of where the model trained. It's a no-op for inputs
    already on CPU, so the CPU code path is unchanged.
    """
    if isinstance(x, torch.Tensor):
        return x.detach().cpu()
    return x



    
    
[docs] def build_hr_model(edge_pred, onsite_pred, gdata, LM, atoms: List[Tuple[str, List[float]]], hop_threshold: float = 0.01, ) -> tbmodels.Model: """Build a tbmodels.Model from the model's dense predictions. Parameters ---------- edge_pred : torch.Tensor or ndarray, shape [num_edges, 18, 18, 2] (or anything reshape-compatible). Real/imag in the last dim. Self-loop entries are overwritten by `onsite_pred` internally — caller may pass the raw head output unchanged. onsite_pred : torch.Tensor or ndarray, shape [num_atoms, 18, 18, 2]. gdata : PyG Data with edge_index, edge_vectors, inv_data, node_features. Same object the model consumed. LM : 3x3 lattice matrix (rows = lattice vectors, Å). Passed to ``tbmodels.Model`` as ``uc``. atoms : [(symbol, [x, y, z]), ...]. Per-atom Cartesian positions; used as the sublattice positions of the ``tbmodels.Model`` so the per-atom orbitals carry the same geometric labels they have in the structure. hop_threshold : drop hops with ``|val| <= this`` (eV). Default 0.01. Returns ------- hr_model : tbmodels.Model populated with on-site energies and hops. """ _require_pybinding() # ---- Move user-supplied tensors to CPU at the boundary ---- # (Allows callers to pass CUDA-resident outputs from a model trained # with device='cuda' without hitting a cross-device assignment error # in the dense-HoppT fill below.) edge_pred = _to_cpu_tensor(edge_pred) onsite_pred = _to_cpu_tensor(onsite_pred) edge_vectors = _to_cpu_tensor(gdata.edge_vectors) # ---- Normalize predictions to numpy with the expected shapes ---- edge_pred = edge_pred.reshape((gdata.edge_index).shape[1],18,18,2) is_self_loop = (edge_vectors[:].norm(dim=-1) == 0) weights=(torch.sign(torch.abs((edge_vectors[:]).norm(dim=1)))) weights=weights.view(-1, 1, 1, 1) edge_pred = weights*edge_pred edge_pred[is_self_loop] = onsite_pred index=gdata.inv_data HoppT=torch.zeros((gdata.atom_number.item(),gdata.atom_number.item(),len(NeighBrs),18,18,2)) cnt=0 for ed in index[:]: atm1=ed[0] atm2=ed[1] k=ed[2] HoppT[atm1,atm2,k,:,:,:]=edge_pred[cnt][:,:,:] cnt=cnt+1 HoppT=HoppT.detach().numpy() nds=gdata.node_features.cpu().numpy() num_wann=0 for nd in nds: num_wann=num_wann+np.sum(nd[109:]) num_wann=int(num_wann) hop_dict1=np.zeros((len(nds),18)) lat1 = pb.Lattice(a1=LM[0],a2=LM[1],a3=LM[2]) pos1=[] ose1=[] cnt=0 for i in range(len(nds)): for k in range(18): if (nds[i][109:])[k]==1: lat1.add_one_sublattice(str(cnt), atoms[i][1], onsite_energy=np.real(HoppT[i,i,0,int(k),int(k),0])) ose1.append(np.real(HoppT[i,i,0,int(k),int(k),0])) pos1.append(atoms[i][1]) hop_dict1[i][k]=cnt cnt=cnt+1 hr_model = tbmodels.Model(on_site=ose1, dim=3, occ=1, pos=pos1, uc=LM) for l in (range(len(NeighBrs))): for atm1 in range(len(nds)): for s1o in range(18): if nds[atm1][109:][s1o]==1: for atm2 in range(len(nds)): for s2o in range(18): if nds[atm2][109:][s2o]==1: if np.linalg.norm(HoppT[atm1,atm2,l,s1o,s2o,:])>0.01: try: lat1.add_one_hopping(NeighBrs[l],str(int(hop_dict1[atm1][s1o])),str(int(hop_dict1[atm2][s2o])),np.real(HoppT[atm1,atm2,l,s1o,s2o,0])+1j*np.real(HoppT[atm1,atm2,l,s1o,s2o,1])) hr_model.add_hop(np.real(HoppT[atm1,atm2,l,s1o,s2o,0])+1j*np.real(HoppT[atm1,atm2,l,s1o,s2o,1]), int(hop_dict1[atm1][s1o]), int(hop_dict1[atm2][s2o]), NeighBrs[l]) except: continue return hr_model
[docs] def build_hr_model_fast(edge_pred, onsite_pred, gdata, LM, atoms: List[Tuple[str, List[float]]], hop_threshold: float = 0.01, ) -> tbmodels.Model: """Vectorized equivalent of `build_hr_model` — same output, much faster. The reference build_hr_model loops `l × atm1 × s1o × atm2 × s2o` in pure Python, which is ~num_R * N^2 * 18^2 inner iterations and crosses into multi-minute territory for ~50-atom inputs. This function keeps the EXACT same iteration order (so tbmodels' first-add-wins semantics and the model's non-Hermitian inter-atom predictions produce the same final recorded values) but lifts the threshold check, the active-mask filter, the magnitude computation, the value gather, and the orbital-index lookup out of Python and into NumPy. The surviving per-hop Python loop only visits hops that actually got recorded. Order preservation is what makes this drop-in safe: `np.nonzero` on a `[num_R, N, 18, N, 18]` mask returns indices in C (row-major) order, which is identical to the reference's outer-to-inner nested-loop ordering of those five axes. Approximate speedup on a 50-atom material: ~100-300x for the hop insertion phase. Output (the tbmodels.Model) is byte-identical to `build_hr_model`'s output as long as `pb.Lattice` / `tbmodels.Model.add_hop` are deterministic — both are. """ _require_pybinding() # ---- Move user-supplied tensors to CPU at the boundary ---- # (Allows callers to pass CUDA-resident outputs from a model trained # with device='cuda' without hitting a cross-device assignment error # in the dense-HoppT fill below.) edge_pred = _to_cpu_tensor(edge_pred) onsite_pred = _to_cpu_tensor(onsite_pred) edge_vectors = _to_cpu_tensor(gdata.edge_vectors) # ---- Same preprocessing as build_hr_model ---- edge_pred = edge_pred.reshape((gdata.edge_index).shape[1], 18, 18, 2) is_self_loop = (edge_vectors[:].norm(dim=-1) == 0) weights = torch.sign(torch.abs(edge_vectors[:].norm(dim=1))) weights = weights.view(-1, 1, 1, 1) edge_pred = weights * edge_pred edge_pred[is_self_loop] = onsite_pred # ---- Vectorized HoppT fill ---- # Replaces the per-edge Python loop with a single fancy-indexing # assignment. Each row of inv_data is a (atm1, atm2, R-index) triple; # we use those three columns as the multi-axis index into HoppT. inv_data = gdata.inv_data.cpu().numpy() # [num_edges, 3] N = int(gdata.atom_number.item()) HoppT = torch.zeros((N, N, len(NeighBrs), 18, 18, 2)) HoppT[inv_data[:, 0], inv_data[:, 1], inv_data[:, 2]] = edge_pred HoppT = HoppT.detach().numpy() nds = gdata.node_features.cpu().numpy() # ---- Sublattices and on-site energies (same logic as reference) ---- # The per-atom orbital loop is N * 18 iterations max — negligible vs # the hop loop — so we leave it as Python. Behavioral equivalence to # the reference is the priority here. hop_dict1 = np.zeros((len(nds), 18), dtype=np.int64) lat1 = pb.Lattice(a1=LM[0], a2=LM[1], a3=LM[2]) pos1: List[list] = [] ose1: List[float] = [] cnt = 0 for i in range(len(nds)): for k in range(18): if (nds[i][109:])[k] == 1: ose_val = float(np.real(HoppT[i, i, 0, int(k), int(k), 0])) lat1.add_one_sublattice(str(cnt), atoms[i][1], onsite_energy=ose_val) ose1.append(ose_val) pos1.append(atoms[i][1]) hop_dict1[i][k] = cnt cnt += 1 hr_model = tbmodels.Model(on_site=ose1, dim=3, occ=1, pos=pos1, uc=LM) # ---- Vectorized hop selection ---- # Transpose HoppT axes from # (atm1, atm2, l, s1o, s2o, re/im) original # to # (l, atm1, s1o, atm2, s2o, re/im) loop order # so np.nonzero scans them in the same order the reference's nested # loops do. HoppT_T = HoppT.transpose(2, 0, 3, 1, 4, 5) # [num_R, N, 18, N, 18, 2] # Per-element complex magnitude. Equivalent to # np.linalg.norm(HoppT_T, axis=-1). mag = np.sqrt(HoppT_T[..., 0] ** 2 + HoppT_T[..., 1] ** 2) active = (nds[:, 109:127] == 1) # [N, 18] bool mask = ( (mag > hop_threshold) & active[None, :, :, None, None] # active[atm1, s1o] & active[None, None, None, :, :] # active[atm2, s2o] ) # np.nonzero -> indices in (l, atm1, s1o, atm2, s2o) row-major # order. SAME order as the reference's nested loops, so the temporal # sequence of add_hop / add_one_hopping calls below is identical. l_idxs, atm1_idxs, s1o_idxs, atm2_idxs, s2o_idxs = np.nonzero(mask) n_hops = int(l_idxs.shape[0]) if n_hops == 0: return hr_model # Vectorize value + index gathering. Each of these is one fancy # index into the existing arrays — O(n_hops) but in NumPy, not Python. re_vals = HoppT_T[l_idxs, atm1_idxs, s1o_idxs, atm2_idxs, s2o_idxs, 0] im_vals = HoppT_T[l_idxs, atm1_idxs, s1o_idxs, atm2_idxs, s2o_idxs, 1] idx1_arr = hop_dict1[atm1_idxs, s1o_idxs] idx2_arr = hop_dict1[atm2_idxs, s2o_idxs] # ---- Per-hop add (no batch API in tbmodels / pybinding) ---- # IMPORTANT: keep the pb.Lattice.add_one_hopping call inside the # try block. The reference puts lat1 BEFORE hr_model in the try; # if pb.Lattice raises for a case tbmodels would accept (or vice # versa), the combined try guarantees we add to hr_model only when # pb also accepts, matching the reference's filtering semantics # exactly. Skipping the lat1 call would let some hops slip into # hr_model that the reference rejects. for n in range(n_hops): l = int(l_idxs[n]) idx1 = int(idx1_arr[n]) idx2 = int(idx2_arr[n]) val = complex(float(re_vals[n]), float(im_vals[n])) R_vec = NeighBrs[l] try: lat1.add_one_hopping(R_vec, str(idx1), str(idx2), val) hr_model.add_hop(val, idx1, idx2, R_vec) except Exception: continue return hr_model
[docs] def write_hr_output(hr_model: tbmodels.Model, out_path: str, fmt: str = "hdf5") -> str: """Persist a tbmodels.Model. `fmt` is "hdf5" or "hr_dat".""" if fmt == "hdf5": hr_model.to_hdf5_file(out_path) elif fmt == "hr_dat": hr_model.to_hr_file(out_path) else: raise ValueError(f"Unknown format {fmt!r}; expected 'hdf5' or 'hr_dat'.") return out_path