Source code for fol.loss_functions.mechanical_elastoplasticity

"""
 Authors: Rishabh Arora, https://github.com/rishabharora236-cell
 Date: Oct, 2025
 License: FOL/LICENSE
"""
from  .mechanical import MechanicalLoss
import jax
import jax.numpy as jnp
from jax.experimental import sparse
from jax import jit
from functools import partial
from fol.tools.fem_utilities import *
from fol.tools.decoration_functions import *
from fol.mesh_input_output.mesh import Mesh
from fol.constitutive_material_models.plasticity import J2Plasticity

[docs]class ElastoplasticityLoss(MechanicalLoss): """ Elastoplastic mechanical loss with J2 plasticity and Gauss-point state updates. This class extends :class:`fol.loss_functions.mechanical.MechanicalLoss` to model small-strain elastoplasticity using a J2 plasticity constitutive law. The total loss value follows the weighted-residual formulation used by the base mechanical loss, while the element response additionally depends on a set of internal state variables stored at Gauss points. For each element, the method :meth:`ComputeElement` computes the element residual vector and a consistent element tangent matrix by automatic differentiation of the element residual with respect to the element DOFs. The Gauss-point state variables are updated during element evaluation and returned so they can be committed by the nonlinear solver upon convergence. Args: name (str): Name identifier for the loss instance. loss_settings (dict): Configuration dictionary. Must include ``material_dict`` with keys ``"young_modulus"``, ``"poisson_ratio"``, ``"yield_limit"``, ``"iso_hardening_parameter_1"``, and ``"iso_hardening_param_2"``. Optional entries may include ``"body_foce"`` specifying a constant body force vector. Element discretization settings (dimension, element type, ordered DOFs) are typically provided by specialized subclasses. fe_mesh (Mesh): Finite element mesh over which the loss is defined. """ def Initialize(self) -> None: super().Initialize() mat_dict = self.loss_settings["material_dict"] self.material_model = J2Plasticity( E=mat_dict["young_modulus"], nu=mat_dict["poisson_ratio"], yield_stress=mat_dict["yield_limit"], hardening_modulus=mat_dict["iso_hardening_parameter_1"], hardening_exponent=mat_dict["iso_hardening_param_2"] # Use .get() for safety )
[docs] def ComputeElement(self,xyze,de,uvwe,element_state_gps): """ Compute element scalar contribution, state update, residual, and tangent. This method evaluates the element residual for an elastoplastic material with Gauss-point internal variables. Strains are computed from the element DOFs, stresses are obtained from the J2 plasticity model, and the internal nodal force vector is assembled by Gaussian quadrature. External body-force contributions are assembled in the same way. The element residual is defined as internal minus external force. The element tangent matrix is computed using automatic differentiation of the element residual with respect to the element DOFs. Updated Gauss- point state variables are returned alongside the residual and tangent. Args: xyze: Element nodal coordinates. de: Element control/parameter values at nodes. This argument is included for interface consistency. uvwe: Element DOF vector arranged consistently with the element type and ordered degrees of freedom. Expected shape is ``(ndofs_elem, 1)``. element_state_gps: Gauss-point state variables for this element. Expected shape is ``(n_gauss_points, n_state_vars)``. Returns: Tuple[float, jax.numpy.ndarray, jax.numpy.ndarray, jax.numpy.ndarray]: - Scalar element loss contribution computed as ``uvwe.T @ r_e`` where ``r_e`` is the element residual. - Updated Gauss-point state variables with the same shape as the input state array. - Element residual vector ``r_e`` with shape ``(ndofs_elem, 1)``. - Element tangent matrix with shape ``(ndofs_elem, ndofs_elem)``. """ def compute_at_gauss_point(gp_point,gp_weight,gp_state_vector,uvwe): N_vec = self.fe_element.ShapeFunctionsValues(gp_point) N_mat = self.CalculateNMatrix(N_vec) DN_DX = self.fe_element.ShapeFunctionsGlobalGradients(xyze,gp_point) B_mat = self.CalculateBMatrix(DN_DX) J = self.fe_element.Jacobian(xyze,gp_point) detJ = jnp.linalg.det(J) strain_gp = B_mat @ uvwe # Determine dimensionality based on strain vector length n_strain_components = strain_gp.shape[0] # Construct strain matrix based on dimensionality if n_strain_components == 3: # 2D case (ε_xx, ε_yy, γ_xy) strain_matrix = jnp.array([ [strain_gp[0], strain_gp[2]], # [ε_xx, ε_xy] [strain_gp[2], strain_gp[1]] # [ε_xy, ε_yy] ]) elif n_strain_components == 6: # 3D case (ε_xx, ε_yy, ε_zz, γ_xy, γ_yz, γ_xz) strain_matrix = jnp.array([ [strain_gp[0], strain_gp[3], strain_gp[5]], # [ε_xx, ε_xy, ε_xz] [strain_gp[3], strain_gp[1], strain_gp[4]], # [ε_xy, ε_yy, ε_yz] [strain_gp[5], strain_gp[4], strain_gp[2]] # [ε_xz, ε_yz, ε_zz] ]) strain_matrix_array= strain_matrix.squeeze() stress_gp_v,gp_state_up = self.material_model.evaluate(strain_matrix_array, gp_state_vector) stress_gp_v = stress_gp_v.reshape(n_strain_components,1) gp_f_int = (gp_weight * detJ * (B_mat.T @ stress_gp_v)) #gp_stiffness = gp_weight * detJ * (B_mat.T @ (tgMM @ B_mat)) use when computing tangent at gauss point level gp_f_body = (gp_weight * detJ * (N_mat.T @ self.body_force)) return gp_f_body,gp_f_int,gp_state_up gp_points,gp_weights = self.fe_element.GetIntegrationData() f_gps,f_gps_int,gps_state = jax.vmap(compute_at_gauss_point,in_axes=(0,0,0,None))(gp_points,gp_weights,element_state_gps,uvwe) #Se = jnp.sum(k_gps, axis=0, keepdims=False) use when computing tangent at gauss point level Fe = jnp.sum(f_gps, axis=0, keepdims=False) Fe_int= jnp.sum(f_gps_int, axis=0) residual = (Fe_int-Fe) #Compute element tangent def compute_residual_flat(u_flat): u = u_flat.reshape(-1, 1) # Reshape back to column vector f_gps, f_gps_int, _ = jax.vmap( compute_at_gauss_point, in_axes=(0, 0, 0, None) )(gp_points, gp_weights, element_state_gps, u) Fe = jnp.sum(f_gps, axis=0, keepdims=False) Fe_int = jnp.sum(f_gps_int, axis=0) residual = (Fe_int - Fe) return residual.flatten() # Return as 1D array # Compute residual uvwe_flat = uvwe.flatten() residual_flat = compute_residual_flat(uvwe_flat) residual = residual_flat.reshape(-1, 1) # Compute tangent stiffness using automatic differentiation Se = jax.jacfwd(compute_residual_flat)(uvwe_flat) element_residuals = jax.lax.stop_gradient(residual) return ((uvwe.T @ element_residuals)[0,0]),gps_state, residual, Se
[docs] def ComputeElementResidualAndJacobian( self, elem_xyz: jnp.array, elem_controls: jnp.array, elem_dofs: jnp.array, elem_BC: jnp.array, elem_mask_BC: jnp.array, transpose_jac: bool, elem_state_gps: jnp.array ): """ Compute element residual and Jacobian with Dirichlet boundary conditions. This method computes the element residual and tangent matrix using :meth:`ComputeElement`, optionally transposes the Jacobian, applies element-level Dirichlet boundary conditions, and returns the updated Gauss-point state variables. Args: elem_xyz (jax.numpy.ndarray): Element nodal coordinates. elem_controls (jax.numpy.ndarray): Element control/parameter values at nodes. elem_dofs (jax.numpy.ndarray): Element DOF vector of shape ``(ndofs_elem, 1)``. elem_BC (jax.numpy.ndarray): Element Dirichlet boundary condition vector. elem_mask_BC (jax.numpy.ndarray): Element boundary condition mask. transpose_jac (bool): If ``True``, the element Jacobian is transposed before applying boundary conditions. elem_state_gps (jax.numpy.ndarray): Gauss-point state variables for this element. Returns: Tuple[jax.numpy.ndarray, jax.numpy.ndarray, jax.numpy.ndarray]: Element residual vector and Jacobian matrix after applying Dirichlet boundary conditions, and the updated Gauss-point state variables. """ _, elem_state_up_gps, re, ke = self.ComputeElement( elem_xyz, elem_controls, elem_dofs, elem_state_gps ) index = jnp.asarray(transpose_jac, dtype=jnp.int32) # Define the two branches for switch branches = [ lambda _: ke, # Case 0: No transpose lambda _: jnp.transpose(ke) # Case 1: Transpose ke ] # Apply the switch operation ke = jax.lax.switch(index, branches, None) # Apply Dirichlet boundary conditions r_e, k_e = self.ApplyDirichletBCOnElementResidualAndJacobian(re, ke, elem_BC, elem_mask_BC) return r_e, k_e, elem_state_up_gps
def ComputeElementResidualAndJacobianVmapCompatible(self,element_id:jnp.integer, elements_nodes:jnp.array, xyz:jnp.array, full_control_vector:jnp.array, full_dof_vector:jnp.array, full_dirichlet_BC_vec:jnp.array, full_mask_dirichlet_BC_vec:jnp.array, transpose_jac:bool, full_state_gps: jnp.array): return self.ComputeElementResidualAndJacobian(xyz[elements_nodes[element_id],:], full_control_vector[elements_nodes[element_id]], full_dof_vector[((self.number_dofs_per_node*elements_nodes[element_id])[:, jnp.newaxis] + jnp.arange(self.number_dofs_per_node))].reshape(-1,1), full_dirichlet_BC_vec[((self.number_dofs_per_node*elements_nodes[element_id])[:, jnp.newaxis] + jnp.arange(self.number_dofs_per_node))].reshape(-1,1), full_mask_dirichlet_BC_vec[((self.number_dofs_per_node*elements_nodes[element_id])[:, jnp.newaxis] + jnp.arange(self.number_dofs_per_node))].reshape(-1,1), transpose_jac, full_state_gps[element_id, :, :])
[docs] @print_with_timestamp_and_execution_time @partial(jit, static_argnums=(0,)) def ComputeJacobianMatrixAndResidualVector( self, total_control_vars: jnp.array, total_primal_vars: jnp.array, old_state_gps: jnp.array, transpose_jacobian: bool = False ): """ Assemble the global residual vector and sparse Jacobian with state update. This method evaluates per-element residuals and tangents in batches, applies Dirichlet boundary conditions, and assembles a global residual vector and a sparse Jacobian matrix. Gauss-point state variables are updated element-wise and returned as a separate array. Args: total_control_vars (jax.numpy.ndarray): Global control/parameter vector (nodal values). total_primal_vars (jax.numpy.ndarray): Global DOF vector (nodal unknowns) used to build element DOFs. old_state_gps (jax.numpy.ndarray): Gauss-point state variables for all elements. Expected shape is ``(n_elements, n_gauss_points, n_state_vars)``. transpose_jacobian (bool, optional): If ``True``, element Jacobians are transposed before assembly. Default is ``False``. Returns: Tuple[jax.numpy.ndarray, jax.experimental.sparse.BCOO, jax.numpy.ndarray]: - Updated Gauss-point state variables with the same shape as ``old_state_gps``. - Sparse global Jacobian matrix. - Global residual vector. """ BC_vector = jnp.ones((self.total_number_of_dofs)) BC_vector = BC_vector.at[self.dirichlet_indices].set(0) mask_BC_vector = jnp.zeros((self.total_number_of_dofs)) mask_BC_vector = mask_BC_vector.at[self.dirichlet_indices].set(1) num_nodes_per_elem = len(self.fe_mesh.GetElementsNodes(self.element_type)[0]) element_matrix_size = self.number_dofs_per_node * num_nodes_per_elem elements_jacobian_flat = jnp.zeros( self.fe_mesh.GetNumberOfElements(self.element_type) * element_matrix_size * element_matrix_size ) template_element_indices = jnp.arange(0, self.adjusted_batch_size) template_elem_res_indices = jnp.arange(0, element_matrix_size, self.number_dofs_per_node) template_elem_jac_indices = jnp.arange(0, self.adjusted_batch_size * element_matrix_size * element_matrix_size) residuals_vector = jnp.zeros((self.total_number_of_dofs)) new_state_gps = jnp.zeros_like(old_state_gps) @jit def fill_arrays(batch_index, batch_arrays): glob_res_vec, elem_jac_vec, new_state_buf = batch_arrays batch_element_indices = (batch_index * self.adjusted_batch_size) + template_element_indices batch_elem_jac_indices = (batch_index * self.adjusted_batch_size * element_matrix_size**2) + template_elem_jac_indices element_nodes = self.fe_mesh.GetElementsNodes(self.element_type) node_coords = self.fe_mesh.GetNodesCoordinates() batch_elements_residuals, batch_elements_stiffness, batch_state_up_gps = jax.vmap( self.ComputeElementResidualAndJacobianVmapCompatible, (0, None, None, None, None, None, None, None, None) )( batch_element_indices, element_nodes, node_coords, total_control_vars, total_primal_vars, BC_vector, mask_BC_vector, transpose_jacobian, old_state_gps, ) elem_jac_vec = elem_jac_vec.at[batch_elem_jac_indices].set(batch_elements_stiffness.ravel()) @jit def fill_res_vec(dof_idx, glob_res_vec): glob_res_vec = glob_res_vec.at[ self.number_dofs_per_node * element_nodes[batch_element_indices] + dof_idx ].add(jnp.squeeze(batch_elements_residuals[:, template_elem_res_indices + dof_idx])) return glob_res_vec glob_res_vec = jax.lax.fori_loop(0, self.number_dofs_per_node, fill_res_vec, glob_res_vec) new_state_buf = new_state_buf.at[batch_element_indices, :, :].set(batch_state_up_gps) return glob_res_vec, elem_jac_vec, new_state_buf # Run loop residuals_vector, elements_jacobian_flat, new_state_gps = jax.lax.fori_loop( 0, self.num_element_batches, fill_arrays, (residuals_vector, elements_jacobian_flat, new_state_gps) ) # Assemble sparse Jacobian jacobian_indices = jax.vmap(self.ComputeElementJacobianIndices)( self.fe_mesh.GetElementsNodes(self.element_type) ).reshape(-1, 2) sparse_jacobian = sparse.BCOO( (elements_jacobian_flat, jacobian_indices), shape=(self.total_number_of_dofs, self.total_number_of_dofs), ) return new_state_gps, sparse_jacobian, residuals_vector
[docs]class ElastoplasticityLoss2DQuad(ElastoplasticityLoss): """ Elastoplasticity loss for 2D quadrilateral elements. This class configures :class:`ElastoplasticityLoss` for two-dimensional problems discretized with quadrilateral elements. The displacement field consists of two components (``Ux``, ``Uy``) per node. If the number of Gauss points is not specified in the loss settings, a default value of ``num_gp = 2`` is used. Args: name (str): Name identifier for the loss instance. loss_settings (dict): Dictionary containing ``material_dict`` and optional settings. fe_mesh (Mesh): Finite element mesh associated with the loss. """ def __init__(self, name: str, loss_settings: dict, fe_mesh: Mesh): if not "num_gp" in loss_settings.keys(): loss_settings["num_gp"] = 2 super().__init__(name,{**loss_settings,"compute_dims":2, "ordered_dofs": ["Ux","Uy"], "element_type":"quad"},fe_mesh)
[docs]class ElastoplasticityLoss3DTetra(ElastoplasticityLoss): """ Elastoplasticity loss for 3D tetrahedral elements. This class configures :class:`ElastoplasticityLoss` for three-dimensional problems discretized with tetrahedral elements. The displacement field consists of three components (``Ux``, ``Uy``, ``Uz``) per node. Args: name (str): Name identifier for the loss instance. loss_settings (dict): Dictionary containing ``material_dict`` and optional settings. fe_mesh (Mesh): Finite element mesh associated with the loss. """ def __init__(self, name: str, loss_settings: dict, fe_mesh: Mesh): super().__init__(name,{**loss_settings,"compute_dims":3, "ordered_dofs": ["Ux","Uy","Uz"], "element_type":"tetra"},fe_mesh)