Compliance-optimal cantilever taper: recovering the analytic exponent

Minimise the compliance of a uniformly loaded cantilever plate at fixed mass, with the thickness restricted to the one-parameter family t(x) = A ((L - x) / L + eps) ** p, and check that the optimiser recovers the analytic exponent p = 1.

In the beam limit the bending stiffness goes as t**3 and the compliance is C = int M(x)**2 / t(x)**3 dx. Minimising that at fixed int t dx gives -3 M**2 / t**4 + lam = 0, so t**4 is proportional to M**2 and t to sqrt(M). This is also what the fully stressed condition sigma = 6M/(w t**2) = const gives. Under uniform load M(x) = q (L - x)**2 / 2, so the optimum is the linear wedge t proportional to (L - x), i.e. p = 1. (Verified independently against a direct 1-D SLSQP solve of the same functional: relative L2 error 2.4e-6.)

Why the design space is parameterised rather than free

An earlier version of this example gave every CG1 node its own thickness and compared the result against the wedge. That comparison is not meaningful, and the reason is worth stating because it is easy to mistake for an optimiser bug.

Bending stiffness goes as int t**3 while mass goes as int t. At fixed mass, making the thickness oscillate therefore raises the stiffness – so a free nodal design space has oscillatory, mesh-dependent optima that genuinely beat any smooth profile in the discrete objective. Measured on this fixture at equal mass: uniform C = 0.056408, the analytic wedge C = 0.017066, and the free-nodal optimum C = 0.003685 – the checkerboard is five times better than the wedge, and it is right to be. The optimiser was correct; the target was not.

This is the classical ill-posedness of unregularised thickness design, and the usual remedies are a filter, a perimeter penalty, or a restricted design space. The last is used here because it also makes the example sharper: instead of comparing profiles, the optimiser is asked to find the exponent, and the analytic result says what it must find. It also exercises exactly what Hermit exists for – CSDL total derivatives through the shell solve driving a real optimiser.

Diagnostics behind that conclusion, for the record: the mass gradient agrees with central finite differences to 1.6e-8, every entry is strictly positive and the entries sum to rho * area; no file/FE ordering permutation explains the oscillation; and starting the free-nodal solve at the wedge moves away from it to a lower-compliance oscillatory design rather than staying put.

conda activate hermit
python examples/verification/ex_optimal_thickness_taper.py
"""Compliance-optimal cantilever taper: recovering the analytic exponent

Minimise the compliance of a uniformly loaded cantilever plate at fixed mass, with the
thickness restricted to the one-parameter family ``t(x) = A ((L - x) / L + eps) ** p``,
and check that the optimiser recovers the analytic exponent **p = 1**.

In the beam limit the bending stiffness goes as ``t**3`` and the compliance is
``C = int M(x)**2 / t(x)**3 dx``. Minimising that at fixed ``int t dx`` gives
``-3 M**2 / t**4 + lam = 0``, so ``t**4`` is proportional to ``M**2`` and ``t`` to
``sqrt(M)``. This is also what the fully stressed condition ``sigma = 6M/(w t**2)``
= const gives. Under uniform load ``M(x) = q (L - x)**2 / 2``, so the optimum is the
**linear wedge** ``t proportional to (L - x)``, i.e. ``p = 1``. (Verified independently
against a direct 1-D SLSQP solve of the same functional: relative L2 error 2.4e-6.)

Why the design space is parameterised rather than free
------------------------------------------------------

An earlier version of this example gave every CG1 node its own thickness and compared
the result against the wedge. That comparison is not meaningful, and the reason is
worth stating because it is easy to mistake for an optimiser bug.

Bending stiffness goes as ``int t**3`` while mass goes as ``int t``. At fixed mass,
making the thickness *oscillate* therefore raises the stiffness -- so a free nodal
design space has oscillatory, mesh-dependent optima that genuinely beat any smooth
profile in the discrete objective. Measured on this fixture at equal mass: uniform
``C = 0.056408``, the analytic wedge ``C = 0.017066``, and the free-nodal optimum
``C = 0.003685`` -- the checkerboard is five times better than the wedge, and it is
*right* to be. The optimiser was correct; the target was not.

This is the classical ill-posedness of unregularised thickness design, and the usual
remedies are a filter, a perimeter penalty, or a restricted design space. The last is
used here because it also makes the example sharper: instead of comparing profiles, the
optimiser is asked to *find* the exponent, and the analytic result says what it must
find. It also exercises exactly what Hermit exists for -- CSDL total derivatives
through the shell solve driving a real optimiser.

Diagnostics behind that conclusion, for the record: the mass gradient agrees with
central finite differences to 1.6e-8, every entry is strictly positive and the entries
sum to ``rho * area``; no file/FE ordering permutation explains the oscillation; and
starting the free-nodal solve *at* the wedge moves away from it to a lower-compliance
oscillatory design rather than staying put.

    conda activate hermit
    python examples/verification/ex_optimal_thickness_taper.py
"""

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                        # noqa: E402
from _harness import Case, main                         # noqa: E402

LENGTH, WIDTH = 4.0, 1.0
E, NU, H0, RHO, PRESSURE = 4.32e8, 0.0, 0.05, 1.0, 1.0
EPS = 1.0e-3          # keeps the tip thickness positive so log() is defined
ANALYTIC_P = 1.0


def solve_at(n):
    """Optimise ``(A, p)`` on a ``4n x n`` plate; return the recovered exponent ``p``."""
    mesh = rect_plate(LENGTH, WIDTH, nx=4 * n, ny=n, cell="quad")

    rec = csdl.Recorder(inline=True)
    rec.start()

    domain = hm.ShellDomain(mesh, element="CG2CG1")
    xi = (LENGTH - np.asarray(domain.node_coords)[:, 0]) / LENGTH + EPS

    # t(x) = A * xi**p, with p free. A variable exponent needs exp(p log xi); xi is a
    # constant array, so this stays a clean CSDL expression in the two design variables.
    scale = csdl.Variable(value=np.array([H0]), name="scale")
    exponent = csdl.Variable(value=np.array([0.3]), name="exponent")   # deliberately
    # started away from the answer, so recovering p = 1 is a real result and not the
    # initial guess surviving.
    log_xi = csdl.Variable(value=np.log(xi))
    thickness = csdl.expand(scale, xi.shape) * csdl.exp(csdl.expand(exponent, xi.shape) * log_xi)

    material = hm.isotropic(domain, E=E, nu=NU,
                            thickness=hm.from_nodal(domain, thickness), density=RHO)
    state = hm.solve(domain, material, hm.pressure(domain, PRESSURE),
                     hm.clamp(domain, where=lambda x: np.isclose(x[0], 0.0)))
    compliance, mass = hm.compliance(state), hm.mass(state)

    mass_target = RHO * H0 * LENGTH * WIDTH
    scale.set_as_design_variable(lower=1e-3, upper=1.0)
    exponent.set_as_design_variable(lower=0.0, upper=3.0)
    mass.set_as_constraint(lower=mass_target, upper=mass_target)
    compliance.set_as_objective()

    from modopt import CSDLAlphaProblem, PySLSQP

    simulator = csdl.experimental.PySimulator(rec)
    problem = CSDLAlphaProblem(problem_name="hermit_taper_exponent", simulator=simulator)
    PySLSQP(problem, solver_options={"maxiter": 200, "acc": 1e-12}).solve()

    p = float(np.ravel(exponent.value)[0])
    c = float(np.ravel(compliance.value)[0])
    m = float(np.ravel(mass.value)[0])
    rec.stop()

    print(f"    recovered p={p:.6f} (analytic {ANALYTIC_P})  "
          f"compliance={c:.6e}  mass={m:.6f} (target {mass_target:.6f})")
    return p


CASE = Case(
    name="Compliance-optimal taper: recovered exponent",
    quantity="optimiser-recovered exponent p in t(x) = A ((L-x)/L)**p",
    reference=ANALYTIC_P,
    # The optimiser has to find p through the shell solve's adjoint, and the plate is
    # not exactly the beam the analytic result assumes (finite width, transverse
    # shear, a non-zero tip thickness from EPS), so a few percent is the honest gate.
    tolerance=0.05,
    citation="Computed in this file: minimising int M^2/t^3 at fixed int t gives "
             "t proportional to sqrt(M), hence (L-x) under uniform load; cross-checked "
             "against a direct 1-D SLSQP solve to 2.4e-6",
    levels=(2, 3, 4),
    quick_level=3,
    solve=solve_at,
    monotone=False,
    # The optimiser stack is an optional Hermit extra, so a plain install -- and CI's
    # environment -- legitimately lacks it. Every other case here needs only the
    # solver itself; this is the one that drives it from an optimiser.
    requires=("modopt", "pyslsqp"),
    notes="p starts at 0.3, deliberately away from the answer. A free per-node "
          "thickness design space is ill-posed here and does NOT converge to this "
          "wedge -- see the module docstring; that is a property of the objective, "
          "not an optimiser fault.",
)

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