Nodal-thickness compliance gradient versus central finite differences.

The cantilever thickness is a CG1 nodal CSDL design variable, supplied through hm.from_nodal. The CSDL total derivative of compliance is checked at three interior design dofs against central differences. A fixed step study is printed: large steps expose Taylor truncation, while the smallest steps eventually expose subtractive cancellation. The reported Case quantity is the best relative agreement over that study; its tolerance is intentionally an FD tolerance, not a solver claim.

"""Nodal-thickness compliance gradient versus central finite differences.

The cantilever thickness is a CG1 nodal CSDL design variable, supplied through
``hm.from_nodal``.  The CSDL total derivative of compliance is checked at three
interior design dofs against central differences.  A fixed step study is printed:
large steps expose Taylor truncation, while the smallest steps eventually expose
subtractive cancellation.  The reported Case quantity is the best relative agreement
over that study; its tolerance is intentionally an FD tolerance, not a solver claim.
"""

import pathlib
import sys

import csdl_alpha as csdl
import numpy as np

import hermit as hm

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

LENGTH, WIDTH = 4.0, 1.0
E, NU, THICKNESS, PRESSURE = 4.32e8, 0.0, 0.05, 1.0
RELATIVE_STEPS = (1e-2, 1e-3, 1e-4, 1e-5, 1e-6)


def _compliance(mesh, thickness, want_gradient=False):
    rec = csdl.Recorder(inline=True)
    rec.start()
    domain = hm.ShellDomain(mesh, element="CG2CG1")
    t = csdl.Variable(value=thickness.copy(), name="nodal_thickness")
    material = hm.isotropic(domain, E=E, nu=NU, thickness=hm.from_nodal(domain, t), density=1.0)
    state = hm.solve(domain, material, hm.pressure(domain, PRESSURE),
                     hm.clamp(domain, where=lambda x: np.isclose(x[0], 0.0)))
    output = hm.compliance(state)
    value = float(np.asarray(output.value).ravel()[0])
    grad = None
    if want_gradient:
        grad = np.asarray(csdl.experimental.PySimulator(rec).compute_totals([output], [t])[output, t]).ravel()
    rec.stop()
    return value, grad


def solve_at(n):
    """Return the best CSDL/central-difference agreement for three nodal dofs."""
    mesh = rect_plate(LENGTH, WIDTH, nx=4 * n, ny=n, cell="quad")
    nn = mesh.geometry.x.shape[0]
    t0 = THICKNESS * np.ones(nn)
    _, gradient = _compliance(mesh, t0, want_gradient=True)
    xyz = mesh.geometry.x
    # Interior points avoid both the clamped boundary and a free-edge corner.
    targets = ([LENGTH / 4, WIDTH / 2, 0.0], [LENGTH / 2, WIDTH / 2, 0.0],
               [3 * LENGTH / 4, WIDTH / 2, 0.0])
    dofs = [int(np.argmin(np.linalg.norm(xyz - p, axis=1))) for p in targets]
    best = np.inf
    print("    relative step       max relative error at dofs", dofs)
    for rel_step in RELATIVE_STEPS:
        step = rel_step * THICKNESS
        errors = []
        for k in dofs:
            tp, tm = t0.copy(), t0.copy()
            tp[k] += step
            tm[k] -= step
            cp, _ = _compliance(mesh, tp)
            cm, _ = _compliance(mesh, tm)
            fd = (cp - cm) / (2.0 * step)
            errors.append(abs(gradient[k] - fd) / max(abs(fd), 1e-14))
        worst = max(errors)
        best = min(best, worst)
        print(f"    {rel_step:13.1e}       {worst:.3e}")
    print(f"    best relative agreement = {best:.3e}")
    return 1.0 - best


CASE = Case(
    name="Nodal thickness gradient: central finite differences",
    quantity="1 - best relative CSDL/FD gradient error",
    reference=1.0,
    tolerance=2e-3,
    citation="Second-order central-difference formula, evaluated by this script",
    levels=(2, 3),
    quick_level=2,
    solve=solve_at,
    monotone=False,
    notes="step study separates FD truncation and cancellation; tolerance is FD-limited",
)


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