Defining new index spaces
Defining your own index spaces can be a powerful tool when working with methods where you are dealing with other types of indices than the default provided spaces GeneralOrbital
, OccupiedOrbital
and VirtualOrbital
. A few examples could be
- Auxiliary index for integral decompositions (RI, Cholesky)
- Multiple photon modes for QED methods
- Subdividing
GeneralOrbital
to have different classes of fermions, for example:- Dividing into active and inactive orbitals for active space methods
- Dividing into orbitals for different kinds of physical fermions such as protons and positrons (NEO-CCSD)
RI/Cholesky index
The first example is adding a simple index space for use as an auxiliary basis for integral decompositions. We define a new index space using the new_space
function
julia> using SpinAdaptedSecondQuantization
julia> AuxiliaryIndex = new_space(:AuxiliaryIndex, "aux", "JKLMN")
5
The variable AuxiliaryIndex
now contains the id for the new index space which are ordered indices starting at 1
. This can also be obtained from the global dictionary index_ids
julia> SASQ.index_ids[:AuxiliaryIndex]
5
The second parameter "aux"
is the "short name" for the space, used when printing terms without index translation. The last parameter "JKLMN"
are the names to give indices in the given space when translated.
Running new_space
for the same key multiple times in the same session will not do anything, meaning it is completely safe to keep index space definitions in the top of a script that gets run multiple times.
We can now use our index space
julia> x = real_tensor("L", 1, 2, 3) * constrain(1 => AuxiliaryIndex) * electron(2, 3)
L_Jpq
julia> disable_external_index_translation()
julia> x # Here the short name "aux" gets used
L_₁₂₃ C(₁∈aux, ₂∈g, ₃∈g)
julia> enable_external_index_translation()
It can be nice to define a convenience function to constrain many indices to the new space
julia> auxiliary(indices...) = constrain(J => AuxiliaryIndex for J in indices)
auxiliary (generic function with 1 method)
julia> real_tensor("S", 1, 2) * auxiliary(1, 2)
S_JK
We can now, for example, define the two electron operator in terms of auxiliary indices
julia> g = 1//2 * ∑(real_tensor("L", 5, 1, 2) * real_tensor("L", 5, 3, 4) * e(1, 2, 3, 4) * electron(1, 2, 3, 4) * auxiliary(5), 1:5)
1/2 ∑_pqrsJ(L_Jpq L_Jrs E_pq E_rs) - 1/2 ∑_pqrsJ(δ_qr L_Jpq L_Jrs E_ps)
julia> simplify_heavy(hf_expectation_value(g))
2 ∑_Jij(L_Jii L_Jjj) - ∑_Jij(L_Jij L_Jji)
Multilevel indices
Say we want to divide our electronic indices into an active and an inactive space. Then we can define the following spaces
julia> ActiveOrbital = new_space(:ActiveOrbital, "a", "pqrstuv")
6
julia> InactiveOrbital = new_space(:InactiveOrbital, "i", "pqrstuv")
7
Note that the same names have been given to these indices as the general electronic indices, so they will be printed the same when translated. To be able to distinguish them we can add a color to be printed for a certain index space (see set_color
for available colors)
julia> set_color(ActiveOrbital, :cyan)
julia> set_color(InactiveOrbital, :red)
now if we use the indices we see that they are no longer identical
julia> ∑(real_tensor("g", 1, 2, 3, 4) * electron(1, 2) * constrain(3 => ActiveOrbital, 4 => InactiveOrbital), 1:4)
∑_pqpp(g_pqpp)
However, we want these two new spaces to act in such a way that
ActiveOrbital ⊆ GeneralOrbital
InctiveOrbital ⊆ GeneralOrbital
ActiveOrbital ∪ InctiveOrbital == GeneralOrbital
ActiveOrbital ∩ InctiveOrbital == {}
To setup these relations we call the function add_space_sum
julia> add_space_sum(ActiveOrbital, InactiveOrbital, GeneralOrbital)
julia> ActiveOrbital ⊆ GeneralOrbital
true
julia> InactiveOrbital ⊆ GeneralOrbital
true
This for example allows the following simplification to occur
julia> ∑(real_tensor("h", 1, 2) * E(1, 2) * electron(1) * constrain(2 => ActiveOrbital), 1:2)
∑_pp(h_pp E_pp)
julia> ans + ∑(real_tensor("h", 1, 2) * E(1, 2) * electron(1) * constrain(2 => InactiveOrbital), 1:2)
∑_pp(h_pp E_pp) + ∑_pp(h_pp E_pp)
julia> simplify(ans)
∑_pq(h_pq E_pq)
Now we also want to subdivide the VirtualOrbital
and OccupiedOrbital
index spaces into active and inactive subspaces, so we define these spaces
julia> begin
ActiveVirtualOrbital = new_space(:ActiveVirtualOrbital, "av", "abcdefg");
ActiveOccupiedOrbital = new_space(:ActiveOccupiedOrbital, "ao", "ijklmno");
InactiveVirtualOrbital = new_space(:InactiveVirtualOrbital, "iv", "abcdefg");
InactiveOccupiedOrbital = new_space(:InactiveOccupiedOrbital, "io", "ijklmno");
set_color(ActiveVirtualOrbital, :cyan)
set_color(ActiveOccupiedOrbital, :cyan)
set_color(InactiveVirtualOrbital, :red)
set_color(InactiveOccupiedOrbital, :red)
end
Then we call add_space_sum
to signal how each space can split into two smaller spaces
julia> add_space_sum(ActiveOrbital, InactiveOrbital, GeneralOrbital)
julia> add_space_sum(ActiveVirtualOrbital, InactiveVirtualOrbital, VirtualOrbital)
julia> add_space_sum(ActiveOccupiedOrbital, InactiveOccupiedOrbital, OccupiedOrbital)
julia> add_space_sum(ActiveOccupiedOrbital, ActiveVirtualOrbital, ActiveOrbital)
julia> add_space_sum(InactiveOccupiedOrbital, InactiveVirtualOrbital, InactiveOrbital)
In addition to this we now have a new relation we want to specify, namely the following
OccupiedOrbital ∩ ActiveOrbital == ActiveOccupiedOrbital
OccupiedOrbital ∩ InactiveOrbital == InactiveOccupiedOrbital
...
To achieve this we call the add_space_intersection
function
julia> add_space_intersection(ActiveOrbital, OccupiedOrbital, ActiveOccupiedOrbital)
julia> add_space_intersection(ActiveOrbital, VirtualOrbital, ActiveVirtualOrbital)
julia> add_space_intersection(InactiveOrbital, OccupiedOrbital, InactiveOccupiedOrbital)
julia> add_space_intersection(InactiveOrbital, VirtualOrbital, InactiveVirtualOrbital)
This is what allows the following simplification to happen
julia> t = real_tensor("h", 1, 2) * constrain(1 => ActiveOrbital, 2 => InactiveOrbital)
h_pp
julia> o = E(1, 2) * occupied(1, 2)
E_ij
julia> t * o
h_ii E_ii
where we see that the two indices got constrained to the smaller ActiveOccupiedOrbital
and InactiveOccupiedOrbital
spaces respectively.
We can make some convenience constructor functions
julia> active(indices...) = constrain(p => ActiveOrbital for p in indices)
active (generic function with 1 method)
julia> inactive(indices...) = constrain(p => InactiveOrbital for p in indices)
inactive (generic function with 1 method)
julia> aocc(indices...) = constrain(p => ActiveOccupiedOrbital for p in indices)
aocc (generic function with 1 method)
julia> iocc(indices...) = constrain(p => InactiveOccupiedOrbital for p in indices)
iocc (generic function with 1 method)
julia> avir(indices...) = constrain(p => ActiveVirtualOrbital for p in indices)
avir (generic function with 1 method)
julia> ivir(indices...) = constrain(p => InactiveVirtualOrbital for p in indices)
ivir (generic function with 1 method)
HF interaction energy
We can now use this to, for example, get an expression for the interaction energy between the active and inactive spaces at the HF level.
We define the full hamiltonian as
julia> h = ∑(rsym_tensor("h", 1, 2) * E(1, 2) * electron(1, 2), 1:2)
∑_pq(h_pq E_pq)
julia> g = 1//2 * simplify( ∑(rsym_tensor("g", 1:4...) * e(1:4...) * electron(1:4...), 1:4) )
- 1/2 ∑_pqr(g_prqr E_pq) + 1/2 ∑_pqrs(g_pqrs E_pq E_rs)
julia> H = h + g
∑_pq(h_pq E_pq) - 1/2 ∑_pqr(g_prqr E_pq) + 1/2 ∑_pqrs(g_pqrs E_pq E_rs)
which can give us the full HF energy
julia> E_HF = simplify_heavy(hf_expectation_value(H))
2 ∑_i(h_ii) + 2 ∑_ij(g_iijj) - ∑_ij(g_ijij)
We can now also define Hamiltonians acting on only the active and inactive spaces as
julia> h_active = ∑(rsym_tensor("h", 1, 2) * E(1, 2) * active(1, 2), 1:2)
∑_pq(h_pq E_pq)
julia> g_active = 1//2 * simplify( ∑(rsym_tensor("g", 1:4...) * e(1:4...) * active(1:4...), 1:4) )
- 1/2 ∑_pqr(g_prqr E_pq) + 1/2 ∑_pqrs(g_pqrs E_pq E_rs)
julia> h_inactive = ∑(rsym_tensor("h", 1, 2) * E(1, 2) * inactive(1, 2), 1:2)
∑_pq(h_pq E_pq)
julia> g_inactive = 1//2 * simplify( ∑(rsym_tensor("g", 1:4...) * e(1:4...) * inactive(1:4...), 1:4) )
- 1/2 ∑_pqr(g_prqr E_pq) + 1/2 ∑_pqrs(g_pqrs E_pq E_rs)
julia> H_active = h_active + g_active
∑_pq(h_pq E_pq) - 1/2 ∑_pqr(g_prqr E_pq) + 1/2 ∑_pqrs(g_pqrs E_pq E_rs)
julia> H_inactive = h_inactive + g_inactive
∑_pq(h_pq E_pq) - 1/2 ∑_pqr(g_prqr E_pq) + 1/2 ∑_pqrs(g_pqrs E_pq E_rs)
We can now define the HF energies of the subspaces
julia> E_active = simplify_heavy(hf_expectation_value(H_active))
2 ∑_i(h_ii) + 2 ∑_ij(g_iijj) - ∑_ij(g_ijij)
julia> E_inactive = simplify_heavy(hf_expectation_value(H_inactive))
2 ∑_i(h_ii) + 2 ∑_ij(g_iijj) - ∑_ij(g_ijij)
Then finally we can define an interaction energy by subtracting these from the full energy
julia> E_int = simplify_heavy(E_HF - E_active - E_inactive, (OccupiedOrbital => (ActiveOccupiedOrbital, InactiveOccupiedOrbital),))
4 ∑_ii(g_iiii) - 2 ∑_ii(g_iiii)
Note here the use of the extra argument to the simplify_heavy
function. This defaults to splitting general indices into occupied and virtual, but it was now more useful to split occupied orbitals into active and inactive parts.
Multilevel coupled cluster
If we now define the Hamiltonian in terms of the fock matrix as usual for coupled cluster derivations
julia> h = ∑(( real_tensor("F", 1, 2) + ∑((-2 * psym_tensor("g", 1, 2, 3, 3) + psym_tensor("g", 1, 3, 3, 2)) * occupied(3), [3]) ) * E(1, 2) * electron(1, 2), 1:2)
∑_pq(F_pq E_pq) - 2 ∑_pqi(g_pqii E_pq) + ∑_pqi(g_piiq E_pq)
julia> g = 1//2 * simplify( ∑(psym_tensor("g", 1:4...) * e(1:4...) * electron(1:4...), 1:4) )
- 1/2 ∑_pqr(g_prrq E_pq) + 1/2 ∑_pqrs(g_pqrs E_pq E_rs)
julia> H = h + g
∑_pq(F_pq E_pq) - 2 ∑_pqi(g_pqii E_pq) - 1/2 ∑_pqr(g_prrq E_pq) + ∑_pqi(g_piiq E_pq) + 1/2 ∑_pqrs(g_pqrs E_pq E_rs)
Then as an interesting example we can define the T2 cluster operator acting on active orbitals for one excitation, then for all orbitals for the other
julia> T2 = 1//2 * ∑(psym_tensor("t", 1:4...) * E(1, 2) * E(3, 4) * occupied(2, 4) * virtual(1, 3) * active(1, 2, 3, 4), 1:4) + ∑(psym_tensor("t", 1:4...) * E(1, 2) * E(3, 4) * occupied(2, 4) * virtual(1, 3) * active(1, 2) * inactive(3, 4), 1:4)
1/2 ∑_aibj(t_aibj E_ai E_bj) + ∑_aiai(t_aiai E_ai E_ai)
We then define the similarity transformed Hamiltonian and its right projection as usual
julia> Hbar = simplify(bch(H, T2, 4));
julia> Hbar_ket = simplify(act_on_ket(Hbar, 2));
Energy:
julia> simplify_heavy(act_on_bra(Hbar_ket));
julia> look_for_tensor_replacements(ans, make_exchange_transformer("g", "L"));
julia> E0 = ans
2 ∑_i(F_ii) - ∑_ij(L_iijj) + ∑_iajb(L_iajb t_aibj) + 2 ∑_iaia(L_iaia t_aiai)
Singles:
julia> project_biorthogonal(Hbar_ket, E(1, 2));
julia> simplify_heavy(ans, [ GeneralOrbital => (OccupiedOrbital, VirtualOrbital), OccupiedOrbital => (ActiveOccupiedOrbital, InactiveOccupiedOrbital), VirtualOrbital => (ActiveVirtualOrbital, InactiveVirtualOrbital), GeneralOrbital => (ActiveOrbital, InactiveOrbital), ActiveOrbital => (ActiveOccupiedOrbital, ActiveVirtualOrbital), InactiveOrbital => (InactiveOccupiedOrbital, InactiveVirtualOrbital), ]);
julia> look_for_tensor_replacements(ans, make_exchange_transformer("t", "u"));
julia> look_for_tensor_replacements(ans, make_exchange_transformer("g", "L"));
julia> omega_ai = ans
F_ai + 2 ∑_ia(F_ia t_aiai) + 2 ∑_ia(F_ia t_aiai) - ∑_ia(F_ia t_aiai) - ∑_ia(F_ia t_aiai) + ∑_jb(F_jb u_aibj) + ∑_aia(L_aaia t_aiai) + ∑_aia(L_aaia t_aiai) - ∑_iia(L_iiia t_aiai) - ∑_iia(L_iiia t_aiai) + ∑_ajb(g_aajb u_aibj) - ∑_ijb(g_iijb u_aibj)
Since the different terms here have different constraints for the external indices, it can be useful to separate this into active/inactive blocks:
julia> omega_ai_AA = omega_ai * active(1, 2)
F_ai + 2 ∑_ia(F_ia t_aiai) + ∑_jb(F_jb u_aibj) + ∑_bia(L_abia t_biai) - ∑_jia(L_jiia t_ajai) + ∑_bjc(g_abjc u_bicj) - ∑_jkb(g_jikb u_ajbk)
julia> omega_ai_AI = omega_ai * active(1) * inactive(2)
F_ai - ∑_ia(F_ia t_aiai) + ∑_aib(L_aaib t_aibi) - ∑_ija(L_iija t_aiaj) - ∑_ijb(g_iijb u_aibj)
julia> omega_ai_IA = omega_ai * active(2) * inactive(1)
F_ai - ∑_ia(F_ia t_aiai) + ∑_aib(L_aaib t_aibi) - ∑_ija(L_iija t_aiaj) + ∑_ajb(g_aajb u_aibj)
julia> omega_ai_II = omega_ai * inactive(1, 2)
F_ai + 2 ∑_ia(F_ia t_aiai) + ∑_bia(L_abia t_biai) - ∑_jia(L_jiia t_ajai)