"""
Spherical Skyrme-Hartree-Fock solver -- Vautherin & Brink (Phys. Rev. C 5, 626, 1972).
Method: radial box + finite-difference matrix diagonalization (effective-mass
kinetic operator kept in self-adjoint divergence form and Hermitized on a
uniform mesh). Self-consistent loop with linear density mixing.
Functional conventions follow Xayavong & Smirnova, arXiv:2111.13531 App. A
(which derives them from Vautherin-Brink), EXCEPT:
- the genuine 3-body term is used directly: H3 = (1/4) t3 rho_n rho_p rho,
equivalently the density-dependent form with x3=1, gamma=1. Its s.p.
potential is derived by hand (their printed A10 has an x3-1/2 sign typo).
All energies in MeV, lengths in fm.
"""
from __future__ import annotations
import numpy as np
if not hasattr(np, "trapezoid"): # numpy < 2 compatibility
np.trapezoid = np.trapz
from dataclasses import dataclass, field
# ---- physical constants -----------------------------------------------------
HBAR2_2M = 20.7355 # hbar^2 / 2m [MeV fm^2], m = (m_p+m_n)/2 average nucleon
E2 = 1.439964 # e^2 [MeV fm] (elementary charge squared, Gaussian)
# ---- Skyrme force -----------------------------------------------------------
@dataclass
class Skyrme:
name: str
t0: float; t1: float; t2: float; t3: float
x0: float; W0: float
x1: float = 0.0; x2: float = 0.0; x3: float = 1.0 # VB: only x0, x1=x2=0
gamma: float = 1.0 # 3-body -> alpha=1
three_body: bool = True # use genuine 3-body H3 = 1/4 t3 rho_n rho_p rho
j2_terms: bool = True # include (t1,t2) J^2 tensor terms in E and Wso
# (VB/SIII: True; SLy4/SkM*: fit without them)
# Original Vautherin-Brink forces (1972). VALUES TO VERIFY against Table.
SI = Skyrme("SI", t0=-1057.3, t1=235.9, t2=-100.0, t3=14463.5, x0=0.56, W0=120.0)
SII = Skyrme("SII", t0=-1169.9, t1=585.6, t2= -27.1, t3= 9331.1, x0=0.34, W0=105.0)
# ---- nucleus / orbital bookkeeping ------------------------------------------
@dataclass
class Orbital:
n: int # radial node index (1 = lowest, i.e. 1s,1p,...)
l: int
j2: int # 2*j (odd integer)
@property
def deg(self) -> int: # (2j+1) full occupancy
return self.j2 + 1
@property
def sig_dot_l(self) -> float: # <sigma . l> = j(j+1)-l(l+1)-3/4 = l or -(l+1)
j = self.j2 / 2.0
return j*(j+1) - self.l*(self.l+1) - 0.75
def label(self) -> str:
spec = "spdfghijklm"
return f"{self.n}{spec[self.l]}{self.j2}/2"
@dataclass
class Nucleus:
name: str
Z: int
N: int
protons: list[Orbital]
neutrons: list[Orbital]
@property
def A(self) -> int:
return self.Z + self.N
def _orbs(spec_list):
"""Build orbitals from (n, l, j2) tuples."""
return [Orbital(n, l, j2) for (n, l, j2) in spec_list]
# spectroscopic filling sequences (n, l, 2j), in shell-model order
_FILL = {
"s16": [(1,0,1),(1,1,3),(1,1,1)],
"s20": [(1,0,1),(1,1,3),(1,1,1),(1,2,5),(2,0,1),(1,2,3)],
"s40": [(1,0,1),(1,1,3),(1,1,1),(1,2,5),(2,0,1),(1,2,3),
(1,3,7),(2,1,3),(1,3,5),(2,1,1)],
"s50": [(1,0,1),(1,1,3),(1,1,1),(1,2,5),(2,0,1),(1,2,3),
(1,3,7),(2,1,3),(1,3,5),(2,1,1),(1,4,9)],
"s82": [(1,0,1),(1,1,3),(1,1,1),(1,2,5),(2,0,1),(1,2,3),
(1,3,7),(2,1,3),(1,3,5),(2,1,1),(1,4,9),
(1,4,7),(2,2,5),(2,2,3),(3,0,1),(1,5,11)],
"s126": [(1,0,1),(1,1,3),(1,1,1),(1,2,5),(2,0,1),(1,2,3),
(1,3,7),(2,1,3),(1,3,5),(2,1,1),(1,4,9),
(1,4,7),(2,2,5),(2,2,3),(3,0,1),(1,5,11),
(1,5,9),(2,3,7),(2,3,5),(3,1,3),(3,1,1),(1,6,13)],
}
def _check(tag, orbs, want):
got = sum(o.deg for o in orbs)
assert got == want, f"{tag}: filled {got}, expected {want}"
def closed_shell_16O() -> Nucleus:
p = _orbs(_FILL["s16"]); n = _orbs(_FILL["s16"])
_check("16O p", p, 8); _check("16O n", n, 8)
return Nucleus("16O", 8, 8, p, n)
def closed_shell_40Ca() -> Nucleus:
p = _orbs(_FILL["s20"]); n = _orbs(_FILL["s20"])
_check("40Ca p", p, 20); _check("40Ca n", n, 20)
return Nucleus("40Ca", 20, 20, p, n)
def closed_shell_48Ca() -> Nucleus:
p = _orbs(_FILL["s20"]); n = _orbs(_FILL["s40"][:7]) # N=28: through 1f7/2
_check("48Ca p", p, 20); _check("48Ca n", n, 28)
return Nucleus("48Ca", 20, 28, p, n)
def closed_shell_90Zr() -> Nucleus:
p = _orbs(_FILL["s40"]); n = _orbs(_FILL["s50"])
_check("90Zr p", p, 40); _check("90Zr n", n, 50)
return Nucleus("90Zr", 40, 50, p, n)
def closed_shell_208Pb() -> Nucleus:
p = _orbs(_FILL["s82"]); n = _orbs(_FILL["s126"])
_check("208Pb p", p, 82); _check("208Pb n", n, 126)
return Nucleus("208Pb", 82, 126, p, n)
# ---- radial grid ------------------------------------------------------------
class Grid:
def __init__(self, rmax=20.0, h=0.1):
self.h = h
self.N = int(round(rmax / h))
self.r = (np.arange(self.N) + 1) * h # r_1..r_N, excludes origin
def integrate(self, f):
"""int f(r) 4 pi r^2 dr via trapezoid on the mesh (u(0)=0 boundary)."""
return 4.0 * np.pi * np.trapezoid(f * self.r**2, self.r)
# ---- densities from occupied orbitals --------------------------------------
def build_densities(grid, states_q):
"""states_q: list of (Orbital, R(r) array) for one isospin.
Returns rho, tau, J (spin-orbit density), all on grid.r."""
r = grid.r
rho = np.zeros_like(r); tau = np.zeros_like(r); J = np.zeros_like(r)
for orb, R in states_q:
w = orb.deg / (4.0 * np.pi)
dR = np.gradient(R, grid.h, edge_order=2)
rho += w * R**2
tau += w * (dR**2 + orb.l*(orb.l+1)/r**2 * R**2)
J += w * orb.sig_dot_l / r * R**2
return rho, tau, J
def coulomb_direct(rho_p, r, h):
"""Direct Coulomb potential of a spherical proton density:
V(r) = e^2 [ (1/r) int_0^r rho_p 4pi r'^2 dr' + int_r^inf rho_p 4pi r' dr' ].
Discretized so the inner sum uses shells j<=i and the outer uses j>i
(clean partition -- no double counting of the r'=r shell)."""
cum_in = np.cumsum(rho_p * r**2) # sum_{j<=i} rho r^2
rev_ge = np.cumsum((rho_p * r)[::-1])[::-1] # sum_{j>=i} rho r
outer = rev_ge - rho_p * r # sum_{j>i} rho r
return E2 * 4*np.pi*h * (cum_in / r + outer)
def deriv(f, h):
return np.gradient(f, h, edge_order=2)
def laplacian_radial(f, r, h):
"""Delta f = f'' + (2/r) f' for a spherically symmetric field."""
df = np.gradient(f, h, edge_order=2)
d2f = np.gradient(df, h, edge_order=2)
return d2f + 2.0/r * df
# ---- mean fields ------------------------------------------------------------
def mean_fields(force, grid, dens_n, dens_p):
"""Return per-isospin dicts of B (=hbar^2/2m*), U (central), Wso, and Vcoul.
dens_* = (rho, tau, J)."""
f = force; r = grid.r; h = grid.h
rho_n, tau_n, J_n = dens_n
rho_p, tau_p, J_p = dens_p
rho = rho_n + rho_p; tau = tau_n + tau_p; J = J_n + J_p
lap_rho = laplacian_radial(rho, r, h)
lap_rho_n = laplacian_radial(rho_n, r, h)
lap_rho_p = laplacian_radial(rho_p, r, h)
drho = deriv(rho, h)
drho_n = deriv(rho_n, h)
drho_p = deriv(rho_p, h)
def B_of(rho_q):
# hbar^2/2m*_q = hbar^2/2m + 1/8[t1(2+x1)+t2(2+x2)]rho
# + 1/8[t2(1+2x2) - t1(1+2x1)]rho_q
# (like-particle term: t1 enters with a MINUS -- from -(x1+1/2) in H_Sk)
return (HBAR2_2M
+ 0.125*(f.t1*(2+f.x1) + f.t2*(2+f.x2)) * rho
+ 0.125*(f.t2*(1+2*f.x2) - f.t1*(1+2*f.x1)) * rho_q)
def U_central(rho_q, tau_q, lap_q):
# t0
U = f.t0*((1+f.x0/2)*rho - (f.x0+0.5)*rho_q)
# t1
U += 0.25*f.t1*((1+f.x1/2)*(tau - 1.5*lap_rho)
- (f.x1+0.5)*(tau_q - 1.5*lap_q))
# t2
U += 0.25*f.t2*((1+f.x2/2)*(tau + 0.5*lap_rho)
+ (f.x2+0.5)*(tau_q + 0.5*lap_q))
return U
# three-body (genuine): U3,q = 1/4 t3 rho_{q'} (2 rho_q + rho_{q'})
if f.three_body:
U3_n = 0.25*f.t3 * rho_p * (2*rho_n + rho_p)
U3_p = 0.25*f.t3 * rho_n * (2*rho_p + rho_n)
else: # density-dependent generalized form
g = f.gamma
def U3(rho_q):
return (f.t3/12.0)*((1+f.x3/2)*(2+g)*rho**(g+1)
- (f.x3+0.5)*(2*rho**g*rho_q + g*rho**(g-1)*(rho_n**2+rho_p**2)))
U3_n, U3_p = U3(rho_n), U3(rho_p)
# spin-orbit central contribution (from W0 term): -(W0/2)[ (J+Jq)/r + 1/2 d/dr(J+Jq) ]
def U_so(J_q):
s = J + J_q
return -0.5*f.W0*(s/r + 0.5*deriv(s, h))
U_n = U_central(rho_n, tau_n, lap_rho_n) + U3_n + U_so(J_n)
U_p = U_central(rho_p, tau_p, lap_rho_p) + U3_p + U_so(J_p)
# spin-orbit form factor W_q (multiplies <sigma.l>/r in the s.p. eq)
def Wso(rho_q, J_q):
w = 0.5*f.W0*(drho + deriv(rho_q, h))
if f.j2_terms: # (t1,t2) J^2 contribution; x1=x2=0 -> 1/8(t1-t2)Jq
w += 0.125*(f.t1 - f.t2)*J_q - 0.125*(f.t1*f.x1 + f.t2*f.x2)*J
return w
W_n = Wso(rho_n, J_n)
W_p = Wso(rho_p, J_p)
# Coulomb (direct, spherical) + Slater exchange, protons only
Vc_dir = coulomb_direct(rho_p, r, h)
Vc_ex = -E2*(3.0/np.pi)**(1/3) * np.cbrt(rho_p)
Vcoul = Vc_dir + Vc_ex
return dict(B_n=B_of(rho_n), B_p=B_of(rho_p),
U_n=U_n, U_p=U_p, W_n=W_n, W_p=W_p, Vcoul=Vcoul)
# ---- single-particle solver for one (l, j, isospin) block -------------------
def solve_block(grid, l, sig_dot_l, B, U, W, Vcoul_or_zero, nstates):
"""Diagonalize the radial HF Hamiltonian; return (eps[:nstates], R[:,:nstates])."""
r = grid.r; h = grid.h; Nn = grid.N
# half-point effective mass (pad: B(0)~B[0], B(R_box)~HBAR2_2M vacuum)
Bpad = np.empty(Nn+2)
Bpad[1:-1] = B; Bpad[0] = B[0]; Bpad[-1] = HBAR2_2M
Bhalf = 0.5*(Bpad[:-1] + Bpad[1:]) # length N+1: B_{i-1/2}, i=1..N and B_{N+1/2}
Bm = Bhalf[:-1] # B_{i-1/2}
Bp = Bhalf[1:] # B_{i+1/2}
diag = (Bm + Bp)/h**2 \
+ B*l*(l+1)/r**2 \
+ U + Vcoul_or_zero \
+ sig_dot_l * W / r
off = -Bp[:-1]/h**2 # coupling i,i+1
H = np.diag(diag) + np.diag(off, 1) + np.diag(off, -1)
eps, vec = np.linalg.eigh(H)
# normalize so that int R^2 r^2 dr = 1 -> int u^2 dr = 1 -> sum u^2 h = 1
u = vec / np.sqrt(h)
R = u / r[:, None]
return eps[:nstates], R[:, :nstates]
def solve_isospin(grid, orbitals, B, U, W, Vcoul):
"""Solve all needed (l,j) blocks; return list of (Orbital, R) for occupied states."""
# group by (l, j2); the n index selects which eigenstate
need = {}
for o in orbitals:
need.setdefault((o.l, o.j2), 0)
need[(o.l, o.j2)] = max(need[(o.l, o.j2)], o.n)
solutions = {}
for (l, j2), nmax in need.items():
sdl = (j2/2.0)*(j2/2.0+1) - l*(l+1) - 0.75
eps, R = solve_block(grid, l, sdl, B, U, W, Vcoul, nmax)
solutions[(l, j2)] = (eps, R)
out = []
levels = []
for o in orbitals:
eps, R = solutions[(o.l, o.j2)]
out.append((o, R[:, o.n-1]))
levels.append((o, eps[o.n-1]))
return out, levels
# ---- total energy from the functional ---------------------------------------
def total_energy(force, grid, dens_n, dens_p, com_correction=True, A=None):
f = force; r = grid.r; h = grid.h
rho_n, tau_n, J_n = dens_n
rho_p, tau_p, J_p = dens_p
rho = rho_n+rho_p; tau = tau_n+tau_p; J = J_n+J_p
drho = deriv(rho, h); drho_n = deriv(rho_n, h); drho_p = deriv(rho_p, h)
kin_fac = 1.0
if com_correction and A:
kin_fac = (A-1)/A
H = HBAR2_2M*kin_fac*tau
H += 0.5*f.t0*((1+f.x0/2)*rho**2 - (f.x0+0.5)*(rho_n**2+rho_p**2))
H += 0.25*f.t1*((1+f.x1/2)*(rho*tau + 0.75*drho**2)
- (f.x1+0.5)*(rho_n*tau_n + 0.75*drho_n**2
+ rho_p*tau_p + 0.75*drho_p**2))
H += 0.25*f.t2*((1+f.x2/2)*(rho*tau - 0.25*drho**2)
+ (f.x2+0.5)*(rho_n*tau_n - 0.25*drho_n**2
+ rho_p*tau_p - 0.25*drho_p**2))
if f.three_body:
H += 0.25*f.t3 * rho_n * rho_p * rho
else:
H += (f.t3/12.0)*rho**f.gamma*((1+f.x3/2)*rho**2
- (f.x3+0.5)*(rho_n**2+rho_p**2))
# J^2 terms (x1=x2=0): + 1/16 (t1-t2) sum Jq^2
if f.j2_terms:
H += (1.0/16.0)*(f.t1 - f.t2)*(J_n**2 + J_p**2)
H += -(1.0/16.0)*(f.t1*f.x1 + f.t2*f.x2)*J**2
# spin-orbit W0 term: (W0/2)(J.drho + sum Jq.drho_q)
H += 0.5*f.W0*(J*drho + J_n*drho_n + J_p*drho_p)
E_nuc = 4*np.pi*np.trapezoid(H*r**2, r)
# Coulomb energy
Vc_dir = coulomb_direct(rho_p, r, h)
E_cdir = 0.5*4*np.pi*np.trapezoid(Vc_dir*rho_p*r**2, r)
E_cex = -0.75*E2*(3/np.pi)**(1/3)*4*np.pi*np.trapezoid(np.cbrt(rho_p)*rho_p*r**2, r)
return E_nuc + E_cdir + E_cex, dict(E_nuc=E_nuc, E_cdir=E_cdir, E_cex=E_cex)
# ---- self-consistent loop ---------------------------------------------------
def run_hf(force, nucleus, grid=None, maxiter=200, mix=0.4, tol=1e-6,
com_correction=False, verbose=True):
if grid is None:
grid = Grid()
r = grid.r
# initial guess: Fermi/Woods-Saxon-like densities
R0 = 1.2 * nucleus.A**(1/3)
a = 0.6
prof = 1.0/(1.0+np.exp((r-R0)/a))
rho_p = prof * nucleus.Z / (4*np.pi*np.trapezoid(prof*r**2, r))
rho_n = prof * nucleus.N / (4*np.pi*np.trapezoid(prof*r**2, r))
tau_n = np.zeros_like(r); tau_p = np.zeros_like(r)
J_n = np.zeros_like(r); J_p = np.zeros_like(r)
E_old = 0.0
for it in range(1, maxiter+1):
dens_n = (rho_n, tau_n, J_n)
dens_p = (rho_p, tau_p, J_p)
fld = mean_fields(force, grid, dens_n, dens_p)
occ_n, lev_n = solve_isospin(grid, nucleus.neutrons,
fld['B_n'], fld['U_n'], fld['W_n'],
np.zeros_like(r))
occ_p, lev_p = solve_isospin(grid, nucleus.protons,
fld['B_p'], fld['U_p'], fld['W_p'],
fld['Vcoul'])
nrho_n, ntau_n, nJ_n = build_densities(grid, occ_n)
nrho_p, ntau_p, nJ_p = build_densities(grid, occ_p)
# linear mixing
rho_n = (1-mix)*rho_n + mix*nrho_n
rho_p = (1-mix)*rho_p + mix*nrho_p
tau_n = (1-mix)*tau_n + mix*ntau_n
tau_p = (1-mix)*tau_p + mix*ntau_p
J_n = (1-mix)*J_n + mix*nJ_n
J_p = (1-mix)*J_p + mix*nJ_p
E, parts = total_energy(force, grid, (rho_n,tau_n,J_n), (rho_p,tau_p,J_p),
com_correction=com_correction, A=nucleus.A)
dE = E - E_old; E_old = E
if verbose and (it <= 3 or it % 10 == 0 or abs(dE) < tol):
print(f" it{it:3d} E={E:12.4f} BE/A={-E/nucleus.A:8.4f} dE={dE:+.2e}")
if abs(dE) < tol and it > 3:
break
# observables
r2p = 4*np.pi*np.trapezoid(rho_p*r**4, r) / nucleus.Z
r2n = 4*np.pi*np.trapezoid(rho_n*r**4, r) / nucleus.N
rms_p = np.sqrt(r2p); rms_n = np.sqrt(r2n)
rms_ch = np.sqrt(r2p + 0.64) # fold proton finite size (0.8 fm)^2
return dict(E=E, BE_per_A=-E/nucleus.A, parts=parts,
rms_p=rms_p, rms_n=rms_n, rms_ch=rms_ch,
levels_p=lev_p, levels_n=lev_n,
dens=(rho_n, rho_p), grid=grid, iters=it,
fields=fld,
dens_n=(rho_n, tau_n, J_n), dens_p=(rho_p, tau_p, J_p))
def sp_levels(res, orbitals_p, orbitals_n):
"""Energies (MeV) of any requested orbitals in the *converged* HF field --
including unoccupied 'particle' states above the Fermi surface."""
g = res["grid"]; fld = res["fields"]; z = np.zeros_like(g.r)
_, lev_p = solve_isospin(g, orbitals_p, fld["B_p"], fld["U_p"], fld["W_p"], fld["Vcoul"])
_, lev_n = solve_isospin(g, orbitals_n, fld["B_n"], fld["U_n"], fld["W_n"], z)
return lev_p, lev_n
# SIII (Beiner et al. 1975) -- same alpha=1, x3=1 structure as SI/SII
SIII = Skyrme("SIII", t0=-1128.75, t1=395.0, t2=-95.0, t3=14000.0, x0=0.45, W0=120.0)
# Modern forces with density-dependent t3 (gamma != 1) and x1,x2 != 0.
# These exercise the generalized 3-body path and are fit WITHOUT J^2 terms.
SLy4 = Skyrme("SLy4", t0=-2488.913, t1=486.818, t2=-546.395, t3=13777.0,
x0=0.834, x1=-0.3438, x2=-1.0, x3=1.354, W0=123.0,
gamma=1/6, three_body=False, j2_terms=False)
SkMs = Skyrme("SkM*", t0=-2645.0, t1=410.0, t2=-135.0, t3=15595.0,
x0=0.09, x1=0.0, x2=0.0, x3=0.0, W0=130.0,
gamma=1/6, three_body=False, j2_terms=False)
def mstar_over_m(force, rho0=0.16):
"""Symmetric-matter effective mass ratio, for validation against literature."""
B = (HBAR2_2M
+ 0.125*(force.t1*(2+force.x1)+force.t2*(2+force.x2))*rho0
+ 0.125*(force.t2*(1+2*force.x2)-force.t1*(1+2*force.x1))*(rho0/2))
return HBAR2_2M / B
if __name__ == "__main__":
# Reproduction of Vautherin & Brink across the doubly-magic chain.
# (A-1)/A c.m. correction ON -- matches VB convention.
EXPT = {"16O": (7.976, 2.699), "40Ca": (8.551, 3.478),
"48Ca": (8.666, 3.477), "90Zr": (8.710, 4.269),
"208Pb": (7.867, 5.501)}
NUCLEI = [closed_shell_16O(), closed_shell_40Ca(), closed_shell_48Ca(),
closed_shell_90Zr(), closed_shell_208Pb()]
for force in (SIII, SI, SII):
print(f"\n=== {force.name} (m*/m = {mstar_over_m(force):.3f}) ===")
print(f"{'nucleus':8s} {'BE/A':>7s} {'exp':>6s} {'d':>6s} "
f"{'r_ch':>6s} {'exp':>6s} {'d':>6s}")
for nuc in NUCLEI:
grid = Grid(rmax=22.0, h=0.1)
res = run_hf(force, nuc, grid=grid, mix=0.25, maxiter=400,
com_correction=True, verbose=False)
bea, rch = EXPT[nuc.name]
print(f"{nuc.name:8s} {res['BE_per_A']:7.3f} {bea:6.3f} "
f"{res['BE_per_A']-bea:+6.3f} {res['rms_ch']:6.3f} "
f"{rch:6.3f} {res['rms_ch']-rch:+6.3f}") """
Spherical Skyrme-Hartree-Fock solver -- Vautherin & Brink (Phys. Rev. C 5, 626, 1972).
Method: radial box + finite-difference matrix diagonalization (effective-mass
kinetic operator kept in self-adjoint divergence form and Hermitized on a
uniform mesh). Self-consistent loop with linear density mixing.
Functional conventions follow Xayavong & Smirnova, arXiv:2111.13531 App. A
(which derives them from Vautherin-Brink), EXCEPT:
- the genuine 3-body term is used directly: H3 = (1/4) t3 rho_n rho_p rho,
equivalently the density-dependent form with x3=1, gamma=1. Its s.p.
potential is derived by hand (their printed A10 has an x3-1/2 sign typo).
All energies in MeV, lengths in fm.
"""
from __future__ import annotations
import numpy as np
if not hasattr(np, "trapezoid"): # numpy < 2 compatibility
np.trapezoid = np.trapz
from dataclasses import dataclass, field
# ---- physical constants -----------------------------------------------------
HBAR2_2M = 20.7355 # hbar^2 / 2m [MeV fm^2], m = (m_p+m_n)/2 average nucleon
E2 = 1.439964 # e^2 [MeV fm] (elementary charge squared, Gaussian)
# ---- Skyrme force -----------------------------------------------------------
@dataclass
class Skyrme:
name: str
t0: float; t1: float; t2: float; t3: float
x0: float; W0: float
x1: float = 0.0; x2: float = 0.0; x3: float = 1.0 # VB: only x0, x1=x2=0
gamma: float = 1.0 # 3-body -> alpha=1
three_body: bool = True # use genuine 3-body H3 = 1/4 t3 rho_n rho_p rho
j2_terms: bool = True # include (t1,t2) J^2 tensor terms in E and Wso
# (VB/SIII: True; SLy4/SkM*: fit without them)
# Original Vautherin-Brink forces (1972). VALUES TO VERIFY against Table.
SI = Skyrme("SI", t0=-1057.3, t1=235.9, t2=-100.0, t3=14463.5, x0=0.56, W0=120.0)
SII = Skyrme("SII", t0=-1169.9, t1=585.6, t2= -27.1, t3= 9331.1, x0=0.34, W0=105.0)
# ---- nucleus / orbital bookkeeping ------------------------------------------
@dataclass
class Orbital:
n: int # radial node index (1 = lowest, i.e. 1s,1p,...)
l: int
j2: int # 2*j (odd integer)
@property
def deg(self) -> int: # (2j+1) full occupancy
return self.j2 + 1
@property
def sig_dot_l(self) -> float: # <sigma . l> = j(j+1)-l(l+1)-3/4 = l or -(l+1)
j = self.j2 / 2.0
return j*(j+1) - self.l*(self.l+1) - 0.75
def label(self) -> str:
spec = "spdfghijklm"
return f"{self.n}{spec[self.l]}{self.j2}/2"
@dataclass
class Nucleus:
name: str
Z: int
N: int
protons: list[Orbital]
neutrons: list[Orbital]
@property
def A(self) -> int:
return self.Z + self.N
def _orbs(spec_list):
"""Build orbitals from (n, l, j2) tuples."""
return [Orbital(n, l, j2) for (n, l, j2) in spec_list]
# spectroscopic filling sequences (n, l, 2j), in shell-model order
_FILL = {
"s16": [(1,0,1),(1,1,3),(1,1,1)],
"s20": [(1,0,1),(1,1,3),(1,1,1),(1,2,5),(2,0,1),(1,2,3)],
"s40": [(1,0,1),(1,1,3),(1,1,1),(1,2,5),(2,0,1),(1,2,3),
(1,3,7),(2,1,3),(1,3,5),(2,1,1)],
"s50": [(1,0,1),(1,1,3),(1,1,1),(1,2,5),(2,0,1),(1,2,3),
(1,3,7),(2,1,3),(1,3,5),(2,1,1),(1,4,9)],
"s82": [(1,0,1),(1,1,3),(1,1,1),(1,2,5),(2,0,1),(1,2,3),
(1,3,7),(2,1,3),(1,3,5),(2,1,1),(1,4,9),
(1,4,7),(2,2,5),(2,2,3),(3,0,1),(1,5,11)],
"s126": [(1,0,1),(1,1,3),(1,1,1),(1,2,5),(2,0,1),(1,2,3),
(1,3,7),(2,1,3),(1,3,5),(2,1,1),(1,4,9),
(1,4,7),(2,2,5),(2,2,3),(3,0,1),(1,5,11),
(1,5,9),(2,3,7),(2,3,5),(3,1,3),(3,1,1),(1,6,13)],
}
def _check(tag, orbs, want):
got = sum(o.deg for o in orbs)
assert got == want, f"{tag}: filled {got}, expected {want}"
def closed_shell_16O() -> Nucleus:
p = _orbs(_FILL["s16"]); n = _orbs(_FILL["s16"])
_check("16O p", p, 8); _check("16O n", n, 8)
return Nucleus("16O", 8, 8, p, n)
def closed_shell_40Ca() -> Nucleus:
p = _orbs(_FILL["s20"]); n = _orbs(_FILL["s20"])
_check("40Ca p", p, 20); _check("40Ca n", n, 20)
return Nucleus("40Ca", 20, 20, p, n)
def closed_shell_48Ca() -> Nucleus:
p = _orbs(_FILL["s20"]); n = _orbs(_FILL["s40"][:7]) # N=28: through 1f7/2
_check("48Ca p", p, 20); _check("48Ca n", n, 28)
return Nucleus("48Ca", 20, 28, p, n)
def closed_shell_90Zr() -> Nucleus:
p = _orbs(_FILL["s40"]); n = _orbs(_FILL["s50"])
_check("90Zr p", p, 40); _check("90Zr n", n, 50)
return Nucleus("90Zr", 40, 50, p, n)
def closed_shell_208Pb() -> Nucleus:
p = _orbs(_FILL["s82"]); n = _orbs(_FILL["s126"])
_check("208Pb p", p, 82); _check("208Pb n", n, 126)
return Nucleus("208Pb", 82, 126, p, n)
# ---- radial grid ------------------------------------------------------------
class Grid:
def __init__(self, rmax=20.0, h=0.1):
self.h = h
self.N = int(round(rmax / h))
self.r = (np.arange(self.N) + 1) * h # r_1..r_N, excludes origin
def integrate(self, f):
"""int f(r) 4 pi r^2 dr via trapezoid on the mesh (u(0)=0 boundary)."""
return 4.0 * np.pi * np.trapezoid(f * self.r**2, self.r)
# ---- densities from occupied orbitals --------------------------------------
def build_densities(grid, states_q):
"""states_q: list of (Orbital, R(r) array) for one isospin.
Returns rho, tau, J (spin-orbit density), all on grid.r."""
r = grid.r
rho = np.zeros_like(r); tau = np.zeros_like(r); J = np.zeros_like(r)
for orb, R in states_q:
w = orb.deg / (4.0 * np.pi)
dR = np.gradient(R, grid.h, edge_order=2)
rho += w * R**2
tau += w * (dR**2 + orb.l*(orb.l+1)/r**2 * R**2)
J += w * orb.sig_dot_l / r * R**2
return rho, tau, J
def coulomb_direct(rho_p, r, h):
"""Direct Coulomb potential of a spherical proton density:
V(r) = e^2 [ (1/r) int_0^r rho_p 4pi r'^2 dr' + int_r^inf rho_p 4pi r' dr' ].
Discretized so the inner sum uses shells j<=i and the outer uses j>i
(clean partition -- no double counting of the r'=r shell)."""
cum_in = np.cumsum(rho_p * r**2) # sum_{j<=i} rho r^2
rev_ge = np.cumsum((rho_p * r)[::-1])[::-1] # sum_{j>=i} rho r
outer = rev_ge - rho_p * r # sum_{j>i} rho r
return E2 * 4*np.pi*h * (cum_in / r + outer)
def deriv(f, h):
return np.gradient(f, h, edge_order=2)
def laplacian_radial(f, r, h):
"""Delta f = f'' + (2/r) f' for a spherically symmetric field."""
df = np.gradient(f, h, edge_order=2)
d2f = np.gradient(df, h, edge_order=2)
return d2f + 2.0/r * df
# ---- mean fields ------------------------------------------------------------
def mean_fields(force, grid, dens_n, dens_p):
"""Return per-isospin dicts of B (=hbar^2/2m*), U (central), Wso, and Vcoul.
dens_* = (rho, tau, J)."""
f = force; r = grid.r; h = grid.h
rho_n, tau_n, J_n = dens_n
rho_p, tau_p, J_p = dens_p
rho = rho_n + rho_p; tau = tau_n + tau_p; J = J_n + J_p
lap_rho = laplacian_radial(rho, r, h)
lap_rho_n = laplacian_radial(rho_n, r, h)
lap_rho_p = laplacian_radial(rho_p, r, h)
drho = deriv(rho, h)
drho_n = deriv(rho_n, h)
drho_p = deriv(rho_p, h)
def B_of(rho_q):
# hbar^2/2m*_q = hbar^2/2m + 1/8[t1(2+x1)+t2(2+x2)]rho
# + 1/8[t2(1+2x2) - t1(1+2x1)]rho_q
# (like-particle term: t1 enters with a MINUS -- from -(x1+1/2) in H_Sk)
return (HBAR2_2M
+ 0.125*(f.t1*(2+f.x1) + f.t2*(2+f.x2)) * rho
+ 0.125*(f.t2*(1+2*f.x2) - f.t1*(1+2*f.x1)) * rho_q)
def U_central(rho_q, tau_q, lap_q):
# t0
U = f.t0*((1+f.x0/2)*rho - (f.x0+0.5)*rho_q)
# t1
U += 0.25*f.t1*((1+f.x1/2)*(tau - 1.5*lap_rho)
- (f.x1+0.5)*(tau_q - 1.5*lap_q))
# t2
U += 0.25*f.t2*((1+f.x2/2)*(tau + 0.5*lap_rho)
+ (f.x2+0.5)*(tau_q + 0.5*lap_q))
return U
# three-body (genuine): U3,q = 1/4 t3 rho_{q'} (2 rho_q + rho_{q'})
if f.three_body:
U3_n = 0.25*f.t3 * rho_p * (2*rho_n + rho_p)
U3_p = 0.25*f.t3 * rho_n * (2*rho_p + rho_n)
else: # density-dependent generalized form
g = f.gamma
def U3(rho_q):
return (f.t3/12.0)*((1+f.x3/2)*(2+g)*rho**(g+1)
- (f.x3+0.5)*(2*rho**g*rho_q + g*rho**(g-1)*(rho_n**2+rho_p**2)))
U3_n, U3_p = U3(rho_n), U3(rho_p)
# spin-orbit central contribution (from W0 term): -(W0/2)[ (J+Jq)/r + 1/2 d/dr(J+Jq) ]
def U_so(J_q):
s = J + J_q
return -0.5*f.W0*(s/r + 0.5*deriv(s, h))
U_n = U_central(rho_n, tau_n, lap_rho_n) + U3_n + U_so(J_n)
U_p = U_central(rho_p, tau_p, lap_rho_p) + U3_p + U_so(J_p)
# spin-orbit form factor W_q (multiplies <sigma.l>/r in the s.p. eq)
def Wso(rho_q, J_q):
w = 0.5*f.W0*(drho + deriv(rho_q, h))
if f.j2_terms: # (t1,t2) J^2 contribution; x1=x2=0 -> 1/8(t1-t2)Jq
w += 0.125*(f.t1 - f.t2)*J_q - 0.125*(f.t1*f.x1 + f.t2*f.x2)*J
return w
W_n = Wso(rho_n, J_n)
W_p = Wso(rho_p, J_p)
# Coulomb (direct, spherical) + Slater exchange, protons only
Vc_dir = coulomb_direct(rho_p, r, h)
Vc_ex = -E2*(3.0/np.pi)**(1/3) * np.cbrt(rho_p)
Vcoul = Vc_dir + Vc_ex
return dict(B_n=B_of(rho_n), B_p=B_of(rho_p),
U_n=U_n, U_p=U_p, W_n=W_n, W_p=W_p, Vcoul=Vcoul)
# ---- single-particle solver for one (l, j, isospin) block -------------------
def solve_block(grid, l, sig_dot_l, B, U, W, Vcoul_or_zero, nstates):
"""Diagonalize the radial HF Hamiltonian; return (eps[:nstates], R[:,:nstates])."""
r = grid.r; h = grid.h; Nn = grid.N
# half-point effective mass (pad: B(0)~B[0], B(R_box)~HBAR2_2M vacuum)
Bpad = np.empty(Nn+2)
Bpad[1:-1] = B; Bpad[0] = B[0]; Bpad[-1] = HBAR2_2M
Bhalf = 0.5*(Bpad[:-1] + Bpad[1:]) # length N+1: B_{i-1/2}, i=1..N and B_{N+1/2}
Bm = Bhalf[:-1] # B_{i-1/2}
Bp = Bhalf[1:] # B_{i+1/2}
diag = (Bm + Bp)/h**2 \
+ B*l*(l+1)/r**2 \
+ U + Vcoul_or_zero \
+ sig_dot_l * W / r
off = -Bp[:-1]/h**2 # coupling i,i+1
H = np.diag(diag) + np.diag(off, 1) + np.diag(off, -1)
eps, vec = np.linalg.eigh(H)
# normalize so that int R^2 r^2 dr = 1 -> int u^2 dr = 1 -> sum u^2 h = 1
u = vec / np.sqrt(h)
R = u / r[:, None]
return eps[:nstates], R[:, :nstates]
def solve_isospin(grid, orbitals, B, U, W, Vcoul):
"""Solve all needed (l,j) blocks; return list of (Orbital, R) for occupied states."""
# group by (l, j2); the n index selects which eigenstate
need = {}
for o in orbitals:
need.setdefault((o.l, o.j2), 0)
need[(o.l, o.j2)] = max(need[(o.l, o.j2)], o.n)
solutions = {}
for (l, j2), nmax in need.items():
sdl = (j2/2.0)*(j2/2.0+1) - l*(l+1) - 0.75
eps, R = solve_block(grid, l, sdl, B, U, W, Vcoul, nmax)
solutions[(l, j2)] = (eps, R)
out = []
levels = []
for o in orbitals:
eps, R = solutions[(o.l, o.j2)]
out.append((o, R[:, o.n-1]))
levels.append((o, eps[o.n-1]))
return out, levels
# ---- total energy from the functional ---------------------------------------
def total_energy(force, grid, dens_n, dens_p, com_correction=True, A=None):
f = force; r = grid.r; h = grid.h
rho_n, tau_n, J_n = dens_n
rho_p, tau_p, J_p = dens_p
rho = rho_n+rho_p; tau = tau_n+tau_p; J = J_n+J_p
drho = deriv(rho, h); drho_n = deriv(rho_n, h); drho_p = deriv(rho_p, h)
kin_fac = 1.0
if com_correction and A:
kin_fac = (A-1)/A
H = HBAR2_2M*kin_fac*tau
H += 0.5*f.t0*((1+f.x0/2)*rho**2 - (f.x0+0.5)*(rho_n**2+rho_p**2))
H += 0.25*f.t1*((1+f.x1/2)*(rho*tau + 0.75*drho**2)
- (f.x1+0.5)*(rho_n*tau_n + 0.75*drho_n**2
+ rho_p*tau_p + 0.75*drho_p**2))
H += 0.25*f.t2*((1+f.x2/2)*(rho*tau - 0.25*drho**2)
+ (f.x2+0.5)*(rho_n*tau_n - 0.25*drho_n**2
+ rho_p*tau_p - 0.25*drho_p**2))
if f.three_body:
H += 0.25*f.t3 * rho_n * rho_p * rho
else:
H += (f.t3/12.0)*rho**f.gamma*((1+f.x3/2)*rho**2
- (f.x3+0.5)*(rho_n**2+rho_p**2))
# J^2 terms (x1=x2=0): + 1/16 (t1-t2) sum Jq^2
if f.j2_terms:
H += (1.0/16.0)*(f.t1 - f.t2)*(J_n**2 + J_p**2)
H += -(1.0/16.0)*(f.t1*f.x1 + f.t2*f.x2)*J**2
# spin-orbit W0 term: (W0/2)(J.drho + sum Jq.drho_q)
H += 0.5*f.W0*(J*drho + J_n*drho_n + J_p*drho_p)
E_nuc = 4*np.pi*np.trapezoid(H*r**2, r)
# Coulomb energy
Vc_dir = coulomb_direct(rho_p, r, h)
E_cdir = 0.5*4*np.pi*np.trapezoid(Vc_dir*rho_p*r**2, r)
E_cex = -0.75*E2*(3/np.pi)**(1/3)*4*np.pi*np.trapezoid(np.cbrt(rho_p)*rho_p*r**2, r)
return E_nuc + E_cdir + E_cex, dict(E_nuc=E_nuc, E_cdir=E_cdir, E_cex=E_cex)
# ---- self-consistent loop ---------------------------------------------------
def run_hf(force, nucleus, grid=None, maxiter=200, mix=0.4, tol=1e-6,
com_correction=False, verbose=True):
if grid is None:
grid = Grid()
r = grid.r
# initial guess: Fermi/Woods-Saxon-like densities
R0 = 1.2 * nucleus.A**(1/3)
a = 0.6
prof = 1.0/(1.0+np.exp((r-R0)/a))
rho_p = prof * nucleus.Z / (4*np.pi*np.trapezoid(prof*r**2, r))
rho_n = prof * nucleus.N / (4*np.pi*np.trapezoid(prof*r**2, r))
tau_n = np.zeros_like(r); tau_p = np.zeros_like(r)
J_n = np.zeros_like(r); J_p = np.zeros_like(r)
E_old = 0.0
for it in range(1, maxiter+1):
dens_n = (rho_n, tau_n, J_n)
dens_p = (rho_p, tau_p, J_p)
fld = mean_fields(force, grid, dens_n, dens_p)
occ_n, lev_n = solve_isospin(grid, nucleus.neutrons,
fld['B_n'], fld['U_n'], fld['W_n'],
np.zeros_like(r))
occ_p, lev_p = solve_isospin(grid, nucleus.protons,
fld['B_p'], fld['U_p'], fld['W_p'],
fld['Vcoul'])
nrho_n, ntau_n, nJ_n = build_densities(grid, occ_n)
nrho_p, ntau_p, nJ_p = build_densities(grid, occ_p)
# linear mixing
rho_n = (1-mix)*rho_n + mix*nrho_n
rho_p = (1-mix)*rho_p + mix*nrho_p
tau_n = (1-mix)*tau_n + mix*ntau_n
tau_p = (1-mix)*tau_p + mix*ntau_p
J_n = (1-mix)*J_n + mix*nJ_n
J_p = (1-mix)*J_p + mix*nJ_p
E, parts = total_energy(force, grid, (rho_n,tau_n,J_n), (rho_p,tau_p,J_p),
com_correction=com_correction, A=nucleus.A)
dE = E - E_old; E_old = E
if verbose and (it <= 3 or it % 10 == 0 or abs(dE) < tol):
print(f" it{it:3d} E={E:12.4f} BE/A={-E/nucleus.A:8.4f} dE={dE:+.2e}")
if abs(dE) < tol and it > 3:
break
# observables
r2p = 4*np.pi*np.trapezoid(rho_p*r**4, r) / nucleus.Z
r2n = 4*np.pi*np.trapezoid(rho_n*r**4, r) / nucleus.N
rms_p = np.sqrt(r2p); rms_n = np.sqrt(r2n)
rms_ch = np.sqrt(r2p + 0.64) # fold proton finite size (0.8 fm)^2
return dict(E=E, BE_per_A=-E/nucleus.A, parts=parts,
rms_p=rms_p, rms_n=rms_n, rms_ch=rms_ch,
levels_p=lev_p, levels_n=lev_n,
dens=(rho_n, rho_p), grid=grid, iters=it,
fields=fld,
dens_n=(rho_n, tau_n, J_n), dens_p=(rho_p, tau_p, J_p))
def sp_levels(res, orbitals_p, orbitals_n):
"""Energies (MeV) of any requested orbitals in the *converged* HF field --
including unoccupied 'particle' states above the Fermi surface."""
g = res["grid"]; fld = res["fields"]; z = np.zeros_like(g.r)
_, lev_p = solve_isospin(g, orbitals_p, fld["B_p"], fld["U_p"], fld["W_p"], fld["Vcoul"])
_, lev_n = solve_isospin(g, orbitals_n, fld["B_n"], fld["U_n"], fld["W_n"], z)
return lev_p, lev_n
# SIII (Beiner et al. 1975) -- same alpha=1, x3=1 structure as SI/SII
SIII = Skyrme("SIII", t0=-1128.75, t1=395.0, t2=-95.0, t3=14000.0, x0=0.45, W0=120.0)
# Modern forces with density-dependent t3 (gamma != 1) and x1,x2 != 0.
# These exercise the generalized 3-body path and are fit WITHOUT J^2 terms.
SLy4 = Skyrme("SLy4", t0=-2488.913, t1=486.818, t2=-546.395, t3=13777.0,
x0=0.834, x1=-0.3438, x2=-1.0, x3=1.354, W0=123.0,
gamma=1/6, three_body=False, j2_terms=False)
SkMs = Skyrme("SkM*", t0=-2645.0, t1=410.0, t2=-135.0, t3=15595.0,
x0=0.09, x1=0.0, x2=0.0, x3=0.0, W0=130.0,
gamma=1/6, three_body=False, j2_terms=False)
def mstar_over_m(force, rho0=0.16):
"""Symmetric-matter effective mass ratio, for validation against literature."""
B = (HBAR2_2M
+ 0.125*(force.t1*(2+force.x1)+force.t2*(2+force.x2))*rho0
+ 0.125*(force.t2*(1+2*force.x2)-force.t1*(1+2*force.x1))*(rho0/2))
return HBAR2_2M / B
if __name__ == "__main__":
# Reproduction of Vautherin & Brink across the doubly-magic chain.
# (A-1)/A c.m. correction ON -- matches VB convention.
EXPT = {"16O": (7.976, 2.699), "40Ca": (8.551, 3.478),
"48Ca": (8.666, 3.477), "90Zr": (8.710, 4.269),
"208Pb": (7.867, 5.501)}
NUCLEI = [closed_shell_16O(), closed_shell_40Ca(), closed_shell_48Ca(),
closed_shell_90Zr(), closed_shell_208Pb()]
for force in (SIII, SI, SII):
print(f"\n=== {force.name} (m*/m = {mstar_over_m(force):.3f}) ===")
print(f"{'nucleus':8s} {'BE/A':>7s} {'exp':>6s} {'d':>6s} "
f"{'r_ch':>6s} {'exp':>6s} {'d':>6s}")
for nuc in NUCLEI:
grid = Grid(rmax=22.0, h=0.1)
res = run_hf(force, nuc, grid=grid, mix=0.25, maxiter=400,
com_correction=True, verbose=False)
bea, rch = EXPT[nuc.name]
print(f"{nuc.name:8s} {res['BE_per_A']:7.3f} {bea:6.3f} "
f"{res['BE_per_A']-bea:+6.3f} {res['rms_ch']:6.3f} "
f"{rch:6.3f} {res['rms_ch']-rch:+6.3f}")