Shape derivative of failure index for direction-oriented laminates.

This benchmark verifies that the shape derivative of the Tsai-Wu failure index is complete when the laminate orientation is specified via a global fiber_direction.

TRAP AVOIDED: The finite difference check perturbs the MESH COORDINATES directly and rebuilding the mesh, rather than perturbing node_disp. A node_disp perturbation fails to expose the missing geometry dependence in the numpy-computed fiber_direction because domain.local_frames() is built from domain.mesh (which node_disp never modifies). This trap was the reason the bug survived.

"""Shape derivative of failure index for direction-oriented laminates.

This benchmark verifies that the shape derivative of the Tsai-Wu failure index
is complete when the laminate orientation is specified via a global `fiber_direction`.

**TRAP AVOIDED**: The finite difference check perturbs the MESH COORDINATES directly
and rebuilding the mesh, rather than perturbing `node_disp`. A `node_disp` perturbation
fails to expose the missing geometry dependence in the numpy-computed `fiber_direction`
because `domain.local_frames()` is built from `domain.mesh` (which `node_disp` never
modifies). This trap was the reason the bug survived.
"""

import pathlib
import sys
import numpy as np
import csdl_alpha as csdl
import hermit as hm

sys.path.insert(0, str(pathlib.Path(__file__).parent))
from _geometry import rect_plate
from _harness import Case, main

LENGTH = 10.0
WIDTH = 2.0
PRESSURE = 1.0e3

def _rebuild_mesh(nx, ny, shape_override=None):
    mesh = rect_plate(LENGTH, WIDTH, nx=nx, ny=ny, cell="quad")
    if shape_override is not None:
        mesh.geometry.x[:] = shape_override
    else:
        # Cambered plate: z = 0.5 * (x / LENGTH)**2
        mesh.geometry.x[:, 2] = 0.5 * (mesh.geometry.x[:, 0] / LENGTH)**2
    return mesh

def _evaluate(mesh, compute_gradient=False, orientation_kind="direction"):
    rec = csdl.Recorder(inline=True)
    rec.start()
    
    domain = hm.ShellDomain(mesh, element="CG2CG1")
    
    import caddee_materials
    ply = caddee_materials.TransverseMaterial(name="carbon", density=1.6e3, EA=135e9, ET=10e9, vA=0.3, vT=0.3, GA=5e9)
    ply.set_strength(F1t=1.5e9, F1c=1.2e9, F2t=50e6, F2c=250e6, F12=70e6, F23=50e6)
    layup = hm.Layup(ply, np.radians([0.0, 90.0, 0.0]), np.array([0.02 / 3] * 3))
    
    if orientation_kind == "direction":
        orientation = hm.fiber_direction(domain, [np.cos(0.4), np.sin(0.4), 0.0])
    else:
        # For 'angle' branch verification
        orientation = hm.fiber_angle(domain, 0.4)
        
    material = hm.laminate(domain, layup=layup, density=1.6e3, orientation=orientation)
    
    # Boundary and loads
    clamped = hm.clamp(domain, where=lambda x: np.isclose(x[0], 0.0))
    load = hm.pressure(domain, PRESSURE)
    
    # Note: we need mesh_nodes as a differentiable input to get the adjoint geometry derivative
    nd = csdl.Variable(value=mesh.geometry.x.copy(), name="mesh_nodes")
    state = hm.solve(domain, material, load, clamped, geometry=hm.geometry(domain, nodes=nd))
    
    fi = hm.failure_index(state, rho=100.0)
    
    val = float(np.asarray(fi.value).ravel()[0])
    grad = None
    if compute_gradient:
        sim = csdl.experimental.PySimulator(rec)
        grad = np.asarray(sim.compute_totals([fi], [nd])[fi, nd])
    rec.stop()
    return val, grad

def solve_at(level):
    nx = 4 * level
    ny = level
    
    # Base configuration
    mesh = _rebuild_mesh(nx, ny)
    base_coords = mesh.geometry.x.copy()
    
    # Adjoint gradient
    fi_base, grad_base = _evaluate(mesh, compute_gradient=True, orientation_kind="direction")
    grad_base = grad_base.reshape(-1, 3)
    
    # Central finite differences by rebuilding the mesh
    np.random.seed(42)
    perturbation = np.random.randn(*base_coords.shape)
    # Hold the clamped edge fixed
    clamped_mask = np.isclose(base_coords[:, 0], 0.0)
    perturbation[clamped_mask] = 0.0
    # Normalize perturbation to unit length
    perturbation /= np.linalg.norm(perturbation)
    
    directional_derivative_adjoint = np.sum(grad_base * perturbation)
    
    step = 1e-6
    mesh_plus = _rebuild_mesh(nx, ny, shape_override=base_coords + step * perturbation)
    fi_plus, _ = _evaluate(mesh_plus, compute_gradient=False, orientation_kind="direction")
    
    mesh_minus = _rebuild_mesh(nx, ny, shape_override=base_coords - step * perturbation)
    fi_minus, _ = _evaluate(mesh_minus, compute_gradient=False, orientation_kind="direction")
    
    fd = (fi_plus - fi_minus) / (2 * step)
    
    rel_error = abs(directional_derivative_adjoint - fd) / max(abs(fd), 1e-14)
    print(f"Level {level}: FI = {fi_base:.6e}, adjoint dir_deriv = {directional_derivative_adjoint:.6e}, FD = {fd:.6e}, rel_error = {rel_error:.3e}")
    
    return 1.0 - rel_error

CASE = Case(
    name="Shape derivative of failure index with fiber direction",
    quantity="1 - relative error between adjoint and FD",
    reference=1.0,
    tolerance=5e-3,
    citation="Tsai-Wu failure index with `fiber_direction` geometry sensitivity",
    levels=(2, 3),
    quick_level=2,
    solve=solve_at,
    monotone=False,
    notes="FD perturbs mesh coordinates directly and rebuilds the mesh to catch severed geometry dependency in orientations",
)

if __name__ == "__main__":
    main(CASE)