Cantilever plate under a tip moment

The 10 by 2 by 0.2 cantilever plate is clamped at x = 0 and carries a total unit moment about global +y at its tip. The resultant is spread over the tip-edge nodes with consistent (trapezoidal) weights rather than equally – equal splitting is statically equivalent but over-loads the two corner nodes, which leaves the tip rotation 0.3 % off. See _consistent_edge_weights. The computed references are w_tip = 8.68055556e-5 from M L**2 / (2 E I) and theta_tip = 1.73611111e-5 from M L / (E I), with I = W h**3 / 12.

This is exact, not merely convergent: constant curvature makes the deflection quadratic (and CG2 contains it) and the rotation linear (and CG1 contains it), so the finite element solution is the analytical one up to round-off. Both quantities land within ~1e-9 at every refinement, and the gate is tight enough to notice if that ever stops being true.

"""Cantilever plate under a tip moment

The 10 by 2 by 0.2 cantilever plate is clamped at ``x = 0`` and carries a
total unit moment about global ``+y`` at its tip.  The resultant is spread over the
tip-edge nodes with *consistent* (trapezoidal) weights rather than equally --
equal splitting is statically equivalent but over-loads the two corner nodes,
which leaves the tip rotation 0.3 % off. See ``_consistent_edge_weights``.
The computed references are ``w_tip = 8.68055556e-5`` from
``M L**2 / (2 E I)`` and ``theta_tip = 1.73611111e-5`` from ``M L / (E I)``,
with ``I = W h**3 / 12``.

This is exact, not merely convergent: constant curvature makes the deflection
quadratic (and CG2 contains it) and the rotation linear (and CG1 contains it),
so the finite element solution is the analytical one up to round-off. Both
quantities land within ~1e-9 at every refinement, and the gate is tight enough
to notice if that ever stops being true.
"""

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, node_nearest  # noqa: E402

L, W, H, E, NU, M = 10.0, 2.0, 0.2, 4.32e8, 0.0, 1.0
I = W * H**3 / 12.0
W_REFERENCE = M * L**2 / (2.0 * E * I)
THETA_REFERENCE = M * L / (E * I)


def _consistent_edge_weights(ys):
    """Normalised trapezoidal weights for a uniform line load on CG1 nodes.

    Splitting the resultant *equally* over the edge nodes is statically equivalent
    but **not consistent**: for a uniform distributed moment on a CG1 field the
    consistent nodal values are trapezoidal (interior nodes get a full element
    length, the two end nodes get half), so equal splitting over-loads the corners
    by a factor of two. That is a self-equilibrated perturbation, so the deflection
    barely notices it -- but the tip *rotation* is measured right where the
    perturbation lives, and it lands 0.3 % off.

    With these weights the rotation is exact to ~2e-10 instead. Measured on this
    fixture: equal splitting gives theta errors of 3.1e-3 / 3.4e-3 / 2.6e-3 at
    n = 8 / 16 / 24; trapezoidal gives 2.7e-10 / 2.0e-9 / 1.6e-9.
    """
    ys = np.asarray(ys, dtype=float)
    h = np.diff(ys)
    w = np.zeros(ys.size)
    w[:-1] += h / 2.0
    w[1:] += h / 2.0
    return w / w.sum()


def solve_at(n):
    """Maximum relative error of tip deflection and rotation on an ``n x n/2`` mesh."""
    mesh = rect_plate(L, W, nx=n, ny=n // 2, cell="quad")
    rec = csdl.Recorder(inline=True)
    rec.start()
    domain = hm.ShellDomain(mesh, element="CG2CG1")
    material = hm.isotropic(domain, E=E, nu=NU, thickness=H, density=1.0)
    xyz = np.asarray(domain.node_coords)
    edge = np.flatnonzero(np.isclose(xyz[:, 0], L))
    edge = edge[np.argsort(xyz[edge, 1])]
    load = None
    for k, weight in zip(edge, _consistent_edge_weights(xyz[edge, 1])):
        term = hm.point_load(domain, at=xyz[k], moment=[0.0, M * weight, 0.0])
        load = term if load is None else load + term
    state = hm.solve(domain, material, load,
                     hm.clamp(domain, where=lambda x: np.isclose(x[0], 0.0)))
    u = hm.nodal_displacement(state).value.reshape(-1, 3)
    theta = hm.nodal_rotation(state).value.reshape(-1, 3)
    rec.stop()
    tip, distance = node_nearest(domain, [L, W / 2.0, 0.0])
    assert distance < 1e-12
    w = abs(float(u[tip, 2]))
    rotation = abs(float(theta[tip, 1]))
    w_error = abs(w / W_REFERENCE - 1.0)
    theta_error = abs(rotation / THETA_REFERENCE - 1.0)
    print(f"    w_tip={w:.8e} (ref {W_REFERENCE:.8e}), "
          f"theta_y={rotation:.8e} (ref {THETA_REFERENCE:.8e})")
    return max(w_error, theta_error)


CASE = Case(
    name="Cantilever plate: total tip moment",
    quantity="maximum relative error of tip deflection and y rotation",
    reference=0.0,
    # The exactness claim is about the discretisation, so the only floor left is
    # floating point in the direct solve; measured residuals are ~1e-9.
    tolerance=1e-7,
    citation="Computed Euler-Bernoulli constant-curvature formulas in this file",
    levels=(8, 12, 16),
    quick_level=16,
    solve=solve_at,
    monotone=False,
    notes="reference zero denotes exact reproduction of both computed quantities",
)

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