Source code for femorph_solver.validation._problems.nafems_le1

"""NAFEMS LE1 — elliptic membrane under outward pressure.

A quarter plate bounded by two confocal ellipses, loaded by a
uniform outward-normal pressure on the outer elliptic edge.
Canonical plane-stress benchmark from the NAFEMS *Benchmark
Tests for Linear Elastic Analysis* (1990, problem LE1).

Geometry
--------

Plane stress (thickness 0.1 m).  Only the first quadrant
(``x ≥ 0, y ≥ 0``) is modelled; symmetry BCs restore the full
ellipse:

* Inner edge:  ellipse :math:`x^{2}/2^{2} + y^{2}/1^{2} = 1`.
* Outer edge:  ellipse :math:`x^{2}/3.25^{2} + y^{2}/2.75^{2} = 1`.
* Symmetry:    ``UY = 0`` on the x-axis (``y = 0``),
               ``UX = 0`` on the y-axis (``x = 0``).
* Load:        1 MPa outward-normal pressure applied on the
               outer elliptic edge.

Closed-form quantities
----------------------

NAFEMS publishes a single reference value for this benchmark:
the hoop stress :math:`\\sigma_{yy}` at **point D**
(``x = 2, y = 0`` — the inner edge of the ellipse on the
x-axis).  The stress concentration at the stub end of the
inner ellipse gives the canonical comparison value used for
solver cross-checking.

.. math::

    \\sigma_{yy}^{(D)} = 92.7 \\; \\text{MPa}.

No analytical closed-form solution exists — the value is
benchmark-defined from converged solutions reported by multiple
independent codes in the 1988 NAFEMS working-group validation.

References
----------
* NAFEMS, *Standard Benchmark Tests for Linear Elastic
  Analysis*, R0015 (1990), §2.1 LE1 "Elliptic membrane under
  outward pressure".
* NAFEMS, *Background to Benchmarks*, R0013 (1988) — methodology
  companion volume describing the converged reference values.

Cross-references (public verification manuals — fair-use
problem-ID citations only; no vendor content vendored):

* **Abaqus AVM 1.3.x** elliptic-membrane plane-stress family.

Implementation notes
--------------------

Mapped mesh: nodes placed on a regular ``(θ, s)`` grid where
:math:`\\theta \\in [0, \\pi/2]` parametrises the ellipse
angles and :math:`s \\in [0, 1]` interpolates linearly from
inner to outer boundary.  The outer-edge pressure is lumped
onto the outer-boundary nodes via trapezoid-rule arc-length
weighting so the total applied force matches the analytical
pressure × arc-length exactly.  σ_yy at D is extracted via the
strain recovery on the inner-edge node at (2, 0).
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Any

import numpy as np
import pyvista as pv

import femorph_solver
from femorph_solver import ELEMENTS
from femorph_solver.validation._benchmark import (
    BenchmarkProblem,
    PublishedValue,
)


def _ellipse_point(a: float, b: float, theta: float) -> np.ndarray:
    """Point on the ellipse ``x²/a² + y²/b² = 1`` at angle ``theta``."""
    return np.array([a * np.cos(theta), b * np.sin(theta), 0.0])


[docs] @dataclass class NafemsLE1(BenchmarkProblem): """NAFEMS LE1 — elliptic membrane, plane stress. Reports σ_yy at point D = (2, 0), the "stub" of the inner ellipse. NAFEMS converged reference: 92.7 MPa. """ name: str = "nafems_le1" description: str = ( "NAFEMS R0015 §2.1 LE1 — elliptic membrane under outward " "pressure, σ_yy at point D (stub of the inner ellipse)." ) analysis: str = "static" #: Inner ellipse semi-axes [m]. a_in: float = 2.0 b_in: float = 1.0 #: Outer ellipse semi-axes [m]. a_out: float = 3.25 b_out: float = 2.75 #: Out-of-plane thickness [m]. thickness: float = 0.1 #: Young's modulus [Pa]. E: float = 210.0e9 nu: float = 0.3 rho: float = 7800.0 #: Outward-normal pressure on the outer elliptic edge [Pa]. pressure: float = 1.0e7 @property def published_values(self) -> tuple[PublishedValue, ...]: return ( PublishedValue( name="sigma_yy_at_D", value=92.7e6, unit="Pa", source="NAFEMS R0015 §2.1 LE1", formula="Benchmark-defined converged reference", # NAFEMS specifies a 5 % engineering tolerance; # coarse plane-stress meshes typically land within # 2–8 % depending on the radial sampling at point D. tolerance=0.08, ), )
[docs] def build_model(self, **mesh_params: Any) -> femorph_solver.Model: n_theta = int(mesh_params.get("n_theta", 16)) n_rad = int(mesh_params.get("n_rad", 4)) # Parametric grid: theta in [0, pi/2], s in [0, 1]. thetas = np.linspace(0.0, np.pi / 2.0, n_theta + 1) ss = np.linspace(0.0, 1.0, n_rad + 1) n_nodes = (n_theta + 1) * (n_rad + 1) pts = np.zeros((n_nodes, 3), dtype=np.float64) for i, theta in enumerate(thetas): p_in = _ellipse_point(self.a_in, self.b_in, theta) p_out = _ellipse_point(self.a_out, self.b_out, theta) for j, s in enumerate(ss): idx = i * (n_rad + 1) + j pts[idx] = p_in + s * (p_out - p_in) # QUAD4 cells — counter-clockwise in (x, y). n_cells = n_theta * n_rad cell_connectivity = np.empty((n_cells, 5), dtype=np.int64) cell_connectivity[:, 0] = 4 c = 0 for i in range(n_theta): for j in range(n_rad): n00 = i * (n_rad + 1) + j n10 = (i + 1) * (n_rad + 1) + j n11 = (i + 1) * (n_rad + 1) + (j + 1) n01 = i * (n_rad + 1) + (j + 1) # Traverse in physical-plane CCW order: inner # theta_i → outer theta_i → outer theta_{i+1} # → inner theta_{i+1}. (The param-grid order # (n00, n10, n11, n01) sweeps the physical loop # clockwise because radial-s runs inner-to-outer # while theta-i runs 0 → π/2 in the first quadrant.) cell_connectivity[c, 1:] = (n00, n01, n11, n10) c += 1 grid = pv.UnstructuredGrid( cell_connectivity.ravel(), np.full(n_cells, 9, dtype=np.int64), # VTK_QUAD pts, ) m = femorph_solver.Model.from_grid(grid) m.assign( ELEMENTS.QUAD4_PLANE, material={"EX": self.E, "PRXY": self.nu, "DENS": self.rho}, real=(self.thickness,), ) # Symmetry BCs. for k, p in enumerate(pts): if p[1] < 1e-9: # on x-axis m.fix(nodes=int(k + 1), dof="UY", value=0.0) if p[0] < 1e-9: # on y-axis m.fix(nodes=int(k + 1), dof="UX", value=0.0) # Outer-edge pressure. The outer boundary nodes live at # (i_theta, n_rad) — spaced along the outer ellipse. For # each pair of adjacent outer nodes we compute the segment # arc length + outward normal, then distribute the # pressure × ds × t force equally onto the two endpoint # nodes. We accumulate per-node contributions locally # first and issue a single ``apply_force`` per node at # the end — otherwise each call overwrites the previous # value (``Model.apply_force`` assigns, not accumulates). outer_idx = [i * (n_rad + 1) + n_rad for i in range(n_theta + 1)] fx_by_node: dict[int, float] = {} fy_by_node: dict[int, float] = {} for segment in range(n_theta): a_idx = outer_idx[segment] b_idx = outer_idx[segment + 1] pa = pts[a_idx] pb = pts[b_idx] ds = float(np.linalg.norm(pb - pa)) # Outward normal to the ellipse at the segment midpoint, # derived from the implicit gradient ``∇(x²/a² + y²/b²)``. midpt = 0.5 * (pa + pb) outward = np.array([midpt[0] / self.a_out**2, midpt[1] / self.b_out**2]) nrm = np.linalg.norm(outward) if nrm < 1e-12: continue outward = outward / nrm # Total force on this segment: pressure × arc × thickness. F_seg = self.pressure * ds * self.thickness for n_idx in (a_idx, b_idx): fx_by_node[n_idx] = fx_by_node.get(n_idx, 0.0) + 0.5 * F_seg * outward[0] fy_by_node[n_idx] = fy_by_node.get(n_idx, 0.0) + 0.5 * F_seg * outward[1] for n_idx, fx in fx_by_node.items(): fy = fy_by_node[n_idx] m.apply_force(int(n_idx + 1), fx=fx, fy=fy) # Stash the point-D node id for extract(). Point D is at # (a_in, 0) — the first ``i_theta = 0`` row's inner node. d_idx = 0 * (n_rad + 1) + 0 assert np.isclose(pts[d_idx, 0], self.a_in) assert np.isclose(pts[d_idx, 1], 0.0) m._bench_point_d_idx = d_idx # type: ignore[attr-defined] m._bench_pts = pts # type: ignore[attr-defined] m._bench_n_theta = n_theta # type: ignore[attr-defined] m._bench_n_rad = n_rad # type: ignore[attr-defined] return m
[docs] def extract(self, model: femorph_solver.Model, result: Any, name: str) -> float: if name != "sigma_yy_at_D": raise KeyError(f"unknown quantity {name!r}") # Canonical path: ``compute_nodal_stress`` now supports plane # kernels (since the QUAD4_PLANE ``eel_batch`` landed as part # of issue #262). σ_yy at point D is the second Voigt # component at the inner-edge node on the x-axis. from femorph_solver.result._stress_recovery import compute_nodal_stress sig = compute_nodal_stress(model, np.asarray(result.displacement)) d_idx = model._bench_point_d_idx # type: ignore[attr-defined] return float(sig[d_idx, 1])