Let us consider the Poisson problem in
from ngsolve import * from netgen.geom2d import unit_square from math import pi mesh = Mesh(unit_square.GenerateMesh(maxh=1/4))
For such a problem, a meaningful choice of finite element spaces for an HDG method with
The discrete weak formulation is then given by: With
Here, the integral multiplied by
However, the actual implementation of such a method looks slightly different. We introduce the compound FE space:
order = 8 V = L2(mesh, order=order) Vhat = FacetFESpace(mesh, order=order, dirichlet=[1,2,3,4]) S = VectorL2(mesh, order=order-1, hide_all_dofs=True) X = FESpace([V,Vhat,S])
In the VectorL2 FE space the "hide_all_dofs" flag marks all corresponding dofs as HIDDEN_DOFs (instead of LOCAL_DOF). Here are a few important properties of hidden d.o.f.s
- Hidden d.o.f.s will be removed (and will not be restored!) during static condensation. This has the following implications:
- A hidden d.o.f. is a LOCAL_DOF in the sense that it only couples with other d.o.f.s on the same element
- The entries of a r.h.s. vector of a linear system corresponding to a hidden d.o.f. are always zero
- During static condensation the (element-local) matrix blocks for hidden d.o.f.s have to be invertible
- No non-zero entries are reserved for hidden d.o.f.s in sparse matrices. Consequently, a hidden d.o.f. that is involved in a bilinear form will be ignored if not eliminated through static condensation.
- Hidden d.o.f.s (as other coupling types) only behave different from other coupling types when it comes to solving linear systems and static condensation is applied. This can be done with the "eliminate_internal" flag of a bilinear form or with the "eliminate_hidden" flag where the latter only applies static condensation of HIDDEN_DOFs and not for LOCAL_DOFs.
Then, the weak formulation of the implementation variant reads as follows. Find
sigma = 2 sigma_lift = 2 (u,uhat,r),(v,vhat,s) = X.TnT() n = specialcf.normal(mesh.dim) h = specialcf.mesh_size jmp_u = u-uhat jmp_v = v-vhat a = BilinearForm( X, symmetric=True, eliminate_hidden=True) a += SymbolicBFI( grad(u)*grad(v) ) a += SymbolicBFI( - (grad(u)*n)*jmp_v - jmp_u*(grad(v)*n) + (sigma/h)*jmp_u*jmp_v, element_boundary=True ) a += SymbolicBFI( - sigma_lift*(r*s) ) a += SymbolicBFI( sigma_lift*( jmp_u*(s*n) + jmp_v*(r*n) ), element_boundary=True ) f = LinearForm(X) f += SymbolicLFI( 2*pi**2*sin(pi*x)*sin(pi*y)*v )
Here, a right-hand side term
Now, one can assemble the linear system and take a look at the number of non-zero entries in the matrix A and its factors which arise due to static condensation:
a.Assemble() f.Assemble() print("a.mat has {} non-zero entries.".format(a.mat.nze))
When applying static condensation (for all LOCAL_DOFs) we simply obtain
a = BilinearForm( X, symmetric=True, eliminate_internal=True) ... print("a.mat has {} non-zero entries.".format(a.mat.nze)) print("a.inner_solve has {} non-zero entries.".format(a.inner_solve.nze)) print("a.harmonic_extension has {} non-zero entries.".format(a.harmonic_extension.nze))
Comparison of non-zero entries
The numbers for the method using hidden DOFs and compression of the FE spaces with static condensation are:
a.mat has 22680 non-zero entries. a.inner_solve has 105300 non-zero entries. a.harmonic_extension has 63180 non-zero entries.
The output without hidden DOFs and with static condensation is as follows:
a.mat has 22680 non-zero entries. a.inner_solve has 711828 non-zero entries. a.harmonic_extension has 164268 non-zero entries.
One observes the same number of non-zero entries in the Schur complement a.mat, but the factors a.inner_solve and a.harmonic_extension have significantly fewer non-zero entries when hidden DOFs are used. This actually leads to the fact that introducing such a large space for the lifting DOFs is only marginally more expensive and only during setup of the matrix.
Compress d.o.f.s
Hidden d.o.f.s don't play a role in the solution, but only a local role in defining some projection-type operators. Hence, we don't need to reserve a global d.o.f. number for a hidden d.o.f.. To prevent hidden d.o.f.s from obtaining a global d.o.f. number, we can use a wrapper which is called a CompressedFESpace that removes HIDDEN_DOFs and UNUSED_DOFs from the global numbering. For an arbitrary FESpace V you obtain a compressed spaces as follows:
print(V.ndof) V = Compress(V) print(V.ndof)
When applied to a CompoundFESpace we use
print("dofs before compression:", X.ndof) X = CompressCompound(X) print("dofs after compression:", X.ndof)
resulting in a reduction of global d.o.f.s:
dofs before compression: 6876 dofs after compression: 3132
However, the non-zero entries in the matrices are essentially unaffected by the compression.
Projected jumps
Another example where hidden d.o.f.s can be used are "projected jumps" where the highest order functions in the FacetFESpace are made element-local to realize a facet-wise
Vhat = FacetFESpace(mesh, order=order, dirichlet=[1,2,3,4], highest_order_dc=True, hide_highest_order_dc=True)
A fully functional python script is attached to this article. In this script, more options to play with are included.