In this activity, we will consider an application of two-way coupling. We want to see how one physical process (thermal) affects the other physical process (mechanical) and vice-versa. For the application that we will consider is heating of a bimetallic strip. The strip is made of two different materials, one with a high thermal expansion coefficient and the other with a low thermal expansion coefficient. When the strip is heated, the material with the high thermal expansion coefficient will expand more than the material with the low thermal expansion coefficient. This will cause the strip to bend.
The total energy functional for the coupled thermo-mechanical problem can be written as:
where \(u\) is the displacement field and \(T\) is the temperature field. The thermal energy functional \(\Psi_\text{thermal}(T)\) describes the thermal behavior of the system, the mechanical energy functional \(\Psi_\text{mechanical}(u)\) describes the mechanical behavior of the system, and the coupling energy functional \(\Psi_\text{coupling}(u, T)\) describes how the two process affect each other. Each individual energy functional can be defined as follows:
where \(\kappa\) is the thermal diffusivity, \(Q\) is the heat source term, and \(\Omega\) is the domain of interest. \(T_0\) is the reference temperature. The term on the boundary \(\Gamma_q\) represents the heat flux boundary condition with \(\bar{q}\) being the prescribed heat flux.
where \(\alpha\) is the thermal expansion coefficient, and \(\lambda\) and \(\mu\) are the Lamé’s parameters.
In order to minimize the total energy functional, we need to ensure that the first variation of the total energy functional is zero.
As mentioned in Fundamentals, we can either solve the fully coupled problem using a monolithic approach or use a staggered approach where we solve the thermal and mechanical problems sequentially. As we have seen how to solve a fully coupled problem using a monolithic approach in For example, in Dirichlet BC as constraints (Lagrange multiplier), In-class activity: Active set method and in Assignment 1, we will now focus on the staggered approach.
Staggered approach
In this approach, we assume that the thermal and mechanical problems can be solved sequentially. The conditions for minimization of the total energy functional can be written as:
Now,we can solve one problem first and use the solution to solve the other problem. For example, we can first solve the thermal problem to obtain the temperature field \(T\), and then use this temperature field to solve the mechanical problem to obtain the displacement field \(u\).
We will use the finite element method to solve the thermo-mechanical problem and analyze the effect of difference in thermal expansion coefficients on the bending of the strip.
Code: Import essential libraries
import jaxjax.config.update("jax_enable_x64", True) # use double-precisionjax.config.update("jax_persistent_cache_min_compile_time_secs", 0)jax.config.update("jax_platforms", "cpu")import equinox as eqximport gmshimport jax.numpy as jnpimport matplotlib.pyplot as pltimport meshiofrom jax import Arrayfrom jax_autovmap import autovmapfrom tatva import Mesh, Operator, elementfrom tatva.plotting import ( STYLE_PATH, colors, plot_element_values, plot_nodal_values,)
Model setup
We consider a bimetallic strip of length \(L=5\) m and height \(H=0.5\) m. The strip is made of two materials with different thermal properties.
Code: Function to generate a mesh for a bimaterial strip
def generate_bimaterial_mesh( width: float, height: float, mesh_size_far: float, mesh_size_interface: float, refine_dist_interface: float,):""" Generates a 2D mesh for two adjacent rectangular blocks with local refinement. Args: width1 (float): Width of the left block. width2 (float): Width of the right block. height (float): Common height of the blocks. mesh_size_far (float): The general, coarse element size away from refined areas. mesh_size_interface (float): The target element size along the common interface. refine_dist_interface (float): Distance over which refinement transitions from the interface. output_filename (str): Name of the output mesh file. """import os mesh_dir = os.path.join(os.getcwd(), "../meshes") os.makedirs(mesh_dir, exist_ok=True) output_filename = os.path.join(mesh_dir, "bimaterial_interface.msh") gmsh.initialize() gmsh.model.add("bimaterial_interface")# define the corners of the two blocks and the shared interface. p1 = gmsh.model.geo.addPoint(0, -height /2, 0) p2 = gmsh.model.geo.addPoint(width, -height /2, 0) p3 = gmsh.model.geo.addPoint(width, 0, 0) p4 = gmsh.model.geo.addPoint(width, height /2, 0) p5 = gmsh.model.geo.addPoint(0, height /2, 0) p6 = gmsh.model.geo.addPoint(0, 0, 0)# Define the lines for the boundaries and the interface l_bottom = gmsh.model.geo.addLine(p1, p2) l_bottom_right = gmsh.model.geo.addLine(p2, p3) l_top_right = gmsh.model.geo.addLine(p3, p4) l_top = gmsh.model.geo.addLine(p4, p5) l_top_left = gmsh.model.geo.addLine(p5, p6) l_bottom_left = gmsh.model.geo.addLine(p6, p1)# common interface is a single geometric entity shared by both surfaces l_interface = gmsh.model.geo.addLine(p6, p3)# create the curve loops and surfaces for each block loop1 = gmsh.model.geo.addCurveLoop( [l_bottom, l_bottom_right, -l_interface, l_bottom_left] ) loop2 = gmsh.model.geo.addCurveLoop([l_interface, l_top_right, l_top, l_top_left]) surface1 = gmsh.model.geo.addPlaneSurface([loop1]) surface2 = gmsh.model.geo.addPlaneSurface([loop2])# synchronize the model to ensure all entities are recognized gmsh.model.geo.synchronize()# helps in identifying materials and boundaries when using the mesh file. gmsh.model.addPhysicalGroup(2, [surface1], 1, name="bottom") gmsh.model.addPhysicalGroup(2, [surface2], 2, name="top") gmsh.model.addPhysicalGroup(1, [l_interface], 3, name="interface")# refinement along the common interface dist_field_interface = gmsh.model.mesh.field.add("Distance", 3) gmsh.model.mesh.field.setNumbers(dist_field_interface, "CurvesList", [l_interface]) thresh_field_interface = gmsh.model.mesh.field.add("Threshold", 4) gmsh.model.mesh.field.setNumber( thresh_field_interface, "InField", dist_field_interface ) gmsh.model.mesh.field.setNumber( thresh_field_interface, "SizeMin", mesh_size_interface ) gmsh.model.mesh.field.setNumber(thresh_field_interface, "SizeMax", mesh_size_far) gmsh.model.mesh.field.setNumber( thresh_field_interface, "DistMin", refine_dist_interface ) gmsh.model.mesh.field.setNumber( thresh_field_interface, "DistMax", refine_dist_interface *2 )# take the minimum element size prescribed by either refinement rule at any point. min_field = gmsh.model.mesh.field.add("Min", 5) gmsh.model.mesh.field.setNumbers(min_field, "FieldsList", [thresh_field_interface])# set this combined field as the background field to control the mesh generation gmsh.model.mesh.field.setAsBackgroundMesh(min_field) gmsh.model.mesh.generate(2) gmsh.write(output_filename)print(f"Mesh successfully generated and saved to '{output_filename}'") gmsh.finalize() _mesh = meshio.read(output_filename) mesh = Mesh( coords=_mesh.points[:, :2], elements=_mesh.cells_dict["triangle"], ) top_elements_indices = _mesh.cell_sets_dict["top"]["triangle"] bottom_elements_indices = _mesh.cell_sets_dict["bottom"]["triangle"] interface_elements = _mesh.cells_dict["line"][ _mesh.cell_sets_dict["interface"]["line"] ]return mesh, top_elements_indices, bottom_elements_indices, interface_elements
Since we will solve the coupled problem using the staggered approach, we need to define the degrees of freedom for each physical problem separately. Lets now define the degrees of freedom for the each physical problem. We will have displacement degrees of freedom for each node in the mesh and temperature degrees of freedom for each node in the mesh.
Since each node has 2 displacement degrees of freedom (for 2D problems) and 1 temperature degree of freedom, the total degrees of each problem will be:
Mechanical problem: \(2 \times \text{number of nodes}\)
Thermal problem: \(1 \times \text{number of nodes}\)
The top part of the strip is made of a material with a thermal diffusivity coefficient of \(\kappa_\text{top}\) and the bottom part is made of a material with a thermal diffusivity coefficient of \(\kappa_\text{bottom}\)., such that
\[
\kappa_\text{top} = 10 \kappa_\text{bottom}
\]
where \(\kappa_\text{bottom}=1\) W/m·K.
Similarly, the thermal expansion coefficients of the top \(\alpha_\text{top}\) and bottom materials \(\alpha_\text{bottom}\) are given as:
\[
\alpha_\text{top} = 10 \alpha_\text{bottom}
\]
where \(\alpha_\text{bottom}=10^{-5}\) 1/K.
The elastic properties of both materials are assumed to be the same, with a Young’s modulus of \(E=50\) N/m² and a Poisson’s ratio of \(\nu=0.2\).
from typing import NamedTupleclass Material(NamedTuple):"""Properties of the thermal material""" kappa: float# Thermal diffusivity alpha: float# Thermal expansion coefficient mu: float# Shear modulus lmbda: float# First Lamé parameterE =50nu =0.2lmbda = E * nu / (1+ nu) / (1-2* nu)mu = E /2/ (1+ nu)mat_top = Material(kappa=10., alpha=1e-4, mu=mu, lmbda=lmbda)mat_bottom = Material(kappa=1., alpha=1e-5, mu=mu, lmbda=lmbda)
We now define the Operators for the top and bottom meshes separately to compute the various energy functionals
tri = element.Tri3()op_top = Operator(top_mesh, tri)op_bottom = Operator(bottom_mesh, tri)
where \(\kappa\) is the thermal conductivity, \(Q\) is the heat source per unit volume per unit time, and \(T_0\) is the reference temperature.
Note
For this exercise we assume that reference temperature \(T_0 = 0\) and the heat source \(Q=0\). Therefore, we can simplify the above expressions by removing \(T_0\) and \(Q\).
The thermal functional to be minimized is therefore:
For computing the \(\nabla T\) term in the thermal energy density, you can use the op.grad method of the Operator class as we have done in previous activities. Except this time, do not reshape the passed temperature field as it has only 1 degree of freedom, so we can use the flattened temperature field directly.
grad_T = op.grad(T_field.flatten())
@autovmap(grad_theta=1, kappa=0)def thermal_energy_density(grad_theta: Array, kappa: float) -> Array:"""compute_thermal energy_density Args: grad_theta (Array): gradient of temperature field kappa (float): thermal conductivity Returns: Array: thermal energy density """return0.5* kappa * jnp.einsum("i, i->", grad_theta, grad_theta)@jax.jitdef total_thermal_energy(theta_flat : Array) -> Array:"""Computes the total thermal energy in the bimaterial strip. Args: theta_flat (Array): flattened temperature field Returns: Array: total thermal energy """ grad_theta = op_top.grad(theta_flat) top_thermal_energy_density = thermal_energy_density(grad_theta, mat_top.kappa) top_thermal_energy = op_top.integrate(top_thermal_energy_density) grad_theta = op_bottom.grad(theta_flat) bottom_thermal_energy_density = thermal_energy_density(grad_theta, mat_bottom.kappa) bottom_thermal_energy = op_bottom.integrate(bottom_thermal_energy_density) thermal_energy = top_thermal_energy + bottom_thermal_energyreturn thermal_energy
Defining the energy functional for mechanical problem
were \(\mu\) and \(\lambda\) are the Lamé’s parameters, \(\boldsymbol{\epsilon}\) is the strain tensor, and \(\mathbf{I}\) is the identity tensor. The strain tensor is defined as:
If you notice the units of thermal properties have Watts (W) which is Joules per second (J/s). Therefore, the thermal energy functional actually represents a rate of energy (power). To be consistent with the mechanical energy functional which has units of energy (Joules), we need to multiply the thermal energy functional by a time step \(\Delta t\) (in seconds) to convert it to energy. For simplicity, we can assume \(\Delta t = 1\) s.
Since the thermal process is independent of the elastic process, we can solve the thermal problem first.
The Dirichlet boundary condition is applied on the left, right and bottom sides of the strip. We maintain the temperature at these boundaries to be \(0^\circ\) C. \[
T(x=x_\text{min}) = 0^\circ \text{C}, \quad T(x=x_\text{max}) = 0^\circ \text{C}, \quad T(y=y_\text{min}) = 0^\circ \text{C}
\]
We define a Neumann boundary condition (heat flux \(\bar{q}\)) on the top edge of the strip. As mentioned earlier, the energy associatd with this boundary condition is given by:
\[
\psi_{\text{th, BC}} = - \int_{\Gamma_q} \bar{q} T \, \text{d}A \quad \text{where} \quad \Gamma_q \text{ is the top edge of the strip, i.e., } y = y_\text{max}
\]
The heat flux \(\bar{q}\) is assumed to be constant over the entire top edge of the strip and is given as
\[
\bar{q} = 20~ \text{W/m²}
\]
To compute the corresponding external heat flux vector, we need to take the variation of this energy term with respect to the temperature field \(T\).
Below we define a few helper functions to compute the external heat flux vector.
Code: Function to extract 1D elements for the top edge of the mesh
def get_elements_on_curve(mesh, curve_func, tol=1e-3): coords = mesh.coords elements_2d = mesh.elements# Efficiently find all nodes on the curve using jax.vmap on_curve_mask = jax.vmap(lambda c: curve_func(c, tol))(coords) elements_1d = []# Iterate through all 2D elements to find edges on the curvefor tri in elements_2d:# Define the three edges of the triangle edges = [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])]for n_a, n_b in edges:# If both nodes of an edge are on the curve, add it to the setif on_curve_mask[n_a] and on_curve_mask[n_b]:# Sort to store canonical representation, e.g., (1, 2) not (2, 1) elements_1d.append(tuple(sorted((n_a, n_b))))ifnot elements_1d:return jnp.array([], dtype=int)return jnp.array(elements_1d)def heat_flux_line(coord: jnp.ndarray, tol: float) ->bool:return jnp.isclose( coord[1], y_max, atol=tol, )
We solve the thermo-mechanical problem using a matrix-free Newton-Raphson solver. Now, we define a few functions to implement the matrix-free solvers. We will use the conjugate gradient method to solve the linear system of equations. The Dirichlet boundary conditions are applied using projection method. As we use the matrix-free solver, we need to define the gradient of the total potential energy, which is also the residual function.
Below define the function to compute the \(\boldsymbol{f}_{int}\) using jax.jacrev and then use this function to compute the Jacobian-vector product using jax.jvp. Remember to apply the Projection approach to enforce the Dirichlet boundary conditions.
Remeber we need to define the internal force vector for both thermal and mechanical problems separately i.e
@eqx.filter_jitdef conjugate_gradient(A, b, atol=1e-8, max_iter=100): iiter =0def body_fun(state): b, p, r, rsold, x, iiter = state Ap = A(p) alpha = rsold / jnp.vdot(p, Ap) x = x + jnp.dot(alpha, p) r = r - jnp.dot(alpha, Ap) rsnew = jnp.vdot(r, r) p = r + (rsnew / rsold) * p rsold = rsnew iiter = iiter +1return (b, p, r, rsold, x, iiter)def cond_fun(state): b, p, r, rsold, x, iiter = statereturn jnp.logical_and(jnp.sqrt(rsold) > atol, iiter < max_iter) x = jnp.full_like(b, fill_value=0.0) r = b - A(x) p = r rsold = jnp.vdot(r, p) b, p, r, rsold, x, iiter = jax.lax.while_loop( cond_fun, body_fun, (b, p, r, rsold, x, iiter) )return x, iiterdef newton_krylov_solver( u, fext, gradient, compute_tangent, fixed_dofs,): fint = gradient(u) iiter =0 norm_res =1.0 tol =1e-8 max_iter =80while norm_res > tol and iiter < max_iter: residual = fext - fint residual = residual.at[fixed_dofs].set(0) A = eqx.Partial( compute_tangent, x_prev=u, gradient=gradient, fixed_dofs=fixed_dofs ) du, cg_iiter = conjugate_gradient(A=A, b=residual, atol=1e-8, max_iter=1000) u = u.at[:].add(du) fint = gradient(u) residual = fext - fint residual = residual.at[fixed_dofs].set(0) norm_res = jnp.linalg.norm(residual) iiter +=1return u, norm_res
Solving thermo-mechanical problem
Now, we will solve the thermo-mechanical problem using the staggered approach. In order to solve, we first solve the thermal problem to obtain the temperature field, and then use this temperature field to solve the mechanical problem to obtain the displacement field.
We will apply the external heat flux \(\boldsymbol{f}_\text{ext, th}\) in 2 steps n_steps=2. In each step, we will perform staggered iterations (max 5) to solve the thermal and mechanical problems sequentially until convergence. Within each staggered iteration, we will first solve the thermal problem using the displacement field from the previous iteration, and then solve the mechanical problem using the temperature field from the previous iteration. We will check for convergence by computing the relative change in displacement and temperature fields given as
If the relative change is less than a tolerance of 1e-8, we will stop the staggered iterations for that step i.e we have converged to a solution.
Hint
The main time-stepping and staggered solution loop is given below. You need to fill in the missing parts to complete the code.
n_steps =2# main load/time stepping loopfor step inrange(n_steps):print(f"Step {step +1}/{n_steps}")# define the external force vectors for thermal and elastic problem for this step ...# apply the dirichlet boundary conditions for thermal and elastic problem ...# the staggered solution loop, we perform 5 staggered iterationsfor i inrange(5):print(f"===Staggered iteration {i +1}/5:===")# solve for thermal field using the displacement field from previous iteration ...# solve for elastic field using the temperature field from previous iteration ...# compute the relative change in displacement and temperature fields err_u = ... err_T = ...# check if err_u and err_T are less than tolerance (1e-8), if yes then break the staggered loop ...
Store the final displacement and temperature fields for post-processing.
Step 1/2
===Staggered iteration 1/5:===
Solving for thermal field
Residual norm in thermal solve: 9.39190850e-09
Solving for elastic field
Residual norm in elastic solve: 9.79290835e-09
Relative change in u: 1.79070725e-01, theta: 9.10442094e+01
===Staggered iteration 2/5:===
Solving for thermal field
Residual norm in thermal solve: 9.26784913e-09
Solving for elastic field
Residual norm in elastic solve: 9.81493143e-09
Relative change in u: 0.00000000e+00, theta: 8.84185501e-06
===Staggered iteration 3/5:===
Solving for thermal field
Residual norm in thermal solve: 9.26784913e-09
Solving for elastic field
Residual norm in elastic solve: 9.81493143e-09
Relative change in u: 0.00000000e+00, theta: 0.00000000e+00
Converged in 3 staggered iterations.
Step 2/2
===Staggered iteration 1/5:===
Solving for thermal field
Residual norm in thermal solve: 9.37487337e-09
Solving for elastic field
Residual norm in elastic solve: 9.70249768e-09
Relative change in u: 1.79070742e-01, theta: 9.10442094e+01
===Staggered iteration 2/5:===
Solving for thermal field
Residual norm in thermal solve: 9.88887850e-09
Solving for elastic field
Residual norm in elastic solve: 9.73265785e-09
Relative change in u: 0.00000000e+00, theta: 8.84174138e-06
===Staggered iteration 3/5:===
Solving for thermal field
Residual norm in thermal solve: 9.88887850e-09
Solving for elastic field
Residual norm in elastic solve: 9.73265785e-09
Relative change in u: 0.00000000e+00, theta: 0.00000000e+00
Converged in 3 staggered iterations.
Note
You will notice that in each staggered iteration, individual problems converge (the rnorm for each problem is below tolerance) but the overall staggered step doesnot converge i.e. the errors in displacements and temperatures do not reduce significantly:
This is because for each problem we assumed that the other problem is fixed. However, in reality both problems are coupled and affect each other. Furthermore, we chose to solve the thermal problem first and then the mechanical problem. This also introduces some error in the combined solution.
Therefore, we need to perform multiple staggered iterations to ensure that both problems converge to a consistent solution.
Plot the deformed shape of the strip
We now plot the deformed shape of the strip along with displacement, von Mises stress and temperature values. We can observe that the strip has bended with top surface expanding more than the bottom part.
Code: Plotting the deformed shape and displacement
Deformed shape of the strip with displacement, von-misesstress and temperature fields. Deformation have been magnified by 10 times for better visibility.
We can also plot the temperature field on the both side of the strip. We define a operator to evaluate the temperature at quadrature points, find the indices of the elements that contains the points where we want to evaluate the temperature and then interpolate the temperature at the points.
Code: Function to find the index of the containing polygon for each point.
import numpy as np@jax.jitdef find_containing_polygons( points: jnp.ndarray, polygons: jnp.ndarray,) -> jnp.ndarray:""" Finds the index of the containing polygon for each point. This function uses a vectorized Ray Casting algorithm and is JIT-compiled for maximum performance. It assumes polygons are non-overlapping. Args: points (jnp.ndarray): An array of points to test, shape (num_points, 2). polygons (jnp.ndarray): A 3D array of polygons, where each polygon is a list of vertices. Shape (num_polygons, num_vertices, 2). Returns: jnp.ndarray: An array of shape (num_points,) where each element is the index of the polygon containing the corresponding point. Returns -1 if a point is not in any polygon. """# --- Core function for a single point and a single polygon ---def is_inside(point, vertices): px, py = point# Get all edges of the polygon by pairing vertices with the next one p1s = vertices p2s = jnp.roll(vertices, -1, axis=0) # Get p_{i+1} for each p_i# Conditions for a valid intersection of the horizontal ray from the point# 1. The point's y-coord must be between the edge's y-endpoints y_cond = (p1s[:, 1] <= py) & (p2s[:, 1] > py) | (p2s[:, 1] <= py) & ( p1s[:, 1] > py )# 2. The point's x-coord must be to the left of the edge's x-intersection# Calculate the x-intersection of the ray with the edge x_intersect = (p2s[:, 0] - p1s[:, 0]) * (py - p1s[:, 1]) / ( p2s[:, 1] - p1s[:, 1] ) + p1s[:, 0] x_cond = px < x_intersect# An intersection occurs if both conditions are met. intersections = jnp.sum(y_cond & x_cond)# The point is inside if the number of intersections is odd.return intersections %2==1# --- Vectorize and apply the function ---# Create a boolean matrix: matrix[i, j] is True if point i is in polygon j# Vmap over points (axis 0) and polygons (axis 0)# in_axes=(0, None) -> maps over points, polygon is fixed# in_axes=(None, 0) -> maps over polygons, point is fixed# We vmap the second case over all points is_inside_matrix = jax.vmap(lambda p: jax.vmap(lambda poly: is_inside(p, poly))(polygons) )(points)# Find the index of the first 'True' value for each point (row).# This gives the index of the containing polygon.# We add a 'False' column to handle points outside all polygons.# jnp.argmax will then return the index of this last column. padded_matrix = jnp.pad( is_inside_matrix, ((0, 0), (0, 1)), "constant", constant_values=False ) indices = jnp.argmax(padded_matrix, axis=1)# If the index is the last one, it means the point was not in any polygon.# We map this index to -1 for clarity.return jnp.where(indices == is_inside_matrix.shape[1], -1, indices)