Skip to content

Noncollinear (spin_polarization=:full) calculation added#1273

Open
physarief78 wants to merge 3 commits into
JuliaMolSim:masterfrom
physarief78:Noncollinear-calculation-added
Open

Noncollinear (spin_polarization=:full) calculation added#1273
physarief78 wants to merge 3 commits into
JuliaMolSim:masterfrom
physarief78:Noncollinear-calculation-added

Conversation

@physarief78
Copy link
Copy Markdown

@physarief78 physarief78 commented Mar 1, 2026

Dear maintainers and community

According to DFTK issue #1272, I have implemented support for noncollinear calculations (spin_polarization=:full) in DFTK. To achieve this, as can be seen in the changed files, 21 files were modified. This was necessary because noncollinear calculations involve the x, y, and z components of the spin direction; in other words, the spin direction is no longer restricted to just up or down, but can point in any direction. Furthermore, we also need to compute the probability density, which together with the three spin components results in a four-dimensional problem. Since DFTK previously assumed a simplified definition of spin polarization (i.e., collinear up/down) before the support for spin_polarization=:full was added, many changes were required to enable noncollinear calculations properly and safely. "Safely" here means that these changes should not affect the existing modes such as spin_polarization=:collinear, :spinless, or :none, nor any other solvers in general.

Getting more detailed we can see this mathematically by comparing the collinear and noncollinear calculation. The fundamental difference lies in how we treat the quantum mechanical spin state of the electrons.

  1. The Wavefunctions (Spinors)

In collinear DFT, the global quantization axis (usually the $z$-axis) is fixed. An electron is strictly either "spin-up" or "spin-down". The wavefunctions are simple scalar fields for each spin channel:

$$\psi_{i,\uparrow}(\mathbf{r}) \quad \text{and} \quad \psi_{i,\downarrow}(\mathbf{r})$$

In non-collinear DFT, the spin quantization axis can rotate freely from point to point in the crystal. Because spin is a two-state quantum system, we must promote the wavefunction to a 2-component Pauli spinor. Every electronic state $i$ becomes a mixture of up and down:

$$\Psi_i(\mathbf{r}) = \begin{pmatrix} \psi_{i,\uparrow}(\mathbf{r}) \ \psi_{i,\downarrow}(\mathbf{r}) \end{pmatrix}$$

  1. The Density & Magnetization

Because the wavefunctions are vectors, the electron density is no longer a simple scalar $n(\mathbf{r})$. It becomes a $2 \times 2$ Hermitian density matrix at every point in space:

$$\rho(\mathbf{r}) = \sum_i f_i \Psi_i(\mathbf{r}) \Psi_i^\dagger(\mathbf{r}) = \begin{pmatrix} \rho_{\uparrow\uparrow}(\mathbf{r}) & \rho_{\uparrow\downarrow}(\mathbf{r}) \ \rho_{\downarrow\uparrow}(\mathbf{r}) & \rho_{\downarrow\downarrow}(\mathbf{r}) \end{pmatrix}$$

Any $2 \times 2$ Hermitian matrix can be decomposed into a scalar charge density $n(\mathbf{r})$ and a 3D magnetization vector $\mathbf{m}(\mathbf{r})$ using the Pauli matrices ($\boldsymbol{\sigma} = (\sigma_x, \sigma_y, \sigma_z)$):

$$\rho(\mathbf{r}) = \frac{1}{2} \left[ n(\mathbf{r}) I_{2\times2} + \mathbf{m}(\mathbf{r}) \cdot \boldsymbol{\sigma} \right]$$

  • Collinear: $\mathbf{m}(\mathbf{r}) = (0, 0, m_z(\mathbf{r}))$. The off-diagonal terms ($\rho_{\uparrow\downarrow}$) are forced to be zero.
  • Non-collinear: $\mathbf{m}(\mathbf{r}) = (m_x(\mathbf{r}), m_y(\mathbf{r}), m_z(\mathbf{r}))$.

This is exactly why we now store the density as a four-component array: $\rho[:,:,:,1]$ is $n$, and $\rho[:,:,:,2:4]$ are $m_x, m_y, m_z$.

  1. The Hamiltonian (The Kohn-Sham Equations)

The effective Kohn-Sham Hamiltonian dictates how the orbitals evolve:

$$\hat{H} = \left( -\frac{1}{2}\nabla^2 + V_{\text{ext}}(\mathbf{r}) + V_{\text{Hartree}}(\mathbf{r}) \right) I_{2\times2} + \hat{V}_{\text{xc}}(\mathbf{r})$$

In collinear DFT, the exchange-correlation potential $\hat{V}_{\text{xc}}$ only has a $z$-component. The Hamiltonian is block-diagonal, meaning we essentially solve two smaller, separate equations (one for up, one for down):

$$\hat{H}_{\text{coll}} = \begin{pmatrix} H_0 + V_{\text{xc},\uparrow} & 0 \ 0 & H_0 + V_{\text{xc},\downarrow} \end{pmatrix}$$

In non-collinear DFT, the exchange-correlation potential acts like a local magnetic field $\mathbf{B}_{\text{xc}}(\mathbf{r})$ that points in arbitrary 3D directions. The Hamiltonian becomes a fully coupled $2 \times 2$ matrix:

$$\hat{H}_{\text{nc}} = \begin{pmatrix} H_0 + B_z & B_x - iB_y \ B_x + iB_y & H_0 - B_z \end{pmatrix}$$

Because of those off-diagonal terms ($B_x \pm iB_y$), an electron's spin can "flip" or rotate as it moves through the lattice. This fully coupled matrix is twice as large, which is why non-collinear calculations take significantly more RAM and CPU time to diagonalize.

  1. The Local Collinear Approximation (Our xc.jl implementation)

Standard exchange-correlation functionals (like LDA or PBE) are only mathematically defined for scalar up/down densities, not vector fields. To use libxc, we must mathematically project the non-collinear state into a local collinear state at every single grid point. This is exactly what we implemented in our xc.jl file:

  • Step A: Find the local eigenvalues
    We compute the magnitude of the local magnetization vector: $|\mathbf{m}| = \sqrt{m_x^2 + m_y^2 + m_z^2}$.
    Then we construct the local up/down densities by diagonalizing the density matrix:

$$n_+ = \frac{n + |\mathbf{m}|}{2}, \qquad n_- = \frac{n - |\mathbf{m}|}{2}.$$

  • Step B: Call libxc
    We feed $n_+$ and $n_-$ into libxc to get the scalar potentials $V_+$ and $V_-$.

  • Step C: Rotate the potential back to the global frame
    Using the chain rule, we map the scalar potentials back into the $2 \times 2$ matrix form to build the exchange-correlation magnetic field:

$$V_n = \frac{V_+ + V_-}{2},$$

$$\mathbf{V}_{\mathbf{m}} = \left( \frac{V_+ - V_-}{2} \right) \frac{\mathbf{m}}{|\mathbf{m}|}.$$

This maps directly to the $V_n$ and $\text{potential}[i, 2:4] \mathrel{+}= V_m \cdot \mathbf{m} / |\mathbf{m}|$ lines in our Julia implementation. By following these steps, we ensure that the noncollinear calculation correctly handles the spin degrees of freedom while still leveraging the standard libxc library.

To check the results we can do this step-by-step:

  1. Package used:
using DFTK
using PseudoPotentialData
using LinearAlgebra
using GLMakie  # wakes up DFTKMakieExt
  1. Setup and calculation
a = 5.4 
lattice = a * I(3)
atoms = [ElementPsp(:Fe, psp=load_psp(PseudoFamily("dojo.nc.sr.lda.v0_4_1.standard.upf"), :Fe)) for _ in 1:2]
positions = [[0.0, 0.0, 0.0], [0.5, 0.5, 0.5]]
Ecut, kgrid, tol = 35, [3, 3, 3], 1e-4

println("Running Collinear SCF...")
basis_coll = PlaneWaveBasis(model_LDA(lattice, atoms, positions; temperature=0.01, spin_polarization=:collinear); Ecut, kgrid)
scfres_coll = self_consistent_field(basis_coll, ρ=guess_density(basis_coll, [3.0, 3.0]), tol=tol)

#--------------------Non-collinear calculation enabled part--------------------------
println("\nRunning Non-Collinear SCF...")
basis_nc = PlaneWaveBasis(model_LDA(lattice, atoms, positions; temperature=0.01, spin_polarization=:full); Ecut, kgrid)
ρ_guess_nc = guess_density(basis_nc, [[-3.0, 1.0, 0], [0, 3.0, 1.0]]) 
scfres_nc = self_consistent_field(basis_nc, ρ=ρ_guess_nc, tol=tol)

println("\nComputing Band Structures...")
kpath = irrfbz_path(basis_coll.model) 
bands_coll = compute_bands(scfres_coll, kpath)
bands_nc   = compute_bands(scfres_nc, kpath)

printed scf calculation results:

Collinear:

n     Energy            log10(ΔE)   log10(Δρ)   Magnet   |Magn|   Diag   Δtime 
---   ---------------   ---------   ---------   ------   ------   ----   ------
  1   -158.2728422285                    0.87   1.061   1.061    7.9    2.18s
  2   -247.1055888775        1.95        0.21   3.699   3.699    9.5    3.51s
  3   -250.4265613181        0.52       -0.39   3.896   3.941    6.7    2.45s
  4   -250.4600455941       -1.48       -0.73   4.102   4.184    3.1    1.64s
  5   -250.4674918321       -2.13       -1.28   4.245   4.361    3.1    1.31s
  6   -250.4708433915       -2.47       -1.42   4.279   4.401    2.2    925ms
  7   -250.4759278563       -2.29       -2.12   4.321   4.448    1.0    673ms
  8   -250.4759862643       -4.23       -2.25   4.345   4.475    2.4    970ms
  9   -250.4760405975       -4.26       -2.91   4.362   4.493    1.0    699ms
 10   -250.4760255599   +   -4.82       -2.68   4.366   4.497    2.7    1.34s
 11   -250.4760106816   +   -4.83       -2.55   4.366   4.497    1.0    635ms
 12   -250.4759963003   +   -4.84       -2.48   4.366   4.497    1.0    622ms
 13   -250.4759856367   +   -4.97       -2.47   4.366   4.497    1.3    702ms
 14   -250.4760186192       -4.48       -2.66   4.366   4.497    1.1    645ms
 15   -250.4760227518       -5.38       -2.70   4.366   4.497    1.0    651ms
 16   -250.4760229131       -6.79       -2.70   4.366   4.497    1.0    639ms
 17   -250.4760412586       -4.74       -3.51   4.366   4.498    1.1    876ms
 18   -250.4760415018       -6.61       -3.76   4.366   4.498    2.5    915ms
 19   -250.4760415167       -7.83       -3.86   4.366   4.498    1.6    675ms
 20   -250.4760415506       -7.47       -4.16   4.366   4.498    1.3    684ms

Non-collinear:

n     Energy            log10(ΔE)   log10(Δρ)   Magnet (x, y, z)           |Magn|   Diag   Δtime 
---   ---------------   ---------   ---------   ----------------           ------   ----   ------
  1   -250.4469495965                   -0.30  [-2.34,  3.12,  0.78]   3.978    9.6    8.34s
  2   -250.4719867625       -1.60       -0.82  [-2.35,  3.13,  0.78]   3.989    2.0    2.12s
  3   -250.4737407296       -2.76       -1.18  [-2.37,  3.16,  0.79]   4.028    2.6    2.28s
  4   -250.4747780205       -2.98       -1.38  [-2.43,  3.24,  0.81]   4.129    2.5    1.98s
  5   -250.4753855137       -3.22       -1.61  [-2.52,  3.36,  0.84]   4.281    3.0    2.40s
  6   -250.4751993113   +   -3.73       -1.65  [-2.55,  3.41,  0.85]   4.343    1.6    1.65s
  7   -250.4760025221       -3.10       -2.14  [-2.58,  3.44,  0.86]   4.389    1.8    1.73s
  8   -250.4759589329   +   -4.36       -2.08  [-2.57,  3.42,  0.85]   4.362    1.9    1.92s
  9   -250.4760353403       -4.12       -2.63  [-2.57,  3.43,  0.86]   4.375    2.3    1.90s
 10   -250.4760312483   +   -5.39       -2.59  [-2.57,  3.43,  0.86]   4.372    2.6    2.20s
 11   -250.4760217586   +   -5.02       -2.48  [-2.57,  3.43,  0.86]   4.371    2.2    1.89s
 12   -250.4760292309       -5.13       -2.63  [-2.57,  3.43,  0.86]   4.367    1.0    1.40s
 13   -250.4760397168       -4.98       -3.04  [-2.57,  3.43,  0.86]   4.367    1.0    1.37s
 14   -250.4760412463       -5.82       -3.39  [-2.57,  3.43,  0.86]   4.367    1.6    1.78s
 15   -250.4760413717       -6.90       -3.46  [-2.57,  3.43,  0.86]   4.367    1.4    1.49s
 16   -250.4760415432       -6.77       -3.54  [-2.57,  3.43,  0.86]   4.366    1.2    1.42s
 17   -250.4760415395   +   -8.43       -3.53  [-2.57,  3.43,  0.86]   4.366    1.0    1.55s
 18   -250.4760415490       -8.02       -3.67  [-2.57,  3.43,  0.86]   4.366    1.0    1.35s
 19   -250.4760415384   +   -7.97       -3.86  [-2.57,  3.43,  0.86]   4.366    2.2    1.79s
 20   -250.4760415547       -7.79       -4.25  [-2.57,  3.43,  0.86]   4.366    1.0    1.55s
  1. Makie based analysis
println("\nRendering High-Res Makie Subplots...")

# --- Panel 1: 3D Comparison ---
fig_3d = Makie.Figure(size = (1900, 800), fontsize = 30) 
plot_spin_3d!(fig_3d[1, 1], scfres_coll; title_text="Collinear Spin (3D)", stride=1, arrow_scale=1.0)
plot_spin_3d!(fig_3d[1, 2], scfres_nc;   title_text="Non-Collinear Spin (3D)", stride=1, arrow_scale=1.0)
Makie.save("Comparison_3D_Spin.png", fig_3d, px_per_unit=3)

# --- Panel 2: 2D Slice Comparison ---
fig_slice = Makie.Figure(size = (1600, 700), fontsize = 18)
plot_spin_slice!(fig_slice[1, 1], scfres_coll; axis=:z, title_text="Collinear (XY Plane)")
plot_spin_slice!(fig_slice[1, 2], scfres_nc;   axis=:z, title_text="Non-Collinear (XY Plane)")
Makie.save("Comparison_2D_Slice.png", fig_slice, px_per_unit=3)

# --- Panel 3: Band Structure Comparison ---
fig_bands = Makie.Figure(size = (1600, 700), fontsize = 18)
# Set your desired zoom level here! (min, max)
my_ylims = (-7.0, 20.0) 
plot_bandstructure!(fig_bands[1, 1], bands_coll; title_text="Bands: Collinear", y_limits=my_ylims)
plot_bandstructure!(fig_bands[1, 2], bands_nc;   title_text="Bands: Non-Collinear", y_limits=my_ylims)
Makie.save("Comparison_Bands.png", fig_bands, px_per_unit=3)

# --- Panel 4: Density of States Comparison ---
fig_dos = Makie.Figure(size = (1600, 700), fontsize = 18)
plot_dos!(fig_dos[1, 1], scfres_coll; title_text="DoS: Collinear")
plot_dos!(fig_dos[1, 2], scfres_nc;   title_text="DoS: Non-Collinear")
Makie.save("Comparison_DoS.png", fig_dos, px_per_unit=3)

The results:
image
image
image
image

  1. Summary of the results:
# 1. Total Energy Analysis (1 Hartree ≈ 27.2114 eV)
E_coll = scfres_coll.energies.total
E_nc   = scfres_nc.energies.total
dE_hartree = E_nc - E_coll
dE_eV      = dE_hartree * 27.211386245988 

# 2. Magnetic Moment Analysis
# Calculate the volume element of a single grid point
dVol = scfres_coll.basis.model.unit_cell_volume / prod(scfres_coll.basis.fft_size)

# Collinear Magnetization (Integral of Spin Up minus Spin Down)
mag_coll = sum(scfres_coll.ρ[:, :, :, 1] .- scfres_coll.ρ[:, :, :, 2]) * dVol

# Non-Collinear Magnetization (Integral of the vector components)
Mx = sum(scfres_nc.ρ[:, :, :, 2]) * dVol
My = sum(scfres_nc.ρ[:, :, :, 3]) * dVol
Mz = sum(scfres_nc.ρ[:, :, :, 4]) * dVol
mag_nc_tot = sqrt(Mx^2 + My^2 + Mz^2)

If we print the code above, this will gives us the summary result of:

1. SCF Convergence Status
   - Collinear     : Converged
   - Non-Collinear : Converged

2. Total Energy
   - Collinear     : -250.47604155 Hartree
   - Non-Collinear : -250.47604155 Hartree
   - ΔE (NC - Coll): -0.0 Hartree (-0.0 eV)

3. Integrated Magnetic Moments (Bohr Magnetons, μB)
   - Collinear     : 4.3663 μB (Z-axis only)
   - Non-Collinear : 4.3663 μB (Total Magnitude)
     * Mx Component: -2.5679 μB
     * My Component: 3.4261 μB
     * Mz Component: 0.8556 μB

4. Stability Conclusion
   -> Both magnetic states are degenerate (identical energy).

Conclusion:

Based on the results, the implementation of noncollinear support (spin_polarization=:full) was successfully carried out in DFTK by modifying 21 files to handle four-component spinor wave functions and fully bound Hamiltonians, while retaining all existing functions. This implementation is mathematically rigorous, based on the Pauli spinor formalism and a local collinear approximation for the exchange-correlation function. Numerical tests on solid iron show excellent agreement: collinear and noncollinear calculations converge to the same total energy (difference < 10⁻⁸ Hartree) and identical magnetic moment magnitude (4.366 μB), with noncollinear results also providing complete vector components (Mx, My, Mz). Further visualization of spin textures, band structures, and state densities confirms physical consistency. These features are numerically stable and physically consistent.

That is all for my PR. I hope this contribution will help make DFTK more robust and enable many future methodological developments. Thank you in advance for any feedback. This is the final step—I hope the PR passes all checks before being merged.

AI statements that were used for making this PR:

  1. GitHub Copilot:
    Since I didn't know where to start, I asked GitHub Copilot which files I should modify. However, after I began modifying them and implementing the calculation for spin_polarization=:full, errors kept appearing until I had to modify all 21 files (as can be seen in the 21 files changed part on this GitHub PR feature).

  2. Gemini AI:
    I used Gemini to help me code and modify the 21 files that were changed for the spin_polarization=:full implementation.

  3. ChatGPT:
    I used ChatGPT to summarize my derivations into compact, short conclusions to help me improve my prompting, bridging the physics theory and guiding the statements from GitHub Copilot that implemented in Gemini AI to make the necessary file changes.

@physarief78 physarief78 marked this pull request as ready for review March 1, 2026 07:12
@antoine-levitt
Copy link
Copy Markdown
Member

Thanks! It looks quite good! Did you test it with a genuinely non-collinear system to see if it works well? Ideally we should check the result against an existing code (abinit or quantum espresso).

The main problem here is that, in the DFTK philosophy, we want to hardcode as less about DFT as possible. The motivation for this is that there are many other possible uses of the multicomponent DFT-like models : elasticity, maxwell's equations, and some coarse-grained models of graphene. A previous attempt was made in #851 but stalled.

That was some time ago but we discussed a lot about data structures at the time: the constraints are that a) we don't want to penalize too much code readability (ie the code should not have a lot of if branches everywhere) b) we want to be generic wrt to the number of components (eg 6 for the full maxwell equations).

The most important choices we have to make are how to represent orbitals and densities. We settled in #851 on psi[sigma, G, n] I think (but that was some time ago, before we had collinear spin). In #849 (comment) I said psi[G,n][sigma] with svectors which is memory-layout compatible but not exactly the same as psi[sigma, G, n]. Looking at the code now it looks like you've basically hardcoded the representation of psi in the functions that use it (ie you made no modification to fft.jl), but it looks like in memory layout it might be closer to psi[G, sigma, n]? We should really make it a part of the "wavefunction API". Regarding densities, it's not completely clear to me what the correct representation should be in the general case: probably it should be density matrices (a 2x2 hermitian matrix). Is it very bad to just use and compute this in the uncompressed zeros(ComplexF64, 2, 2) form? If so we should probably have helper functions that convert to and from the 2x2 hermitian format to the R^3 format.

@mfherbst what do you think?

@physarief78
Copy link
Copy Markdown
Author

Thank you for the thoughtful feedback and suggestions.

To be honest, I haven't yet had the chance to test the implementation against existing codes like Quantum ESPRESSO or ABINIT. This is mainly because I've been focusing on getting spin_polarization=:full to run correctly and produce physically meaningful results. I fully agree that benchmarking against established codes is essential, and I plan to do that in the next steps.

Regarding the code structure, I did try to keep it as simple, clear, and general as possible. However, I encountered quite a few conflicts along the way, and at some point it became mentally taxing to balance generality with functionality. That's why, in the current PR, I ended up hardcoding certain parts—partly as a way to move forward and gather early feedback. I realize this goes against the design philosophy of DFTK.

Your insights are very helpful, and I will now work on refactoring the code to better align with the generic, multicomponent framework you described. I'll also try to incorporate the ideas about wavefunction and density representations we discussed, and I'll keep the updated on progress and any remaining challenges.

One aspect I'm still struggling with is the mapping between the density matrix and the magnetization vector. It's not a straightforward one-to-one mapping if we want to preserve the full Hermitian structure for physical robustness.

@antoine-levitt
Copy link
Copy Markdown
Member

Why not 1 to 1? Is it not just splitting a 2x2 hermitian matrix onto the four Pauli matrices (with sigma0=Id)?

@physarief78
Copy link
Copy Markdown
Author

Ah, you are completely right, and I phrased that poorly! My apologies. Mathematically, it is absolutely a 1-to-1 mapping via the Pauli matrices $(\rho = \frac{1}{2}(n I + \vec{m} \cdot \vec{\sigma}))$.

When I said I was struggling with the mapping and 'robustness,' I didn't mean the mathematical theory. I meant I was struggling with the best way to implement this cleanly in Julia while adhering to DFTK's generic architecture.

If we just use an uncompressed zeros(ComplexF64, 2, 2) (or a generic N x N matrix for the multicomponent case), we have to be careful about floating-point noise breaking exact Hermiticity during computations. On the other hand, explicitly projecting onto the real $(n, \vec{m})$ space perfectly guarantees physical robustness, but it requires us to hardcode the Pauli decomposition, which feels less generic if we want to support arbitrary N components later.

However, your suggestion makes perfect sense. About to implement the helper functions to explicitly convert between the 2 x 2 Hermitian matrix form and the $(n, \vec{m})$ format in $\mathbb{R}^3$. This should keep the core arrays relatively generic while handling the specific spin_polarization=:full physics robustly.

@antoine-levitt
Copy link
Copy Markdown
Member

antoine-levitt commented Mar 3, 2026

That decomposition is fully general, it's just selecting a real basis for the space of traceless hermitian matrices. In the 2x2 case it's R^3 (two because complex times one for the off-diagonal, two for the diagonal, minus one for the trace), in the 3x3 case it's 8 (two times three for the off-diagonal, three for the diagonal, minus one for the trace), etc

@physarief78
Copy link
Copy Markdown
Author

Ah, I see exactly what you mean now! I was getting tunnel vision on the Pauli matrices as a special 2 x 2 case and completely missed the broader algebraic structure. Thank you for spelling that out.

So since the decomposition into a scalar trace and an $\mathbb{R}^{N^2-1}$ vector of traceless components is fully general, that completely resolves my architectural concern about 'hardcoding' a 2-component assumption. It scales perfectly to N components.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants