Introduction
There is a very useful Python library for solving Finite Element Analysis (FEA) problems: Scikit-FEM.
It is much more elegant and flexible than I initially expected.
Here, I introduce a simple test case and briefly explain the background theory.
Structural Analysis with FEA
Theory – Static Condensation
One of the most stable and widely used techniques to reduce the size of an FEA system is static condensation.
Static condensation eliminates internal degrees of freedom (DOFs) so that the remaining system becomes smaller while still preserving the correct solution on the boundary DOFs.
Suppose the equilibrium equation is partitioned as follows:
\begin{align}
\begin{bmatrix}
K_{ff} & K_{fb} \\
K_{bf} & K_{bb}
\end{bmatrix}
\begin{bmatrix}
U_{f} \\
U_{b}
\end{bmatrix}
=
\begin{bmatrix}
f_{f} \\
f_{b}
\end{bmatrix}
\end{align}Here:
- fff-subscript: free DOFs (unknown displacements)
- bbb-subscript: boundary DOFs (constrained or prescribed displacements)
The stiffness matrix KKK is partitioned into the free part KffK_{ff}Kff and the boundary part KbbK_{bb}Kbb.
There are two common approaches for reducing DOFs:
- Direct substitution of boundary conditions (enforcement)
- Schur complement (true static condensation)
1: Direct Substitution of Boundary Conditions (Enforcement)
If the boundary displacements are known, we can write: U_b = G_b.
In practice, many solvers enforce this by modifying the matrix:
- Replace the diagonal block KbbK_{bb}Kbb with a large penalty value or a unit diagonal.
- Replace the load vector entries to enforce the displacement: f_b = K_{bb}, G_b[katex]</li>
</ul>
<p>After solving, the system automatically satisfies:[katex]U_b = G_b
This approach is simple and computationally efficient because it avoids forming the Schur complement and keeps the sparsity of the system.
However:
It is not a true static condensation — it is simply boundary condition enforcement using matrix modification.
It does not provide correct reaction forces, since the modified system no longer represents the true equilibrium equations.
2: Schur Complement (Condensation)
Another way to reduce the system is to use the Schur complement.
From linear algebra, a block matrix\mathbf{M} = \begin{bmatrix} \mathbf{A} & \mathbf{B} \\ \mathbf{C} & \mathbf{D} \end{bmatrix}has the following inverse (assuming \mathbf{D} is invertible):
\mathbf{M}^{-1} = \begin{bmatrix} \mathbf{S}^{-1} & -\mathbf{S}^{-1} \mathbf{B}\mathbf{D}^{-1} \\ -\mathbf{D}^{-1}\mathbf{C}\mathbf{S}^{-1} & \mathbf{D}^{-1} + \mathbf{D}^{-1}\mathbf{C}\mathbf{S}^{-1}\mathbf{B}\mathbf{D}^{-1} \end{bmatrix}where the Schur complement of \mathbf{D} in \mathbf{M} is
\mathbf{S} = \mathbf{A} - \mathbf{B}\mathbf{D}^{-1}\mathbf{C}.Static condensation in FEA is precisely the use of this Schur complement to eliminate internal (or boundary) DOFs.
Application to Force Balance Equations
Starting from the equilibrium equation partitioned as:
\begin{bmatrix} K_{ff} & K_{fb} \\ K_{bf} & K_{bb} \end{bmatrix} \begin{bmatrix} U_f \\ U_b \end{bmatrix} = \begin{bmatrix} f_f \\ f_b \end{bmatrix}we assume K_{bb} is invertible. Then the boundary displacement can be written as:
U_b = K_{bb}^{-1}(f_b - K_{bf} U_f)Substituting this into the first row gives the condensed system:
\left(K_{ff} - K_{fb} K_{bb}^{-1} K_{bf}\right) U_f f_f - K_{fb} K_{bb}^{-1} f_b.The matrix S = K_{ff} - K_{fb} K_{bb}^{-1} K_{bf} is exactly the Schur complement.
This condensed system has fewer DOFs than the original system, and unlike direct enforcement, it preserves the correct equilibrium structure.Reaction Forces
Unlike method 1 (direct substitution), the Schur complement method allows proper computation of reaction forces:
r_b = K_{bf} U_f + K_{bb} U_b - f_b.Using the equilibrium equation, this reduces to:
r_b = K_{bf} U_f - f_b + K_{bb} U_b.This represents the exact reaction force required to enforce the constraint on DOF
https://scikit-fem.readthedocs.io/en/latest/api.html#function-condense
Code
import numpy as np import scipy.sparse.linalg import scipy import skfem from skfem import * from skfem.helpers import ddot, sym_grad, eye, trace from skfem.models.elasticity import lame_parameters from skfem.models.elasticity import linear_elasticity from skfem.helpers import dot, sym_grad def get_in_range(x_range, y_range, z_range): return lambda x: (x_range[0] <= x[0]) & (x[0] <= x_range[1]) & \ (y_range[0] <= x[1]) & (x[1] <= y_range[1]) & \ (z_range[0] <= x[2]) & (x[2] <= z_range[1]) def get_dofs_in_range(basis, x_range, y_range, z_range): f = get_in_range(x_range, y_range, z_range) return basis.get_dofs(f) # Parameters E = 210e9 # Young [Pa] nu = 0.3 # Poisson L, W, H = 1.0, 1.0, 1.0 # The size of Box displacement_scale = 1 # Scale to Enhance the Displacement mesh = skfem.MeshHex().refined(3).with_defaults() e = skfem.ElementVector(skfem.ElementHex1()) basis = skfem.Basis(mesh, e, intorder=1) lam, mu = lame_parameters(E, nu) def C(T): return 2. * mu * T + lam * eye(trace(T), T.shape[0]) @skfem.BilinearForm def stiffness(u, v, w): return ddot(C(sym_grad(u)), sym_grad(v)) K = stiffness.assemble(basis) K = linear_elasticity(lam, mu).assemble(basis) # Fixes dofs on plane (z=0) fixed_dofs = get_dofs_in_range(basis, [0, L], [0, W], [0, 0]).all() # Get the dofs(z only) on the surface (z=H) top_dofs = get_dofs_in_range( # basis, [0, L], [0, W], [H, H] basis, [L/3, 2*L/3], [L/3, 2*W/3], [H, H] ).nodal['u^3'] F_in_range = get_in_range([L/3, 2*L/3], [L/3, 2*W/3], [H, H]) F_facet_ids = mesh.facets_satisfying(F_in_range) # F_facet_ids = mesh.facets_satisfying(lambda x: np.isclose(x[0], 1.0)) fbasis = skfem.FacetBasis(mesh, e, facets=F_facet_ids) total_force = 100.0 # [N] @skfem.Functional def surface_measure(w): return 1.0 A_proj_z = surface_measure.assemble(fbasis) pressure = total_force / A_proj_z # ---- traction: t = p * n (l(v) = ∫_Γ (p n) · v ds)---- # @skfem.LinearForm # def l_comp(v, w): # return pressure * v[2] @skfem.LinearForm def l_comp(v, w): # dim, local_node, quad_point # print(v.shape) # (3, 4, 16) return pressure * dot(w.n, v) F = l_comp.assemble(fbasis) K_e, F_e = skfem.enforce(K, F, D=fixed_dofs) K_c, F_c, U_c, I = skfem.condense(K, F, D=fixed_dofs) # K_e, F_e (2187, 2187) (2187,) # K_c, F_c (1944, 1944) (1944,) # Solve linear equation # U_e = solve(K_e, F_e) U_e = scipy.sparse.linalg.spsolve(K_e, F_e) # U_c = solve(A_c, f_c) U_c = scipy.sparse.linalg.spsolve(K_c, F_c) U_c_full = np.zeros(K.shape[0]) free_dofs = np.setdiff1d(np.arange(K.shape[0]), fixed_dofs) U_c_full[free_dofs] = U_c reaction_force = K[fixed_dofs, :] @ U_c_full - F[fixed_dofs] print("reaction_force:", np.sum(reaction_force)) # print(np.sum(U_e - U_c)) # --------------------------- sf = 1e8 # print(U, np.max(U), np.min(U)) m = mesh.translated(sf * U_e[basis.nodal_dofs]) m.save('box_deformed.vtk') mesh.save('box_org.vtk')Result

We got a reaction force -100[N] which is as same as force we added as boundary condition.
Assemble Body Force
rho_kg_m3 = 7850.0 # the density of steel g_m_s2 = 9.81 gamma_N_m3 = rho_kg_m3 * g_m_s2 # [N/m^3] gamma_N_mm3 = gamma_N_m3 / 1e9 # [N/mm^3] # Constant Body Force along with -Z axis f = np.array([0.0, 0.0, -gamma_N_mm3]) # [N/mm^3] @skfem.LinearForm def body(v, w): # l(v) = ∫_Ω f · v dΩ return dot(f, v) F_body = body.assemble(basis)