Heisenberg Chain
Ground state
In this section, we compute the ground-state, finite-T and dynamical properties of a Heisenberg chain, whose Hamiltonian reads
$H = J\sum_{i}S_{i} S_{i+1}$. Here we set $J=1$ as energy unit and use spin SU(2) symmetry.
using FiniteMPS
using CairoMakie, Statistics # visualization
using LsqFit: curve_fit
using NumericalIntegration: integrate
mkpath("figs_Heisenberg") # save figures
# parameters
L = 32
D = 128 # max bond dimension
# generate the Hamiltonian MPO
Tree = InteractionTree(L)
for i in 1:L-1
addIntr!(Tree, SU2Spin.SS, (i, i+1), (false, false), 1.0; name = (:S, :S))
end
H = AutomataMPO(Tree)
SparseMPO{32}: total memory = 88.936 KiB
Bond 0-> 1: 1 -> 1
Bond 1-> 2: 2 -> 4
Bond 2-> 3: 3 -> 5
Bond 3-> 4: 3 -> 5
Bond 4-> 5: 3 -> 5
Bond 5-> 6: 3 -> 5
Bond 6-> 7: 3 -> 5
Bond 7-> 8: 3 -> 5
Bond 8-> 9: 3 -> 5
Bond 9->10: 3 -> 5
Bond 10->11: 3 -> 5
Bond 11->12: 3 -> 5
Bond 12->13: 3 -> 5
Bond 13->14: 3 -> 5
Bond 14->15: 3 -> 5
Bond 15->16: 3 -> 5
Bond 16->17: 3 -> 5
Bond 17->18: 3 -> 5
Bond 18->19: 3 -> 5
Bond 19->20: 3 -> 5
Bond 20->21: 3 -> 5
Bond 21->22: 3 -> 5
Bond 22->23: 3 -> 5
Bond 23->24: 3 -> 5
Bond 24->25: 3 -> 5
Bond 25->26: 3 -> 5
Bond 26->27: 3 -> 5
Bond 27->28: 3 -> 5
Bond 28->29: 3 -> 5
Bond 29->30: 3 -> 5
Bond 30->31: 3 -> 5
Bond 31->32: 2 -> 4
Here SU2Spin.SS
is a predefined 2-tuple of rank-3 operators that represents the Heisenberg interaction $S_i\cdot S_j$. Next, we initialize a random state in $S_\textrm{tot}^z = 0$ sector.
# initialize a random state in S_tot = 0 sector
bspace = Rep[SU₂](0 => 1)
aspace = Rep[SU₂](i => 1 for i in 0:1/2:1)
Ψ = randMPS(fill(SU2Spin.pspace, L), vcat(bspace, fill(aspace, L-1)))
MPS{32, Float64, StoreMemory}: Center = [1, 1], Norm = 1.0, total memory = 11.758 KiB
Bond 0-> 1: Rep[SU₂](0=>1), dim = 1 -> 1
Bond 1-> 2: Rep[SU₂](1/2=>1), dim = 1 -> 2
Bond 2-> 3: Rep[SU₂](0=>1, 1=>1), dim = 2 -> 4
Bond 3-> 4: Rep[SU₂](1/2=>1), dim = 1 -> 2
Bond 4-> 5: Rep[SU₂](0=>1, 1=>1), dim = 2 -> 4
Bond 5-> 6: Rep[SU₂](1/2=>1), dim = 1 -> 2
Bond 6-> 7: Rep[SU₂](0=>1, 1=>1), dim = 2 -> 4
Bond 7-> 8: Rep[SU₂](1/2=>1), dim = 1 -> 2
Bond 8-> 9: Rep[SU₂](0=>1, 1=>1), dim = 2 -> 4
Bond 9->10: Rep[SU₂](1/2=>1), dim = 1 -> 2
Bond 10->11: Rep[SU₂](0=>1, 1=>1), dim = 2 -> 4
Bond 11->12: Rep[SU₂](1/2=>1), dim = 1 -> 2
Bond 12->13: Rep[SU₂](0=>1, 1=>1), dim = 2 -> 4
Bond 13->14: Rep[SU₂](1/2=>1), dim = 1 -> 2
Bond 14->15: Rep[SU₂](0=>1, 1=>1), dim = 2 -> 4
Bond 15->16: Rep[SU₂](1/2=>1), dim = 1 -> 2
Bond 16->17: Rep[SU₂](0=>1, 1=>1), dim = 2 -> 4
Bond 17->18: Rep[SU₂](1/2=>1), dim = 1 -> 2
Bond 18->19: Rep[SU₂](0=>1, 1=>1), dim = 2 -> 4
Bond 19->20: Rep[SU₂](1/2=>1), dim = 1 -> 2
Bond 20->21: Rep[SU₂](0=>1, 1=>1), dim = 2 -> 4
Bond 21->22: Rep[SU₂](1/2=>1), dim = 1 -> 2
Bond 22->23: Rep[SU₂](0=>1, 1=>1), dim = 2 -> 4
Bond 23->24: Rep[SU₂](1/2=>1), dim = 1 -> 2
Bond 24->25: Rep[SU₂](0=>1, 1=>1), dim = 2 -> 4
Bond 25->26: Rep[SU₂](1/2=>1), dim = 1 -> 2
Bond 26->27: Rep[SU₂](0=>1, 1=>1), dim = 2 -> 4
Bond 27->28: Rep[SU₂](1/2=>1), dim = 1 -> 2
Bond 28->29: Rep[SU₂](0=>1, 1=>1), dim = 2 -> 4
Bond 29->30: Rep[SU₂](1/2=>1), dim = 1 -> 2
Bond 30->31: Rep[SU₂](0=>1, 1=>1), dim = 2 -> 4
Bond 31->32: Rep[SU₂](1/2=>1), dim = 1 -> 2
Here bspace = Rep[SU₂](0 => 1)
is the space of left boundary bond, 0
is the SU(2) quantum number of the total MPS, and 1
is the multiplicity of the trivial representation, therefore this setup indicates the total MPS is a SU(2) scalar.
aspace
is the space of bulk bonds. Note the fusion of physical space and bond space leads to a constrain due to symmetry. For example, the SU(2) quantum numbers of the bonds exhibit a integer/half integer oscillation, as the physical space exactly shifts the bond quantum number by 1/2. Here we use a larger (with redundancy) initial bond space so that contraction of bond indices gives a non-vanished result.
# DMRG
NSweeps = 5
Env = Environment(Ψ', H, Ψ)
lsE = [scalar!(Env)] # initial energy
for nsweep in 1:NSweeps
info, TO = DMRGSweep2!(Env; K = 16, trunc = truncdim(D))
push!(lsE, info[2][1].Eg)
end
Eg = lsE[end]
-13.997315618224954
Here we first construct the tri-layer environment to store the environment tensors of the local 2-site projective Hamiltonian in MPS-based DMRG. scalar!
method triggers full contraction of the total tri-layer tensor network thus gives the initial energy.
Then we perform NSweep
times 2-DMRG sweeping via the key function DMRGSweep2!
, where K = 16
is the Krylov space dimension and trunc = truncdim(D)
is a TensorKit.jl syntax that determines the truncation scheme, i.e. keep up to D
bond dimension. Returned info
stores the information of a DMRG sweep and TO
is a TimerOutput
object contains the time usage. Here we directly extract the energy from the output information, one can also use scalar!
again.
# plot the energy vs nsweep
fig = Figure(size = (480, 240))
ax = Axis(fig[1, 1];
xlabel = "nsweep",
ylabel = "energy per site")
scatterlines!(ax, lsE / L) # per site
save("figs_Heisenberg/GS_energy.png", fig)
Then we compute the ground-state spin correlations.
# compute spin correlations
ObsTree = ObservableTree(L)
for i in 1:L, j in i:L
addObs!(ObsTree, SU2Spin.SS, (i, j), (false, false); name = (:S, :S))
end
calObs!(ObsTree, Ψ)
Obs = convert(Dict, ObsTree)
# average spin correlation with AFM correction
lsr = 1:L-1
lsSr = map(lsr) do r
(-1.0) ^ r * mean(1:L-r) do i
# note SU2Spin.SS corresponds to S⋅S
Obs["SS"][(i, i + r)] / 3
end
end
# plot
fig = Figure(size = (480, 240))
ax = Axis(fig[1, 1];
xlabel = L"r",
ylabel = L"(-1)^r S(r)",
xscale = log10,
yscale = log10,
limits = (1, L, nothing, nothing),
xticks = 2.0 .^ (0:1:log(L)/log(2)),
yticks = (10.0 .^ (-3:1:0), [L"10^{-3}", L"10^{-2}", L"10^{-1}", L"10^{0}"]),
)
scatterlines!(ax, lsr, lsSr)
# fit
ids = 3:2:div(L, 2)
lsx = log.(lsr[ids])
lsy = log.(lsSr[ids])
@. model(x, p) = p[1] + p[2] * x
fit = curve_fit(model, lsx, lsy, [-log(4), -1])
lines!(ax, exp.(lsx), exp.(model(lsx, fit.param));
color = :red,
label = L"\sim r^{%$(round(fit.param[2]; digits = 3))}"
)
axislegend(ax; position = (0, 0))
save("figs_Heisenberg/GS_Sr.png", fig)
We reproduce the algebraic decay behaver $ (-1)^rS(r) \sim 1/r. $
Finite temperature
Now we move to the finite-temperature properties via tanTRG, which belongs to an imaginary-time-evolution method based on TDVP of MPO. Note we will use CBE-TDVP to accelerate the computation.
# define a beta list
lsβ = vcat(2.0 .^ (-15:0), 2:16)
lslnZ = fill(NaN, length(lsβ))
lsE = fill(NaN, length(lsβ))
lsSM = fill(NaN, length(lsβ)) # AFM struct factor S(pi, pi)
# compute S(pi, pi) at ground state for comparison
calObs!(ObsTree, Ψ)
Obs = convert(Dict, ObsTree)
function calSM(Obs::Dict)
return sum([(i, j) for i in 1:L for j in i:L]) do (i, j)
Sij = (-1.0)^abs(i - j) * Obs["SS"][(i, j)] / (3*L)
return i == j ? Sij : 2 * Sij
end
end
SM_GS = calSM(Obs)
# SETTN
ρ, lsF_SETTN = SETTN(H, lsβ[1];
CBEAlg = NaiveCBE(D + div(D, 4), 1e-8; rsvd = true),
lsnoise = [0.01, 0.001], tol = 1e-12,
trunc = truncdim(D) & truncbelow(1e-16),
)
lslnZ[1] = 2 * log(norm(ρ))
normalize!(ρ)
# generate the trilayer environment
Env = Environment(ρ', H, ρ)
lsE[1] = scalar!(Env)
calObs!(ObsTree, ρ)
Obs = convert(Dict, ObsTree)
lsSM[1] = calSM(Obs)
0.2500036955434818
First we define a $\beta$ list, which determines the step length of imaginary-time cooling. SETTN is adopted to initialize a high-temperature MPO, where CBEAlg
indicates the algorithm to implement CBE. Currently only NaiveCBE
is valid, where we directly find the optimal subspace via a svd (use random svd to accelerate), and the expanded bond dimension is set as D + div(D, 4)
. lsnoise
sets the noise applied in the first several sweeps of variational multiplication. Next we use TDVP to cool down the system.
# TDVP cooling
for idx in 2:length(lsβ)
dβ = lsβ[idx] - lsβ[idx-1]
TDVPSweep1!(Env, -dβ / 2;
CBEAlg = NaiveCBE(D + div(D, 4), 1e-8; rsvd = true),
trunc = truncdim(D), GCsweep = true,
)
lslnZ[idx] = lslnZ[idx-1] + 2 * log(norm(ρ))
normalize!(ρ)
lsE[idx] = scalar!(Env)
# update data stored in ObsTree
calObs!(ObsTree, ρ)
lsSM[idx] = calSM(convert(Dict, ObsTree))
end
The key function in this part is TDVPSweep1!
that performs a single left-to-right and right-to-left 1-TDVP sweep. GCsweep = true
indicates that a manual GC.gc()
is called per sweep. If you suffer memory problem when using FiniteMPS.jl, the first thing to try is setting CGsweep = true
and a stronger GCstep = true
in the main sweeping function (e.g. DMRGSweep1!
and TDVPSweep1!
).
In each sweep, the normalization factor after an imaginary-time evolution is extracted to calculate the partition function lnZ
. In order to calculate spin correlations, regenerating ObsTree
is not necessary, just use calObs!
again to trigger the in-place update with the new MPO ρ
.
Below is a simple script for visualization, where the temperature dependence of energy $e$, specific heat $c_V$ and AFM structure factor $S(\pi, \pi)$ are shown.
# visualization
# compute C = - ∂S / ∂lnβ
lsS = lsβ .* lsE .+ lslnZ
lslnβ = log.(lsβ)
lsCv = - diff(lsS) ./ diff(lslnβ)
lsβ_c = exp.((lslnβ[1:end-1] + lslnβ[2:end])/2)
fig = Figure(size = (480, 400))
ax1, ax2, ax3 = map(1:3, [L"e", L"c_V", L"S(\pi, \pi)"]) do idx, ylabel
ax = Axis(fig[idx, 1];
xlabel = L"T",
ylabel = ylabel,
xscale = log10,
limits = (0.05, 10, nothing, nothing),
xticks = (10.0 .^ (-2:1:1), [L"10^{-2}", L"10^{-1}", L"10^0", L"10^1"]),
xminorticks = vcat(0.02:0.01:0.09, 0.2:0.1:0.9, 2:9),
xminorticksvisible = true)
idx < 3 && hidexdecorations!(ax; grid = false, ticks = false)
ax
end
scatterlines!(ax1, 1 ./ lsβ, lsE ./ L)
lines!(ax1, [0.05, 1], [Eg/L, Eg/L]; color = :red, label = "DMRG")
axislegend(ax1; position = (0, 0.5))
scatterlines!(ax2, 1 ./ lsβ_c, lsCv ./ L)
scatterlines!(ax3, 1 ./ lsβ, lsSM)
lines!(ax3, [0.05, 1], [SM_GS, SM_GS]; color = :red)
save("figs_Heisenberg/FiniteT.png", fig)
From this example we see that the low-temperature limit of tanTRG does shake hands with the ground state DMRG.
Spin dynamics
In this section we will compute the ground-state dynamical spin structure factor (DSF) $S(k,\omega) = \sum_{i = 1}^L e^{-ik(r_i-r_j)} \int_{-\infty}^\infty dt e^{i\omega t}\langle S_i^z(t)S_j^z\rangle$, where $j$ is chosen as a reference site. More specifically, we will use TDVP to compute $\langle S_i^z(t)S_j^z\rangle = e^{iE_gt}\langle \Psi| S_i^z |\Phi(t)\rangle$ where $|\Phi(t)\rangle = e^{-iHt}S_j^z|\Psi\rangle$.
lst = 0:1.0:10 # time list
matSijt = zeros(ComplexF64, L, length(lst)) # S_{i,j_ref}(t)
# obtain |Φ(0)⟩ = S_j|Ψ⟩
j_ref = div(L, 2)
# fuse the additional bonds due to non-abelian symmetry
aspace_S = codomain(SU2Spin.SS[2])[1]
aspace_Ψ = codomain(Ψ[1])[1]
aspace_Φ = fuse(aspace_S, aspace_Ψ)
# wrap the operator S_j to a MPO
Tree = InteractionTree(L)
addIntr!(Tree, SU2Spin.SS[2], j_ref, 1.0; name = :S)
S_MPO = AutomataMPO(Tree)
# note Φ should be a complex MPS
Φ = randMPS(ComplexF64, fill(SU2Spin.pspace, L), vcat(aspace_Φ, fill(aspace, L - 1)))
# variationally find |Φ⟩ = S_j|Ψ⟩
mul!(Φ, S_MPO, Ψ;
CBEAlg = NaiveCBE(D + div(D, 4), 1e-8; rsvd = true),
trunc = truncdim(D), GCsweep = true,
lsnoise = [0.1, 0.01, 0.001], tol = 1e-12,
)
MPS{32, ComplexF64, StoreMemory}: Center = [1, 1], Norm = 0.8660254035294417, total memory = 312.938 KiB
Bond 0-> 1: Rep[SU₂](1=>1), dim = 1 -> 3
Bond 1-> 2: Rep[SU₂](1/2=>1, 3/2=>1), dim = 2 -> 6
Bond 2-> 3: Rep[SU₂](0=>1, 1=>2, 2=>1), dim = 4 -> 12
Bond 3-> 4: Rep[SU₂](1/2=>3, 3/2=>3, 5/2=>1), dim = 7 -> 24
Bond 4-> 5: Rep[SU₂](0=>3, 1=>6, 2=>4, 3=>1), dim = 14 -> 48
Bond 5-> 6: Rep[SU₂](1/2=>9, 3/2=>10, 5/2=>5, 7/2=>1), dim = 25 -> 96
Bond 6-> 7: Rep[SU₂](0=>8, 1=>14, 2=>11, 3=>3), dim = 36 -> 126
Bond 7-> 8: Rep[SU₂](1/2=>13, 3/2=>13, 5/2=>7, 7/2=>1), dim = 34 -> 128
Bond 8-> 9: Rep[SU₂](0=>8, 1=>15, 2=>10, 3=>3), dim = 36 -> 124
Bond 9->10: Rep[SU₂](1/2=>13, 3/2=>14, 5/2=>6, 7/2=>1), dim = 34 -> 126
Bond 10->11: Rep[SU₂](0=>8, 1=>15, 2=>10, 3=>3), dim = 36 -> 124
Bond 11->12: Rep[SU₂](1/2=>13, 3/2=>14, 5/2=>6, 7/2=>1), dim = 34 -> 126
Bond 12->13: Rep[SU₂](0=>9, 1=>15, 2=>10, 3=>3), dim = 37 -> 125
Bond 13->14: Rep[SU₂](1/2=>14, 3/2=>14, 5/2=>6, 7/2=>1), dim = 35 -> 128
Bond 14->15: Rep[SU₂](0=>9, 1=>17, 2=>10, 3=>2), dim = 38 -> 124
Bond 15->16: Rep[SU₂](1/2=>16, 3/2=>16, 5/2=>5), dim = 37 -> 126
Bond 16->17: Rep[SU₂](0=>11, 1=>20, 2=>10, 3=>1), dim = 42 -> 128
Bond 17->18: Rep[SU₂](1/2=>18, 3/2=>17, 5/2=>4), dim = 39 -> 128
Bond 18->19: Rep[SU₂](0=>11, 1=>20, 2=>10, 3=>1), dim = 42 -> 128
Bond 19->20: Rep[SU₂](1/2=>18, 3/2=>17, 5/2=>4), dim = 39 -> 128
Bond 20->21: Rep[SU₂](0=>11, 1=>20, 2=>10, 3=>1), dim = 42 -> 128
Bond 21->22: Rep[SU₂](1/2=>18, 3/2=>17, 5/2=>4), dim = 39 -> 128
Bond 22->23: Rep[SU₂](0=>11, 1=>20, 2=>10, 3=>1), dim = 42 -> 128
Bond 23->24: Rep[SU₂](1/2=>17, 3/2=>16, 5/2=>5), dim = 38 -> 128
Bond 24->25: Rep[SU₂](0=>10, 1=>18, 2=>10, 3=>2), dim = 40 -> 128
Bond 25->26: Rep[SU₂](1/2=>14, 3/2=>14, 5/2=>6, 7/2=>1), dim = 35 -> 128
Bond 26->27: Rep[SU₂](0=>5, 1=>9, 2=>5, 3=>1), dim = 20 -> 64
Bond 27->28: Rep[SU₂](1/2=>5, 3/2=>4, 5/2=>1), dim = 10 -> 32
Bond 28->29: Rep[SU₂](0=>2, 1=>3, 2=>1), dim = 6 -> 16
Bond 29->30: Rep[SU₂](1/2=>2, 3/2=>1), dim = 3 -> 8
Bond 30->31: Rep[SU₂](0=>1, 1=>1), dim = 2 -> 4
Bond 31->32: Rep[SU₂](1/2=>1), dim = 1 -> 2
Note we cannot directly compute $\langle S_i^z(t)S_j^z\rangle$, as the operator $S^z$ breaks the SU(2) symmetry. Therefore, what we actually compute is $\langle S_i(t) \cdot S_j\rangle / 3$, where $S_j$ operator is a SU(2) spinor with an additional index that labels the representation space. After preparing the initial Φ
with correct symmetry index, we call mul!
function to perform variational multiplication.
# define a new ObsTree to calculate each inner product ⟨Ψ|S_i|Φ⟩
ObsTree = ObservableTree(L)
for i in 1:L
addObs!(ObsTree, SU2Spin.SS[1], i; name = :S)
end
iso = isometry(aspace_Φ, aspace_S ⊗ aspace_Ψ)
El = permute(iso', ((2, 1), (3,)))
calObs!(ObsTree, Ψ, Φ; El = El)
matSijt[:, 1] = [ObsTree.Refs["S"][(i,)][] / 3 for i in 1:L]
32-element Vector{ComplexF64}:
-0.01176256496443069 - 5.007715327649367e-18im
0.007678317422078493 + 4.757329561266899e-18im
-0.014729592522709124 - 1.3020059851888354e-17im
0.011018256873999259 + 1.0015430655298733e-17im
-0.01778316048363218 - 3.0046291965896206e-18im
0.014218471473971326 - 9.934739929804383e-19im
-0.021667439779460552 + 1.273137560041892e-17im
0.018163377553368918 - 6.166567102057288e-18im
-0.02738424868931609 + 1.631354904679271e-17im
0.02397641826667812 - 1.5376183189320258e-17im
⋮
0.01895965259847142 + 8.641256998010479e-18im
-0.013344102784460243 + 2.9341789016676864e-21im
0.015429146429031655 - 1.8352462872566728e-18im
-0.010484575356457379 + 9.19029469315483e-19im
0.012873407653864686 - 1.782898932719531e-18im
-0.008132861965841032 + 1.0567726210196543e-18im
0.010767614568083395 - 1.5622778473989899e-18im
-0.00566269594933009 + 8.0162308404709125e-19im
0.008643251199225672 - 1.2625697435563286e-18im
Emphasize that the isometry to fuse the space of $S_j$ and $|\Psi\rangle$ must be considered carefully. We exactly insert a pair of isometries without changing the contraction result. One is implicitly applied in mul!
to fuse the two additonal indices to the new one of Φ
, and the other becomes the left boundary environment tensor El
sent to calObs!
.
# time evolution
# construct the trilayer environment for TDVP
Env = Environment(Φ', H, Φ)
# time evolution
for idx in 2:length(lst)
dt = lst[idx] - lst[idx-1]
TDVPSweep1!(Env, -im * dt;
CBEAlg = NaiveCBE(D + div(D, 4), 1e-8; rsvd = true),
GCsweep = true, trunc = truncdim(D)
)
calObs!(ObsTree, Ψ, Φ; El = El)
# e^{iEgt}⟨Ψ|S_i^+|Φ⟩
matSijt[:, idx] = exp(im * Eg * lst[idx]) * map(1:L) do i
ObsTree.Refs["S"][(i,)][] / 3
end
end
In this part we use CBE-TDVP to perform real-time evolution of Φ
and calculate the time-dependent spin correlations as some inner products.
# spatial FT
lsk = 0:2/L:2 # unit = π
matcoef = map([(k, i) for k in lsk, i in 1:L]) do (k, i)
exp(-im * k * (i - j_ref) * π)
end
matSkt = matcoef * matSijt
# time FT
lsω = 0:0.1:4 # frequency list
# use a Parzen window to suppress the non-physical oscillation
function ParzenWindow(x::Float64)
if abs(x) < 1 / 2
return 1 - 6 * x^2 + 6 * abs(x)^3
elseif abs(x) < 1
return 2 * (1 - abs(x))^3
else
return 0.0
end
end
lsWt = ParzenWindow.(lst ./ maximum(lst))
matSkω = zeros(length(lsk), length(lsω))
for iω in 1:length(lsω)
lscoef = exp.(im * lsω[iω] .* lst) .* lsWt
for ik in 1:length(lsk)
matSkω[ik, iω] = 2 * integrate(lst, real.(matSkt[ik, :] .* lscoef))
end
end
After collecting all real-space time-dependent spin correlations, we perform FT to obtain the DSF. Note time-reversal symmetry is used in the numerical integration so that only $t>0$ data is required. Moreover, we multiply a parzen window function to the time domain to suppress the non-physical oscillation in frequency domain.
# visualization
fig = Figure(size = (480, 240))
ax = Axis(fig[1, 1];
xlabel = L"k / \pi",
ylabel = L"\omega",
limits = ((0, 2), extrema(lsω))
)
hm = heatmap!(ax, lsk, lsω, matSkω)
Colorbar(fig[1, 2], hm; label = L"S(k, \omega)")
# upper and lower boundaries
lsω_upper = π .* sin.(lsk .* (π/2))
lsω_lower = (π/2) .* abs.(sin.(lsk .* π))
lines!(ax, lsk, lsω_upper; color = :white, linestyle = :dash)
lines!(ax, lsk, lsω_lower; color = :white, linestyle = :dash)
save("figs_Heisenberg/Skomega.png", fig)
The exact (thermodynamical limit) upper and lower boundaries are also shown. Our numerial results align well with them, up to the frequency resolution (FWHM $\approx 8 / t_\textrm{max}$) due to the finite evolution time $t_\textrm{max} = 10$.