Embedded 1D Fibersยค
Colab Setup (Install Dependencies)
# Only run this if we are in Google Colab
if 'google.colab' in str(get_ipython()):
print("Installing dependencies using uv...")
# Install uv if not available
!pip install -q uv
# Install system dependencies
!apt-get install -qq gmsh
# Use uv to install Python dependencies
!uv pip install --system matplotlib meshio
!uv pip install --system "git+https://github.com/smec-ethz/tatva-docs.git"
print("Installation complete!")
else:
import pyvista as pv
we consider a fiber-reinforced composite in which stiff 1D fibers are embedded in a soft 2D soft material. A central difficulty in such problems is that the fiber geometry typically does not align with the bulk mesh. The fibers may intersect bulk elements at arbitrary positions and orientations. In this example, we use an embedded-element approach formulated entirely at the level of the total potential energy.
import jax
from typing import NamedTuple
import jax.numpy as jnp
from jax import Array
from jax_autovmap import autovmap
from tatva import Mesh, Operator, compound, element, lifter, sparse
jax.config.update("jax_enable_x64", True) # use double-precision
Mesh Generation
import os
import gmsh
import matplotlib.pyplot as plt
import meshio
import numpy as np
def generate_plate_mesh(
Lx: float, Ly: float, mesh_size: float, work_dir: str = "."
) -> Mesh:
"""
Generates a 2D unstructured triangular mesh for a rectangular plate.
Args:
Lx (float): Length of the plate in the x-direction.
Ly (float): Length of the plate in the y-direction.
mesh_size (float): Target mesh size for the mesh generation.
work_dir (str): Directory to store temporary mesh files.
Returns:
Mesh: The generated plate mesh.
"""
if not os.path.exists(work_dir):
os.makedirs(work_dir)
filename = os.path.join(work_dir, "plate_2d.msh")
gmsh.initialize()
gmsh.model.add("plate")
p1 = gmsh.model.geo.addPoint(0, 0, 0, mesh_size)
p2 = gmsh.model.geo.addPoint(Lx, 0, 0, mesh_size)
p3 = gmsh.model.geo.addPoint(Lx, Ly, 0, mesh_size)
p4 = gmsh.model.geo.addPoint(0, Ly, 0, mesh_size)
l1 = gmsh.model.geo.addLine(p1, p2)
l2 = gmsh.model.geo.addLine(p2, p3)
l3 = gmsh.model.geo.addLine(p3, p4)
l4 = gmsh.model.geo.addLine(p4, p1)
loop = gmsh.model.geo.addCurveLoop([l1, l2, l3, l4])
_ = gmsh.model.geo.addPlaneSurface([loop])
# 2. Mesh Generation
gmsh.model.geo.synchronize()
gmsh.model.mesh.generate(2)
gmsh.write(filename)
gmsh.finalize()
# 3. Read back with meshio
m = meshio.read(filename)
if os.path.exists(filename):
os.remove(filename)
points = m.points[:, :2] # Drop z-coordinate for 2D
triangles = m.cells_dict["triangle"]
return Mesh(points, triangles)
def generate_honeycomb_mesh(
start_x: float,
start_y: float,
n_x: int,
n_y: int,
side_length: float,
segments_per_side: int = 1,
) -> Mesh:
"""
Generates a 2D honeycomb mesh (hexagonal grid) with specified parameters.
Each hexagon side can be subdivided into smaller segments.
Args:
start_x (float): Starting x-coordinate of the honeycomb grid.
start_y (float): Starting y-coordinate of the honeycomb grid.
n_x (int): Number of hexagons along the x-direction.
n_y (int): Number of hexagons along the y-direction.
side_length (float): Length of each side of the hexagon.
segments_per_side (int): Number of subdivisions per hexagon side.
Returns:
Mesh: The generated honeycomb mesh.
"""
dx = np.sqrt(3) * side_length
dy = 1.5 * side_length
row_offset = (np.sqrt(3) * side_length) / 2.0
node_map = {}
coords_list = []
lines_list = []
edge_set = set()
def get_or_create_node(x, y):
key = (round(x, 6), round(y, 6))
if key not in node_map:
idx = len(coords_list)
coords_list.append([x, y])
node_map[key] = idx
return idx
return node_map[key]
angles = np.deg2rad([30, 90, 150, 210, 270, 330])
for row in range(n_y):
cols_in_this_row = n_x if (row % 2 == 0) else (n_x - 1)
current_offset = 0.0 if (row % 2 == 0) else row_offset
for col in range(cols_in_this_row):
cx = start_x + (col * dx) + current_offset
cy = start_y + (row * dy)
corners = []
for theta in angles:
vx = cx + side_length * np.cos(theta)
vy = cy + side_length * np.sin(theta)
corners.append((vx, vy))
for k in range(6):
# Start and End coordinates of the current side
start_pt = np.array(corners[k])
end_pt = np.array(corners[(k + 1) % 6])
# Get the Node Index for the start of the side
current_node_idx = get_or_create_node(start_pt[0], start_pt[1])
# Vector along the side
side_vector = end_pt - start_pt
for i in range(1, segments_per_side + 1):
# Calculate fraction of distance (e.g., 1/3, 2/3, 3/3)
t = i / segments_per_side
# Calculate next coordinate
next_pt = start_pt + t * side_vector
# Get index for this new point
next_node_idx = get_or_create_node(next_pt[0], next_pt[1])
# Create the small segment
edge_key = tuple(sorted((current_node_idx, next_node_idx)))
if edge_key not in edge_set:
edge_set.add(edge_key)
lines_list.append([current_node_idx, next_node_idx])
# Move forward
current_node_idx = next_node_idx
return Mesh(np.array(coords_list), np.array(lines_list))
plate_mesh = generate_plate_mesh(Lx=Lx, Ly=Ly, mesh_size=0.04)
n_nodes = plate_mesh.coords.shape[0]
n_dofs_per_node = 2
n_dofs = n_nodes * n_dofs_per_node
fiber_mesh = generate_honeycomb_mesh(
start_x=0.6, start_y=0.7, n_x=3, n_y=3, side_length=0.2, segments_per_side=3
)
Output
Info : Meshing 1D... Info : [ 0%] Meshing curve 1 (Line) Info : [ 30%] Meshing curve 2 (Line) Info : [ 60%] Meshing curve 3 (Line) Info : [ 80%] Meshing curve 4 (Line) Info : Done meshing 1D (Wall 0.000292872s, CPU 0.000414s) Info : Meshing 2D... Info : Meshing surface 1 (Plane, Frontal-Delaunay) Info : Done meshing 2D (Wall 0.0697915s, CPU 0.068195s) Info : 3013 nodes 6028 elements Info : Writing './plate_2d.msh'... Info : Done writing './plate_2d.msh'
Visualize Meshes
plt.figure(figsize=(3, 3))
ax = plt.gca()
ax.tripcolor(
plate_mesh.coords[:, 0],
plate_mesh.coords[:, 1],
plate_mesh.elements,
facecolors=jnp.ones(plate_mesh.elements.shape[0]),
edgecolors="k",
cmap="managua",
lw=0.2,
)
for i, el in enumerate(fiber_mesh.elements):
p0 = fiber_mesh.coords[el[0]]
p1 = fiber_mesh.coords[el[1]]
ax.plot(
[p0[0], p1[0]],
[p0[1], p1[1]],
"k-",
lw=1,
)
ax.set_xlabel("x")
ax.set_ylabel("y")
ax.axis("equal")
ax.margins(0.0, 0.0)
plt.show()

We define two operator one for the bulk material which consists of Tri3 elements and one for fibers which are 1D elements emebeded in 2D space. For this we define a new elemenr Line2in3D which takes the displacements defined in 2D or 3D space and then project this displacement along its tangent vector.
Define Custom Line Element in 3D for Fiber Representation
class SpringElement(element.Line2):
"""
A 2-node linear element embedded in 3D space.
Reference domain: [-1, 1]
"""
def shape_function(self, xi: Array) -> Array:
return jnp.array([0.5 * (1.0 - xi[0]), 0.5 * (1.0 + xi[0])])
def shape_function_derivative(self, xi: Array) -> Array:
return jnp.array([[-0.5, 0.5]])
def get_jacobian(self, xi: Array, nodal_coords: Array) -> tuple[Array, Array]:
"""
nodal_coords: (2, 3) -> Two nodes in 3D space
"""
dN_dxi = self.shape_function_derivative(xi)
J_vec = dN_dxi @ nodal_coords # (1, 2) @ (2, 3) -> (1, 3)
detJ = jnp.linalg.norm(J_vec)
return J_vec, detJ
def gradient(self, xi: Array, nodal_values: Array, nodal_coords: Array) -> Array:
"""
Returns the 3D Gradient vector.
"""
J_vec, detJ = self.get_jacobian(xi, nodal_coords)
dN_dxi = self.shape_function_derivative(xi)
du_dxi = dN_dxi @ nodal_values
du_ds = du_dxi / detJ
tangent = J_vec / detJ
grad_u_3d = jnp.vdot(du_ds, tangent)
return grad_u_3d
We now find the bulk material elements that contain the nodes of each fiber and then map these nodes to the quadrature points of that element. We use Operator.make_interpolater to do this.
Defining the energy for the bulk materialยค
@autovmap(grad_u=2)
def compute_deformation_gradient(grad_u):
I = jnp.eye(2)
F = I + grad_u
return F
@autovmap(F=2, mu=0, lmbda=0)
def strain_energy(F, mu, lmbda):
C = F.T @ F
I1 = jnp.trace(C)
J = jnp.linalg.det(F)
# return mu / 2 * (I1 - 3) - lmbda * jnp.log(J) + (lmbda / 2) * (jnp.log(J)) ** 2
return 0.5 * mu * (I1 - 2 - 2 * jnp.log(J)) + (lmbda / 2) * (jnp.log(J)) ** 2
@jax.jit
def total_material_energy(u: Array) -> Array:
u_grad = op_plate.grad(u)
F = compute_deformation_gradient(u_grad)
energy_density = strain_energy(F, mat.mu, mat.lmbda)
return op_plate.integrate(energy_density)
@autovmap(grad_u=0)
def compute_fiber_energy(grad_u):
return 0.5 * E_fiber * jnp.dot(grad_u, grad_u) * Area_fiber
class Material(NamedTuple):
"""Material properties for the elasticity operator."""
mu: float # Diffusion coefficient
lmbda: float # Diffusion coefficient
mat = Material(mu=1, lmbda=10.0)
E = mat.mu * (3 * mat.lmbda + 2 * mat.mu) / (mat.lmbda + mat.mu)
print(f"Effective Young's Modulus of the Plate: E = {E:.2f}")
Effective Young's Modulus of the Plate: E = 2.91
Defining energy for the fiber networkยค
We now compute the energy of the fibe network which defined as
p0 = fiber_mesh.coords[fiber_mesh.elements[:, 0]]
p1 = fiber_mesh.coords[fiber_mesh.elements[:, 1]]
vecs = p1 - p0
lengths = np.linalg.norm(vecs, axis=1)
tangents = vecs / lengths[:, None] # Unit vectors (N_seg, 2)
fiber_L0 = jnp.array(lengths)
fiber_tangents = jnp.array(tangents)
@jax.jit
def fiber_strain_energy(
u: Array,
) -> Array:
u_at_nodes = interpolater(u)
u_grad = op_line.grad(u_at_nodes) # (N_seg, 1, 2)
energy_density = compute_fiber_energy(u_grad) # (N_seg,)
return op_line.integrate(energy_density)
Coupling the energiesยค
@jax.jit
def total_energy(u_flat: Array) -> float:
(u,) = Solution(u_flat)
U_plate = total_material_energy(u)
U_fiber = fiber_strain_energy(u)
return U_fiber + U_plate
Applying boundary conditionsยค
We apply uniaxial tension to the bulk material.
y_max = jnp.max(plate_mesh.coords[:, 1])
y_min = jnp.min(plate_mesh.coords[:, 1])
x_min = jnp.min(plate_mesh.coords[:, 0])
height = y_max - y_min
upper_nodes = jnp.where(jnp.isclose(plate_mesh.coords[:, 1], y_max))[0]
lower_nodes = jnp.where(jnp.isclose(plate_mesh.coords[:, 1], y_min))[0]
class Solution(compound.Compound):
u = compound.field(
shape=(plate_mesh.coords.shape[0], 2), field_type=compound.FieldType.NODAL
)
lifter_plate = lifter.Lifter(
Solution.size,
lifter.Fixed(Solution.u[upper_nodes, 0], 0.0),
lifter.Fixed(Solution.u[lower_nodes, :], 0.0),
lifter.Fixed(Solution.u[upper_nodes, 1], lifter.RuntimeValue("top_disp", 0.0)),
)
Defining Free Energyยค
def total_energy_free(u_free: Array, lifter: lifter.Lifter):
u_full = lifter.lift_from_zeros(u_free)
return total_energy(u_full)
total_energy_free(jnp.zeros(lifter_plate.size_reduced), lifter_plate) # Test call
Array(0., dtype=float64)
Sparse Differentiation to construct the \(K\)ยค
gradient = jax.grad(total_energy_free, argnums=0)
sparsity_pattern = sparse.pattern_from_energy(
total_energy_free, lifter_plate.size_reduced, lifter_plate
)
sparsity_pattern_mesh = sparse.pattern_from_mesh(plate_mesh, n_dofs_per_node=2)
sparsity_pattern_mesh = lifter_plate.adapt_sparsity(sparsity_pattern_mesh)
cm = sparse.ColoredMatrix.from_csr(sparsity_pattern)
n_colors = int(cm.colors.max() + 1)
print(n_colors)
hessian_fn = sparse.jacfwd(gradient, colored_matrix=cm, color_batch_size=n_colors)
hessian_fn = jax.jit(hessian_fn)
42
import matplotlib.pyplot as plt
ax = plt.axes()
plt.spy(
sparsity_pattern_mesh.toarray(),
markersize=1,
color="darkgray",
markeredgecolor="darkgray",
)
plt.spy(
sparsity_pattern.toarray() - sparsity_pattern_mesh.toarray(),
markersize=1,
markeredgecolor="#009AF9",
)
ax.set_xticks([])
ax.set_yticks([])
plt.show()

Newton-Solver with sparse solver
def newton_krylov_solver(
u,
fext,
lifter_plate: lifter.Lifter,
):
fint = gradient(u, lifter_plate)
iiter = 0
norm_res = 1.0
tol = 1e-8
max_iter = 80
while norm_res > tol and iiter < max_iter:
residual = fext - fint
A = hessian_fn(u, lifter_plate)
du = jax.experimental.sparse.linalg.spsolve(
A.data, A.indices, A.indptr, residual
)
u = u.at[:].add(du)
fint = gradient(u, lifter_plate)
residual = fext - fint
norm_res = jnp.linalg.norm(residual)
print(f" Residual: {norm_res:.2e}")
iiter += 1
return u, norm_res
fext = jnp.zeros(lifter_plate.size_reduced)
n_steps = 50
applied_displacement = height * 0.2 / n_steps
u_sol_per_step = []
for i in range(n_steps):
lifter_plate = lifter_plate.at["top_disp"].set((i + 1) * applied_displacement)
u_new, rnorm = newton_krylov_solver(u_prev, fext, lifter_plate=lifter_plate)
u_prev = u_new
u_full = lifter_plate.lift_from_zeros(u_prev)
u_sol_per_step.append(u_full)
print(f"Iteration {i}: Residual Norm = {rnorm:.4e}")
Output
Residual: 2.77e-01 Residual: 2.49e-02 Residual: 2.63e-04 Residual: 4.45e-08 Residual: 4.74e-15 Iteration 0: Residual Norm = 4.7447e-15 Residual: 2.75e-01 Residual: 2.46e-02 Residual: 2.57e-04 Residual: 4.21e-08 Residual: 4.71e-15 Iteration 1: Residual Norm = 4.7133e-15 Residual: 2.73e-01 Residual: 2.43e-02 Residual: 2.52e-04 Residual: 3.98e-08 Residual: 4.69e-15 Iteration 2: Residual Norm = 4.6920e-15 Residual: 2.71e-01 Residual: 2.41e-02 Residual: 2.46e-04 Residual: 3.77e-08 Residual: 4.77e-15 Iteration 3: Residual Norm = 4.7739e-15 Residual: 2.69e-01 Residual: 2.38e-02 Residual: 2.41e-04 Residual: 3.58e-08 Residual: 4.89e-15 Iteration 4: Residual Norm = 4.8948e-15 Residual: 2.67e-01 Residual: 2.35e-02 Residual: 2.36e-04 Residual: 3.40e-08 Residual: 5.13e-15 Iteration 5: Residual Norm = 5.1254e-15 Residual: 2.66e-01 Residual: 2.33e-02 Residual: 2.31e-04 Residual: 3.24e-08 Residual: 5.24e-15 Iteration 6: Residual Norm = 5.2382e-15 Residual: 2.64e-01 Residual: 2.30e-02 Residual: 2.26e-04 Residual: 3.08e-08 Residual: 5.46e-15 Iteration 7: Residual Norm = 5.4569e-15 Residual: 2.62e-01 Residual: 2.28e-02 Residual: 2.21e-04 Residual: 2.94e-08 Residual: 6.04e-15 Iteration 8: Residual Norm = 6.0375e-15 Residual: 2.60e-01 Residual: 2.26e-02 Residual: 2.17e-04 Residual: 2.80e-08 Residual: 6.35e-15 Iteration 9: Residual Norm = 6.3468e-15 Residual: 2.58e-01 Residual: 2.23e-02 Residual: 2.13e-04 Residual: 2.68e-08 Residual: 6.69e-15 Iteration 10: Residual Norm = 6.6873e-15 Residual: 2.57e-01 Residual: 2.21e-02 Residual: 2.09e-04 Residual: 2.56e-08 Residual: 7.21e-15 Iteration 11: Residual Norm = 7.2092e-15 Residual: 2.55e-01 Residual: 2.19e-02 Residual: 2.05e-04 Residual: 2.45e-08 Residual: 7.30e-15 Iteration 12: Residual Norm = 7.2986e-15 Residual: 2.53e-01 Residual: 2.17e-02 Residual: 2.01e-04 Residual: 2.35e-08 Residual: 7.33e-15 Iteration 13: Residual Norm = 7.3292e-15 Residual: 2.52e-01 Residual: 2.14e-02 Residual: 1.97e-04 Residual: 2.26e-08 Residual: 7.98e-15 Iteration 14: Residual Norm = 7.9768e-15 Residual: 2.50e-01 Residual: 2.12e-02 Residual: 1.94e-04 Residual: 2.17e-08 Residual: 8.71e-15 Iteration 15: Residual Norm = 8.7135e-15 Residual: 2.49e-01 Residual: 2.10e-02 Residual: 1.90e-04 Residual: 2.08e-08 Residual: 9.19e-15 Iteration 16: Residual Norm = 9.1940e-15 Residual: 2.47e-01 Residual: 2.08e-02 Residual: 1.87e-04 Residual: 2.01e-08 Residual: 9.96e-15 Iteration 17: Residual Norm = 9.9569e-15 Residual: 2.45e-01 Residual: 2.06e-02 Residual: 1.84e-04 Residual: 1.93e-08 Residual: 1.02e-14 Iteration 18: Residual Norm = 1.0230e-14 Residual: 2.44e-01 Residual: 2.04e-02 Residual: 1.81e-04 Residual: 1.86e-08 Residual: 1.04e-14 Iteration 19: Residual Norm = 1.0381e-14 Residual: 2.42e-01 Residual: 2.02e-02 Residual: 1.78e-04 Residual: 1.80e-08 Residual: 1.07e-14 Iteration 20: Residual Norm = 1.0665e-14 Residual: 2.41e-01 Residual: 2.01e-02 Residual: 1.75e-04 Residual: 1.74e-08 Residual: 1.13e-14 Iteration 21: Residual Norm = 1.1253e-14 Residual: 2.40e-01 Residual: 1.99e-02 Residual: 1.72e-04 Residual: 1.68e-08 Residual: 1.08e-14 Iteration 22: Residual Norm = 1.0810e-14 Residual: 2.38e-01 Residual: 1.97e-02 Residual: 1.69e-04 Residual: 1.63e-08 Residual: 1.24e-14 Iteration 23: Residual Norm = 1.2413e-14 Residual: 2.37e-01 Residual: 1.95e-02 Residual: 1.67e-04 Residual: 1.58e-08 Residual: 1.20e-14 Iteration 24: Residual Norm = 1.1975e-14 Residual: 2.35e-01 Residual: 1.93e-02 Residual: 1.64e-04 Residual: 1.53e-08 Residual: 1.34e-14 Iteration 25: Residual Norm = 1.3373e-14 Residual: 2.34e-01 Residual: 1.92e-02 Residual: 1.62e-04 Residual: 1.48e-08 Residual: 1.32e-14 Iteration 26: Residual Norm = 1.3175e-14 Residual: 2.33e-01 Residual: 1.90e-02 Residual: 1.59e-04 Residual: 1.44e-08 Residual: 1.29e-14 Iteration 27: Residual Norm = 1.2864e-14 Residual: 2.31e-01 Residual: 1.88e-02 Residual: 1.57e-04 Residual: 1.40e-08 Residual: 1.29e-14 Iteration 28: Residual Norm = 1.2860e-14 Residual: 2.30e-01 Residual: 1.87e-02 Residual: 1.55e-04 Residual: 1.36e-08 Residual: 1.37e-14 Iteration 29: Residual Norm = 1.3660e-14 Residual: 2.29e-01 Residual: 1.85e-02 Residual: 1.52e-04 Residual: 1.33e-08 Residual: 1.35e-14 Iteration 30: Residual Norm = 1.3472e-14 Residual: 2.27e-01 Residual: 1.84e-02 Residual: 1.50e-04 Residual: 1.30e-08 Residual: 1.39e-14 Iteration 31: Residual Norm = 1.3900e-14 Residual: 2.26e-01 Residual: 1.82e-02 Residual: 1.48e-04 Residual: 1.27e-08 Residual: 1.56e-14 Iteration 32: Residual Norm = 1.5554e-14 Residual: 2.25e-01 Residual: 1.81e-02 Residual: 1.46e-04 Residual: 1.24e-08 Residual: 1.63e-14 Iteration 33: Residual Norm = 1.6259e-14 Residual: 2.24e-01 Residual: 1.79e-02 Residual: 1.44e-04 Residual: 1.21e-08 Residual: 1.60e-14 Iteration 34: Residual Norm = 1.5969e-14 Residual: 2.23e-01 Residual: 1.78e-02 Residual: 1.43e-04 Residual: 1.19e-08 Residual: 1.75e-14 Iteration 35: Residual Norm = 1.7458e-14 Residual: 2.21e-01 Residual: 1.77e-02 Residual: 1.41e-04 Residual: 1.16e-08 Residual: 1.71e-14 Iteration 36: Residual Norm = 1.7066e-14 Residual: 2.20e-01 Residual: 1.75e-02 Residual: 1.39e-04 Residual: 1.14e-08 Residual: 1.77e-14 Iteration 37: Residual Norm = 1.7716e-14 Residual: 2.19e-01 Residual: 1.74e-02 Residual: 1.38e-04 Residual: 1.12e-08 Residual: 1.76e-14 Iteration 38: Residual Norm = 1.7566e-14 Residual: 2.18e-01 Residual: 1.72e-02 Residual: 1.36e-04 Residual: 1.10e-08 Residual: 1.82e-14 Iteration 39: Residual Norm = 1.8217e-14 Residual: 2.17e-01 Residual: 1.71e-02 Residual: 1.34e-04 Residual: 1.09e-08 Residual: 1.95e-14 Iteration 40: Residual Norm = 1.9522e-14 Residual: 2.16e-01 Residual: 1.70e-02 Residual: 1.33e-04 Residual: 1.07e-08 Residual: 1.97e-14 Iteration 41: Residual Norm = 1.9704e-14 Residual: 2.15e-01 Residual: 1.69e-02 Residual: 1.32e-04 Residual: 1.06e-08 Residual: 1.99e-14 Iteration 42: Residual Norm = 1.9866e-14 Residual: 2.14e-01 Residual: 1.68e-02 Residual: 1.30e-04 Residual: 1.05e-08 Residual: 2.04e-14 Iteration 43: Residual Norm = 2.0404e-14 Residual: 2.12e-01 Residual: 1.66e-02 Residual: 1.29e-04 Residual: 1.04e-08 Residual: 2.01e-14 Iteration 44: Residual Norm = 2.0149e-14 Residual: 2.11e-01 Residual: 1.65e-02 Residual: 1.28e-04 Residual: 1.03e-08 Residual: 2.08e-14 Iteration 45: Residual Norm = 2.0827e-14 Residual: 2.10e-01 Residual: 1.64e-02 Residual: 1.27e-04 Residual: 1.02e-08 Residual: 2.14e-14 Iteration 46: Residual Norm = 2.1355e-14 Residual: 2.09e-01 Residual: 1.63e-02 Residual: 1.26e-04 Residual: 1.02e-08 Residual: 2.42e-14 Iteration 47: Residual Norm = 2.4154e-14 Residual: 2.08e-01 Residual: 1.62e-02 Residual: 1.25e-04 Residual: 1.02e-08 Residual: 2.29e-14 Iteration 48: Residual Norm = 2.2869e-14 Residual: 2.07e-01 Residual: 1.61e-02 Residual: 1.24e-04 Residual: 1.02e-08 Residual: 2.35e-14 Iteration 49: Residual Norm = 2.3507e-14
Visualizationยค
We now visualize the deformation of the bulk material and the embedded fibres.
Post-processing and Visualization
import pyvista as pv
def set_size(fraction=1, height_ratio="golden", width="two-column", subplots=(1, 1)):
if width == "two-column":
width_pt = 180 # mm
elif width == "one-column":
width_pt = 90 # mm
else:
width_pt = width
if height_ratio == "golden":
ratio_pt = (np.sqrt(5) - 1.0) / 2.0
else:
ratio_pt = height_ratio
fig_width_pt = width_pt * fraction
inches_per_pt = 1.0 / 25.4
fig_width_in = fig_width_pt * inches_per_pt
fig_height_in = fig_width_in * ratio_pt * (subplots[0] / subplots[1])
fig_dim = (fig_width_in, fig_height_in)
return fig_dim
def get_pv_grid(mesh: Mesh) -> pv.UnstructuredGrid:
"""Convert Tatva mesh to PyVista UnstructuredGrid."""
if mesh.coords.shape[1] == 2:
pv_points = np.hstack((mesh.coords, np.zeros(shape=(mesh.coords.shape[0], 1))))
else:
pv_points = np.array(mesh.coords)
cells = np.hstack(
[
np.full((mesh.elements.shape[0], 1), 3, dtype=np.int64),
np.array(mesh.elements, dtype=np.int64),
]
)
grid = pv.UnstructuredGrid(
cells, np.full(mesh.elements.shape[0], pv.CellType.TRIANGLE), pv_points
)
return grid
def find_domain_boundary(elements):
# 1. Get all edges (3 per triangle)
edges = np.concatenate(
[elements[:, [0, 1]], elements[:, [1, 2]], elements[:, [2, 0]]], axis=0
)
# 2. Sort node IDs within each edge to handle directionality
edges_sorted = np.sort(edges, axis=1)
# 3. Find unique edges and their counts
unique_edges, indices, counts = np.unique(
edges_sorted, axis=0, return_index=True, return_counts=True
)
# 4. Boundary edges are those that appear only once
boundary_edges = edges[indices[counts == 1]]
return boundary_edges
def get_pv_line_grid(mesh: Mesh) -> pv.PolyData:
"""Convert a Line2 (segment) Tatva mesh to a PyVista PolyData of lines."""
coords = np.asarray(mesh.coords)
if coords.shape[1] == 2:
pv_points = np.hstack((coords, np.zeros((coords.shape[0], 1))))
else:
pv_points = coords
elems = np.asarray(mesh.elements, dtype=np.int64)
# VTK line connectivity: [n_points_in_cell, id0, id1, ...] per cell
lines = np.hstack([np.full((elems.shape[0], 1), 2, dtype=np.int64), elems])
return pv.PolyData(pv_points, lines=lines)
def _pad3(u: Array) -> np.ndarray:
"""Pad a 2D nodal (n, 2) field to (n, 3) so it can warp PyVista points."""
u = np.asarray(u)
return np.hstack([u, np.zeros((u.shape[0], 1))])
def _neo_hookean_energy(F: Array) -> Array:
"""Scalar strain-energy density for a single 2x2 deformation gradient."""
C = F.T @ F
I1 = jnp.trace(C)
J = jnp.linalg.det(F)
return (
0.5 * mat.mu * (I1 - 2 - 2 * jnp.log(J)) + (mat.lmbda / 2) * (jnp.log(J)) ** 2
)
@autovmap(F=2)
def von_mises_from_F(F: Array) -> Array:
"""In-plane von Mises stress from the Cauchy stress sigma = J^-1 P F^T."""
P = jax.grad(_neo_hookean_energy)(F) # 1st Piola-Kirchhoff (2, 2)
J = jnp.linalg.det(F)
sigma = (P @ F.T) / J # Cauchy stress (2, 2)
sxx, syy, sxy = sigma[0, 0], sigma[1, 1], sigma[0, 1]
return jnp.sqrt(sxx**2 - sxx * syy + syy**2 + 3 * sxy**2)
def plate_von_mises(u2d: Array) -> np.ndarray:
"""Per-element (per quadrature point) von Mises stress for a nodal field."""
grad_u = op_plate.grad(u2d)
F = compute_deformation_gradient(grad_u)
return np.asarray(von_mises_from_F(F)).reshape(-1)
def animate_deformation(
save_gif: str | None = None,
plate_cmap: str = "viridis",
fiber_cmap: str = "magma",
):
"""Step through ``u_sol_per_step``, warping the plate and fibers.
With ``save_gif`` a GIF is written; otherwise an interactive window opens
with a slider to scrub through the load steps. Colour limits are fixed to
the global range so steps are directly comparable.
"""
plate_grid = get_pv_grid(plate_mesh)
fiber_grid = get_pv_line_grid(fiber_mesh)
plate_pts0 = plate_grid.points.copy()
fiber_pts0 = fiber_grid.points.copy()
# lift_from_zeros returns a flat DOF vector; reshape to (n_nodes, 2).
u2d_steps = [
jnp.asarray(u).reshape(plate_mesh.coords.shape[0], 2) for u in u_sol_per_step
]
# Precompute per-step warp fields and scalars.
plate_disp = [_pad3(u) for u in u2d_steps]
plate_vm = [plate_von_mises(u) for u in u2d_steps] # per-element von Mises
fiber_disp = [_pad3(interpolater(u)) for u in u2d_steps]
fiber_strain = [
np.asarray(op_line.grad(interpolater(u)).flatten()) for u in u2d_steps
]
plate_clim = [0.0, float(max(s.max() for s in plate_vm))]
fiber_clim = [
float(min(s.min() for s in fiber_strain)),
float(max(s.max() for s in fiber_strain)),
]
def set_step(i):
i = int(round(i))
plate_grid.points = plate_pts0 + plate_disp[i]
plate_grid["von Mises"] = plate_vm[i]
fiber_grid.points = fiber_pts0 + fiber_disp[i]
fiber_grid["strain"] = fiber_strain[i]
pl = pv.Plotter(off_screen=save_gif is not None)
set_step(0)
pl.add_mesh(
plate_grid,
scalars="von Mises",
cmap=plate_cmap,
clim=plate_clim,
show_edges=False,
edge_color="gray",
line_width=0.0,
scalar_bar_args={
"title": "von Mises",
"vertical": False,
"position_x": 0.05,
"position_y": 0.05,
"width": 0.4,
"height": 0.05,
},
)
pl.add_mesh(
fiber_grid,
scalars="strain",
cmap=fiber_cmap,
clim=fiber_clim,
line_width=6,
render_lines_as_tubes=True,
scalar_bar_args={
"title": "fiber strain",
"vertical": False,
"position_x": 0.55,
"position_y": 0.05,
"width": 0.4,
"height": 0.05,
},
)
pl.view_xy()
pl.enable_parallel_projection()
n = len(u_sol_per_step)
if save_gif is not None:
pl.open_gif(save_gif)
for i in range(n):
set_step(i)
pl.write_frame()
pl.close()
else:
pl.add_slider_widget(
set_step,
[0, n - 1],
value=0,
title="load step",
fmt="%.0f",
)
pl.show()
return pl
<pyvista.plotting.plotter.Plotter at 0x7391e4604350>
