8  Manual 2D Assembly: From Local Tensors to Global Solution

This notebook demonstrates the assembly process: combining element matrices and vectors into a global system, applying boundary conditions, and solving for the finite element solution. Assembly is fundamentally a topological operation—it depends only on node connectivity, not on the analytical properties of elements.

import sympy as sp
import numpy as np
import matplotlib.pyplot as plt
from IPython.display import Math, display
from symbolic_fem_workbench import (
    build_poisson_triangle_p1_local_problem,
    assemble_dense_matrix, assemble_dense_vector,
    apply_dirichlet_by_reduction, expand_reduced_solution,
)

8.1 Step 1: Get symbolic local tensors and convert to numerical

We retrieve the element stiffness matrix and load vector from the previous notebook and convert them to numerical functions via lambdify.

data = build_poisson_triangle_p1_local_problem()
geom = data["geometry"]

ke_fn = sp.lambdify(
    (geom.x1, geom.y1, geom.x2, geom.y2, geom.x3, geom.y3),
    data["Ke"], "numpy"
)
fe_fn = sp.lambdify(
    (geom.x1, geom.y1, geom.x2, geom.y2, geom.x3, geom.y3, data["f"]),
    data["fe"], "numpy"
)
print("Numerical kernels ready.")
Numerical kernels ready.

8.2 Step 2: Define a tiny mesh

We discretize the unit square using 4 triangles arranged around a central point. This simple mesh illustrates the assembly process without excessive complexity.

nodes = np.array([
    [0.0, 0.0],  # 0: bottom-left
    [1.0, 0.0],  # 1: bottom-right
    [1.0, 1.0],  # 2: top-right
    [0.0, 1.0],  # 3: top-left
    [0.5, 0.5],  # 4: center
])
elements = [(0,1,4), (1,2,4), (2,3,4), (3,0,4)]

# Visualize the mesh
fig, ax = plt.subplots(1, 1, figsize=(5, 5))
for tri in elements:
    pts = nodes[list(tri) + [tri[0]]]
    ax.plot(pts[:, 0], pts[:, 1], "b-")
for i, (x, y) in enumerate(nodes):
    ax.plot(x, y, "ko", markersize=8)
    ax.annotate(f"  {i}", (x, y), fontsize=12)
ax.set_title("Mesh: 4 triangles, 5 nodes")
ax.set_aspect("equal")
plt.show()

8.3 Step 3: Element-by-element assembly

The assembly loop scans through all elements, retrieves element matrices/vectors, and accumulates them into global arrays using the connectivity information.

ndofs = len(nodes)
K_global = np.zeros((ndofs, ndofs))
F_global = np.zeros(ndofs)
source_value = 1.0

for eid, conn in enumerate(elements):
    coords = nodes[list(conn)]
    x1v, y1v = coords[0]
    x2v, y2v = coords[1]
    x3v, y3v = coords[2]

    K_local = np.asarray(ke_fn(x1v, y1v, x2v, y2v, x3v, y3v), dtype=float)
    F_local = np.asarray(fe_fn(x1v, y1v, x2v, y2v, x3v, y3v, source_value), dtype=float).reshape(-1)

    print(f"Element {eid}, connectivity {conn}")
    print(f"  K_local diagonal: {np.diag(K_local)}")
    print(f"  F_local: {F_local}")

    assemble_dense_matrix(K_global, K_local, conn)
    assemble_dense_vector(F_global, F_local, conn)

print("\nAssembled K_global:")
print(np.round(K_global, 4))
print("\nAssembled F_global:")
print(np.round(F_global, 4))
Element 0, connectivity (0, 1, 4)
  K_local diagonal: [0.5 0.5 1. ]
  F_local: [0.08333333 0.08333333 0.08333333]
Element 1, connectivity (1, 2, 4)
  K_local diagonal: [0.5 0.5 1. ]
  F_local: [0.08333333 0.08333333 0.08333333]
Element 2, connectivity (2, 3, 4)
  K_local diagonal: [0.5 0.5 1. ]
  F_local: [0.08333333 0.08333333 0.08333333]
Element 3, connectivity (3, 0, 4)
  K_local diagonal: [0.5 0.5 1. ]
  F_local: [0.08333333 0.08333333 0.08333333]

Assembled K_global:
[[ 1.  0.  0.  0. -1.]
 [ 0.  1.  0.  0. -1.]
 [ 0.  0.  1.  0. -1.]
 [ 0.  0.  0.  1. -1.]
 [-1. -1. -1. -1.  4.]]

Assembled F_global:
[0.1667 0.1667 0.1667 0.1667 0.3333]

8.4 Step 4: Apply Dirichlet BCs and solve

Dirichlet boundary conditions are applied by reducing the system: we eliminate rows and columns corresponding to constrained DOFs and solve for the free DOFs, then expand the solution.

constrained = [0, 1, 2, 3]  # All boundary nodes
K_ff, F_f, free_dofs = apply_dirichlet_by_reduction(K_global, F_global, constrained)

print(f"Free DOFs: {free_dofs}")
print(f"K_ff = {K_ff}")
print(f"F_f = {F_f}")

u_free = np.linalg.solve(K_ff, F_f)
u = expand_reduced_solution(u_free, ndofs, free_dofs, constrained)

print(f"\nSolution vector u = {u}")
print(f"Center node value u[4] = {u[4]:.6f}")
print(f"Expected (analytical): 1/12 = {1/12:.6f}")
print(f"Match: {np.isclose(u[4], 1/12)}")
Free DOFs: [4]
K_ff = [[4.]]
F_f = [0.33333333]

Solution vector u = [0.         0.         0.         0.         0.08333333]
Center node value u[4] = 0.083333
Expected (analytical): 1/12 = 0.083333
Match: True

8.5 Step 5: Visualize the solution

We plot the FEM solution as a colored patch over the mesh, with annotations showing nodal values.

from matplotlib.tri import Triangulation

tri_conn = np.array(elements)
triang = Triangulation(nodes[:, 0], nodes[:, 1], tri_conn)

fig, ax = plt.subplots(figsize=(6, 5))
tpc = ax.tripcolor(triang, u, shading="flat", cmap="viridis")
ax.triplot(triang, "k-", linewidth=0.5)
for i, (x, y) in enumerate(nodes):
    ax.annotate(f"{i}: u={u[i]:.4f}", (x, y), fontsize=9,
                xytext=(5, 5), textcoords="offset points")
plt.colorbar(tpc, label="u")
ax.set_title("FEM Solution: Poisson on 4-Triangle Mesh")
ax.set_aspect("equal")
plt.show()

8.6 Exercise

Question: Move the center node from \((0.5, 0.5)\) to \((0.3, 0.7)\). How does the solution change? Is the center node value still \(1/12\)? Why or why not?

Hint: The center node is no longer at a symmetric position, so the Jacobians of the four elements are all different. This affects their stiffness matrices.

# Exercise: Move the center node from (0.5, 0.5) to (0.3, 0.7).
# How does the solution change? Is the center node value still 1/12?
# Why or why not?