Exporting models: sparse .npz, HDF5, _hr.dat, pybinding, PythTB, Kwant
By default the API returns the tight-binding Hamiltonian in sparse
form — a wannier90_hr.npz (a tailwater.SparseHR) that
stores only the non-zero hoppings, so it is O(N) in memory and file size
rather than O(N²). tailwater.tw_api_call() handles the two size
regimes for you:
Small systems (< 30 atoms) — the
.npzis automatically converted to a dense tbmodels HDF5 on your machine and returned underpaths["hdf5"](the.npzis kept too, underpaths["npz"]), so every dense recipe on this page —tb_model.load("...hdf5")onward — works unchanged.Large systems — the result stays sparse (
paths["npz"]), and a note is printed pointing at the conversions here. For big cells you should keep it sparse (see Why maintaining sparsity matters for large systems).
Force either format with tw_api_call(..., output_format="hdf5") or
"sparse".
Importantly, the conversion functions below accept either a sparse
.npz / SparseHR or a dense HDF5 /
tbmodels.Model, so the same one-liner works whatever you’re holding.
The sparse .npz format (SparseHR)
Load a .npz with tailwater.SparseHR:
from tailwater import SparseHR
shr = SparseHR.load("wannier90_hr.npz")
shr.num_wann # number of Wannier orbitals (the H(k) matrix dimension)
shr.nnz # number of stored hoppings
Internally the .npz holds the Hamiltonian as a COO sparse list of
hoppings plus the on-site diagonal and (optionally) the geometry — a
dense [num_wann, num_wann] matrix is never formed:
|
real on-site energy per orbital, shape |
|
orbital indices |
|
lattice vector |
|
complex hopping amplitude |
|
3×3 lattice vectors (Å), when geometry was recovered |
|
per-orbital Cartesian positions, when available |
Only the forward half of each ±R pair is stored (the Hermitian
conjugate at -R is implied) and the R = 0 diagonal lives in
on_site. That is what makes it O(N): a .npz for a 14,000-orbital
moiré cell is a few MB, where the dense HDF5 would be tens of GB.
Convert the .npz to any format (one call, auto-detecting the input)
The top-level converters accept a SparseHR / .npz
path or a tbmodels.Model / .hdf5 / _hr.dat and dispatch
automatically:
from tailwater import (as_tbmodels, to_hdf5, to_hr_dat,
to_pb, to_pythtb, to_kwant)
npz = "wannier90_hr.npz"
model = as_tbmodels(npz) # tbmodels.Model (dense)
to_hdf5(npz, "wannier90_hr.hdf5") # tbmodels HDF5
to_hr_dat(npz, "wannier90_hr.dat") # Wannier90 _hr.dat
pb_lattice = to_pb(npz) # pybinding.Lattice
py_model = to_pythtb(npz) # pythtb model
syst, lat = to_kwant(npz) # kwant (Builder, lattice)
# the identical calls work on a dense HDF5 / _hr.dat, too:
to_hr_dat("wannier90_hr.hdf5", "wannier90_hr.dat")
as_tbmodels(npz) is the bridge to the rest of this page: once you have
the tbmodels.Model (or the auto-converted HDF5 for a small system),
every pybinding / PythTB / Kwant / WannierBerri recipe below applies.
Note
_hr.dat and HDF5 are dense on-disk formats — size grows as
num_R · num_wann². They are guarded for very large systems; pass
max_wann= to SparseHR.to_hr_dat() / SparseHR.to_hdf5()
to override the guard if you really intend to write a huge file.
Staying sparse: pybinding, Kwant, and built-in solvers
For large systems you usually do not want to densify at all.
SparseHR builds pybinding and Kwant models straight
from the COO list (no dense matrix is ever formed) and carries its own
sparse solvers:
from tailwater import SparseHR
shr = SparseHR.load("wannier90_hr.npz")
# --- built-in sparse spectra (large num_wann OK) ---
Hk = shr.Hk([0.0, 0.0, 0.0]) # scipy sparse H(k) at Γ
w = shr.eigsh_near_fermi([0, 0, 0], e_fermi=0.0, num=20) # 20 states near E_F
Rd = shr.hr_dict() # {R: scipy.sparse.csr_matrix}
# --- hand the sparse model to pybinding / Kwant (built from the COO) ---
pb_lattice = shr.to_pb() # pybinding.Lattice
syst, lat = shr.to_kwant() # kwant (Builder, lattice)
eigsh_near_fermi uses a shift-invert sparse eigensolver, so you can
get just the handful of bands nearest the Fermi level for a Hamiltonian
far larger than a dense H(k) could hold. hr_dict returns the
H(R) blocks as scipy sparse matrices to feed your own KPM /
Green’s-function / transport code. to_pb and to_kwant are
sparse-native and scale to large num_wann — pybinding and Kwant are
themselves sparse solvers, so the whole pipeline stays O(N).
Why maintaining sparsity matters for large systems
A dense Hamiltonian stores every [num_wann, num_wann] block for
every lattice vector R: memory and file size scale as
O(num_R · num_wann²). But a physical tight-binding Hamiltonian is
sparse — each orbital hops only to a bounded number of neighbours — so
the number of non-zeros scales just as O(num_wann). For large cells
the difference is decisive:
A twisted-bilayer moiré cell with ~14,000 orbitals is a few MB as a
.npzbut tens of GB as a dense_hr.dat/ HDF5 — the dense form often cannot be written, let alone loaded into RAM.Diagonalising a dense
H(k)is O(num_wann³) and needs the entire matrix resident; the sparse shift-invert path (SparseHR.eigsh_near_fermi()) touches only the non-zeros and returns just the near-Fermi bands you ask for.pybinding and Kwant are sparse-native, so converting via
SparseHR.to_pb()/SparseHR.to_kwant()keeps memory and compute O(N) end to end.
Rule of thumb: for small systems, let tw_api_call convert to HDF5
and use the dense recipes below. For large systems, keep the ``.npz``
sparse — write _hr.dat / HDF5 only if an external tool demands it,
and prefer the sparse pybinding / Kwant / built-in solvers above.
Working with the dense tbmodels.Model
The sections below operate on a dense tbmodels.Model — the HDF5 you
get for a small system (tb_model.load("wannier90_hr.hdf5")), or
as_tbmodels("wannier90_hr.npz") for a converted sparse model. From a
dense model there are four common downstream needs, each a one-liner:
Write the model to a Wannier90-style
_hr.datfile — so it can be consumed by external tools (Z2Pack,WannierTools, downstream DFT pipelines, custom analysis scripts that expect the plain-text Wannier90 format).Convert the model to a pybinding
Lattice— so you can use pybinding’s solvers, KPM routines, eigenvalue plotters, and transport tools on top of the Tailwater Hamiltonian.Convert the model to a PythTB
tb_model— so you can use PythTB’s band-path helpers, slab/wire builders, Berry-phase / Wannier-charge-centre routines, and the body of literature that targets PythTB.Convert the model to a Kwant
Builder— so you can use Kwant’s transport machinery (leads,smatrix,greens_function), the wraparound trick for bulk H(k), or any of Kwant’s sample-builder utilities.
Writing an _hr.dat file
Two equivalent entry points:
from tailwater import tb_model, write_hr_output
model = tb_model.load("wannier90_hr.hdf5")
# Option A — tbmodels' native method, attached to the loaded model:
model.to_hr_file("wannier90_hr.dat")
# Option B — Tailwater's thin wrapper, useful when the format is a
# runtime choice rather than hard-coded:
write_hr_output(model, "wannier90_hr.dat", fmt="hr_dat")
write_hr_output(model, "wannier90_hr.hdf5", fmt="hdf5") # re-emit HDF5
Both produce the standard Wannier90 column layout:
Rx Ry Rz i j Re(H) Im(H)
with the unit-cell weights written in the same convention Wannier90
uses, so the result drops into any tool that already reads
wannier90_hr.dat. The HDF5 round-trip is bit-identical.
No optional dependencies are needed for the hr export — it’s pure tbmodels under the hood.
Converting to a pybinding Lattice
Every model returned by tailwater.tb_model.load() carries a
.to_pb() instance method:
from tailwater import tb_model, k_cart_from_frac
import pybinding as pb
model = tb_model.load("wannier90_hr.hdf5")
lat = model.to_pb()
# Build a pybinding model and sample H(k) at any fractional k:
pmod = pb.Model(lat, pb.translational_symmetry())
pmod.set_wave_vector(k_cart_from_frac([0.0, 0.0, 0.0], model.uc)) # Gamma
eig = np.sort(np.linalg.eigvalsh(pmod.hamiltonian.todense()))
These eigenvalues match np.sort(np.linalg.eigvalsh(model.hamilton([0,0,0])))
to float32 precision (~1e-6 eV).
Important
Pybinding’s pb.Model.set_wave_vector() expects k in
Cartesian (rad/length) — not fractional. Use
tailwater.k_cart_from_frac() to convert from the fractional
convention tbmodels.Model.hamilton uses. Passing fractional
k directly to set_wave_vector is the most common source of
“the pybinding bands don’t match the tbmodels bands” reports.
What .to_pb() does internally:
Reads the on-site energies off the diagonal of the
R = (0, 0, 0)hop block (doubling them, see “Convention notes” below).Adds one
Sublatticeper Wannier orbital, with the position converted from fractional → Cartesian viapos_cart = pos_frac @ LM.Iterates the hopping dict and adds each
(R, i, j)tolat.add_one_hopping. pybinding implies the Hermitian conjugate automatically, so duplicates returned by tbmodels are silently skipped.
Computing a full band structure
import numpy as np
from tailwater import tb_model, k_cart_from_frac
import pybinding as pb
model = tb_model.load("wannier90_hr.hdf5")
pmod = pb.Model(model.to_pb(), pb.translational_symmetry())
# Gamma -> M -> K -> Gamma on the (k_x, k_y) plane
k_frac_path = np.array([
[0.000, 0.000, 0],
[0.500, 0.000, 0],
[0.333, 0.333, 0],
[0.000, 0.000, 0],
])
bands = []
for kf in k_frac_path:
pmod.set_wave_vector(k_cart_from_frac(kf, model.uc))
bands.append(np.sort(np.linalg.eigvalsh(pmod.hamiltonian.todense())))
bands = np.array(bands) # shape (Npts, num_wann)
Comparing this against [np.sort(np.linalg.eigvalsh(model.hamilton(kf))) for kf in k_frac_path]
gives matching curves to ~1e-6 eV — both routes diagonalise the same
Hamiltonian, just with different per-orbital phase conventions on the
eigenvectors.
Convention notes
These are the two conventions to_pb quietly handles for you; the
CHANGELOG covers the same story for upgraders from 0.4.2 or
earlier.
On-site doubling.
tbmodels.Model.hamilton(k)constructs the Hamiltonian viaΣ_R stored[R] e^{ikR}followed byH += H.c.This H.c. step supplies the missing -R half for R ≠ 0, but at R=(0,0,0) it doubles the stored block. tbmodels therefore stores exactly half the physical on-site block.to_pbmultiplieshop[(0,0,0)]by 2 before feeding it to pybinding so pybinding’s H(k) recovers the full physical on-site contribution.Position basis.
tbmodels.Model.posis in fractional coordinates; pybinding’sadd_one_sublatticeexpects Cartesian.to_pbdoes the conversionpos_cart = pos_frac @ LMso the resulting pybindingLatticehas physically meaningful positions (its Brillouin-zone routines, real-space LDOS plotters, etc. all see the right geometry).
Overriding the lattice vectors
API-produced HDF5 files don’t always carry the unit cell as
model.uc (it’s deliberately left as None so the same model
works under any choice of lattice convention). .to_pb() falls
back to the identity np.eye(3) in that case, which is fine for
algebraic work but wrong for any plot in physical k-coordinates.
Pass the real lattice explicitly:
import numpy as np
a, c = 4.143, 28.636 # Bi2Se3 hexagonal in Å
LM = np.array([
[ a, 0, 0],
[-a/2, a*np.sqrt(3)/2, 0],
[ 0, 0, c],
])
lat = model.to_pb(lattice_vectors=LM)
After this, the pybinding brillouin_zone() returns the correct
hexagonal BZ and any band plot is in physical Å-1.
Filtering tiny hops
For exploratory analysis or visualisation you may want to drop the
smallest hops to keep lat lightweight:
lat = model.to_pb(hop_threshold=1e-6) # drop |H_ij(R)| < 1e-6 eV
Keep hop_threshold low — it’s there to clean up sparse-storage
zeros, not to act as a physical cutoff. The right place to set
the physical threshold is the inference / hr-build step (default
0.01 eV), not here.
Converting to a PythTB tb_model
Every model returned by tailwater.tb_model.load() also carries
a .to_pythtb() instance method:
from tailwater import tb_model
model = tb_model.load("wannier90_hr.hdf5")
py_model = model.to_pythtb()
# Sample H(k) at any fractional k:
eig_gamma = py_model.solve_one([0.0, 0.0, 0.0]) # Γ
eig_m = py_model.solve_one([0.5, 0.0, 0.0]) # M (hex zone)
These eigenvalues match
np.linalg.eigvalsh(model.hamilton([0,0,0])) to float64
precision (~5×10⁻¹⁴ eV) — a much tighter agreement than the
pybinding path, which is float32 (~10⁻⁶ eV).
The PythTB path is generally the easier of the two:
PythTB takes orbital positions in fractional coordinates, the same convention
tbmodels.Model.posuses — no Cartesian conversion needed.PythTB’s
solve_one(k)accepts fractional k directly — no analogue oftailwater.k_cart_from_frac()is needed.PythTB ships rich first-class helpers for band paths, slabs/wires (
cut_piece), supercells (make_supercell), and Wannier-centre / Berry-phase analyses.
Computing a band structure with PythTB’s built-in helper
import numpy as np
from tailwater import tb_model
import matplotlib.pyplot as plt
model = tb_model.load("wannier90_hr.hdf5")
py_model = model.to_pythtb()
# PythTB does the path interpolation for you:
k_path, k_dist, k_node = py_model.k_path(
[[0,0,0], [0.5,0,0], [0.333,0.333,0], [0,0,0]],
nk=101, report=False,
)
bands = py_model.solve_all(k_path) # (num_wann, nk)
fig, ax = plt.subplots()
for band in bands:
ax.plot(k_dist, band, lw=0.7, color="k")
ax.set_xticks(k_node, [r"$\Gamma$", "M", "K", r"$\Gamma$"])
ax.set_ylabel("E (eV)")
fig.savefig("bands_pythtb.png", dpi=150)
Slabs and wires
PythTB’s cut_piece makes a 1D / 2D slab from the bulk model. For
a 6-layer Bi2Se3 slab terminated along the c-axis:
py_slab = py_model.cut_piece(num=6, fin_dir=2, glue_edgs=False)
print(py_slab.get_num_orbitals()) # 6 * 124 = 744
The resulting model is 2D-periodic (in-plane) and 0D along the surface-normal direction — solve it the same way:
eig_2d = py_slab.solve_one([0.0, 0.0]) # Γ of the surface BZ
Both model.to_pythtb() and model.to_pb() produce models with
identical bulk Hamiltonians; pick whichever ecosystem (PythTB,
pybinding, or both) fits your downstream analysis.
Converting to a Kwant Builder
Every model returned by tailwater.tb_model.load() also carries
a .to_kwant() instance method:
import numpy as np, kwant
from tailwater import tb_model
model = tb_model.load("wannier90_hr.hdf5")
builder = model.to_kwant() # kwant.Builder, 3D periodic
# For bulk H(k) sampling, wrap-around and finalise:
syst = kwant.wraparound.wraparound(builder).finalized()
# Kwant's wraparound takes 2π·k_frac as k_x, k_y, k_z (the
# per-cell Bloch phase) — NOT Cartesian rad/length like pybinding.
k_frac = np.array([0.5, 0.0, 0.0])
phase = 2 * np.pi * k_frac
H = syst.hamiltonian_submatrix(
params=dict(k_x=phase[0], k_y=phase[1], k_z=phase[2]),
)
eig = np.sort(np.linalg.eigvalsh(H))
This matches np.linalg.eigvalsh(model.hamilton([0.5, 0, 0])) to
float64 precision (~10⁻¹³ eV) — Kwant is double-precision
internally.
Important
Kwant’s kwant.wraparound is the one place in this
ecosystem where k is neither fractional nor Cartesian
rad/length. The k_x/k_y/k_z parameters are
2π · k_frac — the per-cell Bloch phase, independent of cell
size. (Use tailwater.k_cart_from_frac() only for pybinding’s
set_wave_vector.) Passing a Cartesian k to Kwant is the most
common source of “the Kwant bands don’t match the tbmodels bands”
reports.
What .to_kwant() returns
It returns an unfinalised kwant.Builder with a 3D
kwant.TranslationalSymmetry, one site per Wannier orbital. From
there the user has two natural next steps:
Bulk H(k) — wrap the Builder via
kwant.wraparound.wraparound(as in the snippet above) and finalise.Transport / scattering — build a finite scattering region on top of the bulk Builder, attach leads via
kwant.Builder(kwant.TranslationalSymmetry(...))for each lead, and callkwant.smatrix/kwant.greens_function.A complete end-to-end recipe (quantum wire of a Bi2Se3 hr-model + two semi-infinite leads + G(E) sweep) lives at
examples/05_kwant_scattering.py. The full Kwant tutorial is at https://kwant-project.org/doc/.One Kwant API quirk to know:
bulk.symmetry.periodsis a custom Kwant array class that doesn’t support direct integer indexing. Cast to a plain NumPy array first:periods = np.asarray(bulk.symmetry.periods) # (3, 3) sym_x = kwant.TranslationalSymmetry(periods[0]) # transport axis
Computing a band structure with Kwant
import numpy as np, kwant
import matplotlib.pyplot as plt
from tailwater import tb_model
model = tb_model.load("wannier90_hr.hdf5")
syst = kwant.wraparound.wraparound(model.to_kwant()).finalized()
# Gamma -> M -> K -> Gamma (linearly sampled fractional path)
nodes = np.array([[0,0,0], [0.5,0,0], [0.333,0.333,0], [0,0,0]])
path = np.vstack([np.linspace(nodes[i], nodes[i+1], 33)
for i in range(len(nodes) - 1)])
bands = []
for k_frac in path:
phase = 2 * np.pi * k_frac
H = syst.hamiltonian_submatrix(
params=dict(k_x=phase[0], k_y=phase[1], k_z=phase[2]),
)
bands.append(np.sort(np.linalg.eigvalsh(H)))
bands = np.array(bands) # (Npts, num_wann)
fig, ax = plt.subplots()
ax.plot(bands, lw=0.7, color="k")
ax.set_ylabel("E (eV)")
fig.savefig("bands_kwant.png", dpi=150)
The bands are identical to those from model.hamilton(k_frac) or
model.to_pythtb().solve_one(k_frac) at every k — Kwant just
gives you the rest of the transport ecosystem for free.
Using the model with WannierBerri
WannierBerri (Berry-curvature integrator for transport quantities,
optical responses, Wannier-charge centres, etc.) reads
tbmodels.Model directly — no model.to_X() method needed.
Just pass the loaded model:
import wannierberri as wb
from tailwater import tb_model
model = tb_model.load("wannier90_hr.hdf5")
sys_wb = wb.system.System_R.from_tbmodels(model, berry=True)
grid = wb.Grid(sys_wb, NK=(8, 8, 8), NKFFT=(4, 4, 4))
Efermi = np.linspace(-3, 3, 61)
result = wb.run(
sys_wb, grid=grid,
calculators={
"dos": wb.calculators.static.DOS (Efermi=Efermi, tetra=True),
"ohmic": wb.calculators.static.Ohmic_FermiSea(Efermi=Efermi),
"ahc": wb.calculators.static.AHC (Efermi=Efermi),
},
parallel=False, symmetrize=False, dump_results=False,
)
berry=True tells WannierBerri to construct the position matrix
elements <0,n|r|R,m> from model.pos — these are what every
Berry-curvature-derived calculator (AHC, optical conductivity,
Wannier charge centres) needs.
A complete worked recipe with DOS, longitudinal conductivity, and
AHC vs. Fermi energy is at examples/06_wannierberri_conductivity.py.
Spin Hall conductivity (SHC and friends) need an extra ingredient:
the spin matrix elements <n,0 | S^alpha | m,R>. WannierBerri’s
from_tbmodels(..., spin=True) is documented but doesn’t actually
populate these — tbmodels carries only the Hamiltonian. Tailwater
synthesises them from the known sigma_z-eigenstate structure of the
18-orbital Wannier basis via
tailwater.wb_system_with_spin():
import numpy as np, wannierberri as wb
from tailwater import tb_model, wb_system_with_spin
model = tb_model.load("wannier90_hr.hdf5")
sys = wb_system_with_spin(model) # SS_R populated; berry=True too
Efermi = np.linspace(-2.0, 2.0, 41)
grid = wb.Grid(sys, NK=(8, 8, 8), NKFFT=(4, 4, 4))
result = wb.run(
sys, grid=grid,
calculators={
"shc": wb.calculators.static.SHC(
Efermi=Efermi,
kwargs_formula={"spin_current_type": "simple"},
),
},
parallel=False, symmetrize=False, dump_results=False,
)
shc = np.asarray(result.results["shc"].data) # (Nef, 3, 3, 3) in (ℏ/e)·S/cm
The function infers the sigma_z eigenstate doublets from the model’s
geometric structure by default — it walks the atomic positions to
group Wannier functions by atom, then pairs consecutive Kramers
partners and verifies their on-site energies match. A worked
end-to-end recipe with Bi2Se3 lives at
examples/07_spin_hall_conductivity.py; the in-gap plateau in
\(\sigma^{z}_{xy}\) is the topological signature.
WannierBerri brings its own optional dependencies — install them explicitly when needed:
pip install wannierberri numba # numba: tetrahedron integration
Round-trip: HDF5 → pybinding → HDF5
A pybinding Lattice is not directly serialisable to HDF5, but
you don’t need to round-trip through pybinding — just keep the
original tbmodels.Model alongside:
from tailwater import tb_model, write_hr_output
model = tb_model.load("wannier90_hr.hdf5")
lat = model.to_pb(lattice_vectors=LM)
# ... do pybinding work with `lat` ...
# When you want to persist or share the Hamiltonian, write the
# original tbmodels model — same content, two interchangeable
# serialisations:
write_hr_output(model, "Bi2Se3.hdf5", fmt="hdf5")
write_hr_output(model, "Bi2Se3_hr.dat", fmt="hr_dat")
Building a model from raw head predictions (advanced)
The standard workflow uses the HDF5 the API ships back. If you
instead need to assemble the tight-binding model on the client side
from the raw dense head outputs — e.g. to experiment with a
different hop_threshold — use
build_hr_model_fast():
from tailwater import build_hr_model_fast, write_hr_output
hr_model = build_hr_model_fast(
edge_pred = edge_pred, # [num_edges, 18, 18, 2] from API/heads
onsite_pred = onsite_pred, # [num_atoms, 18, 18, 2]
gdata = gdata, # PyG Data the model consumed
LM = lattice_matrix, # 3x3 real lattice (Å)
atoms = [(sym, xyz), ...], # Cartesian per-atom positions
hop_threshold = 0.01, # drop |H_ij(R)| <= this (eV)
)
write_hr_output(hr_model, "wannier90_hr.dat", fmt="hr_dat")
build_hr_model_fast is byte-identical to build_hr_model at
~100-300× the speed; prefer it unless you’re debugging a build
discrepancy. Both require pip install pybinding-dev — see
Installation.
API reference
Sparse Hamiltonian + format-detecting converters
- class tailwater.SparseHR(num_wann, on_site, rows, cols, Rs, vals, cell=None, positions=None)[source]
Bases:
objectSelf-contained sparse Wannier H(R): COO hops + on-site + (optional) geometry (primitive lattice
celland per-orbitalpositions). Carries everything needed to reload the inference output straight into scipy / tbmodels / pybinding / Kwant — no crystal graph or GNN required.Construct one with
load()(from an API-produced.npz); the per-hop arrays are the forward-only half of each+/-Rpair (the H.c. is implied), with the R=0 diagonal carried separately inon_site.- num_wann
- on_site
- rows
- cols
- Rs
- vals
- cell
- positions
- property nnz
Number of stored (forward-only) hops.
- save(path)[source]
Write the full sparse model (hops + on-site + geometry) to
.npz.
- classmethod load(path)[source]
Reload a SparseHR from an API
wannier90_hr.npz(or one written bysave()) — no graph/model needed.
- Hk(k)[source]
Sparse H(k) matching
tbmodels.hamilton(convention 2). Each stored hop(val, i, j, R)contributese^{2*pi*i*k.R}*valat(i, j)and its conjugate at(j, i);on_sitefills the diagonal. Vectorised -> O(nnz).
- eigvals_grid(kpts, dense=True)[source]
Dense eigenvalues (sorted ascending) at each fractional k in
kpts. Returns an array of shape[len(kpts), num_wann].
- eigsh_near_fermi(k, e_fermi=0.0, num=40)[source]
The
numeigenvalues neareste_fermiat fractional k, via shift-invert sparse diagonalisation — usable for num_wann far beyond what a dense H(k) can hold.
- hr_dict()[source]
Real-space H(R) as
{R_tuple: scipy.sparse.csr_matrix}— the natural sparse in-memory form for large systems (feed to your own solver, KPM, pybinding, Kwant, …). O(nnz).
- to_tbmodels(uc=None)[source]
Convert to a
tbmodels.Model(dense per-R; small/medium systems).ucdefaults to the storedcell. From the returned model use.to_hr_file()(Wannier90_hr.dat) or.to_hdf5_file(), or the tbmodels solvers directly.
- to_pb()[source]
Load directly into a pybinding
pb.Lattice(one sublattice per orbital), built straight from the COO — no dense matrix. Mirrorstb_model.load(hdf5).to_pb()for the dense path.
- to_pybinding()[source]
Deprecated alias for
to_pb()(kept for older sparse-API code).
- to_kwant()[source]
Build a bulk Kwant
(Builder, lattice)(single site carrying allnum_wannorbitals as matrix-valued blocks), straight from the COO — scales to large num_wann. Mirrorstb_model.load(hdf5).to_kwant().
- to_hr_dat(path, uc=None, max_wann=4000)[source]
Write a Wannier90
_hr.dat(via tbmodels). DENSE format: file size ~num_R * num_wann**2, so it is guarded to small/medium systems. For large systems keep it sparse (hr_dict/Hk/KPM). Passmax_wannto override the guard.
- to_hdf5(path, uc=None, max_wann=4000)[source]
Write a tbmodels HDF5 (dense per-R). Same dense-format guard as
to_hr_dat(). This is the format thetw_api_callauto-conversion produces for small systems, so downstream code that expectswannier90_hr.hdf5keeps working unchanged.
- tailwater.convert.as_tbmodels(src, uc=None)[source]
Return
srcas atbmodels.Model(the sparse -> dense bridge).A dense input is returned unchanged; a sparse input is densified (
ucdefaults to its stored cell).
- tailwater.convert.to_hdf5(src, path, **kw)[source]
Write
srcto a tbmodels HDF5 (auto-detects sparse/dense).DENSE on-disk format. For a sparse input the
max_wannguard applies (passmax_wann=to override).
- tailwater.convert.to_hr_dat(src, path, **kw)[source]
Write
srcto a Wannier90_hr.dat(auto-detects sparse/dense).DENSE on-disk format (
~ num_R * num_wann**2). For a sparse input themax_wannguard applies (passmax_wann=to override); prefer keeping large systems sparse.
- tailwater.convert.to_pb(src, lattice_vectors=None, hop_threshold=1e-12)[source]
Convert
srcto a pybindingpb.Lattice(auto-detects sparse/dense).For a sparse input the lattice is built straight from the COO (no dense matrix);
lattice_vectorsis honored only for the dense path.
- tailwater.convert.to_pythtb(src, lattice_vectors=None, hop_threshold=1e-12)[source]
Convert
srcto a PythTB model (auto-detects sparse/dense).PythTB is inherently dense, so a sparse input is densified via tbmodels first (small/medium systems only).
- tailwater.convert.to_kwant(src, lattice_vectors=None, hop_threshold=1e-12)[source]
Convert
srcto a Kwantkwant.Builder(auto-detects sparse/dense).For a sparse input the builder carries matrix-valued blocks built straight from the COO (scales to large num_wann) and returns
(Builder, lattice);lattice_vectorsis honored only for the dense path.
Dense (tbmodels) converters
- tailwater.client._to_pb_method(self, lattice_vectors=None, hop_threshold: float = 1e-12)[source]
Convert this tbmodels.Model into a pybinding.Lattice.
Bound as
model.to_pbon instances returned bytb_model.load(). After conversion, eigenvalues ofmodel.hamilton(k_frac)and the pybinding model match to float32 precision (~1e-6 eV) at every k.To plug pybinding into a band-structure calculation, pair the returned lattice with the companion helper
k_cart_from_frac()— pybinding expectsset_wave_vector(k_cart)in rad/length:from tailwater import tb_model, k_cart_from_frac import pybinding as pb model = tb_model.load("wannier90_hr.hdf5") lat = model.to_pb() pmod = pb.Model(lat, pb.translational_symmetry()) for k_frac in k_path: pmod.set_wave_vector(k_cart_from_frac(k_frac, model.uc)) bands.append(np.linalg.eigvalsh(pmod.hamiltonian.todense()))
- Parameters:
lattice_vectors (array-like (3, 3), optional) – Real-space lattice vectors as rows. If None, uses
self.uc(the unit cell the tbmodels.Model carried when it was loaded); if that’s also None, falls back tonp.eye(3).hop_threshold (float, default 1e-12) – Skip hops with
|val| <= hop_threshold. Keep this low — we’re only filtering exact-zero entries from sparse hop storage; the band-relevant threshold should have been applied upstream when the HDF5 was first written.
- Returns:
pb.Lattice – with the same sublattices, on-site energies, lattice vectors, and hops as
self, producing the same H(k) eigenvalues at every k.Conventions
———–
**On-site doubling.* tbmodels’ Hamiltonian construction sums*
stored[R] * exp(i k . R)over R, then adds its Hermitianconjugate to symmetrise. That second step supplies the missing
minus-R half for nonzero R, but at R=0 it doubles the stored block
on top of itself. tbmodels therefore stores half the user-supplied
on-site value at R=0, and the round-trip Hamiltonian matches the
physical Hamiltonian. Pybinding has no such double-up step, so
we feed it twice the stored R=0 block, restoring the physical
contribution.
**Position basis.** tbmodels stores
self.posin fractionalcoordinates. Pybinding expects positions in Cartesian. We convert
pos_cart = pos_frac @ LMso the resulting lattice’sBrillouin-zone and real-space geometry routines are physically
meaningful. Eigenvalues are invariant under the per-orbital phase
change induced by this choice — only the eigenvectors get rephased.
**Hop duplicates.** For nonzero R, both
(R, i, j)and(R, j, i)entries of the stored hop matrix are added explicitlyto pybinding; the H.c. of each pybinding add automatically supplies
the matching minus-R contribution, so the full Hamiltonian is
reconstructed. For R = 0, the auto-implied H.c. of a given
add_one_hoppingcall lands at the transposed indices —pybinding rejects the explicit second add as a duplicate. We catch
that rejection silently.
- tailwater.client._to_pythtb_method(self, lattice_vectors=None, hop_threshold: float = 1e-12)[source]
Convert this tbmodels.Model into a pythtb.tb_model.
Bound as
model.to_pythtbon instances returned bytb_model.load(). After conversion, eigenvalues ofmodel.hamilton(k_frac)andpy_model.solve_one(k_frac)match to float64 precision (~1e-12 eV) at every k.The PythTB conversion is simpler than the pybinding one in two ways:
PythTB takes
orb(orbital positions) in fractional coordinates, the same convention astbmodels.Model.pos, so no Cartesian conversion is needed.PythTB’s
solve_one(k)accepts fractional k directly, so no analogue ofk_cart_from_frac()is needed:
from tailwater import tb_model model = tb_model.load("wannier90_hr.hdf5") py_model = model.to_pythtb() # Sample H(k) at any fractional k: eig = py_model.solve_one([0.0, 0.0, 0.0]) # Γ eig = py_model.solve_one([0.5, 0.0, 0.0]) # M # ...or use PythTB's built-in band-path helpers: k_path, k_dist, k_node = py_model.k_path( [[0,0,0], [0.5,0,0], [0.333,0.333,0], [0,0,0]], nk=101, report=False, ) bands = py_model.solve_all(k_path) # (num_wann, nk)
- Parameters:
lattice_vectors (array-like (3, 3), optional) – Real-space lattice vectors as rows. If None, uses
self.uc; if that’s also None, falls back tonp.eye(3).hop_threshold (float, default 1e-12) – Skip hops with
|val| <= hop_threshold. Keep this low — only filters exact-zero entries from sparse hop storage.
- Returns:
pythtb.tb_model – A 3D periodic tight-binding model with the same Hamiltonian as
self. For slabs / wires, call PythTB’s.cut_piece(num, fin_dir)on the returned model.Conventions
———–
The same on-site doubling story as
_to_pb_method()applieshere — PythTB counts each
(0,0,0)entry once (like pybindingdoes), while tbmodels effectively counts it twice via its
H += H.c.symmetrisation. To match, we multiplyhop[(0,0,0)]by 2 before feeding PythTB.PythTB defaults to ``allow_conjugate_pair=False`` (only one of)
each H.c. pair may be added explicitly. At R=(0,0,0) we therefore
only add the upper-triangle off-diagonals; PythTB fills in the
lower triangle via its automatic conjugate. For R != 0 we add
every nonzero entry of
hop[+R]; PythTB implies thecorresponding (-R, j, i) contribution from each.
- tailwater.client._to_kwant_method(self, lattice_vectors=None, hop_threshold: float = 1e-12)[source]
Convert this tbmodels.Model into a kwant.Builder.
Bound as
model.to_kwanton instances returned bytb_model.load(). Returns an unfinalisedkwant.Builderwith a 3Dkwant.TranslationalSymmetryso callers can either:Finalise immediately for bulk H(k) sampling:
import numpy as np, kwant from tailwater import tb_model model = tb_model.load("wannier90_hr.hdf5") syst = kwant.wraparound.wraparound(model.to_kwant()).finalized() # Kwant's wraparound takes 2π·k_frac (per-cell Bloch phase), # NOT Cartesian rad/length. k_frac = [0.5, 0.0, 0.0] phase = 2 * np.pi * np.asarray(k_frac) H = syst.hamiltonian_submatrix( params=dict(k_x=phase[0], k_y=phase[1], k_z=phase[2]), ) eigs = np.sort(np.linalg.eigvalsh(H))
Attach leads / build a finite scattering region on top of the bulk Builder for transport calculations:
bulk = model.to_kwant() # Cut a finite slab, add leads, attach to bulk, etc. # See the Kwant tutorial: https://kwant-project.org/doc/
The returned eigenvalues match
np.linalg.eigvalsh(model.hamilton(k_frac))to ~float64 precision (~1e-12 eV) at every k.- Parameters:
lattice_vectors (array-like (3, 3), optional) – Real-space lattice vectors as rows. If None, uses
self.uc; if that’s also None, falls back tonp.eye(3).hop_threshold (float, default 1e-12) – Skip hops with
|val| <= hop_threshold. Keep this low — only filters exact-zero entries from sparse hop storage.
- Returns:
kwant.Builder – A 3D-periodic Builder with one site per Wannier orbital. Sublattices are accessible in the same order as
model.posvia the Builder’slattice.sublattices(the lattice object is the first argument of the TranslationalSymmetry stored on the Builder).Conventions
———–
The same on-site doubling story as
_to_pb_method()and_to_pythtb_method()applies — Kwant counts each(0,0,0)entry once, while tbmodels effectively counts it twice via its
H += H.c.symmetrisation. To match, we multiplyhop[(0,0,0)]by 2 before feeding Kwant.Positions are Cartesian (Kwant’s convention; converted from
fractional via
pos_cart = pos_frac @ LM)... warning:: – The k-parameters that
kwant.wraparoundexposes (k_x,k_y,k_z) are not Cartesian rad/length like pybinding’s. They are the per-cell Bloch phase (i.e.2π · k_frac), independent of the physical cell size. To sample H(k) at the same fractional k tbmodels uses, passk_x = 2π · k_frac[0], etc. — see the worked example above. This is the most common source of “the Kwant bands don’t match the tbmodels bands” reports.
- tailwater.client.k_cart_from_frac(k_frac, lattice_vectors)[source]
Convert a fractional k-point to Cartesian (rad/length) for pybinding.
Pybinding’s
set_wave_vector(k)expectskin rad/length — i.e. in the basis of the Cartesian reciprocal-lattice vectorsb_i, not the fractionalk_iWannier90 and tbmodels use by default. The conversion is:k_cart = 2π · inv(LM) @ k_frac
where
LMhas the real-space lattice vectors as rows.- Parameters:
k_frac (array-like, shape (3,) or (N, 3)) – Fractional k (or batch of k-points), in the same units
tbmodels.Model.hamilton(k)expects.lattice_vectors (array-like, shape (3, 3)) – Real-space lattice vectors as rows (e.g.
model.uc).
- Returns:
Cartesian k in rad/length, ready for
pb.Model.set_wave_vector.- Return type:
np.ndarray of shape
(3,)or(N, 3)
Example
import numpy as np, pybinding as pb from tailwater import tb_model, k_cart_from_frac model = tb_model.load("wannier90_hr.hdf5") lat = model.to_pb() pmod = pb.Model(lat, pb.translational_symmetry()) # Sample H(k) at Gamma → M (Bi2Se3) on a fractional path: k_path_frac = np.array([[0,0,0], [0.5, 0, 0]]) bands = [] for kf in k_path_frac: pmod.set_wave_vector(k_cart_from_frac(kf, model.uc)) bands.append(np.sort(np.linalg.eigvalsh(pmod.hamiltonian.todense())))
- tailwater.hr_export.write_hr_output(hr_model: Model, out_path: str, fmt: str = 'hdf5')[source]
Persist a tbmodels.Model. fmt is “hdf5” or “hr_dat”.
- tailwater.hr_export.build_hr_model(edge_pred, onsite_pred, gdata, LM, atoms: List[Tuple[str, List[float]]], hop_threshold: float = 0.01)[source]
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.Modelasuc.atoms ([(symbol, [x, y, z]), ...]. Per-atom Cartesian) – positions; used as the sublattice positions of the
tbmodels.Modelso 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
- Return type:
tbmodels.Model populated with on-site energies and hops.
- tailwater.hr_export.build_hr_model_fast(edge_pred, onsite_pred, gdata, LM, atoms: List[Tuple[str, List[float]]], hop_threshold: float = 0.01)[source]
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.